feat: preview enemy battle intents
This commit is contained in:
@@ -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'
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user