import Phaser from 'phaser'; import { soundDirector } from '../audio/SoundDirector'; import { firstBattleBonds, firstBattleMap, firstBattleUnits, type BattleBond, type UnitData, type UnitStats } from '../data/scenario'; import { getTerrainRule, getUnitClass, type TerrainType, type UnitClassKey } from '../data/battleRules'; import { equipmentExpToNext, equipmentSlotLabels, equipmentSlots, getItem, type EquipmentSlot } from '../data/battleItems'; import { palette } from '../ui/palette'; const unitTexture: Record = { '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-leader': 'unit-rebel-leader' }; const unitFrameRows: Record = { south: 0, east: 1, north: 2, west: 3 }; type BattleLayout = { mapX: number; mapY: number; mapWidth: number; mapHeight: number; gridX: number; gridY: number; gridSize: number; tileSize: number; panelX: number; panelY: number; panelWidth: number; panelHeight: number; }; type UnitView = { sprite: Phaser.GameObjects.Sprite; label: Phaser.GameObjects.Text; textureBase: string; direction: UnitDirection; }; type UnitDirection = 'south' | 'east' | 'north' | 'west'; type BattlePhase = 'idle' | 'moving' | 'command' | 'targeting' | 'animating'; type BattleCommand = 'attack' | 'strategy' | 'item' | 'wait'; type DamageCommand = Exclude; type RosterTab = 'ally' | 'enemy'; type ActiveFaction = 'ally' | 'enemy'; type MapMenuAction = 'endTurn' | 'save' | 'load' | 'roster' | 'bond' | 'situation' | 'bgm' | 'close'; type EnemyAiBehavior = 'aggressive' | 'guard' | 'hold'; 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 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 CombatPreview = { action: DamageCommand; attacker: UnitData; defender: UnitData; damage: number; hitRate: number; terrainLabel: string; }; type CombatResult = CombatPreview & { previousDefenderHp: number; defeated: boolean; characterGrowth: CharacterGrowthResult; attackerGrowth: EquipmentGrowthResult; defenderGrowth: EquipmentGrowthResult; bondExp: number; }; type SavedUnitState = Pick & { equipment: UnitData['equipment']; direction?: UnitDirection; }; type BattleSaveState = { version: 1; savedAt: string; turnNumber: number; activeFaction: ActiveFaction; rosterTab: RosterTab; actedUnitIds: string[]; attackIntents: AttackIntent[]; battleLog: string[]; units: SavedUnitState[]; bonds: BondState[]; }; const maxEquipmentLevel = 9; const maxCharacterLevel = 99; const battleSaveStorageKey = 'heros-web:first-battle-state'; const attackRangeByClass: Partial> = { archer: 2 }; const enemyAiByUnitId: Record = { 'rebel-a': 'aggressive', 'rebel-b': 'guard', 'rebel-c': 'hold', 'rebel-leader': 'guard' }; const defaultEnemyAiByClass: Partial> = { archer: 'hold', cavalry: 'aggressive', yellowTurban: 'guard', rebelLeader: 'guard' }; const guardDetectionRange = 5; const commandLabels: Record = { attack: '공격', strategy: '책략', item: '도구', wait: '대기' }; const rosterLabels: Record = { ally: '아군', enemy: '적군' }; const factionLabels: Record = { ally: '아군', enemy: '적군' }; const mapMenuLabels: Partial> = { endTurn: '턴 종료', 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: '운' } ]; 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 turnText?: Phaser.GameObjects.Text; private markers: Phaser.GameObjects.Rectangle[] = []; private rosterTab: RosterTab = 'ally'; private sidePanelObjects: Phaser.GameObjects.GameObject[] = []; private commandButtons: CommandButton[] = []; private commandMenuObjects: Array = []; private mapMenuObjects: Array = []; private alertObjects: Phaser.GameObjects.GameObject[] = []; private alertDismissEvent?: Phaser.Time.TimerEvent; private turnPromptObjects: Phaser.GameObjects.GameObject[] = []; private combatCutInObjects: Phaser.GameObjects.GameObject[] = []; private unitViews = new Map(); private actedUnitIds = new Set(); private bondStates = new Map(); private attackIntents: AttackIntent[] = []; private battleLog: string[] = []; private enemyHomeTiles = new Map(); private pendingMove?: PendingMove; private debugOverlay?: Phaser.GameObjects.Text; constructor() { super('BattleScene'); } create() { const { width, height } = this.scale; this.layout = this.createLayout(width, height); this.bondStates = this.createBondStates(); this.enemyHomeTiles = this.createEnemyHomeTiles(); this.battleLog = []; soundDirector.playMusic('battle-prep'); this.input.mouse?.disableContextMenu(); this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => { if (pointer.rightButtonDown()) { this.handleRightClick(pointer); } }); this.installDebugHotkeys(); this.add.rectangle(0, 0, width, height, 0x080b0d).setOrigin(0); this.drawMap(); this.drawUnits(); this.drawSidePanel(); this.updateTurnText(); this.renderRosterPanel('ally', '행동할 장수를 선택하세요.'); } 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 gridSize = Math.floor(Math.min(mapHeight - 36, mapWidth - 72) / firstBattleMap.width) * firstBattleMap.width; const tileSize = gridSize / firstBattleMap.width; const mapX = margin; const mapY = margin; return { mapX, mapY, mapWidth, mapHeight, gridX: mapX + Math.floor((mapWidth - gridSize) / 2), gridY: mapY + Math.floor((mapHeight - gridSize) / 2), gridSize, tileSize, panelX: mapX + mapWidth + gap, panelY: margin, panelWidth, panelHeight: mapHeight }; } private drawMap() { const layout = this.layout; const background = this.add.image( layout.mapX + layout.mapWidth / 2, layout.mapY + layout.mapHeight / 2, 'battle-map-first' ); const source = this.textures.get('battle-map-first').getSourceImage(); const coverScale = Math.max(layout.mapWidth / source.width, layout.mapHeight / source.height); background.setScale(coverScale); background.setDepth(0); const maskShape = this.add.graphics(); maskShape.fillStyle(0xffffff, 1); maskShape.fillRect(layout.mapX, layout.mapY, layout.mapWidth, layout.mapHeight); maskShape.setAlpha(0); background.setMask(maskShape.createGeometryMask()); 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); firstBattleMap.terrain.forEach((row, y) => { row.forEach((terrain, x) => { const terrainRule = getTerrainRule(terrain); const tile = this.add.rectangle( layout.gridX + x * layout.tileSize, layout.gridY + y * layout.tileSize, layout.tileSize, layout.tileSize, terrainRule.color, terrainRule.alpha ); tile.setOrigin(0); tile.setStrokeStyle(1, 0x0a1014, 0.5); tile.setDepth(2); }); }); this.add .rectangle(layout.gridX, layout.gridY, layout.gridSize, layout.gridSize) .setOrigin(0) .setStrokeStyle(2, 0xf1d489, 0.48) .setDepth(3); } private drawUnits() { firstBattleUnits.forEach((unit) => { const sizeRatio = unit.faction === 'ally' ? 1.16 : 1.1; const textureBase = unitTexture[unit.id] ?? 'unit-rebel'; 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); 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); 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 + 28, '첫 전투', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '30px', color: '#e8dfca', fontStyle: '700', padding: { top: 4, bottom: 4 } }); this.turnText = this.add.text(panelX + 24, panelY + 88, '1턴 / 아군', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '22px', color: '#d8b15f' }); this.add.text(panelX + 24, panelY + panelHeight - 104, '승리 조건: 적 전멸', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '18px', color: '#9aa3ad' }); this.add.text(panelX + 24, panelY + panelHeight - 72, '패배 조건: 유비 퇴각', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '18px', color: '#9aa3ad' }); this.add.text(panelX + 24, panelY + panelHeight - 40, '이동 후 명령: 공격 / 책략 / 도구 / 대기', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '18px', color: '#9aa3ad' }); } private selectUnit(unit: UnitData) { this.hideMapMenu(); this.hideTurnEndPrompt(); if (this.phase === 'animating') { return; } if (this.phase === 'targeting' && this.selectedUnit) { if (unit.faction !== this.selectedUnit.faction && unit.hp > 0 && this.targetingAction) { void this.tryResolveDamageTarget(this.selectedUnit, unit, this.targetingAction); 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, '적 부대 정보입니다.'); 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.layout.gridX + x * this.layout.tileSize, this.layout.gridY + y * this.layout.tileSize, this.layout.tileSize, this.layout.tileSize, palette.blue, 0.24 ); marker.setOrigin(0); marker.setStrokeStyle(1, palette.blue, 0.68); marker.setDepth(4); 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; unit.x = x; unit.y = y; this.pendingMove = { unit, fromX, fromY, toX: x, toY: y }; this.phase = 'command'; this.clearMarkers(); soundDirector.playSelect(); const targetX = this.tileCenterX(x); const targetY = this.tileCenterY(y); 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 costs = new Map([[this.tileKey(unit.x, unit.y), 0]]); const queue = [{ x: unit.x, y: unit.y, cost: 0 }]; const reachable = new Map(); 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.isOccupied(nextX, nextY, unit.id)) { return; } const terrain = firstBattleMap.terrain[nextY][nextX]; const stepCost = this.movementCost(unit, terrain); 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 }; reachable.set(key, tile); queue.push(tile); }); } return Array.from(reachable.values()); } private movementCost(unit: UnitData, terrain: TerrainType) { const unitClass = getUnitClass(unit.classKey); return unitClass.movementCosts?.[terrain] ?? getTerrainRule(terrain).moveCost; } private isInBounds(x: number, y: number) { return x >= 0 && y >= 0 && x < firstBattleMap.width && y < firstBattleMap.height; } private tileKey(x: number, y: number) { return `${x},${y}`; } private isOccupied(x: number, y: number, exceptUnitId?: string) { return firstBattleUnits.some((unit) => unit.id !== exceptUnitId && unit.hp > 0 && unit.x === x && unit.y === y); } private tileCenterX(x: number) { return this.layout.gridX + x * this.layout.tileSize + this.layout.tileSize / 2; } private tileCenterY(y: number) { return this.layout.gridY + y * this.layout.tileSize + this.layout.tileSize / 2; } private clearMarkers() { this.markers.forEach((marker) => marker.destroy()); this.markers = []; } 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.showUnavailableCommandAlert(command); 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 showUnavailableCommandAlert(command: Extract) { 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) { if (this.phase !== 'command' || this.selectedUnit?.id !== unit.id || this.actedUnitIds.has(unit.id)) { return; } const targets = this.damageableTargets(unit, action); if (targets.length === 0) { this.renderUnitDetail(unit, `현재 위치에서 ${commandLabels[action]} 가능한 적이 없습니다. 다른 명령을 선택하거나 우클릭으로 이동을 취소하세요.`); return; } this.phase = 'targeting'; this.targetingAction = action; this.hideCommandMenu(); this.clearMarkers(); this.renderUnitDetail(unit, `붉은 칸의 적을 선택하면 ${commandLabels[action]}합니다. 우클릭하면 명령 선택으로 돌아갑니다.`); this.showDamageTargets(unit, action, targets); soundDirector.playSelect(); } private showDamageTargets(attacker: UnitData, action: DamageCommand, targets = this.damageableTargets(attacker, action)) { targets.forEach((target) => { const preview = this.combatPreview(attacker, target, action); const marker = this.add.rectangle( this.layout.gridX + target.x * this.layout.tileSize, this.layout.gridY + target.y * this.layout.tileSize, this.layout.tileSize, this.layout.tileSize, 0xc93b30, 0.3 ); marker.setOrigin(0); marker.setStrokeStyle(2, 0xffd27a, 0.9); marker.setDepth(9); 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); } }); this.markers.push(marker); }); } private async tryResolveDamageTarget(attacker: UnitData, target: UnitData, action: DamageCommand) { if (this.phase !== 'targeting' || this.selectedUnit?.id !== attacker.id) { return; } if (!this.canUseDamageCommand(attacker, target, action)) { this.renderUnitDetail(attacker, '사거리 밖의 적입니다. 붉은 칸으로 표시된 적을 선택하세요.'); return; } this.phase = 'animating'; const result = this.resolveCombatAction(attacker, target, action); this.clearMarkers(); await this.playCombatCutIn(result); this.finishUnitAction(attacker, this.formatCombatResult(result)); } private renderAttackPreview(preview: CombatPreview) { this.renderUnitDetail( preview.defender, `${preview.attacker.name} ${commandLabels[preview.action]} 예측\n피해 ${preview.damage} / 명중 ${preview.hitRate}% / 지형 ${preview.terrainLabel}` ); } private finishUnitAction(unit: UnitData, message: string) { this.actedUnitIds.add(unit.id); this.phase = 'idle'; this.selectedUnit = undefined; this.pendingMove = undefined; this.targetingAction = undefined; this.hideCommandMenu(); this.hideTurnEndPrompt(); this.applyActedStyle(unit); soundDirector.playSelect(); const remaining = this.remainingAllyCount(); const turnHint = remaining > 0 ? `${remaining}명의 아군이 아직 행동할 수 있습니다.` : '모든 아군이 행동했습니다. 턴 종료 여부를 선택하세요.'; this.pushBattleLog(message); this.renderRosterPanel('ally', `${message}\n${turnHint}`); if (remaining === 0) { this.showTurnEndPrompt(message); } } private attackableTargets(attacker: UnitData) { return this.damageableTargets(attacker, 'attack'); } private damageableTargets(attacker: UnitData, action: DamageCommand) { return firstBattleUnits.filter((target) => this.canUseDamageCommand(attacker, target, action)); } private canAttack(attacker: UnitData, target: UnitData) { return this.canUseDamageCommand(attacker, target, 'attack'); } private canUseDamageCommand(attacker: UnitData, target: UnitData, action: DamageCommand) { if (attacker.faction === target.faction || target.hp <= 0) { return false; } return this.tileDistance(attacker, target) <= this.actionRange(attacker, action); } private attackRange(unit: UnitData) { return attackRangeByClass[unit.classKey] ?? 1; } private actionRange(unit: UnitData, action: DamageCommand) { if (action === 'attack') { return this.attackRange(unit); } return unit.classKey === 'archer' ? 3 : 2; } private combatPreview(attacker: UnitData, defender: UnitData, action: DamageCommand): CombatPreview { const terrain = firstBattleMap.terrain[defender.y][defender.x]; const terrainRule = getTerrainRule(terrain); const attackPower = this.actionPower(attacker, action); const defensePower = this.actionDefense(defender, action, terrainRule.defenseBonus); const bondBonus = attacker.faction === 'ally' ? this.strongestBondBonus(attacker.id).damageBonus : 0; const damage = Phaser.Math.Clamp(Math.round((attackPower - defensePower) * (1 + bondBonus / 100)), 3, defender.maxHp); const hitRate = Phaser.Math.Clamp(84 + Math.floor((attacker.stats.agility - defender.stats.agility) / 4), 65, 98); return { action, attacker, defender, damage, hitRate, terrainLabel: terrainRule.label }; } private resolveAttack(attacker: UnitData, defender: UnitData): CombatResult { return this.resolveCombatAction(attacker, defender, 'attack'); } private resolveCombatAction(attacker: UnitData, defender: UnitData, action: DamageCommand): CombatResult { const preview = this.combatPreview(attacker, defender, action); const previousDefenderHp = defender.hp; const attackerGrowth = this.awardEquipmentExp(attacker, action === 'attack' ? 'weapon' : 'accessory', this.attackerEquipmentExpGain(attacker, action)); const armorGrowth = this.awardEquipmentExp(defender, 'armor', 6); const characterGrowth = this.awardCharacterExp(attacker, this.characterExpGain(preview)); const bondExp = attacker.faction === 'ally' ? this.recordBondAttackIntent(attacker, defender) : 0; defender.hp = Math.max(0, defender.hp - preview.damage); this.faceUnitToward(attacker, defender); this.flashDamage(defender, preview.damage); if (defender.hp <= 0) { this.applyDefeatedStyle(defender); } else { this.syncUnitMotion(defender); } return { ...preview, previousDefenderHp, defeated: defender.hp <= 0, characterGrowth, attackerGrowth, defenderGrowth: armorGrowth, bondExp }; } private actionPower(unit: UnitData, action: DamageCommand) { if (action === 'strategy') { return 10 + Math.floor(unit.stats.intelligence / 5) + this.equipmentStrategyBonus(unit); } if (action === 'item') { return 9 + Math.floor(unit.stats.luck / 8) + Math.floor(this.equipmentAttackBonus(unit) / 2); } return unit.attack + this.equipmentAttackBonus(unit) + Math.floor(unit.stats.might / 12); } 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) { const levelGap = result.defender.level - result.attacker.level; const actionBonus = result.action === 'attack' ? 10 : 8; const defeatBonus = result.damage >= result.defender.hp ? 12 : 0; return Phaser.Math.Clamp(actionBonus + levelGap * 2 + defeatBonus, 4, 28); } private formatCombatResult(result: CombatResult) { const defeated = result.defeated ? `\n${result.defender.name} 퇴각!` : `\n${result.defender.name} HP ${result.defender.hp}/${result.defender.maxHp}`; const bond = result.bondExp > 0 ? `\n공명 경험치 +${result.bondExp}` : ''; return `${result.attacker.name} ${commandLabels[result.action]}\n${result.defender.name}에게 ${result.damage} 피해${defeated}\n장수 경험치 +${result.characterGrowth.amount}\n${this.formatEquipmentGrowth(result.attackerGrowth)}\n${this.formatEquipmentGrowth(result.defenderGrowth)}${bond}`; } private combatGrowthLines(result: CombatResult) { const characterLevel = result.characterGrowth.leveled ? ` / Lv ${result.characterGrowth.level}` : ''; const lines = [ `장수 경험치 +${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 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 firstBattleUnits.filter((unit) => unit.faction === 'ally' && unit.hp > 0 && !this.actedUnitIds.has(unit.id)).length; } private createBondStates() { return new Map( firstBattleBonds.map((bond) => [ bond.id, { ...bond, battleExp: 0 } ]) ); } private createEnemyHomeTiles() { return new Map( firstBattleUnits .filter((unit) => unit.faction === 'enemy') .map((unit) => [unit.id, { x: unit.x, y: unit.y }] as const) ); } private handleRightClick(pointer: Phaser.Input.Pointer) { 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 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, gridSize, tileSize } = this.layout; if (pointer.x < gridX || pointer.y < gridY || pointer.x >= gridX + gridSize || pointer.y >= gridY + gridSize) { return undefined; } return { x: Math.floor((pointer.x - gridX) / tileSize), y: Math.floor((pointer.y - gridY) / tileSize) }; } 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', '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); 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 = (action === 'endTurn' && this.activeFaction !== 'ally') || ((action === 'save' || action === 'load') && this.activeFaction !== 'ally') || (action === 'load' && !this.hasBattleSave()); 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.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 === 'save') { this.saveBattleState(); return; } if (action === 'load') { this.loadBattleState(); 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 = []; } private mapMenuLabel(action: MapMenuAction) { if (action === 'save') { return '저장'; } if (action === 'load') { return '불러오기'; } return mapMenuLabels[action] ?? action; } private hasBattleSave() { return Boolean(window.localStorage.getItem(battleSaveStorageKey)); } private saveBattleState() { try { const state = this.createBattleSaveState(); window.localStorage.setItem(battleSaveStorageKey, JSON.stringify(state)); this.renderSituationPanel(`전투 상태를 저장했습니다.\n${this.formatSavedAt(state.savedAt)}`); } catch { this.renderSituationPanel('전투 상태를 저장하지 못했습니다.'); } } private loadBattleState() { const raw = window.localStorage.getItem(battleSaveStorageKey); if (!raw) { this.renderSituationPanel('불러올 저장 데이터가 없습니다.'); return; } try { const state = JSON.parse(raw) as BattleSaveState; if (!state || state.version !== 1 || !Array.isArray(state.units)) { throw new Error('Invalid battle save'); } this.applyBattleSaveState(state); this.renderSituationPanel(`저장된 전투를 불러왔습니다.\n${this.formatSavedAt(state.savedAt)}`); } catch { this.renderSituationPanel('저장 데이터를 불러오지 못했습니다.'); } } private createBattleSaveState(): BattleSaveState { return { version: 1, 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: firstBattleUnits.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] })) }; } private applyBattleSaveState(state: BattleSaveState) { this.clearMarkers(); this.hideCommandMenu(); this.hideTurnEndPrompt(); this.hideCombatCutIn(); this.selectedUnit = undefined; this.pendingMove = undefined; this.targetingAction = 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 } ]) ); state.units.forEach((savedUnit) => { const unit = firstBattleUnits.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, firstBattleMap.width - 1); unit.y = Phaser.Math.Clamp(savedUnit.y, 0, firstBattleMap.height - 1); unit.equipment = this.cloneEquipment(savedUnit.equipment); this.restoreUnitView(unit, savedUnit.direction); }); this.resetActedStyles(); this.updateTurnText(); 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 formatSavedAt(savedAt: string) { return new Date(savedAt).toLocaleString('ko-KR'); } private endAllyTurn() { if (this.activeFaction !== 'ally') { this.renderSituationPanel('이미 적군 행동 중입니다.'); 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.renderSituationPanel('아군 턴을 종료했습니다.\n적군이 행동을 시작합니다.'); void this.runEnemyTurn(); return; this.activeFaction = 'enemy'; this.updateTurnText(); const pressureMessage = this.resolveEnemyPressure(); this.renderSituationPanel(`아군 턴을 종료했습니다.\n${pressureMessage}`); this.time.delayedCall(700, () => this.beginNextAllyTurn()); } private async runEnemyTurn() { const enemies = firstBattleUnits.filter((unit) => unit.faction === 'enemy' && unit.hp > 0); const messages: string[] = []; for (const enemy of enemies) { const message = await this.executeEnemyAction(enemy); if (message) { messages.push(message); this.pushBattleLog(message); this.renderSituationPanel(messages.slice(-3).join('\n')); } await this.delay(260); } this.time.delayedCall(520, () => this.beginNextAllyTurn()); } private async executeEnemyAction(enemy: UnitData) { 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; await this.moveUnitViewAsync(enemy, destination.x, destination.y, this.unitViews.get(enemy.id), 260); } } target = this.chooseTargetInRange(enemy) ?? target; if (this.canAttack(enemy, target)) { const result = this.resolveAttack(enemy, target); await this.playCombatCutIn(result); 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 firstBattleUnits .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(firstBattleMap.terrain[a.y][a.x]).defenseBonus; const bTerrain = getTerrainRule(firstBattleMap.terrain[b.y][b.x]).defenseBonus; return aDistance - bDistance || bTerrain - aTerrain || a.cost - b.cost; })[0]; } private nearestAliveUnit(unit: UnitData, faction: UnitData['faction']) { return firstBattleUnits .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 formatEnemyCombatResult(result: CombatResult, behavior: EnemyAiBehavior) { const defeated = result.defeated ? ` / ${result.defender.name} 퇴각` : ` / ${result.defender.name} HP ${result.defender.hp}/${result.defender.maxHp}`; return `${result.attacker.name} ${this.enemyBehaviorLabel(behavior)} 공격: ${result.defender.name}에게 ${result.damage} 피해${defeated}`; } private async playCombatCutIn(result: CombatResult) { this.hideCombatCutIn(); const panelWidth = 900; const panelHeight = 430; 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 title = this.trackCombatObject(this.add.text(left + panelWidth / 2, top + 24, commandLabels[result.action], { 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, unitTexture[result.attacker.id] ?? 'unit-rebel', this.unitFrameIndex(attackerDirection)) ); attackerSprite.setDepth(depth + 6); attackerSprite.setDisplaySize(126, 126); const defenderSprite = this.trackCombatObject( this.add.sprite(defenderX, groundY, unitTexture[result.defender.id] ?? 'unit-rebel', 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}`); await this.delay(180); await this.playCombatActionMotion(result, attackerSprite, defenderSprite, attackerX, defenderX, groundY, depth + 10); this.showCombatImpact(result, defenderX, groundY - 46, 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}`); if (result.characterGrowth.amount > 0) { soundDirector.playEffect('exp-gain', { volume: result.characterGrowth.leveled ? 0.44 : 0.28, stopAfterMs: 1000 }); } await this.animateCombatGauge( attackerStatus.expFill, result.characterGrowth.previousExp / result.characterGrowth.next, result.characterGrowth.exp / result.characterGrowth.next, 420 ); attackerStatus.expText.setText(`${result.characterGrowth.exp} / ${result.characterGrowth.next}`); const resultText = this.trackCombatObject(this.add.text(left + panelWidth / 2, top + 252, `${result.damage} 피해`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '22px', color: '#ffdf7b', fontStyle: '700', stroke: '#2b0909', strokeThickness: 4 })); resultText.setOrigin(0.5); resultText.setDepth(depth + 16); const expText = this.trackCombatObject(this.add.text(left + panelWidth / 2, top + 386, `장수 경험치 +${result.characterGrowth.amount}${result.characterGrowth.leveled ? ` / Lv ${result.characterGrowth.level}` : ''}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '17px', color: '#d4dce6', fontStyle: '700' })); expText.setOrigin(0.5); expText.setDepth(depth + 16); expText.setVisible(false); const growthText = this.trackCombatObject(this.add.text(left + panelWidth / 2, top + 348, this.combatGrowthLines(result).join('\n'), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '15px', color: '#d4dce6', fontStyle: '700', align: 'center', lineSpacing: 4 })); growthText.setOrigin(0.5, 0); growthText.setDepth(depth + 16); await this.delay(760); 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, unitTexture[unit.id] ?? 'unit-rebel', this.unitFrameIndex('south'))); unitIcon.setDepth(85); unitIcon.setDisplaySize(64, 64); } const name = this.trackCombatObject(this.add.text(x + 78, y + 14, `${unit.name} Lv ${unit.level}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '17px', color: '#f2e3bf', fontStyle: '700' })); name.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 }; } 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 ) { const direction = result.attacker.faction === 'ally' ? 1 : -1; if (result.action === 'attack') { soundDirector.playEffect('sword-slash', { volume: 0.52, stopAfterMs: 700 }); this.tweens.add({ targets: attackerSprite, x: attackerX + direction * 84, duration: 170, yoyo: true, ease: 'Sine.easeInOut' }); await this.delay(180); this.tweens.add({ targets: defenderSprite, x: defenderX + direction * 12, duration: 70, 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 * 72, duration: 430, ease: 'Cubic.easeIn' }); await this.delay(450); 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 showCombatImpact(result: CombatResult, x: number, y: number, depth: number) { soundDirector.playEffect('combat-impact', { volume: result.action === 'strategy' ? 0.3 : 0.48, stopAfterMs: 650 }); const impact = this.trackCombatObject(this.add.star(x, y, 8, 12, 42, result.action === 'strategy' ? 0x6cc5ff : 0xffdf7b, 0.92)); impact.setDepth(depth); this.tweens.add({ targets: impact, scale: 1.45, alpha: 0, duration: 320, ease: 'Sine.easeOut', onComplete: () => impact.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((resolve) => { this.tweens.add({ targets: target, scaleX: Phaser.Math.Clamp(toRatio, 0, 1), duration, ease: 'Sine.easeInOut', onComplete: () => resolve() }); }); } private trackCombatObject(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((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((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); resolve(); } }); this.tweens.add({ targets: view.label, x: targetX, y: targetY + this.layout.tileSize * 0.52, duration, ease: 'Sine.easeInOut' }); }); } private beginNextAllyTurn() { this.turnNumber += 1; this.activeFaction = 'ally'; this.actedUnitIds.clear(); this.attackIntents = []; this.resetActedStyles(); this.updateTurnText(); this.renderRosterPanel('ally', `${this.turnNumber}턴 아군 차례입니다.\n행동할 장수를 선택하세요.`); } private updateTurnText() { this.turnText?.setText(`${this.turnNumber}턴 / ${factionLabels[this.activeFaction]}`); } private resetActedStyles() { this.unitViews.forEach((view, unitId) => { const unit = firstBattleUnits.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); 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 }); return gained; } 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 = firstBattleUnits.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 = firstBattleUnits .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 = firstBattleUnits.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 }; } return { damageBonus: Math.floor(strongest.level / 20) * 3, chainRate: strongest.level >= 70 ? 18 : strongest.level >= 50 ? 8 : 0 }; } 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.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(); 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.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 applyActedStyle(unit: UnitData) { const view = this.unitViews.get(unit.id); if (!view) { 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) { 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, `-${damage}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '20px', color: '#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 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 renderBondPanel(message?: string) { this.clearSidePanelContent(); const { panelX, panelY, panelWidth } = this.layout; const left = panelX + 24; const width = panelWidth - 48; const top = panelY + 132; 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, panelY, panelWidth } = this.layout; const left = panelX + 24; const width = panelWidth - 48; const top = panelY + 132; const actedCount = firstBattleUnits.filter((unit) => unit.faction === 'ally' && this.actedUnitIds.has(unit.id)).length; const allyCount = firstBattleUnits.filter((unit) => unit.faction === 'ally').length; const enemyCount = firstBattleUnits.filter((unit) => unit.faction === 'enemy' && unit.hp > 0).length; const bestBond = Array.from(this.bondStates.values()).sort((a, b) => b.level - a.level)[0]; 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 + 48, width); this.renderSituationLine('아군 행동', `${actedCount} / ${allyCount}`, left, top + 92, width); this.renderSituationLine('남은 적', `${enemyCount}`, left, top + 136, width); this.renderSituationLine('BGM', soundDirector.isMuted() ? '꺼짐' : '켜짐', left, top + 180, width); this.renderSituationLine( '최고 공명', bestBond ? `${this.unitName(bestBond.unitIds[0])}·${this.unitName(bestBond.unitIds[1])} ${bestBond.level}` : '-', left, top + 224, width ); this.renderPanelMessage(message ?? '빈 맵에서 우클릭하면 전술 메뉴를 다시 열 수 있습니다.', left, top + 286, width); } 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 firstBattleUnits.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 = panelY + 132; 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 = firstBattleUnits.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, panelY, panelWidth } = this.layout; const left = panelX + 24; const width = panelWidth - 48; const top = panelY + 124; const acted = this.actedUnitIds.has(unit.id); const factionColor = unit.faction === 'ally' ? palette.blue : 0xb86b55; const unitClass = getUnitClass(unit.classKey); const terrain = firstBattleMap.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) { 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(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, turnNumber: this.turnNumber, activeFaction: this.activeFaction, phase: this.phase, 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], units: firstBattleUnits.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 })) }; } 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' ]); } }