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