diff --git a/DEVLOG.md b/DEVLOG.md index e46a8dd..8898dbd 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -23,13 +23,23 @@ - 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**: +- **Renderizado Dinámico de Niebla**: - `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. +#### 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 -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. --- diff --git a/implementación/task.md b/implementación/task.md index 0ae8599..d875bb1 100644 --- a/implementación/task.md +++ b/implementación/task.md @@ -44,6 +44,8 @@ - [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) + - [x] Refine FOW (Entity Hiding, Turn Skipping) + - [x] UI Polish (End Turn placement, Target Rings, Clean Monster Phase) ## Phase 4: Campaign System - [ ] **Campaign Manager** diff --git a/src/engine/game/GameEngine.js b/src/engine/game/GameEngine.js index 5c6289b..c14283c 100644 --- a/src/engine/game/GameEngine.js +++ b/src/engine/game/GameEngine.js @@ -55,6 +55,12 @@ export class GameEngine { if (phase === 'hero') { this.initializeTurnOrder(); } + if (phase === 'monster') { + if (window.RENDERER && window.RENDERER.clearAllActiveRings) { + window.RENDERER.clearAllActiveRings(); + } + this.deselectEntity(); + } }); // End of Turn Logic (Buffs, cooldowns, etc) @@ -211,9 +217,44 @@ export class GameEngine { nextHeroTurn() { this.currentTurnIndex++; - if (this.currentTurnIndex < this.heroTurnOrder.length) { - this.activateHero(this.heroTurnOrder[this.currentTurnIndex]); - } else { + + // Loop to find next VALID hero (visible) + 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?"); this.deselectEntity(); if (window.RENDERER) { @@ -391,9 +432,23 @@ export class GameEngine { 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(); + // EXCEPTION: In Hero Phase, if I click MYSELF (Active Hero), do NOT deselect. + // It's annoying to lose the card. + 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) { // Clicking on already selected monster - deselect it 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)); } @@ -720,7 +778,32 @@ export class GameEngine { 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(); + } } diff --git a/src/engine/game/MonsterAI.js b/src/engine/game/MonsterAI.js index 197ace4..6c5b21d 100644 --- a/src/engine/game/MonsterAI.js +++ b/src/engine/game/MonsterAI.js @@ -214,11 +214,22 @@ export class MonsterAI { performAttack(monster, hero) { // 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 + // 0. Show TARGET (Blue Ring) on Hero + if (this.game.onRangedTarget) { + // Re-using onRangedTarget? Or directly calling renderer? + // Better to use a specific callback or direct call if available, or just add a new callback. + // 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); @@ -229,9 +240,6 @@ export class MonsterAI { // Step 2: Attack animation delay (500ms) 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) setTimeout(() => { @@ -239,6 +247,11 @@ export class MonsterAI { 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 setTimeout(() => { if (this.game.onCombatResult) { @@ -246,7 +259,7 @@ export class MonsterAI { } }, 200); // Small delay after rings disappear }, 1200); // Wait for red ring to disappear - }, 500); // Attack animation delay + }, 800); // Attack animation delay + focus time } getAdjacentHero(entity) { diff --git a/src/view/GameRenderer.js b/src/view/GameRenderer.js index ccca6d6..2ac2c5c 100644 --- a/src/view/GameRenderer.js +++ b/src/view/GameRenderer.js @@ -957,46 +957,40 @@ export class GameRenderer { if (!this.dungeonGroup) return; const visibleSet = new Set(visibleTileIds); + const visibleCellKeys = new Set(); + // 1. Update Tile Visibility & Collect Visible Cells this.dungeonGroup.children.forEach(mesh => { const isVisible = visibleSet.has(mesh.userData.tileId); 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) 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 @@ -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) { if (!doorMesh || !doorMesh.userData.isDoor) return; if (doorMesh.userData.isOpen) return; // Already open diff --git a/src/view/UIManager.js b/src/view/UIManager.js index 8de6144..ba1de58 100644 --- a/src/view/UIManager.js +++ b/src/view/UIManager.js @@ -113,4 +113,5 @@ export class UIManager { showTemporaryMessage(t, m, d) { this.feedback.showTemporaryMessage(t, m, d); } showCombatLog(log) { this.feedback.showCombatLog(log); } showRangedAttackUI(monster) { this.cards.showRangedAttackUI(monster); } + hideMonsterCard() { this.cards.hideMonsterCard(); } } diff --git a/src/view/ui/TurnStatusUI.js b/src/view/ui/TurnStatusUI.js index 3ddd110..8b096e9 100644 --- a/src/view/ui/TurnStatusUI.js +++ b/src/view/ui/TurnStatusUI.js @@ -44,12 +44,24 @@ export class TurnStatusUI { `; 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.textContent = 'ACABAR FASE AVENTUREROS'; Object.assign(this.endPhaseBtn.style, { - marginTop: '10px', - width: '300px', + flex: '1', // Take available space (50% if shared) padding: '8px', backgroundColor: '#daa520', color: '#000', @@ -59,7 +71,7 @@ export class TurnStatusUI { cursor: 'pointer', display: 'none', fontFamily: '"Cinzel", serif', - fontSize: '12px', + fontSize: '11px', // Slightly smaller text for split pointerEvents: 'auto' }); @@ -67,10 +79,38 @@ export class TurnStatusUI { this.endPhaseBtn.onmouseout = () => { this.endPhaseBtn.style.backgroundColor = '#daa520'; }; this.endPhaseBtn.onclick = () => { + // Only if visible! console.log('[TurnStatusUI] End Phase Button Clicked', this.game.turnManager.currentPhase); 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) this.notificationArea = document.createElement('div'); @@ -109,17 +149,31 @@ export class TurnStatusUI { 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') { - this.endPhaseBtn.style.display = 'block'; - this.endPhaseBtn.textContent = 'ACABAR FASE AVENTUREROS'; - this.endPhaseBtn.title = "Pasar a la Fase de Monstruos"; + // Split Mode + if (this.endPhaseBtn) { + 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') { - this.endPhaseBtn.style.display = 'block'; - this.endPhaseBtn.textContent = 'ACABAR TURNO'; - this.endPhaseBtn.title = "Finalizar turno y comenzar Fase de Poder"; + // Full Width Mode for End Phase (used as End Turn in exp) + if (this.endPhaseBtn) { + this.endPhaseBtn.style.display = 'block'; + this.endPhaseBtn.textContent = 'ACABAR TURNO'; + this.endPhaseBtn.title = "Finalizar turno y comenzar Fase de Poder"; + } } else { - this.endPhaseBtn.style.display = 'none'; + // Nothing visible } } } diff --git a/src/view/ui/UnitCardManager.js b/src/view/ui/UnitCardManager.js index eb114a8..e3abdf1 100644 --- a/src/view/ui/UnitCardManager.js +++ b/src/view/ui/UnitCardManager.js @@ -237,11 +237,13 @@ export class UnitCardManager { Object.assign(invBtn.style, { width: '100%', padding: '8px', marginTop: '8px', backgroundColor: '#444', 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)'; card.appendChild(invBtn); + + // Wizard Spells if (hero.key === 'wizard') { const spellsBtn = document.createElement('button');