feat(combat): 디버프 시스템 추가

- CombatEventType.playerDebuff 추가
- CombatState에 activeDebuffs 목록 추가
- SkillService.useDebuffSkill() 구현
- 스킬 자동 선택에 디버프 우선순위 추가
- 밸런스 상수 업데이트
This commit is contained in:
JiWoong Sul
2025-12-30 15:58:03 +09:00
parent bdd3b45329
commit 80b6cd63e3
5 changed files with 294 additions and 51 deletions

View File

@@ -984,12 +984,20 @@ class ProgressService {
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 틱 처리
// =========================================================================
@@ -1090,6 +1098,7 @@ class ProgressService {
skillSystem: updatedSkillSystem,
availableSkillIds: availableSkillIds,
activeDoTs: activeDoTs,
activeDebuffs: activeDebuffs,
);
if (selectedSkill != null && selectedSkill.isAttack) {
@@ -1183,6 +1192,33 @@ class ProgressService {
skillName: selectedSkill.name,
),
);
} else if (selectedSkill != null && selectedSkill.isDebuff) {
// 디버프 스킬 사용
final skillResult = skillService.useDebuffSkill(
skill: selectedSkill,
player: playerStats,
skillSystem: updatedSkillSystem,
currentDebuffs: activeDebuffs,
);
playerStats = skillResult.updatedPlayer;
updatedSkillSystem = skillResult.updatedSkillSystem;
// 디버프 효과 추가 (기존 같은 디버프 제거 후)
if (skillResult.debuffEffect != null) {
activeDebuffs = activeDebuffs
.where((d) => d.effect.id != skillResult.debuffEffect!.effect.id)
.toList()
..add(skillResult.debuffEffect!);
}
// 디버프 이벤트 생성
newEvents.add(
CombatEvent.playerDebuff(
timestamp: timestamp,
skillName: selectedSkill.name,
targetName: monsterStats.name,
),
);
} else {
// 일반 공격
final attackResult = calculator.playerAttackMonster(
@@ -1221,8 +1257,25 @@ class ProgressService {
// 몬스터가 살아있으면 반격
if (monsterStats.isAlive &&
monsterAccumulator >= monsterStats.attackDelayMs) {
// 디버프 효과 적용된 몬스터 스탯 계산
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: monsterStats,
attacker: debuffedMonster,
defender: playerStats,
);
playerStats = attackResult.updatedDefender;
@@ -1288,6 +1341,7 @@ class ProgressService {
recentEvents: recentEvents,
activeDoTs: activeDoTs,
usedPotionTypes: usedPotionTypes,
activeDebuffs: activeDebuffs,
),
skillSystem: updatedSkillSystem,
potionInventory: updatedPotionInventory,

View File

@@ -185,6 +185,56 @@ class SkillService {
);
}
/// 디버프 스킬 사용
///
/// 디버프 효과를 생성하여 반환. 호출자가 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에 추가해야 함.
@@ -248,16 +298,19 @@ class SkillService {
///
/// 우선순위:
/// 1. HP < 30% → 회복 스킬
/// 2. 몬스터 HP > 50% & DOT 없음 → DOT 스킬 (장기전 유리)
/// 3. 보스전 (레벨 차이 10 이상) → 가장 강력한 공격 스킬
/// 4. 일반 전투 → MP 효율이 좋은 스킬
/// 5. MP < 20% → null (일반 공격)
/// 2. HP > 70% & MP > 50% → 버프 스킬 (안전할 때)
/// 3. 몬스터 HP > 70% & 활성 디버프 없음 → 디버프 스킬
/// 4. 몬스터 HP > 50% & DOT 없음 → DOT 스킬 (장기전 유리)
/// 5. 보스전 (레벨 차이 10 이상) → 가장 강력한 공격 스킬
/// 6. 일반 전투 → MP 효율이 좋은 스킬
/// 7. MP < 20% → null (일반 공격)
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;
@@ -289,6 +342,18 @@ class SkillService {
if (healSkill != null) return healSkill;
}
// HP > 70% & MP > 50% → 버프 스킬 (안전할 때)
if (hpRatio > 0.7 && mpRatio > 0.5) {
final buffSkill = _findBestBuffSkill(availableSkills, currentMp);
if (buffSkill != null) return buffSkill;
}
// 몬스터 HP > 70% & 활성 디버프 없음 → 디버프 스킬
if (monster.hpRatio > 0.7 && activeDebuffs.isEmpty) {
final debuffSkill = _findBestDebuffSkill(availableSkills, currentMp);
if (debuffSkill != null) return debuffSkill;
}
// 몬스터 HP > 50% & 활성 DOT 없음 → DOT 스킬 사용
if (monster.hpRatio > 0.5 && activeDoTs.isEmpty) {
final dotSkill = _findBestDotSkill(availableSkills, currentMp);
@@ -369,6 +434,52 @@ class SkillService {
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 회복
// ============================================================================

View File

@@ -27,6 +27,9 @@ enum CombatEventType {
/// 플레이어 버프
playerBuff,
/// 플레이어 디버프 (적에게 적용)
playerDebuff,
/// DOT 틱 데미지
dotTick,
@@ -209,6 +212,20 @@ class CombatEvent {
);
}
/// 디버프 이벤트 생성 (적에게 디버프 적용)
factory CombatEvent.playerDebuff({
required int timestamp,
required String skillName,
required String targetName,
}) {
return CombatEvent(
type: CombatEventType.playerDebuff,
timestamp: timestamp,
skillName: skillName,
targetName: targetName,
);
}
/// DOT 틱 이벤트 생성
factory CombatEvent.dotTick({
required int timestamp,

View File

@@ -21,6 +21,7 @@ class CombatState {
this.recentEvents = const [],
this.activeDoTs = const [],
this.usedPotionTypes = const {},
this.activeDebuffs = const [],
});
/// 플레이어 전투 스탯
@@ -56,6 +57,9 @@ class CombatState {
/// 이번 전투에서 사용한 물약 종류 (종류별 1회 제한)
final Set<PotionType> usedPotionTypes;
/// 몬스터에 적용된 활성 디버프 목록
final List<ActiveBuff> activeDebuffs;
// ============================================================================
// 유틸리티
// ============================================================================
@@ -88,6 +92,24 @@ class CombatState {
});
}
/// 활성 디버프 존재 여부
bool get hasActiveDebuffs => activeDebuffs.isNotEmpty;
/// 몬스터에 적용된 총 디버프 효과 계산
///
/// 디버프 효과는 몬스터 ATK/DEF에 부정적 배율로 적용됨
({double atkMod, double defMod}) get totalDebuffModifiers {
double atkMod = 0;
double defMod = 0;
for (final debuff in activeDebuffs) {
atkMod += debuff.effect.atkModifier;
defMod += debuff.effect.defModifier;
}
return (atkMod: atkMod, defMod: defMod);
}
CombatState copyWith({
CombatStats? playerStats,
MonsterCombatStats? monsterStats,
@@ -100,6 +122,7 @@ class CombatState {
List<CombatEvent>? recentEvents,
List<DotEffect>? activeDoTs,
Set<PotionType>? usedPotionTypes,
List<ActiveBuff>? activeDebuffs,
}) {
return CombatState(
playerStats: playerStats ?? this.playerStats,
@@ -115,6 +138,7 @@ class CombatState {
recentEvents: recentEvents ?? this.recentEvents,
activeDoTs: activeDoTs ?? this.activeDoTs,
usedPotionTypes: usedPotionTypes ?? this.usedPotionTypes,
activeDebuffs: activeDebuffs ?? this.activeDebuffs,
);
}

View File

@@ -10,27 +10,42 @@ class ExpConstants {
/// 기본 경험치 값
static const int baseExp = 100;
/// 레벨 경험치 증가율 (1.15 = 15% 증가)
static const double expGrowthRate = 1.15;
/// 레벨업에 필요한 경험치 계산
///
/// 공식: baseExp * (expGrowthRate ^ level)
/// 레벨 10: ~405 exp
/// 레벨 50: ~108,366 exp
/// 레벨 100: ~11,739,085 exp
static int requiredExp(int level) {
if (level <= 0) return baseExp;
return (baseExp * _pow(expGrowthRate, level)).round();
/// 레벨 구간별 경험치 증가율 (tiered growth rate)
/// - 1-30: 1.10 (초반 빠른 진행)
/// - 31-60: 1.12 (중반 적정 속도)
/// - 61-100: 1.14 (후반 도전)
static double _getGrowthRate(int level) {
if (level <= 30) return 1.10;
if (level <= 60) return 1.12;
return 1.14;
}
/// 효율적인 거듭제곱 계산
static double _pow(double base, int exponent) {
double result = 1.0;
for (int i = 0; i < exponent; i++) {
result *= base;
/// 레벨업에 필요한 경험치 계산 (구간별 차등 적용)
///
/// 조정 후 예상:
/// 레벨 10: ~259 exp
/// 레벨 30: ~1,744 exp
/// 레벨 50: ~9,705 exp
/// 레벨 80: ~133,860 exp
/// 레벨 100: ~636,840 exp
static int requiredExp(int level) {
if (level <= 0) return baseExp;
// 구간별 복합 성장 계산
double result = baseExp.toDouble();
for (int i = 1; i <= level; i++) {
result *= _getGrowthRate(i);
}
return result;
return result.round();
}
/// 총 누적 경험치 계산 (특정 레벨까지)
static int totalExpToLevel(int level) {
int total = 0;
for (int i = 1; i < level; i++) {
total += requiredExp(i);
}
return total;
}
}
@@ -88,40 +103,40 @@ class MonsterTypeMultiplier {
gold: 1.0,
);
/// 정예: HP 2배, ATK 1.3배, DEF 1.2배, 보상 2
/// 정예: HP 2배, ATK 1.3배, DEF 1.2배, EXP 3배 (상향), GOLD 2.5
static const elite = MonsterTypeMultiplier(
hp: 2.0,
atk: 1.3,
def: 1.2,
exp: 2.0,
gold: 2.0,
exp: 3.0, // 2.0 → 3.0 상향
gold: 2.5,
);
/// 미니보스: HP 5배, ATK/DEF 1.5배, 보상 5
/// 미니보스: HP 5배, ATK/DEF 1.5배, EXP 8배 (상향), GOLD 6
static const miniboss = MonsterTypeMultiplier(
hp: 5.0,
atk: 1.5,
def: 1.5,
exp: 5.0,
gold: 5.0,
exp: 8.0, // 5.0 → 8.0 상향
gold: 6.0,
);
/// 보스: HP 10배, ATK/DEF 2배, EXP 15배, GOLD 10
/// 보스: HP 8배 (하향), ATK/DEF 1.8배 (하향), EXP 25배 (상향), GOLD 15
static const boss = MonsterTypeMultiplier(
hp: 10.0,
atk: 2.0,
def: 2.0,
exp: 15.0,
gold: 10.0,
hp: 8.0, // 10.0 → 8.0 하향 (플레이어 접근성 개선)
atk: 1.8, // 2.0 → 1.8 하향
def: 1.8, // 2.0 → 1.8 하향
exp: 25.0, // 15.0 → 25.0 상향
gold: 15.0,
);
/// 최종 보스: HP 20배, ATK/DEF 2.5배, EXP 50배, GOLD 30배
/// 최종 보스: HP 12배 (하향), ATK/DEF 2.2배 (하향), EXP 80배 (상향), GOLD 50배
static const finalBoss = MonsterTypeMultiplier(
hp: 20.0,
atk: 2.5,
def: 2.5,
exp: 50.0,
gold: 30.0,
hp: 12.0, // 20.0 → 12.0 대폭 하향 (클리어 가능성 확보)
atk: 2.2, // 2.5 → 2.2 하향
def: 2.2, // 2.5 → 2.2 하향
exp: 80.0, // 50.0 → 80.0 상향
gold: 50.0,
);
}
@@ -283,6 +298,8 @@ class BossStats extends MonsterBaseStats {
}
/// Kernel Panic Archon (Act IV 보스, 레벨 80)
///
/// Phase 6 밸런스 조정: enrageMultiplier 1.6 → 1.5
static BossStats kernelPanicArchon(int baseLevel) {
final base = MonsterBaseStats.generate(baseLevel, MonsterType.boss);
return BossStats(
@@ -293,7 +310,7 @@ class BossStats extends MonsterBaseStats {
gold: base.gold,
phases: 3,
enrageThreshold: 0.2,
enrageMultiplier: 1.6,
enrageMultiplier: 1.5, // 1.6 → 1.5 (분노 시 50% 스탯 증가)
hasShield: true,
shieldAmount: (base.hp * 0.2).round(),
abilities: [BossAbilityType.stunAttack],
@@ -301,6 +318,11 @@ class BossStats extends MonsterBaseStats {
}
/// Glitch God (최종 보스, 레벨 100)
///
/// Phase 6 밸런스 조정:
/// - enrageThreshold: 0.1 → 0.15 (분노 발동 시점 완화)
/// - enrageMultiplier: 2.0 → 1.7 (분노 시 스탯 증가 완화)
/// - shieldAmount: 50% → 35% (보호막 감소)
static BossStats glitchGod(int baseLevel) {
final base = MonsterBaseStats.generate(baseLevel, MonsterType.finalBoss);
return BossStats(
@@ -310,10 +332,10 @@ class BossStats extends MonsterBaseStats {
exp: base.exp,
gold: base.gold,
phases: 5,
enrageThreshold: 0.1,
enrageMultiplier: 2.0,
enrageThreshold: 0.15, // 0.1 → 0.15 (15% HP에서 분노)
enrageMultiplier: 1.7, // 2.0 → 1.7 (분노 시 70% 스탯 증가)
hasShield: true,
shieldAmount: (base.hp * 0.5).round(),
shieldAmount: (base.hp * 0.35).round(), // 0.5 → 0.35 (보호막 30% 감소)
abilities: [
BossAbilityType.phaseShift,
BossAbilityType.multiAttack,
@@ -398,11 +420,17 @@ class LevelTierSettings {
class PlayerScaling {
PlayerScaling._();
/// 레벨당 HP 증가량
static const int hpPerLevel = 10;
/// 레벨당 HP 증가량 (10 → 12 상향)
static const int hpPerLevel = 12;
/// 레벨당 MP 증가량
static const int mpPerLevel = 5;
/// 레벨당 MP 증가량 (5 → 6 상향)
static const int mpPerLevel = 6;
/// CON당 HP 보너스 (5 → 6 상향)
static const int hpPerCon = 6;
/// INT당 MP 보너스 (3 → 4 상향)
static const int mpPerInt = 4;
/// 레벨업 시 HP/MP 계산
static ({int hpMax, int mpMax}) calculateResources({
@@ -412,8 +440,17 @@ class PlayerScaling {
required int conBonus,
required int intBonus,
}) {
final hpMax = baseHp + (level - 1) * hpPerLevel + conBonus * 5;
final mpMax = baseMp + (level - 1) * mpPerLevel + intBonus * 3;
final hpMax = baseHp + (level - 1) * hpPerLevel + conBonus * hpPerCon;
final mpMax = baseMp + (level - 1) * mpPerLevel + intBonus * mpPerInt;
return (hpMax: hpMax, mpMax: mpMax);
}
/// 레벨 구간별 ATK 보너스 (후반 DPS 보조)
/// - 레벨 60+: +1 ATK per level
/// - 레벨 80+: +2 ATK per level
static int bonusAtk(int level) {
if (level >= 80) return (level - 80) * 2 + 20;
if (level >= 60) return level - 60;
return 0;
}
}