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:
parent
22b4b51883
commit
5b206fda33
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.
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue