1661 lines
64 KiB
TypeScript
1661 lines
64 KiB
TypeScript
import Phaser from 'phaser';
|
||
import { soundDirector } from '../audio/SoundDirector';
|
||
import { equipmentItemIdForInventoryLabel } from '../data/battleItems';
|
||
import {
|
||
battleUiIconFrames,
|
||
battleUiIconMicroTextureKey,
|
||
battleUiIconTextureKey,
|
||
loadBattleUiIcons,
|
||
type BattleUiIconKey
|
||
} from '../data/battleUiIcons';
|
||
import { portraitAssetEntriesForKey, portraitKeyForSpeaker, type PortraitAssetEntry } from '../data/portraitAssets';
|
||
import {
|
||
storyBackgroundAssets,
|
||
storyBackgroundKeyForPage,
|
||
storyBackgroundKeysForPages
|
||
} from '../data/storyAssets';
|
||
import {
|
||
ensureUnitAnimations,
|
||
loadUnitBaseSheets,
|
||
unitBaseFramesPerDirection,
|
||
type UnitDirection
|
||
} from '../data/unitAssets';
|
||
import {
|
||
storyCutsceneActorProfileFor,
|
||
storyCutsceneActorTextureKeys,
|
||
type StoryCutscene,
|
||
type StoryCutsceneActor,
|
||
type StoryCutsceneMarker,
|
||
type StoryCutsceneReward
|
||
} from '../data/storyCutscenes';
|
||
import { prologuePages, type PortraitKey, type StoryPage } from '../data/scenario';
|
||
import { campaignVictoryRewardPresentation, getFirstBattleReport } from '../state/campaignState';
|
||
import { palette } from '../ui/palette';
|
||
import { ensureLazyScene, startGameScene } from './lazyScenes';
|
||
|
||
type AlphaObject = Phaser.GameObjects.Container | Phaser.GameObjects.Image | Phaser.GameObjects.Rectangle | Phaser.GameObjects.Text;
|
||
|
||
type StorySceneData = {
|
||
pages?: StoryPage[];
|
||
nextScene?: string;
|
||
nextSceneData?: Record<string, unknown>;
|
||
};
|
||
|
||
type StoryPageAssetRequest = {
|
||
pageIndex: number;
|
||
callbacks: Array<() => void>;
|
||
interactive: boolean;
|
||
};
|
||
|
||
type StoryPageAssetPlan = {
|
||
backgrounds: Array<{ key: string; url: string }>;
|
||
portraits: PortraitAssetEntry[];
|
||
unitTextureKeys: string[];
|
||
};
|
||
|
||
const sceneShadeDepth = 1;
|
||
const cutsceneDepth = 8;
|
||
const dialogDepth = 20;
|
||
const fhdUiScale = 1.5;
|
||
const ui = (value: number) => value * fhdUiScale;
|
||
const uiPx = (value: number) => `${Math.round(ui(value))}px`;
|
||
const storyUnitFrameRows: Record<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;
|
||
|
||
type StoryRewardCategory = 'gold' | 'supply' | 'equipment' | 'reputation' | 'recruit' | 'unlock' | 'fallback';
|
||
type StoryRewardSource = 'campaign' | 'fallback' | 'none';
|
||
|
||
type StoryRewardIcon =
|
||
| { kind: 'item'; textureKey: string; fallbackMark: string }
|
||
| { kind: 'battle-ui'; iconKey: BattleUiIconKey; fallbackMark: string }
|
||
| { kind: 'mark'; fallbackMark: string };
|
||
|
||
type StoryRewardDisplayItem = {
|
||
id: string;
|
||
category: StoryRewardCategory;
|
||
eyebrow: string;
|
||
label: string;
|
||
tone: StoryCutsceneReward['tone'];
|
||
icon: StoryRewardIcon;
|
||
};
|
||
|
||
type StoryRewardDisplayModel = {
|
||
pageId: string | null;
|
||
source: StoryRewardSource;
|
||
battleId: string | null;
|
||
rewardGold: number | null;
|
||
items: StoryRewardDisplayItem[];
|
||
};
|
||
|
||
const firstBattleVictoryPageIds = new Set([
|
||
'first-victory-village',
|
||
'first-victory-liu-bei',
|
||
'first-victory-guan-yu',
|
||
'first-victory-zhang-fei',
|
||
'first-victory-next'
|
||
]);
|
||
const firstBattleId = 'first-battle-zhuo-commandery';
|
||
const firstBattleWarmupUnitTextureKeys = [
|
||
'unit-rebel',
|
||
'unit-rebel-archer',
|
||
'unit-rebel-cavalry'
|
||
] as const;
|
||
|
||
export class StoryScene extends Phaser.Scene {
|
||
private pageIndex = 0;
|
||
private pages: StoryPage[] = prologuePages;
|
||
private selectedStoryBackgroundKeys = storyBackgroundKeysForPages(prologuePages);
|
||
private nextScene = 'BattleScene';
|
||
private nextSceneData?: Record<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 progressBackground?: Phaser.GameObjects.Rectangle;
|
||
private progressText?: Phaser.GameObjects.Text;
|
||
private loadingText?: Phaser.GameObjects.Text;
|
||
private pageLoadingText?: Phaser.GameObjects.Text;
|
||
private cutsceneLayer?: Phaser.GameObjects.Container;
|
||
private readyStoryPageIndexes = new Set<number>();
|
||
private storyPageAssetQueue: StoryPageAssetRequest[] = [];
|
||
private activeStoryPageAssetRequest?: StoryPageAssetRequest;
|
||
private activeStoryLoadErrorHandler?: (file: Phaser.Loader.File) => void;
|
||
private storyAssetLoadGeneration = 0;
|
||
private storyAssetFailures = new Set<string>();
|
||
private rewardDisplay: StoryRewardDisplayModel = this.emptyRewardDisplay();
|
||
|
||
constructor() {
|
||
super('StoryScene');
|
||
}
|
||
|
||
init(data?: StorySceneData) {
|
||
this.pages = data?.pages?.length ? data.pages : prologuePages;
|
||
this.selectedStoryBackgroundKeys = storyBackgroundKeysForPages(this.pages);
|
||
this.nextScene = data?.nextScene ?? 'BattleScene';
|
||
this.nextSceneData = data?.nextSceneData;
|
||
this.pageIndex = 0;
|
||
this.transitioning = false;
|
||
this.readyStoryPageIndexes.clear();
|
||
this.storyPageAssetQueue = [];
|
||
this.activeStoryPageAssetRequest = undefined;
|
||
this.activeStoryLoadErrorHandler = undefined;
|
||
this.storyAssetFailures.clear();
|
||
this.storyAssetLoadGeneration += 1;
|
||
this.rewardDisplay = this.emptyRewardDisplay();
|
||
}
|
||
|
||
getDebugState() {
|
||
const page = this.pages[this.pageIndex];
|
||
const progressBounds = this.progressText?.active ? this.progressText.getBounds() : undefined;
|
||
const progressPanelBounds = this.progressBackground?.active ? this.progressBackground.getBounds() : undefined;
|
||
const isLastPage = this.pageIndex >= this.pages.length - 1;
|
||
|
||
return {
|
||
scene: this.scene.key,
|
||
ready: this.loadingText === undefined && this.background !== undefined,
|
||
pageIndex: this.pageIndex,
|
||
totalPages: this.pages.length,
|
||
currentPageId: page?.id,
|
||
requestedBackground: page?.background,
|
||
background: page ? this.pageBackgroundKey(page) : undefined,
|
||
backgroundReady: page ? this.textures.exists(this.pageBackgroundKey(page)) : false,
|
||
activeTexture: this.background?.texture.key ?? null,
|
||
portraitKey: page ? this.pagePortraitKey(page) ?? null : null,
|
||
portraitTextureKey: this.portrait?.visible ? this.portrait.texture.key : null,
|
||
progressLabel: this.progressText?.text ?? '',
|
||
progressBounds: progressBounds
|
||
? { x: progressBounds.x, y: progressBounds.y, width: progressBounds.width, height: progressBounds.height }
|
||
: null,
|
||
progressPanelBounds: progressPanelBounds
|
||
? { x: progressPanelBounds.x, y: progressPanelBounds.y, width: progressPanelBounds.width, height: progressPanelBounds.height }
|
||
: null,
|
||
isLastPage,
|
||
advanceHint:
|
||
isLastPage && this.nextScene === 'BattleScene'
|
||
? 'battle-deployment'
|
||
: isLastPage && this.nextScene === 'CampScene'
|
||
? 'camp-return'
|
||
: isLastPage
|
||
? 'continue'
|
||
: 'next',
|
||
advanceLabel: this.progressActionLabel(isLastPage),
|
||
nextScene: this.nextScene,
|
||
cutsceneKind: page?.cutscene?.kind ?? null,
|
||
cutsceneActors: page?.cutscene?.actors?.map((actor) => actor.unitId) ?? [],
|
||
rewardDisplay: {
|
||
...this.rewardDisplay,
|
||
items: this.rewardDisplay.items.map((item) => ({ ...item, icon: { ...item.icon } }))
|
||
},
|
||
assetStreaming: {
|
||
currentPageReady: this.readyStoryPageIndexes.has(this.pageIndex),
|
||
nextPageIndex: this.pageIndex + 1 < this.pages.length ? this.pageIndex + 1 : null,
|
||
nextPageReady: this.pageIndex + 1 < this.pages.length ? this.readyStoryPageIndexes.has(this.pageIndex + 1) : null,
|
||
loadingPageIndex: this.activeStoryPageAssetRequest?.pageIndex ?? null,
|
||
queuedPageIndexes: this.storyPageAssetQueue.map((request) => request.pageIndex),
|
||
readyPageIndexes: [...this.readyStoryPageIndexes].sort((left, right) => left - right),
|
||
failedAssetKeys: [...this.storyAssetFailures],
|
||
currentUnitBaseTexturesLoaded: page
|
||
? this.storyPageAssetPlan(this.pageIndex).unitTextureKeys.filter((key) => this.textures.exists(key))
|
||
: [],
|
||
currentUnitActionTexturesLoaded: page
|
||
? this.storyPageAssetPlan(this.pageIndex).unitTextureKeys.filter((key) => this.textures.exists(`${key}-actions`))
|
||
: []
|
||
}
|
||
};
|
||
}
|
||
|
||
create() {
|
||
const { width, height } = this.scale;
|
||
this.add.rectangle(0, 0, width, height, 0x0b0d12).setOrigin(0);
|
||
this.createFallbackStoryTexture();
|
||
this.loadingText = this.add.text(width / 2, height / 2, '장면을 준비하는 중...', {
|
||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||
fontSize: uiPx(22),
|
||
color: '#f5e6b8',
|
||
stroke: '#05070a',
|
||
strokeThickness: ui(4)
|
||
});
|
||
this.loadingText.setOrigin(0.5);
|
||
|
||
const generation = this.storyAssetLoadGeneration;
|
||
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
|
||
this.storyAssetLoadGeneration += 1;
|
||
this.storyPageAssetQueue = [];
|
||
this.activeStoryPageAssetRequest = undefined;
|
||
if (this.activeStoryLoadErrorHandler) {
|
||
this.load.off('loaderror', this.activeStoryLoadErrorHandler);
|
||
this.activeStoryLoadErrorHandler = undefined;
|
||
}
|
||
this.pageLoadingText = undefined;
|
||
});
|
||
this.warmFirstBattleSceneModule();
|
||
|
||
this.ensureStoryPageAssets(0, () => {
|
||
if (generation !== this.storyAssetLoadGeneration) {
|
||
return;
|
||
}
|
||
this.loadingText?.destroy();
|
||
this.loadingText = undefined;
|
||
this.background = this.add.image(width / 2, height / 2, this.resolveBackgroundKey(this.pageBackgroundKey(this.pages[0])));
|
||
this.background.setDepth(0);
|
||
this.drawSceneShade(width, height);
|
||
this.drawDialogPanel(width, height);
|
||
this.showPage(0, true);
|
||
|
||
this.input.on('pointerdown', () => this.advance());
|
||
this.input.keyboard?.on('keydown-SPACE', () => this.advance());
|
||
this.input.keyboard?.on('keydown-ENTER', () => this.advance());
|
||
}, true);
|
||
}
|
||
|
||
private ensureStoryPageAssets(pageIndex: number, onReady?: () => void, interactive = false) {
|
||
if (pageIndex < 0 || pageIndex >= this.pages.length) {
|
||
onReady?.();
|
||
return;
|
||
}
|
||
|
||
if (this.readyStoryPageIndexes.has(pageIndex)) {
|
||
onReady?.();
|
||
return;
|
||
}
|
||
|
||
const activeRequest = this.activeStoryPageAssetRequest;
|
||
if (activeRequest?.pageIndex === pageIndex) {
|
||
if (onReady) {
|
||
activeRequest.callbacks.push(onReady);
|
||
}
|
||
activeRequest.interactive ||= interactive;
|
||
this.refreshPageLoadingIndicator();
|
||
return;
|
||
}
|
||
|
||
const queuedRequest = this.storyPageAssetQueue.find((request) => request.pageIndex === pageIndex);
|
||
if (queuedRequest) {
|
||
if (onReady) {
|
||
queuedRequest.callbacks.push(onReady);
|
||
}
|
||
queuedRequest.interactive ||= interactive;
|
||
if (interactive) {
|
||
this.storyPageAssetQueue = [
|
||
queuedRequest,
|
||
...this.storyPageAssetQueue.filter((request) => request !== queuedRequest)
|
||
];
|
||
}
|
||
this.refreshPageLoadingIndicator();
|
||
return;
|
||
}
|
||
|
||
const request: StoryPageAssetRequest = {
|
||
pageIndex,
|
||
callbacks: onReady ? [onReady] : [],
|
||
interactive
|
||
};
|
||
if (interactive) {
|
||
this.storyPageAssetQueue.unshift(request);
|
||
} else {
|
||
this.storyPageAssetQueue.push(request);
|
||
}
|
||
this.processStoryPageAssetQueue();
|
||
}
|
||
|
||
private processStoryPageAssetQueue() {
|
||
if (this.activeStoryPageAssetRequest || this.storyPageAssetQueue.length === 0) {
|
||
this.refreshPageLoadingIndicator();
|
||
return;
|
||
}
|
||
|
||
const request = this.storyPageAssetQueue.shift();
|
||
if (!request) {
|
||
return;
|
||
}
|
||
|
||
this.activeStoryPageAssetRequest = request;
|
||
this.refreshPageLoadingIndicator();
|
||
const generation = this.storyAssetLoadGeneration;
|
||
const plan = this.storyPageAssetPlan(request.pageIndex);
|
||
|
||
const finishRequest = () => {
|
||
if (generation !== this.storyAssetLoadGeneration) {
|
||
return;
|
||
}
|
||
|
||
ensureUnitAnimations(this, plan.unitTextureKeys);
|
||
this.readyStoryPageIndexes.add(request.pageIndex);
|
||
this.activeStoryPageAssetRequest = undefined;
|
||
this.refreshPageLoadingIndicator();
|
||
request.callbacks.forEach((callback) => callback());
|
||
this.processStoryPageAssetQueue();
|
||
};
|
||
|
||
const finishUnitBases = () => {
|
||
if (generation !== this.storyAssetLoadGeneration) {
|
||
return;
|
||
}
|
||
if (this.isFirstBattleWarmupPage(request.pageIndex)) {
|
||
loadBattleUiIcons(this, finishRequest);
|
||
return;
|
||
}
|
||
finishRequest();
|
||
};
|
||
|
||
const loadUnitBases = () => {
|
||
if (generation !== this.storyAssetLoadGeneration || plan.unitTextureKeys.length === 0) {
|
||
finishUnitBases();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
loadUnitBaseSheets(this, plan.unitTextureKeys, finishUnitBases);
|
||
} catch (error) {
|
||
console.warn(`Failed to prepare story unit bases for page ${request.pageIndex}`, error);
|
||
plan.unitTextureKeys
|
||
.filter((key) => !this.textures.exists(key))
|
||
.forEach((key) => this.storyAssetFailures.add(key));
|
||
finishUnitBases();
|
||
}
|
||
};
|
||
|
||
const imageLoads = [
|
||
...plan.backgrounds.map(({ key, url }) => ({ key, url })),
|
||
...plan.portraits.map(({ textureKey: key, url }) => ({ key, url }))
|
||
].filter(({ key }, index, entries) => {
|
||
return !this.textures.exists(key) && entries.findIndex((entry) => entry.key === key) === index;
|
||
});
|
||
|
||
if (imageLoads.length === 0) {
|
||
loadUnitBases();
|
||
return;
|
||
}
|
||
|
||
const onLoadError = (file: Phaser.Loader.File) => {
|
||
this.storyAssetFailures.add(String(file.key));
|
||
console.warn(`Failed to load story asset ${file.key}`);
|
||
};
|
||
this.activeStoryLoadErrorHandler = onLoadError;
|
||
this.load.on('loaderror', onLoadError);
|
||
this.load.once('complete', () => {
|
||
this.load.off('loaderror', onLoadError);
|
||
if (this.activeStoryLoadErrorHandler === onLoadError) {
|
||
this.activeStoryLoadErrorHandler = undefined;
|
||
}
|
||
loadUnitBases();
|
||
});
|
||
imageLoads.forEach(({ key, url }) => this.load.image(key, url));
|
||
this.load.start();
|
||
}
|
||
|
||
private storyPageAssetPlan(pageIndex: number): StoryPageAssetPlan {
|
||
const page = this.pages[pageIndex];
|
||
if (!page) {
|
||
return { backgrounds: [], portraits: [], unitTextureKeys: [] };
|
||
}
|
||
|
||
const backgroundKey = this.pageBackgroundAssetKey(page);
|
||
const backgroundUrl = backgroundKey ? storyBackgroundAssets[backgroundKey] : undefined;
|
||
if (backgroundKey && !backgroundUrl) {
|
||
this.storyAssetFailures.add(backgroundKey);
|
||
console.warn(`Missing story background asset for ${backgroundKey}`);
|
||
}
|
||
|
||
const pagePortrait = this.pagePortraitAssetEntry(page);
|
||
const actorPortraits =
|
||
page.cutscene?.actors
|
||
?.map((actor, index) => this.actorPortraitAssetEntry(actor, index))
|
||
.filter((entry): entry is PortraitAssetEntry => entry !== undefined) ?? [];
|
||
const portraits = [pagePortrait, ...actorPortraits]
|
||
.filter((entry): entry is PortraitAssetEntry => entry !== undefined)
|
||
.filter((entry, index, entries) => entries.findIndex((candidate) => candidate.textureKey === entry.textureKey) === index);
|
||
|
||
const warmupUnitTextureKeys = this.isFirstBattleWarmupPage(pageIndex)
|
||
? firstBattleWarmupUnitTextureKeys
|
||
: [];
|
||
return {
|
||
backgrounds: backgroundKey && backgroundUrl ? [{ key: backgroundKey, url: backgroundUrl }] : [],
|
||
portraits,
|
||
unitTextureKeys: Array.from(new Set([
|
||
...storyCutsceneActorTextureKeys(page.cutscene),
|
||
...warmupUnitTextureKeys
|
||
]))
|
||
};
|
||
}
|
||
|
||
private isFirstBattlePrelude() {
|
||
const requestedBattleId = typeof this.nextSceneData?.battleId === 'string'
|
||
? this.nextSceneData.battleId
|
||
: firstBattleId;
|
||
return this.nextScene === 'BattleScene' && requestedBattleId === firstBattleId;
|
||
}
|
||
|
||
private isFirstBattleWarmupPage(pageIndex: number) {
|
||
return this.isFirstBattlePrelude() && pageIndex === Math.min(1, this.pages.length - 1);
|
||
}
|
||
|
||
private warmFirstBattleSceneModule() {
|
||
if (!this.isFirstBattlePrelude()) {
|
||
return;
|
||
}
|
||
void ensureLazyScene(this.game, 'BattleScene').catch((error) => {
|
||
console.warn('Failed to warm the first battle scene module.', error);
|
||
});
|
||
}
|
||
|
||
private prefetchNextStoryPage(pageIndex: number) {
|
||
const nextPageIndex = pageIndex + 1;
|
||
if (nextPageIndex < this.pages.length) {
|
||
this.ensureStoryPageAssets(nextPageIndex);
|
||
}
|
||
}
|
||
|
||
private refreshPageLoadingIndicator() {
|
||
const interactiveRequest =
|
||
(this.activeStoryPageAssetRequest?.interactive ? this.activeStoryPageAssetRequest : undefined) ??
|
||
this.storyPageAssetQueue.find((request) => request.interactive);
|
||
|
||
if (!interactiveRequest || this.loadingText) {
|
||
this.pageLoadingText?.destroy();
|
||
this.pageLoadingText = undefined;
|
||
return;
|
||
}
|
||
|
||
if (!this.pageLoadingText) {
|
||
this.pageLoadingText = this.add.text(this.scale.width / 2, this.scale.height - ui(54), '', {
|
||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||
fontSize: uiPx(14),
|
||
color: '#f5e6b8',
|
||
backgroundColor: '#070809',
|
||
padding: { x: ui(18), y: ui(8) }
|
||
});
|
||
this.pageLoadingText.setOrigin(0.5);
|
||
this.pageLoadingText.setDepth(dialogDepth + 6);
|
||
}
|
||
this.pageLoadingText.setText(`다음 장면 준비 중… ${interactiveRequest.pageIndex + 1} / ${this.pages.length}`);
|
||
}
|
||
|
||
private createFallbackStoryTexture() {
|
||
if (this.textures.exists('story-fallback')) {
|
||
return;
|
||
}
|
||
|
||
const { width, height } = this.scale;
|
||
const graphics = this.make.graphics({ x: 0, y: 0 });
|
||
graphics.fillStyle(0x10131a, 1);
|
||
graphics.fillRect(0, 0, width, height);
|
||
graphics.fillStyle(0x1d2530, 1);
|
||
graphics.fillRect(0, 0, width, height * (180 / 720));
|
||
graphics.lineStyle(ui(4), palette.gold, 0.5);
|
||
graphics.beginPath();
|
||
graphics.moveTo(width * (120 / 1280), height * (600 / 720));
|
||
graphics.lineTo(width * (1160 / 1280), height * (120 / 720));
|
||
graphics.strokePath();
|
||
graphics.generateTexture('story-fallback', width, height);
|
||
graphics.destroy();
|
||
}
|
||
|
||
private drawSceneShade(width: number, height: number) {
|
||
const shade = this.add.graphics();
|
||
shade.setDepth(sceneShadeDepth);
|
||
shade.fillStyle(0x030405, 0.34);
|
||
shade.fillRect(0, 0, width, height);
|
||
shade.fillStyle(0x030405, 0.5);
|
||
shade.fillRect(0, height - ui(286), width, ui(286));
|
||
shade.fillStyle(0x030405, 0.34);
|
||
shade.fillRect(0, 0, width, ui(118));
|
||
shade.fillStyle(0x030405, 0.2);
|
||
shade.fillRect(0, ui(94), width, ui(388));
|
||
shade.fillStyle(0x030405, 0.28);
|
||
shade.fillRect(0, 0, ui(120), height);
|
||
shade.fillRect(width - ui(120), 0, ui(120), height);
|
||
}
|
||
|
||
private drawDialogPanel(width: number, height: number) {
|
||
const panelY = height - ui(238);
|
||
const panelX = ui(72);
|
||
const panelW = width - ui(144);
|
||
const panelH = ui(172);
|
||
|
||
const panel = this.add.graphics();
|
||
panel.setDepth(dialogDepth);
|
||
panel.fillStyle(cutsceneUiColors.veil, 0.62);
|
||
panel.fillRoundedRect(panelX - ui(12), panelY + ui(12), panelW + ui(24), panelH + ui(18), ui(12));
|
||
panel.fillStyle(cutsceneUiColors.woodDark, 0.96);
|
||
panel.fillRoundedRect(panelX, panelY, panelW, panelH, ui(10));
|
||
panel.fillStyle(cutsceneUiColors.lacquer, 0.9);
|
||
panel.fillRoundedRect(panelX + ui(12), panelY + ui(14), panelW - ui(24), panelH - ui(28), ui(8));
|
||
panel.fillStyle(cutsceneUiColors.silk, 0.52);
|
||
panel.fillRoundedRect(panelX + ui(22), panelY + ui(24), panelW - ui(44), panelH - ui(48), ui(6));
|
||
panel.fillStyle(cutsceneUiColors.woodLight, 0.48);
|
||
panel.fillRoundedRect(panelX + ui(20), panelY - ui(6), panelW - ui(40), ui(14), ui(7));
|
||
panel.fillRoundedRect(panelX + ui(20), panelY + panelH - ui(8), panelW - ui(40), ui(14), ui(7));
|
||
panel.lineStyle(ui(2), palette.gold, 0.28);
|
||
panel.strokeRoundedRect(panelX + ui(10), panelY + ui(10), panelW - ui(20), panelH - ui(20), ui(8));
|
||
panel.lineStyle(ui(1), 0xf0d08a, 0.12);
|
||
panel.lineBetween(panelX + ui(198), panelY + ui(24), panelX + panelW - ui(30), panelY + ui(18));
|
||
panel.lineBetween(panelX + ui(198), panelY + panelH - ui(26), panelX + panelW - ui(30), panelY + panelH - ui(18));
|
||
panel.lineStyle(ui(1), cutsceneUiColors.woodLight, 0.14);
|
||
for (let index = 0; index < 4; index += 1) {
|
||
const y = panelY + ui(40) + index * ui(24);
|
||
panel.lineBetween(panelX + ui(198), y, panelX + panelW - ui(36), y + ui(index % 2 === 0 ? 3 : -3));
|
||
}
|
||
|
||
this.chapterText = this.add.text(panelX, panelY - ui(42), '', {
|
||
fontFamily: '"Malgun Gothic", "Noto Sans KR", serif',
|
||
fontSize: uiPx(22),
|
||
color: '#d8b15f',
|
||
fontStyle: '700',
|
||
padding: { top: ui(4), bottom: ui(4) },
|
||
shadow: { offsetX: 0, offsetY: ui(2), color: '#05070a', blur: ui(6), fill: true }
|
||
});
|
||
this.chapterText.setDepth(dialogDepth + 2);
|
||
|
||
this.portraitFrame = this.add.rectangle(panelX + ui(86), panelY + ui(86), ui(136), ui(136), cutsceneUiColors.ink, 0.92);
|
||
this.portraitFrame.setStrokeStyle(ui(2), palette.gold, 0.34);
|
||
this.portraitFrame.setDepth(dialogDepth + 2);
|
||
|
||
this.portrait = this.add.image(panelX + ui(86), panelY + ui(86), this.pagePortraitTextureKey(this.pages[0]) ?? 'story-fallback');
|
||
this.portrait.setDisplaySize(ui(128), ui(128));
|
||
this.portrait.setDepth(dialogDepth + 3);
|
||
|
||
this.portraitDivider = this.add.rectangle(panelX + ui(170), panelY + ui(30), ui(1), panelH - ui(60), palette.gold, 0.12).setOrigin(0);
|
||
this.portraitDivider.setDepth(dialogDepth + 2);
|
||
|
||
this.nameText = this.add.text(panelX + ui(198), panelY + ui(28), '', {
|
||
fontFamily: '"Malgun Gothic", "Noto Sans KR", serif',
|
||
fontSize: uiPx(24),
|
||
color: '#f1e3c2',
|
||
fontStyle: '700',
|
||
padding: { top: ui(4), bottom: ui(4) }
|
||
});
|
||
this.nameText.setDepth(dialogDepth + 2);
|
||
|
||
this.bodyText = this.add.text(panelX + ui(198), panelY + ui(78), '', {
|
||
fontFamily: '"Malgun Gothic", "Noto Sans KR", serif',
|
||
fontSize: uiPx(25),
|
||
color: '#e8dfca',
|
||
wordWrap: { width: panelW - ui(236), useAdvancedWrap: true },
|
||
lineSpacing: ui(10),
|
||
padding: { top: ui(4), bottom: ui(6) },
|
||
shadow: { offsetX: 0, offsetY: ui(2), color: '#05070a', blur: ui(5), fill: true }
|
||
});
|
||
this.bodyText.setDepth(dialogDepth + 2);
|
||
|
||
const progressX = width - ui(214);
|
||
const progressY = panelY + panelH + ui(20);
|
||
this.progressBackground = this.add.rectangle(progressX, progressY, ui(380), ui(34), cutsceneUiColors.ink, 0.82);
|
||
this.progressBackground.setStrokeStyle(ui(1), palette.gold, 0.42);
|
||
this.progressBackground.setDepth(dialogDepth + 1);
|
||
|
||
this.progressText = this.add.text(progressX, progressY, '', {
|
||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||
fontSize: uiPx(14),
|
||
color: '#d8b15f',
|
||
fontStyle: '700',
|
||
align: 'center',
|
||
fixedWidth: ui(356)
|
||
});
|
||
this.progressText.setOrigin(0.5);
|
||
this.progressText.setDepth(dialogDepth + 2);
|
||
}
|
||
|
||
private showPage(index: number, immediate = false) {
|
||
const page = this.pages[index];
|
||
|
||
if (immediate) {
|
||
this.applyPage(page);
|
||
this.prefetchNextStoryPage(index);
|
||
return;
|
||
}
|
||
|
||
this.transitioning = true;
|
||
this.tweens.killTweensOf([this.background, ...this.dialogueObjects()]);
|
||
this.tweens.add({
|
||
targets: this.dialogueObjects(),
|
||
alpha: 0.18,
|
||
duration: 90,
|
||
ease: 'Sine.easeIn'
|
||
});
|
||
this.tweens.add({
|
||
targets: this.background,
|
||
alpha: 0.68,
|
||
duration: 120,
|
||
ease: 'Sine.easeIn',
|
||
onComplete: () => {
|
||
this.applyPage(page);
|
||
this.prefetchNextStoryPage(index);
|
||
this.background?.setAlpha(0.68);
|
||
this.setDialogueAlpha(0.18);
|
||
this.tweens.add({
|
||
targets: this.dialogueObjects(),
|
||
alpha: 1,
|
||
duration: 170,
|
||
ease: 'Sine.easeOut'
|
||
});
|
||
this.tweens.add({
|
||
targets: this.background,
|
||
alpha: 1,
|
||
duration: 220,
|
||
ease: 'Sine.easeOut',
|
||
onComplete: () => {
|
||
this.transitioning = false;
|
||
}
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
private applyPage(page: StoryPage) {
|
||
soundDirector.playMusic(page.bgm);
|
||
this.applyBackground(page);
|
||
this.renderCutscene(page);
|
||
this.chapterText?.setText(page.chapter);
|
||
|
||
const textureKey = this.pagePortraitTextureKey(page);
|
||
|
||
if (textureKey && this.textures.exists(textureKey)) {
|
||
this.portraitFrame?.setVisible(true);
|
||
this.portrait?.setVisible(true);
|
||
this.portraitDivider?.setVisible(true);
|
||
this.portrait?.setTexture(textureKey);
|
||
this.portrait?.setDisplaySize(ui(128), ui(128));
|
||
this.nameText?.setPosition(ui(270), this.scale.height - ui(210));
|
||
this.bodyText?.setPosition(ui(270), this.scale.height - ui(160));
|
||
this.bodyText?.setWordWrapWidth(this.scale.width - ui(374), true);
|
||
} else {
|
||
this.portraitFrame?.setVisible(false);
|
||
this.portrait?.setVisible(false);
|
||
this.portraitDivider?.setVisible(false);
|
||
this.nameText?.setPosition(ui(96), this.scale.height - ui(210));
|
||
this.bodyText?.setPosition(ui(96), this.scale.height - ui(160));
|
||
this.bodyText?.setWordWrapWidth(this.scale.width - ui(200), true);
|
||
}
|
||
|
||
this.nameText?.setText(page.speaker ?? '나레이션');
|
||
this.bodyText?.setText(page.text);
|
||
const isLastPage = this.pageIndex >= this.pages.length - 1;
|
||
this.progressText?.setText(
|
||
`${this.pageIndex + 1} / ${this.pages.length} · 클릭 / 스페이스·엔터 · ${this.progressActionLabel(isLastPage)}`
|
||
);
|
||
}
|
||
|
||
private progressActionLabel(isLastPage = this.pageIndex >= this.pages.length - 1) {
|
||
if (!isLastPage) {
|
||
return '다음';
|
||
}
|
||
if (this.nextScene === 'BattleScene') {
|
||
return '전투 배치';
|
||
}
|
||
if (this.nextScene === 'CampScene') {
|
||
return '군영으로';
|
||
}
|
||
return '계속';
|
||
}
|
||
|
||
private pagePortraitKey(page: StoryPage) {
|
||
return page.portrait ?? portraitKeyForSpeaker(page.speaker);
|
||
}
|
||
|
||
private pagePortraitTextureKey(page: StoryPage) {
|
||
const entry = this.pagePortraitAssetEntry(page);
|
||
return entry && this.textures.exists(entry.textureKey) ? entry.textureKey : undefined;
|
||
}
|
||
|
||
private pagePortraitAssetEntry(page: StoryPage): PortraitAssetEntry | undefined {
|
||
const portraitKey = this.pagePortraitKey(page);
|
||
if (!portraitKey) {
|
||
return undefined;
|
||
}
|
||
|
||
const entries = portraitAssetEntriesForKey(portraitKey);
|
||
if (entries.length === 0) {
|
||
return undefined;
|
||
}
|
||
|
||
return entries[this.hashString(page.id) % entries.length];
|
||
}
|
||
|
||
private pageBackgroundKey(page: StoryPage) {
|
||
return this.pageBackgroundAssetKey(page) ?? page.background;
|
||
}
|
||
|
||
private pageBackgroundAssetKey(page: StoryPage) {
|
||
const pageIndex = this.pages.indexOf(page);
|
||
const key = pageIndex >= 0
|
||
? this.selectedStoryBackgroundKeys[pageIndex]
|
||
: storyBackgroundKeyForPage(page.background, page.id);
|
||
return storyBackgroundAssets[key] ? key : page.background;
|
||
}
|
||
|
||
private applyBackground(page: StoryPage) {
|
||
if (!this.background) {
|
||
return;
|
||
}
|
||
|
||
const { width, height } = this.scale;
|
||
const safeTextureKey = this.resolveBackgroundKey(this.pageBackgroundKey(page));
|
||
const source = this.textures.get(safeTextureKey).getSourceImage();
|
||
const imageWidth = source.width;
|
||
const imageHeight = source.height;
|
||
const coverScale = Math.max(width / imageWidth, height / imageHeight);
|
||
|
||
this.tweens.killTweensOf(this.background);
|
||
this.background.setTexture(safeTextureKey);
|
||
this.background.setAlpha(1);
|
||
this.background.setDepth(0);
|
||
this.background.setScale(coverScale * 1.07);
|
||
this.background.setPosition(width / 2 - ui(16), height / 2);
|
||
|
||
this.tweens.add({
|
||
targets: this.background,
|
||
x: width / 2 + ui(16),
|
||
y: height / 2 - ui(8),
|
||
scale: coverScale * 1.11,
|
||
duration: 15000,
|
||
ease: 'Sine.easeInOut',
|
||
yoyo: true,
|
||
repeat: -1
|
||
});
|
||
}
|
||
|
||
private renderCutscene(page: StoryPage) {
|
||
this.cutsceneLayer?.list.forEach((child) => this.tweens.killTweensOf(child));
|
||
this.cutsceneLayer?.destroy();
|
||
this.cutsceneLayer = undefined;
|
||
this.rewardDisplay = this.rewardDisplayForPage(page);
|
||
|
||
const cutscene = page.cutscene;
|
||
|
||
if (!cutscene) {
|
||
return;
|
||
}
|
||
|
||
const layer = this.add.container(0, 0);
|
||
layer.setDepth(cutsceneDepth);
|
||
this.cutsceneLayer = layer;
|
||
|
||
this.renderCutsceneStage(layer, cutscene);
|
||
const boardBounds = this.renderTacticalBoard(layer, cutscene);
|
||
this.renderCutsceneActorCards(layer, cutscene, boardBounds);
|
||
this.renderCutsceneRewards(layer, this.rewardDisplay);
|
||
}
|
||
|
||
private renderCutsceneStage(layer: Phaser.GameObjects.Container, cutscene: StoryCutscene) {
|
||
const { x: stageX, y: stageY, width: stageW, height: stageH } = this.cutsceneStageBounds();
|
||
const accent = cutscene.kind === 'victory' ? palette.green : cutscene.kind === 'operation' ? palette.blue : palette.gold;
|
||
const stage = this.add.graphics();
|
||
stage.fillStyle(cutsceneUiColors.veil, 0.62);
|
||
stage.fillRoundedRect(stageX - ui(18), stageY - ui(14), stageW + ui(36), stageH + ui(28), ui(14));
|
||
stage.fillStyle(cutsceneUiColors.woodDark, 0.98);
|
||
stage.fillRoundedRect(stageX, stageY, stageW, stageH, ui(12));
|
||
stage.fillStyle(cutsceneUiColors.woodMid, 0.9);
|
||
stage.fillRoundedRect(stageX + ui(12), stageY + ui(12), stageW - ui(24), stageH - ui(24), ui(9));
|
||
stage.fillStyle(cutsceneUiColors.lacquer, 0.68);
|
||
stage.fillRoundedRect(stageX + ui(26), stageY + ui(76), stageW - ui(52), stageH - ui(112), ui(8));
|
||
stage.fillStyle(cutsceneUiColors.woodLight, 0.2);
|
||
stage.fillRoundedRect(stageX + ui(18), stageY + ui(14), stageW - ui(36), ui(16), ui(8));
|
||
stage.fillStyle(cutsceneUiColors.woodDark, 0.36);
|
||
stage.fillRoundedRect(stageX + ui(18), stageY + stageH - ui(28), stageW - ui(36), ui(12), ui(6));
|
||
stage.lineStyle(ui(1), accent, 0.28);
|
||
stage.strokeRoundedRect(stageX + ui(11), stageY + ui(11), stageW - ui(22), stageH - ui(22), ui(9));
|
||
stage.lineStyle(ui(1), 0xf0d08a, 0.1);
|
||
stage.lineBetween(stageX + ui(30), stageY + ui(70), stageX + stageW - ui(30), stageY + ui(68));
|
||
stage.lineBetween(stageX + ui(30), stageY + stageH - ui(52), stageX + stageW - ui(30), stageY + stageH - ui(46));
|
||
stage.lineStyle(ui(1), cutsceneUiColors.woodLight, 0.16);
|
||
for (let index = 0; index < 12; index += 1) {
|
||
const y = stageY + ui(28) + index * ui(24);
|
||
stage.lineBetween(stageX + ui(28), y, stageX + stageW - ui(28), y + ui(index % 2 === 0 ? 4 : -3));
|
||
}
|
||
stage.fillStyle(0x0a0504, 0.38);
|
||
stage.fillCircle(stageX + ui(28), stageY + ui(28), ui(4));
|
||
stage.fillCircle(stageX + stageW - ui(28), stageY + ui(28), ui(4));
|
||
stage.fillCircle(stageX + ui(28), stageY + stageH - ui(28), ui(4));
|
||
stage.fillCircle(stageX + stageW - ui(28), stageY + stageH - ui(28), ui(4));
|
||
|
||
const foldingScreen = this.add.graphics();
|
||
foldingScreen.lineStyle(ui(1), 0xd0b16e, 0.1);
|
||
foldingScreen.fillStyle(0x342216, 0.22);
|
||
for (let index = 0; index < 5; index += 1) {
|
||
const panelX = stageX + ui(26) + index * ui(86);
|
||
foldingScreen.fillRoundedRect(panelX, stageY + ui(92), ui(66), stageH - ui(134), ui(4));
|
||
foldingScreen.strokeRoundedRect(panelX, stageY + ui(92), ui(66), stageH - ui(134), ui(4));
|
||
}
|
||
|
||
const tableShadow = this.add.ellipse(stageX + stageW * 0.62, stageY + stageH - ui(34), stageW * 0.6, ui(38), 0x030405, 0.34);
|
||
const titlePlate = this.add.graphics();
|
||
titlePlate.fillStyle(cutsceneUiColors.lacquer, 0.88);
|
||
titlePlate.fillRoundedRect(stageX + ui(22), stageY + ui(18), ui(366), ui(48), ui(8));
|
||
titlePlate.fillStyle(accent, 0.14);
|
||
titlePlate.fillRoundedRect(stageX + ui(30), stageY + ui(25), ui(34), ui(34), ui(5));
|
||
titlePlate.lineStyle(ui(1), accent, 0.3);
|
||
titlePlate.lineBetween(stageX + ui(74), stageY + ui(25), stageX + ui(374), stageY + ui(25));
|
||
titlePlate.lineBetween(stageX + ui(74), stageY + ui(60), stageX + ui(374), stageY + ui(60));
|
||
titlePlate.lineStyle(ui(1), 0xf0d08a, 0.12);
|
||
titlePlate.strokeRoundedRect(stageX + ui(22), stageY + ui(18), ui(366), ui(48), ui(8));
|
||
|
||
const title = this.add.text(stageX + ui(34), stageY + ui(18), cutscene.title, {
|
||
fontFamily: '"Malgun Gothic", "Noto Sans KR", serif',
|
||
fontSize: uiPx(26),
|
||
color: '#f2e3bf',
|
||
fontStyle: '700',
|
||
stroke: '#05070a',
|
||
strokeThickness: ui(4)
|
||
});
|
||
const subtitle = this.add.text(stageX + ui(36), stageY + ui(53), cutscene.subtitle ?? '', {
|
||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||
fontSize: uiPx(14),
|
||
color: '#cdbb94'
|
||
});
|
||
layer.add([stage, foldingScreen, tableShadow, titlePlate, title, subtitle]);
|
||
}
|
||
|
||
private renderTacticalBoard(layer: Phaser.GameObjects.Container, cutscene: StoryCutscene) {
|
||
const stage = this.cutsceneStageBounds();
|
||
const boardX = stage.x + Math.round(stage.width * 0.36);
|
||
const boardY = stage.y + ui(78);
|
||
const boardW = Math.round(stage.width * 0.58);
|
||
const boardH = stage.height - ui(118);
|
||
const board: CutsceneBoardBounds = { x: boardX, y: boardY, width: boardW, height: boardH };
|
||
const parchment = this.add.graphics();
|
||
parchment.fillStyle(cutsceneUiColors.veil, 0.34);
|
||
parchment.fillEllipse(boardX + boardW * 0.52, boardY + boardH + ui(12), boardW * 0.9, ui(34));
|
||
parchment.fillStyle(cutsceneUiColors.woodDark, 0.92);
|
||
parchment.fillRoundedRect(boardX + ui(10), boardY - ui(10), boardW - ui(20), ui(20), ui(10));
|
||
parchment.fillRoundedRect(boardX + ui(8), boardY + boardH - ui(10), boardW - ui(16), ui(20), ui(10));
|
||
parchment.fillStyle(cutsceneUiColors.woodLight, 0.35);
|
||
parchment.fillRoundedRect(boardX + ui(22), boardY - ui(5), boardW - ui(44), ui(5), ui(3));
|
||
parchment.fillRoundedRect(boardX + ui(22), boardY + boardH - ui(5), boardW - ui(44), ui(5), ui(3));
|
||
parchment.fillStyle(cutsceneUiColors.parchmentDark, 0.94);
|
||
parchment.fillRoundedRect(boardX + ui(3), boardY + ui(4), boardW - ui(6), boardH - ui(8), ui(5));
|
||
parchment.fillStyle(cutsceneUiColors.parchment, 0.92);
|
||
parchment.fillRoundedRect(boardX + ui(10), boardY + ui(12), boardW - ui(20), boardH - ui(24), ui(4));
|
||
parchment.fillStyle(cutsceneUiColors.parchmentLight, 0.08);
|
||
parchment.fillEllipse(boardX + boardW * 0.5, boardY + boardH * 0.48, boardW * 0.82, boardH * 0.66);
|
||
parchment.fillStyle(cutsceneUiColors.paperEdge, 0.12);
|
||
parchment.fillRoundedRect(boardX + ui(10), boardY + ui(12), boardW - ui(20), ui(10), ui(4));
|
||
parchment.fillRoundedRect(boardX + ui(10), boardY + boardH - ui(22), boardW - ui(20), ui(10), ui(4));
|
||
parchment.fillStyle(0x23140c, 0.1);
|
||
parchment.fillEllipse(boardX + boardW * 0.5, boardY + boardH * 0.62, boardW * 0.72, boardH * 0.34);
|
||
parchment.lineStyle(ui(1), cutsceneUiColors.mapInk, 0.24);
|
||
parchment.strokeRoundedRect(boardX + ui(8), boardY + ui(10), boardW - ui(16), boardH - ui(20), ui(5));
|
||
parchment.lineStyle(ui(1), 0xf0d08a, 0.1);
|
||
parchment.lineBetween(boardX + ui(24), boardY + ui(20), boardX + boardW - ui(28), boardY + ui(16));
|
||
parchment.lineBetween(boardX + ui(26), boardY + boardH - ui(20), boardX + boardW - ui(24), boardY + boardH - ui(24));
|
||
parchment.fillStyle(0x3d2413, 0.16);
|
||
parchment.beginPath();
|
||
parchment.moveTo(boardX + boardW - ui(54), boardY + ui(12));
|
||
parchment.lineTo(boardX + boardW - ui(12), boardY + ui(12));
|
||
parchment.lineTo(boardX + boardW - ui(12), boardY + ui(52));
|
||
parchment.closePath();
|
||
parchment.fillPath();
|
||
parchment.lineStyle(ui(1), 0x3d2413, 0.1);
|
||
for (let index = 0; index < 11; index += 1) {
|
||
const y = boardY + ui(38) + index * ui(14);
|
||
const xOffset = ui(index % 3 === 0 ? 26 : index % 3 === 1 ? 38 : 31);
|
||
parchment.lineBetween(boardX + xOffset, y, boardX + boardW - ui(30), y + ui(index % 2 === 0 ? 2 : -2));
|
||
}
|
||
for (let index = 0; index < 30; index += 1) {
|
||
const x = boardX + ui(30) + ((index * ui(47)) % Math.max(ui(60), boardW - ui(62)));
|
||
const y = boardY + ui(34) + ((index * ui(29)) % Math.max(ui(60), boardH - ui(66)));
|
||
parchment.fillStyle(index % 2 === 0 ? 0x4f3219 : 0xd2b172, 0.1);
|
||
parchment.fillCircle(x, y, ui(index % 3 === 0 ? 1.4 : 0.9));
|
||
}
|
||
layer.add(parchment);
|
||
|
||
const map = this.add.graphics();
|
||
map.lineStyle(ui(1), cutsceneUiColors.mapInk, 0.08);
|
||
for (let index = 1; index < 7; index += 1) {
|
||
const x = boardX + (boardW / 7) * index;
|
||
map.lineBetween(x, boardY + ui(24), x, boardY + boardH - ui(24));
|
||
}
|
||
for (let index = 1; index < 4; index += 1) {
|
||
const y = boardY + (boardH / 4) * index;
|
||
map.lineBetween(boardX + ui(24), y, boardX + boardW - ui(24), y);
|
||
}
|
||
map.fillStyle(0x365238, 0.18);
|
||
map.fillEllipse(boardX + boardW * 0.35, boardY + boardH * 0.58, boardW * 0.42, boardH * 0.46);
|
||
map.fillStyle(0x36465b, 0.14);
|
||
map.fillEllipse(boardX + boardW * 0.72, boardY + boardH * 0.36, boardW * 0.28, boardH * 0.28);
|
||
map.lineStyle(ui(8), 0x6b4826, 0.18);
|
||
map.beginPath();
|
||
map.moveTo(boardX + boardW * 0.16, boardY + boardH * 0.74);
|
||
map.lineTo(boardX + boardW * 0.38, boardY + boardH * 0.54);
|
||
map.lineTo(boardX + boardW * 0.66, boardY + boardH * 0.36);
|
||
map.lineTo(boardX + boardW * 0.83, boardY + boardH * 0.24);
|
||
map.strokePath();
|
||
map.lineStyle(ui(2), cutsceneUiColors.mapInk, 0.22);
|
||
map.beginPath();
|
||
map.moveTo(boardX + boardW * 0.16, boardY + boardH * 0.74);
|
||
map.lineTo(boardX + boardW * 0.38, boardY + boardH * 0.54);
|
||
map.lineTo(boardX + boardW * 0.66, boardY + boardH * 0.36);
|
||
map.lineTo(boardX + boardW * 0.83, boardY + boardH * 0.24);
|
||
map.strokePath();
|
||
map.lineStyle(ui(1), cutsceneUiColors.mapInk, 0.18);
|
||
map.strokeRoundedRect(boardX + ui(26), boardY + ui(28), boardW - ui(52), boardH - ui(56), ui(4));
|
||
layer.add(map);
|
||
|
||
this.renderBoardLegend(layer, cutscene, board);
|
||
(cutscene.markers ?? []).forEach((marker) => this.renderOperationMarker(layer, marker, boardX, boardY, boardW, boardH));
|
||
this.renderCutsceneUnitTokens(layer, cutscene, board);
|
||
|
||
return board;
|
||
}
|
||
|
||
private renderBoardLegend(layer: Phaser.GameObjects.Container, cutscene: StoryCutscene, board: CutsceneBoardBounds) {
|
||
const label = cutscene.kind === 'victory' ? 'RESULT MAP' : cutscene.kind === 'operation' ? 'TACTICAL MAP' : 'SORTIE BOARD';
|
||
const bg = this.add.graphics();
|
||
bg.fillStyle(cutsceneUiColors.mapInk, 0.18);
|
||
bg.fillRoundedRect(board.x + ui(24), board.y + ui(24), ui(126), ui(22), ui(4));
|
||
bg.lineStyle(ui(1), cutsceneUiColors.mapInk, 0.18);
|
||
bg.lineBetween(board.x + ui(32), board.y + ui(43), board.x + ui(140), board.y + ui(43));
|
||
bg.fillStyle(cutsceneUiColors.sealRed, 0.22);
|
||
bg.fillCircle(board.x + ui(134), board.y + ui(35), ui(8));
|
||
const text = this.add.text(board.x + ui(34), board.y + ui(28), label, {
|
||
fontFamily: '"Segoe UI", sans-serif',
|
||
fontSize: uiPx(12),
|
||
color: '#ead6aa',
|
||
fontStyle: '700'
|
||
});
|
||
layer.add([bg, text]);
|
||
}
|
||
|
||
private renderCutsceneActorCards(
|
||
layer: Phaser.GameObjects.Container,
|
||
cutscene: StoryCutscene,
|
||
board: CutsceneBoardBounds
|
||
) {
|
||
const stage = this.cutsceneStageBounds();
|
||
const actors = (cutscene.actors ?? []).filter((actor) => actor.unitId !== 'rebel-leader').slice(0, 3);
|
||
const cardX = stage.x + ui(28);
|
||
const cardW = Math.max(ui(250), board.x - cardX - ui(24));
|
||
const cardH = ui(actors.length >= 4 ? 60 : 70);
|
||
const firstY = stage.y + ui(86);
|
||
|
||
actors.forEach((actor, index) => {
|
||
const y = firstY + index * (cardH + ui(10));
|
||
this.renderCutsceneActorCard(layer, actor, cardX, y, cardW, cardH, index);
|
||
});
|
||
|
||
if ((cutscene.briefing?.lines ?? []).length > 0) {
|
||
const briefingY = firstY + actors.length * (cardH + ui(10)) + ui(4);
|
||
const maxH = stage.y + stage.height - ui(36) - briefingY;
|
||
if (maxH >= ui(70)) {
|
||
this.renderBriefingCard(layer, cutscene, cardX, briefingY, cardW, Math.min(ui(126), maxH));
|
||
}
|
||
}
|
||
}
|
||
|
||
private renderCutsceneActorCard(
|
||
layer: Phaser.GameObjects.Container,
|
||
actor: StoryCutsceneActor,
|
||
x: number,
|
||
y: number,
|
||
width: number,
|
||
height: number,
|
||
index: number
|
||
) {
|
||
const profile = storyCutsceneActorProfileFor(actor.unitId);
|
||
if (!profile) {
|
||
return;
|
||
}
|
||
|
||
const card = this.add.graphics();
|
||
card.fillStyle(cutsceneUiColors.veil, 0.34);
|
||
card.fillRoundedRect(x + ui(5), y + ui(7), width, height, ui(8));
|
||
card.fillStyle(cutsceneUiColors.woodDark, 0.94);
|
||
card.fillRoundedRect(x, y, width, height, ui(8));
|
||
card.fillStyle(cutsceneUiColors.lacquerLight, 0.46);
|
||
card.fillRoundedRect(x + ui(7), y + ui(7), width - ui(14), height - ui(14), ui(6));
|
||
card.fillStyle(cutsceneUiColors.lacquer, 0.42);
|
||
card.fillRoundedRect(x + ui(72), y + ui(9), width - ui(88), height - ui(18), ui(5));
|
||
card.fillStyle(profile.color, 0.42);
|
||
card.fillRoundedRect(x + ui(11), y + ui(10), ui(4), height - ui(20), ui(2));
|
||
card.fillStyle(cutsceneUiColors.paperEdge, 0.16);
|
||
card.fillRoundedRect(x + height + ui(4), y + ui(9), Math.min(ui(130), width - height - ui(52)), ui(24), ui(4));
|
||
card.lineStyle(ui(1), 0xf0d08a, 0.14);
|
||
card.lineBetween(x + ui(22), y + ui(9), x + width - ui(24), y + ui(9));
|
||
card.lineBetween(x + ui(22), y + height - ui(9), x + width - ui(24), y + height - ui(9));
|
||
card.lineStyle(ui(1), profile.color, 0.24);
|
||
card.lineBetween(x + ui(18), y + ui(15), x + ui(18), y + height - ui(15));
|
||
card.fillStyle(profile.color, 0.18);
|
||
card.fillCircle(x + width - ui(24), y + height / 2, ui(12));
|
||
card.fillStyle(cutsceneUiColors.sealRed, 0.18);
|
||
card.fillCircle(x + width - ui(24), y + height / 2, ui(7));
|
||
layer.add(card);
|
||
|
||
const portraitTexture = this.actorPortraitTextureKey(actor, index);
|
||
const portraitX = x + ui(38);
|
||
const portraitY = y + height / 2;
|
||
const portraitFrame = this.add.graphics();
|
||
portraitFrame.fillStyle(cutsceneUiColors.ink, 0.92);
|
||
portraitFrame.fillRoundedRect(portraitX - (height - ui(14)) / 2, portraitY - (height - ui(14)) / 2, height - ui(14), height - ui(14), ui(5));
|
||
portraitFrame.lineStyle(ui(1), profile.color, 0.34);
|
||
portraitFrame.strokeRoundedRect(portraitX - (height - ui(14)) / 2, portraitY - (height - ui(14)) / 2, height - ui(14), height - ui(14), ui(5));
|
||
portraitFrame.lineStyle(ui(1), 0xf0d08a, 0.1);
|
||
portraitFrame.strokeRoundedRect(portraitX - (height - ui(24)) / 2, portraitY - (height - ui(24)) / 2, height - ui(24), height - ui(24), ui(3));
|
||
layer.add(portraitFrame);
|
||
|
||
if (portraitTexture) {
|
||
const portrait = this.add.image(portraitX, portraitY, portraitTexture);
|
||
portrait.setDisplaySize(height - ui(18), height - ui(18));
|
||
layer.add(portrait);
|
||
} else if (this.textures.exists(profile.unitTextureKey)) {
|
||
const sprite = this.add.sprite(portraitX, portraitY + ui(3), profile.unitTextureKey, this.storyUnitFrameIndex(actor.direction ?? 'south'));
|
||
sprite.setDisplaySize(height - ui(8), height - ui(8));
|
||
layer.add(sprite);
|
||
}
|
||
|
||
const name = this.add.text(x + height + ui(14), y + ui(11), actor.label ?? profile.name, {
|
||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||
fontSize: uiPx(16),
|
||
color: '#f5e6c8',
|
||
fontStyle: '700'
|
||
});
|
||
const roleBg = this.add.graphics();
|
||
roleBg.lineStyle(ui(1), profile.color, 0.26);
|
||
roleBg.lineBetween(x + height + ui(14), y + ui(38), x + height + Math.min(ui(128), width - height - ui(34)), y + ui(38));
|
||
const role = this.add.text(x + height + ui(14), y + ui(42), profile.roleLabel, {
|
||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||
fontSize: uiPx(12),
|
||
color: '#ddc99f'
|
||
});
|
||
layer.add([roleBg, name, role]);
|
||
|
||
if (actor.line && height >= ui(66)) {
|
||
const line = this.add.text(x + ui(144), y + ui(50), actor.line, {
|
||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||
fontSize: uiPx(11),
|
||
color: '#d8cbb2',
|
||
wordWrap: { width: width - ui(166), useAdvancedWrap: true }
|
||
});
|
||
layer.add(line);
|
||
}
|
||
}
|
||
|
||
private renderCutsceneUnitTokens(layer: Phaser.GameObjects.Container, cutscene: StoryCutscene, board: CutsceneBoardBounds) {
|
||
const actors = cutscene.actors ?? [];
|
||
actors.forEach((actor, index) => {
|
||
const profile = storyCutsceneActorProfileFor(actor.unitId);
|
||
if (!profile) {
|
||
return;
|
||
}
|
||
|
||
const normalizedX = actor.x;
|
||
const normalizedY = actor.y;
|
||
const x = board.x + board.width * Phaser.Math.Clamp(normalizedX, 0.12, 0.88);
|
||
const y = board.y + board.height * Phaser.Math.Clamp(normalizedY, 0.18, 0.82);
|
||
const sideColor = actor.unitId === 'rebel-leader' ? 0xd46a4c : profile.color;
|
||
const token = this.add.container(x, y);
|
||
const shadow = this.add.ellipse(ui(2), ui(12), ui(38), ui(12), 0x05070a, 0.28);
|
||
const base = this.add.ellipse(0, ui(6), ui(30), ui(18), cutsceneUiColors.woodDark, 0.78);
|
||
base.setStrokeStyle(ui(1), sideColor, 0.46);
|
||
const pin = this.add.circle(0, ui(6), ui(3), sideColor, 0.86);
|
||
pin.setStrokeStyle(ui(1), 0x05070a, 0.62);
|
||
token.add([shadow, base, pin]);
|
||
|
||
if (this.textures.exists(profile.unitTextureKey)) {
|
||
const sprite = this.add.sprite(0, -ui(8), profile.unitTextureKey, this.storyUnitFrameIndex(actor.direction ?? 'south'));
|
||
sprite.setDisplaySize(ui(34), ui(34));
|
||
token.add(sprite);
|
||
} else {
|
||
const fallback = this.add.rectangle(0, -ui(8), ui(22), ui(24), sideColor, 0.9);
|
||
fallback.setStrokeStyle(ui(1), 0x05070a, 0.82);
|
||
token.add(fallback);
|
||
}
|
||
|
||
const label = this.add.text(0, ui(14), actor.label ?? profile.name, {
|
||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||
fontSize: uiPx(9),
|
||
color: '#ead9b8',
|
||
stroke: '#05070a',
|
||
strokeThickness: ui(2)
|
||
});
|
||
label.setOrigin(0.5, 0);
|
||
token.add(label);
|
||
layer.add(token);
|
||
});
|
||
}
|
||
|
||
private renderBriefingCard(layer: Phaser.GameObjects.Container, cutscene: StoryCutscene, x: number, y: number, width: number, height: number) {
|
||
const briefingLines = cutscene.briefing?.lines ?? [];
|
||
if (!cutscene.briefing || briefingLines.length === 0) {
|
||
return;
|
||
}
|
||
|
||
const bg = this.add.graphics();
|
||
bg.fillStyle(cutsceneUiColors.veil, 0.28);
|
||
bg.fillRoundedRect(x + ui(4), y + ui(5), width, height, ui(8));
|
||
bg.fillStyle(cutsceneUiColors.lacquer, 0.68);
|
||
bg.fillRoundedRect(x, y, width, height, ui(8));
|
||
bg.fillStyle(cutsceneUiColors.paperEdge, 0.08);
|
||
bg.fillRoundedRect(x + ui(10), y + ui(10), width - ui(20), height - ui(20), ui(6));
|
||
bg.lineStyle(ui(1), palette.gold, 0.18);
|
||
bg.lineBetween(x + ui(16), y + ui(13), x + width - ui(16), y + ui(13));
|
||
bg.lineBetween(x + ui(16), y + height - ui(13), x + width - ui(16), y + height - ui(13));
|
||
const title = this.add.text(x + ui(18), y + ui(16), cutscene.briefing?.title ?? '전황', {
|
||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||
fontSize: uiPx(18),
|
||
color: '#d8b15f',
|
||
fontStyle: '700'
|
||
});
|
||
layer.add([bg, title]);
|
||
|
||
briefingLines.slice(0, 4).forEach((line, index) => {
|
||
const bullet = this.add.circle(x + ui(24), y + ui(54) + index * ui(30), ui(3), palette.gold, 0.72);
|
||
const text = this.add.text(x + ui(38), y + ui(43) + index * ui(30), line, {
|
||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||
fontSize: uiPx(14),
|
||
color: '#d8cbb2',
|
||
wordWrap: { width: width - ui(58), useAdvancedWrap: true }
|
||
});
|
||
layer.add([bullet, text]);
|
||
});
|
||
}
|
||
|
||
private renderOperationBoard(layer: Phaser.GameObjects.Container, cutscene: StoryCutscene) {
|
||
const boardX = this.scale.width - ui(560);
|
||
const boardY = ui(128);
|
||
const boardW = ui(474);
|
||
const boardH = ui(246);
|
||
const board = this.add.rectangle(boardX, boardY, boardW, boardH, 0x0b1218, 0.92).setOrigin(0);
|
||
board.setStrokeStyle(ui(1), palette.blue, 0.5);
|
||
layer.add(board);
|
||
|
||
const grid = this.add.graphics();
|
||
grid.lineStyle(ui(1), 0x53606c, 0.26);
|
||
for (let i = 1; i < 6; i += 1) {
|
||
const x = boardX + (boardW / 6) * i;
|
||
grid.lineBetween(x, boardY + ui(16), x, boardY + boardH - ui(16));
|
||
}
|
||
for (let i = 1; i < 4; i += 1) {
|
||
const y = boardY + (boardH / 4) * i;
|
||
grid.lineBetween(boardX + ui(16), y, boardX + boardW - ui(16), y);
|
||
}
|
||
grid.lineStyle(ui(3), palette.gold, 0.36);
|
||
grid.beginPath();
|
||
grid.moveTo(boardX + boardW * 0.2, boardY + boardH * 0.74);
|
||
grid.lineTo(boardX + boardW * 0.44, boardY + boardH * 0.5);
|
||
grid.lineTo(boardX + boardW * 0.74, boardY + boardH * 0.28);
|
||
grid.strokePath();
|
||
layer.add(grid);
|
||
|
||
this.renderBriefingCard(layer, cutscene, ui(82), ui(184), ui(364), ui(138));
|
||
|
||
(cutscene.markers ?? []).forEach((marker) => this.renderOperationMarker(layer, marker, boardX, boardY, boardW, boardH));
|
||
}
|
||
|
||
private renderOperationMarker(
|
||
layer: Phaser.GameObjects.Container,
|
||
marker: StoryCutsceneMarker,
|
||
boardX: number,
|
||
boardY: number,
|
||
boardW: number,
|
||
boardH: number
|
||
) {
|
||
const color = marker.side === 'ally' ? palette.blue : marker.side === 'enemy' ? 0xd46a4c : palette.gold;
|
||
const x = boardX + boardW * marker.x;
|
||
const y = boardY + boardH * marker.y;
|
||
const halo = this.add.circle(x, y, ui(9), color, 0.1);
|
||
const dot = this.add.circle(x, y, ui(4), color, 0.86);
|
||
dot.setStrokeStyle(ui(1), cutsceneUiColors.mapInk, 0.72);
|
||
const stem = this.add.rectangle(x - ui(0.5), y + ui(4), ui(1), ui(13), color, 0.42).setOrigin(0, 0);
|
||
const labelX = marker.side === 'objective' ? x - ui(8) : x + ui(11);
|
||
const labelY = marker.side === 'objective' ? y - ui(27) : y - ui(10);
|
||
const label = this.add.text(labelX, labelY, marker.label, {
|
||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||
fontSize: uiPx(11),
|
||
color: '#ead9b8',
|
||
stroke: '#05070a',
|
||
strokeThickness: ui(2)
|
||
});
|
||
if (marker.side === 'objective') {
|
||
label.setOrigin(0.5, 0);
|
||
}
|
||
layer.add([halo, stem, dot, label]);
|
||
}
|
||
|
||
private renderCutsceneActor(layer: Phaser.GameObjects.Container, actor: StoryCutsceneActor, index: number) {
|
||
const profile = storyCutsceneActorProfileFor(actor.unitId);
|
||
if (!profile) {
|
||
return;
|
||
}
|
||
|
||
const x = this.scale.width * actor.x;
|
||
const y = this.scale.height * actor.y;
|
||
const size = ui(actor.size ?? 126);
|
||
const direction = actor.direction ?? 'south';
|
||
const shadow = this.add.ellipse(x, y + size * 0.36, size * 0.46, size * 0.14, 0x05070a, 0.56);
|
||
layer.add(shadow);
|
||
|
||
if (this.textures.exists(profile.unitTextureKey)) {
|
||
const sprite = this.add.sprite(x, y, profile.unitTextureKey, this.storyUnitFrameIndex(direction));
|
||
sprite.setDisplaySize(size, size);
|
||
sprite.setBlendMode(Phaser.BlendModes.NORMAL);
|
||
layer.add(sprite);
|
||
this.tweens.add({
|
||
targets: sprite,
|
||
y: y - ui(4),
|
||
duration: 1200 + index * 90,
|
||
ease: 'Sine.easeInOut',
|
||
yoyo: true,
|
||
repeat: -1,
|
||
delay: index * 110
|
||
});
|
||
} else {
|
||
const fallback = this.add.rectangle(x, y, size * 0.56, size * 0.86, profile.color, 0.88);
|
||
fallback.setStrokeStyle(ui(2), 0x05070a, 0.8);
|
||
layer.add(fallback);
|
||
}
|
||
|
||
const labelWidth = Math.max(ui(78), Math.min(ui(132), size * 0.86));
|
||
const labelBg = this.add.rectangle(x, y + size * 0.54, labelWidth, ui(28), 0x070b10, 0.9);
|
||
labelBg.setStrokeStyle(ui(1), profile.color, 0.64);
|
||
const label = this.add.text(x, y + size * 0.54 - ui(7), actor.label ?? profile.name, {
|
||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||
fontSize: uiPx(13),
|
||
color: '#f2e3bf',
|
||
fontStyle: '700'
|
||
});
|
||
label.setOrigin(0.5, 0);
|
||
layer.add([labelBg, label]);
|
||
|
||
if (actor.line) {
|
||
const line = this.add.text(x, y - size * 0.58, actor.line, {
|
||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||
fontSize: uiPx(12),
|
||
color: '#e8dfca',
|
||
align: 'center',
|
||
wordWrap: { width: ui(160), useAdvancedWrap: true },
|
||
stroke: '#05070a',
|
||
strokeThickness: ui(3)
|
||
});
|
||
line.setOrigin(0.5, 1);
|
||
layer.add(line);
|
||
}
|
||
}
|
||
|
||
private renderCutsceneRewards(layer: Phaser.GameObjects.Container, model: StoryRewardDisplayModel) {
|
||
if (model.items.length === 0) {
|
||
return;
|
||
}
|
||
|
||
const stage = this.cutsceneStageBounds();
|
||
const visibleItems = model.items.slice(0, 4);
|
||
const maxItems = visibleItems.length;
|
||
const stripX = stage.x + ui(28);
|
||
const stripY = stage.y + stage.height - ui(62);
|
||
const stripW = stage.width - ui(56);
|
||
const gap = ui(8);
|
||
const itemW = (stripW - gap * (maxItems - 1)) / maxItems;
|
||
const itemH = ui(54);
|
||
const bg = this.add.graphics();
|
||
bg.fillStyle(cutsceneUiColors.veil, 0.42);
|
||
bg.fillRoundedRect(stripX - ui(8), stripY - ui(7), stripW + ui(16), itemH + ui(14), ui(9));
|
||
bg.lineStyle(ui(1), palette.gold, 0.18);
|
||
bg.strokeRoundedRect(stripX - ui(8), stripY - ui(7), stripW + ui(16), itemH + ui(14), ui(9));
|
||
layer.add(bg);
|
||
|
||
visibleItems.forEach((reward, index) => {
|
||
const toneColor = reward.tone === 'next' ? palette.blue : reward.tone === 'bond' ? palette.green : palette.gold;
|
||
const card = this.add.container(stripX + index * (itemW + gap), stripY + ui(6));
|
||
const item = this.add.graphics();
|
||
item.fillStyle(cutsceneUiColors.veil, 0.5);
|
||
item.fillRoundedRect(ui(3), ui(4), itemW, itemH, ui(7));
|
||
item.fillStyle(cutsceneUiColors.lacquer, 0.94);
|
||
item.fillRoundedRect(0, 0, itemW, itemH, ui(7));
|
||
item.fillStyle(toneColor, 0.12);
|
||
item.fillRoundedRect(ui(5), ui(5), itemW - ui(10), itemH - ui(10), ui(5));
|
||
item.fillStyle(toneColor, 0.2);
|
||
item.fillRoundedRect(ui(7), ui(7), ui(42), itemH - ui(14), ui(5));
|
||
item.lineStyle(ui(1), toneColor, 0.42);
|
||
item.strokeRoundedRect(0, 0, itemW, itemH, ui(7));
|
||
item.lineStyle(ui(1), 0xf0d08a, 0.12);
|
||
item.lineBetween(ui(58), ui(29), itemW - ui(12), ui(29));
|
||
|
||
const icon = this.renderRewardIcon(reward.icon, ui(28), itemH / 2, ui(31), toneColor);
|
||
const eyebrow = this.add.text(ui(60), ui(8), reward.eyebrow, {
|
||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||
fontSize: uiPx(11),
|
||
color: reward.tone === 'next' ? '#9fc3e8' : reward.tone === 'bond' ? '#a9ddb9' : '#d8b15f',
|
||
fontStyle: '700'
|
||
});
|
||
const text = this.add.text(ui(60), ui(31), reward.label, {
|
||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||
fontSize: uiPx(14),
|
||
color: '#f2e3bf',
|
||
fontStyle: '700',
|
||
wordWrap: { width: itemW - ui(74), useAdvancedWrap: true }
|
||
});
|
||
text.setOrigin(0, 0.5);
|
||
card.add([item, icon, eyebrow, text]);
|
||
card.setAlpha(0);
|
||
layer.add(card);
|
||
this.tweens.add({
|
||
targets: card,
|
||
y: stripY,
|
||
alpha: 1,
|
||
duration: 220,
|
||
delay: 60 + index * 70,
|
||
ease: 'Cubic.easeOut'
|
||
});
|
||
});
|
||
}
|
||
|
||
private rewardDisplayForPage(page: StoryPage): StoryRewardDisplayModel {
|
||
const fallback = this.fallbackRewardDisplay(page);
|
||
if (!firstBattleVictoryPageIds.has(page.id)) {
|
||
return fallback;
|
||
}
|
||
|
||
const report = getFirstBattleReport();
|
||
if (!report || report.battleId !== firstBattleId || report.outcome !== 'victory') {
|
||
return fallback;
|
||
}
|
||
|
||
const rewards = report.campaignRewards;
|
||
const presentation = campaignVictoryRewardPresentation(report);
|
||
const categorizedRewardItems = new Set([
|
||
...(rewards?.supplies ?? []),
|
||
...(rewards?.equipment ?? []),
|
||
...(rewards?.reputation ?? [])
|
||
]);
|
||
const legacyExtraItems = report.itemRewards.filter((item) => !categorizedRewardItems.has(item));
|
||
const supplyItems = rewards
|
||
? [
|
||
...rewards.supplies,
|
||
...legacyExtraItems,
|
||
...(presentation.sortieOrderBonus?.rewardItems ?? [])
|
||
]
|
||
: [...presentation.itemRewards];
|
||
if (!rewards && page.id !== 'first-victory-village') {
|
||
return fallback;
|
||
}
|
||
|
||
let items: StoryRewardDisplayItem[] = [];
|
||
if (page.id === 'first-victory-village') {
|
||
items = [this.goldRewardItem(presentation.rewardGold), ...supplyItems.map((label, index) => this.supplyRewardItem(label, index))];
|
||
} else if (page.id === 'first-victory-liu-bei' && rewards) {
|
||
items = [
|
||
...rewards.equipment.map((label, index) => this.equipmentRewardItem(label, index)),
|
||
...rewards.reputation.map((label, index) => this.reputationRewardItem(label, index))
|
||
];
|
||
} else if (page.id === 'first-victory-guan-yu' && rewards) {
|
||
items = rewards.recruits
|
||
.filter((recruit) => recruit.unitId === 'guan-yu')
|
||
.map((recruit) => this.recruitRewardItem(recruit.unitId, recruit.name));
|
||
} else if (page.id === 'first-victory-zhang-fei' && rewards) {
|
||
items = rewards.recruits
|
||
.filter((recruit) => recruit.unitId !== 'guan-yu')
|
||
.map((recruit) => this.recruitRewardItem(recruit.unitId, recruit.name));
|
||
} else if (page.id === 'first-victory-next' && rewards) {
|
||
items = rewards.unlocks.map((unlock) => this.unlockRewardItem(unlock.battleId, unlock.title));
|
||
}
|
||
|
||
return {
|
||
pageId: page.id,
|
||
source: 'campaign',
|
||
battleId: report.battleId,
|
||
rewardGold: presentation.rewardGold,
|
||
items
|
||
};
|
||
}
|
||
|
||
private fallbackRewardDisplay(page: StoryPage): StoryRewardDisplayModel {
|
||
const rewards = page.cutscene?.rewards ?? [];
|
||
return {
|
||
pageId: page.id,
|
||
source: rewards.length > 0 ? 'fallback' : 'none',
|
||
battleId: null,
|
||
rewardGold: null,
|
||
items: rewards.map((reward, index) => ({
|
||
id: `fallback-${index}`,
|
||
category: 'fallback',
|
||
eyebrow: reward.tone === 'next' ? '다음 목표' : reward.tone === 'bond' ? '전과' : '획득 정보',
|
||
label: reward.label,
|
||
tone: reward.tone,
|
||
icon: { kind: 'mark', fallbackMark: reward.tone === 'next' ? '路' : reward.tone === 'bond' ? '功' : '賞' }
|
||
}))
|
||
};
|
||
}
|
||
|
||
private emptyRewardDisplay(): StoryRewardDisplayModel {
|
||
return { pageId: null, source: 'none', battleId: null, rewardGold: null, items: [] };
|
||
}
|
||
|
||
private goldRewardItem(amount: number): StoryRewardDisplayItem {
|
||
return {
|
||
id: 'gold',
|
||
category: 'gold',
|
||
eyebrow: '군자금',
|
||
label: `+${Math.max(0, amount).toLocaleString('ko-KR')}`,
|
||
tone: 'reward',
|
||
icon: { kind: 'mark', fallbackMark: '金' }
|
||
};
|
||
}
|
||
|
||
private supplyRewardItem(label: string, index: number): StoryRewardDisplayItem {
|
||
const iconKey: BattleUiIconKey = /탁주/.test(label) ? 'wine' : /상처약/.test(label) ? 'salve' : 'bean';
|
||
return {
|
||
id: `supply-${index}-${label}`,
|
||
category: 'supply',
|
||
eyebrow: '보급품',
|
||
label,
|
||
tone: 'reward',
|
||
icon: { kind: 'battle-ui', iconKey, fallbackMark: /탁주/.test(label) ? '酒' : /상처약/.test(label) ? '藥' : '糧' }
|
||
};
|
||
}
|
||
|
||
private equipmentRewardItem(label: string, index: number): StoryRewardDisplayItem {
|
||
const inventoryLabel = label.replace(/\s+(?:[x×]\s*)?\d+\s*$/u, '').trim();
|
||
const itemId = equipmentItemIdForInventoryLabel(inventoryLabel);
|
||
return {
|
||
id: `equipment-${index}-${label}`,
|
||
category: 'equipment',
|
||
eyebrow: '장비 획득',
|
||
label,
|
||
tone: 'reward',
|
||
icon: itemId
|
||
? { kind: 'item', textureKey: `item-${itemId}`, fallbackMark: '具' }
|
||
: { kind: 'battle-ui', iconKey: 'sword', fallbackMark: '具' }
|
||
};
|
||
}
|
||
|
||
private reputationRewardItem(label: string, index: number): StoryRewardDisplayItem {
|
||
return {
|
||
id: `reputation-${index}-${label}`,
|
||
category: 'reputation',
|
||
eyebrow: '명성 상승',
|
||
label,
|
||
tone: 'bond',
|
||
icon: { kind: 'mark', fallbackMark: '名' }
|
||
};
|
||
}
|
||
|
||
private recruitRewardItem(unitId: string, name: string): StoryRewardDisplayItem {
|
||
return {
|
||
id: `recruit-${unitId}`,
|
||
category: 'recruit',
|
||
eyebrow: '신규 합류',
|
||
label: `${name} 출전 가능`,
|
||
tone: 'bond',
|
||
icon: { kind: 'mark', fallbackMark: '將' }
|
||
};
|
||
}
|
||
|
||
private unlockRewardItem(battleId: string, title: string): StoryRewardDisplayItem {
|
||
return {
|
||
id: `unlock-${battleId}`,
|
||
category: 'unlock',
|
||
eyebrow: '전장 해금',
|
||
label: title,
|
||
tone: 'next',
|
||
icon: { kind: 'battle-ui', iconKey: 'success', fallbackMark: '開' }
|
||
};
|
||
}
|
||
|
||
private renderRewardIcon(icon: StoryRewardIcon, x: number, y: number, size: number, toneColor: number) {
|
||
if (icon.kind === 'item' && this.textures.exists(icon.textureKey)) {
|
||
const image = this.add.image(x, y, icon.textureKey);
|
||
image.setDisplaySize(size, size);
|
||
return image;
|
||
}
|
||
|
||
if (icon.kind === 'battle-ui') {
|
||
const textureKey = this.textures.exists(battleUiIconTextureKey)
|
||
? battleUiIconTextureKey
|
||
: this.textures.exists(battleUiIconMicroTextureKey)
|
||
? battleUiIconMicroTextureKey
|
||
: undefined;
|
||
if (textureKey) {
|
||
const image = this.add.image(x, y, textureKey, battleUiIconFrames[icon.iconKey]);
|
||
image.setDisplaySize(size, size);
|
||
return image;
|
||
}
|
||
}
|
||
|
||
const fallback = this.add.container(x, y);
|
||
const seal = this.add.graphics();
|
||
seal.fillStyle(cutsceneUiColors.ink, 0.9);
|
||
seal.fillCircle(0, 0, size * 0.48);
|
||
seal.lineStyle(ui(1), toneColor, 0.72);
|
||
seal.strokeCircle(0, 0, size * 0.46);
|
||
seal.lineStyle(ui(1), 0xf0d08a, 0.16);
|
||
seal.strokeCircle(0, 0, size * 0.35);
|
||
const mark = this.add.text(0, 0, icon.fallbackMark, {
|
||
fontFamily: '"Malgun Gothic", "Noto Sans KR", serif',
|
||
fontSize: uiPx(18),
|
||
color: '#f2e3bf',
|
||
fontStyle: '700'
|
||
});
|
||
mark.setOrigin(0.5);
|
||
fallback.add([seal, mark]);
|
||
return fallback;
|
||
}
|
||
|
||
private cutsceneStageBounds(): CutsceneStageBounds {
|
||
return {
|
||
x: ui(58),
|
||
y: ui(98),
|
||
width: this.scale.width - ui(116),
|
||
height: ui(348)
|
||
};
|
||
}
|
||
|
||
private actorPortraitTextureKey(actor: StoryCutsceneActor, index: number) {
|
||
const entry = this.actorPortraitAssetEntry(actor, index);
|
||
return entry && this.textures.exists(entry.textureKey) ? entry.textureKey : undefined;
|
||
}
|
||
|
||
private actorPortraitAssetEntry(actor: StoryCutsceneActor, index: number) {
|
||
const profile = storyCutsceneActorProfileFor(actor.unitId);
|
||
if (!profile) {
|
||
return undefined;
|
||
}
|
||
|
||
const entries = portraitAssetEntriesForKey(profile.portraitKey);
|
||
if (entries.length === 0) {
|
||
return undefined;
|
||
}
|
||
|
||
return entries[this.hashString(`${actor.unitId}-${index}`) % entries.length];
|
||
}
|
||
|
||
private storyUnitFrameIndex(direction: UnitDirection, frame = 0) {
|
||
return storyUnitFrameRows[direction] * unitBaseFramesPerDirection + frame;
|
||
}
|
||
|
||
private resolveBackgroundKey(textureKey: string) {
|
||
return this.textures.exists(textureKey) ? textureKey : 'story-fallback';
|
||
}
|
||
|
||
private hashString(value: string) {
|
||
let hash = 0;
|
||
for (let index = 0; index < value.length; index += 1) {
|
||
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
|
||
}
|
||
return hash;
|
||
}
|
||
|
||
private advance() {
|
||
if (this.transitioning) {
|
||
return;
|
||
}
|
||
|
||
if (this.pageIndex >= this.pages.length - 1) {
|
||
this.transitioning = true;
|
||
void startGameScene(this, this.nextScene, this.nextSceneData).catch((error) => {
|
||
console.error(`Failed to start ${this.nextScene}`, error);
|
||
this.transitioning = false;
|
||
});
|
||
return;
|
||
}
|
||
|
||
const targetPageIndex = this.pageIndex + 1;
|
||
this.transitioning = true;
|
||
this.ensureStoryPageAssets(
|
||
targetPageIndex,
|
||
() => {
|
||
if (!this.scene.isActive()) {
|
||
return;
|
||
}
|
||
this.pageIndex = targetPageIndex;
|
||
this.showPage(targetPageIndex);
|
||
},
|
||
true
|
||
);
|
||
}
|
||
|
||
private dialogueObjects() {
|
||
const objects: Array<AlphaObject | undefined> = [
|
||
this.chapterText,
|
||
this.portrait,
|
||
this.portraitFrame,
|
||
this.portraitDivider,
|
||
this.nameText,
|
||
this.bodyText,
|
||
this.progressBackground,
|
||
this.progressText,
|
||
this.cutsceneLayer
|
||
];
|
||
|
||
return objects.filter((object): object is AlphaObject => object !== undefined);
|
||
}
|
||
|
||
private setDialogueAlpha(alpha: number) {
|
||
this.dialogueObjects().forEach((object) => {
|
||
object.setAlpha(alpha);
|
||
});
|
||
}
|
||
}
|