feat: implement "all" target modifier for skills and battle music playlist

Add a new `all` modifier to skills (`strike`, `pierce`, `mortar`, `rally`, `heal`, `enfeeble`, `weaken`, `rupture`) that targets all valid enemies or allies instead of a single target. This includes:

- **Data Layer**: Updated cards.json with expanded skill definitions and added new cards (Imperial Guardian, Pillager, Marauder). Added `allDescription` and `supportsAll` flags to skills.json.
- **Combat Logic**: Extended SkillProcessor and CombatEngine to handle "all" variants, managing parallel state updates for HP, armor, attack stats, and buffs/debuffs across multiple targets.
- **Visuals**: Implemented new animation sequences in BattleScene for area-of-effect skills (parallel projectiles, shared sound effects) and updated CardTooltip/CardObject to display the "all" modifier.
- **Audio**: Introduced a shuffled battle music playlist that cycles through multiple tracks during combat, resuming main menu music upon exit.
This commit is contained in:
Brian Fertig 2026-03-14 15:26:21 -06:00
parent 313d2548d9
commit f927529555
13 changed files with 2231 additions and 204 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -2,8 +2,10 @@
{
"name": "strike",
"description": "Before attacking: fire a missile at the card directly across (or commander) for value damage (armor applies).",
"allDescription": "Before attacking: fire missiles at ALL enemy cards for value damage each (armor applies). Commander only if no cards remain.",
"trigger": "preAttack",
"category": "offense"
"category": "offense",
"supportsAll": true
},
{
"name": "swipe",
@ -14,14 +16,18 @@
{
"name": "pierce",
"description": "Before attacking: reduce the target's armor by value for this attack, then restore it after.",
"allDescription": "Before attacking: reduce ALL enemy cards' armor by value for this attack, then restore after.",
"trigger": "preAttack",
"category": "offense"
"category": "offense",
"supportsAll": true
},
{
"name": "rupture",
"description": "Apply rupture stacks to target. Each stack deals 1 damage at the start of the turn.",
"allDescription": "Apply rupture stacks to ALL enemy cards. Each stack deals 1 damage at the start of the turn.",
"trigger": "on_attack",
"category": "offense"
"category": "offense",
"supportsAll": true
},
{
"name": "berserk",
@ -38,14 +44,18 @@
{
"name": "rally",
"description": "Grant a random allied assault card +value attack for this turn.",
"allDescription": "Grant ALL allied assault cards +value attack for this turn.",
"trigger": "preBattle",
"category": "support"
"category": "support",
"supportsAll": true
},
{
"name": "heal",
"description": "Restore value HP to the most wounded ally.",
"allDescription": "Restore value HP to ALL wounded allies.",
"trigger": "on_attack",
"category": "support"
"category": "support",
"supportsAll": true
},
{
"name": "protect",
@ -62,14 +72,18 @@
{
"name": "enfeeble",
"description": "Reduce target's attack by value permanently.",
"allDescription": "Reduce ALL enemy cards' attack by value. Restored after battle.",
"trigger": "on_attack",
"category": "control"
"category": "control",
"supportsAll": true
},
{
"name": "weaken",
"description": "Reduce all of target's skill values by value.",
"allDescription": "Reduce ALL enemy cards' skill values by value.",
"trigger": "on_attack",
"category": "control"
"category": "control",
"supportsAll": true
},
{
"name": "tribute",
@ -86,8 +100,10 @@
{
"name": "mortar",
"description": "Before attacking: fire a mortar at a random enemy card for value damage (armor applies).",
"allDescription": "Before attacking: fire mortars at ALL enemy cards for value damage each (armor applies).",
"trigger": "preAttack",
"category": "offense"
"category": "offense",
"supportsAll": true
},
{
"name": "flurry",

View File

@ -181,10 +181,20 @@ export class CombatEngine {
// on_attack skills
for (const s of attacker.skills) {
if (s.trigger === 'on_attack') {
const onAtkCtx = { ...context, trigger: 'on_attack', damageDealt: dmg };
this.skillProcessor.process(s, attacker, currentTarget,
alliedLanes.filter(c => c.currentHP > 0),
enemyLanes.filter(c => c.currentHP > 0),
{ ...context, trigger: 'on_attack', damageDealt: dmg });
onAtkCtx);
if (onAtkCtx.ruptureAllTargets) {
this.events.push({ type: 'ruptureAll', attacker, targets: onAtkCtx.ruptureAllTargets, side });
}
if (onAtkCtx.healAllTargets) {
this.events.push({ type: 'healAll', attacker, targets: onAtkCtx.healAllTargets, side });
}
if (onAtkCtx.weakenAllTargets) {
this.events.push({ type: 'weakenAll', attacker, targets: onAtkCtx.weakenAllTargets, side });
}
}
}
@ -360,7 +370,11 @@ export class CombatEngine {
// Pass laneCards + enemyLaneCards so positional skills (protect, enfeeble) work
const ctx = { rng: this.rng, enemyCommander, laneCards: cards, enemyLaneCards: enemyLanes };
this.skillProcessor.process(s, card, null, liveAllies, liveEnemies, ctx);
if (s.name === 'rally' && ctx.rallyTarget) {
if (s.name === 'rally' && ctx.rallyAllTargets) {
for (const t of ctx.rallyAllTargets) {
buffs.push({ skill: 'rally', source: card, target: t.target, amount: t.amount, isAll: true });
}
} else if (s.name === 'rally' && ctx.rallyTarget) {
buffs.push({ skill: 'rally', source: card, target: ctx.rallyTarget, amount: s.value });
}
if (s.name === 'siege' && ctx.siegeTarget) {
@ -372,7 +386,12 @@ export class CombatEngine {
protectFires.push({ skill: 'protect', source: card, targets: ctx.protectTargets });
this._log(`${card.name} protect buffs ${ctx.protectTargets.map(t => t.card.name).join(', ')} +${s.value} ARM`);
}
if (s.name === 'enfeeble' && ctx.enfeebleTarget) {
if (s.name === 'enfeeble' && ctx.enfeebleAllTargets) {
for (const t of ctx.enfeebleAllTargets) {
enfeebeFires.push({ skill: 'enfeeble', source: card, target: t.target, amount: t.amount, isAll: true });
this._log(`${card.name} enfeeble all reduces ${t.target.name} ATK by ${t.amount}`);
}
} else if (s.name === 'enfeeble' && ctx.enfeebleTarget) {
enfeebeFires.push({ skill: 'enfeeble', source: card, target: ctx.enfeebleTarget, amount: ctx.enfeebleAmount });
this._log(`${card.name} enfeeble reduces ${ctx.enfeebleTarget.name} ATK by ${ctx.enfeebleAmount}`);
}
@ -463,7 +482,15 @@ export class CombatEngine {
if (s.trigger !== 'preAttack') continue;
const ctx = { rng: this.rng, enemyCommander: pending.enemyCommander };
this.skillProcessor.process(s, pending.attacker, pending.target, liveAllies, liveEnemies, ctx);
if (s.name === 'mortar' && ctx.mortarTarget) {
if (s.name === 'mortar' && ctx.mortarAllTargets) {
for (const t of ctx.mortarAllTargets) {
preAttackFires.push({ skill: 'mortar', target: t.target, damage: t.damage, hpBefore: t.hpBefore, isAll: true });
if (t.target.currentHP <= 0) {
this._log(`${t.target.name} is destroyed by mortar all`);
this.events.push({ type: 'death', card: t.target, side: pending.side === 'player' ? 'opponent' : 'player' });
}
}
} else if (s.name === 'mortar' && ctx.mortarTarget) {
// hpBefore: recover the HP value before the skill applied its damage
const hpBefore = ctx.mortarTarget.currentHP + ctx.mortarDamage;
preAttackFires.push({ skill: 'mortar', target: ctx.mortarTarget, damage: ctx.mortarDamage, hpBefore });
@ -472,7 +499,16 @@ export class CombatEngine {
this.events.push({ type: 'death', card: ctx.mortarTarget, side: pending.side === 'player' ? 'opponent' : 'player' });
}
}
if (s.name === 'strike' && ctx.strikeTarget) {
if (s.name === 'strike' && ctx.strikeAllTargets) {
for (const t of ctx.strikeAllTargets) {
const targetIsCommander = t.target === pending.enemyCommander;
preAttackFires.push({ skill: 'strike', target: t.target, damage: t.damage, hpBefore: t.hpBefore, targetIsCommander, isAll: true });
if (!targetIsCommander && t.target.currentHP <= 0) {
this._log(`${t.target.name} is destroyed by strike all`);
this.events.push({ type: 'death', card: t.target, side: pending.side === 'player' ? 'opponent' : 'player' });
}
}
} else if (s.name === 'strike' && ctx.strikeTarget) {
const hpBefore = ctx.strikeTarget.currentHP + ctx.strikeDamage;
const targetIsCommander = ctx.strikeTarget === pending.enemyCommander;
preAttackFires.push({ skill: 'strike', target: ctx.strikeTarget, damage: ctx.strikeDamage, hpBefore, targetIsCommander });
@ -481,7 +517,13 @@ export class CombatEngine {
this.events.push({ type: 'death', card: ctx.strikeTarget, side: pending.side === 'player' ? 'opponent' : 'player' });
}
}
if (s.name === 'pierce' && ctx.pierceTarget) {
if (s.name === 'pierce' && ctx.pierceAllTargets) {
for (const t of ctx.pierceAllTargets) {
const targetIsCommander = t.target === pending.enemyCommander;
preAttackFires.push({ skill: 'pierce', target: t.target, amount: t.amount, armBefore: t.armBefore, targetIsCommander, isAll: true });
this._log(`${pending.attacker.name} pierce all reduces ${t.target.name} ARM by ${t.amount}`);
}
} else if (s.name === 'pierce' && ctx.pierceTarget) {
const armBefore = ctx.pierceTarget.currentArmor + ctx.pierceAmount;
const targetIsCommander = ctx.pierceTarget === pending.enemyCommander;
preAttackFires.push({ skill: 'pierce', target: ctx.pierceTarget, amount: ctx.pierceAmount, armBefore, targetIsCommander });
@ -510,6 +552,7 @@ export class CombatEngine {
// postAttack event — fires after the attack animation resolves.
// Only references cards still alive so skill handlers don't need to guard.
// pierceRestores: armor that was temporarily reduced by pierce — restore if target survived.
// For "all" pierce, restore armor on ALL targets that survived.
const pierceRestores = preAttackFires
.filter(f => f.skill === 'pierce' && f.amount > 0 && f.target.currentHP > 0)
.map(f => ({ card: f.target, amount: f.amount }));

View File

@ -7,6 +7,19 @@ export class SkillProcessor {
}
strike(skill, attacker, defender, allies, enemies, context) {
if (skill.all) {
const targets = enemies.length > 0 ? enemies.filter(e => e.currentHP > 0) : (context?.enemyCommander ? [context.enemyCommander] : []);
if (targets.length === 0) return;
const allTargets = [];
for (const t of targets) {
const dmg = Math.max(0, skill.value - Math.max(0, t.currentArmor));
t.currentHP -= dmg;
attacker._lastSkillDmg = (attacker._lastSkillDmg || 0) + dmg;
allTargets.push({ target: t, damage: dmg, hpBefore: t.currentHP + dmg });
}
if (context) context.strikeAllTargets = allTargets;
return;
}
const target = defender || context?.enemyCommander;
if (!target) return;
const dmg = Math.max(0, skill.value - Math.max(0, target.currentArmor));
@ -27,6 +40,18 @@ export class SkillProcessor {
}
pierce(skill, attacker, defender, allies, enemies, context) {
if (skill.all) {
const targets = enemies.length > 0 ? enemies.filter(e => e.currentHP > 0) : (context?.enemyCommander ? [context.enemyCommander] : []);
if (targets.length === 0) return;
const allTargets = [];
for (const t of targets) {
const amount = Math.min(skill.value, Math.max(0, t.currentArmor));
t.currentArmor -= amount;
allTargets.push({ target: t, amount, armBefore: t.currentArmor + amount });
}
if (context) context.pierceAllTargets = allTargets;
return;
}
const target = defender || context?.enemyCommander;
if (!target) return;
// Reduce armor directly (floored at 0); track actual amount so it can be restored.
@ -38,7 +63,18 @@ export class SkillProcessor {
}
}
rupture(skill, attacker, defender) {
rupture(skill, attacker, defender, allies, enemies, context) {
if (skill.all) {
const targets = enemies.filter(e => e.currentHP > 0);
if (targets.length === 0) return;
const allTargets = [];
for (const t of targets) {
t.ruptureStacks = (t.ruptureStacks || 0) + skill.value;
allTargets.push({ target: t, stacks: skill.value });
}
if (context) context.ruptureAllTargets = allTargets;
return;
}
if (!defender) return;
defender.ruptureStacks = (defender.ruptureStacks || 0) + skill.value;
}
@ -61,6 +97,17 @@ export class SkillProcessor {
rally(skill, attacker, defender, allies, enemies, context) {
const assaultAllies = allies.filter(a => a.type === 'assault' && a !== attacker && a.currentHP > 0);
if (assaultAllies.length === 0) return;
if (skill.all) {
const allTargets = [];
for (const t of assaultAllies) {
t.currentAttack += skill.value;
t._tempBuffs = t._tempBuffs || [];
t._tempBuffs.push({ stat: 'currentAttack', amount: skill.value, source: 'rally' });
allTargets.push({ target: t, amount: skill.value });
}
if (context) context.rallyAllTargets = allTargets;
return;
}
const rng = context?.rng;
const idx = rng
? rng.nextInt(0, assaultAllies.length - 1)
@ -74,11 +121,21 @@ export class SkillProcessor {
context.rallyTarget = target;
}
heal(skill, attacker, defender, allies) {
heal(skill, attacker, defender, allies, enemies, context) {
const damaged = allies
.filter(a => a.currentHP > 0 && a.currentHP < a.health)
.sort((a, b) => (a.currentHP / a.health) - (b.currentHP / b.health));
if (damaged.length === 0) return;
if (skill.all) {
const allTargets = [];
for (const t of damaged) {
const hpBefore = t.currentHP;
t.currentHP = Math.min(t.health, t.currentHP + skill.value);
allTargets.push({ target: t, healAmount: t.currentHP - hpBefore, hpBefore });
}
if (context) context.healAllTargets = allTargets;
return;
}
damaged[0].currentHP = Math.min(damaged[0].health, damaged[0].currentHP + skill.value);
}
@ -161,9 +218,22 @@ export class SkillProcessor {
}
enfeeble(skill, attacker, defender, allies, enemies, context) {
// preBattle: target the enemy card directly across from this card's lane position
const laneCards = context?.laneCards || [];
const enemyLaneCards = context?.enemyLaneCards || [];
if (skill.all) {
const allTargets = [];
for (const t of enemyLaneCards) {
if (!t || t.currentHP <= 0) continue;
const amount = Math.min(skill.value, t.currentAttack);
t.currentAttack = Math.max(0, t.currentAttack - amount);
t._tempBuffs = t._tempBuffs || [];
t._tempBuffs.push({ stat: 'currentAttack', amount: -amount, source: 'enfeeble' });
allTargets.push({ target: t, amount });
}
if (context && allTargets.length > 0) context.enfeebleAllTargets = allTargets;
return;
}
// preBattle: target the enemy card directly across from this card's lane position
const laneCards = context?.laneCards || [];
const idx = laneCards.indexOf(attacker);
if (idx < 0 || idx >= enemyLaneCards.length) return;
const target = enemyLaneCards[idx];
@ -179,7 +249,20 @@ export class SkillProcessor {
}
}
weaken(skill, attacker, defender) {
weaken(skill, attacker, defender, allies, enemies, context) {
if (skill.all) {
const targets = enemies.filter(e => e.currentHP > 0);
if (targets.length === 0) return;
const allTargets = [];
for (const t of targets) {
for (const s of t.skills) {
s.value = Math.max(0, (s.value || 0) - skill.value);
}
allTargets.push({ target: t });
}
if (context) context.weakenAllTargets = allTargets;
return;
}
if (!defender) return;
for (const s of defender.skills) {
s.value = Math.max(0, (s.value || 0) - skill.value);
@ -210,6 +293,18 @@ export class SkillProcessor {
mortar(skill, attacker, defender, allies, enemies, context) {
if (enemies.length === 0) return;
if (skill.all) {
const targets = enemies.filter(e => e.currentHP > 0);
if (targets.length === 0) return;
const allTargets = [];
for (const t of targets) {
const dmg = Math.max(0, skill.value - Math.max(0, t.currentArmor));
t.currentHP -= dmg;
allTargets.push({ target: t, damage: dmg, hpBefore: t.currentHP + dmg });
}
if (context) context.mortarAllTargets = allTargets;
return;
}
const rng = context?.rng;
const idx = rng ? rng.nextInt(0, enemies.length - 1) : Math.floor(Math.random() * enemies.length);
const target = enemies[idx];

View File

@ -125,7 +125,10 @@ export class CardObject extends Phaser.GameObjects.Container {
if (hasSkills) {
const skillStr = this.cardData.skills
.map(s => s.value != null ? `${s.name} ${s.value}` : s.name)
.map(s => {
const allMod = s.all ? ' all' : '';
return s.value != null ? `${s.name}${allMod} ${s.value}` : `${s.name}${allMod}`;
})
.join(' · ');
const skillY = contentTop + contentH * 0.72;
this.skillText = this.scene.add.text(0, skillY, skillStr, {
@ -206,7 +209,10 @@ export class CardObject extends Phaser.GameObjects.Container {
}
if (this.skillText && this.cardData.skills?.length) {
const skillStr = this.cardData.skills
.map(s => s.value != null ? `${s.name} ${s.value}` : s.name)
.map(s => {
const allMod = s.all ? ' all' : '';
return s.value != null ? `${s.name}${allMod} ${s.value}` : `${s.name}${allMod}`;
})
.join(' · ');
this.skillText.setText(skillStr);
}

View File

@ -75,21 +75,23 @@ export class CardTooltip extends Phaser.GameObjects.Container {
const catColor = CATEGORY_COLORS[def?.category] || '#ffcc44';
const triggerLabel = TRIGGER_LABELS[skill.trigger] || skill.trigger;
// Skill header: "strike 3 (Pre-Attack)"
// Skill header: "strike all 3 (Pre-Attack)"
const allMod = skill.all ? ' all' : '';
const valStr = skill.value != null ? ` ${skill.value}` : '';
lines.push({
text: `${skill.name}${valStr}`,
text: `${skill.name}${allMod}${valStr}`,
style: { fontSize: '21px', color: catColor, fontStyle: 'bold', wordWrap: { width: W - PAD * 2 } },
marginTop: 14, align: 'left',
suffix: { text: ` ${triggerLabel}`, style: { fontSize: '15px', color: '#888888' } }
});
// Skill description
if (def?.description) {
// Skill description — use allDescription when "all" modifier is present
const rawDesc = (skill.all && def?.allDescription) ? def.allDescription : def?.description;
if (rawDesc) {
// Replace "value" with the actual number for clarity
const desc = skill.value != null
? def.description.replace(/\bvalue\b/g, `${skill.value}`)
: def.description;
? rawDesc.replace(/\bvalue\b/g, `${skill.value}`)
: rawDesc;
lines.push({
text: desc,
style: { fontSize: '17px', color: '#bbbbbb', wordWrap: { width: W - PAD * 2 } },

View File

@ -3,9 +3,29 @@ import { CardObject } from '../objects/CardObject.js';
import { BattleField } from '../objects/BattleField.js';
import { SaveManager } from '../managers/SaveManager.js';
// ── Battle music playlist ─────────────────────────────────────────────────────
// Add or remove entries here to extend the track list.
// Tracks are loaded on-demand when BattleScene first loads (not at boot),
// shuffled randomly each time battle music starts, and cycle automatically.
const BATTLE_MUSIC = [
{ key: 'music_battle_01', path: 'assets/audio/music/battle_01.mp3' },
{ key: 'music_battle_02', path: 'assets/audio/music/battle_02.mp3' },
{ key: 'music_battle_03', path: 'assets/audio/music/battle_03.mp3' },
{ key: 'music_battle_04', path: 'assets/audio/music/battle_04.mp3' },
{ key: 'music_battle_05', path: 'assets/audio/music/battle_05.mp3' },
{ key: 'music_battle_06', path: 'assets/audio/music/battle_06.mp3' },
];
export class BattleScene extends Phaser.Scene {
constructor() { super('BattleScene'); }
preload() {
// Load battle music tracks on demand — only downloaded if not already cached
for (const track of BATTLE_MUSIC) {
this.load.audio(track.key, track.path);
}
}
init(data) {
this.missionData = data.mission || null;
this.playerDeckData = data.deck || null;
@ -23,6 +43,15 @@ export class BattleScene extends Phaser.Scene {
// Stop main menu music for the duration of the battle
this.registry.get('music_main_menu')?.stop();
// Resume main menu music and stop battle music when this scene shuts down
this.events.once('shutdown', () => {
this._stopBattleMusic();
const menuMusic = this.registry.get('music_main_menu');
if (menuMusic && !menuMusic.isPlaying) menuMusic.play();
});
this._startBattleMusic();
// Layout constants
// Lanes: 4 lanes centred at x = 430, 740, 1050, 1360 (spacing 310)
// Commander column: x = 150
@ -257,13 +286,15 @@ export class BattleScene extends Phaser.Scene {
const commitEvents = this.engine.beginCommit(chosenCard);
const preBattleEvents = commitEvents.filter(e => e.type === 'preBattle');
// Temporarily restore any jam-reduced skill values so _renderState() builds
// CardObjects with the original (pre-jam) values displayed. They are re-applied
// Temporarily restore any jam/enfeeble changes so _renderState() builds
// CardObjects with the original (pre-combat) values displayed. They are re-applied
// immediately after so combat math is unaffected.
this._restoreJamSkillsForDisplay(preBattleEvents);
this._restoreEnfeebleForDisplay(preBattleEvents);
// Render the field with all newly deployed cards but pre-combat HP
this._renderState();
this._reapplyJamSkillReductions(preBattleEvents);
this._reapplyEnfeebleReductions(preBattleEvents);
const newPlayerCard = chosenCard
? this.engine.getState().player.lanes.find(c => !oldPlayerIds.has(c.instanceId))
@ -324,10 +355,25 @@ export class BattleScene extends Phaser.Scene {
const events = this.engine.processNextAttack();
const preAttackEvent = events.find(e => e.type === 'preAttack');
const attackEvent = events.find(e => e.type === 'attack');
const postAttackEvent = events.find(e => e.type === 'postAttack');
if (!attackEvent) {
// Group events into attack rounds — flurry produces multiple attack events.
// Each round carries its own attack, berserk, counter, and on-attack-all events.
const attackRounds = [];
let currentRound = null;
for (const e of events) {
if (e.type === 'attack') {
currentRound = { attackEvent: e, berserkEvent: null, counterEvent: null, onAttackAllEvents: [] };
attackRounds.push(currentRound);
} else if (currentRound) {
if (e.type === 'berserk') currentRound.berserkEvent = e;
else if (e.type === 'counter') currentRound.counterEvent = e;
else if (e.type === 'ruptureAll' || e.type === 'healAll' || e.type === 'weakenAll')
currentRound.onAttackAllEvents.push(e);
}
}
if (attackRounds.length === 0) {
// Attacker was already dead (counter-killed earlier this turn) — skip
this.time.delayedCall(30, () => this._processNextAttackStep());
return;
@ -340,23 +386,36 @@ export class BattleScene extends Phaser.Scene {
this.commanderObjects.get(state.player.commander.instanceId)?.refresh();
this.commanderObjects.get(state.opponent.commander.instanceId)?.refresh();
const berserkEvent = events.find(e => e.type === 'berserk');
const counterEvent = events.find(e => e.type === 'counter');
// Fire pre-attack hook, then proceed to attack animation.
// Counter fires at impact (cards still enlarged at center) via onImpact callback.
// _refreshCardAfterAttack handles attacker refresh/death once cards return.
this._onPreAttackStep(preAttackEvent, () => {
this._animateAttack(attackEvent, () => {
this._refreshCardAfterAttack(attackEvent.defender);
this._refreshCardAfterAttack(attackEvent.attacker);
this.time.delayedCall(220, () => {
this._onPostAttackStep(postAttackEvent, () => this._processNextAttackStep());
// Animate each round sequentially. For flurry, cards return to their original
// positions after each attack before the next round begins.
const animateRound = (roundIdx) => {
if (roundIdx >= attackRounds.length) {
this._onPostAttackStep(postAttackEvent, () => this._processNextAttackStep());
return;
}
const round = attackRounds[roundIdx];
// Fire pre-attack hook only before the first round
const runAttack = () => {
this._animateAttack(round.attackEvent, () => {
this._refreshCardAfterAttack(round.attackEvent.defender);
this._refreshCardAfterAttack(round.attackEvent.attacker);
this.time.delayedCall(220, () => {
this._processOnAttackAllEvents(round.onAttackAllEvents, () => {
animateRound(roundIdx + 1);
});
});
}, round.berserkEvent, () => {
this._animateCounterFire(round.counterEvent);
});
}, berserkEvent, () => {
this._animateCounterFire(counterEvent);
});
});
};
if (roundIdx === 0) {
this._onPreAttackStep(preAttackEvent, runAttack);
} else {
runAttack();
}
};
animateRound(0);
}
// Update a card's HP bar and stats at the moment of impact, then handle death.
@ -374,6 +433,77 @@ export class BattleScene extends Phaser.Scene {
}
}
// ── on_attack "all" event processing ────────────────────────────────────────
_processOnAttackAllEvents(events, onComplete) {
if (!events || events.length === 0) { onComplete(); return; }
const next = (idx) => {
if (idx >= events.length) { onComplete(); return; }
const event = events[idx];
const cb = () => next(idx + 1);
if (event.type === 'ruptureAll') this._animateRuptureAll(event, cb);
else if (event.type === 'healAll') this._animateHealAll(event, cb);
else if (event.type === 'weakenAll') this._animateWeakenAll(event, cb);
else cb();
};
next(0);
}
_animateRuptureAll(event, onComplete) {
if (!event.targets?.length) { onComplete(); return; }
this.statusText.setText(`${event.attacker.name} ruptures all enemies!`);
let remaining = event.targets.length;
const done = () => {
if (--remaining <= 0) onComplete();
};
for (const t of event.targets) {
const obj = this.cardObjects.get(t.target.instanceId) || this.commanderObjects?.get(t.target.instanceId);
if (!obj?.scene) { done(); continue; }
obj.refresh();
// Flash the card red to indicate rupture
obj.flash(0xff0000);
this.time.delayedCall(400, done);
}
}
_animateHealAll(event, onComplete) {
if (!event.targets?.length) { onComplete(); return; }
this.statusText.setText(`${event.attacker.name} heals all allies!`);
let remaining = event.targets.length;
const done = () => {
if (--remaining <= 0) onComplete();
};
for (const t of event.targets) {
const obj = this.cardObjects.get(t.target.instanceId) || this.commanderObjects?.get(t.target.instanceId);
if (!obj?.scene || t.healAmount <= 0) { done(); continue; }
// Show HP increase: set to before, animate to after
const hpBefore = t.hpBefore;
const hpAfter = t.target.currentHP;
if (obj.hpText) obj.hpText.setText(`${hpBefore}`);
// Flash green and update
obj.flash(0x00ff00);
this.time.delayedCall(300, () => {
if (obj.scene) obj.refresh();
done();
});
}
}
_animateWeakenAll(event, onComplete) {
if (!event.targets?.length) { onComplete(); return; }
this.statusText.setText(`${event.attacker.name} weakens all enemies!`);
let remaining = event.targets.length;
const done = () => {
if (--remaining <= 0) onComplete();
};
for (const t of event.targets) {
const obj = this.cardObjects.get(t.target.instanceId) || this.commanderObjects?.get(t.target.instanceId);
if (!obj?.scene) { done(); continue; }
obj.refresh();
obj.flash(0x8800ff);
this.time.delayedCall(400, done);
}
}
// Explosion sprite + fade-out for a defeated lane card.
_playCardDeath(obj, cardData, onComplete = null) {
this.cardObjects.delete(cardData.instanceId);
@ -512,20 +642,35 @@ export class BattleScene extends Phaser.Scene {
_onPreAttackStep(event, onComplete) {
if (!event || !event.preAttackFires?.length) { onComplete(); return; }
// Animate each preAttack fire sequentially in the order they were emitted
const fires = event.preAttackFires;
const next = idx => {
if (idx >= fires.length) { onComplete(); return; }
const fire = fires[idx];
const cb = () => next(idx + 1);
if (fire.skill === 'mortar') {
this._animateMortarFire(event.attacker, fire, cb);
} else if (fire.skill === 'strike') {
this._animateStrikeFire(event.attacker, fire, cb);
} else if (fire.skill === 'pierce') {
this._animatePierceFire(event.attacker, fire, cb);
// Group consecutive "all" fires of the same skill for parallel animation
const groups = [];
for (const fire of event.preAttackFires) {
if (fire.isAll) {
const last = groups[groups.length - 1];
if (last?.isAll && last.skill === fire.skill) {
last.fires.push(fire);
} else {
groups.push({ skill: fire.skill, fires: [fire], isAll: true });
}
} else {
cb();
groups.push(fire);
}
}
const next = idx => {
if (idx >= groups.length) { onComplete(); return; }
const group = groups[idx];
const cb = () => next(idx + 1);
if (group.isAll) {
if (group.skill === 'mortar') this._animateMortarAll(event.attacker, group.fires, cb);
else if (group.skill === 'strike') this._animateStrikeAll(event.attacker, group.fires, cb);
else if (group.skill === 'pierce') this._animatePierceAll(event.attacker, group.fires, cb);
else cb();
} else {
if (group.skill === 'mortar') this._animateMortarFire(event.attacker, group, cb);
else if (group.skill === 'strike') this._animateStrikeFire(event.attacker, group, cb);
else if (group.skill === 'pierce') this._animatePierceFire(event.attacker, group, cb);
else cb();
}
};
next(0);
@ -784,6 +929,193 @@ export class BattleScene extends Phaser.Scene {
});
}
// ── "all" preAttack animations — parallel fire to all targets, sound plays once ──
_animateMortarAll(attacker, fires, onComplete) {
if (!fires.length) { onComplete(); return; }
this.sound.play('sfx_mortar', { volume: 0.8 });
this.statusText.setText(`${attacker.name} fires mortar at all enemies!`);
let remaining = fires.length;
const done = () => { if (--remaining <= 0) onComplete(); };
for (const fire of fires) {
this._animateMortarFireNoSound(attacker, fire, done);
}
}
// Same as _animateMortarFire but without playing sound (called by _animateMortarAll)
_animateMortarFireNoSound(attacker, mortarFire, onComplete) {
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
const sourceObj = _lookup(attacker.instanceId);
const targetObj = _lookup(mortarFire.target.instanceId);
if (!sourceObj?.scene || !targetObj?.scene) { onComplete(); return; }
if (targetObj.hpText && targetObj.scene) {
targetObj.hpText.setText(`${Math.max(0, mortarFire.hpBefore)}`);
}
const BASE_SCALE = 160 / 460;
const mortarSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 11)
.setScale(BASE_SCALE).setDepth(30);
const angle = Phaser.Math.Angle.Between(sourceObj.x, sourceObj.y, targetObj.x, targetObj.y);
mortarSprite.rotation = angle + Math.PI / 2;
this.time.delayedCall(450, () => {
const startX = sourceObj.x, startY = sourceObj.y;
const endX = targetObj.x, endY = targetObj.y;
const ARC_HEIGHT = -180;
const progress = { t: 0 };
const emitter = this.add.particles(startX, startY, 'particle_dot', {
speed: { min: 15, max: 60 }, scale: { start: 0.5, end: 0 },
alpha: { start: 1, end: 0 }, lifespan: 280,
tint: [0xff8800, 0xff4400, 0x888888], blendMode: 'ADD', frequency: 30
}).setDepth(29);
this.tweens.add({
targets: progress, t: 1, duration: 600, ease: 'Quad.easeIn',
onUpdate: () => {
const sin = Math.sin(progress.t * Math.PI);
mortarSprite.x = startX + (endX - startX) * progress.t;
mortarSprite.y = startY + (endY - startY) * progress.t + ARC_HEIGHT * sin;
if (emitter.scene) emitter.setPosition(mortarSprite.x, mortarSprite.y);
},
onComplete: () => {
emitter.stop();
this.time.delayedCall(250, () => { if (emitter.scene) emitter.destroy(); });
if (mortarSprite.scene) mortarSprite.destroy();
if (!targetObj.scene) { onComplete(); return; }
this._playSkillExplosion(targetObj.x, targetObj.y, () => {
if (!targetObj.scene) { onComplete(); return; }
const hpAfter = Math.max(0, mortarFire.hpBefore - mortarFire.damage);
const afterImpact = () => {
if (targetObj.hpText && targetObj.scene) targetObj.hpText.setText(`${hpAfter}`);
if (mortarFire.target.currentHP <= 0 && !targetObj.isCommander) {
this._playCardDeath(targetObj, mortarFire.target, onComplete);
} else { onComplete(); }
};
if (mortarFire.damage > 0) {
if (hpAfter > 0) this.sound.play('sfx_damage', { volume: 0.8 });
targetObj.animateHPLoss(mortarFire.damage, afterImpact, mortarFire.hpBefore, hpAfter);
} else { afterImpact(); }
});
}
});
});
}
_animateStrikeAll(attacker, fires, onComplete) {
if (!fires.length) { onComplete(); return; }
this.sound.play('sfx_strike', { volume: 0.8 });
this.statusText.setText(`${attacker.name} strikes all enemies!`);
let remaining = fires.length;
const done = () => { if (--remaining <= 0) onComplete(); };
for (const fire of fires) {
this._animateStrikeFireNoSound(attacker, fire, done);
}
}
_animateStrikeFireNoSound(attacker, strikeFire, onComplete) {
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
const sourceObj = _lookup(attacker.instanceId);
const targetObj = _lookup(strikeFire.target.instanceId);
if (!sourceObj?.scene || !targetObj?.scene) { onComplete(); return; }
if (targetObj.hpText && targetObj.scene) {
targetObj.hpText.setText(`${Math.max(0, strikeFire.hpBefore)}`);
}
const BASE_SCALE = 160 / 460;
const missileSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 9)
.setScale(BASE_SCALE).setDepth(30);
const angle = Phaser.Math.Angle.Between(sourceObj.x, sourceObj.y, targetObj.x, targetObj.y);
missileSprite.rotation = angle + Math.PI / 2;
this.time.delayedCall(300, () => {
const startX = sourceObj.x, startY = sourceObj.y;
const endX = targetObj.x, endY = targetObj.y;
const ARC_HEIGHT = -70;
const progress = { t: 0 };
const emitter = this.add.particles(startX, startY, 'particle_dot', {
speed: { min: 20, max: 80 }, scale: { start: 0.4, end: 0 },
alpha: { start: 1, end: 0 }, lifespan: 200,
tint: [0xff6600, 0xffcc00, 0xffffff], blendMode: 'ADD', frequency: 20
}).setDepth(29);
this.tweens.add({
targets: progress, t: 1, duration: 450, ease: 'Quad.easeIn',
onUpdate: () => {
const sin = Math.sin(progress.t * Math.PI);
missileSprite.x = startX + (endX - startX) * progress.t;
missileSprite.y = startY + (endY - startY) * progress.t + ARC_HEIGHT * sin;
if (emitter.scene) emitter.setPosition(missileSprite.x, missileSprite.y);
},
onComplete: () => {
emitter.stop();
this.time.delayedCall(200, () => { if (emitter.scene) emitter.destroy(); });
if (missileSprite.scene) missileSprite.destroy();
if (!targetObj.scene) { onComplete(); return; }
this._playSkillExplosion(targetObj.x, targetObj.y, () => {
if (!targetObj.scene) { onComplete(); return; }
const hpAfter = Math.max(0, strikeFire.hpBefore - strikeFire.damage);
const afterImpact = () => {
if (targetObj.hpText && targetObj.scene) targetObj.hpText.setText(`${hpAfter}`);
if (strikeFire.target.currentHP <= 0 && !strikeFire.targetIsCommander) {
this._playCardDeath(targetObj, strikeFire.target, onComplete);
} else { onComplete(); }
};
if (strikeFire.damage > 0) {
if (hpAfter > 0) this.sound.play('sfx_damage', { volume: 0.8 });
targetObj.animateHPLoss(strikeFire.damage, afterImpact, strikeFire.hpBefore, hpAfter);
} else { afterImpact(); }
});
}
});
});
}
_animatePierceAll(attacker, fires, onComplete) {
if (!fires.length) { onComplete(); return; }
this.sound.play('sfx_pierce', { volume: 0.8 });
this.statusText.setText(`${attacker.name} pierces all enemies' armor!`);
let remaining = fires.length;
const done = () => { if (--remaining <= 0) onComplete(); };
for (const fire of fires) {
this._animatePierceFireNoSound(attacker, fire, done);
}
}
_animatePierceFireNoSound(attacker, pierceFire, onComplete) {
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
const sourceObj = _lookup(attacker.instanceId);
const targetObj = _lookup(pierceFire.target.instanceId);
if (!sourceObj?.scene || !targetObj?.scene || pierceFire.amount <= 0) { onComplete(); return; }
const BASE_SCALE = 160 / 460;
const pierceSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 16)
.setScale(BASE_SCALE).setDepth(30);
this.tweens.add({
targets: pierceSprite, x: targetObj.x, y: targetObj.y,
duration: 320, ease: 'Cubic.easeIn',
onComplete: () => {
if (!targetObj.scene) { onComplete(); return; }
this.tweens.add({
targets: pierceSprite,
scaleX: BASE_SCALE * 1.25, scaleY: BASE_SCALE * 1.25,
duration: 200, ease: 'Sine.easeInOut', yoyo: true, repeat: 2,
onComplete: () => {
if (!pierceSprite.scene) { onComplete(); return; }
this.tweens.add({
targets: pierceSprite, alpha: 0, duration: 300, ease: 'Power2',
onComplete: () => { if (pierceSprite.scene) pierceSprite.destroy(); }
});
if (!targetObj.scene) { onComplete(); return; }
if (targetObj.armText) targetObj.armText.setText(`${pierceFire.armBefore}`);
const armAfter = pierceFire.armBefore - pierceFire.amount;
targetObj.animateArmorLoss(pierceFire.amount, onComplete, pierceFire.armBefore, armAfter);
}
});
}
});
}
_playSkillExplosion(x, y, onComplete) {
const BASE_SCALE = 160 / 460;
const sprite = this.add.sprite(x, y, 'attacks', 12)
@ -1060,9 +1392,28 @@ export class BattleScene extends Phaser.Scene {
}
_processEnfeebeFires(fires, onComplete) {
// Group consecutive "all" enfeeble fires for parallel animation
const groups = [];
for (const fire of fires) {
if (fire.isAll) {
const last = groups[groups.length - 1];
if (last?.isAll) {
last.items.push(fire);
} else {
groups.push({ isAll: true, items: [fire] });
}
} else {
groups.push(fire);
}
}
const next = (idx) => {
if (idx >= fires.length) { onComplete(); return; }
this._animateEnfeebeFire(fires[idx], () => next(idx + 1));
if (idx >= groups.length) { onComplete(); return; }
const group = groups[idx];
if (group.isAll) {
this._animateEnfeebleAll(group.items, () => next(idx + 1));
} else {
this._animateEnfeebeFire(group, () => next(idx + 1));
}
};
next(0);
}
@ -1076,11 +1427,27 @@ export class BattleScene extends Phaser.Scene {
}
_processBuffAnimations(buffs, onComplete) {
// Group consecutive "all" rally buffs for parallel animation
const groups = [];
for (const buff of buffs) {
if (buff.isAll && buff.skill === 'rally') {
const last = groups[groups.length - 1];
if (last?.isAll && last.skill === 'rally') {
last.items.push(buff);
} else {
groups.push({ skill: 'rally', items: [buff], isAll: true });
}
} else {
groups.push(buff);
}
}
const next = (idx) => {
if (idx >= buffs.length) { onComplete(); return; }
const buff = buffs[idx];
if (buff.skill === 'rally') {
this._animateRallyBuff(buff, () => next(idx + 1));
if (idx >= groups.length) { onComplete(); return; }
const group = groups[idx];
if (group.isAll && group.skill === 'rally') {
this._animateRallyAll(group.items, () => next(idx + 1));
} else if (group.skill === 'rally') {
this._animateRallyBuff(group, () => next(idx + 1));
} else {
next(idx + 1);
}
@ -1177,6 +1544,73 @@ export class BattleScene extends Phaser.Scene {
});
}
// ── "all" rally — parallel arc flights to all allies, sound plays once ──
_animateRallyAll(buffs, onComplete) {
if (!buffs.length) { onComplete(); return; }
this.sound.play('sfx_rally', { volume: 0.8 });
this.statusText.setText(`${buffs[0].source.name} rallies all allies!`);
let remaining = buffs.length;
const done = () => { if (--remaining <= 0) onComplete(); };
for (const buff of buffs) {
this._animateRallyBuffNoSound(buff, done);
}
}
_animateRallyBuffNoSound(buff, onComplete) {
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
const sourceObj = _lookup(buff.source.instanceId);
const targetObj = _lookup(buff.target.instanceId);
if (!sourceObj?.scene || !targetObj?.scene) { onComplete(); return; }
const BASE_SCALE = 160 / 460;
const flagSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 10)
.setScale(BASE_SCALE).setDepth(30);
const glow = flagSprite.postFX.addGlow(0xffdd00, 8, 0);
this.tweens.add({ targets: glow, outerStrength: 18, duration: 300, yoyo: true, repeat: -1, ease: 'Sine.inOut' });
const emitter = this.add.particles(sourceObj.x, sourceObj.y, 'particle_dot', {
speed: { min: 20, max: 70 }, scale: { start: 0.7, end: 0 },
alpha: { start: 1, end: 0 }, lifespan: 350,
tint: [0xffdd00, 0xffaa00, 0xffffff], blendMode: 'ADD', frequency: 25
}).setDepth(29);
const startX = sourceObj.x, startY = sourceObj.y;
const endX = targetObj.x, endY = targetObj.y;
const ARC_HEIGHT = -200;
const progress = { t: 0 };
this.tweens.add({
targets: progress, t: 1, duration: 700, ease: 'Sine.inOut',
onUpdate: () => {
const sin = Math.sin(progress.t * Math.PI);
flagSprite.x = startX + (endX - startX) * progress.t;
flagSprite.y = startY + (endY - startY) * progress.t + ARC_HEIGHT * sin;
flagSprite.setScale(BASE_SCALE * (1 + 0.55 * sin));
if (emitter.scene) emitter.setPosition(flagSprite.x, flagSprite.y);
},
onComplete: () => {
flagSprite.setScale(BASE_SCALE);
emitter.stop();
this.time.delayedCall(300, () => { if (emitter.scene) emitter.destroy(); });
if (!targetObj.scene) {
if (flagSprite.scene) flagSprite.destroy();
onComplete(); return;
}
targetObj.atkText.setText(`${buff.target.currentAttack - buff.amount}`);
targetObj.animateBerserkGain(buff.amount, () => {
this.tweens.add({
targets: flagSprite, alpha: 0,
scaleX: BASE_SCALE * 1.5, scaleY: BASE_SCALE * 1.5,
duration: 300, ease: 'Power2',
onComplete: () => {
if (flagSprite.scene) flagSprite.destroy();
onComplete();
}
});
});
}
});
}
// Animate siege: overlay Siege Launcher (sprite 13) on the source card, then launch
// siege missiles (sprite 14) in an arc to the enemy commander with explosion on impact.
_animateSiegeFire(siegeFire, onComplete) {
@ -1425,6 +1859,53 @@ export class BattleScene extends Phaser.Scene {
});
}
// ── "all" enfeeble — parallel sprites to all enemy lane cards, sound plays once ──
_animateEnfeebleAll(fires, onComplete) {
if (!fires.length) { onComplete(); return; }
this.sound.play('sfx_enfeeble', { volume: 0.8 });
this.statusText.setText(`${fires[0].source.name} enfeebles all enemies!`);
let remaining = fires.length;
const done = () => { if (--remaining <= 0) onComplete(); };
for (const fire of fires) {
this._animateEnfeebleFireNoSound(fire, done);
}
}
_animateEnfeebleFireNoSound(enfeebeFire, onComplete) {
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
const sourceObj = _lookup(enfeebeFire.source.instanceId);
const targetObj = _lookup(enfeebeFire.target.instanceId);
if (!sourceObj?.scene || !targetObj?.scene || enfeebeFire.amount <= 0) { onComplete(); return; }
const BASE_SCALE = 160 / 460;
const enfeebleSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 18)
.setScale(BASE_SCALE).setDepth(30);
this.tweens.add({
targets: enfeebleSprite, x: targetObj.x, y: targetObj.y,
duration: 320, ease: 'Cubic.easeIn',
onComplete: () => {
if (!targetObj.scene) { onComplete(); return; }
this.tweens.add({
targets: enfeebleSprite,
scaleX: BASE_SCALE * 1.25, scaleY: BASE_SCALE * 1.25,
duration: 200, ease: 'Sine.easeInOut', yoyo: true, repeat: 2,
onComplete: () => {
if (!enfeebleSprite.scene) { onComplete(); return; }
this.tweens.add({
targets: enfeebleSprite, alpha: 0, duration: 300, ease: 'Power2',
onComplete: () => { if (enfeebleSprite.scene) enfeebleSprite.destroy(); }
});
if (!targetObj.scene) { onComplete(); return; }
const atkBefore = enfeebeFire.target.currentAttack + enfeebeFire.amount;
if (targetObj.atkText) targetObj.atkText.setText(`${atkBefore}`);
this._animateAttackLoss(targetObj, enfeebeFire.amount, atkBefore, enfeebeFire.target.currentAttack, onComplete);
}
});
}
});
}
// Animate an ATK reduction on a CardObject (mirrors animateArmorLoss / animateHPLoss pattern).
_animateAttackLoss(cardObj, amount, fromATK, toATK, onComplete) {
if (!cardObj.atkText || !cardObj.scene) { if (onComplete) onComplete(); return; }
@ -1615,6 +2096,24 @@ export class BattleScene extends Phaser.Scene {
}
}
// Temporarily restore enfeeble ATK reductions so _renderState() shows original values.
_restoreEnfeebleForDisplay(preBattleEvents) {
for (const event of preBattleEvents) {
for (const fire of event.enfeebeFires || []) {
fire.target.currentAttack += fire.amount;
}
}
}
// Re-apply enfeeble ATK reductions after _renderState() so combat math stays correct.
_reapplyEnfeebleReductions(preBattleEvents) {
for (const event of preBattleEvents) {
for (const fire of event.enfeebeFires || []) {
fire.target.currentAttack = Math.max(0, fire.target.currentAttack - fire.amount);
}
}
}
// Show floating "-N skill" reduction text near a card's skill area. Calls onComplete when done.
_showJamReductionFloat(cardObj, reductions, duration, onComplete) {
if (!cardObj?.scene || !reductions?.length) { if (onComplete) onComplete(); return; }
@ -1781,6 +2280,47 @@ export class BattleScene extends Phaser.Scene {
}
}
// ── Battle music ─────────────────────────────────────────────────────────────
_startBattleMusic() {
// Build a shuffled copy of the playlist so tracks play in random order
this._battlePlaylist = [...BATTLE_MUSIC].sort(() => Math.random() - 0.5);
this._battlePlaylistIdx = 0;
this._playNextBattleTrack();
}
_playNextBattleTrack() {
if (!this._battlePlaylist?.length) return;
// Wrap around to re-shuffle once the playlist is exhausted,
// avoiding repeating the track that just played
if (this._battlePlaylistIdx >= this._battlePlaylist.length) {
const lastKey = this._battlePlaylist[this._battlePlaylist.length - 1].key;
this._battlePlaylist = [...BATTLE_MUSIC].sort(() => Math.random() - 0.5);
// If the shuffle placed the same track first, rotate it to the end
if (this._battlePlaylist.length > 1 && this._battlePlaylist[0].key === lastKey) {
this._battlePlaylist.push(this._battlePlaylist.shift());
}
this._battlePlaylistIdx = 0;
}
const track = this._battlePlaylist[this._battlePlaylistIdx++];
if (!this.cache.audio.exists(track.key)) return; // not loaded yet — skip gracefully
this._currentBattleMusic = this.sound.add(track.key, { volume: 0.5 });
this._currentBattleMusic.once('complete', () => this._playNextBattleTrack());
this._currentBattleMusic.play();
}
_stopBattleMusic() {
if (this._currentBattleMusic) {
this._currentBattleMusic.stop();
this._currentBattleMusic.destroy();
this._currentBattleMusic = null;
}
this._battlePlaylist = null;
}
_renderState() {
const state = this.engine.getState();