feat: surface turn-end risk forecasts
This commit is contained in:
@@ -292,10 +292,35 @@ RPG로 다듬는다.
|
|||||||
남겼다. 결과→후일담→군영→장부 역할은 정적 계약과 1920x1080 브라우저
|
남겼다. 결과→후일담→군영→장부 역할은 정적 계약과 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)에
|
[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:monolith": "node scripts/verify-flow.mjs",
|
||||||
"verify:flow:segment": "node scripts/verify-flow.mjs",
|
"verify:flow:segment": "node scripts/verify-flow.mjs",
|
||||||
"verify:interaction-ux": "node scripts/verify-interaction-ux.mjs",
|
"verify:interaction-ux": "node scripts/verify-interaction-ux.mjs",
|
||||||
|
"verify: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:battle-keyboard:browser": "node scripts/verify-battle-keyboard-command-flow-browser.mjs",
|
||||||
"verify:title-accessibility:browser": "node scripts/verify-title-accessibility-browser.mjs",
|
"verify:title-accessibility:browser": "node scripts/verify-title-accessibility-browser.mjs",
|
||||||
"verify:save-flow": "node scripts/verify-save-retry-flow.mjs",
|
"verify:save-flow": "node scripts/verify-save-retry-flow.mjs",
|
||||||
"verify:release": "node scripts/verify-release-candidate.mjs",
|
"verify:release": "node scripts/verify-release-candidate.mjs",
|
||||||
"verify:local-release": "pnpm run verify:static-data && pnpm run verify:unit-action-assets && pnpm run verify:enemy-intent && tsc --noEmit && pnpm run build && pnpm run verify:unit-action-assets:dist && pnpm run verify:loader-lifecycle && pnpm run verify:interaction-ux && pnpm run verify: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: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",
|
||||||
|
|||||||
@@ -447,6 +447,11 @@ async function verifyBattlePointerFlow(page) {
|
|||||||
automaticTurnPrompt?.title === '모든 아군의 행동이 종료되었습니다.' &&
|
automaticTurnPrompt?.title === '모든 아군의 행동이 종료되었습니다.' &&
|
||||||
automaticTurnPrompt.focusedAction === 'primary' &&
|
automaticTurnPrompt.focusedAction === 'primary' &&
|
||||||
automaticTurnPrompt.cancelAction === 'secondary' &&
|
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 === 'primary')?.meaning === 'end-turn' &&
|
||||||
automaticTurnPrompt.buttons?.find((button) => button.id === 'secondary')?.meaning === 'review-battlefield',
|
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)}`
|
`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.actedUnitIds.clear();
|
||||||
scene.clearEnemyIntentForecast();
|
scene.clearEnemyIntentForecast();
|
||||||
|
|
||||||
const threatTargets = Array.from({ length: 3 }, (_, index) => {
|
const threatTargets = Array.from({ length: 3 }, (_, index) => (
|
||||||
const ally = allies[index];
|
allies[index] ?? {
|
||||||
return ally ?? {
|
|
||||||
...allies[0],
|
...allies[0],
|
||||||
id: `qa-overflow-threat-target-${index + 1}`,
|
id: `qa-overflow-threat-target-${index + 1}`,
|
||||||
name: `추가 위협 대상 ${index + 1}`
|
name: `추가 위협 대상 ${index + 1}`
|
||||||
};
|
}
|
||||||
});
|
));
|
||||||
threatTargets.forEach((ally, index) => {
|
const forcedAttacks = [
|
||||||
const enemy = enemies[index];
|
{ enemy: enemies[0], target: threatTargets[0], damage: 12, hitRate: 60, criticalRate: 0 },
|
||||||
const plan = scene.buildEnemyActionPlan(enemy);
|
{ enemy: enemies[1], target: threatTargets[0], damage: 18, hitRate: 90, criticalRate: 20 },
|
||||||
scene.enemyIntentForecasts.set(enemy.id, {
|
{ 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,
|
...plan,
|
||||||
|
enemy: attack.enemy,
|
||||||
kind: 'attack',
|
kind: 'attack',
|
||||||
target: ally,
|
target: attack.target,
|
||||||
destination: { x: enemy.x, y: enemy.y },
|
approachTarget: attack.target,
|
||||||
|
usable: undefined,
|
||||||
|
destination: { x: attack.enemy.x, y: attack.enemy.y },
|
||||||
preview: {
|
preview: {
|
||||||
...(plan.preview ?? {}),
|
...(plan.preview ?? {}),
|
||||||
damage: Math.max(1, plan.preview?.damage ?? 1),
|
damage: attack.damage,
|
||||||
hitRate: plan.preview?.hitRate ?? 100,
|
hitRate: attack.hitRate,
|
||||||
criticalRate: plan.preview?.criticalRate ?? 0
|
criticalRate: attack.criticalRate
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -674,17 +697,61 @@ async function verifyEarlyTurnEndSafety(page) {
|
|||||||
window.__HEROS_INTERACTION_UX__.earlyTurnEnemyRunCount += 1;
|
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 {
|
return {
|
||||||
allyIds: allies.map((unit) => unit.id),
|
allyIds: allies.map((unit) => unit.id),
|
||||||
allyNames: allies.map((unit) => unit.name),
|
allyNames: allies.map((unit) => unit.name),
|
||||||
|
forcedAttackCount: forcedAttacks.length,
|
||||||
forcedThreatTargetCount: new Set(
|
forcedThreatTargetCount: new Set(
|
||||||
Array.from(scene.enemyIntentForecasts.values()).map((plan) => plan.target?.id).filter(Boolean)
|
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(
|
assert(
|
||||||
setup?.allyIds.length >= 1 && setup.forcedThreatTargetCount === 3,
|
setup?.allyIds.length >= 1 &&
|
||||||
`Expected unacted allies with three distinct forced threats: ${JSON.stringify(setup)}`
|
setup.forcedAttackCount === 4 &&
|
||||||
|
setup.forcedThreatTargetCount === 3,
|
||||||
|
`Expected four attacks concentrated across three distinct forced threats: ${JSON.stringify(setup)}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const initialPrompt = await openManualTurnEndPrompt(page);
|
const initialPrompt = await openManualTurnEndPrompt(page);
|
||||||
@@ -819,10 +886,36 @@ async function openManualTurnEndPrompt(page) {
|
|||||||
function assertEarlyTurnPrompt(prompt, setup, stage) {
|
function assertEarlyTurnPrompt(prompt, setup, stage) {
|
||||||
const primary = prompt?.buttons?.find((button) => button.id === 'primary');
|
const primary = prompt?.buttons?.find((button) => button.id === 'primary');
|
||||||
const secondary = prompt?.buttons?.find((button) => button.id === 'secondary');
|
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(
|
assert(
|
||||||
prompt?.title.includes(`미행동 ${setup.allyIds.length}명`) &&
|
prompt?.title.includes(`미행동 ${setup.allyIds.length}명`) &&
|
||||||
setup.allyNames.every((name) => prompt.body.includes(name)) &&
|
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.focusedAction === 'primary' &&
|
||||||
prompt.cancelAction === 'primary' &&
|
prompt.cancelAction === 'primary' &&
|
||||||
primary?.label === '계속 지휘' &&
|
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 {
|
import {
|
||||||
buildCombatExchangeProjection,
|
buildCombatExchangeProjection,
|
||||||
compareMoveIntentRisk,
|
compareMoveIntentRisk,
|
||||||
|
summarizeIntentRisk,
|
||||||
type CombatExchangeProjection,
|
type CombatExchangeProjection,
|
||||||
type MoveIntentRiskDelta
|
type MoveIntentRiskDelta
|
||||||
} from '../data/battleForecast';
|
} from '../data/battleForecast';
|
||||||
@@ -1673,6 +1674,7 @@ type TurnEndPromptOptions = {
|
|||||||
mode?: TurnPromptMode;
|
mode?: TurnPromptMode;
|
||||||
title?: string;
|
title?: string;
|
||||||
body?: string;
|
body?: string;
|
||||||
|
riskContext?: string;
|
||||||
primaryLabel?: string;
|
primaryLabel?: string;
|
||||||
secondaryLabel?: string;
|
secondaryLabel?: string;
|
||||||
primaryAction?: () => void;
|
primaryAction?: () => void;
|
||||||
@@ -1706,6 +1708,49 @@ type TurnPromptButtonView = {
|
|||||||
hint: Phaser.GameObjects.Text;
|
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 = {
|
type DeploymentSlot = {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
@@ -2288,6 +2333,7 @@ type EnemyIntentSnapshot = {
|
|||||||
usableId?: string;
|
usableId?: string;
|
||||||
damage: number;
|
damage: number;
|
||||||
hitRate: number;
|
hitRate: number;
|
||||||
|
criticalRate?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type IntentCounterplayKind = 'defeat' | 'line-break' | 'guard';
|
type IntentCounterplayKind = 'defeat' | 'line-break' | 'guard';
|
||||||
@@ -3946,6 +3992,11 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
private turnPromptFocusedAction?: TurnPromptActionId;
|
private turnPromptFocusedAction?: TurnPromptActionId;
|
||||||
private turnPromptCancelActionId?: TurnPromptActionId;
|
private turnPromptCancelActionId?: TurnPromptActionId;
|
||||||
private turnPromptButtonViews: TurnPromptButtonView[] = [];
|
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 mapResultPopupObjects: Phaser.GameObjects.Text[] = [];
|
||||||
private mapResultPopupPeakCount = 0;
|
private mapResultPopupPeakCount = 0;
|
||||||
private mapResultPopupLast?: {
|
private mapResultPopupLast?: {
|
||||||
@@ -4159,6 +4210,11 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
this.turnPromptFocusedAction = undefined;
|
this.turnPromptFocusedAction = undefined;
|
||||||
this.turnPromptCancelActionId = undefined;
|
this.turnPromptCancelActionId = undefined;
|
||||||
this.turnPromptButtonViews = [];
|
this.turnPromptButtonViews = [];
|
||||||
|
this.turnPromptPanel = undefined;
|
||||||
|
this.turnPromptRiskForecast = undefined;
|
||||||
|
this.turnPromptRiskSummaryText = undefined;
|
||||||
|
this.turnPromptRiskFootnoteText = undefined;
|
||||||
|
this.turnPromptRiskRows = [];
|
||||||
this.turnText = undefined;
|
this.turnText = undefined;
|
||||||
this.battleTitleText = undefined;
|
this.battleTitleText = undefined;
|
||||||
this.objectiveTrackerText = undefined;
|
this.objectiveTrackerText = undefined;
|
||||||
@@ -11525,43 +11581,129 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private enemyIntentTurnEndBody() {
|
private enemyIntentTurnEndRiskForecast(): TurnEndRiskForecast | undefined {
|
||||||
if (!this.enemyIntentForecastEnabled()) {
|
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
|
const plans = this.enemyIntentForecasts.size > 0
|
||||||
? Array.from(this.enemyIntentForecasts.values())
|
? Array.from(this.enemyIntentForecasts.values())
|
||||||
: this.currentEnemyActionPlans();
|
: this.currentEnemyActionPlans();
|
||||||
|
const snapshots = this.enemyIntentSnapshotsFromPlans(plans);
|
||||||
|
const plansByTarget = new Map<string, { target: UnitData; plans: EnemyActionPlan[] }>();
|
||||||
plans.forEach((plan) => {
|
plans.forEach((plan) => {
|
||||||
if (!plan.target || plan.kind === 'wait') {
|
if (!plan.target || plan.target.faction !== 'ally' || plan.kind === 'wait') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const group = groups.get(plan.target.id) ?? { target: plan.target, immediate: 0, approach: 0 };
|
const group = plansByTarget.get(plan.target.id) ?? { target: plan.target, plans: [] };
|
||||||
if (plan.kind === 'attack' || plan.kind === 'usable') {
|
group.plans.push(plan);
|
||||||
group.immediate += 1;
|
plansByTarget.set(plan.target.id, group);
|
||||||
} else {
|
|
||||||
group.approach += 1;
|
|
||||||
}
|
|
||||||
groups.set(plan.target.id, group);
|
|
||||||
});
|
});
|
||||||
const sortedGroups = Array.from(groups.values())
|
|
||||||
.sort((left, right) => right.immediate - left.immediate || right.approach - left.approach);
|
const groups = Array.from(plansByTarget.values())
|
||||||
const visibleGroups = sortedGroups.slice(0, 2);
|
.map(({ target, plans: targetPlans }): TurnEndRiskGroup => {
|
||||||
const omittedCount = Math.max(0, sortedGroups.length - visibleGroups.length);
|
const risk = summarizeIntentRisk(snapshots, target.id, target.hp);
|
||||||
const summaries = visibleGroups.map(
|
const immediatePlans = targetPlans.filter(
|
||||||
(group) => `${group.target.name} 공${group.immediate}·추${group.approach}`
|
(plan) => plan.kind === 'attack' || plan.kind === 'usable'
|
||||||
);
|
);
|
||||||
return summaries.length > 0
|
const attacks = immediatePlans.map((plan) => ({
|
||||||
? `적 예상 · ${summaries.join(' · ')}${omittedCount > 0 ? ` · 외 ${omittedCount}명` : ''}`
|
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) {
|
private enemyIntentTargetCounts(unitId: string) {
|
||||||
@@ -22112,7 +22254,7 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
if (unactedAllies.length === 0) {
|
if (unactedAllies.length === 0) {
|
||||||
this.showTurnEndPrompt(undefined, {
|
this.showTurnEndPrompt(undefined, {
|
||||||
title: '턴 종료 확인',
|
title: '턴 종료 확인',
|
||||||
body: this.enemyIntentTurnEndBody(),
|
riskContext: '모든 장수의 행동을 마쳤습니다.',
|
||||||
primaryLabel: '턴 종료',
|
primaryLabel: '턴 종료',
|
||||||
secondaryLabel: '계속 지휘',
|
secondaryLabel: '계속 지휘',
|
||||||
primaryMeaning: 'end-turn',
|
primaryMeaning: 'end-turn',
|
||||||
@@ -22126,7 +22268,7 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
const unactedNames = `${visibleNames.join(' · ')}${omittedNames > 0 ? ` 외 ${omittedNames}명` : ''}`;
|
const unactedNames = `${visibleNames.join(' · ')}${omittedNames > 0 ? ` 외 ${omittedNames}명` : ''}`;
|
||||||
this.showTurnEndPrompt(undefined, {
|
this.showTurnEndPrompt(undefined, {
|
||||||
title: `미행동 ${unactedAllies.length}명 · 턴 종료 확인`,
|
title: `미행동 ${unactedAllies.length}명 · 턴 종료 확인`,
|
||||||
body: `미행동 장수 · ${unactedNames}\n${this.enemyIntentTurnEndSummary()}\n행동을 포기하고 턴을 종료할까요?`,
|
riskContext: `미행동 장수 · ${unactedNames}`,
|
||||||
primaryLabel: '계속 지휘',
|
primaryLabel: '계속 지휘',
|
||||||
secondaryLabel: '미행동 포기 후 종료',
|
secondaryLabel: '미행동 포기 후 종료',
|
||||||
primaryAction: () => this.hideTurnEndPrompt(),
|
primaryAction: () => this.hideTurnEndPrompt(),
|
||||||
@@ -29370,7 +29512,15 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
this.hideTurnEndPrompt();
|
this.hideTurnEndPrompt();
|
||||||
this.turnPromptMode = options.mode ?? 'turn-end';
|
this.turnPromptMode = options.mode ?? 'turn-end';
|
||||||
const promptTitle = options.title ?? '모든 아군의 행동이 종료되었습니다.';
|
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 primaryLabel = options.primaryLabel ?? '턴 종료';
|
||||||
const secondaryLabel = options.secondaryLabel ?? '전장 확인';
|
const secondaryLabel = options.secondaryLabel ?? '전장 확인';
|
||||||
const primaryAction = options.primaryAction ?? (() => this.endAllyTurn());
|
const primaryAction = options.primaryAction ?? (() => this.endAllyTurn());
|
||||||
@@ -29391,8 +29541,11 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
this.turnPromptSecondaryAction = secondaryAction;
|
this.turnPromptSecondaryAction = secondaryAction;
|
||||||
this.turnPromptFocusedAction = options.initialFocus ?? 'primary';
|
this.turnPromptFocusedAction = options.initialFocus ?? 'primary';
|
||||||
this.turnPromptCancelActionId = options.cancelActionId ?? 'secondary';
|
this.turnPromptCancelActionId = options.cancelActionId ?? 'secondary';
|
||||||
|
this.turnPromptRiskForecast = riskForecast;
|
||||||
|
const riskLayout = Boolean(riskForecast && riskForecast.targetCount > 0);
|
||||||
const expandedPrompt = promptBody.split('\n').length >= 3;
|
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 buttonY = promptHeight - 46;
|
||||||
const hintY = promptHeight - 19;
|
const hintY = promptHeight - 19;
|
||||||
|
|
||||||
@@ -29411,7 +29564,7 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
);
|
);
|
||||||
this.turnPromptObjects.push(shade);
|
this.turnPromptObjects.push(shade);
|
||||||
|
|
||||||
const width = this.battleUiLength(388);
|
const width = this.battleUiLength(promptWidth);
|
||||||
const height = this.battleUiLength(promptHeight);
|
const height = this.battleUiLength(promptHeight);
|
||||||
const left = this.layout.mapX + this.layout.mapWidth / 2 - width / 2;
|
const left = this.layout.mapX + this.layout.mapWidth / 2 - width / 2;
|
||||||
const top = this.layout.mapY + this.layout.mapHeight / 2 - height / 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.setDepth(40);
|
||||||
panel.setStrokeStyle(this.battleUiLength(2), palette.gold, 0.9);
|
panel.setStrokeStyle(this.battleUiLength(2), palette.gold, 0.9);
|
||||||
this.turnPromptObjects.push(panel);
|
this.turnPromptObjects.push(panel);
|
||||||
|
this.turnPromptPanel = panel;
|
||||||
|
|
||||||
const title = this.add.text(left + width / 2, top + this.battleUiLength(26), promptTitle, {
|
const title = this.add.text(left + width / 2, top + this.battleUiLength(26), promptTitle, {
|
||||||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||||||
@@ -29431,32 +29585,39 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
title.setDepth(41);
|
title.setDepth(41);
|
||||||
this.turnPromptObjects.push(title);
|
this.turnPromptObjects.push(title);
|
||||||
|
|
||||||
const body = this.add.text(left + width / 2, top + this.battleUiLength(52), promptBody, {
|
if (riskForecast && riskLayout) {
|
||||||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
this.renderTurnEndRiskPromptContents(left, top, width, riskContext, riskForecast);
|
||||||
fontSize: this.battleUiFontSize(17),
|
} else {
|
||||||
color: '#d4dce6',
|
const body = this.add.text(left + width / 2, top + this.battleUiLength(52), promptBody, {
|
||||||
align: 'center',
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||||||
wordWrap: { width: width - this.battleUiLength(44), useAdvancedWrap: true }
|
fontSize: this.battleUiFontSize(17),
|
||||||
});
|
color: '#d4dce6',
|
||||||
body.setOrigin(0.5, 0);
|
align: 'center',
|
||||||
body.setDepth(41);
|
wordWrap: { width: width - this.battleUiLength(44), useAdvancedWrap: true }
|
||||||
this.turnPromptObjects.push(body);
|
});
|
||||||
|
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 = [
|
const buttons = [
|
||||||
{
|
{
|
||||||
id: 'primary' as const,
|
id: 'primary' as const,
|
||||||
label: primaryLabel,
|
label: primaryLabel,
|
||||||
meaning: primaryMeaning,
|
meaning: primaryMeaning,
|
||||||
dangerous: options.primaryDangerous ?? false,
|
dangerous: options.primaryDangerous ?? false,
|
||||||
x: left + this.battleUiLength(119)
|
x: buttonCenter - buttonOffset
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'secondary' as const,
|
id: 'secondary' as const,
|
||||||
label: secondaryLabel,
|
label: secondaryLabel,
|
||||||
meaning: secondaryMeaning,
|
meaning: secondaryMeaning,
|
||||||
dangerous: options.secondaryDangerous ?? false,
|
dangerous: options.secondaryDangerous ?? false,
|
||||||
x: left + this.battleUiLength(269)
|
x: buttonCenter + buttonOffset
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -29497,6 +29658,198 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
void message;
|
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) {
|
private handleTurnPromptNavigationKey(event: KeyboardEvent) {
|
||||||
if (this.turnPromptObjects.length === 0) {
|
if (this.turnPromptObjects.length === 0) {
|
||||||
return false;
|
return false;
|
||||||
@@ -29587,6 +29940,11 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
this.turnPromptFocusedAction = undefined;
|
this.turnPromptFocusedAction = undefined;
|
||||||
this.turnPromptCancelActionId = undefined;
|
this.turnPromptCancelActionId = undefined;
|
||||||
this.turnPromptButtonViews = [];
|
this.turnPromptButtonViews = [];
|
||||||
|
this.turnPromptPanel = undefined;
|
||||||
|
this.turnPromptRiskForecast = undefined;
|
||||||
|
this.turnPromptRiskSummaryText = undefined;
|
||||||
|
this.turnPromptRiskFootnoteText = undefined;
|
||||||
|
this.turnPromptRiskRows = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
private pushBattleLog(message: string) {
|
private pushBattleLog(message: string) {
|
||||||
@@ -33722,13 +34080,42 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
body: this.turnPromptBody ?? '',
|
body: this.turnPromptBody ?? '',
|
||||||
focusedAction: this.turnPromptFocusedAction ?? null,
|
focusedAction: this.turnPromptFocusedAction ?? null,
|
||||||
cancelAction: this.turnPromptCancelActionId ?? 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) => ({
|
buttons: this.turnPromptButtonViews.map((view) => ({
|
||||||
id: view.id,
|
id: view.id,
|
||||||
label: view.label,
|
label: view.label,
|
||||||
meaning: view.meaning,
|
meaning: view.meaning,
|
||||||
dangerous: view.dangerous,
|
dangerous: view.dangerous,
|
||||||
focused: this.turnPromptFocusedAction === view.id,
|
focused: this.turnPromptFocusedAction === view.id,
|
||||||
escapeCancels: this.turnPromptCancelActionId === view.id
|
escapeCancels: this.turnPromptCancelActionId === view.id,
|
||||||
|
bounds: this.gameObjectBoundsDebug(view.background)
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
Reference in New Issue
Block a user