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

View File

@ -181,10 +181,20 @@ export class CombatEngine {
// on_attack skills // on_attack skills
for (const s of attacker.skills) { for (const s of attacker.skills) {
if (s.trigger === 'on_attack') { if (s.trigger === 'on_attack') {
const onAtkCtx = { ...context, trigger: 'on_attack', damageDealt: dmg };
this.skillProcessor.process(s, attacker, currentTarget, this.skillProcessor.process(s, attacker, currentTarget,
alliedLanes.filter(c => c.currentHP > 0), alliedLanes.filter(c => c.currentHP > 0),
enemyLanes.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 // Pass laneCards + enemyLaneCards so positional skills (protect, enfeeble) work
const ctx = { rng: this.rng, enemyCommander, laneCards: cards, enemyLaneCards: enemyLanes }; const ctx = { rng: this.rng, enemyCommander, laneCards: cards, enemyLaneCards: enemyLanes };
this.skillProcessor.process(s, card, null, liveAllies, liveEnemies, ctx); 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 }); buffs.push({ skill: 'rally', source: card, target: ctx.rallyTarget, amount: s.value });
} }
if (s.name === 'siege' && ctx.siegeTarget) { if (s.name === 'siege' && ctx.siegeTarget) {
@ -372,7 +386,12 @@ export class CombatEngine {
protectFires.push({ skill: 'protect', source: card, targets: ctx.protectTargets }); 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`); 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 }); 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}`); 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; if (s.trigger !== 'preAttack') continue;
const ctx = { rng: this.rng, enemyCommander: pending.enemyCommander }; const ctx = { rng: this.rng, enemyCommander: pending.enemyCommander };
this.skillProcessor.process(s, pending.attacker, pending.target, liveAllies, liveEnemies, ctx); 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 // hpBefore: recover the HP value before the skill applied its damage
const hpBefore = ctx.mortarTarget.currentHP + ctx.mortarDamage; const hpBefore = ctx.mortarTarget.currentHP + ctx.mortarDamage;
preAttackFires.push({ skill: 'mortar', target: ctx.mortarTarget, damage: ctx.mortarDamage, hpBefore }); 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' }); 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 hpBefore = ctx.strikeTarget.currentHP + ctx.strikeDamage;
const targetIsCommander = ctx.strikeTarget === pending.enemyCommander; const targetIsCommander = ctx.strikeTarget === pending.enemyCommander;
preAttackFires.push({ skill: 'strike', target: ctx.strikeTarget, damage: ctx.strikeDamage, hpBefore, targetIsCommander }); 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' }); 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 armBefore = ctx.pierceTarget.currentArmor + ctx.pierceAmount;
const targetIsCommander = ctx.pierceTarget === pending.enemyCommander; const targetIsCommander = ctx.pierceTarget === pending.enemyCommander;
preAttackFires.push({ skill: 'pierce', target: ctx.pierceTarget, amount: ctx.pierceAmount, armBefore, targetIsCommander }); 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. // postAttack event — fires after the attack animation resolves.
// Only references cards still alive so skill handlers don't need to guard. // 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. // 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 const pierceRestores = preAttackFires
.filter(f => f.skill === 'pierce' && f.amount > 0 && f.target.currentHP > 0) .filter(f => f.skill === 'pierce' && f.amount > 0 && f.target.currentHP > 0)
.map(f => ({ card: f.target, amount: f.amount })); .map(f => ({ card: f.target, amount: f.amount }));

View File

@ -7,6 +7,19 @@ export class SkillProcessor {
} }
strike(skill, attacker, defender, allies, enemies, context) { 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; const target = defender || context?.enemyCommander;
if (!target) return; if (!target) return;
const dmg = Math.max(0, skill.value - Math.max(0, target.currentArmor)); 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) { 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; const target = defender || context?.enemyCommander;
if (!target) return; if (!target) return;
// Reduce armor directly (floored at 0); track actual amount so it can be restored. // 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; if (!defender) return;
defender.ruptureStacks = (defender.ruptureStacks || 0) + skill.value; defender.ruptureStacks = (defender.ruptureStacks || 0) + skill.value;
} }
@ -61,6 +97,17 @@ export class SkillProcessor {
rally(skill, attacker, defender, allies, enemies, context) { rally(skill, attacker, defender, allies, enemies, context) {
const assaultAllies = allies.filter(a => a.type === 'assault' && a !== attacker && a.currentHP > 0); const assaultAllies = allies.filter(a => a.type === 'assault' && a !== attacker && a.currentHP > 0);
if (assaultAllies.length === 0) return; 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 rng = context?.rng;
const idx = rng const idx = rng
? rng.nextInt(0, assaultAllies.length - 1) ? rng.nextInt(0, assaultAllies.length - 1)
@ -74,11 +121,21 @@ export class SkillProcessor {
context.rallyTarget = target; context.rallyTarget = target;
} }
heal(skill, attacker, defender, allies) { heal(skill, attacker, defender, allies, enemies, context) {
const damaged = allies const damaged = allies
.filter(a => a.currentHP > 0 && a.currentHP < a.health) .filter(a => a.currentHP > 0 && a.currentHP < a.health)
.sort((a, b) => (a.currentHP / a.health) - (b.currentHP / b.health)); .sort((a, b) => (a.currentHP / a.health) - (b.currentHP / b.health));
if (damaged.length === 0) return; 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); 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) { 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 || []; 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); const idx = laneCards.indexOf(attacker);
if (idx < 0 || idx >= enemyLaneCards.length) return; if (idx < 0 || idx >= enemyLaneCards.length) return;
const target = enemyLaneCards[idx]; 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; if (!defender) return;
for (const s of defender.skills) { for (const s of defender.skills) {
s.value = Math.max(0, (s.value || 0) - skill.value); s.value = Math.max(0, (s.value || 0) - skill.value);
@ -210,6 +293,18 @@ export class SkillProcessor {
mortar(skill, attacker, defender, allies, enemies, context) { mortar(skill, attacker, defender, allies, enemies, context) {
if (enemies.length === 0) return; 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 rng = context?.rng;
const idx = rng ? rng.nextInt(0, enemies.length - 1) : Math.floor(Math.random() * enemies.length); const idx = rng ? rng.nextInt(0, enemies.length - 1) : Math.floor(Math.random() * enemies.length);
const target = enemies[idx]; const target = enemies[idx];

View File

@ -125,7 +125,10 @@ export class CardObject extends Phaser.GameObjects.Container {
if (hasSkills) { if (hasSkills) {
const skillStr = this.cardData.skills 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(' · '); .join(' · ');
const skillY = contentTop + contentH * 0.72; const skillY = contentTop + contentH * 0.72;
this.skillText = this.scene.add.text(0, skillY, skillStr, { 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) { if (this.skillText && this.cardData.skills?.length) {
const skillStr = this.cardData.skills 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(' · '); .join(' · ');
this.skillText.setText(skillStr); 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 catColor = CATEGORY_COLORS[def?.category] || '#ffcc44';
const triggerLabel = TRIGGER_LABELS[skill.trigger] || skill.trigger; 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}` : ''; const valStr = skill.value != null ? ` ${skill.value}` : '';
lines.push({ lines.push({
text: `${skill.name}${valStr}`, text: `${skill.name}${allMod}${valStr}`,
style: { fontSize: '21px', color: catColor, fontStyle: 'bold', wordWrap: { width: W - PAD * 2 } }, style: { fontSize: '21px', color: catColor, fontStyle: 'bold', wordWrap: { width: W - PAD * 2 } },
marginTop: 14, align: 'left', marginTop: 14, align: 'left',
suffix: { text: ` ${triggerLabel}`, style: { fontSize: '15px', color: '#888888' } } suffix: { text: ` ${triggerLabel}`, style: { fontSize: '15px', color: '#888888' } }
}); });
// Skill description // Skill description — use allDescription when "all" modifier is present
if (def?.description) { const rawDesc = (skill.all && def?.allDescription) ? def.allDescription : def?.description;
if (rawDesc) {
// Replace "value" with the actual number for clarity // Replace "value" with the actual number for clarity
const desc = skill.value != null const desc = skill.value != null
? def.description.replace(/\bvalue\b/g, `${skill.value}`) ? rawDesc.replace(/\bvalue\b/g, `${skill.value}`)
: def.description; : rawDesc;
lines.push({ lines.push({
text: desc, text: desc,
style: { fontSize: '17px', color: '#bbbbbb', wordWrap: { width: W - PAD * 2 } }, 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 { BattleField } from '../objects/BattleField.js';
import { SaveManager } from '../managers/SaveManager.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 { export class BattleScene extends Phaser.Scene {
constructor() { super('BattleScene'); } 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) { init(data) {
this.missionData = data.mission || null; this.missionData = data.mission || null;
this.playerDeckData = data.deck || 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 // Stop main menu music for the duration of the battle
this.registry.get('music_main_menu')?.stop(); 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 // Layout constants
// Lanes: 4 lanes centred at x = 430, 740, 1050, 1360 (spacing 310) // Lanes: 4 lanes centred at x = 430, 740, 1050, 1360 (spacing 310)
// Commander column: x = 150 // Commander column: x = 150
@ -257,13 +286,15 @@ export class BattleScene extends Phaser.Scene {
const commitEvents = this.engine.beginCommit(chosenCard); const commitEvents = this.engine.beginCommit(chosenCard);
const preBattleEvents = commitEvents.filter(e => e.type === 'preBattle'); const preBattleEvents = commitEvents.filter(e => e.type === 'preBattle');
// Temporarily restore any jam-reduced skill values so _renderState() builds // Temporarily restore any jam/enfeeble changes so _renderState() builds
// CardObjects with the original (pre-jam) values displayed. They are re-applied // CardObjects with the original (pre-combat) values displayed. They are re-applied
// immediately after so combat math is unaffected. // immediately after so combat math is unaffected.
this._restoreJamSkillsForDisplay(preBattleEvents); this._restoreJamSkillsForDisplay(preBattleEvents);
this._restoreEnfeebleForDisplay(preBattleEvents);
// Render the field with all newly deployed cards but pre-combat HP // Render the field with all newly deployed cards but pre-combat HP
this._renderState(); this._renderState();
this._reapplyJamSkillReductions(preBattleEvents); this._reapplyJamSkillReductions(preBattleEvents);
this._reapplyEnfeebleReductions(preBattleEvents);
const newPlayerCard = chosenCard const newPlayerCard = chosenCard
? this.engine.getState().player.lanes.find(c => !oldPlayerIds.has(c.instanceId)) ? 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 events = this.engine.processNextAttack();
const preAttackEvent = events.find(e => e.type === 'preAttack'); const preAttackEvent = events.find(e => e.type === 'preAttack');
const attackEvent = events.find(e => e.type === 'attack');
const postAttackEvent = events.find(e => e.type === 'postAttack'); 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 // Attacker was already dead (counter-killed earlier this turn) — skip
this.time.delayedCall(30, () => this._processNextAttackStep()); this.time.delayedCall(30, () => this._processNextAttackStep());
return; return;
@ -340,23 +386,36 @@ export class BattleScene extends Phaser.Scene {
this.commanderObjects.get(state.player.commander.instanceId)?.refresh(); this.commanderObjects.get(state.player.commander.instanceId)?.refresh();
this.commanderObjects.get(state.opponent.commander.instanceId)?.refresh(); this.commanderObjects.get(state.opponent.commander.instanceId)?.refresh();
const berserkEvent = events.find(e => e.type === 'berserk'); // Animate each round sequentially. For flurry, cards return to their original
const counterEvent = events.find(e => e.type === 'counter'); // positions after each attack before the next round begins.
const animateRound = (roundIdx) => {
// Fire pre-attack hook, then proceed to attack animation. if (roundIdx >= attackRounds.length) {
// Counter fires at impact (cards still enlarged at center) via onImpact callback. this._onPostAttackStep(postAttackEvent, () => this._processNextAttackStep());
// _refreshCardAfterAttack handles attacker refresh/death once cards return. return;
this._onPreAttackStep(preAttackEvent, () => { }
this._animateAttack(attackEvent, () => { const round = attackRounds[roundIdx];
this._refreshCardAfterAttack(attackEvent.defender); // Fire pre-attack hook only before the first round
this._refreshCardAfterAttack(attackEvent.attacker); const runAttack = () => {
this.time.delayedCall(220, () => { this._animateAttack(round.attackEvent, () => {
this._onPostAttackStep(postAttackEvent, () => this._processNextAttackStep()); 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. // 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. // Explosion sprite + fade-out for a defeated lane card.
_playCardDeath(obj, cardData, onComplete = null) { _playCardDeath(obj, cardData, onComplete = null) {
this.cardObjects.delete(cardData.instanceId); this.cardObjects.delete(cardData.instanceId);
@ -512,20 +642,35 @@ export class BattleScene extends Phaser.Scene {
_onPreAttackStep(event, onComplete) { _onPreAttackStep(event, onComplete) {
if (!event || !event.preAttackFires?.length) { onComplete(); return; } if (!event || !event.preAttackFires?.length) { onComplete(); return; }
// Animate each preAttack fire sequentially in the order they were emitted // Group consecutive "all" fires of the same skill for parallel animation
const fires = event.preAttackFires; const groups = [];
const next = idx => { for (const fire of event.preAttackFires) {
if (idx >= fires.length) { onComplete(); return; } if (fire.isAll) {
const fire = fires[idx]; const last = groups[groups.length - 1];
const cb = () => next(idx + 1); if (last?.isAll && last.skill === fire.skill) {
if (fire.skill === 'mortar') { last.fires.push(fire);
this._animateMortarFire(event.attacker, fire, cb); } else {
} else if (fire.skill === 'strike') { groups.push({ skill: fire.skill, fires: [fire], isAll: true });
this._animateStrikeFire(event.attacker, fire, cb); }
} else if (fire.skill === 'pierce') {
this._animatePierceFire(event.attacker, fire, cb);
} else { } 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); 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) { _playSkillExplosion(x, y, onComplete) {
const BASE_SCALE = 160 / 460; const BASE_SCALE = 160 / 460;
const sprite = this.add.sprite(x, y, 'attacks', 12) const sprite = this.add.sprite(x, y, 'attacks', 12)
@ -1060,9 +1392,28 @@ export class BattleScene extends Phaser.Scene {
} }
_processEnfeebeFires(fires, onComplete) { _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) => { const next = (idx) => {
if (idx >= fires.length) { onComplete(); return; } if (idx >= groups.length) { onComplete(); return; }
this._animateEnfeebeFire(fires[idx], () => next(idx + 1)); const group = groups[idx];
if (group.isAll) {
this._animateEnfeebleAll(group.items, () => next(idx + 1));
} else {
this._animateEnfeebeFire(group, () => next(idx + 1));
}
}; };
next(0); next(0);
} }
@ -1076,11 +1427,27 @@ export class BattleScene extends Phaser.Scene {
} }
_processBuffAnimations(buffs, onComplete) { _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) => { const next = (idx) => {
if (idx >= buffs.length) { onComplete(); return; } if (idx >= groups.length) { onComplete(); return; }
const buff = buffs[idx]; const group = groups[idx];
if (buff.skill === 'rally') { if (group.isAll && group.skill === 'rally') {
this._animateRallyBuff(buff, () => next(idx + 1)); this._animateRallyAll(group.items, () => next(idx + 1));
} else if (group.skill === 'rally') {
this._animateRallyBuff(group, () => next(idx + 1));
} else { } else {
next(idx + 1); 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 // 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. // siege missiles (sprite 14) in an arc to the enemy commander with explosion on impact.
_animateSiegeFire(siegeFire, onComplete) { _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). // Animate an ATK reduction on a CardObject (mirrors animateArmorLoss / animateHPLoss pattern).
_animateAttackLoss(cardObj, amount, fromATK, toATK, onComplete) { _animateAttackLoss(cardObj, amount, fromATK, toATK, onComplete) {
if (!cardObj.atkText || !cardObj.scene) { if (onComplete) onComplete(); return; } 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. // Show floating "-N skill" reduction text near a card's skill area. Calls onComplete when done.
_showJamReductionFloat(cardObj, reductions, duration, onComplete) { _showJamReductionFloat(cardObj, reductions, duration, onComplete) {
if (!cardObj?.scene || !reductions?.length) { if (onComplete) onComplete(); return; } 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() { _renderState() {
const state = this.engine.getState(); const state = this.engine.getState();