feat(game-loop): implement strict phase rules, exploration stops, and hero attacks

This commit is contained in:
2026-01-05 23:11:31 +01:00
parent b619e4cee4
commit 77c0c07a44
11 changed files with 591 additions and 142 deletions

View File

@@ -0,0 +1,145 @@
export const TO_HIT_CHART = [
// Defender WS 1 2 3 4 5 6 7 8 9 10
/* Attacker 1 */[4, 4, 5, 6, 6, 6, 6, 6, 6, 6],
/* Attacker 2 */[3, 4, 4, 4, 5, 5, 6, 6, 6, 6],
/* Attacker 3 */[2, 3, 4, 4, 4, 4, 5, 5, 5, 6],
/* Attacker 4 */[2, 3, 3, 4, 4, 4, 4, 4, 5, 5],
/* Attacker 5 */[2, 2, 3, 3, 4, 4, 4, 4, 4, 4],
/* Attacker 6 */[2, 2, 3, 3, 3, 4, 4, 4, 4, 4],
/* Attacker 7 */[2, 2, 2, 3, 3, 3, 4, 4, 4, 4],
/* Attacker 8 */[2, 2, 2, 3, 3, 3, 3, 4, 4, 4],
/* Attacker 9 */[2, 2, 2, 2, 3, 3, 3, 3, 4, 4],
/* Attacker 10*/[2, 2, 2, 2, 3, 3, 3, 3, 3, 4]
];
export class CombatMechanics {
/**
* Resolves a melee attack sequence between two entities.
* @param {Object} attacker
* @param {Object} defender
* @returns {Object} Result log
*/
static resolveMeleeAttack(attacker, defender) {
const log = {
attackerId: attacker.id,
defenderId: defender.id,
hitRoll: 0,
targetToHit: 0,
hitSuccess: false,
damageRoll: 0,
damageTotal: 0,
woundsCaused: 0,
defenderDied: false,
message: ''
};
// 1. Determine Stats
// Use stats object if available, otherwise direct property (fallback)
const attStats = attacker.stats || attacker;
const defStats = defender.stats || defender;
const attWS = Math.min(Math.max(attStats.ws || 1, 1), 10);
const defWS = Math.min(Math.max(defStats.ws || 1, 1), 10);
// 2. Roll To Hit
log.targetToHit = this.getToHitTarget(attWS, defWS);
log.hitRoll = this.rollD6();
// Debug
// console.log(`Combat: ${attacker.name} (WS${attWS}) vs ${defender.name} (WS${defWS}) -> Need ${log.targetToHit}+. Rolled ${log.hitRoll}`);
if (log.hitRoll < log.targetToHit) {
log.hitSuccess = false;
log.message = `${attacker.name} falla el ataque (Sacó ${log.hitRoll}, necesita ${log.targetToHit}+).`;
return log;
}
log.hitSuccess = true;
// 3. Roll To Damage
const attStr = attStats.str || 3;
const defTough = defStats.toughness || 3;
const damageDice = attStats.damageDice || 1; // Default 1D6
let damageSum = 0;
let rolls = [];
for (let i = 0; i < damageDice; i++) {
const r = this.rollD6();
rolls.push(r);
damageSum += r;
}
log.damageRoll = damageSum; // Just sum for simple log, or we could array it
log.damageTotal = damageSum + attStr;
// 4. Calculate Wounds
// Wounds = (Dice + Str) - Toughness
let wounds = log.damageTotal - defTough;
if (wounds < 0) wounds = 0;
log.woundsCaused = wounds;
// 5. Build Message
if (wounds > 0) {
log.message = `${attacker.name} impacta y causa ${wounds} heridas! (Daño ${log.damageTotal} - Res ${defTough})`;
} else {
log.message = `${attacker.name} impacta pero no logra herir. (Daño ${log.damageTotal} vs Res ${defTough})`;
}
// 6. Apply Damage to Defender State
this.applyDamage(defender, wounds);
if (defender.isDead) {
log.defenderDied = true;
log.message += ` ¡${defender.name} ha muerto!`;
} else if (defender.isUnconscious) {
log.message += ` ¡${defender.name} cae inconsciente!`;
}
return log;
}
static getToHitTarget(attackerWS, defenderWS) {
// Adjust for 0-index array
const row = attackerWS - 1;
const col = defenderWS - 1;
if (TO_HIT_CHART[row] && TO_HIT_CHART[row][col]) {
return TO_HIT_CHART[row][col];
}
return 6; // Fallback
}
static applyDamage(entity, amount) {
if (!entity.stats) entity.stats = {};
// If entity doesn't have current wounds tracked, init it from max
if (entity.currentWounds === undefined) {
// For Heros it is 'wounds', for Monsters typical just 'wounds' in def
// We assume entity has been initialized properly before,
// but if not, we grab max from definition
entity.currentWounds = entity.stats.wounds || 1;
}
entity.currentWounds -= amount;
// Check Status
if (entity.type === 'hero') {
if (entity.currentWounds <= 0) {
entity.currentWounds = 0;
entity.isConscious = false;
// entity.isDead is not immediate for heroes usually, but let's handle via isConscious
}
} else {
// Monsters die at 0
if (entity.currentWounds <= 0) {
entity.currentWounds = 0;
entity.isDead = true;
}
}
}
static rollD6() {
return Math.floor(Math.random() * 6) + 1;
}
}

View File

@@ -1,6 +1,7 @@
import { DungeonGenerator } from '../dungeon/DungeonGenerator.js';
import { TurnManager } from './TurnManager.js';
import { MonsterAI } from './MonsterAI.js';
import { CombatMechanics } from './CombatMechanics.js';
import { HERO_DEFINITIONS } from '../data/Heroes.js';
import { MONSTER_DEFINITIONS } from '../data/Monsters.js';
import { createEventDeck, EVENT_TYPES } from '../data/Events.js';
@@ -17,6 +18,7 @@ export class GameEngine {
this.selectedEntity = null;
this.isRunning = false;
this.plannedPath = []; // Array of {x,y}
this.visitedRoomIds = new Set(); // Track tiles triggered
this.eventDeck = createEventDeck();
// Callbacks
@@ -102,7 +104,7 @@ export class GameEngine {
this.player = this.heroes[0];
}
spawnMonster(monsterKey, x, y) {
spawnMonster(monsterKey, x, y, options = {}) {
const definition = MONSTER_DEFINITIONS[monsterKey];
if (!definition) {
console.error(`Monster definition not found: ${monsterKey}`);
@@ -126,8 +128,9 @@ export class GameEngine {
texturePath: definition.portrait,
stats: { ...definition.stats },
// Game State
currentWounds: definition.stats.wounds,
isDead: false
currentWounds: definition.stats.wounds || 1,
isDead: false,
skipTurn: !!options.skipTurn // Summoning sickness flag
};
this.monsters.push(monster);
@@ -140,9 +143,19 @@ export class GameEngine {
}
onCellClick(x, y) {
// 1. Check for Hero/Monster Selection
// 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) : null;
const clickedMonster = this.monsters ? this.monsters.find(m => m.x === x && m.y === y && !m.isDead) : null;
// COMBAT: Hero Attack Check
if (clickedMonster && this.selectedEntity && this.selectedEntity.type === 'hero') {
const attackResult = this.performHeroAttack(clickedMonster.id);
if (attackResult && attackResult.success) {
// Attack performed, do not deselect hero
return;
}
// If attack failed (e.g. not adjacent), proceeds to select the monster
}
const clickedEntity = clickedHero || clickedMonster;
@@ -168,6 +181,32 @@ export class GameEngine {
}
}
performHeroAttack(targetMonsterId) {
const hero = this.selectedEntity;
const monster = this.monsters.find(m => m.id === targetMonsterId);
if (!hero || !monster) return null;
// Check Phase
if (this.turnManager.currentPhase !== 'hero') return { success: false, reason: 'phase' };
// Check Adjacency
const dx = Math.abs(hero.x - monster.x);
const dy = Math.abs(hero.y - monster.y);
if (dx + dy !== 1) return { success: false, reason: 'range' };
// Check Action Economy
if (hero.hasAttacked) return { success: false, reason: 'cooldown' };
// Execute Attack
const result = CombatMechanics.resolveMeleeAttack(hero, monster);
hero.hasAttacked = true;
if (this.onCombatResult) this.onCombatResult(result);
return { success: true, result };
}
deselectEntity() {
if (!this.selectedEntity) return;
const id = this.selectedEntity.id;
@@ -185,6 +224,13 @@ export class GameEngine {
planStep(x, y) {
if (!this.selectedEntity) return;
// Valid Phase Check
// Allow movement in Hero Phase AND Exploration Phase (for positioning ease)
const phase = this.turnManager.currentPhase;
if (phase !== 'hero' && phase !== 'exploration' && this.selectedEntity.type === 'hero') {
return;
}
// Determine start point
const lastStep = this.plannedPath.length > 0
? this.plannedPath[this.plannedPath.length - 1]
@@ -234,28 +280,90 @@ export class GameEngine {
executeMovePath() {
if (!this.selectedEntity || !this.plannedPath.length) return;
const path = [...this.plannedPath];
const fullPath = [...this.plannedPath];
const entity = this.selectedEntity;
// Update verify immediately
const finalDest = path[path.length - 1];
entity.x = finalDest.x;
entity.y = finalDest.y;
let stepsTaken = 0;
let triggeredEvents = false;
// Visual animation
if (this.onEntityMove) {
this.onEntityMove(entity, path);
// Step-by-step execution to check for triggers
for (let i = 0; i < fullPath.length; i++) {
const step = fullPath[i];
// 1. Move Entity State
entity.x = step.x;
entity.y = step.y;
stepsTaken++;
// 2. Check for New Tile Entry
const tileId = this.dungeon.grid.occupiedCells.get(`${step.x},${step.y}`);
if (tileId && !this.visitedRoomIds.has(tileId)) {
// Mark as visited immediatley
this.visitedRoomIds.add(tileId);
// Check Tile Type (Room vs Corridor)
const tileInfo = this.dungeon.placedTiles.find(t => t.id === tileId);
const isRoom = tileInfo && (tileInfo.defId.startsWith('room') || tileInfo.defId.includes('objective'));
if (isRoom) {
console.log(`[GameEngine] Hero entered NEW ROOM: ${tileId}`);
// Disparar Evento (need cells)
const newCells = [];
for (const [key, tid] of this.dungeon.grid.occupiedCells.entries()) {
if (tid === tileId) {
const [cx, cy] = key.split(',').map(Number);
newCells.push({ x: cx, y: cy });
}
}
// Call Event Logic
const eventResult = this.onRoomRevealed(newCells);
// Always stop for Rooms
if (eventResult) {
console.log("Movement stopped by Room Entry!");
triggeredEvents = true;
// Notify UI via callback
if (this.onEventTriggered) {
this.onEventTriggered(eventResult);
}
// Send PARTIAL path to renderer (from 0 to current step i+1)
if (this.onEntityMove) {
this.onEntityMove(entity, fullPath.slice(0, i + 1));
}
break; // Stop loop
}
} else {
console.log(`[GameEngine] Hero entered Corridor: ${tileId} (No Stop)`);
}
}
}
// If NO interruption, send full path
if (!triggeredEvents) {
if (this.onEntityMove) {
this.onEntityMove(entity, fullPath);
}
}
// Deduct Moves
if (entity.currentMoves !== undefined) {
entity.currentMoves -= path.length;
// Only deduct steps actually taken. No penalty.
entity.currentMoves -= stepsTaken;
if (entity.currentMoves < 0) entity.currentMoves = 0;
}
this.deselectEntity();
}
canMoveTo(x, y) {
// Check if cell is walkable (occupied by a tile)
return this.dungeon.grid.isOccupied(x, y);
@@ -268,45 +376,17 @@ export class GameEngine {
if (this.onEntityMove) this.onEntityMove(this.player, [{ x, y }]);
}
// Check if the Leader (Lamp Bearer) is adjacent to the door
isLeaderAdjacentToDoor(doorCells) {
if (!this.heroes || this.heroes.length === 0) return false;
const leader = this.getLeader();
if (!leader) return false;
const cells = Array.isArray(doorCells) ? doorCells : [doorCells];
for (const cell of cells) {
const dx = Math.abs(leader.x - cell.x);
const dy = Math.abs(leader.y - cell.y);
// Orthogonal adjacency check (Manhattan distance === 1)
if ((dx === 1 && dy === 0) || (dx === 0 && dy === 1)) {
return true;
}
}
return false;
}
getLeader() {
// Find hero with lantern, default to barbarian if something breaks, or first hero
return this.heroes.find(h => h.hasLantern) || this.heroes.find(h => h.key === 'barbarian') || this.heroes[0];
}
// Deprecated generic adjacency (kept for safety or other interactions)
isPlayerAdjacentToDoor(doorCells) {
return this.isLeaderAdjacentToDoor(doorCells);
}
update(time) {
// Minimal update loop
}
findSpawnPoints(count) {
const points = [];
const queue = [{ x: 1, y: 1 }]; // Start search near origin but ensure not 0,0 which might be tricky if it's door
// Actually, just scan the grid or BFS from center of first tile?
// First tile is placed at 0,0. Let's scan from 0,0.
const startNode = { x: 0, y: 0 };
const searchQueue = [startNode];
const visited = new Set(['0,0']);
@@ -384,7 +464,11 @@ export class GameEngine {
if (i < availableCells.length) {
const pos = availableCells[i];
console.log(`[GameEngine] Spawning at ${pos.x},${pos.y}`);
// Monster Spawn (Step-and-Stop rule)
// Monsters act in the upcoming Monster Phase.
this.spawnMonster(card.monsterKey, pos.x, pos.y);
spawnedCount++;
} else {
console.warn("[GameEngine] Not enough space!");
@@ -400,16 +484,21 @@ export class GameEngine {
};
}
return null;
// Return event info even if empty, so movement stops
return {
type: 'EVENT',
cardName: card.name,
message: 'La sala parece despejada.'
};
}
// =========================================
// MONSTER AI & TURN LOGIC
// =========================================
playMonsterTurn() {
async playMonsterTurn() {
if (this.ai) {
this.ai.executeTurn();
await this.ai.executeTurn();
}
}

View File

@@ -1,3 +1,5 @@
import { CombatMechanics } from './CombatMechanics.js';
export class MonsterAI {
constructor(gameEngine) {
this.game = gameEngine;
@@ -13,9 +15,19 @@ export class MonsterAI {
// Sequential execution with delay
for (const monster of this.game.monsters) {
// Check if monster still exists (e.g. didn't die from a trap in previous move - unlikely but good practice)
// Check if monster still exists
if (monster.isDead) continue;
// Check for Summoning Sickness / Ambush delay
if (monster.skipTurn) {
console.log(`[MonsterAI] ${monster.name} (${monster.id}) is gathering its senses (Skipping Turn).`);
monster.skipTurn = false; // Ready for next turn
// Add a small visual delay even if skipping, to show focus?
// No, better to just skip significantly to keep flow fast.
continue;
}
await this.processMonster(monster);
}
}
@@ -23,33 +35,26 @@ export class MonsterAI {
processMonster(monster) {
return new Promise(resolve => {
// Calculate delay based on potential move distance to ensure animation finishes
// Renderer takes ~300ms per step.
// Move is max 4 usually -> 1200ms.
// We use simple heuristic: wait for max possible animation time
const moveTime = (monster.stats.move * 300) + 200; // +buffer
const moveTime = (monster.stats.move * 300) + 500; // +buffer for attack logic
setTimeout(() => {
this.moveMonster(monster);
// IMPORTANT: The moveMonster function initiates the animation.
// We should technically resolve AFTER the animation time.
// moveMonster returns instantly.
// So we wait here.
this.actMonster(monster);
setTimeout(resolve, moveTime);
}, 100);
});
}
moveMonster(monster) {
// 1. Check if already adjacent (Engaged)
if (this.isEntityAdjacentToHero(monster)) {
console.log(`[MonsterAI] ${monster.id} is already engaged.`);
actMonster(monster) {
// 1. Check if already adjacent (Engaged) -> ATTACK
const adjacentHero = this.getAdjacentHero(monster);
if (adjacentHero) {
console.log(`[MonsterAI] ${monster.id} is engaged with ${adjacentHero.name}. Attacking!`);
this.performAttack(monster, adjacentHero);
return;
}
// 2. Find Closest Hero
// 2. Find Closest Hero to Move Towards
const targetHero = this.getClosestHero(monster);
if (!targetHero) {
console.log(`[MonsterAI] ${monster.id} has no targets.`);
@@ -57,7 +62,6 @@ export class MonsterAI {
}
// 3. Calculate Path (BFS with fallback)
// We use a flexible limit.
const path = this.findPath(monster, targetHero, 30);
if (!path || path.length === 0) {
@@ -73,33 +77,22 @@ export class MonsterAI {
// 5. Update Renderer ONCE with full path
if (this.game.onEntityMove) {
// We need the full path for the renderer to animate smoothly step by step
// The renderer logic expects a queue of steps. we should pass `actualPath`
this.game.onEntityMove(monster, actualPath);
}
// 6. Final Update Logic (Instant state update for AI calculation)
// But wait! If we update state instantly, the next monster will see this monster at end position.
// This is correct behavior for sequential movement logic.
// The VISUALS might be lagging, but the LOGIC is solid.
// However, we want the "wait" in processMonster to actually wait for the visual animation.
// Let's verify Renderer duration: 300ms per step.
// If path is 4 steps -> 1200ms.
// Our wait is 600ms constant. This is why it looks jumpy or sync issues?
// Actually, let's update the coordinates sequentially too if we want AI to respect intermediate blocking?
// No, standard turn-based usually calculates full path then executes.
// 6. Final Logic Update (Instant coordinates)
const finalDest = actualPath[actualPath.length - 1];
monster.x = finalDest.x;
monster.y = finalDest.y;
// We do NOT loop with breaks here anymore for visual steps, because we pass the full path to renderer.
// We only check for end condition (adjacency) to potentially truncate the path if we want to stop early?
// But we already calculated the path to stop at adjacency.
console.log(`[MonsterAI] ${monster.id} moved to ${monster.x},${monster.y}`);
// 7. Check if NOW adjacent after move -> ATTACK
const postMoveHero = this.getAdjacentHero(monster);
if (postMoveHero) {
console.log(`[MonsterAI] ${monster.id} engaged ${postMoveHero.name} after move. Attacking!`);
this.performAttack(monster, postMoveHero);
}
}
getClosestHero(monster) {
@@ -199,4 +192,31 @@ export class MonsterAI {
// Only return if we actually have a path to move (length > 0)
return bestPath;
}
performAttack(monster, hero) {
const result = CombatMechanics.resolveMeleeAttack(monster, hero);
console.log(`[COMBAT] ${result.message}`);
// Notify UI/GameEngine about damage (if we had a hook)
if (this.game.onCombatResult) {
this.game.onCombatResult(result);
}
}
getAdjacentHero(entity) {
return this.game.heroes.find(hero => {
// Check conscious or allow beating unconscious? standard rules say monsters attack unconscious heroes until death.
// But let's check basic mechanics first.
// "Cuando al Aventurero no le quedan más Heridas cae al suelo inconsciente... El Aventurero no está necesariamente muerto"
// "Continúa anotando el número de Heridas hasta que no le quedan más... nunca puede bajar de 0."
// Implicitly, they can still be attacked.
if (hero.isDead) return false;
const dx = Math.abs(entity.x - hero.x);
const dy = Math.abs(entity.y - hero.y);
// Orthogonal adjacency only (Manhattan dist 1)
return (dx + dy) === 1;
});
}
}