Compare commits

6 Commits

19 changed files with 1374 additions and 121 deletions

View File

@@ -1,5 +1,72 @@
# Devlog - Warhammer Quest (Versión Web 3D)
## 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)**:
- Implementado botón en UI para alternar views.
- **2D**: Cámara cenital pura (Top-Down) para planificación táctica.
- **Visualización de Tokens**:
- En modo 2D, las miniaturas 3D se complementan con Tokens planos.
- **Imágenes Específicas**: Carga dinámica de assets para héroes (`heroes/barbarian.png`...) y monstruos (`enemies/orc.png`...).
- **Sincronización**: Los tokens se mueven en tiempo real y desaparecen limpiamente al volver a 3D.
- **UX**: Transiciones suaves y gestión robusta de visibilidad.
2. **Refinamiento de Línea de Visión (LOS)**:
- Implementado algoritmo estricto (Amanatides & Woo) para evitar tiros a través de muros.
- **Tolerancia de Rozamiento**: Añadido margen (hitbox 0.4) para permitir tiros que rozan el borde de una casilla de entidad.
- **Corrección de "Diagonal Leaking"**: Solucionado el problema donde los disparos atravesaban esquinas diagonales entre muros (se verifican ambos vecinos en cruces de vértice).
- **Detección de Muros por Conectividad**: Reemplazada la comprobación simple de vacío por `canMoveBetween`, asegurando que los muros entre habitaciones/pasillos contiguos bloquen la visión correctamente si no hay puerta, incluso si ambas celdas tienen suelo.
3. **Sistema de Audio**:
- Implementado `SoundManager` para gestión centralizada de audio.
- **Música Ambiental**: Reproducción de `Abandoned_Ruins.mp3` con loop y manejo de políticas de autoplay del navegador.
- **Efectos de Sonido (SFX)**: Gatillo de sonido `opendoor.mp3` sincronizado con la apertura visual de puertas.
### Estado Actual
El juego cuenta con una visualización táctica profesional y un sistema de línea de visión robusto y justo, eliminando los fallos de detección en esquinas y muros.
### Próximos Pasos
- Sistema de combate completo (dados, daño).
- UI de estadísticas y gestión de inventario.
---
## Sesión 6: Sistema de Fases y Lógica de Juego (5 Enero 2026)
### Objetivos Completados

View File

@@ -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**

Binary file not shown.

BIN
public/assets/sfx/arrow.mp3 Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

24
src/engine/data/Spells.js Normal file
View File

@@ -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."
}
];

View File

@@ -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 => {

View File

@@ -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 };
}
}

View File

@@ -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,27 +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 (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) {
@@ -293,43 +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 (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();
@@ -642,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);
}
@@ -809,6 +861,9 @@ export class GameEngine {
const maxSteps = Math.abs(endX - currentX) + Math.abs(endY - currentY) + 20;
let prevX = null;
let prevY = null;
for (let i = 0; i < maxSteps; i++) {
path.push({ x: currentX, y: currentY });
@@ -816,32 +871,97 @@ export class GameEngine {
const isEnd = (currentX === target.x && currentY === target.y);
if (!isStart && !isEnd) {
if (this.dungeon.grid.isWall(currentX, currentY)) {
// WALL CHECK: Use Connectvity (canMoveBetween)
// This detects walls between tiles even if both tiles are floor.
// It also detects VOID cells (because canMoveBetween returns false if destination is void).
if (prevX !== null) {
if (!this.dungeon.grid.canMoveBetween(prevX, prevY, currentX, currentY)) {
blocked = true;
blocker = { type: 'wall', x: currentX, y: currentY };
console.log(`[LOS] Blocked by WALL/BORDER between ${prevX},${prevY} and ${currentX},${currentY}`);
break;
}
} else if (this.dungeon.grid.isWall(currentX, currentY)) {
// Fallback for start/isolated case (should rarely happen for LOS path)
blocked = true;
blocker = { type: 'wall', x: currentX, y: currentY };
console.log(`[LOS] Blocked by WALL at ${currentX},${currentY}`);
break;
}
// Helper: Distance from Cell Center to Ray (for grazing tolerance)
const getDist = () => {
const cx = currentX + 0.5;
const cy = currentY + 0.5;
const len = Math.sqrt(dx * dx + dy * dy);
if (len === 0) return 0;
return Math.abs(dy * cx - dx * cy + dx * y1 - dy * x1) / len;
};
// Tolerance: Allow shots to pass if they graze the edge (0.5 is full width)
// 0.4 means the outer 20% of the tile is "safe" to shoot through.
const ENTITY_HITBOX_RADIUS = 0.4;
// 2. Monster Check
const m = this.monsters.find(m => m.x === currentX && m.y === currentY && !m.isDead && m.id !== target.id);
if (m) {
if (getDist() < ENTITY_HITBOX_RADIUS) {
blocked = true;
blocker = { type: 'monster', entity: m };
console.log(`[LOS] Blocked by MONSTER: ${m.name}`);
break;
} else {
console.log(`[LOS] Grazed MONSTER ${m.name} (Dist: ${getDist().toFixed(2)})`);
}
}
// 3. Hero Check
const h = this.heroes.find(h => h.x === currentX && h.y === currentY && h.id !== hero.id);
if (h) {
if (getDist() < ENTITY_HITBOX_RADIUS) {
blocked = true;
blocker = { type: 'hero', entity: h };
console.log(`[LOS] Blocked by HERO: ${h.name}`);
break;
} else {
console.log(`[LOS] Grazed HERO ${h.name} (Dist: ${getDist().toFixed(2)})`);
}
}
}
if (currentX === endX && currentY === endY) break;
// CORNER CROSSING CHECK: Prevent diagonal wall leaking
// When tMaxX ≈ tMaxY, the ray passes through a vertex shared by 4 cells.
// Standard algorithm only visits 2 of them. We must check BOTH neighbors.
const CORNER_EPSILON = 0.001;
const cornerCrossing = Math.abs(tMaxX - tMaxY) < CORNER_EPSILON;
if (cornerCrossing) {
// Check connectivity to both orthogonal neighbors
const neighborX = currentX + stepX;
const neighborY = currentY + stepY;
// Check horizontal neighbor connectivity
if (!this.dungeon.grid.canMoveBetween(currentX, currentY, neighborX, currentY)) {
blocked = true;
blocker = { type: 'wall', x: neighborX, y: currentY };
console.log(`[LOS] Blocked by CORNER WALL (H) at ${neighborX},${currentY}`);
break;
}
// Check vertical neighbor connectivity
if (!this.dungeon.grid.canMoveBetween(currentX, currentY, currentX, neighborY)) {
blocked = true;
blocker = { type: 'wall', x: currentX, y: neighborY };
console.log(`[LOS] Blocked by CORNER WALL (V) at ${currentX},${neighborY}`);
break;
}
}
// Update Previous
prevX = currentX;
prevY = currentY;
if (tMaxX < tMaxY) {
tMaxX += tDeltaX;
currentX += stepX;

View File

@@ -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
}
}

View File

@@ -11,6 +11,10 @@ export class TurnManager {
this.eventsTriggered = [];
}
get power() {
return this.currentPowerRoll;
}
startGame() {
this.currentTurn = 1;
console.log(`--- TURN ${this.currentTurn} START ---`);

View File

@@ -2,6 +2,7 @@ import { GameEngine } from './engine/game/GameEngine.js';
import { GameRenderer } from './view/GameRenderer.js';
import { CameraManager } from './view/CameraManager.js';
import { UIManager } from './view/UIManager.js';
import { SoundManager } from './view/SoundManager.js';
import { MissionConfig, MISSION_TYPES } from './engine/dungeon/MissionConfig.js';
@@ -19,10 +20,15 @@ const renderer = new GameRenderer('app');
const cameraManager = new CameraManager(renderer);
const game = new GameEngine();
const ui = new UIManager(cameraManager, game);
const soundManager = new SoundManager();
// Start Music (Autoplay handling included in manager)
soundManager.playMusic('exploration');
// Global Access
window.GAME = game;
window.RENDERER = renderer;
window.SOUND_MANAGER = soundManager;
// 3. Connect Dungeon Generator to Renderer
const generator = game.dungeon;
@@ -97,6 +103,22 @@ game.turnManager.on('phase_changed', (phase) => {
game.onCombatResult = (log) => {
ui.showCombatLog(log);
// 1. Show Attack Roll on Attacker
// Find Attacker pos
const attacker = game.heroes.find(h => h.id === log.attackerId) || game.monsters.find(m => m.id === log.attackerId);
if (attacker) {
const rollColor = log.hitSuccess ? '#00ff00' : '#888888'; // Green vs Gray
renderer.showFloatingText(attacker.x, attacker.y, `🎲 ${log.hitRoll}`, rollColor);
}
// 2. Show Damage on Defender
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
renderer.showCombatFeedback(defender.x, defender.y, log.woundsCaused, log.hitSuccess);
}, 500);
}
};
game.onEntityMove = (entity, path) => {
@@ -222,6 +244,7 @@ const handleClick = (x, y, doorMesh) => {
// Open door visually
renderer.openDoor(doorMesh);
if (window.SOUND_MANAGER) window.SOUND_MANAGER.playSound('door_open');
// Get proper exit data with direction
const exitData = doorMesh.userData.exitData;
@@ -259,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);
}
);

View File

@@ -161,12 +161,18 @@ export class CameraManager {
if (this.animationProgress >= 1) {
this.isAnimating = false;
this.camera.position.copy(this.animationTargetPos);
if (this.onAnimationComplete) {
this.onAnimationComplete();
this.onAnimationComplete = null; // Consume callback
}
}
}
}
// --- Fixed Orbit Logic ---
setIsoView(direction) {
this.lastIsoDirection = direction || DIRECTIONS.NORTH;
// Rotate camera around target while maintaining isometric angle
// Isometric view: 45 degree angle from horizontal
const distance = 28; // Distance from target
@@ -207,4 +213,31 @@ export class CameraManager {
this.currentViewAngle = horizontalAngle;
}
toggleViewMode() {
if (this.viewMode === '2D') {
this.viewMode = '3D';
this.setIsoView(this.lastIsoDirection);
return true; // Is 3D
} else {
this.viewMode = '2D';
this.setZenithalView();
return false; // Is 2D
}
}
setZenithalView() {
// Top-down view (Zenithal)
const height = 40;
// Slight Z offset to Ensure North is Up (avoiding gimbal lock with Up=(0,1,0))
const x = this.target.x;
const z = this.target.z + 0.01;
const y = height;
this.animationStartPos.copy(this.camera.position);
this.animationTargetPos.set(x, y, z);
this.animationProgress = 0;
this.animationStartTime = performance.now();
this.isAnimating = true;
}
}

View File

@@ -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);
@@ -47,6 +54,14 @@ export class GameRenderer {
this.rangedGroup = new THREE.Group();
this.scene.add(this.rangedGroup);
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();
}
@@ -55,6 +70,10 @@ export class GameRenderer {
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
this.scene.add(ambientLight);
// Group for floating texts
this.floatingTextGroup = new THREE.Group();
this.scene.add(this.floatingTextGroup);
// Directional Light (Sun/Moon - creates shadows)
const dirLight = new THREE.DirectionalLight(0xffffff, 0.7);
dirLight.position.set(50, 100, 50);
@@ -62,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 {
@@ -71,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());
@@ -159,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;
@@ -256,29 +314,20 @@ export class GameRenderer {
const mesh = this.entities.get(entityId);
if (!mesh) return;
// 1. Red Halo (Temporary) - MATCHING ATTACKER SIZE (0.3 - 0.4)
const hitRingGeom = new THREE.RingGeometry(0.3, 0.4, 32);
const hitRingMat = new THREE.MeshBasicMaterial({
color: 0xff0000,
side: THREE.DoubleSide,
transparent: true,
opacity: 0.9
});
const hitRing = new THREE.Mesh(hitRingGeom, hitRingMat);
hitRing.rotation.x = -Math.PI / 2;
// Align with floor
const h = 1.56;
hitRing.position.y = -h / 2 + 0.05;
hitRing.name = "HitRing";
mesh.add(hitRing);
// Remove Red Halo after 1200ms (matching the timing in MonsterAI)
// 1. Flash Effect (White Flash)
mesh.traverse((child) => {
if (child.material && child.material.map) { // Texture mesh
// Store original color if not stored
if (!child.userData.originalColor) {
child.userData.originalColor = child.material.color.clone();
}
// Set to red/white flash
child.material.color.setHex(0xff0000);
setTimeout(() => {
if (mesh && hitRing) mesh.remove(hitRing);
}, 1200);
if (child.material) child.material.color.copy(child.userData.originalColor);
}, 150);
}
});
// 2. Shake Animation (800ms)
const originalPos = mesh.position.clone();
@@ -293,6 +342,89 @@ 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;
canvas.height = 128; // Rectangular
const ctx = canvas.getContext('2d');
ctx.font = "bold 60px Arial";
ctx.textAlign = "center";
ctx.lineWidth = 4;
ctx.strokeStyle = "black";
ctx.strokeText(text, 128, 64);
ctx.fillStyle = color;
ctx.fillText(text, 128, 64);
const texture = new THREE.CanvasTexture(canvas);
const material = new THREE.SpriteMaterial({ map: texture, transparent: true });
const sprite = new THREE.Sprite(material);
// Position slightly above head (standard height ~1.5)
sprite.position.set(x, 2.0, -y);
// Small initial random offset for stacking readability
sprite.position.x += (Math.random() - 0.5) * 0.2;
// Scale down to world units
sprite.scale.set(2, 1, 1);
sprite.userData = {
startTime: performance.now(),
duration: 2000, // 2 seconds life
startY: sprite.position.y
};
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;
@@ -319,6 +451,7 @@ export class GameRenderer {
}, duration);
}
moveEntityAlongPath(entity, path) {
const mesh = this.entities.get(entity.id);
if (mesh) {
@@ -333,10 +466,29 @@ export class GameRenderer {
// Prevent snapping if animation is active
if (mesh.userData.isMoving || mesh.userData.pathQueue.length > 0) return;
mesh.position.set(entity.x, 1.56 / 2, -entity.y);
// Sync Token
if (this.tokens) {
const token = this.tokens.get(entity.id);
if (token) {
token.position.set(entity.x, 0.05, -entity.y);
}
}
}
}
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) => {
const data = mesh.userData;
@@ -350,6 +502,11 @@ export class GameRenderer {
data.targetPos = new THREE.Vector3(nextStep.x, mesh.position.y, -nextStep.y);
}
// Check if this entity is contributing to movement sound
if (data.isMoving || data.pathQueue.length > 0) {
isAnyMoving = true;
}
if (data.isMoving) {
const duration = 300; // Hero movement speed (300ms per tile)
const elapsed = time - data.startTime;
@@ -359,6 +516,15 @@ export class GameRenderer {
mesh.position.x = THREE.MathUtils.lerp(data.startPos.x, data.targetPos.x, progress);
mesh.position.z = THREE.MathUtils.lerp(data.startPos.z, data.targetPos.z, progress);
// Sync Token
if (this.tokens) {
const token = this.tokens.get(id);
if (token) {
token.position.x = mesh.position.x;
token.position.z = mesh.position.z;
}
}
// Hop (Botecito)
const jumpHeight = 0.5;
const baseHeight = 1.56 / 2;
@@ -436,14 +602,43 @@ export class GameRenderer {
// IF Finished Sequence (Queue empty)
if (data.pathQueue.length === 0) {
// Check if it's the player (id 'p1')
if (id === 'p1' && this.onHeroFinishedMove) {
if (data.pathQueue.length === 0 && !data.isMoving) { // Ensure strict finished state
// Check if it's the player (id 'p1') -- NOTE: ID might be hero_barbarian etc.
// Using generic callback
if (id === 'p1' && this.onHeroFinishedMove) { // Legacy check?
// Grid Coords from World Coords (X, -Z)
this.onHeroFinishedMove(mesh.position.x, -mesh.position.z);
}
}
});
// Update Floating Texts
const now = time;
for (let i = this.floatingTextGroup.children.length - 1; i >= 0; i--) {
const sprite = this.floatingTextGroup.children[i];
const elapsed = now - sprite.userData.startTime;
const progress = elapsed / sprite.userData.duration;
if (progress >= 1) {
this.floatingTextGroup.remove(sprite);
} else {
// Float Up
sprite.position.y = sprite.userData.startY + (progress * 1.5);
// Fade Out in last half
if (progress > 0.5) {
sprite.material.opacity = 1 - ((progress - 0.5) * 2);
}
}
}
// Handle Footsteps Audio Globally
if (window.SOUND_MANAGER) {
if (isAnyMoving) {
window.SOUND_MANAGER.startLoop('footsteps');
} else {
window.SOUND_MANAGER.stopLoop('footsteps');
}
}
}
renderExits(exits) {
// Cancel any pending render
@@ -1130,4 +1325,69 @@ export class GameRenderer {
// Walls are implicit (Line just turns red and stops/passes through)
}
}
showTokens(heroes, monsters) {
this.hideTokens(); // Clear existing (makes invisible)
if (this.tokensGroup) this.tokensGroup.visible = true; // Now force visible
const createToken = (entity, type, subType) => {
const geometry = new THREE.CircleGeometry(0.35, 32);
const material = new THREE.MeshBasicMaterial({
color: (type === 'hero') ? 0x00BFFF : 0xDC143C, // Fallback color
side: THREE.DoubleSide,
transparent: true,
opacity: 1.0
});
const token = new THREE.Mesh(geometry, material);
token.rotation.x = -Math.PI / 2;
const mesh3D = this.entities.get(entity.id);
if (mesh3D) {
token.position.set(mesh3D.position.x, 0.05, mesh3D.position.z);
} else {
token.position.set(entity.x, 0.05, -entity.y);
}
this.tokensGroup.add(token);
this.tokens.set(entity.id, token);
// White Border Ring
const borderGeo = new THREE.RingGeometry(0.35, 0.38, 32);
const borderMat = new THREE.MeshBasicMaterial({ color: 0xFFFFFF, side: THREE.DoubleSide });
const border = new THREE.Mesh(borderGeo, borderMat);
border.position.z = 0.001;
token.add(border);
// Load Image
let path = '';
// Ensure filename is safe (though keys usually are)
const filename = subType;
if (type === 'hero') {
path = `/ assets / images / dungeon1 / tokens / heroes / ${filename}.png`;
} else {
path = `/ assets / images / dungeon1 / tokens / enemies / ${filename}.png`;
}
this.getTexture(path, (texture) => {
token.material.map = texture;
token.material.color.setHex(0xFFFFFF); // Reset to white to show texture
token.material.needsUpdate = true;
}, undefined, (err) => {
console.warn(`[GameRenderer] Token texture missing: ${path} `);
});
};
if (heroes) heroes.forEach(h => createToken(h, 'hero', h.key));
if (monsters) monsters.forEach(m => {
if (!m.isDead) createToken(m, 'monster', m.key);
});
}
hideTokens() {
if (this.tokensGroup) {
this.tokensGroup.clear();
this.tokensGroup.visible = false;
}
if (this.tokens) this.tokens.clear();
}
}

216
src/view/ParticleManager.js Normal file
View File

@@ -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);
}
}
}
}

142
src/view/SoundManager.js Normal file
View File

@@ -0,0 +1,142 @@
export class SoundManager {
constructor() {
this.musicVolume = 0.3; // Default volume (not too loud)
this.sfxVolume = 0.5;
this.currentMusic = null;
this.isMuted = false;
// Asset Library
this.assets = {
music: {
'exploration': '/assets/music/ingame/Abandoned_Ruins.mp3'
},
sfx: {
'door_open': '/assets/sfx/opendoor.mp3',
'footsteps': '/assets/sfx/footsteps.mp3',
'sword': '/assets/sfx/sword1.mp3',
'arrow': '/assets/sfx/arrow.mp3'
}
};
this.initialized = false;
this.activeLoops = new Map();
}
/**
* Initializes the audio context if needed (browser restriction handling)
* Can be called on the first user interaction (click)
*/
init() {
if (this.initialized) return;
this.initialized = true;
console.log("[SoundManager] Audio System Initialized");
}
playMusic(key) {
if (this.isMuted) return;
const url = this.assets.music[key];
if (!url) {
console.warn(`[SoundManager] Music track not found: ${key}`);
return;
}
// If same track is playing, do nothing
if (this.currentMusic && this.currentMusic.src.includes(url) && !this.currentMusic.paused) {
return;
}
// Stop current
this.stopMusic();
// Start new
this.currentMusic = new Audio(url);
this.currentMusic.loop = true;
this.currentMusic.volume = this.musicVolume;
// Handle autoplay promises
const playPromise = this.currentMusic.play();
if (playPromise !== undefined) {
playPromise.catch(error => {
console.log("[SoundManager] Autoplay prevented. Waiting for user interaction.");
// We can add a one-time click listener to window to resume if needed
const resume = () => {
this.currentMusic.play();
window.removeEventListener('click', resume);
window.removeEventListener('keydown', resume);
};
window.addEventListener('click', resume);
window.addEventListener('keydown', resume);
});
}
console.log(`[SoundManager] Playing music: ${key}`);
}
stopMusic() {
if (this.currentMusic) {
this.currentMusic.pause();
this.currentMusic.currentTime = 0;
this.currentMusic = null;
}
}
setMusicVolume(vol) {
this.musicVolume = Math.max(0, Math.min(1, vol));
if (this.currentMusic) {
this.currentMusic.volume = this.musicVolume;
}
}
toggleMute() {
this.isMuted = !this.isMuted;
if (this.isMuted) {
if (this.currentMusic) this.currentMusic.pause();
} else {
if (this.currentMusic) this.currentMusic.play();
}
return this.isMuted;
}
playSound(key) {
if (this.isMuted) return;
const url = this.assets.sfx[key];
if (!url) {
console.warn(`[SoundManager] SFX not found: ${key}`);
return;
}
const audio = new Audio(url);
audio.volume = this.sfxVolume;
// Fire and forget, but catch errors
audio.play().catch(e => {
// Check if error is NotAllowedError (autoplay) - silently ignore usually for SFX
// or log if needed
});
}
startLoop(key) {
if (this.isMuted) return;
if (this.activeLoops.has(key)) return; // Already playing
const url = this.assets.sfx[key];
if (!url) return;
const audio = new Audio(url);
audio.loop = true;
audio.volume = this.sfxVolume;
audio.play().catch(() => { });
this.activeLoops.set(key, audio);
}
stopLoop(key) {
const audio = this.activeLoops.get(key);
if (audio) {
audio.pause();
audio.currentTime = 0;
this.activeLoops.delete(key);
}
}
}

View File

@@ -1,4 +1,5 @@
import { DIRECTIONS } from '../engine/dungeon/Constants.js';
import { SPELLS } from '../engine/data/Spells.js';
export class UIManager {
constructor(cameraManager, gameEngine) {
@@ -145,12 +146,62 @@ export class UIManager {
zoomContainer.appendChild(zoomLabel);
zoomContainer.appendChild(zoomSlider);
// 2D/3D Toggle Button
const toggleViewBtn = document.createElement('button');
toggleViewBtn.textContent = '3D';
toggleViewBtn.title = 'Cambiar vista 2D/3D';
toggleViewBtn.style.width = '40px';
toggleViewBtn.style.height = '40px';
toggleViewBtn.style.borderRadius = '5px';
toggleViewBtn.style.border = '1px solid #aaa'; // Slightly softer border
toggleViewBtn.style.backgroundColor = 'rgba(0, 0, 0, 0.6)';
toggleViewBtn.style.color = '#daa520'; // Gold text
toggleViewBtn.style.cursor = 'pointer';
toggleViewBtn.style.fontFamily = '"Cinzel", serif';
toggleViewBtn.style.fontWeight = 'bold';
toggleViewBtn.style.fontSize = '14px';
toggleViewBtn.style.display = 'flex';
toggleViewBtn.style.alignItems = 'center';
toggleViewBtn.style.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;
// Determine if we are ABOUT to switch to 3D (currently 2D)
const isCurrently2D = (this.cameraManager.viewMode === '2D');
if (isCurrently2D) {
// Start of 2D -> 3D transition: Hide tokens immediately
if (this.cameraManager.renderer) {
this.cameraManager.renderer.hideTokens();
}
}
const is3D = this.cameraManager.toggleViewMode();
toggleViewBtn.textContent = is3D ? '3D' : '2D';
// If we switched to 2D (is3D === false), show tokens AFTER animation
if (!is3D) {
this.cameraManager.onAnimationComplete = () => {
if (this.cameraManager.renderer) {
this.cameraManager.renderer.showTokens(this.game.heroes, this.game.monsters);
}
};
}
}
};
// Direction buttons grid
const buttonsGrid = document.createElement('div');
buttonsGrid.style.display = 'grid';
buttonsGrid.style.gridTemplateColumns = '40px 40px 40px';
buttonsGrid.style.gap = '5px';
controlsContainer.appendChild(toggleViewBtn); // Leftmost
controlsContainer.appendChild(zoomContainer);
controlsContainer.appendChild(buttonsGrid);
@@ -482,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 => {
@@ -549,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;
@@ -664,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 => {