feat: complete keyboard flows and stabilize camp focus

This commit is contained in:
2026-07-27 20:52:57 +09:00
parent c22f77b4e1
commit 43e5533515
10 changed files with 1938 additions and 232 deletions

View File

@@ -79,9 +79,11 @@
"verify:flow:monolith": "node scripts/verify-flow.mjs", "verify:flow:monolith": "node scripts/verify-flow.mjs",
"verify:flow:segment": "node scripts/verify-flow.mjs", "verify:flow:segment": "node scripts/verify-flow.mjs",
"verify:interaction-ux": "node scripts/verify-interaction-ux.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:save-flow": "node scripts/verify-save-retry-flow.mjs",
"verify:release": "node scripts/verify-release-candidate.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: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", "verify:public-deploy": "node scripts/verify-public-deploy.mjs",
"qa:representative": "node scripts/qa-representative-battles.mjs", "qa:representative": "node scripts/qa-representative-battles.mjs",

View File

@@ -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);
}
}

View File

@@ -136,9 +136,11 @@ function validateCampInteractionUxGuards() {
checkedGuardCount += 1; checkedGuardCount += 1;
expectCampUx( 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'), 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; checkedGuardCount += 1;

View File

@@ -21,7 +21,8 @@ const fixtures = [
quickAchieved: false, quickAchieved: false,
sourceTurn: 34, sourceTurn: 34,
expectedVariant: 'fort-recovery', expectedVariant: 'fort-recovery',
expectedTargetDialogueId: 'liu-guan-after-guangzong-road' expectedTargetDialogueId: 'liu-guan-after-guangzong-road',
reducedMotion: true
}, },
{ {
id: 'canvas-fort-missed-quick-achieved', id: 'canvas-fort-missed-quick-achieved',
@@ -139,6 +140,14 @@ async function verifyFixture(browser, baseUrl, fixture) {
}); });
await waitForDebugApi(page); await waitForDebugApi(page);
await assertFhdViewport(page, fixture); 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); const seeded = await seedThirdVictoryCamp(page, fixture);
assert.deepEqual( assert.deepEqual(
@@ -160,16 +169,50 @@ async function verifyFixture(browser, baseUrl, fixture) {
await page.evaluate(async () => { await page.evaluate(async () => {
await window.__HEROS_DEBUG__.goToCamp(); 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( let camp = await waitForPriorityReturn(
page, page,
fixture, fixture,
false false,
true
); );
assert.equal(camp.campaign.step, 'third-camp'); assert.equal(camp.campaign.step, 'third-camp');
assert.equal(camp.campBattleId, sourceBattleId); 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, { assertPriorityReturn(camp.thirdBattlePriorityReturn, fixture, {
completed: false, completed: false,
requireRowBounds: false requireRowBounds: true
}); });
const dialogueTab = camp.campTabs.find( 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` `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; const priorityReturn = camp.thirdBattlePriorityReturn;
assertPriorityReturn(priorityReturn, fixture, { assertPriorityReturn(priorityReturn, fixture, {
completed: false, completed: false,

View File

@@ -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));
}

View File

@@ -114,6 +114,17 @@ try {
titleSource.includes('ui(468)'), titleSource.includes('ui(468)'),
'The title settings panel must expose the persisted motion toggle in its expanded layout.' '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'); const battleSource = readFileSync('src/game/scenes/BattleScene.ts', 'utf8');
assert( assert(

View File

@@ -1728,6 +1728,24 @@ type CommandButton = {
command: BattleCommand; command: BattleCommand;
background: Phaser.GameObjects.Rectangle; background: Phaser.GameObjects.Rectangle;
text: Phaser.GameObjects.Text; 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 = { type PendingMove = {
@@ -3839,7 +3857,14 @@ export class BattleScene extends Phaser.Scene {
private sidePanelTransitionActive = false; private sidePanelTransitionActive = false;
private sidePanelTransitionLast?: SidePanelTransitionSnapshot; private sidePanelTransitionLast?: SidePanelTransitionSnapshot;
private commandButtons: CommandButton[] = []; private commandButtons: CommandButton[] = [];
private usableMenuButtons: UsableMenuButton[] = [];
private commandMenuObjects: Phaser.GameObjects.GameObject[] = []; 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 deploymentObjects: Phaser.GameObjects.GameObject[] = [];
private deploymentSelectedUnitId?: string; private deploymentSelectedUnitId?: string;
private deploymentNotice = '추천 배치는 이미 적용되어 있습니다. 필요하면 장수를 선택해 시작 구역 안에서 위치를 바꾸세요.'; private deploymentNotice = '추천 배치는 이미 적용되어 있습니다. 필요하면 장수를 선택해 시작 구역 안에서 위치를 바꾸세요.';
@@ -4069,7 +4094,14 @@ export class BattleScene extends Phaser.Scene {
this.terrainPanelLayout = undefined; this.terrainPanelLayout = undefined;
this.sidePanelObjects = []; this.sidePanelObjects = [];
this.commandButtons = []; this.commandButtons = [];
this.usableMenuButtons = [];
this.commandMenuObjects = []; this.commandMenuObjects = [];
this.commandMenuMode = undefined;
this.commandMenuUnitId = undefined;
this.commandMenuParentCommand = undefined;
this.commandMenuAnchor = undefined;
this.commandMenuFocusedIndex = -1;
this.commandMenuFocusMarker = undefined;
this.deploymentObjects = []; this.deploymentObjects = [];
this.mapMenuObjects = []; this.mapMenuObjects = [];
this.mapMenuLayout = undefined; this.mapMenuLayout = undefined;
@@ -4351,6 +4383,10 @@ export class BattleScene extends Phaser.Scene {
if (this.tacticalInitiativePanelObjects.length > 0) { if (this.tacticalInitiativePanelObjects.length > 0) {
return; return;
} }
if (this.commandMenuObjects.length > 0) {
this.activateCommandMenuFocusedAction();
return;
}
if (this.turnPromptObjects.length > 0) { if (this.turnPromptObjects.length > 0) {
this.activateTurnPromptFocusedAction(); this.activateTurnPromptFocusedAction();
return; return;
@@ -4404,6 +4440,15 @@ export class BattleScene extends Phaser.Scene {
return; return;
} }
if (this.commandMenuObjects.length > 0) {
if (this.returnFromUsableMenu()) {
return;
}
if (this.cancelPendingMove()) {
return;
}
}
if (this.cancelDeploymentSelection()) { if (this.cancelDeploymentSelection()) {
soundDirector.playSelect(); soundDirector.playSelect();
return; return;
@@ -4452,6 +4497,9 @@ export class BattleScene extends Phaser.Scene {
if (this.handleTacticalInitiativeKey(event)) { if (this.handleTacticalInitiativeKey(event)) {
return; return;
} }
if (this.handleCommandMenuNavigationKey(event)) {
return;
}
if (this.handleBattleKeyboardNavigation(event)) { if (this.handleBattleKeyboardNavigation(event)) {
return; return;
} }
@@ -8392,6 +8440,183 @@ export class BattleScene extends Phaser.Scene {
this.lastPointerFeedbackKey = undefined; 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) { private handleBattleKeyboardNavigation(event: KeyboardEvent) {
if ( if (
(this.phase !== 'moving' && this.phase !== 'targeting') || (this.phase !== 'moving' && this.phase !== 'targeting') ||
@@ -8763,7 +8988,10 @@ export class BattleScene extends Phaser.Scene {
} }
this.showCommandMenu(unit, commandX, commandY); this.showCommandMenu(unit, commandX, commandY);
this.renderUnitDetail(unit, '명령 선택 · 우클릭은 이동 취소'); this.renderUnitDetail(
unit,
'명령 선택 · 우클릭은 이동 취소\n키보드: ↑↓/Tab 이동 · Enter 선택 · Esc 이동 취소'
);
this.hideTurnEndPrompt(); this.hideTurnEndPrompt();
if (this.firstBattleTutorialStep === 'move') { if (this.firstBattleTutorialStep === 'move') {
this.suppressNextLeftClick = true; this.suppressNextLeftClick = true;
@@ -10951,6 +11179,9 @@ export class BattleScene extends Phaser.Scene {
private showCommandMenu(unit: UnitData, pointerX: number, pointerY: number) { private showCommandMenu(unit: UnitData, pointerX: number, pointerY: number) {
this.hideCommandMenu(); this.hideCommandMenu();
this.commandMenuMode = 'command';
this.commandMenuUnitId = unit.id;
this.commandMenuAnchor = { x: pointerX, y: pointerY };
const commands: BattleCommand[] = ['attack', 'strategy', 'item', 'wait']; const commands: BattleCommand[] = ['attack', 'strategy', 'item', 'wait'];
const menuWidth = 330; const menuWidth = 330;
@@ -11064,27 +11295,13 @@ export class BattleScene extends Phaser.Scene {
} }
if (choicePointer.leftButtonDown()) { if (choicePointer.leftButtonDown()) {
this.suppressNextLeftClick = true; this.suppressNextLeftClick = true;
if (!this.acceptFirstBattleTutorialCommand(command)) { this.activateCommandMenuChoice(unit, command, choicePointer.x, choicePointer.y);
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);
} }
}; };
const showDetail = () => { const showDetail = () => {
if (info.available) {
this.setCommandMenuFocus(index);
}
background.setFillStyle(info.available ? 0x283947 : 0x27303a, 0.98); background.setFillStyle(info.available ? 0x283947 : 0x27303a, 0.98);
this.renderUnitDetail(unit, `${commandLabels[command]}\n${info.detail}`); this.renderUnitDetail(unit, `${commandLabels[command]}\n${info.detail}`);
}; };
@@ -11097,8 +11314,47 @@ export class BattleScene extends Phaser.Scene {
text.on('pointerout', clearHover); text.on('pointerout', clearHover);
text.on('pointerdown', chooseCommand); 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) { private commandMenuPosition(pointerX: number, pointerY: number, width: number, height: number) {
@@ -11477,6 +11733,13 @@ export class BattleScene extends Phaser.Scene {
text.destroy(); text.destroy();
}); });
this.commandButtons = []; 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) { private showUsableMenu(unit: UnitData, command: UsableCommand, pointerX: number, pointerY: number) {
@@ -11487,6 +11750,10 @@ export class BattleScene extends Phaser.Scene {
} }
this.hideCommandMenu(); 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 forecasts = usables.map((usable) => ({ usable, forecast: this.usableMenuForecast(unit, usable) }));
const readyCount = forecasts.filter(({ forecast }) => forecast.available).length; const readyCount = forecasts.filter(({ forecast }) => forecast.available).length;
@@ -11645,7 +11912,7 @@ export class BattleScene extends Phaser.Scene {
const choose = (choicePointer: Phaser.Input.Pointer) => { const choose = (choicePointer: Phaser.Input.Pointer) => {
if (choicePointer.rightButtonDown()) { if (choicePointer.rightButtonDown()) {
this.showCommandMenu(unit, pointerX, pointerY); this.returnFromUsableMenu();
return; return;
} }
if (choicePointer.leftButtonDown()) { if (choicePointer.leftButtonDown()) {
@@ -11654,10 +11921,13 @@ export class BattleScene extends Phaser.Scene {
showDetail(); showDetail();
return; return;
} }
this.beginUsableTargeting(unit, usable); this.activateUsableMenuChoice(unit, usable);
} }
}; };
const showDetail = () => { const showDetail = () => {
if (forecast.available) {
this.setCommandMenuFocus(index);
}
bg.setFillStyle(forecast.available ? 0x283947 : 0x27303a, 0.98); bg.setFillStyle(forecast.available ? 0x283947 : 0x27303a, 0.98);
this.renderUnitDetail(unit, this.usableDetailText(unit, usable)); this.renderUnitDetail(unit, this.usableDetailText(unit, usable));
}; };
@@ -11673,7 +11943,29 @@ export class BattleScene extends Phaser.Scene {
name.on('pointerover', showDetail); name.on('pointerover', showDetail);
name.on('pointerout', clearHover); name.on('pointerout', clearHover);
name.on('pointerdown', choose); 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) { private availableUsables(unit: UnitData, command: UsableCommand) {
@@ -26473,7 +26765,10 @@ export class BattleScene extends Phaser.Scene {
this.clearMarkers(); this.clearMarkers();
soundDirector.playSelect(); soundDirector.playSelect();
this.showCommandMenu(unit, view?.sprite.x ?? this.tileCenterX(unit.x), view?.sprite.y ?? this.tileCenterY(unit.y)); 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') { if (this.firstBattleTutorialStep === 'target-preview') {
this.setFirstBattleTutorialStep('attack-command', '대상 선택을 취소해 공격 명령 단계로 돌아왔습니다.'); 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() { private battleHudDebugState() {
const miniMap = this.miniMapLayout; const miniMap = this.miniMapLayout;
const selectedDot = this.selectedUnit ? this.miniMapUnitDots.get(this.selectedUnit.id) : undefined; const selectedDot = this.selectedUnit ? this.miniMapUnitDots.get(this.selectedUnit.id) : undefined;
@@ -31868,6 +32237,7 @@ export class BattleScene extends Phaser.Scene {
this.mapBackground?.active && this.mapBackground.texture.key === battleScenario.mapTextureKey this.mapBackground?.active && this.mapBackground.texture.key === battleScenario.mapTextureKey
), ),
commandMenuVisible: this.commandMenuObjects.length > 0, commandMenuVisible: this.commandMenuObjects.length > 0,
commandMenuKeyboard: this.commandMenuKeyboardDebugState(),
mapMenuVisible: this.mapMenuObjects.length > 0, mapMenuVisible: this.mapMenuObjects.length > 0,
saveSlotPanelVisible: this.saveSlotPanelObjects.length > 0, saveSlotPanelVisible: this.saveSlotPanelObjects.length > 0,
saveSlotPanelMode: this.saveSlotPanelMode ?? null, saveSlotPanelMode: this.saveSlotPanelMode ?? null,

View File

@@ -261,6 +261,7 @@ import {
} from '../state/thirdCampPreparationActions'; } from '../state/thirdCampPreparationActions';
import { clearCampaignBattleSavesForSlot } from '../state/battleSaveKeys'; import { clearCampaignBattleSavesForSlot } from '../state/battleSaveKeys';
import { releaseTextureSource } from '../render/loaderLifecycle'; import { releaseTextureSource } from '../render/loaderLifecycle';
import { isVisualMotionReduced } from '../settings/visualMotion';
import { palette } from '../ui/palette'; import { palette } from '../ui/palette';
import { ensureLazyScene, type LazySceneKey } from './lazyScenes'; import { ensureLazyScene, type LazySceneKey } from './lazyScenes';
@@ -11461,6 +11462,7 @@ export class CampScene extends Phaser.Scene {
private campSkinTransitionRevision = 0; private campSkinTransitionRevision = 0;
private campSkinTransitionCompletedRevision = 0; private campSkinTransitionCompletedRevision = 0;
private campSkinTransitionActive = false; private campSkinTransitionActive = false;
private visualMotionReduced = false;
private ownedCampTextureKeys = new Set<string>(); private ownedCampTextureKeys = new Set<string>();
private campSkinTransitionLast?: { private campSkinTransitionLast?: {
kind: 'backdrop-fade'; kind: 'backdrop-fade';
@@ -11593,6 +11595,7 @@ export class CampScene extends Phaser.Scene {
private retrySortieBattleId?: BattleScenarioId; private retrySortieBattleId?: BattleScenarioId;
private skipIntroStoryForSortie = false; private skipIntroStoryForSortie = false;
private navigationPending = false; private navigationPending = false;
private campNoticeRemovalTimer?: Phaser.Time.TimerEvent;
constructor() { constructor() {
super('CampScene'); super('CampScene');
@@ -11615,6 +11618,10 @@ export class CampScene extends Phaser.Scene {
create() { create() {
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => this.releaseOwnedCampTextures()); this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => this.releaseOwnedCampTextures());
this.navigationPending = false; this.navigationPending = false;
this.visualMotionReduced = isVisualMotionReduced();
this.activeTab = 'status';
this.selectedCityLocation = 'information';
this.campNoticeRemovalTimer = undefined;
this.contentObjects = []; this.contentObjects = [];
this.campRosterPage = 0; this.campRosterPage = 0;
this.campRosterLayout = undefined; this.campRosterLayout = undefined;
@@ -11755,8 +11762,6 @@ export class CampScene extends Phaser.Scene {
: !dialogueComplete : !dialogueComplete
? 'dialogue' ? 'dialogue'
: 'market'; : 'market';
} else if (this.activeTab === 'city') {
this.activeTab = 'status';
} }
const pendingDirectExplorationVisit = this.availableCampVisits().find( const pendingDirectExplorationVisit = this.availableCampVisits().find(
(visit) => (visit) =>
@@ -11766,6 +11771,8 @@ export class CampScene extends Phaser.Scene {
if (!cityStay && pendingDirectExplorationVisit) { if (!cityStay && pendingDirectExplorationVisit) {
this.selectedVisitId = pendingDirectExplorationVisit.id; this.selectedVisitId = pendingDirectExplorationVisit.id;
this.activeTab = 'visit'; this.activeTab = 'visit';
} else if (!cityStay && pendingPriorityReturnDialogueId) {
this.activeTab = 'dialogue';
} }
this.initializeCityEquipmentContext(); this.initializeCityEquipmentContext();
soundDirector.playSoundscape({ soundDirector.playSoundscape({
@@ -11865,9 +11872,9 @@ export class CampScene extends Phaser.Scene {
fontStyle: '700' fontStyle: '700'
})).setOrigin(0.5).setDepth(-17); })).setOrigin(0.5).setDepth(-17);
const transitionDurationMs = 420; const transitionDurationMs = this.visualMotionReduced ? 0 : 420;
const transitionRevision = ++this.campSkinTransitionRevision; const transitionRevision = ++this.campSkinTransitionRevision;
this.campSkinTransitionActive = true; this.campSkinTransitionActive = !this.visualMotionReduced;
this.campSkinTransitionLast = { this.campSkinTransitionLast = {
kind: 'backdrop-fade', kind: 'backdrop-fade',
durationMs: transitionDurationMs, durationMs: transitionDurationMs,
@@ -11875,19 +11882,24 @@ export class CampScene extends Phaser.Scene {
fromAlpha: 0, fromAlpha: 0,
toAlpha: skin.backdropAlpha toAlpha: skin.backdropAlpha
}; };
this.tweens.add({ if (this.visualMotionReduced) {
targets: backdrop, backdrop.setAlpha(skin.backdropAlpha);
alpha: skin.backdropAlpha, this.campSkinTransitionCompletedRevision = transitionRevision;
duration: transitionDurationMs, } else {
ease: 'Sine.easeOut', this.tweens.add({
onComplete: () => { targets: backdrop,
if (this.campSkinBackdrop !== backdrop) { alpha: skin.backdropAlpha,
return; 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.renderStaticButtons();
this.render(); this.render();
@@ -25162,21 +25174,30 @@ export class CampScene extends Phaser.Scene {
box.setDepth(depth); box.setDepth(depth);
const noticeObjects: Phaser.GameObjects.GameObject[] = [box, text]; const noticeObjects: Phaser.GameObjects.GameObject[] = [box, text];
this.dialogueObjects = noticeObjects; this.dialogueObjects = noticeObjects;
this.tweens.add({ const removeNotice = () => {
targets: noticeObjects, noticeObjects.forEach((object) => object.active && object.destroy());
alpha: 0, if (this.dialogueObjects === noticeObjects) {
delay: this.campNoticeHoldDuration(message), this.dialogueObjects = [];
duration: 320,
onComplete: () => {
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() { private clearCampNotice() {
this.campNoticeRemovalTimer?.remove(false);
this.campNoticeRemovalTimer = undefined;
this.tweens.killTweensOf(this.dialogueObjects); this.tweens.killTweensOf(this.dialogueObjects);
this.dialogueObjects.forEach((object) => object.active && object.destroy()); this.dialogueObjects.forEach((object) => object.active && object.destroy());
this.dialogueObjects = []; this.dialogueObjects = [];
@@ -26228,6 +26249,7 @@ export class CampScene extends Phaser.Scene {
: undefined; : undefined;
return { return {
scene: this.scene.key, scene: this.scene.key,
visualMotionReduced: this.visualMotionReduced,
victoryRewardAcknowledgement: { victoryRewardAcknowledgement: {
visible: this.victoryRewardObjects.length > 0, visible: this.victoryRewardObjects.length > 0,
battleId: this.pendingVictoryRewardReport?.battleId ?? null, battleId: this.pendingVictoryRewardReport?.battleId ?? null,

View File

@@ -14,6 +14,7 @@ import {
updateAudioPreference updateAudioPreference
} from '../settings/audioPreferences'; } from '../settings/audioPreferences';
import { import {
isVisualMotionReduced,
loadVisualMotionMode, loadVisualMotionMode,
nextVisualMotionMode, nextVisualMotionMode,
saveVisualMotionMode, saveVisualMotionMode,
@@ -43,11 +44,31 @@ const fhdUiScale = 1.5;
const ui = (value: number) => value * fhdUiScale; const ui = (value: number) => value * fhdUiScale;
const uiPx = (value: number) => `${Math.round(ui(value))}px`; 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 { export class TitleScene extends Phaser.Scene {
private focusedButton?: Phaser.GameObjects.Text;
private settingsPanel?: Phaser.GameObjects.Container; private settingsPanel?: Phaser.GameObjects.Container;
private newGameConfirmPanel?: Phaser.GameObjects.Container; private newGameConfirmPanel?: Phaser.GameObjects.Container;
private saveSlotPanel?: Phaser.GameObjects.Container; private saveSlotPanel?: Phaser.GameObjects.Container;
private readonly focusItems = new Map<TitleFocusScope, TitleFocusItem[]>();
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<KeyboardEvent>();
private navigating = false; private navigating = false;
constructor() { constructor() {
@@ -55,10 +76,18 @@ export class TitleScene extends Phaser.Scene {
} }
create() { create() {
this.focusedButton = undefined;
this.settingsPanel = undefined; this.settingsPanel = undefined;
this.newGameConfirmPanel = undefined; this.newGameConfirmPanel = undefined;
this.saveSlotPanel = 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<KeyboardEvent>();
this.navigating = false; this.navigating = false;
const debugBattleId = this.debugBattleId(); const debugBattleId = this.debugBattleId();
@@ -90,6 +119,7 @@ export class TitleScene extends Phaser.Scene {
const { width, height } = this.scale; const { width, height } = this.scale;
this.drawBackground(width, height); this.drawBackground(width, height);
this.drawAtmosphere(width, height); this.drawAtmosphere(width, height);
this.applyTitleMotionPreference(this.visualMotionReduced);
this.drawTitleMark(width, height); this.drawTitleMark(width, height);
this.drawMenu(width, height); this.drawMenu(width, height);
@@ -102,9 +132,11 @@ export class TitleScene extends Phaser.Scene {
this.input.once('pointerdown', unlockAudio); this.input.once('pointerdown', unlockAudio);
this.input.keyboard?.once('keydown', unlockAudio); this.input.keyboard?.once('keydown', unlockAudio);
this.input.keyboard?.on('keydown-ENTER', (event: KeyboardEvent) => this.handleEnterKey(event)); this.input.keyboard?.on('keydown', this.handleKeyboardInput, this);
this.input.keyboard?.on('keydown', (event: KeyboardEvent) => this.handleSaveSlotKey(event)); this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
this.input.keyboard?.on('keydown-ESC', () => this.handleEscapeKey()); this.input.keyboard?.off('keydown', this.handleKeyboardInput, this);
this.stopTitleMotionTweens();
});
} }
private shouldOpenDebugSortiePrep() { private shouldOpenDebugSortiePrep() {
@@ -173,18 +205,8 @@ export class TitleScene extends Phaser.Scene {
const texture = this.textures.get('title-taoyuan').getSourceImage(); const texture = this.textures.get('title-taoyuan').getSourceImage();
const coverScale = Math.max(width / texture.width, height / texture.height); const coverScale = Math.max(width / texture.width, height / texture.height);
background.setScale(coverScale * 1.08); this.titleBackground = background;
background.setPosition(width / 2 - ui(18), height / 2 + ui(4)); this.titleBackgroundCoverScale = coverScale;
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.add.rectangle(0, 0, width, height, 0x080a0e, 0.2).setOrigin(0); this.add.rectangle(0, 0, width, height, 0x080a0e, 0.2).setOrigin(0);
this.add.image(0, 0, this.createShadeTexture(width, height)).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) { private drawAtmosphere(width: number, height: number) {
const mist = this.add.ellipse(width / 2, height * 0.78, width * 1.25, ui(190), 0xd8b15f, 0.04); const mist = this.add.ellipse(width / 2, height * 0.78, width * 1.25, ui(190), 0xd8b15f, 0.04);
this.tweens.add({ this.titleMist = mist;
targets: mist,
alpha: 0.11,
x: width / 2 + ui(22),
duration: 8000,
ease: 'Sine.easeInOut',
yoyo: true,
repeat: -1
});
for (let index = 0; index < 34; index += 1) { for (let index = 0; index < 34; index += 1) {
const petal = this.add.image( const petal = this.add.image(
@@ -244,8 +258,63 @@ export class TitleScene extends Phaser.Scene {
petal.setScale(scale * fhdUiScale); petal.setScale(scale * fhdUiScale);
petal.setAlpha(Phaser.Math.FloatBetween(0.22, 0.62)); petal.setAlpha(Phaser.Math.FloatBetween(0.22, 0.62));
petal.setRotation(Phaser.Math.FloatBetween(-1.2, 1.2)); 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, targets: petal,
x: petal.x + Phaser.Math.Between(ui(80), ui(220)), x: petal.x + Phaser.Math.Between(ui(80), ui(220)),
y: height + Phaser.Math.Between(ui(40), ui(180)), 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))); 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) { 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.7);
this.add.rectangle(menuX, menuY + plateHeight / 2, ui(186), ui(2), palette.gold, 0.36); 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('new-game', menuX, menuY - ui(70), '새 게임', true, () => this.requestStartGame());
this.createMenuButton(menuX, menuY, isEndingComplete ? '엔딩 보기' : '이어하기', canContinue, () => this.requestContinueGame()); this.createMenuButton(
this.createMenuButton(menuX, menuY + ui(70), '설정', true, () => this.openSettingsPanel(menuX, menuY)); 'continue',
menuX,
menuY,
isEndingComplete ? '엔딩 보기' : '이어하기',
canContinue,
() => this.requestContinueGame()
);
this.createMenuButton('settings', menuX, menuY + ui(70), '설정', true, () => this.openSettingsPanel(menuX, menuY));
if (campaign) { if (campaign) {
this.drawCampaignSaveBadge(menuX, menuY + ui(132), campaign, isEndingComplete); this.drawCampaignSaveBadge(menuX, menuY + ui(132), campaign, isEndingComplete);
} }
this.setFocusScope('main', canContinue ? 'continue' : 'new-game');
} }
private drawCampaignSaveBadge( private drawCampaignSaveBadge(
@@ -404,6 +493,7 @@ export class TitleScene extends Phaser.Scene {
} }
private createMenuButton( private createMenuButton(
id: string,
x: number, x: number,
y: number, y: number,
label: string, label: string,
@@ -421,41 +511,49 @@ export class TitleScene extends Phaser.Scene {
text.setOrigin(0.5); text.setOrigin(0.5);
text.setShadow(0, ui(3), '#080a0e', ui(8), true, true); text.setShadow(0, ui(3), '#080a0e', ui(8), true, true);
if (!enabled) { const marker = this.add.text(x - ui(116), y, '▶', {
return text; 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)); const hitArea = this.add.zone(x, y, ui(216), ui(58));
hitArea.setOrigin(0.5); hitArea.setOrigin(0.5);
hitArea.setInteractive({ useHandCursor: true }); const render = (focused: boolean) => {
hitArea.on('pointerover', () => { marker.setVisible(enabled && focused);
this.focusButton(text); text.setColor(!enabled ? '#7d8189' : focused ? '#111821' : '#f1e3c2');
soundDirector.playHover(); text.setBackgroundColor(enabled && focused ? '#d8b15f' : 'rgba(0,0,0,0)');
}); };
hitArea.on('pointerout', () => this.unfocusButton(text));
hitArea.on('pointerdown', () => { this.registerFocusItem('main', {
soundDirector.start(); id,
soundDirector.resume(); enabled,
onSelect(); 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; 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) { private openSettingsPanel(x: number, y: number) {
if (this.settingsPanel) { if (this.settingsPanel) {
this.closeSettingsPanel(); this.closeSettingsPanel();
@@ -463,8 +561,9 @@ export class TitleScene extends Phaser.Scene {
} }
soundDirector.playSelect(); soundDirector.playSelect();
this.closeNewGameConfirmPanel(); this.closeNewGameConfirmPanel(false);
this.closeSaveSlotPanel(); this.closeSaveSlotPanel(false);
this.clearFocusScope('settings');
const panel = this.add.container(x, y + ui(126)); const panel = this.add.container(x, y + ui(126));
const backdrop = this.add.rectangle(0, 0, ui(320), ui(468), 0x0b1118, 1); 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); title.setOrigin(0.5);
const soundText = this.createSettingsButton(0, -ui(126), this.soundLabel()); this.createSettingsButton(
soundText.on('pointerdown', () => { panel,
const preferences = loadAudioPreferences(); 'settings',
const muted = !soundDirector.isMuted(); 'sound',
soundDirector.setMuted(muted); 0,
saveAudioPreferences({ ...preferences, muted }); -ui(126),
soundText.setText(this.soundLabel()); this.soundLabel(),
soundDirector.playSelect(); (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')); this.createSettingsButton(
musicText.on('pointerdown', () => this.cycleAudioLevel('music', musicText)); panel,
'settings',
'music',
0,
-ui(84),
this.audioCategoryLabel('music'),
(text) => this.cycleAudioLevel('music', text)
);
const effectsText = this.createSettingsButton(0, -ui(42), this.audioCategoryLabel('effects')); this.createSettingsButton(
effectsText.on('pointerdown', () => this.cycleAudioLevel('effects', effectsText)); panel,
'settings',
'effects',
0,
-ui(42),
this.audioCategoryLabel('effects'),
(text) => this.cycleAudioLevel('effects', text)
);
const ambienceText = this.createSettingsButton(0, 0, this.audioCategoryLabel('ambience')); this.createSettingsButton(
ambienceText.on('pointerdown', () => this.cycleAudioLevel('ambience', ambienceText)); panel,
'settings',
'ambience',
0,
0,
this.audioCategoryLabel('ambience'),
(text) => this.cycleAudioLevel('ambience', text)
);
const presentationText = this.createSettingsButton(0, ui(42), this.combatPresentationLabel()); this.createSettingsButton(
presentationText.on('pointerdown', () => { panel,
const nextMode = nextCombatPresentationMode(loadCombatPresentationMode()); 'settings',
saveCombatPresentationMode(nextMode); 'combat-presentation',
presentationText.setText(this.combatPresentationLabel()); 0,
soundDirector.playSelect(); 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()); this.createSettingsButton(
motionText.on('pointerdown', () => { panel,
const nextMode = nextVisualMotionMode(loadVisualMotionMode()); 'settings',
saveVisualMotionMode(nextMode); 'visual-motion',
motionText.setText(this.visualMotionLabel()); 0,
soundDirector.playSelect(); 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), '닫기'); this.createSettingsButton(
close.on('pointerdown', () => { panel,
soundDirector.playSelect(); 'settings',
this.closeSettingsPanel(); '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', fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: uiPx(11), fontSize: uiPx(11),
color: '#9fb0bf', color: '#9fb0bf',
@@ -531,24 +680,70 @@ export class TitleScene extends Phaser.Scene {
}); });
hint.setOrigin(0.5); 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); panel.setAlpha(0);
this.settingsPanel = panel; 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<TitleFocusScope, 'main'>,
id: string,
x: number,
y: number,
label: string,
onSelect: (text: Phaser.GameObjects.Text) => void,
baseColor = '#d8b15f'
) {
const text = this.add.text(x, y, label, { const text = this.add.text(x, y, label, {
fontSize: uiPx(20), fontSize: uiPx(20),
color: '#d8b15f', color: baseColor,
fixedWidth: ui(210), fixedWidth: ui(210),
align: 'center', align: 'center',
padding: { top: ui(8), bottom: ui(8) } padding: { top: ui(8), bottom: ui(8) }
}); });
text.setOrigin(0.5); text.setOrigin(0.5);
text.setInteractive({ useHandCursor: true }); 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; return text;
} }
@@ -584,9 +779,13 @@ export class TitleScene extends Phaser.Scene {
return `화면 움직임: ${visualMotionModeLabel(loadVisualMotionMode())}`; return `화면 움직임: ${visualMotionModeLabel(loadVisualMotionMode())}`;
} }
private closeSettingsPanel() { private closeSettingsPanel(restoreFocus = true) {
this.clearFocusScope('settings');
this.settingsPanel?.destroy(); this.settingsPanel?.destroy();
this.settingsPanel = undefined; this.settingsPanel = undefined;
if (restoreFocus) {
this.setFocusScope('main', 'settings');
}
} }
private requestContinueGame() { private requestContinueGame() {
@@ -614,12 +813,14 @@ export class TitleScene extends Phaser.Scene {
} }
soundDirector.playSelect(); soundDirector.playSelect();
this.closeSettingsPanel(); this.closeSettingsPanel(false);
this.closeNewGameConfirmPanel(); this.closeNewGameConfirmPanel(false);
this.clearFocusScope('save-slots');
const panel = this.add.container(x, y + ui(252)); const panel = this.add.container(x, y + ui(252));
const backdrop = this.add.rectangle(0, 0, ui(318), ui(280), 0x0e151d, 0.95); const backdrop = this.add.rectangle(0, 0, ui(318), ui(280), 0x0e151d, 0.95);
backdrop.setStrokeStyle(ui(1), palette.gold, 0.58); backdrop.setStrokeStyle(ui(1), palette.gold, 0.58);
backdrop.setInteractive();
const title = this.add.text(0, -ui(116), '이어갈 기록 선택 (1~3)', { const title = this.add.text(0, -ui(116), '이어갈 기록 선택 (1~3)', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
@@ -632,13 +833,20 @@ export class TitleScene extends Phaser.Scene {
title.setOrigin(0.5); title.setOrigin(0.5);
const rows = listCampaignSaveSlots().map((slot, index) => this.createSaveSlotRow(slot, -ui(82) + index * ui(58))); const rows = listCampaignSaveSlots().map((slot, index) => this.createSaveSlotRow(slot, -ui(82) + index * ui(58)));
const close = this.createSettingsButton(0, ui(96), '닫기'); this.createSettingsButton(
close.on('pointerdown', () => { panel,
soundDirector.playSelect(); 'save-slots',
this.closeSaveSlotPanel(); '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', fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: uiPx(11), fontSize: uiPx(11),
color: '#9fb0bf', color: '#9fb0bf',
@@ -648,10 +856,18 @@ export class TitleScene extends Phaser.Scene {
}); });
hint.setOrigin(0.5); hint.setOrigin(0.5);
panel.add([backdrop, title, ...rows, close, hint]); panel.addAt([backdrop, title, ...rows, hint], 0);
panel.setAlpha(0); panel.setAlpha(0);
this.saveSlotPanel = panel; 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) { private createSaveSlotRow(slot: CampaignSaveSlotSummary, y: number) {
@@ -701,20 +917,41 @@ export class TitleScene extends Phaser.Scene {
}); });
keyText.setOrigin(0.5); 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) { if (enabled) {
background.setInteractive({ useHandCursor: true }); background.setInteractive({ useHandCursor: true });
background.on('pointerover', () => { background.on('pointerover', () => this.focusItem('save-slots', `slot-${slot.slot}`, true));
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('pointerdown', () => { background.on('pointerdown', () => {
this.focusItem('save-slots', `slot-${slot.slot}`);
this.activateSaveSlot(slot.slot); this.activateSaveSlot(slot.slot);
}); });
} }
@@ -722,54 +959,156 @@ export class TitleScene extends Phaser.Scene {
return row; 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) { private activateSaveSlot(slot: number) {
if (!listCampaignSaveSlots().some((summary) => summary.slot === slot && summary.exists)) { if (!listCampaignSaveSlots().some((summary) => summary.slot === slot && summary.exists)) {
return; return;
} }
this.closeSaveSlotPanel(); this.closeSaveSlotPanel(false);
this.continueGame(slot); this.continueGame(slot);
} }
private closeSaveSlotPanel() { private closeSaveSlotPanel(restoreFocus = true) {
this.clearFocusScope('save-slots');
this.saveSlotPanel?.destroy(); this.saveSlotPanel?.destroy();
this.saveSlotPanel = undefined; this.saveSlotPanel = undefined;
if (restoreFocus) {
this.setFocusScope('main', 'continue');
}
} }
private handleEnterKey(event?: KeyboardEvent) { private registerFocusItem(scope: TitleFocusScope, item: TitleFocusItem) {
if (event?.repeat || this.navigating) { 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; return;
} }
if (this.saveSlotPanel) { const nextItem = (this.focusItems.get(scope) ?? []).find((item) => item.id === id && item.enabled);
if (!nextItem) {
return; return;
} }
if (this.newGameConfirmPanel) { const changed = this.focusScope !== scope || this.focusedItemId !== id;
this.closeNewGameConfirmPanel(); this.currentFocusItem()?.render(false);
this.startGame(); this.focusScope = scope;
return; this.focusedItemId = id;
nextItem.render(true);
if (changed && playHover) {
soundDirector.playHover();
} }
}
private activeModalScope(): Exclude<TitleFocusScope, 'main'> | undefined {
if (this.settingsPanel) { 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; 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() { private handleEscapeKey() {
@@ -811,12 +1150,14 @@ export class TitleScene extends Phaser.Scene {
} }
soundDirector.playSelect(); soundDirector.playSelect();
this.closeSettingsPanel(); this.closeSettingsPanel(false);
this.closeSaveSlotPanel(); this.closeSaveSlotPanel(false);
this.clearFocusScope('new-game-confirm');
const panel = this.add.container(x, y + ui(240)); const panel = this.add.container(x, y + ui(240));
const backdrop = this.add.rectangle(0, 0, ui(286), ui(198), 0x0e151d, 0.94); const backdrop = this.add.rectangle(0, 0, ui(286), ui(198), 0x0e151d, 0.94);
backdrop.setStrokeStyle(ui(1), palette.gold, 0.58); backdrop.setStrokeStyle(ui(1), palette.gold, 0.58);
backdrop.setInteractive();
const title = this.add.text(0, -ui(72), '새로 시작', { const title = this.add.text(0, -ui(72), '새로 시작', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
@@ -838,7 +1179,7 @@ export class TitleScene extends Phaser.Scene {
}); });
message.setOrigin(0.5); 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', fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: uiPx(11), fontSize: uiPx(11),
color: '#9fb0bf', color: '#9fb0bf',
@@ -848,37 +1189,100 @@ export class TitleScene extends Phaser.Scene {
}); });
hint.setOrigin(0.5); hint.setOrigin(0.5);
const confirm = this.createSettingsButton(0, ui(48), '새 게임 시작'); this.createSettingsButton(
confirm.setColor('#f1e3c2'); panel,
confirm.on('pointerdown', () => { 'new-game-confirm',
this.closeNewGameConfirmPanel(); 'confirm',
this.startGame(); 0,
}); ui(48),
'새 게임 시작',
() => {
this.closeNewGameConfirmPanel(false);
this.startGame();
},
'#f1e3c2'
);
const cancel = this.createSettingsButton(0, ui(88), '취소'); this.createSettingsButton(
cancel.on('pointerdown', () => { panel,
soundDirector.playSelect(); 'new-game-confirm',
this.closeNewGameConfirmPanel(); '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); panel.setAlpha(0);
this.newGameConfirmPanel = panel; 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?.destroy();
this.newGameConfirmPanel = undefined; this.newGameConfirmPanel = undefined;
if (restoreFocus) {
this.setFocusScope('main', 'new-game');
}
} }
private activateDefaultMenuAction() { getDebugState() {
if (hasCampaignSave()) { const items = (this.focusItems.get(this.focusScope) ?? []).map((item) => {
this.requestContinueGame(); const bounds = item.bounds();
return; 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() { private startGame() {

View File

@@ -21,6 +21,10 @@ type DebugBattleScene = Phaser.Scene & {
debugForceBattleOutcome?: (outcome: 'victory' | 'defeat') => void; debugForceBattleOutcome?: (outcome: 'victory' | 'defeat') => void;
}; };
type DebugTitleScene = Phaser.Scene & {
getDebugState?: () => unknown;
};
type DebugCampScene = Phaser.Scene & { type DebugCampScene = Phaser.Scene & {
getDebugState?: () => unknown; getDebugState?: () => unknown;
}; };
@@ -55,6 +59,7 @@ type HerosDebugApi = {
game: Phaser.Game; game: Phaser.Game;
activeScenes: () => string[]; activeScenes: () => string[];
audio: () => ReturnType<typeof soundDirector.getDebugState>; audio: () => ReturnType<typeof soundDirector.getDebugState>;
title: () => unknown;
battle: () => unknown; battle: () => unknown;
camp: () => unknown; camp: () => unknown;
village: () => unknown; village: () => unknown;
@@ -115,6 +120,7 @@ if (debugEnabled) {
function createDebugApi(game: Phaser.Game): HerosDebugApi { function createDebugApi(game: Phaser.Game): HerosDebugApi {
const scene = (key: string) => game.scene.getScene(key); const scene = (key: string) => game.scene.getScene(key);
const titleScene = () => scene('TitleScene') as DebugTitleScene | undefined;
const battleScene = () => scene('BattleScene') as DebugBattleScene | undefined; const battleScene = () => scene('BattleScene') as DebugBattleScene | undefined;
const campScene = () => scene('CampScene') as DebugCampScene | undefined; const campScene = () => scene('CampScene') as DebugCampScene | undefined;
const villageScene = () => scene('PrologueVillageScene') as DebugVillageScene | undefined; const villageScene = () => scene('PrologueVillageScene') as DebugVillageScene | undefined;
@@ -127,6 +133,9 @@ function createDebugApi(game: Phaser.Game): HerosDebugApi {
game, game,
activeScenes: () => game.scene.getScenes(true).map((activeScene) => activeScene.scene.key), activeScenes: () => game.scene.getScenes(true).map((activeScene) => activeScene.scene.key),
audio: () => soundDirector.getDebugState(), 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') battle: () => game.scene.isActive('BattleScene')
? battleScene()?.getDebugState?.() ?? { active: false, reason: 'BattleScene debug state is unavailable.' } ? battleScene()?.getDebugState?.() ?? { active: false, reason: 'BattleScene debug state is unavailable.' }
: { active: false, reason: 'BattleScene is not active yet.' }, : { active: false, reason: 'BattleScene is not active yet.' },