feat: surface turn-end risk forecasts

This commit is contained in:
2026-07-28 07:27:21 +09:00
parent 7e06ffb9f5
commit a67cfbfbe3
5 changed files with 1429 additions and 68 deletions

View File

@@ -60,6 +60,7 @@ import {
import {
buildCombatExchangeProjection,
compareMoveIntentRisk,
summarizeIntentRisk,
type CombatExchangeProjection,
type MoveIntentRiskDelta
} from '../data/battleForecast';
@@ -1673,6 +1674,7 @@ type TurnEndPromptOptions = {
mode?: TurnPromptMode;
title?: string;
body?: string;
riskContext?: string;
primaryLabel?: string;
secondaryLabel?: string;
primaryAction?: () => void;
@@ -1706,6 +1708,49 @@ type TurnPromptButtonView = {
hint: Phaser.GameObjects.Text;
};
type TurnEndRiskAttack = {
enemyId: string;
enemyName: string;
damage: number;
hitRate: number;
criticalRate: number;
};
type TurnEndRiskGroup = {
targetId: string;
targetName: string;
hp: number;
immediateCount: number;
approachCount: number;
expectedDamage: number;
rawDamage: number;
maximumDamage: number;
lethal: boolean;
hitRateMin: number;
hitRateMax: number;
concentrated: boolean;
attacks: TurnEndRiskAttack[];
};
type TurnEndRiskForecast = {
immediateAttackCount: number;
approachCount: number;
targetCount: number;
expectedDamage: number;
lethalTargetCount: number;
omittedTargetCount: number;
visibleGroupIds: string[];
groups: TurnEndRiskGroup[];
};
type TurnEndRiskRowView = {
targetId: string;
background: Phaser.GameObjects.Rectangle;
badgeText: Phaser.GameObjects.Text;
titleText: Phaser.GameObjects.Text;
metricText: Phaser.GameObjects.Text;
};
type DeploymentSlot = {
x: number;
y: number;
@@ -2288,6 +2333,7 @@ type EnemyIntentSnapshot = {
usableId?: string;
damage: number;
hitRate: number;
criticalRate?: number;
};
type IntentCounterplayKind = 'defeat' | 'line-break' | 'guard';
@@ -3946,6 +3992,11 @@ export class BattleScene extends Phaser.Scene {
private turnPromptFocusedAction?: TurnPromptActionId;
private turnPromptCancelActionId?: TurnPromptActionId;
private turnPromptButtonViews: TurnPromptButtonView[] = [];
private turnPromptPanel?: Phaser.GameObjects.Rectangle;
private turnPromptRiskForecast?: TurnEndRiskForecast;
private turnPromptRiskSummaryText?: Phaser.GameObjects.Text;
private turnPromptRiskFootnoteText?: Phaser.GameObjects.Text;
private turnPromptRiskRows: TurnEndRiskRowView[] = [];
private mapResultPopupObjects: Phaser.GameObjects.Text[] = [];
private mapResultPopupPeakCount = 0;
private mapResultPopupLast?: {
@@ -4159,6 +4210,11 @@ export class BattleScene extends Phaser.Scene {
this.turnPromptFocusedAction = undefined;
this.turnPromptCancelActionId = undefined;
this.turnPromptButtonViews = [];
this.turnPromptPanel = undefined;
this.turnPromptRiskForecast = undefined;
this.turnPromptRiskSummaryText = undefined;
this.turnPromptRiskFootnoteText = undefined;
this.turnPromptRiskRows = [];
this.turnText = undefined;
this.battleTitleText = undefined;
this.objectiveTrackerText = undefined;
@@ -11525,43 +11581,129 @@ export class BattleScene extends Phaser.Scene {
});
}
private enemyIntentTurnEndBody() {
private enemyIntentTurnEndRiskForecast(): TurnEndRiskForecast | undefined {
if (!this.enemyIntentForecastEnabled()) {
return '아군 턴을 종료하시겠습니까?';
return undefined;
}
return `${this.enemyIntentTurnEndSummary()}\n이대로 턴을 종료할까요?`;
}
private enemyIntentTurnEndSummary() {
if (!this.enemyIntentForecastEnabled()) {
return '적 예상 정보 없음';
}
const groups = new Map<string, { target: UnitData; immediate: number; approach: number }>();
const plans = this.enemyIntentForecasts.size > 0
? Array.from(this.enemyIntentForecasts.values())
: this.currentEnemyActionPlans();
const snapshots = this.enemyIntentSnapshotsFromPlans(plans);
const plansByTarget = new Map<string, { target: UnitData; plans: EnemyActionPlan[] }>();
plans.forEach((plan) => {
if (!plan.target || plan.kind === 'wait') {
if (!plan.target || plan.target.faction !== 'ally' || plan.kind === 'wait') {
return;
}
const group = groups.get(plan.target.id) ?? { target: plan.target, immediate: 0, approach: 0 };
if (plan.kind === 'attack' || plan.kind === 'usable') {
group.immediate += 1;
} else {
group.approach += 1;
}
groups.set(plan.target.id, group);
const group = plansByTarget.get(plan.target.id) ?? { target: plan.target, plans: [] };
group.plans.push(plan);
plansByTarget.set(plan.target.id, group);
});
const sortedGroups = Array.from(groups.values())
.sort((left, right) => right.immediate - left.immediate || right.approach - left.approach);
const visibleGroups = sortedGroups.slice(0, 2);
const omittedCount = Math.max(0, sortedGroups.length - visibleGroups.length);
const summaries = visibleGroups.map(
(group) => `${group.target.name}${group.immediate}·추${group.approach}`
);
return summaries.length > 0
? `적 예상 · ${summaries.join(' · ')}${omittedCount > 0 ? ` · 외 ${omittedCount}` : ''}`
: '적 예상 · 즉시 공격 없음';
const groups = Array.from(plansByTarget.values())
.map(({ target, plans: targetPlans }): TurnEndRiskGroup => {
const risk = summarizeIntentRisk(snapshots, target.id, target.hp);
const immediatePlans = targetPlans.filter(
(plan) => plan.kind === 'attack' || plan.kind === 'usable'
);
const attacks = immediatePlans.map((plan) => ({
enemyId: plan.enemy.id,
enemyName: plan.enemy.name,
damage: Math.max(0, plan.preview?.damage ?? 0),
hitRate: Phaser.Math.Clamp(Math.round(plan.preview?.hitRate ?? 0), 0, 100),
criticalRate: Phaser.Math.Clamp(Math.round(plan.preview?.criticalRate ?? 0), 0, 100)
}));
const hitRates = attacks.map((attack) => attack.hitRate);
return {
targetId: target.id,
targetName: target.name,
hp: Math.max(0, target.hp),
immediateCount: risk.immediateCount,
approachCount: risk.approachCount,
expectedDamage: risk.expectedDamage,
rawDamage: risk.rawDamage,
maximumDamage: risk.criticalAdjustedMaxDamage,
lethal: risk.lethal,
hitRateMin: hitRates.length > 0 ? Math.min(...hitRates) : 0,
hitRateMax: hitRates.length > 0 ? Math.max(...hitRates) : 0,
concentrated: risk.immediateCount >= 2,
attacks
};
})
.sort((left, right) => (
Number(right.lethal) - Number(left.lethal) ||
right.expectedDamage / Math.max(1, right.hp) - left.expectedDamage / Math.max(1, left.hp) ||
right.expectedDamage - left.expectedDamage ||
right.immediateCount - left.immediateCount ||
right.approachCount - left.approachCount ||
left.targetName.localeCompare(right.targetName, 'ko')
));
const visibleGroups = groups.slice(0, 2);
return {
immediateAttackCount: groups.reduce((total, group) => total + group.immediateCount, 0),
approachCount: groups.reduce((total, group) => total + group.approachCount, 0),
targetCount: groups.length,
expectedDamage: groups.reduce((total, group) => total + group.expectedDamage, 0),
lethalTargetCount: groups.filter((group) => group.lethal).length,
omittedTargetCount: Math.max(0, groups.length - visibleGroups.length),
visibleGroupIds: visibleGroups.map((group) => group.targetId),
groups
};
}
private enemyIntentTurnEndSummary(forecast = this.enemyIntentTurnEndRiskForecast()) {
if (!forecast) {
return '적 예상 정보 없음';
}
if (forecast.targetCount === 0) {
return '적 예상 · 즉시 공격 없음';
}
return [
`적 예상 · 즉시 공격 ${forecast.immediateAttackCount}`,
`위험 대상 ${forecast.targetCount}`,
`기대 피해 ${forecast.expectedDamage}`,
forecast.lethalTargetCount > 0 ? `격파 가능 ${forecast.lethalTargetCount}` : ''
].filter(Boolean).join(' · ');
}
private enemyIntentTurnEndGroupLine(group: TurnEndRiskGroup) {
if (group.immediateCount === 0) {
return `${group.targetName} · HP ${group.hp} · 즉시 공격 없음 · 접근 ${group.approachCount}`;
}
const hitRate = group.hitRateMin === group.hitRateMax
? `${group.hitRateMax}%`
: `${group.hitRateMin}~${group.hitRateMax}%`;
return [
`${group.targetName} · HP ${group.hp}`,
`공격 ${group.immediateCount}`,
`기대 피해 ${group.expectedDamage}`,
`최대 피해 ${group.maximumDamage}`,
`명중 ${hitRate}`,
group.concentrated ? '집중 공격' : '',
group.lethal ? '격파 가능' : ''
].filter(Boolean).join(' · ');
}
private enemyIntentTurnEndBody(
forecast = this.enemyIntentTurnEndRiskForecast(),
context = '현재 위치에서 적 턴을 시작합니다.'
) {
if (!forecast) {
return '아군 턴을 종료하시겠습니까?';
}
if (forecast.targetCount === 0) {
return `${context}\n${this.enemyIntentTurnEndSummary(forecast)}\n이대로 턴을 종료할까요?`;
}
const visibleGroups = forecast.groups.slice(0, 2);
return [
context,
this.enemyIntentTurnEndSummary(forecast),
...visibleGroups.map((group) => this.enemyIntentTurnEndGroupLine(group)),
forecast.omittedTargetCount > 0 ? `${forecast.omittedTargetCount}명 위험` : '',
'현재 위치와 공개된 적 의도를 기준으로 한 예상입니다.',
'이대로 턴을 종료할까요?'
].filter(Boolean).join('\n');
}
private enemyIntentTargetCounts(unitId: string) {
@@ -22112,7 +22254,7 @@ export class BattleScene extends Phaser.Scene {
if (unactedAllies.length === 0) {
this.showTurnEndPrompt(undefined, {
title: '턴 종료 확인',
body: this.enemyIntentTurnEndBody(),
riskContext: '모든 장수의 행동을 마쳤습니다.',
primaryLabel: '턴 종료',
secondaryLabel: '계속 지휘',
primaryMeaning: 'end-turn',
@@ -22126,7 +22268,7 @@ export class BattleScene extends Phaser.Scene {
const unactedNames = `${visibleNames.join(' · ')}${omittedNames > 0 ? `${omittedNames}` : ''}`;
this.showTurnEndPrompt(undefined, {
title: `미행동 ${unactedAllies.length}명 · 턴 종료 확인`,
body: `미행동 장수 · ${unactedNames}\n${this.enemyIntentTurnEndSummary()}\n행동을 포기하고 턴을 종료할까요?`,
riskContext: `미행동 장수 · ${unactedNames}`,
primaryLabel: '계속 지휘',
secondaryLabel: '미행동 포기 후 종료',
primaryAction: () => this.hideTurnEndPrompt(),
@@ -29370,7 +29512,15 @@ export class BattleScene extends Phaser.Scene {
this.hideTurnEndPrompt();
this.turnPromptMode = options.mode ?? 'turn-end';
const promptTitle = options.title ?? '모든 아군의 행동이 종료되었습니다.';
const promptBody = options.body ?? this.enemyIntentTurnEndBody();
const riskForecast = this.turnPromptMode === 'turn-end'
? this.enemyIntentTurnEndRiskForecast()
: undefined;
const riskContext = options.riskContext ?? '모든 장수의 행동을 마쳤습니다.';
const promptBody = options.body ?? (
this.turnPromptMode === 'turn-end'
? this.enemyIntentTurnEndBody(riskForecast, riskContext)
: '계속 진행하시겠습니까?'
);
const primaryLabel = options.primaryLabel ?? '턴 종료';
const secondaryLabel = options.secondaryLabel ?? '전장 확인';
const primaryAction = options.primaryAction ?? (() => this.endAllyTurn());
@@ -29391,8 +29541,11 @@ export class BattleScene extends Phaser.Scene {
this.turnPromptSecondaryAction = secondaryAction;
this.turnPromptFocusedAction = options.initialFocus ?? 'primary';
this.turnPromptCancelActionId = options.cancelActionId ?? 'secondary';
this.turnPromptRiskForecast = riskForecast;
const riskLayout = Boolean(riskForecast && riskForecast.targetCount > 0);
const expandedPrompt = promptBody.split('\n').length >= 3;
const promptHeight = expandedPrompt ? 202 : 170;
const promptWidth = riskLayout ? 560 : 388;
const promptHeight = riskLayout ? 326 : expandedPrompt ? 202 : 170;
const buttonY = promptHeight - 46;
const hintY = promptHeight - 19;
@@ -29411,7 +29564,7 @@ export class BattleScene extends Phaser.Scene {
);
this.turnPromptObjects.push(shade);
const width = this.battleUiLength(388);
const width = this.battleUiLength(promptWidth);
const height = this.battleUiLength(promptHeight);
const left = this.layout.mapX + this.layout.mapWidth / 2 - width / 2;
const top = this.layout.mapY + this.layout.mapHeight / 2 - height / 2;
@@ -29420,6 +29573,7 @@ export class BattleScene extends Phaser.Scene {
panel.setDepth(40);
panel.setStrokeStyle(this.battleUiLength(2), palette.gold, 0.9);
this.turnPromptObjects.push(panel);
this.turnPromptPanel = panel;
const title = this.add.text(left + width / 2, top + this.battleUiLength(26), promptTitle, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
@@ -29431,32 +29585,39 @@ export class BattleScene extends Phaser.Scene {
title.setDepth(41);
this.turnPromptObjects.push(title);
const body = this.add.text(left + width / 2, top + this.battleUiLength(52), promptBody, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: this.battleUiFontSize(17),
color: '#d4dce6',
align: 'center',
wordWrap: { width: width - this.battleUiLength(44), useAdvancedWrap: true }
});
body.setOrigin(0.5, 0);
body.setDepth(41);
this.turnPromptObjects.push(body);
if (riskForecast && riskLayout) {
this.renderTurnEndRiskPromptContents(left, top, width, riskContext, riskForecast);
} else {
const body = this.add.text(left + width / 2, top + this.battleUiLength(52), promptBody, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: this.battleUiFontSize(17),
color: '#d4dce6',
align: 'center',
wordWrap: { width: width - this.battleUiLength(44), useAdvancedWrap: true }
});
body.setOrigin(0.5, 0);
body.setDepth(41);
this.turnPromptObjects.push(body);
}
const buttonWidth = this.battleUiLength(136);
const buttonWidth = this.battleUiLength(riskLayout ? 160 : 136);
const buttonGap = this.battleUiLength(riskLayout ? 16 : 14);
const buttonOffset = (buttonWidth + buttonGap) / 2;
const buttonCenter = left + width / 2;
const buttons = [
{
id: 'primary' as const,
label: primaryLabel,
meaning: primaryMeaning,
dangerous: options.primaryDangerous ?? false,
x: left + this.battleUiLength(119)
x: buttonCenter - buttonOffset
},
{
id: 'secondary' as const,
label: secondaryLabel,
meaning: secondaryMeaning,
dangerous: options.secondaryDangerous ?? false,
x: left + this.battleUiLength(269)
x: buttonCenter + buttonOffset
}
];
@@ -29497,6 +29658,198 @@ export class BattleScene extends Phaser.Scene {
void message;
}
private renderTurnEndRiskPromptContents(
left: number,
top: number,
width: number,
context: string,
forecast: TurnEndRiskForecast
) {
const depth = 41;
const contextText = this.add.text(
left + width / 2,
top + this.battleUiLength(50),
context,
{
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: this.battleUiFontSize(13),
color: '#d4dce6',
fontStyle: '700',
align: 'center'
}
);
contextText.setOrigin(0.5, 0);
contextText.setDepth(depth);
this.fitTextToWidth(contextText, context, width - this.battleUiLength(56));
this.turnPromptObjects.push(contextText);
const summaryTop = top + this.battleUiLength(76);
const summaryHeight = this.battleUiLength(32);
const summaryWidth = width - this.battleUiLength(56);
const summaryBackground = this.add.rectangle(
left + this.battleUiLength(28),
summaryTop,
summaryWidth,
summaryHeight,
0x182431,
0.98
);
summaryBackground.setOrigin(0);
summaryBackground.setDepth(depth);
summaryBackground.setStrokeStyle(this.battleUiLength(1), palette.gold, 0.72);
this.turnPromptObjects.push(summaryBackground);
const summaryLabel = [
`즉시 공격 ${forecast.immediateAttackCount}`,
`위험 대상 ${forecast.targetCount}`,
`기대 피해 ${forecast.expectedDamage}`,
forecast.lethalTargetCount > 0 ? `격파 가능 ${forecast.lethalTargetCount}` : ''
].filter(Boolean).join(' · ');
const summaryText = this.add.text(
left + width / 2,
summaryTop + summaryHeight / 2,
summaryLabel,
{
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: this.battleUiFontSize(12),
color: '#fff2c4',
fontStyle: '700'
}
);
summaryText.setOrigin(0.5);
summaryText.setDepth(depth + 1);
this.fitTextToWidth(summaryText, summaryLabel, summaryWidth - this.battleUiLength(20));
this.turnPromptObjects.push(summaryText);
this.turnPromptRiskSummaryText = summaryText;
const rowLeft = left + this.battleUiLength(28);
const rowWidth = width - this.battleUiLength(56);
const rowHeight = this.battleUiLength(52);
forecast.groups.slice(0, 2).forEach((group, index) => {
const rowTop = top + this.battleUiLength(116 + index * 60);
const tone = group.lethal
? 0xd46a54
: group.concentrated
? 0xd8732c
: group.immediateCount > 0
? palette.gold
: palette.blue;
const rowBackground = this.add.rectangle(
rowLeft,
rowTop,
rowWidth,
rowHeight,
group.lethal ? 0x281819 : 0x121d27,
0.98
);
rowBackground.setOrigin(0);
rowBackground.setDepth(depth);
rowBackground.setStrokeStyle(this.battleUiLength(group.lethal ? 2 : 1), tone, 0.9);
this.turnPromptObjects.push(rowBackground);
const targetTitle = `${group.targetName} · 현재 HP ${group.hp}`;
const simultaneousWarnings = [
group.concentrated ? '집중 공격' : '',
group.lethal ? '격파 가능' : ''
].filter(Boolean);
const badgeLabel = simultaneousWarnings.length > 0
? simultaneousWarnings.join(' · ')
: group.immediateCount > 0
? '피격 예상'
: '접근 예정';
const badgeWidth = this.battleUiLength(Math.max(68, badgeLabel.length * 12 + 20));
const badge = this.add.rectangle(
rowLeft + rowWidth - this.battleUiLength(10) - badgeWidth / 2,
rowTop + this.battleUiLength(14),
badgeWidth,
this.battleUiLength(22),
group.lethal ? 0x6a2923 : 0x1b2a36,
0.98
);
badge.setDepth(depth + 1);
badge.setStrokeStyle(this.battleUiLength(1), tone, 0.94);
this.turnPromptObjects.push(badge);
const badgeText = this.add.text(badge.x, badge.y, badgeLabel, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: this.battleUiFontSize(10),
color: group.lethal ? '#ffd4c9' : '#fff0c8',
fontStyle: '700'
});
badgeText.setOrigin(0.5);
badgeText.setDepth(depth + 2);
this.turnPromptObjects.push(badgeText);
const titleText = this.add.text(
rowLeft + this.battleUiLength(14),
rowTop + this.battleUiLength(6),
targetTitle,
{
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: this.battleUiFontSize(13),
color: '#f2e3bf',
fontStyle: '700'
}
);
titleText.setDepth(depth + 1);
this.fitTextToWidth(
titleText,
targetTitle,
rowWidth - badgeWidth - this.battleUiLength(46)
);
this.turnPromptObjects.push(titleText);
const hitRateLabel = group.hitRateMin === group.hitRateMax
? `${group.hitRateMax}%`
: `${group.hitRateMin}~${group.hitRateMax}%`;
const metricLabel = group.immediateCount > 0
? `공격 ${group.immediateCount}회 · 기대 피해 ${group.expectedDamage} · 최대 피해 ${group.maximumDamage} · 명중 ${hitRateLabel}${group.approachCount > 0 ? ` · 접근 ${group.approachCount}` : ''}`
: `즉시 공격 없음 · 접근 ${group.approachCount}명 · 다음 적 행동에서 거리 단축`;
const metricText = this.add.text(
rowLeft + this.battleUiLength(14),
rowTop + this.battleUiLength(30),
metricLabel,
{
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: this.battleUiFontSize(11),
color: group.lethal ? '#ffc3b4' : '#b9c9d8',
fontStyle: '700'
}
);
metricText.setDepth(depth + 1);
this.fitTextToWidth(metricText, metricLabel, rowWidth - this.battleUiLength(28));
this.turnPromptObjects.push(metricText);
this.turnPromptRiskRows.push({
targetId: group.targetId,
background: rowBackground,
badgeText,
titleText,
metricText
});
});
const footnoteLabel = [
'현재 위치와 공개된 적 의도를 기준으로 한 예상입니다.',
forecast.omittedTargetCount > 0 ? `${forecast.omittedTargetCount}명 위험` : ''
].filter(Boolean).join(' · ');
const footnoteText = this.add.text(
left + width / 2,
top + this.battleUiLength(240),
footnoteLabel,
{
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: this.battleUiFontSize(11),
color: '#91a7bd',
fontStyle: '700'
}
);
footnoteText.setOrigin(0.5, 0);
footnoteText.setDepth(depth);
this.fitTextToWidth(footnoteText, footnoteLabel, width - this.battleUiLength(56));
this.turnPromptObjects.push(footnoteText);
this.turnPromptRiskFootnoteText = footnoteText;
}
private handleTurnPromptNavigationKey(event: KeyboardEvent) {
if (this.turnPromptObjects.length === 0) {
return false;
@@ -29587,6 +29940,11 @@ export class BattleScene extends Phaser.Scene {
this.turnPromptFocusedAction = undefined;
this.turnPromptCancelActionId = undefined;
this.turnPromptButtonViews = [];
this.turnPromptPanel = undefined;
this.turnPromptRiskForecast = undefined;
this.turnPromptRiskSummaryText = undefined;
this.turnPromptRiskFootnoteText = undefined;
this.turnPromptRiskRows = [];
}
private pushBattleLog(message: string) {
@@ -33722,13 +34080,42 @@ export class BattleScene extends Phaser.Scene {
body: this.turnPromptBody ?? '',
focusedAction: this.turnPromptFocusedAction ?? null,
cancelAction: this.turnPromptCancelActionId ?? null,
panelBounds: this.gameObjectBoundsDebug(this.turnPromptPanel),
summaryBounds: this.gameObjectBoundsDebug(this.turnPromptRiskSummaryText),
footnoteBounds: this.gameObjectBoundsDebug(this.turnPromptRiskFootnoteText),
riskForecast: this.turnPromptRiskForecast
? {
immediateAttackCount: this.turnPromptRiskForecast.immediateAttackCount,
approachCount: this.turnPromptRiskForecast.approachCount,
targetCount: this.turnPromptRiskForecast.targetCount,
expectedDamage: this.turnPromptRiskForecast.expectedDamage,
lethalTargetCount: this.turnPromptRiskForecast.lethalTargetCount,
omittedTargetCount: this.turnPromptRiskForecast.omittedTargetCount,
visibleGroupIds: [...this.turnPromptRiskForecast.visibleGroupIds],
groups: this.turnPromptRiskForecast.groups.map((group) => ({
...group,
attacks: group.attacks.map((attack) => ({ ...attack }))
}))
}
: null,
riskRows: this.turnPromptRiskRows.map((row) => ({
targetId: row.targetId,
bounds: this.gameObjectBoundsDebug(row.background),
badgeText: row.badgeText.text,
badgeBounds: this.gameObjectBoundsDebug(row.badgeText),
titleText: row.titleText.text,
titleBounds: this.gameObjectBoundsDebug(row.titleText),
metricText: row.metricText.text,
metricBounds: this.gameObjectBoundsDebug(row.metricText)
})),
buttons: this.turnPromptButtonViews.map((view) => ({
id: view.id,
label: view.label,
meaning: view.meaning,
dangerous: view.dangerous,
focused: this.turnPromptFocusedAction === view.id,
escapeCancels: this.turnPromptCancelActionId === view.id
escapeCancels: this.turnPromptCancelActionId === view.id,
bounds: this.gameObjectBoundsDebug(view.background)
}))
}
: null,