feat: preview enemy battle intents

This commit is contained in:
2026-07-10 07:18:09 +09:00
parent dfc27bf053
commit 8214240540

View File

@@ -1487,6 +1487,27 @@ type EnemyUsablePlan = {
preview: CombatPreview;
};
type EnemyActionPlanKind = 'attack' | 'usable' | 'approach' | 'wait';
type EnemyActionPlan = {
enemy: UnitData;
behavior: EnemyAiBehavior;
kind: EnemyActionPlanKind;
destination?: { x: number; y: number };
approachTarget?: UnitData;
target?: UnitData;
usable?: BattleUsable;
preview?: CombatPreview;
sortiePreventedDamage?: number;
};
type EnemyIntentVisual = {
unitId: string;
container: Phaser.GameObjects.Container;
offsetX: number;
offsetY: number;
};
type BattleStatusKind = 'burn' | 'confusion';
type BattleStatusState = {
@@ -3116,6 +3137,8 @@ export class BattleScene extends Phaser.Scene {
private battleBuffs = new Map<string, BattleBuffState>();
private battleStatuses = new Map<string, BattleStatusState[]>();
private attackIntents: AttackIntent[] = [];
private enemyIntentForecasts = new Map<string, EnemyActionPlan>();
private enemyIntentVisuals: EnemyIntentVisual[] = [];
private battleLog: string[] = [];
private enemyHomeTiles = new Map<string, { x: number; y: number }>();
private battleStats = new Map<string, UnitBattleStats>();
@@ -3184,6 +3207,7 @@ export class BattleScene extends Phaser.Scene {
this.battleLog = [];
this.actedUnitIds.clear();
this.attackIntents = [];
this.clearEnemyIntentForecast();
this.approachPathCostCache.clear();
this.selectedUnit = undefined;
this.pendingMove = undefined;
@@ -3304,6 +3328,7 @@ export class BattleScene extends Phaser.Scene {
} else {
this.time.delayedCall(180, () => this.showOpeningBattleEvent());
this.renderRosterPanel('ally', '행동할 장수를 선택하세요.');
this.refreshEnemyIntentForecast();
}
this.scheduleDebugForcedBattleOutcome();
}
@@ -4071,6 +4096,7 @@ export class BattleScene extends Phaser.Scene {
update(_time: number, delta: number) {
this.updateEdgeScroll(delta);
this.positionEnemyIntentVisuals();
}
private drawMiniMap() {
@@ -4868,6 +4894,7 @@ export class BattleScene extends Phaser.Scene {
this.phase = 'command';
this.clearMarkers();
this.updateMiniMap();
this.refreshEnemyIntentForecast();
soundDirector.playSelect();
const targetX = this.tileCenterX(x);
@@ -4881,13 +4908,13 @@ export class BattleScene extends Phaser.Scene {
if (isFinalAlly) {
this.hideCommandMenu();
this.renderUnitDetail(unit, '마지막 미행동 장수입니다.\n바로 턴을 종료하거나 명령을 선택할 수 있습니다.');
this.renderUnitDetail(unit, '마지막 장수 · 명령 또는 턴 종료');
this.showPostMoveTurnEndPrompt(unit, commandX, commandY);
return;
}
this.showCommandMenu(unit, commandX, commandY);
this.renderUnitDetail(unit, '공격, 책략, 도구, 대기 중 하나를 선택하세요.\n우클릭하면 이동 취소합니다.');
this.renderUnitDetail(unit, '명령 선택 · 우클릭 이동 취소');
this.hideTurnEndPrompt();
}
@@ -4896,7 +4923,7 @@ export class BattleScene extends Phaser.Scene {
this.showTurnEndPrompt(undefined, {
mode: 'post-move',
title: '마지막 장수 이동 완료',
body: '대기로 턴을 끝낼까요?',
body: this.enemyIntentTurnEndBody(),
primaryLabel: '대기 후 턴 종료',
secondaryLabel: '명령 선택',
primaryAction: () => {
@@ -5846,6 +5873,7 @@ export class BattleScene extends Phaser.Scene {
this.phase = 'idle';
this.refreshUnitLegibilityStyles();
this.renderRosterPanel('ally', '출전 위치 확정. 행동할 장수를 선택하세요.');
this.refreshEnemyIntentForecast();
this.showOpeningBattleEvent();
soundDirector.playSelect();
}
@@ -5902,18 +5930,11 @@ export class BattleScene extends Phaser.Scene {
private showEnemyUnitThreatRange(enemy: UnitData) {
this.clearMarkers();
const behavior = this.enemyBehavior(enemy);
const moveTiles = this.reachableTiles(enemy);
const moveTiles = behavior === 'hold' ? [] : this.reachableTiles(enemy);
const threatTiles = this.enemyThreatTilesFor(enemy, moveTiles);
moveTiles.forEach((tile) => this.renderEnemyMoveMarker(enemy, tile.x, tile.y));
threatTiles.forEach((tile) => this.renderEnemyUnitThreatMarker(enemy, tile.x, tile.y));
this.renderSituationPanel(
[
`${enemy.name} 대응 범위`,
`이동 ${moveTiles.length}칸 · 공격 ${threatTiles.length}`,
`${this.enemyBehaviorLabel(behavior)} 태세 · 이동 ${enemy.move} · 사거리 ${this.attackRange(enemy)}`
].join('\n')
);
}
private enemyThreatTilesFor(enemy: UnitData, moveTiles = this.reachableTiles(enemy)) {
@@ -6085,6 +6106,233 @@ export class BattleScene extends Phaser.Scene {
return 0x9e3e58;
}
private enemyIntentForecastEnabled() {
return (
this.isFirstPursuitRoleEffectBattle() &&
this.activeFaction === 'ally' &&
this.phase !== 'deployment' &&
this.phase !== 'resolved' &&
!this.battleOutcome
);
}
private currentEnemyActionPlans() {
return battleUnits
.filter((unit) => unit.faction === 'enemy' && unit.hp > 0)
.map((enemy) => this.buildEnemyActionPlan(enemy));
}
private enemyActionPlanIcon(plan: EnemyActionPlan): BattleUiIconKey {
if (plan.usable?.id === 'fireTactic') {
return 'fire';
}
if (plan.usable?.id === 'roar') {
return 'shout';
}
if (plan.kind === 'usable') {
return 'strategy';
}
if (plan.kind === 'attack') {
return 'sword';
}
if (plan.kind === 'approach') {
return 'move';
}
return 'focus';
}
private enemyActionPlanTone(plan: EnemyActionPlan) {
if (plan.kind === 'usable') {
return plan.usable?.id === 'fireTactic' ? 0xd8732c : 0x9e3e58;
}
if (plan.kind === 'attack') {
return 0xc93b30;
}
if (plan.kind === 'approach') {
return 0xd8732c;
}
return 0x647485;
}
private enemyActionPlanCompactValue(plan: EnemyActionPlan) {
const defenseOrHit = (plan.sortiePreventedDamage ?? 0) > 0
? `방-${plan.sortiePreventedDamage}`
: `${plan.preview?.hitRate ?? 0}`;
if (plan.kind === 'usable' && plan.usable) {
return `${plan.usable.name} ${plan.preview?.damage ?? 0}/${defenseOrHit}`;
}
if (plan.kind === 'attack') {
return `${plan.preview?.damage ?? 0}·${defenseOrHit}`;
}
if (plan.kind === 'approach') {
return '접근 예정';
}
return '현재 위치 대기';
}
private enemyActionPlanCardLabel(plan: EnemyActionPlan) {
if (!plan.target) {
return '예상 행동';
}
const role = this.firstPursuitRoleForUnit(plan.target);
return role ? `${plan.target.name} · ${this.deploymentRoleLabel(role)}` : `예상 · ${plan.target.name}`;
}
private refreshEnemyIntentForecast() {
this.clearEnemyIntentForecast();
if (!this.enemyIntentForecastEnabled()) {
return;
}
const plans = this.currentEnemyActionPlans();
plans.forEach((plan) => this.enemyIntentForecasts.set(plan.enemy.id, plan));
plans
.filter((plan) => plan.kind !== 'wait')
.sort((left, right) => {
const rank = (plan: EnemyActionPlan) => plan.kind === 'usable' ? 3 : plan.kind === 'attack' ? 2 : 1;
return rank(right) - rank(left) || (right.preview?.damage ?? 0) - (left.preview?.damage ?? 0);
})
.slice(0, 6)
.forEach((plan) => this.createEnemyIntentPlanVisual(plan));
const targetGroups = new Map<string, EnemyActionPlan[]>();
plans.forEach((plan) => {
if (!plan.target || plan.kind === 'wait') {
return;
}
const group = targetGroups.get(plan.target.id) ?? [];
group.push(plan);
targetGroups.set(plan.target.id, group);
});
targetGroups.forEach((targetPlans, targetId) => this.createEnemyIntentTargetVisual(targetId, targetPlans));
this.positionEnemyIntentVisuals();
}
private clearEnemyIntentForecast() {
this.enemyIntentVisuals.forEach(({ container }) => container.destroy());
this.enemyIntentVisuals = [];
this.enemyIntentForecasts.clear();
}
private createEnemyIntentPlanVisual(plan: EnemyActionPlan) {
const tone = this.enemyActionPlanTone(plan);
const container = this.add.container(0, 0);
const bg = this.add.circle(0, 0, 13, 0x0a1014, 0.94);
bg.setStrokeStyle(2, tone, 0.94);
const icon = this.createBattleUiIcon(0, 0, this.enemyActionPlanIcon(plan), 18);
container.add([bg, icon]);
container.setDepth(11.4);
if (this.mapMask) {
container.setMask(this.mapMask);
}
this.enemyIntentVisuals.push({
unitId: plan.enemy.id,
container,
offsetX: this.layout.tileSize * 0.32,
offsetY: -this.layout.tileSize * 0.42
});
}
private createEnemyIntentTargetVisual(targetId: string, plans: EnemyActionPlan[]) {
const immediateCount = plans.filter((plan) => plan.kind === 'attack' || plan.kind === 'usable').length;
const approachCount = plans.length - immediateCount;
const tone = immediateCount > 0 ? 0xc93b30 : 0xd8732c;
const label = `${immediateCount}·추${approachCount}`;
const badgeWidth = 54;
const container = this.add.container(0, 0);
const ring = this.add.circle(0, 0, this.layout.tileSize * 0.43, 0x000000, 0);
ring.setStrokeStyle(immediateCount > 1 ? 3 : 2, tone, immediateCount > 0 ? 0.86 : 0.64);
const badge = this.add.rectangle(0, -this.layout.tileSize * 0.66, badgeWidth, 20, 0x0a1014, 0.94);
badge.setStrokeStyle(1, tone, 0.9);
const text = this.add.text(0, -this.layout.tileSize * 0.66, label, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '11px',
color: '#fff0dd',
fontStyle: '700',
stroke: '#071018',
strokeThickness: 2
});
text.setOrigin(0.5);
container.add([ring, badge, text]);
container.setDepth(10.4);
if (this.mapMask) {
container.setMask(this.mapMask);
}
this.enemyIntentVisuals.push({ unitId: targetId, container, offsetX: 0, offsetY: 0 });
}
private positionEnemyIntentVisuals() {
const visible = this.enemyIntentForecastEnabled() && this.phase !== 'targeting' && this.phase !== 'animating';
this.enemyIntentVisuals.forEach((entry) => {
const unit = battleUnits.find((candidate) => candidate.id === entry.unitId && candidate.hp > 0);
const view = unit ? this.unitViews.get(unit.id) : undefined;
if (!unit || !view || !visible || !this.isTileVisible(unit.x, unit.y)) {
entry.container.setVisible(false);
return;
}
entry.container.setVisible(true);
entry.container.setPosition(view.sprite.x + entry.offsetX, view.sprite.y + entry.offsetY);
});
}
private enemyIntentTurnEndBody() {
if (!this.enemyIntentForecastEnabled()) {
return '아군 턴을 종료하시겠습니까?';
}
const groups = new Map<string, { target: UnitData; immediate: number; approach: number }>();
this.currentEnemyActionPlans().forEach((plan) => {
if (!plan.target || 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 summaries = Array.from(groups.values())
.sort((left, right) => right.immediate - left.immediate || right.approach - left.approach)
.slice(0, 2)
.map((group) => `${group.target.name}${group.immediate}·추${group.approach}`);
return summaries.length > 0
? `적 예상 · ${summaries.join(' · ')}\n이대로 턴을 종료할까요?`
: '적 예상 · 즉시 공격 없음\n이대로 턴을 종료할까요?';
}
private enemyIntentTargetCounts(unitId: string) {
if (!this.enemyIntentForecastEnabled()) {
return { immediate: 0, approach: 0 };
}
const plans = Array.from(this.enemyIntentForecasts.values()).filter((plan) => plan.target?.id === unitId);
const immediate = plans.filter((plan) => plan.kind === 'attack' || plan.kind === 'usable').length;
const approach = plans.filter((plan) => plan.kind === 'approach').length;
return { immediate, approach };
}
private enemyIntentTargetCompactValue(unitId: string) {
const counts = this.enemyIntentTargetCounts(unitId);
return counts.immediate > 0 || counts.approach > 0
? `${counts.immediate}·추${counts.approach}`
: '안전';
}
private enemyActionPlanDebugValue(plan: EnemyActionPlan) {
return {
enemyId: plan.enemy.id,
behavior: plan.behavior,
kind: plan.kind,
destination: plan.destination ? { ...plan.destination } : null,
targetId: plan.target?.id ?? null,
usableId: plan.usable?.id ?? null,
damage: plan.preview?.damage ?? 0,
hitRate: plan.preview?.hitRate ?? 0,
sortiePreventedDamage: plan.sortiePreventedDamage ?? 0
};
}
private commandAccentColor(command: BattleCommand) {
if (command === 'attack') {
return 0xd8b15f;
@@ -8801,6 +9049,8 @@ export class BattleScene extends Phaser.Scene {
return;
}
this.refreshEnemyIntentForecast();
const remaining = this.remainingAllyCount();
const turnHint =
remaining > 0
@@ -8909,6 +9159,7 @@ export class BattleScene extends Phaser.Scene {
this.targetingAction = undefined;
this.selectedUsable = undefined;
this.clearMarkers();
this.clearEnemyIntentForecast();
this.hideCommandMenu();
this.hideMapMenu();
this.hideTurnEndPrompt();
@@ -10932,7 +11183,7 @@ export class BattleScene extends Phaser.Scene {
const sortieItemAssignments = this.effectiveSortieItemAssignments(campaign);
const usesSortieAssignments = Object.keys(sortieItemAssignments).length > 0;
return new Map(
battleUnits.map((unit) => {
battleUnits.filter((unit) => unit.faction === 'ally').map((unit) => {
const stocks = unit.faction === 'ally' && usesSortieAssignments
? sortieItemAssignments[unit.id] ?? {}
: initialItemStocks[unit.id] ?? {};
@@ -10976,7 +11227,11 @@ export class BattleScene extends Phaser.Scene {
if (this.isFirstPursuitRoleEffectBattle()) {
const roleLines = this.firstPursuitOpeningLines();
const title = this.firstPursuitTrinityConfigured() ? '도원 삼재진 발동' : '편성 전술 발동';
this.triggerBattleEvent('opening', title, [...roleLines, `작전 목표 · ${objectiveLines[0]}`]);
this.triggerBattleEvent('opening', title, [
...roleLines,
'적 의도(예상) · 검=공격 · 발걸음=추격 · 공/추=표적 수',
`작전 목표 · ${objectiveLines[0]}`
]);
return;
}
this.triggerBattleEvent('opening', '작전 목표', objectiveLines);
@@ -11003,10 +11258,10 @@ export class BattleScene extends Phaser.Scene {
this.hideBattleEventBanner();
const showExpandedSortieBanner = this.isFirstPursuitRoleEffectBattle() && (title.includes('편성') || title.includes('삼재진'));
const maxBodyLines = showExpandedSortieBanner ? 4 : 2;
const maxBodyLines = showExpandedSortieBanner ? 5 : 2;
const bodyLines = lines.slice(0, maxBodyLines).map((line) => this.truncateBattleLogText(line, 42));
const width = 540;
const height = Phaser.Math.Clamp(80 + bodyLines.length * 22, 108, maxBodyLines > 2 ? 170 : 126);
const height = Phaser.Math.Clamp(80 + bodyLines.length * 22, 108, maxBodyLines > 2 ? 192 : 126);
const left = this.layout.gridX + Math.max(16, (this.layout.gridWidth - width) / 2);
const top = this.layout.gridY + 18;
const depth = 45;
@@ -11501,7 +11756,7 @@ export class BattleScene extends Phaser.Scene {
if (action === 'endTurn') {
this.showTurnEndPrompt(undefined, {
title: '턴 종료 확인',
body: '아군 턴을 종료하시겠습니까?',
body: this.enemyIntentTurnEndBody(),
primaryLabel: '턴 종료',
secondaryLabel: '계속 지휘'
});
@@ -11985,6 +12240,7 @@ export class BattleScene extends Phaser.Scene {
this.updateTurnText();
this.updateObjectiveTracker();
this.renderRosterPanel(this.rosterTab, '저장된 전투 상태입니다.');
this.refreshEnemyIntentForecast();
}
private restoreUnitView(unit: UnitData, direction: UnitDirection = 'south') {
@@ -12085,6 +12341,7 @@ export class BattleScene extends Phaser.Scene {
this.pendingMove = undefined;
this.phase = 'idle';
this.clearMarkers();
this.clearEnemyIntentForecast();
this.hideCommandMenu();
this.hideTurnEndPrompt();
this.refreshUnitLegibilityStyles();
@@ -12138,45 +12395,138 @@ export class BattleScene extends Phaser.Scene {
this.time.delayedCall(this.scaledBattleDuration(520, 120), () => this.beginNextAllyTurn());
}
private enemyPlanSortiePreventedDamage(
attacker: UnitData,
target: UnitData,
action: DamageCommand,
usable: BattleUsable | undefined,
preview: CombatPreview
) {
if (preview.sortieDamageReduction <= 0) {
return 0;
}
const baseline = this.combatPreview(attacker, target, action, usable, false, true);
return Math.max(0, baseline.damage - preview.damage);
}
private buildEnemyActionPlan(enemy: UnitData): EnemyActionPlan {
const projectedEnemy = { ...enemy };
const behavior = this.enemyBehavior(enemy);
let target = this.chooseEnemyTarget(projectedEnemy, behavior);
if (!target) {
const usablePlan = this.chooseEnemyUsablePlan(projectedEnemy, behavior);
if (usablePlan) {
return {
enemy,
behavior,
kind: 'usable',
target: usablePlan.target,
usable: usablePlan.usable,
preview: usablePlan.preview,
sortiePreventedDamage: this.enemyPlanSortiePreventedDamage(
projectedEnemy,
usablePlan.target,
usablePlan.usable.command,
usablePlan.usable,
usablePlan.preview
)
};
}
return { enemy, behavior, kind: 'wait' };
}
const approachTarget = target;
let destination: EnemyActionPlan['destination'];
if (!this.canAttack(projectedEnemy, target) && behavior !== 'hold') {
const nextTile = this.chooseApproachTile(projectedEnemy, target);
if (nextTile && (nextTile.x !== projectedEnemy.x || nextTile.y !== projectedEnemy.y)) {
destination = { x: nextTile.x, y: nextTile.y };
projectedEnemy.x = nextTile.x;
projectedEnemy.y = nextTile.y;
}
}
const usablePlan = this.chooseEnemyUsablePlan(projectedEnemy, behavior);
if (usablePlan) {
return {
enemy,
behavior,
kind: 'usable',
destination,
approachTarget,
target: usablePlan.target,
usable: usablePlan.usable,
preview: usablePlan.preview,
sortiePreventedDamage: this.enemyPlanSortiePreventedDamage(
projectedEnemy,
usablePlan.target,
usablePlan.usable.command,
usablePlan.usable,
usablePlan.preview
)
};
}
target = this.chooseTargetInRange(projectedEnemy) ?? target;
if (this.canAttack(projectedEnemy, target)) {
const preview = this.combatPreview(projectedEnemy, target, 'attack');
return {
enemy,
behavior,
kind: 'attack',
destination,
approachTarget,
target,
preview,
sortiePreventedDamage: this.enemyPlanSortiePreventedDamage(projectedEnemy, target, 'attack', undefined, preview)
};
}
return {
enemy,
behavior,
kind: 'approach',
destination,
approachTarget,
target
};
}
private async executeEnemyAction(enemy: UnitData) {
this.centerCameraOnTile(enemy.x, enemy.y);
await this.delay(120);
const behavior = this.enemyBehavior(enemy);
let target = this.chooseEnemyTarget(enemy, behavior);
if (!target) {
const usablePlan = this.chooseEnemyUsablePlan(enemy, behavior);
if (usablePlan) {
return this.executeEnemyUsablePlan(enemy, usablePlan, behavior);
}
this.renderSituationPanel(`적 행동: ${enemy.name}\n대기`);
return `${enemy.name}: 대기`;
const plan = this.buildEnemyActionPlan(enemy);
if (plan.destination) {
const fromX = enemy.x;
const fromY = enemy.y;
enemy.x = plan.destination.x;
enemy.y = plan.destination.y;
const movementTarget = plan.approachTarget ?? plan.target;
this.renderSituationPanel(
`적 행동: ${enemy.name}\n${movementTarget ? `${movementTarget.name} 쪽으로 이동` : '전진'}${this.enemyMoveTerrainNote(enemy, plan.destination)}`
);
this.centerCameraOnTile(plan.destination.x, plan.destination.y);
await this.moveUnitViewAsync(enemy, plan.destination.x, plan.destination.y, this.unitViews.get(enemy.id), 260, fromX, fromY);
}
if (!this.canAttack(enemy, target) && behavior !== 'hold') {
const destination = this.chooseApproachTile(enemy, target);
if (destination && (destination.x !== enemy.x || destination.y !== enemy.y)) {
const fromX = enemy.x;
const fromY = enemy.y;
enemy.x = destination.x;
enemy.y = destination.y;
this.renderSituationPanel(`적 행동: ${enemy.name}\n${target.name} 쪽으로 이동${this.enemyMoveTerrainNote(enemy, destination)}`);
this.centerCameraOnTile(destination.x, destination.y);
await this.moveUnitViewAsync(enemy, destination.x, destination.y, this.unitViews.get(enemy.id), 260, fromX, fromY);
}
if (plan.kind === 'usable' && plan.target && plan.usable) {
return this.executeEnemyUsablePlan(
enemy,
{
target: plan.target,
usable: plan.usable,
preview: plan.preview ?? this.combatPreview(enemy, plan.target, plan.usable.command, plan.usable)
},
plan.behavior
);
}
const usablePlan = this.chooseEnemyUsablePlan(enemy, behavior);
if (usablePlan) {
return this.executeEnemyUsablePlan(enemy, usablePlan, behavior);
}
target = this.chooseTargetInRange(enemy) ?? target;
if (this.canAttack(enemy, target)) {
this.renderSituationPanel(`적 행동: ${enemy.name}\n${target.name} 공격`);
this.centerCameraOnTile(Math.floor((enemy.x + target.x) / 2), Math.floor((enemy.y + target.y) / 2));
const result = this.resolveAttack(enemy, target);
if (plan.kind === 'attack' && plan.target) {
this.renderSituationPanel(`적 행동: ${enemy.name}\n${plan.target.name} 공격`);
this.centerCameraOnTile(Math.floor((enemy.x + plan.target.x) / 2), Math.floor((enemy.y + plan.target.y) / 2));
const result = this.resolveAttack(enemy, plan.target);
await this.playCombatCutIn(result);
result.counter = this.resolveCounterAttack(result);
if (result.counter) {
@@ -12188,10 +12538,15 @@ export class BattleScene extends Phaser.Scene {
this.showCombatMapResult(result.counter);
}
await this.delay(result.counter ? 220 : 160);
return this.formatEnemyCombatResult(result, behavior);
return this.formatEnemyCombatResult(result, plan.behavior);
}
return `${enemy.name}: ${this.enemyBehaviorLabel(behavior)} 태세로 접근`;
if (plan.kind === 'wait') {
this.renderSituationPanel(`적 행동: ${enemy.name}\n대기`);
return `${enemy.name}: 대기`;
}
return `${enemy.name}: ${this.enemyBehaviorLabel(plan.behavior)} 태세로 접근`;
}
private async executeEnemyUsablePlan(enemy: UnitData, plan: EnemyUsablePlan, behavior: EnemyAiBehavior) {
@@ -15079,6 +15434,7 @@ export class BattleScene extends Phaser.Scene {
.filter(Boolean)
.join('\n');
this.renderRosterPanel('ally', turnMessage);
this.refreshEnemyIntentForecast();
const firstUnit = this.firstActionableAlly();
if (firstUnit) {
@@ -15771,6 +16127,7 @@ export class BattleScene extends Phaser.Scene {
this.hideTurnEndPrompt();
this.updateMiniMap();
this.refreshUnitLegibilityStyles();
this.refreshEnemyIntentForecast();
soundDirector.playSelect();
this.moveUnitView(unit, fromX, fromY, view, 180, toX, toY, false);
@@ -16641,7 +16998,7 @@ export class BattleScene extends Phaser.Scene {
title.setDepth(41);
this.turnPromptObjects.push(title);
const body = this.add.text(left + width / 2, top + 58, options.body ?? '턴을 종료하시겠습니까?', {
const body = this.add.text(left + width / 2, top + 58, options.body ?? this.enemyIntentTurnEndBody(), {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '17px',
color: '#d4dce6',
@@ -17577,13 +17934,15 @@ export class BattleScene extends Phaser.Scene {
const summaryBoxWidth = (width - summaryGap * 2) / 3;
this.renderCompactValueBox(left, top + 114, summaryBoxWidth, 'attack', '공격', `${unit.attack + attackBonus}`);
this.renderCompactValueBox(left + summaryBoxWidth + summaryGap, top + 114, summaryBoxWidth, 'defense', '방어+', `${this.equipmentDefenseBonus(unit)}`);
const intentCounts = this.enemyIntentTargetCounts(unit.id);
const showIntentSummary = unit.faction === 'ally' && this.enemyIntentForecastEnabled();
this.renderCompactValueBox(
left + (summaryBoxWidth + summaryGap) * 2,
top + 114,
summaryBoxWidth,
this.unitMoveIcon(unit),
'방향',
`${this.directionSymbol(direction)} ${this.unitDirectionLabel(direction)}`
showIntentSummary ? (intentCounts.immediate > 0 ? 'counter' : intentCounts.approach > 0 ? 'move' : 'success') : this.unitMoveIcon(unit),
showIntentSummary ? '적 예상' : '방향',
showIntentSummary ? this.enemyIntentTargetCompactValue(unit.id) : `${this.directionSymbol(direction)} ${this.unitDirectionLabel(direction)}`
);
const effects = this.unitEffectSummaries(unit);
@@ -17634,6 +17993,17 @@ export class BattleScene extends Phaser.Scene {
const terrainRule = getTerrainRule(terrain);
const terrainValue = this.terrainEffectText(terrain, unit, 'card');
if (unit.faction === 'enemy' && this.enemyIntentForecastEnabled()) {
const plan = this.enemyIntentForecasts.get(unit.id) ?? this.buildEnemyActionPlan(unit);
effects.push({
icon: this.enemyActionPlanIcon(plan),
label: this.enemyActionPlanCardLabel(plan),
value: this.enemyActionPlanCompactValue(plan),
tone: this.enemyActionPlanTone(plan),
polarity: plan.kind === 'attack' || plan.kind === 'usable' ? 'debuff' : 'buff'
});
}
const sortieRole = this.firstPursuitRoleForUnit(unit);
const sortieEffect = sortieRole ? firstPursuitRoleEffects[sortieRole] : undefined;
if (sortieRole && sortieEffect) {
@@ -18112,6 +18482,8 @@ export class BattleScene extends Phaser.Scene {
markerCount: this.markers.length,
threatMarkerCount: this.markers.filter((marker) => marker.getData('markerType') === 'threat').length,
enemyMoveMarkerCount: this.markers.filter((marker) => marker.getData('markerType') === 'enemy-move').length,
enemyIntentPreviews: Array.from(this.enemyIntentForecasts.values()).map((plan) => this.enemyActionPlanDebugValue(plan)),
enemyIntentVisualCount: this.enemyIntentVisuals.length,
camera: {
x: this.cameraTileX,
y: this.cameraTileY,
@@ -18470,6 +18842,13 @@ export class BattleScene extends Phaser.Scene {
: 'none';
const sortieProbe = this.debugFirstPursuitRoleMechanicsProbe();
const sortieTotals = this.isFirstPursuitRoleEffectBattle() ? this.firstPursuitContributionTotals() : undefined;
const intentCounts = Array.from(this.enemyIntentForecasts.values()).reduce(
(counts, plan) => {
counts[plan.kind] += 1;
return counts;
},
{ attack: 0, usable: 0, approach: 0, wait: 0 }
);
const sortieProbeLines = sortieProbe
? [
`role front in=${sortieProbe.front.incomingDamage}/${sortieProbe.front.incomingBaseline} counter=${sortieProbe.front.counterDamage}/${sortieProbe.front.counterBaseline}`,
@@ -18488,6 +18867,7 @@ export class BattleScene extends Phaser.Scene {
`pendingMove=${pending}`,
`acted=${Array.from(this.actedUnitIds).join(',') || 'none'}`,
`pointerTile=${pointerTile ? `${pointerTile.x},${pointerTile.y}` : 'outside'}`,
`enemy intent a=${intentCounts.attack} u=${intentCounts.usable} move=${intentCounts.approach} wait=${intentCounts.wait} v=${this.enemyIntentVisuals.length}`,
...sortieProbeLines,
'F8 force victory / F9 overlay / F10 log state'
]);