From 8214240540fb29e9926695b1c6aa322ce022ad08 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Fri, 10 Jul 2026 07:18:09 +0900 Subject: [PATCH] feat: preview enemy battle intents --- src/game/scenes/BattleScene.ts | 486 +++++++++++++++++++++++++++++---- 1 file changed, 433 insertions(+), 53 deletions(-) diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 60b2b84..1378947 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -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(); private battleStatuses = new Map(); private attackIntents: AttackIntent[] = []; + private enemyIntentForecasts = new Map(); + private enemyIntentVisuals: EnemyIntentVisual[] = []; private battleLog: string[] = []; private enemyHomeTiles = new Map(); private battleStats = new Map(); @@ -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(); + 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(); + 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' ]);