Files
asciinevrdie/lib/src/core/engine/skill_service.dart
JiWoong Sul b0913a24ff feat(skill): DamageType 및 magAtk/magDef 스킬 시스템 추가
- DamageType enum 추가 (physical/magical)
- 스킬별 데미지 타입 지정 기능 구현
- 마법 스킬 데미지에 magAtk/magDef 적용
- 장비 아이템에서 magAtk/magDef 스탯 추출
- 관련 테스트 업데이트
2026-01-15 23:22:36 +09:00

754 lines
23 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:asciineverdie/data/skill_data.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/skill.dart';
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
import 'package:asciineverdie/src/core/util/roman.dart';
/// 스킬 시스템 서비스
///
/// 스킬 사용, 쿨타임 관리, MP 관리, 자동 스킬 선택 등을 담당
class SkillService {
const SkillService({required this.rng});
final DeterministicRandom rng;
// ============================================================================
// 스킬 사용 가능 여부 확인
// ============================================================================
/// 스킬 사용 가능 여부 확인
SkillFailReason? canUseSkill({
required Skill skill,
required int currentMp,
required SkillSystemState skillSystem,
}) {
// GCD 체크 (글로벌 쿨타임 1500ms)
if (skillSystem.isGlobalCooldownActive) {
return SkillFailReason.onGlobalCooldown;
}
// MP 체크
if (currentMp < skill.mpCost) {
return SkillFailReason.notEnoughMp;
}
// 쿨타임 체크
final skillState = skillSystem.getSkillState(skill.id);
if (skillState != null &&
!skillState.isReady(skillSystem.elapsedMs, skill.cooldownMs)) {
return SkillFailReason.onCooldown;
}
return null; // 사용 가능
}
// ============================================================================
// 스킬 사용
// ============================================================================
/// 공격 스킬 사용
///
/// Returns: (결과, 업데이트된 플레이어 스탯, 업데이트된 몬스터 스탯)
({
SkillUseResult result,
CombatStats updatedPlayer,
MonsterCombatStats updatedMonster,
SkillSystemState updatedSkillSystem,
})
useAttackSkill({
required Skill skill,
required CombatStats player,
required MonsterCombatStats monster,
required SkillSystemState skillSystem,
}) {
// 데미지 타입에 따른 공격력/방어력 선택
final (attackStat, defenseStat) = _getStatsByDamageType(
skill.damageType,
player,
monster,
);
// 기본 데미지 계산
final baseDamage = attackStat * skill.damageMultiplier;
// 버프 효과 적용
final buffMods = skillSystem.totalBuffModifiers;
final buffedDamage = baseDamage * (1 + buffMods.atkMod);
// 적 방어력 감소 적용
final effectiveMonsterDef = defenseStat * (1 - skill.targetDefReduction);
// 최종 데미지 계산 (방어력 감산 0.3)
final finalDamage = (buffedDamage - effectiveMonsterDef * 0.3)
.round()
.clamp(1, 9999);
// 몬스터에 데미지 적용
var updatedMonster = monster.applyDamage(finalDamage);
// 자해 데미지 적용
var updatedPlayer = player;
if (skill.selfDamagePercent > 0) {
final selfDamage = (player.hpMax * skill.selfDamagePercent).round();
updatedPlayer = player.applyDamage(selfDamage);
}
// MP 소모
updatedPlayer = updatedPlayer.withMp(
updatedPlayer.mpCurrent - skill.mpCost,
);
// 스킬 상태 업데이트 (쿨타임 시작)
final updatedSkillSystem = _updateSkillCooldown(skillSystem, skill.id);
return (
result: SkillUseResult(skill: skill, success: true, damage: finalDamage),
updatedPlayer: updatedPlayer,
updatedMonster: updatedMonster,
updatedSkillSystem: updatedSkillSystem,
);
}
/// 회복 스킬 사용
({
SkillUseResult result,
CombatStats updatedPlayer,
SkillSystemState updatedSkillSystem,
})
useHealSkill({
required Skill skill,
required CombatStats player,
required SkillSystemState skillSystem,
}) {
// 회복량 계산
int healAmount = skill.healAmount;
if (skill.healPercent > 0) {
healAmount += (player.hpMax * skill.healPercent).round();
}
// HP 회복
var updatedPlayer = player.applyHeal(healAmount);
// MP 소모
updatedPlayer = updatedPlayer.withMp(
updatedPlayer.mpCurrent - skill.mpCost,
);
// 스킬 상태 업데이트
final updatedSkillSystem = _updateSkillCooldown(skillSystem, skill.id);
return (
result: SkillUseResult(
skill: skill,
success: true,
healedAmount: healAmount,
),
updatedPlayer: updatedPlayer,
updatedSkillSystem: updatedSkillSystem,
);
}
/// 버프 스킬 사용
({
SkillUseResult result,
CombatStats updatedPlayer,
SkillSystemState updatedSkillSystem,
})
useBuffSkill({
required Skill skill,
required CombatStats player,
required SkillSystemState skillSystem,
}) {
if (skill.buff == null) {
return (
result: SkillUseResult.failed(skill, SkillFailReason.invalidState),
updatedPlayer: player,
updatedSkillSystem: skillSystem,
);
}
// 버프 적용
final newBuff = ActiveBuff(
effect: skill.buff!,
startedMs: skillSystem.elapsedMs,
sourceSkillId: skill.id,
);
// 기존 같은 버프 제거 후 새 버프 추가
final updatedBuffs =
skillSystem.activeBuffs
.where((b) => b.effect.id != skill.buff!.id)
.toList()
..add(newBuff);
// MP 소모
var updatedPlayer = player.withMp(player.mpCurrent - skill.mpCost);
// 스킬 상태 업데이트
var updatedSkillSystem = _updateSkillCooldown(skillSystem, skill.id);
updatedSkillSystem = updatedSkillSystem.copyWith(activeBuffs: updatedBuffs);
return (
result: SkillUseResult(skill: skill, success: true, appliedBuff: newBuff),
updatedPlayer: updatedPlayer,
updatedSkillSystem: updatedSkillSystem,
);
}
/// 디버프 스킬 사용
///
/// 디버프 효과를 생성하여 반환. 호출자가 CombatState.activeDebuffs에 추가해야 함.
/// 디버프는 몬스터의 ATK/DEF를 감소시킴.
({
SkillUseResult result,
CombatStats updatedPlayer,
SkillSystemState updatedSkillSystem,
ActiveBuff? debuffEffect,
})
useDebuffSkill({
required Skill skill,
required CombatStats player,
required SkillSystemState skillSystem,
required List<ActiveBuff> currentDebuffs,
}) {
if (skill.buff == null) {
return (
result: SkillUseResult.failed(skill, SkillFailReason.invalidState),
updatedPlayer: player,
updatedSkillSystem: skillSystem,
debuffEffect: null,
);
}
// 디버프 효과 생성
final newDebuff = ActiveBuff(
effect: skill.buff!,
startedMs: skillSystem.elapsedMs,
sourceSkillId: skill.id,
);
// MP 소모
var updatedPlayer = player.withMp(player.mpCurrent - skill.mpCost);
// 스킬 상태 업데이트 (쿨타임 시작)
final updatedSkillSystem = _updateSkillCooldown(skillSystem, skill.id);
return (
result: SkillUseResult(
skill: skill,
success: true,
appliedBuff: newDebuff,
),
updatedPlayer: updatedPlayer,
updatedSkillSystem: updatedSkillSystem,
debuffEffect: newDebuff,
);
}
/// DOT 스킬 사용
///
/// DOT 효과를 생성하여 반환. 호출자가 전투 상태의 activeDoTs에 추가해야 함.
/// INT → 틱당 데미지 보정, WIS → 틱 간격 보정
({
SkillUseResult result,
CombatStats updatedPlayer,
SkillSystemState updatedSkillSystem,
DotEffect? dotEffect,
})
useDotSkill({
required Skill skill,
required CombatStats player,
required SkillSystemState skillSystem,
required int playerInt,
required int playerWis,
}) {
if (!skill.isDot) {
return (
result: SkillUseResult.failed(skill, SkillFailReason.invalidState),
updatedPlayer: player,
updatedSkillSystem: skillSystem,
dotEffect: null,
);
}
// DOT 효과 생성 (INT/WIS 보정 적용)
final dotEffect = DotEffect.fromSkill(
skill,
playerInt: playerInt,
playerWis: playerWis,
);
// MP 소모
var updatedPlayer = player.withMp(player.mpCurrent - skill.mpCost);
// 스킬 상태 업데이트 (쿨타임 시작)
final updatedSkillSystem = _updateSkillCooldown(skillSystem, skill.id);
// 예상 총 데미지 계산 (틱 수 × 틱당 데미지)
final expectedTicks = dotEffect.totalDurationMs ~/ dotEffect.tickIntervalMs;
final expectedDamage = expectedTicks * dotEffect.damagePerTick;
return (
result: SkillUseResult(
skill: skill,
success: true,
damage: expectedDamage,
),
updatedPlayer: updatedPlayer,
updatedSkillSystem: updatedSkillSystem,
dotEffect: dotEffect,
);
}
// ============================================================================
// 자동 스킬 선택
// ============================================================================
/// 전투 중 자동 스킬 선택
///
/// 우선순위:
/// 1. HP < 30% → 회복 스킬 (최우선)
/// 2. 70% 확률로 일반 공격 (MP 절약, 기본 전투)
/// 3. 30% 확률로 스킬 사용:
/// - 버프: HP > 80% & MP > 60% & 활성 버프 없음
/// - 디버프: 몬스터 HP > 80% & 활성 디버프 없음
/// - DOT: 몬스터 HP > 60% & 활성 DOT 없음
/// - 공격: 보스전이면 강력한 스킬, 일반전이면 효율적 스킬
/// 4. MP < 20% → 일반 공격
Skill? selectAutoSkill({
required CombatStats player,
required MonsterCombatStats monster,
required SkillSystemState skillSystem,
required List<String> availableSkillIds,
List<DotEffect> activeDoTs = const [],
List<ActiveBuff> activeDebuffs = const [],
}) {
final currentMp = player.mpCurrent;
final mpRatio = player.mpRatio;
final hpRatio = player.hpRatio;
// MP 20% 미만이면 일반 공격
if (mpRatio < 0.2) return null;
// 사용 가능한 스킬 필터링
final availableSkills = availableSkillIds
.map((id) => SkillData.getSkillById(id))
.whereType<Skill>()
.where(
(skill) =>
canUseSkill(
skill: skill,
currentMp: currentMp,
skillSystem: skillSystem,
) ==
null,
)
.toList();
if (availableSkills.isEmpty) return null;
// HP < 30% → 회복 스킬 최우선 (생존)
if (hpRatio < 0.3) {
final healSkill = _findBestHealSkill(availableSkills, currentMp);
if (healSkill != null) return healSkill;
}
// 70% 확률로 일반 공격 (스킬은 특별한 상황에서만)
final useNormalAttack = rng.nextInt(100) < 70;
if (useNormalAttack) return null;
// === 아래부터 30% 확률로 스킬 사용 ===
// 버프: HP > 80% & MP > 60% (매우 안전할 때만)
// 활성 버프가 있으면 건너뜀 (중복 방지)
if (hpRatio > 0.8 && mpRatio > 0.6) {
final hasActiveBuff = skillSystem.activeBuffs.isNotEmpty;
if (!hasActiveBuff) {
final buffSkill = _findBestBuffSkill(availableSkills, currentMp);
if (buffSkill != null) return buffSkill;
}
}
// 디버프: 몬스터 HP > 80% & 활성 디버프 없음 (전투 초반)
if (monster.hpRatio > 0.8 && activeDebuffs.isEmpty) {
final debuffSkill = _findBestDebuffSkill(availableSkills, currentMp);
if (debuffSkill != null) return debuffSkill;
}
// DOT: 몬스터 HP > 60% & 활성 DOT 없음 (장기전 유리)
if (monster.hpRatio > 0.6 && activeDoTs.isEmpty) {
final dotSkill = _findBestDotSkill(availableSkills, currentMp);
if (dotSkill != null) return dotSkill;
}
// 보스전 판단 (몬스터 레벨 20 이상 & HP 50% 이상)
final isBossFight = monster.level >= 20 && monster.hpRatio > 0.5;
if (isBossFight) {
// 가장 강력한 공격 스킬
return _findStrongestAttackSkill(availableSkills);
}
// 일반 전투 → MP 효율 좋은 공격 스킬
return _findEfficientAttackSkill(availableSkills);
}
/// 가장 좋은 DOT 스킬 찾기
///
/// 예상 총 데미지 (틱 × 데미지) 기준으로 선택
Skill? _findBestDotSkill(List<Skill> skills, int currentMp) {
final dotSkills = skills
.where((s) => s.isDot && s.mpCost <= currentMp)
.toList();
if (dotSkills.isEmpty) return null;
// 예상 총 데미지 기준 정렬
dotSkills.sort((a, b) {
final aTotal =
(a.baseDotDamage ?? 0) *
((a.baseDotDurationMs ?? 0) ~/ (a.baseDotTickMs ?? 1000));
final bTotal =
(b.baseDotDamage ?? 0) *
((b.baseDotDurationMs ?? 0) ~/ (b.baseDotTickMs ?? 1000));
return bTotal.compareTo(aTotal);
});
return dotSkills.first;
}
/// 가장 좋은 회복 스킬 찾기
Skill? _findBestHealSkill(List<Skill> skills, int currentMp) {
final healSkills = skills
.where((s) => s.isHeal && s.mpCost <= currentMp)
.toList();
if (healSkills.isEmpty) return null;
// 회복량 기준 정렬 (% 회복 > 고정 회복)
healSkills.sort((a, b) {
final aValue = a.healPercent * 100 + a.healAmount;
final bValue = b.healPercent * 100 + b.healAmount;
return bValue.compareTo(aValue);
});
return healSkills.first;
}
/// 가장 강력한 공격 스킬 찾기
Skill? _findStrongestAttackSkill(List<Skill> skills) {
final attackSkills = skills.where((s) => s.isAttack).toList();
if (attackSkills.isEmpty) return null;
attackSkills.sort(
(a, b) => b.damageMultiplier.compareTo(a.damageMultiplier),
);
return attackSkills.first;
}
/// MP 효율 좋은 공격 스킬 찾기
Skill? _findEfficientAttackSkill(List<Skill> skills) {
final attackSkills = skills.where((s) => s.isAttack).toList();
if (attackSkills.isEmpty) return null;
attackSkills.sort((a, b) => b.mpEfficiency.compareTo(a.mpEfficiency));
return attackSkills.first;
}
/// 가장 좋은 버프 스킬 찾기
///
/// ATK 증가 버프 우선, 그 다음 복합 버프
Skill? _findBestBuffSkill(List<Skill> skills, int currentMp) {
final buffSkills = skills
.where((s) => s.isBuff && s.mpCost <= currentMp && s.buff != null)
.toList();
if (buffSkills.isEmpty) return null;
// ATK 증가량 기준 정렬
buffSkills.sort((a, b) {
final aValue =
(a.buff?.atkModifier ?? 0) +
(a.buff?.defModifier ?? 0) * 0.5 +
(a.buff?.criRateModifier ?? 0) * 0.3;
final bValue =
(b.buff?.atkModifier ?? 0) +
(b.buff?.defModifier ?? 0) * 0.5 +
(b.buff?.criRateModifier ?? 0) * 0.3;
return bValue.compareTo(aValue);
});
return buffSkills.first;
}
/// 가장 좋은 디버프 스킬 찾기
///
/// 적 ATK 감소 디버프 우선
Skill? _findBestDebuffSkill(List<Skill> skills, int currentMp) {
final debuffSkills = skills
.where((s) => s.isDebuff && s.mpCost <= currentMp && s.buff != null)
.toList();
if (debuffSkills.isEmpty) return null;
// 디버프 효과 크기 기준 정렬 (음수 값이므로 절대값으로 비교)
debuffSkills.sort((a, b) {
final aValue =
(a.buff?.atkModifier ?? 0).abs() +
(a.buff?.defModifier ?? 0).abs() * 0.5;
final bValue =
(b.buff?.atkModifier ?? 0).abs() +
(b.buff?.defModifier ?? 0).abs() * 0.5;
return bValue.compareTo(aValue);
});
return debuffSkills.first;
}
// ============================================================================
// MP 회복
// ============================================================================
/// MP 자연 회복
///
/// [elapsedMs] 경과 시간 (밀리초)
/// [isInCombat] 전투 중 여부
/// [wis] 지혜 스탯 (회복 속도 보정)
int calculateMpRegen({
required int elapsedMs,
required bool isInCombat,
required int wis,
}) {
if (isInCombat) {
// 전투 중: WIS에 비례한 느린 회복 (500ms당 1 + WIS/20)
final regenPerTick = 1 + wis ~/ 20;
return (elapsedMs ~/ 500) * regenPerTick;
} else {
// 비전투: 50ms당 1 회복
return elapsedMs ~/ 50;
}
}
// ============================================================================
// 버프 관리
// ============================================================================
/// 만료된 버프 제거
SkillSystemState cleanupExpiredBuffs(SkillSystemState state) {
final activeBuffs = state.activeBuffs
.where((b) => !b.isExpired(state.elapsedMs))
.toList();
return state.copyWith(activeBuffs: activeBuffs);
}
// ============================================================================
// 유틸리티
// ============================================================================
/// 스킬 쿨타임 업데이트
SkillSystemState _updateSkillCooldown(
SkillSystemState state,
String skillId,
) {
final skillStates = List<SkillState>.from(state.skillStates);
// 기존 상태 찾기
final existingIndex = skillStates.indexWhere((s) => s.skillId == skillId);
if (existingIndex >= 0) {
// 기존 상태 업데이트
skillStates[existingIndex] = skillStates[existingIndex].copyWith(
lastUsedMs: state.elapsedMs,
);
} else {
// 새 상태 추가
skillStates.add(
SkillState(skillId: skillId, lastUsedMs: state.elapsedMs, rank: 1),
);
}
return state.copyWith(skillStates: skillStates);
}
/// 스킬 시스템 시간 업데이트
SkillSystemState updateElapsedTime(SkillSystemState state, int deltaMs) {
return state.copyWith(elapsedMs: state.elapsedMs + deltaMs);
}
// ============================================================================
// SkillBook 연동
// ============================================================================
/// SkillBook에서 사용 가능한 스킬 목록 조회
///
/// SkillEntry 이름을 Skill로 매핑하여 반환
List<Skill> getAvailableSkillsFromSkillBook(SkillBook skillBook) {
return skillBook.skills
.map((entry) => SkillData.getSkillBySpellName(entry.name))
.whereType<Skill>()
.toList();
}
/// SkillBook에서 스킬의 랭크(레벨) 조회
///
/// 로마숫자 랭크(I, II, III)를 정수로 변환하여 반환
/// 스킬이 없으면 1 반환
int getSkillRankFromSkillBook(SkillBook skillBook, String skillId) {
// skillId로 스킬 찾기
final skill = SkillData.getSkillById(skillId);
if (skill == null) return 1;
// 스킬 이름으로 SkillEntry 찾기
for (final entry in skillBook.skills) {
if (entry.name == skill.name) {
return romanToInt(entry.rank);
}
}
return 1; // 기본 랭크
}
/// SkillBook에서 스킬 ID 목록 조회
///
/// 전투 시스템에서 사용 가능한 스킬 ID 목록 반환
List<String> getAvailableSkillIdsFromSkillBook(SkillBook skillBook) {
return getAvailableSkillsFromSkillBook(
skillBook,
).map((skill) => skill.id).toList();
}
/// 랭크 스케일링이 적용된 공격 스킬 사용
///
/// [rank] 스펠 랭크 (SkillBook에서 조회)
({
SkillUseResult result,
CombatStats updatedPlayer,
MonsterCombatStats updatedMonster,
SkillSystemState updatedSkillSystem,
})
useAttackSkillWithRank({
required Skill skill,
required CombatStats player,
required MonsterCombatStats monster,
required SkillSystemState skillSystem,
required int rank,
}) {
// 랭크 스케일링 적용
final rankMult = getRankMultiplier(rank);
final mpMult = getRankMpMultiplier(rank);
// 실제 MP 비용 계산
final actualMpCost = (skill.mpCost * mpMult).round();
// 데미지 타입에 따른 공격력/방어력 선택
final (attackStat, defenseStat) = _getStatsByDamageType(
skill.damageType,
player,
monster,
);
// 기본 데미지 계산 (랭크 배율 적용)
final baseDamage = attackStat * skill.damageMultiplier * rankMult;
// 버프 효과 적용
final buffMods = skillSystem.totalBuffModifiers;
final buffedDamage = baseDamage * (1 + buffMods.atkMod);
// 적 방어력 감소 적용
final effectiveMonsterDef = defenseStat * (1 - skill.targetDefReduction);
// 최종 데미지 계산 (방어력 감산 0.3)
final finalDamage = (buffedDamage - effectiveMonsterDef * 0.3)
.round()
.clamp(1, 9999);
// 몬스터에 데미지 적용
var updatedMonster = monster.applyDamage(finalDamage);
// 자해 데미지 적용
var updatedPlayer = player;
if (skill.selfDamagePercent > 0) {
final selfDamage = (player.hpMax * skill.selfDamagePercent).round();
updatedPlayer = player.applyDamage(selfDamage);
}
// MP 소모 (랭크 스케일링 적용)
updatedPlayer = updatedPlayer.withMp(
updatedPlayer.mpCurrent - actualMpCost,
);
// 스킬 상태 업데이트 (쿨타임 시작, 랭크 저장)
// 쿨타임 스케일링은 isReady 체크 시 적용됨
final updatedSkillSystem = _updateSkillCooldownWithRank(
skillSystem,
skill.id,
rank,
);
return (
result: SkillUseResult(skill: skill, success: true, damage: finalDamage),
updatedPlayer: updatedPlayer,
updatedMonster: updatedMonster,
updatedSkillSystem: updatedSkillSystem,
);
}
/// 랭크 정보를 포함한 스킬 쿨타임 업데이트
SkillSystemState _updateSkillCooldownWithRank(
SkillSystemState state,
String skillId,
int rank,
) {
final skillStates = List<SkillState>.from(state.skillStates);
// 기존 상태 찾기
final existingIndex = skillStates.indexWhere((s) => s.skillId == skillId);
if (existingIndex >= 0) {
// 기존 상태 업데이트
skillStates[existingIndex] = skillStates[existingIndex].copyWith(
lastUsedMs: state.elapsedMs,
rank: rank,
);
} else {
// 새 상태 추가
skillStates.add(
SkillState(skillId: skillId, lastUsedMs: state.elapsedMs, rank: rank),
);
}
return state.copyWith(skillStates: skillStates);
}
// ============================================================================
// 데미지 타입 헬퍼
// ============================================================================
/// 데미지 타입에 따른 공격력/방어력 스탯 반환
///
/// [damageType] 스킬의 데미지 타입
/// [player] 플레이어 전투 스탯
/// [monster] 몬스터 전투 스탯
/// Returns: (공격력, 방어력) 튜플
(double, double) _getStatsByDamageType(
DamageType damageType,
CombatStats player,
MonsterCombatStats monster,
) {
return switch (damageType) {
DamageType.physical => (player.atk.toDouble(), monster.def.toDouble()),
DamageType.magical => (
player.magAtk.toDouble(),
monster.magDef.toDouble(),
),
DamageType.hybrid => (
(player.atk + player.magAtk) / 2,
(monster.def + monster.magDef) / 2,
),
};
}
}