From b74332973bc84eb875bab213305ffc5a37315eef Mon Sep 17 00:00:00 2001 From: Wickedness Date: Sat, 27 Jun 2026 07:49:38 +0900 Subject: [PATCH] Optimize initial loading performance --- package.json | 1 + scripts/measure-performance.mjs | 236 ++++++++++++ scripts/qa-representative-battles.mjs | 8 +- scripts/verify-flow.mjs | 30 +- src/game/data/portraitAssets.ts | 17 + src/game/scenes/BattleScene.ts | 3 +- src/game/scenes/BootScene.ts | 4 - src/game/scenes/CampScene.ts | 72 +++- src/game/scenes/StoryScene.ts | 38 +- src/game/scenes/TitleScene.ts | 506 ++------------------------ src/game/scenes/lazyScenes.ts | 57 +++ src/game/state/campaignRouting.ts | 79 ++++ src/main.ts | 32 +- vite.config.ts | 2 +- 14 files changed, 552 insertions(+), 533 deletions(-) create mode 100644 scripts/measure-performance.mjs create mode 100644 src/game/scenes/lazyScenes.ts create mode 100644 src/game/state/campaignRouting.ts diff --git a/package.json b/package.json index 8177806..cd7f79a 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "generate:audio": "node scripts/generate-bgm.mjs", "generate:sfx": "node scripts/generate-sfx.mjs", "preview": "vite preview --host 0.0.0.0", + "measure:performance": "node scripts/measure-performance.mjs", "verify:flow": "node scripts/verify-flow.mjs", "verify:save-flow": "node scripts/verify-save-retry-flow.mjs", "qa:representative": "node scripts/qa-representative-battles.mjs" diff --git a/scripts/measure-performance.mjs b/scripts/measure-performance.mjs new file mode 100644 index 0000000..9990f18 --- /dev/null +++ b/scripts/measure-performance.mjs @@ -0,0 +1,236 @@ +import { spawn } from 'node:child_process'; +import { existsSync, readdirSync, statSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { chromium } from 'playwright'; + +const targetUrl = withDebugParam(process.env.MEASURE_URL ?? 'http://127.0.0.1:4173/'); +const outputPath = process.env.MEASURE_OUT ?? 'dist/performance-report.json'; + +let serverProcess; +let browser; + +try { + serverProcess = await ensurePreviewServer(targetUrl); + browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ viewport: { width: 1280, height: 720 } }); + await context.addInitScript(() => window.localStorage.clear()); + const page = await context.newPage(); + + const titleStartedAt = Date.now(); + await page.goto(targetUrl, { waitUntil: 'domcontentloaded' }); + await waitForTitle(page); + await page.waitForLoadState('networkidle', { timeout: 120000 }); + const titleMs = Date.now() - titleStartedAt; + const titleEntries = await resourceEntries(page); + + const storyStartedAt = Date.now(); + await page.mouse.click(962, 240); + await waitForStoryReady(page); + await page.waitForLoadState('networkidle', { timeout: 120000 }); + const storyMs = Date.now() - storyStartedAt; + const storyEntries = await resourceEntries(page); + + const battleStartedAt = Date.now(); + await advanceStoryUntilBattle(page); + await waitForBattleReady(page, 'first-battle-zhuo-commandery'); + await page.waitForLoadState('networkidle', { timeout: 120000 }); + const battleMs = Date.now() - battleStartedAt; + const battleEntries = await resourceEntries(page); + + const titleNames = new Set(titleEntries.map((entry) => entry.name)); + 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 report = { + targetUrl, + measuredAt: new Date().toISOString(), + timingsMs: { + titleReady: titleMs, + storyReadyFromNewGame: storyMs, + firstBattleReadyFromStory: battleMs + }, + build: buildStats(), + firstLoad: segmentStats(titleEntries), + storyTransition: segmentStats(storyOnly), + battleTransition: segmentStats(battleOnly) + }; + + writeFileSync(outputPath, `${JSON.stringify(report, null, 2)}\n`); + console.log(`Performance report written to ${outputPath}`); + console.log(JSON.stringify({ + timingsMs: report.timingsMs, + firstLoad: report.firstLoad.summary, + storyTransition: report.storyTransition.summary, + battleTransition: report.battleTransition.summary, + largestJs: report.build.javascript[0] ?? null + }, null, 2)); +} finally { + await browser?.close(); + if (serverProcess && !serverProcess.killed) { + serverProcess.kill(); + } +} + +function withDebugParam(url) { + const parsed = new URL(url); + parsed.searchParams.set('debug', '1'); + parsed.searchParams.set('renderer', 'canvas'); + return parsed.toString(); +} + +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'); + }, undefined, { timeout: 90000 }); +} + +async function waitForStoryReady(page) { + await page.waitForFunction(() => { + const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; + const story = window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.(); + return activeScenes.includes('StoryScene') && story?.ready === true; + }, undefined, { timeout: 120000 }); +} + +async function advanceStoryUntilBattle(page) { + for (let i = 0; i < 32; i += 1) { + const activeScenes = await page.evaluate(() => window.__HEROS_DEBUG__?.activeScenes() ?? []); + if (activeScenes.includes('BattleScene')) { + return; + } + + await page.keyboard.press('Space'); + await page.waitForTimeout(160); + } +} + +async function waitForBattleReady(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?.resultVisible === false + ); + }, battleId, { timeout: 120000 }); +} + +async function resourceEntries(page) { + return page.evaluate(() => + performance.getEntriesByType('resource').map((entry) => ({ + name: entry.name, + initiatorType: entry.initiatorType, + startTime: Math.round(entry.startTime), + duration: Math.round(entry.duration), + transferSize: entry.transferSize || 0, + encodedBodySize: entry.encodedBodySize || 0 + })) + ); +} + +function segmentStats(entries) { + const encodedBytes = entries.reduce((sum, entry) => sum + entry.encodedBodySize, 0); + const transferBytes = entries.reduce((sum, entry) => sum + entry.transferSize, 0); + const byExt = {}; + + entries.forEach((entry) => { + const ext = extensionOf(entry.name); + byExt[ext] ??= { count: 0, encodedKB: 0, transferKB: 0 }; + byExt[ext].count += 1; + byExt[ext].encodedKB += bytesToKb(entry.encodedBodySize); + byExt[ext].transferKB += bytesToKb(entry.transferSize); + }); + + return { + summary: { + requests: entries.length, + encodedKB: bytesToKb(encodedBytes), + transferKB: bytesToKb(transferBytes) + }, + byExt, + largest: [...entries] + .sort((left, right) => right.encodedBodySize - left.encodedBodySize) + .slice(0, 12) + .map((entry) => ({ + file: entry.name.split('/').pop(), + encodedKB: bytesToKb(entry.encodedBodySize), + startMs: entry.startTime, + durationMs: entry.duration + })) + }; +} + +function buildStats() { + const assetsDir = join(process.cwd(), 'dist', 'assets'); + if (!existsSync(assetsDir)) { + return { javascript: [], sourcemaps: [] }; + } + + const assets = readdirSync(assetsDir) + .map((file) => ({ file, sizeKB: bytesToKb(statSync(join(assetsDir, file)).size) })) + .sort((left, right) => right.sizeKB - left.sizeKB); + + return { + javascript: assets.filter((asset) => asset.file.endsWith('.js')), + sourcemaps: assets.filter((asset) => asset.file.endsWith('.map')) + }; +} + +function extensionOf(name) { + const path = name.split('?')[0]; + return path.match(/\.([a-z0-9]+)$/i)?.[1]?.toLowerCase() ?? 'other'; +} + +function bytesToKb(bytes) { + return Math.round((bytes / 1024) * 10) / 10; +} + +async function ensurePreviewServer(url) { + if (await canReach(url)) { + return undefined; + } + + const parsed = new URL(url); + const isLocal = ['localhost', '127.0.0.1', '0.0.0.0'].includes(parsed.hostname); + if (!isLocal) { + throw new Error(`No server responded at ${url}`); + } + + const stderr = []; + const child = spawn(process.execPath, ['node_modules/vite/bin/vite.js', 'preview', '--host', '127.0.0.1', '--port', parsed.port || '4173'], { + cwd: process.cwd(), + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + child.stderr.on('data', (chunk) => stderr.push(chunk.toString())); + child.stdout.on('data', () => {}); + + for (let i = 0; i < 120; i += 1) { + if (await canReach(url)) { + return child; + } + await delay(250); + } + + child.kill(); + throw new Error(`Vite preview did not start at ${url}\n${stderr.join('')}`); +} + +async function canReach(url) { + try { + const response = await fetch(url, { signal: AbortSignal.timeout(1000) }); + return response.ok; + } catch { + return false; + } +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/scripts/qa-representative-battles.mjs b/scripts/qa-representative-battles.mjs index e24914e..1641d64 100644 --- a/scripts/qa-representative-battles.mjs +++ b/scripts/qa-representative-battles.mjs @@ -159,13 +159,7 @@ async function setupBattle(page, battle) { }, battle.selected); await page.reload({ waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => window.__HEROS_GAME__ !== undefined && window.__HEROS_DEBUG__ !== undefined, undefined, { timeout: 90000 }); - await page.evaluate((battleId) => { - const game = window.__HEROS_GAME__; - for (const scene of game.scene.getScenes(true)) { - game.scene.stop(scene.scene.key); - } - game.scene.start('BattleScene', { battleId }); - }, battle.id); + await page.evaluate((battleId) => window.__HEROS_DEBUG__.goToBattle(battleId), battle.id); await page.waitForFunction( (battleId) => { const state = window.__HEROS_DEBUG__?.battle(); diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs index f3d0e53..1f0d67f 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -60,7 +60,7 @@ try { await page.mouse.click(962, 240); await page.waitForTimeout(250); - for (let i = 0; i < 20; i += 1) { + for (let i = 0; i < 80; i += 1) { const isBattleActive = await page.evaluate(() => { const activeScenes = window.__HEROS_GAME__?.scene.getScenes(true) ?? []; return activeScenes.some((scene) => scene.scene.key === 'BattleScene'); @@ -77,11 +77,11 @@ try { await page.waitForFunction(() => { const activeScenes = window.__HEROS_GAME__?.scene.getScenes(true) ?? []; return activeScenes.some((scene) => scene.scene.key === 'BattleScene'); - }); + }, undefined, { timeout: 90000 }); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.phase === 'idle' && state?.mapBackgroundReady === true; - }); + }, undefined, { timeout: 90000 }); await page.keyboard.press('F9'); await page.waitForTimeout(100); @@ -1610,8 +1610,7 @@ try { throw new Error(`Expected fifteenth camp to expose Yuan refuge dialogue set, preserve expanded roster, and recruit Zhao Yun: ${JSON.stringify(fifteenthCampState)}`); } - await page.mouse.click(1120, 38); - await page.waitForTimeout(160); + await openSortiePrep(page); const fifteenthCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( !fifteenthCampSortieState?.sortieVisible || @@ -9850,6 +9849,26 @@ function assertSortieTacticalRoster(state, requiredUnitIds) { } } +async function openSortiePrep(page) { + for (let attempt = 0; attempt < 4; attempt += 1) { + const state = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + if (state?.sortieVisible) { + return; + } + + await page.mouse.click(1120, 38); + try { + await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.sortieVisible === true, undefined, { timeout: 1200 }); + return; + } catch { + await page.waitForTimeout(160); + } + } + + const state = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + throw new Error(`Cannot open sortie prep: ${JSON.stringify(state)}`); +} + async function clickSortieRosterUnit(page, unitId) { let state = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (!state?.sortieVisible || !Array.isArray(state.sortieRoster)) { @@ -9906,4 +9925,3 @@ async function clickReserveTrainingFocus(page, focusId) { await page.mouse.click(selectorX + 12 + index * (buttonWidth + 8) + buttonWidth / 2, 655); await page.waitForTimeout(120); } - diff --git a/src/game/data/portraitAssets.ts b/src/game/data/portraitAssets.ts index 63f07c7..eca70e7 100644 --- a/src/game/data/portraitAssets.ts +++ b/src/game/data/portraitAssets.ts @@ -56,6 +56,11 @@ export const portraitAssets: Record = Object.fromEntries( Object.entries(portraitModules).map(([path, url]) => [fileSlugFromPath(path), url]) ); +export type PortraitAssetEntry = { + textureKey: string; + url: string; +}; + export function portraitSlugForKey(key: string) { return legacyPortraitSlugs[key] ?? camelToKebab(key); } @@ -65,6 +70,18 @@ export function portraitTextureKey(key: string) { return portraitAssets[slug] ? `portrait-${slug}` : undefined; } +export function portraitAssetEntriesForKey(key: string): PortraitAssetEntry[] { + const slug = portraitSlugForKey(key); + const prefix = `${slug}-`; + return Object.keys(portraitAssets) + .filter((candidate) => candidate === slug || candidate.startsWith(prefix)) + .sort() + .map((candidate) => ({ + textureKey: `portrait-${candidate}`, + url: portraitAssets[candidate] + })); +} + export function portraitTextureKeysForKey(key: string) { const slug = portraitSlugForKey(key); const prefix = `${slug}-`; diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index caca6ce..b935ebe 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -24,6 +24,7 @@ import { type CampaignStep } from '../state/campaignState'; import { palette } from '../ui/palette'; +import { startLazyScene } from './lazyScenes'; const unitTexture: Record = { 'liu-bei': 'unit-liu-bei', @@ -4566,7 +4567,7 @@ export class BattleScene extends Phaser.Scene { if (outcome === 'victory') { this.addResultButton('군영으로', left + panelWidth - 422, top + 612, 132, () => { soundDirector.playSelect(); - this.scene.start('CampScene'); + void startLazyScene(this, 'CampScene'); }, depth + 3); } this.addResultButton('다시 하기', left + panelWidth - 276, top + 612, 124, () => { diff --git a/src/game/scenes/BootScene.ts b/src/game/scenes/BootScene.ts index a208139..6327ca2 100644 --- a/src/game/scenes/BootScene.ts +++ b/src/game/scenes/BootScene.ts @@ -1,7 +1,5 @@ import Phaser from 'phaser'; import titleBackgroundUrl from '../../assets/images/taoyuan-oath-title.png'; -import { portraitAssets } from '../data/portraitAssets'; -import { storyBackgroundAssets } from '../data/storyAssets'; export class BootScene extends Phaser.Scene { constructor() { @@ -10,8 +8,6 @@ export class BootScene extends Phaser.Scene { preload() { this.load.image('title-taoyuan', titleBackgroundUrl); - this.load.image('story-militia', storyBackgroundAssets['story-militia']); - Object.entries(portraitAssets).forEach(([key, url]) => this.load.image(`portrait-${key}`, url)); } create() { diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index 421ca36..9d96fa8 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -1,9 +1,11 @@ import Phaser from 'phaser'; +import storyMilitiaUrl from '../../assets/images/story/04-volunteer-militia.png'; import { soundDirector } from '../audio/SoundDirector'; import { equipmentExpToNext, equipmentSlotLabels, equipmentSlots, getItem, type EquipmentSlot } from '../data/battleItems'; import { getUnitClass, terrainRules, type TerrainType } from '../data/battleRules'; import { defaultBattleScenario, getBattleScenario, type BattleScenarioDefinition, type BattleScenarioId } from '../data/battles'; import { getSortieFlow } from '../data/campaignFlow'; +import { portraitAssetEntriesForKey, portraitTextureKey, type PortraitAssetEntry } from '../data/portraitAssets'; import { caoBreakRecruitBonds, caoBreakRecruitUnits, @@ -94,6 +96,7 @@ import { type FirstBattleReport } from '../state/campaignState'; import { palette } from '../ui/palette'; +import { startLazyScene } from './lazyScenes'; type CampTab = 'status' | 'dialogue' | 'visit' | 'supplies' | 'progress'; @@ -254,16 +257,6 @@ type CampaignTimelineChapter = { nextHints: string[]; }; -const portraitKeys: Record = { - liuBei: 'portrait-liu-bei', - guanYu: 'portrait-guan-yu', - zhangFei: 'portrait-zhang-fei', - zhugeLiang: 'portrait-zhuge-liang', - zhaoYun: 'portrait-zhao-yun', - jiangWei: 'portrait-jiang-wei', - simaYi: 'portrait-sima-yi' -}; - const portraitByUnitId: Record = { 'liu-bei': 'liuBei', 'guan-yu': 'guanYu', @@ -10028,7 +10021,10 @@ export class CampScene extends Phaser.Scene { this.selectedDialogueId = this.availableCampDialogues()[0]?.id ?? campDialogues[0].id; this.selectedVisitId = this.availableCampVisits()[0]?.id ?? campVisits[0].id; soundDirector.playMusic('militia-theme'); + this.ensureCampAssets(() => this.drawCampScene()); + } + private drawCampScene() { const { width, height } = this.scale; this.add.image(width / 2, height / 2, 'story-militia').setDisplaySize(width * 1.08, height * 1.08).setAlpha(0.62); this.add.rectangle(0, 0, width, height, 0x06090d, 0.54).setOrigin(0); @@ -10052,6 +10048,46 @@ export class CampScene extends Phaser.Scene { this.render(); } + private ensureCampAssets(onReady: () => void) { + const missingPortraits = this.currentUnits() + .map((unit) => { + const portraitKey = portraitByUnitId[unit.id]; + return portraitKey ? portraitAssetEntriesForKey(portraitKey)[0] : undefined; + }) + .filter((entry): entry is PortraitAssetEntry => entry !== undefined) + .filter((entry, index, entries) => entries.findIndex((candidate) => candidate.textureKey === entry.textureKey) === index) + .filter((entry) => !this.textures.exists(entry.textureKey)); + const missingMilitia = !this.textures.exists('story-militia'); + + if (!missingMilitia && missingPortraits.length === 0) { + onReady(); + return; + } + + const loadingText = this.add.text(this.scale.width / 2, this.scale.height / 2, 'Camp loading...', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '22px', + color: '#f5e6b8', + stroke: '#05070a', + strokeThickness: 4 + }); + loadingText.setOrigin(0.5); + + this.load.once('complete', () => { + loadingText.destroy(); + onReady(); + }); + this.load.once('loaderror', (file: Phaser.Loader.File) => { + console.warn(`Failed to load camp asset ${file.key}`); + }); + + if (missingMilitia) { + this.load.image('story-militia', storyMilitiaUrl); + } + missingPortraits.forEach(({ textureKey, url }) => this.load.image(textureKey, url)); + this.load.start(); + } + private createFallbackReport(): FirstBattleReport { const allies = firstBattleUnits.filter((unit) => unit.faction === 'ally'); return { @@ -12015,14 +12051,14 @@ export class CampScene extends Phaser.Scene { } markCampaignStep(flow.campaignStep); this.campaign = getCampaignState(); - this.scene.start('StoryScene', { + void startLazyScene(this, 'StoryScene', { pages: flow.pages, nextScene: flow.campaignStep === 'ending-complete' ? 'EndingScene' : 'CampScene' }); return; } if (flow.pages.length > 0) { - this.scene.start('StoryScene', { + void startLazyScene(this, 'StoryScene', { pages: flow.pages, nextScene: 'CampScene' }); @@ -12041,7 +12077,7 @@ export class CampScene extends Phaser.Scene { } markCampaignStep(flow.campaignStep); - this.scene.start('StoryScene', { + void startLazyScene(this, 'StoryScene', { pages: flow.pages, nextScene: 'BattleScene', nextSceneData: { battleId: flow.nextBattleId } @@ -12095,10 +12131,11 @@ export class CampScene extends Phaser.Scene { }); const portraitKey = portraitByUnitId[unit.id]; + const portraitTexture = portraitKey ? portraitTextureKey(portraitKey) : undefined; const portraitCenterY = y + cardHeight / 2; const portraitSize = compact ? 40 : 84; - if (portraitKey) { - const portrait = this.track(this.add.image(x + 58, portraitCenterY, portraitKeys[portraitKey])); + if (portraitTexture && this.textures.exists(portraitTexture)) { + const portrait = this.track(this.add.image(x + 58, portraitCenterY, portraitTexture)); portrait.setDisplaySize(portraitSize, portraitSize); } else { this.renderUnitFallbackPortrait(x + 58, portraitCenterY, compact ? 20 : 40, unit); @@ -12427,10 +12464,11 @@ export class CampScene extends Phaser.Scene { const unitClass = getUnitClass(unit.classKey); const portraitKey = portraitByUnitId[unit.id]; + const portraitTexture = portraitKey ? portraitTextureKey(portraitKey) : undefined; const portraitFrame = this.track(this.add.rectangle(x + 84, y + 90, 122, 122, 0x0b1219, 0.92)); portraitFrame.setStrokeStyle(2, palette.gold, 0.62); - if (portraitKey) { - const portrait = this.track(this.add.image(x + 84, y + 90, portraitKeys[portraitKey])); + if (portraitTexture && this.textures.exists(portraitTexture)) { + const portrait = this.track(this.add.image(x + 84, y + 90, portraitTexture)); portrait.setDisplaySize(112, 112); } else { this.renderUnitFallbackPortrait(x + 84, y + 90, 48, unit); diff --git a/src/game/scenes/StoryScene.ts b/src/game/scenes/StoryScene.ts index e4a98f0..ffd5e44 100644 --- a/src/game/scenes/StoryScene.ts +++ b/src/game/scenes/StoryScene.ts @@ -1,9 +1,10 @@ import Phaser from 'phaser'; import { soundDirector } from '../audio/SoundDirector'; -import { portraitKeyForSpeaker, portraitTextureKeysForKey } from '../data/portraitAssets'; +import { portraitAssetEntriesForKey, portraitKeyForSpeaker, type PortraitAssetEntry } from '../data/portraitAssets'; import { storyBackgroundAssets, storyBackgroundKeysFor } from '../data/storyAssets'; import { prologuePages, type PortraitKey, type StoryPage } from '../data/scenario'; import { palette } from '../ui/palette'; +import { startGameScene } from './lazyScenes'; type AlphaObject = Phaser.GameObjects.Image | Phaser.GameObjects.Rectangle | Phaser.GameObjects.Text; @@ -72,7 +73,7 @@ export class StoryScene extends Phaser.Scene { }); this.loadingText.setOrigin(0.5); - this.ensureStoryBackgroundsLoaded(() => { + this.ensureStoryAssetsLoaded(() => { this.loadingText?.destroy(); this.loadingText = undefined; this.background = this.add.image(width / 2, height / 2, this.resolveBackgroundKey(this.pageBackgroundKey(this.pages[0]))); @@ -86,16 +87,23 @@ export class StoryScene extends Phaser.Scene { }); } - private ensureStoryBackgroundsLoaded(onReady: () => void) { + private ensureStoryAssetsLoaded(onReady: () => void) { const requestedKeys = this.pages.flatMap((page) => storyBackgroundKeysFor(page.background)); 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 entry = this.pagePortraitAssetEntry(page); + return entry ? [entry] : []; + }) + .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}`)); - if (loadableKeys.length === 0) { + if (loadableKeys.length === 0 && missingPortraits.length === 0) { onReady(); return; } @@ -105,6 +113,7 @@ export class StoryScene extends Phaser.Scene { console.warn(`Failed to load story background ${file.key}`); }); loadableKeys.forEach((key) => this.load.image(key, storyBackgroundAssets[key])); + missingPortraits.forEach(({ textureKey, url }) => this.load.image(textureKey, url)); this.load.start(); } @@ -159,7 +168,7 @@ export class StoryScene extends Phaser.Scene { this.portraitFrame = this.add.rectangle(panelX + 86, panelY + 86, 136, 136, 0x090d12, 0.88); this.portraitFrame.setStrokeStyle(2, palette.gold, 0.58); - this.portrait = this.add.image(panelX + 86, panelY + 86, 'portrait-liu-bei'); + this.portrait = this.add.image(panelX + 86, panelY + 86, this.pagePortraitTextureKey(this.pages[0]) ?? 'story-fallback'); this.portrait.setDisplaySize(128, 128); this.portraitDivider = this.add.rectangle(panelX + 166, panelY + 28, 1, panelH - 56, palette.gold, 0.22).setOrigin(0); @@ -268,17 +277,22 @@ export class StoryScene extends Phaser.Scene { } private pagePortraitTextureKey(page: StoryPage) { + const entry = this.pagePortraitAssetEntry(page); + return entry && this.textures.exists(entry.textureKey) ? entry.textureKey : undefined; + } + + private pagePortraitAssetEntry(page: StoryPage): PortraitAssetEntry | undefined { const portraitKey = this.pagePortraitKey(page); if (!portraitKey) { return undefined; } - const keys = portraitTextureKeysForKey(portraitKey).filter((key) => this.textures.exists(key)); - if (keys.length <= 1) { - return keys[0]; + const entries = portraitAssetEntriesForKey(portraitKey); + if (entries.length === 0) { + return undefined; } - return keys[this.hashString(page.id) % keys.length]; + return entries[this.hashString(page.id) % entries.length]; } private pageBackgroundKey(page: StoryPage) { @@ -338,7 +352,11 @@ export class StoryScene extends Phaser.Scene { } if (this.pageIndex >= this.pages.length - 1) { - this.scene.start(this.nextScene, this.nextSceneData); + this.transitioning = true; + void startGameScene(this, this.nextScene, this.nextSceneData).catch((error) => { + console.error(`Failed to start ${this.nextScene}`, error); + this.transitioning = false; + }); return; } diff --git a/src/game/scenes/TitleScene.ts b/src/game/scenes/TitleScene.ts index 6621c76..2f586e4 100644 --- a/src/game/scenes/TitleScene.ts +++ b/src/game/scenes/TitleScene.ts @@ -1,85 +1,24 @@ import Phaser from 'phaser'; import { soundDirector } from '../audio/SoundDirector'; -import { firstBattleVictoryPages } from '../data/scenario'; -import { - eighthBattleScenario, - eighteenthBattleScenario, - eleventhBattleScenario, - fiftiethBattleScenario, - fiftyFirstBattleScenario, - fiftySecondBattleScenario, - fiftyThirdBattleScenario, - fiftyFourthBattleScenario, - fiftyFifthBattleScenario, - fiftySixthBattleScenario, - fiftySeventhBattleScenario, - fiftyEighthBattleScenario, - fiftyNinthBattleScenario, - sixtiethBattleScenario, - sixtyFirstBattleScenario, - sixtySecondBattleScenario, - sixtyThirdBattleScenario, - sixtyFourthBattleScenario, - sixtyFifthBattleScenario, - sixtySixthBattleScenario, - fortiethBattleScenario, - fortyFirstBattleScenario, - fortySecondBattleScenario, - fortyThirdBattleScenario, - fortyFourthBattleScenario, - fortyFifthBattleScenario, - fortyEighthBattleScenario, - fortyNinthBattleScenario, - fortySixthBattleScenario, - fortySeventhBattleScenario, - fifthBattleScenario, - fifteenthBattleScenario, - fourteenthBattleScenario, - fourthBattleScenario, - ninthBattleScenario, - nineteenthBattleScenario, - secondBattleScenario, - seventhBattleScenario, - sixthBattleScenario, - sixteenthBattleScenario, - seventeenthBattleScenario, - tenthBattleScenario, - thirteenthBattleScenario, - thirtyEighthBattleScenario, - thirtyFifthBattleScenario, - thirtyFourthBattleScenario, - thirtyFirstBattleScenario, - thirtyNinthBattleScenario, - thirtySecondBattleScenario, - thirtyThirdBattleScenario, - thirtySixthBattleScenario, - thirtySeventhBattleScenario, - thirtiethBattleScenario, - twentyEighthBattleScenario, - twentyFirstBattleScenario, - twentySecondBattleScenario, - twentyThirdBattleScenario, - twentyFifthBattleScenario, - twentyFourthBattleScenario, - twentyNinthBattleScenario, - twentySixthBattleScenario, - twentySeventhBattleScenario, - twentiethBattleScenario, - twelfthBattleScenario, - thirdBattleScenario -} from '../data/battles'; +import { battleIdForCampaignStep, isCampCampaignStep } from '../state/campaignRouting'; import { hasCampaignSave, loadCampaignState, startNewCampaign } from '../state/campaignState'; import { palette } from '../ui/palette'; +import { startLazyScene } from './lazyScenes'; export class TitleScene extends Phaser.Scene { private focusedButton?: Phaser.GameObjects.Text; private settingsPanel?: Phaser.GameObjects.Container; + private navigating = false; constructor() { super('TitleScene'); } create() { + this.focusedButton = undefined; + this.settingsPanel = undefined; + this.navigating = false; + const { width, height } = this.scale; this.drawBackground(width, height); this.drawAtmosphere(width, height); @@ -356,431 +295,54 @@ export class TitleScene extends Phaser.Scene { soundDirector.resume(); soundDirector.playSelect(); startNewCampaign(); - this.scene.start('StoryScene'); + void this.navigateTo('StoryScene'); } private continueGame() { soundDirector.start(); soundDirector.resume(); soundDirector.playSelect(); + void this.continueGameAsync(); + } + private async continueGameAsync() { const campaign = loadCampaignState(); if (campaign.step === 'ending-complete') { - this.scene.start('EndingScene'); + await this.navigateTo('EndingScene'); return; } - if ( - campaign.step === 'first-camp' || - campaign.step === 'second-camp' || - campaign.step === 'third-camp' || - campaign.step === 'fourth-camp' || - campaign.step === 'fifth-camp' || - campaign.step === 'sixth-camp' || - campaign.step === 'seventh-camp' || - campaign.step === 'eighth-camp' || - campaign.step === 'ninth-camp' || - campaign.step === 'tenth-camp' || - campaign.step === 'eleventh-camp' || - campaign.step === 'twelfth-camp' || - campaign.step === 'thirteenth-camp' || - campaign.step === 'fourteenth-camp' || - campaign.step === 'fifteenth-camp' || - campaign.step === 'sixteenth-camp' || - campaign.step === 'seventeenth-camp' || - campaign.step === 'eighteenth-camp' || - campaign.step === 'nineteenth-camp' || - campaign.step === 'twentieth-camp' || - campaign.step === 'twenty-first-camp' || - campaign.step === 'twenty-second-camp' || - campaign.step === 'twenty-third-camp' || - campaign.step === 'twenty-fourth-camp' || - campaign.step === 'twenty-fifth-camp' || - campaign.step === 'twenty-sixth-camp' || - campaign.step === 'twenty-seventh-camp' || - campaign.step === 'twenty-eighth-camp' || - campaign.step === 'twenty-ninth-camp' || - campaign.step === 'thirtieth-camp' || - campaign.step === 'thirty-first-camp' || - campaign.step === 'thirty-second-camp' || - campaign.step === 'thirty-third-camp' || - campaign.step === 'thirty-fourth-camp' || - campaign.step === 'thirty-fifth-camp' || - campaign.step === 'thirty-sixth-camp' || - campaign.step === 'thirty-seventh-camp' || - campaign.step === 'thirty-eighth-camp' || - campaign.step === 'thirty-ninth-camp' || - campaign.step === 'fortieth-camp' || - campaign.step === 'forty-first-camp' || - campaign.step === 'forty-second-camp' || - campaign.step === 'forty-third-camp' || - campaign.step === 'forty-fourth-camp' || - campaign.step === 'forty-fifth-camp' || - campaign.step === 'forty-sixth-camp' || - campaign.step === 'forty-seventh-camp' || - campaign.step === 'forty-eighth-camp' || - campaign.step === 'forty-ninth-camp' || - campaign.step === 'fiftieth-camp' || - campaign.step === 'fifty-first-camp' || - campaign.step === 'fifty-second-camp' || - campaign.step === 'fifty-third-camp' || - campaign.step === 'fifty-fourth-camp' || - campaign.step === 'fifty-fifth-camp' || - campaign.step === 'fifty-sixth-camp' || - campaign.step === 'fifty-seventh-camp' || - campaign.step === 'fifty-eighth-camp' || - campaign.step === 'fifty-ninth-camp' || - campaign.step === 'sixtieth-camp' || - campaign.step === 'sixty-first-camp' || - campaign.step === 'sixty-second-camp' || - campaign.step === 'sixty-third-camp' || - campaign.step === 'sixty-fourth-camp' || - campaign.step === 'sixty-fifth-camp' || - campaign.step === 'sixty-sixth-camp' || - campaign.step === 'hanzhong-king-camp' || - campaign.step === 'shu-han-foundation-camp' || - campaign.step === 'baidi-entrustment-camp' || - campaign.step === 'northern-campaign-prep-camp' - ) { - this.scene.start('CampScene'); + if (isCampCampaignStep(campaign.step)) { + await this.navigateTo('CampScene'); return; } - if (campaign.step === 'first-battle') { - this.scene.start('BattleScene'); - return; - } - - if (campaign.step === 'second-battle') { - this.scene.start('BattleScene', { battleId: secondBattleScenario.id }); - return; - } - - if (campaign.step === 'third-battle') { - this.scene.start('BattleScene', { battleId: thirdBattleScenario.id }); - return; - } - - if (campaign.step === 'fourth-battle') { - this.scene.start('BattleScene', { battleId: fourthBattleScenario.id }); - return; - } - - if (campaign.step === 'fifth-battle') { - this.scene.start('BattleScene', { battleId: fifthBattleScenario.id }); - return; - } - - if (campaign.step === 'sixth-battle') { - this.scene.start('BattleScene', { battleId: sixthBattleScenario.id }); - return; - } - - if (campaign.step === 'seventh-battle') { - this.scene.start('BattleScene', { battleId: seventhBattleScenario.id }); - return; - } - - if (campaign.step === 'eighth-battle') { - this.scene.start('BattleScene', { battleId: eighthBattleScenario.id }); - return; - } - - if (campaign.step === 'ninth-battle') { - this.scene.start('BattleScene', { battleId: ninthBattleScenario.id }); - return; - } - - if (campaign.step === 'tenth-battle') { - this.scene.start('BattleScene', { battleId: tenthBattleScenario.id }); - return; - } - - if (campaign.step === 'eleventh-battle') { - this.scene.start('BattleScene', { battleId: eleventhBattleScenario.id }); - return; - } - - if (campaign.step === 'twelfth-battle') { - this.scene.start('BattleScene', { battleId: twelfthBattleScenario.id }); - return; - } - - if (campaign.step === 'thirteenth-battle') { - this.scene.start('BattleScene', { battleId: thirteenthBattleScenario.id }); - return; - } - - if (campaign.step === 'fourteenth-battle') { - this.scene.start('BattleScene', { battleId: fourteenthBattleScenario.id }); - return; - } - - if (campaign.step === 'fifteenth-battle') { - this.scene.start('BattleScene', { battleId: fifteenthBattleScenario.id }); - return; - } - - if (campaign.step === 'sixteenth-battle') { - this.scene.start('BattleScene', { battleId: sixteenthBattleScenario.id }); - return; - } - - if (campaign.step === 'seventeenth-battle') { - this.scene.start('BattleScene', { battleId: seventeenthBattleScenario.id }); - return; - } - - if (campaign.step === 'eighteenth-battle') { - this.scene.start('BattleScene', { battleId: eighteenthBattleScenario.id }); - return; - } - - if (campaign.step === 'nineteenth-battle') { - this.scene.start('BattleScene', { battleId: nineteenthBattleScenario.id }); - return; - } - - if (campaign.step === 'twentieth-battle') { - this.scene.start('BattleScene', { battleId: twentiethBattleScenario.id }); - return; - } - - if (campaign.step === 'twenty-first-battle') { - this.scene.start('BattleScene', { battleId: twentyFirstBattleScenario.id }); - return; - } - - if (campaign.step === 'twenty-second-battle') { - this.scene.start('BattleScene', { battleId: twentySecondBattleScenario.id }); - return; - } - - if (campaign.step === 'twenty-third-battle') { - this.scene.start('BattleScene', { battleId: twentyThirdBattleScenario.id }); - return; - } - - if (campaign.step === 'twenty-fourth-battle') { - this.scene.start('BattleScene', { battleId: twentyFourthBattleScenario.id }); - return; - } - - if (campaign.step === 'twenty-fifth-battle') { - this.scene.start('BattleScene', { battleId: twentyFifthBattleScenario.id }); - return; - } - - if (campaign.step === 'twenty-sixth-battle') { - this.scene.start('BattleScene', { battleId: twentySixthBattleScenario.id }); - return; - } - - if (campaign.step === 'twenty-seventh-battle') { - this.scene.start('BattleScene', { battleId: twentySeventhBattleScenario.id }); - return; - } - - if (campaign.step === 'twenty-eighth-battle') { - this.scene.start('BattleScene', { battleId: twentyEighthBattleScenario.id }); - return; - } - - if (campaign.step === 'twenty-ninth-battle') { - this.scene.start('BattleScene', { battleId: twentyNinthBattleScenario.id }); - return; - } - - if (campaign.step === 'thirtieth-battle') { - this.scene.start('BattleScene', { battleId: thirtiethBattleScenario.id }); - return; - } - - if (campaign.step === 'thirty-first-battle') { - this.scene.start('BattleScene', { battleId: thirtyFirstBattleScenario.id }); - return; - } - - if (campaign.step === 'thirty-second-battle') { - this.scene.start('BattleScene', { battleId: thirtySecondBattleScenario.id }); - return; - } - - if (campaign.step === 'thirty-third-battle') { - this.scene.start('BattleScene', { battleId: thirtyThirdBattleScenario.id }); - return; - } - - if (campaign.step === 'thirty-fourth-battle') { - this.scene.start('BattleScene', { battleId: thirtyFourthBattleScenario.id }); - return; - } - - if (campaign.step === 'thirty-fifth-battle') { - this.scene.start('BattleScene', { battleId: thirtyFifthBattleScenario.id }); - return; - } - - if (campaign.step === 'thirty-sixth-battle') { - this.scene.start('BattleScene', { battleId: thirtySixthBattleScenario.id }); - return; - } - - if (campaign.step === 'thirty-seventh-battle') { - this.scene.start('BattleScene', { battleId: thirtySeventhBattleScenario.id }); - return; - } - - if (campaign.step === 'thirty-eighth-battle') { - this.scene.start('BattleScene', { battleId: thirtyEighthBattleScenario.id }); - return; - } - - if (campaign.step === 'thirty-ninth-battle') { - this.scene.start('BattleScene', { battleId: thirtyNinthBattleScenario.id }); - return; - } - - if (campaign.step === 'fortieth-battle') { - this.scene.start('BattleScene', { battleId: fortiethBattleScenario.id }); - return; - } - - if (campaign.step === 'forty-first-battle') { - this.scene.start('BattleScene', { battleId: fortyFirstBattleScenario.id }); - return; - } - - if (campaign.step === 'forty-second-battle') { - this.scene.start('BattleScene', { battleId: fortySecondBattleScenario.id }); - return; - } - - if (campaign.step === 'forty-third-battle') { - this.scene.start('BattleScene', { battleId: fortyThirdBattleScenario.id }); - return; - } - - if (campaign.step === 'forty-fourth-battle') { - this.scene.start('BattleScene', { battleId: fortyFourthBattleScenario.id }); - return; - } - - if (campaign.step === 'forty-fifth-battle') { - this.scene.start('BattleScene', { battleId: fortyFifthBattleScenario.id }); - return; - } - - if (campaign.step === 'forty-sixth-battle') { - this.scene.start('BattleScene', { battleId: fortySixthBattleScenario.id }); - return; - } - - if (campaign.step === 'forty-seventh-battle') { - this.scene.start('BattleScene', { battleId: fortySeventhBattleScenario.id }); - return; - } - - if (campaign.step === 'forty-eighth-battle') { - this.scene.start('BattleScene', { battleId: fortyEighthBattleScenario.id }); - return; - } - - if (campaign.step === 'forty-ninth-battle') { - this.scene.start('BattleScene', { battleId: fortyNinthBattleScenario.id }); - return; - } - - if (campaign.step === 'fiftieth-battle') { - this.scene.start('BattleScene', { battleId: fiftiethBattleScenario.id }); - return; - } - - if (campaign.step === 'fifty-first-battle') { - this.scene.start('BattleScene', { battleId: fiftyFirstBattleScenario.id }); - return; - } - - if (campaign.step === 'fifty-second-battle') { - this.scene.start('BattleScene', { battleId: fiftySecondBattleScenario.id }); - return; - } - - if (campaign.step === 'fifty-third-battle') { - this.scene.start('BattleScene', { battleId: fiftyThirdBattleScenario.id }); - return; - } - - if (campaign.step === 'fifty-fourth-battle') { - this.scene.start('BattleScene', { battleId: fiftyFourthBattleScenario.id }); - return; - } - - if (campaign.step === 'fifty-fifth-battle') { - this.scene.start('BattleScene', { battleId: fiftyFifthBattleScenario.id }); - return; - } - - if (campaign.step === 'fifty-sixth-battle') { - this.scene.start('BattleScene', { battleId: fiftySixthBattleScenario.id }); - return; - } - - if (campaign.step === 'fifty-seventh-battle') { - this.scene.start('BattleScene', { battleId: fiftySeventhBattleScenario.id }); - return; - } - - if (campaign.step === 'fifty-eighth-battle') { - this.scene.start('BattleScene', { battleId: fiftyEighthBattleScenario.id }); - return; - } - - if (campaign.step === 'fifty-ninth-battle') { - this.scene.start('BattleScene', { battleId: fiftyNinthBattleScenario.id }); - return; - } - - if (campaign.step === 'sixtieth-battle') { - this.scene.start('BattleScene', { battleId: sixtiethBattleScenario.id }); - return; - } - - if (campaign.step === 'sixty-first-battle') { - this.scene.start('BattleScene', { battleId: sixtyFirstBattleScenario.id }); - return; - } - - if (campaign.step === 'sixty-second-battle') { - this.scene.start('BattleScene', { battleId: sixtySecondBattleScenario.id }); - return; - } - - if (campaign.step === 'sixty-third-battle') { - this.scene.start('BattleScene', { battleId: sixtyThirdBattleScenario.id }); - return; - } - - if (campaign.step === 'sixty-fourth-battle') { - this.scene.start('BattleScene', { battleId: sixtyFourthBattleScenario.id }); - return; - } - - if (campaign.step === 'sixty-fifth-battle') { - this.scene.start('BattleScene', { battleId: sixtyFifthBattleScenario.id }); - return; - } - - if (campaign.step === 'sixty-sixth-battle') { - this.scene.start('BattleScene', { battleId: sixtySixthBattleScenario.id }); + const battleId = battleIdForCampaignStep(campaign.step); + if (battleId) { + await this.navigateTo('BattleScene', { battleId }); return; } if (campaign.step === 'first-victory-story') { - this.scene.start('StoryScene', { pages: firstBattleVictoryPages, nextScene: 'TitleScene' }); + const { firstBattleVictoryPages } = await import('../data/scenario'); + await this.navigateTo('StoryScene', { pages: firstBattleVictoryPages, nextScene: 'TitleScene' }); return; } - this.scene.start('StoryScene'); + await this.navigateTo('StoryScene'); + } + + private async navigateTo(sceneKey: 'StoryScene' | 'BattleScene' | 'CampScene' | 'EndingScene', data?: Record) { + if (this.navigating) { + return; + } + + this.navigating = true; + try { + await startLazyScene(this, sceneKey, data); + } catch (error) { + console.error(`Failed to start ${sceneKey}`, error); + this.navigating = false; + } } } diff --git a/src/game/scenes/lazyScenes.ts b/src/game/scenes/lazyScenes.ts new file mode 100644 index 0000000..bf48906 --- /dev/null +++ b/src/game/scenes/lazyScenes.ts @@ -0,0 +1,57 @@ +import Phaser from 'phaser'; + +export type LazySceneKey = 'StoryScene' | 'BattleScene' | 'CampScene' | 'EndingScene'; + +type LazySceneConstructor = new () => Phaser.Scene; + +const lazySceneLoaders: Record Promise> = { + StoryScene: () => import('./StoryScene').then((module) => module.StoryScene), + BattleScene: () => import('./BattleScene').then((module) => module.BattleScene), + CampScene: () => import('./CampScene').then((module) => module.CampScene), + EndingScene: () => import('./EndingScene').then((module) => module.EndingScene) +}; + +const pendingSceneLoads = new Map>(); + +export function isLazySceneKey(key: string): key is LazySceneKey { + return key === 'StoryScene' || key === 'BattleScene' || key === 'CampScene' || key === 'EndingScene'; +} + +export async function ensureLazyScene(game: Phaser.Game, key: LazySceneKey) { + if (game.scene.getScene(key)) { + return; + } + + const pendingLoad = pendingSceneLoads.get(key); + if (pendingLoad) { + await pendingLoad; + return; + } + + const load = lazySceneLoaders[key]().then((SceneClass) => { + if (!game.scene.getScene(key)) { + game.scene.add(key, SceneClass, false); + } + }); + pendingSceneLoads.set(key, load); + await load; +} + +export async function startLazyScene(scene: Phaser.Scene, key: LazySceneKey, data?: Record) { + await ensureLazyScene(scene.game, key); + scene.scene.start(key, data); +} + +export async function startGameScene(scene: Phaser.Scene, key: string, data?: Record) { + if (isLazySceneKey(key)) { + await startLazyScene(scene, key, data); + return; + } + + scene.scene.start(key, data); +} + +export async function startLazySceneFromGame(game: Phaser.Game, key: LazySceneKey, data?: Record) { + await ensureLazyScene(game, key); + game.scene.start(key, data); +} diff --git a/src/game/state/campaignRouting.ts b/src/game/state/campaignRouting.ts new file mode 100644 index 0000000..036247f --- /dev/null +++ b/src/game/state/campaignRouting.ts @@ -0,0 +1,79 @@ +import type { BattleScenarioId } from '../data/battles'; +import type { CampaignStep } from './campaignState'; + +const battleIdByCampaignStep: Partial> = { + 'first-battle': 'first-battle-zhuo-commandery', + 'second-battle': 'second-battle-yellow-turban-pursuit', + 'third-battle': 'third-battle-guangzong-road', + 'fourth-battle': 'fourth-battle-guangzong-camp', + 'fifth-battle': 'fifth-battle-sishui-vanguard', + 'sixth-battle': 'sixth-battle-jieqiao-relief', + 'seventh-battle': 'seventh-battle-xuzhou-rescue', + 'eighth-battle': 'eighth-battle-xiaopei-supply-road', + 'ninth-battle': 'ninth-battle-xuzhou-gate-night-raid', + 'tenth-battle': 'tenth-battle-xuzhou-breakout', + 'eleventh-battle': 'eleventh-battle-xudu-refuge-road', + 'twelfth-battle': 'twelfth-battle-xiapi-outer-siege', + 'thirteenth-battle': 'thirteenth-battle-xiapi-final', + 'fourteenth-battle': 'fourteenth-battle-cao-break', + 'fifteenth-battle': 'fifteenth-battle-yuan-refuge-road', + 'sixteenth-battle': 'sixteenth-battle-liu-biao-refuge', + 'seventeenth-battle': 'seventeenth-battle-wolong-visit-road', + 'eighteenth-battle': 'eighteenth-battle-bowang-ambush', + 'nineteenth-battle': 'nineteenth-battle-changban-refuge', + 'twentieth-battle': 'twentieth-battle-jiangdong-envoy', + 'twenty-first-battle': 'twenty-first-battle-red-cliffs-vanguard', + 'twenty-second-battle': 'twenty-second-battle-red-cliffs-fire', + 'twenty-third-battle': 'twenty-third-battle-jingzhou-south-entry', + 'twenty-fourth-battle': 'twenty-fourth-battle-guiyang-persuasion', + 'twenty-fifth-battle': 'twenty-fifth-battle-wuling-mountain-road', + 'twenty-sixth-battle': 'twenty-sixth-battle-changsha-veteran', + 'twenty-seventh-battle': 'twenty-seventh-battle-yizhou-relief-road', + 'twenty-eighth-battle': 'twenty-eighth-battle-fu-pass-entry', + 'twenty-ninth-battle': 'twenty-ninth-battle-luo-outer-wall', + 'thirtieth-battle': 'thirtieth-battle-luofeng-ambush', + 'thirty-first-battle': 'thirty-first-battle-luo-main-gate', + 'thirty-second-battle': 'thirty-second-battle-mianzhu-gate', + 'thirty-third-battle': 'thirty-third-battle-chengdu-surrender', + 'thirty-fourth-battle': 'thirty-fourth-battle-jiameng-pass', + 'thirty-fifth-battle': 'thirty-fifth-battle-yangping-scout', + 'thirty-sixth-battle': 'thirty-sixth-battle-dingjun-vanguard', + 'thirty-seventh-battle': 'thirty-seventh-battle-hanzhong-decisive', + 'thirty-eighth-battle': 'thirty-eighth-battle-jing-defense', + 'thirty-ninth-battle': 'thirty-ninth-battle-fan-castle-vanguard', + 'fortieth-battle': 'fortieth-battle-han-river-flood', + 'forty-first-battle': 'forty-first-battle-fan-castle-siege', + 'forty-second-battle': 'forty-second-battle-jing-rear-crisis', + 'forty-third-battle': 'forty-third-battle-gongan-collapse', + 'forty-fourth-battle': 'forty-fourth-battle-maicheng-isolation', + 'forty-fifth-battle': 'forty-fifth-battle-yiling-vanguard', + 'forty-sixth-battle': 'forty-sixth-battle-yiling-fire', + 'forty-seventh-battle': 'forty-seventh-battle-nanzhong-stabilization', + 'forty-eighth-battle': 'forty-eighth-battle-meng-huo-main-force', + 'forty-ninth-battle': 'forty-ninth-battle-meng-huo-second-capture', + 'fiftieth-battle': 'fiftieth-battle-meng-huo-third-capture', + 'fifty-first-battle': 'fifty-first-battle-meng-huo-fourth-capture', + 'fifty-second-battle': 'fifty-second-battle-meng-huo-fifth-capture', + 'fifty-third-battle': 'fifty-third-battle-meng-huo-sixth-capture', + 'fifty-fourth-battle': 'fifty-fourth-battle-meng-huo-final-capture', + 'fifty-fifth-battle': 'fifty-fifth-battle-northern-qishan-road', + 'fifty-sixth-battle': 'fifty-sixth-battle-tianshui-advance', + 'fifty-seventh-battle': 'fifty-seventh-battle-jieting-crisis', + 'fifty-eighth-battle': 'fifty-eighth-battle-qishan-retreat', + 'fifty-ninth-battle': 'fifty-ninth-battle-chencang-siege', + 'sixtieth-battle': 'sixtieth-battle-wudu-yinping', + 'sixty-first-battle': 'sixty-first-battle-hanzhong-rain-defense', + 'sixty-second-battle': 'sixty-second-battle-qishan-renewed-offensive', + 'sixty-third-battle': 'sixty-third-battle-lucheng-pursuit', + 'sixty-fourth-battle': 'sixty-fourth-battle-weishui-camps', + 'sixty-fifth-battle': 'sixty-fifth-battle-weishui-northbank', + 'sixty-sixth-battle': 'sixty-sixth-battle-wuzhang-final' +}; + +export function battleIdForCampaignStep(step: CampaignStep) { + return battleIdByCampaignStep[step]; +} + +export function isCampCampaignStep(step: CampaignStep) { + return step.endsWith('-camp'); +} diff --git a/src/main.ts b/src/main.ts index 6136c4d..476aeda 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,11 +1,8 @@ import Phaser from 'phaser'; import { effectTracks, musicTracks } from './game/audio/audioAssets'; import { soundDirector } from './game/audio/SoundDirector'; -import { BattleScene } from './game/scenes/BattleScene'; import { BootScene } from './game/scenes/BootScene'; -import { CampScene } from './game/scenes/CampScene'; -import { EndingScene } from './game/scenes/EndingScene'; -import { StoryScene } from './game/scenes/StoryScene'; +import { startLazySceneFromGame } from './game/scenes/lazyScenes'; import { TitleScene } from './game/scenes/TitleScene'; import './styles/global.css'; @@ -31,15 +28,23 @@ type HerosDebugApi = { activeScenes: () => string[]; battle: () => unknown; camp: () => unknown; - goToBattle: () => void; - goToCamp: () => void; + goToBattle: (battleId?: string) => Promise; + goToCamp: () => Promise; forceBattleOutcome: (outcome: 'victory' | 'defeat') => void; scene: (key: string) => Phaser.Scene | undefined; toggleBattleOverlay: () => void; }; +const searchParams = new URLSearchParams(window.location.search); +const rendererType = + searchParams.get('renderer') === 'canvas' + ? Phaser.CANVAS + : searchParams.get('renderer') === 'webgl' + ? Phaser.WEBGL + : Phaser.AUTO; + const config: Phaser.Types.Core.GameConfig = { - type: Phaser.AUTO, + type: rendererType, parent: 'game', width: 1280, height: 720, @@ -48,15 +53,16 @@ const config: Phaser.Types.Core.GameConfig = { mode: Phaser.Scale.RESIZE, autoCenter: Phaser.Scale.CENTER_BOTH }, - scene: [BootScene, TitleScene, StoryScene, BattleScene, CampScene, EndingScene] + scene: [BootScene, TitleScene] }; soundDirector.registerMusicTracks(musicTracks); soundDirector.registerEffectTracks(effectTracks); const game = new Phaser.Game(config); +const debugEnabled = import.meta.env.DEV || searchParams.has('debug'); -if (import.meta.env.DEV) { +if (debugEnabled) { window.__HEROS_GAME__ = game; window.__HEROS_DEBUG__ = createDebugApi(game); console.info('[Heros Debug] Use window.__HEROS_DEBUG__.battle() or press F9 in battle.'); @@ -72,12 +78,8 @@ function createDebugApi(game: Phaser.Game): HerosDebugApi { activeScenes: () => game.scene.getScenes(true).map((activeScene) => activeScene.scene.key), battle: () => battleScene()?.getDebugState?.() ?? { active: false, reason: 'BattleScene is not active yet.' }, camp: () => campScene()?.getDebugState?.() ?? { active: false, reason: 'CampScene is not active yet.' }, - goToBattle: () => { - game.scene.start('BattleScene'); - }, - goToCamp: () => { - game.scene.start('CampScene'); - }, + goToBattle: (battleId) => startLazySceneFromGame(game, 'BattleScene', battleId ? { battleId } : undefined), + goToCamp: () => startLazySceneFromGame(game, 'CampScene'), forceBattleOutcome: (outcome) => { battleScene()?.debugForceBattleOutcome?.(outcome); }, diff --git a/vite.config.ts b/vite.config.ts index 5cb7214..cf9b173 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,6 +6,6 @@ export default defineConfig({ port: 5173 }, build: { - sourcemap: true + sourcemap: process.env.VITE_BUILD_SOURCEMAP === 'true' } });