refactor(engine): 전투 틱 로직을 CombatTickService로 분리

- ProgressService에서 전투 처리 로직 추출
- 스킬 자동 사용, DOT, 물약 사용 로직 포함
- CombatTickResult 결과 클래스 정의
This commit is contained in:
JiWoong Sul
2026-01-15 01:53:20 +09:00
parent 6c92a323c0
commit 77dfa48ddf

View File

@@ -0,0 +1,580 @@
import 'package:asciineverdie/data/skill_data.dart';
import 'package:asciineverdie/src/core/engine/combat_calculator.dart';
import 'package:asciineverdie/src/core/engine/potion_service.dart';
import 'package:asciineverdie/src/core/engine/skill_service.dart';
import 'package:asciineverdie/src/core/model/combat_event.dart';
import 'package:asciineverdie/src/core/model/combat_state.dart';
import 'package:asciineverdie/src/core/model/combat_stats.dart';
import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/core/model/monster_combat_stats.dart';
import 'package:asciineverdie/src/core/model/potion.dart';
import 'package:asciineverdie/src/core/model/skill.dart';
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
/// 전투 틱 처리 결과
class CombatTickResult {
const CombatTickResult({
required this.combat,
required this.skillSystem,
this.potionInventory,
});
final CombatState combat;
final SkillSystemState skillSystem;
final PotionInventory? potionInventory;
}
/// 전투 틱 처리 서비스
///
/// ProgressService에서 분리된 전투 로직 담당:
/// - 스킬 자동 사용
/// - DOT 처리
/// - 물약 자동 사용
/// - 플레이어/몬스터 공격 처리
class CombatTickService {
CombatTickService({required this.rng});
final DeterministicRandom rng;
/// 전투 틱 처리 (스킬 자동 사용, DOT, 물약 포함)
///
/// [state] 현재 게임 상태
/// [combat] 현재 전투 상태
/// [skillSystem] 스킬 시스템 상태
/// [elapsedMs] 경과 시간 (밀리초)
CombatTickResult processTick({
required GameState state,
required CombatState combat,
required SkillSystemState skillSystem,
required int elapsedMs,
}) {
if (!combat.isActive || combat.isCombatOver) {
return CombatTickResult(
combat: combat,
skillSystem: skillSystem,
potionInventory: null,
);
}
final calculator = CombatCalculator(rng: rng);
final skillService = SkillService(rng: rng);
final potionService = const PotionService();
var playerStats = combat.playerStats;
var monsterStats = combat.monsterStats;
var playerAccumulator = combat.playerAttackAccumulatorMs + elapsedMs;
var monsterAccumulator = combat.monsterAttackAccumulatorMs + elapsedMs;
var totalDamageDealt = combat.totalDamageDealt;
var totalDamageTaken = combat.totalDamageTaken;
var turnsElapsed = combat.turnsElapsed;
var updatedSkillSystem = skillSystem;
var activeDoTs = [...combat.activeDoTs];
var usedPotionTypes = {...combat.usedPotionTypes};
var activeDebuffs = [...combat.activeDebuffs];
PotionInventory? updatedPotionInventory;
// 새 전투 이벤트 수집
final newEvents = <CombatEvent>[];
final timestamp = updatedSkillSystem.elapsedMs;
// 만료된 디버프 정리
activeDebuffs = activeDebuffs
.where((debuff) => !debuff.isExpired(timestamp))
.toList();
// DOT 틱 처리
final dotResult = _processDotTicks(
activeDoTs: activeDoTs,
monsterStats: monsterStats,
elapsedMs: elapsedMs,
timestamp: timestamp,
totalDamageDealt: totalDamageDealt,
);
activeDoTs = dotResult.activeDoTs;
monsterStats = dotResult.monsterStats;
totalDamageDealt = dotResult.totalDamageDealt;
newEvents.addAll(dotResult.events);
// 긴급 물약 자동 사용 (HP < 30%)
final potionResult = _tryEmergencyPotion(
playerStats: playerStats,
potionInventory: state.potionInventory,
usedPotionTypes: usedPotionTypes,
playerLevel: state.traits.level,
timestamp: timestamp,
potionService: potionService,
);
if (potionResult != null) {
playerStats = potionResult.playerStats;
usedPotionTypes = potionResult.usedPotionTypes;
updatedPotionInventory = potionResult.potionInventory;
newEvents.addAll(potionResult.events);
}
// 플레이어 공격 체크
if (playerAccumulator >= playerStats.attackDelayMs) {
final attackResult = _processPlayerAttack(
state: state,
playerStats: playerStats,
monsterStats: monsterStats,
updatedSkillSystem: updatedSkillSystem,
activeDoTs: activeDoTs,
activeDebuffs: activeDebuffs,
totalDamageDealt: totalDamageDealt,
timestamp: timestamp,
calculator: calculator,
skillService: skillService,
);
playerStats = attackResult.playerStats;
monsterStats = attackResult.monsterStats;
updatedSkillSystem = attackResult.skillSystem;
activeDoTs = attackResult.activeDoTs;
activeDebuffs = attackResult.activeDebuffs;
totalDamageDealt = attackResult.totalDamageDealt;
newEvents.addAll(attackResult.events);
playerAccumulator -= playerStats.attackDelayMs;
turnsElapsed++;
}
// 몬스터가 살아있으면 반격
if (monsterStats.isAlive &&
monsterAccumulator >= monsterStats.attackDelayMs) {
final monsterAttackResult = _processMonsterAttack(
playerStats: playerStats,
monsterStats: monsterStats,
activeDebuffs: activeDebuffs,
totalDamageTaken: totalDamageTaken,
timestamp: timestamp,
calculator: calculator,
);
playerStats = monsterAttackResult.playerStats;
totalDamageTaken = monsterAttackResult.totalDamageTaken;
newEvents.addAll(monsterAttackResult.events);
monsterAccumulator -= monsterStats.attackDelayMs;
}
// 전투 종료 체크
final isActive = playerStats.isAlive && monsterStats.isAlive;
// 기존 이벤트와 합쳐서 최대 10개 유지
final combinedEvents = [...combat.recentEvents, ...newEvents];
final recentEvents = combinedEvents.length > 10
? combinedEvents.sublist(combinedEvents.length - 10)
: combinedEvents;
return CombatTickResult(
combat: combat.copyWith(
playerStats: playerStats,
monsterStats: monsterStats,
playerAttackAccumulatorMs: playerAccumulator,
monsterAttackAccumulatorMs: monsterAccumulator,
totalDamageDealt: totalDamageDealt,
totalDamageTaken: totalDamageTaken,
turnsElapsed: turnsElapsed,
isActive: isActive,
recentEvents: recentEvents,
activeDoTs: activeDoTs,
usedPotionTypes: usedPotionTypes,
activeDebuffs: activeDebuffs,
),
skillSystem: updatedSkillSystem,
potionInventory: updatedPotionInventory,
);
}
/// DOT 틱 처리
({
List<DotEffect> activeDoTs,
MonsterCombatStats monsterStats,
int totalDamageDealt,
List<CombatEvent> events,
}) _processDotTicks({
required List<DotEffect> activeDoTs,
required MonsterCombatStats monsterStats,
required int elapsedMs,
required int timestamp,
required int totalDamageDealt,
}) {
var dotDamageThisTick = 0;
final updatedDoTs = <DotEffect>[];
final events = <CombatEvent>[];
var updatedMonster = monsterStats;
for (final dot in activeDoTs) {
final (updatedDot, ticksTriggered) = dot.tick(elapsedMs);
if (ticksTriggered > 0) {
final damage = dot.damagePerTick * ticksTriggered;
dotDamageThisTick += damage;
// DOT 데미지 이벤트 생성
final dotSkillName =
SkillData.getSkillById(dot.skillId)?.name ?? dot.skillId;
events.add(
CombatEvent.dotTick(
timestamp: timestamp,
skillName: dotSkillName,
damage: damage,
targetName: updatedMonster.name,
),
);
}
// 만료되지 않은 DOT만 유지
if (updatedDot.isActive) {
updatedDoTs.add(updatedDot);
}
}
// DOT 데미지 적용
if (dotDamageThisTick > 0 && updatedMonster.isAlive) {
final newMonsterHp = (updatedMonster.hpCurrent - dotDamageThisTick).clamp(
0,
updatedMonster.hpMax,
);
updatedMonster = updatedMonster.copyWith(hpCurrent: newMonsterHp);
totalDamageDealt += dotDamageThisTick;
}
return (
activeDoTs: updatedDoTs,
monsterStats: updatedMonster,
totalDamageDealt: totalDamageDealt,
events: events,
);
}
/// 긴급 물약 자동 사용
({
CombatStats playerStats,
Set<PotionType> usedPotionTypes,
PotionInventory potionInventory,
List<CombatEvent> events,
})? _tryEmergencyPotion({
required CombatStats playerStats,
required PotionInventory potionInventory,
required Set<PotionType> usedPotionTypes,
required int playerLevel,
required int timestamp,
required PotionService potionService,
}) {
final hpRatio = playerStats.hpCurrent / playerStats.hpMax;
if (hpRatio > PotionService.emergencyHpThreshold) {
return null;
}
final emergencyPotion = potionService.selectEmergencyHpPotion(
currentHp: playerStats.hpCurrent,
maxHp: playerStats.hpMax,
inventory: potionInventory,
playerLevel: playerLevel,
);
if (emergencyPotion == null || usedPotionTypes.contains(PotionType.hp)) {
return null;
}
final result = potionService.usePotion(
potionId: emergencyPotion.id,
inventory: potionInventory,
currentHp: playerStats.hpCurrent,
maxHp: playerStats.hpMax,
currentMp: playerStats.mpCurrent,
maxMp: playerStats.mpMax,
);
if (!result.success) {
return null;
}
return (
playerStats: playerStats.copyWith(hpCurrent: result.newHp),
usedPotionTypes: {...usedPotionTypes, PotionType.hp},
potionInventory: result.newInventory!,
events: [
CombatEvent.playerPotion(
timestamp: timestamp,
potionName: emergencyPotion.name,
healAmount: result.healedAmount,
isHp: true,
),
],
);
}
/// 플레이어 공격 처리
({
CombatStats playerStats,
MonsterCombatStats monsterStats,
SkillSystemState skillSystem,
List<DotEffect> activeDoTs,
List<ActiveBuff> activeDebuffs,
int totalDamageDealt,
List<CombatEvent> events,
}) _processPlayerAttack({
required GameState state,
required CombatStats playerStats,
required MonsterCombatStats monsterStats,
required SkillSystemState updatedSkillSystem,
required List<DotEffect> activeDoTs,
required List<ActiveBuff> activeDebuffs,
required int totalDamageDealt,
required int timestamp,
required CombatCalculator calculator,
required SkillService skillService,
}) {
final events = <CombatEvent>[];
var newPlayerStats = playerStats;
var newMonsterStats = monsterStats;
var newSkillSystem = updatedSkillSystem;
var newActiveDoTs = [...activeDoTs];
var newActiveBuffs = [...activeDebuffs];
var newTotalDamageDealt = totalDamageDealt;
// 장착된 스킬 슬롯에서 사용 가능한 스킬 ID 목록 조회
var availableSkillIds = state.skillSystem.equippedSkills.allSkills
.map((s) => s.id)
.toList();
// 장착된 스킬이 없으면 기본 스킬 사용
if (availableSkillIds.isEmpty) {
availableSkillIds = SkillData.defaultSkillIds;
}
final selectedSkill = skillService.selectAutoSkill(
player: newPlayerStats,
monster: newMonsterStats,
skillSystem: newSkillSystem,
availableSkillIds: availableSkillIds,
activeDoTs: newActiveDoTs,
activeDebuffs: newActiveBuffs,
);
if (selectedSkill != null && selectedSkill.isAttack) {
// 스킬 랭크 조회
final skillRank = skillService.getSkillRankFromSkillBook(
state.skillBook,
selectedSkill.id,
);
// 랭크 스케일링 적용된 공격 스킬 사용
final skillResult = skillService.useAttackSkillWithRank(
skill: selectedSkill,
player: newPlayerStats,
monster: newMonsterStats,
skillSystem: newSkillSystem,
rank: skillRank,
);
newPlayerStats = skillResult.updatedPlayer;
newMonsterStats = skillResult.updatedMonster;
newTotalDamageDealt += skillResult.result.damage;
newSkillSystem = skillResult.updatedSkillSystem.startGlobalCooldown();
events.add(
CombatEvent.playerSkill(
timestamp: timestamp,
skillName: selectedSkill.name,
damage: skillResult.result.damage,
targetName: newMonsterStats.name,
attackDelayMs: newPlayerStats.attackDelayMs,
),
);
} else if (selectedSkill != null && selectedSkill.isDot) {
final skillResult = skillService.useDotSkill(
skill: selectedSkill,
player: newPlayerStats,
skillSystem: newSkillSystem,
playerInt: state.stats.intelligence,
playerWis: state.stats.wis,
);
newPlayerStats = skillResult.updatedPlayer;
newSkillSystem = skillResult.updatedSkillSystem.startGlobalCooldown();
if (skillResult.dotEffect != null) {
newActiveDoTs.add(skillResult.dotEffect!);
}
events.add(
CombatEvent.playerSkill(
timestamp: timestamp,
skillName: selectedSkill.name,
damage: skillResult.result.damage,
targetName: newMonsterStats.name,
attackDelayMs: newPlayerStats.attackDelayMs,
),
);
} else if (selectedSkill != null && selectedSkill.isHeal) {
final skillResult = skillService.useHealSkill(
skill: selectedSkill,
player: newPlayerStats,
skillSystem: newSkillSystem,
);
newPlayerStats = skillResult.updatedPlayer;
newSkillSystem = skillResult.updatedSkillSystem.startGlobalCooldown();
events.add(
CombatEvent.playerHeal(
timestamp: timestamp,
healAmount: skillResult.result.healedAmount,
skillName: selectedSkill.name,
),
);
} else if (selectedSkill != null && selectedSkill.isBuff) {
final skillResult = skillService.useBuffSkill(
skill: selectedSkill,
player: newPlayerStats,
skillSystem: newSkillSystem,
);
newPlayerStats = skillResult.updatedPlayer;
newSkillSystem = skillResult.updatedSkillSystem.startGlobalCooldown();
events.add(
CombatEvent.playerBuff(
timestamp: timestamp,
skillName: selectedSkill.name,
),
);
} else if (selectedSkill != null && selectedSkill.isDebuff) {
final skillResult = skillService.useDebuffSkill(
skill: selectedSkill,
player: newPlayerStats,
skillSystem: newSkillSystem,
currentDebuffs: newActiveBuffs,
);
newPlayerStats = skillResult.updatedPlayer;
newSkillSystem = skillResult.updatedSkillSystem.startGlobalCooldown();
if (skillResult.debuffEffect != null) {
newActiveBuffs = newActiveBuffs
.where((d) => d.effect.id != skillResult.debuffEffect!.effect.id)
.toList()
..add(skillResult.debuffEffect!);
}
events.add(
CombatEvent.playerDebuff(
timestamp: timestamp,
skillName: selectedSkill.name,
targetName: newMonsterStats.name,
),
);
} else {
// 일반 공격
final attackResult = calculator.playerAttackMonster(
attacker: newPlayerStats,
defender: newMonsterStats,
);
newMonsterStats = attackResult.updatedDefender;
newTotalDamageDealt += attackResult.result.damage;
final result = attackResult.result;
if (result.isEvaded) {
events.add(
CombatEvent.monsterEvade(
timestamp: timestamp,
targetName: newMonsterStats.name,
),
);
} else {
events.add(
CombatEvent.playerAttack(
timestamp: timestamp,
damage: result.damage,
targetName: newMonsterStats.name,
isCritical: result.isCritical,
attackDelayMs: newPlayerStats.attackDelayMs,
),
);
}
}
return (
playerStats: newPlayerStats,
monsterStats: newMonsterStats,
skillSystem: newSkillSystem,
activeDoTs: newActiveDoTs,
activeDebuffs: newActiveBuffs,
totalDamageDealt: newTotalDamageDealt,
events: events,
);
}
/// 몬스터 공격 처리
({
CombatStats playerStats,
int totalDamageTaken,
List<CombatEvent> events,
}) _processMonsterAttack({
required CombatStats playerStats,
required MonsterCombatStats monsterStats,
required List<ActiveBuff> activeDebuffs,
required int totalDamageTaken,
required int timestamp,
required CombatCalculator calculator,
}) {
final events = <CombatEvent>[];
// 디버프 효과 적용된 몬스터 스탯 계산
var debuffedMonster = monsterStats;
if (activeDebuffs.isNotEmpty) {
double atkMod = 0;
for (final debuff in activeDebuffs) {
if (!debuff.isExpired(timestamp)) {
atkMod += debuff.effect.atkModifier;
}
}
// ATK 감소 적용 (최소 10% ATK 유지)
final newAtk = (monsterStats.atk * (1 + atkMod)).round().clamp(
monsterStats.atk ~/ 10,
monsterStats.atk,
);
debuffedMonster = monsterStats.copyWith(atk: newAtk);
}
final attackResult = calculator.monsterAttackPlayer(
attacker: debuffedMonster,
defender: playerStats,
);
final result = attackResult.result;
if (result.isEvaded) {
events.add(
CombatEvent.playerEvade(
timestamp: timestamp,
attackerName: monsterStats.name,
),
);
} else if (result.isBlocked) {
events.add(
CombatEvent.playerBlock(
timestamp: timestamp,
reducedDamage: result.damage,
attackerName: monsterStats.name,
),
);
} else if (result.isParried) {
events.add(
CombatEvent.playerParry(
timestamp: timestamp,
reducedDamage: result.damage,
attackerName: monsterStats.name,
),
);
} else {
events.add(
CombatEvent.monsterAttack(
timestamp: timestamp,
damage: result.damage,
attackerName: monsterStats.name,
attackDelayMs: monsterStats.attackDelayMs,
),
);
}
return (
playerStats: attackResult.updatedDefender,
totalDamageTaken: totalDamageTaken + attackResult.result.damage,
events: events,
);
}
}