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

387 lines
13 KiB
TypeScript

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<string, unknown>;
};
export class StoryScene extends Phaser.Scene {
private pageIndex = 0;
private pages: StoryPage[] = prologuePages;
private nextScene = 'BattleScene';
private nextSceneData?: Record<string, unknown>;
private transitioning = false;
private background?: Phaser.GameObjects.Image;
private chapterText?: Phaser.GameObjects.Text;
private portrait?: Phaser.GameObjects.Image;
private portraitFrame?: Phaser.GameObjects.Rectangle;
private portraitDivider?: Phaser.GameObjects.Rectangle;
private nameText?: Phaser.GameObjects.Text;
private bodyText?: Phaser.GameObjects.Text;
private 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<AlphaObject | undefined> = [
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);
});
}
}