feat: magic system visuals, audio sfx, and ui polish
This commit is contained in:
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 { 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);
|
||||
}
|
||||
|
||||
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 = [];
|
||||
}
|
||||
|
||||
get power() {
|
||||
return this.currentPowerRoll;
|
||||
}
|
||||
|
||||
startGame() {
|
||||
this.currentTurn = 1;
|
||||
console.log(`--- TURN ${this.currentTurn} START ---`);
|
||||
|
||||
Reference in New Issue
Block a user