import Phaser from 'phaser'; import { soundDirector } from '../audio/SoundDirector'; import { portraitAssetEntriesForKey, portraitKeyForSpeaker, type PortraitAssetEntry } from '../data/portraitAssets'; import { storyBackgroundAssets, storyBackgroundKeysFor } from '../data/storyAssets'; import { ensureUnitAnimations, loadUnitSheets, 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 { palette } from '../ui/palette'; import { 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; }; const sceneShadeDepth = 1; const cutsceneDepth = 8; const dialogDepth = 20; 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; export class StoryScene extends Phaser.Scene { private pageIndex = 0; private pages: StoryPage[] = 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 progressText?: Phaser.GameObjects.Text; private loadingText?: Phaser.GameObjects.Text; private cutsceneLayer?: Phaser.GameObjects.Container; constructor() { super('StoryScene'); } init(data?: StorySceneData) { this.pages = data?.pages?.length ? data.pages : prologuePages; this.nextScene = data?.nextScene ?? 'BattleScene'; this.nextSceneData = data?.nextSceneData; this.pageIndex = 0; this.transitioning = false; } getDebugState() { const page = this.pages[this.pageIndex]; 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, cutsceneKind: page?.cutscene?.kind ?? null, cutsceneActors: page?.cutscene?.actors?.map((actor) => actor.unitId) ?? [] }; } 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: '22px', color: '#f5e6b8', stroke: '#05070a', strokeThickness: 4 }); this.loadingText.setOrigin(0.5); this.ensureStoryAssetsLoaded(() => { 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()); }); } private ensureStoryAssetsLoaded(onReady: () => void) { const requestedKeys = this.pages.flatMap((page) => storyBackgroundKeysFor(page.background)); const requestedUnitTextureKeys = Array.from(new Set(this.pages.flatMap((page) => storyCutsceneActorTextureKeys(page.cutscene)))); const missingKeys = Array.from(new Set(requestedKeys)).filter((key) => !this.textures.exists(key)); const loadableKeys = missingKeys.filter((key) => storyBackgroundAssets[key]); const missingPortraits = this.pages .flatMap((page) => { const cutscenePortraits = page.cutscene?.actors?.flatMap((actor) => { const profile = storyCutsceneActorProfileFor(actor.unitId); return profile ? portraitAssetEntriesForKey(profile.portraitKey) : []; }) ?? []; const entry = this.pagePortraitAssetEntry(page); return entry ? [entry, ...cutscenePortraits] : cutscenePortraits; }) .filter((entry, index, entries) => entries.findIndex((candidate) => candidate.textureKey === entry.textureKey) === index) .filter((entry) => !this.textures.exists(entry.textureKey)); missingKeys .filter((key) => !storyBackgroundAssets[key]) .forEach((key) => console.warn(`Missing story background asset for ${key}`)); const finishLoading = () => { if (requestedUnitTextureKeys.length === 0) { onReady(); return; } loadUnitSheets(this, requestedUnitTextureKeys, () => { ensureUnitAnimations(this, requestedUnitTextureKeys); onReady(); }); }; if (loadableKeys.length === 0 && missingPortraits.length === 0) { finishLoading(); return; } this.load.once('complete', finishLoading); this.load.once('loaderror', (file: Phaser.Loader.File) => { console.warn(`Failed to load story background ${file.key}`); }); loadableKeys.forEach((key) => this.load.image(key, storyBackgroundAssets[key])); missingPortraits.forEach(({ textureKey, url }) => this.load.image(textureKey, url)); this.load.start(); } private createFallbackStoryTexture() { if (this.textures.exists('story-fallback')) { return; } const graphics = this.make.graphics({ x: 0, y: 0 }); graphics.fillStyle(0x10131a, 1); graphics.fillRect(0, 0, 1280, 720); graphics.fillStyle(0x1d2530, 1); graphics.fillRect(0, 0, 1280, 180); graphics.lineStyle(4, palette.gold, 0.5); graphics.beginPath(); graphics.moveTo(120, 600); graphics.lineTo(1160, 120); graphics.strokePath(); graphics.generateTexture('story-fallback', 1280, 720); 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 - 286, width, 286); shade.fillStyle(0x030405, 0.34); shade.fillRect(0, 0, width, 118); shade.fillStyle(0x030405, 0.2); shade.fillRect(0, 94, width, 388); shade.fillStyle(0x030405, 0.28); shade.fillRect(0, 0, 120, height); shade.fillRect(width - 120, 0, 120, height); } private drawDialogPanel(width: number, height: number) { const panelY = height - 238; const panelX = 72; const panelW = width - 144; const panelH = 172; const panel = this.add.graphics(); panel.setDepth(dialogDepth); panel.fillStyle(cutsceneUiColors.veil, 0.62); panel.fillRoundedRect(panelX - 12, panelY + 12, panelW + 24, panelH + 18, 12); panel.fillStyle(cutsceneUiColors.woodDark, 0.96); panel.fillRoundedRect(panelX, panelY, panelW, panelH, 10); panel.fillStyle(cutsceneUiColors.lacquer, 0.9); panel.fillRoundedRect(panelX + 12, panelY + 14, panelW - 24, panelH - 28, 8); panel.fillStyle(cutsceneUiColors.silk, 0.52); panel.fillRoundedRect(panelX + 22, panelY + 24, panelW - 44, panelH - 48, 6); panel.fillStyle(cutsceneUiColors.woodLight, 0.48); panel.fillRoundedRect(panelX + 20, panelY - 6, panelW - 40, 14, 7); panel.fillRoundedRect(panelX + 20, panelY + panelH - 8, panelW - 40, 14, 7); panel.lineStyle(2, palette.gold, 0.28); panel.strokeRoundedRect(panelX + 10, panelY + 10, panelW - 20, panelH - 20, 8); panel.lineStyle(1, 0xf0d08a, 0.12); panel.lineBetween(panelX + 198, panelY + 24, panelX + panelW - 30, panelY + 18); panel.lineBetween(panelX + 198, panelY + panelH - 26, panelX + panelW - 30, panelY + panelH - 18); panel.lineStyle(1, cutsceneUiColors.woodLight, 0.14); for (let index = 0; index < 4; index += 1) { const y = panelY + 40 + index * 24; panel.lineBetween(panelX + 198, y, panelX + panelW - 36, y + (index % 2 === 0 ? 3 : -3)); } this.chapterText = this.add.text(panelX, panelY - 42, '', { fontFamily: '"Malgun Gothic", "Noto Sans KR", serif', fontSize: '22px', color: '#d8b15f', fontStyle: '700', padding: { top: 4, bottom: 4 }, shadow: { offsetX: 0, offsetY: 2, color: '#05070a', blur: 6, fill: true } }); this.chapterText.setDepth(dialogDepth + 2); this.portraitFrame = this.add.rectangle(panelX + 86, panelY + 86, 136, 136, cutsceneUiColors.ink, 0.92); this.portraitFrame.setStrokeStyle(2, palette.gold, 0.34); this.portraitFrame.setDepth(dialogDepth + 2); this.portrait = this.add.image(panelX + 86, panelY + 86, this.pagePortraitTextureKey(this.pages[0]) ?? 'story-fallback'); this.portrait.setDisplaySize(128, 128); this.portrait.setDepth(dialogDepth + 3); this.portraitDivider = this.add.rectangle(panelX + 170, panelY + 30, 1, panelH - 60, palette.gold, 0.12).setOrigin(0); this.portraitDivider.setDepth(dialogDepth + 2); this.nameText = this.add.text(panelX + 198, panelY + 28, '', { fontFamily: '"Malgun Gothic", "Noto Sans KR", serif', fontSize: '24px', color: '#f1e3c2', fontStyle: '700', padding: { top: 4, bottom: 4 } }); this.nameText.setDepth(dialogDepth + 2); this.bodyText = this.add.text(panelX + 198, panelY + 78, '', { fontFamily: '"Malgun Gothic", "Noto Sans KR", serif', fontSize: '25px', color: '#e8dfca', wordWrap: { width: panelW - 236, useAdvancedWrap: true }, lineSpacing: 10, padding: { top: 4, bottom: 6 }, shadow: { offsetX: 0, offsetY: 2, color: '#05070a', blur: 5, fill: true } }); this.bodyText.setDepth(dialogDepth + 2); this.progressText = this.add.text(width - 220, panelY + panelH + 20, '', { fontFamily: '"Segoe UI", sans-serif', fontSize: '15px', color: '#9aa3ad' }); this.progressText.setDepth(dialogDepth + 2); } private showPage(index: number, immediate = false) { const page = this.pages[index]; if (immediate) { this.applyPage(page); 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.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.cutscene); 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(128, 128); this.nameText?.setPosition(270, this.scale.height - 210); this.bodyText?.setPosition(270, this.scale.height - 160); this.bodyText?.setWordWrapWidth(this.scale.width - 374, true); } else { this.portraitFrame?.setVisible(false); this.portrait?.setVisible(false); this.portraitDivider?.setVisible(false); this.nameText?.setPosition(96, this.scale.height - 210); this.bodyText?.setPosition(96, this.scale.height - 160); this.bodyText?.setWordWrapWidth(this.scale.width - 200, true); } this.nameText?.setText(page.speaker ?? '나레이션'); this.bodyText?.setText(page.text); this.progressText?.setText(`${this.pageIndex + 1} / ${this.pages.length} SPACE / ENTER`); } 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) { const keys = storyBackgroundKeysFor(page.background).filter((key) => this.textures.exists(key)); if (keys.length <= 1) { return keys[0] ?? page.background; } return keys[this.hashString(page.id) % keys.length]; } 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 - 16, height / 2); this.tweens.add({ targets: this.background, x: width / 2 + 16, y: height / 2 - 8, scale: coverScale * 1.11, duration: 15000, ease: 'Sine.easeInOut', yoyo: true, repeat: -1 }); } private renderCutscene(cutscene?: StoryCutscene) { this.cutsceneLayer?.list.forEach((child) => this.tweens.killTweensOf(child)); this.cutsceneLayer?.destroy(); this.cutsceneLayer = undefined; 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, cutscene.rewards ?? []); } 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 - 18, stageY - 14, stageW + 36, stageH + 28, 14); stage.fillStyle(cutsceneUiColors.woodDark, 0.98); stage.fillRoundedRect(stageX, stageY, stageW, stageH, 12); stage.fillStyle(cutsceneUiColors.woodMid, 0.9); stage.fillRoundedRect(stageX + 12, stageY + 12, stageW - 24, stageH - 24, 9); stage.fillStyle(cutsceneUiColors.lacquer, 0.68); stage.fillRoundedRect(stageX + 26, stageY + 76, stageW - 52, stageH - 112, 8); stage.fillStyle(cutsceneUiColors.woodLight, 0.2); stage.fillRoundedRect(stageX + 18, stageY + 14, stageW - 36, 16, 8); stage.fillStyle(cutsceneUiColors.woodDark, 0.36); stage.fillRoundedRect(stageX + 18, stageY + stageH - 28, stageW - 36, 12, 6); stage.lineStyle(1, accent, 0.28); stage.strokeRoundedRect(stageX + 11, stageY + 11, stageW - 22, stageH - 22, 9); stage.lineStyle(1, 0xf0d08a, 0.1); stage.lineBetween(stageX + 30, stageY + 70, stageX + stageW - 30, stageY + 68); stage.lineBetween(stageX + 30, stageY + stageH - 52, stageX + stageW - 30, stageY + stageH - 46); stage.lineStyle(1, cutsceneUiColors.woodLight, 0.16); for (let index = 0; index < 12; index += 1) { const y = stageY + 28 + index * 24; stage.lineBetween(stageX + 28, y, stageX + stageW - 28, y + (index % 2 === 0 ? 4 : -3)); } stage.fillStyle(0x0a0504, 0.38); stage.fillCircle(stageX + 28, stageY + 28, 4); stage.fillCircle(stageX + stageW - 28, stageY + 28, 4); stage.fillCircle(stageX + 28, stageY + stageH - 28, 4); stage.fillCircle(stageX + stageW - 28, stageY + stageH - 28, 4); const foldingScreen = this.add.graphics(); foldingScreen.lineStyle(1, 0xd0b16e, 0.1); foldingScreen.fillStyle(0x342216, 0.22); for (let index = 0; index < 5; index += 1) { const panelX = stageX + 26 + index * 86; foldingScreen.fillRoundedRect(panelX, stageY + 92, 66, stageH - 134, 4); foldingScreen.strokeRoundedRect(panelX, stageY + 92, 66, stageH - 134, 4); } const tableShadow = this.add.ellipse(stageX + stageW * 0.62, stageY + stageH - 34, stageW * 0.6, 38, 0x030405, 0.34); const titlePlate = this.add.graphics(); titlePlate.fillStyle(cutsceneUiColors.lacquer, 0.88); titlePlate.fillRoundedRect(stageX + 22, stageY + 18, 366, 48, 8); titlePlate.fillStyle(accent, 0.14); titlePlate.fillRoundedRect(stageX + 30, stageY + 25, 34, 34, 5); titlePlate.lineStyle(1, accent, 0.3); titlePlate.lineBetween(stageX + 74, stageY + 25, stageX + 374, stageY + 25); titlePlate.lineBetween(stageX + 74, stageY + 60, stageX + 374, stageY + 60); titlePlate.lineStyle(1, 0xf0d08a, 0.12); titlePlate.strokeRoundedRect(stageX + 22, stageY + 18, 366, 48, 8); const title = this.add.text(stageX + 34, stageY + 18, cutscene.title, { fontFamily: '"Malgun Gothic", "Noto Sans KR", serif', fontSize: '26px', color: '#f2e3bf', fontStyle: '700', stroke: '#05070a', strokeThickness: 4 }); const subtitle = this.add.text(stageX + 36, stageY + 53, cutscene.subtitle ?? '', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '14px', 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 + 78; const boardW = Math.round(stage.width * 0.58); const boardH = stage.height - 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 + 12, boardW * 0.9, 34); parchment.fillStyle(cutsceneUiColors.woodDark, 0.92); parchment.fillRoundedRect(boardX + 10, boardY - 10, boardW - 20, 20, 10); parchment.fillRoundedRect(boardX + 8, boardY + boardH - 10, boardW - 16, 20, 10); parchment.fillStyle(cutsceneUiColors.woodLight, 0.35); parchment.fillRoundedRect(boardX + 22, boardY - 5, boardW - 44, 5, 3); parchment.fillRoundedRect(boardX + 22, boardY + boardH - 5, boardW - 44, 5, 3); parchment.fillStyle(cutsceneUiColors.parchmentDark, 0.94); parchment.fillRoundedRect(boardX + 3, boardY + 4, boardW - 6, boardH - 8, 5); parchment.fillStyle(cutsceneUiColors.parchment, 0.92); parchment.fillRoundedRect(boardX + 10, boardY + 12, boardW - 20, boardH - 24, 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 + 10, boardY + 12, boardW - 20, 10, 4); parchment.fillRoundedRect(boardX + 10, boardY + boardH - 22, boardW - 20, 10, 4); parchment.fillStyle(0x23140c, 0.1); parchment.fillEllipse(boardX + boardW * 0.5, boardY + boardH * 0.62, boardW * 0.72, boardH * 0.34); parchment.lineStyle(1, cutsceneUiColors.mapInk, 0.24); parchment.strokeRoundedRect(boardX + 8, boardY + 10, boardW - 16, boardH - 20, 5); parchment.lineStyle(1, 0xf0d08a, 0.1); parchment.lineBetween(boardX + 24, boardY + 20, boardX + boardW - 28, boardY + 16); parchment.lineBetween(boardX + 26, boardY + boardH - 20, boardX + boardW - 24, boardY + boardH - 24); parchment.fillStyle(0x3d2413, 0.16); parchment.beginPath(); parchment.moveTo(boardX + boardW - 54, boardY + 12); parchment.lineTo(boardX + boardW - 12, boardY + 12); parchment.lineTo(boardX + boardW - 12, boardY + 52); parchment.closePath(); parchment.fillPath(); parchment.lineStyle(1, 0x3d2413, 0.1); for (let index = 0; index < 11; index += 1) { const y = boardY + 38 + index * 14; const xOffset = index % 3 === 0 ? 26 : index % 3 === 1 ? 38 : 31; parchment.lineBetween(boardX + xOffset, y, boardX + boardW - 30, y + (index % 2 === 0 ? 2 : -2)); } for (let index = 0; index < 30; index += 1) { const x = boardX + 30 + ((index * 47) % Math.max(60, boardW - 62)); const y = boardY + 34 + ((index * 29) % Math.max(60, boardH - 66)); parchment.fillStyle(index % 2 === 0 ? 0x4f3219 : 0xd2b172, 0.1); parchment.fillCircle(x, y, index % 3 === 0 ? 1.4 : 0.9); } layer.add(parchment); const map = this.add.graphics(); map.lineStyle(1, cutsceneUiColors.mapInk, 0.08); for (let index = 1; index < 7; index += 1) { const x = boardX + (boardW / 7) * index; map.lineBetween(x, boardY + 24, x, boardY + boardH - 24); } for (let index = 1; index < 4; index += 1) { const y = boardY + (boardH / 4) * index; map.lineBetween(boardX + 24, y, boardX + boardW - 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(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(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(1, cutsceneUiColors.mapInk, 0.18); map.strokeRoundedRect(boardX + 26, boardY + 28, boardW - 52, boardH - 56, 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 + 24, board.y + 24, 126, 22, 4); bg.lineStyle(1, cutsceneUiColors.mapInk, 0.18); bg.lineBetween(board.x + 32, board.y + 43, board.x + 140, board.y + 43); bg.fillStyle(cutsceneUiColors.sealRed, 0.22); bg.fillCircle(board.x + 134, board.y + 35, 8); const text = this.add.text(board.x + 34, board.y + 28, label, { fontFamily: '"Segoe UI", sans-serif', fontSize: '12px', 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 + 28; const cardW = Math.max(250, board.x - cardX - 24); const cardH = actors.length >= 4 ? 60 : 70; const firstY = stage.y + 86; actors.forEach((actor, index) => { const y = firstY + index * (cardH + 10); this.renderCutsceneActorCard(layer, actor, cardX, y, cardW, cardH, index); }); if ((cutscene.briefing?.lines ?? []).length > 0) { const briefingY = firstY + actors.length * (cardH + 10) + 4; const maxH = stage.y + stage.height - 36 - briefingY; if (maxH >= 70) { this.renderBriefingCard(layer, cutscene, cardX, briefingY, cardW, Math.min(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 + 5, y + 7, width, height, 8); card.fillStyle(cutsceneUiColors.woodDark, 0.94); card.fillRoundedRect(x, y, width, height, 8); card.fillStyle(cutsceneUiColors.lacquerLight, 0.46); card.fillRoundedRect(x + 7, y + 7, width - 14, height - 14, 6); card.fillStyle(cutsceneUiColors.lacquer, 0.42); card.fillRoundedRect(x + 72, y + 9, width - 88, height - 18, 5); card.fillStyle(profile.color, 0.42); card.fillRoundedRect(x + 11, y + 10, 4, height - 20, 2); card.fillStyle(cutsceneUiColors.paperEdge, 0.16); card.fillRoundedRect(x + height + 4, y + 9, Math.min(130, width - height - 52), 24, 4); card.lineStyle(1, 0xf0d08a, 0.14); card.lineBetween(x + 22, y + 9, x + width - 24, y + 9); card.lineBetween(x + 22, y + height - 9, x + width - 24, y + height - 9); card.lineStyle(1, profile.color, 0.24); card.lineBetween(x + 18, y + 15, x + 18, y + height - 15); card.fillStyle(profile.color, 0.18); card.fillCircle(x + width - 24, y + height / 2, 12); card.fillStyle(cutsceneUiColors.sealRed, 0.18); card.fillCircle(x + width - 24, y + height / 2, 7); layer.add(card); const portraitTexture = this.actorPortraitTextureKey(actor, index); const portraitX = x + 38; const portraitY = y + height / 2; const portraitFrame = this.add.graphics(); portraitFrame.fillStyle(cutsceneUiColors.ink, 0.92); portraitFrame.fillRoundedRect(portraitX - (height - 14) / 2, portraitY - (height - 14) / 2, height - 14, height - 14, 5); portraitFrame.lineStyle(1, profile.color, 0.34); portraitFrame.strokeRoundedRect(portraitX - (height - 14) / 2, portraitY - (height - 14) / 2, height - 14, height - 14, 5); portraitFrame.lineStyle(1, 0xf0d08a, 0.1); portraitFrame.strokeRoundedRect(portraitX - (height - 24) / 2, portraitY - (height - 24) / 2, height - 24, height - 24, 3); layer.add(portraitFrame); if (portraitTexture) { const portrait = this.add.image(portraitX, portraitY, portraitTexture); portrait.setDisplaySize(height - 18, height - 18); layer.add(portrait); } else if (this.textures.exists(profile.unitTextureKey)) { const sprite = this.add.sprite(portraitX, portraitY + 3, profile.unitTextureKey, this.storyUnitFrameIndex(actor.direction ?? 'south')); sprite.setDisplaySize(height - 8, height - 8); layer.add(sprite); } const name = this.add.text(x + height + 14, y + 11, actor.label ?? profile.name, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '16px', color: '#f5e6c8', fontStyle: '700' }); const roleBg = this.add.graphics(); roleBg.lineStyle(1, profile.color, 0.26); roleBg.lineBetween(x + height + 14, y + 38, x + height + Math.min(128, width - height - 34), y + 38); const role = this.add.text(x + height + 14, y + 42, profile.roleLabel, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: '#ddc99f' }); layer.add([roleBg, name, role]); if (actor.line && height >= 66) { const line = this.add.text(x + 144, y + 50, actor.line, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '11px', color: '#d8cbb2', wordWrap: { width: width - 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(2, 12, 38, 12, 0x05070a, 0.28); const base = this.add.ellipse(0, 6, 30, 18, cutsceneUiColors.woodDark, 0.78); base.setStrokeStyle(1, sideColor, 0.46); const pin = this.add.circle(0, 6, 3, sideColor, 0.86); pin.setStrokeStyle(1, 0x05070a, 0.62); token.add([shadow, base, pin]); if (this.textures.exists(profile.unitTextureKey)) { const sprite = this.add.sprite(0, -8, profile.unitTextureKey, this.storyUnitFrameIndex(actor.direction ?? 'south')); sprite.setDisplaySize(34, 34); token.add(sprite); } else { const fallback = this.add.rectangle(0, -8, 22, 24, sideColor, 0.9); fallback.setStrokeStyle(1, 0x05070a, 0.82); token.add(fallback); } const label = this.add.text(0, 14, actor.label ?? profile.name, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '9px', color: '#ead9b8', stroke: '#05070a', strokeThickness: 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 + 4, y + 5, width, height, 8); bg.fillStyle(cutsceneUiColors.lacquer, 0.68); bg.fillRoundedRect(x, y, width, height, 8); bg.fillStyle(cutsceneUiColors.paperEdge, 0.08); bg.fillRoundedRect(x + 10, y + 10, width - 20, height - 20, 6); bg.lineStyle(1, palette.gold, 0.18); bg.lineBetween(x + 16, y + 13, x + width - 16, y + 13); bg.lineBetween(x + 16, y + height - 13, x + width - 16, y + height - 13); const title = this.add.text(x + 18, y + 16, cutscene.briefing?.title ?? '전황', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '18px', color: '#d8b15f', fontStyle: '700' }); layer.add([bg, title]); briefingLines.slice(0, 4).forEach((line, index) => { const bullet = this.add.circle(x + 24, y + 54 + index * 30, 3, palette.gold, 0.72); const text = this.add.text(x + 38, y + 43 + index * 30, line, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '14px', color: '#d8cbb2', wordWrap: { width: width - 58, useAdvancedWrap: true } }); layer.add([bullet, text]); }); } private renderOperationBoard(layer: Phaser.GameObjects.Container, cutscene: StoryCutscene) { const boardX = this.scale.width - 560; const boardY = 128; const boardW = 474; const boardH = 246; const board = this.add.rectangle(boardX, boardY, boardW, boardH, 0x0b1218, 0.92).setOrigin(0); board.setStrokeStyle(1, palette.blue, 0.5); layer.add(board); const grid = this.add.graphics(); grid.lineStyle(1, 0x53606c, 0.26); for (let i = 1; i < 6; i += 1) { const x = boardX + (boardW / 6) * i; grid.lineBetween(x, boardY + 16, x, boardY + boardH - 16); } for (let i = 1; i < 4; i += 1) { const y = boardY + (boardH / 4) * i; grid.lineBetween(boardX + 16, y, boardX + boardW - 16, y); } grid.lineStyle(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, 82, 184, 364, 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, 9, color, 0.1); const dot = this.add.circle(x, y, 4, color, 0.86); dot.setStrokeStyle(1, cutsceneUiColors.mapInk, 0.72); const stem = this.add.rectangle(x - 0.5, y + 4, 1, 13, color, 0.42).setOrigin(0, 0); const labelX = marker.side === 'objective' ? x - 8 : x + 11; const labelY = marker.side === 'objective' ? y - 27 : y - 10; const label = this.add.text(labelX, labelY, marker.label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '11px', color: '#ead9b8', stroke: '#05070a', strokeThickness: 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 = 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 - 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(2, 0x05070a, 0.8); layer.add(fallback); } const labelWidth = Math.max(78, Math.min(132, size * 0.86)); const labelBg = this.add.rectangle(x, y + size * 0.54, labelWidth, 28, 0x070b10, 0.9); labelBg.setStrokeStyle(1, profile.color, 0.64); const label = this.add.text(x, y + size * 0.54 - 7, actor.label ?? profile.name, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', 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: '12px', color: '#e8dfca', align: 'center', wordWrap: { width: 160, useAdvancedWrap: true }, stroke: '#05070a', strokeThickness: 3 }); line.setOrigin(0.5, 1); layer.add(line); } } private renderCutsceneRewards(layer: Phaser.GameObjects.Container, rewards: StoryCutsceneReward[]) { if (rewards.length === 0) { return; } const stage = this.cutsceneStageBounds(); const maxItems = Math.min(4, rewards.length); const stripX = stage.x + 28; const stripY = stage.y + stage.height - 44; const stripW = stage.width - 56; const itemW = Math.floor(stripW / maxItems); const bg = this.add.graphics(); bg.fillStyle(cutsceneUiColors.veil, 0.22); bg.fillRoundedRect(stripX + 4, stripY + 5, stripW, 34, 6); bg.fillStyle(cutsceneUiColors.lacquer, 0.72); bg.fillRoundedRect(stripX, stripY, stripW, 34, 6); bg.lineStyle(1, palette.gold, 0.18); bg.lineBetween(stripX + 12, stripY + 6, stripX + stripW - 12, stripY + 6); bg.lineBetween(stripX + 12, stripY + 28, stripX + stripW - 12, stripY + 28); layer.add(bg); rewards.slice(0, maxItems).forEach((reward, index) => { const toneColor = reward.tone === 'next' ? palette.blue : reward.tone === 'bond' ? palette.green : palette.gold; const x = stripX + index * itemW; const item = this.add.graphics(); item.fillStyle(toneColor, 0.16); item.fillCircle(x + 17, stripY + 17, 5); item.lineStyle(1, toneColor, 0.22); if (index > 0) { item.lineBetween(x, stripY + 9, x, stripY + 25); } const text = this.add.text(x + 30, stripY + 8, reward.label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color: '#f2e3bf', fontStyle: '700', wordWrap: { width: itemW - 40, useAdvancedWrap: true } }); layer.add([item, text]); }); } private cutsceneStageBounds(): CutsceneStageBounds { return { x: 58, y: 98, width: this.scale.width - 116, height: 348 }; } private actorPortraitTextureKey(actor: StoryCutsceneActor, index: number) { const profile = storyCutsceneActorProfileFor(actor.unitId); if (!profile) { return undefined; } const entries = portraitAssetEntriesForKey(profile.portraitKey).filter((entry) => this.textures.exists(entry.textureKey)); if (entries.length === 0) { return undefined; } return entries[(this.hashString(`${actor.unitId}-${index}`) % entries.length)].textureKey; } 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; } this.pageIndex += 1; this.showPage(this.pageIndex); } private dialogueObjects() { const objects: Array = [ this.chapterText, this.portrait, this.portraitFrame, this.portraitDivider, this.nameText, this.bodyText, this.progressText, this.cutsceneLayer ]; return objects.filter((object): object is AlphaObject => object !== undefined); } private setDialogueAlpha(alpha: number) { this.dialogueObjects().forEach((object) => { object.setAlpha(alpha); }); } }