feat: magic system visuals, audio sfx, and ui polish

This commit is contained in:
2026-01-07 22:42:34 +01:00
parent df3f892eb2
commit f2f399c296
15 changed files with 841 additions and 107 deletions

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,29 +306,38 @@ export class GameEngine {
performHeroAttack(targetMonsterId) {
const hero = this.selectedEntity;
const monster = this.monsters.find(m => m.id === targetMonsterId);
return this.combatSystem.handleMeleeAttack(hero, monster);
}
if (!hero || !monster) return null;
performRangedAttack(targetMonsterId) {
const hero = this.selectedEntity;
const monster = this.monsters.find(m => m.id === targetMonsterId);
return this.combatSystem.handleRangedAttack(hero, monster);
}
// Check Phase
if (this.turnManager.currentPhase !== 'hero') return { success: false, reason: 'phase' };
canCastSpell(spell) {
return this.magicSystem.canCastSpell(this.selectedEntity, spell);
}
// Check Adjacency
const dx = Math.abs(hero.x - monster.x);
const dy = Math.abs(hero.y - monster.y);
if (dx + dy !== 1) return { success: false, reason: 'range' };
executeSpell(spell, targetCells = []) {
if (!this.selectedEntity) return { success: false, reason: 'no_caster' };
return this.magicSystem.executeSpell(this.selectedEntity, spell, targetCells);
}
// Check Action Economy
if (hero.hasAttacked) return { success: false, reason: 'cooldown' };
deselectEntity() {
if (!this.selectedEntity) return;
const id = this.selectedEntity.id;
this.selectedEntity = null;
this.plannedPath = [];
if (this.onEntitySelect) this.onEntitySelect(id, false);
if (this.onPathChange) this.onPathChange([]);
// Execute Attack
const result = CombatMechanics.resolveMeleeAttack(hero, monster, this);
hero.hasAttacked = true;
if (window.SOUND_MANAGER) window.SOUND_MANAGER.playSound('sword');
if (this.onCombatResult) this.onCombatResult(result);
return { success: true, result };
// Also deselect monster if selected
if (this.selectedMonster) {
const monsterId = this.selectedMonster.id;
this.selectedMonster = null;
if (this.onEntitySelect) this.onEntitySelect(monsterId, false);
}
}
isEntityPinned(entity) {
@@ -295,45 +374,6 @@ export class GameEngine {
});
}
performRangedAttack(targetMonsterId) {
const hero = this.selectedEntity;
const monster = this.monsters.find(m => m.id === targetMonsterId);
if (!hero || !monster) return null;
if (this.turnManager.currentPhase !== 'hero') return { success: false, reason: 'phase' };
if (hero.hasAttacked) return { success: false, reason: 'cooldown' };
if (this.isEntityPinned(hero)) return { success: false, reason: 'pinned' };
// LOS Check should be done before calling this, but we can double check or assume UI did it.
// For simplicity, we execute the attack here assuming validation passed.
const result = CombatMechanics.resolveRangedAttack(hero, monster, this);
hero.hasAttacked = true;
if (window.SOUND_MANAGER) window.SOUND_MANAGER.playSound('arrow');
if (this.onCombatResult) this.onCombatResult(result);
return { success: true, result };
}
deselectEntity() {
if (!this.selectedEntity) return;
const id = this.selectedEntity.id;
this.selectedEntity = null;
this.plannedPath = [];
if (this.onEntitySelect) this.onEntitySelect(id, false);
if (this.onPathChange) this.onPathChange([]);
// Also deselect monster if selected
if (this.selectedMonster) {
const monsterId = this.selectedMonster.id;
this.selectedMonster = null;
if (this.onEntitySelect) this.onEntitySelect(monsterId, false);
}
}
// Alias for legacy calls if any
deselectPlayer() {
this.deselectEntity();
@@ -646,8 +686,16 @@ export class GameEngine {
console.log("Ranged Targeting Mode ON");
}
startSpellTargeting(spell) {
this.targetingMode = 'spell';
this.currentSpell = spell;
console.log(`Spell Targeting Mode ON: ${spell.name}`);
if (this.onShowMessage) this.onShowMessage(spell.name, 'Selecciona el objetivo (Monstruo o Casilla).');
}
cancelTargeting() {
this.targetingMode = null;
this.currentSpell = null;
if (this.onRangedTarget) {
this.onRangedTarget(null, null);
}

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 ---`);