fix: harden turn flow interaction UX
This commit is contained in:
@@ -49,9 +49,10 @@
|
|||||||
"verify:desktop-viewport": "node scripts/verify-desktop-browser-viewport.mjs",
|
"verify:desktop-viewport": "node scripts/verify-desktop-browser-viewport.mjs",
|
||||||
"verify:static-data": "node scripts/verify-static-data.mjs",
|
"verify:static-data": "node scripts/verify-static-data.mjs",
|
||||||
"verify:flow": "node scripts/verify-flow.mjs",
|
"verify:flow": "node scripts/verify-flow.mjs",
|
||||||
|
"verify:interaction-ux": "node scripts/verify-interaction-ux.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: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: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",
|
||||||
|
|||||||
@@ -141,12 +141,21 @@ function validateCampInteractionUxGuards() {
|
|||||||
);
|
);
|
||||||
checkedGuardCount += 1;
|
checkedGuardCount += 1;
|
||||||
|
|
||||||
|
const navigationLockIndex = navigationMethod.indexOf('this.navigationPending = true;');
|
||||||
|
const navigationBlockerIndex = navigationMethod.indexOf('this.showCampNavigationBlocker();');
|
||||||
|
const lazySceneReadyIndex = navigationMethod.indexOf('await ensureLazyScene(this.game, key);');
|
||||||
|
const stateCommitIndex = navigationMethod.indexOf('commitState();');
|
||||||
|
const sceneStartIndex = navigationMethod.indexOf('this.scene.start(key, data);');
|
||||||
expectCampUx(
|
expectCampUx(
|
||||||
navigationMethod.includes('if (this.navigationPending)') &&
|
navigationMethod.includes('if (this.navigationPending)') &&
|
||||||
navigationMethod.includes('this.navigationPending = true;') &&
|
navigationLockIndex >= 0 &&
|
||||||
navigationMethod.includes('startLazyScene(this, key, data)') &&
|
navigationBlockerIndex > navigationLockIndex &&
|
||||||
navigationMethod.includes('this.navigationPending = false;'),
|
lazySceneReadyIndex > navigationBlockerIndex &&
|
||||||
'lazy camp navigation must lock before loading and unlock after a failed transition'
|
stateCommitIndex > lazySceneReadyIndex &&
|
||||||
|
sceneStartIndex > stateCommitIndex &&
|
||||||
|
navigationMethod.includes('this.navigationPending = false;') &&
|
||||||
|
navigationMethod.includes('this.hideCampNavigationBlocker();'),
|
||||||
|
'lazy camp navigation must lock the screen, finish loading, commit state, and then start the scene'
|
||||||
);
|
);
|
||||||
checkedGuardCount += 1;
|
checkedGuardCount += 1;
|
||||||
|
|
||||||
|
|||||||
795
scripts/verify-interaction-ux.mjs
Normal file
795
scripts/verify-interaction-ux.mjs
Normal file
@@ -0,0 +1,795 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { chromium } from 'playwright';
|
||||||
|
import { desktopBrowserContextOptions, desktopBrowserViewport } from './desktop-browser-viewport.mjs';
|
||||||
|
|
||||||
|
const targetUrl = withDebugOptions(
|
||||||
|
process.env.VERIFY_INTERACTION_UX_URL ?? 'http://127.0.0.1:41783/'
|
||||||
|
);
|
||||||
|
const expectedBattleId = 'second-battle-yellow-turban-pursuit';
|
||||||
|
|
||||||
|
let serverProcess;
|
||||||
|
let browser;
|
||||||
|
|
||||||
|
try {
|
||||||
|
serverProcess = await ensureLocalServer(targetUrl);
|
||||||
|
browser = await chromium.launch({ headless: process.env.VERIFY_INTERACTION_UX_HEADLESS !== '0' });
|
||||||
|
const context = await browser.newContext(desktopBrowserContextOptions);
|
||||||
|
const page = await context.newPage();
|
||||||
|
page.setDefaultTimeout(30000);
|
||||||
|
|
||||||
|
const pageErrors = [];
|
||||||
|
page.on('pageerror', (error) => pageErrors.push(error.stack ?? error.message));
|
||||||
|
|
||||||
|
let delayedBattleModuleRequests = 0;
|
||||||
|
await page.route('**/src/game/scenes/BattleScene.ts*', async (route) => {
|
||||||
|
delayedBattleModuleRequests += 1;
|
||||||
|
await delay(1200);
|
||||||
|
await route.continue();
|
||||||
|
});
|
||||||
|
|
||||||
|
await verifyCampModalAndNavigation(page);
|
||||||
|
await verifyBattlePointerFlow(page);
|
||||||
|
|
||||||
|
if (pageErrors.length > 0) {
|
||||||
|
throw new Error(`Unexpected browser errors: ${JSON.stringify(pageErrors.slice(-5))}`);
|
||||||
|
}
|
||||||
|
if (delayedBattleModuleRequests !== 1) {
|
||||||
|
throw new Error(
|
||||||
|
`Expected one delayed BattleScene module request during single-flight navigation, received ${delayedBattleModuleRequests}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Verified pointer-based interaction UX at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height}: ` +
|
||||||
|
'camp and battle save modals block click-through, slow camp navigation is single-flight and commits after loading, ' +
|
||||||
|
'battle event overlays block edge-scroll and hover feedback, ' +
|
||||||
|
'movement commands stay anchored to the destination, the final ally prompt waits for the command, ' +
|
||||||
|
'the persistent turn-end action reopens it, and the right-click menu follows the pointer.'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await browser?.close();
|
||||||
|
if (serverProcess && !serverProcess.killed) {
|
||||||
|
serverProcess.kill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function withDebugOptions(url) {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
parsed.searchParams.set('debug', '1');
|
||||||
|
parsed.searchParams.set('renderer', 'canvas');
|
||||||
|
parsed.searchParams.set('debugCombatAssetWatchdogMs', '250');
|
||||||
|
return parsed.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyCampModalAndNavigation(page) {
|
||||||
|
const campUrl = new URL(targetUrl);
|
||||||
|
campUrl.searchParams.set('debugSortiePrep', '1');
|
||||||
|
|
||||||
|
await page.goto(campUrl.toString(), { waitUntil: 'domcontentloaded', timeout: 90000 });
|
||||||
|
await page.evaluate(() => window.localStorage.clear());
|
||||||
|
await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 });
|
||||||
|
await waitForDebugApi(page);
|
||||||
|
await page.waitForFunction(() => {
|
||||||
|
const scenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
|
||||||
|
const camp = window.__HEROS_DEBUG__?.camp();
|
||||||
|
return scenes.includes('CampScene') && camp?.scene === 'CampScene' && camp?.sortieVisible === true;
|
||||||
|
}, undefined, { timeout: 90000 });
|
||||||
|
await assertDesktopViewport(page);
|
||||||
|
|
||||||
|
const saveButtonBounds = await page.evaluate(() => {
|
||||||
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
||||||
|
const candidates = (scene?.children.list ?? [])
|
||||||
|
.filter((object) => (
|
||||||
|
object.type === 'Text' &&
|
||||||
|
object.text === '저장' &&
|
||||||
|
object.active &&
|
||||||
|
object.visible &&
|
||||||
|
object.input?.enabled
|
||||||
|
))
|
||||||
|
.map((object) => ({ depth: object.depth, bounds: object.getBounds() }))
|
||||||
|
.sort((left, right) => right.depth - left.depth || right.bounds.y - left.bounds.y);
|
||||||
|
const bounds = candidates[0]?.bounds;
|
||||||
|
return bounds ? { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height } : null;
|
||||||
|
});
|
||||||
|
assert(saveButtonBounds, 'Expected a visible camp save button in sortie preparation.');
|
||||||
|
|
||||||
|
const campBeforeModal = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
||||||
|
await clickSceneBounds(page, 'CampScene', saveButtonBounds);
|
||||||
|
await page.waitForFunction(() => {
|
||||||
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
||||||
|
return (scene?.saveSlotObjects?.length ?? 0) > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const campSaveModal = await page.evaluate(() => {
|
||||||
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
||||||
|
const rectangles = (scene?.saveSlotObjects ?? [])
|
||||||
|
.filter((object) => object.type === 'Rectangle' && object.active && object.input?.enabled)
|
||||||
|
.map((object) => {
|
||||||
|
const bounds = object.getBounds();
|
||||||
|
return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height, depth: object.depth };
|
||||||
|
});
|
||||||
|
const blocker = rectangles.find((bounds) => (
|
||||||
|
bounds.width >= scene.scale.width * 0.98 && bounds.height >= scene.scale.height * 0.98
|
||||||
|
));
|
||||||
|
return {
|
||||||
|
blocker: blocker ?? null,
|
||||||
|
objectCount: scene?.saveSlotObjects?.length ?? 0
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assert(
|
||||||
|
campSaveModal.blocker,
|
||||||
|
`Expected the camp save panel to include a full-screen interactive blocker: ${JSON.stringify(campSaveModal)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const coveredCampAction = campBeforeModal?.sortiePrimaryAction;
|
||||||
|
assert(coveredCampAction?.bounds, 'Expected the covered sortie primary action for camp click-through QA.');
|
||||||
|
await clickSceneBounds(page, 'CampScene', coveredCampAction.bounds);
|
||||||
|
await page.waitForTimeout(120);
|
||||||
|
const campAfterCoveredClick = await page.evaluate(() => ({
|
||||||
|
state: window.__HEROS_DEBUG__?.camp(),
|
||||||
|
savePanelObjectCount: window.__HEROS_GAME__?.scene.getScene('CampScene')?.saveSlotObjects?.length ?? 0
|
||||||
|
}));
|
||||||
|
assert(
|
||||||
|
campAfterCoveredClick.state?.sortiePrepStep === campBeforeModal?.sortiePrepStep,
|
||||||
|
`Camp save modal click-through changed the underlying sortie step: ${JSON.stringify({
|
||||||
|
before: campBeforeModal?.sortiePrepStep,
|
||||||
|
after: campAfterCoveredClick.state?.sortiePrepStep,
|
||||||
|
savePanelObjectCount: campAfterCoveredClick.savePanelObjectCount
|
||||||
|
})}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const launchAction = await prepareCampLaunch(page);
|
||||||
|
const navigationBaseline = await page.evaluate(() => {
|
||||||
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
||||||
|
const camp = window.__HEROS_DEBUG__?.camp();
|
||||||
|
window.__HEROS_INTERACTION_UX__ = { battleSceneStarts: 0 };
|
||||||
|
const originalStart = scene.scene.start;
|
||||||
|
scene.scene.start = function (key, data) {
|
||||||
|
if (key === 'BattleScene') {
|
||||||
|
window.__HEROS_INTERACTION_UX__.battleSceneStarts += 1;
|
||||||
|
}
|
||||||
|
return originalStart.call(this, key, data);
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
campaignStep: camp?.campaign?.step ?? null,
|
||||||
|
sortiePrepStep: camp?.sortiePrepStep ?? null,
|
||||||
|
selectedSortieUnitIds: [...(camp?.selectedSortieUnitIds ?? [])]
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const launchPoint = await sceneBoundsCenter(page, 'CampScene', launchAction.bounds);
|
||||||
|
await page.mouse.click(launchPoint.x, launchPoint.y);
|
||||||
|
await page.waitForFunction(() => {
|
||||||
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
||||||
|
return scene?.navigationPending === true && (scene?.navigationBlockerObjects?.length ?? 0) > 0;
|
||||||
|
}, undefined, { timeout: 3000 });
|
||||||
|
|
||||||
|
const navigationPendingState = await page.evaluate(() => {
|
||||||
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
||||||
|
const camp = window.__HEROS_DEBUG__?.camp();
|
||||||
|
const blocker = (scene?.navigationBlockerObjects ?? [])
|
||||||
|
.filter((object) => object.type === 'Rectangle' && object.input?.enabled)
|
||||||
|
.map((object) => object.getBounds())
|
||||||
|
.find((bounds) => bounds.width >= scene.scale.width * 0.98 && bounds.height >= scene.scale.height * 0.98);
|
||||||
|
return {
|
||||||
|
pending: scene?.navigationPending ?? false,
|
||||||
|
blocker: blocker ? { x: blocker.x, y: blocker.y, width: blocker.width, height: blocker.height } : null,
|
||||||
|
campaignStep: camp?.campaign?.step ?? null,
|
||||||
|
sortieVisible: camp?.sortieVisible ?? false,
|
||||||
|
selectedSortieUnitIds: [...(camp?.selectedSortieUnitIds ?? [])]
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assert(
|
||||||
|
navigationPendingState.blocker && navigationPendingState.campaignStep === navigationBaseline.campaignStep,
|
||||||
|
`Camp navigation must block input and defer the campaign-step commit until loading succeeds: ${JSON.stringify({
|
||||||
|
baseline: navigationBaseline,
|
||||||
|
pending: navigationPendingState
|
||||||
|
})}`
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.mouse.click(launchPoint.x, launchPoint.y);
|
||||||
|
const coveredTabBounds = (await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.campTabs?.find((tab) => tab.id === 'dialogue')?.bounds)) ?? null;
|
||||||
|
if (coveredTabBounds) {
|
||||||
|
await clickSceneBounds(page, 'CampScene', coveredTabBounds);
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(120);
|
||||||
|
|
||||||
|
const navigationAfterRepeatedInput = await page.evaluate(() => {
|
||||||
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
||||||
|
const camp = window.__HEROS_DEBUG__?.camp();
|
||||||
|
return {
|
||||||
|
activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? [],
|
||||||
|
pending: scene?.navigationPending ?? false,
|
||||||
|
blockerCount: scene?.navigationBlockerObjects?.length ?? 0,
|
||||||
|
campaignStep: camp?.campaign?.step ?? null,
|
||||||
|
sortiePrepStep: camp?.sortiePrepStep ?? null,
|
||||||
|
selectedSortieUnitIds: [...(camp?.selectedSortieUnitIds ?? [])],
|
||||||
|
battleSceneStarts: window.__HEROS_INTERACTION_UX__?.battleSceneStarts ?? 0
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assert(
|
||||||
|
navigationAfterRepeatedInput.activeScenes.includes('CampScene') &&
|
||||||
|
navigationAfterRepeatedInput.pending &&
|
||||||
|
navigationAfterRepeatedInput.blockerCount > 0 &&
|
||||||
|
navigationAfterRepeatedInput.campaignStep === navigationBaseline.campaignStep &&
|
||||||
|
navigationAfterRepeatedInput.sortiePrepStep === navigationBaseline.sortiePrepStep &&
|
||||||
|
sameStrings(navigationAfterRepeatedInput.selectedSortieUnitIds, navigationBaseline.selectedSortieUnitIds) &&
|
||||||
|
navigationAfterRepeatedInput.battleSceneStarts === 0,
|
||||||
|
`Repeated input changed camp state during lazy navigation: ${JSON.stringify({
|
||||||
|
baseline: navigationBaseline,
|
||||||
|
after: navigationAfterRepeatedInput
|
||||||
|
})}`
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.waitForFunction(() => {
|
||||||
|
const scenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
|
||||||
|
const battle = window.__HEROS_DEBUG__?.battle();
|
||||||
|
return scenes.includes('BattleScene') && battle?.scene === 'BattleScene';
|
||||||
|
}, undefined, { timeout: 90000 });
|
||||||
|
const navigationCompleteState = await page.evaluate(() => ({
|
||||||
|
battleSceneStarts: window.__HEROS_INTERACTION_UX__?.battleSceneStarts ?? 0,
|
||||||
|
activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? []
|
||||||
|
}));
|
||||||
|
assert(
|
||||||
|
navigationCompleteState.battleSceneStarts === 1 && !navigationCompleteState.activeScenes.includes('CampScene'),
|
||||||
|
`Camp navigation must start BattleScene exactly once: ${JSON.stringify(navigationCompleteState)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareCampLaunch(page) {
|
||||||
|
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||||||
|
const state = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
||||||
|
assert(state?.sortieVisible, `Expected sortie preparation while finding launch action: ${JSON.stringify(state)}`);
|
||||||
|
|
||||||
|
if (state.sortiePrepStep === 'formation' && state.sortieOperationOrder?.required && !state.sortieOperationOrder.complete) {
|
||||||
|
if (!state.sortieOperationOrder.open) {
|
||||||
|
assert(state.sortieOperationOrder.toggleButtonBounds, 'Expected the operation-order picker button.');
|
||||||
|
await clickSceneBounds(page, 'CampScene', state.sortieOperationOrder.toggleButtonBounds);
|
||||||
|
await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.sortieOperationOrder?.open === true);
|
||||||
|
}
|
||||||
|
const orderState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortieOperationOrder);
|
||||||
|
const order = orderState?.cards?.find((card) => card.cardBounds);
|
||||||
|
assert(order?.cardBounds, `Expected a selectable operation order: ${JSON.stringify(orderState)}`);
|
||||||
|
await clickSceneBounds(page, 'CampScene', order.cardBounds);
|
||||||
|
await page.waitForFunction((orderId) => (
|
||||||
|
window.__HEROS_DEBUG__?.camp()?.sortieOperationOrder?.selectedId === orderId
|
||||||
|
), order.id);
|
||||||
|
const selectedOrderState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortieOperationOrder);
|
||||||
|
if (selectedOrderState?.open && selectedOrderState.closeButtonBounds) {
|
||||||
|
await clickSceneBounds(page, 'CampScene', selectedOrderState.closeButtonBounds);
|
||||||
|
await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.sortieOperationOrder?.open === false);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = state.sortiePrimaryAction;
|
||||||
|
assert(action?.bounds && action.interactive, `Expected an interactive sortie primary action: ${JSON.stringify(action)}`);
|
||||||
|
if (action.kind === 'launch') {
|
||||||
|
return action;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousStep = state.sortiePrepStep;
|
||||||
|
await clickSceneBounds(page, 'CampScene', action.bounds);
|
||||||
|
await page.waitForFunction((step) => window.__HEROS_DEBUG__?.camp()?.sortiePrepStep !== step, previousStep);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Could not reach the sortie launch action within the expected three-step flow.');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyBattlePointerFlow(page) {
|
||||||
|
await page.evaluate((battleId) => window.__HEROS_DEBUG__?.goToBattle(battleId), expectedBattleId);
|
||||||
|
await page.waitForFunction((battleId) => {
|
||||||
|
const battle = window.__HEROS_DEBUG__?.battle();
|
||||||
|
return (
|
||||||
|
battle?.battleId === battleId &&
|
||||||
|
['deployment', 'idle'].includes(battle.phase) &&
|
||||||
|
battle.mapBackgroundReady === true &&
|
||||||
|
battle.combatAssets?.status !== 'loading'
|
||||||
|
);
|
||||||
|
}, expectedBattleId, { timeout: 90000 });
|
||||||
|
await startDeploymentIfNeeded(page, expectedBattleId);
|
||||||
|
await verifyBattleEventOverlayInputBlock(page);
|
||||||
|
|
||||||
|
const lastAllySetup = await page.evaluate(() => {
|
||||||
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
||||||
|
const units = scene?.debugBattleUnits?.() ?? [];
|
||||||
|
const allies = units.filter((unit) => unit.faction === 'ally' && unit.hp > 0);
|
||||||
|
const lastAlly = allies.find((unit) => scene.reachableTiles(unit).some((tile) => tile.x !== unit.x || tile.y !== unit.y));
|
||||||
|
if (!scene || !lastAlly) {
|
||||||
|
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();
|
||||||
|
allies.filter((unit) => unit.id !== lastAlly.id).forEach((unit) => scene.actedUnitIds.add(unit.id));
|
||||||
|
scene.centerCameraOnTile(lastAlly.x, lastAlly.y);
|
||||||
|
scene.renderSituationPanel();
|
||||||
|
|
||||||
|
const hitZone = scene.unitViews.get(lastAlly.id)?.hitZone;
|
||||||
|
const bounds = hitZone?.getBounds();
|
||||||
|
return bounds
|
||||||
|
? {
|
||||||
|
unitId: lastAlly.id,
|
||||||
|
unitTile: { x: lastAlly.x, y: lastAlly.y },
|
||||||
|
bounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
});
|
||||||
|
assert(lastAllySetup?.bounds, 'Expected a visible final allied unit hit area.');
|
||||||
|
await page.waitForTimeout(80);
|
||||||
|
|
||||||
|
await clickSceneBounds(page, 'BattleScene', lastAllySetup.bounds);
|
||||||
|
await page.waitForFunction((unitId) => {
|
||||||
|
const battle = window.__HEROS_DEBUG__?.battle();
|
||||||
|
return battle?.selectedUnitId === unitId && battle?.phase === 'moving';
|
||||||
|
}, lastAllySetup.unitId);
|
||||||
|
const selectedBeforeMove = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
|
||||||
|
assert(
|
||||||
|
!selectedBeforeMove.turnPromptVisible,
|
||||||
|
`Selecting the final ally must not show the turn-end prompt: ${JSON.stringify(selectedBeforeMove)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const movementTarget = await page.evaluate((unitId) => {
|
||||||
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
||||||
|
const unit = scene?.debugUnitById?.(unitId);
|
||||||
|
const destination = unit
|
||||||
|
? scene.reachableTiles(unit).find((tile) => tile.x !== unit.x || tile.y !== unit.y)
|
||||||
|
: undefined;
|
||||||
|
if (!scene || !destination) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tile: { x: destination.x, y: destination.y },
|
||||||
|
point: { x: scene.tileCenterX(destination.x), y: scene.tileCenterY(destination.y) },
|
||||||
|
farPoint: {
|
||||||
|
x: destination.x - scene.cameraTileX < scene.layout.visibleColumns / 2
|
||||||
|
? scene.layout.gridX + scene.layout.gridWidth - 12
|
||||||
|
: scene.layout.gridX + 12,
|
||||||
|
y: destination.y - scene.cameraTileY < scene.layout.visibleRows / 2
|
||||||
|
? scene.layout.gridY + scene.layout.gridHeight - 12
|
||||||
|
: scene.layout.gridY + 12
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, lastAllySetup.unitId);
|
||||||
|
assert(movementTarget?.point, 'Expected a reachable non-origin tile for the final ally.');
|
||||||
|
|
||||||
|
const movementScreenPoint = await sceneLogicalPoint(page, 'BattleScene', movementTarget.point);
|
||||||
|
const farScreenPoint = await sceneLogicalPoint(page, 'BattleScene', movementTarget.farPoint);
|
||||||
|
await page.mouse.click(movementScreenPoint.x, movementScreenPoint.y);
|
||||||
|
await page.mouse.move(farScreenPoint.x, farScreenPoint.y);
|
||||||
|
const duringMovement = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
|
||||||
|
assert(
|
||||||
|
!duringMovement?.turnPromptVisible && !duringMovement?.actedUnitIds?.includes(lastAllySetup.unitId),
|
||||||
|
`Movement of the final ally must not complete the action or show the prompt: ${JSON.stringify(duringMovement)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.waitForFunction((unitId) => {
|
||||||
|
const battle = window.__HEROS_DEBUG__?.battle();
|
||||||
|
return battle?.phase === 'command' && battle?.pendingMove?.unitId === unitId && battle?.commandMenuVisible === true;
|
||||||
|
}, lastAllySetup.unitId);
|
||||||
|
const postMovement = await page.evaluate(({ unitId, destination }) => {
|
||||||
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
||||||
|
const state = window.__HEROS_DEBUG__?.battle();
|
||||||
|
const activeObjects = (scene?.commandMenuObjects ?? []).filter((object) => object.active);
|
||||||
|
const objectBounds = activeObjects.map((object) => object.getBounds());
|
||||||
|
const left = Math.min(...objectBounds.map((bounds) => bounds.x));
|
||||||
|
const top = Math.min(...objectBounds.map((bounds) => bounds.y));
|
||||||
|
const right = Math.max(...objectBounds.map((bounds) => bounds.right));
|
||||||
|
const bottom = Math.max(...objectBounds.map((bounds) => bounds.bottom));
|
||||||
|
const destinationPoint = { x: scene.tileCenterX(destination.x), y: scene.tileCenterY(destination.y) };
|
||||||
|
const waitButton = scene.commandButtons.find((button) => button.command === 'wait')?.background;
|
||||||
|
const waitBounds = waitButton?.getBounds();
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
unitId,
|
||||||
|
menuBounds: { x: left, y: top, width: right - left, height: bottom - top },
|
||||||
|
destinationPoint,
|
||||||
|
waitBounds: waitBounds
|
||||||
|
? { x: waitBounds.x, y: waitBounds.y, width: waitBounds.width, height: waitBounds.height }
|
||||||
|
: null
|
||||||
|
};
|
||||||
|
}, { unitId: lastAllySetup.unitId, destination: movementTarget.tile });
|
||||||
|
assert(
|
||||||
|
!postMovement.state?.turnPromptVisible &&
|
||||||
|
postMovement.state?.phase === 'command' &&
|
||||||
|
!postMovement.state?.actedUnitIds?.includes(lastAllySetup.unitId),
|
||||||
|
`Arrival must keep command selection open before showing the turn-end prompt: ${JSON.stringify(postMovement.state)}`
|
||||||
|
);
|
||||||
|
const destinationMenuDistance = pointToBoundsDistance(postMovement.destinationPoint, postMovement.menuBounds);
|
||||||
|
assert(
|
||||||
|
destinationMenuDistance <= 48,
|
||||||
|
`The movement command menu drifted away from the clicked destination (${destinationMenuDistance.toFixed(1)}px): ${JSON.stringify(postMovement)}`
|
||||||
|
);
|
||||||
|
assert(postMovement.waitBounds, 'Expected the wait command button after movement.');
|
||||||
|
|
||||||
|
await clickSceneBounds(page, 'BattleScene', postMovement.waitBounds);
|
||||||
|
await page.waitForFunction((unitId) => {
|
||||||
|
const battle = window.__HEROS_DEBUG__?.battle();
|
||||||
|
return (
|
||||||
|
battle?.turnPromptVisible === true &&
|
||||||
|
battle?.turnPromptMode === 'turn-end' &&
|
||||||
|
battle?.actedUnitIds?.includes(unitId)
|
||||||
|
);
|
||||||
|
}, lastAllySetup.unitId);
|
||||||
|
|
||||||
|
await clickTurnPromptSecondary(page);
|
||||||
|
await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.turnPromptVisible === false);
|
||||||
|
const persistentTurnEndBounds = await page.evaluate(() => {
|
||||||
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
||||||
|
const text = (scene?.sidePanelObjects ?? []).find((object) => (
|
||||||
|
object.type === 'Text' && object.active && object.visible && object.input?.enabled && object.text === '턴 종료'
|
||||||
|
));
|
||||||
|
const bounds = text?.getBounds();
|
||||||
|
return bounds ? { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height } : null;
|
||||||
|
});
|
||||||
|
assert(persistentTurnEndBounds, 'Expected a persistent turn-end action after choosing battlefield review.');
|
||||||
|
await clickSceneBounds(page, 'BattleScene', persistentTurnEndBounds);
|
||||||
|
await page.waitForFunction(() => (
|
||||||
|
window.__HEROS_DEBUG__?.battle()?.turnPromptVisible === true &&
|
||||||
|
window.__HEROS_DEBUG__?.battle()?.turnPromptMode === 'turn-end'
|
||||||
|
));
|
||||||
|
await clickTurnPromptSecondary(page);
|
||||||
|
await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.turnPromptVisible === false);
|
||||||
|
|
||||||
|
const mapMenuPointer = await findRightmostEmptyBattleTile(page);
|
||||||
|
await page.mouse.click(mapMenuPointer.x, mapMenuPointer.y, { button: 'right' });
|
||||||
|
await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.mapMenuVisible === true);
|
||||||
|
const mapMenu = await page.evaluate(() => {
|
||||||
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
||||||
|
return scene?.mapMenuLayout ? { ...scene.mapMenuLayout, actions: [...scene.mapMenuLayout.actions] } : null;
|
||||||
|
});
|
||||||
|
assert(mapMenu, 'Expected the right-click battlefield menu.');
|
||||||
|
const horizontalPointerGap = mapMenuPointer.x < mapMenu.left
|
||||||
|
? mapMenu.left - mapMenuPointer.x
|
||||||
|
: mapMenuPointer.x > mapMenu.left + mapMenu.width
|
||||||
|
? mapMenuPointer.x - (mapMenu.left + mapMenu.width)
|
||||||
|
: 0;
|
||||||
|
assert(
|
||||||
|
horizontalPointerGap <= 24,
|
||||||
|
`The right-click menu did not open next to the pointer (${horizontalPointerGap.toFixed(1)}px): ${JSON.stringify({ mapMenuPointer, mapMenu })}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const saveActionIndex = mapMenu.actions.indexOf('save');
|
||||||
|
assert(saveActionIndex >= 0, `Expected a save action in the map menu: ${JSON.stringify(mapMenu)}`);
|
||||||
|
await clickSceneBounds(page, 'BattleScene', {
|
||||||
|
x: mapMenu.left,
|
||||||
|
y: mapMenu.top + mapMenu.padding + saveActionIndex * mapMenu.buttonHeight,
|
||||||
|
width: mapMenu.width,
|
||||||
|
height: mapMenu.buttonHeight
|
||||||
|
});
|
||||||
|
await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.saveSlotPanelMode === 'save');
|
||||||
|
|
||||||
|
const battleModalProbe = await page.evaluate(() => {
|
||||||
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
||||||
|
const rectangles = (scene?.saveSlotPanelObjects ?? [])
|
||||||
|
.filter((object) => object.type === 'Rectangle' && object.active && object.input?.enabled)
|
||||||
|
.map((object) => {
|
||||||
|
const bounds = object.getBounds();
|
||||||
|
return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height, depth: object.depth };
|
||||||
|
});
|
||||||
|
const blocker = rectangles.find((bounds) => (
|
||||||
|
bounds.width >= scene.scale.width * 0.98 && bounds.height >= scene.scale.height * 0.98
|
||||||
|
));
|
||||||
|
const panel = rectangles
|
||||||
|
.filter((bounds) => bounds.width < scene.scale.width * 0.9 && bounds.height > 200)
|
||||||
|
.sort((left, right) => right.width * right.height - left.width * left.height)[0];
|
||||||
|
|
||||||
|
const tryCamera = (x, y) => {
|
||||||
|
scene.setCameraTilePosition(x, y);
|
||||||
|
return (scene.debugBattleUnits?.() ?? [])
|
||||||
|
.filter((unit) => unit.faction === 'ally' && unit.hp > 0)
|
||||||
|
.map((unit) => ({ unit, zone: scene.unitViews.get(unit.id)?.hitZone }))
|
||||||
|
.find(({ zone }) => {
|
||||||
|
if (!zone?.visible || !zone.input?.enabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const bounds = zone.getBounds();
|
||||||
|
const point = { x: bounds.centerX, y: bounds.centerY };
|
||||||
|
return !panel || !pointInsideBounds(point, panel);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const candidate = tryCamera(0, 0) ?? tryCamera(scene.maxCameraTileX(), scene.maxCameraTileY());
|
||||||
|
const candidateBounds = candidate?.zone?.getBounds();
|
||||||
|
const persistentTurnEndAction = (scene?.sidePanelObjects ?? []).find((object) => (
|
||||||
|
object.type === 'Text' &&
|
||||||
|
object.active &&
|
||||||
|
object.visible &&
|
||||||
|
object.input?.enabled &&
|
||||||
|
object.text === '턴 종료'
|
||||||
|
));
|
||||||
|
const persistentActionBounds = persistentTurnEndAction?.getBounds();
|
||||||
|
const coveredBounds = candidateBounds ?? persistentActionBounds;
|
||||||
|
return {
|
||||||
|
blocker: blocker ?? null,
|
||||||
|
panel: panel ?? null,
|
||||||
|
unitId: candidate?.unit.id ?? null,
|
||||||
|
targetKind: candidateBounds ? 'unit' : persistentActionBounds ? 'turn-end-action' : null,
|
||||||
|
coveredBounds: coveredBounds
|
||||||
|
? { x: coveredBounds.x, y: coveredBounds.y, width: coveredBounds.width, height: coveredBounds.height }
|
||||||
|
: null,
|
||||||
|
selectedUnitId: scene.selectedUnit?.id ?? null
|
||||||
|
};
|
||||||
|
|
||||||
|
function pointInsideBounds(point, bounds) {
|
||||||
|
return (
|
||||||
|
point.x >= bounds.x && point.x <= bounds.x + bounds.width &&
|
||||||
|
point.y >= bounds.y && point.y <= bounds.y + bounds.height
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert(
|
||||||
|
battleModalProbe.blocker && battleModalProbe.coveredBounds,
|
||||||
|
`Expected a full-screen battle save blocker and an interactive control behind it: ${JSON.stringify(battleModalProbe)}`
|
||||||
|
);
|
||||||
|
await clickSceneBounds(page, 'BattleScene', battleModalProbe.coveredBounds);
|
||||||
|
await page.waitForTimeout(120);
|
||||||
|
const battleAfterCoveredClick = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
|
||||||
|
assert(
|
||||||
|
battleAfterCoveredClick?.saveSlotPanelMode === 'save' &&
|
||||||
|
battleAfterCoveredClick?.selectedUnitId === battleModalProbe.selectedUnitId &&
|
||||||
|
battleAfterCoveredClick?.turnPromptVisible === false,
|
||||||
|
`Battle save modal allowed an underlying control click: ${JSON.stringify({
|
||||||
|
before: battleModalProbe,
|
||||||
|
after: {
|
||||||
|
saveSlotPanelMode: battleAfterCoveredClick?.saveSlotPanelMode,
|
||||||
|
selectedUnitId: battleAfterCoveredClick?.selectedUnitId,
|
||||||
|
turnPromptVisible: battleAfterCoveredClick?.turnPromptVisible
|
||||||
|
}
|
||||||
|
})}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyBattleEventOverlayInputBlock(page) {
|
||||||
|
const before = await page.evaluate(() => {
|
||||||
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
||||||
|
if (!scene || (scene.battleEventObjects?.length ?? 0) === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxX = scene.maxCameraTileX();
|
||||||
|
const maxY = scene.maxCameraTileY();
|
||||||
|
const edge = 2;
|
||||||
|
const point = scene.cameraTileX < maxX
|
||||||
|
? { x: scene.layout.gridX + scene.layout.gridWidth - edge, y: scene.layout.gridY + scene.layout.gridHeight / 2 }
|
||||||
|
: scene.cameraTileX > 0
|
||||||
|
? { x: scene.layout.gridX + edge, y: scene.layout.gridY + scene.layout.gridHeight / 2 }
|
||||||
|
: scene.cameraTileY < maxY
|
||||||
|
? { x: scene.layout.gridX + scene.layout.gridWidth / 2, y: scene.layout.gridY + scene.layout.gridHeight - edge }
|
||||||
|
: scene.cameraTileY > 0
|
||||||
|
? { x: scene.layout.gridX + scene.layout.gridWidth / 2, y: scene.layout.gridY + edge }
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
eventObjectCount: scene.battleEventObjects.length,
|
||||||
|
cameraTileX: scene.cameraTileX,
|
||||||
|
cameraTileY: scene.cameraTileY,
|
||||||
|
point
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assert(before?.point, `Expected a visible battle event overlay and a scrollable map edge: ${JSON.stringify(before)}`);
|
||||||
|
|
||||||
|
const edgePoint = await sceneLogicalPoint(page, 'BattleScene', before.point);
|
||||||
|
await page.mouse.move(edgePoint.x, edgePoint.y);
|
||||||
|
await page.waitForTimeout(360);
|
||||||
|
|
||||||
|
const after = await page.evaluate(() => {
|
||||||
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
||||||
|
return {
|
||||||
|
eventObjectCount: scene?.battleEventObjects?.length ?? 0,
|
||||||
|
cameraTileX: scene?.cameraTileX ?? null,
|
||||||
|
cameraTileY: scene?.cameraTileY ?? null,
|
||||||
|
pointerFeedbackKey: scene?.lastPointerFeedbackKey ?? null,
|
||||||
|
pointerFeedbackVisible: scene?.pointerFeedbackMarker?.visible ?? false
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assert(
|
||||||
|
after.eventObjectCount > 0 &&
|
||||||
|
after.cameraTileX === before.cameraTileX &&
|
||||||
|
after.cameraTileY === before.cameraTileY &&
|
||||||
|
after.pointerFeedbackKey === null &&
|
||||||
|
after.pointerFeedbackVisible === false,
|
||||||
|
`Battle event overlay allowed edge-scroll or pointer feedback through: ${JSON.stringify({ before, after })}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const safePoint = await page.evaluate(() => {
|
||||||
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
||||||
|
return scene
|
||||||
|
? { x: scene.layout.gridX + scene.layout.gridWidth / 2, y: scene.layout.gridY + scene.layout.gridHeight / 2 }
|
||||||
|
: null;
|
||||||
|
});
|
||||||
|
assert(safePoint, 'Expected a safe battlefield pointer position after overlay QA.');
|
||||||
|
const safeScreenPoint = await sceneLogicalPoint(page, 'BattleScene', safePoint);
|
||||||
|
await page.mouse.move(safeScreenPoint.x, safeScreenPoint.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startDeploymentIfNeeded(page, battleId) {
|
||||||
|
const state = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
|
||||||
|
if (state?.phase === 'idle') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assert(
|
||||||
|
state?.battleId === battleId && state?.phase === 'deployment' && state?.battleHud?.deploymentPanel?.startButtonBounds,
|
||||||
|
`Expected ${battleId} deployment controls: ${JSON.stringify(state)}`
|
||||||
|
);
|
||||||
|
await clickSceneBounds(page, 'BattleScene', state.battleHud.deploymentPanel.startButtonBounds);
|
||||||
|
await page.waitForFunction((expectedId) => {
|
||||||
|
const battle = window.__HEROS_DEBUG__?.battle();
|
||||||
|
return battle?.battleId === expectedId && battle?.phase === 'idle';
|
||||||
|
}, battleId, { timeout: 30000 });
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clickTurnPromptSecondary(page) {
|
||||||
|
const bounds = await page.evaluate(() => {
|
||||||
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
||||||
|
const buttons = (scene?.turnPromptObjects ?? [])
|
||||||
|
.filter((object) => (
|
||||||
|
object.type === 'Rectangle' &&
|
||||||
|
object.active &&
|
||||||
|
object.input?.enabled &&
|
||||||
|
object.displayWidth < 500 &&
|
||||||
|
object.displayHeight < 100
|
||||||
|
))
|
||||||
|
.map((object) => object.getBounds())
|
||||||
|
.sort((left, right) => right.centerX - left.centerX);
|
||||||
|
const button = buttons[0];
|
||||||
|
return button ? { x: button.x, y: button.y, width: button.width, height: button.height } : null;
|
||||||
|
});
|
||||||
|
assert(bounds, 'Expected the battlefield-review button in the turn-end prompt.');
|
||||||
|
await clickSceneBounds(page, 'BattleScene', bounds);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findRightmostEmptyBattleTile(page) {
|
||||||
|
const logicalPoint = await page.evaluate(() => {
|
||||||
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
||||||
|
if (!scene) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (let column = scene.layout.visibleColumns - 1; column >= 0; column -= 1) {
|
||||||
|
for (let row = 0; row < scene.layout.visibleRows; row += 1) {
|
||||||
|
const x = scene.cameraTileX + column;
|
||||||
|
const y = scene.cameraTileY + row;
|
||||||
|
if (scene.isInBounds(x, y) && !scene.isOccupied(x, y)) {
|
||||||
|
return { x: scene.tileCenterX(x), y: scene.tileCenterY(y) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
assert(logicalPoint, 'Expected a visible empty battlefield tile for the right-click menu.');
|
||||||
|
return sceneLogicalPoint(page, 'BattleScene', logicalPoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForDebugApi(page) {
|
||||||
|
await page.waitForFunction(() => (
|
||||||
|
document.querySelector('canvas') !== null &&
|
||||||
|
window.__HEROS_GAME__ !== undefined &&
|
||||||
|
window.__HEROS_DEBUG__ !== undefined
|
||||||
|
), undefined, { timeout: 90000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertDesktopViewport(page) {
|
||||||
|
const viewport = await page.evaluate(() => ({
|
||||||
|
width: window.innerWidth,
|
||||||
|
height: window.innerHeight,
|
||||||
|
dpr: window.devicePixelRatio,
|
||||||
|
canvas: (() => {
|
||||||
|
const canvas = document.querySelector('canvas');
|
||||||
|
return canvas ? { width: canvas.width, height: canvas.height } : null;
|
||||||
|
})()
|
||||||
|
}));
|
||||||
|
assert(
|
||||||
|
viewport.width === desktopBrowserViewport.width &&
|
||||||
|
viewport.height === desktopBrowserViewport.height &&
|
||||||
|
viewport.dpr === 1 &&
|
||||||
|
viewport.canvas?.width === desktopBrowserViewport.width &&
|
||||||
|
viewport.canvas?.height === desktopBrowserViewport.height,
|
||||||
|
`Expected the required 1920x1080 CSS viewport at 100% zoom: ${JSON.stringify(viewport)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clickSceneBounds(page, sceneKey, bounds, options) {
|
||||||
|
const point = await sceneBoundsCenter(page, sceneKey, bounds);
|
||||||
|
await page.mouse.click(point.x, point.y, options);
|
||||||
|
return point;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sceneBoundsCenter(page, sceneKey, bounds) {
|
||||||
|
return sceneLogicalPoint(page, sceneKey, {
|
||||||
|
x: bounds.x + bounds.width / 2,
|
||||||
|
y: bounds.y + bounds.height / 2
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sceneLogicalPoint(page, sceneKey, logicalPoint) {
|
||||||
|
const point = await page.evaluate(({ requestedSceneKey, requestedPoint }) => {
|
||||||
|
const scene = window.__HEROS_GAME__?.scene.getScene(requestedSceneKey);
|
||||||
|
const canvas = document.querySelector('canvas');
|
||||||
|
const canvasBounds = canvas?.getBoundingClientRect();
|
||||||
|
if (!scene || !canvasBounds || !requestedPoint) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
x: canvasBounds.left + requestedPoint.x * canvasBounds.width / scene.scale.width,
|
||||||
|
y: canvasBounds.top + requestedPoint.y * canvasBounds.height / scene.scale.height
|
||||||
|
};
|
||||||
|
}, { requestedSceneKey: sceneKey, requestedPoint: logicalPoint });
|
||||||
|
assert(point && Number.isFinite(point.x) && Number.isFinite(point.y), `Expected a valid ${sceneKey} pointer coordinate.`);
|
||||||
|
return point;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointToBoundsDistance(point, bounds) {
|
||||||
|
const dx = Math.max(bounds.x - point.x, 0, point.x - (bounds.x + bounds.width));
|
||||||
|
const dy = Math.max(bounds.y - point.y, 0, point.y - (bounds.y + bounds.height));
|
||||||
|
return Math.hypot(dx, dy);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameStrings(left, right) {
|
||||||
|
return (
|
||||||
|
Array.isArray(left) &&
|
||||||
|
Array.isArray(right) &&
|
||||||
|
left.length === right.length &&
|
||||||
|
left.every((value, index) => value === right[index])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 || '41783'],
|
||||||
|
{ 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
child.kill();
|
||||||
|
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));
|
||||||
|
}
|
||||||
@@ -3945,6 +3945,13 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
if (this.tacticalCommandCutInObjects.length > 0) {
|
if (this.tacticalCommandCutInObjects.length > 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (this.battleEventObjects.length > 0) {
|
||||||
|
this.hideBattleEventBanner();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.saveSlotPanelObjects.length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (this.tacticalInitiativePanelObjects.length > 0) {
|
if (this.tacticalInitiativePanelObjects.length > 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -3961,6 +3968,11 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
if (this.tacticalCommandCutInObjects.length > 0) {
|
if (this.tacticalCommandCutInObjects.length > 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (this.battleEventObjects.length > 0) {
|
||||||
|
soundDirector.playSelect();
|
||||||
|
this.hideBattleEventBanner();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (this.resultSortieOrderVisible) {
|
if (this.resultSortieOrderVisible) {
|
||||||
soundDirector.playSelect();
|
soundDirector.playSelect();
|
||||||
this.hideResultSortieOrder();
|
this.hideResultSortieOrder();
|
||||||
@@ -4029,9 +4041,15 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.input.keyboard?.on('keydown', (event: KeyboardEvent) => {
|
this.input.keyboard?.on('keydown', (event: KeyboardEvent) => {
|
||||||
|
if (this.battleEventObjects.length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (this.handleSaveSlotPanelKey(event)) {
|
if (this.handleSaveSlotPanelKey(event)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (this.saveSlotPanelObjects.length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (this.handleTacticalInitiativeKey(event)) {
|
if (this.handleTacticalInitiativeKey(event)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -4041,7 +4059,14 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
this.handleSideQuickTabKey(event);
|
this.handleSideQuickTabKey(event);
|
||||||
});
|
});
|
||||||
this.input.keyboard?.on('keydown-SPACE', (event: KeyboardEvent) => {
|
this.input.keyboard?.on('keydown-SPACE', (event: KeyboardEvent) => {
|
||||||
if (this.activeFaction !== 'enemy' || this.fastForwardHeld) {
|
if (
|
||||||
|
this.battleEventObjects.length > 0 ||
|
||||||
|
this.saveSlotPanelObjects.length > 0 ||
|
||||||
|
this.tacticalInitiativePanelObjects.length > 0 ||
|
||||||
|
this.tacticalCommandCutInObjects.length > 0 ||
|
||||||
|
this.activeFaction !== 'enemy' ||
|
||||||
|
this.fastForwardHeld
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -4056,7 +4081,12 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
this.renderBattleSpeedControl();
|
this.renderBattleSpeedControl();
|
||||||
});
|
});
|
||||||
this.input.keyboard?.on('keydown-PERIOD', () => {
|
this.input.keyboard?.on('keydown-PERIOD', () => {
|
||||||
if (this.tacticalInitiativePanelObjects.length > 0 || this.tacticalCommandCutInObjects.length > 0) {
|
if (
|
||||||
|
this.battleEventObjects.length > 0 ||
|
||||||
|
this.saveSlotPanelObjects.length > 0 ||
|
||||||
|
this.tacticalInitiativePanelObjects.length > 0 ||
|
||||||
|
this.tacticalCommandCutInObjects.length > 0
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.cycleBattleSpeed();
|
this.cycleBattleSpeed();
|
||||||
@@ -6553,9 +6583,11 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
this.battleOutcome ||
|
this.battleOutcome ||
|
||||||
this.phase === 'animating' ||
|
this.phase === 'animating' ||
|
||||||
this.phase === 'resolved' ||
|
this.phase === 'resolved' ||
|
||||||
|
this.battleEventObjects.length > 0 ||
|
||||||
this.commandMenuObjects.length > 0 ||
|
this.commandMenuObjects.length > 0 ||
|
||||||
this.mapMenuObjects.length > 0 ||
|
this.mapMenuObjects.length > 0 ||
|
||||||
this.tacticalInitiativePanelObjects.length > 0 ||
|
this.tacticalInitiativePanelObjects.length > 0 ||
|
||||||
|
this.tacticalCommandCutInObjects.length > 0 ||
|
||||||
this.saveSlotPanelObjects.length > 0 ||
|
this.saveSlotPanelObjects.length > 0 ||
|
||||||
this.turnPromptObjects.length > 0 ||
|
this.turnPromptObjects.length > 0 ||
|
||||||
this.combatCutInObjects.length > 0 ||
|
this.combatCutInObjects.length > 0 ||
|
||||||
@@ -6913,9 +6945,11 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
this.battleOutcome ||
|
this.battleOutcome ||
|
||||||
this.phase === 'animating' ||
|
this.phase === 'animating' ||
|
||||||
this.phase === 'resolved' ||
|
this.phase === 'resolved' ||
|
||||||
|
this.battleEventObjects.length > 0 ||
|
||||||
this.commandMenuObjects.length > 0 ||
|
this.commandMenuObjects.length > 0 ||
|
||||||
this.mapMenuObjects.length > 0 ||
|
this.mapMenuObjects.length > 0 ||
|
||||||
this.tacticalInitiativePanelObjects.length > 0 ||
|
this.tacticalInitiativePanelObjects.length > 0 ||
|
||||||
|
this.tacticalCommandCutInObjects.length > 0 ||
|
||||||
this.saveSlotPanelObjects.length > 0 ||
|
this.saveSlotPanelObjects.length > 0 ||
|
||||||
this.turnPromptObjects.length > 0 ||
|
this.turnPromptObjects.length > 0 ||
|
||||||
this.combatCutInObjects.length > 0 ||
|
this.combatCutInObjects.length > 0 ||
|
||||||
@@ -7214,6 +7248,7 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
private handleBattleKeyboardNavigation(event: KeyboardEvent) {
|
private handleBattleKeyboardNavigation(event: KeyboardEvent) {
|
||||||
if (
|
if (
|
||||||
(this.phase !== 'moving' && this.phase !== 'targeting') ||
|
(this.phase !== 'moving' && this.phase !== 'targeting') ||
|
||||||
|
this.battleEventObjects.length > 0 ||
|
||||||
this.commandMenuObjects.length > 0 ||
|
this.commandMenuObjects.length > 0 ||
|
||||||
this.mapMenuObjects.length > 0 ||
|
this.mapMenuObjects.length > 0 ||
|
||||||
this.tacticalInitiativePanelObjects.length > 0 ||
|
this.tacticalInitiativePanelObjects.length > 0 ||
|
||||||
@@ -7357,7 +7392,7 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.turnPromptMode) {
|
if (this.battleEventObjects.length > 0 || this.saveSlotPanelObjects.length > 0 || this.turnPromptMode) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7520,6 +7555,9 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async moveSelectedUnit(x: number, y: number, pointer?: Phaser.Input.Pointer) {
|
private async moveSelectedUnit(x: number, y: number, pointer?: Phaser.Input.Pointer) {
|
||||||
|
if (this.battleEventObjects.length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (this.isFirstBattleTutorialActive()) {
|
if (this.isFirstBattleTutorialActive()) {
|
||||||
const path = this.firstBattleTutorialPath!;
|
const path = this.firstBattleTutorialPath!;
|
||||||
if (this.firstBattleTutorialStep !== 'move') {
|
if (this.firstBattleTutorialStep !== 'move') {
|
||||||
@@ -7567,6 +7605,8 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
|
|
||||||
const targetX = this.tileCenterX(x);
|
const targetX = this.tileCenterX(x);
|
||||||
const targetY = this.tileCenterY(y);
|
const targetY = this.tileCenterY(y);
|
||||||
|
const commandX = pointer?.x ?? targetX;
|
||||||
|
const commandY = pointer?.y ?? targetY;
|
||||||
if (!stayingInPlace) {
|
if (!stayingInPlace) {
|
||||||
await this.moveUnitViewAsync(unit, x, y, view, 260, fromX, fromY);
|
await this.moveUnitViewAsync(unit, x, y, view, 260, fromX, fromY);
|
||||||
if (this.battleOutcome || this.selectedUnit?.id !== unit.id || this.pendingMove?.unit.id !== unit.id) {
|
if (this.battleOutcome || this.selectedUnit?.id !== unit.id || this.pendingMove?.unit.id !== unit.id) {
|
||||||
@@ -7574,8 +7614,6 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
this.phase = 'command';
|
this.phase = 'command';
|
||||||
}
|
}
|
||||||
const commandX = pointer?.x ?? targetX;
|
|
||||||
const commandY = pointer?.y ?? targetY;
|
|
||||||
|
|
||||||
this.showCommandMenu(unit, commandX, commandY);
|
this.showCommandMenu(unit, commandX, commandY);
|
||||||
this.renderUnitDetail(unit, '명령 선택 · 우클릭은 이동 취소');
|
this.renderUnitDetail(unit, '명령 선택 · 우클릭은 이동 취소');
|
||||||
@@ -12873,22 +12911,24 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.addResultButton('다시 하기', left + panelWidth - 276, top + 612, 124, () => {
|
this.addResultButton('다시 하기', left + panelWidth - 276, top + 612, 124, () => {
|
||||||
soundDirector.playSelect();
|
this.beginResultNavigation(() => {
|
||||||
this.scene.restart({
|
this.scene.restart({
|
||||||
battleId: battleScenario.id,
|
battleId: battleScenario.id,
|
||||||
selectedSortieUnitIds: [...this.launchSortieUnitIds],
|
selectedSortieUnitIds: [...this.launchSortieUnitIds],
|
||||||
sortieFormationAssignments: { ...this.launchSortieFormationAssignments },
|
sortieFormationAssignments: { ...this.launchSortieFormationAssignments },
|
||||||
sortieItemAssignments: this.normalizeLaunchSortieItemAssignments(this.launchSortieItemAssignments),
|
sortieItemAssignments: this.normalizeLaunchSortieItemAssignments(this.launchSortieItemAssignments),
|
||||||
sortieOrderId: this.launchSortieOrderId,
|
sortieOrderId: this.launchSortieOrderId,
|
||||||
sortieResonanceBondId: this.launchSortieResonanceBondId,
|
sortieResonanceBondId: this.launchSortieResonanceBondId,
|
||||||
sortieRecommendation: this.launchSortieRecommendation
|
sortieRecommendation: this.launchSortieRecommendation
|
||||||
? cloneCampaignSortieRecommendationSnapshot(this.launchSortieRecommendation)
|
? cloneCampaignSortieRecommendationSnapshot(this.launchSortieRecommendation)
|
||||||
: undefined
|
: undefined
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}, depth + 3, outcome === 'defeat' ? 'primary' : 'secondary', 'retry');
|
}, depth + 3, outcome === 'defeat' ? 'primary' : 'secondary', 'retry');
|
||||||
this.addResultButton('타이틀로', left + panelWidth - 140, top + 612, 124, () => {
|
this.addResultButton('타이틀로', left + panelWidth - 140, top + 612, 124, () => {
|
||||||
soundDirector.playSelect();
|
this.beginResultNavigation(() => {
|
||||||
this.scene.start('TitleScene');
|
this.scene.start('TitleScene');
|
||||||
|
});
|
||||||
}, depth + 3, 'ghost', 'title');
|
}, depth + 3, 'ghost', 'title');
|
||||||
|
|
||||||
void this.playResultSettlementAnimation(animationToken);
|
void this.playResultSettlementAnimation(animationToken);
|
||||||
@@ -12911,13 +12951,53 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
this.resultContinueButton?.label.setText(this.resultContinueCtaLabel());
|
this.resultContinueButton?.label.setText(this.resultContinueCtaLabel());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private setResultNavigationButtonsEnabled(enabled: boolean) {
|
||||||
|
this.resultActionButtons.forEach((button) => {
|
||||||
|
if (!button.background.active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (enabled) {
|
||||||
|
button.background.setInteractive({ useHandCursor: true });
|
||||||
|
} else {
|
||||||
|
button.background.disableInteractive();
|
||||||
|
}
|
||||||
|
button.background.setAlpha(enabled ? 1 : 0.72);
|
||||||
|
button.label.setAlpha(enabled ? 1 : 0.72);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private recoverResultNavigation() {
|
||||||
|
this.resultNavigationPending = false;
|
||||||
|
this.setResultNavigationButtonsEnabled(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private beginResultNavigation(navigate: () => void | Promise<unknown>) {
|
||||||
|
if (this.resultNavigationPending) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.resultNavigationPending = true;
|
||||||
|
this.setResultNavigationButtonsEnabled(false);
|
||||||
|
soundDirector.playSelect();
|
||||||
|
try {
|
||||||
|
const pending = navigate();
|
||||||
|
if (pending instanceof Promise) {
|
||||||
|
void pending.catch(() => this.recoverResultNavigation());
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.recoverResultNavigation();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private handleResultContinue() {
|
private handleResultContinue() {
|
||||||
if (this.resultNavigationPending) {
|
if (this.resultNavigationPending) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
soundDirector.playSelect();
|
|
||||||
if (this.resultSettlementStatus !== 'complete') {
|
if (this.resultSettlementStatus !== 'complete') {
|
||||||
|
soundDirector.playSelect();
|
||||||
this.completeResultSettlement(true);
|
this.completeResultSettlement(true);
|
||||||
this.resultContinueLockedUntil = this.time.now + 420;
|
this.resultContinueLockedUntil = this.time.now + 420;
|
||||||
const button = this.resultContinueButton;
|
const button = this.resultContinueButton;
|
||||||
@@ -12942,31 +13022,14 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { destination } = this.resultContinueRoute();
|
const { destination } = this.resultContinueRoute();
|
||||||
this.resultNavigationPending = true;
|
|
||||||
this.resultActionButtons.forEach((button) => {
|
|
||||||
button.background.disableInteractive();
|
|
||||||
button.background.setAlpha(0.72);
|
|
||||||
button.label.setAlpha(0.72);
|
|
||||||
});
|
|
||||||
const recoverNavigation = () => {
|
|
||||||
this.resultNavigationPending = false;
|
|
||||||
this.resultActionButtons.forEach((button) => {
|
|
||||||
if (!button.background.active) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
button.background.setInteractive({ useHandCursor: true });
|
|
||||||
button.background.setAlpha(1);
|
|
||||||
button.label.setAlpha(1);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
if (destination === 'victory-story') {
|
if (destination === 'victory-story') {
|
||||||
void startLazyScene(this, 'StoryScene', {
|
this.beginResultNavigation(() => startLazyScene(this, 'StoryScene', {
|
||||||
pages: firstBattleVictoryPages,
|
pages: firstBattleVictoryPages,
|
||||||
nextScene: 'CampScene'
|
nextScene: 'CampScene'
|
||||||
}).catch(recoverNavigation);
|
}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void startLazyScene(this, 'CampScene').catch(recoverNavigation);
|
this.beginResultNavigation(() => startLazyScene(this, 'CampScene'));
|
||||||
}
|
}
|
||||||
|
|
||||||
private hideBattleResult() {
|
private hideBattleResult() {
|
||||||
@@ -13225,11 +13288,13 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private openResultSortieImprovement(outcome: BattleOutcome) {
|
private openResultSortieImprovement(outcome: BattleOutcome) {
|
||||||
|
if (this.resultNavigationPending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const targetBattleId = this.resultSortieImprovementTargetBattleId(outcome);
|
const targetBattleId = this.resultSortieImprovementTargetBattleId(outcome);
|
||||||
if (!targetBattleId || !this.launchSortieRecommendation) {
|
if (!targetBattleId || !this.launchSortieRecommendation) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
soundDirector.playSelect();
|
|
||||||
const campSceneData = {
|
const campSceneData = {
|
||||||
openSortiePrep: true,
|
openSortiePrep: true,
|
||||||
openSortieImprovement: true,
|
openSortieImprovement: true,
|
||||||
@@ -13240,15 +13305,16 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
: {})
|
: {})
|
||||||
};
|
};
|
||||||
|
this.hideResultFormationReview();
|
||||||
if (outcome === 'victory' && battleScenario.id === 'first-battle-zhuo-commandery') {
|
if (outcome === 'victory' && battleScenario.id === 'first-battle-zhuo-commandery') {
|
||||||
void startLazyScene(this, 'StoryScene', {
|
this.beginResultNavigation(() => startLazyScene(this, 'StoryScene', {
|
||||||
pages: firstBattleVictoryPages,
|
pages: firstBattleVictoryPages,
|
||||||
nextScene: 'CampScene',
|
nextScene: 'CampScene',
|
||||||
nextSceneData: campSceneData
|
nextSceneData: campSceneData
|
||||||
});
|
}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void startLazyScene(this, 'CampScene', campSceneData);
|
this.beginResultNavigation(() => startLazyScene(this, 'CampScene', campSceneData));
|
||||||
}
|
}
|
||||||
|
|
||||||
private publishFirstBattleReport(outcome: BattleOutcome) {
|
private publishFirstBattleReport(outcome: BattleOutcome) {
|
||||||
@@ -16484,6 +16550,7 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
this.firstBattleTutorialFeedback = '';
|
this.firstBattleTutorialFeedback = '';
|
||||||
this.firstBattleTutorialTargetPreviewLocked = false;
|
this.firstBattleTutorialTargetPreviewLocked = false;
|
||||||
this.firstBattleTutorialCompletion = undefined;
|
this.firstBattleTutorialCompletion = undefined;
|
||||||
|
this.hideBattleEventBanner();
|
||||||
this.ensureFirstBattleTutorialPathVisible();
|
this.ensureFirstBattleTutorialPathVisible();
|
||||||
this.renderFirstBattleTutorial();
|
this.renderFirstBattleTutorial();
|
||||||
}
|
}
|
||||||
@@ -16987,10 +17054,16 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
const left = this.layout.gridX + Math.max(16, (this.layout.gridWidth - width) / 2);
|
const left = this.layout.gridX + Math.max(16, (this.layout.gridWidth - width) / 2);
|
||||||
const top = this.layout.gridY + 18;
|
const top = this.layout.gridY + 18;
|
||||||
const depth = 45;
|
const depth = 45;
|
||||||
|
const inputShade = this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0x000000, 0.001);
|
||||||
|
inputShade.setOrigin(0);
|
||||||
|
inputShade.setDepth(depth - 1);
|
||||||
|
inputShade.setInteractive();
|
||||||
|
this.battleEventObjects.push(inputShade);
|
||||||
const panel = this.add.rectangle(left, top, width, height, 0x101821, 0.94);
|
const panel = this.add.rectangle(left, top, width, height, 0x101821, 0.94);
|
||||||
panel.setOrigin(0);
|
panel.setOrigin(0);
|
||||||
panel.setDepth(depth);
|
panel.setDepth(depth);
|
||||||
panel.setStrokeStyle(2, palette.gold, 0.86);
|
panel.setStrokeStyle(2, palette.gold, 0.86);
|
||||||
|
panel.setInteractive();
|
||||||
this.battleEventObjects.push(panel);
|
this.battleEventObjects.push(panel);
|
||||||
|
|
||||||
const titleText = this.add.text(left + 20, top + 14, title, {
|
const titleText = this.add.text(left + 20, top + 14, title, {
|
||||||
@@ -17096,10 +17169,16 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
const left = this.layout.gridX + Math.max(16, (this.layout.gridWidth - width) / 2);
|
const left = this.layout.gridX + Math.max(16, (this.layout.gridWidth - width) / 2);
|
||||||
const top = this.layout.gridY + 18;
|
const top = this.layout.gridY + 18;
|
||||||
const depth = 45;
|
const depth = 45;
|
||||||
|
const inputShade = this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0x000000, 0.001);
|
||||||
|
inputShade.setOrigin(0);
|
||||||
|
inputShade.setDepth(depth - 1);
|
||||||
|
inputShade.setInteractive();
|
||||||
|
this.battleEventObjects.push(inputShade);
|
||||||
const panel = this.add.rectangle(left, top, width, height, 0x0c141b, 0.97);
|
const panel = this.add.rectangle(left, top, width, height, 0x0c141b, 0.97);
|
||||||
panel.setOrigin(0);
|
panel.setOrigin(0);
|
||||||
panel.setDepth(depth);
|
panel.setDepth(depth);
|
||||||
panel.setStrokeStyle(2, palette.gold, 0.9);
|
panel.setStrokeStyle(2, palette.gold, 0.9);
|
||||||
|
panel.setInteractive();
|
||||||
this.battleEventObjects.push(panel);
|
this.battleEventObjects.push(panel);
|
||||||
|
|
||||||
const commandSeal = this.add.rectangle(left + 29, top + 28, 30, 30, 0x651f1b, 0.98);
|
const commandSeal = this.add.rectangle(left + 29, top + 28, 30, 30, 0x651f1b, 0.98);
|
||||||
@@ -17414,6 +17493,11 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.battleEventObjects.length > 0) {
|
||||||
|
this.hideBattleEventBanner();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.tacticalInitiativePanelObjects.length > 0) {
|
if (this.tacticalInitiativePanelObjects.length > 0) {
|
||||||
soundDirector.playSelect();
|
soundDirector.playSelect();
|
||||||
this.hideTacticalInitiativePanel();
|
this.hideTacticalInitiativePanel();
|
||||||
@@ -17473,6 +17557,11 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.battleEventObjects.length > 0) {
|
||||||
|
this.hideBattleEventBanner();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.tacticalInitiativePanelObjects.length > 0) {
|
if (this.tacticalInitiativePanelObjects.length > 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -17597,7 +17686,13 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
const buttonHeight = 34;
|
const buttonHeight = 34;
|
||||||
const padding = 8;
|
const padding = 8;
|
||||||
const menuHeight = padding * 2 + actions.length * buttonHeight;
|
const menuHeight = padding * 2 + actions.length * buttonHeight;
|
||||||
const left = this.layout.mapX + 84;
|
const mapLeft = this.layout.mapX + 16;
|
||||||
|
const mapRight = this.layout.mapX + this.layout.mapWidth - 16;
|
||||||
|
const pointerGap = 12;
|
||||||
|
const preferredLeft = pointerX + pointerGap + menuWidth <= mapRight
|
||||||
|
? pointerX + pointerGap
|
||||||
|
: pointerX - menuWidth - pointerGap;
|
||||||
|
const left = Phaser.Math.Clamp(preferredLeft, mapLeft, mapRight - menuWidth);
|
||||||
const top = Phaser.Math.Clamp(pointerY - menuHeight / 2, this.layout.mapY + 16, this.layout.mapY + this.layout.mapHeight - menuHeight - 16);
|
const top = Phaser.Math.Clamp(pointerY - menuHeight / 2, this.layout.mapY + 16, this.layout.mapY + this.layout.mapHeight - menuHeight - 16);
|
||||||
this.mapMenuLayout = { left, top, width: menuWidth, buttonHeight, padding, actions };
|
this.mapMenuLayout = { left, top, width: menuWidth, buttonHeight, padding, actions };
|
||||||
|
|
||||||
@@ -17605,6 +17700,7 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
panel.setOrigin(0);
|
panel.setOrigin(0);
|
||||||
panel.setDepth(20);
|
panel.setDepth(20);
|
||||||
panel.setStrokeStyle(2, palette.gold, 0.86);
|
panel.setStrokeStyle(2, palette.gold, 0.86);
|
||||||
|
panel.setInteractive();
|
||||||
this.mapMenuObjects.push(panel);
|
this.mapMenuObjects.push(panel);
|
||||||
|
|
||||||
actions.forEach((action, index) => {
|
actions.forEach((action, index) => {
|
||||||
@@ -17789,6 +17885,12 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
const top = this.layout.mapY + this.battleUiLength(82);
|
const top = this.layout.mapY + this.battleUiLength(82);
|
||||||
const campaignSlots = listCampaignSaveSlots();
|
const campaignSlots = listCampaignSaveSlots();
|
||||||
|
|
||||||
|
const shade = this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0x020406, 0.54);
|
||||||
|
shade.setOrigin(0);
|
||||||
|
shade.setDepth(depth - 1);
|
||||||
|
shade.setInteractive();
|
||||||
|
this.saveSlotPanelObjects.push(shade);
|
||||||
|
|
||||||
const panel = this.add.rectangle(left, top, width, height, 0x0f1720, 0.98);
|
const panel = this.add.rectangle(left, top, width, height, 0x0f1720, 0.98);
|
||||||
panel.setOrigin(0);
|
panel.setOrigin(0);
|
||||||
panel.setDepth(depth);
|
panel.setDepth(depth);
|
||||||
@@ -18569,8 +18671,7 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
await this.delay(180);
|
await this.delay(180);
|
||||||
await this.presentCombatResult(result.counter);
|
await this.presentCombatResult(result.counter);
|
||||||
}
|
}
|
||||||
await this.showCombatExchangeMapResults(result);
|
await this.showCombatExchangeMapResults(result, true);
|
||||||
await this.delay(result.counter ? 220 : 160);
|
|
||||||
return this.formatEnemyCombatResult(result, plan.behavior);
|
return this.formatEnemyCombatResult(result, plan.behavior);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18589,8 +18690,8 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
this.centerCameraOnTile(Math.floor((enemy.x + target.x) / 2), Math.floor((enemy.y + target.y) / 2));
|
this.centerCameraOnTile(Math.floor((enemy.x + target.x) / 2), Math.floor((enemy.y + target.y) / 2));
|
||||||
const result = this.resolveCombatAction(enemy, target, usable.command, false, usable);
|
const result = this.resolveCombatAction(enemy, target, usable.command, false, usable);
|
||||||
await this.presentCombatResult(result);
|
await this.presentCombatResult(result);
|
||||||
this.showCombatMapResult(result);
|
const popupDuration = this.showCombatMapResult(result);
|
||||||
await this.delay(160);
|
await this.waitSceneDuration(popupDuration);
|
||||||
return this.formatEnemyCombatResult(result, behavior);
|
return this.formatEnemyCombatResult(result, behavior);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22323,6 +22424,12 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private waitSceneDuration(ms: number) {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
this.time.delayedCall(ms, () => resolve());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private moveUnitViewAsync(
|
private moveUnitViewAsync(
|
||||||
unit: UnitData,
|
unit: UnitData,
|
||||||
x: number,
|
x: number,
|
||||||
@@ -22330,7 +22437,8 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
view = this.unitViews.get(unit.id),
|
view = this.unitViews.get(unit.id),
|
||||||
duration = 260,
|
duration = 260,
|
||||||
fromX = unit.x,
|
fromX = unit.x,
|
||||||
fromY = unit.y
|
fromY = unit.y,
|
||||||
|
showSortieTrail = true
|
||||||
) {
|
) {
|
||||||
return new Promise<void>((resolve) => {
|
return new Promise<void>((resolve) => {
|
||||||
if (!view) {
|
if (!view) {
|
||||||
@@ -22345,7 +22453,9 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
const direction = this.directionFromDelta(x - fromX, y - fromY);
|
const direction = this.directionFromDelta(x - fromX, y - fromY);
|
||||||
const movementDistance = this.movementTileDistanceFrom(fromX, fromY, x, y);
|
const movementDistance = this.movementTileDistanceFrom(fromX, fromY, x, y);
|
||||||
const movementDuration = this.movementDuration(duration, movementDistance);
|
const movementDuration = this.movementDuration(duration, movementDistance);
|
||||||
this.showSortieFlankMovementTrail(unit, startX, startY, targetX, targetY, movementDuration);
|
if (showSortieTrail) {
|
||||||
|
this.showSortieFlankMovementTrail(unit, startX, startY, targetX, targetY, movementDuration);
|
||||||
|
}
|
||||||
this.tweens.killTweensOf([view.sprite, view.hitZone, view.label]);
|
this.tweens.killTweensOf([view.sprite, view.hitZone, view.label]);
|
||||||
if (view.roleSeal) {
|
if (view.roleSeal) {
|
||||||
this.tweens.killTweensOf(view.roleSeal);
|
this.tweens.killTweensOf(view.roleSeal);
|
||||||
@@ -23191,7 +23301,7 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
const view = this.unitViews.get(unit.id);
|
const view = this.unitViews.get(unit.id);
|
||||||
unit.x = fromX;
|
unit.x = fromX;
|
||||||
unit.y = fromY;
|
unit.y = fromY;
|
||||||
this.phase = 'moving';
|
this.phase = 'animating';
|
||||||
this.selectedUnit = unit;
|
this.selectedUnit = unit;
|
||||||
this.pendingMove = undefined;
|
this.pendingMove = undefined;
|
||||||
this.hideCommandMenu();
|
this.hideCommandMenu();
|
||||||
@@ -23201,7 +23311,29 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
this.refreshEnemyIntentForecast();
|
this.refreshEnemyIntentForecast();
|
||||||
soundDirector.playSelect();
|
soundDirector.playSelect();
|
||||||
|
|
||||||
this.moveUnitView(unit, fromX, fromY, view, 180, toX, toY, false);
|
this.clearMarkers();
|
||||||
|
this.renderUnitDetail(
|
||||||
|
unit,
|
||||||
|
`이동을 취소하는 중입니다.\n${toX + 1}, ${toY + 1}에서 원래 위치 ${fromX + 1}, ${fromY + 1}로 되돌아갑니다.`
|
||||||
|
);
|
||||||
|
void this.completePendingMoveCancellation(unit, fromX, fromY, toX, toY, view);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async completePendingMoveCancellation(
|
||||||
|
unit: UnitData,
|
||||||
|
fromX: number,
|
||||||
|
fromY: number,
|
||||||
|
toX: number,
|
||||||
|
toY: number,
|
||||||
|
view?: UnitView
|
||||||
|
) {
|
||||||
|
await this.moveUnitViewAsync(unit, fromX, fromY, view, 180, toX, toY, false);
|
||||||
|
if (this.battleOutcome || this.phase !== 'animating' || this.selectedUnit?.id !== unit.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.phase = 'moving';
|
||||||
this.clearMarkers();
|
this.clearMarkers();
|
||||||
this.showMoveRange(unit);
|
this.showMoveRange(unit);
|
||||||
this.renderUnitDetail(
|
this.renderUnitDetail(
|
||||||
@@ -23211,7 +23343,6 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
if (this.firstBattleTutorialStep === 'attack-command') {
|
if (this.firstBattleTutorialStep === 'attack-command') {
|
||||||
this.setFirstBattleTutorialStep('move', '이동을 취소해 권장 이동칸 단계로 돌아왔습니다.');
|
this.setFirstBattleTutorialStep('move', '이동을 취소해 권장 이동칸 단계로 돌아왔습니다.');
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private moveUnitView(
|
private moveUnitView(
|
||||||
@@ -23907,19 +24038,25 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async showCombatExchangeMapResults(result: CombatResult) {
|
private async showCombatExchangeMapResults(result: CombatResult, waitUntilReadable = false) {
|
||||||
this.showCombatMapResult(result);
|
let popupDuration = this.showCombatMapResult(result);
|
||||||
if (!result.counter) {
|
if (!result.counter) {
|
||||||
|
if (waitUntilReadable) {
|
||||||
|
await this.waitSceneDuration(popupDuration);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.delay(110);
|
await this.delay(110);
|
||||||
this.showCombatMapResult(result.counter);
|
popupDuration = this.showCombatMapResult(result.counter);
|
||||||
|
if (waitUntilReadable) {
|
||||||
|
await this.waitSceneDuration(popupDuration);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private showCombatMapResult(result: CombatResult) {
|
private showCombatMapResult(result: CombatResult) {
|
||||||
if (!result.hit) {
|
if (!result.hit) {
|
||||||
this.showMapResultPopup(
|
return this.showMapResultPopup(
|
||||||
result.defender,
|
result.defender,
|
||||||
[this.missFeedbackLabel(result.action, result.usable)],
|
[this.missFeedbackLabel(result.action, result.usable)],
|
||||||
'#d9f1ff',
|
'#d9f1ff',
|
||||||
@@ -23927,7 +24064,6 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
19,
|
19,
|
||||||
26
|
26
|
||||||
);
|
);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const effectLine = [
|
const effectLine = [
|
||||||
@@ -23948,7 +24084,7 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
this.truncateUiText(effectLine, 28)
|
this.truncateUiText(effectLine, 28)
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
|
||||||
this.showMapResultPopup(
|
return this.showMapResultPopup(
|
||||||
result.defender,
|
result.defender,
|
||||||
lines,
|
lines,
|
||||||
result.critical ? '#ff8f5f' : '#ffdf7b',
|
result.critical ? '#ff8f5f' : '#ffdf7b',
|
||||||
@@ -24003,12 +24139,12 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
private showMapResultPopup(unit: UnitData, lines: string[], color: string, stroke: string, fontSize: number, lift: number) {
|
private showMapResultPopup(unit: UnitData, lines: string[], color: string, stroke: string, fontSize: number, lift: number) {
|
||||||
const view = this.unitViews.get(unit.id);
|
const view = this.unitViews.get(unit.id);
|
||||||
if (!view) {
|
if (!view) {
|
||||||
return;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const visibleLines = lines.filter(Boolean);
|
const visibleLines = lines.filter(Boolean);
|
||||||
if (visibleLines.length === 0) {
|
if (visibleLines.length === 0) {
|
||||||
return;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const popup = this.add.text(
|
const popup = this.add.text(
|
||||||
@@ -24078,6 +24214,7 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
return duration + delay;
|
||||||
}
|
}
|
||||||
|
|
||||||
private showObjectiveMapFeedback(
|
private showObjectiveMapFeedback(
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ import {
|
|||||||
import { clearCampaignBattleSavesForSlot } from '../state/battleSaveKeys';
|
import { clearCampaignBattleSavesForSlot } from '../state/battleSaveKeys';
|
||||||
import { releaseTextureSource } from '../render/loaderLifecycle';
|
import { releaseTextureSource } from '../render/loaderLifecycle';
|
||||||
import { palette } from '../ui/palette';
|
import { palette } from '../ui/palette';
|
||||||
import { startLazyScene, type LazySceneKey } from './lazyScenes';
|
import { ensureLazyScene, type LazySceneKey } from './lazyScenes';
|
||||||
|
|
||||||
const campLegacyCanvasWidth = 1280;
|
const campLegacyCanvasWidth = 1280;
|
||||||
const campLegacyCanvasHeight = 720;
|
const campLegacyCanvasHeight = 720;
|
||||||
@@ -11321,6 +11321,7 @@ export class CampScene extends Phaser.Scene {
|
|||||||
private sortieObjects: Phaser.GameObjects.GameObject[] = [];
|
private sortieObjects: Phaser.GameObjects.GameObject[] = [];
|
||||||
private saveSlotObjects: Phaser.GameObjects.GameObject[] = [];
|
private saveSlotObjects: Phaser.GameObjects.GameObject[] = [];
|
||||||
private saveSlotConfirmObjects: Phaser.GameObjects.GameObject[] = [];
|
private saveSlotConfirmObjects: Phaser.GameObjects.GameObject[] = [];
|
||||||
|
private navigationBlockerObjects: Phaser.GameObjects.GameObject[] = [];
|
||||||
private pendingSaveSlot?: number;
|
private pendingSaveSlot?: number;
|
||||||
private pendingVictoryRewardReport?: CampaignVictoryRewardReport;
|
private pendingVictoryRewardReport?: CampaignVictoryRewardReport;
|
||||||
private victoryRewardObjects: Phaser.GameObjects.GameObject[] = [];
|
private victoryRewardObjects: Phaser.GameObjects.GameObject[] = [];
|
||||||
@@ -11442,6 +11443,7 @@ export class CampScene extends Phaser.Scene {
|
|||||||
this.sortieOrderToggleButton = undefined;
|
this.sortieOrderToggleButton = undefined;
|
||||||
this.saveSlotObjects = [];
|
this.saveSlotObjects = [];
|
||||||
this.saveSlotConfirmObjects = [];
|
this.saveSlotConfirmObjects = [];
|
||||||
|
this.navigationBlockerObjects = [];
|
||||||
this.pendingSaveSlot = undefined;
|
this.pendingSaveSlot = undefined;
|
||||||
this.pendingVictoryRewardReport = undefined;
|
this.pendingVictoryRewardReport = undefined;
|
||||||
this.victoryRewardObjects = [];
|
this.victoryRewardObjects = [];
|
||||||
@@ -12739,6 +12741,10 @@ export class CampScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private showCampSaveSlotPanel() {
|
private showCampSaveSlotPanel() {
|
||||||
|
if (this.navigationPending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.saveSlotObjects.length > 0) {
|
if (this.saveSlotObjects.length > 0) {
|
||||||
this.hideCampSaveSlotPanel();
|
this.hideCampSaveSlotPanel();
|
||||||
return;
|
return;
|
||||||
@@ -12752,6 +12758,35 @@ export class CampScene extends Phaser.Scene {
|
|||||||
const activeSlot = this.campaign?.activeSaveSlot ?? getCampaignState().activeSaveSlot;
|
const activeSlot = this.campaign?.activeSaveSlot ?? getCampaignState().activeSaveSlot;
|
||||||
const slots = listCampaignSaveSlots();
|
const slots = listCampaignSaveSlots();
|
||||||
|
|
||||||
|
const shade = this.add.rectangle(0, 0, campLegacyCanvasWidth, campLegacyCanvasHeight, 0x020406, 0.28);
|
||||||
|
shade.setOrigin(0);
|
||||||
|
shade.setDepth(depth - 1);
|
||||||
|
shade.setInteractive();
|
||||||
|
shade.on(
|
||||||
|
'pointerdown',
|
||||||
|
(
|
||||||
|
_pointer: Phaser.Input.Pointer,
|
||||||
|
_localX: number,
|
||||||
|
_localY: number,
|
||||||
|
event?: { stopPropagation: () => void }
|
||||||
|
) => {
|
||||||
|
event?.stopPropagation();
|
||||||
|
soundDirector.playSelect();
|
||||||
|
this.hideCampSaveSlotPanel();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
shade.on(
|
||||||
|
'wheel',
|
||||||
|
(
|
||||||
|
_pointer: Phaser.Input.Pointer,
|
||||||
|
_deltaX: number,
|
||||||
|
_deltaY: number,
|
||||||
|
_deltaZ: number,
|
||||||
|
event?: { stopPropagation: () => void }
|
||||||
|
) => event?.stopPropagation()
|
||||||
|
);
|
||||||
|
this.trackCampSaveSlot(shade);
|
||||||
|
|
||||||
const panel = this.add.rectangle(left, top, width, height, 0x0f1720, 0.98);
|
const panel = this.add.rectangle(left, top, width, height, 0x0f1720, 0.98);
|
||||||
panel.setOrigin(0);
|
panel.setOrigin(0);
|
||||||
panel.setDepth(depth);
|
panel.setDepth(depth);
|
||||||
@@ -12980,6 +13015,11 @@ export class CampScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handleSaveSlotKey(event: KeyboardEvent) {
|
private handleSaveSlotKey(event: KeyboardEvent) {
|
||||||
|
if (this.navigationPending) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.victoryRewardObjects.length > 0) {
|
if (this.victoryRewardObjects.length > 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -13491,6 +13531,10 @@ export class CampScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handleEscapeKey() {
|
private handleEscapeKey() {
|
||||||
|
if (this.navigationPending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.victoryRewardObjects.length > 0) {
|
if (this.victoryRewardObjects.length > 0) {
|
||||||
this.completeVictoryRewardAcknowledgement('close');
|
this.completeVictoryRewardAcknowledgement('close');
|
||||||
return;
|
return;
|
||||||
@@ -19877,19 +19921,26 @@ export class CampScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const previousStep = getCampaignState().step;
|
const previousStep = getCampaignState().step;
|
||||||
markCampaignStep(step);
|
this.startCampNavigation(
|
||||||
this.campaign = getCampaignState();
|
key,
|
||||||
this.startCampNavigation(key, data, () => {
|
data,
|
||||||
if (getCampaignState().step === step) {
|
() => {
|
||||||
markCampaignStep(previousStep);
|
markCampaignStep(step);
|
||||||
|
this.campaign = getCampaignState();
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
if (getCampaignState().step === step) {
|
||||||
|
markCampaignStep(previousStep);
|
||||||
|
}
|
||||||
|
this.campaign = getCampaignState();
|
||||||
}
|
}
|
||||||
this.campaign = getCampaignState();
|
);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private startCampNavigation(
|
private startCampNavigation(
|
||||||
key: LazySceneKey,
|
key: LazySceneKey,
|
||||||
data?: Record<string, unknown>,
|
data?: Record<string, unknown>,
|
||||||
|
commitState?: () => void,
|
||||||
recoverState?: () => void
|
recoverState?: () => void
|
||||||
) {
|
) {
|
||||||
if (this.navigationPending) {
|
if (this.navigationPending) {
|
||||||
@@ -19897,14 +19948,94 @@ export class CampScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.navigationPending = true;
|
this.navigationPending = true;
|
||||||
void startLazyScene(this, key, data).catch((error: unknown) => {
|
this.showCampNavigationBlocker();
|
||||||
this.navigationPending = false;
|
let stateCommitted = false;
|
||||||
recoverState?.();
|
void (async () => {
|
||||||
console.error(`[CampScene] ${key} 전환에 실패했습니다.`, error);
|
try {
|
||||||
if (this.sys.isActive()) {
|
await ensureLazyScene(this.game, key);
|
||||||
this.showCampNotice('다음 장면을 불러오지 못했습니다. 잠시 후 다시 시도하십시오.');
|
if (!this.sys.isActive()) {
|
||||||
|
this.navigationPending = false;
|
||||||
|
this.hideCampNavigationBlocker();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (commitState) {
|
||||||
|
stateCommitted = true;
|
||||||
|
commitState();
|
||||||
|
}
|
||||||
|
this.scene.start(key, data);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
this.navigationPending = false;
|
||||||
|
this.hideCampNavigationBlocker();
|
||||||
|
if (stateCommitted) {
|
||||||
|
recoverState?.();
|
||||||
|
}
|
||||||
|
console.error(`[CampScene] ${key} 전환에 실패했습니다.`, error);
|
||||||
|
if (this.sys.isActive()) {
|
||||||
|
this.showCampNotice('다음 장면을 불러오지 못했습니다. 잠시 후 다시 시도하십시오.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
private showCampNavigationBlocker() {
|
||||||
|
this.hideCampNavigationBlocker();
|
||||||
|
const depth = 120;
|
||||||
|
const shade = this.add.rectangle(0, 0, campLegacyCanvasWidth, campLegacyCanvasHeight, 0x020406, 0.68);
|
||||||
|
shade.setOrigin(0);
|
||||||
|
shade.setDepth(depth);
|
||||||
|
shade.setInteractive();
|
||||||
|
shade.on(
|
||||||
|
'pointerdown',
|
||||||
|
(
|
||||||
|
_pointer: Phaser.Input.Pointer,
|
||||||
|
_localX: number,
|
||||||
|
_localY: number,
|
||||||
|
event?: { stopPropagation: () => void }
|
||||||
|
) => event?.stopPropagation()
|
||||||
|
);
|
||||||
|
shade.on(
|
||||||
|
'wheel',
|
||||||
|
(
|
||||||
|
_pointer: Phaser.Input.Pointer,
|
||||||
|
_deltaX: number,
|
||||||
|
_deltaY: number,
|
||||||
|
_deltaZ: number,
|
||||||
|
event?: { stopPropagation: () => void }
|
||||||
|
) => event?.stopPropagation()
|
||||||
|
);
|
||||||
|
this.trackCampNavigationBlocker(shade);
|
||||||
|
|
||||||
|
const panel = this.add.rectangle(campLegacyCanvasWidth / 2, campLegacyCanvasHeight / 2, 330, 104, 0x111a24, 0.99);
|
||||||
|
panel.setDepth(depth + 1);
|
||||||
|
panel.setStrokeStyle(2, palette.gold, 0.9);
|
||||||
|
panel.setInteractive();
|
||||||
|
this.trackCampNavigationBlocker(panel);
|
||||||
|
|
||||||
|
const title = this.add.text(
|
||||||
|
campLegacyCanvasWidth / 2,
|
||||||
|
campLegacyCanvasHeight / 2 - 18,
|
||||||
|
'불러오는 중…',
|
||||||
|
this.textStyle(22, '#f2e3bf', true)
|
||||||
|
);
|
||||||
|
title.setOrigin(0.5);
|
||||||
|
title.setDepth(depth + 2);
|
||||||
|
this.trackCampNavigationBlocker(title);
|
||||||
|
|
||||||
|
const guide = this.add.text(
|
||||||
|
campLegacyCanvasWidth / 2,
|
||||||
|
campLegacyCanvasHeight / 2 + 20,
|
||||||
|
'편성과 진행을 안전하게 준비하고 있습니다.',
|
||||||
|
this.textStyle(12, '#aeb7c2')
|
||||||
|
);
|
||||||
|
guide.setOrigin(0.5);
|
||||||
|
guide.setDepth(depth + 2);
|
||||||
|
this.trackCampNavigationBlocker(guide);
|
||||||
|
}
|
||||||
|
|
||||||
|
private hideCampNavigationBlocker() {
|
||||||
|
this.navigationBlockerObjects.forEach((object) => object.destroy());
|
||||||
|
this.navigationBlockerObjects = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
private isFinalEpilogueFlow(flow = this.currentSortieFlow()) {
|
private isFinalEpilogueFlow(flow = this.currentSortieFlow()) {
|
||||||
@@ -23745,6 +23876,12 @@ export class CampScene extends Phaser.Scene {
|
|||||||
return object;
|
return object;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private trackCampNavigationBlocker<T extends Phaser.GameObjects.GameObject>(object: T) {
|
||||||
|
this.scaleLegacyCampUi(object);
|
||||||
|
this.navigationBlockerObjects.push(object);
|
||||||
|
return object;
|
||||||
|
}
|
||||||
|
|
||||||
private trackVictoryReward<T extends Phaser.GameObjects.GameObject>(object: T) {
|
private trackVictoryReward<T extends Phaser.GameObjects.GameObject>(object: T) {
|
||||||
this.scaleLegacyCampUi(object);
|
this.scaleLegacyCampUi(object);
|
||||||
this.victoryRewardObjects.push(object);
|
this.victoryRewardObjects.push(object);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const uiPx = (value: number) => `${Math.round(ui(value))}px`;
|
|||||||
export class EndingScene extends Phaser.Scene {
|
export class EndingScene extends Phaser.Scene {
|
||||||
private readonly backgroundKey = 'story-wuzhang-jiangwei-inheritance';
|
private readonly backgroundKey = 'story-wuzhang-jiangwei-inheritance';
|
||||||
private ready = false;
|
private ready = false;
|
||||||
|
private navigating = false;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('EndingScene');
|
super('EndingScene');
|
||||||
@@ -19,6 +20,9 @@ export class EndingScene extends Phaser.Scene {
|
|||||||
|
|
||||||
create() {
|
create() {
|
||||||
this.ready = false;
|
this.ready = false;
|
||||||
|
this.navigating = false;
|
||||||
|
this.input.keyboard?.on('keydown-ENTER', (event: KeyboardEvent) => this.returnToTitle(event));
|
||||||
|
this.input.keyboard?.on('keydown-SPACE', (event: KeyboardEvent) => this.returnToTitle(event));
|
||||||
this.createFallbackTexture();
|
this.createFallbackTexture();
|
||||||
this.renderEnding();
|
this.renderEnding();
|
||||||
this.ensureBackgroundLoaded(() => this.renderEnding());
|
this.ensureBackgroundLoaded(() => this.renderEnding());
|
||||||
@@ -170,16 +174,20 @@ export class EndingScene extends Phaser.Scene {
|
|||||||
shadow: { offsetX: 0, offsetY: ui(2), color: '#020408', blur: ui(6), fill: true }
|
shadow: { offsetX: 0, offsetY: ui(2), color: '#020408', blur: ui(6), fill: true }
|
||||||
}).setOrigin(0.5);
|
}).setOrigin(0.5);
|
||||||
|
|
||||||
this.addButton(width / 2, height - ui(52), '타이틀로', () => {
|
this.addButton(width / 2, height - ui(52), '타이틀로', () => this.returnToTitle());
|
||||||
soundDirector.playSelect();
|
|
||||||
this.scene.start('TitleScene');
|
|
||||||
});
|
|
||||||
|
|
||||||
this.input.keyboard?.on('keydown-ENTER', () => this.scene.start('TitleScene'));
|
|
||||||
this.input.keyboard?.on('keydown-SPACE', () => this.scene.start('TitleScene'));
|
|
||||||
soundDirector.playMusic('oath-theme');
|
soundDirector.playMusic('oath-theme');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private returnToTitle(event?: KeyboardEvent) {
|
||||||
|
if (event?.repeat || this.navigating) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.navigating = true;
|
||||||
|
soundDirector.playSelect();
|
||||||
|
this.scene.start('TitleScene');
|
||||||
|
}
|
||||||
|
|
||||||
private addButton(x: number, y: number, label: string, action: () => void) {
|
private addButton(x: number, y: number, label: string, action: () => void) {
|
||||||
const bg = this.add.rectangle(x, y, ui(160), ui(42), 0x1a2630, 0.96);
|
const bg = this.add.rectangle(x, y, ui(160), ui(42), 0x1a2630, 0.96);
|
||||||
bg.setStrokeStyle(ui(1), palette.gold, 0.82);
|
bg.setStrokeStyle(ui(1), palette.gold, 0.82);
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ 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', () => this.handleEnterKey());
|
this.input.keyboard?.on('keydown-ENTER', (event: KeyboardEvent) => this.handleEnterKey(event));
|
||||||
this.input.keyboard?.on('keydown', (event: KeyboardEvent) => this.handleSaveSlotKey(event));
|
this.input.keyboard?.on('keydown', (event: KeyboardEvent) => this.handleSaveSlotKey(event));
|
||||||
this.input.keyboard?.on('keydown-ESC', () => this.handleEscapeKey());
|
this.input.keyboard?.on('keydown-ESC', () => this.handleEscapeKey());
|
||||||
}
|
}
|
||||||
@@ -636,7 +636,11 @@ export class TitleScene extends Phaser.Scene {
|
|||||||
this.saveSlotPanel = undefined;
|
this.saveSlotPanel = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleEnterKey() {
|
private handleEnterKey(event?: KeyboardEvent) {
|
||||||
|
if (event?.repeat || this.navigating) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.saveSlotPanel) {
|
if (this.saveSlotPanel) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -764,6 +768,9 @@ export class TitleScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private startGame() {
|
private startGame() {
|
||||||
|
if (this.navigating) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
soundDirector.start();
|
soundDirector.start();
|
||||||
soundDirector.resume();
|
soundDirector.resume();
|
||||||
soundDirector.playSelect();
|
soundDirector.playSelect();
|
||||||
@@ -772,6 +779,9 @@ export class TitleScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private continueGame(slot?: number) {
|
private continueGame(slot?: number) {
|
||||||
|
if (this.navigating) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
soundDirector.start();
|
soundDirector.start();
|
||||||
soundDirector.resume();
|
soundDirector.resume();
|
||||||
soundDirector.playSelect();
|
soundDirector.playSelect();
|
||||||
|
|||||||
Reference in New Issue
Block a user