feat: make guidance recoverable across camp and battle

This commit is contained in:
2026-07-28 05:57:42 +09:00
parent 495f98372c
commit 5f6490b2ae
7 changed files with 1300 additions and 44 deletions

View File

@@ -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 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): - [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): - [Into the Breach Design Postmortem PDF](https://media.gdcvault.com/gdc2019/presentations/Into%20the%20Breach%20Postmortem%20Final.pdf):

View File

@@ -94,6 +94,7 @@ try {
await installPointerInputRecorder(page); await installPointerInputRecorder(page);
let state = await readBattleState(page); let state = await readBattleState(page);
assertFirstBattleObjectiveDefinitions(state.objectiveDefinitions); assertFirstBattleObjectiveDefinitions(state.objectiveDefinitions);
assertBattleMenuDiscovery(state.battleHud?.header);
assertDeploymentKeyboardFocus(state.deploymentKeyboard, 'start'); assertDeploymentKeyboardFocus(state.deploymentKeyboard, 'start');
assertDeploymentKeyboardOrder(state.deploymentKeyboard); assertDeploymentKeyboardOrder(state.deploymentKeyboard);
@@ -162,12 +163,84 @@ try {
{ timeout: 30000 } { 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( await page.waitForFunction(
() => { () => {
const battle = window.__HEROS_DEBUG__?.battle(); const battle = window.__HEROS_DEBUG__?.battle();
return ( return (
battle?.phase === 'idle' && battle?.phase === 'idle' &&
battle.activeBattleEvent === null && battle.activeBattleEvent === null &&
battle.mapMenuKeyboard?.visible === false &&
battle.firstBattleTutorial?.active === true && battle.firstBattleTutorial?.active === true &&
battle.firstBattleTutorial.step === 'select-unit' battle.firstBattleTutorial.step === 'select-unit'
); );
@@ -177,6 +250,121 @@ try {
); );
state = await readBattleState(page); 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; const tutorialPath = state.firstBattleTutorial?.path;
assert( assert(
tutorialPath?.unitId && tutorialPath?.unitId &&
@@ -205,7 +393,8 @@ try {
return ( return (
battle?.phase === 'moving' && battle?.phase === 'moving' &&
battle.selectedUnitId === expectedUnitId && battle.selectedUnitId === expectedUnitId &&
battle.firstBattleTutorial?.step === 'move' battle.firstBattleTutorial?.step === 'move' &&
battle.firstBattleTutorial.stepNumber === 2
); );
}, },
tutorialPath.unitId tutorialPath.unitId
@@ -224,6 +413,7 @@ try {
battle.selectedUnitId === expectedUnitId && battle.selectedUnitId === expectedUnitId &&
battle.pendingMove?.unitId === expectedUnitId && battle.pendingMove?.unitId === expectedUnitId &&
battle.firstBattleTutorial?.step === 'attack-command' && battle.firstBattleTutorial?.step === 'attack-command' &&
battle.firstBattleTutorial.stepNumber === 3 &&
battle.commandMenuKeyboard?.visible === true battle.commandMenuKeyboard?.visible === true
); );
}, },
@@ -245,7 +435,8 @@ try {
return ( return (
battle?.phase === 'targeting' && battle?.phase === 'targeting' &&
battle.selectedUnitId === expectedUnitId && battle.selectedUnitId === expectedUnitId &&
battle.firstBattleTutorial?.step === 'target-preview' battle.firstBattleTutorial?.step === 'target-preview' &&
battle.firstBattleTutorial.stepNumber === 4
); );
}, },
tutorialPath.unitId tutorialPath.unitId
@@ -294,7 +485,8 @@ try {
return ( return (
battle?.actedUnitIds?.includes(unitId) && battle?.actedUnitIds?.includes(unitId) &&
battle.commandMenuKeyboard?.visible === false && battle.commandMenuKeyboard?.visible === false &&
battle.firstBattleTutorial?.active === false battle.firstBattleTutorial?.active === false &&
battle.firstBattleTutorial.completion === 'completed'
); );
}, },
tutorialPath.unitId, tutorialPath.unitId,
@@ -311,13 +503,16 @@ try {
commandMenu: state?.commandMenuKeyboard ?? null, commandMenu: state?.commandMenuKeyboard ?? null,
phase: state?.phase ?? null, phase: state?.phase ?? null,
tutorialActive: state?.firstBattleTutorial?.active ?? null, tutorialActive: state?.firstBattleTutorial?.active ?? null,
tutorialCompletion:
state?.firstBattleTutorial?.completion ?? null,
pointerInputCount: qa?.pointerInputCount ?? -1 pointerInputCount: qa?.pointerInputCount ?? -1
}; };
}, tutorialPath.unitId); }, tutorialPath.unitId);
assert( assert(
completed.acted && completed.acted &&
completed.commandMenu?.visible === false && 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)}` `Keyboard attack confirmation must complete exactly one first action: ${JSON.stringify(completed)}`
); );
assert( assert(
@@ -529,6 +724,7 @@ try {
console.log( console.log(
`Verified ${renderer} keyboard-only first-battle flow at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height} DPR1 under /heros_web/: ` + `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, ' + '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, ' + '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.' '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) { function assertDeploymentKeyboardFocus(keyboard, expectedId) {
const focusedItem = keyboard?.focusedItem; const focusedItem = keyboard?.focusedItem;
const matchingItem = keyboard?.items?.find(({ id }) => id === expectedId); 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( async function focusBattleCursorAt(
page, page,
expectedTile, expectedTile,

View File

@@ -40,11 +40,12 @@ try {
verifyDominantSelection(memoryModule); verifyDominantSelection(memoryModule);
verifyMemoryAndBonus(memoryModule); verifyMemoryAndBonus(memoryModule);
verifyCampGuidanceAndSortieGuard(memoryModule);
verifyCampaignRoundTrip(memoryModule, campaignModule, battleScenarios); verifyCampaignRoundTrip(memoryModule, campaignModule, battleScenarios);
verifyBattleSaveRoundTrip(battleSaveModule); verifyBattleSaveRoundTrip(battleSaveModule);
console.log( 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 { } finally {
await server.close(); 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( function verifyCampaignRoundTrip(
memoryModule, memoryModule,
campaignModule, campaignModule,

View File

@@ -3372,28 +3372,26 @@ try {
completedFirstVictoryDialogueProbe.state?.campTabs?.find( completedFirstVictoryDialogueProbe.state?.campTabs?.find(
(tab) => tab.id === 'dialogue' (tab) => tab.id === 'dialogue'
)?.newBadgeVisible === true && )?.newBadgeVisible === true &&
completedFirstVictoryDialogueProbe.state?.sortieCommand?.label === '출진 준비' && completedFirstVictoryDialogueProbe.state?.sortieCommand?.label === '전우 기억 되짚기' &&
completedFirstVictoryDialogueProbe.state?.sortieCommand?.guidedKind === 'sortie' && completedFirstVictoryDialogueProbe.state?.sortieCommand?.guidedKind === 'dialogue' &&
completedFirstVictoryDialogueProbe.state?.sortieCommand?.targetId === null && completedFirstVictoryDialogueProbe.state?.sortieCommand?.targetId ===
completedFirstVictoryDialogueProbe.state?.sortieCommand?.bypass === null && 'first-battle-camaraderie-memory' &&
completedFirstVictoryDialogueProbe.state?.sortieCommand?.bypass?.label === '바로 출진' &&
completedFirstVictoryDialogueProbe.state?.sortieCommand?.bypass?.interactive === true &&
completedFirstVictoryDialogueProbe.remainingRowMarkerCount === 0 && completedFirstVictoryDialogueProbe.remainingRowMarkerCount === 0 &&
firstVictoryDialoguePersisted(completedFirstVictoryDialogueSave.current) && firstVictoryDialoguePersisted(completedFirstVictoryDialogueSave.current) &&
firstVictoryDialoguePersisted(completedFirstVictoryDialogueSave.slot1), 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, probe: completedFirstVictoryDialogueProbe,
save: completedFirstVictoryDialogueSave, save: completedFirstVictoryDialogueSave,
choiceId: firstVictoryChoice.id choiceId: firstVictoryChoice.id
})}` })}`
); );
const firstBattleCamaraderieDialogueRow = await readCampDialogueControlPoint( await clickSceneBounds(
page, page,
'row', 'CampScene',
'first-battle-camaraderie-memory' completedFirstVictoryDialogueProbe.state.sortieCommand.bounds
);
await page.mouse.click(
firstBattleCamaraderieDialogueRow.x,
firstBattleCamaraderieDialogueRow.y
); );
await page.waitForFunction( await page.waitForFunction(
() => ( () => (
@@ -3542,6 +3540,10 @@ try {
completedFirstBattleCamaraderieState?.campTabs?.find( completedFirstBattleCamaraderieState?.campTabs?.find(
(tab) => tab.id === 'dialogue' (tab) => tab.id === 'dialogue'
)?.newBadgeVisible === false && )?.newBadgeVisible === false &&
completedFirstBattleCamaraderieState?.sortieCommand?.label === '전우 조 편성하기' &&
completedFirstBattleCamaraderieState?.sortieCommand?.guidedKind === 'sortie' &&
completedFirstBattleCamaraderieState?.sortieCommand?.targetId === null &&
completedFirstBattleCamaraderieState?.sortieCommand?.bypass === null &&
firstBattleCamaraderiePersisted(completedFirstBattleCamaraderieSave.current) && firstBattleCamaraderiePersisted(completedFirstBattleCamaraderieSave.current) &&
firstBattleCamaraderiePersisted(completedFirstBattleCamaraderieSave.slot1), firstBattleCamaraderiePersisted(completedFirstBattleCamaraderieSave.slot1),
`Expected the chosen camaraderie memory to persist in current/slot saves, unlock +5%p, and still avoid auto-selection: ${JSON.stringify({ `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, undefined,
{ timeout: 30000 } { 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 firstSortiePursuitSaveBefore = await readCampaignSave(page);
const firstSortiePursuitMutation = await page.evaluate(() => { const firstSortiePursuitMutation = await page.evaluate(() => {
@@ -10678,10 +10704,19 @@ async function openCampSortiePrep(page) {
const rewardVisible = await page.evaluate( const rewardVisible = await page.evaluate(
() => window.__HEROS_DEBUG__?.camp?.()?.victoryRewardAcknowledgement?.visible === true () => window.__HEROS_DEBUG__?.camp?.()?.victoryRewardAcknowledgement?.visible === true
); );
const point = rewardVisible if (rewardVisible) {
? await readCampVictoryRewardControlPoint(page, 'sortie') const point = await readCampVictoryRewardControlPoint(page, 'sortie');
: await readCampStaticTextControlPoint(page, '출진 준비'); await page.mouse.click(point.x, point.y);
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); await waitForSortiePrep(page);
} }

View File

@@ -53,6 +53,41 @@ export type FirstBattleCamaraderieMemory = {
dialogue: FirstBattleCamaraderieDialogue; 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 = { export type FirstBattleCamaraderieCampaignState = {
battleHistory?: Partial<Record<string, CampaignBattleSettlement | undefined>>; battleHistory?: Partial<Record<string, CampaignBattleSettlement | undefined>>;
completedCampDialogues?: readonly string[]; completedCampDialogues?: readonly string[];
@@ -183,6 +218,90 @@ export function firstBattleCamaraderieBonusFor(options: {
: 0; : 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( function compareCooperationSnapshots(
left: CampaignSortieCooperationSnapshot, left: CampaignSortieCooperationSnapshot,
right: CampaignSortieCooperationSnapshot right: CampaignSortieCooperationSnapshot

View File

@@ -1557,7 +1557,7 @@ type BattleSoundCaptionSnapshot = {
}; };
const battleSpeedOptions = ['normal', 'fast', 'very-fast'] as const; const battleSpeedOptions = ['normal', 'fast', 'very-fast'] as const;
type BattleSpeed = (typeof battleSpeedOptions)[number]; 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 EnemyAiBehavior = 'aggressive' | 'guard' | 'hold';
type BattleOutcome = 'victory' | 'defeat'; type BattleOutcome = 'victory' | 'defeat';
type SaveSlotMode = 'save' | 'load'; type SaveSlotMode = 'save' | 'load';
@@ -1975,9 +1975,18 @@ type MapMenuLayout = {
left: number; left: number;
top: number; top: number;
width: number; width: number;
height: number;
buttonHeight: number; buttonHeight: number;
padding: number; padding: number;
actions: MapMenuAction[]; actions: MapMenuAction[];
guidanceBounds: HudBounds;
};
type MapMenuButtonView = {
action: MapMenuAction;
background: Phaser.GameObjects.Rectangle;
label: Phaser.GameObjects.Text;
available: boolean;
}; };
type BattleObjectiveResult = { type BattleObjectiveResult = {
@@ -3726,6 +3735,7 @@ const factionLabels: Record<ActiveFaction, string> = {
const mapMenuLabels: Partial<Record<MapMenuAction, string>> = { const mapMenuLabels: Partial<Record<MapMenuAction, string>> = {
endTurn: '턴 종료', endTurn: '턴 종료',
threat: '위험 범위', threat: '위험 범위',
tutorial: '실전 안내',
roster: '부대 일람', roster: '부대 일람',
bond: '공명', bond: '공명',
situation: '전황', situation: '전황',
@@ -3885,6 +3895,13 @@ export class BattleScene extends Phaser.Scene {
private deploymentNotice = '추천 배치는 이미 적용되어 있습니다. 필요하면 장수를 선택해 시작 구역 안에서 위치를 바꾸세요.'; private deploymentNotice = '추천 배치는 이미 적용되어 있습니다. 필요하면 장수를 선택해 시작 구역 안에서 위치를 바꾸세요.';
private mapMenuObjects: Array<Phaser.GameObjects.Rectangle | Phaser.GameObjects.Text> = []; private mapMenuObjects: Array<Phaser.GameObjects.Rectangle | Phaser.GameObjects.Text> = [];
private mapMenuLayout?: MapMenuLayout; 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 battleSpeed: BattleSpeed = 'normal';
private combatPresentationMode: CombatPresentationMode = 'important'; private combatPresentationMode: CombatPresentationMode = 'important';
private combatPresentationCount = { full: 0, map: 0 }; private combatPresentationCount = { full: 0, map: 0 };
@@ -4120,6 +4137,13 @@ export class BattleScene extends Phaser.Scene {
this.deploymentObjects = []; this.deploymentObjects = [];
this.mapMenuObjects = []; this.mapMenuObjects = [];
this.mapMenuLayout = undefined; 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.tacticalInitiativePanelRoot = undefined;
this.tacticalInitiativePanelObjects = []; this.tacticalInitiativePanelObjects = [];
this.tacticalInitiativePanelBounds = undefined; this.tacticalInitiativePanelBounds = undefined;
@@ -4602,6 +4626,10 @@ export class BattleScene extends Phaser.Scene {
if (this.tacticalInitiativePanelObjects.length > 0) { if (this.tacticalInitiativePanelObjects.length > 0) {
return; return;
} }
if (this.mapMenuObjects.length > 0) {
this.activateMapMenuFocusedAction();
return;
}
if (this.commandMenuObjects.length > 0) { if (this.commandMenuObjects.length > 0) {
this.activateCommandMenuFocusedAction(); this.activateCommandMenuFocusedAction();
return; return;
@@ -4717,6 +4745,12 @@ export class BattleScene extends Phaser.Scene {
} }
private handleBattleKeyDown(event: KeyboardEvent) { private handleBattleKeyDown(event: KeyboardEvent) {
if (this.handleFirstBattleTutorialShortcut(event)) {
return;
}
if (this.handleMapMenuToggleKey(event)) {
return;
}
const repeatableNavigation = const repeatableNavigation =
event.code === 'ArrowLeft' || event.code === 'ArrowLeft' ||
event.code === 'ArrowRight' || event.code === 'ArrowRight' ||
@@ -4739,6 +4773,9 @@ export class BattleScene extends Phaser.Scene {
if (this.saveSlotPanelObjects.length > 0) { if (this.saveSlotPanelObjects.length > 0) {
return; return;
} }
if (this.handleMapMenuNavigationKey(event)) {
return;
}
if (this.handleTacticalInitiativeKey(event)) { if (this.handleTacticalInitiativeKey(event)) {
return; return;
} }
@@ -6400,10 +6437,75 @@ export class BattleScene extends Phaser.Scene {
wordWrap: { width: panelWidth - this.battleUiLength(48), useAdvancedWrap: true } wordWrap: { width: panelWidth - this.battleUiLength(48), useAdvancedWrap: true }
}); });
this.renderBattleSpeedControl(); this.renderBattleSpeedControl();
this.renderBattleMenuButton();
this.updateObjectiveTracker(); this.updateObjectiveTracker();
this.drawMiniMap(); 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() { private renderBattleSpeedControl() {
this.battleSpeedControlObjects.forEach((object) => object.destroy()); this.battleSpeedControlObjects.forEach((object) => object.destroy());
this.battleSpeedControlObjects = []; this.battleSpeedControlObjects = [];
@@ -19355,7 +19457,12 @@ export class BattleScene extends Phaser.Scene {
this.phase === 'idle' && this.phase === 'idle' &&
this.activeFaction === 'ally' && this.activeFaction === 'ally' &&
this.turnNumber === 1 && 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(); this.startFirstBattleTutorial();
return; return;
@@ -19370,7 +19477,7 @@ export class BattleScene extends Phaser.Scene {
private startFirstBattleTutorial() { private startFirstBattleTutorial() {
const path = this.createFirstBattleTutorialPath(); const path = this.createFirstBattleTutorialPath();
if (!path) { if (!path) {
return; return false;
} }
this.firstBattleTutorialPath = path; this.firstBattleTutorialPath = path;
@@ -19382,6 +19489,7 @@ export class BattleScene extends Phaser.Scene {
this.ensureFirstBattleTutorialPathVisible(); this.ensureFirstBattleTutorialPathVisible();
this.syncFirstBattleTutorialKeyboardCursor(); this.syncFirstBattleTutorialKeyboardCursor();
this.renderFirstBattleTutorial(); this.renderFirstBattleTutorial();
return true;
} }
private createFirstBattleTutorialPath(): FirstBattleTutorialPath | undefined { private createFirstBattleTutorialPath(): FirstBattleTutorialPath | undefined {
@@ -19669,7 +19777,7 @@ export class BattleScene extends Phaser.Scene {
const feedback = this.trackFirstBattleTutorialObject(this.add.text( const feedback = this.trackFirstBattleTutorialObject(this.add.text(
left + 22, left + 22,
top + 111, top + 111,
this.firstBattleTutorialFeedback || '빛나는 표시를 따라 클릭/Enter로 조작하세요. · 우클릭/ESC 이전 단계', this.firstBattleTutorialFeedback || '빛나는 표시를 따라 클릭/Enter로 조작하세요. · 우클릭/ESC 이전 · K 건너뛰기',
{ {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '12px', fontSize: '12px',
@@ -19690,7 +19798,7 @@ export class BattleScene extends Phaser.Scene {
skip.setDepth(depth + 2); skip.setDepth(depth + 2);
skip.setStrokeStyle(1, 0x7f8994, 0.82); skip.setStrokeStyle(1, 0x7f8994, 0.82);
skip.setInteractive({ useHandCursor: true }); 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', fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '13px', fontSize: '13px',
color: '#d4dce6', color: '#d4dce6',
@@ -19703,6 +19811,7 @@ export class BattleScene extends Phaser.Scene {
this.suppressNextLeftClick = true; this.suppressNextLeftClick = true;
soundDirector.playSelect(); soundDirector.playSelect();
this.completeFirstBattleTutorial('skipped'); this.completeFirstBattleTutorial('skipped');
this.renderSituationPanel('실전 안내를 건너뛰었습니다.\n메뉴 M · 실전 안내에서 다시 시작할 수 있습니다.');
}; };
skip.on('pointerdown', skipTutorial); skip.on('pointerdown', skipTutorial);
skipText.on('pointerdown', skipTutorial); skipText.on('pointerdown', skipTutorial);
@@ -19836,6 +19945,25 @@ export class BattleScene extends Phaser.Scene {
completeCampaignTutorial(firstBattleTutorialId); 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) { private acceptFirstBattleTutorialCommand(command: BattleCommand) {
if (!this.isFirstBattleTutorialActive()) { if (!this.isFirstBattleTutorialActive()) {
return true; return true;
@@ -21595,8 +21723,194 @@ export class BattleScene extends Phaser.Scene {
return this.isInBounds(x, y) ? { x, y } : undefined; 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) { private showMapMenu(pointerX: number, pointerY: number) {
if (this.phase === 'deployment' || this.phase === 'command' || this.phase === 'targeting' || this.phase === 'animating') { if (this.mapMenuOpenBlockedReason()) {
return; return;
} }
@@ -21608,11 +21922,12 @@ export class BattleScene extends Phaser.Scene {
this.phase = 'idle'; this.phase = 'idle';
this.refreshUnitLegibilityStyles(); this.refreshUnitLegibilityStyles();
const actions: MapMenuAction[] = ['endTurn', 'threat', 'save', 'load', 'roster', 'bond', 'situation', 'speed', 'presentation', 'bgm', 'close']; const actions: MapMenuAction[] = ['endTurn', 'threat', 'tutorial', 'save', 'load', 'roster', 'bond', 'situation', 'speed', 'presentation', 'bgm', 'close'];
const menuWidth = 148; const menuWidth = 204;
const buttonHeight = 34; const buttonHeight = 34;
const padding = 8; 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 mapLeft = this.layout.mapX + 16;
const mapRight = this.layout.mapX + this.layout.mapWidth - 16; const mapRight = this.layout.mapX + this.layout.mapWidth - 16;
const pointerGap = 12; const pointerGap = 12;
@@ -21621,9 +21936,25 @@ export class BattleScene extends Phaser.Scene {
: pointerX - menuWidth - pointerGap; : pointerX - menuWidth - pointerGap;
const left = Phaser.Math.Clamp(preferredLeft, mapLeft, mapRight - menuWidth); 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); 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); const panel = this.add.rectangle(left, top, menuWidth, menuHeight, 0x101821, 0.97);
panel.setName('map-menu-panel');
panel.setOrigin(0); panel.setOrigin(0);
panel.setDepth(20); panel.setDepth(20);
panel.setStrokeStyle(2, palette.gold, 0.86); panel.setStrokeStyle(2, palette.gold, 0.86);
@@ -21634,6 +21965,7 @@ export class BattleScene extends Phaser.Scene {
const y = top + padding + index * buttonHeight; const y = top + padding + index * buttonHeight;
const disabled = this.isMapMenuActionDisabled(action); 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); 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.setOrigin(0);
bg.setDepth(21); bg.setDepth(21);
bg.setStrokeStyle(1, action === 'endTurn' ? 0xd8b15f : 0x5a7588, disabled ? 0.32 : 0.68); 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.setOrigin(0.5);
text.setDepth(22); text.setDepth(22);
this.mapMenuObjects.push(text); this.mapMenuObjects.push(text);
this.mapMenuButtons.push({
action,
background: bg,
label: text,
available: !disabled
});
if (!disabled) { if (!disabled) {
const focus = () => this.setMapMenuFocus(index);
const run = () => { const run = () => {
this.suppressNextLeftClick = true; this.suppressNextLeftClick = true;
this.runMapMenuAction(action); this.runMapMenuAction(action);
}; };
bg.setInteractive({ useHandCursor: true }); bg.setInteractive({ useHandCursor: true });
bg.on('pointerover', () => bg.setFillStyle(0x283947, 0.98)); bg.on('pointerover', focus);
bg.on('pointerout', () => bg.setFillStyle(0x1a2630, 0.94)); bg.on('pointerout', () => this.updateMapMenuButtonVisuals());
bg.on('pointerdown', run); bg.on('pointerdown', run);
text.setInteractive({ useHandCursor: true }); text.setInteractive({ useHandCursor: true });
text.on('pointerover', focus);
text.on('pointerout', () => this.updateMapMenuButtonVisuals());
text.on('pointerdown', run); 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) { private runMapMenuAction(action: MapMenuAction) {
this.hideMapMenu(); this.hideMapMenu();
soundDirector.playSelect(); soundDirector.playSelect();
if (action === 'threat' || action === 'roster' || action === 'bond' || action === 'situation') { if (action === 'threat' || action === 'tutorial' || action === 'roster' || action === 'bond' || action === 'situation') {
this.hideBattleEventBanner(); this.hideBattleEventBanner();
} }
@@ -21680,6 +22053,10 @@ export class BattleScene extends Phaser.Scene {
this.activateSideQuickTab('threat', false); this.activateSideQuickTab('threat', false);
return; return;
} }
if (action === 'tutorial') {
this.startFirstBattleTutorialOnDemand();
return;
}
if (action === 'save') { if (action === 'save') {
this.showSaveSlotPanel('save'); this.showSaveSlotPanel('save');
return; 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() { private showManualTurnEndPrompt() {
const unactedAllies = this.actionableAllies(); const unactedAllies = this.actionableAllies();
if (unactedAllies.length === 0) { if (unactedAllies.length === 0) {
@@ -21753,6 +22144,10 @@ export class BattleScene extends Phaser.Scene {
this.mapMenuObjects.forEach((object) => object.destroy()); this.mapMenuObjects.forEach((object) => object.destroy());
this.mapMenuObjects = []; this.mapMenuObjects = [];
this.mapMenuLayout = undefined; this.mapMenuLayout = undefined;
this.mapMenuButtons = [];
this.mapMenuFocusedIndex = -1;
this.mapMenuFocusMarker = undefined;
this.updateBattleMenuButtonVisual();
} }
private mapMenuActionAt(x: number, y: number) { 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() { private battleHudDebugState() {
const miniMap = this.miniMapLayout; const miniMap = this.miniMapLayout;
const selectedDot = this.selectedUnit ? this.miniMapUnitDots.get(this.selectedUnit.id) : undefined; const selectedDot = this.selectedUnit ? this.miniMapUnitDots.get(this.selectedUnit.id) : undefined;
@@ -32066,6 +32536,8 @@ export class BattleScene extends Phaser.Scene {
const header = { const header = {
titleBounds: this.gameObjectBoundsDebug(this.battleTitleText), titleBounds: this.gameObjectBoundsDebug(this.battleTitleText),
turnBounds: this.gameObjectBoundsDebug(this.turnText), turnBounds: this.gameObjectBoundsDebug(this.turnText),
menuBounds: this.battleMenuButtonBounds ? { ...this.battleMenuButtonBounds } : null,
menuLabel: this.battleMenuButtonText?.active ? this.battleMenuButtonText.text : null,
speedBounds: this.gameObjectCollectionBoundsDebug(this.battleSpeedControlObjects), speedBounds: this.gameObjectCollectionBoundsDebug(this.battleSpeedControlObjects),
objectiveBounds: this.gameObjectBoundsDebug(this.objectiveTrackerText), objectiveBounds: this.gameObjectBoundsDebug(this.objectiveTrackerText),
defeatBounds: this.gameObjectBoundsDebug(this.objectiveTrackerSubText), defeatBounds: this.gameObjectBoundsDebug(this.objectiveTrackerSubText),
@@ -32926,6 +33398,7 @@ export class BattleScene extends Phaser.Scene {
return { return {
id: firstBattleTutorialId, id: firstBattleTutorialId,
eligibleBattle: battleScenario.id === 'first-battle-zhuo-commandery', eligibleBattle: battleScenario.id === 'first-battle-zhuo-commandery',
onDemandAvailable: !this.battleOutcome,
active: this.isFirstBattleTutorialActive(), active: this.isFirstBattleTutorialActive(),
completed: hasCompletedCampaignTutorial(firstBattleTutorialId), completed: hasCompletedCampaignTutorial(firstBattleTutorialId),
completion: this.firstBattleTutorialCompletion ?? null, completion: this.firstBattleTutorialCompletion ?? null,
@@ -32934,6 +33407,8 @@ export class BattleScene extends Phaser.Scene {
totalSteps: 4, totalSteps: 4,
targetPreviewLocked: this.firstBattleTutorialTargetPreviewLocked, targetPreviewLocked: this.firstBattleTutorialTargetPreviewLocked,
feedback: this.firstBattleTutorialFeedback, feedback: this.firstBattleTutorialFeedback,
skipShortcut: 'K',
skipAvailable: this.isFirstBattleTutorialActive(),
path: path path: path
? { ? {
unitId: path.unitId, unitId: path.unitId,
@@ -33115,6 +33590,7 @@ export class BattleScene extends Phaser.Scene {
commandMenuVisible: this.commandMenuObjects.length > 0, commandMenuVisible: this.commandMenuObjects.length > 0,
commandMenuKeyboard: this.commandMenuKeyboardDebugState(), commandMenuKeyboard: this.commandMenuKeyboardDebugState(),
mapMenuVisible: this.mapMenuObjects.length > 0, mapMenuVisible: this.mapMenuObjects.length > 0,
mapMenuKeyboard: this.mapMenuKeyboardDebugState(),
saveSlotPanelVisible: this.saveSlotPanelObjects.length > 0, saveSlotPanelVisible: this.saveSlotPanelObjects.length > 0,
saveSlotPanelMode: this.saveSlotPanelMode ?? null, saveSlotPanelMode: this.saveSlotPanelMode ?? null,
resultVisible: this.resultObjects.length > 0, resultVisible: this.resultObjects.length > 0,

View File

@@ -31,6 +31,8 @@ import {
import { import {
firstBattleCamaraderieBonusFor, firstBattleCamaraderieBonusFor,
firstBattleCamaraderieCoreRateBonus, firstBattleCamaraderieCoreRateBonus,
resolveFirstBattleCamaraderieSortieGuard,
resolveFirstCampCamaraderieGuidedAction,
resolveFirstBattleCamaraderieMemory, resolveFirstBattleCamaraderieMemory,
type FirstBattleCamaraderieMemory type FirstBattleCamaraderieMemory
} from '../data/firstBattleCamaraderieMemory'; } from '../data/firstBattleCamaraderieMemory';
@@ -403,7 +405,7 @@ type FirstCampGuidedAction =
} }
| { | {
kind: 'sortie'; kind: 'sortie';
label: '출진 준비'; label: '전우 조 편성하기' | '출진 준비';
targetId: null; targetId: null;
}; };
@@ -11506,6 +11508,7 @@ export class CampScene extends Phaser.Scene {
private thirdCampPreparationCardViews: ThirdCampPreparationCardView[] = []; private thirdCampPreparationCardViews: ThirdCampPreparationCardView[] = [];
private thirdCampPreparationKeyboardIndex = 0; private thirdCampPreparationKeyboardIndex = 0;
private thirdCampIncompatibleSortieConfirmSignature?: string; private thirdCampIncompatibleSortieConfirmSignature?: string;
private firstBattleCamaraderieSortieConfirmSignature?: string;
private visitedTabs = new Set<CampTab>(); private visitedTabs = new Set<CampTab>();
private selectedSortieUnitIds: string[] = []; private selectedSortieUnitIds: string[] = [];
private sortieFormationAssignments: SortieFormationAssignments = {}; private sortieFormationAssignments: SortieFormationAssignments = {};
@@ -11561,6 +11564,7 @@ export class CampScene extends Phaser.Scene {
this.thirdCampPreparationBrowserOpen = false; this.thirdCampPreparationBrowserOpen = false;
this.thirdCampPreparationKeyboardIndex = 0; this.thirdCampPreparationKeyboardIndex = 0;
this.thirdCampIncompatibleSortieConfirmSignature = undefined; this.thirdCampIncompatibleSortieConfirmSignature = undefined;
this.firstBattleCamaraderieSortieConfirmSignature = undefined;
if (data?.completedAftermathBattleId) { if (data?.completedAftermathBattleId) {
completeCampaignAftermath(data.completedAftermathBattleId); completeCampaignAftermath(data.completedAftermathBattleId);
} }
@@ -13055,11 +13059,19 @@ export class CampScene extends Phaser.Scene {
targetId: followup.targetDialogueId targetId: followup.targetDialogueId
}; };
} }
return { const camaraderieMemory = this.firstBattleCamaraderieMemory();
kind: 'sortie', const nextBattleId = this.currentSortieFlow().nextBattleId;
label: '출진 준비', const sortieResonanceSelection =
targetId: null this.campaign?.sortieResonanceSelection;
}; const selectedCoreBondId =
sortieResonanceSelection?.battleId === nextBattleId
? sortieResonanceSelection?.bondId
: undefined;
return resolveFirstCampCamaraderieGuidedAction({
memory: camaraderieMemory,
nextBattleId,
selectedCoreBondId
});
} }
private runFirstCampGuidedAction() { private runFirstCampGuidedAction() {
@@ -13069,12 +13081,14 @@ export class CampScene extends Phaser.Scene {
return; return;
} }
if (action.kind === 'visit') { if (action.kind === 'visit') {
this.clearCampNotice();
this.activeTab = 'visit'; this.activeTab = 'visit';
this.selectedVisitId = action.targetId; this.selectedVisitId = action.targetId;
this.render(); this.render();
this.startCampNavigation('CampVisitExplorationScene', { visitId: action.targetId }); this.startCampNavigation('CampVisitExplorationScene', { visitId: action.targetId });
return; return;
} }
this.clearCampNotice();
this.activeTab = 'dialogue'; this.activeTab = 'dialogue';
this.selectedDialogueId = action.targetId; this.selectedDialogueId = action.targetId;
this.render(); this.render();
@@ -13953,6 +13967,7 @@ export class CampScene extends Phaser.Scene {
} }
private showRequestedSortiePrep() { private showRequestedSortiePrep() {
this.firstBattleCamaraderieSortieConfirmSignature = undefined;
if (this.openSortieImprovementOnCreate) { if (this.openSortieImprovementOnCreate) {
this.prepareGuidedSortieImprovement(); this.prepareGuidedSortieImprovement();
} }
@@ -18453,6 +18468,7 @@ export class CampScene extends Phaser.Scene {
this.persistSortieSelection(); this.persistSortieSelection();
const clearing = this.currentSortieResonanceBondId(scenario) === candidate.id; const clearing = this.currentSortieResonanceBondId(scenario) === candidate.id;
this.campaign = setCampaignSortieResonanceSelection(scenario.id, clearing ? undefined : candidate.id); this.campaign = setCampaignSortieResonanceSelection(scenario.id, clearing ? undefined : candidate.id);
this.firstBattleCamaraderieSortieConfirmSignature = undefined;
const guidedRecalculated = this.resetRecommendationAfterSortieStrategyChange(); const guidedRecalculated = this.resetRecommendationAfterSortieStrategyChange();
const pairLabel = `${candidate.unitNames[0]}${candidate.unitNames[1]}`; const pairLabel = `${candidate.unitNames[0]}${candidate.unitNames[1]}`;
this.sortiePlanFeedback = clearing this.sortiePlanFeedback = clearing
@@ -21315,6 +21331,29 @@ export class CampScene extends Phaser.Scene {
return; 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 = const thirdCampSortie =
this.thirdCampPreparationSortieState(); this.thirdCampPreparationSortieState();
if ( if (
@@ -21580,6 +21619,7 @@ export class CampScene extends Phaser.Scene {
this.sortieCoreResonancePage = 0; this.sortieCoreResonancePage = 0;
this.thirdCampPreparationBrowserOpen = false; this.thirdCampPreparationBrowserOpen = false;
this.thirdCampIncompatibleSortieConfirmSignature = undefined; this.thirdCampIncompatibleSortieConfirmSignature = undefined;
this.firstBattleCamaraderieSortieConfirmSignature = undefined;
} }
this.sortieComparisonPanelView = undefined; this.sortieComparisonPanelView = undefined;
this.sortiePursuitPanelView = undefined; this.sortiePursuitPanelView = undefined;