feat: implement BattleResultScene with full post-battle flow

- Add new BattleResultScene to handle post-battle victory/defeat sequence
- Include animated terminal-style debrief with stats, rating, and card/commander unlocks
- Support dynamic commander videos (victory/defeated) with fallback placeholders
- Implement gold counter animation and card reveal system with rarity effects
- Add victory/defeat music assets and integrate into result flow

BattleScene changes:
- Track all battle events across turns for detailed post-battle stats
- Transition to BattleResultScene instead of showing inline results
- Preserve save logic (gold, cards, unlocks) in result scene transition

BootScene changes:
- Load victory/defeat music assets for result scene playback
This commit is contained in:
Brian Fertig 2026-03-31 17:54:00 -06:00
parent 22b4b51883
commit 5b206fda33
38 changed files with 1054 additions and 66 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -5,6 +5,7 @@ import { CampaignSelectScene } from './scenes/CampaignSelectScene.js';
import { CampaignScene } from './scenes/CampaignScene.js';
import { SkirmishSetupScene } from './scenes/SkirmishSetupScene.js';
import { BattleScene } from './scenes/BattleScene.js';
import { BattleResultScene } from './scenes/BattleResultScene.js';
import { DeckBuilderScene } from './scenes/DeckBuilderScene.js';
import { CollectionScene } from './scenes/CollectionScene.js';
import { StoreScene } from './scenes/StoreScene.js';
@ -28,6 +29,7 @@ const config = {
CampaignScene,
SkirmishSetupScene,
BattleScene,
BattleResultScene,
DeckBuilderScene,
CollectionScene,
StoreScene,

View File

@ -0,0 +1,989 @@
import { CardObject } from '../objects/CardObject.js';
// ── Amber CRT Palette ───────────────────────────────────────────────────────────
const AMBER = '#ffaa33';
const AMBER_DIM = '#cc8822';
const AMBER_HEX = 0xffaa33;
const AMBER_DARK_HEX = 0x332200;
const AMBER_BG_HEX = 0x1a1100;
const TERM_BG_HEX = 0x0a0800;
const RARITY_COLORS = {
common: 0x888888,
rare: 0x4488ff,
epic: 0xaa44ff,
legendary: 0xffaa00
};
const CARD_BACK_KEYS = {
imperial: 'card_back_imperial',
raider: 'card_back_raider',
bloodthirsty: 'card_back_bloodthirsty',
xeno: 'card_back_xeno',
righteous: 'card_back_right'
};
const FACTION_LABELS = {
imperial: 'IMPERIAL',
raider: 'RAIDER',
bloodthirsty: 'BLOODTHIRSTY',
xeno: 'XENO',
righteous: 'RIGHTEOUS'
};
// ── Layout ──────────────────────────────────────────────────────────────────────
const LEFT_W = 640;
const VIDEO_W = 640;
const VIDEO_H = 360;
// Terminal area on the right panel
const TERM_X = LEFT_W + 30;
const TERM_Y = 160;
const TERM_W = 1920 - LEFT_W - 60;
const TERM_H = 520;
export class BattleResultScene extends Phaser.Scene {
constructor() { super('BattleResultScene'); }
init(data) {
this.won = data.won;
this.turnCount = data.turnCount;
this.battleEvents = data.battleEvents || [];
this.battleLog = data.battleLog || [];
this.playerCommanderId = data.playerCommanderId;
this.opponentCommanderId = data.opponentCommanderId;
this.playerCommanderHP = data.playerCommanderHP;
this.opponentCommanderHP = data.opponentCommanderHP;
this.missionData = data.missionData || null;
this.campaignId = data.campaignId || null;
this.isFirstClear = data.isFirstClear || false;
this.rewardGold = data.rewardGold || 0;
this.rewardCards = data.rewardCards || null;
this.unlockedCommanders = data.unlockedCommanders || null;
this.isFinalMission = data.isFinalMission || false;
this._typingEvents = [];
this._terminalTextObjects = [];
this._revealObjects = [];
this._emitters = [];
this._typingComplete = false;
}
preload() {
// Load commander videos dynamically
const playerVideoDir = this.won ? 'victory' : 'defeated';
const opponentVideoDir = this.won ? 'defeated' : 'victory';
const playerVideoKey = `result_player_${this.playerCommanderId}`;
const opponentVideoKey = `result_opponent_${this.opponentCommanderId}`;
if (!this.cache.video.has(playerVideoKey)) {
this.load.video(playerVideoKey, `assets/video/${playerVideoDir}/${this.playerCommanderId}.mp4`);
}
if (!this.cache.video.has(opponentVideoKey)) {
this.load.video(opponentVideoKey, `assets/video/${opponentVideoDir}/${this.opponentCommanderId}.mp4`);
}
}
create() {
const { width, height } = this.scale;
const cardManager = this.registry.get('cardManager');
// ── Result Music ──────────────────────────────────────────────────────────
const musicKey = this.won ? 'music_victory' : 'music_defeat';
this._resultMusic = this.sound.add(musicKey, { loop: false, volume: 0.5 });
this._resultMusic.play();
// ── Background ────────────────────────────────────────────────────────────
this.add.rectangle(width / 2, height / 2, width, height, 0x0a0a14);
// Subtle gradient overlay on left panel
const leftGrad = this.add.graphics();
leftGrad.fillStyle(this.won ? 0x002200 : 0x220000, 0.3);
leftGrad.fillRect(0, 0, LEFT_W, height);
// Divider line between panels
this.add.rectangle(LEFT_W, height / 2, 2, height, this.won ? 0x44ff44 : 0xff4444, 0.4);
// ── Commander Videos ──────────────────────────────────────────────────────
this._setupVideos();
// ── Commander Info Panel (below videos) ───────────────────────────────────
this._buildCommanderInfoPanel(cardManager);
// ── Title (fades in) ──────────────────────────────────────────────────────
this.time.delayedCall(400, () => this._showTitle());
// ── Gold Counter (animated tick-up) ───────────────────────────────────────
if (this.rewardGold > 0) {
this.time.delayedCall(600, () => this._animateGoldCounter());
}
// ── CRT Terminal ──────────────────────────────────────────────────────────
this._buildDebriefTerminal();
// Start typing after delay
this.time.delayedCall(1200, () => {
const stats = this._computeStats();
const lines = this._buildDebriefLines(stats);
this._typeLines(lines, 0, () => {
this._typingComplete = true;
this._onDebriefComplete();
});
});
// ── Click-to-skip typing ──────────────────────────────────────────────────
this.input.on('pointerdown', () => {
if (!this._typingComplete) {
this._skipTyping();
}
});
}
// ════════════════════════════════════════════════════════════════════════════
// ── Videos ─────────────────────────────────────────────────────────────────
// ════════════════════════════════════════════════════════════════════════════
_setupVideos() {
const playerVideoKey = `result_player_${this.playerCommanderId}`;
const opponentVideoKey = `result_opponent_${this.opponentCommanderId}`;
// Opponent video (top)
const oppLabel = this.add.text(LEFT_W / 2, 8, 'ENEMY COMMANDER', {
fontSize: '14px', color: '#ff6666', fontFamily: 'Audiowide'
}).setOrigin(0.5, 0).setAlpha(0.7);
if (this.cache.video.has(opponentVideoKey)) {
this._opponentVideo = this.add.video(LEFT_W / 2, 28 + VIDEO_H / 2, opponentVideoKey);
this._opponentVideo.setMute(true);
this._opponentVideo.on('created', () => {
if (this._opponentVideo.scene) this._opponentVideo.setDisplaySize(VIDEO_W, VIDEO_H);
});
this._opponentVideo.play(false); // play once
} else {
// Fallback placeholder
this.add.rectangle(LEFT_W / 2, 28 + VIDEO_H / 2, VIDEO_W, VIDEO_H, 0x111122)
.setStrokeStyle(1, 0x333344);
this.add.text(LEFT_W / 2, 28 + VIDEO_H / 2, this.won ? 'DEFEATED' : 'VICTORIOUS', {
fontSize: '28px', color: '#666666', fontFamily: 'RaiderCrusader'
}).setOrigin(0.5);
}
// Player video (middle)
const playerLabel = this.add.text(LEFT_W / 2, VIDEO_H + 36, 'YOUR COMMANDER', {
fontSize: '14px', color: '#6688ff', fontFamily: 'Audiowide'
}).setOrigin(0.5, 0).setAlpha(0.7);
const playerVideoY = VIDEO_H + 56 + VIDEO_H / 2;
if (this.cache.video.has(playerVideoKey)) {
this._playerVideo = this.add.video(LEFT_W / 2, playerVideoY, playerVideoKey);
this._playerVideo.setMute(true);
this._playerVideo.on('created', () => {
if (this._playerVideo.scene) this._playerVideo.setDisplaySize(VIDEO_W, VIDEO_H);
});
this._playerVideo.play(false); // play once
} else {
this.add.rectangle(LEFT_W / 2, playerVideoY, VIDEO_W, VIDEO_H, 0x111122)
.setStrokeStyle(1, 0x333344);
this.add.text(LEFT_W / 2, playerVideoY, this.won ? 'VICTORIOUS' : 'DEFEATED', {
fontSize: '28px', color: '#666666', fontFamily: 'RaiderCrusader'
}).setOrigin(0.5);
}
}
// ════════════════════════════════════════════════════════════════════════════
// ── Commander Info Panel ───────────────────────────────────────────────────
// ════════════════════════════════════════════════════════════════════════════
_buildCommanderInfoPanel(cardManager) {
const panelY = VIDEO_H * 2 + 64;
const panelH = 1080 - panelY;
// Panel background
this.add.rectangle(LEFT_W / 2, panelY + panelH / 2, LEFT_W - 20, panelH - 10, 0x0a0a1a, 0.8)
.setStrokeStyle(1, 0x334466);
const opponentCard = cardManager.getCard(this.opponentCommanderId);
const playerCard = cardManager.getCard(this.playerCommanderId);
// Opponent commander info
const oppY = panelY + 30;
this.add.text(LEFT_W / 2, oppY, opponentCard?.name || this.opponentCommanderId, {
fontSize: '20px', color: '#ff8888', fontFamily: 'RaiderCrusader'
}).setOrigin(0.5);
const oppFaction = opponentCard?.faction || 'unknown';
this.add.text(LEFT_W / 2, oppY + 28, FACTION_LABELS[oppFaction] || oppFaction.toUpperCase(), {
fontSize: '12px', color: '#888888', fontFamily: 'Audiowide'
}).setOrigin(0.5);
// Opponent HP bar
this._drawHPBar(LEFT_W / 2, oppY + 52, this.opponentCommanderHP, '#ff6666', '#ff2222');
// Player commander info
const plyY = panelY + 120;
this.add.text(LEFT_W / 2, plyY, playerCard?.name || this.playerCommanderId, {
fontSize: '20px', color: '#88aaff', fontFamily: 'RaiderCrusader'
}).setOrigin(0.5);
const playerFaction = playerCard?.faction || 'unknown';
this.add.text(LEFT_W / 2, plyY + 28, FACTION_LABELS[playerFaction] || playerFaction.toUpperCase(), {
fontSize: '12px', color: '#888888', fontFamily: 'Audiowide'
}).setOrigin(0.5);
// Player HP bar
this._drawHPBar(LEFT_W / 2, plyY + 52, this.playerCommanderHP, '#6688ff', '#4466dd');
}
_drawHPBar(cx, y, hpData, fillColor, borderColor) {
const barW = 280;
const barH = 18;
const x = cx - barW / 2;
// Background
this.add.rectangle(cx, y, barW, barH, 0x111122).setStrokeStyle(1, Phaser.Display.Color.HexStringToColor(borderColor).color);
// Fill
const ratio = Math.max(0, hpData.current / hpData.max);
if (ratio > 0) {
const fillW = barW * ratio;
this.add.rectangle(x + fillW / 2, y, fillW, barH - 4, Phaser.Display.Color.HexStringToColor(fillColor).color, 0.8);
}
// Text
this.add.text(cx, y, `${Math.max(0, hpData.current)} / ${hpData.max} HP`, {
fontSize: '12px', color: '#ffffff', fontFamily: 'Audiowide'
}).setOrigin(0.5);
}
// ════════════════════════════════════════════════════════════════════════════
// ── Title ──────────────────────────────────────────────────────────────────
// ════════════════════════════════════════════════════════════════════════════
_showTitle() {
const cx = LEFT_W + (1920 - LEFT_W) / 2;
const title = this.add.text(cx, 50, this.won ? 'VICTORY!' : 'DEFEAT', {
fontSize: '72px',
color: this.won ? '#44ff44' : '#ff4444',
stroke: '#000000', strokeThickness: 6,
fontFamily: 'RaiderCrusader'
}).setOrigin(0.5).setAlpha(0).setScale(0.5);
this.tweens.add({
targets: title,
alpha: 1, scaleX: 1, scaleY: 1,
duration: 500,
ease: 'Back.Out'
});
// Subtitle
const missionName = this.missionData?.name || 'Skirmish Engagement';
const subtitle = this.add.text(cx, 105, missionName, {
fontSize: '22px', color: '#aaaaaa', fontFamily: 'Audiowide'
}).setOrigin(0.5).setAlpha(0);
this.tweens.add({
targets: subtitle,
alpha: 1,
duration: 400,
delay: 200
});
}
// ════════════════════════════════════════════════════════════════════════════
// ── Animated Gold Counter ──────────────────────────────────────────────────
// ════════════════════════════════════════════════════════════════════════════
_animateGoldCounter() {
const cx = LEFT_W + (1920 - LEFT_W) / 2;
const goldText = this.add.text(cx, 138, '+0 Gold', {
fontSize: '32px', color: '#ffd700', fontFamily: 'RaiderCrusader',
stroke: '#000000', strokeThickness: 3
}).setOrigin(0.5).setAlpha(0);
this.tweens.add({ targets: goldText, alpha: 1, duration: 300 });
// Tick up from 0 to rewardGold
const counter = { value: 0 };
this.tweens.add({
targets: counter,
value: this.rewardGold,
duration: 1500,
ease: 'Quad.Out',
onUpdate: () => {
goldText.setText(`+${Math.round(counter.value)} Gold`);
}
});
}
// ════════════════════════════════════════════════════════════════════════════
// ── CRT Terminal ───────────────────────────────────────────────────────────
// ════════════════════════════════════════════════════════════════════════════
_buildDebriefTerminal() {
this._termContainer = this.add.container(0, 0);
const tx = TERM_X;
const ty = TERM_Y;
const tw = TERM_W;
const th = TERM_H;
this._termBoundsX = tx;
this._termBoundsY = ty;
this._termBoundsW = tw;
this._termBoundsH = th;
// Outer border
const termBg = this.add.rectangle(tx + tw / 2, ty + th / 2, tw, th, TERM_BG_HEX, 0.95)
.setStrokeStyle(2, AMBER_DARK_HEX);
this._termContainer.add(termBg);
// Inner border
const innerBorder = this.add.rectangle(tx + tw / 2, ty + th / 2, tw - 8, th - 8)
.setStrokeStyle(1, AMBER_BG_HEX)
.setFillStyle(0x000000, 0);
this._termContainer.add(innerBorder);
// Header bar
const headerBar = this.add.rectangle(tx + tw / 2, ty + 18, tw - 12, 28, AMBER_DARK_HEX, 0.3);
this._termContainer.add(headerBar);
const headerText = this.add.text(tx + 16, ty + 10, '\u2588 AFTER-ACTION REPORT v2.7', {
fontSize: '14px', color: AMBER_DIM, fontFamily: 'monospace'
});
this._termContainer.add(headerText);
// Status indicator
this._statusIndicator = this.add.text(tx + tw - 16, ty + 10, '[\u25CF ONLINE]', {
fontSize: '14px', color: '#44aa33', fontFamily: 'monospace'
}).setOrigin(1, 0);
this._termContainer.add(this._statusIndicator);
// Tactical grid overlay
const grid = this.add.graphics();
grid.lineStyle(1, 0x331100, 0.06);
for (let gx = tx; gx < tx + tw; gx += 40) {
grid.lineBetween(gx, ty, gx, ty + th);
}
for (let gy = ty; gy < ty + th; gy += 40) {
grid.lineBetween(tx, gy, tx + tw, gy);
}
this._termContainer.add(grid);
// Scanlines
const scanlines = this.add.graphics();
scanlines.fillStyle(0x000000, 0.1);
for (let y = ty; y < ty + th; y += 3) {
scanlines.fillRect(tx, y, tw, 1);
}
this._termContainer.add(scanlines);
// Vignette
this._buildVignette(tx, ty, tw, th);
// Flicker
this.time.addEvent({
delay: 100, loop: true,
callback: () => {
if (this._termContainer) this._termContainer.alpha = 0.96 + Math.random() * 0.04;
}
});
// Status blink
this.time.addEvent({
delay: 800, loop: true,
callback: () => {
if (this._statusIndicator) this._statusIndicator.setVisible(!this._statusIndicator.visible);
}
});
// Text content area
this._termTextX = tx + 20;
this._termTextY = ty + 44;
this._termMaxW = tw - 40;
this._termLineH = 22;
this._currentTermLine = 0;
// Cursor
this._cursor = this.add.text(this._termTextX, this._termTextY, '\u2588', {
fontSize: '18px', color: AMBER, fontFamily: 'monospace'
});
this._termContainer.add(this._cursor);
this._cursor.setVisible(false);
this._cursorTimer = this.time.addEvent({
delay: 400, loop: true,
callback: () => {
if (this._cursor) this._cursor.setVisible(!this._cursor.visible);
}
});
}
_buildVignette(tx, ty, tw, th) {
const vig = this.add.graphics();
for (let i = 0; i < 30; i++) {
vig.fillStyle(0x000000, 0.3 * (1 - i / 30));
vig.fillRect(tx, ty + i, tw, 1);
}
for (let i = 0; i < 30; i++) {
vig.fillStyle(0x000000, 0.3 * (1 - i / 30));
vig.fillRect(tx, ty + th - i, tw, 1);
}
for (let i = 0; i < 20; i++) {
vig.fillStyle(0x000000, 0.25 * (1 - i / 20));
vig.fillRect(tx + i, ty, 1, th);
}
for (let i = 0; i < 20; i++) {
vig.fillStyle(0x000000, 0.25 * (1 - i / 20));
vig.fillRect(tx + tw - i, ty, 1, th);
}
this._termContainer.add(vig);
}
// ════════════════════════════════════════════════════════════════════════════
// ── Typewriter System ──────────────────────────────────────────────────────
// ════════════════════════════════════════════════════════════════════════════
_typeLines(lines, lineIdx, onComplete) {
this._onTypingComplete = onComplete;
this._pendingLines = lines;
this._doTypeLine(lines, lineIdx);
}
_doTypeLine(lines, lineIdx) {
if (lineIdx >= lines.length) {
if (this._onTypingComplete) this._onTypingComplete();
return;
}
const line = lines[lineIdx];
if (line === '') {
this._currentTermLine = (this._currentTermLine || 0) + 1;
const evt = this.time.delayedCall(20, () => this._doTypeLine(lines, lineIdx + 1));
this._typingEvents.push(evt);
return;
}
this._currentTermLine = this._currentTermLine || 0;
const y = this._termTextY + this._currentTermLine * this._termLineH;
// Check overflow
const maxY = this._termBoundsY + this._termBoundsH - 40;
if (y + this._termLineH > maxY) {
this._scrollTerminal();
this._doTypeLine(lines, lineIdx);
return;
}
const textObj = this.add.text(this._termTextX, y, '', {
fontSize: '16px', color: AMBER, fontFamily: 'monospace',
wordWrap: { width: this._termMaxW }
});
this._termContainer.add(textObj);
this._terminalTextObjects.push(textObj);
let charIdx = 0;
const typeChar = () => {
charIdx = Math.min(charIdx + 5, line.length);
textObj.setText(line.substring(0, charIdx));
if (charIdx % 15 === 0) this._playKeyclick();
this._updateCursorPos(textObj);
if (charIdx >= line.length) {
const wrappedCount = Math.max(1, Math.round(textObj.height / this._termLineH));
this._currentTermLine += wrappedCount;
const evt = this.time.delayedCall(24, () => this._doTypeLine(lines, lineIdx + 1));
this._typingEvents.push(evt);
return;
}
const evt = this.time.delayedCall(4, typeChar);
this._typingEvents.push(evt);
};
if (this._cursor) this._cursor.setVisible(true);
typeChar();
}
_scrollTerminal() {
if (this._terminalTextObjects.length > 0) {
const oldest = this._terminalTextObjects.shift();
if (oldest && oldest.scene) oldest.destroy();
}
for (const t of this._terminalTextObjects) {
t.y -= this._termLineH;
}
this._currentTermLine--;
}
_updateCursorPos(textObj) {
if (!this._cursor || !textObj) return;
this._cursor.setPosition(textObj.x + textObj.width + 2, textObj.y);
this._cursor.setVisible(true);
}
_playKeyclick() {
try {
const ctx = this.sound.context;
if (!ctx) return;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = 800 + Math.random() * 200;
gain.gain.value = 0.04;
osc.start(ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.03);
osc.stop(ctx.currentTime + 0.03);
} catch (e) { /* WebAudio not available */ }
}
_skipTyping() {
// Cancel all pending typing events
for (const evt of this._typingEvents) {
if (evt && evt.remove) evt.remove();
}
this._typingEvents = [];
// Render all remaining lines instantly
if (this._pendingLines) {
// Clear current terminal text
for (const t of this._terminalTextObjects) {
if (t && t.scene) t.destroy();
}
this._terminalTextObjects = [];
this._currentTermLine = 0;
for (const line of this._pendingLines) {
if (line === '') {
this._currentTermLine++;
continue;
}
const y = this._termTextY + this._currentTermLine * this._termLineH;
const maxY = this._termBoundsY + this._termBoundsH - 40;
if (y + this._termLineH > maxY) {
this._scrollTerminal();
}
const finalY = this._termTextY + this._currentTermLine * this._termLineH;
const textObj = this.add.text(this._termTextX, finalY, line, {
fontSize: '16px', color: AMBER, fontFamily: 'monospace',
wordWrap: { width: this._termMaxW }
});
this._termContainer.add(textObj);
this._terminalTextObjects.push(textObj);
const wrappedCount = Math.max(1, Math.round(textObj.height / this._termLineH));
this._currentTermLine += wrappedCount;
}
}
if (this._cursor) this._cursor.setVisible(false);
this._typingComplete = true;
if (this._onTypingComplete) {
this._onTypingComplete();
this._onTypingComplete = null;
}
}
// ════════════════════════════════════════════════════════════════════════════
// ── Stats Computation ──────────────────────────────────────────────────────
// ════════════════════════════════════════════════════════════════════════════
_computeStats() {
const stats = {
playerDamage: 0,
opponentDamage: 0,
playerDeploys: 0,
opponentDeploys: 0,
playerDeaths: 0,
opponentDeaths: 0,
playerDotDamage: 0,
opponentDotDamage: 0
};
for (const e of this.battleEvents) {
switch (e.type) {
case 'attack':
if (e.side === 'player') stats.playerDamage += (e.damage || 0);
else stats.opponentDamage += (e.damage || 0);
break;
case 'counter':
// Counter damage is dealt by the defender back to the attacker
if (e.side === 'player') stats.opponentDamage += (e.damage || 0);
else stats.playerDamage += (e.damage || 0);
break;
case 'deploy':
if (e.side === 'player') stats.playerDeploys++;
else stats.opponentDeploys++;
break;
case 'death':
if (e.side === 'player') stats.playerDeaths++;
else stats.opponentDeaths++;
break;
case 'woundTick':
case 'venomTick':
case 'smiteTick':
if (e.side === 'player') stats.playerDotDamage += (e.damage || 0);
else stats.opponentDotDamage += (e.damage || 0);
break;
}
}
// Total damage includes DoT
stats.playerTotalDamage = stats.playerDamage + stats.opponentDotDamage; // DoT on opponent side hurts opponent
stats.opponentTotalDamage = stats.opponentDamage + stats.playerDotDamage;
return stats;
}
_computeRating() {
if (!this.won) return { grade: 'F', text: 'Mission failure' };
const ratio = this.playerCommanderHP.current / this.playerCommanderHP.max;
if (ratio >= 0.9) return { grade: 'S', text: 'Flawless execution' };
if (ratio >= 0.7) return { grade: 'A', text: 'Excellent performance' };
if (ratio >= 0.5) return { grade: 'B', text: 'Solid operation' };
if (ratio >= 0.25) return { grade: 'C', text: 'Pyrrhic victory' };
return { grade: 'D', text: 'Barely survived' };
}
// ════════════════════════════════════════════════════════════════════════════
// ── Debrief Content ────────────────────────────────────────────────────────
// ════════════════════════════════════════════════════════════════════════════
_buildDebriefLines(stats) {
const rating = this._computeRating();
const missionName = this.missionData?.name || 'SKIRMISH ENGAGEMENT';
const lines = [
`> INITIALIZING AFTER-ACTION REPORT...`,
`> DECRYPTING COMBAT TELEMETRY...`,
'',
`> \u2500\u2500\u2500\u2500 AFTER-ACTION REPORT \u2500\u2500\u2500\u2500`,
`> OPERATION: ${missionName.toUpperCase()}`,
`> RESULT: ${this.won ? 'VICTORY' : 'DEFEAT'}`,
`> DURATION: ${this.turnCount} TURNS`,
'',
`> \u2500\u2500\u2500\u2500 COMBAT METRICS \u2500\u2500\u2500\u2500`,
`> DAMAGE DEALT: ${stats.playerTotalDamage}`,
`> DAMAGE RECEIVED: ${stats.opponentTotalDamage}`,
`> UNITS DEPLOYED: ${stats.playerDeploys}`,
`> UNITS LOST: ${stats.playerDeaths}`,
`> ENEMY UNITS DESTROYED: ${stats.opponentDeaths}`,
];
if (stats.playerDotDamage > 0 || stats.opponentDotDamage > 0) {
lines.push(`> STATUS EFFECT DAMAGE: ${stats.opponentDotDamage} DEALT / ${stats.playerDotDamage} RECEIVED`);
}
lines.push('');
lines.push(`> \u2500\u2500\u2500\u2500 COMMANDER STATUS \u2500\u2500\u2500\u2500`);
lines.push(`> YOUR COMMANDER: ${Math.max(0, this.playerCommanderHP.current)} / ${this.playerCommanderHP.max} HP`);
lines.push(`> ENEMY COMMANDER: ${Math.max(0, this.opponentCommanderHP.current)} / ${this.opponentCommanderHP.max} HP`);
lines.push('');
lines.push(`> \u2500\u2500\u2500\u2500 PERFORMANCE RATING \u2500\u2500\u2500\u2500`);
lines.push(`> RATING: ${rating.grade} \u2014 ${rating.text}`);
lines.push('');
lines.push(`> \u2500\u2500\u2500\u2500 END TRANSMISSION \u2500\u2500\u2500\u2500`);
return lines;
}
// ════════════════════════════════════════════════════════════════════════════
// ── Post-Debrief: Card Reveal & Continue ───────────────────────────────────
// ════════════════════════════════════════════════════════════════════════════
_onDebriefComplete() {
if (this._cursor) this._cursor.setVisible(false);
const hasCardRewards = this.isFirstClear && this.rewardCards && this.rewardCards.length > 0;
const hasCommanderUnlocks = this.unlockedCommanders && this.unlockedCommanders.length > 0;
if (hasCardRewards || hasCommanderUnlocks) {
this.time.delayedCall(400, () => this._startCardReveal());
} else {
this.time.delayedCall(400, () => this._showContinueButton());
}
}
// ════════════════════════════════════════════════════════════════════════════
// ── Card Reveal ────────────────────────────────────────────────────────────
// ════════════════════════════════════════════════════════════════════════════
_startCardReveal() {
const cardManager = this.registry.get('cardManager');
const save = this.registry.get('save');
// Combine reward cards and unlocked commanders
const allCardIds = [];
if (this.rewardCards) allCardIds.push(...this.rewardCards);
if (this.unlockedCommanders) {
for (const cmd of this.unlockedCommanders) {
allCardIds.push(cmd.id);
}
}
if (allCardIds.length === 0) {
this._showContinueButton();
return;
}
// Generate particle texture if needed
if (!this.textures.exists('result_particle')) {
const gfx = this.make.graphics({ add: false });
gfx.fillStyle(0xffffff);
gfx.fillCircle(4, 4, 4);
gfx.generateTexture('result_particle', 8, 8);
gfx.destroy();
}
// "REWARDS" header
const cx = LEFT_W + (1920 - LEFT_W) / 2;
const rewardsY = TERM_Y + TERM_H + 20;
const rewardsLabel = this.add.text(cx, rewardsY, this.unlockedCommanders?.length ? 'REWARDS & UNLOCKED COMMANDERS' : 'MISSION REWARDS', {
fontSize: '20px', color: '#ffd700', fontFamily: 'Audiowide'
}).setOrigin(0.5).setAlpha(0);
this.tweens.add({ targets: rewardsLabel, alpha: 1, duration: 300 });
this._revealObjects.push(rewardsLabel);
// Card layout
const CARD_W = 180;
const CARD_H = 247;
const GAP = 30;
const cardY = rewardsY + CARD_H / 2 + 35;
const totalW = allCardIds.length * CARD_W + (allCardIds.length - 1) * GAP;
const startX = cx - totalW / 2 + CARD_W / 2;
const cardBacks = [];
const cardFronts = [];
allCardIds.forEach((cardId, i) => {
const cardData = cardManager.getCard(cardId);
const cardX = startX + i * (CARD_W + GAP);
// Card back
const backContainer = this.add.container(cardX, cardY).setDepth(101);
const backKey = CARD_BACK_KEYS[cardData?.faction] || 'card_back_imperial';
const backImg = this.add.image(0, 0, backKey).setDisplaySize(CARD_W, CARD_H);
backContainer.add(backImg);
// Golden border for commanders
const isCommander = this.unlockedCommanders?.some(c => c.id === cardId);
if (isCommander) {
const goldBorder = this.add.rectangle(0, 0, CARD_W + 8, CARD_H + 8)
.setStrokeStyle(4, 0xffd700).setFillStyle(0x000000, 0);
backContainer.addAt(goldBorder, 0);
this.tweens.add({
targets: goldBorder,
strokeAlpha: { from: 0.5, to: 1 },
duration: 600, yoyo: true, repeat: -1, ease: 'Sine.InOut'
});
}
// Entrance animation
backContainer.setAlpha(0).setScale(0.8);
this.tweens.add({
targets: backContainer,
alpha: 1, scaleX: 1, scaleY: 1,
duration: 300, delay: i * 100, ease: 'Back.Out'
});
cardBacks.push(backContainer);
this._revealObjects.push(backContainer);
// Card front (hidden)
const instance = cardManager.createInstance(cardId, save.level || 1);
const cardObj = new CardObject(this, cardX, cardY, instance, {
width: CARD_W, height: CARD_H, disableTooltip: true
});
cardObj.setDepth(101).setAlpha(0);
cardObj.scaleX = 0;
cardFronts.push({ cardObj, card: cardData, x: cardX, y: cardY, isCommander });
this._revealObjects.push(cardObj);
});
// Begin reveal after entrance animations
const initialDelay = 600 + allCardIds.length * 100;
this.time.delayedCall(initialDelay, () => {
this._revealCards(cardBacks, cardFronts, 0);
});
}
_revealCards(backs, fronts, index) {
if (index >= backs.length) {
this.time.delayedCall(600, () => this._showContinueButton());
return;
}
const back = backs[index];
const { cardObj, card, x, y, isCommander } = fronts[index];
const CARD_W = 180;
const CARD_H = 247;
// Flip: back scaleX → 0
this.tweens.add({
targets: back,
scaleX: 0,
duration: 150,
ease: 'Quad.In',
onComplete: () => {
back.setVisible(false);
if (this.sound.get('sfx_store_card')) {
this.sound.play('sfx_store_card', { volume: 0.8 });
}
// Show front, flip in
cardObj.setAlpha(1);
this.tweens.add({
targets: cardObj,
scaleX: 1,
duration: 150,
ease: 'Quad.Out',
onComplete: () => {
// Bonus sound for rare+ or commanders
if (card && (['rare', 'epic', 'legendary'].includes(card.rarity) || isCommander)) {
if (this.sound.get('sfx_store_card_bonus')) {
this.sound.play('sfx_store_card_bonus', { volume: 0.9 });
}
}
// Pop effect
this.tweens.add({
targets: cardObj,
scaleX: 1.2, scaleY: 1.2,
duration: 150,
ease: 'Quad.Out',
onComplete: () => {
this.tweens.add({
targets: cardObj,
scaleX: 1, scaleY: 1,
duration: 200,
ease: 'Back.Out'
});
}
});
// Rarity glow
const rarity = card?.rarity || 'common';
const glowColor = isCommander ? 0xffd700 : (RARITY_COLORS[rarity] || 0x888888);
if (cardObj.postFX) {
try { cardObj.postFX.addGlow(glowColor, 8, 0, false, 0.2, 12); } catch (e) { /* no postFX */ }
}
// Particle emitter
const hw = CARD_W / 2;
const hh = CARD_H / 2;
if (this.textures.exists('result_particle')) {
const emitter = this.add.particles(x, y, 'result_particle', {
speed: { min: 30, max: 80 },
lifespan: 1400,
scale: { start: 0.8, end: 0 },
alpha: { start: 0.8, end: 0 },
blendMode: 'ADD',
frequency: 50,
tint: glowColor,
emitZone: {
type: 'edge',
source: new Phaser.Geom.Rectangle(-hw, -hh, CARD_W, CARD_H),
quantity: 48
}
});
emitter.setDepth(102);
this._emitters.push(emitter);
this._revealObjects.push(emitter);
}
// Label: "NEW!" or "COMMANDER UNLOCKED!"
const labelText = isCommander ? 'UNLOCKED!' : 'NEW!';
const labelColor = isCommander ? '#ffd700' : '#44ff44';
const badge = this.add.text(x, y - hh - 20, labelText, {
fontSize: '18px', color: labelColor, fontStyle: 'bold',
stroke: '#000000', strokeThickness: 4, fontFamily: 'Audiowide'
}).setOrigin(0.5).setDepth(103).setAlpha(0);
this.tweens.add({ targets: badge, alpha: 1, duration: 200 });
this._revealObjects.push(badge);
// Rarity label
const rarityHex = '#' + glowColor.toString(16).padStart(6, '0');
const rarityLabel = this.add.text(x, y + hh + 14, (rarity || '').toUpperCase(), {
fontSize: '14px', color: rarityHex, fontFamily: 'Audiowide'
}).setOrigin(0.5).setDepth(103).setAlpha(0);
this.tweens.add({ targets: rarityLabel, alpha: 1, duration: 200 });
this._revealObjects.push(rarityLabel);
// Card name
const cardName = this.add.text(x, y + hh + 34, card?.name || '', {
fontSize: '14px', color: '#ffffff', fontFamily: 'Audiowide',
align: 'center', wordWrap: { width: CARD_W + 20 }
}).setOrigin(0.5).setDepth(103).setAlpha(0);
this.tweens.add({ targets: cardName, alpha: 1, duration: 200 });
this._revealObjects.push(cardName);
}
});
}
});
// Reveal next card after delay
this.time.delayedCall(500, () => {
this._revealCards(backs, fronts, index + 1);
});
}
// ════════════════════════════════════════════════════════════════════════════
// ── Continue Button ────────────────────────────────────────────────────────
// ════════════════════════════════════════════════════════════════════════════
_showContinueButton() {
const cx = LEFT_W + (1920 - LEFT_W) / 2;
const btnY = 1080 - 50;
const btnBg = this.add.rectangle(cx, btnY, 300, 60, 0x1a3a5c)
.setInteractive({ useHandCursor: true })
.setStrokeStyle(2, 0x4488ff)
.setAlpha(0);
const btnText = this.add.text(cx, btnY, 'CONTINUE', {
fontSize: '26px', color: '#ffffff', fontFamily: 'Audiowide'
}).setOrigin(0.5).setAlpha(0);
// Fade in
this.tweens.add({ targets: [btnBg, btnText], alpha: 1, duration: 400 });
// Pulsing glow
this.tweens.add({
targets: btnBg,
strokeAlpha: { from: 0.5, to: 1 },
duration: 1200, yoyo: true, repeat: -1, ease: 'Sine.InOut'
});
btnBg.on('pointerover', () => btnBg.setFillStyle(0x2a4a6c));
btnBg.on('pointerout', () => btnBg.setFillStyle(0x1a3a5c));
btnBg.on('pointerdown', () => {
// Stop result music and resume menu music
if (this._resultMusic && this._resultMusic.isPlaying) {
this._resultMusic.stop();
}
const menuMusic = this.registry.get('music_main_menu');
if (menuMusic && !menuMusic.isPlaying) menuMusic.play();
// Stop emitters
for (const e of this._emitters) {
if (e.stop) e.stop();
}
if (!this.missionData) {
// Skirmish → Main Menu
this.scene.start('MainMenuScene');
} else if (this.isFinalMission) {
// Final campaign mission → Campaign Select
this.scene.start('CampaignSelectScene');
} else {
// Regular mission → Campaign Scene
this.scene.start('CampaignScene', { campaignId: this.campaignId || 'campaign_raider' });
}
});
}
}

View File

@ -67,14 +67,21 @@ export class BattleScene extends Phaser.Scene {
const cardManager = this.registry.get('cardManager');
const save = this.registry.get('save');
// Accumulate all battle events across turns for post-battle stats
this._allBattleEvents = [];
// 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
// Stop battle music when this scene shuts down.
// Menu music is resumed by the destination scene (BattleResultScene handles its own music).
this.events.once('shutdown', () => {
this._stopBattleMusic();
const menuMusic = this.registry.get('music_main_menu');
if (menuMusic && !menuMusic.isPlaying) menuMusic.play();
// Only resume menu music if NOT going to the result scene
if (!this._goingToResultScene) {
const menuMusic = this.registry.get('music_main_menu');
if (menuMusic && !menuMusic.isPlaying) menuMusic.play();
}
});
this._startBattleMusic();
@ -759,6 +766,7 @@ export class BattleScene extends Phaser.Scene {
);
const { hand, canDeploy, playerGoesFirst } = this.engine.beginTurn();
this._allBattleEvents.push(...this.engine.events);
this.playerGoesFirst = playerGoesFirst;
this.turnText.setText(`Turn ${this.engine.turn}`);
this._renderState();
@ -809,6 +817,7 @@ export class BattleScene extends Phaser.Scene {
// Deploy + pre-attack phases (NO damage applied yet)
// On player-first turns, beginCommit also deploys the AI card
const commitEvents = this.engine.beginCommit(chosenCard);
this._allBattleEvents.push(...commitEvents);
const preBattleEvents = commitEvents.filter(e => e.type === 'preBattle' || e.type === 'woundTick' || e.type === 'venomTick' || e.type === 'smiteTick' || e.type === 'carapace' || e.type === 'molt' || e.type === 'hive_link');
// Build preBattle hive_link map for venom kills: dead card instanceId → hive_link event
@ -883,6 +892,7 @@ export class BattleScene extends Phaser.Scene {
if (!this.engine.hasPendingAttacks()) {
// All attacks resolved — run death-check, win-check, then tidy up display
const postBattleEvents = this.engine.finalizeCommit();
this._allBattleEvents.push(...postBattleEvents);
this._renderState(); // removes dead cards, shows clean final state
const state = this.engine.getState();
@ -904,6 +914,7 @@ export class BattleScene extends Phaser.Scene {
}
const events = this.engine.processNextAttack();
this._allBattleEvents.push(...events);
const preAttackEvent = events.find(e => e.type === 'preAttack');
const postAttackEvent = events.find(e => e.type === 'postAttack');
@ -4659,104 +4670,88 @@ export class BattleScene extends Phaser.Scene {
if (this._resultShown) return;
this._resultShown = true;
if (this.autoTimer) this.autoTimer.remove();
const { width, height } = this.scale;
const won = this.engine.winner === 'player';
const save = this.registry.get('save');
const state = this.engine.getState();
// 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);
// ── Save logic (kept here for data integrity) ────────────────────────────
let rewardGold = 0;
let rewardCards = null;
let unlockedCommanders = null;
let isFirstClear = false;
let isFinalMission = false;
if (won && this.missionData) {
const rewards = this.missionData.rewards;
const firstClear = !save.campaignProgress.completedMissions.includes(this.missionData.id);
isFirstClear = !save.campaignProgress.completedMissions.includes(this.missionData.id);
rewardGold = rewards.gold;
SaveManager.addGold(save, rewards.gold);
SaveManager.completeMission(save, this.missionData.id);
// Cards only awarded on first completion
if (firstClear && rewards.cards) {
if (isFirstClear && rewards.cards) {
rewardCards = rewards.cards;
rewards.cards.forEach(cardId => SaveManager.addCard(save, cardId));
}
// Check if this is the final mission of a campaign — if so, unlock all faction commanders (first clear only)
let unlockedCommanders = [];
if (firstClear) {
// Check if final mission of campaign — unlock faction commanders on first clear
if (isFirstClear) {
const campaigns = this.registry.get('campaigns') || [];
const campaign = campaigns.find(c => c.missions && c.missions[c.missions.length - 1] === this.missionData.id);
if (campaign) {
isFinalMission = true;
const faction = this.missionData.campaignId.replace('campaign_', '');
const cardManager = this.registry.get('cardManager');
const factionCommanders = cardManager.getAllCards().filter(c => c.type === 'commander' && c.faction === faction);
unlockedCommanders = [];
for (const cmd of factionCommanders) {
if (!save.collection[cmd.id]) {
SaveManager.addCard(save, cmd.id);
unlockedCommanders.push(cmd);
}
}
if (unlockedCommanders.length === 0) unlockedCommanders = null;
}
}
this.registry.set('save', save);
this.add.text(width / 2, height / 2 + 20, `+${rewards.gold} Gold`, {
fontSize: '36px', color: '#ffd700', fontFamily: 'RaiderCrusader'
}).setOrigin(0.5);
if (firstClear && rewards.cards && rewards.cards.length > 0) {
const cardManager = this.registry.get('cardManager');
const cardNames = rewards.cards.map(id => {
const c = cardManager.getCard(id);
return c ? c.name : id;
}).join(', ');
this.add.text(width / 2, height / 2 + 70, `New card(s): ${cardNames}`, {
fontSize: '22px', color: '#aaffaa', fontFamily: 'Audiowide'
}).setOrigin(0.5);
}
if (unlockedCommanders.length > 0) {
const names = unlockedCommanders.map(c => c.name).join(', ');
this.add.text(width / 2, height / 2 + 110, `Commanders unlocked: ${names}!`, {
fontSize: '20px', color: '#ffdd44', fontFamily: 'Audiowide'
}).setOrigin(0.5);
}
} else if (!won && this.missionData) {
const consolationGold = Math.ceil(this.missionData.rewards.gold / 2);
rewardGold = consolationGold;
SaveManager.addGold(save, consolationGold);
this.registry.set('save', save);
this.add.text(width / 2, height / 2 + 20, 'Defeat...', {
fontSize: '28px', color: '#aaaaaa', fontFamily: 'Audiowide'
}).setOrigin(0.5);
this.add.text(width / 2, height / 2 + 60, `+${consolationGold} Gold (consolation)`, {
fontSize: '22px', color: '#aa8800', fontFamily: 'Audiowide'
}).setOrigin(0.5);
} else if (!won) {
this.add.text(width / 2, height / 2 + 20, 'Better luck next time!', {
fontSize: '24px', color: '#aaaaaa', fontFamily: 'Audiowide'
}).setOrigin(0.5);
}
const backBtn = this.add.rectangle(width / 2, height / 2 + 160, 260, 60, 0x1a3a5c)
.setInteractive({ useHandCursor: true })
.setStrokeStyle(2, 0x4488ff);
this.add.text(width / 2, height / 2 + 160, 'Continue', {
fontSize: '28px', color: '#ffffff', fontFamily: 'Audiowide'
}).setOrigin(0.5);
backBtn.on('pointerdown', () => {
if (this.missionData) {
this.scene.start('CampaignScene', { campaignId: this.campaignId || 'campaign_raider' });
} else {
this.scene.start('MainMenuScene');
}
// ── Determine opponent commander ID ──────────────────────────────────────
const opponentCommanderId = this.missionData?.opponent?.commander
|| this.engine.opponentCommander?.id
|| 'raider_cmd_1';
// ── Transition to BattleResultScene ──────────────────────────────────────
this._goingToResultScene = true;
this.scene.start('BattleResultScene', {
won,
turnCount: this.engine.turn,
battleEvents: this._allBattleEvents || [],
battleLog: this.engine.log,
playerCommanderId: this.playerDeckData.commander,
opponentCommanderId,
playerCommanderHP: {
current: state.player.commander.currentHP,
max: state.player.commander.health
},
opponentCommanderHP: {
current: state.opponent.commander.currentHP,
max: state.opponent.commander.health
},
missionData: this.missionData,
campaignId: this.campaignId,
isFirstClear,
rewardGold,
rewardCards,
unlockedCommanders,
isFinalMission
});
}

View File

@ -73,6 +73,8 @@ export class BootScene extends Phaser.Scene {
this.load.audio('sfx_fuse_card', 'assets/audio/fx/fuse-card.mp3');
this.load.audio('music_main_menu', 'assets/audio/music/main_menu.mp3');
this.load.audio('music_victory', 'assets/audio/music/victory.mp3');
this.load.audio('music_defeat', 'assets/audio/music/defeat.mp3');
// Once cards.json finishes, queue card art images and resume loading
this.load.once('filecomplete-json-cards', (_key, _type, data) => {