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; }; 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 = { 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; 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(); private storyPageAssetQueue: StoryPageAssetRequest[] = []; private activeStoryPageAssetRequest?: StoryPageAssetRequest; private activeStoryLoadErrorHandler?: (file: Phaser.Loader.File) => void; private storyAssetLoadGeneration = 0; private storyAssetFailures = new Set(); 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 = [ 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); }); } }