From 412b8691b5bcb14c619afd0a2a099bebcd11a114 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Fri, 24 Jul 2026 02:20:10 +0900 Subject: [PATCH] feat: add playable inter-battle city stays --- scripts/verify-city-stay-browser.mjs | 1232 ++++++++++------- scripts/verify-city-stay-data.mjs | 220 ++- src/game/data/cityStayExploration.ts | 301 ++++ src/game/scenes/CampScene.ts | 242 ++-- src/game/scenes/CityStayScene.ts | 1916 ++++++++++++++++++++++++++ src/game/scenes/TitleScene.ts | 6 + src/game/scenes/lazyScenes.ts | 3 + src/game/state/campaignState.ts | 51 + src/game/state/cityStayActions.ts | 205 +++ src/main.ts | 18 + 10 files changed, 3605 insertions(+), 589 deletions(-) create mode 100644 src/game/data/cityStayExploration.ts create mode 100644 src/game/scenes/CityStayScene.ts create mode 100644 src/game/state/cityStayActions.ts diff --git a/scripts/verify-city-stay-browser.mjs b/scripts/verify-city-stay-browser.mjs index 64ceefd..77ead1a 100644 --- a/scripts/verify-city-stay-browser.mjs +++ b/scripts/verify-city-stay-browser.mjs @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; +import { mkdirSync } from 'node:fs'; import { chromium } from 'playwright'; import { desktopBrowserContextOptions, @@ -7,38 +8,40 @@ import { desktopBrowserViewport } from './desktop-browser-viewport.mjs'; -const xuzhouBattleId = 'seventh-battle-xuzhou-rescue'; -const xiaopeiBattleId = 'eighth-battle-xiaopei-supply-road'; -const xuzhouCityStayId = 'xuzhou'; const seedGold = 5000; -const additionalCityVisualCases = [ +const cityCases = [ + { + cityStayId: 'xuzhou', + afterBattleId: 'seventh-battle-xuzhou-rescue', + nextBattleId: 'eighth-battle-xiaopei-supply-road', + campStep: 'seventh-camp', + partnerId: 'mi-zhu', + bondId: 'liu-bei__mi-zhu', + informationId: 'city-xuzhou-xiaopei-ledger', + dialogueId: 'city-xuzhou-liu-mi-supply-trust' + }, { cityStayId: 'xinye', - cityName: '신야', afterBattleId: 'seventeenth-battle-wolong-visit-road', nextBattleId: 'eighteenth-battle-bowang-ambush', - battleTitle: '와룡 방문로', campStep: 'seventeenth-camp', partnerId: 'zhuge-liang', - partnerName: '제갈량', bondId: 'liu-bei__zhuge-liang', - bondTitle: '삼고초려', - screenshotPath: 'dist/verification-city-xinye-information.png' + informationId: 'city-xinye-bowang-scout-map', + dialogueId: 'city-xinye-liu-zhuge-first-command' }, { cityStayId: 'chengdu', - cityName: '성도', afterBattleId: 'thirty-third-battle-chengdu-surrender', nextBattleId: 'thirty-fourth-battle-jiameng-pass', - battleTitle: '성도 항복', campStep: 'thirty-third-camp', partnerId: 'huang-quan', - partnerName: '황권', bondId: 'liu-bei__huang-quan', - bondTitle: '익주의 신뢰', - screenshotPath: 'dist/verification-city-chengdu-information.png' + informationId: 'city-chengdu-jiameng-route-ledger', + dialogueId: 'city-chengdu-liu-huang-quan-order' } ]; +const [xuzhouCase, xinyeCase, chengduCase] = cityCases; const targetUrl = withDebugOptions( process.env.VERIFY_CITY_STAY_URL ?? 'http://127.0.0.1:41795/' ); @@ -47,6 +50,7 @@ let serverProcess; let browser; try { + mkdirSync('dist', { recursive: true }); serverProcess = await ensureLocalServer(targetUrl); browser = await chromium.launch({ headless: process.env.VERIFY_CITY_STAY_HEADLESS !== '0' @@ -56,45 +60,75 @@ try { 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 openCleanCamp(page); await assertDesktopViewport(page); - await seedXuzhouCityStay(page); - await restartCamp(page); + await seedCityStay(page, xuzhouCase); + await restartCamp(page, xuzhouCase.cityStayId); - const initialState = await waitForXuzhouCityStay(page); - verifyCityStayLayout(initialState); - await captureStableScreenshot(page, 'dist/verification-city-xuzhou-information.png'); - assert.equal( - initialState.victoryRewardAcknowledgement.visible, - false, - 'The seeded victory-reward notice must not obstruct the city-stay flow.' + const xuzhouGateway = await waitForCityGateway(page, xuzhouCase.cityStayId); + verifyGatewayLayout(xuzhouGateway); + await captureStableScreenshot(page, 'dist/verification-city-xuzhou-gateway.png'); + let city = await enterCityFromGateway(page, xuzhouGateway, xuzhouCase.cityStayId); + verifyExplorationLayout(city, xuzhouCase.cityStayId); + await captureStableScreenshot(page, 'dist/verification-city-xuzhou-map.png'); + + await verifyKeyboardMovement(page); + await verifyDistantInteractionIsBlocked(page); + + const informationResult = await verifyInformationReward( + page, + xuzhouCase, + xuzhouGateway.cityStay.information + ); + const marketResult = await verifyMarketPurchase( + page, + 'dist/verification-city-xuzhou-market.png' + ); + const resonanceResult = await verifyCompanionResonance( + page, + xuzhouCase, + 'dist/verification-city-xuzhou-dialogue.png' ); - const informationResult = await verifyInformationReward(page, initialState); - const marketResult = await verifyMarketPurchase(page); - const dialogueResult = await verifyCompanionDialogue(page); + city = await reloadAndContinueCityStay(page, xuzhouCase.cityStayId); + verifyRestoredOutcomes(city, xuzhouCase, informationResult, marketResult, resonanceResult); + await verifyNextBattleBriefing( + page, + xuzhouCase, + xuzhouGateway.cityStay.information.briefingLines + ); - await restartCamp(page); - const restoredState = await waitForXuzhouCityStay(page); - verifyRestoredOutcomes(restoredState, informationResult, marketResult, dialogueResult); - - await verifyNextBattleBriefing(page, restoredState.cityStay.information.briefingLines); - await verifyAdditionalCityLayouts(page); + await verifyAdditionalCityLayout(page, xinyeCase, { + verifyIncompleteExit: true + }); + await verifyAdditionalCityLayout(page, chengduCase, { + verifyIncompleteExit: false + }); assert.deepEqual( pageErrors, [], - `Expected zero browser page errors, received: ${JSON.stringify(pageErrors.slice(-5))}` + `Expected no browser page errors: ${JSON.stringify(pageErrors.slice(-8))}` + ); + assert.deepEqual( + consoleErrors.filter((message) => !message.includes('The AudioContext was not allowed to start')), + [], + `Expected no browser console errors: ${JSON.stringify(consoleErrors.slice(-8))}` ); console.log( - `Verified the Xuzhou, Xinye, and Chengdu city stays at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height}, ` + - `DPR ${desktopBrowserDeviceScaleFactor}: seven non-overlapping tabs and an in-viewport city panel; ` + - 'idempotent information rewards; exact market gold and inventory changes; saved companion choice; ' + - 'all outcomes surviving a full browser reload; completed city information appearing in each next battle briefing; ' + - 'and information, market, and resonance layouts remaining readable at the desktop baseline.' + `Verified direct inter-battle city exploration at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height}, ` + + `DPR ${desktopBrowserDeviceScaleFactor}: Camp gateway entry, real keyboard movement, distance-gated interaction, ` + + 'one-time information rewards, exact market purchases, saved resonance choices, reload/Continue restoration, ' + + 'optional-objective exit, next-battle briefing integration, and Xuzhou/Xinye/Chengdu map and modal layouts.' ); } finally { await browser?.close(); @@ -115,266 +149,74 @@ async function openCleanCamp(page) { await page.evaluate(() => window.localStorage.clear()); await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 }); await waitForDebugApi(page); - await page.evaluate(() => window.__HEROS_DEBUG__?.goToCamp()); + await openCampScene(page); +} + +async function openCampScene(page) { + await page.evaluate(async () => { + const game = window.__HEROS_GAME__; + const debug = window.__HEROS_DEBUG__; + if (!game || !debug) { + throw new Error('The game debug API is unavailable while opening CampScene.'); + } + for (const scene of game.scene.getScenes(true)) { + if (scene.scene.key !== 'CampScene') { + game.scene.stop(scene.scene.key); + } + } + await debug.goToCamp(); + game.scene.bringToTop('CampScene'); + }); await page.waitForFunction(() => { - const camp = window.__HEROS_DEBUG__?.camp(); + const camp = window.__HEROS_DEBUG__?.camp?.(); return ( - window.__HEROS_DEBUG__?.activeScenes().includes('CampScene') && + window.__HEROS_DEBUG__?.activeScenes?.().includes('CampScene') && camp?.scene === 'CampScene' && - camp?.report?.battleId + camp?.report?.battleId && + camp?.campaign ); }, undefined, { timeout: 90000 }); } -async function seedXuzhouCityStay(page) { - const result = await page.evaluate(({ battleId, battleTitle, gold }) => { +async function seedCityStay(page, cityCase) { + const result = await page.evaluate((config) => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); if (!scene?.campaign || !scene.report || typeof scene.persistSortieSelection !== 'function') { - return { saved: false, reason: 'CampScene campaign/report/persistSortieSelection is unavailable.' }; + return { + saved: false, + reason: 'CampScene campaign/report/persistSortieSelection is unavailable.' + }; } - const now = new Date().toISOString(); - const templateUnit = scene.campaign.roster[0] ?? scene.report.units[0]; - const liuBei = templateUnit - ? { - ...structuredClone(templateUnit), - id: 'liu-bei', - name: '유비' - } - : null; - const xuzhouBond = { - id: 'liu-bei__mi-zhu', - unitIds: ['liu-bei', 'mi-zhu'], - title: '서주 후원', - level: 42, - exp: 0, - battleExp: 0, - description: '미축은 서주의 민심과 군량을 묶어 유비군이 오래 버틸 수 있게 돕는다.' - }; - const report = { - ...scene.report, - battleId, - battleTitle, - outcome: 'victory', - turnNumber: Math.max(1, Number(scene.report.turnNumber) || 1), - rewardGold: 0, - itemRewards: [], - objectives: [], - units: liuBei ? [liuBei] : [], - bonds: [xuzhouBond], - completedCampDialogues: [], - completedCampVisits: [], - createdAt: now - }; - delete report.campaignRewards; - - scene.campaign.step = 'seventh-camp'; - scene.campaign.gold = gold; - scene.campaign.latestBattleId = battleId; - scene.campaign.roster = liuBei ? [liuBei] : []; - scene.campaign.bonds = [xuzhouBond]; - scene.campaign.firstBattleReport = report; - scene.campaign.battleHistory = { - [battleId]: { - battleId, - battleTitle, - outcome: 'victory', - turnNumber: report.turnNumber, - rewardGold: 0, - itemRewards: [], - objectives: [], - units: [], - bonds: [], - completedAt: now - } - }; - scene.campaign.completedCampDialogues = []; - scene.campaign.completedCampVisits = []; - scene.campaign.campDialogueChoiceIds = {}; - scene.campaign.campVisitChoiceIds = {}; - scene.campaign.acknowledgedVictoryRewardBattleIds = [battleId]; - scene.campaign.acknowledgedVictoryRewardCategories = {}; - scene.campaign.dismissedVictoryRewardNoticeBattleIds = [battleId]; - delete scene.campaign.pendingAftermathBattleId; - scene.report = report; - - scene.persistSortieSelection(); - return { - saved: true, - latestBattleId: scene.campaign.latestBattleId, - reportBattleId: scene.campaign.firstBattleReport?.battleId, - gold: scene.campaign.gold - }; - }, { - battleId: xuzhouBattleId, - battleTitle: '서주 구원전', - gold: seedGold - }); - - assert.deepEqual( - result, - { - saved: true, - latestBattleId: xuzhouBattleId, - reportBattleId: xuzhouBattleId, - gold: seedGold - }, - `Failed to persist the Xuzhou seed through CampScene.persistSortieSelection(): ${JSON.stringify(result)}` - ); -} - -async function restartCamp(page, expectedCityStayId = xuzhouCityStayId) { - await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 }); - await waitForDebugApi(page); - await page.evaluate(() => window.__HEROS_DEBUG__?.goToCamp()); - await page.waitForFunction((expectedId) => { - const camp = window.__HEROS_DEBUG__?.camp(); - return ( - window.__HEROS_DEBUG__?.activeScenes().includes('CampScene') && - camp?.scene === 'CampScene' && - camp?.cityStay?.id === expectedId && - camp?.cityStay?.active === true - ); - }, expectedCityStayId, { timeout: 90000 }); -} - -async function verifyAdditionalCityLayouts(page) { - for (const visualCase of additionalCityVisualCases) { - await openCampAboveBattle(page); - - const seedResult = await seedCityStayPreview(page, { ...visualCase, gold: seedGold }); - assert.deepEqual( - seedResult, - { - saved: true, - latestBattleId: visualCase.afterBattleId, - reportBattleId: visualCase.afterBattleId, - cityStayId: visualCase.cityStayId - }, - `Failed to persist the ${visualCase.cityName} city-stay preview: ${JSON.stringify(seedResult)}` - ); - - await restartCamp(page, visualCase.cityStayId); - const state = await waitForCityStay(page, visualCase.cityStayId); - verifyCityStayLayout(state); - assert.equal(state.cityStay.name, visualCase.cityName); - assert.equal(state.cityStay.information.completed, false); - assert.equal(state.cityStay.dialogue.completed, false); - await captureStableScreenshot(page, visualCase.screenshotPath); - - await selectCityLocation(page, 'market'); - const marketState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); - assert.equal(marketState.cityStay.market.offers.length, 3); - marketState.cityStay.market.offers.forEach((offer) => { - assert(offer.buyBounds && offer.interactive && offer.canBuy); - assertBoundsInside( - offer.buyBounds, - marketState.cityStay.panelBounds, - `${visualCase.cityName} market offer "${offer.id}" must remain inside the city panel.` - ); - }); - assertNoOverlappingBounds( - marketState.cityStay.market.offers.map((offer) => ({ id: offer.id, bounds: offer.buyBounds })), - `${visualCase.cityName} market offer buttons` - ); - await captureStableScreenshot( - page, - visualCase.screenshotPath.replace('-information.png', '-market.png') - ); - - await selectCityLocation(page, 'dialogue'); - const dialogueState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); - assert.equal(dialogueState.cityStay.dialogue.choices.length, 2); - dialogueState.cityStay.dialogue.choices.forEach((choice) => { - assert(choice.bounds && choice.interactive); - assertBoundsInside( - choice.bounds, - dialogueState.cityStay.panelBounds, - `${visualCase.cityName} resonance choice "${choice.id}" must remain inside the city panel.` - ); - }); - assertNoOverlappingBounds( - dialogueState.cityStay.dialogue.choices.map((choice) => ({ id: choice.id, bounds: choice.bounds })), - `${visualCase.cityName} resonance choice buttons` - ); - await captureStableScreenshot( - page, - visualCase.screenshotPath.replace('-information.png', '-dialogue.png') - ); - - await selectCityLocation(page, 'information'); - const functionalInitialState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); - const informationResult = await verifyInformationReward(page, functionalInitialState); - const marketResult = await verifyMarketPurchase(page, null); - const dialogueResult = await verifyCompanionDialogue(page, null); - - await restartCamp(page, visualCase.cityStayId); - const restoredState = await waitForCityStay(page, visualCase.cityStayId); - verifyRestoredOutcomes(restoredState, informationResult, marketResult, dialogueResult); - await verifyNextBattleBriefing( - page, - restoredState.cityStay.information.briefingLines, - visualCase.nextBattleId, - visualCase.cityName - ); - } -} - -async function openCampAboveBattle(page) { - await page.evaluate(async () => { - const game = window.__HEROS_GAME__; - if (!game || !window.__HEROS_DEBUG__) { - throw new Error('The game debug API is unavailable while opening CampScene.'); - } - if (game.scene.isActive('BattleScene')) { - game.scene.stop('BattleScene'); - } - await window.__HEROS_DEBUG__.goToCamp(); - game.scene.bringToTop('CampScene'); - }); - await page.waitForFunction(() => ( - !window.__HEROS_GAME__?.scene.isActive('BattleScene') && - window.__HEROS_DEBUG__?.activeScenes().includes('CampScene') && - window.__HEROS_DEBUG__?.camp()?.scene === 'CampScene' - ), undefined, { timeout: 90000 }); -} - -async function seedCityStayPreview(page, visualCase) { - return page.evaluate((config) => { - const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); - if (!scene?.campaign || !scene.report || typeof scene.persistSortieSelection !== 'function') { - return { saved: false, reason: 'CampScene campaign/report/persistSortieSelection is unavailable.' }; - } - - const now = new Date().toISOString(); const templateUnit = scene.campaign.roster[0] ?? scene.report.units[0]; if (!templateUnit) { - return { saved: false, reason: 'No unit template is available for the city preview.' }; + return { saved: false, reason: 'No unit template is available for the city-stay seed.' }; } + const now = new Date().toISOString(); const liuBei = { ...structuredClone(templateUnit), id: 'liu-bei', - name: '유비' + name: 'Liu Bei' }; const partner = { ...structuredClone(templateUnit), id: config.partnerId, - name: config.partnerName + name: config.partnerId }; const bond = { id: config.bondId, unitIds: ['liu-bei', config.partnerId], - title: config.bondTitle, + title: `${config.cityStayId}-resonance`, level: 42, exp: 0, battleExp: 0, - description: `${config.cityName} 체류 중 함께 다음 전투를 준비하며 쌓은 공명입니다.` + description: `Verification bond for ${config.cityStayId}.` }; const report = { ...scene.report, battleId: config.afterBattleId, - battleTitle: config.battleTitle, + battleTitle: config.afterBattleId, outcome: 'victory', turnNumber: Math.max(1, Number(scene.report.turnNumber) || 1), rewardGold: 0, @@ -390,6 +232,7 @@ async function seedCityStayPreview(page, visualCase) { scene.campaign.step = config.campStep; scene.campaign.gold = config.gold; + scene.campaign.inventory = {}; scene.campaign.latestBattleId = config.afterBattleId; scene.campaign.roster = [liuBei, partner]; scene.campaign.bonds = [bond]; @@ -397,7 +240,7 @@ async function seedCityStayPreview(page, visualCase) { scene.campaign.battleHistory = { [config.afterBattleId]: { battleId: config.afterBattleId, - battleTitle: config.battleTitle, + battleTitle: config.afterBattleId, outcome: 'victory', turnNumber: report.turnNumber, rewardGold: 0, @@ -416,269 +259,665 @@ async function seedCityStayPreview(page, visualCase) { scene.campaign.acknowledgedVictoryRewardCategories = {}; scene.campaign.dismissedVictoryRewardNoticeBattleIds = [config.afterBattleId]; delete scene.campaign.pendingAftermathBattleId; + delete scene.campaign.activeCityStayId; scene.report = report; - scene.persistSortieSelection(); - const cityStayId = scene.currentCityStay?.()?.id ?? null; + scene.persistSortieSelection(); + const currentCityStayId = scene.currentCityStay?.()?.id ?? null; return { saved: true, latestBattleId: scene.campaign.latestBattleId, reportBattleId: scene.campaign.firstBattleReport?.battleId, - cityStayId + cityStayId: currentCityStayId, + gold: scene.campaign.gold }; - }, visualCase); + }, { + ...cityCase, + gold: seedGold + }); + + assert.deepEqual( + result, + { + saved: true, + latestBattleId: cityCase.afterBattleId, + reportBattleId: cityCase.afterBattleId, + cityStayId: cityCase.cityStayId, + gold: seedGold + }, + `Failed to seed ${cityCase.cityStayId}: ${JSON.stringify(result)}` + ); } -async function waitForCityStay(page, expectedId) { +async function restartCamp(page, expectedCityStayId) { + await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 }); + await waitForDebugApi(page); + await openCampScene(page); + await waitForCityGateway(page, expectedCityStayId); +} + +async function waitForCityGateway(page, expectedCityStayId) { await page.waitForFunction((cityStayId) => { - const camp = window.__HEROS_DEBUG__?.camp(); + const camp = window.__HEROS_DEBUG__?.camp?.(); return ( + window.__HEROS_DEBUG__?.activeScenes?.().includes('CampScene') && camp?.scene === 'CampScene' && + camp?.activeTab === 'city' && camp?.cityStay?.id === cityStayId && camp?.cityStay?.active === true && camp?.cityStay?.panelBounds && - camp?.campTabs?.length === 7 + camp?.cityStay?.explorationButtonBounds && + ( + camp?.cityStay?.explorationButtonInteractive === true || + camp?.cityStay?.explorationInteractive === true + ) ); - }, expectedId, { timeout: 90000 }); - return page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + }, expectedCityStayId, { timeout: 90000 }); + return page.evaluate(() => window.__HEROS_DEBUG__?.camp?.()); } -async function waitForXuzhouCityStay(page) { - return waitForCityStay(page, xuzhouCityStayId); -} - -function verifyCityStayLayout(state) { - assert.equal(state.activeTab, 'city', 'The city tab should be active when the Xuzhou stay opens.'); - assert.equal(state.campTabs.length, 7, 'A city stay must expose exactly seven CampScene tabs.'); +function verifyGatewayLayout(camp) { + assert.equal(camp.activeTab, 'city'); + assert.equal(camp.campTabs.length, 7, 'A city stay must retain the seven CampScene tabs.'); assert( - state.campTabs.every((tab) => tab.bounds && tab.interactive), - `Every city-stay tab must be visible and interactive: ${JSON.stringify(state.campTabs)}` + camp.campTabs.every((tab) => tab.bounds && tab.interactive), + `Every Camp tab must remain visible and interactive: ${JSON.stringify(camp.campTabs)}` ); assertNoOverlappingBounds( - state.campTabs.map((tab) => ({ id: tab.id, bounds: tab.bounds })), - 'CampScene tabs' + camp.campTabs.map((tab) => ({ id: tab.id, bounds: tab.bounds })), + 'Camp tabs' ); - const sceneBounds = state.campSkin?.sceneBounds ?? { + const viewportBounds = { x: 0, y: 0, width: desktopBrowserViewport.width, height: desktopBrowserViewport.height }; + assertBoundsInside(camp.cityStay.panelBounds, viewportBounds, 'City gateway panel'); assertBoundsInside( - state.cityStay.panelBounds, - sceneBounds, - `The Xuzhou city panel must remain inside the ${desktopBrowserViewport.width}x${desktopBrowserViewport.height} scene.` + camp.cityStay.explorationButtonBounds, + camp.cityStay.panelBounds, + 'City exploration gateway button' + ); + assert.equal( + camp.victoryRewardAcknowledgement.visible, + false, + 'The acknowledged battle reward must not obscure the city gateway.' ); - state.campTabs.forEach((tab) => { - assertBoundsInside(tab.bounds, sceneBounds, `CampScene tab "${tab.id}" must remain inside the scene.`); - }); } -async function verifyInformationReward(page, initialState) { - const information = initialState.cityStay.information; - assert.equal(information.completed, false, 'Xuzhou information should initially be unclaimed.'); - assert(information.actionInteractive && information.actionBounds, 'The information action must be clickable.'); +async function enterCityFromGateway(page, gatewayState, expectedCityStayId) { + assert.equal( + gatewayState.cityStay.explorationButtonInteractive ?? + gatewayState.cityStay.explorationInteractive, + true + ); + await clickSceneBounds( + page, + 'CampScene', + gatewayState.cityStay.explorationButtonBounds + ); + return waitForCityReady(page, expectedCityStayId); +} - const reward = parseInventoryReward(information.itemReward); - const beforeCount = initialState.campaign.inventory[reward.label] ?? 0; - await clickSceneBounds(page, 'CampScene', information.actionBounds); - await page.waitForFunction(({ visitId, label, expectedCount }) => { - const camp = window.__HEROS_DEBUG__?.camp(); - return ( - camp?.cityStay?.information?.id === visitId && - camp.cityStay.information.completed === true && - (camp.campaign?.inventory?.[label] ?? 0) === expectedCount +async function waitForCityReady(page, expectedCityStayId) { + try { + await page.waitForFunction((cityStayId) => { + const city = window.__HEROS_DEBUG__?.cityStay?.(); + return ( + window.__HEROS_DEBUG__?.activeScenes?.().includes('CityStayScene') && + city?.scene === 'CityStayScene' && + city?.ready === true && + city?.cityStayId === cityStayId && + city?.requiredTexturesReady === true + ); + }, expectedCityStayId, { timeout: 90000 }); + } catch (error) { + const diagnostic = await page.evaluate(() => ({ + activeScenes: window.__HEROS_DEBUG__?.activeScenes?.() ?? [], + cityStay: window.__HEROS_DEBUG__?.cityStay?.() ?? null, + camp: window.__HEROS_DEBUG__?.camp?.() ?? null + })); + throw new Error( + `CityStayScene ${expectedCityStayId} did not become ready: ${JSON.stringify(diagnostic)}`, + { cause: error } ); - }, { - visitId: information.id, - label: reward.label, - expectedCount: beforeCount + reward.amount - }); - - const afterFirstClaim = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); - await page.evaluate(() => { - const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); - const cityStay = scene?.currentCityStay?.(); - if (!scene || !cityStay) { - throw new Error('Xuzhou city stay disappeared before the idempotency probe.'); - } - scene.gatherCityInformation(cityStay); - }); - await page.waitForTimeout(100); - const afterRepeatedClaim = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); - - assert.equal( - afterRepeatedClaim.campaign.inventory[reward.label] ?? 0, - afterFirstClaim.campaign.inventory[reward.label] ?? 0, - 'Calling the completed information action again must not duplicate its inventory reward.' - ); - assert.equal( - afterRepeatedClaim.campaign.completedCampVisits.filter((id) => id === information.id).length, - 1, - 'The information completion record must remain unique.' - ); - - return { - id: information.id, - inventoryLabel: reward.label, - inventoryCount: afterRepeatedClaim.campaign.inventory[reward.label] ?? 0 - }; + } + await page.waitForTimeout(380); + return readCity(page); } -async function verifyMarketPurchase(page, screenshotPath = 'dist/verification-city-xuzhou-market.png') { - await selectCityLocation(page, 'market'); - const before = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); - if (screenshotPath) { - await captureStableScreenshot(page, screenshotPath); - } - const offer = before.cityStay.market.offers[0]; - assert(offer?.buyBounds && offer.interactive && offer.canBuy, 'The first Xuzhou market offer must be affordable and clickable.'); +function verifyExplorationLayout(city, expectedCityStayId) { + assert.equal(city.scene, 'CityStayScene'); + assert.equal(city.cityStayId, expectedCityStayId); + assert.equal(city.activeCityStayId, expectedCityStayId); + assert.deepEqual(city.viewport, desktopBrowserViewport); + assert.equal(city.movement.speed, 300); + assert.equal(city.interaction.radius, 122); + assert.equal(city.progress.total, 2); + assert.equal(city.progress.marketOptional, true); + assert.equal(city.progress.exitUnlocked, true); + assert.equal(city.actors.length, 3); + assert.deepEqual( + [...city.actors.map((actor) => actor.kind)].sort(), + ['dialogue', 'information', 'market'] + ); + assert.equal(city.requiredTexturesReady, true); - const goldBefore = before.cityStay.market.gold; - const ownedBefore = offer.owned; - await clickSceneBounds(page, 'CampScene', offer.buyBounds); - await page.waitForFunction(({ offerId, expectedGold, expectedOwned }) => { - const market = window.__HEROS_DEBUG__?.camp()?.cityStay?.market; - const currentOffer = market?.offers?.find((candidate) => candidate.id === offerId); - return market?.gold === expectedGold && currentOffer?.owned === expectedOwned; - }, { - offerId: offer.id, - expectedGold: goldBefore - offer.price, - expectedOwned: ownedBefore + 1 + assertBoundsInsideViewport(city.player.bounds, `${expectedCityStayId} player`); + assertBoundsInsideViewport(city.movement.bounds, `${expectedCityStayId} movement area`); + city.actors.forEach((actor) => { + assertBoundsInsideViewport(actor.bounds, `${expectedCityStayId} actor ${actor.id}`); + }); + city.blockers.forEach((blocker, index) => { + assertBoundsInsideViewport(blocker, `${expectedCityStayId} blocker ${index}`); }); - - const after = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); - const purchasedOffer = after.cityStay.market.offers.find((candidate) => candidate.id === offer.id); - assert.equal(after.cityStay.market.gold, goldBefore - offer.price, 'The market must deduct the exact listed price.'); - assert.equal(purchasedOffer.owned, ownedBefore + 1, 'The purchased equipment inventory must increase by exactly one.'); - - return { - offerId: offer.id, - itemId: offer.itemId, - itemName: offer.itemName, - gold: after.cityStay.market.gold, - owned: purchasedOffer.owned - }; } -async function verifyCompanionDialogue(page, screenshotPath = 'dist/verification-city-xuzhou-dialogue.png') { - await selectCityLocation(page, 'dialogue'); - const before = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); - if (screenshotPath) { - await captureStableScreenshot(page, screenshotPath); - } - const choice = before.cityStay.dialogue.choices[0]; - assert(choice?.bounds && choice.interactive, 'The first Xuzhou companion choice must be clickable.'); +async function verifyKeyboardMovement(page) { + const before = await readCity(page); + assert.equal(before.dialogue.active, false); + assert.equal(before.shop.open, false); + assert.equal(before.choice.open, false); - await clickSceneBounds(page, 'CampScene', choice.bounds); - await page.waitForTimeout(150); - const after = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + await page.keyboard.down('a'); + await page.waitForTimeout(430); + await page.keyboard.up('a'); + await page.waitForTimeout(90); + + const after = await readCity(page); assert( - after?.cityStay?.dialogue?.id === before.cityStay.dialogue.id && - after.cityStay.dialogue.completed === true && - after.cityStay.dialogue.choiceId === choice.id, - `The companion choice did not complete synchronously: ${JSON.stringify({ - before: before.cityStay.dialogue, - after: after?.cityStay?.dialogue, - campaignBonds: after?.report?.bonds + before.player.x - after.player.x >= 85, + `Expected real keyboard movement to move Liu Bei left: ${JSON.stringify({ + before: before.player, + after: after.player })}` ); - assert.equal( - after.campaign.completedCampDialogues.filter((id) => id === before.cityStay.dialogue.id).length, - 1, - 'The companion dialogue completion record must be unique.' + assert( + Math.abs(after.player.y - before.player.y) <= 3, + `Expected horizontal movement to retain Y: ${JSON.stringify({ + before: before.player, + after: after.player + })}` ); + assert.equal(after.player.moving, false); +} + +async function verifyDistantInteractionIsBlocked(page) { + const before = await readCity(page); + assert.equal(before.interaction.canInteract, false, 'The movement probe should end outside every interaction radius.'); + await page.keyboard.press('e'); + await page.waitForTimeout(160); + const after = await readCity(page); + assert.equal(after.dialogue.active, false, 'A distant interaction must not open dialogue.'); + assert.equal(after.shop.open, false, 'A distant interaction must not open the market.'); + assert.equal(after.choice.open, false, 'A distant interaction must not open a resonance choice.'); + assert.deepEqual(after.progress, before.progress, 'A distant interaction must not change city progress.'); + + await page.keyboard.press('Escape'); + await page.waitForTimeout(120); + const afterEscape = await readCity(page); assert.equal( - after.campaign.campDialogueChoiceIds[before.cityStay.dialogue.id], - choice.id, - 'The selected companion response must be saved by choice ID.' + afterEscape.navigationPending, + false, + 'ESC on the open map must not bypass walking to the south exit.' + ); + assert( + afterEscape.lastNotice.includes('남쪽 군영 출구'), + `ESC should direct the player to the physical exit: ${afterEscape.lastNotice}` + ); +} + +async function verifyInformationReward(page, cityCase, gatewayInformation) { + const before = await readCity(page); + assert.equal(before.progress.informationComplete, false); + assert(!before.campaign.completedCampVisits.includes(cityCase.informationId)); + const inventoryBefore = { ...before.campaign.inventory }; + + await interactWith(page, 'information'); + await page.waitForFunction(() => ( + window.__HEROS_DEBUG__?.cityStay?.()?.dialogue?.active === true + )); + await captureStableScreenshot( + page, + `dist/verification-city-${cityCase.cityStayId}-information.png` + ); + await advanceCityDialogueUntilClosed(page); + await page.waitForFunction((informationId) => { + const city = window.__HEROS_DEBUG__?.cityStay?.(); + return ( + city?.progress?.informationComplete === true && + city?.campaign?.completedCampVisits?.includes(informationId) + ); + }, cityCase.informationId); + + const afterFirst = await readCity(page); + const rewardDelta = inventoryDelta(inventoryBefore, afterFirst.campaign.inventory); + assert.equal( + rewardDelta.length, + 1, + `Information must grant exactly one inventory entry: ${JSON.stringify(rewardDelta)}` + ); + assert.equal(rewardDelta[0].amount, 1, 'The information inventory reward must increase by exactly one.'); + assert.equal( + afterFirst.campaign.completedCampVisits.filter((id) => id === cityCase.informationId).length, + 1, + 'The information completion ID must be unique.' ); + await interactWith(page, 'information'); + await page.waitForFunction(() => ( + window.__HEROS_DEBUG__?.cityStay?.()?.dialogue?.active === true + )); + await advanceCityDialogueUntilClosed(page); + const afterRepeated = await readCity(page); + assert.deepEqual( + afterRepeated.campaign.inventory, + afterFirst.campaign.inventory, + 'Repeating completed information dialogue must not duplicate its reward.' + ); + assert.equal( + afterRepeated.campaign.completedCampVisits.filter((id) => id === cityCase.informationId).length, + 1, + 'Repeating completed information dialogue must not duplicate its completion ID.' + ); + + assert( + Array.isArray(gatewayInformation?.briefingLines) && gatewayInformation.briefingLines.length > 0, + 'The Camp gateway must expose the authored next-battle briefing lines.' + ); return { - id: before.cityStay.dialogue.id, - choiceId: choice.id + inventoryLabel: rewardDelta[0].label, + inventoryCount: afterRepeated.campaign.inventory[rewardDelta[0].label] }; } -async function selectCityLocation(page, locationId) { - const state = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); - const location = state.cityStay.locations.find((candidate) => candidate.id === locationId); - assert(location?.bounds && location.interactive, `City location "${locationId}" must be clickable.`); - await clickSceneBounds(page, 'CampScene', location.bounds); - await page.waitForFunction((expectedId) => ( - window.__HEROS_DEBUG__?.camp()?.cityStay?.selectedLocation === expectedId - ), locationId); +async function verifyMarketPurchase(page, screenshotPath) { + await interactWith(page, 'market'); + await page.waitForFunction(() => window.__HEROS_DEBUG__?.cityStay?.()?.shop?.open === true); + const before = await readCity(page); + verifyShopLayout(before); + await captureStableScreenshot(page, screenshotPath); + + const offer = before.shop.offers[0]; + assert(offer?.interactive && offer.buttonBounds && offer.canBuy); + const expectedGold = before.shop.gold - offer.price; + const expectedOwned = offer.owned + 1; + + await clickSceneBounds(page, 'CityStayScene', offer.buttonBounds); + await page.waitForFunction(({ offerId, gold, owned }) => { + const shop = window.__HEROS_DEBUG__?.cityStay?.()?.shop; + const currentOffer = shop?.offers?.find((candidate) => candidate.id === offerId); + return shop?.gold === gold && currentOffer?.owned === owned; + }, { + offerId: offer.id, + gold: expectedGold, + owned: expectedOwned + }); + + const after = await readCity(page); + const purchased = after.shop.offers.find((candidate) => candidate.id === offer.id); + assert.equal(after.shop.gold, expectedGold, 'The market must deduct the exact displayed price.'); + assert.equal(purchased?.owned, expectedOwned, 'The purchased item count must increase by exactly one.'); + assert.equal(after.campaign.gold, expectedGold); + + await page.keyboard.press('Escape'); + await page.waitForFunction(() => window.__HEROS_DEBUG__?.cityStay?.()?.shop?.open === false); + return { + offerId: offer.id, + gold: expectedGold, + owned: expectedOwned + }; } -function verifyRestoredOutcomes(state, information, market, dialogue) { - assert.equal(state.cityStay.information.completed, true, 'Information completion must survive a CampScene restart.'); - assert.equal( - state.campaign.inventory[information.inventoryLabel] ?? 0, - information.inventoryCount, - 'The one-time information reward count must survive a CampScene restart.' - ); - - const restoredOffer = state.cityStay.market.offers.find((offer) => offer.id === market.offerId); - assert.equal(state.cityStay.market.gold, market.gold, 'The exact post-purchase gold balance must survive a CampScene restart.'); - assert.equal(restoredOffer?.owned, market.owned, 'The purchased equipment count must survive a CampScene restart.'); - - assert.equal(state.cityStay.dialogue.completed, true, 'Companion dialogue completion must survive a CampScene restart.'); - assert.equal(state.cityStay.dialogue.choiceId, dialogue.choiceId, 'The companion choice must survive a CampScene restart.'); - assert.equal( - state.campaign.campDialogueChoiceIds[dialogue.id], - dialogue.choiceId, - 'The persisted companion choice history must match the selected response.' +function verifyShopLayout(city) { + assert.equal(city.shop.open, true); + assertBoundsInsideViewport(city.shop.panelBounds, `${city.cityStayId} shop panel`); + assert.equal(city.shop.offers.length, 3); + city.shop.offers.forEach((offer) => { + assert(offer.buttonBounds, `${offer.id} must expose purchase button bounds.`); + assertBoundsInsideViewport(offer.buttonBounds, `${city.cityStayId} shop offer ${offer.id}`); + }); + assertNoOverlappingBounds( + city.shop.offers.map((offer) => ({ id: offer.id, bounds: offer.buttonBounds })), + `${city.cityStayId} shop offer buttons` ); } -async function verifyNextBattleBriefing( - page, - expectedBriefingLines, - nextBattleId = xiaopeiBattleId, - cityName = '서주' -) { - await page.evaluate((battleId) => window.__HEROS_DEBUG__?.goToBattle(battleId), nextBattleId); - await page.waitForFunction((battleId) => { - const battle = window.__HEROS_DEBUG__?.battle(); +async function verifyCompanionResonance(page, cityCase, screenshotPath) { + const campaignBefore = await readActiveCampaign(page); + const bondBefore = campaignBefore.bonds.find((bond) => bond.id === cityCase.bondId); + assert(bondBefore, `Expected seeded bond ${cityCase.bondId}.`); + + await interactWith(page, 'dialogue'); + await page.waitForFunction(() => ( + window.__HEROS_DEBUG__?.cityStay?.()?.dialogue?.active === true + )); + await advanceDialogueUntilChoice(page); + const choiceState = await readCity(page); + verifyChoiceLayout(choiceState); + await captureStableScreenshot(page, screenshotPath); + + const choice = choiceState.choice.choices[0]; + assert(choice?.interactive && choice.bounds); + await clickSceneBounds(page, 'CityStayScene', choice.bounds); + await page.waitForFunction(({ dialogueId, choiceId }) => { + const city = window.__HEROS_DEBUG__?.cityStay?.(); return ( - window.__HEROS_DEBUG__?.activeScenes().includes('BattleScene') && + city?.progress?.dialogueComplete === true && + city?.campaign?.completedCampDialogues?.includes(dialogueId) && + city?.campaign?.campDialogueChoiceIds?.[dialogueId] === choiceId + ); + }, { + dialogueId: cityCase.dialogueId, + choiceId: choice.id + }); + + const afterChoice = await readCity(page); + assert.equal( + afterChoice.campaign.completedCampDialogues.filter((id) => id === cityCase.dialogueId).length, + 1, + 'The companion dialogue completion ID must be unique.' + ); + assert.equal(afterChoice.campaign.campDialogueChoiceIds[cityCase.dialogueId], choice.id); + const campaignAfter = await readActiveCampaign(page); + const bondAfter = campaignAfter.bonds.find((bond) => bond.id === cityCase.bondId); + assert(bondAfter, `Expected persisted bond ${cityCase.bondId}.`); + assert.equal( + bondAfter.battleExp, + bondBefore.battleExp + choice.rewardExp, + 'The selected response must persist its exact resonance reward.' + ); + assert.equal( + bondAfter.exp, + bondBefore.exp + choice.rewardExp, + 'The seeded bond should retain the exact resonance EXP below the level threshold.' + ); + + await advanceCityDialogueUntilClosed(page); + return { + dialogueId: cityCase.dialogueId, + choiceId: choice.id, + rewardExp: choice.rewardExp, + bondExp: bondAfter.exp, + bondBattleExp: bondAfter.battleExp + }; +} + +function verifyChoiceLayout(city) { + assert.equal(city.choice.open, true); + assertBoundsInsideViewport(city.choice.panelBounds, `${city.cityStayId} resonance panel`); + assert.equal(city.choice.choices.length, 2); + city.choice.choices.forEach((choice) => { + assert(choice.interactive && choice.bounds, `${choice.id} must be visible and interactive.`); + assertBoundsInsideViewport(choice.bounds, `${city.cityStayId} resonance choice ${choice.id}`); + }); + assertNoOverlappingBounds( + city.choice.choices.map((choice) => ({ id: choice.id, bounds: choice.bounds })), + `${city.cityStayId} resonance choices` + ); +} + +async function reloadAndContinueCityStay(page, expectedCityStayId) { + const beforeReload = await readCity(page); + assert.equal(beforeReload.activeCityStayId, expectedCityStayId); + + await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 }); + await waitForDebugApi(page); + await page.waitForFunction(() => ( + window.__HEROS_DEBUG__?.activeScenes?.().includes('TitleScene') + ), undefined, { timeout: 90000 }); + await assertDesktopViewport(page); + await page.waitForTimeout(380); + await page.keyboard.press('Enter'); + return waitForCityReady(page, expectedCityStayId); +} + +function verifyRestoredOutcomes(city, cityCase, information, market, resonance) { + verifyExplorationLayout(city, cityCase.cityStayId); + assert.equal(city.progress.informationComplete, true); + assert.equal(city.progress.dialogueComplete, true); + assert.equal( + city.campaign.inventory[information.inventoryLabel], + information.inventoryCount, + 'The one-time information reward must survive reload/Continue.' + ); + assert.equal(city.shop.gold, market.gold, 'The exact post-purchase gold balance must survive reload/Continue.'); + assert.equal( + city.shop.offers.find((offer) => offer.id === market.offerId)?.owned, + market.owned, + 'The purchased equipment count must survive reload/Continue.' + ); + assert.equal( + city.campaign.campDialogueChoiceIds[cityCase.dialogueId], + resonance.choiceId, + 'The selected companion response must survive reload/Continue.' + ); +} + +async function verifyNextBattleBriefing(page, cityCase, expectedBriefingLines) { + await page.evaluate(async (battleId) => { + const game = window.__HEROS_GAME__; + if (!game || !window.__HEROS_DEBUG__) { + throw new Error('The game debug API is unavailable while opening BattleScene.'); + } + for (const scene of game.scene.getScenes(true)) { + if (scene.scene.key !== 'BattleScene') { + game.scene.stop(scene.scene.key); + } + } + await window.__HEROS_DEBUG__.goToBattle(battleId); + game.scene.bringToTop('BattleScene'); + }, cityCase.nextBattleId); + await page.waitForFunction((battleId) => { + const battle = window.__HEROS_DEBUG__?.battle?.(); + return ( + window.__HEROS_DEBUG__?.activeScenes?.().includes('BattleScene') && battle?.scene === 'BattleScene' && battle?.battleId === battleId && battle?.cityInformation ); - }, nextBattleId, { timeout: 90000 }); + }, cityCase.nextBattleId, { timeout: 90000 }); - let battle = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); - assert.equal(battle.cityInformation.available, true, `${nextBattleId} must expose its preceding city information.`); - assert.equal(battle.cityInformation.completed, true, `${nextBattleId} must see the saved ${cityName} information completion.`); + let battle = await page.evaluate(() => window.__HEROS_DEBUG__?.battle?.()); + assert.equal(battle.cityInformation.available, true); + assert.equal(battle.cityInformation.completed, true); assert.deepEqual( battle.cityInformation.briefingLines, expectedBriefingLines, - `${nextBattleId} must expose the exact authored ${cityName} briefing lines.` + 'The next battle must expose the exact authored city briefing.' ); - await page.evaluate(() => { + + const openingBriefingLines = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); - scene?.showOpeningBattleEvent?.(); + return scene?.completedCityInformationBriefingLines?.() ?? []; }); - await page.waitForFunction(() => { - const battleState = window.__HEROS_DEBUG__?.battle(); - return battleState?.activeBattleEvent?.key === 'opening' || battleState?.battleLog?.length > 0; - }); - battle = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); - const openingEvent = battle.activeBattleEvent?.key === 'opening' - ? battle.activeBattleEvent - : battle.pendingBattleEvents?.find((event) => event.key === 'opening'); - const briefingRecordedInLog = expectedBriefingLines.every((line) => ( - battle.battleLog?.some((entry) => entry.includes(line)) - )); - assert( - (openingEvent && expectedBriefingLines.every((line) => openingEvent.lines.includes(line))) || briefingRecordedInLog, - `The opening battle briefing must record every completed ${cityName} information line: ${JSON.stringify({ - openingEvent, - battleLog: battle.battleLog?.slice(-4) - })}` + assert.equal( + openingBriefingLines.length, + expectedBriefingLines.length + 1, + 'The opening briefing payload must prepend one city-information heading.' ); + assert.deepEqual( + openingBriefingLines.slice(1), + expectedBriefingLines, + 'Every gathered city-information line must feed the next battle opening briefing.' + ); +} + +async function verifyAdditionalCityLayout(page, cityCase, options) { + await openCampScene(page); + await seedCityStay(page, cityCase); + await restartCamp(page, cityCase.cityStayId); + + const gateway = await waitForCityGateway(page, cityCase.cityStayId); + verifyGatewayLayout(gateway); + let city = await enterCityFromGateway(page, gateway, cityCase.cityStayId); + verifyExplorationLayout(city, cityCase.cityStayId); + assert.equal(city.progress.informationComplete, false); + assert.equal(city.progress.dialogueComplete, false); + await captureStableScreenshot( + page, + `dist/verification-city-${cityCase.cityStayId}-map.png` + ); + + await interactWith(page, 'information'); + await page.waitForFunction(() => ( + window.__HEROS_DEBUG__?.cityStay?.()?.dialogue?.active === true + )); + city = await readCity(page); + assertBoundsInsideViewport(city.dialogue.bounds, `${cityCase.cityStayId} information dialogue`); + await captureStableScreenshot( + page, + `dist/verification-city-${cityCase.cityStayId}-information.png` + ); + await page.keyboard.press('Escape'); + await page.waitForFunction(() => ( + window.__HEROS_DEBUG__?.cityStay?.()?.dialogue?.active === false + )); + assert.equal((await readCity(page)).progress.informationComplete, false); + + await interactWith(page, 'market'); + await page.waitForFunction(() => window.__HEROS_DEBUG__?.cityStay?.()?.shop?.open === true); + city = await readCity(page); + verifyShopLayout(city); + await captureStableScreenshot( + page, + `dist/verification-city-${cityCase.cityStayId}-market.png` + ); + await page.keyboard.press('Escape'); + await page.waitForFunction(() => window.__HEROS_DEBUG__?.cityStay?.()?.shop?.open === false); + + await interactWith(page, 'dialogue'); + await page.waitForFunction(() => ( + window.__HEROS_DEBUG__?.cityStay?.()?.dialogue?.active === true + )); + await advanceDialogueUntilChoice(page); + city = await readCity(page); + verifyChoiceLayout(city); + await captureStableScreenshot( + page, + `dist/verification-city-${cityCase.cityStayId}-dialogue.png` + ); + await page.keyboard.press('Escape'); + await page.waitForFunction(() => window.__HEROS_DEBUG__?.cityStay?.()?.choice?.open === false); + city = await readCity(page); + assert.equal(city.progress.dialogueComplete, false); + + if (options.verifyIncompleteExit) { + await verifyIncompleteCityExit(page, cityCase); + } +} + +async function verifyIncompleteCityExit(page, cityCase) { + const before = await readCity(page); + assert.equal(before.progress.completed, 0); + assert.equal(before.progress.exitUnlocked, true); + + await interactWith(page, 'exit'); + await page.waitForFunction(() => ( + window.__HEROS_DEBUG__?.cityStay?.()?.dialogue?.active === true + )); + await captureStableScreenshot( + page, + `dist/verification-city-${cityCase.cityStayId}-exit-warning.png` + ); + await advanceCityDialogueUntilSceneChanges(page); + await page.waitForFunction(() => { + const camp = window.__HEROS_DEBUG__?.camp?.(); + return ( + window.__HEROS_DEBUG__?.activeScenes?.().includes('CampScene') && + camp?.scene === 'CampScene' + ); + }, undefined, { timeout: 90000 }); + + const campaign = await readActiveCampaign(page); + assert.equal(campaign.activeCityStayId ?? null, null, 'Returning to Camp must clear activeCityStayId.'); + assert(!campaign.completedCampVisits.includes(cityCase.informationId)); + assert(!campaign.completedCampDialogues.includes(cityCase.dialogueId)); +} + +async function interactWith(page, targetIdOrKind) { + const result = await page.evaluate((target) => { + const scene = window.__HEROS_GAME__?.scene.getScene('CityStayScene'); + return scene?.debugInteractWith?.(target) ?? false; + }, targetIdOrKind); + assert.equal(result, true, `Expected CityStayScene interaction with ${targetIdOrKind}.`); + await page.waitForTimeout(90); +} + +async function advanceCityDialogueUntilClosed(page) { + for (let attempt = 0; attempt < 16; attempt += 1) { + const city = await readCity(page); + if (!city?.dialogue?.active) { + return; + } + await page.mouse.click(960, 850); + await page.waitForTimeout(130); + } + throw new Error(`City dialogue did not close: ${JSON.stringify(await readCity(page))}`); +} + +async function advanceDialogueUntilChoice(page) { + for (let attempt = 0; attempt < 16; attempt += 1) { + const city = await readCity(page); + if (city?.choice?.open) { + return; + } + if (!city?.dialogue?.active) { + throw new Error(`Dialogue closed without opening a choice: ${JSON.stringify(city)}`); + } + await page.mouse.click(960, 850); + await page.waitForTimeout(130); + } + throw new Error(`Resonance choice did not open: ${JSON.stringify(await readCity(page))}`); +} + +async function advanceCityDialogueUntilSceneChanges(page) { + for (let attempt = 0; attempt < 16; attempt += 1) { + const activeScenes = await page.evaluate(() => window.__HEROS_DEBUG__?.activeScenes?.() ?? []); + if (!activeScenes.includes('CityStayScene')) { + return; + } + const city = await readCity(page); + if (city?.dialogue?.active) { + await page.mouse.click(960, 850); + await page.waitForTimeout(150); + continue; + } + if (city?.navigationPending) { + await page.waitForTimeout(150); + continue; + } + throw new Error(`City exit stopped before navigation: ${JSON.stringify(city)}`); + } + throw new Error(`City exit did not return to Camp: ${JSON.stringify(await readCity(page))}`); +} + +async function readCity(page) { + return page.evaluate(() => window.__HEROS_DEBUG__?.cityStay?.()); +} + +async function readActiveCampaign(page) { + return page.evaluate(() => { + const debug = window.__HEROS_DEBUG__; + const activeKey = ['CityStayScene', 'CampScene', 'BattleScene'] + .find((key) => window.__HEROS_GAME__?.scene.isActive(key)); + const campaign = activeKey ? debug?.scene(activeKey)?.campaign : undefined; + if (!campaign) { + throw new Error(`No active scene campaign is available (scene: ${activeKey ?? 'none'}).`); + } + return structuredClone(campaign); + }); +} + +function inventoryDelta(before, after) { + return [...new Set([...Object.keys(before), ...Object.keys(after)])] + .map((label) => ({ + label, + amount: (after[label] ?? 0) - (before[label] ?? 0) + })) + .filter((entry) => entry.amount !== 0); } function assertNoOverlappingBounds(entries, label) { @@ -686,9 +925,11 @@ function assertNoOverlappingBounds(entries, label) { for (let rightIndex = leftIndex + 1; rightIndex < entries.length; rightIndex += 1) { const left = entries[leftIndex]; const right = entries[rightIndex]; - const overlapWidth = Math.min(left.bounds.x + left.bounds.width, right.bounds.x + right.bounds.width) - + const overlapWidth = + Math.min(left.bounds.x + left.bounds.width, right.bounds.x + right.bounds.width) - Math.max(left.bounds.x, right.bounds.x); - const overlapHeight = Math.min(left.bounds.y + left.bounds.height, right.bounds.y + right.bounds.height) - + const overlapHeight = + Math.min(left.bounds.y + left.bounds.height, right.bounds.y + right.bounds.height) - Math.max(left.bounds.y, right.bounds.y); assert( overlapWidth <= 0 || overlapHeight <= 0, @@ -698,14 +939,28 @@ function assertNoOverlappingBounds(entries, label) { } } -function assertBoundsInside(bounds, container, message) { +function assertBoundsInsideViewport(bounds, label) { + assertBoundsInside( + bounds, + { + x: 0, + y: 0, + width: desktopBrowserViewport.width, + height: desktopBrowserViewport.height + }, + label + ); +} + +function assertBoundsInside(bounds, container, label) { + assert(bounds, `${label}: bounds are required.`); const epsilon = 0.01; assert( bounds.x >= container.x - epsilon && bounds.y >= container.y - epsilon && bounds.x + bounds.width <= container.x + container.width + epsilon && bounds.y + bounds.height <= container.y + container.height + epsilon, - `${message} ${JSON.stringify({ bounds, container })}` + `${label}: expected bounds inside container, received ${JSON.stringify({ bounds, container })}` ); } @@ -720,28 +975,35 @@ async function waitForDebugApi(page) { async function assertDesktopViewport(page) { const viewport = await page.evaluate(() => { const canvas = document.querySelector('canvas'); + const bounds = canvas?.getBoundingClientRect(); return { width: window.innerWidth, height: window.innerHeight, dpr: window.devicePixelRatio, visualScale: window.visualViewport?.scale ?? 1, - canvas: canvas ? { width: canvas.width, height: canvas.height } : null + 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.deepEqual( - viewport, - { - width: desktopBrowserViewport.width, - height: desktopBrowserViewport.height, - dpr: desktopBrowserDeviceScaleFactor, - visualScale: 1, - canvas: { - width: desktopBrowserViewport.width, - height: desktopBrowserViewport.height - } - }, - 'The browser QA baseline must be an explicit 1920x1080 CSS viewport at DPR 1 and 100% zoom.' - ); + assert.equal(viewport.width, desktopBrowserViewport.width); + assert.equal(viewport.height, desktopBrowserViewport.height); + assert.equal(viewport.dpr, desktopBrowserDeviceScaleFactor); + assert.equal(viewport.visualScale, 1); + 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); } async function clickSceneBounds(page, sceneKey, bounds) { @@ -762,12 +1024,12 @@ async function clickSceneBounds(page, sceneKey, bounds) { requestedSceneKey: sceneKey, requestedBounds: bounds }); - assert(point && Number.isFinite(point.x) && Number.isFinite(point.y), `Expected a valid ${sceneKey} pointer coordinate.`); + assert(point && Number.isFinite(point.x) && Number.isFinite(point.y)); await page.mouse.click(point.x, point.y); } async function captureStableScreenshot(page, path) { - await page.waitForTimeout(300); + await page.waitForTimeout(260); const loopSlept = await page.evaluate(() => { const loop = window.__HEROS_GAME__?.loop; if (!loop || typeof loop.sleep !== 'function') { @@ -776,13 +1038,13 @@ async function captureStableScreenshot(page, path) { loop.sleep(); return true; }); - await page.waitForTimeout(50); + 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(50); + await page.waitForTimeout(40); } } } @@ -841,9 +1103,3 @@ async function canReach(url) { function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } - -function parseInventoryReward(reward) { - const match = /^(.*?)\s+([1-9]\d*)$/.exec(reward); - assert(match, `Expected an inventory reward ending in a positive amount: ${reward}`); - return { label: match[1], amount: Number(match[2]) }; -} diff --git a/scripts/verify-city-stay-data.mjs b/scripts/verify-city-stay-data.mjs index d57b5ae..bf7224d 100644 --- a/scripts/verify-city-stay-data.mjs +++ b/scripts/verify-city-stay-data.mjs @@ -18,6 +18,12 @@ try { } = await server.ssrLoadModule('/src/game/data/cityStays.ts'); const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts'); const { itemCatalog } = await server.ssrLoadModule('/src/game/data/battleItems.ts'); + const { + cityStayExplorationProfiles, + getCityStayExplorationProfile + } = await server.ssrLoadModule('/src/game/data/cityStayExploration.ts'); + const { unitBaseSheetAssetInfo } = await server.ssrLoadModule('/src/game/data/unitAssets.ts'); + const { musicTracks, ambienceTracks } = await server.ssrLoadModule('/src/game/audio/audioAssets.ts'); validateCityStayCollection(cityStayDefinitions, battleScenarios, itemCatalog); validateLookupHelpers( @@ -26,13 +32,21 @@ try { findCityStayAfterBattle, findCityStayBeforeBattle ); + validateExplorationProfiles( + cityStayDefinitions, + cityStayExplorationProfiles, + getCityStayExplorationProfile, + unitBaseSheetAssetInfo, + musicTracks, + ambienceTracks + ); if (errors.length > 0) { throw new Error(`City stay data verification failed:\n${errors.map((error) => `- ${error}`).join('\n')}`); } console.log( - `Verified ${cityStayDefinitions.length} city stays, ${cityStayDefinitions.length * 3} common equipment offers, battle links, information visits, resonance dialogues, and typed lookup helpers.` + `Verified ${cityStayDefinitions.length} city stays, ${cityStayDefinitions.length * 3} common equipment offers, battle links, direct-exploration profiles, NPC sprites, soundscapes, information visits, resonance dialogues, and typed lookup helpers.` ); } finally { await server.close(); @@ -228,6 +242,210 @@ function validateLookupHelpers( }); } +function validateExplorationProfiles( + definitions, + profiles, + getProfile, + unitBaseSheetAssetInfo, + musicTracks, + ambienceTracks +) { + if (!profiles || typeof profiles !== 'object') { + errors.push('cityStayExplorationProfiles must be an object'); + return; + } + + const actualIds = Object.keys(profiles); + if (JSON.stringify(actualIds) !== JSON.stringify(expectedCityStayIds)) { + errors.push( + `exploration profile ids/order must be ${expectedCityStayIds.join(', ')} (found ${actualIds.join(', ')})` + ); + } + + const knownDirections = new Set(['north', 'east', 'south', 'west']); + const knownTerrains = new Set([ + 'earth', + 'stone', + 'wet', + 'wood', + 'plain', + 'road', + 'forest', + 'hill', + 'village', + 'fort', + 'camp', + 'river', + 'cliff', + 'bridge', + 'water', + 'marsh', + 'swamp' + ]); + const seenActorIds = new Set(); + const seenBuildingIds = new Set(); + + definitions.forEach((definition) => { + const profile = profiles[definition.id]; + const context = `cityStayExplorationProfiles.${definition.id}`; + if (!profile || typeof profile !== 'object') { + errors.push(`${context}: profile is missing`); + return; + } + if (profile.id !== definition.id) { + errors.push(`${context}: id "${profile.id}" does not match city stay "${definition.id}"`); + } + if (getProfile(definition.id) !== profile) { + errors.push(`${context}: getCityStayExplorationProfile did not return the canonical profile`); + } + + ['heading', 'subtitle', 'landmarkLabel', 'musicKey', 'ambienceKey'].forEach((field) => { + assertNonEmptyString(profile[field], `${context}.${field}`); + }); + ['groundColor', 'grassColor', 'roadColor', 'roadHighlightColor', 'accentColor'].forEach((field) => { + if (!Number.isInteger(profile[field]) || profile[field] < 0 || profile[field] > 0xffffff) { + errors.push(`${context}.${field} must be a 24-bit color`); + } + }); + if (!musicTracks[profile.musicKey]) { + errors.push(`${context}: unknown musicKey "${profile.musicKey}"`); + } + if (!ambienceTracks[profile.ambienceKey]) { + errors.push(`${context}: unknown ambienceKey "${profile.ambienceKey}"`); + } + if (!knownTerrains.has(profile.movementTerrain)) { + errors.push(`${context}: unsupported movementTerrain "${profile.movementTerrain}"`); + } + + validateExplorationPoint(profile.playerStart, `${context}.playerStart`, { + minX: 67, + maxX: 1427, + minY: 133, + maxY: 897 + }); + if (!knownDirections.has(profile.playerStart?.direction)) { + errors.push(`${context}.playerStart.direction must be north/east/south/west`); + } + + if (profile.exit?.id !== 'camp-exit') { + errors.push(`${context}.exit.id must be "camp-exit"`); + } + assertNonEmptyString(profile.exit?.name, `${context}.exit.name`); + validateExplorationPoint(profile.exit, `${context}.exit`, { + minX: 42, + maxX: 1452, + minY: 108, + maxY: 922 + }); + const startToExit = distanceBetween(profile.playerStart, profile.exit); + if (startToExit <= 164) { + errors.push( + `${context}: player must start outside the exit prompt radius (distance ${Math.round(startToExit)})` + ); + } + + if (!Array.isArray(profile.actors) || profile.actors.length !== 3) { + errors.push(`${context}.actors must contain exactly 3 entries`); + } else { + const kinds = profile.actors.map((actor) => actor.kind).sort(); + if (JSON.stringify(kinds) !== JSON.stringify(['dialogue', 'information', 'market'])) { + errors.push(`${context}.actors must contain one information, market, and dialogue actor`); + } + profile.actors.forEach((actor, actorIndex) => { + const actorContext = `${context}.actors[${actorIndex}]`; + assertUniqueNonEmptyString(actor?.id, seenActorIds, `${actorContext}.id`); + assertNonEmptyString(actor?.name, `${actorContext}.name`); + assertNonEmptyString(actor?.role, `${actorContext}.role`); + assertNonEmptyString(actor?.textureKey, `${actorContext}.textureKey`); + validateExplorationPoint(actor, actorContext, { + minX: 42, + maxX: 1452, + minY: 108, + maxY: 922 + }); + if (!knownDirections.has(actor?.direction)) { + errors.push(`${actorContext}.direction must be north/east/south/west`); + } + if (!unitBaseSheetAssetInfo(actor?.textureKey)) { + errors.push(`${actorContext}: unknown unit texture "${actor?.textureKey}"`); + } + }); + } + + if (!unitBaseSheetAssetInfo('unit-liu-bei')) { + errors.push(`${context}: player texture "unit-liu-bei" is missing`); + } + if (!Array.isArray(profile.buildings) || profile.buildings.length !== 3) { + errors.push(`${context}.buildings must contain exactly 3 entries`); + } else { + profile.buildings.forEach((building, buildingIndex) => { + const buildingContext = `${context}.buildings[${buildingIndex}]`; + assertUniqueNonEmptyString(building?.id, seenBuildingIds, `${buildingContext}.id`); + assertNonEmptyString(building?.label, `${buildingContext}.label`); + ['x', 'y', 'width', 'height'].forEach((field) => { + if (!Number.isFinite(building?.[field])) { + errors.push(`${buildingContext}.${field} must be a finite number`); + } + }); + if ( + building?.x < 0 || + building?.y < 92 || + building?.width <= 0 || + building?.height <= 0 || + building?.x + building?.width > 1495 || + building?.y + building?.height > 958 + ) { + errors.push(`${buildingContext} must fit inside the city map`); + } + ['wallColor', 'roofColor'].forEach((field) => { + if (!Number.isInteger(building?.[field]) || building[field] < 0 || building[field] > 0xffffff) { + errors.push(`${buildingContext}.${field} must be a 24-bit color`); + } + }); + }); + } + + const points = [ + profile.playerStart, + profile.exit, + ...(Array.isArray(profile.actors) ? profile.actors : []) + ]; + for (let left = 0; left < points.length; left += 1) { + for (let right = left + 1; right < points.length; right += 1) { + if (distanceBetween(points[left], points[right]) < 46) { + errors.push(`${context}: exploration points ${left} and ${right} overlap`); + } + } + } + }); +} + +function validateExplorationPoint(point, context, bounds) { + if (!point || typeof point !== 'object') { + errors.push(`${context} is missing`); + return; + } + if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) { + errors.push(`${context} must use finite x/y coordinates`); + return; + } + if ( + point.x < bounds.minX || + point.x > bounds.maxX || + point.y < bounds.minY || + point.y > bounds.maxY + ) { + errors.push(`${context} (${point.x}, ${point.y}) lies outside the playable map`); + } +} + +function distanceBetween(left, right) { + if (!left || !right || !Number.isFinite(left.x) || !Number.isFinite(left.y) || !Number.isFinite(right.x) || !Number.isFinite(right.y)) { + return Number.POSITIVE_INFINITY; + } + return Math.hypot(left.x - right.x, left.y - right.y); +} + function assertUniqueNonEmptyString(value, seen, context) { assertNonEmptyString(value, context); if (typeof value !== 'string' || value.trim().length === 0) { diff --git a/src/game/data/cityStayExploration.ts b/src/game/data/cityStayExploration.ts new file mode 100644 index 0000000..156e494 --- /dev/null +++ b/src/game/data/cityStayExploration.ts @@ -0,0 +1,301 @@ +import type { MovementTerrain } from '../audio/SoundDirector'; +import type { CityStayId } from './cityStays'; +import type { UnitDirection } from './unitAssets'; + +export type CityStayExplorationTargetKind = 'information' | 'market' | 'dialogue' | 'exit'; + +export type CityStayExplorationActor = { + id: string; + kind: Exclude; + name: string; + role: string; + textureKey: string; + x: number; + y: number; + direction: UnitDirection; +}; + +export type CityStayExplorationBuilding = { + id: string; + label: string; + x: number; + y: number; + width: number; + height: number; + wallColor: number; + roofColor: number; +}; + +export type CityStayExplorationProfile = { + id: CityStayId; + heading: string; + subtitle: string; + landmarkLabel: string; + groundColor: number; + grassColor: number; + roadColor: number; + roadHighlightColor: number; + accentColor: number; + playerStart: { x: number; y: number; direction: UnitDirection }; + exit: { id: 'camp-exit'; name: string; x: number; y: number }; + actors: readonly [ + CityStayExplorationActor, + CityStayExplorationActor, + CityStayExplorationActor + ]; + buildings: readonly [ + CityStayExplorationBuilding, + CityStayExplorationBuilding, + CityStayExplorationBuilding + ]; + musicKey: string; + ambienceKey: string; + movementTerrain: MovementTerrain; +}; + +export const cityStayExplorationProfiles: Readonly> = { + xuzhou: { + id: 'xuzhou', + heading: '서주성 · 구원전 뒤', + subtitle: '성내의 장부와 시장을 살피고 미축과 다음 방위선을 준비합니다.', + landmarkLabel: '서주 중앙대로', + groundColor: 0x526044, + grassColor: 0x394b35, + roadColor: 0xb39263, + roadHighlightColor: 0xd2b77f, + accentColor: 0xd8b15f, + playerStart: { x: 760, y: 720, direction: 'north' }, + exit: { id: 'camp-exit', name: '군영으로 돌아가기', x: 760, y: 895 }, + actors: [ + { + id: 'xuzhou-information-clerk', + kind: 'information', + name: '서주 서리', + role: '관청 장부 담당', + textureKey: 'unit-shu-strategist', + x: 365, + y: 485, + direction: 'east' + }, + { + id: 'xuzhou-market-smith', + kind: 'market', + name: '서주 대장장이', + role: '일반 장비 거래', + textureKey: 'unit-shu-infantry-guard', + x: 1110, + y: 480, + direction: 'west' + }, + { + id: 'xuzhou-companion-mi-zhu', + kind: 'dialogue', + name: '미축', + role: '서주의 창고와 민심', + textureKey: 'unit-shu-officer-council', + x: 1015, + y: 770, + direction: 'west' + } + ], + buildings: [ + { + id: 'xuzhou-office', + label: '서주 관청', + x: 105, + y: 155, + width: 410, + height: 235, + wallColor: 0xc1a06d, + roofColor: 0x6c3027 + }, + { + id: 'xuzhou-market', + label: '장인 거리', + x: 990, + y: 150, + width: 390, + height: 225, + wallColor: 0xbda471, + roofColor: 0x334a5d + }, + { + id: 'xuzhou-guest-hall', + label: '미축 객관', + x: 1090, + y: 670, + width: 330, + height: 215, + wallColor: 0xab8b61, + roofColor: 0x4b3930 + } + ], + musicKey: 'camp-wayfarer', + ambienceKey: 'river-night-ambience', + movementTerrain: 'village' + }, + xinye: { + id: 'xinye', + heading: '신야성 · 박망파 출진 준비', + subtitle: '객사의 숲길 정보를 모으고 제갈량의 첫 군령을 함께 정리합니다.', + landmarkLabel: '신야 느티나무 길', + groundColor: 0x435943, + grassColor: 0x2f4934, + roadColor: 0xa88a5f, + roadHighlightColor: 0xc9ad78, + accentColor: 0x9eb978, + playerStart: { x: 760, y: 720, direction: 'north' }, + exit: { id: 'camp-exit', name: '군영으로 돌아가기', x: 760, y: 895 }, + actors: [ + { + id: 'xinye-information-guide', + kind: 'information', + name: '신야 길잡이', + role: '박망파 숲길 정찰', + textureKey: 'unit-shu-cavalry-scout', + x: 365, + y: 485, + direction: 'east' + }, + { + id: 'xinye-market-smith', + kind: 'market', + name: '신야 장인', + role: '출진 장비 거래', + textureKey: 'unit-shu-infantry-guard', + x: 1110, + y: 480, + direction: 'west' + }, + { + id: 'xinye-companion-zhuge', + kind: 'dialogue', + name: '제갈량', + role: '와룡의 첫 군령', + textureKey: 'unit-zhuge-liang', + x: 1015, + y: 770, + direction: 'west' + } + ], + buildings: [ + { + id: 'xinye-guesthouse', + label: '신야 객사', + x: 105, + y: 155, + width: 410, + height: 235, + wallColor: 0xb4a274, + roofColor: 0x3d593d + }, + { + id: 'xinye-market', + label: '북문 장터', + x: 990, + y: 150, + width: 390, + height: 225, + wallColor: 0xb9a06f, + roofColor: 0x635134 + }, + { + id: 'xinye-command-hall', + label: '군사 관사', + x: 1090, + y: 670, + width: 330, + height: 215, + wallColor: 0xa99670, + roofColor: 0x2f4d50 + } + ], + musicKey: 'camp-grand-strategy', + ambienceKey: 'mountain-wind-ambience', + movementTerrain: 'forest' + }, + chengdu: { + id: 'chengdu', + heading: '성도 · 항복 뒤의 첫날', + subtitle: '성도의 질서를 살피고 황권과 가맹관 북문 길을 준비합니다.', + landmarkLabel: '성도 북문대로', + groundColor: 0x5b5a43, + grassColor: 0x414a35, + roadColor: 0xbc9c68, + roadHighlightColor: 0xddc286, + accentColor: 0xe0be71, + playerStart: { x: 760, y: 720, direction: 'north' }, + exit: { id: 'camp-exit', name: '군영으로 돌아가기', x: 760, y: 895 }, + actors: [ + { + id: 'chengdu-information-clerk', + kind: 'information', + name: '익주 서리', + role: '북문 장부 담당', + textureKey: 'unit-shu-strategist-field', + x: 365, + y: 485, + direction: 'east' + }, + { + id: 'chengdu-market-smith', + kind: 'market', + name: '성도 대장장이', + role: '서량 대비 장비 거래', + textureKey: 'unit-shu-officer-vanguard', + x: 1110, + y: 480, + direction: 'west' + }, + { + id: 'chengdu-companion-huang-quan', + kind: 'dialogue', + name: '황권', + role: '익주의 질서와 북문', + textureKey: 'unit-huang-quan', + x: 1015, + y: 770, + direction: 'west' + } + ], + buildings: [ + { + id: 'chengdu-office', + label: '성도 임시 관청', + x: 105, + y: 155, + width: 410, + height: 235, + wallColor: 0xc6ad77, + roofColor: 0x704132 + }, + { + id: 'chengdu-market', + label: '성도 시장', + x: 990, + y: 150, + width: 390, + height: 225, + wallColor: 0xc1a56f, + roofColor: 0x4c5365 + }, + { + id: 'chengdu-north-hall', + label: '황권 관사', + x: 1090, + y: 670, + width: 330, + height: 215, + wallColor: 0xb29868, + roofColor: 0x40382e + } + ], + musicKey: 'camp-frontier', + ambienceKey: 'mountain-wind-ambience', + movementTerrain: 'fort' + } +}; + +export function getCityStayExplorationProfile(id: CityStayId) { + return cityStayExplorationProfiles[id]; +} diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index d78ecf4..7d527ce 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -191,6 +191,7 @@ import { listCampaignSaveSlots, campaignSaveSlotCount, saveCampaignState, + setActiveCityStayId, setCampaignSortieResonanceSelection, setCampaignSortieOrderSelection, setCampaignReserveTrainingAssignment, @@ -210,6 +211,11 @@ import { type CampaignVictoryRewardReport, type FirstBattleReport } from '../state/campaignState'; +import { + chooseCityStayResonance, + collectCityStayInformation, + purchaseCityStayEquipment +} from '../state/cityStayActions'; import { clearCampaignBattleSavesForSlot } from '../state/battleSaveKeys'; import { releaseTextureSource } from '../render/loaderLifecycle'; import { palette } from '../ui/palette'; @@ -11341,6 +11347,7 @@ export class CampScene extends Phaser.Scene { private activeTab: CampTab = 'status'; private selectedCityLocation: CityStayLocationId = 'information'; private cityPanelBackground?: Phaser.GameObjects.Rectangle; + private cityExploreButton?: Phaser.GameObjects.Rectangle; private cityLocationViews: CityStayLocationView[] = []; private cityInformationActionButton?: Phaser.GameObjects.Rectangle; private cityDialogueChoiceButtons: Phaser.GameObjects.Rectangle[] = []; @@ -11459,6 +11466,7 @@ export class CampScene extends Phaser.Scene { this.campRosterPage = 0; this.campRosterLayout = undefined; this.cityPanelBackground = undefined; + this.cityExploreButton = undefined; this.cityLocationViews = []; this.cityInformationActionButton = undefined; this.cityDialogueChoiceButtons = []; @@ -22537,7 +22545,7 @@ export class CampScene extends Phaser.Scene { this.track(this.add.text(x + 24, y + 18, cityStay.city.title, this.textStyle(24, '#f2e3bf', true))); this.track(this.add.text(x + 24, y + 51, cityStay.city.description, { ...this.textStyle(13, '#d4dce6'), - wordWrap: { width: 600, useAdvancedWrap: true } + wordWrap: { width: 610, useAdvancedWrap: true } })); const progress = this.track(this.add.text( x + width - 24, @@ -22547,8 +22555,23 @@ export class CampScene extends Phaser.Scene { )); progress.setOrigin(1, 0); - const locations: Array<{ - id: CityStayLocationId; + const guide = this.track(this.add.rectangle(x + 24, y + 86, width - 48, 58, 0x17222d, 0.96)); + guide.setOrigin(0); + guide.setStrokeStyle(1, palette.blue, 0.58); + this.track(this.add.text( + x + 44, + y + 99, + '직접 성 안을 걸으며 사람들에게 다가가 활동을 진행합니다.', + this.textStyle(14, '#e8edf2', true) + )); + this.track(this.add.text( + x + 44, + y + 122, + 'WASD·방향키·바닥 클릭으로 이동 · 가까이에서 E / Space로 대화', + this.textStyle(11, '#9fb0bf') + )); + + const activities: Array<{ mark: string; title: string; subtitle: string; @@ -22556,94 +22579,110 @@ export class CampScene extends Phaser.Scene { complete: boolean; }> = [ { - id: 'information', mark: '첩', title: '정보 수집', subtitle: cityStay.information.location, - status: informationComplete ? '다음 전투 정보 확보' : '새 정보 확인 가능', + status: informationComplete ? '✓ 다음 전투 정보 확보' : '관리인에게 직접 문의', complete: informationComplete }, { - id: 'market', mark: '시', title: '시장과 대장간', - subtitle: `${cityStay.equipmentOffers.length}종 장비`, - status: `군자금 ${this.campaign?.gold ?? 0}`, + subtitle: `${cityStay.equipmentOffers.length}종 일반 장비`, + status: `선택 활동 · 군자금 ${this.campaign?.gold ?? 0}`, complete: false }, { - id: 'dialogue', mark: '공', title: '동료와 대화', subtitle: this.cityDialogueUnitNames(cityStay), - status: dialogueComplete ? '공명 대화 완료' : '선택에 따라 공명 상승', + status: dialogueComplete ? '✓ 공명 대화 완료' : '선택으로 공명 상승', complete: dialogueComplete } ]; - this.cityLocationViews = locations.map((location, index) => { - const cardX = x + 24 + index * 266; - const cardY = y + 86; - const selected = this.selectedCityLocation === location.id; + activities.forEach((activity, index) => { + const cardX = x + 24 + index * 259; + const cardY = y + 158; const card = this.track(this.add.rectangle( cardX, cardY, - 248, - 70, - selected ? 0x283d50 : 0x151f2a, - selected ? 0.98 : 0.9 + 245, + 128, + activity.complete ? 0x17291f : 0x151f2a, + 0.96 )); card.setOrigin(0); card.setStrokeStyle( - selected ? 2 : 1, - location.complete ? palette.green : selected ? palette.gold : palette.blue, - selected ? 0.88 : 0.5 + activity.complete ? 2 : 1, + activity.complete ? palette.green : palette.blue, + activity.complete ? 0.86 : 0.54 ); - card.setInteractive({ useHandCursor: true }); - card.on('pointerover', () => { - if (this.selectedCityLocation !== location.id) { - card.setFillStyle(0x213343, 0.96); - } - }); - card.on('pointerout', () => { - if (this.selectedCityLocation !== location.id) { - card.setFillStyle(0x151f2a, 0.9); - } - }); - card.on('pointerdown', () => { - soundDirector.playSelect(); - this.selectedCityLocation = location.id; - this.render(); - }); - const mark = this.track(this.add.circle(cardX + 29, cardY + 35, 18, 0x0d141c, 0.96)); - mark.setStrokeStyle(1, location.complete ? palette.green : palette.gold, 0.72); - const markText = this.track(this.add.text(cardX + 29, cardY + 35, location.mark, this.textStyle(15, '#f2e3bf', true))); - markText.setOrigin(0.5); - this.track(this.add.text(cardX + 56, cardY + 10, location.title, this.textStyle(15, '#f2e3bf', true))); - this.track(this.add.text(cardX + 56, cardY + 31, this.compactText(location.subtitle, 20), this.textStyle(11, '#9fb0bf'))); - this.track(this.add.text( - cardX + 56, - cardY + 49, - location.status, - this.textStyle(10, location.complete ? '#a8ffd0' : selected ? '#e8c978' : '#d4dce6', true) + const mark = this.track(this.add.circle(cardX + 31, cardY + 32, 19, 0x0d141c, 0.96)); + mark.setStrokeStyle(1, activity.complete ? palette.green : palette.gold, 0.72); + const markText = this.track(this.add.text( + cardX + 31, + cardY + 32, + activity.mark, + this.textStyle(15, '#f2e3bf', true) + )); + markText.setOrigin(0.5); + this.track(this.add.text( + cardX + 60, + cardY + 20, + activity.title, + this.textStyle(15, '#f2e3bf', true) + )); + this.track(this.add.text(cardX + 18, cardY + 58, this.compactText(activity.subtitle, 24), { + ...this.textStyle(11, '#9fb0bf'), + wordWrap: { width: 209, useAdvancedWrap: true } + })); + this.track(this.add.text( + cardX + 18, + cardY + 101, + activity.status, + this.textStyle(10, activity.complete ? '#a8ffd0' : '#e8c978', true) )); - return { id: location.id, background: card }; }); - const detailX = x + 24; - const detailY = y + 172; - const detailWidth = width - 48; - const detailHeight = 248; - if (this.selectedCityLocation === 'market') { - this.renderCityMarket(cityStay, detailX, detailY, detailWidth, detailHeight); - return; - } - if (this.selectedCityLocation === 'dialogue') { - this.renderCityDialogue(cityStay, detailX, detailY, detailWidth, detailHeight); - return; - } - this.renderCityInformation(cityStay, detailX, detailY, detailWidth, detailHeight); + const enter = this.track(this.add.rectangle(x + width / 2, y + 335, 360, 54, 0x4a371d, 0.99)); + this.cityExploreButton = enter; + enter.setStrokeStyle(2, palette.gold, 0.94); + enter.setInteractive({ useHandCursor: true }); + enter.on('pointerover', () => enter.setFillStyle(0x654c25, 1)); + enter.on('pointerout', () => enter.setFillStyle(0x4a371d, 0.99)); + enter.on('pointerdown', () => this.enterCityStay(cityStay)); + const enterLabel = this.track(this.add.text( + x + width / 2, + y + 335, + '성 안으로 이동', + this.textStyle(17, '#fff2b8', true) + )); + enterLabel.setOrigin(0.5); + + const optionalNotice = this.track(this.add.text( + x + width / 2, + y + 387, + '체류 활동은 선택 사항이며, 성 안의 남쪽 출구에서 언제든 군영으로 돌아올 수 있습니다.', + this.textStyle(11, '#9fb0bf') + )); + optionalNotice.setOrigin(0.5, 0); + } + + private enterCityStay(cityStay: CityStayDefinition) { + const previousActiveCityStayId = getCampaignState().activeCityStayId; + soundDirector.playSelect(); + this.startCampNavigation( + 'CityStayScene', + { cityStayId: cityStay.id }, + () => { + this.campaign = setActiveCityStayId(cityStay.id); + }, + () => { + this.campaign = setActiveCityStayId(previousActiveCityStayId); + } + ); } private renderCityInformation( @@ -22842,71 +22881,67 @@ export class CampScene extends Phaser.Scene { } private gatherCityInformation(cityStay: CityStayDefinition) { - if (this.completedCampVisits().includes(cityStay.information.id)) { - this.showCampNotice('이미 확보한 정보입니다.'); - return; - } - const updated = applyCampVisitReward( - cityStay.information.id, - { itemRewards: [cityStay.information.itemReward] }, - 'city-information-gathered' - ); - if (!updated) { + const result = collectCityStayInformation(cityStay.id, cityStay.information.id); + if (!result.ok) { + if (result.reason === 'already-completed') { + this.showCampNotice('이미 확보한 정보입니다.'); + return; + } this.showCampNotice('정보를 장부에 기록하지 못했습니다. 전투 결과 저장 상태를 확인해 주세요.'); return; } - this.campaign = updated; - this.report = updated.firstBattleReport ?? this.report; + this.campaign = result.campaign; + this.report = result.campaign.firstBattleReport ?? this.report; soundDirector.playEffect('exp-gain', { volume: 0.24, stopAfterMs: 700 }); this.showCampNotice( - `정보 확보 · ${cityStay.information.title}\n다음 전투 브리핑에 ${cityStay.city.name} 현지 정보가 추가됩니다.` + `정보 확보 · ${result.information.title}\n다음 전투 브리핑에 ${result.cityStay.city.name} 현지 정보가 추가됩니다.` ); this.render(); } private completeCityDialogue(cityStay: CityStayDefinition, choice: CityResonanceDialogueChoice) { - const dialogue = cityStay.dialogue; - if (this.completedCampDialogues().includes(dialogue.id)) { - this.showCampNotice('이미 완료된 공명 대화입니다.'); - return; - } - const rewardExp = dialogue.rewardExp + choice.rewardExp; - const updated = applyCampBondExp(dialogue.id, dialogue.bondId, rewardExp, choice.id); - if (!updated) { + const result = chooseCityStayResonance(cityStay.id, choice.id); + if (!result.ok) { + if (result.reason === 'already-completed') { + this.showCampNotice('이미 완료된 공명 대화입니다.'); + return; + } this.showCampNotice('두 동료의 공명 관계를 찾지 못했습니다. 합류 상태를 확인해 주세요.'); return; } - this.report = updated; - this.campaign = getCampaignState(); + this.report = result.report; + this.campaign = result.campaign; soundDirector.playEffect('exp-gain', { volume: 0.28, stopAfterMs: 700 }); - this.showCampNotice(`공명 +${rewardExp} · ${choice.label}\n${choice.response}`); + this.showCampNotice(`공명 +${result.rewardExp} · ${result.choice.label}\n${result.choice.response}`); this.render(); } private buyCityEquipment(offer: CityEquipmentOffer) { const cityStay = this.currentCityStay(); - if (!cityStay || !cityStay.equipmentOffers.some((candidate) => candidate.id === offer.id)) { + if (!cityStay) { this.showCampNotice('현재 시장에서는 살 수 없는 장비입니다.'); return; } - const item = getItem(offer.itemId); - const campaign = this.campaign ?? getCampaignState(); - if (item.rank !== 'common') { - this.showCampNotice('보물 장비는 일반 시장에서 거래할 수 없습니다.'); - return; - } - if (campaign.gold < offer.price) { - this.showCampNotice('군자금이 부족합니다.'); + const result = purchaseCityStayEquipment(cityStay.id, offer.id); + if (!result.ok) { + if (result.reason === 'restricted-item') { + this.showCampNotice('보물 장비는 일반 시장에서 거래할 수 없습니다.'); + return; + } + if (result.reason === 'insufficient-gold') { + this.showCampNotice('군자금이 부족합니다.'); + return; + } + this.showCampNotice('현재 시장에서는 살 수 없는 장비입니다.'); return; } - campaign.gold -= offer.price; - const label = itemInventoryLabel(item.id); - campaign.inventory[label] = (campaign.inventory[label] ?? 0) + 1; - this.campaign = saveCampaignState(campaign); + this.campaign = result.campaign; this.report = this.campaign.firstBattleReport ?? this.report; soundDirector.playSelect(); - this.showCampNotice(`${cityStay.city.name} 시장 · ${item.name} 구입 · 군자금 -${offer.price}`); + this.showCampNotice( + `${result.cityStay.city.name} 시장 · ${result.item.name} 구입 · 군자금 -${result.offer.price}` + ); this.render(); } @@ -24530,6 +24565,7 @@ export class CampScene extends Phaser.Scene { this.contentObjects = []; this.campRosterLayout = undefined; this.cityPanelBackground = undefined; + this.cityExploreButton = undefined; this.cityLocationViews = []; this.cityInformationActionButton = undefined; this.cityDialogueChoiceButtons = []; @@ -24745,8 +24781,11 @@ export class CampScene extends Phaser.Scene { afterBattleId: cityStay.afterBattleId, nextBattleId: cityStay.nextBattleId, active: this.activeTab === 'city', + mode: 'exploration-entry', selectedLocation: this.selectedCityLocation, panelBounds: this.sortieObjectBoundsDebug(this.cityPanelBackground), + explorationButtonBounds: this.sortieObjectBoundsDebug(this.cityExploreButton), + explorationInteractive: Boolean(this.cityExploreButton?.input?.enabled), pendingActions: Number(!cityInformationComplete) + Number(!cityDialogueComplete), locations: this.cityLocationViews.map((view) => ({ id: view.id, @@ -24798,8 +24837,11 @@ export class CampScene extends Phaser.Scene { id: null, name: null, active: false, + mode: null, selectedLocation: null, panelBounds: null, + explorationButtonBounds: null, + explorationInteractive: false, pendingActions: 0, locations: [], information: null, diff --git a/src/game/scenes/CityStayScene.ts b/src/game/scenes/CityStayScene.ts new file mode 100644 index 0000000..7ff8ce1 --- /dev/null +++ b/src/game/scenes/CityStayScene.ts @@ -0,0 +1,1916 @@ +import Phaser from 'phaser'; +import { soundDirector } from '../audio/SoundDirector'; +import { + cityStayExplorationProfiles, + getCityStayExplorationProfile, + type CityStayExplorationActor, + type CityStayExplorationProfile, + type CityStayExplorationTargetKind +} from '../data/cityStayExploration'; +import { + cityStayDefinitions, + findCityStayAfterBattle, + type CityEquipmentOffer, + type CityResonanceDialogueChoice, + type CityStayDefinition, + type CityStayId +} from '../data/cityStays'; +import type { BattleScenarioId } from '../data/battles'; +import { + equipmentSlotLabels, + getItem, + itemInventoryLabel +} from '../data/battleItems'; +import { + ensureUnitAnimations, + loadUnitBaseSheets, + releaseUnitBaseSheetTextures, + type UnitDirection +} from '../data/unitAssets'; +import { + chooseCityStayResonance, + collectCityStayInformation, + purchaseCityStayEquipment +} from '../state/cityStayActions'; +import { + getCampaignState, + setActiveCityStayId, + type CampaignState +} from '../state/campaignState'; +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 = 164; +const characterDisplaySize = 104; +const inputCarryoverGuardMs = 320; + +type CityStaySceneData = { + cityStayId?: CityStayId; +}; + +type CityActorView = { + definition: CityStayExplorationActor; + sprite: Phaser.GameObjects.Sprite; + shadow: Phaser.GameObjects.Ellipse; + nameplate: Phaser.GameObjects.Text; + marker: Phaser.GameObjects.Text; +}; + +type CityInteractionTarget = + | { + kind: Exclude; + id: string; + name: string; + x: number; + y: number; + } + | { + kind: 'exit'; + id: 'camp-exit'; + name: string; + x: number; + y: number; + }; + +type CityDialogueLine = { + speaker: string; + text: string; + textureKey?: string; +}; + +type DialogueState = { + lines: CityDialogueLine[]; + lineIndex: number; + onComplete?: () => void; + sourceTargetId?: string; +}; + +type ShopOfferView = { + offerId: string; + button: Phaser.GameObjects.Rectangle; + interactive: boolean; +}; + +type ChoiceView = { + choiceId: string; + button: Phaser.GameObjects.Rectangle; + interactive: boolean; +}; + +const cityFallback = cityStayDefinitions[0]; + +export class CityStayScene extends Phaser.Scene { + private cityStay: CityStayDefinition = cityFallback; + private profile: CityStayExplorationProfile = cityStayExplorationProfiles[cityFallback.id]; + private campaign?: CampaignState; + private player?: Phaser.GameObjects.Sprite; + private playerShadow?: Phaser.GameObjects.Ellipse; + private playerNameplate?: Phaser.GameObjects.Text; + private playerDirection: UnitDirection = 'north'; + private playerMoving = false; + private actors = new Map(); + private blockers: Phaser.Geom.Rectangle[] = []; + private promptBackground?: Phaser.GameObjects.Rectangle; + private promptText?: Phaser.GameObjects.Text; + private progressText?: Phaser.GameObjects.Text; + private informationStatus?: Phaser.GameObjects.Text; + private dialogueStatus?: Phaser.GameObjects.Text; + private informationCard?: Phaser.GameObjects.Rectangle; + private dialogueCard?: Phaser.GameObjects.Rectangle; + 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 shopPanel?: Phaser.GameObjects.Container; + private shopOfferViews: ShopOfferView[] = []; + private shopNotice = ''; + private choicePanel?: Phaser.GameObjects.Container; + private choiceViews: ChoiceView[] = []; + 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 lastNotice = ''; + + constructor() { + super('CityStayScene'); + } + + init(data?: CityStaySceneData) { + const campaign = getCampaignState(); + const requested = data?.cityStayId + ? cityStayDefinitions.find((definition) => definition.id === data.cityStayId) + : undefined; + const fromCampaign = campaign.latestBattleId + ? findCityStayAfterBattle(campaign.latestBattleId as BattleScenarioId) + : undefined; + this.cityStay = requested && (!fromCampaign || requested.id === fromCampaign.id) + ? requested + : fromCampaign ?? cityFallback; + this.profile = getCityStayExplorationProfile(this.cityStay.id); + } + + create() { + this.resetRuntimeState(); + this.markCityStayActive(); + this.drawCity(); + this.drawHud(); + this.createLoadingOverlay(); + this.setupInput(); + + soundDirector.playSoundscape({ + musicKey: this.profile.musicKey, + ambienceKey: this.profile.ambienceKey, + musicVolume: 0.68, + ambienceVolume: 0.1 + }); + + this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => { + this.ready = false; + this.navigationPending = false; + this.moveTarget = undefined; + this.dialogueState = undefined; + releaseUnitBaseSheetTextures(this); + }); + + const textureKeys = this.characterTextureKeys(); + loadUnitBaseSheets(this, textureKeys, () => { + if (!this.scene.isActive()) { + return; + } + ensureUnitAnimations(this, textureKeys); + this.createActors(); + this.createDialoguePanel(); + this.loadingOverlay?.destroy(); + this.loadingOverlay = undefined; + this.ready = true; + this.inputReadyAt = this.time.now + inputCarryoverGuardMs; + this.refreshProgressHud(); + this.refreshActorMarkers(); + this.refreshInteractionPrompt(); + }); + } + + update(time: number, delta: number) { + if (!this.ready || this.navigationPending || time < this.inputReadyAt) { + this.interactionQueued = false; + return; + } + + if (this.shopPanel || this.choicePanel) { + this.stopPlayerMovement(); + 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 nearest = this.nearestInteractionTarget(promptRadius); + const informationComplete = this.informationComplete(campaign); + const dialogueComplete = this.dialogueComplete(campaign); + + return { + scene: this.scene.key, + ready: this.ready, + cityStayId: this.cityStay.id, + cityName: this.cityStay.city.name, + viewport: { width: this.scale.width, height: this.scale.height }, + activeCityStayId: campaign.activeCityStayId ?? null, + 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, + bounds: this.boundsSnapshot(movementBounds), + collisionRadius: playerCollisionRadius + }, + controls: { + movement: ['WASD', '방향키'], + interact: ['E', 'Space', 'Enter'], + pointerMove: true + }, + interaction: { + radius: interactionRadius, + promptRadius, + targetId: nearest?.id ?? null, + targetKind: nearest?.kind ?? null, + canInteract: Boolean(nearest && this.distanceTo(nearest.x, nearest.y) <= interactionRadius), + promptVisible: this.promptBackground?.visible ?? false, + promptBounds: this.promptBackground?.visible + ? this.boundsSnapshot(this.promptBackground.getBounds()) + : null + }, + progress: { + completed: Number(informationComplete) + Number(dialogueComplete), + total: 2, + informationComplete, + dialogueComplete, + marketOptional: true, + exitUnlocked: true + }, + actors: Array.from(this.actors.values()).map(({ definition, sprite }) => ({ + id: definition.id, + kind: definition.kind, + name: definition.name, + role: definition.role, + textureKey: definition.textureKey, + x: sprite.x, + y: sprite.y, + distance: playerPosition + ? Phaser.Math.Distance.Between(playerPosition.x, playerPosition.y, sprite.x, sprite.y) + : null, + completed: definition.kind === 'information' + ? informationComplete + : definition.kind === 'dialogue' + ? dialogueComplete + : false, + bounds: this.boundsSnapshot(sprite.getBounds()) + })), + exit: { + ...this.profile.exit, + distance: playerPosition + ? Phaser.Math.Distance.Between( + playerPosition.x, + playerPosition.y, + this.profile.exit.x, + this.profile.exit.y + ) + : null + }, + dialogue: this.dialogueState + ? { + active: true, + speaker: this.dialogueState.lines[this.dialogueState.lineIndex]?.speaker ?? '', + lineIndex: this.dialogueState.lineIndex, + totalLines: this.dialogueState.lines.length, + sourceTargetId: this.dialogueState.sourceTargetId ?? null, + bounds: this.dialoguePanel?.visible + ? this.boundsSnapshot(this.dialoguePanel.getBounds()) + : null + } + : { + active: false, + speaker: null, + lineIndex: null, + totalLines: 0, + sourceTargetId: null, + bounds: null + }, + shop: { + open: Boolean(this.shopPanel), + gold: campaign.gold, + notice: this.shopNotice, + panelBounds: this.shopPanel ? this.boundsSnapshot(this.shopPanel.getBounds()) : null, + offers: this.cityStay.equipmentOffers.map((offer) => { + const item = getItem(offer.itemId); + const view = this.shopOfferViews.find((candidate) => candidate.offerId === offer.id); + return { + id: offer.id, + itemId: item.id, + itemName: item.name, + price: offer.price, + owned: campaign.inventory[itemInventoryLabel(item.id)] ?? 0, + canBuy: campaign.gold >= offer.price, + interactive: Boolean(view?.interactive), + buttonBounds: view ? this.boundsSnapshot(view.button.getBounds()) : null + }; + }) + }, + choice: { + open: Boolean(this.choicePanel), + panelBounds: this.choicePanel ? this.boundsSnapshot(this.choicePanel.getBounds()) : null, + choices: this.cityStay.dialogue.choices.map((choice) => { + const view = this.choiceViews.find((candidate) => candidate.choiceId === choice.id); + return { + id: choice.id, + label: choice.label, + rewardExp: this.cityStay.dialogue.rewardExp + choice.rewardExp, + interactive: Boolean(view?.interactive), + bounds: view ? this.boundsSnapshot(view.button.getBounds()) : null + }; + }) + }, + campaign: { + gold: campaign.gold, + inventory: { ...campaign.inventory }, + completedCampVisits: [...campaign.completedCampVisits], + completedCampDialogues: [...campaign.completedCampDialogues], + campDialogueChoiceIds: { ...campaign.campDialogueChoiceIds } + }, + blockers: this.blockers.map((blocker) => this.boundsSnapshot(blocker)), + navigationPending: this.navigationPending, + lastNotice: this.lastNotice, + requiredTexturesReady: this.characterTextureKeys().every((key) => this.textures.exists(key)) + }; + } + + debugTeleportTo(targetIdOrKind: string) { + if (!this.player || !this.ready || this.navigationPending || this.modalOpen()) { + return false; + } + const target = this.interactionTargetByIdOrKind(targetIdOrKind); + 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(targetIdOrKind: string) { + if (!this.debugTeleportTo(targetIdOrKind)) { + return false; + } + const target = this.interactionTargetByIdOrKind(targetIdOrKind); + if (!target) { + return false; + } + this.interactWithTarget(target); + return true; + } + + private resetRuntimeState() { + this.campaign = getCampaignState(); + this.player = undefined; + this.playerShadow = undefined; + this.playerNameplate = undefined; + this.playerDirection = this.profile.playerStart.direction; + this.playerMoving = false; + this.actors.clear(); + this.blockers = []; + this.dialogueState = undefined; + this.shopPanel = undefined; + this.shopOfferViews = []; + this.shopNotice = ''; + this.choicePanel = undefined; + this.choiceViews = []; + this.moveTarget = undefined; + this.ready = false; + this.navigationPending = false; + this.inputReadyAt = Number.POSITIVE_INFINITY; + this.interactionQueued = false; + this.stepIndex = 0; + this.lastStepAt = 0; + this.lastNotice = ''; + } + + private markCityStayActive() { + this.campaign = setActiveCityStayId(this.cityStay.id); + } + + private characterTextureKeys() { + return [ + ...new Set([ + 'unit-liu-bei', + ...this.profile.actors.map((actor) => actor.textureKey) + ]) + ]; + } + + private drawCity() { + const graphics = this.add.graphics().setDepth(-100); + this.cameras.main.setBackgroundColor(`#${this.profile.groundColor.toString(16).padStart(6, '0')}`); + graphics.fillStyle(this.profile.groundColor, 1); + graphics.fillRect(0, 0, sceneWidth, sceneHeight); + graphics.fillStyle(this.profile.grassColor, 1); + graphics.fillRect(0, 92, mapRight, 868); + + this.drawGroundTexture(graphics); + this.drawRoads(graphics); + this.profile.buildings.forEach((building) => { + this.drawBuilding(graphics, building); + this.addBlocker(building.x, building.y, building.width, building.height); + }); + this.drawCityDetails(); + this.drawCampGate(); + + this.add.rectangle(mapRight, 0, sceneWidth - mapRight, sceneHeight, 0x121720, 0.97) + .setOrigin(0) + .setDepth(1400); + this.add.rectangle(mapRight, 0, 3, sceneHeight, this.profile.accentColor, 0.78) + .setOrigin(0) + .setDepth(1401); + this.add.rectangle(0, 0, sceneWidth, 92, 0x111720, 0.97) + .setOrigin(0) + .setDepth(1400); + this.add.rectangle(0, 90, sceneWidth, 2, this.profile.accentColor, 0.72) + .setOrigin(0) + .setDepth(1401); + this.add.rectangle(0, 958, mapRight, 122, 0x10151c, 0.95) + .setOrigin(0) + .setDepth(1400); + this.add.rectangle(0, 956, mapRight, 2, this.profile.accentColor, 0.58) + .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 + ? this.profile.groundColor + : index % 3 === 1 + ? this.profile.grassColor + : this.profile.roadHighlightColor; + graphics.fillStyle(tone, index % 3 === 2 ? 0.16 : 0.3); + graphics.fillCircle(x, y, index % 4 === 0 ? 3 : 2); + } + } + + private drawRoads(graphics: Phaser.GameObjects.Graphics) { + graphics.fillStyle(this.profile.roadColor, 1); + graphics.fillRoundedRect(650, 92, 255, 868, 42); + graphics.fillRoundedRect(70, 525, 1340, 190, 55); + graphics.fillRoundedRect(865, 390, 390, 190, 48); + graphics.fillStyle(this.profile.roadHighlightColor, 0.38); + graphics.fillRoundedRect(686, 92, 56, 868, 28); + graphics.fillRoundedRect(72, 566, 1338, 38, 18); + + graphics.fillStyle(0x775f43, 0.42); + 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 drawBuilding( + graphics: Phaser.GameObjects.Graphics, + building: CityStayExplorationProfile['buildings'][number] + ) { + const { x, y, width, height, wallColor, roofColor, label } = building; + graphics.fillStyle(0x15120f, 0.25); + 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.82); + 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.74); + 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, label, { + 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 drawCityDetails() { + [ + [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(this.profile.roadHighlightColor, 1); + stall.fillTriangle(1260, 445, 1450, 445, 1355, 394); + stall.fillStyle(this.profile.accentColor, 0.78); + stall.fillRect(1278, 430, 154, 18); + this.addBlocker(1270, 414, 170, 112); + + const plaza = this.add.graphics().setDepth(26); + plaza.fillStyle(0x4f5d68, 0.9); + plaza.fillCircle(780, 405, 42); + plaza.fillStyle(0x87a0ad, 0.72); + plaza.fillCircle(780, 405, 28); + plaza.fillStyle(0xc9d6da, 0.72); + plaza.fillCircle(780, 398, 11); + this.addBlocker(750, 390, 60, 38); + + this.add.text(780, 474, this.profile.landmarkLabel, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '22px', + color: '#f3dfaa', + fontStyle: 'bold', + backgroundColor: '#2c2025cc', + padding: { left: 12, right: 12, top: 5, bottom: 5 } + }).setOrigin(0.5).setDepth(45); + } + + 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 drawCampGate() { + const gate = this.add.graphics().setDepth(36); + gate.fillStyle(0x47362a, 1); + gate.fillRect(695, 874, 170, 48); + gate.fillStyle(0x252018, 1); + gate.fillRect(722, 858, 18, 100); + gate.fillRect(820, 858, 18, 100); + gate.fillStyle(this.profile.accentColor, 0.92); + gate.fillRect(700, 920, 160, 5); + this.add.text(780, 898, '군영 출구', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '22px', + color: '#f3dfaa', + fontStyle: 'bold' + }).setOrigin(0.5).setDepth(40); + } + + 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, this.profile.heading, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '33px', + color: '#f4e3b5', + fontStyle: 'bold' + }).setDepth(1500); + this.add.text(495, 35, this.cityStay.city.region, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '19px', + color: '#b4c0cb' + }).setDepth(1500); + + this.add.text(1536, 43, `${this.cityStay.city.name} 체류`, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '31px', + color: '#f4e3b5', + fontStyle: 'bold' + }).setDepth(1500); + this.add.text(1538, 91, this.profile.subtitle, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: '#9fabb8', + wordWrap: { width: 330, useAdvancedWrap: true } + }).setDepth(1500); + + this.informationCard = this.drawObjectiveCard( + 168, + '정보 수집', + this.cityStay.information.location, + '관청 관리인에게 다가가세요.' + ); + this.informationStatus = this.add.text(1550, 187, '', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: '#d8b15f', + fontStyle: 'bold' + }).setDepth(1502); + + this.drawObjectiveCard( + 322, + '시장과 대장간', + `${this.cityStay.equipmentOffers.length}종 일반 장비`, + '필요한 장비를 원하는 만큼 구매합니다.' + ); + this.add.text(1550, 341, '◇ 선택 활동', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: '#d8b15f', + fontStyle: 'bold' + }).setDepth(1502); + + this.dialogueCard = this.drawObjectiveCard( + 476, + '동료와 대화', + this.cityDialogueUnitNames(), + '선택에 따라 공명이 상승합니다.' + ); + this.dialogueStatus = this.add.text(1550, 495, '', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: '#d8b15f', + fontStyle: 'bold' + }).setDepth(1502); + + this.drawObjectiveCard( + 630, + '군영으로 복귀', + '남쪽 군영 출구', + '체류 활동은 선택이며 언제든 돌아갈 수 있습니다.' + ); + this.add.text(1550, 649, '◆ 항상 가능', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: '#a8d58e', + fontStyle: 'bold' + }).setDepth(1502); + + this.progressText = this.add.text(1538, 811, '', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '18px', + color: '#cbd3dc', + lineSpacing: 8, + wordWrap: { width: 330 } + }).setDepth(1502); + + 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.add.text(1250, 1012, 'ESC · 창 닫기', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: '#9fabb8' + }).setOrigin(1, 0).setDepth(1502); + + this.promptBackground = this.add.rectangle(880, 1018, 570, 58, 0x2a2119, 0.98) + .setStrokeStyle(2, this.profile.accentColor, 0.92) + .setDepth(1510) + .setVisible(false); + this.promptText = this.add.text(880, 1018, '', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '21px', + color: '#f6e3af', + fontStyle: 'bold' + }).setOrigin(0.5).setDepth(1511).setVisible(false); + } + + private drawObjectiveCard(y: number, title: string, location: string, help: string) { + const background = this.add.rectangle(1530, y, 350, 132, 0x1b222d, 0.94) + .setOrigin(0) + .setStrokeStyle(2, 0x3d4858, 1) + .setDepth(1500); + this.add.text(1550, y + 48, title, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '22px', + color: '#e8dfca', + fontStyle: 'bold' + }).setDepth(1501); + this.add.text(1550, y + 82, location, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '15px', + color: '#a4afba' + }).setDepth(1501); + this.add.text(1550, y + 105, help, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '12px', + color: '#788491', + wordWrap: { width: 318 } + }).setDepth(1501); + return background; + } + + private createLoadingOverlay() { + const shade = this.add.rectangle(0, 0, sceneWidth, sceneHeight, 0x080b0f, 0.74).setOrigin(0); + const panel = this.add.rectangle(sceneWidth / 2, sceneHeight / 2, 520, 126, 0x151b24, 0.98) + .setStrokeStyle(2, this.profile.accentColor, 0.82); + const text = this.add.text(sceneWidth / 2, sceneHeight / 2, `${this.cityStay.city.name}의 사람들을 불러오는 중...`, { + 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() { + const start = this.profile.playerStart; + this.playerShadow = this.add.ellipse(start.x, start.y + 20, 66, 25, 0x07100a, 0.45).setDepth(800); + this.player = this.createCharacterSprite('unit-liu-bei', start.x, start.y, characterDisplaySize + 8, start.direction); + this.player.setDepth(100 + start.y); + this.playerNameplate = this.add.text(start.x, start.y + 66, '유비', { + 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); + + this.profile.actors.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 = this.add.text(definition.x, definition.y - 72, this.markerText(definition.kind), { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: definition.kind === 'market' ? '22px' : '31px', + color: '#2a2013', + fontStyle: 'bold', + backgroundColor: '#e5bd68', + padding: { left: 10, right: 10, top: 3, bottom: 3 } + }).setOrigin(0.5).setDepth(1201); + this.tweens.add({ + targets: marker, + y: marker.y - 8, + duration: 720, + yoyo: true, + repeat: -1, + ease: 'Sine.easeInOut' + }); + this.actors.set(definition.id, { definition, sprite, shadow, nameplate, marker }); + }); + + const exit = this.profile.exit; + const exitMarker = this.add.text(exit.x, exit.y - 58, '↩', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '32px', + color: '#f0d79b', + fontStyle: 'bold', + backgroundColor: '#3a3024dd', + padding: { left: 10, right: 10, top: 4, bottom: 4 } + }).setOrigin(0.5).setDepth(1201); + this.tweens.add({ + targets: exitMarker, + alpha: { from: 0.68, to: 1 }, + duration: 880, + yoyo: true, + repeat: -1 + }); + } + + 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.45) + .setOrigin(0) + .setInteractive(); + const background = this.add.rectangle(x, y, width, height, 0x141a23, 0.985) + .setOrigin(0) + .setStrokeStyle(3, this.profile.accentColor, 0.94); + const inner = this.add.rectangle(x + 16, y + 16, width - 32, height - 32, 0x1d2531, 0.84) + .setOrigin(0) + .setStrokeStyle(1, 0x546174, 0.72); + 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.addKey(Phaser.Input.Keyboard.KeyCodes.ESC).on('down', () => this.handleEscape()); + keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ONE).on('down', () => this.chooseDialogueByIndex(0)); + keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.TWO).on('down', () => this.chooseDialogueByIndex(1)); + 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.shopPanel || this.choicePanel) { + return; + } + if (this.dialogueState) { + this.advanceDialogue(); + return; + } + if ( + pointer.worldX >= movementBounds.left && + pointer.worldX <= movementBounds.right && + pointer.worldY >= movementBounds.top && + pointer.worldY <= movementBounds.bottom + ) { + this.moveTarget = new Phaser.Math.Vector2(pointer.worldX, pointer.worldY); + } + }); + } + + private handleEscape() { + if (!this.ready || this.navigationPending || this.time.now < this.inputReadyAt) { + return; + } + if (this.shopPanel) { + this.closeShop(); + return; + } + if (this.choicePanel) { + this.closeChoicePanel(); + return; + } + if (this.dialogueState) { + this.cancelDialogue(); + return; + } + this.showPromptMessage('군영으로 돌아가려면 남쪽 군영 출구로 이동하세요.'); + } + + 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: this.profile.movementTerrain, + volume: 0.085, + rate: 1.02, + 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.actors.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); + this.playerNameplate?.setPosition(this.player.x, this.player.y + 66); + } + + 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: CityInteractionTarget) { + this.stopPlayerMovement(); + if (target.kind === 'exit') { + this.interactWithExit(); + return; + } + const view = this.actors.get(target.id); + if (!view) { + return; + } + this.faceCharactersForConversation(view); + if (target.kind === 'information') { + this.interactWithInformation(view); + return; + } + if (target.kind === 'market') { + this.openShop(); + return; + } + this.interactWithCompanion(view); + } + + private interactWithInformation(view: CityActorView) { + const campaign = getCampaignState(); + const information = this.cityStay.information; + if (this.informationComplete(campaign)) { + this.startDialogue([ + { + speaker: view.definition.name, + textureKey: view.definition.textureKey, + text: `${information.title}은 이미 장부에 기록했습니다.` + }, + ...information.briefingLines.map((text) => ({ + speaker: '확보한 정보', + textureKey: view.definition.textureKey, + text + })) + ], undefined, view.definition.id); + return; + } + + this.startDialogue([ + { + speaker: view.definition.name, + textureKey: view.definition.textureKey, + text: information.description + }, + { + speaker: '유비', + textureKey: 'unit-liu-bei', + text: '현지의 증언과 장부를 함께 대조해 다음 싸움에서 놓칠 길이 없도록 합시다.' + }, + ...information.briefingLines.map((text) => ({ + speaker: view.definition.name, + textureKey: view.definition.textureKey, + text + })) + ], () => this.completeInformation(), view.definition.id); + } + + private completeInformation() { + const result = collectCityStayInformation( + this.cityStay.id, + this.cityStay.information.id + ); + if (!result.ok) { + const message = result.reason === 'already-completed' + ? '이미 확보한 정보입니다.' + : '정보를 장부에 기록하지 못했습니다. 저장 상태를 확인해 주세요.'; + this.showCompletionToast(message, false); + this.refreshProgressHud(); + return; + } + this.campaign = result.campaign; + this.lastNotice = `정보 확보 · ${result.information.title}`; + soundDirector.playEffect('exp-gain', { volume: 0.24, stopAfterMs: 700 }); + this.showCompletionToast(`정보 확보 · ${this.cityStay.information.title}`); + this.refreshProgressHud(); + this.refreshActorMarkers(); + } + + private interactWithCompanion(view: CityActorView) { + const campaign = getCampaignState(); + const dialogue = this.cityStay.dialogue; + if (this.dialogueComplete(campaign)) { + const choiceId = campaign.campDialogueChoiceIds[dialogue.id]; + const choice = dialogue.choices.find((candidate) => candidate.id === choiceId); + this.startDialogue([ + { + speaker: view.definition.name, + textureKey: view.definition.textureKey, + text: choice?.response ?? '함께 나눈 결정은 이미 다음 출전을 위한 약속으로 남아 있습니다.' + }, + { + speaker: '체류 기록', + text: choice ? `나의 결정 · ${choice.label}` : '공명 대화 완료' + } + ], undefined, view.definition.id); + return; + } + + const lines = dialogue.lines.map((line) => this.authoredDialogueLine(line, view.definition)); + this.startDialogue(lines, () => this.openChoicePanel(), view.definition.id); + } + + private authoredDialogueLine(line: string, companion: CityStayExplorationActor): CityDialogueLine { + const separator = line.indexOf(':'); + const speaker = separator > 0 ? line.slice(0, separator).trim() : companion.name; + const text = separator > 0 ? line.slice(separator + 1).trim() : line; + return { + speaker, + text, + textureKey: speaker === '유비' ? 'unit-liu-bei' : companion.textureKey + }; + } + + private openChoicePanel() { + if (this.choicePanel || this.navigationPending) { + return; + } + this.stopPlayerMovement(); + this.promptBackground?.setVisible(false); + this.promptText?.setVisible(false); + const dialogue = this.cityStay.dialogue; + const shade = this.add.rectangle(0, 0, sceneWidth, sceneHeight, 0x05070a, 0.56) + .setOrigin(0) + .setInteractive(); + const panel = this.add.rectangle(110, 610, 1700, 360, 0x151c26, 0.99) + .setOrigin(0) + .setStrokeStyle(3, this.profile.accentColor, 0.94); + const title = this.add.text(150, 650, dialogue.title, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '31px', + color: '#f1d691', + fontStyle: 'bold' + }); + const hint = this.add.text(150, 697, '유비의 답을 선택하세요. 선택은 저장되며 공명 경험치가 달라집니다.', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '19px', + color: '#aeb8c4' + }); + const objects: Phaser.GameObjects.GameObject[] = [shade, panel, title, hint]; + this.choiceViews = dialogue.choices.map((choice, index) => { + const x = 150 + index * 820; + const y = 755; + const width = 790; + const button = this.add.rectangle(x, y, width, 160, 0x1d2936, 0.98) + .setOrigin(0) + .setStrokeStyle(2, this.profile.accentColor, 0.76) + .setInteractive({ useHandCursor: true }); + const number = this.add.text(x + 28, y + 24, `${index + 1}`, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '27px', + color: '#f1d691', + fontStyle: 'bold' + }); + const label = this.add.text(x + 76, y + 24, choice.label, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '23px', + color: '#f0ede5', + fontStyle: 'bold', + wordWrap: { width: 670, useAdvancedWrap: true } + }); + const reward = this.add.text( + x + 76, + y + 98, + `공명 +${dialogue.rewardExp + choice.rewardExp}`, + { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '18px', + color: '#a8d58e', + fontStyle: 'bold' + } + ); + button.on('pointerover', () => button.setFillStyle(0x2a3c4e, 0.99)); + button.on('pointerout', () => button.setFillStyle(0x1d2936, 0.98)); + button.on('pointerdown', () => this.chooseDialogue(choice)); + objects.push(button, number, label, reward); + return { choiceId: choice.id, button, interactive: true }; + }); + const cancel = this.add.text(1740, 940, 'ESC · 나중에 결정', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: '#8f9aa7' + }).setOrigin(1, 0.5); + objects.push(cancel); + this.choicePanel = this.add.container(0, 0, objects).setDepth(3200); + } + + private chooseDialogueByIndex(index: number) { + if (!this.choicePanel || this.navigationPending) { + return; + } + const choice = this.cityStay.dialogue.choices[index]; + if (choice) { + this.chooseDialogue(choice); + } + } + + private chooseDialogue(choice: CityResonanceDialogueChoice) { + const result = chooseCityStayResonance(this.cityStay.id, choice.id); + if (!result.ok) { + this.closeChoicePanel(); + const message = result.reason === 'already-completed' + ? '이미 완료한 공명 대화입니다.' + : result.reason === 'bond-unavailable' + ? '두 동료의 공명 관계를 찾지 못했습니다.' + : '선택을 저장하지 못했습니다.'; + this.showCompletionToast(message, false); + this.refreshProgressHud(); + return; + } + this.campaign = result.campaign; + this.lastNotice = `공명 +${result.rewardExp} · ${result.choice.label}`; + this.closeChoicePanel(); + soundDirector.playEffect('bond-resonance', { volume: 0.28, stopAfterMs: 1100 }); + this.refreshProgressHud(); + this.refreshActorMarkers(); + this.startDialogue([ + { + speaker: this.companionActor().name, + textureKey: this.companionActor().textureKey, + text: choice.response + }, + { + speaker: '공명', + text: `${this.cityDialogueUnitNames()} · 공명 +${this.cityStay.dialogue.rewardExp + choice.rewardExp}` + } + ], () => this.showCompletionToast('동료 공명 대화 완료'), this.companionActor().id); + } + + private closeChoicePanel() { + this.choicePanel?.destroy(); + this.choicePanel = undefined; + this.choiceViews = []; + this.refreshInteractionPrompt(); + } + + private openShop() { + if (this.shopPanel || this.navigationPending) { + return; + } + this.stopPlayerMovement(); + this.shopNotice = ''; + this.renderShopPanel(); + } + + private renderShopPanel() { + this.shopPanel?.destroy(); + this.shopOfferViews = []; + const campaign = getCampaignState(); + this.campaign = campaign; + + const shade = this.add.rectangle(0, 0, sceneWidth, sceneHeight, 0x05070a, 0.62) + .setOrigin(0) + .setInteractive(); + const panel = this.add.rectangle(180, 126, 1560, 828, 0x151c26, 0.995) + .setOrigin(0) + .setStrokeStyle(3, this.profile.accentColor, 0.94); + const title = this.add.text(230, 174, `${this.cityStay.city.name} 시장과 대장간`, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '36px', + color: '#f1d691', + fontStyle: 'bold' + }); + const gold = this.add.text(1685, 184, `군자금 ${campaign.gold}`, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '25px', + color: '#f3dfaa', + fontStyle: 'bold' + }).setOrigin(1, 0); + const guide = this.add.text(230, 225, '일반 장비는 여러 개 구매할 수 있으며, 구입 즉시 군영 장비 탭에서 사용할 수 있습니다.', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '19px', + color: '#aeb8c4' + }); + const closeButton = this.add.rectangle(1625, 236, 120, 44, 0x263342, 0.98) + .setStrokeStyle(1, 0x738194, 0.8) + .setInteractive({ useHandCursor: true }); + const closeLabel = this.add.text(1625, 236, '닫기 · ESC', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: '#dce2e8', + fontStyle: 'bold' + }).setOrigin(0.5); + closeButton.on('pointerdown', () => this.closeShop()); + closeButton.on('pointerover', () => closeButton.setFillStyle(0x34485c, 0.99)); + closeButton.on('pointerout', () => closeButton.setFillStyle(0x263342, 0.98)); + + const objects: Phaser.GameObjects.GameObject[] = [ + shade, + panel, + title, + gold, + guide, + closeButton, + closeLabel + ]; + + this.cityStay.equipmentOffers.forEach((offer, index) => { + const item = getItem(offer.itemId); + const owned = campaign.inventory[itemInventoryLabel(item.id)] ?? 0; + const canBuy = campaign.gold >= offer.price; + const rowX = 230; + const rowY = 300 + index * 180; + const row = this.add.rectangle(rowX, rowY, 1460, 146, canBuy ? 0x1b2734 : 0x131a22, 0.98) + .setOrigin(0) + .setStrokeStyle(2, canBuy ? 0x54708a : 0x3f4853, canBuy ? 0.76 : 0.45); + const slotBadge = this.add.circle(rowX + 68, rowY + 73, 42, 0x0e151d, 0.98) + .setStrokeStyle(2, this.profile.accentColor, 0.74); + const slotLabel = this.add.text(rowX + 68, rowY + 73, equipmentSlotLabels[item.slot].slice(0, 2), { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '18px', + color: '#f1d691', + fontStyle: 'bold' + }).setOrigin(0.5); + const name = this.add.text(rowX + 132, rowY + 24, `${item.name} · 보유 ${owned}`, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '25px', + color: canBuy ? '#f0ede5' : '#808a95', + fontStyle: 'bold' + }); + const description = this.add.text(rowX + 132, rowY + 66, item.description, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '18px', + color: canBuy ? '#aeb8c4' : '#69737d', + wordWrap: { width: 850, useAdvancedWrap: true } + }); + const bonus = this.add.text(rowX + 132, rowY + 104, this.itemBonusText(item), { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '16px', + color: canBuy ? '#9ebd98' : '#68736a' + }); + const price = this.add.text(rowX + 1195, rowY + 43, `${offer.price}금`, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '22px', + color: canBuy ? '#f1d691' : '#7f8994', + fontStyle: 'bold' + }).setOrigin(1, 0); + const button = this.add.rectangle(rowX + 1350, rowY + 73, 170, 64, canBuy ? 0x4a371d : 0x20262d, 0.99) + .setStrokeStyle(2, canBuy ? this.profile.accentColor : 0x56606a, canBuy ? 0.9 : 0.45); + const buttonLabel = this.add.text(rowX + 1350, rowY + 73, canBuy ? '구매' : '군자금 부족', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: canBuy ? '21px' : '16px', + color: canBuy ? '#fff2b8' : '#7f8994', + fontStyle: 'bold' + }).setOrigin(0.5); + if (canBuy) { + button.setInteractive({ useHandCursor: true }); + button.on('pointerover', () => button.setFillStyle(0x654c25, 0.99)); + button.on('pointerout', () => button.setFillStyle(0x4a371d, 0.99)); + button.on('pointerdown', () => this.buyOffer(offer)); + } + this.shopOfferViews.push({ offerId: offer.id, button, interactive: canBuy }); + objects.push( + row, + slotBadge, + slotLabel, + name, + description, + bonus, + price, + button, + buttonLabel + ); + }); + + const notice = this.add.text(230, 876, this.shopNotice || '상인에게 필요한 장비를 골라 보세요.', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '19px', + color: this.shopNotice ? '#a8d58e' : '#8995a2', + fontStyle: this.shopNotice ? 'bold' : 'normal' + }); + objects.push(notice); + this.shopPanel = this.add.container(0, 0, objects).setDepth(3100); + } + + private buyOffer(offer: CityEquipmentOffer) { + const result = purchaseCityStayEquipment(this.cityStay.id, offer.id); + const message = result.ok + ? `${result.item.name} 구입 · 군자금 -${result.offer.price}` + : result.reason === 'insufficient-gold' + ? '군자금이 부족합니다.' + : result.reason === 'restricted-item' + ? '보물 장비는 일반 시장에서 거래할 수 없습니다.' + : '현재 시장에서는 살 수 없는 장비입니다.'; + this.shopNotice = message; + this.lastNotice = message; + if (result.ok) { + this.campaign = result.campaign; + soundDirector.playEffect('reward-reveal', { volume: 0.22, stopAfterMs: 720 }); + } else { + soundDirector.playEffect('objective-failure', { volume: 0.16, stopAfterMs: 620 }); + } + this.renderShopPanel(); + this.refreshProgressHud(); + } + + private closeShop() { + this.shopPanel?.destroy(); + this.shopPanel = undefined; + this.shopOfferViews = []; + this.shopNotice = ''; + this.refreshInteractionPrompt(); + } + + private itemBonusText(item: ReturnType) { + const bonuses = [ + item.attackBonus ? `공격 +${item.attackBonus}` : '', + item.defenseBonus ? `방어 +${item.defenseBonus}` : '', + item.strategyBonus ? `책략 +${item.strategyBonus}` : '' + ].filter(Boolean); + return bonuses.length > 0 ? bonuses.join(' · ') : item.effects[0] ?? '일반 장비'; + } + + private interactWithExit() { + const campaign = getCampaignState(); + const pending = [ + !this.informationComplete(campaign) ? '정보 수집' : '', + !this.dialogueComplete(campaign) ? '동료 대화' : '' + ].filter(Boolean); + if (pending.length > 0) { + this.startDialogue([ + { + speaker: '유비', + textureKey: 'unit-liu-bei', + text: `${pending.join('과 ')}이 아직 남아 있다. 체류 활동은 선택이니 필요하면 다음에 다시 들르자.` + }, + { + speaker: '길잡이', + text: '군영으로 돌아갑니다.' + } + ], () => this.returnToCamp(), 'camp-exit'); + return; + } + this.returnToCamp(); + } + + private returnToCamp() { + if (this.navigationPending) { + return; + } + this.navigationPending = true; + this.stopPlayerMovement(); + const previousActiveCityStayId = getCampaignState().activeCityStayId; + this.campaign = setActiveCityStayId(); + this.showTransitionOverlay(); + + void startGameScene(this, 'CampScene').catch((error) => { + console.error('Failed to return from the city stay.', error); + this.campaign = setActiveCityStayId(previousActiveCityStayId ?? this.cityStay.id); + this.navigationPending = false; + this.transitionOverlay?.destroy(); + this.transitionOverlay = undefined; + this.showCompletionToast('군영으로 돌아가지 못했습니다. 잠시 후 다시 시도하세요.', false); + this.refreshInteractionPrompt(); + }); + } + + private showTransitionOverlay() { + const shade = this.add.rectangle(0, 0, sceneWidth, sceneHeight, 0x080b0f, 0.8).setOrigin(0); + const title = this.add.text(sceneWidth / 2, sceneHeight / 2 - 32, `${this.cityStay.city.name} 체류 기록`, { + 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 startDialogue( + lines: CityDialogueLine[], + onComplete?: () => void, + sourceTargetId?: string + ) { + if (!lines.length || this.navigationPending || this.shopPanel || this.choicePanel) { + return; + } + this.stopPlayerMovement(); + this.dialogueState = { lines, lineIndex: 0, onComplete, sourceTargetId }; + 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 cancelDialogue() { + this.dialogueState = undefined; + this.dialoguePanel?.setVisible(false); + this.stopPlayerMovement(); + this.refreshInteractionPrompt(); + } + + private refreshProgressHud() { + const campaign = getCampaignState(); + this.campaign = campaign; + const informationComplete = this.informationComplete(campaign); + const dialogueComplete = this.dialogueComplete(campaign); + const completed = Number(informationComplete) + Number(dialogueComplete); + + this.informationStatus?.setText(informationComplete ? '✓ 정보 확보 완료' : '◆ 진행 가능'); + this.informationStatus?.setColor(informationComplete ? '#a8d58e' : '#e1bc69'); + this.informationCard?.setStrokeStyle( + 2, + informationComplete ? 0x628653 : this.profile.accentColor, + informationComplete ? 0.9 : 0.76 + ); + this.dialogueStatus?.setText(dialogueComplete ? '✓ 공명 대화 완료' : '◆ 진행 가능'); + this.dialogueStatus?.setColor(dialogueComplete ? '#a8d58e' : '#e1bc69'); + this.dialogueCard?.setStrokeStyle( + 2, + dialogueComplete ? 0x628653 : this.profile.accentColor, + dialogueComplete ? 0.9 : 0.76 + ); + this.progressText?.setText( + completed < 2 + ? `체류 기록 ${completed} / 2\n미완료 활동을 남겨 두고도 군영으로 돌아갈 수 있습니다.` + : `주요 체류 활동 완료\n시장 이용 후 군영 출구로 돌아가세요.` + ); + } + + private refreshActorMarkers() { + const campaign = getCampaignState(); + this.actors.forEach(({ definition, marker }) => { + const completed = definition.kind === 'information' + ? this.informationComplete(campaign) + : definition.kind === 'dialogue' + ? this.dialogueComplete(campaign) + : false; + marker.setText(completed ? '✓' : this.markerText(definition.kind)); + marker.setColor(completed ? '#e5f2d5' : '#2a2013'); + marker.setBackgroundColor(completed ? '#4e7549' : '#e5bd68'); + }); + } + + private markerText(kind: CityStayExplorationTargetKind) { + return kind === 'market' ? '전' : kind === 'exit' ? '↩' : '!'; + } + + private refreshInteractionPrompt() { + if (!this.ready || this.modalOpen() || 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 showPromptMessage(message: string) { + this.lastNotice = message; + this.promptBackground?.setVisible(true); + this.promptText?.setText(message).setVisible(true); + this.time.delayedCall(850, () => this.refreshInteractionPrompt()); + } + + private showCompletionToast(label: string, success = true) { + this.completionToast?.destroy(); + this.lastNotice = label; + const background = this.add.rectangle(748, 132, 520, 72, success ? 0x17251c : 0x38201f, 0.98) + .setStrokeStyle(2, success ? 0x8fbd72 : 0xb36f62, 0.92); + const text = this.add.text(748, 132, `${success ? '✓' : '!'} ${label}`, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '22px', + color: success ? '#dff0c9' : '#f2cbc2', + fontStyle: 'bold', + wordWrap: { width: 470, useAdvancedWrap: true }, + align: 'center' + }).setOrigin(0.5); + this.completionToast = this.add.container(0, 0, [background, text]).setDepth(3300); + this.tweens.add({ + targets: this.completionToast, + alpha: 0, + y: -18, + delay: 1500, + duration: 380, + onComplete: () => { + this.completionToast?.destroy(); + this.completionToast = undefined; + } + }); + } + + private nearestInteractionTarget(radius: number) { + if (!this.player) { + return undefined; + } + return this.allInteractionTargets() + .map((target) => ({ target, distance: this.distanceTo(target.x, target.y) })) + .filter(({ distance }) => distance <= radius) + .sort((left, right) => left.distance - right.distance)[0]?.target; + } + + private allInteractionTargets(): CityInteractionTarget[] { + return [ + ...Array.from(this.actors.values()).map(({ definition, sprite }) => ({ + kind: definition.kind, + id: definition.id, + name: definition.kind === 'market' + ? `${definition.name}에게 장비 보기` + : `${definition.name}와 대화`, + x: sprite.x, + y: sprite.y + })), + { + kind: 'exit', + id: 'camp-exit', + name: this.profile.exit.name, + x: this.profile.exit.x, + y: this.profile.exit.y + } + ]; + } + + private interactionTargetByIdOrKind(targetIdOrKind: string) { + return this.allInteractionTargets().find((target) => ( + target.id === targetIdOrKind || target.kind === targetIdOrKind + )); + } + + private faceCharactersForConversation(view: CityActorView) { + 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 informationComplete(campaign = getCampaignState()) { + return campaign.completedCampVisits.includes(this.cityStay.information.id); + } + + private dialogueComplete(campaign = getCampaignState()) { + return campaign.completedCampDialogues.includes(this.cityStay.dialogue.id); + } + + private cityDialogueUnitNames() { + const campaign = this.campaign ?? getCampaignState(); + return this.cityStay.dialogue.unitIds + .map((unitId) => campaign.roster.find((unit) => unit.id === unitId)?.name ?? ( + unitId === 'liu-bei' ? '유비' : this.companionActor().name + )) + .join(' · '); + } + + private companionActor() { + return this.profile.actors.find((actor) => actor.kind === 'dialogue')!; + } + + 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 modalOpen() { + return Boolean(this.dialogueState || this.shopPanel || this.choicePanel); + } + + 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/TitleScene.ts b/src/game/scenes/TitleScene.ts index 696173a..d51f10f 100644 --- a/src/game/scenes/TitleScene.ts +++ b/src/game/scenes/TitleScene.ts @@ -924,6 +924,12 @@ export class TitleScene extends Phaser.Scene { }); return; } + if (campaign.activeCityStayId) { + await this.navigateTo('CityStayScene', { + cityStayId: campaign.activeCityStayId + }); + return; + } if (campaign.step === 'ending-complete') { await this.navigateTo('EndingScene'); return; diff --git a/src/game/scenes/lazyScenes.ts b/src/game/scenes/lazyScenes.ts index 4bc7808..b4de44e 100644 --- a/src/game/scenes/lazyScenes.ts +++ b/src/game/scenes/lazyScenes.ts @@ -3,6 +3,7 @@ import Phaser from 'phaser'; export type LazySceneKey = | 'StoryScene' | 'PrologueVillageScene' + | 'CityStayScene' | 'BattleScene' | 'CampScene' | 'EndingScene'; @@ -12,6 +13,7 @@ type LazySceneConstructor = new () => Phaser.Scene; const lazySceneLoaders: Record Promise> = { StoryScene: () => import('./StoryScene').then((module) => module.StoryScene), PrologueVillageScene: () => import('./PrologueVillageScene').then((module) => module.PrologueVillageScene), + CityStayScene: () => import('./CityStayScene').then((module) => module.CityStayScene), BattleScene: () => import('./BattleScene').then((module) => module.BattleScene), CampScene: () => import('./CampScene').then((module) => module.CampScene), EndingScene: () => import('./EndingScene').then((module) => module.EndingScene) @@ -23,6 +25,7 @@ export function isLazySceneKey(key: string): key is LazySceneKey { return ( key === 'StoryScene' || key === 'PrologueVillageScene' || + key === 'CityStayScene' || key === 'BattleScene' || key === 'CampScene' || key === 'EndingScene' diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts index faabfc9..5a03101 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -1,6 +1,7 @@ import { equipmentExpToNext, equipmentSlots, itemCatalog, type EquipmentSlot } from '../data/battleItems'; import { unitClasses } from '../data/battleRules'; import { battleScenarios, type BattleScenarioId } from '../data/battles'; +import { findCityStayAfterBattle, type CityStayId } from '../data/cityStays'; import { campaignRecruitUnits, jianYongRecruitBond, @@ -508,6 +509,7 @@ export type CampaignState = { battleHistory: Record; latestBattleId?: string; pendingAftermathBattleId?: BattleScenarioId; + activeCityStayId?: CityStayId; firstBattleReport?: FirstBattleReport; }; @@ -808,6 +810,22 @@ export function listCampaignSaveSlots(): CampaignSaveSlotSummary[] { export function markCampaignStep(step: CampaignStep) { const state = ensureCampaignState(); state.step = step; + if (!normalizeActiveCityStayId(state.activeCityStayId, state.latestBattleId, step)) { + delete state.activeCityStayId; + } + state.updatedAt = new Date().toISOString(); + persistCampaignState(state); + return cloneCampaignState(state); +} + +export function setActiveCityStayId(cityStayId?: CityStayId) { + const state = ensureCampaignState(); + const normalizedCityStayId = normalizeActiveCityStayId(cityStayId, state.latestBattleId, state.step); + if (normalizedCityStayId) { + state.activeCityStayId = normalizedCityStayId; + } else { + delete state.activeCityStayId; + } state.updatedAt = new Date().toISOString(); persistCampaignState(state); return cloneCampaignState(state); @@ -1026,6 +1044,7 @@ export function setFirstBattleReport(report: FirstBattleReport) { } state.step = retryStep; state.latestBattleId = battleId; + delete state.activeCityStayId; delete state.pendingAftermathBattleId; state.updatedAt = new Date().toISOString(); persistCampaignState(state); @@ -1075,6 +1094,7 @@ export function setFirstBattleReport(report: FirstBattleReport) { delete state.sortieRecommendationSelection; } state.latestBattleId = battleId; + delete state.activeCityStayId; state.pendingAftermathBattleId = battleId as BattleScenarioId; state.updatedAt = new Date().toISOString(); @@ -1728,6 +1748,16 @@ function normalizeCampaignState(state: Partial): CampaignState { ); } normalized.latestBattleId = normalizeLatestBattleId(normalized.latestBattleId, normalized.battleHistory); + const activeCityStayId = normalizeActiveCityStayId( + normalized.activeCityStayId, + normalized.latestBattleId, + normalized.step + ); + if (activeCityStayId) { + normalized.activeCityStayId = activeCityStayId; + } else { + delete normalized.activeCityStayId; + } const pendingAftermathBattleId = typeof normalized.pendingAftermathBattleId === 'string' && normalized.pendingAftermathBattleId in battleScenarios ? normalized.pendingAftermathBattleId as BattleScenarioId @@ -3228,6 +3258,27 @@ function cloneCampaignState(state: CampaignState): CampaignState { return JSON.parse(JSON.stringify(state)) as CampaignState; } +function normalizeActiveCityStayId( + cityStayId: unknown, + latestBattleId: string | undefined, + step: CampaignStep +): CityStayId | undefined { + if (typeof cityStayId !== 'string' || !latestBattleId || !isCampCampaignStep(step)) { + return undefined; + } + if (!(latestBattleId in battleScenarios)) { + return undefined; + } + + const cityStay = findCityStayAfterBattle(latestBattleId as BattleScenarioId); + const expectedCampStep = cityStay + ? campaignBattleSteps[cityStay.afterBattleId]?.victory + : undefined; + return cityStay?.id === cityStayId && expectedCampStep === step + ? cityStay.id + : undefined; +} + function cloneReport(report: FirstBattleReport): FirstBattleReport { const cloned = JSON.parse(JSON.stringify(report)) as FirstBattleReport; cloned.itemRewards = normalizeRewardStrings(cloned.itemRewards); diff --git a/src/game/state/cityStayActions.ts b/src/game/state/cityStayActions.ts new file mode 100644 index 0000000..1a051a7 --- /dev/null +++ b/src/game/state/cityStayActions.ts @@ -0,0 +1,205 @@ +import { + cityStayDefinitions, + type CityEquipmentOffer, + type CityInformationVisit, + type CityResonanceDialogue, + type CityResonanceDialogueChoice, + type CityStayDefinition, + type CityStayId +} from '../data/cityStays'; +import { + getItem, + itemInventoryLabel, + type ItemDefinition +} from '../data/battleItems'; +import { + applyCampBondExp, + applyCampVisitReward, + getCampaignState, + saveCampaignState, + type CampaignState, + type FirstBattleReport +} from './campaignState'; + +type CityStayActionFailure = { + ok: false; + reason: Reason; +}; + +export type CityInformationActionResult = + | { + ok: true; + cityStay: CityStayDefinition; + information: CityInformationVisit; + campaign: CampaignState; + } + | CityStayActionFailure< + 'invalid-city' | + 'invalid-action' | + 'already-completed' | + 'save-unavailable' + >; + +export type CityResonanceActionResult = + | { + ok: true; + cityStay: CityStayDefinition; + dialogue: CityResonanceDialogue; + choice: CityResonanceDialogueChoice; + rewardExp: number; + report: FirstBattleReport; + campaign: CampaignState; + } + | CityStayActionFailure< + 'invalid-city' | + 'invalid-action' | + 'already-completed' | + 'bond-unavailable' + >; + +export type CityEquipmentPurchaseResult = + | { + ok: true; + cityStay: CityStayDefinition; + offer: CityEquipmentOffer; + item: ItemDefinition; + campaign: CampaignState; + } + | CityStayActionFailure< + 'invalid-city' | + 'invalid-action' | + 'restricted-item' | + 'insufficient-gold' + >; + +export function collectCityStayInformation( + cityStayId: CityStayId, + actionId: string +): CityInformationActionResult { + const cityStay = canonicalCityStay(cityStayId); + if (!cityStay) { + return { ok: false, reason: 'invalid-city' }; + } + if (cityStay.information.id !== actionId) { + return { ok: false, reason: 'invalid-action' }; + } + + const campaign = getCampaignState(); + if (!campaignSupportsCityStay(campaign, cityStay)) { + return { ok: false, reason: 'invalid-city' }; + } + if (campaign.completedCampVisits.includes(cityStay.information.id)) { + return { ok: false, reason: 'already-completed' }; + } + + const updated = applyCampVisitReward( + cityStay.information.id, + { itemRewards: [cityStay.information.itemReward] }, + 'city-information-gathered' + ); + if (!updated) { + return { ok: false, reason: 'save-unavailable' }; + } + + return { + ok: true, + cityStay, + information: cityStay.information, + campaign: updated + }; +} + +export function chooseCityStayResonance( + cityStayId: CityStayId, + actionId: string +): CityResonanceActionResult { + const cityStay = canonicalCityStay(cityStayId); + if (!cityStay) { + return { ok: false, reason: 'invalid-city' }; + } + + const dialogue = cityStay.dialogue; + const choice = dialogue.choices.find((candidate) => candidate.id === actionId); + if (!choice) { + return { ok: false, reason: 'invalid-action' }; + } + + const campaign = getCampaignState(); + if (!campaignSupportsCityStay(campaign, cityStay)) { + return { ok: false, reason: 'invalid-city' }; + } + if (campaign.completedCampDialogues.includes(dialogue.id)) { + return { ok: false, reason: 'already-completed' }; + } + + const rewardExp = dialogue.rewardExp + choice.rewardExp; + const report = applyCampBondExp(dialogue.id, dialogue.bondId, rewardExp, choice.id); + if (!report) { + return { ok: false, reason: 'bond-unavailable' }; + } + + return { + ok: true, + cityStay, + dialogue, + choice, + rewardExp, + report, + campaign: getCampaignState() + }; +} + +export function purchaseCityStayEquipment( + cityStayId: CityStayId, + actionId: string +): CityEquipmentPurchaseResult { + const cityStay = canonicalCityStay(cityStayId); + if (!cityStay) { + return { ok: false, reason: 'invalid-city' }; + } + + const offer = cityStay.equipmentOffers.find((candidate) => candidate.id === actionId); + if (!offer) { + return { ok: false, reason: 'invalid-action' }; + } + + const item = getItem(offer.itemId); + if (item.id !== offer.itemId || item.slot !== offer.slot) { + return { ok: false, reason: 'invalid-action' }; + } + if (item.rank !== 'common') { + return { ok: false, reason: 'restricted-item' }; + } + + const campaign = getCampaignState(); + if (!campaignSupportsCityStay(campaign, cityStay)) { + return { ok: false, reason: 'invalid-city' }; + } + if (campaign.gold < offer.price) { + return { ok: false, reason: 'insufficient-gold' }; + } + + campaign.gold -= offer.price; + const label = itemInventoryLabel(item.id); + campaign.inventory[label] = (campaign.inventory[label] ?? 0) + 1; + + return { + ok: true, + cityStay, + offer, + item, + campaign: saveCampaignState(campaign) + }; +} + +function canonicalCityStay(cityStayId: CityStayId) { + return cityStayDefinitions.find((definition) => definition.id === cityStayId); +} + +function campaignSupportsCityStay( + campaign: CampaignState, + cityStay: CityStayDefinition +) { + return campaign.latestBattleId === cityStay.afterBattleId && + campaign.step.endsWith('-camp'); +} diff --git a/src/main.ts b/src/main.ts index 5ec11d6..3a30f2b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,7 @@ import Phaser from 'phaser'; import { ambienceTracks, effectPools, effectTracks, musicTracks } from './game/audio/audioAssets'; import { soundDirector } from './game/audio/SoundDirector'; +import type { CityStayId } from './game/data/cityStays'; import { BootScene } from './game/scenes/BootScene'; import { startLazySceneFromGame } from './game/scenes/lazyScenes'; import { TitleScene } from './game/scenes/TitleScene'; @@ -32,6 +33,12 @@ type DebugVillageScene = Phaser.Scene & { debugCompleteVillage?: () => boolean; }; +type DebugCityStayScene = Phaser.Scene & { + getDebugState?: () => unknown; + debugTeleportTo?: (targetIdOrKind: string) => boolean; + debugInteractWith?: (targetIdOrKind: string) => boolean; +}; + type HerosDebugApi = { game: Phaser.Game; activeScenes: () => string[]; @@ -39,9 +46,11 @@ type HerosDebugApi = { battle: () => unknown; camp: () => unknown; village: () => unknown; + cityStay: () => unknown; goToBattle: (battleId?: string) => Promise; goToCamp: () => Promise; goToVillage: () => Promise; + goToCityStay: (cityStayId?: CityStayId) => Promise; forceBattleOutcome: (outcome: 'victory' | 'defeat') => void; scene: (key: string) => Phaser.Scene | undefined; toggleBattleOverlay: () => void; @@ -93,6 +102,7 @@ function createDebugApi(game: Phaser.Game): HerosDebugApi { const battleScene = () => scene('BattleScene') as DebugBattleScene | undefined; const campScene = () => scene('CampScene') as DebugCampScene | undefined; const villageScene = () => scene('PrologueVillageScene') as DebugVillageScene | undefined; + const cityStayScene = () => scene('CityStayScene') as DebugCityStayScene | undefined; return { game, @@ -107,9 +117,17 @@ function createDebugApi(game: Phaser.Game): HerosDebugApi { village: () => game.scene.isActive('PrologueVillageScene') ? villageScene()?.getDebugState?.() ?? { active: false, reason: 'PrologueVillageScene debug state is unavailable.' } : { active: false, reason: 'PrologueVillageScene is not active yet.' }, + cityStay: () => game.scene.isActive('CityStayScene') + ? cityStayScene()?.getDebugState?.() ?? { active: false, reason: 'CityStayScene debug state is unavailable.' } + : { active: false, reason: 'CityStayScene is not active yet.' }, goToBattle: (battleId) => startLazySceneFromGame(game, 'BattleScene', battleId ? { battleId } : undefined), goToCamp: () => startLazySceneFromGame(game, 'CampScene'), goToVillage: () => startLazySceneFromGame(game, 'PrologueVillageScene'), + goToCityStay: (cityStayId) => startLazySceneFromGame( + game, + 'CityStayScene', + cityStayId ? { cityStayId } : undefined + ), forceBattleOutcome: (outcome) => { battleScene()?.debugForceBattleOutcome?.(outcome); },