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

@@ -4,9 +4,14 @@ import { join } from 'node:path';
import { chromium } from 'playwright';
import { desktopBrowserContextOptions, desktopBrowserViewport } from './desktop-browser-viewport.mjs';
const targetUrl = withDebugParam(process.env.MEASURE_URL ?? 'http://127.0.0.1:4173/');
const measureRenderer = readRenderer(process.env.MEASURE_RENDERER ?? 'webgl');
const targetUrl = withDebugParam(process.env.MEASURE_URL ?? 'http://127.0.0.1:4173/heros_web/');
const outputPath = process.env.MEASURE_OUT ?? 'dist/performance-report.json';
const baselineViewport = desktopBrowserViewport;
const chromiumLaunchOptions = Object.freeze({
headless: true,
args: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader']
});
const legacyUiScale = 1.5;
const budgets = {
titleReadyMs: readBudget('PERF_BUDGET_TITLE_READY_MS', 30000),
@@ -14,8 +19,10 @@ const budgets = {
firstBattleReadyFromStoryMs: readBudget('PERF_BUDGET_BATTLE_READY_MS', 60000),
firstBattlePlayableFromStoryMs: readBudget('PERF_BUDGET_BATTLE_PLAYABLE_MS', 70000),
firstLoadEncodedKB: readBudget('PERF_BUDGET_FIRST_LOAD_KB', 10000),
storyTransitionEncodedKB: readBudget('PERF_BUDGET_STORY_TRANSITION_KB', 130000),
battleTransitionEncodedKB: readBudget('PERF_BUDGET_BATTLE_TRANSITION_KB', 50000),
storyTransitionEncodedKB: readBudget('PERF_BUDGET_STORY_TRANSITION_KB', 15000),
battleTransitionEncodedKB: readBudget('PERF_BUDGET_BATTLE_TRANSITION_KB', 110000),
storyBattleTransitionEncodedKB: readBudget('PERF_BUDGET_STORY_BATTLE_TRANSITION_KB', 125000),
storyActionSheetRequests: 0,
largestJsKB: readBudget('PERF_BUDGET_LARGEST_JS_KB', 2500)
};
@@ -50,14 +57,35 @@ let browser;
try {
serverProcess = await ensurePreviewServer(targetUrl);
browser = await chromium.launch({ headless: true });
browser = await chromium.launch(chromiumLaunchOptions);
const context = await browser.newContext(desktopBrowserContextOptions);
await context.addInitScript(() => window.localStorage.clear());
await context.addInitScript(() => {
window.localStorage.clear();
window.__HEROS_WEBGL_QA_EVENTS__ = [];
window.addEventListener(
'webglcontextlost',
(event) => {
window.__HEROS_WEBGL_QA_EVENTS__.push({ type: event.type, at: performance.now() });
},
true
);
window.addEventListener(
'webglcontextrestored',
(event) => {
window.__HEROS_WEBGL_QA_EVENTS__.push({ type: event.type, at: performance.now() });
},
true
);
});
const page = await context.newPage();
const pageErrors = [];
page.on('pageerror', (error) => pageErrors.push(error.message));
const titleStartedAt = Date.now();
await page.goto(targetUrl, { waitUntil: 'domcontentloaded' });
await waitForTitle(page);
const rendering = await readRenderingProbe(page);
assertRenderingProbe(rendering, measureRenderer);
await page.waitForLoadState('networkidle', { timeout: 120000 });
const titleMs = Date.now() - titleStartedAt;
const titleEntries = await resourceEntries(page);
@@ -88,11 +116,14 @@ try {
const storyNames = new Set(storyEntries.map((entry) => entry.name));
const storyOnly = storyEntries.filter((entry) => !titleNames.has(entry.name));
const battleOnly = battleEntries.filter((entry) => !storyNames.has(entry.name));
const storyTransition = segmentStats(storyOnly);
const battleTransition = segmentStats(battleOnly);
const report = {
targetUrl,
measuredAt: new Date().toISOString(),
viewport: { ...baselineViewport },
deviceScaleFactor: desktopBrowserContextOptions.deviceScaleFactor,
rendering,
timingsMs: {
titleReady: titleMs,
storyReadyFromNewGame: storyMs,
@@ -101,8 +132,12 @@ try {
},
build: buildStats(),
firstLoad: segmentStats(titleEntries),
storyTransition: segmentStats(storyOnly),
battleTransition: segmentStats(battleOnly)
storyTransition,
battleTransition,
assetPolicy: {
storyActionSheetRequests: actionSheetFiles(storyOnly),
battleActionSheetRequests: actionSheetFiles(battleOnly)
}
};
report.budget = evaluateBudgets(report, budgets);
@@ -119,6 +154,9 @@ try {
if (report.budget.failures.length > 0) {
throw new Error(`Performance budget failed: ${report.budget.failures.map((failure) => `${failure.metric}=${failure.actual} > ${failure.budget}`).join(', ')}`);
}
if (pageErrors.length > 0) {
throw new Error(`Performance run reported page errors: ${JSON.stringify(pageErrors, null, 2)}`);
}
} finally {
await browser?.close();
if (serverProcess && !serverProcess.killed) {
@@ -129,17 +167,110 @@ try {
function withDebugParam(url) {
const parsed = new URL(url);
parsed.searchParams.set('debug', '1');
parsed.searchParams.set('renderer', 'canvas');
parsed.searchParams.set('renderer', measureRenderer);
return parsed.toString();
}
function readRenderer(value) {
if (value !== 'webgl' && value !== 'canvas') {
throw new Error(`MEASURE_RENDERER must be "webgl" or "canvas", received "${value}".`);
}
return value;
}
async function readRenderingProbe(page) {
return page.evaluate(() => {
const game = window.__HEROS_GAME__;
const canvas = document.querySelector('canvas');
const bounds = canvas?.getBoundingClientRect();
const gl = game?.renderer?.gl ?? null;
const webgl2 = typeof WebGL2RenderingContext !== 'undefined' && gl instanceof WebGL2RenderingContext;
const webgl1 = typeof WebGLRenderingContext !== 'undefined' && gl instanceof WebGLRenderingContext;
return {
requestedRenderer: new URLSearchParams(window.location.search).get('renderer'),
renderer: {
type: game?.renderer?.type ?? null,
name: game?.renderer?.constructor?.name ?? null
},
viewport: {
width: window.innerWidth,
height: window.innerHeight,
deviceScaleFactor: window.devicePixelRatio
},
canvas: canvas
? {
width: canvas.width,
height: canvas.height,
clientWidth: canvas.clientWidth,
clientHeight: canvas.clientHeight,
bounds: bounds
? { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }
: null
}
: null,
webgl: gl
? {
contextType: webgl2 ? 'webgl2' : webgl1 ? 'webgl' : 'unknown',
drawingBufferWidth: gl.drawingBufferWidth,
drawingBufferHeight: gl.drawingBufferHeight,
contextLost: gl.isContextLost(),
errorCode: gl.getError(),
noErrorCode: gl.NO_ERROR
}
: null,
webglEvents: window.__HEROS_WEBGL_QA_EVENTS__ ?? []
};
});
}
function assertRenderingProbe(probe, expectedRenderer) {
const expectedRendererType = expectedRenderer === 'webgl' ? 2 : 1;
assert(probe.requestedRenderer === expectedRenderer, `Expected ${expectedRenderer} query parameter: ${JSON.stringify(probe)}`);
assert(
probe.viewport?.width === baselineViewport.width &&
probe.viewport?.height === baselineViewport.height &&
probe.viewport?.deviceScaleFactor === desktopBrowserContextOptions.deviceScaleFactor,
`Expected ${baselineViewport.width}x${baselineViewport.height} DPR ${desktopBrowserContextOptions.deviceScaleFactor} viewport: ${JSON.stringify(probe)}`
);
assert(
probe.canvas?.width === baselineViewport.width &&
probe.canvas?.height === baselineViewport.height &&
probe.canvas?.clientWidth === baselineViewport.width &&
probe.canvas?.clientHeight === baselineViewport.height &&
probe.canvas?.bounds?.width === baselineViewport.width &&
probe.canvas?.bounds?.height === baselineViewport.height,
`Expected an exact FHD game canvas: ${JSON.stringify(probe)}`
);
assert(probe.renderer?.type === expectedRendererType, `Expected Phaser ${expectedRenderer} renderer type ${expectedRendererType}: ${JSON.stringify(probe)}`);
if (expectedRenderer === 'webgl') {
assert(probe.webgl?.contextType === 'webgl' || probe.webgl?.contextType === 'webgl2', `Expected an active WebGL context: ${JSON.stringify(probe)}`);
assert(
probe.webgl.drawingBufferWidth === baselineViewport.width && probe.webgl.drawingBufferHeight === baselineViewport.height,
`Expected an FHD WebGL drawing buffer: ${JSON.stringify(probe)}`
);
assert(probe.webgl.contextLost === false, `Expected the WebGL context to remain active: ${JSON.stringify(probe)}`);
assert(probe.webgl.errorCode === probe.webgl.noErrorCode, `Expected no WebGL error flag: ${JSON.stringify(probe)}`);
assert(probe.webglEvents.length === 0, `Expected no WebGL context loss/restoration events: ${JSON.stringify(probe)}`);
}
}
async function waitForTitle(page) {
await page.waitForFunction(() => document.querySelector('canvas') !== null, undefined, { timeout: 90000 });
await page.waitForFunction(() => window.__HEROS_DEBUG__ !== undefined, undefined, { timeout: 90000 });
await page.waitForFunction(() => {
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
return activeScenes.includes('TitleScene');
const webglEvents = window.__HEROS_WEBGL_QA_EVENTS__ ?? [];
return activeScenes.includes('TitleScene') || webglEvents.some((event) => event.type === 'webglcontextlost');
}, undefined, { timeout: 90000 });
const startupState = await page.evaluate(() => ({
activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? [],
webglEvents: window.__HEROS_WEBGL_QA_EVENTS__ ?? []
}));
assert(
startupState.activeScenes.includes('TitleScene'),
`Renderer failed before TitleScene became active: ${JSON.stringify(startupState)}`
);
}
async function waitForStoryReady(page) {
@@ -153,8 +284,12 @@ async function waitForStoryReady(page) {
async function advanceStoryUntilBattle(page, battleId) {
for (let i = 0; i < 48; i += 1) {
const ready = await page.evaluate((expectedBattleId) => {
const state = window.__HEROS_DEBUG__?.battle();
return state?.scene === 'BattleScene' && state?.battleId === expectedBattleId;
try {
const state = window.__HEROS_DEBUG__?.battle();
return state?.scene === 'BattleScene' && state?.battleId === expectedBattleId;
} catch {
return false;
}
}, battleId);
if (ready) {
return;
@@ -167,30 +302,38 @@ async function advanceStoryUntilBattle(page, battleId) {
async function waitForBattleReady(page, battleId) {
return page.waitForFunction((expectedBattleId) => {
const state = window.__HEROS_DEBUG__?.battle();
return (
state?.scene === 'BattleScene' &&
state?.battleId === expectedBattleId &&
(state?.phase === 'deployment' || state?.phase === 'idle') &&
state?.mapBackgroundReady === true &&
state?.resultVisible === false
)
? { phase: state.phase }
: false;
try {
const state = window.__HEROS_DEBUG__?.battle();
return (
state?.scene === 'BattleScene' &&
state?.battleId === expectedBattleId &&
(state?.phase === 'deployment' || state?.phase === 'idle') &&
state?.mapBackgroundReady === true &&
state?.resultVisible === false
)
? { phase: state.phase }
: false;
} catch {
return false;
}
}, battleId, { timeout: 120000 }).then((handle) => handle.jsonValue());
}
async function waitForBattlePlayable(page, battleId) {
await page.waitForFunction((expectedBattleId) => {
const state = window.__HEROS_DEBUG__?.battle();
return (
state?.scene === 'BattleScene' &&
state?.battleId === expectedBattleId &&
state?.phase === 'idle' &&
state?.mapBackgroundReady === true &&
state?.triggeredBattleEvents?.includes('opening') &&
state?.resultVisible === false
);
try {
const state = window.__HEROS_DEBUG__?.battle();
return (
state?.scene === 'BattleScene' &&
state?.battleId === expectedBattleId &&
state?.phase === 'idle' &&
state?.mapBackgroundReady === true &&
state?.triggeredBattleEvents?.includes('opening') &&
state?.resultVisible === false
);
} catch {
return false;
}
}, battleId, { timeout: 30000 });
}
@@ -260,6 +403,12 @@ function extensionOf(name) {
return path.match(/\.([a-z0-9]+)$/i)?.[1]?.toLowerCase() ?? 'other';
}
function actionSheetFiles(entries) {
return entries
.map((entry) => decodeURIComponent(entry.name.split('/').pop() ?? entry.name))
.filter((file) => /^unit-.*-actions(?:-[^.]+)?\.png(?:[?#]|$)/i.test(file));
}
function bytesToKb(bytes) {
return Math.round((bytes / 1024) * 10) / 10;
}
@@ -271,6 +420,9 @@ function readBudget(name, fallback) {
function evaluateBudgets(report, budgetValues) {
const largestJs = report.build.javascript[0]?.sizeKB ?? 0;
const storyBattleTransitionEncodedKB = bytesToKb(
(report.storyTransition.summary.encodedKB + report.battleTransition.summary.encodedKB) * 1024
);
const checks = [
['titleReadyMs', report.timingsMs.titleReady, budgetValues.titleReadyMs],
['storyReadyFromNewGameMs', report.timingsMs.storyReadyFromNewGame, budgetValues.storyReadyFromNewGameMs],
@@ -279,6 +431,8 @@ function evaluateBudgets(report, budgetValues) {
['firstLoadEncodedKB', report.firstLoad.summary.encodedKB, budgetValues.firstLoadEncodedKB],
['storyTransitionEncodedKB', report.storyTransition.summary.encodedKB, budgetValues.storyTransitionEncodedKB],
['battleTransitionEncodedKB', report.battleTransition.summary.encodedKB, budgetValues.battleTransitionEncodedKB],
['storyBattleTransitionEncodedKB', storyBattleTransitionEncodedKB, budgetValues.storyBattleTransitionEncodedKB],
['storyActionSheetRequests', report.assetPolicy.storyActionSheetRequests.length, budgetValues.storyActionSheetRequests],
['largestJsKB', largestJs, budgetValues.largestJsKB]
];
const failures = checks
@@ -340,3 +494,9 @@ async function canReach(url) {
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}

View File

@@ -1,6 +1,7 @@
import { spawnSync } from 'node:child_process';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { desktopBrowserDeviceScaleFactor, desktopBrowserViewport } from './desktop-browser-viewport.mjs';
const qaVersion = process.env.PUBLIC_QA_VERSION ?? makeQaVersion();
const releaseCommit = gitValue(['rev-parse', 'HEAD'], 'unknown');
@@ -11,7 +12,7 @@ const releaseDashboardFile = 'release-dashboard.html';
const qaRepeatRuns = Number(process.env.PUBLIC_QA_REPEAT_RUNS ?? 2);
assertNoTrackedChanges();
runPnpmScript('verify:local-release');
runPnpmScript('verify:local-release', { MEASURE_RENDERER: 'webgl' });
runPnpmScript('qa:early', { QA_REPORT_PATH: join('dist', qaReportFile) });
if (qaRepeatRuns > 1) {
runPnpmScript('qa:early:repeat', {
@@ -37,7 +38,7 @@ writeReleaseManifest(
runPnpmScript('deploy:nas:dist');
runPnpmScript('verify:public-deploy', { PUBLIC_QA_VERSION: qaVersion, PUBLIC_QA_COMMIT: releaseCommit });
console.log(`Shipped and verified public deploy with PUBLIC_QA_VERSION=${qaVersion} at ${releaseShortCommit}`);
console.log(`Shipped and verified FHD DPR 1 WebGL public deploy with Canvas compatibility, PUBLIC_QA_VERSION=${qaVersion} at ${releaseShortCommit}`);
function makeQaVersion() {
const stamp = new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14);
@@ -70,7 +71,21 @@ function writeReleaseManifest(qaVersion, commit, shortCommit, qaReport, qaRepeat
qaReport,
qaRepeatReport,
releaseDashboard,
verification: ['verify:local-release', 'qa:early', ...(qaRepeatReport ? ['qa:early:repeat'] : []), 'verify:public-deploy']
renderingGate: {
requiredRenderer: 'webgl',
compatibilityRenderer: 'canvas',
viewport: {
...desktopBrowserViewport,
deviceScaleFactor: desktopBrowserDeviceScaleFactor
}
},
verification: [
'verify:local-release',
'verify:performance:webgl',
'qa:early',
...(qaRepeatReport ? ['qa:early:repeat'] : []),
'verify:public-deploy:webgl+canvas'
]
};
mkdirSync('dist', { recursive: true });

View File

@@ -1,112 +1,102 @@
import { mkdirSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { dirname } from 'node:path';
import { basename, dirname, extname, join } from 'node:path';
import { chromium } from 'playwright';
import { desktopBrowserContextOptions } from './desktop-browser-viewport.mjs';
import {
desktopBrowserContextOptions,
desktopBrowserDeviceScaleFactor,
desktopBrowserViewport
} from './desktop-browser-viewport.mjs';
const targetUrl = withQaParams(process.env.PUBLIC_QA_URL ?? 'https://comtropy.synology.me/heros_web/');
const qaCacheVersion = process.env.PUBLIC_QA_VERSION ?? String(Date.now());
const publicBaseUrl = process.env.PUBLIC_QA_URL ?? 'https://comtropy.synology.me/heros_web/';
const targetUrl = withQaParams(publicBaseUrl, 'webgl');
const canvasTargetUrl = withQaParams(publicBaseUrl, 'canvas');
const screenshotPath = process.env.PUBLIC_QA_SCREENSHOT ?? 'dist/public-deploy-qa.png';
const canvasScreenshotPath = process.env.PUBLIC_QA_CANVAS_SCREENSHOT ?? companionScreenshotPath(screenshotPath);
const expectedQaVersion = process.env.PUBLIC_QA_VERSION;
const expectedCommit = process.env.PUBLIC_QA_COMMIT ?? currentGitCommit();
const expectedTitle = '\uC0BC\uAD6D\uC9C0: \uC138 \uD615\uC81C\uC758 \uB9F9\uC138';
const relevantLogPattern = /asset|audio|cannot|failed|map|missing|portrait|sprite|story|texture|unit/i;
const relevantLogPattern = /asset|audio|cannot|context|failed|map|missing|portrait|renderer|sprite|story|texture|unit|webgl/i;
const chromiumLaunchOptions = Object.freeze({
headless: true,
args: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader']
});
mkdirSync(dirname(screenshotPath), { recursive: true });
mkdirSync(dirname(canvasScreenshotPath), { recursive: true });
const browser = await chromium.launch({ headless: true });
const browser = await chromium.launch(chromiumLaunchOptions);
try {
const page = await browser.newPage(desktopBrowserContextOptions);
const consoleMessages = [];
const pageErrors = [];
const requestFailures = [];
page.on('console', (message) => {
consoleMessages.push({ type: message.type(), text: message.text() });
});
page.on('pageerror', (error) => {
pageErrors.push(error.message);
});
page.on('requestfailed', (request) => {
requestFailures.push({
url: request.url(),
type: request.resourceType(),
failure: request.failure()?.errorText ?? 'unknown'
});
const context = await browser.newContext(desktopBrowserContextOptions);
const page = await context.newPage();
await page.addInitScript(() => {
window.__HEROS_WEBGL_QA_EVENTS__ = [];
window.addEventListener(
'webglcontextlost',
(event) => {
window.__HEROS_WEBGL_QA_EVENTS__.push({ type: event.type, at: performance.now() });
},
true
);
window.addEventListener(
'webglcontextrestored',
(event) => {
window.__HEROS_WEBGL_QA_EVENTS__.push({ type: event.type, at: performance.now() });
},
true
);
});
const webglDiagnostics = observePage(page);
await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 90000 });
await page.waitForFunction(() => document.querySelector('canvas') !== null, undefined, { timeout: 90000 });
await page.waitForFunction(() => {
const canvas = document.querySelector('canvas');
return Boolean(canvas && canvas.width === 1920 && canvas.height === 1080);
}, undefined, { timeout: 90000 });
await waitForGameCanvas(page);
await page.waitForTimeout(3000);
await page.screenshot({ path: screenshotPath, fullPage: true });
const webglScreenshot = await page.screenshot({ path: screenshotPath, fullPage: true });
const webglState = await readWebglState(page);
assertPublicShellState(webglState, 'WebGL');
assert(webglState.requestedRenderer === 'webgl', `Expected the public URL to request WebGL: ${JSON.stringify(webglState)}`);
assert(
webglState.webgl?.contextType === 'webgl' || webglState.webgl?.contextType === 'webgl2',
`Expected an active public WebGL context: ${JSON.stringify(webglState)}`
);
assert(
webglState.webgl.drawingBufferWidth === desktopBrowserViewport.width &&
webglState.webgl.drawingBufferHeight === desktopBrowserViewport.height,
`Expected an FHD public WebGL drawing buffer: ${JSON.stringify(webglState)}`
);
assert(webglState.webgl.contextLost === false, `Expected the public WebGL context to remain active: ${JSON.stringify(webglState)}`);
assert(webglState.webgl.errorCode === webglState.webgl.noErrorCode, `Expected no public WebGL error flag: ${JSON.stringify(webglState)}`);
assert(webglState.webglEvents.length === 0, `Expected no public WebGL context loss/restoration events: ${JSON.stringify(webglState)}`);
assert(webglScreenshot.byteLength >= 100_000, `Expected a visibly painted public WebGL frame: ${JSON.stringify({ byteLength: webglScreenshot.byteLength })}`);
const state = await page.evaluate(() => {
const canvas = document.querySelector('canvas');
return {
title: document.title,
canvasCount: document.querySelectorAll('canvas').length,
debugApiPresent: Boolean(window.__HEROS_DEBUG__),
gameApiPresent: Boolean(window.__HEROS_GAME__),
canvas: canvas
? {
width: canvas.width,
height: canvas.height,
clientWidth: canvas.clientWidth,
clientHeight: canvas.clientHeight,
pixelProbe: sampleCanvasPixels(canvas)
}
: undefined
};
function sampleCanvasPixels(canvas) {
const context = canvas.getContext('2d', { willReadFrequently: true });
if (!context) {
return { readable: false, sampleCount: 0, nonTransparentRatio: 0, distinctColorBuckets: 0 };
}
const stepX = Math.max(1, Math.floor(canvas.width / 64));
const stepY = Math.max(1, Math.floor(canvas.height / 64));
const colorBuckets = new Set();
let sampleCount = 0;
let nonTransparentCount = 0;
for (let y = Math.floor(stepY / 2); y < canvas.height; y += stepY) {
for (let x = Math.floor(stepX / 2); x < canvas.width; x += stepX) {
const [r, g, b, a] = context.getImageData(x, y, 1, 1).data;
sampleCount += 1;
if (a > 8) {
nonTransparentCount += 1;
}
colorBuckets.add(`${r >> 4},${g >> 4},${b >> 4},${a >> 5}`);
}
}
return {
readable: true,
sampleCount,
nonTransparentRatio: sampleCount > 0 ? nonTransparentCount / sampleCount : 0,
distinctColorBuckets: colorBuckets.size
};
}
});
assert(state.title === expectedTitle, `Expected deployed page title "${expectedTitle}": ${JSON.stringify(state)}`);
assert(state.canvasCount === 1, `Expected exactly one game canvas: ${JSON.stringify(state)}`);
assert(state.debugApiPresent === false, `Expected public deploy to keep debug API disabled: ${JSON.stringify(state)}`);
assert(state.gameApiPresent === false, `Expected public deploy to keep game handle disabled: ${JSON.stringify(state)}`);
assert(state.canvas?.width === 1920 && state.canvas?.height === 1080, `Expected FHD game canvas: ${JSON.stringify(state)}`);
assert(state.canvas?.pixelProbe?.readable === true, `Expected readable canvas pixels: ${JSON.stringify(state)}`);
assert(state.canvas.pixelProbe.sampleCount >= 500, `Expected enough canvas pixel samples: ${JSON.stringify(state)}`);
assert(state.canvas.pixelProbe.nonTransparentRatio > 0.5, `Expected non-empty canvas pixels: ${JSON.stringify(state)}`);
assert(state.canvas.pixelProbe.distinctColorBuckets >= 12, `Expected visually varied canvas pixels: ${JSON.stringify(state)}`);
const canvasPage = await context.newPage();
const canvasDiagnostics = observePage(canvasPage);
await canvasPage.goto(canvasTargetUrl, { waitUntil: 'domcontentloaded', timeout: 90000 });
await waitForGameCanvas(canvasPage);
await canvasPage.waitForTimeout(3000);
await canvasPage.screenshot({ path: canvasScreenshotPath, fullPage: true });
const canvasState = await readCanvasState(canvasPage);
assertPublicShellState(canvasState, 'Canvas');
assert(canvasState.requestedRenderer === 'canvas', `Expected the compatibility URL to request Canvas: ${JSON.stringify(canvasState)}`);
assert(canvasState.canvas?.pixelProbe?.readable === true, `Expected readable Canvas pixels: ${JSON.stringify(canvasState)}`);
assert(canvasState.canvas.pixelProbe.sampleCount >= 500, `Expected enough Canvas pixel samples: ${JSON.stringify(canvasState)}`);
assert(canvasState.canvas.pixelProbe.nonTransparentRatio > 0.5, `Expected non-empty Canvas pixels: ${JSON.stringify(canvasState)}`);
assert(canvasState.canvas.pixelProbe.distinctColorBuckets >= 12, `Expected visually varied Canvas pixels: ${JSON.stringify(canvasState)}`);
const releaseManifest = await readReleaseManifest(targetUrl);
assert(releaseManifest.app === 'heros_web', `Expected deployed release manifest app id: ${JSON.stringify(releaseManifest)}`);
assert(typeof releaseManifest.commit === 'string' && releaseManifest.commit.length >= 7, `Expected deployed release manifest commit: ${JSON.stringify(releaseManifest)}`);
assert(typeof releaseManifest.generatedAt === 'string' && releaseManifest.generatedAt.length > 0, `Expected deployed release manifest timestamp: ${JSON.stringify(releaseManifest)}`);
assert(
releaseManifest.renderingGate?.requiredRenderer === 'webgl' &&
releaseManifest.renderingGate?.compatibilityRenderer === 'canvas' &&
releaseManifest.renderingGate?.viewport?.width === desktopBrowserViewport.width &&
releaseManifest.renderingGate?.viewport?.height === desktopBrowserViewport.height &&
releaseManifest.renderingGate?.viewport?.deviceScaleFactor === desktopBrowserDeviceScaleFactor,
`Expected the deployed release manifest to require FHD DPR 1 WebGL with Canvas compatibility: ${JSON.stringify(releaseManifest)}`
);
if (expectedQaVersion) {
assert(
releaseManifest.qaVersion === expectedQaVersion,
@@ -192,28 +182,200 @@ try {
console.log(`Verified release dashboard ${releaseManifest.releaseDashboard}`);
}
const relevantLogs = consoleMessages.filter(
(message) => message.type === 'error' || (isWarning(message.type) && relevantLogPattern.test(message.text))
);
const blockingRequestFailures = requestFailures.filter((request) => request.failure !== 'net::ERR_ABORTED');
assert(relevantLogs.length === 0, `Unexpected public deploy console issues: ${JSON.stringify(relevantLogs, null, 2)}`);
assert(pageErrors.length === 0, `Unexpected public deploy page errors: ${JSON.stringify(pageErrors, null, 2)}`);
assert(blockingRequestFailures.length === 0, `Unexpected public deploy request failures: ${JSON.stringify(blockingRequestFailures, null, 2)}`);
assertPageDiagnostics('WebGL public deploy', webglDiagnostics);
assertPageDiagnostics('Canvas compatibility deploy', canvasDiagnostics);
console.log(`Verified public deploy at ${targetUrl}`);
console.log(`Verified FHD DPR 1 WebGL public deploy at ${targetUrl}`);
console.log(`Verified Canvas pixel compatibility at ${canvasTargetUrl}`);
console.log(`Verified release manifest ${releaseManifest.shortCommit ?? releaseManifest.commit} (${releaseManifest.qaVersion ?? 'no qaVersion'})`);
console.log(`Captured ${screenshotPath}`);
console.log(`Captured ${screenshotPath} and ${canvasScreenshotPath}`);
} finally {
await browser.close();
}
function withQaParams(url) {
function withQaParams(url, renderer) {
const parsed = new URL(url);
parsed.searchParams.set('renderer', 'canvas');
parsed.searchParams.set('v', process.env.PUBLIC_QA_VERSION ?? String(Date.now()));
parsed.searchParams.set('renderer', renderer);
parsed.searchParams.set('v', qaCacheVersion);
return parsed.toString();
}
function companionScreenshotPath(filePath) {
const extension = extname(filePath) || '.png';
return join(dirname(filePath), `${basename(filePath, extname(filePath))}-canvas${extension}`);
}
function observePage(page) {
const diagnostics = { consoleMessages: [], pageErrors: [], requestFailures: [] };
page.on('console', (message) => {
diagnostics.consoleMessages.push({ type: message.type(), text: message.text() });
});
page.on('pageerror', (error) => {
diagnostics.pageErrors.push(error.message);
});
page.on('requestfailed', (request) => {
diagnostics.requestFailures.push({
url: request.url(),
type: request.resourceType(),
failure: request.failure()?.errorText ?? 'unknown'
});
});
return diagnostics;
}
async function waitForGameCanvas(page) {
await page.waitForFunction(() => document.querySelector('canvas') !== null, undefined, { timeout: 90000 });
await page.waitForFunction(
({ width, height }) => {
const canvas = document.querySelector('canvas');
return Boolean(canvas && canvas.width === width && canvas.height === height);
},
desktopBrowserViewport,
{ timeout: 90000 }
);
}
async function readWebglState(page) {
return page.evaluate(() => {
const canvas = document.querySelector('canvas');
const common = readCommonState(canvas);
const gl = canvas?.getContext('webgl') ?? canvas?.getContext('webgl2') ?? null;
const webgl2 = typeof WebGL2RenderingContext !== 'undefined' && gl instanceof WebGL2RenderingContext;
const webgl1 = typeof WebGLRenderingContext !== 'undefined' && gl instanceof WebGLRenderingContext;
return {
...common,
webgl: gl
? {
contextType: webgl2 ? 'webgl2' : webgl1 ? 'webgl' : 'unknown',
drawingBufferWidth: gl.drawingBufferWidth,
drawingBufferHeight: gl.drawingBufferHeight,
contextLost: gl.isContextLost(),
errorCode: gl.getError(),
noErrorCode: gl.NO_ERROR
}
: null,
webglEvents: window.__HEROS_WEBGL_QA_EVENTS__ ?? []
};
function readCommonState(gameCanvas) {
const bounds = gameCanvas?.getBoundingClientRect();
return {
title: document.title,
requestedRenderer: new URLSearchParams(window.location.search).get('renderer'),
canvasCount: document.querySelectorAll('canvas').length,
debugApiPresent: Boolean(window.__HEROS_DEBUG__),
gameApiPresent: Boolean(window.__HEROS_GAME__),
viewport: {
width: window.innerWidth,
height: window.innerHeight,
deviceScaleFactor: window.devicePixelRatio
},
canvas: gameCanvas
? {
width: gameCanvas.width,
height: gameCanvas.height,
clientWidth: gameCanvas.clientWidth,
clientHeight: gameCanvas.clientHeight,
bounds: bounds ? { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height } : null
}
: null
};
}
});
}
async function readCanvasState(page) {
return page.evaluate(() => {
const canvas = document.querySelector('canvas');
const bounds = canvas?.getBoundingClientRect();
return {
title: document.title,
requestedRenderer: new URLSearchParams(window.location.search).get('renderer'),
canvasCount: document.querySelectorAll('canvas').length,
debugApiPresent: Boolean(window.__HEROS_DEBUG__),
gameApiPresent: Boolean(window.__HEROS_GAME__),
viewport: {
width: window.innerWidth,
height: window.innerHeight,
deviceScaleFactor: window.devicePixelRatio
},
canvas: canvas
? {
width: canvas.width,
height: canvas.height,
clientWidth: canvas.clientWidth,
clientHeight: canvas.clientHeight,
bounds: bounds ? { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height } : null,
pixelProbe: sampleCanvasPixels(canvas)
}
: null
};
function sampleCanvasPixels(gameCanvas) {
const context = gameCanvas.getContext('2d', { willReadFrequently: true });
if (!context) {
return { readable: false, sampleCount: 0, nonTransparentRatio: 0, distinctColorBuckets: 0 };
}
const stepX = Math.max(1, Math.floor(gameCanvas.width / 64));
const stepY = Math.max(1, Math.floor(gameCanvas.height / 64));
const colorBuckets = new Set();
let sampleCount = 0;
let nonTransparentCount = 0;
for (let y = Math.floor(stepY / 2); y < gameCanvas.height; y += stepY) {
for (let x = Math.floor(stepX / 2); x < gameCanvas.width; x += stepX) {
const [r, g, b, a] = context.getImageData(x, y, 1, 1).data;
sampleCount += 1;
if (a > 8) {
nonTransparentCount += 1;
}
colorBuckets.add(`${r >> 4},${g >> 4},${b >> 4},${a >> 5}`);
}
}
return {
readable: true,
sampleCount,
nonTransparentRatio: sampleCount > 0 ? nonTransparentCount / sampleCount : 0,
distinctColorBuckets: colorBuckets.size
};
}
});
}
function assertPublicShellState(state, rendererLabel) {
assert(state.title === expectedTitle, `Expected deployed page title "${expectedTitle}" for ${rendererLabel}: ${JSON.stringify(state)}`);
assert(state.canvasCount === 1, `Expected exactly one ${rendererLabel} game canvas: ${JSON.stringify(state)}`);
assert(state.debugApiPresent === false, `Expected public ${rendererLabel} deploy to keep debug API disabled: ${JSON.stringify(state)}`);
assert(state.gameApiPresent === false, `Expected public ${rendererLabel} deploy to keep game handle disabled: ${JSON.stringify(state)}`);
assert(
state.viewport?.width === desktopBrowserViewport.width &&
state.viewport?.height === desktopBrowserViewport.height &&
state.viewport?.deviceScaleFactor === desktopBrowserDeviceScaleFactor,
`Expected ${rendererLabel} viewport ${desktopBrowserViewport.width}x${desktopBrowserViewport.height} DPR ${desktopBrowserDeviceScaleFactor}: ${JSON.stringify(state)}`
);
assert(
state.canvas?.width === desktopBrowserViewport.width &&
state.canvas?.height === desktopBrowserViewport.height &&
state.canvas?.clientWidth === desktopBrowserViewport.width &&
state.canvas?.clientHeight === desktopBrowserViewport.height &&
state.canvas?.bounds?.width === desktopBrowserViewport.width &&
state.canvas?.bounds?.height === desktopBrowserViewport.height,
`Expected exact FHD ${rendererLabel} game canvas dimensions: ${JSON.stringify(state)}`
);
}
function assertPageDiagnostics(label, diagnostics) {
const relevantLogs = diagnostics.consoleMessages.filter(
(message) => message.type === 'error' || (isWarning(message.type) && relevantLogPattern.test(message.text))
);
const blockingRequestFailures = diagnostics.requestFailures.filter((request) => request.failure !== 'net::ERR_ABORTED');
assert(relevantLogs.length === 0, `Unexpected ${label} console issues: ${JSON.stringify(relevantLogs, null, 2)}`);
assert(diagnostics.pageErrors.length === 0, `Unexpected ${label} page errors: ${JSON.stringify(diagnostics.pageErrors, null, 2)}`);
assert(blockingRequestFailures.length === 0, `Unexpected ${label} request failures: ${JSON.stringify(blockingRequestFailures, null, 2)}`);
}
function isWarning(type) {
return type === 'warning' || type === 'warn';
}

View File

@@ -59,6 +59,7 @@ try {
const consoleMessages = [];
const pageErrors = [];
const requestFailures = [];
const requestedResourceUrls = [];
page.on('console', (message) => {
consoleMessages.push({ type: message.type(), text: message.text() });
});
@@ -72,6 +73,9 @@ try {
failure: request.failure()?.errorText ?? 'unknown'
});
});
page.on('request', (request) => {
requestedResourceUrls.push(request.url());
});
await page.goto(targetUrl, { waitUntil: 'domcontentloaded' });
await page.evaluate(() => window.localStorage.clear());
@@ -88,6 +92,7 @@ try {
const emptySave = await readCampaignSave(page);
assert(!emptySave.current && !emptySave.slot1, `Expected first run without campaign save: ${JSON.stringify(emptySave)}`);
const freshStoryRequestStartIndex = requestedResourceUrls.length;
await clickLegacyUi(page, 962, 240);
await waitForStoryReady(page);
await setDebugQueryParam(page, 'debugOrderCommandReady', '1');
@@ -108,6 +113,7 @@ try {
);
await page.screenshot({ path: `${screenshotDir}/rc-new-game-story.png`, fullPage: true });
await assertCanvasPainted(page, 'new game story');
await assertFreshStoryAssetStreaming(page, requestedResourceUrls, freshStoryRequestStartIndex);
const firstBattleTransition = await advanceUntilBattle(page, 'first-battle-zhuo-commandery', {
startDeployment: false,
captureLastStory: true
@@ -7504,6 +7510,140 @@ async function waitForStoryReady(page) {
}, undefined, { timeout: 90000 });
}
async function assertFreshStoryAssetStreaming(page, requestedResourceUrls, requestStartIndex) {
for (let sampleIndex = 0; sampleIndex < 8; sampleIndex += 1) {
const probe = await readStoryAssetStreamingProbe(page);
assertStoryAssetStreamingProbe(probe, 0, 'fresh story page 1');
assert(
storyStreamingIndexes(probe.story.assetStreaming).every((pageIndex) => pageIndex <= 1),
`Expected fresh story loading to stay within pages 1-2 before input: ${JSON.stringify(probe)}`
);
await page.waitForTimeout(75);
}
let probe = await advanceOneStoryPage(page, 1);
assertStoryAssetStreamingProbe(probe, 1, 'fresh story page 2');
probe = await advanceOneStoryPage(page, 2);
assertStoryAssetStreamingProbe(probe, 2, 'fresh story page 3');
while ((probe.story?.cutsceneActors?.length ?? 0) === 0 && probe.story?.pageIndex < probe.story?.totalPages - 1) {
const targetPageIndex = probe.story.pageIndex + 1;
probe = await advanceOneStoryPage(page, targetPageIndex);
assertStoryAssetStreamingProbe(probe, targetPageIndex, `fresh story page ${targetPageIndex + 1}`);
}
assert(
(probe.story?.cutsceneActors?.length ?? 0) > 0 && probe.expectedUnitBaseTextureKeys.length > 0,
`Expected the fresh story regression to reach a cutscene that uses unit base sheets: ${JSON.stringify(probe)}`
);
assert(
sameJsonValue(
[...probe.story.assetStreaming.currentUnitBaseTexturesLoaded].sort(),
[...probe.expectedUnitBaseTextureKeys].sort()
),
`Expected every current cutscene unit base sheet to be loaded: ${JSON.stringify(probe)}`
);
const actionRequests = requestedResourceUrls
.slice(requestStartIndex)
.filter((url) => /\/unit-[^/?]+-actions(?:-[^/?]+)?\.png(?:[?#]|$)/i.test(decodeURIComponent(url)));
assert(
actionRequests.length === 0,
`Expected story streaming not to request battle action sheets: ${JSON.stringify(actionRequests)}`
);
}
async function advanceOneStoryPage(page, expectedPageIndex) {
const previousPageIndex = expectedPageIndex - 1;
const before = await readStoryAssetStreamingProbe(page);
assert(
before.story?.pageIndex === previousPageIndex,
`Expected story page ${previousPageIndex + 1} before advancing once: ${JSON.stringify(before)}`
);
await page.keyboard.press('Space');
await page.waitForFunction((targetPageIndex) => {
const scene = window.__HEROS_GAME__?.scene.getScene('StoryScene');
const story = scene?.getDebugState?.();
return (
story?.pageIndex === targetPageIndex &&
story?.assetStreaming?.currentPageReady === true &&
story?.backgroundReady === true &&
story?.activeTexture === story?.background &&
scene?.transitioning === false
);
}, expectedPageIndex, { timeout: 90000 });
const after = await readStoryAssetStreamingProbe(page);
assert(
after.story?.pageIndex === previousPageIndex + 1,
`Expected exactly one story page advance without skips: ${JSON.stringify(after)}`
);
return after;
}
async function readStoryAssetStreamingProbe(page) {
return page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('StoryScene');
const story = scene?.getDebugState?.() ?? null;
const plan = story && scene?.storyPageAssetPlan?.(story.pageIndex);
const textureKeys = scene?.textures?.list ? Object.keys(scene.textures.list) : [];
return {
viewport: {
width: window.innerWidth,
height: window.innerHeight,
devicePixelRatio: window.devicePixelRatio
},
story,
expectedUnitBaseTextureKeys: plan?.unitTextureKeys ?? [],
loadedUnitActionTextureKeys: textureKeys.filter((key) => key.endsWith('-actions')),
transitioning: scene?.transitioning ?? null
};
});
}
function assertStoryAssetStreamingProbe(probe, expectedPageIndex, label) {
const streaming = probe.story?.assetStreaming;
assert(
probe.viewport?.width === baselineViewport.width &&
probe.viewport?.height === baselineViewport.height &&
probe.viewport?.devicePixelRatio === desktopBrowserDeviceScaleFactor,
`Expected ${label} at the explicit 1920x1080 DPR1 baseline: ${JSON.stringify(probe.viewport)}`
);
assert(
probe.story?.ready === true &&
probe.story.pageIndex === expectedPageIndex &&
streaming?.currentPageReady === true &&
probe.story.backgroundReady === true &&
probe.story.activeTexture === probe.story.background,
`Expected ${label} to use its loaded deterministic background: ${JSON.stringify(probe)}`
);
assert(
Array.isArray(streaming?.failedAssetKeys) && streaming.failedAssetKeys.length === 0,
`Expected ${label} without story asset failures: ${JSON.stringify(probe)}`
);
assert(
storyStreamingIndexes(streaming).every((pageIndex) => pageIndex <= expectedPageIndex + 1),
`Expected ${label} streaming not to run beyond the current/next page window: ${JSON.stringify(probe)}`
);
assert(
probe.expectedUnitBaseTextureKeys.every((key) => streaming.currentUnitBaseTexturesLoaded.includes(key)),
`Expected ${label} current unit base sheets to be ready: ${JSON.stringify(probe)}`
);
assert(
streaming.currentUnitActionTexturesLoaded.length === 0 && probe.loadedUnitActionTextureKeys.length === 0,
`Expected ${label} to keep battle action sheets unloaded: ${JSON.stringify(probe)}`
);
}
function storyStreamingIndexes(streaming) {
return [
...(streaming?.readyPageIndexes ?? []),
...(streaming?.queuedPageIndexes ?? []),
...(Number.isInteger(streaming?.loadingPageIndex) ? [streaming.loadingPageIndex] : [])
];
}
async function waitForStoryOrEnding(page) {
await page.waitForFunction(() => {
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];

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() {