diff --git a/package.json b/package.json index 20c1d3c..de9b0bb 100644 --- a/package.json +++ b/package.json @@ -79,9 +79,11 @@ "verify:flow:monolith": "node scripts/verify-flow.mjs", "verify:flow:segment": "node scripts/verify-flow.mjs", "verify:interaction-ux": "node scripts/verify-interaction-ux.mjs", + "verify:battle-keyboard:browser": "node scripts/verify-battle-keyboard-command-flow-browser.mjs", + "verify:title-accessibility:browser": "node scripts/verify-title-accessibility-browser.mjs", "verify:save-flow": "node scripts/verify-save-retry-flow.mjs", "verify:release": "node scripts/verify-release-candidate.mjs", - "verify:local-release": "pnpm run verify:static-data && pnpm run verify:unit-action-assets && pnpm run verify:enemy-intent && tsc --noEmit && pnpm run build && pnpm run verify:unit-action-assets:dist && pnpm run verify:loader-lifecycle && pnpm run verify:interaction-ux && pnpm run verify:city-stays:browser && pnpm run verify:xuzhou-equipment-link:browser && pnpm run verify:xuzhou-stay-payoff:browser && pnpm run verify:prologue-village:browser && pnpm run verify:first-pursuit-camp:browser && pnpm run verify:second-battle-relief:browser && pnpm run verify:third-battle-return:browser && pnpm run verify:third-camp-preparation:browser && pnpm run verify:av-feedback:browser && pnpm run verify:release && pnpm run verify:performance", + "verify:local-release": "pnpm run verify:static-data && pnpm run verify:unit-action-assets && pnpm run verify:enemy-intent && tsc --noEmit && pnpm run build && pnpm run verify:unit-action-assets:dist && pnpm run verify:loader-lifecycle && pnpm run verify:interaction-ux && pnpm run verify:title-accessibility:browser && pnpm run verify:battle-keyboard:browser && pnpm run verify:city-stays:browser && pnpm run verify:xuzhou-equipment-link:browser && pnpm run verify:xuzhou-stay-payoff:browser && pnpm run verify:prologue-village:browser && pnpm run verify:first-pursuit-camp:browser && pnpm run verify:second-battle-relief:browser && pnpm run verify:third-battle-return:browser && pnpm run verify:third-camp-preparation:browser && pnpm run verify:av-feedback:browser && pnpm run verify:release && pnpm run verify:performance", "verify:1.0": "pnpm run verify:local-release && pnpm run verify:flow && pnpm run qa:campaign-complete && pnpm run qa:battle-maps:webgl", "verify:public-deploy": "node scripts/verify-public-deploy.mjs", "qa:representative": "node scripts/qa-representative-battles.mjs", diff --git a/scripts/verify-battle-keyboard-command-flow-browser.mjs b/scripts/verify-battle-keyboard-command-flow-browser.mjs new file mode 100644 index 0000000..da8076b --- /dev/null +++ b/scripts/verify-battle-keyboard-command-flow-browser.mjs @@ -0,0 +1,551 @@ +import { spawn, spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { chromium } from 'playwright'; +import { + desktopBrowserContextOptions, + desktopBrowserViewport +} from './desktop-browser-viewport.mjs'; + +const battleId = 'second-battle-yellow-turban-pursuit'; +const renderer = process.env.VERIFY_BATTLE_KEYBOARD_RENDERER; +if (!renderer) { + for (const requestedRenderer of ['canvas', 'webgl']) { + const result = spawnSync( + process.execPath, + [fileURLToPath(import.meta.url)], + { + cwd: process.cwd(), + env: { + ...process.env, + VERIFY_BATTLE_KEYBOARD_RENDERER: requestedRenderer + }, + stdio: 'inherit' + } + ); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } + } + process.exit(0); +} +assert( + renderer === 'canvas' || renderer === 'webgl', + `Unsupported renderer: ${renderer}` +); +const baseUrl = + process.env.VERIFY_BATTLE_KEYBOARD_URL ?? + 'http://127.0.0.1:41796/heros_web/'; +const targetUrl = withDebugOptions(baseUrl, renderer); + +let serverProcess; +let browser; + +try { + serverProcess = await ensureLocalServer(targetUrl); + browser = await chromium.launch({ + headless: process.env.VERIFY_BATTLE_KEYBOARD_HEADLESS !== '0' + }); + const context = await browser.newContext(desktopBrowserContextOptions); + const page = await context.newPage(); + page.setDefaultTimeout(30000); + + const pageErrors = []; + const consoleErrors = []; + page.on('pageerror', (error) => pageErrors.push(error.stack ?? error.message)); + page.on('console', (message) => { + if (message.type() === 'error') { + consoleErrors.push(message.text()); + } + }); + + await page.goto(targetUrl, { + waitUntil: 'domcontentloaded', + timeout: 90000 + }); + await page.evaluate(() => window.localStorage.clear()); + await page.reload({ + waitUntil: 'domcontentloaded', + timeout: 90000 + }); + await waitForDebugApi(page); + await assertDesktopViewport(page); + await assertRendererAndBasePath(page, renderer); + + await page.evaluate((requestedBattleId) => { + window.__HEROS_DEBUG__?.goToBattle(requestedBattleId); + }, battleId); + await page.waitForFunction( + (requestedBattleId) => { + const battle = window.__HEROS_DEBUG__?.battle(); + return ( + battle?.battleId === requestedBattleId && + ['deployment', 'idle'].includes(battle.phase) && + battle.mapBackgroundReady === true && + (battle.phase === 'idle' || + ['ready', 'degraded'].includes(battle.combatAssets?.status)) + ); + }, + battleId, + { timeout: 90000 } + ); + + await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + if (scene?.phase === 'deployment') { + scene.confirmPreBattleDeployment(); + } + }); + await page.waitForFunction( + (requestedBattleId) => { + const battle = window.__HEROS_DEBUG__?.battle(); + return battle?.battleId === requestedBattleId && battle.phase === 'idle'; + }, + battleId, + { 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; + } + + 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); + + 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 + }; + }); + + 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)}` + ); + + await page.screenshot({ + path: `dist/verification-battle-keyboard-command-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('Shift+Tab'); + let 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)}` + ); + + 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)}` + ); + + 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); + } + assert( + visitedCommands.at(-1) === initialCommandFocus && + visitedCommands.every((id) => availableCommandIds.includes(id)), + `Command focus must wrap and skip every unavailable row: ${JSON.stringify({ + availableCommandIds, + visitedCommands + })}` + ); + + await focusMenuItem(page, 'strategy'); + const beforeUsable = await readBattleState(page); + 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)}` + ); + + 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)}` + ); + + 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; + return ( + keyboard?.visible === true && + keyboard.mode === 'command' && + keyboard.focusedItem?.id === 'strategy' + ); + }); + 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)}` + ); + + await focusMenuItem(page, 'wait'); + await page.keyboard.press('Enter'); + await page.waitForFunction( + (unitId) => { + const battle = window.__HEROS_DEBUG__?.battle(); + return ( + battle?.commandMenuKeyboard?.visible === false && + battle?.actedUnitIds?.includes(unitId) + ); + }, + fixture.unitId + ); + + const completed = await page.evaluate((unitId) => { + const state = window.__HEROS_DEBUG__?.battle(); + const qa = window.__HEROS_KEYBOARD_COMMAND_QA__; + if (qa?.recordPointerInput) { + window.removeEventListener('mousedown', qa.recordPointerInput, true); + window.removeEventListener('pointerdown', qa.recordPointerInput, true); + } + return { + selectedUnitId: state?.selectedUnitId ?? null, + pendingMove: state?.pendingMove ?? null, + acted: state?.actedUnitIds?.includes(unitId) ?? false, + commandMenu: state?.commandMenuKeyboard ?? null, + pointerInputCount: qa?.pointerInputCount ?? -1 + }; + }, 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)}` + ); + assert( + pageErrors.length === 0, + `Unexpected browser errors: ${JSON.stringify(pageErrors.slice(-5))}` + ); + assert( + consoleErrors.length === 0, + `Unexpected console errors: ${JSON.stringify(consoleErrors.slice(-5))}` + ); + + 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.' + ); +} finally { + await browser?.close(); + await stopServerProcess(serverProcess); +} + +function withDebugOptions(url, requestedRenderer) { + const parsed = new URL(url); + parsed.searchParams.set('debug', '1'); + parsed.searchParams.set('renderer', requestedRenderer); + parsed.searchParams.set('debugCombatAssetWatchdogMs', '250'); + return parsed.toString(); +} + +async function focusMenuItem(page, expectedId) { + for (let attempt = 0; attempt < 8; attempt += 1) { + const keyboard = await page.evaluate( + () => window.__HEROS_DEBUG__?.battle()?.commandMenuKeyboard + ); + assertCommandMenuFocus(keyboard, keyboard?.mode); + if (keyboard.focusedItem.id === expectedId) { + return; + } + const target = keyboard.items.find(({ id }) => id === expectedId); + assert( + target?.available, + `Expected ${expectedId} to be keyboard-selectable: ${JSON.stringify(keyboard)}` + ); + await page.keyboard.press('Tab'); + } + throw new Error(`Could not focus command menu item ${expectedId}.`); +} + +function assertCommandMenuFocus(keyboard, expectedMode) { + assert( + keyboard?.visible === true && + keyboard.mode === expectedMode && + keyboard.focusedItem?.available === true && + keyboard.focusedItem.bounds && + keyboard.focusedItem.lineWidth >= 3 && + keyboard.focusMarker?.visible === true && + keyboard.focusMarker.text === '▶' && + keyboard.focusMarker.bounds && + boundsInside(keyboard.focusMarker.bounds, keyboard.focusedItem.bounds) && + keyboard.guidance?.text.includes('↑↓/Tab') && + keyboard.guidance.text.includes('Enter') && + keyboard.guidance.text.includes('Esc') && + boundsInsideViewport(keyboard.guidance.bounds), + `Expected one visible non-color keyboard focus and on-screen controls guidance: ${JSON.stringify(keyboard)}` + ); + assert( + keyboard.items.filter(({ focused }) => focused).length === 1, + `Expected exactly one focused menu item: ${JSON.stringify(keyboard)}` + ); +} + +async function readBattleState(page) { + return page.evaluate(() => window.__HEROS_DEBUG__?.battle()); +} + +function boundsInside(inner, outer) { + return ( + inner.x >= outer.x - 1 && + inner.y >= outer.y - 1 && + inner.x + inner.width <= outer.x + outer.width + 1 && + inner.y + inner.height <= outer.y + outer.height + 1 + ); +} + +function boundsInsideViewport(bounds) { + return ( + bounds && + bounds.x >= 0 && + bounds.y >= 0 && + bounds.x + bounds.width <= desktopBrowserViewport.width && + bounds.y + bounds.height <= desktopBrowserViewport.height + ); +} + +async function waitForDebugApi(page) { + await page.waitForFunction( + () => + Boolean( + window.__HEROS_DEBUG__?.activeScenes && + window.__HEROS_DEBUG__?.goToBattle + ), + undefined, + { timeout: 90000 } + ); +} + +async function assertDesktopViewport(page) { + const viewport = await page.evaluate(() => { + const canvas = document.querySelector('canvas'); + return { + width: window.innerWidth, + height: window.innerHeight, + dpr: window.devicePixelRatio, + zoom: window.visualViewport?.scale ?? 1, + canvas: canvas + ? { width: canvas.width, height: canvas.height } + : null + }; + }); + assert( + viewport.width === desktopBrowserViewport.width && + viewport.height === desktopBrowserViewport.height && + viewport.dpr === 1 && + viewport.zoom === 1 && + viewport.canvas?.width === desktopBrowserViewport.width && + viewport.canvas?.height === desktopBrowserViewport.height, + `Expected explicit 1920x1080 CSS viewport, 100% zoom, and DPR1: ${JSON.stringify(viewport)}` + ); +} + +async function assertRendererAndBasePath(page, requestedRenderer) { + const runtime = await page.evaluate(() => ({ + pathname: window.location.pathname, + rendererType: window.__HEROS_GAME__?.renderer?.type ?? null, + rendererName: + window.__HEROS_GAME__?.renderer?.constructor?.name ?? null + })); + const expectedRendererType = requestedRenderer === 'canvas' ? 1 : 2; + assert( + runtime.pathname === '/heros_web/' && + runtime.rendererType === expectedRendererType, + `Expected ${requestedRenderer} under /heros_web/: ${JSON.stringify(runtime)}` + ); +} + +async function ensureLocalServer(url) { + if (await canReach(url)) { + return undefined; + } + + const parsed = new URL(url); + if (!['localhost', '127.0.0.1', '0.0.0.0'].includes(parsed.hostname)) { + throw new Error(`No server responded at ${url}`); + } + + const stderr = []; + const child = spawn( + process.execPath, + [ + 'node_modules/vite/bin/vite.js', + '--host', + '127.0.0.1', + '--port', + parsed.port || '41796', + '--strictPort' + ], + { + cwd: process.cwd(), + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'] + } + ); + child.stderr.on('data', (chunk) => stderr.push(chunk.toString())); + child.stdout.on('data', () => {}); + + for (let attempt = 0; attempt < 120; attempt += 1) { + if (await canReach(url)) { + return child; + } + await delay(250); + } + + await stopServerProcess(child); + throw new Error( + `Vite server did not start at ${url}\n${stderr.join('')}` + ); +} + +async function canReach(url) { + try { + const response = await fetch(url, { + signal: AbortSignal.timeout(1000) + }); + return response.ok; + } catch { + return false; + } +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function stopServerProcess(child) { + if (!child || child.exitCode !== null) { + return; + } + + const exited = new Promise((resolve) => { + child.once('exit', resolve); + }); + child.kill(); + await Promise.race([exited, delay(5000)]); + if (child.exitCode === null) { + child.kill('SIGKILL'); + await Promise.race([exited, delay(2000)]); + } +} + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} diff --git a/scripts/verify-camp-reward-data.mjs b/scripts/verify-camp-reward-data.mjs index f5f570b..872f732 100644 --- a/scripts/verify-camp-reward-data.mjs +++ b/scripts/verify-camp-reward-data.mjs @@ -136,9 +136,11 @@ function validateCampInteractionUxGuards() { checkedGuardCount += 1; expectCampUx( - noticeMethod.includes('delay: this.campNoticeHoldDuration(message)') && + noticeMethod.includes('const holdDuration = this.campNoticeHoldDuration(message)') && + noticeMethod.includes('delay: holdDuration') && + noticeMethod.includes('this.time.delayedCall(holdDuration, removeNotice)') && noticeMethod.includes('text.height + 28'), - 'camp notices must size multi-line content and use a message-aware hold duration' + 'camp notices must size multi-line content, use a message-aware hold duration, and preserve it without fade motion' ); checkedGuardCount += 1; diff --git a/scripts/verify-third-battle-priority-return-browser.mjs b/scripts/verify-third-battle-priority-return-browser.mjs index 898bfb0..ef1e1d8 100644 --- a/scripts/verify-third-battle-priority-return-browser.mjs +++ b/scripts/verify-third-battle-priority-return-browser.mjs @@ -21,7 +21,8 @@ const fixtures = [ quickAchieved: false, sourceTurn: 34, expectedVariant: 'fort-recovery', - expectedTargetDialogueId: 'liu-guan-after-guangzong-road' + expectedTargetDialogueId: 'liu-guan-after-guangzong-road', + reducedMotion: true }, { id: 'canvas-fort-missed-quick-achieved', @@ -139,6 +140,14 @@ async function verifyFixture(browser, baseUrl, fixture) { }); await waitForDebugApi(page); await assertFhdViewport(page, fixture); + if (fixture.reducedMotion) { + await page.evaluate(() => { + window.localStorage.setItem( + 'heros-web:visual-motion-mode', + 'reduced' + ); + }); + } const seeded = await seedThirdVictoryCamp(page, fixture); assert.deepEqual( @@ -160,16 +169,50 @@ async function verifyFixture(browser, baseUrl, fixture) { await page.evaluate(async () => { await window.__HEROS_DEBUG__.goToCamp(); }); + await waitForPriorityReturn(page, fixture, false); + await page.evaluate(() => { + const scene = window.__HEROS_GAME__.scene.getScene('CampScene'); + scene.activeTab = 'visit'; + scene.scene.restart(); + }); let camp = await waitForPriorityReturn( page, fixture, - false + false, + true ); assert.equal(camp.campaign.step, 'third-camp'); assert.equal(camp.campBattleId, sourceBattleId); + assert.equal( + camp.activeTab, + 'dialogue', + `${fixture.id}: a pending priority return must reclaim focus after CampScene reuse.` + ); + if (fixture.reducedMotion) { + assert.equal( + camp.visualMotionReduced, + true, + `${fixture.id}: CampScene must honor the persisted reduced-motion preference.` + ); + assert.equal( + camp.campSkin?.transition?.last?.durationMs, + 0, + `${fixture.id}: reduced motion must remove the camp backdrop fade.` + ); + assert.equal( + camp.campSkin?.transition?.active, + false, + `${fixture.id}: reduced motion must not leave a camp transition active.` + ); + assert.equal( + camp.campSkin?.transition?.completedRevision, + camp.campSkin?.transition?.revision, + `${fixture.id}: the zero-duration camp transition must complete synchronously.` + ); + } assertPriorityReturn(camp.thirdBattlePriorityReturn, fixture, { completed: false, - requireRowBounds: false + requireRowBounds: true }); const dialogueTab = camp.campTabs.find( @@ -201,13 +244,6 @@ async function verifyFixture(browser, baseUrl, fixture) { `dist/verification-third-battle-priority-return-${fixture.id}-tab-badge.png` ); - await clickSceneBounds(page, 'CampScene', dialogueTab.bounds); - await page.waitForFunction( - () => - window.__HEROS_DEBUG__?.camp?.()?.activeTab === - 'dialogue' - ); - camp = await waitForPriorityReturn(page, fixture, false, true); const priorityReturn = camp.thirdBattlePriorityReturn; assertPriorityReturn(priorityReturn, fixture, { completed: false, diff --git a/scripts/verify-title-accessibility-browser.mjs b/scripts/verify-title-accessibility-browser.mjs new file mode 100644 index 0000000..70fa7e7 --- /dev/null +++ b/scripts/verify-title-accessibility-browser.mjs @@ -0,0 +1,299 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { chromium } from 'playwright'; +import { + desktopBrowserContextOptions, + desktopBrowserDeviceScaleFactor, + desktopBrowserViewport +} from './desktop-browser-viewport.mjs'; + +const explicitUrl = process.env.TITLE_ACCESSIBILITY_QA_URL; +const serverUrl = new URL(explicitUrl ?? 'http://127.0.0.1:41764/heros_web/'); +let serverProcess; +let browser; + +try { + if (!explicitUrl) { + serverProcess = spawn( + process.execPath, + ['node_modules/vite/bin/vite.js', '--host', '127.0.0.1', '--port', '41764', '--strictPort'], + { cwd: process.cwd(), stdio: 'ignore', windowsHide: true } + ); + await waitForUrl(serverUrl, 20000); + } + + browser = await chromium.launch({ headless: true }); + for (const renderer of ['canvas', 'webgl']) { + await verifyRenderer(browser, renderer); + } + + console.info('Verified title keyboard focus, modal return, save-slot skipping, and live reduced motion in Canvas and WebGL.'); +} finally { + await browser?.close(); + await stopServerProcess(serverProcess); +} + +async function verifyRenderer(browserInstance, renderer) { + const context = await browserInstance.newContext(desktopBrowserContextOptions); + const page = await context.newPage(); + await page.emulateMedia({ reducedMotion: 'reduce' }); + const pageErrors = []; + const consoleErrors = []; + page.on('pageerror', (error) => pageErrors.push(error.message)); + page.on('console', (message) => { + if (message.type() === 'error') { + consoleErrors.push(message.text()); + } + }); + + try { + const targetUrl = new URL(serverUrl); + targetUrl.searchParams.set('debug', '1'); + targetUrl.searchParams.set('renderer', renderer); + await page.goto(targetUrl.href, { waitUntil: 'domcontentloaded', timeout: 90000 }); + await waitForTitle(page); + + const initial = await titleSnapshot(page); + assert.deepEqual(initial.viewport, { + width: desktopBrowserViewport.width, + height: desktopBrowserViewport.height, + dpr: desktopBrowserDeviceScaleFactor, + scale: 1 + }); + assert.deepEqual(initial.canvas, { x: 0, y: 0, width: 1920, height: 1080 }); + assert.equal(initial.title.focus.scope, 'main'); + assert.equal(initial.title.focus.itemId, 'new-game'); + assert.equal(focusItem(initial.title, 'continue').enabled, false); + assert.equal(focusItem(initial.title, 'new-game').focusIndicatorVisible, true); + assert.equal(initial.title.motion.mode, 'reduced'); + assert.equal(initial.title.motion.reduced, true); + assert.equal(initial.title.motion.activeInfiniteTweenCount, 0); + + await page.keyboard.press('ArrowDown'); + assert.equal((await titleState(page)).focus.itemId, 'settings', 'ArrowDown must skip disabled Continue.'); + await page.keyboard.press('Enter'); + let title = await waitForFocus(page, 'settings', 'sound'); + assert.equal(title.panels.settings, true); + + await page.keyboard.press('Space'); + for (const [expectedId, activationKey] of [ + ['music', 'Enter'], + ['effects', 'Space'], + ['ambience', 'Enter'], + ['combat-presentation', 'Space'] + ]) { + await page.keyboard.press('Tab'); + title = await waitForFocus(page, 'settings', expectedId); + await page.keyboard.press(activationKey); + assert.equal((await titleState(page)).focus.itemId, expectedId); + } + + await page.keyboard.press('Tab'); + title = await waitForFocus(page, 'settings', 'visual-motion'); + assert.equal(title.motion.reduced, true); + await page.keyboard.press('Enter'); + title = await titleState(page); + assert.equal(title.motion.mode, 'full'); + assert.equal(title.motion.reduced, false); + assert.equal(title.motion.activeInfiniteTweenCount, 36); + await page.keyboard.press('Space'); + title = await titleState(page); + assert.equal(title.motion.mode, 'reduced'); + assert.equal(title.motion.reduced, true); + assert.equal(title.motion.activeInfiniteTweenCount, 0); + + await page.keyboard.press('Tab'); + await waitForFocus(page, 'settings', 'close'); + await page.keyboard.press('Enter'); + title = await waitForFocus(page, 'main', 'settings'); + assert.equal(title.panels.settings, false); + + await page.keyboard.press('Enter'); + await waitForFocus(page, 'settings', 'sound'); + await page.keyboard.press('Shift+Tab'); + await waitForFocus(page, 'settings', 'close'); + await page.keyboard.press('Escape'); + await waitForFocus(page, 'main', 'settings'); + + const newGameBounds = focusItem(await titleState(page), 'new-game').bounds; + await page.mouse.move(newGameBounds.x + newGameBounds.width / 2, newGameBounds.y + newGameBounds.height / 2); + await waitForFocus(page, 'main', 'new-game'); + await page.mouse.move(4, 4); + title = await titleState(page); + assert.equal(title.focus.itemId, 'new-game', 'Pointer-out must preserve the persistent focus.'); + assert.equal(focusItem(title, 'new-game').focusIndicatorVisible, true); + + await seedMultipleSavesAndRestartTitle(page); + title = await waitForFocus(page, 'main', 'continue'); + assert.equal(title.motion.reduced, true); + assert.equal(title.motion.activeInfiniteTweenCount, 0); + + await page.keyboard.press('Enter'); + title = await waitForFocus(page, 'save-slots', 'slot-1'); + assert.equal(title.panels.saveSlots, true); + assert.equal(focusItem(title, 'slot-2').enabled, false); + await page.keyboard.press('ArrowDown'); + await waitForFocus(page, 'save-slots', 'slot-3'); + await page.keyboard.press('Tab'); + await waitForFocus(page, 'save-slots', 'close'); + await page.keyboard.press('Shift+Tab'); + await waitForFocus(page, 'save-slots', 'slot-3'); + await page.screenshot({ + path: `dist/qa-title-accessibility-${renderer}-1920x1080.png`, + fullPage: true + }); + + const slot1Bounds = focusItem(await titleState(page), 'slot-1').bounds; + await page.mouse.move(slot1Bounds.x + slot1Bounds.width / 2, slot1Bounds.y + slot1Bounds.height / 2); + await waitForFocus(page, 'save-slots', 'slot-1'); + await page.mouse.move(4, 4); + assert.equal((await titleState(page)).focus.itemId, 'slot-1'); + await page.keyboard.press('Escape'); + await waitForFocus(page, 'main', 'continue'); + + await page.keyboard.press('ArrowUp'); + await waitForFocus(page, 'main', 'new-game'); + await page.keyboard.press('Space'); + await waitForFocus(page, 'new-game-confirm', 'confirm'); + await page.keyboard.press('ArrowDown'); + await waitForFocus(page, 'new-game-confirm', 'cancel'); + await page.keyboard.press('Enter'); + await waitForFocus(page, 'main', 'new-game'); + + await page.keyboard.press('ArrowDown'); + await waitForFocus(page, 'main', 'continue'); + await page.keyboard.press('Space'); + await waitForFocus(page, 'save-slots', 'slot-1'); + await page.keyboard.press('ArrowDown'); + await waitForFocus(page, 'save-slots', 'slot-3'); + await page.evaluate(() => { + const scene = window.__HEROS_GAME__.scene.getScene('TitleScene'); + window.__TITLE_QA_ACTIVATED_SLOT__ = undefined; + scene.continueGame = (slot) => { + window.__TITLE_QA_ACTIVATED_SLOT__ = slot; + }; + }); + await page.keyboard.press('Space'); + await page.waitForFunction(() => window.__TITLE_QA_ACTIVATED_SLOT__ === 3); + + assert.deepEqual(pageErrors, [], `${renderer} page errors: ${pageErrors.join('\n')}`); + assert.deepEqual(consoleErrors, [], `${renderer} console errors: ${consoleErrors.join('\n')}`); + } finally { + await context.close(); + } +} + +async function seedMultipleSavesAndRestartTitle(page) { + await page.evaluate(() => { + const makeSave = (slot, updatedAt) => ({ + version: 1, + updatedAt, + step: 'prologue', + activeSaveSlot: slot + }); + const slot1 = makeSave(1, '2026-07-27T08:00:00.000Z'); + const slot3 = makeSave(3, '2026-07-27T09:00:00.000Z'); + window.localStorage.setItem('heros-web:campaign-state', JSON.stringify(slot1)); + window.localStorage.setItem('heros-web:campaign-state:slot-1', JSON.stringify(slot1)); + window.localStorage.removeItem('heros-web:campaign-state:slot-2'); + window.localStorage.setItem('heros-web:campaign-state:slot-3', JSON.stringify(slot3)); + window.__HEROS_GAME__.scene.getScene('TitleScene').scene.restart(); + }); + await waitForTitle(page); +} + +async function waitForTitle(page) { + await page.waitForFunction(() => { + const title = window.__HEROS_DEBUG__?.title?.(); + return title?.scene === 'TitleScene' && title.focus?.items?.length > 0; + }, undefined, { timeout: 90000 }); +} + +async function waitForFocus(page, scope, itemId) { + try { + await page.waitForFunction( + ({ expectedScope, expectedItemId }) => { + const focus = window.__HEROS_DEBUG__?.title?.()?.focus; + return focus?.scope === expectedScope && focus?.itemId === expectedItemId; + }, + { expectedScope: scope, expectedItemId: itemId }, + { timeout: 5000 } + ); + } catch (error) { + const actual = await titleState(page); + throw new Error( + `Timed out waiting for ${scope}/${itemId}; current focus is ${actual?.focus?.scope}/${actual?.focus?.itemId}.`, + { cause: error } + ); + } + return titleState(page); +} + +async function titleState(page) { + return page.evaluate(() => window.__HEROS_DEBUG__.title()); +} + +async function titleSnapshot(page) { + return page.evaluate(() => { + const canvasBounds = document.querySelector('canvas')?.getBoundingClientRect(); + return { + viewport: { + width: window.innerWidth, + height: window.innerHeight, + dpr: window.devicePixelRatio, + scale: window.visualViewport?.scale ?? 1 + }, + canvas: canvasBounds + ? { + x: canvasBounds.x, + y: canvasBounds.y, + width: canvasBounds.width, + height: canvasBounds.height + } + : null, + title: window.__HEROS_DEBUG__.title() + }; + }); +} + +function focusItem(title, id) { + const item = title.focus.items.find((candidate) => candidate.id === id); + assert(item, `Expected focus item ${id}: ${JSON.stringify(title.focus)}`); + return item; +} + +async function waitForUrl(url, timeoutMs) { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + try { + const response = await fetch(url); + if (response.ok) { + return; + } + } catch { + // The local development server is still starting. + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`Timed out waiting for ${url.href}`); +} + +async function stopServerProcess(child) { + if (!child || child.exitCode !== null) { + return; + } + + const exited = new Promise((resolve) => { + child.once('exit', resolve); + }); + child.kill(); + await Promise.race([exited, delay(5000)]); + if (child.exitCode === null) { + child.kill('SIGKILL'); + await Promise.race([exited, delay(2000)]); + } +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/scripts/verify-visual-motion-settings.mjs b/scripts/verify-visual-motion-settings.mjs index bc93481..b9115ca 100644 --- a/scripts/verify-visual-motion-settings.mjs +++ b/scripts/verify-visual-motion-settings.mjs @@ -114,6 +114,17 @@ try { titleSource.includes('ui(468)'), 'The title settings panel must expose the persisted motion toggle in its expanded layout.' ); + assert( + titleSource.includes('this.visualMotionReduced = isVisualMotionReduced();') && + titleSource.includes('this.applyTitleMotionPreference(this.visualMotionReduced);'), + 'TitleScene must honor the stored or operating-system motion preference on creation.' + ); + assert( + titleSource.includes('this.stopTitleMotionTweens();') && + titleSource.includes("this.applyTitleMotionPreference(nextMode === 'reduced');") && + titleSource.includes('activeInfiniteTweenCount: this.titleMotionTweens.length'), + 'The title motion toggle must immediately stop or rebuild its tracked infinite atmosphere tweens.' + ); const battleSource = readFileSync('src/game/scenes/BattleScene.ts', 'utf8'); assert( diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index fba7c3c..fd0740b 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -1728,6 +1728,24 @@ type CommandButton = { command: BattleCommand; background: Phaser.GameObjects.Rectangle; text: Phaser.GameObjects.Text; + available: boolean; + accent: number; +}; + +type UsableMenuButton = { + usable: BattleUsable; + background: Phaser.GameObjects.Rectangle; + text: Phaser.GameObjects.Text; + available: boolean; + accent: number; +}; + +type KeyboardCommandMenuEntry = { + id: string; + label: string; + available: boolean; + accent: number; + background: Phaser.GameObjects.Rectangle; }; type PendingMove = { @@ -3839,7 +3857,14 @@ export class BattleScene extends Phaser.Scene { private sidePanelTransitionActive = false; private sidePanelTransitionLast?: SidePanelTransitionSnapshot; private commandButtons: CommandButton[] = []; + private usableMenuButtons: UsableMenuButton[] = []; private commandMenuObjects: Phaser.GameObjects.GameObject[] = []; + private commandMenuMode?: 'command' | 'usable'; + private commandMenuUnitId?: string; + private commandMenuParentCommand?: UsableCommand; + private commandMenuAnchor?: { x: number; y: number }; + private commandMenuFocusedIndex = -1; + private commandMenuFocusMarker?: Phaser.GameObjects.Text; private deploymentObjects: Phaser.GameObjects.GameObject[] = []; private deploymentSelectedUnitId?: string; private deploymentNotice = '추천 배치는 이미 적용되어 있습니다. 필요하면 장수를 선택해 시작 구역 안에서 위치를 바꾸세요.'; @@ -4069,7 +4094,14 @@ export class BattleScene extends Phaser.Scene { this.terrainPanelLayout = undefined; this.sidePanelObjects = []; this.commandButtons = []; + this.usableMenuButtons = []; this.commandMenuObjects = []; + this.commandMenuMode = undefined; + this.commandMenuUnitId = undefined; + this.commandMenuParentCommand = undefined; + this.commandMenuAnchor = undefined; + this.commandMenuFocusedIndex = -1; + this.commandMenuFocusMarker = undefined; this.deploymentObjects = []; this.mapMenuObjects = []; this.mapMenuLayout = undefined; @@ -4351,6 +4383,10 @@ export class BattleScene extends Phaser.Scene { if (this.tacticalInitiativePanelObjects.length > 0) { return; } + if (this.commandMenuObjects.length > 0) { + this.activateCommandMenuFocusedAction(); + return; + } if (this.turnPromptObjects.length > 0) { this.activateTurnPromptFocusedAction(); return; @@ -4404,6 +4440,15 @@ export class BattleScene extends Phaser.Scene { return; } + if (this.commandMenuObjects.length > 0) { + if (this.returnFromUsableMenu()) { + return; + } + if (this.cancelPendingMove()) { + return; + } + } + if (this.cancelDeploymentSelection()) { soundDirector.playSelect(); return; @@ -4452,6 +4497,9 @@ export class BattleScene extends Phaser.Scene { if (this.handleTacticalInitiativeKey(event)) { return; } + if (this.handleCommandMenuNavigationKey(event)) { + return; + } if (this.handleBattleKeyboardNavigation(event)) { return; } @@ -8392,6 +8440,183 @@ export class BattleScene extends Phaser.Scene { this.lastPointerFeedbackKey = undefined; } + private activeCommandMenuEntries(): KeyboardCommandMenuEntry[] { + if (this.commandMenuMode === 'command') { + return this.commandButtons + .filter(({ background }) => background.active) + .map(({ command, background, available, accent }) => ({ + id: command, + label: commandLabels[command], + available, + accent, + background + })); + } + if (this.commandMenuMode === 'usable') { + return this.usableMenuButtons + .filter(({ background }) => background.active) + .map(({ usable, background, available, accent }) => ({ + id: usable.id, + label: usable.name, + available, + accent, + background + })); + } + return []; + } + + private setCommandMenuFocus(index: number) { + const entries = this.activeCommandMenuEntries(); + const entry = entries[index]; + if (!entry?.available) { + return false; + } + + this.commandMenuFocusedIndex = index; + entries.forEach((candidate, candidateIndex) => { + candidate.background.setStrokeStyle( + candidateIndex === index ? 3 : 1, + candidateIndex === index ? 0xffffff : candidate.accent, + candidateIndex === index ? 1 : candidate.available ? 0.74 : 0.46 + ); + }); + + if (!this.commandMenuFocusMarker?.active) { + const marker = this.add.text(0, 0, '▶', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '18px', + color: '#fff8df', + fontStyle: '700', + stroke: '#05080b', + strokeThickness: 4 + }); + marker.setName('command-keyboard-focus-marker'); + marker.setOrigin(0, 0.5); + marker.setDepth(19); + this.commandMenuFocusMarker = marker; + this.commandMenuObjects.push(marker); + } + + const bounds = entry.background.getBounds(); + this.commandMenuFocusMarker + .setPosition(bounds.x + 8, bounds.centerY) + .setVisible(true); + return true; + } + + private focusFirstAvailableCommandMenuItem() { + const entries = this.activeCommandMenuEntries(); + const firstAvailableIndex = entries.findIndex(({ available }) => available); + this.commandMenuFocusedIndex = -1; + this.commandMenuFocusMarker?.setVisible(false); + if (firstAvailableIndex >= 0) { + this.setCommandMenuFocus(firstAvailableIndex); + } + } + + private moveCommandMenuFocus(step: -1 | 1) { + const entries = this.activeCommandMenuEntries(); + if (entries.length === 0) { + return false; + } + + let index = this.commandMenuFocusedIndex; + for (let attempt = 0; attempt < entries.length; attempt += 1) { + index = (index + step + entries.length) % entries.length; + if (entries[index]?.available) { + const changed = index !== this.commandMenuFocusedIndex; + this.setCommandMenuFocus(index); + if (changed) { + soundDirector.playHover(); + } + return true; + } + } + return false; + } + + private handleCommandMenuNavigationKey(event: KeyboardEvent) { + if (this.commandMenuObjects.length === 0 || !this.commandMenuMode) { + return false; + } + + const step = + event.code === 'ArrowUp' + ? -1 + : event.code === 'ArrowDown' + ? 1 + : event.code === 'Tab' + ? event.shiftKey + ? -1 + : 1 + : undefined; + if (step !== undefined) { + event.preventDefault(); + this.moveCommandMenuFocus(step); + } + return true; + } + + private activateCommandMenuFocusedAction() { + const entries = this.activeCommandMenuEntries(); + const entry = entries[this.commandMenuFocusedIndex]; + const unit = battleUnits.find( + (candidate) => + candidate.id === this.commandMenuUnitId && + candidate.id === this.selectedUnit?.id && + candidate.hp > 0 + ); + if (!entry?.available || !unit) { + return false; + } + + const bounds = entry.background.getBounds(); + if (this.commandMenuMode === 'command') { + const button = this.commandButtons[this.commandMenuFocusedIndex]; + if (!button || button.command !== entry.id) { + return false; + } + return this.activateCommandMenuChoice(unit, button.command, bounds.centerX, bounds.centerY); + } + + const button = this.usableMenuButtons[this.commandMenuFocusedIndex]; + if (!button || button.usable.id !== entry.id) { + return false; + } + return this.activateUsableMenuChoice(unit, button.usable); + } + + private returnFromUsableMenu() { + if (this.commandMenuMode !== 'usable') { + return false; + } + + const unit = battleUnits.find( + (candidate) => + candidate.id === this.commandMenuUnitId && + candidate.id === this.selectedUnit?.id && + candidate.hp > 0 + ); + const parentCommand = this.commandMenuParentCommand; + const anchor = this.commandMenuAnchor; + if (!unit || !parentCommand || !anchor) { + return false; + } + + soundDirector.playSelect(); + this.showCommandMenu(unit, anchor.x, anchor.y); + const parentIndex = this.commandButtons.findIndex(({ command, available }) => command === parentCommand && available); + if (parentIndex >= 0) { + this.setCommandMenuFocus(parentIndex); + } + this.renderUnitDetail( + unit, + `${commandLabels[parentCommand]} 선택을 취소했습니다.\n키보드: ↑↓/Tab 이동 · Enter 선택 · Esc 이동 취소` + ); + return true; + } + private handleBattleKeyboardNavigation(event: KeyboardEvent) { if ( (this.phase !== 'moving' && this.phase !== 'targeting') || @@ -8763,7 +8988,10 @@ export class BattleScene extends Phaser.Scene { } this.showCommandMenu(unit, commandX, commandY); - this.renderUnitDetail(unit, '명령 선택 · 우클릭은 이동 취소'); + this.renderUnitDetail( + unit, + '명령 선택 · 우클릭은 이동 취소\n키보드: ↑↓/Tab 이동 · Enter 선택 · Esc 이동 취소' + ); this.hideTurnEndPrompt(); if (this.firstBattleTutorialStep === 'move') { this.suppressNextLeftClick = true; @@ -10951,6 +11179,9 @@ export class BattleScene extends Phaser.Scene { private showCommandMenu(unit: UnitData, pointerX: number, pointerY: number) { this.hideCommandMenu(); + this.commandMenuMode = 'command'; + this.commandMenuUnitId = unit.id; + this.commandMenuAnchor = { x: pointerX, y: pointerY }; const commands: BattleCommand[] = ['attack', 'strategy', 'item', 'wait']; const menuWidth = 330; @@ -11064,27 +11295,13 @@ export class BattleScene extends Phaser.Scene { } if (choicePointer.leftButtonDown()) { this.suppressNextLeftClick = true; - if (!this.acceptFirstBattleTutorialCommand(command)) { - return; - } - this.hideTurnEndPrompt(); - if (command === 'strategy' || command === 'item') { - this.showUsableMenu(unit, command, choicePointer.x, choicePointer.y); - return; - } - - if (command !== 'wait') { - this.beginDamageTargeting(unit, command); - if (command === 'attack' && this.phase === 'targeting' && this.firstBattleTutorialStep === 'attack-command') { - this.setFirstBattleTutorialStep('target-preview'); - } - return; - } - - this.completeUnitAction(unit, command); + this.activateCommandMenuChoice(unit, command, choicePointer.x, choicePointer.y); } }; const showDetail = () => { + if (info.available) { + this.setCommandMenuFocus(index); + } background.setFillStyle(info.available ? 0x283947 : 0x27303a, 0.98); this.renderUnitDetail(unit, `${commandLabels[command]}\n${info.detail}`); }; @@ -11097,8 +11314,47 @@ export class BattleScene extends Phaser.Scene { text.on('pointerout', clearHover); text.on('pointerdown', chooseCommand); - this.commandButtons.push({ command, background, text }); + this.commandButtons.push({ + command, + background, + text, + available: info.available, + accent + }); }); + this.focusFirstAvailableCommandMenuItem(); + this.renderUnitDetail( + unit, + '명령 선택\n키보드: ↑↓/Tab 이동 · Enter 선택 · Esc 이동 취소' + ); + } + + private activateCommandMenuChoice( + unit: UnitData, + command: BattleCommand, + anchorX: number, + anchorY: number + ) { + if (!this.acceptFirstBattleTutorialCommand(command)) { + return false; + } + this.hideTurnEndPrompt(); + if (command === 'strategy' || command === 'item') { + this.showUsableMenu(unit, command, anchorX, anchorY); + soundDirector.playSelect(); + return true; + } + + if (command !== 'wait') { + this.beginDamageTargeting(unit, command); + if (command === 'attack' && this.phase === 'targeting' && this.firstBattleTutorialStep === 'attack-command') { + this.setFirstBattleTutorialStep('target-preview'); + } + return this.phase === 'targeting'; + } + + this.completeUnitAction(unit, command); + return true; } private commandMenuPosition(pointerX: number, pointerY: number, width: number, height: number) { @@ -11477,6 +11733,13 @@ export class BattleScene extends Phaser.Scene { text.destroy(); }); this.commandButtons = []; + this.usableMenuButtons = []; + this.commandMenuMode = undefined; + this.commandMenuUnitId = undefined; + this.commandMenuParentCommand = undefined; + this.commandMenuAnchor = undefined; + this.commandMenuFocusedIndex = -1; + this.commandMenuFocusMarker = undefined; } private showUsableMenu(unit: UnitData, command: UsableCommand, pointerX: number, pointerY: number) { @@ -11487,6 +11750,10 @@ export class BattleScene extends Phaser.Scene { } this.hideCommandMenu(); + this.commandMenuMode = 'usable'; + this.commandMenuUnitId = unit.id; + this.commandMenuParentCommand = command; + this.commandMenuAnchor = { x: pointerX, y: pointerY }; const forecasts = usables.map((usable) => ({ usable, forecast: this.usableMenuForecast(unit, usable) })); const readyCount = forecasts.filter(({ forecast }) => forecast.available).length; @@ -11645,7 +11912,7 @@ export class BattleScene extends Phaser.Scene { const choose = (choicePointer: Phaser.Input.Pointer) => { if (choicePointer.rightButtonDown()) { - this.showCommandMenu(unit, pointerX, pointerY); + this.returnFromUsableMenu(); return; } if (choicePointer.leftButtonDown()) { @@ -11654,10 +11921,13 @@ export class BattleScene extends Phaser.Scene { showDetail(); return; } - this.beginUsableTargeting(unit, usable); + this.activateUsableMenuChoice(unit, usable); } }; const showDetail = () => { + if (forecast.available) { + this.setCommandMenuFocus(index); + } bg.setFillStyle(forecast.available ? 0x283947 : 0x27303a, 0.98); this.renderUnitDetail(unit, this.usableDetailText(unit, usable)); }; @@ -11673,7 +11943,29 @@ export class BattleScene extends Phaser.Scene { name.on('pointerover', showDetail); name.on('pointerout', clearHover); name.on('pointerdown', choose); + this.usableMenuButtons.push({ + usable, + background: bg, + text: name, + available: forecast.available, + accent + }); }); + this.focusFirstAvailableCommandMenuItem(); + this.renderUnitDetail( + unit, + `${commandLabels[command]} 선택\n키보드: ↑↓/Tab 이동 · Enter 선택 · Esc 명령으로` + ); + } + + private activateUsableMenuChoice(unit: UnitData, usable: BattleUsable) { + const forecast = this.usableMenuForecast(unit, usable); + if (!forecast.available) { + this.renderUnitDetail(unit, this.usableDetailText(unit, usable)); + return false; + } + this.beginUsableTargeting(unit, usable); + return this.phase === 'targeting'; } private availableUsables(unit: UnitData, command: UsableCommand) { @@ -26473,7 +26765,10 @@ export class BattleScene extends Phaser.Scene { this.clearMarkers(); soundDirector.playSelect(); this.showCommandMenu(unit, view?.sprite.x ?? this.tileCenterX(unit.x), view?.sprite.y ?? this.tileCenterY(unit.y)); - this.renderUnitDetail(unit, '대상 선택을 취소했습니다.\n공격, 책략, 도구, 대기 중 하나를 다시 선택하세요.\n우클릭하면 이동 취소로 돌아갑니다.'); + this.renderUnitDetail( + unit, + '대상 선택을 취소했습니다.\n공격, 책략, 도구, 대기 중 하나를 다시 선택하세요.\n키보드: ↑↓/Tab 이동 · Enter 선택 · Esc 이동 취소' + ); if (this.firstBattleTutorialStep === 'target-preview') { this.setFirstBattleTutorialStep('attack-command', '대상 선택을 취소해 공격 명령 단계로 돌아왔습니다.'); } @@ -30783,6 +31078,80 @@ export class BattleScene extends Phaser.Scene { }; } + private commandMenuKeyboardDebugState() { + const entries = this.activeCommandMenuEntries(); + const focused = entries[this.commandMenuFocusedIndex]; + const guidance = this.sidePanelObjects.find( + (object): object is Phaser.GameObjects.Text => + object instanceof Phaser.GameObjects.Text && + object.active && + object.visible && + object.text.includes('↑↓/Tab') + ); + const toBounds = (background?: Phaser.GameObjects.Rectangle) => { + const bounds = background?.active ? background.getBounds() : undefined; + return bounds + ? { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height } + : null; + }; + const markerBounds = + this.commandMenuFocusMarker?.active && this.commandMenuFocusMarker.visible + ? this.commandMenuFocusMarker.getBounds() + : undefined; + + return { + visible: this.commandMenuObjects.length > 0 && Boolean(this.commandMenuMode), + mode: this.commandMenuMode ?? null, + unitId: this.commandMenuUnitId ?? null, + parentCommand: this.commandMenuParentCommand ?? null, + focusedIndex: this.commandMenuFocusedIndex, + guidance: guidance + ? { + text: guidance.text, + bounds: { + x: guidance.getBounds().x, + y: guidance.getBounds().y, + width: guidance.getBounds().width, + height: guidance.getBounds().height + } + } + : null, + focusedItem: focused + ? { + id: focused.id, + label: focused.label, + available: focused.available, + bounds: toBounds(focused.background), + lineWidth: focused.background.lineWidth + } + : null, + items: entries.map((entry, index) => ({ + id: entry.id, + label: entry.label, + available: entry.available, + focused: index === this.commandMenuFocusedIndex, + bounds: toBounds(entry.background), + lineWidth: entry.background.lineWidth + })), + focusMarker: markerBounds + ? { + visible: true, + text: this.commandMenuFocusMarker?.text ?? '', + bounds: { + x: markerBounds.x, + y: markerBounds.y, + width: markerBounds.width, + height: markerBounds.height + } + } + : { + visible: false, + text: this.commandMenuFocusMarker?.text ?? '', + bounds: null + } + }; + } + private battleHudDebugState() { const miniMap = this.miniMapLayout; const selectedDot = this.selectedUnit ? this.miniMapUnitDots.get(this.selectedUnit.id) : undefined; @@ -31868,6 +32237,7 @@ export class BattleScene extends Phaser.Scene { this.mapBackground?.active && this.mapBackground.texture.key === battleScenario.mapTextureKey ), commandMenuVisible: this.commandMenuObjects.length > 0, + commandMenuKeyboard: this.commandMenuKeyboardDebugState(), mapMenuVisible: this.mapMenuObjects.length > 0, saveSlotPanelVisible: this.saveSlotPanelObjects.length > 0, saveSlotPanelMode: this.saveSlotPanelMode ?? null, diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index 5f1dabd..abb7de9 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -261,6 +261,7 @@ import { } from '../state/thirdCampPreparationActions'; import { clearCampaignBattleSavesForSlot } from '../state/battleSaveKeys'; import { releaseTextureSource } from '../render/loaderLifecycle'; +import { isVisualMotionReduced } from '../settings/visualMotion'; import { palette } from '../ui/palette'; import { ensureLazyScene, type LazySceneKey } from './lazyScenes'; @@ -11461,6 +11462,7 @@ export class CampScene extends Phaser.Scene { private campSkinTransitionRevision = 0; private campSkinTransitionCompletedRevision = 0; private campSkinTransitionActive = false; + private visualMotionReduced = false; private ownedCampTextureKeys = new Set(); private campSkinTransitionLast?: { kind: 'backdrop-fade'; @@ -11593,6 +11595,7 @@ export class CampScene extends Phaser.Scene { private retrySortieBattleId?: BattleScenarioId; private skipIntroStoryForSortie = false; private navigationPending = false; + private campNoticeRemovalTimer?: Phaser.Time.TimerEvent; constructor() { super('CampScene'); @@ -11615,6 +11618,10 @@ export class CampScene extends Phaser.Scene { create() { this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => this.releaseOwnedCampTextures()); this.navigationPending = false; + this.visualMotionReduced = isVisualMotionReduced(); + this.activeTab = 'status'; + this.selectedCityLocation = 'information'; + this.campNoticeRemovalTimer = undefined; this.contentObjects = []; this.campRosterPage = 0; this.campRosterLayout = undefined; @@ -11755,8 +11762,6 @@ export class CampScene extends Phaser.Scene { : !dialogueComplete ? 'dialogue' : 'market'; - } else if (this.activeTab === 'city') { - this.activeTab = 'status'; } const pendingDirectExplorationVisit = this.availableCampVisits().find( (visit) => @@ -11766,6 +11771,8 @@ export class CampScene extends Phaser.Scene { if (!cityStay && pendingDirectExplorationVisit) { this.selectedVisitId = pendingDirectExplorationVisit.id; this.activeTab = 'visit'; + } else if (!cityStay && pendingPriorityReturnDialogueId) { + this.activeTab = 'dialogue'; } this.initializeCityEquipmentContext(); soundDirector.playSoundscape({ @@ -11865,9 +11872,9 @@ export class CampScene extends Phaser.Scene { fontStyle: '700' })).setOrigin(0.5).setDepth(-17); - const transitionDurationMs = 420; + const transitionDurationMs = this.visualMotionReduced ? 0 : 420; const transitionRevision = ++this.campSkinTransitionRevision; - this.campSkinTransitionActive = true; + this.campSkinTransitionActive = !this.visualMotionReduced; this.campSkinTransitionLast = { kind: 'backdrop-fade', durationMs: transitionDurationMs, @@ -11875,19 +11882,24 @@ export class CampScene extends Phaser.Scene { fromAlpha: 0, toAlpha: skin.backdropAlpha }; - this.tweens.add({ - targets: backdrop, - alpha: skin.backdropAlpha, - duration: transitionDurationMs, - ease: 'Sine.easeOut', - onComplete: () => { - if (this.campSkinBackdrop !== backdrop) { - return; + if (this.visualMotionReduced) { + backdrop.setAlpha(skin.backdropAlpha); + this.campSkinTransitionCompletedRevision = transitionRevision; + } else { + this.tweens.add({ + targets: backdrop, + alpha: skin.backdropAlpha, + duration: transitionDurationMs, + ease: 'Sine.easeOut', + onComplete: () => { + if (this.campSkinBackdrop !== backdrop) { + return; + } + this.campSkinTransitionCompletedRevision = transitionRevision; + this.campSkinTransitionActive = false; } - this.campSkinTransitionCompletedRevision = transitionRevision; - this.campSkinTransitionActive = false; - } - }); + }); + } this.renderStaticButtons(); this.render(); @@ -25162,21 +25174,30 @@ export class CampScene extends Phaser.Scene { box.setDepth(depth); const noticeObjects: Phaser.GameObjects.GameObject[] = [box, text]; this.dialogueObjects = noticeObjects; - this.tweens.add({ - targets: noticeObjects, - alpha: 0, - delay: this.campNoticeHoldDuration(message), - duration: 320, - onComplete: () => { - noticeObjects.forEach((object) => object.active && object.destroy()); - if (this.dialogueObjects === noticeObjects) { - this.dialogueObjects = []; - } + const removeNotice = () => { + noticeObjects.forEach((object) => object.active && object.destroy()); + if (this.dialogueObjects === noticeObjects) { + this.dialogueObjects = []; } - }); + this.campNoticeRemovalTimer = undefined; + }; + const holdDuration = this.campNoticeHoldDuration(message); + if (this.visualMotionReduced) { + this.campNoticeRemovalTimer = this.time.delayedCall(holdDuration, removeNotice); + } else { + this.tweens.add({ + targets: noticeObjects, + alpha: 0, + delay: holdDuration, + duration: 320, + onComplete: removeNotice + }); + } } private clearCampNotice() { + this.campNoticeRemovalTimer?.remove(false); + this.campNoticeRemovalTimer = undefined; this.tweens.killTweensOf(this.dialogueObjects); this.dialogueObjects.forEach((object) => object.active && object.destroy()); this.dialogueObjects = []; @@ -26228,6 +26249,7 @@ export class CampScene extends Phaser.Scene { : undefined; return { scene: this.scene.key, + visualMotionReduced: this.visualMotionReduced, victoryRewardAcknowledgement: { visible: this.victoryRewardObjects.length > 0, battleId: this.pendingVictoryRewardReport?.battleId ?? null, diff --git a/src/game/scenes/TitleScene.ts b/src/game/scenes/TitleScene.ts index 791ec73..3ecee6c 100644 --- a/src/game/scenes/TitleScene.ts +++ b/src/game/scenes/TitleScene.ts @@ -14,6 +14,7 @@ import { updateAudioPreference } from '../settings/audioPreferences'; import { + isVisualMotionReduced, loadVisualMotionMode, nextVisualMotionMode, saveVisualMotionMode, @@ -43,11 +44,31 @@ const fhdUiScale = 1.5; const ui = (value: number) => value * fhdUiScale; const uiPx = (value: number) => `${Math.round(ui(value))}px`; +type TitleFocusScope = 'main' | 'settings' | 'save-slots' | 'new-game-confirm'; + +type TitleFocusItem = { + id: string; + enabled: boolean; + marker: Phaser.GameObjects.Text; + activate: () => void; + render: (focused: boolean) => void; + bounds: () => Phaser.Geom.Rectangle; +}; + export class TitleScene extends Phaser.Scene { - private focusedButton?: Phaser.GameObjects.Text; private settingsPanel?: Phaser.GameObjects.Container; private newGameConfirmPanel?: Phaser.GameObjects.Container; private saveSlotPanel?: Phaser.GameObjects.Container; + private readonly focusItems = new Map(); + private focusScope: TitleFocusScope = 'main'; + private focusedItemId?: string; + private titleBackground?: Phaser.GameObjects.Image; + private titleBackgroundCoverScale = 1; + private titleMist?: Phaser.GameObjects.Ellipse; + private titlePetals: Phaser.GameObjects.Image[] = []; + private titleMotionTweens: Phaser.Tweens.Tween[] = []; + private visualMotionReduced = false; + private handledKeyboardEvents = new WeakSet(); private navigating = false; constructor() { @@ -55,10 +76,18 @@ export class TitleScene extends Phaser.Scene { } create() { - this.focusedButton = undefined; this.settingsPanel = undefined; this.newGameConfirmPanel = undefined; this.saveSlotPanel = undefined; + this.focusItems.clear(); + this.focusScope = 'main'; + this.focusedItemId = undefined; + this.titleBackground = undefined; + this.titleMist = undefined; + this.titlePetals = []; + this.titleMotionTweens = []; + this.visualMotionReduced = isVisualMotionReduced(); + this.handledKeyboardEvents = new WeakSet(); this.navigating = false; const debugBattleId = this.debugBattleId(); @@ -90,6 +119,7 @@ export class TitleScene extends Phaser.Scene { const { width, height } = this.scale; this.drawBackground(width, height); this.drawAtmosphere(width, height); + this.applyTitleMotionPreference(this.visualMotionReduced); this.drawTitleMark(width, height); this.drawMenu(width, height); @@ -102,9 +132,11 @@ export class TitleScene extends Phaser.Scene { this.input.once('pointerdown', unlockAudio); this.input.keyboard?.once('keydown', unlockAudio); - this.input.keyboard?.on('keydown-ENTER', (event: KeyboardEvent) => this.handleEnterKey(event)); - this.input.keyboard?.on('keydown', (event: KeyboardEvent) => this.handleSaveSlotKey(event)); - this.input.keyboard?.on('keydown-ESC', () => this.handleEscapeKey()); + this.input.keyboard?.on('keydown', this.handleKeyboardInput, this); + this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => { + this.input.keyboard?.off('keydown', this.handleKeyboardInput, this); + this.stopTitleMotionTweens(); + }); } private shouldOpenDebugSortiePrep() { @@ -173,18 +205,8 @@ export class TitleScene extends Phaser.Scene { const texture = this.textures.get('title-taoyuan').getSourceImage(); const coverScale = Math.max(width / texture.width, height / texture.height); - background.setScale(coverScale * 1.08); - background.setPosition(width / 2 - ui(18), height / 2 + ui(4)); - this.tweens.add({ - targets: background, - x: width / 2 + ui(18), - y: height / 2 - ui(6), - scale: coverScale * 1.12, - duration: 18000, - ease: 'Sine.easeInOut', - yoyo: true, - repeat: -1 - }); + this.titleBackground = background; + this.titleBackgroundCoverScale = coverScale; this.add.rectangle(0, 0, width, height, 0x080a0e, 0.2).setOrigin(0); this.add.image(0, 0, this.createShadeTexture(width, height)).setOrigin(0); @@ -224,15 +246,7 @@ export class TitleScene extends Phaser.Scene { private drawAtmosphere(width: number, height: number) { const mist = this.add.ellipse(width / 2, height * 0.78, width * 1.25, ui(190), 0xd8b15f, 0.04); - this.tweens.add({ - targets: mist, - alpha: 0.11, - x: width / 2 + ui(22), - duration: 8000, - ease: 'Sine.easeInOut', - yoyo: true, - repeat: -1 - }); + this.titleMist = mist; for (let index = 0; index < 34; index += 1) { const petal = this.add.image( @@ -244,8 +258,63 @@ export class TitleScene extends Phaser.Scene { petal.setScale(scale * fhdUiScale); petal.setAlpha(Phaser.Math.FloatBetween(0.22, 0.62)); petal.setRotation(Phaser.Math.FloatBetween(-1.2, 1.2)); + this.titlePetals.push(petal); + } + } - this.tweens.add({ + private applyTitleMotionPreference(reduced: boolean) { + this.visualMotionReduced = reduced; + this.stopTitleMotionTweens(); + + const { width, height } = this.scale; + this.titleBackground + ?.setPosition(width / 2, height / 2) + .setScale(this.titleBackgroundCoverScale * 1.1); + this.titleMist?.setPosition(width / 2, height * 0.78).setAlpha(reduced ? 0.07 : 0.04); + + this.titlePetals.forEach((petal, index) => { + const x = ((index * 211 + 97) % Math.max(1, width + ui(120))) - ui(60); + const y = ((index * 137 + 43) % Math.max(1, height + ui(100))) - ui(80); + petal + .setVisible(true) + .setPosition(x, y) + .setRotation(-0.9 + (index % 9) * 0.21); + }); + + if (reduced) { + return; + } + + if (this.titleBackground) { + this.titleBackground + .setPosition(width / 2 - ui(18), height / 2 + ui(4)) + .setScale(this.titleBackgroundCoverScale * 1.08); + this.trackTitleMotionTween({ + targets: this.titleBackground, + x: width / 2 + ui(18), + y: height / 2 - ui(6), + scale: this.titleBackgroundCoverScale * 1.12, + duration: 18000, + ease: 'Sine.easeInOut', + yoyo: true, + repeat: -1 + }); + } + + if (this.titleMist) { + this.trackTitleMotionTween({ + targets: this.titleMist, + alpha: 0.11, + x: width / 2 + ui(22), + duration: 8000, + ease: 'Sine.easeInOut', + yoyo: true, + repeat: -1 + }); + } + + this.titlePetals.forEach((petal) => { + this.trackTitleMotionTween({ targets: petal, x: petal.x + Phaser.Math.Between(ui(80), ui(220)), y: height + Phaser.Math.Between(ui(40), ui(180)), @@ -258,7 +327,18 @@ export class TitleScene extends Phaser.Scene { petal.setPosition(Phaser.Math.Between(-ui(120), width), Phaser.Math.Between(-ui(180), -ui(40))); } }); - } + }); + } + + private trackTitleMotionTween(config: Phaser.Types.Tweens.TweenBuilderConfig) { + const tween = this.tweens.add(config); + this.titleMotionTweens.push(tween); + return tween; + } + + private stopTitleMotionTweens() { + this.titleMotionTweens.forEach((tween) => tween.stop()); + this.titleMotionTweens = []; } private drawTitleMark(width: number, height: number) { @@ -303,13 +383,22 @@ export class TitleScene extends Phaser.Scene { this.add.rectangle(menuX, menuY - plateHeight / 2, ui(186), ui(2), palette.gold, 0.7); this.add.rectangle(menuX, menuY + plateHeight / 2, ui(186), ui(2), palette.gold, 0.36); - this.createMenuButton(menuX, menuY - ui(70), '새 게임', true, () => this.requestStartGame()); - this.createMenuButton(menuX, menuY, isEndingComplete ? '엔딩 보기' : '이어하기', canContinue, () => this.requestContinueGame()); - this.createMenuButton(menuX, menuY + ui(70), '설정', true, () => this.openSettingsPanel(menuX, menuY)); + this.createMenuButton('new-game', menuX, menuY - ui(70), '새 게임', true, () => this.requestStartGame()); + this.createMenuButton( + 'continue', + menuX, + menuY, + isEndingComplete ? '엔딩 보기' : '이어하기', + canContinue, + () => this.requestContinueGame() + ); + this.createMenuButton('settings', menuX, menuY + ui(70), '설정', true, () => this.openSettingsPanel(menuX, menuY)); if (campaign) { this.drawCampaignSaveBadge(menuX, menuY + ui(132), campaign, isEndingComplete); } + + this.setFocusScope('main', canContinue ? 'continue' : 'new-game'); } private drawCampaignSaveBadge( @@ -404,6 +493,7 @@ export class TitleScene extends Phaser.Scene { } private createMenuButton( + id: string, x: number, y: number, label: string, @@ -421,41 +511,49 @@ export class TitleScene extends Phaser.Scene { text.setOrigin(0.5); text.setShadow(0, ui(3), '#080a0e', ui(8), true, true); - if (!enabled) { - return text; - } + const marker = this.add.text(x - ui(116), y, '▶', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: uiPx(17), + color: '#f6e6bd', + fontStyle: '700' + }); + marker.setOrigin(0.5).setVisible(false); const hitArea = this.add.zone(x, y, ui(216), ui(58)); hitArea.setOrigin(0.5); - hitArea.setInteractive({ useHandCursor: true }); - hitArea.on('pointerover', () => { - this.focusButton(text); - soundDirector.playHover(); - }); - hitArea.on('pointerout', () => this.unfocusButton(text)); - hitArea.on('pointerdown', () => { - soundDirector.start(); - soundDirector.resume(); - onSelect(); + const render = (focused: boolean) => { + marker.setVisible(enabled && focused); + text.setColor(!enabled ? '#7d8189' : focused ? '#111821' : '#f1e3c2'); + text.setBackgroundColor(enabled && focused ? '#d8b15f' : 'rgba(0,0,0,0)'); + }; + + this.registerFocusItem('main', { + id, + enabled, + marker, + activate: onSelect, + render, + bounds: () => hitArea.getBounds() }); + render(false); + + if (enabled) { + hitArea.setInteractive({ useHandCursor: true }); + hitArea.on('pointerover', () => this.focusItem('main', id, true)); + hitArea.on('pointerdown', () => { + if (this.activeModalScope()) { + return; + } + this.focusItem('main', id); + soundDirector.start(); + soundDirector.resume(); + onSelect(); + }); + } return text; } - private focusButton(text: Phaser.GameObjects.Text) { - this.focusedButton = text; - text.setColor('#111821'); - text.setBackgroundColor('#d8b15f'); - } - - private unfocusButton(text: Phaser.GameObjects.Text) { - if (this.focusedButton === text) { - this.focusedButton = undefined; - } - text.setColor('#f1e3c2'); - text.setBackgroundColor('rgba(0,0,0,0)'); - } - private openSettingsPanel(x: number, y: number) { if (this.settingsPanel) { this.closeSettingsPanel(); @@ -463,8 +561,9 @@ export class TitleScene extends Phaser.Scene { } soundDirector.playSelect(); - this.closeNewGameConfirmPanel(); - this.closeSaveSlotPanel(); + this.closeNewGameConfirmPanel(false); + this.closeSaveSlotPanel(false); + this.clearFocusScope('settings'); const panel = this.add.container(x, y + ui(126)); const backdrop = this.add.rectangle(0, 0, ui(320), ui(468), 0x0b1118, 1); @@ -480,48 +579,98 @@ export class TitleScene extends Phaser.Scene { }); title.setOrigin(0.5); - const soundText = this.createSettingsButton(0, -ui(126), this.soundLabel()); - soundText.on('pointerdown', () => { - const preferences = loadAudioPreferences(); - const muted = !soundDirector.isMuted(); - soundDirector.setMuted(muted); - saveAudioPreferences({ ...preferences, muted }); - soundText.setText(this.soundLabel()); - soundDirector.playSelect(); - }); + this.createSettingsButton( + panel, + 'settings', + 'sound', + 0, + -ui(126), + this.soundLabel(), + (text) => { + const preferences = loadAudioPreferences(); + const muted = !soundDirector.isMuted(); + soundDirector.setMuted(muted); + saveAudioPreferences({ ...preferences, muted }); + text.setText(this.soundLabel()); + soundDirector.playSelect(); + } + ); - const musicText = this.createSettingsButton(0, -ui(84), this.audioCategoryLabel('music')); - musicText.on('pointerdown', () => this.cycleAudioLevel('music', musicText)); + this.createSettingsButton( + panel, + 'settings', + 'music', + 0, + -ui(84), + this.audioCategoryLabel('music'), + (text) => this.cycleAudioLevel('music', text) + ); - const effectsText = this.createSettingsButton(0, -ui(42), this.audioCategoryLabel('effects')); - effectsText.on('pointerdown', () => this.cycleAudioLevel('effects', effectsText)); + this.createSettingsButton( + panel, + 'settings', + 'effects', + 0, + -ui(42), + this.audioCategoryLabel('effects'), + (text) => this.cycleAudioLevel('effects', text) + ); - const ambienceText = this.createSettingsButton(0, 0, this.audioCategoryLabel('ambience')); - ambienceText.on('pointerdown', () => this.cycleAudioLevel('ambience', ambienceText)); + this.createSettingsButton( + panel, + 'settings', + 'ambience', + 0, + 0, + this.audioCategoryLabel('ambience'), + (text) => this.cycleAudioLevel('ambience', text) + ); - const presentationText = this.createSettingsButton(0, ui(42), this.combatPresentationLabel()); - presentationText.on('pointerdown', () => { - const nextMode = nextCombatPresentationMode(loadCombatPresentationMode()); - saveCombatPresentationMode(nextMode); - presentationText.setText(this.combatPresentationLabel()); - soundDirector.playSelect(); - }); + this.createSettingsButton( + panel, + 'settings', + 'combat-presentation', + 0, + ui(42), + this.combatPresentationLabel(), + (text) => { + const nextMode = nextCombatPresentationMode(loadCombatPresentationMode()); + saveCombatPresentationMode(nextMode); + text.setText(this.combatPresentationLabel()); + soundDirector.playSelect(); + } + ); - const motionText = this.createSettingsButton(0, ui(84), this.visualMotionLabel()); - motionText.on('pointerdown', () => { - const nextMode = nextVisualMotionMode(loadVisualMotionMode()); - saveVisualMotionMode(nextMode); - motionText.setText(this.visualMotionLabel()); - soundDirector.playSelect(); - }); + this.createSettingsButton( + panel, + 'settings', + 'visual-motion', + 0, + ui(84), + this.visualMotionLabel(), + (text) => { + const nextMode = nextVisualMotionMode(loadVisualMotionMode()); + saveVisualMotionMode(nextMode); + this.applyTitleMotionPreference(nextMode === 'reduced'); + text.setText(this.visualMotionLabel()); + soundDirector.playSelect(); + } + ); - const close = this.createSettingsButton(0, ui(132), '닫기'); - close.on('pointerdown', () => { - soundDirector.playSelect(); - this.closeSettingsPanel(); - }); + this.createSettingsButton( + panel, + 'settings', + 'close', + 0, + ui(132), + '닫기', + () => { + soundDirector.playSelect(); + this.closeSettingsPanel(); + } + ); - const hint = this.add.text(0, ui(178), '항목을 눌러 단계 변경 · Esc 닫기', { + const hint = this.add.text(0, ui(178), '↑↓/Tab 이동 · Enter/Space 변경\nEsc 닫기', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: uiPx(11), color: '#9fb0bf', @@ -531,24 +680,70 @@ export class TitleScene extends Phaser.Scene { }); hint.setOrigin(0.5); - panel.add([backdrop, title, soundText, musicText, effectsText, ambienceText, presentationText, motionText, close, hint]); + panel.addAt([backdrop, title, hint], 0); panel.setAlpha(0); this.settingsPanel = panel; - this.tweens.add({ targets: panel, alpha: 1, y: y + ui(116), duration: 160, ease: 'Sine.easeOut' }); + this.tweens.add({ + targets: panel, + alpha: 1, + y: y + ui(116), + duration: this.visualMotionReduced ? 0 : 160, + ease: 'Sine.easeOut' + }); + this.setFocusScope('settings', 'sound'); } - private createSettingsButton(x: number, y: number, label: string) { + private createSettingsButton( + panel: Phaser.GameObjects.Container, + scope: Exclude, + id: string, + x: number, + y: number, + label: string, + onSelect: (text: Phaser.GameObjects.Text) => void, + baseColor = '#d8b15f' + ) { const text = this.add.text(x, y, label, { fontSize: uiPx(20), - color: '#d8b15f', + color: baseColor, fixedWidth: ui(210), align: 'center', padding: { top: ui(8), bottom: ui(8) } }); text.setOrigin(0.5); text.setInteractive({ useHandCursor: true }); - text.on('pointerover', () => text.setColor('#f1e3c2')); - text.on('pointerout', () => text.setColor('#d8b15f')); + + const marker = this.add.text(x - ui(124), y, '▶', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: uiPx(14), + color: '#f6e6bd', + fontStyle: '700' + }); + marker.setOrigin(0.5).setVisible(false); + + const render = (focused: boolean) => { + marker.setVisible(focused); + text.setColor(focused ? '#111821' : baseColor); + text.setBackgroundColor(focused ? '#d8b15f' : 'rgba(0,0,0,0)'); + }; + + panel.add([marker, text]); + this.registerFocusItem(scope, { + id, + enabled: true, + marker, + activate: () => onSelect(text), + render, + bounds: () => text.getBounds() + }); + render(false); + text.on('pointerover', () => this.focusItem(scope, id, true)); + text.on('pointerdown', () => { + this.focusItem(scope, id); + soundDirector.start(); + soundDirector.resume(); + onSelect(text); + }); return text; } @@ -584,9 +779,13 @@ export class TitleScene extends Phaser.Scene { return `화면 움직임: ${visualMotionModeLabel(loadVisualMotionMode())}`; } - private closeSettingsPanel() { + private closeSettingsPanel(restoreFocus = true) { + this.clearFocusScope('settings'); this.settingsPanel?.destroy(); this.settingsPanel = undefined; + if (restoreFocus) { + this.setFocusScope('main', 'settings'); + } } private requestContinueGame() { @@ -614,12 +813,14 @@ export class TitleScene extends Phaser.Scene { } soundDirector.playSelect(); - this.closeSettingsPanel(); - this.closeNewGameConfirmPanel(); + this.closeSettingsPanel(false); + this.closeNewGameConfirmPanel(false); + this.clearFocusScope('save-slots'); const panel = this.add.container(x, y + ui(252)); const backdrop = this.add.rectangle(0, 0, ui(318), ui(280), 0x0e151d, 0.95); backdrop.setStrokeStyle(ui(1), palette.gold, 0.58); + backdrop.setInteractive(); const title = this.add.text(0, -ui(116), '이어갈 기록 선택 (1~3)', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', @@ -632,13 +833,20 @@ export class TitleScene extends Phaser.Scene { title.setOrigin(0.5); const rows = listCampaignSaveSlots().map((slot, index) => this.createSaveSlotRow(slot, -ui(82) + index * ui(58))); - const close = this.createSettingsButton(0, ui(96), '닫기'); - close.on('pointerdown', () => { - soundDirector.playSelect(); - this.closeSaveSlotPanel(); - }); + this.createSettingsButton( + panel, + 'save-slots', + 'close', + 0, + ui(96), + '닫기', + () => { + soundDirector.playSelect(); + this.closeSaveSlotPanel(); + } + ); - const hint = this.add.text(0, ui(126), '숫자키 1~3 선택 · Esc 닫기', { + const hint = this.add.text(0, ui(120), '↑↓/Tab 이동 · Enter/Space 선택\n숫자키 1~3 · Esc 닫기', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: uiPx(11), color: '#9fb0bf', @@ -648,10 +856,18 @@ export class TitleScene extends Phaser.Scene { }); hint.setOrigin(0.5); - panel.add([backdrop, title, ...rows, close, hint]); + panel.addAt([backdrop, title, ...rows, hint], 0); panel.setAlpha(0); this.saveSlotPanel = panel; - this.tweens.add({ targets: panel, alpha: 1, y: y + ui(242), duration: 160, ease: 'Sine.easeOut' }); + this.tweens.add({ + targets: panel, + alpha: 1, + y: y + ui(242), + duration: this.visualMotionReduced ? 0 : 160, + ease: 'Sine.easeOut' + }); + const activeSlot = loadCampaignState().activeSaveSlot; + this.setFocusScope('save-slots', `slot-${activeSlot}`); } private createSaveSlotRow(slot: CampaignSaveSlotSummary, y: number) { @@ -701,20 +917,41 @@ export class TitleScene extends Phaser.Scene { }); keyText.setOrigin(0.5); - row.add([background, title, detail, meta, keycap, keyText]); + const marker = this.add.text(-ui(143), 0, '▶', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: uiPx(12), + color: '#f6e6bd', + fontStyle: '700' + }); + marker.setOrigin(0.5).setVisible(false); + + row.add([background, title, detail, meta, keycap, keyText, marker]); + + const render = (focused: boolean) => { + marker.setVisible(enabled && focused); + background.setFillStyle(enabled && focused ? 0xd8b15f : enabled ? 0x1b2530 : 0x121820, enabled && focused ? 0.22 : enabled ? 0.9 : 0.56); + background.setStrokeStyle( + ui(focused ? 2 : 1), + enabled ? palette.gold : 0x56616d, + enabled && focused ? 0.9 : enabled ? 0.42 : 0.2 + ); + }; + + this.registerFocusItem('save-slots', { + id: `slot-${slot.slot}`, + enabled, + marker, + activate: () => this.activateSaveSlot(slot.slot), + render, + bounds: () => background.getBounds() + }); + render(false); if (enabled) { background.setInteractive({ useHandCursor: true }); - background.on('pointerover', () => { - background.setFillStyle(0xd8b15f, 0.22); - background.setStrokeStyle(ui(1), palette.gold, 0.72); - soundDirector.playHover(); - }); - background.on('pointerout', () => { - background.setFillStyle(0x1b2530, 0.9); - background.setStrokeStyle(ui(1), palette.gold, 0.42); - }); + background.on('pointerover', () => this.focusItem('save-slots', `slot-${slot.slot}`, true)); background.on('pointerdown', () => { + this.focusItem('save-slots', `slot-${slot.slot}`); this.activateSaveSlot(slot.slot); }); } @@ -722,54 +959,156 @@ export class TitleScene extends Phaser.Scene { return row; } - private handleSaveSlotKey(event: KeyboardEvent) { - if (!this.saveSlotPanel) { - return; - } - - const slot = Number(event.key); - if (!Number.isInteger(slot)) { - return; - } - - event.preventDefault(); - this.activateSaveSlot(slot); - } - private activateSaveSlot(slot: number) { if (!listCampaignSaveSlots().some((summary) => summary.slot === slot && summary.exists)) { return; } - this.closeSaveSlotPanel(); + this.closeSaveSlotPanel(false); this.continueGame(slot); } - private closeSaveSlotPanel() { + private closeSaveSlotPanel(restoreFocus = true) { + this.clearFocusScope('save-slots'); this.saveSlotPanel?.destroy(); this.saveSlotPanel = undefined; + if (restoreFocus) { + this.setFocusScope('main', 'continue'); + } } - private handleEnterKey(event?: KeyboardEvent) { - if (event?.repeat || this.navigating) { + private registerFocusItem(scope: TitleFocusScope, item: TitleFocusItem) { + const items = this.focusItems.get(scope) ?? []; + const duplicateIndex = items.findIndex((candidate) => candidate.id === item.id); + if (duplicateIndex >= 0) { + items[duplicateIndex] = item; + } else { + items.push(item); + } + this.focusItems.set(scope, items); + } + + private setFocusScope(scope: TitleFocusScope, preferredId?: string) { + this.currentFocusItem()?.render(false); + this.focusScope = scope; + + const enabledItems = (this.focusItems.get(scope) ?? []).filter((item) => item.enabled); + const nextItem = enabledItems.find((item) => item.id === preferredId) ?? enabledItems[0]; + this.focusedItemId = nextItem?.id; + nextItem?.render(true); + } + + private clearFocusScope(scope: TitleFocusScope) { + const items = this.focusItems.get(scope) ?? []; + items.forEach((item) => item.render(false)); + this.focusItems.delete(scope); + if (this.focusScope === scope) { + this.focusedItemId = undefined; + } + } + + private focusItem(scope: TitleFocusScope, id: string, playHover = false) { + const modalScope = this.activeModalScope(); + if (modalScope && scope !== modalScope) { return; } - if (this.saveSlotPanel) { + const nextItem = (this.focusItems.get(scope) ?? []).find((item) => item.id === id && item.enabled); + if (!nextItem) { return; } - if (this.newGameConfirmPanel) { - this.closeNewGameConfirmPanel(); - this.startGame(); - return; + const changed = this.focusScope !== scope || this.focusedItemId !== id; + this.currentFocusItem()?.render(false); + this.focusScope = scope; + this.focusedItemId = id; + nextItem.render(true); + if (changed && playHover) { + soundDirector.playHover(); } + } + private activeModalScope(): Exclude | undefined { if (this.settingsPanel) { + return 'settings'; + } + if (this.saveSlotPanel) { + return 'save-slots'; + } + if (this.newGameConfirmPanel) { + return 'new-game-confirm'; + } + return undefined; + } + + private moveFocus(direction: 1 | -1) { + const enabledItems = (this.focusItems.get(this.focusScope) ?? []).filter((item) => item.enabled); + if (enabledItems.length === 0) { return; } - this.activateDefaultMenuAction(); + const currentIndex = enabledItems.findIndex((item) => item.id === this.focusedItemId); + const nextIndex = currentIndex < 0 + ? 0 + : (currentIndex + direction + enabledItems.length) % enabledItems.length; + this.focusItem(this.focusScope, enabledItems[nextIndex].id, true); + } + + private currentFocusItem() { + return (this.focusItems.get(this.focusScope) ?? []).find((item) => item.id === this.focusedItemId); + } + + private activateFocusedItem() { + const item = this.currentFocusItem(); + if (!item?.enabled) { + return; + } + + soundDirector.start(); + soundDirector.resume(); + item.activate(); + } + + private handleKeyboardInput(event: KeyboardEvent) { + if (this.handledKeyboardEvents.has(event)) { + return; + } + this.handledKeyboardEvents.add(event); + + if (event.repeat || this.navigating) { + return; + } + + if (event.key === 'Escape') { + event.preventDefault(); + this.handleEscapeKey(); + return; + } + + if (this.saveSlotPanel && /^[1-3]$/.test(event.key)) { + event.preventDefault(); + const slot = Number(event.key); + this.focusItem('save-slots', `slot-${slot}`); + this.activateSaveSlot(slot); + return; + } + + if (event.key === 'ArrowUp' || (event.key === 'Tab' && event.shiftKey)) { + event.preventDefault(); + this.moveFocus(-1); + return; + } + + if (event.key === 'ArrowDown' || event.key === 'Tab') { + event.preventDefault(); + this.moveFocus(1); + return; + } + + if (event.key === 'Enter' || event.key === ' ' || event.code === 'Space') { + event.preventDefault(); + this.activateFocusedItem(); + } } private handleEscapeKey() { @@ -811,12 +1150,14 @@ export class TitleScene extends Phaser.Scene { } soundDirector.playSelect(); - this.closeSettingsPanel(); - this.closeSaveSlotPanel(); + this.closeSettingsPanel(false); + this.closeSaveSlotPanel(false); + this.clearFocusScope('new-game-confirm'); const panel = this.add.container(x, y + ui(240)); const backdrop = this.add.rectangle(0, 0, ui(286), ui(198), 0x0e151d, 0.94); backdrop.setStrokeStyle(ui(1), palette.gold, 0.58); + backdrop.setInteractive(); const title = this.add.text(0, -ui(72), '새로 시작', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', @@ -838,7 +1179,7 @@ export class TitleScene extends Phaser.Scene { }); message.setOrigin(0.5); - const hint = this.add.text(0, ui(13), 'Enter 새 게임 · Esc 취소', { + const hint = this.add.text(0, ui(13), '↑↓ 선택 · Enter/Space 결정 · Esc 취소', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: uiPx(11), color: '#9fb0bf', @@ -848,37 +1189,100 @@ export class TitleScene extends Phaser.Scene { }); hint.setOrigin(0.5); - const confirm = this.createSettingsButton(0, ui(48), '새 게임 시작'); - confirm.setColor('#f1e3c2'); - confirm.on('pointerdown', () => { - this.closeNewGameConfirmPanel(); - this.startGame(); - }); + this.createSettingsButton( + panel, + 'new-game-confirm', + 'confirm', + 0, + ui(48), + '새 게임 시작', + () => { + this.closeNewGameConfirmPanel(false); + this.startGame(); + }, + '#f1e3c2' + ); - const cancel = this.createSettingsButton(0, ui(88), '취소'); - cancel.on('pointerdown', () => { - soundDirector.playSelect(); - this.closeNewGameConfirmPanel(); - }); + this.createSettingsButton( + panel, + 'new-game-confirm', + 'cancel', + 0, + ui(88), + '취소', + () => { + soundDirector.playSelect(); + this.closeNewGameConfirmPanel(); + } + ); - panel.add([backdrop, title, message, hint, confirm, cancel]); + panel.addAt([backdrop, title, message, hint], 0); panel.setAlpha(0); this.newGameConfirmPanel = panel; - this.tweens.add({ targets: panel, alpha: 1, y: y + ui(230), duration: 160, ease: 'Sine.easeOut' }); + this.tweens.add({ + targets: panel, + alpha: 1, + y: y + ui(230), + duration: this.visualMotionReduced ? 0 : 160, + ease: 'Sine.easeOut' + }); + this.setFocusScope('new-game-confirm', 'confirm'); } - private closeNewGameConfirmPanel() { + private closeNewGameConfirmPanel(restoreFocus = true) { + this.clearFocusScope('new-game-confirm'); this.newGameConfirmPanel?.destroy(); this.newGameConfirmPanel = undefined; + if (restoreFocus) { + this.setFocusScope('main', 'new-game'); + } } - private activateDefaultMenuAction() { - if (hasCampaignSave()) { - this.requestContinueGame(); - return; - } + getDebugState() { + const items = (this.focusItems.get(this.focusScope) ?? []).map((item) => { + const bounds = item.bounds(); + return { + id: item.id, + enabled: item.enabled, + focused: item.id === this.focusedItemId, + focusIndicatorVisible: item.marker.visible, + bounds: { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height + } + }; + }); - this.startGame(); + return { + scene: 'TitleScene', + navigating: this.navigating, + focus: { + scope: this.focusScope, + itemId: this.focusedItemId, + items + }, + panels: { + settings: Boolean(this.settingsPanel), + saveSlots: Boolean(this.saveSlotPanel), + newGameConfirm: Boolean(this.newGameConfirmPanel) + }, + motion: { + mode: loadVisualMotionMode(), + reduced: this.visualMotionReduced, + activeInfiniteTweenCount: this.titleMotionTweens.length, + background: this.titleBackground + ? { + x: this.titleBackground.x, + y: this.titleBackground.y, + scale: this.titleBackground.scale + } + : undefined, + mistAlpha: this.titleMist?.alpha, + petalCount: this.titlePetals.length + } + }; } private startGame() { diff --git a/src/main.ts b/src/main.ts index cb515de..3e335cb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -21,6 +21,10 @@ type DebugBattleScene = Phaser.Scene & { debugForceBattleOutcome?: (outcome: 'victory' | 'defeat') => void; }; +type DebugTitleScene = Phaser.Scene & { + getDebugState?: () => unknown; +}; + type DebugCampScene = Phaser.Scene & { getDebugState?: () => unknown; }; @@ -55,6 +59,7 @@ type HerosDebugApi = { game: Phaser.Game; activeScenes: () => string[]; audio: () => ReturnType; + title: () => unknown; battle: () => unknown; camp: () => unknown; village: () => unknown; @@ -115,6 +120,7 @@ if (debugEnabled) { function createDebugApi(game: Phaser.Game): HerosDebugApi { const scene = (key: string) => game.scene.getScene(key); + const titleScene = () => scene('TitleScene') as DebugTitleScene | undefined; const battleScene = () => scene('BattleScene') as DebugBattleScene | undefined; const campScene = () => scene('CampScene') as DebugCampScene | undefined; const villageScene = () => scene('PrologueVillageScene') as DebugVillageScene | undefined; @@ -127,6 +133,9 @@ function createDebugApi(game: Phaser.Game): HerosDebugApi { game, activeScenes: () => game.scene.getScenes(true).map((activeScene) => activeScene.scene.key), audio: () => soundDirector.getDebugState(), + title: () => game.scene.isActive('TitleScene') + ? titleScene()?.getDebugState?.() ?? { active: false, reason: 'TitleScene debug state is unavailable.' } + : { active: false, reason: 'TitleScene is not active yet.' }, battle: () => game.scene.isActive('BattleScene') ? battleScene()?.getDebugState?.() ?? { active: false, reason: 'BattleScene debug state is unavailable.' } : { active: false, reason: 'BattleScene is not active yet.' },