diff --git a/docs/game-design-improvement-goal.md b/docs/game-design-improvement-goal.md index cee88ec..9eb62ee 100644 --- a/docs/game-design-improvement-goal.md +++ b/docs/game-design-improvement-goal.md @@ -234,10 +234,49 @@ RPG로 다듬는다. 마지막 연결한 준비만 성과로 강조해 결과가 장비 목록처럼 번잡해지지 않도록 한다. -다음 묶음은 서주 관청에서 얻은 정보와 미축과 나눈 공명 선택을 여덟 번째 -전투의 적 의도·보급 판단·동료 행동과 결과 회고로 각각 구분해 회수한다. -장비와 마찬가지로 이미 한 준비를 중복 강화하지 않고, 플레이어가 실제로 -고른 한 가지 관점이 전투 중 눈에 보이는 판단 차이를 만들게 한다. +### 완료한 11차 묶음: 초반 전투의 행동 학습과 키보드 지휘 + +- 첫 전투의 선택 → 이동 → 공격 → 피해 예측 확인을 설명 화면이 아니라 + 실제 전장에서 한 번씩 직접 수행하는 4단계 안내로 바꿨다. +- 이동 전후 적 의도와 개인·전군 기대 피해 차이, 공격·반격의 피해·명중· + 전투 뒤 HP를 같은 판단 흐름 안에서 비교할 수 있게 했다. +- 배치, 부대 선택, 이동, 명령, 대상 예측과 확정을 방향키·Tab·Enter·Esc로 + 끝까지 진행할 수 있게 하고, 비활성 명령은 자동으로 건너뛴다. +- 행동을 마친 장수는 지도 표식·명암·부대 목록·상세 정보에서 같은 상태로 + 표시하며, 마지막 이동만으로 턴 종료 확인창이 열리지 않게 유지한다. + +### 완료한 12차 묶음: 탐색 인물 연출과 끊기지 않는 음향 흐름 + +- 마을·의용군 막사·전투 사이 체류의 이동 인물을 전투 장수와 구분되는 + 프로젝트 고유 SD 인물로 통일하고 방향·보행 동작을 보강했다. +- 대화창은 전신 대신 얼굴 중심 초상으로 맞추고, 말하는 인물과 배경· + 이동 인물이 서로 다른 사람처럼 보이지 않게 공통 자산 규칙을 적용했다. +- 장비가 관우 곁으로 순간이동하던 동선을 제거하고, 목표 인물에게 실제로 + 걸어가 도착한 뒤 대화를 시작하도록 우회 경로와 자동 상호작용을 다듬었다. +- 발걸음과 반복 전투 효과음에 작은 변화를 주고, 음악·환경음의 볼륨 전환을 + 짧게 보간해 장면 이동과 대화 시작 때 소리가 갑자기 끊기지 않게 했다. + +### 완료한 13차 묶음: 놓치지 않는 전우 기억과 다시 찾는 실전 안내 + +- 첫 승리 뒤 기본 동선을 `북문 정찰 → 핵심 회고 → 전우 기억 → 출진`으로 + 연결한다. 전우 기억을 마치면 주 행동은 `전우 조 편성하기`로 바뀐다. +- 다음 전투 한정 전우 기억 `+5%p`를 회고하지 않았거나 정확한 두 사람을 + 핵심 공명조로 지정하지 않은 채 출진하면, 첫 입력에서 손실을 구체적으로 + 알리고 같은 조건에서 한 번 더 확인해야만 보너스 없이 진행한다. +- 전투 HUD에 `메뉴 M`을 항상 보이게 두고, M·방향키·Tab·Enter·Esc만으로 + 턴 종료·위험 범위·저장·전황·음향 등 모든 지도 메뉴 기능을 다룬다. +- `실전 안내`에서 기존 4단계 실제 조작을 완료 여부와 관계없이 다시 + 시작할 수 있다. 현재 배치로 공격까지 이어지는 경로가 없으면 이유와 + 다시 시도할 시점을 알려 주며, 진행 중에는 `K`로 건너뛸 수 있다. +- Canvas와 WebGL 모두 1920x1080 CSS viewport, 100% 확대, DPR 1에서 + 포인터 입력 없이 메뉴와 4단계 전투 조작을 자동 검증한다. + +다음 묶음은 첫 승리 직후 같은 보상 정보가 전투 결과·후일담·군영 도착· +전투 보고에 연속 반복되는 흐름을 정리한다. 각 화면은 `무엇을 얻었는가`, +`인물이 어떻게 받아들이는가`, `다음에 무엇을 할 것인가` 중 한 역할만 +맡게 해 정보 피로를 줄이되, 상세 기록은 원할 때 다시 볼 수 있게 남긴다. +그다음에는 조기 턴 종료 확인창에 적의 대상별 공격 수뿐 아니라 예상 피해와 +명중 위험을 함께 요약해 마지막 결정에 필요한 정보를 보존한다. ## 장기 개선 순서 @@ -306,6 +345,12 @@ RPG로 다듬는다. - [Xbox Accessibility Guideline 105](https://learn.microsoft.com/en-us/xbox/accessibility/xbox-accessibility-guidelines/105): 음악·효과·환경음을 분리 조절하고 중요한 정보음이 배경음에 묻히지 않게 설계 +- [Xbox Accessibility Guideline 107](https://learn.microsoft.com/en-us/xbox/accessibility/xbox-accessibility-guidelines/107): + 마우스 중심 기능도 단일 키 입력으로 같은 결과에 도달하게 하고, 메뉴와 + 게임 내 UI에 디지털 탐색 경로를 제공 +- [Xbox Accessibility Guideline 109](https://learn.microsoft.com/en-us/xbox/accessibility/xbox-accessibility-guidelines/109): + 핵심 튜토리얼은 정적 조작표가 아니라 실제 행동으로 익히게 하며, 이미 + 완료했더라도 플레이 중 원할 때 다시 찾을 수 있게 제공 - [Darkest Dungeon Affliction System, Game Developer](https://www.gamedeveloper.com/design/game-design-deep-dive-i-darkest-dungeon-s-i-affliction-system): 원정의 결과가 귀환 뒤 인물별 준비 선택으로 이어지고 수치 결과는 명확히 공개 - [Into the Breach Design Postmortem PDF](https://media.gdcvault.com/gdc2019/presentations/Into%20the%20Breach%20Postmortem%20Final.pdf): diff --git a/scripts/verify-battle-keyboard-command-flow-browser.mjs b/scripts/verify-battle-keyboard-command-flow-browser.mjs index 454ac85..f8d9a88 100644 --- a/scripts/verify-battle-keyboard-command-flow-browser.mjs +++ b/scripts/verify-battle-keyboard-command-flow-browser.mjs @@ -94,6 +94,7 @@ try { await installPointerInputRecorder(page); let state = await readBattleState(page); assertFirstBattleObjectiveDefinitions(state.objectiveDefinitions); + assertBattleMenuDiscovery(state.battleHud?.header); assertDeploymentKeyboardFocus(state.deploymentKeyboard, 'start'); assertDeploymentKeyboardOrder(state.deploymentKeyboard); @@ -162,12 +163,84 @@ try { { timeout: 30000 } ); + await page.waitForFunction( + () => { + const battle = window.__HEROS_DEBUG__?.battle(); + return ( + battle?.phase === 'idle' && + battle.activeBattleEvent?.key === 'opening' && + battle.firstBattleTutorial?.active === false + ); + }, + undefined, + { timeout: 30000 } + ); + + const automaticTutorialMenuGuardSetup = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + if ( + !scene || + typeof scene.hideBattleEventBanner !== 'function' + ) { + return { ready: false }; + } + scene.hideBattleEventBanner(); + window.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'm', + code: 'KeyM', + bubbles: true, + cancelable: true + }) + ); + window.dispatchEvent( + new KeyboardEvent('keyup', { + key: 'm', + code: 'KeyM', + bubbles: true, + cancelable: true + }) + ); + const battle = window.__HEROS_DEBUG__?.battle(); + return { + ready: true, + activeBattleEvent: battle?.activeBattleEvent ?? null, + mapMenuKeyboard: battle?.mapMenuKeyboard ?? null, + firstBattleTutorial: battle?.firstBattleTutorial ?? null + }; + }); + assert( + automaticTutorialMenuGuardSetup.ready === true && + automaticTutorialMenuGuardSetup.activeBattleEvent === null && + automaticTutorialMenuGuardSetup.mapMenuKeyboard?.visible === true && + automaticTutorialMenuGuardSetup.firstBattleTutorial?.active === false, + `Expected the opening event to close and the M menu to open before the automatic tutorial retry: ${JSON.stringify(automaticTutorialMenuGuardSetup)}` + ); + assertMapMenuFocus(automaticTutorialMenuGuardSetup.mapMenuKeyboard); + + await page.waitForTimeout(540); + state = await readBattleState(page); + assert( + state.mapMenuKeyboard?.visible === true && + state.activeBattleEvent === null && + state.firstBattleTutorial?.active === false && + state.firstBattleTutorial.step === null, + `Automatic tutorial must remain inactive across multiple retry intervals while the M menu is open: ${JSON.stringify({ + mapMenuKeyboard: state.mapMenuKeyboard, + activeBattleEvent: state.activeBattleEvent, + firstBattleTutorial: state.firstBattleTutorial + })}` + ); + + await page.keyboard.press('KeyM'); + await waitForMapMenuVisibility(page, false); await page.waitForFunction( () => { const battle = window.__HEROS_DEBUG__?.battle(); return ( battle?.phase === 'idle' && battle.activeBattleEvent === null && + battle.mapMenuKeyboard?.visible === false && battle.firstBattleTutorial?.active === true && battle.firstBattleTutorial.step === 'select-unit' ); @@ -177,6 +250,121 @@ try { ); state = await readBattleState(page); + assertFirstBattleTutorialStep( + state.firstBattleTutorial, + 'select-unit', + 1, + false + ); + assert( + boundsInsideViewport(state.firstBattleTutorial.skipBounds), + `Expected the first-battle tutorial skip control inside the desktop viewport: ${JSON.stringify(state.firstBattleTutorial)}` + ); + + await page.keyboard.press('KeyK'); + await page.waitForFunction(() => { + const tutorial = window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial; + return ( + tutorial?.active === false && + tutorial.completion === 'skipped' && + tutorial.step === null + ); + }); + state = await readBattleState(page); + assert( + state.firstBattleTutorial?.completed === true && + state.firstBattleTutorial.cardBounds === null && + state.firstBattleTutorial.skipBounds === null, + `K must dismiss and persist the initially active first-battle tutorial: ${JSON.stringify(state.firstBattleTutorial)}` + ); + + await page.keyboard.press('KeyM'); + await waitForMapMenuVisibility(page, true); + state = await readBattleState(page); + assertMapMenuFocus(state.mapMenuKeyboard); + await page.waitForTimeout(80); + + await page.keyboard.press('KeyM'); + await waitForMapMenuVisibility(page, false); + await page.waitForTimeout(80); + + await page.keyboard.press('KeyM'); + await waitForMapMenuVisibility(page, true); + await page.waitForTimeout(80); + await page.keyboard.press('Escape'); + await waitForMapMenuVisibility(page, false); + await page.waitForTimeout(80); + + await page.keyboard.press('KeyM'); + await waitForMapMenuVisibility(page, true); + state = await readBattleState(page); + const mapMenuItems = state.mapMenuKeyboard.items; + const disabledMapMenuIndex = mapMenuItems.findIndex( + (item, index) => + item.available === false && + mapMenuItems[index - 1]?.available === true && + mapMenuItems[index + 1]?.available === true + ); + assert( + disabledMapMenuIndex > 0 && + mapMenuItemId(mapMenuItems[disabledMapMenuIndex]) === 'load', + `Expected an unavailable load action between enabled map-menu actions after clearing saves: ${JSON.stringify(state.mapMenuKeyboard)}` + ); + const beforeDisabledMapMenuId = + mapMenuItemId(mapMenuItems[disabledMapMenuIndex - 1]); + const afterDisabledMapMenuId = + mapMenuItemId(mapMenuItems[disabledMapMenuIndex + 1]); + + await focusMapMenuItem(page, beforeDisabledMapMenuId); + await page.keyboard.press('ArrowDown'); + await waitForMapMenuFocus(page, afterDisabledMapMenuId); + state = await readBattleState(page); + assertMapMenuFocus(state.mapMenuKeyboard, afterDisabledMapMenuId); + + await page.keyboard.press('ArrowUp'); + await waitForMapMenuFocus(page, beforeDisabledMapMenuId); + state = await readBattleState(page); + assertMapMenuFocus(state.mapMenuKeyboard, beforeDisabledMapMenuId); + + await page.keyboard.press('Tab'); + await waitForMapMenuFocus(page, afterDisabledMapMenuId); + state = await readBattleState(page); + assertMapMenuFocus(state.mapMenuKeyboard, afterDisabledMapMenuId); + + await page.keyboard.press('Shift+Tab'); + await waitForMapMenuFocus(page, beforeDisabledMapMenuId); + + await focusMapMenuItem(page, 'tutorial'); + state = await readBattleState(page); + assertMapMenuFocus(state.mapMenuKeyboard, 'tutorial'); + await page.screenshot({ + path: `dist/verification-battle-keyboard-map-menu-${renderer}.png`, + fullPage: true + }); + await page.keyboard.press('Enter'); + await page.waitForFunction(() => { + const battle = window.__HEROS_DEBUG__?.battle(); + const tutorial = battle?.firstBattleTutorial; + return ( + battle?.mapMenuKeyboard?.visible === false && + tutorial?.active === true && + tutorial.step === 'select-unit' && + tutorial.stepNumber === 1 && + tutorial.totalSteps === 4 + ); + }); + + state = await readBattleState(page); + assertFirstBattleTutorialStep( + state.firstBattleTutorial, + 'select-unit', + 1, + true + ); + assert( + state.firstBattleTutorial.completion === null, + `On-demand tutorial replay must begin as a fresh run: ${JSON.stringify(state.firstBattleTutorial)}` + ); const tutorialPath = state.firstBattleTutorial?.path; assert( tutorialPath?.unitId && @@ -205,7 +393,8 @@ try { return ( battle?.phase === 'moving' && battle.selectedUnitId === expectedUnitId && - battle.firstBattleTutorial?.step === 'move' + battle.firstBattleTutorial?.step === 'move' && + battle.firstBattleTutorial.stepNumber === 2 ); }, tutorialPath.unitId @@ -224,6 +413,7 @@ try { battle.selectedUnitId === expectedUnitId && battle.pendingMove?.unitId === expectedUnitId && battle.firstBattleTutorial?.step === 'attack-command' && + battle.firstBattleTutorial.stepNumber === 3 && battle.commandMenuKeyboard?.visible === true ); }, @@ -245,7 +435,8 @@ try { return ( battle?.phase === 'targeting' && battle.selectedUnitId === expectedUnitId && - battle.firstBattleTutorial?.step === 'target-preview' + battle.firstBattleTutorial?.step === 'target-preview' && + battle.firstBattleTutorial.stepNumber === 4 ); }, tutorialPath.unitId @@ -294,7 +485,8 @@ try { return ( battle?.actedUnitIds?.includes(unitId) && battle.commandMenuKeyboard?.visible === false && - battle.firstBattleTutorial?.active === false + battle.firstBattleTutorial?.active === false && + battle.firstBattleTutorial.completion === 'completed' ); }, tutorialPath.unitId, @@ -311,13 +503,16 @@ try { commandMenu: state?.commandMenuKeyboard ?? null, phase: state?.phase ?? null, tutorialActive: state?.firstBattleTutorial?.active ?? null, + tutorialCompletion: + state?.firstBattleTutorial?.completion ?? null, pointerInputCount: qa?.pointerInputCount ?? -1 }; }, tutorialPath.unitId); assert( completed.acted && completed.commandMenu?.visible === false && - completed.tutorialActive === false, + completed.tutorialActive === false && + completed.tutorialCompletion === 'completed', `Keyboard attack confirmation must complete exactly one first action: ${JSON.stringify(completed)}` ); assert( @@ -529,6 +724,7 @@ try { console.log( `Verified ${renderer} keyboard-only first-battle flow at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height} DPR1 under /heros_web/: ` + 'deployment focus cycles with Tab/Shift+Tab and held arrows, focus-loss recovery works without keyup, Enter starts the recommended deployment, ' + + 'the automatic tutorial stays paused while the M menu is open and resumes after it closes; K skips it, M toggles the map menu, Esc closes it, arrows/Tab skip its disabled load action, and Enter replays the four-step tutorial; ' + 'idle ally selection, movement, attack forecast, and confirmation complete one action; disabled command and usable choices are skipped, ' + 'submenu Escape preserves the pending move, and a second action completes with zero pointer input.' ); @@ -638,6 +834,38 @@ function assertFirstBattleObjectiveDefinitions(objectives) { ); } +function assertFirstBattleTutorialStep( + tutorial, + expectedStep, + stepNumber, + expectedCompleted +) { + assert( + tutorial?.active === true && + tutorial.completed === expectedCompleted && + tutorial.onDemandAvailable === true && + tutorial.step === expectedStep && + tutorial.stepNumber === stepNumber && + tutorial.totalSteps === 4 && + tutorial.cardBounds && + tutorial.highlightBounds && + tutorial.skipBounds && + boundsInsideViewport(tutorial.cardBounds) && + boundsInsideViewport(tutorial.highlightBounds) && + boundsInsideViewport(tutorial.skipBounds), + `Expected visible first-battle tutorial step ${stepNumber}/4 (${expectedStep}) inside the desktop viewport: ${JSON.stringify(tutorial)}` + ); +} + +function assertBattleMenuDiscovery(header) { + assert( + header?.menuLabel === '메뉴 M' && + header.menuBounds && + boundsInsideViewport(header.menuBounds), + `Expected a visible 메뉴 M discovery control inside the desktop HUD: ${JSON.stringify(header)}` + ); +} + function assertDeploymentKeyboardFocus(keyboard, expectedId) { const focusedItem = keyboard?.focusedItem; const matchingItem = keyboard?.items?.find(({ id }) => id === expectedId); @@ -679,6 +907,118 @@ async function waitForDeploymentFocus(page, expectedId) { } } +async function waitForMapMenuVisibility(page, expectedVisible) { + try { + await page.waitForFunction( + (visible) => + window.__HEROS_DEBUG__?.battle()?.mapMenuKeyboard?.visible === visible, + expectedVisible + ); + } catch (error) { + const diagnostic = await page.evaluate(() => ({ + phase: window.__HEROS_DEBUG__?.battle()?.phase, + mapMenuVisible: window.__HEROS_DEBUG__?.battle()?.mapMenuVisible, + mapMenuKeyboard: + window.__HEROS_DEBUG__?.battle()?.mapMenuKeyboard ?? null, + firstBattleTutorial: + window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial ?? null, + keyboardInputs: + window.__HEROS_KEYBOARD_COMMAND_QA__?.keyboardInputs ?? [] + })); + throw new Error( + `Timed out waiting for map-menu visibility ${expectedVisible}: ${JSON.stringify(diagnostic)}`, + { cause: error } + ); + } +} + +async function waitForMapMenuFocus(page, expectedId) { + try { + await page.waitForFunction( + (id) => { + const focused = + window.__HEROS_DEBUG__?.battle()?.mapMenuKeyboard?.focusedItem; + return (focused?.id ?? focused?.action) === id; + }, + expectedId + ); + } catch (error) { + const diagnostic = await page.evaluate(() => ({ + mapMenuKeyboard: + window.__HEROS_DEBUG__?.battle()?.mapMenuKeyboard ?? null, + keyboardInputs: + window.__HEROS_KEYBOARD_COMMAND_QA__?.keyboardInputs ?? [] + })); + throw new Error( + `Timed out waiting for map-menu focus ${expectedId}: ${JSON.stringify(diagnostic)}`, + { cause: error } + ); + } +} + +async function focusMapMenuItem(page, expectedId) { + for (let attempt = 0; attempt < 24; attempt += 1) { + const keyboard = await page.evaluate( + () => window.__HEROS_DEBUG__?.battle()?.mapMenuKeyboard + ); + assertMapMenuFocus(keyboard); + if (mapMenuItemId(keyboard.focusedItem) === expectedId) { + return; + } + const target = keyboard.items.find( + (item) => mapMenuItemId(item) === expectedId + ); + assert( + target?.available === true, + `Expected ${expectedId} to be keyboard-selectable in the map menu: ${JSON.stringify(keyboard)}` + ); + await page.keyboard.press('Tab'); + await page.waitForTimeout(20); + } + throw new Error(`Could not focus map-menu item ${expectedId}.`); +} + +function assertMapMenuFocus(keyboard, expectedId) { + const focusedId = mapMenuItemId(keyboard?.focusedItem); + const matchingItem = keyboard?.items?.find( + (item) => mapMenuItemId(item) === focusedId + ); + const guidanceText = keyboard?.guidance?.text ?? ''; + assert( + keyboard?.visible === true && + keyboard.bounds && + boundsInsideViewport(keyboard.bounds) && + Array.isArray(keyboard.items) && + keyboard.items.length >= 4 && + keyboard.items.every( + (item) => + typeof mapMenuItemId(item) === 'string' && + typeof item.available === 'boolean' && + item.bounds && + boundsInside(item.bounds, keyboard.bounds) && + boundsInsideViewport(item.bounds) + ) && + keyboard.items.some(({ available }) => available === false) && + keyboard.focusedItem?.available === true && + matchingItem?.focused === true && + keyboard.items.filter(({ focused }) => focused).length === 1 && + keyboard.focusedItem.bounds && + boundsInsideViewport(keyboard.focusedItem.bounds) && + (!expectedId || focusedId === expectedId) && + guidanceText.includes('M') && + guidanceText.includes('Tab') && + guidanceText.includes('Enter') && + guidanceText.includes('Esc') && + keyboard.guidance?.bounds && + boundsInsideViewport(keyboard.guidance.bounds), + `Expected a visible map menu with one enabled keyboard focus, desktop-safe bounds, and M/Tab/Enter/Esc guidance${expectedId ? ` on ${expectedId}` : ''}: ${JSON.stringify(keyboard)}` + ); +} + +function mapMenuItemId(item) { + return item?.id ?? item?.action ?? null; +} + async function focusBattleCursorAt( page, expectedTile, diff --git a/scripts/verify-first-battle-camaraderie-memory.mjs b/scripts/verify-first-battle-camaraderie-memory.mjs index 0629445..8e17fe6 100644 --- a/scripts/verify-first-battle-camaraderie-memory.mjs +++ b/scripts/verify-first-battle-camaraderie-memory.mjs @@ -40,11 +40,12 @@ try { verifyDominantSelection(memoryModule); verifyMemoryAndBonus(memoryModule); + verifyCampGuidanceAndSortieGuard(memoryModule); verifyCampaignRoundTrip(memoryModule, campaignModule, battleScenarios); verifyBattleSaveRoundTrip(battleSaveModule); console.log( - 'First-battle camaraderie memory verification passed (selection, campaign settlement, legacy battle save, and +5%p gate).' + 'First-battle camaraderie memory verification passed (selection, camp guidance, one-warning sortie confirmation, campaign settlement, legacy battle save, and +5%p gate).' ); } finally { await server.close(); @@ -202,6 +203,206 @@ function verifyMemoryAndBonus(memoryModule) { ); } +function verifyCampGuidanceAndSortieGuard(memoryModule) { + const snapshot = { + version: 1, + bondId: 'liu-bei__zhang-fei', + unitIds: ['liu-bei', 'zhang-fei'], + attempts: 1, + successes: 1, + additionalDamage: 7 + }; + const baseCampaign = { + battleHistory: { + [memoryModule.firstBattleCamaraderieSourceBattleId]: { + battleId: memoryModule.firstBattleCamaraderieSourceBattleId, + outcome: 'victory', + units: [ + { unitId: 'liu-bei', name: '유비' }, + { unitId: 'zhang-fei', name: '장비' } + ], + sortieCooperation: snapshot + } + }, + completedCampDialogues: [] + }; + const incompleteMemory = + memoryModule.resolveFirstBattleCamaraderieMemory(baseCampaign); + const completedMemory = + memoryModule.resolveFirstBattleCamaraderieMemory({ + ...baseCampaign, + completedCampDialogues: [ + memoryModule.firstBattleCamaraderieDialogueId + ] + }); + const targetBattleId = + memoryModule.firstBattleCamaraderieTargetBattleId; + + const guidanceCases = [ + { + label: '기억 없음', + options: { + memory: undefined, + nextBattleId: targetBattleId, + selectedCoreBondId: undefined + }, + expected: { + kind: 'sortie', + label: '출진 준비', + targetId: null + } + }, + { + label: '기억 미완료', + options: { + memory: incompleteMemory, + nextBattleId: targetBattleId, + selectedCoreBondId: undefined + }, + expected: { + kind: 'dialogue', + label: '전우 기억 되짚기', + targetId: memoryModule.firstBattleCamaraderieDialogueId + } + }, + { + label: '기억 완료 + 핵심조 미지정', + options: { + memory: completedMemory, + nextBattleId: targetBattleId, + selectedCoreBondId: undefined + }, + expected: { + kind: 'sortie', + label: '전우 조 편성하기', + targetId: null + } + }, + { + label: '기억 완료 + 정확한 핵심조 지정', + options: { + memory: completedMemory, + nextBattleId: targetBattleId, + selectedCoreBondId: snapshot.bondId + }, + expected: { + kind: 'sortie', + label: '출진 준비', + targetId: null + } + } + ]; + guidanceCases.forEach((testCase) => { + const actual = + memoryModule.resolveFirstCampCamaraderieGuidedAction( + testCase.options + ); + assert( + JSON.stringify(actual) === JSON.stringify(testCase.expected), + `${testCase.label}: 안내 동작이 예상과 다릅니다. ${JSON.stringify(actual)}` + ); + }); + + const commonGuardOptions = { + nextBattleId: targetBattleId, + selectedUnitIds: ['zhang-fei', 'guan-yu', 'liu-bei'] + }; + const noMemoryGuard = + memoryModule.resolveFirstBattleCamaraderieSortieGuard({ + ...commonGuardOptions, + memory: undefined + }); + assert( + noMemoryGuard.applies === false && + noMemoryGuard.shouldWarn === false && + noMemoryGuard.confirmedByRepeat === false, + `기억 없음: 첫 출진을 막으면 안 됩니다. ${JSON.stringify(noMemoryGuard)}` + ); + + const incompleteFirst = + memoryModule.resolveFirstBattleCamaraderieSortieGuard({ + ...commonGuardOptions, + memory: incompleteMemory + }); + const incompleteSecond = + memoryModule.resolveFirstBattleCamaraderieSortieGuard({ + ...commonGuardOptions, + memory: incompleteMemory, + selectedUnitIds: ['liu-bei', 'guan-yu', 'zhang-fei'], + previousSignature: incompleteFirst.signature ?? undefined + }); + assert( + incompleteFirst.applies === true && + incompleteFirst.shouldWarn === true && + incompleteFirst.notice.includes('전우 기억 미완료') && + incompleteFirst.notice.includes('유비↔장비') && + incompleteFirst.notice.includes('+5%p') && + incompleteFirst.notice.includes('출진을 한 번 더') && + incompleteSecond.applies === true && + incompleteSecond.shouldWarn === false && + incompleteSecond.confirmedByRepeat === true && + incompleteSecond.signature === incompleteFirst.signature, + `기억 미완료: 첫 클릭 경고 뒤 같은 조건의 둘째 클릭은 통과해야 합니다. ${JSON.stringify({ + first: incompleteFirst, + second: incompleteSecond + })}` + ); + + const unselectedFirst = + memoryModule.resolveFirstBattleCamaraderieSortieGuard({ + ...commonGuardOptions, + memory: completedMemory + }); + const unselectedSecond = + memoryModule.resolveFirstBattleCamaraderieSortieGuard({ + ...commonGuardOptions, + memory: completedMemory, + previousSignature: unselectedFirst.signature ?? undefined + }); + assert( + unselectedFirst.applies === true && + unselectedFirst.shouldWarn === true && + unselectedFirst.notice.includes('전우 기억 미지정') && + unselectedFirst.notice.includes('유비↔장비') && + unselectedFirst.notice.includes('+5%p') && + unselectedFirst.notice.includes('출진을 한 번 더') && + unselectedSecond.applies === true && + unselectedSecond.shouldWarn === false && + unselectedSecond.confirmedByRepeat === true && + unselectedSecond.signature === unselectedFirst.signature, + `기억 완료 + 핵심조 미지정: 첫 클릭 경고 뒤 같은 조건의 둘째 클릭은 통과해야 합니다. ${JSON.stringify({ + first: unselectedFirst, + second: unselectedSecond + })}` + ); + + const exactPairGuard = + memoryModule.resolveFirstBattleCamaraderieSortieGuard({ + ...commonGuardOptions, + memory: completedMemory, + selectedCoreBondId: snapshot.bondId + }); + assert( + exactPairGuard.applies === false && + exactPairGuard.shouldWarn === false && + exactPairGuard.confirmedByRepeat === false, + `기억 완료 + 정확한 핵심조 지정: 첫 출진을 막으면 안 됩니다. ${JSON.stringify(exactPairGuard)}` + ); + + const changedRosterGuard = + memoryModule.resolveFirstBattleCamaraderieSortieGuard({ + ...commonGuardOptions, + memory: completedMemory, + selectedUnitIds: ['liu-bei', 'guan-yu'], + previousSignature: unselectedFirst.signature ?? undefined + }); + assert( + changedRosterGuard.shouldWarn === true && + changedRosterGuard.signature !== unselectedFirst.signature, + `첫 경고 뒤 편성이 달라지면 오래된 확인을 재사용하면 안 됩니다. ${JSON.stringify(changedRosterGuard)}` + ); +} + function verifyCampaignRoundTrip( memoryModule, campaignModule, diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index bc4cd24..2e297ee 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -3372,28 +3372,26 @@ try { completedFirstVictoryDialogueProbe.state?.campTabs?.find( (tab) => tab.id === 'dialogue' )?.newBadgeVisible === true && - completedFirstVictoryDialogueProbe.state?.sortieCommand?.label === '출진 준비' && - completedFirstVictoryDialogueProbe.state?.sortieCommand?.guidedKind === 'sortie' && - completedFirstVictoryDialogueProbe.state?.sortieCommand?.targetId === null && - completedFirstVictoryDialogueProbe.state?.sortieCommand?.bypass === null && + completedFirstVictoryDialogueProbe.state?.sortieCommand?.label === '전우 기억 되짚기' && + completedFirstVictoryDialogueProbe.state?.sortieCommand?.guidedKind === 'dialogue' && + completedFirstVictoryDialogueProbe.state?.sortieCommand?.targetId === + 'first-battle-camaraderie-memory' && + completedFirstVictoryDialogueProbe.state?.sortieCommand?.bypass?.label === '바로 출진' && + completedFirstVictoryDialogueProbe.state?.sortieCommand?.bypass?.interactive === true && completedFirstVictoryDialogueProbe.remainingRowMarkerCount === 0 && firstVictoryDialoguePersisted(completedFirstVictoryDialogueSave.current) && firstVictoryDialoguePersisted(completedFirstVictoryDialogueSave.slot1), - `Expected the first-victory choice to persist while the separate camaraderie memory keeps the dialogue NEW state pending: ${JSON.stringify({ + `Expected the first-victory choice to persist while the primary command guides the pending, next-battle camaraderie memory without removing the direct-sortie bypass: ${JSON.stringify({ probe: completedFirstVictoryDialogueProbe, save: completedFirstVictoryDialogueSave, choiceId: firstVictoryChoice.id })}` ); - const firstBattleCamaraderieDialogueRow = await readCampDialogueControlPoint( + await clickSceneBounds( page, - 'row', - 'first-battle-camaraderie-memory' - ); - await page.mouse.click( - firstBattleCamaraderieDialogueRow.x, - firstBattleCamaraderieDialogueRow.y + 'CampScene', + completedFirstVictoryDialogueProbe.state.sortieCommand.bounds ); await page.waitForFunction( () => ( @@ -3542,6 +3540,10 @@ try { completedFirstBattleCamaraderieState?.campTabs?.find( (tab) => tab.id === 'dialogue' )?.newBadgeVisible === false && + completedFirstBattleCamaraderieState?.sortieCommand?.label === '전우 조 편성하기' && + completedFirstBattleCamaraderieState?.sortieCommand?.guidedKind === 'sortie' && + completedFirstBattleCamaraderieState?.sortieCommand?.targetId === null && + completedFirstBattleCamaraderieState?.sortieCommand?.bypass === null && firstBattleCamaraderiePersisted(completedFirstBattleCamaraderieSave.current) && firstBattleCamaraderiePersisted(completedFirstBattleCamaraderieSave.slot1), `Expected the chosen camaraderie memory to persist in current/slot saves, unlock +5%p, and still avoid auto-selection: ${JSON.stringify({ @@ -4759,6 +4761,30 @@ try { undefined, { timeout: 30000 } ); + const unselectedCamaraderieLaunchWarning = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + if (!scene || typeof scene.startVictoryStory !== 'function') { + return { ready: false }; + } + scene.startVictoryStory(); + return { + ready: true, + navigationPending: scene.navigationPending ?? false, + notice: (scene.dialogueObjects ?? []) + .filter((object) => object?.type === 'Text' && object.active) + .map((object) => object.text) + .join('\n') + }; + }); + assert( + unselectedCamaraderieLaunchWarning.ready === true && + unselectedCamaraderieLaunchWarning.navigationPending === false && + unselectedCamaraderieLaunchWarning.notice.includes('전우 기억 미지정') && + unselectedCamaraderieLaunchWarning.notice.includes('유비↔장비') && + unselectedCamaraderieLaunchWarning.notice.includes('+5%p') && + unselectedCamaraderieLaunchWarning.notice.includes('출진을 한 번 더'), + `Expected the first launch attempt without the remembered core pair to warn once instead of silently forfeiting its next-battle bonus: ${JSON.stringify(unselectedCamaraderieLaunchWarning)}` + ); const firstSortiePursuitSaveBefore = await readCampaignSave(page); const firstSortiePursuitMutation = await page.evaluate(() => { @@ -10678,10 +10704,19 @@ async function openCampSortiePrep(page) { const rewardVisible = await page.evaluate( () => window.__HEROS_DEBUG__?.camp?.()?.victoryRewardAcknowledgement?.visible === true ); - const point = rewardVisible - ? await readCampVictoryRewardControlPoint(page, 'sortie') - : await readCampStaticTextControlPoint(page, '출진 준비'); - await page.mouse.click(point.x, point.y); + if (rewardVisible) { + const point = await readCampVictoryRewardControlPoint(page, 'sortie'); + await page.mouse.click(point.x, point.y); + } else { + const command = await page.evaluate( + () => window.__HEROS_DEBUG__?.camp?.()?.sortieCommand ?? null + ); + assert( + command?.interactive === true && isFiniteBounds(command.bounds), + `Expected a visible interactive camp sortie command: ${JSON.stringify(command)}` + ); + await clickSceneBounds(page, 'CampScene', command.bounds); + } await waitForSortiePrep(page); } diff --git a/src/game/data/firstBattleCamaraderieMemory.ts b/src/game/data/firstBattleCamaraderieMemory.ts index c860a5e..267cc35 100644 --- a/src/game/data/firstBattleCamaraderieMemory.ts +++ b/src/game/data/firstBattleCamaraderieMemory.ts @@ -53,6 +53,41 @@ export type FirstBattleCamaraderieMemory = { dialogue: FirstBattleCamaraderieDialogue; }; +export type FirstCampCamaraderieGuidedAction = + | { + kind: 'dialogue'; + label: '전우 기억 되짚기'; + targetId: typeof firstBattleCamaraderieDialogueId; + } + | { + kind: 'sortie'; + label: '전우 조 편성하기' | '출진 준비'; + targetId: null; + }; + +export type FirstBattleCamaraderieSortieGuard = + | { + applies: false; + shouldWarn: false; + confirmedByRepeat: false; + signature: null; + notice: null; + } + | { + applies: true; + shouldWarn: true; + confirmedByRepeat: false; + signature: string; + notice: string; + } + | { + applies: true; + shouldWarn: false; + confirmedByRepeat: true; + signature: string; + notice: null; + }; + export type FirstBattleCamaraderieCampaignState = { battleHistory?: Partial>; completedCampDialogues?: readonly string[]; @@ -183,6 +218,90 @@ export function firstBattleCamaraderieBonusFor(options: { : 0; } +export function resolveFirstCampCamaraderieGuidedAction(options: { + memory?: FirstBattleCamaraderieMemory; + nextBattleId?: string; + selectedCoreBondId?: string; +}): FirstCampCamaraderieGuidedAction { + const { memory } = options; + if (memory && !memory.completed) { + return { + kind: 'dialogue', + label: '전우 기억 되짚기', + targetId: memory.dialogue.id + }; + } + if ( + memory?.completed && + options.nextBattleId === memory.targetBattleId && + options.selectedCoreBondId !== memory.bondId + ) { + return { + kind: 'sortie', + label: '전우 조 편성하기', + targetId: null + }; + } + return { + kind: 'sortie', + label: '출진 준비', + targetId: null + }; +} + +export function resolveFirstBattleCamaraderieSortieGuard(options: { + memory?: FirstBattleCamaraderieMemory; + nextBattleId?: string; + selectedCoreBondId?: string; + selectedUnitIds?: readonly string[]; + previousSignature?: string; +}): FirstBattleCamaraderieSortieGuard { + const { memory } = options; + if ( + !memory || + options.nextBattleId !== memory.targetBattleId || + (memory.completed && options.selectedCoreBondId === memory.bondId) + ) { + return { + applies: false, + shouldWarn: false, + confirmedByRepeat: false, + signature: null, + notice: null + }; + } + + const selectedUnitsSignature = [...(options.selectedUnitIds ?? [])] + .sort((left, right) => left.localeCompare(right)) + .join(','); + const signature = [ + options.nextBattleId, + memory.completed ? 'remembered' : 'unreviewed', + options.selectedCoreBondId ?? 'none', + selectedUnitsSignature + ].join(':'); + if (options.previousSignature === signature) { + return { + applies: true, + shouldWarn: false, + confirmedByRepeat: true, + signature, + notice: null + }; + } + + const pairLabel = memory.unitNames.join('↔'); + return { + applies: true, + shouldWarn: true, + confirmedByRepeat: false, + signature, + notice: memory.completed + ? `전우 기억 미지정 · ${pairLabel}를 핵심 공명조로 지정해야 이번 추격에서 +${memory.bonusRate}%p가 적용됩니다. 보너스 없이 출진하려면 출진을 한 번 더 누르세요.` + : `전우 기억 미완료 · ${pairLabel}의 회고를 마쳐야 이번 추격에서 핵심 공명 +${memory.bonusRate}%p를 사용할 수 있습니다. 회고 없이 출진하려면 출진을 한 번 더 누르세요.` + }; +} + function compareCooperationSnapshots( left: CampaignSortieCooperationSnapshot, right: CampaignSortieCooperationSnapshot diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 342df42..2a7a4b7 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -1557,7 +1557,7 @@ type BattleSoundCaptionSnapshot = { }; const battleSpeedOptions = ['normal', 'fast', 'very-fast'] as const; type BattleSpeed = (typeof battleSpeedOptions)[number]; -type MapMenuAction = 'endTurn' | 'threat' | 'save' | 'load' | 'roster' | 'bond' | 'situation' | 'speed' | 'presentation' | 'bgm' | 'close'; +type MapMenuAction = 'endTurn' | 'threat' | 'tutorial' | 'save' | 'load' | 'roster' | 'bond' | 'situation' | 'speed' | 'presentation' | 'bgm' | 'close'; type EnemyAiBehavior = 'aggressive' | 'guard' | 'hold'; type BattleOutcome = 'victory' | 'defeat'; type SaveSlotMode = 'save' | 'load'; @@ -1975,9 +1975,18 @@ type MapMenuLayout = { left: number; top: number; width: number; + height: number; buttonHeight: number; padding: number; actions: MapMenuAction[]; + guidanceBounds: HudBounds; +}; + +type MapMenuButtonView = { + action: MapMenuAction; + background: Phaser.GameObjects.Rectangle; + label: Phaser.GameObjects.Text; + available: boolean; }; type BattleObjectiveResult = { @@ -3726,6 +3735,7 @@ const factionLabels: Record = { const mapMenuLabels: Partial> = { endTurn: '턴 종료', threat: '위험 범위', + tutorial: '실전 안내', roster: '부대 일람', bond: '공명', situation: '전황', @@ -3885,6 +3895,13 @@ export class BattleScene extends Phaser.Scene { private deploymentNotice = '추천 배치는 이미 적용되어 있습니다. 필요하면 장수를 선택해 시작 구역 안에서 위치를 바꾸세요.'; private mapMenuObjects: Array = []; private mapMenuLayout?: MapMenuLayout; + private mapMenuButtons: MapMenuButtonView[] = []; + private mapMenuFocusedIndex = -1; + private mapMenuFocusMarker?: Phaser.GameObjects.Text; + private battleMenuButtonObjects: Phaser.GameObjects.GameObject[] = []; + private battleMenuButtonBounds?: HudBounds; + private battleMenuButtonBackground?: Phaser.GameObjects.Rectangle; + private battleMenuButtonText?: Phaser.GameObjects.Text; private battleSpeed: BattleSpeed = 'normal'; private combatPresentationMode: CombatPresentationMode = 'important'; private combatPresentationCount = { full: 0, map: 0 }; @@ -4120,6 +4137,13 @@ export class BattleScene extends Phaser.Scene { this.deploymentObjects = []; this.mapMenuObjects = []; this.mapMenuLayout = undefined; + this.mapMenuButtons = []; + this.mapMenuFocusedIndex = -1; + this.mapMenuFocusMarker = undefined; + this.battleMenuButtonObjects = []; + this.battleMenuButtonBounds = undefined; + this.battleMenuButtonBackground = undefined; + this.battleMenuButtonText = undefined; this.tacticalInitiativePanelRoot = undefined; this.tacticalInitiativePanelObjects = []; this.tacticalInitiativePanelBounds = undefined; @@ -4602,6 +4626,10 @@ export class BattleScene extends Phaser.Scene { if (this.tacticalInitiativePanelObjects.length > 0) { return; } + if (this.mapMenuObjects.length > 0) { + this.activateMapMenuFocusedAction(); + return; + } if (this.commandMenuObjects.length > 0) { this.activateCommandMenuFocusedAction(); return; @@ -4717,6 +4745,12 @@ export class BattleScene extends Phaser.Scene { } private handleBattleKeyDown(event: KeyboardEvent) { + if (this.handleFirstBattleTutorialShortcut(event)) { + return; + } + if (this.handleMapMenuToggleKey(event)) { + return; + } const repeatableNavigation = event.code === 'ArrowLeft' || event.code === 'ArrowRight' || @@ -4739,6 +4773,9 @@ export class BattleScene extends Phaser.Scene { if (this.saveSlotPanelObjects.length > 0) { return; } + if (this.handleMapMenuNavigationKey(event)) { + return; + } if (this.handleTacticalInitiativeKey(event)) { return; } @@ -6400,10 +6437,75 @@ export class BattleScene extends Phaser.Scene { wordWrap: { width: panelWidth - this.battleUiLength(48), useAdvancedWrap: true } }); this.renderBattleSpeedControl(); + this.renderBattleMenuButton(); this.updateObjectiveTracker(); this.drawMiniMap(); } + private renderBattleMenuButton() { + this.battleMenuButtonObjects.forEach((object) => object.destroy()); + this.battleMenuButtonObjects = []; + this.battleMenuButtonBounds = undefined; + this.battleMenuButtonBackground = undefined; + this.battleMenuButtonText = undefined; + + const { panelX, panelY, panelWidth } = this.layout; + const speedWidth = this.battleUiLength(112); + const width = this.battleUiLength(82); + const height = this.battleUiLength(28); + const gap = this.battleUiLength(8); + const left = panelX + panelWidth - this.battleUiLength(24) - speedWidth - gap - width; + const top = panelY + this.battleUiLength(74); + const button = this.add.rectangle(left, top, width, height, 0x17232e, 0.96); + button.setName('battle-menu-button'); + button.setOrigin(0); + button.setDepth(16); + button.setInteractive({ useHandCursor: true }); + + const text = this.add.text(left + width / 2, top + height / 2 - this.battleUiLength(1), '메뉴 M', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: this.battleUiFontSize(13), + color: '#f2e3bf', + fontStyle: '700' + }); + text.setOrigin(0.5); + text.setDepth(17); + text.setInteractive({ useHandCursor: true }); + + const onSelect = () => { + this.suppressNextLeftClick = true; + this.toggleMapMenuFromHud(); + }; + button.on('pointerover', () => button.setFillStyle(0x243746, 0.98)); + button.on('pointerout', () => this.updateBattleMenuButtonVisual()); + button.on('pointerdown', onSelect); + text.on('pointerover', () => button.setFillStyle(0x243746, 0.98)); + text.on('pointerout', () => this.updateBattleMenuButtonVisual()); + text.on('pointerdown', onSelect); + + this.battleMenuButtonObjects.push(button, text); + this.battleMenuButtonBounds = { x: left, y: top, width, height }; + this.battleMenuButtonBackground = button; + this.battleMenuButtonText = text; + this.updateBattleMenuButtonVisual(); + } + + private updateBattleMenuButtonVisual() { + const button = this.battleMenuButtonBackground; + const text = this.battleMenuButtonText; + if (!button?.active || !text?.active) { + return; + } + const open = this.mapMenuObjects.length > 0; + button.setFillStyle(open ? 0x30475a : 0x17232e, 0.96); + button.setStrokeStyle( + this.battleUiLength(open ? 2 : 1), + open ? palette.gold : 0x5a7588, + open ? 0.96 : 0.86 + ); + text.setColor(open ? '#fff2b8' : '#f2e3bf'); + } + private renderBattleSpeedControl() { this.battleSpeedControlObjects.forEach((object) => object.destroy()); this.battleSpeedControlObjects = []; @@ -19355,7 +19457,12 @@ export class BattleScene extends Phaser.Scene { this.phase === 'idle' && this.activeFaction === 'ally' && this.turnNumber === 1 && - this.battleEventObjects.length === 0 + this.battleEventObjects.length === 0 && + this.mapMenuObjects.length === 0 && + this.saveSlotPanelObjects.length === 0 && + this.turnPromptObjects.length === 0 && + this.tacticalInitiativePanelObjects.length === 0 && + this.tacticalCommandCutInObjects.length === 0 ) { this.startFirstBattleTutorial(); return; @@ -19370,7 +19477,7 @@ export class BattleScene extends Phaser.Scene { private startFirstBattleTutorial() { const path = this.createFirstBattleTutorialPath(); if (!path) { - return; + return false; } this.firstBattleTutorialPath = path; @@ -19382,6 +19489,7 @@ export class BattleScene extends Phaser.Scene { this.ensureFirstBattleTutorialPathVisible(); this.syncFirstBattleTutorialKeyboardCursor(); this.renderFirstBattleTutorial(); + return true; } private createFirstBattleTutorialPath(): FirstBattleTutorialPath | undefined { @@ -19669,7 +19777,7 @@ export class BattleScene extends Phaser.Scene { const feedback = this.trackFirstBattleTutorialObject(this.add.text( left + 22, top + 111, - this.firstBattleTutorialFeedback || '빛나는 표시를 따라 클릭/Enter로 조작하세요. · 우클릭/ESC는 이전 단계', + this.firstBattleTutorialFeedback || '빛나는 표시를 따라 클릭/Enter로 조작하세요. · 우클릭/ESC 이전 · K 건너뛰기', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', @@ -19690,7 +19798,7 @@ export class BattleScene extends Phaser.Scene { skip.setDepth(depth + 2); skip.setStrokeStyle(1, 0x7f8994, 0.82); skip.setInteractive({ useHandCursor: true }); - const skipText = this.trackFirstBattleTutorialObject(this.add.text(skipX + skipWidth / 2, skipY + skipHeight / 2, '건너뛰기', { + const skipText = this.trackFirstBattleTutorialObject(this.add.text(skipX + skipWidth / 2, skipY + skipHeight / 2, '건너뛰기 K', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color: '#d4dce6', @@ -19703,6 +19811,7 @@ export class BattleScene extends Phaser.Scene { this.suppressNextLeftClick = true; soundDirector.playSelect(); this.completeFirstBattleTutorial('skipped'); + this.renderSituationPanel('실전 안내를 건너뛰었습니다.\n메뉴 M · 실전 안내에서 다시 시작할 수 있습니다.'); }; skip.on('pointerdown', skipTutorial); skipText.on('pointerdown', skipTutorial); @@ -19836,6 +19945,25 @@ export class BattleScene extends Phaser.Scene { completeCampaignTutorial(firstBattleTutorialId); } + private handleFirstBattleTutorialShortcut(event: KeyboardEvent) { + if ( + !this.isFirstBattleTutorialActive() || + event.code !== 'KeyK' || + event.repeat || + event.ctrlKey || + event.altKey || + event.metaKey + ) { + return false; + } + + event.preventDefault(); + soundDirector.playSelect(); + this.completeFirstBattleTutorial('skipped'); + this.renderSituationPanel('실전 안내를 건너뛰었습니다.\n메뉴 M · 실전 안내에서 다시 시작할 수 있습니다.'); + return true; + } + private acceptFirstBattleTutorialCommand(command: BattleCommand) { if (!this.isFirstBattleTutorialActive()) { return true; @@ -21595,8 +21723,194 @@ export class BattleScene extends Phaser.Scene { return this.isInBounds(x, y) ? { x, y } : undefined; } + private mapMenuOpenBlockedReason() { + if (this.battleOutcome) { + return '전투가 끝난 뒤에는 지휘 메뉴를 열 수 없습니다.'; + } + if (this.isFirstBattleTutorialActive()) { + return '실전 안내가 진행 중입니다. 빛나는 순서를 따르거나 K로 건너뛴 뒤 메뉴를 여세요.'; + } + if (this.phase === 'deployment') { + return '배치를 확정한 뒤 지휘 메뉴를 열 수 있습니다.'; + } + if (this.activeFaction !== 'ally') { + return '적군 행동 중에는 지휘 메뉴를 열 수 없습니다.'; + } + if (this.phase !== 'idle') { + return '현재 명령을 마치거나 취소한 뒤 지휘 메뉴를 여세요.'; + } + if ( + this.battleEventObjects.length > 0 || + this.tacticalInitiativePanelObjects.length > 0 || + this.tacticalCommandCutInObjects.length > 0 || + this.saveSlotPanelObjects.length > 0 || + this.turnPromptObjects.length > 0 + ) { + return '열려 있는 안내나 확인 창을 먼저 닫아 주세요.'; + } + return undefined; + } + + private showMapMenuUnavailableFeedback(reason: string) { + if (this.isFirstBattleTutorialActive()) { + this.showFirstBattleTutorialFeedback(reason); + return; + } + if (this.phase === 'deployment') { + this.deploymentNotice = reason; + this.renderDeploymentPanel(); + return; + } + this.renderSituationPanel(`메뉴를 열 수 없습니다.\n${reason}`); + } + + private toggleMapMenuFromHud() { + if (this.mapMenuObjects.length > 0) { + soundDirector.playSelect(); + this.hideMapMenu(); + return; + } + + const blockedReason = this.mapMenuOpenBlockedReason(); + if (blockedReason) { + soundDirector.playSelect(); + this.showMapMenuUnavailableFeedback(blockedReason); + return; + } + + soundDirector.playSelect(); + this.showMapMenu( + this.layout.gridX + this.layout.gridWidth - this.battleUiLength(28), + this.layout.gridY + this.layout.gridHeight / 2 + ); + } + + private handleMapMenuToggleKey(event: KeyboardEvent) { + if ( + event.code !== 'KeyM' || + event.repeat || + event.ctrlKey || + event.altKey || + event.metaKey + ) { + return false; + } + + event.preventDefault(); + this.toggleMapMenuFromHud(); + return true; + } + + private handleMapMenuNavigationKey(event: KeyboardEvent) { + if (this.mapMenuObjects.length === 0) { + return false; + } + + if (event.code === 'ArrowUp' || (event.code === 'Tab' && event.shiftKey)) { + event.preventDefault(); + this.moveMapMenuFocus(-1); + return true; + } + if (event.code === 'ArrowDown' || event.code === 'Tab') { + event.preventDefault(); + this.moveMapMenuFocus(1); + return true; + } + + return true; + } + + private moveMapMenuFocus(delta: -1 | 1) { + if (this.mapMenuButtons.length === 0) { + return; + } + + const start = this.mapMenuFocusedIndex >= 0 + ? this.mapMenuFocusedIndex + : delta > 0 + ? -1 + : 0; + for (let offset = 1; offset <= this.mapMenuButtons.length; offset += 1) { + const index = Phaser.Math.Wrap( + start + delta * offset, + 0, + this.mapMenuButtons.length + ); + if (this.mapMenuButtons[index]?.available) { + this.setMapMenuFocus(index); + return; + } + } + } + + private setMapMenuFocus(index: number) { + const entry = this.mapMenuButtons[index]; + if (!entry?.available) { + return false; + } + this.mapMenuFocusedIndex = index; + this.updateMapMenuButtonVisuals(); + return true; + } + + private focusFirstAvailableMapMenuItem() { + const index = this.mapMenuButtons.findIndex((entry) => entry.available); + if (index >= 0) { + this.setMapMenuFocus(index); + } + } + + private updateMapMenuButtonVisuals() { + this.mapMenuButtons.forEach((entry, index) => { + const focused = index === this.mapMenuFocusedIndex && entry.available; + entry.background.setFillStyle( + entry.available + ? focused + ? 0x30475a + : 0x1a2630 + : 0x121820, + entry.available ? 0.96 : 0.72 + ); + entry.background.setStrokeStyle( + focused ? 3 : 1, + focused + ? 0xffec9d + : entry.action === 'endTurn' + ? 0xd8b15f + : 0x5a7588, + entry.available ? (focused ? 1 : 0.68) : 0.32 + ); + entry.label.setColor( + entry.available + ? focused + ? '#fff4c7' + : '#f2e3bf' + : '#737b84' + ); + }); + + const focused = this.mapMenuButtons[this.mapMenuFocusedIndex]; + if (!this.mapMenuFocusMarker?.active || !focused?.available) { + this.mapMenuFocusMarker?.setVisible(false); + return; + } + const bounds = focused.background.getBounds(); + this.mapMenuFocusMarker + .setPosition(bounds.x + 9, bounds.y + bounds.height / 2 - 1) + .setVisible(true); + } + + private activateMapMenuFocusedAction() { + const entry = this.mapMenuButtons[this.mapMenuFocusedIndex]; + if (!entry?.available) { + this.focusFirstAvailableMapMenuItem(); + return; + } + this.runMapMenuAction(entry.action); + } + private showMapMenu(pointerX: number, pointerY: number) { - if (this.phase === 'deployment' || this.phase === 'command' || this.phase === 'targeting' || this.phase === 'animating') { + if (this.mapMenuOpenBlockedReason()) { return; } @@ -21608,11 +21922,12 @@ export class BattleScene extends Phaser.Scene { this.phase = 'idle'; this.refreshUnitLegibilityStyles(); - const actions: MapMenuAction[] = ['endTurn', 'threat', 'save', 'load', 'roster', 'bond', 'situation', 'speed', 'presentation', 'bgm', 'close']; - const menuWidth = 148; + const actions: MapMenuAction[] = ['endTurn', 'threat', 'tutorial', 'save', 'load', 'roster', 'bond', 'situation', 'speed', 'presentation', 'bgm', 'close']; + const menuWidth = 204; const buttonHeight = 34; const padding = 8; - const menuHeight = padding * 2 + actions.length * buttonHeight; + const guidanceHeight = 38; + const menuHeight = padding * 2 + actions.length * buttonHeight + guidanceHeight; const mapLeft = this.layout.mapX + 16; const mapRight = this.layout.mapX + this.layout.mapWidth - 16; const pointerGap = 12; @@ -21621,9 +21936,25 @@ export class BattleScene extends Phaser.Scene { : pointerX - menuWidth - pointerGap; const left = Phaser.Math.Clamp(preferredLeft, mapLeft, mapRight - menuWidth); const top = Phaser.Math.Clamp(pointerY - menuHeight / 2, this.layout.mapY + 16, this.layout.mapY + this.layout.mapHeight - menuHeight - 16); - this.mapMenuLayout = { left, top, width: menuWidth, buttonHeight, padding, actions }; + const guidanceBounds = { + x: left + 8, + y: top + padding + actions.length * buttonHeight, + width: menuWidth - 16, + height: guidanceHeight + }; + this.mapMenuLayout = { + left, + top, + width: menuWidth, + height: menuHeight, + buttonHeight, + padding, + actions, + guidanceBounds + }; const panel = this.add.rectangle(left, top, menuWidth, menuHeight, 0x101821, 0.97); + panel.setName('map-menu-panel'); panel.setOrigin(0); panel.setDepth(20); panel.setStrokeStyle(2, palette.gold, 0.86); @@ -21634,6 +21965,7 @@ export class BattleScene extends Phaser.Scene { const y = top + padding + index * buttonHeight; const disabled = this.isMapMenuActionDisabled(action); const bg = this.add.rectangle(left + 8, y, menuWidth - 16, buttonHeight - 4, disabled ? 0x121820 : 0x1a2630, disabled ? 0.72 : 0.94); + bg.setName(`map-menu-action-${action}`); bg.setOrigin(0); bg.setDepth(21); bg.setStrokeStyle(1, action === 'endTurn' ? 0xd8b15f : 0x5a7588, disabled ? 0.32 : 0.68); @@ -21648,27 +21980,68 @@ export class BattleScene extends Phaser.Scene { text.setOrigin(0.5); text.setDepth(22); this.mapMenuObjects.push(text); + this.mapMenuButtons.push({ + action, + background: bg, + label: text, + available: !disabled + }); if (!disabled) { + const focus = () => this.setMapMenuFocus(index); const run = () => { this.suppressNextLeftClick = true; this.runMapMenuAction(action); }; bg.setInteractive({ useHandCursor: true }); - bg.on('pointerover', () => bg.setFillStyle(0x283947, 0.98)); - bg.on('pointerout', () => bg.setFillStyle(0x1a2630, 0.94)); + bg.on('pointerover', focus); + bg.on('pointerout', () => this.updateMapMenuButtonVisuals()); bg.on('pointerdown', run); text.setInteractive({ useHandCursor: true }); + text.on('pointerover', focus); + text.on('pointerout', () => this.updateMapMenuButtonVisuals()); text.on('pointerdown', run); } }); + + const guidance = this.add.text( + guidanceBounds.x + guidanceBounds.width / 2, + guidanceBounds.y + guidanceBounds.height / 2 - 1, + 'M 닫기 · ↑↓/Tab 이동\nEnter 선택 · Esc 닫기', + { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '12px', + color: '#aab8c4', + fontStyle: '700', + align: 'center' + } + ); + guidance.setName('map-menu-keyboard-guidance'); + guidance.setOrigin(0.5); + guidance.setDepth(22); + this.mapMenuObjects.push(guidance); + + const focusMarker = this.add.text(0, 0, '▶', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '14px', + color: '#ffec9d', + fontStyle: '700' + }); + focusMarker.setName('map-menu-focus-marker'); + focusMarker.setOrigin(0, 0.5); + focusMarker.setDepth(23); + focusMarker.setVisible(false); + this.mapMenuObjects.push(focusMarker); + this.mapMenuFocusMarker = focusMarker; + this.focusFirstAvailableMapMenuItem(); + this.updateBattleMenuButtonVisual(); } private runMapMenuAction(action: MapMenuAction) { this.hideMapMenu(); soundDirector.playSelect(); - if (action === 'threat' || action === 'roster' || action === 'bond' || action === 'situation') { + if (action === 'threat' || action === 'tutorial' || action === 'roster' || action === 'bond' || action === 'situation') { this.hideBattleEventBanner(); } @@ -21680,6 +22053,10 @@ export class BattleScene extends Phaser.Scene { this.activateSideQuickTab('threat', false); return; } + if (action === 'tutorial') { + this.startFirstBattleTutorialOnDemand(); + return; + } if (action === 'save') { this.showSaveSlotPanel('save'); return; @@ -21717,6 +22094,20 @@ export class BattleScene extends Phaser.Scene { } } + private startFirstBattleTutorialOnDemand() { + if (this.activeFaction !== 'ally' || this.phase !== 'idle') { + this.renderSituationPanel( + '실전 안내를 시작할 수 없습니다.\n아군 지휘 차례에 진행 중인 명령을 마친 뒤 다시 시도하세요.' + ); + return; + } + if (!this.startFirstBattleTutorial()) { + this.renderSituationPanel( + '지금은 4단계 실전 안내 경로를 만들 수 없습니다.\n행동 가능한 아군이 적에게 접근할 수 있는 다음 아군 턴에 다시 시도하세요.' + ); + } + } + private showManualTurnEndPrompt() { const unactedAllies = this.actionableAllies(); if (unactedAllies.length === 0) { @@ -21753,6 +22144,10 @@ export class BattleScene extends Phaser.Scene { this.mapMenuObjects.forEach((object) => object.destroy()); this.mapMenuObjects = []; this.mapMenuLayout = undefined; + this.mapMenuButtons = []; + this.mapMenuFocusedIndex = -1; + this.mapMenuFocusMarker = undefined; + this.updateBattleMenuButtonVisual(); } private mapMenuActionAt(x: number, y: number) { @@ -32017,6 +32412,81 @@ export class BattleScene extends Phaser.Scene { }; } + private mapMenuKeyboardDebugState() { + const focused = this.mapMenuButtons[this.mapMenuFocusedIndex]; + const guidance = this.mapMenuObjects.find( + (object): object is Phaser.GameObjects.Text => + object instanceof Phaser.GameObjects.Text && + object.active && + object.visible && + object.name === 'map-menu-keyboard-guidance' + ); + const toBounds = (object?: Phaser.GameObjects.Rectangle | Phaser.GameObjects.Text) => { + const bounds = object?.active ? object.getBounds() : undefined; + return bounds + ? { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height } + : null; + }; + const markerBounds = + this.mapMenuFocusMarker?.active && this.mapMenuFocusMarker.visible + ? this.mapMenuFocusMarker.getBounds() + : undefined; + + return { + visible: this.mapMenuObjects.length > 0 && Boolean(this.mapMenuLayout), + bounds: this.mapMenuLayout + ? { + x: this.mapMenuLayout.left, + y: this.mapMenuLayout.top, + width: this.mapMenuLayout.width, + height: this.mapMenuLayout.height + } + : null, + focusedIndex: this.mapMenuFocusedIndex, + guidance: guidance + ? { + text: guidance.text, + bounds: toBounds(guidance) + } + : null, + focusedItem: focused + ? { + id: focused.action, + action: focused.action, + label: focused.label.text, + available: focused.available, + bounds: toBounds(focused.background), + lineWidth: focused.background.lineWidth + } + : null, + items: this.mapMenuButtons.map((entry, index) => ({ + id: entry.action, + action: entry.action, + label: entry.label.text, + available: entry.available, + focused: index === this.mapMenuFocusedIndex, + bounds: toBounds(entry.background), + lineWidth: entry.background.lineWidth + })), + focusMarker: markerBounds + ? { + visible: true, + text: this.mapMenuFocusMarker?.text ?? '', + bounds: { + x: markerBounds.x, + y: markerBounds.y, + width: markerBounds.width, + height: markerBounds.height + } + } + : { + visible: false, + text: this.mapMenuFocusMarker?.text ?? '', + bounds: null + } + }; + } + private battleHudDebugState() { const miniMap = this.miniMapLayout; const selectedDot = this.selectedUnit ? this.miniMapUnitDots.get(this.selectedUnit.id) : undefined; @@ -32066,6 +32536,8 @@ export class BattleScene extends Phaser.Scene { const header = { titleBounds: this.gameObjectBoundsDebug(this.battleTitleText), turnBounds: this.gameObjectBoundsDebug(this.turnText), + menuBounds: this.battleMenuButtonBounds ? { ...this.battleMenuButtonBounds } : null, + menuLabel: this.battleMenuButtonText?.active ? this.battleMenuButtonText.text : null, speedBounds: this.gameObjectCollectionBoundsDebug(this.battleSpeedControlObjects), objectiveBounds: this.gameObjectBoundsDebug(this.objectiveTrackerText), defeatBounds: this.gameObjectBoundsDebug(this.objectiveTrackerSubText), @@ -32926,6 +33398,7 @@ export class BattleScene extends Phaser.Scene { return { id: firstBattleTutorialId, eligibleBattle: battleScenario.id === 'first-battle-zhuo-commandery', + onDemandAvailable: !this.battleOutcome, active: this.isFirstBattleTutorialActive(), completed: hasCompletedCampaignTutorial(firstBattleTutorialId), completion: this.firstBattleTutorialCompletion ?? null, @@ -32934,6 +33407,8 @@ export class BattleScene extends Phaser.Scene { totalSteps: 4, targetPreviewLocked: this.firstBattleTutorialTargetPreviewLocked, feedback: this.firstBattleTutorialFeedback, + skipShortcut: 'K', + skipAvailable: this.isFirstBattleTutorialActive(), path: path ? { unitId: path.unitId, @@ -33115,6 +33590,7 @@ export class BattleScene extends Phaser.Scene { commandMenuVisible: this.commandMenuObjects.length > 0, commandMenuKeyboard: this.commandMenuKeyboardDebugState(), mapMenuVisible: this.mapMenuObjects.length > 0, + mapMenuKeyboard: this.mapMenuKeyboardDebugState(), saveSlotPanelVisible: this.saveSlotPanelObjects.length > 0, saveSlotPanelMode: this.saveSlotPanelMode ?? null, resultVisible: this.resultObjects.length > 0, diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index 6be4741..599122b 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -31,6 +31,8 @@ import { import { firstBattleCamaraderieBonusFor, firstBattleCamaraderieCoreRateBonus, + resolveFirstBattleCamaraderieSortieGuard, + resolveFirstCampCamaraderieGuidedAction, resolveFirstBattleCamaraderieMemory, type FirstBattleCamaraderieMemory } from '../data/firstBattleCamaraderieMemory'; @@ -403,7 +405,7 @@ type FirstCampGuidedAction = } | { kind: 'sortie'; - label: '출진 준비'; + label: '전우 조 편성하기' | '출진 준비'; targetId: null; }; @@ -11506,6 +11508,7 @@ export class CampScene extends Phaser.Scene { private thirdCampPreparationCardViews: ThirdCampPreparationCardView[] = []; private thirdCampPreparationKeyboardIndex = 0; private thirdCampIncompatibleSortieConfirmSignature?: string; + private firstBattleCamaraderieSortieConfirmSignature?: string; private visitedTabs = new Set(); private selectedSortieUnitIds: string[] = []; private sortieFormationAssignments: SortieFormationAssignments = {}; @@ -11561,6 +11564,7 @@ export class CampScene extends Phaser.Scene { this.thirdCampPreparationBrowserOpen = false; this.thirdCampPreparationKeyboardIndex = 0; this.thirdCampIncompatibleSortieConfirmSignature = undefined; + this.firstBattleCamaraderieSortieConfirmSignature = undefined; if (data?.completedAftermathBattleId) { completeCampaignAftermath(data.completedAftermathBattleId); } @@ -13055,11 +13059,19 @@ export class CampScene extends Phaser.Scene { targetId: followup.targetDialogueId }; } - return { - kind: 'sortie', - label: '출진 준비', - targetId: null - }; + const camaraderieMemory = this.firstBattleCamaraderieMemory(); + const nextBattleId = this.currentSortieFlow().nextBattleId; + const sortieResonanceSelection = + this.campaign?.sortieResonanceSelection; + const selectedCoreBondId = + sortieResonanceSelection?.battleId === nextBattleId + ? sortieResonanceSelection?.bondId + : undefined; + return resolveFirstCampCamaraderieGuidedAction({ + memory: camaraderieMemory, + nextBattleId, + selectedCoreBondId + }); } private runFirstCampGuidedAction() { @@ -13069,12 +13081,14 @@ export class CampScene extends Phaser.Scene { return; } if (action.kind === 'visit') { + this.clearCampNotice(); this.activeTab = 'visit'; this.selectedVisitId = action.targetId; this.render(); this.startCampNavigation('CampVisitExplorationScene', { visitId: action.targetId }); return; } + this.clearCampNotice(); this.activeTab = 'dialogue'; this.selectedDialogueId = action.targetId; this.render(); @@ -13953,6 +13967,7 @@ export class CampScene extends Phaser.Scene { } private showRequestedSortiePrep() { + this.firstBattleCamaraderieSortieConfirmSignature = undefined; if (this.openSortieImprovementOnCreate) { this.prepareGuidedSortieImprovement(); } @@ -18453,6 +18468,7 @@ export class CampScene extends Phaser.Scene { this.persistSortieSelection(); const clearing = this.currentSortieResonanceBondId(scenario) === candidate.id; this.campaign = setCampaignSortieResonanceSelection(scenario.id, clearing ? undefined : candidate.id); + this.firstBattleCamaraderieSortieConfirmSignature = undefined; const guidedRecalculated = this.resetRecommendationAfterSortieStrategyChange(); const pairLabel = `${candidate.unitNames[0]}↔${candidate.unitNames[1]}`; this.sortiePlanFeedback = clearing @@ -21315,6 +21331,29 @@ export class CampScene extends Phaser.Scene { return; } + const sortieResonanceSelection = + this.campaign?.sortieResonanceSelection; + const selectedCamaraderieBondId = + sortieResonanceSelection?.battleId === flow.nextBattleId + ? sortieResonanceSelection?.bondId + : undefined; + const camaraderieSortieGuard = + resolveFirstBattleCamaraderieSortieGuard({ + memory: this.firstBattleCamaraderieMemory(), + nextBattleId: flow.nextBattleId, + selectedCoreBondId: selectedCamaraderieBondId, + selectedUnitIds: this.selectedSortieUnitIds, + previousSignature: + this.firstBattleCamaraderieSortieConfirmSignature + }); + if (camaraderieSortieGuard.shouldWarn) { + this.firstBattleCamaraderieSortieConfirmSignature = + camaraderieSortieGuard.signature; + this.showCampNotice(camaraderieSortieGuard.notice); + this.showSortiePrep(); + return; + } + const thirdCampSortie = this.thirdCampPreparationSortieState(); if ( @@ -21580,6 +21619,7 @@ export class CampScene extends Phaser.Scene { this.sortieCoreResonancePage = 0; this.thirdCampPreparationBrowserOpen = false; this.thirdCampIncompatibleSortieConfirmSignature = undefined; + this.firstBattleCamaraderieSortieConfirmSignature = undefined; } this.sortieComparisonPanelView = undefined; this.sortiePursuitPanelView = undefined;