feat: surface turn-end risk forecasts
This commit is contained in:
@@ -292,10 +292,35 @@ RPG로 다듬는다.
|
||||
남겼다. 결과→후일담→군영→장부 역할은 정적 계약과 1920x1080 브라우저
|
||||
회귀 검증으로 고정한다.
|
||||
|
||||
다음 묶음은 조기 턴 종료 확인창에 적의 대상별 공격 수뿐 아니라 예상 피해와
|
||||
명중 위험을 함께 요약해 마지막 결정에 필요한 정보를 보존한다. 그 뒤에는
|
||||
### 완료한 15차 묶음: 끝내기 전에 결과를 읽는 턴 종료 위험 예보
|
||||
|
||||
- 모든 적 의도와 표적·공격 유형을 행동 전에 공개해 대응 자체를 전술로 만든
|
||||
[Into the Breach GDC 포스트모템](https://media.gdcvault.com/gdc2019/presentations/Into%20the%20Breach%20Postmortem%20Final.pdf)과,
|
||||
HP·피해·명중·치명 정보를 분리해 선택 전에 비교하게 하는
|
||||
[Nintendo의 Fire Emblem: Radiant Dawn 공식 전술 안내](https://www.nintendo.com/en-gb/Games/Wii/Fire-Emblem-Radiant-Dawn-281265.html)를
|
||||
턴 종료 위험창의 정보 구조 기준으로 삼았다.
|
||||
- 되돌릴 수 없는 행동 전에 검토·취소 기회를 주라는
|
||||
[Xbox XAG 115](https://learn.microsoft.com/en-us/xbox/accessibility/xbox-accessibility-guidelines/115)와,
|
||||
색상 신호를 텍스트·아이콘 같은 추가 단서로 반복하라는
|
||||
[Xbox XAG 103](https://learn.microsoft.com/en-us/xbox/accessibility/xbox-accessibility-guidelines/103)을
|
||||
확인 흐름과 위험 표현의 접근성 기준으로 적용했다.
|
||||
- 턴 종료 위험창은 위험 대상별 카드를 최대 2개까지 보여 준다. 각 카드에는
|
||||
공격 횟수·기대 피해·최대 피해·명중률 범위·현재 HP를 함께 배치하고,
|
||||
`집중 공격`과 `격파 가능`을 색상뿐 아니라 명시적인 텍스트로 표시한다.
|
||||
나머지 대상은 `외 N명`으로 요약해 창의 정보 밀도를 제한한다.
|
||||
- 미행동 장수가 남은 조기 종료에서는 `계속 지휘`에 안전 기본 포커스를 두고,
|
||||
미행동 포기와 턴 종료는 별도의 위험 행동으로 남긴다. 마지막 장수의 이동만
|
||||
끝났을 때는 확인창을 자동으로 열지 않는다. 모든 아군의 명령까지 끝난 정상
|
||||
확인과 플레이어가 직접 요청한 조기 종료 모두 현재 위치를 기준으로 위험을
|
||||
다시 계산해 보여 준다.
|
||||
- 대상 카드·초과 요약·안전 기본 포커스·텍스트 위험 경고가 잘리지 않고 실제
|
||||
전투 예보와 일치하는지를 Canvas와 WebGL 모두 1920x1080 CSS viewport,
|
||||
100% 확대, DPR 1에서 자동 검증하는 계약으로 고정한다.
|
||||
|
||||
다음 묶음은
|
||||
[Xbox 목표 명료성 지침](https://learn.microsoft.com/en-us/xbox/accessibility/xbox-accessibility-guidelines/109)에
|
||||
맞춰 현재 목표와 완료 목표의 재확인 위치를 캠페인 전반에서 다시 감사한다.
|
||||
맞춰 현재 목표·완료 목표·다음 목표를 어디에서 다시 확인할 수 있는지
|
||||
캠페인 전반에서 감사한다.
|
||||
|
||||
## 장기 개선 순서
|
||||
|
||||
|
||||
@@ -81,11 +81,12 @@
|
||||
"verify:flow:monolith": "node scripts/verify-flow.mjs",
|
||||
"verify:flow:segment": "node scripts/verify-flow.mjs",
|
||||
"verify:interaction-ux": "node scripts/verify-interaction-ux.mjs",
|
||||
"verify:turn-end-risk:browser": "node scripts/verify-turn-end-risk-browser.mjs",
|
||||
"verify:battle-keyboard:browser": "node scripts/verify-battle-keyboard-command-flow-browser.mjs",
|
||||
"verify:title-accessibility:browser": "node scripts/verify-title-accessibility-browser.mjs",
|
||||
"verify:save-flow": "node scripts/verify-save-retry-flow.mjs",
|
||||
"verify:release": "node scripts/verify-release-candidate.mjs",
|
||||
"verify:local-release": "pnpm run verify:static-data && pnpm run verify:unit-action-assets && pnpm run verify:enemy-intent && tsc --noEmit && pnpm run build && pnpm run verify:unit-action-assets:dist && pnpm run verify:loader-lifecycle && pnpm run verify:interaction-ux && pnpm run verify:title-accessibility:browser && pnpm run verify:battle-keyboard:browser && pnpm run verify:city-stays:browser && pnpm run verify:xuzhou-equipment-link:browser && pnpm run verify:xuzhou-stay-payoff:browser && pnpm run verify:prologue-village:browser && pnpm run verify:first-pursuit-camp:browser && pnpm run verify:second-battle-relief:browser && pnpm run verify:third-battle-return:browser && pnpm run verify:third-camp-preparation:browser && pnpm run verify:av-feedback:browser && pnpm run verify:release && pnpm run verify:performance",
|
||||
"verify: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:turn-end-risk:browser && pnpm run verify:title-accessibility:browser && pnpm run verify:battle-keyboard:browser && pnpm run verify:city-stays:browser && pnpm run verify:xuzhou-equipment-link:browser && pnpm run verify:xuzhou-stay-payoff:browser && pnpm run verify:prologue-village:browser && pnpm run verify:first-pursuit-camp:browser && pnpm run verify:second-battle-relief:browser && pnpm run verify:third-battle-return:browser && pnpm run verify:third-camp-preparation:browser && pnpm run verify:av-feedback:browser && pnpm run verify:release && pnpm run verify:performance",
|
||||
"verify:1.0": "pnpm run verify:local-release && pnpm run verify:flow && pnpm run qa:campaign-complete && pnpm run qa:battle-maps:webgl",
|
||||
"verify:public-deploy": "node scripts/verify-public-deploy.mjs",
|
||||
"qa:representative": "node scripts/qa-representative-battles.mjs",
|
||||
|
||||
@@ -447,6 +447,11 @@ async function verifyBattlePointerFlow(page) {
|
||||
automaticTurnPrompt?.title === '모든 아군의 행동이 종료되었습니다.' &&
|
||||
automaticTurnPrompt.focusedAction === 'primary' &&
|
||||
automaticTurnPrompt.cancelAction === 'secondary' &&
|
||||
automaticTurnPrompt.riskForecast?.targetCount > 0 &&
|
||||
automaticTurnPrompt.riskRows?.length === Math.min(
|
||||
2,
|
||||
automaticTurnPrompt.riskForecast.targetCount
|
||||
) &&
|
||||
automaticTurnPrompt.buttons?.find((button) => button.id === 'primary')?.meaning === 'end-turn' &&
|
||||
automaticTurnPrompt.buttons?.find((button) => button.id === 'secondary')?.meaning === 'review-battlefield',
|
||||
`The all-acted automatic prompt must retain Enter-to-end and Esc-to-review semantics: ${JSON.stringify(automaticTurnPrompt)}`
|
||||
@@ -641,27 +646,45 @@ async function verifyEarlyTurnEndSafety(page) {
|
||||
scene.actedUnitIds.clear();
|
||||
scene.clearEnemyIntentForecast();
|
||||
|
||||
const threatTargets = Array.from({ length: 3 }, (_, index) => {
|
||||
const ally = allies[index];
|
||||
return ally ?? {
|
||||
const threatTargets = Array.from({ length: 3 }, (_, index) => (
|
||||
allies[index] ?? {
|
||||
...allies[0],
|
||||
id: `qa-overflow-threat-target-${index + 1}`,
|
||||
name: `추가 위협 대상 ${index + 1}`
|
||||
};
|
||||
});
|
||||
threatTargets.forEach((ally, index) => {
|
||||
const enemy = enemies[index];
|
||||
const plan = scene.buildEnemyActionPlan(enemy);
|
||||
scene.enemyIntentForecasts.set(enemy.id, {
|
||||
}
|
||||
));
|
||||
const forcedAttacks = [
|
||||
{ enemy: enemies[0], target: threatTargets[0], damage: 12, hitRate: 60, criticalRate: 0 },
|
||||
{ enemy: enemies[1], target: threatTargets[0], damage: 18, hitRate: 90, criticalRate: 20 },
|
||||
{ enemy: enemies[2], target: threatTargets[1], damage: 10, hitRate: 75, criticalRate: 0 },
|
||||
{
|
||||
enemy: {
|
||||
...enemies[0],
|
||||
id: `qa-overflow-attacker-${enemies[0].id}`,
|
||||
name: '추가 위험 검증 적군'
|
||||
},
|
||||
planEnemy: enemies[0],
|
||||
target: threatTargets[2],
|
||||
damage: 8,
|
||||
hitRate: 50,
|
||||
criticalRate: 0
|
||||
}
|
||||
];
|
||||
forcedAttacks.forEach((attack) => {
|
||||
const plan = scene.buildEnemyActionPlan(attack.planEnemy ?? attack.enemy);
|
||||
scene.enemyIntentForecasts.set(attack.enemy.id, {
|
||||
...plan,
|
||||
enemy: attack.enemy,
|
||||
kind: 'attack',
|
||||
target: ally,
|
||||
destination: { x: enemy.x, y: enemy.y },
|
||||
target: attack.target,
|
||||
approachTarget: attack.target,
|
||||
usable: undefined,
|
||||
destination: { x: attack.enemy.x, y: attack.enemy.y },
|
||||
preview: {
|
||||
...(plan.preview ?? {}),
|
||||
damage: Math.max(1, plan.preview?.damage ?? 1),
|
||||
hitRate: plan.preview?.hitRate ?? 100,
|
||||
criticalRate: plan.preview?.criticalRate ?? 0
|
||||
damage: attack.damage,
|
||||
hitRate: attack.hitRate,
|
||||
criticalRate: attack.criticalRate
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -674,17 +697,61 @@ async function verifyEarlyTurnEndSafety(page) {
|
||||
window.__HEROS_INTERACTION_UX__.earlyTurnEnemyRunCount += 1;
|
||||
};
|
||||
|
||||
const expectedGroups = threatTargets.map((target) => {
|
||||
const attacks = forcedAttacks.filter((attack) => attack.target.id === target.id);
|
||||
const expectedDamage = Math.round(attacks.reduce(
|
||||
(total, attack) => total +
|
||||
attack.damage * attack.hitRate / 100 * (1 + attack.criticalRate / 200),
|
||||
0
|
||||
));
|
||||
const rawDamage = attacks.reduce((total, attack) => total + attack.damage, 0);
|
||||
const maximumDamage = attacks.reduce(
|
||||
(total, attack) => total + (
|
||||
attack.criticalRate > 0 ? Math.round(attack.damage * 1.5) : attack.damage
|
||||
),
|
||||
0
|
||||
);
|
||||
return {
|
||||
targetId: target.id,
|
||||
targetName: target.name,
|
||||
hp: target.hp,
|
||||
immediateCount: attacks.length,
|
||||
approachCount: 0,
|
||||
expectedDamage,
|
||||
rawDamage,
|
||||
maximumDamage,
|
||||
lethal: maximumDamage >= target.hp,
|
||||
hitRateMin: Math.min(...attacks.map((attack) => attack.hitRate)),
|
||||
hitRateMax: Math.max(...attacks.map((attack) => attack.hitRate)),
|
||||
concentrated: attacks.length > 1,
|
||||
attackCount: attacks.length
|
||||
};
|
||||
}).sort((left, right) => (
|
||||
Number(right.lethal) - Number(left.lethal) ||
|
||||
right.expectedDamage / Math.max(1, right.hp) - left.expectedDamage / Math.max(1, left.hp) ||
|
||||
right.expectedDamage - left.expectedDamage ||
|
||||
right.immediateCount - left.immediateCount ||
|
||||
right.approachCount - left.approachCount ||
|
||||
left.targetName.localeCompare(right.targetName, 'ko')
|
||||
));
|
||||
return {
|
||||
allyIds: allies.map((unit) => unit.id),
|
||||
allyNames: allies.map((unit) => unit.name),
|
||||
forcedAttackCount: forcedAttacks.length,
|
||||
forcedThreatTargetCount: new Set(
|
||||
Array.from(scene.enemyIntentForecasts.values()).map((plan) => plan.target?.id).filter(Boolean)
|
||||
).size
|
||||
).size,
|
||||
expectedGroups,
|
||||
expectedVisibleGroupIds: expectedGroups.slice(0, 2).map((group) => group.targetId),
|
||||
expectedDamage: expectedGroups.reduce((total, group) => total + group.expectedDamage, 0),
|
||||
expectedLethalTargetCount: expectedGroups.filter((group) => group.lethal).length
|
||||
};
|
||||
});
|
||||
assert(
|
||||
setup?.allyIds.length >= 1 && setup.forcedThreatTargetCount === 3,
|
||||
`Expected unacted allies with three distinct forced threats: ${JSON.stringify(setup)}`
|
||||
setup?.allyIds.length >= 1 &&
|
||||
setup.forcedAttackCount === 4 &&
|
||||
setup.forcedThreatTargetCount === 3,
|
||||
`Expected four attacks concentrated across three distinct forced threats: ${JSON.stringify(setup)}`
|
||||
);
|
||||
|
||||
const initialPrompt = await openManualTurnEndPrompt(page);
|
||||
@@ -819,10 +886,36 @@ async function openManualTurnEndPrompt(page) {
|
||||
function assertEarlyTurnPrompt(prompt, setup, stage) {
|
||||
const primary = prompt?.buttons?.find((button) => button.id === 'primary');
|
||||
const secondary = prompt?.buttons?.find((button) => button.id === 'secondary');
|
||||
const forecast = prompt?.riskForecast;
|
||||
const groupContractsValid = setup.expectedGroups.every((expected) => {
|
||||
const actual = forecast?.groups?.find((group) => group.targetId === expected.targetId);
|
||||
return (
|
||||
actual?.targetName === expected.targetName &&
|
||||
actual.hp === expected.hp &&
|
||||
actual.immediateCount === expected.immediateCount &&
|
||||
actual.approachCount === expected.approachCount &&
|
||||
actual.expectedDamage === expected.expectedDamage &&
|
||||
actual.rawDamage === expected.rawDamage &&
|
||||
actual.maximumDamage === expected.maximumDamage &&
|
||||
actual.lethal === expected.lethal &&
|
||||
actual.hitRateMin === expected.hitRateMin &&
|
||||
actual.hitRateMax === expected.hitRateMax &&
|
||||
actual.concentrated === expected.concentrated &&
|
||||
actual.attacks?.length === expected.attackCount
|
||||
);
|
||||
});
|
||||
assert(
|
||||
prompt?.title.includes(`미행동 ${setup.allyIds.length}명`) &&
|
||||
setup.allyNames.every((name) => prompt.body.includes(name)) &&
|
||||
prompt.body.includes('외 1명') &&
|
||||
forecast?.immediateAttackCount === setup.forcedAttackCount &&
|
||||
forecast.approachCount === 0 &&
|
||||
forecast.targetCount === setup.forcedThreatTargetCount &&
|
||||
forecast.expectedDamage === setup.expectedDamage &&
|
||||
forecast.lethalTargetCount === setup.expectedLethalTargetCount &&
|
||||
forecast.omittedTargetCount === 1 &&
|
||||
sameStrings(forecast.visibleGroupIds, setup.expectedVisibleGroupIds) &&
|
||||
forecast.groups?.length === setup.expectedGroups.length &&
|
||||
groupContractsValid &&
|
||||
prompt.focusedAction === 'primary' &&
|
||||
prompt.cancelAction === 'primary' &&
|
||||
primary?.label === '계속 지휘' &&
|
||||
|
||||
855
scripts/verify-turn-end-risk-browser.mjs
Normal file
855
scripts/verify-turn-end-risk-browser.mjs
Normal file
@@ -0,0 +1,855 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { chromium } from 'playwright';
|
||||
import {
|
||||
desktopBrowserContextOptions,
|
||||
desktopBrowserDeviceScaleFactor,
|
||||
desktopBrowserViewport,
|
||||
desktopBrowserViewportBounds
|
||||
} from './desktop-browser-viewport.mjs';
|
||||
|
||||
const battleId = 'second-battle-yellow-turban-pursuit';
|
||||
const renderer = process.env.VERIFY_TURN_END_RISK_RENDERER;
|
||||
const rendererNames = ['canvas', 'webgl'];
|
||||
|
||||
if (!renderer) {
|
||||
for (const requestedRenderer of rendererNames) {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[fileURLToPath(import.meta.url)],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
VERIFY_TURN_END_RISK_RENDERER: requestedRenderer
|
||||
},
|
||||
stdio: 'inherit'
|
||||
}
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
assert(
|
||||
rendererNames.includes(renderer),
|
||||
`Unsupported turn-end risk renderer "${renderer}".`
|
||||
);
|
||||
|
||||
const baseUrl =
|
||||
process.env.VERIFY_TURN_END_RISK_URL ??
|
||||
'http://127.0.0.1:41811/heros_web/';
|
||||
const targetUrl = withDebugOptions(baseUrl, renderer);
|
||||
const screenshotPath =
|
||||
`dist/verification-turn-end-risk-${renderer}-1920x1080.png`;
|
||||
const forcedAttacks = Object.freeze([
|
||||
{ targetIndex: 0, damage: 16, hitRate: 60, criticalRate: 0 },
|
||||
{ targetIndex: 0, damage: 24, hitRate: 90, criticalRate: 20 },
|
||||
{ targetIndex: 1, damage: 10, hitRate: 75, criticalRate: 0 },
|
||||
{ targetIndex: 2, damage: 8, hitRate: 50, criticalRate: 0 }
|
||||
]);
|
||||
|
||||
let serverProcess;
|
||||
let browser;
|
||||
|
||||
try {
|
||||
mkdirSync('dist', { recursive: true });
|
||||
serverProcess = await ensureLocalServer(targetUrl);
|
||||
browser = await chromium.launch({
|
||||
headless: process.env.VERIFY_TURN_END_RISK_HEADLESS !== '0',
|
||||
args: renderer === 'webgl'
|
||||
? ['--use-angle=swiftshader', '--enable-unsafe-swiftshader']
|
||||
: []
|
||||
});
|
||||
const context = await browser.newContext(desktopBrowserContextOptions);
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(30000);
|
||||
|
||||
const pageErrors = [];
|
||||
const consoleErrors = [];
|
||||
page.on('pageerror', (error) =>
|
||||
pageErrors.push(error.stack ?? error.message)
|
||||
);
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') {
|
||||
consoleErrors.push(message.text());
|
||||
}
|
||||
});
|
||||
|
||||
await page.addInitScript(() => window.localStorage.clear());
|
||||
await page.goto(targetUrl, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 90000
|
||||
});
|
||||
await waitForBattleReady(page);
|
||||
|
||||
const runtime = await readRuntimeProbe(page);
|
||||
assertRuntimeBaseline(runtime, renderer);
|
||||
await startDeploymentIfNeeded(page);
|
||||
|
||||
const setup = await installTurnEndRiskFixture(page);
|
||||
assert.equal(setup.allyIds.length >= 3, true);
|
||||
assert.equal(setup.forcedAttackCount, 4);
|
||||
assert.equal(setup.forcedThreatTargetCount, 3);
|
||||
assert.deepEqual(
|
||||
setup.expectedVisibleGroupIds,
|
||||
setup.expectedGroups.slice(0, 2).map((group) => group.targetId)
|
||||
);
|
||||
|
||||
const prompt = await openManualTurnEndPrompt(page);
|
||||
assertRiskForecast(prompt, setup, renderer);
|
||||
assertPromptLayout(prompt, renderer);
|
||||
|
||||
const screenshot = await page.screenshot({
|
||||
path: screenshotPath,
|
||||
fullPage: true
|
||||
});
|
||||
assert(
|
||||
screenshot.byteLength >= 100_000,
|
||||
`Expected a painted ${renderer} turn-end risk screenshot: ${screenshot.byteLength} bytes.`
|
||||
);
|
||||
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForFunction(() => {
|
||||
const battle = window.__HEROS_DEBUG__?.battle();
|
||||
return (
|
||||
battle?.turnPromptVisible === false &&
|
||||
battle.activeFaction === 'ally'
|
||||
);
|
||||
});
|
||||
const safeReturn = await page.evaluate(() =>
|
||||
window.__HEROS_DEBUG__?.battle()
|
||||
);
|
||||
assert.equal(
|
||||
safeReturn?.actedUnitIds?.length,
|
||||
0,
|
||||
`${renderer}: safe default must return to command without abandoning unacted allies.`
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
pageErrors,
|
||||
[],
|
||||
`${renderer}: unexpected page errors: ${pageErrors.join(' | ')}`
|
||||
);
|
||||
assert.deepEqual(
|
||||
consoleErrors,
|
||||
[],
|
||||
`${renderer}: unexpected console errors: ${consoleErrors.join(' | ')}`
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Verified ${renderer} early turn-end risk forecast at ` +
|
||||
`${desktopBrowserViewport.width}x${desktopBrowserViewport.height}, ` +
|
||||
`100% zoom, DPR ${desktopBrowserDeviceScaleFactor}; captured ${screenshotPath}.`
|
||||
);
|
||||
} finally {
|
||||
await browser?.close();
|
||||
await stopServerProcess(serverProcess);
|
||||
}
|
||||
|
||||
function withDebugOptions(url, requestedRenderer) {
|
||||
const parsed = new URL(url);
|
||||
parsed.searchParams.set('debug', '1');
|
||||
parsed.searchParams.set('renderer', requestedRenderer);
|
||||
parsed.searchParams.set('debugBattle', battleId);
|
||||
parsed.searchParams.set('debugCombatAssetWatchdogMs', '250');
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
async function waitForBattleReady(page) {
|
||||
await page.waitForFunction(
|
||||
(expectedBattleId) => {
|
||||
const battle = window.__HEROS_DEBUG__?.battle?.();
|
||||
const activeScenes = window.__HEROS_DEBUG__?.activeScenes?.() ?? [];
|
||||
return (
|
||||
window.__HEROS_GAME__ !== undefined &&
|
||||
battle?.battleId === expectedBattleId &&
|
||||
['deployment', 'idle'].includes(battle.phase) &&
|
||||
battle.mapBackgroundReady === true &&
|
||||
(
|
||||
battle.phase === 'idle' ||
|
||||
['ready', 'degraded'].includes(battle.combatAssets?.status)
|
||||
) &&
|
||||
activeScenes.includes('BattleScene')
|
||||
);
|
||||
},
|
||||
battleId,
|
||||
{ timeout: 90000 }
|
||||
);
|
||||
await page.evaluate(() => new Promise((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve));
|
||||
}));
|
||||
}
|
||||
|
||||
async function startDeploymentIfNeeded(page) {
|
||||
const state = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
|
||||
if (state?.phase === 'idle') {
|
||||
return;
|
||||
}
|
||||
const startButtonBounds =
|
||||
state?.battleHud?.deploymentPanel?.startButtonBounds;
|
||||
assert(
|
||||
state?.battleId === battleId &&
|
||||
state.phase === 'deployment' &&
|
||||
startButtonBounds,
|
||||
`Expected ${battleId} deployment controls: ${JSON.stringify(state)}`
|
||||
);
|
||||
await clickSceneBounds(page, startButtonBounds);
|
||||
await page.waitForFunction(
|
||||
(expectedBattleId) => {
|
||||
const battle = window.__HEROS_DEBUG__?.battle();
|
||||
return (
|
||||
battle?.battleId === expectedBattleId &&
|
||||
battle.phase === 'idle' &&
|
||||
battle.battleHud?.deploymentPanel === null
|
||||
);
|
||||
},
|
||||
battleId,
|
||||
{ timeout: 90000 }
|
||||
);
|
||||
}
|
||||
|
||||
async function installTurnEndRiskFixture(page) {
|
||||
return page.evaluate((attackDefinitions) => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
||||
const units = scene?.debugBattleUnits?.() ?? [];
|
||||
const allies = units.filter(
|
||||
(unit) => unit.faction === 'ally' && unit.hp > 0
|
||||
);
|
||||
const enemies = units.filter(
|
||||
(unit) => unit.faction === 'enemy' && unit.hp > 0
|
||||
);
|
||||
if (!scene || allies.length < 3 || enemies.length < attackDefinitions.length) {
|
||||
return {
|
||||
allyIds: [],
|
||||
forcedAttackCount: 0,
|
||||
forcedThreatTargetCount: 0,
|
||||
expectedGroups: [],
|
||||
expectedVisibleGroupIds: [],
|
||||
expectedDamage: 0,
|
||||
expectedLethalTargetCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
scene.clearEnemyIntentForecast();
|
||||
|
||||
const attacks = attackDefinitions.map((definition, index) => ({
|
||||
...definition,
|
||||
target: allies[definition.targetIndex],
|
||||
enemy: enemies[index]
|
||||
}));
|
||||
attacks.forEach((attack) => {
|
||||
const plan = scene.buildEnemyActionPlan(attack.enemy);
|
||||
scene.enemyIntentForecasts.set(attack.enemy.id, {
|
||||
...plan,
|
||||
kind: 'attack',
|
||||
target: attack.target,
|
||||
approachTarget: attack.target,
|
||||
usable: undefined,
|
||||
destination: { x: attack.enemy.x, y: attack.enemy.y },
|
||||
preview: {
|
||||
...(plan.preview ?? {}),
|
||||
damage: attack.damage,
|
||||
hitRate: attack.hitRate,
|
||||
criticalRate: attack.criticalRate
|
||||
}
|
||||
});
|
||||
});
|
||||
scene.renderSituationPanel('조기 턴 종료 위험 요약 검증');
|
||||
|
||||
const expectedGroups = allies.slice(0, 3).map((target) => {
|
||||
const targeted = attacks.filter(
|
||||
(attack) => attack.target.id === target.id
|
||||
);
|
||||
const expectedDamage = Math.round(targeted.reduce(
|
||||
(total, attack) => total +
|
||||
attack.damage *
|
||||
attack.hitRate /
|
||||
100 *
|
||||
(1 + attack.criticalRate / 200),
|
||||
0
|
||||
));
|
||||
const rawDamage = targeted.reduce(
|
||||
(total, attack) => total + attack.damage,
|
||||
0
|
||||
);
|
||||
const maximumDamage = targeted.reduce(
|
||||
(total, attack) => total + (
|
||||
attack.criticalRate > 0
|
||||
? Math.round(attack.damage * 1.5)
|
||||
: attack.damage
|
||||
),
|
||||
0
|
||||
);
|
||||
return {
|
||||
targetId: target.id,
|
||||
targetName: target.name,
|
||||
hp: target.hp,
|
||||
immediateCount: targeted.length,
|
||||
approachCount: 0,
|
||||
expectedDamage,
|
||||
rawDamage,
|
||||
maximumDamage,
|
||||
lethal: maximumDamage >= target.hp,
|
||||
hitRateMin: Math.min(
|
||||
...targeted.map((attack) => attack.hitRate)
|
||||
),
|
||||
hitRateMax: Math.max(
|
||||
...targeted.map((attack) => attack.hitRate)
|
||||
),
|
||||
concentrated: targeted.length > 1,
|
||||
attackCount: targeted.length
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
allyIds: allies.map((unit) => unit.id),
|
||||
allyNames: allies.map((unit) => unit.name),
|
||||
forcedAttackCount: attacks.length,
|
||||
forcedThreatTargetCount: new Set(
|
||||
attacks.map((attack) => attack.target.id)
|
||||
).size,
|
||||
expectedGroups,
|
||||
expectedVisibleGroupIds:
|
||||
expectedGroups.slice(0, 2).map((group) => group.targetId),
|
||||
expectedDamage: expectedGroups.reduce(
|
||||
(total, group) => total + group.expectedDamage,
|
||||
0
|
||||
),
|
||||
expectedLethalTargetCount:
|
||||
expectedGroups.filter((group) => group.lethal).length
|
||||
};
|
||||
}, forcedAttacks);
|
||||
}
|
||||
|
||||
async function openManualTurnEndPrompt(page) {
|
||||
const pointer = await findRightmostEmptyBattleTile(page);
|
||||
await page.mouse.click(pointer.x, pointer.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;
|
||||
});
|
||||
const endTurnIndex = mapMenu?.actions.indexOf('endTurn') ?? -1;
|
||||
assert(
|
||||
mapMenu && endTurnIndex >= 0,
|
||||
`Expected an enabled manual turn-end action: ${JSON.stringify(mapMenu)}`
|
||||
);
|
||||
const keyboardState = await page.evaluate(
|
||||
() => window.__HEROS_DEBUG__?.battle()?.mapMenuKeyboard
|
||||
);
|
||||
assert.equal(
|
||||
keyboardState?.focusedItem?.action,
|
||||
'endTurn',
|
||||
`Expected turn end to be the map menu's safe keyboard default: ${JSON.stringify(keyboardState)}`
|
||||
);
|
||||
assert.equal(keyboardState?.focusedItem?.available, true);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForFunction(
|
||||
() => window.__HEROS_DEBUG__?.battle()?.turnPromptVisible === true
|
||||
);
|
||||
return page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.turnPrompt);
|
||||
}
|
||||
|
||||
function assertRiskForecast(prompt, setup, requestedRenderer) {
|
||||
const forecast = prompt?.riskForecast;
|
||||
const primary = prompt?.buttons?.find((button) => button.id === 'primary');
|
||||
const secondary =
|
||||
prompt?.buttons?.find((button) => button.id === 'secondary');
|
||||
|
||||
assert.equal(
|
||||
prompt?.title,
|
||||
`미행동 ${setup.allyIds.length}명 · 턴 종료 확인`,
|
||||
`${requestedRenderer}: early turn-end title must disclose every unacted ally.`
|
||||
);
|
||||
setup.allyNames.forEach((name) => {
|
||||
assert(
|
||||
prompt.body.includes(name),
|
||||
`${requestedRenderer}: missing unacted ally ${name}: ${prompt.body}`
|
||||
);
|
||||
});
|
||||
assert.equal(forecast?.immediateAttackCount, setup.forcedAttackCount);
|
||||
assert.equal(forecast?.approachCount, 0);
|
||||
assert.equal(forecast?.targetCount, setup.forcedThreatTargetCount);
|
||||
assert.equal(forecast?.expectedDamage, setup.expectedDamage);
|
||||
assert.equal(
|
||||
forecast?.lethalTargetCount,
|
||||
setup.expectedLethalTargetCount
|
||||
);
|
||||
assert.equal(forecast?.omittedTargetCount, 1);
|
||||
assert.deepEqual(
|
||||
forecast?.visibleGroupIds,
|
||||
setup.expectedVisibleGroupIds
|
||||
);
|
||||
assert.equal(forecast?.groups?.length, setup.expectedGroups.length);
|
||||
|
||||
setup.expectedGroups.forEach((expected) => {
|
||||
const actual = forecast.groups.find(
|
||||
(group) => group.targetId === expected.targetId
|
||||
);
|
||||
const row = prompt.riskRows?.find(
|
||||
(candidate) => candidate.targetId === expected.targetId
|
||||
);
|
||||
assert(actual, `${requestedRenderer}: missing risk group ${expected.targetId}.`);
|
||||
assert.deepEqual(
|
||||
{
|
||||
targetId: actual.targetId,
|
||||
targetName: actual.targetName,
|
||||
hp: actual.hp,
|
||||
immediateCount: actual.immediateCount,
|
||||
approachCount: actual.approachCount,
|
||||
expectedDamage: actual.expectedDamage,
|
||||
rawDamage: actual.rawDamage,
|
||||
maximumDamage: actual.maximumDamage,
|
||||
lethal: actual.lethal,
|
||||
hitRateMin: actual.hitRateMin,
|
||||
hitRateMax: actual.hitRateMax,
|
||||
concentrated: actual.concentrated,
|
||||
attackCount: actual.attacks?.length
|
||||
},
|
||||
expected,
|
||||
`${requestedRenderer}: risk group mismatch for ${expected.targetName}.`
|
||||
);
|
||||
if (forecast.visibleGroupIds.includes(expected.targetId)) {
|
||||
const expectedBadge = expected.concentrated && expected.lethal
|
||||
? '집중 공격 · 격파 가능'
|
||||
: expected.lethal
|
||||
? '격파 가능'
|
||||
: expected.concentrated
|
||||
? '집중 공격'
|
||||
: expected.immediateCount > 0
|
||||
? '피격 예상'
|
||||
: '접근 예정';
|
||||
assert(row, `${requestedRenderer}: missing visible risk row ${expected.targetId}.`);
|
||||
assert.equal(row.badgeText, expectedBadge);
|
||||
assert.equal(
|
||||
row.titleText,
|
||||
`${expected.targetName} · 현재 HP ${expected.hp}`
|
||||
);
|
||||
assert(
|
||||
!row.titleText.includes('기대 후'),
|
||||
`${requestedRenderer}: risk row must not imply a deterministic post-turn HP.`
|
||||
);
|
||||
assert(
|
||||
row.metricText.includes(`공격 ${expected.immediateCount}회`) &&
|
||||
row.metricText.includes(`기대 피해 ${expected.expectedDamage}`) &&
|
||||
row.metricText.includes(`최대 피해 ${expected.maximumDamage}`),
|
||||
`${requestedRenderer}: incomplete risk metrics for ${expected.targetName}: ${row.metricText}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
{
|
||||
focusedAction: prompt.focusedAction,
|
||||
cancelAction: prompt.cancelAction,
|
||||
primary: {
|
||||
label: primary?.label,
|
||||
meaning: primary?.meaning,
|
||||
dangerous: primary?.dangerous,
|
||||
focused: primary?.focused,
|
||||
escapeCancels: primary?.escapeCancels
|
||||
},
|
||||
secondary: {
|
||||
label: secondary?.label,
|
||||
meaning: secondary?.meaning,
|
||||
dangerous: secondary?.dangerous,
|
||||
focused: secondary?.focused,
|
||||
escapeCancels: secondary?.escapeCancels
|
||||
}
|
||||
},
|
||||
{
|
||||
focusedAction: 'primary',
|
||||
cancelAction: 'primary',
|
||||
primary: {
|
||||
label: '계속 지휘',
|
||||
meaning: 'continue-command',
|
||||
dangerous: false,
|
||||
focused: true,
|
||||
escapeCancels: true
|
||||
},
|
||||
secondary: {
|
||||
label: '미행동 포기 후 종료',
|
||||
meaning: 'abandon-unacted-and-end',
|
||||
dangerous: true,
|
||||
focused: false,
|
||||
escapeCancels: false
|
||||
}
|
||||
},
|
||||
`${requestedRenderer}: safe focus or explicit danger semantics regressed.`
|
||||
);
|
||||
}
|
||||
|
||||
function assertPromptLayout(prompt, requestedRenderer) {
|
||||
const panel = assertFiniteBounds(
|
||||
prompt?.panelBounds,
|
||||
`${requestedRenderer} prompt panel`
|
||||
);
|
||||
const summary = assertFiniteBounds(
|
||||
prompt?.summaryBounds,
|
||||
`${requestedRenderer} prompt summary`
|
||||
);
|
||||
const footnote = assertFiniteBounds(
|
||||
prompt?.footnoteBounds,
|
||||
`${requestedRenderer} prompt footnote`
|
||||
);
|
||||
const rows = prompt?.riskRows ?? [];
|
||||
const buttons = prompt?.buttons ?? [];
|
||||
|
||||
assert.equal(
|
||||
rows.length,
|
||||
prompt?.riskForecast?.visibleGroupIds?.length,
|
||||
`${requestedRenderer}: every visible risk group must have one row.`
|
||||
);
|
||||
assert.deepEqual(
|
||||
rows.map((row) => row.targetId),
|
||||
prompt.riskForecast.visibleGroupIds,
|
||||
`${requestedRenderer}: row order must match visible risk priority.`
|
||||
);
|
||||
assert.equal(buttons.length, 2, `${requestedRenderer}: expected two prompt buttons.`);
|
||||
|
||||
const rowBounds = rows.map((row) => {
|
||||
const bounds = assertFiniteBounds(
|
||||
row.bounds,
|
||||
`${requestedRenderer} risk row ${row.targetId}`
|
||||
);
|
||||
const badgeBounds = assertFiniteBounds(
|
||||
row.badgeBounds,
|
||||
`${requestedRenderer} risk badge ${row.targetId}`
|
||||
);
|
||||
const titleBounds = assertFiniteBounds(
|
||||
row.titleBounds,
|
||||
`${requestedRenderer} risk title ${row.targetId}`
|
||||
);
|
||||
const metricBounds = assertFiniteBounds(
|
||||
row.metricBounds,
|
||||
`${requestedRenderer} risk metrics ${row.targetId}`
|
||||
);
|
||||
[badgeBounds, titleBounds, metricBounds].forEach((textBounds, index) => {
|
||||
assertBoundsContained(
|
||||
textBounds,
|
||||
bounds,
|
||||
`${requestedRenderer} risk row ${row.targetId} text ${index + 1}`
|
||||
);
|
||||
});
|
||||
assert(
|
||||
!boundsOverlap(titleBounds, badgeBounds),
|
||||
`${requestedRenderer}: title and warning badge overlap for ${row.targetId}.`
|
||||
);
|
||||
assert(
|
||||
!boundsOverlap(metricBounds, badgeBounds),
|
||||
`${requestedRenderer}: metrics and warning badge overlap for ${row.targetId}.`
|
||||
);
|
||||
assert(
|
||||
!boundsOverlap(titleBounds, metricBounds),
|
||||
`${requestedRenderer}: title and metrics overlap for ${row.targetId}.`
|
||||
);
|
||||
return bounds;
|
||||
});
|
||||
const buttonBounds = buttons.map((button) =>
|
||||
assertFiniteBounds(
|
||||
button.bounds,
|
||||
`${requestedRenderer} prompt button ${button.id}`
|
||||
)
|
||||
);
|
||||
const contentBounds = [summary, ...rowBounds, footnote, ...buttonBounds];
|
||||
|
||||
assertBoundsContained(
|
||||
panel,
|
||||
desktopBrowserViewportBounds,
|
||||
`${requestedRenderer} prompt panel in viewport`
|
||||
);
|
||||
contentBounds.forEach((bounds, index) => {
|
||||
assertBoundsContained(
|
||||
bounds,
|
||||
panel,
|
||||
`${requestedRenderer} prompt content ${index + 1} in panel`
|
||||
);
|
||||
assertBoundsContained(
|
||||
bounds,
|
||||
desktopBrowserViewportBounds,
|
||||
`${requestedRenderer} prompt content ${index + 1} in viewport`
|
||||
);
|
||||
});
|
||||
|
||||
for (let leftIndex = 0; leftIndex < contentBounds.length; leftIndex += 1) {
|
||||
for (
|
||||
let rightIndex = leftIndex + 1;
|
||||
rightIndex < contentBounds.length;
|
||||
rightIndex += 1
|
||||
) {
|
||||
assert(
|
||||
!boundsOverlap(
|
||||
contentBounds[leftIndex],
|
||||
contentBounds[rightIndex]
|
||||
),
|
||||
`${requestedRenderer}: prompt regions ${leftIndex + 1} and ` +
|
||||
`${rightIndex + 1} overlap: ${JSON.stringify({
|
||||
left: contentBounds[leftIndex],
|
||||
right: contentBounds[rightIndex]
|
||||
})}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readRuntimeProbe(page) {
|
||||
return page.evaluate(() => {
|
||||
const game = window.__HEROS_GAME__;
|
||||
const scene = game?.scene.getScene('BattleScene');
|
||||
const canvas = document.querySelector('canvas');
|
||||
const canvasBounds = canvas?.getBoundingClientRect();
|
||||
return {
|
||||
viewport: {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
dpr: window.devicePixelRatio,
|
||||
zoom: window.visualViewport?.scale ?? 1
|
||||
},
|
||||
canvas: canvas
|
||||
? {
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
bounds: canvasBounds
|
||||
? {
|
||||
x: canvasBounds.x,
|
||||
y: canvasBounds.y,
|
||||
width: canvasBounds.width,
|
||||
height: canvasBounds.height
|
||||
}
|
||||
: null
|
||||
}
|
||||
: null,
|
||||
renderer: {
|
||||
requested: new URLSearchParams(window.location.search).get(
|
||||
'renderer'
|
||||
),
|
||||
type: game?.renderer?.type ?? null,
|
||||
name: game?.renderer?.constructor?.name ?? null
|
||||
},
|
||||
scene: scene
|
||||
? { width: scene.scale.width, height: scene.scale.height }
|
||||
: null
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function assertRuntimeBaseline(runtime, requestedRenderer) {
|
||||
assert.deepEqual(runtime.viewport, {
|
||||
width: desktopBrowserViewport.width,
|
||||
height: desktopBrowserViewport.height,
|
||||
dpr: desktopBrowserDeviceScaleFactor,
|
||||
zoom: 1
|
||||
});
|
||||
assert.deepEqual(runtime.canvas, {
|
||||
width: desktopBrowserViewport.width,
|
||||
height: desktopBrowserViewport.height,
|
||||
bounds: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: desktopBrowserViewport.width,
|
||||
height: desktopBrowserViewport.height
|
||||
}
|
||||
});
|
||||
assert.deepEqual(runtime.scene, {
|
||||
width: desktopBrowserViewport.width,
|
||||
height: desktopBrowserViewport.height
|
||||
});
|
||||
assert.equal(runtime.renderer.requested, requestedRenderer);
|
||||
assert.equal(
|
||||
runtime.renderer.type,
|
||||
requestedRenderer === 'canvas' ? 1 : 2,
|
||||
`Expected ${requestedRenderer} renderer: ${JSON.stringify(runtime.renderer)}`
|
||||
);
|
||||
}
|
||||
|
||||
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.');
|
||||
return sceneLogicalPoint(page, logicalPoint);
|
||||
}
|
||||
|
||||
async function clickSceneBounds(page, bounds) {
|
||||
const point = await sceneLogicalPoint(page, {
|
||||
x: bounds.x + bounds.width / 2,
|
||||
y: bounds.y + bounds.height / 2
|
||||
});
|
||||
await page.mouse.click(point.x, point.y);
|
||||
}
|
||||
|
||||
async function sceneLogicalPoint(page, logicalPoint) {
|
||||
const point = await page.evaluate((requestedPoint) => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
||||
const canvasBounds =
|
||||
document.querySelector('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
|
||||
};
|
||||
}, logicalPoint);
|
||||
assert(
|
||||
point && Number.isFinite(point.x) && Number.isFinite(point.y),
|
||||
`Expected a finite BattleScene point: ${JSON.stringify({
|
||||
logicalPoint,
|
||||
point
|
||||
})}`
|
||||
);
|
||||
return point;
|
||||
}
|
||||
|
||||
function assertFiniteBounds(bounds, label) {
|
||||
assert(
|
||||
bounds &&
|
||||
Number.isFinite(bounds.x) &&
|
||||
Number.isFinite(bounds.y) &&
|
||||
Number.isFinite(bounds.width) &&
|
||||
Number.isFinite(bounds.height) &&
|
||||
bounds.width > 0 &&
|
||||
bounds.height > 0,
|
||||
`Expected finite positive bounds for ${label}: ${JSON.stringify(bounds)}`
|
||||
);
|
||||
return bounds;
|
||||
}
|
||||
|
||||
function assertBoundsContained(inner, outer, label) {
|
||||
const tolerance = 1;
|
||||
assert(
|
||||
inner.x >= outer.x - tolerance &&
|
||||
inner.y >= outer.y - tolerance &&
|
||||
inner.x + inner.width <= outer.x + outer.width + tolerance &&
|
||||
inner.y + inner.height <= outer.y + outer.height + tolerance,
|
||||
`${label} escaped its container: ${JSON.stringify({ inner, outer })}`
|
||||
);
|
||||
}
|
||||
|
||||
function boundsOverlap(left, right) {
|
||||
const tolerance = 1;
|
||||
return (
|
||||
left.x < right.x + right.width - tolerance &&
|
||||
left.x + left.width > right.x + tolerance &&
|
||||
left.y < right.y + right.height - tolerance &&
|
||||
left.y + left.height > right.y + tolerance
|
||||
);
|
||||
}
|
||||
|
||||
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 || '41811',
|
||||
'--strictPort'
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true
|
||||
}
|
||||
);
|
||||
child.stderr.on('data', (chunk) => stderr.push(chunk.toString()));
|
||||
child.stdout.on('data', () => {});
|
||||
|
||||
for (let attempt = 0; attempt < 120; attempt += 1) {
|
||||
if (await canReach(url)) {
|
||||
return child;
|
||||
}
|
||||
await delay(250);
|
||||
}
|
||||
|
||||
await stopServerProcess(child);
|
||||
throw new Error(
|
||||
`Vite server did not start at ${url}\n${stderr.join('')}`
|
||||
);
|
||||
}
|
||||
|
||||
async function canReach(url) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: AbortSignal.timeout(1000)
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function stopServerProcess(child) {
|
||||
if (!child || child.exitCode !== null) {
|
||||
return;
|
||||
}
|
||||
const exited = new Promise((resolve) => child.once('exit', resolve));
|
||||
child.kill();
|
||||
await Promise.race([exited, delay(5000)]);
|
||||
if (child.exitCode === null) {
|
||||
child.kill('SIGKILL');
|
||||
await Promise.race([exited, delay(2000)]);
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
import {
|
||||
buildCombatExchangeProjection,
|
||||
compareMoveIntentRisk,
|
||||
summarizeIntentRisk,
|
||||
type CombatExchangeProjection,
|
||||
type MoveIntentRiskDelta
|
||||
} from '../data/battleForecast';
|
||||
@@ -1673,6 +1674,7 @@ type TurnEndPromptOptions = {
|
||||
mode?: TurnPromptMode;
|
||||
title?: string;
|
||||
body?: string;
|
||||
riskContext?: string;
|
||||
primaryLabel?: string;
|
||||
secondaryLabel?: string;
|
||||
primaryAction?: () => void;
|
||||
@@ -1706,6 +1708,49 @@ type TurnPromptButtonView = {
|
||||
hint: Phaser.GameObjects.Text;
|
||||
};
|
||||
|
||||
type TurnEndRiskAttack = {
|
||||
enemyId: string;
|
||||
enemyName: string;
|
||||
damage: number;
|
||||
hitRate: number;
|
||||
criticalRate: number;
|
||||
};
|
||||
|
||||
type TurnEndRiskGroup = {
|
||||
targetId: string;
|
||||
targetName: string;
|
||||
hp: number;
|
||||
immediateCount: number;
|
||||
approachCount: number;
|
||||
expectedDamage: number;
|
||||
rawDamage: number;
|
||||
maximumDamage: number;
|
||||
lethal: boolean;
|
||||
hitRateMin: number;
|
||||
hitRateMax: number;
|
||||
concentrated: boolean;
|
||||
attacks: TurnEndRiskAttack[];
|
||||
};
|
||||
|
||||
type TurnEndRiskForecast = {
|
||||
immediateAttackCount: number;
|
||||
approachCount: number;
|
||||
targetCount: number;
|
||||
expectedDamage: number;
|
||||
lethalTargetCount: number;
|
||||
omittedTargetCount: number;
|
||||
visibleGroupIds: string[];
|
||||
groups: TurnEndRiskGroup[];
|
||||
};
|
||||
|
||||
type TurnEndRiskRowView = {
|
||||
targetId: string;
|
||||
background: Phaser.GameObjects.Rectangle;
|
||||
badgeText: Phaser.GameObjects.Text;
|
||||
titleText: Phaser.GameObjects.Text;
|
||||
metricText: Phaser.GameObjects.Text;
|
||||
};
|
||||
|
||||
type DeploymentSlot = {
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -2288,6 +2333,7 @@ type EnemyIntentSnapshot = {
|
||||
usableId?: string;
|
||||
damage: number;
|
||||
hitRate: number;
|
||||
criticalRate?: number;
|
||||
};
|
||||
|
||||
type IntentCounterplayKind = 'defeat' | 'line-break' | 'guard';
|
||||
@@ -3946,6 +3992,11 @@ export class BattleScene extends Phaser.Scene {
|
||||
private turnPromptFocusedAction?: TurnPromptActionId;
|
||||
private turnPromptCancelActionId?: TurnPromptActionId;
|
||||
private turnPromptButtonViews: TurnPromptButtonView[] = [];
|
||||
private turnPromptPanel?: Phaser.GameObjects.Rectangle;
|
||||
private turnPromptRiskForecast?: TurnEndRiskForecast;
|
||||
private turnPromptRiskSummaryText?: Phaser.GameObjects.Text;
|
||||
private turnPromptRiskFootnoteText?: Phaser.GameObjects.Text;
|
||||
private turnPromptRiskRows: TurnEndRiskRowView[] = [];
|
||||
private mapResultPopupObjects: Phaser.GameObjects.Text[] = [];
|
||||
private mapResultPopupPeakCount = 0;
|
||||
private mapResultPopupLast?: {
|
||||
@@ -4159,6 +4210,11 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.turnPromptFocusedAction = undefined;
|
||||
this.turnPromptCancelActionId = undefined;
|
||||
this.turnPromptButtonViews = [];
|
||||
this.turnPromptPanel = undefined;
|
||||
this.turnPromptRiskForecast = undefined;
|
||||
this.turnPromptRiskSummaryText = undefined;
|
||||
this.turnPromptRiskFootnoteText = undefined;
|
||||
this.turnPromptRiskRows = [];
|
||||
this.turnText = undefined;
|
||||
this.battleTitleText = undefined;
|
||||
this.objectiveTrackerText = undefined;
|
||||
@@ -11525,43 +11581,129 @@ export class BattleScene extends Phaser.Scene {
|
||||
});
|
||||
}
|
||||
|
||||
private enemyIntentTurnEndBody() {
|
||||
private enemyIntentTurnEndRiskForecast(): TurnEndRiskForecast | undefined {
|
||||
if (!this.enemyIntentForecastEnabled()) {
|
||||
return '아군 턴을 종료하시겠습니까?';
|
||||
return undefined;
|
||||
}
|
||||
return `${this.enemyIntentTurnEndSummary()}\n이대로 턴을 종료할까요?`;
|
||||
}
|
||||
|
||||
private enemyIntentTurnEndSummary() {
|
||||
if (!this.enemyIntentForecastEnabled()) {
|
||||
return '적 예상 정보 없음';
|
||||
}
|
||||
const groups = new Map<string, { target: UnitData; immediate: number; approach: number }>();
|
||||
const plans = this.enemyIntentForecasts.size > 0
|
||||
? Array.from(this.enemyIntentForecasts.values())
|
||||
: this.currentEnemyActionPlans();
|
||||
const snapshots = this.enemyIntentSnapshotsFromPlans(plans);
|
||||
const plansByTarget = new Map<string, { target: UnitData; plans: EnemyActionPlan[] }>();
|
||||
plans.forEach((plan) => {
|
||||
if (!plan.target || plan.kind === 'wait') {
|
||||
if (!plan.target || plan.target.faction !== 'ally' || plan.kind === 'wait') {
|
||||
return;
|
||||
}
|
||||
const group = groups.get(plan.target.id) ?? { target: plan.target, immediate: 0, approach: 0 };
|
||||
if (plan.kind === 'attack' || plan.kind === 'usable') {
|
||||
group.immediate += 1;
|
||||
} else {
|
||||
group.approach += 1;
|
||||
}
|
||||
groups.set(plan.target.id, group);
|
||||
const group = plansByTarget.get(plan.target.id) ?? { target: plan.target, plans: [] };
|
||||
group.plans.push(plan);
|
||||
plansByTarget.set(plan.target.id, group);
|
||||
});
|
||||
const sortedGroups = Array.from(groups.values())
|
||||
.sort((left, right) => right.immediate - left.immediate || right.approach - left.approach);
|
||||
const visibleGroups = sortedGroups.slice(0, 2);
|
||||
const omittedCount = Math.max(0, sortedGroups.length - visibleGroups.length);
|
||||
const summaries = visibleGroups.map(
|
||||
(group) => `${group.target.name} 공${group.immediate}·추${group.approach}`
|
||||
);
|
||||
return summaries.length > 0
|
||||
? `적 예상 · ${summaries.join(' · ')}${omittedCount > 0 ? ` · 외 ${omittedCount}명` : ''}`
|
||||
: '적 예상 · 즉시 공격 없음';
|
||||
|
||||
const groups = Array.from(plansByTarget.values())
|
||||
.map(({ target, plans: targetPlans }): TurnEndRiskGroup => {
|
||||
const risk = summarizeIntentRisk(snapshots, target.id, target.hp);
|
||||
const immediatePlans = targetPlans.filter(
|
||||
(plan) => plan.kind === 'attack' || plan.kind === 'usable'
|
||||
);
|
||||
const attacks = immediatePlans.map((plan) => ({
|
||||
enemyId: plan.enemy.id,
|
||||
enemyName: plan.enemy.name,
|
||||
damage: Math.max(0, plan.preview?.damage ?? 0),
|
||||
hitRate: Phaser.Math.Clamp(Math.round(plan.preview?.hitRate ?? 0), 0, 100),
|
||||
criticalRate: Phaser.Math.Clamp(Math.round(plan.preview?.criticalRate ?? 0), 0, 100)
|
||||
}));
|
||||
const hitRates = attacks.map((attack) => attack.hitRate);
|
||||
return {
|
||||
targetId: target.id,
|
||||
targetName: target.name,
|
||||
hp: Math.max(0, target.hp),
|
||||
immediateCount: risk.immediateCount,
|
||||
approachCount: risk.approachCount,
|
||||
expectedDamage: risk.expectedDamage,
|
||||
rawDamage: risk.rawDamage,
|
||||
maximumDamage: risk.criticalAdjustedMaxDamage,
|
||||
lethal: risk.lethal,
|
||||
hitRateMin: hitRates.length > 0 ? Math.min(...hitRates) : 0,
|
||||
hitRateMax: hitRates.length > 0 ? Math.max(...hitRates) : 0,
|
||||
concentrated: risk.immediateCount >= 2,
|
||||
attacks
|
||||
};
|
||||
})
|
||||
.sort((left, right) => (
|
||||
Number(right.lethal) - Number(left.lethal) ||
|
||||
right.expectedDamage / Math.max(1, right.hp) - left.expectedDamage / Math.max(1, left.hp) ||
|
||||
right.expectedDamage - left.expectedDamage ||
|
||||
right.immediateCount - left.immediateCount ||
|
||||
right.approachCount - left.approachCount ||
|
||||
left.targetName.localeCompare(right.targetName, 'ko')
|
||||
));
|
||||
const visibleGroups = groups.slice(0, 2);
|
||||
|
||||
return {
|
||||
immediateAttackCount: groups.reduce((total, group) => total + group.immediateCount, 0),
|
||||
approachCount: groups.reduce((total, group) => total + group.approachCount, 0),
|
||||
targetCount: groups.length,
|
||||
expectedDamage: groups.reduce((total, group) => total + group.expectedDamage, 0),
|
||||
lethalTargetCount: groups.filter((group) => group.lethal).length,
|
||||
omittedTargetCount: Math.max(0, groups.length - visibleGroups.length),
|
||||
visibleGroupIds: visibleGroups.map((group) => group.targetId),
|
||||
groups
|
||||
};
|
||||
}
|
||||
|
||||
private enemyIntentTurnEndSummary(forecast = this.enemyIntentTurnEndRiskForecast()) {
|
||||
if (!forecast) {
|
||||
return '적 예상 정보 없음';
|
||||
}
|
||||
if (forecast.targetCount === 0) {
|
||||
return '적 예상 · 즉시 공격 없음';
|
||||
}
|
||||
return [
|
||||
`적 예상 · 즉시 공격 ${forecast.immediateAttackCount}회`,
|
||||
`위험 대상 ${forecast.targetCount}명`,
|
||||
`기대 피해 ${forecast.expectedDamage}`,
|
||||
forecast.lethalTargetCount > 0 ? `격파 가능 ${forecast.lethalTargetCount}명` : ''
|
||||
].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
private enemyIntentTurnEndGroupLine(group: TurnEndRiskGroup) {
|
||||
if (group.immediateCount === 0) {
|
||||
return `${group.targetName} · HP ${group.hp} · 즉시 공격 없음 · 접근 ${group.approachCount}명`;
|
||||
}
|
||||
const hitRate = group.hitRateMin === group.hitRateMax
|
||||
? `${group.hitRateMax}%`
|
||||
: `${group.hitRateMin}~${group.hitRateMax}%`;
|
||||
return [
|
||||
`${group.targetName} · HP ${group.hp}`,
|
||||
`공격 ${group.immediateCount}회`,
|
||||
`기대 피해 ${group.expectedDamage}`,
|
||||
`최대 피해 ${group.maximumDamage}`,
|
||||
`명중 ${hitRate}`,
|
||||
group.concentrated ? '집중 공격' : '',
|
||||
group.lethal ? '격파 가능' : ''
|
||||
].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
private enemyIntentTurnEndBody(
|
||||
forecast = this.enemyIntentTurnEndRiskForecast(),
|
||||
context = '현재 위치에서 적 턴을 시작합니다.'
|
||||
) {
|
||||
if (!forecast) {
|
||||
return '아군 턴을 종료하시겠습니까?';
|
||||
}
|
||||
if (forecast.targetCount === 0) {
|
||||
return `${context}\n${this.enemyIntentTurnEndSummary(forecast)}\n이대로 턴을 종료할까요?`;
|
||||
}
|
||||
const visibleGroups = forecast.groups.slice(0, 2);
|
||||
return [
|
||||
context,
|
||||
this.enemyIntentTurnEndSummary(forecast),
|
||||
...visibleGroups.map((group) => this.enemyIntentTurnEndGroupLine(group)),
|
||||
forecast.omittedTargetCount > 0 ? `외 ${forecast.omittedTargetCount}명 위험` : '',
|
||||
'현재 위치와 공개된 적 의도를 기준으로 한 예상입니다.',
|
||||
'이대로 턴을 종료할까요?'
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
private enemyIntentTargetCounts(unitId: string) {
|
||||
@@ -22112,7 +22254,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
if (unactedAllies.length === 0) {
|
||||
this.showTurnEndPrompt(undefined, {
|
||||
title: '턴 종료 확인',
|
||||
body: this.enemyIntentTurnEndBody(),
|
||||
riskContext: '모든 장수의 행동을 마쳤습니다.',
|
||||
primaryLabel: '턴 종료',
|
||||
secondaryLabel: '계속 지휘',
|
||||
primaryMeaning: 'end-turn',
|
||||
@@ -22126,7 +22268,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
const unactedNames = `${visibleNames.join(' · ')}${omittedNames > 0 ? ` 외 ${omittedNames}명` : ''}`;
|
||||
this.showTurnEndPrompt(undefined, {
|
||||
title: `미행동 ${unactedAllies.length}명 · 턴 종료 확인`,
|
||||
body: `미행동 장수 · ${unactedNames}\n${this.enemyIntentTurnEndSummary()}\n행동을 포기하고 턴을 종료할까요?`,
|
||||
riskContext: `미행동 장수 · ${unactedNames}`,
|
||||
primaryLabel: '계속 지휘',
|
||||
secondaryLabel: '미행동 포기 후 종료',
|
||||
primaryAction: () => this.hideTurnEndPrompt(),
|
||||
@@ -29370,7 +29512,15 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.hideTurnEndPrompt();
|
||||
this.turnPromptMode = options.mode ?? 'turn-end';
|
||||
const promptTitle = options.title ?? '모든 아군의 행동이 종료되었습니다.';
|
||||
const promptBody = options.body ?? this.enemyIntentTurnEndBody();
|
||||
const riskForecast = this.turnPromptMode === 'turn-end'
|
||||
? this.enemyIntentTurnEndRiskForecast()
|
||||
: undefined;
|
||||
const riskContext = options.riskContext ?? '모든 장수의 행동을 마쳤습니다.';
|
||||
const promptBody = options.body ?? (
|
||||
this.turnPromptMode === 'turn-end'
|
||||
? this.enemyIntentTurnEndBody(riskForecast, riskContext)
|
||||
: '계속 진행하시겠습니까?'
|
||||
);
|
||||
const primaryLabel = options.primaryLabel ?? '턴 종료';
|
||||
const secondaryLabel = options.secondaryLabel ?? '전장 확인';
|
||||
const primaryAction = options.primaryAction ?? (() => this.endAllyTurn());
|
||||
@@ -29391,8 +29541,11 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.turnPromptSecondaryAction = secondaryAction;
|
||||
this.turnPromptFocusedAction = options.initialFocus ?? 'primary';
|
||||
this.turnPromptCancelActionId = options.cancelActionId ?? 'secondary';
|
||||
this.turnPromptRiskForecast = riskForecast;
|
||||
const riskLayout = Boolean(riskForecast && riskForecast.targetCount > 0);
|
||||
const expandedPrompt = promptBody.split('\n').length >= 3;
|
||||
const promptHeight = expandedPrompt ? 202 : 170;
|
||||
const promptWidth = riskLayout ? 560 : 388;
|
||||
const promptHeight = riskLayout ? 326 : expandedPrompt ? 202 : 170;
|
||||
const buttonY = promptHeight - 46;
|
||||
const hintY = promptHeight - 19;
|
||||
|
||||
@@ -29411,7 +29564,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
);
|
||||
this.turnPromptObjects.push(shade);
|
||||
|
||||
const width = this.battleUiLength(388);
|
||||
const width = this.battleUiLength(promptWidth);
|
||||
const height = this.battleUiLength(promptHeight);
|
||||
const left = this.layout.mapX + this.layout.mapWidth / 2 - width / 2;
|
||||
const top = this.layout.mapY + this.layout.mapHeight / 2 - height / 2;
|
||||
@@ -29420,6 +29573,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
panel.setDepth(40);
|
||||
panel.setStrokeStyle(this.battleUiLength(2), palette.gold, 0.9);
|
||||
this.turnPromptObjects.push(panel);
|
||||
this.turnPromptPanel = panel;
|
||||
|
||||
const title = this.add.text(left + width / 2, top + this.battleUiLength(26), promptTitle, {
|
||||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||||
@@ -29431,32 +29585,39 @@ export class BattleScene extends Phaser.Scene {
|
||||
title.setDepth(41);
|
||||
this.turnPromptObjects.push(title);
|
||||
|
||||
const body = this.add.text(left + width / 2, top + this.battleUiLength(52), promptBody, {
|
||||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||||
fontSize: this.battleUiFontSize(17),
|
||||
color: '#d4dce6',
|
||||
align: 'center',
|
||||
wordWrap: { width: width - this.battleUiLength(44), useAdvancedWrap: true }
|
||||
});
|
||||
body.setOrigin(0.5, 0);
|
||||
body.setDepth(41);
|
||||
this.turnPromptObjects.push(body);
|
||||
if (riskForecast && riskLayout) {
|
||||
this.renderTurnEndRiskPromptContents(left, top, width, riskContext, riskForecast);
|
||||
} else {
|
||||
const body = this.add.text(left + width / 2, top + this.battleUiLength(52), promptBody, {
|
||||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||||
fontSize: this.battleUiFontSize(17),
|
||||
color: '#d4dce6',
|
||||
align: 'center',
|
||||
wordWrap: { width: width - this.battleUiLength(44), useAdvancedWrap: true }
|
||||
});
|
||||
body.setOrigin(0.5, 0);
|
||||
body.setDepth(41);
|
||||
this.turnPromptObjects.push(body);
|
||||
}
|
||||
|
||||
const buttonWidth = this.battleUiLength(136);
|
||||
const buttonWidth = this.battleUiLength(riskLayout ? 160 : 136);
|
||||
const buttonGap = this.battleUiLength(riskLayout ? 16 : 14);
|
||||
const buttonOffset = (buttonWidth + buttonGap) / 2;
|
||||
const buttonCenter = left + width / 2;
|
||||
const buttons = [
|
||||
{
|
||||
id: 'primary' as const,
|
||||
label: primaryLabel,
|
||||
meaning: primaryMeaning,
|
||||
dangerous: options.primaryDangerous ?? false,
|
||||
x: left + this.battleUiLength(119)
|
||||
x: buttonCenter - buttonOffset
|
||||
},
|
||||
{
|
||||
id: 'secondary' as const,
|
||||
label: secondaryLabel,
|
||||
meaning: secondaryMeaning,
|
||||
dangerous: options.secondaryDangerous ?? false,
|
||||
x: left + this.battleUiLength(269)
|
||||
x: buttonCenter + buttonOffset
|
||||
}
|
||||
];
|
||||
|
||||
@@ -29497,6 +29658,198 @@ export class BattleScene extends Phaser.Scene {
|
||||
void message;
|
||||
}
|
||||
|
||||
private renderTurnEndRiskPromptContents(
|
||||
left: number,
|
||||
top: number,
|
||||
width: number,
|
||||
context: string,
|
||||
forecast: TurnEndRiskForecast
|
||||
) {
|
||||
const depth = 41;
|
||||
const contextText = this.add.text(
|
||||
left + width / 2,
|
||||
top + this.battleUiLength(50),
|
||||
context,
|
||||
{
|
||||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||||
fontSize: this.battleUiFontSize(13),
|
||||
color: '#d4dce6',
|
||||
fontStyle: '700',
|
||||
align: 'center'
|
||||
}
|
||||
);
|
||||
contextText.setOrigin(0.5, 0);
|
||||
contextText.setDepth(depth);
|
||||
this.fitTextToWidth(contextText, context, width - this.battleUiLength(56));
|
||||
this.turnPromptObjects.push(contextText);
|
||||
|
||||
const summaryTop = top + this.battleUiLength(76);
|
||||
const summaryHeight = this.battleUiLength(32);
|
||||
const summaryWidth = width - this.battleUiLength(56);
|
||||
const summaryBackground = this.add.rectangle(
|
||||
left + this.battleUiLength(28),
|
||||
summaryTop,
|
||||
summaryWidth,
|
||||
summaryHeight,
|
||||
0x182431,
|
||||
0.98
|
||||
);
|
||||
summaryBackground.setOrigin(0);
|
||||
summaryBackground.setDepth(depth);
|
||||
summaryBackground.setStrokeStyle(this.battleUiLength(1), palette.gold, 0.72);
|
||||
this.turnPromptObjects.push(summaryBackground);
|
||||
|
||||
const summaryLabel = [
|
||||
`즉시 공격 ${forecast.immediateAttackCount}회`,
|
||||
`위험 대상 ${forecast.targetCount}명`,
|
||||
`기대 피해 ${forecast.expectedDamage}`,
|
||||
forecast.lethalTargetCount > 0 ? `격파 가능 ${forecast.lethalTargetCount}명` : ''
|
||||
].filter(Boolean).join(' · ');
|
||||
const summaryText = this.add.text(
|
||||
left + width / 2,
|
||||
summaryTop + summaryHeight / 2,
|
||||
summaryLabel,
|
||||
{
|
||||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||||
fontSize: this.battleUiFontSize(12),
|
||||
color: '#fff2c4',
|
||||
fontStyle: '700'
|
||||
}
|
||||
);
|
||||
summaryText.setOrigin(0.5);
|
||||
summaryText.setDepth(depth + 1);
|
||||
this.fitTextToWidth(summaryText, summaryLabel, summaryWidth - this.battleUiLength(20));
|
||||
this.turnPromptObjects.push(summaryText);
|
||||
this.turnPromptRiskSummaryText = summaryText;
|
||||
|
||||
const rowLeft = left + this.battleUiLength(28);
|
||||
const rowWidth = width - this.battleUiLength(56);
|
||||
const rowHeight = this.battleUiLength(52);
|
||||
forecast.groups.slice(0, 2).forEach((group, index) => {
|
||||
const rowTop = top + this.battleUiLength(116 + index * 60);
|
||||
const tone = group.lethal
|
||||
? 0xd46a54
|
||||
: group.concentrated
|
||||
? 0xd8732c
|
||||
: group.immediateCount > 0
|
||||
? palette.gold
|
||||
: palette.blue;
|
||||
const rowBackground = this.add.rectangle(
|
||||
rowLeft,
|
||||
rowTop,
|
||||
rowWidth,
|
||||
rowHeight,
|
||||
group.lethal ? 0x281819 : 0x121d27,
|
||||
0.98
|
||||
);
|
||||
rowBackground.setOrigin(0);
|
||||
rowBackground.setDepth(depth);
|
||||
rowBackground.setStrokeStyle(this.battleUiLength(group.lethal ? 2 : 1), tone, 0.9);
|
||||
this.turnPromptObjects.push(rowBackground);
|
||||
|
||||
const targetTitle = `${group.targetName} · 현재 HP ${group.hp}`;
|
||||
const simultaneousWarnings = [
|
||||
group.concentrated ? '집중 공격' : '',
|
||||
group.lethal ? '격파 가능' : ''
|
||||
].filter(Boolean);
|
||||
const badgeLabel = simultaneousWarnings.length > 0
|
||||
? simultaneousWarnings.join(' · ')
|
||||
: group.immediateCount > 0
|
||||
? '피격 예상'
|
||||
: '접근 예정';
|
||||
const badgeWidth = this.battleUiLength(Math.max(68, badgeLabel.length * 12 + 20));
|
||||
const badge = this.add.rectangle(
|
||||
rowLeft + rowWidth - this.battleUiLength(10) - badgeWidth / 2,
|
||||
rowTop + this.battleUiLength(14),
|
||||
badgeWidth,
|
||||
this.battleUiLength(22),
|
||||
group.lethal ? 0x6a2923 : 0x1b2a36,
|
||||
0.98
|
||||
);
|
||||
badge.setDepth(depth + 1);
|
||||
badge.setStrokeStyle(this.battleUiLength(1), tone, 0.94);
|
||||
this.turnPromptObjects.push(badge);
|
||||
|
||||
const badgeText = this.add.text(badge.x, badge.y, badgeLabel, {
|
||||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||||
fontSize: this.battleUiFontSize(10),
|
||||
color: group.lethal ? '#ffd4c9' : '#fff0c8',
|
||||
fontStyle: '700'
|
||||
});
|
||||
badgeText.setOrigin(0.5);
|
||||
badgeText.setDepth(depth + 2);
|
||||
this.turnPromptObjects.push(badgeText);
|
||||
|
||||
const titleText = this.add.text(
|
||||
rowLeft + this.battleUiLength(14),
|
||||
rowTop + this.battleUiLength(6),
|
||||
targetTitle,
|
||||
{
|
||||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||||
fontSize: this.battleUiFontSize(13),
|
||||
color: '#f2e3bf',
|
||||
fontStyle: '700'
|
||||
}
|
||||
);
|
||||
titleText.setDepth(depth + 1);
|
||||
this.fitTextToWidth(
|
||||
titleText,
|
||||
targetTitle,
|
||||
rowWidth - badgeWidth - this.battleUiLength(46)
|
||||
);
|
||||
this.turnPromptObjects.push(titleText);
|
||||
|
||||
const hitRateLabel = group.hitRateMin === group.hitRateMax
|
||||
? `${group.hitRateMax}%`
|
||||
: `${group.hitRateMin}~${group.hitRateMax}%`;
|
||||
const metricLabel = group.immediateCount > 0
|
||||
? `공격 ${group.immediateCount}회 · 기대 피해 ${group.expectedDamage} · 최대 피해 ${group.maximumDamage} · 명중 ${hitRateLabel}${group.approachCount > 0 ? ` · 접근 ${group.approachCount}` : ''}`
|
||||
: `즉시 공격 없음 · 접근 ${group.approachCount}명 · 다음 적 행동에서 거리 단축`;
|
||||
const metricText = this.add.text(
|
||||
rowLeft + this.battleUiLength(14),
|
||||
rowTop + this.battleUiLength(30),
|
||||
metricLabel,
|
||||
{
|
||||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||||
fontSize: this.battleUiFontSize(11),
|
||||
color: group.lethal ? '#ffc3b4' : '#b9c9d8',
|
||||
fontStyle: '700'
|
||||
}
|
||||
);
|
||||
metricText.setDepth(depth + 1);
|
||||
this.fitTextToWidth(metricText, metricLabel, rowWidth - this.battleUiLength(28));
|
||||
this.turnPromptObjects.push(metricText);
|
||||
this.turnPromptRiskRows.push({
|
||||
targetId: group.targetId,
|
||||
background: rowBackground,
|
||||
badgeText,
|
||||
titleText,
|
||||
metricText
|
||||
});
|
||||
});
|
||||
|
||||
const footnoteLabel = [
|
||||
'현재 위치와 공개된 적 의도를 기준으로 한 예상입니다.',
|
||||
forecast.omittedTargetCount > 0 ? `외 ${forecast.omittedTargetCount}명 위험` : ''
|
||||
].filter(Boolean).join(' · ');
|
||||
const footnoteText = this.add.text(
|
||||
left + width / 2,
|
||||
top + this.battleUiLength(240),
|
||||
footnoteLabel,
|
||||
{
|
||||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||||
fontSize: this.battleUiFontSize(11),
|
||||
color: '#91a7bd',
|
||||
fontStyle: '700'
|
||||
}
|
||||
);
|
||||
footnoteText.setOrigin(0.5, 0);
|
||||
footnoteText.setDepth(depth);
|
||||
this.fitTextToWidth(footnoteText, footnoteLabel, width - this.battleUiLength(56));
|
||||
this.turnPromptObjects.push(footnoteText);
|
||||
this.turnPromptRiskFootnoteText = footnoteText;
|
||||
}
|
||||
|
||||
private handleTurnPromptNavigationKey(event: KeyboardEvent) {
|
||||
if (this.turnPromptObjects.length === 0) {
|
||||
return false;
|
||||
@@ -29587,6 +29940,11 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.turnPromptFocusedAction = undefined;
|
||||
this.turnPromptCancelActionId = undefined;
|
||||
this.turnPromptButtonViews = [];
|
||||
this.turnPromptPanel = undefined;
|
||||
this.turnPromptRiskForecast = undefined;
|
||||
this.turnPromptRiskSummaryText = undefined;
|
||||
this.turnPromptRiskFootnoteText = undefined;
|
||||
this.turnPromptRiskRows = [];
|
||||
}
|
||||
|
||||
private pushBattleLog(message: string) {
|
||||
@@ -33722,13 +34080,42 @@ export class BattleScene extends Phaser.Scene {
|
||||
body: this.turnPromptBody ?? '',
|
||||
focusedAction: this.turnPromptFocusedAction ?? null,
|
||||
cancelAction: this.turnPromptCancelActionId ?? null,
|
||||
panelBounds: this.gameObjectBoundsDebug(this.turnPromptPanel),
|
||||
summaryBounds: this.gameObjectBoundsDebug(this.turnPromptRiskSummaryText),
|
||||
footnoteBounds: this.gameObjectBoundsDebug(this.turnPromptRiskFootnoteText),
|
||||
riskForecast: this.turnPromptRiskForecast
|
||||
? {
|
||||
immediateAttackCount: this.turnPromptRiskForecast.immediateAttackCount,
|
||||
approachCount: this.turnPromptRiskForecast.approachCount,
|
||||
targetCount: this.turnPromptRiskForecast.targetCount,
|
||||
expectedDamage: this.turnPromptRiskForecast.expectedDamage,
|
||||
lethalTargetCount: this.turnPromptRiskForecast.lethalTargetCount,
|
||||
omittedTargetCount: this.turnPromptRiskForecast.omittedTargetCount,
|
||||
visibleGroupIds: [...this.turnPromptRiskForecast.visibleGroupIds],
|
||||
groups: this.turnPromptRiskForecast.groups.map((group) => ({
|
||||
...group,
|
||||
attacks: group.attacks.map((attack) => ({ ...attack }))
|
||||
}))
|
||||
}
|
||||
: null,
|
||||
riskRows: this.turnPromptRiskRows.map((row) => ({
|
||||
targetId: row.targetId,
|
||||
bounds: this.gameObjectBoundsDebug(row.background),
|
||||
badgeText: row.badgeText.text,
|
||||
badgeBounds: this.gameObjectBoundsDebug(row.badgeText),
|
||||
titleText: row.titleText.text,
|
||||
titleBounds: this.gameObjectBoundsDebug(row.titleText),
|
||||
metricText: row.metricText.text,
|
||||
metricBounds: this.gameObjectBoundsDebug(row.metricText)
|
||||
})),
|
||||
buttons: this.turnPromptButtonViews.map((view) => ({
|
||||
id: view.id,
|
||||
label: view.label,
|
||||
meaning: view.meaning,
|
||||
dangerous: view.dangerous,
|
||||
focused: this.turnPromptFocusedAction === view.id,
|
||||
escapeCancels: this.turnPromptCancelActionId === view.id
|
||||
escapeCancels: this.turnPromptCancelActionId === view.id,
|
||||
bounds: this.gameObjectBoundsDebug(view.background)
|
||||
}))
|
||||
}
|
||||
: null,
|
||||
|
||||
Reference in New Issue
Block a user