diff --git a/package.json b/package.json index 7322791..d0214b9 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,8 @@ "verify:camp-rewards": "node scripts/verify-camp-reward-data.mjs", "verify:city-stays": "node scripts/verify-city-stay-data.mjs", "verify:city-stays:browser": "node scripts/verify-city-stay-browser.mjs", + "verify:prologue-village": "node scripts/verify-prologue-village-data.mjs", + "verify:prologue-village:browser": "node scripts/verify-prologue-village-browser.mjs", "verify:camp-skins": "node scripts/verify-camp-skin-data.mjs", "verify:camp-soundscapes": "node scripts/verify-camp-soundscape-data.mjs", "verify:sound-director": "node scripts/verify-sound-director.mjs", @@ -61,7 +63,7 @@ "verify:interaction-ux": "node scripts/verify-interaction-ux.mjs", "verify:save-flow": "node scripts/verify-save-retry-flow.mjs", "verify:release": "node scripts/verify-release-candidate.mjs", - "verify:local-release": "pnpm run verify:static-data && pnpm run verify:unit-action-assets && pnpm run verify:enemy-intent && tsc --noEmit && pnpm run build && pnpm run verify:unit-action-assets:dist && pnpm run verify:loader-lifecycle && pnpm run verify:interaction-ux && pnpm run verify:city-stays:browser && pnpm run verify:av-feedback:browser && pnpm run verify:release && pnpm run verify:performance", + "verify:local-release": "pnpm run verify:static-data && pnpm run verify:unit-action-assets && pnpm run verify:enemy-intent && tsc --noEmit && pnpm run build && pnpm run verify:unit-action-assets:dist && pnpm run verify:loader-lifecycle && pnpm run verify:interaction-ux && pnpm run verify:city-stays:browser && pnpm run verify:prologue-village:browser && pnpm run verify:av-feedback:browser && pnpm run verify:release && pnpm run verify:performance", "verify:1.0": "pnpm run verify:local-release && pnpm run verify:flow && pnpm run qa:campaign-complete && pnpm run qa:battle-maps:webgl", "verify:public-deploy": "node scripts/verify-public-deploy.mjs", "qa:representative": "node scripts/qa-representative-battles.mjs", diff --git a/scripts/measure-performance.mjs b/scripts/measure-performance.mjs index d6b17c3..7e241c6 100644 --- a/scripts/measure-performance.mjs +++ b/scripts/measure-performance.mjs @@ -331,7 +331,8 @@ async function advanceStoryUntilBattle(page, battleId) { storyReady: story?.ready === true, storyLastPage: story?.isLastPage === true, storyTargetsBattle: story?.nextScene === 'BattleScene', - currentPageReady: story?.assetStreaming?.currentPageReady === true + currentPageReady: story?.assetStreaming?.currentPageReady === true, + villageReady: window.__HEROS_DEBUG__?.village?.()?.ready === true }; } catch { return {}; @@ -348,6 +349,18 @@ async function advanceStoryUntilBattle(page, battleId) { return { startedAt, resourceEntries: entries }; } + if (state.villageReady) { + const advanced = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('PrologueVillageScene'); + return scene?.debugCompleteVillage?.() ?? false; + }); + if (!advanced) { + throw new Error('Playable prologue village was ready but could not advance to the departure story.'); + } + await page.waitForTimeout(400); + continue; + } + await page.keyboard.press('Space'); await page.waitForTimeout(220); } diff --git a/scripts/verify-campaign-presentation-profiles.mjs b/scripts/verify-campaign-presentation-profiles.mjs index 2bc48a6..89c205f 100644 --- a/scripts/verify-campaign-presentation-profiles.mjs +++ b/scripts/verify-campaign-presentation-profiles.mjs @@ -369,10 +369,20 @@ try { ].forEach((requiredSource) => { assert(campSceneSource.includes(requiredSource), `CampScene presentation integration: ${requiredSource}`); }); + const prologueOpeningRouteCount = titleSceneSource.split('pages: prologueOpeningPages()').length - 1; + [ + "nextScene: 'PrologueVillageScene'", + "campaign.step === 'prologue-town'", + 'pages: prologueDeparturePages()', + "nextScene: 'BattleScene'", + "presentationBattleId: 'first-battle-zhuo-commandery'", + "presentationStage: 'story'" + ].forEach((requiredSource) => { + assert(titleSceneSource.includes(requiredSource), `TitleScene prologue presentation route: ${requiredSource}`); + }); assert( - titleSceneSource.split("presentationStage: 'story'").length - 1 === 2 && - titleSceneSource.split("presentationBattleId: 'first-battle-zhuo-commandery'").length - 1 >= 2, - 'TitleScene must apply the first campaign arc to both new-game and resumed prologue stories' + prologueOpeningRouteCount >= 2, + 'TitleScene must apply the first campaign arc to both new-game and resumed prologue openings' ); if (errors.length > 0) { diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs index 44a3e6e..df69266 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -471,6 +471,21 @@ try { break; } + const villageAdvanced = await page.evaluate(() => { + const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; + if (!activeScenes.includes('PrologueVillageScene')) { + return false; + } + const scene = window.__HEROS_GAME__?.scene.getScene('PrologueVillageScene'); + return scene?.getDebugState?.()?.ready === true + ? scene?.debugCompleteVillage?.() ?? false + : false; + }); + if (villageAdvanced) { + await page.waitForTimeout(320); + continue; + } + await page.keyboard.press('Space'); await page.waitForTimeout(220); } diff --git a/scripts/verify-prologue-village-browser.mjs b/scripts/verify-prologue-village-browser.mjs new file mode 100644 index 0000000..bcc10eb --- /dev/null +++ b/scripts/verify-prologue-village-browser.mjs @@ -0,0 +1,415 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdirSync } from 'node:fs'; +import { chromium } from 'playwright'; +import { + desktopBrowserContextOptions, + desktopBrowserDeviceScaleFactor, + desktopBrowserViewport +} from './desktop-browser-viewport.mjs'; + +const targetUrl = withDebugOptions( + process.env.VERIFY_PROLOGUE_VILLAGE_URL ?? 'http://127.0.0.1:41796/' +); + +let serverProcess; +let browser; + +try { + mkdirSync('dist', { recursive: true }); + serverProcess = await ensureLocalServer(targetUrl); + browser = await chromium.launch({ + headless: process.env.VERIFY_PROLOGUE_VILLAGE_HEADLESS !== '0' + }); + const context = await browser.newContext(desktopBrowserContextOptions); + const page = await context.newPage(); + page.setDefaultTimeout(30000); + + const pageErrors = []; + const consoleErrors = []; + page.on('pageerror', (error) => pageErrors.push(error.stack ?? error.message)); + page.on('console', (message) => { + if (message.type() === 'error') { + consoleErrors.push(message.text()); + } + }); + + await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 90000 }); + await page.evaluate(() => window.localStorage.clear()); + await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 }); + await waitForDebugApi(page); + await waitForTitle(page); + await assertDesktopViewport(page); + + await clickLegacyTitleControl(page, 962, 240); + await waitForStoryReady(page); + const opening = await readStory(page); + assert.equal(opening.totalPages, 2, 'The playable prologue should retain only the two setup pages before control is handed over.'); + assert.equal(opening.nextScene, 'PrologueVillageScene'); + assert.equal(opening.currentPageId, 'yellow-turban-chaos'); + await page.mouse.click(960, 850); + await page.waitForFunction(() => { + const scene = window.__HEROS_DEBUG__?.scene('StoryScene'); + return scene?.getDebugState?.()?.currentPageId === 'liu-bei-resolve' && scene?.transitioning === false; + }); + await page.keyboard.press('Space'); + await waitForVillageReady(page); + + await page.waitForTimeout(380); + let village = await readVillage(page); + assert.equal(village.campaignStep, 'prologue-town'); + assert.equal(village.requiredTexturesReady, true); + assert.equal(village.viewport.width, desktopBrowserViewport.width); + assert.equal(village.viewport.height, desktopBrowserViewport.height); + assert.equal(village.movement.speed, 300); + assert.equal(village.interaction.radius, 122); + assert.equal(village.npcs.length, 4); + assert.equal(village.objectives.length, 4); + assert.equal(village.completedObjectiveIds.length, 0); + assert.equal(village.exitUnlocked, false); + assert.equal(village.dialogue.active, true, 'The first visit should open one short Liu Bei orientation line.'); + assertBoundsInsideViewport(village.player.bounds, 'initial player'); + village.npcs.forEach((npc) => assertBoundsInsideViewport(npc.bounds, `NPC ${npc.id}`)); + + await page.mouse.click(960, 850); + await page.waitForFunction(() => window.__HEROS_DEBUG__?.village?.()?.dialogue?.active === false); + await captureStableScreenshot(page, 'dist/verification-prologue-village-initial.png'); + + const beforeMove = await readVillage(page); + await page.keyboard.down('d'); + await page.waitForTimeout(430); + await page.keyboard.up('d'); + await page.waitForTimeout(80); + const afterMove = await readVillage(page); + assert( + afterMove.player.x - beforeMove.player.x >= 85, + `Expected direct keyboard movement to advance Liu Bei: ${JSON.stringify({ before: beforeMove.player, after: afterMove.player })}` + ); + assert( + Math.abs(afterMove.player.y - beforeMove.player.y) <= 3, + `Expected horizontal movement to retain Y: ${JSON.stringify({ before: beforeMove.player, after: afterMove.player })}` + ); + assert.equal(afterMove.player.moving, false); + + await page.keyboard.press('e'); + await page.waitForTimeout(120); + assert.equal((await readVillage(page)).dialogue.active, false, 'Interaction outside the radius must not open dialogue.'); + + await teleportTo(page, 'zhang-fei'); + await page.keyboard.press('e'); + await page.waitForFunction(() => window.__HEROS_DEBUG__?.village?.()?.dialogue?.sourceNpcId === 'zhang-fei'); + await captureStableScreenshot(page, 'dist/verification-prologue-village-active-dialogue.png'); + const dialoguePlayerBefore = (await readVillage(page)).player; + await page.keyboard.down('ArrowRight'); + await page.waitForTimeout(360); + await page.keyboard.up('ArrowRight'); + const dialoguePlayerAfter = (await readVillage(page)).player; + assert( + Math.abs(dialoguePlayerAfter.x - dialoguePlayerBefore.x) < 1 && + Math.abs(dialoguePlayerAfter.y - dialoguePlayerBefore.y) < 1, + 'Movement must remain blocked while dialogue is open.' + ); + await advanceDialogueUntilClosed(page); + village = await readVillage(page); + assert(village.completedObjectiveIds.includes('meet-zhang-fei')); + assert.equal(village.exitUnlocked, false); + await captureStableScreenshot(page, 'dist/verification-prologue-village-dialogue.png'); + + await teleportTo(page, 'make-oath'); + await page.keyboard.press('e'); + await page.waitForFunction(() => window.__HEROS_DEBUG__?.village?.()?.dialogue?.active === true); + const lockedOath = await readVillage(page); + assert.equal(lockedOath.navigationPending, false); + assert.equal(lockedOath.dialogue.totalLines, 1); + await advanceDialogueUntilClosed(page); + assert.equal((await readVillage(page)).exitUnlocked, false, 'The oath must stay locked before all preparation goals are complete.'); + + await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 }); + await waitForDebugApi(page); + await page.waitForFunction(() => window.__HEROS_DEBUG__?.activeScenes?.().includes('TitleScene')); + await clickLegacyTitleControl(page, 962, 310); + await waitForVillageReady(page); + await page.waitForTimeout(380); + village = await readVillage(page); + assert(village.completedObjectiveIds.includes('meet-zhang-fei'), 'A completed NPC goal must survive reload and Continue.'); + assert.equal(village.dialogue.active, false, 'The one-time orientation line must not replay on Continue.'); + + for (const npcId of ['guan-yu', 'quartermaster']) { + await teleportTo(page, npcId); + await page.keyboard.press('e'); + await page.waitForFunction((expectedNpcId) => ( + window.__HEROS_DEBUG__?.village?.()?.dialogue?.sourceNpcId === expectedNpcId + ), npcId); + await advanceDialogueUntilClosed(page); + } + + village = await readVillage(page); + assert.deepEqual( + [...village.completedObjectiveIds].sort(), + ['check-supplies', 'meet-guan-yu', 'meet-zhang-fei'].sort() + ); + assert.equal(village.exitUnlocked, true); + assert.equal(village.oath.visible, true); + await captureStableScreenshot(page, 'dist/verification-prologue-village-ready.png'); + + await teleportTo(page, 'make-oath'); + await page.keyboard.press('e'); + await page.waitForFunction(() => window.__HEROS_DEBUG__?.village?.()?.dialogue?.sourceNpcId === 'make-oath'); + assert.equal((await readVillage(page)).dialogue.totalLines, 6); + await advanceDialogueUntilClosed(page); + await waitForStoryReady(page); + + const departure = await readStory(page); + assert.equal(departure.totalPages, 4); + assert.equal(departure.currentPageId, 'first-sortie'); + assert.equal(departure.nextScene, 'BattleScene'); + const savedDeparture = await readCampaignSave(page); + assert.equal(savedDeparture.step, 'prologue-town', 'The campaign should remain resumable in departure story until BattleScene starts.'); + assert(savedDeparture.completedTutorialIds.includes('prologue-village-complete')); + assert(!savedDeparture.completedTutorialIds.includes('first-battle-basic-controls')); + + for (let pageIndex = 1; pageIndex < 4; pageIndex += 1) { + await page.mouse.click(960, 850); + await page.waitForFunction((expectedPageIndex) => { + const scene = window.__HEROS_DEBUG__?.scene('StoryScene'); + return scene?.getDebugState?.()?.pageIndex === expectedPageIndex && scene?.transitioning === false; + }, pageIndex); + } + await page.keyboard.press('Space'); + await page.waitForFunction(() => { + const battle = window.__HEROS_DEBUG__?.battle?.(); + return ( + window.__HEROS_DEBUG__?.activeScenes?.().includes('BattleScene') && + battle?.battleId === 'first-battle-zhuo-commandery' && + battle?.phase === 'deployment' && + battle?.mapBackgroundReady === true + ); + }, undefined, { timeout: 90000 }); + const battleSave = await readCampaignSave(page); + assert.equal(battleSave.step, 'first-battle'); + assert(!battleSave.completedTutorialIds.includes('first-battle-basic-controls')); + + assert.deepEqual(pageErrors, [], `Expected no browser page errors: ${JSON.stringify(pageErrors)}`); + assert.deepEqual( + consoleErrors.filter((message) => !message.includes('The AudioContext was not allowed to start')), + [], + `Expected no browser console errors: ${JSON.stringify(consoleErrors)}` + ); + + console.log( + `Verified the playable prologue village at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height}, ` + + `DPR ${desktopBrowserDeviceScaleFactor}: opening handoff, direct movement, distance-gated dialogue, ` + + 'dialogue movement lock, locked oath, objective autosave/reload, final oath, departure story, and first deployment.' + ); +} finally { + await browser?.close(); + if (serverProcess && !serverProcess.killed) { + serverProcess.kill(); + } +} + +function withDebugOptions(url) { + const parsed = new URL(url); + parsed.searchParams.set('debug', '1'); + parsed.searchParams.set('renderer', 'canvas'); + return parsed.toString(); +} + +async function waitForDebugApi(page) { + await page.waitForFunction(() => ( + document.querySelector('canvas') !== null && + window.__HEROS_DEBUG__ !== undefined + ), undefined, { timeout: 90000 }); +} + +async function waitForTitle(page) { + await page.waitForFunction(() => window.__HEROS_DEBUG__?.activeScenes?.().includes('TitleScene'), undefined, { + timeout: 90000 + }); +} + +async function clickLegacyTitleControl(page, x, y) { + await page.mouse.click(x * 1.5, y * 1.5); +} + +async function waitForStoryReady(page) { + await page.waitForFunction(() => { + const story = window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.(); + return window.__HEROS_DEBUG__?.activeScenes?.().includes('StoryScene') && story?.ready === true; + }, undefined, { timeout: 90000 }); +} + +async function waitForVillageReady(page) { + try { + await page.waitForFunction(() => { + const village = window.__HEROS_DEBUG__?.village?.(); + return ( + window.__HEROS_DEBUG__?.activeScenes?.().includes('PrologueVillageScene') && + village?.scene === 'PrologueVillageScene' && + village?.ready === true + ); + }, undefined, { timeout: 30000 }); + } catch (error) { + const diagnostic = await page.evaluate(() => ({ + activeScenes: window.__HEROS_DEBUG__?.activeScenes?.() ?? [], + village: window.__HEROS_DEBUG__?.village?.() ?? null, + story: window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.() ?? null + })); + throw new Error(`Playable village did not become ready: ${JSON.stringify(diagnostic)}`, { cause: error }); + } +} + +async function readStory(page) { + return page.evaluate(() => window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.()); +} + +async function readVillage(page) { + return page.evaluate(() => window.__HEROS_DEBUG__?.village?.()); +} + +async function teleportTo(page, targetId) { + const teleported = await page.evaluate((id) => { + const scene = window.__HEROS_GAME__?.scene.getScene('PrologueVillageScene'); + return scene?.debugTeleportTo?.(id) ?? false; + }, targetId); + assert.equal(teleported, true, `Expected debug teleport to ${targetId}.`); + await page.waitForTimeout(80); +} + +async function advanceDialogueUntilClosed(page) { + for (let attempt = 0; attempt < 12; attempt += 1) { + const active = (await readVillage(page))?.dialogue?.active; + if (!active) { + return; + } + await page.mouse.click(960, 850); + await page.waitForTimeout(130); + } + throw new Error(`Dialogue did not close: ${JSON.stringify(await readVillage(page))}`); +} + +async function readCampaignSave(page) { + return page.evaluate(() => { + const raw = window.localStorage.getItem('heros-web:campaign-state'); + return raw ? JSON.parse(raw) : null; + }); +} + +async function assertDesktopViewport(page) { + const viewport = await page.evaluate(() => { + const canvas = document.querySelector('canvas'); + const bounds = canvas?.getBoundingClientRect(); + return { + innerWidth: window.innerWidth, + innerHeight: window.innerHeight, + devicePixelRatio: 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 + }; + }); + assert.equal(viewport.innerWidth, desktopBrowserViewport.width); + assert.equal(viewport.innerHeight, desktopBrowserViewport.height); + assert.equal(viewport.devicePixelRatio, desktopBrowserDeviceScaleFactor); + assert.equal(viewport.canvas?.width, desktopBrowserViewport.width); + assert.equal(viewport.canvas?.height, desktopBrowserViewport.height); + assert.equal(viewport.canvas?.clientWidth, desktopBrowserViewport.width); + assert.equal(viewport.canvas?.clientHeight, desktopBrowserViewport.height); + assert.equal(viewport.canvas?.bounds?.width, desktopBrowserViewport.width); + assert.equal(viewport.canvas?.bounds?.height, desktopBrowserViewport.height); +} + +function assertBoundsInsideViewport(bounds, label) { + assert(bounds, `${label}: bounds are required`); + assert( + bounds.x >= 0 && + bounds.y >= 0 && + bounds.x + bounds.width <= desktopBrowserViewport.width && + bounds.y + bounds.height <= desktopBrowserViewport.height, + `${label}: expected in-viewport bounds, received ${JSON.stringify(bounds)}` + ); +} + +async function captureStableScreenshot(page, path) { + await page.waitForTimeout(220); + const loopSlept = await page.evaluate(() => { + const loop = window.__HEROS_GAME__?.loop; + if (!loop || typeof loop.sleep !== 'function') { + return false; + } + loop.sleep(); + return true; + }); + await page.waitForTimeout(40); + try { + await page.screenshot({ path, fullPage: true }); + } finally { + if (loopSlept) { + await page.evaluate(() => window.__HEROS_GAME__?.loop.wake()); + await page.waitForTimeout(40); + } + } +} + +async function ensureLocalServer(url) { + if (await canReach(url)) { + return undefined; + } + + const parsed = new URL(url); + if (!['localhost', '127.0.0.1', '0.0.0.0'].includes(parsed.hostname)) { + throw new Error(`No server responded at ${url}`); + } + + const stderr = []; + const child = spawn( + process.execPath, + [ + 'node_modules/vite/bin/vite.js', + '--host', + '127.0.0.1', + '--port', + parsed.port || '41796', + '--strictPort' + ], + { + cwd: process.cwd(), + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true + } + ); + child.stderr.on('data', (chunk) => stderr.push(chunk.toString())); + child.stdout.on('data', () => {}); + + for (let attempt = 0; attempt < 120; attempt += 1) { + if (await canReach(url)) { + return child; + } + await delay(250); + } + + child.kill(); + throw new Error(`Vite server 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/verify-prologue-village-data.mjs b/scripts/verify-prologue-village-data.mjs new file mode 100644 index 0000000..267cef6 --- /dev/null +++ b/scripts/verify-prologue-village-data.mjs @@ -0,0 +1,160 @@ +import { createServer } from 'vite'; + +const expectedOpeningPageIds = ['yellow-turban-chaos', 'liu-bei-resolve']; +const expectedDeparturePageIds = [ + 'first-sortie', + 'yellow-turban-nearby', + 'first-battle-plan-talk', + 'battle-briefing' +]; +const expectedRequiredObjectiveIds = [ + 'meet-zhang-fei', + 'meet-guan-yu', + 'check-supplies' +]; +const expectedCampaignMarkerIds = [ + 'prologue-village-entered', + 'prologue-village-meet-zhang-fei', + 'prologue-village-meet-guan-yu', + 'prologue-village-check-supplies', + 'prologue-village-complete' +]; + +const server = await createServer({ + logLevel: 'error', + server: { middlewareMode: true }, + appType: 'custom' +}); + +try { + const village = await server.ssrLoadModule('/src/game/data/prologueVillage.ts'); + const campaign = await server.ssrLoadModule('/src/game/state/campaignState.ts'); + const unitAssets = await server.ssrLoadModule('/src/game/data/unitAssets.ts'); + const failures = []; + + const openingPages = village.prologueOpeningPages(); + const departurePages = village.prologueDeparturePages(); + assertArrayEquals( + openingPages.map((page) => page.id), + expectedOpeningPageIds, + 'opening story page order', + failures + ); + assertArrayEquals( + departurePages.map((page) => page.id), + expectedDeparturePageIds, + 'departure story page order', + failures + ); + assertArrayEquals( + [...village.prologueVillageRequiredObjectiveIds], + expectedRequiredObjectiveIds, + 'required objective order', + failures + ); + + const objectives = village.prologueVillageObjectiveDefinitions; + assert(objectives.length === 4, `expected 4 objective rows, received ${objectives.length}`, failures); + assert( + new Set(objectives.map((objective) => objective.id)).size === objectives.length, + 'objective ids must be unique', + failures + ); + assert( + objectives.at(-1)?.id === 'make-oath', + 'the final objective must be make-oath', + failures + ); + + const npcs = village.prologueVillageNpcDefinitions; + const objectiveNpcIds = npcs + .filter((npc) => npc.objectiveId) + .map((npc) => npc.objectiveId); + assertArrayEquals( + [...objectiveNpcIds].sort(), + [...expectedRequiredObjectiveIds].sort(), + 'required NPC objective coverage', + failures + ); + assert( + new Set(npcs.map((npc) => npc.id)).size === npcs.length, + 'NPC ids must be unique', + failures + ); + assert(npcs.some((npc) => !npc.objectiveId), 'at least one optional villager should be available', failures); + + for (const npc of npcs) { + assert( + Number.isFinite(npc.x) && npc.x >= 70 && npc.x <= 1435 && + Number.isFinite(npc.y) && npc.y >= 130 && npc.y <= 900, + `${npc.id}: position must remain inside the FHD village play area`, + failures + ); + assert(unitAssets.hasUnitSheetAsset(npc.textureKey), `${npc.id}: missing ${npc.textureKey}`, failures); + assert(Array.isArray(npc.dialogue) && npc.dialogue.length > 0, `${npc.id}: dialogue is required`, failures); + for (const [lineIndex, line] of npc.dialogue.entries()) { + assert( + typeof line.speaker === 'string' && line.speaker.trim().length > 0, + `${npc.id}[${lineIndex}]: speaker is required`, + failures + ); + assert( + typeof line.text === 'string' && line.text.trim().length >= 8 && line.text.length <= 180, + `${npc.id}[${lineIndex}]: dialogue must be 8-180 characters`, + failures + ); + if (line.textureKey) { + assert( + unitAssets.hasUnitSheetAsset(line.textureKey), + `${npc.id}[${lineIndex}]: missing dialogue sprite ${line.textureKey}`, + failures + ); + } + } + } + + assert( + unitAssets.hasUnitSheetAsset('unit-liu-bei'), + 'the controllable Liu Bei base sheet is required', + failures + ); + assertArrayEquals( + Object.values(campaign.prologueVillageCampaignTutorialIds), + expectedCampaignMarkerIds, + 'campaign progress marker ids', + failures + ); + assert( + expectedCampaignMarkerIds.every((id) => campaign.campaignTutorialIds.includes(id)), + 'every village marker must survive campaign save normalization', + failures + ); + + if (failures.length > 0) { + throw new Error( + `Prologue village data verification failed with ${failures.length} issue(s):\n` + + failures.map((failure) => `- ${failure}`).join('\n') + ); + } + + console.log( + `Verified ${npcs.length} village NPCs, ${expectedRequiredObjectiveIds.length} required interactions, ` + + `${openingPages.length} opening pages, ${departurePages.length} departure pages, and save markers.` + ); +} finally { + await server.close(); +} + +function assert(condition, message, failures) { + if (!condition) { + failures.push(message); + } +} + +function assertArrayEquals(actual, expected, label, failures) { + assert( + JSON.stringify(actual) === JSON.stringify(expected), + `${label}: expected [${expected.join(', ')}], received [${actual.join(', ')}]`, + failures + ); +} diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index 3157c59..18cb84c 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -8157,36 +8157,51 @@ async function assertFreshStoryAssetStreaming(page, requestedResourceUrls, reque await page.waitForTimeout(75); } - let probe = await advanceOneStoryPage(page, 1); + const 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)}` + probe.story?.isLastPage === true && + probe.story?.nextScene === 'PrologueVillageScene' && + probe.story?.advanceHint === 'village-exploration' && + probe.story?.advanceLabel === '마을로 이동', + `Expected the short opening story to hand control to the playable village: ${JSON.stringify(probe)}` + ); + + await page.keyboard.press('Space'); + await page.waitForFunction(() => ( + window.__HEROS_DEBUG__?.village?.()?.ready === true + ), undefined, { timeout: 90000 }); + const villageAdvanced = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('PrologueVillageScene'); + return scene?.debugCompleteVillage?.() ?? false; + }); + assert(villageAdvanced, 'Expected the playable prologue village to hand off to the departure story.'); + await waitForStoryReady(page); + + let departureProbe = await readStoryAssetStreamingProbe(page); + assertStoryAssetStreamingProbe(departureProbe, 0, 'departure story page 1'); + assert( + departureProbe.story?.currentPageId === 'first-sortie' && + departureProbe.story?.totalPages === 4 && + departureProbe.story?.nextScene === 'BattleScene' && + (departureProbe.story?.cutsceneActors?.length ?? 0) > 0 && + departureProbe.expectedUnitBaseTextureKeys.length > 0, + `Expected the departure story to retain its streamed founding-trio cutscene: ${JSON.stringify(departureProbe)}` ); assert( sameJsonValue( - [...probe.story.assetStreaming.currentUnitBaseTexturesLoaded].sort(), - [...probe.expectedUnitBaseTextureKeys].sort() + [...departureProbe.story.assetStreaming.currentUnitBaseTexturesLoaded].sort(), + [...departureProbe.expectedUnitBaseTextureKeys].sort() ), - `Expected every current cutscene unit base sheet to be loaded: ${JSON.stringify(probe)}` + `Expected every departure cutscene unit base sheet to be loaded: ${JSON.stringify(departureProbe)}` ); - const repeatedSortieBackgrounds = new Set(); - while (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}`); - if (probe.story.requestedBackground === 'story-sortie') { - repeatedSortieBackgrounds.add(probe.story.background); + const repeatedSortieBackgrounds = new Set([departureProbe.story.background]); + for (let targetPageIndex = 1; targetPageIndex < departureProbe.story.totalPages; targetPageIndex += 1) { + departureProbe = await advanceOneStoryPage(page, targetPageIndex); + assertStoryAssetStreamingProbe(departureProbe, targetPageIndex, `departure story page ${targetPageIndex + 1}`); + if (departureProbe.story.requestedBackground === 'story-sortie') { + repeatedSortieBackgrounds.add(departureProbe.story.background); } } assert( @@ -8317,7 +8332,8 @@ async function advanceUntilBattle(page, battleId, options = {}) { (state?.phase === 'deployment' || state?.phase === 'idle') && state?.mapBackgroundReady === true ), - story: window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.() ?? null + story: window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.() ?? null, + village: window.__HEROS_DEBUG__?.village?.() ?? null }; }, battleId); @@ -8339,6 +8355,16 @@ async function advanceUntilBattle(page, battleId, options = {}) { return { lastStory }; } + if (probe.village?.ready === true && probe.village?.navigationPending !== true) { + const completed = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('PrologueVillageScene'); + return scene?.debugCompleteVillage?.() ?? false; + }); + assert(completed, `Expected the playable village debug completion to advance the release flow: ${JSON.stringify(probe.village)}`); + await page.waitForTimeout(400); + continue; + } + await page.keyboard.press('Space'); await page.waitForTimeout(220); } diff --git a/scripts/verify-save-retry-flow.mjs b/scripts/verify-save-retry-flow.mjs index f3e3379..6ec71e7 100644 --- a/scripts/verify-save-retry-flow.mjs +++ b/scripts/verify-save-retry-flow.mjs @@ -272,6 +272,18 @@ async function advanceUntilBattle(page, battleId) { } const activeScenes = await page.evaluate(() => window.__HEROS_DEBUG__?.activeScenes() ?? []); + if (activeScenes.includes('PrologueVillageScene')) { + const advanced = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('PrologueVillageScene'); + return scene?.getDebugState?.()?.ready === true + ? scene?.debugCompleteVillage?.() ?? false + : false; + }); + if (advanced) { + await page.waitForTimeout(320); + continue; + } + } await page.keyboard.press(activeScenes.includes('TitleScene') ? 'Enter' : 'Space'); await page.waitForTimeout(240); } diff --git a/scripts/verify-static-data.mjs b/scripts/verify-static-data.mjs index c33fcd8..0994c95 100644 --- a/scripts/verify-static-data.mjs +++ b/scripts/verify-static-data.mjs @@ -22,6 +22,7 @@ const checks = [ 'scripts/verify-narrative-continuity.mjs', 'scripts/verify-camp-reward-data.mjs', 'scripts/verify-city-stay-data.mjs', + 'scripts/verify-prologue-village-data.mjs', 'scripts/verify-victory-reward-acknowledgements.mjs', 'scripts/verify-camp-skin-data.mjs', 'scripts/verify-camp-soundscape-data.mjs', diff --git a/src/game/data/prologueVillage.ts b/src/game/data/prologueVillage.ts new file mode 100644 index 0000000..32f00d5 --- /dev/null +++ b/src/game/data/prologueVillage.ts @@ -0,0 +1,204 @@ +import { prologuePages, type StoryPage } from './scenario'; + +export const prologueVillageId = 'zhuo-prologue'; + +export const prologueVillageRequiredObjectiveIds = [ + 'meet-zhang-fei', + 'meet-guan-yu', + 'check-supplies' +] as const; + +export type PrologueVillageRequiredObjectiveId = + (typeof prologueVillageRequiredObjectiveIds)[number]; + +export const prologueVillageObjectiveDefinitions: ReadonlyArray<{ + id: PrologueVillageRequiredObjectiveId | 'make-oath'; + label: string; + shortLabel: string; + location: string; +}> = [ + { + id: 'meet-zhang-fei', + label: '장터 주점에서 장비와 이야기하기', + shortLabel: '장비와 대화', + location: '서쪽 주점' + }, + { + id: 'meet-guan-yu', + label: '동쪽 장터에서 관우의 정찰 듣기', + shortLabel: '관우와 대화', + location: '동쪽 장터' + }, + { + id: 'check-supplies', + label: '의병소에서 무기와 보급 확인하기', + shortLabel: '출전 준비', + location: '남동쪽 의병소' + }, + { + id: 'make-oath', + label: '복숭아 동산에서 두 사람과 결의하기', + shortLabel: '도원결의', + location: '북쪽 복숭아 동산' + } +]; + +export type PrologueVillageDialogueLine = { + speaker: string; + text: string; + textureKey?: string; +}; + +export type PrologueVillageNpcDefinition = { + id: string; + name: string; + role: string; + textureKey: string; + x: number; + y: number; + direction: 'south' | 'east' | 'north' | 'west'; + objectiveId?: PrologueVillageRequiredObjectiveId; + dialogue: PrologueVillageDialogueLine[]; + repeatDialogue?: PrologueVillageDialogueLine[]; +}; + +export const prologueVillageNpcDefinitions: readonly PrologueVillageNpcDefinition[] = [ + { + id: 'zhang-fei', + name: '장비', + role: '뜻을 함께할 호걸', + textureKey: 'unit-zhang-fei', + x: 360, + y: 495, + direction: 'east', + objectiveId: 'meet-zhang-fei', + dialogue: [ + { + speaker: '장비', + textureKey: 'unit-zhang-fei', + text: '나라가 어지러운데 뜻 있는 사내가 어찌 팔짱만 끼고 있겠소! 내 재산을 내어 의병을 모으겠소.' + }, + { + speaker: '유비', + textureKey: 'unit-liu-bei', + text: '백성을 지키려는 마음이 같다면 힘을 합칩시다. 먼저 싸울 사람과 물자를 갖추어야 하오.' + }, + { + speaker: '장비', + textureKey: 'unit-zhang-fei', + text: '좋소! 내가 장터 사람들을 모아 두겠소. 의병소의 준비를 확인한 뒤 복숭아 동산에서 다시 만납시다.' + } + ], + repeatDialogue: [ + { + speaker: '장비', + textureKey: 'unit-zhang-fei', + text: '사람들에게 뜻을 전하고 있소. 관우와 의병소도 둘러본 뒤 동산으로 오시오!' + } + ] + }, + { + id: 'guan-yu', + name: '관우', + role: '길목을 살피는 무인', + textureKey: 'unit-guan-yu', + x: 1110, + y: 460, + direction: 'west', + objectiveId: 'meet-guan-yu', + dialogue: [ + { + speaker: '관우', + textureKey: 'unit-guan-yu', + text: '북쪽 길을 살펴보니 황건 잔당이 마을 어귀로 내려오고 있습니다. 더 늦으면 백성이 위험합니다.' + }, + { + speaker: '유비', + textureKey: 'unit-liu-bei', + text: '우리가 먼저 길목을 막아야겠군요. 함께 백성을 구하는 길을 열어 주시겠소?' + }, + { + speaker: '관우', + textureKey: 'unit-guan-yu', + text: '뜻이 올바르다면 천 리라도 함께하겠습니다. 준비를 마치고 복숭아 동산에서 맹세하지요.' + } + ], + repeatDialogue: [ + { + speaker: '관우', + textureKey: 'unit-guan-yu', + text: '적은 보병을 앞세우고 뒤에서 활을 쏠 것입니다. 준비가 끝나는 대로 북문으로 나가야 합니다.' + } + ] + }, + { + id: 'quartermaster', + name: '의병소 관리인', + role: '무기와 보급 담당', + textureKey: 'unit-shu-officer', + x: 1045, + y: 760, + direction: 'east', + objectiveId: 'check-supplies', + dialogue: [ + { + speaker: '의병소 관리인', + textureKey: 'unit-shu-officer', + text: '장비 공이 맡긴 돈으로 창과 연습검을 손질했습니다. 콩과 상처약도 각 조에 나누어 두었습니다.' + }, + { + speaker: '유비', + textureKey: 'unit-liu-bei', + text: '좋습니다. 무기보다 먼저 사람을 살피고, 다친 이는 곧바로 뒤로 물리도록 전해 주시오.' + }, + { + speaker: '의병소 관리인', + textureKey: 'unit-shu-officer', + text: '출전 준비를 마쳤습니다. 이제 동료들과 결의하면 북문을 열겠습니다.' + } + ], + repeatDialogue: [ + { + speaker: '의병소 관리인', + textureKey: 'unit-shu-officer', + text: '무기와 보급은 모두 나누었습니다. 북문을 나서기 전에 동산에서 동료들과 합류하십시오.' + } + ] + }, + { + id: 'market-villager', + name: '탁현 주민', + role: '장터 소식', + textureKey: 'unit-shu-infantry', + x: 745, + y: 620, + direction: 'south', + dialogue: [ + { + speaker: '탁현 주민', + textureKey: 'unit-shu-infantry', + text: '서쪽 주점의 장비 공과 동쪽 장터의 긴 수염 무인이 의병 이야기를 나누고 있었습니다.' + } + ] + } +]; + +const openingPageIds = new Set(['yellow-turban-chaos', 'liu-bei-resolve']); +const departurePageIds = new Set([ + 'first-sortie', + 'yellow-turban-nearby', + 'first-battle-plan-talk', + 'battle-briefing' +]); + +export function prologueOpeningPages(): StoryPage[] { + return selectProloguePages(openingPageIds); +} + +export function prologueDeparturePages(): StoryPage[] { + return selectProloguePages(departurePageIds); +} + +function selectProloguePages(pageIds: ReadonlySet) { + return prologuePages.filter((page) => pageIds.has(page.id)); +} diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index e0d68a1..b010e25 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -4025,7 +4025,12 @@ export class BattleScene extends Phaser.Scene { this.resetSortieOrderHudState(); const campaign = getCampaignState(); this.resetBattleData(campaign); - if (campaign.step === 'new' || campaign.step === 'prologue' || campaign.step === 'first-battle') { + if ( + campaign.step === 'new' || + campaign.step === 'prologue' || + campaign.step === 'prologue-town' || + campaign.step === 'first-battle' + ) { markCampaignStep('first-battle'); } const { width, height } = this.scale; diff --git a/src/game/scenes/PrologueVillageScene.ts b/src/game/scenes/PrologueVillageScene.ts new file mode 100644 index 0000000..26f12bc --- /dev/null +++ b/src/game/scenes/PrologueVillageScene.ts @@ -0,0 +1,1432 @@ +import Phaser from 'phaser'; +import { soundDirector } from '../audio/SoundDirector'; +import { + prologueDeparturePages, + prologueVillageNpcDefinitions, + prologueVillageObjectiveDefinitions, + prologueVillageRequiredObjectiveIds, + type PrologueVillageDialogueLine, + type PrologueVillageNpcDefinition, + type PrologueVillageRequiredObjectiveId +} from '../data/prologueVillage'; +import { + ensureUnitAnimations, + loadUnitBaseSheets, + releaseUnitBaseSheetTextures, + type UnitDirection +} from '../data/unitAssets'; +import { + completeCampaignTutorial, + getCampaignState, + hasCompletedCampaignTutorial, + markCampaignStep, + prologueVillageCampaignTutorialIds, + type CampaignTutorialId +} from '../state/campaignState'; +import { palette } from '../ui/palette'; +import { startGameScene } from './lazyScenes'; + +const sceneWidth = 1920; +const sceneHeight = 1080; +const mapRight = 1495; +const movementBounds = new Phaser.Geom.Rectangle(42, 108, 1410, 814); +const playerSpeed = 300; +const playerCollisionRadius = 25; +const interactionRadius = 122; +const promptRadius = 160; +const characterDisplaySize = 104; +const inputCarryoverGuardMs = 320; +const oathPosition = { x: 780, y: 405 }; +const characterTextureKeys = [ + 'unit-liu-bei', + 'unit-guan-yu', + 'unit-zhang-fei', + 'unit-shu-officer', + 'unit-shu-infantry' +] as const; + +type VillageNpcView = { + definition: PrologueVillageNpcDefinition; + sprite: Phaser.GameObjects.Sprite; + shadow: Phaser.GameObjects.Ellipse; + nameplate: Phaser.GameObjects.Text; + marker?: Phaser.GameObjects.Text; +}; + +type DialogueState = { + lines: PrologueVillageDialogueLine[]; + lineIndex: number; + onComplete?: () => void; + sourceNpcId?: string; +}; + +type InteractionTarget = + | { kind: 'npc'; id: string; name: string; x: number; y: number } + | { kind: 'oath'; id: 'make-oath'; name: string; x: number; y: number }; + +type ObjectiveRowView = { + id: PrologueVillageRequiredObjectiveId | 'make-oath'; + background: Phaser.GameObjects.Rectangle; + status: Phaser.GameObjects.Text; + label: Phaser.GameObjects.Text; + location: Phaser.GameObjects.Text; +}; + +const objectiveTutorialIds: Record = { + 'meet-zhang-fei': prologueVillageCampaignTutorialIds.meetZhangFei, + 'meet-guan-yu': prologueVillageCampaignTutorialIds.meetGuanYu, + 'check-supplies': prologueVillageCampaignTutorialIds.checkSupplies +}; + +export class PrologueVillageScene extends Phaser.Scene { + private player?: Phaser.GameObjects.Sprite; + private playerShadow?: Phaser.GameObjects.Ellipse; + private playerDirection: UnitDirection = 'north'; + private playerMoving = false; + private npcViews = new Map(); + private blockers: Phaser.Geom.Rectangle[] = []; + private objectiveRows = new Map(); + private objectiveSummaryText?: Phaser.GameObjects.Text; + private promptBackground?: Phaser.GameObjects.Rectangle; + private promptText?: Phaser.GameObjects.Text; + private oathMarker?: Phaser.GameObjects.Text; + private oathLabel?: Phaser.GameObjects.Text; + private dialoguePanel?: Phaser.GameObjects.Container; + private dialogueAvatar?: Phaser.GameObjects.Sprite; + private dialogueNameText?: Phaser.GameObjects.Text; + private dialogueBodyText?: Phaser.GameObjects.Text; + private dialogueProgressText?: Phaser.GameObjects.Text; + private dialogueState?: DialogueState; + private loadingOverlay?: Phaser.GameObjects.Container; + private transitionOverlay?: Phaser.GameObjects.Container; + private completionToast?: Phaser.GameObjects.Container; + private cursorKeys?: Phaser.Types.Input.Keyboard.CursorKeys; + private moveKeys?: { + up: Phaser.Input.Keyboard.Key; + down: Phaser.Input.Keyboard.Key; + left: Phaser.Input.Keyboard.Key; + right: Phaser.Input.Keyboard.Key; + }; + private interactKeys: Phaser.Input.Keyboard.Key[] = []; + private moveTarget?: Phaser.Math.Vector2; + private ready = false; + private navigationPending = false; + private inputReadyAt = Number.POSITIVE_INFINITY; + private interactionQueued = false; + private stepIndex = 0; + private lastStepAt = 0; + private firstVisit = false; + + constructor() { + super('PrologueVillageScene'); + } + + create() { + this.resetRuntimeState(); + this.prepareCampaignProgress(); + this.drawVillage(); + this.drawHud(); + this.createLoadingOverlay(); + this.setupInput(); + + soundDirector.playSoundscape({ + musicKey: 'militia-theme', + ambienceKey: 'mountain-wind-ambience', + musicVolume: 0.72, + ambienceVolume: 0.11 + }); + + this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => { + this.ready = false; + this.navigationPending = false; + this.moveTarget = undefined; + this.dialogueState = undefined; + releaseUnitBaseSheetTextures(this); + }); + + loadUnitBaseSheets(this, characterTextureKeys, () => { + if (!this.scene.isActive()) { + return; + } + ensureUnitAnimations(this, characterTextureKeys); + this.createActors(); + this.createDialoguePanel(); + this.loadingOverlay?.destroy(); + this.loadingOverlay = undefined; + this.ready = true; + this.inputReadyAt = this.time.now + inputCarryoverGuardMs; + this.refreshObjectiveHud(); + this.refreshNpcMarkers(); + this.refreshInteractionPrompt(); + + if (this.firstVisit) { + this.startDialogue([ + { + speaker: '유비', + textureKey: 'unit-liu-bei', + text: '장터에서 뜻을 함께할 사람을 찾고 의병소의 준비를 확인하자. 모든 준비가 끝나면 북쪽 복숭아 동산으로 가야겠다.' + } + ]); + } + }); + } + + update(time: number, delta: number) { + if (!this.ready || this.navigationPending || time < this.inputReadyAt) { + this.interactionQueued = false; + return; + } + + if (this.dialogueState) { + this.stopPlayerMovement(); + if (this.consumeInteractionRequest()) { + this.advanceDialogue(); + } + return; + } + + if (this.consumeInteractionRequest()) { + this.tryInteract(); + return; + } + + this.updateMovement(time, delta); + this.refreshInteractionPrompt(); + } + + getDebugState() { + const campaign = getCampaignState(); + const playerPosition = this.player + ? { x: this.player.x, y: this.player.y } + : null; + const target = this.nearestInteractionTarget(promptRadius); + const dialogueBounds = this.dialoguePanel?.visible + ? this.boundsSnapshot(this.dialoguePanel.getBounds()) + : null; + + return { + scene: this.scene.key, + ready: this.ready, + viewport: { width: this.scale.width, height: this.scale.height }, + campaignStep: campaign.step, + player: playerPosition + ? { + ...playerPosition, + direction: this.playerDirection, + moving: this.playerMoving, + bounds: this.player ? this.boundsSnapshot(this.player.getBounds()) : null + } + : null, + movement: { + speed: playerSpeed, + target: this.moveTarget ? { x: this.moveTarget.x, y: this.moveTarget.y } : null, + walkBounds: this.boundsSnapshot(movementBounds), + collisionRadius: playerCollisionRadius + }, + controls: { + movement: ['WASD', '방향키'], + interact: ['E', 'Space', 'Enter'], + pointerMove: true, + carryoverGuardMs: inputCarryoverGuardMs + }, + interaction: { + radius: interactionRadius, + promptRadius, + targetId: target?.id ?? null, + canInteract: Boolean(target && this.distanceTo(target.x, target.y) <= interactionRadius), + promptVisible: this.promptBackground?.visible ?? false, + promptBounds: this.promptBackground?.visible + ? this.boundsSnapshot(this.promptBackground.getBounds()) + : null + }, + objectives: prologueVillageObjectiveDefinitions.map((objective) => ({ + id: objective.id, + label: objective.label, + location: objective.location, + completed: objective.id === 'make-oath' + ? hasCompletedCampaignTutorial(prologueVillageCampaignTutorialIds.complete) + : this.isObjectiveComplete(objective.id), + unlocked: objective.id !== 'make-oath' || this.allRequiredObjectivesComplete() + })), + completedObjectiveIds: this.completedRequiredObjectiveIds(), + exitUnlocked: this.allRequiredObjectivesComplete(), + oath: { + x: oathPosition.x, + y: oathPosition.y, + visible: this.oathMarker?.visible ?? false, + completed: hasCompletedCampaignTutorial(prologueVillageCampaignTutorialIds.complete) + }, + npcs: Array.from(this.npcViews.values()).map(({ definition, sprite }) => ({ + id: definition.id, + name: definition.name, + objectiveId: definition.objectiveId ?? null, + x: sprite.x, + y: sprite.y, + distance: playerPosition ? Phaser.Math.Distance.Between(playerPosition.x, playerPosition.y, sprite.x, sprite.y) : null, + completed: definition.objectiveId ? this.isObjectiveComplete(definition.objectiveId) : false, + bounds: this.boundsSnapshot(sprite.getBounds()) + })), + dialogue: this.dialogueState + ? { + active: true, + speaker: this.dialogueState.lines[this.dialogueState.lineIndex]?.speaker ?? '', + lineIndex: this.dialogueState.lineIndex, + totalLines: this.dialogueState.lines.length, + sourceNpcId: this.dialogueState.sourceNpcId ?? null, + bounds: dialogueBounds + } + : { + active: false, + speaker: null, + lineIndex: null, + totalLines: 0, + sourceNpcId: null, + bounds: null + }, + blockers: this.blockers.map((blocker) => this.boundsSnapshot(blocker)), + navigationPending: this.navigationPending, + requiredTexturesReady: characterTextureKeys.every((key) => this.textures.exists(key)) + }; + } + + debugTeleportTo(targetId: string) { + if (!this.player || !this.ready || this.navigationPending) { + return false; + } + const target = this.interactionTargetById(targetId); + if (!target) { + return false; + } + const candidate = this.safeApproachPosition(target.x, target.y); + this.setPlayerPosition(candidate.x, candidate.y); + this.moveTarget = undefined; + this.refreshInteractionPrompt(); + return true; + } + + debugInteractWith(targetId: string) { + if (!this.debugTeleportTo(targetId)) { + return false; + } + this.interactWithTarget(this.interactionTargetById(targetId)!); + return true; + } + + debugCompleteRequiredObjectives() { + prologueVillageRequiredObjectiveIds.forEach((objectiveId) => { + completeCampaignTutorial(objectiveTutorialIds[objectiveId]); + }); + this.refreshObjectiveHud(); + this.refreshNpcMarkers(); + return this.completedRequiredObjectiveIds(); + } + + debugCompleteVillage() { + if (!this.ready || this.navigationPending) { + return false; + } + this.debugCompleteRequiredObjectives(); + this.finishVillage(); + return true; + } + + private resetRuntimeState() { + this.player = undefined; + this.playerShadow = undefined; + this.playerDirection = 'north'; + this.playerMoving = false; + this.npcViews.clear(); + this.blockers = []; + this.objectiveRows.clear(); + this.dialogueState = undefined; + this.moveTarget = undefined; + this.ready = false; + this.navigationPending = false; + this.inputReadyAt = Number.POSITIVE_INFINITY; + this.interactionQueued = false; + this.stepIndex = 0; + this.lastStepAt = 0; + } + + private prepareCampaignProgress() { + const campaign = getCampaignState(); + this.firstVisit = campaign.step !== 'prologue-town' && + !campaign.completedTutorialIds.includes(prologueVillageCampaignTutorialIds.entered); + if (campaign.step === 'new' || campaign.step === 'prologue') { + markCampaignStep('prologue-town'); + } + completeCampaignTutorial(prologueVillageCampaignTutorialIds.entered); + } + + private drawVillage() { + this.cameras.main.setBackgroundColor('#28372a'); + const graphics = this.add.graphics().setDepth(-100); + graphics.fillStyle(0x354a34, 1); + graphics.fillRect(0, 0, sceneWidth, sceneHeight); + graphics.fillStyle(0x42583a, 1); + graphics.fillRect(0, 92, mapRight, 868); + + this.drawGroundTexture(graphics); + this.drawRoads(graphics); + this.drawVillageBuildings(graphics); + this.drawVillageDetails(); + + this.add.rectangle(mapRight, 0, sceneWidth - mapRight, sceneHeight, 0x121720, 0.96) + .setOrigin(0) + .setDepth(1400); + this.add.rectangle(mapRight, 0, 3, sceneHeight, palette.gold, 0.72) + .setOrigin(0) + .setDepth(1401); + this.add.rectangle(0, 0, sceneWidth, 92, 0x111720, 0.96) + .setOrigin(0) + .setDepth(1400); + this.add.rectangle(0, 90, sceneWidth, 2, palette.gold, 0.68) + .setOrigin(0) + .setDepth(1401); + this.add.rectangle(0, 958, mapRight, 122, 0x10151c, 0.94) + .setOrigin(0) + .setDepth(1400); + this.add.rectangle(0, 956, mapRight, 2, palette.gold, 0.54) + .setOrigin(0) + .setDepth(1401); + } + + private drawGroundTexture(graphics: Phaser.GameObjects.Graphics) { + for (let index = 0; index < 150; index += 1) { + const x = 25 + ((index * 83) % 1435); + const y = 116 + ((index * 137) % 815); + const tone = index % 3 === 0 ? 0x55704a : index % 3 === 1 ? 0x2c4430 : 0x6a7447; + graphics.fillStyle(tone, 0.28); + graphics.fillCircle(x, y, index % 4 === 0 ? 3 : 2); + } + } + + private drawRoads(graphics: Phaser.GameObjects.Graphics) { + graphics.fillStyle(0xb59362, 1); + graphics.fillRoundedRect(650, 92, 255, 868, 42); + graphics.fillRoundedRect(70, 525, 1340, 190, 55); + graphics.fillRoundedRect(865, 390, 390, 190, 48); + graphics.fillStyle(0xd0b47c, 0.42); + graphics.fillRoundedRect(685, 92, 58, 868, 28); + graphics.fillRoundedRect(72, 566, 1338, 38, 18); + + graphics.fillStyle(0x8a6c48, 0.44); + for (let y = 126; y < 930; y += 64) { + graphics.fillEllipse(804 + (y % 3) * 9, y, 44, 13); + } + for (let x = 110; x < 1390; x += 76) { + graphics.fillEllipse(x, 655 + (x % 4) * 5, 38, 12); + } + } + + private drawVillageBuildings(graphics: Phaser.GameObjects.Graphics) { + this.drawBuilding(graphics, { + x: 105, + y: 160, + width: 410, + height: 236, + wallColor: 0xb99a68, + roofColor: 0x6d2f26, + sign: '장터 주점' + }); + this.addBlocker(105, 160, 410, 236); + + this.drawBuilding(graphics, { + x: 990, + y: 148, + width: 390, + height: 226, + wallColor: 0xc2a974, + roofColor: 0x31475a, + sign: '동쪽 장터' + }); + this.addBlocker(990, 148, 390, 226); + + this.drawBuilding(graphics, { + x: 1120, + y: 665, + width: 310, + height: 215, + wallColor: 0xa98d61, + roofColor: 0x493a32, + sign: '탁현 의병소' + }); + this.addBlocker(1120, 665, 310, 215); + + graphics.fillStyle(0x47362a, 1); + graphics.fillRect(706, 94, 150, 48); + graphics.fillStyle(0x252018, 1); + graphics.fillRect(733, 94, 18, 120); + graphics.fillRect(812, 94, 18, 120); + graphics.fillStyle(palette.gold, 0.9); + graphics.fillRect(710, 140, 142, 5); + this.add.text(781, 117, '북문', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '24px', + color: '#f3dfaa', + fontStyle: 'bold' + }).setOrigin(0.5).setDepth(40); + } + + private drawBuilding( + graphics: Phaser.GameObjects.Graphics, + building: { + x: number; + y: number; + width: number; + height: number; + wallColor: number; + roofColor: number; + sign: string; + } + ) { + const { x, y, width, height, wallColor, roofColor, sign } = building; + graphics.fillStyle(0x15120f, 0.24); + graphics.fillRoundedRect(x + 16, y + 18, width, height, 14); + graphics.fillStyle(wallColor, 1); + graphics.fillRoundedRect(x, y + 58, width, height - 58, 10); + graphics.lineStyle(3, 0x392a22, 0.8); + graphics.strokeRoundedRect(x, y + 58, width, height - 58, 10); + graphics.fillStyle(roofColor, 1); + graphics.fillPoints([ + new Phaser.Geom.Point(x - 24, y + 72), + new Phaser.Geom.Point(x + width / 2, y), + new Phaser.Geom.Point(x + width + 24, y + 72), + new Phaser.Geom.Point(x + width - 3, y + 100), + new Phaser.Geom.Point(x + 3, y + 100) + ], true); + graphics.lineStyle(5, 0x211b19, 0.72); + graphics.lineBetween(x - 22, y + 72, x + width + 22, y + 72); + graphics.fillStyle(0x34271f, 1); + graphics.fillRect(x + width / 2 - 30, y + height - 72, 60, 72); + graphics.fillStyle(0x16110e, 0.8); + graphics.fillRect(x + 56, y + 132, 62, 45); + graphics.fillRect(x + width - 118, y + 132, 62, 45); + + this.add.text(x + width / 2, y + 104, sign, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '23px', + color: '#2a2019', + fontStyle: 'bold', + backgroundColor: '#dfc792', + padding: { left: 13, right: 13, top: 6, bottom: 6 } + }).setOrigin(0.5).setDepth(42); + } + + private drawVillageDetails() { + [ + [585, 180, 0.9], + [935, 210, 0.85], + [570, 350, 0.78], + [930, 345, 0.82], + [575, 800, 0.9], + [950, 840, 0.82], + [95, 820, 0.82], + [250, 875, 0.75] + ].forEach(([x, y, scale]) => this.drawTree(x, y, scale)); + + const stall = this.add.graphics().setDepth(32); + stall.fillStyle(0x6a4330, 1); + stall.fillRect(1280, 442, 150, 78); + stall.fillStyle(0xd7bc78, 1); + stall.fillTriangle(1260, 445, 1450, 445, 1355, 394); + stall.fillStyle(0x982f2c, 0.78); + stall.fillRect(1278, 430, 154, 18); + this.addBlocker(1270, 414, 170, 112); + + const weapons = this.add.graphics().setDepth(34); + weapons.lineStyle(6, 0x544334, 1); + weapons.lineBetween(1090, 710, 1090, 828); + weapons.lineBetween(1062, 808, 1118, 808); + weapons.lineStyle(4, 0x9eabb5, 1); + weapons.lineBetween(1070, 710, 1110, 805); + weapons.lineBetween(1110, 710, 1070, 805); + + this.add.text(780, 244, '복숭아 동산', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '28px', + color: '#ffe1df', + fontStyle: 'bold', + stroke: '#4b252d', + strokeThickness: 6 + }).setOrigin(0.5).setDepth(45); + + const blossomGraphics = this.add.graphics().setDepth(25); + [ + [665, 280], + [715, 325], + [850, 305], + [900, 260] + ].forEach(([x, y]) => { + blossomGraphics.fillStyle(0x51352e, 1); + blossomGraphics.fillRect(x - 5, y, 10, 54); + blossomGraphics.fillStyle(0xf3b4bd, 0.95); + blossomGraphics.fillCircle(x - 18, y, 29); + blossomGraphics.fillCircle(x + 18, y - 5, 31); + blossomGraphics.fillCircle(x, y - 24, 32); + blossomGraphics.fillStyle(0xffd0d5, 0.9); + blossomGraphics.fillCircle(x + 4, y - 4, 22); + }); + } + + private drawTree(x: number, y: number, scale: number) { + const graphics = this.add.graphics().setDepth(20 + y / 100); + graphics.fillStyle(0x3f2b20, 1); + graphics.fillRoundedRect(x - 8 * scale, y, 16 * scale, 58 * scale, 5); + graphics.fillStyle(0x25432c, 1); + graphics.fillCircle(x - 21 * scale, y - 4 * scale, 37 * scale); + graphics.fillCircle(x + 22 * scale, y - 9 * scale, 40 * scale); + graphics.fillStyle(0x3c6740, 1); + graphics.fillCircle(x, y - 33 * scale, 43 * scale); + graphics.fillStyle(0x648057, 0.7); + graphics.fillCircle(x - 9 * scale, y - 44 * scale, 20 * scale); + this.addBlocker(x - 16 * scale, y + 12 * scale, 32 * scale, 43 * scale); + } + + private addBlocker(x: number, y: number, width: number, height: number) { + this.blockers.push(new Phaser.Geom.Rectangle(x, y, width, height)); + } + + private drawHud() { + this.add.text(48, 27, '탁현 · 황건 봉기 직전', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '34px', + color: '#f4e3b5', + fontStyle: 'bold' + }).setDepth(1500); + this.add.text(460, 36, '유비의 첫걸음', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '20px', + color: '#b4c0cb' + }).setDepth(1500); + + this.add.text(1536, 45, '출전 준비', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '31px', + color: '#f4e3b5', + fontStyle: 'bold' + }).setDepth(1500); + this.add.text(1538, 92, '마을을 걸어 다니며 사람들과 직접 이야기하세요.', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: '#9fabb8', + wordWrap: { width: 330 } + }).setDepth(1500); + + prologueVillageObjectiveDefinitions.forEach((objective, index) => { + const y = 168 + index * 154; + const background = this.add.rectangle(1530, y, 350, 132, 0x1b222d, 0.94) + .setOrigin(0) + .setStrokeStyle(2, 0x3d4858, 1) + .setDepth(1500); + const status = this.add.text(1550, y + 19, '', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: '#d8b15f', + fontStyle: 'bold' + }).setDepth(1501); + const label = this.add.text(1550, y + 48, objective.shortLabel, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '22px', + color: '#e8dfca', + fontStyle: 'bold' + }).setDepth(1501); + const location = this.add.text(1550, y + 87, objective.location, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '16px', + color: '#9aa3ad' + }).setDepth(1501); + this.objectiveRows.set(objective.id, { id: objective.id, background, status, label, location }); + }); + + this.objectiveSummaryText = this.add.text(1538, 810, '', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '18px', + color: '#cbd3dc', + lineSpacing: 8, + wordWrap: { width: 330 } + }).setDepth(1501); + + this.add.text(46, 980, '이동', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: '#d8b15f', + fontStyle: 'bold' + }).setDepth(1502); + this.add.text(46, 1012, 'WASD / 방향키 · 바닥 클릭', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '19px', + color: '#e3e7eb' + }).setDepth(1502); + this.add.text(445, 980, '대화', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: '#d8b15f', + fontStyle: 'bold' + }).setDepth(1502); + this.add.text(445, 1012, '가까이에서 E / Space / Enter', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '19px', + color: '#e3e7eb' + }).setDepth(1502); + + this.promptBackground = this.add.rectangle(870, 1018, 540, 58, 0x2a2119, 0.98) + .setStrokeStyle(2, palette.gold, 0.9) + .setDepth(1510) + .setVisible(false); + this.promptText = this.add.text(870, 1018, '', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '21px', + color: '#f6e3af', + fontStyle: 'bold' + }).setOrigin(0.5).setDepth(1511).setVisible(false); + } + + private createLoadingOverlay() { + const shade = this.add.rectangle(0, 0, sceneWidth, sceneHeight, 0x080b0f, 0.72).setOrigin(0); + const panel = this.add.rectangle(sceneWidth / 2, sceneHeight / 2, 500, 126, 0x151b24, 0.98) + .setStrokeStyle(2, palette.gold, 0.8); + const text = this.add.text(sceneWidth / 2, sceneHeight / 2, '탁현의 사람들을 불러오는 중...', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '24px', + color: '#f3dfaa' + }).setOrigin(0.5); + this.loadingOverlay = this.add.container(0, 0, [shade, panel, text]).setDepth(4000); + } + + private createActors() { + this.playerShadow = this.add.ellipse(750, 870, 66, 25, 0x07100a, 0.45).setDepth(800); + this.player = this.createCharacterSprite('unit-liu-bei', 750, 850, characterDisplaySize + 8, 'north'); + this.player.setDepth(851); + + prologueVillageNpcDefinitions.forEach((definition) => { + const shadow = this.add.ellipse(definition.x, definition.y + 20, 62, 23, 0x07100a, 0.4) + .setDepth(100 + definition.y); + const sprite = this.createCharacterSprite( + definition.textureKey, + definition.x, + definition.y, + characterDisplaySize, + definition.direction + ); + sprite.setDepth(110 + definition.y); + const nameplate = this.add.text(definition.x, definition.y + 63, definition.name, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: '#f5ead0', + backgroundColor: '#14181ccc', + padding: { left: 8, right: 8, top: 4, bottom: 4 } + }).setOrigin(0.5).setDepth(1200); + const marker = definition.objectiveId + ? this.add.text(definition.x, definition.y - 72, '!', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '31px', + color: '#2a2013', + fontStyle: 'bold', + backgroundColor: '#e5bd68', + padding: { left: 11, right: 11, top: 2, bottom: 2 } + }).setOrigin(0.5).setDepth(1201) + : undefined; + if (marker) { + this.tweens.add({ + targets: marker, + y: marker.y - 8, + duration: 720, + yoyo: true, + repeat: -1, + ease: 'Sine.easeInOut' + }); + } + this.npcViews.set(definition.id, { definition, sprite, shadow, nameplate, marker }); + }); + + this.oathMarker = this.add.text(oathPosition.x, oathPosition.y - 58, '◇', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '35px', + color: '#88756c', + fontStyle: 'bold', + stroke: '#341e27', + strokeThickness: 5 + }).setOrigin(0.5).setDepth(1202); + this.oathLabel = this.add.text(oathPosition.x, oathPosition.y + 42, '결의의 자리', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: '#d9c0c1', + backgroundColor: '#2c2025dd', + padding: { left: 10, right: 10, top: 4, bottom: 4 } + }).setOrigin(0.5).setDepth(1201); + this.tweens.add({ + targets: [this.oathMarker, this.oathLabel], + alpha: { from: 0.7, to: 1 }, + duration: 900, + yoyo: true, + repeat: -1, + ease: 'Sine.easeInOut' + }); + } + + private createCharacterSprite( + textureKey: string, + x: number, + y: number, + size: number, + direction: UnitDirection + ) { + const resolvedTextureKey = this.textures.exists(textureKey) ? textureKey : '__DEFAULT'; + const sprite = this.add.sprite(x, y, resolvedTextureKey, 0).setDisplaySize(size, size); + if (this.textures.exists(textureKey)) { + sprite.play(`${textureKey}-idle-${direction}`); + } + return sprite; + } + + private createDialoguePanel() { + const x = 72; + const y = 710; + const width = 1776; + const height = 304; + const shade = this.add.rectangle(0, 0, sceneWidth, sceneHeight, 0x06080b, 0.44) + .setOrigin(0) + .setInteractive(); + const background = this.add.rectangle(x, y, width, height, 0x141a23, 0.985) + .setOrigin(0) + .setStrokeStyle(3, palette.gold, 0.92); + const inner = this.add.rectangle(x + 16, y + 16, width - 32, height - 32, 0x1d2531, 0.82) + .setOrigin(0) + .setStrokeStyle(1, 0x546174, 0.7); + const avatarFrame = this.add.rectangle(x + 105, y + 152, 178, 230, 0x0e131a, 0.92) + .setStrokeStyle(2, 0x9e8350, 0.9); + this.dialogueAvatar = this.add.sprite(x + 105, y + 155, 'unit-liu-bei', 0) + .setDisplaySize(186, 186); + this.dialogueNameText = this.add.text(x + 222, y + 42, '', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '29px', + color: '#f1d691', + fontStyle: 'bold' + }); + this.dialogueBodyText = this.add.text(x + 222, y + 101, '', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '27px', + color: '#f0ede5', + lineSpacing: 11, + wordWrap: { width: 1440, useAdvancedWrap: true } + }); + this.dialogueProgressText = this.add.text(x + width - 34, y + height - 33, '', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '18px', + color: '#aeb8c4' + }).setOrigin(1, 0.5); + + this.dialoguePanel = this.add.container(0, 0, [ + shade, + background, + inner, + avatarFrame, + this.dialogueAvatar, + this.dialogueNameText, + this.dialogueBodyText, + this.dialogueProgressText + ]).setDepth(3000).setVisible(false); + } + + private setupInput() { + const keyboard = this.input.keyboard; + if (keyboard) { + this.cursorKeys = keyboard.createCursorKeys(); + this.moveKeys = { + up: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.W), + down: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.S), + left: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.A), + right: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.D) + }; + this.interactKeys = [ + keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.E), + keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE), + keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ENTER) + ]; + this.interactKeys.forEach((key) => { + key.on('down', () => { + this.interactionQueued = true; + }); + }); + keyboard.addCapture([ + Phaser.Input.Keyboard.KeyCodes.UP, + Phaser.Input.Keyboard.KeyCodes.DOWN, + Phaser.Input.Keyboard.KeyCodes.LEFT, + Phaser.Input.Keyboard.KeyCodes.RIGHT, + Phaser.Input.Keyboard.KeyCodes.SPACE + ]); + } + + this.input.on(Phaser.Input.Events.POINTER_DOWN, (pointer: Phaser.Input.Pointer) => { + if (!this.ready || this.navigationPending || this.time.now < this.inputReadyAt) { + return; + } + if (this.dialogueState) { + this.advanceDialogue(); + return; + } + if (pointer.x >= movementBounds.left && pointer.x <= movementBounds.right && + pointer.y >= movementBounds.top && pointer.y <= movementBounds.bottom) { + this.moveTarget = new Phaser.Math.Vector2(pointer.x, pointer.y); + } + }); + } + + private updateMovement(time: number, delta: number) { + if (!this.player) { + return; + } + const keyboardDirection = this.keyboardMovementDirection(); + let movement = keyboardDirection; + if (movement.lengthSq() > 0) { + this.moveTarget = undefined; + } else if (this.moveTarget) { + const targetDelta = this.moveTarget.clone().subtract(new Phaser.Math.Vector2(this.player.x, this.player.y)); + if (targetDelta.length() <= 7) { + this.moveTarget = undefined; + movement = new Phaser.Math.Vector2(); + } else { + movement = targetDelta.normalize(); + } + } + + if (movement.lengthSq() <= 0) { + this.setPlayerMoving(false); + return; + } + + movement.normalize(); + this.playerDirection = this.directionForVector(movement); + const distance = playerSpeed * Math.min(delta, 50) / 1000; + const startX = this.player.x; + const startY = this.player.y; + const nextX = startX + movement.x * distance; + const nextY = startY + movement.y * distance; + + let moved = false; + if (this.canPlayerOccupy(nextX, startY)) { + this.player.x = nextX; + moved = true; + } + if (this.canPlayerOccupy(this.player.x, nextY)) { + this.player.y = nextY; + moved = true; + } + if (!moved && this.moveTarget) { + this.moveTarget = undefined; + } + + this.syncPlayerView(); + this.setPlayerMoving(moved); + if (moved && time - this.lastStepAt >= 280) { + this.lastStepAt = time; + this.stepIndex += 1; + soundDirector.playMovementStep(false, this.stepIndex, { + terrain: 'plain', + volume: 0.09, + rate: 1.04, + stopAfterMs: 620 + }); + } + } + + private consumeInteractionRequest() { + const keyPressedThisFrame = this.interactKeys.some((key) => Phaser.Input.Keyboard.JustDown(key)); + const requested = this.interactionQueued || keyPressedThisFrame; + this.interactionQueued = false; + return requested; + } + + private keyboardMovementDirection() { + const vector = new Phaser.Math.Vector2(); + if (this.cursorKeys?.left.isDown || this.moveKeys?.left.isDown) { + vector.x -= 1; + } + if (this.cursorKeys?.right.isDown || this.moveKeys?.right.isDown) { + vector.x += 1; + } + if (this.cursorKeys?.up.isDown || this.moveKeys?.up.isDown) { + vector.y -= 1; + } + if (this.cursorKeys?.down.isDown || this.moveKeys?.down.isDown) { + vector.y += 1; + } + return vector; + } + + private directionForVector(vector: Phaser.Math.Vector2): UnitDirection { + if (Math.abs(vector.x) > Math.abs(vector.y)) { + return vector.x > 0 ? 'east' : 'west'; + } + return vector.y > 0 ? 'south' : 'north'; + } + + private canPlayerOccupy(x: number, y: number) { + if ( + x - playerCollisionRadius < movementBounds.left || + x + playerCollisionRadius > movementBounds.right || + y - playerCollisionRadius < movementBounds.top || + y + playerCollisionRadius > movementBounds.bottom + ) { + return false; + } + const feet = new Phaser.Geom.Rectangle( + x - playerCollisionRadius, + y + 18, + playerCollisionRadius * 2, + 28 + ); + if (this.blockers.some((blocker) => Phaser.Geom.Intersects.RectangleToRectangle(feet, blocker))) { + return false; + } + return Array.from(this.npcViews.values()).every(({ sprite }) => ( + Phaser.Math.Distance.Between(x, y, sprite.x, sprite.y) >= 48 + )); + } + + private setPlayerMoving(moving: boolean) { + if (!this.player) { + return; + } + const animationKey = `unit-liu-bei-${moving ? 'walk' : 'idle'}-${this.playerDirection}`; + if (this.textures.exists('unit-liu-bei') && this.player.anims.currentAnim?.key !== animationKey) { + this.player.play(animationKey); + } + this.playerMoving = moving; + } + + private stopPlayerMovement() { + this.moveTarget = undefined; + this.setPlayerMoving(false); + } + + private syncPlayerView() { + if (!this.player) { + return; + } + this.playerShadow?.setPosition(this.player.x, this.player.y + 20); + this.playerShadow?.setDepth(90 + this.player.y); + this.player.setDepth(100 + this.player.y); + } + + private setPlayerPosition(x: number, y: number) { + if (!this.player) { + return; + } + this.player.setPosition(x, y); + this.syncPlayerView(); + this.stopPlayerMovement(); + } + + private safeApproachPosition(targetX: number, targetY: number) { + const candidates = [ + { x: targetX, y: targetY + 88 }, + { x: targetX - 88, y: targetY }, + { x: targetX + 88, y: targetY }, + { x: targetX, y: targetY - 88 } + ]; + return candidates.find((candidate) => this.canPlayerOccupy(candidate.x, candidate.y)) ?? + { x: Phaser.Math.Clamp(targetX, movementBounds.left + 40, movementBounds.right - 40), + y: Phaser.Math.Clamp(targetY + 100, movementBounds.top + 40, movementBounds.bottom - 40) }; + } + + private tryInteract() { + const target = this.nearestInteractionTarget(interactionRadius); + if (!target) { + this.showPromptMessage('조금 더 가까이 다가가세요.'); + return; + } + this.interactWithTarget(target); + } + + private interactWithTarget(target: InteractionTarget) { + this.stopPlayerMovement(); + if (target.kind === 'oath') { + this.interactWithOath(); + return; + } + + const view = this.npcViews.get(target.id); + if (!view) { + return; + } + this.faceCharactersForConversation(view); + const completed = view.definition.objectiveId + ? this.isObjectiveComplete(view.definition.objectiveId) + : false; + const dialogue = completed && view.definition.repeatDialogue?.length + ? view.definition.repeatDialogue + : view.definition.dialogue; + this.startDialogue(dialogue, () => { + if (view.definition.objectiveId && !completed) { + this.completeObjective(view.definition.objectiveId); + } + }, view.definition.id); + } + + private interactWithOath() { + if (!this.allRequiredObjectivesComplete()) { + const remaining = prologueVillageObjectiveDefinitions + .filter((objective) => objective.id !== 'make-oath' && !this.isObjectiveComplete(objective.id)) + .map((objective) => objective.shortLabel) + .join(' · '); + this.startDialogue([ + { + speaker: '유비', + textureKey: 'unit-liu-bei', + text: `아직 준비가 끝나지 않았다. 먼저 ${remaining}을 마치자.` + } + ]); + return; + } + + this.startDialogue([ + { + speaker: '유비', + textureKey: 'unit-liu-bei', + text: '우리는 성은 다르나 이제 형제가 되어, 위로는 나라에 보답하고 아래로는 백성을 편안하게 하겠습니다.' + }, + { + speaker: '관우', + textureKey: 'unit-guan-yu', + text: '한마음으로 힘을 합쳐 어려움 속에서도 서로를 저버리지 않겠습니다.' + }, + { + speaker: '장비', + textureKey: 'unit-zhang-fei', + text: '좋소! 오늘부터 함께 싸워 이 마을과 백성을 지킵시다!' + }, + { + speaker: '전황', + text: '세 사람의 뜻이 하나로 공명했다. 탁현 의용군이 북문 앞에 모이기 시작했다.' + }, + { + speaker: '관우', + textureKey: 'unit-guan-yu', + text: '황건 잔당은 북동쪽 길목에 있습니다. 장비가 측면을 흔들면 제가 정면을 열겠습니다.' + }, + { + speaker: '유비', + textureKey: 'unit-liu-bei', + text: '좋소. 부상자를 지키며 전열을 잇겠습니다. 이제 첫 출전입니다.' + } + ], () => this.finishVillage(), 'make-oath'); + } + + private startDialogue( + lines: PrologueVillageDialogueLine[], + onComplete?: () => void, + sourceNpcId?: string + ) { + if (!lines.length || this.navigationPending) { + return; + } + this.stopPlayerMovement(); + this.dialogueState = { lines, lineIndex: 0, onComplete, sourceNpcId }; + this.dialoguePanel?.setVisible(true); + this.promptBackground?.setVisible(false); + this.promptText?.setVisible(false); + soundDirector.playSelect(); + this.renderDialogueLine(); + } + + private renderDialogueLine() { + const dialogue = this.dialogueState; + if (!dialogue) { + return; + } + const line = dialogue.lines[dialogue.lineIndex]; + this.dialogueNameText?.setText(line.speaker); + this.dialogueBodyText?.setText(line.text); + this.dialogueProgressText?.setText( + `${dialogue.lineIndex + 1} / ${dialogue.lines.length} E · Space · 클릭으로 계속` + ); + if (this.dialogueAvatar) { + const textureKey = line.textureKey; + if (textureKey && this.textures.exists(textureKey)) { + this.dialogueAvatar.setTexture(textureKey, 0).setVisible(true); + this.dialogueAvatar.play(`${textureKey}-idle-south`); + } else { + this.dialogueAvatar.setVisible(false); + } + } + } + + private advanceDialogue() { + const dialogue = this.dialogueState; + if (!dialogue || this.navigationPending) { + return; + } + if (dialogue.lineIndex < dialogue.lines.length - 1) { + dialogue.lineIndex += 1; + soundDirector.playStoryAdvanceCue(); + this.renderDialogueLine(); + return; + } + + const onComplete = dialogue.onComplete; + this.dialogueState = undefined; + this.dialoguePanel?.setVisible(false); + this.stopPlayerMovement(); + onComplete?.(); + this.refreshInteractionPrompt(); + } + + private completeObjective(objectiveId: PrologueVillageRequiredObjectiveId) { + if (this.isObjectiveComplete(objectiveId)) { + return; + } + completeCampaignTutorial(objectiveTutorialIds[objectiveId]); + soundDirector.playObjectiveAchieved(); + this.refreshObjectiveHud(); + this.refreshNpcMarkers(); + const objective = prologueVillageObjectiveDefinitions.find((entry) => entry.id === objectiveId); + this.showCompletionToast(`${objective?.shortLabel ?? '준비'} 완료`); + } + + private finishVillage() { + if (this.navigationPending) { + return; + } + if (!this.allRequiredObjectivesComplete()) { + this.interactWithOath(); + return; + } + + completeCampaignTutorial(prologueVillageCampaignTutorialIds.complete); + this.refreshObjectiveHud(); + this.navigationPending = true; + this.stopPlayerMovement(); + soundDirector.playObjectiveAchieved(); + this.showTransitionOverlay(); + + void startGameScene(this, 'StoryScene', { + pages: prologueDeparturePages(), + nextScene: 'BattleScene', + nextSceneData: { battleId: 'first-battle-zhuo-commandery' }, + presentationBattleId: 'first-battle-zhuo-commandery', + presentationStage: 'story' + }).catch((error) => { + console.error('Failed to continue from the prologue village.', error); + this.navigationPending = false; + this.transitionOverlay?.destroy(); + this.transitionOverlay = undefined; + this.refreshInteractionPrompt(); + }); + } + + private showTransitionOverlay() { + const shade = this.add.rectangle(0, 0, sceneWidth, sceneHeight, 0x080b0f, 0.78).setOrigin(0); + const title = this.add.text(sceneWidth / 2, sceneHeight / 2 - 32, '탁현 의용군 · 첫 출전', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '44px', + color: '#f3dda2', + fontStyle: 'bold' + }).setOrigin(0.5); + const subtitle = this.add.text(sceneWidth / 2, sceneHeight / 2 + 44, '북문 밖의 황건 잔당을 향해 이동합니다.', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '24px', + color: '#d7dde4' + }).setOrigin(0.5); + this.transitionOverlay = this.add.container(0, 0, [shade, title, subtitle]) + .setDepth(5000) + .setAlpha(0); + this.tweens.add({ + targets: this.transitionOverlay, + alpha: 1, + duration: 320, + ease: 'Sine.easeOut' + }); + } + + private showCompletionToast(label: string) { + this.completionToast?.destroy(); + const background = this.add.rectangle(748, 132, 380, 70, 0x17251c, 0.98) + .setStrokeStyle(2, 0x8fbd72, 0.9); + const text = this.add.text(748, 132, `✓ ${label}`, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '24px', + color: '#dff0c9', + fontStyle: 'bold' + }).setOrigin(0.5); + this.completionToast = this.add.container(0, 0, [background, text]).setDepth(2200); + this.tweens.add({ + targets: this.completionToast, + alpha: 0, + y: -18, + delay: 1150, + duration: 360, + onComplete: () => { + this.completionToast?.destroy(); + this.completionToast = undefined; + } + }); + } + + private showPromptMessage(message: string) { + this.promptBackground?.setVisible(true); + this.promptText?.setText(message).setVisible(true); + this.time.delayedCall(850, () => this.refreshInteractionPrompt()); + } + + private refreshObjectiveHud() { + const requiredComplete = this.completedRequiredObjectiveIds(); + prologueVillageObjectiveDefinitions.forEach((objective) => { + const row = this.objectiveRows.get(objective.id); + if (!row) { + return; + } + const isOath = objective.id === 'make-oath'; + let completed: boolean; + if (objective.id === 'make-oath') { + completed = hasCompletedCampaignTutorial(prologueVillageCampaignTutorialIds.complete); + } else { + completed = this.isObjectiveComplete(objective.id); + } + const unlocked = !isOath || this.allRequiredObjectivesComplete(); + row.status.setText(completed ? '✓ 완료' : unlocked ? '◆ 진행 가능' : '◇ 준비 후 개방'); + row.status.setColor(completed ? '#a8d58e' : unlocked ? '#e1bc69' : '#7f8994'); + row.label.setColor(completed ? '#b8c4b3' : unlocked ? '#f0e6d2' : '#7f8994'); + row.location.setColor(completed ? '#7f9b78' : unlocked ? '#a4afba' : '#666f79'); + row.background.setStrokeStyle( + 2, + completed ? 0x628653 : unlocked ? 0x8a7043 : 0x3d4858, + completed ? 0.9 : 0.75 + ); + }); + + const remaining = prologueVillageRequiredObjectiveIds.length - requiredComplete.length; + this.objectiveSummaryText?.setText( + remaining > 0 + ? `필수 준비 ${requiredComplete.length} / ${prologueVillageRequiredObjectiveIds.length}\n세 항목은 원하는 순서로 확인할 수 있습니다.` + : '모든 준비 완료\n복숭아 동산의 빛나는 표식으로 이동하세요.' + ); + if (this.oathMarker) { + const unlocked = this.allRequiredObjectivesComplete(); + this.oathMarker.setText(unlocked ? '!' : '◇'); + this.oathMarker.setColor(unlocked ? '#2a2013' : '#88756c'); + this.oathMarker.setBackgroundColor(unlocked ? '#f1c96f' : ''); + this.oathLabel?.setText(unlocked ? '세 사람의 결의' : '결의의 자리'); + this.oathLabel?.setColor(unlocked ? '#ffe7b0' : '#a78f91'); + } + } + + private refreshNpcMarkers() { + this.npcViews.forEach(({ definition, marker }) => { + if (!definition.objectiveId || !marker) { + return; + } + const completed = this.isObjectiveComplete(definition.objectiveId); + marker.setText(completed ? '✓' : '!'); + marker.setColor(completed ? '#e5f2d5' : '#2a2013'); + marker.setBackgroundColor(completed ? '#4e7549' : '#e5bd68'); + }); + } + + private refreshInteractionPrompt() { + if (!this.ready || this.dialogueState || this.navigationPending) { + this.promptBackground?.setVisible(false); + this.promptText?.setVisible(false); + return; + } + const target = this.nearestInteractionTarget(promptRadius); + if (!target) { + this.promptBackground?.setVisible(false); + this.promptText?.setVisible(false); + return; + } + const closeEnough = this.distanceTo(target.x, target.y) <= interactionRadius; + this.promptBackground?.setVisible(true); + this.promptText + ?.setText(closeEnough ? `E / Space ${target.name}` : `${target.name}에게 가까이 가세요`) + .setColor(closeEnough ? '#f6e3af' : '#b2bbc4') + .setVisible(true); + } + + private nearestInteractionTarget(radius: number) { + if (!this.player) { + return undefined; + } + const targets: InteractionTarget[] = [ + ...Array.from(this.npcViews.values()).map(({ definition, sprite }) => ({ + kind: 'npc' as const, + id: definition.id, + name: definition.name, + x: sprite.x, + y: sprite.y + })), + { + kind: 'oath' as const, + id: 'make-oath' as const, + name: this.allRequiredObjectivesComplete() ? '세 사람의 결의' : '결의의 자리', + x: oathPosition.x, + y: oathPosition.y + } + ]; + return targets + .map((target) => ({ target, distance: this.distanceTo(target.x, target.y) })) + .filter(({ distance }) => distance <= radius) + .sort((left, right) => left.distance - right.distance)[0]?.target; + } + + private interactionTargetById(targetId: string): InteractionTarget | undefined { + if (targetId === 'make-oath') { + return { + kind: 'oath', + id: 'make-oath', + name: '세 사람의 결의', + x: oathPosition.x, + y: oathPosition.y + }; + } + const view = this.npcViews.get(targetId); + return view + ? { + kind: 'npc', + id: view.definition.id, + name: view.definition.name, + x: view.sprite.x, + y: view.sprite.y + } + : undefined; + } + + private faceCharactersForConversation(view: VillageNpcView) { + if (!this.player) { + return; + } + const toNpc = new Phaser.Math.Vector2(view.sprite.x - this.player.x, view.sprite.y - this.player.y); + this.playerDirection = this.directionForVector(toNpc); + this.setPlayerMoving(false); + const npcDirection = this.directionForVector(toNpc.scale(-1)); + if (this.textures.exists(view.definition.textureKey)) { + view.sprite.play(`${view.definition.textureKey}-idle-${npcDirection}`); + } + } + + private isObjectiveComplete(objectiveId: PrologueVillageRequiredObjectiveId) { + return hasCompletedCampaignTutorial(objectiveTutorialIds[objectiveId]); + } + + private completedRequiredObjectiveIds() { + return prologueVillageRequiredObjectiveIds.filter((objectiveId) => this.isObjectiveComplete(objectiveId)); + } + + private allRequiredObjectivesComplete() { + return this.completedRequiredObjectiveIds().length === prologueVillageRequiredObjectiveIds.length; + } + + private distanceTo(x: number, y: number) { + return this.player ? Phaser.Math.Distance.Between(this.player.x, this.player.y, x, y) : Number.POSITIVE_INFINITY; + } + + private boundsSnapshot(bounds: Phaser.Geom.Rectangle) { + return { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height + }; + } +} diff --git a/src/game/scenes/StoryScene.ts b/src/game/scenes/StoryScene.ts index de8fe73..4a8f531 100644 --- a/src/game/scenes/StoryScene.ts +++ b/src/game/scenes/StoryScene.ts @@ -46,7 +46,8 @@ import { type StoryCutsceneMarker, type StoryCutsceneReward } from '../data/storyCutscenes'; -import { prologuePages, type PortraitKey, type StoryPage } from '../data/scenario'; +import { prologueOpeningPages } from '../data/prologueVillage'; +import { type PortraitKey, type StoryPage } from '../data/scenario'; import { campaignVictoryRewardPresentation, getFirstBattleReport } from '../state/campaignState'; import { releaseTextureSource } from '../render/loaderLifecycle'; import { isVisualMotionReduced } from '../settings/visualMotion'; @@ -187,9 +188,9 @@ const firstBattleWarmupUnitTextureKeys = [ export class StoryScene extends Phaser.Scene { private pageIndex = 0; - private pages: StoryPage[] = prologuePages; - private selectedStoryBackgroundKeys = storyBackgroundKeysForPages(prologuePages); - private nextScene = 'BattleScene'; + private pages: StoryPage[] = prologueOpeningPages(); + private selectedStoryBackgroundKeys = storyBackgroundKeysForPages(prologueOpeningPages()); + private nextScene = 'PrologueVillageScene'; private nextSceneData?: Record; private presentationBattleId?: string; private presentationStage: Extract = 'story'; @@ -230,9 +231,9 @@ export class StoryScene extends Phaser.Scene { } init(data?: StorySceneData) { - this.pages = data?.pages?.length ? data.pages : prologuePages; + this.pages = data?.pages?.length ? data.pages : prologueOpeningPages(); this.selectedStoryBackgroundKeys = storyBackgroundKeysForPages(this.pages); - this.nextScene = data?.nextScene ?? 'BattleScene'; + this.nextScene = data?.nextScene ?? 'PrologueVillageScene'; this.nextSceneData = data?.nextSceneData; this.presentationBattleId = data?.presentationBattleId; this.presentationStage = data?.presentationStage ?? 'story'; @@ -301,6 +302,8 @@ export class StoryScene extends Phaser.Scene { advanceHint: isLastPage && this.nextScene === 'BattleScene' ? 'battle-deployment' + : isLastPage && this.nextScene === 'PrologueVillageScene' + ? 'village-exploration' : isLastPage && this.nextScene === 'CampScene' ? 'camp-return' : isLastPage @@ -576,6 +579,11 @@ export class StoryScene extends Phaser.Scene { } private warmFirstBattleSceneModule() { + if (this.nextScene === 'PrologueVillageScene') { + void ensureLazyScene(this.game, 'PrologueVillageScene').catch((error) => { + console.warn('Failed to warm the prologue village scene module.', error); + }); + } if (!this.isFirstBattlePrelude()) { return; } @@ -878,6 +886,9 @@ export class StoryScene extends Phaser.Scene { if (this.nextScene === 'BattleScene') { return '전투 배치'; } + if (this.nextScene === 'PrologueVillageScene') { + return '마을로 이동'; + } if (this.nextScene === 'CampScene') { return '군영으로'; } diff --git a/src/game/scenes/TitleScene.ts b/src/game/scenes/TitleScene.ts index 01ccf81..696173a 100644 --- a/src/game/scenes/TitleScene.ts +++ b/src/game/scenes/TitleScene.ts @@ -19,18 +19,20 @@ import { saveVisualMotionMode, visualMotionModeLabel } from '../settings/visualMotion'; +import { prologueDeparturePages, prologueOpeningPages } from '../data/prologueVillage'; import { battleIdForCampaignStep, isCampCampaignStep } from '../state/campaignRouting'; import { readCampaignBattleResume, type CampaignBattleResume } from '../state/battleSaveStorage'; import { hasCampaignSave, listCampaignSaveSlots, loadCampaignState, + prologueVillageCampaignTutorialIds, summarizeCampaignProgress, startNewCampaign, type CampaignSaveSlotSummary } from '../state/campaignState'; import { palette } from '../ui/palette'; -import { startLazyScene } from './lazyScenes'; +import { startLazyScene, type LazySceneKey } from './lazyScenes'; const fhdUiScale = 1.5; const ui = (value: number) => value * fhdUiScale; @@ -883,6 +885,8 @@ export class TitleScene extends Phaser.Scene { soundDirector.playSelect(); startNewCampaign(); void this.navigateTo('StoryScene', { + pages: prologueOpeningPages(), + nextScene: 'PrologueVillageScene', presentationBattleId: 'first-battle-zhuo-commandery', presentationStage: 'story' }); @@ -930,6 +934,31 @@ export class TitleScene extends Phaser.Scene { return; } + if (campaign.step === 'prologue') { + await this.navigateTo('StoryScene', { + pages: prologueOpeningPages(), + nextScene: 'PrologueVillageScene', + presentationBattleId: 'first-battle-zhuo-commandery', + presentationStage: 'story' + }); + return; + } + + if (campaign.step === 'prologue-town') { + if (campaign.completedTutorialIds.includes(prologueVillageCampaignTutorialIds.complete)) { + await this.navigateTo('StoryScene', { + pages: prologueDeparturePages(), + nextScene: 'BattleScene', + nextSceneData: { battleId: 'first-battle-zhuo-commandery' }, + presentationBattleId: 'first-battle-zhuo-commandery', + presentationStage: 'story' + }); + } else { + await this.navigateTo('PrologueVillageScene'); + } + return; + } + const battleId = battleIdForCampaignStep(campaign.step); if (battleId) { if (campaign.step !== 'first-battle' && campaign.firstBattleReport?.outcome === 'victory') { @@ -956,12 +985,14 @@ export class TitleScene extends Phaser.Scene { } await this.navigateTo('StoryScene', { + pages: prologueOpeningPages(), + nextScene: 'PrologueVillageScene', presentationBattleId: 'first-battle-zhuo-commandery', presentationStage: 'story' }); } - private async navigateTo(sceneKey: 'StoryScene' | 'BattleScene' | 'CampScene' | 'EndingScene', data?: Record) { + private async navigateTo(sceneKey: LazySceneKey, data?: Record) { if (this.navigating) { return; } diff --git a/src/game/scenes/lazyScenes.ts b/src/game/scenes/lazyScenes.ts index 9430d6a..4bc7808 100644 --- a/src/game/scenes/lazyScenes.ts +++ b/src/game/scenes/lazyScenes.ts @@ -1,11 +1,17 @@ import Phaser from 'phaser'; -export type LazySceneKey = 'StoryScene' | 'BattleScene' | 'CampScene' | 'EndingScene'; +export type LazySceneKey = + | 'StoryScene' + | 'PrologueVillageScene' + | 'BattleScene' + | 'CampScene' + | 'EndingScene'; type LazySceneConstructor = new () => Phaser.Scene; const lazySceneLoaders: Record Promise> = { StoryScene: () => import('./StoryScene').then((module) => module.StoryScene), + PrologueVillageScene: () => import('./PrologueVillageScene').then((module) => module.PrologueVillageScene), BattleScene: () => import('./BattleScene').then((module) => module.BattleScene), CampScene: () => import('./CampScene').then((module) => module.CampScene), EndingScene: () => import('./EndingScene').then((module) => module.EndingScene) @@ -14,7 +20,13 @@ const lazySceneLoaders: Record Promise const pendingSceneLoads = new Map>(); export function isLazySceneKey(key: string): key is LazySceneKey { - return key === 'StoryScene' || key === 'BattleScene' || key === 'CampScene' || key === 'EndingScene'; + return ( + key === 'StoryScene' || + key === 'PrologueVillageScene' || + key === 'BattleScene' || + key === 'CampScene' || + key === 'EndingScene' + ); } export async function ensureLazyScene(game: Phaser.Game, key: LazySceneKey) { diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts index 1a0c65a..faabfc9 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -171,6 +171,7 @@ export type CampaignVictoryRewardPresentation = { export type CampaignStep = | 'new' | 'prologue' + | 'prologue-town' | 'first-battle' | 'first-camp' | 'first-victory-story' @@ -461,7 +462,18 @@ export type CampaignSortieOrderHistory = Partial< Record>> >; -export const campaignTutorialIds = ['first-battle-basic-controls'] as const; +export const prologueVillageCampaignTutorialIds = { + entered: 'prologue-village-entered', + meetZhangFei: 'prologue-village-meet-zhang-fei', + meetGuanYu: 'prologue-village-meet-guan-yu', + checkSupplies: 'prologue-village-check-supplies', + complete: 'prologue-village-complete' +} as const; + +export const campaignTutorialIds = [ + 'first-battle-basic-controls', + ...Object.values(prologueVillageCampaignTutorialIds) +] as const; export type CampaignTutorialId = (typeof campaignTutorialIds)[number]; const campaignTutorialIdSet = new Set(campaignTutorialIds); @@ -574,6 +586,7 @@ const campaignBattleSteps: Record([ 'new', 'prologue', + 'prologue-town', 'first-victory-story', 'ending-complete', 'hanzhong-king-camp', @@ -603,6 +616,7 @@ const campaignRecruitUnitById = new Map(campaignRecruitUnits.map((unit) => [unit const campaignStepProgressLabels: Partial> = { new: { title: '새 캠페인', meta: '시작 전' }, prologue: { title: '도원 결의', meta: '프롤로그' }, + 'prologue-town': { title: '탁현에서 모인 뜻', meta: '마을 탐색 · 출전 준비' }, 'first-victory-story': { title: '탁현 방어 성공', meta: '승리 후 이야기' }, 'hanzhong-king-camp': { title: '한중왕 즉위 준비', meta: '군영 의정' }, 'shu-han-foundation-camp': { title: '촉한 건국 선포', meta: '군영 의정' }, diff --git a/src/main.ts b/src/main.ts index 4891c84..5ec11d6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -24,14 +24,24 @@ type DebugCampScene = Phaser.Scene & { getDebugState?: () => unknown; }; +type DebugVillageScene = Phaser.Scene & { + getDebugState?: () => unknown; + debugTeleportTo?: (targetId: string) => boolean; + debugInteractWith?: (targetId: string) => boolean; + debugCompleteRequiredObjectives?: () => string[]; + debugCompleteVillage?: () => boolean; +}; + type HerosDebugApi = { game: Phaser.Game; activeScenes: () => string[]; audio: () => ReturnType; battle: () => unknown; camp: () => unknown; + village: () => unknown; goToBattle: (battleId?: string) => Promise; goToCamp: () => Promise; + goToVillage: () => Promise; forceBattleOutcome: (outcome: 'victory' | 'defeat') => void; scene: (key: string) => Phaser.Scene | undefined; toggleBattleOverlay: () => void; @@ -82,6 +92,7 @@ function createDebugApi(game: Phaser.Game): HerosDebugApi { const scene = (key: string) => game.scene.getScene(key); const battleScene = () => scene('BattleScene') as DebugBattleScene | undefined; const campScene = () => scene('CampScene') as DebugCampScene | undefined; + const villageScene = () => scene('PrologueVillageScene') as DebugVillageScene | undefined; return { game, @@ -93,8 +104,12 @@ function createDebugApi(game: Phaser.Game): HerosDebugApi { camp: () => game.scene.isActive('CampScene') ? campScene()?.getDebugState?.() ?? { active: false, reason: 'CampScene debug state is unavailable.' } : { active: false, reason: 'CampScene is not active yet.' }, + village: () => game.scene.isActive('PrologueVillageScene') + ? villageScene()?.getDebugState?.() ?? { active: false, reason: 'PrologueVillageScene debug state is unavailable.' } + : { active: false, reason: 'PrologueVillageScene is not active yet.' }, goToBattle: (battleId) => startLazySceneFromGame(game, 'BattleScene', battleId ? { battleId } : undefined), goToCamp: () => startLazySceneFromGame(game, 'CampScene'), + goToVillage: () => startLazySceneFromGame(game, 'PrologueVillageScene'), forceBattleOutcome: (outcome) => { battleScene()?.debugForceBattleOutcome?.(outcome); },