perf: stream story assets by page

This commit is contained in:
2026-07-18 11:06:56 +09:00
parent af2f5dbf06
commit 1df324ecd2
6 changed files with 941 additions and 182 deletions

View File

@@ -63,6 +63,57 @@ export function hasUnitSheetAsset(key: string) {
return unitSheetAssetsByKey.has(key);
}
export function unitBaseSheetsReady(scene: Phaser.Scene, keys: Iterable<string>) {
return Array.from(keys).every((key) => scene.textures.exists(key));
}
export function waitForUnitBaseSheets(scene: Phaser.Scene, keys: Iterable<string>, onReady: () => void) {
waitForUnitBaseSheetsAttempt(scene, Array.from(new Set(keys)), onReady);
}
export function loadUnitBaseSheets(scene: Phaser.Scene, keys: Iterable<string>, onReady: () => void) {
const uniqueKeys = Array.from(new Set(keys));
if (unitBaseSheetsReady(scene, uniqueKeys)) {
onReady();
return;
}
const pendingLoads: Array<{ key: string; url: string }> = [];
uniqueKeys.forEach((key) => {
const sheet = unitSheetAssetsByKey.get(key) ?? missingUnitSheet(key);
if (!scene.textures.exists(sheet.key) && !pendingUnitTextureKeys.has(sheet.key)) {
pendingUnitTextureKeys.add(sheet.key);
pendingLoads.push({ key: sheet.key, url: sheet.url });
}
});
if (pendingLoads.length <= 0) {
waitForUnitBaseSheets(scene, uniqueKeys, onReady);
return;
}
const clearPendingLoads = () => {
pendingLoads.forEach(({ key }) => pendingUnitTextureKeys.delete(key));
};
scene.load.once('complete', () => {
clearPendingLoads();
const missing = uniqueKeys.filter((key) => !scene.textures.exists(key));
if (missing.length > 0) {
console.warn(`Failed to load unit base sheet textures: ${missing.join(', ')}`);
}
onReady();
});
scene.load.once('loaderror', clearPendingLoads);
pendingLoads.forEach(({ key, url }) => {
scene.load.spritesheet(key, url, {
frameWidth: unitSheetFrameSize,
frameHeight: unitSheetFrameSize
});
});
scene.load.start();
}
export function loadUnitSheets(scene: Phaser.Scene, keys: Iterable<string>, onReady: () => void) {
const uniqueKeys = Array.from(new Set(keys));
if (unitSheetsReady(scene, uniqueKeys)) {
@@ -110,6 +161,22 @@ function unitSheetsReady(scene: Phaser.Scene, keys: Iterable<string>) {
return Array.from(keys).every((key) => scene.textures.exists(key) && scene.textures.exists(`${key}-actions`));
}
function waitForUnitBaseSheetsAttempt(scene: Phaser.Scene, keys: string[], onReady: () => void, attempts = 0) {
if (unitBaseSheetsReady(scene, keys)) {
onReady();
return;
}
if (attempts > 200) {
const missing = keys.filter((key) => !scene.textures.exists(key));
console.warn(`Timed out waiting for unit base sheet textures: ${missing.join(', ')}`);
onReady();
return;
}
scene.time.delayedCall(50, () => waitForUnitBaseSheetsAttempt(scene, keys, onReady, attempts + 1));
}
function waitForUnitSheets(scene: Phaser.Scene, keys: string[], onReady: () => void, attempts = 0) {
if (unitSheetsReady(scene, keys)) {
onReady();

View File

@@ -4,7 +4,7 @@ import { portraitAssetEntriesForKey, portraitKeyForSpeaker, type PortraitAssetEn
import { storyBackgroundAssets, storyBackgroundKeysFor } from '../data/storyAssets';
import {
ensureUnitAnimations,
loadUnitSheets,
loadUnitBaseSheets,
unitBaseFramesPerDirection,
type UnitDirection
} from '../data/unitAssets';
@@ -28,6 +28,18 @@ type StorySceneData = {
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;
@@ -83,7 +95,14 @@ export class StoryScene extends Phaser.Scene {
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>();
constructor() {
super('StoryScene');
@@ -95,6 +114,12 @@ export class StoryScene extends Phaser.Scene {
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;
}
getDebugState() {
@@ -134,7 +159,22 @@ export class StoryScene extends Phaser.Scene {
advanceLabel: this.progressActionLabel(isLastPage),
nextScene: this.nextScene,
cutsceneKind: page?.cutscene?.kind ?? null,
cutsceneActors: page?.cutscene?.actors?.map((actor) => actor.unitId) ?? []
cutsceneActors: page?.cutscene?.actors?.map((actor) => actor.unitId) ?? [],
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`))
: []
}
};
}
@@ -151,7 +191,22 @@ export class StoryScene extends Phaser.Scene {
});
this.loadingText.setOrigin(0.5);
this.ensureStoryAssetsLoaded(() => {
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.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])));
@@ -163,57 +218,195 @@ export class StoryScene extends Phaser.Scene {
this.input.on('pointerdown', () => this.advance());
this.input.keyboard?.on('keydown-SPACE', () => this.advance());
this.input.keyboard?.on('keydown-ENTER', () => this.advance());
});
}, true);
}
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();
private ensureStoryPageAssets(pageIndex: number, onReady?: () => void, interactive = false) {
if (pageIndex < 0 || pageIndex >= this.pages.length) {
onReady?.();
return;
}
this.load.once('complete', finishLoading);
this.load.once('loaderror', (file: Phaser.Loader.File) => {
console.warn(`Failed to load story background ${file.key}`);
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 loadUnitBases = () => {
if (generation !== this.storyAssetLoadGeneration || plan.unitTextureKeys.length === 0) {
finishRequest();
return;
}
try {
loadUnitBaseSheets(this, plan.unitTextureKeys, finishRequest);
} 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));
finishRequest();
}
};
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;
});
loadableKeys.forEach((key) => this.load.image(key, storyBackgroundAssets[key]));
missingPortraits.forEach(({ textureKey, url }) => this.load.image(textureKey, url));
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);
return {
backgrounds: backgroundKey && backgroundUrl ? [{ key: backgroundKey, url: backgroundUrl }] : [],
portraits,
unitTextureKeys: Array.from(new Set(storyCutsceneActorTextureKeys(page.cutscene)))
};
}
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;
@@ -344,6 +537,7 @@ export class StoryScene extends Phaser.Scene {
if (immediate) {
this.applyPage(page);
this.prefetchNextStoryPage(index);
return;
}
@@ -361,7 +555,8 @@ export class StoryScene extends Phaser.Scene {
duration: 120,
ease: 'Sine.easeIn',
onComplete: () => {
this.applyPage(page);
this.applyPage(page);
this.prefetchNextStoryPage(index);
this.background?.setAlpha(0.68);
this.setDialogueAlpha(0.18);
this.tweens.add({
@@ -454,7 +649,11 @@ export class StoryScene extends Phaser.Scene {
}
private pageBackgroundKey(page: StoryPage) {
const keys = storyBackgroundKeysFor(page.background).filter((key) => this.textures.exists(key));
return this.pageBackgroundAssetKey(page) ?? page.background;
}
private pageBackgroundAssetKey(page: StoryPage) {
const keys = storyBackgroundKeysFor(page.background).filter((key) => Boolean(storyBackgroundAssets[key]));
if (keys.length <= 1) {
return keys[0] ?? page.background;
}
@@ -1058,17 +1257,22 @@ export class StoryScene extends Phaser.Scene {
}
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).filter((entry) => this.textures.exists(entry.textureKey));
const entries = portraitAssetEntriesForKey(profile.portraitKey);
if (entries.length === 0) {
return undefined;
}
return entries[(this.hashString(`${actor.unitId}-${index}`) % entries.length)].textureKey;
return entries[this.hashString(`${actor.unitId}-${index}`) % entries.length];
}
private storyUnitFrameIndex(direction: UnitDirection, frame = 0) {
@@ -1101,8 +1305,19 @@ export class StoryScene extends Phaser.Scene {
return;
}
this.pageIndex += 1;
this.showPage(this.pageIndex);
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() {