import { CombatEngine } from '../combat/CombatEngine.js'; import { CardObject } from '../objects/CardObject.js'; import { BattleField } from '../objects/BattleField.js'; import { SaveManager } from '../managers/SaveManager.js'; // ── Battle background images by faction ─────────────────────────────────────── // Each faction with art has 3 numbered variants; one is picked at random per battle. const BATTLE_BACKGROUNDS = { imperial: ['imperial_01', 'imperial_02', 'imperial_03'], raider: ['raider_01', 'raider_02', 'raider_03'], bloodthirsty: ['bloodthirsty_01', 'bloodthirsty_02', 'bloodthirsty_03'], xeno: ['xeno_01', 'xeno_02', 'xeno_03'], righteous: ['right_01', 'right_02', 'right_03'] }; // ── Battle music playlist ───────────────────────────────────────────────────── // Add or remove entries here to extend the track list. // Tracks are loaded on-demand when BattleScene first loads (not at boot), // shuffled randomly each time battle music starts, and cycle automatically. const BATTLE_MUSIC = [ { key: 'music_battle_01', path: 'assets/audio/music/battle_01.mp3' }, { key: 'music_battle_02', path: 'assets/audio/music/battle_02.mp3' }, { key: 'music_battle_03', path: 'assets/audio/music/battle_03.mp3' }, { key: 'music_battle_04', path: 'assets/audio/music/battle_04.mp3' }, { key: 'music_battle_05', path: 'assets/audio/music/battle_05.mp3' }, { key: 'music_battle_06', path: 'assets/audio/music/battle_06.mp3' }, ]; export class BattleScene extends Phaser.Scene { constructor() { super('BattleScene'); } preload() { // Load battle music tracks on demand — only downloaded if not already cached for (const track of BATTLE_MUSIC) { this.load.audio(track.key, track.path); } // Load a random battle background for the opponent's faction (if available) const opponentDeck = this.missionData?.opponent || { commander: 'raider_cmd_1' }; // skirmish fallback const cardManager = this.registry.get('cardManager'); const cmdCard = cardManager?.getCard(opponentDeck.commander); const faction = cmdCard?.faction; const variants = faction && BATTLE_BACKGROUNDS[faction]; if (variants) { const pick = variants[Math.floor(Math.random() * variants.length)]; this._battleBgKey = `bg_${pick}`; if (!this.textures.exists(this._battleBgKey)) { this.load.image(this._battleBgKey, `assets/images/ui/${pick}.png`); } } } init(data) { this.missionData = data.mission || null; this.playerDeckData = data.deck || null; this.isSkirmish = data.skirmish || false; this.playerLevel = data.playerLevel || 1; this.enemyLevel = data.enemyLevel || 1; this.campaignId = data.campaignId || null; } create() { const { width, height } = this.scale; const cardManager = this.registry.get('cardManager'); const save = this.registry.get('save'); // Stop main menu music for the duration of the battle this.registry.get('music_main_menu')?.stop(); // Resume main menu music and stop battle music when this scene shuts down this.events.once('shutdown', () => { this._stopBattleMusic(); const menuMusic = this.registry.get('music_main_menu'); if (menuMusic && !menuMusic.isPlaying) menuMusic.play(); }); this._startBattleMusic(); // Layout constants // Lanes: 4 lanes centred at x = 430, 740, 1050, 1360 (spacing 310) // Commander column: x = 150 // Opponent row: y = 305 Player row: y = 715 Midline: y = 510 // Particle dot texture (white circle, 8×8) used for attack effects if (!this.textures.exists('particle_dot')) { const g = this.make.graphics({ x: 0, y: 0, add: false }); g.fillStyle(0xffffff, 1); g.fillCircle(4, 4, 4); g.generateTexture('particle_dot', 8, 8); g.destroy(); } // Sprite animations (global — skip if already registered from a previous battle) if (!this.anims.exists('attack_anim')) { this.anims.create({ key: 'attack_anim', frames: this.anims.generateFrameNumbers('attacks', { start: 0, end: 4 }), frameRate: 10, repeat: 0 }); } if (!this.anims.exists('explosion_anim')) { this.anims.create({ key: 'explosion_anim', frames: this.anims.generateFrameNumbers('attacks', { start: 5, end: 8 }), frameRate: 10, repeat: 0 }); } // Background — faction-specific image with dimming overlay, or plain color fallback if (this._battleBgKey && this.textures.exists(this._battleBgKey)) { const bg = this.add.image(width / 2, height / 2, this._battleBgKey); bg.setDisplaySize(width, height); this.add.rectangle(width / 2, height / 2, width, height, 0x000000, 0.55); } else { this.add.rectangle(width / 2, height / 2, width, height, 0x1a1a2e); } // Midfield divider this.add.rectangle(width / 2, 510, width, 2, 0x334466); // Vertical lane separators (5 lines bounding 4 lanes) for (let i = 0; i < 5; i++) { const x = 280 + i * 310; this.add.rectangle(x, 510, 1, 780, 0x222244); } // Commander column right edge this.add.rectangle(280, height / 2, 1, height, 0x222244); // Side labels this.add.text(14, 95, 'OPPONENT', { fontSize: '17px', color: '#ff8888', fontFamily: 'Audiowide' }); this.add.text(14, 520, 'PLAYER', { fontSize: '17px', color: '#88aaff', fontFamily: 'Audiowide' }); this.battlefield = new BattleField(this, { playerY: 715, opponentY: 305, commanderPlayerX: 150, commanderOpponentX: 150, laneStartX: 430, laneSpacing: 310 }); // Setup combat let opponentDeck; if (this.missionData) { opponentDeck = this.missionData.opponent; } else { // Skirmish: use a raider deck opponentDeck = { commander: 'raider_cmd_1', cards: [ 'raider_grunt_1', 'raider_grunt_1', 'raider_grunt_1', 'raider_scout_1', 'raider_scout_1', 'raider_berserker_1', 'raider_berserker_1', 'raider_cutthroat_1', 'raider_marauder_1', 'raider_pillager_1' ] }; } if (!this.playerDeckData) { this.playerDeckData = save.decks[0]; } if (!this.playerDeckData) { this.add.text(width / 2, height / 2, 'No deck configured!\nGo to Deck Builder first.', { fontSize: '20px', color: '#ff4444', align: 'center', fontFamily: 'Audiowide' }).setOrigin(0.5); this._makeBackButton(); return; } this.engine = new CombatEngine(this.playerDeckData, opponentDeck, cardManager, undefined, this.playerLevel, this.enemyLevel); this.cardObjects = new Map(); // instanceId -> CardObject // Commander visuals this._buildCommanderDisplay(); // ── Top bar ────────────────────────────────────────────────────────────── this.turnText = this.add.text(960, 30, 'Turn 0', { fontSize: '20px', color: '#ffffff', fontFamily: 'Audiowide' }).setOrigin(0.5); const battleLabel = this.missionData ? this.missionData.name : 'Skirmish Battle'; this.add.text(960, 58, battleLabel, { fontSize: '14px', color: '#888888', fontFamily: 'Audiowide' }).setOrigin(0.5); // ── Combat log — bottom strip ──────────────────────────────────────────── this.statusText = this.add.text(width / 2, 1058, 'Press SPACE or click Next Turn to start', { fontSize: '17px', color: '#aaaaaa', fontFamily: 'Audiowide' }).setOrigin(0.5); this.logLines = []; for (let i = 0; i < 6; i++) { this.logLines.push(this.add.text(14, 915 + i * 20, '', { fontSize: '15px', color: '#777777', fontFamily: 'Audiowide' })); } // ── Top bar buttons ───────────────────────────────────────────────────── this.autoPlay = false; this.autoTimer = null; this.waitingForPick = false; this.isAnimating = false; this.playerGoesFirst = true; this.initiativeIndicator = null; const autoBtn = this.add.rectangle(1680, 35, 180, 44, 0x224422) .setInteractive({ useHandCursor: true }) .setStrokeStyle(1, 0x44aa44); this.autoBtnText = this.add.text(1680, 35, 'Auto: OFF', { fontSize: '18px', color: '#ffffff', fontFamily: 'Audiowide' }).setOrigin(0.5); autoBtn.on('pointerdown', () => this._toggleAuto()); const nextBtn = this.add.rectangle(1840, 35, 180, 44, 0x1a3a5c) .setInteractive({ useHandCursor: true }) .setStrokeStyle(1, 0x4488ff); this.add.text(1840, 35, 'Next Turn', { fontSize: '18px', color: '#ffffff', fontFamily: 'Audiowide' }).setOrigin(0.5); nextBtn.on('pointerdown', () => this._beginTurn()); // Keyboard this.input.keyboard.on('keydown-SPACE', () => this._beginTurn()); this._makeBackButton(); this._renderState(); } _buildCommanderDisplay() { const state = this.engine.getState(); this.commanderObjects = new Map(); // instanceId → CardObject (persists across _renderState) const specs = [ { data: state.opponent.commander, cx: 150, cy: 305, label: 'ENEMY CMD', labelColor: '#ff8888' }, { data: state.player.commander, cx: 150, cy: 715, label: 'COMMANDER', labelColor: '#ffd700' } ]; for (const s of specs) { const w = 240, h = 336; // Label above the card (separate text so it never scales/shakes with the card) this.add.text(s.cx, s.cy - h / 2 - 14, s.label, { fontSize: '15px', color: s.labelColor, fontFamily: 'Audiowide' }).setOrigin(0.5); // Use CardObject for visual consistency with lane cards const cardObj = new CardObject(this, s.cx, s.cy, s.data, { width: w, height: h }); cardObj.isCommander = true; this.commanderObjects.set(s.data.instanceId, cardObj); } this.oDeckText = this.add.text(14, 115, '', { fontSize: '15px', color: '#aaaaaa', fontFamily: 'Audiowide' }); this.pDeckText = this.add.text(14, 540, '', { fontSize: '15px', color: '#aaaaaa', fontFamily: 'Audiowide' }); } _beginTurn() { if (this.waitingForPick || this.isAnimating) return; if (this.engine.winner) { this._showResult(); return; } // Snapshot opponent lanes before this turn (only matters on opponent-first turns) const oldOpponentIds = new Set( this.engine.getState().opponent.lanes.map(c => c.instanceId) ); const { hand, canDeploy, playerGoesFirst } = this.engine.beginTurn(); this.playerGoesFirst = playerGoesFirst; this.turnText.setText(`Turn ${this.engine.turn}`); this._renderState(); this._updateInitiativeIndicator(playerGoesFirst); const proceedAfterDeploy = () => { if (canDeploy && !this.autoPlay) { this.waitingForPick = true; this.statusText.setText('Choose a card to deploy'); this._showCardPicker(hand); } else { this._finishTurn(canDeploy ? hand[0] : null); } }; // On opponent-first turns, show enemy deploy animation before showing picker. // On player-first turns, opponent hasn't deployed yet — go straight to picker. const newOpponentCard = !playerGoesFirst ? this.engine.getState().opponent.lanes.find(c => !oldOpponentIds.has(c.instanceId)) : null; if (newOpponentCard) { this.isAnimating = true; const cardObj = this.cardObjects.get(newOpponentCard.instanceId); this.statusText.setText(`Enemy deploys: ${newOpponentCard.name}`); this._animateDeploy(cardObj, () => { this.isAnimating = false; proceedAfterDeploy(); }); } else { proceedAfterDeploy(); } } _finishTurn(chosenCard) { this.waitingForPick = false; this.isAnimating = true; this.statusText.setText('Deploying...'); // Snapshot both sides before deploy so we can detect new cards const oldPlayerIds = new Set( this.engine.getState().player.lanes.map(c => c.instanceId) ); const oldOpponentIds = new Set( this.engine.getState().opponent.lanes.map(c => c.instanceId) ); // Deploy + pre-attack phases (NO damage applied yet) // On player-first turns, beginCommit also deploys the AI card const commitEvents = this.engine.beginCommit(chosenCard); const preBattleEvents = commitEvents.filter(e => e.type === 'preBattle' || e.type === 'venomTick' || e.type === 'smiteTick' || e.type === 'carapace' || e.type === 'molt' || e.type === 'hive_link'); // Build preBattle hive_link map for venom kills: dead card instanceId → hive_link event this._preBattleHiveLinks = {}; for (const e of commitEvents) { if (e.type === 'hive_link') this._preBattleHiveLinks[e.source.instanceId] = e; } // Temporarily restore all preBattle stat changes so _renderState() builds // CardObjects with original (pre-combat) values. Re-applied immediately after // so combat math is unaffected. Animations then show each change visually. this._restoreBuffsForDisplay(preBattleEvents); this._restoreJamSkillsForDisplay(preBattleEvents); this._restoreEnfeebleForDisplay(preBattleEvents); this._restoreDrainForDisplay(preBattleEvents); this._restoreMoltForDisplay(preBattleEvents); this._restoreHealForDisplay(preBattleEvents); this._restoreSanctifyForDisplay(preBattleEvents); this._restoreOverchargeForDisplay(preBattleEvents); this._restoreFortifyForDisplay(preBattleEvents); // Render the field with all newly deployed cards but pre-combat stats this._renderState(); this._reapplyBuffs(preBattleEvents); this._reapplyJamSkillReductions(preBattleEvents); this._reapplyEnfeebleReductions(preBattleEvents); this._reapplyDrainDamage(preBattleEvents); this._reapplyMolt(preBattleEvents); this._reapplyHeal(preBattleEvents); this._reapplySanctify(preBattleEvents); this._reapplyOvercharge(preBattleEvents); this._reapplyFortify(preBattleEvents); const newPlayerCard = chosenCard ? this.engine.getState().player.lanes.find(c => !oldPlayerIds.has(c.instanceId)) : null; // On player-first turns the AI deployed inside beginCommit — animate it after player const newOpponentCard = this.playerGoesFirst ? this.engine.getState().opponent.lanes.find(c => !oldOpponentIds.has(c.instanceId)) : null; // Collect spawn events emitted before preBattle (hive_link fires during processNextAttack, not here). const phaseEvents = commitEvents.filter(e => e.type === 'spawn'); const startAttacks = () => this._processPhaseEvents(phaseEvents, () => { this._processPreBattle(preBattleEvents, () => { this.time.delayedCall(200, () => this._processNextAttackStep()); }); }); const animateOpponentThenAttack = () => { if (newOpponentCard) { const cardObj = this.cardObjects.get(newOpponentCard.instanceId); this.statusText.setText(`Enemy deploys: ${newOpponentCard.name}`); this._animateDeploy(cardObj, startAttacks); } else { startAttacks(); } }; if (newPlayerCard) { const cardObj = this.cardObjects.get(newPlayerCard.instanceId); this.statusText.setText(`You deploy: ${newPlayerCard.name}`); this._animateDeploy(cardObj, animateOpponentThenAttack); } else { animateOpponentThenAttack(); } } // Drive one attack at a time. Called recursively until all attacks are done. _processNextAttackStep() { if (!this.engine.hasPendingAttacks()) { // All attacks resolved — run death-check, win-check, then tidy up display const postBattleEvents = this.engine.finalizeCommit(); this._renderState(); // removes dead cards, shows clean final state const state = this.engine.getState(); this.commanderObjects.get(state.player.commander.instanceId)?.refresh(); this.commanderObjects.get(state.opponent.commander.instanceId)?.refresh(); this.pDeckText.setText(`Deck:${state.player.deckRemaining} Hand:${state.player.hand.length}`); this.oDeckText.setText(`Deck:${state.opponent.deckRemaining} Hand:${state.opponent.hand.length}`); this._updateLog(); this._processPostBattle(postBattleEvents.filter(e => e.type === 'postBattle' || e.type === 'burrowTick'), () => { this._animateInitiativeHandoff(); this.isAnimating = false; this.statusText.setText('Press SPACE or click Next Turn to advance'); if (this.engine.winner) { this.time.delayedCall(600, () => this._showResult()); } }); return; } const events = this.engine.processNextAttack(); const preAttackEvent = events.find(e => e.type === 'preAttack'); const postAttackEvent = events.find(e => e.type === 'postAttack'); // Group events into attack rounds — flurry produces multiple attack events. // Each round carries its own attack, berserk, counter, and on-attack-all events. const attackRounds = []; let currentRound = null; for (const e of events) { if (e.type === 'attack') { currentRound = { attackEvent: e, berserkEvent: null, counterEvent: null, onAttackAllEvents: [] }; attackRounds.push(currentRound); } else if (currentRound) { if (e.type === 'berserk') currentRound.berserkEvent = e; else if (e.type === 'counter') currentRound.counterEvent = e; else if (['ruptureAll', 'weakenAll', 'venomApply', 'venomApplyAll', 'smiteApply', 'smiteApplyAll', 'carapace'].includes(e.type)) currentRound.onAttackAllEvents.push(e); } } // Build pending hive_link map: dead card instanceId → hive_link event, // so _onAttackReconcile can fire it immediately after the death explosion. this._pendingHiveLinks = {}; for (const round of attackRounds) { for (const e of round.onAttackAllEvents) { if (e.type === 'hive_link') this._pendingHiveLinks[e.source.instanceId] = e; } } // Also scan events not yet bucketed into rounds (e.g. from counter kills) for (const e of events) { if (e.type === 'hive_link') this._pendingHiveLinks[e.source.instanceId] = e; } if (attackRounds.length === 0) { // Attacker was already dead (counter-killed earlier this turn) — skip this.time.delayedCall(30, () => this._processNextAttackStep()); return; } // Update commander HP bars for siege damage (fires during _performAttack). // preAttack skills (strike) correct the targeted commander's HP themselves at the // start of their animation, overriding this refresh for that specific card. const state = this.engine.getState(); this.commanderObjects.get(state.player.commander.instanceId)?.refresh(); this.commanderObjects.get(state.opponent.commander.instanceId)?.refresh(); // Animate each round sequentially. For flurry, cards return to their original // positions after each attack before the next round begins. const animateRound = (roundIdx) => { if (roundIdx >= attackRounds.length) { this._onPostAttackStep(postAttackEvent, () => this._processNextAttackStep()); return; } const round = attackRounds[roundIdx]; // Fire pre-attack hook only before the first round const runAttack = () => { this._animateAttack(round.attackEvent, () => { this._refreshCardAfterAttack(round.attackEvent.defender); this._refreshCardAfterAttack(round.attackEvent.attacker); // If swarm buffed this attacker and will revert in postAttack, restore the // buffed ATK display immediately — obj.refresh() above already reset it to the // un-buffed value because the engine reverts currentAttack synchronously. if (postAttackEvent?.swarmRevert && round.attackEvent.attacker) { const rev = postAttackEvent.swarmRevert; if (rev.card.instanceId === round.attackEvent.attacker.instanceId) { const obj = this.cardObjects.get(rev.card.instanceId) ?? this.commanderObjects?.get(rev.card.instanceId); if (obj?.atkText?.scene) obj.atkText.setText(`${rev.card.currentAttack + rev.amount}`); } } this.time.delayedCall(220, () => { this._processOnAttackAllEvents(round.onAttackAllEvents, () => { animateRound(roundIdx + 1); }); }); }, round.berserkEvent, () => { this._animateCounterFire(round.counterEvent); }); }; if (roundIdx === 0) { this._onPreAttackStep(preAttackEvent, runAttack); } else { runAttack(); } }; animateRound(0); } // Update a card's HP bar and stats at the moment of impact, then handle death. // If the defender was already killed via _onAttackReconcile, cardObjects.get() returns // null here and the function exits early — no double-death. _refreshCardAfterAttack(cardData) { if (!cardData) return; const obj = this.cardObjects.get(cardData.instanceId) ?? this.commanderObjects?.get(cardData.instanceId); if (!obj) return; obj.refresh(); // Fallback death path for cards killed by counter/rupture without an HP loss animation if (cardData.currentHP <= 0 && !obj.isCommander) { this._playCardDeath(obj, cardData); } } // ── on_attack "all" event processing ──────────────────────────────────────── _processOnAttackAllEvents(events, onComplete) { if (!events || events.length === 0) { onComplete(); return; } const next = (idx) => { if (idx >= events.length) { onComplete(); return; } const event = events[idx]; const cb = () => next(idx + 1); if (event.type === 'ruptureAll') this._animateRuptureAll(event, cb); else if (event.type === 'weakenAll') this._animateWeakenAll(event, cb); else if (event.type === 'venomApply') this._animateVenomApply(event, cb); else if (event.type === 'venomApplyAll') this._animateVenomApplyAll(event, cb); else if (event.type === 'smiteApply') this._animateSmiteApply(event, cb); else if (event.type === 'smiteApplyAll') this._animateSmiteApplyAll(event, cb); else if (event.type === 'hive_link') this._animateHiveLink(event, cb); else if (event.type === 'carapace') this._animateCarapace(event, cb); else cb(); }; next(0); } _animateRuptureAll(event, onComplete) { if (!event.targets?.length) { onComplete(); return; } 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); } } // ── Heal preBattle animations ────────────────────────────────────────────── _processHealFires(fires, onComplete) { const next = (idx) => { if (idx >= fires.length) { onComplete(); return; } const fire = fires[idx]; if (fire.isAll) { this._animateHealFireAll(fire, () => next(idx + 1)); } else { this._animateHealFire(fire, () => next(idx + 1)); } }; next(0); } _animateHealFire(fire, onComplete) { const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id)); const sourceObj = _lookup(fire.source.instanceId); const targetObj = _lookup(fire.target.instanceId); if (!sourceObj?.scene || !targetObj?.scene || fire.healAmount <= 0) { onComplete(); return; } this.sound.play('sfx_heal', { volume: 0.8 }); this.statusText.setText(`${fire.source.name} heals ${fire.target.name} for ${fire.healAmount}!`); if (targetObj.hpText?.scene) targetObj.hpText.setText(`${fire.hpBefore}`); this._doHealSpriteAndHP(sourceObj, targetObj, fire.healAmount, fire.target.currentHP, onComplete); } _animateHealFireAll(fire, onComplete) { if (!fire.targets?.length) { onComplete(); return; } const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id)); const sourceObj = _lookup(fire.source.instanceId); if (!sourceObj?.scene) { onComplete(); return; } this.sound.play('sfx_heal', { volume: 0.8 }); this.statusText.setText(`${fire.source.name} heals all allies!`); // Animate sprite sequentially to each target const next = (idx) => { if (idx >= fire.targets.length) { onComplete(); return; } const t = fire.targets[idx]; const targetObj = _lookup(t.target.instanceId); if (!targetObj?.scene || t.healAmount <= 0) { next(idx + 1); return; } if (targetObj.hpText?.scene) targetObj.hpText.setText(`${t.hpBefore}`); this._doHealSpriteAndHP(sourceObj, targetObj, t.healAmount, t.target.currentHP, () => next(idx + 1)); }; next(0); } // Spawns heal sprite at sourceObj, flies it to targetObj, pulses it while animating HP gain. _doHealSpriteAndHP(sourceObj, targetObj, healAmount, hpAfter, onComplete) { const BASE_SCALE = 100 / 460; const healSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 34) .setScale(BASE_SCALE) .setDepth(30) .setAlpha(0.9); this.tweens.add({ targets: healSprite, x: targetObj.x, y: targetObj.y, duration: 400, ease: 'Cubic.easeIn', onComplete: () => { if (!targetObj.scene) { if (healSprite.scene) healSprite.destroy(); onComplete(); return; } // Pulse the sprite over the target this.tweens.add({ targets: healSprite, scaleX: { from: BASE_SCALE * 0.8, to: BASE_SCALE * 1.5 }, scaleY: { from: BASE_SCALE * 0.8, to: BASE_SCALE * 1.5 }, alpha: { from: 0.9, to: 0.3 }, duration: 300, yoyo: true, repeat: 1, ease: 'Sine.easeInOut', onComplete: () => { if (healSprite.scene) healSprite.destroy(); } }); // Animate HP gain text if (healAmount > 0 && targetObj.hpText?.scene) { this.tweens.add({ targets: targetObj.hpText, scaleX: 2, scaleY: 2, duration: 200, ease: 'Back.Out', onComplete: () => { if (!targetObj.scene) return; const gainText = this.add.text(targetObj.x, targetObj.y - 20, `+${healAmount}`, { fontSize: '14px', color: '#00ff88', fontStyle: 'bold', stroke: '#000000', strokeThickness: 3, fontFamily: 'Audiowide' }).setOrigin(0.5).setDepth(35); this.tweens.add({ targets: gainText, alpha: 0, y: targetObj.y - 50, duration: 700, ease: 'Power2', onComplete: () => { if (gainText.scene) gainText.destroy(); } }); this.time.delayedCall(200, () => { if (!targetObj.scene) return; targetObj.hpText.setText(`${hpAfter}`); this.tweens.add({ targets: targetObj.hpText, scaleX: 1, scaleY: 1, duration: 200, ease: 'Power2' }); }); } }); } // Complete after pulse + HP animation settle this.time.delayedCall(800, onComplete); } }); } _animateWeakenAll(event, onComplete) { if (!event.targets?.length) { onComplete(); return; } this.statusText.setText(`${event.attacker.name} weakens all enemies!`); let remaining = event.targets.length; const done = () => { if (--remaining <= 0) onComplete(); }; for (const t of event.targets) { const obj = this.cardObjects.get(t.target.instanceId) || this.commanderObjects?.get(t.target.instanceId); if (!obj?.scene) { done(); continue; } obj.refresh(); obj.flash(0x8800ff); this.time.delayedCall(400, done); } } // ── Xeno skill animations ────────────────────────────────────────────────── _processPhaseEvents(events, onComplete) { if (!events || events.length === 0) { onComplete(); return; } const next = (idx) => { if (idx >= events.length) { onComplete(); return; } const event = events[idx]; const cb = () => next(idx + 1); if (event.type === 'spawn') this._animateSpawn(event, cb); else cb(); }; next(0); } _animateSpawn(event, onComplete) { // Re-render state so the new drone card object exists this._renderState(); const cardObj = this.cardObjects.get(event.card.instanceId); if (!cardObj?.scene) { onComplete(); return; } this.statusText.setText(`${event.side === 'player' ? 'Your' : 'Enemy'} commander spawns a drone!`); cardObj.setAlpha(0).setScale(0.5); this.tweens.add({ targets: cardObj, alpha: 1, scaleX: 1, scaleY: 1, duration: 400, ease: 'Back.Out', onComplete: () => { this.time.delayedCall(200, onComplete); } }); } _animateMolt(event, onComplete) { const obj = this.cardObjects.get(event.card.instanceId); if (!obj?.scene) { onComplete(); return; } this.statusText.setText(`${event.card.name} molts: sheds ${event.armorLost} armor for +${event.heal} HP!`); this.sound.play('sfx_molt_stage1', { volume: 0.8 }); // Sprite 30: slowly rotating molt icon over the card const moltSprite = this.add.sprite(obj.x, obj.y, 'attacks', 30) .setDisplaySize(160, 160) .setDepth(30) .setAlpha(0.85); this.tweens.add({ targets: moltSprite, angle: 360, duration: 1200, ease: 'Linear', onComplete: () => { if (moltSprite.scene) moltSprite.destroy(); } }); // Animate HP gain: show pre-molt HP, then float +heal text and update to post-molt HP if (event.heal > 0 && obj.hpText) { const hpBefore = event.card.currentHP - event.heal; obj.hpText.setText(`${hpBefore}`); this.tweens.add({ targets: obj.hpText, scaleX: 2, scaleY: 2, duration: 200, ease: 'Back.Out', onComplete: () => { if (!obj.scene) return; const gainText = this.add.text(obj.x, obj.y - 20, `+${event.heal}`, { fontSize: '14px', color: '#00ff88', fontStyle: 'bold', stroke: '#000000', strokeThickness: 3, fontFamily: 'Audiowide' }).setOrigin(0.5).setDepth(35); this.tweens.add({ targets: gainText, alpha: 0, y: obj.y - 50, duration: 700, ease: 'Power2', onComplete: () => { if (gainText.scene) gainText.destroy(); } }); this.time.delayedCall(200, () => { if (!obj.scene) return; obj.hpText.setText(`${event.card.currentHP}`); this.tweens.add({ targets: obj.hpText, scaleX: 1, scaleY: 1, duration: 200, ease: 'Power2' }); }); } }); } // Animate armor loss after a short delay (armor → 0) this.time.delayedCall(500, () => { if (!obj?.scene) { onComplete(); return; } obj.animateArmorLoss(event.armorLost, () => { this.time.delayedCall(200, onComplete); }, event.armorLost, 0); }); } _animateMoltRestore(fire, onComplete) { const obj = this.cardObjects.get(fire.card.instanceId); if (!obj?.scene) { onComplete(); return; } this.statusText.setText(`${fire.card.name} regrows shell: +${fire.armorGain} ARM!`); this.sound.play('sfx_molt_stage2', { volume: 0.8 }); // Sprite 31: grow/shrink over the card const restoreSprite = this.add.sprite(obj.x, obj.y, 'attacks', 31) .setDisplaySize(100, 100) .setDepth(30) .setAlpha(0.9); this.tweens.add({ targets: restoreSprite, scaleX: { from: 0.7, to: 1.6 }, scaleY: { from: 0.7, to: 1.6 }, alpha: { from: 0.9, to: 0.2 }, duration: 250, yoyo: true, repeat: 1, onComplete: () => { if (restoreSprite.scene) restoreSprite.destroy(); } }); // Armor text starts at 0 then animates to restored value if (obj.armText) obj.armText.setText('0'); obj.animateArmorGain(fire.armorGain, onComplete); } // Swarm surges when the card attacks: sprite 33 spins, ATK animates up, sound plays. _animateSwarmPreAttack(attacker, fire, onComplete) { const obj = this.cardObjects.get(attacker.instanceId) ?? this.commanderObjects?.get(attacker.instanceId); if (!obj?.scene) { onComplete(); return; } this.statusText.setText(`${attacker.name} swarm surges: +${fire.gain} ATK!`); this.sound.play('sfx_swarm', { volume: 0.85 }); // Sprite 33 spins continuously over the card (1 rotation/second) while ATK gain plays const swarmSprite = this.add.sprite(obj.x, obj.y, 'attacks', 33) .setDisplaySize(180, 180) .setDepth(30) .setAlpha(1); this.tweens.add({ targets: swarmSprite, angle: 360, duration: 1000, repeat: -1, ease: 'Linear' }); // currentAttack is already reverted synchronously by processNextAttack, so it equals // the pre-buff value (X). Start the animation from X and animate to X+gain. if (obj.atkText) obj.atkText.setText(`${attacker.currentAttack}`); obj.animateBerserkGain(fire.gain, () => { if (swarmSprite.scene) swarmSprite.destroy(); onComplete(); }, attacker.currentAttack + fire.gain); } // Swarm fades after the card attacks: floating -N text communicates the ATK loss. _animateSwarmRevert(revert, onComplete) { const obj = this.cardObjects.get(revert.card.instanceId) ?? this.commanderObjects?.get(revert.card.instanceId); if (!obj?.scene) { onComplete(); return; } this.statusText.setText(`${revert.card.name} swarm fades: -${revert.amount} ATK`); // Ensure ATK text shows the pre-revert (buffed) value so the drop is visible if (obj.atkText) obj.atkText.setText(`${revert.card.currentAttack + revert.amount}`); // Brief hold so the buffed value is visible, then snap to un-buffed with a floating -N this.time.delayedCall(150, () => { if (!obj.scene) { onComplete(); return; } if (obj.atkText) obj.atkText.setText(`${revert.card.currentAttack}`); const lossText = this.add.text(obj.x, obj.y - 50, `-${revert.amount}`, { fontSize: '34px', color: '#aaaaff', stroke: '#000000', strokeThickness: 4, fontFamily: 'RaiderCrusader' } ).setOrigin(0.5).setDepth(31); this.tweens.add({ targets: lossText, y: obj.y - 120, alpha: 0, duration: 600, ease: 'Power2', onComplete: () => { if (lossText.scene) lossText.destroy(); onComplete(); } }); }); } _animateVenomTick(event, onComplete) { const obj = this.cardObjects.get(event.card.instanceId) || this.commanderObjects?.get(event.card.instanceId); if (!obj?.scene) { onComplete(); return; } this.statusText.setText(`${event.card.name} suffers ${event.damage} venom damage!`); this.sound.play('sfx_venom_effect', { volume: 0.8 }); // Sprite 28: venom effect pulses larger/smaller twice const effectSprite = this.add.sprite(obj.x, obj.y, 'attacks', 28) .setDisplaySize(140, 140) .setDepth(30) .setAlpha(0.9); this.tweens.add({ targets: effectSprite, scaleX: { from: 0.8, to: 1.5 }, scaleY: { from: 0.8, to: 1.5 }, alpha: { from: 0.9, to: 0.2 }, duration: 250, yoyo: true, repeat: 1, onComplete: () => { if (effectSprite.scene) effectSprite.destroy(); } }); // Animate the HP loss on the card while the sprite pulses. // If venom killed the card, destroy it after the HP animation. if (event.damage > 0 && obj.animateHPLoss) { obj.animateHPLoss(event.damage, () => { if (event.killed) { const hlEvent = this._preBattleHiveLinks?.[event.card.instanceId]; if (hlEvent) delete this._preBattleHiveLinks[event.card.instanceId]; this._playCardDeath(obj, event.card, hlEvent ? () => this._animateHiveLink(hlEvent, onComplete) : onComplete ); } else { if (obj.scene) obj.refresh(); onComplete(); } }); } else { this.time.delayedCall(550, () => { if (obj.scene) obj.refresh(); onComplete(); }); } } _animateCarapace(event, onComplete) { const obj = this.cardObjects.get(event.card.instanceId); if (!obj?.scene) { onComplete(); return; } this.statusText.setText(`${event.card.name} hardens: +${event.gain} ARM!`); this._animateCarapaceEffect(obj, event.gain, onComplete); } // Shared carapace visual: sprite 29 pulses over the card while armor gain animates. _animateCarapaceEffect(obj, gain, onComplete) { if (!obj?.scene) { if (onComplete) onComplete(); return; } this.sound.play('sfx_carapace', { volume: 0.8 }); const effectSprite = this.add.sprite(obj.x, obj.y, 'attacks', 29) .setDisplaySize(160, 160) .setDepth(30) .setAlpha(0.9); this.tweens.add({ targets: effectSprite, scaleX: { from: 0.7, to: 1.6 }, scaleY: { from: 0.7, to: 1.6 }, alpha: { from: 0.9, to: 0.2 }, duration: 280, yoyo: true, repeat: 1, onComplete: () => { if (effectSprite.scene) effectSprite.destroy(); } }); obj.animateArmorGain(gain, onComplete); } _animateHiveLink(event, onComplete) { if (!event.targets?.length) { onComplete(); return; } this.statusText.setText(`${event.source.name} — hive link pulses through the swarm! +${event.gain} ATK!`); this.sound.play('sfx_hive_link', { volume: 0.85 }); let remaining = event.targets.length; const done = () => { if (--remaining <= 0) onComplete(); }; for (const t of event.targets) { const obj = this.cardObjects.get(t.target.instanceId) ?? this.commanderObjects?.get(t.target.instanceId); if (!obj?.scene) { done(); continue; } // Sprite 32: hive-link pulse — grows and shrinks over the target card const linkSprite = this.add.sprite(obj.x, obj.y, 'attacks', 32) .setDisplaySize(130, 130) .setDepth(30) .setAlpha(0.9); this.tweens.add({ targets: linkSprite, scaleX: { from: 0.6, to: 1.7 }, scaleY: { from: 0.6, to: 1.7 }, alpha: { from: 0.9, to: 0.1 }, duration: 300, yoyo: true, repeat: 1, onComplete: () => { if (linkSprite.scene) linkSprite.destroy(); } }); // Animate ATK value going up on each buffed card if (obj.atkText) obj.atkText.setText(`${t.target.currentAttack - t.gain}`); obj.animateBerserkGain(t.gain, done); } } _animateVenomApply(event, onComplete) { const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id)); const sourceObj = _lookup(event.attacker.instanceId); const targetObj = _lookup(event.target.instanceId); if (!targetObj?.scene) { onComplete(); return; } this.statusText.setText(`${event.attacker.name} injects venom into ${event.target.name}! [${event.stacks} stacks]`); this.sound.play('sfx_venom_apply', { volume: 0.8 }); const doApply = () => { if (!targetObj.scene) { onComplete(); return; } // Sprite 28: venom effect — pulse larger/smaller twice over target const effectSprite = this.add.sprite(targetObj.x, targetObj.y, 'attacks', 28) .setDisplaySize(140, 140) .setDepth(30) .setAlpha(0.9); this.tweens.add({ targets: effectSprite, scaleX: { from: 0.8, to: 1.4 }, scaleY: { from: 0.8, to: 1.4 }, alpha: { from: 0.9, to: 0.3 }, duration: 200, yoyo: true, repeat: 1, onComplete: () => { if (effectSprite.scene) effectSprite.destroy(); } }); if (targetObj.scene) targetObj.refresh(); this.time.delayedCall(500, onComplete); }; if (!sourceObj?.scene) { doApply(); return; } // Sprite 27: venom projectile flying from attacker to target const venomSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 27) .setDisplaySize(80, 80) .setDepth(30); this.tweens.add({ targets: venomSprite, x: targetObj.x, y: targetObj.y, duration: 350, ease: 'Quad.In', onComplete: () => { if (venomSprite.scene) venomSprite.destroy(); doApply(); } }); } _animateVenomApplyAll(event, onComplete) { if (!event.targets?.length) { onComplete(); return; } const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id)); const sourceObj = _lookup(event.attacker.instanceId); this.statusText.setText(`${event.attacker.name} venoms all enemies!`); this.sound.play('sfx_venom_apply', { volume: 0.8 }); let remaining = event.targets.length; const done = () => { if (--remaining <= 0) onComplete(); }; for (const t of event.targets) { const targetObj = _lookup(t.target.instanceId); if (!targetObj?.scene) { done(); continue; } const animateEffect = (obj) => { if (!obj?.scene) { done(); return; } const effectSprite = this.add.sprite(obj.x, obj.y, 'attacks', 28) .setDisplaySize(140, 140) .setDepth(30) .setAlpha(0.9); this.tweens.add({ targets: effectSprite, scaleX: { from: 0.8, to: 1.4 }, scaleY: { from: 0.8, to: 1.4 }, alpha: { from: 0.9, to: 0.3 }, duration: 200, yoyo: true, repeat: 1, onComplete: () => { if (effectSprite.scene) effectSprite.destroy(); } }); if (obj.scene) obj.refresh(); this.time.delayedCall(500, done); }; if (!sourceObj?.scene) { animateEffect(targetObj); continue; } // Staggered projectile per target const venomSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 27) .setDisplaySize(80, 80) .setDepth(30); this.tweens.add({ targets: venomSprite, x: targetObj.x, y: targetObj.y, duration: 350, ease: 'Quad.In', onComplete: () => { if (venomSprite.scene) venomSprite.destroy(); animateEffect(targetObj); } }); } } _animateSwarmBuff(buff, onComplete) { // Reuse the legion buff animation pattern const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id)); const targetObj = _lookup(buff.target.instanceId); if (!targetObj?.scene) { onComplete(); return; } this.statusText.setText(`${buff.target.name} swarm: +${buff.amount} ATK`); targetObj.animateBerserkGain(buff.amount, onComplete); } // Explosion sprite + fade-out for a defeated lane card. _playCardDeath(obj, cardData, onComplete = null) { this.cardObjects.delete(cardData.instanceId); this.tweens.killTweensOf(obj); obj.setScale(1).setDepth(0); const explosionSprite = this.add.sprite(obj.x, obj.y, 'attacks') .setDisplaySize(630, 630) .setDepth(23); explosionSprite.play('explosion_anim'); this.sound.play('sfx_destroy', { volume: 0.85 }); explosionSprite.once('animationcomplete', () => { if (explosionSprite.scene) explosionSprite.destroy(); if (!obj.scene) { if (onComplete) onComplete(); return; } this.tweens.add({ targets: obj, scaleX: 1.25, scaleY: 1.25, alpha: 0, duration: 400, ease: 'Power2', onComplete: () => { if (obj.scene) obj.destroy(); if (onComplete) onComplete(); } }); }); } // Animate a card appearing on the field (scale from 0 with overshoot) _animateDeploy(cardObj, onComplete) { if (!cardObj) { onComplete(); return; } if (cardObj.cardData?.rarity === 'legendary') { this.sound.play('sfx_legendary_play', { volume: 0.9 }); } cardObj.setScale(0).setAlpha(0); this.tweens.add({ targets: cardObj, scaleX: 1.12, scaleY: 1.12, alpha: 1, duration: 280, ease: 'Back.Out', onComplete: () => { this.tweens.add({ targets: cardObj, scaleX: 1, scaleY: 1, duration: 180, ease: 'Power2', onComplete }); } }); } // Called immediately before each card's attack animation. // Fires after the attack animation completes and cards have returned to their positions. _onPostAttackStep(event, onComplete) { const doSwarmRevert = (cb) => { if (event?.swarmRevert) { this._animateSwarmRevert(event.swarmRevert, cb); } else { cb(); } }; doSwarmRevert(() => this._processPierceRestores(event?.pierceRestores || [], onComplete)); } // Fire-and-forget counter animation: spike (sprite 15) flies from counter card // to attacker, HP loss animation plays. Death is handled by _refreshCardAfterAttack // which is called from _processNextAttackStep's onComplete callback. _animateCounterFire(counterEvent) { if (!counterEvent) return; const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id)); const sourceObj = _lookup(counterEvent.source.instanceId); // the counter card const targetObj = _lookup(counterEvent.target.instanceId); // the original attacker if (!sourceObj?.scene || !targetObj?.scene) return; this.sound.play('sfx_counter', { volume: 0.8 }); this.statusText.setText(`${counterEvent.source.name} counters ${counterEvent.target.name}!`); const BASE_SCALE = 160 / 460; const spikeSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 15) .setScale(BASE_SCALE) .setDepth(30); this.time.delayedCall(200, () => { this.tweens.add({ targets: spikeSprite, x: targetObj.x, y: targetObj.y, duration: 300, ease: 'Cubic.easeIn', onComplete: () => { this.tweens.add({ targets: spikeSprite, alpha: 0, duration: 200, ease: 'Power2', onComplete: () => { if (spikeSprite.scene) spikeSprite.destroy(); } }); if (!targetObj.scene) return; if (counterEvent.damage > 0) { const target = counterEvent.target; const hpBefore = target.currentHP + counterEvent.damage; const hpAfter = Math.max(0, target.currentHP); if (targetObj.hpText) targetObj.hpText.setText(`${hpBefore}`); if (target.currentHP > 0) this.sound.play('sfx_damage', { volume: 0.8 }); targetObj.animateHPLoss(counterEvent.damage, null, hpBefore, hpAfter); } } }); }); } // Restore armor that was temporarily reduced by pierce, animating the gain on each target. _processPierceRestores(restores, onComplete) { if (!restores.length) { onComplete(); return; } const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id)); let remaining = restores.length; const done = () => { if (--remaining === 0) onComplete(); }; for (const restore of restores) { const targetObj = _lookup(restore.card.instanceId); // Restore the armor value in card data regardless of animation restore.card.currentArmor += restore.amount; if (targetObj?.scene) { targetObj.animateArmorGain(restore.amount, done); } else { done(); } } } // Handles preAttack skill fires (mortar, strike, etc.) before the regular attack plays. _onPreAttackStep(event, onComplete) { if (!event || !event.preAttackFires?.length) { onComplete(); return; } // Group consecutive "all" fires of the same skill for parallel animation const groups = []; for (const fire of event.preAttackFires) { if (fire.isAll) { const last = groups[groups.length - 1]; if (last?.isAll && last.skill === fire.skill) { last.fires.push(fire); } else { groups.push({ skill: fire.skill, fires: [fire], isAll: true }); } } else { groups.push(fire); } } const next = idx => { if (idx >= groups.length) { onComplete(); return; } const group = groups[idx]; const cb = () => next(idx + 1); if (group.isAll) { if (group.skill === 'mortar') this._animateMortarAll(event.attacker, group.fires, cb); else if (group.skill === 'strike') this._animateStrikeAll(event.attacker, group.fires, cb); else if (group.skill === 'pierce') this._animatePierceAll(event.attacker, group.fires, cb); else cb(); } else if (group.skill === 'swipe') { // Swipe fires are never isAll — they always come as individual fires that animate sequentially // Collect all consecutive swipe fires into one sequence const swipeFires = [group]; while (idx + 1 < groups.length && groups[idx + 1].skill === 'swipe') { swipeFires.push(groups[++idx]); } this._animateSwipeSequence(event.attacker, swipeFires, cb); } else { if (group.skill === 'mortar') this._animateMortarFire(event.attacker, group, cb); else if (group.skill === 'strike') this._animateStrikeFire(event.attacker, group, cb); else if (group.skill === 'pierce') this._animatePierceFire(event.attacker, group, cb); else if (group.skill === 'siphon') this._animateSiphonFire(event.attacker, group, cb); else if (group.skill === 'bloodpact') this._animateBloodpactFire(event.attacker, group, cb); else if (group.skill === 'moltRestore') this._animateMoltRestore(group, cb); else if (group.skill === 'swarm') this._animateSwarmPreAttack(event.attacker, group, cb); else if (group.skill === 'hack') this._animateHackFire(event.attacker, group, cb); else cb(); } }; next(0); } // Animate a mortar shell flying from attacker to target, apply damage, handle death. _animateMortarFire(attacker, mortarFire, onComplete) { const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id)); const sourceObj = _lookup(attacker.instanceId); const targetObj = _lookup(mortarFire.target.instanceId); if (!sourceObj?.scene || !targetObj?.scene) { onComplete(); return; } this.sound.play('sfx_mortar', { volume: 0.8 }); // Immediately correct the target's HP display to the pre-mortar value. // The early commander refresh (and any prior render) may have already set it to // the final post-all-damage value; we override it here so it's accurate throughout. if (targetObj.hpText && targetObj.scene) { targetObj.hpText.setText(`${Math.max(0, mortarFire.hpBefore)}`); } this.statusText.setText(`${attacker.name} fires mortar at ${mortarFire.target.name}!`); const BASE_SCALE = 160 / 460; // Place mortar sprite on the attacking card; default sprite points up const mortarSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 11) .setScale(BASE_SCALE) .setDepth(30); // Rotate to face the target (sprite points up = -PI/2 from Phaser's "right" baseline) const angle = Phaser.Math.Angle.Between(sourceObj.x, sourceObj.y, targetObj.x, targetObj.y); mortarSprite.rotation = angle + Math.PI / 2; // Brief pause so the rotated mortar is visible on the card before launching this.time.delayedCall(450, () => { const startX = sourceObj.x, startY = sourceObj.y; const endX = targetObj.x, endY = targetObj.y; const ARC_HEIGHT = -180; const progress = { t: 0 }; const emitter = this.add.particles(startX, startY, 'particle_dot', { speed: { min: 15, max: 60 }, scale: { start: 0.5, end: 0 }, alpha: { start: 1, end: 0 }, lifespan: 280, tint: [0xff8800, 0xff4400, 0x888888], blendMode: 'ADD', frequency: 30 }).setDepth(29); this.tweens.add({ targets: progress, t: 1, duration: 600, ease: 'Quad.easeIn', onUpdate: () => { const sin = Math.sin(progress.t * Math.PI); mortarSprite.x = startX + (endX - startX) * progress.t; mortarSprite.y = startY + (endY - startY) * progress.t + ARC_HEIGHT * sin; if (emitter.scene) emitter.setPosition(mortarSprite.x, mortarSprite.y); }, onComplete: () => { emitter.stop(); this.time.delayedCall(250, () => { if (emitter.scene) emitter.destroy(); }); if (mortarSprite.scene) mortarSprite.destroy(); if (!targetObj.scene) { onComplete(); return; } // Explosion bursts on impact, then resolve HP loss and death this._playSkillExplosion(targetObj.x, targetObj.y, () => { if (!targetObj.scene) { onComplete(); return; } // Show only this skill's damage; use explicit HP values so the display // reflects the post-mortar HP correctly even though the engine has // already applied the regular attack damage to cardData.currentHP. const hpAfter = Math.max(0, mortarFire.hpBefore - mortarFire.damage); const afterImpact = () => { // Lock display to the post-mortar HP so the regular attack // animateHPLoss starts from the right value if (targetObj.hpText && targetObj.scene) targetObj.hpText.setText(`${hpAfter}`); if (mortarFire.target.currentHP <= 0 && !targetObj.isCommander) { const hlEvent = this._pendingHiveLinks?.[mortarFire.target.instanceId]; if (hlEvent) delete this._pendingHiveLinks[mortarFire.target.instanceId]; this._playCardDeath(targetObj, mortarFire.target, hlEvent ? () => this._animateHiveLink(hlEvent, onComplete) : onComplete ); } else if (mortarFire.carapaceGain > 0 && targetObj.scene) { this._animateCarapaceEffect(targetObj, mortarFire.carapaceGain, onComplete); } else { onComplete(); } }; if (mortarFire.damage > 0) { if (hpAfter > 0) this.sound.play('sfx_damage', { volume: 0.8 }); targetObj.animateHPLoss(mortarFire.damage, afterImpact, mortarFire.hpBefore, hpAfter); } else { afterImpact(); } }); } }); }); } // Plays the skill-explosion sprite (frame 12) at (x, y): grows from tiny while // rotating, then fades out. Calls onComplete when the fade finishes. // Animate a missile flying from attacker to the card directly across (or commander), // play skill explosion on impact, apply damage, handle death. _animateStrikeFire(attacker, strikeFire, onComplete) { const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id)); const sourceObj = _lookup(attacker.instanceId); const targetObj = _lookup(strikeFire.target.instanceId); if (!sourceObj?.scene || !targetObj?.scene) { onComplete(); return; } this.sound.play('sfx_strike', { volume: 0.8 }); // Immediately correct the target's HP display to the pre-strike value. // Commanders are refreshed to the final HP before _onPreAttackStep runs; // overriding here ensures the correct value is visible for the full animation. if (targetObj.hpText && targetObj.scene) { targetObj.hpText.setText(`${Math.max(0, strikeFire.hpBefore)}`); } this.statusText.setText(`${attacker.name} fires missile at ${strikeFire.target.name}!`); const BASE_SCALE = 160 / 460; // Place missile sprite (frame 9) on the attacking card; default points up const missileSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 9) .setScale(BASE_SCALE) .setDepth(30); // Rotate to face the target (sprite points up = -PI/2 from Phaser's right baseline) const angle = Phaser.Math.Angle.Between(sourceObj.x, sourceObj.y, targetObj.x, targetObj.y); missileSprite.rotation = angle + Math.PI / 2; // Brief pause so the aimed missile is visible before launch this.time.delayedCall(300, () => { const startX = sourceObj.x, startY = sourceObj.y; const endX = targetObj.x, endY = targetObj.y; const ARC_HEIGHT = -70; // missiles fly flatter than mortar shells const progress = { t: 0 }; const emitter = this.add.particles(startX, startY, 'particle_dot', { speed: { min: 20, max: 80 }, scale: { start: 0.4, end: 0 }, alpha: { start: 1, end: 0 }, lifespan: 200, tint: [0xff6600, 0xffcc00, 0xffffff], blendMode: 'ADD', frequency: 20 }).setDepth(29); this.tweens.add({ targets: progress, t: 1, duration: 450, ease: 'Quad.easeIn', onUpdate: () => { const sin = Math.sin(progress.t * Math.PI); missileSprite.x = startX + (endX - startX) * progress.t; missileSprite.y = startY + (endY - startY) * progress.t + ARC_HEIGHT * sin; if (emitter.scene) emitter.setPosition(missileSprite.x, missileSprite.y); }, onComplete: () => { emitter.stop(); this.time.delayedCall(200, () => { if (emitter.scene) emitter.destroy(); }); if (missileSprite.scene) missileSprite.destroy(); if (!targetObj.scene) { onComplete(); return; } // Explosion on impact, then resolve damage this._playSkillExplosion(targetObj.x, targetObj.y, () => { if (!targetObj.scene) { onComplete(); return; } const hpAfter = Math.max(0, strikeFire.hpBefore - strikeFire.damage); const afterImpact = () => { // Lock display to post-strike HP so the regular attack // animateHPLoss starts from the right value if (targetObj.hpText && targetObj.scene) targetObj.hpText.setText(`${hpAfter}`); if (strikeFire.target.currentHP <= 0 && !strikeFire.targetIsCommander) { const hlEvent = this._pendingHiveLinks?.[strikeFire.target.instanceId]; if (hlEvent) delete this._pendingHiveLinks[strikeFire.target.instanceId]; this._playCardDeath(targetObj, strikeFire.target, hlEvent ? () => this._animateHiveLink(hlEvent, onComplete) : onComplete ); } else if (strikeFire.carapaceGain > 0 && targetObj.scene) { this._animateCarapaceEffect(targetObj, strikeFire.carapaceGain, onComplete); } else { onComplete(); } }; if (strikeFire.damage > 0) { if (hpAfter > 0) this.sound.play('sfx_damage', { volume: 0.8 }); targetObj.animateHPLoss(strikeFire.damage, afterImpact, strikeFire.hpBefore, hpAfter); } else { afterImpact(); } }); } }); }); } // Animate pierce: sprite 16 flies from attacker to defender, fades, then armor loss animates. // After the main attack the armor is restored in _onPostAttackStep. _animatePierceFire(attacker, pierceFire, onComplete) { const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id)); const sourceObj = _lookup(attacker.instanceId); const targetObj = _lookup(pierceFire.target.instanceId); if (!sourceObj?.scene || !targetObj?.scene || pierceFire.amount <= 0) { onComplete(); return; } this.sound.play('sfx_pierce', { volume: 0.8 }); this.statusText.setText(`${attacker.name} pierces ${pierceFire.target.name}'s armor!`); const BASE_SCALE = 160 / 460; // Pierce sprite overlaid on the attacker, rotated to face the target const pierceSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 16) .setScale(BASE_SCALE) .setDepth(30); // No rotation — reticle stays upright throughout // Fly to the defender this.tweens.add({ targets: pierceSprite, x: targetObj.x, y: targetObj.y, duration: 320, ease: 'Cubic.easeIn', onComplete: () => { if (!targetObj.scene) { onComplete(); return; } // Phase 1: reticle locks on — slow pulse for 1000ms this.tweens.add({ targets: pierceSprite, scaleX: BASE_SCALE * 1.25, scaleY: BASE_SCALE * 1.25, duration: 200, ease: 'Sine.easeInOut', yoyo: true, repeat: 2, onComplete: () => { if (!pierceSprite.scene) { onComplete(); return; } // Phase 2: fade out the reticle this.tweens.add({ targets: pierceSprite, alpha: 0, duration: 300, ease: 'Power2', onComplete: () => { if (pierceSprite.scene) pierceSprite.destroy(); } }); // Trigger armor loss at the moment the reticle starts fading if (!targetObj.scene) { onComplete(); return; } if (targetObj.armText) targetObj.armText.setText(`${pierceFire.armBefore}`); const armAfter = pierceFire.armBefore - pierceFire.amount; targetObj.animateArmorLoss(pierceFire.amount, onComplete, pierceFire.armBefore, armAfter); } }); } }); } // ── "all" preAttack animations — parallel fire to all targets, sound plays once ── _animateMortarAll(attacker, fires, onComplete) { if (!fires.length) { onComplete(); return; } this.sound.play('sfx_mortar', { volume: 0.8 }); this.statusText.setText(`${attacker.name} fires mortar at all enemies!`); let remaining = fires.length; const done = () => { if (--remaining <= 0) onComplete(); }; for (const fire of fires) { this._animateMortarFireNoSound(attacker, fire, done); } } // Same as _animateMortarFire but without playing sound (called by _animateMortarAll) _animateMortarFireNoSound(attacker, mortarFire, onComplete) { const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id)); const sourceObj = _lookup(attacker.instanceId); const targetObj = _lookup(mortarFire.target.instanceId); if (!sourceObj?.scene || !targetObj?.scene) { onComplete(); return; } if (targetObj.hpText && targetObj.scene) { targetObj.hpText.setText(`${Math.max(0, mortarFire.hpBefore)}`); } const BASE_SCALE = 160 / 460; const mortarSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 11) .setScale(BASE_SCALE).setDepth(30); const angle = Phaser.Math.Angle.Between(sourceObj.x, sourceObj.y, targetObj.x, targetObj.y); mortarSprite.rotation = angle + Math.PI / 2; this.time.delayedCall(450, () => { const startX = sourceObj.x, startY = sourceObj.y; const endX = targetObj.x, endY = targetObj.y; const ARC_HEIGHT = -180; const progress = { t: 0 }; const emitter = this.add.particles(startX, startY, 'particle_dot', { speed: { min: 15, max: 60 }, scale: { start: 0.5, end: 0 }, alpha: { start: 1, end: 0 }, lifespan: 280, tint: [0xff8800, 0xff4400, 0x888888], blendMode: 'ADD', frequency: 30 }).setDepth(29); this.tweens.add({ targets: progress, t: 1, duration: 600, ease: 'Quad.easeIn', onUpdate: () => { const sin = Math.sin(progress.t * Math.PI); mortarSprite.x = startX + (endX - startX) * progress.t; mortarSprite.y = startY + (endY - startY) * progress.t + ARC_HEIGHT * sin; if (emitter.scene) emitter.setPosition(mortarSprite.x, mortarSprite.y); }, onComplete: () => { emitter.stop(); this.time.delayedCall(250, () => { if (emitter.scene) emitter.destroy(); }); if (mortarSprite.scene) mortarSprite.destroy(); if (!targetObj.scene) { onComplete(); return; } this._playSkillExplosion(targetObj.x, targetObj.y, () => { if (!targetObj.scene) { onComplete(); return; } const hpAfter = Math.max(0, mortarFire.hpBefore - mortarFire.damage); const afterImpact = () => { if (targetObj.hpText && targetObj.scene) targetObj.hpText.setText(`${hpAfter}`); if (mortarFire.target.currentHP <= 0 && !targetObj.isCommander) { const hlEvent = this._pendingHiveLinks?.[mortarFire.target.instanceId]; if (hlEvent) delete this._pendingHiveLinks[mortarFire.target.instanceId]; this._playCardDeath(targetObj, mortarFire.target, hlEvent ? () => this._animateHiveLink(hlEvent, onComplete) : onComplete ); } else if (mortarFire.carapaceGain > 0 && targetObj.scene) { this._animateCarapaceEffect(targetObj, mortarFire.carapaceGain, onComplete); } else { onComplete(); } }; if (mortarFire.damage > 0) { if (hpAfter > 0) this.sound.play('sfx_damage', { volume: 0.8 }); targetObj.animateHPLoss(mortarFire.damage, afterImpact, mortarFire.hpBefore, hpAfter); } else { afterImpact(); } }); } }); }); } _animateStrikeAll(attacker, fires, onComplete) { if (!fires.length) { onComplete(); return; } this.sound.play('sfx_strike', { volume: 0.8 }); this.statusText.setText(`${attacker.name} strikes all enemies!`); let remaining = fires.length; const done = () => { if (--remaining <= 0) onComplete(); }; for (const fire of fires) { this._animateStrikeFireNoSound(attacker, fire, done); } } _animateStrikeFireNoSound(attacker, strikeFire, onComplete) { const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id)); const sourceObj = _lookup(attacker.instanceId); const targetObj = _lookup(strikeFire.target.instanceId); if (!sourceObj?.scene || !targetObj?.scene) { onComplete(); return; } if (targetObj.hpText && targetObj.scene) { targetObj.hpText.setText(`${Math.max(0, strikeFire.hpBefore)}`); } const BASE_SCALE = 160 / 460; const missileSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 9) .setScale(BASE_SCALE).setDepth(30); const angle = Phaser.Math.Angle.Between(sourceObj.x, sourceObj.y, targetObj.x, targetObj.y); missileSprite.rotation = angle + Math.PI / 2; this.time.delayedCall(300, () => { const startX = sourceObj.x, startY = sourceObj.y; const endX = targetObj.x, endY = targetObj.y; const ARC_HEIGHT = -70; const progress = { t: 0 }; const emitter = this.add.particles(startX, startY, 'particle_dot', { speed: { min: 20, max: 80 }, scale: { start: 0.4, end: 0 }, alpha: { start: 1, end: 0 }, lifespan: 200, tint: [0xff6600, 0xffcc00, 0xffffff], blendMode: 'ADD', frequency: 20 }).setDepth(29); this.tweens.add({ targets: progress, t: 1, duration: 450, ease: 'Quad.easeIn', onUpdate: () => { const sin = Math.sin(progress.t * Math.PI); missileSprite.x = startX + (endX - startX) * progress.t; missileSprite.y = startY + (endY - startY) * progress.t + ARC_HEIGHT * sin; if (emitter.scene) emitter.setPosition(missileSprite.x, missileSprite.y); }, onComplete: () => { emitter.stop(); this.time.delayedCall(200, () => { if (emitter.scene) emitter.destroy(); }); if (missileSprite.scene) missileSprite.destroy(); if (!targetObj.scene) { onComplete(); return; } this._playSkillExplosion(targetObj.x, targetObj.y, () => { if (!targetObj.scene) { onComplete(); return; } const hpAfter = Math.max(0, strikeFire.hpBefore - strikeFire.damage); const afterImpact = () => { if (targetObj.hpText && targetObj.scene) targetObj.hpText.setText(`${hpAfter}`); if (strikeFire.target.currentHP <= 0 && !strikeFire.targetIsCommander) { const hlEvent = this._pendingHiveLinks?.[strikeFire.target.instanceId]; if (hlEvent) delete this._pendingHiveLinks[strikeFire.target.instanceId]; this._playCardDeath(targetObj, strikeFire.target, hlEvent ? () => this._animateHiveLink(hlEvent, onComplete) : onComplete ); } else if (strikeFire.carapaceGain > 0 && targetObj.scene) { this._animateCarapaceEffect(targetObj, strikeFire.carapaceGain, onComplete); } else { onComplete(); } }; if (strikeFire.damage > 0) { if (hpAfter > 0) this.sound.play('sfx_damage', { volume: 0.8 }); targetObj.animateHPLoss(strikeFire.damage, afterImpact, strikeFire.hpBefore, hpAfter); } else { afterImpact(); } }); } }); }); } _animatePierceAll(attacker, fires, onComplete) { if (!fires.length) { onComplete(); return; } this.sound.play('sfx_pierce', { volume: 0.8 }); this.statusText.setText(`${attacker.name} pierces all enemies' armor!`); let remaining = fires.length; const done = () => { if (--remaining <= 0) onComplete(); }; for (const fire of fires) { this._animatePierceFireNoSound(attacker, fire, done); } } _animatePierceFireNoSound(attacker, pierceFire, onComplete) { const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id)); const sourceObj = _lookup(attacker.instanceId); const targetObj = _lookup(pierceFire.target.instanceId); if (!sourceObj?.scene || !targetObj?.scene || pierceFire.amount <= 0) { onComplete(); return; } const BASE_SCALE = 160 / 460; const pierceSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 16) .setScale(BASE_SCALE).setDepth(30); this.tweens.add({ targets: pierceSprite, x: targetObj.x, y: targetObj.y, duration: 320, ease: 'Cubic.easeIn', onComplete: () => { if (!targetObj.scene) { onComplete(); return; } this.tweens.add({ targets: pierceSprite, scaleX: BASE_SCALE * 1.25, scaleY: BASE_SCALE * 1.25, duration: 200, ease: 'Sine.easeInOut', yoyo: true, repeat: 2, onComplete: () => { if (!pierceSprite.scene) { onComplete(); return; } this.tweens.add({ targets: pierceSprite, alpha: 0, duration: 300, ease: 'Power2', onComplete: () => { if (pierceSprite.scene) pierceSprite.destroy(); } }); if (!targetObj.scene) { onComplete(); return; } if (targetObj.armText) targetObj.armText.setText(`${pierceFire.armBefore}`); const armAfter = pierceFire.armBefore - pierceFire.amount; targetObj.animateArmorLoss(pierceFire.amount, onComplete, pierceFire.armBefore, armAfter); } }); } }); } // ── Swipe animation — sprite 20 moves sequentially to each target ─────────── // Animate swipe: play swipe_01 once, then fly the swipe sprite to each target one at a time. // On arrival at each target: play swipe_02, fade out over 500ms, apply damage, then move to next. _animateSwipeSequence(attacker, swipeFires, onComplete) { if (!swipeFires.length) { onComplete(); return; } this.sound.play('sfx_swipe_01', { volume: 0.8 }); this.statusText.setText(`${attacker.name} swipes through all enemies!`); const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id)); const sourceObj = _lookup(attacker.instanceId); if (!sourceObj?.scene) { onComplete(); return; } // Correct all target HP displays to pre-swipe values for (const fire of swipeFires) { const targetObj = _lookup(fire.target.instanceId); if (targetObj?.hpText && targetObj.scene) { targetObj.hpText.setText(`${Math.max(0, fire.hpBefore)}`); } } const animateNext = (idx, currentX, currentY) => { if (idx >= swipeFires.length) { onComplete(); return; } const fire = swipeFires[idx]; const targetObj = _lookup(fire.target.instanceId); if (!targetObj?.scene) { animateNext(idx + 1, currentX, currentY); return; } const BASE_SCALE = 160 / 460; const swipeSprite = this.add.sprite(currentX, currentY, 'attacks', 20) .setScale(BASE_SCALE) .setDepth(30); // Continuous 360-degree rotation (full spin every 750ms) this.tweens.add({ targets: swipeSprite, angle: 360, duration: 750, repeat: -1 }); // Fly to target const flyDuration = 350; this.tweens.add({ targets: swipeSprite, x: targetObj.x, y: targetObj.y, duration: flyDuration, ease: 'Cubic.easeIn', onComplete: () => { // Play impact sound this.sound.play('sfx_swipe_02', { volume: 0.8 }); // Flash the target card if (targetObj.bg) { this.tweens.add({ targets: targetObj.bg, fillAlpha: { from: 0.6, to: 0 }, duration: 300, ease: 'Linear' }); } // Fade out sprite over 500ms this.tweens.add({ targets: swipeSprite, alpha: 0, duration: 500, ease: 'Power2', onComplete: () => { if (swipeSprite.scene) swipeSprite.destroy(); } }); // Apply damage display if (!targetObj.scene) { animateNext(idx + 1, targetObj.x, targetObj.y); return; } const hpAfter = Math.max(0, fire.hpBefore - fire.damage); const afterImpact = () => { if (targetObj.hpText && targetObj.scene) targetObj.hpText.setText(`${hpAfter}`); if (fire.target.currentHP <= 0 && !fire.targetIsCommander) { const hlEvent = this._pendingHiveLinks?.[fire.target.instanceId]; if (hlEvent) delete this._pendingHiveLinks[fire.target.instanceId]; this._playCardDeath(targetObj, fire.target, hlEvent ? () => this._animateHiveLink(hlEvent, () => animateNext(idx + 1, targetObj.x, targetObj.y)) : () => animateNext(idx + 1, targetObj.x, targetObj.y) ); } else if (fire.carapaceGain > 0 && targetObj.scene) { this._animateCarapaceEffect(targetObj, fire.carapaceGain, () => { animateNext(idx + 1, targetObj.x, targetObj.y); }); } else { animateNext(idx + 1, targetObj.x, targetObj.y); } }; if (fire.damage > 0) { if (hpAfter > 0) this.sound.play('sfx_damage', { volume: 0.8 }); targetObj.animateHPLoss(fire.damage, afterImpact, fire.hpBefore, hpAfter); } else { afterImpact(); } } }); }; // Start from the attacker's position animateNext(0, sourceObj.x, sourceObj.y); } _playSkillExplosion(x, y, onComplete) { const BASE_SCALE = 160 / 460; const sprite = this.add.sprite(x, y, 'attacks', 12) .setScale(0.05) .setAngle(0) .setDepth(35); // Phase 1 — grow and spin into existence this.tweens.add({ targets: sprite, scaleX: BASE_SCALE * 1.5, scaleY: BASE_SCALE * 1.5, angle: 270, duration: 420, ease: 'Cubic.Out', onComplete: () => { if (!sprite.scene) { onComplete(); return; } // Phase 2 — expand slightly and fade out this.tweens.add({ targets: sprite, scaleX: BASE_SCALE * 2.0, scaleY: BASE_SCALE * 2.0, angle: 360, alpha: 0, duration: 320, ease: 'Power2', onComplete: () => { if (sprite.scene) sprite.destroy(); onComplete(); } }); } }); } // Animate a single attack. // Cards slide to center (attacker left, defender right), scale up, hold while // particle burst + shake + damage number play, then scale down and slide back. _animateAttack(event, onComplete, berserkEvent = null, onImpact = null) { const SCALE = 1.78; const SCALE_UP_MS = 280; const HOLD_MS = 3100; const SCALE_DN_MS = 280; const ATTACK_MS = SCALE_UP_MS + HOLD_MS + SCALE_DN_MS; // 1660 ms const { width, height } = this.scale; const cardW = 260; const enlargedW = cardW * SCALE; // ~463px at 1.78× const gap = enlargedW * 0.75; // ~347px between enlarged cards const attackerDestX = width / 2 - gap / 2 - enlargedW / 2; // ~554 const defenderDestX = width / 2 + gap / 2 + enlargedW / 2; // ~1366 const centerY = height / 2; // 540 const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id)); const attackerObj = _lookup(event.attacker.instanceId); const defenderObj = _lookup(event.defender?.instanceId); // Save original positions so we can slide cards back afterward const attackerOrigX = attackerObj?.x; const attackerOrigY = attackerObj?.y; const defenderOrigX = defenderObj?.x; const defenderOrigY = defenderObj?.y; const attName = event.attacker.name; const defName = event.defender?.name ?? 'Commander'; this.statusText.setText(`${attName} ⚔ ${defName} — ${event.damage} damage!`); // ── Slide to center + scale up, hold, slide back + scale down ──────────── const enlargeCard = (obj, cardRef, destX, origX, origY) => { if (!obj) return; obj.setDepth(20); // Scale up (Back.Out for the snappy overshoot feel) this.tweens.add({ targets: obj, scaleX: SCALE, scaleY: SCALE, duration: SCALE_UP_MS, ease: 'Back.Out' }); // Slide to battle center (smooth Power2) this.tweens.add({ targets: obj, x: destX, y: centerY, duration: SCALE_UP_MS, ease: 'Power2.inOut', onComplete: () => { this.time.delayedCall(HOLD_MS, () => { if (!obj.scene) return; // Dead cards stay at center — _refreshCardAfterAttack handles their death if (cardRef.currentHP > 0) { this.tweens.add({ targets: obj, scaleX: 1, scaleY: 1, duration: SCALE_DN_MS, ease: 'Power2', onComplete: () => { if (obj.scene) obj.setDepth(0); } }); this.tweens.add({ targets: obj, x: origX, y: origY, duration: SCALE_DN_MS, ease: 'Power2' }); } }); } }); }; enlargeCard(attackerObj, event.attacker, attackerDestX, attackerOrigX, attackerOrigY); enlargeCard(defenderObj, event.defender ?? { currentHP: 1 }, defenderDestX, defenderOrigX, defenderOrigY); // ── VS image — appears between cards once they arrive at center ─────────── let vsImage = null; this.time.delayedCall(SCALE_UP_MS, () => { if (!this.textures.exists('vs')) return; vsImage = this.add.image(width / 2, centerY, 'vs') .setDisplaySize(Math.round(gap * 0.85), Math.round(gap * 0.85)) .setDepth(25) .setAlpha(0); this.tweens.add({ targets: vsImage, alpha: 1, duration: 150, ease: 'Power2' }); }); this.time.delayedCall(SCALE_UP_MS + HOLD_MS, () => { if (!vsImage?.scene) return; this.tweens.add({ targets: vsImage, alpha: 0, duration: SCALE_DN_MS, ease: 'Power2', onComplete: () => { if (vsImage?.scene) vsImage.destroy(); } }); }); // ── Attacker: attack sprite + particle burst — fires once card arrives at center ── if (attackerObj) { const enlargedH = 364 * SCALE; this.time.delayedCall(SCALE_UP_MS, () => { if (!attackerObj.scene) return; // Gun turret animation centered on the attacking card const attackSprite = this.add.sprite( attackerDestX, centerY, 'attacks' ).setDisplaySize(480, 480).setDepth(22); attackSprite.play('attack_anim'); this.sound.play('sfx_attack', { volume: 0.7 }); attackSprite.once('animationcomplete', () => { if (attackSprite.scene) attackSprite.destroy(); this._onAttackReconcile(event, berserkEvent); if (onImpact) onImpact(); }); const emitter = this.add.particles(attackerDestX, centerY, 'particle_dot', { speed: { min: 90, max: 300 }, scale: { start: 1.8, end: 0 }, alpha: { start: 1, end: 0 }, lifespan: 650, tint: [0xff8800, 0xffee00, 0xffffff, 0x88ccff, 0xff44ff], blendMode: 'ADD', emitZone: { type: 'edge', source: new Phaser.Geom.Rectangle(-enlargedW / 2, -enlargedH / 2, enlargedW, enlargedH), quantity: 56 }, stopAfter: 56 }); this.time.delayedCall(800, () => { if (emitter?.scene) emitter.destroy(); }); }); } // ── Defender: shake + red flash + HP update + floating damage number ────── // Fires once both cards are fully enlarged at center this.time.delayedCall(SCALE_UP_MS + 80, () => { if (defenderObj?.scene) { // Refresh stats (ATK, ARM, DLY) but preserve the HP text that was already // set by the preAttack animation or by animateHPLoss (which fired 80ms ago // in _onAttackReconcile). Overwriting HP here would corrupt the in-progress // HP loss animation and prematurely show the final HP on commanders. const savedHP = defenderObj.hpText?.text; defenderObj.refresh(); if (savedHP !== undefined && defenderObj.hpText?.scene) { defenderObj.hpText.setText(savedHP); } // Flash red overlay → fade out defenderObj.bg.setAlpha(0.70); this.tweens.add({ targets: defenderObj.bg, alpha: 0, duration: 600, ease: 'Power2' }); // Shake around the defender's center position const shakeX = defenderObj.x; this.tweens.add({ targets: defenderObj, x: { from: shakeX - 26, to: shakeX + 26 }, duration: 60, yoyo: true, repeat: 6, ease: 'Linear', onComplete: () => { if (defenderObj.scene) defenderObj.x = shakeX; } }); } // Floating damage number above the defender at center const dmgAnchorX = defenderDestX; const dmgAnchorY = centerY; const dmgText = this.add.text( dmgAnchorX, dmgAnchorY - 100, `-${event.damage}`, { fontSize: '56px', color: '#ff3333', stroke: '#000000', strokeThickness: 5, fontFamily: 'RaiderCrusader' } ).setOrigin(0.5).setDepth(30); this.tweens.add({ targets: dmgText, y: dmgAnchorY - 250, alpha: 0, duration: 1300, ease: 'Power2', onComplete: () => dmgText.destroy() }); }); this.time.delayedCall(ATTACK_MS, onComplete); } // Drives the preBattle sequence (including venom ticks) before attacks begin. _processPreBattle(events, onComplete) { const processStep = (idx) => { if (idx >= events.length) { onComplete(); return; } if (events[idx].type === 'venomTick') { this._animateVenomTick(events[idx], () => processStep(idx + 1)); return; } if (events[idx].type === 'smiteTick') { this._animateSmiteTick(events[idx], () => processStep(idx + 1)); return; } if (events[idx].type === 'carapace') { this._animateCarapace(events[idx], () => processStep(idx + 1)); return; } if (events[idx].type === 'molt') { this._animateMolt(events[idx], () => processStep(idx + 1)); return; } // hive_link events are animated inline by _animateVenomTick/SmiteTick (for kills) — skip here if (events[idx].type === 'hive_link') { processStep(idx + 1); return; } this._onPreBattleStep(events[idx], () => processStep(idx + 1)); }; processStep(0); } // Called once per preBattle step. Handles buff animations, siege fires, protect fires, then enfeeble fires. _onPreBattleStep(event, onComplete) { const hasBuffs = event.buffs?.length > 0; const hasSiege = event.siegeFires?.length > 0; const hasProtect = event.protectFires?.length > 0; const hasEnfeeble = event.enfeebeFires?.length > 0; const hasJam = event.jamFires?.length > 0; const hasDrain = event.drainFires?.length > 0; const hasHeal = event.healFires?.length > 0; const hasSanctify = event.sanctifyFires?.length > 0; const hasOvercharge = event.overchargeFires?.length > 0; const hasFortify = event.fortifyFires?.length > 0; if (hasBuffs || hasSiege || hasProtect || hasEnfeeble || hasJam || hasDrain || hasHeal || hasSanctify || hasOvercharge || hasFortify) { this._processSanctifyFires(event.sanctifyFires || [], () => { this._processDrainFires(event.drainFires || [], () => { this._processOverchargeFires(event.overchargeFires || [], () => { this._processBuffAnimations(event.buffs || [], () => { this._processFortifyFires(event.fortifyFires || [], () => { this._processSiegeFires(event.siegeFires || [], () => { this._processProtectFires(event.protectFires || [], () => { this._processEnfeebeFires(event.enfeebeFires || [], () => { this._processJamFires(event.jamFires || [], () => { this._processHealFires(event.healFires || [], onComplete); }); }); }); }); }); }); }); }); }); } else { onComplete(); } } _processSiegeFires(fires, onComplete) { const next = (idx) => { if (idx >= fires.length) { onComplete(); return; } this._animateSiegeFire(fires[idx], () => next(idx + 1)); }; next(0); } _processProtectFires(fires, onComplete) { const next = (idx) => { if (idx >= fires.length) { onComplete(); return; } this._animateProtectBuff(fires[idx], () => next(idx + 1)); }; next(0); } _processEnfeebeFires(fires, onComplete) { // Group consecutive "all" enfeeble fires for parallel animation const groups = []; for (const fire of fires) { if (fire.isAll) { const last = groups[groups.length - 1]; if (last?.isAll) { last.items.push(fire); } else { groups.push({ isAll: true, items: [fire] }); } } else { groups.push(fire); } } const next = (idx) => { if (idx >= groups.length) { onComplete(); return; } const group = groups[idx]; if (group.isAll) { this._animateEnfeebleAll(group.items, () => next(idx + 1)); } else { this._animateEnfeebeFire(group, () => next(idx + 1)); } }; next(0); } _processJamFires(fires, onComplete) { const next = (idx) => { if (idx >= fires.length) { onComplete(); return; } this._animateJamFire(fires[idx], () => next(idx + 1)); }; next(0); } _processBloodpactFires(fires, onComplete) { const next = (idx) => { if (idx >= fires.length) { onComplete(); return; } this._animateBloodpactFire(fires[idx], () => next(idx + 1)); }; next(0); } _animateBloodpactFire(attacker, bloodpactFire, onComplete) { const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id)); const sourceObj = _lookup(attacker.instanceId); if (!sourceObj?.scene) { onComplete(); return; } const atkGain = bloodpactFire.value; const hpCost = bloodpactFire.hpCost ?? bloodpactFire.value; this.statusText.setText(`${attacker.name} blood pact: -${hpCost} HP, +${atkGain} ATK!`); this.sound.play('sfx_bloodpact', { volume: 0.6 }); // Bloodpact sprite (frame 24) overlaid on the card const BASE_SCALE = 160 / 460; const sprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 24) .setScale(BASE_SCALE) .setDepth(35) .setAlpha(1); // Red glow around the sprite if (sprite.postFX) { sprite.postFX.addGlow(0xff0000, 10, 0, false, 0.2, 16); } // Red flash on card sourceObj.flash(0xff0000); // Pulse the sprite larger and back 3 times while stats animate const pulseScale = BASE_SCALE * 1.6; this.tweens.chain({ targets: sprite, tweens: [ { scaleX: pulseScale, scaleY: pulseScale, duration: 180, ease: 'Quad.Out' }, { scaleX: BASE_SCALE, scaleY: BASE_SCALE, duration: 180, ease: 'Quad.In' }, { scaleX: pulseScale, scaleY: pulseScale, duration: 180, ease: 'Quad.Out' }, { scaleX: BASE_SCALE, scaleY: BASE_SCALE, duration: 180, ease: 'Quad.In' }, { scaleX: pulseScale, scaleY: pulseScale, duration: 180, ease: 'Quad.Out' }, { scaleX: BASE_SCALE, scaleY: BASE_SCALE, duration: 180, ease: 'Quad.In' } ] }); // After first pulse, refresh stats and show floating numbers this.time.delayedCall(200, () => { if (!sourceObj.scene) return; sourceObj.refresh(); // Floating -HP (red, offset left) const hpLabel = this.add.text(sourceObj.x - 28, sourceObj.y + 20, `-${hpCost} HP`, { fontSize: '16px', color: '#ff4444', fontStyle: 'bold', fontFamily: 'Audiowide', stroke: '#000000', strokeThickness: 3 }).setOrigin(0.5).setDepth(50); this.tweens.add({ targets: hpLabel, y: hpLabel.y - 50, alpha: 0, duration: 700, ease: 'Power2', onComplete: () => hpLabel.destroy() }); // Floating +ATK (orange, offset right) const atkLabel = this.add.text(sourceObj.x + 28, sourceObj.y + 20, `+${atkGain} ATK`, { fontSize: '16px', color: '#ffaa00', fontStyle: 'bold', fontFamily: 'Audiowide', stroke: '#000000', strokeThickness: 3 }).setOrigin(0.5).setDepth(50); this.tweens.add({ targets: atkLabel, y: atkLabel.y - 50, alpha: 0, duration: 700, ease: 'Power2', onComplete: () => atkLabel.destroy() }); }); // Fade sprite out after pulses complete (~1.1s), then resolve this.time.delayedCall(1100, () => { if (!sprite.scene) return; this.tweens.add({ targets: sprite, alpha: 0, duration: 250, onComplete: () => { if (sprite.scene) sprite.destroy(); if (!sourceObj.scene) { onComplete(); return; } if (attacker.currentHP <= 0) { this._playCardDeath(sourceObj, attacker, onComplete); } else { onComplete(); } } }); }); } _processDrainFires(fires, onComplete) { const next = (idx) => { if (idx >= fires.length) { onComplete(); return; } this._animateDrainFire(fires[idx].source, fires[idx], () => next(idx + 1)); }; next(0); } // Drain animation: reuse strike missile pattern with red/purple tint, then heal self // Drain animation: // 1. Sprite 22 flies from attacker to primary target, HP loss on primary // 2. 2/3-scale copies fly left/right to neighbors, HP loss on each // 3. Green emitters fly from all affected cards back to attacker, heal + refresh _animateDrainFire(attacker, drainFire, onComplete) { const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id)); const sourceObj = _lookup(attacker.instanceId); const targetObj = _lookup(drainFire.target.instanceId); if (!sourceObj?.scene || !targetObj?.scene) { onComplete(); return; } this.sound.play('sfx_drain', { volume: 0.8 }); // Correct target HP display to pre-drain value if (targetObj.hpText && targetObj.scene) { targetObj.hpText.setText(`${Math.max(0, drainFire.hpBefore)}`); } this.statusText.setText(`${attacker.name} drains ${drainFire.target.name}!`); const BASE_SCALE = 160 / 460; const SEC_SCALE = BASE_SCALE * (2 / 3); // Place drain sprite (frame 22) on the attacking card const drainSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 22) .setScale(BASE_SCALE) .setDepth(30); // Fly to the primary target this.tweens.add({ targets: drainSprite, x: targetObj.x, y: targetObj.y, duration: 400, ease: 'Cubic.easeIn', onComplete: () => { if (!targetObj.scene) { if (drainSprite.scene) drainSprite.destroy(); onComplete(); return; } // Apply HP loss on primary target const hpAfter = Math.max(0, drainFire.hpBefore - drainFire.damage); if (drainFire.damage > 0) { if (hpAfter > 0) this.sound.play('sfx_damage', { volume: 0.8 }); targetObj.animateHPLoss(drainFire.damage, null, drainFire.hpBefore, hpAfter); } // Collect all affected card objects for the heal-return phase const affectedCards = [{ obj: targetObj, data: drainFire.target, damage: drainFire.damage, hpAfter }]; // Spawn secondary sprites to left/right neighbors const secondaries = drainFire.secondaries || []; let secondariesDone = 0; const totalSecondaries = secondaries.filter(s => _lookup(s.target.instanceId)?.scene).length; const onAllSecondariesLanded = () => { // Brief pause, then fade primary sprite this.time.delayedCall(300, () => { if (drainSprite.scene) { this.tweens.add({ targets: drainSprite, alpha: 0, duration: 200, ease: 'Power2', onComplete: () => { if (drainSprite.scene) drainSprite.destroy(); } }); } // Handle deaths, then send green emitters back this._drainProcessDeaths(drainFire, affectedCards, () => { this._drainHealReturn(sourceObj, attacker, affectedCards, drainFire.heal, onComplete); }); }); }; if (totalSecondaries === 0) { onAllSecondariesLanded(); } else { for (const sec of secondaries) { const secObj = _lookup(sec.target.instanceId); if (!secObj?.scene) continue; // Correct secondary HP display if (secObj.hpText && secObj.scene) { secObj.hpText.setText(`${Math.max(0, sec.hpBefore)}`); } // Spawn 2/3-scale copy at primary target position, fly to neighbor const secSprite = this.add.sprite(targetObj.x, targetObj.y, 'attacks', 22) .setScale(SEC_SCALE) .setDepth(30); this.tweens.add({ targets: secSprite, x: secObj.x, y: secObj.y, duration: 300, ease: 'Cubic.easeIn', onComplete: () => { // Fade out secondary sprite this.tweens.add({ targets: secSprite, alpha: 0, duration: 200, ease: 'Power2', onComplete: () => { if (secSprite.scene) secSprite.destroy(); } }); // HP loss on secondary const secHpAfter = Math.max(0, sec.hpBefore - sec.damage); if (sec.damage > 0 && secObj.scene) { if (secHpAfter > 0) this.sound.play('sfx_damage', { volume: 0.6 }); secObj.animateHPLoss(sec.damage, null, sec.hpBefore, secHpAfter); } affectedCards.push({ obj: secObj, data: sec.target, damage: sec.damage, hpAfter: secHpAfter }); if (++secondariesDone >= totalSecondaries) { onAllSecondariesLanded(); } } }); } } } }); } // Handle deaths for all cards hit by drain before the heal-return phase. _drainProcessDeaths(drainFire, affectedCards, onComplete) { let pending = 0; let anyDeath = false; for (const ac of affectedCards) { if (ac.data.currentHP <= 0 && ac.obj.scene && !ac.obj.isCommander) { anyDeath = true; pending++; this._playCardDeath(ac.obj, ac.data, () => { const hlEvent = this._preBattleHiveLinks?.[ac.data.instanceId]; if (hlEvent) delete this._preBattleHiveLinks[ac.data.instanceId]; const afterAll = () => { if (--pending === 0) onComplete(); }; hlEvent ? this._animateHiveLink(hlEvent, afterAll) : afterAll(); }); } } if (!anyDeath) onComplete(); } // Send bright green emitters from each affected card back to the drain source, then heal. _drainHealReturn(sourceObj, attacker, affectedCards, healAmount, onComplete) { if (healAmount <= 0 || !sourceObj?.scene) { onComplete(); return; } // Filter to cards still on scene (alive cards that were drained) const sources = affectedCards.filter(ac => ac.obj?.scene && ac.damage > 0); if (sources.length === 0) { // Just refresh and finish if (sourceObj.scene) sourceObj.refresh(); onComplete(); return; } let emittersReturned = 0; const onAllReturned = () => { // Green flash + HP refresh on the drain card if (sourceObj.scene) { sourceObj.flash(0x00ff00); this.time.delayedCall(200, () => { if (sourceObj.scene) sourceObj.refresh(); onComplete(); }); } else { onComplete(); } }; for (const ac of sources) { const startX = ac.obj.x; const startY = ac.obj.y; const endX = sourceObj.x; const endY = sourceObj.y; // Green particle emitter that flies back to source const emitter = this.add.particles(startX, startY, 'particle_dot', { speed: { min: 10, max: 40 }, scale: { start: 0.7, end: 0 }, alpha: { start: 1, end: 0 }, lifespan: 300, tint: [0x00ff00, 0x44ff44, 0x88ff88, 0x00cc00], blendMode: 'ADD', frequency: 20 }).setDepth(29); const progress = { t: 0 }; this.tweens.add({ targets: progress, t: 1, duration: 500, ease: 'Sine.inOut', onUpdate: () => { const sin = Math.sin(progress.t * Math.PI); const px = startX + (endX - startX) * progress.t; const py = startY + (endY - startY) * progress.t + (-120) * sin; if (emitter.scene) emitter.setPosition(px, py); }, onComplete: () => { emitter.stop(); this.time.delayedCall(300, () => { if (emitter.scene) emitter.destroy(); }); if (++emittersReturned >= sources.length) { onAllReturned(); } } }); } } // Bloodrage animation: red pulse on card + ATK gain text // Siphon animation: sprite 21 flies from attacker to card across, // stays ~500ms with glow + particles, then fades out. Heals attacker after. _animateSiphonFire(attacker, siphonFire, onComplete) { const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id)); const sourceObj = _lookup(attacker.instanceId); if (!sourceObj?.scene) { onComplete(); return; } if (siphonFire.heal <= 0) { onComplete(); return; } this.sound.play('sfx_siphon', { volume: 0.8 }); this.statusText.setText(`${attacker.name} siphons +${siphonFire.heal} HP!`); const BASE_SCALE = 160 / 460; const PULSE_SCALE = BASE_SCALE * 1.55; const PULSE_DUR = 220; const sprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 21) .setScale(BASE_SCALE) .setDepth(35); // Blood-red glow const glow = sprite.postFX.addGlow(0xcc0000, 6, 0); this.tweens.add({ targets: glow, outerStrength: 18, duration: 300, yoyo: true, repeat: -1, ease: 'Sine.InOut' }); // Pulse up and back 3 times, then heal and fade this.tweens.chain({ targets: sprite, tweens: [ { scaleX: PULSE_SCALE, scaleY: PULSE_SCALE, duration: PULSE_DUR, ease: 'Quad.Out' }, { scaleX: BASE_SCALE, scaleY: BASE_SCALE, duration: PULSE_DUR, ease: 'Quad.In' }, { scaleX: PULSE_SCALE, scaleY: PULSE_SCALE, duration: PULSE_DUR, ease: 'Quad.Out' }, { scaleX: BASE_SCALE, scaleY: BASE_SCALE, duration: PULSE_DUR, ease: 'Quad.In' }, { scaleX: PULSE_SCALE, scaleY: PULSE_SCALE, duration: PULSE_DUR, ease: 'Quad.Out' }, { scaleX: BASE_SCALE, scaleY: BASE_SCALE, duration: PULSE_DUR, ease: 'Quad.In' }, ], onComplete: () => { // Green flash + HP refresh on the attacker if (sourceObj.scene) { sourceObj.flash(0x00ff00); this.time.delayedCall(150, () => { if (sourceObj.scene) sourceObj.refresh(); }); } // Fade sprite out this.tweens.add({ targets: sprite, alpha: 0, duration: 250, onComplete: () => { if (sprite.scene) sprite.destroy(); onComplete(); } }); } }); } // ── Bloodrage — frame 23 sprite pulses over card 3×, red glow, ATK gain text ── _animateBloodrageBuff(buff, onComplete) { const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id)); const cardObj = _lookup(buff.target.instanceId); if (!cardObj?.scene) { onComplete(); return; } this.statusText.setText(`${buff.source.name} enters a blood rage! +${buff.amount} ATK`); this.sound.play('sfx_bloodrage', { volume: 0.8 }); const BASE_SCALE = 160 / 460; const PULSE_SCALE = BASE_SCALE * 1.6; const PULSE_DUR = 200; const sprite = this.add.sprite(cardObj.x, cardObj.y, 'attacks', 23) .setScale(BASE_SCALE) .setDepth(35) .setAlpha(0); // Red pulsing glow const glow = sprite.postFX.addGlow(0xff2200, 8, 0); this.tweens.add({ targets: glow, outerStrength: 22, duration: 250, yoyo: true, repeat: -1, ease: 'Sine.InOut' }); // Fade in, then pulse 3×, then show ATK gain and fade out this.tweens.add({ targets: sprite, alpha: 1, duration: 120, onComplete: () => { // Show ATK floating text; refresh so ATK text shows post-buff value after animation cardObj.animateBerserkGain(buff.amount, () => { if (cardObj.scene) cardObj.refresh(); }); this.tweens.chain({ targets: sprite, tweens: [ { scaleX: PULSE_SCALE, scaleY: PULSE_SCALE, duration: PULSE_DUR, ease: 'Quad.Out' }, { scaleX: BASE_SCALE, scaleY: BASE_SCALE, duration: PULSE_DUR, ease: 'Quad.In' }, { scaleX: PULSE_SCALE, scaleY: PULSE_SCALE, duration: PULSE_DUR, ease: 'Quad.Out' }, { scaleX: BASE_SCALE, scaleY: BASE_SCALE, duration: PULSE_DUR, ease: 'Quad.In' }, { scaleX: PULSE_SCALE, scaleY: PULSE_SCALE, duration: PULSE_DUR, ease: 'Quad.Out' }, { scaleX: BASE_SCALE, scaleY: BASE_SCALE, duration: PULSE_DUR, ease: 'Quad.In' }, ], onComplete: () => { if (cardObj.scene) cardObj.flash(0xff2200); this.tweens.add({ targets: sprite, alpha: 0, scaleX: BASE_SCALE * 1.4, scaleY: BASE_SCALE * 1.4, duration: 300, ease: 'Power2', onComplete: () => { if (sprite.scene) sprite.destroy(); onComplete(); } }); } }); } }); } _processBuffAnimations(buffs, onComplete) { // Group consecutive "all" rally buffs for parallel animation const groups = []; for (const buff of buffs) { if (buff.isAll && buff.skill === 'rally') { const last = groups[groups.length - 1]; if (last?.isAll && last.skill === 'rally') { last.items.push(buff); } else { groups.push({ skill: 'rally', items: [buff], isAll: true }); } } else { groups.push(buff); } } const next = (idx) => { if (idx >= groups.length) { onComplete(); return; } const group = groups[idx]; if (group.isAll && group.skill === 'rally') { this._animateRallyAll(group.items, () => next(idx + 1)); } else if (group.skill === 'rally') { this._animateRallyBuff(group, () => next(idx + 1)); } else if (group.skill === 'legion') { this._animateLegionBuff(group, () => next(idx + 1)); } else if (group.skill === 'swarm') { this._animateSwarmBuff(group, () => next(idx + 1)); } else if (group.skill === 'bloodrage') { this._animateBloodrageBuff(group, () => next(idx + 1)); } else { next(idx + 1); } }; next(0); } _animateRallyBuff(buff, onComplete) { const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id)); const sourceObj = _lookup(buff.source.instanceId); const targetObj = _lookup(buff.target.instanceId); if (!sourceObj?.scene || !targetObj?.scene) { onComplete(); return; } this.sound.play('sfx_rally', { volume: 0.8 }); this.statusText.setText(`${buff.source.name} rallies ${buff.target.name}! +${buff.amount} ATK`); // Base scale for 160px display from 460px source frame const BASE_SCALE = 160 / 460; // Flag sprite placed on the source card const flagSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 10) .setScale(BASE_SCALE) .setDepth(30); // Pulsing glow on the flag const glow = flagSprite.postFX.addGlow(0xffdd00, 8, 0); this.tweens.add({ targets: glow, outerStrength: 18, duration: 300, yoyo: true, repeat: -1, ease: 'Sine.inOut' }); // Particle emitter — position synced manually via onUpdate so it tracks the moving flag const emitter = this.add.particles(sourceObj.x, sourceObj.y, 'particle_dot', { speed: { min: 20, max: 70 }, scale: { start: 0.7, end: 0 }, alpha: { start: 1, end: 0 }, lifespan: 350, tint: [0xffdd00, 0xffaa00, 0xffffff], blendMode: 'ADD', frequency: 25 }).setDepth(29); // Arc flight — tween a progress value and compute position + scale each frame const startX = sourceObj.x; const startY = sourceObj.y; const endX = targetObj.x; const endY = targetObj.y; const ARC_HEIGHT = -200; // px upward at the midpoint const progress = { t: 0 }; this.tweens.add({ targets: progress, t: 1, duration: 700, ease: 'Sine.inOut', onUpdate: () => { const sin = Math.sin(progress.t * Math.PI); flagSprite.x = startX + (endX - startX) * progress.t; flagSprite.y = startY + (endY - startY) * progress.t + ARC_HEIGHT * sin; flagSprite.setScale(BASE_SCALE * (1 + 0.55 * sin)); // swells in the middle if (emitter.scene) emitter.setPosition(flagSprite.x, flagSprite.y); }, onComplete: () => { flagSprite.setScale(BASE_SCALE); emitter.stop(); this.time.delayedCall(300, () => { if (emitter.scene) emitter.destroy(); }); if (!targetObj.scene) { if (flagSprite.scene) flagSprite.destroy(); onComplete(); return; } // Reuse berserk animation to show the ATK boost targetObj.atkText.setText(`${buff.target.currentAttack - buff.amount}`); targetObj.animateBerserkGain(buff.amount, () => { this.tweens.add({ targets: flagSprite, alpha: 0, scaleX: BASE_SCALE * 1.5, scaleY: BASE_SCALE * 1.5, duration: 300, ease: 'Power2', onComplete: () => { if (flagSprite.scene) flagSprite.destroy(); onComplete(); } }); }); } }); } // ── Legion — flexing arm sprite cycles 3× over card, blue glow, ATK gain text ── _animateLegionBuff(buff, onComplete) { const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id)); const cardObj = _lookup(buff.target.instanceId); if (!cardObj?.scene) { onComplete(); return; } this.statusText.setText(`${buff.source.name} legion: +${buff.amount} ATK from allies!`); this.sound.play('sfx_legion', { volume: 0.8 }); // Show ATK gain floating text immediately; refresh so ATK text shows post-buff value cardObj.animateBerserkGain(buff.amount, () => { if (cardObj.scene) cardObj.refresh(); }); // Sprite animation: frames 25 (arm down) and 26 (arm up) cycling 3× over ~3s // 3 cycles × 2 frames = 6 swaps; each frame shows for ~500ms → total ~3s const FRAME_DUR = 480; const BASE_SCALE = 160 / 460; const sprite = this.add.sprite(cardObj.x, cardObj.y, 'attacks', 25) .setScale(BASE_SCALE * 1.1) .setDepth(35) .setAlpha(0); // Blue glow const glow = sprite.postFX.addGlow(0x4488ff, 8, 0); this.tweens.add({ targets: glow, outerStrength: 20, duration: 400, yoyo: true, repeat: -1, ease: 'Sine.InOut' }); // Fade in, then cycle frames, then fade out this.tweens.add({ targets: sprite, alpha: 1, duration: 150, onComplete: () => { let frame = 25; let cycles = 0; const totalFlips = 6; // 3 full cycles × 2 frames each const flip = () => { if (!sprite.scene) { onComplete(); return; } frame = frame === 25 ? 26 : 25; sprite.setFrame(frame); cycles++; if (cycles < totalFlips) { this.time.delayedCall(FRAME_DUR, flip); } else { // All cycles done — fade out sprite then call complete this.tweens.add({ targets: sprite, alpha: 0, duration: 250, delay: FRAME_DUR, onComplete: () => { if (sprite.scene) sprite.destroy(); onComplete(); } }); } }; this.time.delayedCall(FRAME_DUR, flip); } }); } // ── "all" rally — parallel arc flights to all allies, sound plays once ── _animateRallyAll(buffs, onComplete) { if (!buffs.length) { onComplete(); return; } this.sound.play('sfx_rally', { volume: 0.8 }); this.statusText.setText(`${buffs[0].source.name} rallies all allies!`); let remaining = buffs.length; const done = () => { if (--remaining <= 0) onComplete(); }; for (const buff of buffs) { this._animateRallyBuffNoSound(buff, done); } } _animateRallyBuffNoSound(buff, onComplete) { const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id)); const sourceObj = _lookup(buff.source.instanceId); const targetObj = _lookup(buff.target.instanceId); if (!sourceObj?.scene || !targetObj?.scene) { onComplete(); return; } const BASE_SCALE = 160 / 460; const flagSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 10) .setScale(BASE_SCALE).setDepth(30); const glow = flagSprite.postFX.addGlow(0xffdd00, 8, 0); this.tweens.add({ targets: glow, outerStrength: 18, duration: 300, yoyo: true, repeat: -1, ease: 'Sine.inOut' }); const emitter = this.add.particles(sourceObj.x, sourceObj.y, 'particle_dot', { speed: { min: 20, max: 70 }, scale: { start: 0.7, end: 0 }, alpha: { start: 1, end: 0 }, lifespan: 350, tint: [0xffdd00, 0xffaa00, 0xffffff], blendMode: 'ADD', frequency: 25 }).setDepth(29); const startX = sourceObj.x, startY = sourceObj.y; const endX = targetObj.x, endY = targetObj.y; const ARC_HEIGHT = -200; const progress = { t: 0 }; this.tweens.add({ targets: progress, t: 1, duration: 700, ease: 'Sine.inOut', onUpdate: () => { const sin = Math.sin(progress.t * Math.PI); flagSprite.x = startX + (endX - startX) * progress.t; flagSprite.y = startY + (endY - startY) * progress.t + ARC_HEIGHT * sin; flagSprite.setScale(BASE_SCALE * (1 + 0.55 * sin)); if (emitter.scene) emitter.setPosition(flagSprite.x, flagSprite.y); }, onComplete: () => { flagSprite.setScale(BASE_SCALE); emitter.stop(); this.time.delayedCall(300, () => { if (emitter.scene) emitter.destroy(); }); if (!targetObj.scene) { if (flagSprite.scene) flagSprite.destroy(); onComplete(); return; } targetObj.atkText.setText(`${buff.target.currentAttack - buff.amount}`); targetObj.animateBerserkGain(buff.amount, () => { this.tweens.add({ targets: flagSprite, alpha: 0, scaleX: BASE_SCALE * 1.5, scaleY: BASE_SCALE * 1.5, duration: 300, ease: 'Power2', onComplete: () => { if (flagSprite.scene) flagSprite.destroy(); onComplete(); } }); }); } }); } // Animate siege: overlay Siege Launcher (sprite 13) on the source card, then launch // siege missiles (sprite 14) in an arc to the enemy commander with explosion on impact. _animateSiegeFire(siegeFire, onComplete) { const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id)); const sourceObj = _lookup(siegeFire.source.instanceId); const targetObj = _lookup(siegeFire.target.instanceId); if (!sourceObj?.scene || !targetObj?.scene) { onComplete(); return; } // Correct the commander's HP display to pre-siege value immediately if (targetObj.hpText && targetObj.scene) { targetObj.hpText.setText(`${Math.max(0, siegeFire.hpBefore)}`); } this.statusText.setText(`${siegeFire.source.name} launches siege missiles at ${siegeFire.target.name}!`); const BASE_SCALE = 160 / 460; // Overlay the Siege Launcher sprite (13) on top of the attacking card const launcherSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 13) .setScale(BASE_SCALE) .setDepth(30); // Hold the launcher visible briefly, then fire the missiles this.time.delayedCall(600, () => { if (!launcherSprite.scene) { onComplete(); return; } const startX = sourceObj.x, startY = sourceObj.y; const endX = targetObj.x, endY = targetObj.y; const ARC_HEIGHT = -160; const progress = { t: 0 }; this.sound.play('sfx_siege', { volume: 0.8 }); // Siege missile sprite (14) — aimed at commander, points up by default const missileSprite = this.add.sprite(startX, startY, 'attacks', 14) .setScale(BASE_SCALE) .setDepth(31); const angle = Phaser.Math.Angle.Between(startX, startY, endX, endY); missileSprite.rotation = angle + Math.PI / 2; // Orange-red particle trail const emitter = this.add.particles(startX, startY, 'particle_dot', { speed: { min: 20, max: 70 }, scale: { start: 0.5, end: 0 }, alpha: { start: 1, end: 0 }, lifespan: 300, tint: [0xff4400, 0xff8800, 0xffcc00], blendMode: 'ADD', frequency: 25 }).setDepth(30); this.tweens.add({ targets: progress, t: 1, duration: 550, ease: 'Quad.easeIn', onUpdate: () => { const sin = Math.sin(progress.t * Math.PI); missileSprite.x = startX + (endX - startX) * progress.t; missileSprite.y = startY + (endY - startY) * progress.t + ARC_HEIGHT * sin; if (emitter.scene) emitter.setPosition(missileSprite.x, missileSprite.y); }, onComplete: () => { emitter.stop(); this.time.delayedCall(250, () => { if (emitter.scene) emitter.destroy(); }); if (missileSprite.scene) missileSprite.destroy(); if (launcherSprite.scene) launcherSprite.destroy(); if (!targetObj.scene) { onComplete(); return; } // Explosion on impact, then resolve HP loss on commander this._playSkillExplosion(targetObj.x, targetObj.y, () => { if (!targetObj.scene) { onComplete(); return; } const hpAfter = Math.max(0, siegeFire.hpBefore - siegeFire.damage); const afterImpact = () => { if (targetObj.hpText && targetObj.scene) targetObj.hpText.setText(`${hpAfter}`); onComplete(); }; if (siegeFire.damage > 0) { if (hpAfter > 0) this.sound.play('sfx_damage', { volume: 0.8 }); targetObj.animateHPLoss(siegeFire.damage, afterImpact, siegeFire.hpBefore, hpAfter); } else { afterImpact(); } }); } }); }); } // Animate protect: full-size shield rises on source card; 2/3-size shields fly to neighbors. // After all shields settle, animate ARM gain on each affected card. _animateProtectBuff(protectFire, onComplete) { const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id)); const sourceObj = _lookup(protectFire.source.instanceId); if (!sourceObj?.scene) { onComplete(); return; } this.sound.play('sfx_protect', { volume: 0.8 }); this.statusText.setText(`${protectFire.source.name} raises shields!`); const BASE_SCALE = 160 / 460; const NEIGHBOR_SCALE = BASE_SCALE * (2 / 3); const RISE_DURATION = 600; const FLY_DURATION = 500; const SETTLE_DELAY = 650; // ms before ARM gain animations start // Resolve the target CardObjects and separate self from neighbors const self = protectFire.targets.find(t => t.laneOffset === 0); const left = protectFire.targets.find(t => t.laneOffset === -1); const right = protectFire.targets.find(t => t.laneOffset === 1); const selfObj = self ? _lookup(self.card.instanceId) : null; const leftObj = left ? _lookup(left.card.instanceId) : null; const rightObj = right ? _lookup(right.card.instanceId) : null; const sprites = []; // Full-size shield on the source card — rises upward if (selfObj?.scene) { const shield = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 17) .setScale(BASE_SCALE) .setDepth(30); sprites.push({ sprite: shield, targetObj: selfObj, cardData: self.card, amount: self.amount }); this.tweens.add({ targets: shield, y: sourceObj.y - 50, duration: RISE_DURATION, ease: 'Sine.easeOut' }); } // 2/3-size shields for each neighbor — fly from source to neighbor card for (const { neighbor, neighborObj } of [ { neighbor: left, neighborObj: leftObj }, { neighbor: right, neighborObj: rightObj } ]) { if (!neighbor || !neighborObj?.scene) continue; const shield = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 17) .setScale(NEIGHBOR_SCALE) .setDepth(30); sprites.push({ sprite: shield, targetObj: neighborObj, cardData: neighbor.card, amount: neighbor.amount }); this.tweens.add({ targets: shield, x: neighborObj.x, y: neighborObj.y - 30, duration: FLY_DURATION, ease: 'Sine.easeOut' }); } if (sprites.length === 0) { onComplete(); return; } // After all shields have settled, animate ARM gain on each card simultaneously this.time.delayedCall(SETTLE_DELAY, () => { // Fade out all shields for (const { sprite } of sprites) { this.tweens.add({ targets: sprite, alpha: 0, duration: 300, ease: 'Power2', onComplete: () => { if (sprite.scene) sprite.destroy(); } }); } // Animate ARM gain on each target in parallel; call onComplete when the last one finishes let remaining = sprites.length; const done = () => { if (--remaining === 0) onComplete(); }; for (const { targetObj, cardData, amount } of sprites) { if (targetObj?.scene) { // Reset arm text to pre-buff value so the animation starts from the right number targetObj.armText?.setText(`${cardData.currentArmor - amount}`); targetObj.animateArmorGain(amount, done); } else { done(); } } }); } // Animate enfeeble: sprite 18 flies from source card to target card (like pierce), then // the reticle locks on and pulses before fading, then ATK loss animates on the target. _animateEnfeebeFire(enfeebeFire, onComplete) { const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id)); const sourceObj = _lookup(enfeebeFire.source.instanceId); const targetObj = _lookup(enfeebeFire.target.instanceId); if (!sourceObj?.scene || !targetObj?.scene || enfeebeFire.amount <= 0) { onComplete(); return; } this.sound.play('sfx_enfeeble', { volume: 0.8 }); this.statusText.setText(`${enfeebeFire.source.name} enfeebles ${enfeebeFire.target.name}!`); const BASE_SCALE = 160 / 460; const enfeebleSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 18) .setScale(BASE_SCALE) .setDepth(30); // Fly to the target this.tweens.add({ targets: enfeebleSprite, x: targetObj.x, y: targetObj.y, duration: 320, ease: 'Cubic.easeIn', onComplete: () => { if (!targetObj.scene) { onComplete(); return; } // Lock on — slow pulse for ~800ms this.tweens.add({ targets: enfeebleSprite, scaleX: BASE_SCALE * 1.25, scaleY: BASE_SCALE * 1.25, duration: 200, ease: 'Sine.easeInOut', yoyo: true, repeat: 2, onComplete: () => { if (!enfeebleSprite.scene) { onComplete(); return; } // Fade out the sprite this.tweens.add({ targets: enfeebleSprite, alpha: 0, duration: 300, ease: 'Power2', onComplete: () => { if (enfeebleSprite.scene) enfeebleSprite.destroy(); } }); // ATK loss animation on target if (!targetObj.scene) { onComplete(); return; } const atkBefore = enfeebeFire.target.currentAttack + enfeebeFire.amount; if (targetObj.atkText) targetObj.atkText.setText(`${atkBefore}`); this._animateAttackLoss(targetObj, enfeebeFire.amount, atkBefore, enfeebeFire.target.currentAttack, onComplete); } }); } }); } // ── "all" enfeeble — parallel sprites to all enemy lane cards, sound plays once ── _animateEnfeebleAll(fires, onComplete) { if (!fires.length) { onComplete(); return; } this.sound.play('sfx_enfeeble', { volume: 0.8 }); this.statusText.setText(`${fires[0].source.name} enfeebles all enemies!`); let remaining = fires.length; const done = () => { if (--remaining <= 0) onComplete(); }; for (const fire of fires) { this._animateEnfeebleFireNoSound(fire, done); } } _animateEnfeebleFireNoSound(enfeebeFire, onComplete) { const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id)); const sourceObj = _lookup(enfeebeFire.source.instanceId); const targetObj = _lookup(enfeebeFire.target.instanceId); if (!sourceObj?.scene || !targetObj?.scene || enfeebeFire.amount <= 0) { onComplete(); return; } const BASE_SCALE = 160 / 460; const enfeebleSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 18) .setScale(BASE_SCALE).setDepth(30); this.tweens.add({ targets: enfeebleSprite, x: targetObj.x, y: targetObj.y, duration: 320, ease: 'Cubic.easeIn', onComplete: () => { if (!targetObj.scene) { onComplete(); return; } this.tweens.add({ targets: enfeebleSprite, scaleX: BASE_SCALE * 1.25, scaleY: BASE_SCALE * 1.25, duration: 200, ease: 'Sine.easeInOut', yoyo: true, repeat: 2, onComplete: () => { if (!enfeebleSprite.scene) { onComplete(); return; } this.tweens.add({ targets: enfeebleSprite, alpha: 0, duration: 300, ease: 'Power2', onComplete: () => { if (enfeebleSprite.scene) enfeebleSprite.destroy(); } }); if (!targetObj.scene) { onComplete(); return; } const atkBefore = enfeebeFire.target.currentAttack + enfeebeFire.amount; if (targetObj.atkText) targetObj.atkText.setText(`${atkBefore}`); this._animateAttackLoss(targetObj, enfeebeFire.amount, atkBefore, enfeebeFire.target.currentAttack, onComplete); } }); } }); } // Animate an ATK reduction on a CardObject (mirrors animateArmorLoss / animateHPLoss pattern). _animateAttackLoss(cardObj, amount, fromATK, toATK, onComplete) { if (!cardObj.atkText || !cardObj.scene) { if (onComplete) onComplete(); return; } const w = cardObj.options?.width || 80; const s = w / 80; const h = cardObj.options?.height || 110; const bannerH = Math.round(h * 0.12); const topBannerCY = -h / 2 + bannerH / 2; cardObj.atkText.setText(`${Math.max(0, fromATK)}`); this.tweens.add({ targets: cardObj.atkText, scaleX: 2, scaleY: 2, duration: 200, ease: 'Back.Out', onComplete: () => { if (!cardObj.scene) { if (onComplete) onComplete(); return; } const lossText = this.add.text( cardObj.x + cardObj.atkText.x * cardObj.scaleX - Math.round(4 * s), cardObj.y + topBannerCY * cardObj.scaleY, `-${amount}`, { fontSize: `${Math.round(11 * s)}px`, color: '#ff8877', fontStyle: 'bold', stroke: '#000000', strokeThickness: Math.max(1, Math.round(2 * s)), fontFamily: 'Audiowide' } ).setOrigin(1, 0.5).setDepth(50); this.time.delayedCall(500, () => { if (!cardObj.scene) { if (onComplete) onComplete(); return; } cardObj.atkText.setText(`${Math.max(0, toATK)}`); this.tweens.add({ targets: cardObj.atkText, scaleX: 1, scaleY: 1, duration: 200, ease: 'Power2' }); this.tweens.add({ targets: lossText, alpha: 0, y: lossText.y - Math.round(14 * s), duration: 300, ease: 'Power2', onComplete: () => { if (lossText.scene) lossText.destroy(); if (onComplete) onComplete(); } }); }); } }); } // Animate jam: sprite 19 flies source → primary (~900ms), pulses, then half-sized copies fan // to secondaries (~700ms). Floating skill-reduction text appears on each hit card and fades // over 1200ms. Cards only refresh (showing reduced skill values) after all floats finish. // Total duration: ~3 seconds. _animateJamFire(jamFire, onComplete) { const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id)); const sourceObj = _lookup(jamFire.source.instanceId); const primaryTarget = jamFire.targets.find(t => t.laneOffset === 0); if (!sourceObj?.scene || !primaryTarget) { onComplete(); return; } const primaryObj = _lookup(primaryTarget.card.instanceId); if (!primaryObj?.scene) { onComplete(); return; } this.sound.play('sfx_jam', { volume: 0.8 }); this.statusText.setText(`${jamFire.source.name} jams the enemy signals!`); const BASE_SCALE = 160 / 460; const HALF_SCALE = BASE_SCALE * 0.5; const FLY_MS = 900; const PULSE_MS = 200; // one yoyo = 400ms const FAN_MS = 700; const FADE_MS = 400; const FLOAT_MS = 1200; const secondaries = jamFire.targets.filter(t => t.laneOffset !== 0); // Count how many float animations we're waiting on before finishing const validTargets = [primaryTarget, ...secondaries].filter( t => _lookup(t.card.instanceId)?.scene ); if (validTargets.length === 0) { onComplete(); return; } let floatsRemaining = validTargets.length; const onFloatDone = () => { if (--floatsRemaining > 0) return; // All floats finished — now refresh every affected card to show reduced values for (const t of validTargets) _lookup(t.card.instanceId)?.refresh(); onComplete(); }; // Main sprite: source → primary const mainSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 19) .setScale(BASE_SCALE) .setDepth(30); this.tweens.add({ targets: mainSprite, x: primaryObj.x, y: primaryObj.y, duration: FLY_MS, ease: 'Cubic.easeIn', onComplete: () => { if (!primaryObj.scene) { onFloatDone(); if (mainSprite.scene) mainSprite.destroy(); return; } // Floating skill-reduction text on primary (drives completion) this._showJamReductionFloat(primaryObj, primaryTarget.reductions, FLOAT_MS, onFloatDone); // Pulse main sprite once, then fade this.tweens.add({ targets: mainSprite, scaleX: BASE_SCALE * 1.35, scaleY: BASE_SCALE * 1.35, duration: PULSE_MS, ease: 'Sine.easeInOut', yoyo: true, onComplete: () => { this.tweens.add({ targets: mainSprite, alpha: 0, duration: FADE_MS, ease: 'Power2', onComplete: () => { if (mainSprite.scene) mainSprite.destroy(); } }); } }); // Half-sized copies fan from primary to each secondary for (const sec of secondaries) { const secObj = _lookup(sec.card.instanceId); if (!secObj?.scene) { onFloatDone(); continue; } const secSprite = this.add.sprite(primaryObj.x, primaryObj.y, 'attacks', 19) .setScale(HALF_SCALE) .setDepth(30); this.tweens.add({ targets: secSprite, x: secObj.x, y: secObj.y, duration: FAN_MS, ease: 'Cubic.easeIn', onComplete: () => { if (!secObj.scene) { onFloatDone(); if (secSprite.scene) secSprite.destroy(); return; } // Floating text on secondary (drives completion) this._showJamReductionFloat(secObj, sec.reductions, FLOAT_MS, onFloatDone); this.tweens.add({ targets: secSprite, alpha: 0, duration: FADE_MS, ease: 'Power2', onComplete: () => { if (secSprite.scene) secSprite.destroy(); } }); } }); } } }); } // Temporarily add back jam reductions so CardObjects are built with original skill values. _restoreJamSkillsForDisplay(preBattleEvents) { for (const event of preBattleEvents) { for (const fire of event.jamFires || []) { for (const t of fire.targets) { for (const r of t.reductions) { if (t.card.skills[r.index]) t.card.skills[r.index].value += r.amount; } } } } } // Re-apply jam reductions after _renderState() so combat math stays correct. _reapplyJamSkillReductions(preBattleEvents) { for (const event of preBattleEvents) { for (const fire of event.jamFires || []) { for (const t of fire.targets) { for (const r of t.reductions) { if (t.card.skills[r.index]) t.card.skills[r.index].value = Math.max(0, t.card.skills[r.index].value - r.amount); } } } } } // Temporarily restore enfeeble ATK reductions so _renderState() shows original values. _restoreEnfeebleForDisplay(preBattleEvents) { for (const event of preBattleEvents) { for (const fire of event.enfeebeFires || []) { fire.target.currentAttack += fire.amount; } } } // Re-apply enfeeble ATK reductions after _renderState() so combat math stays correct. _reapplyEnfeebleReductions(preBattleEvents) { for (const event of preBattleEvents) { for (const fire of event.enfeebeFires || []) { fire.target.currentAttack = Math.max(0, fire.target.currentAttack - fire.amount); } } } // Temporarily undo ATK/ARM buffs (rally, protect, legion, bloodrage, hive_link) so // _renderState() shows pre-buff values. The preBattle animations show the gains visually. _restoreBuffsForDisplay(preBattleEvents) { for (const event of preBattleEvents) { for (const buff of event.buffs || []) { if (buff.skill === 'rally' || buff.skill === 'legion' || buff.skill === 'bloodrage') { buff.target.currentAttack -= buff.amount; } else if (buff.skill === 'protect') { buff.target.currentArmor -= buff.amount; } } // Undo hive_link ATK gains so buffed cards display pre-buff ATK until the animation fires. if (event.type === 'hive_link') { for (const t of event.targets || []) { t.target.currentAttack -= t.gain; } } } } // Re-apply ATK/ARM buffs after _renderState() so combat math stays correct. _reapplyBuffs(preBattleEvents) { for (const event of preBattleEvents) { for (const buff of event.buffs || []) { if (buff.skill === 'rally' || buff.skill === 'legion' || buff.skill === 'bloodrage') { buff.target.currentAttack += buff.amount; } else if (buff.skill === 'protect') { buff.target.currentArmor += buff.amount; } } // Re-apply hive_link ATK gains after _renderState() so the data model is correct // for the animation (animateBerserkGain reads cardData.currentAttack for the final value). if (event.type === 'hive_link') { for (const t of event.targets || []) { t.target.currentAttack += t.gain; } } } } // Temporarily restore drain victims' HP and undo source heal so _renderState() // shows pre-drain values. The drain animation will handle the visual HP changes. _restoreDrainForDisplay(preBattleEvents) { for (const event of preBattleEvents) { for (const fire of event.drainFires || []) { fire.target.currentHP += fire.damage; fire.source.currentHP -= fire.heal; for (const sec of fire.secondaries || []) { sec.target.currentHP += sec.damage; } } } } // Re-apply drain damage and source heal after _renderState() so combat math stays correct. _reapplyDrainDamage(preBattleEvents) { for (const event of preBattleEvents) { for (const fire of event.drainFires || []) { fire.target.currentHP -= fire.damage; fire.source.currentHP += fire.heal; for (const sec of fire.secondaries || []) { sec.target.currentHP -= sec.damage; } } } } // Temporarily undo molt HP/armor changes so _renderState() shows pre-molt values. _restoreHealForDisplay(preBattleEvents) { for (const event of preBattleEvents) { for (const fire of event.healFires || []) { if (fire.isAll) { for (const t of fire.targets || []) { t.target.currentHP -= t.healAmount; } } else if (fire.healAmount > 0) { fire.target.currentHP -= fire.healAmount; } } } } _reapplyHeal(preBattleEvents) { for (const event of preBattleEvents) { for (const fire of event.healFires || []) { if (fire.isAll) { for (const t of fire.targets || []) { t.target.currentHP += t.healAmount; } } else if (fire.healAmount > 0) { fire.target.currentHP += fire.healAmount; } } } } _restoreMoltForDisplay(preBattleEvents) { for (const event of preBattleEvents) { if (event.type !== 'molt' || event.heal <= 0) continue; event.card.currentHP -= event.heal; event.card.currentArmor = event.armorLost; } } // Re-apply molt changes after _renderState() so combat math stays correct. _reapplyMolt(preBattleEvents) { for (const event of preBattleEvents) { if (event.type !== 'molt' || event.heal <= 0) continue; event.card.currentHP += event.heal; event.card.currentArmor = 0; } } // Show floating "-N skill" reduction text near a card's skill area. Calls onComplete when done. _showJamReductionFloat(cardObj, reductions, duration, onComplete) { if (!cardObj?.scene || !reductions?.length) { if (onComplete) onComplete(); return; } // Place float near the skill text using the cardObj's world position const skillOffsetY = cardObj.skillText ? cardObj.skillText.y * (cardObj.scaleY || 1) : cardObj.displayHeight * 0.2; const worldX = cardObj.x; const worldY = cardObj.y + skillOffsetY; const lines = reductions.map(r => { const skillName = cardObj.cardData.skills[r.index]?.name || 'skill'; return `-${r.amount} ${skillName}`; }).join('\n'); const floatText = this.add.text(worldX, worldY, lines, { fontSize: '18px', color: '#ff9933', fontStyle: 'bold', stroke: '#000000', strokeThickness: 3, align: 'center', fontFamily: 'Audiowide' }).setOrigin(0.5, 0.5).setDepth(55); this.tweens.add({ targets: floatText, alpha: 0, y: worldY - 55, duration, ease: 'Power2', onComplete: () => { if (floatText.scene) floatText.destroy(); if (onComplete) onComplete(); } }); } // Drives the postBattle sequence after all attacks resolve. _processPostBattle(events, onComplete) { const processStep = (idx) => { if (idx >= events.length) { onComplete(); return; } if (events[idx].type === 'burrowTick') { this._animateBurrowTick(events[idx], () => processStep(idx + 1)); return; } this._onPostBattleStep(events[idx], () => processStep(idx + 1)); }; processStep(0); } // Called once per postBattle step. Refreshes cards whose temp buffs were removed. _onPostBattleStep(event, onComplete) { if (event.debuffs?.length > 0) { for (const debuff of event.debuffs) { const obj = this.cardObjects.get(debuff.target.instanceId) ?? this.commanderObjects?.get(debuff.target.instanceId); obj?.refresh(); } } onComplete(); } _animateBurrowTick(event, onComplete) { const obj = this.cardObjects.get(event.card.instanceId); if (!obj?.scene) { onComplete(); return; } this.sound.play('sfx_burrow', { volume: 0.8 }); const label = event.remaining > 0 ? `${event.card.name} surfaces next turn [${event.remaining} remaining]` : `${event.card.name} emerges from the ground!`; this.statusText.setText(label); if (obj.scene) obj.refresh(); this.time.delayedCall(400, onComplete); } // Fires when the attack animation ends — attach all post-attack consequences here. _onAttackReconcile(event, berserkEvent) { const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id)); const defenderObj = _lookup(event.defender?.instanceId); const attackerObj = _lookup(event.attacker?.instanceId); // HP loss animation on defender; on completion, trigger death if HP reached zero. // If the defender had hive_link, fire that animation immediately after the death explosion. if (defenderObj?.scene && event.damage > 0) { if (event.defender.currentHP > 0) this.sound.play('sfx_damage', { volume: 0.8 }); defenderObj.animateHPLoss(event.damage, () => { if (event.defender.currentHP <= 0 && !defenderObj.isCommander) { const hlEvent = this._pendingHiveLinks?.[event.defender.instanceId]; if (hlEvent) delete this._pendingHiveLinks[event.defender.instanceId]; this._playCardDeath(defenderObj, event.defender, hlEvent ? () => this._animateHiveLink(hlEvent, () => { }) : null ); } }); } // Berserk ATK gain animation on attacker if (berserkEvent && attackerObj?.scene) { attackerObj.atkText.setText(`${berserkEvent.card.currentAttack - berserkEvent.gain}`); attackerObj.animateBerserkGain(berserkEvent.gain); } } _showCardPicker(hand) { const { width, height } = this.scale; this._destroyCardPicker(); this.pickerObjects = []; // Dim overlay const overlay = this.add.rectangle(width / 2, height / 2, width, height, 0x000000, 0.60) .setDepth(10); this.pickerObjects.push(overlay); // Panel background const cardW = 260, cardH = 364; const gap = 40; const panelPadX = 60, panelPadY = 70; const totalW = hand.length * cardW + (hand.length - 1) * gap; const panelW = Math.max(totalW + panelPadX * 2, 500); const panelH = cardH + panelPadY * 2; const panelY = 540; const panel = this.add.rectangle(width / 2, panelY, panelW, panelH, 0x0d1b2a, 0.97) .setStrokeStyle(2, 0x4488ff) .setDepth(10); this.pickerObjects.push(panel); const title = this.add.text(width / 2, panelY - panelH / 2 + 26, 'Choose a card to deploy', { fontSize: '22px', color: '#d4af37', fontFamily: 'Audiowide' }).setOrigin(0.5).setDepth(11); this.pickerObjects.push(title); const startX = width / 2 - totalW / 2 + cardW / 2; const cardY = panelY + 8; hand.forEach((card, i) => { const x = startX + i * (cardW + gap); // CardObject at depth 11 const cardObj = new CardObject(this, x, cardY, card, { width: cardW, height: cardH }); cardObj.setDepth(11); this.pickerObjects.push(cardObj); // Use the CardObject's own interactive area for hover + click cardObj.setInteractive( new Phaser.Geom.Rectangle(-cardW / 2, -cardH / 2, cardW, cardH), Phaser.Geom.Rectangle.Contains ); cardObj.input.cursor = 'pointer'; cardObj.on('pointerover', () => { cardObj.setScale(1.05); cardObj.setDepth(13); }); cardObj.on('pointerout', () => { cardObj.setScale(1); cardObj.setDepth(11); }); cardObj.on('pointerdown', () => { this._destroyCardPicker(); this._finishTurn(card); }); }); // Pass button const passBtn = this.add.rectangle(width / 2, panelY + panelH / 2 - 28, 220, 44, 0x333333) .setStrokeStyle(1, 0x888888) .setInteractive({ useHandCursor: true }) .setDepth(11); const passTxt = this.add.text(width / 2, panelY + panelH / 2 - 28, 'Pass (deploy nothing)', { fontSize: '17px', color: '#aaaaaa', fontFamily: 'Audiowide' }).setOrigin(0.5).setDepth(12); passBtn.on('pointerdown', () => { this._destroyCardPicker(); this._finishTurn(null); }); this.pickerObjects.push(passBtn, passTxt); } _destroyCardPicker() { if (this.pickerObjects) { this.pickerObjects.forEach(o => o.destroy()); this.pickerObjects = null; } } // ── Battle music ───────────────────────────────────────────────────────────── _startBattleMusic() { // Build a shuffled copy of the playlist so tracks play in random order this._battlePlaylist = [...BATTLE_MUSIC].sort(() => Math.random() - 0.5); this._battlePlaylistIdx = 0; this._playNextBattleTrack(); } _playNextBattleTrack() { if (!this._battlePlaylist?.length) return; // Wrap around to re-shuffle once the playlist is exhausted, // avoiding repeating the track that just played if (this._battlePlaylistIdx >= this._battlePlaylist.length) { const lastKey = this._battlePlaylist[this._battlePlaylist.length - 1].key; this._battlePlaylist = [...BATTLE_MUSIC].sort(() => Math.random() - 0.5); // If the shuffle placed the same track first, rotate it to the end if (this._battlePlaylist.length > 1 && this._battlePlaylist[0].key === lastKey) { this._battlePlaylist.push(this._battlePlaylist.shift()); } this._battlePlaylistIdx = 0; } const track = this._battlePlaylist[this._battlePlaylistIdx++]; if (!this.cache.audio.exists(track.key)) return; // not loaded yet — skip gracefully this._currentBattleMusic = this.sound.add(track.key, { volume: 0.5 }); this._currentBattleMusic.once('complete', () => this._playNextBattleTrack()); this._currentBattleMusic.play(); } _stopBattleMusic() { if (this._currentBattleMusic) { this._currentBattleMusic.stop(); this._currentBattleMusic.destroy(); this._currentBattleMusic = null; } this._battlePlaylist = null; } _renderState() { const state = this.engine.getState(); this.cardObjects.forEach(co => co.destroy()); this.cardObjects.clear(); state.player.lanes.forEach((card, i) => { const pos = this.battlefield.getPlayerLanePos(i); const co = new CardObject(this, pos.x, pos.y, card, { width: 260, height: 364 }); this.cardObjects.set(card.instanceId, co); }); state.opponent.lanes.forEach((card, i) => { const pos = this.battlefield.getOpponentLanePos(i); const co = new CardObject(this, pos.x, pos.y, card, { width: 260, height: 364 }); this.cardObjects.set(card.instanceId, co); }); } _updateLog() { const recent = this.engine.log.slice(-6); this.logLines.forEach((line, i) => { line.setText(recent[i] || ''); }); } _toggleAuto() { this.autoPlay = !this.autoPlay; this.autoBtnText.setText(`Auto: ${this.autoPlay ? 'ON' : 'OFF'}`); if (this.autoPlay) { this.autoTimer = this.time.addEvent({ delay: 300, callback: () => { if (!this.engine.winner) { this._beginTurn(); } else { this.autoPlay = false; this.autoBtnText.setText('Auto: OFF'); if (this.autoTimer) this.autoTimer.remove(); } }, loop: true }); } else { if (this.autoTimer) this.autoTimer.remove(); } } _showResult() { if (this.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'); }); } // ── Smite animations ────────────────────────────────────────────────────── // Shared helper: fly sprite 38 from source to target, burst on arrival, call cb _doSmiteCastTo(sourceObj, targetObj, onArrival) { const projectile = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 38) .setDisplaySize(180, 180) .setDepth(30); this.tweens.add({ targets: projectile, x: targetObj.x, y: targetObj.y, duration: 350, ease: 'Quad.In', onComplete: () => { if (projectile.scene) projectile.destroy(); onArrival(); } }); } _animateSmiteApply(event, onComplete) { const sourceObj = this.cardObjects.get(event.attacker.instanceId) || this.commanderObjects?.get(event.attacker.instanceId); const targetObj = this.cardObjects.get(event.target.instanceId) || this.commanderObjects?.get(event.target.instanceId); if (!sourceObj?.scene || !targetObj?.scene) { onComplete(); return; } this.statusText.setText(`${event.attacker.name} smites ${event.target.name}! [${event.stacks} stacks]`); this.sound.play('sfx_smite_cast', { volume: 0.8 }); this._doSmiteCastTo(sourceObj, targetObj, () => { if (targetObj.scene) targetObj.refresh(); onComplete(); }); } _animateSmiteApplyAll(event, onComplete) { if (!event.targets?.length) { onComplete(); return; } this.statusText.setText(`${event.attacker.name} smites all enemies!`); this.sound.play('sfx_smite_cast', { volume: 0.8 }); const sourceObj = this.cardObjects.get(event.attacker.instanceId) || this.commanderObjects?.get(event.attacker.instanceId); let remaining = event.targets.length; const done = () => { if (--remaining <= 0) onComplete(); }; for (const t of event.targets) { const targetObj = this.cardObjects.get(t.target.instanceId) || this.commanderObjects?.get(t.target.instanceId); if (!sourceObj?.scene || !targetObj?.scene) { done(); continue; } this._doSmiteCastTo(sourceObj, targetObj, () => { if (targetObj.scene) targetObj.refresh(); done(); }); } } _animateSmiteTick(event, onComplete) { const obj = this.cardObjects.get(event.card.instanceId) || this.commanderObjects?.get(event.card.instanceId); if (!obj?.scene) { onComplete(); return; } this.statusText.setText(`${event.card.name} suffers ${event.damage} smite damage!`); this.sound.play('sfx_smite_damage', { volume: 0.8 }); // Sprite 39 pulses twice (250ms × 4 = 1000ms total) over the card const effectSprite = this.add.sprite(obj.x, obj.y, 'attacks', 39) .setDisplaySize(140, 140) .setDepth(30) .setAlpha(0.9); this.tweens.add({ targets: effectSprite, scaleX: { from: 0.8, to: 1.5 }, scaleY: { from: 0.8, to: 1.5 }, duration: 250, yoyo: true, repeat: 1, onComplete: () => { if (effectSprite.scene) effectSprite.destroy(); } }); // Latch: wait for both HP animation AND the 1s minimum before continuing let spriteHeld = true; let hpDone = false; const tryFinish = () => { if (!spriteHeld && hpDone) { if (obj.scene) obj.refresh(); onComplete(); } }; this.time.delayedCall(1000, () => { spriteHeld = false; tryFinish(); }); const afterHP = () => { if (event.killed) { const hlEvent = this._preBattleHiveLinks?.[event.card.instanceId]; if (hlEvent) delete this._preBattleHiveLinks[event.card.instanceId]; // For kills wait out the full 1s, then play death this.time.delayedCall(1000, () => { this._playCardDeath(obj, event.card, hlEvent ? () => this._animateHiveLink(hlEvent, onComplete) : onComplete ); }); } else { hpDone = true; tryFinish(); } }; if (event.damage > 0 && obj.animateHPLoss) { obj.animateHPLoss(event.damage, afterHP); } else { hpDone = true; tryFinish(); } } // ── Sanctify animations ────────────────────────────────────────────────── _processSanctifyFires(fires, onComplete) { if (!fires || fires.length === 0) { onComplete(); return; } const next = (idx) => { if (idx >= fires.length) { onComplete(); return; } const fire = fires[idx]; if (fire.isAll) { this._animateSanctifyAll(fire, () => next(idx + 1)); } else { this._animateSanctifySingle(fire, () => next(idx + 1)); } }; next(0); } _animateSanctifySingle(fire, onComplete) { const sourceObj = this.cardObjects.get(fire.source.instanceId) || this.commanderObjects?.get(fire.source.instanceId); const targetObj = this.cardObjects.get(fire.target.instanceId) || this.commanderObjects?.get(fire.target.instanceId); if (!targetObj?.scene) { onComplete(); return; } const effects = fire.cleansed.map(c => c.effect).join(', '); this.statusText.setText(`${fire.source.name} sanctifies self! Cleansed: ${effects}`); this.sound.play('sfx_sanctify', { volume: 0.8 }); const startX = sourceObj?.scene ? sourceObj.x : targetObj.x; const startY = sourceObj?.scene ? sourceObj.y : targetObj.y; this._doSanctifySpriteTo(startX, startY, targetObj, () => { if (targetObj.scene) targetObj.refresh(); onComplete(); }); } _animateSanctifyAll(fire, onComplete) { if (!fire.targets?.length) { onComplete(); return; } this.statusText.setText(`${fire.source.name} sanctifies all allies!`); this.sound.play('sfx_sanctify', { volume: 0.8 }); const sourceObj = this.cardObjects.get(fire.source.instanceId) || this.commanderObjects?.get(fire.source.instanceId); const startX = sourceObj?.scene ? sourceObj.x : 0; const startY = sourceObj?.scene ? sourceObj.y : 0; let remaining = fire.targets.length; const done = () => { if (--remaining <= 0) onComplete(); }; for (const t of fire.targets) { const targetObj = this.cardObjects.get(t.target.instanceId) || this.commanderObjects?.get(t.target.instanceId); if (!targetObj?.scene) { done(); continue; } this._doSanctifySpriteTo(startX, startY, targetObj, () => { if (targetObj.scene) targetObj.refresh(); done(); }); } } // Fly sprite 35 from (startX, startY) to targetObj, shake it, swap to sprite 36, fade out. _doSanctifySpriteTo(startX, startY, targetObj, onComplete) { const sprite = this.add.sprite(startX, startY, 'attacks', 35) .setDisplaySize(100, 100) .setDepth(30) .setAlpha(1); const flyDuration = 350; this.tweens.add({ targets: sprite, x: targetObj.x, y: targetObj.y, duration: flyDuration, ease: 'Quad.easeIn', onComplete: () => { if (!sprite.scene) { onComplete(); return; } // Shake: rapid left-right oscillation const shakeAmp = 6; const shakeDuration = 60; this.tweens.add({ targets: sprite, x: { from: targetObj.x - shakeAmp, to: targetObj.x + shakeAmp }, duration: shakeDuration, yoyo: true, repeat: 3, ease: 'Sine.easeInOut', onComplete: () => { if (!sprite.scene) { onComplete(); return; } // Snap back to centre, swap to sprite 36 sprite.setPosition(targetObj.x, targetObj.y); sprite.setFrame(36); // Fade out sprite 36 this.tweens.add({ targets: sprite, alpha: 0, scaleX: { from: 1, to: 1.4 }, scaleY: { from: 1, to: 1.4 }, duration: 350, ease: 'Power2', onComplete: () => { if (sprite.scene) sprite.destroy(); onComplete(); } }); } }); } }); } // ── Overcharge animations ──────────────────────────────────────────────── _processOverchargeFires(fires, onComplete) { if (!fires || fires.length === 0) { onComplete(); return; } const next = (idx) => { if (idx >= fires.length) { onComplete(); return; } this._animateOvercharge(fires[idx], () => next(idx + 1)); }; next(0); } _animateOvercharge(fire, onComplete) { const sourceObj = this.cardObjects.get(fire.source.instanceId) || this.commanderObjects?.get(fire.source.instanceId); if (!sourceObj?.scene) { onComplete(); return; } this.statusText.setText(`${fire.source.name} overcharges! -${fire.selfDamage} HP, +${fire.selfDamage} ATK to allies`); this.sound.play('sfx_overcharge', { volume: 0.8 }); // Sprite 40 pulses over source while HP loss animates const pulseSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 40) .setDisplaySize(130, 130) .setDepth(30) .setAlpha(0.9); this.tweens.add({ targets: pulseSprite, scaleX: { from: 0.8, to: 1.5 }, scaleY: { from: 0.8, to: 1.5 }, duration: 220, yoyo: true, repeat: 1, onComplete: () => { if (pulseSprite.scene) pulseSprite.destroy(); } }); const afterDamage = () => { if (fire.selfKilled && sourceObj.scene) { this._playCardDeath(sourceObj, fire.source, () => this._animateOverchargeBuffs(fire, onComplete)); } else { if (sourceObj.scene) sourceObj.refresh(); this._animateOverchargeBuffs(fire, onComplete); } }; if (fire.selfDamage > 0 && sourceObj.animateHPLoss) { sourceObj.animateHPLoss(fire.selfDamage, afterDamage); } else { this.time.delayedCall(550, afterDamage); } } _animateOverchargeBuffs(fire, onComplete) { if (!fire.targets?.length) { onComplete(); return; } let remaining = fire.targets.length; const done = () => { if (--remaining <= 0) onComplete(); }; for (const t of fire.targets) { const obj = this.cardObjects.get(t.target.instanceId) || this.commanderObjects?.get(t.target.instanceId); if (!obj?.scene) { done(); continue; } // Sprite 41 pulses over each buffed ally while ATK gain animates const buffSprite = this.add.sprite(obj.x, obj.y, 'attacks', 41) .setDisplaySize(130, 130) .setDepth(30) .setAlpha(0.9); this.tweens.add({ targets: buffSprite, scaleX: { from: 0.8, to: 1.5 }, scaleY: { from: 0.8, to: 1.5 }, duration: 220, yoyo: true, repeat: 1, onComplete: () => { if (buffSprite.scene) buffSprite.destroy(); } }); obj.animateBerserkGain(fire.selfDamage, () => { if (obj.scene) obj.refresh(); done(); }); } } // ── Fortify animations ─────────────────────────────────────────────────── _processFortifyFires(fires, onComplete) { if (!fires || fires.length === 0) { onComplete(); return; } const next = (idx) => { if (idx >= fires.length) { onComplete(); return; } const fire = fires[idx]; if (fire.isAll) { this._animateFortifyAll(fire, () => next(idx + 1)); } else { this._animateFortifySingle(fire, () => next(idx + 1)); } }; next(0); } _animateFortifySingle(fire, onComplete) { const obj = this.cardObjects.get(fire.target.instanceId) || this.commanderObjects?.get(fire.target.instanceId); if (!obj?.scene) { onComplete(); return; } this.statusText.setText(`${fire.source.name} fortifies! +${fire.amount} armor`); this.sound.play('sfx_fortify', { volume: 0.8 }); this._doFortifyPulse(obj, fire.amount, onComplete); } _animateFortifyAll(fire, onComplete) { if (!fire.targets?.length) { onComplete(); return; } const amount = fire.targets[0]?.amount || 0; this.statusText.setText(`${fire.source.name} fortifies all allies! +${amount} armor`); this.sound.play('sfx_fortify', { volume: 0.8 }); const sourceObj = this.cardObjects.get(fire.source.instanceId) || this.commanderObjects?.get(fire.source.instanceId); const startX = sourceObj?.scene ? sourceObj.x : 0; const startY = sourceObj?.scene ? sourceObj.y : 0; let remaining = fire.targets.length; const done = () => { if (--remaining <= 0) onComplete(); }; for (const t of fire.targets) { const targetObj = this.cardObjects.get(t.target.instanceId) || this.commanderObjects?.get(t.target.instanceId); if (!targetObj?.scene) { done(); continue; } // Fly sprite 37 from source to each target, then pulse on arrival const isSelf = sourceObj && targetObj === sourceObj; const flyDuration = isSelf ? 0 : 300; const projectile = this.add.sprite(startX, startY, 'attacks', 37) .setDisplaySize(110, 110) .setDepth(30) .setAlpha(1); this.tweens.add({ targets: projectile, x: targetObj.x, y: targetObj.y, duration: flyDuration, ease: 'Quad.easeIn', onComplete: () => { if (projectile.scene) projectile.destroy(); if (!targetObj.scene) { done(); return; } this._doFortifyPulse(targetObj, t.amount, done); } }); } } // Pulse sprite 37 over a card (grow/shrink twice) while animating armor gain. _doFortifyPulse(obj, amount, onComplete) { // Reset armor text to pre-fortify value so the gain animates visibly if (obj.armText) obj.armText.setText(`${(obj.cardData.currentArmor || 0) - amount}`); const sprite = this.add.sprite(obj.x, obj.y, 'attacks', 37) .setDisplaySize(110, 110) .setDepth(30) .setAlpha(0.9); // Pulse: grow and shrink twice (yoyo × 2) this.tweens.add({ targets: sprite, scaleX: { from: 0.8, to: 1.5 }, scaleY: { from: 0.8, to: 1.5 }, duration: 220, yoyo: true, repeat: 1, ease: 'Sine.easeInOut', onComplete: () => { if (sprite.scene) sprite.destroy(); } }); // Animate armor gain in parallel with the pulse obj.animateArmorGain(amount, () => { if (obj.scene) obj.refresh(); onComplete(); }); } // ── Hack animation (preAttack) ─────────────────────────────────────────── _animateHackFire(attacker, hackGroup, onComplete) { const sourceObj = this.cardObjects.get(attacker.instanceId) || this.commanderObjects?.get(attacker.instanceId); this.statusText.setText(`${attacker.name} hacks ${hackGroup.copiedFrom?.name || 'enemy'}!`); this.sound.play('sfx_hack', { volume: 0.8 }); // Sprite 42 pulses on source for 1.5s before copied skills fire const runFires = () => { const fires = hackGroup.fires || []; const nextFire = (idx) => { if (idx >= fires.length) { onComplete(); return; } const fire = fires[idx]; const cb = () => nextFire(idx + 1); if (fire.skill === 'strike') this._animateStrikeFire(attacker, fire, cb); else if (fire.skill === 'strikeAll') this._animateStrikeFire(attacker, fire, cb); else if (fire.skill === 'mortar') this._animateMortarFire(attacker, fire, cb); else if (fire.skill === 'pierce') this._animatePierceFire(attacker, fire, cb); else if (fire.skill === 'swipe') this._animateSwipeSequence(attacker, [fire], cb); else if (fire.skill === 'drain') this._animateDrainFire(fire.source, fire, cb); else if (fire.skill === 'jam') this._animateJamFire(fire, cb); else if (fire.skill === 'venom') this._animateVenomApply(fire, cb); else if (fire.skill === 'venomAll') this._animateVenomApplyAll(fire, cb); else if (fire.skill === 'smite') this._animateSmiteApply(fire, cb); else if (fire.skill === 'smiteAll') this._animateSmiteApplyAll(fire, cb); else if (fire.skill === 'molt') this._animateMolt(fire, cb); else if (fire.skill === 'berserk') { const obj = this.cardObjects.get(fire.card.instanceId) || this.commanderObjects?.get(fire.card.instanceId); if (obj?.scene) { this.statusText.setText(`${fire.card.name} goes berserk! +${fire.gain} ATK`); obj.animateBerserkGain(fire.gain, () => { if (obj.scene) obj.refresh(); cb(); }); } else cb(); } else if (fire.skill === 'flurry') { this.statusText.setText(`${fire.card.name} gains flurry! +1 extra attack`); this.time.delayedCall(600, cb); } else cb(); }; nextFire(0); }; if (!sourceObj?.scene) { this.time.delayedCall(1500, runFires); return; } const pulseSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 42) .setDisplaySize(130, 130) .setDepth(30) .setAlpha(0.9); // 3 cycles of grow/shrink over ~1500ms (500ms per cycle: 250ms out + 250ms back) this.tweens.add({ targets: pulseSprite, scaleX: { from: 0.8, to: 1.5 }, scaleY: { from: 0.8, to: 1.5 }, duration: 250, yoyo: true, repeat: 2, ease: 'Sine.easeInOut', onComplete: () => { if (pulseSprite.scene) pulseSprite.destroy(); runFires(); } }); } // ── Restore/reapply for new preBattle skills ───────────────────────────── _restoreSanctifyForDisplay(preBattleEvents) { for (const event of preBattleEvents) { for (const fire of event.sanctifyFires || []) { if (fire.isAll) { for (const t of fire.targets || []) { for (const c of t.cleansed) { if (c.effect === 'venom') t.target.venomStacks += c.removed; if (c.effect === 'smite') t.target.smiteStacks += c.removed; if (c.effect === 'rupture') t.target.ruptureStacks += c.removed; } } } else if (fire.cleansed?.length) { for (const c of fire.cleansed) { if (c.effect === 'venom') fire.target.venomStacks += c.removed; if (c.effect === 'smite') fire.target.smiteStacks += c.removed; if (c.effect === 'rupture') fire.target.ruptureStacks += c.removed; } } } } } _reapplySanctify(preBattleEvents) { for (const event of preBattleEvents) { for (const fire of event.sanctifyFires || []) { if (fire.isAll) { for (const t of fire.targets || []) { for (const c of t.cleansed) { if (c.effect === 'venom') t.target.venomStacks -= c.removed; if (c.effect === 'smite') t.target.smiteStacks -= c.removed; if (c.effect === 'rupture') t.target.ruptureStacks -= c.removed; } } } else if (fire.cleansed?.length) { for (const c of fire.cleansed) { if (c.effect === 'venom') fire.target.venomStacks -= c.removed; if (c.effect === 'smite') fire.target.smiteStacks -= c.removed; if (c.effect === 'rupture') fire.target.ruptureStacks -= c.removed; } } } } } _restoreOverchargeForDisplay(preBattleEvents) { for (const event of preBattleEvents) { for (const fire of event.overchargeFires || []) { // Restore self-damage fire.source.currentHP += fire.selfDamage; // Remove ally buffs for (const t of fire.targets || []) { t.target.currentAttack -= t.amount; } } } } _reapplyOvercharge(preBattleEvents) { for (const event of preBattleEvents) { for (const fire of event.overchargeFires || []) { fire.source.currentHP -= fire.selfDamage; for (const t of fire.targets || []) { t.target.currentAttack += t.amount; } } } } _restoreFortifyForDisplay(preBattleEvents) { for (const event of preBattleEvents) { for (const fire of event.fortifyFires || []) { if (fire.isAll) { for (const t of fire.targets || []) { t.target.currentArmor -= t.amount; } } else if (fire.amount > 0) { fire.target.currentArmor -= fire.amount; } } } } _reapplyFortify(preBattleEvents) { for (const event of preBattleEvents) { for (const fire of event.fortifyFires || []) { if (fire.isAll) { for (const t of fire.targets || []) { t.target.currentArmor += t.amount; } } else if (fire.amount > 0) { fire.target.currentArmor += fire.amount; } } } } }