Balance battle tactics and status AI
This commit is contained in:
@@ -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) => ({
|
||||
|
||||
Reference in New Issue
Block a user