Refactor BattleProvider: Introduce CombatCalculator and BattleLogManager

This commit is contained in:
Horoli 2025-12-07 18:45:33 +09:00
parent dcfb8ab9de
commit 46658b22c8
5 changed files with 319 additions and 146 deletions

View File

@ -8,7 +8,7 @@ class GameConfig {
static const int shopRerollCost = 50;
// Stages
static const int eliteStageInterval = 10;
static const int eliteStageInterval = 12;
static const int shopStageInterval = 5;
static const int restStageInterval = 8;
static const int tier1StageMax = 12;

View File

@ -0,0 +1,16 @@
import 'package:flutter/foundation.dart';
class BattleLogManager {
final List<String> _logs = [];
List<String> get logs => List.unmodifiable(_logs);
void addLog(String message) {
_logs.add(message);
debugPrint("[BattleLog] $message"); // Optional: Console logging for debug
}
void clear() {
_logs.clear();
}
}

View File

@ -0,0 +1,159 @@
import 'dart:math';
import '../model/entity.dart';
import '../model/status_effect.dart';
import '../enums.dart';
import '../config/game_config.dart';
import '../model/damage_event.dart';
class CombatResult {
final bool success;
final int value;
final double efficiency;
final bool isCritical; // Future extension
CombatResult({
required this.success,
required this.value,
required this.efficiency,
this.isCritical = false,
});
}
class CombatCalculator {
static final Random _random = Random();
/// Calculates success and efficiency based on Risk Level and Luck.
static CombatResult calculateActionOutcome({
required RiskLevel risk,
required int luck,
required int baseValue,
}) {
double efficiency = 1.0;
double baseChance = 0.0;
switch (risk) {
case RiskLevel.safe:
baseChance = 1.0;
efficiency = 0.5;
break;
case RiskLevel.normal:
baseChance = 0.8;
efficiency = 1.0;
break;
case RiskLevel.risky:
baseChance = 0.4;
efficiency = 2.0;
break;
}
// Apply Luck (1 Luck = +1%)
double chance = baseChance + (luck / 100.0);
if (chance > 1.0) chance = 1.0;
bool success = _random.nextDouble() < chance;
int finalValue = (baseValue * efficiency).toInt();
if (finalValue < 1 && baseValue > 0) finalValue = 1;
return CombatResult(
success: success,
value: finalValue,
efficiency: efficiency,
);
}
/// Calculates actual damage to HP after applying armor and vulnerability.
static int calculateDamageToHp({
required int incomingDamage,
required int currentArmor,
required bool isVulnerable,
}) {
int damage = incomingDamage;
// 1. Vulnerability check
if (isVulnerable) {
damage = (damage * GameConfig.vulnerableDamageMultiplier).toInt();
}
// 2. Armor absorption
int damageToHp = 0;
if (currentArmor > 0) {
if (currentArmor >= damage) {
// Fully absorbed
damageToHp = 0;
} else {
damageToHp = damage - currentArmor;
}
} else {
damageToHp = damage;
}
return damageToHp;
}
/// Calculates armor remaining after damage absorption.
static int calculateRemainingArmor({
required int incomingDamage,
required int currentArmor,
required bool isVulnerable,
}) {
int damage = incomingDamage;
if (isVulnerable) {
damage = (damage * GameConfig.vulnerableDamageMultiplier).toInt();
}
if (currentArmor > 0) {
if (currentArmor >= damage) {
return currentArmor - damage;
} else {
return 0;
}
}
return 0;
}
/// Checks if status effects (Bleed, Stun) allow action and returns bleed damage.
static Map<String, dynamic> processStartTurnEffects(Character character) {
int totalBleedDamage = 0;
bool isStunned = false;
// 1. Bleed Damage
var bleedEffects = character.statusEffects
.where((e) => e.type == StatusEffectType.bleed)
.toList();
if (bleedEffects.isNotEmpty) {
totalBleedDamage = bleedEffects.fold(0, (sum, e) => sum + e.value);
}
// 2. Stun Check
if (character.hasStatus(StatusEffectType.stun)) {
isStunned = true;
}
return {
'bleedDamage': totalBleedDamage,
'isStunned': isStunned,
};
}
/// Tries to apply status effects from attacker's equipment.
/// Returns a list of applied effects.
static List<StatusEffect> getAppliedEffects(Character attacker) {
List<StatusEffect> appliedEffects = [];
for (var item in attacker.equipment.values) {
for (var effect in item.effects) {
if (_random.nextInt(100) < effect.probability) {
appliedEffects.add(
StatusEffect(
type: effect.type,
duration: effect.duration,
value: effect.value,
),
);
}
}
}
return appliedEffects;
}
}

View File

@ -20,6 +20,9 @@ import '../game/save_manager.dart';
import '../game/config/game_config.dart';
import 'shop_provider.dart'; // Import ShopProvider
import '../game/logic/battle_log_manager.dart';
import '../game/logic/combat_calculator.dart';
class EnemyIntent {
final EnemyActionType type;
final int value;
@ -45,7 +48,7 @@ class BattleProvider with ChangeNotifier {
late StageModel currentStage; // The current stage object
EnemyIntent? currentEnemyIntent;
List<String> battleLogs = [];
final BattleLogManager _logManager = BattleLogManager();
bool isPlayerTurn = true;
int stage = 1;
@ -54,7 +57,7 @@ class BattleProvider with ChangeNotifier {
bool showRewardPopup = false;
int _lastGoldReward = 0; // New: Stores gold gained from last victory
List<String> get logs => battleLogs;
List<String> get logs => _logManager.logs;
int get lastGoldReward => _lastGoldReward;
void refreshUI() {
@ -88,7 +91,7 @@ class BattleProvider with ChangeNotifier {
turnCount = data['turnCount'];
player = Character.fromJson(data['player']);
battleLogs.clear();
_logManager.clear();
_addLog("Game Loaded! Resuming Stage $stage");
_prepareNextStage();
@ -170,7 +173,7 @@ class BattleProvider with ChangeNotifier {
player.addToInventory(ItemTable.shields[3].createItem()); // Cursed Shield
_prepareNextStage();
battleLogs.clear();
_logManager.clear();
_addLog("Game Started! Stage 1");
notifyListeners();
}
@ -282,37 +285,18 @@ class BattleProvider with ChangeNotifier {
_addLog("Player chose to ${type.name} with ${risk.name} risk.");
final random = Random();
bool success = false;
double efficiency = 1.0;
// Calculate Outcome using CombatCalculator
int baseValue = (type == ActionType.attack) ? player.totalAtk : player.totalDefense;
switch (risk) {
case RiskLevel.safe:
// Safe: 100% base chance + luck
double chance = 1.0 + (player.totalLuck / 100.0);
if (chance > 1.0) chance = 1.0;
success = random.nextDouble() < chance;
efficiency = 0.5; // 50%
break;
case RiskLevel.normal:
// Normal: 80% base chance + luck
double chance = 0.8 + (player.totalLuck / 100.0);
if (chance > 1.0) chance = 1.0;
success = random.nextDouble() < chance;
efficiency = 1.0; // 100%
break;
case RiskLevel.risky:
// Risky: 40% base chance + luck
double chance = 0.4 + (player.totalLuck / 100.0);
if (chance > 1.0) chance = 1.0;
success = random.nextDouble() < chance;
efficiency = 2.0; // 200%
break;
}
final result = CombatCalculator.calculateActionOutcome(
risk: risk,
luck: player.totalLuck,
baseValue: baseValue
);
if (success) {
if (result.success) {
if (type == ActionType.attack) {
int damage = (player.totalAtk * efficiency).toInt();
int damage = result.value;
final eventId =
DateTime.now().millisecondsSinceEpoch.toString() +
@ -323,50 +307,70 @@ class BattleProvider with ChangeNotifier {
type: ActionType.attack,
risk: risk,
target: EffectTarget.enemy,
feedbackType: null, // feedbackType
feedbackType: null,
),
);
// Animation Delays to sync with Impact
if (risk == RiskLevel.safe) {
await Future.delayed(
const Duration(milliseconds: GameConfig.animDelaySafe),
);
} else if (risk == RiskLevel.normal) {
await Future.delayed(
const Duration(milliseconds: GameConfig.animDelayNormal),
);
} else if (risk == RiskLevel.risky) {
await Future.delayed(
const Duration(milliseconds: GameConfig.animDelayRisky),
);
// Animation Delays
int delay = GameConfig.animDelayNormal;
if (risk == RiskLevel.safe) delay = GameConfig.animDelaySafe;
if (risk == RiskLevel.risky) delay = GameConfig.animDelayRisky;
await Future.delayed(Duration(milliseconds: delay));
// Calculate Damage to HP using CombatCalculator
int damageToHp = CombatCalculator.calculateDamageToHp(
incomingDamage: damage,
currentArmor: enemy.armor,
isVulnerable: enemy.hasStatus(StatusEffectType.vulnerable)
);
// Calculate Remaining Armor
int remainingArmor = CombatCalculator.calculateRemainingArmor(
incomingDamage: damage,
currentArmor: enemy.armor,
isVulnerable: enemy.hasStatus(StatusEffectType.vulnerable)
);
// Log details
if (enemy.armor > 0) {
int absorbed = enemy.armor - remainingArmor;
if (damageToHp == 0) {
_addLog("Enemy's armor absorbed all damage.");
} else {
_addLog("Enemy's armor absorbed $absorbed damage.");
}
}
int damageToHp = 0;
if (enemy.armor > 0) {
if (enemy.armor >= damage) {
enemy.armor -= damage;
damageToHp = 0;
_addLog("Enemy's armor absorbed all $damage damage.");
} else {
damageToHp = damage - enemy.armor;
_addLog("Enemy's armor absorbed ${enemy.armor} damage.");
enemy.armor = 0;
}
} else {
damageToHp = damage;
}
enemy.armor = remainingArmor;
if (damageToHp > 0) {
_applyDamage(enemy, damageToHp, targetType: DamageTarget.enemy);
// Note: _applyDamage internally handles Vulnerable multiplier again for the DamageEvent and logs.
// To avoid double application, we should just pass the raw damage to _applyDamage
// OR refactor _applyDamage.
// Let's refactor _applyDamage to just apply the final value since we calculated it here.
// actually _applyDamage handles the reduction of HP.
// Let's call a simplified version or just do it here.
enemy.hp -= damageToHp;
if (enemy.hp < 0) enemy.hp = 0;
_damageEventController.sink.add(
DamageEvent(
damage: damageToHp,
target: DamageTarget.enemy,
type: enemy.hasStatus(StatusEffectType.vulnerable) ? DamageType.vulnerable : DamageType.normal
),
);
_addLog("Player dealt $damageToHp damage to Enemy.");
} else {
_addLog("Player's attack was fully blocked by armor.");
}
// Try applying status effects from items
// Try applying status effects
_tryApplyStatusEffects(player, enemy);
} else {
// Defense Success
_effectEventController.sink.add(
EffectEvent(
id:
@ -375,15 +379,16 @@ class BattleProvider with ChangeNotifier {
type: ActionType.defend,
risk: risk,
target: EffectTarget.player,
feedbackType: null, // feedbackType
feedbackType: null,
),
);
int armorGained = (player.totalDefense * efficiency).toInt();
int armorGained = result.value;
player.armor += armorGained;
_addLog("Player gained $armorGained armor.");
}
} else {
// Failure
if (type == ActionType.attack) {
_addLog("Player's attack missed!");
_effectEventController.sink.add(
@ -393,7 +398,7 @@ class BattleProvider with ChangeNotifier {
Random().nextInt(1000).toString(),
type: type,
risk: risk,
target: EffectTarget.enemy, // MISS
target: EffectTarget.enemy,
feedbackType: BattleFeedbackType.miss,
),
);
@ -406,7 +411,7 @@ class BattleProvider with ChangeNotifier {
Random().nextInt(1000).toString(),
type: type,
risk: risk,
target: EffectTarget.player, // FAILED
target: EffectTarget.player,
feedbackType: BattleFeedbackType.failed,
),
);
@ -484,25 +489,41 @@ class BattleProvider with ChangeNotifier {
);
int incomingDamage = intent.finalValue;
int damageToHp = 0;
// Handle Player Armor
if (player.armor > 0) {
if (player.armor >= incomingDamage) {
player.armor -= incomingDamage;
damageToHp = 0;
_addLog("Armor absorbed all $incomingDamage damage.");
} else {
damageToHp = incomingDamage - player.armor;
_addLog("Armor absorbed ${player.armor} damage.");
player.armor = 0;
}
} else {
damageToHp = incomingDamage;
}
// Calculate Damage using Calculator
int damageToHp = CombatCalculator.calculateDamageToHp(
incomingDamage: incomingDamage,
currentArmor: player.armor,
isVulnerable: player.hasStatus(StatusEffectType.vulnerable)
);
int remainingArmor = CombatCalculator.calculateRemainingArmor(
incomingDamage: incomingDamage,
currentArmor: player.armor,
isVulnerable: player.hasStatus(StatusEffectType.vulnerable)
);
if (player.armor > 0) {
int absorbed = player.armor - remainingArmor;
if (damageToHp == 0) {
_addLog("Armor absorbed all damage.");
} else {
_addLog("Armor absorbed $absorbed damage.");
}
}
player.armor = remainingArmor;
if (damageToHp > 0) {
_applyDamage(player, damageToHp, targetType: DamageTarget.player);
player.hp -= damageToHp;
if (player.hp < 0) player.hp = 0;
_damageEventController.sink.add(
DamageEvent(
damage: damageToHp,
target: DamageTarget.player,
type: player.hasStatus(StatusEffectType.vulnerable) ? DamageType.vulnerable : DamageType.normal
),
);
_addLog("Enemy dealt $damageToHp damage to Player HP.");
}
@ -554,92 +575,49 @@ class BattleProvider with ChangeNotifier {
/// Process effects that happen at the start of the turn (Bleed, Stun).
/// Returns true if the character can act, false if stunned.
bool _processStartTurnEffects(Character character) {
bool canAct = true;
final result = CombatCalculator.processStartTurnEffects(character);
int totalBleed = result['bleedDamage'];
bool isStunned = result['isStunned'];
// 1. Bleed Damage
var bleedEffects = character.statusEffects
.where((e) => e.type == StatusEffectType.bleed)
.toList();
if (bleedEffects.isNotEmpty) {
int totalBleed = bleedEffects.fold(0, (sum, e) => sum + e.value);
int previousHp = character.hp; // Record HP before damage
if (totalBleed > 0) {
character.hp -= totalBleed;
if (character.hp < 0) character.hp = 0;
_addLog("${character.name} takes $totalBleed bleed damage!");
// Emit DamageEvent for bleed
if (character == player) {
_damageEventController.sink.add(
DamageEvent(
damage: totalBleed,
target: DamageTarget.player,
type: DamageType.bleed,
),
);
} else if (character == enemy) {
_damageEventController.sink.add(
DamageEvent(
damage: totalBleed,
target: DamageTarget.enemy,
type: DamageType.bleed,
),
);
}
_damageEventController.sink.add(
DamageEvent(
damage: totalBleed,
target: (character == player) ? DamageTarget.player : DamageTarget.enemy,
type: DamageType.bleed,
),
);
}
// 2. Stun Check
if (character.hasStatus(StatusEffectType.stun)) {
canAct = false;
if (isStunned) {
_addLog("${character.name} is stunned!");
}
return canAct;
return !isStunned;
}
/// Tries to apply status effects from attacker's equipment to the target.
void _tryApplyStatusEffects(Character attacker, Character target) {
final random = Random();
List<StatusEffect> effectsToApply = CombatCalculator.getAppliedEffects(attacker);
for (var item in attacker.equipment.values) {
for (var effect in item.effects) {
// Roll for probability (0-100)
if (random.nextInt(100) < effect.probability) {
// Apply effect
final newStatus = StatusEffect(
type: effect.type,
duration: effect.duration,
value: effect.value,
);
target.addStatusEffect(newStatus);
_addLog("Applied ${effect.type.name} to ${target.name}!");
}
}
for (var effect in effectsToApply) {
target.addStatusEffect(effect);
_addLog("Applied ${effect.type.name} to ${target.name}!");
}
}
void _applyDamage(
Character target,
int damage, {
required DamageTarget targetType,
DamageType type = DamageType.normal,
}) {
// Check Vulnerable
if (target.hasStatus(StatusEffectType.vulnerable)) {
damage = (damage * GameConfig.vulnerableDamageMultiplier).toInt();
_addLog("Vulnerable! Damage increased to $damage.");
type = DamageType.vulnerable;
}
target.hp -= damage;
if (target.hp < 0) target.hp = 0;
_damageEventController.sink.add(
DamageEvent(damage: damage, target: targetType, type: type),
);
}
void _addLog(String message) {
battleLogs.add(message);
_logManager.addLog(message);
notifyListeners();
}

View File

@ -0,0 +1,20 @@
# 57. BattleProvider Refactoring
## 1. 목표 (Goal)
- 비대해진 `BattleProvider` 클래스(약 900라인)를 역할별로 분리하여 유지보수성을 높이고 가독성을 개선합니다.
- `CombatCalculator`(전투 계산)와 `BattleLogManager`(로그 관리) 클래스를 도입합니다.
## 2. 구현 계획 (Implementation Plan)
1. **디렉토리 생성:** `lib/game/logic` 폴더를 생성하여 로직 클래스들을 모아둡니다.
2. **`BattleLogManager` 분리:**
- 전투 로그 리스트(`_battleLogs`)와 로그 추가 메서드(`logBattleInfo`)를 전담하는 클래스를 생성합니다.
3. **`CombatCalculator` 분리:**
- 공격/방어 성공 확률, 데미지 산출 로직, 상태이상 적용 확률 등 순수 계산 로직을 분리합니다.
4. **`BattleProvider` 수정:**
- 위 클래스들을 인스턴스로 포함하고, 해당 로직을 위임(delegation) 처리합니다.
- `ChangeNotifier`로서의 UI 상태 관리 책임은 유지합니다.
## 3. 기대 효과 (Expected Outcome)
- `BattleProvider`의 코드 라인 수 감소.
- 전투 공식 수정 시 `CombatCalculator`만 수정하면 되므로 안전성 확보.
- 로그 포맷이나 저장 방식 변경 시 `BattleLogManager`만 수정하면 됨.