feat(skill): Phase 3 MP 기반 스킬 시스템 구현

- Skill, SkillType, BuffEffect, SkillState, SkillUseResult 클래스 정의 (skill.dart)
- SkillSystemState를 GameState에 통합 (activeBuffs, skillStates, elapsedMs)
- 프로그래밍 테마 스킬 데이터 정의 (skill_data.dart)
  - 공격: Debug Strike, Memory Leak, Core Dump, Kernel Panic 등
  - 회복: Hot Reload, Garbage Collection, Quick Fix
  - 버프: Safe Mode, Overclock, Firewall
- SkillService 구현 (skill_service.dart)
  - 스킬 사용 가능 여부 확인 (MP, 쿨타임)
  - 공격/회복/버프 스킬 사용 로직
  - 자동 스킬 선택 (HP < 30% → 회복, 보스전 → 강력한 공격, 일반 → MP 효율)
  - MP 자연 회복 (비전투: 50ms당 1, 전투: WIS 기반)
- progress_service.dart에 스킬 시스템 통합
  - tick()에서 스킬 시간 업데이트 및 버프 만료 처리
  - _processCombatTickWithSkills()로 전투 중 자동 스킬 사용
This commit is contained in:
JiWoong Sul
2025-12-17 17:05:48 +09:00
parent 6a696ecd57
commit 517bf54a56
5 changed files with 1041 additions and 22 deletions

View File

@@ -1,9 +1,11 @@
import 'dart:math' as math;
import 'package:askiineverdie/data/game_text_l10n.dart' as l10n;
import 'package:askiineverdie/data/skill_data.dart';
import 'package:askiineverdie/src/core/engine/combat_calculator.dart';
import 'package:askiineverdie/src/core/engine/game_mutations.dart';
import 'package:askiineverdie/src/core/engine/reward_service.dart';
import 'package:askiineverdie/src/core/engine/skill_service.dart';
import 'package:askiineverdie/src/core/model/combat_state.dart';
import 'package:askiineverdie/src/core/model/combat_stats.dart';
import 'package:askiineverdie/src/core/model/game_state.dart';
@@ -141,6 +143,34 @@ class ProgressService {
var questDone = false;
var actDone = false;
// 스킬 시스템 시간 업데이트 (Phase 3)
final skillService = SkillService(rng: state.rng);
var skillSystem = skillService.updateElapsedTime(state.skillSystem, clamped);
// 만료된 버프 정리
skillSystem = skillService.cleanupExpiredBuffs(skillSystem);
// 비전투 시 MP 회복
final isInCombat = progress.currentTask.type == TaskType.kill &&
progress.currentCombat != null &&
progress.currentCombat!.isActive;
if (!isInCombat && nextState.stats.mp < nextState.stats.mpMax) {
final mpRegen = skillService.calculateMpRegen(
elapsedMs: clamped,
isInCombat: false,
wis: nextState.stats.wis,
);
if (mpRegen > 0) {
final newMp = (nextState.stats.mp + mpRegen).clamp(0, nextState.stats.mpMax);
nextState = nextState.copyWith(
stats: nextState.stats.copyWith(mpCurrent: newMp),
);
}
}
nextState = nextState.copyWith(skillSystem: skillSystem);
// Advance task bar if still running.
if (progress.task.position < progress.task.max) {
final uncapped = progress.task.position + clamped;
@@ -148,10 +178,18 @@ class ProgressService {
? progress.task.max
: uncapped;
// 킬 태스크 중 전투 진행
// 킬 태스크 중 전투 진행 (스킬 자동 사용 포함)
var updatedCombat = progress.currentCombat;
var updatedSkillSystem = nextState.skillSystem;
if (progress.currentTask.type == TaskType.kill && updatedCombat != null && updatedCombat.isActive) {
updatedCombat = _processCombatTick(nextState, updatedCombat, clamped);
final combatResult = _processCombatTickWithSkills(
nextState,
updatedCombat,
updatedSkillSystem,
clamped,
);
updatedCombat = combatResult.combat;
updatedSkillSystem = combatResult.skillSystem;
}
progress = progress.copyWith(
@@ -159,7 +197,7 @@ class ProgressService {
currentCombat: updatedCombat,
);
nextState = _recalculateEncumbrance(
nextState.copyWith(progress: progress),
nextState.copyWith(progress: progress, skillSystem: updatedSkillSystem),
);
return ProgressTickResult(state: nextState);
}
@@ -791,22 +829,25 @@ class ProgressService {
);
}
/// 전투 틱 처리
/// 전투 틱 처리 (스킬 자동 사용 포함)
///
/// [state] 현재 게임 상태
/// [combat] 현재 전투 상태
/// [skillSystem] 스킬 시스템 상태
/// [elapsedMs] 경과 시간 (밀리초)
/// Returns: 업데이트된 전투 상태
CombatState _processCombatTick(
/// Returns: 업데이트된 전투 상태 및 스킬 시스템 상태
({CombatState combat, SkillSystemState skillSystem}) _processCombatTickWithSkills(
GameState state,
CombatState combat,
SkillSystemState skillSystem,
int elapsedMs,
) {
if (!combat.isActive || combat.isCombatOver) {
return combat;
return (combat: combat, skillSystem: skillSystem);
}
final calculator = CombatCalculator(rng: state.rng);
final skillService = SkillService(rng: state.rng);
var playerStats = combat.playerStats;
var monsterStats = combat.monsterStats;
var playerAccumulator = combat.playerAttackAccumulatorMs + elapsedMs;
@@ -814,15 +855,66 @@ class ProgressService {
var totalDamageDealt = combat.totalDamageDealt;
var totalDamageTaken = combat.totalDamageTaken;
var turnsElapsed = combat.turnsElapsed;
var updatedSkillSystem = skillSystem;
// 플레이어 공격 체크
if (playerAccumulator >= playerStats.attackDelayMs) {
final attackResult = calculator.playerAttackMonster(
attacker: playerStats,
defender: monsterStats,
// 스킬 자동 선택
final availableSkillIds = updatedSkillSystem.skillStates
.map((s) => s.skillId)
.toList();
// 기본 스킬이 없으면 기본 스킬 추가
if (availableSkillIds.isEmpty) {
availableSkillIds.addAll(SkillData.defaultSkillIds);
}
final selectedSkill = skillService.selectAutoSkill(
player: playerStats,
monster: monsterStats,
skillSystem: updatedSkillSystem,
availableSkillIds: availableSkillIds,
);
monsterStats = attackResult.updatedDefender;
totalDamageDealt += attackResult.result.damage;
if (selectedSkill != null && selectedSkill.isAttack) {
// 공격 스킬 사용
final skillResult = skillService.useAttackSkill(
skill: selectedSkill,
player: playerStats,
monster: monsterStats,
skillSystem: updatedSkillSystem,
);
playerStats = skillResult.updatedPlayer;
monsterStats = skillResult.updatedMonster;
totalDamageDealt += skillResult.result.damage;
updatedSkillSystem = skillResult.updatedSkillSystem;
} else if (selectedSkill != null && selectedSkill.isHeal) {
// 회복 스킬 사용
final skillResult = skillService.useHealSkill(
skill: selectedSkill,
player: playerStats,
skillSystem: updatedSkillSystem,
);
playerStats = skillResult.updatedPlayer;
updatedSkillSystem = skillResult.updatedSkillSystem;
} else if (selectedSkill != null && selectedSkill.isBuff) {
// 버프 스킬 사용
final skillResult = skillService.useBuffSkill(
skill: selectedSkill,
player: playerStats,
skillSystem: updatedSkillSystem,
);
playerStats = skillResult.updatedPlayer;
updatedSkillSystem = skillResult.updatedSkillSystem;
} else {
// 일반 공격
final attackResult = calculator.playerAttackMonster(
attacker: playerStats,
defender: monsterStats,
);
monsterStats = attackResult.updatedDefender;
totalDamageDealt += attackResult.result.damage;
}
playerAccumulator -= playerStats.attackDelayMs;
turnsElapsed++;
}
@@ -841,15 +933,18 @@ class ProgressService {
// 전투 종료 체크
final isActive = playerStats.isAlive && monsterStats.isAlive;
return combat.copyWith(
playerStats: playerStats,
monsterStats: monsterStats,
playerAttackAccumulatorMs: playerAccumulator,
monsterAttackAccumulatorMs: monsterAccumulator,
totalDamageDealt: totalDamageDealt,
totalDamageTaken: totalDamageTaken,
turnsElapsed: turnsElapsed,
isActive: isActive,
return (
combat: combat.copyWith(
playerStats: playerStats,
monsterStats: monsterStats,
playerAttackAccumulatorMs: playerAccumulator,
monsterAttackAccumulatorMs: monsterAccumulator,
totalDamageDealt: totalDamageDealt,
totalDamageTaken: totalDamageTaken,
turnsElapsed: turnsElapsed,
isActive: isActive,
),
skillSystem: updatedSkillSystem,
);
}
}

View File

@@ -0,0 +1,345 @@
import 'package:askiineverdie/data/skill_data.dart';
import 'package:askiineverdie/src/core/model/combat_stats.dart';
import 'package:askiineverdie/src/core/model/game_state.dart';
import 'package:askiineverdie/src/core/model/monster_combat_stats.dart';
import 'package:askiineverdie/src/core/model/skill.dart';
import 'package:askiineverdie/src/core/util/deterministic_random.dart';
/// 스킬 시스템 서비스
///
/// 스킬 사용, 쿨타임 관리, MP 관리, 자동 스킬 선택 등을 담당
class SkillService {
const SkillService({required this.rng});
final DeterministicRandom rng;
// ============================================================================
// 스킬 사용 가능 여부 확인
// ============================================================================
/// 스킬 사용 가능 여부 확인
SkillFailReason? canUseSkill({
required Skill skill,
required int currentMp,
required SkillSystemState skillSystem,
}) {
// 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 baseDamage = player.atk * skill.damageMultiplier;
// 버프 효과 적용
final buffMods = skillSystem.totalBuffModifiers;
final buffedDamage = baseDamage * (1 + buffMods.atkMod);
// 적 방어력 감소 적용
final effectiveMonsterDef = monster.def * (1 - skill.targetDefReduction);
// 최종 데미지 계산 (방어력 감산)
final finalDamage = (buffedDamage - effectiveMonsterDef * 0.5).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,
);
}
// ============================================================================
// 자동 스킬 선택
// ============================================================================
/// 전투 중 자동 스킬 선택
///
/// 우선순위:
/// 1. HP < 30% → 회복 스킬
/// 2. 보스전 (레벨 차이 10 이상) → 가장 강력한 공격 스킬
/// 3. 일반 전투 → MP 효율이 좋은 스킬
/// 4. MP < 20% → null (일반 공격)
Skill? selectAutoSkill({
required CombatStats player,
required MonsterCombatStats monster,
required SkillSystemState skillSystem,
required List<String> availableSkillIds,
}) {
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;
}
// 보스전 판단 (몬스터 레벨이 높음)
final isBossFight = monster.level >= 10 && monster.hpRatio > 0.5;
if (isBossFight) {
// 가장 강력한 공격 스킬
return _findStrongestAttackSkill(availableSkills);
}
// 일반 전투 → MP 효율 좋은 스킬
return _findEfficientAttackSkill(availableSkills);
}
/// 가장 좋은 회복 스킬 찾기
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;
}
// ============================================================================
// 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);
}
}