Implement tactical combat loop
This commit is contained in:
@@ -8,7 +8,7 @@ import {
|
|||||||
type UnitData,
|
type UnitData,
|
||||||
type UnitStats
|
type UnitStats
|
||||||
} from '../data/scenario';
|
} from '../data/scenario';
|
||||||
import { getTerrainRule, getUnitClass, type TerrainType } from '../data/battleRules';
|
import { getTerrainRule, getUnitClass, type TerrainType, type UnitClassKey } from '../data/battleRules';
|
||||||
import {
|
import {
|
||||||
equipmentExpToNext,
|
equipmentExpToNext,
|
||||||
equipmentSlotLabels,
|
equipmentSlotLabels,
|
||||||
@@ -59,11 +59,12 @@ type UnitView = {
|
|||||||
|
|
||||||
type UnitDirection = 'south' | 'east' | 'north' | 'west';
|
type UnitDirection = 'south' | 'east' | 'north' | 'west';
|
||||||
|
|
||||||
type BattlePhase = 'idle' | 'moving' | 'command';
|
type BattlePhase = 'idle' | 'moving' | 'command' | 'targeting';
|
||||||
type BattleCommand = 'attack' | 'strategy' | 'item' | 'wait';
|
type BattleCommand = 'attack' | 'strategy' | 'item' | 'wait';
|
||||||
type RosterTab = 'ally' | 'enemy';
|
type RosterTab = 'ally' | 'enemy';
|
||||||
type ActiveFaction = 'ally' | 'enemy';
|
type ActiveFaction = 'ally' | 'enemy';
|
||||||
type MapMenuAction = 'endTurn' | 'roster' | 'bond' | 'situation' | 'bgm' | 'close';
|
type MapMenuAction = 'endTurn' | 'roster' | 'bond' | 'situation' | 'bgm' | 'close';
|
||||||
|
type EnemyAiBehavior = 'aggressive' | 'guard' | 'hold';
|
||||||
|
|
||||||
type CommandButton = {
|
type CommandButton = {
|
||||||
command: BattleCommand;
|
command: BattleCommand;
|
||||||
@@ -98,8 +99,43 @@ type EquipmentGrowthResult = {
|
|||||||
leveled: boolean;
|
leveled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CombatPreview = {
|
||||||
|
attacker: UnitData;
|
||||||
|
defender: UnitData;
|
||||||
|
damage: number;
|
||||||
|
hitRate: number;
|
||||||
|
terrainLabel: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CombatResult = CombatPreview & {
|
||||||
|
defeated: boolean;
|
||||||
|
weaponGrowth: EquipmentGrowthResult;
|
||||||
|
armorGrowth: EquipmentGrowthResult;
|
||||||
|
bondExp: number;
|
||||||
|
};
|
||||||
|
|
||||||
const maxEquipmentLevel = 9;
|
const maxEquipmentLevel = 9;
|
||||||
|
|
||||||
|
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> = {
|
const commandLabels: Record<BattleCommand, string> = {
|
||||||
attack: '공격',
|
attack: '공격',
|
||||||
strategy: '책략',
|
strategy: '책략',
|
||||||
@@ -147,10 +183,13 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
private commandButtons: CommandButton[] = [];
|
private commandButtons: CommandButton[] = [];
|
||||||
private commandMenuObjects: Array<Phaser.GameObjects.Rectangle | Phaser.GameObjects.Text> = [];
|
private commandMenuObjects: Array<Phaser.GameObjects.Rectangle | Phaser.GameObjects.Text> = [];
|
||||||
private mapMenuObjects: Array<Phaser.GameObjects.Rectangle | Phaser.GameObjects.Text> = [];
|
private mapMenuObjects: Array<Phaser.GameObjects.Rectangle | Phaser.GameObjects.Text> = [];
|
||||||
|
private turnPromptObjects: Phaser.GameObjects.GameObject[] = [];
|
||||||
private unitViews = new Map<string, UnitView>();
|
private unitViews = new Map<string, UnitView>();
|
||||||
private actedUnitIds = new Set<string>();
|
private actedUnitIds = new Set<string>();
|
||||||
private bondStates = new Map<string, BondState>();
|
private bondStates = new Map<string, BondState>();
|
||||||
private attackIntents: AttackIntent[] = [];
|
private attackIntents: AttackIntent[] = [];
|
||||||
|
private battleLog: string[] = [];
|
||||||
|
private enemyHomeTiles = new Map<string, { x: number; y: number }>();
|
||||||
private pendingMove?: PendingMove;
|
private pendingMove?: PendingMove;
|
||||||
private debugOverlay?: Phaser.GameObjects.Text;
|
private debugOverlay?: Phaser.GameObjects.Text;
|
||||||
|
|
||||||
@@ -162,6 +201,8 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
const { width, height } = this.scale;
|
const { width, height } = this.scale;
|
||||||
this.layout = this.createLayout(width, height);
|
this.layout = this.createLayout(width, height);
|
||||||
this.bondStates = this.createBondStates();
|
this.bondStates = this.createBondStates();
|
||||||
|
this.enemyHomeTiles = this.createEnemyHomeTiles();
|
||||||
|
this.battleLog = [];
|
||||||
soundDirector.playMusic('battle-prep');
|
soundDirector.playMusic('battle-prep');
|
||||||
this.input.mouse?.disableContextMenu();
|
this.input.mouse?.disableContextMenu();
|
||||||
this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
|
this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
|
||||||
@@ -317,12 +358,28 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
|
|
||||||
private selectUnit(unit: UnitData) {
|
private selectUnit(unit: UnitData) {
|
||||||
this.hideMapMenu();
|
this.hideMapMenu();
|
||||||
|
this.hideTurnEndPrompt();
|
||||||
|
|
||||||
if (this.phase === 'command') {
|
if (this.phase === 'targeting' && this.selectedUnit) {
|
||||||
|
if (unit.faction !== this.selectedUnit.faction && unit.hp > 0) {
|
||||||
|
this.tryResolveAttackTarget(this.selectedUnit, unit);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderUnitDetail(this.selectedUnit, '공격할 적 부대를 선택하세요. 우클릭하면 명령 선택으로 돌아갑니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.phase === 'command' || this.phase === 'targeting') {
|
||||||
this.setInfo('먼저 현재 장수의 행동을 결정하세요.\n공격, 책략, 도구, 대기 중 하나를 선택해야 합니다.');
|
this.setInfo('먼저 현재 장수의 행동을 결정하세요.\n공격, 책략, 도구, 대기 중 하나를 선택해야 합니다.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (unit.hp <= 0) {
|
||||||
|
this.renderRosterPanel(unit.faction, '이미 퇴각한 부대입니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (unit.faction !== 'ally') {
|
if (unit.faction !== 'ally') {
|
||||||
this.selectedUnit = undefined;
|
this.selectedUnit = undefined;
|
||||||
this.pendingMove = undefined;
|
this.pendingMove = undefined;
|
||||||
@@ -469,7 +526,7 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private isOccupied(x: number, y: number, exceptUnitId?: string) {
|
private isOccupied(x: number, y: number, exceptUnitId?: string) {
|
||||||
return firstBattleUnits.some((unit) => unit.id !== exceptUnitId && unit.x === x && unit.y === y);
|
return firstBattleUnits.some((unit) => unit.id !== exceptUnitId && unit.hp > 0 && unit.x === x && unit.y === y);
|
||||||
}
|
}
|
||||||
|
|
||||||
private tileCenterX(x: number) {
|
private tileCenterX(x: number) {
|
||||||
@@ -537,6 +594,11 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (choicePointer.leftButtonDown()) {
|
if (choicePointer.leftButtonDown()) {
|
||||||
|
if (command === 'attack') {
|
||||||
|
this.beginAttackTargeting(unit);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.completeUnitAction(unit, command);
|
this.completeUnitAction(unit, command);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -577,6 +639,9 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.finishUnitAction(unit, this.commandResultMessage(unit, command));
|
||||||
|
return;
|
||||||
|
|
||||||
this.actedUnitIds.add(unit.id);
|
this.actedUnitIds.add(unit.id);
|
||||||
this.phase = 'idle';
|
this.phase = 'idle';
|
||||||
this.selectedUnit = undefined;
|
this.selectedUnit = undefined;
|
||||||
@@ -591,6 +656,164 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
this.renderRosterPanel('ally', `${actionMessage}\n${turnHint}`);
|
this.renderRosterPanel('ally', `${actionMessage}\n${turnHint}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private beginAttackTargeting(unit: UnitData) {
|
||||||
|
if (this.phase !== 'command' || this.selectedUnit?.id !== unit.id || this.actedUnitIds.has(unit.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets = this.attackableTargets(unit);
|
||||||
|
if (targets.length === 0) {
|
||||||
|
this.renderUnitDetail(unit, '현재 위치에서 공격 가능한 적이 없습니다. 다른 명령을 선택하거나 우클릭으로 이동을 취소하세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.phase = 'targeting';
|
||||||
|
this.hideCommandMenu();
|
||||||
|
this.clearMarkers();
|
||||||
|
this.renderUnitDetail(unit, '붉은 칸의 적을 선택하면 공격합니다. 우클릭하면 명령 선택으로 돌아갑니다.');
|
||||||
|
this.showAttackTargets(unit, targets);
|
||||||
|
soundDirector.playSelect();
|
||||||
|
}
|
||||||
|
|
||||||
|
private showAttackTargets(attacker: UnitData, targets = this.attackableTargets(attacker)) {
|
||||||
|
targets.forEach((target) => {
|
||||||
|
const preview = this.combatPreview(attacker, target);
|
||||||
|
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()) {
|
||||||
|
this.tryResolveAttackTarget(attacker, target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.markers.push(marker);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private tryResolveAttackTarget(attacker: UnitData, target: UnitData) {
|
||||||
|
if (this.phase !== 'targeting' || this.selectedUnit?.id !== attacker.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.canAttack(attacker, target)) {
|
||||||
|
this.renderUnitDetail(attacker, '사거리 밖의 적입니다. 붉은 칸으로 표시된 적을 선택하세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = this.resolveAttack(attacker, target);
|
||||||
|
this.clearMarkers();
|
||||||
|
this.finishUnitAction(attacker, this.formatCombatResult(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderAttackPreview(preview: CombatPreview) {
|
||||||
|
this.renderUnitDetail(
|
||||||
|
preview.defender,
|
||||||
|
`${preview.attacker.name} 공격 예측\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.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 firstBattleUnits.filter((target) => this.canAttack(attacker, target));
|
||||||
|
}
|
||||||
|
|
||||||
|
private canAttack(attacker: UnitData, target: UnitData) {
|
||||||
|
if (attacker.faction === target.faction || target.hp <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.tileDistance(attacker, target) <= this.attackRange(attacker);
|
||||||
|
}
|
||||||
|
|
||||||
|
private attackRange(unit: UnitData) {
|
||||||
|
return attackRangeByClass[unit.classKey] ?? 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private combatPreview(attacker: UnitData, defender: UnitData): CombatPreview {
|
||||||
|
const terrain = firstBattleMap.terrain[defender.y][defender.x];
|
||||||
|
const terrainRule = getTerrainRule(terrain);
|
||||||
|
const attackPower = attacker.attack + this.equipmentAttackBonus(attacker) + Math.floor(attacker.stats.might / 12);
|
||||||
|
const defensePower = this.equipmentDefenseBonus(defender) + Math.floor(defender.stats.leadership / 18) + Math.floor(terrainRule.defenseBonus / 3);
|
||||||
|
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 {
|
||||||
|
attacker,
|
||||||
|
defender,
|
||||||
|
damage,
|
||||||
|
hitRate,
|
||||||
|
terrainLabel: terrainRule.label
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveAttack(attacker: UnitData, defender: UnitData): CombatResult {
|
||||||
|
const preview = this.combatPreview(attacker, defender);
|
||||||
|
const weaponGrowth = this.awardEquipmentExp(attacker, 'weapon', this.weaponExpGain(attacker));
|
||||||
|
const armorGrowth = this.awardEquipmentExp(defender, 'armor', 6);
|
||||||
|
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,
|
||||||
|
defeated: defender.hp <= 0,
|
||||||
|
weaponGrowth,
|
||||||
|
armorGrowth,
|
||||||
|
bondExp
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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} 공격\n${result.defender.name}에게 ${result.damage} 피해${defeated}\n${this.formatEquipmentGrowth(result.weaponGrowth)}\n${this.formatEquipmentGrowth(result.armorGrowth)}${bond}`;
|
||||||
|
}
|
||||||
|
|
||||||
private commandResultMessage(unit: UnitData, command: BattleCommand) {
|
private commandResultMessage(unit: UnitData, command: BattleCommand) {
|
||||||
if (command === 'attack') {
|
if (command === 'attack') {
|
||||||
return this.recordAttackIntent(unit);
|
return this.recordAttackIntent(unit);
|
||||||
@@ -605,7 +828,7 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private remainingAllyCount() {
|
private remainingAllyCount() {
|
||||||
return firstBattleUnits.filter((unit) => unit.faction === 'ally' && !this.actedUnitIds.has(unit.id)).length;
|
return firstBattleUnits.filter((unit) => unit.faction === 'ally' && unit.hp > 0 && !this.actedUnitIds.has(unit.id)).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
private createBondStates() {
|
private createBondStates() {
|
||||||
@@ -620,7 +843,19 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
private handleRightClick(pointer: Phaser.Input.Pointer) {
|
||||||
|
if (this.cancelAttackTargeting()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.cancelPendingMove()) {
|
if (this.cancelPendingMove()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -655,11 +890,12 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private showMapMenu(pointerX: number, pointerY: number) {
|
private showMapMenu(pointerX: number, pointerY: number) {
|
||||||
if (this.phase === 'command') {
|
if (this.phase === 'command' || this.phase === 'targeting') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.hideMapMenu();
|
this.hideMapMenu();
|
||||||
|
this.hideTurnEndPrompt();
|
||||||
this.clearMarkers();
|
this.clearMarkers();
|
||||||
this.selectedUnit = undefined;
|
this.selectedUnit = undefined;
|
||||||
this.pendingMove = undefined;
|
this.pendingMove = undefined;
|
||||||
@@ -746,7 +982,7 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
this.renderSituationPanel('이미 적군 행동 중입니다.');
|
this.renderSituationPanel('이미 적군 행동 중입니다.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.phase === 'command') {
|
if (this.phase === 'command' || this.phase === 'targeting') {
|
||||||
this.setInfo('현재 장수의 행동을 먼저 결정하세요.');
|
this.setInfo('현재 장수의 행동을 먼저 결정하세요.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -756,6 +992,13 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
this.phase = 'idle';
|
this.phase = 'idle';
|
||||||
this.clearMarkers();
|
this.clearMarkers();
|
||||||
this.hideCommandMenu();
|
this.hideCommandMenu();
|
||||||
|
this.hideTurnEndPrompt();
|
||||||
|
|
||||||
|
this.activeFaction = 'enemy';
|
||||||
|
this.updateTurnText();
|
||||||
|
this.renderSituationPanel('아군 턴을 종료했습니다.\n적군이 행동을 시작합니다.');
|
||||||
|
void this.runEnemyTurn();
|
||||||
|
return;
|
||||||
|
|
||||||
this.activeFaction = 'enemy';
|
this.activeFaction = 'enemy';
|
||||||
this.updateTurnText();
|
this.updateTurnText();
|
||||||
@@ -765,6 +1008,159 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
this.time.delayedCall(700, () => this.beginNextAllyTurn());
|
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);
|
||||||
|
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 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.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() {
|
private beginNextAllyTurn() {
|
||||||
this.turnNumber += 1;
|
this.turnNumber += 1;
|
||||||
this.activeFaction = 'ally';
|
this.activeFaction = 'ally';
|
||||||
@@ -782,6 +1178,10 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
private resetActedStyles() {
|
private resetActedStyles() {
|
||||||
this.unitViews.forEach((view, unitId) => {
|
this.unitViews.forEach((view, unitId) => {
|
||||||
const unit = firstBattleUnits.find((candidate) => candidate.id === unitId);
|
const unit = firstBattleUnits.find((candidate) => candidate.id === unitId);
|
||||||
|
if (unit && unit.hp <= 0) {
|
||||||
|
this.applyDefeatedStyle(unit);
|
||||||
|
return;
|
||||||
|
}
|
||||||
view.sprite.postFX.clear();
|
view.sprite.postFX.clear();
|
||||||
view.sprite.clearTint();
|
view.sprite.clearTint();
|
||||||
view.sprite.setAlpha(1);
|
view.sprite.setAlpha(1);
|
||||||
@@ -796,6 +1196,32 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
private recordAttackIntent(unit: UnitData) {
|
||||||
const target = this.findNearestEnemy(unit);
|
const target = this.findNearestEnemy(unit);
|
||||||
const weaponGrowth = this.awardEquipmentExp(unit, 'weapon', this.weaponExpGain(unit));
|
const weaponGrowth = this.awardEquipmentExp(unit, 'weapon', this.weaponExpGain(unit));
|
||||||
@@ -922,6 +1348,21 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.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() {
|
private cancelPendingMove() {
|
||||||
if (this.phase !== 'command' || !this.pendingMove) {
|
if (this.phase !== 'command' || !this.pendingMove) {
|
||||||
return false;
|
return false;
|
||||||
@@ -1038,6 +1479,142 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
view.label.setBackgroundColor('');
|
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) {
|
private renderBondPanel(message?: string) {
|
||||||
this.clearSidePanelContent();
|
this.clearSidePanelContent();
|
||||||
const { panelX, panelY, panelWidth } = this.layout;
|
const { panelX, panelY, panelWidth } = this.layout;
|
||||||
@@ -1406,6 +1983,14 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
}, 0);
|
}, 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) {
|
private renderSmallValueBox(x: number, y: number, label: string, value: string) {
|
||||||
const boxWidth = (this.layout.panelWidth - 60) / 2;
|
const boxWidth = (this.layout.panelWidth - 60) / 2;
|
||||||
const bg = this.trackSideObject(this.add.rectangle(x, y, boxWidth, 38, 0x16212d, 0.92));
|
const bg = this.trackSideObject(this.add.rectangle(x, y, boxWidth, 38, 0x16212d, 0.92));
|
||||||
@@ -1511,11 +2096,14 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
: null,
|
: null,
|
||||||
actedUnitIds: Array.from(this.actedUnitIds),
|
actedUnitIds: Array.from(this.actedUnitIds),
|
||||||
attackIntents: this.attackIntents.map((intent) => ({ ...intent })),
|
attackIntents: this.attackIntents.map((intent) => ({ ...intent })),
|
||||||
|
battleLog: [...this.battleLog],
|
||||||
units: firstBattleUnits.map((unit) => ({
|
units: firstBattleUnits.map((unit) => ({
|
||||||
id: unit.id,
|
id: unit.id,
|
||||||
name: unit.name,
|
name: unit.name,
|
||||||
faction: unit.faction,
|
faction: unit.faction,
|
||||||
classKey: unit.classKey,
|
classKey: unit.classKey,
|
||||||
|
ai: unit.faction === 'enemy' ? this.enemyBehavior(unit) : null,
|
||||||
|
attackRange: this.attackRange(unit),
|
||||||
hp: unit.hp,
|
hp: unit.hp,
|
||||||
maxHp: unit.maxHp,
|
maxHp: unit.maxHp,
|
||||||
x: unit.x,
|
x: unit.x,
|
||||||
|
|||||||
Reference in New Issue
Block a user