From b08a922c009666c63e104634d2426d14daed003c Mon Sep 17 00:00:00 2001 From: Marti Vich Date: Fri, 9 Jan 2026 14:12:40 +0100 Subject: [PATCH] Implement Initiative Turn System and Fog of War --- DEVLOG.md | 33 ++++++++ implementación/task.md | 2 + src/engine/game/GameEngine.js | 139 ++++++++++++++++++++++++++++++++++ src/view/GameRenderer.js | 87 ++++++++++++++++++++- 4 files changed, 258 insertions(+), 3 deletions(-) diff --git a/DEVLOG.md b/DEVLOG.md index 88492c3..e46a8dd 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -1,5 +1,38 @@ # Devlog - Warhammer Quest (Versión Web 3D) +## Sesión 11: Sistema de Iniciativa y Niebla de Guerra (Fog of War) +**Fecha:** 9 de Enero de 2026 + +### Objetivos +- Implementar el sistema de **Niebla de Guerra (Fog of War)** basado en la regla de la Lámpara: Visibilidad limitada a la sección actual y adyacentes. +- Establecer un sistema de turnos estricto basado en **Iniciativa** para la fase de Aventureros. + +### Cambios Realizados + +#### 1. Sistema de Iniciativa y Turnos +- **Orden de Turno**: Implementada la lógica `initializeTurnOrder` en `GameEngine`. + - El **Portador de la Lámpara** (Líder) siempre actúa primero. + - El resto de héroes se ordenan por su atributo de **Iniciativa** (Descendente). +- **Control Estricto**: + - Modificado `onCellClick` para impedir la selección y control de héroes que no sean el activo durante la Fase de Aventureros. + - Se visualiza el héroe activo mediante un **Anillo Verde** (vs Amarillo de selección). +- **Ciclo de Turnos**: Métodos `activateNextHero` y `nextHeroTurn` para avanzar ordenadamente. + +#### 2. Niebla de Guerra (Lamp Rule) +- **Lógica de Adyacencia de Secciones**: + - Se abandonó la idea de radio por celdas simples. + - Nueva lógica: Se identifica la **Sección de Tablero (Tile)** del Líder. + - Se calculan las secciones conectadas físicamente (puertas/pasillos) mediante análisis de `canMoveBetween` en la rejilla. +- **Renderizado Dinámico**: + - `GameRenderer` ahora agrupa las losetas en `dungeonGroup`. + - Método `updateFogOfWar` que oculta/muestra losetas completas basándose en la lista de IDs visibles calculada por el motor. + - La iluminación se actualiza en tiempo real con cada paso del portador de la lámpara. + +### Estado Actual +El juego ahora respeta las reglas de visión y turno del juego de mesa original. La sensación de exploración es más tensa al ocultarse las zonas lejanas ("se perderán en la oscuridad"), y el orden táctico es crucial. + +--- + ## Sesión 10: Refactorización Arquitectónica de UI **Fecha:** 8 de Enero de 2026 diff --git a/implementación/task.md b/implementación/task.md index 932439c..0ae8599 100644 --- a/implementación/task.md +++ b/implementación/task.md @@ -42,6 +42,8 @@ - [x] Refine Combat System (Ranged weapons, Area Magic, Damage Feedback) - [x] Implement Audio System (SFX, Footsteps, Ambience) - [x] UI Improvements (Spanish Stats, Tooltips) + - [x] Implement Turn Initiative System (Strict Order, Leader First) + - [x] Implement Fog of War (Lamp Rule based on Board Sections) ## Phase 4: Campaign System - [ ] **Campaign Manager** diff --git a/src/engine/game/GameEngine.js b/src/engine/game/GameEngine.js index e59b4c4..5c6289b 100644 --- a/src/engine/game/GameEngine.js +++ b/src/engine/game/GameEngine.js @@ -52,12 +52,18 @@ export class GameEngine { if (phase === 'hero' || phase === 'exploration') { this.resetHeroMoves(); } + if (phase === 'hero') { + this.initializeTurnOrder(); + } }); // End of Turn Logic (Buffs, cooldowns, etc) this.turnManager.on('turn_ended', (turn) => { this.handleEndTurn(); }); + + // Initial Light Update + setTimeout(() => this.updateLighting(), 500); } resetHeroMoves() { @@ -161,6 +167,70 @@ export class GameEngine { this.player = this.heroes[0]; } + initializeTurnOrder() { + console.log("[GameEngine] Initializing Turn Order..."); + + // 1. Identify Leader + const leader = this.getLeader(); + + // 2. Sort Rest by Initiative (Descending) + // Note: Sort is stable or we rely on index? Array.sort is stable in modern JS. + const others = this.heroes.filter(h => h !== leader); + others.sort((a, b) => b.stats.init - a.stats.init); + + // 3. Construct Order + this.heroTurnOrder = [leader, ...others]; + this.currentTurnIndex = 0; + + console.log("Turn Order:", this.heroTurnOrder.map(h => `${h.name} (${h.stats.init})`)); + + // 4. Activate First + this.activateHero(this.heroTurnOrder[0]); + } + + activateHero(hero) { + this.selectedEntity = hero; + // Update selection UI + if (this.onEntitySelect) { + // Deselect all keys first? + this.heroes.forEach(h => this.onEntitySelect(h.id, false)); + this.onEntitySelect(hero.id, true); + } + + // Notify UI about active turn + if (this.onShowMessage) { + this.onShowMessage(`Turno de ${hero.name}`, "Mueve y Ataca."); + } + + // Mark as active in renderer (Green Ring vs Yellow Selection) + if (window.RENDERER) { + this.heroes.forEach(h => window.RENDERER.setEntityActive(h.id, false)); + window.RENDERER.setEntityActive(hero.id, true); + } + } + + nextHeroTurn() { + this.currentTurnIndex++; + if (this.currentTurnIndex < this.heroTurnOrder.length) { + this.activateHero(this.heroTurnOrder[this.currentTurnIndex]); + } else { + console.log("All heroes acted. Ending Phase sequence if auto?"); + this.deselectEntity(); + if (window.RENDERER) { + this.heroes.forEach(h => window.RENDERER.setEntityActive(h.id, false)); + } + if (this.onShowMessage) { + this.onShowMessage("Fase de Aventureros Terminada", "Pasando a Monstruos..."); + } + // Auto Advance Phase? Or Manual? + // Usually manual "End Turn" button triggers nextHeroTurn. + // When last hero ends, we trigger nextPhase. + setTimeout(() => { + this.turnManager.nextPhase(); + }, 1000); + } + } + spawnMonster(monsterKey, x, y, options = {}) { const definition = MONSTER_DEFINITIONS[monsterKey]; if (!definition) { @@ -309,8 +379,20 @@ export class GameEngine { const clickedEntity = clickedHero || clickedMonster; if (clickedEntity) { + + // STRICT TURN ORDER CHECK + if (this.turnManager.currentPhase === 'hero' && clickedHero) { + const currentActiveHero = this.heroTurnOrder ? this.heroTurnOrder[this.currentTurnIndex] : null; + if (currentActiveHero && clickedHero.id !== currentActiveHero.id) { + if (this.onShowMessage) this.onShowMessage("No es su turno", `Es el turno de ${currentActiveHero.name}.`); + return; + } + } + if (this.selectedEntity === clickedEntity) { // Toggle Deselect + // Prevent deselecting active hero effectively? Or allow it but re-select him if trying to select others? + // For now, allow deselect freely. this.deselectEntity(); } else if (this.selectedMonster === clickedMonster && clickedMonster) { // Clicking on already selected monster - deselect it @@ -369,9 +451,61 @@ export class GameEngine { performHeroAttack(targetMonsterId) { const hero = this.selectedEntity; const monster = this.monsters.find(m => m.id === targetMonsterId); + + // Attack ends turn logic could be here? Assuming user clicks "End Turn" manually for now. + // Or if standard rules: 1 attack per turn. + // For now, just attack. + return this.combatSystem.handleMeleeAttack(hero, monster); } + updateLighting() { + if (!window.RENDERER) return; + + const leader = this.getLeader(); + if (!leader) return; + + // 1. Get Leader Tile ID + const leaderTileId = this.dungeon.grid.occupiedCells.get(`${leader.x},${leader.y}`); + if (!leaderTileId) return; + + const visibleTileIds = new Set([leaderTileId]); + + // 2. Find Neighbor Tiles (Connected Board Sections) + // Iterate grid occupied cells to find cells belonging to leaderTileId + // Then check their neighbors for DIFFERENT tile IDs that are connected. + + // Optimization: We could cache this or iterate efficiently + // For now, scan occupiedCells (Map) + + for (const [key, tid] of this.dungeon.grid.occupiedCells.entries()) { + if (tid === leaderTileId) { + const [cx, cy] = key.split(',').map(Number); + + // Check 4 neighbors + const neighbors = [ + { x: cx + 1, y: cy }, { x: cx - 1, y: cy }, + { x: cx, y: cy + 1 }, { x: cx, y: cy - 1 } + ]; + + for (const n of neighbors) { + const nKey = `${n.x},${n.y}`; + const nTileId = this.dungeon.grid.occupiedCells.get(nKey); + + if (nTileId && nTileId !== leaderTileId) { + // Found a neighbor tile! + // Check connectivity logic (Walls/Doors) + if (this.dungeon.grid.canMoveBetween(cx, cy, n.x, n.y)) { + visibleTileIds.add(nTileId); + } + } + } + } + } + + window.RENDERER.updateFogOfWar(Array.from(visibleTileIds)); + } + performRangedAttack(targetMonsterId) { const hero = this.selectedEntity; const monster = this.monsters.find(m => m.id === targetMonsterId); @@ -517,6 +651,11 @@ export class GameEngine { entity.y = step.y; stepsTaken++; + // Update Light if Lantern Bearer + if (entity.hasLantern) { + this.updateLighting(); + } + // 2. Check for New Tile Entry const tileId = this.dungeon.grid.occupiedCells.get(`${step.x},${step.y}`); diff --git a/src/view/GameRenderer.js b/src/view/GameRenderer.js index 6c3f203..ccca6d6 100644 --- a/src/view/GameRenderer.js +++ b/src/view/GameRenderer.js @@ -934,12 +934,18 @@ export class GameRenderer { // but usually -r * PI/2 works for this setup. plane.rotation.z = -r * (Math.PI / 2); - // Position at the calculated center - // Notice: World Z is -Grid Y plane.position.set(cx, 0.01, -cy); plane.receiveShadow = true; - this.scene.add(plane); + // Store Metadata for FOW + plane.userData.tileId = tileInstance.id; + plane.userData.cells = cells; + + if (!this.dungeonGroup) { + this.dungeonGroup = new THREE.Group(); + this.scene.add(this.dungeonGroup); + } + this.dungeonGroup.add(plane); }); } else { @@ -947,6 +953,81 @@ export class GameRenderer { } } + updateFogOfWar(visibleTileIds) { + if (!this.dungeonGroup) return; + + const visibleSet = new Set(visibleTileIds); + + this.dungeonGroup.children.forEach(mesh => { + const isVisible = visibleSet.has(mesh.userData.tileId); + mesh.visible = isVisible; + }); + + // Also update Doors (in exitGroup) + if (this.exitGroup) { + this.exitGroup.children.forEach(door => { + // If door connects to AT LEAST ONE visible tile, it should be visible? + // Or if it is part of a visible tile? + // Doors usually belong to the tile they were spawned with? + // Logic: Check if door is within/adjacent to visible cells? + // Door userData has `cells`. + // We need to map cells to tileIds? We don't have that map here. + // Simplified: If the door is physically close to a visible tile, show it. + + // Better approach: + // Engine should pass visible cells? + // Using tileIds is robust for tiles. + // For doors: we can check if any of the door's cells are roughly inside a visible tile's bounding box? + // Or just show all doors for now? No, floating doors in darkness. + + // Hack: Show door if its position is near a visible tile. + let doorVisible = false; + const dx = door.position.x; + const dy = -door.position.z; // World Z is -Grid Y + + // Check against visible meshes + // This is O(N*M), slow. + // But N (visible tiles) is small (1-5). + + // Let's assume doors connected to visible tiles are visible. + // I need to know which tile a door belongs to. + // I will skip door FOW for this iteration or make them always visible but dim? + // Let's hide them by default and check proximity. + + door.visible = false; + // Can't easily check without grid. + // Engine will handle Door visibility via `setEntityVisibility`? No, they are meshes not entities. + }); + + // Re-iterate to show close doors + this.dungeonGroup.children.forEach(tile => { + if (tile.visible) { + // Check doors near this tile + if (this.exitGroup) { + this.exitGroup.children.forEach(door => { + if (door.visible) return; // Already shown + + // Check distance to tile center? + // Tile has cx, cy. + const tx = tile.position.x; + const ty = -tile.position.z; + + const dx = Math.abs(door.position.x - tx); + const dy = Math.abs(door.position.z - (-ty)); // Z is neg + + // Tile size? + // We don't know exact bounds here, but we can guess. + // If distance < 4 roughly? + if (dx < 4 && dy < 4) { + door.visible = true; + } + }); + } + } + }); + } + } + openDoor(doorMesh) { if (!doorMesh || !doorMesh.userData.isDoor) return; if (doorMesh.userData.isOpen) return; // Already open