diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index f1a31b1..3bcf502 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -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; + enemyUsableUseKeys?: string[]; triggeredBattleEvents?: string[]; }; @@ -1510,8 +1517,8 @@ const usableCatalog: Record = { target: 'ally', effect: 'heal', range: 2, - power: 12, - description: '가까운 아군의 병력을 조금 회복한다.' + power: 10, + description: '사거리 2칸의 작은 책략 회복. 위험한 아군을 멀리서 살린다.' }, encourage: { id: 'encourage', @@ -1522,25 +1529,25 @@ const usableCatalog: Record = { 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 = { 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 = { target: 'ally', effect: 'heal', range: 1, - power: 10, - description: '아군 한 부대의 병력을 소량 회복한다.' + power: 12, + description: '인접 아군을 빠르게 소량 회복하는 기본 보급품.' }, salve: { id: 'salve', @@ -1574,8 +1581,8 @@ const usableCatalog: Record = { target: 'ally', effect: 'heal', range: 1, - power: 18, - description: '아군 한 부대의 병력을 크게 회복한다.' + power: 22, + description: '인접 아군의 큰 피해를 되돌리는 고가 회복품.' }, wine: { id: 'wine', @@ -1586,10 +1593,10 @@ const usableCatalog: Record = { 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(); private battleStats = new Map(); + private enemyUsableUseKeys = new Set(); private triggeredBattleEvents = new Set(); 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) => ({