From 80b6cd63e33006c9446358d904892a3aa83ac44b Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Tue, 30 Dec 2025 15:58:03 +0900 Subject: [PATCH] =?UTF-8?q?feat(combat):=20=EB=94=94=EB=B2=84=ED=94=84=20?= =?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CombatEventType.playerDebuff 추가 - CombatState에 activeDebuffs 목록 추가 - SkillService.useDebuffSkill() 구현 - 스킬 자동 선택에 디버프 우선순위 추가 - 밸런스 상수 업데이트 --- lib/src/core/engine/progress_service.dart | 56 +++++++++- lib/src/core/engine/skill_service.dart | 119 +++++++++++++++++++- lib/src/core/model/combat_event.dart | 17 +++ lib/src/core/model/combat_state.dart | 24 ++++ lib/src/core/util/balance_constants.dart | 129 ++++++++++++++-------- 5 files changed, 294 insertions(+), 51 deletions(-) diff --git a/lib/src/core/engine/progress_service.dart b/lib/src/core/engine/progress_service.dart index 4b1e4a6..bbdd4fc 100644 --- a/lib/src/core/engine/progress_service.dart +++ b/lib/src/core/engine/progress_service.dart @@ -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 = []; 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, diff --git a/lib/src/core/engine/skill_service.dart b/lib/src/core/engine/skill_service.dart index 399ac41..4fbb407 100644 --- a/lib/src/core/engine/skill_service.dart +++ b/lib/src/core/engine/skill_service.dart @@ -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 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 availableSkillIds, List activeDoTs = const [], + List 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 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 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 회복 // ============================================================================ diff --git a/lib/src/core/model/combat_event.dart b/lib/src/core/model/combat_event.dart index ce5c456..91bef74 100644 --- a/lib/src/core/model/combat_event.dart +++ b/lib/src/core/model/combat_event.dart @@ -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, diff --git a/lib/src/core/model/combat_state.dart b/lib/src/core/model/combat_state.dart index 4a7b470..3dc0c0c 100644 --- a/lib/src/core/model/combat_state.dart +++ b/lib/src/core/model/combat_state.dart @@ -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 usedPotionTypes; + /// 몬스터에 적용된 활성 디버프 목록 + final List 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? recentEvents, List? activeDoTs, Set? usedPotionTypes, + List? 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, ); } diff --git a/lib/src/core/util/balance_constants.dart b/lib/src/core/util/balance_constants.dart index 8e21882..32030ae 100644 --- a/lib/src/core/util/balance_constants.dart +++ b/lib/src/core/util/balance_constants.dart @@ -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; + } }