Files
heros_web/src/game/scenes/StoryScene.ts

1661 lines
64 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Phaser from 'phaser';
import { soundDirector } from '../audio/SoundDirector';
import { equipmentItemIdForInventoryLabel } from '../data/battleItems';
import {
battleUiIconFrames,
battleUiIconMicroTextureKey,
battleUiIconTextureKey,
loadBattleUiIcons,
type BattleUiIconKey
} from '../data/battleUiIcons';
import { portraitAssetEntriesForKey, portraitKeyForSpeaker, type PortraitAssetEntry } from '../data/portraitAssets';
import {
storyBackgroundAssets,
storyBackgroundKeyForPage,
storyBackgroundKeysForPages
} from '../data/storyAssets';
import {
ensureUnitAnimations,
loadUnitBaseSheets,
unitBaseFramesPerDirection,
type UnitDirection
} from '../data/unitAssets';
import {
storyCutsceneActorProfileFor,
storyCutsceneActorTextureKeys,
type StoryCutscene,
type StoryCutsceneActor,
type StoryCutsceneMarker,
type StoryCutsceneReward
} from '../data/storyCutscenes';
import { prologuePages, type PortraitKey, type StoryPage } from '../data/scenario';
import { campaignVictoryRewardPresentation, getFirstBattleReport } from '../state/campaignState';
import { palette } from '../ui/palette';
import { ensureLazyScene, startGameScene } from './lazyScenes';
type AlphaObject = Phaser.GameObjects.Container | Phaser.GameObjects.Image | Phaser.GameObjects.Rectangle | Phaser.GameObjects.Text;
type StorySceneData = {
pages?: StoryPage[];
nextScene?: string;
nextSceneData?: Record<string, unknown>;
};
type StoryPageAssetRequest = {
pageIndex: number;
callbacks: Array<() => void>;
interactive: boolean;
};
type StoryPageAssetPlan = {
backgrounds: Array<{ key: string; url: string }>;
portraits: PortraitAssetEntry[];
unitTextureKeys: string[];
};
const sceneShadeDepth = 1;
const cutsceneDepth = 8;
const dialogDepth = 20;
const fhdUiScale = 1.5;
const ui = (value: number) => value * fhdUiScale;
const uiPx = (value: number) => `${Math.round(ui(value))}px`;
const storyUnitFrameRows: Record<UnitDirection, number> = {
south: 0,
east: 1,
north: 2,
west: 3
};
const cutsceneUiColors = {
veil: 0x020304,
ink: 0x070809,
lacquer: 0x100806,
lacquerLight: 0x24120b,
silk: 0x17100d,
silkDeep: 0x0d0a08,
woodDark: 0x160d07,
woodMid: 0x2d1b0f,
woodLight: 0x5f3d20,
parchment: 0x594832,
parchmentLight: 0x7e6542,
parchmentDark: 0x2d1a0e,
paperEdge: 0xa67f4f,
mapInk: 0x302113,
sealRed: 0x8f3023
} as const;
type CutsceneStageBounds = {
x: number;
y: number;
width: number;
height: number;
};
type CutsceneBoardBounds = CutsceneStageBounds;
type StoryRewardCategory = 'gold' | 'supply' | 'equipment' | 'reputation' | 'recruit' | 'unlock' | 'fallback';
type StoryRewardSource = 'campaign' | 'fallback' | 'none';
type StoryRewardIcon =
| { kind: 'item'; textureKey: string; fallbackMark: string }
| { kind: 'battle-ui'; iconKey: BattleUiIconKey; fallbackMark: string }
| { kind: 'mark'; fallbackMark: string };
type StoryRewardDisplayItem = {
id: string;
category: StoryRewardCategory;
eyebrow: string;
label: string;
tone: StoryCutsceneReward['tone'];
icon: StoryRewardIcon;
};
type StoryRewardDisplayModel = {
pageId: string | null;
source: StoryRewardSource;
battleId: string | null;
rewardGold: number | null;
items: StoryRewardDisplayItem[];
};
const firstBattleVictoryPageIds = new Set([
'first-victory-village',
'first-victory-liu-bei',
'first-victory-guan-yu',
'first-victory-zhang-fei',
'first-victory-next'
]);
const firstBattleId = 'first-battle-zhuo-commandery';
const firstBattleWarmupUnitTextureKeys = [
'unit-rebel',
'unit-rebel-archer',
'unit-rebel-cavalry'
] as const;
export class StoryScene extends Phaser.Scene {
private pageIndex = 0;
private pages: StoryPage[] = prologuePages;
private selectedStoryBackgroundKeys = storyBackgroundKeysForPages(prologuePages);
private nextScene = 'BattleScene';
private nextSceneData?: Record<string, unknown>;
private transitioning = false;
private background?: Phaser.GameObjects.Image;
private chapterText?: Phaser.GameObjects.Text;
private portrait?: Phaser.GameObjects.Image;
private portraitFrame?: Phaser.GameObjects.Rectangle;
private portraitDivider?: Phaser.GameObjects.Rectangle;
private nameText?: Phaser.GameObjects.Text;
private bodyText?: Phaser.GameObjects.Text;
private progressBackground?: Phaser.GameObjects.Rectangle;
private progressText?: Phaser.GameObjects.Text;
private loadingText?: Phaser.GameObjects.Text;
private pageLoadingText?: Phaser.GameObjects.Text;
private cutsceneLayer?: Phaser.GameObjects.Container;
private readyStoryPageIndexes = new Set<number>();
private storyPageAssetQueue: StoryPageAssetRequest[] = [];
private activeStoryPageAssetRequest?: StoryPageAssetRequest;
private activeStoryLoadErrorHandler?: (file: Phaser.Loader.File) => void;
private storyAssetLoadGeneration = 0;
private storyAssetFailures = new Set<string>();
private rewardDisplay: StoryRewardDisplayModel = this.emptyRewardDisplay();
constructor() {
super('StoryScene');
}
init(data?: StorySceneData) {
this.pages = data?.pages?.length ? data.pages : prologuePages;
this.selectedStoryBackgroundKeys = storyBackgroundKeysForPages(this.pages);
this.nextScene = data?.nextScene ?? 'BattleScene';
this.nextSceneData = data?.nextSceneData;
this.pageIndex = 0;
this.transitioning = false;
this.readyStoryPageIndexes.clear();
this.storyPageAssetQueue = [];
this.activeStoryPageAssetRequest = undefined;
this.activeStoryLoadErrorHandler = undefined;
this.storyAssetFailures.clear();
this.storyAssetLoadGeneration += 1;
this.rewardDisplay = this.emptyRewardDisplay();
}
getDebugState() {
const page = this.pages[this.pageIndex];
const progressBounds = this.progressText?.active ? this.progressText.getBounds() : undefined;
const progressPanelBounds = this.progressBackground?.active ? this.progressBackground.getBounds() : undefined;
const isLastPage = this.pageIndex >= this.pages.length - 1;
return {
scene: this.scene.key,
ready: this.loadingText === undefined && this.background !== undefined,
pageIndex: this.pageIndex,
totalPages: this.pages.length,
currentPageId: page?.id,
requestedBackground: page?.background,
background: page ? this.pageBackgroundKey(page) : undefined,
backgroundReady: page ? this.textures.exists(this.pageBackgroundKey(page)) : false,
activeTexture: this.background?.texture.key ?? null,
portraitKey: page ? this.pagePortraitKey(page) ?? null : null,
portraitTextureKey: this.portrait?.visible ? this.portrait.texture.key : null,
progressLabel: this.progressText?.text ?? '',
progressBounds: progressBounds
? { x: progressBounds.x, y: progressBounds.y, width: progressBounds.width, height: progressBounds.height }
: null,
progressPanelBounds: progressPanelBounds
? { x: progressPanelBounds.x, y: progressPanelBounds.y, width: progressPanelBounds.width, height: progressPanelBounds.height }
: null,
isLastPage,
advanceHint:
isLastPage && this.nextScene === 'BattleScene'
? 'battle-deployment'
: isLastPage && this.nextScene === 'CampScene'
? 'camp-return'
: isLastPage
? 'continue'
: 'next',
advanceLabel: this.progressActionLabel(isLastPage),
nextScene: this.nextScene,
cutsceneKind: page?.cutscene?.kind ?? null,
cutsceneActors: page?.cutscene?.actors?.map((actor) => actor.unitId) ?? [],
rewardDisplay: {
...this.rewardDisplay,
items: this.rewardDisplay.items.map((item) => ({ ...item, icon: { ...item.icon } }))
},
assetStreaming: {
currentPageReady: this.readyStoryPageIndexes.has(this.pageIndex),
nextPageIndex: this.pageIndex + 1 < this.pages.length ? this.pageIndex + 1 : null,
nextPageReady: this.pageIndex + 1 < this.pages.length ? this.readyStoryPageIndexes.has(this.pageIndex + 1) : null,
loadingPageIndex: this.activeStoryPageAssetRequest?.pageIndex ?? null,
queuedPageIndexes: this.storyPageAssetQueue.map((request) => request.pageIndex),
readyPageIndexes: [...this.readyStoryPageIndexes].sort((left, right) => left - right),
failedAssetKeys: [...this.storyAssetFailures],
currentUnitBaseTexturesLoaded: page
? this.storyPageAssetPlan(this.pageIndex).unitTextureKeys.filter((key) => this.textures.exists(key))
: [],
currentUnitActionTexturesLoaded: page
? this.storyPageAssetPlan(this.pageIndex).unitTextureKeys.filter((key) => this.textures.exists(`${key}-actions`))
: []
}
};
}
create() {
const { width, height } = this.scale;
this.add.rectangle(0, 0, width, height, 0x0b0d12).setOrigin(0);
this.createFallbackStoryTexture();
this.loadingText = this.add.text(width / 2, height / 2, '장면을 준비하는 중...', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: uiPx(22),
color: '#f5e6b8',
stroke: '#05070a',
strokeThickness: ui(4)
});
this.loadingText.setOrigin(0.5);
const generation = this.storyAssetLoadGeneration;
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
this.storyAssetLoadGeneration += 1;
this.storyPageAssetQueue = [];
this.activeStoryPageAssetRequest = undefined;
if (this.activeStoryLoadErrorHandler) {
this.load.off('loaderror', this.activeStoryLoadErrorHandler);
this.activeStoryLoadErrorHandler = undefined;
}
this.pageLoadingText = undefined;
});
this.warmFirstBattleSceneModule();
this.ensureStoryPageAssets(0, () => {
if (generation !== this.storyAssetLoadGeneration) {
return;
}
this.loadingText?.destroy();
this.loadingText = undefined;
this.background = this.add.image(width / 2, height / 2, this.resolveBackgroundKey(this.pageBackgroundKey(this.pages[0])));
this.background.setDepth(0);
this.drawSceneShade(width, height);
this.drawDialogPanel(width, height);
this.showPage(0, true);
this.input.on('pointerdown', () => this.advance());
this.input.keyboard?.on('keydown-SPACE', () => this.advance());
this.input.keyboard?.on('keydown-ENTER', () => this.advance());
}, true);
}
private ensureStoryPageAssets(pageIndex: number, onReady?: () => void, interactive = false) {
if (pageIndex < 0 || pageIndex >= this.pages.length) {
onReady?.();
return;
}
if (this.readyStoryPageIndexes.has(pageIndex)) {
onReady?.();
return;
}
const activeRequest = this.activeStoryPageAssetRequest;
if (activeRequest?.pageIndex === pageIndex) {
if (onReady) {
activeRequest.callbacks.push(onReady);
}
activeRequest.interactive ||= interactive;
this.refreshPageLoadingIndicator();
return;
}
const queuedRequest = this.storyPageAssetQueue.find((request) => request.pageIndex === pageIndex);
if (queuedRequest) {
if (onReady) {
queuedRequest.callbacks.push(onReady);
}
queuedRequest.interactive ||= interactive;
if (interactive) {
this.storyPageAssetQueue = [
queuedRequest,
...this.storyPageAssetQueue.filter((request) => request !== queuedRequest)
];
}
this.refreshPageLoadingIndicator();
return;
}
const request: StoryPageAssetRequest = {
pageIndex,
callbacks: onReady ? [onReady] : [],
interactive
};
if (interactive) {
this.storyPageAssetQueue.unshift(request);
} else {
this.storyPageAssetQueue.push(request);
}
this.processStoryPageAssetQueue();
}
private processStoryPageAssetQueue() {
if (this.activeStoryPageAssetRequest || this.storyPageAssetQueue.length === 0) {
this.refreshPageLoadingIndicator();
return;
}
const request = this.storyPageAssetQueue.shift();
if (!request) {
return;
}
this.activeStoryPageAssetRequest = request;
this.refreshPageLoadingIndicator();
const generation = this.storyAssetLoadGeneration;
const plan = this.storyPageAssetPlan(request.pageIndex);
const finishRequest = () => {
if (generation !== this.storyAssetLoadGeneration) {
return;
}
ensureUnitAnimations(this, plan.unitTextureKeys);
this.readyStoryPageIndexes.add(request.pageIndex);
this.activeStoryPageAssetRequest = undefined;
this.refreshPageLoadingIndicator();
request.callbacks.forEach((callback) => callback());
this.processStoryPageAssetQueue();
};
const finishUnitBases = () => {
if (generation !== this.storyAssetLoadGeneration) {
return;
}
if (this.isFirstBattleWarmupPage(request.pageIndex)) {
loadBattleUiIcons(this, finishRequest);
return;
}
finishRequest();
};
const loadUnitBases = () => {
if (generation !== this.storyAssetLoadGeneration || plan.unitTextureKeys.length === 0) {
finishUnitBases();
return;
}
try {
loadUnitBaseSheets(this, plan.unitTextureKeys, finishUnitBases);
} catch (error) {
console.warn(`Failed to prepare story unit bases for page ${request.pageIndex}`, error);
plan.unitTextureKeys
.filter((key) => !this.textures.exists(key))
.forEach((key) => this.storyAssetFailures.add(key));
finishUnitBases();
}
};
const imageLoads = [
...plan.backgrounds.map(({ key, url }) => ({ key, url })),
...plan.portraits.map(({ textureKey: key, url }) => ({ key, url }))
].filter(({ key }, index, entries) => {
return !this.textures.exists(key) && entries.findIndex((entry) => entry.key === key) === index;
});
if (imageLoads.length === 0) {
loadUnitBases();
return;
}
const onLoadError = (file: Phaser.Loader.File) => {
this.storyAssetFailures.add(String(file.key));
console.warn(`Failed to load story asset ${file.key}`);
};
this.activeStoryLoadErrorHandler = onLoadError;
this.load.on('loaderror', onLoadError);
this.load.once('complete', () => {
this.load.off('loaderror', onLoadError);
if (this.activeStoryLoadErrorHandler === onLoadError) {
this.activeStoryLoadErrorHandler = undefined;
}
loadUnitBases();
});
imageLoads.forEach(({ key, url }) => this.load.image(key, url));
this.load.start();
}
private storyPageAssetPlan(pageIndex: number): StoryPageAssetPlan {
const page = this.pages[pageIndex];
if (!page) {
return { backgrounds: [], portraits: [], unitTextureKeys: [] };
}
const backgroundKey = this.pageBackgroundAssetKey(page);
const backgroundUrl = backgroundKey ? storyBackgroundAssets[backgroundKey] : undefined;
if (backgroundKey && !backgroundUrl) {
this.storyAssetFailures.add(backgroundKey);
console.warn(`Missing story background asset for ${backgroundKey}`);
}
const pagePortrait = this.pagePortraitAssetEntry(page);
const actorPortraits =
page.cutscene?.actors
?.map((actor, index) => this.actorPortraitAssetEntry(actor, index))
.filter((entry): entry is PortraitAssetEntry => entry !== undefined) ?? [];
const portraits = [pagePortrait, ...actorPortraits]
.filter((entry): entry is PortraitAssetEntry => entry !== undefined)
.filter((entry, index, entries) => entries.findIndex((candidate) => candidate.textureKey === entry.textureKey) === index);
const warmupUnitTextureKeys = this.isFirstBattleWarmupPage(pageIndex)
? firstBattleWarmupUnitTextureKeys
: [];
return {
backgrounds: backgroundKey && backgroundUrl ? [{ key: backgroundKey, url: backgroundUrl }] : [],
portraits,
unitTextureKeys: Array.from(new Set([
...storyCutsceneActorTextureKeys(page.cutscene),
...warmupUnitTextureKeys
]))
};
}
private isFirstBattlePrelude() {
const requestedBattleId = typeof this.nextSceneData?.battleId === 'string'
? this.nextSceneData.battleId
: firstBattleId;
return this.nextScene === 'BattleScene' && requestedBattleId === firstBattleId;
}
private isFirstBattleWarmupPage(pageIndex: number) {
return this.isFirstBattlePrelude() && pageIndex === Math.min(1, this.pages.length - 1);
}
private warmFirstBattleSceneModule() {
if (!this.isFirstBattlePrelude()) {
return;
}
void ensureLazyScene(this.game, 'BattleScene').catch((error) => {
console.warn('Failed to warm the first battle scene module.', error);
});
}
private prefetchNextStoryPage(pageIndex: number) {
const nextPageIndex = pageIndex + 1;
if (nextPageIndex < this.pages.length) {
this.ensureStoryPageAssets(nextPageIndex);
}
}
private refreshPageLoadingIndicator() {
const interactiveRequest =
(this.activeStoryPageAssetRequest?.interactive ? this.activeStoryPageAssetRequest : undefined) ??
this.storyPageAssetQueue.find((request) => request.interactive);
if (!interactiveRequest || this.loadingText) {
this.pageLoadingText?.destroy();
this.pageLoadingText = undefined;
return;
}
if (!this.pageLoadingText) {
this.pageLoadingText = this.add.text(this.scale.width / 2, this.scale.height - ui(54), '', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: uiPx(14),
color: '#f5e6b8',
backgroundColor: '#070809',
padding: { x: ui(18), y: ui(8) }
});
this.pageLoadingText.setOrigin(0.5);
this.pageLoadingText.setDepth(dialogDepth + 6);
}
this.pageLoadingText.setText(`다음 장면 준비 중… ${interactiveRequest.pageIndex + 1} / ${this.pages.length}`);
}
private createFallbackStoryTexture() {
if (this.textures.exists('story-fallback')) {
return;
}
const { width, height } = this.scale;
const graphics = this.make.graphics({ x: 0, y: 0 });
graphics.fillStyle(0x10131a, 1);
graphics.fillRect(0, 0, width, height);
graphics.fillStyle(0x1d2530, 1);
graphics.fillRect(0, 0, width, height * (180 / 720));
graphics.lineStyle(ui(4), palette.gold, 0.5);
graphics.beginPath();
graphics.moveTo(width * (120 / 1280), height * (600 / 720));
graphics.lineTo(width * (1160 / 1280), height * (120 / 720));
graphics.strokePath();
graphics.generateTexture('story-fallback', width, height);
graphics.destroy();
}
private drawSceneShade(width: number, height: number) {
const shade = this.add.graphics();
shade.setDepth(sceneShadeDepth);
shade.fillStyle(0x030405, 0.34);
shade.fillRect(0, 0, width, height);
shade.fillStyle(0x030405, 0.5);
shade.fillRect(0, height - ui(286), width, ui(286));
shade.fillStyle(0x030405, 0.34);
shade.fillRect(0, 0, width, ui(118));
shade.fillStyle(0x030405, 0.2);
shade.fillRect(0, ui(94), width, ui(388));
shade.fillStyle(0x030405, 0.28);
shade.fillRect(0, 0, ui(120), height);
shade.fillRect(width - ui(120), 0, ui(120), height);
}
private drawDialogPanel(width: number, height: number) {
const panelY = height - ui(238);
const panelX = ui(72);
const panelW = width - ui(144);
const panelH = ui(172);
const panel = this.add.graphics();
panel.setDepth(dialogDepth);
panel.fillStyle(cutsceneUiColors.veil, 0.62);
panel.fillRoundedRect(panelX - ui(12), panelY + ui(12), panelW + ui(24), panelH + ui(18), ui(12));
panel.fillStyle(cutsceneUiColors.woodDark, 0.96);
panel.fillRoundedRect(panelX, panelY, panelW, panelH, ui(10));
panel.fillStyle(cutsceneUiColors.lacquer, 0.9);
panel.fillRoundedRect(panelX + ui(12), panelY + ui(14), panelW - ui(24), panelH - ui(28), ui(8));
panel.fillStyle(cutsceneUiColors.silk, 0.52);
panel.fillRoundedRect(panelX + ui(22), panelY + ui(24), panelW - ui(44), panelH - ui(48), ui(6));
panel.fillStyle(cutsceneUiColors.woodLight, 0.48);
panel.fillRoundedRect(panelX + ui(20), panelY - ui(6), panelW - ui(40), ui(14), ui(7));
panel.fillRoundedRect(panelX + ui(20), panelY + panelH - ui(8), panelW - ui(40), ui(14), ui(7));
panel.lineStyle(ui(2), palette.gold, 0.28);
panel.strokeRoundedRect(panelX + ui(10), panelY + ui(10), panelW - ui(20), panelH - ui(20), ui(8));
panel.lineStyle(ui(1), 0xf0d08a, 0.12);
panel.lineBetween(panelX + ui(198), panelY + ui(24), panelX + panelW - ui(30), panelY + ui(18));
panel.lineBetween(panelX + ui(198), panelY + panelH - ui(26), panelX + panelW - ui(30), panelY + panelH - ui(18));
panel.lineStyle(ui(1), cutsceneUiColors.woodLight, 0.14);
for (let index = 0; index < 4; index += 1) {
const y = panelY + ui(40) + index * ui(24);
panel.lineBetween(panelX + ui(198), y, panelX + panelW - ui(36), y + ui(index % 2 === 0 ? 3 : -3));
}
this.chapterText = this.add.text(panelX, panelY - ui(42), '', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", serif',
fontSize: uiPx(22),
color: '#d8b15f',
fontStyle: '700',
padding: { top: ui(4), bottom: ui(4) },
shadow: { offsetX: 0, offsetY: ui(2), color: '#05070a', blur: ui(6), fill: true }
});
this.chapterText.setDepth(dialogDepth + 2);
this.portraitFrame = this.add.rectangle(panelX + ui(86), panelY + ui(86), ui(136), ui(136), cutsceneUiColors.ink, 0.92);
this.portraitFrame.setStrokeStyle(ui(2), palette.gold, 0.34);
this.portraitFrame.setDepth(dialogDepth + 2);
this.portrait = this.add.image(panelX + ui(86), panelY + ui(86), this.pagePortraitTextureKey(this.pages[0]) ?? 'story-fallback');
this.portrait.setDisplaySize(ui(128), ui(128));
this.portrait.setDepth(dialogDepth + 3);
this.portraitDivider = this.add.rectangle(panelX + ui(170), panelY + ui(30), ui(1), panelH - ui(60), palette.gold, 0.12).setOrigin(0);
this.portraitDivider.setDepth(dialogDepth + 2);
this.nameText = this.add.text(panelX + ui(198), panelY + ui(28), '', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", serif',
fontSize: uiPx(24),
color: '#f1e3c2',
fontStyle: '700',
padding: { top: ui(4), bottom: ui(4) }
});
this.nameText.setDepth(dialogDepth + 2);
this.bodyText = this.add.text(panelX + ui(198), panelY + ui(78), '', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", serif',
fontSize: uiPx(25),
color: '#e8dfca',
wordWrap: { width: panelW - ui(236), useAdvancedWrap: true },
lineSpacing: ui(10),
padding: { top: ui(4), bottom: ui(6) },
shadow: { offsetX: 0, offsetY: ui(2), color: '#05070a', blur: ui(5), fill: true }
});
this.bodyText.setDepth(dialogDepth + 2);
const progressX = width - ui(214);
const progressY = panelY + panelH + ui(20);
this.progressBackground = this.add.rectangle(progressX, progressY, ui(380), ui(34), cutsceneUiColors.ink, 0.82);
this.progressBackground.setStrokeStyle(ui(1), palette.gold, 0.42);
this.progressBackground.setDepth(dialogDepth + 1);
this.progressText = this.add.text(progressX, progressY, '', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: uiPx(14),
color: '#d8b15f',
fontStyle: '700',
align: 'center',
fixedWidth: ui(356)
});
this.progressText.setOrigin(0.5);
this.progressText.setDepth(dialogDepth + 2);
}
private showPage(index: number, immediate = false) {
const page = this.pages[index];
if (immediate) {
this.applyPage(page);
this.prefetchNextStoryPage(index);
return;
}
this.transitioning = true;
this.tweens.killTweensOf([this.background, ...this.dialogueObjects()]);
this.tweens.add({
targets: this.dialogueObjects(),
alpha: 0.18,
duration: 90,
ease: 'Sine.easeIn'
});
this.tweens.add({
targets: this.background,
alpha: 0.68,
duration: 120,
ease: 'Sine.easeIn',
onComplete: () => {
this.applyPage(page);
this.prefetchNextStoryPage(index);
this.background?.setAlpha(0.68);
this.setDialogueAlpha(0.18);
this.tweens.add({
targets: this.dialogueObjects(),
alpha: 1,
duration: 170,
ease: 'Sine.easeOut'
});
this.tweens.add({
targets: this.background,
alpha: 1,
duration: 220,
ease: 'Sine.easeOut',
onComplete: () => {
this.transitioning = false;
}
});
}
});
}
private applyPage(page: StoryPage) {
soundDirector.playMusic(page.bgm);
this.applyBackground(page);
this.renderCutscene(page);
this.chapterText?.setText(page.chapter);
const textureKey = this.pagePortraitTextureKey(page);
if (textureKey && this.textures.exists(textureKey)) {
this.portraitFrame?.setVisible(true);
this.portrait?.setVisible(true);
this.portraitDivider?.setVisible(true);
this.portrait?.setTexture(textureKey);
this.portrait?.setDisplaySize(ui(128), ui(128));
this.nameText?.setPosition(ui(270), this.scale.height - ui(210));
this.bodyText?.setPosition(ui(270), this.scale.height - ui(160));
this.bodyText?.setWordWrapWidth(this.scale.width - ui(374), true);
} else {
this.portraitFrame?.setVisible(false);
this.portrait?.setVisible(false);
this.portraitDivider?.setVisible(false);
this.nameText?.setPosition(ui(96), this.scale.height - ui(210));
this.bodyText?.setPosition(ui(96), this.scale.height - ui(160));
this.bodyText?.setWordWrapWidth(this.scale.width - ui(200), true);
}
this.nameText?.setText(page.speaker ?? '나레이션');
this.bodyText?.setText(page.text);
const isLastPage = this.pageIndex >= this.pages.length - 1;
this.progressText?.setText(
`${this.pageIndex + 1} / ${this.pages.length} · 클릭 / 스페이스·엔터 · ${this.progressActionLabel(isLastPage)}`
);
}
private progressActionLabel(isLastPage = this.pageIndex >= this.pages.length - 1) {
if (!isLastPage) {
return '다음';
}
if (this.nextScene === 'BattleScene') {
return '전투 배치';
}
if (this.nextScene === 'CampScene') {
return '군영으로';
}
return '계속';
}
private pagePortraitKey(page: StoryPage) {
return page.portrait ?? portraitKeyForSpeaker(page.speaker);
}
private pagePortraitTextureKey(page: StoryPage) {
const entry = this.pagePortraitAssetEntry(page);
return entry && this.textures.exists(entry.textureKey) ? entry.textureKey : undefined;
}
private pagePortraitAssetEntry(page: StoryPage): PortraitAssetEntry | undefined {
const portraitKey = this.pagePortraitKey(page);
if (!portraitKey) {
return undefined;
}
const entries = portraitAssetEntriesForKey(portraitKey);
if (entries.length === 0) {
return undefined;
}
return entries[this.hashString(page.id) % entries.length];
}
private pageBackgroundKey(page: StoryPage) {
return this.pageBackgroundAssetKey(page) ?? page.background;
}
private pageBackgroundAssetKey(page: StoryPage) {
const pageIndex = this.pages.indexOf(page);
const key = pageIndex >= 0
? this.selectedStoryBackgroundKeys[pageIndex]
: storyBackgroundKeyForPage(page.background, page.id);
return storyBackgroundAssets[key] ? key : page.background;
}
private applyBackground(page: StoryPage) {
if (!this.background) {
return;
}
const { width, height } = this.scale;
const safeTextureKey = this.resolveBackgroundKey(this.pageBackgroundKey(page));
const source = this.textures.get(safeTextureKey).getSourceImage();
const imageWidth = source.width;
const imageHeight = source.height;
const coverScale = Math.max(width / imageWidth, height / imageHeight);
this.tweens.killTweensOf(this.background);
this.background.setTexture(safeTextureKey);
this.background.setAlpha(1);
this.background.setDepth(0);
this.background.setScale(coverScale * 1.07);
this.background.setPosition(width / 2 - ui(16), height / 2);
this.tweens.add({
targets: this.background,
x: width / 2 + ui(16),
y: height / 2 - ui(8),
scale: coverScale * 1.11,
duration: 15000,
ease: 'Sine.easeInOut',
yoyo: true,
repeat: -1
});
}
private renderCutscene(page: StoryPage) {
this.cutsceneLayer?.list.forEach((child) => this.tweens.killTweensOf(child));
this.cutsceneLayer?.destroy();
this.cutsceneLayer = undefined;
this.rewardDisplay = this.rewardDisplayForPage(page);
const cutscene = page.cutscene;
if (!cutscene) {
return;
}
const layer = this.add.container(0, 0);
layer.setDepth(cutsceneDepth);
this.cutsceneLayer = layer;
this.renderCutsceneStage(layer, cutscene);
const boardBounds = this.renderTacticalBoard(layer, cutscene);
this.renderCutsceneActorCards(layer, cutscene, boardBounds);
this.renderCutsceneRewards(layer, this.rewardDisplay);
}
private renderCutsceneStage(layer: Phaser.GameObjects.Container, cutscene: StoryCutscene) {
const { x: stageX, y: stageY, width: stageW, height: stageH } = this.cutsceneStageBounds();
const accent = cutscene.kind === 'victory' ? palette.green : cutscene.kind === 'operation' ? palette.blue : palette.gold;
const stage = this.add.graphics();
stage.fillStyle(cutsceneUiColors.veil, 0.62);
stage.fillRoundedRect(stageX - ui(18), stageY - ui(14), stageW + ui(36), stageH + ui(28), ui(14));
stage.fillStyle(cutsceneUiColors.woodDark, 0.98);
stage.fillRoundedRect(stageX, stageY, stageW, stageH, ui(12));
stage.fillStyle(cutsceneUiColors.woodMid, 0.9);
stage.fillRoundedRect(stageX + ui(12), stageY + ui(12), stageW - ui(24), stageH - ui(24), ui(9));
stage.fillStyle(cutsceneUiColors.lacquer, 0.68);
stage.fillRoundedRect(stageX + ui(26), stageY + ui(76), stageW - ui(52), stageH - ui(112), ui(8));
stage.fillStyle(cutsceneUiColors.woodLight, 0.2);
stage.fillRoundedRect(stageX + ui(18), stageY + ui(14), stageW - ui(36), ui(16), ui(8));
stage.fillStyle(cutsceneUiColors.woodDark, 0.36);
stage.fillRoundedRect(stageX + ui(18), stageY + stageH - ui(28), stageW - ui(36), ui(12), ui(6));
stage.lineStyle(ui(1), accent, 0.28);
stage.strokeRoundedRect(stageX + ui(11), stageY + ui(11), stageW - ui(22), stageH - ui(22), ui(9));
stage.lineStyle(ui(1), 0xf0d08a, 0.1);
stage.lineBetween(stageX + ui(30), stageY + ui(70), stageX + stageW - ui(30), stageY + ui(68));
stage.lineBetween(stageX + ui(30), stageY + stageH - ui(52), stageX + stageW - ui(30), stageY + stageH - ui(46));
stage.lineStyle(ui(1), cutsceneUiColors.woodLight, 0.16);
for (let index = 0; index < 12; index += 1) {
const y = stageY + ui(28) + index * ui(24);
stage.lineBetween(stageX + ui(28), y, stageX + stageW - ui(28), y + ui(index % 2 === 0 ? 4 : -3));
}
stage.fillStyle(0x0a0504, 0.38);
stage.fillCircle(stageX + ui(28), stageY + ui(28), ui(4));
stage.fillCircle(stageX + stageW - ui(28), stageY + ui(28), ui(4));
stage.fillCircle(stageX + ui(28), stageY + stageH - ui(28), ui(4));
stage.fillCircle(stageX + stageW - ui(28), stageY + stageH - ui(28), ui(4));
const foldingScreen = this.add.graphics();
foldingScreen.lineStyle(ui(1), 0xd0b16e, 0.1);
foldingScreen.fillStyle(0x342216, 0.22);
for (let index = 0; index < 5; index += 1) {
const panelX = stageX + ui(26) + index * ui(86);
foldingScreen.fillRoundedRect(panelX, stageY + ui(92), ui(66), stageH - ui(134), ui(4));
foldingScreen.strokeRoundedRect(panelX, stageY + ui(92), ui(66), stageH - ui(134), ui(4));
}
const tableShadow = this.add.ellipse(stageX + stageW * 0.62, stageY + stageH - ui(34), stageW * 0.6, ui(38), 0x030405, 0.34);
const titlePlate = this.add.graphics();
titlePlate.fillStyle(cutsceneUiColors.lacquer, 0.88);
titlePlate.fillRoundedRect(stageX + ui(22), stageY + ui(18), ui(366), ui(48), ui(8));
titlePlate.fillStyle(accent, 0.14);
titlePlate.fillRoundedRect(stageX + ui(30), stageY + ui(25), ui(34), ui(34), ui(5));
titlePlate.lineStyle(ui(1), accent, 0.3);
titlePlate.lineBetween(stageX + ui(74), stageY + ui(25), stageX + ui(374), stageY + ui(25));
titlePlate.lineBetween(stageX + ui(74), stageY + ui(60), stageX + ui(374), stageY + ui(60));
titlePlate.lineStyle(ui(1), 0xf0d08a, 0.12);
titlePlate.strokeRoundedRect(stageX + ui(22), stageY + ui(18), ui(366), ui(48), ui(8));
const title = this.add.text(stageX + ui(34), stageY + ui(18), cutscene.title, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", serif',
fontSize: uiPx(26),
color: '#f2e3bf',
fontStyle: '700',
stroke: '#05070a',
strokeThickness: ui(4)
});
const subtitle = this.add.text(stageX + ui(36), stageY + ui(53), cutscene.subtitle ?? '', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: uiPx(14),
color: '#cdbb94'
});
layer.add([stage, foldingScreen, tableShadow, titlePlate, title, subtitle]);
}
private renderTacticalBoard(layer: Phaser.GameObjects.Container, cutscene: StoryCutscene) {
const stage = this.cutsceneStageBounds();
const boardX = stage.x + Math.round(stage.width * 0.36);
const boardY = stage.y + ui(78);
const boardW = Math.round(stage.width * 0.58);
const boardH = stage.height - ui(118);
const board: CutsceneBoardBounds = { x: boardX, y: boardY, width: boardW, height: boardH };
const parchment = this.add.graphics();
parchment.fillStyle(cutsceneUiColors.veil, 0.34);
parchment.fillEllipse(boardX + boardW * 0.52, boardY + boardH + ui(12), boardW * 0.9, ui(34));
parchment.fillStyle(cutsceneUiColors.woodDark, 0.92);
parchment.fillRoundedRect(boardX + ui(10), boardY - ui(10), boardW - ui(20), ui(20), ui(10));
parchment.fillRoundedRect(boardX + ui(8), boardY + boardH - ui(10), boardW - ui(16), ui(20), ui(10));
parchment.fillStyle(cutsceneUiColors.woodLight, 0.35);
parchment.fillRoundedRect(boardX + ui(22), boardY - ui(5), boardW - ui(44), ui(5), ui(3));
parchment.fillRoundedRect(boardX + ui(22), boardY + boardH - ui(5), boardW - ui(44), ui(5), ui(3));
parchment.fillStyle(cutsceneUiColors.parchmentDark, 0.94);
parchment.fillRoundedRect(boardX + ui(3), boardY + ui(4), boardW - ui(6), boardH - ui(8), ui(5));
parchment.fillStyle(cutsceneUiColors.parchment, 0.92);
parchment.fillRoundedRect(boardX + ui(10), boardY + ui(12), boardW - ui(20), boardH - ui(24), ui(4));
parchment.fillStyle(cutsceneUiColors.parchmentLight, 0.08);
parchment.fillEllipse(boardX + boardW * 0.5, boardY + boardH * 0.48, boardW * 0.82, boardH * 0.66);
parchment.fillStyle(cutsceneUiColors.paperEdge, 0.12);
parchment.fillRoundedRect(boardX + ui(10), boardY + ui(12), boardW - ui(20), ui(10), ui(4));
parchment.fillRoundedRect(boardX + ui(10), boardY + boardH - ui(22), boardW - ui(20), ui(10), ui(4));
parchment.fillStyle(0x23140c, 0.1);
parchment.fillEllipse(boardX + boardW * 0.5, boardY + boardH * 0.62, boardW * 0.72, boardH * 0.34);
parchment.lineStyle(ui(1), cutsceneUiColors.mapInk, 0.24);
parchment.strokeRoundedRect(boardX + ui(8), boardY + ui(10), boardW - ui(16), boardH - ui(20), ui(5));
parchment.lineStyle(ui(1), 0xf0d08a, 0.1);
parchment.lineBetween(boardX + ui(24), boardY + ui(20), boardX + boardW - ui(28), boardY + ui(16));
parchment.lineBetween(boardX + ui(26), boardY + boardH - ui(20), boardX + boardW - ui(24), boardY + boardH - ui(24));
parchment.fillStyle(0x3d2413, 0.16);
parchment.beginPath();
parchment.moveTo(boardX + boardW - ui(54), boardY + ui(12));
parchment.lineTo(boardX + boardW - ui(12), boardY + ui(12));
parchment.lineTo(boardX + boardW - ui(12), boardY + ui(52));
parchment.closePath();
parchment.fillPath();
parchment.lineStyle(ui(1), 0x3d2413, 0.1);
for (let index = 0; index < 11; index += 1) {
const y = boardY + ui(38) + index * ui(14);
const xOffset = ui(index % 3 === 0 ? 26 : index % 3 === 1 ? 38 : 31);
parchment.lineBetween(boardX + xOffset, y, boardX + boardW - ui(30), y + ui(index % 2 === 0 ? 2 : -2));
}
for (let index = 0; index < 30; index += 1) {
const x = boardX + ui(30) + ((index * ui(47)) % Math.max(ui(60), boardW - ui(62)));
const y = boardY + ui(34) + ((index * ui(29)) % Math.max(ui(60), boardH - ui(66)));
parchment.fillStyle(index % 2 === 0 ? 0x4f3219 : 0xd2b172, 0.1);
parchment.fillCircle(x, y, ui(index % 3 === 0 ? 1.4 : 0.9));
}
layer.add(parchment);
const map = this.add.graphics();
map.lineStyle(ui(1), cutsceneUiColors.mapInk, 0.08);
for (let index = 1; index < 7; index += 1) {
const x = boardX + (boardW / 7) * index;
map.lineBetween(x, boardY + ui(24), x, boardY + boardH - ui(24));
}
for (let index = 1; index < 4; index += 1) {
const y = boardY + (boardH / 4) * index;
map.lineBetween(boardX + ui(24), y, boardX + boardW - ui(24), y);
}
map.fillStyle(0x365238, 0.18);
map.fillEllipse(boardX + boardW * 0.35, boardY + boardH * 0.58, boardW * 0.42, boardH * 0.46);
map.fillStyle(0x36465b, 0.14);
map.fillEllipse(boardX + boardW * 0.72, boardY + boardH * 0.36, boardW * 0.28, boardH * 0.28);
map.lineStyle(ui(8), 0x6b4826, 0.18);
map.beginPath();
map.moveTo(boardX + boardW * 0.16, boardY + boardH * 0.74);
map.lineTo(boardX + boardW * 0.38, boardY + boardH * 0.54);
map.lineTo(boardX + boardW * 0.66, boardY + boardH * 0.36);
map.lineTo(boardX + boardW * 0.83, boardY + boardH * 0.24);
map.strokePath();
map.lineStyle(ui(2), cutsceneUiColors.mapInk, 0.22);
map.beginPath();
map.moveTo(boardX + boardW * 0.16, boardY + boardH * 0.74);
map.lineTo(boardX + boardW * 0.38, boardY + boardH * 0.54);
map.lineTo(boardX + boardW * 0.66, boardY + boardH * 0.36);
map.lineTo(boardX + boardW * 0.83, boardY + boardH * 0.24);
map.strokePath();
map.lineStyle(ui(1), cutsceneUiColors.mapInk, 0.18);
map.strokeRoundedRect(boardX + ui(26), boardY + ui(28), boardW - ui(52), boardH - ui(56), ui(4));
layer.add(map);
this.renderBoardLegend(layer, cutscene, board);
(cutscene.markers ?? []).forEach((marker) => this.renderOperationMarker(layer, marker, boardX, boardY, boardW, boardH));
this.renderCutsceneUnitTokens(layer, cutscene, board);
return board;
}
private renderBoardLegend(layer: Phaser.GameObjects.Container, cutscene: StoryCutscene, board: CutsceneBoardBounds) {
const label = cutscene.kind === 'victory' ? 'RESULT MAP' : cutscene.kind === 'operation' ? 'TACTICAL MAP' : 'SORTIE BOARD';
const bg = this.add.graphics();
bg.fillStyle(cutsceneUiColors.mapInk, 0.18);
bg.fillRoundedRect(board.x + ui(24), board.y + ui(24), ui(126), ui(22), ui(4));
bg.lineStyle(ui(1), cutsceneUiColors.mapInk, 0.18);
bg.lineBetween(board.x + ui(32), board.y + ui(43), board.x + ui(140), board.y + ui(43));
bg.fillStyle(cutsceneUiColors.sealRed, 0.22);
bg.fillCircle(board.x + ui(134), board.y + ui(35), ui(8));
const text = this.add.text(board.x + ui(34), board.y + ui(28), label, {
fontFamily: '"Segoe UI", sans-serif',
fontSize: uiPx(12),
color: '#ead6aa',
fontStyle: '700'
});
layer.add([bg, text]);
}
private renderCutsceneActorCards(
layer: Phaser.GameObjects.Container,
cutscene: StoryCutscene,
board: CutsceneBoardBounds
) {
const stage = this.cutsceneStageBounds();
const actors = (cutscene.actors ?? []).filter((actor) => actor.unitId !== 'rebel-leader').slice(0, 3);
const cardX = stage.x + ui(28);
const cardW = Math.max(ui(250), board.x - cardX - ui(24));
const cardH = ui(actors.length >= 4 ? 60 : 70);
const firstY = stage.y + ui(86);
actors.forEach((actor, index) => {
const y = firstY + index * (cardH + ui(10));
this.renderCutsceneActorCard(layer, actor, cardX, y, cardW, cardH, index);
});
if ((cutscene.briefing?.lines ?? []).length > 0) {
const briefingY = firstY + actors.length * (cardH + ui(10)) + ui(4);
const maxH = stage.y + stage.height - ui(36) - briefingY;
if (maxH >= ui(70)) {
this.renderBriefingCard(layer, cutscene, cardX, briefingY, cardW, Math.min(ui(126), maxH));
}
}
}
private renderCutsceneActorCard(
layer: Phaser.GameObjects.Container,
actor: StoryCutsceneActor,
x: number,
y: number,
width: number,
height: number,
index: number
) {
const profile = storyCutsceneActorProfileFor(actor.unitId);
if (!profile) {
return;
}
const card = this.add.graphics();
card.fillStyle(cutsceneUiColors.veil, 0.34);
card.fillRoundedRect(x + ui(5), y + ui(7), width, height, ui(8));
card.fillStyle(cutsceneUiColors.woodDark, 0.94);
card.fillRoundedRect(x, y, width, height, ui(8));
card.fillStyle(cutsceneUiColors.lacquerLight, 0.46);
card.fillRoundedRect(x + ui(7), y + ui(7), width - ui(14), height - ui(14), ui(6));
card.fillStyle(cutsceneUiColors.lacquer, 0.42);
card.fillRoundedRect(x + ui(72), y + ui(9), width - ui(88), height - ui(18), ui(5));
card.fillStyle(profile.color, 0.42);
card.fillRoundedRect(x + ui(11), y + ui(10), ui(4), height - ui(20), ui(2));
card.fillStyle(cutsceneUiColors.paperEdge, 0.16);
card.fillRoundedRect(x + height + ui(4), y + ui(9), Math.min(ui(130), width - height - ui(52)), ui(24), ui(4));
card.lineStyle(ui(1), 0xf0d08a, 0.14);
card.lineBetween(x + ui(22), y + ui(9), x + width - ui(24), y + ui(9));
card.lineBetween(x + ui(22), y + height - ui(9), x + width - ui(24), y + height - ui(9));
card.lineStyle(ui(1), profile.color, 0.24);
card.lineBetween(x + ui(18), y + ui(15), x + ui(18), y + height - ui(15));
card.fillStyle(profile.color, 0.18);
card.fillCircle(x + width - ui(24), y + height / 2, ui(12));
card.fillStyle(cutsceneUiColors.sealRed, 0.18);
card.fillCircle(x + width - ui(24), y + height / 2, ui(7));
layer.add(card);
const portraitTexture = this.actorPortraitTextureKey(actor, index);
const portraitX = x + ui(38);
const portraitY = y + height / 2;
const portraitFrame = this.add.graphics();
portraitFrame.fillStyle(cutsceneUiColors.ink, 0.92);
portraitFrame.fillRoundedRect(portraitX - (height - ui(14)) / 2, portraitY - (height - ui(14)) / 2, height - ui(14), height - ui(14), ui(5));
portraitFrame.lineStyle(ui(1), profile.color, 0.34);
portraitFrame.strokeRoundedRect(portraitX - (height - ui(14)) / 2, portraitY - (height - ui(14)) / 2, height - ui(14), height - ui(14), ui(5));
portraitFrame.lineStyle(ui(1), 0xf0d08a, 0.1);
portraitFrame.strokeRoundedRect(portraitX - (height - ui(24)) / 2, portraitY - (height - ui(24)) / 2, height - ui(24), height - ui(24), ui(3));
layer.add(portraitFrame);
if (portraitTexture) {
const portrait = this.add.image(portraitX, portraitY, portraitTexture);
portrait.setDisplaySize(height - ui(18), height - ui(18));
layer.add(portrait);
} else if (this.textures.exists(profile.unitTextureKey)) {
const sprite = this.add.sprite(portraitX, portraitY + ui(3), profile.unitTextureKey, this.storyUnitFrameIndex(actor.direction ?? 'south'));
sprite.setDisplaySize(height - ui(8), height - ui(8));
layer.add(sprite);
}
const name = this.add.text(x + height + ui(14), y + ui(11), actor.label ?? profile.name, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: uiPx(16),
color: '#f5e6c8',
fontStyle: '700'
});
const roleBg = this.add.graphics();
roleBg.lineStyle(ui(1), profile.color, 0.26);
roleBg.lineBetween(x + height + ui(14), y + ui(38), x + height + Math.min(ui(128), width - height - ui(34)), y + ui(38));
const role = this.add.text(x + height + ui(14), y + ui(42), profile.roleLabel, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: uiPx(12),
color: '#ddc99f'
});
layer.add([roleBg, name, role]);
if (actor.line && height >= ui(66)) {
const line = this.add.text(x + ui(144), y + ui(50), actor.line, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: uiPx(11),
color: '#d8cbb2',
wordWrap: { width: width - ui(166), useAdvancedWrap: true }
});
layer.add(line);
}
}
private renderCutsceneUnitTokens(layer: Phaser.GameObjects.Container, cutscene: StoryCutscene, board: CutsceneBoardBounds) {
const actors = cutscene.actors ?? [];
actors.forEach((actor, index) => {
const profile = storyCutsceneActorProfileFor(actor.unitId);
if (!profile) {
return;
}
const normalizedX = actor.x;
const normalizedY = actor.y;
const x = board.x + board.width * Phaser.Math.Clamp(normalizedX, 0.12, 0.88);
const y = board.y + board.height * Phaser.Math.Clamp(normalizedY, 0.18, 0.82);
const sideColor = actor.unitId === 'rebel-leader' ? 0xd46a4c : profile.color;
const token = this.add.container(x, y);
const shadow = this.add.ellipse(ui(2), ui(12), ui(38), ui(12), 0x05070a, 0.28);
const base = this.add.ellipse(0, ui(6), ui(30), ui(18), cutsceneUiColors.woodDark, 0.78);
base.setStrokeStyle(ui(1), sideColor, 0.46);
const pin = this.add.circle(0, ui(6), ui(3), sideColor, 0.86);
pin.setStrokeStyle(ui(1), 0x05070a, 0.62);
token.add([shadow, base, pin]);
if (this.textures.exists(profile.unitTextureKey)) {
const sprite = this.add.sprite(0, -ui(8), profile.unitTextureKey, this.storyUnitFrameIndex(actor.direction ?? 'south'));
sprite.setDisplaySize(ui(34), ui(34));
token.add(sprite);
} else {
const fallback = this.add.rectangle(0, -ui(8), ui(22), ui(24), sideColor, 0.9);
fallback.setStrokeStyle(ui(1), 0x05070a, 0.82);
token.add(fallback);
}
const label = this.add.text(0, ui(14), actor.label ?? profile.name, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: uiPx(9),
color: '#ead9b8',
stroke: '#05070a',
strokeThickness: ui(2)
});
label.setOrigin(0.5, 0);
token.add(label);
layer.add(token);
});
}
private renderBriefingCard(layer: Phaser.GameObjects.Container, cutscene: StoryCutscene, x: number, y: number, width: number, height: number) {
const briefingLines = cutscene.briefing?.lines ?? [];
if (!cutscene.briefing || briefingLines.length === 0) {
return;
}
const bg = this.add.graphics();
bg.fillStyle(cutsceneUiColors.veil, 0.28);
bg.fillRoundedRect(x + ui(4), y + ui(5), width, height, ui(8));
bg.fillStyle(cutsceneUiColors.lacquer, 0.68);
bg.fillRoundedRect(x, y, width, height, ui(8));
bg.fillStyle(cutsceneUiColors.paperEdge, 0.08);
bg.fillRoundedRect(x + ui(10), y + ui(10), width - ui(20), height - ui(20), ui(6));
bg.lineStyle(ui(1), palette.gold, 0.18);
bg.lineBetween(x + ui(16), y + ui(13), x + width - ui(16), y + ui(13));
bg.lineBetween(x + ui(16), y + height - ui(13), x + width - ui(16), y + height - ui(13));
const title = this.add.text(x + ui(18), y + ui(16), cutscene.briefing?.title ?? '전황', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: uiPx(18),
color: '#d8b15f',
fontStyle: '700'
});
layer.add([bg, title]);
briefingLines.slice(0, 4).forEach((line, index) => {
const bullet = this.add.circle(x + ui(24), y + ui(54) + index * ui(30), ui(3), palette.gold, 0.72);
const text = this.add.text(x + ui(38), y + ui(43) + index * ui(30), line, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: uiPx(14),
color: '#d8cbb2',
wordWrap: { width: width - ui(58), useAdvancedWrap: true }
});
layer.add([bullet, text]);
});
}
private renderOperationBoard(layer: Phaser.GameObjects.Container, cutscene: StoryCutscene) {
const boardX = this.scale.width - ui(560);
const boardY = ui(128);
const boardW = ui(474);
const boardH = ui(246);
const board = this.add.rectangle(boardX, boardY, boardW, boardH, 0x0b1218, 0.92).setOrigin(0);
board.setStrokeStyle(ui(1), palette.blue, 0.5);
layer.add(board);
const grid = this.add.graphics();
grid.lineStyle(ui(1), 0x53606c, 0.26);
for (let i = 1; i < 6; i += 1) {
const x = boardX + (boardW / 6) * i;
grid.lineBetween(x, boardY + ui(16), x, boardY + boardH - ui(16));
}
for (let i = 1; i < 4; i += 1) {
const y = boardY + (boardH / 4) * i;
grid.lineBetween(boardX + ui(16), y, boardX + boardW - ui(16), y);
}
grid.lineStyle(ui(3), palette.gold, 0.36);
grid.beginPath();
grid.moveTo(boardX + boardW * 0.2, boardY + boardH * 0.74);
grid.lineTo(boardX + boardW * 0.44, boardY + boardH * 0.5);
grid.lineTo(boardX + boardW * 0.74, boardY + boardH * 0.28);
grid.strokePath();
layer.add(grid);
this.renderBriefingCard(layer, cutscene, ui(82), ui(184), ui(364), ui(138));
(cutscene.markers ?? []).forEach((marker) => this.renderOperationMarker(layer, marker, boardX, boardY, boardW, boardH));
}
private renderOperationMarker(
layer: Phaser.GameObjects.Container,
marker: StoryCutsceneMarker,
boardX: number,
boardY: number,
boardW: number,
boardH: number
) {
const color = marker.side === 'ally' ? palette.blue : marker.side === 'enemy' ? 0xd46a4c : palette.gold;
const x = boardX + boardW * marker.x;
const y = boardY + boardH * marker.y;
const halo = this.add.circle(x, y, ui(9), color, 0.1);
const dot = this.add.circle(x, y, ui(4), color, 0.86);
dot.setStrokeStyle(ui(1), cutsceneUiColors.mapInk, 0.72);
const stem = this.add.rectangle(x - ui(0.5), y + ui(4), ui(1), ui(13), color, 0.42).setOrigin(0, 0);
const labelX = marker.side === 'objective' ? x - ui(8) : x + ui(11);
const labelY = marker.side === 'objective' ? y - ui(27) : y - ui(10);
const label = this.add.text(labelX, labelY, marker.label, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: uiPx(11),
color: '#ead9b8',
stroke: '#05070a',
strokeThickness: ui(2)
});
if (marker.side === 'objective') {
label.setOrigin(0.5, 0);
}
layer.add([halo, stem, dot, label]);
}
private renderCutsceneActor(layer: Phaser.GameObjects.Container, actor: StoryCutsceneActor, index: number) {
const profile = storyCutsceneActorProfileFor(actor.unitId);
if (!profile) {
return;
}
const x = this.scale.width * actor.x;
const y = this.scale.height * actor.y;
const size = ui(actor.size ?? 126);
const direction = actor.direction ?? 'south';
const shadow = this.add.ellipse(x, y + size * 0.36, size * 0.46, size * 0.14, 0x05070a, 0.56);
layer.add(shadow);
if (this.textures.exists(profile.unitTextureKey)) {
const sprite = this.add.sprite(x, y, profile.unitTextureKey, this.storyUnitFrameIndex(direction));
sprite.setDisplaySize(size, size);
sprite.setBlendMode(Phaser.BlendModes.NORMAL);
layer.add(sprite);
this.tweens.add({
targets: sprite,
y: y - ui(4),
duration: 1200 + index * 90,
ease: 'Sine.easeInOut',
yoyo: true,
repeat: -1,
delay: index * 110
});
} else {
const fallback = this.add.rectangle(x, y, size * 0.56, size * 0.86, profile.color, 0.88);
fallback.setStrokeStyle(ui(2), 0x05070a, 0.8);
layer.add(fallback);
}
const labelWidth = Math.max(ui(78), Math.min(ui(132), size * 0.86));
const labelBg = this.add.rectangle(x, y + size * 0.54, labelWidth, ui(28), 0x070b10, 0.9);
labelBg.setStrokeStyle(ui(1), profile.color, 0.64);
const label = this.add.text(x, y + size * 0.54 - ui(7), actor.label ?? profile.name, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: uiPx(13),
color: '#f2e3bf',
fontStyle: '700'
});
label.setOrigin(0.5, 0);
layer.add([labelBg, label]);
if (actor.line) {
const line = this.add.text(x, y - size * 0.58, actor.line, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: uiPx(12),
color: '#e8dfca',
align: 'center',
wordWrap: { width: ui(160), useAdvancedWrap: true },
stroke: '#05070a',
strokeThickness: ui(3)
});
line.setOrigin(0.5, 1);
layer.add(line);
}
}
private renderCutsceneRewards(layer: Phaser.GameObjects.Container, model: StoryRewardDisplayModel) {
if (model.items.length === 0) {
return;
}
const stage = this.cutsceneStageBounds();
const visibleItems = model.items.slice(0, 4);
const maxItems = visibleItems.length;
const stripX = stage.x + ui(28);
const stripY = stage.y + stage.height - ui(62);
const stripW = stage.width - ui(56);
const gap = ui(8);
const itemW = (stripW - gap * (maxItems - 1)) / maxItems;
const itemH = ui(54);
const bg = this.add.graphics();
bg.fillStyle(cutsceneUiColors.veil, 0.42);
bg.fillRoundedRect(stripX - ui(8), stripY - ui(7), stripW + ui(16), itemH + ui(14), ui(9));
bg.lineStyle(ui(1), palette.gold, 0.18);
bg.strokeRoundedRect(stripX - ui(8), stripY - ui(7), stripW + ui(16), itemH + ui(14), ui(9));
layer.add(bg);
visibleItems.forEach((reward, index) => {
const toneColor = reward.tone === 'next' ? palette.blue : reward.tone === 'bond' ? palette.green : palette.gold;
const card = this.add.container(stripX + index * (itemW + gap), stripY + ui(6));
const item = this.add.graphics();
item.fillStyle(cutsceneUiColors.veil, 0.5);
item.fillRoundedRect(ui(3), ui(4), itemW, itemH, ui(7));
item.fillStyle(cutsceneUiColors.lacquer, 0.94);
item.fillRoundedRect(0, 0, itemW, itemH, ui(7));
item.fillStyle(toneColor, 0.12);
item.fillRoundedRect(ui(5), ui(5), itemW - ui(10), itemH - ui(10), ui(5));
item.fillStyle(toneColor, 0.2);
item.fillRoundedRect(ui(7), ui(7), ui(42), itemH - ui(14), ui(5));
item.lineStyle(ui(1), toneColor, 0.42);
item.strokeRoundedRect(0, 0, itemW, itemH, ui(7));
item.lineStyle(ui(1), 0xf0d08a, 0.12);
item.lineBetween(ui(58), ui(29), itemW - ui(12), ui(29));
const icon = this.renderRewardIcon(reward.icon, ui(28), itemH / 2, ui(31), toneColor);
const eyebrow = this.add.text(ui(60), ui(8), reward.eyebrow, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: uiPx(11),
color: reward.tone === 'next' ? '#9fc3e8' : reward.tone === 'bond' ? '#a9ddb9' : '#d8b15f',
fontStyle: '700'
});
const text = this.add.text(ui(60), ui(31), reward.label, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: uiPx(14),
color: '#f2e3bf',
fontStyle: '700',
wordWrap: { width: itemW - ui(74), useAdvancedWrap: true }
});
text.setOrigin(0, 0.5);
card.add([item, icon, eyebrow, text]);
card.setAlpha(0);
layer.add(card);
this.tweens.add({
targets: card,
y: stripY,
alpha: 1,
duration: 220,
delay: 60 + index * 70,
ease: 'Cubic.easeOut'
});
});
}
private rewardDisplayForPage(page: StoryPage): StoryRewardDisplayModel {
const fallback = this.fallbackRewardDisplay(page);
if (!firstBattleVictoryPageIds.has(page.id)) {
return fallback;
}
const report = getFirstBattleReport();
if (!report || report.battleId !== firstBattleId || report.outcome !== 'victory') {
return fallback;
}
const rewards = report.campaignRewards;
const presentation = campaignVictoryRewardPresentation(report);
const categorizedRewardItems = new Set([
...(rewards?.supplies ?? []),
...(rewards?.equipment ?? []),
...(rewards?.reputation ?? [])
]);
const legacyExtraItems = report.itemRewards.filter((item) => !categorizedRewardItems.has(item));
const supplyItems = rewards
? [
...rewards.supplies,
...legacyExtraItems,
...(presentation.sortieOrderBonus?.rewardItems ?? [])
]
: [...presentation.itemRewards];
if (!rewards && page.id !== 'first-victory-village') {
return fallback;
}
let items: StoryRewardDisplayItem[] = [];
if (page.id === 'first-victory-village') {
items = [this.goldRewardItem(presentation.rewardGold), ...supplyItems.map((label, index) => this.supplyRewardItem(label, index))];
} else if (page.id === 'first-victory-liu-bei' && rewards) {
items = [
...rewards.equipment.map((label, index) => this.equipmentRewardItem(label, index)),
...rewards.reputation.map((label, index) => this.reputationRewardItem(label, index))
];
} else if (page.id === 'first-victory-guan-yu' && rewards) {
items = rewards.recruits
.filter((recruit) => recruit.unitId === 'guan-yu')
.map((recruit) => this.recruitRewardItem(recruit.unitId, recruit.name));
} else if (page.id === 'first-victory-zhang-fei' && rewards) {
items = rewards.recruits
.filter((recruit) => recruit.unitId !== 'guan-yu')
.map((recruit) => this.recruitRewardItem(recruit.unitId, recruit.name));
} else if (page.id === 'first-victory-next' && rewards) {
items = rewards.unlocks.map((unlock) => this.unlockRewardItem(unlock.battleId, unlock.title));
}
return {
pageId: page.id,
source: 'campaign',
battleId: report.battleId,
rewardGold: presentation.rewardGold,
items
};
}
private fallbackRewardDisplay(page: StoryPage): StoryRewardDisplayModel {
const rewards = page.cutscene?.rewards ?? [];
return {
pageId: page.id,
source: rewards.length > 0 ? 'fallback' : 'none',
battleId: null,
rewardGold: null,
items: rewards.map((reward, index) => ({
id: `fallback-${index}`,
category: 'fallback',
eyebrow: reward.tone === 'next' ? '다음 목표' : reward.tone === 'bond' ? '전과' : '획득 정보',
label: reward.label,
tone: reward.tone,
icon: { kind: 'mark', fallbackMark: reward.tone === 'next' ? '路' : reward.tone === 'bond' ? '功' : '賞' }
}))
};
}
private emptyRewardDisplay(): StoryRewardDisplayModel {
return { pageId: null, source: 'none', battleId: null, rewardGold: null, items: [] };
}
private goldRewardItem(amount: number): StoryRewardDisplayItem {
return {
id: 'gold',
category: 'gold',
eyebrow: '군자금',
label: `+${Math.max(0, amount).toLocaleString('ko-KR')}`,
tone: 'reward',
icon: { kind: 'mark', fallbackMark: '金' }
};
}
private supplyRewardItem(label: string, index: number): StoryRewardDisplayItem {
const iconKey: BattleUiIconKey = /탁주/.test(label) ? 'wine' : /상처약/.test(label) ? 'salve' : 'bean';
return {
id: `supply-${index}-${label}`,
category: 'supply',
eyebrow: '보급품',
label,
tone: 'reward',
icon: { kind: 'battle-ui', iconKey, fallbackMark: /탁주/.test(label) ? '酒' : /상처약/.test(label) ? '藥' : '糧' }
};
}
private equipmentRewardItem(label: string, index: number): StoryRewardDisplayItem {
const inventoryLabel = label.replace(/\s+(?:[x×]\s*)?\d+\s*$/u, '').trim();
const itemId = equipmentItemIdForInventoryLabel(inventoryLabel);
return {
id: `equipment-${index}-${label}`,
category: 'equipment',
eyebrow: '장비 획득',
label,
tone: 'reward',
icon: itemId
? { kind: 'item', textureKey: `item-${itemId}`, fallbackMark: '具' }
: { kind: 'battle-ui', iconKey: 'sword', fallbackMark: '具' }
};
}
private reputationRewardItem(label: string, index: number): StoryRewardDisplayItem {
return {
id: `reputation-${index}-${label}`,
category: 'reputation',
eyebrow: '명성 상승',
label,
tone: 'bond',
icon: { kind: 'mark', fallbackMark: '名' }
};
}
private recruitRewardItem(unitId: string, name: string): StoryRewardDisplayItem {
return {
id: `recruit-${unitId}`,
category: 'recruit',
eyebrow: '신규 합류',
label: `${name} 출전 가능`,
tone: 'bond',
icon: { kind: 'mark', fallbackMark: '將' }
};
}
private unlockRewardItem(battleId: string, title: string): StoryRewardDisplayItem {
return {
id: `unlock-${battleId}`,
category: 'unlock',
eyebrow: '전장 해금',
label: title,
tone: 'next',
icon: { kind: 'battle-ui', iconKey: 'success', fallbackMark: '開' }
};
}
private renderRewardIcon(icon: StoryRewardIcon, x: number, y: number, size: number, toneColor: number) {
if (icon.kind === 'item' && this.textures.exists(icon.textureKey)) {
const image = this.add.image(x, y, icon.textureKey);
image.setDisplaySize(size, size);
return image;
}
if (icon.kind === 'battle-ui') {
const textureKey = this.textures.exists(battleUiIconTextureKey)
? battleUiIconTextureKey
: this.textures.exists(battleUiIconMicroTextureKey)
? battleUiIconMicroTextureKey
: undefined;
if (textureKey) {
const image = this.add.image(x, y, textureKey, battleUiIconFrames[icon.iconKey]);
image.setDisplaySize(size, size);
return image;
}
}
const fallback = this.add.container(x, y);
const seal = this.add.graphics();
seal.fillStyle(cutsceneUiColors.ink, 0.9);
seal.fillCircle(0, 0, size * 0.48);
seal.lineStyle(ui(1), toneColor, 0.72);
seal.strokeCircle(0, 0, size * 0.46);
seal.lineStyle(ui(1), 0xf0d08a, 0.16);
seal.strokeCircle(0, 0, size * 0.35);
const mark = this.add.text(0, 0, icon.fallbackMark, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", serif',
fontSize: uiPx(18),
color: '#f2e3bf',
fontStyle: '700'
});
mark.setOrigin(0.5);
fallback.add([seal, mark]);
return fallback;
}
private cutsceneStageBounds(): CutsceneStageBounds {
return {
x: ui(58),
y: ui(98),
width: this.scale.width - ui(116),
height: ui(348)
};
}
private actorPortraitTextureKey(actor: StoryCutsceneActor, index: number) {
const entry = this.actorPortraitAssetEntry(actor, index);
return entry && this.textures.exists(entry.textureKey) ? entry.textureKey : undefined;
}
private actorPortraitAssetEntry(actor: StoryCutsceneActor, index: number) {
const profile = storyCutsceneActorProfileFor(actor.unitId);
if (!profile) {
return undefined;
}
const entries = portraitAssetEntriesForKey(profile.portraitKey);
if (entries.length === 0) {
return undefined;
}
return entries[this.hashString(`${actor.unitId}-${index}`) % entries.length];
}
private storyUnitFrameIndex(direction: UnitDirection, frame = 0) {
return storyUnitFrameRows[direction] * unitBaseFramesPerDirection + frame;
}
private resolveBackgroundKey(textureKey: string) {
return this.textures.exists(textureKey) ? textureKey : 'story-fallback';
}
private hashString(value: string) {
let hash = 0;
for (let index = 0; index < value.length; index += 1) {
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
}
return hash;
}
private advance() {
if (this.transitioning) {
return;
}
if (this.pageIndex >= this.pages.length - 1) {
this.transitioning = true;
void startGameScene(this, this.nextScene, this.nextSceneData).catch((error) => {
console.error(`Failed to start ${this.nextScene}`, error);
this.transitioning = false;
});
return;
}
const targetPageIndex = this.pageIndex + 1;
this.transitioning = true;
this.ensureStoryPageAssets(
targetPageIndex,
() => {
if (!this.scene.isActive()) {
return;
}
this.pageIndex = targetPageIndex;
this.showPage(targetPageIndex);
},
true
);
}
private dialogueObjects() {
const objects: Array<AlphaObject | undefined> = [
this.chapterText,
this.portrait,
this.portraitFrame,
this.portraitDivider,
this.nameText,
this.bodyText,
this.progressBackground,
this.progressText,
this.cutsceneLayer
];
return objects.filter((object): object is AlphaObject => object !== undefined);
}
private setDialogueAlpha(alpha: number) {
this.dialogueObjects().forEach((object) => {
object.setAlpha(alpha);
});
}
}