diff --git a/lib/src/core/engine/combat_tick_service.dart b/lib/src/core/engine/combat_tick_service.dart new file mode 100644 index 0000000..84383df --- /dev/null +++ b/lib/src/core/engine/combat_tick_service.dart @@ -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 = []; + 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 activeDoTs, + MonsterCombatStats monsterStats, + int totalDamageDealt, + List events, + }) _processDotTicks({ + required List activeDoTs, + required MonsterCombatStats monsterStats, + required int elapsedMs, + required int timestamp, + required int totalDamageDealt, + }) { + var dotDamageThisTick = 0; + final updatedDoTs = []; + final events = []; + 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 usedPotionTypes, + PotionInventory potionInventory, + List events, + })? _tryEmergencyPotion({ + required CombatStats playerStats, + required PotionInventory potionInventory, + required Set 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 activeDoTs, + List activeDebuffs, + int totalDamageDealt, + List events, + }) _processPlayerAttack({ + required GameState state, + required CombatStats playerStats, + required MonsterCombatStats monsterStats, + required SkillSystemState updatedSkillSystem, + required List activeDoTs, + required List activeDebuffs, + required int totalDamageDealt, + required int timestamp, + required CombatCalculator calculator, + required SkillService skillService, + }) { + final events = []; + 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 events, + }) _processMonsterAttack({ + required CombatStats playerStats, + required MonsterCombatStats monsterStats, + required List activeDebuffs, + required int totalDamageTaken, + required int timestamp, + required CombatCalculator calculator, + }) { + final events = []; + + // 디버프 효과 적용된 몬스터 스탯 계산 + 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, + ); + } +}