Implement Elf Ranged Combat and Pinned Mechanic
- Added 'Shoot Bow' action for Elf with Ballistic Skill mechanics (1995 rules). - Implemented strict Line of Sight (LOS) raycasting (Amanatides & Woo) with UI feedback for blockers. - Added 'Pinned' status: Heroes adjacent to monsters (without intervening walls) cannot move. - Enhanced UI with visual indicators for blocked shots (red circles) and temporary modals. - Polished 'End Phase' button layout and hidden it during Monster phase.
This commit is contained in:
@@ -100,6 +100,73 @@ export class CombatMechanics {
|
||||
return log;
|
||||
}
|
||||
|
||||
static resolveRangedAttack(attacker, defender, gameEngine = null) {
|
||||
const log = {
|
||||
attackerId: attacker.id,
|
||||
defenderId: defender.id,
|
||||
hitSuccess: false,
|
||||
damageTotal: 0,
|
||||
woundsCaused: 0,
|
||||
defenderDied: false,
|
||||
message: ''
|
||||
};
|
||||
|
||||
// 1. Roll To Hit (BS vs WS)
|
||||
// Use attacker BS or default to WS if missing (fallback).
|
||||
const attackerBS = attacker.stats.bs || attacker.stats.ws;
|
||||
const defenderWS = defender.stats.ws;
|
||||
|
||||
const toHitTarget = this.getToHitTarget(attackerBS, defenderWS);
|
||||
const hitRoll = this.rollD6();
|
||||
log.hitRoll = hitRoll;
|
||||
log.toHitTarget = toHitTarget;
|
||||
|
||||
if (hitRoll === 1) {
|
||||
log.hitSuccess = false;
|
||||
log.message = `${attacker.name} dispara y falla (1 es fallo automático)`;
|
||||
return log;
|
||||
}
|
||||
|
||||
if (hitRoll < toHitTarget) {
|
||||
log.hitSuccess = false;
|
||||
log.message = `${attacker.name} dispara y falla (${hitRoll} < ${toHitTarget})`;
|
||||
return log;
|
||||
}
|
||||
|
||||
log.hitSuccess = true;
|
||||
|
||||
// 2. Roll Damage
|
||||
// Elf Bow Strength = 3
|
||||
const weaponStrength = 3;
|
||||
const damageRoll = this.rollD6();
|
||||
const damageTotal = weaponStrength + damageRoll;
|
||||
log.damageRoll = damageRoll;
|
||||
log.damageTotal = damageTotal;
|
||||
|
||||
// 3. Compare vs Toughness
|
||||
const defTough = defender.stats.toughness || 1;
|
||||
const wounds = Math.max(0, damageTotal - defTough);
|
||||
|
||||
log.woundsCaused = wounds;
|
||||
|
||||
// 4. Build Message
|
||||
if (wounds > 0) {
|
||||
log.message = `${attacker.name} acierta con el arco y causa ${wounds} heridas! (Daño ${log.damageTotal} - Res ${defTough})`;
|
||||
} else {
|
||||
log.message = `${attacker.name} acierta pero la flecha rebota. (Daño ${log.damageTotal} vs Res ${defTough})`;
|
||||
}
|
||||
|
||||
// 5. Apply Damage
|
||||
this.applyDamage(defender, wounds, gameEngine);
|
||||
|
||||
if (defender.isDead) {
|
||||
log.defenderDied = true;
|
||||
log.message += ` ¡${defender.name} ha muerto!`;
|
||||
}
|
||||
|
||||
return log;
|
||||
}
|
||||
|
||||
static getToHitTarget(attackerWS, defenderWS) {
|
||||
// Adjust for 0-index array
|
||||
const row = attackerWS - 1;
|
||||
|
||||
@@ -25,7 +25,9 @@ export class GameEngine {
|
||||
this.onEntityUpdate = null;
|
||||
this.onEntityMove = null;
|
||||
this.onEntitySelect = null;
|
||||
this.onRangedTarget = null; // New: For ranged targeting visualization
|
||||
this.onEntityActive = null; // New: When entity starts/ends turn
|
||||
this.onShowMessage = null; // New: Generic temporary message UI callback
|
||||
this.onEntityHit = null; // New: When entity takes damage
|
||||
this.onEntityDeath = null; // New: When entity dies
|
||||
this.onPathChange = null;
|
||||
@@ -146,6 +148,27 @@ export class GameEngine {
|
||||
}
|
||||
|
||||
onCellClick(x, y) {
|
||||
// RANGED TARGETING LOGIC
|
||||
if (this.targetingMode === 'ranged') {
|
||||
const clickedMonster = this.monsters ? this.monsters.find(m => m.x === x && m.y === y && !m.isDead) : null;
|
||||
if (clickedMonster) {
|
||||
if (this.selectedEntity && this.selectedEntity.type === 'hero') {
|
||||
const los = this.checkLineOfSightStrict(this.selectedEntity, clickedMonster);
|
||||
this.selectedMonster = clickedMonster;
|
||||
if (this.onRangedTarget) {
|
||||
this.onRangedTarget(clickedMonster, los);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Determine if we clicked something else relevant or empty space
|
||||
// If clicked self (hero), maybe cancel?
|
||||
// For now, any non-monster click cancels targeting
|
||||
// Unless it's just a UI click (handled by DOM)
|
||||
this.cancelTargeting();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Identify clicked contents
|
||||
const clickedHero = this.heroes.find(h => h.x === x && h.y === y);
|
||||
const clickedMonster = this.monsters ? this.monsters.find(m => m.x === x && m.y === y && !m.isDead) : null;
|
||||
@@ -186,6 +209,15 @@ export class GameEngine {
|
||||
if (this.onEntitySelect) {
|
||||
this.onEntitySelect(clickedEntity.id, true);
|
||||
}
|
||||
|
||||
// Check Pinned Status
|
||||
if (clickedEntity.type === 'hero' && this.turnManager.currentPhase === 'hero') {
|
||||
if (this.isEntityPinned(clickedEntity)) {
|
||||
if (this.onShowMessage) {
|
||||
this.onShowMessage('Trabado', 'Enemigos adyacentes impiden el movimiento.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -193,6 +225,10 @@ export class GameEngine {
|
||||
|
||||
// 2. PLAN MOVEMENT (If entity selected and we clicked empty space)
|
||||
if (this.selectedEntity) {
|
||||
if (this.selectedEntity.type === 'hero' && this.turnManager.currentPhase === 'hero' && this.isEntityPinned(this.selectedEntity)) {
|
||||
if (this.onShowMessage) this.onShowMessage('Trabado', 'No puedes moverte.');
|
||||
return;
|
||||
}
|
||||
this.planStep(x, y);
|
||||
}
|
||||
}
|
||||
@@ -223,6 +259,61 @@ export class GameEngine {
|
||||
return { success: true, result };
|
||||
}
|
||||
|
||||
isEntityPinned(entity) {
|
||||
if (!this.monsters || this.monsters.length === 0) return false;
|
||||
|
||||
return this.monsters.some(m => {
|
||||
if (m.isDead) return false;
|
||||
const dx = Math.abs(entity.x - m.x);
|
||||
const dy = Math.abs(entity.y - m.y);
|
||||
|
||||
// 1. Must be Adjacent (Manhattan distance 1)
|
||||
if (dx + dy !== 1) return false;
|
||||
|
||||
// 2. Check Logical Connectivity (Wall check)
|
||||
const grid = this.dungeon.grid;
|
||||
const key1 = `${entity.x},${entity.y}`;
|
||||
const key2 = `${m.x},${m.y}`;
|
||||
|
||||
const data1 = grid.cellData.get(key1);
|
||||
const data2 = grid.cellData.get(key2);
|
||||
|
||||
if (!data1 || !data2) return false;
|
||||
|
||||
// Same Tile -> Connected
|
||||
if (data1.tileId === data2.tileId) return true;
|
||||
|
||||
// Different Tile -> Must be connected by a Door
|
||||
const isDoor1 = grid.doorCells.has(key1);
|
||||
const isDoor2 = grid.doorCells.has(key2);
|
||||
|
||||
if (!isDoor1 && !isDoor2) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
performRangedAttack(targetMonsterId) {
|
||||
const hero = this.selectedEntity;
|
||||
const monster = this.monsters.find(m => m.id === targetMonsterId);
|
||||
|
||||
if (!hero || !monster) return null;
|
||||
|
||||
if (this.turnManager.currentPhase !== 'hero') return { success: false, reason: 'phase' };
|
||||
if (hero.hasAttacked) return { success: false, reason: 'cooldown' };
|
||||
if (this.isEntityPinned(hero)) return { success: false, reason: 'pinned' };
|
||||
|
||||
// LOS Check should be done before calling this, but we can double check or assume UI did it.
|
||||
// For simplicity, we execute the attack here assuming validation passed.
|
||||
|
||||
const result = CombatMechanics.resolveRangedAttack(hero, monster, this);
|
||||
hero.hasAttacked = true;
|
||||
|
||||
if (this.onCombatResult) this.onCombatResult(result);
|
||||
|
||||
return { success: true, result };
|
||||
}
|
||||
|
||||
deselectEntity() {
|
||||
if (!this.selectedEntity) return;
|
||||
const id = this.selectedEntity.id;
|
||||
@@ -546,4 +637,220 @@ export class GameEngine {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
startRangedTargeting() {
|
||||
this.targetingMode = 'ranged';
|
||||
console.log("Ranged Targeting Mode ON");
|
||||
}
|
||||
|
||||
cancelTargeting() {
|
||||
this.targetingMode = null;
|
||||
if (this.onRangedTarget) {
|
||||
this.onRangedTarget(null, null);
|
||||
}
|
||||
}
|
||||
|
||||
checkLineOfSight(hero, target) {
|
||||
// Robust Grid Traversal (Amanatides & Woo)
|
||||
const x = hero.x + 0.5;
|
||||
const y = hero.y + 0.5;
|
||||
const endX = target.x + 0.5;
|
||||
const endY = target.y + 0.5;
|
||||
|
||||
const dx = endX - x;
|
||||
const dy = endY - y;
|
||||
|
||||
let currentX = Math.floor(x);
|
||||
let currentY = Math.floor(y);
|
||||
const targetX = Math.floor(endX);
|
||||
const targetY = Math.floor(endY);
|
||||
|
||||
const stepX = Math.sign(dx);
|
||||
const stepY = Math.sign(dy);
|
||||
|
||||
const tDeltaX = stepX !== 0 ? Math.abs(1 / dx) : Infinity;
|
||||
const tDeltaY = stepY !== 0 ? Math.abs(1 / dy) : Infinity;
|
||||
|
||||
let tMaxX = stepX > 0 ? (Math.floor(x) + 1 - x) * tDeltaX : (x - Math.floor(x)) * tDeltaX;
|
||||
let tMaxY = stepY > 0 ? (Math.floor(y) + 1 - y) * tDeltaY : (y - Math.floor(y)) * tDeltaY;
|
||||
|
||||
if (isNaN(tMaxX)) tMaxX = Infinity;
|
||||
if (isNaN(tMaxY)) tMaxY = Infinity;
|
||||
|
||||
const path = [];
|
||||
let blocked = false;
|
||||
|
||||
// Safety limit
|
||||
const maxSteps = Math.abs(targetX - currentX) + Math.abs(targetY - currentY) + 20;
|
||||
|
||||
for (let i = 0; i < maxSteps; i++) {
|
||||
path.push({ x: currentX, y: currentY });
|
||||
|
||||
if (!(currentX === hero.x && currentY === hero.y) && !(currentX === target.x && currentY === target.y)) {
|
||||
if (this.dungeon.grid.isWall(currentX, currentY)) {
|
||||
blocked = true;
|
||||
break;
|
||||
}
|
||||
|
||||
const blockerMonster = this.monsters.find(m => m.x === currentX && m.y === currentY && !m.isDead && m.id !== target.id);
|
||||
if (blockerMonster) { blocked = true; break; }
|
||||
|
||||
const blockerHero = this.heroes.find(h => h.x === currentX && h.y === currentY && h.id !== hero.id);
|
||||
if (blockerHero) { blocked = true; break; }
|
||||
}
|
||||
|
||||
if (currentX === targetX && currentY === targetY) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (tMaxX < tMaxY) {
|
||||
tMaxX += tDeltaX;
|
||||
currentX += stepX;
|
||||
} else {
|
||||
tMaxY += tDeltaY;
|
||||
currentY += stepY;
|
||||
}
|
||||
}
|
||||
|
||||
return { clear: !blocked, path: path };
|
||||
}
|
||||
checkLineOfSightPermissive(hero, target) {
|
||||
const startX = hero.x + 0.5;
|
||||
const startY = hero.y + 0.5;
|
||||
const endX = target.x + 0.5;
|
||||
const endY = target.y + 0.5;
|
||||
|
||||
const dist = Math.sqrt((endX - startX) ** 2 + (endY - startY) ** 2);
|
||||
const steps = Math.ceil(dist * 5);
|
||||
|
||||
const path = [];
|
||||
const visited = new Set();
|
||||
let blocked = false;
|
||||
let blocker = null;
|
||||
|
||||
const MARGIN = 0.2;
|
||||
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const t = i / steps;
|
||||
const x = startX + (endX - startX) * t;
|
||||
const y = startY + (endY - startY) * t;
|
||||
|
||||
const gx = Math.floor(x);
|
||||
const gy = Math.floor(y);
|
||||
const key = `${gx},${gy}`;
|
||||
|
||||
if (!visited.has(key)) {
|
||||
path.push({ x: gx, y: gy, xWorld: x, yWorld: y });
|
||||
visited.add(key);
|
||||
}
|
||||
|
||||
if ((gx === hero.x && gy === hero.y) || (gx === target.x && gy === target.y)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.dungeon.grid.isWall(gx, gy)) {
|
||||
const lx = x - gx;
|
||||
const ly = y - gy;
|
||||
if (lx > MARGIN && lx < (1 - MARGIN) && ly > MARGIN && ly < (1 - MARGIN)) {
|
||||
blocked = true;
|
||||
blocker = { type: 'wall', x: gx, y: gy };
|
||||
console.log(`[LOS] Blocked by WALL at ${gx},${gy}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const blockerMonster = this.monsters.find(m => m.x === gx && m.y === gy && !m.isDead && m.id !== target.id);
|
||||
if (blockerMonster) {
|
||||
blocked = true;
|
||||
blocker = { type: 'monster', entity: blockerMonster };
|
||||
console.log(`[LOS] Blocked by MONSTER: ${blockerMonster.name}`);
|
||||
break;
|
||||
}
|
||||
|
||||
const blockerHero = this.heroes.find(h => h.x === gx && h.y === gy && h.id !== hero.id);
|
||||
if (blockerHero) {
|
||||
blocked = true;
|
||||
blocker = { type: 'hero', entity: blockerHero };
|
||||
console.log(`[LOS] Blocked by HERO: ${blockerHero.name}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { clear: !blocked, path: path, blocker: blocker };
|
||||
}
|
||||
checkLineOfSightStrict(hero, target) {
|
||||
// STRICT Grid Traversal (Amanatides & Woo)
|
||||
const x1 = hero.x + 0.5;
|
||||
const y1 = hero.y + 0.5;
|
||||
const x2 = target.x + 0.5;
|
||||
const y2 = target.y + 0.5;
|
||||
|
||||
let currentX = Math.floor(x1);
|
||||
let currentY = Math.floor(y1);
|
||||
const endX = Math.floor(x2);
|
||||
const endY = Math.floor(y2);
|
||||
|
||||
const dx = x2 - x1;
|
||||
const dy = y2 - y1;
|
||||
const stepX = Math.sign(dx);
|
||||
const stepY = Math.sign(dy);
|
||||
|
||||
const tDeltaX = stepX !== 0 ? Math.abs(1 / dx) : Infinity;
|
||||
const tDeltaY = stepY !== 0 ? Math.abs(1 / dy) : Infinity;
|
||||
|
||||
let tMaxX = stepX > 0 ? (Math.floor(x1) + 1 - x1) * tDeltaX : (x1 - Math.floor(x1)) * tDeltaX;
|
||||
let tMaxY = stepY > 0 ? (Math.floor(y1) + 1 - y1) * tDeltaY : (y1 - Math.floor(y1)) * tDeltaY;
|
||||
|
||||
if (isNaN(tMaxX)) tMaxX = Infinity;
|
||||
if (isNaN(tMaxY)) tMaxY = Infinity;
|
||||
|
||||
const path = [];
|
||||
let blocked = false;
|
||||
let blocker = null;
|
||||
|
||||
const maxSteps = Math.abs(endX - currentX) + Math.abs(endY - currentY) + 20;
|
||||
|
||||
for (let i = 0; i < maxSteps; i++) {
|
||||
path.push({ x: currentX, y: currentY });
|
||||
|
||||
const isStart = (currentX === hero.x && currentY === hero.y);
|
||||
const isEnd = (currentX === target.x && currentY === target.y);
|
||||
|
||||
if (!isStart && !isEnd) {
|
||||
if (this.dungeon.grid.isWall(currentX, currentY)) {
|
||||
blocked = true;
|
||||
blocker = { type: 'wall', x: currentX, y: currentY };
|
||||
console.log(`[LOS] Blocked by WALL at ${currentX},${currentY}`);
|
||||
break;
|
||||
}
|
||||
|
||||
const m = this.monsters.find(m => m.x === currentX && m.y === currentY && !m.isDead && m.id !== target.id);
|
||||
if (m) {
|
||||
blocked = true;
|
||||
blocker = { type: 'monster', entity: m };
|
||||
console.log(`[LOS] Blocked by MONSTER: ${m.name}`);
|
||||
break;
|
||||
}
|
||||
|
||||
const h = this.heroes.find(h => h.x === currentX && h.y === currentY && h.id !== hero.id);
|
||||
if (h) {
|
||||
blocked = true;
|
||||
blocker = { type: 'hero', entity: h };
|
||||
console.log(`[LOS] Blocked by HERO: ${h.name}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentX === endX && currentY === endY) break;
|
||||
|
||||
if (tMaxX < tMaxY) {
|
||||
tMaxX += tDeltaX;
|
||||
currentX += stepX;
|
||||
} else {
|
||||
tMaxY += tDeltaY;
|
||||
currentY += stepY;
|
||||
}
|
||||
}
|
||||
|
||||
return { clear: !blocked, path, blocker };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user