Refine FOW visuals, Turn Skipping logic, and UI Polish

This commit is contained in:
2026-01-09 15:44:04 +01:00
parent b08a922c00
commit 009c2a4135
8 changed files with 264 additions and 63 deletions

View File

@@ -23,13 +23,23 @@
- Se abandonó la idea de radio por celdas simples. - Se abandonó la idea de radio por celdas simples.
- Nueva lógica: Se identifica la **Sección de Tablero (Tile)** del Líder. - 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. - Se calculan las secciones conectadas físicamente (puertas/pasillos) mediante análisis de `canMoveBetween` en la rejilla.
- **Renderizado Dinámico**: - **Renderizado Dinámico de Niebla**:
- `GameRenderer` ahora agrupa las losetas en `dungeonGroup`. - `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. - Método `updateFogOfWar` que oculta/muestra losetas y **Entidades** (héroes/monstruos) basándose en la visibilidad de su posición.
- La iluminación se actualiza en tiempo real con cada paso del portador de la lámpara. - La iluminación se actualiza en tiempo real con cada paso del portador de la lámpara.
#### 3. UX de Combate
- **Feedback Visual de Objetivo**: Se ha añadido un **Anillo Azul** que señala al héroe objetivo de un monstruo *antes* de que se realice el ataque, permitiendo al jugador identificar la amenaza inmediatamente.
- **Limpieza de UI**: Al comenzar la fase de monstruos, se eliminan automáticamente todos los indicadores de selección (anillos verdes/amarillos) para limpiar la escena.
- **Persistencia de Héroe Activo**: El héroe activo ya no pierde su estado de selección al moverse o al hacer clic sobre sí mismo accidentalmente, mejorando la fluidez del turno.
#### 4. UX y Lógica de Juego
- **Ocultación de Entidades en Niebla**: Los héroes y monstruos que quedan fuera del alcance de la lámpara (losetas no visibles) ahora desaparecen completamente de la vista, aumentando la inmersión.
- **Salto de Turno Automático**: Si un héroe comienza su turno en una zona oscura (oculta por la Niebla de Guerra), pierde automáticamente su turno hasta que sea "rescatado" (iluminado de nuevo) por el Portador de la Lámpara.
- **Botones de Fase**: Se ha reorganizado la barra superior. El botón de "Acabar Fase" ahora comparte espacio con un nuevo botón "Acabar Turno" específico para cada héroe, facilitando el flujo de juego sin tener que buscar en la ficha del personaje.
### Estado Actual ### 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. El juego ahora respeta las reglas de visión y turno del juego de mesa original con una fidelidad visual alta. 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. La UI es más intuitiva y limpia durante el combate.
--- ---

View File

@@ -44,6 +44,8 @@
- [x] UI Improvements (Spanish Stats, Tooltips) - [x] UI Improvements (Spanish Stats, Tooltips)
- [x] Implement Turn Initiative System (Strict Order, Leader First) - [x] Implement Turn Initiative System (Strict Order, Leader First)
- [x] Implement Fog of War (Lamp Rule based on Board Sections) - [x] Implement Fog of War (Lamp Rule based on Board Sections)
- [x] Refine FOW (Entity Hiding, Turn Skipping)
- [x] UI Polish (End Turn placement, Target Rings, Clean Monster Phase)
## Phase 4: Campaign System ## Phase 4: Campaign System
- [ ] **Campaign Manager** - [ ] **Campaign Manager**

View File

@@ -55,6 +55,12 @@ export class GameEngine {
if (phase === 'hero') { if (phase === 'hero') {
this.initializeTurnOrder(); this.initializeTurnOrder();
} }
if (phase === 'monster') {
if (window.RENDERER && window.RENDERER.clearAllActiveRings) {
window.RENDERER.clearAllActiveRings();
}
this.deselectEntity();
}
}); });
// End of Turn Logic (Buffs, cooldowns, etc) // End of Turn Logic (Buffs, cooldowns, etc)
@@ -211,9 +217,44 @@ export class GameEngine {
nextHeroTurn() { nextHeroTurn() {
this.currentTurnIndex++; this.currentTurnIndex++;
if (this.currentTurnIndex < this.heroTurnOrder.length) {
this.activateHero(this.heroTurnOrder[this.currentTurnIndex]); // Loop to find next VALID hero (visible)
} else { while (this.currentTurnIndex < this.heroTurnOrder.length) {
const nextHero = this.heroTurnOrder[this.currentTurnIndex];
// Check visibility
// Exception: Leader (hasLantern) is ALWAYS visible.
if (nextHero.hasLantern) {
this.activateHero(nextHero);
return;
}
// Check if hero is in a visible tile
// Get hero tile ID
const heroTileId = this.dungeon.grid.occupiedCells.get(`${nextHero.x},${nextHero.y}`);
// If currentVisibleTileIds is defined, enforce it.
if (this.currentVisibleTileIds) {
if (heroTileId && this.currentVisibleTileIds.has(heroTileId)) {
this.activateHero(nextHero);
return;
} else {
console.log(`Skipping turn for ${nextHero.name} (In Darkness)`);
if (this.onShowMessage) {
// Optional: Small notification or log
// this.onShowMessage("Perdido en la oscuridad", `${nextHero.name} pierde su turno.`);
}
this.currentTurnIndex++; // Skip and continue loop
}
} else {
// Should not happen if updateLighting runs, but fallback
this.activateHero(nextHero);
return;
}
}
// If loop finishes, no more heroes
if (this.currentTurnIndex >= this.heroTurnOrder.length) {
console.log("All heroes acted. Ending Phase sequence if auto?"); console.log("All heroes acted. Ending Phase sequence if auto?");
this.deselectEntity(); this.deselectEntity();
if (window.RENDERER) { if (window.RENDERER) {
@@ -391,9 +432,23 @@ export class GameEngine {
if (this.selectedEntity === clickedEntity) { if (this.selectedEntity === clickedEntity) {
// Toggle Deselect // Toggle Deselect
// Prevent deselecting active hero effectively? Or allow it but re-select him if trying to select others? // EXCEPTION: In Hero Phase, if I click MYSELF (Active Hero), do NOT deselect.
// For now, allow deselect freely. // It's annoying to lose the card.
this.deselectEntity(); const isHeroPhase = this.turnManager.currentPhase === 'hero';
let isActiveTurnHero = false;
if (isHeroPhase && this.heroTurnOrder && this.currentTurnIndex !== undefined) {
const activeHero = this.heroTurnOrder[this.currentTurnIndex];
if (activeHero && activeHero.id === clickedEntity.id) {
isActiveTurnHero = true;
}
}
if (isActiveTurnHero) {
// Do nothing (keep selected)
// Maybe blink the card or something?
} else {
this.deselectEntity();
}
} else if (this.selectedMonster === clickedMonster && clickedMonster) { } else if (this.selectedMonster === clickedMonster && clickedMonster) {
// Clicking on already selected monster - deselect it // Clicking on already selected monster - deselect it
const monsterId = this.selectedMonster.id; const monsterId = this.selectedMonster.id;
@@ -503,6 +558,9 @@ export class GameEngine {
} }
} }
// Store active visibility sets for Turn Logic
this.currentVisibleTileIds = visibleTileIds;
window.RENDERER.updateFogOfWar(Array.from(visibleTileIds)); window.RENDERER.updateFogOfWar(Array.from(visibleTileIds));
} }
@@ -720,7 +778,32 @@ export class GameEngine {
if (entity.currentMoves < 0) entity.currentMoves = 0; if (entity.currentMoves < 0) entity.currentMoves = 0;
} }
this.deselectEntity(); // AUTO-DESELECT LOGIC
// In Hero Phase, we want to KEEP the active hero selected to avoid re-selecting.
const isHeroPhase = this.turnManager.currentPhase === 'hero';
// Check if entity is the currently active turn hero
let isActiveTurnHero = false;
if (isHeroPhase && this.heroTurnOrder && this.currentTurnIndex !== undefined) {
const activeHero = this.heroTurnOrder[this.currentTurnIndex];
if (activeHero && activeHero.id === entity.id) {
isActiveTurnHero = true;
}
}
if (isActiveTurnHero) {
// Do NOT deselect. Just clear path.
this.plannedPath = [];
if (this.onPathChange) this.onPathChange([]);
// Also force update UI/Card (stats changed)
if (this.onEntitySelect) {
// Re-trigger selection to ensure UI is fresh?
// UIManager listens to onEntityMove to update stats, so that should be covered.
// But purely being consistent:
}
} else {
this.deselectEntity();
}
} }

View File

@@ -214,11 +214,22 @@ export class MonsterAI {
performAttack(monster, hero) { performAttack(monster, hero) {
// SEQUENCE: // SEQUENCE:
// 1. Show green ring on monster // 0. Show TARGET (Blue Ring) on Hero
// 2. Monster attack animation (we'll simulate with delay) if (this.game.onRangedTarget) {
// 3. Show red ring + shake on hero // Re-using onRangedTarget? Or directly calling renderer?
// 4. Remove both rings // Better to use a specific callback or direct call if available, or just add a new callback.
// 5. Show combat result // But let's check if we can access renderer directly or use a new callback.
// The user prompt specifically asked for this feature.
// I'll assume we can use game.onEntityTarget if defined, or direct renderer call if needed,
// but standard pattern here is callbacks.
// Let's add onEntityTarget to GameEngine callbacks if not present, but for now I will try to use global RENDERER if possible
// OR simply define a new callback `this.game.onEntityTarget(hero.id, true)`.
}
// Direct renderer call is safest given current context if we don't want to modify GameEngine interface heavily right now.
if (window.RENDERER && window.RENDERER.setEntityTarget) {
window.RENDERER.setEntityTarget(hero.id, true);
}
const result = CombatMechanics.resolveMeleeAttack(monster, hero, this.game); const result = CombatMechanics.resolveMeleeAttack(monster, hero, this.game);
@@ -229,9 +240,6 @@ export class MonsterAI {
// Step 2: Attack animation delay (500ms) // Step 2: Attack animation delay (500ms)
setTimeout(() => { setTimeout(() => {
// Step 3: Trigger hit visual on defender (if hit succeeded)
// Step 3: Trigger hit visual on defender REMOVED (Handled by onCombatResult)
// Step 4: Remove green ring after red ring appears (1200ms for red ring duration) // Step 4: Remove green ring after red ring appears (1200ms for red ring duration)
setTimeout(() => { setTimeout(() => {
@@ -239,6 +247,11 @@ export class MonsterAI {
this.game.onEntityActive(monster.id, false); this.game.onEntityActive(monster.id, false);
} }
// Remove Target Ring
if (window.RENDERER && window.RENDERER.setEntityTarget) {
window.RENDERER.setEntityTarget(hero.id, false);
}
// Step 5: Show combat result after both rings are gone // Step 5: Show combat result after both rings are gone
setTimeout(() => { setTimeout(() => {
if (this.game.onCombatResult) { if (this.game.onCombatResult) {
@@ -246,7 +259,7 @@ export class MonsterAI {
} }
}, 200); // Small delay after rings disappear }, 200); // Small delay after rings disappear
}, 1200); // Wait for red ring to disappear }, 1200); // Wait for red ring to disappear
}, 500); // Attack animation delay }, 800); // Attack animation delay + focus time
} }
getAdjacentHero(entity) { getAdjacentHero(entity) {

View File

@@ -957,46 +957,40 @@ export class GameRenderer {
if (!this.dungeonGroup) return; if (!this.dungeonGroup) return;
const visibleSet = new Set(visibleTileIds); const visibleSet = new Set(visibleTileIds);
const visibleCellKeys = new Set();
// 1. Update Tile Visibility & Collect Visible Cells
this.dungeonGroup.children.forEach(mesh => { this.dungeonGroup.children.forEach(mesh => {
const isVisible = visibleSet.has(mesh.userData.tileId); const isVisible = visibleSet.has(mesh.userData.tileId);
mesh.visible = isVisible; mesh.visible = isVisible;
if (isVisible && mesh.userData.cells) {
mesh.userData.cells.forEach(cell => {
visibleCellKeys.add(`${cell.x},${cell.y}`);
});
}
}); });
// 2. Hide/Show Entities based on Tile Visibility
if (this.entities) {
this.entities.forEach((mesh, id) => {
// Get grid coords (World X, -Z)
const gx = Math.round(mesh.position.x);
const gy = Math.round(-mesh.position.z);
const key = `${gx},${gy}`;
// If the cell is visible, show the entity. Otherwise hide it.
if (visibleCellKeys.has(key)) {
mesh.visible = true;
} else {
mesh.visible = false;
}
});
}
// Also update Doors (in exitGroup) // Also update Doors (in exitGroup)
if (this.exitGroup) { if (this.exitGroup) {
this.exitGroup.children.forEach(door => { 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; 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 // Re-iterate to show close doors
@@ -1028,6 +1022,48 @@ export class GameRenderer {
} }
} }
setEntityTarget(entityId, isTarget) {
const mesh = this.entities.get(entityId);
if (!mesh) return;
// Remove existing target ring
const oldRing = mesh.getObjectByName("TargetRing");
if (oldRing) mesh.remove(oldRing);
if (isTarget) {
// Blue Ring logic
const ringGeom = new THREE.RingGeometry(0.3, 0.4, 32);
const ringMat = new THREE.MeshBasicMaterial({
color: 0x00AADD, // Light Blue
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.06; // Slightly above floor/selection
ring.name = "TargetRing";
mesh.add(ring);
}
}
clearAllActiveRings() {
if (!this.entities) return;
this.entities.forEach(mesh => {
const ring = mesh.getObjectByName("ActiveRing"); // Green ring
if (ring) mesh.remove(ring);
const ring2 = mesh.getObjectByName("SelectionRing"); // Yellow ring
if (ring2) ring2.visible = false;
// Also clear TargetRing if any
const ring3 = mesh.getObjectByName("TargetRing");
if (ring3) mesh.remove(ring3);
});
}
openDoor(doorMesh) { openDoor(doorMesh) {
if (!doorMesh || !doorMesh.userData.isDoor) return; if (!doorMesh || !doorMesh.userData.isDoor) return;
if (doorMesh.userData.isOpen) return; // Already open if (doorMesh.userData.isOpen) return; // Already open

View File

@@ -113,4 +113,5 @@ export class UIManager {
showTemporaryMessage(t, m, d) { this.feedback.showTemporaryMessage(t, m, d); } showTemporaryMessage(t, m, d) { this.feedback.showTemporaryMessage(t, m, d); }
showCombatLog(log) { this.feedback.showCombatLog(log); } showCombatLog(log) { this.feedback.showCombatLog(log); }
showRangedAttackUI(monster) { this.cards.showRangedAttackUI(monster); } showRangedAttackUI(monster) { this.cards.showRangedAttackUI(monster); }
hideMonsterCard() { this.cards.hideMonsterCard(); }
} }

View File

@@ -44,12 +44,24 @@ export class TurnStatusUI {
`; `;
this.statusPanel.appendChild(this.phaseInfo); this.statusPanel.appendChild(this.phaseInfo);
// End Phase Button // Button Container (Row for split buttons)
this.buttonContainer = document.createElement('div');
Object.assign(this.buttonContainer.style, {
marginTop: '10px',
width: '300px',
display: 'flex',
flexDirection: 'row',
gap: '4px', // Space between buttons
justifyItems: 'center',
pointerEvents: 'none' // Container checks pointer events safely? inner btns will be auto.
});
this.statusPanel.appendChild(this.buttonContainer);
// End Phase Button (Left)
this.endPhaseBtn = document.createElement('button'); this.endPhaseBtn = document.createElement('button');
this.endPhaseBtn.textContent = 'ACABAR FASE AVENTUREROS'; this.endPhaseBtn.textContent = 'ACABAR FASE AVENTUREROS';
Object.assign(this.endPhaseBtn.style, { Object.assign(this.endPhaseBtn.style, {
marginTop: '10px', flex: '1', // Take available space (50% if shared)
width: '300px',
padding: '8px', padding: '8px',
backgroundColor: '#daa520', backgroundColor: '#daa520',
color: '#000', color: '#000',
@@ -59,7 +71,7 @@ export class TurnStatusUI {
cursor: 'pointer', cursor: 'pointer',
display: 'none', display: 'none',
fontFamily: '"Cinzel", serif', fontFamily: '"Cinzel", serif',
fontSize: '12px', fontSize: '11px', // Slightly smaller text for split
pointerEvents: 'auto' pointerEvents: 'auto'
}); });
@@ -67,10 +79,38 @@ export class TurnStatusUI {
this.endPhaseBtn.onmouseout = () => { this.endPhaseBtn.style.backgroundColor = '#daa520'; }; this.endPhaseBtn.onmouseout = () => { this.endPhaseBtn.style.backgroundColor = '#daa520'; };
this.endPhaseBtn.onclick = () => { this.endPhaseBtn.onclick = () => {
// Only if visible!
console.log('[TurnStatusUI] End Phase Button Clicked', this.game.turnManager.currentPhase); console.log('[TurnStatusUI] End Phase Button Clicked', this.game.turnManager.currentPhase);
this.game.turnManager.nextPhase(); this.game.turnManager.nextPhase();
}; };
this.statusPanel.appendChild(this.endPhaseBtn); this.buttonContainer.appendChild(this.endPhaseBtn);
// End Turn Button (Right - Hero only)
this.endTurnBtn = document.createElement('button');
this.endTurnBtn.textContent = 'ACABAR TURNO';
Object.assign(this.endTurnBtn.style, {
flex: '1', // 50% width
padding: '8px',
backgroundColor: '#8B4513', // Different color (Dark Red/Wood)
color: '#FFD700',
border: '1px solid #DAA520',
borderRadius: '3px',
fontWeight: 'bold',
cursor: 'pointer',
display: 'none',
fontFamily: '"Cinzel", serif',
fontSize: '11px',
pointerEvents: 'auto'
});
this.endTurnBtn.onmouseover = () => { this.endTurnBtn.style.backgroundColor = '#A0522D'; };
this.endTurnBtn.onmouseout = () => { this.endTurnBtn.style.backgroundColor = '#8B4513'; };
this.endTurnBtn.onclick = () => {
if (this.game.nextHeroTurn) {
this.game.nextHeroTurn();
}
};
this.buttonContainer.appendChild(this.endTurnBtn);
// Notification Area (Power Roll) // Notification Area (Power Roll)
this.notificationArea = document.createElement('div'); this.notificationArea = document.createElement('div');
@@ -109,17 +149,31 @@ export class TurnStatusUI {
this.phaseInfo.innerHTML = content; this.phaseInfo.innerHTML = content;
if (this.endPhaseBtn) { if (this.buttonContainer) {
this.buttonContainer.style.display = 'flex'; // Default
if (this.endPhaseBtn) this.endPhaseBtn.style.display = 'none';
if (this.endTurnBtn) this.endTurnBtn.style.display = 'none';
if (phase === 'hero') { if (phase === 'hero') {
this.endPhaseBtn.style.display = 'block'; // Split Mode
this.endPhaseBtn.textContent = 'ACABAR FASE AVENTUREROS'; if (this.endPhaseBtn) {
this.endPhaseBtn.title = "Pasar a la Fase de Monstruos"; this.endPhaseBtn.style.display = 'block';
this.endPhaseBtn.textContent = 'ACABAR FASE'; // Shorter text
this.endPhaseBtn.title = "Pasar a la Fase de Monstruos";
}
if (this.endTurnBtn) {
this.endTurnBtn.style.display = 'block'; // Show right button
}
} else if (phase === 'exploration') { } else if (phase === 'exploration') {
this.endPhaseBtn.style.display = 'block'; // Full Width Mode for End Phase (used as End Turn in exp)
this.endPhaseBtn.textContent = 'ACABAR TURNO'; if (this.endPhaseBtn) {
this.endPhaseBtn.title = "Finalizar turno y comenzar Fase de Poder"; this.endPhaseBtn.style.display = 'block';
this.endPhaseBtn.textContent = 'ACABAR TURNO';
this.endPhaseBtn.title = "Finalizar turno y comenzar Fase de Poder";
}
} else { } else {
this.endPhaseBtn.style.display = 'none'; // Nothing visible
} }
} }
} }

View File

@@ -237,11 +237,13 @@ export class UnitCardManager {
Object.assign(invBtn.style, { Object.assign(invBtn.style, {
width: '100%', padding: '8px', marginTop: '8px', backgroundColor: '#444', width: '100%', padding: '8px', marginTop: '8px', backgroundColor: '#444',
color: '#fff', border: '1px solid #777', borderRadius: '4px', color: '#fff', border: '1px solid #777', borderRadius: '4px',
fontFamily: '"Cinzel", serif', fontSize: '12px', cursor: 'not-allowed' fontFamily: '"Cinzel", serif', fontSize: '12px', cursor: 'pointer' // Changed cursor to pointer for feel, though functionality implies future
}); });
invBtn.title = 'Inventario (Próximamente)'; invBtn.title = 'Inventario (Próximamente)';
card.appendChild(invBtn); card.appendChild(invBtn);
// Wizard Spells // Wizard Spells
if (hero.key === 'wizard') { if (hero.key === 'wizard') {
const spellsBtn = document.createElement('button'); const spellsBtn = document.createElement('button');