From a67cfbfbe3f9c0f76fb5d809e4eee15733c4c4e3 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Tue, 28 Jul 2026 07:27:21 +0900 Subject: [PATCH] feat: surface turn-end risk forecasts --- docs/game-design-improvement-goal.md | 31 +- package.json | 3 +- scripts/verify-interaction-ux.mjs | 129 +++- scripts/verify-turn-end-risk-browser.mjs | 855 +++++++++++++++++++++++ src/game/scenes/BattleScene.ts | 479 +++++++++++-- 5 files changed, 1429 insertions(+), 68 deletions(-) create mode 100644 scripts/verify-turn-end-risk-browser.mjs diff --git a/docs/game-design-improvement-goal.md b/docs/game-design-improvement-goal.md index 94a81a9..c606127 100644 --- a/docs/game-design-improvement-goal.md +++ b/docs/game-design-improvement-goal.md @@ -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)에 -맞춰 현재 목표와 완료 목표의 재확인 위치를 캠페인 전반에서 다시 감사한다. +맞춰 현재 목표·완료 목표·다음 목표를 어디에서 다시 확인할 수 있는지 +캠페인 전반에서 감사한다. ## 장기 개선 순서 diff --git a/package.json b/package.json index 3f11da3..2f7b26b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/verify-interaction-ux.mjs b/scripts/verify-interaction-ux.mjs index 335b38a..855c5ec 100644 --- a/scripts/verify-interaction-ux.mjs +++ b/scripts/verify-interaction-ux.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 === '계속 지휘' && diff --git a/scripts/verify-turn-end-risk-browser.mjs b/scripts/verify-turn-end-risk-browser.mjs new file mode 100644 index 0000000..5871308 --- /dev/null +++ b/scripts/verify-turn-end-risk-browser.mjs @@ -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)]); + } +} diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 1ccb924..7517582 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -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(); const plans = this.enemyIntentForecasts.size > 0 ? Array.from(this.enemyIntentForecasts.values()) : this.currentEnemyActionPlans(); + const snapshots = this.enemyIntentSnapshotsFromPlans(plans); + const plansByTarget = new Map(); 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,