Files
heros_web/src/game/scenes/BattleScene.ts

6166 lines
214 KiB
TypeScript

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 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'
};
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 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']
};
const initialItemStocks: Record<string, Record<string, number>> = {
'liu-bei': { bean: 2, salve: 1 },
'guan-yu': { bean: 1 },
'zhang-fei': { bean: 1, wine: 1 }
};
const attackRangeByClass: Partial<Record<UnitClassKey, number>> = {
archer: 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'
};
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 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() {
this.resetBattleData();
const campaign = getCampaignState();
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() {
battleUnits.splice(0, battleUnits.length, ...initialBattleUnits.map(cloneUnitData));
battleBonds.splice(0, battleBonds.length, ...initialBattleBonds.map(cloneBattleBond));
}
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 = 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);
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 === '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;
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);
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<string, number>([[this.tileKey(unit.x, unit.y), 0]]);
const queue = [{ x: unit.x, y: unit.y, cost: 0 }];
const reachable = new Map<string, { x: number; y: number; cost: number }>();
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 = 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 };
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 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();
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);
const unitTitle = this.trackResultObject(this.add.text(left + 44, top + 332, '장수 성장', {
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 + 366 + index * 70, panelWidth - 88, 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();
}, depth + 3);
this.addResultButton('타이틀로', left + panelWidth - 140, top + 612, 124, () => {
soundDirector.playSelect();
this.scene.start('TitleScene');
}, depth + 3);
}
private hideBattleResult() {
this.resultObjects.forEach((object) => object.destroy());
this.resultObjects = [];
}
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';
const secured = !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 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 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 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);
this.renderResultProgressGauge(x + 392, y + 36, 200, 8, unit.exp / 100, palette.gold, depth + 1);
equipmentSlots.forEach((slot, index) => {
const state = unit.equipment[slot];
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], {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '11px',
color: alive ? '#9fb0bf' : '#777777',
fontStyle: '700'
}));
label.setDepth(depth + 1);
this.renderResultProgressGauge(slotX, y + 36, 76, 8, state.exp / equipmentExpToNext(state.level), color, depth + 1);
});
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 fillWidth = Math.max(2, Math.floor((width - 2) * Phaser.Math.Clamp(ratio, 0, 1)));
const fill = this.trackResultObject(this.add.rectangle(x + 1, y + 1, fillWidth, Math.max(2, height - 2), color, 0.96));
fill.setOrigin(0);
fill.setDepth(depth + 1);
}
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 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}의 기세가 꺾였습니다.`, '두령을 몰아붙이면 황건적의 전열이 무너집니다.']);
}
if (!this.hasEnemyOnVillageTile()) {
this.triggerBattleEvent('village-secured', '마을 확보', ['마을 주변의 황건적을 몰아냈습니다.', '승리 시 마을 확보 보상이 추가됩니다.']);
}
if (this.turnNumber === 2 && this.activeFaction === 'ally') {
this.triggerBattleEvent('enemy-posture', '적 전술 변화', ['기병은 계속 접근하고, 궁병은 자리를 지키며 사격합니다.', '숲과 마을 지형을 이용해 피해를 줄이세요.']);
}
}
private hasEnemyOnVillageTile() {
return this.hasEnemyOnTerrain('village');
}
private hasEnemyOnTerrain(terrain: string) {
return battleUnits.some((unit) => {
return unit.faction === 'enemy' && unit.hp > 0 && battleMap.terrain[unit.y][unit.x] === terrain;
});
}
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, 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}`);
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, unitTexture[result.target.id] ?? 'unit-rebel', 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, unitTexture[unit.id] ?? 'unit-rebel', 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 unitActionTexture(unit: UnitData) {
const base = unitTexture[unit.id] ?? 'unit-rebel';
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'
]);
}
}