feat: make guidance recoverable across camp and battle
This commit is contained in:
@@ -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):
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user