4699 lines
177 KiB
JavaScript
4699 lines
177 KiB
JavaScript
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;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|