Implement Initiative Turn System and Fog of War

This commit is contained in:
2026-01-09 14:12:40 +01:00
parent e45207807d
commit b08a922c00
4 changed files with 258 additions and 3 deletions

View File

@@ -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

View File

@@ -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**

View File

@@ -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}`);

View File

@@ -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