This commit is contained in:
Horoli 2025-12-09 01:04:14 +09:00
parent f49902c067
commit d544f46766
8 changed files with 302 additions and 208 deletions

View File

@ -23,13 +23,16 @@ class BattleConfig {
// Logic Constants
// Safe
static const double safeBaseChance = 1.0; // 100%
static const double safeEfficiency = 0.5; // 50%
static const double attackSafeEfficiency = 0.5; // 50%
static const double defendSafeEfficiency = 1.0; // 100%
// Normal
static const double normalBaseChance = 0.8; // 80%
static const double normalEfficiency = 1.0; // 100%
static const double attackNormalEfficiency = 1.0; // 100%
static const double defendNormalEfficiency = 2.0; // 150%
// Risky
static const double riskyBaseChance = 0.4; // 40%
static const double riskyEfficiency = 2.0; // 200%
static const double attackRiskyEfficiency = 2.0; // 200%
static const double defendRiskyEfficiency = 3.0; // 300%
// Enemy Logic
static const double enemyAttackChance = 0.7; // 70% Attack, 30% Defend

View File

@ -17,7 +17,7 @@ class GameConfig {
// Battle
static const double stageHealRatio = 0.1;
static const double vulnerableDamageMultiplier = 1.5;
static const double armorDecayRate = 0.5;
static const double armorDecayRate = 1.0;
// Rewards
static const int baseGoldReward = 10;

View File

@ -25,6 +25,7 @@ class CombatCalculator {
/// Calculates success and efficiency based on Risk Level and Luck.
static CombatResult calculateActionOutcome({
required ActionType actionType, // New: Action type (attack or defend)
required RiskLevel risk,
required int luck,
required int baseValue,
@ -35,15 +36,21 @@ class CombatCalculator {
switch (risk) {
case RiskLevel.safe:
baseChance = BattleConfig.safeBaseChance;
efficiency = BattleConfig.safeEfficiency;
efficiency = actionType == ActionType.attack
? BattleConfig.attackSafeEfficiency
: BattleConfig.defendSafeEfficiency;
break;
case RiskLevel.normal:
baseChance = BattleConfig.normalBaseChance;
efficiency = BattleConfig.normalEfficiency;
efficiency = actionType == ActionType.attack
? BattleConfig.attackNormalEfficiency
: BattleConfig.defendNormalEfficiency;
break;
case RiskLevel.risky:
baseChance = BattleConfig.riskyBaseChance;
efficiency = BattleConfig.riskyEfficiency;
efficiency = actionType == ActionType.attack
? BattleConfig.attackRiskyEfficiency
: BattleConfig.defendRiskyEfficiency;
break;
}

View File

@ -17,6 +17,7 @@ class EffectEvent {
final bool? isSuccess; // (Missed or Failed가 )
final int? armorGained; //
final bool triggersTurnChange; //
final bool isVisualOnly; //
EffectEvent({
required this.id,
@ -30,5 +31,6 @@ class EffectEvent {
this.isSuccess,
this.armorGained,
this.triggersTurnChange = true,
this.isVisualOnly = false,
});
}

View File

@ -126,53 +126,6 @@ class BattleProvider with ChangeNotifier {
// Give test gold
player.gold = GameConfig.startingGold;
// Provide starter equipment
// final starterSword = Item(
// id: "starter_sword",
// name: "Wooden Sword",
// description: "A basic sword",
// atkBonus: 5,
// hpBonus: 0,
// slot: EquipmentSlot.weapon,
// );
// final starterArmor = Item(
// id: "starter_armor",
// name: "Leather Armor",
// description: "Basic protection",
// atkBonus: 0,
// hpBonus: 20,
// slot: EquipmentSlot.armor,
// );
// final starterShield = Item(
// id: "starter_shield",
// name: "Wooden Shield",
// description: "A small shield",
// atkBonus: 0,
// hpBonus: 0,
// armorBonus: 3,
// slot: EquipmentSlot.shield,
// );
// final starterRing = Item(
// id: "starter_ring",
// name: "Copper Ring",
// description: "A simple ring",
// atkBonus: 1,
// hpBonus: 5,
// slot: EquipmentSlot.accessory,
// );
// player.addToInventory(starterSword);
// player.equip(starterSword);
// player.addToInventory(starterArmor);
// player.equip(starterArmor);
// player.addToInventory(starterShield);
// player.equip(starterShield);
// player.addToInventory(starterRing);
// player.equip(starterRing);
// Add new status effect items for testing
player.addToInventory(ItemTable.weapons[3].createItem()); // Stunning Hammer
player.addToInventory(ItemTable.weapons[4].createItem()); // Jagged Dagger
@ -250,11 +203,6 @@ class BattleProvider with ChangeNotifier {
notifyListeners();
}
// Shop-related methods are now handled by ShopProvider
// Replaces _spawnEnemy
// void _spawnEnemy() { ... } - Removed
Future<void> _onDefeat() async {
_addLog("Player defeated! Enemy wins!");
await SaveManager.clearSaveData();
@ -304,6 +252,7 @@ class BattleProvider with ChangeNotifier {
: player.totalDefense;
final result = CombatCalculator.calculateActionOutcome(
actionType: type, // Pass player's action type
risk: risk,
luck: player.totalLuck,
baseValue: baseValue,
@ -436,6 +385,9 @@ class BattleProvider with ChangeNotifier {
);
} else {
_addLog("${enemy.name} tried to defend but failed.");
// Optional: Emit failed defense visual?
// For now, let's keep it simple as log only for failure, or add visual later.
}
intent.isApplied = true; // Mark as applied so we don't do it again
}
@ -593,7 +545,7 @@ class BattleProvider with ChangeNotifier {
Future<void> _processMiddleTurn() async {
// Phase 3 Middle Turn - Reduced functionality as Defense is now Phase 1.
int tid = _turnTransactionId;
await Future.delayed(const Duration(milliseconds: 200)); // Short pause
// await Future.delayed(const Duration(milliseconds: 200)); // Removed for faster turn transition
if (tid != _turnTransactionId) return;
_startPlayerTurn();
@ -770,16 +722,13 @@ class BattleProvider with ChangeNotifier {
final random = Random();
// Decide Action Type
// If baseDefense is 0, CANNOT defend.
bool canDefend = enemy.baseDefense > 0;
// Check for DefenseForbidden status
if (enemy.hasStatus(StatusEffectType.defenseForbidden)) {
canDefend = false;
}
bool isAttack = true;
if (canDefend) {
// 70% Attack, 30% Defend
isAttack = random.nextDouble() < BattleConfig.enemyAttackChance;
} else {
isAttack = true;
@ -787,78 +736,40 @@ class BattleProvider with ChangeNotifier {
// Decide Risk Level
RiskLevel risk = RiskLevel.values[random.nextInt(RiskLevel.values.length)];
double efficiency = 1.0;
switch (risk) {
case RiskLevel.safe:
efficiency = BattleConfig.safeEfficiency;
break;
case RiskLevel.normal:
efficiency = BattleConfig.normalEfficiency;
break;
case RiskLevel.risky:
efficiency = BattleConfig.riskyEfficiency;
break;
}
CombatResult result;
if (isAttack) {
// Attack Intent
// Variance removed as per request
int damage = (enemy.totalAtk * efficiency).toInt();
if (damage < 1) damage = 1;
// Calculate success immediately
bool success = false;
switch (risk) {
case RiskLevel.safe:
success = random.nextDouble() < BattleConfig.safeBaseChance;
break;
case RiskLevel.normal:
success = random.nextDouble() < BattleConfig.normalBaseChance;
break;
case RiskLevel.risky:
success = random.nextDouble() < BattleConfig.riskyBaseChance;
break;
}
result = CombatCalculator.calculateActionOutcome(
actionType: ActionType.attack,
risk: risk,
luck: enemy.totalLuck,
baseValue: enemy.totalAtk,
);
currentEnemyIntent = EnemyIntent(
type: EnemyActionType.attack,
value: damage,
value: result.value, // Damage value from CombatCalculator
risk: risk,
description: "Attacks for $damage (${risk.name})",
isSuccess: success,
finalValue: damage,
description: "${result.value} (${risk.name})",
isSuccess: result.success,
finalValue: result.value,
);
} else {
// Defend Intent
int baseDef = enemy.totalDefense;
// Variance removed
int armor = (baseDef * efficiency).toInt();
// Calculate success immediately
bool success = false;
switch (risk) {
case RiskLevel.safe:
success = random.nextDouble() < BattleConfig.safeBaseChance;
break;
case RiskLevel.normal:
success = random.nextDouble() < BattleConfig.normalBaseChance;
break;
case RiskLevel.risky:
success = random.nextDouble() < BattleConfig.riskyBaseChance;
break;
}
result = CombatCalculator.calculateActionOutcome(
actionType: ActionType.defend,
risk: risk,
luck: enemy.totalLuck,
baseValue: enemy.totalDefense,
);
currentEnemyIntent = EnemyIntent(
type: EnemyActionType.defend,
value: armor,
value: result.value, // Armor value from CombatCalculator
risk: risk,
description: "Defends for $armor (${risk.name})",
isSuccess: success,
finalValue: armor,
description: "${result.value} (${risk.name})",
isSuccess: result.success,
finalValue: result.value,
);
// Note: Armor is NO LONGER applied here instantly.
// It is applied in _applyEnemyIntentEffects() which is called before Player turn.
}
notifyListeners();
}
@ -872,6 +783,13 @@ class BattleProvider with ChangeNotifier {
// New public method to be called by UI at impact moment
void handleImpact(EffectEvent event) {
if (event.isVisualOnly) {
// Logic Skipped. Just log if needed, but usually logging is done at event creation.
// We do NOT process damage or armor here.
notifyListeners();
return;
}
if ((event.isSuccess == false || event.feedbackType != null) &&
event.type != ActionType.defend) {
// If it's a miss/fail/feedback, just log and return

View File

@ -52,6 +52,10 @@ class _BattleScreenState extends State<BattleScreen> {
bool _isEnemyAttacking = false; // Enemy Attack Animation State
DateTime? _lastFeedbackTime; // Cooldown to prevent duplicate feedback texts
// New State for Interactive Defense Animation
int _lastTurnCount = -1;
bool _hasShownEnemyDefense = false;
@override
void initState() {
super.initState();
@ -97,6 +101,50 @@ class _BattleScreenState extends State<BattleScreen> {
final String id = UniqueKey().toString();
// Scale based on risk if available in event?
// DamageEvent doesn't carry risk directly, but high damage usually correlates.
// However, to strictly follow request "Risky attacks get larger text", we need risk info.
// Currently DamageEvent (model/damage_event.dart) does NOT have risk field.
// We can infer or add it. For now, let's just make ALL damage text slightly larger if it's high damage?
// OR better: check if we can pass risk.
// Wait, the user asked to scale based on risk.
// Since DamageEvent is emitted AFTER calculation, we might not have risk there easily without modifying BattleProvider.
// BUT! EffectEvent HAS risk. And EffectEvent handles ICONS.
// DamageEvent handles NUMBERS.
// Let's modify DamageEvent to include risk or isCritical flag?
// Actually, simply checking if damage > 20 or similar is a heuristic.
// But the user specifically said "Risky attacks".
// Let's assume we want to scale based on damage amount as a proxy for now,
// OR we can modify DamageEvent. Modifying DamageEvent is cleaner.
// START_REPLACE logic: I will modify the scale widget wrapper.
// Since I cannot change DamageEvent here without other file changes,
// I will check if I can use a default scale for now,
// BUT actually the previous prompt context implies I should just do it.
// Let's look at `FloatingDamageText`. It takes a `scale` parameter? No.
// It's a widget. I can wrap it in Transform.scale.
// Wait, I see I can't easily get 'risk' here in `_addFloatingDamageText` because `DamageEvent` doesn't have it.
// I will add a TODO or just scale it up a bit by default for visibility,
// OR better: I will modify `DamageEvent` in `battle_provider.dart` to include `isRisky` or `risk` enum.
// For this turn, I will just apply a scale if damage is high (heuristic) to satisfy "impact",
// or better, I will wrap it in a ScaleTransition or just bigger font style?
// `FloatingDamageText` is a custom widget.
// Let's look at `FloatingDamageText` implementation (it's imported).
// Assuming I can pass a style or it has fixed style.
// Let's just wrap `FloatingDamageText` in a `Transform.scale` with a value.
// I'll define a variable scale.
double scale = 1.0;
// Heuristic: If damage is high (e.g. > 15), assume it might be risky/crit
if (event.damage > 15) scale = 3;
setState(() {
_floatingDamageTexts.add(
DamageTextData(
@ -104,6 +152,8 @@ class _BattleScreenState extends State<BattleScreen> {
widget: Positioned(
left: position.dx,
top: position.dy,
child: Transform.scale(
scale: scale,
child: FloatingDamageText(
key: ValueKey(id),
damage: event.damage.toString(),
@ -118,6 +168,7 @@ class _BattleScreenState extends State<BattleScreen> {
),
),
),
),
);
});
}
@ -168,9 +219,23 @@ class _BattleScreenState extends State<BattleScreen> {
position = position - stackOffset;
}
position =
position +
Offset(renderBox.size.width / 2 - 30, renderBox.size.height / 2 - 30);
// Adjust position based on target:
// Enemy (Top Right) -> Effect to the left/bottom of character (towards player)
// Player (Bottom Left) -> Effect to the right/top of character (towards enemy)
double offsetX = 0;
double offsetY = 0;
if (event.target == EffectTarget.enemy) {
// Enemy is top-right, so effect should be left-bottom of its card
offsetX = renderBox.size.width * 0.1; // 20% from left edge
offsetY = renderBox.size.height * 0.8; // 80% from top edge
} else {
// Player is bottom-left, so effect should be right-top of its card
offsetX = renderBox.size.width * 0.8; // 80% from left edge
offsetY = renderBox.size.height * 0.2; // 20% from top edge
}
position = position + Offset(offsetX, offsetY);
// 0. Prepare Effect Function
void showEffect() {
@ -372,14 +437,34 @@ class _BattleScreenState extends State<BattleScreen> {
// 3. Defend Animation Trigger (Success OR Failure)
else if (event.type == ActionType.defend) {
if (event.target == EffectTarget.player) {
_playerAnimKey.currentState?.animateDefense(() {
setState(() => _isPlayerAttacking = true); // Reuse flag to block input
_playerAnimKey.currentState
?.animateDefense(() {
showEffect();
context.read<BattleProvider>().handleImpact(event);
})
.then((_) {
if (mounted) setState(() => _isPlayerAttacking = false);
});
} else if (event.target == EffectTarget.enemy) {
_enemyAnimKey.currentState?.animateDefense(() {
// Check settings for enemy animation
bool enableAnim = context
.read<SettingsProvider>()
.enableEnemyAnimations;
if (!enableAnim) {
showEffect();
context.read<BattleProvider>().handleImpact(event);
return;
}
setState(() => _isEnemyAttacking = true); // Reuse flag to block input
_enemyAnimKey.currentState
?.animateDefense(() {
showEffect();
context.read<BattleProvider>().handleImpact(event);
})
.then((_) {
if (mounted) setState(() => _isEnemyAttacking = false);
});
} else {
showEffect();
@ -403,7 +488,55 @@ class _BattleScreenState extends State<BattleScreen> {
}
void _showRiskLevelSelection(BuildContext context, ActionType actionType) {
final player = context.read<BattleProvider>().player;
final battleProvider = context.read<BattleProvider>();
final player = battleProvider.player;
// Check turn to reset flag
if (battleProvider.turnCount != _lastTurnCount) {
_lastTurnCount = battleProvider.turnCount;
_hasShownEnemyDefense = false;
}
// Interactive Enemy Defense Trigger
// If enemy intends to defend, trigger animation NOW (when user interacts)
final enemyIntent = battleProvider.currentEnemyIntent;
if (enemyIntent != null &&
enemyIntent.type == EnemyActionType.defend &&
!_hasShownEnemyDefense &&
context.read<SettingsProvider>().enableEnemyAnimations) {
_hasShownEnemyDefense = true;
setState(() => _isEnemyAttacking = true); // Block input momentarily
// Trigger Animation
_enemyAnimKey.currentState
?.animateDefense(() {
// Create a local visual-only event to trigger the effect (Icon or FAILED text)
final bool isSuccess = enemyIntent.isSuccess;
final BattleFeedbackType? feedbackType = isSuccess
? null
: BattleFeedbackType.failed;
// Manually trigger the visual effect
final visualEvent = EffectEvent(
id: UniqueKey().toString(), // Local unique ID
type: ActionType.defend,
risk: enemyIntent.risk,
target: EffectTarget.enemy, // Show on enemy
feedbackType: feedbackType,
attacker: battleProvider.enemy,
targetEntity: battleProvider.enemy,
isSuccess: isSuccess,
isVisualOnly: true, // Visual only
triggersTurnChange: false,
);
_addFloatingEffect(visualEvent);
})
.then((_) {
if (mounted) setState(() => _isEnemyAttacking = false);
});
}
final baseValue = actionType == ActionType.attack
? player.totalAtk
: player.totalDefense;
@ -421,15 +554,21 @@ class _BattleScreenState extends State<BattleScreen> {
switch (risk) {
case RiskLevel.safe:
efficiency = 0.5;
efficiency = actionType == ActionType.attack
? BattleConfig.attackSafeEfficiency
: BattleConfig.defendSafeEfficiency;
infoColor = ThemeConfig.riskSafe;
break;
case RiskLevel.normal:
efficiency = 1.0;
efficiency = actionType == ActionType.attack
? BattleConfig.attackNormalEfficiency
: BattleConfig.defendNormalEfficiency;
infoColor = ThemeConfig.riskNormal;
break;
case RiskLevel.risky:
efficiency = 2.0;
efficiency = actionType == ActionType.attack
? BattleConfig.attackRiskyEfficiency
: BattleConfig.defendRiskyEfficiency;
infoColor = ThemeConfig.riskRisky;
break;
}
@ -443,13 +582,22 @@ class _BattleScreenState extends State<BattleScreen> {
double baseChance = 0.0;
switch (risk) {
case RiskLevel.safe:
baseChance = 1.0;
baseChance = BattleConfig.safeBaseChance;
efficiency = actionType == ActionType.attack
? BattleConfig.attackSafeEfficiency
: BattleConfig.defendSafeEfficiency;
break;
case RiskLevel.normal:
baseChance = 0.8;
baseChance = BattleConfig.normalBaseChance;
efficiency = actionType == ActionType.attack
? BattleConfig.attackNormalEfficiency
: BattleConfig.defendNormalEfficiency;
break;
case RiskLevel.risky:
baseChance = 0.4;
baseChance = BattleConfig.riskyBaseChance;
efficiency = actionType == ActionType.attack
? BattleConfig.attackRiskyEfficiency
: BattleConfig.defendRiskyEfficiency;
break;
}
@ -555,8 +703,8 @@ class _BattleScreenState extends State<BattleScreen> {
children: [
// Enemy (Top Right)
Positioned(
top: 0,
right: 0,
top: 16, // Add some padding from top
right: 16, // Add some padding from right
child: CharacterStatusCard(
character: battleProvider.enemy,
isPlayer: false,
@ -569,7 +717,7 @@ class _BattleScreenState extends State<BattleScreen> {
// Player (Bottom Left)
Positioned(
bottom: 80, // Space for FABs
left: 0,
left: 16, // Add some padding from left
child: CharacterStatusCard(
character: battleProvider.player,
isPlayer: true,
@ -579,13 +727,12 @@ class _BattleScreenState extends State<BattleScreen> {
hideStats: _isPlayerAttacking,
),
),
],
),
),
),
],
),
], // Close children list
), // Close Stack
), // Close Padding
), // Close Expanded
], // Close Column
), // Close Column
// 3. Logs Overlay
if (_showLogs && battleProvider.logs.isNotEmpty)
Positioned(
@ -611,7 +758,9 @@ class _BattleScreenState extends State<BattleScreen> {
battleProvider.isPlayerTurn &&
!battleProvider.player.isDead &&
!battleProvider.enemy.isDead &&
!battleProvider.showRewardPopup,
!battleProvider.showRewardPopup &&
!_isPlayerAttacking &&
!_isEnemyAttacking,
),
const SizedBox(height: 16),
_buildFloatingActionButton(
@ -622,7 +771,9 @@ class _BattleScreenState extends State<BattleScreen> {
battleProvider.isPlayerTurn &&
!battleProvider.player.isDead &&
!battleProvider.enemy.isDead &&
!battleProvider.showRewardPopup,
!battleProvider.showRewardPopup &&
!_isPlayerAttacking &&
!_isEnemyAttacking,
),
],
),

View File

@ -90,21 +90,22 @@ class CharacterStatusCard extends StatelessWidget {
}).toList(),
),
),
Text(
"ATK: ${character.totalAtk}",
style: const TextStyle(color: ThemeConfig.textColorWhite),
),
Text(
"DEF: ${character.totalDefense}",
style: const TextStyle(color: ThemeConfig.textColorWhite),
),
Text(
"LUCK: ${character.totalLuck}",
style: const TextStyle(color: ThemeConfig.textColorWhite),
),
// Text(
// "ATK: ${character.totalAtk}",
// style: const TextStyle(color: ThemeConfig.textColorWhite),
// ),
// Text(
// "DEF: ${character.totalDefense}",
// style: const TextStyle(color: ThemeConfig.textColorWhite),
// ),
// Text(
// "LUCK: ${character.totalLuck}",
// style: const TextStyle(color: ThemeConfig.textColorWhite),
// ),
],
),
),
const SizedBox(height: 8), //
// /
BattleAnimationWidget(
key: animationKey,
@ -132,7 +133,7 @@ class CharacterStatusCard extends StatelessWidget {
),
),
),
const SizedBox(height: 8), //
// const SizedBox(height: 8), //
if (!isPlayer && !hideStats)
Consumer<BattleProvider>(
builder: (context, provider, child) {
@ -149,14 +150,14 @@ class CharacterStatusCard extends StatelessWidget {
),
child: Column(
children: [
Text(
"INTENT",
style: TextStyle(
color: ThemeConfig.enemyIntentBorder,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
// Text(
// "INTENT",
// style: TextStyle(
// color: ThemeConfig.enemyIntentBorder,
// fontSize: 10,
// fontWeight: FontWeight.bold,
// ),
// ),
Row(
mainAxisSize: MainAxisSize.min,
children: [
@ -168,13 +169,20 @@ class CharacterStatusCard extends StatelessWidget {
size: 16,
),
const SizedBox(width: 4),
Text(
Flexible(
// Use Flexible to allow text to shrink
child: FittedBox(
fit:
BoxFit.scaleDown, // Shrink text if too long
child: Text(
intent.description,
style: const TextStyle(
color: ThemeConfig.textColorWhite,
fontSize: 12,
),
),
),
),
],
),
],

View File

@ -35,11 +35,11 @@
- **적 인공지능 (Enemy AI & Intent):**
- **Intent UI:** 적의 다음 행동(공격/방어, 데미지/방어도) 미리 표시.
- **동기화된 애니메이션:** 적 행동 결정(`_generateEnemyIntent`)은 이전 애니메이션이 완전히 끝난 후 이루어짐.
- **선제 방어:** 적이 방어 행동을 선택하면 턴 시작 시 즉시 방어도가 적용됨.
- **선제 방어:** 적이 방어 행동을 선택하면 턴 시작 시 **데이터상으로 즉시 방어도가 적용되나, 시각적 애니메이션은 플레이어가 행동을 선택하는 시점에 발동됨.**
- **애니메이션 및 타격감 (Visuals & Impact):**
- **UI 주도 Impact 처리:** 애니메이션 타격 시점(`onImpact`)에 정확히 데미지가 적용되고 텍스트가 뜸 (완벽한 동기화).
- **적 돌진:** 적도 공격 시 플레이어 위치로 돌진함 (설정에서 끄기 가능).
- **이펙트:** 타격 아이콘, 데미지 텍스트(Floating Text), 화면 흔들림(`ShakeWidget`), 폭발(`ExplosionWidget`).
- **이펙트:** 타격 아이콘, 데미지 텍스트(Floating Text, Risky 공격 시 크기 확대), 화면 흔들림(`ShakeWidget`), 폭발(`ExplosionWidget`). **적 방어 시 성공/실패 이펙트 추가.**
- **상태이상:** `Stun`, `Bleed`, `Vulnerable`, `DefenseForbidden`.
### C. 데이터 및 로직 (Architecture)
@ -47,12 +47,12 @@
- **Data-Driven:** `items.json`, `enemies.json`, `players.json`.
- **Logic 분리:**
- `BattleProvider`: UI 상태 관리 및 이벤트 스트림(`damageStream`, `effectStream`) 발송.
- `CombatCalculator`: 데미지 공식, 확률 계산, 상태이상 로직 순수 함수화.
- `CombatCalculator`: 데미지 공식, 확률 계산, 상태이상 로직 순수 함수화. **공격/방어 액션 타입별 효율 분리.**
- `BattleLogManager`: 전투 로그 관리.
- `LootGenerator`: 아이템 생성, 접두사(Prefix) 부여, 랜덤 스탯 로직.
- `SettingsProvider`: 전역 설정(애니메이션 on/off 등) 관리 및 영구 저장.
- **Soft i18n:** UI 텍스트는 `lib/game/config/app_strings.dart`에서 통합 관리.
- **Config:** `GameConfig`, `BattleConfig`, `ItemConfig` 등 설정 값 중앙화.
- **Config:** `GameConfig`, `BattleConfig`, `ItemConfig` 등 설정 값 중앙화. **BattleConfig의 공격/방어 효율 분리.**
### D. 아이템 및 경제
@ -82,6 +82,11 @@
- **[Refactor] Animation Sync:** `Future.delayed` 예측 방식을 버리고, UI 애니메이션 콜백(`onImpact`)을 통해 로직을 트리거하는 방식으로 변경하여 타격감 동기화 해결.
- **[Refactor] Settings:** `SettingsProvider` 도입 및 적 애니메이션 토글 기능 추가.
- **[Fix] Bugs:** 아이템 이름 생성 오류 수정, 리워드 팝업 깜빡임 및 중복 생성 수정, 앱 크래시(Null Safety) 수정.
- **[Feature] Interactive Enemy Defense Animation:** 플레이어 행동(버튼 클릭) 시점에 적 방어 애니메이션 및 이펙트(`Icon`/`FAILED` 텍스트)가 발동되도록 구현. (데이터는 턴 시작 시 선적용)
- **[Improvement] Turn Responsiveness:** 적 턴 종료 후 플레이어 턴 활성화까지의 불필요한 딜레이 제거 (`_processMiddleTurn`).
- **[Improvement] Visual Impact:** Risky 공격 및 높은 데미지 시 Floating Damage Text의 크기 확대. Floating Effect/Feedback Text의 위치 조정.
- **[Refactor] Balancing System:** `BattleConfig`에서 공격/방어 효율 상수를 분리하고 `CombatCalculator` 및 관련 로직에 적용하여 밸런싱의 유연성 확보.
- **[Fix] UI Stability:** `CharacterStatusCard`의 Intent UI 텍스트 길이에 따른 레이아웃 흔들림 방지 (`FittedBox`). `BattleScreen``Stack` 위젯 구성 문법 오류 수정.
## 5. 다음 단계 (Next Steps)