diff --git a/docs/game-design-improvement-goal.md b/docs/game-design-improvement-goal.md new file mode 100644 index 0000000..36adc04 --- /dev/null +++ b/docs/game-design-improvement-goal.md @@ -0,0 +1,141 @@ +# 게임 디자인 개선 목표와 운영 원칙 + +작성일: 2026-07-27 + +## 목표 + +`heros_web`를 단순히 콘텐츠가 많은 삼국지 게임이 아니라, 다음 행동이 +궁금하고 거점에서 내린 결정이 전투와 동료 관계에서 되돌아오는 전략 +RPG로 다듬는다. + +개선은 개별 아이디어를 임의로 추가하는 방식이 아니라 아래 순환으로 +진행한다. + +1. 실제 플레이 흐름과 자동 검증 결과를 감사한다. +2. 검증된 게임 디자인 원칙과 비교한다. +3. 체감 효과, 서사 적합성, 구현 위험으로 우선순위를 정한다. +4. 한 번에 검증 가능한 개선 묶음을 구현한다. +5. 1920x1080, DPR 1, 브라우저 확대 100%에서 전체 흐름을 재검증한다. +6. Gitea와 운영본에 반영하고 다음 묶음의 기준선을 갱신한다. + +## 프로젝트의 핵심 기둥 + +### 1. 설명보다 행동 + +초반 학습은 긴 설명창이 아니라 이동, 대화, 준비, 첫 전투의 실제 행동으로 +이뤄져야 한다. 새 규칙은 한 번에 하나씩 소개하고, 플레이어가 직접 한 번 +수행한 뒤 결과를 즉시 보여 준다. + +### 2. 거점과 전투의 왕복 + +마을·막사에서 얻은 정보, 정한 군령, 선택한 동료와 장비는 다음 전투의 +목표·배치·정보·보상에 반영되어야 한다. 전투 결과도 다시 거점의 대사, +공명, 상품, 인물 상태를 바꿔야 한다. + +### 3. 역사적 결과보다 플레이어의 관점 + +유비의 큰 역사 흐름은 유지한다. 선택은 역사적 사건을 무작정 갈라놓기보다 +유비가 무엇을 우선했는지, 관우와 장비가 그 결정을 어떻게 기억하는지, +어떤 전술 준비를 했는지를 바꾼다. + +### 4. 인물별 가치관이 있는 공명 + +공명은 정답 대사를 골라 숫자를 채우는 호감도가 아니다. 관우, 장비, +제갈량을 비롯한 동료는 각자의 가치관으로 같은 결정에 다르게 반응해야 +한다. 관계 변화는 대사뿐 아니라 원호, 추격, 엄호, 조언 같은 전장 행동으로 +보여 준다. + +### 5. 읽을 수 있는 전술 + +위험, 적 의도, 예상 결과, 지형 효과와 다음 행동을 선택 전에 이해할 수 +있어야 한다. 어려움은 정보 은폐나 조작 마찰이 아니라 여러 타당한 선택 +사이의 긴장에서 나와야 한다. + +### 6. 회수 밀도가 높은 전개 + +필수 대화 한 장면에는 새 인물 정보, 가치관, 다음 행동 단서를 각각 하나 +이상 넣되 중복 설명은 피한다. 중요한 선택은 즉시 인물 반응을 보여 주고, +늦어도 다음 전투 3턴 안에 기능이나 후속 대사로 다시 회수한다. + +## 1차 우선순위: 초반 30분 + +초반의 현재 강점은 유비·관우·장비의 만남을 직접 이동하며 경험하고, +막사에서 정찰·대열·보급을 확인한 뒤 전투로 이어진다는 점이다. 가장 큰 +약점은 첫 전투 직전까지 플레이어가 이야기의 진행 순서를 따를 뿐, +자신의 전술적 우선순위를 결정하지 못한다는 점이다. + +첫 개선 묶음은 다음 조건을 만족해야 한다. + +- 추정의 출전 명령에서 첫 군령을 직접 선택한다. +- 선택지는 기존 `정예`, `기동`, `공성` 전투 평가 체계와 연결한다. +- 선택 전에 달성 조건과 보상을 명확히 보여 준다. +- 초반 맥락에 맞춰 `공성`은 `거점`으로 풀어 쓰고 실패해도 기본 보상에는 + 불이익이 없음을 알린다. +- 카드를 비교한 뒤 확정하며, 확정 전에는 선택을 자유롭게 바꿀 수 있다. +- 관우나 장비가 선택의 성격에 맞게 즉시 반응한다. +- 저장 후 다시 시작해도 선택이 유지된다. +- 첫 전투 HUD, 평가와 보상이 같은 선택을 사용한다. +- 기존 대사를 줄여 선택 추가가 전체 클릭 수 증가로 이어지지 않게 한다. +- 동료 합류 이동 중 들어온 첫 이동 입력을 보존하고, 목표 NPC 한 번 클릭이 + 장애물을 우회해 대화 거리까지 이어지게 한다. +- 내레이터 대사에는 빈 초상 틀을 남기지 않고 본문 폭을 넓힌다. + +후속 묶음에서는 막사 준비가 화면과 전투에서 더 직접 보이게 한다. + +- 관우의 정찰 완료: 작전판과 첫 전투 적 정보 변화 +- 장비의 대열 정비 완료: 흩어진 의병의 실제 집결 연출 +- 군수관 점검 완료: 보급 수레와 첫 전투 사용 물자 변화 +- 젊은 의병 격려: 첫 전투 중 유비의 말을 기억하는 짧은 반응 + +## 장기 개선 순서 + +1. **초반 30분**: 가치 선택, 준비의 전투 회수, 대화 밀도와 길 찾기 +2. **전투 의사결정**: 적 의도, 피해 예측, 지형·공명·군령의 비교 가능성 +3. **체류와 동료**: 인물별 공명 반응, 선택 기억, 장비·정보·대화의 왕복 +4. **편성과 성장**: 새 무장이 합류할수록 역할과 조합 선택이 실제로 달라짐 +5. **캠페인 리듬**: 전투·탐색·휴식·중대 사건의 강약과 반복 억제 +6. **시청각·접근성**: 중요도별 연출, 읽기 속도, 재확인, 입력과 색상 대안 +7. **성능·용량**: 자산 예산, 로더 수명, 운영 배포 회귀 검증 + +## 초기 성공 기준 + +- 시작 후 60초 안에 직접 조작 가능 +- 장비 첫 만남 3분 이내, 세 사람 합류 8분 이내 +- 첫 전투 시작 중앙값 16분 이내, 상위 90%도 20분 이내 +- 다음 장소를 45초 안에 찾는 사용자 85% 이상 +- 30초 이상 방향을 잃는 사용자 10% 미만 +- 세 인물의 합류 이유를 각각 설명하는 사용자 85% 이상 +- 막사 준비 효과를 하나 이상 설명하는 사용자 80% 이상 +- 모든 중요 선택에 즉시 반응과 다음 전투 후속 반응 존재 +- 특정 선택지가 의도 없이 70% 이상으로 쏠리지 않음 +- 프롤로그 중도 이탈 10% 미만 + +자동 검증에서는 다음을 대체 지표로 사용한다. + +- 필수 대화의 최대 연속 줄 수와 총 진행 횟수 +- 선택 전 조건·보상 표시 여부 +- 선택 저장·복원과 전투 상태 전달 +- 장면별 막힘 없는 이동, 입력 차단과 재개 +- 1920x1080 UI 경계, 겹침, 가독성 +- 로컬과 운영본의 동일 릴리스 및 전체 프롤로그 통과 + +## 조사 근거 + +- [Plants vs. Zombies 튜토리얼 설계, GDC 정리](https://www.gamedeveloper.com/design/gdc-2012-10-tutorial-tips-from-i-plants-vs-zombies-i-creator-george-fan): + 행동으로 가르치기, 적은 단어, 비차단 안내, 점진적 규칙 공개 +- [Nintendo: Tears of the Kingdom 개발자 인터뷰](https://www.nintendo.com/en-gb/News/2023/May/Ask-the-Developer-Vol-9-The-Legend-of-Zelda-Tears-of-the-Kingdom-Chapter-3-2383821.html): + 시야 안의 단서가 다음 발견을 부르는 탐험과 시각적 잡음 억제 +- [Outer Wilds: The Intentionality of Wandering](https://www.mobiusdigitalgames.com/news/the-intentionality-of-wandering): + 자유 탐험에서도 다음 호기심을 만드는 정보 연결 +- [Final Fantasy XV 동료 설계, GDC](https://www.gdcvault.com/play/1025442/Walk-Tall-My-Friends-Giving): + 컷신 밖의 이동과 행동으로 동료 성격 전달 +- [Fire Emblem Engage 개발자 인터뷰](https://www.nintendo.com/us/whatsnew/ask-the-developer-vol-8-fire-emblem-engage-part-3/): + 관계 대화와 전술 기능의 결합 +- [Fallout: New Vegas 선택 구조, GDC](https://www.gdcvault.com/play/1015758/Do-%28Say%29-The-Right-Thing): + 대화 선택을 플레이어 표현과 실제 결과로 연결 +- [Dragon Age 동료 관계와 성장, BioWare](https://www.ea.com/en/games/dragon-age/dragon-age-the-veilguard/news/combat-and-progression): + 대화·결정·동료 활동을 유대와 기능 변화로 연결 +- [X-COM 고전 회고, GDC](https://www.gdcvault.com/play/1019056/Classic-Game-Postmortem-X-COM): + 전략층과 전술층이 서로 의미를 만드는 이중 구조 +- [Into the Breach 디자인 회고, GDC](https://www.gdcvault.com/play/1025772/-Into-the-Breach-Design): + 명확한 정보와 제한된 규칙으로 깊은 전술 선택 만들기 diff --git a/scripts/verify-prologue-village-browser.mjs b/scripts/verify-prologue-village-browser.mjs index 41f2c10..ce5f3d4 100644 --- a/scripts/verify-prologue-village-browser.mjs +++ b/scripts/verify-prologue-village-browser.mjs @@ -125,6 +125,11 @@ try { false, 'Narrative context should not impersonate a character portrait.' ); + assert.equal( + (await readVillage(page)).dialogue.portraitFrameVisible, + false, + 'Narrative context must hide the empty portrait frame instead of resembling a missing asset.' + ); assert.equal( (await readVillage(page)).dialogue.totalLines, 1, @@ -169,7 +174,24 @@ try { walkingZhangFei.x > 360 && walkingZhangFei.x < 1000, `Zhang Fei should move progressively instead of teleporting: ${JSON.stringify(walkingZhangFei)}` ); + const queuedGuanTarget = villageNpc(village, 'guan-yu'); + await page.mouse.click(queuedGuanTarget.x, queuedGuanTarget.y); + await page.waitForFunction(() => { + const state = window.__HEROS_DEBUG__?.village?.(); + return ( + state?.movement?.queuedIntent?.kind === 'pointer' && + state?.narrativeMovement?.playerInputDeferred === true + ); + }); await waitForVillageNpcPosition(page, 'zhang-fei', { x: 1000, y: 540 }); + await page.waitForFunction(() => { + const state = window.__HEROS_DEBUG__?.village?.(); + return ( + state?.movement?.appliedQueuedIntentCount >= 1 && + state?.movement?.targetNpcId === 'guan-yu' + ); + }); + await waitForVillageInteractionTarget(page, 'guan-yu'); village = await readVillage(page); const tavernZhangFei = villageNpc(village, 'zhang-fei'); assert.equal(tavernZhangFei.moving, false); @@ -237,12 +259,24 @@ try { walkingZhangToRecruitment.y > 540 && walkingZhangToRecruitment.y < 805, `Zhang Fei should move progressively toward registration: ${JSON.stringify(walkingZhangToRecruitment)}` ); + const queuedClerkTarget = villageNpc(village, 'recruiting-clerk'); + await page.mouse.click(queuedClerkTarget.x, queuedClerkTarget.y); + await page.waitForFunction(() => { + const state = window.__HEROS_DEBUG__?.village?.(); + return ( + state?.movement?.queuedIntent?.kind === 'pointer' && + state?.narrativeMovement?.playerInputDeferred === true + ); + }); await waitForVillageNarrativeMovement(page); + await page.waitForFunction(() => ( + window.__HEROS_DEBUG__?.village?.()?.movement?.targetNpcId === 'recruiting-clerk' + )); + await waitForVillageInteractionTarget(page, 'recruiting-clerk'); village = await readVillage(page); assertNpcPosition(village, 'guan-yu', { x: 850, y: 655 }, 'recruitment Guan Yu'); assertNpcPosition(village, 'zhang-fei', { x: 810, y: 805 }, 'recruitment Zhang Fei'); - await teleportTo(page, 'recruiting-clerk'); await page.keyboard.press('e'); await page.waitForFunction(() => ( window.__HEROS_DEBUG__?.village?.()?.dialogue?.sourceNpcId === 'recruiting-clerk' @@ -453,7 +487,7 @@ try { window.__HEROS_DEBUG__?.militiaCamp?.()?.dialogue?.sourceNpcId === 'zou-jing' )); const commandCamp = await readMilitiaCamp(page); - assert.equal(commandCamp.dialogue.totalLines, 5); + assert.equal(commandCamp.dialogue.totalLines, 1); assert.equal(commandCamp.dialogue.portraitTextureKey, 'portrait-zou-jing-yellow-turban'); assert.deepEqual( commandCamp.npcs @@ -468,6 +502,88 @@ try { assertBoundsInsideViewport(commandCamp.dialogue.bounds, 'final command dialogue'); await captureStableScreenshot(page, 'dist/verification-prologue-militia-camp-command.png'); await advanceMilitiaCampDialogueUntilClosed(page); + await page.waitForFunction(() => ( + window.__HEROS_DEBUG__?.militiaCamp?.()?.commandChoice?.open === true && + window.__HEROS_DEBUG__?.militiaCamp?.()?.commandChoice?.ready === true + )); + let commandChoice = (await readMilitiaCamp(page)).commandChoice; + assert.equal(commandChoice.open, true); + assert.equal(commandChoice.selectedId, null); + assert.equal(commandChoice.pendingId, null); + assert.equal(commandChoice.confirmEnabled, false); + assert.deepEqual( + commandChoice.cards.map((card) => card.orderId), + ['elite', 'mobile', 'siege'] + ); + assertBoundsInsideViewport(commandChoice.panelBounds, 'first command panel'); + commandChoice.cards.forEach((card) => { + assert(card.title.length > 0); + assert(card.condition.length > 0); + assert(card.rewardGold > 0); + assert(card.rewardItems.length > 0); + assertBoundsInsideViewport(card.bounds, `first command card ${card.orderId}`); + }); + assertBoundsInsideViewport(commandChoice.confirmBounds, 'first command confirm button'); + assertBoundsDoNotOverlap( + commandChoice.cards.map((card) => ({ label: card.orderId, bounds: card.bounds })), + 'first command cards' + ); + + await page.keyboard.press('1'); + await page.waitForFunction(() => ( + window.__HEROS_DEBUG__?.militiaCamp?.()?.commandChoice?.pendingId === 'elite' + )); + commandChoice = (await readMilitiaCamp(page)).commandChoice; + assert.equal(commandChoice.selectedId, null, 'Previewing a command must not persist it before confirmation.'); + assert.equal(commandChoice.confirmEnabled, true); + await page.waitForTimeout(160); + await page.keyboard.down('2'); + await page.waitForTimeout(80); + await page.keyboard.up('2'); + await page.waitForFunction(() => ( + window.__HEROS_DEBUG__?.militiaCamp?.()?.commandChoice?.pendingId === 'mobile' + )); + commandChoice = (await readMilitiaCamp(page)).commandChoice; + assert.equal( + commandChoice.cards.find((card) => card.orderId === 'mobile')?.pending, + true, + 'The player must be able to change the previewed command before confirmation.' + ); + const siegeCardBounds = commandChoice.cards.find((card) => card.orderId === 'siege')?.bounds; + assert(siegeCardBounds, 'The siege command card bounds are required for pointer selection.'); + await page.mouse.click( + siegeCardBounds.x + siegeCardBounds.width / 2, + siegeCardBounds.y + siegeCardBounds.height / 2 + ); + await page.waitForFunction(() => ( + window.__HEROS_DEBUG__?.militiaCamp?.()?.commandChoice?.pendingId === 'siege' + )); + const mobileCardBounds = commandChoice.cards.find((card) => card.orderId === 'mobile')?.bounds; + assert(mobileCardBounds, 'The mobile command card bounds are required for pointer selection.'); + await page.mouse.click( + mobileCardBounds.x + mobileCardBounds.width / 2, + mobileCardBounds.y + mobileCardBounds.height / 2 + ); + await page.waitForFunction(() => ( + window.__HEROS_DEBUG__?.militiaCamp?.()?.commandChoice?.pendingId === 'mobile' + )); + await captureStableScreenshot(page, 'dist/verification-prologue-militia-camp-command-choice.png'); + + await page.keyboard.press('Enter'); + await page.waitForFunction(() => ( + window.__HEROS_DEBUG__?.militiaCamp?.()?.dialogue?.sourceNpcId === 'first-command-mobile' + )); + const commandReaction = await readMilitiaCamp(page); + assert.equal(commandReaction.commandChoice.open, false); + assert.equal(commandReaction.dialogue.totalLines, 3); + assert.equal(commandReaction.dialogue.portraitTextureKey, 'portrait-zhang-fei-yellow-turban'); + const savedCommand = await readCampaignSave(page); + assert.deepEqual(savedCommand.sortieOrderSelection, { + battleId: 'first-battle-zhuo-commandery', + orderId: 'mobile' + }); + await captureStableScreenshot(page, 'dist/verification-prologue-militia-camp-command-reaction.png'); + await advanceMilitiaCampDialogueUntilClosed(page); await waitForStoryReady(page); const departure = await readStory(page); @@ -482,6 +598,10 @@ try { ); assert(savedDeparture.completedTutorialIds.includes('prologue-camp-complete')); assert(!savedDeparture.completedTutorialIds.includes('first-battle-basic-controls')); + assert.deepEqual(savedDeparture.sortieOrderSelection, { + battleId: 'first-battle-zhuo-commandery', + orderId: 'mobile' + }); await page.mouse.click(960, 850); await page.waitForFunction(() => { @@ -501,6 +621,9 @@ try { const battleSave = await readCampaignSave(page); assert.equal(battleSave.step, 'first-battle'); assert(!battleSave.completedTutorialIds.includes('first-battle-basic-controls')); + const firstBattle = await page.evaluate(() => window.__HEROS_DEBUG__?.battle?.()); + assert.equal(firstBattle.sortieOrder.selectedId, 'mobile'); + assert.equal(firstBattle.sortieOrder.live.orderId, 'mobile'); await seedLegacyCompletedVillageSave(page, battleSave); await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 }); @@ -682,6 +805,17 @@ async function waitForVillageNarrativeMovement(page) { }, undefined, { timeout: 6000 }); } +async function waitForVillageInteractionTarget(page, targetId) { + await page.waitForFunction((expectedTargetId) => { + const village = window.__HEROS_DEBUG__?.village?.(); + return ( + village?.interaction?.targetId === expectedTargetId && + village?.interaction?.canInteract === true && + village?.movement?.target === null + ); + }, targetId, { timeout: 9000 }); +} + async function advanceDialogueUntilClosed(page) { for (let attempt = 0; attempt < 12; attempt += 1) { const active = (await readVillage(page))?.dialogue?.active; @@ -805,6 +939,25 @@ function assertNpcTextureKeys(npcs, expectedTextureKeyByNpcId, sceneLabel) { ); } +function assertBoundsDoNotOverlap(entries, label) { + for (let leftIndex = 0; leftIndex < entries.length; leftIndex += 1) { + for (let rightIndex = leftIndex + 1; rightIndex < entries.length; rightIndex += 1) { + const left = entries[leftIndex]; + const right = entries[rightIndex]; + const overlaps = !( + left.bounds.x + left.bounds.width <= right.bounds.x || + right.bounds.x + right.bounds.width <= left.bounds.x || + left.bounds.y + left.bounds.height <= right.bounds.y || + right.bounds.y + right.bounds.height <= left.bounds.y + ); + assert( + !overlaps, + `${label}: ${left.label} overlaps ${right.label}: ${JSON.stringify({ left, right })}` + ); + } + } +} + function assertExplorationBackground(background, expectedKey) { assert(background, `Expected ${expectedKey} debug state.`); assert.equal(background.key, expectedKey); diff --git a/scripts/verify-prologue-village-data.mjs b/scripts/verify-prologue-village-data.mjs index d49ccfe..0f37a54 100644 --- a/scripts/verify-prologue-village-data.mjs +++ b/scripts/verify-prologue-village-data.mjs @@ -29,6 +29,7 @@ const expectedCampMarkerIds = [ 'prologue-camp-inspect-arms', 'prologue-camp-complete' ]; +const expectedFirstCommandOrderIds = ['elite', 'mobile', 'siege']; const server = await createServer({ logLevel: 'error', @@ -39,6 +40,7 @@ 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 sortieOrders = await server.ssrLoadModule('/src/game/data/sortieOrders.ts'); const campaign = await server.ssrLoadModule('/src/game/state/campaignState.ts'); const unitAssets = await server.ssrLoadModule('/src/game/data/unitAssets.ts'); const failures = []; @@ -178,6 +180,58 @@ try { 'militia camp must have Zou Jing as the single final command interaction', failures ); + const firstCommandChoices = militiaCamp.prologueMilitiaCampCommandChoices; + assertArrayEquals( + firstCommandChoices.map((choice) => choice.orderId), + expectedFirstCommandOrderIds, + 'first command choice order', + failures + ); + assert( + new Set(firstCommandChoices.map((choice) => choice.orderId)).size === + expectedFirstCommandOrderIds.length, + 'the first command choices must cover three unique sortie orders', + failures + ); + for (const choice of firstCommandChoices) { + const definition = sortieOrders.sortieOrderDefinition(choice.orderId); + assert( + definition.id === choice.orderId && + definition.rewardGold > 0 && + definition.rewardItems.length > 0, + `first command ${choice.orderId} must reuse a complete battle order definition`, + failures + ); + assert( + choice.label === sortieOrders.sortieOrderDisplayLabel( + 'first-battle-zhuo-commandery', + choice.orderId + ) && + choice.lead.length >= 8 && + choice.battlefieldPromise.length >= 20 && + choice.firstBattleCondition.length >= 20, + `first command ${choice.orderId} must explain its player value and battlefield promise`, + failures + ); + assert( + choice.reaction.length === 3 && + choice.reaction.some((line) => line.speaker === '관우') && + choice.reaction.some((line) => line.speaker === '장비'), + `first command ${choice.orderId} must receive distinct Guan Yu and Zhang Fei reactions`, + failures + ); + verifyDialogueLines( + choice.reaction, + `militia camp/first-command/${choice.orderId}`, + unitAssets, + failures + ); + } + assert( + campNpcs.find((npc) => npc.id === 'zou-jing')?.dialogue.length === 1, + 'Zou Jing command setup should be compressed to one line so the new decision adds no extra input', + failures + ); assert( campNpcs.some((npc) => !npc.objectiveId && !npc.departure), 'militia camp should include at least one optional volunteer conversation', @@ -234,7 +288,7 @@ try { console.log( `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.' + `${firstCommandChoices.length} first-command choices, sequential meetings, camp preparation, and save markers.` ); } finally { await server.close(); diff --git a/src/game/data/prologueMilitiaCamp.ts b/src/game/data/prologueMilitiaCamp.ts index 76856ee..b1892ee 100644 --- a/src/game/data/prologueMilitiaCamp.ts +++ b/src/game/data/prologueMilitiaCamp.ts @@ -1,4 +1,5 @@ import type { PrologueVillageDialogueLine } from './prologueVillage'; +import type { SortieOrderId } from './sortieOrders'; export const prologueMilitiaCampId = 'zhuo-volunteer-camp'; @@ -11,6 +12,87 @@ export const prologueMilitiaCampRequiredObjectiveIds = [ export type PrologueMilitiaCampRequiredObjectiveId = (typeof prologueMilitiaCampRequiredObjectiveIds)[number]; +export type PrologueMilitiaCampCommandChoice = { + orderId: SortieOrderId; + label: string; + lead: string; + battlefieldPromise: string; + firstBattleCondition: string; + reaction: PrologueVillageDialogueLine[]; +}; + +export const prologueMilitiaCampCommandChoices: readonly PrologueMilitiaCampCommandChoice[] = [ + { + orderId: 'elite', + label: '정예', + lead: '사람을 잃지 않는 승리', + battlefieldPromise: '전열을 지키고 세 형제가 모두 귀환하는 완성도 높은 승리를 노린다.', + firstBattleCondition: '승리 · 세 형제 전원 생존 · 편성 평가 A 이상', + reaction: [ + { + speaker: '관우', + textureKey: 'unit-guan-yu', + text: '옳은 판단입니다. 제가 숲길 전열을 붙들어 의병들이 흩어지지 않게 하겠습니다.' + }, + { + speaker: '장비', + textureKey: 'unit-zhang-fei', + text: '좋소! 앞장서되 혼자 깊이 들지는 않겠소. 모두 함께 돌아오는 첫 승리를 만들겠소!' + }, + { + speaker: '군령', + text: '정예 군령이 기록되었다. 첫 전투에서 전원 생존과 높은 편성 평가를 달성하면 추가 전공을 얻는다.' + } + ] + }, + { + orderId: 'mobile', + label: '기동', + lead: '속도를 살린 선봉 돌파', + battlefieldPromise: '장비의 남쪽 압박으로 적의 의도를 먼저 꺾고 제한 턴 안에 승리한다.', + firstBattleCondition: '승리 · 15턴 안에 끝내기 · 장비가 한 번 이상 공격에 기여하기', + reaction: [ + { + speaker: '장비', + textureKey: 'unit-zhang-fei', + text: '기다리던 명령이오! 남쪽 길을 단숨에 흔들어 적이 진형을 갖추기 전에 숨통을 끊겠소!' + }, + { + speaker: '관우', + textureKey: 'unit-guan-yu', + text: '속도에 휩쓸려 대열이 갈라지지 않도록 중앙에서 장비의 돌파를 받치겠습니다.' + }, + { + speaker: '군령', + text: '기동 군령이 기록되었다. 첫 전투에서 제한 턴과 돌파 기여 조건을 달성하면 추가 전공을 얻는다.' + } + ] + }, + { + orderId: 'siege', + label: '거점', + lead: '마을 어귀까지 확실히 구원', + battlefieldPromise: '두령만 쫓지 않고 마을 거점과 전열·후원의 역할까지 완수한다.', + firstBattleCondition: '승리 · 마을 어귀 확보 · 관우가 앞을 지키고 유비가 책략·회복으로 돕기', + reaction: [ + { + speaker: '관우', + textureKey: 'unit-guan-yu', + text: '백성을 두고 두령만 쫓을 수는 없습니다. 제가 마을 어귀의 길목부터 확보하겠습니다.' + }, + { + speaker: '장비', + textureKey: 'unit-zhang-fei', + text: '좋소! 관우가 전열을 열면 내가 측면을 걷어 내 형님의 후원이 닿게 하겠소!' + }, + { + speaker: '군령', + text: '거점 군령이 기록되었다. 첫 전투에서 마을 목표와 전열·후원 기여를 달성하면 추가 전공을 얻는다.' + } + ] + } +]; + export const prologueMilitiaCampObjectiveDefinitions: ReadonlyArray<{ id: PrologueMilitiaCampRequiredObjectiveId | 'receive-command'; label: string; @@ -179,26 +261,7 @@ export const prologueMilitiaCampNpcDefinitions: readonly PrologueMilitiaCampNpcD { 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: '세 사람이 나란히 막사를 나섰다. 갓 세운 의용군의 첫 깃발이 북문을 향해 움직이기 시작했다.' + text: '정찰과 대열, 보급까지 갖추었군. 황건 두령 한석이 북동쪽에서 마을을 노린다. 그대들이 선봉을 맡되, 첫 승리를 어떤 군령으로 이끌지 직접 정하게.' } ], lockedDialogue: [ diff --git a/src/game/data/prologueVillage.ts b/src/game/data/prologueVillage.ts index 2bcc5fc..e0c5c47 100644 --- a/src/game/data/prologueVillage.ts +++ b/src/game/data/prologueVillage.ts @@ -96,12 +96,7 @@ export const prologueVillageNpcDefinitions: readonly PrologueVillageNpcDefinitio { speaker: '장비', textureKey: 'unit-zhang-fei', - text: '나는 장비라 하오. 이 고을에서 술과 고기를 팔며 장원을 돌보고 있소. 뜻있는 이를 돕고자 모은 재산이 있으니 함께 의병을 일으킵시다.' - }, - { - speaker: '유비', - textureKey: 'unit-liu-bei', - text: '백성을 지키려는 마음이 같으니 더없이 든든하오. 주점에서 사람을 모을 방도를 자세히 의논합시다.' + text: '나는 장비라 하오. 이 고을에서 술과 고기를 팔며 장원을 돌보고 있소. 뜻있는 이를 돕고자 모은 재산이 있으니 함께 의병을 일으키고, 주점에서 사람을 모을 방도도 의논합시다.' } ], repeatDialogue: [ @@ -122,14 +117,10 @@ export const prologueVillageNpcDefinitions: readonly PrologueVillageNpcDefinitio direction: 'west', objectiveId: 'meet-guan-yu', dialogue: [ - { - speaker: '전황', - text: '손수레를 세운 붉은 얼굴의 나그네가 주점에 들어와, 군문에 자원하러 가는 길이라며 술을 청했다.' - }, { speaker: '유비', textureKey: 'unit-liu-bei', - text: '기상이 범상치 않으십니다. 존함과 이곳 군문을 찾은 까닭을 여쭈어도 되겠소?' + text: '손수레를 세우고 주점에 들어오시더니 군문에 자원하러 가는 길이라 하셨소. 기상이 범상치 않으니 존함과 연유를 여쭈어도 되겠소?' }, { speaker: '관우', @@ -174,17 +165,12 @@ export const prologueVillageNpcDefinitions: readonly PrologueVillageNpcDefinitio { speaker: '탁현 모병 관리', textureKey: 'unit-shu-officer', - text: '성명과 출신, 의병을 일으키는 뜻을 명부에 남겨 주십시오. 인가가 나면 모인 군마와 병기를 막사에서 정식으로 나누겠습니다.' + text: '성명과 출신, 의병을 일으키는 뜻을 명부에 남겨 주십시오. 복숭아 동산에서 한뜻임을 맹세한 뒤 장비 공의 장원 밖 의용군 막사로 오시면 군마와 병기를 나누겠습니다.' }, { speaker: '유비', textureKey: 'unit-liu-bei', text: '탁현의 유비, 하동의 관우, 이 고을의 장비요. 위로는 나라의 어려움에 보답하고 아래로는 백성을 편안하게 하고자 모였소.' - }, - { - speaker: '탁현 모병 관리', - textureKey: 'unit-shu-officer', - text: '세 분의 뜻을 군후 추정께 보고하겠습니다. 결의를 마친 뒤 장비 공의 장원 밖 의용군 막사로 와 주십시오.' } ], lockedDialogue: [ diff --git a/src/game/data/sortieOrders.ts b/src/game/data/sortieOrders.ts index ba49e1b..52debb7 100644 --- a/src/game/data/sortieOrders.ts +++ b/src/game/data/sortieOrders.ts @@ -143,6 +143,20 @@ export function sortieOrderDefinition(orderId: CampaignSortieOrderId) { return sortieOrderDefinitions.find((definition) => definition.id === orderId) ?? sortieOrderDefinitions[0]; } +export function sortieOrderDisplayLabel(battleId: string, orderId: CampaignSortieOrderId) { + if (battleId === 'first-battle-zhuo-commandery' && orderId === 'siege') { + return '거점'; + } + return sortieOrderDefinition(orderId).label; +} + +export function sortieOrderDisplaySummary(battleId: string, orderId: CampaignSortieOrderId) { + if (battleId === 'first-battle-zhuo-commandery' && orderId === 'siege') { + return '마을 어귀와 전열·후원 역할을 함께 완수합니다.'; + } + return sortieOrderDefinition(orderId).summary; +} + export function sortieOrderRewardClaimId(battleId: string, orderId: CampaignSortieOrderId) { return `${battleId}:${orderId}`; } diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 5b1073e..161f6da 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -119,6 +119,8 @@ import { evaluateSortieOrder, evaluateSortieOrderProgress, sortieOrderDefinition, + sortieOrderDisplayLabel, + sortieOrderDisplaySummary, sortieOrderRewardClaimId, type CampaignSortieOrderProgressId, type CampaignSortieOrderProgressSnapshot, @@ -14207,11 +14209,11 @@ export class BattleScene extends Phaser.Scene { } private sortieOrderLabel(orderId: SortieOrderId) { - return sortieOrderDefinition(orderId).label; + return sortieOrderDisplayLabel(battleScenario.id, orderId); } private sortieOrderSummary(orderId: SortieOrderId) { - return sortieOrderDefinition(orderId).summary; + return sortieOrderDisplaySummary(battleScenario.id, orderId); } private sortieOrderProgressLabel(progressId: string) { @@ -14919,7 +14921,9 @@ export class BattleScene extends Phaser.Scene { { label: '전투 수입', value: `군자금 +${this.resultDisplayedGold(outcome)}`, - badge: sortieOrderReward ? `${sortieOrderReward.label} 공훈 포함` : `목표 ${achievedCount}/${objectives.length}`, + badge: sortieOrderReward + ? `${this.sortieOrderLabel(sortieOrderReward.id)} 공훈 포함` + : `목표 ${achievedCount}/${objectives.length}`, icon: 'leadership', accent: palette.gold }, @@ -17599,6 +17603,7 @@ export class BattleScene extends Phaser.Scene { const roleLines = this.sortieOpeningLines(); const title = this.firstPursuitTrinityConfigured() ? '출진 군세 · 도원 삼재진' : '출진 군세'; this.triggerBattleEvent('opening', title, [ + `선택 군령 · ${this.sortieOrderLabel(this.launchSortieOrderId)} · ${this.sortieOrderSummary(this.launchSortieOrderId)}`, ...roleLines, ...(this.launchSortieRecommendation ? [`선택 전략 · ${this.launchSortieRecommendation.label} · ${this.launchSortieRecommendation.summary}`] diff --git a/src/game/scenes/PrologueMilitiaCampScene.ts b/src/game/scenes/PrologueMilitiaCampScene.ts index e106a2a..6f501db 100644 --- a/src/game/scenes/PrologueMilitiaCampScene.ts +++ b/src/game/scenes/PrologueMilitiaCampScene.ts @@ -7,13 +7,19 @@ import { type PortraitAssetEntry } from '../data/portraitAssets'; import { + prologueMilitiaCampCommandChoices, prologueMilitiaCampNpcDefinitions, prologueMilitiaCampObjectiveDefinitions, prologueMilitiaCampRequiredObjectiveIds, + type PrologueMilitiaCampCommandChoice, type PrologueMilitiaCampNpcDefinition, type PrologueMilitiaCampRequiredObjectiveId } from '../data/prologueMilitiaCamp'; import { prologueDeparturePages, type PrologueVillageDialogueLine } from '../data/prologueVillage'; +import { + sortieOrderDefinition, + type SortieOrderId +} from '../data/sortieOrders'; import { ensureExplorationCharacterAnimations, explorationCharacterAssetInfos, @@ -30,6 +36,7 @@ import { hasCompletedCampaignTutorial, markCampaignStep, prologueMilitiaCampCampaignTutorialIds, + setCampaignSortieOrderSelection, type CampaignTutorialId } from '../state/campaignState'; import { releaseTextureSource } from '../render/loaderLifecycle'; @@ -121,6 +128,13 @@ type ObjectiveRowView = { location: Phaser.GameObjects.Text; }; +type CommandChoiceView = { + choice: PrologueMilitiaCampCommandChoice; + button: Phaser.GameObjects.Rectangle; + accent: number; + selectLabel: Phaser.GameObjects.Text; +}; + const objectiveTutorialIds: Record = { 'review-scout-report': prologueMilitiaCampCampaignTutorialIds.reviewScoutReport, 'ready-vanguard': prologueMilitiaCampCampaignTutorialIds.readyVanguard, @@ -153,11 +167,19 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { private promptBackground?: Phaser.GameObjects.Rectangle; private promptText?: Phaser.GameObjects.Text; private dialoguePanel?: Phaser.GameObjects.Container; + private dialoguePortraitFrame?: Phaser.GameObjects.Rectangle; private dialoguePortrait?: Phaser.GameObjects.Image; private dialogueNameText?: Phaser.GameObjects.Text; private dialogueBodyText?: Phaser.GameObjects.Text; private dialogueProgressText?: Phaser.GameObjects.Text; private dialogueState?: DialogueState; + private commandChoicePanel?: Phaser.GameObjects.Container; + private commandChoicePanelBounds?: Phaser.Geom.Rectangle; + private commandChoiceViews: CommandChoiceView[] = []; + private commandChoiceConfirmButton?: Phaser.GameObjects.Rectangle; + private commandChoiceConfirmText?: Phaser.GameObjects.Text; + private pendingCommandOrderId?: SortieOrderId; + private commandChoiceReadyAt = Number.POSITIVE_INFINITY; private loadingOverlay?: Phaser.GameObjects.Container; private transitionOverlay?: Phaser.GameObjects.Container; private completionToast?: Phaser.GameObjects.Container; @@ -225,6 +247,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { this.navigationPending = false; this.moveTarget = undefined; this.dialogueState = undefined; + this.closeCommandChoice(); this.stopNpcMovement(); releaseExplorationCharacterTextureKeys(this, characterTextureKeys); this.releasePresentationTextures(); @@ -260,6 +283,11 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { this.interactionQueued = false; return; } + if (this.commandChoicePanel) { + this.stopPlayerMovement(); + this.interactionQueued = false; + return; + } if (this.dialogueState) { this.stopPlayerMovement(); if (this.consumeInteractionRequest()) { @@ -325,6 +353,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { controls: { movement: ['WASD', '방향키'], interact: ['E', 'Space', 'Enter'], + commandChoice: ['1', '2', '3', 'pointer'], pointerMove: true, carryoverGuardMs: inputCarryoverGuardMs }, @@ -381,6 +410,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { ? this.dialoguePortrait.texture.key : null, portraitVisible: this.dialoguePortrait?.visible ?? false, + portraitFrameVisible: this.dialoguePortraitFrame?.visible ?? false, bounds: dialogueBounds } : { @@ -391,15 +421,50 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { sourceNpcId: null, portraitTextureKey: null, portraitVisible: false, + portraitFrameVisible: false, bounds: null }, + commandChoice: { + open: Boolean(this.commandChoicePanel), + ready: Boolean(this.commandChoicePanel && this.time.now >= this.commandChoiceReadyAt), + selectedId: campaign.sortieOrderSelection?.battleId === 'first-battle-zhuo-commandery' + ? campaign.sortieOrderSelection.orderId + : null, + pendingId: this.pendingCommandOrderId ?? null, + confirmEnabled: Boolean(this.pendingCommandOrderId), + confirmBounds: this.commandChoiceConfirmButton + ? this.boundsSnapshot(this.commandChoiceConfirmButton.getBounds()) + : null, + panelBounds: this.commandChoicePanelBounds + ? this.boundsSnapshot(this.commandChoicePanelBounds) + : null, + cards: this.commandChoiceViews.map(({ choice, button }) => { + const definition = sortieOrderDefinition(choice.orderId); + return { + orderId: choice.orderId, + label: choice.label, + title: `${choice.label} 군령`, + condition: choice.firstBattleCondition, + rewardGold: definition.rewardGold, + rewardItems: [...definition.rewardItems], + pending: this.pendingCommandOrderId === choice.orderId, + bounds: this.boundsSnapshot(button.getBounds()) + }; + }) + }, blockers: this.blockers.map((blocker) => this.boundsSnapshot(blocker)), navigationPending: this.navigationPending }; } debugTeleportTo(targetId: string) { - if (!this.ready || this.navigationPending || this.npcMovementPending || this.dialogueState) { + if ( + !this.ready || + this.navigationPending || + this.npcMovementPending || + this.dialogueState || + this.commandChoicePanel + ) { return false; } const view = this.npcViews.get(targetId); @@ -413,7 +478,13 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { } debugInteractWith(targetId: string) { - if (!this.ready || this.navigationPending || this.npcMovementPending || this.dialogueState) { + if ( + !this.ready || + this.navigationPending || + this.npcMovementPending || + this.dialogueState || + this.commandChoicePanel + ) { return false; } const view = this.npcViews.get(targetId); @@ -438,10 +509,21 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { return false; } this.debugCompleteRequiredObjectives(); + const selection = getCampaignState().sortieOrderSelection; + if (selection?.battleId !== 'first-battle-zhuo-commandery') { + setCampaignSortieOrderSelection('first-battle-zhuo-commandery', 'elite'); + } this.finishCamp(); return true; } + debugChooseFirstCommand(orderId: SortieOrderId) { + if (!this.previewFirstCommand(orderId, true)) { + return false; + } + return this.confirmFirstCommand(true); + } + private resetRuntimeState() { this.backgroundImage = undefined; this.backgroundFallback = false; @@ -454,8 +536,16 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { this.npcMovementPending = false; this.blockers = []; this.objectiveRows.clear(); + this.dialoguePortraitFrame = undefined; this.dialoguePortrait = undefined; this.dialogueState = undefined; + this.commandChoicePanel = undefined; + this.commandChoicePanelBounds = undefined; + this.commandChoiceViews = []; + this.commandChoiceConfirmButton = undefined; + this.commandChoiceConfirmText = undefined; + this.pendingCommandOrderId = undefined; + this.commandChoiceReadyAt = Number.POSITIVE_INFINITY; this.moveTarget = undefined; this.ready = false; this.navigationPending = false; @@ -952,7 +1042,9 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { .setOrigin(0) .setStrokeStyle(1, 0x546174, 0.7); const portraitFrame = this.add.rectangle(x + 132, y + 152, 226, 226, 0x0e131a, 0.92) - .setStrokeStyle(2, 0x9e8350, 0.9); + .setStrokeStyle(2, 0x9e8350, 0.9) + .setVisible(false); + this.dialoguePortraitFrame = portraitFrame; const firstPortraitKey = campDialoguePortraitEntries() .find(({ textureKey }) => this.textures.exists(textureKey)) ?.textureKey ?? '__DEFAULT'; @@ -1010,12 +1102,25 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { this.interactionQueued = true; }); }); + keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ONE) + .on('down', () => this.chooseFirstCommandByIndex(0)); + keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.TWO) + .on('down', () => this.chooseFirstCommandByIndex(1)); + keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.THREE) + .on('down', () => this.chooseFirstCommandByIndex(2)); + keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ENTER) + .on('down', () => this.confirmFirstCommand()); + keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ESC) + .on('down', () => this.cancelFirstCommandChoice()); } 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.commandChoicePanel) { + return; + } if (this.dialogueState) { this.advanceDialogue(); return; @@ -1210,7 +1315,11 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { return; } this.gatherCommandParty(() => { - this.startDialogue(view.definition.dialogue, () => this.finishCamp(), view.definition.id); + this.startDialogue( + view.definition.dialogue, + () => this.openFirstCommandChoice(), + view.definition.id + ); }); return; } @@ -1259,12 +1368,18 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { if (this.dialoguePortrait) { const entry = this.dialoguePortraitEntry(line); if (entry && this.textures.exists(entry.textureKey)) { + this.dialoguePortraitFrame?.setVisible(true); this.dialoguePortrait .setTexture(entry.textureKey) .setDisplaySize(218, 218) .setVisible(true); + this.dialogueNameText?.setX(350); + this.dialogueBodyText?.setX(350).setWordWrapWidth(1382, true); } else { + this.dialoguePortraitFrame?.setVisible(false); this.dialoguePortrait.setVisible(false); + this.dialogueNameText?.setX(128); + this.dialogueBodyText?.setX(128).setWordWrapWidth(1604, true); } } } @@ -1318,6 +1433,350 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { this.showCompletionToast(`${objective?.shortLabel ?? '점검'} 완료`); } + private openFirstCommandChoice() { + if (this.commandChoicePanel || this.navigationPending || this.dialogueState) { + return; + } + + this.stopPlayerMovement(); + this.promptBackground?.setVisible(false); + this.promptText?.setVisible(false); + + const panelX = 100; + const panelY = 90; + const panelWidth = 1720; + const panelHeight = 900; + const cardX = 145; + const cardY = 265; + const cardWidth = 510; + const cardHeight = 530; + const cardGap = 35; + const cardAccents = [0xd8b96f, 0x78b8cf, 0xc98163]; + const selectedOrderId = getCampaignState().sortieOrderSelection?.battleId === + 'first-battle-zhuo-commandery' + ? getCampaignState().sortieOrderSelection?.orderId + : undefined; + this.pendingCommandOrderId = selectedOrderId; + + const objects: Phaser.GameObjects.GameObject[] = []; + const shade = this.add.rectangle(0, 0, sceneWidth, sceneHeight, 0x05070a, 0.8) + .setOrigin(0) + .setInteractive(); + const panel = this.add.rectangle(panelX, panelY, panelWidth, panelHeight, 0x111821, 0.995) + .setOrigin(0) + .setStrokeStyle(3, 0xb89454, 0.94); + const title = this.add.text(sceneWidth / 2, panelY + 54, '첫 출전 군령', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '42px', + color: '#f3dda2', + fontStyle: 'bold' + }).setOrigin(0.5, 0); + const subtitle = this.add.text( + sceneWidth / 2, + panelY + 114, + '같은 전투도 무엇을 지키고 증명할지에 따라 목표와 추가 전공이 달라집니다.', + { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '22px', + color: '#c5ccd3' + } + ).setOrigin(0.5, 0); + const saveHint = this.add.text( + sceneWidth / 2, + panelY + 154, + '역사의 결과를 바꾸는 선택이 아니라, 유비가 첫 승리를 이끄는 방식입니다.', + { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '18px', + color: '#8fa3b8' + } + ).setOrigin(0.5, 0); + objects.push(shade, panel, title, subtitle, saveHint); + + this.commandChoiceViews = prologueMilitiaCampCommandChoices.map((choice, index) => { + const definition = sortieOrderDefinition(choice.orderId); + const x = cardX + index * (cardWidth + cardGap); + const accent = cardAccents[index] ?? cardAccents[0]; + const selected = selectedOrderId === choice.orderId; + const button = this.add.rectangle( + x, + cardY, + cardWidth, + cardHeight, + selected ? 0x263746 : 0x1a2531, + 0.995 + ) + .setOrigin(0) + .setStrokeStyle(selected ? 4 : 2, accent, selected ? 1 : 0.78) + .setInteractive({ useHandCursor: true }); + const number = this.add.text(x + 28, cardY + 24, `${index + 1}`, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '29px', + color: '#f3dda2', + fontStyle: 'bold' + }); + const label = this.add.text(x + 78, cardY + 25, `${choice.label} 군령`, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '27px', + color: '#f4f0e6', + fontStyle: 'bold' + }); + const lead = this.add.text(x + 28, cardY + 82, choice.lead, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '22px', + color: '#f0cf82', + fontStyle: 'bold', + wordWrap: { width: cardWidth - 56, useAdvancedWrap: true } + }); + const promise = this.add.text(x + 28, cardY + 126, choice.battlefieldPromise, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '19px', + color: '#d4dce5', + lineSpacing: 7, + wordWrap: { width: cardWidth - 56, useAdvancedWrap: true } + }); + const divider = this.add.rectangle( + x + 28, + cardY + 224, + cardWidth - 56, + 1, + accent, + 0.48 + ).setOrigin(0); + const conditionTitle = this.add.text(x + 28, cardY + 252, '달성 조건', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: '#93a7b9', + fontStyle: 'bold' + }); + const condition = this.add.text(x + 28, cardY + 284, choice.firstBattleCondition, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '19px', + color: '#f0ede5', + lineSpacing: 7, + wordWrap: { width: cardWidth - 56, useAdvancedWrap: true } + }); + const rewardTitle = this.add.text(x + 28, cardY + 382, '첫 달성 전공', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: '#93a7b9', + fontStyle: 'bold' + }); + const reward = this.add.text( + x + 28, + cardY + 416, + `금 ${definition.rewardGold} · ${definition.rewardItems.join(' · ')}`, + { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '21px', + color: '#a8d58e', + fontStyle: 'bold', + wordWrap: { width: cardWidth - 56, useAdvancedWrap: true } + } + ); + const selectLabel = this.add.text( + x + cardWidth / 2, + cardY + cardHeight - 38, + selected ? '현재 선택 · 다시 선택 가능' : '이 군령으로 출전', + { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '18px', + color: selected ? '#f3dda2' : '#c3ccd5', + fontStyle: 'bold' + } + ).setOrigin(0.5); + + button.on('pointerover', () => { + button.setFillStyle(0x2a3c4e, 0.995).setStrokeStyle(4, accent, 1); + }); + button.on('pointerout', () => { + this.refreshFirstCommandChoice(); + }); + button.on('pointerdown', () => this.previewFirstCommand(choice.orderId)); + objects.push( + button, + number, + label, + lead, + promise, + divider, + conditionTitle, + condition, + rewardTitle, + reward, + selectLabel + ); + return { choice, button, accent, selectLabel }; + }); + + const callback = this.add.text( + sceneWidth / 2, + panelY + panelHeight - 142, + '선택한 군령은 첫 전투 목표판에 이어집니다. 실패해도 전투와 기본 보상에는 불이익이 없습니다.', + { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '19px', + color: '#d5c59e' + } + ).setOrigin(0.5); + const confirmButton = this.add.rectangle( + sceneWidth / 2 - 250, + panelY + panelHeight - 108, + 500, + 58, + this.pendingCommandOrderId ? 0x806536 : 0x2a3037, + 0.99 + ) + .setOrigin(0) + .setStrokeStyle(2, this.pendingCommandOrderId ? 0xe0bf70 : 0x606b76, 0.9) + .setInteractive({ useHandCursor: true }); + const confirmText = this.add.text( + sceneWidth / 2, + panelY + panelHeight - 79, + this.pendingCommandOrderId ? '이 군령으로 출전 확정' : '먼저 군령을 선택하세요', + { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '21px', + color: this.pendingCommandOrderId ? '#fff0bd' : '#89939e', + fontStyle: 'bold' + } + ).setOrigin(0.5); + this.commandChoiceConfirmButton = confirmButton; + this.commandChoiceConfirmText = confirmText; + confirmButton.on('pointerover', () => { + if (this.pendingCommandOrderId) { + confirmButton.setFillStyle(0x9a7940, 1); + } + }); + confirmButton.on('pointerout', () => this.refreshFirstCommandChoice()); + confirmButton.on('pointerdown', () => this.confirmFirstCommand()); + const controls = this.add.text( + sceneWidth / 2, + panelY + panelHeight - 25, + '숫자키 1 · 2 · 3 또는 카드 클릭으로 비교 | Enter 확정 | ESC 막사로 돌아가기', + { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: '#98a5b2' + } + ).setOrigin(0.5); + objects.push( + callback, + confirmButton, + confirmText, + controls + ); + + this.commandChoicePanel = this.add.container(0, 0, objects).setDepth(3200); + this.commandChoicePanelBounds = new Phaser.Geom.Rectangle( + panelX, + panelY, + panelWidth, + panelHeight + ); + this.commandChoiceReadyAt = this.time.now + 260; + this.refreshFirstCommandChoice(); + soundDirector.playSelect(); + } + + private chooseFirstCommandByIndex(index: number) { + const choice = prologueMilitiaCampCommandChoices[index]; + if (choice) { + this.previewFirstCommand(choice.orderId); + } + } + + private previewFirstCommand(orderId: SortieOrderId, bypassInputGuard = false) { + if ( + !this.commandChoicePanel || + this.navigationPending || + (!bypassInputGuard && this.time.now < this.commandChoiceReadyAt) + ) { + return false; + } + const choice = prologueMilitiaCampCommandChoices.find((entry) => entry.orderId === orderId); + if (!choice) { + return false; + } + + this.pendingCommandOrderId = choice.orderId; + this.refreshFirstCommandChoice(); + soundDirector.playSelect(); + return true; + } + + private refreshFirstCommandChoice() { + this.commandChoiceViews.forEach(({ choice, button, accent, selectLabel }) => { + const selected = this.pendingCommandOrderId === choice.orderId; + button + .setFillStyle(selected ? 0x263746 : 0x1a2531, 0.995) + .setStrokeStyle(selected ? 4 : 2, accent, selected ? 1 : 0.78); + selectLabel + .setText(selected ? '선택됨 · 확정 전 변경 가능' : '이 군령을 살펴보기') + .setColor(selected ? '#f3dda2' : '#c3ccd5'); + }); + const enabled = Boolean(this.pendingCommandOrderId); + this.commandChoiceConfirmButton + ?.setFillStyle(enabled ? 0x806536 : 0x2a3037, 0.99) + .setStrokeStyle(2, enabled ? 0xe0bf70 : 0x606b76, 0.9); + this.commandChoiceConfirmText + ?.setText(enabled ? '이 군령으로 출전 확정' : '먼저 군령을 선택하세요') + .setColor(enabled ? '#fff0bd' : '#89939e'); + } + + private confirmFirstCommand(bypassInputGuard = false) { + if ( + !this.commandChoicePanel || + !this.pendingCommandOrderId || + this.navigationPending || + (!bypassInputGuard && this.time.now < this.commandChoiceReadyAt) + ) { + return false; + } + const choice = prologueMilitiaCampCommandChoices.find( + (entry) => entry.orderId === this.pendingCommandOrderId + ); + if (!choice) { + return false; + } + + setCampaignSortieOrderSelection('first-battle-zhuo-commandery', choice.orderId); + this.inputReadyAt = this.time.now + 260; + this.closeCommandChoice(); + soundDirector.playObjectiveAchieved(); + this.startDialogue( + choice.reaction, + () => this.finishCamp(), + `first-command-${choice.orderId}` + ); + return true; + } + + private cancelFirstCommandChoice() { + if ( + !this.commandChoicePanel || + this.navigationPending || + this.time.now < this.commandChoiceReadyAt + ) { + return false; + } + this.closeCommandChoice(); + this.showPromptMessage('추정에게 다시 말을 걸면 첫 출전 군령을 정할 수 있습니다.'); + this.refreshInteractionPrompt(); + return true; + } + + private closeCommandChoice() { + this.commandChoicePanel?.destroy(); + this.commandChoicePanel = undefined; + this.commandChoicePanelBounds = undefined; + this.commandChoiceViews = []; + this.commandChoiceConfirmButton = undefined; + this.commandChoiceConfirmText = undefined; + this.pendingCommandOrderId = undefined; + this.commandChoiceReadyAt = Number.POSITIVE_INFINITY; + } + private finishCamp() { if (this.navigationPending) { return; @@ -1329,6 +1788,10 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { } return; } + if (getCampaignState().sortieOrderSelection?.battleId !== 'first-battle-zhuo-commandery') { + this.openFirstCommandChoice(); + return; + } completeCampaignTutorial(prologueMilitiaCampCampaignTutorialIds.complete); this.refreshObjectiveHud(); diff --git a/src/game/scenes/PrologueVillageScene.ts b/src/game/scenes/PrologueVillageScene.ts index 9a4b372..b1ec318 100644 --- a/src/game/scenes/PrologueVillageScene.ts +++ b/src/game/scenes/PrologueVillageScene.ts @@ -48,6 +48,12 @@ const promptRadius = 160; const characterDisplaySize = 104; const inputCarryoverGuardMs = 320; const narrativeNpcSpeed = 300; +const queuedKeyboardMoveDistance = 150; +const navigationArrivalRadius = 8; +const navigationProbeStep = 24; +const navigationDetourClearance = 64; +const objectivePointerSnapRadius = 172; +const directPointerSnapRadius = 92; const villageBackgroundKey = 'prologue-village-background'; const villageDialoguePortraitContext = { id: 'prologue-village-dialogue', @@ -146,6 +152,20 @@ type NarrativeNpcMove = { }; }; +type QueuedMovementIntent = + | { + kind: 'pointer'; + x: number; + y: number; + queuedAt: number; + } + | { + kind: 'keyboard'; + x: number; + y: number; + queuedAt: number; + }; + const objectiveTutorialIds: Record = { 'meet-zhang-fei': prologueVillageCampaignTutorialIds.meetZhangFei, 'meet-guan-yu': prologueVillageCampaignTutorialIds.meetGuanYu, @@ -178,6 +198,7 @@ export class PrologueVillageScene extends Phaser.Scene { private oathMarker?: Phaser.GameObjects.Text; private oathLabel?: Phaser.GameObjects.Text; private dialoguePanel?: Phaser.GameObjects.Container; + private dialoguePortraitFrame?: Phaser.GameObjects.Rectangle; private dialoguePortrait?: Phaser.GameObjects.Image; private dialogueNameText?: Phaser.GameObjects.Text; private dialogueBodyText?: Phaser.GameObjects.Text; @@ -195,6 +216,17 @@ export class PrologueVillageScene extends Phaser.Scene { }; private interactKeys: Phaser.Input.Keyboard.Key[] = []; private moveTarget?: Phaser.Math.Vector2; + private moveWaypoints: Phaser.Math.Vector2[] = []; + private moveTargetNpcId?: string; + private queuedMovementIntent?: QueuedMovementIntent; + private queuedMovementIntentCount = 0; + private appliedQueuedMovementIntentCount = 0; + private lastAppliedQueuedMovementIntent?: { + kind: QueuedMovementIntent['kind']; + appliedAt: number; + }; + private navigationDetourCount = 0; + private navigationReplanAttempts = 0; private npcMoveTweens = new Map(); private oathGatherPending = false; private ready = false; @@ -252,6 +284,9 @@ export class PrologueVillageScene extends Phaser.Scene { this.navigationPending = false; this.oathGatherPending = false; this.moveTarget = undefined; + this.moveWaypoints = []; + this.moveTargetNpcId = undefined; + this.queuedMovementIntent = undefined; this.dialogueState = undefined; this.stopAllNpcMovement(); releaseExplorationCharacterTextureKeys(this, characterTextureKeys); @@ -301,6 +336,8 @@ export class PrologueVillageScene extends Phaser.Scene { return; } + this.applyQueuedMovementIntentIfReady(); + if (this.consumeInteractionRequest()) { this.tryInteract(); return; @@ -349,6 +386,22 @@ export class PrologueVillageScene extends Phaser.Scene { movement: { speed: playerSpeed, target: this.moveTarget ? { x: this.moveTarget.x, y: this.moveTarget.y } : null, + targetNpcId: this.moveTargetNpcId ?? null, + activeWaypoint: this.moveWaypoints[0] + ? { x: this.moveWaypoints[0].x, y: this.moveWaypoints[0].y } + : null, + waypoints: this.moveWaypoints.map(({ x, y }) => ({ x, y })), + queuedIntent: this.queuedMovementIntent + ? { ...this.queuedMovementIntent } + : null, + queuedIntentCount: this.queuedMovementIntentCount, + appliedQueuedIntentCount: this.appliedQueuedMovementIntentCount, + lastAppliedQueuedIntent: this.lastAppliedQueuedMovementIntent + ? { ...this.lastAppliedQueuedMovementIntent } + : null, + deferredForNarrativeMovement: this.hasNarrativeNpcMovement(), + detourCount: this.navigationDetourCount, + replanAttempts: this.navigationReplanAttempts, walkBounds: this.boundsSnapshot(movementBounds), collisionRadius: playerCollisionRadius }, @@ -411,6 +464,7 @@ export class PrologueVillageScene extends Phaser.Scene { ? this.dialoguePortrait.texture.key : null, portraitVisible: this.dialoguePortrait?.visible ?? false, + portraitFrameVisible: this.dialoguePortraitFrame?.visible ?? false, bounds: dialogueBounds } : { @@ -421,12 +475,15 @@ export class PrologueVillageScene extends Phaser.Scene { sourceNpcId: null, portraitTextureKey: null, portraitVisible: false, + portraitFrameVisible: false, bounds: null }, blockers: this.blockers.map((blocker) => this.boundsSnapshot(blocker)), narrativeMovement: { movingNpcIds: Array.from(this.npcMoveTweens.keys()), - oathGatherPending: this.oathGatherPending + oathGatherPending: this.oathGatherPending, + playerInputDeferred: this.hasNarrativeNpcMovement() && + this.queuedMovementIntent !== undefined }, navigationPending: this.navigationPending, requiredTextures: characterTextureKeys.map((key) => ({ @@ -489,9 +546,18 @@ export class PrologueVillageScene extends Phaser.Scene { this.npcViews.clear(); this.blockers = []; this.objectiveRows.clear(); + this.dialoguePortraitFrame = undefined; this.dialoguePortrait = undefined; this.dialogueState = undefined; this.moveTarget = undefined; + this.moveWaypoints = []; + this.moveTargetNpcId = undefined; + this.queuedMovementIntent = undefined; + this.queuedMovementIntentCount = 0; + this.appliedQueuedMovementIntentCount = 0; + this.lastAppliedQueuedMovementIntent = undefined; + this.navigationDetourCount = 0; + this.navigationReplanAttempts = 0; this.ready = false; this.navigationPending = false; this.inputReadyAt = Number.POSITIVE_INFINITY; @@ -1010,7 +1076,9 @@ export class PrologueVillageScene extends Phaser.Scene { .setOrigin(0) .setStrokeStyle(1, 0x546174, 0.7); const portraitFrame = this.add.rectangle(x + 132, y + 152, 226, 226, 0x0e131a, 0.92) - .setStrokeStyle(2, 0x9e8350, 0.9); + .setStrokeStyle(2, 0x9e8350, 0.9) + .setVisible(false); + this.dialoguePortraitFrame = portraitFrame; const firstPortraitKey = villageDialoguePortraitEntries() .find(({ textureKey }) => this.textures.exists(textureKey)) ?.textureKey ?? '__DEFAULT'; @@ -1058,6 +1126,18 @@ export class PrologueVillageScene extends Phaser.Scene { left: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.A), right: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.D) }; + [ + { key: this.cursorKeys.up, x: 0, y: -1 }, + { key: this.cursorKeys.down, x: 0, y: 1 }, + { key: this.cursorKeys.left, x: -1, y: 0 }, + { key: this.cursorKeys.right, x: 1, y: 0 }, + { key: this.moveKeys.up, x: 0, y: -1 }, + { key: this.moveKeys.down, x: 0, y: 1 }, + { key: this.moveKeys.left, x: -1, y: 0 }, + { key: this.moveKeys.right, x: 1, y: 0 } + ].forEach(({ key, x, y }) => { + key.on('down', () => this.queueNarrativeKeyboardMovement(x, y)); + }); this.interactKeys = [ keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.E), keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE), @@ -1092,7 +1172,16 @@ export class PrologueVillageScene extends Phaser.Scene { } 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); + if (this.hasNarrativeNpcMovement()) { + this.queueMovementIntent({ + kind: 'pointer', + x: pointer.x, + y: pointer.y, + queuedAt: this.time.now + }); + } else { + this.beginPointerMovement(pointer.x, pointer.y); + } } }); } @@ -1102,13 +1191,34 @@ export class PrologueVillageScene extends Phaser.Scene { return; } const keyboardDirection = this.keyboardMovementDirection(); + if (this.hasNarrativeNpcMovement()) { + if (keyboardDirection.lengthSq() > 0) { + const normalized = keyboardDirection.normalize(); + this.queueMovementIntent({ + kind: 'keyboard', + x: normalized.x, + y: normalized.y, + queuedAt: time + }); + } + this.setPlayerMoving(false); + return; + } + let movement = keyboardDirection; if (movement.lengthSq() > 0) { - this.moveTarget = undefined; + this.clearNavigationTarget(); } 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; + const activeTarget = this.moveWaypoints[0] ?? this.moveTarget; + const targetDelta = activeTarget.clone().subtract( + new Phaser.Math.Vector2(this.player.x, this.player.y) + ); + if (targetDelta.length() <= navigationArrivalRadius) { + if (this.moveWaypoints.length > 0) { + this.moveWaypoints.shift(); + } else { + this.clearNavigationTarget(); + } movement = new Phaser.Math.Vector2(); } else { movement = targetDelta.normalize(); @@ -1138,7 +1248,9 @@ export class PrologueVillageScene extends Phaser.Scene { moved = true; } if (!moved && this.moveTarget) { - this.moveTarget = undefined; + if (!this.replanBlockedPointerMovement()) { + this.clearNavigationTarget(); + } } this.syncPlayerView(); @@ -1155,6 +1267,361 @@ export class PrologueVillageScene extends Phaser.Scene { } } + private hasNarrativeNpcMovement() { + return this.npcMoveTweens.size > 0; + } + + private queueNarrativeKeyboardMovement(x: number, y: number) { + if ( + !this.ready || + this.navigationPending || + this.oathGatherPending || + this.dialogueState || + this.time.now < this.inputReadyAt || + !this.hasNarrativeNpcMovement() + ) { + return; + } + this.queueMovementIntent({ + kind: 'keyboard', + x, + y, + queuedAt: this.time.now + }); + } + + private queueMovementIntent(intent: QueuedMovementIntent) { + const current = this.queuedMovementIntent; + if ( + current?.kind === intent.kind && + Math.abs(current.x - intent.x) < 0.01 && + Math.abs(current.y - intent.y) < 0.01 + ) { + return; + } + this.queuedMovementIntent = intent; + this.queuedMovementIntentCount += 1; + } + + private applyQueuedMovementIntentIfReady() { + if ( + !this.queuedMovementIntent || + this.hasNarrativeNpcMovement() || + this.dialogueState || + this.oathGatherPending || + this.navigationPending + ) { + return; + } + const intent = this.queuedMovementIntent; + this.queuedMovementIntent = undefined; + this.appliedQueuedMovementIntentCount += 1; + this.lastAppliedQueuedMovementIntent = { + kind: intent.kind, + appliedAt: this.time.now + }; + if (intent.kind === 'pointer') { + this.beginPointerMovement(intent.x, intent.y); + return; + } + if (!this.player) { + return; + } + const direction = new Phaser.Math.Vector2(intent.x, intent.y); + if (direction.lengthSq() <= 0) { + return; + } + direction.normalize().scale(queuedKeyboardMoveDistance); + this.beginNavigationTo( + this.player.x + direction.x, + this.player.y + direction.y + ); + } + + private beginPointerMovement(x: number, y: number) { + const snappedTarget = this.pointerMovementTargetAt(x, y); + if (!snappedTarget) { + this.beginNavigationTo(x, y); + return; + } + + const plannedApproaches = this.approachPositions(snappedTarget.x, snappedTarget.y) + .map((destination) => ({ + destination, + route: this.planNavigationRoute(destination) + })) + .filter((entry): entry is { + destination: Phaser.Math.Vector2; + route: Phaser.Math.Vector2[]; + } => Boolean(entry.route)) + .sort((left, right) => ( + this.navigationRouteLength(left.route) - this.navigationRouteLength(right.route) + )); + const best = plannedApproaches[0]; + if (best) { + this.applyNavigationRoute(best.route, snappedTarget.id); + return; + } + const fallback = this.safeApproachPosition(snappedTarget.x, snappedTarget.y); + this.beginNavigationTo(fallback.x, fallback.y, snappedTarget.id); + } + + private beginNavigationTo(x: number, y: number, targetNpcId?: string) { + const destination = this.nearestWalkablePoint(x, y); + if (!destination) { + this.clearNavigationTarget(); + return; + } + const route = this.planNavigationRoute(destination); + if (!route) { + this.clearNavigationTarget(); + return; + } + this.applyNavigationRoute(route, targetNpcId); + } + + private applyNavigationRoute(route: Phaser.Math.Vector2[], targetNpcId?: string) { + const destination = route[route.length - 1]; + if (!destination) { + this.clearNavigationTarget(); + return; + } + this.moveTarget = destination.clone(); + this.moveWaypoints = route.slice(0, -1).map((waypoint) => waypoint.clone()); + this.moveTargetNpcId = targetNpcId; + this.navigationReplanAttempts = 0; + if (route.length > 1) { + this.navigationDetourCount += 1; + } + } + + private pointerMovementTargetAt(x: number, y: number) { + const targets: InteractionTarget[] = [ + ...Array.from(this.npcViews.values()) + .filter(({ definition }) => !this.npcMoveTweens.has(definition.id)) + .map(({ definition, sprite }) => ({ + kind: 'npc' as const, + id: definition.id, + name: definition.name, + x: sprite.x, + y: sprite.y + })), + { + kind: 'oath' as const, + id: 'make-oath' as const, + name: '도원결의', + x: oathPosition.x, + y: oathPosition.y + } + ]; + return targets + .map((target) => ({ + target, + distance: Phaser.Math.Distance.Between(x, y, target.x, target.y), + priority: this.interactionTargetPriority(target) + })) + .filter(({ distance, priority }) => ( + distance <= (priority === 0 ? objectivePointerSnapRadius : directPointerSnapRadius) + )) + .sort((left, right) => ( + left.priority - right.priority || + left.distance - right.distance + ))[0]?.target; + } + + private approachPositions(targetX: number, targetY: number) { + return [ + new Phaser.Math.Vector2(targetX, targetY + 88), + new Phaser.Math.Vector2(targetX - 88, targetY), + new Phaser.Math.Vector2(targetX + 88, targetY), + new Phaser.Math.Vector2(targetX, targetY - 88) + ].filter(({ x, y }) => this.canPlayerOccupy(x, y)); + } + + private nearestWalkablePoint(x: number, y: number) { + const margin = playerCollisionRadius + 4; + const clamped = new Phaser.Math.Vector2( + Phaser.Math.Clamp(x, movementBounds.left + margin, movementBounds.right - margin), + Phaser.Math.Clamp(y, movementBounds.top + margin, movementBounds.bottom - margin) + ); + if (this.canPlayerOccupy(clamped.x, clamped.y)) { + return clamped; + } + for (const radius of [48, 80, 112, 144]) { + for (let index = 0; index < 8; index += 1) { + const angle = index * Math.PI / 4; + const candidate = new Phaser.Math.Vector2( + Phaser.Math.Clamp( + clamped.x + Math.cos(angle) * radius, + movementBounds.left + margin, + movementBounds.right - margin + ), + Phaser.Math.Clamp( + clamped.y + Math.sin(angle) * radius, + movementBounds.top + margin, + movementBounds.bottom - margin + ) + ); + if (this.canPlayerOccupy(candidate.x, candidate.y)) { + return candidate; + } + } + } + return undefined; + } + + private planNavigationRoute(destination: Phaser.Math.Vector2) { + if (!this.player || !this.canPlayerOccupy(destination.x, destination.y)) { + return undefined; + } + const start = new Phaser.Math.Vector2(this.player.x, this.player.y); + if (this.segmentIsNavigable(start, destination)) { + return [destination.clone()]; + } + + const candidates = this.navigationDetourCandidates(start, destination); + let bestRoute: Phaser.Math.Vector2[] | undefined; + let bestLength = Number.POSITIVE_INFINITY; + const startVisible = candidates.filter((candidate) => ( + this.segmentIsNavigable(start, candidate) + )); + const destinationVisible = candidates.filter((candidate) => ( + this.segmentIsNavigable(candidate, destination) + )); + + startVisible.forEach((waypoint) => { + if (!this.segmentIsNavigable(waypoint, destination)) { + return; + } + const route = [waypoint, destination]; + const length = this.navigationRouteLength(route); + if (length < bestLength) { + bestLength = length; + bestRoute = route; + } + }); + startVisible.forEach((first) => { + destinationVisible.forEach((second) => { + if (first.equals(second) || !this.segmentIsNavigable(first, second)) { + return; + } + const route = [first, second, destination]; + const length = this.navigationRouteLength(route); + if (length < bestLength) { + bestLength = length; + bestRoute = route; + } + }); + }); + return bestRoute?.map((waypoint) => waypoint.clone()); + } + + private navigationDetourCandidates( + start: Phaser.Math.Vector2, + destination: Phaser.Math.Vector2 + ) { + const margin = playerCollisionRadius + 4; + const candidates = [ + new Phaser.Math.Vector2(start.x, destination.y), + new Phaser.Math.Vector2(destination.x, start.y) + ]; + this.blockers.forEach((blocker) => { + const left = blocker.left - navigationDetourClearance; + const right = blocker.right + navigationDetourClearance; + const top = blocker.top - navigationDetourClearance; + const bottom = blocker.bottom + navigationDetourClearance; + candidates.push( + new Phaser.Math.Vector2(left, top), + new Phaser.Math.Vector2(right, top), + new Phaser.Math.Vector2(left, bottom), + new Phaser.Math.Vector2(right, bottom) + ); + }); + this.npcViews.forEach(({ sprite }) => { + candidates.push( + new Phaser.Math.Vector2(sprite.x - 72, sprite.y), + new Phaser.Math.Vector2(sprite.x + 72, sprite.y), + new Phaser.Math.Vector2(sprite.x, sprite.y - 72), + new Phaser.Math.Vector2(sprite.x, sprite.y + 72) + ); + }); + + const unique = new Map(); + candidates.forEach((candidate) => { + candidate.set( + Phaser.Math.Clamp( + candidate.x, + movementBounds.left + margin, + movementBounds.right - margin + ), + Phaser.Math.Clamp( + candidate.y, + movementBounds.top + margin, + movementBounds.bottom - margin + ) + ); + if (!this.canPlayerOccupy(candidate.x, candidate.y)) { + return; + } + unique.set(`${Math.round(candidate.x)}:${Math.round(candidate.y)}`, candidate); + }); + return Array.from(unique.values()); + } + + private segmentIsNavigable( + from: Phaser.Math.Vector2, + to: Phaser.Math.Vector2 + ) { + const distance = Phaser.Math.Distance.Between(from.x, from.y, to.x, to.y); + const steps = Math.max(1, Math.ceil(distance / navigationProbeStep)); + for (let index = 1; index <= steps; index += 1) { + const progress = index / steps; + const x = Phaser.Math.Linear(from.x, to.x, progress); + const y = Phaser.Math.Linear(from.y, to.y, progress); + if (!this.canPlayerOccupy(x, y)) { + return false; + } + } + return true; + } + + private navigationRouteLength(route: readonly Phaser.Math.Vector2[]) { + if (!this.player) { + return Number.POSITIVE_INFINITY; + } + let previous = new Phaser.Math.Vector2(this.player.x, this.player.y); + return route.reduce((total, waypoint) => { + const length = Phaser.Math.Distance.Between( + previous.x, + previous.y, + waypoint.x, + waypoint.y + ); + previous = waypoint; + return total + length; + }, 0); + } + + private replanBlockedPointerMovement() { + if (!this.moveTarget || this.navigationReplanAttempts >= 2) { + return false; + } + this.navigationReplanAttempts += 1; + const route = this.planNavigationRoute(this.moveTarget); + if (!route) { + return false; + } + this.moveWaypoints = route.slice(0, -1).map((waypoint) => waypoint.clone()); + return true; + } + + private clearNavigationTarget() { + this.moveTarget = undefined; + this.moveWaypoints = []; + this.moveTargetNpcId = undefined; + this.navigationReplanAttempts = 0; + } + private consumeInteractionRequest() { const keyPressedThisFrame = this.interactKeys.some((key) => Phaser.Input.Keyboard.JustDown(key)); const requested = this.interactionQueued || keyPressedThisFrame; @@ -1221,7 +1688,7 @@ export class PrologueVillageScene extends Phaser.Scene { } private stopPlayerMovement() { - this.moveTarget = undefined; + this.clearNavigationTarget(); this.setPlayerMoving(false); } @@ -1540,12 +2007,18 @@ export class PrologueVillageScene extends Phaser.Scene { if (this.dialoguePortrait) { const entry = this.dialoguePortraitEntry(line); if (entry && this.textures.exists(entry.textureKey)) { + this.dialoguePortraitFrame?.setVisible(true); this.dialoguePortrait .setTexture(entry.textureKey) .setDisplaySize(218, 218) .setVisible(true); + this.dialogueNameText?.setX(350); + this.dialogueBodyText?.setX(350).setWordWrapWidth(1382, true); } else { + this.dialoguePortraitFrame?.setVisible(false); this.dialoguePortrait.setVisible(false); + this.dialogueNameText?.setX(128); + this.dialogueBodyText?.setX(128).setWordWrapWidth(1604, true); } } } @@ -1800,9 +2273,33 @@ export class PrologueVillageScene extends Phaser.Scene { } ]; return targets - .map((target) => ({ target, distance: this.distanceTo(target.x, target.y) })) + .map((target) => ({ + target, + distance: this.distanceTo(target.x, target.y), + priority: this.interactionTargetPriority(target) + })) .filter(({ distance }) => distance <= radius) - .sort((left, right) => left.distance - right.distance)[0]?.target; + .sort((left, right) => ( + left.priority - right.priority || + left.distance - right.distance + ))[0]?.target; + } + + private interactionTargetPriority(target: InteractionTarget) { + if (target.kind === 'oath') { + return this.allRequiredObjectivesComplete() ? 0 : 4; + } + const definition = this.npcViews.get(target.id)?.definition; + if (!definition?.objectiveId) { + return 2; + } + if (!this.isObjectiveComplete(definition.objectiveId)) { + return this.objectiveUnlocked(definition.objectiveId) ? 0 : 3; + } + if (definition.id === 'guan-yu' || definition.id === 'zhang-fei') { + return 4; + } + return 2; } private interactionTargetById(targetId: string): InteractionTarget | undefined { diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts index 135b0b9..b428f07 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -19,6 +19,7 @@ import { isSortieOrderId, sortieOrderCheckIdsByOrder, sortieOrderDefinition, + sortieOrderDisplayLabel, sortieOrderIds, sortieOrderRewardClaimId, type CampaignSortieOrderCommandSnapshot, @@ -1142,9 +1143,9 @@ export function campaignVictoryRewardPresentation(report: CampaignVictoryRewardR ? sortieOrderDefinition(report.sortieOrder.orderId) : undefined; const sortieOrderBonus = definition - ? { + ? { orderId: definition.id, - label: definition.label, + label: sortieOrderDisplayLabel(report.battleId, definition.id), rewardGold: definition.rewardGold, rewardItems: [...definition.rewardItems] }