diff --git a/scripts/verify-battle-keyboard-command-flow-browser.mjs b/scripts/verify-battle-keyboard-command-flow-browser.mjs index da8076b..454ac85 100644 --- a/scripts/verify-battle-keyboard-command-flow-browser.mjs +++ b/scripts/verify-battle-keyboard-command-flow-browser.mjs @@ -6,7 +6,7 @@ import { desktopBrowserViewport } from './desktop-browser-viewport.mjs'; -const battleId = 'second-battle-yellow-turban-pursuit'; +const battleId = 'first-battle-zhuo-commandery'; const renderer = process.env.VERIFY_BATTLE_KEYBOARD_RENDERER; if (!renderer) { for (const requestedRenderer of ['canvas', 'webgl']) { @@ -77,24 +77,82 @@ try { await page.waitForFunction( (requestedBattleId) => { const battle = window.__HEROS_DEBUG__?.battle(); + const activeScenes = window.__HEROS_DEBUG__?.activeScenes?.() ?? []; return ( battle?.battleId === requestedBattleId && - ['deployment', 'idle'].includes(battle.phase) && + battle.phase === 'deployment' && battle.mapBackgroundReady === true && - (battle.phase === 'idle' || - ['ready', 'degraded'].includes(battle.combatAssets?.status)) + ['ready', 'degraded'].includes(battle.combatAssets?.status) && + activeScenes.length === 1 && + activeScenes[0] === 'BattleScene' ); }, battleId, { timeout: 90000 } ); + await installPointerInputRecorder(page); + let state = await readBattleState(page); + assertFirstBattleObjectiveDefinitions(state.objectiveDefinitions); + assertDeploymentKeyboardFocus(state.deploymentKeyboard, 'start'); + assertDeploymentKeyboardOrder(state.deploymentKeyboard); + + await page.keyboard.press('Tab'); + await waitForDeploymentFocus(page, state.deploymentKeyboard.items[0].id); + state = await readBattleState(page); + assertDeploymentKeyboardFocus( + state.deploymentKeyboard, + state.deploymentKeyboard.items[0].id + ); + + await page.keyboard.press('Shift+Tab'); + await waitForDeploymentFocus(page, 'start'); + state = await readBattleState(page); + assertDeploymentKeyboardFocus(state.deploymentKeyboard, 'start'); + + await page.keyboard.press('ArrowRight'); + await waitForDeploymentFocus(page, state.deploymentKeyboard.items[0].id); + state = await readBattleState(page); + assertDeploymentKeyboardFocus( + state.deploymentKeyboard, + state.deploymentKeyboard.items[0].id + ); + + await page.keyboard.press('ArrowLeft'); + await waitForDeploymentFocus(page, 'start'); + state = await readBattleState(page); + assertDeploymentKeyboardFocus(state.deploymentKeyboard, 'start'); + + const firstDeploymentItemId = state.deploymentKeyboard.items[0].id; + const secondDeploymentItemId = state.deploymentKeyboard.items[1].id; + await page.keyboard.down('ArrowRight'); + await waitForDeploymentFocus(page, firstDeploymentItemId); + await page.keyboard.down('ArrowRight'); + await waitForDeploymentFocus(page, secondDeploymentItemId); + await page.keyboard.up('ArrowRight'); + await page.keyboard.press('ArrowLeft'); + await waitForDeploymentFocus(page, firstDeploymentItemId); + await page.keyboard.press('ArrowLeft'); + await waitForDeploymentFocus(page, 'start'); + + await dispatchKeyDownWithoutKeyUp(page, 'Tab', 'Tab'); + await waitForDeploymentFocus(page, firstDeploymentItemId); await page.evaluate(() => { - const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); - if (scene?.phase === 'deployment') { - scene.confirmPreBattleDeployment(); - } + window.dispatchEvent(new Event('blur')); }); + await page.keyboard.press('Tab'); + await waitForDeploymentFocus(page, secondDeploymentItemId); + await page.keyboard.press('Shift+Tab'); + await waitForDeploymentFocus(page, firstDeploymentItemId); + await page.keyboard.press('Shift+Tab'); + await waitForDeploymentFocus(page, 'start'); + + await page.screenshot({ + path: `dist/verification-battle-keyboard-deployment-focus-${renderer}.png`, + fullPage: true + }); + + await page.keyboard.press('Enter'); await page.waitForFunction( (requestedBattleId) => { const battle = window.__HEROS_DEBUG__?.battle(); @@ -104,172 +162,301 @@ try { { timeout: 30000 } ); - const fixture = await page.evaluate(async () => { - const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); - const units = scene?.debugBattleUnits?.() ?? []; - const unit = units.find( - (candidate) => - candidate.id === 'liu-bei' && - candidate.faction === 'ally' && - candidate.hp > 0 - ); - if (!scene || !unit) { - return null; - } + await page.waitForFunction( + () => { + const battle = window.__HEROS_DEBUG__?.battle(); + return ( + battle?.phase === 'idle' && + battle.activeBattleEvent === null && + battle.firstBattleTutorial?.active === true && + battle.firstBattleTutorial.step === 'select-unit' + ); + }, + undefined, + { timeout: 30000 } + ); - scene.hideBattleEventBanner(); - scene.hideTurnEndPrompt(); - scene.hideSaveSlotPanel(); - scene.hideMapMenu(); - scene.hideCommandMenu(); - scene.clearMarkers(); - scene.activeFaction = 'ally'; - scene.phase = 'idle'; - scene.selectedUnit = undefined; - scene.pendingMove = undefined; - scene.targetingAction = undefined; - scene.selectedUsable = undefined; - scene.actedUnitIds.clear(); - units - .filter((candidate) => candidate.faction === 'ally' && candidate.hp > 0) - .forEach((candidate) => { - candidate.hp = candidate.maxHp; - }); - scene.centerCameraOnTile(unit.x, unit.y); - scene.selectUnit(unit); - await scene.moveSelectedUnit(unit.x, unit.y); + state = await readBattleState(page); + const tutorialPath = state.firstBattleTutorial?.path; + assert( + tutorialPath?.unitId && + tutorialPath.from && + tutorialPath.moveTile && + tutorialPath.targetId && + tutorialPath.targetTile, + `Expected a complete first-battle tutorial path: ${JSON.stringify(state.firstBattleTutorial)}` + ); - const recordPointerInput = () => { - window.__HEROS_KEYBOARD_COMMAND_QA__.pointerInputCount += 1; - }; - window.__HEROS_KEYBOARD_COMMAND_QA__ = { - pointerInputCount: 0, - recordPointerInput - }; - window.addEventListener('mousedown', recordPointerInput, true); - window.addEventListener('pointerdown', recordPointerInput, true); - - const state = window.__HEROS_DEBUG__?.battle(); - return { - unitId: unit.id, - unitTile: { x: unit.x, y: unit.y }, - pendingMove: state?.pendingMove ?? null, - commandMenu: state?.commandMenuKeyboard ?? null - }; + state = await focusBattleCursorAt(page, tutorialPath.from, { + expectedMode: 'unit', + expectedUnitId: tutorialPath.unitId }); - - assert(fixture, 'Expected the keyboard command fixture to select Liu Bei.'); - assert( - fixture.pendingMove?.unitId === fixture.unitId, - `Expected a preserved pending stay-in-place move: ${JSON.stringify(fixture)}` - ); - assertCommandMenuFocus(fixture.commandMenu, 'command'); - assert( - fixture.commandMenu.items.some( - ({ id, available }) => id === 'attack' && available === false - ), - `Expected the distant attack command to be unavailable for skip QA: ${JSON.stringify(fixture.commandMenu)}` - ); - assert( - fixture.commandMenu.items.some( - ({ id, available }) => id === 'strategy' && available - ), - `Expected Liu Bei to have an available strategy: ${JSON.stringify(fixture.commandMenu)}` - ); - assert( - fixture.commandMenu.focusedItem.id === 'strategy', - `Default focus must skip unavailable attack and land on strategy: ${JSON.stringify(fixture.commandMenu)}` - ); + assertIdleUnitKeyboardFocus(state, tutorialPath.unitId, tutorialPath.from); await page.screenshot({ - path: `dist/verification-battle-keyboard-command-focus-${renderer}.png`, + path: `dist/verification-battle-keyboard-idle-focus-${renderer}.png`, fullPage: true }); - const initialCommandFocus = fixture.commandMenu.focusedItem.id; - const availableCommandIds = fixture.commandMenu.items - .filter(({ available }) => available) - .map(({ id }) => id); - const previousCommandId = - availableCommandIds[ - (availableCommandIds.indexOf(initialCommandFocus) - - 1 + - availableCommandIds.length) % - availableCommandIds.length - ]; + await page.keyboard.press('Enter'); + await page.waitForFunction( + (expectedUnitId) => { + const battle = window.__HEROS_DEBUG__?.battle(); + return ( + battle?.phase === 'moving' && + battle.selectedUnitId === expectedUnitId && + battle.firstBattleTutorial?.step === 'move' + ); + }, + tutorialPath.unitId + ); - await page.keyboard.press('Shift+Tab'); - let state = await readBattleState(page); + state = await focusBattleCursorAt(page, tutorialPath.moveTile, { + expectedMode: 'move' + }); + assertBattleCursor(state.keyboardBattleCursor, tutorialPath.moveTile, 'move'); + await page.keyboard.press('Enter'); + await page.waitForFunction( + (expectedUnitId) => { + const battle = window.__HEROS_DEBUG__?.battle(); + return ( + battle?.phase === 'command' && + battle.selectedUnitId === expectedUnitId && + battle.pendingMove?.unitId === expectedUnitId && + battle.firstBattleTutorial?.step === 'attack-command' && + battle.commandMenuKeyboard?.visible === true + ); + }, + tutorialPath.unitId, + { timeout: 30000 } + ); + + await focusMenuItem(page, 'attack'); + state = await readBattleState(page); assertCommandMenuFocus(state.commandMenuKeyboard, 'command'); assert( - state.commandMenuKeyboard.focusedItem.id === previousCommandId, - `Shift+Tab must wrap to the previous available command: ${JSON.stringify(state.commandMenuKeyboard)}` + state.commandMenuKeyboard.focusedItem.id === 'attack', + `Expected attack to receive keyboard focus: ${JSON.stringify(state.commandMenuKeyboard)}` + ); + await page.keyboard.press('Enter'); + await page.waitForFunction( + (expectedUnitId) => { + const battle = window.__HEROS_DEBUG__?.battle(); + return ( + battle?.phase === 'targeting' && + battle.selectedUnitId === expectedUnitId && + battle.firstBattleTutorial?.step === 'target-preview' + ); + }, + tutorialPath.unitId + ); + + state = await focusBattleCursorAt(page, tutorialPath.targetTile, { + expectedMode: 'damage', + expectedUnitId: tutorialPath.targetId + }); + assertBattleCursor( + state.keyboardBattleCursor, + tutorialPath.targetTile, + 'damage', + tutorialPath.targetId + ); + await page.keyboard.press('Enter'); + await page.waitForFunction( + (expectedTargetId) => { + const battle = window.__HEROS_DEBUG__?.battle(); + return ( + battle?.firstBattleTutorial?.targetPreviewLocked === true && + battle.combatExchangePreview?.defenderId === expectedTargetId + ); + }, + tutorialPath.targetId ); - await page.keyboard.press('Tab'); state = await readBattleState(page); assert( - state.commandMenuKeyboard.focusedItem.id === initialCommandFocus, - `Tab must wrap back to the initial command without visiting a disabled row: ${JSON.stringify(state.commandMenuKeyboard)}` + state.combatExchangePreview?.attackerId === tutorialPath.unitId && + state.combatExchangePreview.defenderId === tutorialPath.targetId && + Number.isFinite(state.combatExchangePreview.attackDamage) && + Number.isFinite(state.combatExchangePreview.attackHitRate), + `Expected a keyboard-selected attack forecast before confirmation: ${JSON.stringify(state.combatExchangePreview)}` ); - const visitedCommands = []; - for (let index = 0; index < availableCommandIds.length; index += 1) { - await page.keyboard.press('Tab'); - state = await readBattleState(page); - assertCommandMenuFocus(state.commandMenuKeyboard, 'command'); - visitedCommands.push(state.commandMenuKeyboard.focusedItem.id); - } + await page.screenshot({ + path: `dist/verification-battle-keyboard-attack-preview-${renderer}.png`, + fullPage: true + }); + + await page.keyboard.press('Enter'); + await page.waitForFunction( + (unitId) => { + const battle = window.__HEROS_DEBUG__?.battle(); + return ( + battle?.actedUnitIds?.includes(unitId) && + battle.commandMenuKeyboard?.visible === false && + battle.firstBattleTutorial?.active === false + ); + }, + tutorialPath.unitId, + { timeout: 30000 } + ); + + const completed = await page.evaluate((unitId) => { + const state = window.__HEROS_DEBUG__?.battle(); + const qa = window.__HEROS_KEYBOARD_COMMAND_QA__; + return { + selectedUnitId: state?.selectedUnitId ?? null, + pendingMove: state?.pendingMove ?? null, + acted: state?.actedUnitIds?.includes(unitId) ?? false, + commandMenu: state?.commandMenuKeyboard ?? null, + phase: state?.phase ?? null, + tutorialActive: state?.firstBattleTutorial?.active ?? null, + pointerInputCount: qa?.pointerInputCount ?? -1 + }; + }, tutorialPath.unitId); assert( - visitedCommands.at(-1) === initialCommandFocus && - visitedCommands.every((id) => availableCommandIds.includes(id)), - `Command focus must wrap and skip every unavailable row: ${JSON.stringify({ - availableCommandIds, - visitedCommands + completed.acted && + completed.commandMenu?.visible === false && + completed.tutorialActive === false, + `Keyboard attack confirmation must complete exactly one first action: ${JSON.stringify(completed)}` + ); + assert( + completed.pointerInputCount === 0, + `Deployment-to-attack keyboard flow emitted mouse/pointer input: ${JSON.stringify(completed)}` + ); + + state = await waitForAutoFocusedUnitWithKeyboard(page); + const handoffUnit = state.units?.find( + (unit) => unit.id === state.selectedUnitId + ); + assert( + handoffUnit?.id === 'zhang-fei' && + handoffUnit.faction === 'ally' && + handoffUnit.hp > 0 && + handoffUnit.acted === false, + `Expected the first completed action to hand keyboard focus to Zhang Fei: ${JSON.stringify({ + selectedUnitId: state.selectedUnitId, + units: state.units })}` ); - await focusMenuItem(page, 'strategy'); - const beforeUsable = await readBattleState(page); + await focusBattleCursorAt( + page, + { x: handoffUnit.x, y: handoffUnit.y }, + { expectedMode: 'move' } + ); + await page.keyboard.press('Enter'); + await page.waitForFunction( + (unitId) => { + const battle = window.__HEROS_DEBUG__?.battle(); + return ( + battle?.selectedUnitId === unitId && + battle.pendingMove?.unitId === unitId && + battle.commandMenuKeyboard?.visible === true + ); + }, + handoffUnit.id + ); + await focusMenuItem(page, 'wait'); + await page.keyboard.press('Enter'); + await page.waitForFunction( + (unitId) => { + const battle = window.__HEROS_DEBUG__?.battle(); + return ( + battle?.actedUnitIds?.includes(unitId) && + battle.commandMenuKeyboard?.visible === false + ); + }, + handoffUnit.id + ); + + state = await waitForAutoFocusedUnitWithKeyboard(page); + const menuUnit = state.units?.find( + (unit) => unit.id === state.selectedUnitId + ); + assert( + menuUnit?.id === 'liu-bei' && + menuUnit.faction === 'ally' && + menuUnit.hp > 0 && + menuUnit.acted === false, + `Expected Zhang Fei's wait to hand keyboard focus to Liu Bei: ${JSON.stringify({ + selectedUnitId: state.selectedUnitId, + units: state.units + })}` + ); + + await focusBattleCursorAt( + page, + { x: menuUnit.x, y: menuUnit.y }, + { expectedMode: 'move' } + ); + await page.keyboard.press('Enter'); + await page.waitForFunction( + (unitId) => { + const battle = window.__HEROS_DEBUG__?.battle(); + return ( + battle?.selectedUnitId === unitId && + battle.pendingMove?.unitId === unitId && + battle.commandMenuKeyboard?.visible === true && + battle.commandMenuKeyboard.mode === 'command' + ); + }, + menuUnit.id + ); + + state = await readBattleState(page); + assertCommandMenuFocus(state.commandMenuKeyboard, 'command'); + const commandItems = state.commandMenuKeyboard.items; + const attackCommand = commandItems.find(({ id }) => id === 'attack'); + const strategyCommand = commandItems.find(({ id }) => id === 'strategy'); + assert( + attackCommand?.available === false && + strategyCommand?.available === true && + state.commandMenuKeyboard.focusedItem.id === 'strategy', + `Default command focus must skip unavailable attack and land on strategy: ${JSON.stringify(state.commandMenuKeyboard)}` + ); + + const availableCommandIds = commandItems + .filter(({ available }) => available) + .map(({ id }) => id); + const strategyIndex = availableCommandIds.indexOf('strategy'); + const previousCommandId = + availableCommandIds[ + (strategyIndex - 1 + availableCommandIds.length) % + availableCommandIds.length + ]; + await page.keyboard.press('Shift+Tab'); + state = await readBattleState(page); + assert( + state.commandMenuKeyboard?.focusedItem?.id === previousCommandId, + `Shift+Tab must wrap to the previous available command: ${JSON.stringify(state.commandMenuKeyboard)}` + ); + await page.keyboard.press('Tab'); + state = await readBattleState(page); + assert( + state.commandMenuKeyboard?.focusedItem?.id === 'strategy', + `Tab must return to strategy without visiting disabled attack: ${JSON.stringify(state.commandMenuKeyboard)}` + ); + await page.keyboard.press('Enter'); await page.waitForFunction(() => { const keyboard = window.__HEROS_DEBUG__?.battle()?.commandMenuKeyboard; return keyboard?.visible === true && keyboard.mode === 'usable'; }); - state = await readBattleState(page); assertCommandMenuFocus(state.commandMenuKeyboard, 'usable'); - assert( - state.selectedUnitId === fixture.unitId && - state.pendingMove?.unitId === fixture.unitId && - state.commandMenuKeyboard.parentCommand === 'strategy', - `Entering the usable menu must preserve unit and pending move: ${JSON.stringify(state)}` - ); assert( state.commandMenuKeyboard.items.some(({ available }) => !available) && state.commandMenuKeyboard.focusedItem.available === true, - `Usable default focus must skip an unavailable full-HP heal: ${JSON.stringify(state.commandMenuKeyboard)}` - ); - assert( - !state.commandMenuKeyboard.items.find( - ({ id }) => id === state.commandMenuKeyboard.focusedItem.id - )?.available === false, - `Usable focus landed on an unavailable item: ${JSON.stringify(state.commandMenuKeyboard)}` + `Usable focus must skip unavailable choices: ${JSON.stringify(state.commandMenuKeyboard)}` ); await page.keyboard.press('Shift+Tab'); state = await readBattleState(page); - assertCommandMenuFocus(state.commandMenuKeyboard, 'usable'); assert( - state.commandMenuKeyboard.focusedItem.available === true, - `Usable Shift+Tab must skip unavailable rows: ${JSON.stringify(state.commandMenuKeyboard)}` + state.commandMenuKeyboard?.focusedItem?.available === true, + `Usable Shift+Tab must keep focus on an available choice: ${JSON.stringify(state.commandMenuKeyboard)}` ); - - await page.screenshot({ - path: `dist/verification-battle-keyboard-usable-focus-${renderer}.png`, - fullPage: true - }); - await page.keyboard.press('Escape'); await page.waitForFunction(() => { const keyboard = window.__HEROS_DEBUG__?.battle()?.commandMenuKeyboard; @@ -280,13 +467,10 @@ try { ); }); state = await readBattleState(page); - assertCommandMenuFocus(state.commandMenuKeyboard, 'command'); assert( - state.selectedUnitId === fixture.unitId && - state.pendingMove?.unitId === fixture.unitId && - state.pendingMove.toX === beforeUsable.pendingMove.toX && - state.pendingMove.toY === beforeUsable.pendingMove.toY, - `Esc must return one level without clearing selection or pending move: ${JSON.stringify(state)}` + state.selectedUnitId === menuUnit.id && + state.pendingMove?.unitId === menuUnit.id, + `Esc must return one menu level without clearing the unit or pending move: ${JSON.stringify(state)}` ); await focusMenuItem(page, 'wait'); @@ -295,37 +479,43 @@ try { (unitId) => { const battle = window.__HEROS_DEBUG__?.battle(); return ( - battle?.commandMenuKeyboard?.visible === false && - battle?.actedUnitIds?.includes(unitId) + battle?.actedUnitIds?.includes(unitId) && + battle.commandMenuKeyboard?.visible === false ); }, - fixture.unitId + menuUnit.id ); - const completed = await page.evaluate((unitId) => { - const state = window.__HEROS_DEBUG__?.battle(); + const finalInputState = await page.evaluate(() => { const qa = window.__HEROS_KEYBOARD_COMMAND_QA__; if (qa?.recordPointerInput) { - window.removeEventListener('mousedown', qa.recordPointerInput, true); - window.removeEventListener('pointerdown', qa.recordPointerInput, true); + window.removeEventListener( + 'mousedown', + qa.recordPointerInput, + true + ); + window.removeEventListener( + 'pointerdown', + qa.recordPointerInput, + true + ); + } + if (qa?.recordKeyboardInput) { + window.removeEventListener( + 'keydown', + qa.recordKeyboardInput, + true + ); } return { - selectedUnitId: state?.selectedUnitId ?? null, - pendingMove: state?.pendingMove ?? null, - acted: state?.actedUnitIds?.includes(unitId) ?? false, - commandMenu: state?.commandMenuKeyboard ?? null, - pointerInputCount: qa?.pointerInputCount ?? -1 + pointerInputCount: qa?.pointerInputCount ?? -1, + keyboardInputCount: qa?.keyboardInputs?.length ?? 0 }; - }, fixture.unitId); + }); assert( - completed.acted && - completed.pendingMove === null && - completed.commandMenu?.visible === false, - `Keyboard Enter on wait must complete exactly one unit action: ${JSON.stringify(completed)}` - ); - assert( - completed.pointerInputCount === 0, - `The command action cycle emitted mouse/pointer input after setup: ${JSON.stringify(completed)}` + finalInputState.pointerInputCount === 0 && + finalInputState.keyboardInputCount > 0, + `Full keyboard regression flow must complete with zero pointer input: ${JSON.stringify(finalInputState)}` ); assert( pageErrors.length === 0, @@ -337,9 +527,10 @@ try { ); console.log( - `Verified ${renderer} keyboard-only battle command flow at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height} DPR1 under /heros_web/: ` + - 'default focus skips disabled commands, Tab and Shift+Tab wrap available rows, Enter opens strategy, ' + - 'usable focus skips unavailable choices, Esc preserves the selected unit and pending move, and Enter completes wait with zero mouse input.' + `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, ' + + '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.' ); } finally { await browser?.close(); @@ -354,6 +545,245 @@ function withDebugOptions(url, requestedRenderer) { return parsed.toString(); } +async function dispatchKeyDownWithoutKeyUp(page, key, code) { + await page.evaluate( + ({ requestedKey, requestedCode }) => { + window.dispatchEvent( + new KeyboardEvent('keydown', { + key: requestedKey, + code: requestedCode, + bubbles: true, + cancelable: true + }) + ); + }, + { requestedKey: key, requestedCode: code } + ); +} + +async function installPointerInputRecorder(page) { + await page.evaluate(() => { + const recordPointerInput = () => { + window.__HEROS_KEYBOARD_COMMAND_QA__.pointerInputCount += 1; + }; + const recordKeyboardInput = (event) => { + window.__HEROS_KEYBOARD_COMMAND_QA__.keyboardInputs.push({ + code: event.code, + key: event.key, + shiftKey: event.shiftKey, + repeat: event.repeat + }); + }; + window.__HEROS_KEYBOARD_COMMAND_QA__ = { + pointerInputCount: 0, + recordPointerInput, + recordKeyboardInput, + keyboardInputs: [] + }; + window.addEventListener('mousedown', recordPointerInput, true); + window.addEventListener('pointerdown', recordPointerInput, true); + window.addEventListener('keydown', recordKeyboardInput, true); + }); +} + +function assertDeploymentKeyboardOrder(keyboard) { + const ids = keyboard?.items?.map(({ id }) => id) ?? []; + const firstSlotIndex = ids.findIndex((id) => id.startsWith('slot:')); + const lastUnitIndex = ids.findLastIndex((id) => id.startsWith('unit:')); + assert( + ids.length >= 4 && + ids.at(-2) === 'restore' && + ids.at(-1) === 'start' && + lastUnitIndex >= 0 && + firstSlotIndex > lastUnitIndex && + ids.slice(0, firstSlotIndex).every((id) => id.startsWith('unit:')) && + ids + .slice(firstSlotIndex, -2) + .every((id) => id.startsWith('slot:')), + `Expected deployment order roles -> slots -> restore -> start: ${JSON.stringify(keyboard)}` + ); +} + +function assertFirstBattleObjectiveDefinitions(objectives) { + assert( + Array.isArray(objectives) && objectives.length === 4, + `Expected exactly four first-battle objective definitions: ${JSON.stringify(objectives)}` + ); + + const byId = Object.fromEntries( + objectives.map((objective) => [objective.id, objective]) + ); + assert( + byId.leader?.kind === 'defeat-leader' && + byId.leader.rewardGold === 200, + `Expected the commander objective to reward 200 gold: ${JSON.stringify(byId.leader)}` + ); + assert( + byId['liu-bei']?.kind === 'keep-unit-alive' && + byId['liu-bei'].rewardGold === 100, + `Expected the Liu Bei survival objective to reward 100 gold: ${JSON.stringify(byId['liu-bei'])}` + ); + assert( + byId.village?.kind === 'secure-terrain' && + byId.village.terrain === 'village' && + byId.village.targetTile?.radius === 4 && + byId.village.rewardGold === 210, + `Expected the village objective to expose radius 4 and reward 210 gold: ${JSON.stringify(byId.village)}` + ); + assert( + byId.quick?.kind === 'quick-victory' && + byId.quick.maxTurn === 15 && + byId.quick.rewardGold === 120, + `Expected the quick-victory objective to expose 15 turns and reward 120 gold: ${JSON.stringify(byId.quick)}` + ); +} + +function assertDeploymentKeyboardFocus(keyboard, expectedId) { + const focusedItem = keyboard?.focusedItem; + const matchingItem = keyboard?.items?.find(({ id }) => id === expectedId); + const guidanceText = keyboard?.guidance?.text ?? ''; + assert( + keyboard?.visible === true && + focusedItem?.id === expectedId && + focusedItem.available !== false && + matchingItem?.focused === true && + keyboard.items.filter(({ focused }) => focused).length === 1 && + focusedItem.bounds && + boundsInsideViewport(focusedItem.bounds) && + (focusedItem.lineWidth ?? matchingItem.lineWidth ?? 0) >= 3 && + guidanceText.includes('Tab') && + guidanceText.includes('Enter') && + guidanceText.includes('Esc') && + boundsInsideViewport(keyboard.guidance?.bounds), + `Expected visible deployment keyboard focus and guidance on ${expectedId}: ${JSON.stringify(keyboard)}` + ); +} + +async function waitForDeploymentFocus(page, expectedId) { + try { + await page.waitForFunction( + (id) => + window.__HEROS_DEBUG__?.battle()?.deploymentKeyboard?.focusedItem + ?.id === id, + expectedId + ); + } catch (error) { + const diagnostic = await page.evaluate(() => ({ + deploymentKeyboard: window.__HEROS_DEBUG__?.battle()?.deploymentKeyboard, + keyboardInputs: window.__HEROS_KEYBOARD_COMMAND_QA__?.keyboardInputs ?? [] + })); + throw new Error( + `Timed out waiting for deployment keyboard focus ${expectedId}: ${JSON.stringify(diagnostic)}`, + { cause: error } + ); + } +} + +async function focusBattleCursorAt( + page, + expectedTile, + { expectedMode, expectedUnitId } = {} +) { + for (let attempt = 0; attempt < 96; attempt += 1) { + const state = await readBattleState(page); + const cursor = state?.keyboardBattleCursor; + const cursorUnitId = + cursor?.unitId ?? + cursor?.targetId ?? + state?.units?.find( + (unit) => + unit.hp > 0 && + unit.x === cursor?.x && + unit.y === cursor?.y + )?.id; + if ( + cursor?.x === expectedTile.x && + cursor?.y === expectedTile.y && + (!expectedMode || cursor.mode === expectedMode) && + (!expectedUnitId || cursorUnitId === expectedUnitId) + ) { + return state; + } + await page.keyboard.press('Tab'); + await page.waitForTimeout(20); + } + throw new Error( + `Could not focus ${expectedMode ?? 'battle'} cursor at ${expectedTile.x},${expectedTile.y} for ${expectedUnitId ?? 'any unit'}.` + ); +} + +function assertIdleUnitKeyboardFocus(state, expectedUnitId, expectedTile) { + const focusedUnit = state?.units?.find( + (unit) => + unit.hp > 0 && + unit.x === state.keyboardBattleCursor?.x && + unit.y === state.keyboardBattleCursor?.y + ); + assert( + state?.phase === 'idle' && + state.selectedUnitId === null && + state.keyboardBattleCursor?.mode === 'unit' && + focusedUnit?.id === expectedUnitId && + focusedUnit.faction === 'ally' && + focusedUnit.acted === false && + state.keyboardBattleCursor.x === expectedTile.x && + state.keyboardBattleCursor.y === expectedTile.y, + `Expected one keyboard-focused actionable ally before selection: ${JSON.stringify({ + cursor: state?.keyboardBattleCursor, + focusedUnit, + selectedUnitId: state?.selectedUnitId, + phase: state?.phase + })}` + ); +} + +function assertBattleCursor(cursor, expectedTile, expectedMode, expectedTargetId) { + assert( + cursor?.mode === expectedMode && + cursor.x === expectedTile.x && + cursor.y === expectedTile.y && + (!expectedTargetId || + cursor.targetId === expectedTargetId || + cursor.unitId === expectedTargetId), + `Expected ${expectedMode} keyboard cursor at ${expectedTile.x},${expectedTile.y}: ${JSON.stringify(cursor)}` + ); +} + +async function waitForAutoFocusedUnitWithKeyboard(page) { + for (let attempt = 0; attempt < 300; attempt += 1) { + const state = await readBattleState(page); + const selected = state?.units?.find( + (unit) => unit.id === state.selectedUnitId + ); + if ( + state?.phase === 'moving' && + state.activeFaction === 'ally' && + !state.activeBattleEvent && + selected?.faction === 'ally' && + selected.hp > 0 && + selected.acted === false + ) { + return state; + } + if (state?.activeBattleEvent) { + await page.keyboard.press('Enter'); + } + await page.waitForTimeout(100); + } + const state = await readBattleState(page); + throw new Error( + `Could not reach the next auto-focused ally after a keyboard action: ${JSON.stringify({ + phase: state?.phase, + activeFaction: state?.activeFaction, + activeBattleEvent: state?.activeBattleEvent, + combatCutIn: state?.combatCutIn, + selectedUnitId: state?.selectedUnitId, + keyboardBattleCursor: state?.keyboardBattleCursor, + actedUnitIds: state?.actedUnitIds + })}` + ); +} + async function focusMenuItem(page, expectedId) { for (let attempt = 0; attempt < 8; attempt += 1) { const keyboard = await page.evaluate( diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs index 3227eed..5d0aa45 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -809,7 +809,7 @@ try { throw new Error(`Expected first battle scenario id: ${JSON.stringify(result.battleState)}`); } - if (result.battleState.victoryConditionLabel !== '두령 한석 격파' || result.battleState.defeatConditionLabel !== '유비 퇴각') { + if (result.battleState.victoryConditionLabel !== '한석 선봉 격퇴' || result.battleState.defeatConditionLabel !== '유비 퇴각') { throw new Error(`Expected first battle objective labels: ${JSON.stringify(result.battleState)}`); } @@ -1360,7 +1360,15 @@ try { throw new Error(`Expected campaign slot 1 to persist progress: ${JSON.stringify(campaignSlotAfterDialogue)}`); } - await page.mouse.click(1120, 38); + const firstCampSortieCommandBounds = + campStateAfterDialogue.sortieCommand?.bypass?.bounds ?? + campStateAfterDialogue.sortieCommand?.bounds; + if (!firstCampSortieCommandBounds) { + throw new Error( + `Expected the first camp to expose a sortie command: ${JSON.stringify(campStateAfterDialogue.sortieCommand)}` + ); + } + await clickSceneBounds(page, 'CampScene', firstCampSortieCommandBounds); await page.waitForTimeout(180); const campStateWithSortiePrep = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (!campStateWithSortiePrep?.sortieVisible) { @@ -1463,7 +1471,15 @@ try { throw new Error(`Expected sortie preparation overlay to close: ${JSON.stringify(campStateAfterSortieClose)}`); } - await page.mouse.click(1120, 38); + const postSortieBypassBounds = + campStateAfterSortieClose?.sortieCommand?.bypass?.bounds ?? + campStateAfterSortieClose?.sortieCommand?.bounds; + if (!postSortieBypassBounds) { + throw new Error( + `Expected a reachable sortie bypass command after closing the overlay: ${JSON.stringify(campStateAfterSortieClose)}`, + ); + } + await clickSceneBounds(page, 'CampScene', postSortieBypassBounds); await page.waitForTimeout(120); await launchSortieFromCurrentStep(page); await waitForStoryReady(page); diff --git a/scripts/verify-narrative-continuity.mjs b/scripts/verify-narrative-continuity.mjs index 99d2fbf..0af34e4 100644 --- a/scripts/verify-narrative-continuity.mjs +++ b/scripts/verify-narrative-continuity.mjs @@ -41,15 +41,22 @@ const server = await createServer({ let battleScenarios; let scenarioModule; let storyAssetModule; +let campaignFlowModule; +let prologueMilitiaCampModule; try { ({ battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts')); scenarioModule = await server.ssrLoadModule('/src/game/data/scenario.ts'); storyAssetModule = await server.ssrLoadModule('/src/game/data/storyAssets.ts'); + campaignFlowModule = await server.ssrLoadModule('/src/game/data/campaignFlow.ts'); + prologueMilitiaCampModule = await server.ssrLoadModule( + '/src/game/data/prologueMilitiaCamp.ts' + ); } finally { await server.close(); } +validateEarlyHanSeokContinuity(); validateCriticalVictoryObjectives(); validateFinalStoryTransition(); validateWuzhangStoryBackgrounds(); @@ -66,11 +73,136 @@ if (failures.length > 0) { const criticalObjectiveCount = Object.values(criticalSecureObjectiveIds) .reduce((count, objectiveIds) => count + objectiveIds.length, 0); console.log( - `Verified ${criticalObjectiveCount} narrative-critical secure objectives, ` + + `Verified the Han Seok first-battle retreat, second-battle pursuit, and final defeat, ` + + `${criticalObjectiveCount} narrative-critical secure objectives, ` + 'the Wuzhang transition and background family, the 55-66 timeline chapter, ' + 'and immediate unique camp reward continuity.' ); +function validateEarlyHanSeokContinuity() { + const firstBattleId = 'first-battle-zhuo-commandery'; + const secondBattleId = 'second-battle-yellow-turban-pursuit'; + const firstScenario = battleScenarios[firstBattleId]; + const secondScenario = battleScenarios[secondBattleId]; + + assert(firstScenario, `Missing first Han Seok encounter: ${firstBattleId}`); + assert(secondScenario, `Missing Han Seok pursuit battle: ${secondBattleId}`); + if (!firstScenario || !secondScenario) { + return; + } + + const firstLeaderObjective = firstScenario.objectives.find( + (objective) => + objective.kind === 'defeat-leader' && + objective.unitId === firstScenario.leaderUnitId + ); + const firstBattleObjectiveCopy = [ + firstScenario.victoryConditionLabel, + firstLeaderObjective?.label, + ...firstScenario.openingObjectiveLines + ].filter(Boolean).join(' '); + assert( + firstBattleObjectiveCopy.includes('한석') && + firstBattleObjectiveCopy.includes('선봉') && + /(격퇴|퇴각)/.test(firstBattleObjectiveCopy), + `${firstBattleId}: the first Han Seok encounter must be framed as routing his vanguard` + ); + assert( + !/(?:한석|두령)[^.!?]{0,12}격파/.test(firstBattleObjectiveCopy), + `${firstBattleId}: first-battle objective copy must not imply Han Seok is finally defeated` + ); + + const commander = prologueMilitiaCampModule + .prologueMilitiaCampNpcDefinitions + .find((npc) => npc.id === 'zou-jing'); + const commanderText = commander?.dialogue.map((line) => line.text).join(' ') ?? ''; + assert( + commanderText.includes('한석') && commanderText.includes('선봉'), + 'The pre-battle militia-camp command must introduce the first enemy as Han Seok’s vanguard' + ); + + const departureText = scenarioModule.prologuePages + .filter((page) => ['first-sortie', 'battle-briefing'].includes(page.id)) + .map((page) => page.text) + .join(' '); + assert( + departureText.includes('한석') && departureText.includes('선봉'), + 'The playable prologue departure must preserve the Han Seok vanguard framing' + ); + + const firstVictoryText = firstScenario.victoryPages + .flatMap((page) => [ + page.text, + page.cutscene?.title, + page.cutscene?.subtitle, + ...(page.cutscene?.rewards?.map((reward) => reward.label) ?? []) + ]) + .filter(Boolean) + .join(' '); + assert( + firstVictoryText.includes('한석') && + /(달아|퇴각|도주|빠져나)/.test(firstVictoryText) && + /(북쪽|나루)/.test(firstVictoryText) && + /(잔당|패잔병|남은 병력)/.test(firstVictoryText), + `${firstBattleId}: victory story must explicitly show Han Seok escaping north with the remnants` + ); + + const firstCampFlow = campaignFlowModule.getSortieFlow(firstBattleId); + const firstCampBridge = [ + firstCampFlow?.title, + firstCampFlow?.description, + firstCampFlow?.rewardHint + ].filter(Boolean).join(' '); + assert( + firstCampFlow?.nextBattleId === secondBattleId && + firstCampBridge.includes('한석') && + /(퇴각|달아|도주)/.test(firstCampBridge) && + firstCampBridge.includes('추격'), + 'The first-camp sortie flow must bridge Han Seok’s retreat directly into the pursuit battle' + ); + + const secondIntroText = scenarioModule.secondBattleIntroPages + .flatMap((page) => [ + page.text, + page.cutscene?.title, + page.cutscene?.subtitle, + ...(page.cutscene?.rewards?.map((reward) => reward.label) ?? []) + ]) + .filter(Boolean) + .join(' '); + const secondBattleObjectiveCopy = [ + secondScenario.victoryConditionLabel, + ...secondScenario.openingObjectiveLines + ].join(' '); + const secondVictoryText = secondScenario.victoryPages + .flatMap((page) => [ + page.text, + page.cutscene?.title, + page.cutscene?.subtitle, + ...(page.cutscene?.rewards?.map((reward) => reward.label) ?? []) + ]) + .filter(Boolean) + .join(' '); + assert( + secondIntroText.includes('탁현') && + secondIntroText.includes('한석') && + /(퇴각|달아|도주)/.test(secondIntroText) && + secondIntroText.includes('추격'), + `${secondBattleId}: intro pages must identify the battle as pursuit of Han Seok after his Tak retreat` + ); + assert( + secondBattleObjectiveCopy.includes('탁현') && + secondBattleObjectiveCopy.includes('한석') && + secondBattleObjectiveCopy.includes('퇴각') && + secondScenario.victoryConditionLabel.includes('격파'), + `${secondBattleId}: battle objective must close the explicitly linked Han Seok pursuit` + ); + assert( + secondVictoryText.includes('한석') && secondVictoryText.includes('격파'), + `${secondBattleId}: victory story must explicitly close the pursuit with Han Seok’s final defeat` + ); +} + function validateCriticalVictoryObjectives() { for (const [battleId, objectiveIds] of Object.entries(criticalSecureObjectiveIds)) { const scenario = battleScenarios[battleId]; diff --git a/src/game/data/battles.ts b/src/game/data/battles.ts index 5fc7b38..5972886 100644 --- a/src/game/data/battles.ts +++ b/src/game/data/battles.ts @@ -435,16 +435,16 @@ export type BattleScenarioDefinition = { export const firstBattleScenario: BattleScenarioDefinition = { id: 'first-battle-zhuo-commandery', title: '탁현의 전투', - victoryConditionLabel: '두령 한석 격파', + victoryConditionLabel: '한석 선봉 격퇴', defeatConditionLabel: '유비 퇴각', openingObjectiveLines: [ - '황건적은 숲과 마을 어귀를 끼고 흩어져 있습니다. 두령 한석을 격파하면 전열이 무너집니다.', + '황건 두령 한석의 선봉은 숲과 마을 어귀를 끼고 흩어져 있습니다. 한석의 지휘선을 무너뜨려 퇴각시키면 마을을 지킬 수 있습니다.', '유비가 퇴각하면 의용군은 전투를 지속할 수 없습니다.', '관우는 중앙 숲길을 잡고, 장비는 남쪽 길을 흔들며, 유비는 진영과 마을 보급선을 지키십시오.' ], tacticalGuide: { summary: '중앙 길은 빠르지만 노출됩니다. 숲과 마을 어귀를 잡아 관우가 버티고, 장비가 아래 길로 궁병을 끊습니다.', - route: '아군 진영 → 숲 낀 중앙 길목 → 마을 어귀 → 두령 한석', + route: '아군 진영 → 숲 낀 중앙 길목 → 마을 어귀 → 한석 선봉 지휘선', focus: '관우를 숲 옆 길목에 세워 첫 접전을 받고, 장비는 남쪽 길로 우회해 궁병과 기병의 진입로를 끊으십시오.', roles: ['관우: 숲길 전열', '장비: 남쪽 길 압박', '유비: 진영/마을 보급 지휘'], events: { @@ -454,11 +454,11 @@ export const firstBattleScenario: BattleScenarioDefinition = { }, 'village-approach': { title: '마을 어귀 접근', - lines: ['마을 어귀는 방어와 회복 보정이 있는 핵심 지형입니다.', '어귀에 전열을 세우면 보급선을 잡고 한석에게 안전하게 압박을 모을 수 있습니다.'] + lines: ['마을 어귀는 방어와 회복 보정이 있는 핵심 지형입니다.', '어귀에 전열을 세우면 보급선을 잡고 한석의 선봉 지휘선에 안전하게 압박을 모을 수 있습니다.'] }, 'vanguard-broken': { title: '초기 선봉 정리', - lines: ['앞쪽 황건 선봉이 무너졌습니다.', '관우는 중앙 길목을 계속 잡고, 장비는 남쪽 엄호선을 끊어 마을 쪽 퇴로를 좁히세요.'] + lines: ['앞쪽 황건 선봉이 무너졌습니다.', '관우는 중앙 길목을 계속 잡고, 장비는 남쪽 엄호선을 끊어 한석이 물러날 북쪽 퇴로를 좁히세요.'] }, 'cavalry-approach': { title: '기병 접근', @@ -497,7 +497,7 @@ export const firstBattleScenario: BattleScenarioDefinition = { { id: 'leader', kind: 'defeat-leader', - label: '황건 두령 격파', + label: '한석 선봉 격퇴', rewardGold: 200, unitId: 'rebel-leader' }, @@ -533,8 +533,8 @@ export const firstBattleScenario: BattleScenarioDefinition = { reputation: ['의용군 명성 +1'], recruits: ['jian-yong'], unlockBattleId: 'second-battle-yellow-turban-pursuit', - unlockLabel: '황건 잔당 추격 개방', - note: '간옹이 합류합니다. 다음 전투부터 유비·관우·장비·간옹 4명 중 최대 3명을 골라 편성하며, 추천 편성은 세 형제입니다.' + unlockLabel: '한석 잔당 추격 개방', + note: '한석은 패잔병을 이끌고 북쪽으로 달아났습니다. 간옹이 합류하며, 다음 전투부터 유비·관우·장비·간옹 4명 중 최대 3명을 골라 편성합니다.' }, victoryPages: firstBattleVictoryPages, nextCampScene: 'CampScene' @@ -546,7 +546,7 @@ export const secondBattleScenario: BattleScenarioDefinition = { victoryConditionLabel: '두령 한석 격파', defeatConditionLabel: '유비 퇴각', openingObjectiveLines: [ - '황건 잔당이 강가 나루와 북쪽 마을을 끼고 물러났습니다. 두령 한석을 격파하면 적의 퇴로가 무너집니다.', + '탁현에서 선봉을 잃고 퇴각한 한석이 잔당을 이끌고 강가 나루와 북쪽 마을을 붙잡았습니다. 이번에는 한석을 격파해 적의 퇴로를 끊어야 합니다.', '나루 앞 숲길을 정리하지 않고 길을 넓히면 궁병과 기병에게 전열이 갈라집니다.', '세 형제를 모두 출전시키고 24턴 이내에 승리하면 추격전 명성과 추가 보상이 붙습니다.' ], diff --git a/src/game/data/campaignFlow.ts b/src/game/data/campaignFlow.ts index e699af7..70fbd6c 100644 --- a/src/game/data/campaignFlow.ts +++ b/src/game/data/campaignFlow.ts @@ -159,7 +159,7 @@ const sortieFlows: Record = { afterBattleId: defaultBattleScenario.id, eyebrow: '다음 전장', title: secondBattleScenario.title, - description: '탁현 방어 직후 달아난 황건 잔당이 북쪽 나루와 마을을 붙잡고 있습니다. 첫 승리 보상으로 정비를 마친 뒤, 세 형제가 다시 전열을 나누어 추격합니다.', + description: '탁현에서 선봉을 잃고 퇴각한 한석이 잔당을 이끌고 북쪽 나루와 마을을 붙잡았습니다. 첫 승리 보상으로 정비를 마친 뒤, 세 형제가 한석의 퇴로를 따라 추격합니다.', rewardHint: '지난 보상: 콩, 탁주, 연습검 · 다음 보상: 소모품, 황건 부적, 의용군 명성', nextBattleId: secondBattleScenario.id, campaignStep: 'second-battle', diff --git a/src/game/data/prologueMilitiaCamp.ts b/src/game/data/prologueMilitiaCamp.ts index 05d8efa..b64d418 100644 --- a/src/game/data/prologueMilitiaCamp.ts +++ b/src/game/data/prologueMilitiaCamp.ts @@ -255,7 +255,7 @@ export const prologueMilitiaCampNpcDefinitions: readonly PrologueMilitiaCampNpcD { speaker: '추정', textureKey: 'unit-shu-officer', - text: '막사가 잘 갖춰졌군. 황건 두령 한석이 북동쪽에서 마을을 노린다. 그대들이 선봉을 맡되, 첫 승리를 어떤 군령으로 이끌지 직접 정하게.' + text: '막사가 잘 갖춰졌군. 황건 두령 한석의 선봉이 북동쪽에서 마을을 노린다. 그대들이 길목을 지켜 한석의 선봉을 물리치되, 첫 출전을 어떤 군령으로 이끌지 직접 정하게.' } ], lockedDialogue: [ diff --git a/src/game/data/scenario.ts b/src/game/data/scenario.ts index fe30fe1..d558b19 100644 --- a/src/game/data/scenario.ts +++ b/src/game/data/scenario.ts @@ -167,7 +167,7 @@ export const firstBattleVictoryPages: StoryPage[] = [ bgm: 'militia-theme', chapter: '첫 승리', background: 'story-yellow-pursuit', - text: '황건적의 깃발이 쓰러지자, 숨어 있던 백성들이 하나둘 마을 어귀로 돌아왔다.' + text: '황건 선봉의 깃발은 꺾였지만, 한석은 남은 병력을 수습해 북쪽 나루로 달아났다. 숨어 있던 백성들은 마을 어귀로 돌아오기 시작했다.' }, { id: 'first-victory-liu-bei', @@ -201,7 +201,7 @@ export const firstBattleVictoryPages: StoryPage[] = [ bgm: 'battle-prep', chapter: '다음 길', background: 'story-yellow-pursuit', - text: '세 형제의 이름은 탁현 일대에 퍼지기 시작했다. 이제 더 넓은 전장과 더 많은 인연이 그들을 기다린다.' + text: '한석은 남은 잔당을 이끌고 북쪽 나루로 빠져나갔다. 그 퇴로를 살피던 유비의 옛 벗 간옹이 의용군 막사를 찾아왔다.' } ]; @@ -232,7 +232,7 @@ const firstBattleStoryOverrides: Record> = { 'first-sortie': { chapter: '첫 출전', speaker: '유비', - text: '마을 사람들을 먼저 들여보내자. 황건군이 근처에 진을 쳤다면, 오늘 여기서 물리쳐 다시는 이 길을 넘보지 못하게 해야 한다.', + text: '마을 사람들을 먼저 들여보내자. 한석의 황건 선봉이 근처에 진을 쳤다면, 오늘 그 전열을 물리쳐 이 길을 지켜야 한다.', cutscene: { kind: 'muster', title: '황건군을 향해', @@ -256,11 +256,11 @@ const firstBattleStoryOverrides: Record> = { chapter: '탁현 작전판', speaker: '관우', portrait: 'guanYu', - text: '작전판으로 보시지요. 황건군은 보병을 앞세워 길목을 막고, 뒤쪽 궁병으로 엄호하며, 두령은 북동쪽 높은 길에 머물고 있습니다.', + text: '작전판으로 보시지요. 한석의 선봉은 보병을 앞세워 길목을 막고 뒤쪽 궁병으로 엄호합니다. 한석은 북동쪽 높은 길에서 퇴로를 살피고 있습니다.', cutscene: { kind: 'operation', title: '황건군 진형', - subtitle: '보병 전열을 걷어 내고, 궁병 엄호를 피한 뒤 두령을 포위하십시오.', + subtitle: '보병 전열을 걷어 내고, 궁병 엄호를 피한 뒤 한석의 선봉 지휘선을 무너뜨리십시오.', actors: [ { unitId: 'liu-bei', x: 0.24, y: 0.56, size: 104, direction: 'east', label: '지휘' }, { unitId: 'guan-yu', x: 0.34, y: 0.54, size: 108, direction: 'east', label: '정면' }, @@ -271,12 +271,12 @@ const firstBattleStoryOverrides: Record> = { { x: 0.2, y: 0.74, label: '아군 시작', side: 'ally' }, { x: 0.42, y: 0.55, label: '보병 전열', side: 'enemy' }, { x: 0.57, y: 0.43, label: '궁병 엄호', side: 'enemy' }, - { x: 0.74, y: 0.28, label: '황건 두령', side: 'enemy' }, + { x: 0.74, y: 0.28, label: '한석 선봉', side: 'enemy' }, { x: 0.35, y: 0.64, label: '마을 입구', side: 'objective' } ], briefing: { title: '공략 방침', - lines: ['장비가 측면에서 앞줄을 흔든다.', '관우가 길목 보병을 정면 돌파한다.', '유비는 후방에서 생존과 회복을 맡는다.', '궁병 엄호를 피한 뒤 두령을 함께 포위한다.'] + lines: ['장비가 측면에서 앞줄을 흔든다.', '관우가 길목 보병을 정면 돌파한다.', '유비는 후방에서 생존과 회복을 맡는다.', '궁병 엄호를 피한 뒤 한석의 선봉 지휘선을 함께 압박한다.'] }, rewards: [ { label: '보상: 보급품 · 연습검 · 의용군 명성', tone: 'reward' } @@ -286,20 +286,20 @@ const firstBattleStoryOverrides: Record> = { 'first-victory-village': { chapter: '첫 승리', speaker: '전황', - text: '황건의 깃발이 꺾이고, 흩어졌던 백성들이 마을로 돌아오기 시작했다.', + text: '황건 선봉의 깃발은 꺾였지만, 한석은 남은 병력을 수습해 북쪽 나루로 달아났다. 숨어 있던 백성들은 마을로 돌아오기 시작했다.', cutscene: { kind: 'victory', - title: '탁현 방어 성공', - subtitle: '첫 출전을 함께 치른 세 형제가 승리의 순간을 나눕니다.', + title: '탁현 선봉 격퇴', + subtitle: '마을은 지켰지만 한석은 잔당을 이끌고 북쪽 나루로 퇴각했습니다.', actors: [ { unitId: 'liu-bei', x: 0.36, y: 0.53, size: 132, direction: 'east', label: '유비' }, { unitId: 'guan-yu', x: 0.52, y: 0.52, size: 142, direction: 'west', label: '관우' }, { unitId: 'zhang-fei', x: 0.67, y: 0.53, size: 142, direction: 'west', label: '장비' } ], rewards: [ - { label: '승리 조건 달성', tone: 'reward' }, + { label: '한석 선봉 격퇴', tone: 'reward' }, { label: '마을 방어', tone: 'bond' }, - { label: '다음: 황건 잔당 추격', tone: 'next' } + { label: '다음: 한석 잔당 추격', tone: 'next' } ] } }, @@ -364,11 +364,11 @@ const firstBattleStoryOverrides: Record> = { 'first-victory-next': { chapter: '다음 길', speaker: '전황', - text: '첫 승리 소식을 들은 유비의 옛 벗 간옹이 막사를 찾아와, 의용군의 군정과 책략을 맡겠다고 나섰다. 군량과 말이 넉넉지 않아 네 사람 가운데 셋이 선두를 맡고 한 사람은 후방을 돌보기로 했다.', + text: '한석은 패잔병을 이끌고 북쪽 나루로 달아났다. 그 퇴로를 살피던 유비의 옛 벗 간옹이 막사를 찾아와 의용군의 군정과 책략을 맡겠다고 나섰다.', cutscene: { kind: 'operation', title: '간옹 합류 · 첫 편성 선택', - subtitle: '4명 중 최대 3명을 골라 황건 잔당 추격에 나섭니다.', + subtitle: '4명 중 최대 3명을 골라 북쪽으로 달아난 한석의 잔당을 추격합니다.', actors: [ { unitId: 'liu-bei', x: 0.22, y: 0.54, size: 108, direction: 'east', label: '유비' }, { unitId: 'guan-yu', x: 0.32, y: 0.53, size: 112, direction: 'east', label: '관우' }, @@ -378,7 +378,7 @@ const firstBattleStoryOverrides: Record> = { markers: [ { x: 0.24, y: 0.68, label: '탁현', side: 'ally' }, { x: 0.58, y: 0.46, label: '북쪽 길목', side: 'objective' }, - { x: 0.76, y: 0.32, label: '황건 잔당', side: 'enemy' } + { x: 0.76, y: 0.32, label: '한석 잔당', side: 'enemy' } ], briefing: { title: '정비 후 출전', @@ -387,7 +387,7 @@ const firstBattleStoryOverrides: Record> = { rewards: [ { label: '신규 무장: 간옹', tone: 'reward' }, { label: '편성: 4명 중 최대 3명', tone: 'bond' }, - { label: '다음: 황건 잔당 추격', tone: 'next' } + { label: '다음: 한석 잔당 추격', tone: 'next' } ] } } @@ -411,7 +411,7 @@ export const secondBattleIntroPages: StoryPage[] = [ bgm: 'militia-theme', chapter: '잔당의 그림자', background: 'story-yellow-pursuit', - text: '첫 승리의 기쁨이 채 가시기도 전에, 황건 잔당이 북쪽 길목의 마을을 습격하고 있다는 급보가 도착했다.' + text: '탁현에서 선봉을 잃고 퇴각한 한석이 잔당을 이끌고 북쪽 나루로 달아나, 길목의 마을을 습격하고 있다는 급보가 도착했다.' }, { id: 'second-liu-bei-command', @@ -420,7 +420,7 @@ export const secondBattleIntroPages: StoryPage[] = [ background: 'story-yellow-pursuit', speaker: '유비', portrait: 'liuBei', - text: '백성이 다시 칼끝에 몰렸다면 우리가 쉬어 갈 곳은 없다. 길을 따라 추격하되, 마을을 먼저 지켜야 한다.' + text: '한석의 잔당이 백성을 다시 칼끝에 몰았다면 우리가 쉬어 갈 곳은 없다. 북쪽 퇴로를 따라 추격하되, 마을을 먼저 지켜야 한다.' }, { id: 'second-guan-yu-vow', @@ -442,7 +442,7 @@ export const secondBattleIntroPages: StoryPage[] = [ cutscene: { kind: 'operation', title: '북쪽 나루 추격 작전', - subtitle: '첫 승리 직후 달아난 잔당을 나루와 마을 사이에서 끊어냅니다.', + subtitle: '탁현에서 퇴각한 한석의 잔당을 북쪽 나루와 마을 사이에서 끊어냅니다.', actors: [ { unitId: 'liu-bei', x: 0.25, y: 0.58, size: 104, direction: 'east', label: '후방 지휘' }, { unitId: 'guan-yu', x: 0.35, y: 0.55, size: 112, direction: 'east', label: '나루 전열' }, @@ -460,7 +460,7 @@ export const secondBattleIntroPages: StoryPage[] = [ lines: ['관우가 나루 앞 전열을 잡는다.', '장비가 남쪽에서 잔당과 궁병을 압박한다.', '유비는 뒤에서 생존과 마을 확보를 맡는다.'] }, rewards: [ - { label: '목표: 두령 격파 · 북쪽 마을 확보', tone: 'next' }, + { label: '목표: 한석 격파 · 북쪽 마을 확보', tone: 'next' }, { label: '보상: 상처약 · 황건 부적', tone: 'reward' } ] } @@ -473,7 +473,7 @@ export const secondBattleVictoryPages: StoryPage[] = [ bgm: 'militia-theme', chapter: '마을의 안도', background: 'story-yellow-pursuit', - text: '잔당의 불길은 꺼지고, 피난하던 백성들이 하나둘 마을로 돌아왔다. 의용군의 이름은 이제 길목의 장터마다 오르내렸다.' + text: '북쪽 나루에서 한석이 끝내 격파되자 잔당의 불길은 꺼졌다. 피난하던 백성들이 하나둘 마을로 돌아왔고, 의용군의 이름은 이제 길목의 장터마다 오르내렸다.' }, { id: 'second-victory-liu-bei', diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 1e61364..342df42 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -1421,6 +1421,7 @@ type DeploymentPanelLayout = { recommended: boolean; noticeBounds: { x: number; y: number; width: number; height: number }; noticeTextBounds: { x: number; y: number; width: number; height: number }; + keyboardGuidanceBounds: { x: number; y: number; width: number; height: number }; roleBounds: Array<{ x: number; y: number; width: number; height: number }>; restoreButtonBounds: { x: number; y: number; width: number; height: number }; startButtonBounds: { x: number; y: number; width: number; height: number }; @@ -1762,7 +1763,7 @@ type PendingMove = { intentRisk: MoveIntentRiskDelta; }; -type BattleKeyboardCursorMode = 'move' | 'damage' | 'support'; +type BattleKeyboardCursorMode = 'unit' | 'move' | 'damage' | 'support'; type BattleKeyboardCursor = { mode: BattleKeyboardCursorMode; @@ -1771,6 +1772,14 @@ type BattleKeyboardCursor = { targetId?: string; }; +type DeploymentKeyboardFocus = { + id: string; + kind: 'unit' | 'slot' | 'restore' | 'start'; + unitId?: string; + x?: number; + y?: number; +}; + type FirstBattleTutorialStep = 'select-unit' | 'move' | 'attack-command' | 'target-preview'; type FirstBattleTutorialPath = { @@ -3870,6 +3879,9 @@ export class BattleScene extends Phaser.Scene { private commandMenuFocusMarker?: Phaser.GameObjects.Text; private deploymentObjects: Phaser.GameObjects.GameObject[] = []; private deploymentSelectedUnitId?: string; + private deploymentKeyboardFocus?: DeploymentKeyboardFocus; + private pressedBattleInputCodes = new Set(); + private battleInputLastEventStamp = new Map(); private deploymentNotice = '추천 배치는 이미 적용되어 있습니다. 필요하면 장수를 선택해 시작 구역 안에서 위치를 바꾸세요.'; private mapMenuObjects: Array = []; private mapMenuLayout?: MapMenuLayout; @@ -4332,6 +4344,9 @@ export class BattleScene extends Phaser.Scene { this.selectedUsable = undefined; this.lockedTargetPreview = undefined; this.keyboardBattleCursor = undefined; + this.deploymentKeyboardFocus = undefined; + this.pressedBattleInputCodes.clear(); + this.battleInputLastEventStamp.clear(); this.moveIntentPreviewCache.clear(); this.lastMoveIntentRisk = undefined; this.lastCombatExchangePreview = undefined; @@ -4354,207 +4369,464 @@ export class BattleScene extends Phaser.Scene { ambienceVolume: presentationSoundscape?.ambienceVolume ?? environmentProfile?.soundscape.ambienceVolume }); this.input.mouse?.disableContextMenu(); - this.installBattleAudioUnlock(); - this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => { - if (pointer.rightButtonDown()) { - this.handleRightClick(pointer); - return; - } - - if (pointer.leftButtonDown()) { - this.handleLeftClick(pointer); - } - }); - this.input.on('pointermove', (pointer: Phaser.Input.Pointer) => { - this.keyboardBattleCursor = undefined; - this.updatePointerFeedback(pointer); - }); - this.input.keyboard?.on('keydown-ENTER', (event: KeyboardEvent) => { - if (event.repeat) { - return; - } - if (this.tacticalCommandCutInObjects.length > 0) { - return; - } - if (this.battleEventObjects.length > 0) { - this.hideBattleEventBanner(); - return; - } - if (this.saveSlotPanelObjects.length > 0) { - return; - } - if (this.tacticalInitiativePanelObjects.length > 0) { - return; - } - if (this.commandMenuObjects.length > 0) { - this.activateCommandMenuFocusedAction(); - return; - } - if (this.turnPromptObjects.length > 0) { - this.activateTurnPromptFocusedAction(); - return; - } - - if (!this.confirmLockedTargetPreview()) { - this.activateKeyboardBattleCursor(); - } - }); - this.input.keyboard?.on('keydown-ESC', () => { - if (this.tacticalCommandCutInObjects.length > 0) { - return; - } - if (this.battleEventObjects.length > 0) { - soundDirector.playSelect(); - this.hideBattleEventBanner(); - return; - } - if (this.resultSortieOrderVisible) { - soundDirector.playSelect(); - this.hideResultSortieOrder(); - return; - } - - if (this.resultFormationReviewVisible) { - soundDirector.playSelect(); - this.hideResultFormationReview(); - return; - } - - if (this.saveSlotPanelObjects.length > 0) { - soundDirector.playSelect(); - this.hideSaveSlotPanel(); - return; - } - - if (this.turnPromptObjects.length > 0) { - this.activateTurnPromptCancelAction(); - return; - } - - if (this.tacticalInitiativePanelObjects.length > 0) { - soundDirector.playSelect(); - this.hideTacticalInitiativePanel(); - return; - } - - if (this.mapMenuObjects.length > 0) { - soundDirector.playSelect(); - this.hideMapMenu(); - return; - } - - if (this.commandMenuObjects.length > 0) { - if (this.returnFromUsableMenu()) { - return; - } - if (this.cancelPendingMove()) { - return; - } - } - - if (this.cancelDeploymentSelection()) { - soundDirector.playSelect(); - return; - } - - if (this.cancelAttackTargeting()) { - return; - } - - if (this.cancelPendingMove()) { - return; - } - - if (this.commandMenuObjects.length > 0) { - const detailUnit = battleUnits.find((unit) => unit.id === this.unitDetailPanelLayout?.unitId); - soundDirector.playSelect(); - this.hideCommandMenu(); - this.phase = 'idle'; - this.selectedUnit = undefined; - this.refreshUnitLegibilityStyles(); - if (detailUnit) { - this.renderUnitDetail(detailUnit, '명령 선택을 취소했습니다.', 'system'); - } else { - this.renderSideQuickTabs(); - } - return; - } - - if (this.returnFromSideDetail()) { - soundDirector.playSelect(); - } - }); - this.input.keyboard?.on('keydown', (event: KeyboardEvent) => { - if (this.battleEventObjects.length > 0) { - return; - } - if (this.handleTurnPromptNavigationKey(event)) { - return; - } - if (this.handleSaveSlotPanelKey(event)) { - return; - } - if (this.saveSlotPanelObjects.length > 0) { - return; - } - if (this.handleTacticalInitiativeKey(event)) { - return; - } - if (this.handleCommandMenuNavigationKey(event)) { - return; - } - if (this.handleBattleKeyboardNavigation(event)) { - return; - } - this.handleSideQuickTabKey(event); - }); - this.input.keyboard?.on('keydown-SPACE', (event: KeyboardEvent) => { - if ( - this.battleEventObjects.length > 0 || - this.saveSlotPanelObjects.length > 0 || - this.tacticalInitiativePanelObjects.length > 0 || - this.tacticalCommandCutInObjects.length > 0 || - this.activeFaction !== 'enemy' || - this.fastForwardHeld - ) { - return; - } - event.preventDefault(); - this.fastForwardHeld = true; - this.renderBattleSpeedControl(); - }); - this.input.keyboard?.on('keyup-SPACE', () => { - if (!this.fastForwardHeld) { - return; - } - this.fastForwardHeld = false; - this.renderBattleSpeedControl(); - }); - this.input.keyboard?.on('keydown-PERIOD', () => { - if ( - this.battleEventObjects.length > 0 || - this.saveSlotPanelObjects.length > 0 || - this.tacticalInitiativePanelObjects.length > 0 || - this.tacticalCommandCutInObjects.length > 0 - ) { - return; - } - this.cycleBattleSpeed(); - }); + this.installBattleInputHandlers(); this.installDebugHotkeys(); this.add.rectangle(0, 0, width, height, 0x080b0d).setOrigin(0); this.ensureScenarioAssets(() => this.drawBattleScene()); } - private installBattleAudioUnlock() { - const unlock = () => { - soundDirector.start(); - soundDirector.resume(); - }; + private installBattleInputHandlers() { + this.uninstallBattleInputHandlers(); + this.installBattleAudioUnlock(); + this.input.on( + 'pointerdown', + this.handleBattlePointerDown, + this + ); + this.input.on( + 'pointermove', + this.handleBattlePointerMove, + this + ); + this.input.keyboard?.on( + 'keydown-ENTER', + this.handleBattleEnterKey, + this + ); + this.input.keyboard?.on( + 'keydown-ESC', + this.handleBattleEscapeKey, + this + ); + this.input.keyboard?.on( + 'keydown', + this.handleBattleKeyDown, + this + ); + this.input.keyboard?.on( + 'keyup', + this.handleBattleKeyUp, + this + ); + this.input.keyboard?.on( + 'keydown-SPACE', + this.handleBattleSpaceDown, + this + ); + this.input.keyboard?.on( + 'keyup-SPACE', + this.handleBattleSpaceUp, + this + ); + this.input.keyboard?.on( + 'keydown-PERIOD', + this.handleBattlePeriodKey, + this + ); + this.game.events.on( + Phaser.Core.Events.BLUR, + this.handleBattleInputReset, + this + ); + this.events.on( + Phaser.Scenes.Events.PAUSE, + this.handleBattleInputReset, + this + ); + this.events.on( + Phaser.Scenes.Events.SLEEP, + this.handleBattleInputReset, + this + ); + this.events.once( + Phaser.Scenes.Events.SHUTDOWN, + this.uninstallBattleInputHandlers, + this + ); + } - this.input.once('pointerdown', unlock); - this.input.keyboard?.once('keydown', unlock); + private uninstallBattleInputHandlers() { + this.events.off( + Phaser.Scenes.Events.SHUTDOWN, + this.uninstallBattleInputHandlers, + this + ); + this.input.off( + 'pointerdown', + this.handleBattlePointerDown, + this + ); + this.input.off( + 'pointermove', + this.handleBattlePointerMove, + this + ); + this.input.off( + 'pointerdown', + this.handleBattleAudioUnlock, + this + ); + this.input.keyboard?.off( + 'keydown', + this.handleBattleAudioUnlock, + this + ); + this.input.keyboard?.off( + 'keydown-ENTER', + this.handleBattleEnterKey, + this + ); + this.input.keyboard?.off( + 'keydown-ESC', + this.handleBattleEscapeKey, + this + ); + this.input.keyboard?.off( + 'keydown', + this.handleBattleKeyDown, + this + ); + this.input.keyboard?.off( + 'keyup', + this.handleBattleKeyUp, + this + ); + this.input.keyboard?.off( + 'keydown-SPACE', + this.handleBattleSpaceDown, + this + ); + this.input.keyboard?.off( + 'keyup-SPACE', + this.handleBattleSpaceUp, + this + ); + this.input.keyboard?.off( + 'keydown-PERIOD', + this.handleBattlePeriodKey, + this + ); + this.game.events.off( + Phaser.Core.Events.BLUR, + this.handleBattleInputReset, + this + ); + this.events.off( + Phaser.Scenes.Events.PAUSE, + this.handleBattleInputReset, + this + ); + this.events.off( + Phaser.Scenes.Events.SLEEP, + this.handleBattleInputReset, + this + ); + this.handleBattleInputReset(); + this.uninstallDebugHotkeys(); + } + + private battleInputEventHandled( + channel: string, + event: KeyboardEvent, + allowRepeat = false + ) { + const inputCode = + `${channel}:${event.code}:${event.shiftKey ? 'shift' : 'plain'}`; + const lastEventStamp = + this.battleInputLastEventStamp.get(inputCode); + if ( + (!allowRepeat && ( + event.repeat || + this.pressedBattleInputCodes.has(inputCode) + )) || + lastEventStamp === event.timeStamp + ) { + return true; + } + if (!allowRepeat) { + this.pressedBattleInputCodes.add(inputCode); + } + this.battleInputLastEventStamp.set( + inputCode, + event.timeStamp + ); + return false; + } + + private handleBattleInputReset() { + this.pressedBattleInputCodes.clear(); + this.battleInputLastEventStamp.clear(); + if (!this.fastForwardHeld) { + return; + } + this.fastForwardHeld = false; + if (this.scene.isActive()) { + this.renderBattleSpeedControl(); + } + } + + private handleBattlePointerDown(pointer: Phaser.Input.Pointer) { + if (pointer.rightButtonDown()) { + this.handleRightClick(pointer); + return; + } + + if (pointer.leftButtonDown()) { + this.handleLeftClick(pointer); + } + } + + private handleBattlePointerMove(pointer: Phaser.Input.Pointer) { + this.keyboardBattleCursor = undefined; + this.updatePointerFeedback(pointer); + } + + private handleBattleEnterKey(event: KeyboardEvent) { + if ( + event.repeat || + this.battleInputEventHandled('enter', event) + ) { + return; + } + if (this.tacticalCommandCutInObjects.length > 0) { + return; + } + if (this.battleEventObjects.length > 0) { + this.hideBattleEventBanner(); + return; + } + if (this.saveSlotPanelObjects.length > 0) { + return; + } + if (this.tacticalInitiativePanelObjects.length > 0) { + return; + } + if (this.commandMenuObjects.length > 0) { + this.activateCommandMenuFocusedAction(); + return; + } + if (this.turnPromptObjects.length > 0) { + this.activateTurnPromptFocusedAction(); + return; + } + if (this.phase === 'deployment') { + this.activateDeploymentKeyboardFocus(); + return; + } + + if (!this.confirmLockedTargetPreview()) { + this.activateKeyboardBattleCursor(); + } + } + + private handleBattleEscapeKey(event: KeyboardEvent) { + if (this.battleInputEventHandled('escape', event)) { + return; + } + if (this.tacticalCommandCutInObjects.length > 0) { + return; + } + if (this.battleEventObjects.length > 0) { + soundDirector.playSelect(); + this.hideBattleEventBanner(); + return; + } + if (this.resultSortieOrderVisible) { + soundDirector.playSelect(); + this.hideResultSortieOrder(); + return; + } + + if (this.resultFormationReviewVisible) { + soundDirector.playSelect(); + this.hideResultFormationReview(); + return; + } + + if (this.saveSlotPanelObjects.length > 0) { + soundDirector.playSelect(); + this.hideSaveSlotPanel(); + return; + } + + if (this.turnPromptObjects.length > 0) { + this.activateTurnPromptCancelAction(); + return; + } + + if (this.tacticalInitiativePanelObjects.length > 0) { + soundDirector.playSelect(); + this.hideTacticalInitiativePanel(); + return; + } + + if (this.mapMenuObjects.length > 0) { + soundDirector.playSelect(); + this.hideMapMenu(); + return; + } + + if (this.commandMenuObjects.length > 0) { + if (this.returnFromUsableMenu()) { + return; + } + if (this.cancelPendingMove()) { + return; + } + } + + if (this.cancelDeploymentSelection()) { + soundDirector.playSelect(); + return; + } + + if (this.cancelAttackTargeting()) { + return; + } + + if (this.cancelPendingMove()) { + return; + } + + if (this.commandMenuObjects.length > 0) { + const detailUnit = battleUnits.find( + (unit) => + unit.id === this.unitDetailPanelLayout?.unitId + ); + soundDirector.playSelect(); + this.hideCommandMenu(); + this.phase = 'idle'; + this.selectedUnit = undefined; + this.refreshUnitLegibilityStyles(); + if (detailUnit) { + this.renderUnitDetail( + detailUnit, + '명령 선택을 취소했습니다.', + 'system' + ); + } else { + this.renderSideQuickTabs(); + } + return; + } + + if (this.returnFromSideDetail()) { + soundDirector.playSelect(); + } + } + + private handleBattleKeyDown(event: KeyboardEvent) { + const repeatableNavigation = + event.code === 'ArrowLeft' || + event.code === 'ArrowRight' || + event.code === 'ArrowUp' || + event.code === 'ArrowDown'; + const duplicate = this.battleInputEventHandled( + 'navigation', + event, + repeatableNavigation + ); + if (duplicate || this.battleEventObjects.length > 0) { + return; + } + if (this.handleTurnPromptNavigationKey(event)) { + return; + } + if (this.handleSaveSlotPanelKey(event)) { + return; + } + if (this.saveSlotPanelObjects.length > 0) { + return; + } + if (this.handleTacticalInitiativeKey(event)) { + return; + } + if (this.handleCommandMenuNavigationKey(event)) { + return; + } + if (this.handleDeploymentKeyboardNavigationKey(event)) { + return; + } + if (this.handleBattleKeyboardNavigation(event)) { + return; + } + this.handleSideQuickTabKey(event); + } + + private handleBattleKeyUp(event: KeyboardEvent) { + const codeToken = `:${event.code}:`; + Array.from(this.pressedBattleInputCodes) + .filter((inputCode) => inputCode.includes(codeToken)) + .forEach((inputCode) => { + this.pressedBattleInputCodes.delete(inputCode); + }); + } + + private handleBattleSpaceDown(event: KeyboardEvent) { + if ( + this.battleInputEventHandled('space-down', event) || + this.battleEventObjects.length > 0 || + this.saveSlotPanelObjects.length > 0 || + this.tacticalInitiativePanelObjects.length > 0 || + this.tacticalCommandCutInObjects.length > 0 || + this.activeFaction !== 'enemy' || + this.fastForwardHeld + ) { + return; + } + event.preventDefault(); + this.fastForwardHeld = true; + this.renderBattleSpeedControl(); + } + + private handleBattleSpaceUp(_event: KeyboardEvent) { + if (!this.fastForwardHeld) { + return; + } + this.fastForwardHeld = false; + this.renderBattleSpeedControl(); + } + + private handleBattlePeriodKey(event: KeyboardEvent) { + if ( + this.battleInputEventHandled('period', event) || + this.battleEventObjects.length > 0 || + this.saveSlotPanelObjects.length > 0 || + this.tacticalInitiativePanelObjects.length > 0 || + this.tacticalCommandCutInObjects.length > 0 + ) { + return; + } + this.cycleBattleSpeed(); + } + + private handleBattleAudioUnlock() { + soundDirector.start(); + soundDirector.resume(); + } + + private installBattleAudioUnlock() { + this.input.off( + 'pointerdown', + this.handleBattleAudioUnlock, + this + ); + this.input.keyboard?.off( + 'keydown', + this.handleBattleAudioUnlock, + this + ); + this.input.once( + 'pointerdown', + this.handleBattleAudioUnlock, + this + ); + this.input.keyboard?.once( + 'keydown', + this.handleBattleAudioUnlock, + this + ); } private drawBattleScene() { @@ -7901,29 +8173,33 @@ export class BattleScene extends Phaser.Scene { if (!target) { return; } - - const marker = this.trackObjectiveMapMarker( - this.add.rectangle(this.tileTopLeftX(target.x), this.tileTopLeftY(target.y), this.layout.tileSize, this.layout.tileSize, palette.gold, 0.07) - ); - marker.setName(`objective-marker-${objective.id}`); - marker.setData('tileX', target.x); - marker.setData('tileY', target.y); - marker.setOrigin(0); - marker.setStrokeStyle(2, palette.gold, 0.68); - marker.setDepth(4.86); + const radius = Math.max(0, target.radius ?? 0); + const area = this.trackObjectiveMapMarker(this.add.graphics()); + area.setName(`objective-area-${objective.id}`); + area.setData('objectiveArea', true); + area.setData('tileX', target.x); + area.setData('tileY', target.y); + area.setData('objectiveId', objective.id); + area.setData('objectiveRadius', radius); + area.setDepth(4.86); if (this.mapMask) { - marker.setMask(this.mapMask); + area.setMask(this.mapMask); } const label = this.trackObjectiveMapMarker( - this.add.text(this.tileCenterX(target.x), this.tileTopLeftY(target.y) + 4, '목표', { + this.add.text( + this.tileCenterX(target.x), + this.tileTopLeftY(target.y) + 4, + `확보${objective.rewardGold > 0 ? ` +${objective.rewardGold}` : ''}`, + { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '10px', color: '#fff1c8', fontStyle: '700', stroke: '#05070a', strokeThickness: 3 - }) + } + ) ); label.setName(`objective-label-${objective.id}`); label.setData('tileX', target.x); @@ -7957,6 +8233,20 @@ export class BattleScene extends Phaser.Scene { return; } + if ( + object instanceof Phaser.GameObjects.Graphics && + object.getData('objectiveArea') === true + ) { + const radius = Math.max( + 0, + (object.getData('objectiveRadius') as number | undefined) ?? 0 + ); + object.setVisible( + this.drawObjectiveAreaMarker(object, tileX, tileY, radius) + ); + return; + } + const offsetX = (object.getData('tileOffsetX') as number | undefined) ?? 0; const offsetY = (object.getData('tileOffsetY') as number | undefined) ?? 0; const overlay = object as Phaser.GameObjects.GameObject & { @@ -7973,6 +8263,65 @@ export class BattleScene extends Phaser.Scene { }); } + private drawObjectiveAreaMarker( + graphics: Phaser.GameObjects.Graphics, + targetX: number, + targetY: number, + radius: number + ) { + graphics.clear(); + const minX = Math.max(0, this.cameraTileX, targetX - radius); + const maxX = Math.min( + battleMap.width - 1, + this.cameraTileX + this.layout.visibleColumns - 1, + targetX + radius + ); + const minY = Math.max(0, this.cameraTileY, targetY - radius); + const maxY = Math.min( + battleMap.height - 1, + this.cameraTileY + this.layout.visibleRows - 1, + targetY + radius + ); + let visibleTileCount = 0; + + for (let y = minY; y <= maxY; y += 1) { + for (let x = minX; x <= maxX; x += 1) { + const distance = this.tileDistanceTo(x, y, targetX, targetY); + if (distance > radius) { + continue; + } + + visibleTileCount += 1; + const center = distance === 0; + const boundary = distance === radius; + const screenX = this.tileTopLeftX(x); + const screenY = this.tileTopLeftY(y); + graphics.fillStyle(palette.gold, center ? 0.12 : 0.045); + graphics.fillRect( + screenX, + screenY, + this.layout.tileSize, + this.layout.tileSize + ); + if (center || boundary) { + graphics.lineStyle( + center ? 3 : 1, + palette.gold, + center ? 0.86 : 0.32 + ); + graphics.strokeRect( + screenX, + screenY, + this.layout.tileSize, + this.layout.tileSize + ); + } + } + } + + return visibleTileCount > 0; + } + private positionBondChainReadyMarkers() { this.bondChainReadyMarkerViews.forEach((view) => { [view.background, view.label].forEach((object) => { @@ -8410,7 +8759,7 @@ export class BattleScene extends Phaser.Scene { const left = this.layout.panelX + this.battleUiLength(18); const width = this.layout.panelWidth - this.battleUiLength(36); const multiline = text.includes('\n'); - const height = this.battleUiLength(multiline ? 50 : 32); + const height = this.battleUiLength(multiline ? 62 : 32); const quickTabsVisible = this.sideQuickTabLayout?.visible === true; const top = quickTabsVisible ? Math.max(this.sidePanelBodyTop(), this.sideContentBottom(6) - height) @@ -8439,7 +8788,14 @@ export class BattleScene extends Phaser.Scene { this.pointerFeedbackHintBg.setVisible(true); this.pointerFeedbackHintText.setDepth(backgroundDepth + 1); this.pointerFeedbackHintText.setPosition(left + this.battleUiLength(12), top + this.battleUiLength(8)); - this.pointerFeedbackHintText.setText(this.truncateUiText(text, multiline ? 100 : 48)); + this.pointerFeedbackHintText.setWordWrapWidth( + width - this.battleUiLength(24), + true + ); + this.pointerFeedbackHintText.setMaxLines(multiline ? 3 : 1); + this.pointerFeedbackHintText.setText( + this.truncateUiText(text, multiline ? 120 : 48) + ); this.pointerFeedbackHintText.setVisible(true); } @@ -8630,7 +8986,7 @@ export class BattleScene extends Phaser.Scene { private handleBattleKeyboardNavigation(event: KeyboardEvent) { if ( - (this.phase !== 'moving' && this.phase !== 'targeting') || + (this.phase !== 'idle' && this.phase !== 'moving' && this.phase !== 'targeting') || this.battleEventObjects.length > 0 || this.commandMenuObjects.length > 0 || this.mapMenuObjects.length > 0 || @@ -8658,6 +9014,31 @@ export class BattleScene extends Phaser.Scene { } event.preventDefault(); + if (this.phase === 'idle' && this.activeFaction === 'ally') { + const tutorialUnitId = this.firstBattleTutorialStep === 'select-unit' + ? this.firstBattleTutorialPath?.unitId + : undefined; + const allies = battleUnits.filter((unit) => ( + unit.faction === 'ally' && + unit.hp > 0 && + !this.actedUnitIds.has(unit.id) && + (!tutorialUnitId || unit.id === tutorialUnitId) + )); + const unit = this.nextKeyboardBattleCursor(allies, 'unit', direction, event.shiftKey ? -1 : 1); + if (unit) { + this.keyboardBattleCursor = { + mode: 'unit', + x: unit.x, + y: unit.y, + targetId: unit.id + }; + this.focusKeyboardBattleCursor(unit.x, unit.y); + this.updateUnitPointerFeedback(unit, unit, true); + soundDirector.playSelect(); + } + return true; + } + if (this.phase === 'moving' && this.selectedUnit) { const tiles = this.reachableTiles(this.selectedUnit).map(({ x, y }) => ({ x, y })); const tile = this.nextKeyboardBattleCursor(tiles, 'move', direction, event.shiftKey ? -1 : 1); @@ -8704,9 +9085,14 @@ export class BattleScene extends Phaser.Scene { const ordered = [...candidates].sort((left, right) => left.y - right.y || left.x - right.x); const current = this.keyboardBattleCursor?.mode === mode ? this.keyboardBattleCursor - : this.selectedUnit - ? { x: this.selectedUnit.x, y: this.selectedUnit.y } - : ordered[0]; + : mode === 'unit' + ? undefined + : this.selectedUnit + ? { x: this.selectedUnit.x, y: this.selectedUnit.y } + : ordered[0]; + if (!current) { + return cycleStep > 0 ? ordered[0] : ordered[ordered.length - 1]; + } if (!direction) { const currentIndex = ordered.findIndex((candidate) => candidate.x === current.x && candidate.y === current.y); const start = currentIndex >= 0 ? currentIndex : cycleStep > 0 ? -1 : 0; @@ -8734,7 +9120,25 @@ export class BattleScene extends Phaser.Scene { private activateKeyboardBattleCursor() { const cursor = this.keyboardBattleCursor; - if (!cursor || !this.selectedUnit) { + if (!cursor) { + return false; + } + + if (cursor.mode === 'unit' && this.phase === 'idle' && cursor.targetId) { + const unit = battleUnits.find((candidate) => ( + candidate.id === cursor.targetId && + candidate.faction === 'ally' && + candidate.hp > 0 && + !this.actedUnitIds.has(candidate.id) + )); + if (!unit) { + return false; + } + this.selectUnit(unit); + return true; + } + + if (!this.selectedUnit) { return false; } @@ -9283,6 +9687,7 @@ export class BattleScene extends Phaser.Scene { this.selectedUsable = undefined; this.lockedTargetPreview = undefined; this.deploymentSelectedUnitId = this.deploymentDefaultSelectedUnit()?.id; + this.deploymentKeyboardFocus = { id: 'start', kind: 'start' }; this.deploymentNotice = this.deploymentDefaultNotice(); this.clearMarkers(); this.hideCommandMenu(); @@ -9715,6 +10120,221 @@ export class BattleScene extends Phaser.Scene { return this.deploymentSlots().find((slot) => slot.x === x && slot.y === y); } + private deploymentKeyboardItems(): DeploymentKeyboardFocus[] { + const units = this.deploymentRoleSummaries().map((summary) => ({ + id: `unit:${summary.unitId}`, + kind: 'unit' as const, + unitId: summary.unitId + })); + const slots = this.deploymentSlots().map((slot) => ({ + id: `slot:${slot.x},${slot.y}`, + kind: 'slot' as const, + x: slot.x, + y: slot.y + })); + return [ + ...units, + ...slots, + { id: 'restore', kind: 'restore' as const }, + { id: 'start', kind: 'start' as const } + ]; + } + + private deploymentKeyboardItemById(id: string) { + return this.deploymentKeyboardItems().find((item) => item.id === id); + } + + private handleDeploymentKeyboardNavigationKey(event: KeyboardEvent) { + if ( + this.phase !== 'deployment' || + this.battleEventObjects.length > 0 || + this.commandMenuObjects.length > 0 || + this.mapMenuObjects.length > 0 || + this.tacticalInitiativePanelObjects.length > 0 || + this.saveSlotPanelObjects.length > 0 || + this.turnPromptObjects.length > 0 || + this.combatCutInObjects.length > 0 || + this.resultObjects.length > 0 + ) { + return false; + } + + if (event.code === 'Space') { + event.preventDefault(); + this.activateDeploymentKeyboardFocus(); + return true; + } + + const step = + event.code === 'ArrowLeft' || event.code === 'ArrowUp' + ? -1 + : event.code === 'ArrowRight' || event.code === 'ArrowDown' + ? 1 + : event.code === 'Tab' + ? event.shiftKey + ? -1 + : 1 + : 0; + if (step === 0) { + return false; + } + + event.preventDefault(); + const items = this.deploymentKeyboardItems(); + if (items.length === 0) { + return true; + } + const currentIndex = this.deploymentKeyboardFocus + ? items.findIndex((item) => item.id === this.deploymentKeyboardFocus?.id) + : -1; + const nextIndex = currentIndex < 0 + ? step > 0 + ? 0 + : items.length - 1 + : (currentIndex + step + items.length) % items.length; + this.setDeploymentKeyboardFocus(items[nextIndex]); + return true; + } + + private setDeploymentKeyboardFocus(item: DeploymentKeyboardFocus) { + this.deploymentKeyboardFocus = { ...item }; + this.deploymentNotice = this.deploymentKeyboardFocusNotice(item); + const focusedUnit = item.unitId + ? battleUnits.find((unit) => unit.id === item.unitId && unit.hp > 0) + : undefined; + const tile = item.kind === 'slot' && item.x !== undefined && item.y !== undefined + ? { x: item.x, y: item.y } + : focusedUnit + ? { x: focusedUnit.x, y: focusedUnit.y } + : undefined; + if (tile && !this.isTileVisible(tile.x, tile.y)) { + this.centerCameraOnTile(tile.x, tile.y); + } + this.renderDeploymentMap(); + this.renderDeploymentPanel(); + if (tile) { + this.updateDeploymentPointerFeedback(tile, true); + } else { + this.clearPointerFeedback(); + } + soundDirector.playSelect(); + } + + private deploymentKeyboardFocusNotice(item: DeploymentKeyboardFocus) { + if (item.kind === 'unit' && item.unitId) { + const unit = battleUnits.find((candidate) => candidate.id === item.unitId); + return unit + ? `${unit.name} 선택 항목입니다. Enter로 집은 뒤 배치 칸을 골라 위치를 바꿉니다.` + : '장수 선택 항목입니다.'; + } + if (item.kind === 'slot' && item.x !== undefined && item.y !== undefined) { + const slot = this.deploymentSlotAt(item.x, item.y); + const occupant = this.unitAtTile(item.x, item.y); + return occupant + ? `${occupant.name}의 ${slot?.label ?? '시작 위치'}입니다. Enter로 선택하거나 다른 장수와 교환합니다.` + : `${slot?.label ?? '시작 위치'}입니다. Enter로 선택한 장수를 이곳에 배치합니다.`; + } + if (item.kind === 'restore') { + return this.deploymentChangeCount() > 0 + ? '추천 배치 복원입니다. Enter로 모든 시작 위치를 원래 추천안으로 되돌립니다.' + : '현재 추천 배치를 유지하고 있습니다.'; + } + return '전투 시작입니다. Enter를 누르면 현재 배치를 확정하고 전장으로 나갑니다.'; + } + + private activateDeploymentKeyboardFocus() { + if (this.phase !== 'deployment') { + return false; + } + const item = this.deploymentKeyboardFocus ?? this.deploymentKeyboardItemById('start'); + if (!item) { + return false; + } + this.deploymentKeyboardFocus = { ...item }; + if (item.kind === 'unit' && item.unitId) { + const unit = battleUnits.find((candidate) => candidate.id === item.unitId && candidate.hp > 0); + if (!unit) { + return false; + } + this.handleDeploymentUnitClick(unit); + return true; + } + if (item.kind === 'slot' && item.x !== undefined && item.y !== undefined) { + this.handleDeploymentTileClick(item.x, item.y); + return true; + } + if (item.kind === 'restore') { + this.restoreRecommendedDeployment(); + return true; + } + soundDirector.playSelect(); + this.confirmPreBattleDeployment(); + return true; + } + + private deploymentKeyboardDebugState() { + const visible = this.phase === 'deployment'; + if (!visible) { + return { + visible: false, + focusedItem: null, + items: [], + focusMarker: null, + guidance: { + text: 'Tab/방향키 이동 · Enter/Space 선택 · Esc 선택 해제', + bounds: null + } + }; + } + + const roleSummaries = this.deploymentRoleSummaries(); + const items = this.deploymentKeyboardItems().map((item) => { + const roleIndex = item.unitId + ? roleSummaries.findIndex((summary) => summary.unitId === item.unitId) + : -1; + const bounds = item.kind === 'unit' && roleIndex >= 0 + ? this.deploymentPanelLayout?.roleBounds[roleIndex] ?? null + : item.kind === 'slot' && item.x !== undefined && item.y !== undefined + ? { + x: this.tileTopLeftX(item.x), + y: this.tileTopLeftY(item.y), + width: this.layout.tileSize, + height: this.layout.tileSize + } + : item.kind === 'restore' + ? this.deploymentPanelLayout?.restoreButtonBounds ?? null + : item.kind === 'start' + ? this.deploymentPanelLayout?.startButtonBounds ?? null + : null; + const focused = item.id === this.deploymentKeyboardFocus?.id; + return { + ...item, + available: true, + focused, + lineWidth: focused ? this.battleUiLength(3) : this.battleUiLength(1), + bounds: bounds ? { ...bounds } : null + }; + }); + const focusedItem = items.find((item) => item.focused) ?? null; + return { + visible: true, + focusedItem, + items, + focusMarker: focusedItem?.bounds + ? { + bounds: { ...focusedItem.bounds }, + lineWidth: focusedItem.lineWidth + } + : null, + guidance: { + text: 'Tab/방향키 이동 · Enter/Space 선택 · Esc 선택 해제', + bounds: this.deploymentPanelLayout?.keyboardGuidanceBounds + ? { ...this.deploymentPanelLayout.keyboardGuidanceBounds } + : null + } + }; + } + private renderDeploymentMap() { this.clearDeploymentObjects(); const selected = this.deploymentSelectedUnit(); @@ -9723,10 +10343,15 @@ export class BattleScene extends Phaser.Scene { const occupant = this.unitAtTile(slot.x, slot.y); const recommendedUnit = slot.unitId ? battleUnits.find((unit) => unit.id === slot.unitId) : undefined; const selectedTile = selected?.x === slot.x && selected?.y === slot.y; + const keyboardFocused = + this.deploymentKeyboardFocus?.kind === 'slot' && + this.deploymentKeyboardFocus.x === slot.x && + this.deploymentKeyboardFocus.y === slot.y; const recommended = Boolean(slot.unitId); - const fillAlpha = selectedTile ? 0.34 : recommended ? 0.2 : 0.12; - const strokeAlpha = selectedTile ? 1 : recommended ? 0.86 : 0.46; - const strokeWidth = selectedTile ? 3 : recommended ? 2 : 1; + const fillAlpha = keyboardFocused ? 0.4 : selectedTile ? 0.34 : recommended ? 0.2 : 0.12; + const strokeAlpha = keyboardFocused || selectedTile ? 1 : recommended ? 0.86 : 0.46; + const strokeWidth = keyboardFocused ? 4 : selectedTile ? 3 : recommended ? 2 : 1; + const strokeTone = keyboardFocused ? 0xffffff : slot.tone; const marker = this.trackDeploymentObject( this.add.rectangle(this.tileTopLeftX(slot.x), this.tileTopLeftY(slot.y), this.layout.tileSize, this.layout.tileSize, slot.tone, fillAlpha) @@ -9736,7 +10361,7 @@ export class BattleScene extends Phaser.Scene { marker.setData('tileY', slot.y); marker.setOrigin(0); marker.setDepth(4.75); - marker.setStrokeStyle(strokeWidth, slot.tone, strokeAlpha); + marker.setStrokeStyle(strokeWidth, strokeTone, strokeAlpha); if (this.mapMask) { marker.setMask(this.mapMask); } @@ -9754,6 +10379,12 @@ export class BattleScene extends Phaser.Scene { marker.on('pointerdown', (pointer: Phaser.Input.Pointer) => { if (pointer.leftButtonDown()) { this.suppressNextLeftClick = true; + this.deploymentKeyboardFocus = { + id: `slot:${slot.x},${slot.y}`, + kind: 'slot', + x: slot.x, + y: slot.y + }; this.handleDeploymentTileClick(slot.x, slot.y); } }); @@ -9904,6 +10535,18 @@ export class BattleScene extends Phaser.Scene { color: selected ? '#f2e3bf' : '#d4dce6', fontStyle: '700' })); + const keyboardGuidance = this.trackSideObject(this.add.text( + left + width - this.battleUiLength(10), + noticeTop + this.battleUiLength(7), + 'Tab/방향키 · Enter/Space · Esc 해제', + { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: this.battleUiFontSize(8), + color: '#b9d7ff', + fontStyle: '700' + } + )); + keyboardGuidance.setOrigin(1, 0); const noticeText = this.trackSideObject(this.add.text(left + this.battleUiLength(10), noticeTop + this.battleUiLength(22), this.deploymentNotice, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: this.battleUiFontSize(9), @@ -9928,13 +10571,30 @@ export class BattleScene extends Phaser.Scene { roleSummaries.forEach((summary, index) => { const rowTop = roleTop + index * roleStep; const active = selected?.id === summary.unitId; - const row = this.trackSideObject(this.add.rectangle(left, rowTop, width, roleRowHeight, active ? 0x21364a : 0x101820, active ? 0.98 : 0.92)); + const keyboardFocused = this.deploymentKeyboardFocus?.id === `unit:${summary.unitId}`; + const row = this.trackSideObject(this.add.rectangle( + left, + rowTop, + width, + roleRowHeight, + keyboardFocused ? 0x28465e : active ? 0x21364a : 0x101820, + keyboardFocused || active ? 0.98 : 0.92 + )); row.setOrigin(0); - row.setStrokeStyle(this.battleUiLength(1), active ? summary.tone : 0x53606c, active ? 0.86 : 0.52); + row.setStrokeStyle( + this.battleUiLength(keyboardFocused ? 3 : 1), + keyboardFocused ? 0xffffff : active ? summary.tone : 0x53606c, + keyboardFocused ? 1 : active ? 0.86 : 0.52 + ); row.setInteractive({ useHandCursor: true }); row.on('pointerdown', () => { const unit = battleUnits.find((candidate) => candidate.id === summary.unitId && candidate.hp > 0); if (unit) { + this.deploymentKeyboardFocus = { + id: `unit:${summary.unitId}`, + kind: 'unit', + unitId: summary.unitId + }; this.handleDeploymentUnitClick(unit); } }); @@ -9966,7 +10626,8 @@ export class BattleScene extends Phaser.Scene { width, startButtonLabel, palette.gold, - () => this.confirmPreBattleDeployment() + () => this.confirmPreBattleDeployment(), + 'start' ); this.renderDeploymentButton( left, @@ -9974,7 +10635,8 @@ export class BattleScene extends Phaser.Scene { width, recommendedDeployment ? '추천 배치 유지 중' : `추천 배치 복원 · ${deploymentChangeCount}명`, recommendedDeployment ? 0x647485 : palette.gold, - () => this.restoreRecommendedDeployment() + () => this.restoreRecommendedDeployment(), + 'restore' ); this.deploymentPanelLayout = { headerBounds: { x: left, y: top, width, height: headerHeight }, @@ -9987,6 +10649,7 @@ export class BattleScene extends Phaser.Scene { recommended: recommendedDeployment, noticeBounds: { x: left, y: noticeTop, width, height: noticeHeight }, noticeTextBounds: this.gameObjectBoundsDebug(noticeText)!, + keyboardGuidanceBounds: this.gameObjectBoundsDebug(keyboardGuidance)!, roleBounds, restoreButtonBounds: { x: left, y: restoreButtonTop, width, height: buttonHeight }, startButtonBounds: { x: left, y: startButtonTop, width, height: buttonHeight }, @@ -9998,14 +10661,17 @@ export class BattleScene extends Phaser.Scene { private renderDeploymentObjectiveBrief(x: number, y: number, width: number) { const primary = battleScenario.objectives .filter((objective) => this.objectiveCategory(objective) !== 'bonus') - .map((objective) => `${this.objectiveCategoryLabel(this.objectiveCategory(objective))}: ${objective.label}`) + .map((objective) => ( + `${this.objectiveCategoryLabel(this.objectiveCategory(objective))}: ${objective.label}` + + (objective.rewardGold > 0 ? ` · +${objective.rewardGold}` : '') + )) .join(' / '); const bonus = battleScenario.objectives .filter((objective) => this.objectiveCategory(objective) === 'bonus') - .map((objective) => objective.label) + .map((objective) => `${objective.label}${objective.rewardGold > 0 ? ` · +${objective.rewardGold}` : ''}`) .join(' / '); - const primaryText = this.trackSideObject(this.add.text(x, y, this.truncateBattleLogText(primary, 34), { + const primaryText = this.trackSideObject(this.add.text(x, y, this.truncateBattleLogText(primary, 44), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: this.battleUiFontSize(10), color: '#f4dfad', @@ -10014,7 +10680,7 @@ export class BattleScene extends Phaser.Scene { })); primaryText.setDepth(32); - const bonusText = this.trackSideObject(this.add.text(x, y + this.battleUiLength(14), bonus ? this.truncateBattleLogText(`보조: ${bonus}`, 36) : '보조: 전장 상황에 맞춰 추가 공훈을 노립니다.', { + const bonusText = this.trackSideObject(this.add.text(x, y + this.battleUiLength(14), bonus ? this.truncateBattleLogText(`보조: ${bonus}`, 44) : '보조: 전장 상황에 맞춰 추가 공훈을 노립니다.', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: this.battleUiFontSize(9), color: '#9fb0bf', @@ -10051,17 +10717,25 @@ export class BattleScene extends Phaser.Scene { width: number, label: string, tone: number, - action: () => void + action: () => void, + keyboardFocusId: 'restore' | 'start' ) { const height = this.battleUiLength(30); - const button = this.trackSideObject(this.add.rectangle(x, y, width, height, 0x17232e, 0.96)); + const keyboardFocused = this.deploymentKeyboardFocus?.id === keyboardFocusId; + const baseFill = keyboardFocused ? 0x28465e : 0x17232e; + const button = this.trackSideObject(this.add.rectangle(x, y, width, height, baseFill, 0.96)); button.setOrigin(0); - button.setStrokeStyle(this.battleUiLength(1), tone, 0.86); + button.setStrokeStyle( + this.battleUiLength(keyboardFocused ? 3 : 1), + keyboardFocused ? 0xffffff : tone, + keyboardFocused ? 1 : 0.86 + ); button.setInteractive({ useHandCursor: true }); button.on('pointerover', () => button.setFillStyle(0x243746, 0.98)); - button.on('pointerout', () => button.setFillStyle(0x17232e, 0.96)); + button.on('pointerout', () => button.setFillStyle(baseFill, 0.96)); button.on('pointerdown', () => { this.suppressNextLeftClick = true; + this.deploymentKeyboardFocus = { id: keyboardFocusId, kind: keyboardFocusId }; action(); }); @@ -10075,6 +10749,7 @@ export class BattleScene extends Phaser.Scene { text.setInteractive({ useHandCursor: true }); text.on('pointerdown', () => { this.suppressNextLeftClick = true; + this.deploymentKeyboardFocus = { id: keyboardFocusId, kind: keyboardFocusId }; action(); }); } @@ -10102,11 +10777,21 @@ export class BattleScene extends Phaser.Scene { return true; } - private handleDeploymentUnitClick(unit: UnitData) { + private handleDeploymentUnitClick( + unit: UnitData, + preserveKeyboardFocus = false + ) { if (this.phase !== 'deployment') { return; } + if (!preserveKeyboardFocus) { + this.deploymentKeyboardFocus = { + id: `unit:${unit.id}`, + kind: 'unit', + unitId: unit.id + }; + } if (unit.faction !== 'ally' || unit.hp <= 0) { this.deploymentNotice = '전열 배치 중에는 아군 시작 위치만 조정할 수 있습니다.'; this.renderDeploymentPanel(); @@ -10131,10 +10816,16 @@ export class BattleScene extends Phaser.Scene { return; } + this.deploymentKeyboardFocus = { + id: `slot:${x},${y}`, + kind: 'slot', + x, + y + }; const selected = this.deploymentSelectedUnit(); const occupant = this.unitAtTile(x, y); if (occupant?.faction === 'ally') { - this.handleDeploymentUnitClick(occupant); + this.handleDeploymentUnitClick(occupant, true); return; } @@ -10187,6 +10878,7 @@ export class BattleScene extends Phaser.Scene { } private restoreRecommendedDeployment() { + this.deploymentKeyboardFocus = { id: 'restore', kind: 'restore' }; const recommended = this.deploymentSlots().filter((slot) => slot.unitId); recommended.forEach((slot) => { const unit = battleUnits.find((candidate) => candidate.id === slot.unitId && candidate.hp > 0); @@ -10213,6 +10905,7 @@ export class BattleScene extends Phaser.Scene { return; } + this.deploymentKeyboardFocus = { id: 'start', kind: 'start' }; if (this.scenarioCombatAssetStatus !== 'ready' && this.scenarioCombatAssetStatus !== 'degraded') { this.pendingDeploymentConfirmation = true; this.pendingDeploymentSignature = this.deploymentSignature(); @@ -10225,6 +10918,7 @@ export class BattleScene extends Phaser.Scene { this.clearDeploymentObjects(); this.clearPointerFeedback(); this.deploymentSelectedUnitId = undefined; + this.deploymentKeyboardFocus = undefined; this.selectedUnit = undefined; this.pendingMove = undefined; this.phase = 'idle'; @@ -12324,8 +13018,11 @@ export class BattleScene extends Phaser.Scene { const successLabel = this.previewSuccessLabel(preview); const counterLabel = this.combatExchangeCompactLabel(exchange); const pendingBonusLabels = this.pendingEndBattleBonusObjectiveLabels(preview); + const pendingBonusRewardGold = this.pendingEndBattleBonusObjectiveReward(preview); const endsBattleWithPendingBonus = pendingBonusLabels.length > 0; - const warningText = endsBattleWithPendingBonus ? this.previewObjectiveWarningSummary(pendingBonusLabels) : undefined; + const warningText = endsBattleWithPendingBonus + ? this.previewObjectiveWarningSummary(pendingBonusLabels, pendingBonusRewardGold) + : undefined; if (warningText && !objectiveWarningNotice) { objectiveWarningNotice = { icon: 'failure', text: warningText, tone: 0xd8732c }; } @@ -12342,7 +13039,7 @@ export class BattleScene extends Phaser.Scene { { icon: this.previewDamageIcon(preview), label: '피해', value: `${preview.damage}`, tone: 0xb64a45 }, { icon: action === 'strategy' ? 'success' : 'hit', label: successLabel, value: `${preview.hitRate}%`, tone: this.rateTone(preview.hitRate) }, endsBattleWithPendingBonus - ? { icon: 'failure', label: '보조', value: '미확보', tone: 0xd8732c } + ? { icon: 'failure', label: '보조', value: `+${pendingBonusRewardGold} 포기`, tone: 0xd8732c } : bondChainReady ? { icon: 'leadership', label: bondChainReady.coreResonance ? '핵심' : '추격', value: `${bondChainReady.rate}% · +${bondChainReady.damage}`, tone: palette.gold } : { @@ -12940,6 +13637,16 @@ export class BattleScene extends Phaser.Scene { } const usable = this.selectedUsable?.id === locked.usableId ? this.selectedUsable : undefined; + const tutorialPath = this.firstBattleTutorialPath; + if ( + this.firstBattleTutorialStep === 'target-preview' && + tutorialPath?.unitId === this.selectedUnit.id && + tutorialPath.targetId === target.id && + locked.action === 'attack' && + !usable + ) { + this.completeFirstBattleTutorial('completed'); + } void this.tryResolveDamageTarget(this.selectedUnit, target, locked.action, usable); return true; } @@ -13669,9 +14376,22 @@ export class BattleScene extends Phaser.Scene { .map(({ state }) => state.label); } - private previewObjectiveWarningSummary(labels: string[]) { + private pendingEndBattleBonusObjectiveReward(preview: CombatPreview) { + if (preview.defender.id !== leaderUnitId || preview.damage < preview.defender.hp) { + return 0; + } + return this.objectiveStates() + .filter((state) => state.category === 'bonus' && state.status !== 'done') + .filter((state) => battleScenario.objectives.find((definition) => definition.id === state.id)?.kind !== 'quick-victory') + .reduce((total, state) => total + state.rewardGold, 0); + } + + private previewObjectiveWarningSummary(labels: string[], rewardGold: number) { const targetLabel = labels.length > 1 ? `${labels[0]} 외 ${labels.length - 1}` : labels[0] ?? '보너스 목표'; - return `지휘관 격파 시 종료 · 미확보 ${this.truncateUiText(targetLabel, 18)}`; + const battleEndAction = battleScenario.id === 'first-battle-zhuo-commandery' + ? '한석 선봉 격퇴' + : '지휘관 격파'; + return `${battleEndAction} 시 종료 · 미확보 ${this.truncateUiText(targetLabel, 14)} · +${rewardGold} 포기`; } private previewActionTypeLabel(preview: CombatPreview) { @@ -13739,6 +14459,7 @@ export class BattleScene extends Phaser.Scene { const left = panelX + 24; const width = panelWidth - 48; const pendingBonusLabels = this.pendingEndBattleBonusObjectiveLabels(preview); + const pendingBonusRewardGold = this.pendingEndBattleBonusObjectiveReward(preview); const showsObjectiveWarning = pendingBonusLabels.length > 0; const height = locked ? (showsObjectiveWarning ? 500 : 464) : showsObjectiveWarning ? 466 : 430; const y = this.sideContentTop(); @@ -13834,26 +14555,40 @@ export class BattleScene extends Phaser.Scene { } } if (showsObjectiveWarning) { - this.renderPreviewObjectiveWarning(left + 10, y + 408, width - 20, pendingBonusLabels); + this.renderPreviewObjectiveWarning( + left + 10, + y + 408, + width - 20, + pendingBonusLabels, + pendingBonusRewardGold + ); } if (locked) { this.renderPreviewConfirmBar(left + 10, y + 426, width - 20, accent, `${preview.defender.name} 선택됨`); } } - private renderPreviewObjectiveWarning(x: number, y: number, width: number, labels: string[]) { + private renderPreviewObjectiveWarning(x: number, y: number, width: number, labels: string[], rewardGold: number) { const targetLabel = labels.length > 1 ? `${labels[0]} 외 ${labels.length - 1}` : labels[0] ?? '보너스 목표'; + const battleEndAction = battleScenario.id === 'first-battle-zhuo-commandery' + ? '한석 선봉 격퇴' + : '적장 격파'; const bg = this.trackSideObject(this.add.rectangle(x, y, width, 18, 0x2a1712, 0.98)); bg.setOrigin(0); bg.setStrokeStyle(1, 0xd8732c, 0.82); this.trackSideIcon(x + 12, y + 9, 'failure', 18); - this.trackSideObject(this.add.text(x + 25, y + 3, `적장 격파 시 종료 · 미확보: ${this.truncateUiText(targetLabel, 18)}`, { + this.trackSideObject(this.add.text( + x + 25, + y + 3, + `${battleEndAction} 시 종료 · 미확보: ${this.truncateUiText(targetLabel, 14)} · +${rewardGold} 포기`, + { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '10px', color: '#ffd0a8', fontStyle: '700', fixedWidth: width - 31 - })); + } + )); } private renderSupportPreviewSummaryCard(preview: SupportPreview, locked = false) { @@ -14922,7 +15657,14 @@ export class BattleScene extends Phaser.Scene { const targetId = objective.unitId ?? leaderUnitId; const target = battleUnits.find((unit) => unit.id === targetId); const defeated = this.isUnitDefeated(targetId); - const doneText = objective.kind === 'pacify-leader' ? '생포' : '격파'; + const firstHanSeokRetreat = + battleScenario.id === 'first-battle-zhuo-commandery' && + targetId === leaderUnitId; + const doneText = objective.kind === 'pacify-leader' + ? '생포' + : firstHanSeokRetreat + ? '퇴각' + : '격파'; const achieved = outcome ? outcome === 'victory' && defeated : defeated; return { id: objective.id, @@ -14932,7 +15674,11 @@ export class BattleScene extends Phaser.Scene { detail: defeated ? `${this.unitName(targetId)} ${doneText}` : `${this.unitName(targetId)} 병력 ${target?.hp ?? 0}/${target?.maxHp ?? 0}`, category, summary: defeated ? '완료' : '진행', - failureReason: outcome === 'defeat' && !defeated ? `${this.unitName(targetId)} 미격파` : undefined, + failureReason: outcome === 'defeat' && !defeated + ? firstHanSeokRetreat + ? '한석 선봉 미격퇴' + : `${this.unitName(targetId)} 미격파` + : undefined, targetTile: target ? { x: target.x, y: target.y, radius: 0 } : undefined, rewardGold: objective.rewardGold }; @@ -18634,6 +19380,7 @@ export class BattleScene extends Phaser.Scene { this.firstBattleTutorialCompletion = undefined; this.hideBattleEventBanner(); this.ensureFirstBattleTutorialPathVisible(); + this.syncFirstBattleTutorialKeyboardCursor(); this.renderFirstBattleTutorial(); } @@ -18724,6 +19471,59 @@ export class BattleScene extends Phaser.Scene { } } + private syncFirstBattleTutorialKeyboardCursor() { + const path = this.firstBattleTutorialPath; + const step = this.firstBattleTutorialStep; + if (!path || !step) { + return; + } + + if (step === 'select-unit') { + const unit = battleUnits.find((candidate) => candidate.id === path.unitId && candidate.hp > 0); + if (!unit) { + return; + } + this.keyboardBattleCursor = { + mode: 'unit', + x: unit.x, + y: unit.y, + targetId: unit.id + }; + this.focusKeyboardBattleCursor(unit.x, unit.y); + this.updateUnitPointerFeedback(unit, unit, true); + return; + } + + if (step === 'move' && this.selectedUnit?.id === path.unitId) { + this.keyboardBattleCursor = { + mode: 'move', + x: path.moveTile.x, + y: path.moveTile.y + }; + this.focusKeyboardBattleCursor(path.moveTile.x, path.moveTile.y); + this.updateMovePointerFeedback(this.selectedUnit, path.moveTile, true); + return; + } + + if (step === 'target-preview' && this.selectedUnit && this.targetingAction) { + const target = battleUnits.find((candidate) => candidate.id === path.targetId && candidate.hp > 0); + if (!target) { + return; + } + this.keyboardBattleCursor = { + mode: 'damage', + x: target.x, + y: target.y, + targetId: target.id + }; + this.focusKeyboardBattleCursor(target.x, target.y); + this.updateTargetingPointerFeedback(this.selectedUnit, target, true); + return; + } + + this.keyboardBattleCursor = undefined; + } + private firstBattleTutorialCopy() { const path = this.firstBattleTutorialPath; const unit = path ? battleUnits.find((candidate) => candidate.id === path.unitId) : undefined; @@ -18732,27 +19532,27 @@ export class BattleScene extends Phaser.Scene { case 'select-unit': return { title: '아군 선택', - body: `${unit?.name ?? '빛나는 아군'}을 클릭해 행동을 시작하세요.` + body: `${unit?.name ?? '빛나는 아군'}을 클릭하거나 Enter로 선택해 행동을 시작하세요.` }; case 'move': return { title: '권장 칸으로 이동', - body: `${unit?.name ?? '선택한 아군'}을 빛나는 이동 칸으로 전진시키세요.` + body: `빛나는 이동 칸을 클릭하거나 Enter로 ${unit?.name ?? '선택한 아군'}을 전진시키세요.` }; case 'attack-command': return { title: '공격 명령', - body: '열린 명령 카드에서 공격을 선택하세요.' + body: '열린 명령 카드에서 공격을 클릭하거나 Enter로 선택하세요.' }; case 'target-preview': return this.firstBattleTutorialTargetPreviewLocked ? { title: '공격 확정', - body: `피해 예측을 확인했습니다. ${target?.name ?? '같은 대상'}을 한 번 더 클릭해 실행하세요.` + body: `피해 예측을 확인했습니다. ${target?.name ?? '같은 대상'}을 다시 클릭하거나 Enter로 실행하세요.` } : { title: '공격 대상 확인', - body: `${target?.name ?? '빛나는 적'}을 클릭해 피해·명중 예측을 먼저 확인하세요.` + body: `${target?.name ?? '빛나는 적'}을 클릭하거나 Enter로 골라 피해·명중 예측을 확인하세요.` }; default: return { title: '', body: '' }; @@ -18869,7 +19669,7 @@ export class BattleScene extends Phaser.Scene { const feedback = this.trackFirstBattleTutorialObject(this.add.text( left + 22, top + 111, - this.firstBattleTutorialFeedback || '빛나는 표시를 따라 실제로 조작하세요. · 우클릭/ESC는 이전 단계', + this.firstBattleTutorialFeedback || '빛나는 표시를 따라 클릭/Enter로 조작하세요. · 우클릭/ESC는 이전 단계', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', @@ -19012,6 +19812,7 @@ export class BattleScene extends Phaser.Scene { this.firstBattleTutorialTargetPreviewLocked = false; } this.ensureFirstBattleTutorialPathVisible(); + this.syncFirstBattleTutorialKeyboardCursor(); this.renderFirstBattleTutorial(); } @@ -29373,7 +30174,11 @@ export class BattleScene extends Phaser.Scene { const leader = battleUnits.find((unit) => unit.id === leaderUnitId); const leaderProgress = leader && leader.hp > 0 ? `${leader.hp}/${leader.maxHp}` : '완료'; const bonusObjectives = this.objectiveStates(this.battleOutcome).filter((objective) => objective.category === 'bonus'); - const bonusSummary = bonusObjectives.map((objective) => `${objective.label} ${this.objectiveStatusText(objective)}`).join(' / '); + const bonusSummary = bonusObjectives + .map((objective) => ( + `${objective.label}${objective.rewardGold > 0 ? ` +${objective.rewardGold}` : ''} ${this.objectiveStatusText(objective)}` + )) + .join(' / '); const bonusText = bonusObjectives.length > 0 ? ` · 보조: ${this.truncateBattleLogText(bonusSummary, 38)}` : ''; @@ -29461,15 +30266,21 @@ export class BattleScene extends Phaser.Scene { })); categoryText.setOrigin(0.5); - this.trackSideObject(this.add.text(x + this.battleUiLength(42), y + this.battleUiLength(1), objective.label, { + const rewardLabel = objective.rewardGold > 0 ? ` · +${objective.rewardGold}` : ''; + this.trackSideObject(this.add.text( + x + this.battleUiLength(42), + y + this.battleUiLength(1), + this.truncateBattleLogText(`${objective.label}${rewardLabel}`, 28), + { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: this.battleUiFontSize(10), color: '#d4dce6', fontStyle: '700', fixedWidth: width - this.battleUiLength(91) - })); + } + )); - this.trackSideObject(this.add.text(x + this.battleUiLength(42), y + this.battleUiLength(13), this.truncateBattleLogText(objective.failureReason ?? objective.detail, 28), { + this.trackSideObject(this.add.text(x + this.battleUiLength(42), y + this.battleUiLength(13), this.truncateBattleLogText(this.objectiveDecisionDetail(objective), 34), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: this.battleUiFontSize(8), color: objective.failureReason ? '#ffb6a6' : '#9fb0bf', @@ -29485,6 +30296,18 @@ export class BattleScene extends Phaser.Scene { status.setOrigin(1, 0.5); } + private objectiveDecisionDetail(objective: BattleObjectiveState) { + const base = objective.failureReason ?? objective.detail; + const definition = battleScenario.objectives.find((candidate) => candidate.id === objective.id); + if (definition?.kind !== 'secure-terrain') { + return base; + } + const terrain = this.objectiveTerrain(definition); + const radius = definition.targetTile?.radius ?? 1; + const threatCount = this.enemiesThreateningObjective(definition, terrain, 2).length; + return `${base} · 반경 ${radius}칸 · 위협 ${threatCount}`; + } + private objectiveStatusText(objective: BattleObjectiveState) { if (objective.status === 'done') { return objective.summary || '완료'; @@ -31295,6 +32118,7 @@ export class BattleScene extends Phaser.Scene { : null, noticeBounds: { ...this.deploymentPanelLayout.noticeBounds }, noticeTextBounds: { ...this.deploymentPanelLayout.noticeTextBounds }, + keyboardGuidanceBounds: { ...this.deploymentPanelLayout.keyboardGuidanceBounds }, roleBounds: this.deploymentPanelLayout.roleBounds.map((bounds) => ({ ...bounds })), restoreButtonBounds: { ...this.deploymentPanelLayout.restoreButtonBounds }, startButtonBounds: { ...this.deploymentPanelLayout.startButtonBounds }, @@ -32257,6 +33081,7 @@ export class BattleScene extends Phaser.Scene { turnNumber: this.turnNumber, activeFaction: this.activeFaction, phase: this.phase, + deploymentKeyboard: this.deploymentKeyboardDebugState(), combatAssets: { status: this.scenarioCombatAssetStatus, deferredDuringDeployment: this.shouldShowPreBattleDeployment(), @@ -32530,9 +33355,13 @@ export class BattleScene extends Phaser.Scene { battleLog: [...this.battleLog], objectiveDefinitions: battleScenario.objectives.map((objective) => ({ id: objective.id, + label: objective.label, kind: objective.kind, terrain: objective.terrain ?? null, - targetTile: objective.targetTile ? { ...objective.targetTile } : null + targetTile: objective.targetTile ? { ...objective.targetTile } : null, + threatRadius: objective.threatRadius ?? null, + maxTurn: objective.maxTurn ?? null, + rewardGold: objective.rewardGold })), objectives: this.objectiveStates(this.battleOutcome).map((objective) => ({ ...objective })), battleStats: this.serializeBattleStats(), @@ -33129,30 +33958,88 @@ export class BattleScene extends Phaser.Scene { } private installDebugHotkeys() { + this.uninstallDebugHotkeys(); if (!this.debugToolsEnabled()) { return; } - this.input.keyboard?.on('keydown-F9', () => this.toggleDebugOverlay()); - this.input.keyboard?.on('keydown-F8', () => this.debugForceBattleOutcome('victory')); - this.input.keyboard?.on('keydown-F10', () => { - const state = this.getDebugState(); - console.info('[Battle Debug]', state); - if (state.firstSortieRoleEffects.active) { - console.info('[First Sortie Role Debug]', JSON.stringify({ - effects: state.firstSortieRoleEffects, - probe: state.firstSortieRoleMechanicsProbe - })); - } - console.info('[Counterplay Debug]', JSON.stringify(state.counterplayFeedback)); - console.info('[Tactical Initiative Debug]', JSON.stringify(state.tacticalInitiative)); - this.updateDebugOverlay(); - }); - this.input.on('pointermove', () => this.updateDebugOverlay()); - this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => { - this.debugOverlay?.destroy(); - this.debugOverlay = undefined; - }); + this.input.keyboard?.on( + 'keydown-F9', + this.handleDebugOverlayToggle, + this + ); + this.input.keyboard?.on( + 'keydown-F8', + this.handleDebugVictory, + this + ); + this.input.keyboard?.on( + 'keydown-F10', + this.handleDebugDump, + this + ); + this.input.on( + 'pointermove', + this.handleDebugPointerMove, + this + ); + } + + private uninstallDebugHotkeys() { + this.input.keyboard?.off( + 'keydown-F9', + this.handleDebugOverlayToggle, + this + ); + this.input.keyboard?.off( + 'keydown-F8', + this.handleDebugVictory, + this + ); + this.input.keyboard?.off( + 'keydown-F10', + this.handleDebugDump, + this + ); + this.input.off( + 'pointermove', + this.handleDebugPointerMove, + this + ); + this.debugOverlay?.destroy(); + this.debugOverlay = undefined; + } + + private handleDebugOverlayToggle() { + this.toggleDebugOverlay(); + } + + private handleDebugVictory() { + this.debugForceBattleOutcome('victory'); + } + + private handleDebugDump() { + const state = this.getDebugState(); + console.info('[Battle Debug]', state); + if (state.firstSortieRoleEffects.active) { + console.info('[First Sortie Role Debug]', JSON.stringify({ + effects: state.firstSortieRoleEffects, + probe: state.firstSortieRoleMechanicsProbe + })); + } + console.info( + '[Counterplay Debug]', + JSON.stringify(state.counterplayFeedback) + ); + console.info( + '[Tactical Initiative Debug]', + JSON.stringify(state.tacticalInitiative) + ); + this.updateDebugOverlay(); + } + + private handleDebugPointerMove() { + this.updateDebugOverlay(); } private debugToolsEnabled() { diff --git a/src/game/scenes/lazyScenes.ts b/src/game/scenes/lazyScenes.ts index ef69092..7152b20 100644 --- a/src/game/scenes/lazyScenes.ts +++ b/src/game/scenes/lazyScenes.ts @@ -81,5 +81,8 @@ export async function startGameScene(scene: Phaser.Scene, key: string, data?: Re export async function startLazySceneFromGame(game: Phaser.Game, key: LazySceneKey, data?: Record) { await ensureLazyScene(game, key); + game.scene.getScenes(true).forEach((activeScene) => { + game.scene.stop(activeScene.scene.key); + }); game.scene.start(key, data); }