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:
parent
313d2548d9
commit
f927529555
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
1601
data/cards.json
1601
data/cards.json
File diff suppressed because it is too large
Load Diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 } },
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue