tyrants-edge/src/scenes/BattleScene.js

3079 lines
115 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { CombatEngine } from '../combat/CombatEngine.js';
import { CardObject } from '../objects/CardObject.js';
import { BattleField } from '../objects/BattleField.js';
import { SaveManager } from '../managers/SaveManager.js';
// ── Battle background images by faction ───────────────────────────────────────
// Each faction with art has 3 numbered variants; one is picked at random per battle.
const BATTLE_BACKGROUNDS = {
imperial: ['imperial_01', 'imperial_02', 'imperial_03'],
raider: ['raider_01', 'raider_02', 'raider_03'],
bloodthirsty: ['bloodthirsty_01', 'bloodthirsty_02', 'bloodthirsty_03']
};
// ── 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);
}
// Load a random battle background for the opponent's faction (if available)
const opponentDeck = this.missionData?.opponent
|| { commander: 'raider_cmd_1' }; // skirmish fallback
const cardManager = this.registry.get('cardManager');
const cmdCard = cardManager?.getCard(opponentDeck.commander);
const faction = cmdCard?.faction;
const variants = faction && BATTLE_BACKGROUNDS[faction];
if (variants) {
const pick = variants[Math.floor(Math.random() * variants.length)];
this._battleBgKey = `bg_${pick}`;
if (!this.textures.exists(this._battleBgKey)) {
this.load.image(this._battleBgKey, `assets/images/ui/${pick}.png`);
}
}
}
init(data) {
this.missionData = data.mission || null;
this.playerDeckData = data.deck || null;
this.isSkirmish = data.skirmish || false;
this.playerLevel = data.playerLevel || 1;
this.enemyLevel = data.enemyLevel || 1;
this.campaignId = data.campaignId || null;
}
create() {
const { width, height } = this.scale;
const cardManager = this.registry.get('cardManager');
const save = this.registry.get('save');
// 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
// Opponent row: y = 305 Player row: y = 715 Midline: y = 510
// Particle dot texture (white circle, 8×8) used for attack effects
if (!this.textures.exists('particle_dot')) {
const g = this.make.graphics({ x: 0, y: 0, add: false });
g.fillStyle(0xffffff, 1);
g.fillCircle(4, 4, 4);
g.generateTexture('particle_dot', 8, 8);
g.destroy();
}
// Sprite animations (global — skip if already registered from a previous battle)
if (!this.anims.exists('attack_anim')) {
this.anims.create({
key: 'attack_anim',
frames: this.anims.generateFrameNumbers('attacks', { start: 0, end: 4 }),
frameRate: 10,
repeat: 0
});
}
if (!this.anims.exists('explosion_anim')) {
this.anims.create({
key: 'explosion_anim',
frames: this.anims.generateFrameNumbers('attacks', { start: 5, end: 8 }),
frameRate: 10,
repeat: 0
});
}
// Background — faction-specific image with dimming overlay, or plain color fallback
if (this._battleBgKey && this.textures.exists(this._battleBgKey)) {
const bg = this.add.image(width / 2, height / 2, this._battleBgKey);
bg.setDisplaySize(width, height);
this.add.rectangle(width / 2, height / 2, width, height, 0x000000, 0.55);
} else {
this.add.rectangle(width / 2, height / 2, width, height, 0x1a1a2e);
}
// Midfield divider
this.add.rectangle(width / 2, 510, width, 2, 0x334466);
// Vertical lane separators (5 lines bounding 4 lanes)
for (let i = 0; i < 5; i++) {
const x = 280 + i * 310;
this.add.rectangle(x, 510, 1, 780, 0x222244);
}
// Commander column right edge
this.add.rectangle(280, height / 2, 1, height, 0x222244);
// Side labels
this.add.text(14, 95, 'OPPONENT', { fontSize: '17px', color: '#ff8888', fontFamily: 'Audiowide' });
this.add.text(14, 520, 'PLAYER', { fontSize: '17px', color: '#88aaff', fontFamily: 'Audiowide' });
this.battlefield = new BattleField(this, {
playerY: 715, opponentY: 305,
commanderPlayerX: 150, commanderOpponentX: 150,
laneStartX: 430, laneSpacing: 310
});
// Setup combat
let opponentDeck;
if (this.missionData) {
opponentDeck = this.missionData.opponent;
} else {
// Skirmish: use a raider deck
opponentDeck = {
commander: 'raider_cmd_1',
cards: [
'raider_grunt_1', 'raider_grunt_1', 'raider_grunt_1',
'raider_scout_1', 'raider_scout_1',
'raider_berserker_1', 'raider_berserker_1',
'raider_cutthroat_1', 'raider_marauder_1', 'raider_pillager_1'
]
};
}
if (!this.playerDeckData) {
this.playerDeckData = save.decks[0];
}
if (!this.playerDeckData) {
this.add.text(width / 2, height / 2, 'No deck configured!\nGo to Deck Builder first.', {
fontSize: '20px', color: '#ff4444', align: 'center', fontFamily: 'Audiowide'
}).setOrigin(0.5);
this._makeBackButton();
return;
}
this.engine = new CombatEngine(this.playerDeckData, opponentDeck, cardManager, undefined, this.playerLevel, this.enemyLevel);
this.cardObjects = new Map(); // instanceId -> CardObject
// Commander visuals
this._buildCommanderDisplay();
// ── Top bar ──────────────────────────────────────────────────────────────
this.turnText = this.add.text(960, 30, 'Turn 0', {
fontSize: '20px', color: '#ffffff', fontFamily: 'Audiowide'
}).setOrigin(0.5);
const battleLabel = this.missionData ? this.missionData.name : 'Skirmish Battle';
this.add.text(960, 58, battleLabel, {
fontSize: '14px', color: '#888888', fontFamily: 'Audiowide'
}).setOrigin(0.5);
// ── Combat log — bottom strip ────────────────────────────────────────────
this.statusText = this.add.text(width / 2, 1058, 'Press SPACE or click Next Turn to start', {
fontSize: '17px', color: '#aaaaaa', fontFamily: 'Audiowide'
}).setOrigin(0.5);
this.logLines = [];
for (let i = 0; i < 6; i++) {
this.logLines.push(this.add.text(14, 915 + i * 20, '', {
fontSize: '15px', color: '#777777', fontFamily: 'Audiowide'
}));
}
// ── Top bar buttons ─────────────────────────────────────────────────────
this.autoPlay = false;
this.autoTimer = null;
this.waitingForPick = false;
this.isAnimating = false;
this.playerGoesFirst = true;
this.initiativeIndicator = null;
const autoBtn = this.add.rectangle(1680, 35, 180, 44, 0x224422)
.setInteractive({ useHandCursor: true })
.setStrokeStyle(1, 0x44aa44);
this.autoBtnText = this.add.text(1680, 35, 'Auto: OFF', {
fontSize: '18px', color: '#ffffff', fontFamily: 'Audiowide'
}).setOrigin(0.5);
autoBtn.on('pointerdown', () => this._toggleAuto());
const nextBtn = this.add.rectangle(1840, 35, 180, 44, 0x1a3a5c)
.setInteractive({ useHandCursor: true })
.setStrokeStyle(1, 0x4488ff);
this.add.text(1840, 35, 'Next Turn', {
fontSize: '18px', color: '#ffffff', fontFamily: 'Audiowide'
}).setOrigin(0.5);
nextBtn.on('pointerdown', () => this._beginTurn());
// Keyboard
this.input.keyboard.on('keydown-SPACE', () => this._beginTurn());
this._makeBackButton();
this._renderState();
}
_buildCommanderDisplay() {
const state = this.engine.getState();
this.commanderObjects = new Map(); // instanceId → CardObject (persists across _renderState)
const specs = [
{ data: state.opponent.commander, cx: 150, cy: 305, label: 'ENEMY CMD', labelColor: '#ff8888' },
{ data: state.player.commander, cx: 150, cy: 715, label: 'COMMANDER', labelColor: '#ffd700' }
];
for (const s of specs) {
const w = 240, h = 336;
// Label above the card (separate text so it never scales/shakes with the card)
this.add.text(s.cx, s.cy - h / 2 - 14, s.label,
{ fontSize: '15px', color: s.labelColor, fontFamily: 'Audiowide' }).setOrigin(0.5);
// Use CardObject for visual consistency with lane cards
const cardObj = new CardObject(this, s.cx, s.cy, s.data, { width: w, height: h });
cardObj.isCommander = true;
this.commanderObjects.set(s.data.instanceId, cardObj);
}
this.oDeckText = this.add.text(14, 115, '', { fontSize: '15px', color: '#aaaaaa', fontFamily: 'Audiowide' });
this.pDeckText = this.add.text(14, 540, '', { fontSize: '15px', color: '#aaaaaa', fontFamily: 'Audiowide' });
}
_beginTurn() {
if (this.waitingForPick || this.isAnimating) return;
if (this.engine.winner) { this._showResult(); return; }
// Snapshot opponent lanes before this turn (only matters on opponent-first turns)
const oldOpponentIds = new Set(
this.engine.getState().opponent.lanes.map(c => c.instanceId)
);
const { hand, canDeploy, playerGoesFirst } = this.engine.beginTurn();
this.playerGoesFirst = playerGoesFirst;
this.turnText.setText(`Turn ${this.engine.turn}`);
this._renderState();
this._updateInitiativeIndicator(playerGoesFirst);
const proceedAfterDeploy = () => {
if (canDeploy && !this.autoPlay) {
this.waitingForPick = true;
this.statusText.setText('Choose a card to deploy');
this._showCardPicker(hand);
} else {
this._finishTurn(canDeploy ? hand[0] : null);
}
};
// On opponent-first turns, show enemy deploy animation before showing picker.
// On player-first turns, opponent hasn't deployed yet — go straight to picker.
const newOpponentCard = !playerGoesFirst
? this.engine.getState().opponent.lanes.find(c => !oldOpponentIds.has(c.instanceId))
: null;
if (newOpponentCard) {
this.isAnimating = true;
const cardObj = this.cardObjects.get(newOpponentCard.instanceId);
this.statusText.setText(`Enemy deploys: ${newOpponentCard.name}`);
this._animateDeploy(cardObj, () => {
this.isAnimating = false;
proceedAfterDeploy();
});
} else {
proceedAfterDeploy();
}
}
_finishTurn(chosenCard) {
this.waitingForPick = false;
this.isAnimating = true;
this.statusText.setText('Deploying...');
// Snapshot both sides before deploy so we can detect new cards
const oldPlayerIds = new Set(
this.engine.getState().player.lanes.map(c => c.instanceId)
);
const oldOpponentIds = new Set(
this.engine.getState().opponent.lanes.map(c => c.instanceId)
);
// Deploy + pre-attack phases (NO damage applied yet)
// On player-first turns, beginCommit also deploys the AI card
const commitEvents = this.engine.beginCommit(chosenCard);
const preBattleEvents = commitEvents.filter(e => e.type === 'preBattle');
// 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))
: null;
// On player-first turns the AI deployed inside beginCommit — animate it after player
const newOpponentCard = this.playerGoesFirst
? this.engine.getState().opponent.lanes.find(c => !oldOpponentIds.has(c.instanceId))
: null;
const startAttacks = () => this._processPreBattle(preBattleEvents, () => {
this.time.delayedCall(200, () => this._processNextAttackStep());
});
const animateOpponentThenAttack = () => {
if (newOpponentCard) {
const cardObj = this.cardObjects.get(newOpponentCard.instanceId);
this.statusText.setText(`Enemy deploys: ${newOpponentCard.name}`);
this._animateDeploy(cardObj, startAttacks);
} else {
startAttacks();
}
};
if (newPlayerCard) {
const cardObj = this.cardObjects.get(newPlayerCard.instanceId);
this.statusText.setText(`You deploy: ${newPlayerCard.name}`);
this._animateDeploy(cardObj, animateOpponentThenAttack);
} else {
animateOpponentThenAttack();
}
}
// Drive one attack at a time. Called recursively until all attacks are done.
_processNextAttackStep() {
if (!this.engine.hasPendingAttacks()) {
// All attacks resolved — run death-check, win-check, then tidy up display
const postBattleEvents = this.engine.finalizeCommit();
this._renderState(); // removes dead cards, shows clean final state
const state = this.engine.getState();
this.commanderObjects.get(state.player.commander.instanceId)?.refresh();
this.commanderObjects.get(state.opponent.commander.instanceId)?.refresh();
this.pDeckText.setText(`Deck:${state.player.deckRemaining} Hand:${state.player.hand.length}`);
this.oDeckText.setText(`Deck:${state.opponent.deckRemaining} Hand:${state.opponent.hand.length}`);
this._updateLog();
this._processPostBattle(postBattleEvents.filter(e => e.type === 'postBattle'), () => {
this._animateInitiativeHandoff();
this.isAnimating = false;
this.statusText.setText('Press SPACE or click Next Turn to advance');
if (this.engine.winner) {
this.time.delayedCall(600, () => this._showResult());
}
});
return;
}
const events = this.engine.processNextAttack();
const preAttackEvent = events.find(e => e.type === 'preAttack');
const postAttackEvent = events.find(e => e.type === 'postAttack');
const bloodrageEvent = events.find(e => e.type === 'bloodrage');
// 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;
}
// Update commander HP bars for siege damage (fires during _performAttack).
// preAttack skills (strike) correct the targeted commander's HP themselves at the
// start of their animation, overriding this refresh for that specific card.
const state = this.engine.getState();
this.commanderObjects.get(state.player.commander.instanceId)?.refresh();
this.commanderObjects.get(state.opponent.commander.instanceId)?.refresh();
// 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);
});
};
if (roundIdx === 0) {
this._onPreAttackStep(preAttackEvent, () => {
if (bloodrageEvent) {
this._animateBloodrageGain(bloodrageEvent, runAttack);
} else {
runAttack();
}
});
} else {
runAttack();
}
};
animateRound(0);
}
// Update a card's HP bar and stats at the moment of impact, then handle death.
// If the defender was already killed via _onAttackReconcile, cardObjects.get() returns
// null here and the function exits early — no double-death.
_refreshCardAfterAttack(cardData) {
if (!cardData) return;
const obj = this.cardObjects.get(cardData.instanceId)
?? this.commanderObjects?.get(cardData.instanceId);
if (!obj) return;
obj.refresh();
// Fallback death path for cards killed by counter/rupture without an HP loss animation
if (cardData.currentHP <= 0 && !obj.isCommander) {
this._playCardDeath(obj, cardData);
}
}
// ── 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);
this.tweens.killTweensOf(obj);
obj.setScale(1).setDepth(0);
const explosionSprite = this.add.sprite(obj.x, obj.y, 'attacks')
.setDisplaySize(630, 630)
.setDepth(23);
explosionSprite.play('explosion_anim');
this.sound.play('sfx_destroy', { volume: 0.85 });
explosionSprite.once('animationcomplete', () => {
if (explosionSprite.scene) explosionSprite.destroy();
if (!obj.scene) { if (onComplete) onComplete(); return; }
this.tweens.add({
targets: obj,
scaleX: 1.25, scaleY: 1.25,
alpha: 0,
duration: 400,
ease: 'Power2',
onComplete: () => {
if (obj.scene) obj.destroy();
if (onComplete) onComplete();
}
});
});
}
// Animate a card appearing on the field (scale from 0 with overshoot)
_animateDeploy(cardObj, onComplete) {
if (!cardObj) { onComplete(); return; }
if (cardObj.cardData?.rarity === 'legendary') {
this.sound.play('sfx_legendary_play', { volume: 0.9 });
}
cardObj.setScale(0).setAlpha(0);
this.tweens.add({
targets: cardObj,
scaleX: 1.12, scaleY: 1.12,
alpha: 1,
duration: 280,
ease: 'Back.Out',
onComplete: () => {
this.tweens.add({
targets: cardObj,
scaleX: 1, scaleY: 1,
duration: 180,
ease: 'Power2',
onComplete
});
}
});
}
// Called immediately before each card's attack animation.
// Fires after the attack animation completes and cards have returned to their positions.
_onPostAttackStep(event, onComplete) {
this._processPierceRestores(event?.pierceRestores || [], onComplete);
}
// Fire-and-forget counter animation: spike (sprite 15) flies from counter card
// to attacker, HP loss animation plays. Death is handled by _refreshCardAfterAttack
// which is called from _processNextAttackStep's onComplete callback.
_animateCounterFire(counterEvent) {
if (!counterEvent) return;
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
const sourceObj = _lookup(counterEvent.source.instanceId); // the counter card
const targetObj = _lookup(counterEvent.target.instanceId); // the original attacker
if (!sourceObj?.scene || !targetObj?.scene) return;
this.sound.play('sfx_counter', { volume: 0.8 });
this.statusText.setText(`${counterEvent.source.name} counters ${counterEvent.target.name}!`);
const BASE_SCALE = 160 / 460;
const spikeSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 15)
.setScale(BASE_SCALE)
.setDepth(30);
this.time.delayedCall(200, () => {
this.tweens.add({
targets: spikeSprite,
x: targetObj.x,
y: targetObj.y,
duration: 300,
ease: 'Cubic.easeIn',
onComplete: () => {
this.tweens.add({
targets: spikeSprite,
alpha: 0,
duration: 200,
ease: 'Power2',
onComplete: () => { if (spikeSprite.scene) spikeSprite.destroy(); }
});
if (!targetObj.scene) return;
if (counterEvent.damage > 0) {
const target = counterEvent.target;
const hpBefore = target.currentHP + counterEvent.damage;
const hpAfter = Math.max(0, target.currentHP);
if (targetObj.hpText) targetObj.hpText.setText(`${hpBefore}`);
if (target.currentHP > 0) this.sound.play('sfx_damage', { volume: 0.8 });
targetObj.animateHPLoss(counterEvent.damage, null, hpBefore, hpAfter);
}
}
});
});
}
// Restore armor that was temporarily reduced by pierce, animating the gain on each target.
_processPierceRestores(restores, onComplete) {
if (!restores.length) { onComplete(); return; }
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
let remaining = restores.length;
const done = () => { if (--remaining === 0) onComplete(); };
for (const restore of restores) {
const targetObj = _lookup(restore.card.instanceId);
// Restore the armor value in card data regardless of animation
restore.card.currentArmor += restore.amount;
if (targetObj?.scene) {
targetObj.animateArmorGain(restore.amount, done);
} else {
done();
}
}
}
// Handles preAttack skill fires (mortar, strike, etc.) before the regular attack plays.
_onPreAttackStep(event, onComplete) {
if (!event || !event.preAttackFires?.length) { onComplete(); return; }
// 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 {
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 === 'swipe') {
// Swipe fires are never isAll — they always come as individual fires that animate sequentially
// Collect all consecutive swipe fires into one sequence
const swipeFires = [group];
while (idx + 1 < groups.length && groups[idx + 1].skill === 'swipe') {
swipeFires.push(groups[++idx]);
}
this._animateSwipeSequence(event.attacker, swipeFires, 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 if (group.skill === 'siphon') this._animateSiphonFire(event.attacker, group, cb);
else if (group.skill === 'bloodpact') this._animateBloodpactFire(event.attacker, group, cb);
else cb();
}
};
next(0);
}
// Animate a mortar shell flying from attacker to target, apply damage, handle death.
_animateMortarFire(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; }
this.sound.play('sfx_mortar', { volume: 0.8 });
// Immediately correct the target's HP display to the pre-mortar value.
// The early commander refresh (and any prior render) may have already set it to
// the final post-all-damage value; we override it here so it's accurate throughout.
if (targetObj.hpText && targetObj.scene) {
targetObj.hpText.setText(`${Math.max(0, mortarFire.hpBefore)}`);
}
this.statusText.setText(`${attacker.name} fires mortar at ${mortarFire.target.name}!`);
const BASE_SCALE = 160 / 460;
// Place mortar sprite on the attacking card; default sprite points up
const mortarSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 11)
.setScale(BASE_SCALE)
.setDepth(30);
// Rotate to face the target (sprite points up = -PI/2 from Phaser's "right" baseline)
const angle = Phaser.Math.Angle.Between(sourceObj.x, sourceObj.y, targetObj.x, targetObj.y);
mortarSprite.rotation = angle + Math.PI / 2;
// Brief pause so the rotated mortar is visible on the card before launching
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; }
// Explosion bursts on impact, then resolve HP loss and death
this._playSkillExplosion(targetObj.x, targetObj.y, () => {
if (!targetObj.scene) { onComplete(); return; }
// Show only this skill's damage; use explicit HP values so the display
// reflects the post-mortar HP correctly even though the engine has
// already applied the regular attack damage to cardData.currentHP.
const hpAfter = Math.max(0, mortarFire.hpBefore - mortarFire.damage);
const afterImpact = () => {
// Lock display to the post-mortar HP so the regular attack
// animateHPLoss starts from the right value
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();
}
});
}
});
});
}
// Plays the skill-explosion sprite (frame 12) at (x, y): grows from tiny while
// rotating, then fades out. Calls onComplete when the fade finishes.
// Animate a missile flying from attacker to the card directly across (or commander),
// play skill explosion on impact, apply damage, handle death.
_animateStrikeFire(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; }
this.sound.play('sfx_strike', { volume: 0.8 });
// Immediately correct the target's HP display to the pre-strike value.
// Commanders are refreshed to the final HP before _onPreAttackStep runs;
// overriding here ensures the correct value is visible for the full animation.
if (targetObj.hpText && targetObj.scene) {
targetObj.hpText.setText(`${Math.max(0, strikeFire.hpBefore)}`);
}
this.statusText.setText(`${attacker.name} fires missile at ${strikeFire.target.name}!`);
const BASE_SCALE = 160 / 460;
// Place missile sprite (frame 9) on the attacking card; default points up
const missileSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 9)
.setScale(BASE_SCALE)
.setDepth(30);
// Rotate to face the target (sprite points up = -PI/2 from Phaser's right baseline)
const angle = Phaser.Math.Angle.Between(sourceObj.x, sourceObj.y, targetObj.x, targetObj.y);
missileSprite.rotation = angle + Math.PI / 2;
// Brief pause so the aimed missile is visible before launch
this.time.delayedCall(300, () => {
const startX = sourceObj.x, startY = sourceObj.y;
const endX = targetObj.x, endY = targetObj.y;
const ARC_HEIGHT = -70; // missiles fly flatter than mortar shells
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; }
// Explosion on impact, then resolve damage
this._playSkillExplosion(targetObj.x, targetObj.y, () => {
if (!targetObj.scene) { onComplete(); return; }
const hpAfter = Math.max(0, strikeFire.hpBefore - strikeFire.damage);
const afterImpact = () => {
// Lock display to post-strike HP so the regular attack
// animateHPLoss starts from the right value
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();
}
});
}
});
});
}
// Animate pierce: sprite 16 flies from attacker to defender, fades, then armor loss animates.
// After the main attack the armor is restored in _onPostAttackStep.
_animatePierceFire(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; }
this.sound.play('sfx_pierce', { volume: 0.8 });
this.statusText.setText(`${attacker.name} pierces ${pierceFire.target.name}'s armor!`);
const BASE_SCALE = 160 / 460;
// Pierce sprite overlaid on the attacker, rotated to face the target
const pierceSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 16)
.setScale(BASE_SCALE)
.setDepth(30);
// No rotation — reticle stays upright throughout
// Fly to the defender
this.tweens.add({
targets: pierceSprite,
x: targetObj.x,
y: targetObj.y,
duration: 320,
ease: 'Cubic.easeIn',
onComplete: () => {
if (!targetObj.scene) { onComplete(); return; }
// Phase 1: reticle locks on — slow pulse for 1000ms
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; }
// Phase 2: fade out the reticle
this.tweens.add({
targets: pierceSprite,
alpha: 0,
duration: 300,
ease: 'Power2',
onComplete: () => { if (pierceSprite.scene) pierceSprite.destroy(); }
});
// Trigger armor loss at the moment the reticle starts fading
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);
}
});
}
});
}
// ── "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);
}
});
}
});
}
// ── Swipe animation — sprite 20 moves sequentially to each target ───────────
// Animate swipe: play swipe_01 once, then fly the swipe sprite to each target one at a time.
// On arrival at each target: play swipe_02, fade out over 500ms, apply damage, then move to next.
_animateSwipeSequence(attacker, swipeFires, onComplete) {
if (!swipeFires.length) { onComplete(); return; }
this.sound.play('sfx_swipe_01', { volume: 0.8 });
this.statusText.setText(`${attacker.name} swipes through all enemies!`);
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
const sourceObj = _lookup(attacker.instanceId);
if (!sourceObj?.scene) { onComplete(); return; }
// Correct all target HP displays to pre-swipe values
for (const fire of swipeFires) {
const targetObj = _lookup(fire.target.instanceId);
if (targetObj?.hpText && targetObj.scene) {
targetObj.hpText.setText(`${Math.max(0, fire.hpBefore)}`);
}
}
const animateNext = (idx, currentX, currentY) => {
if (idx >= swipeFires.length) { onComplete(); return; }
const fire = swipeFires[idx];
const targetObj = _lookup(fire.target.instanceId);
if (!targetObj?.scene) {
animateNext(idx + 1, currentX, currentY);
return;
}
const BASE_SCALE = 160 / 460;
const swipeSprite = this.add.sprite(currentX, currentY, 'attacks', 20)
.setScale(BASE_SCALE)
.setDepth(30);
// Continuous 360-degree rotation (full spin every 750ms)
this.tweens.add({
targets: swipeSprite,
angle: 360,
duration: 750,
repeat: -1
});
// Fly to target
const flyDuration = 350;
this.tweens.add({
targets: swipeSprite,
x: targetObj.x,
y: targetObj.y,
duration: flyDuration,
ease: 'Cubic.easeIn',
onComplete: () => {
// Play impact sound
this.sound.play('sfx_swipe_02', { volume: 0.8 });
// Flash the target card
if (targetObj.bg) {
this.tweens.add({
targets: targetObj.bg,
fillAlpha: { from: 0.6, to: 0 },
duration: 300,
ease: 'Linear'
});
}
// Fade out sprite over 500ms
this.tweens.add({
targets: swipeSprite,
alpha: 0,
duration: 500,
ease: 'Power2',
onComplete: () => {
if (swipeSprite.scene) swipeSprite.destroy();
}
});
// Apply damage display
if (!targetObj.scene) {
animateNext(idx + 1, targetObj.x, targetObj.y);
return;
}
const hpAfter = Math.max(0, fire.hpBefore - fire.damage);
const afterImpact = () => {
if (targetObj.hpText && targetObj.scene) targetObj.hpText.setText(`${hpAfter}`);
if (fire.target.currentHP <= 0 && !fire.targetIsCommander) {
this._playCardDeath(targetObj, fire.target, () => {
animateNext(idx + 1, targetObj.x, targetObj.y);
});
} else {
animateNext(idx + 1, targetObj.x, targetObj.y);
}
};
if (fire.damage > 0) {
if (hpAfter > 0) this.sound.play('sfx_damage', { volume: 0.8 });
targetObj.animateHPLoss(fire.damage, afterImpact, fire.hpBefore, hpAfter);
} else {
afterImpact();
}
}
});
};
// Start from the attacker's position
animateNext(0, sourceObj.x, sourceObj.y);
}
_playSkillExplosion(x, y, onComplete) {
const BASE_SCALE = 160 / 460;
const sprite = this.add.sprite(x, y, 'attacks', 12)
.setScale(0.05)
.setAngle(0)
.setDepth(35);
// Phase 1 — grow and spin into existence
this.tweens.add({
targets: sprite,
scaleX: BASE_SCALE * 1.5,
scaleY: BASE_SCALE * 1.5,
angle: 270,
duration: 420,
ease: 'Cubic.Out',
onComplete: () => {
if (!sprite.scene) { onComplete(); return; }
// Phase 2 — expand slightly and fade out
this.tweens.add({
targets: sprite,
scaleX: BASE_SCALE * 2.0,
scaleY: BASE_SCALE * 2.0,
angle: 360,
alpha: 0,
duration: 320,
ease: 'Power2',
onComplete: () => {
if (sprite.scene) sprite.destroy();
onComplete();
}
});
}
});
}
// Animate a single attack.
// Cards slide to center (attacker left, defender right), scale up, hold while
// particle burst + shake + damage number play, then scale down and slide back.
_animateAttack(event, onComplete, berserkEvent = null, onImpact = null) {
const SCALE = 1.78;
const SCALE_UP_MS = 280;
const HOLD_MS = 3100;
const SCALE_DN_MS = 280;
const ATTACK_MS = SCALE_UP_MS + HOLD_MS + SCALE_DN_MS; // 1660 ms
const { width, height } = this.scale;
const cardW = 260;
const enlargedW = cardW * SCALE; // ~463px at 1.78×
const gap = enlargedW * 0.75; // ~347px between enlarged cards
const attackerDestX = width / 2 - gap / 2 - enlargedW / 2; // ~554
const defenderDestX = width / 2 + gap / 2 + enlargedW / 2; // ~1366
const centerY = height / 2; // 540
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
const attackerObj = _lookup(event.attacker.instanceId);
const defenderObj = _lookup(event.defender?.instanceId);
// Save original positions so we can slide cards back afterward
const attackerOrigX = attackerObj?.x;
const attackerOrigY = attackerObj?.y;
const defenderOrigX = defenderObj?.x;
const defenderOrigY = defenderObj?.y;
const attName = event.attacker.name;
const defName = event.defender?.name ?? 'Commander';
this.statusText.setText(`${attName}${defName}${event.damage} damage!`);
// ── Slide to center + scale up, hold, slide back + scale down ────────────
const enlargeCard = (obj, cardRef, destX, origX, origY) => {
if (!obj) return;
obj.setDepth(20);
// Scale up (Back.Out for the snappy overshoot feel)
this.tweens.add({
targets: obj,
scaleX: SCALE, scaleY: SCALE,
duration: SCALE_UP_MS,
ease: 'Back.Out'
});
// Slide to battle center (smooth Power2)
this.tweens.add({
targets: obj,
x: destX, y: centerY,
duration: SCALE_UP_MS,
ease: 'Power2.inOut',
onComplete: () => {
this.time.delayedCall(HOLD_MS, () => {
if (!obj.scene) return;
// Dead cards stay at center — _refreshCardAfterAttack handles their death
if (cardRef.currentHP > 0) {
this.tweens.add({
targets: obj,
scaleX: 1, scaleY: 1,
duration: SCALE_DN_MS,
ease: 'Power2',
onComplete: () => { if (obj.scene) obj.setDepth(0); }
});
this.tweens.add({
targets: obj,
x: origX, y: origY,
duration: SCALE_DN_MS,
ease: 'Power2'
});
}
});
}
});
};
enlargeCard(attackerObj, event.attacker, attackerDestX, attackerOrigX, attackerOrigY);
enlargeCard(defenderObj, event.defender ?? { currentHP: 1 }, defenderDestX, defenderOrigX, defenderOrigY);
// ── VS image — appears between cards once they arrive at center ───────────
let vsImage = null;
this.time.delayedCall(SCALE_UP_MS, () => {
if (!this.textures.exists('vs')) return;
vsImage = this.add.image(width / 2, centerY, 'vs')
.setDisplaySize(Math.round(gap * 0.85), Math.round(gap * 0.85))
.setDepth(25)
.setAlpha(0);
this.tweens.add({ targets: vsImage, alpha: 1, duration: 150, ease: 'Power2' });
});
this.time.delayedCall(SCALE_UP_MS + HOLD_MS, () => {
if (!vsImage?.scene) return;
this.tweens.add({
targets: vsImage, alpha: 0, duration: SCALE_DN_MS, ease: 'Power2',
onComplete: () => { if (vsImage?.scene) vsImage.destroy(); }
});
});
// ── Attacker: attack sprite + particle burst — fires once card arrives at center ──
if (attackerObj) {
const enlargedH = 364 * SCALE;
this.time.delayedCall(SCALE_UP_MS, () => {
if (!attackerObj.scene) return;
// Gun turret animation centered on the attacking card
const attackSprite = this.add.sprite(
attackerDestX,
centerY,
'attacks'
).setDisplaySize(480, 480).setDepth(22);
attackSprite.play('attack_anim');
this.sound.play('sfx_attack', { volume: 0.7 });
attackSprite.once('animationcomplete', () => {
if (attackSprite.scene) attackSprite.destroy();
this._onAttackReconcile(event, berserkEvent);
if (onImpact) onImpact();
});
const emitter = this.add.particles(attackerDestX, centerY, 'particle_dot', {
speed: { min: 90, max: 300 },
scale: { start: 1.8, end: 0 },
alpha: { start: 1, end: 0 },
lifespan: 650,
tint: [0xff8800, 0xffee00, 0xffffff, 0x88ccff, 0xff44ff],
blendMode: 'ADD',
emitZone: {
type: 'edge',
source: new Phaser.Geom.Rectangle(-enlargedW / 2, -enlargedH / 2, enlargedW, enlargedH),
quantity: 56
},
stopAfter: 56
});
this.time.delayedCall(800, () => { if (emitter?.scene) emitter.destroy(); });
});
}
// ── Defender: shake + red flash + HP update + floating damage number ──────
// Fires once both cards are fully enlarged at center
this.time.delayedCall(SCALE_UP_MS + 80, () => {
if (defenderObj?.scene) {
// Refresh stats (ATK, ARM, DLY) but preserve the HP text that was already
// set by the preAttack animation or by animateHPLoss (which fired 80ms ago
// in _onAttackReconcile). Overwriting HP here would corrupt the in-progress
// HP loss animation and prematurely show the final HP on commanders.
const savedHP = defenderObj.hpText?.text;
defenderObj.refresh();
if (savedHP !== undefined && defenderObj.hpText?.scene) {
defenderObj.hpText.setText(savedHP);
}
// Flash red overlay → fade out
defenderObj.bg.setAlpha(0.70);
this.tweens.add({
targets: defenderObj.bg,
alpha: 0,
duration: 600,
ease: 'Power2'
});
// Shake around the defender's center position
const shakeX = defenderObj.x;
this.tweens.add({
targets: defenderObj,
x: { from: shakeX - 26, to: shakeX + 26 },
duration: 60,
yoyo: true,
repeat: 6,
ease: 'Linear',
onComplete: () => { if (defenderObj.scene) defenderObj.x = shakeX; }
});
}
// Floating damage number above the defender at center
const dmgAnchorX = defenderDestX;
const dmgAnchorY = centerY;
const dmgText = this.add.text(
dmgAnchorX, dmgAnchorY - 100,
`-${event.damage}`,
{ fontSize: '56px', color: '#ff3333', stroke: '#000000', strokeThickness: 5, fontFamily: 'RaiderCrusader' }
).setOrigin(0.5).setDepth(30);
this.tweens.add({
targets: dmgText,
y: dmgAnchorY - 250,
alpha: 0,
duration: 1300,
ease: 'Power2',
onComplete: () => dmgText.destroy()
});
});
this.time.delayedCall(ATTACK_MS, onComplete);
}
// Drives the 8-step preBattle sequence before attacks begin.
_processPreBattle(events, onComplete) {
const processStep = (idx) => {
if (idx >= events.length) { onComplete(); return; }
this._onPreBattleStep(events[idx], () => processStep(idx + 1));
};
processStep(0);
}
// Called once per preBattle step. Handles buff animations, siege fires, protect fires, then enfeeble fires.
_onPreBattleStep(event, onComplete) {
const hasBuffs = event.buffs?.length > 0;
const hasSiege = event.siegeFires?.length > 0;
const hasProtect = event.protectFires?.length > 0;
const hasEnfeeble = event.enfeebeFires?.length > 0;
const hasJam = event.jamFires?.length > 0;
const hasDrain = event.drainFires?.length > 0;
if (hasBuffs || hasSiege || hasProtect || hasEnfeeble || hasJam || hasDrain) {
this._processDrainFires(event.drainFires || [], () => {
this._processBuffAnimations(event.buffs || [], () => {
this._processSiegeFires(event.siegeFires || [], () => {
this._processProtectFires(event.protectFires || [], () => {
this._processEnfeebeFires(event.enfeebeFires || [], () => {
this._processJamFires(event.jamFires || [], onComplete);
});
});
});
});
});
} else {
onComplete();
}
}
_processSiegeFires(fires, onComplete) {
const next = (idx) => {
if (idx >= fires.length) { onComplete(); return; }
this._animateSiegeFire(fires[idx], () => next(idx + 1));
};
next(0);
}
_processProtectFires(fires, onComplete) {
const next = (idx) => {
if (idx >= fires.length) { onComplete(); return; }
this._animateProtectBuff(fires[idx], () => next(idx + 1));
};
next(0);
}
_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 >= 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);
}
_processJamFires(fires, onComplete) {
const next = (idx) => {
if (idx >= fires.length) { onComplete(); return; }
this._animateJamFire(fires[idx], () => next(idx + 1));
};
next(0);
}
_processBloodpactFires(fires, onComplete) {
const next = (idx) => {
if (idx >= fires.length) { onComplete(); return; }
this._animateBloodpactFire(fires[idx], () => next(idx + 1));
};
next(0);
}
_animateBloodpactFire(attacker, bloodpactFire, onComplete) {
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
const sourceObj = _lookup(attacker.instanceId);
if (!sourceObj?.scene) { onComplete(); return; }
const atkGain = bloodpactFire.value;
const hpCost = bloodpactFire.hpCost ?? bloodpactFire.value;
this.statusText.setText(`${attacker.name} blood pact: -${hpCost} HP, +${atkGain} ATK!`);
this.sound.play('sfx_damage', { volume: 0.6 });
// Bloodpact sprite (frame 24) overlaid on the card
const BASE_SCALE = 160 / 460;
const sprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 24)
.setScale(BASE_SCALE)
.setDepth(35)
.setAlpha(1);
// Red glow around the sprite
if (sprite.postFX) {
sprite.postFX.addGlow(0xff0000, 10, 0, false, 0.2, 16);
}
// Red flash on card
sourceObj.flash(0xff0000);
// Pulse the sprite larger and back 3 times while stats animate
const pulseScale = BASE_SCALE * 1.6;
this.tweens.chain({
targets: sprite,
tweens: [
{ scaleX: pulseScale, scaleY: pulseScale, duration: 180, ease: 'Quad.Out' },
{ scaleX: BASE_SCALE, scaleY: BASE_SCALE, duration: 180, ease: 'Quad.In' },
{ scaleX: pulseScale, scaleY: pulseScale, duration: 180, ease: 'Quad.Out' },
{ scaleX: BASE_SCALE, scaleY: BASE_SCALE, duration: 180, ease: 'Quad.In' },
{ scaleX: pulseScale, scaleY: pulseScale, duration: 180, ease: 'Quad.Out' },
{ scaleX: BASE_SCALE, scaleY: BASE_SCALE, duration: 180, ease: 'Quad.In' }
]
});
// After first pulse, refresh stats and show floating numbers
this.time.delayedCall(200, () => {
if (!sourceObj.scene) return;
sourceObj.refresh();
// Floating -HP (red, offset left)
const hpLabel = this.add.text(sourceObj.x - 28, sourceObj.y + 20, `-${hpCost} HP`, {
fontSize: '16px', color: '#ff4444', fontStyle: 'bold', fontFamily: 'Audiowide',
stroke: '#000000', strokeThickness: 3
}).setOrigin(0.5).setDepth(50);
this.tweens.add({ targets: hpLabel, y: hpLabel.y - 50, alpha: 0, duration: 700, ease: 'Power2',
onComplete: () => hpLabel.destroy() });
// Floating +ATK (orange, offset right)
const atkLabel = this.add.text(sourceObj.x + 28, sourceObj.y + 20, `+${atkGain} ATK`, {
fontSize: '16px', color: '#ffaa00', fontStyle: 'bold', fontFamily: 'Audiowide',
stroke: '#000000', strokeThickness: 3
}).setOrigin(0.5).setDepth(50);
this.tweens.add({ targets: atkLabel, y: atkLabel.y - 50, alpha: 0, duration: 700, ease: 'Power2',
onComplete: () => atkLabel.destroy() });
});
// Fade sprite out after pulses complete (~1.1s), then resolve
this.time.delayedCall(1100, () => {
if (!sprite.scene) return;
this.tweens.add({
targets: sprite,
alpha: 0,
duration: 250,
onComplete: () => {
if (sprite.scene) sprite.destroy();
if (!sourceObj.scene) { onComplete(); return; }
if (attacker.currentHP <= 0) {
this._playCardDeath(sourceObj, attacker, onComplete);
} else {
onComplete();
}
}
});
});
}
_processDrainFires(fires, onComplete) {
const next = (idx) => {
if (idx >= fires.length) { onComplete(); return; }
this._animateDrainFire(fires[idx].source, fires[idx], () => next(idx + 1));
};
next(0);
}
// Drain animation: reuse strike missile pattern with red/purple tint, then heal self
// Drain animation:
// 1. Sprite 22 flies from attacker to primary target, HP loss on primary
// 2. 2/3-scale copies fly left/right to neighbors, HP loss on each
// 3. Green emitters fly from all affected cards back to attacker, heal + refresh
_animateDrainFire(attacker, drainFire, onComplete) {
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
const sourceObj = _lookup(attacker.instanceId);
const targetObj = _lookup(drainFire.target.instanceId);
if (!sourceObj?.scene || !targetObj?.scene) { onComplete(); return; }
this.sound.play('sfx_drain', { volume: 0.8 });
// Correct target HP display to pre-drain value
if (targetObj.hpText && targetObj.scene) {
targetObj.hpText.setText(`${Math.max(0, drainFire.hpBefore)}`);
}
this.statusText.setText(`${attacker.name} drains ${drainFire.target.name}!`);
const BASE_SCALE = 160 / 460;
const SEC_SCALE = BASE_SCALE * (2 / 3);
// Place drain sprite (frame 22) on the attacking card
const drainSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 22)
.setScale(BASE_SCALE)
.setDepth(30);
// Fly to the primary target
this.tweens.add({
targets: drainSprite,
x: targetObj.x,
y: targetObj.y,
duration: 400,
ease: 'Cubic.easeIn',
onComplete: () => {
if (!targetObj.scene) {
if (drainSprite.scene) drainSprite.destroy();
onComplete();
return;
}
// Apply HP loss on primary target
const hpAfter = Math.max(0, drainFire.hpBefore - drainFire.damage);
if (drainFire.damage > 0) {
if (hpAfter > 0) this.sound.play('sfx_damage', { volume: 0.8 });
targetObj.animateHPLoss(drainFire.damage, null, drainFire.hpBefore, hpAfter);
}
// Collect all affected card objects for the heal-return phase
const affectedCards = [{ obj: targetObj, data: drainFire.target, damage: drainFire.damage, hpAfter }];
// Spawn secondary sprites to left/right neighbors
const secondaries = drainFire.secondaries || [];
let secondariesDone = 0;
const totalSecondaries = secondaries.filter(s => _lookup(s.target.instanceId)?.scene).length;
const onAllSecondariesLanded = () => {
// Brief pause, then fade primary sprite
this.time.delayedCall(300, () => {
if (drainSprite.scene) {
this.tweens.add({
targets: drainSprite,
alpha: 0, duration: 200, ease: 'Power2',
onComplete: () => { if (drainSprite.scene) drainSprite.destroy(); }
});
}
// Handle deaths, then send green emitters back
this._drainProcessDeaths(drainFire, affectedCards, () => {
this._drainHealReturn(sourceObj, attacker, affectedCards, drainFire.heal, onComplete);
});
});
};
if (totalSecondaries === 0) {
onAllSecondariesLanded();
} else {
for (const sec of secondaries) {
const secObj = _lookup(sec.target.instanceId);
if (!secObj?.scene) continue;
// Correct secondary HP display
if (secObj.hpText && secObj.scene) {
secObj.hpText.setText(`${Math.max(0, sec.hpBefore)}`);
}
// Spawn 2/3-scale copy at primary target position, fly to neighbor
const secSprite = this.add.sprite(targetObj.x, targetObj.y, 'attacks', 22)
.setScale(SEC_SCALE)
.setDepth(30);
this.tweens.add({
targets: secSprite,
x: secObj.x,
y: secObj.y,
duration: 300,
ease: 'Cubic.easeIn',
onComplete: () => {
// Fade out secondary sprite
this.tweens.add({
targets: secSprite,
alpha: 0, duration: 200, ease: 'Power2',
onComplete: () => { if (secSprite.scene) secSprite.destroy(); }
});
// HP loss on secondary
const secHpAfter = Math.max(0, sec.hpBefore - sec.damage);
if (sec.damage > 0 && secObj.scene) {
if (secHpAfter > 0) this.sound.play('sfx_damage', { volume: 0.6 });
secObj.animateHPLoss(sec.damage, null, sec.hpBefore, secHpAfter);
}
affectedCards.push({ obj: secObj, data: sec.target, damage: sec.damage, hpAfter: secHpAfter });
if (++secondariesDone >= totalSecondaries) {
onAllSecondariesLanded();
}
}
});
}
}
}
});
}
// Handle deaths for all cards hit by drain before the heal-return phase.
_drainProcessDeaths(drainFire, affectedCards, onComplete) {
let pending = 0;
let anyDeath = false;
for (const ac of affectedCards) {
if (ac.data.currentHP <= 0 && ac.obj.scene && !ac.obj.isCommander) {
anyDeath = true;
pending++;
this._playCardDeath(ac.obj, ac.data, () => {
if (--pending === 0) onComplete();
});
}
}
if (!anyDeath) onComplete();
}
// Send bright green emitters from each affected card back to the drain source, then heal.
_drainHealReturn(sourceObj, attacker, affectedCards, healAmount, onComplete) {
if (healAmount <= 0 || !sourceObj?.scene) { onComplete(); return; }
// Filter to cards still on scene (alive cards that were drained)
const sources = affectedCards.filter(ac => ac.obj?.scene && ac.damage > 0);
if (sources.length === 0) {
// Just refresh and finish
if (sourceObj.scene) sourceObj.refresh();
onComplete();
return;
}
let emittersReturned = 0;
const onAllReturned = () => {
// Green flash + HP refresh on the drain card
if (sourceObj.scene) {
sourceObj.flash(0x00ff00);
this.time.delayedCall(200, () => {
if (sourceObj.scene) sourceObj.refresh();
onComplete();
});
} else {
onComplete();
}
};
for (const ac of sources) {
const startX = ac.obj.x;
const startY = ac.obj.y;
const endX = sourceObj.x;
const endY = sourceObj.y;
// Green particle emitter that flies back to source
const emitter = this.add.particles(startX, startY, 'particle_dot', {
speed: { min: 10, max: 40 },
scale: { start: 0.7, end: 0 },
alpha: { start: 1, end: 0 },
lifespan: 300,
tint: [0x00ff00, 0x44ff44, 0x88ff88, 0x00cc00],
blendMode: 'ADD',
frequency: 20
}).setDepth(29);
const progress = { t: 0 };
this.tweens.add({
targets: progress,
t: 1,
duration: 500,
ease: 'Sine.inOut',
onUpdate: () => {
const sin = Math.sin(progress.t * Math.PI);
const px = startX + (endX - startX) * progress.t;
const py = startY + (endY - startY) * progress.t + (-120) * sin;
if (emitter.scene) emitter.setPosition(px, py);
},
onComplete: () => {
emitter.stop();
this.time.delayedCall(300, () => { if (emitter.scene) emitter.destroy(); });
if (++emittersReturned >= sources.length) {
onAllReturned();
}
}
});
}
}
// Bloodrage animation: red pulse on card + ATK gain text
// Siphon animation: sprite 21 flies from attacker to card across,
// stays ~500ms with glow + particles, then fades out. Heals attacker after.
_animateSiphonFire(attacker, siphonFire, onComplete) {
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
const sourceObj = _lookup(attacker.instanceId);
const targetObj = siphonFire.target ? _lookup(siphonFire.target.instanceId) : null;
if (!sourceObj?.scene) { onComplete(); return; }
// If no target across, skip animation but still refresh HP (heal already applied in engine)
if (!targetObj?.scene) {
if (siphonFire.heal > 0) {
sourceObj.flash(0x00ff00);
this.time.delayedCall(200, () => {
if (sourceObj.scene) sourceObj.refresh();
onComplete();
});
} else {
onComplete();
}
return;
}
this.sound.play('sfx_siphon', { volume: 0.8 });
this.statusText.setText(`${attacker.name} siphons +${siphonFire.heal} HP!`);
const BASE_SCALE = 160 / 460;
const siphonSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 21)
.setScale(BASE_SCALE * 0.6)
.setDepth(30);
// Blood-red glow
const glow = siphonSprite.postFX.addGlow(0xcc0000, 6, 0);
this.tweens.add({
targets: glow,
outerStrength: 14,
duration: 250,
yoyo: true,
repeat: -1,
ease: 'Sine.inOut'
});
// Bright red particle emitter tracking the sprite
const emitter = this.add.particles(siphonSprite.x, siphonSprite.y, 'particle_dot', {
speed: { min: 20, max: 70 },
scale: { start: 0.6, end: 0 },
alpha: { start: 1, end: 0 },
lifespan: 350,
tint: [0xff2222, 0xff4444, 0xff0000, 0xff6666],
blendMode: 'ADD',
frequency: 30
}).setDepth(29);
// Fly from attacker to the card directly across
this.tweens.add({
targets: siphonSprite,
x: targetObj.x,
y: targetObj.y,
scaleX: BASE_SCALE,
scaleY: BASE_SCALE,
duration: 320,
ease: 'Cubic.easeIn',
onUpdate: () => {
if (emitter.scene) emitter.setPosition(siphonSprite.x, siphonSprite.y);
},
onComplete: () => {
if (!siphonSprite.scene) {
if (emitter.scene) emitter.destroy();
onComplete();
return;
}
// Hold on target for ~500ms with pulsing glow
emitter.stop();
this.time.delayedCall(300, () => { if (emitter.scene) emitter.destroy(); });
this.time.delayedCall(500, () => {
if (!siphonSprite.scene) { onComplete(); return; }
// Fade out the sprite
this.tweens.add({
targets: siphonSprite,
alpha: 0,
scaleX: BASE_SCALE * 1.3,
scaleY: BASE_SCALE * 1.3,
duration: 300,
ease: 'Power2',
onComplete: () => {
if (siphonSprite.scene) siphonSprite.destroy();
// Refresh HP on the attacker (heal already applied)
if (sourceObj.scene) {
sourceObj.flash(0x00ff00);
this.time.delayedCall(200, () => {
if (sourceObj.scene) sourceObj.refresh();
onComplete();
});
} else {
onComplete();
}
}
});
});
}
});
}
_animateBloodrageGain(bloodrageEvent, onComplete) {
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
const cardObj = _lookup(bloodrageEvent.card.instanceId);
if (!cardObj?.scene) { onComplete(); return; }
this.statusText.setText(`${bloodrageEvent.card.name} enters a blood rage! +${bloodrageEvent.gain} ATK`);
cardObj.flash(0xff0000);
// Show ATK gain using berserk animation
cardObj.atkText.setText(`${bloodrageEvent.card.currentAttack - bloodrageEvent.gain}`);
cardObj.animateBerserkGain(bloodrageEvent.gain, () => {
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) => {
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);
}
};
next(0);
}
_animateRallyBuff(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; }
this.sound.play('sfx_rally', { volume: 0.8 });
this.statusText.setText(`${buff.source.name} rallies ${buff.target.name}! +${buff.amount} ATK`);
// Base scale for 160px display from 460px source frame
const BASE_SCALE = 160 / 460;
// Flag sprite placed on the source card
const flagSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 10)
.setScale(BASE_SCALE)
.setDepth(30);
// Pulsing glow on the flag
const glow = flagSprite.postFX.addGlow(0xffdd00, 8, 0);
this.tweens.add({
targets: glow,
outerStrength: 18,
duration: 300,
yoyo: true,
repeat: -1,
ease: 'Sine.inOut'
});
// Particle emitter — position synced manually via onUpdate so it tracks the moving flag
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);
// Arc flight — tween a progress value and compute position + scale each frame
const startX = sourceObj.x;
const startY = sourceObj.y;
const endX = targetObj.x;
const endY = targetObj.y;
const ARC_HEIGHT = -200; // px upward at the midpoint
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)); // swells in the middle
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;
}
// Reuse berserk animation to show the ATK boost
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();
}
});
});
}
});
}
// ── "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) {
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
const sourceObj = _lookup(siegeFire.source.instanceId);
const targetObj = _lookup(siegeFire.target.instanceId);
if (!sourceObj?.scene || !targetObj?.scene) { onComplete(); return; }
// Correct the commander's HP display to pre-siege value immediately
if (targetObj.hpText && targetObj.scene) {
targetObj.hpText.setText(`${Math.max(0, siegeFire.hpBefore)}`);
}
this.statusText.setText(`${siegeFire.source.name} launches siege missiles at ${siegeFire.target.name}!`);
const BASE_SCALE = 160 / 460;
// Overlay the Siege Launcher sprite (13) on top of the attacking card
const launcherSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 13)
.setScale(BASE_SCALE)
.setDepth(30);
// Hold the launcher visible briefly, then fire the missiles
this.time.delayedCall(600, () => {
if (!launcherSprite.scene) { onComplete(); return; }
const startX = sourceObj.x, startY = sourceObj.y;
const endX = targetObj.x, endY = targetObj.y;
const ARC_HEIGHT = -160;
const progress = { t: 0 };
this.sound.play('sfx_siege', { volume: 0.8 });
// Siege missile sprite (14) — aimed at commander, points up by default
const missileSprite = this.add.sprite(startX, startY, 'attacks', 14)
.setScale(BASE_SCALE)
.setDepth(31);
const angle = Phaser.Math.Angle.Between(startX, startY, endX, endY);
missileSprite.rotation = angle + Math.PI / 2;
// Orange-red particle trail
const emitter = this.add.particles(startX, startY, 'particle_dot', {
speed: { min: 20, max: 70 },
scale: { start: 0.5, end: 0 },
alpha: { start: 1, end: 0 },
lifespan: 300,
tint: [0xff4400, 0xff8800, 0xffcc00],
blendMode: 'ADD',
frequency: 25
}).setDepth(30);
this.tweens.add({
targets: progress,
t: 1,
duration: 550,
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(250, () => { if (emitter.scene) emitter.destroy(); });
if (missileSprite.scene) missileSprite.destroy();
if (launcherSprite.scene) launcherSprite.destroy();
if (!targetObj.scene) { onComplete(); return; }
// Explosion on impact, then resolve HP loss on commander
this._playSkillExplosion(targetObj.x, targetObj.y, () => {
if (!targetObj.scene) { onComplete(); return; }
const hpAfter = Math.max(0, siegeFire.hpBefore - siegeFire.damage);
const afterImpact = () => {
if (targetObj.hpText && targetObj.scene) targetObj.hpText.setText(`${hpAfter}`);
onComplete();
};
if (siegeFire.damage > 0) {
if (hpAfter > 0) this.sound.play('sfx_damage', { volume: 0.8 });
targetObj.animateHPLoss(siegeFire.damage, afterImpact, siegeFire.hpBefore, hpAfter);
} else {
afterImpact();
}
});
}
});
});
}
// Animate protect: full-size shield rises on source card; 2/3-size shields fly to neighbors.
// After all shields settle, animate ARM gain on each affected card.
_animateProtectBuff(protectFire, onComplete) {
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
const sourceObj = _lookup(protectFire.source.instanceId);
if (!sourceObj?.scene) { onComplete(); return; }
this.sound.play('sfx_protect', { volume: 0.8 });
this.statusText.setText(`${protectFire.source.name} raises shields!`);
const BASE_SCALE = 160 / 460;
const NEIGHBOR_SCALE = BASE_SCALE * (2 / 3);
const RISE_DURATION = 600;
const FLY_DURATION = 500;
const SETTLE_DELAY = 650; // ms before ARM gain animations start
// Resolve the target CardObjects and separate self from neighbors
const self = protectFire.targets.find(t => t.laneOffset === 0);
const left = protectFire.targets.find(t => t.laneOffset === -1);
const right = protectFire.targets.find(t => t.laneOffset === 1);
const selfObj = self ? _lookup(self.card.instanceId) : null;
const leftObj = left ? _lookup(left.card.instanceId) : null;
const rightObj = right ? _lookup(right.card.instanceId) : null;
const sprites = [];
// Full-size shield on the source card — rises upward
if (selfObj?.scene) {
const shield = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 17)
.setScale(BASE_SCALE)
.setDepth(30);
sprites.push({ sprite: shield, targetObj: selfObj, cardData: self.card, amount: self.amount });
this.tweens.add({
targets: shield,
y: sourceObj.y - 50,
duration: RISE_DURATION,
ease: 'Sine.easeOut'
});
}
// 2/3-size shields for each neighbor — fly from source to neighbor card
for (const { neighbor, neighborObj } of [
{ neighbor: left, neighborObj: leftObj },
{ neighbor: right, neighborObj: rightObj }
]) {
if (!neighbor || !neighborObj?.scene) continue;
const shield = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 17)
.setScale(NEIGHBOR_SCALE)
.setDepth(30);
sprites.push({ sprite: shield, targetObj: neighborObj, cardData: neighbor.card, amount: neighbor.amount });
this.tweens.add({
targets: shield,
x: neighborObj.x,
y: neighborObj.y - 30,
duration: FLY_DURATION,
ease: 'Sine.easeOut'
});
}
if (sprites.length === 0) { onComplete(); return; }
// After all shields have settled, animate ARM gain on each card simultaneously
this.time.delayedCall(SETTLE_DELAY, () => {
// Fade out all shields
for (const { sprite } of sprites) {
this.tweens.add({
targets: sprite,
alpha: 0,
duration: 300,
ease: 'Power2',
onComplete: () => { if (sprite.scene) sprite.destroy(); }
});
}
// Animate ARM gain on each target in parallel; call onComplete when the last one finishes
let remaining = sprites.length;
const done = () => { if (--remaining === 0) onComplete(); };
for (const { targetObj, cardData, amount } of sprites) {
if (targetObj?.scene) {
// Reset arm text to pre-buff value so the animation starts from the right number
targetObj.armText?.setText(`${cardData.currentArmor - amount}`);
targetObj.animateArmorGain(amount, done);
} else {
done();
}
}
});
}
// Animate enfeeble: sprite 18 flies from source card to target card (like pierce), then
// the reticle locks on and pulses before fading, then ATK loss animates on the target.
_animateEnfeebeFire(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; }
this.sound.play('sfx_enfeeble', { volume: 0.8 });
this.statusText.setText(`${enfeebeFire.source.name} enfeebles ${enfeebeFire.target.name}!`);
const BASE_SCALE = 160 / 460;
const enfeebleSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 18)
.setScale(BASE_SCALE)
.setDepth(30);
// Fly to the target
this.tweens.add({
targets: enfeebleSprite,
x: targetObj.x,
y: targetObj.y,
duration: 320,
ease: 'Cubic.easeIn',
onComplete: () => {
if (!targetObj.scene) { onComplete(); return; }
// Lock on — slow pulse for ~800ms
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; }
// Fade out the sprite
this.tweens.add({
targets: enfeebleSprite,
alpha: 0,
duration: 300,
ease: 'Power2',
onComplete: () => { if (enfeebleSprite.scene) enfeebleSprite.destroy(); }
});
// ATK loss animation on target
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);
}
});
}
});
}
// ── "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; }
const w = cardObj.options?.width || 80;
const s = w / 80;
const h = cardObj.options?.height || 110;
const bannerH = Math.round(h * 0.12);
const topBannerCY = -h / 2 + bannerH / 2;
cardObj.atkText.setText(`${Math.max(0, fromATK)}`);
this.tweens.add({
targets: cardObj.atkText,
scaleX: 2, scaleY: 2,
duration: 200,
ease: 'Back.Out',
onComplete: () => {
if (!cardObj.scene) { if (onComplete) onComplete(); return; }
const lossText = this.add.text(
cardObj.x + cardObj.atkText.x * cardObj.scaleX - Math.round(4 * s),
cardObj.y + topBannerCY * cardObj.scaleY,
`-${amount}`,
{
fontSize: `${Math.round(11 * s)}px`,
color: '#ff8877',
fontStyle: 'bold',
stroke: '#000000',
strokeThickness: Math.max(1, Math.round(2 * s)),
fontFamily: 'Audiowide'
}
).setOrigin(1, 0.5).setDepth(50);
this.time.delayedCall(500, () => {
if (!cardObj.scene) { if (onComplete) onComplete(); return; }
cardObj.atkText.setText(`${Math.max(0, toATK)}`);
this.tweens.add({ targets: cardObj.atkText, scaleX: 1, scaleY: 1, duration: 200, ease: 'Power2' });
this.tweens.add({
targets: lossText,
alpha: 0,
y: lossText.y - Math.round(14 * s),
duration: 300,
ease: 'Power2',
onComplete: () => {
if (lossText.scene) lossText.destroy();
if (onComplete) onComplete();
}
});
});
}
});
}
// Animate jam: sprite 19 flies source → primary (~900ms), pulses, then half-sized copies fan
// to secondaries (~700ms). Floating skill-reduction text appears on each hit card and fades
// over 1200ms. Cards only refresh (showing reduced skill values) after all floats finish.
// Total duration: ~3 seconds.
_animateJamFire(jamFire, onComplete) {
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
const sourceObj = _lookup(jamFire.source.instanceId);
const primaryTarget = jamFire.targets.find(t => t.laneOffset === 0);
if (!sourceObj?.scene || !primaryTarget) { onComplete(); return; }
const primaryObj = _lookup(primaryTarget.card.instanceId);
if (!primaryObj?.scene) { onComplete(); return; }
this.sound.play('sfx_jam', { volume: 0.8 });
this.statusText.setText(`${jamFire.source.name} jams the enemy signals!`);
const BASE_SCALE = 160 / 460;
const HALF_SCALE = BASE_SCALE * 0.5;
const FLY_MS = 900;
const PULSE_MS = 200; // one yoyo = 400ms
const FAN_MS = 700;
const FADE_MS = 400;
const FLOAT_MS = 1200;
const secondaries = jamFire.targets.filter(t => t.laneOffset !== 0);
// Count how many float animations we're waiting on before finishing
const validTargets = [primaryTarget, ...secondaries].filter(
t => _lookup(t.card.instanceId)?.scene
);
if (validTargets.length === 0) { onComplete(); return; }
let floatsRemaining = validTargets.length;
const onFloatDone = () => {
if (--floatsRemaining > 0) return;
// All floats finished — now refresh every affected card to show reduced values
for (const t of validTargets) _lookup(t.card.instanceId)?.refresh();
onComplete();
};
// Main sprite: source → primary
const mainSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 19)
.setScale(BASE_SCALE)
.setDepth(30);
this.tweens.add({
targets: mainSprite,
x: primaryObj.x,
y: primaryObj.y,
duration: FLY_MS,
ease: 'Cubic.easeIn',
onComplete: () => {
if (!primaryObj.scene) { onFloatDone(); if (mainSprite.scene) mainSprite.destroy(); return; }
// Floating skill-reduction text on primary (drives completion)
this._showJamReductionFloat(primaryObj, primaryTarget.reductions, FLOAT_MS, onFloatDone);
// Pulse main sprite once, then fade
this.tweens.add({
targets: mainSprite,
scaleX: BASE_SCALE * 1.35,
scaleY: BASE_SCALE * 1.35,
duration: PULSE_MS,
ease: 'Sine.easeInOut',
yoyo: true,
onComplete: () => {
this.tweens.add({
targets: mainSprite,
alpha: 0,
duration: FADE_MS,
ease: 'Power2',
onComplete: () => { if (mainSprite.scene) mainSprite.destroy(); }
});
}
});
// Half-sized copies fan from primary to each secondary
for (const sec of secondaries) {
const secObj = _lookup(sec.card.instanceId);
if (!secObj?.scene) { onFloatDone(); continue; }
const secSprite = this.add.sprite(primaryObj.x, primaryObj.y, 'attacks', 19)
.setScale(HALF_SCALE)
.setDepth(30);
this.tweens.add({
targets: secSprite,
x: secObj.x,
y: secObj.y,
duration: FAN_MS,
ease: 'Cubic.easeIn',
onComplete: () => {
if (!secObj.scene) { onFloatDone(); if (secSprite.scene) secSprite.destroy(); return; }
// Floating text on secondary (drives completion)
this._showJamReductionFloat(secObj, sec.reductions, FLOAT_MS, onFloatDone);
this.tweens.add({
targets: secSprite,
alpha: 0,
duration: FADE_MS,
ease: 'Power2',
onComplete: () => { if (secSprite.scene) secSprite.destroy(); }
});
}
});
}
}
});
}
// Temporarily add back jam reductions so CardObjects are built with original skill values.
_restoreJamSkillsForDisplay(preBattleEvents) {
for (const event of preBattleEvents) {
for (const fire of event.jamFires || []) {
for (const t of fire.targets) {
for (const r of t.reductions) {
if (t.card.skills[r.index]) t.card.skills[r.index].value += r.amount;
}
}
}
}
}
// Re-apply jam reductions after _renderState() so combat math stays correct.
_reapplyJamSkillReductions(preBattleEvents) {
for (const event of preBattleEvents) {
for (const fire of event.jamFires || []) {
for (const t of fire.targets) {
for (const r of t.reductions) {
if (t.card.skills[r.index]) t.card.skills[r.index].value = Math.max(0, t.card.skills[r.index].value - r.amount);
}
}
}
}
}
// 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; }
// Place float near the skill text using the cardObj's world position
const skillOffsetY = cardObj.skillText
? cardObj.skillText.y * (cardObj.scaleY || 1)
: cardObj.displayHeight * 0.2;
const worldX = cardObj.x;
const worldY = cardObj.y + skillOffsetY;
const lines = reductions.map(r => {
const skillName = cardObj.cardData.skills[r.index]?.name || 'skill';
return `-${r.amount} ${skillName}`;
}).join('\n');
const floatText = this.add.text(worldX, worldY, lines, {
fontSize: '18px',
color: '#ff9933',
fontStyle: 'bold',
stroke: '#000000',
strokeThickness: 3,
align: 'center',
fontFamily: 'Audiowide'
}).setOrigin(0.5, 0.5).setDepth(55);
this.tweens.add({
targets: floatText,
alpha: 0,
y: worldY - 55,
duration,
ease: 'Power2',
onComplete: () => {
if (floatText.scene) floatText.destroy();
if (onComplete) onComplete();
}
});
}
// Drives the 8-step postBattle sequence after all attacks resolve.
_processPostBattle(events, onComplete) {
const processStep = (idx) => {
if (idx >= events.length) { onComplete(); return; }
this._onPostBattleStep(events[idx], () => processStep(idx + 1));
};
processStep(0);
}
// Called once per postBattle step. Refreshes cards whose temp buffs were removed.
_onPostBattleStep(event, onComplete) {
if (event.debuffs?.length > 0) {
for (const debuff of event.debuffs) {
const obj = this.cardObjects.get(debuff.target.instanceId)
?? this.commanderObjects?.get(debuff.target.instanceId);
obj?.refresh();
}
}
onComplete();
}
// Fires when the attack animation ends — attach all post-attack consequences here.
_onAttackReconcile(event, berserkEvent) {
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
const defenderObj = _lookup(event.defender?.instanceId);
const attackerObj = _lookup(event.attacker?.instanceId);
// HP loss animation on defender; on completion, trigger death if HP reached zero
if (defenderObj?.scene && event.damage > 0) {
if (event.defender.currentHP > 0) this.sound.play('sfx_damage', { volume: 0.8 });
defenderObj.animateHPLoss(event.damage, () => {
if (event.defender.currentHP <= 0 && !defenderObj.isCommander) {
this._playCardDeath(defenderObj, event.defender);
}
});
}
// Berserk ATK gain animation on attacker
if (berserkEvent && attackerObj?.scene) {
attackerObj.atkText.setText(`${berserkEvent.card.currentAttack - berserkEvent.gain}`);
attackerObj.animateBerserkGain(berserkEvent.gain);
}
}
_showCardPicker(hand) {
const { width, height } = this.scale;
this._destroyCardPicker();
this.pickerObjects = [];
// Dim overlay
const overlay = this.add.rectangle(width / 2, height / 2, width, height, 0x000000, 0.60)
.setDepth(10);
this.pickerObjects.push(overlay);
// Panel background
const cardW = 260, cardH = 364;
const gap = 40;
const panelPadX = 60, panelPadY = 70;
const totalW = hand.length * cardW + (hand.length - 1) * gap;
const panelW = Math.max(totalW + panelPadX * 2, 500);
const panelH = cardH + panelPadY * 2;
const panelY = 540;
const panel = this.add.rectangle(width / 2, panelY, panelW, panelH, 0x0d1b2a, 0.97)
.setStrokeStyle(2, 0x4488ff)
.setDepth(10);
this.pickerObjects.push(panel);
const title = this.add.text(width / 2, panelY - panelH / 2 + 26, 'Choose a card to deploy', {
fontSize: '22px', color: '#d4af37', fontFamily: 'Audiowide'
}).setOrigin(0.5).setDepth(11);
this.pickerObjects.push(title);
const startX = width / 2 - totalW / 2 + cardW / 2;
const cardY = panelY + 8;
hand.forEach((card, i) => {
const x = startX + i * (cardW + gap);
// CardObject at depth 11
const cardObj = new CardObject(this, x, cardY, card, { width: cardW, height: cardH });
cardObj.setDepth(11);
this.pickerObjects.push(cardObj);
// Use the CardObject's own interactive area for hover + click
cardObj.setInteractive(
new Phaser.Geom.Rectangle(-cardW / 2, -cardH / 2, cardW, cardH),
Phaser.Geom.Rectangle.Contains
);
cardObj.input.cursor = 'pointer';
cardObj.on('pointerover', () => {
cardObj.setScale(1.05);
cardObj.setDepth(13);
});
cardObj.on('pointerout', () => {
cardObj.setScale(1);
cardObj.setDepth(11);
});
cardObj.on('pointerdown', () => {
this._destroyCardPicker();
this._finishTurn(card);
});
});
// Pass button
const passBtn = this.add.rectangle(width / 2, panelY + panelH / 2 - 28, 220, 44, 0x333333)
.setStrokeStyle(1, 0x888888)
.setInteractive({ useHandCursor: true })
.setDepth(11);
const passTxt = this.add.text(width / 2, panelY + panelH / 2 - 28, 'Pass (deploy nothing)', {
fontSize: '17px', color: '#aaaaaa', fontFamily: 'Audiowide'
}).setOrigin(0.5).setDepth(12);
passBtn.on('pointerdown', () => {
this._destroyCardPicker();
this._finishTurn(null);
});
this.pickerObjects.push(passBtn, passTxt);
}
_destroyCardPicker() {
if (this.pickerObjects) {
this.pickerObjects.forEach(o => o.destroy());
this.pickerObjects = null;
}
}
// ── 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();
this.cardObjects.forEach(co => co.destroy());
this.cardObjects.clear();
state.player.lanes.forEach((card, i) => {
const pos = this.battlefield.getPlayerLanePos(i);
const co = new CardObject(this, pos.x, pos.y, card, { width: 260, height: 364 });
this.cardObjects.set(card.instanceId, co);
});
state.opponent.lanes.forEach((card, i) => {
const pos = this.battlefield.getOpponentLanePos(i);
const co = new CardObject(this, pos.x, pos.y, card, { width: 260, height: 364 });
this.cardObjects.set(card.instanceId, co);
});
}
_updateLog() {
const recent = this.engine.log.slice(-6);
this.logLines.forEach((line, i) => {
line.setText(recent[i] || '');
});
}
_toggleAuto() {
this.autoPlay = !this.autoPlay;
this.autoBtnText.setText(`Auto: ${this.autoPlay ? 'ON' : 'OFF'}`);
if (this.autoPlay) {
this.autoTimer = this.time.addEvent({
delay: 300,
callback: () => {
if (!this.engine.winner) {
this._beginTurn();
} else {
this.autoPlay = false;
this.autoBtnText.setText('Auto: OFF');
if (this.autoTimer) this.autoTimer.remove();
}
},
loop: true
});
} else {
if (this.autoTimer) this.autoTimer.remove();
}
}
_showResult() {
if (this.autoTimer) this.autoTimer.remove();
const { width, height } = this.scale;
const won = this.engine.winner === 'player';
const save = this.registry.get('save');
// Overlay
this.add.rectangle(width / 2, height / 2, width, height, won ? 0x003300 : 0x330000, 0.75);
this.add.text(width / 2, height / 2 - 120, won ? 'VICTORY!' : 'DEFEAT', {
fontSize: '72px', color: won ? '#44ff44' : '#ff4444',
stroke: '#000000', strokeThickness: 6, fontFamily: 'RaiderCrusader'
}).setOrigin(0.5);
this.add.text(width / 2, height / 2 - 40, `Battle lasted ${this.engine.turn} turns`, {
fontSize: '24px', color: '#aaaaaa', fontFamily: 'Audiowide'
}).setOrigin(0.5);
if (won && this.missionData) {
const rewards = this.missionData.rewards;
SaveManager.addGold(save, rewards.gold);
SaveManager.completeMission(save, this.missionData.id);
if (rewards.cards) {
rewards.cards.forEach(cardId => SaveManager.addCard(save, cardId));
}
// Check if this is the final mission of a campaign — if so, unlock the enemy commander (once only)
let unlockedCommander = null;
const campaigns = this.registry.get('campaigns') || [];
const campaign = campaigns.find(c => c.missions && c.missions[c.missions.length - 1] === this.missionData.id);
if (campaign) {
const commanderId = this.missionData.opponent?.commander;
if (commanderId && !save.collection[commanderId]) {
SaveManager.addCard(save, commanderId);
unlockedCommander = commanderId;
}
}
this.registry.set('save', save);
this.add.text(width / 2, height / 2 + 20, `+${rewards.gold} Gold`, {
fontSize: '36px', color: '#ffd700', fontFamily: 'RaiderCrusader'
}).setOrigin(0.5);
if (rewards.cards && rewards.cards.length > 0) {
const cardManager = this.registry.get('cardManager');
const cardNames = rewards.cards.map(id => {
const c = cardManager.getCard(id);
return c ? c.name : id;
}).join(', ');
this.add.text(width / 2, height / 2 + 70, `New card(s): ${cardNames}`, {
fontSize: '22px', color: '#aaffaa', fontFamily: 'Audiowide'
}).setOrigin(0.5);
}
if (unlockedCommander) {
const cardManager = this.registry.get('cardManager');
const cmdCard = cardManager.getCard(unlockedCommander);
const cmdName = cmdCard ? cmdCard.name : unlockedCommander;
this.add.text(width / 2, height / 2 + 110, `Commander unlocked: ${cmdName}!`, {
fontSize: '22px', color: '#ffdd44', fontFamily: 'Audiowide'
}).setOrigin(0.5);
}
} else if (!won) {
this.add.text(width / 2, height / 2 + 20, 'Better luck next time!', {
fontSize: '24px', color: '#aaaaaa', fontFamily: 'Audiowide'
}).setOrigin(0.5);
}
const backBtn = this.add.rectangle(width / 2, height / 2 + 160, 260, 60, 0x1a3a5c)
.setInteractive({ useHandCursor: true })
.setStrokeStyle(2, 0x4488ff);
this.add.text(width / 2, height / 2 + 160, 'Continue', {
fontSize: '28px', color: '#ffffff', fontFamily: 'Audiowide'
}).setOrigin(0.5);
backBtn.on('pointerdown', () => {
if (this.missionData) {
this.scene.start('CampaignScene', { campaignId: this.campaignId || 'campaign_raider' });
} else {
this.scene.start('MainMenuScene');
}
});
}
_animateInitiativeHandoff() {
if (!this.initiativeIndicator?.scene) return;
// playerGoesFirst still reflects the turn that just ended; next turn is the opposite
const targetY = this.playerGoesFirst ? 305 : 715;
this.tweens.add({
targets: this.initiativeIndicator,
y: targetY,
duration: 700,
ease: 'Power2.inOut'
});
}
_updateInitiativeIndicator(playerGoesFirst) {
if (this.initiativeIndicator) {
this.initiativeIndicator.destroy();
this.initiativeIndicator = null;
}
if (!this.textures.exists('attacksFirst')) return;
// Position the indicator to the left of the commander card (commander at x=150, width=240)
const indicatorX = 60;
const indicatorY = playerGoesFirst ? 715 : 305;
this.initiativeIndicator = this.add.image(indicatorX, indicatorY, 'attacksFirst')
.setDisplaySize(110, 110)
.setDepth(5);
}
_makeBackButton() {
const bg = this.add.rectangle(80, 35, 180, 44, 0x333333)
.setInteractive({ useHandCursor: true })
.setStrokeStyle(1, 0x888888);
this.add.text(80, 35, '← Back', { fontSize: '18px', color: '#ffffff', fontFamily: 'Audiowide' }).setOrigin(0.5);
bg.on('pointerdown', () => {
if (this.autoTimer) this.autoTimer.remove();
this.scene.start('MainMenuScene');
});
}
}