feat: complete keyboard flows and stabilize camp focus
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
551
scripts/verify-battle-keyboard-command-flow-browser.mjs
Normal file
551
scripts/verify-battle-keyboard-command-flow-browser.mjs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
299
scripts/verify-title-accessibility-browser.mjs
Normal file
299
scripts/verify-title-accessibility-browser.mjs
Normal 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));
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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.' },
|
||||||
|
|||||||
Reference in New Issue
Block a user