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