Add campaign ending epilogue

This commit is contained in:
2026-06-25 03:47:41 +09:00
parent e97ec0ada2
commit cb40be9940
8 changed files with 298 additions and 25 deletions

View File

@@ -10954,6 +10954,11 @@ export class CampScene extends Phaser.Scene {
});
this.addCommandButton('다음 이야기', 1160, 38, 142, () => {
soundDirector.playSelect();
const flow = this.currentSortieFlow();
if (this.isFinalEpilogueFlow(flow)) {
this.startVictoryStory();
return;
}
this.showSortiePrep();
});
}
@@ -11795,7 +11800,7 @@ export class CampScene extends Phaser.Scene {
private startVictoryStory() {
const flow = this.currentSortieFlow();
if (!this.ensureSortieSelectionSaved()) {
if (!this.isFinalEpilogueFlow(flow) && !this.ensureSortieSelectionSaved()) {
const availableIds = new Set(this.sortieAllies().map((unit) => unit.id));
const requiredNames = [...this.requiredSortieUnitIdsFor()]
.filter((unitId) => availableIds.has(unitId))
@@ -11819,7 +11824,7 @@ export class CampScene extends Phaser.Scene {
this.campaign = getCampaignState();
this.scene.start('StoryScene', {
pages: flow.pages,
nextScene: 'CampScene'
nextScene: flow.campaignStep === 'ending-complete' ? 'EndingScene' : 'CampScene'
});
return;
}
@@ -11850,6 +11855,10 @@ export class CampScene extends Phaser.Scene {
});
}
private isFinalEpilogueFlow(flow = this.currentSortieFlow()) {
return flow.campaignStep === 'ending-complete' && !flow.nextBattleId;
}
private hideSortiePrep() {
this.sortieObjects.forEach((object) => object.destroy());
this.sortieObjects = [];

View File

@@ -0,0 +1,178 @@
import Phaser from 'phaser';
import { soundDirector } from '../audio/SoundDirector';
import { storyBackgroundAssets } from '../data/storyAssets';
import { getCampaignState } from '../state/campaignState';
import { palette } from '../ui/palette';
export class EndingScene extends Phaser.Scene {
private readonly backgroundKey = 'story-weishui-northbank';
private ready = false;
constructor() {
super('EndingScene');
}
create() {
this.ready = false;
this.createFallbackTexture();
this.ensureBackgroundLoaded(() => {
this.ready = true;
this.drawEnding();
});
}
getDebugState() {
const campaign = getCampaignState();
return {
scene: this.scene.key,
ready: this.ready,
campaignStep: campaign.step,
latestBattleId: campaign.latestBattleId ?? null,
completedBattles: Object.keys(campaign.battleHistory).length,
rosterCount: campaign.roster.length,
endingTitle: '북벌의 끝과 남은 뜻'
};
}
private ensureBackgroundLoaded(onReady: () => void) {
if (this.textures.exists(this.backgroundKey)) {
onReady();
return;
}
const url = storyBackgroundAssets[this.backgroundKey];
if (!url) {
onReady();
return;
}
this.load.once('complete', onReady);
this.load.once('loaderror', () => {
console.warn(`Failed to load ending background ${this.backgroundKey}`);
});
this.load.image(this.backgroundKey, url);
this.load.start();
}
private createFallbackTexture() {
if (this.textures.exists('ending-fallback')) {
return;
}
const graphics = this.make.graphics({ x: 0, y: 0 });
graphics.fillStyle(0x080b10, 1);
graphics.fillRect(0, 0, 1280, 720);
graphics.fillStyle(0x152331, 1);
graphics.fillRect(0, 0, 1280, 720);
graphics.lineStyle(5, palette.gold, 0.42);
graphics.beginPath();
graphics.moveTo(120, 570);
graphics.lineTo(1160, 170);
graphics.strokePath();
graphics.generateTexture('ending-fallback', 1280, 720);
graphics.destroy();
}
private drawEnding() {
const { width, height } = this.scale;
const campaign = getCampaignState();
const textureKey = this.textures.exists(this.backgroundKey) ? this.backgroundKey : 'ending-fallback';
const background = this.add.image(width / 2, height / 2, textureKey);
const source = this.textures.get(textureKey).getSourceImage();
const coverScale = Math.max(width / source.width, height / source.height);
background.setScale(coverScale * 1.08);
background.setPosition(width / 2 - 14, height / 2 + 2);
this.tweens.add({
targets: background,
x: width / 2 + 14,
y: height / 2 - 6,
scale: coverScale * 1.12,
duration: 18000,
ease: 'Sine.easeInOut',
yoyo: true,
repeat: -1
});
this.add.rectangle(0, 0, width, height, 0x04070b, 0.34).setOrigin(0);
this.add.rectangle(0, 0, width, height, 0x05080c, 0.16).setOrigin(0);
this.add.rectangle(0, height - 220, width, 220, 0x05070a, 0.64).setOrigin(0);
const completedBattles = Object.keys(campaign.battleHistory).length;
const titleY = Math.max(96, height * 0.18);
this.add.text(width / 2, titleY, '북벌의 끝과 남은 뜻', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", serif',
fontSize: '48px',
color: '#f6e6bd',
fontStyle: '700',
align: 'center',
shadow: { offsetX: 0, offsetY: 3, color: '#020408', blur: 8, fill: true }
}).setOrigin(0.5);
this.add.text(width / 2, titleY + 62, '촉한 군영 후일담 완료', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '22px',
color: '#d8b15f',
fontStyle: '700',
align: 'center',
shadow: { offsetX: 0, offsetY: 2, color: '#020408', blur: 6, fill: true }
}).setOrigin(0.5);
const summary = [
`완료 전투 ${completedBattles}`,
`최종 전장 ${campaign.latestBattleId ? '위수 북안 압박전' : '기록 없음'}`,
`합류 무장 ${campaign.roster.length}`,
`군영 공명 ${campaign.bonds.length}`
];
const startX = width / 2 - 390;
summary.forEach((line, index) => {
this.add.text(startX + index * 260, height - 178, line, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '18px',
color: '#f2e3bf',
fontStyle: '700',
shadow: { offsetX: 0, offsetY: 2, color: '#020408', blur: 5, fill: true }
}).setOrigin(0.5);
});
this.add.text(width / 2, height - 122, '도원에서 시작된 선택은 촉한의 깃발과 북벌의 장부를 지나, 다음 세대가 이어 받을 뜻으로 남았습니다.', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", serif',
fontSize: '22px',
color: '#e8dfca',
align: 'center',
wordWrap: { width: Math.min(920, width - 160), useAdvancedWrap: true },
lineSpacing: 8,
shadow: { offsetX: 0, offsetY: 2, color: '#020408', blur: 6, fill: true }
}).setOrigin(0.5);
this.addButton(width / 2, height - 52, '타이틀로', () => {
soundDirector.playSelect();
this.scene.start('TitleScene');
});
this.input.keyboard?.on('keydown-ENTER', () => this.scene.start('TitleScene'));
this.input.keyboard?.on('keydown-SPACE', () => this.scene.start('TitleScene'));
soundDirector.playMusic('oath-theme');
}
private addButton(x: number, y: number, label: string, action: () => void) {
const bg = this.add.rectangle(x, y, 160, 42, 0x1a2630, 0.96);
bg.setStrokeStyle(1, palette.gold, 0.82);
bg.setInteractive({ useHandCursor: true });
bg.on('pointerover', () => bg.setFillStyle(0x283947, 0.98));
bg.on('pointerout', () => bg.setFillStyle(0x1a2630, 0.96));
bg.on('pointerdown', action);
const text = this.add.text(x, y, label, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '17px',
color: '#f2e3bf',
fontStyle: '700'
});
text.setOrigin(0.5);
text.setInteractive({ useHandCursor: true });
text.on('pointerdown', action);
}
}

View File

@@ -364,6 +364,11 @@ export class TitleScene extends Phaser.Scene {
soundDirector.playSelect();
const campaign = loadCampaignState();
if (campaign.step === 'ending-complete') {
this.scene.start('EndingScene');
return;
}
if (
campaign.step === 'first-camp' ||
campaign.step === 'second-camp' ||