Implement Initiative Turn System and Fog of War
This commit is contained in:
33
DEVLOG.md
33
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
|
||||
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user