diff --git a/docs/visual-asset-prologue-exploration-report.md b/docs/visual-asset-prologue-exploration-report.md index c8712ac..b989c14 100644 --- a/docs/visual-asset-prologue-exploration-report.md +++ b/docs/visual-asset-prologue-exploration-report.md @@ -10,6 +10,7 @@ character artwork is included in the runtime files. | --- | --- | --- | | Zhuo village and militia camp | `src/assets/images/exploration/prologue-village.webp`, `prologue-militia-camp.webp` | 1920×1080 opaque WebP | | Exploration SD characters | `src/assets/images/exploration/characters/exploration-*.webp` | 3072×768 transparent WebP sprite sheets | +| First-victory camp Jian Yong | `src/assets/images/exploration/characters/exploration-jian-yong.webp` | 3072×768 transparent WebP sprite sheet | | Prologue NPC dialogue portraits | `src/assets/images/portraits/zhuo-*-yellow-turban.webp`, `zou-jing-yellow-turban.webp` | 1254×1254 WebP | The two backgrounds reserve the exact top, right, and bottom HUD regions used @@ -69,6 +70,20 @@ infantry archetype. The west view is a mirrored side view, and restrained idle/walk motion frames were assembled from the turnarounds for consistent in-game scale. +The first-victory camp visit adds Jian Yong with the same built-in image +generation workflow. Its final request was: + +> Original 2.5-head SD Jian Yong turnaround for a historical desktop RPG, +> exactly three equal front, right, and back views. Preserve the identity and +> practical early-campaign scholar-officer costume from the supplied project +> portrait, match the project's painterly cel-shaded exploration style, show +> the complete body and feet, and use a flat magenta removable background. No +> text, logo, watermark, extra character, or copyrighted likeness. + +The generated turnaround was chroma-cleaned and assembled by +`scripts/build-exploration-character-sheet.py`; only the optimized transparent +runtime sheet is shipped. + ## Optimization and verification - Backgrounds are limited to 2 MiB each and 4 MiB combined. diff --git a/package.json b/package.json index 45b381f..7bfa876 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "verify:first-battle-camp-followup": "node scripts/verify-first-battle-camp-followup.mjs", "verify:first-battle-camaraderie": "node scripts/verify-first-battle-camaraderie-memory.mjs", "verify:first-pursuit-scout": "node scripts/verify-first-pursuit-scout-memory.mjs", + "verify:first-pursuit-camp:browser": "node scripts/verify-first-pursuit-camp-exploration-browser.mjs", "verify:prologue-exploration-assets": "node scripts/verify-prologue-exploration-asset-data.mjs", "verify:exploration-characters": "node scripts/verify-exploration-character-asset-data.mjs", "verify:prologue-dialogue-portraits": "node scripts/verify-prologue-dialogue-portrait-data.mjs", @@ -69,7 +70,7 @@ "verify:interaction-ux": "node scripts/verify-interaction-ux.mjs", "verify:save-flow": "node scripts/verify-save-retry-flow.mjs", "verify:release": "node scripts/verify-release-candidate.mjs", - "verify:local-release": "pnpm run verify:static-data && pnpm run verify:unit-action-assets && pnpm run verify:enemy-intent && tsc --noEmit && pnpm run build && pnpm run verify:unit-action-assets:dist && pnpm run verify:loader-lifecycle && pnpm run verify:interaction-ux && pnpm run verify:city-stays:browser && pnpm run verify:prologue-village:browser && pnpm run verify:av-feedback:browser && pnpm run verify:release && pnpm run verify:performance", + "verify:local-release": "pnpm run verify:static-data && pnpm run verify:unit-action-assets && pnpm run verify:enemy-intent && tsc --noEmit && pnpm run build && pnpm run verify:unit-action-assets:dist && pnpm run verify:loader-lifecycle && pnpm run verify:interaction-ux && pnpm run verify:city-stays:browser && pnpm run verify:prologue-village:browser && pnpm run verify:first-pursuit-camp:browser && pnpm run verify:av-feedback:browser && pnpm run verify:release && pnpm run verify:performance", "verify:1.0": "pnpm run verify:local-release && pnpm run verify:flow && pnpm run qa:campaign-complete && pnpm run qa:battle-maps:webgl", "verify:public-deploy": "node scripts/verify-public-deploy.mjs", "qa:representative": "node scripts/qa-representative-battles.mjs", diff --git a/scripts/build-exploration-character-sheet.py b/scripts/build-exploration-character-sheet.py new file mode 100644 index 0000000..15c17a5 --- /dev/null +++ b/scripts/build-exploration-character-sheet.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Build a four-direction exploration sprite sheet from a three-view turnaround. + +The input is expected to contain transparent front, right, and back views in +three equal horizontal columns. The output matches the runtime exploration +contract: 192px frames, four rows (south/east/north/west), and sixteen frames +per row (eight idle followed by eight walk frames). +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from PIL import Image + + +FRAME_SIZE = 192 +IDLE_FRAME_COUNT = 8 +WALK_FRAME_COUNT = 8 +FRAMES_PER_DIRECTION = IDLE_FRAME_COUNT + WALK_FRAME_COUNT +DIRECTIONS = ("south", "east", "north", "west") + +IDLE_OFFSETS = ( + (0, 0), + (0, -1), + (0, -2), + (0, -1), + (0, 0), + (0, 1), + (0, 0), + (0, -1), +) +WALK_OFFSETS = ( + (-2, 0), + (-1, -2), + (0, -3), + (1, -1), + (2, 0), + (1, -2), + (0, -3), + (-1, -1), +) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Build a 3072x768 exploration SD character sheet." + ) + parser.add_argument("--input", required=True, type=Path) + parser.add_argument("--output", required=True, type=Path) + parser.add_argument( + "--max-height", + type=int, + default=184, + help="Maximum character height inside each 192px frame.", + ) + parser.add_argument( + "--max-width", + type=int, + default=172, + help="Maximum character width inside each 192px frame.", + ) + return parser.parse_args() + + +def split_turnaround(source: Image.Image) -> tuple[Image.Image, Image.Image, Image.Image]: + views: list[Image.Image] = [] + for index in range(3): + left = round(source.width * index / 3) + right = round(source.width * (index + 1) / 3) + view = source.crop((left, 0, right, source.height)) + alpha = view.getchannel("A") + bbox = alpha.getbbox() + if not bbox: + raise ValueError(f"Turnaround column {index + 1} contains no visible pixels.") + views.append(view.crop(bbox)) + return views[0], views[1], views[2] + + +def resize_views( + views: tuple[Image.Image, Image.Image, Image.Image], + max_width: int, + max_height: int, +) -> tuple[Image.Image, Image.Image, Image.Image]: + widest = max(view.width for view in views) + tallest = max(view.height for view in views) + scale = min(max_width / widest, max_height / tallest) + if scale <= 0: + raise ValueError("The requested sprite bounds must be positive.") + return tuple( + view.resize( + (max(1, round(view.width * scale)), max(1, round(view.height * scale))), + Image.Resampling.LANCZOS, + ) + for view in views + ) + + +def place_frame(view: Image.Image, offset: tuple[int, int]) -> Image.Image: + frame = Image.new("RGBA", (FRAME_SIZE, FRAME_SIZE), (0, 0, 0, 0)) + x = (FRAME_SIZE - view.width) // 2 + offset[0] + y = FRAME_SIZE - 4 - view.height + offset[1] + frame.alpha_composite(view, (x, y)) + return frame + + +def build_sheet(source: Image.Image, max_width: int, max_height: int) -> Image.Image: + front, right, back = resize_views( + split_turnaround(source), + max_width=max_width, + max_height=max_height, + ) + directional_views = { + "south": front, + "east": right, + "north": back, + "west": right.transpose(Image.Transpose.FLIP_LEFT_RIGHT), + } + sheet = Image.new( + "RGBA", + (FRAME_SIZE * FRAMES_PER_DIRECTION, FRAME_SIZE * len(DIRECTIONS)), + (0, 0, 0, 0), + ) + for row, direction in enumerate(DIRECTIONS): + view = directional_views[direction] + offsets = (*IDLE_OFFSETS, *WALK_OFFSETS) + for column, offset in enumerate(offsets): + sheet.alpha_composite( + place_frame(view, offset), + (column * FRAME_SIZE, row * FRAME_SIZE), + ) + return sheet + + +def main() -> None: + args = parse_args() + source = Image.open(args.input).convert("RGBA") + if source.getchannel("A").getextrema()[0] != 0: + raise ValueError("The turnaround must contain transparent background pixels.") + sheet = build_sheet(source, max_width=args.max_width, max_height=args.max_height) + args.output.parent.mkdir(parents=True, exist_ok=True) + sheet.save(args.output, "WEBP", lossless=True, method=6) + print( + f"Wrote {args.output} " + f"({sheet.width}x{sheet.height}, {FRAMES_PER_DIRECTION} frames x {len(DIRECTIONS)} rows)." + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/measure-performance.mjs b/scripts/measure-performance.mjs index 22626dd..ac0f723 100644 --- a/scripts/measure-performance.mjs +++ b/scripts/measure-performance.mjs @@ -321,19 +321,34 @@ async function waitForStoryReady(page) { } async function advanceStoryUntilBattle(page, battleId) { - for (let i = 0; i < 48; i += 1) { + const deadline = Date.now() + 120000; + while (Date.now() < deadline) { const state = await page.evaluate((expectedBattleId) => { try { + const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; const battle = window.__HEROS_DEBUG__?.battle(); - const story = window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.(); + const storyScene = window.__HEROS_DEBUG__?.scene('StoryScene'); + const story = storyScene?.getDebugState?.(); + const village = window.__HEROS_DEBUG__?.village?.(); + const militiaCamp = window.__HEROS_DEBUG__?.militiaCamp?.(); return { - battleReady: battle?.scene === 'BattleScene' && battle?.battleId === expectedBattleId, + battleReady: ( + activeScenes.includes('BattleScene') && + battle?.scene === 'BattleScene' && + battle?.battleId === expectedBattleId + ), + storyActive: activeScenes.includes('StoryScene'), storyReady: story?.ready === true, + storyTransitioning: storyScene?.transitioning === true, storyLastPage: story?.isLastPage === true, storyTargetsBattle: story?.nextScene === 'BattleScene', currentPageReady: story?.assetStreaming?.currentPageReady === true, - villageReady: window.__HEROS_DEBUG__?.village?.()?.ready === true, - militiaCampReady: window.__HEROS_DEBUG__?.militiaCamp?.()?.ready === true + villageActive: activeScenes.includes('PrologueVillageScene'), + villageReady: village?.ready === true, + villageNavigationPending: village?.navigationPending === true, + militiaCampActive: activeScenes.includes('PrologueMilitiaCampScene'), + militiaCampReady: militiaCamp?.ready === true, + militiaCampNavigationPending: militiaCamp?.navigationPending === true }; } catch { return {}; @@ -342,15 +357,37 @@ async function advanceStoryUntilBattle(page, battleId) { if (state.battleReady) { throw new Error('Battle became ready before the transition start could be measured.'); } - if (state.storyReady && state.storyLastPage && state.storyTargetsBattle && state.currentPageReady) { - await page.waitForTimeout(600); + if ( + state.storyActive && + state.storyReady && + !state.storyTransitioning && + state.storyLastPage && + state.storyTargetsBattle && + state.currentPageReady + ) { const entries = await resourceEntries(page); const startedAt = Date.now(); await page.keyboard.press('Space'); + await page.waitForFunction((expectedBattleId) => { + const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; + const battle = window.__HEROS_DEBUG__?.battle(); + const storyScene = window.__HEROS_DEBUG__?.scene('StoryScene'); + return ( + ( + activeScenes.includes('BattleScene') && + battle?.scene === 'BattleScene' && + battle?.battleId === expectedBattleId + ) || + ( + activeScenes.includes('StoryScene') && + storyScene?.transitioning === true + ) + ); + }, battleId, { timeout: 5000 }); return { startedAt, resourceEntries: entries }; } - if (state.villageReady) { + if (state.villageActive && state.villageReady && !state.villageNavigationPending) { const advanced = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('PrologueVillageScene'); return scene?.debugCompleteVillage?.() ?? false; @@ -358,11 +395,15 @@ async function advanceStoryUntilBattle(page, battleId) { if (!advanced) { throw new Error('Playable prologue village was ready but could not advance to the brotherhood story.'); } - await page.waitForTimeout(400); + await page.waitForFunction(() => { + const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; + const village = window.__HEROS_DEBUG__?.village?.(); + return !activeScenes.includes('PrologueVillageScene') || village?.navigationPending === true; + }, undefined, { timeout: 5000 }); continue; } - if (state.militiaCampReady) { + if (state.militiaCampActive && state.militiaCampReady && !state.militiaCampNavigationPending) { const advanced = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('PrologueMilitiaCampScene'); return scene?.debugCompleteCamp?.() ?? false; @@ -370,12 +411,18 @@ async function advanceStoryUntilBattle(page, battleId) { if (!advanced) { throw new Error('Playable prologue militia camp was ready but could not advance to the departure story.'); } - await page.waitForTimeout(400); + await page.waitForFunction(() => { + const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; + const militiaCamp = window.__HEROS_DEBUG__?.militiaCamp?.(); + return !activeScenes.includes('PrologueMilitiaCampScene') || militiaCamp?.navigationPending === true; + }, undefined, { timeout: 5000 }); continue; } - await page.keyboard.press('Space'); - await page.waitForTimeout(220); + if (state.storyActive && state.storyReady && !state.storyTransitioning && state.currentPageReady) { + await page.keyboard.press('Space'); + } + await page.waitForTimeout(100); } throw new Error(`Story did not reach the battle transition for ${battleId}.`); } diff --git a/scripts/verify-camp-reward-data.mjs b/scripts/verify-camp-reward-data.mjs index eed011b..7a13ebb 100644 --- a/scripts/verify-camp-reward-data.mjs +++ b/scripts/verify-camp-reward-data.mjs @@ -13,6 +13,9 @@ const server = await createServer({ try { const { battleScenarios, defaultBattleScenario } = await server.ssrLoadModule('/src/game/data/battles.ts'); + const { firstPursuitScoutVisitDefinition } = await server.ssrLoadModule( + '/src/game/data/firstPursuitScoutVisit.ts' + ); const scenarioData = await server.ssrLoadModule('/src/game/data/scenario.ts'); const { isCampaignStep } = await server.ssrLoadModule('/src/game/state/campaignState.ts'); const itemRewardArrays = collectItemRewardArrays(source); @@ -22,7 +25,12 @@ try { validateCampBattleIdEntries(campBattleIdEntries, battleScenarios); const knownBondsById = collectKnownBondsById(battleScenarios, scenarioData); const dialogueEvents = collectCampEvents(source, 'campDialogues'); - const visitEvents = collectCampEvents(source, 'campVisits'); + const visitEvents = collectCampEvents(source, 'campVisits').map((event) => + materializeCanonicalFirstPursuitVisit( + event, + firstPursuitScoutVisitDefinition + ) + ); const campaignStepReferences = validateCampEventCollection(dialogueEvents, 'campDialogues', campBattleIdKeys, isCampaignStep) + validateCampEventCollection(visitEvents, 'campVisits', campBattleIdKeys, isCampaignStep); @@ -324,6 +332,29 @@ function collectCampEvents(text, arrayName) { })); } +function materializeCanonicalFirstPursuitVisit(event, definition) { + if (!event.body.includes('...firstPursuitScoutVisitDefinition')) { + return event; + } + + const quote = (value) => `'${String(value).replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`; + const choiceSource = definition.choices + .map( + (choice) => + `{ id: ${quote(choice.id)}, bondExp: ${choice.bondExp}, ` + + `itemRewards: [${choice.itemRewards.map(quote).join(', ')}] }` + ) + .join(', '); + const canonicalSource = + `{ id: ${quote(definition.id)}, ` + + 'availableAfterBattleIds: [campBattleIds.first], ' + + `bondId: ${quote(definition.bondId)}, choices: [${choiceSource}], `; + return { + ...event, + body: canonicalSource + event.body.slice(1) + }; +} + function validateCampEventCollection(events, collectionName, campBattleIdKeys, isCampaignStep) { const seenIds = new Map(); let campaignStepReferenceCount = 0; diff --git a/scripts/verify-exploration-character-asset-data.mjs b/scripts/verify-exploration-character-asset-data.mjs index 1c10aff..9699455 100644 --- a/scripts/verify-exploration-character-asset-data.mjs +++ b/scripts/verify-exploration-character-asset-data.mjs @@ -12,6 +12,7 @@ const characterDirectory = join('src', 'assets', 'images', 'exploration', 'chara const modulePath = join('src', 'game', 'data', 'explorationCharacterAssets.ts'); const villageScenePath = join('src', 'game', 'scenes', 'PrologueVillageScene.ts'); const militiaCampScenePath = join('src', 'game', 'scenes', 'PrologueMilitiaCampScene.ts'); +const campVisitScenePath = join('src', 'game', 'scenes', 'CampVisitExplorationScene.ts'); const expectedBaseMappings = { 'unit-liu-bei': 'exploration-liu-bei', 'unit-guan-yu': 'exploration-guan-yu', @@ -23,7 +24,8 @@ const expectedNamedTextureFallbacks = { 'exploration-zhuo-recruiting-clerk': 'unit-shu-officer', 'exploration-zhuo-villager': 'unit-shu-infantry', 'exploration-zhuo-quartermaster': 'unit-shu-officer', - 'exploration-zou-jing': 'unit-shu-officer' + 'exploration-zou-jing': 'unit-shu-officer', + 'exploration-jian-yong': 'unit-shu-officer' }; const expectedSceneNpcTextureKeys = [ { @@ -94,7 +96,7 @@ assert.deepEqual( assert.deepEqual( readStringMap(moduleSource, 'explorationCharacterNamedTextureKeyFallbacks'), expectedNamedTextureFallbacks, - 'Exploration character named-sheet fallbacks must exactly match the four dedicated NPC sheets.' + 'Exploration character named-sheet fallbacks must exactly match the dedicated NPC sheets.' ); expectedSceneNpcTextureKeys.forEach(({ scenePath, sceneLabel, mappingName, mappings }) => { @@ -106,6 +108,19 @@ expectedSceneNpcTextureKeys.forEach(({ scenePath, sceneLabel, mappingName, mappi ); }); +assert(existsSync(campVisitScenePath), `Missing first-camp visit exploration scene: ${campVisitScenePath}`); +const campVisitSceneSource = readFileSync(campVisitScenePath, 'utf8'); +assert.match( + campVisitSceneSource, + /id:\s*['"]jian-yong['"][\s\S]*?textureKey:\s*['"]exploration-jian-yong['"]/, + 'The first-camp visit scene must render Jian Yong with his dedicated exploration sheet.' +); +assert.match( + campVisitSceneSource, + /initialPosition:\s*\{\s*x:\s*definition\.x,\s*y:\s*definition\.y\s*\}/, + 'The first-camp visit scene must retain each NPC initial position for no-teleport verification.' +); + assert( existsSync(characterDirectory), `Missing exploration character asset directory: ${characterDirectory}` @@ -116,7 +131,7 @@ const deployedFiles = readdirSync(characterDirectory) assert.deepEqual( deployedFiles, expectedFiles, - `Expected exactly the nine mapped exploration character sheets in ${characterDirectory}.` + `Expected exactly the ${expectedFiles.length} mapped exploration character sheets in ${characterDirectory}.` ); deployedFiles.forEach((fileName) => { diff --git a/scripts/verify-first-pursuit-camp-exploration-browser.mjs b/scripts/verify-first-pursuit-camp-exploration-browser.mjs new file mode 100644 index 0000000..ec8703d --- /dev/null +++ b/scripts/verify-first-pursuit-camp-exploration-browser.mjs @@ -0,0 +1,1159 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdirSync } from 'node:fs'; +import { chromium } from 'playwright'; +import { + desktopBrowserContextOptions, + desktopBrowserDeviceScaleFactor, + desktopBrowserViewport +} from './desktop-browser-viewport.mjs'; + +const sceneKey = 'CampVisitExplorationScene'; +const sourceBattleId = 'first-battle-zhuo-commandery'; +const visitId = 'first-pursuit-scout-tent'; +const choiceId = 'trace-river-ambush'; +const choiceLabel = '강가 매복 흔적을 쫓는다'; +const rewardItemLabel = '상처약'; +const rewardItemText = '상처약 1'; +const bondId = 'liu-bei__jian-yong'; +const expectedActors = { + 'jian-yong': { + x: 510, + y: 492, + textureKey: 'exploration-jian-yong' + }, + 'guan-yu': { + x: 1015, + y: 565, + textureKey: 'exploration-guan-yu' + }, + 'zhang-fei': { + x: 1270, + y: 595, + textureKey: 'exploration-zhang-fei' + } +}; +const targetUrl = withDebugOptions( + process.env.VERIFY_FIRST_PURSUIT_CAMP_EXPLORATION_URL ?? + 'http://127.0.0.1:41798/' +); + +let serverProcess; +let browser; + +try { + mkdirSync('dist', { recursive: true }); + serverProcess = await ensureLocalServer(targetUrl); + browser = await chromium.launch({ + headless: + process.env.VERIFY_FIRST_PURSUIT_CAMP_EXPLORATION_HEADLESS !== '0' + }); + const context = await browser.newContext(desktopBrowserContextOptions); + const page = await context.newPage(); + page.setDefaultTimeout(30000); + + const pageErrors = []; + const consoleErrors = []; + page.on('pageerror', (error) => + pageErrors.push(error.stack ?? error.message) + ); + page.on('console', (message) => { + if (message.type() === 'error') { + consoleErrors.push(message.text()); + } + }); + + await page.goto(targetUrl, { + waitUntil: 'domcontentloaded', + timeout: 90000 + }); + await page.evaluate(() => window.localStorage.clear()); + await page.reload({ + waitUntil: 'domcontentloaded', + timeout: 90000 + }); + await waitForDebugApi(page); + await assertDesktopViewport(page); + + const seeded = await seedFirstVictoryCamp(page); + assert.equal(seeded.step, 'first-camp'); + assert.equal(seeded.latestBattleId, sourceBattleId); + assert.equal(seeded.reportBattleId, sourceBattleId); + assert.equal(seeded.reportOutcome, 'victory'); + assert.equal(seeded.settlementOutcome, 'victory'); + assert.equal(seeded.pendingAftermathBattleId, null); + assert.equal(seeded.completed, false); + assert.equal(seeded.choiceId, null); + assert.equal(seeded.rewardItemAmount, 0); + assert( + seeded.bond, + `The first-victory seed must include ${bondId}: ${JSON.stringify(seeded)}` + ); + + await page.evaluate(async () => { + await window.__HEROS_DEBUG__.goToCampVisitExploration(); + }); + await waitForExplorationReady(page); + await page.waitForTimeout(380); + + let exploration = await readExploration(page); + assert.equal(exploration.scene, sceneKey); + assert.equal(exploration.locationId, visitId); + assert.equal(exploration.campaignStep, 'first-camp'); + assert.deepEqual(exploration.viewport, desktopBrowserViewport); + assert.equal(exploration.background.key, 'first-pursuit-camp-background'); + assert.equal(exploration.background.ready, true); + assert.equal(exploration.background.fallback, false); + assert.equal( + exploration.background.sourceWidth, + desktopBrowserViewport.width + ); + assert.equal( + exploration.background.sourceHeight, + desktopBrowserViewport.height + ); + assert.deepEqual(exploration.background.bounds, { + x: 0, + y: 0, + width: desktopBrowserViewport.width, + height: desktopBrowserViewport.height + }); + assert.equal(exploration.requiredTexturesReady, true); + assert.deepEqual( + exploration.requiredTextures, + [ + { key: 'exploration-liu-bei', ready: true }, + { key: 'exploration-jian-yong', ready: true }, + { key: 'exploration-guan-yu', ready: true }, + { key: 'exploration-zhang-fei', ready: true } + ] + ); + assert.equal(exploration.player.textureKey, 'exploration-liu-bei'); + assert.deepEqual( + { + x: exploration.player.x, + y: exploration.player.y + }, + { x: 780, y: 720 } + ); + assert.equal(exploration.movement.speed, 300); + assert.equal(exploration.interaction.radius, 122); + assert.equal(exploration.interaction.promptRadius, 164); + assert.equal(exploration.visit.completed, false); + assert.equal(exploration.visit.choiceId, null); + assertActorsFixed(exploration, 'initial camp'); + assertBoundsInsideViewport( + exploration.background.bounds, + 'exploration background' + ); + assertBoundsInsideViewport(exploration.player.bounds, 'initial Liu Bei'); + exploration.actors.forEach((actor) => + assertBoundsInsideViewport(actor.bounds, `initial ${actor.name}`) + ); + + const jianYongSource = await readTextureSource( + page, + 'exploration-jian-yong' + ); + assert.equal(jianYongSource.exists, true); + assert.match( + decodeURIComponent(jianYongSource.url), + /\/exploration-jian-yong\.webp(?:$|[?#])/, + `Jian Yong must use his dedicated SD sheet instead of a generic fallback: ${JSON.stringify(jianYongSource)}` + ); + assert.equal(jianYongSource.width, 3072); + assert.equal(jianYongSource.height, 768); + + await captureStableScreenshot( + page, + 'dist/verification-first-pursuit-camp-exploration-initial.png' + ); + + const beforeKeyboardMove = exploration.player; + await page.keyboard.down('ArrowLeft'); + try { + await page.waitForFunction( + ({ key, startX }) => + window.__HEROS_DEBUG__ + ?.scene(key) + ?.getDebugState?.()?.player?.x <= startX - 65, + { key: sceneKey, startX: beforeKeyboardMove.x }, + { timeout: 3000 } + ); + } finally { + await page.keyboard.up('ArrowLeft'); + } + await page.waitForTimeout(100); + exploration = await readExploration(page); + assert( + beforeKeyboardMove.x - exploration.player.x >= 65, + `Expected real keyboard movement to move Liu Bei left: ${JSON.stringify({ + before: beforeKeyboardMove, + after: exploration.player + })}` + ); + assert( + Math.abs(beforeKeyboardMove.y - exploration.player.y) <= 2, + `Horizontal keyboard movement must retain Y: ${JSON.stringify({ + before: beforeKeyboardMove, + after: exploration.player + })}` + ); + assert.equal(exploration.player.moving, false); + assertActorsFixed(exploration, 'after keyboard movement'); + assert( + nearestActorDistance(exploration) > exploration.interaction.radius && + exploration.exit.distance > exploration.interaction.radius, + `The distant-interaction probe must start outside every interaction radius: ${JSON.stringify({ + player: exploration.player, + actors: exploration.actors.map(({ id, distance }) => ({ + id, + distance + })), + exit: exploration.exit + })}` + ); + + const savesBeforeDistantInteraction = await readCampaignSaves(page); + await page.keyboard.press('e'); + await page.waitForTimeout(150); + exploration = await readExploration(page); + assert.equal( + exploration.dialogue.active, + false, + 'Pressing E outside the interaction radius must not open dialogue.' + ); + assert.equal(exploration.choice.open, false); + assert.equal(exploration.visit.completed, false); + assert.equal(exploration.lastNotice, '조금 더 가까이 다가가세요.'); + assertRelevantProgressEqual( + savesBeforeDistantInteraction, + await readCampaignSaves(page), + 'distant E interaction' + ); + + const jianYong = actorById(exploration, 'jian-yong'); + const clickStart = { ...exploration.player }; + await clickScenePoint(page, jianYong.x, jianYong.y); + await page.waitForFunction( + ({ key, actorId, startX, startY }) => { + const state = window.__HEROS_DEBUG__ + ?.scene(key) + ?.getDebugState?.(); + return ( + state?.movement?.targetId === actorId && + Math.hypot( + state.player.x - startX, + state.player.y - startY + ) >= 10 + ); + }, + { + key: sceneKey, + actorId: 'jian-yong', + startX: clickStart.x, + startY: clickStart.y + }, + { timeout: 5000 } + ); + await page.waitForFunction( + ({ key, actorId }) => { + const state = window.__HEROS_DEBUG__ + ?.scene(key) + ?.getDebugState?.(); + return ( + state?.movement?.target === null && + state?.movement?.lastTargetId === actorId && + state?.interaction?.targetId === actorId && + state?.interaction?.canInteract === true + ); + }, + { key: sceneKey, actorId: 'jian-yong' }, + { timeout: 12000 } + ); + exploration = await readExploration(page); + assert( + actorById(exploration, 'jian-yong').distance <= + exploration.interaction.radius, + `Click movement must stop inside Jian Yong's interaction radius: ${JSON.stringify(exploration.interaction)}` + ); + assertActorsFixed(exploration, 'after click navigation'); + + await page.keyboard.press('e'); + await page.waitForFunction( + (key) => { + const state = window.__HEROS_DEBUG__ + ?.scene(key) + ?.getDebugState?.(); + return ( + state?.dialogue?.active === true && + state.dialogue.sourceNpcId === 'jian-yong' + ); + }, + sceneKey + ); + exploration = await readExploration(page); + assert.equal(exploration.dialogue.speaker, '간옹'); + assert.equal(exploration.dialogue.portraitVisible, true); + assert.equal(exploration.dialogue.portraitFrameVisible, true); + assert.equal( + exploration.dialogue.portraitTextureKey, + 'portrait-jian-yong-campaign' + ); + assert.equal(exploration.dialogue.totalLines, 2); + assertBoundsInsideViewport( + exploration.dialogue.bounds, + 'Jian Yong dialogue' + ); + await captureStableScreenshot( + page, + 'dist/verification-first-pursuit-camp-exploration-dialogue.png' + ); + + await advanceDialogueUntilChoice(page); + await page.waitForFunction( + (key) => + window.__HEROS_DEBUG__ + ?.scene(key) + ?.getDebugState?.()?.choice?.ready === true, + sceneKey + ); + exploration = await readExploration(page); + assert.equal(exploration.dialogue.active, false); + assert.equal(exploration.choice.open, true); + assert.deepEqual( + exploration.choice.choices.map( + ({ id, label, rewardText, interactive }) => ({ + id, + label, + rewardText, + interactive + }) + ), + [ + { + id: choiceId, + label: choiceLabel, + rewardText: `공명 +10 · ${rewardItemText}`, + interactive: true + }, + { + id: 'mark-village-relief', + label: '마을 구호 길을 표시한다', + rewardText: '공명 +10 · 콩 1', + interactive: true + } + ] + ); + assertBoundsInsideViewport( + exploration.choice.panelBounds, + 'scout choice panel' + ); + exploration.choice.choices.forEach((choice) => + assertBoundsInside( + choice.bounds, + exploration.choice.panelBounds, + `choice ${choice.id}` + ) + ); + assertNoOverlappingBounds( + exploration.choice.choices.map(({ id, bounds }) => ({ + id, + bounds + })), + 'scout choices' + ); + await captureStableScreenshot( + page, + 'dist/verification-first-pursuit-camp-exploration-choice.png' + ); + + const savesBeforeChoice = await readCampaignSaves(page); + const bondBeforeChoice = campaignBond( + savesBeforeChoice.current, + bondId + ); + assert( + bondBeforeChoice, + `Missing ${bondId} before the scout choice: ${JSON.stringify(savesBeforeChoice.current?.bonds)}` + ); + const selectedChoice = exploration.choice.choices.find( + (choice) => choice.id === choiceId + ); + assert(selectedChoice, `Missing choice ${choiceId}.`); + await clickSceneBounds(page, selectedChoice.bounds); + await page.waitForFunction( + ({ key, expectedVisitId, expectedChoiceId }) => { + const state = window.__HEROS_DEBUG__ + ?.scene(key) + ?.getDebugState?.(); + return ( + state?.visit?.completed === true && + state.visit.id === expectedVisitId && + state.visit.choiceId === expectedChoiceId && + state.dialogue.active === true + ); + }, + { + key: sceneKey, + expectedVisitId: visitId, + expectedChoiceId: choiceId + } + ); + + exploration = await readExploration(page); + assert.equal(exploration.visit.choiceLabel, choiceLabel); + assert.deepEqual(exploration.visit.itemRewards, [rewardItemText]); + assert.equal(exploration.visit.bondExp, 10); + assert.equal(exploration.dialogue.speaker, '간옹'); + assert.equal(exploration.dialogue.portraitVisible, true); + assert.equal( + exploration.dialogue.portraitTextureKey, + 'portrait-jian-yong-campaign' + ); + assert.equal(actorById(exploration, 'jian-yong').completed, true); + assertActorsFixed(exploration, 'after scout completion'); + + const savesAfterChoice = await readCampaignSaves(page); + assertScoutRewardSaved( + savesBeforeChoice, + savesAfterChoice, + bondBeforeChoice + ); + + await advanceDialogueUntilClosed(page); + exploration = await readExploration(page); + assert.equal(exploration.dialogue.active, false); + assert.equal(exploration.choice.open, false); + assert.equal(exploration.visit.completed, true); + assertActorsFixed(exploration, 'completed camp'); + await captureStableScreenshot( + page, + 'dist/verification-first-pursuit-camp-exploration-completed.png' + ); + + const duplicateDebugResult = await page.evaluate( + ({ key, expectedChoiceId }) => + window.__HEROS_DEBUG__ + ?.scene(key) + ?.debugChooseScout?.(expectedChoiceId) ?? null, + { key: sceneKey, expectedChoiceId: choiceId } + ); + assert.equal( + duplicateDebugResult, + false, + 'The scene debug choice hook must reject a completed visit.' + ); + + await page.keyboard.press('e'); + await page.waitForFunction( + (key) => + window.__HEROS_DEBUG__ + ?.scene(key) + ?.getDebugState?.()?.dialogue?.sourceNpcId === 'jian-yong', + sceneKey + ); + await advanceDialogueUntilClosed(page); + exploration = await readExploration(page); + assert.equal( + exploration.choice.open, + false, + 'Revisiting Jian Yong after completion must replay the result without reopening choices.' + ); + assertRelevantProgressEqual( + savesAfterChoice, + await readCampaignSaves(page), + 'completed Jian Yong revisit' + ); + + const exitStart = { ...exploration.player }; + await clickScenePoint(page, exploration.exit.x, exploration.exit.y); + await page.waitForFunction( + ({ key, exitId, startX, startY }) => { + const state = window.__HEROS_DEBUG__ + ?.scene(key) + ?.getDebugState?.(); + return ( + state?.movement?.targetId === exitId && + Math.hypot( + state.player.x - startX, + state.player.y - startY + ) >= 10 + ); + }, + { + key: sceneKey, + exitId: 'camp-exit', + startX: exitStart.x, + startY: exitStart.y + }, + { timeout: 5000 } + ); + await page.waitForFunction( + ({ key, exitId }) => { + const state = window.__HEROS_DEBUG__ + ?.scene(key) + ?.getDebugState?.(); + return ( + state?.movement?.target === null && + state?.movement?.lastTargetId === exitId && + state?.interaction?.targetId === exitId && + state?.interaction?.canInteract === true + ); + }, + { key: sceneKey, exitId: 'camp-exit' }, + { timeout: 12000 } + ); + await page.keyboard.press('e'); + await waitForCampReturn(page); + const camp = await page.evaluate(() => + window.__HEROS_DEBUG__?.camp?.() + ); + assert.equal(camp.campaign.step, 'first-camp'); + assert.equal(camp.campBattleId, sourceBattleId); + assert.equal(camp.firstPursuitScoutMemory.completed, true); + assert.equal(camp.firstPursuitScoutMemory.choiceId, choiceId); + assert.equal( + camp.firstPursuitScoutMemory.activeForNextSortie, + true + ); + assertRelevantProgressEqual( + savesAfterChoice, + await readCampaignSaves(page), + 'CampScene return' + ); + + assert.deepEqual( + pageErrors, + [], + `Expected no browser page errors: ${JSON.stringify(pageErrors)}` + ); + assert.deepEqual( + consoleErrors.filter( + (message) => + !message.includes('The AudioContext was not allowed to start') + ), + [], + `Expected no browser console errors: ${JSON.stringify(consoleErrors)}` + ); + + console.log( + `Verified the first-pursuit camp exploration at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height}, ` + + `DPR ${desktopBrowserDeviceScaleFactor}: dedicated Jian Yong SD art, fixed NPC positions, keyboard and pointer movement, ` + + 'distance-gated interaction, face portraits, two real choices, one-time resonance/item rewards, duplicate protection, ' + + 'CampScene return, and current/slot-1 save persistence.' + ); +} finally { + await browser?.close(); + if (serverProcess && !serverProcess.killed) { + serverProcess.kill(); + } +} + +function withDebugOptions(url) { + const parsed = new URL(url); + parsed.searchParams.set('debug', '1'); + parsed.searchParams.set( + 'renderer', + process.env.VERIFY_FIRST_PURSUIT_CAMP_EXPLORATION_RENDERER ?? + 'canvas' + ); + return parsed.toString(); +} + +async function waitForDebugApi(page) { + await page.waitForFunction( + () => + document.querySelector('canvas') !== null && + window.__HEROS_GAME__ !== undefined && + window.__HEROS_DEBUG__ !== undefined, + undefined, + { timeout: 90000 } + ); +} + +async function seedFirstVictoryCamp(page) { + return page.evaluate( + async ({ battleId, expectedVisitId, expectedBondId, itemLabel }) => { + const campaignModule = await import( + '/heros_web/src/game/state/campaignState.ts' + ); + const { battleScenarios } = await import( + '/heros_web/src/game/data/battles.ts' + ); + const scenario = battleScenarios[battleId]; + if (!scenario) { + throw new Error(`Missing battle scenario ${battleId}.`); + } + + campaignModule.resetCampaignState(); + campaignModule.setFirstBattleReport({ + battleId: scenario.id, + battleTitle: scenario.title, + outcome: 'victory', + turnNumber: 6, + rewardGold: scenario.baseVictoryGold, + defeatedEnemies: scenario.units.filter( + (unit) => unit.faction === 'enemy' + ).length, + totalEnemies: scenario.units.filter( + (unit) => unit.faction === 'enemy' + ).length, + objectives: [], + units: scenario.units, + bonds: scenario.bonds.map((bond) => ({ + ...bond, + battleExp: 0 + })), + itemRewards: [], + completedCampDialogues: [], + completedCampVisits: [], + createdAt: '2026-07-27T00:00:00.000Z' + }); + campaignModule.completeCampaignAftermath(scenario.id); + const campaign = campaignModule.getCampaignState(); + const bond = campaign.bonds.find( + (candidate) => candidate.id === expectedBondId + ); + return { + step: campaign.step, + latestBattleId: campaign.latestBattleId ?? null, + reportBattleId: + campaign.firstBattleReport?.battleId ?? null, + reportOutcome: + campaign.firstBattleReport?.outcome ?? null, + settlementOutcome: + campaign.battleHistory[battleId]?.outcome ?? null, + pendingAftermathBattleId: + campaign.pendingAftermathBattleId ?? null, + completed: + campaign.completedCampVisits.includes(expectedVisitId), + choiceId: + campaign.campVisitChoiceIds[expectedVisitId] ?? null, + rewardItemAmount: campaign.inventory[itemLabel] ?? 0, + bond: bond + ? { + level: bond.level, + exp: bond.exp, + battleExp: bond.battleExp + } + : null + }; + }, + { + battleId: sourceBattleId, + expectedVisitId: visitId, + expectedBondId: bondId, + itemLabel: rewardItemLabel + } + ); +} + +async function waitForExplorationReady(page) { + try { + await page.waitForFunction( + (key) => { + const debug = window.__HEROS_DEBUG__; + const state = debug?.scene(key)?.getDebugState?.(); + return ( + debug?.activeScenes?.().includes(key) && + state?.scene === key && + state?.ready === true + ); + }, + sceneKey, + { timeout: 90000 } + ); + } catch (error) { + const diagnostic = await page.evaluate((key) => ({ + activeScenes: + window.__HEROS_DEBUG__?.activeScenes?.() ?? [], + exploration: + window.__HEROS_DEBUG__?.scene(key)?.getDebugState?.() ?? + null, + camp: window.__HEROS_DEBUG__?.camp?.() ?? null + }), sceneKey); + throw new Error( + `Camp visit exploration did not become ready: ${JSON.stringify(diagnostic)}`, + { cause: error } + ); + } +} + +async function waitForCampReturn(page) { + await page.waitForFunction( + () => { + const debug = window.__HEROS_DEBUG__; + const camp = debug?.camp?.(); + return ( + debug?.activeScenes?.().includes('CampScene') && + !debug?.activeScenes?.().includes('CampVisitExplorationScene') && + camp?.scene === 'CampScene' && + camp?.campaign?.step === 'first-camp' + ); + }, + undefined, + { timeout: 90000 } + ); +} + +async function readExploration(page) { + return page.evaluate((key) => + window.__HEROS_DEBUG__?.scene(key)?.getDebugState?.() + , sceneKey); +} + +async function readTextureSource(page, textureKey) { + return page.evaluate( + ({ key, requestedTextureKey }) => { + const scene = window.__HEROS_DEBUG__?.scene(key); + const texture = scene?.textures?.get(requestedTextureKey); + const source = texture?.source?.[0]; + const image = source?.image; + return { + exists: + Boolean(scene?.textures?.exists(requestedTextureKey)), + url: + image?.currentSrc ?? + image?.src ?? + '', + width: + source?.width ?? + image?.naturalWidth ?? + image?.width ?? + null, + height: + source?.height ?? + image?.naturalHeight ?? + image?.height ?? + null + }; + }, + { key: sceneKey, requestedTextureKey: textureKey } + ); +} + +async function advanceDialogueUntilChoice(page) { + for (let attempt = 0; attempt < 8; attempt += 1) { + const state = await readExploration(page); + if (state?.choice?.open) { + return; + } + if (!state?.dialogue?.active) { + throw new Error( + `Dialogue closed without opening the scout choice: ${JSON.stringify(state)}` + ); + } + await page.keyboard.press('e'); + await page.waitForTimeout(140); + } + throw new Error( + `Scout choice did not open: ${JSON.stringify(await readExploration(page))}` + ); +} + +async function advanceDialogueUntilClosed(page) { + for (let attempt = 0; attempt < 8; attempt += 1) { + const state = await readExploration(page); + if (!state?.dialogue?.active) { + return; + } + await page.keyboard.press('e'); + await page.waitForTimeout(140); + } + throw new Error( + `Camp exploration dialogue did not close: ${JSON.stringify(await readExploration(page))}` + ); +} + +async function clickScenePoint(page, x, y) { + const point = await page.evaluate( + ({ key, sceneX, sceneY }) => { + const scene = window.__HEROS_DEBUG__?.scene(key); + const canvas = document.querySelector('canvas'); + const canvasBounds = canvas?.getBoundingClientRect(); + if (!scene || !canvasBounds) { + return null; + } + return { + x: + canvasBounds.left + + (sceneX * canvasBounds.width) / scene.scale.width, + y: + canvasBounds.top + + (sceneY * canvasBounds.height) / scene.scale.height + }; + }, + { key: sceneKey, sceneX: x, sceneY: y } + ); + assert( + point && Number.isFinite(point.x) && Number.isFinite(point.y), + `Unable to map scene point ${JSON.stringify({ x, y })}.` + ); + await page.mouse.click(point.x, point.y); +} + +async function clickSceneBounds(page, bounds) { + await clickScenePoint( + page, + bounds.x + bounds.width / 2, + bounds.y + bounds.height / 2 + ); +} + +async function readCampaignSaves(page) { + return page.evaluate(() => { + const parse = (key) => { + const raw = window.localStorage.getItem(key); + return raw ? JSON.parse(raw) : null; + }; + return { + current: parse('heros-web:campaign-state'), + slot1: parse('heros-web:campaign-state:slot-1') + }; + }); +} + +function actorById(exploration, actorId) { + const actor = exploration?.actors?.find( + (candidate) => candidate.id === actorId + ); + assert( + actor, + `Missing exploration actor ${actorId}: ${JSON.stringify(exploration?.actors)}` + ); + return actor; +} + +function assertActorsFixed(exploration, label) { + assert.equal( + exploration.actors.length, + Object.keys(expectedActors).length, + `${label}: unexpected actor count.` + ); + Object.entries(expectedActors).forEach( + ([actorId, expected]) => { + const actor = actorById(exploration, actorId); + assert.deepEqual( + { + x: actor.x, + y: actor.y, + initialX: actor.initialX, + initialY: actor.initialY, + moved: actor.moved, + textureKey: actor.textureKey + }, + { + x: expected.x, + y: expected.y, + initialX: expected.x, + initialY: expected.y, + moved: false, + textureKey: expected.textureKey + }, + `${label}: ${actorId} must remain at the authored position.` + ); + } + ); +} + +function nearestActorDistance(exploration) { + return Math.min( + ...exploration.actors.map(({ distance }) => distance) + ); +} + +function campaignBond(campaign, requestedBondId) { + return campaign?.bonds?.find( + (bond) => bond.id === requestedBondId + ); +} + +function bondProgress(bond) { + return bond.level * 100 + bond.exp; +} + +function assertScoutRewardSaved(before, after, bondBefore) { + [after.current, after.slot1].forEach((campaign, index) => { + const saveLabel = index === 0 ? 'current save' : 'slot-1 save'; + assert(campaign, `${saveLabel} is missing.`); + assert.equal(campaign.step, 'first-camp'); + assert.equal(campaign.latestBattleId, sourceBattleId); + assert.equal( + campaign.completedCampVisits.filter( + (candidate) => candidate === visitId + ).length, + 1, + `${saveLabel} must contain one completion flag.` + ); + assert.equal( + campaign.firstBattleReport.completedCampVisits.filter( + (candidate) => candidate === visitId + ).length, + 1, + `${saveLabel} report must contain one completion flag.` + ); + assert.equal( + campaign.campVisitChoiceIds[visitId], + choiceId, + `${saveLabel} must retain the canonical choice.` + ); + assert.equal( + campaign.inventory[rewardItemLabel] ?? + 0, + (before.current?.inventory?.[rewardItemLabel] ?? 0) + 1, + `${saveLabel} must grant exactly one ${rewardItemLabel}.` + ); + const bondAfter = campaignBond(campaign, bondId); + assert( + bondAfter, + `${saveLabel} is missing ${bondId}.` + ); + assert.equal( + bondProgress(bondAfter) - bondProgress(bondBefore), + 10, + `${saveLabel} must grant exactly 10 resonance EXP.` + ); + assert.equal( + bondAfter.battleExp - bondBefore.battleExp, + 10, + `${saveLabel} must retain exactly 10 earned resonance EXP.` + ); + }); +} + +function relevantProgress(saves) { + const project = (campaign) => { + const bond = campaignBond(campaign, bondId); + return { + completedCampVisits: + campaign?.completedCampVisits?.filter( + (candidate) => candidate === visitId + ) ?? [], + reportCompletedCampVisits: + campaign?.firstBattleReport?.completedCampVisits?.filter( + (candidate) => candidate === visitId + ) ?? [], + choiceId: + campaign?.campVisitChoiceIds?.[visitId] ?? null, + rewardItemAmount: + campaign?.inventory?.[rewardItemLabel] ?? 0, + bond: bond + ? { + level: bond.level, + exp: bond.exp, + battleExp: bond.battleExp + } + : null + }; + }; + return { + current: project(saves.current), + slot1: project(saves.slot1) + }; +} + +function assertRelevantProgressEqual(before, after, label) { + assert.deepEqual( + relevantProgress(after), + relevantProgress(before), + `${label} must not change scout completion, choice, reward, or resonance.` + ); +} + +function assertNoOverlappingBounds(entries, label) { + for ( + let leftIndex = 0; + leftIndex < entries.length; + leftIndex += 1 + ) { + 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 + ) - Math.max(left.bounds.x, right.bounds.x); + 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, + `${label} ${left.id} and ${right.id} overlap: ${JSON.stringify({ left, right })}` + ); + } + } +} + +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, + `${label}: expected bounds inside container, received ${JSON.stringify({ bounds, container })}` + ); +} + +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, + clientWidth: canvas.clientWidth, + clientHeight: canvas.clientHeight, + bounds: bounds + ? { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height + } + : null + } + : null + }; + }); + assert.equal(viewport.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 captureStableScreenshot(page, path) { + await page.waitForTimeout(300); + const loopSlept = await page.evaluate(() => { + const loop = window.__HEROS_GAME__?.loop; + if (!loop || typeof loop.sleep !== 'function') { + return false; + } + loop.sleep(); + return true; + }); + await page.waitForTimeout(40); + try { + await page.screenshot({ path, fullPage: true }); + } finally { + if (loopSlept) { + await page.evaluate(() => + window.__HEROS_GAME__?.loop.wake() + ); + await page.waitForTimeout(40); + } + } +} + +async function ensureLocalServer(url) { + if (await canReach(url)) { + return undefined; + } + + const parsed = new URL(url); + if ( + !['localhost', '127.0.0.1', '0.0.0.0'].includes( + parsed.hostname + ) + ) { + throw new Error(`No server responded at ${url}`); + } + + const stderr = []; + const child = spawn( + process.execPath, + [ + 'node_modules/vite/bin/vite.js', + '--host', + '127.0.0.1', + '--port', + parsed.port || '41798', + '--strictPort' + ], + { + cwd: process.cwd(), + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true + } + ); + child.stderr.on('data', (chunk) => + stderr.push(chunk.toString()) + ); + child.stdout.on('data', () => {}); + + for (let attempt = 0; attempt < 120; attempt += 1) { + if (await canReach(url)) { + return child; + } + await delay(250); + } + + child.kill(); + throw new Error( + `Vite server did not start at ${url}\n${stderr.join('')}` + ); +} + +async function canReach(url) { + try { + const response = await fetch(url, { + signal: AbortSignal.timeout(1000) + }); + return response.ok; + } catch { + return false; + } +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/scripts/verify-first-pursuit-scout-memory.mjs b/scripts/verify-first-pursuit-scout-memory.mjs index 3290dcd..ed7dd10 100644 --- a/scripts/verify-first-pursuit-scout-memory.mjs +++ b/scripts/verify-first-pursuit-scout-memory.mjs @@ -2,12 +2,16 @@ import { readFile } from 'node:fs/promises'; import { createServer } from 'vite'; const storage = new Map(); +let rejectStorageWrites = false; globalThis.window = { localStorage: { getItem(key) { return storage.has(key) ? storage.get(key) : null; }, setItem(key, value) { + if (rejectStorageWrites) { + throw new Error('Simulated campaign storage write failure.'); + } storage.set(key, String(value)); }, removeItem(key) { @@ -29,21 +33,35 @@ try { const memoryModule = await server.ssrLoadModule( '/src/game/data/firstPursuitScoutMemory.ts' ); + const visitModule = await server.ssrLoadModule( + '/src/game/data/firstPursuitScoutVisit.ts' + ); const campaignModule = await server.ssrLoadModule( '/src/game/state/campaignState.ts' ); + const actionModule = await server.ssrLoadModule( + '/src/game/state/firstPursuitScoutActions.ts' + ); const { battleScenarios } = await server.ssrLoadModule( '/src/game/data/battles.ts' ); verifyCanonicalIds(memoryModule); + verifyCanonicalVisitDefinition(memoryModule, visitModule); verifyValidChoices(memoryModule, campaignModule, battleScenarios); verifyInvalidMarkers(memoryModule, campaignModule, battleScenarios); verifyTargetIsolation(memoryModule, campaignModule, battleScenarios); - await verifyCampSceneLiteralSync(memoryModule); + verifyGuardedExplorationCompletion( + memoryModule, + visitModule, + campaignModule, + actionModule, + battleScenarios + ); + await verifyCampExplorationWiring(memoryModule, visitModule); console.log( - 'First-pursuit scout memory verification passed (canonical visit data, strict marker validation, target isolation, and CampScene literal sync).' + 'First-pursuit scout verification passed (canonical visit data, guarded exploration completion, strict memory validation, target isolation, and camp-scene wiring).' ); } finally { await server.close(); @@ -68,6 +86,35 @@ function verifyCanonicalIds(memoryModule) { ); } +function verifyCanonicalVisitDefinition(memoryModule, visitModule) { + const definition = visitModule.firstPursuitScoutVisitDefinition; + assert( + definition.id === memoryModule.firstPursuitScoutVisitId && + definition.availableAfterBattleIds.length === 1 && + definition.availableAfterBattleIds[0] === + memoryModule.firstPursuitScoutSourceBattleId, + 'The first-camp exploration definition must use the canonical visit and source-battle ids.' + ); + assert( + JSON.stringify(definition.choices.map(({ id }) => id)) === + JSON.stringify(memoryModule.firstPursuitScoutChoiceIds), + 'The first-camp exploration definition must expose the two canonical choices in order.' + ); + definition.choices.forEach((choice) => { + assert( + choice.label.length > 0 && + choice.response.startsWith('간옹:') && + choice.bondExp > 0 && + choice.itemRewards.length > 0, + `Choice ${choice.id} must include dialogue, bond, and item feedback.` + ); + assert( + visitModule.getFirstPursuitScoutVisitChoice(choice.id) === choice, + `Choice lookup must return the canonical object for ${choice.id}.` + ); + }); +} + function verifyValidChoices(memoryModule, campaignModule, battleScenarios) { const memories = memoryModule.firstPursuitScoutChoiceIds.map((choiceId) => { const campaign = createCompletedScoutCampaign( @@ -224,48 +271,150 @@ function verifyTargetIsolation( }); } -async function verifyCampSceneLiteralSync(memoryModule) { - const source = await readFile( +function verifyGuardedExplorationCompletion( + memoryModule, + visitModule, + campaignModule, + actionModule, + battleScenarios +) { + storage.clear(); + campaignModule.resetCampaignState(); + assert( + actionModule.completeFirstPursuitScoutVisit( + memoryModule.firstPursuitScoutChoiceIds[0] + ).reason === 'invalid-campaign', + 'The exploration action must reject scout rewards before the first victory camp.' + ); + + const scenario = + battleScenarios[memoryModule.firstPursuitScoutSourceBattleId]; + campaignModule.setFirstBattleReport({ + battleId: scenario.id, + battleTitle: scenario.title, + outcome: 'victory', + turnNumber: 6, + rewardGold: scenario.baseVictoryGold, + defeatedEnemies: scenario.units.filter( + (unit) => unit.faction === 'enemy' + ).length, + totalEnemies: scenario.units.filter( + (unit) => unit.faction === 'enemy' + ).length, + objectives: [], + units: scenario.units, + bonds: scenario.bonds.map((bond) => ({ ...bond, battleExp: 0 })), + itemRewards: [], + completedCampDialogues: [], + completedCampVisits: [], + createdAt: '2026-07-27T00:00:00.000Z' + }); + assert( + actionModule.completeFirstPursuitScoutVisit('forged-choice').reason === + 'invalid-choice', + 'The exploration action must reject a forged scout choice.' + ); + + const choice = visitModule.firstPursuitScoutVisitDefinition.choices[0]; + const beforeFailedSave = scoutProgressSnapshot( + campaignModule.getCampaignState(), + memoryModule.firstPursuitScoutVisitId + ); + let failedSave; + rejectStorageWrites = true; + try { + failedSave = actionModule.completeFirstPursuitScoutVisit(choice.id); + } finally { + rejectStorageWrites = false; + } + const afterFailedSave = scoutProgressSnapshot( + campaignModule.getCampaignState(), + memoryModule.firstPursuitScoutVisitId + ); + assert( + failedSave?.ok === false && + failedSave.reason === 'save-unavailable' && + JSON.stringify(afterFailedSave) === JSON.stringify(beforeFailedSave), + `A failed campaign write must restore the in-memory scout state: ${JSON.stringify({ + failedSave, + beforeFailedSave, + afterFailedSave + })}` + ); + + const completed = actionModule.completeFirstPursuitScoutVisit(choice.id); + assert( + completed.ok === true && + completed.choice.id === choice.id && + completed.campaign.completedCampVisits.includes( + memoryModule.firstPursuitScoutVisitId + ) && + completed.campaign.campVisitChoiceIds[ + memoryModule.firstPursuitScoutVisitId + ] === choice.id, + 'A valid exploration choice must atomically save completion and its canonical choice id.' + ); + assert( + choice.itemRewards.every((reward) => { + const match = reward.match(/^(.+?)\s+([+-]?\d+)$/); + return Boolean( + match && + (completed.campaign.inventory[match[1]] ?? 0) >= + Number.parseInt(match[2], 10) + ); + }), + 'A valid exploration choice must save its item reward.' + ); + assert( + actionModule.completeFirstPursuitScoutVisit(choice.id).reason === + 'already-completed', + 'The exploration action must reject duplicate rewards.' + ); +} + +async function verifyCampExplorationWiring(memoryModule, visitModule) { + const campSource = await readFile( new URL('../src/game/scenes/CampScene.ts', import.meta.url), 'utf8' ); - const visitsStart = source.indexOf( - 'const campVisits: CampVisitDefinition[] = [' - ); - const visitStart = source.indexOf( - `id: '${memoryModule.firstPursuitScoutVisitId}'`, - visitsStart - ); - assert( - visitsStart >= 0 && visitStart > visitsStart, - 'CampScene must declare the canonical first-pursuit visit inside campVisits.' + const explorationSource = await readFile( + new URL( + '../src/game/scenes/CampVisitExplorationScene.ts', + import.meta.url + ), + 'utf8' ); - const visitSource = source.slice(visitStart, visitStart + 2600); assert( - /availableAfterBattleIds:\s*\[\s*campBattleIds\.first\s*\]/.test( - visitSource + campSource.includes( + "import { firstPursuitScoutVisitDefinition } from '../data/firstPursuitScoutVisit';" ), - 'The canonical scout visit must be available immediately after the first battle.' + 'CampScene must import the canonical first-pursuit visit definition.' + ); + assert( + campSource.includes("'CampVisitExplorationScene'") && + campSource.includes('정찰막으로 이동'), + 'An incomplete first-pursuit visit must lead to the walkable camp exploration scene.' + ); + assert( + explorationSource.includes( + "completeFirstPursuitScoutVisit(choice.id)" + ) && + explorationSource.includes("textureKey: 'exploration-jian-yong'"), + 'The exploration scene must use the guarded action and Jian Yong dedicated SD sheet.' ); - memoryModule.firstPursuitScoutChoiceIds.forEach((choiceId) => { - assert( - visitSource.includes(`id: '${choiceId}'`), - `CampScene scout visit is missing canonical choice id ${choiceId}.` - ); - }); const campaign = createLiteralSyncCampaign(memoryModule); - memoryModule.firstPursuitScoutChoiceIds.forEach((choiceId) => { + visitModule.firstPursuitScoutVisitDefinition.choices.forEach((choice) => { campaign.campVisitChoiceIds[memoryModule.firstPursuitScoutVisitId] = - choiceId; + choice.id; const memory = memoryModule.resolveFirstPursuitScoutMemory({ campaign, battleId: memoryModule.firstPursuitScoutTargetBattleId }); assert( - visitSource.includes(`label: '${memory.choiceLabel}'`), - `CampScene label for ${choiceId} must match the canonical memory label.` + memory?.choiceLabel === choice.label, + `Visit and battle memory labels must remain synchronized for ${choice.id}.` ); }); } @@ -325,6 +474,25 @@ function createLiteralSyncCampaign(memoryModule) { }; } +function scoutProgressSnapshot(campaign, visitId) { + return { + completedCampVisits: [...campaign.completedCampVisits], + reportCompletedCampVisits: [ + ...(campaign.firstBattleReport?.completedCampVisits ?? []) + ], + campVisitChoiceIds: { ...campaign.campVisitChoiceIds }, + inventory: { ...campaign.inventory }, + bonds: campaign.bonds.map(({ id, level, exp, battleExp }) => ({ + id, + level, + exp, + battleExp + })), + visitCompleted: campaign.completedCampVisits.includes(visitId), + visitChoiceId: campaign.campVisitChoiceIds[visitId] ?? null + }; +} + function assert(condition, message) { if (!condition) { throw new Error(message); diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index b7d7d98..d6abd54 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -2720,7 +2720,7 @@ try { await page.screenshot({ path: `${screenshotDir}/rc-first-camp.png`, fullPage: true }); await assertCanvasPainted(page, 'first camp'); - const firstCampProbe = await page.evaluate(() => { + let firstCampProbe = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); return { state: window.__HEROS_DEBUG__?.camp(), @@ -2762,6 +2762,10 @@ try { firstCampProbe.state?.sortieRoster ?.filter((unit) => ['guan-yu', 'zhang-fei'].includes(unit.id)) .every((unit) => unit.serviceHistory?.recentRecruit !== true) && + firstCampProbe.state?.activeTab === 'visit' && + firstCampProbe.state?.selectedVisitId === firstPursuitScoutVisitId && + firstCampProbe.state?.campTabs?.find((tab) => tab.id === 'visit')?.visualState === 'active' && + firstCampProbe.state?.campTabs?.find((tab) => tab.id === 'visit')?.newBadgeVisible === true && firstCampProbe.state?.campTabs?.filter((tab) => ['supplies', 'equipment'].includes(tab.id)).every((tab) => tab.newBadgeVisible === true), `Expected first camp arrival to expose bounded reward cards, deep links, and NEW badges: ${JSON.stringify(firstArrivalReward)}` ); @@ -2784,6 +2788,18 @@ try { ); await clickLegacyUi(page, 576, 38); await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.activeTab === 'status', undefined, { timeout: 30000 }); + firstCampProbe = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + return { + state: window.__HEROS_DEBUG__?.camp(), + summary: scene?.reportSummary?.(), + texts: textValues(scene?.contentObjects) + }; + + function textValues(objects = []) { + return objects.filter((object) => object?.type === 'Text').map((object) => object.text); + } + }); assertCampSkinState(firstCampProbe.state, 'yellow-turban', 'first camp'); assertCampSoundscapeState(firstCampProbe.state, { musicKey: 'camp-rally', @@ -2889,9 +2905,62 @@ try { firstPursuitScoutVisitId, { timeout: 30000 } ); - const firstPursuitScoutChoicePoint = await readCampVisitChoiceControlPoint( + const firstPursuitScoutExplorationPoint = await readCampVisitExplorationControlPoint( page, + firstPursuitScoutVisitId + ); + await assertBaselineBrowserViewport(page, 'first-camp scout visit exploration CTA'); + await page.screenshot({ path: `${screenshotDir}/rc-first-camp-scout-visit-cta.png`, fullPage: true }); + await assertCanvasPainted(page, 'first-camp scout visit exploration CTA'); + + await page.mouse.click(firstPursuitScoutExplorationPoint.x, firstPursuitScoutExplorationPoint.y); + await page.waitForFunction( + (visitId) => { + const debug = window.__HEROS_DEBUG__; + const exploration = debug?.scene('CampVisitExplorationScene')?.getDebugState?.(); + return ( + debug?.activeScenes?.().includes('CampVisitExplorationScene') && + exploration?.ready === true && + exploration?.locationId === visitId && + exploration?.visit?.completed === false + ); + }, firstPursuitScoutVisitId, + { timeout: 90000 } + ); + const firstPursuitExplorationEntry = await page.evaluate(() => + window.__HEROS_DEBUG__?.scene('CampVisitExplorationScene')?.getDebugState?.() + ); + assert( + firstPursuitExplorationEntry?.viewport?.width === 1920 && + firstPursuitExplorationEntry.viewport.height === 1080 && + firstPursuitExplorationEntry.requiredTexturesReady === true && + firstPursuitExplorationEntry.actors?.find((actor) => actor.id === 'jian-yong')?.moved === false, + `Expected the scout CTA to open the ready FHD camp exploration with Jian Yong fixed in place: ${JSON.stringify(firstPursuitExplorationEntry)}` + ); + + const firstPursuitJianYongInteraction = await page.evaluate(() => + window.__HEROS_DEBUG__?.scene('CampVisitExplorationScene')?.debugInteractWith?.('jian-yong') ?? false + ); + assert( + firstPursuitJianYongInteraction === true, + 'Expected the RC debug interaction to approach Jian Yong and begin the scout conversation.' + ); + await page.waitForFunction( + () => { + const exploration = window.__HEROS_DEBUG__?.scene('CampVisitExplorationScene')?.getDebugState?.(); + return ( + exploration?.dialogue?.active === true && + exploration.dialogue.sourceNpcId === 'jian-yong' && + exploration.dialogue.totalLines === 2 + ); + }, + undefined, + { timeout: 30000 } + ); + await advanceCampVisitExplorationDialogueUntilChoice(page); + const firstPursuitScoutChoicePoint = await readCampVisitExplorationChoiceControlPoint( + page, firstPursuitScoutChoiceId ); await assertBaselineBrowserViewport(page, 'first-camp scout visit choice'); @@ -2899,14 +2968,64 @@ try { await assertCanvasPainted(page, 'first-camp scout visit choice'); await page.mouse.click(firstPursuitScoutChoicePoint.x, firstPursuitScoutChoicePoint.y); + await page.waitForFunction( + ({ visitId, choiceId }) => { + const exploration = window.__HEROS_DEBUG__?.scene('CampVisitExplorationScene')?.getDebugState?.(); + return ( + exploration?.visit?.completed === true && + exploration.visit.id === visitId && + exploration.visit.choiceId === choiceId && + exploration.dialogue?.active === true + ); + }, + { visitId: firstPursuitScoutVisitId, choiceId: firstPursuitScoutChoiceId }, + { timeout: 30000 } + ); + await advanceCampVisitExplorationDialogueUntilClosed(page); + + const firstPursuitCampExit = await page.evaluate(() => + window.__HEROS_DEBUG__?.scene('CampVisitExplorationScene')?.debugInteractWith?.('camp-exit') ?? false + ); + assert(firstPursuitCampExit === true, 'Expected the completed scout visit to return through the camp exit.'); + await page.waitForFunction( + () => { + const debug = window.__HEROS_DEBUG__; + return ( + debug?.activeScenes?.().includes('CampScene') && + !debug.activeScenes().includes('CampVisitExplorationScene') && + debug.camp?.()?.scene === 'CampScene' + ); + }, + undefined, + { timeout: 90000 } + ); + await page.waitForTimeout(250); + if (await page.evaluate(() => window.__HEROS_DEBUG__?.camp?.()?.victoryRewardAcknowledgement?.visible === true)) { + await page.keyboard.press('Escape'); + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.camp?.()?.victoryRewardAcknowledgement?.visible === false, + undefined, + { timeout: 30000 } + ); + } + const returnedFirstCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp?.()); + if ( + returnedFirstCampState?.activeTab !== 'visit' || + returnedFirstCampState?.selectedVisitId !== firstPursuitScoutVisitId + ) { + const returnedFirstCampVisitTabPoint = await readCampTabControlPoint(page, 'visit'); + await page.mouse.click(returnedFirstCampVisitTabPoint.x, returnedFirstCampVisitTabPoint.y); + } await page.waitForFunction( ({ visitId, choiceId }) => { const camp = window.__HEROS_DEBUG__?.camp?.(); return ( - camp?.completedAvailableVisits?.includes(visitId) && - camp?.campaign?.completedCampVisits?.includes(visitId) && - camp?.campaign?.campVisitChoiceIds?.[visitId] === choiceId && - camp?.firstPursuitScoutMemory?.activeForNextSortie === true + camp?.activeTab === 'visit' && + camp.selectedVisitId === visitId && + camp.completedAvailableVisits?.includes(visitId) && + camp.campaign?.completedCampVisits?.includes(visitId) && + camp.campaign?.campVisitChoiceIds?.[visitId] === choiceId && + camp.firstPursuitScoutMemory?.activeForNextSortie === true ); }, { visitId: firstPursuitScoutVisitId, choiceId: firstPursuitScoutChoiceId }, @@ -10985,73 +11104,143 @@ async function readCampTabControlPoint(page, tab) { return probe; } -async function readCampVisitChoiceControlPoint(page, visitId, choiceId) { - const probe = await page.evaluate(({ visitId, choiceId }) => { +async function readCampVisitExplorationControlPoint(page, visitId) { + const probe = await page.evaluate((expectedVisitId) => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); const state = window.__HEROS_DEBUG__?.camp?.(); const canvas = document.querySelector('canvas'); - const visit = scene?.availableCampVisits?.().find((candidate) => candidate.id === visitId); - const choice = visit?.choices?.find((candidate) => candidate.id === choiceId); - const candidates = (scene?.contentObjects ?? []) - .filter((object) => ( - object?.type === 'Text' && - choice?.label && - object.text?.startsWith(choice.label) && - object.active && - object.visible && - object.input?.enabled - )) - .sort((left, right) => left.y - right.y || left.x - right.x); - const target = candidates[0]; + const bounds = state?.firstPursuitScoutMemory?.explorationButtonBounds; if ( !scene || !canvas || state?.activeTab !== 'visit' || - state?.selectedVisitId !== visitId || - !choice || - !target + state?.selectedVisitId !== expectedVisitId || + state?.firstPursuitScoutMemory?.mode !== 'walk-to-npc' || + state?.firstPursuitScoutMemory?.explorationInteractive !== true || + !bounds ) { return { ready: false, - visitId, - choiceId, + visitId: expectedVisitId, activeTab: state?.activeTab ?? null, selectedVisitId: state?.selectedVisitId ?? null, availableVisitIds: state?.availableVisitIds ?? [], - availableChoiceIds: visit?.choices?.map((candidate) => candidate.id) ?? [], - candidateCount: candidates.length + scoutMemory: state?.firstPursuitScoutMemory ?? null }; } - const bounds = target.getBounds(); const canvasBounds = canvas.getBoundingClientRect(); const centerX = bounds.x + bounds.width / 2; const centerY = bounds.y + bounds.height / 2; return { ready: true, - visitId, - choiceId, - choiceLabel: choice.label, - renderedLabel: target.text, - candidateCount: candidates.length, + visitId: expectedVisitId, bounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }, x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width, y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height }; - }, { visitId, choiceId }); + }, visitId); assert( probe.ready === true && - probe.choiceLabel === firstPursuitScoutChoiceLabel && - probe.renderedLabel?.startsWith(firstPursuitScoutChoiceLabel) && - probe.candidateCount === 1 && Number.isFinite(probe.x) && Number.isFinite(probe.y) && boundsWithinFhdViewport(probe.bounds), - `Expected one visible interactive choice ${choiceId} for camp visit ${visitId}: ${JSON.stringify(probe)}` + `Expected one visible exploration CTA for camp visit ${visitId}: ${JSON.stringify(probe)}` ); return probe; } +async function readCampVisitExplorationChoiceControlPoint(page, choiceId) { + const probe = await page.evaluate((expectedChoiceId) => { + const scene = window.__HEROS_DEBUG__?.scene('CampVisitExplorationScene'); + const state = scene?.getDebugState?.(); + const canvas = document.querySelector('canvas'); + const choice = state?.choice?.choices?.find((candidate) => candidate.id === expectedChoiceId); + const bounds = choice?.bounds; + if ( + !scene || + !canvas || + state?.ready !== true || + state?.choice?.open !== true || + state.choice.ready !== true || + choice?.interactive !== true || + !bounds + ) { + return { + ready: false, + choiceId: expectedChoiceId, + sceneReady: state?.ready ?? false, + choiceState: state?.choice ?? null + }; + } + + const canvasBounds = canvas.getBoundingClientRect(); + const centerX = bounds.x + bounds.width / 2; + const centerY = bounds.y + bounds.height / 2; + return { + ready: true, + choiceId: expectedChoiceId, + choiceLabel: choice.label, + bounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }, + x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width, + y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height + }; + }, choiceId); + assert( + probe.ready === true && + probe.choiceLabel === firstPursuitScoutChoiceLabel && + Number.isFinite(probe.x) && + Number.isFinite(probe.y) && + boundsWithinFhdViewport(probe.bounds), + `Expected one visible interactive scout choice ${choiceId} in camp exploration: ${JSON.stringify(probe)}` + ); + return probe; +} + +async function advanceCampVisitExplorationDialogueUntilChoice(page) { + for (let attempt = 0; attempt < 8; attempt += 1) { + const state = await page.evaluate(() => + window.__HEROS_DEBUG__?.scene('CampVisitExplorationScene')?.getDebugState?.() + ); + if (state?.choice?.ready === true) { + return; + } + assert( + state?.dialogue?.active === true || state?.choice?.open === true, + `Expected Jian Yong dialogue to remain active until the scout choice opens: ${JSON.stringify(state)}` + ); + await page.keyboard.press('e'); + await page.waitForTimeout(140); + } + throw new Error( + `Scout choice did not open after Jian Yong dialogue: ${JSON.stringify( + await page.evaluate(() => + window.__HEROS_DEBUG__?.scene('CampVisitExplorationScene')?.getDebugState?.() + ) + )}` + ); +} + +async function advanceCampVisitExplorationDialogueUntilClosed(page) { + for (let attempt = 0; attempt < 8; attempt += 1) { + const state = await page.evaluate(() => + window.__HEROS_DEBUG__?.scene('CampVisitExplorationScene')?.getDebugState?.() + ); + if (!state?.dialogue?.active) { + return; + } + await page.keyboard.press('e'); + await page.waitForTimeout(140); + } + throw new Error( + `Camp exploration result dialogue did not close: ${JSON.stringify( + await page.evaluate(() => + window.__HEROS_DEBUG__?.scene('CampVisitExplorationScene')?.getDebugState?.() + ) + )}` + ); +} + async function readFirstVictoryFollowupControlPoint(page, control, choiceId) { const probe = await page.evaluate(({ control, choiceId }) => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); diff --git a/src/assets/images/exploration/characters/exploration-jian-yong.webp b/src/assets/images/exploration/characters/exploration-jian-yong.webp new file mode 100644 index 0000000..32bfbe9 Binary files /dev/null and b/src/assets/images/exploration/characters/exploration-jian-yong.webp differ diff --git a/src/game/data/explorationCharacterAssets.ts b/src/game/data/explorationCharacterAssets.ts index ae28ddb..489bc2c 100644 --- a/src/game/data/explorationCharacterAssets.ts +++ b/src/game/data/explorationCharacterAssets.ts @@ -19,6 +19,7 @@ export const explorationCharacterTextureKeyByUnitTextureKey = { } as const; export const explorationCharacterNamedTextureKeyFallbacks = { + 'exploration-jian-yong': 'unit-shu-officer', 'exploration-zhuo-recruiting-clerk': 'unit-shu-officer', 'exploration-zhuo-villager': 'unit-shu-infantry', 'exploration-zhuo-quartermaster': 'unit-shu-officer', diff --git a/src/game/data/firstPursuitScoutVisit.ts b/src/game/data/firstPursuitScoutVisit.ts new file mode 100644 index 0000000..45f63bf --- /dev/null +++ b/src/game/data/firstPursuitScoutVisit.ts @@ -0,0 +1,68 @@ +import { + firstPursuitScoutChoiceIds, + firstPursuitScoutSourceBattleId, + firstPursuitScoutVisitId, + type FirstPursuitScoutChoiceId +} from './firstPursuitScoutMemory'; + +export type FirstPursuitScoutVisitChoice = { + readonly id: FirstPursuitScoutChoiceId; + readonly label: string; + readonly response: string; + readonly bondExp: number; + readonly itemRewards: readonly string[]; +}; + +type FirstPursuitScoutVisitDefinition = { + readonly id: typeof firstPursuitScoutVisitId; + readonly title: string; + readonly location: string; + readonly availableAfterBattleIds: readonly [ + typeof firstPursuitScoutSourceBattleId + ]; + readonly bondId: string; + readonly description: string; + readonly lines: readonly string[]; + readonly choices: readonly FirstPursuitScoutVisitChoice[]; +}; + +export const firstPursuitScoutVisitDefinition = { + id: firstPursuitScoutVisitId, + title: '북문 추격로 정찰', + location: '탁현 북문 정찰막', + availableAfterBattleIds: [firstPursuitScoutSourceBattleId], + bondId: 'liu-bei__jian-yong', + description: + '간옹과 황건 잔당의 두 갈래 길을 살피고 정찰 기록을 출진 준비에 올립니다.', + lines: [ + '간옹: 발자국이 강가와 마을길로 갈렸습니다.', + '유비: 매복도 찾고 백성의 길도 지켜야 하오.', + '정찰 지도에 두 길을 나란히 표시했습니다.' + ], + choices: [ + { + id: firstPursuitScoutChoiceIds[0], + label: '강가 매복 흔적을 쫓는다', + response: + '간옹: 강변의 꺾인 갈대와 버려진 횃불을 표시했습니다. 상처약도 챙겨 이 기록을 출진 준비에 올리겠습니다.', + bondExp: 10, + itemRewards: ['상처약 1'] + }, + { + id: firstPursuitScoutChoiceIds[1], + label: '마을 구호 길을 표시한다', + response: + '간옹: 피난민이 돌아올 샛길과 군량을 나눌 곳을 적었습니다. 콩 한 자루와 이 기록을 다음 전투 준비에 보태겠습니다.', + bondExp: 10, + itemRewards: ['콩 1'] + } + ] +} as const satisfies FirstPursuitScoutVisitDefinition; + +export function getFirstPursuitScoutVisitChoice( + choiceId: string +): FirstPursuitScoutVisitChoice | undefined { + return firstPursuitScoutVisitDefinition.choices.find( + (choice) => choice.id === choiceId + ); +} diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index ba2608d..d30a9eb 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -38,6 +38,7 @@ import { firstPursuitScoutVisitId, resolveFirstPursuitScoutMemory } from '../data/firstPursuitScoutMemory'; +import { firstPursuitScoutVisitDefinition } from '../data/firstPursuitScoutVisit'; import { findCityStayAfterBattle, type CityEquipmentOffer, @@ -8401,33 +8402,13 @@ const campDialogues: CampDialogue[] = [ const campVisits: CampVisitDefinition[] = [ { - id: 'first-pursuit-scout-tent', - title: '북문 추격로 정찰', - location: '탁현 북문 정찰막', - availableAfterBattleIds: [campBattleIds.first], - bondId: 'liu-bei__jian-yong', - description: '간옹과 황건 잔당의 두 갈래 길을 살피고 정찰 기록을 출진 준비에 올립니다.', - lines: [ - '간옹: 발자국이 강가와 마을길로 갈렸습니다.', - '유비: 매복도 찾고 백성의 길도 지켜야 하오.', - '정찰 지도에 두 길을 나란히 표시했습니다.' - ], - choices: [ - { - id: 'trace-river-ambush', - label: '강가 매복 흔적을 쫓는다', - response: '간옹: 강변의 꺾인 갈대와 버려진 횃불을 표시했습니다. 상처약도 챙겨 이 기록을 출진 준비에 올리겠습니다.', - bondExp: 10, - itemRewards: ['상처약 1'] - }, - { - id: 'mark-village-relief', - label: '마을 구호 길을 표시한다', - response: '간옹: 피난민이 돌아올 샛길과 군량을 나눌 곳을 적었습니다. 콩 한 자루와 이 기록을 다음 전투 준비에 보태겠습니다.', - bondExp: 10, - itemRewards: ['콩 1'] - } - ] + ...firstPursuitScoutVisitDefinition, + availableAfterBattleIds: [...firstPursuitScoutVisitDefinition.availableAfterBattleIds], + lines: [...firstPursuitScoutVisitDefinition.lines], + choices: firstPursuitScoutVisitDefinition.choices.map((choice) => ({ + ...choice, + itemRewards: [...choice.itemRewards] + })) }, { id: 'jingzhou-scholar-rumors', @@ -11407,6 +11388,7 @@ export class CampScene extends Phaser.Scene { private campDialogueChoiceButtons: Record = {}; private campDialogueBodyText?: Phaser.GameObjects.Text; private selectedVisitId = campVisits[0].id; + private firstPursuitExplorationButton?: Phaser.GameObjects.Rectangle; private equipmentInventoryPage = 0; private equipmentPanelBackground?: Phaser.GameObjects.Rectangle; private equipmentInventoryPreviousButton?: Phaser.GameObjects.Rectangle; @@ -11531,6 +11513,7 @@ export class CampScene extends Phaser.Scene { this.campDialogueRowButtons = {}; this.campDialogueChoiceButtons = {}; this.campDialogueBodyText = undefined; + this.firstPursuitExplorationButton = undefined; this.dialogueObjects = []; this.sortieObjects = []; this.sortieComparisonPanelView = undefined; @@ -11642,6 +11625,14 @@ export class CampScene extends Phaser.Scene { } else if (this.activeTab === 'city') { this.activeTab = 'status'; } + const pendingFirstPursuitScoutVisit = this.availableCampVisits().some( + (visit) => + visit.id === firstPursuitScoutVisitId && + !this.completedCampVisits().includes(visit.id) + ); + if (!cityStay && pendingFirstPursuitScoutVisit && this.activeTab === 'status') { + this.activeTab = 'visit'; + } soundDirector.playSoundscape({ musicKey: this.campSoundscape.music.trackKey, ambienceKey: this.campSoundscape.ambience.trackKey, @@ -13883,7 +13874,7 @@ export class CampScene extends Phaser.Scene { : width < 70 ? 'N' : 'NEW'; - const newBadge = tab === 'city' || tab === 'dialogue' || tab === 'supplies' || tab === 'equipment' + const newBadge = tab === 'city' || tab === 'dialogue' || tab === 'visit' || tab === 'supplies' || tab === 'equipment' ? this.scaleLegacyCampUi(this.add.text(x + width / 2 - 10, y - 17, badgeLabel, { ...this.textStyle(9, '#fff2b8', true), backgroundColor: '#8a3f25', @@ -13911,6 +13902,9 @@ export class CampScene extends Phaser.Scene { const active = this.activeTab === tab; const pendingFirstBattleFollowup = tab === 'dialogue' && this.hasPendingFirstBattleCampFollowup(); const pendingCamaraderieMemory = tab === 'dialogue' && this.hasPendingFirstBattleCamaraderieMemory(); + const pendingCampVisit = tab === 'visit' && this.availableCampVisits().some( + (visit) => !this.completedCampVisits().includes(visit.id) + ); bg.setFillStyle(active ? 0x2b4052 : hovered ? 0x243747 : 0x182431, active || hovered ? 0.98 : 0.94); bg.setStrokeStyle(active || hovered ? 2 : 1, active || hovered ? accentColor : palette.blue, active ? 0.96 : hovered ? 0.82 : 0.62); text.setColor(active || hovered ? '#fff2b8' : '#f2e3bf'); @@ -13923,6 +13917,7 @@ export class CampScene extends Phaser.Scene { newBadge?.setVisible(Boolean( (tab === 'city' && this.hasPendingCityStayActions()) || (tab === 'dialogue' && (pendingFirstBattleFollowup || pendingCamaraderieMemory)) || + pendingCampVisit || (tab === 'supplies' && pendingCategories.has('supplies')) || (tab === 'equipment' && pendingCategories.has('equipment')) )); @@ -23366,6 +23361,48 @@ export class CampScene extends Phaser.Scene { return; } + if (visit.id === firstPursuitScoutVisitId) { + const guide = this.track(this.add.rectangle(x + 18, y + height - 102, width - 36, 84, 0x151f2a, 0.96)); + guide.setOrigin(0); + guide.setStrokeStyle(1, palette.blue, 0.62); + this.track(this.add.text( + x + 32, + y + height - 90, + '유비를 움직여 간옹에게 직접 말을 거세요.', + this.textStyle(13, '#d4dce6', true) + )); + this.track(this.add.text( + x + 32, + y + height - 68, + 'WASD·방향키 이동 · E/Space 대화', + this.textStyle(11, '#9fb0bf') + )); + + const buttonY = y + height - 36; + const button = this.track(this.add.rectangle(x + width / 2, buttonY, width - 64, 34, 0x4a371d, 0.98)); + button.setStrokeStyle(2, palette.gold, 0.94); + button.setInteractive({ useHandCursor: true }); + const openExploration = () => { + soundDirector.playSelect(); + this.startCampNavigation('CampVisitExplorationScene', { visitId: visit.id }); + }; + button.on('pointerover', () => button.setFillStyle(0x654c25, 1)); + button.on('pointerout', () => button.setFillStyle(0x4a371d, 0.98)); + button.on('pointerdown', openExploration); + this.firstPursuitExplorationButton = button; + + const label = this.track(this.add.text( + x + width / 2, + buttonY, + '정찰막으로 이동', + this.textStyle(14, '#fff2b8', true) + )); + label.setOrigin(0.5); + label.setInteractive({ useHandCursor: true }); + label.on('pointerdown', openExploration); + return; + } + visit.choices.forEach((choice, index) => { const choiceY = y + height - 80 + index * 42; const button = this.track(this.add.rectangle(x + width / 2, choiceY, width - 36, 34, 0x1a2630, 0.96)); @@ -24904,6 +24941,7 @@ export class CampScene extends Phaser.Scene { this.campDialogueRowButtons = {}; this.campDialogueChoiceButtons = {}; this.campDialogueBodyText = undefined; + this.firstPursuitExplorationButton = undefined; this.campRosterLayout = undefined; this.cityPanelBackground = undefined; this.cityExploreButton = undefined; @@ -25227,6 +25265,15 @@ export class CampScene extends Phaser.Scene { visitId: firstPursuitScoutVisitId, available: Boolean(firstPursuitScoutVisit), completed: this.completedCampVisits().includes(firstPursuitScoutVisitId), + mode: this.completedCampVisits().includes(firstPursuitScoutVisitId) + ? 'completed' + : firstPursuitScoutVisit + ? 'walk-to-npc' + : 'unavailable', + explorationButtonBounds: this.sortieInteractiveObjectBoundsDebug( + this.firstPursuitExplorationButton + ), + explorationInteractive: Boolean(this.firstPursuitExplorationButton?.input?.enabled), choiceId: this.campaign?.campVisitChoiceIds[firstPursuitScoutVisitId] ?? null, choices: firstPursuitScoutVisit?.choices.map((choice) => ({ id: choice.id, diff --git a/src/game/scenes/CampVisitExplorationScene.ts b/src/game/scenes/CampVisitExplorationScene.ts new file mode 100644 index 0000000..0ffb3d1 --- /dev/null +++ b/src/game/scenes/CampVisitExplorationScene.ts @@ -0,0 +1,2438 @@ +import Phaser from 'phaser'; +import campBackgroundUrl from '../../assets/images/exploration/prologue-militia-camp.webp'; +import { soundDirector } from '../audio/SoundDirector'; +import { + ensureExplorationCharacterAnimations, + explorationCharacterAnimationKey, + explorationCharacterAssetInfos, + explorationCharacterTextureKeyByUnitTextureKey, + releaseExplorationCharacterTextureKeys, + type ExplorationCharacterDirection, + type ExplorationCharacterTextureKey +} from '../data/explorationCharacterAssets'; +import { + firstPursuitScoutSourceBattleId, + firstPursuitScoutVisitId +} from '../data/firstPursuitScoutMemory'; +import { + firstPursuitScoutVisitDefinition, + getFirstPursuitScoutVisitChoice, + type FirstPursuitScoutVisitChoice +} from '../data/firstPursuitScoutVisit'; +import { + portraitAssetEntryForStoryContext, + type PortraitAssetEntry +} from '../data/portraitAssets'; +import { releaseTextureSource } from '../render/loaderLifecycle'; +import { getCampaignState } from '../state/campaignState'; +import { completeFirstPursuitScoutVisit } from '../state/firstPursuitScoutActions'; +import { startGameScene } from './lazyScenes'; + +const sceneWidth = 1920; +const sceneHeight = 1080; +const mapRight = 1495; +const movementBounds = new Phaser.Geom.Rectangle(42, 108, 1404, 814); +const playerSpeed = 300; +const playerCollisionRadius = 25; +const interactionRadius = 122; +const promptRadius = 164; +const characterDisplaySize = 112; +const inputCarryoverGuardMs = 320; +const navigationArrivalRadius = 7; +const navigationDetourClearance = 58; +const navigationProbeStep = 18; +const directPointerSnapRadius = 96; +const transitionFadeDurationMs = 320; +const campBackgroundKey = 'first-pursuit-camp-background'; +const playerTextureKey = + explorationCharacterTextureKeyByUnitTextureKey['unit-liu-bei']; + +type CampVisitExplorationSceneData = { + visitId?: string; +}; + +type ExplorationActorId = 'jian-yong' | 'guan-yu' | 'zhang-fei'; + +type ExplorationActorDefinition = { + id: ExplorationActorId; + name: string; + role: string; + textureKey: ExplorationCharacterTextureKey; + portraitKey: string; + x: number; + y: number; + direction: ExplorationCharacterDirection; + kind: 'objective' | 'companion'; + dialogue: readonly DialogueLine[]; + repeatDialogue?: readonly DialogueLine[]; +}; + +type ExplorationActorView = { + definition: ExplorationActorDefinition; + sprite: Phaser.GameObjects.Sprite; + shadow: Phaser.GameObjects.Ellipse; + nameplate: Phaser.GameObjects.Text; + marker: Phaser.GameObjects.Text; + initialPosition: { x: number; y: number }; +}; + +type InteractionTarget = + | { + kind: 'actor'; + id: ExplorationActorId; + name: string; + x: number; + y: number; + } + | { + kind: 'exit'; + id: 'camp-exit'; + name: string; + x: number; + y: number; + }; + +type DialogueLine = { + speaker: string; + text: string; + portraitKey?: string; +}; + +type DialogueState = { + lines: DialogueLine[]; + lineIndex: number; + onComplete?: () => void; + sourceNpcId?: ExplorationActorId | 'camp-exit'; +}; + +type ChoiceView = { + choice: FirstPursuitScoutVisitChoice; + button: Phaser.GameObjects.Rectangle; +}; + +const actorDefinitions: readonly ExplorationActorDefinition[] = [ + { + id: 'jian-yong', + name: '간옹', + role: '북문 추격로 정찰', + textureKey: 'exploration-jian-yong', + portraitKey: 'jian-yong-campaign', + x: 510, + y: 492, + direction: 'east', + kind: 'objective', + dialogue: [ + { + speaker: '간옹', + portraitKey: 'jian-yong-campaign', + text: '발자국이 강가와 마을길로 갈렸습니다. 어느 길을 먼저 정찰도에 올릴지 정해야 합니다.' + }, + { + speaker: '유비', + portraitKey: 'liu-bei-yellow-turban', + text: '매복도 찾고 백성의 길도 지켜야 하오. 우리가 먼저 확인할 길을 정합시다.' + } + ] + }, + { + id: 'guan-yu', + name: '관우', + role: '추격 전열 점검', + textureKey: explorationCharacterTextureKeyByUnitTextureKey['unit-guan-yu'], + portraitKey: 'guan-yu-yellow-turban', + x: 1015, + y: 565, + direction: 'west', + kind: 'companion', + dialogue: [ + { + speaker: '관우', + portraitKey: 'guan-yu-yellow-turban', + text: '적이 흩어졌어도 추격로가 좁으면 앞선 병력이 고립됩니다. 간옹의 정찰도를 본 뒤 전열을 정하겠습니다.' + }, + { + speaker: '유비', + portraitKey: 'liu-bei-yellow-turban', + text: '좋소. 승리의 기세보다 서로 돌아올 길을 먼저 맞춥시다.' + } + ] + }, + { + id: 'zhang-fei', + name: '장비', + role: '의병대 재집결', + textureKey: explorationCharacterTextureKeyByUnitTextureKey['unit-zhang-fei'], + portraitKey: 'zhang-fei-yellow-turban', + x: 1270, + y: 595, + direction: 'west', + kind: 'companion', + dialogue: [ + { + speaker: '장비', + portraitKey: 'zhang-fei-yellow-turban', + text: '이번에는 내가 먼저 달려 나가지 않겠소. 길이 정해지면 의병대를 그 순서대로 세우겠소.' + }, + { + speaker: '유비', + portraitKey: 'liu-bei-yellow-turban', + text: '익덕이 자리를 지켜 주면 뒤따르는 이들도 흔들리지 않을 것이오.' + } + ] + } +] as const; + +const characterTextureKeys = [ + playerTextureKey, + ...actorDefinitions.map((actor) => actor.textureKey) +] as const; +const portraitKeys = [ + 'liu-bei-yellow-turban', + ...actorDefinitions.map((actor) => actor.portraitKey) +] as const; +const portraitContext = { + id: 'first-pursuit-camp-exploration', + background: 'story-militia' +} as const; +const campExit = { + id: 'camp-exit' as const, + name: '군영 장부로 돌아가기', + x: 780, + y: 895 +}; + +function explorationPortraitEntries() { + return [...new Set(portraitKeys)] + .map((portraitKey) => + portraitAssetEntryForStoryContext( + portraitKey, + portraitContext, + `first-pursuit-${portraitKey}` + ) + ) + .filter((entry): entry is PortraitAssetEntry => entry !== undefined); +} + +export class CampVisitExplorationScene extends Phaser.Scene { + private visitId = firstPursuitScoutVisitId; + private backgroundImage?: Phaser.GameObjects.Image; + private backgroundFallback = false; + private player?: Phaser.GameObjects.Sprite; + private playerShadow?: Phaser.GameObjects.Ellipse; + private playerNameplate?: Phaser.GameObjects.Text; + private playerDirection: ExplorationCharacterDirection = 'north'; + private playerMoving = false; + private actorViews = new Map(); + private blockers: Phaser.Geom.Rectangle[] = []; + private promptBackground?: Phaser.GameObjects.Rectangle; + private promptText?: Phaser.GameObjects.Text; + private objectiveStatus?: Phaser.GameObjects.Text; + private objectiveCard?: Phaser.GameObjects.Rectangle; + private progressText?: Phaser.GameObjects.Text; + private dialoguePanel?: Phaser.GameObjects.Container; + private dialoguePortraitFrame?: Phaser.GameObjects.Rectangle; + private dialoguePortrait?: Phaser.GameObjects.Image; + private dialogueNameText?: Phaser.GameObjects.Text; + private dialogueBodyText?: Phaser.GameObjects.Text; + private dialogueProgressText?: Phaser.GameObjects.Text; + private dialogueState?: DialogueState; + private choicePanel?: Phaser.GameObjects.Container; + private choicePanelBounds?: Phaser.Geom.Rectangle; + private choiceViews: ChoiceView[] = []; + private choiceReadyAt = Number.POSITIVE_INFINITY; + 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 moveWaypoints: Phaser.Math.Vector2[] = []; + private moveTargetId?: InteractionTarget['id']; + private lastNavigationTargetId?: InteractionTarget['id']; + private navigationReplanAttempts = 0; + private navigationDetourCount = 0; + private ready = false; + private navigationPending = false; + private inputReadyAt = Number.POSITIVE_INFINITY; + private interactionQueued = false; + private stepIndex = 0; + private lastStepAt = 0; + private lastNotice = ''; + private ownedPresentationTextureKeys = new Set(); + + constructor() { + super('CampVisitExplorationScene'); + } + + init(data?: CampVisitExplorationSceneData) { + this.visitId = data?.visitId === firstPursuitScoutVisitId + ? data.visitId + : firstPursuitScoutVisitId; + } + + preload() { + explorationCharacterAssetInfos(characterTextureKeys).forEach((asset) => { + if (!this.textures.exists(asset.key)) { + this.load.spritesheet(asset.key, asset.url, { + frameWidth: asset.frameWidth, + frameHeight: asset.frameHeight + }); + } + }); + if (!this.textures.exists(campBackgroundKey)) { + this.ownedPresentationTextureKeys.add(campBackgroundKey); + this.load.image(campBackgroundKey, campBackgroundUrl); + } + explorationPortraitEntries().forEach(({ textureKey, url }) => { + if (!this.textures.exists(textureKey)) { + this.ownedPresentationTextureKeys.add(textureKey); + this.load.image(textureKey, url); + } + }); + } + + create() { + this.resetRuntimeState(); + this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => { + this.ready = false; + this.navigationPending = false; + this.clearNavigationTarget(); + this.dialogueState = undefined; + this.closeChoicePanel(); + releaseExplorationCharacterTextureKeys(this, characterTextureKeys); + this.releasePresentationTextures(); + }); + if (!this.visitAvailable()) { + this.navigationPending = true; + void startGameScene(this, 'CampScene').catch((error) => { + console.error('Failed to leave an unavailable camp visit.', error); + this.navigationPending = false; + if (this.sys.isActive()) { + this.scene.start('TitleScene'); + } + }); + return; + } + + this.drawCamp(); + this.drawHud(); + this.setupInput(); + ensureExplorationCharacterAnimations(this, characterTextureKeys); + this.createActors(); + this.createDialoguePanel(); + this.ready = true; + this.inputReadyAt = this.time.now + inputCarryoverGuardMs; + this.refreshObjectiveHud(); + this.refreshActorMarkers(); + this.refreshInteractionPrompt(); + + soundDirector.playSoundscape({ + musicKey: 'camp-rally', + ambienceKey: 'campfire-ambience', + musicVolume: 0.22, + ambienceVolume: 0.12 + }); + this.showCompletionToast( + '정찰대 막사의 간옹에게 직접 다가가세요.', + 'guide', + 1850 + ); + } + + update(time: number, delta: number) { + if (!this.ready || this.navigationPending || time < this.inputReadyAt) { + this.interactionQueued = false; + return; + } + if (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 target = this.nearestInteractionTarget(promptRadius); + const completed = this.visitComplete(campaign); + const choiceId = campaign.campVisitChoiceIds[firstPursuitScoutVisitId] ?? null; + const chosen = choiceId ? getFirstPursuitScoutVisitChoice(choiceId) : undefined; + + return { + scene: this.scene.key, + locationId: this.visitId, + ready: this.ready, + viewport: { width: this.scale.width, height: this.scale.height }, + campaignStep: campaign.step, + background: { + key: this.backgroundImage?.texture.key ?? campBackgroundKey, + ready: this.backgroundImage?.active === true, + fallback: this.backgroundFallback, + sourceWidth: this.backgroundImage?.frame.realWidth ?? null, + sourceHeight: this.backgroundImage?.frame.realHeight ?? null, + bounds: this.backgroundImage + ? this.boundsSnapshot(this.backgroundImage.getBounds()) + : null + }, + requiredTextures: characterTextureKeys.map((key) => ({ + key, + ready: this.textures.exists(key) + })), + requiredTexturesReady: characterTextureKeys.every((key) => + this.textures.exists(key) + ), + player: playerPosition + ? { + ...playerPosition, + direction: this.playerDirection, + moving: this.playerMoving, + textureKey: this.player?.texture.key ?? null, + animationKey: this.player?.anims.currentAnim?.key ?? null, + 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, + targetId: this.moveTargetId ?? null, + lastTargetId: this.lastNavigationTargetId ?? null, + waypoints: this.moveWaypoints.map(({ x, y }) => ({ x, y })), + walkBounds: this.boundsSnapshot(movementBounds), + collisionRadius: playerCollisionRadius, + detourCount: this.navigationDetourCount + }, + controls: { + movement: ['WASD', '방향키'], + interact: ['E', 'Space', 'Enter'], + choices: ['1', '2', 'pointer'], + pointerMove: true, + carryoverGuardMs: inputCarryoverGuardMs + }, + interaction: { + radius: interactionRadius, + promptRadius, + targetId: target?.id ?? null, + targetKind: target?.kind ?? null, + canInteract: Boolean( + target && + this.distanceTo(target.x, target.y) <= interactionRadius + ), + promptVisible: this.promptBackground?.visible ?? false, + promptBounds: this.promptBackground?.visible + ? this.boundsSnapshot(this.promptBackground.getBounds()) + : null + }, + actors: Array.from(this.actorViews.values()).map( + ({ definition, sprite, initialPosition }) => ({ + id: definition.id, + name: definition.name, + role: definition.role, + kind: definition.kind, + x: sprite.x, + y: sprite.y, + initialX: initialPosition.x, + initialY: initialPosition.y, + moved: Math.abs(sprite.x - initialPosition.x) > 0.1 || + Math.abs(sprite.y - initialPosition.y) > 0.1, + textureKey: sprite.texture.key, + animationKey: sprite.anims.currentAnim?.key ?? null, + completed: definition.kind === 'objective' && completed, + distance: playerPosition + ? Phaser.Math.Distance.Between( + playerPosition.x, + playerPosition.y, + sprite.x, + sprite.y + ) + : null, + bounds: this.boundsSnapshot(sprite.getBounds()) + }) + ), + exit: { + ...campExit, + distance: playerPosition + ? Phaser.Math.Distance.Between( + playerPosition.x, + playerPosition.y, + campExit.x, + campExit.y + ) + : null + }, + dialogue: this.dialogueState + ? { + active: true, + speaker: + this.dialogueState.lines[this.dialogueState.lineIndex]?.speaker ?? + '', + lineIndex: this.dialogueState.lineIndex, + totalLines: this.dialogueState.lines.length, + sourceNpcId: this.dialogueState.sourceNpcId ?? null, + portraitTextureKey: this.dialoguePortrait?.visible + ? this.dialoguePortrait.texture.key + : null, + portraitVisible: this.dialoguePortrait?.visible ?? false, + portraitFrameVisible: + this.dialoguePortraitFrame?.visible ?? false, + bounds: this.dialoguePanel?.visible + ? this.boundsSnapshot(this.dialoguePanel.getBounds()) + : null + } + : { + active: false, + speaker: null, + lineIndex: -1, + totalLines: 0, + sourceNpcId: null, + portraitTextureKey: null, + portraitVisible: false, + portraitFrameVisible: false, + bounds: null + }, + choice: { + open: Boolean(this.choicePanel), + ready: Boolean( + this.choicePanel && this.time.now >= this.choiceReadyAt + ), + panelBounds: this.choicePanelBounds + ? this.boundsSnapshot(this.choicePanelBounds) + : null, + choices: this.choiceViews.map(({ choice, button }) => ({ + id: choice.id, + label: choice.label, + rewardText: this.choiceRewardText(choice), + interactive: Boolean(button.input?.enabled), + bounds: this.boundsSnapshot(button.getBounds()) + })) + }, + visit: { + id: firstPursuitScoutVisitId, + completed, + choiceId, + choiceLabel: chosen?.label ?? null, + itemRewards: chosen ? [...chosen.itemRewards] : [], + bondExp: chosen?.bondExp ?? 0 + }, + campaign: { + latestBattleId: campaign.latestBattleId ?? null, + completedCampVisits: [...campaign.completedCampVisits], + campVisitChoiceIds: { ...campaign.campVisitChoiceIds }, + inventory: { ...campaign.inventory } + }, + blockers: this.blockers.map((blocker) => + this.boundsSnapshot(blocker) + ), + navigationPending: this.navigationPending, + lastNotice: this.lastNotice + }; + } + + debugTeleportTo(targetId: string) { + if ( + !this.ready || + this.navigationPending || + this.dialogueState || + this.choicePanel || + !this.player + ) { + return false; + } + const target = this.interactionTargetById(targetId); + if (!target) { + return false; + } + const position = this.safeApproachPosition(target.x, target.y); + this.setPlayerPosition(position.x, position.y); + this.lastNavigationTargetId = target.id; + this.refreshInteractionPrompt(); + return true; + } + + debugInteractWith(targetId: string) { + if (!this.debugTeleportTo(targetId)) { + return false; + } + const target = this.interactionTargetById(targetId); + if (!target) { + return false; + } + this.interactWithTarget(target); + return true; + } + + debugChooseScout(choiceId: string) { + const choice = getFirstPursuitScoutVisitChoice(choiceId); + if (!choice || this.visitComplete()) { + return false; + } + this.chooseScout(choice); + return true; + } + + private resetRuntimeState() { + this.backgroundImage = undefined; + this.backgroundFallback = false; + this.player = undefined; + this.playerShadow = undefined; + this.playerNameplate = undefined; + this.playerDirection = 'north'; + this.playerMoving = false; + this.actorViews.clear(); + this.blockers = []; + this.dialogueState = undefined; + this.choicePanel = undefined; + this.choicePanelBounds = undefined; + this.choiceViews = []; + this.choiceReadyAt = Number.POSITIVE_INFINITY; + this.moveTarget = undefined; + this.moveWaypoints = []; + this.moveTargetId = undefined; + this.lastNavigationTargetId = undefined; + this.navigationReplanAttempts = 0; + this.navigationDetourCount = 0; + this.ready = false; + this.navigationPending = false; + this.inputReadyAt = Number.POSITIVE_INFINITY; + this.interactionQueued = false; + this.stepIndex = 0; + this.lastStepAt = 0; + this.lastNotice = ''; + } + + private visitAvailable() { + const campaign = getCampaignState(); + return ( + this.visitId === firstPursuitScoutVisitId && + campaign.step === 'first-camp' && + campaign.latestBattleId === firstPursuitScoutSourceBattleId && + campaign.firstBattleReport?.battleId === + firstPursuitScoutSourceBattleId && + campaign.firstBattleReport.outcome === 'victory' && + campaign.battleHistory[firstPursuitScoutSourceBattleId]?.outcome === + 'victory' + ); + } + + private drawCamp() { + this.cameras.main.setBackgroundColor('#1a241e'); + if (this.textures.exists(campBackgroundKey)) { + this.backgroundImage = this.add + .image(0, 0, campBackgroundKey) + .setOrigin(0) + .setDepth(-120); + this.registerCampCollisions(); + this.drawCampWayfinding(); + this.drawCampFireGlow(); + } else { + this.backgroundFallback = true; + this.add + .rectangle(0, 0, sceneWidth, sceneHeight, 0x26352b, 1) + .setOrigin(0) + .setDepth(-120); + } + + this.add + .rectangle( + mapRight, + 0, + sceneWidth - mapRight, + sceneHeight, + 0x111720, + 0.97 + ) + .setOrigin(0) + .setDepth(1400); + this.add + .rectangle(mapRight, 0, 3, sceneHeight, 0xd8b15f, 0.72) + .setOrigin(0) + .setDepth(1401); + this.add + .rectangle(0, 0, sceneWidth, 92, 0x10161d, 0.97) + .setOrigin(0) + .setDepth(1400); + this.add + .rectangle(0, 90, sceneWidth, 2, 0xd8b15f, 0.68) + .setOrigin(0) + .setDepth(1401); + this.add + .rectangle(0, 958, mapRight, 122, 0x0e141a, 0.95) + .setOrigin(0) + .setDepth(1400); + this.add + .rectangle(0, 956, mapRight, 2, 0xd8b15f, 0.54) + .setOrigin(0) + .setDepth(1401); + } + + private registerCampCollisions() { + [ + [622, 178, 325, 125], + [108, 205, 248, 140], + [1188, 212, 214, 132], + [90, 694, 216, 140], + [295, 380, 124, 72], + [1018, 655, 182, 142], + [330, 820, 76, 54], + [413, 832, 62, 43], + [1285, 760, 72, 52], + [728, 520, 104, 64], + [955, 448, 40, 46], + [1080, 448, 40, 46], + [1205, 448, 40, 46] + ].forEach(([x, y, width, height]) => { + this.blockers.push(new Phaser.Geom.Rectangle(x, y, width, height)); + }); + } + + private drawCampWayfinding() { + [ + [780, 294, '지휘 막사'], + [250, 340, '정찰대 막사'], + [1290, 340, '의병 숙영지'], + [198, 824, '보급 천막'], + [1100, 760, '병기 훈련장'], + [784, 116, '북문 · 추격로'] + ].forEach(([x, y, label]) => { + this.add + .text(Number(x), Number(y), String(label), { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '18px', + color: '#f4e3b5', + fontStyle: 'bold', + backgroundColor: '#15120ed4', + padding: { left: 10, right: 10, top: 4, bottom: 4 } + }) + .setOrigin(0.5) + .setDepth(45); + }); + this.add + .text(450, 405, '정찰 작전판', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '18px', + color: '#edf1dd', + fontStyle: 'bold', + backgroundColor: '#16221add', + padding: { left: 10, right: 10, top: 4, bottom: 4 } + }) + .setOrigin(0.5) + .setDepth(47); + const objectiveGlow = this.add + .ellipse(510, 492, 150, 86, 0xe0bd6e, 0.08) + .setDepth(44); + this.tweens.add({ + targets: objectiveGlow, + alpha: { from: 0.04, to: 0.17 }, + scaleX: { from: 0.9, to: 1.08 }, + scaleY: { from: 0.9, to: 1.08 }, + duration: 820, + yoyo: true, + repeat: -1, + ease: 'Sine.easeInOut' + }); + } + + private drawCampFireGlow() { + const glow = this.add + .ellipse(780, 540, 230, 150, 0xf2a241, 0.08) + .setDepth(35); + this.tweens.add({ + targets: glow, + alpha: { from: 0.05, to: 0.14 }, + scaleX: { from: 0.95, to: 1.05 }, + scaleY: { from: 0.95, to: 1.05 }, + duration: 880, + yoyo: true, + repeat: -1, + ease: 'Sine.easeInOut' + }); + } + + private drawHud() { + this.add + .text(48, 27, '탁현 의용군 막사 · 첫 승리 뒤', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '34px', + color: '#f4e3b5', + fontStyle: 'bold' + }) + .setDepth(1500); + this.add + .text(590, 35, '다음 추격전을 위한 현지 준비', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '19px', + color: '#b4c0cb' + }) + .setDepth(1500); + + this.add + .text(1536, 43, '북문 추격로 정찰', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '30px', + color: '#f4e3b5', + fontStyle: 'bold' + }) + .setDepth(1500); + this.add + .text( + 1538, + 91, + '유비를 직접 움직여 간옹과 정찰 방향을 정합니다. 선택은 다음 전투에 이어집니다.', + { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: '#9fabb8', + lineSpacing: 4, + wordWrap: { width: 330, useAdvancedWrap: true } + } + ) + .setDepth(1500); + + this.objectiveCard = this.drawObjectiveCard( + 188, + '필수 준비', + '간옹 · 정찰 작전판', + '두 추격로 중 먼저 확인할 길을 선택합니다.' + ); + this.objectiveStatus = this.add + .text(1550, 207, '', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: '#d8b15f', + fontStyle: 'bold' + }) + .setDepth(1502); + + this.drawObjectiveCard( + 376, + '전우와 대화', + '관우 · 장비', + '두 장수는 제자리에서 다음 추격전을 준비합니다.' + ); + this.add + .text(1550, 395, '◇ 선택 활동', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: '#d8b15f', + fontStyle: 'bold' + }) + .setDepth(1502); + + this.drawObjectiveCard( + 564, + '군영 장부로 복귀', + '남쪽 출구', + '정찰을 미루고도 돌아갈 수 있습니다.' + ); + this.add + .text(1550, 583, '◆ 항상 가능', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: '#a8d58e', + fontStyle: 'bold' + }) + .setDepth(1502); + + this.progressText = this.add + .text(1538, 772, '', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '18px', + color: '#cbd3dc', + lineSpacing: 9, + wordWrap: { width: 330, useAdvancedWrap: true } + }) + .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(485, 980, '상호작용', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: '#d8b15f', + fontStyle: 'bold' + }) + .setDepth(1502); + this.add + .text(485, 1012, '가까이에서 E / Space / Enter', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '19px', + color: '#e3e7eb' + }) + .setDepth(1502); + this.add + .text(1420, 1012, 'ESC · 대화/선택 닫기', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: '#9fabb8' + }) + .setOrigin(1, 0) + .setDepth(1502); + + this.promptBackground = this.add + .rectangle(940, 1018, 610, 58, 0x2a2119, 0.98) + .setStrokeStyle(2, 0xd8b15f, 0.92) + .setDepth(1510) + .setVisible(false); + this.promptText = this.add + .text(940, 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, 156, 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 + 109, help, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '13px', + color: '#788491', + wordWrap: { width: 318, useAdvancedWrap: true } + }) + .setDepth(1501); + return background; + } + + private createActors() { + const start = { x: 780, y: 720 }; + this.playerShadow = this.add + .ellipse(start.x, start.y + 20, 66, 25, 0x07100a, 0.45) + .setDepth(800); + this.player = this.createCharacterSprite( + playerTextureKey, + start.x, + start.y, + characterDisplaySize + 6, + 'north' + ); + this.player.setDepth(100 + start.y); + this.playerNameplate = this.add + .text(start.x, start.y + 68, '유비', { + 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); + + actorDefinitions.forEach((definition) => { + const shadow = this.add + .ellipse( + definition.x, + definition.y + 20, + 64, + 24, + 0x07100a, + 0.43 + ) + .setDepth(90 + definition.y); + const sprite = this.createCharacterSprite( + definition.textureKey, + definition.x, + definition.y, + definition.id === 'jian-yong' + ? characterDisplaySize + 4 + : characterDisplaySize, + definition.direction + ); + sprite.setDepth(100 + definition.y); + const nameplate = this.add + .text(definition.x, definition.y + 67, 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 - 77, + definition.kind === 'objective' ? '!' : '…', + { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: definition.kind === 'objective' ? '32px' : '23px', + color: '#2a2013', + fontStyle: 'bold', + backgroundColor: + definition.kind === 'objective' ? '#e5bd68' : '#aeb8c4', + padding: { left: 10, right: 10, top: 3, bottom: 3 } + } + ) + .setOrigin(0.5) + .setDepth(1201); + this.tweens.add({ + targets: marker, + y: marker.y - 8, + duration: definition.kind === 'objective' ? 680 : 900, + yoyo: true, + repeat: -1, + ease: 'Sine.easeInOut' + }); + this.actorViews.set(definition.id, { + definition, + sprite, + shadow, + nameplate, + marker, + initialPosition: { x: definition.x, y: definition.y } + }); + }); + + const exitMarker = this.add + .text(campExit.x, campExit.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.add + .text(campExit.x, campExit.y + 18, '군영 장부', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '16px', + color: '#f0dfbd', + fontStyle: 'bold', + backgroundColor: '#17130fcc', + padding: { left: 8, right: 8, top: 3, bottom: 3 } + }) + .setOrigin(0.5) + .setDepth(1200); + this.tweens.add({ + targets: exitMarker, + alpha: { from: 0.68, to: 1 }, + duration: 880, + yoyo: true, + repeat: -1 + }); + } + + private createCharacterSprite( + textureKey: ExplorationCharacterTextureKey, + x: number, + y: number, + size: number, + direction: ExplorationCharacterDirection + ) { + 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( + explorationCharacterAnimationKey(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, 0xd8b15f, 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); + this.dialoguePortraitFrame = this.add + .rectangle(x + 130, y + 152, 232, 236, 0x0e131a, 0.92) + .setStrokeStyle(2, 0x9e8350, 0.9); + this.dialoguePortrait = this.add + .image(x + 130, y + 152, '__DEFAULT') + .setDisplaySize(220, 220) + .setVisible(false); + this.dialogueNameText = this.add.text(x + 270, y + 42, '', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '29px', + color: '#f1d691', + fontStyle: 'bold' + }); + this.dialogueBodyText = this.add.text(x + 270, y + 101, '', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '27px', + color: '#f0ede5', + lineSpacing: 11, + wordWrap: { width: 1360, 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, + this.dialoguePortraitFrame, + this.dialoguePortrait, + 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.chooseScoutByIndex(0)); + keyboard + .addKey(Phaser.Input.Keyboard.KeyCodes.TWO) + .on('down', () => this.chooseScoutByIndex(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.choicePanel) { + return; + } + if (this.dialogueState) { + this.advanceDialogue(); + return; + } + if ( + pointer.x >= movementBounds.left && + pointer.x <= movementBounds.right && + pointer.y >= movementBounds.top && + pointer.y <= movementBounds.bottom + ) { + this.beginPointerMovement(pointer.x, pointer.y); + } + } + ); + } + + private handleEscape() { + if ( + !this.ready || + this.navigationPending || + this.time.now < this.inputReadyAt + ) { + 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.clearNavigationTarget(true); + } else if (this.moveTarget) { + const activeTarget = this.moveWaypoints[0] ?? this.moveTarget; + const targetDelta = activeTarget + .clone() + .subtract(new Phaser.Math.Vector2(this.player.x, this.player.y)); + if (targetDelta.length() <= navigationArrivalRadius) { + if (this.moveWaypoints.length > 0) { + this.moveWaypoints.shift(); + } else { + this.lastNavigationTargetId = this.moveTargetId; + this.clearNavigationTarget(false); + } + 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) { + if (!this.replanBlockedPointerMovement()) { + this.clearNavigationTarget(true); + } + } + + this.syncPlayerView(); + this.setPlayerMoving(moved); + if (moved && time - this.lastStepAt >= 280) { + this.lastStepAt = time; + this.stepIndex += 1; + soundDirector.playMovementStep(false, this.stepIndex, { + terrain: 'plain', + volume: 0.09, + rate: 1.04, + stopAfterMs: 620 + }); + } + } + + private beginPointerMovement(x: number, y: number) { + const snappedTarget = this.pointerMovementTargetAt(x, y); + if (!snappedTarget) { + this.beginNavigationTo(x, y); + return; + } + const plannedApproaches = this.approachPositions( + snappedTarget.x, + snappedTarget.y + ) + .map((destination) => ({ + destination, + route: this.planNavigationRoute(destination) + })) + .filter( + ( + entry + ): entry is { + destination: Phaser.Math.Vector2; + route: Phaser.Math.Vector2[]; + } => Boolean(entry.route) + ) + .sort( + (left, right) => + this.navigationRouteLength(left.route) - + this.navigationRouteLength(right.route) + ); + const best = plannedApproaches[0]; + if (best) { + this.applyNavigationRoute(best.route, snappedTarget.id); + return; + } + const fallback = this.safeApproachPosition( + snappedTarget.x, + snappedTarget.y + ); + this.beginNavigationTo( + fallback.x, + fallback.y, + snappedTarget.id + ); + } + + private beginNavigationTo( + x: number, + y: number, + targetId?: InteractionTarget['id'] + ) { + const destination = this.nearestWalkablePoint(x, y); + if (!destination) { + this.clearNavigationTarget(true); + return; + } + const route = this.planNavigationRoute(destination); + if (!route) { + this.clearNavigationTarget(true); + return; + } + this.applyNavigationRoute(route, targetId); + } + + private applyNavigationRoute( + route: Phaser.Math.Vector2[], + targetId?: InteractionTarget['id'] + ) { + const destination = route[route.length - 1]; + if (!destination) { + this.clearNavigationTarget(true); + return; + } + this.moveTarget = destination.clone(); + this.moveWaypoints = route + .slice(0, -1) + .map((waypoint) => waypoint.clone()); + this.moveTargetId = targetId; + this.lastNavigationTargetId = undefined; + this.navigationReplanAttempts = 0; + if (route.length > 1) { + this.navigationDetourCount += 1; + } + } + + private pointerMovementTargetAt(x: number, y: number) { + return this.allInteractionTargets() + .map((target) => ({ + target, + distance: Phaser.Math.Distance.Between(x, y, target.x, target.y) + })) + .filter(({ distance }) => distance <= directPointerSnapRadius) + .sort((left, right) => left.distance - right.distance)[0]?.target; + } + + private approachPositions(targetX: number, targetY: number) { + return [ + new Phaser.Math.Vector2(targetX, targetY + 88), + new Phaser.Math.Vector2(targetX - 88, targetY), + new Phaser.Math.Vector2(targetX + 88, targetY), + new Phaser.Math.Vector2(targetX, targetY - 88) + ].filter(({ x, y }) => this.canPlayerOccupy(x, y)); + } + + private nearestWalkablePoint(x: number, y: number) { + const margin = playerCollisionRadius + 4; + const clamped = new Phaser.Math.Vector2( + Phaser.Math.Clamp( + x, + movementBounds.left + margin, + movementBounds.right - margin + ), + Phaser.Math.Clamp( + y, + movementBounds.top + margin, + movementBounds.bottom - margin + ) + ); + if (this.canPlayerOccupy(clamped.x, clamped.y)) { + return clamped; + } + for (const radius of [48, 80, 112, 144]) { + for (let index = 0; index < 8; index += 1) { + const angle = (index * Math.PI) / 4; + const candidate = new Phaser.Math.Vector2( + Phaser.Math.Clamp( + clamped.x + Math.cos(angle) * radius, + movementBounds.left + margin, + movementBounds.right - margin + ), + Phaser.Math.Clamp( + clamped.y + Math.sin(angle) * radius, + movementBounds.top + margin, + movementBounds.bottom - margin + ) + ); + if (this.canPlayerOccupy(candidate.x, candidate.y)) { + return candidate; + } + } + } + return undefined; + } + + private planNavigationRoute(destination: Phaser.Math.Vector2) { + if ( + !this.player || + !this.canPlayerOccupy(destination.x, destination.y) + ) { + return undefined; + } + const start = new Phaser.Math.Vector2(this.player.x, this.player.y); + if (this.segmentIsNavigable(start, destination)) { + return [destination.clone()]; + } + + const candidates = this.navigationDetourCandidates(start, destination); + let bestRoute: Phaser.Math.Vector2[] | undefined; + let bestLength = Number.POSITIVE_INFINITY; + const startVisible = candidates.filter((candidate) => + this.segmentIsNavigable(start, candidate) + ); + const destinationVisible = candidates.filter((candidate) => + this.segmentIsNavigable(candidate, destination) + ); + + startVisible.forEach((waypoint) => { + if (!this.segmentIsNavigable(waypoint, destination)) { + return; + } + const route = [waypoint, destination]; + const length = this.navigationRouteLength(route); + if (length < bestLength) { + bestLength = length; + bestRoute = route; + } + }); + startVisible.forEach((first) => { + destinationVisible.forEach((second) => { + if ( + first.equals(second) || + !this.segmentIsNavigable(first, second) + ) { + return; + } + const route = [first, second, destination]; + const length = this.navigationRouteLength(route); + if (length < bestLength) { + bestLength = length; + bestRoute = route; + } + }); + }); + return bestRoute?.map((waypoint) => waypoint.clone()); + } + + private navigationDetourCandidates( + start: Phaser.Math.Vector2, + destination: Phaser.Math.Vector2 + ) { + const margin = playerCollisionRadius + 4; + const candidates = [ + new Phaser.Math.Vector2(start.x, destination.y), + new Phaser.Math.Vector2(destination.x, start.y) + ]; + this.blockers.forEach((blocker) => { + const left = blocker.left - navigationDetourClearance; + const right = blocker.right + navigationDetourClearance; + const top = blocker.top - navigationDetourClearance; + const bottom = blocker.bottom + navigationDetourClearance; + candidates.push( + new Phaser.Math.Vector2(left, top), + new Phaser.Math.Vector2(right, top), + new Phaser.Math.Vector2(left, bottom), + new Phaser.Math.Vector2(right, bottom) + ); + }); + this.actorViews.forEach(({ sprite }) => { + candidates.push( + new Phaser.Math.Vector2(sprite.x - 72, sprite.y), + new Phaser.Math.Vector2(sprite.x + 72, sprite.y), + new Phaser.Math.Vector2(sprite.x, sprite.y - 72), + new Phaser.Math.Vector2(sprite.x, sprite.y + 72) + ); + }); + + const unique = new Map(); + candidates.forEach((candidate) => { + candidate.set( + Phaser.Math.Clamp( + candidate.x, + movementBounds.left + margin, + movementBounds.right - margin + ), + Phaser.Math.Clamp( + candidate.y, + movementBounds.top + margin, + movementBounds.bottom - margin + ) + ); + if (!this.canPlayerOccupy(candidate.x, candidate.y)) { + return; + } + unique.set( + `${Math.round(candidate.x)}:${Math.round(candidate.y)}`, + candidate + ); + }); + return Array.from(unique.values()); + } + + private segmentIsNavigable( + from: Phaser.Math.Vector2, + to: Phaser.Math.Vector2 + ) { + const distance = Phaser.Math.Distance.Between( + from.x, + from.y, + to.x, + to.y + ); + const steps = Math.max(1, Math.ceil(distance / navigationProbeStep)); + for (let index = 1; index <= steps; index += 1) { + const progress = index / steps; + const x = Phaser.Math.Linear(from.x, to.x, progress); + const y = Phaser.Math.Linear(from.y, to.y, progress); + if (!this.canPlayerOccupy(x, y)) { + return false; + } + } + return true; + } + + private navigationRouteLength( + route: readonly Phaser.Math.Vector2[] + ) { + if (!this.player) { + return Number.POSITIVE_INFINITY; + } + let previous = new Phaser.Math.Vector2(this.player.x, this.player.y); + return route.reduce((total, waypoint) => { + const length = Phaser.Math.Distance.Between( + previous.x, + previous.y, + waypoint.x, + waypoint.y + ); + previous = waypoint; + return total + length; + }, 0); + } + + private replanBlockedPointerMovement() { + if (!this.moveTarget || this.navigationReplanAttempts >= 2) { + return false; + } + this.navigationReplanAttempts += 1; + const route = this.planNavigationRoute(this.moveTarget); + if (!route) { + return false; + } + this.moveWaypoints = route + .slice(0, -1) + .map((waypoint) => waypoint.clone()); + return true; + } + + private clearNavigationTarget(clearLast = false) { + this.moveTarget = undefined; + this.moveWaypoints = []; + this.moveTargetId = undefined; + this.navigationReplanAttempts = 0; + if (clearLast) { + this.lastNavigationTargetId = undefined; + } + } + + 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 + ): ExplorationCharacterDirection { + 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.actorViews.values()).every(({ sprite }) => + Phaser.Math.Distance.Between(x, y, sprite.x, sprite.y) >= 48 + ); + } + + private setPlayerMoving(moving: boolean) { + if (!this.player) { + return; + } + const animationKey = explorationCharacterAnimationKey( + playerTextureKey, + moving ? 'walk' : 'idle', + this.playerDirection + ); + if ( + this.textures.exists(playerTextureKey) && + this.player.anims.currentAnim?.key !== animationKey + ) { + this.player.play(animationKey); + } + this.playerMoving = moving; + } + + private stopPlayerMovement() { + this.clearNavigationTarget(); + this.setPlayerMoving(false); + } + + private syncPlayerView() { + if (!this.player) { + return; + } + this.playerShadow + ?.setPosition(this.player.x, this.player.y + 20) + .setDepth(90 + this.player.y); + this.player.setDepth(100 + this.player.y); + this.playerNameplate?.setPosition(this.player.x, this.player.y + 68); + } + + 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) { + return ( + this.approachPositions(targetX, targetY)[0] ?? { + x: Phaser.Math.Clamp( + targetX, + movementBounds.left + 40, + movementBounds.right - 40 + ), + y: Phaser.Math.Clamp( + targetY + 100, + movementBounds.top + 40, + movementBounds.bottom - 40 + ) + } + ); + } + + private tryInteract() { + const target = this.nearestInteractionTarget(interactionRadius); + if (!target) { + this.showPromptMessage('조금 더 가까이 다가가세요.'); + return; + } + this.interactWithTarget(target); + } + + private interactWithTarget(target: InteractionTarget) { + this.stopPlayerMovement(); + if (target.kind === 'exit') { + this.interactWithExit(); + return; + } + const view = this.actorViews.get(target.id); + if (!view) { + return; + } + this.faceCharactersForConversation(view); + if (view.definition.kind === 'objective') { + this.interactWithJianYong(view); + return; + } + const lines = this.visitComplete() && view.definition.repeatDialogue + ? view.definition.repeatDialogue + : view.definition.dialogue; + this.startDialogue( + [...lines], + () => this.restoreActorDirection(view), + view.definition.id + ); + } + + private interactWithJianYong(view: ExplorationActorView) { + const campaign = getCampaignState(); + const choiceId = + campaign.campVisitChoiceIds[firstPursuitScoutVisitId]; + const choice = choiceId + ? getFirstPursuitScoutVisitChoice(choiceId) + : undefined; + if (this.visitComplete(campaign)) { + this.startDialogue( + [ + { + speaker: '간옹', + portraitKey: view.definition.portraitKey, + text: + choice?.response.replace(/^간옹:\s*/, '') ?? + '정찰 기록은 이미 출진 장부에 올렸습니다.' + }, + { + speaker: '정찰 기록', + text: choice + ? `나의 결정 · ${choice.label}` + : '북문 추격로 정찰 완료' + } + ], + () => this.restoreActorDirection(view), + view.definition.id + ); + return; + } + + this.startDialogue( + [...view.definition.dialogue], + () => { + this.restoreActorDirection(view); + this.openChoicePanel(); + }, + view.definition.id + ); + } + + private faceCharactersForConversation(view: ExplorationActorView) { + 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)); + view.sprite.play( + explorationCharacterAnimationKey( + view.definition.textureKey, + 'idle', + npcDirection + ) + ); + } + + private restoreActorDirection(view: ExplorationActorView) { + view.sprite.play( + explorationCharacterAnimationKey( + view.definition.textureKey, + 'idle', + view.definition.direction + ) + ); + } + + private startDialogue( + lines: DialogueLine[], + onComplete?: () => void, + sourceNpcId?: DialogueState['sourceNpcId'] + ) { + if ( + lines.length === 0 || + this.navigationPending || + this.choicePanel + ) { + return; + } + this.stopPlayerMovement(); + this.dialogueState = { + lines, + lineIndex: 0, + onComplete, + sourceNpcId + }; + this.dialoguePanel?.setVisible(true); + this.promptBackground?.setVisible(false); + this.promptText?.setVisible(false); + soundDirector.playSelect(); + this.renderDialogueLine(); + } + + private renderDialogueLine() { + const dialogue = this.dialogueState; + if (!dialogue) { + return; + } + const line = dialogue.lines[dialogue.lineIndex]; + this.dialogueNameText?.setText(line.speaker); + this.dialogueBodyText?.setText(line.text); + this.dialogueProgressText?.setText( + `${dialogue.lineIndex + 1} / ${dialogue.lines.length} E · Space · 클릭으로 계속` + ); + const portraitEntry = this.dialoguePortraitEntry(line); + if ( + this.dialoguePortrait && + portraitEntry && + this.textures.exists(portraitEntry.textureKey) + ) { + this.dialoguePortraitFrame?.setVisible(true); + this.dialoguePortrait + .setTexture(portraitEntry.textureKey) + .setDisplaySize(220, 220) + .setVisible(true); + return; + } + this.dialoguePortraitFrame?.setVisible(false); + this.dialoguePortrait?.setVisible(false); + } + + private dialoguePortraitEntry(line: DialogueLine) { + return line.portraitKey + ? portraitAssetEntryForStoryContext( + line.portraitKey, + portraitContext, + `first-pursuit-dialogue-${line.speaker}` + ) + : undefined; + } + + 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() { + const sourceNpcId = this.dialogueState?.sourceNpcId; + this.dialogueState = undefined; + this.dialoguePanel?.setVisible(false); + this.stopPlayerMovement(); + if (sourceNpcId && sourceNpcId !== 'camp-exit') { + const view = this.actorViews.get(sourceNpcId); + if (view) { + this.restoreActorDirection(view); + } + } + this.refreshInteractionPrompt(); + } + + private openChoicePanel() { + if ( + this.choicePanel || + this.navigationPending || + this.visitComplete() + ) { + return; + } + this.stopPlayerMovement(); + this.promptBackground?.setVisible(false); + this.promptText?.setVisible(false); + const panelBounds = new Phaser.Geom.Rectangle(110, 600, 1700, 370); + const shade = this.add + .rectangle(0, 0, sceneWidth, sceneHeight, 0x05070a, 0.62) + .setOrigin(0) + .setInteractive(); + const panel = this.add + .rectangle( + panelBounds.x, + panelBounds.y, + panelBounds.width, + panelBounds.height, + 0x151c26, + 0.995 + ) + .setOrigin(0) + .setStrokeStyle(3, 0xd8b15f, 0.94); + const title = this.add.text( + panelBounds.x + 40, + panelBounds.y + 38, + firstPursuitScoutVisitDefinition.title, + { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '31px', + color: '#f1d691', + fontStyle: 'bold' + } + ); + const hint = this.add.text( + panelBounds.x + 40, + panelBounds.y + 84, + '먼저 확인할 길을 선택하세요. 결정과 보상은 저장되며 두 번째 전투의 배치 정보로 이어집니다.', + { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '19px', + color: '#aeb8c4' + } + ); + const objects: Phaser.GameObjects.GameObject[] = [ + shade, + panel, + title, + hint + ]; + this.choiceViews = firstPursuitScoutVisitDefinition.choices.map( + (choice, index) => { + const x = panelBounds.x + 40 + index * 820; + const y = panelBounds.y + 140; + const width = 790; + const button = this.add + .rectangle(x, y, width, 170, 0x1d2936, 0.98) + .setOrigin(0) + .setStrokeStyle(2, 0xd8b15f, 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 + 91, + this.choiceRewardText(choice), + { + 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.chooseScout(choice)); + objects.push(button, number, label, reward); + return { choice, button }; + } + ); + const cancel = this.add + .text( + panelBounds.right - 34, + panelBounds.bottom - 24, + '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); + this.choicePanelBounds = panelBounds; + this.choiceReadyAt = this.time.now + inputCarryoverGuardMs; + } + + private chooseScoutByIndex(index: number) { + if ( + !this.choicePanel || + this.navigationPending || + this.time.now < this.choiceReadyAt + ) { + return; + } + const choice = firstPursuitScoutVisitDefinition.choices[index]; + if (choice) { + this.chooseScout(choice); + } + } + + private chooseScout(choice: FirstPursuitScoutVisitChoice) { + if ( + this.navigationPending || + (this.choicePanel && this.time.now < this.choiceReadyAt) + ) { + return; + } + const result = completeFirstPursuitScoutVisit(choice.id); + if (!result.ok) { + this.closeChoicePanel(); + const message = + result.reason === 'already-completed' + ? '이미 정찰 방향을 기록했습니다.' + : result.reason === 'invalid-choice' + ? '선택할 수 없는 정찰 방향입니다.' + : '정찰 기록을 저장하지 못했습니다.'; + this.showCompletionToast(message, 'error'); + this.refreshObjectiveHud(); + this.refreshActorMarkers(); + return; + } + + this.inputReadyAt = this.time.now + inputCarryoverGuardMs; + this.closeChoicePanel(); + this.lastNotice = `정찰 완료 · ${result.choice.label}`; + soundDirector.playEffect('bond-resonance', { + volume: 0.28, + stopAfterMs: 1100 + }); + this.refreshObjectiveHud(); + this.refreshActorMarkers(); + this.startDialogue( + [ + { + speaker: '간옹', + portraitKey: 'jian-yong-campaign', + text: result.choice.response.replace(/^간옹:\s*/, '') + }, + { + speaker: '정찰 기록', + text: `${result.choice.label} · 공명 +${result.choice.bondExp} · ${result.choice.itemRewards.join(' · ')}` + } + ], + () => + this.showCompletionToast( + '정찰 기록이 출진 준비와 다음 전투에 반영됩니다.' + ), + 'jian-yong' + ); + } + + private closeChoicePanel() { + this.choicePanel?.destroy(); + this.choicePanel = undefined; + this.choicePanelBounds = undefined; + this.choiceViews = []; + this.choiceReadyAt = Number.POSITIVE_INFINITY; + this.refreshInteractionPrompt(); + } + + private choiceRewardText(choice: FirstPursuitScoutVisitChoice) { + return `공명 +${choice.bondExp} · ${choice.itemRewards.join(' · ')}`; + } + + private interactWithExit() { + if (!this.visitComplete()) { + this.startDialogue( + [ + { + speaker: '유비', + portraitKey: 'liu-bei-yellow-turban', + text: '간옹과 정찰 방향을 아직 정하지 않았다. 이번에는 군영 장부로 돌아가되 출진 전 다시 들를 수 있다.' + }, + { + speaker: '군영 기록', + text: '정찰은 선택 활동입니다. 진행하지 않아도 군영으로 돌아갈 수 있습니다.' + } + ], + () => this.returnToCamp(), + 'camp-exit' + ); + return; + } + this.returnToCamp(); + } + + private returnToCamp() { + if (this.navigationPending) { + return; + } + this.navigationPending = true; + this.stopPlayerMovement(); + this.showTransitionOverlay(); + this.time.delayedCall(transitionFadeDurationMs, () => { + if (!this.sys.isActive()) { + return; + } + void startGameScene(this, 'CampScene').catch((error) => { + console.error('Failed to return from camp exploration.', error); + this.navigationPending = false; + this.transitionOverlay?.destroy(); + this.transitionOverlay = undefined; + this.showCompletionToast( + '군영 장부로 돌아가지 못했습니다. 잠시 후 다시 시도하세요.', + 'error' + ); + this.refreshInteractionPrompt(); + }); + }); + } + + private showTransitionOverlay() { + const shade = this.add + .rectangle(0, 0, sceneWidth, sceneHeight, 0x080b0f, 0.82) + .setOrigin(0); + const title = this.add + .text( + sceneWidth / 2, + sceneHeight / 2 - 32, + '탁현 의용군 막사 기록', + { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '44px', + color: '#f3dda2', + fontStyle: 'bold' + } + ) + .setOrigin(0.5); + const subtitle = this.add + .text( + sceneWidth / 2, + sceneHeight / 2 + 44, + this.visitComplete() + ? '간옹의 정찰 기록을 출진 장부에 올립니다.' + : '정찰 활동을 남겨 두고 군영 장부로 돌아갑니다.', + { + 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: transitionFadeDurationMs, + ease: 'Sine.easeOut' + }); + } + + private refreshObjectiveHud() { + const campaign = getCampaignState(); + const completed = this.visitComplete(campaign); + const choiceId = + campaign.campVisitChoiceIds[firstPursuitScoutVisitId]; + const choice = choiceId + ? getFirstPursuitScoutVisitChoice(choiceId) + : undefined; + this.objectiveStatus + ?.setText(completed ? '✓ 정찰 기록 완료' : '◆ 진행 가능') + .setColor(completed ? '#a8d58e' : '#e1bc69'); + this.objectiveCard?.setStrokeStyle( + 2, + completed ? 0x628653 : 0xd8b15f, + completed ? 0.9 : 0.76 + ); + this.progressText?.setText( + completed + ? `현지 준비 완료\n${choice?.label ?? firstPursuitScoutVisitDefinition.title}\n\n확보한 정보는 출진 준비와 두 번째 전투 배치 단계에서 확인할 수 있습니다.` + : '현지 준비 0 / 1\n\n정찰 작전판 곁의 간옹에게 다가가 두 갈래 추격로 중 먼저 확인할 길을 정하세요.' + ); + } + + private refreshActorMarkers() { + const completed = this.visitComplete(); + this.actorViews.forEach(({ definition, marker }) => { + if (definition.kind !== 'objective') { + marker.setText('…'); + return; + } + marker.setText(completed ? '✓' : '!'); + marker.setColor(completed ? '#e5f2d5' : '#2a2013'); + marker.setBackgroundColor(completed ? '#4e7549' : '#e5bd68'); + }); + } + + private refreshInteractionPrompt() { + if ( + !this.ready || + this.dialogueState || + this.choicePanel || + 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, + kind: 'success' | 'error' | 'guide' = 'success', + duration = 1500 + ) { + this.completionToast?.destroy(); + this.lastNotice = label; + const success = kind === 'success'; + const guide = kind === 'guide'; + const background = this.add + .rectangle( + 748, + 132, + 610, + 72, + success ? 0x17251c : guide ? 0x2c281b : 0x38201f, + 0.98 + ) + .setStrokeStyle( + 2, + success ? 0x8fbd72 : guide ? 0xd8b15f : 0xb36f62, + 0.92 + ); + const text = this.add + .text(748, 132, `${success ? '✓' : guide ? '◆' : '!'} ${label}`, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '22px', + color: success ? '#dff0c9' : guide ? '#f3dda2' : '#f2cbc2', + fontStyle: 'bold', + wordWrap: { width: 560, 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: duration, + 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(): InteractionTarget[] { + return [ + ...Array.from(this.actorViews.values()).map( + ({ definition, sprite }) => ({ + kind: 'actor' as const, + id: definition.id, + name: + definition.kind === 'objective' + ? `${definition.name}과 정찰로 상의` + : `${definition.name}와 대화`, + x: sprite.x, + y: sprite.y + }) + ), + { + kind: 'exit', + ...campExit + } + ]; + } + + private interactionTargetById(targetId: string) { + return this.allInteractionTargets().find( + (target) => target.id === targetId + ); + } + + private visitComplete(campaign = getCampaignState()) { + return campaign.completedCampVisits.includes( + firstPursuitScoutVisitId + ); + } + + 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 releasePresentationTextures() { + this.ownedPresentationTextureKeys.forEach((textureKey) => { + releaseTextureSource(this, textureKey); + }); + this.ownedPresentationTextureKeys.clear(); + } + + 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/lazyScenes.ts b/src/game/scenes/lazyScenes.ts index 20bff74..ef69092 100644 --- a/src/game/scenes/lazyScenes.ts +++ b/src/game/scenes/lazyScenes.ts @@ -5,6 +5,7 @@ export type LazySceneKey = | 'PrologueVillageScene' | 'PrologueMilitiaCampScene' | 'CityStayScene' + | 'CampVisitExplorationScene' | 'BattleScene' | 'CampScene' | 'EndingScene'; @@ -16,6 +17,8 @@ const lazySceneLoaders: Record Promise PrologueVillageScene: () => import('./PrologueVillageScene').then((module) => module.PrologueVillageScene), PrologueMilitiaCampScene: () => import('./PrologueMilitiaCampScene').then((module) => module.PrologueMilitiaCampScene), CityStayScene: () => import('./CityStayScene').then((module) => module.CityStayScene), + CampVisitExplorationScene: () => + import('./CampVisitExplorationScene').then((module) => module.CampVisitExplorationScene), BattleScene: () => import('./BattleScene').then((module) => module.BattleScene), CampScene: () => import('./CampScene').then((module) => module.CampScene), EndingScene: () => import('./EndingScene').then((module) => module.EndingScene) @@ -29,6 +32,7 @@ export function isLazySceneKey(key: string): key is LazySceneKey { key === 'PrologueVillageScene' || key === 'PrologueMilitiaCampScene' || key === 'CityStayScene' || + key === 'CampVisitExplorationScene' || key === 'BattleScene' || key === 'CampScene' || key === 'EndingScene' diff --git a/src/game/state/firstPursuitScoutActions.ts b/src/game/state/firstPursuitScoutActions.ts new file mode 100644 index 0000000..7359d9e --- /dev/null +++ b/src/game/state/firstPursuitScoutActions.ts @@ -0,0 +1,91 @@ +import { + firstPursuitScoutSourceBattleId, + firstPursuitScoutVisitId +} from '../data/firstPursuitScoutMemory'; +import { + firstPursuitScoutVisitDefinition, + getFirstPursuitScoutVisitChoice, + type FirstPursuitScoutVisitChoice +} from '../data/firstPursuitScoutVisit'; +import { + applyCampVisitReward, + getCampaignState, + setCampaignState, + type CampaignState +} from './campaignState'; + +type FirstPursuitScoutActionFailure = + | 'invalid-campaign' + | 'invalid-choice' + | 'already-completed' + | 'save-unavailable'; + +export type FirstPursuitScoutActionResult = + | { + ok: true; + choice: FirstPursuitScoutVisitChoice; + campaign: CampaignState; + } + | { + ok: false; + reason: FirstPursuitScoutActionFailure; + }; + +export function completeFirstPursuitScoutVisit( + choiceId: string +): FirstPursuitScoutActionResult { + let campaign: CampaignState; + try { + campaign = getCampaignState(); + } catch { + return { ok: false, reason: 'save-unavailable' }; + } + const settlement = campaign.battleHistory[firstPursuitScoutSourceBattleId]; + const report = campaign.firstBattleReport; + if ( + campaign.step !== 'first-camp' || + campaign.latestBattleId !== firstPursuitScoutSourceBattleId || + settlement?.outcome !== 'victory' || + report?.battleId !== firstPursuitScoutSourceBattleId || + report.outcome !== 'victory' + ) { + return { ok: false, reason: 'invalid-campaign' }; + } + + const choice = getFirstPursuitScoutVisitChoice(choiceId); + if (!choice) { + return { ok: false, reason: 'invalid-choice' }; + } + if (campaign.completedCampVisits.includes(firstPursuitScoutVisitId)) { + return { ok: false, reason: 'already-completed' }; + } + + let updated: CampaignState | undefined; + try { + updated = applyCampVisitReward( + firstPursuitScoutVisitId, + { + bondId: firstPursuitScoutVisitDefinition.bondId, + bondExp: choice.bondExp, + itemRewards: [...choice.itemRewards] + }, + choice.id + ); + } catch { + try { + setCampaignState(campaign); + } catch { + // setCampaignState restores the in-memory snapshot before persistence. + } + return { ok: false, reason: 'save-unavailable' }; + } + if (!updated) { + return { ok: false, reason: 'save-unavailable' }; + } + + return { + ok: true, + choice, + campaign: updated + }; +} diff --git a/src/main.ts b/src/main.ts index f390ca0..cb515de 100644 --- a/src/main.ts +++ b/src/main.ts @@ -47,6 +47,10 @@ type DebugCityStayScene = Phaser.Scene & { debugInteractWith?: (targetIdOrKind: string) => boolean; }; +type DebugCampVisitExplorationScene = Phaser.Scene & { + getDebugState?: () => unknown; +}; + type HerosDebugApi = { game: Phaser.Game; activeScenes: () => string[]; @@ -56,11 +60,13 @@ type HerosDebugApi = { village: () => unknown; militiaCamp: () => unknown; cityStay: () => unknown; + campVisitExploration: () => unknown; goToBattle: (battleId?: string) => Promise; goToCamp: () => Promise; goToVillage: () => Promise; goToMilitiaCamp: () => Promise; goToCityStay: (cityStayId?: CityStayId) => Promise; + goToCampVisitExploration: (visitId?: string) => Promise; forceBattleOutcome: (outcome: 'victory' | 'defeat') => void; scene: (key: string) => Phaser.Scene | undefined; toggleBattleOverlay: () => void; @@ -114,6 +120,8 @@ function createDebugApi(game: Phaser.Game): HerosDebugApi { const villageScene = () => scene('PrologueVillageScene') as DebugVillageScene | undefined; const militiaCampScene = () => scene('PrologueMilitiaCampScene') as DebugMilitiaCampScene | undefined; const cityStayScene = () => scene('CityStayScene') as DebugCityStayScene | undefined; + const campVisitExplorationScene = () => + scene('CampVisitExplorationScene') as DebugCampVisitExplorationScene | undefined; return { game, @@ -137,6 +145,12 @@ function createDebugApi(game: Phaser.Game): HerosDebugApi { cityStay: () => game.scene.isActive('CityStayScene') ? cityStayScene()?.getDebugState?.() ?? { active: false, reason: 'CityStayScene debug state is unavailable.' } : { active: false, reason: 'CityStayScene is not active yet.' }, + campVisitExploration: () => game.scene.isActive('CampVisitExplorationScene') + ? campVisitExplorationScene()?.getDebugState?.() ?? { + active: false, + reason: 'CampVisitExplorationScene debug state is unavailable.' + } + : { active: false, reason: 'CampVisitExplorationScene is not active yet.' }, goToBattle: (battleId) => startLazySceneFromGame(game, 'BattleScene', battleId ? { battleId } : undefined), goToCamp: () => startLazySceneFromGame(game, 'CampScene'), goToVillage: () => startLazySceneFromGame(game, 'PrologueVillageScene'), @@ -146,6 +160,11 @@ function createDebugApi(game: Phaser.Game): HerosDebugApi { 'CityStayScene', cityStayId ? { cityStayId } : undefined ), + goToCampVisitExploration: (visitId = 'first-pursuit-scout-tent') => startLazySceneFromGame( + game, + 'CampVisitExplorationScene', + { visitId } + ), forceBattleOutcome: (outcome) => { battleScene()?.debugForceBattleOutcome?.(outcome); },