1077 lines
41 KiB
TypeScript
1077 lines
41 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 {
|
|
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<string, unknown>;
|
|
};
|
|
|
|
const sceneShadeDepth = 1;
|
|
const cutsceneDepth = 8;
|
|
const dialogDepth = 20;
|
|
const storyUnitFrameRows: Record<UnitDirection, number> = {
|
|
south: 0,
|
|
east: 1,
|
|
north: 2,
|
|
west: 3
|
|
};
|
|
const cutsceneUiColors = {
|
|
veil: 0x020304,
|
|
ink: 0x070809,
|
|
lacquer: 0x100806,
|
|
lacquerLight: 0x24120b,
|
|
silk: 0x17100d,
|
|
silkDeep: 0x0d0a08,
|
|
woodDark: 0x160d07,
|
|
woodMid: 0x2d1b0f,
|
|
woodLight: 0x5f3d20,
|
|
parchment: 0x594832,
|
|
parchmentLight: 0x7e6542,
|
|
parchmentDark: 0x2d1a0e,
|
|
paperEdge: 0xa67f4f,
|
|
mapInk: 0x302113,
|
|
sealRed: 0x8f3023
|
|
} as const;
|
|
|
|
type CutsceneStageBounds = {
|
|
x: number;
|
|
y: number;
|
|
width: number;
|
|
height: number;
|
|
};
|
|
|
|
type CutsceneBoardBounds = CutsceneStageBounds;
|
|
|
|
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;
|
|
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<AlphaObject | undefined> = [
|
|
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);
|
|
});
|
|
}
|
|
}
|