feat: spell book UI, iron skin spell, buffs system and devlog update

This commit is contained in:
2026-01-08 23:35:01 +01:00
parent f2f399c296
commit 0685c1249e
10 changed files with 387 additions and 47 deletions

View File

@@ -379,3 +379,45 @@ 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
## Sesión 9: Pulido de Combate, UI de Hechizos y Buffs
**Fecha:** 8 de Enero de 2026
### Objetivos
- Resolver la duplicación de animaciones en el ataque de los monstruos.
- Mejorar la interfaz de usuario para el manejo de hechizos (Libro de Hechizos Visual).
- Implementar validaciones de línea de visión (LOS) en el lanzamiento de hechizos.
- Añadir nuevos hechizos ("Piel de Hierro") y sistema de duración de efectos (Buffs).
### Cambios Realizados
#### 1. Corrección de Animaciones y Audio
- **Doble Animación**: Se eliminó la llamada redundante a `onEntityHit` dentro de `MonsterAI.js`. Ahora el feedback visual (destello rojo/temblor) se delega exclusivamente a `game.onCombatResult`, unificando el flujo entre héroes y monstruos y evitando que la animación se dispare dos veces.
- **Audio**: Se investigó el retraso en el audio del golpe. Se decidió mantener el sonido actual (`sword1.mp3`) por el momento.
#### 2. Interfaz de Usuario (UI)
- **Botón de Inventario**: Añadido un botón placeholder "🎒 INVENTARIO" a las fichas de todos los aventureros.
- **Libro de Hechizos (Mago)**:
- Se reemplazó la lista de botones de texto por un sistema visual de cartas.
- Al hacer clic en "HECHIZOS", se despliega una mano de cartas generadas dinámicamente con plantillas (`attack_template`, `defense_template`, `healing_template`).
- Las cartas muestran el coste de poder en la esquina y se oscurecen si no hay maná suficiente.
- Implementado cierre automático al seleccionar o hacer clic fuera.
#### 3. Sistema de Magia y Buffs
- **Validación LOS**: Corregido bug donde "Bola de Fuego" podía lanzarse a través de muros aunque la previsualización mostrara rojo. Ahora `onCellClick` valida estrictamente la línea de visión antes de ejecutar.
- **Nuevo Hechizo: Piel de Hierro**:
- Coste: 5. Tipo: Defensa.
- Efecto: Otorga +2 a Resistencia durante 1 turno.
- Requiere selección de objetivo (héroe).
- **Sistema de Buffs Temporales**:
- Implementado evento `turn_ended` en `TurnManager`.
- Añadido método `handleEndTurn` en `GameEngine` para gestionar la duración de los efectos.
- Los buffs ahora se limpian automáticamente cuando su duración llega a 0, revirtiendo los cambios en las estadísticas.
### Estado Actual
El combate se siente mucho más sólido sin las animaciones dobles. La interfaz del mago es ahora visualmente atractiva y funcional. El sistema de magia soporta hechizos de defensa y buffs con duración limitada, abriendo la puerta a mecánicas más complejas.
### Próximos Pasos
- Implementar la funcionalidad real del Inventario.
- Añadir más cartas/hechizos y refinar el diseño visual de los textos en las cartas.
- Ajustar el timing del sonido de ataque para sincronizarlo perfectamente con la animación de impacto.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

View File

@@ -4,18 +4,32 @@ export const SPELLS = [
id: 'fireball',
name: 'Bola de Fuego',
type: 'attack',
cost: 1,
cost: 5,
range: 12, // Arbitrary line of sight
damageDice: 1,
damageBonus: 'hero_level', // Dynamic logic
area: 2, // 2x2
description: "Elige un área de 2x2 casillas en línea de visión. Cada miniatura sufre 1D6 + Nivel herois."
},
{
id: 'iron_skin',
name: 'Piel de Hierro',
type: 'defense',
cost: 1,
range: 'board', // Anywhere on board
target: 'single_hero', // Needs selection
effect: {
stat: 'toughness',
value: 2,
duration: 1
},
description: "Elige a un Aventurero. +2 a Resistencia durante este turno."
},
{
id: 'healing_hands',
name: 'Manos Curadoras',
type: 'heal',
cost: 1,
cost: 2,
range: 'board', // Same board section
healAmount: 1,
target: 'all_heroes',

View File

@@ -53,6 +53,11 @@ export class GameEngine {
this.resetHeroMoves();
}
});
// End of Turn Logic (Buffs, cooldowns, etc)
this.turnManager.on('turn_ended', (turn) => {
this.handleEndTurn();
});
}
resetHeroMoves() {
@@ -64,6 +69,49 @@ export class GameEngine {
console.log("Refilled Hero Moves");
}
handleEndTurn() {
console.log("[GameEngine] Handling End of Turn Effects...");
if (!this.heroes) return;
this.heroes.forEach(hero => {
if (hero.buffs && hero.buffs.length > 0) {
// Decrement duration
hero.buffs.forEach(buff => {
buff.duration--;
});
// Remove expired
const activeBuffs = [];
const expiredBuffs = [];
hero.buffs.forEach(buff => {
if (buff.duration > 0) {
activeBuffs.push(buff);
} else {
expiredBuffs.push(buff);
}
});
// Revert expired
expiredBuffs.forEach(buff => {
if (buff.stat === 'toughness') {
hero.stats.toughness -= buff.value;
if (hero.tempStats && hero.tempStats.toughnessBonus) {
hero.tempStats.toughnessBonus -= buff.value;
}
console.log(`[GameEngine] Buff expired: ${buff.id} on ${hero.name}. -${buff.value} ${buff.stat}`);
if (this.onShowMessage) {
this.onShowMessage("Efecto Finalizado", `La ${buff.id === 'iron_skin' ? 'Piel de Hierro' : 'Magia'} de ${hero.name} se desvanece.`);
}
}
});
hero.buffs = activeBuffs;
}
});
}
createParty() {
this.heroes = [];
this.monsters = []; // Initialize monsters array
@@ -204,17 +252,32 @@ export class GameEngine {
targetCells.push({ x: x, y: y });
}
// NEW: Enforce LOS Check before execution
const caster = this.selectedEntity;
if (caster) {
const targetObj = { x: x, y: y };
const los = this.checkLineOfSightStrict(caster, targetObj);
if (!los || !los.clear) {
if (this.onShowMessage) this.onShowMessage('Bloqueado', 'No tienes línea de visión.');
// Do NOT cancel targeting, let them try again
return;
}
}
// Execute Spell
const result = this.executeSpell(this.currentSpell, targetCells);
if (result.success) {
// Success
} else {
if (this.onShowMessage) this.onShowMessage('Fallo', result.reason || 'No se pudo lanzar el hechizo.');
}
this.cancelTargeting();
if (window.RENDERER) window.RENDERER.hideAreaPreview();
} else {
if (this.onShowMessage) this.onShowMessage('Fallo', result.reason || 'No se pudo lanzar el hechizo.');
this.cancelTargeting(); // Cancel on error? maybe keep open? usually cancel.
if (window.RENDERER) window.RENDERER.hideAreaPreview();
}
return;
}

View File

@@ -36,6 +36,8 @@ export class MagicSystem {
return this.resolveHeal(caster, spell);
} else if (spell.type === 'attack') {
return this.resolveAttack(caster, spell, targetCells);
} else if (spell.type === 'defense') {
return this.resolveDefense(caster, spell, targetCells);
}
return { success: false, reason: 'unknown_spell_type' };
@@ -156,4 +158,67 @@ export class MagicSystem {
return { success: true, type: 'attack', hits: 1 }; // Return success immediately
}
resolveDefense(caster, spell, targetCells) {
// Needs a target hero
let targetHero = null;
// Find hero in target cells
for (const cell of targetCells) {
const h = this.game.heroes.find(h => h.x === cell.x && h.y === cell.y);
if (h) {
targetHero = h;
break;
}
}
if (!targetHero) {
return { success: false, reason: 'no_target_hero' };
}
const effect = spell.effect;
if (!effect) return { success: false };
// Apply Buff
if (effect.stat === 'toughness') {
// Store original if not already stored (simple buffering)
if (!targetHero.tempStats) targetHero.tempStats = {};
// Stackable? Probably not for same spell.
// Check if already has this buff?
// For simplicity: Add modifier
if (!targetHero.tempStats.toughnessBonus) targetHero.tempStats.toughnessBonus = 0;
targetHero.tempStats.toughnessBonus += effect.value;
// Also modify actual stat for calculation access?
// Usually stats are accessed via getter or direct.
// If direct property, we modify it and store original?
// Let's modify the stat directly for now and trust Turn Manager to revert or track it.
// BETTER: modify 'toughness' in stats, store 'buff_iron_skin' in activeBuffs?
targetHero.stats.toughness += effect.value;
// Mark for cleanup (Pseudo-implementation for cleanup)
if (!targetHero.buffs) targetHero.buffs = [];
targetHero.buffs.push({
id: spell.id,
stat: 'toughness',
value: effect.value,
duration: effect.duration
});
if (this.game.onShowMessage) {
this.game.onShowMessage('Piel de Hierro', `Resistencia de ${targetHero.name} +${effect.value}`);
}
// Visual Effect
if (window.RENDERER) {
window.RENDERER.triggerVisualEffect('defense_buff', targetHero.x, targetHero.y);
// Highlight or keep aura?
}
console.log(`[MagicSystem] Applied ${spell.name} to ${targetHero.name}`);
}
return { success: true, type: 'defense', target: targetHero.name };
}
}

View File

@@ -230,9 +230,8 @@ export class MonsterAI {
// Step 2: Attack animation delay (500ms)
setTimeout(() => {
// Step 3: Trigger hit visual on defender (if hit succeeded)
if (result.hitSuccess && this.game.onEntityHit) {
this.game.onEntityHit(hero.id);
}
// Step 3: Trigger hit visual on defender REMOVED (Handled by onCombatResult)
// Step 4: Remove green ring after red ring appears (1200ms for red ring duration)
setTimeout(() => {

View File

@@ -88,6 +88,7 @@ export class TurnManager {
endTurn() {
console.log(`--- TURN ${this.currentTurn} END ---`);
this.emit('turn_ended', this.currentTurn);
this.currentTurn++;
this.startPowerPhase();
}

View File

@@ -600,50 +600,45 @@ export class UIManager {
};
}
card.appendChild(bowBtn);
} else if (hero.key === 'wizard') {
// SPELLS UI
const spellsTitle = document.createElement('div');
spellsTitle.textContent = "HECHIZOS (Poder: " + (this.game.turnManager.power || 0) + ")";
spellsTitle.style.marginTop = '10px';
spellsTitle.style.fontSize = '12px';
spellsTitle.style.fontWeight = 'bold';
spellsTitle.style.color = '#aa88ff';
spellsTitle.style.borderBottom = '1px solid #aa88ff';
card.appendChild(spellsTitle);
}
SPELLS.forEach(spell => {
const btn = document.createElement('button');
btn.textContent = `${spell.name} (${spell.cost})`;
btn.title = spell.description;
btn.style.width = '100%';
btn.style.padding = '5px';
btn.style.marginTop = '4px';
btn.style.fontSize = '11px';
btn.style.fontFamily = '"Cinzel", serif';
// INVENTORY BUTTON (For All Heroes)
const invBtn = document.createElement('button');
invBtn.textContent = '🎒 INVENTARIO';
invBtn.style.width = '100%';
invBtn.style.padding = '8px';
invBtn.style.marginTop = '8px';
invBtn.style.backgroundColor = '#444';
invBtn.style.color = '#fff';
invBtn.style.border = '1px solid #777';
invBtn.style.borderRadius = '4px';
invBtn.style.fontFamily = '"Cinzel", serif';
invBtn.style.fontSize = '12px';
invBtn.style.cursor = 'not-allowed'; // Placeholder functionality
invBtn.title = 'Inventario (Próximamente)';
card.appendChild(invBtn);
const canCast = this.game.canCastSpell(spell);
// SPELLS UI (Wizard Only)
if (hero.key === 'wizard') {
const spellsBtn = document.createElement('button');
spellsBtn.textContent = '🔮 HECHIZOS';
spellsBtn.style.width = '100%';
spellsBtn.style.padding = '8px';
spellsBtn.style.marginTop = '5px';
spellsBtn.style.backgroundColor = '#4b0082'; // Indigo
spellsBtn.style.color = '#fff';
spellsBtn.style.border = '1px solid #8a2be2';
spellsBtn.style.borderRadius = '4px';
spellsBtn.style.fontFamily = '"Cinzel", serif';
spellsBtn.style.cursor = 'pointer';
btn.style.backgroundColor = canCast ? '#4b0082' : '#333';
btn.style.color = canCast ? '#fff' : '#888';
btn.style.border = '1px solid #666';
btn.style.cursor = canCast ? 'pointer' : 'not-allowed';
if (canCast) {
btn.onclick = (e) => {
spellsBtn.onclick = (e) => {
e.stopPropagation();
if (spell.type === 'attack') {
// Use Targeting Mode
this.game.startSpellTargeting(spell);
} else {
// Healing is instant/global (no target needed for 'healing_hands')
this.game.executeSpell(spell);
}
// Toggle Spell Book UI
this.toggleSpellBook(hero);
};
}
card.appendChild(btn);
});
card.appendChild(spellsBtn);
}
card.dataset.heroId = hero.id;
@@ -1381,4 +1376,165 @@ export class UIManager {
}, 500);
}, duration);
}
toggleSpellBook(hero) {
// Close if already open
if (this.spellBookContainer) {
document.body.removeChild(this.spellBookContainer);
this.spellBookContainer = null;
return;
}
// Create Container
const container = document.createElement('div');
Object.assign(container.style, {
position: 'absolute',
bottom: '140px', // Just above placement/UI bottom area
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)'
});
// Title
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);
const currentPower = this.game.turnManager.power || 0;
// Render Spells as Cards
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();
// Close book
document.body.removeChild(this.spellBookContainer);
this.spellBookContainer = null;
if (spell.type === 'attack' || spell.type === 'defense') {
// Use Targeting Mode for attacks AND defense (buffs on allies)
this.game.startSpellTargeting(spell);
} else {
// Global/Instant spells (like healing_hands currently configured as global in previous logic, though typically healing hands is touch... let's keep it consistent with previous logic for now)
this.game.executeSpell(spell);
}
};
}
// COST INDICATOR (Top Left)
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 (Middle/Top area roughly)
const nameEl = document.createElement('div');
nameEl.textContent = spell.name.toUpperCase();
Object.assign(nameEl.style, {
position: 'absolute',
top: '45px', // Adjusted for template header
width: '100%',
textAlign: 'center',
fontSize: '14px',
color: '#000',
fontWeight: 'bold',
fontFamily: '"Cinzel", serif',
padding: '0 10px',
boxSizing: 'border-box'
});
card.appendChild(nameEl);
// DESCRIPTION (Bottom area)
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;
// Close on click outside (simple implementation)
const closeHandler = (e) => {
if (this.spellBookContainer && !this.spellBookContainer.contains(e.target) && e.target !== this.spellBookContainer) {
// We don't auto close here to avoid conflicts, just relying on toggle or card click
}
};
}
getSpellTemplate(type) {
// Return CSS url string based on type
// Templates: attack_template.png, defense_template.png, healing_template.png
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}')`;
}
}