2924 lines
100 KiB
TypeScript
2924 lines
100 KiB
TypeScript
import Phaser from 'phaser';
|
|
import { soundDirector } from '../audio/SoundDirector';
|
|
import {
|
|
firstBattleBonds,
|
|
firstBattleMap,
|
|
firstBattleUnits,
|
|
type BattleBond,
|
|
type UnitData,
|
|
type UnitStats
|
|
} from '../data/scenario';
|
|
import { getTerrainRule, getUnitClass, type TerrainType, type UnitClassKey } from '../data/battleRules';
|
|
import {
|
|
equipmentExpToNext,
|
|
equipmentSlotLabels,
|
|
equipmentSlots,
|
|
getItem,
|
|
type EquipmentSlot
|
|
} from '../data/battleItems';
|
|
import { palette } from '../ui/palette';
|
|
|
|
const unitTexture: Record<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-leader': 'unit-rebel-leader'
|
|
};
|
|
|
|
const unitFrameRows: Record<UnitDirection, number> = {
|
|
south: 0,
|
|
east: 1,
|
|
north: 2,
|
|
west: 3
|
|
};
|
|
|
|
type BattleLayout = {
|
|
mapX: number;
|
|
mapY: number;
|
|
mapWidth: number;
|
|
mapHeight: number;
|
|
gridX: number;
|
|
gridY: number;
|
|
gridSize: number;
|
|
tileSize: number;
|
|
panelX: number;
|
|
panelY: number;
|
|
panelWidth: number;
|
|
panelHeight: number;
|
|
};
|
|
|
|
type UnitView = {
|
|
sprite: Phaser.GameObjects.Sprite;
|
|
label: Phaser.GameObjects.Text;
|
|
textureBase: string;
|
|
direction: UnitDirection;
|
|
};
|
|
|
|
type UnitDirection = 'south' | 'east' | 'north' | 'west';
|
|
|
|
type BattlePhase = 'idle' | 'moving' | 'command' | 'targeting' | 'animating';
|
|
type BattleCommand = 'attack' | 'strategy' | 'item' | 'wait';
|
|
type DamageCommand = Exclude<BattleCommand, 'wait'>;
|
|
type RosterTab = 'ally' | 'enemy';
|
|
type ActiveFaction = 'ally' | 'enemy';
|
|
type MapMenuAction = 'endTurn' | 'save' | 'load' | 'roster' | 'bond' | 'situation' | 'bgm' | 'close';
|
|
type EnemyAiBehavior = 'aggressive' | 'guard' | 'hold';
|
|
|
|
type CommandButton = {
|
|
command: BattleCommand;
|
|
background: Phaser.GameObjects.Rectangle;
|
|
text: Phaser.GameObjects.Text;
|
|
};
|
|
|
|
type PendingMove = {
|
|
unit: UnitData;
|
|
fromX: number;
|
|
fromY: number;
|
|
toX: number;
|
|
toY: number;
|
|
};
|
|
|
|
type AttackIntent = {
|
|
attackerId: string;
|
|
targetId: string;
|
|
};
|
|
|
|
type BondState = BattleBond & {
|
|
battleExp: number;
|
|
};
|
|
|
|
type EquipmentGrowthResult = {
|
|
slot: EquipmentSlot;
|
|
itemName: string;
|
|
amount: number;
|
|
previousLevel: number;
|
|
previousExp: number;
|
|
level: number;
|
|
exp: number;
|
|
next: number;
|
|
leveled: boolean;
|
|
};
|
|
|
|
type CharacterGrowthResult = {
|
|
amount: number;
|
|
previousLevel: number;
|
|
previousExp: number;
|
|
level: number;
|
|
exp: number;
|
|
next: number;
|
|
leveled: boolean;
|
|
};
|
|
|
|
type CombatPreview = {
|
|
action: DamageCommand;
|
|
attacker: UnitData;
|
|
defender: UnitData;
|
|
damage: number;
|
|
hitRate: number;
|
|
terrainLabel: string;
|
|
};
|
|
|
|
type CombatResult = CombatPreview & {
|
|
previousDefenderHp: number;
|
|
defeated: boolean;
|
|
characterGrowth: CharacterGrowthResult;
|
|
attackerGrowth: EquipmentGrowthResult;
|
|
defenderGrowth: EquipmentGrowthResult;
|
|
bondExp: number;
|
|
};
|
|
|
|
type SavedUnitState = Pick<UnitData, 'id' | 'level' | 'exp' | 'hp' | 'maxHp' | 'attack' | 'move' | 'x' | 'y'> & {
|
|
equipment: UnitData['equipment'];
|
|
direction?: UnitDirection;
|
|
};
|
|
|
|
type BattleSaveState = {
|
|
version: 1;
|
|
savedAt: string;
|
|
turnNumber: number;
|
|
activeFaction: ActiveFaction;
|
|
rosterTab: RosterTab;
|
|
actedUnitIds: string[];
|
|
attackIntents: AttackIntent[];
|
|
battleLog: string[];
|
|
units: SavedUnitState[];
|
|
bonds: BondState[];
|
|
};
|
|
|
|
const maxEquipmentLevel = 9;
|
|
const maxCharacterLevel = 99;
|
|
const battleSaveStorageKey = 'heros-web:first-battle-state';
|
|
|
|
const attackRangeByClass: Partial<Record<UnitClassKey, number>> = {
|
|
archer: 2
|
|
};
|
|
|
|
const enemyAiByUnitId: Record<string, EnemyAiBehavior> = {
|
|
'rebel-a': 'aggressive',
|
|
'rebel-b': 'guard',
|
|
'rebel-c': 'hold',
|
|
'rebel-leader': 'guard'
|
|
};
|
|
|
|
const defaultEnemyAiByClass: Partial<Record<UnitClassKey, EnemyAiBehavior>> = {
|
|
archer: 'hold',
|
|
cavalry: 'aggressive',
|
|
yellowTurban: 'guard',
|
|
rebelLeader: 'guard'
|
|
};
|
|
|
|
const guardDetectionRange = 5;
|
|
|
|
const commandLabels: Record<BattleCommand, string> = {
|
|
attack: '공격',
|
|
strategy: '책략',
|
|
item: '도구',
|
|
wait: '대기'
|
|
};
|
|
|
|
const rosterLabels: Record<RosterTab, string> = {
|
|
ally: '아군',
|
|
enemy: '적군'
|
|
};
|
|
|
|
const factionLabels: Record<ActiveFaction, string> = {
|
|
ally: '아군',
|
|
enemy: '적군'
|
|
};
|
|
|
|
const mapMenuLabels: Partial<Record<MapMenuAction, string>> = {
|
|
endTurn: '턴 종료',
|
|
roster: '부대 일람',
|
|
bond: '공명',
|
|
situation: '전황',
|
|
bgm: 'BGM',
|
|
close: '닫기'
|
|
};
|
|
|
|
const statLabels: Array<{ key: keyof UnitStats; label: string }> = [
|
|
{ key: 'might', label: '무력' },
|
|
{ key: 'intelligence', label: '지력' },
|
|
{ key: 'leadership', label: '통솔력' },
|
|
{ key: 'agility', label: '민첩성' },
|
|
{ key: 'luck', label: '운' }
|
|
];
|
|
|
|
export class BattleScene extends Phaser.Scene {
|
|
private layout!: BattleLayout;
|
|
private phase: BattlePhase = 'idle';
|
|
private turnNumber = 1;
|
|
private activeFaction: ActiveFaction = 'ally';
|
|
private selectedUnit?: UnitData;
|
|
private targetingAction?: DamageCommand;
|
|
private turnText?: Phaser.GameObjects.Text;
|
|
private markers: Phaser.GameObjects.Rectangle[] = [];
|
|
private rosterTab: RosterTab = 'ally';
|
|
private sidePanelObjects: Phaser.GameObjects.GameObject[] = [];
|
|
private commandButtons: CommandButton[] = [];
|
|
private commandMenuObjects: Array<Phaser.GameObjects.Rectangle | Phaser.GameObjects.Text> = [];
|
|
private mapMenuObjects: Array<Phaser.GameObjects.Rectangle | Phaser.GameObjects.Text> = [];
|
|
private alertObjects: Phaser.GameObjects.GameObject[] = [];
|
|
private alertDismissEvent?: Phaser.Time.TimerEvent;
|
|
private turnPromptObjects: Phaser.GameObjects.GameObject[] = [];
|
|
private combatCutInObjects: Phaser.GameObjects.GameObject[] = [];
|
|
private unitViews = new Map<string, UnitView>();
|
|
private actedUnitIds = new Set<string>();
|
|
private bondStates = new Map<string, BondState>();
|
|
private attackIntents: AttackIntent[] = [];
|
|
private battleLog: string[] = [];
|
|
private enemyHomeTiles = new Map<string, { x: number; y: number }>();
|
|
private pendingMove?: PendingMove;
|
|
private debugOverlay?: Phaser.GameObjects.Text;
|
|
|
|
constructor() {
|
|
super('BattleScene');
|
|
}
|
|
|
|
create() {
|
|
const { width, height } = this.scale;
|
|
this.layout = this.createLayout(width, height);
|
|
this.bondStates = this.createBondStates();
|
|
this.enemyHomeTiles = this.createEnemyHomeTiles();
|
|
this.battleLog = [];
|
|
soundDirector.playMusic('battle-prep');
|
|
this.input.mouse?.disableContextMenu();
|
|
this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
|
|
if (pointer.rightButtonDown()) {
|
|
this.handleRightClick(pointer);
|
|
}
|
|
});
|
|
this.installDebugHotkeys();
|
|
|
|
this.add.rectangle(0, 0, width, height, 0x080b0d).setOrigin(0);
|
|
this.drawMap();
|
|
this.drawUnits();
|
|
this.drawSidePanel();
|
|
this.updateTurnText();
|
|
this.renderRosterPanel('ally', '행동할 장수를 선택하세요.');
|
|
}
|
|
|
|
private createLayout(width: number, height: number): BattleLayout {
|
|
const margin = 24;
|
|
const gap = 20;
|
|
const panelWidth = Phaser.Math.Clamp(Math.floor(width * 0.27), 320, 360);
|
|
const mapWidth = width - panelWidth - gap - margin * 2;
|
|
const mapHeight = height - margin * 2;
|
|
const gridSize = Math.floor(Math.min(mapHeight - 36, mapWidth - 72) / firstBattleMap.width) * firstBattleMap.width;
|
|
const tileSize = gridSize / firstBattleMap.width;
|
|
const mapX = margin;
|
|
const mapY = margin;
|
|
|
|
return {
|
|
mapX,
|
|
mapY,
|
|
mapWidth,
|
|
mapHeight,
|
|
gridX: mapX + Math.floor((mapWidth - gridSize) / 2),
|
|
gridY: mapY + Math.floor((mapHeight - gridSize) / 2),
|
|
gridSize,
|
|
tileSize,
|
|
panelX: mapX + mapWidth + gap,
|
|
panelY: margin,
|
|
panelWidth,
|
|
panelHeight: mapHeight
|
|
};
|
|
}
|
|
|
|
private drawMap() {
|
|
const layout = this.layout;
|
|
const background = this.add.image(
|
|
layout.mapX + layout.mapWidth / 2,
|
|
layout.mapY + layout.mapHeight / 2,
|
|
'battle-map-first'
|
|
);
|
|
const source = this.textures.get('battle-map-first').getSourceImage();
|
|
const coverScale = Math.max(layout.mapWidth / source.width, layout.mapHeight / source.height);
|
|
background.setScale(coverScale);
|
|
background.setDepth(0);
|
|
|
|
const maskShape = this.add.graphics();
|
|
maskShape.fillStyle(0xffffff, 1);
|
|
maskShape.fillRect(layout.mapX, layout.mapY, layout.mapWidth, layout.mapHeight);
|
|
maskShape.setAlpha(0);
|
|
background.setMask(maskShape.createGeometryMask());
|
|
|
|
this.add.rectangle(layout.mapX, layout.mapY, layout.mapWidth, layout.mapHeight, 0x06090b, 0.16).setOrigin(0).setDepth(1);
|
|
this.add
|
|
.rectangle(layout.mapX, layout.mapY, layout.mapWidth, layout.mapHeight)
|
|
.setOrigin(0)
|
|
.setStrokeStyle(2, palette.gold, 0.62)
|
|
.setDepth(8);
|
|
|
|
firstBattleMap.terrain.forEach((row, y) => {
|
|
row.forEach((terrain, x) => {
|
|
const terrainRule = getTerrainRule(terrain);
|
|
const tile = this.add.rectangle(
|
|
layout.gridX + x * layout.tileSize,
|
|
layout.gridY + y * layout.tileSize,
|
|
layout.tileSize,
|
|
layout.tileSize,
|
|
terrainRule.color,
|
|
terrainRule.alpha
|
|
);
|
|
tile.setOrigin(0);
|
|
tile.setStrokeStyle(1, 0x0a1014, 0.5);
|
|
tile.setDepth(2);
|
|
});
|
|
});
|
|
|
|
this.add
|
|
.rectangle(layout.gridX, layout.gridY, layout.gridSize, layout.gridSize)
|
|
.setOrigin(0)
|
|
.setStrokeStyle(2, 0xf1d489, 0.48)
|
|
.setDepth(3);
|
|
}
|
|
|
|
private drawUnits() {
|
|
firstBattleUnits.forEach((unit) => {
|
|
const sizeRatio = unit.faction === 'ally' ? 1.16 : 1.1;
|
|
const textureBase = unitTexture[unit.id] ?? 'unit-rebel';
|
|
const sprite = this.add.sprite(this.tileCenterX(unit.x), this.tileCenterY(unit.y), textureBase, this.unitFrameIndex('south'));
|
|
sprite.setName(`unit-${unit.id}`);
|
|
sprite.setDisplaySize(this.layout.tileSize * sizeRatio, this.layout.tileSize * sizeRatio);
|
|
sprite.setDepth(6);
|
|
sprite.setInteractive({ useHandCursor: unit.faction === 'ally' });
|
|
sprite.on('pointerdown', () => this.selectUnit(unit));
|
|
|
|
const label = this.add.text(sprite.x, sprite.y + this.layout.tileSize * 0.52, unit.name, {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '11px',
|
|
color: unit.faction === 'ally' ? '#e7edf7' : '#ffebe7',
|
|
stroke: '#05070a',
|
|
strokeThickness: 3
|
|
});
|
|
label.setOrigin(0.5, 0);
|
|
label.setDepth(7);
|
|
const view = { sprite, label, textureBase, direction: 'south' as UnitDirection };
|
|
this.unitViews.set(unit.id, view);
|
|
this.syncUnitMotion(unit, view);
|
|
});
|
|
}
|
|
|
|
private drawSidePanel() {
|
|
const { panelX, panelY, panelWidth, panelHeight } = this.layout;
|
|
this.add.rectangle(panelX, panelY, panelWidth, panelHeight, palette.panel, 0.96).setOrigin(0);
|
|
this.add.rectangle(panelX, panelY, 3, panelHeight, palette.gold, 0.82).setOrigin(0);
|
|
|
|
this.add.text(panelX + 24, panelY + 28, '첫 전투', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '30px',
|
|
color: '#e8dfca',
|
|
fontStyle: '700',
|
|
padding: { top: 4, bottom: 4 }
|
|
});
|
|
this.turnText = this.add.text(panelX + 24, panelY + 88, '1턴 / 아군', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '22px',
|
|
color: '#d8b15f'
|
|
});
|
|
this.add.text(panelX + 24, panelY + panelHeight - 104, '승리 조건: 적 전멸', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '18px',
|
|
color: '#9aa3ad'
|
|
});
|
|
this.add.text(panelX + 24, panelY + panelHeight - 72, '패배 조건: 유비 퇴각', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '18px',
|
|
color: '#9aa3ad'
|
|
});
|
|
this.add.text(panelX + 24, panelY + panelHeight - 40, '이동 후 명령: 공격 / 책략 / 도구 / 대기', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '18px',
|
|
color: '#9aa3ad'
|
|
});
|
|
}
|
|
|
|
private selectUnit(unit: UnitData) {
|
|
this.hideMapMenu();
|
|
this.hideTurnEndPrompt();
|
|
|
|
if (this.phase === 'animating') {
|
|
return;
|
|
}
|
|
|
|
if (this.phase === 'targeting' && this.selectedUnit) {
|
|
if (unit.faction !== this.selectedUnit.faction && unit.hp > 0 && this.targetingAction) {
|
|
void this.tryResolveDamageTarget(this.selectedUnit, unit, this.targetingAction);
|
|
return;
|
|
}
|
|
|
|
this.renderUnitDetail(this.selectedUnit, '대상으로 삼을 적 부대를 선택하세요. 우클릭하면 명령 선택으로 돌아갑니다.');
|
|
return;
|
|
}
|
|
|
|
if (this.phase === 'command' || this.phase === 'targeting') {
|
|
this.setInfo('먼저 현재 장수의 행동을 결정하세요.\n공격, 책략, 도구, 대기 중 하나를 선택해야 합니다.');
|
|
return;
|
|
}
|
|
|
|
if (unit.hp <= 0) {
|
|
this.renderRosterPanel(unit.faction, '이미 퇴각한 부대입니다.');
|
|
return;
|
|
}
|
|
|
|
if (unit.faction !== 'ally') {
|
|
this.selectedUnit = undefined;
|
|
this.pendingMove = undefined;
|
|
this.phase = 'idle';
|
|
this.clearMarkers();
|
|
this.hideCommandMenu();
|
|
this.renderUnitDetail(unit, '적 부대 정보입니다.');
|
|
return;
|
|
}
|
|
|
|
if (this.activeFaction !== 'ally') {
|
|
this.renderUnitDetail(unit, '적군 행동 중에는 장수를 움직일 수 없습니다.');
|
|
return;
|
|
}
|
|
|
|
if (this.actedUnitIds.has(unit.id)) {
|
|
this.selectedUnit = undefined;
|
|
this.clearMarkers();
|
|
this.hideCommandMenu();
|
|
this.phase = 'idle';
|
|
this.renderUnitDetail(unit, '이미 행동을 마친 장수입니다.');
|
|
return;
|
|
}
|
|
|
|
this.phase = 'moving';
|
|
this.selectedUnit = unit;
|
|
this.pendingMove = undefined;
|
|
this.clearMarkers();
|
|
this.hideCommandMenu();
|
|
this.showMoveRange(unit);
|
|
this.renderUnitDetail(unit, '이동할 파란 칸을 선택하세요.');
|
|
}
|
|
|
|
private showMoveRange(unit: UnitData) {
|
|
this.reachableTiles(unit).forEach(({ x, y }) => {
|
|
const marker = this.add.rectangle(
|
|
this.layout.gridX + x * this.layout.tileSize,
|
|
this.layout.gridY + y * this.layout.tileSize,
|
|
this.layout.tileSize,
|
|
this.layout.tileSize,
|
|
palette.blue,
|
|
0.24
|
|
);
|
|
marker.setOrigin(0);
|
|
marker.setStrokeStyle(1, palette.blue, 0.68);
|
|
marker.setDepth(4);
|
|
marker.setInteractive({ useHandCursor: true });
|
|
marker.on('pointerover', () => marker.setFillStyle(palette.blue, 0.36));
|
|
marker.on('pointerout', () => marker.setFillStyle(palette.blue, 0.24));
|
|
marker.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
|
|
if (pointer.leftButtonDown()) {
|
|
this.moveSelectedUnit(x, y, pointer);
|
|
}
|
|
});
|
|
this.markers.push(marker);
|
|
});
|
|
}
|
|
|
|
private moveSelectedUnit(x: number, y: number, pointer?: Phaser.Input.Pointer) {
|
|
if (!this.selectedUnit || !this.canMoveTo(this.selectedUnit, x, y)) {
|
|
return;
|
|
}
|
|
|
|
const unit = this.selectedUnit;
|
|
const view = this.unitViews.get(unit.id);
|
|
if (!view) {
|
|
return;
|
|
}
|
|
|
|
const fromX = unit.x;
|
|
const fromY = unit.y;
|
|
unit.x = x;
|
|
unit.y = y;
|
|
this.pendingMove = { unit, fromX, fromY, toX: x, toY: y };
|
|
this.phase = 'command';
|
|
this.clearMarkers();
|
|
soundDirector.playSelect();
|
|
|
|
const targetX = this.tileCenterX(x);
|
|
const targetY = this.tileCenterY(y);
|
|
this.moveUnitView(unit, x, y, view);
|
|
this.showCommandMenu(unit, pointer?.x ?? targetX, pointer?.y ?? targetY);
|
|
this.renderUnitDetail(unit, '공격, 책략, 도구, 대기 중 하나를 선택하세요.\n우클릭하면 이동을 취소합니다.');
|
|
}
|
|
|
|
private canMoveTo(unit: UnitData, x: number, y: number) {
|
|
return this.reachableTiles(unit).some((tile) => tile.x === x && tile.y === y);
|
|
}
|
|
|
|
private reachableTiles(unit: UnitData) {
|
|
const directions = [
|
|
{ x: 1, y: 0 },
|
|
{ x: -1, y: 0 },
|
|
{ x: 0, y: 1 },
|
|
{ x: 0, y: -1 }
|
|
];
|
|
const costs = new Map<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 = firstBattleMap.terrain[nextY][nextX];
|
|
const stepCost = this.movementCost(unit, terrain);
|
|
const nextCost = current.cost + stepCost;
|
|
const key = this.tileKey(nextX, nextY);
|
|
if (nextCost > unit.move || nextCost >= (costs.get(key) ?? Number.POSITIVE_INFINITY)) {
|
|
return;
|
|
}
|
|
|
|
costs.set(key, nextCost);
|
|
const tile = { x: nextX, y: nextY, cost: nextCost };
|
|
reachable.set(key, tile);
|
|
queue.push(tile);
|
|
});
|
|
}
|
|
|
|
return Array.from(reachable.values());
|
|
}
|
|
|
|
private movementCost(unit: UnitData, terrain: TerrainType) {
|
|
const unitClass = getUnitClass(unit.classKey);
|
|
return unitClass.movementCosts?.[terrain] ?? getTerrainRule(terrain).moveCost;
|
|
}
|
|
|
|
private isInBounds(x: number, y: number) {
|
|
return x >= 0 && y >= 0 && x < firstBattleMap.width && y < firstBattleMap.height;
|
|
}
|
|
|
|
private tileKey(x: number, y: number) {
|
|
return `${x},${y}`;
|
|
}
|
|
|
|
private isOccupied(x: number, y: number, exceptUnitId?: string) {
|
|
return firstBattleUnits.some((unit) => unit.id !== exceptUnitId && unit.hp > 0 && unit.x === x && unit.y === y);
|
|
}
|
|
|
|
private tileCenterX(x: number) {
|
|
return this.layout.gridX + x * this.layout.tileSize + this.layout.tileSize / 2;
|
|
}
|
|
|
|
private tileCenterY(y: number) {
|
|
return this.layout.gridY + y * this.layout.tileSize + this.layout.tileSize / 2;
|
|
}
|
|
|
|
private clearMarkers() {
|
|
this.markers.forEach((marker) => marker.destroy());
|
|
this.markers = [];
|
|
}
|
|
|
|
private showCommandMenu(unit: UnitData, pointerX: number, pointerY: number) {
|
|
this.hideCommandMenu();
|
|
|
|
const commands: BattleCommand[] = ['attack', 'strategy', 'item', 'wait'];
|
|
const menuWidth = 136;
|
|
const titleHeight = 31;
|
|
const buttonHeight = 34;
|
|
const gap = 5;
|
|
const padding = 8;
|
|
const menuHeight = padding * 2 + titleHeight + commands.length * buttonHeight + (commands.length - 1) * gap;
|
|
const { left, top } = this.commandMenuPosition(pointerX, pointerY, menuWidth, menuHeight);
|
|
|
|
const panel = this.add.rectangle(left, top, menuWidth, menuHeight, 0x101821, 0.97);
|
|
panel.setOrigin(0);
|
|
panel.setStrokeStyle(2, palette.gold, 0.76);
|
|
panel.setDepth(14);
|
|
this.commandMenuObjects.push(panel);
|
|
|
|
const title = this.add.text(left + menuWidth / 2, top + padding + 2, '명령', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '16px',
|
|
color: '#d8b15f',
|
|
fontStyle: '700'
|
|
});
|
|
title.setOrigin(0.5, 0);
|
|
title.setDepth(15);
|
|
this.commandMenuObjects.push(title);
|
|
|
|
commands.forEach((command, index) => {
|
|
const buttonTop = top + padding + titleHeight + index * (buttonHeight + gap);
|
|
const centerX = left + menuWidth / 2;
|
|
const centerY = buttonTop + buttonHeight / 2;
|
|
const background = this.add.rectangle(centerX, centerY, menuWidth - padding * 2, buttonHeight, 0x1a2630, 0.94);
|
|
background.setStrokeStyle(1, command === 'wait' ? palette.gold : palette.blue, 0.7);
|
|
background.setInteractive({ useHandCursor: true });
|
|
background.setDepth(15);
|
|
|
|
const text = this.add.text(centerX, centerY, commandLabels[command], {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '18px',
|
|
color: '#f2e3bf',
|
|
fontStyle: '700'
|
|
});
|
|
text.setOrigin(0.5);
|
|
text.setDepth(16);
|
|
|
|
const chooseCommand = (choicePointer: Phaser.Input.Pointer) => {
|
|
if (choicePointer.rightButtonDown()) {
|
|
this.cancelPendingMove();
|
|
return;
|
|
}
|
|
if (choicePointer.leftButtonDown()) {
|
|
if (command === 'strategy' || command === 'item') {
|
|
this.showUnavailableCommandAlert(command);
|
|
return;
|
|
}
|
|
|
|
if (command !== 'wait') {
|
|
this.beginDamageTargeting(unit, command);
|
|
return;
|
|
}
|
|
|
|
this.completeUnitAction(unit, command);
|
|
}
|
|
};
|
|
background.on('pointerover', () => background.setFillStyle(0x283947, 0.98));
|
|
background.on('pointerout', () => background.setFillStyle(0x1a2630, 0.94));
|
|
background.on('pointerdown', chooseCommand);
|
|
text.setInteractive({ useHandCursor: true });
|
|
text.on('pointerdown', chooseCommand);
|
|
|
|
this.commandButtons.push({ command, background, text });
|
|
});
|
|
}
|
|
|
|
private commandMenuPosition(pointerX: number, pointerY: number, width: number, height: number) {
|
|
const margin = 14;
|
|
const offset = 18;
|
|
const preferredLeft = pointerX + offset + width > this.scale.width - margin ? pointerX - offset - width : pointerX + offset;
|
|
const preferredTop = pointerY + height > this.scale.height - margin ? this.scale.height - margin - height : pointerY;
|
|
|
|
return {
|
|
left: Phaser.Math.Clamp(preferredLeft, margin, this.scale.width - margin - width),
|
|
top: Phaser.Math.Clamp(preferredTop, margin, this.scale.height - margin - height)
|
|
};
|
|
}
|
|
|
|
private hideCommandMenu() {
|
|
this.commandMenuObjects.forEach((object) => object.destroy());
|
|
this.commandMenuObjects = [];
|
|
this.commandButtons.forEach(({ background, text }) => {
|
|
background.destroy();
|
|
text.destroy();
|
|
});
|
|
this.commandButtons = [];
|
|
}
|
|
|
|
private showUnavailableCommandAlert(command: Extract<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) {
|
|
if (this.phase !== 'command' || this.selectedUnit?.id !== unit.id || this.actedUnitIds.has(unit.id)) {
|
|
return;
|
|
}
|
|
|
|
const targets = this.damageableTargets(unit, action);
|
|
if (targets.length === 0) {
|
|
this.renderUnitDetail(unit, `현재 위치에서 ${commandLabels[action]} 가능한 적이 없습니다. 다른 명령을 선택하거나 우클릭으로 이동을 취소하세요.`);
|
|
return;
|
|
}
|
|
|
|
this.phase = 'targeting';
|
|
this.targetingAction = action;
|
|
this.hideCommandMenu();
|
|
this.clearMarkers();
|
|
this.renderUnitDetail(unit, `붉은 칸의 적을 선택하면 ${commandLabels[action]}합니다. 우클릭하면 명령 선택으로 돌아갑니다.`);
|
|
this.showDamageTargets(unit, action, targets);
|
|
soundDirector.playSelect();
|
|
}
|
|
|
|
private showDamageTargets(attacker: UnitData, action: DamageCommand, targets = this.damageableTargets(attacker, action)) {
|
|
targets.forEach((target) => {
|
|
const preview = this.combatPreview(attacker, target, action);
|
|
const marker = this.add.rectangle(
|
|
this.layout.gridX + target.x * this.layout.tileSize,
|
|
this.layout.gridY + target.y * this.layout.tileSize,
|
|
this.layout.tileSize,
|
|
this.layout.tileSize,
|
|
0xc93b30,
|
|
0.3
|
|
);
|
|
marker.setOrigin(0);
|
|
marker.setStrokeStyle(2, 0xffd27a, 0.9);
|
|
marker.setDepth(9);
|
|
marker.setInteractive({ useHandCursor: true });
|
|
marker.on('pointerover', () => {
|
|
marker.setFillStyle(0xd9503f, 0.44);
|
|
this.renderAttackPreview(preview);
|
|
});
|
|
marker.on('pointerout', () => marker.setFillStyle(0xc93b30, 0.3));
|
|
marker.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
|
|
if (pointer.leftButtonDown()) {
|
|
void this.tryResolveDamageTarget(attacker, target, action);
|
|
}
|
|
});
|
|
this.markers.push(marker);
|
|
});
|
|
}
|
|
|
|
private async tryResolveDamageTarget(attacker: UnitData, target: UnitData, action: DamageCommand) {
|
|
if (this.phase !== 'targeting' || this.selectedUnit?.id !== attacker.id) {
|
|
return;
|
|
}
|
|
|
|
if (!this.canUseDamageCommand(attacker, target, action)) {
|
|
this.renderUnitDetail(attacker, '사거리 밖의 적입니다. 붉은 칸으로 표시된 적을 선택하세요.');
|
|
return;
|
|
}
|
|
|
|
this.phase = 'animating';
|
|
const result = this.resolveCombatAction(attacker, target, action);
|
|
this.clearMarkers();
|
|
await this.playCombatCutIn(result);
|
|
this.finishUnitAction(attacker, this.formatCombatResult(result));
|
|
}
|
|
|
|
private renderAttackPreview(preview: CombatPreview) {
|
|
this.renderUnitDetail(
|
|
preview.defender,
|
|
`${preview.attacker.name} ${commandLabels[preview.action]} 예측\n피해 ${preview.damage} / 명중 ${preview.hitRate}% / 지형 ${preview.terrainLabel}`
|
|
);
|
|
}
|
|
|
|
private finishUnitAction(unit: UnitData, message: string) {
|
|
this.actedUnitIds.add(unit.id);
|
|
this.phase = 'idle';
|
|
this.selectedUnit = undefined;
|
|
this.pendingMove = undefined;
|
|
this.targetingAction = undefined;
|
|
this.hideCommandMenu();
|
|
this.hideTurnEndPrompt();
|
|
this.applyActedStyle(unit);
|
|
soundDirector.playSelect();
|
|
|
|
const remaining = this.remainingAllyCount();
|
|
const turnHint =
|
|
remaining > 0
|
|
? `${remaining}명의 아군이 아직 행동할 수 있습니다.`
|
|
: '모든 아군이 행동했습니다. 턴 종료 여부를 선택하세요.';
|
|
this.pushBattleLog(message);
|
|
this.renderRosterPanel('ally', `${message}\n${turnHint}`);
|
|
|
|
if (remaining === 0) {
|
|
this.showTurnEndPrompt(message);
|
|
}
|
|
}
|
|
|
|
private attackableTargets(attacker: UnitData) {
|
|
return this.damageableTargets(attacker, 'attack');
|
|
}
|
|
|
|
private damageableTargets(attacker: UnitData, action: DamageCommand) {
|
|
return firstBattleUnits.filter((target) => this.canUseDamageCommand(attacker, target, action));
|
|
}
|
|
|
|
private canAttack(attacker: UnitData, target: UnitData) {
|
|
return this.canUseDamageCommand(attacker, target, 'attack');
|
|
}
|
|
|
|
private canUseDamageCommand(attacker: UnitData, target: UnitData, action: DamageCommand) {
|
|
if (attacker.faction === target.faction || target.hp <= 0) {
|
|
return false;
|
|
}
|
|
|
|
return this.tileDistance(attacker, target) <= this.actionRange(attacker, action);
|
|
}
|
|
|
|
private attackRange(unit: UnitData) {
|
|
return attackRangeByClass[unit.classKey] ?? 1;
|
|
}
|
|
|
|
private actionRange(unit: UnitData, action: DamageCommand) {
|
|
if (action === 'attack') {
|
|
return this.attackRange(unit);
|
|
}
|
|
return unit.classKey === 'archer' ? 3 : 2;
|
|
}
|
|
|
|
private combatPreview(attacker: UnitData, defender: UnitData, action: DamageCommand): CombatPreview {
|
|
const terrain = firstBattleMap.terrain[defender.y][defender.x];
|
|
const terrainRule = getTerrainRule(terrain);
|
|
const attackPower = this.actionPower(attacker, action);
|
|
const defensePower = this.actionDefense(defender, action, terrainRule.defenseBonus);
|
|
const bondBonus = attacker.faction === 'ally' ? this.strongestBondBonus(attacker.id).damageBonus : 0;
|
|
const damage = Phaser.Math.Clamp(Math.round((attackPower - defensePower) * (1 + bondBonus / 100)), 3, defender.maxHp);
|
|
const hitRate = Phaser.Math.Clamp(84 + Math.floor((attacker.stats.agility - defender.stats.agility) / 4), 65, 98);
|
|
|
|
return {
|
|
action,
|
|
attacker,
|
|
defender,
|
|
damage,
|
|
hitRate,
|
|
terrainLabel: terrainRule.label
|
|
};
|
|
}
|
|
|
|
private resolveAttack(attacker: UnitData, defender: UnitData): CombatResult {
|
|
return this.resolveCombatAction(attacker, defender, 'attack');
|
|
}
|
|
|
|
private resolveCombatAction(attacker: UnitData, defender: UnitData, action: DamageCommand): CombatResult {
|
|
const preview = this.combatPreview(attacker, defender, action);
|
|
const previousDefenderHp = defender.hp;
|
|
const attackerGrowth = this.awardEquipmentExp(attacker, action === 'attack' ? 'weapon' : 'accessory', this.attackerEquipmentExpGain(attacker, action));
|
|
const armorGrowth = this.awardEquipmentExp(defender, 'armor', 6);
|
|
const characterGrowth = this.awardCharacterExp(attacker, this.characterExpGain(preview));
|
|
const bondExp = attacker.faction === 'ally' ? this.recordBondAttackIntent(attacker, defender) : 0;
|
|
|
|
defender.hp = Math.max(0, defender.hp - preview.damage);
|
|
this.faceUnitToward(attacker, defender);
|
|
this.flashDamage(defender, preview.damage);
|
|
|
|
if (defender.hp <= 0) {
|
|
this.applyDefeatedStyle(defender);
|
|
} else {
|
|
this.syncUnitMotion(defender);
|
|
}
|
|
|
|
return {
|
|
...preview,
|
|
previousDefenderHp,
|
|
defeated: defender.hp <= 0,
|
|
characterGrowth,
|
|
attackerGrowth,
|
|
defenderGrowth: armorGrowth,
|
|
bondExp
|
|
};
|
|
}
|
|
|
|
private actionPower(unit: UnitData, action: DamageCommand) {
|
|
if (action === 'strategy') {
|
|
return 10 + Math.floor(unit.stats.intelligence / 5) + this.equipmentStrategyBonus(unit);
|
|
}
|
|
if (action === 'item') {
|
|
return 9 + Math.floor(unit.stats.luck / 8) + Math.floor(this.equipmentAttackBonus(unit) / 2);
|
|
}
|
|
return unit.attack + this.equipmentAttackBonus(unit) + Math.floor(unit.stats.might / 12);
|
|
}
|
|
|
|
private actionDefense(unit: UnitData, action: DamageCommand, terrainDefenseBonus: number) {
|
|
if (action === 'strategy') {
|
|
return Math.floor(unit.stats.intelligence / 10) + Math.floor(terrainDefenseBonus / 4);
|
|
}
|
|
if (action === 'item') {
|
|
return Math.floor(unit.stats.leadership / 22) + Math.floor(terrainDefenseBonus / 5);
|
|
}
|
|
return this.equipmentDefenseBonus(unit) + Math.floor(unit.stats.leadership / 18) + Math.floor(terrainDefenseBonus / 3);
|
|
}
|
|
|
|
private attackerEquipmentExpGain(unit: UnitData, action: DamageCommand) {
|
|
if (action === 'attack') {
|
|
return this.weaponExpGain(unit);
|
|
}
|
|
return 5 + (getItem(unit.equipment.accessory.itemId).rank === 'treasure' ? 2 : 0);
|
|
}
|
|
|
|
private characterExpGain(result: CombatPreview) {
|
|
const levelGap = result.defender.level - result.attacker.level;
|
|
const actionBonus = result.action === 'attack' ? 10 : 8;
|
|
const defeatBonus = result.damage >= result.defender.hp ? 12 : 0;
|
|
return Phaser.Math.Clamp(actionBonus + levelGap * 2 + defeatBonus, 4, 28);
|
|
}
|
|
|
|
private formatCombatResult(result: CombatResult) {
|
|
const defeated = result.defeated ? `\n${result.defender.name} 퇴각!` : `\n${result.defender.name} HP ${result.defender.hp}/${result.defender.maxHp}`;
|
|
const bond = result.bondExp > 0 ? `\n공명 경험치 +${result.bondExp}` : '';
|
|
return `${result.attacker.name} ${commandLabels[result.action]}\n${result.defender.name}에게 ${result.damage} 피해${defeated}\n장수 경험치 +${result.characterGrowth.amount}\n${this.formatEquipmentGrowth(result.attackerGrowth)}\n${this.formatEquipmentGrowth(result.defenderGrowth)}${bond}`;
|
|
}
|
|
|
|
private combatGrowthLines(result: CombatResult) {
|
|
const characterLevel = result.characterGrowth.leveled ? ` / Lv ${result.characterGrowth.level}` : '';
|
|
const lines = [
|
|
`장수 경험치 +${result.characterGrowth.amount} (${result.characterGrowth.exp}/${result.characterGrowth.next})${characterLevel}`,
|
|
this.formatEquipmentGrowth(result.attackerGrowth),
|
|
this.formatEquipmentGrowth(result.defenderGrowth)
|
|
];
|
|
|
|
if (result.bondExp > 0) {
|
|
lines.push(`공명 경험치 +${result.bondExp}`);
|
|
}
|
|
|
|
return lines;
|
|
}
|
|
|
|
private commandResultMessage(unit: UnitData, command: BattleCommand) {
|
|
if (command === 'attack') {
|
|
return this.recordAttackIntent(unit);
|
|
}
|
|
if (command === 'strategy') {
|
|
return `${unit.name} 책략 명령 완료\n책략 목록과 MP는 다음 전투 확장 때 붙이겠습니다.`;
|
|
}
|
|
if (command === 'item') {
|
|
return `${unit.name} 도구 명령 완료\n도구 가방은 이후 회복 아이템과 함께 열겠습니다.`;
|
|
}
|
|
return `${unit.name} 대기 명령 완료`;
|
|
}
|
|
|
|
private remainingAllyCount() {
|
|
return firstBattleUnits.filter((unit) => unit.faction === 'ally' && unit.hp > 0 && !this.actedUnitIds.has(unit.id)).length;
|
|
}
|
|
|
|
private createBondStates() {
|
|
return new Map(
|
|
firstBattleBonds.map((bond) => [
|
|
bond.id,
|
|
{
|
|
...bond,
|
|
battleExp: 0
|
|
}
|
|
])
|
|
);
|
|
}
|
|
|
|
private createEnemyHomeTiles() {
|
|
return new Map(
|
|
firstBattleUnits
|
|
.filter((unit) => unit.faction === 'enemy')
|
|
.map((unit) => [unit.id, { x: unit.x, y: unit.y }] as const)
|
|
);
|
|
}
|
|
|
|
private handleRightClick(pointer: Phaser.Input.Pointer) {
|
|
if (this.phase === 'animating') {
|
|
return;
|
|
}
|
|
|
|
if (this.cancelAttackTargeting()) {
|
|
return;
|
|
}
|
|
|
|
if (this.cancelPendingMove()) {
|
|
return;
|
|
}
|
|
|
|
if (this.isPointerOnEmptyMapTile(pointer)) {
|
|
this.showMapMenu(pointer.x, pointer.y);
|
|
return;
|
|
}
|
|
|
|
this.hideMapMenu();
|
|
}
|
|
|
|
private isPointerOnEmptyMapTile(pointer: Phaser.Input.Pointer) {
|
|
const tile = this.pointerToTile(pointer);
|
|
if (!tile) {
|
|
return false;
|
|
}
|
|
|
|
return !this.isOccupied(tile.x, tile.y);
|
|
}
|
|
|
|
private pointerToTile(pointer: Phaser.Input.Pointer) {
|
|
const { gridX, gridY, gridSize, tileSize } = this.layout;
|
|
if (pointer.x < gridX || pointer.y < gridY || pointer.x >= gridX + gridSize || pointer.y >= gridY + gridSize) {
|
|
return undefined;
|
|
}
|
|
|
|
return {
|
|
x: Math.floor((pointer.x - gridX) / tileSize),
|
|
y: Math.floor((pointer.y - gridY) / tileSize)
|
|
};
|
|
}
|
|
|
|
private showMapMenu(pointerX: number, pointerY: number) {
|
|
if (this.phase === 'command' || this.phase === 'targeting' || this.phase === 'animating') {
|
|
return;
|
|
}
|
|
|
|
this.hideMapMenu();
|
|
this.hideTurnEndPrompt();
|
|
this.clearMarkers();
|
|
this.selectedUnit = undefined;
|
|
this.pendingMove = undefined;
|
|
this.phase = 'idle';
|
|
|
|
const actions: MapMenuAction[] = ['endTurn', 'save', 'load', 'roster', 'bond', 'situation', 'bgm', 'close'];
|
|
const menuWidth = 148;
|
|
const buttonHeight = 34;
|
|
const padding = 8;
|
|
const menuHeight = padding * 2 + actions.length * buttonHeight;
|
|
const left = this.layout.mapX + 84;
|
|
const top = Phaser.Math.Clamp(pointerY - menuHeight / 2, this.layout.mapY + 16, this.layout.mapY + this.layout.mapHeight - menuHeight - 16);
|
|
|
|
const panel = this.add.rectangle(left, top, menuWidth, menuHeight, 0x101821, 0.97);
|
|
panel.setOrigin(0);
|
|
panel.setDepth(20);
|
|
panel.setStrokeStyle(2, palette.gold, 0.86);
|
|
this.mapMenuObjects.push(panel);
|
|
|
|
actions.forEach((action, index) => {
|
|
const y = top + padding + index * buttonHeight;
|
|
const disabled =
|
|
(action === 'endTurn' && this.activeFaction !== 'ally') ||
|
|
((action === 'save' || action === 'load') && this.activeFaction !== 'ally') ||
|
|
(action === 'load' && !this.hasBattleSave());
|
|
const bg = this.add.rectangle(left + 8, y, menuWidth - 16, buttonHeight - 4, disabled ? 0x121820 : 0x1a2630, disabled ? 0.72 : 0.94);
|
|
bg.setOrigin(0);
|
|
bg.setDepth(21);
|
|
bg.setStrokeStyle(1, action === 'endTurn' ? 0xd8b15f : 0x5a7588, disabled ? 0.32 : 0.68);
|
|
this.mapMenuObjects.push(bg);
|
|
|
|
const text = this.add.text(left + menuWidth / 2, y + buttonHeight / 2 - 2, this.mapMenuLabel(action), {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '17px',
|
|
color: disabled ? '#737b84' : '#f2e3bf',
|
|
fontStyle: '700'
|
|
});
|
|
text.setOrigin(0.5);
|
|
text.setDepth(22);
|
|
this.mapMenuObjects.push(text);
|
|
|
|
if (!disabled) {
|
|
const run = () => this.runMapMenuAction(action);
|
|
bg.setInteractive({ useHandCursor: true });
|
|
bg.on('pointerover', () => bg.setFillStyle(0x283947, 0.98));
|
|
bg.on('pointerout', () => bg.setFillStyle(0x1a2630, 0.94));
|
|
bg.on('pointerdown', run);
|
|
text.setInteractive({ useHandCursor: true });
|
|
text.on('pointerdown', run);
|
|
}
|
|
});
|
|
}
|
|
|
|
private runMapMenuAction(action: MapMenuAction) {
|
|
this.hideMapMenu();
|
|
soundDirector.playSelect();
|
|
|
|
if (action === 'endTurn') {
|
|
this.endAllyTurn();
|
|
return;
|
|
}
|
|
if (action === 'save') {
|
|
this.saveBattleState();
|
|
return;
|
|
}
|
|
if (action === 'load') {
|
|
this.loadBattleState();
|
|
return;
|
|
}
|
|
if (action === 'roster') {
|
|
this.renderRosterPanel('ally', '부대 일람입니다.');
|
|
return;
|
|
}
|
|
if (action === 'bond') {
|
|
this.renderBondPanel();
|
|
return;
|
|
}
|
|
if (action === 'situation') {
|
|
this.renderSituationPanel();
|
|
return;
|
|
}
|
|
if (action === 'bgm') {
|
|
soundDirector.setMuted(!soundDirector.isMuted());
|
|
this.renderSituationPanel(`BGM을 ${soundDirector.isMuted() ? '껐습니다' : '켰습니다'}.`);
|
|
}
|
|
}
|
|
|
|
private hideMapMenu() {
|
|
this.mapMenuObjects.forEach((object) => object.destroy());
|
|
this.mapMenuObjects = [];
|
|
}
|
|
|
|
private mapMenuLabel(action: MapMenuAction) {
|
|
if (action === 'save') {
|
|
return '저장';
|
|
}
|
|
if (action === 'load') {
|
|
return '불러오기';
|
|
}
|
|
return mapMenuLabels[action] ?? action;
|
|
}
|
|
|
|
private hasBattleSave() {
|
|
return Boolean(window.localStorage.getItem(battleSaveStorageKey));
|
|
}
|
|
|
|
private saveBattleState() {
|
|
try {
|
|
const state = this.createBattleSaveState();
|
|
window.localStorage.setItem(battleSaveStorageKey, JSON.stringify(state));
|
|
this.renderSituationPanel(`전투 상태를 저장했습니다.\n${this.formatSavedAt(state.savedAt)}`);
|
|
} catch {
|
|
this.renderSituationPanel('전투 상태를 저장하지 못했습니다.');
|
|
}
|
|
}
|
|
|
|
private loadBattleState() {
|
|
const raw = window.localStorage.getItem(battleSaveStorageKey);
|
|
if (!raw) {
|
|
this.renderSituationPanel('불러올 저장 데이터가 없습니다.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const state = JSON.parse(raw) as BattleSaveState;
|
|
if (!state || state.version !== 1 || !Array.isArray(state.units)) {
|
|
throw new Error('Invalid battle save');
|
|
}
|
|
this.applyBattleSaveState(state);
|
|
this.renderSituationPanel(`저장된 전투를 불러왔습니다.\n${this.formatSavedAt(state.savedAt)}`);
|
|
} catch {
|
|
this.renderSituationPanel('저장 데이터를 불러오지 못했습니다.');
|
|
}
|
|
}
|
|
|
|
private createBattleSaveState(): BattleSaveState {
|
|
return {
|
|
version: 1,
|
|
savedAt: new Date().toISOString(),
|
|
turnNumber: this.turnNumber,
|
|
activeFaction: this.activeFaction,
|
|
rosterTab: this.rosterTab,
|
|
actedUnitIds: Array.from(this.actedUnitIds),
|
|
attackIntents: this.attackIntents.map((intent) => ({ ...intent })),
|
|
battleLog: [...this.battleLog],
|
|
units: firstBattleUnits.map((unit) => ({
|
|
id: unit.id,
|
|
level: unit.level,
|
|
exp: unit.exp,
|
|
hp: unit.hp,
|
|
maxHp: unit.maxHp,
|
|
attack: unit.attack,
|
|
move: unit.move,
|
|
x: unit.x,
|
|
y: unit.y,
|
|
direction: this.unitViews.get(unit.id)?.direction,
|
|
equipment: this.cloneEquipment(unit.equipment)
|
|
})),
|
|
bonds: Array.from(this.bondStates.values()).map((bond) => ({
|
|
...bond,
|
|
unitIds: [...bond.unitIds] as [string, string]
|
|
}))
|
|
};
|
|
}
|
|
|
|
private applyBattleSaveState(state: BattleSaveState) {
|
|
this.clearMarkers();
|
|
this.hideCommandMenu();
|
|
this.hideTurnEndPrompt();
|
|
this.hideCombatCutIn();
|
|
this.selectedUnit = undefined;
|
|
this.pendingMove = undefined;
|
|
this.targetingAction = undefined;
|
|
this.phase = 'idle';
|
|
|
|
this.turnNumber = Math.max(1, state.turnNumber || 1);
|
|
this.activeFaction = state.activeFaction === 'enemy' ? 'enemy' : 'ally';
|
|
this.rosterTab = state.rosterTab === 'enemy' ? 'enemy' : 'ally';
|
|
this.actedUnitIds = new Set(state.actedUnitIds ?? []);
|
|
this.attackIntents = (state.attackIntents ?? []).map((intent) => ({ ...intent }));
|
|
this.battleLog = [...(state.battleLog ?? [])];
|
|
this.bondStates = new Map(
|
|
(state.bonds ?? []).map((bond) => [
|
|
bond.id,
|
|
{
|
|
...bond,
|
|
unitIds: [...bond.unitIds] as [string, string],
|
|
battleExp: bond.battleExp ?? 0
|
|
}
|
|
])
|
|
);
|
|
|
|
state.units.forEach((savedUnit) => {
|
|
const unit = firstBattleUnits.find((candidate) => candidate.id === savedUnit.id);
|
|
if (!unit) {
|
|
return;
|
|
}
|
|
|
|
unit.level = savedUnit.level;
|
|
unit.exp = savedUnit.exp;
|
|
unit.hp = Phaser.Math.Clamp(savedUnit.hp, 0, savedUnit.maxHp);
|
|
unit.maxHp = savedUnit.maxHp;
|
|
unit.attack = savedUnit.attack;
|
|
unit.move = savedUnit.move;
|
|
unit.x = Phaser.Math.Clamp(savedUnit.x, 0, firstBattleMap.width - 1);
|
|
unit.y = Phaser.Math.Clamp(savedUnit.y, 0, firstBattleMap.height - 1);
|
|
unit.equipment = this.cloneEquipment(savedUnit.equipment);
|
|
this.restoreUnitView(unit, savedUnit.direction);
|
|
});
|
|
|
|
this.resetActedStyles();
|
|
this.updateTurnText();
|
|
this.renderRosterPanel(this.rosterTab, '저장된 전투 상태입니다.');
|
|
}
|
|
|
|
private restoreUnitView(unit: UnitData, direction: UnitDirection = 'south') {
|
|
const view = this.unitViews.get(unit.id);
|
|
if (!view) {
|
|
return;
|
|
}
|
|
|
|
this.tweens.killTweensOf([view.sprite, view.label]);
|
|
view.direction = direction;
|
|
view.sprite.setPosition(this.tileCenterX(unit.x), this.tileCenterY(unit.y));
|
|
view.label.setPosition(view.sprite.x, view.sprite.y + this.layout.tileSize * 0.52);
|
|
if (unit.hp > 0) {
|
|
view.sprite.setInteractive({ useHandCursor: true });
|
|
}
|
|
}
|
|
|
|
private cloneEquipment(equipment: UnitData['equipment']) {
|
|
return JSON.parse(JSON.stringify(equipment)) as UnitData['equipment'];
|
|
}
|
|
|
|
private formatSavedAt(savedAt: string) {
|
|
return new Date(savedAt).toLocaleString('ko-KR');
|
|
}
|
|
|
|
private endAllyTurn() {
|
|
if (this.activeFaction !== 'ally') {
|
|
this.renderSituationPanel('이미 적군 행동 중입니다.');
|
|
return;
|
|
}
|
|
if (this.phase === 'command' || this.phase === 'targeting' || this.phase === 'animating') {
|
|
this.setInfo('현재 장수의 행동을 먼저 결정하세요.');
|
|
return;
|
|
}
|
|
|
|
this.selectedUnit = undefined;
|
|
this.pendingMove = undefined;
|
|
this.phase = 'idle';
|
|
this.clearMarkers();
|
|
this.hideCommandMenu();
|
|
this.hideTurnEndPrompt();
|
|
|
|
this.activeFaction = 'enemy';
|
|
this.updateTurnText();
|
|
this.renderSituationPanel('아군 턴을 종료했습니다.\n적군이 행동을 시작합니다.');
|
|
void this.runEnemyTurn();
|
|
return;
|
|
|
|
this.activeFaction = 'enemy';
|
|
this.updateTurnText();
|
|
const pressureMessage = this.resolveEnemyPressure();
|
|
this.renderSituationPanel(`아군 턴을 종료했습니다.\n${pressureMessage}`);
|
|
|
|
this.time.delayedCall(700, () => this.beginNextAllyTurn());
|
|
}
|
|
|
|
private async runEnemyTurn() {
|
|
const enemies = firstBattleUnits.filter((unit) => unit.faction === 'enemy' && unit.hp > 0);
|
|
const messages: string[] = [];
|
|
|
|
for (const enemy of enemies) {
|
|
const message = await this.executeEnemyAction(enemy);
|
|
if (message) {
|
|
messages.push(message);
|
|
this.pushBattleLog(message);
|
|
this.renderSituationPanel(messages.slice(-3).join('\n'));
|
|
}
|
|
await this.delay(260);
|
|
}
|
|
|
|
this.time.delayedCall(520, () => this.beginNextAllyTurn());
|
|
}
|
|
|
|
private async executeEnemyAction(enemy: UnitData) {
|
|
const behavior = this.enemyBehavior(enemy);
|
|
let target = this.chooseEnemyTarget(enemy, behavior);
|
|
|
|
if (!target) {
|
|
return `${enemy.name}: 대기`;
|
|
}
|
|
|
|
if (!this.canAttack(enemy, target) && behavior !== 'hold') {
|
|
const destination = this.chooseApproachTile(enemy, target);
|
|
if (destination && (destination.x !== enemy.x || destination.y !== enemy.y)) {
|
|
enemy.x = destination.x;
|
|
enemy.y = destination.y;
|
|
await this.moveUnitViewAsync(enemy, destination.x, destination.y, this.unitViews.get(enemy.id), 260);
|
|
}
|
|
}
|
|
|
|
target = this.chooseTargetInRange(enemy) ?? target;
|
|
if (this.canAttack(enemy, target)) {
|
|
const result = this.resolveAttack(enemy, target);
|
|
await this.playCombatCutIn(result);
|
|
return this.formatEnemyCombatResult(result, behavior);
|
|
}
|
|
|
|
return `${enemy.name}: ${this.enemyBehaviorLabel(behavior)} 태세로 접근`;
|
|
}
|
|
|
|
private chooseEnemyTarget(enemy: UnitData, behavior: EnemyAiBehavior) {
|
|
if (behavior === 'hold') {
|
|
return this.chooseTargetInRange(enemy);
|
|
}
|
|
|
|
const nearest = this.nearestAliveUnit(enemy, 'ally');
|
|
if (!nearest) {
|
|
return undefined;
|
|
}
|
|
|
|
if (behavior === 'guard') {
|
|
const home = this.enemyHomeTiles.get(enemy.id) ?? { x: enemy.x, y: enemy.y };
|
|
const distanceFromHome = Math.abs(nearest.x - home.x) + Math.abs(nearest.y - home.y);
|
|
if (distanceFromHome > guardDetectionRange && !this.canAttack(enemy, nearest)) {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
return nearest;
|
|
}
|
|
|
|
private chooseTargetInRange(enemy: UnitData) {
|
|
return firstBattleUnits
|
|
.filter((unit) => unit.faction === 'ally' && unit.hp > 0 && this.canAttack(enemy, unit))
|
|
.sort((a, b) => a.hp - b.hp || this.tileDistance(enemy, a) - this.tileDistance(enemy, b))[0];
|
|
}
|
|
|
|
private chooseApproachTile(enemy: UnitData, target: UnitData) {
|
|
const candidates = [{ x: enemy.x, y: enemy.y, cost: 0 }, ...this.reachableTiles(enemy)];
|
|
return candidates
|
|
.filter((tile) => !this.isOccupied(tile.x, tile.y, enemy.id))
|
|
.sort((a, b) => {
|
|
const aDistance = Math.abs(a.x - target.x) + Math.abs(a.y - target.y);
|
|
const bDistance = Math.abs(b.x - target.x) + Math.abs(b.y - target.y);
|
|
const aTerrain = getTerrainRule(firstBattleMap.terrain[a.y][a.x]).defenseBonus;
|
|
const bTerrain = getTerrainRule(firstBattleMap.terrain[b.y][b.x]).defenseBonus;
|
|
return aDistance - bDistance || bTerrain - aTerrain || a.cost - b.cost;
|
|
})[0];
|
|
}
|
|
|
|
private nearestAliveUnit(unit: UnitData, faction: UnitData['faction']) {
|
|
return firstBattleUnits
|
|
.filter((candidate) => candidate.faction === faction && candidate.hp > 0)
|
|
.sort((a, b) => this.tileDistance(unit, a) - this.tileDistance(unit, b))[0];
|
|
}
|
|
|
|
private enemyBehavior(enemy: UnitData): EnemyAiBehavior {
|
|
return enemyAiByUnitId[enemy.id] ?? defaultEnemyAiByClass[enemy.classKey] ?? 'guard';
|
|
}
|
|
|
|
private enemyBehaviorLabel(behavior: EnemyAiBehavior) {
|
|
if (behavior === 'aggressive') {
|
|
return '추격';
|
|
}
|
|
if (behavior === 'hold') {
|
|
return '고수';
|
|
}
|
|
return '경계';
|
|
}
|
|
|
|
private formatEnemyCombatResult(result: CombatResult, behavior: EnemyAiBehavior) {
|
|
const defeated = result.defeated ? ` / ${result.defender.name} 퇴각` : ` / ${result.defender.name} HP ${result.defender.hp}/${result.defender.maxHp}`;
|
|
return `${result.attacker.name} ${this.enemyBehaviorLabel(behavior)} 공격: ${result.defender.name}에게 ${result.damage} 피해${defeated}`;
|
|
}
|
|
|
|
private async playCombatCutIn(result: CombatResult) {
|
|
this.hideCombatCutIn();
|
|
|
|
const panelWidth = 900;
|
|
const panelHeight = 430;
|
|
const left = Math.floor((this.scale.width - panelWidth) / 2);
|
|
const top = Math.floor((this.scale.height - panelHeight) / 2);
|
|
const depth = 70;
|
|
const attackerX = left + 190;
|
|
const defenderX = left + panelWidth - 190;
|
|
const groundY = top + 246;
|
|
|
|
const dim = this.trackCombatObject(this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0x020406, 0.62));
|
|
dim.setOrigin(0);
|
|
dim.setDepth(depth);
|
|
|
|
const panel = this.trackCombatObject(this.add.rectangle(left, top, panelWidth, panelHeight, 0x101821, 0.98));
|
|
panel.setOrigin(0);
|
|
panel.setDepth(depth + 1);
|
|
panel.setStrokeStyle(3, palette.gold, 0.9);
|
|
|
|
const stage = this.trackCombatObject(this.add.graphics());
|
|
stage.setDepth(depth + 2);
|
|
stage.fillStyle(0x8ec9e6, 1);
|
|
stage.fillRect(left + 18, top + 18, panelWidth - 36, 64);
|
|
stage.fillStyle(0x3f6f44, 1);
|
|
stage.fillRect(left + 18, top + 82, panelWidth - 36, 186);
|
|
stage.fillStyle(0x6f8c43, 0.92);
|
|
stage.fillRect(left + 18, top + 158, panelWidth - 36, 110);
|
|
stage.lineStyle(1, 0x2f4f2f, 0.45);
|
|
for (let x = left + 40; x < left + panelWidth - 40; x += 42) {
|
|
stage.beginPath();
|
|
stage.moveTo(x, top + 163);
|
|
stage.lineTo(x - 16, top + 264);
|
|
stage.strokePath();
|
|
}
|
|
stage.fillStyle(0x23472c, 1);
|
|
stage.fillRect(left + 18, top + 82, panelWidth - 36, 18);
|
|
|
|
const title = this.trackCombatObject(this.add.text(left + panelWidth / 2, top + 24, commandLabels[result.action], {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '24px',
|
|
color: '#f4dfad',
|
|
fontStyle: '700',
|
|
stroke: '#05070a',
|
|
strokeThickness: 4
|
|
}));
|
|
title.setOrigin(0.5, 0);
|
|
title.setDepth(depth + 8);
|
|
|
|
const attackerDirection = 'east';
|
|
const defenderDirection = 'west';
|
|
const attackerSprite = this.trackCombatObject(
|
|
this.add.sprite(attackerX, groundY, unitTexture[result.attacker.id] ?? 'unit-rebel', this.unitFrameIndex(attackerDirection))
|
|
);
|
|
attackerSprite.setDepth(depth + 6);
|
|
attackerSprite.setDisplaySize(126, 126);
|
|
|
|
const defenderSprite = this.trackCombatObject(
|
|
this.add.sprite(defenderX, groundY, unitTexture[result.defender.id] ?? 'unit-rebel', this.unitFrameIndex(defenderDirection))
|
|
);
|
|
defenderSprite.setDepth(depth + 6);
|
|
defenderSprite.setDisplaySize(126, 126);
|
|
|
|
const attackerStatus = this.renderCombatStatusPanel(result.attacker, left + 26, top + 286, 392, result.characterGrowth);
|
|
const defenderStatus = this.renderCombatStatusPanel(result.defender, left + panelWidth - 418, top + 286, 392);
|
|
defenderStatus.hpFill.setScale(result.previousDefenderHp / result.defender.maxHp, 1);
|
|
defenderStatus.hpText.setText(`${result.previousDefenderHp} / ${result.defender.maxHp}`);
|
|
attackerStatus.expFill.setScale(result.characterGrowth.previousExp / result.characterGrowth.next, 1);
|
|
attackerStatus.expText.setText(`${result.characterGrowth.previousExp} / ${result.characterGrowth.next}`);
|
|
|
|
await this.delay(180);
|
|
await this.playCombatActionMotion(result, attackerSprite, defenderSprite, attackerX, defenderX, groundY, depth + 10);
|
|
this.showCombatImpact(result, defenderX, groundY - 46, depth + 14);
|
|
|
|
await this.animateCombatGauge(defenderStatus.hpFill, result.previousDefenderHp / result.defender.maxHp, result.defender.hp / result.defender.maxHp, 420);
|
|
defenderStatus.hpText.setText(`${result.defender.hp} / ${result.defender.maxHp}`);
|
|
|
|
if (result.characterGrowth.amount > 0) {
|
|
soundDirector.playEffect('exp-gain', { volume: result.characterGrowth.leveled ? 0.44 : 0.28, stopAfterMs: 1000 });
|
|
}
|
|
|
|
await this.animateCombatGauge(
|
|
attackerStatus.expFill,
|
|
result.characterGrowth.previousExp / result.characterGrowth.next,
|
|
result.characterGrowth.exp / result.characterGrowth.next,
|
|
420
|
|
);
|
|
attackerStatus.expText.setText(`${result.characterGrowth.exp} / ${result.characterGrowth.next}`);
|
|
|
|
const resultText = this.trackCombatObject(this.add.text(left + panelWidth / 2, top + 252, `${result.damage} 피해`, {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '22px',
|
|
color: '#ffdf7b',
|
|
fontStyle: '700',
|
|
stroke: '#2b0909',
|
|
strokeThickness: 4
|
|
}));
|
|
resultText.setOrigin(0.5);
|
|
resultText.setDepth(depth + 16);
|
|
|
|
const expText = this.trackCombatObject(this.add.text(left + panelWidth / 2, top + 386, `장수 경험치 +${result.characterGrowth.amount}${result.characterGrowth.leveled ? ` / Lv ${result.characterGrowth.level}` : ''}`, {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '17px',
|
|
color: '#d4dce6',
|
|
fontStyle: '700'
|
|
}));
|
|
expText.setOrigin(0.5);
|
|
expText.setDepth(depth + 16);
|
|
expText.setVisible(false);
|
|
|
|
const growthText = this.trackCombatObject(this.add.text(left + panelWidth / 2, top + 348, this.combatGrowthLines(result).join('\n'), {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '15px',
|
|
color: '#d4dce6',
|
|
fontStyle: '700',
|
|
align: 'center',
|
|
lineSpacing: 4
|
|
}));
|
|
growthText.setOrigin(0.5, 0);
|
|
growthText.setDepth(depth + 16);
|
|
|
|
await this.delay(760);
|
|
this.hideCombatCutIn();
|
|
}
|
|
|
|
private renderCombatStatusPanel(unit: UnitData, x: number, y: number, width: number, growth?: CharacterGrowthResult) {
|
|
const panel = this.trackCombatObject(this.add.rectangle(x, y, width, 116, 0x101820, 0.96));
|
|
panel.setOrigin(0);
|
|
panel.setDepth(84);
|
|
panel.setStrokeStyle(2, unit.faction === 'ally' ? palette.blue : 0xb86b55, 0.78);
|
|
|
|
const textureKey = this.combatPortraitKey(unit);
|
|
if (textureKey && this.textures.exists(textureKey)) {
|
|
const portrait = this.trackCombatObject(this.add.image(x + 38, y + 58, textureKey));
|
|
portrait.setDepth(85);
|
|
portrait.setDisplaySize(58, 80);
|
|
} else {
|
|
const unitIcon = this.trackCombatObject(this.add.sprite(x + 38, y + 58, unitTexture[unit.id] ?? 'unit-rebel', this.unitFrameIndex('south')));
|
|
unitIcon.setDepth(85);
|
|
unitIcon.setDisplaySize(64, 64);
|
|
}
|
|
|
|
const name = this.trackCombatObject(this.add.text(x + 78, y + 14, `${unit.name} Lv ${unit.level}`, {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '17px',
|
|
color: '#f2e3bf',
|
|
fontStyle: '700'
|
|
}));
|
|
name.setDepth(85);
|
|
|
|
const hpLabel = this.trackCombatObject(this.add.text(x + 78, y + 45, '병력', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '13px',
|
|
color: '#9fb0bf',
|
|
fontStyle: '700'
|
|
}));
|
|
hpLabel.setDepth(85);
|
|
const hpTrack = this.trackCombatObject(this.add.rectangle(x + 126, y + 54, width - 210, 9, 0x0a0f14, 0.9));
|
|
hpTrack.setOrigin(0, 0.5);
|
|
hpTrack.setDepth(85);
|
|
const hpFill = this.trackCombatObject(this.add.rectangle(x + 126, y + 54, width - 210, 7, 0x59d18c, 0.96));
|
|
hpFill.setOrigin(0, 0.5);
|
|
hpFill.setDepth(86);
|
|
hpFill.setScale(unit.hp / unit.maxHp, 1);
|
|
const hpText = this.trackCombatObject(this.add.text(x + width - 18, y + 43, `${unit.hp} / ${unit.maxHp}`, {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '14px',
|
|
color: '#f0e4c8',
|
|
fontStyle: '700'
|
|
}));
|
|
hpText.setOrigin(1, 0);
|
|
hpText.setDepth(85);
|
|
|
|
const expLabel = this.trackCombatObject(this.add.text(x + 78, y + 75, '경험', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '13px',
|
|
color: '#9fb0bf',
|
|
fontStyle: '700'
|
|
}));
|
|
expLabel.setDepth(85);
|
|
const expTrack = this.trackCombatObject(this.add.rectangle(x + 126, y + 84, width - 210, 9, 0x0a0f14, 0.9));
|
|
expTrack.setOrigin(0, 0.5);
|
|
expTrack.setDepth(85);
|
|
const expFill = this.trackCombatObject(this.add.rectangle(x + 126, y + 84, width - 210, 7, 0xd8b15f, 0.96));
|
|
expFill.setOrigin(0, 0.5);
|
|
expFill.setDepth(86);
|
|
expFill.setScale((growth?.previousExp ?? unit.exp) / 100, 1);
|
|
const expText = this.trackCombatObject(this.add.text(x + width - 18, y + 73, `${growth?.previousExp ?? unit.exp} / 100`, {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '14px',
|
|
color: '#f0e4c8',
|
|
fontStyle: '700'
|
|
}));
|
|
expText.setOrigin(1, 0);
|
|
expText.setDepth(85);
|
|
|
|
return { hpFill, hpText, expFill, expText };
|
|
}
|
|
|
|
private combatPortraitKey(unit: UnitData) {
|
|
if (unit.id === 'liu-bei') {
|
|
return 'portrait-liu-bei';
|
|
}
|
|
if (unit.id === 'guan-yu') {
|
|
return 'portrait-guan-yu';
|
|
}
|
|
if (unit.id === 'zhang-fei') {
|
|
return 'portrait-zhang-fei';
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
private async playCombatActionMotion(
|
|
result: CombatResult,
|
|
attackerSprite: Phaser.GameObjects.Sprite,
|
|
defenderSprite: Phaser.GameObjects.Sprite,
|
|
attackerX: number,
|
|
defenderX: number,
|
|
groundY: number,
|
|
depth: number
|
|
) {
|
|
const direction = result.attacker.faction === 'ally' ? 1 : -1;
|
|
if (result.action === 'attack') {
|
|
soundDirector.playEffect('sword-slash', { volume: 0.52, stopAfterMs: 700 });
|
|
this.tweens.add({
|
|
targets: attackerSprite,
|
|
x: attackerX + direction * 84,
|
|
duration: 170,
|
|
yoyo: true,
|
|
ease: 'Sine.easeInOut'
|
|
});
|
|
await this.delay(180);
|
|
this.tweens.add({
|
|
targets: defenderSprite,
|
|
x: defenderX + direction * 12,
|
|
duration: 70,
|
|
yoyo: true
|
|
});
|
|
await this.delay(230);
|
|
return;
|
|
}
|
|
|
|
soundDirector.playEffect(result.action === 'item' ? 'cart-roll' : 'strategy-cast', {
|
|
volume: result.action === 'item' ? 0.34 : 0.44,
|
|
stopAfterMs: result.action === 'item' ? 720 : 950
|
|
});
|
|
|
|
const projectile =
|
|
result.action === 'item'
|
|
? this.createCombatCart(attackerX + direction * 78, groundY - 10, depth)
|
|
: this.createStrategyEffect(attackerX + direction * 78, groundY - 48, depth);
|
|
projectile.setDepth(depth);
|
|
this.combatCutInObjects.push(projectile);
|
|
this.tweens.add({
|
|
targets: projectile,
|
|
x: defenderX - direction * 72,
|
|
duration: 430,
|
|
ease: 'Cubic.easeIn'
|
|
});
|
|
await this.delay(450);
|
|
projectile.destroy();
|
|
}
|
|
|
|
private createCombatCart(x: number, y: number, depth: number) {
|
|
const container = this.add.container(x, y);
|
|
container.setDepth(depth);
|
|
const body = this.add.rectangle(0, 0, 58, 28, 0x7c5432, 1);
|
|
body.setStrokeStyle(2, 0x2a160c, 1);
|
|
const cover = this.add.rectangle(0, -9, 46, 10, 0xa57a45, 1);
|
|
const leftWheel = this.add.circle(-18, 15, 7, 0x1b1511, 1);
|
|
const rightWheel = this.add.circle(18, 15, 7, 0x1b1511, 1);
|
|
const blade = this.add.triangle(35, -4, 0, -10, 22, 0, 0, 10, 0xd8dfe6, 1);
|
|
blade.setStrokeStyle(1, 0x4d5960, 1);
|
|
container.add([body, cover, leftWheel, rightWheel, blade]);
|
|
return container;
|
|
}
|
|
|
|
private createStrategyEffect(x: number, y: number, depth: number) {
|
|
const container = this.add.container(x, y);
|
|
container.setDepth(depth);
|
|
const core = this.add.circle(0, 0, 14, 0x6cc5ff, 0.9);
|
|
const ring = this.add.circle(0, 0, 24);
|
|
ring.setStrokeStyle(3, 0xd8b15f, 0.9);
|
|
const spark = this.add.star(0, 0, 5, 7, 18, 0xf7e6a1, 0.9);
|
|
container.add([ring, core, spark]);
|
|
this.tweens.add({ targets: ring, angle: 180, duration: 430 });
|
|
this.tweens.add({ targets: spark, angle: -240, duration: 430 });
|
|
return container;
|
|
}
|
|
|
|
private showCombatImpact(result: CombatResult, x: number, y: number, depth: number) {
|
|
soundDirector.playEffect('combat-impact', { volume: result.action === 'strategy' ? 0.3 : 0.48, stopAfterMs: 650 });
|
|
|
|
const impact = this.trackCombatObject(this.add.star(x, y, 8, 12, 42, result.action === 'strategy' ? 0x6cc5ff : 0xffdf7b, 0.92));
|
|
impact.setDepth(depth);
|
|
this.tweens.add({
|
|
targets: impact,
|
|
scale: 1.45,
|
|
alpha: 0,
|
|
duration: 320,
|
|
ease: 'Sine.easeOut',
|
|
onComplete: () => impact.destroy()
|
|
});
|
|
}
|
|
|
|
private animateCombatGauge(target: Phaser.GameObjects.Rectangle, fromRatio: number, toRatio: number, duration: number) {
|
|
target.setScale(Phaser.Math.Clamp(fromRatio, 0, 1), 1);
|
|
return new Promise<void>((resolve) => {
|
|
this.tweens.add({
|
|
targets: target,
|
|
scaleX: Phaser.Math.Clamp(toRatio, 0, 1),
|
|
duration,
|
|
ease: 'Sine.easeInOut',
|
|
onComplete: () => 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);
|
|
resolve();
|
|
}
|
|
});
|
|
this.tweens.add({
|
|
targets: view.label,
|
|
x: targetX,
|
|
y: targetY + this.layout.tileSize * 0.52,
|
|
duration,
|
|
ease: 'Sine.easeInOut'
|
|
});
|
|
});
|
|
}
|
|
|
|
private beginNextAllyTurn() {
|
|
this.turnNumber += 1;
|
|
this.activeFaction = 'ally';
|
|
this.actedUnitIds.clear();
|
|
this.attackIntents = [];
|
|
this.resetActedStyles();
|
|
this.updateTurnText();
|
|
this.renderRosterPanel('ally', `${this.turnNumber}턴 아군 차례입니다.\n행동할 장수를 선택하세요.`);
|
|
}
|
|
|
|
private updateTurnText() {
|
|
this.turnText?.setText(`${this.turnNumber}턴 / ${factionLabels[this.activeFaction]}`);
|
|
}
|
|
|
|
private resetActedStyles() {
|
|
this.unitViews.forEach((view, unitId) => {
|
|
const unit = firstBattleUnits.find((candidate) => candidate.id === unitId);
|
|
if (unit && unit.hp <= 0) {
|
|
this.applyDefeatedStyle(unit);
|
|
return;
|
|
}
|
|
view.sprite.postFX.clear();
|
|
view.sprite.clearTint();
|
|
view.sprite.setAlpha(1);
|
|
if (unit) {
|
|
this.syncUnitMotion(unit, view);
|
|
} else {
|
|
this.stopUnitWalk(view, view.direction);
|
|
}
|
|
view.label.setAlpha(1);
|
|
view.label.setColor(unit?.faction === 'ally' ? '#e7edf7' : '#ffebe7');
|
|
view.label.setBackgroundColor('');
|
|
});
|
|
}
|
|
|
|
private recordBondAttackIntent(unit: UnitData, target: UnitData) {
|
|
const partners = this.attackIntents
|
|
.filter((intent) => intent.targetId === target.id && intent.attackerId !== unit.id)
|
|
.map((intent) => intent.attackerId);
|
|
let gained = 0;
|
|
|
|
partners.forEach((partnerId) => {
|
|
const bond = this.findBond(unit.id, partnerId);
|
|
if (!bond) {
|
|
return;
|
|
}
|
|
bond.battleExp += 4;
|
|
bond.exp += 4;
|
|
while (bond.exp >= 100) {
|
|
bond.exp -= 100;
|
|
bond.level = Math.min(100, bond.level + 1);
|
|
}
|
|
gained += 4;
|
|
});
|
|
|
|
this.attackIntents = this.attackIntents.filter((intent) => intent.attackerId !== unit.id);
|
|
this.attackIntents.push({ attackerId: unit.id, targetId: target.id });
|
|
|
|
return gained;
|
|
}
|
|
|
|
private recordAttackIntent(unit: UnitData) {
|
|
const target = this.findNearestEnemy(unit);
|
|
const weaponGrowth = this.awardEquipmentExp(unit, 'weapon', this.weaponExpGain(unit));
|
|
if (!target) {
|
|
return `${unit.name} 공격 명령 완료\n공격 가능한 적이 없습니다.\n${this.formatEquipmentGrowth(weaponGrowth)}`;
|
|
}
|
|
|
|
const partners = this.attackIntents
|
|
.filter((intent) => intent.targetId === target.id && intent.attackerId !== unit.id)
|
|
.map((intent) => intent.attackerId);
|
|
let gained = 0;
|
|
|
|
partners.forEach((partnerId) => {
|
|
const bond = this.findBond(unit.id, partnerId);
|
|
if (!bond) {
|
|
return;
|
|
}
|
|
bond.battleExp += 4;
|
|
bond.exp += 4;
|
|
while (bond.exp >= 100) {
|
|
bond.exp -= 100;
|
|
bond.level = Math.min(100, bond.level + 1);
|
|
}
|
|
gained += 4;
|
|
});
|
|
|
|
this.attackIntents = this.attackIntents.filter((intent) => intent.attackerId !== unit.id);
|
|
this.attackIntents.push({ attackerId: unit.id, targetId: target.id });
|
|
|
|
const bonus = this.strongestBondBonus(unit.id);
|
|
const chain = bonus.chainRate > 0 ? `\n공명 보너스: 피해 +${bonus.damageBonus}% / 연계 ${bonus.chainRate}%` : '';
|
|
const exp = gained > 0 ? `\n같은 목표를 노린 공명 경험치 +${gained}` : '\n같은 목표를 노린 장수가 생기면 공명 경험치가 쌓입니다.';
|
|
return `${unit.name} 공격 명령 완료\n예상 목표: ${target.name}\n${this.formatEquipmentGrowth(weaponGrowth)}${chain}${exp}`;
|
|
}
|
|
|
|
private resolveEnemyPressure() {
|
|
const defender = this.findEnemyPressureTarget();
|
|
if (!defender) {
|
|
return '황건적은 움직일 틈을 찾지 못했습니다.';
|
|
}
|
|
|
|
const armorGrowth = this.awardEquipmentExp(defender, 'armor', 6);
|
|
return `황건적의 견제 공격을 ${defender.name}이 받아냈습니다.\n${this.formatEquipmentGrowth(armorGrowth)}`;
|
|
}
|
|
|
|
private findEnemyPressureTarget() {
|
|
const allies = firstBattleUnits.filter((unit) => unit.faction === 'ally' && unit.hp > 0);
|
|
if (allies.length === 0) {
|
|
return undefined;
|
|
}
|
|
|
|
return [...allies].sort((a, b) => this.nearestEnemyDistance(a) - this.nearestEnemyDistance(b))[0];
|
|
}
|
|
|
|
private nearestEnemyDistance(unit: UnitData) {
|
|
const distances = firstBattleUnits
|
|
.filter((candidate) => candidate.faction === 'enemy' && candidate.hp > 0)
|
|
.map((enemy) => this.tileDistance(unit, enemy));
|
|
return distances.length > 0 ? Math.min(...distances) : Number.POSITIVE_INFINITY;
|
|
}
|
|
|
|
private weaponExpGain(unit: UnitData) {
|
|
const weapon = getItem(unit.equipment.weapon.itemId);
|
|
const bonus = this.strongestBondBonus(unit.id);
|
|
return 8 + (weapon.rank === 'treasure' ? 2 : 0) + (bonus.chainRate > 0 ? 2 : 0);
|
|
}
|
|
|
|
private awardEquipmentExp(unit: UnitData, slot: EquipmentSlot, amount: number): EquipmentGrowthResult {
|
|
const state = unit.equipment[slot];
|
|
const item = getItem(state.itemId);
|
|
const previousLevel = state.level;
|
|
const previousExp = state.exp;
|
|
let exp = state.exp + amount;
|
|
let leveled = false;
|
|
|
|
while (state.level < maxEquipmentLevel && exp >= equipmentExpToNext(state.level)) {
|
|
exp -= equipmentExpToNext(state.level);
|
|
state.level += 1;
|
|
leveled = true;
|
|
}
|
|
|
|
const next = equipmentExpToNext(state.level);
|
|
state.exp = state.level >= maxEquipmentLevel ? Math.min(exp, next) : exp;
|
|
|
|
return {
|
|
slot,
|
|
itemName: item.name,
|
|
amount,
|
|
previousLevel,
|
|
previousExp,
|
|
level: state.level,
|
|
exp: state.exp,
|
|
next,
|
|
leveled
|
|
};
|
|
}
|
|
|
|
private awardCharacterExp(unit: UnitData, amount: number): CharacterGrowthResult {
|
|
const previousLevel = unit.level;
|
|
const previousExp = unit.exp;
|
|
let exp = unit.exp + amount;
|
|
let leveled = false;
|
|
|
|
while (unit.level < maxCharacterLevel && exp >= 100) {
|
|
exp -= 100;
|
|
unit.level += 1;
|
|
leveled = true;
|
|
}
|
|
|
|
unit.exp = unit.level >= maxCharacterLevel ? Math.min(exp, 100) : exp;
|
|
|
|
return {
|
|
amount,
|
|
previousLevel,
|
|
previousExp,
|
|
level: unit.level,
|
|
exp: unit.exp,
|
|
next: 100,
|
|
leveled
|
|
};
|
|
}
|
|
|
|
private formatEquipmentGrowth(result: EquipmentGrowthResult) {
|
|
const levelUp = result.leveled ? ` / Lv ${result.level} 달성` : '';
|
|
return `${equipmentSlotLabels[result.slot]} ${result.itemName} 경험치 +${result.amount} (${result.exp}/${result.next})${levelUp}`;
|
|
}
|
|
|
|
private findNearestEnemy(unit: UnitData) {
|
|
const enemies = firstBattleUnits.filter((candidate) => candidate.faction === 'enemy' && candidate.hp > 0);
|
|
return enemies.sort((a, b) => this.tileDistance(unit, a) - this.tileDistance(unit, b))[0];
|
|
}
|
|
|
|
private tileDistance(a: UnitData, b: UnitData) {
|
|
return Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
|
|
}
|
|
|
|
private findBond(unitId: string, partnerId: string) {
|
|
return Array.from(this.bondStates.values()).find((bond) => {
|
|
return bond.unitIds.includes(unitId) && bond.unitIds.includes(partnerId);
|
|
});
|
|
}
|
|
|
|
private strongestBondBonus(unitId: string) {
|
|
const related = Array.from(this.bondStates.values()).filter((bond) => bond.unitIds.includes(unitId));
|
|
const strongest = related.sort((a, b) => b.level - a.level)[0];
|
|
if (!strongest) {
|
|
return { damageBonus: 0, chainRate: 0 };
|
|
}
|
|
|
|
return {
|
|
damageBonus: Math.floor(strongest.level / 20) * 3,
|
|
chainRate: strongest.level >= 70 ? 18 : strongest.level >= 50 ? 8 : 0
|
|
};
|
|
}
|
|
|
|
private cancelAttackTargeting() {
|
|
if (this.phase !== 'targeting' || !this.selectedUnit) {
|
|
return false;
|
|
}
|
|
|
|
const unit = this.selectedUnit;
|
|
const view = this.unitViews.get(unit.id);
|
|
this.phase = 'command';
|
|
this.targetingAction = undefined;
|
|
this.clearMarkers();
|
|
soundDirector.playSelect();
|
|
this.showCommandMenu(unit, view?.sprite.x ?? this.tileCenterX(unit.x), view?.sprite.y ?? this.tileCenterY(unit.y));
|
|
this.renderUnitDetail(unit, '공격 선택을 취소했습니다. 명령을 다시 선택하세요.');
|
|
return true;
|
|
}
|
|
|
|
private cancelPendingMove() {
|
|
if (this.phase !== 'command' || !this.pendingMove) {
|
|
return false;
|
|
}
|
|
|
|
const { unit, fromX, fromY, toX, toY } = this.pendingMove;
|
|
const view = this.unitViews.get(unit.id);
|
|
unit.x = fromX;
|
|
unit.y = fromY;
|
|
this.phase = 'moving';
|
|
this.selectedUnit = unit;
|
|
this.pendingMove = undefined;
|
|
this.hideCommandMenu();
|
|
soundDirector.playSelect();
|
|
|
|
this.moveUnitView(unit, fromX, fromY, view, 180);
|
|
this.clearMarkers();
|
|
this.showMoveRange(unit);
|
|
this.renderUnitDetail(
|
|
unit,
|
|
`이동을 취소했습니다.\n${toX + 1}, ${toY + 1}에서 원래 위치 ${fromX + 1}, ${fromY + 1}로 되돌렸습니다.`
|
|
);
|
|
return true;
|
|
}
|
|
|
|
private moveUnitView(
|
|
unit: UnitData,
|
|
x: number,
|
|
y: number,
|
|
view = this.unitViews.get(unit.id),
|
|
duration = 260
|
|
) {
|
|
if (!view) {
|
|
return;
|
|
}
|
|
|
|
const targetX = this.tileCenterX(x);
|
|
const targetY = this.tileCenterY(y);
|
|
const direction = this.directionFromDelta(targetX - view.sprite.x, targetY - view.sprite.y);
|
|
this.tweens.killTweensOf([view.sprite, view.label]);
|
|
this.playMovementSound(unit, duration);
|
|
this.playUnitWalk(view, direction);
|
|
this.tweens.add({
|
|
targets: view.sprite,
|
|
x: targetX,
|
|
y: targetY,
|
|
duration,
|
|
ease: 'Sine.easeInOut',
|
|
onComplete: () => this.syncUnitMotion(unit, view, direction)
|
|
});
|
|
this.tweens.add({
|
|
targets: view.label,
|
|
x: targetX,
|
|
y: targetY + this.layout.tileSize * 0.52,
|
|
duration,
|
|
ease: 'Sine.easeInOut'
|
|
});
|
|
}
|
|
|
|
private playMovementSound(unit: UnitData, duration: number) {
|
|
const mountedClasses: UnitClassKey[] = ['cavalry'];
|
|
const isMounted = mountedClasses.includes(unit.classKey);
|
|
soundDirector.playEffect(isMounted ? 'horse-gallop' : 'footstep-walk', {
|
|
volume: isMounted ? 0.34 : 0.26,
|
|
rate: isMounted ? 1.08 : 1,
|
|
stopAfterMs: Math.max(280, duration + 160)
|
|
});
|
|
}
|
|
|
|
private directionFromDelta(deltaX: number, deltaY: number): UnitDirection {
|
|
if (Math.abs(deltaX) > Math.abs(deltaY)) {
|
|
return deltaX >= 0 ? 'east' : 'west';
|
|
}
|
|
return deltaY >= 0 ? 'south' : 'north';
|
|
}
|
|
|
|
private playUnitWalk(view: UnitView, direction: UnitDirection) {
|
|
view.direction = direction;
|
|
view.sprite.setFlipX(false);
|
|
view.sprite.play(`${view.textureBase}-walk-${direction}`, true);
|
|
}
|
|
|
|
private playUnitIdle(view: UnitView, direction: UnitDirection) {
|
|
view.direction = direction;
|
|
view.sprite.setFlipX(false);
|
|
view.sprite.play(`${view.textureBase}-idle-${direction}`, true);
|
|
}
|
|
|
|
private stopUnitWalk(view: UnitView, direction: UnitDirection) {
|
|
view.direction = direction;
|
|
view.sprite.stop();
|
|
this.setUnitDirectionFrame(view, direction);
|
|
}
|
|
|
|
private syncUnitMotion(unit: UnitData, view = this.unitViews.get(unit.id), direction = view?.direction) {
|
|
if (!view || !direction) {
|
|
return;
|
|
}
|
|
|
|
view.direction = direction;
|
|
if (this.actedUnitIds.has(unit.id) || unit.hp <= 0) {
|
|
this.stopUnitWalk(view, direction);
|
|
return;
|
|
}
|
|
|
|
this.playUnitIdle(view, direction);
|
|
}
|
|
|
|
private setUnitDirectionFrame(view: UnitView, direction: UnitDirection) {
|
|
view.sprite.setTexture(view.textureBase, this.unitFrameIndex(direction));
|
|
view.sprite.setFlipX(false);
|
|
}
|
|
|
|
private unitFrameIndex(direction: UnitDirection, frame = 0) {
|
|
return unitFrameRows[direction] * 4 + frame;
|
|
}
|
|
|
|
private applyActedStyle(unit: UnitData) {
|
|
const view = this.unitViews.get(unit.id);
|
|
if (!view) {
|
|
return;
|
|
}
|
|
|
|
view.sprite.postFX.clear();
|
|
view.sprite.clearTint();
|
|
view.sprite.setAlpha(1);
|
|
this.syncUnitMotion(unit, view);
|
|
view.label.setAlpha(1);
|
|
view.label.setColor(unit.faction === 'ally' ? '#e7edf7' : '#ffebe7');
|
|
view.label.setBackgroundColor('');
|
|
}
|
|
|
|
private applyDefeatedStyle(unit: UnitData) {
|
|
const view = this.unitViews.get(unit.id);
|
|
if (!view) {
|
|
return;
|
|
}
|
|
|
|
view.sprite.stop();
|
|
view.sprite.disableInteractive();
|
|
view.sprite.postFX.clear();
|
|
view.sprite.postFX.addColorMatrix().grayscale(1);
|
|
view.sprite.setAlpha(0.22);
|
|
view.sprite.setTint(0x4f4f4f);
|
|
view.label.setAlpha(0.3);
|
|
view.label.setColor('#8a8a8a');
|
|
}
|
|
|
|
private faceUnitToward(unit: UnitData, target: UnitData) {
|
|
const view = this.unitViews.get(unit.id);
|
|
if (!view) {
|
|
return;
|
|
}
|
|
|
|
const direction = this.directionFromDelta(target.x - unit.x, target.y - unit.y);
|
|
this.syncUnitMotion(unit, view, direction);
|
|
}
|
|
|
|
private flashDamage(unit: UnitData, damage: number) {
|
|
const view = this.unitViews.get(unit.id);
|
|
if (!view) {
|
|
return;
|
|
}
|
|
|
|
const popup = this.add.text(view.sprite.x, view.sprite.y - this.layout.tileSize * 0.5, `-${damage}`, {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '20px',
|
|
color: '#ffdf7b',
|
|
stroke: '#220909',
|
|
strokeThickness: 4,
|
|
fontStyle: '700'
|
|
});
|
|
popup.setOrigin(0.5);
|
|
popup.setDepth(30);
|
|
|
|
view.sprite.setTintFill(0xffe0a3);
|
|
this.time.delayedCall(120, () => {
|
|
if (unit.hp <= 0) {
|
|
this.applyDefeatedStyle(unit);
|
|
} else {
|
|
view.sprite.clearTint();
|
|
this.syncUnitMotion(unit, view);
|
|
}
|
|
});
|
|
|
|
this.tweens.add({
|
|
targets: popup,
|
|
y: popup.y - 28,
|
|
alpha: 0,
|
|
duration: 520,
|
|
ease: 'Sine.easeOut',
|
|
onComplete: () => popup.destroy()
|
|
});
|
|
}
|
|
|
|
private showTurnEndPrompt(message?: string) {
|
|
this.hideTurnEndPrompt();
|
|
|
|
const width = 360;
|
|
const height = 156;
|
|
const left = this.layout.mapX + this.layout.mapWidth / 2 - width / 2;
|
|
const top = this.layout.mapY + this.layout.mapHeight / 2 - height / 2;
|
|
const panel = this.add.rectangle(left, top, width, height, 0x101821, 0.96);
|
|
panel.setOrigin(0);
|
|
panel.setDepth(40);
|
|
panel.setStrokeStyle(2, palette.gold, 0.9);
|
|
this.turnPromptObjects.push(panel);
|
|
|
|
const title = this.add.text(left + width / 2, top + 26, '모든 아군의 행동이 종료되었습니다.', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '20px',
|
|
color: '#f2e3bf',
|
|
fontStyle: '700'
|
|
});
|
|
title.setOrigin(0.5);
|
|
title.setDepth(41);
|
|
this.turnPromptObjects.push(title);
|
|
|
|
const body = this.add.text(left + width / 2, top + 58, '턴을 종료하시겠습니까?', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '17px',
|
|
color: '#d4dce6'
|
|
});
|
|
body.setOrigin(0.5);
|
|
body.setDepth(41);
|
|
this.turnPromptObjects.push(body);
|
|
|
|
const buttons = [
|
|
{ label: '턴 종료', x: left + 106, action: () => this.endAllyTurn() },
|
|
{ label: '전장 확인', x: left + 254, action: () => this.hideTurnEndPrompt() }
|
|
];
|
|
|
|
buttons.forEach(({ label, x, action }) => {
|
|
const bg = this.add.rectangle(x, top + 112, 116, 36, 0x1a2630, 0.95);
|
|
bg.setDepth(41);
|
|
bg.setStrokeStyle(1, label === '턴 종료' ? palette.gold : palette.blue, 0.72);
|
|
bg.setInteractive({ useHandCursor: true });
|
|
bg.on('pointerover', () => bg.setFillStyle(0x283947, 0.98));
|
|
bg.on('pointerout', () => bg.setFillStyle(0x1a2630, 0.95));
|
|
bg.on('pointerdown', action);
|
|
this.turnPromptObjects.push(bg);
|
|
|
|
const text = this.add.text(x, top + 112, label, {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '16px',
|
|
color: '#f2e3bf',
|
|
fontStyle: '700'
|
|
});
|
|
text.setOrigin(0.5);
|
|
text.setDepth(42);
|
|
text.setInteractive({ useHandCursor: true });
|
|
text.on('pointerdown', action);
|
|
this.turnPromptObjects.push(text);
|
|
});
|
|
|
|
void message;
|
|
}
|
|
|
|
private hideTurnEndPrompt() {
|
|
this.turnPromptObjects.forEach((object) => object.destroy());
|
|
this.turnPromptObjects = [];
|
|
}
|
|
|
|
private pushBattleLog(message: string) {
|
|
const firstLine = message.split('\n')[0];
|
|
this.battleLog = [firstLine, ...this.battleLog].slice(0, 8);
|
|
}
|
|
|
|
private renderBondPanel(message?: string) {
|
|
this.clearSidePanelContent();
|
|
const { panelX, panelY, panelWidth } = this.layout;
|
|
const left = panelX + 24;
|
|
const width = panelWidth - 48;
|
|
const top = panelY + 132;
|
|
|
|
this.trackSideObject(this.add.text(left, top, '공명 관계', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '22px',
|
|
color: '#f2e3bf',
|
|
fontStyle: '700'
|
|
}));
|
|
this.trackSideObject(this.add.text(left, top + 34, '같은 적을 노리거나 전투 사이 이벤트로 상승합니다.', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '14px',
|
|
color: '#9fb0bf'
|
|
}));
|
|
|
|
Array.from(this.bondStates.values()).forEach((bond, index) => {
|
|
const y = top + 70 + index * 104;
|
|
const [first, second] = bond.unitIds.map((unitId) => this.unitName(unitId));
|
|
const bg = this.trackSideObject(this.add.rectangle(left, y, width, 92, 0x101820, 0.9));
|
|
bg.setOrigin(0);
|
|
bg.setStrokeStyle(1, bond.level >= 70 ? palette.gold : palette.blue, 0.66);
|
|
|
|
this.trackSideObject(this.add.text(left + 12, y + 8, `${first} · ${second}`, {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '17px',
|
|
color: '#e8dfca',
|
|
fontStyle: '700'
|
|
}));
|
|
const levelText = this.trackSideObject(this.add.text(left + width - 12, y + 8, `${bond.level}`, {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '18px',
|
|
color: '#f2e3bf',
|
|
fontStyle: '700'
|
|
}));
|
|
levelText.setOrigin(1, 0);
|
|
|
|
this.trackSideObject(this.add.text(left + 12, y + 33, bond.title, {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '14px',
|
|
color: '#d8b15f'
|
|
}));
|
|
this.drawGauge(left + 86, y + 39, width - 148, 8, bond.exp / 100, bond.level >= 70 ? 0xd8b15f : 0x58aee0);
|
|
|
|
const bonus = this.bondBonus(bond);
|
|
this.trackSideObject(this.add.text(left + 12, y + 59, `피해 +${bonus.damageBonus}% 연계 ${bonus.chainRate}% 전투 +${bond.battleExp}`, {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '14px',
|
|
color: '#d4dce6'
|
|
}));
|
|
});
|
|
|
|
this.renderPanelMessage(message ?? '공격 명령에서 같은 예상 목표를 노리면 공명 경험치가 쌓입니다.', left, top + 396, width);
|
|
}
|
|
|
|
private renderSituationPanel(message?: string) {
|
|
this.clearSidePanelContent();
|
|
const { panelX, panelY, panelWidth } = this.layout;
|
|
const left = panelX + 24;
|
|
const width = panelWidth - 48;
|
|
const top = panelY + 132;
|
|
const actedCount = firstBattleUnits.filter((unit) => unit.faction === 'ally' && this.actedUnitIds.has(unit.id)).length;
|
|
const allyCount = firstBattleUnits.filter((unit) => unit.faction === 'ally').length;
|
|
const enemyCount = firstBattleUnits.filter((unit) => unit.faction === 'enemy' && unit.hp > 0).length;
|
|
const bestBond = Array.from(this.bondStates.values()).sort((a, b) => b.level - a.level)[0];
|
|
|
|
this.trackSideObject(this.add.text(left, top, '전황', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '24px',
|
|
color: '#f2e3bf',
|
|
fontStyle: '700'
|
|
}));
|
|
|
|
this.renderSituationLine('현재 턴', `${this.turnNumber}턴 / ${factionLabels[this.activeFaction]}`, left, top + 48, width);
|
|
this.renderSituationLine('아군 행동', `${actedCount} / ${allyCount}`, left, top + 92, width);
|
|
this.renderSituationLine('남은 적', `${enemyCount}`, left, top + 136, width);
|
|
this.renderSituationLine('BGM', soundDirector.isMuted() ? '꺼짐' : '켜짐', left, top + 180, width);
|
|
this.renderSituationLine(
|
|
'최고 공명',
|
|
bestBond ? `${this.unitName(bestBond.unitIds[0])}·${this.unitName(bestBond.unitIds[1])} ${bestBond.level}` : '-',
|
|
left,
|
|
top + 224,
|
|
width
|
|
);
|
|
|
|
this.renderPanelMessage(message ?? '빈 맵에서 우클릭하면 전술 메뉴를 다시 열 수 있습니다.', left, top + 286, width);
|
|
}
|
|
|
|
private renderSituationLine(label: string, value: string, x: number, y: number, width: number) {
|
|
const bg = this.trackSideObject(this.add.rectangle(x, y, width, 34, 0x101820, 0.86));
|
|
bg.setOrigin(0);
|
|
bg.setStrokeStyle(1, 0x53606c, 0.46);
|
|
this.trackSideObject(this.add.text(x + 12, y + 8, label, {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '15px',
|
|
color: '#9fb0bf'
|
|
}));
|
|
const valueText = this.trackSideObject(this.add.text(x + width - 12, y + 7, value, {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '16px',
|
|
color: '#f2e3bf',
|
|
fontStyle: '700'
|
|
}));
|
|
valueText.setOrigin(1, 0);
|
|
}
|
|
|
|
private bondBonus(bond: BondState) {
|
|
return {
|
|
damageBonus: Math.floor(bond.level / 20) * 3,
|
|
chainRate: bond.level >= 70 ? 18 : bond.level >= 50 ? 8 : 0
|
|
};
|
|
}
|
|
|
|
private unitName(unitId: string) {
|
|
return firstBattleUnits.find((unit) => unit.id === unitId)?.name ?? unitId;
|
|
}
|
|
|
|
private renderRosterPanel(tab: RosterTab = this.rosterTab, message?: string) {
|
|
this.rosterTab = tab;
|
|
this.clearSidePanelContent();
|
|
|
|
const { panelX, panelY, panelWidth, panelHeight } = this.layout;
|
|
const left = panelX + 24;
|
|
const width = panelWidth - 48;
|
|
const top = panelY + 132;
|
|
const tabGap = 8;
|
|
const tabWidth = (width - tabGap) / 2;
|
|
|
|
(['ally', 'enemy'] as RosterTab[]).forEach((targetTab, index) => {
|
|
const active = targetTab === tab;
|
|
const x = left + index * (tabWidth + tabGap);
|
|
const tabBg = this.trackSideObject(this.add.rectangle(x, top, tabWidth, 38, active ? 0x25384a : 0x141e29, active ? 0.98 : 0.86));
|
|
tabBg.setOrigin(0);
|
|
tabBg.setStrokeStyle(1, active ? palette.gold : 0x53606c, active ? 0.92 : 0.62);
|
|
tabBg.setInteractive({ useHandCursor: true });
|
|
tabBg.on('pointerdown', () => this.renderRosterPanel(targetTab));
|
|
|
|
const tabText = this.trackSideObject(this.add.text(x + tabWidth / 2, top + 19, rosterLabels[targetTab], {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '18px',
|
|
color: active ? '#f4dfad' : '#aeb7c2',
|
|
fontStyle: '700'
|
|
}));
|
|
tabText.setOrigin(0.5);
|
|
});
|
|
|
|
const units = firstBattleUnits.filter((unit) => unit.faction === tab);
|
|
const listTop = top + 54;
|
|
units.forEach((unit, index) => {
|
|
this.renderRosterRow(unit, left, listTop + index * 50, width);
|
|
});
|
|
|
|
if (message) {
|
|
const messageHeight = 104;
|
|
const messageTop = Math.min(panelY + panelHeight - messageHeight - 124, listTop + units.length * 50 + 14);
|
|
this.renderPanelMessage(message, left, messageTop, width, messageHeight);
|
|
}
|
|
}
|
|
|
|
private renderRosterRow(unit: UnitData, x: number, y: number, width: number) {
|
|
const acted = this.actedUnitIds.has(unit.id);
|
|
const active = this.selectedUnit?.id === unit.id;
|
|
const unitClass = getUnitClass(unit.classKey);
|
|
const rowBg = this.trackSideObject(this.add.rectangle(x, y, width, 42, active ? 0x2f4050 : 0x101820, acted ? 0.54 : 0.9));
|
|
rowBg.setOrigin(0);
|
|
rowBg.setStrokeStyle(1, active ? palette.gold : unit.faction === 'ally' ? palette.blue : 0xb86b55, active ? 0.88 : 0.52);
|
|
rowBg.setInteractive({ useHandCursor: true });
|
|
rowBg.on('pointerdown', () => this.selectUnit(unit));
|
|
|
|
const nameColor = acted ? '#a8a8a8' : unit.faction === 'ally' ? '#e9f0f8' : '#ffe0d8';
|
|
this.trackSideObject(this.add.text(x + 12, y + 7, unit.name, {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '17px',
|
|
color: nameColor,
|
|
fontStyle: '700'
|
|
}));
|
|
this.trackSideObject(this.add.text(x + 88, y + 9, unitClass.name,
|
|
{
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '14px',
|
|
color: acted ? '#8c8c8c' : '#9fb0bf'
|
|
}));
|
|
|
|
const hpText = this.trackSideObject(this.add.text(x + width - 12, y + 7, `${unit.hp}/${unit.maxHp}`, {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '16px',
|
|
color: acted ? '#9a9a9a' : '#f0e4c8',
|
|
fontStyle: '700'
|
|
}));
|
|
hpText.setOrigin(1, 0);
|
|
|
|
this.drawGauge(x + 88, y + 29, width - 100, 6, unit.hp / unit.maxHp, acted ? 0x9a9a9a : 0x59d18c);
|
|
|
|
if (acted) {
|
|
const actedText = this.trackSideObject(this.add.text(x + 12, y + 27, '행동완료', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '12px',
|
|
color: '#bdbdbd'
|
|
}));
|
|
actedText.setAlpha(0.78);
|
|
}
|
|
}
|
|
|
|
private renderUnitDetail(unit: UnitData, message?: string) {
|
|
this.clearSidePanelContent();
|
|
|
|
const { panelX, panelY, panelWidth } = this.layout;
|
|
const left = panelX + 24;
|
|
const width = panelWidth - 48;
|
|
const top = panelY + 124;
|
|
const acted = this.actedUnitIds.has(unit.id);
|
|
const factionColor = unit.faction === 'ally' ? palette.blue : 0xb86b55;
|
|
const unitClass = getUnitClass(unit.classKey);
|
|
const terrain = firstBattleMap.terrain[unit.y][unit.x];
|
|
const terrainRule = getTerrainRule(terrain);
|
|
const terrainRating = unitClass.terrainRatings[terrain];
|
|
|
|
const header = this.trackSideObject(this.add.rectangle(left, top, width, 60, 0x101820, 0.94));
|
|
header.setOrigin(0);
|
|
header.setStrokeStyle(1, factionColor, 0.76);
|
|
|
|
this.trackSideObject(this.add.text(left + 14, top + 9, `${rosterLabels[unit.faction]} / ${unitClass.family}`, {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '14px',
|
|
color: unit.faction === 'ally' ? '#9fd0ff' : '#ffb2a4',
|
|
fontStyle: '700'
|
|
}));
|
|
this.trackSideObject(this.add.text(left + 14, top + 29, `${unit.name} ${unitClass.name}`, {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '22px',
|
|
color: acted ? '#bdbdbd' : '#f2e3bf',
|
|
fontStyle: '700'
|
|
}));
|
|
|
|
if (this.phase !== 'command') {
|
|
const backBg = this.trackSideObject(this.add.rectangle(left + width - 66, top + 10, 52, 28, 0x1b2834, 0.9));
|
|
backBg.setOrigin(0);
|
|
backBg.setStrokeStyle(1, palette.gold, 0.72);
|
|
backBg.setInteractive({ useHandCursor: true });
|
|
backBg.on('pointerdown', () => this.returnToRosterPanel(unit.faction));
|
|
|
|
const backText = this.trackSideObject(this.add.text(left + width - 40, top + 24, '목록', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '14px',
|
|
color: '#f4dfad',
|
|
fontStyle: '700'
|
|
}));
|
|
backText.setOrigin(0.5);
|
|
}
|
|
|
|
this.trackSideObject(this.add.text(left, top + 78, '병력', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '17px',
|
|
color: '#d4dce6',
|
|
fontStyle: '700'
|
|
}));
|
|
const hpValue = this.trackSideObject(this.add.text(left + width, top + 76, `${unit.hp} / ${unit.maxHp}`, {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '18px',
|
|
color: '#f0e4c8',
|
|
fontStyle: '700'
|
|
}));
|
|
hpValue.setOrigin(1, 0);
|
|
this.drawGauge(left + 76, top + 84, width - 160, 10, unit.hp / unit.maxHp, 0x59d18c);
|
|
|
|
const attackBonus = this.equipmentAttackBonus(unit);
|
|
this.renderSmallValueBox(left, top + 112, '공격', `${unit.attack + attackBonus}`);
|
|
this.renderSmallValueBox(left + width / 2 + 6, top + 112, '이동', `${unit.move}`);
|
|
|
|
const statTop = top + 158;
|
|
statLabels.forEach((stat, index) => {
|
|
this.renderStatRow(stat.label, unit.stats[stat.key], left, statTop + index * 28, width);
|
|
});
|
|
|
|
const positionText = `위치 ${unit.x + 1}, ${unit.y + 1} / ${terrainRule.label} ${terrainRating}%${acted ? ' / 행동완료' : ''}`;
|
|
this.trackSideObject(this.add.text(left, top + 306, positionText, {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '15px',
|
|
color: acted ? '#bdbdbd' : '#9fb0bf'
|
|
}));
|
|
|
|
this.renderEquipmentSummary(unit, left, top + 330, width);
|
|
|
|
if (message) {
|
|
this.renderPanelMessage(message, left, top + 440, width, 50);
|
|
}
|
|
}
|
|
|
|
private renderEquipmentSummary(unit: UnitData, x: number, y: number, width: number) {
|
|
this.trackSideObject(this.add.text(x, y, '장비', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '16px',
|
|
color: '#f2e3bf',
|
|
fontStyle: '700'
|
|
}));
|
|
|
|
equipmentSlots.forEach((slot, index) => {
|
|
this.renderEquipmentRow(unit, slot, x, y + 24 + index * 28, width);
|
|
});
|
|
}
|
|
|
|
private renderEquipmentRow(unit: UnitData, slot: EquipmentSlot, x: number, y: number, width: number) {
|
|
const state = unit.equipment[slot];
|
|
const item = getItem(state.itemId);
|
|
const next = equipmentExpToNext(state.level);
|
|
const isTreasure = item.rank === 'treasure';
|
|
const bg = this.trackSideObject(this.add.rectangle(x, y, width, 26, isTreasure ? 0x1f2430 : 0x101820, isTreasure ? 0.94 : 0.86));
|
|
bg.setOrigin(0);
|
|
bg.setStrokeStyle(1, isTreasure ? palette.gold : 0x53606c, isTreasure ? 0.58 : 0.42);
|
|
|
|
const iconFrame = this.trackSideObject(this.add.rectangle(x + 14, y + 13, 22, 22, 0x0a0f14, 0.82));
|
|
iconFrame.setStrokeStyle(1, isTreasure ? palette.gold : 0x53606c, isTreasure ? 0.72 : 0.48);
|
|
|
|
const icon = this.trackSideObject(this.add.image(x + 14, y + 13, this.itemIconKey(item.id)));
|
|
icon.setDisplaySize(22, 22);
|
|
|
|
this.trackSideObject(this.add.text(x + 31, y + 6, equipmentSlotLabels[slot], {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '12px',
|
|
color: '#9fb0bf',
|
|
fontStyle: '700'
|
|
}));
|
|
this.trackSideObject(this.add.text(x + 78, y + 4, item.name, {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '13px',
|
|
color: isTreasure ? '#f4dfad' : '#d4dce6',
|
|
fontStyle: isTreasure ? '700' : '400'
|
|
}));
|
|
|
|
const bonusText = this.itemBonusText(item);
|
|
this.trackSideObject(this.add.text(x + width - 124, y + 6, bonusText, {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '11px',
|
|
color: '#9fb0bf'
|
|
}));
|
|
|
|
const expText = this.trackSideObject(this.add.text(x + width - 54, y + 6, `${state.exp}/${next}`, {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '11px',
|
|
color: '#9fb0bf'
|
|
}));
|
|
expText.setOrigin(1, 0);
|
|
|
|
const levelText = this.trackSideObject(this.add.text(x + width - 8, y + 3, `Lv ${state.level}`, {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '14px',
|
|
color: isTreasure ? '#f2e3bf' : '#d4dce6',
|
|
fontStyle: '700'
|
|
}));
|
|
levelText.setOrigin(1, 0);
|
|
|
|
this.drawGauge(x + 78, y + 20, width - 178, 4, state.exp / next, isTreasure ? 0xd8b15f : 0x58aee0);
|
|
}
|
|
|
|
private itemIconKey(itemId: string) {
|
|
const key = `item-${itemId}`;
|
|
return this.textures.exists(key) ? key : 'item-training-sword';
|
|
}
|
|
|
|
private itemBonusText(item: ReturnType<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,
|
|
turnNumber: this.turnNumber,
|
|
activeFaction: this.activeFaction,
|
|
phase: this.phase,
|
|
selectedUnitId: this.selectedUnit?.id ?? null,
|
|
pendingMove: this.pendingMove
|
|
? {
|
|
unitId: this.pendingMove.unit.id,
|
|
from: { x: this.pendingMove.fromX, y: this.pendingMove.fromY },
|
|
to: { x: this.pendingMove.toX, y: this.pendingMove.toY }
|
|
}
|
|
: null,
|
|
actedUnitIds: Array.from(this.actedUnitIds),
|
|
attackIntents: this.attackIntents.map((intent) => ({ ...intent })),
|
|
battleLog: [...this.battleLog],
|
|
units: firstBattleUnits.map((unit) => ({
|
|
id: unit.id,
|
|
name: unit.name,
|
|
faction: unit.faction,
|
|
classKey: unit.classKey,
|
|
ai: unit.faction === 'enemy' ? this.enemyBehavior(unit) : null,
|
|
attackRange: this.attackRange(unit),
|
|
level: unit.level,
|
|
exp: unit.exp,
|
|
hp: unit.hp,
|
|
maxHp: unit.maxHp,
|
|
x: unit.x,
|
|
y: unit.y,
|
|
acted: this.actedUnitIds.has(unit.id),
|
|
animating: this.unitViews.get(unit.id)?.sprite.anims.isPlaying ?? false,
|
|
animationKey: this.unitViews.get(unit.id)?.sprite.anims.currentAnim?.key ?? null
|
|
})),
|
|
bonds: Array.from(this.bondStates.values()).map((bond) => ({
|
|
id: bond.id,
|
|
unitIds: bond.unitIds,
|
|
level: bond.level,
|
|
exp: bond.exp,
|
|
battleExp: bond.battleExp
|
|
}))
|
|
};
|
|
}
|
|
|
|
toggleDebugOverlay() {
|
|
if (this.debugOverlay) {
|
|
this.debugOverlay.destroy();
|
|
this.debugOverlay = undefined;
|
|
return;
|
|
}
|
|
|
|
this.debugOverlay = this.add.text(12, 12, '', {
|
|
fontFamily: 'Consolas, monospace',
|
|
fontSize: '13px',
|
|
color: '#dff7ff',
|
|
backgroundColor: 'rgba(0, 0, 0, 0.72)',
|
|
padding: { x: 8, y: 6 }
|
|
});
|
|
this.debugOverlay.setDepth(1000);
|
|
this.updateDebugOverlay();
|
|
}
|
|
|
|
private installDebugHotkeys() {
|
|
if (!import.meta.env.DEV) {
|
|
return;
|
|
}
|
|
|
|
this.input.keyboard?.on('keydown-F9', () => this.toggleDebugOverlay());
|
|
this.input.keyboard?.on('keydown-F10', () => {
|
|
console.info('[Battle Debug]', this.getDebugState());
|
|
this.updateDebugOverlay();
|
|
});
|
|
this.input.on('pointermove', () => this.updateDebugOverlay());
|
|
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
|
|
this.debugOverlay?.destroy();
|
|
this.debugOverlay = undefined;
|
|
});
|
|
}
|
|
|
|
private updateDebugOverlay() {
|
|
if (!this.debugOverlay) {
|
|
return;
|
|
}
|
|
|
|
const pointerTile = this.pointerToTile(this.input.activePointer);
|
|
const selected = this.selectedUnit ? `${this.selectedUnit.name} (${this.selectedUnit.x},${this.selectedUnit.y})` : 'none';
|
|
const pending = this.pendingMove
|
|
? `${this.pendingMove.unit.name}: (${this.pendingMove.fromX},${this.pendingMove.fromY}) -> (${this.pendingMove.toX},${this.pendingMove.toY})`
|
|
: 'none';
|
|
|
|
this.debugOverlay.setText([
|
|
'Heros Web Debug',
|
|
`scene=${this.scene.key}`,
|
|
`turn=${this.turnNumber} faction=${this.activeFaction} phase=${this.phase}`,
|
|
`selected=${selected}`,
|
|
`pendingMove=${pending}`,
|
|
`acted=${Array.from(this.actedUnitIds).join(',') || 'none'}`,
|
|
`pointerTile=${pointerTile ? `${pointerTile.x},${pointerTile.y}` : 'outside'}`,
|
|
'F9 toggle overlay / F10 log state'
|
|
]);
|
|
}
|
|
}
|