From 3efbf8d5fb9a81ac85249935f5cdf58cdd5ecb69 Mon Sep 17 00:00:00 2001 From: Marti Vich Date: Tue, 6 Jan 2026 16:18:46 +0100 Subject: [PATCH] Implement advanced pathfinding and combat visual effects - Add monster turn visual feedback (green ring on attacker, red ring on victim) - Implement proper attack sequence with timing and animations - Add room boundary and height level pathfinding system - Monsters now respect room walls and can only pass through doors - Add height level support (1-8) with stairs (9) for level transitions - Fix attack validation to prevent attacks through walls - Speed up hero movement animation (300ms per tile) - Fix exploration phase message to not show on initial tile placement - Disable hero movement during exploration phase (doors only) --- src/engine/dungeon/GridSystem.js | 88 ++++++++++++++++++++++++- src/engine/game/GameEngine.js | 7 +- src/engine/game/MonsterAI.js | 108 +++++++++++++++++++++++-------- src/main.js | 13 ++++ src/view/GameRenderer.js | 94 ++++++++++++++++++++++++++- 5 files changed, 279 insertions(+), 31 deletions(-) diff --git a/src/engine/dungeon/GridSystem.js b/src/engine/dungeon/GridSystem.js index bc159c1..bcb3524 100644 --- a/src/engine/dungeon/GridSystem.js +++ b/src/engine/dungeon/GridSystem.js @@ -3,6 +3,16 @@ export class GridSystem { constructor() { // Map "x,y" -> "tileId" this.occupiedCells = new Map(); + + // Map "x,y" -> { tileId: string, height: number (1-9) } + this.cellData = new Map(); + + // Map "tileId" -> Set of "x,y" strings (all cells belonging to this tile) + this.tileCells = new Map(); + + // Set of "x,y" strings that are door/exit cells (can cross room boundaries) + this.doorCells = new Set(); + this.tiles = []; } @@ -48,13 +58,20 @@ export class GridSystem { const rows = layout.length; const anchorX = tileInstance.x; const anchorY = tileInstance.y; + const tileId = tileInstance.id; + + // Initialize tile cell set + if (!this.tileCells.has(tileId)) { + this.tileCells.set(tileId, new Set()); + } for (let row = 0; row < rows; row++) { const rowData = layout[row]; const cols = rowData.length; for (let col = 0; col < cols; col++) { - if (rowData[col] === 0) continue; + const heightValue = rowData[col]; + if (heightValue === 0) continue; const lx = col; const ly = (rows - 1) - row; @@ -63,9 +80,28 @@ export class GridSystem { const gy = anchorY + ly; const key = `${gx},${gy}`; - this.occupiedCells.set(key, tileInstance.id); + // Store basic occupation + this.occupiedCells.set(key, tileId); + + // Store detailed cell data (height level) + this.cellData.set(key, { + tileId: tileId, + height: heightValue // 1-8 = levels, 9 = stairs + }); + + // Add to tile's cell set + this.tileCells.get(tileId).add(key); } } + + // Mark exit/door cells + if (variant.exits) { + variant.exits.forEach(exit => { + const exitKey = `${anchorX + exit.x},${anchorY + exit.y}`; + this.doorCells.add(exitKey); + }); + } + this.tiles.push(tileInstance); } @@ -102,4 +138,52 @@ export class GridSystem { isOccupied(x, y) { return this.occupiedCells.has(`${x},${y}`); } + + /** + * Get cell data (tileId, height) for a coordinate + */ + getCellData(x, y) { + return this.cellData.get(`${x},${y}`) || null; + } + + /** + * Check if movement from (x1,y1) to (x2,y2) is valid + * considering room boundaries, height levels, and stairs + */ + canMoveBetween(x1, y1, x2, y2) { + const key1 = `${x1},${y1}`; + const key2 = `${x2},${y2}`; + + const data1 = this.cellData.get(key1); + const data2 = this.cellData.get(key2); + + // Both cells must exist + if (!data1 || !data2) return false; + + const sameTile = data1.tileId === data2.tileId; + const isDoor1 = this.doorCells.has(key1); + const isDoor2 = this.doorCells.has(key2); + + // If different tiles, at least one must be a door + if (!sameTile && !isDoor1 && !isDoor2) { + return false; + } + + // Height validation + const h1 = data1.height; + const h2 = data2.height; + + // Stairs (9) can connect to any level + if (h1 === 9 || h2 === 9) { + return true; + } + + // Same height level is always OK + if (h1 === h2) { + return true; + } + + // Different heights require stairs - not allowed directly + return false; + } } diff --git a/src/engine/game/GameEngine.js b/src/engine/game/GameEngine.js index df8db8d..7b10916 100644 --- a/src/engine/game/GameEngine.js +++ b/src/engine/game/GameEngine.js @@ -25,6 +25,8 @@ export class GameEngine { this.onEntityUpdate = null; this.onEntityMove = null; this.onEntitySelect = null; + this.onEntityActive = null; // New: When entity starts/ends turn + this.onEntityHit = null; // New: When entity takes damage this.onPathChange = null; } @@ -225,9 +227,10 @@ export class GameEngine { if (!this.selectedEntity) return; // Valid Phase Check - // Allow movement in Hero Phase AND Exploration Phase (for positioning ease) + // Allow movement ONLY in Hero Phase. + // Exploration Phase is for opening doors only (no movement). const phase = this.turnManager.currentPhase; - if (phase !== 'hero' && phase !== 'exploration' && this.selectedEntity.type === 'hero') { + if (phase !== 'hero' && this.selectedEntity.type === 'hero') { return; } diff --git a/src/engine/game/MonsterAI.js b/src/engine/game/MonsterAI.js index 980e92a..41a7a18 100644 --- a/src/engine/game/MonsterAI.js +++ b/src/engine/game/MonsterAI.js @@ -34,12 +34,18 @@ export class MonsterAI { processMonster(monster) { return new Promise(resolve => { + // NO green ring here - only during attack + // Calculate delay based on potential move distance to ensure animation finishes - const moveTime = (monster.stats.move * 300) + 500; // +buffer for attack logic + // SLOWER: 600ms per tile + Extra buffer for potential attack sequence + const moveTime = (monster.stats.move * 600) + 3000; // 3s buffer for attack sequence setTimeout(() => { this.actMonster(monster); - setTimeout(resolve, moveTime); + + setTimeout(() => { + resolve(); + }, moveTime); }, 100); }); } @@ -88,11 +94,15 @@ export class MonsterAI { 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); - } + // Wait for movement animation to complete before checking + const movementDuration = actualPath.length * 600; + setTimeout(() => { + const postMoveHero = this.getAdjacentHero(monster); + if (postMoveHero) { + console.log(`[MonsterAI] ${monster.id} engaged ${postMoveHero.name} after move. Attacking!`); + this.performAttack(monster, postMoveHero); + } + }, movementDuration); } getClosestHero(monster) { @@ -119,15 +129,28 @@ export class MonsterAI { }); } - isOccupied(x, y) { - // Check Grid - if (!this.game.dungeon.grid.isOccupied(x, y)) return true; // Wall/Void + isOccupied(x, y, fromX, fromY) { + // Check if target cell exists in grid + if (!this.game.dungeon.grid.isOccupied(x, y)) { + return true; // Wall/Void + } // Check Heroes - if (this.game.heroes.some(h => h.x === x && h.y === y)) return true; + if (this.game.heroes.some(h => h.x === x && h.y === y)) { + return true; + } // Check Monsters - if (this.game.monsters.some(m => m.x === x && m.y === y)) return true; + if (this.game.monsters.some(m => m.x === x && m.y === y)) { + return true; + } + + // NEW: Check if movement is valid (room boundaries, height levels, stairs) + if (fromX !== undefined && fromY !== undefined) { + if (!this.game.dungeon.grid.canMoveBetween(fromX, fromY, x, y)) { + return true; // Movement blocked by room boundary or height restriction + } + } return false; } @@ -168,13 +191,9 @@ export class MonsterAI { ]; for (const n of neighbors) { - // Determine blocking - // Note: We normally block if occupied. - // But for Fallback to work in a crowd, we might want to know if we can at least get closer. - // However, we literally cannot walk on occupied tiles. - // So the fallback is simply: "To the tile closest to the hero that ISN'T occupied." - - if (this.isOccupied(n.x, n.y)) continue; + // Check if movement from current to neighbor is valid + // This now includes room boundary, height, and stair checks + if (this.isOccupied(n.x, n.y, current.x, current.y)) continue; const key = `${n.x},${n.y}`; if (!visited.has(key)) { @@ -194,13 +213,41 @@ export class MonsterAI { } performAttack(monster, hero) { - const result = CombatMechanics.resolveMeleeAttack(monster, hero); - console.log(`[COMBAT] ${result.message}`); + // SEQUENCE: + // 1. Show green ring on monster + // 2. Monster attack animation (we'll simulate with delay) + // 3. Show red ring + shake on hero + // 4. Remove both rings + // 5. Show combat result - // Notify UI/GameEngine about damage (if we had a hook) - if (this.game.onCombatResult) { - this.game.onCombatResult(result); + const result = CombatMechanics.resolveMeleeAttack(monster, hero); + + // Step 1: Green ring on attacker + if (this.game.onEntityActive) { + this.game.onEntityActive(monster.id, true); } + + // Step 2: Attack animation delay (500ms) + setTimeout(() => { + // Step 3: Trigger hit visual on defender (if hit succeeded) + if (result.hitSuccess && this.game.onEntityHit) { + this.game.onEntityHit(hero.id); + } + + // Step 4: Remove green ring after red ring appears (1200ms for red ring duration) + setTimeout(() => { + if (this.game.onEntityActive) { + this.game.onEntityActive(monster.id, false); + } + + // Step 5: Show combat result after both rings are gone + setTimeout(() => { + if (this.game.onCombatResult) { + this.game.onCombatResult(result); + } + }, 200); // Small delay after rings disappear + }, 1200); // Wait for red ring to disappear + }, 500); // Attack animation delay } getAdjacentHero(entity) { @@ -215,8 +262,17 @@ export class MonsterAI { 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; + + // Must be orthogonally adjacent (Manhattan dist 1) + if ((dx + dy) !== 1) return false; + + // NEW: Check if movement between monster and hero is valid + // This prevents attacking through walls/room boundaries + if (!this.game.dungeon.grid.canMoveBetween(entity.x, entity.y, hero.x, hero.y)) { + return false; // Wall or room boundary blocks attack + } + + return true; }); } } diff --git a/src/main.js b/src/main.js index fa4bd52..5bc2634 100644 --- a/src/main.js +++ b/src/main.js @@ -37,6 +37,11 @@ generator.grid.placeTile = (instance, variant, card) => { setTimeout(() => { renderer.renderExits(generator.availableExits); + // Don't show modal if we are not in Exploration phase (e.g. during Setup) + if (game.turnManager.currentPhase !== 'exploration') { + return; + } + // NEW RULE: Exploration ends turn immediately. No monsters yet. // Monsters appear when a hero ENTERS the new room in the next turn. ui.showModal('Exploración Completada', @@ -98,6 +103,14 @@ game.onEntityMove = (entity, path) => { renderer.moveEntityAlongPath(entity, path); }; +game.onEntityActive = (entityId, isActive) => { + renderer.setEntityActive(entityId, isActive); +}; + +game.onEntityHit = (entityId) => { + renderer.triggerDamageEffect(entityId); +}; + // game.onEntitySelect is now handled by UIManager to wrap the renderer call renderer.onHeroFinishedMove = (x, y) => { diff --git a/src/view/GameRenderer.js b/src/view/GameRenderer.js index 092bc55..5447ef4 100644 --- a/src/view/GameRenderer.js +++ b/src/view/GameRenderer.js @@ -217,6 +217,79 @@ export class GameRenderer { } } + setEntityActive(entityId, isActive) { + const mesh = this.entities.get(entityId); + if (!mesh) return; + + // Remove existing active ring if any + const oldRing = mesh.getObjectByName("ActiveRing"); + if (oldRing) mesh.remove(oldRing); + + if (isActive) { + // Phosphorescent Green Ring - MATCHING SIZE (0.3 - 0.4) + const ringGeom = new THREE.RingGeometry(0.3, 0.4, 32); + + // Basic Material does not support emissive. Use color + opacity for "glow" feel. + const ringMat = new THREE.MeshBasicMaterial({ + color: 0x00ff00, // Green + side: THREE.DoubleSide, + transparent: true, + opacity: 0.8 + }); + + const ring = new THREE.Mesh(ringGeom, ringMat); + ring.rotation.x = -Math.PI / 2; + + // Align with floor (relative to mesh center) + const h = 1.56; + ring.position.y = -h / 2 + 0.05; + + ring.name = "ActiveRing"; + mesh.add(ring); + } + } + + triggerDamageEffect(entityId) { + const mesh = this.entities.get(entityId); + if (!mesh) return; + + // 1. Red Halo (Temporary) - MATCHING ATTACKER SIZE (0.3 - 0.4) + const hitRingGeom = new THREE.RingGeometry(0.3, 0.4, 32); + const hitRingMat = new THREE.MeshBasicMaterial({ + color: 0xff0000, + side: THREE.DoubleSide, + transparent: true, + opacity: 0.9 + }); + const hitRing = new THREE.Mesh(hitRingGeom, hitRingMat); + hitRing.rotation.x = -Math.PI / 2; + + // Align with floor + const h = 1.56; + hitRing.position.y = -h / 2 + 0.05; + + hitRing.name = "HitRing"; + + mesh.add(hitRing); + + // Remove Red Halo after 1200ms (matching the timing in MonsterAI) + setTimeout(() => { + if (mesh && hitRing) mesh.remove(hitRing); + }, 1200); + + // 2. Shake Animation (800ms) + const originalPos = mesh.position.clone(); + const startTime = performance.now(); + const duration = 800; // ms + + mesh.userData.shake = { + startTime: startTime, + duration: duration, + magnitude: 0.1, + originalPos: originalPos + }; + } + moveEntityAlongPath(entity, path) { const mesh = this.entities.get(entity.id); if (mesh) { @@ -249,7 +322,7 @@ export class GameRenderer { } if (data.isMoving) { - const duration = 300; // Faster jump (300ms) + const duration = 300; // Hero movement speed (300ms per tile) const elapsed = time - data.startTime; const progress = Math.min(elapsed / duration, 1); @@ -278,6 +351,25 @@ export class GameRenderer { } } } + } else if (data.shake) { + // HANDLE SHAKE + const elapsed = time - data.shake.startTime; + if (elapsed < data.shake.duration) { + const progress = elapsed / data.shake.duration; + // Dampen over time + const mag = data.shake.magnitude * (1 - progress); + + // Random jitter + const offsetX = (Math.random() - 0.5) * mag * 2; + const offsetZ = (Math.random() - 0.5) * mag * 2; + + mesh.position.x = data.shake.originalPos.x + offsetX; + mesh.position.z = data.shake.originalPos.z + offsetZ; + } else { + // Reset + mesh.position.copy(data.shake.originalPos); + delete data.shake; + } }