tyrants-edge/src/scenes/BattleScene.js

4699 lines
177 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'],
xeno: ['xeno_01', 'xeno_02', 'xeno_03'],
righteous: ['right_01', 'right_02', 'right_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' || e.type === 'ruptureTick' || e.type === 'venomTick' || e.type === 'smiteTick' || e.type === 'carapace' || e.type === 'molt' || e.type === 'hive_link');
// Build preBattle hive_link map for venom kills: dead card instanceId → hive_link event
this._preBattleHiveLinks = {};
for (const e of commitEvents) {
if (e.type === 'hive_link') this._preBattleHiveLinks[e.source.instanceId] = e;
}
// Temporarily restore all preBattle stat changes so _renderState() builds
// CardObjects with original (pre-combat) values. Re-applied immediately after
// so combat math is unaffected. Animations then show each change visually.
this._restoreBuffsForDisplay(preBattleEvents);
this._restoreJamSkillsForDisplay(preBattleEvents);
this._restoreEnfeebleForDisplay(preBattleEvents);
this._restoreDrainForDisplay(preBattleEvents);
this._restoreMoltForDisplay(preBattleEvents);
this._restoreHealForDisplay(preBattleEvents);
this._restoreSanctifyForDisplay(preBattleEvents);
this._restoreOverchargeForDisplay(preBattleEvents);
this._restoreFortifyForDisplay(preBattleEvents);
// Render the field with all newly deployed cards but pre-combat stats
this._renderState();
this._reapplyBuffs(preBattleEvents);
this._reapplyJamSkillReductions(preBattleEvents);
this._reapplyEnfeebleReductions(preBattleEvents);
this._reapplyDrainDamage(preBattleEvents);
this._reapplyMolt(preBattleEvents);
this._reapplyHeal(preBattleEvents);
this._reapplySanctify(preBattleEvents);
this._reapplyOvercharge(preBattleEvents);
this._reapplyFortify(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;
// Collect spawn events emitted before preBattle (hive_link fires during processNextAttack, not here).
const phaseEvents = commitEvents.filter(e => e.type === 'spawn');
const startAttacks = () => this._processPhaseEvents(phaseEvents, () => {
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' || e.type === 'burrowTick'), () => {
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');
// 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 (['ruptureAll', 'ruptureApply', 'weakenAll', 'venomApply', 'venomApplyAll', 'smiteApply', 'smiteApplyAll', 'carapace'].includes(e.type))
currentRound.onAttackAllEvents.push(e);
}
}
// Build pending hive_link map: dead card instanceId → hive_link event,
// so _onAttackReconcile can fire it immediately after the death explosion.
this._pendingHiveLinks = {};
for (const round of attackRounds) {
for (const e of round.onAttackAllEvents) {
if (e.type === 'hive_link') this._pendingHiveLinks[e.source.instanceId] = e;
}
}
// Also scan events not yet bucketed into rounds (e.g. from counter kills)
for (const e of events) {
if (e.type === 'hive_link') this._pendingHiveLinks[e.source.instanceId] = 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);
// If swarm buffed this attacker and will revert in postAttack, restore the
// buffed ATK display immediately — obj.refresh() above already reset it to the
// un-buffed value because the engine reverts currentAttack synchronously.
if (postAttackEvent?.swarmRevert && round.attackEvent.attacker) {
const rev = postAttackEvent.swarmRevert;
if (rev.card.instanceId === round.attackEvent.attacker.instanceId) {
const obj = this.cardObjects.get(rev.card.instanceId) ?? this.commanderObjects?.get(rev.card.instanceId);
if (obj?.atkText?.scene) obj.atkText.setText(`${rev.card.currentAttack + rev.amount}`);
}
}
this.time.delayedCall(220, () => {
this._processOnAttackAllEvents(round.onAttackAllEvents, () => {
animateRound(roundIdx + 1);
});
});
}, round.berserkEvent, () => {
this._animateCounterFire(round.counterEvent);
});
};
if (roundIdx === 0) {
this._onPreAttackStep(preAttackEvent, runAttack);
} else {
runAttack();
}
};
animateRound(0);
}
// Update a card's HP bar and stats at the moment of impact, then handle death.
// 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 === 'ruptureApply') this._animateRuptureApply(event, cb);
else if (event.type === 'weakenAll') this._animateWeakenAll(event, cb);
else if (event.type === 'venomApply') this._animateVenomApply(event, cb);
else if (event.type === 'venomApplyAll') this._animateVenomApplyAll(event, cb);
else if (event.type === 'smiteApply') this._animateSmiteApply(event, cb);
else if (event.type === 'smiteApplyAll') this._animateSmiteApplyAll(event, cb);
else if (event.type === 'hive_link') this._animateHiveLink(event, cb);
else if (event.type === 'carapace') this._animateCarapace(event, cb);
else cb();
};
next(0);
}
_animateRuptureAll(event, onComplete) {
if (!event.targets?.length) { onComplete(); return; }
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
const sourceObj = _lookup(event.attacker.instanceId);
this.statusText.setText(`${event.attacker.name} ruptures all enemies!`);
this.sound.play('sfx_rupture', { volume: 0.8 });
let remaining = event.targets.length;
const done = () => { if (--remaining <= 0) onComplete(); };
for (const t of event.targets) {
const targetObj = _lookup(t.target.instanceId);
if (!targetObj?.scene) { done(); continue; }
if (!sourceObj?.scene) {
targetObj.refresh();
this._showRuptureStackGain(targetObj, t.stacks);
this.time.delayedCall(400, done);
continue;
}
const rot = Math.atan2(targetObj.y - sourceObj.y, targetObj.x - sourceObj.x) + Math.PI / 2;
const knife = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 44)
.setDisplaySize(80, 80)
.setRotation(rot)
.setDepth(30);
this.tweens.add({
targets: knife,
x: targetObj.x,
y: targetObj.y,
duration: 2000,
ease: 'Quad.In',
onComplete: () => {
if (knife.scene) knife.destroy();
if (targetObj.scene) {
targetObj.refresh();
this._showRuptureStackGain(targetObj, t.stacks);
}
done();
}
});
}
}
_animateRuptureApply(event, onComplete) {
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
const sourceObj = _lookup(event.attacker.instanceId);
const targetObj = _lookup(event.target.instanceId);
if (!targetObj?.scene) { onComplete(); return; }
this.statusText.setText(`${event.attacker.name} ruptures ${event.target.name}! [${event.stacks} stacks]`);
this.sound.play('sfx_rupture', { volume: 0.8 });
if (!sourceObj?.scene) {
targetObj.refresh();
this._showRuptureStackGain(targetObj, event.stacks);
this.time.delayedCall(400, onComplete);
return;
}
const rot = Math.atan2(targetObj.y - sourceObj.y, targetObj.x - sourceObj.x) + Math.PI / 2;
const knife = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 44)
.setDisplaySize(250, 250)
.setRotation(rot)
.setDepth(30);
this.tweens.add({
targets: knife,
x: targetObj.x,
y: targetObj.y,
duration: 800,
ease: 'Quad.In',
onComplete: () => {
if (knife.scene) knife.destroy();
if (!targetObj.scene) { onComplete(); return; }
targetObj.refresh();
this._showRuptureStackGain(targetObj, event.stacks);
this.time.delayedCall(400, onComplete);
}
});
}
_animateRuptureTick(event, onComplete) {
const obj = this.cardObjects.get(event.card.instanceId) || this.commanderObjects?.get(event.card.instanceId);
if (!obj?.scene) { onComplete(); return; }
this.statusText.setText(`${event.card.name} suffers ${event.damage} rupture damage!`);
this.sound.play('sfx_rupture_effect', { volume: 0.8 });
// Sprite 45 (heart) pulses larger/smaller over the card for ~2 seconds
const effectSprite = this.add.sprite(obj.x, obj.y, 'attacks', 45)
.setDisplaySize(140, 140)
.setDepth(30)
.setAlpha(0.9);
this.tweens.add({
targets: effectSprite,
scaleX: { from: effectSprite.scaleX * 0.8, to: effectSprite.scaleX * 1.5 },
scaleY: { from: effectSprite.scaleY * 0.8, to: effectSprite.scaleY * 1.5 },
alpha: { from: 0.9, to: 0.2 },
duration: 500,
yoyo: true,
repeat: 1,
onComplete: () => { if (effectSprite.scene) effectSprite.destroy(); }
});
if (event.damage > 0 && obj.animateHPLoss) {
obj.animateHPLoss(event.damage, null);
}
// Wait for sprite to finish (~2 seconds), then handle kill/complete
this.time.delayedCall(2000, () => {
if (event.killed) {
const hlEvent = this._preBattleHiveLinks?.[event.card.instanceId];
if (hlEvent) delete this._preBattleHiveLinks[event.card.instanceId];
this._playCardDeath(obj, event.card,
hlEvent ? () => this._animateHiveLink(hlEvent, onComplete) : onComplete
);
} else {
if (obj.scene) obj.refresh();
onComplete();
}
});
}
_showRuptureStackGain(targetObj, stacks) {
if (!targetObj?.scene || stacks <= 0) return;
const gainText = this.add.text(targetObj.x, targetObj.y - 20, `+${stacks} rupture`, {
fontSize: '12px', color: '#ff4444', fontStyle: 'bold',
stroke: '#000000', strokeThickness: 3, fontFamily: 'Audiowide'
}).setOrigin(0.5).setDepth(35);
this.tweens.add({
targets: gainText,
alpha: 0, y: targetObj.y - 55,
duration: 800, ease: 'Power2',
onComplete: () => { if (gainText.scene) gainText.destroy(); }
});
}
// ── Heal preBattle animations ──────────────────────────────────────────────
_processHealFires(fires, onComplete) {
const next = (idx) => {
if (idx >= fires.length) { onComplete(); return; }
const fire = fires[idx];
if (fire.isAll) {
this._animateHealFireAll(fire, () => next(idx + 1));
} else {
this._animateHealFire(fire, () => next(idx + 1));
}
};
next(0);
}
_animateHealFire(fire, onComplete) {
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
const sourceObj = _lookup(fire.source.instanceId);
const targetObj = _lookup(fire.target.instanceId);
if (!sourceObj?.scene || !targetObj?.scene || fire.healAmount <= 0) { onComplete(); return; }
this.sound.play('sfx_heal', { volume: 0.8 });
this.statusText.setText(`${fire.source.name} heals ${fire.target.name} for ${fire.healAmount}!`);
if (targetObj.hpText?.scene) targetObj.hpText.setText(`${fire.hpBefore}`);
this._doHealSpriteAndHP(sourceObj, targetObj, fire.healAmount, fire.target.currentHP, onComplete);
}
_animateHealFireAll(fire, onComplete) {
if (!fire.targets?.length) { onComplete(); return; }
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
const sourceObj = _lookup(fire.source.instanceId);
if (!sourceObj?.scene) { onComplete(); return; }
this.sound.play('sfx_heal', { volume: 0.8 });
this.statusText.setText(`${fire.source.name} heals all allies!`);
// Animate sprite sequentially to each target
const next = (idx) => {
if (idx >= fire.targets.length) { onComplete(); return; }
const t = fire.targets[idx];
const targetObj = _lookup(t.target.instanceId);
if (!targetObj?.scene || t.healAmount <= 0) { next(idx + 1); return; }
if (targetObj.hpText?.scene) targetObj.hpText.setText(`${t.hpBefore}`);
this._doHealSpriteAndHP(sourceObj, targetObj, t.healAmount, t.target.currentHP, () => next(idx + 1));
};
next(0);
}
// Spawns heal sprite at sourceObj, flies it to targetObj, pulses it while animating HP gain.
_doHealSpriteAndHP(sourceObj, targetObj, healAmount, hpAfter, onComplete) {
const BASE_SCALE = 100 / 460;
const healSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 34)
.setScale(BASE_SCALE)
.setDepth(30)
.setAlpha(0.9);
this.tweens.add({
targets: healSprite,
x: targetObj.x,
y: targetObj.y,
duration: 400,
ease: 'Cubic.easeIn',
onComplete: () => {
if (!targetObj.scene) {
if (healSprite.scene) healSprite.destroy();
onComplete();
return;
}
// Pulse the sprite over the target
this.tweens.add({
targets: healSprite,
scaleX: { from: BASE_SCALE * 0.8, to: BASE_SCALE * 1.5 },
scaleY: { from: BASE_SCALE * 0.8, to: BASE_SCALE * 1.5 },
alpha: { from: 0.9, to: 0.3 },
duration: 300,
yoyo: true,
repeat: 1,
ease: 'Sine.easeInOut',
onComplete: () => { if (healSprite.scene) healSprite.destroy(); }
});
// Animate HP gain text
if (healAmount > 0 && targetObj.hpText?.scene) {
this.tweens.add({
targets: targetObj.hpText,
scaleX: 2, scaleY: 2,
duration: 200,
ease: 'Back.Out',
onComplete: () => {
if (!targetObj.scene) return;
const gainText = this.add.text(targetObj.x, targetObj.y - 20, `+${healAmount}`, {
fontSize: '14px', color: '#00ff88', fontStyle: 'bold',
stroke: '#000000', strokeThickness: 3, fontFamily: 'Audiowide'
}).setOrigin(0.5).setDepth(35);
this.tweens.add({
targets: gainText,
alpha: 0, y: targetObj.y - 50,
duration: 700, ease: 'Power2',
onComplete: () => { if (gainText.scene) gainText.destroy(); }
});
this.time.delayedCall(200, () => {
if (!targetObj.scene) return;
targetObj.hpText.setText(`${hpAfter}`);
this.tweens.add({ targets: targetObj.hpText, scaleX: 1, scaleY: 1, duration: 200, ease: 'Power2' });
});
}
});
}
// Complete after pulse + HP animation settle
this.time.delayedCall(800, onComplete);
}
});
}
_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);
}
}
// ── Xeno skill animations ──────────────────────────────────────────────────
_processPhaseEvents(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 === 'spawn') this._animateSpawn(event, cb);
else cb();
};
next(0);
}
_animateSpawn(event, onComplete) {
// Re-render state so the new drone card object exists
this._renderState();
const cardObj = this.cardObjects.get(event.card.instanceId);
if (!cardObj?.scene) { onComplete(); return; }
this.statusText.setText(`${event.side === 'player' ? 'Your' : 'Enemy'} commander spawns a drone!`);
cardObj.setAlpha(0).setScale(0.5);
this.tweens.add({
targets: cardObj,
alpha: 1, scaleX: 1, scaleY: 1,
duration: 400,
ease: 'Back.Out',
onComplete: () => { this.time.delayedCall(200, onComplete); }
});
}
_animateMolt(event, onComplete) {
const obj = this.cardObjects.get(event.card.instanceId);
if (!obj?.scene) { onComplete(); return; }
this.statusText.setText(`${event.card.name} molts: sheds ${event.armorLost} armor for +${event.heal} HP!`);
this.sound.play('sfx_molt_stage1', { volume: 0.8 });
// Sprite 30: slowly rotating molt icon over the card
const moltSprite = this.add.sprite(obj.x, obj.y, 'attacks', 30)
.setDisplaySize(160, 160)
.setDepth(30)
.setAlpha(0.85);
this.tweens.add({
targets: moltSprite,
angle: 360,
duration: 1200,
ease: 'Linear',
onComplete: () => { if (moltSprite.scene) moltSprite.destroy(); }
});
// Animate HP gain: show pre-molt HP, then float +heal text and update to post-molt HP
if (event.heal > 0 && obj.hpText) {
const hpBefore = event.card.currentHP - event.heal;
obj.hpText.setText(`${hpBefore}`);
this.tweens.add({
targets: obj.hpText,
scaleX: 2, scaleY: 2,
duration: 200,
ease: 'Back.Out',
onComplete: () => {
if (!obj.scene) return;
const gainText = this.add.text(obj.x, obj.y - 20, `+${event.heal}`, {
fontSize: '14px', color: '#00ff88', fontStyle: 'bold',
stroke: '#000000', strokeThickness: 3, fontFamily: 'Audiowide'
}).setOrigin(0.5).setDepth(35);
this.tweens.add({
targets: gainText,
alpha: 0, y: obj.y - 50,
duration: 700, ease: 'Power2',
onComplete: () => { if (gainText.scene) gainText.destroy(); }
});
this.time.delayedCall(200, () => {
if (!obj.scene) return;
obj.hpText.setText(`${event.card.currentHP}`);
this.tweens.add({ targets: obj.hpText, scaleX: 1, scaleY: 1, duration: 200, ease: 'Power2' });
});
}
});
}
// Animate armor loss after a short delay (armor → 0)
this.time.delayedCall(500, () => {
if (!obj?.scene) { onComplete(); return; }
obj.animateArmorLoss(event.armorLost, () => {
this.time.delayedCall(200, onComplete);
}, event.armorLost, 0);
});
}
_animateMoltRestore(fire, onComplete) {
const obj = this.cardObjects.get(fire.card.instanceId);
if (!obj?.scene) { onComplete(); return; }
this.statusText.setText(`${fire.card.name} regrows shell: +${fire.armorGain} ARM!`);
this.sound.play('sfx_molt_stage2', { volume: 0.8 });
// Sprite 31: grow/shrink over the card
const restoreSprite = this.add.sprite(obj.x, obj.y, 'attacks', 31)
.setDisplaySize(100, 100)
.setDepth(30)
.setAlpha(0.9);
this.tweens.add({
targets: restoreSprite,
scaleX: { from: 0.7, to: 1.6 },
scaleY: { from: 0.7, to: 1.6 },
alpha: { from: 0.9, to: 0.2 },
duration: 250,
yoyo: true,
repeat: 1,
onComplete: () => { if (restoreSprite.scene) restoreSprite.destroy(); }
});
// Armor text starts at 0 then animates to restored value
if (obj.armText) obj.armText.setText('0');
obj.animateArmorGain(fire.armorGain, onComplete);
}
// Swarm surges when the card attacks: sprite 33 spins, ATK animates up, sound plays.
_animateSwarmPreAttack(attacker, fire, onComplete) {
const obj = this.cardObjects.get(attacker.instanceId) ?? this.commanderObjects?.get(attacker.instanceId);
if (!obj?.scene) { onComplete(); return; }
this.statusText.setText(`${attacker.name} swarm surges: +${fire.gain} ATK!`);
this.sound.play('sfx_swarm', { volume: 0.85 });
// Sprite 33 spins continuously over the card (1 rotation/second) while ATK gain plays
const swarmSprite = this.add.sprite(obj.x, obj.y, 'attacks', 33)
.setDisplaySize(180, 180)
.setDepth(30)
.setAlpha(1);
this.tweens.add({
targets: swarmSprite,
angle: 360,
duration: 1000,
repeat: -1,
ease: 'Linear'
});
// currentAttack is already reverted synchronously by processNextAttack, so it equals
// the pre-buff value (X). Start the animation from X and animate to X+gain.
if (obj.atkText) obj.atkText.setText(`${attacker.currentAttack}`);
obj.animateBerserkGain(fire.gain, () => {
if (swarmSprite.scene) swarmSprite.destroy();
onComplete();
}, attacker.currentAttack + fire.gain);
}
// Swarm fades after the card attacks: floating -N text communicates the ATK loss.
_animateSwarmRevert(revert, onComplete) {
const obj = this.cardObjects.get(revert.card.instanceId) ?? this.commanderObjects?.get(revert.card.instanceId);
if (!obj?.scene) { onComplete(); return; }
this.statusText.setText(`${revert.card.name} swarm fades: -${revert.amount} ATK`);
// Ensure ATK text shows the pre-revert (buffed) value so the drop is visible
if (obj.atkText) obj.atkText.setText(`${revert.card.currentAttack + revert.amount}`);
// Brief hold so the buffed value is visible, then snap to un-buffed with a floating -N
this.time.delayedCall(150, () => {
if (!obj.scene) { onComplete(); return; }
if (obj.atkText) obj.atkText.setText(`${revert.card.currentAttack}`);
const lossText = this.add.text(obj.x, obj.y - 50, `-${revert.amount}`,
{ fontSize: '34px', color: '#aaaaff', stroke: '#000000', strokeThickness: 4, fontFamily: 'RaiderCrusader' }
).setOrigin(0.5).setDepth(31);
this.tweens.add({
targets: lossText,
y: obj.y - 120,
alpha: 0,
duration: 600,
ease: 'Power2',
onComplete: () => {
if (lossText.scene) lossText.destroy();
onComplete();
}
});
});
}
_animateVenomTick(event, onComplete) {
const obj = this.cardObjects.get(event.card.instanceId) || this.commanderObjects?.get(event.card.instanceId);
if (!obj?.scene) { onComplete(); return; }
this.statusText.setText(`${event.card.name} suffers ${event.damage} venom damage!`);
this.sound.play('sfx_venom_effect', { volume: 0.8 });
// Sprite 28: venom effect pulses larger/smaller twice
const effectSprite = this.add.sprite(obj.x, obj.y, 'attacks', 28)
.setDisplaySize(140, 140)
.setDepth(30)
.setAlpha(0.9);
this.tweens.add({
targets: effectSprite,
scaleX: { from: 0.8, to: 1.5 },
scaleY: { from: 0.8, to: 1.5 },
alpha: { from: 0.9, to: 0.2 },
duration: 250,
yoyo: true,
repeat: 1,
onComplete: () => { if (effectSprite.scene) effectSprite.destroy(); }
});
// Animate the HP loss on the card while the sprite pulses.
// If venom killed the card, destroy it after the HP animation.
if (event.damage > 0 && obj.animateHPLoss) {
obj.animateHPLoss(event.damage, () => {
if (event.killed) {
const hlEvent = this._preBattleHiveLinks?.[event.card.instanceId];
if (hlEvent) delete this._preBattleHiveLinks[event.card.instanceId];
this._playCardDeath(obj, event.card,
hlEvent ? () => this._animateHiveLink(hlEvent, onComplete) : onComplete
);
} else {
if (obj.scene) obj.refresh();
onComplete();
}
});
} else {
this.time.delayedCall(550, () => {
if (obj.scene) obj.refresh();
onComplete();
});
}
}
_animateCarapace(event, onComplete) {
const obj = this.cardObjects.get(event.card.instanceId);
if (!obj?.scene) { onComplete(); return; }
this.statusText.setText(`${event.card.name} hardens: +${event.gain} ARM!`);
this._animateCarapaceEffect(obj, event.gain, onComplete);
}
// Shared carapace visual: sprite 29 pulses over the card while armor gain animates.
_animateCarapaceEffect(obj, gain, onComplete) {
if (!obj?.scene) { if (onComplete) onComplete(); return; }
this.sound.play('sfx_carapace', { volume: 0.8 });
const effectSprite = this.add.sprite(obj.x, obj.y, 'attacks', 29)
.setDisplaySize(160, 160)
.setDepth(30)
.setAlpha(0.9);
this.tweens.add({
targets: effectSprite,
scaleX: { from: 0.7, to: 1.6 },
scaleY: { from: 0.7, to: 1.6 },
alpha: { from: 0.9, to: 0.2 },
duration: 280,
yoyo: true,
repeat: 1,
onComplete: () => { if (effectSprite.scene) effectSprite.destroy(); }
});
obj.animateArmorGain(gain, onComplete);
}
_animateHiveLink(event, onComplete) {
if (!event.targets?.length) { onComplete(); return; }
this.statusText.setText(`${event.source.name} — hive link pulses through the swarm! +${event.gain} ATK!`);
this.sound.play('sfx_hive_link', { volume: 0.85 });
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; }
// Sprite 32: hive-link pulse — grows and shrinks over the target card
const linkSprite = this.add.sprite(obj.x, obj.y, 'attacks', 32)
.setDisplaySize(130, 130)
.setDepth(30)
.setAlpha(0.9);
this.tweens.add({
targets: linkSprite,
scaleX: { from: 0.6, to: 1.7 },
scaleY: { from: 0.6, to: 1.7 },
alpha: { from: 0.9, to: 0.1 },
duration: 300,
yoyo: true,
repeat: 1,
onComplete: () => { if (linkSprite.scene) linkSprite.destroy(); }
});
// Animate ATK value going up on each buffed card
if (obj.atkText) obj.atkText.setText(`${t.target.currentAttack - t.gain}`);
obj.animateBerserkGain(t.gain, done);
}
}
_animateVenomApply(event, onComplete) {
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
const sourceObj = _lookup(event.attacker.instanceId);
const targetObj = _lookup(event.target.instanceId);
if (!targetObj?.scene) { onComplete(); return; }
this.statusText.setText(`${event.attacker.name} injects venom into ${event.target.name}! [${event.stacks} stacks]`);
this.sound.play('sfx_venom_apply', { volume: 0.8 });
const doApply = () => {
if (!targetObj.scene) { onComplete(); return; }
// Sprite 28: venom effect — pulse larger/smaller twice over target
const effectSprite = this.add.sprite(targetObj.x, targetObj.y, 'attacks', 28)
.setDisplaySize(140, 140)
.setDepth(30)
.setAlpha(0.9);
this.tweens.add({
targets: effectSprite,
scaleX: { from: 0.8, to: 1.4 },
scaleY: { from: 0.8, to: 1.4 },
alpha: { from: 0.9, to: 0.3 },
duration: 200,
yoyo: true,
repeat: 1,
onComplete: () => {
if (effectSprite.scene) effectSprite.destroy();
}
});
if (targetObj.scene) targetObj.refresh();
this.time.delayedCall(500, onComplete);
};
if (!sourceObj?.scene) {
doApply();
return;
}
// Sprite 27: venom projectile flying from attacker to target
const venomSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 27)
.setDisplaySize(80, 80)
.setDepth(30);
this.tweens.add({
targets: venomSprite,
x: targetObj.x,
y: targetObj.y,
duration: 350,
ease: 'Quad.In',
onComplete: () => {
if (venomSprite.scene) venomSprite.destroy();
doApply();
}
});
}
_animateVenomApplyAll(event, onComplete) {
if (!event.targets?.length) { onComplete(); return; }
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
const sourceObj = _lookup(event.attacker.instanceId);
this.statusText.setText(`${event.attacker.name} venoms all enemies!`);
this.sound.play('sfx_venom_apply', { volume: 0.8 });
let remaining = event.targets.length;
const done = () => { if (--remaining <= 0) onComplete(); };
for (const t of event.targets) {
const targetObj = _lookup(t.target.instanceId);
if (!targetObj?.scene) { done(); continue; }
const animateEffect = (obj) => {
if (!obj?.scene) { done(); return; }
const effectSprite = this.add.sprite(obj.x, obj.y, 'attacks', 28)
.setDisplaySize(140, 140)
.setDepth(30)
.setAlpha(0.9);
this.tweens.add({
targets: effectSprite,
scaleX: { from: 0.8, to: 1.4 },
scaleY: { from: 0.8, to: 1.4 },
alpha: { from: 0.9, to: 0.3 },
duration: 200,
yoyo: true,
repeat: 1,
onComplete: () => { if (effectSprite.scene) effectSprite.destroy(); }
});
if (obj.scene) obj.refresh();
this.time.delayedCall(500, done);
};
if (!sourceObj?.scene) {
animateEffect(targetObj);
continue;
}
// Staggered projectile per target
const venomSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 27)
.setDisplaySize(80, 80)
.setDepth(30);
this.tweens.add({
targets: venomSprite,
x: targetObj.x,
y: targetObj.y,
duration: 350,
ease: 'Quad.In',
onComplete: () => {
if (venomSprite.scene) venomSprite.destroy();
animateEffect(targetObj);
}
});
}
}
_animateSwarmBuff(buff, onComplete) {
// Reuse the legion buff animation pattern
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
const targetObj = _lookup(buff.target.instanceId);
if (!targetObj?.scene) { onComplete(); return; }
this.statusText.setText(`${buff.target.name} swarm: +${buff.amount} ATK`);
targetObj.animateBerserkGain(buff.amount, onComplete);
}
// 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) {
const doSwarmRevert = (cb) => {
if (event?.swarmRevert) {
this._animateSwarmRevert(event.swarmRevert, cb);
} else {
cb();
}
};
doSwarmRevert(() => 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 if (group.skill === 'moltRestore') this._animateMoltRestore(group, cb);
else if (group.skill === 'swarm') this._animateSwarmPreAttack(event.attacker, group, cb);
else if (group.skill === 'hack') this._animateHackFire(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) {
const hlEvent = this._pendingHiveLinks?.[mortarFire.target.instanceId];
if (hlEvent) delete this._pendingHiveLinks[mortarFire.target.instanceId];
this._playCardDeath(targetObj, mortarFire.target,
hlEvent ? () => this._animateHiveLink(hlEvent, onComplete) : onComplete
);
} else if (mortarFire.carapaceGain > 0 && targetObj.scene) {
this._animateCarapaceEffect(targetObj, mortarFire.carapaceGain, 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) {
const hlEvent = this._pendingHiveLinks?.[strikeFire.target.instanceId];
if (hlEvent) delete this._pendingHiveLinks[strikeFire.target.instanceId];
this._playCardDeath(targetObj, strikeFire.target,
hlEvent ? () => this._animateHiveLink(hlEvent, onComplete) : onComplete
);
} else if (strikeFire.carapaceGain > 0 && targetObj.scene) {
this._animateCarapaceEffect(targetObj, strikeFire.carapaceGain, 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) {
const hlEvent = this._pendingHiveLinks?.[mortarFire.target.instanceId];
if (hlEvent) delete this._pendingHiveLinks[mortarFire.target.instanceId];
this._playCardDeath(targetObj, mortarFire.target,
hlEvent ? () => this._animateHiveLink(hlEvent, onComplete) : onComplete
);
} else if (mortarFire.carapaceGain > 0 && targetObj.scene) {
this._animateCarapaceEffect(targetObj, mortarFire.carapaceGain, 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) {
const hlEvent = this._pendingHiveLinks?.[strikeFire.target.instanceId];
if (hlEvent) delete this._pendingHiveLinks[strikeFire.target.instanceId];
this._playCardDeath(targetObj, strikeFire.target,
hlEvent ? () => this._animateHiveLink(hlEvent, onComplete) : onComplete
);
} else if (strikeFire.carapaceGain > 0 && targetObj.scene) {
this._animateCarapaceEffect(targetObj, strikeFire.carapaceGain, 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) {
const hlEvent = this._pendingHiveLinks?.[fire.target.instanceId];
if (hlEvent) delete this._pendingHiveLinks[fire.target.instanceId];
this._playCardDeath(targetObj, fire.target,
hlEvent ? () => this._animateHiveLink(hlEvent, () => animateNext(idx + 1, targetObj.x, targetObj.y))
: () => animateNext(idx + 1, targetObj.x, targetObj.y)
);
} else if (fire.carapaceGain > 0 && targetObj.scene) {
this._animateCarapaceEffect(targetObj, fire.carapaceGain, () => {
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 preBattle sequence (including venom ticks) before attacks begin.
_processPreBattle(events, onComplete) {
const processStep = (idx) => {
if (idx >= events.length) { onComplete(); return; }
if (events[idx].type === 'ruptureTick') {
this._animateRuptureTick(events[idx], () => processStep(idx + 1));
return;
}
if (events[idx].type === 'venomTick') {
this._animateVenomTick(events[idx], () => processStep(idx + 1));
return;
}
if (events[idx].type === 'smiteTick') {
this._animateSmiteTick(events[idx], () => processStep(idx + 1));
return;
}
if (events[idx].type === 'carapace') {
this._animateCarapace(events[idx], () => processStep(idx + 1));
return;
}
if (events[idx].type === 'molt') {
this._animateMolt(events[idx], () => processStep(idx + 1));
return;
}
// hive_link events are animated inline by _animateVenomTick/SmiteTick (for kills) — skip here
if (events[idx].type === 'hive_link') {
processStep(idx + 1);
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 hasWeaken = event.weakenFires?.length > 0;
const hasDrain = event.drainFires?.length > 0;
const hasHeal = event.healFires?.length > 0;
const hasSanctify = event.sanctifyFires?.length > 0;
const hasOvercharge = event.overchargeFires?.length > 0;
const hasFortify = event.fortifyFires?.length > 0;
if (hasBuffs || hasSiege || hasProtect || hasEnfeeble || hasJam || hasWeaken || hasDrain || hasHeal || hasSanctify || hasOvercharge || hasFortify) {
this._processSanctifyFires(event.sanctifyFires || [], () => {
this._processDrainFires(event.drainFires || [], () => {
this._processOverchargeFires(event.overchargeFires || [], () => {
this._processBuffAnimations(event.buffs || [], () => {
this._processFortifyFires(event.fortifyFires || [], () => {
this._processSiegeFires(event.siegeFires || [], () => {
this._processProtectFires(event.protectFires || [], () => {
this._processEnfeebeFires(event.enfeebeFires || [], () => {
this._processJamFires(event.jamFires || [], () => {
this._processWeakenFires(event.weakenFires || [], () => {
this._processHealFires(event.healFires || [], 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);
}
_processWeakenFires(fires, onComplete) {
const next = (idx) => {
if (idx >= fires.length) { onComplete(); return; }
this._animateWeakenFire(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_bloodpact', { 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, () => {
const hlEvent = this._preBattleHiveLinks?.[ac.data.instanceId];
if (hlEvent) delete this._preBattleHiveLinks[ac.data.instanceId];
const afterAll = () => { if (--pending === 0) onComplete(); };
hlEvent ? this._animateHiveLink(hlEvent, afterAll) : afterAll();
});
}
}
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);
if (!sourceObj?.scene) { onComplete(); return; }
if (siphonFire.heal <= 0) { 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 PULSE_SCALE = BASE_SCALE * 1.55;
const PULSE_DUR = 220;
const sprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 21)
.setScale(BASE_SCALE)
.setDepth(35);
// Blood-red glow
const glow = sprite.postFX.addGlow(0xcc0000, 6, 0);
this.tweens.add({
targets: glow,
outerStrength: 18,
duration: 300,
yoyo: true,
repeat: -1,
ease: 'Sine.InOut'
});
// Pulse up and back 3 times, then heal and fade
this.tweens.chain({
targets: sprite,
tweens: [
{ scaleX: PULSE_SCALE, scaleY: PULSE_SCALE, duration: PULSE_DUR, ease: 'Quad.Out' },
{ scaleX: BASE_SCALE, scaleY: BASE_SCALE, duration: PULSE_DUR, ease: 'Quad.In' },
{ scaleX: PULSE_SCALE, scaleY: PULSE_SCALE, duration: PULSE_DUR, ease: 'Quad.Out' },
{ scaleX: BASE_SCALE, scaleY: BASE_SCALE, duration: PULSE_DUR, ease: 'Quad.In' },
{ scaleX: PULSE_SCALE, scaleY: PULSE_SCALE, duration: PULSE_DUR, ease: 'Quad.Out' },
{ scaleX: BASE_SCALE, scaleY: BASE_SCALE, duration: PULSE_DUR, ease: 'Quad.In' },
],
onComplete: () => {
// Green flash + HP refresh on the attacker
if (sourceObj.scene) {
sourceObj.flash(0x00ff00);
this.time.delayedCall(150, () => {
if (sourceObj.scene) sourceObj.refresh();
});
}
// Fade sprite out
this.tweens.add({
targets: sprite,
alpha: 0,
duration: 250,
onComplete: () => {
if (sprite.scene) sprite.destroy();
onComplete();
}
});
}
});
}
// ── Bloodrage — frame 23 sprite pulses over card 3×, red glow, ATK gain text ──
_animateBloodrageBuff(buff, onComplete) {
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
const cardObj = _lookup(buff.target.instanceId);
if (!cardObj?.scene) { onComplete(); return; }
this.statusText.setText(`${buff.source.name} enters a blood rage! +${buff.amount} ATK`);
this.sound.play('sfx_bloodrage', { volume: 0.8 });
const BASE_SCALE = 160 / 460;
const PULSE_SCALE = BASE_SCALE * 1.6;
const PULSE_DUR = 200;
const sprite = this.add.sprite(cardObj.x, cardObj.y, 'attacks', 23)
.setScale(BASE_SCALE)
.setDepth(35)
.setAlpha(0);
// Red pulsing glow
const glow = sprite.postFX.addGlow(0xff2200, 8, 0);
this.tweens.add({
targets: glow,
outerStrength: 22,
duration: 250,
yoyo: true,
repeat: -1,
ease: 'Sine.InOut'
});
// Fade in, then pulse 3×, then show ATK gain and fade out
this.tweens.add({
targets: sprite,
alpha: 1,
duration: 120,
onComplete: () => {
// Show ATK floating text; refresh so ATK text shows post-buff value after animation
cardObj.animateBerserkGain(buff.amount, () => { if (cardObj.scene) cardObj.refresh(); });
this.tweens.chain({
targets: sprite,
tweens: [
{ scaleX: PULSE_SCALE, scaleY: PULSE_SCALE, duration: PULSE_DUR, ease: 'Quad.Out' },
{ scaleX: BASE_SCALE, scaleY: BASE_SCALE, duration: PULSE_DUR, ease: 'Quad.In' },
{ scaleX: PULSE_SCALE, scaleY: PULSE_SCALE, duration: PULSE_DUR, ease: 'Quad.Out' },
{ scaleX: BASE_SCALE, scaleY: BASE_SCALE, duration: PULSE_DUR, ease: 'Quad.In' },
{ scaleX: PULSE_SCALE, scaleY: PULSE_SCALE, duration: PULSE_DUR, ease: 'Quad.Out' },
{ scaleX: BASE_SCALE, scaleY: BASE_SCALE, duration: PULSE_DUR, ease: 'Quad.In' },
],
onComplete: () => {
if (cardObj.scene) cardObj.flash(0xff2200);
this.tweens.add({
targets: sprite,
alpha: 0,
scaleX: BASE_SCALE * 1.4,
scaleY: BASE_SCALE * 1.4,
duration: 300,
ease: 'Power2',
onComplete: () => {
if (sprite.scene) sprite.destroy();
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 if (group.skill === 'legion') {
this._animateLegionBuff(group, () => next(idx + 1));
} else if (group.skill === 'swarm') {
this._animateSwarmBuff(group, () => next(idx + 1));
} else if (group.skill === 'bloodrage') {
this._animateBloodrageBuff(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();
}
});
});
}
});
}
// ── Legion — flexing arm sprite cycles 3× over card, blue glow, ATK gain text ──
_animateLegionBuff(buff, onComplete) {
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
const cardObj = _lookup(buff.target.instanceId);
if (!cardObj?.scene) { onComplete(); return; }
this.statusText.setText(`${buff.source.name} legion: +${buff.amount} ATK from allies!`);
this.sound.play('sfx_legion', { volume: 0.8 });
// Show ATK gain floating text immediately; refresh so ATK text shows post-buff value
cardObj.animateBerserkGain(buff.amount, () => { if (cardObj.scene) cardObj.refresh(); });
// Sprite animation: frames 25 (arm down) and 26 (arm up) cycling 3× over ~3s
// 3 cycles × 2 frames = 6 swaps; each frame shows for ~500ms → total ~3s
const FRAME_DUR = 480;
const BASE_SCALE = 160 / 460;
const sprite = this.add.sprite(cardObj.x, cardObj.y, 'attacks', 25)
.setScale(BASE_SCALE * 1.1)
.setDepth(35)
.setAlpha(0);
// Blue glow
const glow = sprite.postFX.addGlow(0x4488ff, 8, 0);
this.tweens.add({
targets: glow,
outerStrength: 20,
duration: 400,
yoyo: true,
repeat: -1,
ease: 'Sine.InOut'
});
// Fade in, then cycle frames, then fade out
this.tweens.add({
targets: sprite,
alpha: 1,
duration: 150,
onComplete: () => {
let frame = 25;
let cycles = 0;
const totalFlips = 6; // 3 full cycles × 2 frames each
const flip = () => {
if (!sprite.scene) { onComplete(); return; }
frame = frame === 25 ? 26 : 25;
sprite.setFrame(frame);
cycles++;
if (cycles < totalFlips) {
this.time.delayedCall(FRAME_DUR, flip);
} else {
// All cycles done — fade out sprite then call complete
this.tweens.add({
targets: sprite,
alpha: 0,
duration: 250,
delay: FRAME_DUR,
onComplete: () => {
if (sprite.scene) sprite.destroy();
onComplete();
}
});
}
};
this.time.delayedCall(FRAME_DUR, flip);
}
});
}
// ── "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;
}
}
}
}
}
_animateWeakenFire(weakenFire, onComplete) {
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
const sourceObj = _lookup(weakenFire.source.instanceId);
const targetObj = _lookup(weakenFire.target.instanceId);
if (!sourceObj?.scene || !targetObj?.scene) { onComplete(); return; }
const armorAmt = weakenFire.armorAmount;
const attackAmt = weakenFire.attackAmount;
if (armorAmt <= 0 && attackAmt <= 0) { onComplete(); return; }
this.statusText.setText(`${weakenFire.source.name} weakens ${weakenFire.target.name}!`);
this.sound.play('sfx_weaken', { volume: 0.8 });
const BASE_SCALE = 160 / 460;
const weakenSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 43)
.setScale(BASE_SCALE)
.setDepth(30);
this.tweens.add({
targets: weakenSprite,
x: targetObj.x,
y: targetObj.y,
duration: 1350,
ease: 'Cubic.easeIn',
onComplete: () => {
if (!targetObj.scene) { onComplete(); return; }
this.tweens.add({
targets: weakenSprite,
alpha: 0,
scaleX: BASE_SCALE * 1.4,
scaleY: BASE_SCALE * 1.4,
duration: 300,
ease: 'Power2',
onComplete: () => { if (weakenSprite.scene) weakenSprite.destroy(); }
});
// Animate both stat reductions in parallel; wait for both before calling onComplete
let pending = (armorAmt > 0 ? 1 : 0) + (attackAmt > 0 ? 1 : 0);
if (pending === 0) { onComplete(); return; }
const done = () => { if (--pending === 0) onComplete(); };
if (armorAmt > 0) {
const fromARM = weakenFire.target.currentArmor + armorAmt;
const toARM = weakenFire.target.currentArmor;
targetObj.animateArmorLoss(armorAmt, done, fromARM, toARM);
}
if (attackAmt > 0) {
const fromATK = weakenFire.target.currentAttack + attackAmt;
const toATK = weakenFire.target.currentAttack;
this._animateAttackLoss(targetObj, attackAmt, fromATK, toATK, done);
}
}
});
}
// 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);
}
}
}
// Temporarily undo ATK/ARM buffs (rally, protect, legion, bloodrage, hive_link) so
// _renderState() shows pre-buff values. The preBattle animations show the gains visually.
_restoreBuffsForDisplay(preBattleEvents) {
for (const event of preBattleEvents) {
for (const buff of event.buffs || []) {
if (buff.skill === 'rally' || buff.skill === 'legion' || buff.skill === 'bloodrage') {
buff.target.currentAttack -= buff.amount;
} else if (buff.skill === 'protect') {
buff.target.currentArmor -= buff.amount;
}
}
// Undo hive_link ATK gains so buffed cards display pre-buff ATK until the animation fires.
if (event.type === 'hive_link') {
for (const t of event.targets || []) {
t.target.currentAttack -= t.gain;
}
}
}
}
// Re-apply ATK/ARM buffs after _renderState() so combat math stays correct.
_reapplyBuffs(preBattleEvents) {
for (const event of preBattleEvents) {
for (const buff of event.buffs || []) {
if (buff.skill === 'rally' || buff.skill === 'legion' || buff.skill === 'bloodrage') {
buff.target.currentAttack += buff.amount;
} else if (buff.skill === 'protect') {
buff.target.currentArmor += buff.amount;
}
}
// Re-apply hive_link ATK gains after _renderState() so the data model is correct
// for the animation (animateBerserkGain reads cardData.currentAttack for the final value).
if (event.type === 'hive_link') {
for (const t of event.targets || []) {
t.target.currentAttack += t.gain;
}
}
}
}
// Temporarily restore drain victims' HP and undo source heal so _renderState()
// shows pre-drain values. The drain animation will handle the visual HP changes.
_restoreDrainForDisplay(preBattleEvents) {
for (const event of preBattleEvents) {
for (const fire of event.drainFires || []) {
fire.target.currentHP += fire.damage;
fire.source.currentHP -= fire.heal;
for (const sec of fire.secondaries || []) {
sec.target.currentHP += sec.damage;
}
}
}
}
// Re-apply drain damage and source heal after _renderState() so combat math stays correct.
_reapplyDrainDamage(preBattleEvents) {
for (const event of preBattleEvents) {
for (const fire of event.drainFires || []) {
fire.target.currentHP -= fire.damage;
fire.source.currentHP += fire.heal;
for (const sec of fire.secondaries || []) {
sec.target.currentHP -= sec.damage;
}
}
}
}
// Temporarily undo molt HP/armor changes so _renderState() shows pre-molt values.
_restoreHealForDisplay(preBattleEvents) {
for (const event of preBattleEvents) {
for (const fire of event.healFires || []) {
if (fire.isAll) {
for (const t of fire.targets || []) { t.target.currentHP -= t.healAmount; }
} else if (fire.healAmount > 0) {
fire.target.currentHP -= fire.healAmount;
}
}
}
}
_reapplyHeal(preBattleEvents) {
for (const event of preBattleEvents) {
for (const fire of event.healFires || []) {
if (fire.isAll) {
for (const t of fire.targets || []) { t.target.currentHP += t.healAmount; }
} else if (fire.healAmount > 0) {
fire.target.currentHP += fire.healAmount;
}
}
}
}
_restoreMoltForDisplay(preBattleEvents) {
for (const event of preBattleEvents) {
if (event.type !== 'molt' || event.heal <= 0) continue;
event.card.currentHP -= event.heal;
event.card.currentArmor = event.armorLost;
}
}
// Re-apply molt changes after _renderState() so combat math stays correct.
_reapplyMolt(preBattleEvents) {
for (const event of preBattleEvents) {
if (event.type !== 'molt' || event.heal <= 0) continue;
event.card.currentHP += event.heal;
event.card.currentArmor = 0;
}
}
// 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 postBattle sequence after all attacks resolve.
_processPostBattle(events, onComplete) {
const processStep = (idx) => {
if (idx >= events.length) { onComplete(); return; }
if (events[idx].type === 'burrowTick') {
this._animateBurrowTick(events[idx], () => processStep(idx + 1));
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();
}
_animateBurrowTick(event, onComplete) {
const obj = this.cardObjects.get(event.card.instanceId);
if (!obj?.scene) { onComplete(); return; }
this.sound.play('sfx_burrow', { volume: 0.8 });
const label = event.remaining > 0
? `${event.card.name} surfaces next turn [${event.remaining} remaining]`
: `${event.card.name} emerges from the ground!`;
this.statusText.setText(label);
if (obj.scene) obj.refresh();
this.time.delayedCall(400, 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 the defender had hive_link, fire that animation immediately after the death explosion.
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) {
const hlEvent = this._pendingHiveLinks?.[event.defender.instanceId];
if (hlEvent) delete this._pendingHiveLinks[event.defender.instanceId];
this._playCardDeath(defenderObj, event.defender,
hlEvent ? () => this._animateHiveLink(hlEvent, () => { }) : null
);
}
});
}
// 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._resultShown) return;
this._resultShown = true;
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;
const firstClear = !save.campaignProgress.completedMissions.includes(this.missionData.id);
SaveManager.addGold(save, rewards.gold);
SaveManager.completeMission(save, this.missionData.id);
// Cards only awarded on first completion
if (firstClear && 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 (firstClear && 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.missionData) {
const consolationGold = Math.ceil(this.missionData.rewards.gold / 2);
SaveManager.addGold(save, consolationGold);
this.registry.set('save', save);
this.add.text(width / 2, height / 2 + 20, 'Defeat...', {
fontSize: '28px', color: '#aaaaaa', fontFamily: 'Audiowide'
}).setOrigin(0.5);
this.add.text(width / 2, height / 2 + 60, `+${consolationGold} Gold (consolation)`, {
fontSize: '22px', color: '#aa8800', 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');
});
}
// ── Smite animations ──────────────────────────────────────────────────────
// Shared helper: fly sprite 38 from source to target, burst on arrival, call cb
_doSmiteCastTo(sourceObj, targetObj, onArrival) {
const projectile = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 38)
.setDisplaySize(180, 180)
.setDepth(30);
this.tweens.add({
targets: projectile,
x: targetObj.x, y: targetObj.y,
duration: 2000,
ease: 'Quad.In',
onComplete: () => {
if (projectile.scene) projectile.destroy();
onArrival();
}
});
}
_animateSmiteApply(event, onComplete) {
const sourceObj = this.cardObjects.get(event.attacker.instanceId) || this.commanderObjects?.get(event.attacker.instanceId);
const targetObj = this.cardObjects.get(event.target.instanceId) || this.commanderObjects?.get(event.target.instanceId);
if (!sourceObj?.scene || !targetObj?.scene) { onComplete(); return; }
this.statusText.setText(`${event.attacker.name} smites ${event.target.name}! [${event.stacks} stacks]`);
this.sound.play('sfx_smite_cast', { volume: 0.8 });
this._doSmiteCastTo(sourceObj, targetObj, () => {
if (targetObj.scene) targetObj.refresh();
onComplete();
});
}
_animateSmiteApplyAll(event, onComplete) {
if (!event.targets?.length) { onComplete(); return; }
this.statusText.setText(`${event.attacker.name} smites all enemies!`);
this.sound.play('sfx_smite_cast', { volume: 0.8 });
const sourceObj = this.cardObjects.get(event.attacker.instanceId) || this.commanderObjects?.get(event.attacker.instanceId);
let remaining = event.targets.length;
const done = () => { if (--remaining <= 0) onComplete(); };
for (const t of event.targets) {
const targetObj = this.cardObjects.get(t.target.instanceId) || this.commanderObjects?.get(t.target.instanceId);
if (!sourceObj?.scene || !targetObj?.scene) { done(); continue; }
this._doSmiteCastTo(sourceObj, targetObj, () => {
if (targetObj.scene) targetObj.refresh();
done();
});
}
}
_animateSmiteTick(event, onComplete) {
const obj = this.cardObjects.get(event.card.instanceId) || this.commanderObjects?.get(event.card.instanceId);
if (!obj?.scene) { onComplete(); return; }
this.statusText.setText(`${event.card.name} suffers ${event.damage} smite damage!`);
this.sound.play('sfx_smite_damage', { volume: 0.8 });
// Sprite 39 pulses twice (250ms × 4 = 1000ms total) over the card
const effectSprite = this.add.sprite(obj.x, obj.y, 'attacks', 39)
.setDisplaySize(140, 140)
.setDepth(30)
.setAlpha(0.9);
this.tweens.add({
targets: effectSprite,
scaleX: { from: 0.8, to: 1.5 },
scaleY: { from: 0.8, to: 1.5 },
duration: 250,
yoyo: true,
repeat: 1,
onComplete: () => { if (effectSprite.scene) effectSprite.destroy(); }
});
// Latch: wait for both HP animation AND the 1s minimum before continuing
let spriteHeld = true;
let hpDone = false;
const tryFinish = () => {
if (!spriteHeld && hpDone) {
if (obj.scene) obj.refresh();
onComplete();
}
};
this.time.delayedCall(1000, () => { spriteHeld = false; tryFinish(); });
const afterHP = () => {
if (event.killed) {
const hlEvent = this._preBattleHiveLinks?.[event.card.instanceId];
if (hlEvent) delete this._preBattleHiveLinks[event.card.instanceId];
// For kills wait out the full 1s, then play death
this.time.delayedCall(1000, () => {
this._playCardDeath(obj, event.card,
hlEvent ? () => this._animateHiveLink(hlEvent, onComplete) : onComplete
);
});
} else {
hpDone = true;
tryFinish();
}
};
if (event.damage > 0 && obj.animateHPLoss) {
obj.animateHPLoss(event.damage, afterHP);
} else {
hpDone = true;
tryFinish();
}
}
// ── Sanctify animations ──────────────────────────────────────────────────
_processSanctifyFires(fires, onComplete) {
if (!fires || fires.length === 0) { onComplete(); return; }
const next = (idx) => {
if (idx >= fires.length) { onComplete(); return; }
const fire = fires[idx];
if (fire.isAll) {
this._animateSanctifyAll(fire, () => next(idx + 1));
} else {
this._animateSanctifySingle(fire, () => next(idx + 1));
}
};
next(0);
}
_animateSanctifySingle(fire, onComplete) {
const sourceObj = this.cardObjects.get(fire.source.instanceId) || this.commanderObjects?.get(fire.source.instanceId);
const targetObj = this.cardObjects.get(fire.target.instanceId) || this.commanderObjects?.get(fire.target.instanceId);
if (!targetObj?.scene) { onComplete(); return; }
const effects = fire.cleansed.map(c => c.effect).join(', ');
this.statusText.setText(`${fire.source.name} sanctifies self! Cleansed: ${effects}`);
this.sound.play('sfx_sanctify', { volume: 0.8 });
const startX = sourceObj?.scene ? sourceObj.x : targetObj.x;
const startY = sourceObj?.scene ? sourceObj.y : targetObj.y;
this._doSanctifySpriteTo(startX, startY, targetObj, () => {
if (targetObj.scene) targetObj.refresh();
onComplete();
});
}
_animateSanctifyAll(fire, onComplete) {
if (!fire.targets?.length) { onComplete(); return; }
this.statusText.setText(`${fire.source.name} sanctifies all allies!`);
this.sound.play('sfx_sanctify', { volume: 0.8 });
const sourceObj = this.cardObjects.get(fire.source.instanceId) || this.commanderObjects?.get(fire.source.instanceId);
const startX = sourceObj?.scene ? sourceObj.x : 0;
const startY = sourceObj?.scene ? sourceObj.y : 0;
let remaining = fire.targets.length;
const done = () => { if (--remaining <= 0) onComplete(); };
for (const t of fire.targets) {
const targetObj = this.cardObjects.get(t.target.instanceId) || this.commanderObjects?.get(t.target.instanceId);
if (!targetObj?.scene) { done(); continue; }
this._doSanctifySpriteTo(startX, startY, targetObj, () => {
if (targetObj.scene) targetObj.refresh();
done();
});
}
}
// Fly sprite 35 from (startX, startY) to targetObj, shake it, swap to sprite 36, fade out.
_doSanctifySpriteTo(startX, startY, targetObj, onComplete) {
const sprite = this.add.sprite(startX, startY, 'attacks', 35)
.setDisplaySize(100, 100)
.setDepth(30)
.setAlpha(1);
const flyDuration = 350;
this.tweens.add({
targets: sprite,
x: targetObj.x,
y: targetObj.y,
duration: flyDuration,
ease: 'Quad.easeIn',
onComplete: () => {
if (!sprite.scene) { onComplete(); return; }
// Shake: rapid left-right oscillation
const shakeAmp = 6;
const shakeDuration = 60;
this.tweens.add({
targets: sprite,
x: { from: targetObj.x - shakeAmp, to: targetObj.x + shakeAmp },
duration: shakeDuration,
yoyo: true,
repeat: 3,
ease: 'Sine.easeInOut',
onComplete: () => {
if (!sprite.scene) { onComplete(); return; }
// Snap back to centre, swap to sprite 36
sprite.setPosition(targetObj.x, targetObj.y);
sprite.setFrame(36);
// Fade out sprite 36
this.tweens.add({
targets: sprite,
alpha: 0,
scaleX: { from: 1, to: 1.4 },
scaleY: { from: 1, to: 1.4 },
duration: 350,
ease: 'Power2',
onComplete: () => {
if (sprite.scene) sprite.destroy();
onComplete();
}
});
}
});
}
});
}
// ── Overcharge animations ────────────────────────────────────────────────
_processOverchargeFires(fires, onComplete) {
if (!fires || fires.length === 0) { onComplete(); return; }
const next = (idx) => {
if (idx >= fires.length) { onComplete(); return; }
this._animateOvercharge(fires[idx], () => next(idx + 1));
};
next(0);
}
_animateOvercharge(fire, onComplete) {
const sourceObj = this.cardObjects.get(fire.source.instanceId) || this.commanderObjects?.get(fire.source.instanceId);
if (!sourceObj?.scene) { onComplete(); return; }
this.statusText.setText(`${fire.source.name} overcharges! -${fire.selfDamage} HP, +${fire.selfDamage} ATK to allies`);
this.sound.play('sfx_overcharge', { volume: 0.8 });
// Sprite 40 pulses over source while HP loss animates
const pulseSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 40)
.setDisplaySize(130, 130)
.setDepth(30)
.setAlpha(0.9);
this.tweens.add({
targets: pulseSprite,
scaleX: { from: 0.8, to: 1.5 },
scaleY: { from: 0.8, to: 1.5 },
duration: 220,
yoyo: true,
repeat: 1,
onComplete: () => { if (pulseSprite.scene) pulseSprite.destroy(); }
});
const afterDamage = () => {
if (fire.selfKilled && sourceObj.scene) {
this._playCardDeath(sourceObj, fire.source, () => this._animateOverchargeBuffs(fire, onComplete));
} else {
if (sourceObj.scene) sourceObj.refresh();
this._animateOverchargeBuffs(fire, onComplete);
}
};
if (fire.selfDamage > 0 && sourceObj.animateHPLoss) {
sourceObj.animateHPLoss(fire.selfDamage, afterDamage);
} else {
this.time.delayedCall(550, afterDamage);
}
}
_animateOverchargeBuffs(fire, onComplete) {
if (!fire.targets?.length) { onComplete(); return; }
let remaining = fire.targets.length;
const done = () => { if (--remaining <= 0) onComplete(); };
for (const t of fire.targets) {
const obj = this.cardObjects.get(t.target.instanceId) || this.commanderObjects?.get(t.target.instanceId);
if (!obj?.scene) { done(); continue; }
// Sprite 41 pulses over each buffed ally while ATK gain animates
const buffSprite = this.add.sprite(obj.x, obj.y, 'attacks', 41)
.setDisplaySize(130, 130)
.setDepth(30)
.setAlpha(0.9);
this.tweens.add({
targets: buffSprite,
scaleX: { from: 0.8, to: 1.5 },
scaleY: { from: 0.8, to: 1.5 },
duration: 220,
yoyo: true,
repeat: 1,
onComplete: () => { if (buffSprite.scene) buffSprite.destroy(); }
});
obj.animateBerserkGain(fire.selfDamage, () => {
if (obj.scene) obj.refresh();
done();
});
}
}
// ── Fortify animations ───────────────────────────────────────────────────
_processFortifyFires(fires, onComplete) {
if (!fires || fires.length === 0) { onComplete(); return; }
const next = (idx) => {
if (idx >= fires.length) { onComplete(); return; }
const fire = fires[idx];
if (fire.isAll) {
this._animateFortifyAll(fire, () => next(idx + 1));
} else {
this._animateFortifySingle(fire, () => next(idx + 1));
}
};
next(0);
}
_animateFortifySingle(fire, onComplete) {
const obj = this.cardObjects.get(fire.target.instanceId) || this.commanderObjects?.get(fire.target.instanceId);
if (!obj?.scene) { onComplete(); return; }
this.statusText.setText(`${fire.source.name} fortifies! +${fire.amount} armor`);
this.sound.play('sfx_fortify', { volume: 0.8 });
this._doFortifyPulse(obj, fire.amount, onComplete);
}
_animateFortifyAll(fire, onComplete) {
if (!fire.targets?.length) { onComplete(); return; }
const amount = fire.targets[0]?.amount || 0;
this.statusText.setText(`${fire.source.name} fortifies all allies! +${amount} armor`);
this.sound.play('sfx_fortify', { volume: 0.8 });
const sourceObj = this.cardObjects.get(fire.source.instanceId) || this.commanderObjects?.get(fire.source.instanceId);
const startX = sourceObj?.scene ? sourceObj.x : 0;
const startY = sourceObj?.scene ? sourceObj.y : 0;
let remaining = fire.targets.length;
const done = () => { if (--remaining <= 0) onComplete(); };
for (const t of fire.targets) {
const targetObj = this.cardObjects.get(t.target.instanceId) || this.commanderObjects?.get(t.target.instanceId);
if (!targetObj?.scene) { done(); continue; }
// Fly sprite 37 from source to each target, then pulse on arrival
const isSelf = sourceObj && targetObj === sourceObj;
const flyDuration = isSelf ? 0 : 300;
const projectile = this.add.sprite(startX, startY, 'attacks', 37)
.setDisplaySize(110, 110)
.setDepth(30)
.setAlpha(1);
this.tweens.add({
targets: projectile,
x: targetObj.x,
y: targetObj.y,
duration: flyDuration,
ease: 'Quad.easeIn',
onComplete: () => {
if (projectile.scene) projectile.destroy();
if (!targetObj.scene) { done(); return; }
this._doFortifyPulse(targetObj, t.amount, done);
}
});
}
}
// Pulse sprite 37 over a card (grow/shrink twice) while animating armor gain.
_doFortifyPulse(obj, amount, onComplete) {
// Reset armor text to pre-fortify value so the gain animates visibly
if (obj.armText) obj.armText.setText(`${(obj.cardData.currentArmor || 0) - amount}`);
const sprite = this.add.sprite(obj.x, obj.y, 'attacks', 37)
.setDisplaySize(110, 110)
.setDepth(30)
.setAlpha(0.9);
// Pulse: grow and shrink twice (yoyo × 2)
this.tweens.add({
targets: sprite,
scaleX: { from: 0.5, to: 1 },
scaleY: { from: 0.5, to: 1 },
duration: 220,
yoyo: true,
repeat: 1,
ease: 'Sine.easeInOut',
onComplete: () => {
if (sprite.scene) sprite.destroy();
}
});
// Animate armor gain in parallel with the pulse
obj.animateArmorGain(amount, () => {
if (obj.scene) obj.refresh();
onComplete();
});
}
// ── Hack animation (preAttack) ───────────────────────────────────────────
_animateHackFire(attacker, hackGroup, onComplete) {
const sourceObj = this.cardObjects.get(attacker.instanceId) || this.commanderObjects?.get(attacker.instanceId);
this.statusText.setText(`${attacker.name} hacks ${hackGroup.copiedFrom?.name || 'enemy'}!`);
this.sound.play('sfx_hack', { volume: 0.8 });
// Sprite 42 pulses on source for 1.5s before copied skills fire
const runFires = () => {
const fires = hackGroup.fires || [];
const nextFire = (idx) => {
if (idx >= fires.length) { onComplete(); return; }
const fire = fires[idx];
const cb = () => nextFire(idx + 1);
if (fire.skill === 'strike') this._animateStrikeFire(attacker, fire, cb);
else if (fire.skill === 'strikeAll') this._animateStrikeFire(attacker, fire, cb);
else if (fire.skill === 'mortar') this._animateMortarFire(attacker, fire, cb);
else if (fire.skill === 'pierce') this._animatePierceFire(attacker, fire, cb);
else if (fire.skill === 'swipe') this._animateSwipeSequence(attacker, [fire], cb);
else if (fire.skill === 'drain') this._animateDrainFire(fire.source, fire, cb);
else if (fire.skill === 'jam') this._animateJamFire(fire, cb);
else if (fire.skill === 'venom') this._animateVenomApply(fire, cb);
else if (fire.skill === 'venomAll') this._animateVenomApplyAll(fire, cb);
else if (fire.skill === 'smite') this._animateSmiteApply(fire, cb);
else if (fire.skill === 'smiteAll') this._animateSmiteApplyAll(fire, cb);
else if (fire.skill === 'molt') this._animateMolt(fire, cb);
else if (fire.skill === 'berserk') {
const obj = this.cardObjects.get(fire.card.instanceId) || this.commanderObjects?.get(fire.card.instanceId);
if (obj?.scene) {
this.statusText.setText(`${fire.card.name} goes berserk! +${fire.gain} ATK`);
obj.animateBerserkGain(fire.gain, () => { if (obj.scene) obj.refresh(); cb(); });
} else cb();
}
else if (fire.skill === 'flurry') {
this.statusText.setText(`${fire.card.name} gains flurry! +1 extra attack`);
this.time.delayedCall(600, cb);
}
else cb();
};
nextFire(0);
};
if (!sourceObj?.scene) { this.time.delayedCall(1500, runFires); return; }
const pulseSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 42)
.setDisplaySize(130, 130)
.setDepth(30)
.setAlpha(0.9);
// 3 cycles of grow/shrink over ~1500ms (500ms per cycle: 250ms out + 250ms back)
this.tweens.add({
targets: pulseSprite,
scaleX: { from: 0.8, to: 1.5 },
scaleY: { from: 0.8, to: 1.5 },
duration: 250,
yoyo: true,
repeat: 2,
ease: 'Sine.easeInOut',
onComplete: () => {
if (pulseSprite.scene) pulseSprite.destroy();
runFires();
}
});
}
// ── Restore/reapply for new preBattle skills ─────────────────────────────
_restoreSanctifyForDisplay(preBattleEvents) {
for (const event of preBattleEvents) {
for (const fire of event.sanctifyFires || []) {
if (fire.isAll) {
for (const t of fire.targets || []) {
for (const c of t.cleansed) {
if (c.effect === 'venom') t.target.venomStacks += c.removed;
if (c.effect === 'smite') t.target.smiteStacks += c.removed;
if (c.effect === 'rupture') t.target.ruptureStacks += c.removed;
}
}
} else if (fire.cleansed?.length) {
for (const c of fire.cleansed) {
if (c.effect === 'venom') fire.target.venomStacks += c.removed;
if (c.effect === 'smite') fire.target.smiteStacks += c.removed;
if (c.effect === 'rupture') fire.target.ruptureStacks += c.removed;
}
}
}
}
}
_reapplySanctify(preBattleEvents) {
for (const event of preBattleEvents) {
for (const fire of event.sanctifyFires || []) {
if (fire.isAll) {
for (const t of fire.targets || []) {
for (const c of t.cleansed) {
if (c.effect === 'venom') t.target.venomStacks -= c.removed;
if (c.effect === 'smite') t.target.smiteStacks -= c.removed;
if (c.effect === 'rupture') t.target.ruptureStacks -= c.removed;
}
}
} else if (fire.cleansed?.length) {
for (const c of fire.cleansed) {
if (c.effect === 'venom') fire.target.venomStacks -= c.removed;
if (c.effect === 'smite') fire.target.smiteStacks -= c.removed;
if (c.effect === 'rupture') fire.target.ruptureStacks -= c.removed;
}
}
}
}
}
_restoreOverchargeForDisplay(preBattleEvents) {
for (const event of preBattleEvents) {
for (const fire of event.overchargeFires || []) {
// Restore self-damage
fire.source.currentHP += fire.selfDamage;
// Remove ally buffs
for (const t of fire.targets || []) {
t.target.currentAttack -= t.amount;
}
}
}
}
_reapplyOvercharge(preBattleEvents) {
for (const event of preBattleEvents) {
for (const fire of event.overchargeFires || []) {
fire.source.currentHP -= fire.selfDamage;
for (const t of fire.targets || []) {
t.target.currentAttack += t.amount;
}
}
}
}
_restoreFortifyForDisplay(preBattleEvents) {
for (const event of preBattleEvents) {
for (const fire of event.fortifyFires || []) {
if (fire.isAll) {
for (const t of fire.targets || []) {
t.target.currentArmor -= t.amount;
}
} else if (fire.amount > 0) {
fire.target.currentArmor -= fire.amount;
}
}
}
}
_reapplyFortify(preBattleEvents) {
for (const event of preBattleEvents) {
for (const fire of event.fortifyFires || []) {
if (fire.isAll) {
for (const t of fire.targets || []) {
t.target.currentArmor += t.amount;
}
} else if (fire.amount > 0) {
fire.target.currentArmor += fire.amount;
}
}
}
}
}