Balance battle tactics and status AI

This commit is contained in:
2026-07-03 22:08:14 +09:00
parent 69d9346c53
commit 17e1a14808

View File

@@ -1401,6 +1401,12 @@ type SupportResult = {
equipmentGrowth: EquipmentGrowthResult;
};
type EnemyUsablePlan = {
usable: BattleUsable;
target: UnitData;
preview: CombatPreview;
};
type BattleStatusKind = 'burn' | 'confusion';
type BattleStatusState = {
@@ -1488,6 +1494,7 @@ type BattleSaveState = {
battleBuffs?: BattleBuffState[];
battleStatuses?: BattleStatusState[];
battleStats?: Record<string, UnitBattleStats>;
enemyUsableUseKeys?: string[];
triggeredBattleEvents?: string[];
};
@@ -1510,8 +1517,8 @@ const usableCatalog: Record<string, BattleUsable> = {
target: 'ally',
effect: 'heal',
range: 2,
power: 12,
description: '가까운 아군의 병력을 조금 회복한다.'
power: 10,
description: '사거리 2칸의 작은 책략 회복. 위험한 아군을 멀리서 살린다.'
},
encourage: {
id: 'encourage',
@@ -1522,25 +1529,25 @@ const usableCatalog: Record<string, BattleUsable> = {
range: 2,
power: 0,
attackBonus: 2,
hitBonus: 8,
criticalBonus: 6,
hitBonus: 10,
criticalBonus: 4,
duration: 2,
description: '아군의 공격, 명중, 치명 보정을 잠시 올린다.'
description: '아군의 공격 명중을 안정적으로 끌어올린다.'
},
fireTactic: {
id: 'fireTactic',
command: 'strategy',
name: '화',
name: '화',
target: 'enemy',
effect: 'damage',
range: 2,
power: 12,
accuracyBonus: 6,
power: 10,
accuracyBonus: 4,
criticalBonus: 0,
statusEffect: 'burn',
statusDuration: 2,
statusPower: 4,
description: '적 한 부대에 책략 피해를 준다.'
statusPower: 3,
description: '적 한 부대에 책략 피해와 짧은 화상 지속 피해를 준다.'
},
roar: {
id: 'roar',
@@ -1549,13 +1556,13 @@ const usableCatalog: Record<string, BattleUsable> = {
target: 'enemy',
effect: 'damage',
range: 1,
power: 9,
accuracyBonus: 8,
criticalBonus: 4,
power: 7,
accuracyBonus: 10,
criticalBonus: 0,
statusEffect: 'confusion',
statusDuration: 1,
statusDuration: 2,
statusPower: 1,
description: '가까운 적을 위압해 피해를 준다.'
description: '인접한 적을 위압해 다음 행동의 공격, 명중, 치명을 낮춘다.'
},
bean: {
id: 'bean',
@@ -1564,8 +1571,8 @@ const usableCatalog: Record<string, BattleUsable> = {
target: 'ally',
effect: 'heal',
range: 1,
power: 10,
description: '아군 한 부대의 병력을 소량 회복한다.'
power: 12,
description: '인접 아군을 빠르게 소량 회복하는 기본 보급품.'
},
salve: {
id: 'salve',
@@ -1574,8 +1581,8 @@ const usableCatalog: Record<string, BattleUsable> = {
target: 'ally',
effect: 'heal',
range: 1,
power: 18,
description: '아군 한 부대의 병력을 크게 회복한다.'
power: 22,
description: '인접 아군의 큰 피해를 되돌리는 고가 회복.'
},
wine: {
id: 'wine',
@@ -1586,10 +1593,10 @@ const usableCatalog: Record<string, BattleUsable> = {
range: 0,
power: 0,
attackBonus: 3,
hitBonus: 6,
criticalBonus: 10,
hitBonus: 4,
criticalBonus: 12,
duration: 2,
description: '사용자 공격치명 보정을 잠시 올린다.'
description: '사용자 자신을 짧게 몰아붙이는 공격·치명 강화 도구.'
}
};
@@ -3038,6 +3045,7 @@ export class BattleScene extends Phaser.Scene {
private battleLog: string[] = [];
private enemyHomeTiles = new Map<string, { x: number; y: number }>();
private battleStats = new Map<string, UnitBattleStats>();
private enemyUsableUseKeys = new Set<string>();
private triggeredBattleEvents = new Set<string>();
private pendingMove?: PendingMove;
private battleOutcome?: BattleOutcome;
@@ -3095,6 +3103,7 @@ export class BattleScene extends Phaser.Scene {
this.applyDebugStatusUiSetup();
this.enemyHomeTiles = this.createEnemyHomeTiles();
this.battleStats = this.createBattleStats();
this.enemyUsableUseKeys.clear();
this.triggeredBattleEvents.clear();
this.battleLog = [];
this.actedUnitIds.clear();
@@ -3324,12 +3333,12 @@ export class BattleScene extends Phaser.Scene {
const guanYu = battleUnits.find((unit) => unit.id === 'guan-yu' && unit.hp > 0);
if (guanYu) {
this.applyBattleStatus(guanYu, 'burn', 2, 4);
this.applyBattleStatus(guanYu, 'burn', 2, 3);
}
const target = battleUnits.find((unit) => unit.id === 'rebel-a' && unit.hp > 0) ?? battleUnits.find((unit) => unit.faction === 'enemy' && unit.hp > 0);
if (target) {
this.applyBattleStatus(target, 'confusion', 1, 1);
this.applyBattleStatus(target, 'confusion', 2, 1);
}
}
@@ -8805,10 +8814,20 @@ export class BattleScene extends Phaser.Scene {
? 2
: 0;
const flatAmount = usable.power + bonus + equipmentBonus;
const percentFloor = Math.ceil(target.maxHp * (usable.command === 'item' ? 0.24 : 0.18));
const percentFloor = Math.ceil(target.maxHp * this.supportHealFloorRate(usable));
return Math.min(target.maxHp - target.hp, Math.max(1, flatAmount, percentFloor));
}
private supportHealFloorRate(usable: BattleUsable) {
if (usable.command === 'strategy') {
return 0.17;
}
if (usable.id === 'salve') {
return 0.34;
}
return 0.24;
}
private combatEquipmentEffect(
attacker: UnitData,
defender: UnitData,
@@ -9821,6 +9840,7 @@ export class BattleScene extends Phaser.Scene {
battleBuffs: Array.from(this.battleBuffs.values()).map((buff) => ({ ...buff })),
battleStatuses: this.serializeBattleStatuses(),
battleStats: this.serializeBattleStats(),
enemyUsableUseKeys: Array.from(this.enemyUsableUseKeys),
triggeredBattleEvents: Array.from(this.triggeredBattleEvents)
};
}
@@ -9859,6 +9879,7 @@ export class BattleScene extends Phaser.Scene {
this.battleBuffs = new Map((state.battleBuffs ?? []).map((buff) => [buff.unitId, { ...buff }]));
this.battleStatuses = this.deserializeBattleStatuses(state.battleStatuses);
this.battleStats = this.deserializeBattleStats(state.battleStats);
this.enemyUsableUseKeys = new Set(state.enemyUsableUseKeys ?? []);
this.triggeredBattleEvents = new Set(state.triggeredBattleEvents ?? []);
state.units.forEach((savedUnit) => {
@@ -10046,6 +10067,10 @@ export class BattleScene extends Phaser.Scene {
let target = this.chooseEnemyTarget(enemy, behavior);
if (!target) {
const usablePlan = this.chooseEnemyUsablePlan(enemy, behavior);
if (usablePlan) {
return this.executeEnemyUsablePlan(enemy, usablePlan, behavior);
}
this.renderSituationPanel(`적 행동: ${enemy.name}\n대기`);
return `${enemy.name}: 대기`;
}
@@ -10063,6 +10088,11 @@ export class BattleScene extends Phaser.Scene {
}
}
const usablePlan = this.chooseEnemyUsablePlan(enemy, behavior);
if (usablePlan) {
return this.executeEnemyUsablePlan(enemy, usablePlan, behavior);
}
target = this.chooseTargetInRange(enemy) ?? target;
if (this.canAttack(enemy, target)) {
this.renderSituationPanel(`적 행동: ${enemy.name}\n${target.name} 공격`);
@@ -10085,6 +10115,18 @@ export class BattleScene extends Phaser.Scene {
return `${enemy.name}: ${this.enemyBehaviorLabel(behavior)} 태세로 접근`;
}
private async executeEnemyUsablePlan(enemy: UnitData, plan: EnemyUsablePlan, behavior: EnemyAiBehavior) {
const { target, usable } = plan;
this.enemyUsableUseKeys.add(this.enemyUsableUseKey(enemy, usable));
this.renderSituationPanel(`적 행동: ${enemy.name}\n${target.name}에게 ${usable.name}`);
this.centerCameraOnTile(Math.floor((enemy.x + target.x) / 2), Math.floor((enemy.y + target.y) / 2));
const result = this.resolveCombatAction(enemy, target, usable.command, false, usable);
await this.playCombatCutIn(result);
this.showCombatMapResult(result);
await this.delay(160);
return this.formatEnemyCombatResult(result, behavior);
}
private chooseEnemyTarget(enemy: UnitData, behavior: EnemyAiBehavior) {
if (behavior === 'hold') {
return this.chooseTargetInRange(enemy);
@@ -10112,6 +10154,99 @@ export class BattleScene extends Phaser.Scene {
.sort((a, b) => a.hp - b.hp || this.tileDistance(enemy, a) - this.tileDistance(enemy, b))[0];
}
private chooseEnemyUsablePlan(enemy: UnitData, behavior: EnemyAiBehavior): EnemyUsablePlan | undefined {
const plans: EnemyUsablePlan[] = [];
this.enemyBattleUsables(enemy, behavior).forEach((usable) => {
if (this.enemyUsableUseKeys.has(this.enemyUsableUseKey(enemy, usable))) {
return;
}
battleUnits
.filter((target) => target.faction === 'ally' && target.hp > 0 && this.canUseDamageCommand(enemy, target, usable.command, usable))
.forEach((target) => {
const preview = this.combatPreview(enemy, target, usable.command, usable);
if (this.isEnemyUsablePlanReasonable(enemy, target, usable, preview)) {
plans.push({ usable, target, preview });
}
});
});
return plans.sort((a, b) => this.enemyUsablePlanScore(b) - this.enemyUsablePlanScore(a))[0];
}
private enemyBattleUsables(enemy: UnitData, behavior: EnemyAiBehavior) {
const ids: string[] = [];
if (this.enemyCanUseFireTactic(enemy, behavior)) {
ids.push('fireTactic');
}
if (this.enemyCanUseRoar(enemy, behavior)) {
ids.push('roar');
}
return ids.map((id) => usableCatalog[id]).filter((usable): usable is BattleUsable => Boolean(usable));
}
private enemyCanUseFireTactic(enemy: UnitData, behavior: EnemyAiBehavior) {
if (battleScenario.id === 'first-battle-zhuo-commandery' || this.turnNumber < 2 || behavior === 'aggressive') {
return false;
}
return (
enemy.classKey === 'strategist' ||
enemy.classKey === 'quartermaster' ||
enemy.id.includes('strategist') ||
enemy.id.includes('advisor') ||
enemy.id.includes('shaman') ||
enemy.id.includes('sima-yi') ||
enemy.id.includes('chen-gong')
);
}
private enemyCanUseRoar(enemy: UnitData, behavior: EnemyAiBehavior) {
if (battleScenario.id === 'first-battle-zhuo-commandery') {
return enemy.id === 'rebel-leader' && this.turnNumber >= 3;
}
if (this.turnNumber < 2 || behavior === 'hold') {
return false;
}
return enemy.classKey === 'rebelLeader' || enemy.id.includes('leader') || enemy.id.includes('officer');
}
private isEnemyUsablePlanReasonable(enemy: UnitData, target: UnitData, usable: BattleUsable, preview: CombatPreview) {
if (preview.hitRate < (usable.id === 'roar' ? 62 : 58)) {
return false;
}
if (preview.damage >= target.hp) {
return false;
}
if (usable.statusEffect && this.hasBattleStatus(target, usable.statusEffect)) {
return false;
}
const hpRate = target.hp / Math.max(1, target.maxHp);
if (usable.id === 'fireTactic') {
return hpRate >= 0.45;
}
if (usable.id === 'roar') {
return hpRate >= 0.35 && this.tileDistance(enemy, target) <= usable.range;
}
return true;
}
private enemyUsablePlanScore(plan: EnemyUsablePlan) {
const defeatConditionPenalty = battleScenario.defeatConditions.some(
(condition) => condition.kind === 'unit-defeated' && condition.unitId === plan.target.id
)
? 8
: 0;
const statusValue = plan.usable.statusEffect && !this.hasBattleStatus(plan.target, plan.usable.statusEffect) ? 12 : 0;
const targetThreat = Math.floor(this.actionPower(plan.target, 'attack') / 8);
return plan.preview.damage + statusValue + Math.floor(plan.preview.hitRate / 10) + targetThreat - defeatConditionPenalty;
}
private enemyUsableUseKey(enemy: UnitData, usable: BattleUsable) {
return `${battleScenario.id}:${enemy.id}:${usable.id}`;
}
private chooseApproachTile(enemy: UnitData, target: UnitData) {
const candidates = [{ x: enemy.x, y: enemy.y, cost: 0 }, ...this.reachableTiles(enemy)];
const pathCosts = this.cachedPathCostsToAttackRange(enemy, target);
@@ -15174,6 +15309,7 @@ export class BattleScene extends Phaser.Scene {
itemStocks: this.serializeItemStocks(),
battleBuffs: Array.from(this.battleBuffs.values()).map((buff) => ({ ...buff })),
battleStatuses: this.serializeBattleStatuses(),
enemyUsableUseKeys: Array.from(this.enemyUsableUseKeys),
treasureEffectPreviews: this.debugTreasureEffectPreviews(),
combatMechanicsProbe: this.debugCombatMechanicsProbe(),
units: battleUnits.map((unit) => ({