diff --git a/DEVLOG.md b/DEVLOG.md index aa4f7b1..015aaec 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -1,7 +1,41 @@ # Devlog - Warhammer Quest (Versión Web 3D) -## Sesión 7: Vista Táctica 2D y Refinamiento LOS (6 Enero 2026) +## Sesión 8: Sistema de Magia, Audio y Pulido UI (7 Enero 2026) + +### Objetivos Completados +1. **Sistema de Audio Inmersivo**: + - Implementada reproducción de efectos de sonido (SFX). + - Pasos en bucle al mover entidades. + - Sonidos de combate: Espadazos, flechas. + - Sonido ambiental al abrir puertas. + +2. **Sistema de Magia Avanzado (Bola de Fuego)**: + - Implementada mecánica de selección de área de efecto (2x2). + - **Feedback Visual**: Visualización de rango y línea de visión (Verde/Rojo) en tiempo real al apuntar. + - **Secuencia de Ataque Completa**: Proyectil físico ➔ Impacto ➔ Explosión Central ➔ Daño en área. + - Daño individual calculado para cada monstruo afectado. + - Cancelación de hechizo mediante clic derecho. + +3. **Feedback de Combate Unificado**: + - Centralizada la lógica de visualización de daño en `showCombatFeedback`. + - Muestra claramente: Daño (Rojo + Temblor), Bloqueos (Amarillo), Fallos (Gris). + - Aplicado tanto a magia como a ataques físicos. + +4. **Mejoras de UI**: + - Las estadísticas de las cartas de personaje ahora usan abreviaturas en español claras (H.C, Fuer, Res, etc.) en lugar de siglas en inglés crípticas. + +### Estado Actual +El juego dispone de un sistema de combate rico visual y auditivamente. La magia se siente poderosa "gameplay-wise". La interfaz es más amigable para el usuario hispanohablante. + +### Tareas Pendientes / Known Issues +1. **Sincronización de Audio**: Los SFX de pasos a veces continúan un instante tras acabar la animación. +2. **Animación Doble**: Ocasionalmente se reproducen dos animaciones de ataque o feedback superpuestos. +3. **Interfaz de Hechizos**: Actualmente lista todos los hechizos en botones; se necesitará un seleccionador tipo "Libro de Hechizos" cuando el Mago tenga más opciones. + +--- + + ### Objetivos Completados 1. **Vista Táctica (Toggle 2D/3D)**: diff --git a/implementación/task.md b/implementación/task.md index ed1f34e..932439c 100644 --- a/implementación/task.md +++ b/implementación/task.md @@ -39,7 +39,9 @@ - [x] Implement Monster AI (Sequential Movement, Pathfinding, Attack Approach) - [x] Implement Combat Logic (Melee Attack Rolls, Damage, Death State) - [x] Implement Game Loop Rules (Exploration Stop, Continuous Combat, Phase Skipping) - - [ ] Refine Combat System (Ranged weapons, Special Monster Rules, Magic) + - [x] Refine Combat System (Ranged weapons, Area Magic, Damage Feedback) + - [x] Implement Audio System (SFX, Footsteps, Ambience) + - [x] UI Improvements (Spanish Stats, Tooltips) ## Phase 4: Campaign System - [ ] **Campaign Manager** diff --git a/public/assets/sfx/arrow.mp3 b/public/assets/sfx/arrow.mp3 new file mode 100644 index 0000000..e76c0fc Binary files /dev/null and b/public/assets/sfx/arrow.mp3 differ diff --git a/public/assets/sfx/footsteps.mp3 b/public/assets/sfx/footsteps.mp3 new file mode 100644 index 0000000..f3223c9 Binary files /dev/null and b/public/assets/sfx/footsteps.mp3 differ diff --git a/public/assets/sfx/sword1.mp3 b/public/assets/sfx/sword1.mp3 new file mode 100644 index 0000000..f295303 Binary files /dev/null and b/public/assets/sfx/sword1.mp3 differ diff --git a/src/engine/data/Spells.js b/src/engine/data/Spells.js new file mode 100644 index 0000000..ce5a109 --- /dev/null +++ b/src/engine/data/Spells.js @@ -0,0 +1,24 @@ + +export const SPELLS = [ + { + id: 'fireball', + name: 'Bola de Fuego', + type: 'attack', + cost: 1, + 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: 'healing_hands', + name: 'Manos Curadoras', + type: 'heal', + cost: 1, + range: 'board', // Same board section + healAmount: 1, + target: 'all_heroes', + description: "Todos los Aventureros en la misma sección de tablero recuperan 1 Herida." + } +]; diff --git a/src/engine/dungeon/DungeonDeck.js b/src/engine/dungeon/DungeonDeck.js index 8b7265e..49ad4df 100644 --- a/src/engine/dungeon/DungeonDeck.js +++ b/src/engine/dungeon/DungeonDeck.js @@ -15,11 +15,9 @@ export class DungeonDeck { // 1. Create a "Pool" of standard dungeon tiles let pool = []; const composition = [ - { id: 'room_dungeon', count: 6 }, - { id: 'corridor_straight', count: 7 }, - { id: 'corridor_steps', count: 1 }, - { id: 'corridor_corner', count: 1 }, // L-Shape - { id: 'junction_t', count: 3 } + { id: 'room_dungeon', count: 18 }, // Rigged: Only Rooms + { id: 'corridor_straight', count: 0 }, + { id: 'junction_t', count: 0 } ]; composition.forEach(item => { diff --git a/src/engine/game/CombatSystem.js b/src/engine/game/CombatSystem.js new file mode 100644 index 0000000..9c40c4a --- /dev/null +++ b/src/engine/game/CombatSystem.js @@ -0,0 +1,101 @@ +import { CombatMechanics } from './CombatMechanics.js'; + +export class CombatSystem { + constructor(gameEngine) { + this.game = gameEngine; + } + + /** + * Handles the complete flow of a Melee Attack request + * @param {Object} attacker + * @param {Object} defender + * @returns {Object} Result object { success: boolean, result: logObject, reason: string } + */ + handleMeleeAttack(attacker, defender) { + // 1. Validations + if (!attacker || !defender) return { success: false, reason: 'invalid_target' }; + + // Check Phase (Hero Phase for heroes) + // Note: Monsters use this too, but their phase check is in AI loop. + // We might want to enforce "Monster Phase" check here later if we pass 'source' context. + if (attacker.type === 'hero' && this.game.turnManager.currentPhase !== 'hero') { + return { success: false, reason: 'phase' }; + } + + // Check Action Economy (Cooldown) + if (attacker.hasAttacked) { + return { success: false, reason: 'cooldown' }; + } + + // Check Adjacency (Melee Range) + // Logic: Manhattan distance == 1 + const dx = Math.abs(attacker.x - defender.x); + const dy = Math.abs(attacker.y - defender.y); + if (dx + dy !== 1) { + return { success: false, reason: 'range' }; + } + + // 2. Execution (Math) + // Calls the pure math module + const result = CombatMechanics.resolveMeleeAttack(attacker, defender, this.game); + + // 3. Update State + attacker.hasAttacked = true; + + // 4. Side Effects (Sound, UI Events) + if (window.SOUND_MANAGER) { + // Logic to choose sound could be expanded here based on Weapon Type + window.SOUND_MANAGER.playSound('sword'); + } + + if (this.game.onCombatResult) { + this.game.onCombatResult(result); + } + + return { success: true, result }; + } + + /** + * Handles the complete flow of a Ranged Attack request + * @param {Object} attacker + * @param {Object} defender + * @returns {Object} Result object + */ + handleRangedAttack(attacker, defender) { + if (!attacker || !defender) return { success: false, reason: 'invalid_target' }; + + // 1. Validations + if (attacker.type === 'hero' && this.game.turnManager.currentPhase !== 'hero') { + return { success: false, reason: 'phase' }; + } + + if (attacker.hasAttacked) { + return { success: false, reason: 'cooldown' }; + } + + // Check "Pinned" Status (Can't shoot if enemies are adjacent) + // Using GameEngine's helper for now as it holds entity lists + if (this.game.isEntityPinned(attacker)) { + return { success: false, reason: 'pinned' }; + } + + // Line of Sight is assumed checked by UI/Input, but we could enforce it here if strict. + + // 2. Execution (Math) + const result = CombatMechanics.resolveRangedAttack(attacker, defender, this.game); + + // 3. Update State + attacker.hasAttacked = true; + + // 4. Side Effects + if (window.SOUND_MANAGER) { + window.SOUND_MANAGER.playSound('arrow'); + } + + if (this.game.onCombatResult) { + this.game.onCombatResult(result); + } + + return { success: true, result }; + } +} diff --git a/src/engine/game/GameEngine.js b/src/engine/game/GameEngine.js index 8f100af..60936c0 100644 --- a/src/engine/game/GameEngine.js +++ b/src/engine/game/GameEngine.js @@ -1,6 +1,8 @@ import { DungeonGenerator } from '../dungeon/DungeonGenerator.js'; import { TurnManager } from './TurnManager.js'; import { MonsterAI } from './MonsterAI.js'; +import { MagicSystem } from './MagicSystem.js'; +import { CombatSystem } from './CombatSystem.js'; import { CombatMechanics } from './CombatMechanics.js'; import { HERO_DEFINITIONS } from '../data/Heroes.js'; import { MONSTER_DEFINITIONS } from '../data/Monsters.js'; @@ -14,6 +16,8 @@ export class GameEngine { this.dungeon = new DungeonGenerator(); this.turnManager = new TurnManager(); this.ai = new MonsterAI(this); // Init AI + this.magicSystem = new MagicSystem(this); // Init Magic + this.combatSystem = new CombatSystem(this); // Init Combat this.player = null; this.selectedEntity = null; this.isRunning = false; @@ -147,7 +151,73 @@ export class GameEngine { return monster; } + onCellHover(x, y) { + if (this.targetingMode === 'spell' && this.currentSpell) { + const area = this.currentSpell.area || 1; + const cells = []; + + if (area === 2) { + cells.push({ x: x, y: y }); + cells.push({ x: x + 1, y: y }); + cells.push({ x: x, y: y + 1 }); + cells.push({ x: x + 1, y: y + 1 }); + } else { + cells.push({ x: x, y: y }); + } + + // LOS Check for Color + let color = 0xffffff; // Default White + const caster = this.selectedEntity; + if (caster) { + // Check LOS to the center/anchor cell (x,y) + const targetObj = { x: x, y: y }; + const los = this.checkLineOfSightStrict(caster, targetObj); + + if (los && los.clear) { + color = 0x00ff00; // Green (Good) + } else { + color = 0xff0000; // Red (Blocked) + } + } + + // Show Preview + if (window.RENDERER) { + window.RENDERER.showAreaPreview(cells, color); + } + } else { + if (window.RENDERER) window.RENDERER.hideAreaPreview(); + } + } + onCellClick(x, y) { + // SPELL TARGETING LOGIC + if (this.targetingMode === 'spell' && this.currentSpell) { + const area = this.currentSpell.area || 1; + const targetCells = []; + + if (area === 2) { + targetCells.push({ x: x, y: y }); + targetCells.push({ x: x + 1, y: y }); + targetCells.push({ x: x, y: y + 1 }); + targetCells.push({ x: x + 1, y: y + 1 }); + } else { + targetCells.push({ x: x, y: y }); + } + + // 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(); + return; + } + // RANGED TARGETING LOGIC if (this.targetingMode === 'ranged') { const clickedMonster = this.monsters ? this.monsters.find(m => m.x === x && m.y === y && !m.isDead) : null; @@ -236,29 +306,38 @@ export class GameEngine { performHeroAttack(targetMonsterId) { const hero = this.selectedEntity; const monster = this.monsters.find(m => m.id === targetMonsterId); + return this.combatSystem.handleMeleeAttack(hero, monster); + } - if (!hero || !monster) return null; + performRangedAttack(targetMonsterId) { + const hero = this.selectedEntity; + const monster = this.monsters.find(m => m.id === targetMonsterId); + return this.combatSystem.handleRangedAttack(hero, monster); + } - // Check Phase - if (this.turnManager.currentPhase !== 'hero') return { success: false, reason: 'phase' }; + canCastSpell(spell) { + return this.magicSystem.canCastSpell(this.selectedEntity, spell); + } - // Check Adjacency - const dx = Math.abs(hero.x - monster.x); - const dy = Math.abs(hero.y - monster.y); - if (dx + dy !== 1) return { success: false, reason: 'range' }; + executeSpell(spell, targetCells = []) { + if (!this.selectedEntity) return { success: false, reason: 'no_caster' }; + return this.magicSystem.executeSpell(this.selectedEntity, spell, targetCells); + } - // Check Action Economy - if (hero.hasAttacked) return { success: false, reason: 'cooldown' }; + deselectEntity() { + if (!this.selectedEntity) return; + const id = this.selectedEntity.id; + this.selectedEntity = null; + this.plannedPath = []; + if (this.onEntitySelect) this.onEntitySelect(id, false); + if (this.onPathChange) this.onPathChange([]); - // Execute Attack - const result = CombatMechanics.resolveMeleeAttack(hero, monster, this); - hero.hasAttacked = true; - - if (window.SOUND_MANAGER) window.SOUND_MANAGER.playSound('sword'); - - if (this.onCombatResult) this.onCombatResult(result); - - return { success: true, result }; + // Also deselect monster if selected + if (this.selectedMonster) { + const monsterId = this.selectedMonster.id; + this.selectedMonster = null; + if (this.onEntitySelect) this.onEntitySelect(monsterId, false); + } } isEntityPinned(entity) { @@ -295,45 +374,6 @@ export class GameEngine { }); } - performRangedAttack(targetMonsterId) { - const hero = this.selectedEntity; - const monster = this.monsters.find(m => m.id === targetMonsterId); - - if (!hero || !monster) return null; - - if (this.turnManager.currentPhase !== 'hero') return { success: false, reason: 'phase' }; - if (hero.hasAttacked) return { success: false, reason: 'cooldown' }; - if (this.isEntityPinned(hero)) return { success: false, reason: 'pinned' }; - - // LOS Check should be done before calling this, but we can double check or assume UI did it. - // For simplicity, we execute the attack here assuming validation passed. - - const result = CombatMechanics.resolveRangedAttack(hero, monster, this); - hero.hasAttacked = true; - - if (window.SOUND_MANAGER) window.SOUND_MANAGER.playSound('arrow'); - - if (this.onCombatResult) this.onCombatResult(result); - - return { success: true, result }; - } - - deselectEntity() { - if (!this.selectedEntity) return; - const id = this.selectedEntity.id; - this.selectedEntity = null; - this.plannedPath = []; - if (this.onEntitySelect) this.onEntitySelect(id, false); - if (this.onPathChange) this.onPathChange([]); - - // Also deselect monster if selected - if (this.selectedMonster) { - const monsterId = this.selectedMonster.id; - this.selectedMonster = null; - if (this.onEntitySelect) this.onEntitySelect(monsterId, false); - } - } - // Alias for legacy calls if any deselectPlayer() { this.deselectEntity(); @@ -646,8 +686,16 @@ export class GameEngine { console.log("Ranged Targeting Mode ON"); } + startSpellTargeting(spell) { + this.targetingMode = 'spell'; + this.currentSpell = spell; + console.log(`Spell Targeting Mode ON: ${spell.name}`); + if (this.onShowMessage) this.onShowMessage(spell.name, 'Selecciona el objetivo (Monstruo o Casilla).'); + } + cancelTargeting() { this.targetingMode = null; + this.currentSpell = null; if (this.onRangedTarget) { this.onRangedTarget(null, null); } diff --git a/src/engine/game/MagicSystem.js b/src/engine/game/MagicSystem.js new file mode 100644 index 0000000..4c17133 --- /dev/null +++ b/src/engine/game/MagicSystem.js @@ -0,0 +1,159 @@ +import { CombatMechanics } from './CombatMechanics.js'; + +export class MagicSystem { + constructor(gameEngine) { + this.game = gameEngine; + } + + canCastSpell(caster, spell) { + if (!caster || !spell) return false; + + // 1. Check Class/Role Restriction + // For now hardcoded validation, but could be part of Spell definition (e.g. spell.classes.includes(caster.key)) + if (caster.key !== 'wizard') return false; + + // 2. Check Phase + if (this.game.turnManager.currentPhase !== 'hero') return false; + + // 3. Check Cost vs Power + // Assuming TurnManager has a way to check available power + const availablePower = this.game.turnManager.power; + if (availablePower < spell.cost) return false; + + return true; + } + + executeSpell(caster, spell, targetCells = []) { + if (!this.canCastSpell(caster, spell)) { + return { success: false, reason: 'validation_failed' }; + } + + console.log(`[MagicSystem] Casting ${spell.name} by ${caster.name}`); + + // Dispatch based on Spell Type + // We could also look up a specific handler function map if this grows + if (spell.type === 'heal') { + return this.resolveHeal(caster, spell); + } else if (spell.type === 'attack') { + return this.resolveAttack(caster, spell, targetCells); + } + + return { success: false, reason: 'unknown_spell_type' }; + } + + resolveHeal(caster, spell) { + // Default Logic: Heal all heroes in same section (simplified to all heroes) + let totalHealed = 0; + + this.game.heroes.forEach(h => { + // Check if wounded + if (h.currentWounds < h.stats.wounds) { + const amount = spell.healAmount || 1; + const oldWounds = h.currentWounds; + h.currentWounds = Math.min(h.currentWounds + amount, h.stats.wounds); + + const healed = h.currentWounds - oldWounds; + if (healed > 0) { + totalHealed += healed; + if (this.game.onShowMessage) { + this.game.onShowMessage('Curación', `${h.name} recupera ${healed} herida(s).`); + } + // Visuals + if (window.RENDERER) { + window.RENDERER.triggerVisualEffect('heal', h.x, h.y); + } + } + } + }); + + return { success: true, type: 'heal', healedCount: totalHealed }; + } + + resolveAttack(caster, spell, targetCells) { + const level = caster.level || 1; + + // 1. Calculate Center of Impact + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; + targetCells.forEach(c => { + if (c.x < minX) minX = c.x; + if (c.x > maxX) maxX = c.x; + if (c.y < minY) minY = c.y; + if (c.y > maxY) maxY = c.y; + }); + + // Exact center of the group + const centerX = (minX + maxX) / 2; + const centerY = (minY + maxY) / 2; + + // 2. Launch Projectile + if (window.RENDERER) { + window.RENDERER.triggerProjectile(caster.x, caster.y, centerX, centerY, () => { + + // --- IMPACT CALLBACK --- + + // 3. Central Explosion + window.RENDERER.triggerVisualEffect('fireball', centerX, centerY); + + // 4. Apply Damage to all targets + let hits = 0; + targetCells.forEach(cell => { + const monster = this.game.monsters.find(m => m.x === cell.x && m.y === cell.y && !m.isDead); + + if (monster) { + const damageDice = spell.damageDice || 1; + let damageTotal = level; + for (let i = 0; i < damageDice; i++) { + damageTotal += Math.floor(Math.random() * 6) + 1; + } + + // Apply Damage + CombatMechanics.applyDamage(monster, damageTotal, this.game); + hits++; + + // Feedback + if (this.game.onEntityHit) { + this.game.onEntityHit(monster.id); + } + + // Use Centralized Combat Feedback + window.RENDERER.showCombatFeedback(monster.x, monster.y, damageTotal, true); + + console.log(`[MagicSystem] ${spell.name} hit ${monster.name} for ${damageTotal} damage.`); + + // Check Death (Handled by events usually, but ensuring cleanup if needed) + if (monster.currentWounds <= 0 && !monster.isDead) { + monster.isDead = true; + if (this.game.onEntityDeath) this.game.onEntityDeath(monster.id); + } + } + }); + }); + } else { + // Fallback for no renderer (tests?) or race condition + // Just apply damage immediately logic (duplicated for brevity check) + let hits = 0; + targetCells.forEach(cell => { + const monster = this.game.monsters.find(m => m.x === cell.x && m.y === cell.y && !m.isDead); + if (monster) { + const damageDice = spell.damageDice || 1; + let damageTotal = level; + for (let i = 0; i < damageDice; i++) { + damageTotal += Math.floor(Math.random() * 6) + 1; + } + CombatMechanics.applyDamage(monster, damageTotal, this.game); + hits++; + if (this.game.onEntityHit) { + this.game.onEntityHit(monster.id); + } + console.log(`[MagicSystem] ${spell.name} hit ${monster.name} for ${damageTotal} damage (no renderer).`); + if (monster.currentWounds <= 0 && !monster.isDead) { + monster.isDead = true; + if (this.game.onEntityDeath) this.game.onEntityDeath(monster.id); + } + } + }); + } + + return { success: true, type: 'attack', hits: 1 }; // Return success immediately + } +} diff --git a/src/engine/game/TurnManager.js b/src/engine/game/TurnManager.js index 7c1fb9a..ef5a130 100644 --- a/src/engine/game/TurnManager.js +++ b/src/engine/game/TurnManager.js @@ -11,6 +11,10 @@ export class TurnManager { this.eventsTriggered = []; } + get power() { + return this.currentPowerRoll; + } + startGame() { this.currentTurn = 1; console.log(`--- TURN ${this.currentTurn} START ---`); diff --git a/src/main.js b/src/main.js index 6eb60a3..66c7350 100644 --- a/src/main.js +++ b/src/main.js @@ -116,19 +116,7 @@ game.onCombatResult = (log) => { const defender = game.heroes.find(h => h.id === log.defenderId) || game.monsters.find(m => m.id === log.defenderId); if (defender) { setTimeout(() => { // Slight delay for cause-effect - if (log.hitSuccess) { - if (log.woundsCaused > 0) { - // HIT and WOUND - renderer.triggerDamageEffect(log.defenderId); - renderer.showFloatingText(defender.x, defender.y, `💥 -${log.woundsCaused}`, '#ff0000'); - } else { - // BLOCKED (Hit but Toughness saved) - renderer.showFloatingText(defender.x, defender.y, `🛡️ Block`, '#ffff00'); - } - } else { - // MISS - renderer.showFloatingText(defender.x, defender.y, `💨 Miss`, '#aaaaaa'); - } + renderer.showCombatFeedback(defender.x, defender.y, log.woundsCaused, log.hitSuccess); }, 500); } }; @@ -294,7 +282,16 @@ renderer.setupInteraction( handleClick, () => { // Right Click Handler + if (game.targetingMode === 'spell' || game.targetingMode === 'ranged') { + game.cancelTargeting(); + if (window.RENDERER) window.RENDERER.hideAreaPreview(); + ui.showTemporaryMessage('Cancelado', 'Lanzamiento de hechizo cancelado.', 1000); + return; + } game.executeMovePath(); + }, + (x, y) => { + if (game.onCellHover) game.onCellHover(x, y); } ); diff --git a/src/view/GameRenderer.js b/src/view/GameRenderer.js index 23ae0b3..6c3f203 100644 --- a/src/view/GameRenderer.js +++ b/src/view/GameRenderer.js @@ -1,13 +1,20 @@ import * as THREE from 'three'; +import { DIRECTIONS } from '../engine/dungeon/Constants.js'; +import { ParticleManager } from './ParticleManager.js'; export class GameRenderer { constructor(containerId) { this.container = document.getElementById(containerId) || document.body; + this.width = this.container.clientWidth; + this.height = this.container.clientHeight; - // 1. Scene + // Scene Setup this.scene = new THREE.Scene(); - this.scene.background = new THREE.Color(0x1a1a1a); + this.scene.background = new THREE.Color(0x111111); // Dark dungeon bg + this.particleManager = new ParticleManager(this.scene); // Init Particles + + this.camera = new THREE.PerspectiveCamera(45, this.width / this.height, 0.1, 1000); // 2. Renderer this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false }); this.renderer.setSize(window.innerWidth, window.innerHeight); @@ -49,6 +56,10 @@ export class GameRenderer { this.tokensGroup = new THREE.Group(); this.scene.add(this.tokensGroup); + + this.spellPreviewGroup = new THREE.Group(); + this.scene.add(this.spellPreviewGroup); + this.tokens = new Map(); this.entities = new Map(); @@ -70,7 +81,7 @@ export class GameRenderer { this.scene.add(dirLight); } - setupInteraction(cameraGetter, onClick, onRightClick) { + setupInteraction(cameraGetter, onClick, onRightClick, onHover = null) { const getMousePos = (event) => { const rect = this.renderer.domElement.getBoundingClientRect(); return { @@ -79,6 +90,21 @@ export class GameRenderer { }; }; + const handleHover = (event) => { + if (!onHover) return; + this.mouse.set(getMousePos(event).x, getMousePos(event).y); + this.raycaster.setFromCamera(this.mouse, cameraGetter()); + const intersects = this.raycaster.intersectObject(this.interactionPlane); + if (intersects.length > 0) { + const p = intersects[0].point; + const x = Math.round(p.x); + const y = Math.round(-p.z); + onHover(x, y); + } + }; + + this.renderer.domElement.addEventListener('mousemove', handleHover); + this.renderer.domElement.addEventListener('click', (event) => { this.mouse.set(getMousePos(event).x, getMousePos(event).y); this.raycaster.setFromCamera(this.mouse, cameraGetter()); @@ -167,6 +193,30 @@ export class GameRenderer { }); } + showAreaPreview(cells, color = 0xffffff) { + this.spellPreviewGroup.clear(); // Ensure cleared first + if (!cells) return; + + const geometry = new THREE.PlaneGeometry(0.9, 0.9); + const material = new THREE.MeshBasicMaterial({ + color: color, + transparent: true, + opacity: 0.5, + side: THREE.DoubleSide + }); + + cells.forEach(cell => { + const mesh = new THREE.Mesh(geometry, material); + mesh.rotation.x = -Math.PI / 2; + mesh.position.set(cell.x, 0.06, -cell.y); // Slightly above floor/highlights + this.spellPreviewGroup.add(mesh); + }); + } + + hideAreaPreview() { + this.spellPreviewGroup.clear(); + } + addEntity(entity) { if (this.entities.has(entity.id)) return; @@ -292,6 +342,25 @@ export class GameRenderer { }; } + triggerVisualEffect(type, x, y) { + if (this.particleManager) { + if (type === 'fireball') { + this.particleManager.spawnFireballExplosion(x, -y); + } else if (type === 'heal') { + this.particleManager.spawnHealEffect(x, -y); + } + } + } + + triggerProjectile(startX, startY, endX, endY, onHitCallback) { + if (this.particleManager) { + // Map Grid Y to World -Z + this.particleManager.spawnProjectile(startX, -startY, endX, -endY, onHitCallback); + } else { + if (onHitCallback) onHitCallback(); + } + } + showFloatingText(x, y, text, color = "#ffffff") { const canvas = document.createElement('canvas'); canvas.width = 256; @@ -329,6 +398,33 @@ export class GameRenderer { this.floatingTextGroup.add(sprite); } + showCombatFeedback(x, y, damage, isHit, defenseText = 'Block') { + const entityKey = `${x},${y}`; // Approximate lookup if needed, but we pass coords. + // Actually to trigger shake we need entity ID. + // We can find entity at X,Y? + let entityId = null; + for (const [id, mesh] of this.entities.entries()) { + // Check approximate position + if (Math.abs(mesh.position.x - x) < 0.1 && Math.abs(mesh.position.z - (-y)) < 0.1) { + entityId = id; + break; + } + } + + if (isHit) { + if (damage > 0) { + // HIT and DAMAGE + this.showFloatingText(x, y, `💥 -${damage}`, '#ff0000'); + if (entityId) this.triggerDamageEffect(entityId); + } else { + // HIT but NO DAMAGE (Blocked) + this.showFloatingText(x, y, `🛡️ ${defenseText}`, '#ffff00'); + } + } else { + // MISS + this.showFloatingText(x, y, `💨 Miss`, '#aaaaaa'); + } + } triggerDeathAnimation(entityId) { const mesh = this.entities.get(entityId); if (!mesh) return; @@ -355,6 +451,7 @@ export class GameRenderer { }, duration); } + moveEntityAlongPath(entity, path) { const mesh = this.entities.get(entity.id); if (mesh) { @@ -381,6 +478,15 @@ export class GameRenderer { } updateAnimations(time) { + // Calculate Delta (Approx) + if (!this.lastTime) this.lastTime = time; + const delta = (time - this.lastTime) / 1000; + this.lastTime = time; + + if (this.particleManager) { + this.particleManager.update(delta); + } + let isAnyMoving = false; this.entities.forEach((mesh, id) => { @@ -553,13 +659,13 @@ export class GameRenderer { this.exitGroup.children.forEach(child => { if (child.userData.isDoor) { child.userData.cells.forEach(cell => { - existingDoorCells.add(`${cell.x},${cell.y}`); + existingDoorCells.add(`${cell.x},${cell.y} `); }); } }); // Filter out exits that already have doors - const newExits = exits.filter(ex => !existingDoorCells.has(`${ex.x},${ex.y}`)); + const newExits = exits.filter(ex => !existingDoorCells.has(`${ex.x},${ex.y} `)); if (newExits.length === 0) { @@ -598,7 +704,7 @@ export class GameRenderer { }; newExits.forEach((ex, i) => { - const key = `${ex.x},${ex.y}`; + const key = `${ex.x},${ex.y} `; const exDir = normalizeDir(ex.direction); if (processed.has(key)) { @@ -608,7 +714,7 @@ export class GameRenderer { let partner = null; for (let j = i + 1; j < newExits.length; j++) { const other = newExits[j]; - const otherKey = `${other.x},${other.y}`; + const otherKey = `${other.x},${other.y} `; const otherDir = normalizeDir(other.direction); if (processed.has(otherKey)) continue; @@ -635,7 +741,7 @@ export class GameRenderer { if (partner) { doors.push([ex, partner]); processed.add(key); - processed.add(`${partner.x},${partner.y}`); + processed.add(`${partner.x},${partner.y} `); } else { doors.push([ex]); processed.add(key); @@ -691,7 +797,7 @@ export class GameRenderer { direction: dirMap[dir] || 'N' } }; - mesh.name = `door_${idx}`; + mesh.name = `door_${idx} `; this.exitGroup.add(mesh); }); @@ -749,7 +855,7 @@ export class GameRenderer { }, undefined, // onProgress (err) => { - console.error(`[GameRenderer] Failed to load texture: ${path}`, err); + console.error(`[GameRenderer] Failed to load texture: ${path} `, err); const callbacks = this._pendingTextureRequests.get(path); if (callbacks) { this._pendingTextureRequests.delete(path); @@ -837,7 +943,7 @@ export class GameRenderer { }); } else { - console.warn(`[GameRenderer] details missing for texture render. def: ${!!tileDef}, inst: ${!!tileInstance}`); + console.warn(`[GameRenderer] details missing for texture render.def: ${!!tileDef}, inst: ${!!tileInstance} `); } } @@ -1111,12 +1217,12 @@ export class GameRenderer { preview.variant.exits.forEach(ex => { const gx = x + ex.x; const gy = y + ex.y; - exitKeys.add(`${gx},${gy}`); + exitKeys.add(`${gx},${gy} `); }); } cells.forEach(cell => { - const key = `${cell.x},${cell.y}`; + const key = `${cell.x},${cell.y} `; let color = baseColor; // If this cell is an exit, color it Blue @@ -1257,9 +1363,9 @@ export class GameRenderer { const filename = subType; if (type === 'hero') { - path = `/assets/images/dungeon1/tokens/heroes/${filename}.png`; + path = `/ assets / images / dungeon1 / tokens / heroes / ${filename}.png`; } else { - path = `/assets/images/dungeon1/tokens/enemies/${filename}.png`; + path = `/ assets / images / dungeon1 / tokens / enemies / ${filename}.png`; } this.getTexture(path, (texture) => { @@ -1267,7 +1373,7 @@ export class GameRenderer { token.material.color.setHex(0xFFFFFF); // Reset to white to show texture token.material.needsUpdate = true; }, undefined, (err) => { - console.warn(`[GameRenderer] Token texture missing: ${path}`); + console.warn(`[GameRenderer] Token texture missing: ${path} `); }); }; diff --git a/src/view/ParticleManager.js b/src/view/ParticleManager.js new file mode 100644 index 0000000..19bdc71 --- /dev/null +++ b/src/view/ParticleManager.js @@ -0,0 +1,216 @@ + +import * as THREE from 'three'; + +export class ParticleManager { + constructor(scene) { + this.scene = scene; + this.particles = []; + // Optional: Preload textures here if needed, or create them procedurally on canvas + } + + createTexture(color, type = 'circle') { + const canvas = document.createElement('canvas'); + canvas.width = 32; + canvas.height = 32; + const ctx = canvas.getContext('2d'); + + if (type === 'circle') { + const grad = ctx.createRadialGradient(16, 16, 0, 16, 16, 16); + grad.addColorStop(0, color); + grad.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, 32, 32); + } else if (type === 'star') { + ctx.fillStyle = color; + ctx.beginPath(); + ctx.moveTo(16, 0); ctx.lineTo(20, 12); + ctx.lineTo(32, 16); ctx.lineTo(20, 20); + ctx.lineTo(16, 32); ctx.lineTo(12, 20); + ctx.lineTo(0, 16); ctx.lineTo(12, 12); + ctx.fill(); + } + + const tex = new THREE.CanvasTexture(canvas); + return tex; + } + + // Generic Emitter + emit(x, y, z, options = {}) { + const count = options.count || 10; + const color = options.color || '#ffaa00'; + const speed = options.speed || 0.1; + const life = options.life || 1.0; // seconds + const type = options.type || 'circle'; + + const material = new THREE.SpriteMaterial({ + map: this.createTexture(color, type), + transparent: true, + blending: THREE.AdditiveBlending, + depthWrite: false + }); + + for (let i = 0; i < count; i++) { + const sprite = new THREE.Sprite(material); + sprite.position.set(x, y, z); + + // Random velocity + const theta = Math.random() * Math.PI * 2; + const phi = Math.random() * Math.PI; + const v = (Math.random() * 0.5 + 0.5) * speed; + + sprite.userData = { + velocity: new THREE.Vector3( + Math.cos(theta) * Math.sin(phi) * v, + Math.cos(phi) * v, // Upward bias? + Math.sin(theta) * Math.sin(phi) * v + ), + life: life, + maxLife: life, + scaleSpeed: options.scaleSpeed || 0 + }; + + // Scale variation + const startScale = options.scale || 0.5; + sprite.scale.setScalar(startScale); + sprite.userData.startScale = startScale; + + this.scene.add(sprite); + this.particles.push(sprite); + } + } + + spawnFireballExplosion(x, y) { + // World coordinates: x, 0.5, y (assuming y is vertical, wait, 3D grid y is usually z?) + // In our game: x is x, y is z (flat), y-up is height. + // Let's check coordinates. Usually map x,y maps to 3D x,0,z or x,z, (-y). + // GameRenderer uses x, 0, y for positions typically. + + // Emitter + this.emit(x, 0.5, y, { + count: 20, + color: '#ff4400', + speed: 0.15, + life: 0.8, + type: 'circle', + scale: 0.8, + scaleSpeed: -1.0 // Shrink + }); + this.emit(x, 0.5, y, { + count: 10, + color: '#ffff00', + speed: 0.1, + life: 0.5, + type: 'circle', + scale: 0.5 + }); + } + + spawnHealEffect(x, y) { + // Upward floating particles + const count = 15; + const material = new THREE.SpriteMaterial({ + map: this.createTexture('#00ff00', 'star'), + transparent: true, + blending: THREE.AdditiveBlending + }); + + for (let i = 0; i < count; i++) { + const sprite = new THREE.Sprite(material); + // Random spread around center + const ox = (Math.random() - 0.5) * 0.6; + const oy = (Math.random() - 0.5) * 0.6; + + sprite.position.set(x + ox, 0.2, y + oy); + + sprite.userData = { + velocity: new THREE.Vector3(0, 0.05 + Math.random() * 0.05, 0), // Up only + life: 1.5, + maxLife: 1.5 + }; + sprite.scale.setScalar(0.3); + + this.scene.add(sprite); + this.particles.push(sprite); + } + } + + spawnProjectile(startX, startZ, endX, endZ, onHit) { + // Simple Projectile (a sprite that moves) + const material = new THREE.SpriteMaterial({ + map: this.createTexture('#ffaa00', 'circle'), + transparent: true, + blending: THREE.AdditiveBlending + }); + + const sprite = new THREE.Sprite(material); + sprite.scale.setScalar(0.4); + // Start height 1.5 (caster head level) + sprite.position.set(startX, 1.5, startZ); + + const speed = 15.0; // Units per second + const dist = Math.sqrt((endX - startX) ** 2 + (endZ - startZ) ** 2); + const duration = dist / speed; + + sprite.userData = { + isProjectile: true, + startPos: new THREE.Vector3(startX, 1.5, startZ), + targetPos: new THREE.Vector3(endX, 0.5, endZ), // Target floor/center + time: 0, + duration: duration, + onHit: onHit + }; + + this.scene.add(sprite); + this.particles.push(sprite); + } + + update(dt) { + for (let i = this.particles.length - 1; i >= 0; i--) { + const p = this.particles[i]; + + if (p.userData.isProjectile) { + p.userData.time += dt; + const t = Math.min(1, p.userData.time / p.userData.duration); + + p.position.lerpVectors(p.userData.startPos, p.userData.targetPos, t); + + // Trail effect + if (Math.random() > 0.5) { + this.emit(p.position.x, p.position.y, p.position.z, { + count: 1, color: '#ff4400', life: 0.3, scale: 0.2, speed: 0.05 + }); + } + + if (t >= 1) { + // Hit! + if (p.userData.onHit) p.userData.onHit(); + this.scene.remove(p); + this.particles.splice(i, 1); + } + continue; + } + + // Normal Particle Update + // Move + p.position.add(p.userData.velocity); + + // Life + p.userData.life -= dt; + const progress = 1 - (p.userData.life / p.userData.maxLife); + + // Opacity Fade + p.material.opacity = p.userData.life / p.userData.maxLife; + + // Scale Change + if (p.userData.scaleSpeed) { + const s = Math.max(0.01, p.userData.startScale + p.userData.scaleSpeed * progress); + p.scale.setScalar(s); + } + + if (p.userData.life <= 0) { + this.scene.remove(p); + this.particles.splice(i, 1); + } + } + } +} diff --git a/src/view/UIManager.js b/src/view/UIManager.js index 0ce87ec..8e63609 100644 --- a/src/view/UIManager.js +++ b/src/view/UIManager.js @@ -1,4 +1,5 @@ import { DIRECTIONS } from '../engine/dungeon/Constants.js'; +import { SPELLS } from '../engine/data/Spells.js'; export class UIManager { constructor(cameraManager, gameEngine) { @@ -532,14 +533,14 @@ export class UIManager { statsGrid.style.marginBottom = '8px'; const stats = [ - { label: 'WS', value: hero.stats.ws || 0 }, - { label: 'BS', value: hero.stats.bs || 0 }, - { label: 'S', value: hero.stats.str || 0 }, - { label: 'T', value: hero.stats.toughness || 0 }, - { label: 'W', value: `${hero.currentWounds || hero.stats.wounds}/${hero.stats.wounds}` }, - { label: 'I', value: hero.stats.initiative || 0 }, - { label: 'A', value: hero.stats.attacks || 0 }, - { label: 'Mov', value: `${hero.currentMoves || 0}/${hero.stats.move}` } + { label: 'H.C', value: hero.stats.ws || 0 }, // Hab. Combate + { label: 'H.P', value: hero.stats.bs || 0 }, // Hab. Proyectiles + { label: 'Fuer', value: hero.stats.str || 0 }, // Fuerza + { label: 'Res', value: hero.stats.toughness || 0 }, // Resistencia + { label: 'Her', value: `${hero.currentWounds || hero.stats.wounds}/${hero.stats.wounds}` }, // Heridas + { label: 'Ini', value: hero.stats.initiative || 0 },// Iniciativa + { label: 'Ata', value: hero.stats.attacks || 0 }, // Ataques + { label: 'Mov', value: `${hero.currentMoves || 0}/${hero.stats.move}` } // Movimiento ]; stats.forEach(stat => { @@ -599,6 +600,50 @@ 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'; + + const canCast = this.game.canCastSpell(spell); + + 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) => { + 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); + } + }; + } + + card.appendChild(btn); + }); } card.dataset.heroId = hero.id; @@ -714,12 +759,12 @@ export class UIManager { statsGrid.style.fontSize = '12px'; const stats = [ - { label: 'WS', value: monster.stats.ws || 0 }, - { label: 'S', value: monster.stats.str || 0 }, - { label: 'T', value: monster.stats.toughness || 0 }, - { label: 'W', value: `${monster.currentWounds || monster.stats.wounds}/${monster.stats.wounds}` }, - { label: 'I', value: monster.stats.initiative || 0 }, - { label: 'A', value: monster.stats.attacks || 0 } + { label: 'H.C', value: monster.stats.ws || 0 }, // Hab. Combate + { label: 'Fuer', value: monster.stats.str || 0 }, // Fuerza + { label: 'Res', value: monster.stats.toughness || 0 }, // Resistencia + { label: 'Her', value: `${monster.currentWounds || monster.stats.wounds}/${monster.stats.wounds}` }, // Heridas + { label: 'Ini', value: monster.stats.initiative || 0 }, // Iniciativa + { label: 'Ata', value: monster.stats.attacks || 0 } // Ataques ]; stats.forEach(stat => {