perf: stream story assets by page
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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() ?? [];
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user