From 9beed0ac02ba397e746cc40f084e628940039825 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Sun, 26 Jul 2026 23:20:47 +0900 Subject: [PATCH] feat: add playable first-battle militia camp --- scripts/measure-performance.mjs | 17 +- .../verify-campaign-presentation-profiles.mjs | 3 + scripts/verify-flow.mjs | 15 + scripts/verify-prologue-village-browser.mjs | 328 +++- scripts/verify-prologue-village-data.mjs | 240 ++- scripts/verify-release-candidate.mjs | 54 +- scripts/verify-save-retry-flow.mjs | 12 + src/game/data/battles.ts | 2 +- src/game/data/prologueMilitiaCamp.ts | 233 +++ src/game/data/prologueVillage.ts | 117 +- src/game/data/scenario.ts | 54 +- src/game/scenes/BattleScene.ts | 1 + src/game/scenes/PrologueMilitiaCampScene.ts | 1348 +++++++++++++++++ src/game/scenes/PrologueVillageScene.ts | 156 +- src/game/scenes/StoryScene.ts | 10 + src/game/scenes/TitleScene.ts | 23 +- src/game/scenes/lazyScenes.ts | 3 + src/game/state/campaignRouting.ts | 2 +- src/game/state/campaignState.ts | 19 +- src/main.ts | 18 + 20 files changed, 2477 insertions(+), 178 deletions(-) create mode 100644 src/game/data/prologueMilitiaCamp.ts create mode 100644 src/game/scenes/PrologueMilitiaCampScene.ts diff --git a/scripts/measure-performance.mjs b/scripts/measure-performance.mjs index 7e241c6..22626dd 100644 --- a/scripts/measure-performance.mjs +++ b/scripts/measure-performance.mjs @@ -332,7 +332,8 @@ async function advanceStoryUntilBattle(page, battleId) { storyLastPage: story?.isLastPage === true, storyTargetsBattle: story?.nextScene === 'BattleScene', currentPageReady: story?.assetStreaming?.currentPageReady === true, - villageReady: window.__HEROS_DEBUG__?.village?.()?.ready === true + villageReady: window.__HEROS_DEBUG__?.village?.()?.ready === true, + militiaCampReady: window.__HEROS_DEBUG__?.militiaCamp?.()?.ready === true }; } catch { return {}; @@ -355,7 +356,19 @@ async function advanceStoryUntilBattle(page, battleId) { return scene?.debugCompleteVillage?.() ?? false; }); if (!advanced) { - throw new Error('Playable prologue village was ready but could not advance to the departure story.'); + throw new Error('Playable prologue village was ready but could not advance to the brotherhood story.'); + } + await page.waitForTimeout(400); + continue; + } + + if (state.militiaCampReady) { + const advanced = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('PrologueMilitiaCampScene'); + return scene?.debugCompleteCamp?.() ?? false; + }); + if (!advanced) { + throw new Error('Playable prologue militia camp was ready but could not advance to the departure story.'); } await page.waitForTimeout(400); continue; diff --git a/scripts/verify-campaign-presentation-profiles.mjs b/scripts/verify-campaign-presentation-profiles.mjs index 89c205f..618596f 100644 --- a/scripts/verify-campaign-presentation-profiles.mjs +++ b/scripts/verify-campaign-presentation-profiles.mjs @@ -373,6 +373,9 @@ try { [ "nextScene: 'PrologueVillageScene'", "campaign.step === 'prologue-town'", + 'pages: prologueBrotherhoodPages()', + "nextScene: 'PrologueMilitiaCampScene'", + "campaign.step === 'prologue-camp'", 'pages: prologueDeparturePages()', "nextScene: 'BattleScene'", "presentationBattleId: 'first-battle-zhuo-commandery'", diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs index df69266..0ae0a93 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -486,6 +486,21 @@ try { continue; } + const militiaCampAdvanced = await page.evaluate(() => { + const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; + if (!activeScenes.includes('PrologueMilitiaCampScene')) { + return false; + } + const scene = window.__HEROS_GAME__?.scene.getScene('PrologueMilitiaCampScene'); + return scene?.getDebugState?.()?.ready === true + ? scene?.debugCompleteCamp?.() ?? false + : false; + }); + if (militiaCampAdvanced) { + await page.waitForTimeout(320); + continue; + } + await page.keyboard.press('Space'); await page.waitForTimeout(220); } diff --git a/scripts/verify-prologue-village-browser.mjs b/scripts/verify-prologue-village-browser.mjs index bcc10eb..84a9a41 100644 --- a/scripts/verify-prologue-village-browser.mjs +++ b/scripts/verify-prologue-village-browser.mjs @@ -95,6 +95,22 @@ try { await page.waitForTimeout(120); assert.equal((await readVillage(page)).dialogue.active, false, 'Interaction outside the radius must not open dialogue.'); + await teleportTo(page, 'guan-yu'); + await page.keyboard.press('e'); + await page.waitForFunction(() => ( + window.__HEROS_DEBUG__?.village?.()?.dialogue?.sourceNpcId === 'guan-yu' + )); + assert.equal( + (await readVillage(page)).dialogue.totalLines, + 1, + 'Guan Yu must remain a locked encounter until Liu Bei has met Zhang Fei.' + ); + await advanceDialogueUntilClosed(page); + assert( + !(await readVillage(page)).completedObjectiveIds.includes('meet-guan-yu'), + 'A locked Guan Yu interaction must not complete its objective.' + ); + await teleportTo(page, 'zhang-fei'); await page.keyboard.press('e'); await page.waitForFunction(() => window.__HEROS_DEBUG__?.village?.()?.dialogue?.sourceNpcId === 'zhang-fei'); @@ -134,7 +150,23 @@ try { assert(village.completedObjectiveIds.includes('meet-zhang-fei'), 'A completed NPC goal must survive reload and Continue.'); assert.equal(village.dialogue.active, false, 'The one-time orientation line must not replay on Continue.'); - for (const npcId of ['guan-yu', 'quartermaster']) { + await teleportTo(page, 'recruiting-clerk'); + await page.keyboard.press('e'); + await page.waitForFunction(() => ( + window.__HEROS_DEBUG__?.village?.()?.dialogue?.sourceNpcId === 'recruiting-clerk' + )); + assert.equal( + (await readVillage(page)).dialogue.totalLines, + 1, + 'The recruiting clerk must remain locked until Guan Yu has joined.' + ); + await advanceDialogueUntilClosed(page); + assert( + !(await readVillage(page)).completedObjectiveIds.includes('register-volunteers'), + 'A locked recruiting interaction must not complete its objective.' + ); + + for (const npcId of ['guan-yu', 'recruiting-clerk']) { await teleportTo(page, npcId); await page.keyboard.press('e'); await page.waitForFunction((expectedNpcId) => ( @@ -146,7 +178,7 @@ try { village = await readVillage(page); assert.deepEqual( [...village.completedObjectiveIds].sort(), - ['check-supplies', 'meet-guan-yu', 'meet-zhang-fei'].sort() + ['register-volunteers', 'meet-guan-yu', 'meet-zhang-fei'].sort() ); assert.equal(village.exitUnlocked, true); assert.equal(village.oath.visible, true); @@ -155,26 +187,186 @@ try { await teleportTo(page, 'make-oath'); await page.keyboard.press('e'); await page.waitForFunction(() => window.__HEROS_DEBUG__?.village?.()?.dialogue?.sourceNpcId === 'make-oath'); - assert.equal((await readVillage(page)).dialogue.totalLines, 6); + assert.equal((await readVillage(page)).dialogue.totalLines, 4); await advanceDialogueUntilClosed(page); await waitForStoryReady(page); + const brotherhood = await readStory(page); + assert.equal(brotherhood.totalPages, 1); + assert.equal(brotherhood.currentPageId, 'militia-rises'); + assert.equal(brotherhood.nextScene, 'PrologueMilitiaCampScene'); + assert.equal(brotherhood.advanceHint, 'militia-camp-exploration'); + assert.equal(brotherhood.advanceLabel, '의용군 막사로'); + const savedBrotherhood = await readCampaignSave(page); + assert.equal( + savedBrotherhood.step, + 'prologue-town', + 'The campaign should remain resumable at the brotherhood bridge until the camp starts.' + ); + assert(savedBrotherhood.completedTutorialIds.includes('prologue-village-complete')); + assert(!savedBrotherhood.completedTutorialIds.includes('prologue-camp-entered')); + + await page.keyboard.press('Space'); + await waitForMilitiaCampReady(page); + await page.waitForTimeout(380); + + let militiaCamp = await readMilitiaCamp(page); + assert.equal(militiaCamp.campaignStep, 'prologue-camp'); + assert.equal(militiaCamp.requiredTexturesReady, true); + assert.equal(militiaCamp.viewport.width, desktopBrowserViewport.width); + assert.equal(militiaCamp.viewport.height, desktopBrowserViewport.height); + assert.equal(militiaCamp.movement.speed, 300); + assert.equal(militiaCamp.interaction.radius, 122); + assert.equal(militiaCamp.npcs.length, 5); + assert.equal(militiaCamp.objectives.length, 4); + assert.equal(militiaCamp.completedObjectiveIds.length, 0); + assert.equal(militiaCamp.exitUnlocked, false); + assert.equal( + militiaCamp.dialogue.active, + true, + 'The first camp visit should explain why Liu Bei must inspect the camp.' + ); + assertBoundsInsideViewport(militiaCamp.player.bounds, 'initial militia camp player'); + assertBoundsInsideViewport(militiaCamp.dialogue.bounds, 'initial militia camp dialogue'); + militiaCamp.npcs.forEach((npc) => assertBoundsInsideViewport(npc.bounds, `camp NPC ${npc.id}`)); + await advanceMilitiaCampDialogueUntilClosed(page); + await captureStableScreenshot(page, 'dist/verification-prologue-militia-camp-initial.png'); + + await page.keyboard.press('e'); + await page.waitForTimeout(120); + assert.equal( + (await readMilitiaCamp(page)).dialogue.active, + false, + 'Militia camp interaction outside the radius must not open dialogue.' + ); + + const campPlayerBeforeMove = (await readMilitiaCamp(page)).player; + await page.keyboard.down('ArrowUp'); + await page.waitForTimeout(360); + await page.keyboard.up('ArrowUp'); + await page.keyboard.down('ArrowLeft'); + await page.waitForTimeout(980); + await page.keyboard.up('ArrowLeft'); + await page.waitForTimeout(80); + const campAfterMove = await readMilitiaCamp(page); + assert( + campPlayerBeforeMove.y - campAfterMove.player.y >= 70 && + campPlayerBeforeMove.x - campAfterMove.player.x >= 220, + `Expected keyboard traversal through the militia camp: ${JSON.stringify({ + before: campPlayerBeforeMove, + after: campAfterMove.player + })}` + ); + assert.equal( + campAfterMove.interaction.targetId, + 'quartermaster', + `Expected keyboard traversal to reach the quartermaster: ${JSON.stringify(campAfterMove.interaction)}` + ); + assert.equal(campAfterMove.interaction.canInteract, true); + assertBoundsInsideViewport(campAfterMove.interaction.promptBounds, 'militia camp interaction prompt'); + + await page.keyboard.press('e'); + await page.waitForFunction(() => ( + window.__HEROS_DEBUG__?.militiaCamp?.()?.dialogue?.sourceNpcId === 'quartermaster' + )); + const campDialoguePlayerBefore = (await readMilitiaCamp(page)).player; + assertBoundsInsideViewport( + (await readMilitiaCamp(page)).dialogue.bounds, + 'quartermaster dialogue' + ); + await page.keyboard.down('ArrowRight'); + await page.waitForTimeout(360); + await page.keyboard.up('ArrowRight'); + const campDialoguePlayerAfter = (await readMilitiaCamp(page)).player; + assert( + Math.abs(campDialoguePlayerAfter.x - campDialoguePlayerBefore.x) < 1 && + Math.abs(campDialoguePlayerAfter.y - campDialoguePlayerBefore.y) < 1, + 'Militia camp movement must remain blocked while dialogue is open.' + ); + await advanceMilitiaCampDialogueUntilClosed(page); + militiaCamp = await readMilitiaCamp(page); + assert(militiaCamp.completedObjectiveIds.includes('inspect-arms')); + + await teleportMilitiaCampTo(page, 'zou-jing'); + await page.keyboard.press('e'); + await page.waitForFunction(() => ( + window.__HEROS_DEBUG__?.militiaCamp?.()?.dialogue?.sourceNpcId === 'zou-jing' + )); + assert.equal((await readMilitiaCamp(page)).dialogue.totalLines, 1); + await advanceMilitiaCampDialogueUntilClosed(page); + assert.equal( + (await readMilitiaCamp(page)).exitUnlocked, + false, + 'Zou Jing must not release the sortie before every camp inspection is complete.' + ); + + await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 }); + await waitForDebugApi(page); + await page.waitForFunction(() => window.__HEROS_DEBUG__?.activeScenes?.().includes('TitleScene')); + await clickLegacyTitleControl(page, 962, 310); + await waitForMilitiaCampReady(page); + await page.waitForTimeout(380); + militiaCamp = await readMilitiaCamp(page); + assert( + militiaCamp.completedObjectiveIds.includes('inspect-arms'), + 'A completed militia camp inspection must survive reload and Continue.' + ); + assert.equal( + militiaCamp.dialogue.active, + false, + 'The one-time militia camp orientation must not replay on Continue.' + ); + + await interactWithMilitiaCampNpc(page, 'guan-yu'); + await interactWithMilitiaCampNpc(page, 'zhang-fei'); + militiaCamp = await readMilitiaCamp(page); + assert.deepEqual( + [...militiaCamp.completedObjectiveIds].sort(), + ['inspect-arms', 'ready-vanguard', 'review-scout-report'].sort() + ); + assert.equal(militiaCamp.exitUnlocked, true); + await captureStableScreenshot(page, 'dist/verification-prologue-militia-camp-ready.png'); + + await teleportMilitiaCampTo(page, 'zou-jing'); + await page.keyboard.press('e'); + await page.waitForFunction(() => ( + window.__HEROS_DEBUG__?.militiaCamp?.()?.dialogue?.sourceNpcId === 'zou-jing' + )); + const commandCamp = await readMilitiaCamp(page); + assert.equal(commandCamp.dialogue.totalLines, 5); + assert.deepEqual( + commandCamp.npcs + .filter((npc) => ['guan-yu', 'zhang-fei'].includes(npc.id)) + .map((npc) => ({ id: npc.id, x: npc.x, y: npc.y })), + [ + { id: 'guan-yu', x: 650, y: 420 }, + { id: 'zhang-fei', x: 890, y: 420 } + ], + 'Guan Yu and Zhang Fei should visibly gather at the command tent before speaking.' + ); + assertBoundsInsideViewport(commandCamp.dialogue.bounds, 'final command dialogue'); + await captureStableScreenshot(page, 'dist/verification-prologue-militia-camp-command.png'); + await advanceMilitiaCampDialogueUntilClosed(page); + await waitForStoryReady(page); + const departure = await readStory(page); - assert.equal(departure.totalPages, 4); + assert.equal(departure.totalPages, 2); assert.equal(departure.currentPageId, 'first-sortie'); assert.equal(departure.nextScene, 'BattleScene'); const savedDeparture = await readCampaignSave(page); - assert.equal(savedDeparture.step, 'prologue-town', 'The campaign should remain resumable in departure story until BattleScene starts.'); - assert(savedDeparture.completedTutorialIds.includes('prologue-village-complete')); + assert.equal( + savedDeparture.step, + 'prologue-camp', + 'The campaign should remain resumable at the departure story until BattleScene starts.' + ); + assert(savedDeparture.completedTutorialIds.includes('prologue-camp-complete')); assert(!savedDeparture.completedTutorialIds.includes('first-battle-basic-controls')); - for (let pageIndex = 1; pageIndex < 4; pageIndex += 1) { - await page.mouse.click(960, 850); - await page.waitForFunction((expectedPageIndex) => { - const scene = window.__HEROS_DEBUG__?.scene('StoryScene'); - return scene?.getDebugState?.()?.pageIndex === expectedPageIndex && scene?.transitioning === false; - }, pageIndex); - } + await page.mouse.click(960, 850); + await page.waitForFunction(() => { + const scene = window.__HEROS_DEBUG__?.scene('StoryScene'); + return scene?.getDebugState?.()?.pageIndex === 1 && scene?.transitioning === false; + }); await page.keyboard.press('Space'); await page.waitForFunction(() => { const battle = window.__HEROS_DEBUG__?.battle?.(); @@ -189,6 +381,20 @@ try { assert.equal(battleSave.step, 'first-battle'); assert(!battleSave.completedTutorialIds.includes('first-battle-basic-controls')); + await seedLegacyCompletedVillageSave(page, battleSave); + await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 }); + await waitForDebugApi(page); + await page.waitForFunction(() => window.__HEROS_DEBUG__?.activeScenes?.().includes('TitleScene')); + await clickLegacyTitleControl(page, 962, 310); + await waitForStoryReady(page); + const legacyBridge = await readStory(page); + assert.equal(legacyBridge.currentPageId, 'militia-rises'); + assert.equal(legacyBridge.nextScene, 'PrologueMilitiaCampScene'); + const normalizedLegacySave = await readCampaignSave(page); + assert.equal(normalizedLegacySave.step, 'prologue-town'); + assert(normalizedLegacySave.completedTutorialIds.includes('prologue-village-check-supplies')); + assert(normalizedLegacySave.completedTutorialIds.includes('prologue-village-complete')); + 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')), @@ -197,9 +403,9 @@ try { ); console.log( - `Verified the playable prologue village at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height}, ` + - `DPR ${desktopBrowserDeviceScaleFactor}: opening handoff, direct movement, distance-gated dialogue, ` + - 'dialogue movement lock, locked oath, objective autosave/reload, final oath, departure story, and first deployment.' + `Verified the playable prologue village and militia camp at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height}, ` + + `DPR ${desktopBrowserDeviceScaleFactor}: sequential meetings, direct movement, distance-gated dialogue, ` + + 'autosave/reload, legacy-save routing, locked oath and command, camp preparation, departure story, and first deployment.' ); } finally { await browser?.close(); @@ -259,6 +465,28 @@ async function waitForVillageReady(page) { } } +async function waitForMilitiaCampReady(page) { + try { + await page.waitForFunction(() => { + const militiaCamp = window.__HEROS_DEBUG__?.militiaCamp?.(); + return ( + window.__HEROS_DEBUG__?.activeScenes?.().includes('PrologueMilitiaCampScene') && + militiaCamp?.scene === 'PrologueMilitiaCampScene' && + militiaCamp?.ready === true + ); + }, undefined, { timeout: 30000 }); + } catch (error) { + const diagnostic = await page.evaluate(() => ({ + activeScenes: window.__HEROS_DEBUG__?.activeScenes?.() ?? [], + militiaCamp: window.__HEROS_DEBUG__?.militiaCamp?.() ?? null, + story: window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.() ?? null + })); + throw new Error(`Playable militia camp did not become ready: ${JSON.stringify(diagnostic)}`, { + cause: error + }); + } +} + async function readStory(page) { return page.evaluate(() => window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.()); } @@ -267,6 +495,10 @@ async function readVillage(page) { return page.evaluate(() => window.__HEROS_DEBUG__?.village?.()); } +async function readMilitiaCamp(page) { + return page.evaluate(() => window.__HEROS_DEBUG__?.militiaCamp?.()); +} + async function teleportTo(page, targetId) { const teleported = await page.evaluate((id) => { const scene = window.__HEROS_GAME__?.scene.getScene('PrologueVillageScene'); @@ -276,6 +508,24 @@ async function teleportTo(page, targetId) { await page.waitForTimeout(80); } +async function teleportMilitiaCampTo(page, targetId) { + const teleported = await page.evaluate((id) => { + const scene = window.__HEROS_GAME__?.scene.getScene('PrologueMilitiaCampScene'); + return scene?.debugTeleportTo?.(id) ?? false; + }, targetId); + assert.equal(teleported, true, `Expected militia camp debug teleport to ${targetId}.`); + await page.waitForTimeout(80); +} + +async function interactWithMilitiaCampNpc(page, npcId) { + await teleportMilitiaCampTo(page, npcId); + await page.keyboard.press('e'); + await page.waitForFunction((expectedNpcId) => ( + window.__HEROS_DEBUG__?.militiaCamp?.()?.dialogue?.sourceNpcId === expectedNpcId + ), npcId); + await advanceMilitiaCampDialogueUntilClosed(page); +} + async function advanceDialogueUntilClosed(page) { for (let attempt = 0; attempt < 12; attempt += 1) { const active = (await readVillage(page))?.dialogue?.active; @@ -288,6 +538,52 @@ async function advanceDialogueUntilClosed(page) { throw new Error(`Dialogue did not close: ${JSON.stringify(await readVillage(page))}`); } +async function advanceMilitiaCampDialogueUntilClosed(page) { + for (let attempt = 0; attempt < 12; attempt += 1) { + const active = (await readMilitiaCamp(page))?.dialogue?.active; + if (!active) { + return; + } + await page.mouse.click(960, 850); + await page.waitForTimeout(130); + } + throw new Error(`Militia camp dialogue did not close: ${JSON.stringify(await readMilitiaCamp(page))}`); +} + +async function seedLegacyCompletedVillageSave(page, sourceState) { + await page.evaluate((state) => { + const legacyState = { + ...state, + updatedAt: new Date().toISOString(), + step: 'prologue-town', + roster: [], + bonds: [], + inventory: {}, + selectedSortieUnitIds: [], + sortieFormationAssignments: {}, + sortieItemAssignments: {}, + battleHistory: {}, + latestBattleId: undefined, + latestBattleReport: undefined, + completedTutorialIds: [ + 'prologue-village-entered', + 'prologue-village-meet-zhang-fei', + 'prologue-village-meet-guan-yu', + 'prologue-village-check-supplies', + 'prologue-village-complete' + ] + }; + const serialized = JSON.stringify(legacyState); + window.localStorage.setItem('heros-web:campaign-state', serialized); + window.localStorage.setItem('heros-web:campaign-state:slot-1', serialized); + for (const key of Object.keys(window.localStorage)) { + if (key.startsWith('heros-web:battle:') || key === 'heros-web:first-battle-state') { + window.localStorage.removeItem(key); + } + } + }, sourceState); +} + async function readCampaignSave(page) { return page.evaluate(() => { const raw = window.localStorage.getItem('heros-web:campaign-state'); diff --git a/scripts/verify-prologue-village-data.mjs b/scripts/verify-prologue-village-data.mjs index 267cef6..d49ccfe 100644 --- a/scripts/verify-prologue-village-data.mjs +++ b/scripts/verify-prologue-village-data.mjs @@ -1,24 +1,34 @@ import { createServer } from 'vite'; const expectedOpeningPageIds = ['yellow-turban-chaos', 'liu-bei-resolve']; -const expectedDeparturePageIds = [ - 'first-sortie', - 'yellow-turban-nearby', - 'first-battle-plan-talk', - 'battle-briefing' -]; +const expectedBrotherhoodPageIds = ['militia-rises']; +const expectedDeparturePageIds = ['first-sortie', 'battle-briefing']; const expectedRequiredObjectiveIds = [ 'meet-zhang-fei', 'meet-guan-yu', - 'check-supplies' + 'register-volunteers' ]; const expectedCampaignMarkerIds = [ 'prologue-village-entered', 'prologue-village-meet-zhang-fei', 'prologue-village-meet-guan-yu', + 'prologue-village-register-volunteers', + // Kept so existing saves made before the narrative rewrite still normalize. 'prologue-village-check-supplies', 'prologue-village-complete' ]; +const expectedCampObjectiveIds = [ + 'review-scout-report', + 'ready-vanguard', + 'inspect-arms' +]; +const expectedCampMarkerIds = [ + 'prologue-camp-entered', + 'prologue-camp-review-scout-report', + 'prologue-camp-ready-vanguard', + 'prologue-camp-inspect-arms', + 'prologue-camp-complete' +]; const server = await createServer({ logLevel: 'error', @@ -28,11 +38,13 @@ const server = await createServer({ try { const village = await server.ssrLoadModule('/src/game/data/prologueVillage.ts'); + const militiaCamp = await server.ssrLoadModule('/src/game/data/prologueMilitiaCamp.ts'); const campaign = await server.ssrLoadModule('/src/game/state/campaignState.ts'); const unitAssets = await server.ssrLoadModule('/src/game/data/unitAssets.ts'); const failures = []; const openingPages = village.prologueOpeningPages(); + const brotherhoodPages = village.prologueBrotherhoodPages(); const departurePages = village.prologueDeparturePages(); assertArrayEquals( openingPages.map((page) => page.id), @@ -40,12 +52,23 @@ try { 'opening story page order', failures ); + assertArrayEquals( + brotherhoodPages.map((page) => page.id), + expectedBrotherhoodPageIds, + 'brotherhood-to-camp story page order', + failures + ); assertArrayEquals( departurePages.map((page) => page.id), expectedDeparturePageIds, 'departure story page order', failures ); + assert( + !JSON.stringify(departurePages).includes('잔당'), + 'the first sortie must introduce the Yellow Turban force before later battles call them remnants', + failures + ); assertArrayEquals( [...village.prologueVillageRequiredObjectiveIds], expectedRequiredObjectiveIds, @@ -65,6 +88,12 @@ try { 'the final objective must be make-oath', failures ); + assertArrayEquals( + objectives.map((objective) => objective.prerequisiteId ?? null), + [null, 'meet-zhang-fei', 'meet-guan-yu', 'register-volunteers'], + 'village objective prerequisite chain', + failures + ); const npcs = village.prologueVillageNpcDefinitions; const objectiveNpcIds = npcs @@ -83,35 +112,23 @@ try { ); assert(npcs.some((npc) => !npc.objectiveId), 'at least one optional villager should be available', failures); - for (const npc of npcs) { - assert( - Number.isFinite(npc.x) && npc.x >= 70 && npc.x <= 1435 && - Number.isFinite(npc.y) && npc.y >= 130 && npc.y <= 900, - `${npc.id}: position must remain inside the FHD village play area`, - failures - ); - assert(unitAssets.hasUnitSheetAsset(npc.textureKey), `${npc.id}: missing ${npc.textureKey}`, failures); - assert(Array.isArray(npc.dialogue) && npc.dialogue.length > 0, `${npc.id}: dialogue is required`, failures); - for (const [lineIndex, line] of npc.dialogue.entries()) { - assert( - typeof line.speaker === 'string' && line.speaker.trim().length > 0, - `${npc.id}[${lineIndex}]: speaker is required`, - failures - ); - assert( - typeof line.text === 'string' && line.text.trim().length >= 8 && line.text.length <= 180, - `${npc.id}[${lineIndex}]: dialogue must be 8-180 characters`, - failures - ); - if (line.textureKey) { - assert( - unitAssets.hasUnitSheetAsset(line.textureKey), - `${npc.id}[${lineIndex}]: missing dialogue sprite ${line.textureKey}`, - failures - ); - } - } - } + verifyNpcs(npcs, 'village', unitAssets, failures); + const zhangFeiMeetingText = dialogueText(npcs.find((npc) => npc.id === 'zhang-fei')); + assert( + zhangFeiMeetingText.includes('모병 격문') && + zhangFeiMeetingText.includes('재산') && + zhangFeiMeetingText.includes('의병'), + 'Zhang Fei meeting must retain the recruitment notice, his means, and the volunteer-army proposal', + failures + ); + const guanYuMeetingText = dialogueText(npcs.find((npc) => npc.id === 'guan-yu')); + assert( + guanYuMeetingText.includes('손수레') && + guanYuMeetingText.includes('군문') && + guanYuMeetingText.includes('하동 해량'), + 'Guan Yu meeting must retain his cart arrival, enlistment journey, and origin', + failures + ); assert( unitAssets.hasUnitSheetAsset('unit-liu-bei'), @@ -130,6 +147,83 @@ try { failures ); + assertArrayEquals( + [...militiaCamp.prologueMilitiaCampRequiredObjectiveIds], + expectedCampObjectiveIds, + 'militia camp required objective order', + failures + ); + const campObjectives = militiaCamp.prologueMilitiaCampObjectiveDefinitions; + assertArrayEquals( + campObjectives.map((objective) => objective.id), + [...expectedCampObjectiveIds, 'receive-command'], + 'militia camp objective rows', + failures + ); + assert( + campObjectives.find((objective) => objective.id === 'review-scout-report')?.label.includes('피난로'), + 'Guan Yu scout objective must describe the evacuation-route information actually reviewed', + failures + ); + const campNpcs = militiaCamp.prologueMilitiaCampNpcDefinitions; + assertArrayEquals( + campNpcs.filter((npc) => npc.objectiveId).map((npc) => npc.objectiveId).sort(), + [...expectedCampObjectiveIds].sort(), + 'militia camp objective NPC coverage', + failures + ); + assert( + campNpcs.filter((npc) => npc.departure).length === 1 && + campNpcs.find((npc) => npc.departure)?.id === 'zou-jing', + 'militia camp must have Zou Jing as the single final command interaction', + failures + ); + assert( + campNpcs.some((npc) => !npc.objectiveId && !npc.departure), + 'militia camp should include at least one optional volunteer conversation', + failures + ); + assert( + !JSON.stringify(campNpcs).includes('피란'), + 'militia camp dialogue must use the established 피난 terminology consistently', + failures + ); + assertArrayEquals( + campNpcs.filter((npc) => dialogueText(npc).includes('후송')).map((npc) => npc.id), + ['quartermaster'], + 'militia camp medical-evacuation responsibility', + failures + ); + verifyNpcs(campNpcs, 'militia camp', unitAssets, failures); + const quartermasterText = dialogueText(campNpcs.find((npc) => npc.id === 'quartermaster')); + assert( + ['장세평', '소쌍', '자웅일대검', '청룡언월도', '장팔사모'].every((term) => ( + quartermasterText.includes(term) + )), + 'quartermaster dialogue must connect the merchant support to all three signature weapons', + failures + ); + assertArrayEquals( + Object.values(campaign.prologueMilitiaCampCampaignTutorialIds), + expectedCampMarkerIds, + 'militia camp campaign progress marker ids', + failures + ); + assert( + expectedCampMarkerIds.every((id) => campaign.campaignTutorialIds.includes(id)), + 'every militia camp marker must survive campaign save normalization', + failures + ); + assert( + campaign.createInitialCampaignState().step === 'new' && + campaign.summarizeCampaignProgress({ + ...campaign.createInitialCampaignState(), + step: 'prologue-camp' + }).title === '탁현 의용군 막사', + 'prologue-camp must be recognized by campaign progress summaries', + failures + ); + if (failures.length > 0) { throw new Error( `Prologue village data verification failed with ${failures.length} issue(s):\n` + @@ -138,8 +232,9 @@ try { } console.log( - `Verified ${npcs.length} village NPCs, ${expectedRequiredObjectiveIds.length} required interactions, ` + - `${openingPages.length} opening pages, ${departurePages.length} departure pages, and save markers.` + `Verified ${npcs.length} village NPCs, ${campNpcs.length} militia camp NPCs, ` + + `${openingPages.length + brotherhoodPages.length + departurePages.length} prologue story pages, ` + + 'sequential meetings, camp preparation, and save markers.' ); } finally { await server.close(); @@ -158,3 +253,72 @@ function assertArrayEquals(actual, expected, label, failures) { failures ); } + +function verifyNpcs(npcs, locationLabel, unitAssets, failures) { + assert( + new Set(npcs.map((npc) => npc.id)).size === npcs.length, + `${locationLabel}: NPC ids must be unique`, + failures + ); + for (const npc of npcs) { + assert( + Number.isFinite(npc.x) && npc.x >= 70 && npc.x <= 1435 && + Number.isFinite(npc.y) && npc.y >= 130 && npc.y <= 900, + `${locationLabel}/${npc.id}: position must remain inside the FHD play area`, + failures + ); + assert( + unitAssets.hasUnitSheetAsset(npc.textureKey), + `${locationLabel}/${npc.id}: missing ${npc.textureKey}`, + failures + ); + verifyDialogueLines(npc.dialogue, `${locationLabel}/${npc.id}/dialogue`, unitAssets, failures); + if (npc.lockedDialogue) { + verifyDialogueLines( + npc.lockedDialogue, + `${locationLabel}/${npc.id}/lockedDialogue`, + unitAssets, + failures + ); + } + if (npc.repeatDialogue) { + verifyDialogueLines( + npc.repeatDialogue, + `${locationLabel}/${npc.id}/repeatDialogue`, + unitAssets, + failures + ); + } + } +} + +function verifyDialogueLines(lines, label, unitAssets, failures) { + assert(Array.isArray(lines) && lines.length > 0, `${label}: dialogue is required`, failures); + for (const [lineIndex, line] of (lines ?? []).entries()) { + assert( + typeof line.speaker === 'string' && line.speaker.trim().length > 0, + `${label}[${lineIndex}]: speaker is required`, + failures + ); + assert( + typeof line.text === 'string' && line.text.trim().length >= 8 && line.text.length <= 180, + `${label}[${lineIndex}]: dialogue must be 8-180 characters`, + failures + ); + if (line.textureKey) { + assert( + unitAssets.hasUnitSheetAsset(line.textureKey), + `${label}[${lineIndex}]: missing dialogue sprite ${line.textureKey}`, + failures + ); + } + } +} + +function dialogueText(npc) { + return [ + ...(npc?.dialogue ?? []), + ...(npc?.lockedDialogue ?? []), + ...(npc?.repeatDialogue ?? []) + ].map((line) => line.text).join(' '); +} diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index 18cb84c..3a72aa1 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -2479,8 +2479,12 @@ try { firstCampProbe.state?.sortieRoster ?.filter((unit) => ['guan-yu', 'zhang-fei', 'jian-yong'].includes(unit.id)).length === 3 && firstCampProbe.state?.sortieRoster - ?.filter((unit) => ['guan-yu', 'zhang-fei', 'jian-yong'].includes(unit.id)) - .every((unit) => unit.serviceHistory?.recentRecruit === true && unit.serviceHistory?.badge?.kind === 'recent-recruit') && + ?.find((unit) => unit.id === 'jian-yong')?.serviceHistory?.recentRecruit === true && + firstCampProbe.state?.sortieRoster + ?.find((unit) => unit.id === 'jian-yong')?.serviceHistory?.badge?.kind === 'recent-recruit' && + firstCampProbe.state?.sortieRoster + ?.filter((unit) => ['guan-yu', 'zhang-fei'].includes(unit.id)) + .every((unit) => unit.serviceHistory?.recentRecruit !== 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)}` ); @@ -8175,14 +8179,40 @@ async function assertFreshStoryAssetStreaming(page, requestedResourceUrls, reque const scene = window.__HEROS_GAME__?.scene.getScene('PrologueVillageScene'); return scene?.debugCompleteVillage?.() ?? false; }); - assert(villageAdvanced, 'Expected the playable prologue village to hand off to the departure story.'); + assert(villageAdvanced, 'Expected the playable prologue village to hand off to the brotherhood story.'); + await waitForStoryReady(page); + + let brotherhoodProbe = await readStoryAssetStreamingProbe(page); + assertStoryAssetStreamingProbe(brotherhoodProbe, 0, 'brotherhood story page 1'); + assert( + brotherhoodProbe.story?.currentPageId === 'militia-rises' && + brotherhoodProbe.story?.totalPages === 1 && + brotherhoodProbe.story?.nextScene === 'PrologueMilitiaCampScene', + `Expected the village to hand off to the militia muster bridge: ${JSON.stringify(brotherhoodProbe)}` + ); + assert( + brotherhoodProbe.story?.currentPageId === 'militia-rises' && + brotherhoodProbe.story?.advanceHint === 'militia-camp-exploration' && + brotherhoodProbe.story?.advanceLabel === '의용군 막사로', + `Expected the brotherhood story to lead into the playable militia camp: ${JSON.stringify(brotherhoodProbe)}` + ); + + await page.keyboard.press('Space'); + await page.waitForFunction(() => ( + window.__HEROS_DEBUG__?.militiaCamp?.()?.ready === true + ), undefined, { timeout: 90000 }); + const militiaCampAdvanced = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('PrologueMilitiaCampScene'); + return scene?.debugCompleteCamp?.() ?? false; + }); + assert(militiaCampAdvanced, 'Expected the playable militia camp to hand off to the departure story.'); await waitForStoryReady(page); let departureProbe = await readStoryAssetStreamingProbe(page); assertStoryAssetStreamingProbe(departureProbe, 0, 'departure story page 1'); assert( departureProbe.story?.currentPageId === 'first-sortie' && - departureProbe.story?.totalPages === 4 && + departureProbe.story?.totalPages === 2 && departureProbe.story?.nextScene === 'BattleScene' && (departureProbe.story?.cutsceneActors?.length ?? 0) > 0 && departureProbe.expectedUnitBaseTextureKeys.length > 0, @@ -8333,7 +8363,8 @@ async function advanceUntilBattle(page, battleId, options = {}) { state?.mapBackgroundReady === true ), story: window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.() ?? null, - village: window.__HEROS_DEBUG__?.village?.() ?? null + village: window.__HEROS_DEBUG__?.village?.() ?? null, + militiaCamp: window.__HEROS_DEBUG__?.militiaCamp?.() ?? null }; }, battleId); @@ -8365,6 +8396,19 @@ async function advanceUntilBattle(page, battleId, options = {}) { continue; } + if (probe.militiaCamp?.ready === true && probe.militiaCamp?.navigationPending !== true) { + const completed = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('PrologueMilitiaCampScene'); + return scene?.debugCompleteCamp?.() ?? false; + }); + assert( + completed, + `Expected the playable militia camp debug completion to advance the release flow: ${JSON.stringify(probe.militiaCamp)}` + ); + await page.waitForTimeout(400); + continue; + } + await page.keyboard.press('Space'); await page.waitForTimeout(220); } diff --git a/scripts/verify-save-retry-flow.mjs b/scripts/verify-save-retry-flow.mjs index 6ec71e7..5a5b134 100644 --- a/scripts/verify-save-retry-flow.mjs +++ b/scripts/verify-save-retry-flow.mjs @@ -284,6 +284,18 @@ async function advanceUntilBattle(page, battleId) { continue; } } + if (activeScenes.includes('PrologueMilitiaCampScene')) { + const advanced = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('PrologueMilitiaCampScene'); + return scene?.getDebugState?.()?.ready === true + ? scene?.debugCompleteCamp?.() ?? false + : false; + }); + if (advanced) { + await page.waitForTimeout(320); + continue; + } + } await page.keyboard.press(activeScenes.includes('TitleScene') ? 'Enter' : 'Space'); await page.waitForTimeout(240); } diff --git a/src/game/data/battles.ts b/src/game/data/battles.ts index 120e660..4c8462c 100644 --- a/src/game/data/battles.ts +++ b/src/game/data/battles.ts @@ -531,7 +531,7 @@ export const firstBattleScenario: BattleScenarioDefinition = { supplies: ['콩 1', '탁주 1'], equipment: ['연습검 1'], reputation: ['의용군 명성 +1'], - recruits: ['guan-yu', 'zhang-fei', 'jian-yong'], + recruits: ['jian-yong'], unlockBattleId: 'second-battle-yellow-turban-pursuit', unlockLabel: '황건 잔당 추격 개방', note: '간옹이 합류합니다. 다음 전투부터 유비·관우·장비·간옹 4명 중 최대 3명을 골라 편성하며, 추천 편성은 세 형제입니다.' diff --git a/src/game/data/prologueMilitiaCamp.ts b/src/game/data/prologueMilitiaCamp.ts new file mode 100644 index 0000000..76856ee --- /dev/null +++ b/src/game/data/prologueMilitiaCamp.ts @@ -0,0 +1,233 @@ +import type { PrologueVillageDialogueLine } from './prologueVillage'; + +export const prologueMilitiaCampId = 'zhuo-volunteer-camp'; + +export const prologueMilitiaCampRequiredObjectiveIds = [ + 'review-scout-report', + 'ready-vanguard', + 'inspect-arms' +] as const; + +export type PrologueMilitiaCampRequiredObjectiveId = + (typeof prologueMilitiaCampRequiredObjectiveIds)[number]; + +export const prologueMilitiaCampObjectiveDefinitions: ReadonlyArray<{ + id: PrologueMilitiaCampRequiredObjectiveId | 'receive-command'; + label: string; + shortLabel: string; + location: string; +}> = [ + { + id: 'review-scout-report', + label: '관우와 북쪽 숲길의 피난로·정찰 상황 확인하기', + shortLabel: '관우의 정찰', + location: '서쪽 작전판' + }, + { + id: 'ready-vanguard', + label: '장비와 의병 선봉의 대열 맞추기', + shortLabel: '장비의 집결', + location: '동쪽 훈련장' + }, + { + id: 'inspect-arms', + label: '군수관에게 병기와 퇴로 보급 확인하기', + shortLabel: '병기·보급 점검', + location: '남서쪽 보급 수레' + }, + { + id: 'receive-command', + label: '추정에게 출전 명령을 받고 북문 열기', + shortLabel: '첫 출전 명령', + location: '북쪽 지휘 막사' + } +]; + +export type PrologueMilitiaCampNpcDefinition = { + id: string; + name: string; + role: string; + textureKey: string; + x: number; + y: number; + direction: 'south' | 'east' | 'north' | 'west'; + objectiveId?: PrologueMilitiaCampRequiredObjectiveId; + departure?: boolean; + dialogue: PrologueVillageDialogueLine[]; + lockedDialogue?: PrologueVillageDialogueLine[]; + repeatDialogue?: PrologueVillageDialogueLine[]; +}; + +export const prologueMilitiaCampNpcDefinitions: readonly PrologueMilitiaCampNpcDefinition[] = [ + { + id: 'guan-yu', + name: '관우', + role: '숲길 정찰 · 중앙 전열', + textureKey: 'unit-guan-yu', + x: 500, + y: 420, + direction: 'east', + objectiveId: 'review-scout-report', + dialogue: [ + { + speaker: '관우', + textureKey: 'unit-guan-yu', + text: '북쪽 숲길을 돌아보았습니다. 피난민의 발자국 사이로 황건 척후가 보였고, 마을로 이어지는 길목마다 감시를 세우고 있었습니다.' + }, + { + speaker: '유비', + textureKey: 'unit-liu-bei', + text: '피난민을 먼저 서쪽 샛길로 들이고 우물과 수레가 있는 길은 비워 둡시다. 정확한 적 진형은 출전 직전 작전판에 옮겨 주시오.' + }, + { + speaker: '관우', + textureKey: 'unit-guan-yu', + text: '보병과 궁병, 두령의 위치를 표식으로 정리하겠습니다. 저는 정찰대를 이끌어 마을 어귀를 계속 살피겠습니다.' + } + ], + repeatDialogue: [ + { + speaker: '관우', + textureKey: 'unit-guan-yu', + text: '적 보병을 한꺼번에 밀지 말고 숲길에서 끊어 내야 합니다. 중앙의 길은 제가 열겠습니다.' + } + ] + }, + { + id: 'zhang-fei', + name: '장비', + role: '의병 집결 · 측면 돌파', + textureKey: 'unit-zhang-fei', + x: 1125, + y: 590, + direction: 'west', + objectiveId: 'ready-vanguard', + dialogue: [ + { + speaker: '장비', + textureKey: 'unit-zhang-fei', + text: '기세는 하늘을 찌르지만 아직 서로 이름도 모르는 이들이 많소. 이대로 달려들면 앞사람이 넘어졌을 때 뒤가 함께 무너질 거요.' + }, + { + speaker: '유비', + textureKey: 'unit-liu-bei', + text: '열 명씩 조를 나누고, 앞줄이 멈추면 뒷줄도 함께 멈추는 구령을 정합시다. 공을 다투느라 대열을 깨서는 안 되오.' + }, + { + speaker: '장비', + textureKey: 'unit-zhang-fei', + text: '좋소! 진군과 정지, 재집결 신호를 몸에 익히게 하겠소. 북문에 모일 때까지 누구도 제멋대로 달려나가지 못하게 하겠소!' + } + ], + repeatDialogue: [ + { + speaker: '장비', + textureKey: 'unit-zhang-fei', + text: '선봉은 열을 맞췄소. 출전 신호가 오면 누구도 홀로 앞서거나 뒤처지지 않게 하겠소!' + } + ] + }, + { + id: 'quartermaster', + name: '의용군 군수관', + role: '병기 · 물 · 상처약', + textureKey: 'unit-shu-officer', + x: 355, + y: 770, + direction: 'east', + objectiveId: 'inspect-arms', + dialogue: [ + { + speaker: '의용군 군수관', + textureKey: 'unit-shu-officer', + text: '중산 상인 장세평과 소쌍이 말과 쇠를 보탰습니다. 대장간에서는 유비 공의 자웅일대검, 관우 공의 청룡언월도, 장비 공의 장팔사모를 벼려 냈습니다.' + }, + { + speaker: '의용군 군수관', + textureKey: 'unit-shu-officer', + text: '장비 공이 장원 창고도 열어 주었습니다. 콩과 마른 곡식, 상처에 쓸 천까지 조마다 나누어 두었습니다.' + }, + { + speaker: '유비', + textureKey: 'unit-liu-bei', + text: '무기보다 물과 퇴로부터 다시 확인해 주시오. 보급은 채울 수 있어도 사람의 목숨은 다시 채울 수 없소.' + }, + { + speaker: '의용군 군수관', + textureKey: 'unit-shu-officer', + text: '명심하겠습니다. 후송 수레는 남쪽 길에 세우고, 각 조에 상처약을 나누겠습니다.' + } + ], + repeatDialogue: [ + { + speaker: '의용군 군수관', + textureKey: 'unit-shu-officer', + text: '병기와 보급, 후송 수레까지 확인했습니다. 지휘 막사에서 출전 명령을 받으십시오.' + } + ] + }, + { + id: 'zou-jing', + name: '군후 추정', + role: '탁현 의병 지휘', + textureKey: 'unit-shu-officer', + x: 770, + y: 340, + direction: 'south', + departure: true, + dialogue: [ + { + speaker: '추정', + textureKey: 'unit-shu-officer', + text: '정찰과 대열, 보급까지 모두 갖추었군. 황건 두령 한석이 북동쪽 높은 길에서 마을을 노리고 있다. 그대들이 의병 선봉을 맡아 주게.' + }, + { + speaker: '관우', + textureKey: 'unit-guan-yu', + text: '맡은 자리를 지키며 형님의 지휘에 따르겠습니다.' + }, + { + speaker: '장비', + textureKey: 'unit-zhang-fei', + text: '의병들이 형님 깃발을 보고 한마음으로 움직이게 하겠소!' + }, + { + speaker: '유비', + textureKey: 'unit-liu-bei', + text: '세 사람의 뜻을 한 깃발 아래 모아 첫 싸움을 책임지겠습니다. 의용군 전원에게 북문 집결을 알리시오.' + }, + { + speaker: '전황', + text: '세 사람이 나란히 막사를 나섰다. 갓 세운 의용군의 첫 깃발이 북문을 향해 움직이기 시작했다.' + } + ], + lockedDialogue: [ + { + speaker: '추정', + textureKey: 'unit-shu-officer', + text: '첫 출전일수록 서로의 역할을 분명히 해야 한다. 관우의 정찰, 장비의 집결, 군수관의 보급을 모두 확인하고 다시 오게.' + } + ] + }, + { + id: 'nervous-volunteer', + name: '젊은 의병', + role: '첫 싸움을 앞둔 병사', + textureKey: 'unit-shu-infantry', + x: 930, + y: 835, + direction: 'west', + dialogue: [ + { + speaker: '젊은 의병', + textureKey: 'unit-shu-infantry', + text: '손에 쥔 창이 자꾸 떨립니다. 그래도 가족이 숨어 있는 마을 어귀만큼은 빼앗기고 싶지 않습니다.' + }, + { + speaker: '유비', + textureKey: 'unit-liu-bei', + text: '두려움을 숨길 필요는 없네. 곁의 동료와 보조를 맞추고 위험하면 신호하게. 혼자 싸우게 두지 않겠네.' + } + ] + } +]; diff --git a/src/game/data/prologueVillage.ts b/src/game/data/prologueVillage.ts index 32f00d5..d0c63e9 100644 --- a/src/game/data/prologueVillage.ts +++ b/src/game/data/prologueVillage.ts @@ -5,7 +5,7 @@ export const prologueVillageId = 'zhuo-prologue'; export const prologueVillageRequiredObjectiveIds = [ 'meet-zhang-fei', 'meet-guan-yu', - 'check-supplies' + 'register-volunteers' ] as const; export type PrologueVillageRequiredObjectiveId = @@ -16,30 +16,34 @@ export const prologueVillageObjectiveDefinitions: ReadonlyArray<{ label: string; shortLabel: string; location: string; + prerequisiteId?: PrologueVillageRequiredObjectiveId; }> = [ { id: 'meet-zhang-fei', - label: '장터 주점에서 장비와 이야기하기', - shortLabel: '장비와 대화', - location: '서쪽 주점' + label: '모병 격문 앞에서 장비와 뜻을 나누기', + shortLabel: '격문 앞의 장비', + location: '서쪽 모병 격문' }, { id: 'meet-guan-yu', - label: '동쪽 장터에서 관우의 정찰 듣기', - shortLabel: '관우와 대화', - location: '동쪽 장터' + label: '주점에 들어온 관우의 뜻을 듣기', + shortLabel: '주점의 관우', + location: '동쪽 주점 앞', + prerequisiteId: 'meet-zhang-fei' }, { - id: 'check-supplies', - label: '의병소에서 무기와 보급 확인하기', - shortLabel: '출전 준비', - location: '남동쪽 의병소' + id: 'register-volunteers', + label: '모병 관리에게 세 사람의 뜻을 밝히기', + shortLabel: '의병 등록', + location: '남동쪽 모병소', + prerequisiteId: 'meet-guan-yu' }, { id: 'make-oath', label: '복숭아 동산에서 두 사람과 결의하기', shortLabel: '도원결의', - location: '북쪽 복숭아 동산' + location: '북쪽 복숭아 동산', + prerequisiteId: 'register-volunteers' } ]; @@ -59,6 +63,7 @@ export type PrologueVillageNpcDefinition = { direction: 'south' | 'east' | 'north' | 'west'; objectiveId?: PrologueVillageRequiredObjectiveId; dialogue: PrologueVillageDialogueLine[]; + lockedDialogue?: PrologueVillageDialogueLine[]; repeatDialogue?: PrologueVillageDialogueLine[]; }; @@ -66,41 +71,50 @@ export const prologueVillageNpcDefinitions: readonly PrologueVillageNpcDefinitio { id: 'zhang-fei', name: '장비', - role: '뜻을 함께할 호걸', + role: '격문 앞에서 만난 호걸', textureKey: 'unit-zhang-fei', x: 360, y: 495, direction: 'east', objectiveId: 'meet-zhang-fei', dialogue: [ + { + speaker: '전황', + text: '유비가 모병 격문을 읽고 길게 탄식하자, 등 뒤에서 우렁찬 목소리가 날아왔다.' + }, { speaker: '장비', textureKey: 'unit-zhang-fei', - text: '나라가 어지러운데 뜻 있는 사내가 어찌 팔짱만 끼고 있겠소! 내 재산을 내어 의병을 모으겠소.' + text: '대장부가 나라를 위해 힘을 내지 않고 어찌 한숨만 쉰단 말이오? 뜻이 있다면 행동으로 보입시다!' }, { speaker: '유비', textureKey: 'unit-liu-bei', - text: '백성을 지키려는 마음이 같다면 힘을 합칩시다. 먼저 싸울 사람과 물자를 갖추어야 하오.' + text: '나는 중산정왕의 후손 유비요. 황건을 물리쳐 백성을 편안하게 하고 싶으나, 홀로는 힘이 모자라 탄식했소.' }, { speaker: '장비', textureKey: 'unit-zhang-fei', - text: '좋소! 내가 장터 사람들을 모아 두겠소. 의병소의 준비를 확인한 뒤 복숭아 동산에서 다시 만납시다.' + text: '나는 장비라 하오. 이 고을에서 술과 고기를 팔며 장원을 돌보고 있소. 뜻있는 이를 돕고자 모은 재산이 있으니 함께 의병을 일으킵시다.' + }, + { + speaker: '유비', + textureKey: 'unit-liu-bei', + text: '백성을 지키려는 마음이 같으니 더없이 든든하오. 주점에서 사람을 모을 방도를 자세히 의논합시다.' } ], repeatDialogue: [ { speaker: '장비', textureKey: 'unit-zhang-fei', - text: '사람들에게 뜻을 전하고 있소. 관우와 의병소도 둘러본 뒤 동산으로 오시오!' + text: '주점에 군문으로 향한다는 붉은 얼굴의 나그네가 들었소. 범상치 않은 사람이니 함께 만나 봅시다!' } ] }, { id: 'guan-yu', name: '관우', - role: '길목을 살피는 무인', + role: '군문으로 향하던 나그네', textureKey: 'unit-guan-yu', x: 1110, y: 460, @@ -108,67 +122,89 @@ export const prologueVillageNpcDefinitions: readonly PrologueVillageNpcDefinitio objectiveId: 'meet-guan-yu', dialogue: [ { - speaker: '관우', - textureKey: 'unit-guan-yu', - text: '북쪽 길을 살펴보니 황건 잔당이 마을 어귀로 내려오고 있습니다. 더 늦으면 백성이 위험합니다.' + speaker: '전황', + text: '손수레를 세운 붉은 얼굴의 나그네가 주점에 들어와, 군문에 자원하러 가는 길이라며 술을 청했다.' }, { speaker: '유비', textureKey: 'unit-liu-bei', - text: '우리가 먼저 길목을 막아야겠군요. 함께 백성을 구하는 길을 열어 주시겠소?' + text: '기상이 범상치 않으십니다. 존함과 이곳 군문을 찾은 까닭을 여쭈어도 되겠소?' }, { speaker: '관우', textureKey: 'unit-guan-yu', - text: '뜻이 올바르다면 천 리라도 함께하겠습니다. 준비를 마치고 복숭아 동산에서 맹세하지요.' + text: '나는 하동 해량 출신 관우라 합니다. 고을의 힘 있는 자가 약한 이를 짓밟는 것을 참지 못해 그를 베고 여러 해 떠돌았습니다.' + }, + { + speaker: '장비', + textureKey: 'unit-zhang-fei', + text: '마침 우리는 황건을 막을 의병을 일으키려던 참이오. 힘보다 의를 먼저 보는 사람이라면 더 들을 것도 없겠소!' + }, + { + speaker: '관우', + textureKey: 'unit-guan-yu', + text: '두 분의 뜻이 백성을 향한다면 이 관우도 함께하겠습니다. 같은 뜻이 쉽게 흩어지지 않도록 맹세로 굳히지요.' + } + ], + lockedDialogue: [ + { + speaker: '전황', + text: '붉은 얼굴의 나그네가 주점 앞에 손수레를 세우고 군문 쪽을 살핀다. 먼저 모병 격문 앞의 호걸과 이야기해 보자.' } ], repeatDialogue: [ { speaker: '관우', textureKey: 'unit-guan-yu', - text: '적은 보병을 앞세우고 뒤에서 활을 쏠 것입니다. 준비가 끝나는 대로 북문으로 나가야 합니다.' + text: '장비 공의 장원 뒤에 복숭아꽃이 한창이라 들었습니다. 모병소에 뜻을 밝힌 뒤 그곳에서 다시 뵙지요.' } ] }, { - id: 'quartermaster', - name: '의병소 관리인', - role: '무기와 보급 담당', + id: 'recruiting-clerk', + name: '탁현 모병 관리', + role: '의병 명부 담당', textureKey: 'unit-shu-officer', x: 1045, y: 760, direction: 'east', - objectiveId: 'check-supplies', + objectiveId: 'register-volunteers', dialogue: [ { - speaker: '의병소 관리인', + speaker: '탁현 모병 관리', textureKey: 'unit-shu-officer', - text: '장비 공이 맡긴 돈으로 창과 연습검을 손질했습니다. 콩과 상처약도 각 조에 나누어 두었습니다.' + text: '성명과 출신, 의병을 일으키는 뜻을 명부에 남겨 주십시오. 인가가 나면 모인 군마와 병기를 막사에서 정식으로 나누겠습니다.' }, { speaker: '유비', textureKey: 'unit-liu-bei', - text: '좋습니다. 무기보다 먼저 사람을 살피고, 다친 이는 곧바로 뒤로 물리도록 전해 주시오.' + text: '탁현의 유비, 하동의 관우, 이 고을의 장비요. 위로는 나라의 어려움에 보답하고 아래로는 백성을 편안하게 하고자 모였소.' }, { - speaker: '의병소 관리인', + speaker: '탁현 모병 관리', textureKey: 'unit-shu-officer', - text: '출전 준비를 마쳤습니다. 이제 동료들과 결의하면 북문을 열겠습니다.' + text: '세 분의 뜻을 군후 추정께 보고하겠습니다. 결의를 마친 뒤 장비 공의 장원 밖 의용군 막사로 와 주십시오.' + } + ], + lockedDialogue: [ + { + speaker: '탁현 모병 관리', + textureKey: 'unit-shu-officer', + text: '함께 이름을 올릴 동료가 모두 정해지면 다시 오십시오.' } ], repeatDialogue: [ { - speaker: '의병소 관리인', + speaker: '탁현 모병 관리', textureKey: 'unit-shu-officer', - text: '무기와 보급은 모두 나누었습니다. 북문을 나서기 전에 동산에서 동료들과 합류하십시오.' + text: '명부는 접수되었습니다. 복숭아 동산에서 뜻을 굳힌 뒤 의용군 막사로 가시면 군후 추정이 기다릴 것입니다.' } ] }, { id: 'market-villager', name: '탁현 주민', - role: '장터 소식', + role: '장터의 목소리', textureKey: 'unit-shu-infantry', x: 745, y: 620, @@ -177,17 +213,16 @@ export const prologueVillageNpcDefinitions: readonly PrologueVillageNpcDefinitio { speaker: '탁현 주민', textureKey: 'unit-shu-infantry', - text: '서쪽 주점의 장비 공과 동쪽 장터의 긴 수염 무인이 의병 이야기를 나누고 있었습니다.' + text: '황건 무리가 길을 막은 뒤로 장터에 곡식이 끊겼습니다. 싸움이 두렵지만, 우리를 먼저 생각해 주는 의병이라면 힘을 보태겠습니다.' } ] } ]; const openingPageIds = new Set(['yellow-turban-chaos', 'liu-bei-resolve']); +const brotherhoodPageIds = new Set(['militia-rises']); const departurePageIds = new Set([ 'first-sortie', - 'yellow-turban-nearby', - 'first-battle-plan-talk', 'battle-briefing' ]); @@ -195,6 +230,10 @@ export function prologueOpeningPages(): StoryPage[] { return selectProloguePages(openingPageIds); } +export function prologueBrotherhoodPages(): StoryPage[] { + return selectProloguePages(brotherhoodPageIds); +} + export function prologueDeparturePages(): StoryPage[] { return selectProloguePages(departurePageIds); } diff --git a/src/game/data/scenario.ts b/src/game/data/scenario.ts index ea6d9fa..02e79b5 100644 --- a/src/game/data/scenario.ts +++ b/src/game/data/scenario.ts @@ -67,29 +67,29 @@ export const prologuePages: StoryPage[] = [ { id: 'liu-bei-resolve', bgm: 'story-dark', - chapter: '탁현의 방', + chapter: '탁현의 의병 모집 격문', background: 'story-liu-bei', speaker: '유비', portrait: 'liuBei', - text: '한실의 백성이 이토록 고통받는데, 어찌 초가집 그늘에 숨어 있을 수 있겠는가.' + text: '황건의 불길은 가까워지는데, 격문 앞의 내게 있는 것은 두 손뿐이구나. 백성을 돕고 싶어도 사람과 재물이 없으니 어찌해야 하는가.' }, { id: 'zhang-fei-joins', bgm: 'story-dark', - chapter: '뜻이 모이다', + chapter: '격문 앞에서 시작된 인연', background: 'story-three-heroes', speaker: '장비', portrait: 'zhangFei', - text: '좋소! 나라를 바로잡고 백성을 구한다면, 내 재산도 이 목숨도 아깝지 않소.' + text: '한숨으로 끝내지 않고 함께 의병을 일으키기로 했으니, 내 장원과 재산을 아끼지 않겠소. 이제 그 뜻을 하늘과 땅 앞에서 굳힙시다.' }, { id: 'guan-yu-joins', bgm: 'story-dark', - chapter: '뜻이 모이다', + chapter: '주점에 든 나그네', background: 'story-three-heroes', speaker: '관우', portrait: 'guanYu', - text: '의로운 뜻을 품은 이와 길을 함께한다면, 천 리라도 멀다 하지 않겠습니다.' + text: '군문을 찾아온 길에서 두 분을 만난 것도 인연일 것입니다. 백성을 먼저 생각하는 그 뜻이 흔들리지 않는 한, 이 관우도 함께하겠습니다.' }, { id: 'oath-narration', @@ -119,11 +119,11 @@ export const prologuePages: StoryPage[] = [ { id: 'militia-rises', bgm: 'militia-theme', - chapter: '의용군 모집', + chapter: '탁현 의용군의 탄생', background: 'story-militia', speaker: '장비', portrait: 'zhangFei', - text: '창을 들 사람은 앞으로 나오시오! 오늘 모인 이름 없는 무리가 내일의 방패가 될 것이오!' + text: '내 장원의 문과 창고를 모두 열었소! 뜻을 함께할 사람은 막사로 모이시오. 오늘 모인 이름 없는 우리가 마을의 방패가 될 것이오!' }, { id: 'first-sortie', @@ -141,7 +141,7 @@ export const prologuePages: StoryPage[] = [ background: 'story-sortie', speaker: '관우', portrait: 'guanYu', - text: '방금 돌아온 마을 사람이 말했습니다. 황건 잔당 일부가 북쪽 숲길에 남아 진을 치고 있습니다. 그냥 지나치면 다시 마을을 덮칠 것입니다.' + text: '방금 돌아온 마을 사람이 말했습니다. 황건군 일부가 북쪽 숲길에 진을 치고 있습니다. 그냥 지나치면 마을을 덮칠 것입니다.' }, { id: 'first-battle-plan-talk', @@ -209,34 +209,34 @@ const firstBattleStoryOverrides: Record> = { 'militia-rises': { chapter: '탁현 의용군', speaker: '장비', - text: '창을 들 사람은 앞으로 나오시오. 오늘 모인 이름 없는 무리가 마을의 방패가 될 것이오.', + text: '내 장원의 문과 창고를 모두 열었소! 뜻을 함께할 사람은 막사로 모이시오. 오늘 모인 이름 없는 우리가 마을의 방패가 될 것이오!', cutscene: { kind: 'muster', title: '탁현 의용군 집결', - subtitle: '세 형제와 마을 사람들이 첫 출전을 준비합니다.', + subtitle: '장비의 장원이 탁현 의용군의 첫 막사로 바뀝니다.', actors: [ { unitId: 'liu-bei', x: 0.34, y: 0.53, size: 122, direction: 'east', label: '군주' }, { unitId: 'guan-yu', x: 0.49, y: 0.52, size: 132, direction: 'east', label: '선봉' }, { unitId: 'zhang-fei', x: 0.64, y: 0.53, size: 132, direction: 'west', label: '돌파' } ], briefing: { - title: '출전 준비', - lines: ['유비는 중앙에서 전열을 잡습니다.', '관우는 앞길을 열고, 장비는 측면을 찌릅니다.'] + title: '막사가 열리다', + lines: ['의병 명부를 정리한다.', '병기와 보급을 나눈다.', '세 형제가 직접 첫 출전을 점검한다.'] }, rewards: [ - { label: '아군: 유비 · 관우 · 장비', tone: 'bond' }, - { label: '목표: 마을 방어', tone: 'next' } + { label: '거점: 탁현 의용군 막사', tone: 'bond' }, + { label: '다음: 첫 출전 준비', tone: 'next' } ] } }, 'first-sortie': { chapter: '첫 출전', speaker: '유비', - text: '마을 사람들을 먼저 들여보내자. 황건 잔당이 근처에 남아 있다면, 오늘 여기서 물리쳐 다시는 이 길을 넘보지 못하게 해야 한다.', + text: '마을 사람들을 먼저 들여보내자. 황건군이 근처에 진을 쳤다면, 오늘 여기서 물리쳐 다시는 이 길을 넘보지 못하게 해야 한다.', cutscene: { kind: 'muster', - title: '황건 잔당을 향해', - subtitle: '마을 북쪽 길목에 남은 황건 무리를 치기 위해 세 형제가 움직입니다.', + title: '황건군을 향해', + subtitle: '마을 북쪽 길목을 노리는 황건 선봉을 치기 위해 세 형제가 움직입니다.', actors: [ { unitId: 'liu-bei', x: 0.32, y: 0.53, size: 136, direction: 'east', label: '유비' }, { unitId: 'guan-yu', x: 0.5, y: 0.51, size: 144, direction: 'east', label: '관우' }, @@ -244,7 +244,7 @@ const firstBattleStoryOverrides: Record> = { ], briefing: { title: '출전 방침', - lines: ['마을 사람을 뒤로 물린다.', '황건 잔당의 진형을 확인한다.', '세 형제가 함께 길목을 연다.'] + lines: ['마을 사람을 뒤로 물린다.', '황건군의 진형을 확인한다.', '세 형제가 함께 길목을 연다.'] }, rewards: [ { label: '패배 조건: 유비 퇴각 금지', tone: 'next' }, @@ -256,10 +256,10 @@ const firstBattleStoryOverrides: Record> = { chapter: '탁현 작전판', speaker: '관우', portrait: 'guanYu', - text: '작전판으로 보시지요. 황건 잔당은 보병을 앞세워 길목을 막고, 뒤쪽 궁병으로 엄호하며, 두령은 북동쪽 높은 길에 머물고 있습니다.', + text: '작전판으로 보시지요. 황건군은 보병을 앞세워 길목을 막고, 뒤쪽 궁병으로 엄호하며, 두령은 북동쪽 높은 길에 머물고 있습니다.', cutscene: { kind: 'operation', - title: '황건 잔당 진형', + title: '황건군 진형', subtitle: '보병 전열을 걷어 내고, 궁병 엄호를 피한 뒤 두령을 포위하십시오.', actors: [ { unitId: 'liu-bei', x: 0.24, y: 0.56, size: 104, direction: 'east', label: '지휘' }, @@ -290,7 +290,7 @@ const firstBattleStoryOverrides: Record> = { cutscene: { kind: 'victory', title: '탁현 방어 성공', - subtitle: '전투에서 싸운 세 유닛이 그대로 승리 장면에 남습니다.', + subtitle: '첫 출전을 함께 치른 세 형제가 승리의 순간을 나눕니다.', actors: [ { unitId: 'liu-bei', x: 0.36, y: 0.53, size: 132, direction: 'east', label: '유비' }, { unitId: 'guan-yu', x: 0.52, y: 0.52, size: 142, direction: 'west', label: '관우' }, @@ -310,7 +310,7 @@ const firstBattleStoryOverrides: Record> = { cutscene: { kind: 'victory', title: '전투 후 정비', - subtitle: '획득 보상은 다음 출전 준비와 장비 화면으로 이어집니다.', + subtitle: '전리품과 보급을 수습해 다음 길을 준비합니다.', actors: [ { unitId: 'liu-bei', x: 0.44, y: 0.52, size: 146, direction: 'south', label: '유비' }, { unitId: 'guan-yu', x: 0.58, y: 0.53, size: 130, direction: 'west', label: '관우' }, @@ -337,8 +337,8 @@ const firstBattleStoryOverrides: Record> = { { unitId: 'zhang-fei', x: 0.64, y: 0.54, size: 128, direction: 'west', label: '돌파' } ], rewards: [ - { label: '출전 가능: 관우', tone: 'bond' }, - { label: '역할 추천: 전면', tone: 'next' } + { label: '첫 전공: 관우 · 중앙 전열', tone: 'bond' }, + { label: '역할 추천: 전열', tone: 'next' } ] } }, @@ -356,7 +356,7 @@ const firstBattleStoryOverrides: Record> = { { unitId: 'guan-yu', x: 0.64, y: 0.53, size: 130, direction: 'west', label: '선봉' } ], rewards: [ - { label: '출전 가능: 장비', tone: 'bond' }, + { label: '첫 전공: 장비 · 측면 돌파', tone: 'bond' }, { label: '역할 추천: 측면', tone: 'next' } ] } @@ -364,7 +364,7 @@ const firstBattleStoryOverrides: Record> = { 'first-victory-next': { chapter: '다음 길', speaker: '전황', - text: '탁현에서부터 유비를 도와 온 간옹이 의용군의 군정과 책략을 맡기로 했다. 군량과 말이 넉넉지 않아, 유비·관우·장비·간옹 네 사람 가운데 셋이 선두를 맡고 나머지 한 사람은 후방을 돌보기로 했다.', + text: '첫 승리 소식을 들은 유비의 옛 벗 간옹이 막사를 찾아와, 의용군의 군정과 책략을 맡겠다고 나섰다. 군량과 말이 넉넉지 않아 네 사람 가운데 셋이 선두를 맡고 한 사람은 후방을 돌보기로 했다.', cutscene: { kind: 'operation', title: '간옹 합류 · 첫 편성 선택', diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index b010e25..5b1073e 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -4029,6 +4029,7 @@ export class BattleScene extends Phaser.Scene { campaign.step === 'new' || campaign.step === 'prologue' || campaign.step === 'prologue-town' || + campaign.step === 'prologue-camp' || campaign.step === 'first-battle' ) { markCampaignStep('first-battle'); diff --git a/src/game/scenes/PrologueMilitiaCampScene.ts b/src/game/scenes/PrologueMilitiaCampScene.ts new file mode 100644 index 0000000..9a542bb --- /dev/null +++ b/src/game/scenes/PrologueMilitiaCampScene.ts @@ -0,0 +1,1348 @@ +import Phaser from 'phaser'; +import { soundDirector } from '../audio/SoundDirector'; +import { + prologueMilitiaCampNpcDefinitions, + prologueMilitiaCampObjectiveDefinitions, + prologueMilitiaCampRequiredObjectiveIds, + type PrologueMilitiaCampNpcDefinition, + type PrologueMilitiaCampRequiredObjectiveId +} from '../data/prologueMilitiaCamp'; +import { prologueDeparturePages, type PrologueVillageDialogueLine } from '../data/prologueVillage'; +import { + ensureUnitAnimations, + loadUnitBaseSheets, + releaseUnitBaseSheetTextures, + type UnitDirection +} from '../data/unitAssets'; +import { + completeCampaignTutorial, + getCampaignState, + hasCompletedCampaignTutorial, + markCampaignStep, + prologueMilitiaCampCampaignTutorialIds, + type CampaignTutorialId +} from '../state/campaignState'; +import { palette } from '../ui/palette'; +import { startGameScene } from './lazyScenes'; + +const sceneWidth = 1920; +const sceneHeight = 1080; +const mapRight = 1488; +const movementBounds = new Phaser.Geom.Rectangle(42, 108, 1404, 814); +const playerSpeed = 300; +const playerCollisionRadius = 23; +const interactionRadius = 122; +const promptRadius = 164; +const characterDisplaySize = 104; +const inputCarryoverGuardMs = 320; +const characterTextureKeys = [ + 'unit-liu-bei', + 'unit-guan-yu', + 'unit-zhang-fei', + 'unit-shu-officer', + 'unit-shu-infantry' +] as const; + +type CampNpcView = { + definition: PrologueMilitiaCampNpcDefinition; + sprite: Phaser.GameObjects.Sprite; + shadow: Phaser.GameObjects.Ellipse; + nameplate: Phaser.GameObjects.Text; + roleplate: Phaser.GameObjects.Text; + marker?: Phaser.GameObjects.Text; +}; + +type DialogueState = { + lines: PrologueVillageDialogueLine[]; + lineIndex: number; + onComplete?: () => void; + sourceNpcId?: string; +}; + +type ObjectiveRowView = { + id: PrologueMilitiaCampRequiredObjectiveId | 'receive-command'; + background: Phaser.GameObjects.Rectangle; + status: Phaser.GameObjects.Text; + label: Phaser.GameObjects.Text; + location: Phaser.GameObjects.Text; +}; + +const objectiveTutorialIds: Record = { + 'review-scout-report': prologueMilitiaCampCampaignTutorialIds.reviewScoutReport, + 'ready-vanguard': prologueMilitiaCampCampaignTutorialIds.readyVanguard, + 'inspect-arms': prologueMilitiaCampCampaignTutorialIds.inspectArms +}; + +export class PrologueMilitiaCampScene extends Phaser.Scene { + private player?: Phaser.GameObjects.Sprite; + private playerShadow?: Phaser.GameObjects.Ellipse; + private playerDirection: UnitDirection = 'north'; + private playerMoving = false; + private npcViews = new Map(); + private blockers: Phaser.Geom.Rectangle[] = []; + private objectiveRows = new Map(); + private objectiveSummaryText?: Phaser.GameObjects.Text; + private promptBackground?: Phaser.GameObjects.Rectangle; + private promptText?: Phaser.GameObjects.Text; + private dialoguePanel?: Phaser.GameObjects.Container; + private dialogueAvatar?: Phaser.GameObjects.Sprite; + private dialogueNameText?: Phaser.GameObjects.Text; + private dialogueBodyText?: Phaser.GameObjects.Text; + private dialogueProgressText?: Phaser.GameObjects.Text; + private dialogueState?: DialogueState; + private loadingOverlay?: Phaser.GameObjects.Container; + private transitionOverlay?: Phaser.GameObjects.Container; + private completionToast?: Phaser.GameObjects.Container; + private cursorKeys?: Phaser.Types.Input.Keyboard.CursorKeys; + private moveKeys?: { + up: Phaser.Input.Keyboard.Key; + down: Phaser.Input.Keyboard.Key; + left: Phaser.Input.Keyboard.Key; + right: Phaser.Input.Keyboard.Key; + }; + private interactKeys: Phaser.Input.Keyboard.Key[] = []; + private moveTarget?: Phaser.Math.Vector2; + private ready = false; + private navigationPending = false; + private inputReadyAt = Number.POSITIVE_INFINITY; + private interactionQueued = false; + private stepIndex = 0; + private lastStepAt = 0; + private firstVisit = false; + + constructor() { + super('PrologueMilitiaCampScene'); + } + + create() { + this.resetRuntimeState(); + this.prepareCampaignProgress(); + this.drawCamp(); + this.drawHud(); + this.createLoadingOverlay(); + this.setupInput(); + + soundDirector.playSoundscape({ + musicKey: 'camp-rally', + ambienceKey: 'campfire-ambience', + musicVolume: 0.22, + ambienceVolume: 0.12 + }); + + this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => { + this.ready = false; + this.navigationPending = false; + this.moveTarget = undefined; + this.dialogueState = undefined; + releaseUnitBaseSheetTextures(this); + }); + + loadUnitBaseSheets(this, characterTextureKeys, () => { + if (!this.scene.isActive()) { + return; + } + ensureUnitAnimations(this, characterTextureKeys); + this.createActors(); + this.createDialoguePanel(); + this.loadingOverlay?.destroy(); + this.loadingOverlay = undefined; + this.ready = true; + this.inputReadyAt = this.time.now + inputCarryoverGuardMs; + this.refreshObjectiveHud(); + this.refreshNpcMarkers(); + this.refreshInteractionPrompt(); + + if (this.firstVisit) { + this.startDialogue([ + { + speaker: '유비', + textureKey: 'unit-liu-bei', + text: '맹세만으로 백성을 지킬 수는 없다. 막사를 직접 돌며 관우의 정찰, 장비의 의병 대열, 군수관의 병기와 후송 준비를 확인하자.' + } + ]); + } + }); + } + + update(time: number, delta: number) { + if (!this.ready || this.navigationPending || time < this.inputReadyAt) { + this.interactionQueued = false; + return; + } + if (this.dialogueState) { + this.stopPlayerMovement(); + if (this.consumeInteractionRequest()) { + this.advanceDialogue(); + } + return; + } + if (this.consumeInteractionRequest()) { + this.tryInteract(); + return; + } + this.updateMovement(time, delta); + this.refreshInteractionPrompt(); + } + + getDebugState() { + const campaign = getCampaignState(); + const playerPosition = this.player ? { x: this.player.x, y: this.player.y } : null; + const target = this.nearestNpc(promptRadius); + const dialogueBounds = this.dialoguePanel?.visible + ? this.boundsSnapshot(this.dialoguePanel.getBounds()) + : null; + return { + scene: this.scene.key, + locationId: 'zhuo-volunteer-camp', + ready: this.ready, + viewport: { width: this.scale.width, height: this.scale.height }, + campaignStep: campaign.step, + requiredTexturesReady: characterTextureKeys.every((key) => this.textures.exists(key)), + player: playerPosition + ? { + ...playerPosition, + direction: this.playerDirection, + moving: this.playerMoving, + bounds: this.player ? this.boundsSnapshot(this.player.getBounds()) : null + } + : null, + movement: { + speed: playerSpeed, + target: this.moveTarget ? { x: this.moveTarget.x, y: this.moveTarget.y } : null, + walkBounds: this.boundsSnapshot(movementBounds), + collisionRadius: playerCollisionRadius + }, + controls: { + movement: ['WASD', '방향키'], + interact: ['E', 'Space', 'Enter'], + pointerMove: true, + carryoverGuardMs: inputCarryoverGuardMs + }, + interaction: { + radius: interactionRadius, + promptRadius, + targetId: target?.definition.id ?? null, + canInteract: Boolean( + target && playerPosition && + Phaser.Math.Distance.Between(playerPosition.x, playerPosition.y, target.sprite.x, target.sprite.y) <= interactionRadius + ), + promptVisible: this.promptBackground?.visible ?? false, + promptBounds: this.promptBackground?.visible + ? this.boundsSnapshot(this.promptBackground.getBounds()) + : null + }, + objectives: prologueMilitiaCampObjectiveDefinitions.map((objective) => ({ + id: objective.id, + label: objective.label, + location: objective.location, + completed: objective.id === 'receive-command' + ? hasCompletedCampaignTutorial(prologueMilitiaCampCampaignTutorialIds.complete) + : this.isObjectiveComplete(objective.id), + unlocked: objective.id !== 'receive-command' || this.allRequiredObjectivesComplete() + })), + completedObjectiveIds: this.completedRequiredObjectiveIds(), + exitUnlocked: this.allRequiredObjectivesComplete(), + npcs: Array.from(this.npcViews.values()).map(({ definition, sprite }) => ({ + id: definition.id, + name: definition.name, + role: definition.role, + objectiveId: definition.objectiveId ?? null, + departure: definition.departure ?? false, + x: sprite.x, + y: sprite.y, + distance: playerPosition + ? Phaser.Math.Distance.Between(playerPosition.x, playerPosition.y, sprite.x, sprite.y) + : null, + completed: definition.objectiveId ? this.isObjectiveComplete(definition.objectiveId) : false, + unlocked: !definition.departure || this.allRequiredObjectivesComplete(), + bounds: this.boundsSnapshot(sprite.getBounds()) + })), + dialogue: this.dialogueState + ? { + active: true, + speaker: this.dialogueState.lines[this.dialogueState.lineIndex]?.speaker ?? '', + lineIndex: this.dialogueState.lineIndex, + totalLines: this.dialogueState.lines.length, + sourceNpcId: this.dialogueState.sourceNpcId ?? null, + bounds: dialogueBounds + } + : { + active: false, + speaker: null, + lineIndex: -1, + totalLines: 0, + sourceNpcId: null, + bounds: null + }, + blockers: this.blockers.map((blocker) => this.boundsSnapshot(blocker)), + navigationPending: this.navigationPending + }; + } + + debugTeleportTo(targetId: string) { + if (!this.ready || this.navigationPending || this.dialogueState) { + return false; + } + const view = this.npcViews.get(targetId); + if (!view) { + return false; + } + const position = this.safeApproachPosition(view.sprite.x, view.sprite.y); + this.setPlayerPosition(position.x, position.y); + this.refreshInteractionPrompt(); + return true; + } + + debugInteractWith(targetId: string) { + if (!this.ready || this.navigationPending || this.dialogueState) { + return false; + } + const view = this.npcViews.get(targetId); + if (!view) { + return false; + } + this.interactWithNpc(view); + return true; + } + + debugCompleteRequiredObjectives() { + prologueMilitiaCampRequiredObjectiveIds.forEach((objectiveId) => { + completeCampaignTutorial(objectiveTutorialIds[objectiveId]); + }); + this.refreshObjectiveHud(); + this.refreshNpcMarkers(); + return this.completedRequiredObjectiveIds(); + } + + debugCompleteCamp() { + if (!this.ready || this.navigationPending) { + return false; + } + this.debugCompleteRequiredObjectives(); + this.finishCamp(); + return true; + } + + private resetRuntimeState() { + this.player = undefined; + this.playerShadow = undefined; + this.playerDirection = 'north'; + this.playerMoving = false; + this.npcViews.clear(); + this.blockers = []; + this.objectiveRows.clear(); + this.dialogueState = undefined; + this.moveTarget = undefined; + this.ready = false; + this.navigationPending = false; + this.inputReadyAt = Number.POSITIVE_INFINITY; + this.interactionQueued = false; + this.stepIndex = 0; + this.lastStepAt = 0; + } + + private prepareCampaignProgress() { + const campaign = getCampaignState(); + this.firstVisit = !campaign.completedTutorialIds.includes( + prologueMilitiaCampCampaignTutorialIds.entered + ); + if (campaign.step !== 'first-battle') { + markCampaignStep('prologue-camp'); + } + completeCampaignTutorial(prologueMilitiaCampCampaignTutorialIds.entered); + } + + private drawCamp() { + this.cameras.main.setBackgroundColor('#1a241e'); + const graphics = this.add.graphics().setDepth(-100); + graphics.fillStyle(0x26352b, 1); + graphics.fillRect(0, 0, sceneWidth, sceneHeight); + graphics.fillStyle(0x344536, 1); + graphics.fillRect(0, 92, mapRight, 866); + + this.drawGroundTexture(graphics); + this.drawCampRoads(graphics); + this.drawPalisade(graphics); + this.drawCampStructures(graphics); + this.drawCampDetails(); + + this.add.rectangle(mapRight, 0, sceneWidth - mapRight, sceneHeight, 0x111720, 0.97) + .setOrigin(0) + .setDepth(1400); + this.add.rectangle(mapRight, 0, 3, sceneHeight, palette.gold, 0.72) + .setOrigin(0) + .setDepth(1401); + this.add.rectangle(0, 0, sceneWidth, 92, 0x10161d, 0.97) + .setOrigin(0) + .setDepth(1400); + this.add.rectangle(0, 90, sceneWidth, 2, palette.gold, 0.68) + .setOrigin(0) + .setDepth(1401); + this.add.rectangle(0, 958, mapRight, 122, 0x0e141a, 0.95) + .setOrigin(0) + .setDepth(1400); + this.add.rectangle(0, 956, mapRight, 2, palette.gold, 0.54) + .setOrigin(0) + .setDepth(1401); + } + + private drawGroundTexture(graphics: Phaser.GameObjects.Graphics) { + for (let index = 0; index < 180; index += 1) { + const x = 22 + ((index * 89) % 1438); + const y = 112 + ((index * 131) % 826); + const tone = index % 3 === 0 ? 0x506248 : index % 3 === 1 ? 0x263b30 : 0x746547; + graphics.fillStyle(tone, 0.25); + graphics.fillCircle(x, y, index % 5 === 0 ? 3 : 2); + } + } + + private drawCampRoads(graphics: Phaser.GameObjects.Graphics) { + graphics.fillStyle(0x8d7556, 0.92); + graphics.fillRoundedRect(676, 92, 210, 866, 34); + graphics.fillRoundedRect(152, 477, 1180, 150, 48); + graphics.fillStyle(0xb49a6a, 0.3); + graphics.fillRoundedRect(715, 92, 42, 866, 20); + graphics.fillRoundedRect(160, 526, 1160, 28, 14); + graphics.fillStyle(0x544735, 0.42); + for (let y = 126; y < 930; y += 60) { + graphics.fillEllipse(808 + (y % 4) * 6, y, 34, 10); + } + } + + private drawPalisade(graphics: Phaser.GameObjects.Graphics) { + graphics.fillStyle(0x493724, 1); + for (let x = 20; x < mapRight; x += 34) { + if (x > 650 && x < 920) { + continue; + } + graphics.fillTriangle(x, 122, x + 15, 94, x + 30, 122); + graphics.fillRect(x + 3, 118, 24, 24); + } + graphics.fillStyle(0x30271e, 1); + graphics.fillRect(0, 136, 646, 12); + graphics.fillRect(924, 136, mapRight - 924, 12); + graphics.fillStyle(palette.gold, 0.85); + graphics.fillRect(674, 119, 220, 6); + this.add.text(784, 116, '북문 · 출전로', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '22px', + color: '#f4dda0', + fontStyle: 'bold', + backgroundColor: '#2c2118dd', + padding: { left: 14, right: 14, top: 5, bottom: 5 } + }).setOrigin(0.5).setDepth(46); + } + + private drawCampStructures(graphics: Phaser.GameObjects.Graphics) { + this.drawTent(graphics, 602, 152, 365, 170, 0x80513b, '지휘 막사'); + this.addBlocker(622, 178, 325, 125); + + this.drawTent(graphics, 92, 180, 280, 185, 0x5a704e, '정찰대 막사'); + this.addBlocker(108, 205, 248, 140); + + this.drawTent(graphics, 1170, 188, 250, 178, 0x6b4c3c, '의병 숙영지'); + this.addBlocker(1188, 212, 214, 132); + + this.drawTent(graphics, 74, 668, 248, 184, 0x6f5c3f, '보급 천막'); + this.addBlocker(90, 694, 216, 140); + + const table = this.add.graphics().setDepth(36); + table.fillStyle(0x3c2a20, 1); + table.fillRoundedRect(295, 380, 124, 72, 8); + table.fillStyle(0xc7af7d, 1); + table.fillRoundedRect(306, 389, 102, 48, 4); + table.lineStyle(2, 0x61788c, 0.9); + table.lineBetween(316, 399, 395, 425); + table.lineBetween(334, 432, 386, 398); + this.addBlocker(295, 380, 124, 72); + + const racks = this.add.graphics().setDepth(38); + racks.lineStyle(7, 0x4a3829, 1); + racks.lineBetween(1030, 650, 1030, 804); + racks.lineBetween(1188, 650, 1188, 804); + racks.lineBetween(1018, 690, 1200, 690); + racks.lineStyle(4, 0xa8b0b1, 1); + for (let x = 1048; x <= 1170; x += 40) { + racks.lineBetween(x, 662, x + 16, 780); + } + this.addBlocker(1018, 655, 182, 142); + + const crates = this.add.graphics().setDepth(38); + [ + [330, 820, 76, 54], + [413, 832, 62, 43], + [1285, 760, 72, 52] + ].forEach(([x, y, width, height]) => { + crates.fillStyle(0x765234, 1); + crates.fillRect(x, y, width, height); + crates.lineStyle(3, 0x34261d, 0.9); + crates.strokeRect(x, y, width, height); + crates.lineBetween(x, y, x + width, y + height); + this.addBlocker(x, y, width, height); + }); + } + + private drawTent( + graphics: Phaser.GameObjects.Graphics, + x: number, + y: number, + width: number, + height: number, + clothColor: number, + label: string + ) { + graphics.fillStyle(0x10110e, 0.28); + graphics.fillTriangle(x + 18, y + height + 18, x + width / 2 + 14, y + 8, x + width + 12, y + height + 18); + graphics.fillStyle(clothColor, 1); + graphics.fillTriangle(x, y + height, x + width / 2, y, x + width, y + height); + graphics.fillStyle(0x241e1a, 0.78); + graphics.fillTriangle(x + width / 2 - 48, y + height, x + width / 2, y + 72, x + width / 2 + 48, y + height); + graphics.lineStyle(4, 0x2b221b, 0.9); + graphics.lineBetween(x, y + height, x + width, y + height); + this.add.text(x + width / 2, y + height - 24, label, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '19px', + color: '#f2dfb0', + fontStyle: 'bold', + backgroundColor: '#211a15dd', + padding: { left: 10, right: 10, top: 4, bottom: 4 } + }).setOrigin(0.5).setDepth(44); + } + + private drawCampDetails() { + const fire = this.add.graphics().setDepth(42); + fire.fillStyle(0x3d2d20, 1); + fire.fillEllipse(780, 566, 118, 48); + fire.fillStyle(0xe46a32, 0.95); + fire.fillTriangle(750, 558, 780, 478, 808, 558); + fire.fillStyle(0xf4c65b, 0.95); + fire.fillTriangle(766, 558, 786, 505, 798, 558); + this.addBlocker(728, 520, 104, 64); + + const glow = this.add.ellipse(780, 540, 250, 170, 0xf2a241, 0.11).setDepth(35); + this.tweens.add({ + targets: glow, + alpha: { from: 0.07, to: 0.16 }, + scaleX: { from: 0.94, to: 1.05 }, + scaleY: { from: 0.94, to: 1.05 }, + duration: 820, + yoyo: true, + repeat: -1, + ease: 'Sine.easeInOut' + }); + + [975, 1100, 1225].forEach((x, index) => { + const training = this.add.graphics().setDepth(39); + training.fillStyle(0x4b3526, 1); + training.fillRect(x - 6, 402, 12, 92); + training.fillRect(x - 34, 423, 68, 12); + training.fillStyle(index === 1 ? 0xb99a62 : 0x8b7350, 1); + training.fillCircle(x, 390, 25); + this.addBlocker(x - 20, 448, 40, 46); + }); + + this.add.text(1098, 360, '훈련장', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '24px', + color: '#ead3a1', + fontStyle: 'bold', + stroke: '#302219', + strokeThickness: 5 + }).setOrigin(0.5).setDepth(45); + this.add.text(356, 467, '북쪽 숲길 작전판', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '18px', + color: '#d9e1d1', + fontStyle: 'bold', + backgroundColor: '#16221acc', + padding: { left: 10, right: 10, top: 4, bottom: 4 } + }).setOrigin(0.5).setDepth(45); + } + + private addBlocker(x: number, y: number, width: number, height: number) { + this.blockers.push(new Phaser.Geom.Rectangle(x, y, width, height)); + } + + private drawHud() { + this.add.text(48, 27, '탁현 의용군 막사 · 첫 출전 직전', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '34px', + color: '#f4e3b5', + fontStyle: 'bold' + }).setDepth(1500); + this.add.text(610, 36, '맹세를 준비로 증명할 시간', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '20px', + color: '#b4c0cb' + }).setDepth(1500); + + this.add.text(1534, 45, '막사 점검', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '31px', + color: '#f4e3b5', + fontStyle: 'bold' + }).setDepth(1500); + this.add.text(1536, 92, '막사를 걸어 다니며 동료와 의병의 준비를 직접 확인하세요.', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: '#9fabb8', + wordWrap: { width: 340 } + }).setDepth(1500); + + prologueMilitiaCampObjectiveDefinitions.forEach((objective, index) => { + const y = 168 + index * 154; + const background = this.add.rectangle(1528, y, 354, 132, 0x1b222d, 0.94) + .setOrigin(0) + .setStrokeStyle(2, 0x3d4858, 1) + .setDepth(1500); + const status = this.add.text(1548, y + 19, '', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: '#d8b15f', + fontStyle: 'bold' + }).setDepth(1501); + const label = this.add.text(1548, y + 48, objective.shortLabel, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '22px', + color: '#e8dfca', + fontStyle: 'bold' + }).setDepth(1501); + const location = this.add.text(1548, y + 87, objective.location, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '16px', + color: '#9aa3ad' + }).setDepth(1501); + this.objectiveRows.set(objective.id, { id: objective.id, background, status, label, location }); + }); + + this.objectiveSummaryText = this.add.text(1536, 810, '', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '18px', + color: '#cbd3dc', + lineSpacing: 8, + wordWrap: { width: 340 } + }).setDepth(1501); + + this.add.text(46, 980, '이동', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: '#d8b15f', + fontStyle: 'bold' + }).setDepth(1502); + this.add.text(46, 1012, 'WASD / 방향키 · 바닥 클릭', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '19px', + color: '#e3e7eb' + }).setDepth(1502); + this.add.text(445, 980, '대화', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: '#d8b15f', + fontStyle: 'bold' + }).setDepth(1502); + this.add.text(445, 1012, '가까이에서 E / Space / Enter', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '19px', + color: '#e3e7eb' + }).setDepth(1502); + + this.promptBackground = this.add.rectangle(1110, 1018, 560, 58, 0x2a2119, 0.98) + .setStrokeStyle(2, palette.gold, 0.9) + .setDepth(1510) + .setVisible(false); + this.promptText = this.add.text(1110, 1018, '', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '21px', + color: '#f6e3af', + fontStyle: 'bold' + }).setOrigin(0.5).setDepth(1511).setVisible(false); + } + + private createLoadingOverlay() { + const shade = this.add.rectangle(0, 0, sceneWidth, sceneHeight, 0x080b0f, 0.72).setOrigin(0); + const panel = this.add.rectangle(sceneWidth / 2, sceneHeight / 2, 530, 126, 0x151b24, 0.98) + .setStrokeStyle(2, palette.gold, 0.8); + const text = this.add.text(sceneWidth / 2, sceneHeight / 2, '의용군 막사를 준비하는 중...', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '24px', + color: '#f3dfaa' + }).setOrigin(0.5); + this.loadingOverlay = this.add.container(0, 0, [shade, panel, text]).setDepth(4000); + } + + private createActors() { + this.playerShadow = this.add.ellipse(750, 890, 66, 25, 0x07100a, 0.45).setDepth(800); + this.player = this.createCharacterSprite('unit-liu-bei', 750, 870, characterDisplaySize + 8, 'north'); + this.player.setDepth(970); + + prologueMilitiaCampNpcDefinitions.forEach((definition) => { + const shadow = this.add.ellipse(definition.x, definition.y + 20, 62, 23, 0x07100a, 0.4) + .setDepth(100 + definition.y); + const sprite = this.createCharacterSprite( + definition.textureKey, + definition.x, + definition.y, + characterDisplaySize, + definition.direction + ); + sprite.setDepth(110 + definition.y); + const nameplate = this.add.text(definition.x, definition.y + 59, 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 roleplate = this.add.text(definition.x, definition.y + 88, definition.role, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '13px', + color: '#b9c3ca', + backgroundColor: '#14181ca8', + padding: { left: 7, right: 7, top: 2, bottom: 2 } + }).setOrigin(0.5).setDepth(1199); + const marker = definition.objectiveId || definition.departure + ? this.add.text(definition.x, definition.y - 72, '!', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '31px', + color: '#2a2013', + fontStyle: 'bold', + backgroundColor: '#e5bd68', + padding: { left: 11, right: 11, top: 2, bottom: 2 } + }).setOrigin(0.5).setDepth(1201) + : undefined; + if (marker) { + this.tweens.add({ + targets: marker, + y: marker.y - 8, + duration: 720, + yoyo: true, + repeat: -1, + ease: 'Sine.easeInOut' + }); + } + this.npcViews.set(definition.id, { definition, sprite, shadow, nameplate, roleplate, marker }); + }); + } + + private createCharacterSprite( + textureKey: string, + x: number, + y: number, + size: number, + direction: UnitDirection + ) { + const resolvedTextureKey = this.textures.exists(textureKey) ? textureKey : '__DEFAULT'; + const sprite = this.add.sprite(x, y, resolvedTextureKey, 0).setDisplaySize(size, size); + if (this.textures.exists(textureKey)) { + sprite.play(`${textureKey}-idle-${direction}`); + } + return sprite; + } + + private createDialoguePanel() { + const x = 72; + const y = 710; + const width = 1776; + const height = 304; + const shade = this.add.rectangle(0, 0, sceneWidth, sceneHeight, 0x06080b, 0.44) + .setOrigin(0) + .setInteractive(); + const background = this.add.rectangle(x, y, width, height, 0x141a23, 0.985) + .setOrigin(0) + .setStrokeStyle(3, palette.gold, 0.92); + const inner = this.add.rectangle(x + 16, y + 16, width - 32, height - 32, 0x1d2531, 0.82) + .setOrigin(0) + .setStrokeStyle(1, 0x546174, 0.7); + const avatarFrame = this.add.rectangle(x + 105, y + 152, 178, 230, 0x0e131a, 0.92) + .setStrokeStyle(2, 0x9e8350, 0.9); + this.dialogueAvatar = this.add.sprite(x + 105, y + 155, 'unit-liu-bei', 0) + .setDisplaySize(186, 186); + this.dialogueNameText = this.add.text(x + 222, y + 42, '', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '29px', + color: '#f1d691', + fontStyle: 'bold' + }); + this.dialogueBodyText = this.add.text(x + 222, y + 101, '', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '27px', + color: '#f0ede5', + lineSpacing: 11, + wordWrap: { width: 1440, useAdvancedWrap: true } + }); + this.dialogueProgressText = this.add.text(x + width - 34, y + height - 33, '', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '18px', + color: '#aeb8c4' + }).setOrigin(1, 0.5); + + this.dialoguePanel = this.add.container(0, 0, [ + shade, + background, + inner, + avatarFrame, + this.dialogueAvatar, + this.dialogueNameText, + this.dialogueBodyText, + this.dialogueProgressText + ]).setDepth(3000).setVisible(false); + } + + private setupInput() { + const keyboard = this.input.keyboard; + if (keyboard) { + this.cursorKeys = keyboard.createCursorKeys(); + this.moveKeys = { + up: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.W), + down: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.S), + left: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.A), + right: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.D) + }; + this.interactKeys = [ + keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.E), + keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ENTER), + keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE) + ]; + this.interactKeys.forEach((key) => { + key.on('down', () => { + this.interactionQueued = true; + }); + }); + } + + this.input.on(Phaser.Input.Events.POINTER_DOWN, (pointer: Phaser.Input.Pointer) => { + if (!this.ready || this.navigationPending || this.time.now < this.inputReadyAt) { + return; + } + if (this.dialogueState) { + this.advanceDialogue(); + return; + } + if ( + pointer.x >= movementBounds.left && + pointer.x <= movementBounds.right && + pointer.y >= movementBounds.top && + pointer.y <= movementBounds.bottom + ) { + this.moveTarget = new Phaser.Math.Vector2(pointer.x, pointer.y); + } + }); + } + + private updateMovement(time: number, delta: number) { + if (!this.player) { + return; + } + const keyboardDirection = this.keyboardMovementDirection(); + let movement = keyboardDirection; + if (movement.lengthSq() > 0) { + this.moveTarget = undefined; + } else if (this.moveTarget) { + const targetDelta = this.moveTarget.clone().subtract( + new Phaser.Math.Vector2(this.player.x, this.player.y) + ); + if (targetDelta.length() <= 7) { + this.moveTarget = undefined; + movement = new Phaser.Math.Vector2(); + } else { + movement = targetDelta.normalize(); + } + } + + if (movement.lengthSq() <= 0) { + this.setPlayerMoving(false); + return; + } + + movement.normalize(); + this.playerDirection = this.directionForVector(movement); + const distance = playerSpeed * Math.min(delta, 50) / 1000; + const startX = this.player.x; + const startY = this.player.y; + const nextX = startX + movement.x * distance; + const nextY = startY + movement.y * distance; + + let moved = false; + if (this.canPlayerOccupy(nextX, startY)) { + this.player.x = nextX; + moved = true; + } + if (this.canPlayerOccupy(this.player.x, nextY)) { + this.player.y = nextY; + moved = true; + } + if (!moved && this.moveTarget) { + this.moveTarget = undefined; + } + + this.syncPlayerView(); + this.setPlayerMoving(moved); + if (moved && time - this.lastStepAt >= 280) { + this.lastStepAt = time; + this.stepIndex += 1; + soundDirector.playMovementStep(false, this.stepIndex, { + terrain: 'plain', + volume: 0.09, + rate: 1.01, + stopAfterMs: 620 + }); + } + } + + private consumeInteractionRequest() { + const keyPressedThisFrame = this.interactKeys.some((key) => Phaser.Input.Keyboard.JustDown(key)); + const requested = this.interactionQueued || keyPressedThisFrame; + this.interactionQueued = false; + return requested; + } + + private keyboardMovementDirection() { + const vector = new Phaser.Math.Vector2(); + if (this.cursorKeys?.left.isDown || this.moveKeys?.left.isDown) { + vector.x -= 1; + } + if (this.cursorKeys?.right.isDown || this.moveKeys?.right.isDown) { + vector.x += 1; + } + if (this.cursorKeys?.up.isDown || this.moveKeys?.up.isDown) { + vector.y -= 1; + } + if (this.cursorKeys?.down.isDown || this.moveKeys?.down.isDown) { + vector.y += 1; + } + return vector; + } + + private directionForVector(vector: Phaser.Math.Vector2): UnitDirection { + if (Math.abs(vector.x) > Math.abs(vector.y)) { + return vector.x > 0 ? 'east' : 'west'; + } + return vector.y > 0 ? 'south' : 'north'; + } + + private canPlayerOccupy(x: number, y: number) { + if ( + x - playerCollisionRadius < movementBounds.left || + x + playerCollisionRadius > movementBounds.right || + y - playerCollisionRadius < movementBounds.top || + y + playerCollisionRadius > movementBounds.bottom + ) { + return false; + } + const feet = new Phaser.Geom.Rectangle( + x - playerCollisionRadius, + y + 18, + playerCollisionRadius * 2, + 28 + ); + if (this.blockers.some((blocker) => Phaser.Geom.Intersects.RectangleToRectangle(feet, blocker))) { + return false; + } + return Array.from(this.npcViews.values()).every(({ sprite }) => ( + Phaser.Math.Distance.Between(x, y, sprite.x, sprite.y) >= 48 + )); + } + + private setPlayerMoving(moving: boolean) { + if (!this.player) { + return; + } + const animationKey = `unit-liu-bei-${moving ? 'walk' : 'idle'}-${this.playerDirection}`; + if (this.textures.exists('unit-liu-bei') && this.player.anims.currentAnim?.key !== animationKey) { + this.player.play(animationKey); + } + this.playerMoving = moving; + } + + private stopPlayerMovement() { + this.moveTarget = undefined; + this.setPlayerMoving(false); + } + + private syncPlayerView() { + if (!this.player) { + return; + } + this.playerShadow?.setPosition(this.player.x, this.player.y + 20); + this.playerShadow?.setDepth(90 + this.player.y); + this.player.setDepth(100 + this.player.y); + } + + private setPlayerPosition(x: number, y: number) { + if (!this.player) { + return; + } + this.player.setPosition(x, y); + this.syncPlayerView(); + this.stopPlayerMovement(); + } + + private safeApproachPosition(targetX: number, targetY: number) { + const candidates = [ + { x: targetX, y: targetY + 88 }, + { x: targetX - 88, y: targetY }, + { x: targetX + 88, y: targetY }, + { x: targetX, y: targetY - 88 } + ]; + return candidates.find((candidate) => this.canPlayerOccupy(candidate.x, candidate.y)) ?? { + x: Phaser.Math.Clamp(targetX, movementBounds.left + 40, movementBounds.right - 40), + y: Phaser.Math.Clamp(targetY + 100, movementBounds.top + 40, movementBounds.bottom - 40) + }; + } + + private tryInteract() { + const view = this.nearestNpc(interactionRadius); + if (!view) { + this.showPromptMessage('조금 더 가까이 다가가세요.'); + return; + } + this.interactWithNpc(view); + } + + private interactWithNpc(view: CampNpcView) { + this.stopPlayerMovement(); + this.faceCharactersForConversation(view); + if (view.definition.departure) { + if (!this.allRequiredObjectivesComplete()) { + this.startDialogue(view.definition.lockedDialogue ?? [], undefined, view.definition.id); + return; + } + this.gatherCommandParty(); + this.startDialogue(view.definition.dialogue, () => this.finishCamp(), view.definition.id); + return; + } + + const completed = view.definition.objectiveId + ? this.isObjectiveComplete(view.definition.objectiveId) + : false; + const dialogue = completed && view.definition.repeatDialogue?.length + ? view.definition.repeatDialogue + : view.definition.dialogue; + this.startDialogue(dialogue, () => { + if (view.definition.objectiveId && !completed) { + this.completeObjective(view.definition.objectiveId); + } + }, view.definition.id); + } + + private startDialogue( + lines: PrologueVillageDialogueLine[], + onComplete?: () => void, + sourceNpcId?: string + ) { + if (!lines.length || this.navigationPending) { + return; + } + this.stopPlayerMovement(); + this.dialogueState = { lines, lineIndex: 0, onComplete, sourceNpcId }; + this.dialoguePanel?.setVisible(true); + this.promptBackground?.setVisible(false); + this.promptText?.setVisible(false); + soundDirector.playSelect(); + this.renderDialogueLine(); + } + + private renderDialogueLine() { + const dialogue = this.dialogueState; + if (!dialogue) { + return; + } + const line = dialogue.lines[dialogue.lineIndex]; + this.dialogueNameText?.setText(line.speaker); + this.dialogueBodyText?.setText(line.text); + this.dialogueProgressText?.setText( + `${dialogue.lineIndex + 1} / ${dialogue.lines.length} E · Space · 클릭으로 계속` + ); + if (this.dialogueAvatar) { + const textureKey = line.textureKey; + if (textureKey && this.textures.exists(textureKey)) { + this.dialogueAvatar.setTexture(textureKey, 0).setVisible(true); + this.dialogueAvatar.play(`${textureKey}-idle-south`); + } else { + this.dialogueAvatar.setVisible(false); + } + } + } + + private advanceDialogue() { + const dialogue = this.dialogueState; + if (!dialogue || this.navigationPending) { + return; + } + if (dialogue.lineIndex < dialogue.lines.length - 1) { + dialogue.lineIndex += 1; + soundDirector.playStoryAdvanceCue(); + this.renderDialogueLine(); + return; + } + const onComplete = dialogue.onComplete; + this.dialogueState = undefined; + this.dialoguePanel?.setVisible(false); + this.stopPlayerMovement(); + onComplete?.(); + this.refreshInteractionPrompt(); + } + + private completeObjective(objectiveId: PrologueMilitiaCampRequiredObjectiveId) { + if (this.isObjectiveComplete(objectiveId)) { + return; + } + completeCampaignTutorial(objectiveTutorialIds[objectiveId]); + soundDirector.playObjectiveAchieved(); + this.refreshObjectiveHud(); + this.refreshNpcMarkers(); + const objective = prologueMilitiaCampObjectiveDefinitions.find((entry) => entry.id === objectiveId); + this.showCompletionToast(`${objective?.shortLabel ?? '점검'} 완료`); + } + + private finishCamp() { + if (this.navigationPending) { + return; + } + if (!this.allRequiredObjectivesComplete()) { + const commander = this.npcViews.get('zou-jing'); + if (commander) { + this.interactWithNpc(commander); + } + return; + } + + completeCampaignTutorial(prologueMilitiaCampCampaignTutorialIds.complete); + this.refreshObjectiveHud(); + this.navigationPending = true; + this.stopPlayerMovement(); + soundDirector.playObjectiveAchieved(); + this.showTransitionOverlay(); + + void startGameScene(this, 'StoryScene', { + pages: prologueDeparturePages(), + nextScene: 'BattleScene', + nextSceneData: { battleId: 'first-battle-zhuo-commandery' }, + presentationBattleId: 'first-battle-zhuo-commandery', + presentationStage: 'story' + }).catch((error) => { + console.error('Failed to continue from the prologue militia camp.', error); + this.navigationPending = false; + this.transitionOverlay?.destroy(); + this.transitionOverlay = undefined; + this.refreshInteractionPrompt(); + }); + } + + private showTransitionOverlay() { + const shade = this.add.rectangle(0, 0, sceneWidth, sceneHeight, 0x080b0f, 0.8).setOrigin(0); + const title = this.add.text(sceneWidth / 2, sceneHeight / 2 - 32, '탁현 의용군 · 북문 집결', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '44px', + color: '#f3dda2', + fontStyle: 'bold' + }).setOrigin(0.5); + const subtitle = this.add.text(sceneWidth / 2, sceneHeight / 2 + 44, '세 형제와 의병 선봉이 황건군의 진지로 향합니다.', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '24px', + color: '#d7dde4' + }).setOrigin(0.5); + this.transitionOverlay = this.add.container(0, 0, [shade, title, subtitle]) + .setDepth(5000) + .setAlpha(0); + this.tweens.add({ + targets: this.transitionOverlay, + alpha: 1, + duration: 320, + ease: 'Sine.easeOut' + }); + } + + private showCompletionToast(label: string) { + this.completionToast?.destroy(); + const background = this.add.rectangle(748, 132, 400, 70, 0x17251c, 0.98) + .setStrokeStyle(2, 0x8fbd72, 0.9); + const text = this.add.text(748, 132, `✓ ${label}`, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '24px', + color: '#dff0c9', + fontStyle: 'bold' + }).setOrigin(0.5); + this.completionToast = this.add.container(0, 0, [background, text]).setDepth(2200); + this.tweens.add({ + targets: this.completionToast, + alpha: 0, + y: -18, + delay: 1150, + duration: 360, + onComplete: () => { + this.completionToast?.destroy(); + this.completionToast = undefined; + } + }); + } + + private showPromptMessage(message: string) { + this.promptBackground?.setVisible(true); + this.promptText?.setText(message).setVisible(true); + this.time.delayedCall(850, () => this.refreshInteractionPrompt()); + } + + private refreshObjectiveHud() { + const completedRequired = this.completedRequiredObjectiveIds(); + prologueMilitiaCampObjectiveDefinitions.forEach((objective) => { + const row = this.objectiveRows.get(objective.id); + if (!row) { + return; + } + const departure = objective.id === 'receive-command'; + const completed = objective.id === 'receive-command' + ? hasCompletedCampaignTutorial(prologueMilitiaCampCampaignTutorialIds.complete) + : this.isObjectiveComplete(objective.id); + const unlocked = !departure || this.allRequiredObjectivesComplete(); + row.status.setText(completed ? '✓ 완료' : unlocked ? '◆ 진행 가능' : '◇ 점검 후 개방'); + row.status.setColor(completed ? '#a8d58e' : unlocked ? '#e1bc69' : '#7f8994'); + row.label.setColor(completed ? '#b8c4b3' : unlocked ? '#f0e6d2' : '#7f8994'); + row.location.setColor(completed ? '#7f9b78' : unlocked ? '#a4afba' : '#666f79'); + row.background.setStrokeStyle( + 2, + completed ? 0x628653 : unlocked ? 0x8a7043 : 0x3d4858, + completed ? 0.9 : 0.75 + ); + }); + + const remaining = prologueMilitiaCampRequiredObjectiveIds.length - completedRequired.length; + this.objectiveSummaryText?.setText( + remaining > 0 + ? `필수 점검 ${completedRequired.length} / ${prologueMilitiaCampRequiredObjectiveIds.length}\n세 항목은 원하는 순서로 확인할 수 있습니다.` + : '막사 점검 완료\n북쪽 지휘 막사의 추정에게 보고하세요.' + ); + } + + private refreshNpcMarkers() { + this.npcViews.forEach(({ definition, marker }) => { + if (!marker) { + return; + } + if (definition.departure) { + const unlocked = this.allRequiredObjectivesComplete(); + marker.setText(unlocked ? '!' : '◇'); + marker.setColor(unlocked ? '#2a2013' : '#9b8f7d'); + marker.setBackgroundColor(unlocked ? '#f1c96f' : '#343b45'); + return; + } + if (!definition.objectiveId) { + return; + } + const completed = this.isObjectiveComplete(definition.objectiveId); + marker.setText(completed ? '✓' : '!'); + marker.setColor(completed ? '#e5f2d5' : '#2a2013'); + marker.setBackgroundColor(completed ? '#4e7549' : '#e5bd68'); + }); + } + + private refreshInteractionPrompt() { + if (!this.ready || this.dialogueState || this.navigationPending) { + this.promptBackground?.setVisible(false); + this.promptText?.setVisible(false); + return; + } + const target = this.nearestNpc(promptRadius); + if (!target) { + this.promptBackground?.setVisible(false); + this.promptText?.setVisible(false); + return; + } + const closeEnough = this.distanceTo(target.sprite.x, target.sprite.y) <= interactionRadius; + const lockedSuffix = target.definition.departure && !this.allRequiredObjectivesComplete() + ? ' · 점검 필요' + : ''; + this.promptBackground?.setVisible(true); + this.promptText + ?.setText( + closeEnough + ? `E / Space ${target.definition.name}${lockedSuffix}` + : `${target.definition.name}에게 가까이 가세요` + ) + .setColor(closeEnough ? '#f6e3af' : '#b2bbc4') + .setVisible(true); + } + + private nearestNpc(radius: number) { + if (!this.player) { + return undefined; + } + return Array.from(this.npcViews.values()) + .map((view) => ({ + view, + distance: this.distanceTo(view.sprite.x, view.sprite.y) + })) + .filter(({ distance }) => distance <= radius) + .sort((left, right) => left.distance - right.distance)[0]?.view; + } + + private faceCharactersForConversation(view: CampNpcView) { + if (!this.player) { + return; + } + const toNpc = new Phaser.Math.Vector2( + view.sprite.x - this.player.x, + view.sprite.y - this.player.y + ); + this.playerDirection = this.directionForVector(toNpc); + this.setPlayerMoving(false); + const npcDirection = this.directionForVector(toNpc.scale(-1)); + if (this.textures.exists(view.definition.textureKey)) { + view.sprite.play(`${view.definition.textureKey}-idle-${npcDirection}`); + } + } + + private gatherCommandParty() { + this.setNpcPosition('guan-yu', 650, 420, 'north'); + this.setNpcPosition('zhang-fei', 890, 420, 'north'); + } + + private setNpcPosition( + npcId: string, + x: number, + y: number, + direction: UnitDirection + ) { + const view = this.npcViews.get(npcId); + if (!view) { + return; + } + view.sprite.setPosition(x, y).setDepth(110 + y); + view.shadow.setPosition(x, y + 20).setDepth(100 + y); + view.nameplate.setPosition(x, y + 59); + view.roleplate.setPosition(x, y + 88); + view.marker?.setPosition(x, y - 72).setVisible(false); + if (this.textures.exists(view.definition.textureKey)) { + view.sprite.play(`${view.definition.textureKey}-idle-${direction}`); + } + } + + private isObjectiveComplete(objectiveId: PrologueMilitiaCampRequiredObjectiveId) { + return hasCompletedCampaignTutorial(objectiveTutorialIds[objectiveId]); + } + + private completedRequiredObjectiveIds() { + return prologueMilitiaCampRequiredObjectiveIds.filter((objectiveId) => ( + this.isObjectiveComplete(objectiveId) + )); + } + + private allRequiredObjectivesComplete() { + return this.completedRequiredObjectiveIds().length === prologueMilitiaCampRequiredObjectiveIds.length; + } + + private distanceTo(x: number, y: number) { + return this.player + ? Phaser.Math.Distance.Between(this.player.x, this.player.y, x, y) + : Number.POSITIVE_INFINITY; + } + + private boundsSnapshot(bounds: Phaser.Geom.Rectangle) { + return { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height + }; + } +} diff --git a/src/game/scenes/PrologueVillageScene.ts b/src/game/scenes/PrologueVillageScene.ts index 26f12bc..c4f7408 100644 --- a/src/game/scenes/PrologueVillageScene.ts +++ b/src/game/scenes/PrologueVillageScene.ts @@ -1,7 +1,7 @@ import Phaser from 'phaser'; import { soundDirector } from '../audio/SoundDirector'; import { - prologueDeparturePages, + prologueBrotherhoodPages, prologueVillageNpcDefinitions, prologueVillageObjectiveDefinitions, prologueVillageRequiredObjectiveIds, @@ -75,7 +75,7 @@ type ObjectiveRowView = { const objectiveTutorialIds: Record = { 'meet-zhang-fei': prologueVillageCampaignTutorialIds.meetZhangFei, 'meet-guan-yu': prologueVillageCampaignTutorialIds.meetGuanYu, - 'check-supplies': prologueVillageCampaignTutorialIds.checkSupplies + 'register-volunteers': prologueVillageCampaignTutorialIds.registerVolunteers }; export class PrologueVillageScene extends Phaser.Scene { @@ -150,6 +150,7 @@ export class PrologueVillageScene extends Phaser.Scene { } ensureUnitAnimations(this, characterTextureKeys); this.createActors(); + this.restoreNarrativeActorPositions(); this.createDialoguePanel(); this.loadingOverlay?.destroy(); this.loadingOverlay = undefined; @@ -164,7 +165,7 @@ export class PrologueVillageScene extends Phaser.Scene { { speaker: '유비', textureKey: 'unit-liu-bei', - text: '장터에서 뜻을 함께할 사람을 찾고 의병소의 준비를 확인하자. 모든 준비가 끝나면 북쪽 복숭아 동산으로 가야겠다.' + text: '방금 등 뒤에서 들린 목소리의 주인을 찾아보자. 서쪽 격문 앞의 호걸에게 다가가 그 뜻을 들어 보자.' } ]); } @@ -246,7 +247,9 @@ export class PrologueVillageScene extends Phaser.Scene { completed: objective.id === 'make-oath' ? hasCompletedCampaignTutorial(prologueVillageCampaignTutorialIds.complete) : this.isObjectiveComplete(objective.id), - unlocked: objective.id !== 'make-oath' || this.allRequiredObjectivesComplete() + unlocked: objective.id === 'make-oath' + ? this.allRequiredObjectivesComplete() + : this.objectiveUnlocked(objective.id) })), completedObjectiveIds: this.completedRequiredObjectiveIds(), exitUnlocked: this.allRequiredObjectivesComplete(), @@ -350,8 +353,9 @@ export class PrologueVillageScene extends Phaser.Scene { private prepareCampaignProgress() { const campaign = getCampaignState(); - this.firstVisit = campaign.step !== 'prologue-town' && - !campaign.completedTutorialIds.includes(prologueVillageCampaignTutorialIds.entered); + this.firstVisit = !campaign.completedTutorialIds.includes( + prologueVillageCampaignTutorialIds.entered + ); if (campaign.step === 'new' || campaign.step === 'prologue') { markCampaignStep('prologue-town'); } @@ -427,7 +431,7 @@ export class PrologueVillageScene extends Phaser.Scene { height: 236, wallColor: 0xb99a68, roofColor: 0x6d2f26, - sign: '장터 주점' + sign: '탁현 관아' }); this.addBlocker(105, 160, 410, 236); @@ -438,7 +442,7 @@ export class PrologueVillageScene extends Phaser.Scene { height: 226, wallColor: 0xc2a974, roofColor: 0x31475a, - sign: '동쪽 장터' + sign: '장터 주점' }); this.addBlocker(990, 148, 390, 226); @@ -449,7 +453,7 @@ export class PrologueVillageScene extends Phaser.Scene { height: 215, wallColor: 0xa98d61, roofColor: 0x493a32, - sign: '탁현 의병소' + sign: '탁현 모병소' }); this.addBlocker(1120, 665, 310, 215); @@ -542,6 +546,22 @@ export class PrologueVillageScene extends Phaser.Scene { weapons.lineBetween(1070, 710, 1110, 805); weapons.lineBetween(1110, 710, 1070, 805); + const notice = this.add.graphics().setDepth(34); + notice.fillStyle(0x4d3826, 1); + notice.fillRect(264, 412, 12, 92); + notice.fillRect(440, 412, 12, 92); + notice.fillRect(250, 402, 216, 18); + notice.fillStyle(0xe3d4a8, 1); + notice.fillRect(286, 427, 144, 62); + notice.lineStyle(2, 0x765e3d, 0.8); + notice.strokeRect(286, 427, 144, 62); + this.add.text(358, 457, '의병 모집', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '20px', + color: '#3c2a1c', + fontStyle: 'bold' + }).setOrigin(0.5).setDepth(35); + this.add.text(780, 244, '복숭아 동산', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '28px', @@ -588,13 +608,13 @@ export class PrologueVillageScene extends Phaser.Scene { } private drawHud() { - this.add.text(48, 27, '탁현 · 황건 봉기 직전', { + this.add.text(48, 27, '탁현 · 의병 모집 격문이 붙은 날', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '34px', color: '#f4e3b5', fontStyle: 'bold' }).setDepth(1500); - this.add.text(460, 36, '유비의 첫걸음', { + this.add.text(650, 36, '유비의 첫걸음', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '20px', color: '#b4c0cb' @@ -670,11 +690,11 @@ export class PrologueVillageScene extends Phaser.Scene { color: '#e3e7eb' }).setDepth(1502); - this.promptBackground = this.add.rectangle(870, 1018, 540, 58, 0x2a2119, 0.98) + this.promptBackground = this.add.rectangle(1110, 1018, 560, 58, 0x2a2119, 0.98) .setStrokeStyle(2, palette.gold, 0.9) .setDepth(1510) .setVisible(false); - this.promptText = this.add.text(870, 1018, '', { + this.promptText = this.add.text(1110, 1018, '', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '21px', color: '#f6e3af', @@ -1019,6 +1039,43 @@ export class PrologueVillageScene extends Phaser.Scene { this.stopPlayerMovement(); } + private gatherOathParty() { + this.playerDirection = 'north'; + this.setPlayerPosition(oathPosition.x, oathPosition.y + 125); + this.setNpcPosition('guan-yu', oathPosition.x - 104, oathPosition.y + 32, 'east'); + this.setNpcPosition('zhang-fei', oathPosition.x + 104, oathPosition.y + 32, 'west'); + } + + private restoreNarrativeActorPositions() { + if (this.isObjectiveComplete('meet-guan-yu')) { + this.setNpcPosition('guan-yu', 850, 655, 'east'); + this.setNpcPosition('zhang-fei', 810, 805, 'east'); + return; + } + if (this.isObjectiveComplete('meet-zhang-fei')) { + this.setNpcPosition('zhang-fei', 1000, 540, 'east'); + } + } + + private setNpcPosition( + npcId: string, + x: number, + y: number, + direction: UnitDirection + ) { + const view = this.npcViews.get(npcId); + if (!view) { + return; + } + view.sprite.setPosition(x, y).setDepth(110 + y); + view.shadow.setPosition(x, y + 20).setDepth(100 + y); + view.nameplate.setPosition(x, y + 63); + view.marker?.setPosition(x, y - 72).setVisible(false); + if (this.textures.exists(view.definition.textureKey)) { + view.sprite.play(`${view.definition.textureKey}-idle-${direction}`); + } + } + private safeApproachPosition(targetX: number, targetY: number) { const candidates = [ { x: targetX, y: targetY + 88 }, @@ -1055,11 +1112,14 @@ export class PrologueVillageScene extends Phaser.Scene { const completed = view.definition.objectiveId ? this.isObjectiveComplete(view.definition.objectiveId) : false; - const dialogue = completed && view.definition.repeatDialogue?.length - ? view.definition.repeatDialogue - : view.definition.dialogue; + const unlocked = !view.definition.objectiveId || this.objectiveUnlocked(view.definition.objectiveId); + const dialogue = !unlocked && view.definition.lockedDialogue?.length + ? view.definition.lockedDialogue + : completed && view.definition.repeatDialogue?.length + ? view.definition.repeatDialogue + : view.definition.dialogue; this.startDialogue(dialogue, () => { - if (view.definition.objectiveId && !completed) { + if (view.definition.objectiveId && unlocked && !completed) { this.completeObjective(view.definition.objectiveId); } }, view.definition.id); @@ -1081,35 +1141,27 @@ export class PrologueVillageScene extends Phaser.Scene { return; } + this.gatherOathParty(); this.startDialogue([ + { + speaker: '장비', + textureKey: 'unit-zhang-fei', + text: '내 장원 뒤 복숭아꽃이 한창이오. 싸움에 앞서 서로의 등을 맡길 뜻부터 하늘과 땅에 고합시다.' + }, { speaker: '유비', textureKey: 'unit-liu-bei', - text: '우리는 성은 다르나 이제 형제가 되어, 위로는 나라에 보답하고 아래로는 백성을 편안하게 하겠습니다.' + text: '좋소. 성은 서로 다르지만 백성을 구하려는 뜻은 하나이니, 오늘부터 형제가 되어 힘을 합칩시다.' }, { speaker: '관우', textureKey: 'unit-guan-yu', - text: '한마음으로 힘을 합쳐 어려움 속에서도 서로를 저버리지 않겠습니다.' + text: '의를 저버리지 않고 형제를 저버리지 않겠습니다. 어려움이 닥쳐도 함께 맞서겠습니다.' }, { speaker: '장비', textureKey: 'unit-zhang-fei', - text: '좋소! 오늘부터 함께 싸워 이 마을과 백성을 지킵시다!' - }, - { - speaker: '전황', - text: '세 사람의 뜻이 하나로 공명했다. 탁현 의용군이 북문 앞에 모이기 시작했다.' - }, - { - speaker: '관우', - textureKey: 'unit-guan-yu', - text: '황건 잔당은 북동쪽 길목에 있습니다. 장비가 측면을 흔들면 제가 정면을 열겠습니다.' - }, - { - speaker: '유비', - textureKey: 'unit-liu-bei', - text: '좋소. 부상자를 지키며 전열을 잇겠습니다. 이제 첫 출전입니다.' + text: '형님들의 뜻을 내 목숨처럼 지키겠소. 결의를 마치면 내 장원 밖 막사에서 의병들을 함께 살핍시다!' } ], () => this.finishVillage(), 'make-oath'); } @@ -1178,6 +1230,12 @@ export class PrologueVillageScene extends Phaser.Scene { return; } completeCampaignTutorial(objectiveTutorialIds[objectiveId]); + if (objectiveId === 'meet-zhang-fei') { + this.setNpcPosition('zhang-fei', 1000, 540, 'east'); + } else if (objectiveId === 'meet-guan-yu') { + this.setNpcPosition('guan-yu', 850, 655, 'east'); + this.setNpcPosition('zhang-fei', 810, 805, 'east'); + } soundDirector.playObjectiveAchieved(); this.refreshObjectiveHud(); this.refreshNpcMarkers(); @@ -1202,9 +1260,8 @@ export class PrologueVillageScene extends Phaser.Scene { this.showTransitionOverlay(); void startGameScene(this, 'StoryScene', { - pages: prologueDeparturePages(), - nextScene: 'BattleScene', - nextSceneData: { battleId: 'first-battle-zhuo-commandery' }, + pages: prologueBrotherhoodPages(), + nextScene: 'PrologueMilitiaCampScene', presentationBattleId: 'first-battle-zhuo-commandery', presentationStage: 'story' }).catch((error) => { @@ -1218,13 +1275,13 @@ export class PrologueVillageScene extends Phaser.Scene { private showTransitionOverlay() { const shade = this.add.rectangle(0, 0, sceneWidth, sceneHeight, 0x080b0f, 0.78).setOrigin(0); - const title = this.add.text(sceneWidth / 2, sceneHeight / 2 - 32, '탁현 의용군 · 첫 출전', { + 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, '북문 밖의 황건 잔당을 향해 이동합니다.', { + const subtitle = this.add.text(sceneWidth / 2, sceneHeight / 2 + 44, '맹세를 마친 뒤 의용군 막사에서 첫 출전을 준비합니다.', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '24px', color: '#d7dde4' @@ -1284,7 +1341,9 @@ export class PrologueVillageScene extends Phaser.Scene { } else { completed = this.isObjectiveComplete(objective.id); } - const unlocked = !isOath || this.allRequiredObjectivesComplete(); + const unlocked = isOath + ? this.allRequiredObjectivesComplete() + : this.objectiveUnlocked(objective.id); row.status.setText(completed ? '✓ 완료' : unlocked ? '◆ 진행 가능' : '◇ 준비 후 개방'); row.status.setColor(completed ? '#a8d58e' : unlocked ? '#e1bc69' : '#7f8994'); row.label.setColor(completed ? '#b8c4b3' : unlocked ? '#f0e6d2' : '#7f8994'); @@ -1299,7 +1358,7 @@ export class PrologueVillageScene extends Phaser.Scene { const remaining = prologueVillageRequiredObjectiveIds.length - requiredComplete.length; this.objectiveSummaryText?.setText( remaining > 0 - ? `필수 준비 ${requiredComplete.length} / ${prologueVillageRequiredObjectiveIds.length}\n세 항목은 원하는 순서로 확인할 수 있습니다.` + ? `만남의 흐름 ${requiredComplete.length} / ${prologueVillageRequiredObjectiveIds.length}\n◆ 표시된 다음 인물을 찾아 이야기하세요.` : '모든 준비 완료\n복숭아 동산의 빛나는 표식으로 이동하세요.' ); if (this.oathMarker) { @@ -1318,9 +1377,10 @@ export class PrologueVillageScene extends Phaser.Scene { return; } const completed = this.isObjectiveComplete(definition.objectiveId); - marker.setText(completed ? '✓' : '!'); - marker.setColor(completed ? '#e5f2d5' : '#2a2013'); - marker.setBackgroundColor(completed ? '#4e7549' : '#e5bd68'); + const unlocked = this.objectiveUnlocked(definition.objectiveId); + marker.setText(completed ? '✓' : unlocked ? '!' : '◇'); + marker.setColor(completed ? '#e5f2d5' : unlocked ? '#2a2013' : '#9b8f7d'); + marker.setBackgroundColor(completed ? '#4e7549' : unlocked ? '#e5bd68' : '#343b45'); }); } @@ -1409,6 +1469,14 @@ export class PrologueVillageScene extends Phaser.Scene { return hasCompletedCampaignTutorial(objectiveTutorialIds[objectiveId]); } + private objectiveUnlocked(objectiveId: PrologueVillageRequiredObjectiveId | 'make-oath') { + if (objectiveId === 'make-oath') { + return this.allRequiredObjectivesComplete(); + } + const objective = prologueVillageObjectiveDefinitions.find((entry) => entry.id === objectiveId); + return !objective?.prerequisiteId || this.isObjectiveComplete(objective.prerequisiteId); + } + private completedRequiredObjectiveIds() { return prologueVillageRequiredObjectiveIds.filter((objectiveId) => this.isObjectiveComplete(objectiveId)); } diff --git a/src/game/scenes/StoryScene.ts b/src/game/scenes/StoryScene.ts index 4a8f531..90ec631 100644 --- a/src/game/scenes/StoryScene.ts +++ b/src/game/scenes/StoryScene.ts @@ -304,6 +304,8 @@ export class StoryScene extends Phaser.Scene { ? 'battle-deployment' : isLastPage && this.nextScene === 'PrologueVillageScene' ? 'village-exploration' + : isLastPage && this.nextScene === 'PrologueMilitiaCampScene' + ? 'militia-camp-exploration' : isLastPage && this.nextScene === 'CampScene' ? 'camp-return' : isLastPage @@ -584,6 +586,11 @@ export class StoryScene extends Phaser.Scene { console.warn('Failed to warm the prologue village scene module.', error); }); } + if (this.nextScene === 'PrologueMilitiaCampScene') { + void ensureLazyScene(this.game, 'PrologueMilitiaCampScene').catch((error) => { + console.warn('Failed to warm the prologue militia camp scene module.', error); + }); + } if (!this.isFirstBattlePrelude()) { return; } @@ -889,6 +896,9 @@ export class StoryScene extends Phaser.Scene { if (this.nextScene === 'PrologueVillageScene') { return '마을로 이동'; } + if (this.nextScene === 'PrologueMilitiaCampScene') { + return '의용군 막사로'; + } if (this.nextScene === 'CampScene') { return '군영으로'; } diff --git a/src/game/scenes/TitleScene.ts b/src/game/scenes/TitleScene.ts index d51f10f..791ec73 100644 --- a/src/game/scenes/TitleScene.ts +++ b/src/game/scenes/TitleScene.ts @@ -19,13 +19,18 @@ import { saveVisualMotionMode, visualMotionModeLabel } from '../settings/visualMotion'; -import { prologueDeparturePages, prologueOpeningPages } from '../data/prologueVillage'; +import { + prologueBrotherhoodPages, + prologueDeparturePages, + prologueOpeningPages +} from '../data/prologueVillage'; import { battleIdForCampaignStep, isCampCampaignStep } from '../state/campaignRouting'; import { readCampaignBattleResume, type CampaignBattleResume } from '../state/battleSaveStorage'; import { hasCampaignSave, listCampaignSaveSlots, loadCampaignState, + prologueMilitiaCampCampaignTutorialIds, prologueVillageCampaignTutorialIds, summarizeCampaignProgress, startNewCampaign, @@ -952,6 +957,20 @@ export class TitleScene extends Phaser.Scene { if (campaign.step === 'prologue-town') { if (campaign.completedTutorialIds.includes(prologueVillageCampaignTutorialIds.complete)) { + await this.navigateTo('StoryScene', { + pages: prologueBrotherhoodPages(), + nextScene: 'PrologueMilitiaCampScene', + presentationBattleId: 'first-battle-zhuo-commandery', + presentationStage: 'story' + }); + } else { + await this.navigateTo('PrologueVillageScene'); + } + return; + } + + if (campaign.step === 'prologue-camp') { + if (campaign.completedTutorialIds.includes(prologueMilitiaCampCampaignTutorialIds.complete)) { await this.navigateTo('StoryScene', { pages: prologueDeparturePages(), nextScene: 'BattleScene', @@ -960,7 +979,7 @@ export class TitleScene extends Phaser.Scene { presentationStage: 'story' }); } else { - await this.navigateTo('PrologueVillageScene'); + await this.navigateTo('PrologueMilitiaCampScene'); } return; } diff --git a/src/game/scenes/lazyScenes.ts b/src/game/scenes/lazyScenes.ts index b4de44e..20bff74 100644 --- a/src/game/scenes/lazyScenes.ts +++ b/src/game/scenes/lazyScenes.ts @@ -3,6 +3,7 @@ import Phaser from 'phaser'; export type LazySceneKey = | 'StoryScene' | 'PrologueVillageScene' + | 'PrologueMilitiaCampScene' | 'CityStayScene' | 'BattleScene' | 'CampScene' @@ -13,6 +14,7 @@ type LazySceneConstructor = new () => Phaser.Scene; const lazySceneLoaders: Record Promise> = { StoryScene: () => import('./StoryScene').then((module) => module.StoryScene), PrologueVillageScene: () => import('./PrologueVillageScene').then((module) => module.PrologueVillageScene), + PrologueMilitiaCampScene: () => import('./PrologueMilitiaCampScene').then((module) => module.PrologueMilitiaCampScene), CityStayScene: () => import('./CityStayScene').then((module) => module.CityStayScene), BattleScene: () => import('./BattleScene').then((module) => module.BattleScene), CampScene: () => import('./CampScene').then((module) => module.CampScene), @@ -25,6 +27,7 @@ export function isLazySceneKey(key: string): key is LazySceneKey { return ( key === 'StoryScene' || key === 'PrologueVillageScene' || + key === 'PrologueMilitiaCampScene' || key === 'CityStayScene' || key === 'BattleScene' || key === 'CampScene' || diff --git a/src/game/state/campaignRouting.ts b/src/game/state/campaignRouting.ts index ce1ec55..0f3168f 100644 --- a/src/game/state/campaignRouting.ts +++ b/src/game/state/campaignRouting.ts @@ -81,5 +81,5 @@ export function campaignBattleRouteEntries() { } export function isCampCampaignStep(step: CampaignStep) { - return step.endsWith('-camp'); + return step !== 'prologue-camp' && step.endsWith('-camp'); } diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts index 5a03101..135b0b9 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -173,6 +173,7 @@ export type CampaignStep = | 'new' | 'prologue' | 'prologue-town' + | 'prologue-camp' | 'first-battle' | 'first-camp' | 'first-victory-story' @@ -467,13 +468,23 @@ export const prologueVillageCampaignTutorialIds = { entered: 'prologue-village-entered', meetZhangFei: 'prologue-village-meet-zhang-fei', meetGuanYu: 'prologue-village-meet-guan-yu', + registerVolunteers: 'prologue-village-register-volunteers', checkSupplies: 'prologue-village-check-supplies', complete: 'prologue-village-complete' } as const; +export const prologueMilitiaCampCampaignTutorialIds = { + entered: 'prologue-camp-entered', + reviewScoutReport: 'prologue-camp-review-scout-report', + readyVanguard: 'prologue-camp-ready-vanguard', + inspectArms: 'prologue-camp-inspect-arms', + complete: 'prologue-camp-complete' +} as const; + export const campaignTutorialIds = [ 'first-battle-basic-controls', - ...Object.values(prologueVillageCampaignTutorialIds) + ...Object.values(prologueVillageCampaignTutorialIds), + ...Object.values(prologueMilitiaCampCampaignTutorialIds) ] as const; export type CampaignTutorialId = (typeof campaignTutorialIds)[number]; const campaignTutorialIdSet = new Set(campaignTutorialIds); @@ -589,6 +600,7 @@ const campaignStepIds = new Set([ 'new', 'prologue', 'prologue-town', + 'prologue-camp', 'first-victory-story', 'ending-complete', 'hanzhong-king-camp', @@ -617,8 +629,9 @@ const campaignRecruitUnitById = new Map(campaignRecruitUnits.map((unit) => [unit const campaignStepProgressLabels: Partial> = { new: { title: '새 캠페인', meta: '시작 전' }, - prologue: { title: '도원 결의', meta: '프롤로그' }, - 'prologue-town': { title: '탁현에서 모인 뜻', meta: '마을 탐색 · 출전 준비' }, + prologue: { title: '황건 봉기 · 탁현', meta: '의병의 시작' }, + 'prologue-town': { title: '탁현에서 모인 뜻', meta: '장비·관우와의 만남' }, + 'prologue-camp': { title: '탁현 의용군 막사', meta: '첫 출전 준비' }, 'first-victory-story': { title: '탁현 방어 성공', meta: '승리 후 이야기' }, 'hanzhong-king-camp': { title: '한중왕 즉위 준비', meta: '군영 의정' }, 'shu-han-foundation-camp': { title: '촉한 건국 선포', meta: '군영 의정' }, diff --git a/src/main.ts b/src/main.ts index 3a30f2b..f390ca0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -33,6 +33,14 @@ type DebugVillageScene = Phaser.Scene & { debugCompleteVillage?: () => boolean; }; +type DebugMilitiaCampScene = Phaser.Scene & { + getDebugState?: () => unknown; + debugTeleportTo?: (targetId: string) => boolean; + debugInteractWith?: (targetId: string) => boolean; + debugCompleteRequiredObjectives?: () => string[]; + debugCompleteCamp?: () => boolean; +}; + type DebugCityStayScene = Phaser.Scene & { getDebugState?: () => unknown; debugTeleportTo?: (targetIdOrKind: string) => boolean; @@ -46,10 +54,12 @@ type HerosDebugApi = { battle: () => unknown; camp: () => unknown; village: () => unknown; + militiaCamp: () => unknown; cityStay: () => unknown; goToBattle: (battleId?: string) => Promise; goToCamp: () => Promise; goToVillage: () => Promise; + goToMilitiaCamp: () => Promise; goToCityStay: (cityStayId?: CityStayId) => Promise; forceBattleOutcome: (outcome: 'victory' | 'defeat') => void; scene: (key: string) => Phaser.Scene | undefined; @@ -102,6 +112,7 @@ function createDebugApi(game: Phaser.Game): HerosDebugApi { const battleScene = () => scene('BattleScene') as DebugBattleScene | undefined; const campScene = () => scene('CampScene') as DebugCampScene | undefined; const villageScene = () => scene('PrologueVillageScene') as DebugVillageScene | undefined; + const militiaCampScene = () => scene('PrologueMilitiaCampScene') as DebugMilitiaCampScene | undefined; const cityStayScene = () => scene('CityStayScene') as DebugCityStayScene | undefined; return { @@ -117,12 +128,19 @@ function createDebugApi(game: Phaser.Game): HerosDebugApi { village: () => game.scene.isActive('PrologueVillageScene') ? villageScene()?.getDebugState?.() ?? { active: false, reason: 'PrologueVillageScene debug state is unavailable.' } : { active: false, reason: 'PrologueVillageScene is not active yet.' }, + militiaCamp: () => game.scene.isActive('PrologueMilitiaCampScene') + ? militiaCampScene()?.getDebugState?.() ?? { + active: false, + reason: 'PrologueMilitiaCampScene debug state is unavailable.' + } + : { active: false, reason: 'PrologueMilitiaCampScene is not active yet.' }, cityStay: () => game.scene.isActive('CityStayScene') ? cityStayScene()?.getDebugState?.() ?? { active: false, reason: 'CityStayScene debug state is unavailable.' } : { active: false, reason: 'CityStayScene is not active yet.' }, goToBattle: (battleId) => startLazySceneFromGame(game, 'BattleScene', battleId ? { battleId } : undefined), goToCamp: () => startLazySceneFromGame(game, 'CampScene'), goToVillage: () => startLazySceneFromGame(game, 'PrologueVillageScene'), + goToMilitiaCamp: () => startLazySceneFromGame(game, 'PrologueMilitiaCampScene'), goToCityStay: (cityStayId) => startLazySceneFromGame( game, 'CityStayScene',