Refactor: UIManager modularization and Devlog update

This commit is contained in:
2026-01-08 23:53:39 +01:00
parent 85a390b94a
commit e45207807d
8 changed files with 1371 additions and 1495 deletions

View File

@@ -1,5 +1,51 @@
# Devlog - Warhammer Quest (Versión Web 3D)
## Sesión 10: Refactorización Arquitectónica de UI
**Fecha:** 8 de Enero de 2026
### Objetivos
- Reducir la complejidad del `UIManager.js` (que superaba las 1500 líneas).
- Modularizar la interfaz para facilitar el mantenimiento y la escalabilidad.
- Separar responsabilidades claras entre HUD, Cartas de Unidad, Feedback, etc.
### Cambios Realizados
#### 1. Modularización de UIManager
Se ha dividido el monolito `UIManager.js` en 6 componentes especializados ubicados en `src/view/ui/`:
* **`HUDManager.js`**:
* Gestiona elementos estáticos de pantalla (Minimapa, Controles de Cámara, Zoom).
* Mantiene el bucle de renderizado del minimapa 2D.
* **`UnitCardManager.js`**:
* Controla el panel lateral izquierdo con las fichas de Héroes y Monstruos.
* Maneja los botones de acción contextual (Atacar, Disparar, Inventario).
* **`TurnStatusUI.js`**:
* Panel superior central. Muestra Fase actual, Turno y botón de "Fin de Fase".
* Visualiza los resultados de la Fase de Poder.
* **`PlacementUI.js`**:
* Interfaz específica para la colocación de losetas (flechas de control, rotar, confirmar/cancelar).
* **`FeedbackUI.js`**:
* Sistema centralizado de comunicación con el usuario.
* Gestiona Modales, Ventanas de Confirmación y Mensajes Flotantes.
* Implementa el **Log de Combate** (anteriormente notificación simple).
* **`SpellbookUI.js`**:
* Módulo independiente para el libro de hechizos visual del Mago.
#### 2. UIManager como Orquestador
El archivo principal `UIManager.js` se ha reducido drásticamente (~140 líneas). Ahora actúa únicamente como "pegamento":
- Inicializa los subsistemas.
- Escucha eventos del `GameEngine` (selección de entidades, cambio de fase).
- Delega la actualización de la interfaz a los módulos correspondientes.
### Estado Actual
La refactorización es totalmente transparente para el usuario final (la funcionalidad visual se mantiene idéntica), pero el código es ahora robusto, mantenible y listo para crecer sin convertirse en código espagueti.
### Próximos Pasos
- Implementar la Gestión de Inventario real.
- Pulir efectos visuales de hechizos y combate.
---
## Sesión 9: Pulido de Combate, UI de Hechizos y Buffs
**Fecha:** 8 de Enero de 2026
@@ -420,5 +466,3 @@ Establecimiento de la base completa del motor de juego con generación procedime
- ✅ Visualización 3D con Three.js
- ✅ Sistema de cámara isométrica
- ✅ Carga de texturas y assets

File diff suppressed because it is too large Load Diff

196
src/view/ui/FeedbackUI.js Normal file
View File

@@ -0,0 +1,196 @@
export class FeedbackUI {
constructor(parentContainer, game) {
this.parentContainer = parentContainer;
this.game = game; // Needed for resolving hero names/ids in logs?
this.combatLogContainer = null;
this.initCombatLogContainer();
}
initCombatLogContainer() {
this.combatLogContainer = document.createElement('div');
Object.assign(this.combatLogContainer.style, {
position: 'absolute',
top: '140px', // Below the top status panel
left: '50%',
transform: 'translateX(-50%)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
pointerEvents: 'none',
width: '100%',
maxWidth: '600px',
zIndex: '500' // Below modals
});
this.parentContainer.appendChild(this.combatLogContainer);
}
showModal(title, message, onClose) {
const overlay = document.createElement('div');
Object.assign(overlay.style, {
position: 'absolute', top: '0', left: '0', width: '100%', height: '100%',
backgroundColor: 'rgba(0, 0, 0, 0.7)', display: 'flex', justifyContent: 'center', alignItems: 'center',
pointerEvents: 'auto', zIndex: '1000'
});
const content = document.createElement('div');
Object.assign(content.style, {
backgroundColor: '#222', border: '2px solid #888', borderRadius: '8px', padding: '20px',
width: '300px', textAlign: 'center', color: '#fff', fontFamily: 'sans-serif'
});
const titleEl = document.createElement('h2');
titleEl.textContent = title;
Object.assign(titleEl.style, { marginTop: '0', color: '#f44' });
content.appendChild(titleEl);
const msgEl = document.createElement('p');
msgEl.innerHTML = message;
Object.assign(msgEl.style, { fontSize: '16px', lineHeight: '1.5' });
content.appendChild(msgEl);
const btn = document.createElement('button');
btn.textContent = 'Entendido';
Object.assign(btn.style, {
marginTop: '20px', padding: '10px 20px', fontSize: '16px', cursor: 'pointer',
backgroundColor: '#444', color: '#fff', border: '1px solid #888'
});
btn.onclick = () => {
if (overlay.parentNode /** Checks if attached */) this.parentContainer.removeChild(overlay);
if (onClose) onClose();
};
content.appendChild(btn);
overlay.appendChild(content);
this.parentContainer.appendChild(overlay);
}
showConfirm(title, message, onConfirm) {
const overlay = document.createElement('div');
Object.assign(overlay.style, {
position: 'absolute', top: '0', left: '0', width: '100%', height: '100%',
backgroundColor: 'rgba(0, 0, 0, 0.7)', display: 'flex', justifyContent: 'center', alignItems: 'center',
pointerEvents: 'auto', zIndex: '1000'
});
const content = document.createElement('div');
Object.assign(content.style, {
backgroundColor: '#222', border: '2px solid #888', borderRadius: '8px', padding: '20px',
width: '300px', textAlign: 'center', color: '#fff', fontFamily: 'sans-serif'
});
const titleEl = document.createElement('h2');
titleEl.textContent = title;
Object.assign(titleEl.style, { marginTop: '0', color: '#f44' });
content.appendChild(titleEl);
const msgEl = document.createElement('p');
msgEl.innerHTML = message;
Object.assign(msgEl.style, { fontSize: '16px', lineHeight: '1.5' });
content.appendChild(msgEl);
const buttons = document.createElement('div');
Object.assign(buttons.style, { display: 'flex', justifyContent: 'space-around', marginTop: '20px' });
const cancelBtn = document.createElement('button');
cancelBtn.textContent = 'Cancelar';
Object.assign(cancelBtn.style, {
padding: '10px 20px', fontSize: '16px', cursor: 'pointer',
backgroundColor: '#555', color: '#fff', border: '1px solid #888'
});
cancelBtn.onclick = () => { this.parentContainer.removeChild(overlay); };
buttons.appendChild(cancelBtn);
const confirmBtn = document.createElement('button');
confirmBtn.textContent = 'Aceptar';
Object.assign(confirmBtn.style, {
padding: '10px 20px', fontSize: '16px', cursor: 'pointer',
backgroundColor: '#2a5', color: '#fff', border: '1px solid #888'
});
confirmBtn.onclick = () => {
if (onConfirm) onConfirm();
this.parentContainer.removeChild(overlay);
};
buttons.appendChild(confirmBtn);
content.appendChild(buttons);
overlay.appendChild(content);
this.parentContainer.appendChild(overlay);
}
showTemporaryMessage(title, message, duration = 2000) {
const modal = document.createElement('div');
Object.assign(modal.style, {
position: 'absolute', top: '25%', left: '50%', transform: 'translate(-50%, -50%)',
backgroundColor: 'rgba(139, 0, 0, 0.9)', color: '#fff', padding: '15px 30px',
borderRadius: '8px', border: '2px solid #ff4444', fontFamily: '"Cinzel", serif',
fontSize: '20px', textShadow: '2px 2px 4px black', zIndex: '2000', pointerEvents: 'none',
opacity: '0', transition: 'opacity 0.5s ease-in-out'
});
modal.innerHTML = `
<h3 style="margin:0; text-align:center; color: #FFD700; text-transform: uppercase;">⚠️ ${title}</h3>
<div style="margin-top:5px; font-size: 16px;">${message}</div>
`;
document.body.appendChild(modal);
requestAnimationFrame(() => { modal.style.opacity = '1'; });
setTimeout(() => {
modal.style.opacity = '0';
setTimeout(() => {
if (modal.parentNode) document.body.removeChild(modal);
}, 500);
}, duration);
}
showCombatLog(log) {
const isHit = log.hitSuccess;
const color = isHit ? '#ff4444' : '#aaaaaa';
let detailHtml = '';
if (isHit) {
if (log.woundsCaused > 0) {
detailHtml = `<div style="font-size: 24px; color: #ff0000; font-weight:bold;">-${log.woundsCaused} HP</div>`;
} else {
detailHtml = `<div style="font-size: 20px; color: #aaa;">Sin Heridas (Armadura)</div>`;
}
} else {
detailHtml = `<div style="font-size: 18px; color: #888;">Esquivado / Fallado</div>`;
}
// We create a new log element or update a singleton?
// The original logic updated a SINGLE notification area.
// Let's create a transient toast style log here, appending to container.
const logItem = document.createElement('div');
Object.assign(logItem.style, {
backgroundColor: 'rgba(0,0,0,0.9)', padding: '15px', border: `2px solid ${color}`,
borderRadius: '5px', textAlign: 'center', minWidth: '250px', marginBottom: '10px',
fontFamily: '"Cinzel", serif', opacity: '0', transition: 'opacity 0.3s'
});
logItem.innerHTML = `
<div style="font-size: 18px; color: ${color}; margin-bottom: 5px; text-transform:uppercase;">${log.attackerId.split('_')[0]} ATACA</div>
${detailHtml}
<div style="font-size: 14px; color: #ccc; margin-top:5px;">${log.message}</div>
`;
// Clear previous logs to act like the single notification area of before, OR stack them?
// Original behavior was overwrite `innerHTML`. I should stick to that to avoid spam.
// So I will clear `combatLogContainer` before adding.
this.combatLogContainer.innerHTML = '';
this.combatLogContainer.appendChild(logItem);
// Fade in
requestAnimationFrame(() => { logItem.style.opacity = '1'; });
// Fade out
setTimeout(() => {
logItem.style.opacity = '0';
// We don't remove immediately to avoid layout jumps if another comes in,
// but we cleared logic above.
}, 3500);
}
}

256
src/view/ui/HUDManager.js Normal file
View File

@@ -0,0 +1,256 @@
import { DIRECTIONS } from '../../engine/dungeon/Constants.js';
export class HUDManager {
constructor(gameContainer, cameraManager, game) {
this.parentContainer = gameContainer;
this.cameraManager = cameraManager;
this.game = game; // Needed for dungeon grid access (minimap)
this.minimapCanvas = null;
this.zoomSlider = null;
this.viewButtons = [];
this.ctx = null;
this.init();
}
init() {
// --- Minimap (Top Left) ---
this.minimapCanvas = document.createElement('canvas');
this.minimapCanvas.width = 200;
this.minimapCanvas.height = 200;
Object.assign(this.minimapCanvas.style, {
position: 'absolute',
top: '10px',
left: '10px',
border: '2px solid #444',
backgroundColor: 'rgba(0, 0, 0, 0.8)',
pointerEvents: 'auto'
});
this.parentContainer.appendChild(this.minimapCanvas);
this.ctx = this.minimapCanvas.getContext('2d');
// --- Camera Controls (Top Right) ---
const controlsContainer = document.createElement('div');
Object.assign(controlsContainer.style, {
position: 'absolute',
top: '20px',
right: '20px',
display: 'flex',
gap: '10px',
alignItems: 'center',
pointerEvents: 'auto'
});
this.parentContainer.appendChild(controlsContainer);
this.createZoomControls(controlsContainer);
this.createViewControls(controlsContainer);
// Start Minimap Loop
this.setupMinimapLoop();
}
createZoomControls(container) {
const zoomContainer = document.createElement('div');
Object.assign(zoomContainer.style, {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '0px',
height: '140px'
});
const zoomLabel = document.createElement('div');
zoomLabel.textContent = 'Zoom';
Object.assign(zoomLabel.style, {
color: '#fff',
fontSize: '15px',
fontFamily: 'sans-serif',
marginBottom: '10px',
marginTop: '0px'
});
const zoomSlider = document.createElement('input');
zoomSlider.type = 'range';
zoomSlider.min = '3';
zoomSlider.max = '15';
zoomSlider.value = '6';
zoomSlider.step = '0.5';
Object.assign(zoomSlider.style, {
width: '100px',
transform: 'rotate(-90deg)',
transformOrigin: 'center',
cursor: 'pointer',
marginTop: '40px'
});
this.zoomSlider = zoomSlider;
// Sync with Camera Manager
this.cameraManager.zoomLevel = 6;
this.cameraManager.updateProjection();
this.cameraManager.onZoomChange = (val) => {
if (this.zoomSlider) this.zoomSlider.value = val;
};
zoomSlider.oninput = (e) => {
this.cameraManager.zoomLevel = parseFloat(e.target.value);
this.cameraManager.updateProjection();
};
zoomContainer.appendChild(zoomLabel);
zoomContainer.appendChild(zoomSlider);
// Add 2D/3D Toggle (Left of Zoom)
const toggleViewBtn = document.createElement('button');
toggleViewBtn.textContent = '3D';
toggleViewBtn.title = 'Cambiar vista 2D/3D';
Object.assign(toggleViewBtn.style, {
width: '40px',
height: '40px',
borderRadius: '5px',
border: '1px solid #aaa',
backgroundColor: 'rgba(0, 0, 0, 0.6)',
color: '#daa520',
cursor: 'pointer',
fontFamily: '"Cinzel", serif',
fontWeight: 'bold',
fontSize: '14px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
});
toggleViewBtn.onmouseover = () => { toggleViewBtn.style.backgroundColor = 'rgba(0, 0, 0, 0.9)'; toggleViewBtn.style.color = '#fff'; };
toggleViewBtn.onmouseout = () => { toggleViewBtn.style.backgroundColor = 'rgba(0, 0, 0, 0.6)'; toggleViewBtn.style.color = '#daa520'; };
toggleViewBtn.onclick = () => {
if (this.cameraManager) {
this.cameraManager.onAnimationComplete = null;
const isCurrently2D = (this.cameraManager.viewMode === '2D');
if (isCurrently2D && this.cameraManager.renderer) {
this.cameraManager.renderer.hideTokens();
}
const is3D = this.cameraManager.toggleViewMode();
toggleViewBtn.textContent = is3D ? '3D' : '2D';
if (!is3D) {
this.cameraManager.onAnimationComplete = () => {
if (this.cameraManager.renderer) {
this.cameraManager.renderer.showTokens(this.game.heroes, this.game.monsters);
}
};
}
}
};
container.appendChild(toggleViewBtn);
container.appendChild(zoomContainer);
}
createViewControls(container) {
const buttonsGrid = document.createElement('div');
Object.assign(buttonsGrid.style, {
display: 'grid',
gridTemplateColumns: '40px 40px 40px',
gap: '5px'
});
const createBtn = (label, dir) => {
const btn = document.createElement('button');
btn.textContent = label;
Object.assign(btn.style, {
width: '40px',
height: '40px',
backgroundColor: '#333',
color: '#fff',
border: '1px solid #666',
cursor: 'pointer',
transition: 'background-color 0.2s'
});
btn.dataset.direction = dir;
btn.onclick = () => {
this.cameraManager.setIsoView(dir);
this.updateActiveViewButton(dir);
};
return btn;
};
const btnN = createBtn('N', DIRECTIONS.NORTH); btnN.style.gridColumn = '2';
const btnW = createBtn('W', DIRECTIONS.WEST); btnW.style.gridColumn = '1';
const btnE = createBtn('E', DIRECTIONS.EAST); btnE.style.gridColumn = '3';
const btnS = createBtn('S', DIRECTIONS.SOUTH); btnS.style.gridColumn = '2';
buttonsGrid.appendChild(btnN);
buttonsGrid.appendChild(btnW);
buttonsGrid.appendChild(btnE);
buttonsGrid.appendChild(btnS);
this.viewButtons = [btnN, btnE, btnS, btnW];
this.updateActiveViewButton(DIRECTIONS.NORTH);
container.appendChild(buttonsGrid);
}
updateActiveViewButton(activeDirection) {
this.viewButtons.forEach(btn => btn.style.backgroundColor = '#333');
const activeBtn = this.viewButtons.find(btn => btn.dataset.direction === activeDirection);
if (activeBtn) activeBtn.style.backgroundColor = '#f0c040';
}
setupMinimapLoop() {
const loop = () => {
this.drawMinimap();
requestAnimationFrame(loop);
};
loop();
}
drawMinimap() {
if (!this.game.dungeon) return;
const ctx = this.ctx;
const w = this.minimapCanvas.width;
const h = this.minimapCanvas.height;
ctx.clearRect(0, 0, w, h);
const cellSize = 5;
const centerX = w / 2;
const centerY = h / 2;
ctx.fillStyle = '#666';
for (const [key, tileId] of this.game.dungeon.grid.occupiedCells) {
const [x, y] = key.split(',').map(Number);
const cx = centerX + (x * cellSize);
const cy = centerY - (y * cellSize);
if (tileId.includes('room')) ctx.fillStyle = '#55a';
else ctx.fillStyle = '#aaa';
ctx.fillRect(cx, cy, cellSize, cellSize);
}
// Draw Exits
ctx.fillStyle = '#0f0';
if (this.game.dungeon.availableExits) {
this.game.dungeon.availableExits.forEach(exit => {
const ex = centerX + (exit.x * cellSize);
const ey = centerY - (exit.y * cellSize);
ctx.fillRect(ex, ey, cellSize, cellSize);
});
}
// Draw Center Cross
ctx.strokeStyle = '#f00';
ctx.beginPath();
ctx.moveTo(centerX - 5, centerY);
ctx.lineTo(centerX + 5, centerY);
ctx.moveTo(centerX, centerY - 5);
ctx.lineTo(centerX, centerY + 5);
ctx.stroke();
}
}

140
src/view/ui/PlacementUI.js Normal file
View File

@@ -0,0 +1,140 @@
export class PlacementUI {
constructor(parentContainer, game, callbacks) {
this.parentContainer = parentContainer;
this.game = game; // We need dynamic access to game.dungeon as it might change? Usually not. But we access game.dungeon.
this.callbacks = callbacks || {}; // { showModal, showConfirm }
this.placementPanel = null;
this.placementStatus = null;
this.placeBtn = null;
this.rotateBtn = null;
this.discardBtn = null;
this.init();
}
init() {
this.placementPanel = document.createElement('div');
Object.assign(this.placementPanel.style, {
position: 'absolute',
bottom: '20px',
left: '50%',
transform: 'translateX(-50%)',
display: 'none', // Hidden by default
pointerEvents: 'auto',
backgroundColor: 'rgba(0, 0, 0, 0.85)',
padding: '15px',
borderRadius: '8px',
border: '2px solid #666'
});
this.parentContainer.appendChild(this.placementPanel);
// Status text
this.placementStatus = document.createElement('div');
Object.assign(this.placementStatus.style, {
color: '#fff', fontSize: '16px', fontFamily: 'sans-serif', marginBottom: '10px', textAlign: 'center'
});
this.placementStatus.textContent = 'Coloca la loseta';
this.placementPanel.appendChild(this.placementStatus);
// Controls container
const placementControls = document.createElement('div');
Object.assign(placementControls.style, { display: 'flex', gap: '15px', alignItems: 'center' });
this.placementPanel.appendChild(placementControls);
// Movement arrows
const arrowGrid = document.createElement('div');
Object.assign(arrowGrid.style, { display: 'grid', gridTemplateColumns: '40px 40px 40px', gap: '3px' });
const createArrow = (label, dx, dy) => {
const btn = document.createElement('button');
btn.textContent = label;
Object.assign(btn.style, {
width: '40px', height: '40px', backgroundColor: '#444', color: '#fff',
border: '1px solid #888', cursor: 'pointer', fontSize: '18px'
});
btn.onclick = () => {
if (this.game.dungeon) this.game.dungeon.movePlacement(dx, dy);
};
return btn;
};
const arrowUp = createArrow('↑', 0, 1); arrowUp.style.gridColumn = '2';
const arrowLeft = createArrow('←', -1, 0); arrowLeft.style.gridColumn = '1';
const arrowRight = createArrow('→', 1, 0); arrowRight.style.gridColumn = '3';
const arrowDown = createArrow('↓', 0, -1); arrowDown.style.gridColumn = '2';
arrowGrid.appendChild(arrowUp);
arrowGrid.appendChild(arrowLeft);
arrowGrid.appendChild(arrowRight);
arrowGrid.appendChild(arrowDown);
placementControls.appendChild(arrowGrid);
// Rotate button
this.rotateBtn = document.createElement('button');
this.rotateBtn.textContent = '🔄 Rotar';
Object.assign(this.rotateBtn.style, {
padding: '10px 20px', backgroundColor: '#555', color: '#fff', border: '1px solid #888',
cursor: 'pointer', fontSize: '16px', borderRadius: '4px'
});
this.rotateBtn.onclick = () => { if (this.game.dungeon) this.game.dungeon.rotatePlacement(); };
placementControls.appendChild(this.rotateBtn);
// Place button
this.placeBtn = document.createElement('button');
this.placeBtn.textContent = '⬇ Bajar';
Object.assign(this.placeBtn.style, {
padding: '10px 20px', backgroundColor: '#2a5', color: '#fff', border: '1px solid #888',
cursor: 'pointer', fontSize: '16px', borderRadius: '4px'
});
this.placeBtn.onclick = () => {
if (this.game.dungeon) {
const success = this.game.dungeon.confirmPlacement();
if (!success && this.callbacks.showModal) {
this.callbacks.showModal('Error de Colocación', 'No se puede colocar la loseta en esta posición.');
}
}
};
placementControls.appendChild(this.placeBtn);
// Discard button
this.discardBtn = document.createElement('button');
this.discardBtn.textContent = '❌ Cancelar';
Object.assign(this.discardBtn.style, {
padding: '10px 20px', backgroundColor: '#d33', color: '#fff', border: '1px solid #888',
cursor: 'pointer', fontSize: '16px', borderRadius: '4px'
});
this.discardBtn.onclick = () => {
if (this.game.dungeon && this.callbacks.showConfirm) {
this.callbacks.showConfirm(
'Confirmar acción',
'¿Quieres descartar esta loseta y bloquear la puerta?',
() => { this.game.dungeon.cancelPlacement(); }
);
}
};
placementControls.appendChild(this.discardBtn);
}
showControls(show) {
if (this.placementPanel) {
this.placementPanel.style.display = show ? 'block' : 'none';
}
}
updateStatus(isValid) {
if (this.placementStatus) {
if (isValid) {
this.placementStatus.textContent = '✅ Posición válida';
this.placementStatus.style.color = '#0f0';
this.placeBtn.style.backgroundColor = '#2a5';
this.placeBtn.style.cursor = 'pointer';
} else {
this.placementStatus.textContent = '❌ Posición inválida';
this.placementStatus.style.color = '#f44';
this.placeBtn.style.backgroundColor = '#555';
this.placeBtn.style.cursor = 'not-allowed';
}
}
}
}

110
src/view/ui/SpellbookUI.js Normal file
View File

@@ -0,0 +1,110 @@
import { SPELLS } from '../../engine/data/Spells.js';
export class SpellbookUI {
constructor(game) {
this.game = game;
this.spellBookContainer = null;
}
toggle(hero) {
if (this.spellBookContainer) {
document.body.removeChild(this.spellBookContainer);
this.spellBookContainer = null;
return;
}
const container = document.createElement('div');
Object.assign(container.style, {
position: 'absolute',
bottom: '140px',
left: '50%',
transform: 'translateX(-50%)',
display: 'flex',
gap: '15px',
backgroundColor: 'rgba(20, 10, 30, 0.9)',
padding: '20px',
borderRadius: '10px',
border: '2px solid #9933ff',
zIndex: '1500',
boxShadow: '0 0 20px rgba(100, 0, 255, 0.5)'
});
const title = document.createElement('div');
title.textContent = "LIBRO DE HECHIZOS";
Object.assign(title.style, {
position: 'absolute', top: '-30px', left: '0', width: '100%', textAlign: 'center',
color: '#d8bfff', fontFamily: '"Cinzel", serif', fontSize: '18px', textShadow: '0 0 5px #8a2be2'
});
container.appendChild(title);
SPELLS.forEach(spell => {
const canCast = this.game.canCastSpell(spell);
const card = document.createElement('div');
Object.assign(card.style, {
width: '180px', height: '260px', position: 'relative', cursor: canCast ? 'pointer' : 'not-allowed',
transition: 'transform 0.2s', filter: canCast ? 'none' : 'grayscale(100%) brightness(50%)',
backgroundImage: this.getSpellTemplate(spell.type), backgroundSize: 'cover'
});
if (canCast) {
card.onmouseenter = () => { card.style.transform = 'scale(1.1) translateY(-10px)'; card.style.zIndex = '10'; };
card.onmouseleave = () => { card.style.transform = 'scale(1)'; card.style.zIndex = '1'; };
card.onclick = (e) => {
e.stopPropagation();
document.body.removeChild(this.spellBookContainer);
this.spellBookContainer = null;
if (spell.type === 'attack' || spell.type === 'defense') {
this.game.startSpellTargeting(spell);
} else {
// Global/Instant
this.game.executeSpell(spell);
}
};
}
// Cost Badge
const costBadge = document.createElement('div');
costBadge.textContent = spell.cost;
Object.assign(costBadge.style, {
position: 'absolute', top: '12px', left: '12px', width: '30px', height: '30px', borderRadius: '50%',
backgroundColor: '#fff', color: '#000', fontWeight: 'bold', display: 'flex', alignItems: 'center', justifyContent: 'center',
border: '2px solid #000', fontSize: '18px', fontFamily: 'serif'
});
card.appendChild(costBadge);
// Name
const nameEl = document.createElement('div');
nameEl.textContent = spell.name.toUpperCase();
Object.assign(nameEl.style, {
position: 'absolute', top: '45px', width: '100%', textAlign: 'center', fontSize: '14px',
color: '#000', fontWeight: 'bold', fontFamily: '"Cinzel", serif', padding: '0 10px', boxSizing: 'border-box'
});
card.appendChild(nameEl);
// Description
const descEl = document.createElement('div');
descEl.textContent = spell.description;
Object.assign(descEl.style, {
position: 'absolute', bottom: '30px', left: '10px', width: '160px', height: '80px',
fontSize: '11px', color: '#000', textAlign: 'center', display: 'flex', alignItems: 'center',
justifyContent: 'center', fontFamily: 'serif', lineHeight: '1.2'
});
card.appendChild(descEl);
container.appendChild(card);
});
document.body.appendChild(container);
this.spellBookContainer = container;
}
getSpellTemplate(type) {
let filename = 'attack_template.png';
if (type === 'heal') filename = 'healing_template.png';
if (type === 'defense') filename = 'defense_template.png';
return `url('/assets/images/dungeon1/spells/${filename}')`;
}
}

172
src/view/ui/TurnStatusUI.js Normal file
View File

@@ -0,0 +1,172 @@
export class TurnStatusUI {
constructor(parentContainer, game) {
this.parentContainer = parentContainer;
this.game = game;
this.statusPanel = null;
this.phaseInfo = null;
this.endPhaseBtn = null;
this.notificationArea = null;
this.init();
}
init() {
this.statusPanel = document.createElement('div');
Object.assign(this.statusPanel.style, {
position: 'absolute',
top: '20px',
left: '50%',
transform: 'translateX(-50%)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
pointerEvents: 'none'
});
// Turn/Phase Info
this.phaseInfo = document.createElement('div');
Object.assign(this.phaseInfo.style, {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
padding: '10px 20px',
border: '2px solid #daa520',
borderRadius: '5px',
color: '#fff',
fontFamily: '"Cinzel", serif',
fontSize: '20px',
textAlign: 'center',
textTransform: 'uppercase',
width: '300px'
});
this.phaseInfo.innerHTML = `
<div style="font-size: 14px; color: #aaa;">Turn 1</div>
<div style="font-size: 24px; color: #daa520;">Setup</div>
`;
this.statusPanel.appendChild(this.phaseInfo);
// End Phase Button
this.endPhaseBtn = document.createElement('button');
this.endPhaseBtn.textContent = 'ACABAR FASE AVENTUREROS';
Object.assign(this.endPhaseBtn.style, {
marginTop: '10px',
width: '300px',
padding: '8px',
backgroundColor: '#daa520',
color: '#000',
border: '1px solid #8B4513',
borderRadius: '3px',
fontWeight: 'bold',
cursor: 'pointer',
display: 'none',
fontFamily: '"Cinzel", serif',
fontSize: '12px',
pointerEvents: 'auto'
});
this.endPhaseBtn.onmouseover = () => { this.endPhaseBtn.style.backgroundColor = '#ffd700'; };
this.endPhaseBtn.onmouseout = () => { this.endPhaseBtn.style.backgroundColor = '#daa520'; };
this.endPhaseBtn.onclick = () => {
console.log('[TurnStatusUI] End Phase Button Clicked', this.game.turnManager.currentPhase);
this.game.turnManager.nextPhase();
};
this.statusPanel.appendChild(this.endPhaseBtn);
// Notification Area (Power Roll)
this.notificationArea = document.createElement('div');
Object.assign(this.notificationArea.style, {
marginTop: '10px',
maxWidth: '600px',
transition: 'opacity 0.5s',
opacity: '0'
});
this.statusPanel.appendChild(this.notificationArea);
this.parentContainer.appendChild(this.statusPanel);
// Inject Font
if (!document.getElementById('game-font')) {
const link = document.createElement('link');
link.id = 'game-font';
link.href = 'https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&display=swap';
link.rel = 'stylesheet';
document.head.appendChild(link);
}
}
updatePhaseDisplay(phase, selectedHero) {
if (!this.phaseInfo) return;
const turn = this.game.turnManager.currentTurn;
let content = `
<div style="font-size: 14px; color: #aaa;">Turn ${turn}</div>
<div style="font-size: 24px; color: #daa520;">${phase.replace('_', ' ')}</div>
`;
if (selectedHero) {
content += this.getHeroStatsHTML(selectedHero);
}
this.phaseInfo.innerHTML = content;
if (this.endPhaseBtn) {
if (phase === 'hero') {
this.endPhaseBtn.style.display = 'block';
this.endPhaseBtn.textContent = 'ACABAR FASE AVENTUREROS';
this.endPhaseBtn.title = "Pasar a la Fase de Monstruos";
} else if (phase === 'exploration') {
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';
}
}
}
updateHeroStats(hero) {
const phase = this.game.turnManager.currentPhase;
this.updatePhaseDisplay(phase, hero);
}
getHeroStatsHTML(hero) {
const portraitUrl = hero.texturePath || '';
const lanternIcon = hero.hasLantern ? '<span style="font-size: 20px; cursor: help;" title="Portador de la Lámpara">🏮</span>' : '';
return `
<div style="margin-top: 15px; border-top: 1px solid #555; paddingTop: 10px; display: flex; align-items: center; justify-content: center; gap: 15px;">
<div style="width: 50px; height: 50px; border-radius: 50%; overflow: hidden; border: 2px solid #daa520; background: #000;">
<img src="${portraitUrl}" style="width: 100%; height: 100%; object-fit: cover;" alt="${hero.name}">
</div>
<div style="text-align: left;">
<div style="color: #daa520; font-weight: bold; font-size: 16px;">
${hero.name} ${lanternIcon}
</div>
<div style="font-size: 14px;">
Moves: <span style="color: ${hero.currentMoves > 0 ? '#4f4' : '#f44'}; font-weight: bold;">${hero.currentMoves}</span> / ${hero.stats.move}
</div>
</div>
</div>
`;
}
showPowerRollResult(data) {
if (!this.notificationArea) return;
const { roll, message, eventTriggered } = data;
const color = eventTriggered ? '#ff4444' : '#44ff44';
this.notificationArea.innerHTML = `
<div style="background-color: rgba(0,0,0,0.9); padding: 15px; border: 1px solid ${color}; border-radius: 5px; text-align: center;">
<div style="font-family: 'Cinzel'; font-size: 18px; color: #fff; margin-bottom: 5px;">Power Phase</div>
<div style="font-size: 40px; font-weight: bold; color: ${color};">${roll}</div>
<div style="font-size: 14px; color: #ccc;">${message}</div>
</div>
`;
this.notificationArea.style.opacity = '1';
setTimeout(() => {
if (this.notificationArea) this.notificationArea.style.opacity = '0';
}, 3000);
}
}

View File

@@ -0,0 +1,382 @@
export class UnitCardManager {
constructor(parentContainer, game, callbacks) {
this.parentContainer = parentContainer;
this.game = game;
this.callbacks = callbacks || {}; // { showModal, toggleSpellBook }
this.cardsContainer = null;
this.currentHeroCard = null;
this.currentMonsterCard = null;
this.placeholderCard = null;
this.attackButton = null;
this.init();
}
init() {
this.cardsContainer = document.createElement('div');
Object.assign(this.cardsContainer.style, {
position: 'absolute',
left: '10px',
top: '220px', // Below minimap
display: 'flex',
flexDirection: 'column',
gap: '10px',
pointerEvents: 'auto',
width: '200px'
});
this.parentContainer.appendChild(this.cardsContainer);
this.createPlaceholderCard();
}
createPlaceholderCard() {
const card = document.createElement('div');
Object.assign(card.style, {
width: '180px',
height: '280px',
backgroundColor: 'rgba(20, 20, 20, 0.95)',
border: '2px solid #8B4513',
borderRadius: '8px',
padding: '10px',
fontFamily: '"Cinzel", serif',
color: '#888',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center'
});
const iconContainer = document.createElement('div');
Object.assign(iconContainer.style, {
width: '100px',
height: '100px',
borderRadius: '50%',
border: '2px solid #8B4513',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: '20px'
});
const icon = document.createElement('div');
icon.textContent = '🎴';
icon.style.fontSize = '48px';
iconContainer.appendChild(icon);
card.appendChild(iconContainer);
const text = document.createElement('div');
text.textContent = 'Selecciona un Aventurero';
text.style.fontSize = '14px';
text.style.color = '#DAA520';
card.appendChild(text);
this.placeholderCard = card;
this.cardsContainer.appendChild(card);
}
showHeroCard(hero) {
if (this.placeholderCard && this.placeholderCard.parentNode) {
this.cardsContainer.removeChild(this.placeholderCard);
}
if (this.currentHeroCard && this.currentHeroCard.parentNode) {
this.cardsContainer.removeChild(this.currentHeroCard);
}
this.currentHeroCard = this.createHeroCard(hero);
this.cardsContainer.insertBefore(this.currentHeroCard, this.cardsContainer.firstChild);
}
hideHeroCard() {
if (this.currentHeroCard && this.currentHeroCard.parentNode) {
this.cardsContainer.removeChild(this.currentHeroCard);
this.currentHeroCard = null;
}
// Show placeholder only if no cards are visible
if (!this.currentMonsterCard && this.placeholderCard && !this.placeholderCard.parentNode) {
this.cardsContainer.appendChild(this.placeholderCard);
}
}
updateHeroCard(heroId) {
if (!this.currentHeroCard || this.currentHeroCard.dataset.heroId !== heroId) return;
const hero = this.game.heroes.find(h => h.id === heroId);
if (!hero) return;
const statsGrid = this.currentHeroCard.querySelector('div[style*="grid-template-columns"]');
if (statsGrid) {
const statDivs = statsGrid.children;
// Assumed order: 4 -> Heridas, 7 -> Movimiento
if (statDivs[4]) {
const wValue = statDivs[4].querySelector('span:last-child');
if (wValue) wValue.textContent = `${hero.currentWounds || hero.stats.wounds}/${hero.stats.wounds}`;
}
if (statDivs[7]) {
const movValue = statDivs[7].querySelector('span:last-child');
if (movValue) movValue.textContent = `${hero.currentMoves || 0}/${hero.stats.move}`;
}
}
}
createHeroCard(hero) {
const card = document.createElement('div');
Object.assign(card.style, {
width: '180px',
backgroundColor: 'rgba(20, 20, 20, 0.95)',
border: '2px solid #8B4513',
borderRadius: '8px',
padding: '10px',
fontFamily: '"Cinzel", serif',
color: '#fff',
transition: 'all 0.3s',
cursor: 'pointer'
});
card.onmouseenter = () => { card.style.borderColor = '#DAA520'; card.style.transform = 'scale(1.05)'; };
card.onmouseleave = () => { card.style.borderColor = '#8B4513'; card.style.transform = 'scale(1)'; };
card.onclick = () => { if (this.game.onCellClick) this.game.onCellClick(hero.x, hero.y); };
// Portrait
const portrait = document.createElement('div');
Object.assign(portrait.style, {
width: '100px',
height: '100px',
borderRadius: '50%',
overflow: 'hidden',
border: '2px solid #DAA520',
marginBottom: '8px',
marginLeft: 'auto',
marginRight: 'auto',
backgroundColor: '#000',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
});
const tokenPath = `/assets/images/dungeon1/tokens/heroes/${hero.key}.png?v=2`;
const img = document.createElement('img');
img.src = tokenPath;
Object.assign(img.style, { width: '100%', height: '100%', objectFit: 'cover' });
img.onerror = () => { portrait.innerHTML = `<div style="color: #DAA520; font-size: 48px;">?</div>`; };
portrait.appendChild(img);
card.appendChild(portrait);
// Name
const name = document.createElement('div');
name.textContent = hero.name;
Object.assign(name.style, {
fontSize: '16px', fontWeight: 'bold', color: '#DAA520', textAlign: 'center', marginBottom: '8px', textTransform: 'uppercase'
});
card.appendChild(name);
if (hero.hasLantern) {
const lantern = document.createElement('div');
lantern.textContent = '🏮 Portador de la Lámpara';
Object.assign(lantern.style, { fontSize: '10px', color: '#FFA500', textAlign: 'center', marginBottom: '8px' });
card.appendChild(lantern);
}
// Stats
const statsGrid = document.createElement('div');
Object.assign(statsGrid.style, { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px', fontSize: '12px', marginBottom: '8px' });
const stats = [
{ label: 'H.C', value: hero.stats.ws || 0 },
{ label: 'H.P', value: hero.stats.bs || 0 },
{ label: 'Fuer', value: hero.stats.str || 0 },
{ label: 'Res', value: hero.stats.toughness || 0 },
{ label: 'Her', value: `${hero.currentWounds || hero.stats.wounds}/${hero.stats.wounds}` },
{ label: 'Ini', value: hero.stats.initiative || 0 },
{ label: 'Ata', value: hero.stats.attacks || 0 },
{ label: 'Mov', value: `${hero.currentMoves || 0}/${hero.stats.move}` }
];
stats.forEach(stat => {
const el = document.createElement('div');
Object.assign(el.style, { backgroundColor: 'rgba(0, 0, 0, 0.5)', padding: '3px 5px', borderRadius: '3px', display: 'flex', justifyContent: 'space-between' });
const l = document.createElement('span'); l.textContent = stat.label + ':'; l.style.color = '#AAA';
const v = document.createElement('span'); v.textContent = stat.value; v.style.color = '#FFF'; v.style.fontWeight = 'bold';
el.appendChild(l); el.appendChild(v);
statsGrid.appendChild(el);
});
card.appendChild(statsGrid);
// Elf Bow Button
if (hero.key === 'elf') {
const isPinned = this.game.isEntityPinned(hero);
const hasAttacked = hero.hasAttacked;
const bowBtn = document.createElement('button');
bowBtn.textContent = hasAttacked ? '🏹 YA DISPARADO' : '🏹 DISPARAR ARCO';
Object.assign(bowBtn.style, {
width: '100%', padding: '8px', marginTop: '8px',
color: '#fff', border: '1px solid #fff', borderRadius: '4px',
fontFamily: '"Cinzel", serif', cursor: (isPinned || hasAttacked) ? 'not-allowed' : 'pointer',
backgroundColor: (isPinned || hasAttacked) ? '#555' : '#2E8B57'
});
if (isPinned) bowBtn.title = "¡Estás trabado en combate cuerpo a cuerpo!";
else if (hasAttacked) bowBtn.title = "Ya has atacado en esta fase.";
else {
bowBtn.onclick = (e) => {
e.stopPropagation();
this.game.startRangedTargeting();
if (this.callbacks.showModal) this.callbacks.showModal('Modo Disparo', 'Selecciona un enemigo visible para disparar.');
};
}
card.appendChild(bowBtn);
}
// Inventory
const invBtn = document.createElement('button');
invBtn.textContent = '🎒 INVENTARIO';
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'
});
invBtn.title = 'Inventario (Próximamente)';
card.appendChild(invBtn);
// Wizard Spells
if (hero.key === 'wizard') {
const spellsBtn = document.createElement('button');
spellsBtn.textContent = '🔮 HECHIZOS';
Object.assign(spellsBtn.style, {
width: '100%', padding: '8px', marginTop: '5px', backgroundColor: '#4b0082',
color: '#fff', border: '1px solid #8a2be2', borderRadius: '4px',
fontFamily: '"Cinzel", serif', cursor: 'pointer'
});
spellsBtn.onclick = (e) => {
e.stopPropagation();
if (this.callbacks.toggleSpellBook) this.callbacks.toggleSpellBook(hero);
};
card.appendChild(spellsBtn);
}
card.dataset.heroId = hero.id;
return card;
}
createMonsterCard(monster) {
const card = document.createElement('div');
Object.assign(card.style, {
width: '180px', backgroundColor: 'rgba(40, 20, 20, 0.95)', border: '2px solid #8B0000',
borderRadius: '8px', padding: '10px', fontFamily: '"Cinzel", serif', color: '#fff'
});
// Portrait
const portrait = document.createElement('div');
Object.assign(portrait.style, {
width: '100px', height: '100px', borderRadius: '50%', overflow: 'hidden',
border: '2px solid #8B0000', marginBottom: '8px', marginLeft: 'auto', marginRight: 'auto',
backgroundColor: '#000', display: 'flex', alignItems: 'center', justifyContent: 'center'
});
const img = document.createElement('img');
img.src = `/assets/images/dungeon1/tokens/enemies/${monster.key}.png?v=2`;
Object.assign(img.style, { width: '100%', height: '100%', objectFit: 'cover' });
img.onerror = () => { portrait.innerHTML = `<div style="color: #8B0000; font-size: 48px;">👹</div>`; };
portrait.appendChild(img);
card.appendChild(portrait);
// Name
const name = document.createElement('div');
name.textContent = monster.name;
Object.assign(name.style, {
fontSize: '16px', fontWeight: 'bold', color: '#FF4444', textAlign: 'center', marginBottom: '8px', textTransform: 'uppercase'
});
card.appendChild(name);
// Stats
const statsGrid = document.createElement('div');
Object.assign(statsGrid.style, { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px', fontSize: '12px' });
const stats = [
{ label: 'H.C', value: monster.stats.ws || 0 },
{ label: 'Fuer', value: monster.stats.str || 0 },
{ label: 'Res', value: monster.stats.toughness || 0 },
{ label: 'Her', value: `${monster.currentWounds || monster.stats.wounds}/${monster.stats.wounds}` },
{ label: 'Ini', value: monster.stats.initiative || 0 },
{ label: 'Ata', value: monster.stats.attacks || 0 }
];
stats.forEach(stat => {
const el = document.createElement('div');
Object.assign(el.style, { backgroundColor: 'rgba(0, 0, 0, 0.5)', padding: '3px 5px', borderRadius: '3px', display: 'flex', justifyContent: 'space-between' });
const l = document.createElement('span'); l.style.color = '#AAA'; l.textContent = stat.label + ':';
const v = document.createElement('span'); v.style.color = '#FFF'; v.textContent = stat.value; v.style.fontWeight = 'bold';
el.appendChild(l); el.appendChild(v);
statsGrid.appendChild(el);
});
card.appendChild(statsGrid);
card.dataset.monsterId = monster.id;
return card;
}
showMonsterCard(monster) {
this.hideMonsterCard();
this.currentMonsterCard = this.createMonsterCard(monster);
this.cardsContainer.appendChild(this.currentMonsterCard);
this.attackButton = document.createElement('button');
this.attackButton.textContent = '⚔️ ATACAR';
Object.assign(this.attackButton.style, {
width: '180px', padding: '12px', backgroundColor: '#8B0000', color: '#fff',
border: '2px solid #FF4444', borderRadius: '8px', fontFamily: '"Cinzel", serif',
fontSize: '16px', fontWeight: 'bold', cursor: 'pointer', transition: 'all 0.2s'
});
this.attackButton.onmouseenter = () => { this.attackButton.style.backgroundColor = '#FF0000'; this.attackButton.style.transform = 'scale(1.05)'; };
this.attackButton.onmouseleave = () => { this.attackButton.style.backgroundColor = '#8B0000'; this.attackButton.style.transform = 'scale(1)'; };
this.attackButton.onclick = () => {
if (this.game.performHeroAttack) {
const result = this.game.performHeroAttack(monster.id);
if (result && result.success) {
this.hideMonsterCard();
// Optional: deselect monster logic if managed externally
if (this.game.selectedMonster) {
if (this.game.onEntitySelect) this.game.onEntitySelect(this.game.selectedMonster.id, false);
this.game.selectedMonster = null;
}
}
}
};
this.cardsContainer.appendChild(this.attackButton);
}
showRangedAttackUI(monster) {
this.showMonsterCard(monster); // Creates button as "ATACAR"
if (this.attackButton) {
this.attackButton.textContent = '🏹 DISPARAR';
this.attackButton.style.backgroundColor = '#2E8B57';
this.attackButton.style.border = '2px solid #32CD32';
this.attackButton.onclick = () => {
const result = this.game.performRangedAttack(monster.id);
if (result && result.success) {
this.game.cancelTargeting();
this.hideMonsterCard();
}
};
this.attackButton.onmouseenter = () => { this.attackButton.style.backgroundColor = '#3CB371'; this.attackButton.style.transform = 'scale(1.05)'; };
this.attackButton.onmouseleave = () => { this.attackButton.style.backgroundColor = '#2E8B57'; this.attackButton.style.transform = 'scale(1)'; };
}
}
hideMonsterCard() {
if (this.currentMonsterCard && this.currentMonsterCard.parentNode) {
this.cardsContainer.removeChild(this.currentMonsterCard);
this.currentMonsterCard = null;
}
if (this.attackButton && this.attackButton.parentNode) {
this.cardsContainer.removeChild(this.attackButton);
this.attackButton = null;
}
}
}