Refactor: UIManager modularization and Devlog update
This commit is contained in:
48
DEVLOG.md
48
DEVLOG.md
@@ -1,5 +1,51 @@
|
|||||||
# Devlog - Warhammer Quest (Versión Web 3D)
|
# 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
|
## Sesión 9: Pulido de Combate, UI de Hechizos y Buffs
|
||||||
**Fecha:** 8 de Enero de 2026
|
**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
|
- ✅ Visualización 3D con Three.js
|
||||||
- ✅ Sistema de cámara isométrica
|
- ✅ Sistema de cámara isométrica
|
||||||
- ✅ Carga de texturas y assets
|
- ✅ Carga de texturas y assets
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
196
src/view/ui/FeedbackUI.js
Normal file
196
src/view/ui/FeedbackUI.js
Normal 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
256
src/view/ui/HUDManager.js
Normal 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
140
src/view/ui/PlacementUI.js
Normal 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
110
src/view/ui/SpellbookUI.js
Normal 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
172
src/view/ui/TurnStatusUI.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
382
src/view/ui/UnitCardManager.js
Normal file
382
src/view/ui/UnitCardManager.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user