diff --git a/scripts/measure-performance.mjs b/scripts/measure-performance.mjs index be88c5a..e1c75a7 100644 --- a/scripts/measure-performance.mjs +++ b/scripts/measure-performance.mjs @@ -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); + } +} diff --git a/scripts/ship-release.mjs b/scripts/ship-release.mjs index 70fb4e2..4034ce9 100644 --- a/scripts/ship-release.mjs +++ b/scripts/ship-release.mjs @@ -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 }); diff --git a/scripts/verify-public-deploy.mjs b/scripts/verify-public-deploy.mjs index f2b3143..898949f 100644 --- a/scripts/verify-public-deploy.mjs +++ b/scripts/verify-public-deploy.mjs @@ -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'; } diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index 63bcef9..80ee9ee 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -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() ?? []; diff --git a/src/game/data/unitAssets.ts b/src/game/data/unitAssets.ts index 98482b8..581c197 100644 --- a/src/game/data/unitAssets.ts +++ b/src/game/data/unitAssets.ts @@ -63,6 +63,57 @@ export function hasUnitSheetAsset(key: string) { return unitSheetAssetsByKey.has(key); } +export function unitBaseSheetsReady(scene: Phaser.Scene, keys: Iterable) { + return Array.from(keys).every((key) => scene.textures.exists(key)); +} + +export function waitForUnitBaseSheets(scene: Phaser.Scene, keys: Iterable, onReady: () => void) { + waitForUnitBaseSheetsAttempt(scene, Array.from(new Set(keys)), onReady); +} + +export function loadUnitBaseSheets(scene: Phaser.Scene, keys: Iterable, 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, onReady: () => void) { const uniqueKeys = Array.from(new Set(keys)); if (unitSheetsReady(scene, uniqueKeys)) { @@ -110,6 +161,22 @@ function unitSheetsReady(scene: Phaser.Scene, keys: Iterable) { 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(); diff --git a/src/game/scenes/StoryScene.ts b/src/game/scenes/StoryScene.ts index b5846fd..5ef591f 100644 --- a/src/game/scenes/StoryScene.ts +++ b/src/game/scenes/StoryScene.ts @@ -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; }; +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(); + private storyPageAssetQueue: StoryPageAssetRequest[] = []; + private activeStoryPageAssetRequest?: StoryPageAssetRequest; + private activeStoryLoadErrorHandler?: (file: Phaser.Loader.File) => void; + private storyAssetLoadGeneration = 0; + private storyAssetFailures = new Set(); 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() {