import Phaser from 'phaser'; import { soundDirector } from '../audio/SoundDirector'; import { portraitAssetEntriesForKey, portraitKeyForSpeaker, type PortraitAssetEntry } from '../data/portraitAssets'; import { storyBackgroundAssets, storyBackgroundKeysFor } from '../data/storyAssets'; import { prologuePages, type PortraitKey, type StoryPage } from '../data/scenario'; import { palette } from '../ui/palette'; import { startGameScene } from './lazyScenes'; type AlphaObject = Phaser.GameObjects.Image | Phaser.GameObjects.Rectangle | Phaser.GameObjects.Text; type StorySceneData = { pages?: StoryPage[]; nextScene?: string; nextSceneData?: Record; }; 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; 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 }; } 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.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 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 entry = this.pagePortraitAssetEntry(page); return entry ? [entry] : []; }) .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}`)); if (loadableKeys.length === 0 && missingPortraits.length === 0) { onReady(); return; } this.load.once('complete', onReady); 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.fillStyle(0x040608, 0.22); shade.fillRect(0, 0, width, height); shade.fillStyle(0x040608, 0.5); shade.fillRect(0, height - 270, width, 270); shade.fillStyle(0x040608, 0.26); shade.fillRect(0, 0, width, 96); } private drawDialogPanel(width: number, height: number) { const panelY = height - 238; const panelX = 72; const panelW = width - 144; const panelH = 172; this.add.rectangle(panelX, panelY, panelW, panelH, palette.panelDark, 0.88).setOrigin(0); this.add.rectangle(panelX, panelY, panelW, 3, palette.gold, 0.9).setOrigin(0); this.add.rectangle(panelX, panelY + panelH, panelW, 1, palette.gold, 0.35).setOrigin(0); 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.portraitFrame = this.add.rectangle(panelX + 86, panelY + 86, 136, 136, 0x090d12, 0.88); this.portraitFrame.setStrokeStyle(2, palette.gold, 0.58); this.portrait = this.add.image(panelX + 86, panelY + 86, this.pagePortraitTextureKey(this.pages[0]) ?? 'story-fallback'); this.portrait.setDisplaySize(128, 128); this.portraitDivider = this.add.rectangle(panelX + 166, panelY + 28, 1, panelH - 56, palette.gold, 0.22).setOrigin(0); 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.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.progressText = this.add.text(width - 220, panelY + panelH + 20, '', { fontFamily: '"Segoe UI", sans-serif', fontSize: '15px', color: '#9aa3ad' }); } 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.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.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 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 ]; return objects.filter((object): object is AlphaObject => object !== undefined); } private setDialogueAlpha(alpha: number) { this.dialogueObjects().forEach((object) => { object.setAlpha(alpha); }); } }