feat: magic system visuals, audio sfx, and ui polish
This commit is contained in:
36
DEVLOG.md
36
DEVLOG.md
@@ -1,7 +1,41 @@
|
|||||||
# Devlog - Warhammer Quest (Versión Web 3D)
|
# 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
|
### Objetivos Completados
|
||||||
1. **Vista Táctica (Toggle 2D/3D)**:
|
1. **Vista Táctica (Toggle 2D/3D)**:
|
||||||
|
|||||||
@@ -39,7 +39,9 @@
|
|||||||
- [x] Implement Monster AI (Sequential Movement, Pathfinding, Attack Approach)
|
- [x] Implement Monster AI (Sequential Movement, Pathfinding, Attack Approach)
|
||||||
- [x] Implement Combat Logic (Melee Attack Rolls, Damage, Death State)
|
- [x] Implement Combat Logic (Melee Attack Rolls, Damage, Death State)
|
||||||
- [x] Implement Game Loop Rules (Exploration Stop, Continuous Combat, Phase Skipping)
|
- [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
|
## Phase 4: Campaign System
|
||||||
- [ ] **Campaign Manager**
|
- [ ] **Campaign Manager**
|
||||||
|
|||||||
BIN
public/assets/sfx/arrow.mp3
Normal file
BIN
public/assets/sfx/arrow.mp3
Normal file
Binary file not shown.
BIN
public/assets/sfx/footsteps.mp3
Normal file
BIN
public/assets/sfx/footsteps.mp3
Normal file
Binary file not shown.
BIN
public/assets/sfx/sword1.mp3
Normal file
BIN
public/assets/sfx/sword1.mp3
Normal file
Binary file not shown.
24
src/engine/data/Spells.js
Normal file
24
src/engine/data/Spells.js
Normal 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."
|
||||||
|
}
|
||||||
|
];
|
||||||
@@ -15,11 +15,9 @@ export class DungeonDeck {
|
|||||||
// 1. Create a "Pool" of standard dungeon tiles
|
// 1. Create a "Pool" of standard dungeon tiles
|
||||||
let pool = [];
|
let pool = [];
|
||||||
const composition = [
|
const composition = [
|
||||||
{ id: 'room_dungeon', count: 6 },
|
{ id: 'room_dungeon', count: 18 }, // Rigged: Only Rooms
|
||||||
{ id: 'corridor_straight', count: 7 },
|
{ id: 'corridor_straight', count: 0 },
|
||||||
{ id: 'corridor_steps', count: 1 },
|
{ id: 'junction_t', count: 0 }
|
||||||
{ id: 'corridor_corner', count: 1 }, // L-Shape
|
|
||||||
{ id: 'junction_t', count: 3 }
|
|
||||||
];
|
];
|
||||||
|
|
||||||
composition.forEach(item => {
|
composition.forEach(item => {
|
||||||
|
|||||||
101
src/engine/game/CombatSystem.js
Normal file
101
src/engine/game/CombatSystem.js
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { DungeonGenerator } from '../dungeon/DungeonGenerator.js';
|
import { DungeonGenerator } from '../dungeon/DungeonGenerator.js';
|
||||||
import { TurnManager } from './TurnManager.js';
|
import { TurnManager } from './TurnManager.js';
|
||||||
import { MonsterAI } from './MonsterAI.js';
|
import { MonsterAI } from './MonsterAI.js';
|
||||||
|
import { MagicSystem } from './MagicSystem.js';
|
||||||
|
import { CombatSystem } from './CombatSystem.js';
|
||||||
import { CombatMechanics } from './CombatMechanics.js';
|
import { CombatMechanics } from './CombatMechanics.js';
|
||||||
import { HERO_DEFINITIONS } from '../data/Heroes.js';
|
import { HERO_DEFINITIONS } from '../data/Heroes.js';
|
||||||
import { MONSTER_DEFINITIONS } from '../data/Monsters.js';
|
import { MONSTER_DEFINITIONS } from '../data/Monsters.js';
|
||||||
@@ -14,6 +16,8 @@ export class GameEngine {
|
|||||||
this.dungeon = new DungeonGenerator();
|
this.dungeon = new DungeonGenerator();
|
||||||
this.turnManager = new TurnManager();
|
this.turnManager = new TurnManager();
|
||||||
this.ai = new MonsterAI(this); // Init AI
|
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.player = null;
|
||||||
this.selectedEntity = null;
|
this.selectedEntity = null;
|
||||||
this.isRunning = false;
|
this.isRunning = false;
|
||||||
@@ -147,7 +151,73 @@ export class GameEngine {
|
|||||||
return monster;
|
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) {
|
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
|
// RANGED TARGETING LOGIC
|
||||||
if (this.targetingMode === 'ranged') {
|
if (this.targetingMode === 'ranged') {
|
||||||
const clickedMonster = this.monsters ? this.monsters.find(m => m.x === x && m.y === y && !m.isDead) : null;
|
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) {
|
performHeroAttack(targetMonsterId) {
|
||||||
const hero = this.selectedEntity;
|
const hero = this.selectedEntity;
|
||||||
const monster = this.monsters.find(m => m.id === targetMonsterId);
|
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
|
canCastSpell(spell) {
|
||||||
if (this.turnManager.currentPhase !== 'hero') return { success: false, reason: 'phase' };
|
return this.magicSystem.canCastSpell(this.selectedEntity, spell);
|
||||||
|
}
|
||||||
|
|
||||||
// Check Adjacency
|
executeSpell(spell, targetCells = []) {
|
||||||
const dx = Math.abs(hero.x - monster.x);
|
if (!this.selectedEntity) return { success: false, reason: 'no_caster' };
|
||||||
const dy = Math.abs(hero.y - monster.y);
|
return this.magicSystem.executeSpell(this.selectedEntity, spell, targetCells);
|
||||||
if (dx + dy !== 1) return { success: false, reason: 'range' };
|
}
|
||||||
|
|
||||||
// Check Action Economy
|
deselectEntity() {
|
||||||
if (hero.hasAttacked) return { success: false, reason: 'cooldown' };
|
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
|
// Also deselect monster if selected
|
||||||
const result = CombatMechanics.resolveMeleeAttack(hero, monster, this);
|
if (this.selectedMonster) {
|
||||||
hero.hasAttacked = true;
|
const monsterId = this.selectedMonster.id;
|
||||||
|
this.selectedMonster = null;
|
||||||
if (window.SOUND_MANAGER) window.SOUND_MANAGER.playSound('sword');
|
if (this.onEntitySelect) this.onEntitySelect(monsterId, false);
|
||||||
|
}
|
||||||
if (this.onCombatResult) this.onCombatResult(result);
|
|
||||||
|
|
||||||
return { success: true, result };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isEntityPinned(entity) {
|
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
|
// Alias for legacy calls if any
|
||||||
deselectPlayer() {
|
deselectPlayer() {
|
||||||
this.deselectEntity();
|
this.deselectEntity();
|
||||||
@@ -646,8 +686,16 @@ export class GameEngine {
|
|||||||
console.log("Ranged Targeting Mode ON");
|
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() {
|
cancelTargeting() {
|
||||||
this.targetingMode = null;
|
this.targetingMode = null;
|
||||||
|
this.currentSpell = null;
|
||||||
if (this.onRangedTarget) {
|
if (this.onRangedTarget) {
|
||||||
this.onRangedTarget(null, null);
|
this.onRangedTarget(null, null);
|
||||||
}
|
}
|
||||||
|
|||||||
159
src/engine/game/MagicSystem.js
Normal file
159
src/engine/game/MagicSystem.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,10 @@ export class TurnManager {
|
|||||||
this.eventsTriggered = [];
|
this.eventsTriggered = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get power() {
|
||||||
|
return this.currentPowerRoll;
|
||||||
|
}
|
||||||
|
|
||||||
startGame() {
|
startGame() {
|
||||||
this.currentTurn = 1;
|
this.currentTurn = 1;
|
||||||
console.log(`--- TURN ${this.currentTurn} START ---`);
|
console.log(`--- TURN ${this.currentTurn} START ---`);
|
||||||
|
|||||||
23
src/main.js
23
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);
|
const defender = game.heroes.find(h => h.id === log.defenderId) || game.monsters.find(m => m.id === log.defenderId);
|
||||||
if (defender) {
|
if (defender) {
|
||||||
setTimeout(() => { // Slight delay for cause-effect
|
setTimeout(() => { // Slight delay for cause-effect
|
||||||
if (log.hitSuccess) {
|
renderer.showCombatFeedback(defender.x, defender.y, log.woundsCaused, 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');
|
|
||||||
}
|
|
||||||
}, 500);
|
}, 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -294,7 +282,16 @@ renderer.setupInteraction(
|
|||||||
handleClick,
|
handleClick,
|
||||||
() => {
|
() => {
|
||||||
// Right Click Handler
|
// 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();
|
game.executeMovePath();
|
||||||
|
},
|
||||||
|
(x, y) => {
|
||||||
|
if (game.onCellHover) game.onCellHover(x, y);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
|
import { DIRECTIONS } from '../engine/dungeon/Constants.js';
|
||||||
|
import { ParticleManager } from './ParticleManager.js';
|
||||||
|
|
||||||
export class GameRenderer {
|
export class GameRenderer {
|
||||||
constructor(containerId) {
|
constructor(containerId) {
|
||||||
this.container = document.getElementById(containerId) || document.body;
|
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 = 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
|
// 2. Renderer
|
||||||
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
|
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
|
||||||
this.renderer.setSize(window.innerWidth, window.innerHeight);
|
this.renderer.setSize(window.innerWidth, window.innerHeight);
|
||||||
@@ -49,6 +56,10 @@ export class GameRenderer {
|
|||||||
|
|
||||||
this.tokensGroup = new THREE.Group();
|
this.tokensGroup = new THREE.Group();
|
||||||
this.scene.add(this.tokensGroup);
|
this.scene.add(this.tokensGroup);
|
||||||
|
|
||||||
|
this.spellPreviewGroup = new THREE.Group();
|
||||||
|
this.scene.add(this.spellPreviewGroup);
|
||||||
|
|
||||||
this.tokens = new Map();
|
this.tokens = new Map();
|
||||||
|
|
||||||
this.entities = new Map();
|
this.entities = new Map();
|
||||||
@@ -70,7 +81,7 @@ export class GameRenderer {
|
|||||||
this.scene.add(dirLight);
|
this.scene.add(dirLight);
|
||||||
}
|
}
|
||||||
|
|
||||||
setupInteraction(cameraGetter, onClick, onRightClick) {
|
setupInteraction(cameraGetter, onClick, onRightClick, onHover = null) {
|
||||||
const getMousePos = (event) => {
|
const getMousePos = (event) => {
|
||||||
const rect = this.renderer.domElement.getBoundingClientRect();
|
const rect = this.renderer.domElement.getBoundingClientRect();
|
||||||
return {
|
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.renderer.domElement.addEventListener('click', (event) => {
|
||||||
this.mouse.set(getMousePos(event).x, getMousePos(event).y);
|
this.mouse.set(getMousePos(event).x, getMousePos(event).y);
|
||||||
this.raycaster.setFromCamera(this.mouse, cameraGetter());
|
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) {
|
addEntity(entity) {
|
||||||
if (this.entities.has(entity.id)) return;
|
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") {
|
showFloatingText(x, y, text, color = "#ffffff") {
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = 256;
|
canvas.width = 256;
|
||||||
@@ -329,6 +398,33 @@ export class GameRenderer {
|
|||||||
this.floatingTextGroup.add(sprite);
|
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) {
|
triggerDeathAnimation(entityId) {
|
||||||
const mesh = this.entities.get(entityId);
|
const mesh = this.entities.get(entityId);
|
||||||
if (!mesh) return;
|
if (!mesh) return;
|
||||||
@@ -355,6 +451,7 @@ export class GameRenderer {
|
|||||||
}, duration);
|
}, duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
moveEntityAlongPath(entity, path) {
|
moveEntityAlongPath(entity, path) {
|
||||||
const mesh = this.entities.get(entity.id);
|
const mesh = this.entities.get(entity.id);
|
||||||
if (mesh) {
|
if (mesh) {
|
||||||
@@ -381,6 +478,15 @@ export class GameRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateAnimations(time) {
|
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;
|
let isAnyMoving = false;
|
||||||
|
|
||||||
this.entities.forEach((mesh, id) => {
|
this.entities.forEach((mesh, id) => {
|
||||||
|
|||||||
216
src/view/ParticleManager.js
Normal file
216
src/view/ParticleManager.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { DIRECTIONS } from '../engine/dungeon/Constants.js';
|
import { DIRECTIONS } from '../engine/dungeon/Constants.js';
|
||||||
|
import { SPELLS } from '../engine/data/Spells.js';
|
||||||
|
|
||||||
export class UIManager {
|
export class UIManager {
|
||||||
constructor(cameraManager, gameEngine) {
|
constructor(cameraManager, gameEngine) {
|
||||||
@@ -532,14 +533,14 @@ export class UIManager {
|
|||||||
statsGrid.style.marginBottom = '8px';
|
statsGrid.style.marginBottom = '8px';
|
||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
{ label: 'WS', value: hero.stats.ws || 0 },
|
{ label: 'H.C', value: hero.stats.ws || 0 }, // Hab. Combate
|
||||||
{ label: 'BS', value: hero.stats.bs || 0 },
|
{ label: 'H.P', value: hero.stats.bs || 0 }, // Hab. Proyectiles
|
||||||
{ label: 'S', value: hero.stats.str || 0 },
|
{ label: 'Fuer', value: hero.stats.str || 0 }, // Fuerza
|
||||||
{ label: 'T', value: hero.stats.toughness || 0 },
|
{ label: 'Res', value: hero.stats.toughness || 0 }, // Resistencia
|
||||||
{ label: 'W', value: `${hero.currentWounds || hero.stats.wounds}/${hero.stats.wounds}` },
|
{ label: 'Her', value: `${hero.currentWounds || hero.stats.wounds}/${hero.stats.wounds}` }, // Heridas
|
||||||
{ label: 'I', value: hero.stats.initiative || 0 },
|
{ label: 'Ini', value: hero.stats.initiative || 0 },// Iniciativa
|
||||||
{ label: 'A', value: hero.stats.attacks || 0 },
|
{ label: 'Ata', value: hero.stats.attacks || 0 }, // Ataques
|
||||||
{ label: 'Mov', value: `${hero.currentMoves || 0}/${hero.stats.move}` }
|
{ label: 'Mov', value: `${hero.currentMoves || 0}/${hero.stats.move}` } // Movimiento
|
||||||
];
|
];
|
||||||
|
|
||||||
stats.forEach(stat => {
|
stats.forEach(stat => {
|
||||||
@@ -599,6 +600,50 @@ export class UIManager {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
card.appendChild(bowBtn);
|
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;
|
card.dataset.heroId = hero.id;
|
||||||
@@ -714,12 +759,12 @@ export class UIManager {
|
|||||||
statsGrid.style.fontSize = '12px';
|
statsGrid.style.fontSize = '12px';
|
||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
{ label: 'WS', value: monster.stats.ws || 0 },
|
{ label: 'H.C', value: monster.stats.ws || 0 }, // Hab. Combate
|
||||||
{ label: 'S', value: monster.stats.str || 0 },
|
{ label: 'Fuer', value: monster.stats.str || 0 }, // Fuerza
|
||||||
{ label: 'T', value: monster.stats.toughness || 0 },
|
{ label: 'Res', value: monster.stats.toughness || 0 }, // Resistencia
|
||||||
{ label: 'W', value: `${monster.currentWounds || monster.stats.wounds}/${monster.stats.wounds}` },
|
{ label: 'Her', value: `${monster.currentWounds || monster.stats.wounds}/${monster.stats.wounds}` }, // Heridas
|
||||||
{ label: 'I', value: monster.stats.initiative || 0 },
|
{ label: 'Ini', value: monster.stats.initiative || 0 }, // Iniciativa
|
||||||
{ label: 'A', value: monster.stats.attacks || 0 }
|
{ label: 'Ata', value: monster.stats.attacks || 0 } // Ataques
|
||||||
];
|
];
|
||||||
|
|
||||||
stats.forEach(stat => {
|
stats.forEach(stat => {
|
||||||
|
|||||||
Reference in New Issue
Block a user