import Phaser from 'phaser';
import { soundDirector } from '../audio/SoundDirector';
import { type BattleBond, type UnitData, type UnitStats } from '../data/scenario';
import { defaultBattleScenario, getBattleScenario, type BattleObjectiveDefinition, type BattleScenarioDefinition } from '../data/battles';
import { getTerrainRule, getUnitClass, type TerrainType, type UnitClassKey } from '../data/battleRules';
import {
  equipmentExpToNext,
  equipmentSlotLabels,
  equipmentSlots,
  getItem,
  type EquipmentSlot
} from '../data/battleItems';
import {
  campaignSaveSlotCount,
  getCampaignState,
  listCampaignSaveSlots,
  loadCampaignState,
  markCampaignStep,
  saveCampaignState,
  setFirstBattleReport,
  type CampaignState,
  type CampaignStep
} from '../state/campaignState';
import { palette } from '../ui/palette';

const unitTexture: Record<string, string> = {
  'liu-bei': 'unit-liu-bei',
  'guan-yu': 'unit-guan-yu',
  'zhang-fei': 'unit-zhang-fei',
  'rebel-a': 'unit-rebel',
  'rebel-b': 'unit-rebel',
  'rebel-c': 'unit-rebel-archer',
  'rebel-d': 'unit-rebel',
  'rebel-e': 'unit-rebel',
  'rebel-f': 'unit-rebel',
  'rebel-g': 'unit-rebel',
  'rebel-cavalry-a': 'unit-rebel-cavalry',
  'rebel-cavalry-b': 'unit-rebel-cavalry',
  'rebel-archer-b': 'unit-rebel-archer',
  'rebel-leader': 'unit-rebel-leader',
  'pursuit-raider-a': 'unit-rebel',
  'pursuit-raider-b': 'unit-rebel',
  'pursuit-bandit-a': 'unit-rebel',
  'pursuit-archer-a': 'unit-rebel-archer',
  'pursuit-cavalry-a': 'unit-rebel-cavalry',
  'pursuit-raider-c': 'unit-rebel',
  'pursuit-archer-b': 'unit-rebel-archer',
  'pursuit-cavalry-b': 'unit-rebel-cavalry',
  'pursuit-guard-a': 'unit-rebel',
  'pursuit-guard-b': 'unit-rebel',
  'pursuit-leader-han-seok': 'unit-rebel-leader',
  'xuzhou-raider-a': 'unit-rebel',
  'xuzhou-infantry-a': 'unit-rebel',
  'xuzhou-archer-a': 'unit-rebel-archer',
  'xuzhou-cavalry-a': 'unit-rebel-cavalry',
  'xuzhou-raider-b': 'unit-rebel',
  'xuzhou-archer-b': 'unit-rebel-archer',
  'xuzhou-cavalry-b': 'unit-rebel-cavalry',
  'xuzhou-guard-a': 'unit-rebel',
  'xuzhou-cavalry-c': 'unit-rebel-cavalry',
  'xuzhou-guard-b': 'unit-rebel',
  'xuzhou-archer-c': 'unit-rebel-archer',
  'xuzhou-leader-xiahou-dun': 'unit-rebel-leader'
};

const unitTextureByClass: Partial<Record<UnitClassKey, string>> = {
  lord: 'unit-liu-bei',
  infantry: 'unit-guan-yu',
  spearman: 'unit-zhang-fei',
  archer: 'unit-rebel-archer',
  strategist: 'unit-liu-bei',
  quartermaster: 'unit-liu-bei',
  cavalry: 'unit-rebel-cavalry',
  bandit: 'unit-rebel',
  yellowTurban: 'unit-rebel',
  rebelLeader: 'unit-rebel-leader'
};

const unitFrameRows: Record<UnitDirection, number> = {
  south: 0,
  east: 1,
  north: 2,
  west: 3
};

const unitActionColumns: Record<UnitActionPose, number> = {
  attack: 0,
  strategy: 1,
  item: 2,
  hurt: 3
};

type BattleLayout = {
  mapX: number;
  mapY: number;
  mapWidth: number;
  mapHeight: number;
  gridX: number;
  gridY: number;
  gridSize: number;
  gridWidth: number;
  gridHeight: number;
  visibleColumns: number;
  visibleRows: number;
  tileSize: number;
  panelX: number;
  panelY: number;
  panelWidth: number;
  panelHeight: number;
};

type TerrainTileView = {
  x: number;
  y: number;
  tile: Phaser.GameObjects.Rectangle;
};

type MiniMapLayout = {
  x: number;
  y: number;
  width: number;
  height: number;
  cellSize: number;
};

type UnitView = {
  sprite: Phaser.GameObjects.Sprite;
  label: Phaser.GameObjects.Text;
  textureBase: string;
  direction: UnitDirection;
};

type UnitDirection = 'south' | 'east' | 'north' | 'west';
type UnitActionPose = 'attack' | 'strategy' | 'item' | 'hurt';

type BattlePhase = 'idle' | 'moving' | 'command' | 'targeting' | 'animating' | 'resolved';
type BattleCommand = 'attack' | 'strategy' | 'item' | 'wait';
type DamageCommand = Exclude<BattleCommand, 'wait'>;
type UsableCommand = Extract<BattleCommand, 'strategy' | 'item'>;
type UsableEffect = 'damage' | 'heal' | 'focus';
type UsableTarget = 'enemy' | 'ally' | 'self';
type RosterTab = 'ally' | 'enemy';
type ActiveFaction = 'ally' | 'enemy';
type MapMenuAction = 'endTurn' | 'threat' | 'save' | 'load' | 'roster' | 'bond' | 'situation' | 'bgm' | 'close';
type EnemyAiBehavior = 'aggressive' | 'guard' | 'hold';
type BattleOutcome = 'victory' | 'defeat';
type SaveSlotMode = 'save' | 'load';

type BattleSceneData = {
  battleId?: string;
};

type CommandButton = {
  command: BattleCommand;
  background: Phaser.GameObjects.Rectangle;
  text: Phaser.GameObjects.Text;
};

type PendingMove = {
  unit: UnitData;
  fromX: number;
  fromY: number;
  toX: number;
  toY: number;
};

type AttackIntent = {
  attackerId: string;
  targetId: string;
};

type BondState = BattleBond & {
  battleExp: number;
};

type UnitBattleStats = {
  damageDealt: number;
  damageTaken: number;
  defeats: number;
  actions: number;
  support: number;
};

type ThreatTile = {
  x: number;
  y: number;
  enemies: UnitData[];
  strongestBehavior: EnemyAiBehavior;
};

type MapMenuLayout = {
  left: number;
  top: number;
  width: number;
  buttonHeight: number;
  padding: number;
  actions: MapMenuAction[];
};

type BattleObjectiveResult = {
  id: string;
  label: string;
  achieved: boolean;
  detail: string;
  rewardGold: number;
};

type BattleObjectiveState = BattleObjectiveResult & {
  status: 'active' | 'done' | 'failed';
};

type EquipmentGrowthResult = {
  slot: EquipmentSlot;
  itemName: string;
  amount: number;
  previousLevel: number;
  previousExp: number;
  level: number;
  exp: number;
  next: number;
  leveled: boolean;
};

type CharacterGrowthResult = {
  amount: number;
  previousLevel: number;
  previousExp: number;
  level: number;
  exp: number;
  next: number;
  leveled: boolean;
};

type BondGrowthResult = {
  label: string;
  amount: number;
  previousLevel: number;
  previousExp: number;
  level: number;
  exp: number;
  next: number;
  leveled: boolean;
};

type GrowthGaugeEntry = {
  label: string;
  owner: string;
  amountLabel: string;
  previousLevel: number;
  previousExp: number;
  previousNext: number;
  level: number;
  exp: number;
  next: number;
  leveled: boolean;
  color: number;
};

type GrowthGaugeView = {
  fill: Phaser.GameObjects.Rectangle;
  valueText: Phaser.GameObjects.Text;
  levelText: Phaser.GameObjects.Text;
  eventText: Phaser.GameObjects.Text;
};

type ResultGaugeAnimation = {
  fill: Phaser.GameObjects.Rectangle;
  valueText: Phaser.GameObjects.Text;
  levelText: Phaser.GameObjects.Text;
  eventText: Phaser.GameObjects.Text;
  levelPrefix: string;
  previousLevel: number;
  previousExp: number;
  previousNext: number;
  level: number;
  exp: number;
  next: number;
  leveled: boolean;
  flashArea: {
    x: number;
    y: number;
    width: number;
    height: number;
    depth: number;
  };
};

type CombatPreview = {
  action: DamageCommand;
  usable?: BattleUsable;
  attacker: UnitData;
  defender: UnitData;
  damage: number;
  bondDamageBonus: number;
  bondLabel?: string;
  hitRate: number;
  criticalRate: number;
  counterAvailable: boolean;
  terrainLabel: string;
};

type CombatResult = CombatPreview & {
  previousDefenderHp: number;
  hit: boolean;
  critical: boolean;
  isCounter: boolean;
  defeated: boolean;
  characterGrowth: CharacterGrowthResult;
  attackerGrowth: EquipmentGrowthResult;
  defenderGrowth: EquipmentGrowthResult;
  bondExp: number;
  bondGrowth: BondGrowthResult[];
  counter?: CombatResult;
};

type BattleUsable = {
  id: string;
  command: UsableCommand;
  name: string;
  target: UsableTarget;
  effect: UsableEffect;
  range: number;
  power: number;
  accuracyBonus?: number;
  criticalBonus?: number;
  attackBonus?: number;
  hitBonus?: number;
  duration?: number;
  description: string;
};

type SupportResult = {
  usable: BattleUsable;
  user: UnitData;
  target: UnitData;
  previousTargetHp: number;
  healAmount: number;
  buff?: BattleBuffState;
  characterGrowth: CharacterGrowthResult;
  equipmentGrowth: EquipmentGrowthResult;
};

type BattleBuffState = {
  unitId: string;
  label: string;
  turns: number;
  attackBonus: number;
  hitBonus: number;
  criticalBonus: number;
};

type SavedUnitState = Pick<UnitData, 'id' | 'level' | 'exp' | 'hp' | 'maxHp' | 'attack' | 'move' | 'x' | 'y'> & {
  equipment: UnitData['equipment'];
  direction?: UnitDirection;
};

type BattleSaveState = {
  version: 1;
  battleId: string;
  campaignStep?: CampaignStep;
  savedAt: string;
  turnNumber: number;
  activeFaction: ActiveFaction;
  rosterTab: RosterTab;
  actedUnitIds: string[];
  attackIntents: AttackIntent[];
  battleLog: string[];
  units: SavedUnitState[];
  bonds: BondState[];
  itemStocks?: Record<string, Record<string, number>>;
  battleBuffs?: BattleBuffState[];
  battleStats?: Record<string, UnitBattleStats>;
  triggeredBattleEvents?: string[];
};

const maxEquipmentLevel = 9;
const maxCharacterLevel = 99;
let battleScenario = defaultBattleScenario;
let battleMap = battleScenario.map;
let battleUnits = battleScenario.units;
let battleBonds = battleScenario.bonds;
const legacyBattleSaveStorageKey = 'heros-web:first-battle-state';
let battleSaveStorageKey = `heros-web:battle:${battleScenario.id}`;
let initialBattleUnits = battleUnits.map(cloneUnitData);
let initialBattleBonds = battleBonds.map(cloneBattleBond);

const usableCatalog: Record<string, BattleUsable> = {
  aid: {
    id: 'aid',
    command: 'strategy',
    name: '응급',
    target: 'ally',
    effect: 'heal',
    range: 2,
    power: 12,
    description: '가까운 아군의 병력을 조금 회복한다.'
  },
  encourage: {
    id: 'encourage',
    command: 'strategy',
    name: '격려',
    target: 'ally',
    effect: 'focus',
    range: 2,
    power: 0,
    attackBonus: 2,
    hitBonus: 8,
    criticalBonus: 6,
    duration: 2,
    description: '아군의 공격, 명중, 치명 보정을 잠시 올린다.'
  },
  fireTactic: {
    id: 'fireTactic',
    command: 'strategy',
    name: '소화',
    target: 'enemy',
    effect: 'damage',
    range: 2,
    power: 12,
    accuracyBonus: 6,
    criticalBonus: 0,
    description: '적 한 부대에 책략 피해를 준다.'
  },
  roar: {
    id: 'roar',
    command: 'strategy',
    name: '고함',
    target: 'enemy',
    effect: 'damage',
    range: 1,
    power: 9,
    accuracyBonus: 8,
    criticalBonus: 4,
    description: '가까운 적을 위압해 피해를 준다.'
  },
  bean: {
    id: 'bean',
    command: 'item',
    name: '콩',
    target: 'ally',
    effect: 'heal',
    range: 1,
    power: 10,
    description: '아군 한 부대의 병력을 소량 회복한다.'
  },
  salve: {
    id: 'salve',
    command: 'item',
    name: '상처약',
    target: 'ally',
    effect: 'heal',
    range: 1,
    power: 18,
    description: '아군 한 부대의 병력을 크게 회복한다.'
  },
  wine: {
    id: 'wine',
    command: 'item',
    name: '술',
    target: 'self',
    effect: 'focus',
    range: 0,
    power: 0,
    attackBonus: 3,
    hitBonus: 6,
    criticalBonus: 10,
    duration: 2,
    description: '사용자의 공격과 치명 보정을 잠시 올린다.'
  }
};

const unitStrategyIds: Record<string, string[]> = {
  'liu-bei': ['aid', 'encourage'],
  'guan-yu': ['fireTactic'],
  'zhang-fei': ['roar'],
  'jian-yong': ['aid', 'encourage', 'fireTactic'],
  'mi-zhu': ['aid', 'encourage']
};

const initialItemStocks: Record<string, Record<string, number>> = {
  'liu-bei': { bean: 2, salve: 1 },
  'guan-yu': { bean: 1 },
  'zhang-fei': { bean: 1, wine: 1 },
  'jian-yong': { bean: 1 },
  'mi-zhu': { bean: 2, salve: 1 }
};

const attackRangeByClass: Partial<Record<UnitClassKey, number>> = {
  archer: 2,
  strategist: 2
};

const enemyAiByUnitId: Record<string, EnemyAiBehavior> = {
  'rebel-a': 'aggressive',
  'rebel-b': 'guard',
  'rebel-c': 'hold',
  'rebel-d': 'guard',
  'rebel-e': 'aggressive',
  'rebel-f': 'guard',
  'rebel-g': 'guard',
  'rebel-cavalry-a': 'aggressive',
  'rebel-cavalry-b': 'aggressive',
  'rebel-archer-b': 'hold',
  'rebel-leader': 'guard',
  'pursuit-raider-a': 'aggressive',
  'pursuit-raider-b': 'guard',
  'pursuit-bandit-a': 'aggressive',
  'pursuit-archer-a': 'hold',
  'pursuit-cavalry-a': 'aggressive',
  'pursuit-raider-c': 'aggressive',
  'pursuit-archer-b': 'hold',
  'pursuit-cavalry-b': 'aggressive',
  'pursuit-guard-a': 'guard',
  'pursuit-guard-b': 'guard',
  'pursuit-leader-han-seok': 'guard',
  'xuzhou-raider-a': 'aggressive',
  'xuzhou-infantry-a': 'guard',
  'xuzhou-archer-a': 'hold',
  'xuzhou-cavalry-a': 'aggressive',
  'xuzhou-raider-b': 'aggressive',
  'xuzhou-archer-b': 'hold',
  'xuzhou-cavalry-b': 'aggressive',
  'xuzhou-guard-a': 'guard',
  'xuzhou-cavalry-c': 'aggressive',
  'xuzhou-guard-b': 'guard',
  'xuzhou-archer-c': 'hold',
  'xuzhou-leader-xiahou-dun': 'guard',
  'xiaopei-raider-a': 'aggressive',
  'xiaopei-raider-b': 'aggressive',
  'xiaopei-infantry-a': 'guard',
  'xiaopei-infantry-b': 'guard',
  'xiaopei-archer-a': 'hold',
  'xiaopei-archer-b': 'hold',
  'xiaopei-guard-a': 'aggressive',
  'xiaopei-guard-b': 'aggressive',
  'xiaopei-cavalry-a': 'aggressive',
  'xiaopei-cavalry-b': 'aggressive',
  'xiaopei-leader-gao-shun': 'guard',
  'xuzhou-gate-traitor-a': 'aggressive',
  'xuzhou-gate-traitor-b': 'aggressive',
  'xuzhou-gate-infantry-a': 'guard',
  'xuzhou-gate-infantry-b': 'guard',
  'xuzhou-gate-archer-a': 'hold',
  'xuzhou-gate-archer-b': 'hold',
  'lubu-cavalry-a': 'aggressive',
  'lubu-cavalry-b': 'aggressive',
  'lubu-cavalry-c': 'aggressive',
  'xuzhou-leader-cao-bao': 'guard',
  'xuzhou-escape-cavalry-a': 'aggressive',
  'xuzhou-escape-cavalry-b': 'aggressive',
  'xuzhou-escape-cavalry-c': 'aggressive',
  'xuzhou-escape-guard-a': 'guard',
  'xuzhou-escape-guard-b': 'guard',
  'xuzhou-escape-guard-c': 'guard',
  'xuzhou-escape-archer-a': 'hold',
  'xuzhou-escape-archer-b': 'hold',
  'xuzhou-escape-raider-a': 'aggressive',
  'xuzhou-escape-raider-b': 'aggressive',
  'xuzhou-escape-leader-song-xian': 'guard'
};

const defaultEnemyAiByClass: Partial<Record<UnitClassKey, EnemyAiBehavior>> = {
  archer: 'hold',
  cavalry: 'aggressive',
  yellowTurban: 'guard',
  rebelLeader: 'guard'
};

const guardDetectionRange = 5;
let leaderUnitId = battleScenario.leaderUnitId;
let quickVictoryTurnLimit = battleScenario.quickVictoryTurnLimit;
let baseVictoryGold = battleScenario.baseVictoryGold;

const commandLabels: Record<BattleCommand, string> = {
  attack: '공격',
  strategy: '책략',
  item: '도구',
  wait: '대기'
};

const rosterLabels: Record<RosterTab, string> = {
  ally: '아군',
  enemy: '적군'
};

const factionLabels: Record<ActiveFaction, string> = {
  ally: '아군',
  enemy: '적군'
};

const terrainDisplayLabels: Record<TerrainType, string> = {
  plain: '평지',
  road: '길',
  forest: '숲',
  hill: '언덕',
  village: '마을',
  fort: '요새',
  camp: '진영',
  river: '강',
  cliff: '절벽'
};

const mapMenuLabels: Partial<Record<MapMenuAction, string>> = {
  endTurn: '턴 종료',
  threat: '위험 범위',
  roster: '부대 일람',
  bond: '공명',
  situation: '전황',
  bgm: 'BGM',
  close: '닫기'
};

const statLabels: Array<{ key: keyof UnitStats; label: string }> = [
  { key: 'might', label: '무력' },
  { key: 'intelligence', label: '지력' },
  { key: 'leadership', label: '통솔력' },
  { key: 'agility', label: '민첩성' },
  { key: 'luck', label: '운' }
];

function cloneUnitData(unit: UnitData): UnitData {
  return {
    ...unit,
    stats: { ...unit.stats },
    equipment: JSON.parse(JSON.stringify(unit.equipment)) as UnitData['equipment']
  };
}

function cloneBattleBond(bond: BattleBond): BattleBond {
  return {
    ...bond,
    unitIds: [...bond.unitIds] as [string, string]
  };
}

function configureBattleScenario(scenario: BattleScenarioDefinition) {
  battleScenario = scenario;
  battleMap = scenario.map;
  battleUnits = scenario.units;
  battleBonds = scenario.bonds;
  battleSaveStorageKey = `heros-web:battle:${scenario.id}`;
  initialBattleUnits = battleUnits.map(cloneUnitData);
  initialBattleBonds = battleBonds.map(cloneBattleBond);
  leaderUnitId = scenario.leaderUnitId;
  quickVictoryTurnLimit = scenario.quickVictoryTurnLimit;
  baseVictoryGold = scenario.baseVictoryGold;
}

export class BattleScene extends Phaser.Scene {
  private layout!: BattleLayout;
  private phase: BattlePhase = 'idle';
  private turnNumber = 1;
  private activeFaction: ActiveFaction = 'ally';
  private selectedUnit?: UnitData;
  private targetingAction?: DamageCommand;
  private selectedUsable?: BattleUsable;
  private turnText?: Phaser.GameObjects.Text;
  private objectiveTrackerText?: Phaser.GameObjects.Text;
  private objectiveTrackerSubText?: Phaser.GameObjects.Text;
  private markers: Phaser.GameObjects.Rectangle[] = [];
  private rosterTab: RosterTab = 'ally';
  private sidePanelObjects: Phaser.GameObjects.GameObject[] = [];
  private commandButtons: CommandButton[] = [];
  private commandMenuObjects: Array<Phaser.GameObjects.Rectangle | Phaser.GameObjects.Text> = [];
  private mapMenuObjects: Array<Phaser.GameObjects.Rectangle | Phaser.GameObjects.Text> = [];
  private mapMenuLayout?: MapMenuLayout;
  private alertObjects: Phaser.GameObjects.GameObject[] = [];
  private alertDismissEvent?: Phaser.Time.TimerEvent;
  private battleEventObjects: Phaser.GameObjects.GameObject[] = [];
  private turnPromptObjects: Phaser.GameObjects.GameObject[] = [];
  private combatCutInObjects: Phaser.GameObjects.GameObject[] = [];
  private resultObjects: Phaser.GameObjects.GameObject[] = [];
  private resultGaugeAnimations: ResultGaugeAnimation[] = [];
  private resultAnimationToken = 0;
  private resultSettlementText?: Phaser.GameObjects.Text;
  private saveSlotPanelObjects: Phaser.GameObjects.GameObject[] = [];
  private unitViews = new Map<string, UnitView>();
  private actedUnitIds = new Set<string>();
  private bondStates = new Map<string, BondState>();
  private itemStocks = new Map<string, Map<string, number>>();
  private battleBuffs = new Map<string, BattleBuffState>();
  private attackIntents: AttackIntent[] = [];
  private battleLog: string[] = [];
  private enemyHomeTiles = new Map<string, { x: number; y: number }>();
  private battleStats = new Map<string, UnitBattleStats>();
  private triggeredBattleEvents = new Set<string>();
  private pendingMove?: PendingMove;
  private battleOutcome?: BattleOutcome;
  private debugOverlay?: Phaser.GameObjects.Text;
  private mapBackground?: Phaser.GameObjects.Image;
  private mapMask?: Phaser.Display.Masks.GeometryMask;
  private terrainTileViews: TerrainTileView[] = [];
  private miniMapLayout?: MiniMapLayout;
  private miniMapObjects: Phaser.GameObjects.GameObject[] = [];
  private miniMapUnitDots = new Map<string, Phaser.GameObjects.Rectangle>();
  private miniMapViewport?: Phaser.GameObjects.Rectangle;
  private cameraTileX = 0;
  private cameraTileY = 0;
  private edgeScrollElapsed = 0;
  private suppressNextLeftClick = false;

  constructor() {
    super('BattleScene');
  }

  init(data?: BattleSceneData) {
    configureBattleScenario(getBattleScenario(data?.battleId));
  }

  create() {
    const campaign = getCampaignState();
    this.resetBattleData(campaign);
    if (campaign.step === 'new' || campaign.step === 'prologue' || campaign.step === 'first-battle') {
      markCampaignStep('first-battle');
    }
    const { width, height } = this.scale;
    this.layout = this.createLayout(width, height);
    const startUnit = battleUnits.find((unit) => unit.id === 'liu-bei') ?? battleUnits[0];
    this.setCameraTilePosition(
      (startUnit?.x ?? 0) - Math.floor(this.layout.visibleColumns / 2),
      (startUnit?.y ?? 0) - Math.floor(this.layout.visibleRows / 2),
      false
    );
    this.bondStates = this.createBondStates();
    this.itemStocks = this.createItemStocks();
    this.battleBuffs.clear();
    this.enemyHomeTiles = this.createEnemyHomeTiles();
    this.battleStats = this.createBattleStats();
    this.triggeredBattleEvents.clear();
    this.battleLog = [];
    this.actedUnitIds.clear();
    this.attackIntents = [];
    this.selectedUnit = undefined;
    this.pendingMove = undefined;
    this.targetingAction = undefined;
    this.selectedUsable = undefined;
    this.battleOutcome = undefined;
    this.phase = 'idle';
    soundDirector.playMusic('battle-prep');
    this.input.mouse?.disableContextMenu();
    this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
      if (pointer.rightButtonDown()) {
        this.handleRightClick(pointer);
        return;
      }

      if (pointer.leftButtonDown()) {
        this.handleLeftClick(pointer);
      }
    });
    this.installDebugHotkeys();

    this.add.rectangle(0, 0, width, height, 0x080b0d).setOrigin(0);
    this.drawMap();
    this.drawUnits();
    this.drawSidePanel();
    this.updateTurnText();
    this.time.delayedCall(180, () => this.showOpeningBattleEvent());
    this.renderRosterPanel('ally', '행동할 장수를 선택하세요.');
  }

  private resetBattleData(campaign?: CampaignState) {
    const selected = new Set(campaign?.selectedSortieUnitIds ?? []);
    const hasSelection = selected.size > 0;
    const nextUnits = initialBattleUnits
      .filter((unit) => unit.faction === 'enemy' || !hasSelection || selected.has(unit.id) || unit.id === 'liu-bei')
      .map((unit) => this.applyCampaignUnitProgress(unit, campaign));
    battleUnits.splice(0, battleUnits.length, ...nextUnits);
    battleBonds.splice(0, battleBonds.length, ...initialBattleBonds.map((bond) => this.applyCampaignBondProgress(bond, campaign)));
    initialBattleUnits = battleUnits.map(cloneUnitData);
    initialBattleBonds = battleBonds.map(cloneBattleBond);
  }

  private applyCampaignUnitProgress(unit: UnitData, campaign?: CampaignState) {
    const cloned = cloneUnitData(unit);
    const progress = campaign?.roster.find((candidate) => candidate.id === unit.id);
    if (!progress || unit.faction !== 'ally') {
      return cloned;
    }

    return {
      ...cloned,
      level: progress.level,
      exp: progress.exp,
      hp: Math.max(1, Math.min(progress.hp, progress.maxHp)),
      maxHp: progress.maxHp,
      equipment: JSON.parse(JSON.stringify(progress.equipment)) as UnitData['equipment']
    };
  }

  private applyCampaignBondProgress(bond: BattleBond, campaign?: CampaignState) {
    const cloned = cloneBattleBond(bond);
    const progress = campaign?.bonds.find((candidate) => candidate.id === bond.id);
    if (!progress) {
      return cloned;
    }

    return {
      ...cloned,
      level: progress.level,
      exp: progress.exp
    };
  }

  private createLayout(width: number, height: number): BattleLayout {
    const margin = 24;
    const gap = 20;
    const panelWidth = Phaser.Math.Clamp(Math.floor(width * 0.27), 320, 360);
    const mapWidth = width - panelWidth - gap - margin * 2;
    const mapHeight = height - margin * 2;
    const visibleRows = Math.min(12, battleMap.height);
    const rowBasedTileSize = Math.floor((mapHeight - 36) / visibleRows);
    const visibleColumns = Math.min(battleMap.width, Math.max(12, Math.floor((mapWidth - 16) / rowBasedTileSize)));
    const tileSize = Math.floor(Math.min((mapHeight - 36) / visibleRows, (mapWidth - 16) / visibleColumns));
    const gridWidth = tileSize * visibleColumns;
    const gridHeight = tileSize * visibleRows;
    const gridSize = Math.min(gridWidth, gridHeight);
    const mapX = margin;
    const mapY = margin;

    return {
      mapX,
      mapY,
      mapWidth,
      mapHeight,
      gridX: mapX + Math.floor((mapWidth - gridWidth) / 2),
      gridY: mapY + Math.floor((mapHeight - gridHeight) / 2),
      gridSize,
      gridWidth,
      gridHeight,
      visibleColumns,
      visibleRows,
      tileSize,
      panelX: mapX + mapWidth + gap,
      panelY: margin,
      panelWidth,
      panelHeight: mapHeight
    };
  }

  private drawMap() {
    const layout = this.layout;
    const maskShape = this.add.graphics();
    maskShape.fillStyle(0xffffff, 1);
    maskShape.fillRect(layout.mapX, layout.mapY, layout.mapWidth, layout.mapHeight);
    maskShape.setAlpha(0);
    this.mapMask = maskShape.createGeometryMask();
    const mapMask = this.mapMask;

    const background = this.add.image(0, 0, battleScenario.mapTextureKey);
    background.setOrigin(0);
    background.setDepth(0);
    background.setDisplaySize(battleMap.width * layout.tileSize, battleMap.height * layout.tileSize);
    background.setMask(mapMask);
    this.mapBackground = background;

    this.add.rectangle(layout.mapX, layout.mapY, layout.mapWidth, layout.mapHeight, 0x06090b, 0.16).setOrigin(0).setDepth(1);
    this.add
      .rectangle(layout.mapX, layout.mapY, layout.mapWidth, layout.mapHeight)
      .setOrigin(0)
      .setStrokeStyle(2, palette.gold, 0.62)
      .setDepth(8);

    this.terrainTileViews = [];
    battleMap.terrain.forEach((row, y) => {
      row.forEach((terrain, x) => {
        const terrainRule = getTerrainRule(terrain);
        const tile = this.add.rectangle(0, 0, layout.tileSize, layout.tileSize, terrainRule.color, terrainRule.alpha);
        tile.setOrigin(0);
        tile.setStrokeStyle(1, terrainRule.passable === false ? 0x111111 : 0x0a1014, terrainRule.passable === false ? 0.82 : 0.5);
        tile.setDepth(2);
        tile.setMask(mapMask);
        this.terrainTileViews.push({ x, y, tile });
      });
    });

    this.add
      .rectangle(layout.gridX, layout.gridY, layout.gridWidth, layout.gridHeight)
      .setOrigin(0)
      .setStrokeStyle(2, 0xf1d489, 0.48)
      .setDepth(3);
    this.updateCameraView();
  }

  private drawUnits() {
    battleUnits.forEach((unit) => {
      const sizeRatio = unit.faction === 'ally' ? 1.16 : 1.1;
      const textureBase = this.unitTextureKey(unit);
      const sprite = this.add.sprite(this.tileCenterX(unit.x), this.tileCenterY(unit.y), textureBase, this.unitFrameIndex('south'));
      sprite.setName(`unit-${unit.id}`);
      sprite.setDisplaySize(this.layout.tileSize * sizeRatio, this.layout.tileSize * sizeRatio);
      sprite.setDepth(6);
      if (this.mapMask) {
        sprite.setMask(this.mapMask);
      }
      sprite.setInteractive({ useHandCursor: unit.faction === 'ally' });
      sprite.on('pointerdown', () => this.selectUnit(unit));

      const label = this.add.text(sprite.x, sprite.y + this.layout.tileSize * 0.52, unit.name, {
        fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
        fontSize: '11px',
        color: unit.faction === 'ally' ? '#e7edf7' : '#ffebe7',
        stroke: '#05070a',
        strokeThickness: 3
      });
      label.setOrigin(0.5, 0);
      label.setDepth(7);
      if (this.mapMask) {
        label.setMask(this.mapMask);
      }
      const view = { sprite, label, textureBase, direction: 'south' as UnitDirection };
      this.unitViews.set(unit.id, view);
      this.syncUnitMotion(unit, view);
    });
  }

  private drawSidePanel() {
    const { panelX, panelY, panelWidth, panelHeight } = this.layout;
    this.add.rectangle(panelX, panelY, panelWidth, panelHeight, palette.panel, 0.96).setOrigin(0);
    this.add.rectangle(panelX, panelY, 3, panelHeight, palette.gold, 0.82).setOrigin(0);

    this.add.text(panelX + 24, panelY + 24, battleScenario.title, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '28px',
      color: '#e8dfca',
      fontStyle: '700',
      padding: { top: 4, bottom: 4 }
    });
    this.turnText = this.add.text(panelX + 24, panelY + 76, '1턴 / 아군', {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '22px',
      color: '#d8b15f'
    });
    this.objectiveTrackerText = this.add.text(panelX + 24, panelY + 108, '', {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '13px',
      color: '#d4dce6',
      wordWrap: { width: panelWidth - 48, useAdvancedWrap: true }
    });
    this.objectiveTrackerSubText = this.add.text(panelX + 24, panelY + 126, '', {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '12px',
      color: '#9aa3ad',
      wordWrap: { width: panelWidth - 48, useAdvancedWrap: true }
    });
    this.add.text(panelX + 24, panelY + panelHeight - 172, '가장자리 커서 / 미니맵 클릭으로 시야 이동', {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '14px',
      color: '#9aa3ad'
    });
    this.updateObjectiveTracker();
    this.drawMiniMap();
  }

  update(_time: number, delta: number) {
    this.updateEdgeScroll(delta);
  }

  private drawMiniMap() {
    const { panelX, panelY, panelWidth, panelHeight } = this.layout;
    const maxWidth = panelWidth - 48;
    const maxHeight = 132;
    const cellSize = Math.floor(Math.min(maxWidth / battleMap.width, maxHeight / battleMap.height));
    const width = battleMap.width * cellSize;
    const height = battleMap.height * cellSize;
    const x = panelX + Math.floor((panelWidth - width) / 2);
    const y = panelY + panelHeight - height - 24;
    this.miniMapLayout = { x, y, width, height, cellSize };

    const frame = this.add.rectangle(x - 5, y - 5, width + 10, height + 10, 0x0a1014, 0.92);
    frame.setOrigin(0);
    frame.setStrokeStyle(2, palette.gold, 0.72);
    frame.setDepth(24);
    this.miniMapObjects.push(frame);

    const hitArea = this.add.rectangle(x, y, width, height, 0x111820, 0.08);
    hitArea.setOrigin(0);
    hitArea.setInteractive({ useHandCursor: true });
    hitArea.setDepth(25);
    hitArea.on('pointerdown', (pointer: Phaser.Input.Pointer) => this.handleMiniMapPointer(pointer));
    this.miniMapObjects.push(hitArea);

    battleMap.terrain.forEach((row, tileY) => {
      row.forEach((terrain, tileX) => {
        const rule = getTerrainRule(terrain);
        const cell = this.add.rectangle(
          x + tileX * cellSize,
          y + tileY * cellSize,
          Math.max(1, cellSize - 1),
          Math.max(1, cellSize - 1),
          rule.color,
          rule.passable === false ? 0.92 : 0.72
        );
        cell.setOrigin(0);
        cell.setDepth(26);
        cell.setInteractive({ useHandCursor: true });
        cell.on('pointerdown', (pointer: Phaser.Input.Pointer) => this.handleMiniMapPointer(pointer));
        this.miniMapObjects.push(cell);
      });
    });

    battleUnits.forEach((unit) => {
      const dot = this.add.rectangle(0, 0, Math.max(3, cellSize), Math.max(3, cellSize), unit.faction === 'ally' ? 0x4aa9ff : 0xff715f, 0.96);
      dot.setOrigin(0.5);
      dot.setDepth(28);
      this.miniMapUnitDots.set(unit.id, dot);
      this.miniMapObjects.push(dot);
    });

    const viewport = this.add.rectangle(0, 0, this.layout.visibleColumns * cellSize, this.layout.visibleRows * cellSize);
    viewport.setOrigin(0);
    viewport.setStrokeStyle(2, 0xf7e6a1, 0.95);
    viewport.setDepth(29);
    this.miniMapViewport = viewport;
    this.miniMapObjects.push(viewport);
    this.updateMiniMap();
  }

  private handleMiniMapPointer(pointer: Phaser.Input.Pointer) {
    if (!this.miniMapLayout || this.battleOutcome || this.phase === 'animating') {
      return;
    }

    const tileX = Phaser.Math.Clamp(
      Math.floor((pointer.x - this.miniMapLayout.x) / this.miniMapLayout.cellSize),
      0,
      battleMap.width - 1
    );
    const tileY = Phaser.Math.Clamp(
      Math.floor((pointer.y - this.miniMapLayout.y) / this.miniMapLayout.cellSize),
      0,
      battleMap.height - 1
    );
    this.centerCameraOnTile(tileX, tileY);
    soundDirector.playSelect();
  }

  private updateEdgeScroll(delta: number) {
    if (
      this.battleOutcome ||
      this.phase === 'animating' ||
      this.phase === 'resolved' ||
      this.commandMenuObjects.length > 0 ||
      this.mapMenuObjects.length > 0 ||
      this.turnPromptObjects.length > 0 ||
      this.combatCutInObjects.length > 0 ||
      this.resultObjects.length > 0
    ) {
      this.edgeScrollElapsed = 0;
      return;
    }

    const pointer = this.input.activePointer;
    const { gridX, gridY, gridWidth, gridHeight } = this.layout;
    if (pointer.x < gridX || pointer.y < gridY || pointer.x > gridX + gridWidth || pointer.y > gridY + gridHeight) {
      this.edgeScrollElapsed = 0;
      return;
    }

    const edge = 34;
    const directionX = pointer.x < gridX + edge ? -1 : pointer.x > gridX + gridWidth - edge ? 1 : 0;
    const directionY = pointer.y < gridY + edge ? -1 : pointer.y > gridY + gridHeight - edge ? 1 : 0;
    if (directionX === 0 && directionY === 0) {
      this.edgeScrollElapsed = 0;
      return;
    }

    this.edgeScrollElapsed += delta;
    if (this.edgeScrollElapsed < 120) {
      return;
    }

    this.edgeScrollElapsed = 0;
    this.setCameraTilePosition(this.cameraTileX + directionX, this.cameraTileY + directionY);
  }

  private centerCameraOnTile(x: number, y: number) {
    this.setCameraTilePosition(x - Math.floor(this.layout.visibleColumns / 2), y - Math.floor(this.layout.visibleRows / 2));
  }

  private setCameraTilePosition(x: number, y: number, refresh = true) {
    const nextX = Phaser.Math.Clamp(Math.round(x), 0, this.maxCameraTileX());
    const nextY = Phaser.Math.Clamp(Math.round(y), 0, this.maxCameraTileY());
    const changed = nextX !== this.cameraTileX || nextY !== this.cameraTileY;
    this.cameraTileX = nextX;
    this.cameraTileY = nextY;

    if (refresh && changed) {
      this.updateCameraView();
    }
  }

  private maxCameraTileX() {
    return Math.max(0, battleMap.width - this.layout.visibleColumns);
  }

  private maxCameraTileY() {
    return Math.max(0, battleMap.height - this.layout.visibleRows);
  }

  private updateCameraView() {
    const { gridX, gridY, tileSize } = this.layout;
    const offsetX = this.cameraTileX * tileSize;
    const offsetY = this.cameraTileY * tileSize;
    this.mapBackground?.setPosition(gridX - offsetX, gridY - offsetY);

    this.terrainTileViews.forEach(({ x, y, tile }) => {
      this.positionTileObject(tile, x, y);
    });

    this.markers.forEach((marker) => {
      const x = marker.getData('tileX') as number | undefined;
      const y = marker.getData('tileY') as number | undefined;
      if (x !== undefined && y !== undefined) {
        this.positionTileObject(marker, x, y);
      }
    });

    battleUnits.forEach((unit) => this.positionUnitView(unit));
    this.updateMiniMap();
  }

  private updateMiniMap() {
    if (!this.miniMapLayout) {
      return;
    }

    const { x, y, cellSize } = this.miniMapLayout;
    this.miniMapUnitDots.forEach((dot, unitId) => {
      const unit = battleUnits.find((candidate) => candidate.id === unitId);
      if (!unit) {
        return;
      }
      dot.setPosition(x + unit.x * cellSize + cellSize / 2, y + unit.y * cellSize + cellSize / 2);
      dot.setVisible(unit.hp > 0);
      dot.setFillStyle(unit.faction === 'ally' ? 0x4aa9ff : 0xff715f, unit.hp > 0 ? 0.96 : 0.18);
    });

    this.miniMapViewport?.setPosition(x + this.cameraTileX * cellSize, y + this.cameraTileY * cellSize);
  }

  private positionTileObject(object: Phaser.GameObjects.Rectangle, x: number, y: number) {
    const screenX = this.layout.gridX + (x - this.cameraTileX) * this.layout.tileSize;
    const screenY = this.layout.gridY + (y - this.cameraTileY) * this.layout.tileSize;
    object.setPosition(screenX, screenY);
    object.setVisible(this.isTileVisible(x, y));
  }

  private positionUnitView(unit: UnitData) {
    const view = this.unitViews.get(unit.id);
    if (!view) {
      return;
    }

    view.sprite.setPosition(this.tileCenterX(unit.x), this.tileCenterY(unit.y));
    view.label.setPosition(view.sprite.x, view.sprite.y + this.layout.tileSize * 0.52);
    const visible = this.isTileVisible(unit.x, unit.y);
    view.sprite.setVisible(visible);
    view.label.setVisible(visible);
  }

  private isTileVisible(x: number, y: number) {
    return (
      x >= this.cameraTileX &&
      y >= this.cameraTileY &&
      x < this.cameraTileX + this.layout.visibleColumns &&
      y < this.cameraTileY + this.layout.visibleRows
    );
  }

  private selectUnit(unit: UnitData) {
    this.hideMapMenu();
    this.hideSaveSlotPanel();
    this.hideTurnEndPrompt();

    if (this.battleOutcome) {
      return;
    }

    if (this.phase === 'animating') {
      return;
    }

    if (!this.isTileVisible(unit.x, unit.y)) {
      this.centerCameraOnTile(unit.x, unit.y);
    }

    if (this.phase === 'moving' && this.selectedUnit?.id === unit.id) {
      this.moveSelectedUnit(unit.x, unit.y);
      return;
    }

    if (this.phase === 'targeting' && this.selectedUnit) {
      if (unit.hp > 0 && this.targetingAction) {
        if (this.selectedUsable && this.selectedUsable.effect !== 'damage') {
          void this.tryResolveSupportTarget(this.selectedUnit, unit, this.selectedUsable);
          return;
        }
        if (unit.faction !== this.selectedUnit.faction) {
          void this.tryResolveDamageTarget(this.selectedUnit, unit, this.targetingAction, this.selectedUsable);
          return;
        }
      }

      if (this.selectedUsable) {
        this.renderUnitDetail(this.selectedUnit, `${this.selectedUsable.name} 대상으로 삼을 부대를 선택하세요. 우클릭하면 명령 선택으로 돌아갑니다.`);
        return;
      }

      this.renderUnitDetail(this.selectedUnit, '대상으로 삼을 적 부대를 선택하세요. 우클릭하면 명령 선택으로 돌아갑니다.');
      return;
    }

    if (this.phase === 'command' || this.phase === 'targeting') {
      this.setInfo('먼저 현재 장수의 행동을 결정하세요.\n공격, 책략, 도구, 대기 중 하나를 선택해야 합니다.');
      return;
    }

    if (unit.hp <= 0) {
      this.renderRosterPanel(unit.faction, '이미 퇴각한 부대입니다.');
      return;
    }

    if (unit.faction !== 'ally') {
      this.selectedUnit = undefined;
      this.pendingMove = undefined;
      this.phase = 'idle';
      this.clearMarkers();
      this.hideCommandMenu();
      this.renderUnitDetail(unit, this.enemyDetailMessage(unit));
      return;
    }

    if (this.activeFaction !== 'ally') {
      this.renderUnitDetail(unit, '적군 행동 중에는 장수를 움직일 수 없습니다.');
      return;
    }

    if (this.actedUnitIds.has(unit.id)) {
      this.selectedUnit = undefined;
      this.clearMarkers();
      this.hideCommandMenu();
      this.phase = 'idle';
      this.renderUnitDetail(unit, '이미 행동을 마친 장수입니다.');
      return;
    }

    this.phase = 'moving';
    this.selectedUnit = unit;
    this.pendingMove = undefined;
    this.clearMarkers();
    this.hideCommandMenu();
    this.showMoveRange(unit);
    this.renderUnitDetail(unit, '이동할 파란 칸을 선택하세요.');
  }

  private showMoveRange(unit: UnitData) {
    this.reachableTiles(unit).forEach(({ x, y }) => {
      const marker = this.add.rectangle(
        this.tileTopLeftX(x),
        this.tileTopLeftY(y),
        this.layout.tileSize,
        this.layout.tileSize,
        palette.blue,
        0.24
      );
      marker.setData('tileX', x);
      marker.setData('tileY', y);
      marker.setOrigin(0);
      marker.setStrokeStyle(1, palette.blue, 0.68);
      marker.setDepth(4);
      if (this.mapMask) {
        marker.setMask(this.mapMask);
      }
      marker.setVisible(this.isTileVisible(x, y));
      marker.setInteractive({ useHandCursor: true });
      marker.on('pointerover', () => marker.setFillStyle(palette.blue, 0.36));
      marker.on('pointerout', () => marker.setFillStyle(palette.blue, 0.24));
      marker.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
        if (pointer.leftButtonDown()) {
          this.moveSelectedUnit(x, y, pointer);
        }
      });
      this.markers.push(marker);
    });
  }

  private moveSelectedUnit(x: number, y: number, pointer?: Phaser.Input.Pointer) {
    if (!this.selectedUnit || !this.canMoveTo(this.selectedUnit, x, y)) {
      return;
    }

    const unit = this.selectedUnit;
    const view = this.unitViews.get(unit.id);
    if (!view) {
      return;
    }

    const fromX = unit.x;
    const fromY = unit.y;
    const stayingInPlace = fromX === x && fromY === y;
    unit.x = x;
    unit.y = y;
    this.pendingMove = { unit, fromX, fromY, toX: x, toY: y };
    this.phase = 'command';
    this.clearMarkers();
    this.updateMiniMap();
    soundDirector.playSelect();

    const targetX = this.tileCenterX(x);
    const targetY = this.tileCenterY(y);
    if (!stayingInPlace) {
      this.moveUnitView(unit, x, y, view);
    }
    this.showCommandMenu(unit, pointer?.x ?? targetX, pointer?.y ?? targetY);
    this.renderUnitDetail(unit, '공격, 책략, 도구, 대기 중 하나를 선택하세요.\n우클릭하면 이동을 취소합니다.');
  }

  private canMoveTo(unit: UnitData, x: number, y: number) {
    return this.reachableTiles(unit).some((tile) => tile.x === x && tile.y === y);
  }

  private reachableTiles(unit: UnitData) {
    const directions = [
      { x: 1, y: 0 },
      { x: -1, y: 0 },
      { x: 0, y: 1 },
      { x: 0, y: -1 }
    ];
    const originKey = this.tileKey(unit.x, unit.y);
    const costs = new Map<string, number>([[originKey, 0]]);
    const queue = [{ x: unit.x, y: unit.y, cost: 0 }];
    const reachable = new Map<string, { x: number; y: number; cost: number }>([[originKey, { x: unit.x, y: unit.y, cost: 0 }]]);

    while (queue.length > 0) {
      queue.sort((a, b) => a.cost - b.cost);
      const current = queue.shift();
      if (!current) {
        break;
      }

      directions.forEach((direction) => {
        const nextX = current.x + direction.x;
        const nextY = current.y + direction.y;
        if (!this.isInBounds(nextX, nextY) || this.isMovementBlocked(unit, nextX, nextY)) {
          return;
        }

        const terrain = battleMap.terrain[nextY][nextX];
        const stepCost = this.movementCost(unit, terrain);
        if (!Number.isFinite(stepCost)) {
          return;
        }
        const nextCost = current.cost + stepCost;
        const key = this.tileKey(nextX, nextY);
        if (nextCost > unit.move || nextCost >= (costs.get(key) ?? Number.POSITIVE_INFINITY)) {
          return;
        }

        costs.set(key, nextCost);
        const tile = { x: nextX, y: nextY, cost: nextCost };
        if (!this.isOccupied(nextX, nextY, unit.id)) {
          reachable.set(key, tile);
        }
        queue.push(tile);
      });
    }

    return Array.from(reachable.values());
  }

  private movementCost(unit: UnitData, terrain: TerrainType) {
    const terrainRule = getTerrainRule(terrain);
    if (terrainRule.passable === false) {
      return Number.POSITIVE_INFINITY;
    }
    const unitClass = getUnitClass(unit.classKey);
    return unitClass.movementCosts?.[terrain] ?? terrainRule.moveCost;
  }

  private isInBounds(x: number, y: number) {
    return x >= 0 && y >= 0 && x < battleMap.width && y < battleMap.height;
  }

  private tileKey(x: number, y: number) {
    return `${x},${y}`;
  }

  private isOccupied(x: number, y: number, exceptUnitId?: string) {
    return battleUnits.some((unit) => unit.id !== exceptUnitId && unit.hp > 0 && unit.x === x && unit.y === y);
  }

  private isMovementBlocked(unit: UnitData, x: number, y: number) {
    return battleUnits.some((other) => {
      if (other.id === unit.id || other.hp <= 0 || other.x !== x || other.y !== y) {
        return false;
      }
      return other.faction !== unit.faction;
    });
  }

  private tileCenterX(x: number) {
    return this.tileTopLeftX(x) + this.layout.tileSize / 2;
  }

  private tileCenterY(y: number) {
    return this.tileTopLeftY(y) + this.layout.tileSize / 2;
  }

  private tileTopLeftX(x: number) {
    return this.layout.gridX + (x - this.cameraTileX) * this.layout.tileSize;
  }

  private tileTopLeftY(y: number) {
    return this.layout.gridY + (y - this.cameraTileY) * this.layout.tileSize;
  }

  private clearMarkers() {
    this.markers.forEach((marker) => marker.destroy());
    this.markers = [];
  }

  private showEnemyThreatRange() {
    this.clearMarkers();
    const threatTiles = this.enemyThreatTiles();
    threatTiles.forEach((tile) => this.renderThreatMarker(tile));
    const aggressiveCount = threatTiles.filter((tile) => tile.strongestBehavior === 'aggressive').length;
    const guardCount = threatTiles.filter((tile) => tile.strongestBehavior === 'guard').length;
    const holdCount = threatTiles.filter((tile) => tile.strongestBehavior === 'hold').length;
    this.renderSituationPanel(
      [
        `위험 범위 ${threatTiles.length}칸 표시`,
        aggressiveCount > 0 ? `추격 ${aggressiveCount}칸` : '',
        guardCount > 0 ? `경계 ${guardCount}칸` : '',
        holdCount > 0 ? `고수 ${holdCount}칸` : ''
      ]
        .filter(Boolean)
        .join('\n')
    );
  }

  private enemyThreatTiles() {
    const threats = new Map<string, ThreatTile>();
    battleUnits
      .filter((enemy) => enemy.faction === 'enemy' && enemy.hp > 0)
      .forEach((enemy) => {
        const behavior = this.enemyBehavior(enemy);
        const origins =
          behavior === 'hold'
            ? [{ x: enemy.x, y: enemy.y, cost: 0 }]
            : [{ x: enemy.x, y: enemy.y, cost: 0 }, ...this.reachableTiles(enemy)];
        const range = this.attackRange(enemy);

        origins.forEach((origin) => {
          this.tilesWithinDistance(origin.x, origin.y, range).forEach(({ x, y }) => {
            if (!this.isThreatTargetTile(x, y, enemy.id)) {
              return;
            }

            const key = this.tileKey(x, y);
            const existing = threats.get(key);
            if (!existing) {
              threats.set(key, {
                x,
                y,
                enemies: [enemy],
                strongestBehavior: behavior
              });
              return;
            }

            if (!existing.enemies.some((candidate) => candidate.id === enemy.id)) {
              existing.enemies.push(enemy);
            }
            if (this.threatBehaviorRank(behavior) > this.threatBehaviorRank(existing.strongestBehavior)) {
              existing.strongestBehavior = behavior;
            }
          });
        });
      });

    return Array.from(threats.values()).sort((a, b) => a.y - b.y || a.x - b.x);
  }

  private renderThreatMarker(tile: ThreatTile) {
    const color = this.threatColor(tile.strongestBehavior);
    const alpha = Phaser.Math.Clamp(0.18 + tile.enemies.length * 0.07, 0.2, 0.48);
    const marker = this.add.rectangle(this.tileTopLeftX(tile.x), this.tileTopLeftY(tile.y), this.layout.tileSize, this.layout.tileSize, color, alpha);
    marker.setData('tileX', tile.x);
    marker.setData('tileY', tile.y);
    marker.setData('markerType', 'threat');
    marker.setOrigin(0);
    marker.setStrokeStyle(1, 0xffd1a1, tile.strongestBehavior === 'aggressive' ? 0.66 : 0.42);
    marker.setDepth(4.5);
    if (this.mapMask) {
      marker.setMask(this.mapMask);
    }
    marker.setVisible(this.isTileVisible(tile.x, tile.y));
    marker.setInteractive({ useHandCursor: true });
    marker.on('pointerover', () => {
      marker.setFillStyle(color, Phaser.Math.Clamp(alpha + 0.16, 0.3, 0.64));
      this.renderThreatDetail(tile);
    });
    marker.on('pointerout', () => marker.setFillStyle(color, alpha));
    marker.on('pointerdown', () => this.renderThreatDetail(tile));
    this.markers.push(marker);
  }

  private tilesWithinDistance(originX: number, originY: number, distance: number) {
    const tiles: Array<{ x: number; y: number }> = [];
    for (let y = originY - distance; y <= originY + distance; y += 1) {
      for (let x = originX - distance; x <= originX + distance; x += 1) {
        if (!this.isInBounds(x, y) || Math.abs(originX - x) + Math.abs(originY - y) > distance) {
          continue;
        }
        tiles.push({ x, y });
      }
    }
    return tiles;
  }

  private isThreatTargetTile(x: number, y: number, enemyId: string) {
    const terrainRule = getTerrainRule(battleMap.terrain[y][x]);
    if (terrainRule.passable === false) {
      return false;
    }
    return !battleUnits.some((unit) => unit.id !== enemyId && unit.faction === 'enemy' && unit.hp > 0 && unit.x === x && unit.y === y);
  }

  private threatBehaviorRank(behavior: EnemyAiBehavior) {
    if (behavior === 'aggressive') {
      return 3;
    }
    if (behavior === 'guard') {
      return 2;
    }
    return 1;
  }

  private threatColor(behavior: EnemyAiBehavior) {
    if (behavior === 'aggressive') {
      return 0xc93b30;
    }
    if (behavior === 'guard') {
      return 0xd8732c;
    }
    return 0x9e3e58;
  }

  private showCommandMenu(unit: UnitData, pointerX: number, pointerY: number) {
    this.hideCommandMenu();

    const commands: BattleCommand[] = ['attack', 'strategy', 'item', 'wait'];
    const menuWidth = 136;
    const titleHeight = 31;
    const buttonHeight = 34;
    const gap = 5;
    const padding = 8;
    const menuHeight = padding * 2 + titleHeight + commands.length * buttonHeight + (commands.length - 1) * gap;
    const { left, top } = this.commandMenuPosition(pointerX, pointerY, menuWidth, menuHeight);

    const panel = this.add.rectangle(left, top, menuWidth, menuHeight, 0x101821, 0.97);
    panel.setOrigin(0);
    panel.setStrokeStyle(2, palette.gold, 0.76);
    panel.setDepth(14);
    this.commandMenuObjects.push(panel);

    const title = this.add.text(left + menuWidth / 2, top + padding + 2, '명령', {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '16px',
      color: '#d8b15f',
      fontStyle: '700'
    });
    title.setOrigin(0.5, 0);
    title.setDepth(15);
    this.commandMenuObjects.push(title);

    commands.forEach((command, index) => {
      const buttonTop = top + padding + titleHeight + index * (buttonHeight + gap);
      const centerX = left + menuWidth / 2;
      const centerY = buttonTop + buttonHeight / 2;
      const background = this.add.rectangle(centerX, centerY, menuWidth - padding * 2, buttonHeight, 0x1a2630, 0.94);
      background.setStrokeStyle(1, command === 'wait' ? palette.gold : palette.blue, 0.7);
      background.setInteractive({ useHandCursor: true });
      background.setDepth(15);

      const text = this.add.text(centerX, centerY, commandLabels[command], {
        fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
        fontSize: '18px',
        color: '#f2e3bf',
        fontStyle: '700'
      });
      text.setOrigin(0.5);
      text.setDepth(16);

      const chooseCommand = (choicePointer: Phaser.Input.Pointer) => {
        if (choicePointer.rightButtonDown()) {
          this.cancelPendingMove();
          return;
        }
        if (choicePointer.leftButtonDown()) {
          if (command === 'strategy' || command === 'item') {
            this.showUsableMenu(unit, command, choicePointer.x, choicePointer.y);
            return;
          }

          if (command !== 'wait') {
            this.beginDamageTargeting(unit, command);
            return;
          }

          this.completeUnitAction(unit, command);
        }
      };
      background.on('pointerover', () => background.setFillStyle(0x283947, 0.98));
      background.on('pointerout', () => background.setFillStyle(0x1a2630, 0.94));
      background.on('pointerdown', chooseCommand);
      text.setInteractive({ useHandCursor: true });
      text.on('pointerdown', chooseCommand);

      this.commandButtons.push({ command, background, text });
    });
  }

  private commandMenuPosition(pointerX: number, pointerY: number, width: number, height: number) {
    const margin = 14;
    const offset = 18;
    const preferredLeft = pointerX + offset + width > this.scale.width - margin ? pointerX - offset - width : pointerX + offset;
    const preferredTop = pointerY + height > this.scale.height - margin ? this.scale.height - margin - height : pointerY;

    return {
      left: Phaser.Math.Clamp(preferredLeft, margin, this.scale.width - margin - width),
      top: Phaser.Math.Clamp(preferredTop, margin, this.scale.height - margin - height)
    };
  }

  private hideCommandMenu() {
    this.commandMenuObjects.forEach((object) => object.destroy());
    this.commandMenuObjects = [];
    this.commandButtons.forEach(({ background, text }) => {
      background.destroy();
      text.destroy();
    });
    this.commandButtons = [];
  }

  private showUsableMenu(unit: UnitData, command: UsableCommand, pointerX: number, pointerY: number) {
    const usables = this.availableUsables(unit, command);
    if (usables.length === 0) {
      this.showUnavailableCommandAlert(command);
      return;
    }

    this.hideCommandMenu();

    const menuWidth = 238;
    const titleHeight = 34;
    const rowHeight = 52;
    const padding = 9;
    const menuHeight = padding * 2 + titleHeight + usables.length * rowHeight;
    const { left, top } = this.commandMenuPosition(pointerX, pointerY, menuWidth, menuHeight);

    const panel = this.add.rectangle(left, top, menuWidth, menuHeight, 0x101821, 0.98);
    panel.setOrigin(0);
    panel.setStrokeStyle(2, command === 'strategy' ? palette.blue : palette.gold, 0.78);
    panel.setDepth(14);
    this.commandMenuObjects.push(panel);

    const title = this.add.text(left + menuWidth / 2, top + padding + 2, commandLabels[command], {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '16px',
      color: '#d8b15f',
      fontStyle: '700'
    });
    title.setOrigin(0.5, 0);
    title.setDepth(15);
    this.commandMenuObjects.push(title);

    usables.forEach((usable, index) => {
      const rowTop = top + padding + titleHeight + index * rowHeight;
      const bg = this.add.rectangle(left + 8, rowTop, menuWidth - 16, rowHeight - 6, 0x1a2630, 0.94);
      bg.setOrigin(0);
      bg.setDepth(15);
      bg.setStrokeStyle(1, command === 'strategy' ? palette.blue : palette.gold, 0.66);
      bg.setInteractive({ useHandCursor: true });
      this.commandMenuObjects.push(bg);

      const stock = command === 'item' ? ` x${this.itemStock(unit.id, usable.id)}` : '';
      const name = this.add.text(left + 18, rowTop + 7, `${usable.name}${stock}`, {
        fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
        fontSize: '16px',
        color: '#f2e3bf',
        fontStyle: '700'
      });
      name.setDepth(16);
      this.commandMenuObjects.push(name);

      const desc = this.add.text(left + 18, rowTop + 28, usable.description, {
        fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
        fontSize: '12px',
        color: '#aeb7c2',
        wordWrap: { width: menuWidth - 36, useAdvancedWrap: true }
      });
      desc.setDepth(16);
      this.commandMenuObjects.push(desc);

      const choose = (choicePointer: Phaser.Input.Pointer) => {
        if (choicePointer.rightButtonDown()) {
          this.showCommandMenu(unit, pointerX, pointerY);
          return;
        }
        if (choicePointer.leftButtonDown()) {
          this.beginUsableTargeting(unit, usable);
        }
      };
      bg.on('pointerover', () => {
        bg.setFillStyle(0x283947, 0.98);
        this.renderUnitDetail(unit, `${usable.name}\n${usable.description}`);
      });
      bg.on('pointerout', () => bg.setFillStyle(0x1a2630, 0.94));
      bg.on('pointerdown', choose);
      name.setInteractive({ useHandCursor: true });
      name.on('pointerdown', choose);
      desc.setInteractive({ useHandCursor: true });
      desc.on('pointerdown', choose);
    });
  }

  private availableUsables(unit: UnitData, command: UsableCommand) {
    if (command === 'strategy') {
      return (unitStrategyIds[unit.id] ?? [])
        .map((id) => usableCatalog[id])
        .filter((usable): usable is BattleUsable => Boolean(usable));
    }

    const stocks = this.itemStocks.get(unit.id);
    if (!stocks) {
      return [];
    }

    return Array.from(stocks.entries())
      .filter(([, count]) => count > 0)
      .map(([id]) => usableCatalog[id])
      .filter((usable): usable is BattleUsable => Boolean(usable));
  }

  private itemStock(unitId: string, itemId: string) {
    return this.itemStocks.get(unitId)?.get(itemId) ?? 0;
  }

  private consumeItem(unitId: string, itemId: string) {
    const stocks = this.itemStocks.get(unitId);
    const current = stocks?.get(itemId) ?? 0;
    if (!stocks || current <= 0) {
      return false;
    }

    stocks.set(itemId, current - 1);
    return true;
  }

  private showUnavailableCommandAlert(command: Extract<BattleCommand, 'strategy' | 'item'>) {
    const message = command === 'strategy' ? '사용할 수 있는 책략이 없습니다.' : '사용할 수 있는 도구가 없습니다.';
    this.showBattleAlert(message);
  }

  private showBattleAlert(message: string) {
    this.hideBattleAlert();
    soundDirector.playSelect();

    const depth = 90;
    const width = 430;
    const height = 158;
    const left = Math.floor((this.scale.width - width) / 2);
    const top = Math.floor((this.scale.height - height) / 2);

    const shade = this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0x020406, 0.28);
    shade.setOrigin(0);
    shade.setDepth(depth);
    shade.setInteractive();

    const panel = this.add.rectangle(left, top, width, height, 0x101821, 0.98);
    panel.setOrigin(0);
    panel.setDepth(depth + 1);
    panel.setStrokeStyle(3, palette.gold, 0.9);

    const text = this.add.text(left + width / 2, top + 45, message, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '21px',
      color: '#f2e3bf',
      fontStyle: '700',
      align: 'center'
    });
    text.setOrigin(0.5, 0);
    text.setDepth(depth + 2);

    const okButton = this.add.rectangle(left + width / 2, top + 112, 112, 34, 0x253748, 0.96);
    okButton.setDepth(depth + 2);
    okButton.setStrokeStyle(1, palette.blue, 0.78);
    okButton.setInteractive({ useHandCursor: true });

    const okText = this.add.text(left + width / 2, top + 112, '확인', {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '17px',
      color: '#ffffff',
      fontStyle: '700'
    });
    okText.setOrigin(0.5);
    okText.setDepth(depth + 3);
    okText.setInteractive({ useHandCursor: true });

    const close = () => this.hideBattleAlert();
    shade.on('pointerdown', close);
    okButton.on('pointerdown', close);
    okText.on('pointerdown', close);
    this.alertDismissEvent = this.time.delayedCall(1400, close);

    this.alertObjects.push(shade, panel, text, okButton, okText);
  }

  private hideBattleAlert() {
    this.alertDismissEvent?.remove(false);
    this.alertDismissEvent = undefined;
    this.alertObjects.forEach((object) => object.destroy());
    this.alertObjects = [];
  }

  private completeUnitAction(unit: UnitData, command: BattleCommand) {
    if (this.phase !== 'command' || this.selectedUnit?.id !== unit.id || this.actedUnitIds.has(unit.id)) {
      return;
    }

    this.finishUnitAction(unit, this.commandResultMessage(unit, command));
  }

  private beginDamageTargeting(unit: UnitData, action: DamageCommand, usable?: BattleUsable) {
    if (this.phase !== 'command' || this.selectedUnit?.id !== unit.id || this.actedUnitIds.has(unit.id)) {
      return;
    }

    const targets = this.damageableTargets(unit, action, usable);
    if (targets.length === 0) {
      this.renderUnitDetail(unit, `현재 위치에서 ${commandLabels[action]} 가능한 적이 없습니다. 다른 명령을 선택하거나 우클릭으로 이동을 취소하세요.`);
      return;
    }

    this.phase = 'targeting';
    this.targetingAction = action;
    this.selectedUsable = usable;
    this.hideCommandMenu();
    this.clearMarkers();
    this.renderUnitDetail(unit, `붉은 칸의 적을 선택하면 ${usable?.name ?? commandLabels[action]}합니다. 우클릭하면 명령 선택으로 돌아갑니다.`);
    this.showDamageTargets(unit, action, targets, usable);
    soundDirector.playSelect();
  }

  private beginUsableTargeting(unit: UnitData, usable: BattleUsable) {
    if (usable.command === 'item' && this.itemStock(unit.id, usable.id) <= 0) {
      this.showUnavailableCommandAlert('item');
      return;
    }

    if (usable.effect === 'damage') {
      this.beginDamageTargeting(unit, usable.command, usable);
      return;
    }

    const targets = this.supportTargets(unit, usable);
    if (targets.length === 0) {
      const reason = usable.effect === 'heal' ? '회복할 수 있는 아군이 없습니다.' : '대상으로 삼을 수 있는 아군이 없습니다.';
      this.renderUnitDetail(unit, `${usable.name}\n${reason}`);
      return;
    }

    this.phase = 'targeting';
    this.targetingAction = usable.command;
    this.selectedUsable = usable;
    this.hideCommandMenu();
    this.clearMarkers();
    this.renderUnitDetail(unit, `푸른 칸의 아군을 선택하면 ${usable.name}을 사용합니다. 우클릭하면 명령 선택으로 돌아갑니다.`);
    this.showSupportTargets(unit, usable, targets);
    soundDirector.playSelect();
  }

  private showDamageTargets(attacker: UnitData, action: DamageCommand, targets = this.damageableTargets(attacker, action), usable?: BattleUsable) {
    targets.forEach((target) => {
      const preview = this.combatPreview(attacker, target, action, usable);
      const marker = this.add.rectangle(
        this.tileTopLeftX(target.x),
        this.tileTopLeftY(target.y),
        this.layout.tileSize,
        this.layout.tileSize,
        0xc93b30,
        0.3
      );
      marker.setData('tileX', target.x);
      marker.setData('tileY', target.y);
      marker.setOrigin(0);
      marker.setStrokeStyle(2, 0xffd27a, 0.9);
      marker.setDepth(9);
      if (this.mapMask) {
        marker.setMask(this.mapMask);
      }
      marker.setVisible(this.isTileVisible(target.x, target.y));
      marker.setInteractive({ useHandCursor: true });
      marker.on('pointerover', () => {
        marker.setFillStyle(0xd9503f, 0.44);
        this.renderAttackPreview(preview);
      });
      marker.on('pointerout', () => marker.setFillStyle(0xc93b30, 0.3));
      marker.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
        if (pointer.leftButtonDown()) {
          void this.tryResolveDamageTarget(attacker, target, action, usable);
        }
      });
      this.markers.push(marker);
    });
  }

  private showSupportTargets(user: UnitData, usable: BattleUsable, targets = this.supportTargets(user, usable)) {
    targets.forEach((target) => {
      const marker = this.add.rectangle(
        this.tileTopLeftX(target.x),
        this.tileTopLeftY(target.y),
        this.layout.tileSize,
        this.layout.tileSize,
        usable.effect === 'heal' ? 0x45b875 : 0x4f86d9,
        0.3
      );
      marker.setData('tileX', target.x);
      marker.setData('tileY', target.y);
      marker.setOrigin(0);
      marker.setStrokeStyle(2, usable.effect === 'heal' ? 0xb6ffd2 : 0xd9e8ff, 0.9);
      marker.setDepth(9);
      if (this.mapMask) {
        marker.setMask(this.mapMask);
      }
      marker.setVisible(this.isTileVisible(target.x, target.y));
      marker.setInteractive({ useHandCursor: true });
      marker.on('pointerover', () => {
        marker.setFillStyle(usable.effect === 'heal' ? 0x45b875 : 0x4f86d9, 0.44);
        this.renderSupportPreview(user, target, usable);
      });
      marker.on('pointerout', () => marker.setFillStyle(usable.effect === 'heal' ? 0x45b875 : 0x4f86d9, 0.3));
      marker.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
        if (pointer.leftButtonDown()) {
          void this.tryResolveSupportTarget(user, target, usable);
        }
      });
      this.markers.push(marker);
    });
  }

  private async tryResolveDamageTarget(attacker: UnitData, target: UnitData, action: DamageCommand, usable?: BattleUsable) {
    if (this.phase !== 'targeting' || this.selectedUnit?.id !== attacker.id) {
      return;
    }

    if (!this.canUseDamageCommand(attacker, target, action, usable)) {
      this.renderUnitDetail(attacker, '사거리 밖의 적입니다. 붉은 칸으로 표시된 적을 선택하세요.');
      return;
    }

    if (usable?.command === 'item' && !this.consumeItem(attacker.id, usable.id)) {
      this.showUnavailableCommandAlert('item');
      return;
    }

    this.triggerBattleEvent('first-engagement', '첫 교전', [
      `${attacker.name}이 ${target.name}을 공격합니다.`,
      '공격 후 경험치와 장비 경험치가 전투창에서 정산됩니다.'
    ]);
    this.phase = 'animating';
    const result = this.resolveCombatAction(attacker, target, action, false, usable);
    this.clearMarkers();
    await this.playCombatCutIn(result);
    result.counter = this.resolveCounterAttack(result);
    if (result.counter) {
      await this.delay(180);
      await this.playCombatCutIn(result.counter);
    }
    this.finishUnitAction(attacker, this.formatCombatResult(result));
  }

  private async tryResolveSupportTarget(user: UnitData, target: UnitData, usable: BattleUsable) {
    if (this.phase !== 'targeting' || this.selectedUnit?.id !== user.id) {
      return;
    }

    if (!this.canUseSupportCommand(user, target, usable)) {
      this.renderUnitDetail(user, '사거리 밖의 대상입니다. 푸른 칸으로 표시된 대상을 선택하세요.');
      return;
    }

    if (usable.command === 'item' && !this.consumeItem(user.id, usable.id)) {
      this.showUnavailableCommandAlert('item');
      return;
    }

    this.phase = 'animating';
    const result = this.resolveSupportAction(user, target, usable);
    this.clearMarkers();
    await this.playSupportCutIn(result);
    this.finishUnitAction(user, this.formatSupportResult(result));
  }

  private renderAttackPreview(preview: CombatPreview) {
    const counter = preview.counterAvailable ? ' / 반격 가능' : '';
    const bond = preview.bondDamageBonus > 0 && preview.bondLabel ? `\n공명 ${preview.bondLabel} 피해 +${preview.bondDamageBonus}%` : '';
    this.renderUnitDetail(
      preview.defender,
      `${preview.attacker.name} ${commandLabels[preview.action]} 예측\n피해 ${preview.damage} / 명중 ${preview.hitRate}% / 치명 ${preview.criticalRate}%\n지형 ${preview.terrainLabel}${counter}${bond}`
    );
  }

  private renderSupportPreview(user: UnitData, target: UnitData, usable: BattleUsable) {
    if (usable.effect === 'heal') {
      const amount = this.supportHealAmount(user, target, usable);
      this.renderUnitDetail(target, `${user.name} ${usable.name} 예측\n병력 +${amount} / ${target.hp} -> ${Math.min(target.maxHp, target.hp + amount)}`);
      return;
    }

    this.renderUnitDetail(
      target,
      `${user.name} ${usable.name} 예측\n공격 +${usable.attackBonus ?? 0} / 명중 +${usable.hitBonus ?? 0} / 치명 +${usable.criticalBonus ?? 0}\n${usable.duration ?? 1}턴 동안 유지`
    );
  }

  private finishUnitAction(unit: UnitData, message: string) {
    this.actedUnitIds.add(unit.id);
    this.phase = 'idle';
    this.selectedUnit = undefined;
    this.pendingMove = undefined;
    this.targetingAction = undefined;
    this.selectedUsable = undefined;
    this.hideCommandMenu();
    this.hideTurnEndPrompt();
    this.applyActedStyle(unit);
    soundDirector.playSelect();
    this.pushBattleLog(message);
    this.checkBattleEvents();
    this.updateObjectiveTracker();

    if (this.resolveBattleOutcomeIfNeeded()) {
      return;
    }

    const remaining = this.remainingAllyCount();
    const turnHint =
      remaining > 0
        ? `${remaining}명의 아군이 아직 행동할 수 있습니다.`
        : '모든 아군이 행동했습니다. 턴 종료 여부를 선택하세요.';
    this.renderRosterPanel('ally', `${message}\n${turnHint}`);

    if (remaining === 0) {
      this.showTurnEndPrompt(message);
    }
  }

  private resolveBattleOutcomeIfNeeded() {
    if (this.battleOutcome) {
      return true;
    }

    const defeatTriggered = battleScenario.defeatConditions.some((condition) => {
      return condition.kind === 'unit-defeated' && !this.isUnitAlive(condition.unitId);
    });
    if (defeatTriggered) {
      this.completeBattle('defeat');
      return true;
    }

    const leader = battleUnits.find((unit) => unit.id === leaderUnitId);
    if (leader && leader.hp <= 0) {
      this.completeBattle('victory');
      return true;
    }

    const enemiesAlive = battleUnits.some((unit) => unit.faction === 'enemy' && unit.hp > 0);
    if (!enemiesAlive) {
      this.completeBattle('victory');
      return true;
    }

    return false;
  }

  private completeBattle(outcome: BattleOutcome) {
    if (this.battleOutcome) {
      return;
    }

    this.battleOutcome = outcome;
    this.phase = 'resolved';
    this.selectedUnit = undefined;
    this.pendingMove = undefined;
    this.targetingAction = undefined;
    this.selectedUsable = undefined;
    this.clearMarkers();
    this.hideCommandMenu();
    this.hideMapMenu();
    this.hideTurnEndPrompt();
    this.hideBattleAlert();
    this.hideBattleEventBanner();
    this.updateObjectiveTracker();

    const message =
      outcome === 'victory'
        ? `승리! ${battleScenario.victoryConditionLabel}에 성공했습니다.`
        : '패배... 유비가 패주했습니다.';
    this.pushBattleLog(message);
    this.renderSituationPanel(message);
    soundDirector.playEffect(outcome === 'victory' ? 'exp-gain' : 'combat-impact', {
      volume: outcome === 'victory' ? 0.38 : 0.28,
      stopAfterMs: 1200
    });
    this.publishFirstBattleReport(outcome);
    this.showBattleResult(outcome);
  }

  private showBattleResult(outcome: BattleOutcome) {
    this.hideBattleResult();
    this.resultAnimationToken += 1;
    this.resultGaugeAnimations = [];
    const animationToken = this.resultAnimationToken;

    const depth = 120;
    const panelWidth = 1040;
    const panelHeight = 660;
    const left = Math.floor((this.scale.width - panelWidth) / 2);
    const top = Math.floor((this.scale.height - panelHeight) / 2);
    const allies = battleUnits.filter((unit) => unit.faction === 'ally');
    const defeatedEnemies = battleUnits.filter((unit) => unit.faction === 'enemy' && unit.hp <= 0).length;
    const totalEnemies = battleUnits.filter((unit) => unit.faction === 'enemy').length;
    const aliveAllies = allies.filter((unit) => unit.hp > 0).length;
    const objectives = this.resultObjectives(outcome);

    const shade = this.trackResultObject(this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0x020406, 0.68));
    shade.setOrigin(0);
    shade.setDepth(depth);
    shade.setInteractive();

    const panel = this.trackResultObject(this.add.rectangle(left, top, panelWidth, panelHeight, 0x101821, 0.98));
    panel.setOrigin(0);
    panel.setDepth(depth + 1);
    panel.setStrokeStyle(3, outcome === 'victory' ? palette.gold : 0xb86b55, 0.94);

    const title = this.trackResultObject(this.add.text(left + panelWidth / 2, top + 26, outcome === 'victory' ? '전투 승리' : '전투 패배', {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '34px',
      color: outcome === 'victory' ? '#f4dfad' : '#ffb6a6',
      fontStyle: '700',
      stroke: '#05070a',
      strokeThickness: 5
    }));
    title.setOrigin(0.5, 0);
    title.setDepth(depth + 2);

    const subtitleText =
      outcome === 'victory'
        ? '탁현 의용군은 첫 싸움에서 황건적을 몰아내고 마을을 지켜냈습니다.'
        : '유비가 전장을 이탈했습니다. 진형을 가다듬고 다시 도전해야 합니다.';
    const subtitle = this.trackResultObject(this.add.text(left + panelWidth / 2, top + 72, subtitleText, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '17px',
      color: '#d4dce6',
      align: 'center'
    }));
    subtitle.setOrigin(0.5, 0);
    subtitle.setDepth(depth + 2);

    const metricTop = top + 112;
    const metricWidth = 226;
    const metricGap = 16;
    this.renderResultMetric('소요 턴', `${this.turnNumber}턴`, left + 44, metricTop, metricWidth, depth + 2);
    this.renderResultMetric('생존 아군', `${aliveAllies} / ${allies.length}`, left + 44 + (metricWidth + metricGap), metricTop, metricWidth, depth + 2);
    this.renderResultMetric('격파 적군', `${defeatedEnemies} / ${totalEnemies}`, left + 44 + (metricWidth + metricGap) * 2, metricTop, metricWidth, depth + 2);
    this.renderResultMetric('전투 보상', outcome === 'victory' ? `군자금 ${this.resultGold(outcome)}` : '없음', left + 44 + (metricWidth + metricGap) * 3, metricTop, metricWidth, depth + 2);

    this.renderResultObjectivePanel(objectives, left + 44, top + 178, 456, depth + 2);
    this.renderResultRewardPanel(outcome, objectives, left + 526, top + 178, 470, depth + 2);
    this.renderResultBondStrip(left + 44, top + 318, panelWidth - 88, depth + 2);

    const unitTitle = this.trackResultObject(this.add.text(left + 44, top + 354, '장수 성장', {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '21px',
      color: '#f2e3bf',
      fontStyle: '700'
    }));
    unitTitle.setDepth(depth + 2);

    allies.forEach((unit, index) => {
      this.renderResultUnitRow(unit, left + 44, top + 386 + index * 70, panelWidth - 88, depth + 2);
    });

    this.resultSettlementText = this.trackResultObject(this.add.text(left + 44, top + 604, '성장 정산 대기', {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '14px',
      color: '#d8b15f',
      fontStyle: '700'
    }));
    this.resultSettlementText.setDepth(depth + 2);

    if (outcome === 'victory') {
      this.addResultButton('군영으로', left + panelWidth - 422, top + 612, 132, () => {
        soundDirector.playSelect();
        this.scene.start('CampScene');
      }, depth + 3);
    }
    this.addResultButton('다시 하기', left + panelWidth - 276, top + 612, 124, () => {
      soundDirector.playSelect();
      this.scene.restart({ battleId: battleScenario.id });
    }, depth + 3);
    this.addResultButton('타이틀로', left + panelWidth - 140, top + 612, 124, () => {
      soundDirector.playSelect();
      this.scene.start('TitleScene');
    }, depth + 3);

    void this.playResultSettlementAnimation(animationToken);
  }

  private hideBattleResult() {
    this.resultAnimationToken += 1;
    this.resultObjects.forEach((object) => object.destroy());
    this.resultObjects = [];
    this.resultGaugeAnimations = [];
    this.resultSettlementText = undefined;
  }

  private resultObjectives(outcome: BattleOutcome): BattleObjectiveResult[] {
    return this.objectiveStates(outcome).map((objective) => ({
      id: objective.id,
      label: objective.label,
      achieved: objective.achieved,
      detail: objective.detail,
      rewardGold: objective.rewardGold
    }));
  }

  private objectiveStates(outcome?: BattleOutcome): BattleObjectiveState[] {
    return battleScenario.objectives.map((objective) => this.objectiveState(objective, outcome));
  }

  private objectiveState(objective: BattleObjectiveDefinition, outcome?: BattleOutcome): BattleObjectiveState {
    if (objective.kind === 'defeat-leader') {
      const targetId = objective.unitId ?? leaderUnitId;
      const target = battleUnits.find((unit) => unit.id === targetId);
      const defeated = this.isUnitDefeated(targetId);
      const achieved = outcome ? outcome === 'victory' && defeated : defeated;
      return {
        id: objective.id,
        label: objective.label,
        achieved,
        status: achieved ? 'done' : outcome === 'defeat' ? 'failed' : 'active',
        detail: defeated ? `${this.unitName(targetId)} 격파` : `${this.unitName(targetId)} 병력 ${target?.hp ?? 0}/${target?.maxHp ?? 0}`,
        rewardGold: objective.rewardGold
      };
    }

    if (objective.kind === 'keep-unit-alive') {
      const targetId = objective.unitId ?? 'liu-bei';
      const alive = this.isUnitAlive(targetId);
      return {
        id: objective.id,
        label: objective.label,
        achieved: alive,
        status: alive ? 'active' : 'failed',
        detail: alive ? `${this.unitName(targetId)} 생존` : `${this.unitName(targetId)} 퇴각`,
        rewardGold: objective.rewardGold
      };
    }

    if (objective.kind === 'secure-terrain') {
      const terrain = (objective.terrain ?? 'village') as TerrainType;
      const secured =
        terrain === 'village'
          ? !this.hasEnemyThreateningTerrain(terrain, 2)
          : !this.hasEnemyOnTerrain(terrain);
      const achieved = outcome ? outcome === 'victory' && secured : secured;
      return {
        id: objective.id,
        label: objective.label,
        achieved,
        status: achieved ? 'done' : outcome === 'defeat' ? 'failed' : 'active',
        detail: secured ? '마을 주변 안전' : '마을 주변 교전 중',
        rewardGold: objective.rewardGold
      };
    }

    const maxTurn = objective.maxTurn ?? quickVictoryTurnLimit;
    const inTime = this.turnNumber <= maxTurn;
    const achieved = outcome ? outcome === 'victory' && inTime : inTime;
    return {
      id: objective.id,
      label: objective.label,
      achieved,
      status: achieved ? (outcome === 'victory' ? 'done' : 'active') : 'failed',
      detail: inTime ? `${this.turnNumber}/${maxTurn}턴 진행 중` : `${this.turnNumber}/${maxTurn}턴 초과`,
      rewardGold: objective.rewardGold
    };
  }

  private resultGold(outcome: BattleOutcome) {
    if (outcome !== 'victory') {
      return 0;
    }

    return baseVictoryGold + this.resultObjectives(outcome).reduce((total, objective) => total + (objective.achieved ? objective.rewardGold : 0), 0);
  }

  private resultMvp() {
    return battleUnits
      .filter((unit) => unit.faction === 'ally')
      .map((unit) => {
        const stats = this.statsFor(unit.id);
        const score = stats.damageDealt + stats.defeats * 18 + Math.floor(stats.support * 0.7) + Math.floor(stats.damageTaken * 0.25) + (unit.hp > 0 ? 8 : 0);
        return { unit, stats, score };
      })
      .sort((a, b) => b.score - a.score || b.stats.defeats - a.stats.defeats || b.stats.damageDealt - a.stats.damageDealt)[0];
  }

  private publishFirstBattleReport(outcome: BattleOutcome) {
    const objectives = this.resultObjectives(outcome);
    const mvp = this.resultMvp();
    const defeatedEnemies = battleUnits.filter((unit) => unit.faction === 'enemy' && unit.hp <= 0).length;
    const totalEnemies = battleUnits.filter((unit) => unit.faction === 'enemy').length;

    setFirstBattleReport({
      battleId: battleScenario.id,
      battleTitle: battleScenario.title,
      outcome,
      turnNumber: this.turnNumber,
      rewardGold: this.resultGold(outcome),
      defeatedEnemies,
      totalEnemies,
      objectives: objectives.map((objective) => ({ ...objective })),
      units: battleUnits.map(cloneUnitData),
      bonds: Array.from(this.bondStates.values()).map((bond) => ({
        ...bond,
        unitIds: [...bond.unitIds] as [string, string]
      })),
      mvp: mvp
        ? {
            unitId: mvp.unit.id,
            name: mvp.unit.name,
            damageDealt: mvp.stats.damageDealt,
            defeats: mvp.stats.defeats
          }
        : undefined,
      itemRewards: outcome === 'victory' ? [...battleScenario.itemRewards] : [],
      completedCampDialogues: [],
      createdAt: new Date().toISOString()
    });
  }

  private renderResultObjectivePanel(objectives: BattleObjectiveResult[], x: number, y: number, width: number, depth: number) {
    const height = 132;
    const bg = this.trackResultObject(this.add.rectangle(x, y, width, height, 0x101820, 0.9));
    bg.setOrigin(0);
    bg.setDepth(depth);
    bg.setStrokeStyle(1, palette.blue, 0.56);

    const title = this.trackResultObject(this.add.text(x + 14, y + 10, '전투 목표', {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '19px',
      color: '#f2e3bf',
      fontStyle: '700'
    }));
    title.setDepth(depth + 1);

    objectives.forEach((objective, index) => {
      const rowY = y + 42 + index * 21;
      const mark = objective.achieved ? '완료' : '미달';
      const color = objective.achieved ? '#a8ffd0' : '#aeb7c2';
      const line = this.trackResultObject(this.add.text(x + 16, rowY, `${mark}  ${objective.label}  ${objective.detail}`, {
        fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
        fontSize: '13px',
        color
      }));
      line.setDepth(depth + 1);

      const reward = this.trackResultObject(this.add.text(x + width - 16, rowY, objective.achieved ? `+${objective.rewardGold}` : '-', {
        fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
        fontSize: '13px',
        color: objective.achieved ? '#d8b15f' : '#6f7a84',
        fontStyle: '700'
      }));
      reward.setOrigin(1, 0);
      reward.setDepth(depth + 1);
    });
  }

  private renderResultRewardPanel(outcome: BattleOutcome, objectives: BattleObjectiveResult[], x: number, y: number, width: number, depth: number) {
    const height = 132;
    const bg = this.trackResultObject(this.add.rectangle(x, y, width, height, 0x101820, 0.9));
    bg.setOrigin(0);
    bg.setDepth(depth);
    bg.setStrokeStyle(1, palette.gold, 0.56);

    const title = this.trackResultObject(this.add.text(x + 14, y + 10, '보상 정산', {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '19px',
      color: '#f2e3bf',
      fontStyle: '700'
    }));
    title.setDepth(depth + 1);

    const mvp = this.resultMvp();
    const bondSummary = Array.from(this.bondStates.values())
      .filter((bond) => bond.battleExp > 0)
      .map((bond) => `${this.unitName(bond.unitIds[0])}·${this.unitName(bond.unitIds[1])} +${bond.battleExp}`)
      .join('  ');
    const achievedCount = objectives.filter((objective) => objective.achieved).length;
    const lines = [
      outcome === 'victory' ? `군자금 ${this.resultGold(outcome)}  목표 ${achievedCount}/${objectives.length}` : '패배: 보상 없음',
      mvp ? `MVP ${mvp.unit.name}: 피해 ${mvp.stats.damageDealt}, 격파 ${mvp.stats.defeats}` : 'MVP 없음',
      bondSummary ? `공명 성장 ${bondSummary}` : '공명 성장 없음',
      outcome === 'victory' ? `전리품: ${battleScenario.itemRewards.join(', ')}` : '재도전하면 목표 보상을 다시 노릴 수 있습니다.'
    ];

    lines.forEach((line, index) => {
      const text = this.trackResultObject(this.add.text(x + 16, y + 42 + index * 21, line, {
        fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
        fontSize: '13px',
        color: index === 0 ? '#d8b15f' : '#d4dce6',
        fontStyle: index === 0 ? '700' : '400',
        wordWrap: { width: width - 32, useAdvancedWrap: true }
      }));
      text.setDepth(depth + 1);
    });
  }

  private renderResultBondStrip(x: number, y: number, width: number, depth: number) {
    const bonds = Array.from(this.bondStates.values());
    const bg = this.trackResultObject(this.add.rectangle(x, y, width, 30, 0x101820, 0.9));
    bg.setOrigin(0);
    bg.setDepth(depth);
    bg.setStrokeStyle(1, palette.gold, 0.46);

    const title = this.trackResultObject(this.add.text(x + 12, y + 7, '공명 성장', {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '13px',
      color: '#f2e3bf',
      fontStyle: '700'
    }));
    title.setDepth(depth + 1);

    if (bonds.length === 0) {
      const empty = this.trackResultObject(this.add.text(x + 110, y + 7, '공명 없음', {
        fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
        fontSize: '13px',
        color: '#9fb0bf'
      }));
      empty.setDepth(depth + 1);
      return;
    }

    const startX = x + 104;
    const columnWidth = Math.floor((width - 118) / bonds.length);
    bonds.forEach((bond, index) => {
      const initial = initialBattleBonds.find((candidate) => candidate.id === bond.id);
      const previousLevel = initial?.level ?? bond.level;
      const previousExp = initial?.exp ?? bond.exp;
      const gained = initial ? Math.max(0, this.characterProgressScore(bond.level, bond.exp) - this.characterProgressScore(initial.level, initial.exp)) : bond.battleExp;
      const colX = startX + index * columnWidth;
      const [first, second] = bond.unitIds.map((unitId) => this.unitName(unitId));
      const label = this.trackResultObject(this.add.text(colX, y + 6, `${first}·${second}`, {
        fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
        fontSize: '12px',
        color: '#d4dce6',
        fontStyle: '700'
      }));
      label.setDepth(depth + 1);

      const amount = this.trackResultObject(this.add.text(colX + 86, y + 6, gained > 0 ? `+${gained}` : '+0', {
        fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
        fontSize: '12px',
        color: gained > 0 ? '#d8b15f' : '#6f7a84',
        fontStyle: '700'
      }));
      amount.setDepth(depth + 1);

      const fill = this.renderResultProgressGauge(colX + 122, y + 11, 82, 7, previousExp / 100, 0x83d6ff, depth + 1);
      const valueText = this.trackResultObject(this.add.text(colX + 210, y + 4, `${previousExp}/100`, {
        fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
        fontSize: '11px',
        color: '#f0e4c8',
        fontStyle: '700'
      }));
      valueText.setOrigin(1, 0);
      valueText.setDepth(depth + 1);
      const levelText = this.trackResultObject(this.add.text(colX + columnWidth - 8, y + 6, `Lv ${previousLevel}`, {
        fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
        fontSize: '12px',
        color: '#f4dfad',
        fontStyle: '700'
      }));
      levelText.setOrigin(1, 0);
      levelText.setDepth(depth + 1);

      const eventText = this.trackResultObject(this.add.text(colX + 164, y + 2, '', {
        fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
        fontSize: '12px',
        color: '#fff2b8',
        fontStyle: '700',
        stroke: '#05070a',
        strokeThickness: 3
      }));
      eventText.setOrigin(0.5);
      eventText.setDepth(depth + 4);
      eventText.setAlpha(0);

      if (gained > 0 || bond.level > previousLevel) {
        this.queueResultGaugeAnimation({
          fill,
          valueText,
          levelText,
          eventText,
          levelPrefix: 'Lv ',
          previousLevel,
          previousExp,
          previousNext: 100,
          level: bond.level,
          exp: bond.exp,
          next: 100,
          leveled: bond.level > previousLevel,
          flashArea: { x: colX - 6, y: y + 3, width: Math.max(220, columnWidth - 12), height: 24, depth: depth + 3 }
        });
      }
    });
  }

  private renderResultMetric(label: string, value: string, x: number, y: number, width: number, depth: number) {
    const bg = this.trackResultObject(this.add.rectangle(x, y, width, 48, 0x16212d, 0.94));
    bg.setOrigin(0);
    bg.setDepth(depth);
    bg.setStrokeStyle(1, 0x53606c, 0.6);

    const labelText = this.trackResultObject(this.add.text(x + 12, y + 8, label, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '13px',
      color: '#9fb0bf'
    }));
    labelText.setDepth(depth + 1);

    const valueText = this.trackResultObject(this.add.text(x + width - 12, y + 22, value, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '18px',
      color: '#f2e3bf',
      fontStyle: '700'
    }));
    valueText.setOrigin(1, 0);
    valueText.setDepth(depth + 1);
  }

  private renderResultUnitRow(unit: UnitData, x: number, y: number, width: number, depth: number) {
    const alive = unit.hp > 0;
    const initial = initialBattleUnits.find((candidate) => candidate.id === unit.id);
    const bg = this.trackResultObject(this.add.rectangle(x, y, width, 68, 0x101820, alive ? 0.94 : 0.66));
    bg.setOrigin(0);
    bg.setDepth(depth);
    bg.setStrokeStyle(1, alive ? palette.blue : 0x646464, alive ? 0.62 : 0.5);

    const name = this.trackResultObject(this.add.text(x + 14, y + 8, `${unit.name}  Lv ${unit.level}`, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '17px',
      color: alive ? '#e9f0f8' : '#9a9a9a',
      fontStyle: '700'
    }));
    name.setDepth(depth + 1);

    const classText = this.trackResultObject(this.add.text(x + 150, y + 10, getUnitClass(unit.classKey).name, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '14px',
      color: alive ? '#9fb0bf' : '#777777'
    }));
    classText.setDepth(depth + 1);

    const hpText = this.trackResultObject(this.add.text(x + 270, y + 9, `HP ${unit.hp}/${unit.maxHp}`, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '14px',
      color: alive ? '#f0e4c8' : '#8c8c8c',
      fontStyle: '700'
    }));
    hpText.setDepth(depth + 1);

    const previousLevel = initial?.level ?? unit.level;
    const previousExp = initial?.exp ?? unit.exp;
    const characterGained = initial ? Math.max(0, this.characterProgressScore(unit.level, unit.exp) - this.characterProgressScore(initial.level, initial.exp)) : 0;
    const expText = this.trackResultObject(this.add.text(x + 392, y + 9, this.characterResultText(unit), {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '14px',
      color: '#d8b15f',
      fontStyle: '700'
    }));
    expText.setDepth(depth + 1);
    const characterFill = this.renderResultProgressGauge(x + 392, y + 36, 158, 8, previousExp / 100, palette.gold, depth + 1);
    const characterValueText = this.trackResultObject(this.add.text(x + 564, y + 28, `${previousExp} / 100`, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '11px',
      color: '#f0e4c8',
      fontStyle: '700'
    }));
    characterValueText.setOrigin(1, 0);
    characterValueText.setDepth(depth + 1);
    const characterLevelText = this.trackResultObject(this.add.text(x + 570, y + 9, `Lv ${previousLevel}`, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '12px',
      color: '#f4dfad',
      fontStyle: '700'
    }));
    characterLevelText.setDepth(depth + 1);
    const characterEventText = this.trackResultObject(this.add.text(x + 476, y + 31, '', {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '14px',
      color: '#fff2b8',
      fontStyle: '700',
      stroke: '#05070a',
      strokeThickness: 3
    }));
    characterEventText.setOrigin(0.5);
    characterEventText.setDepth(depth + 4);
    characterEventText.setAlpha(0);
    if (characterGained > 0 || unit.level > previousLevel) {
      this.queueResultGaugeAnimation({
        fill: characterFill,
        valueText: characterValueText,
        levelText: characterLevelText,
        eventText: characterEventText,
        levelPrefix: 'Lv ',
        previousLevel,
        previousExp,
        previousNext: 100,
        level: unit.level,
        exp: unit.exp,
        next: 100,
        leveled: unit.level > previousLevel,
        flashArea: { x, y, width, height: 68, depth: depth + 3 }
      });
    }

    equipmentSlots.forEach((slot, index) => {
      const state = unit.equipment[slot];
      const initialState = initial?.equipment[slot];
      const previousEquipmentLevel = initialState?.level ?? state.level;
      const previousEquipmentExp = initialState?.exp ?? state.exp;
      const previousNext = equipmentExpToNext(previousEquipmentLevel);
      const next = equipmentExpToNext(state.level);
      const gained = initialState ? Math.max(0, this.equipmentProgressScore(state.level, state.exp) - this.equipmentProgressScore(initialState.level, initialState.exp)) : 0;
      const color = slot === 'weapon' ? 0xffc45f : slot === 'armor' ? 0x77b7ff : 0xb7df7a;
      const slotX = x + 632 + index * 96;
      const label = this.trackResultObject(this.add.text(slotX, y + 9, `${equipmentSlotLabels[slot]} Lv${previousEquipmentLevel}`, {
        fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
        fontSize: '11px',
        color: alive ? '#9fb0bf' : '#777777',
        fontStyle: '700'
      }));
      label.setDepth(depth + 1);
      const valueText = this.trackResultObject(this.add.text(slotX + 38, y + 22, `${previousEquipmentExp}/${previousNext}`, {
        fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
        fontSize: '10px',
        color: alive ? '#f0e4c8' : '#7e858d',
        fontStyle: '700'
      }));
      valueText.setOrigin(0.5, 0);
      valueText.setDepth(depth + 1);
      const fill = this.renderResultProgressGauge(slotX, y + 38, 76, 7, previousEquipmentExp / previousNext, color, depth + 1);
      const eventText = this.trackResultObject(this.add.text(slotX + 38, y + 31, '', {
        fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
        fontSize: '12px',
        color: '#fff2b8',
        fontStyle: '700',
        stroke: '#05070a',
        strokeThickness: 3
      }));
      eventText.setOrigin(0.5);
      eventText.setDepth(depth + 4);
      eventText.setAlpha(0);
      if (gained > 0 || state.level > previousEquipmentLevel) {
        this.queueResultGaugeAnimation({
          fill,
          valueText,
          levelText: label,
          eventText,
          levelPrefix: `${equipmentSlotLabels[slot]} Lv`,
          previousLevel: previousEquipmentLevel,
          previousExp: previousEquipmentExp,
          previousNext,
          level: state.level,
          exp: state.exp,
          next,
          leveled: state.level > previousEquipmentLevel,
          flashArea: { x: slotX - 6, y: y + 5, width: 88, height: 44, depth: depth + 3 }
        });
      }
    });

    const equipment = this.trackResultObject(this.add.text(x + 14, y + 48, this.equipmentResultText(unit), {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '11px',
      color: alive ? '#c8d2dd' : '#7e858d',
      wordWrap: { width: width - 28, useAdvancedWrap: true }
    }));
    equipment.setDepth(depth + 1);
  }

  private renderResultProgressGauge(x: number, y: number, width: number, height: number, ratio: number, color: number, depth: number) {
    const track = this.trackResultObject(this.add.rectangle(x, y, width, height, 0x05090e, 0.9));
    track.setOrigin(0);
    track.setDepth(depth);
    track.setStrokeStyle(1, 0x40515e, 0.5);

    const fill = this.trackResultObject(this.add.rectangle(x + 1, y + 1, Math.max(2, width - 2), Math.max(2, height - 2), color, 0.96));
    fill.setOrigin(0);
    fill.setDepth(depth + 1);
    fill.setScale(Phaser.Math.Clamp(ratio, 0, 1), 1);
    return fill;
  }

  private characterResultText(unit: UnitData) {
    const initial = initialBattleUnits.find((candidate) => candidate.id === unit.id);
    const gained = initial ? Math.max(0, this.characterProgressScore(unit.level, unit.exp) - this.characterProgressScore(initial.level, initial.exp)) : 0;
    const levelDelta = initial ? unit.level - initial.level : 0;
    const levelText = levelDelta > 0 ? ` / Lv +${levelDelta}` : '';
    return `경험 +${gained} (${unit.exp}/100)${levelText}`;
  }

  private equipmentResultText(unit: UnitData) {
    const initial = initialBattleUnits.find((candidate) => candidate.id === unit.id);
    return equipmentSlots
      .map((slot) => {
        const state = unit.equipment[slot];
        const item = getItem(state.itemId);
        const initialState = initial?.equipment[slot];
        const gained = initialState ? Math.max(0, this.equipmentProgressScore(state.level, state.exp) - this.equipmentProgressScore(initialState.level, initialState.exp)) : 0;
        const levelDelta = initialState ? state.level - initialState.level : 0;
        const levelText = levelDelta > 0 ? `(+${levelDelta})` : '';
        return `${equipmentSlotLabels[slot]} ${item.name} Lv${state.level}${levelText} +${gained}`;
      })
      .join('   ');
  }

  private characterProgressScore(level: number, exp: number) {
    return (level - 1) * 100 + exp;
  }

  private equipmentProgressScore(level: number, exp: number) {
    let score = exp;
    for (let currentLevel = 1; currentLevel < level; currentLevel += 1) {
      score += equipmentExpToNext(currentLevel);
    }
    return score;
  }

  private renderResultBondRow(bond: BondState, x: number, y: number, width: number, depth: number) {
    const bg = this.trackResultObject(this.add.rectangle(x, y, width, 58, 0x101820, 0.88));
    bg.setOrigin(0);
    bg.setDepth(depth);
    bg.setStrokeStyle(1, bond.battleExp > 0 ? palette.gold : 0x53606c, bond.battleExp > 0 ? 0.72 : 0.5);

    const [first, second] = bond.unitIds.map((unitId) => this.unitName(unitId));
    const title = this.trackResultObject(this.add.text(x + 12, y + 8, `${first} · ${second}`, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '14px',
      color: '#e8dfca',
      fontStyle: '700'
    }));
    title.setDepth(depth + 1);

    const value = this.trackResultObject(this.add.text(x + 12, y + 31, this.bondResultText(bond), {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '13px',
      color: bond.battleExp > 0 ? '#d8b15f' : '#9fb0bf'
    }));
    value.setDepth(depth + 1);
  }

  private bondResultText(bond: BondState) {
    const initial = initialBattleBonds.find((candidate) => candidate.id === bond.id);
    const gained = initial ? Math.max(0, this.characterProgressScore(bond.level, bond.exp) - this.characterProgressScore(initial.level, initial.exp)) : bond.battleExp;
    const levelDelta = initial ? bond.level - initial.level : 0;
    const levelText = levelDelta > 0 ? ` / Lv +${levelDelta}` : '';
    return `공명 ${bond.level}  경험 +${gained}${levelText}  전투 +${bond.battleExp}`;
  }

  private queueResultGaugeAnimation(animation: ResultGaugeAnimation) {
    this.resultGaugeAnimations.push(animation);
  }

  private async playResultSettlementAnimation(token: number) {
    await this.delay(360);
    if (token !== this.resultAnimationToken) {
      return;
    }

    if (this.resultGaugeAnimations.length === 0) {
      this.resultSettlementText?.setText('성장 변화 없음');
      return;
    }

    for (let index = 0; index < this.resultGaugeAnimations.length; index += 1) {
      if (token !== this.resultAnimationToken) {
        return;
      }
      this.resultSettlementText?.setText(`성장 정산 ${index + 1} / ${this.resultGaugeAnimations.length}`);
      await this.animateResultGauge(this.resultGaugeAnimations[index], 280);
      await this.delay(44);
    }

    if (token === this.resultAnimationToken) {
      this.resultSettlementText?.setText('성장 정산 완료');
    }
  }

  private async animateResultGauge(animation: ResultGaugeAnimation, duration: number) {
    if (animation.leveled) {
      await this.animateGrowthGauge(
        animation.fill,
        animation.valueText,
        animation.previousExp / animation.previousNext,
        1,
        animation.previousExp,
        animation.previousNext,
        animation.previousNext,
        false,
        duration
      );
      animation.levelText.setText(`${animation.levelPrefix}${animation.level}`);
      animation.fill.setScale(0, 1);
      animation.valueText.setText(`0 / ${animation.next}`);
      this.showGrowthEventText(animation.eventText, `Lv ${animation.level} 달성!`);
      this.flashResultGauge(animation);
      soundDirector.playLevelUp();
      await this.delay(180);
      await this.animateGrowthGauge(animation.fill, animation.valueText, 0, animation.exp / animation.next, 0, animation.exp, animation.next, false, duration);
      return;
    }

    await this.animateGrowthGauge(
      animation.fill,
      animation.valueText,
      animation.previousExp / animation.previousNext,
      animation.exp / animation.next,
      animation.previousExp,
      animation.exp,
      animation.next,
      false,
      duration
    );
    animation.levelText.setText(`${animation.levelPrefix}${animation.level}`);
  }

  private flashResultGauge(animation: ResultGaugeAnimation) {
    const { x, y, width, height, depth } = animation.flashArea;
    const flash = this.trackResultObject(this.add.rectangle(x + width / 2, y + height / 2, width, height, 0xffdf7b, 0.16));
    flash.setDepth(depth);
    this.tweens.add({
      targets: flash,
      alpha: 0,
      scaleX: 1.04,
      scaleY: 1.16,
      duration: 520,
      ease: 'Sine.easeOut'
    });
    this.tweens.add({
      targets: [animation.levelText, animation.valueText],
      scale: 1.14,
      duration: 120,
      yoyo: true,
      ease: 'Sine.easeOut'
    });
  }

  private addResultButton(label: string, x: number, y: number, width: number, action: () => void, depth: number) {
    const bg = this.trackResultObject(this.add.rectangle(x, y, width, 34, 0x1a2630, 0.96));
    bg.setDepth(depth);
    bg.setStrokeStyle(1, label === '다시 하기' ? palette.blue : palette.gold, 0.78);
    bg.setInteractive({ useHandCursor: true });
    bg.on('pointerover', () => bg.setFillStyle(0x283947, 0.98));
    bg.on('pointerout', () => bg.setFillStyle(0x1a2630, 0.96));
    bg.on('pointerdown', action);

    const text = this.trackResultObject(this.add.text(x, y, label, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '16px',
      color: '#f2e3bf',
      fontStyle: '700'
    }));
    text.setOrigin(0.5);
    text.setDepth(depth + 1);
    text.setInteractive({ useHandCursor: true });
    text.on('pointerdown', action);
  }

  private trackResultObject<T extends Phaser.GameObjects.GameObject>(object: T) {
    this.resultObjects.push(object);
    return object;
  }

  private attackableTargets(attacker: UnitData) {
    return this.damageableTargets(attacker, 'attack');
  }

  private damageableTargets(attacker: UnitData, action: DamageCommand, usable?: BattleUsable) {
    return battleUnits.filter((target) => this.canUseDamageCommand(attacker, target, action, usable));
  }

  private canAttack(attacker: UnitData, target: UnitData) {
    return this.canUseDamageCommand(attacker, target, 'attack');
  }

  private canUseDamageCommand(attacker: UnitData, target: UnitData, action: DamageCommand, usable?: BattleUsable) {
    if (attacker.faction === target.faction || target.hp <= 0) {
      return false;
    }

    return this.tileDistance(attacker, target) <= this.actionRange(attacker, action, usable);
  }

  private attackRange(unit: UnitData) {
    return attackRangeByClass[unit.classKey] ?? 1;
  }

  private actionRange(unit: UnitData, action: DamageCommand, usable?: BattleUsable) {
    if (usable) {
      return usable.range;
    }
    if (action === 'attack') {
      return this.attackRange(unit);
    }
    return unit.classKey === 'archer' ? 3 : 2;
  }

  private supportTargets(user: UnitData, usable: BattleUsable) {
    return battleUnits.filter((target) => this.canUseSupportCommand(user, target, usable));
  }

  private canUseSupportCommand(user: UnitData, target: UnitData, usable: BattleUsable) {
    if (target.hp <= 0) {
      return false;
    }
    if (usable.target === 'self' && target.id !== user.id) {
      return false;
    }
    if (usable.target === 'ally' && target.faction !== user.faction) {
      return false;
    }
    if (usable.target === 'enemy' && target.faction === user.faction) {
      return false;
    }
    if (usable.effect === 'heal' && target.hp >= target.maxHp) {
      return false;
    }
    return this.tileDistance(user, target) <= usable.range;
  }

  private combatPreview(attacker: UnitData, defender: UnitData, action: DamageCommand, usable?: BattleUsable): CombatPreview {
    const terrain = battleMap.terrain[defender.y][defender.x];
    const terrainRule = getTerrainRule(terrain);
    const attackPower = this.actionPower(attacker, action, usable);
    const defensePower = this.actionDefense(defender, action, terrainRule.defenseBonus);
    const bondBonus = attacker.faction === 'ally' ? this.strongestBondBonus(attacker.id) : { damageBonus: 0, chainRate: 0 };
    const damage = Phaser.Math.Clamp(Math.round((attackPower - defensePower) * (1 + bondBonus.damageBonus / 100)), 3, defender.maxHp);
    const defenderTerrainRating = getUnitClass(defender.classKey).terrainRatings[terrain] ?? 100;
    const terrainHitPenalty = Math.floor(terrainRule.defenseBonus / 2) + Math.max(0, Math.floor((defenderTerrainRating - 100) / 4));
    const hitRate = Phaser.Math.Clamp(
      88 +
        Math.floor((attacker.stats.agility - defender.stats.agility) / 3) +
        Math.floor((attacker.stats.luck - defender.stats.luck) / 8) -
        terrainHitPenalty +
        (usable?.accuracyBonus ?? 0) +
        this.hitBuff(attacker),
      45,
      98
    );
    const criticalRate = Phaser.Math.Clamp(
      5 +
        Math.floor((attacker.stats.luck - defender.stats.luck) / 7) +
        Math.floor((attacker.stats.might - defender.stats.leadership) / 12) +
        (action === 'attack' && getItem(attacker.equipment.weapon.itemId).rank === 'treasure' ? 2 : 0) +
        (usable?.criticalBonus ?? 0) +
        this.criticalBuff(attacker),
      2,
      28
    );

    return {
      action,
      usable,
      attacker,
      defender,
      damage,
      bondDamageBonus: bondBonus.damageBonus,
      bondLabel: bondBonus.label,
      hitRate,
      criticalRate,
      counterAvailable: this.canCounterAttack(defender, attacker, action),
      terrainLabel: terrainRule.label
    };
  }

  private resolveAttack(attacker: UnitData, defender: UnitData): CombatResult {
    return this.resolveCombatAction(attacker, defender, 'attack');
  }

  private statsFor(unitId: string) {
    const existing = this.battleStats.get(unitId);
    if (existing) {
      return existing;
    }

    const created = this.emptyBattleStats();
    this.battleStats.set(unitId, created);
    return created;
  }

  private recordCombatStats(attacker: UnitData, defender: UnitData, damage: number, defeated: boolean) {
    const attackerStats = this.statsFor(attacker.id);
    attackerStats.actions += 1;
    attackerStats.damageDealt += damage;
    if (defeated) {
      attackerStats.defeats += 1;
    }

    const defenderStats = this.statsFor(defender.id);
    defenderStats.damageTaken += damage;
  }

  private recordSupportStats(user: UnitData, amount: number) {
    const stats = this.statsFor(user.id);
    stats.actions += 1;
    stats.support += amount;
  }

  private resolveCombatAction(attacker: UnitData, defender: UnitData, action: DamageCommand, isCounter = false, usable?: BattleUsable): CombatResult {
    const preview = this.combatPreview(attacker, defender, action, usable);
    const previousDefenderHp = defender.hp;
    const hit = this.rollPercent(preview.hitRate);
    const critical = hit && this.rollPercent(preview.criticalRate);
    const damage = hit ? Phaser.Math.Clamp(Math.round(preview.damage * (critical ? 1.5 : 1)), 1, defender.hp) : 0;
    const attackerGrowth = this.awardEquipmentExp(attacker, action === 'attack' ? 'weapon' : 'accessory', this.attackerEquipmentExpGain(attacker, action));
    const armorGrowth = this.awardEquipmentExp(defender, 'armor', hit ? 6 : 3);
    const characterGrowth = this.awardCharacterExp(attacker, this.characterExpGain(preview, damage, hit, critical));
    const bondGrowth = attacker.faction === 'ally' && !isCounter ? this.recordBondAttackIntent(attacker, defender) : [];
    const bondExp = bondGrowth.reduce((total, growth) => total + growth.amount, 0);

    defender.hp = Math.max(0, defender.hp - damage);
    this.recordCombatStats(attacker, defender, damage, defender.hp <= 0 && previousDefenderHp > 0);
    this.faceUnitToward(attacker, defender);
    if (hit) {
      this.flashDamage(defender, damage, critical);
    } else {
      this.flashMiss(defender);
    }

    if (defender.hp <= 0) {
      this.applyDefeatedStyle(defender);
    } else {
      this.syncUnitMotion(defender);
    }
    this.updateMiniMap();

    return {
      ...preview,
      damage,
      previousDefenderHp,
      hit,
      critical,
      isCounter,
      defeated: defender.hp <= 0,
      characterGrowth,
      attackerGrowth,
      defenderGrowth: armorGrowth,
      bondExp,
      bondGrowth
    };
  }

  private resolveCounterAttack(result: CombatResult) {
    if (result.defender.hp <= 0) {
      return undefined;
    }
    if (!this.canCounterAttack(result.defender, result.attacker, result.action)) {
      return undefined;
    }

    return this.resolveCombatAction(result.defender, result.attacker, 'attack', true);
  }

  private canCounterAttack(defender: UnitData, attacker: UnitData, incomingAction: DamageCommand) {
    if (incomingAction !== 'attack' || defender.hp <= 0 || attacker.hp <= 0) {
      return false;
    }

    const distance = this.tileDistance(defender, attacker);
    if (defender.classKey === 'archer' && distance <= 1) {
      return false;
    }

    return this.canAttack(defender, attacker);
  }

  private rollPercent(rate: number) {
    return Phaser.Math.Between(1, 100) <= Phaser.Math.Clamp(rate, 0, 100);
  }

  private resolveSupportAction(user: UnitData, target: UnitData, usable: BattleUsable): SupportResult {
    const previousTargetHp = target.hp;
    const healAmount = usable.effect === 'heal' ? this.supportHealAmount(user, target, usable) : 0;
    let buff: BattleBuffState | undefined;

    if (healAmount > 0) {
      target.hp = Math.min(target.maxHp, target.hp + healAmount);
      this.syncUnitMotion(target);
      this.flashSupport(target, `+${healAmount}`, '#a8ffd0');
      this.updateMiniMap();
    }

    if (usable.effect === 'focus') {
      buff = {
        unitId: target.id,
        label: usable.name,
        turns: usable.duration ?? 1,
        attackBonus: usable.attackBonus ?? 0,
        hitBonus: usable.hitBonus ?? 0,
        criticalBonus: usable.criticalBonus ?? 0
      };
      this.battleBuffs.set(target.id, buff);
      this.flashSupport(target, usable.name, '#ffdf7b');
    }

    const equipmentGrowth = this.awardEquipmentExp(user, 'accessory', usable.command === 'strategy' ? 7 : 5);
    const characterGrowth = this.awardCharacterExp(user, usable.effect === 'heal' ? 8 : 6);
    this.recordSupportStats(user, healAmount);

    return {
      usable,
      user,
      target,
      previousTargetHp,
      healAmount,
      buff,
      characterGrowth,
      equipmentGrowth
    };
  }

  private supportHealAmount(user: UnitData, target: UnitData, usable: BattleUsable) {
    const bonus = usable.command === 'strategy' ? Math.floor(user.stats.intelligence / 12) : Math.floor(user.stats.luck / 14);
    return Math.min(target.maxHp - target.hp, Math.max(1, usable.power + bonus));
  }

  private actionPower(unit: UnitData, action: DamageCommand, usable?: BattleUsable) {
    if (usable?.effect === 'damage') {
      const statBonus = usable.command === 'strategy' ? Math.floor(unit.stats.intelligence / 5) : Math.floor(unit.stats.luck / 8);
      const equipmentBonus = usable.command === 'strategy' ? this.equipmentStrategyBonus(unit) : Math.floor(this.equipmentAttackBonus(unit) / 2);
      return usable.power + statBonus + equipmentBonus + this.attackBuff(unit);
    }
    if (action === 'strategy') {
      return 10 + Math.floor(unit.stats.intelligence / 5) + this.equipmentStrategyBonus(unit) + this.attackBuff(unit);
    }
    if (action === 'item') {
      return 9 + Math.floor(unit.stats.luck / 8) + Math.floor(this.equipmentAttackBonus(unit) / 2) + this.attackBuff(unit);
    }
    return unit.attack + this.equipmentAttackBonus(unit) + Math.floor(unit.stats.might / 12) + this.attackBuff(unit);
  }

  private actionDefense(unit: UnitData, action: DamageCommand, terrainDefenseBonus: number) {
    if (action === 'strategy') {
      return Math.floor(unit.stats.intelligence / 10) + Math.floor(terrainDefenseBonus / 4);
    }
    if (action === 'item') {
      return Math.floor(unit.stats.leadership / 22) + Math.floor(terrainDefenseBonus / 5);
    }
    return this.equipmentDefenseBonus(unit) + Math.floor(unit.stats.leadership / 18) + Math.floor(terrainDefenseBonus / 3);
  }

  private attackerEquipmentExpGain(unit: UnitData, action: DamageCommand) {
    if (action === 'attack') {
      return this.weaponExpGain(unit);
    }
    return 5 + (getItem(unit.equipment.accessory.itemId).rank === 'treasure' ? 2 : 0);
  }

  private characterExpGain(result: CombatPreview, damage: number, hit: boolean, critical: boolean) {
    if (!hit) {
      return 2;
    }

    const levelGap = result.defender.level - result.attacker.level;
    const actionBonus = result.action === 'attack' ? 10 : 8;
    const defeatBonus = damage >= result.defender.hp ? 12 : 0;
    const criticalBonus = critical ? 2 : 0;
    return Phaser.Math.Clamp(actionBonus + levelGap * 2 + defeatBonus + criticalBonus, 4, 30);
  }

  private formatCombatResult(result: CombatResult) {
    const outcome = this.formatCombatOutcome(result);
    const defeated = result.defeated ? `\n${result.defender.name} 퇴각!` : `\n${result.defender.name} HP ${result.defender.hp}/${result.defender.maxHp}`;
    const bondBonus = result.bondDamageBonus > 0 && result.bondLabel ? `\n공명 효과 ${result.bondLabel}: 피해 +${result.bondDamageBonus}%` : '';
    const bond = result.bondExp > 0 ? `\n공명 경험치 +${result.bondExp}` : '';
    const counter = result.counter ? `\n${this.formatCounterLine(result.counter)}` : '';
    return `${result.attacker.name} ${result.usable?.name ?? commandLabels[result.action]}\n${outcome}${defeated}${bondBonus}\n장수 경험치 +${result.characterGrowth.amount}\n${this.formatEquipmentGrowth(result.attackerGrowth)}\n${this.formatEquipmentGrowth(result.defenderGrowth)}${bond}${counter}`;
  }

  private formatCombatOutcome(result: CombatResult) {
    if (!result.hit) {
      return `${result.defender.name}이 공격을 회피했습니다.`;
    }

    const critical = result.critical ? '치명타! ' : '';
    return `${critical}${result.defender.name}에게 ${result.damage} 피해`;
  }

  private formatCounterLine(result: CombatResult) {
    const outcome = result.hit ? `${result.critical ? '치명타로 ' : ''}${result.damage} 피해` : '회피됨';
    const defeated = result.defeated ? ` / ${result.defender.name} 퇴각` : ` / ${result.defender.name} HP ${result.defender.hp}/${result.defender.maxHp}`;
    return `반격: ${result.attacker.name} -> ${result.defender.name} ${outcome}${defeated}`;
  }

  private combatGrowthLines(result: CombatResult) {
    const characterLevel = result.characterGrowth.leveled ? ` / Lv ${result.characterGrowth.level}` : '';
    const verdict = result.hit ? `${result.critical ? '치명타 / ' : ''}명중 ${result.hitRate}%` : `회피됨 / 명중 ${result.hitRate}%`;
    const lines = [
      verdict,
      `장수 경험치 +${result.characterGrowth.amount} (${result.characterGrowth.exp}/${result.characterGrowth.next})${characterLevel}`,
      this.formatEquipmentGrowth(result.attackerGrowth),
      this.formatEquipmentGrowth(result.defenderGrowth)
    ];

    if (result.bondExp > 0) {
      lines.push(`공명 경험치 +${result.bondExp}`);
    }

    return lines;
  }

  private formatSupportResult(result: SupportResult) {
    const effect =
      result.usable.effect === 'heal'
        ? `${result.target.name} 병력 +${result.healAmount} (${result.target.hp}/${result.target.maxHp})`
        : `${result.target.name} 강화: 공격 +${result.buff?.attackBonus ?? 0} / 명중 +${result.buff?.hitBonus ?? 0} / 치명 +${result.buff?.criticalBonus ?? 0}`;
    return `${result.user.name} ${result.usable.name}\n${effect}\n장수 경험치 +${result.characterGrowth.amount}\n${this.formatEquipmentGrowth(result.equipmentGrowth)}`;
  }

  private commandResultMessage(unit: UnitData, command: BattleCommand) {
    if (command === 'attack') {
      return this.recordAttackIntent(unit);
    }
    if (command === 'strategy') {
      return `${unit.name} 책략 명령 완료\n책략 목록과 MP는 다음 전투 확장 때 붙이겠습니다.`;
    }
    if (command === 'item') {
      return `${unit.name} 도구 명령 완료\n도구 가방은 이후 회복 아이템과 함께 열겠습니다.`;
    }
    return `${unit.name} 대기 명령 완료`;
  }

  private remainingAllyCount() {
    return battleUnits.filter((unit) => unit.faction === 'ally' && unit.hp > 0 && !this.actedUnitIds.has(unit.id)).length;
  }

  private createBondStates() {
    return new Map(
      battleBonds.map((bond) => [
        bond.id,
        {
          ...bond,
          battleExp: 0
        }
      ])
    );
  }

  private createItemStocks() {
    return new Map(
      Object.entries(initialItemStocks).map(([unitId, stocks]) => [
        unitId,
        new Map(Object.entries(stocks))
      ])
    );
  }

  private createEnemyHomeTiles() {
    return new Map(
      battleUnits
        .filter((unit) => unit.faction === 'enemy')
        .map((unit) => [unit.id, { x: unit.x, y: unit.y }] as const)
    );
  }

  private createBattleStats() {
    return new Map(battleUnits.map((unit) => [unit.id, this.emptyBattleStats()] as const));
  }

  private emptyBattleStats(): UnitBattleStats {
    return {
      damageDealt: 0,
      damageTaken: 0,
      defeats: 0,
      actions: 0,
      support: 0
    };
  }

  private showOpeningBattleEvent() {
    this.triggerBattleEvent('opening', '전투 목표', battleScenario.openingObjectiveLines);
  }

  private triggerBattleEvent(key: string, title: string, lines: string[]) {
    if (this.triggeredBattleEvents.has(key) || this.battleOutcome) {
      return;
    }

    this.triggeredBattleEvents.add(key);
    const message = `${title}\n${lines.join('\n')}`;
    this.pushBattleLog(message);
    this.renderSituationPanel(message);
    this.showBattleEventBanner(title, lines);
  }

  private showBattleEventBanner(title: string, lines: string[]) {
    this.hideBattleEventBanner();

    const width = 520;
    const height = 106;
    const left = this.layout.gridX + Math.max(16, (this.layout.gridWidth - width) / 2);
    const top = this.layout.gridY + 18;
    const depth = 45;
    const panel = this.add.rectangle(left, top, width, height, 0x101821, 0.94);
    panel.setOrigin(0);
    panel.setDepth(depth);
    panel.setStrokeStyle(2, palette.gold, 0.86);
    this.battleEventObjects.push(panel);

    const titleText = this.add.text(left + 20, top + 14, title, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '21px',
      color: '#f4dfad',
      fontStyle: '700',
      stroke: '#05070a',
      strokeThickness: 3
    });
    titleText.setDepth(depth + 1);
    this.battleEventObjects.push(titleText);

    const body = this.add.text(left + 20, top + 45, lines.join('\n'), {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '14px',
      color: '#d4dce6',
      lineSpacing: 4,
      wordWrap: { width: width - 40, useAdvancedWrap: true }
    });
    body.setDepth(depth + 1);
    this.battleEventObjects.push(body);

    const eventObjects = [...this.battleEventObjects];
    this.tweens.add({
      targets: eventObjects,
      alpha: 0,
      delay: 2600,
      duration: 420,
      ease: 'Sine.easeIn',
      onComplete: () => {
        eventObjects.forEach((object) => object.destroy());
        this.battleEventObjects = this.battleEventObjects.filter((object) => !eventObjects.includes(object));
      }
    });
  }

  private hideBattleEventBanner() {
    this.battleEventObjects.forEach((object) => object.destroy());
    this.battleEventObjects = [];
  }

  private checkBattleEvents() {
    const leader = battleUnits.find((unit) => unit.id === leaderUnitId);
    if (leader && leader.hp > 0 && leader.hp <= Math.ceil(leader.maxHp / 2)) {
      this.triggerBattleEvent('leader-wavering', '두령 동요', [`${leader.name}의 기세가 꺾였습니다.`, '두령을 몰아붙이면 황건적의 전열이 무너집니다.']);
    }

    const endangeredAlly = this.endangeredAlly();
    if (endangeredAlly) {
      this.triggerBattleEvent('ally-danger', '아군 위기', [
        `${endangeredAlly.name}의 병력이 크게 줄었습니다.`,
        '유비가 퇴각하면 패배합니다. 마을과 성채 지형을 이용해 전열을 다시 잡으세요.'
      ]);
    }

    if (this.isAnyAllyNearTerrain('village', 2)) {
      this.triggerBattleEvent('village-approach', '마을 어귀', [
        '마을 주변에 황건적이 몰려 있습니다.',
        '마을의 적을 몰아내면 승리 보상이 추가됩니다.'
      ]);
    }

    if (!this.hasEnemyThreateningVillage()) {
      this.triggerBattleEvent('village-secured', '마을 확보', ['마을 주변의 황건적을 몰아냈습니다.', '승리 시 마을 확보 보상이 추가됩니다.']);
    }

    if (this.areUnitsDefeated(['rebel-a', 'rebel-b', 'rebel-c'])) {
      this.triggerBattleEvent('vanguard-broken', '선봉 격파', [
        '남쪽 길목의 황건 선봉이 무너졌습니다.',
        '이제 중앙 마을과 기병의 움직임을 경계하세요.'
      ]);
    }

    if (this.isEnemyClassNearAlly('cavalry', 6)) {
      this.triggerBattleEvent('cavalry-approach', '기병 접근', [
        '황건 기병이 빠르게 압박해옵니다.',
        '위험 범위를 켜고 숲이나 마을 쪽으로 전선을 잡으세요.'
      ]);
    }

    if (this.turnNumber === 2 && this.activeFaction === 'ally') {
      this.triggerBattleEvent('enemy-posture', '적 전술 변화', ['기병은 계속 접근하고, 궁병은 자리를 지키며 사격합니다.', '숲과 마을 지형을 이용해 피해를 줄이세요.']);
    }
  }

  private hasEnemyThreateningVillage() {
    return this.hasEnemyThreateningTerrain('village', 2);
  }

  private hasEnemyThreateningTerrain(terrain: TerrainType, distance: number) {
    return battleUnits.some((unit) => {
      return unit.faction === 'enemy' && unit.hp > 0 && this.isUnitNearTerrain(unit, terrain, distance);
    });
  }

  private hasEnemyOnTerrain(terrain: TerrainType) {
    return battleUnits.some((unit) => {
      return unit.faction === 'enemy' && unit.hp > 0 && battleMap.terrain[unit.y][unit.x] === terrain;
    });
  }

  private isAnyAllyNearTerrain(terrain: TerrainType, distance: number) {
    return battleUnits.some((unit) => {
      return unit.faction === 'ally' && unit.hp > 0 && this.isUnitNearTerrain(unit, terrain, distance);
    });
  }

  private isUnitNearTerrain(unit: UnitData, terrain: TerrainType, distance: number) {
    return battleMap.terrain.some((row, y) => {
      return row.some((tile, x) => tile === terrain && this.tileDistanceTo(unit.x, unit.y, x, y) <= distance);
    });
  }

  private areUnitsDefeated(unitIds: string[]) {
    return unitIds.every((unitId) => this.isUnitDefeated(unitId));
  }

  private isEnemyClassNearAlly(classKey: UnitClassKey, distance: number) {
    return battleUnits.some((enemy) => {
      if (enemy.faction !== 'enemy' || enemy.hp <= 0 || enemy.classKey !== classKey) {
        return false;
      }

      return battleUnits.some((ally) => {
        return ally.faction === 'ally' && ally.hp > 0 && this.tileDistance(enemy, ally) <= distance;
      });
    });
  }

  private endangeredAlly() {
    return battleUnits.find((unit) => {
      return unit.faction === 'ally' && unit.hp > 0 && unit.hp <= Math.ceil(unit.maxHp * 0.35);
    });
  }

  private tileDistanceTo(x1: number, y1: number, x2: number, y2: number) {
    return Math.abs(x1 - x2) + Math.abs(y1 - y2);
  }

  private isUnitAlive(unitId: string) {
    const unit = battleUnits.find((candidate) => candidate.id === unitId);
    return Boolean(unit && unit.hp > 0);
  }

  private isUnitDefeated(unitId: string) {
    return !this.isUnitAlive(unitId);
  }

  private handleRightClick(pointer: Phaser.Input.Pointer) {
    if (this.battleOutcome) {
      return;
    }

    if (this.phase === 'animating') {
      return;
    }

    if (this.cancelAttackTargeting()) {
      return;
    }

    if (this.cancelPendingMove()) {
      return;
    }

    if (this.isPointerOnEmptyMapTile(pointer)) {
      this.showMapMenu(pointer.x, pointer.y);
      return;
    }

    this.hideMapMenu();
  }

  private handleLeftClick(pointer: Phaser.Input.Pointer) {
    if (this.battleOutcome) {
      return;
    }

    if (this.suppressNextLeftClick) {
      this.suppressNextLeftClick = false;
      return;
    }

    const mapMenuAction = this.mapMenuActionAt(pointer.x, pointer.y);
    if (mapMenuAction) {
      this.runMapMenuAction(mapMenuAction);
      return;
    }

    if (this.phase !== 'idle' || !this.isPointerOnEmptyMapTile(pointer)) {
      return;
    }

    const tile = this.pointerToTile(pointer);
    if (!tile) {
      return;
    }

    this.selectedUnit = undefined;
    this.pendingMove = undefined;
    this.targetingAction = undefined;
    this.selectedUsable = undefined;
    this.clearMarkers();
    this.hideCommandMenu();
    this.hideTurnEndPrompt();
    this.hideMapMenu();
    this.renderTerrainDetail(tile.x, tile.y);
  }

  private isPointerOnEmptyMapTile(pointer: Phaser.Input.Pointer) {
    const tile = this.pointerToTile(pointer);
    if (!tile) {
      return false;
    }

    return !this.isOccupied(tile.x, tile.y);
  }

  private pointerToTile(pointer: Phaser.Input.Pointer) {
    const { gridX, gridY, gridWidth, gridHeight, tileSize } = this.layout;
    if (pointer.x < gridX || pointer.y < gridY || pointer.x >= gridX + gridWidth || pointer.y >= gridY + gridHeight) {
      return undefined;
    }

    const x = this.cameraTileX + Math.floor((pointer.x - gridX) / tileSize);
    const y = this.cameraTileY + Math.floor((pointer.y - gridY) / tileSize);
    return this.isInBounds(x, y) ? { x, y } : undefined;
  }

  private showMapMenu(pointerX: number, pointerY: number) {
    if (this.phase === 'command' || this.phase === 'targeting' || this.phase === 'animating') {
      return;
    }

    this.hideMapMenu();
    this.hideTurnEndPrompt();
    this.clearMarkers();
    this.selectedUnit = undefined;
    this.pendingMove = undefined;
    this.phase = 'idle';

    const actions: MapMenuAction[] = ['endTurn', 'threat', 'save', 'load', 'roster', 'bond', 'situation', 'bgm', 'close'];
    const menuWidth = 148;
    const buttonHeight = 34;
    const padding = 8;
    const menuHeight = padding * 2 + actions.length * buttonHeight;
    const left = this.layout.mapX + 84;
    const top = Phaser.Math.Clamp(pointerY - menuHeight / 2, this.layout.mapY + 16, this.layout.mapY + this.layout.mapHeight - menuHeight - 16);
    this.mapMenuLayout = { left, top, width: menuWidth, buttonHeight, padding, actions };

    const panel = this.add.rectangle(left, top, menuWidth, menuHeight, 0x101821, 0.97);
    panel.setOrigin(0);
    panel.setDepth(20);
    panel.setStrokeStyle(2, palette.gold, 0.86);
    this.mapMenuObjects.push(panel);

    actions.forEach((action, index) => {
      const y = top + padding + index * buttonHeight;
      const disabled = this.isMapMenuActionDisabled(action);
      const bg = this.add.rectangle(left + 8, y, menuWidth - 16, buttonHeight - 4, disabled ? 0x121820 : 0x1a2630, disabled ? 0.72 : 0.94);
      bg.setOrigin(0);
      bg.setDepth(21);
      bg.setStrokeStyle(1, action === 'endTurn' ? 0xd8b15f : 0x5a7588, disabled ? 0.32 : 0.68);
      this.mapMenuObjects.push(bg);

      const text = this.add.text(left + menuWidth / 2, y + buttonHeight / 2 - 2, this.mapMenuLabel(action), {
        fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
        fontSize: '17px',
        color: disabled ? '#737b84' : '#f2e3bf',
        fontStyle: '700'
      });
      text.setOrigin(0.5);
      text.setDepth(22);
      this.mapMenuObjects.push(text);

      if (!disabled) {
        const run = () => {
          this.suppressNextLeftClick = true;
          this.runMapMenuAction(action);
        };
        bg.setInteractive({ useHandCursor: true });
        bg.on('pointerover', () => bg.setFillStyle(0x283947, 0.98));
        bg.on('pointerout', () => bg.setFillStyle(0x1a2630, 0.94));
        bg.on('pointerdown', run);
        text.setInteractive({ useHandCursor: true });
        text.on('pointerdown', run);
      }
    });
  }

  private runMapMenuAction(action: MapMenuAction) {
    this.hideMapMenu();
    soundDirector.playSelect();

    if (action === 'endTurn') {
      this.endAllyTurn();
      return;
    }
    if (action === 'threat') {
      this.showEnemyThreatRange();
      return;
    }
    if (action === 'save') {
      this.showSaveSlotPanel('save');
      return;
    }
    if (action === 'load') {
      this.showSaveSlotPanel('load');
      return;
    }
    if (action === 'roster') {
      this.renderRosterPanel('ally', '부대 일람입니다.');
      return;
    }
    if (action === 'bond') {
      this.renderBondPanel();
      return;
    }
    if (action === 'situation') {
      this.renderSituationPanel();
      return;
    }
    if (action === 'bgm') {
      soundDirector.setMuted(!soundDirector.isMuted());
      this.renderSituationPanel(`BGM을 ${soundDirector.isMuted() ? '껐습니다' : '켰습니다'}.`);
    }
  }

  private hideMapMenu() {
    this.mapMenuObjects.forEach((object) => object.destroy());
    this.mapMenuObjects = [];
    this.mapMenuLayout = undefined;
  }

  private mapMenuActionAt(x: number, y: number) {
    if (!this.mapMenuLayout) {
      return undefined;
    }

    const { left, top, width, buttonHeight, padding, actions } = this.mapMenuLayout;
    if (x < left || x > left + width || y < top + padding || y > top + padding + actions.length * buttonHeight) {
      return undefined;
    }

    const index = Math.floor((y - top - padding) / buttonHeight);
    const action = actions[index];
    return action && !this.isMapMenuActionDisabled(action) ? action : undefined;
  }

  private isMapMenuActionDisabled(action: MapMenuAction) {
    return (
      (action === 'endTurn' && this.activeFaction !== 'ally') ||
      (action === 'threat' && this.activeFaction !== 'ally') ||
      ((action === 'save' || action === 'load') && this.activeFaction !== 'ally') ||
      (action === 'load' && !this.hasBattleSave())
    );
  }

  private mapMenuLabel(action: MapMenuAction) {
    if (action === 'save') {
      return '저장';
    }
    if (action === 'load') {
      return '불러오기';
    }
    return mapMenuLabels[action] ?? action;
  }

  private hasBattleSave() {
    return Array.from({ length: campaignSaveSlotCount }, (_, index) => index + 1).some((slot) => Boolean(this.readBattleSaveState(slot)));
  }

  private showSaveSlotPanel(mode: SaveSlotMode) {
    this.hideSaveSlotPanel();
    const depth = 70;
    const width = 520;
    const height = 312;
    const left = this.layout.mapX + 118;
    const top = this.layout.mapY + 82;
    const campaignSlots = listCampaignSaveSlots();

    const panel = this.add.rectangle(left, top, width, height, 0x0f1720, 0.98);
    panel.setOrigin(0);
    panel.setDepth(depth);
    panel.setStrokeStyle(2, mode === 'save' ? palette.gold : palette.blue, 0.9);
    panel.setInteractive();
    this.saveSlotPanelObjects.push(panel);

    const title = this.add.text(left + 22, top + 18, mode === 'save' ? '전투 저장 슬롯' : '전투 불러오기 슬롯', {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '22px',
      color: '#f2e3bf',
      fontStyle: '700'
    });
    title.setDepth(depth + 1);
    this.saveSlotPanelObjects.push(title);

    const guide = this.add.text(
      left + 22,
      top + 50,
      mode === 'save' ? '현재 전투와 캠페인 진행 상태를 함께 저장합니다.' : '저장된 전투 상태와 캠페인 슬롯을 함께 불러옵니다.',
      {
        fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
        fontSize: '13px',
        color: '#aeb7c2'
      }
    );
    guide.setDepth(depth + 1);
    this.saveSlotPanelObjects.push(guide);

    for (let slot = 1; slot <= campaignSaveSlotCount; slot += 1) {
      const rowTop = top + 82 + (slot - 1) * 62;
      const battleSave = this.readBattleSaveState(slot);
      const campaignSlot = campaignSlots.find((candidate) => candidate.slot === slot);
      const disabled = mode === 'load' && !battleSave;
      const row = this.add.rectangle(left + 18, rowTop, width - 36, 50, disabled ? 0x121820 : 0x172230, disabled ? 0.76 : 0.94);
      row.setOrigin(0);
      row.setDepth(depth + 1);
      row.setStrokeStyle(1, disabled ? 0x53606c : slot === getCampaignState().activeSaveSlot ? palette.gold : palette.blue, disabled ? 0.34 : 0.72);
      this.saveSlotPanelObjects.push(row);

      const titleText = this.add.text(left + 34, rowTop + 8, `슬롯 ${slot}`, {
        fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
        fontSize: '16px',
        color: disabled ? '#78818a' : '#f2e3bf',
        fontStyle: '700'
      });
      titleText.setDepth(depth + 2);
      this.saveSlotPanelObjects.push(titleText);

      const battleSummary = battleSave
        ? `${this.formatSavedAt(battleSave.savedAt)} · ${battleSave.turnNumber}턴 · ${factionLabels[battleSave.activeFaction]}`
        : '전투 저장 없음';
      const campaignSummary = campaignSlot?.exists
        ? `군자금 ${campaignSlot.gold ?? 0} · ${campaignSlot.battleTitle ?? '진행 중'}`
        : '캠페인 저장 없음';
      const detail = this.add.text(left + 104, rowTop + 9, `${battleSummary}\n${campaignSummary}`, {
        fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
        fontSize: '12px',
        color: disabled ? '#6d7580' : '#d4dce6',
        lineSpacing: 3
      });
      detail.setDepth(depth + 2);
      this.saveSlotPanelObjects.push(detail);

      if (!disabled) {
        const run = () => {
          soundDirector.playSelect();
          if (mode === 'save') {
            this.saveBattleState(slot);
          } else {
            this.loadBattleState(slot);
          }
          this.hideSaveSlotPanel();
        };
        row.setInteractive({ useHandCursor: true });
        row.on('pointerover', () => row.setFillStyle(0x263647, 0.98));
        row.on('pointerout', () => row.setFillStyle(0x172230, 0.94));
        row.on('pointerdown', run);
        titleText.setInteractive({ useHandCursor: true });
        titleText.on('pointerdown', run);
        detail.setInteractive({ useHandCursor: true });
        detail.on('pointerdown', run);
      }
    }

    const close = this.add.text(left + width - 52, top + 20, '닫기', {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '14px',
      color: '#d8b15f',
      fontStyle: '700'
    });
    close.setDepth(depth + 2);
    close.setInteractive({ useHandCursor: true });
    close.on('pointerdown', () => {
      soundDirector.playSelect();
      this.hideSaveSlotPanel();
    });
    this.saveSlotPanelObjects.push(close);
  }

  private hideSaveSlotPanel() {
    this.saveSlotPanelObjects.forEach((object) => object.destroy());
    this.saveSlotPanelObjects = [];
  }

  private battleSaveStorageKeyForSlot(slot: number) {
    return `${battleSaveStorageKey}:slot-${Phaser.Math.Clamp(Math.floor(slot), 1, campaignSaveSlotCount)}`;
  }

  private readBattleSaveState(slot: number) {
    const normalizedSlot = Phaser.Math.Clamp(Math.floor(slot), 1, campaignSaveSlotCount);
    const raw =
      window.localStorage.getItem(this.battleSaveStorageKeyForSlot(normalizedSlot)) ??
      (normalizedSlot === 1 ? window.localStorage.getItem(battleSaveStorageKey) ?? window.localStorage.getItem(legacyBattleSaveStorageKey) : undefined);
    if (!raw) {
      return undefined;
    }

    try {
      const state = JSON.parse(raw) as BattleSaveState;
      if (!state || state.version !== 1 || !Array.isArray(state.units)) {
        return undefined;
      }
      if (state.battleId && state.battleId !== battleScenario.id) {
        return undefined;
      }
      return state;
    } catch {
      return undefined;
    }
  }

  private saveBattleState(slot = 1) {
    try {
      const state = this.createBattleSaveState();
      window.localStorage.setItem(this.battleSaveStorageKeyForSlot(slot), JSON.stringify(state));
      if (slot === 1) {
        window.localStorage.setItem(battleSaveStorageKey, JSON.stringify(state));
        window.localStorage.removeItem(legacyBattleSaveStorageKey);
      }
      saveCampaignState(getCampaignState(), slot);
      this.renderSituationPanel(`슬롯 ${slot}에 전투 상태를 저장했습니다.\n${this.formatSavedAt(state.savedAt)}`);
    } catch {
      this.renderSituationPanel('전투 상태를 저장하지 못했습니다.');
    }
  }

  private loadBattleState(slot = 1) {
    const state = this.readBattleSaveState(slot);
    if (!state) {
      this.renderSituationPanel('불러올 저장 데이터가 없습니다.');
      return;
    }

    try {
      loadCampaignState(slot);
      this.applyBattleSaveState(state);
      this.renderSituationPanel(`슬롯 ${slot}의 전투를 불러왔습니다.\n${this.formatSavedAt(state.savedAt)}`);
    } catch {
      this.renderSituationPanel('저장 데이터를 불러오지 못했습니다.');
    }
  }

  private createBattleSaveState(): BattleSaveState {
    return {
      version: 1,
      battleId: battleScenario.id,
      campaignStep: getCampaignState().step,
      savedAt: new Date().toISOString(),
      turnNumber: this.turnNumber,
      activeFaction: this.activeFaction,
      rosterTab: this.rosterTab,
      actedUnitIds: Array.from(this.actedUnitIds),
      attackIntents: this.attackIntents.map((intent) => ({ ...intent })),
      battleLog: [...this.battleLog],
      units: battleUnits.map((unit) => ({
        id: unit.id,
        level: unit.level,
        exp: unit.exp,
        hp: unit.hp,
        maxHp: unit.maxHp,
        attack: unit.attack,
        move: unit.move,
        x: unit.x,
        y: unit.y,
        direction: this.unitViews.get(unit.id)?.direction,
        equipment: this.cloneEquipment(unit.equipment)
      })),
      bonds: Array.from(this.bondStates.values()).map((bond) => ({
        ...bond,
        unitIds: [...bond.unitIds] as [string, string]
      })),
      itemStocks: this.serializeItemStocks(),
      battleBuffs: Array.from(this.battleBuffs.values()).map((buff) => ({ ...buff })),
      battleStats: this.serializeBattleStats(),
      triggeredBattleEvents: Array.from(this.triggeredBattleEvents)
    };
  }

  private applyBattleSaveState(state: BattleSaveState) {
    this.clearMarkers();
    this.hideCommandMenu();
    this.hideTurnEndPrompt();
    this.hideCombatCutIn();
    this.hideBattleResult();
    this.selectedUnit = undefined;
    this.pendingMove = undefined;
    this.targetingAction = undefined;
    this.battleOutcome = undefined;
    this.phase = 'idle';

    this.turnNumber = Math.max(1, state.turnNumber || 1);
    this.activeFaction = state.activeFaction === 'enemy' ? 'enemy' : 'ally';
    this.rosterTab = state.rosterTab === 'enemy' ? 'enemy' : 'ally';
    this.actedUnitIds = new Set(state.actedUnitIds ?? []);
    this.attackIntents = (state.attackIntents ?? []).map((intent) => ({ ...intent }));
    this.battleLog = [...(state.battleLog ?? [])];
    this.bondStates = new Map(
      (state.bonds ?? []).map((bond) => [
        bond.id,
        {
          ...bond,
          unitIds: [...bond.unitIds] as [string, string],
          battleExp: bond.battleExp ?? 0
        }
      ])
    );
    this.itemStocks = this.deserializeItemStocks(state.itemStocks);
    this.battleBuffs = new Map((state.battleBuffs ?? []).map((buff) => [buff.unitId, { ...buff }]));
    this.battleStats = this.deserializeBattleStats(state.battleStats);
    this.triggeredBattleEvents = new Set(state.triggeredBattleEvents ?? []);

    state.units.forEach((savedUnit) => {
      const unit = battleUnits.find((candidate) => candidate.id === savedUnit.id);
      if (!unit) {
        return;
      }

      unit.level = savedUnit.level;
      unit.exp = savedUnit.exp;
      unit.hp = Phaser.Math.Clamp(savedUnit.hp, 0, savedUnit.maxHp);
      unit.maxHp = savedUnit.maxHp;
      unit.attack = savedUnit.attack;
      unit.move = savedUnit.move;
      unit.x = Phaser.Math.Clamp(savedUnit.x, 0, battleMap.width - 1);
      unit.y = Phaser.Math.Clamp(savedUnit.y, 0, battleMap.height - 1);
      unit.equipment = this.cloneEquipment(savedUnit.equipment);
      this.restoreUnitView(unit, savedUnit.direction);
    });

    const cameraFocus = battleUnits.find((unit) => unit.faction === 'ally' && unit.hp > 0) ?? battleUnits[0];
    if (cameraFocus) {
      this.centerCameraOnTile(cameraFocus.x, cameraFocus.y);
    } else {
      this.updateCameraView();
    }
    this.resetActedStyles();
    this.updateTurnText();
    this.updateObjectiveTracker();
    this.renderRosterPanel(this.rosterTab, '저장된 전투 상태입니다.');
  }

  private restoreUnitView(unit: UnitData, direction: UnitDirection = 'south') {
    const view = this.unitViews.get(unit.id);
    if (!view) {
      return;
    }

    this.tweens.killTweensOf([view.sprite, view.label]);
    view.direction = direction;
    view.sprite.setPosition(this.tileCenterX(unit.x), this.tileCenterY(unit.y));
    view.label.setPosition(view.sprite.x, view.sprite.y + this.layout.tileSize * 0.52);
    if (unit.hp > 0) {
      view.sprite.setInteractive({ useHandCursor: true });
    }
  }

  private cloneEquipment(equipment: UnitData['equipment']) {
    return JSON.parse(JSON.stringify(equipment)) as UnitData['equipment'];
  }

  private serializeItemStocks() {
    return Object.fromEntries(
      Array.from(this.itemStocks.entries()).map(([unitId, stocks]) => [unitId, Object.fromEntries(stocks.entries())])
    );
  }

  private deserializeItemStocks(savedStocks?: Record<string, Record<string, number>>) {
    if (!savedStocks) {
      return this.createItemStocks();
    }

    return new Map(
      Object.entries(savedStocks).map(([unitId, stocks]) => [
        unitId,
        new Map(Object.entries(stocks).map(([itemId, count]) => [itemId, Math.max(0, count)]))
      ])
    );
  }

  private serializeBattleStats() {
    return Object.fromEntries(Array.from(this.battleStats.entries()).map(([unitId, stats]) => [unitId, { ...stats }]));
  }

  private deserializeBattleStats(savedStats?: Record<string, UnitBattleStats>) {
    const stats = this.createBattleStats();
    if (!savedStats) {
      return stats;
    }

    Object.entries(savedStats).forEach(([unitId, value]) => {
      stats.set(unitId, {
        damageDealt: value.damageDealt ?? 0,
        damageTaken: value.damageTaken ?? 0,
        defeats: value.defeats ?? 0,
        actions: value.actions ?? 0,
        support: value.support ?? 0
      });
    });
    return stats;
  }

  private formatSavedAt(savedAt: string) {
    return new Date(savedAt).toLocaleString('ko-KR');
  }

  private endAllyTurn() {
    if (this.activeFaction !== 'ally') {
      this.renderSituationPanel('이미 적군 행동 중입니다.');
      return;
    }
    if (this.battleOutcome || this.resolveBattleOutcomeIfNeeded()) {
      return;
    }
    if (this.phase === 'command' || this.phase === 'targeting' || this.phase === 'animating') {
      this.setInfo('현재 장수의 행동을 먼저 결정하세요.');
      return;
    }

    this.selectedUnit = undefined;
    this.pendingMove = undefined;
    this.phase = 'idle';
    this.clearMarkers();
    this.hideCommandMenu();
    this.hideTurnEndPrompt();

    this.activeFaction = 'enemy';
    this.updateTurnText();
    this.updateObjectiveTracker();
    const recoveryMessage = this.applyTerrainRecovery('enemy');
    this.renderSituationPanel(['아군 턴을 종료했습니다.', recoveryMessage, '적군이 행동을 시작합니다.'].filter(Boolean).join('\n'));
    void this.runEnemyTurn();
  }

  private async runEnemyTurn() {
    if (this.battleOutcome) {
      return;
    }

    const enemies = battleUnits.filter((unit) => unit.faction === 'enemy' && unit.hp > 0);
    const messages: string[] = [];

    for (const enemy of enemies) {
      if (this.battleOutcome) {
        return;
      }

      const message = await this.executeEnemyAction(enemy);
      if (message) {
        messages.push(message);
        this.pushBattleLog(message);
        this.updateObjectiveTracker();
        this.renderSituationPanel(messages.slice(-3).join('\n'));
      }

      if (this.resolveBattleOutcomeIfNeeded()) {
        return;
      }

      await this.delay(260);
    }

    if (this.resolveBattleOutcomeIfNeeded()) {
      return;
    }

    this.time.delayedCall(520, () => this.beginNextAllyTurn());
  }

  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) {
      return `${enemy.name}: 대기`;
    }

    if (!this.canAttack(enemy, target) && behavior !== 'hold') {
      const destination = this.chooseApproachTile(enemy, target);
      if (destination && (destination.x !== enemy.x || destination.y !== enemy.y)) {
        enemy.x = destination.x;
        enemy.y = destination.y;
        this.centerCameraOnTile(destination.x, destination.y);
        await this.moveUnitViewAsync(enemy, destination.x, destination.y, this.unitViews.get(enemy.id), 260);
      }
    }

    target = this.chooseTargetInRange(enemy) ?? target;
    if (this.canAttack(enemy, target)) {
      this.centerCameraOnTile(Math.floor((enemy.x + target.x) / 2), Math.floor((enemy.y + target.y) / 2));
      const result = this.resolveAttack(enemy, target);
      await this.playCombatCutIn(result);
      result.counter = this.resolveCounterAttack(result);
      if (result.counter) {
        await this.delay(180);
        await this.playCombatCutIn(result.counter);
      }
      return this.formatEnemyCombatResult(result, behavior);
    }

    return `${enemy.name}: ${this.enemyBehaviorLabel(behavior)} 태세로 접근`;
  }

  private chooseEnemyTarget(enemy: UnitData, behavior: EnemyAiBehavior) {
    if (behavior === 'hold') {
      return this.chooseTargetInRange(enemy);
    }

    const nearest = this.nearestAliveUnit(enemy, 'ally');
    if (!nearest) {
      return undefined;
    }

    if (behavior === 'guard') {
      const home = this.enemyHomeTiles.get(enemy.id) ?? { x: enemy.x, y: enemy.y };
      const distanceFromHome = Math.abs(nearest.x - home.x) + Math.abs(nearest.y - home.y);
      if (distanceFromHome > guardDetectionRange && !this.canAttack(enemy, nearest)) {
        return undefined;
      }
    }

    return nearest;
  }

  private chooseTargetInRange(enemy: UnitData) {
    return battleUnits
      .filter((unit) => unit.faction === 'ally' && unit.hp > 0 && this.canAttack(enemy, unit))
      .sort((a, b) => a.hp - b.hp || this.tileDistance(enemy, a) - this.tileDistance(enemy, b))[0];
  }

  private chooseApproachTile(enemy: UnitData, target: UnitData) {
    const candidates = [{ x: enemy.x, y: enemy.y, cost: 0 }, ...this.reachableTiles(enemy)];
    return candidates
      .filter((tile) => !this.isOccupied(tile.x, tile.y, enemy.id))
      .sort((a, b) => {
        const aDistance = Math.abs(a.x - target.x) + Math.abs(a.y - target.y);
        const bDistance = Math.abs(b.x - target.x) + Math.abs(b.y - target.y);
        const aTerrain = getTerrainRule(battleMap.terrain[a.y][a.x]).defenseBonus;
        const bTerrain = getTerrainRule(battleMap.terrain[b.y][b.x]).defenseBonus;
        return aDistance - bDistance || bTerrain - aTerrain || a.cost - b.cost;
      })[0];
  }

  private nearestAliveUnit(unit: UnitData, faction: UnitData['faction']) {
    return battleUnits
      .filter((candidate) => candidate.faction === faction && candidate.hp > 0)
      .sort((a, b) => this.tileDistance(unit, a) - this.tileDistance(unit, b))[0];
  }

  private enemyBehavior(enemy: UnitData): EnemyAiBehavior {
    return enemyAiByUnitId[enemy.id] ?? defaultEnemyAiByClass[enemy.classKey] ?? 'guard';
  }

  private enemyBehaviorLabel(behavior: EnemyAiBehavior) {
    if (behavior === 'aggressive') {
      return '추격';
    }
    if (behavior === 'hold') {
      return '고수';
    }
    return '경계';
  }

  private enemyDetailMessage(enemy: UnitData) {
    const behavior = this.enemyBehavior(enemy);
    const nearest = this.nearestAliveUnit(enemy, 'ally');
    const distance = nearest ? Math.abs(enemy.x - nearest.x) + Math.abs(enemy.y - nearest.y) : undefined;
    const distanceText = nearest ? `\n가장 가까운 아군: ${nearest.name} ${distance}칸` : '';
    return [
      `${this.enemyBehaviorLabel(behavior)} 태세 · 이동 ${enemy.move} · 사거리 ${this.attackRange(enemy)}`,
      this.enemyBehaviorDescription(behavior),
      `공격력 ${this.actionPower(enemy, 'attack')} · 방어 ${this.equipmentDefenseBonus(enemy)} · 명중 보정 ${this.hitBuff(enemy)}`,
      distanceText.trim()
    ]
      .filter(Boolean)
      .join('\n');
  }

  private enemyBehaviorDescription(behavior: EnemyAiBehavior) {
    if (behavior === 'aggressive') {
      return '계속 접근하여 공격 대상을 만들려 합니다.';
    }
    if (behavior === 'hold') {
      return '자리를 지키며 사거리 안의 대상만 노립니다.';
    }
    return `초기 위치 주변 ${guardDetectionRange}칸 안에 들어온 아군을 추격합니다.`;
  }

  private formatEnemyCombatResult(result: CombatResult, behavior: EnemyAiBehavior) {
    const outcome = result.hit ? `${result.critical ? '치명타 ' : ''}${result.damage} 피해` : '회피됨';
    const defeated = result.defeated ? ` / ${result.defender.name} 퇴각` : ` / ${result.defender.name} HP ${result.defender.hp}/${result.defender.maxHp}`;
    const counter = result.counter ? ` / ${this.formatCounterLine(result.counter)}` : '';
    return `${result.attacker.name} ${this.enemyBehaviorLabel(behavior)} 공격: ${result.defender.name} ${outcome}${defeated}${counter}`;
  }

  private async playCombatCutIn(result: CombatResult) {
    this.hideCombatCutIn();

    const panelWidth = 940;
    const panelHeight = 560;
    const left = Math.floor((this.scale.width - panelWidth) / 2);
    const top = Math.floor((this.scale.height - panelHeight) / 2);
    const depth = 70;
    const attackerX = left + 190;
    const defenderX = left + panelWidth - 190;
    const groundY = top + 246;

    const dim = this.trackCombatObject(this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0x020406, 0.62));
    dim.setOrigin(0);
    dim.setDepth(depth);

    const panel = this.trackCombatObject(this.add.rectangle(left, top, panelWidth, panelHeight, 0x101821, 0.98));
    panel.setOrigin(0);
    panel.setDepth(depth + 1);
    panel.setStrokeStyle(3, palette.gold, 0.9);

    const stage = this.trackCombatObject(this.add.graphics());
    stage.setDepth(depth + 2);
    stage.fillStyle(0x8ec9e6, 1);
    stage.fillRect(left + 18, top + 18, panelWidth - 36, 64);
    stage.fillStyle(0x3f6f44, 1);
    stage.fillRect(left + 18, top + 82, panelWidth - 36, 186);
    stage.fillStyle(0x6f8c43, 0.92);
    stage.fillRect(left + 18, top + 158, panelWidth - 36, 110);
    stage.lineStyle(1, 0x2f4f2f, 0.45);
    for (let x = left + 40; x < left + panelWidth - 40; x += 42) {
      stage.beginPath();
      stage.moveTo(x, top + 163);
      stage.lineTo(x - 16, top + 264);
      stage.strokePath();
    }
    stage.fillStyle(0x23472c, 1);
    stage.fillRect(left + 18, top + 82, panelWidth - 36, 18);

    const titleText = result.isCounter ? '반격' : result.usable?.name ?? commandLabels[result.action];
    const title = this.trackCombatObject(this.add.text(left + panelWidth / 2, top + 24, titleText, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '24px',
      color: '#f4dfad',
      fontStyle: '700',
      stroke: '#05070a',
      strokeThickness: 4
    }));
    title.setOrigin(0.5, 0);
    title.setDepth(depth + 8);

    const attackerDirection = 'east';
    const defenderDirection = 'west';
    const attackerSprite = this.trackCombatObject(
      this.add.sprite(
        attackerX,
        groundY,
        this.unitActionTexture(result.attacker),
        this.unitActionFrameIndex(attackerDirection, this.actionPoseForCommand(result.action))
      )
    );
    attackerSprite.setDepth(depth + 6);
    attackerSprite.setDisplaySize(126, 126);

    const defenderSprite = this.trackCombatObject(
      this.add.sprite(defenderX, groundY, this.unitTextureKey(result.defender), this.unitFrameIndex(defenderDirection))
    );
    defenderSprite.setDepth(depth + 6);
    defenderSprite.setDisplaySize(126, 126);

    const attackerStatus = this.renderCombatStatusPanel(result.attacker, left + 26, top + 286, 392, result.characterGrowth);
    const defenderStatus = this.renderCombatStatusPanel(result.defender, left + panelWidth - 418, top + 286, 392);
    defenderStatus.hpFill.setScale(result.previousDefenderHp / result.defender.maxHp, 1);
    defenderStatus.hpText.setText(`${result.previousDefenderHp} / ${result.defender.maxHp}`);
    attackerStatus.expFill.setScale(result.characterGrowth.previousExp / result.characterGrowth.next, 1);
    attackerStatus.expText.setText(`${result.characterGrowth.previousExp} / ${result.characterGrowth.next}`);

    if (result.bondDamageBonus > 0 && result.bondLabel) {
      await this.showBondCombatEffect(result, left + panelWidth / 2, top + 106, attackerX, groundY - 70, depth + 15);
    }

    await this.delay(180);
    await this.playCombatActionMotion(result, attackerSprite, defenderSprite, attackerX, defenderX, groundY, depth + 10, defenderDirection);
    if (result.hit) {
      this.showCombatImpact(result, defenderX, groundY - 46, depth + 14);
    } else {
      this.showCombatMiss(defenderX, groundY - 70, depth + 14);
    }

    await this.animateCombatGauge(defenderStatus.hpFill, result.previousDefenderHp / result.defender.maxHp, result.defender.hp / result.defender.maxHp, 420);
    defenderStatus.hpText.setText(`${result.defender.hp} / ${result.defender.maxHp}`);

    await this.animateGrowthGauge(
      attackerStatus.expFill,
      attackerStatus.expText,
      result.characterGrowth.previousExp / result.characterGrowth.next,
      result.characterGrowth.exp / result.characterGrowth.next,
      result.characterGrowth.previousExp,
      result.characterGrowth.exp,
      result.characterGrowth.next,
      result.characterGrowth.leveled,
      680
    );
    if (result.characterGrowth.leveled) {
      attackerStatus.nameText.setText(`${result.attacker.name}  Lv ${result.characterGrowth.level}`);
    }

    const resultLabel = result.hit ? `${result.critical ? '치명타! ' : ''}${result.damage} 피해` : '회피!';
    const resultText = this.trackCombatObject(this.add.text(left + panelWidth / 2, top + 252, resultLabel, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '22px',
      color: result.hit ? (result.critical ? '#ff8f5f' : '#ffdf7b') : '#d9f1ff',
      fontStyle: '700',
      stroke: '#2b0909',
      strokeThickness: 4
    }));
    resultText.setOrigin(0.5);
    resultText.setDepth(depth + 16);

    await this.playGrowthRewardPanel({
      x: left + 86,
      y: top + 410,
      width: panelWidth - 172,
      title: '성장 결과',
      entries: this.combatGrowthEntries(result),
      celebrant: this.growthCelebrant(result),
      depth: depth + 18
    });

    await this.delay(360);
    this.hideCombatCutIn();
  }

  private async playSupportCutIn(result: SupportResult) {
    this.hideCombatCutIn();

    const panelWidth = 760;
    const panelHeight = 500;
    const left = Math.floor((this.scale.width - panelWidth) / 2);
    const top = Math.floor((this.scale.height - panelHeight) / 2);
    const depth = 70;

    const dim = this.trackCombatObject(this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0x020406, 0.58));
    dim.setOrigin(0);
    dim.setDepth(depth);

    const panel = this.trackCombatObject(this.add.rectangle(left, top, panelWidth, panelHeight, 0x101821, 0.98));
    panel.setOrigin(0);
    panel.setDepth(depth + 1);
    panel.setStrokeStyle(3, result.usable.command === 'strategy' ? palette.blue : palette.gold, 0.9);

    const title = this.trackCombatObject(this.add.text(left + panelWidth / 2, top + 24, result.usable.name, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '26px',
      color: '#f4dfad',
      fontStyle: '700',
      stroke: '#05070a',
      strokeThickness: 4
    }));
    title.setOrigin(0.5, 0);
    title.setDepth(depth + 2);

    const supportPose: UnitActionPose = result.usable.command === 'strategy' ? 'strategy' : 'item';
    const userSprite = this.trackCombatObject(
      this.add.sprite(left + 160, top + 146, this.unitActionTexture(result.user), this.unitActionFrameIndex('east', supportPose))
    );
    userSprite.setDepth(depth + 3);
    userSprite.setDisplaySize(112, 112);

    const targetSprite = this.trackCombatObject(this.add.sprite(left + panelWidth - 160, top + 146, this.unitTextureKey(result.target), this.unitFrameIndex('west')));
    targetSprite.setDepth(depth + 3);
    targetSprite.setDisplaySize(112, 112);

    const effectColor = result.usable.effect === 'heal' ? 0x59d18c : 0xd8b15f;
    const effect = this.trackCombatObject(this.add.circle(left + panelWidth / 2, top + 145, 30, effectColor, 0.76));
    effect.setDepth(depth + 4);
    this.tweens.add({ targets: effect, scale: 1.45, alpha: 0.18, duration: 520, yoyo: true });
    soundDirector.playEffect(result.usable.command === 'strategy' ? 'strategy-cast' : 'exp-gain', {
      volume: result.usable.command === 'strategy' ? 0.38 : 0.28,
      stopAfterMs: 900
    });

    const targetStatus = this.renderCombatStatusPanel(result.target, left + panelWidth / 2 - 196, top + 190, 392);
    targetStatus.hpFill.setScale(result.previousTargetHp / result.target.maxHp, 1);
    targetStatus.hpText.setText(`${result.previousTargetHp} / ${result.target.maxHp}`);

    await this.delay(250);
    await this.animateCombatGauge(targetStatus.hpFill, result.previousTargetHp / result.target.maxHp, result.target.hp / result.target.maxHp, 480);
    targetStatus.hpText.setText(`${result.target.hp} / ${result.target.maxHp}`);

    const resultLine =
      result.usable.effect === 'heal'
        ? `${result.target.name} 병력 +${result.healAmount}`
        : `${result.target.name} ${result.usable.name}: 공격 +${result.buff?.attackBonus ?? 0} / 명중 +${result.buff?.hitBonus ?? 0} / 치명 +${result.buff?.criticalBonus ?? 0}`;
    const resultText = this.trackCombatObject(this.add.text(left + panelWidth / 2, top + 106, resultLine, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '19px',
      color: result.usable.effect === 'heal' ? '#a8ffd0' : '#ffdf7b',
      fontStyle: '700',
      stroke: '#05070a',
      strokeThickness: 4
    }));
    resultText.setOrigin(0.5);
    resultText.setDepth(depth + 5);

    await this.playGrowthRewardPanel({
      x: left + 64,
      y: top + 322,
      width: panelWidth - 128,
      title: '성장 결과',
      entries: this.supportGrowthEntries(result),
      celebrant: result.user,
      depth: depth + 8
    });

    await this.delay(360);
    this.hideCombatCutIn();
  }

  private renderCombatStatusPanel(unit: UnitData, x: number, y: number, width: number, growth?: CharacterGrowthResult) {
    const panel = this.trackCombatObject(this.add.rectangle(x, y, width, 116, 0x101820, 0.96));
    panel.setOrigin(0);
    panel.setDepth(84);
    panel.setStrokeStyle(2, unit.faction === 'ally' ? palette.blue : 0xb86b55, 0.78);

    const textureKey = this.combatPortraitKey(unit);
    if (textureKey && this.textures.exists(textureKey)) {
      const portrait = this.trackCombatObject(this.add.image(x + 38, y + 58, textureKey));
      portrait.setDepth(85);
      portrait.setDisplaySize(58, 80);
    } else {
      const unitIcon = this.trackCombatObject(this.add.sprite(x + 38, y + 58, this.unitTextureKey(unit), this.unitFrameIndex('south')));
      unitIcon.setDepth(85);
      unitIcon.setDisplaySize(64, 64);
    }

    const nameText = this.trackCombatObject(this.add.text(x + 78, y + 14, `${unit.name}  Lv ${growth?.previousLevel ?? unit.level}`, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '17px',
      color: '#f2e3bf',
      fontStyle: '700'
    }));
    nameText.setDepth(85);

    const hpLabel = this.trackCombatObject(this.add.text(x + 78, y + 45, '병력', {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '13px',
      color: '#9fb0bf',
      fontStyle: '700'
    }));
    hpLabel.setDepth(85);
    const hpTrack = this.trackCombatObject(this.add.rectangle(x + 126, y + 54, width - 210, 9, 0x0a0f14, 0.9));
    hpTrack.setOrigin(0, 0.5);
    hpTrack.setDepth(85);
    const hpFill = this.trackCombatObject(this.add.rectangle(x + 126, y + 54, width - 210, 7, 0x59d18c, 0.96));
    hpFill.setOrigin(0, 0.5);
    hpFill.setDepth(86);
    hpFill.setScale(unit.hp / unit.maxHp, 1);
    const hpText = this.trackCombatObject(this.add.text(x + width - 18, y + 43, `${unit.hp} / ${unit.maxHp}`, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '14px',
      color: '#f0e4c8',
      fontStyle: '700'
    }));
    hpText.setOrigin(1, 0);
    hpText.setDepth(85);

    const expLabel = this.trackCombatObject(this.add.text(x + 78, y + 75, '경험', {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '13px',
      color: '#9fb0bf',
      fontStyle: '700'
    }));
    expLabel.setDepth(85);
    const expTrack = this.trackCombatObject(this.add.rectangle(x + 126, y + 84, width - 210, 9, 0x0a0f14, 0.9));
    expTrack.setOrigin(0, 0.5);
    expTrack.setDepth(85);
    const expFill = this.trackCombatObject(this.add.rectangle(x + 126, y + 84, width - 210, 7, 0xd8b15f, 0.96));
    expFill.setOrigin(0, 0.5);
    expFill.setDepth(86);
    expFill.setScale((growth?.previousExp ?? unit.exp) / 100, 1);
    const expText = this.trackCombatObject(this.add.text(x + width - 18, y + 73, `${growth?.previousExp ?? unit.exp} / 100`, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '14px',
      color: '#f0e4c8',
      fontStyle: '700'
    }));
    expText.setOrigin(1, 0);
    expText.setDepth(85);

    return { hpFill, hpText, expFill, expText, nameText };
  }

  private combatGrowthEntries(result: CombatResult): GrowthGaugeEntry[] {
    const entries: GrowthGaugeEntry[] = [];

    if (result.attacker.faction === 'ally') {
      entries.push(this.characterGrowthEntry(result.attacker, result.characterGrowth));
      entries.push(this.equipmentGrowthEntry(result.attacker, result.attackerGrowth));
    }
    if (result.defender.faction === 'ally') {
      entries.push(this.equipmentGrowthEntry(result.defender, result.defenderGrowth));
    }
    result.bondGrowth.forEach((growth) => entries.push(this.bondGrowthEntry(growth)));

    return entries;
  }

  private supportGrowthEntries(result: SupportResult): GrowthGaugeEntry[] {
    return [this.characterGrowthEntry(result.user, result.characterGrowth), this.equipmentGrowthEntry(result.user, result.equipmentGrowth)];
  }

  private characterGrowthEntry(unit: UnitData, growth: CharacterGrowthResult): GrowthGaugeEntry {
    return {
      label: '장수 경험치',
      owner: unit.name,
      amountLabel: `+${growth.amount}`,
      previousLevel: growth.previousLevel,
      previousExp: growth.previousExp,
      previousNext: 100,
      level: growth.level,
      exp: growth.exp,
      next: growth.next,
      leveled: growth.leveled,
      color: 0xd8b15f
    };
  }

  private equipmentGrowthEntry(unit: UnitData, growth: EquipmentGrowthResult): GrowthGaugeEntry {
    return {
      label: `${equipmentSlotLabels[growth.slot]} ${growth.itemName}`,
      owner: unit.name,
      amountLabel: `+${growth.amount}`,
      previousLevel: growth.previousLevel,
      previousExp: growth.previousExp,
      previousNext: equipmentExpToNext(growth.previousLevel),
      level: growth.level,
      exp: growth.exp,
      next: growth.next,
      leveled: growth.leveled,
      color: growth.slot === 'weapon' ? 0xffc45f : growth.slot === 'armor' ? 0x77b7ff : 0xb7df7a
    };
  }

  private bondGrowthEntry(growth: BondGrowthResult): GrowthGaugeEntry {
    return {
      label: '공명 경험치',
      owner: growth.label,
      amountLabel: `+${growth.amount}`,
      previousLevel: growth.previousLevel,
      previousExp: growth.previousExp,
      previousNext: 100,
      level: growth.level,
      exp: growth.exp,
      next: growth.next,
      leveled: growth.leveled,
      color: 0x83d6ff
    };
  }

  private growthCelebrant(result: CombatResult) {
    if (result.attacker.faction === 'ally') {
      return result.attacker;
    }
    if (result.defender.faction === 'ally') {
      return result.defender;
    }
    return undefined;
  }

  private async playGrowthRewardPanel(config: {
    x: number;
    y: number;
    width: number;
    title: string;
    entries: GrowthGaugeEntry[];
    celebrant?: UnitData;
    depth: number;
  }) {
    const entries = config.entries.filter((entry) => entry.amountLabel !== '+0' || entry.leveled);
    if (entries.length === 0) {
      await this.delay(280);
      return;
    }

    const rowHeight = 25;
    const height = 48 + entries.length * rowHeight;
    const panel = this.trackCombatObject(this.add.rectangle(config.x, config.y, config.width, height, 0x0b121a, 0.96));
    panel.setOrigin(0);
    panel.setDepth(config.depth);
    panel.setStrokeStyle(2, palette.gold, 0.82);

    const title = this.trackCombatObject(this.add.text(config.x + 16, config.y + 12, config.title, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '16px',
      color: '#f4dfad',
      fontStyle: '700'
    }));
    title.setDepth(config.depth + 1);

    const levelEvent = entries.some((entry) => entry.leveled);
    const rowWidth = config.width - (levelEvent && config.celebrant ? 126 : 36);
    const views = entries.map((entry, index) => this.renderGrowthGaugeRow(entry, config.x + 18, config.y + 42 + index * rowHeight, rowWidth, config.depth + 1));

    let celebration: Phaser.GameObjects.Sprite | undefined;
    if (levelEvent && config.celebrant) {
      celebration = this.createGrowthCelebrationSprite(config.celebrant, config.x + config.width - 46, config.y + 24, config.depth + 5);
    }

    for (let index = 0; index < entries.length; index += 1) {
      await this.animateGrowthEntry(views[index], entries[index], 540 + index * 70);
      if (entries[index].leveled) {
        this.flashGrowthLevelUp(views[index], config.x, config.y, config.width, height, config.depth + 4);
      }
    }

    if (celebration) {
      await this.playGrowthCelebration(celebration);
    }
  }

  private renderGrowthGaugeRow(entry: GrowthGaugeEntry, x: number, y: number, width: number, depth: number): GrowthGaugeView {
    const label = this.trackCombatObject(this.add.text(x, y - 3, `${entry.owner} ${entry.label}`, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '13px',
      color: '#d4dce6',
      fontStyle: '700'
    }));
    label.setDepth(depth);

    const amount = this.trackCombatObject(this.add.text(x + 214, y - 3, entry.amountLabel, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '13px',
      color: '#ffdf7b',
      fontStyle: '700'
    }));
    amount.setDepth(depth);

    const barX = x + 270;
    const barWidth = Math.max(130, width - 440);
    const track = this.trackCombatObject(this.add.rectangle(barX, y + 8, barWidth, 9, 0x05090e, 0.92));
    track.setOrigin(0, 0.5);
    track.setDepth(depth);
    const fill = this.trackCombatObject(this.add.rectangle(barX, y + 8, barWidth, 7, entry.color, 0.98));
    fill.setOrigin(0, 0.5);
    fill.setDepth(depth + 1);
    fill.setScale(entry.previousExp / entry.previousNext, 1);

    const valueText = this.trackCombatObject(this.add.text(barX + barWidth + 10, y - 3, `${entry.previousExp}/${entry.previousNext}`, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '13px',
      color: '#f0e4c8',
      fontStyle: '700'
    }));
    valueText.setDepth(depth);

    const levelText = this.trackCombatObject(this.add.text(x + width - 6, y - 3, `Lv ${entry.previousLevel}`, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '13px',
      color: '#f4dfad',
      fontStyle: '700'
    }));
    levelText.setOrigin(1, 0);
    levelText.setDepth(depth);

    const eventText = this.trackCombatObject(this.add.text(barX + Math.floor(barWidth / 2), y - 12, '', {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '15px',
      color: '#fff2b8',
      fontStyle: '700',
      stroke: '#05070a',
      strokeThickness: 3
    }));
    eventText.setOrigin(0.5);
    eventText.setDepth(depth + 3);

    return { fill, valueText, levelText, eventText };
  }

  private async animateGrowthEntry(view: GrowthGaugeView, entry: GrowthGaugeEntry, duration: number) {
    if (entry.leveled) {
      await this.animateGrowthGauge(view.fill, view.valueText, entry.previousExp / entry.previousNext, 1, entry.previousExp, entry.previousNext, entry.previousNext, false, duration);
      view.levelText.setText(`Lv ${entry.level}`);
      view.fill.setScale(0, 1);
      view.valueText.setText(`0/${entry.next}`);
      this.showGrowthEventText(view.eventText, `Lv ${entry.level} 달성!`);
      soundDirector.playLevelUp();
      await this.delay(260);
      await this.animateGrowthGauge(view.fill, view.valueText, 0, entry.exp / entry.next, 0, entry.exp, entry.next, false, Math.max(260, Math.floor(duration * 0.48)));
      return;
    }

    await this.animateGrowthGauge(view.fill, view.valueText, entry.previousExp / entry.previousNext, entry.exp / entry.next, entry.previousExp, entry.exp, entry.next, false, duration);
    view.levelText.setText(`Lv ${entry.level}`);
  }

  private showGrowthEventText(text: Phaser.GameObjects.Text, label: string) {
    text.setText(label);
    text.setAlpha(1);
    text.setScale(0.9);
    this.tweens.add({
      targets: text,
      y: text.y - 12,
      scale: 1.16,
      alpha: 0,
      duration: 780,
      ease: 'Sine.easeOut'
    });
  }

  private flashGrowthLevelUp(view: GrowthGaugeView, x: number, y: number, width: number, height: number, depth: number) {
    const flash = this.trackCombatObject(this.add.rectangle(x + width / 2, y + height / 2, width - 10, height - 10, 0xffdf7b, 0.14));
    flash.setDepth(depth);
    this.tweens.add({
      targets: flash,
      alpha: 0,
      scaleX: 1.03,
      scaleY: 1.12,
      duration: 520,
      ease: 'Sine.easeOut',
      onComplete: () => flash.destroy()
    });
    this.tweens.add({
      targets: [view.levelText, view.valueText],
      scale: 1.16,
      duration: 130,
      yoyo: true,
      ease: 'Sine.easeOut'
    });
  }

  private createGrowthCelebrationSprite(unit: UnitData, x: number, y: number, depth: number) {
    const sprite = this.trackCombatObject(
      this.add.sprite(x, y, this.unitActionTexture(unit), this.unitActionFrameIndex('south', this.celebrationPose(unit)))
    );
    sprite.setDisplaySize(82, 82);
    sprite.setDepth(depth);
    return sprite;
  }

  private celebrationPose(unit: UnitData): UnitActionPose {
    if (unit.id === 'liu-bei') {
      return 'strategy';
    }
    if (unit.id === 'zhang-fei') {
      return 'attack';
    }
    return 'item';
  }

  private playGrowthCelebration(sprite: Phaser.GameObjects.Sprite) {
    return new Promise<void>((resolve) => {
      this.tweens.add({
        targets: sprite,
        y: sprite.y - 18,
        scale: 1.16,
        angle: 3,
        duration: 160,
        yoyo: true,
        repeat: 2,
        ease: 'Sine.easeInOut',
        onComplete: () => {
          sprite.setAngle(0);
          resolve();
        }
      });
    });
  }

  private combatPortraitKey(unit: UnitData) {
    if (unit.id === 'liu-bei') {
      return 'portrait-liu-bei';
    }
    if (unit.id === 'guan-yu') {
      return 'portrait-guan-yu';
    }
    if (unit.id === 'zhang-fei') {
      return 'portrait-zhang-fei';
    }
    return undefined;
  }

  private async playCombatActionMotion(
    result: CombatResult,
    attackerSprite: Phaser.GameObjects.Sprite,
    defenderSprite: Phaser.GameObjects.Sprite,
    attackerX: number,
    defenderX: number,
    groundY: number,
    depth: number,
    defenderDirection: UnitDirection
  ) {
    const direction = result.attacker.faction === 'ally' ? 1 : -1;
    if (result.action === 'attack') {
      soundDirector.playEffect('sword-slash', { volume: result.hit ? 0.52 : 0.38, stopAfterMs: 700 });
      this.tweens.add({
        targets: attackerSprite,
        x: attackerX + direction * 84,
        duration: 170,
        yoyo: true,
        ease: 'Sine.easeInOut'
      });
      await this.delay(180);
      if (result.hit) {
        defenderSprite.setTexture(this.unitActionTexture(result.defender), this.unitActionFrameIndex(defenderDirection, 'hurt'));
      }
      this.tweens.add({
        targets: defenderSprite,
        x: defenderX + direction * (result.hit ? 12 : 28),
        y: result.hit ? defenderSprite.y : defenderSprite.y - 10,
        duration: result.hit ? 70 : 120,
        yoyo: true
      });
      await this.delay(230);
      return;
    }

    soundDirector.playEffect(result.action === 'item' ? 'cart-roll' : 'strategy-cast', {
      volume: result.action === 'item' ? 0.34 : 0.44,
      stopAfterMs: result.action === 'item' ? 720 : 950
    });

    const projectile =
      result.action === 'item'
        ? this.createCombatCart(attackerX + direction * 78, groundY - 10, depth)
        : this.createStrategyEffect(attackerX + direction * 78, groundY - 48, depth);
    projectile.setDepth(depth);
    this.combatCutInObjects.push(projectile);
    this.tweens.add({
      targets: projectile,
      x: defenderX - direction * (result.hit ? 72 : 12),
      y: result.hit ? projectile.y : projectile.y - 24,
      duration: 430,
      ease: 'Cubic.easeIn'
    });
    await this.delay(450);
    if (result.hit) {
      defenderSprite.setTexture(this.unitActionTexture(result.defender), this.unitActionFrameIndex(defenderDirection, 'hurt'));
    }
    projectile.destroy();
  }

  private createCombatCart(x: number, y: number, depth: number) {
    const container = this.add.container(x, y);
    container.setDepth(depth);
    const body = this.add.rectangle(0, 0, 58, 28, 0x7c5432, 1);
    body.setStrokeStyle(2, 0x2a160c, 1);
    const cover = this.add.rectangle(0, -9, 46, 10, 0xa57a45, 1);
    const leftWheel = this.add.circle(-18, 15, 7, 0x1b1511, 1);
    const rightWheel = this.add.circle(18, 15, 7, 0x1b1511, 1);
    const blade = this.add.triangle(35, -4, 0, -10, 22, 0, 0, 10, 0xd8dfe6, 1);
    blade.setStrokeStyle(1, 0x4d5960, 1);
    container.add([body, cover, leftWheel, rightWheel, blade]);
    return container;
  }

  private createStrategyEffect(x: number, y: number, depth: number) {
    const container = this.add.container(x, y);
    container.setDepth(depth);
    const core = this.add.circle(0, 0, 14, 0x6cc5ff, 0.9);
    const ring = this.add.circle(0, 0, 24);
    ring.setStrokeStyle(3, 0xd8b15f, 0.9);
    const spark = this.add.star(0, 0, 5, 7, 18, 0xf7e6a1, 0.9);
    container.add([ring, core, spark]);
    this.tweens.add({ targets: ring, angle: 180, duration: 430 });
    this.tweens.add({ targets: spark, angle: -240, duration: 430 });
    return container;
  }

  private showBondCombatEffect(result: CombatResult, textX: number, textY: number, auraX: number, auraY: number, depth: number) {
    if (!result.bondLabel || result.bondDamageBonus <= 0) {
      return Promise.resolve();
    }

    soundDirector.playEffect('strategy-cast', { volume: 0.22, rate: 1.08, stopAfterMs: 720 });

    const aura = this.trackCombatObject(this.add.circle(auraX, auraY, 42));
    aura.setStrokeStyle(4, 0xffdf7b, 0.88);
    aura.setDepth(depth);

    const inner = this.trackCombatObject(this.add.circle(auraX, auraY, 24, 0xffdf7b, 0.18));
    inner.setDepth(depth - 1);

    const spark = this.trackCombatObject(this.add.star(auraX, auraY, 6, 8, 24, 0xfff0b8, 0.86));
    spark.setDepth(depth + 1);

    const text = this.trackCombatObject(this.add.text(textX, textY, `공명 효과  ${result.bondLabel}\n피해 +${result.bondDamageBonus}%`, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '18px',
      color: '#fff2b8',
      fontStyle: '700',
      align: 'center',
      stroke: '#2b1606',
      strokeThickness: 4,
      lineSpacing: 2
    }));
    text.setOrigin(0.5);
    text.setDepth(depth + 2);

    this.tweens.add({ targets: aura, scale: 1.7, alpha: 0, duration: 560, ease: 'Sine.easeOut' });
    this.tweens.add({ targets: inner, scale: 1.35, alpha: 0.04, duration: 560, ease: 'Sine.easeOut' });
    this.tweens.add({ targets: spark, angle: 180, scale: 1.22, duration: 560, ease: 'Sine.easeInOut' });
    this.tweens.add({ targets: text, y: textY - 8, scale: 1.05, duration: 180, yoyo: true, ease: 'Sine.easeOut' });

    return this.delay(580);
  }

  private showCombatImpact(result: CombatResult, x: number, y: number, depth: number) {
    soundDirector.playEffect('combat-impact', { volume: result.critical ? 0.62 : result.action === 'strategy' ? 0.3 : 0.48, stopAfterMs: 650 });

    const impact = this.trackCombatObject(
      this.add.star(x, y, 8, 12, result.critical ? 54 : 42, result.critical ? 0xff8f5f : result.action === 'strategy' ? 0x6cc5ff : 0xffdf7b, 0.92)
    );
    impact.setDepth(depth);
    this.tweens.add({
      targets: impact,
      scale: result.critical ? 1.7 : 1.45,
      alpha: 0,
      duration: 320,
      ease: 'Sine.easeOut',
      onComplete: () => impact.destroy()
    });
  }

  private showCombatMiss(x: number, y: number, depth: number) {
    const miss = this.trackCombatObject(this.add.text(x, y, '회피', {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '24px',
      color: '#d9f1ff',
      fontStyle: '700',
      stroke: '#071623',
      strokeThickness: 4
    }));
    miss.setOrigin(0.5);
    miss.setDepth(depth);
    this.tweens.add({
      targets: miss,
      y: y - 20,
      alpha: 0,
      duration: 460,
      ease: 'Sine.easeOut',
      onComplete: () => miss.destroy()
    });
  }

  private animateCombatGauge(target: Phaser.GameObjects.Rectangle, fromRatio: number, toRatio: number, duration: number) {
    target.setScale(Phaser.Math.Clamp(fromRatio, 0, 1), 1);
    return new Promise<void>((resolve) => {
      this.tweens.add({
        targets: target,
        scaleX: Phaser.Math.Clamp(toRatio, 0, 1),
        duration,
        ease: 'Sine.easeInOut',
        onComplete: () => resolve()
      });
    });
  }

  private animateGrowthGauge(
    target: Phaser.GameObjects.Rectangle,
    text: Phaser.GameObjects.Text,
    fromRatio: number,
    toRatio: number,
    fromValue: number,
    toValue: number,
    next: number,
    leveled: boolean,
    duration: number
  ) {
    target.setScale(Phaser.Math.Clamp(fromRatio, 0, 1), 1);
    text.setText(`${fromValue} / ${next}`);

    if (leveled) {
      soundDirector.playGrowthTick();
      return new Promise<void>((resolve) => {
        const firstTracker = { ratio: Phaser.Math.Clamp(fromRatio, 0, 1), value: fromValue };
        this.tweens.add({
          targets: firstTracker,
          ratio: 1,
          value: next,
          duration: Math.floor(duration * 0.62),
          ease: 'Sine.easeInOut',
          onUpdate: () => {
            target.setScale(firstTracker.ratio, 1);
            text.setText(`${Math.round(firstTracker.value)} / ${next}`);
          },
          onComplete: () => {
            soundDirector.playLevelUp();
            target.setScale(0, 1);
            text.setText(`0 / ${next}`);
            const secondTracker = { ratio: 0, value: 0 };
            this.tweens.add({
              targets: secondTracker,
              ratio: Phaser.Math.Clamp(toRatio, 0, 1),
              value: toValue,
              duration: Math.floor(duration * 0.46),
              ease: 'Sine.easeInOut',
              onUpdate: () => {
                target.setScale(secondTracker.ratio, 1);
                text.setText(`${Math.round(secondTracker.value)} / ${next}`);
              },
              onComplete: () => {
                target.setScale(Phaser.Math.Clamp(toRatio, 0, 1), 1);
                text.setText(`${toValue} / ${next}`);
                this.tweens.add({
                  targets: text,
                  scale: 1.18,
                  duration: 120,
                  yoyo: true,
                  ease: 'Sine.easeOut'
                });
                resolve();
              }
            });
          }
        });
      });
    }

    if (toValue !== fromValue) {
      soundDirector.playGrowthTick();
    }

    return new Promise<void>((resolve) => {
      const tracker = { ratio: Phaser.Math.Clamp(fromRatio, 0, 1), value: fromValue };
      this.tweens.add({
        targets: tracker,
        ratio: Phaser.Math.Clamp(toRatio, 0, 1),
        value: toValue,
        duration,
        ease: 'Sine.easeInOut',
        onUpdate: () => {
          target.setScale(tracker.ratio, 1);
          text.setText(`${Math.round(tracker.value)} / ${next}`);
        },
        onComplete: () => {
          target.setScale(Phaser.Math.Clamp(toRatio, 0, 1), 1);
          text.setText(`${toValue} / ${next}`);
          if (leveled) {
            this.tweens.add({
              targets: text,
              scale: 1.18,
              duration: 120,
              yoyo: true,
              ease: 'Sine.easeOut'
            });
          }
          resolve();
        }
      });
    });
  }

  private trackCombatObject<T extends Phaser.GameObjects.GameObject>(object: T) {
    this.combatCutInObjects.push(object);
    return object;
  }

  private hideCombatCutIn() {
    this.combatCutInObjects.forEach((object) => {
      if (object.active) {
        object.destroy();
      }
    });
    this.combatCutInObjects = [];
  }

  private delay(ms: number) {
    return new Promise<void>((resolve) => {
      this.time.delayedCall(ms, () => resolve());
    });
  }

  private moveUnitViewAsync(
    unit: UnitData,
    x: number,
    y: number,
    view = this.unitViews.get(unit.id),
    duration = 260
  ) {
    return new Promise<void>((resolve) => {
      if (!view) {
        resolve();
        return;
      }

      const targetX = this.tileCenterX(x);
      const targetY = this.tileCenterY(y);
      const direction = this.directionFromDelta(targetX - view.sprite.x, targetY - view.sprite.y);
      this.tweens.killTweensOf([view.sprite, view.label]);
      this.playMovementSound(unit, duration);
      this.playUnitWalk(view, direction);
      this.tweens.add({
        targets: view.sprite,
        x: targetX,
        y: targetY,
        duration,
        ease: 'Sine.easeInOut',
        onComplete: () => {
          this.syncUnitMotion(unit, view, direction);
          this.updateMiniMap();
          resolve();
        }
      });
      this.tweens.add({
        targets: view.label,
        x: targetX,
        y: targetY + this.layout.tileSize * 0.52,
        duration,
        ease: 'Sine.easeInOut'
      });
    });
  }

  private beginNextAllyTurn() {
    if (this.battleOutcome || this.resolveBattleOutcomeIfNeeded()) {
      return;
    }

    this.turnNumber += 1;
    this.tickBattleBuffs();
    this.activeFaction = 'ally';
    this.actedUnitIds.clear();
    this.attackIntents = [];
    const recoveryMessage = this.applyTerrainRecovery('ally');
    this.resetActedStyles();
    this.updateTurnText();
    this.updateObjectiveTracker();
    this.checkBattleEvents();
    this.renderRosterPanel('ally', [`${this.turnNumber}턴 아군 차례입니다.`, recoveryMessage, '행동할 장수를 선택하세요.'].filter(Boolean).join('\n'));
  }

  private applyTerrainRecovery(faction: ActiveFaction) {
    const messages: string[] = [];

    battleUnits
      .filter((unit) => unit.faction === faction && unit.hp > 0)
      .forEach((unit) => {
        const terrain = battleMap.terrain[unit.y][unit.x];
        const terrainRule = getTerrainRule(terrain);
        const hpGain = Math.min(terrainRule.recoveryHp ?? 0, unit.maxHp - unit.hp);
        const moraleBonus = terrainRule.moraleBonus ?? 0;
        if (hpGain <= 0 && moraleBonus <= 0) {
          return;
        }

        if (hpGain > 0) {
          unit.hp += hpGain;
        }
        if (moraleBonus > 0) {
          this.applyTerrainMorale(unit, moraleBonus);
        }

        const parts = [
          hpGain > 0 ? `병력 +${hpGain}` : '',
          moraleBonus > 0 ? `사기 +${moraleBonus}` : ''
        ].filter(Boolean);
        messages.push(`${unit.name} ${terrainRule.label}: ${parts.join(' / ')}`);
        this.showTerrainRecoveryPopup(unit, parts.join('  '));
      });

    if (messages.length === 0) {
      return undefined;
    }

    soundDirector.playEffect('exp-gain', { volume: 0.18, stopAfterMs: 520 });
    const message = `거점 효과\n${messages.slice(0, 3).join('\n')}${messages.length > 3 ? `\n외 ${messages.length - 3}명` : ''}`;
    this.pushBattleLog(message);
    return message;
  }

  private applyTerrainMorale(unit: UnitData, moraleBonus: number) {
    const existing = this.battleBuffs.get(unit.id);
    this.battleBuffs.set(unit.id, {
      unitId: unit.id,
      label: existing ? `${existing.label} / 거점 사기` : '거점 사기',
      turns: Math.max(existing?.turns ?? 0, 1),
      attackBonus: existing?.attackBonus ?? 0,
      hitBonus: Math.max(existing?.hitBonus ?? 0, moraleBonus * 2),
      criticalBonus: Math.max(existing?.criticalBonus ?? 0, moraleBonus)
    });
  }

  private showTerrainRecoveryPopup(unit: UnitData, label: string) {
    const view = this.unitViews.get(unit.id);
    if (!view || !this.isTileVisible(unit.x, unit.y)) {
      return;
    }

    const popup = this.add.text(view.sprite.x, view.sprite.y - this.layout.tileSize * 0.56, label, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '14px',
      color: '#a8ffd0',
      fontStyle: '700',
      stroke: '#082416',
      strokeThickness: 4
    });
    popup.setOrigin(0.5);
    popup.setDepth(31);
    if (this.mapMask) {
      popup.setMask(this.mapMask);
    }
    this.tweens.add({
      targets: popup,
      y: popup.y - 22,
      alpha: 0,
      duration: 900,
      ease: 'Sine.easeOut',
      onComplete: () => popup.destroy()
    });
  }

  private updateTurnText() {
    this.turnText?.setText(`${this.turnNumber}턴 / ${factionLabels[this.activeFaction]}`);
    this.updateObjectiveTracker();
  }

  private resetActedStyles() {
    this.unitViews.forEach((view, unitId) => {
      const unit = battleUnits.find((candidate) => candidate.id === unitId);
      if (unit && unit.hp <= 0) {
        this.applyDefeatedStyle(unit);
        return;
      }
      view.sprite.postFX.clear();
      view.sprite.clearTint();
      view.sprite.setAlpha(1);
      if (unit) {
        this.syncUnitMotion(unit, view);
      } else {
        this.stopUnitWalk(view, view.direction);
      }
      view.label.setAlpha(1);
      view.label.setColor(unit?.faction === 'ally' ? '#e7edf7' : '#ffebe7');
      view.label.setBackgroundColor('');
    });
  }

  private recordBondAttackIntent(unit: UnitData, target: UnitData) {
    const partners = this.attackIntents
      .filter((intent) => intent.targetId === target.id && intent.attackerId !== unit.id)
      .map((intent) => intent.attackerId);
    const growthResults: BondGrowthResult[] = [];

    partners.forEach((partnerId) => {
      const bond = this.findBond(unit.id, partnerId);
      if (!bond) {
        return;
      }
      const previousLevel = bond.level;
      const previousExp = bond.exp;
      let leveled = false;
      bond.battleExp += 4;
      bond.exp += 4;
      while (bond.exp >= 100) {
        bond.exp -= 100;
        bond.level = Math.min(100, bond.level + 1);
        leveled = true;
      }
      const [first, second] = bond.unitIds.map((unitId) => this.unitName(unitId));
      growthResults.push({
        label: `${first}·${second}`,
        amount: 4,
        previousLevel,
        previousExp,
        level: bond.level,
        exp: bond.exp,
        next: 100,
        leveled
      });
    });

    this.attackIntents = this.attackIntents.filter((intent) => intent.attackerId !== unit.id);
    this.attackIntents.push({ attackerId: unit.id, targetId: target.id });

    return growthResults;
  }

  private recordAttackIntent(unit: UnitData) {
    const target = this.findNearestEnemy(unit);
    const weaponGrowth = this.awardEquipmentExp(unit, 'weapon', this.weaponExpGain(unit));
    if (!target) {
      return `${unit.name} 공격 명령 완료\n공격 가능한 적이 없습니다.\n${this.formatEquipmentGrowth(weaponGrowth)}`;
    }

    const partners = this.attackIntents
      .filter((intent) => intent.targetId === target.id && intent.attackerId !== unit.id)
      .map((intent) => intent.attackerId);
    let gained = 0;

    partners.forEach((partnerId) => {
      const bond = this.findBond(unit.id, partnerId);
      if (!bond) {
        return;
      }
      bond.battleExp += 4;
      bond.exp += 4;
      while (bond.exp >= 100) {
        bond.exp -= 100;
        bond.level = Math.min(100, bond.level + 1);
      }
      gained += 4;
    });

    this.attackIntents = this.attackIntents.filter((intent) => intent.attackerId !== unit.id);
    this.attackIntents.push({ attackerId: unit.id, targetId: target.id });

    const bonus = this.strongestBondBonus(unit.id);
    const chain = bonus.chainRate > 0 ? `\n공명 보너스: 피해 +${bonus.damageBonus}% / 연계 ${bonus.chainRate}%` : '';
    const exp = gained > 0 ? `\n같은 목표를 노린 공명 경험치 +${gained}` : '\n같은 목표를 노린 장수가 생기면 공명 경험치가 쌓입니다.';
    return `${unit.name} 공격 명령 완료\n예상 목표: ${target.name}\n${this.formatEquipmentGrowth(weaponGrowth)}${chain}${exp}`;
  }

  private resolveEnemyPressure() {
    const defender = this.findEnemyPressureTarget();
    if (!defender) {
      return '황건적은 움직일 틈을 찾지 못했습니다.';
    }

    const armorGrowth = this.awardEquipmentExp(defender, 'armor', 6);
    return `황건적의 견제 공격을 ${defender.name}이 받아냈습니다.\n${this.formatEquipmentGrowth(armorGrowth)}`;
  }

  private findEnemyPressureTarget() {
    const allies = battleUnits.filter((unit) => unit.faction === 'ally' && unit.hp > 0);
    if (allies.length === 0) {
      return undefined;
    }

    return [...allies].sort((a, b) => this.nearestEnemyDistance(a) - this.nearestEnemyDistance(b))[0];
  }

  private nearestEnemyDistance(unit: UnitData) {
    const distances = battleUnits
      .filter((candidate) => candidate.faction === 'enemy' && candidate.hp > 0)
      .map((enemy) => this.tileDistance(unit, enemy));
    return distances.length > 0 ? Math.min(...distances) : Number.POSITIVE_INFINITY;
  }

  private weaponExpGain(unit: UnitData) {
    const weapon = getItem(unit.equipment.weapon.itemId);
    const bonus = this.strongestBondBonus(unit.id);
    return 8 + (weapon.rank === 'treasure' ? 2 : 0) + (bonus.chainRate > 0 ? 2 : 0);
  }

  private awardEquipmentExp(unit: UnitData, slot: EquipmentSlot, amount: number): EquipmentGrowthResult {
    const state = unit.equipment[slot];
    const item = getItem(state.itemId);
    const previousLevel = state.level;
    const previousExp = state.exp;
    let exp = state.exp + amount;
    let leveled = false;

    while (state.level < maxEquipmentLevel && exp >= equipmentExpToNext(state.level)) {
      exp -= equipmentExpToNext(state.level);
      state.level += 1;
      leveled = true;
    }

    const next = equipmentExpToNext(state.level);
    state.exp = state.level >= maxEquipmentLevel ? Math.min(exp, next) : exp;

    return {
      slot,
      itemName: item.name,
      amount,
      previousLevel,
      previousExp,
      level: state.level,
      exp: state.exp,
      next,
      leveled
    };
  }

  private awardCharacterExp(unit: UnitData, amount: number): CharacterGrowthResult {
    const previousLevel = unit.level;
    const previousExp = unit.exp;
    let exp = unit.exp + amount;
    let leveled = false;

    while (unit.level < maxCharacterLevel && exp >= 100) {
      exp -= 100;
      unit.level += 1;
      leveled = true;
    }

    unit.exp = unit.level >= maxCharacterLevel ? Math.min(exp, 100) : exp;

    return {
      amount,
      previousLevel,
      previousExp,
      level: unit.level,
      exp: unit.exp,
      next: 100,
      leveled
    };
  }

  private formatEquipmentGrowth(result: EquipmentGrowthResult) {
    const levelUp = result.leveled ? ` / Lv ${result.level} 달성` : '';
    return `${equipmentSlotLabels[result.slot]} ${result.itemName} 경험치 +${result.amount} (${result.exp}/${result.next})${levelUp}`;
  }

  private findNearestEnemy(unit: UnitData) {
    const enemies = battleUnits.filter((candidate) => candidate.faction === 'enemy' && candidate.hp > 0);
    return enemies.sort((a, b) => this.tileDistance(unit, a) - this.tileDistance(unit, b))[0];
  }

  private tileDistance(a: UnitData, b: UnitData) {
    return Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
  }

  private findBond(unitId: string, partnerId: string) {
    return Array.from(this.bondStates.values()).find((bond) => {
      return bond.unitIds.includes(unitId) && bond.unitIds.includes(partnerId);
    });
  }

  private strongestBondBonus(unitId: string) {
    const related = Array.from(this.bondStates.values()).filter((bond) => bond.unitIds.includes(unitId));
    const strongest = related.sort((a, b) => b.level - a.level)[0];
    if (!strongest) {
      return { damageBonus: 0, chainRate: 0 };
    }
    const [first, second] = strongest.unitIds.map((id) => this.unitName(id));

    return {
      damageBonus: Math.floor(strongest.level / 20) * 3,
      chainRate: strongest.level >= 70 ? 18 : strongest.level >= 50 ? 8 : 0,
      label: `${strongest.title}: ${first}·${second}`
    };
  }

  private attackBuff(unit: UnitData) {
    return this.battleBuffs.get(unit.id)?.attackBonus ?? 0;
  }

  private hitBuff(unit: UnitData) {
    return this.battleBuffs.get(unit.id)?.hitBonus ?? 0;
  }

  private criticalBuff(unit: UnitData) {
    return this.battleBuffs.get(unit.id)?.criticalBonus ?? 0;
  }

  private tickBattleBuffs() {
    this.battleBuffs.forEach((buff, unitId) => {
      buff.turns -= 1;
      if (buff.turns <= 0) {
        this.battleBuffs.delete(unitId);
      }
    });
  }

  private cancelAttackTargeting() {
    if (this.phase !== 'targeting' || !this.selectedUnit) {
      return false;
    }

    const unit = this.selectedUnit;
    const view = this.unitViews.get(unit.id);
    this.phase = 'command';
    this.targetingAction = undefined;
    this.selectedUsable = undefined;
    this.clearMarkers();
    soundDirector.playSelect();
    this.showCommandMenu(unit, view?.sprite.x ?? this.tileCenterX(unit.x), view?.sprite.y ?? this.tileCenterY(unit.y));
    this.renderUnitDetail(unit, '대상 선택을 취소했습니다. 명령을 다시 선택하세요.');
    return true;
  }

  private cancelPendingMove() {
    if (this.phase !== 'command' || !this.pendingMove) {
      return false;
    }

    const { unit, fromX, fromY, toX, toY } = this.pendingMove;
    const view = this.unitViews.get(unit.id);
    unit.x = fromX;
    unit.y = fromY;
    this.phase = 'moving';
    this.selectedUnit = unit;
    this.pendingMove = undefined;
    this.hideCommandMenu();
    this.updateMiniMap();
    soundDirector.playSelect();

    this.moveUnitView(unit, fromX, fromY, view, 180);
    this.clearMarkers();
    this.showMoveRange(unit);
    this.renderUnitDetail(
      unit,
      `이동을 취소했습니다.\n${toX + 1}, ${toY + 1}에서 원래 위치 ${fromX + 1}, ${fromY + 1}로 되돌렸습니다.`
    );
    return true;
  }

  private moveUnitView(
    unit: UnitData,
    x: number,
    y: number,
    view = this.unitViews.get(unit.id),
    duration = 260
  ) {
    if (!view) {
      return;
    }

    const targetX = this.tileCenterX(x);
    const targetY = this.tileCenterY(y);
    const direction = this.directionFromDelta(targetX - view.sprite.x, targetY - view.sprite.y);
    this.tweens.killTweensOf([view.sprite, view.label]);
    this.playMovementSound(unit, duration);
    this.playUnitWalk(view, direction);
    this.tweens.add({
      targets: view.sprite,
      x: targetX,
      y: targetY,
      duration,
      ease: 'Sine.easeInOut',
      onComplete: () => {
        this.syncUnitMotion(unit, view, direction);
        this.updateMiniMap();
      }
    });
    this.tweens.add({
      targets: view.label,
      x: targetX,
      y: targetY + this.layout.tileSize * 0.52,
      duration,
      ease: 'Sine.easeInOut'
    });
  }

  private playMovementSound(unit: UnitData, duration: number) {
    const mountedClasses: UnitClassKey[] = ['cavalry'];
    const isMounted = mountedClasses.includes(unit.classKey);
    soundDirector.playEffect(isMounted ? 'horse-gallop' : 'footstep-walk', {
      volume: isMounted ? 0.34 : 0.26,
      rate: isMounted ? 1.08 : 1,
      stopAfterMs: Math.max(280, duration + 160)
    });
  }

  private directionFromDelta(deltaX: number, deltaY: number): UnitDirection {
    if (Math.abs(deltaX) > Math.abs(deltaY)) {
      return deltaX >= 0 ? 'east' : 'west';
    }
    return deltaY >= 0 ? 'south' : 'north';
  }

  private playUnitWalk(view: UnitView, direction: UnitDirection) {
    view.direction = direction;
    view.sprite.setFlipX(false);
    view.sprite.play(`${view.textureBase}-walk-${direction}`, true);
  }

  private playUnitIdle(view: UnitView, direction: UnitDirection) {
    view.direction = direction;
    view.sprite.setFlipX(false);
    view.sprite.play(`${view.textureBase}-idle-${direction}`, true);
  }

  private stopUnitWalk(view: UnitView, direction: UnitDirection) {
    view.direction = direction;
    view.sprite.stop();
    this.setUnitDirectionFrame(view, direction);
  }

  private syncUnitMotion(unit: UnitData, view = this.unitViews.get(unit.id), direction = view?.direction) {
    if (!view || !direction) {
      return;
    }

    view.direction = direction;
    if (this.actedUnitIds.has(unit.id) || unit.hp <= 0) {
      this.stopUnitWalk(view, direction);
      return;
    }

    this.playUnitIdle(view, direction);
  }

  private setUnitDirectionFrame(view: UnitView, direction: UnitDirection) {
    view.sprite.setTexture(view.textureBase, this.unitFrameIndex(direction));
    view.sprite.setFlipX(false);
  }

  private unitFrameIndex(direction: UnitDirection, frame = 0) {
    return unitFrameRows[direction] * 4 + frame;
  }

  private unitTextureKey(unit: UnitData) {
    return unitTexture[unit.id] ?? unitTextureByClass[unit.classKey] ?? 'unit-rebel';
  }

  private unitActionTexture(unit: UnitData) {
    const base = this.unitTextureKey(unit);
    const actionKey = `${base}-actions`;
    return this.textures.exists(actionKey) ? actionKey : base;
  }

  private unitActionFrameIndex(direction: UnitDirection, pose: UnitActionPose) {
    return unitFrameRows[direction] * 4 + unitActionColumns[pose];
  }

  private actionPoseForCommand(action: DamageCommand): UnitActionPose {
    if (action === 'strategy') {
      return 'strategy';
    }
    if (action === 'item') {
      return 'item';
    }
    return 'attack';
  }

  private applyActedStyle(unit: UnitData) {
    const view = this.unitViews.get(unit.id);
    if (!view) {
      return;
    }
    if (unit.hp <= 0) {
      this.applyDefeatedStyle(unit);
      return;
    }

    view.sprite.postFX.clear();
    view.sprite.clearTint();
    view.sprite.setAlpha(1);
    this.syncUnitMotion(unit, view);
    view.label.setAlpha(1);
    view.label.setColor(unit.faction === 'ally' ? '#e7edf7' : '#ffebe7');
    view.label.setBackgroundColor('');
  }

  private applyDefeatedStyle(unit: UnitData) {
    const view = this.unitViews.get(unit.id);
    if (!view) {
      return;
    }

    view.sprite.stop();
    view.sprite.disableInteractive();
    view.sprite.postFX.clear();
    view.sprite.postFX.addColorMatrix().grayscale(1);
    view.sprite.setAlpha(0.22);
    view.sprite.setTint(0x4f4f4f);
    view.label.setAlpha(0.3);
    view.label.setColor('#8a8a8a');
  }

  private faceUnitToward(unit: UnitData, target: UnitData) {
    const view = this.unitViews.get(unit.id);
    if (!view) {
      return;
    }

    const direction = this.directionFromDelta(target.x - unit.x, target.y - unit.y);
    this.syncUnitMotion(unit, view, direction);
  }

  private flashDamage(unit: UnitData, damage: number, critical = false) {
    const view = this.unitViews.get(unit.id);
    if (!view) {
      return;
    }

    const popup = this.add.text(view.sprite.x, view.sprite.y - this.layout.tileSize * 0.5, critical ? `치명 -${damage}` : `-${damage}`, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: critical ? '22px' : '20px',
      color: critical ? '#ff8f5f' : '#ffdf7b',
      stroke: '#220909',
      strokeThickness: 4,
      fontStyle: '700'
    });
    popup.setOrigin(0.5);
    popup.setDepth(30);

    view.sprite.setTintFill(0xffe0a3);
    this.time.delayedCall(120, () => {
      if (unit.hp <= 0) {
        this.applyDefeatedStyle(unit);
      } else {
        view.sprite.clearTint();
        this.syncUnitMotion(unit, view);
      }
    });

    this.tweens.add({
      targets: popup,
      y: popup.y - 28,
      alpha: 0,
      duration: 520,
      ease: 'Sine.easeOut',
      onComplete: () => popup.destroy()
    });
  }

  private flashMiss(unit: UnitData) {
    const view = this.unitViews.get(unit.id);
    if (!view) {
      return;
    }

    const popup = this.add.text(view.sprite.x, view.sprite.y - this.layout.tileSize * 0.5, '회피', {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '20px',
      color: '#d9f1ff',
      stroke: '#071623',
      strokeThickness: 4,
      fontStyle: '700'
    });
    popup.setOrigin(0.5);
    popup.setDepth(30);

    this.tweens.add({
      targets: [view.sprite, view.label],
      x: `+=${unit.faction === 'ally' ? -8 : 8}`,
      duration: 90,
      yoyo: true,
      ease: 'Sine.easeOut'
    });

    this.tweens.add({
      targets: popup,
      y: popup.y - 24,
      alpha: 0,
      duration: 500,
      ease: 'Sine.easeOut',
      onComplete: () => popup.destroy()
    });
  }

  private flashSupport(unit: UnitData, label: string, color: string) {
    const view = this.unitViews.get(unit.id);
    if (!view) {
      return;
    }

    const popup = this.add.text(view.sprite.x, view.sprite.y - this.layout.tileSize * 0.5, label, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '20px',
      color,
      stroke: '#071623',
      strokeThickness: 4,
      fontStyle: '700'
    });
    popup.setOrigin(0.5);
    popup.setDepth(30);

    this.tweens.add({
      targets: view.sprite,
      scale: view.sprite.scale * 1.08,
      duration: 120,
      yoyo: true,
      ease: 'Sine.easeOut'
    });

    this.tweens.add({
      targets: popup,
      y: popup.y - 24,
      alpha: 0,
      duration: 560,
      ease: 'Sine.easeOut',
      onComplete: () => popup.destroy()
    });
  }

  private showTurnEndPrompt(message?: string) {
    this.hideTurnEndPrompt();

    const width = 360;
    const height = 156;
    const left = this.layout.mapX + this.layout.mapWidth / 2 - width / 2;
    const top = this.layout.mapY + this.layout.mapHeight / 2 - height / 2;
    const panel = this.add.rectangle(left, top, width, height, 0x101821, 0.96);
    panel.setOrigin(0);
    panel.setDepth(40);
    panel.setStrokeStyle(2, palette.gold, 0.9);
    this.turnPromptObjects.push(panel);

    const title = this.add.text(left + width / 2, top + 26, '모든 아군의 행동이 종료되었습니다.', {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '20px',
      color: '#f2e3bf',
      fontStyle: '700'
    });
    title.setOrigin(0.5);
    title.setDepth(41);
    this.turnPromptObjects.push(title);

    const body = this.add.text(left + width / 2, top + 58, '턴을 종료하시겠습니까?', {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '17px',
      color: '#d4dce6'
    });
    body.setOrigin(0.5);
    body.setDepth(41);
    this.turnPromptObjects.push(body);

    const buttons = [
      { label: '턴 종료', x: left + 106, action: () => this.endAllyTurn() },
      { label: '전장 확인', x: left + 254, action: () => this.hideTurnEndPrompt() }
    ];

    buttons.forEach(({ label, x, action }) => {
      const bg = this.add.rectangle(x, top + 112, 116, 36, 0x1a2630, 0.95);
      bg.setDepth(41);
      bg.setStrokeStyle(1, label === '턴 종료' ? palette.gold : palette.blue, 0.72);
      bg.setInteractive({ useHandCursor: true });
      bg.on('pointerover', () => bg.setFillStyle(0x283947, 0.98));
      bg.on('pointerout', () => bg.setFillStyle(0x1a2630, 0.95));
      bg.on('pointerdown', action);
      this.turnPromptObjects.push(bg);

      const text = this.add.text(x, top + 112, label, {
        fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
        fontSize: '16px',
        color: '#f2e3bf',
        fontStyle: '700'
      });
      text.setOrigin(0.5);
      text.setDepth(42);
      text.setInteractive({ useHandCursor: true });
      text.on('pointerdown', action);
      this.turnPromptObjects.push(text);
    });

    void message;
  }

  private hideTurnEndPrompt() {
    this.turnPromptObjects.forEach((object) => object.destroy());
    this.turnPromptObjects = [];
  }

  private pushBattleLog(message: string) {
    const firstLine = message.split('\n')[0];
    this.battleLog = [firstLine, ...this.battleLog].slice(0, 8);
  }

  private sideContentTop() {
    return this.layout.panelY + 152;
  }

  private renderBondPanel(message?: string) {
    this.clearSidePanelContent();
    const { panelX, panelWidth } = this.layout;
    const left = panelX + 24;
    const width = panelWidth - 48;
    const top = this.sideContentTop();

    this.trackSideObject(this.add.text(left, top, '공명 관계', {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '22px',
      color: '#f2e3bf',
      fontStyle: '700'
    }));
    this.trackSideObject(this.add.text(left, top + 34, '같은 적을 노리거나 전투 사이 이벤트로 상승합니다.', {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '14px',
      color: '#9fb0bf'
    }));

    Array.from(this.bondStates.values()).forEach((bond, index) => {
      const y = top + 70 + index * 104;
      const [first, second] = bond.unitIds.map((unitId) => this.unitName(unitId));
      const bg = this.trackSideObject(this.add.rectangle(left, y, width, 92, 0x101820, 0.9));
      bg.setOrigin(0);
      bg.setStrokeStyle(1, bond.level >= 70 ? palette.gold : palette.blue, 0.66);

      this.trackSideObject(this.add.text(left + 12, y + 8, `${first} · ${second}`, {
        fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
        fontSize: '17px',
        color: '#e8dfca',
        fontStyle: '700'
      }));
      const levelText = this.trackSideObject(this.add.text(left + width - 12, y + 8, `${bond.level}`, {
        fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
        fontSize: '18px',
        color: '#f2e3bf',
        fontStyle: '700'
      }));
      levelText.setOrigin(1, 0);

      this.trackSideObject(this.add.text(left + 12, y + 33, bond.title, {
        fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
        fontSize: '14px',
        color: '#d8b15f'
      }));
      this.drawGauge(left + 86, y + 39, width - 148, 8, bond.exp / 100, bond.level >= 70 ? 0xd8b15f : 0x58aee0);

      const bonus = this.bondBonus(bond);
      this.trackSideObject(this.add.text(left + 12, y + 59, `피해 +${bonus.damageBonus}%  연계 ${bonus.chainRate}%  전투 +${bond.battleExp}`, {
        fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
        fontSize: '14px',
        color: '#d4dce6'
      }));
    });

    this.renderPanelMessage(message ?? '공격 명령에서 같은 예상 목표를 노리면 공명 경험치가 쌓입니다.', left, top + 396, width);
  }

  private renderSituationPanel(message?: string) {
    this.clearSidePanelContent();
    const { panelX, panelWidth } = this.layout;
    const left = panelX + 24;
    const width = panelWidth - 48;
    const top = this.sideContentTop();
    const actedCount = battleUnits.filter((unit) => unit.faction === 'ally' && this.actedUnitIds.has(unit.id)).length;
    const allyCount = battleUnits.filter((unit) => unit.faction === 'ally').length;
    const enemyCount = battleUnits.filter((unit) => unit.faction === 'enemy' && unit.hp > 0).length;
    const bestBond = Array.from(this.bondStates.values()).sort((a, b) => b.level - a.level)[0];
    const objectiveStates = this.objectiveStates(this.battleOutcome);

    this.trackSideObject(this.add.text(left, top, '전황', {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '24px',
      color: '#f2e3bf',
      fontStyle: '700'
    }));

    this.renderSituationLine('현재 턴', `${this.turnNumber}턴 / ${factionLabels[this.activeFaction]}`, left, top + 46, width);
    this.renderSituationLine('아군 행동', `${actedCount} / ${allyCount}`, left, top + 84, width);
    this.renderSituationLine('남은 적', `${enemyCount}`, left, top + 122, width);
    this.renderSituationLine('BGM', soundDirector.isMuted() ? '꺼짐' : '켜짐', left, top + 160, width);
    this.renderSituationLine(
      '최고 공명',
      bestBond ? `${this.unitName(bestBond.unitIds[0])}·${this.unitName(bestBond.unitIds[1])} ${bestBond.level}` : '-',
      left,
      top + 198,
      width
    );

    if (message) {
      this.renderPanelMessage(this.compactSituationMessage(message), left, top + 246, width, 72);
      return;
    }

    this.trackSideObject(this.add.text(left, top + 238, '목표 현황', {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '17px',
      color: '#f2e3bf',
      fontStyle: '700'
    }));
    objectiveStates.forEach((objective, index) => {
      this.renderObjectiveStateRow(objective, left, top + 264 + index * 28, width);
    });
  }

  private compactSituationMessage(message: string) {
    return message
      .split('\n')
      .slice(0, 2)
      .map((line) => (line.length > 18 ? `${line.slice(0, 17)}...` : line))
      .join('\n');
  }

  private updateObjectiveTracker() {
    if (!this.objectiveTrackerText || !this.objectiveTrackerSubText) {
      return;
    }

    const leader = battleUnits.find((unit) => unit.id === leaderUnitId);
    const leaderProgress = leader && leader.hp > 0 ? `${leader.hp}/${leader.maxHp}` : '완료';
    const quickObjective = this.objectiveStates(this.battleOutcome).find((objective) => objective.id === 'quick');
    const quickText = quickObjective ? ` · ${quickObjective.label} ${this.objectiveStatusText(quickObjective)}` : '';
    const victoryText =
      this.battleOutcome === 'victory'
        ? `승리: ${battleScenario.victoryConditionLabel} 완료`
        : `승리: ${battleScenario.victoryConditionLabel} (${leaderProgress})`;
    const defeatText =
      this.battleOutcome === 'defeat'
        ? `패배: ${battleScenario.defeatConditionLabel} 발생`
        : `패배: ${battleScenario.defeatConditionLabel}`;

    this.objectiveTrackerText.setText(victoryText);
    this.objectiveTrackerSubText.setText(`${defeatText}${quickText}`);
  }

  private renderObjectiveStateRow(objective: BattleObjectiveState, x: number, y: number, width: number) {
    const bg = this.trackSideObject(this.add.rectangle(x, y, width, 25, 0x101820, 0.86));
    bg.setOrigin(0);
    bg.setStrokeStyle(1, this.objectiveStatusStroke(objective), 0.5);

    this.trackSideObject(this.add.text(x + 10, y + 5, this.objectiveStatusText(objective), {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '12px',
      color: this.objectiveStatusColor(objective),
      fontStyle: '700'
    }));
    this.trackSideObject(this.add.text(x + 54, y + 4, objective.label, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '13px',
      color: '#d4dce6',
      fontStyle: '700'
    }));
    const detail = this.trackSideObject(this.add.text(x + width - 10, y + 4, objective.detail, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '12px',
      color: '#9fb0bf'
    }));
    detail.setOrigin(1, 0);
  }

  private objectiveStatusText(objective: BattleObjectiveState) {
    if (objective.status === 'done') {
      return '완료';
    }
    if (objective.status === 'failed') {
      return '실패';
    }
    if (objective.id === 'liu-bei') {
      return '유지';
    }
    if (objective.id === 'quick') {
      return '가능';
    }
    return '진행';
  }

  private objectiveStatusColor(objective: BattleObjectiveState) {
    if (objective.status === 'done') {
      return '#a8ffd0';
    }
    if (objective.status === 'failed') {
      return '#ffb6a6';
    }
    return '#f4dfad';
  }

  private objectiveStatusStroke(objective: BattleObjectiveState) {
    if (objective.status === 'done') {
      return 0x59d18c;
    }
    if (objective.status === 'failed') {
      return 0xb86b55;
    }
    return palette.gold;
  }

  private renderThreatDetail(tile: ThreatTile) {
    this.clearSidePanelContent();
    const terrain = battleMap.terrain[tile.y][tile.x];
    const terrainRule = getTerrainRule(terrain);
    const { panelX, panelWidth } = this.layout;
    const left = panelX + 24;
    const width = panelWidth - 48;
    const top = this.sideContentTop();
    const strongest = [...tile.enemies].sort((a, b) => this.actionPower(b, 'attack') - this.actionPower(a, 'attack'))[0];

    this.trackSideObject(this.add.text(left, top, '위험 범위', {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '24px',
      color: '#f2e3bf',
      fontStyle: '700'
    }));

    this.renderSituationLine('좌표', `${tile.x + 1}, ${tile.y + 1}`, left, top + 48, width);
    this.renderSituationLine('지형', `${terrainRule.label} / 방어 ${this.formatSignedValue(terrainRule.defenseBonus)}`, left, top + 86, width);
    this.renderSituationLine('위협 수', `${tile.enemies.length}`, left, top + 124, width);
    this.renderSituationLine('최대 태세', this.enemyBehaviorLabel(tile.strongestBehavior), left, top + 162, width);

    this.trackSideObject(this.add.text(left, top + 206, '위협 부대', {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '17px',
      color: '#f2e3bf',
      fontStyle: '700'
    }));

    tile.enemies.slice(0, 4).forEach((enemy, index) => {
      const rowY = top + 232 + index * 28;
      const unitClass = getUnitClass(enemy.classKey);
      const bg = this.trackSideObject(this.add.rectangle(left, rowY, width, 24, 0x101820, 0.86));
      bg.setOrigin(0);
      bg.setStrokeStyle(1, this.threatColor(this.enemyBehavior(enemy)), 0.52);
      this.trackSideObject(this.add.text(left + 10, rowY + 5, `${enemy.name} · ${unitClass.name}`, {
        fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
        fontSize: '12px',
        color: '#d4dce6',
        fontStyle: '700'
      }));
      const value = this.trackSideObject(this.add.text(left + width - 10, rowY + 5, `${this.enemyBehaviorLabel(this.enemyBehavior(enemy))} / 공 ${this.actionPower(enemy, 'attack')} / 사 ${this.attackRange(enemy)}`, {
        fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
        fontSize: '12px',
        color: '#f4dfad'
      }));
      value.setOrigin(1, 0);
    });

    const message = strongest
      ? `${strongest.name} 최대 위협. 방어 보정 지형을 활용하세요.`
      : '현재 이 칸을 위협하는 적이 없습니다.';
    this.renderPanelMessage(message, left, top + 348, width, 46);
  }

  private renderTerrainDetail(x: number, y: number) {
    this.clearSidePanelContent();
    const terrain = battleMap.terrain[y][x];
    const terrainRule = getTerrainRule(terrain);
    const { panelX, panelWidth } = this.layout;
    const left = panelX + 24;
    const width = panelWidth - 48;
    const top = this.sideContentTop();
    const attackDefense = Math.floor(terrainRule.defenseBonus / 3);
    const strategyDefense = Math.floor(terrainRule.defenseBonus / 4);
    const itemDefense = Math.floor(terrainRule.defenseBonus / 5);

    this.trackSideObject(this.add.text(left, top, '지형 정보', {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '24px',
      color: '#f2e3bf',
      fontStyle: '700'
    }));

    this.renderSituationLine('좌표', `${x + 1}, ${y + 1}`, left, top + 48, width);
    this.renderSituationLine('지형', terrainDisplayLabels[terrain], left, top + 92, width);
    this.renderSituationLine('이동 비용', terrainRule.passable === false ? '진입 불가' : `${terrainRule.moveCost}`, left, top + 136, width);
    this.renderSituationLine('방어 보정', this.formatSignedValue(terrainRule.defenseBonus), left, top + 180, width);
    this.renderSituationLine('공격 방어', this.formatSignedValue(attackDefense), left, top + 224, width);
    this.renderSituationLine('책략 저항', this.formatSignedValue(strategyDefense), left, top + 268, width);
    this.renderSituationLine('도구 피해', this.formatSignedValue(itemDefense), left, top + 312, width);

    const recovery =
      terrainRule.recoveryHp || terrainRule.moraleBonus
        ? `\n턴 시작 시 병력 +${terrainRule.recoveryHp ?? 0}, 사기 +${terrainRule.moraleBonus ?? 0} 효과를 받습니다.`
        : '';
    const message =
      terrainRule.passable === false
        ? '이 지형은 이동할 수 없습니다. 길목과 다리, 완만한 평지를 찾아 우회해야 합니다.'
        : terrainRule.defenseBonus > 0
        ? '이 지형에 있는 부대는 방어 보정으로 받는 피해가 줄어듭니다.'
        : '이 지형은 기본 방어 보정이 없습니다.';
    this.renderPanelMessage(`${message}${recovery}`, left, top + 370, width, 76);
  }

  private formatSignedValue(value: number) {
    if (value > 0) {
      return `+${value}`;
    }
    return `${value}`;
  }

  private renderSituationLine(label: string, value: string, x: number, y: number, width: number) {
    const bg = this.trackSideObject(this.add.rectangle(x, y, width, 34, 0x101820, 0.86));
    bg.setOrigin(0);
    bg.setStrokeStyle(1, 0x53606c, 0.46);
    this.trackSideObject(this.add.text(x + 12, y + 8, label, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '15px',
      color: '#9fb0bf'
    }));
    const valueText = this.trackSideObject(this.add.text(x + width - 12, y + 7, value, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '16px',
      color: '#f2e3bf',
      fontStyle: '700'
    }));
    valueText.setOrigin(1, 0);
  }

  private bondBonus(bond: BondState) {
    return {
      damageBonus: Math.floor(bond.level / 20) * 3,
      chainRate: bond.level >= 70 ? 18 : bond.level >= 50 ? 8 : 0
    };
  }

  private unitName(unitId: string) {
    return battleUnits.find((unit) => unit.id === unitId)?.name ?? unitId;
  }

  private renderRosterPanel(tab: RosterTab = this.rosterTab, message?: string) {
    this.rosterTab = tab;
    this.clearSidePanelContent();

    const { panelX, panelY, panelWidth, panelHeight } = this.layout;
    const left = panelX + 24;
    const width = panelWidth - 48;
    const top = this.sideContentTop();
    const tabGap = 8;
    const tabWidth = (width - tabGap) / 2;

    (['ally', 'enemy'] as RosterTab[]).forEach((targetTab, index) => {
      const active = targetTab === tab;
      const x = left + index * (tabWidth + tabGap);
      const tabBg = this.trackSideObject(this.add.rectangle(x, top, tabWidth, 38, active ? 0x25384a : 0x141e29, active ? 0.98 : 0.86));
      tabBg.setOrigin(0);
      tabBg.setStrokeStyle(1, active ? palette.gold : 0x53606c, active ? 0.92 : 0.62);
      tabBg.setInteractive({ useHandCursor: true });
      tabBg.on('pointerdown', () => this.renderRosterPanel(targetTab));

      const tabText = this.trackSideObject(this.add.text(x + tabWidth / 2, top + 19, rosterLabels[targetTab], {
        fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
        fontSize: '18px',
        color: active ? '#f4dfad' : '#aeb7c2',
        fontStyle: '700'
      }));
      tabText.setOrigin(0.5);
    });

    const units = battleUnits.filter((unit) => unit.faction === tab);
    const listTop = top + 54;
    units.forEach((unit, index) => {
      this.renderRosterRow(unit, left, listTop + index * 50, width);
    });

    if (message) {
      const messageHeight = 104;
      const messageTop = Math.min(panelY + panelHeight - messageHeight - 124, listTop + units.length * 50 + 14);
      this.renderPanelMessage(message, left, messageTop, width, messageHeight);
    }
  }

  private renderRosterRow(unit: UnitData, x: number, y: number, width: number) {
    const acted = this.actedUnitIds.has(unit.id);
    const active = this.selectedUnit?.id === unit.id;
    const unitClass = getUnitClass(unit.classKey);
    const rowBg = this.trackSideObject(this.add.rectangle(x, y, width, 42, active ? 0x2f4050 : 0x101820, acted ? 0.54 : 0.9));
    rowBg.setOrigin(0);
    rowBg.setStrokeStyle(1, active ? palette.gold : unit.faction === 'ally' ? palette.blue : 0xb86b55, active ? 0.88 : 0.52);
    rowBg.setInteractive({ useHandCursor: true });
    rowBg.on('pointerdown', () => this.selectUnit(unit));

    const nameColor = acted ? '#a8a8a8' : unit.faction === 'ally' ? '#e9f0f8' : '#ffe0d8';
    this.trackSideObject(this.add.text(x + 12, y + 7, unit.name, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '17px',
      color: nameColor,
      fontStyle: '700'
    }));
    this.trackSideObject(this.add.text(x + 88, y + 9, unitClass.name,
    {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '14px',
      color: acted ? '#8c8c8c' : '#9fb0bf'
    }));

    const hpText = this.trackSideObject(this.add.text(x + width - 12, y + 7, `${unit.hp}/${unit.maxHp}`, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '16px',
      color: acted ? '#9a9a9a' : '#f0e4c8',
      fontStyle: '700'
    }));
    hpText.setOrigin(1, 0);

    this.drawGauge(x + 88, y + 29, width - 100, 6, unit.hp / unit.maxHp, acted ? 0x9a9a9a : 0x59d18c);

    if (acted) {
      const actedText = this.trackSideObject(this.add.text(x + 12, y + 27, '행동완료', {
        fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
        fontSize: '12px',
        color: '#bdbdbd'
      }));
      actedText.setAlpha(0.78);
    }
  }

  private renderUnitDetail(unit: UnitData, message?: string) {
    this.clearSidePanelContent();

    const { panelX, panelWidth } = this.layout;
    const left = panelX + 24;
    const width = panelWidth - 48;
    const top = this.sideContentTop();
    const acted = this.actedUnitIds.has(unit.id);
    const factionColor = unit.faction === 'ally' ? palette.blue : 0xb86b55;
    const unitClass = getUnitClass(unit.classKey);
    const terrain = battleMap.terrain[unit.y][unit.x];
    const terrainRule = getTerrainRule(terrain);
    const terrainRating = unitClass.terrainRatings[terrain];

    const header = this.trackSideObject(this.add.rectangle(left, top, width, 60, 0x101820, 0.94));
    header.setOrigin(0);
    header.setStrokeStyle(1, factionColor, 0.76);

    this.trackSideObject(this.add.text(left + 14, top + 9, `${rosterLabels[unit.faction]} / ${unitClass.family}`, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '14px',
      color: unit.faction === 'ally' ? '#9fd0ff' : '#ffb2a4',
      fontStyle: '700'
    }));
    this.trackSideObject(this.add.text(left + 14, top + 29, `${unit.name}  ${unitClass.name}`, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '22px',
      color: acted ? '#bdbdbd' : '#f2e3bf',
      fontStyle: '700'
    }));

    if (this.phase !== 'command') {
      const backBg = this.trackSideObject(this.add.rectangle(left + width - 66, top + 10, 52, 28, 0x1b2834, 0.9));
      backBg.setOrigin(0);
      backBg.setStrokeStyle(1, palette.gold, 0.72);
      backBg.setInteractive({ useHandCursor: true });
      backBg.on('pointerdown', () => this.returnToRosterPanel(unit.faction));

      const backText = this.trackSideObject(this.add.text(left + width - 40, top + 24, '목록', {
        fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
        fontSize: '14px',
        color: '#f4dfad',
        fontStyle: '700'
      }));
      backText.setOrigin(0.5);
    }

    this.trackSideObject(this.add.text(left, top + 78, '병력', {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '17px',
      color: '#d4dce6',
      fontStyle: '700'
    }));
    const hpValue = this.trackSideObject(this.add.text(left + width, top + 76, `${unit.hp} / ${unit.maxHp}`, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '18px',
      color: '#f0e4c8',
      fontStyle: '700'
    }));
    hpValue.setOrigin(1, 0);
    this.drawGauge(left + 76, top + 84, width - 160, 10, unit.hp / unit.maxHp, 0x59d18c);

    const attackBonus = this.equipmentAttackBonus(unit);
    this.renderSmallValueBox(left, top + 112, '공격', `${unit.attack + attackBonus}`);
    this.renderSmallValueBox(left + width / 2 + 6, top + 112, '이동', `${unit.move}`);

    const statTop = top + 158;
    statLabels.forEach((stat, index) => {
      this.renderStatRow(stat.label, unit.stats[stat.key], left, statTop + index * 28, width);
    });

    const positionText = `위치 ${unit.x + 1}, ${unit.y + 1} / ${terrainRule.label} ${terrainRating}%${acted ? ' / 행동완료' : ''}`;
    this.trackSideObject(this.add.text(left, top + 306, positionText, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '15px',
      color: acted ? '#bdbdbd' : '#9fb0bf'
    }));

    this.renderEquipmentSummary(unit, left, top + 330, width);

    if (message) {
      this.renderPanelMessage(message, left, top + 440, width, 50);
    }
  }

  private renderEquipmentSummary(unit: UnitData, x: number, y: number, width: number) {
    this.trackSideObject(this.add.text(x, y, '장비', {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '16px',
      color: '#f2e3bf',
      fontStyle: '700'
    }));

    equipmentSlots.forEach((slot, index) => {
      this.renderEquipmentRow(unit, slot, x, y + 24 + index * 28, width);
    });
  }

  private renderEquipmentRow(unit: UnitData, slot: EquipmentSlot, x: number, y: number, width: number) {
    const state = unit.equipment[slot];
    const item = getItem(state.itemId);
    const next = equipmentExpToNext(state.level);
    const isTreasure = item.rank === 'treasure';
    const bg = this.trackSideObject(this.add.rectangle(x, y, width, 26, isTreasure ? 0x1f2430 : 0x101820, isTreasure ? 0.94 : 0.86));
    bg.setOrigin(0);
    bg.setStrokeStyle(1, isTreasure ? palette.gold : 0x53606c, isTreasure ? 0.58 : 0.42);

    const iconFrame = this.trackSideObject(this.add.rectangle(x + 14, y + 13, 22, 22, 0x0a0f14, 0.82));
    iconFrame.setStrokeStyle(1, isTreasure ? palette.gold : 0x53606c, isTreasure ? 0.72 : 0.48);

    const icon = this.trackSideObject(this.add.image(x + 14, y + 13, this.itemIconKey(item.id)));
    icon.setDisplaySize(22, 22);

    this.trackSideObject(this.add.text(x + 31, y + 6, equipmentSlotLabels[slot], {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '12px',
      color: '#9fb0bf',
      fontStyle: '700'
    }));
    this.trackSideObject(this.add.text(x + 78, y + 4, item.name, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '13px',
      color: isTreasure ? '#f4dfad' : '#d4dce6',
      fontStyle: isTreasure ? '700' : '400'
    }));

    const bonusText = this.itemBonusText(item);
    this.trackSideObject(this.add.text(x + width - 124, y + 6, bonusText, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '11px',
      color: '#9fb0bf'
    }));

    const expText = this.trackSideObject(this.add.text(x + width - 54, y + 6, `${state.exp}/${next}`, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '11px',
      color: '#9fb0bf'
    }));
    expText.setOrigin(1, 0);

    const levelText = this.trackSideObject(this.add.text(x + width - 8, y + 3, `Lv ${state.level}`, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '14px',
      color: isTreasure ? '#f2e3bf' : '#d4dce6',
      fontStyle: '700'
    }));
    levelText.setOrigin(1, 0);

    this.drawGauge(x + 78, y + 20, width - 178, 4, state.exp / next, isTreasure ? 0xd8b15f : 0x58aee0);
  }

  private itemIconKey(itemId: string) {
    const key = `item-${itemId}`;
    return this.textures.exists(key) ? key : 'item-training-sword';
  }

  private itemBonusText(item: ReturnType<typeof getItem>) {
    const parts = [
      item.attackBonus ? `공+${item.attackBonus}` : '',
      item.defenseBonus ? `방+${item.defenseBonus}` : '',
      item.strategyBonus ? `책+${item.strategyBonus}` : ''
    ].filter(Boolean);
    return parts.length > 0 ? parts.join(' ') : '-';
  }

  private equipmentAttackBonus(unit: UnitData) {
    return equipmentSlots.reduce((total, slot) => {
      const item = getItem(unit.equipment[slot].itemId);
      const levelBonus = slot === 'weapon' ? unit.equipment[slot].level - 1 : 0;
      return total + (item.attackBonus ?? 0) + levelBonus;
    }, 0);
  }

  private equipmentStrategyBonus(unit: UnitData) {
    return equipmentSlots.reduce((total, slot) => {
      const item = getItem(unit.equipment[slot].itemId);
      const levelBonus = slot === 'accessory' ? unit.equipment[slot].level - 1 : 0;
      return total + (item.strategyBonus ?? 0) + levelBonus;
    }, 0);
  }

  private equipmentDefenseBonus(unit: UnitData) {
    return equipmentSlots.reduce((total, slot) => {
      const item = getItem(unit.equipment[slot].itemId);
      const levelBonus = slot === 'armor' ? unit.equipment[slot].level - 1 : 0;
      return total + (item.defenseBonus ?? 0) + levelBonus;
    }, 0);
  }

  private renderSmallValueBox(x: number, y: number, label: string, value: string) {
    const boxWidth = (this.layout.panelWidth - 60) / 2;
    const bg = this.trackSideObject(this.add.rectangle(x, y, boxWidth, 38, 0x16212d, 0.92));
    bg.setOrigin(0);
    bg.setStrokeStyle(1, 0x53606c, 0.56);
    this.trackSideObject(this.add.text(x + 12, y + 9, label, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '14px',
      color: '#9fb0bf'
    }));
    const valueText = this.trackSideObject(this.add.text(x + boxWidth - 12, y + 7, value, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '18px',
      color: '#f2e3bf',
      fontStyle: '700'
    }));
    valueText.setOrigin(1, 0);
  }

  private renderStatRow(label: string, value: number, x: number, y: number, width: number) {
    this.trackSideObject(this.add.text(x, y, label, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '16px',
      color: '#d4dce6',
      fontStyle: '700'
    }));

    this.drawGauge(x + 76, y + 8, width - 130, 9, value / 100, value >= 80 ? 0xd8b15f : 0x58aee0);

    const valueText = this.trackSideObject(this.add.text(x + width, y - 1, `${value}`, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '17px',
      color: '#f2e3bf',
      fontStyle: '700'
    }));
    valueText.setOrigin(1, 0);
  }

  private drawGauge(x: number, y: number, width: number, height: number, ratio: number, color: number) {
    const track = this.trackSideObject(this.add.rectangle(x, y, width, height, 0x0a0f14, 0.82));
    track.setOrigin(0);
    track.setStrokeStyle(1, 0x40515e, 0.58);

    const fillWidth = Math.max(2, Math.floor(width * Phaser.Math.Clamp(ratio, 0, 1)));
    const fill = this.trackSideObject(this.add.rectangle(x + 1, y + 1, fillWidth - 2, Math.max(2, height - 2), color, 0.96));
    fill.setOrigin(0);
  }

  private renderPanelMessage(text: string, x: number, y: number, width: number, height = 74) {
    const bg = this.trackSideObject(this.add.rectangle(x, y, width, height, 0x101820, 0.72));
    bg.setOrigin(0);
    bg.setStrokeStyle(1, 0x53606c, 0.42);
    this.trackSideObject(this.add.text(x + 12, y + 10, text, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: height < 60 ? '13px' : '15px',
      color: '#d4dce6',
      wordWrap: { width: width - 24, useAdvancedWrap: true },
      lineSpacing: height < 60 ? 2 : 4
    }));
  }

  private returnToRosterPanel(tab: RosterTab) {
    this.selectedUnit = undefined;
    this.pendingMove = undefined;
    this.phase = 'idle';
    this.clearMarkers();
    this.hideCommandMenu();
    this.renderRosterPanel(tab);
  }

  private clearSidePanelContent() {
    this.sidePanelObjects.forEach((object) => object.destroy());
    this.sidePanelObjects = [];
  }

  private trackSideObject<T extends Phaser.GameObjects.GameObject>(object: T) {
    this.sidePanelObjects.push(object);
    return object;
  }

  private setInfo(text: string) {
    if (this.selectedUnit) {
      this.renderUnitDetail(this.selectedUnit, text);
      return;
    }

    this.renderRosterPanel(this.rosterTab, text);
  }

  getDebugState() {
    return {
      scene: this.scene.key,
      battleId: battleScenario.id,
      battleTitle: battleScenario.title,
      victoryConditionLabel: battleScenario.victoryConditionLabel,
      defeatConditionLabel: battleScenario.defeatConditionLabel,
      turnNumber: this.turnNumber,
      activeFaction: this.activeFaction,
      phase: this.phase,
      battleOutcome: this.battleOutcome ?? null,
      resultVisible: this.resultObjects.length > 0,
      markerCount: this.markers.length,
      threatMarkerCount: this.markers.filter((marker) => marker.getData('markerType') === 'threat').length,
      camera: {
        x: this.cameraTileX,
        y: this.cameraTileY,
        visibleColumns: this.layout.visibleColumns,
        visibleRows: this.layout.visibleRows,
        mapWidth: battleMap.width,
        mapHeight: battleMap.height
      },
      selectedUnitId: this.selectedUnit?.id ?? null,
      pendingMove: this.pendingMove
        ? {
            unitId: this.pendingMove.unit.id,
            from: { x: this.pendingMove.fromX, y: this.pendingMove.fromY },
            to: { x: this.pendingMove.toX, y: this.pendingMove.toY }
          }
        : null,
      actedUnitIds: Array.from(this.actedUnitIds),
      attackIntents: this.attackIntents.map((intent) => ({ ...intent })),
      battleLog: [...this.battleLog],
      objectives: this.objectiveStates(this.battleOutcome).map((objective) => ({ ...objective })),
      battleStats: this.serializeBattleStats(),
      triggeredBattleEvents: Array.from(this.triggeredBattleEvents),
      selectedUsable: this.selectedUsable?.id ?? null,
      itemStocks: this.serializeItemStocks(),
      battleBuffs: Array.from(this.battleBuffs.values()).map((buff) => ({ ...buff })),
      units: battleUnits.map((unit) => ({
        id: unit.id,
        name: unit.name,
        faction: unit.faction,
        classKey: unit.classKey,
        ai: unit.faction === 'enemy' ? this.enemyBehavior(unit) : null,
        attackRange: this.attackRange(unit),
        level: unit.level,
        exp: unit.exp,
        hp: unit.hp,
        maxHp: unit.maxHp,
        x: unit.x,
        y: unit.y,
        acted: this.actedUnitIds.has(unit.id),
        animating: this.unitViews.get(unit.id)?.sprite.anims?.isPlaying ?? false,
        animationKey: this.unitViews.get(unit.id)?.sprite.anims?.currentAnim?.key ?? null
      })),
      bonds: Array.from(this.bondStates.values()).map((bond) => ({
        id: bond.id,
        unitIds: bond.unitIds,
        level: bond.level,
        exp: bond.exp,
        battleExp: bond.battleExp
      }))
    };
  }

  debugForceBattleOutcome(outcome: BattleOutcome = 'victory') {
    if (!import.meta.env.DEV || this.battleOutcome) {
      return;
    }

    if (outcome === 'victory') {
      battleUnits
        .filter((unit) => unit.faction === 'enemy')
        .forEach((unit) => {
          unit.hp = 0;
          this.applyDefeatedStyle(unit);
        });
      this.updateMiniMap();
    } else {
      const liuBei = battleUnits.find((unit) => unit.id === 'liu-bei');
      if (liuBei) {
        liuBei.hp = 0;
        this.applyDefeatedStyle(liuBei);
      }
      this.updateMiniMap();
    }

    this.completeBattle(outcome);
  }

  toggleDebugOverlay() {
    if (this.debugOverlay) {
      this.debugOverlay.destroy();
      this.debugOverlay = undefined;
      return;
    }

    this.debugOverlay = this.add.text(12, 12, '', {
      fontFamily: 'Consolas, monospace',
      fontSize: '13px',
      color: '#dff7ff',
      backgroundColor: 'rgba(0, 0, 0, 0.72)',
      padding: { x: 8, y: 6 }
    });
    this.debugOverlay.setDepth(1000);
    this.updateDebugOverlay();
  }

  private installDebugHotkeys() {
    if (!import.meta.env.DEV) {
      return;
    }

    this.input.keyboard?.on('keydown-F9', () => this.toggleDebugOverlay());
    this.input.keyboard?.on('keydown-F10', () => {
      console.info('[Battle Debug]', this.getDebugState());
      this.updateDebugOverlay();
    });
    this.input.on('pointermove', () => this.updateDebugOverlay());
    this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
      this.debugOverlay?.destroy();
      this.debugOverlay = undefined;
    });
  }

  private updateDebugOverlay() {
    if (!this.debugOverlay) {
      return;
    }

    const pointerTile = this.pointerToTile(this.input.activePointer);
    const selected = this.selectedUnit ? `${this.selectedUnit.name} (${this.selectedUnit.x},${this.selectedUnit.y})` : 'none';
    const pending = this.pendingMove
      ? `${this.pendingMove.unit.name}: (${this.pendingMove.fromX},${this.pendingMove.fromY}) -> (${this.pendingMove.toX},${this.pendingMove.toY})`
      : 'none';

    this.debugOverlay.setText([
      'Heros Web Debug',
      `scene=${this.scene.key}`,
      `turn=${this.turnNumber} faction=${this.activeFaction} phase=${this.phase}`,
      `selected=${selected}`,
      `pendingMove=${pending}`,
      `acted=${Array.from(this.actedUnitIds).join(',') || 'none'}`,
      `pointerTile=${pointerTile ? `${pointerTile.x},${pointerTile.y}` : 'outside'}`,
      'F9 toggle overlay / F10 log state'
    ]);
  }
}
