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