Fix: Delay Enemy Intent generation to allow attack animation to finish

This commit is contained in:
Horoli 2025-12-07 21:28:33 +09:00
parent 0b48aea16d
commit fef803d064
9 changed files with 421 additions and 248 deletions

View File

@ -25,9 +25,9 @@ class GameConfig {
static const int goldRewardVariance = 10;
// Animations (Duration in milliseconds)
static const int animDelaySafe = 500;
static const int animDelayNormal = 400;
static const int animDelayRisky = 1100;
static const int animDelaySafe = 600; // 500 + 100 buffer
static const int animDelayNormal = 500; // 400 + 100 buffer
static const int animDelayRisky = 1200; // 1100 + 100 buffer
static const int animDelayEnemyTurn = 1000;
// Save System

View File

@ -1,4 +1,5 @@
import '../enums.dart';
import 'entity.dart'; // Import Character entity
enum EffectTarget { player, enemy }
@ -9,11 +10,23 @@ class EffectEvent {
final EffectTarget target; //
final BattleFeedbackType? feedbackType; //
// New fields for impact logic
final Character? attacker;
final Character? targetEntity; //
final int? damageValue; //
final bool? isSuccess; // (Missed or Failed가 )
final int? armorGained; //
EffectEvent({
required this.id,
required this.type,
required this.risk,
required this.target,
this.feedbackType, // feedbackType
this.attacker,
this.targetEntity,
this.damageValue,
this.isSuccess,
this.armorGained,
});
}

View File

@ -203,7 +203,10 @@ class BattleProvider with ChangeNotifier {
if (type == StageType.battle || type == StageType.elite) {
bool isElite = type == StageType.elite;
EnemyTemplate template = EnemyTable.getRandomEnemy(stage: stage, isElite: isElite);
EnemyTemplate template = EnemyTable.getRandomEnemy(
stage: stage,
isElite: isElite,
);
newEnemy = template.createCharacter(stage: stage);
@ -251,7 +254,6 @@ class BattleProvider with ChangeNotifier {
}
/// Handle player's action choice
Future<void> playerAction(ActionType type, RiskLevel risk) async {
if (!isPlayerTurn || player.isDead || enemy.isDead || showRewardPopup)
return;
@ -287,93 +289,39 @@ class BattleProvider with ChangeNotifier {
_addLog("Player chose to ${type.name} with ${risk.name} risk.");
// Calculate Outcome using CombatCalculator
int baseValue = (type == ActionType.attack) ? player.totalAtk : player.totalDefense;
int baseValue = (type == ActionType.attack)
? player.totalAtk
: player.totalDefense;
final result = CombatCalculator.calculateActionOutcome(
risk: risk,
luck: player.totalLuck,
baseValue: baseValue
baseValue: baseValue,
);
if (result.success) {
if (type == ActionType.attack) {
int damage = result.value;
final eventId =
final event = EffectEvent(
id:
DateTime.now().millisecondsSinceEpoch.toString() +
Random().nextInt(1000).toString();
_effectEventController.sink.add(
EffectEvent(
id: eventId,
Random().nextInt(1000).toString(),
type: ActionType.attack,
risk: risk,
target: EffectTarget.enemy,
feedbackType: null,
),
attacker: player,
targetEntity: enemy,
damageValue: damage,
isSuccess: true,
);
// 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.");
}
}
enemy.armor = remainingArmor;
if (damageToHp > 0) {
// 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
_tryApplyStatusEffects(player, enemy);
} else {
// Defense Success
_effectEventController.sink.add(
EffectEvent(
event,
); // No Future.delayed here, BattleScreen will trigger impact
} else {
// Defense Success - Impact is immediate, so process it directly
final event = EffectEvent(
id:
DateTime.now().millisecondsSinceEpoch.toString() +
Random().nextInt(1000).toString(),
@ -381,45 +329,49 @@ class BattleProvider with ChangeNotifier {
risk: risk,
target: EffectTarget.player,
feedbackType: null,
),
targetEntity: player, // player is target for defense
armorGained: result.value,
attacker: player, // player is attacker in this context
isSuccess: true,
);
int armorGained = result.value;
player.armor += armorGained;
_addLog("Player gained $armorGained armor.");
_effectEventController.sink.add(event);
handleImpact(event); // Process impact via handleImpact for safety
}
} else {
// Failure
if (type == ActionType.attack) {
_addLog("Player's attack missed!");
_effectEventController.sink.add(
EffectEvent(
id:
final eventId =
DateTime.now().millisecondsSinceEpoch.toString() +
Random().nextInt(1000).toString(),
Random().nextInt(1000).toString();
BattleFeedbackType feedbackType = (type == ActionType.attack)
? BattleFeedbackType.miss
: BattleFeedbackType.failed;
EffectTarget eventTarget = (type == ActionType.attack)
? EffectTarget.enemy
: EffectTarget.player;
Character eventTargetEntity = (type == ActionType.attack)
? enemy
: player;
final event = EffectEvent(
id: eventId,
type: type,
risk: risk,
target: EffectTarget.enemy,
feedbackType: BattleFeedbackType.miss,
),
target: eventTarget,
feedbackType: feedbackType,
attacker: player,
targetEntity: eventTargetEntity,
isSuccess: false,
);
} else {
_addLog("Player's defense failed!");
_effectEventController.sink.add(
EffectEvent(
id:
DateTime.now().millisecondsSinceEpoch.toString() +
Random().nextInt(1000).toString(),
type: type,
risk: risk,
target: EffectTarget.player,
feedbackType: BattleFeedbackType.failed,
),
);
}
event,
); // Send event for miss/fail feedback
_addLog("${player.name}'s ${type.name} ${feedbackType.name}!");
handleImpact(event); // Process impact via handleImpact for safety
}
// Now check for enemy death (if applicable from bleed, or previous impacts)
if (enemy.isDead) {
// Check enemy death after player's action
_onVictory();
return;
}
@ -465,92 +417,83 @@ class BattleProvider with ChangeNotifier {
if (enemy.isDead) {
_onVictory();
return;
// return; // Already handled by _processStartTurnEffects if damage applied
}
if (canAct && currentEnemyIntent != null) {
final intent = currentEnemyIntent!;
if (intent.type == EnemyActionType.defend) {
// Already handled in _generateEnemyIntent
_addLog("Enemy maintains defensive stance.");
// Apply defense immediately if successful, then send event
if (intent.isSuccess) {
final event = EffectEvent(
id:
DateTime.now().millisecondsSinceEpoch.toString() +
Random().nextInt(1000).toString(),
type: ActionType.defend,
risk: intent.risk,
target: EffectTarget.enemy,
feedbackType: null,
attacker: enemy,
targetEntity: enemy,
armorGained: intent.finalValue,
isSuccess: true,
);
_effectEventController.sink.add(event);
handleImpact(event); // Process impact via handleImpact for safety
} else {
_addLog("Enemy tried to defend but fumbled!");
final event = EffectEvent(
id:
DateTime.now().millisecondsSinceEpoch.toString() +
Random().nextInt(1000).toString(),
type: ActionType.defend,
risk: intent.risk,
target: EffectTarget.enemy,
feedbackType:
BattleFeedbackType.failed, // Feedback type for failed defense
attacker: enemy,
targetEntity: enemy,
isSuccess: false,
);
_effectEventController.sink.add(event);
handleImpact(event); // Process impact via handleImpact for safety
}
} else {
// Attack Logic
if (intent.isSuccess) {
_effectEventController.sink.add(
EffectEvent(
final event = EffectEvent(
id:
DateTime.now().millisecondsSinceEpoch.toString() +
Random().nextInt(1000).toString(),
type: ActionType.attack,
risk: intent.risk,
target: EffectTarget.player,
feedbackType: null, // feedbackType
),
feedbackType: null,
attacker: enemy,
targetEntity: player,
damageValue: intent.finalValue,
isSuccess: true,
);
// Fix: Wait for animation to play before applying damage
// Determine delay based on risk level
int delay = GameConfig.animDelayNormal;
if (intent.risk == RiskLevel.safe) delay = GameConfig.animDelaySafe;
if (intent.risk == RiskLevel.risky) delay = GameConfig.animDelayRisky;
await Future.delayed(Duration(milliseconds: delay));
int incomingDamage = intent.finalValue;
// 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) {
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.");
}
// Try applying status effects from enemy equipment
_tryApplyStatusEffects(enemy, player);
_effectEventController.sink.add(event);
// No Future.delayed here, BattleScreen will trigger impact
} else {
_addLog("Enemy's ${intent.risk.name} attack missed!");
_effectEventController.sink.add(
EffectEvent(
final event = EffectEvent(
id:
DateTime.now().millisecondsSinceEpoch.toString() +
Random().nextInt(1000).toString(),
type: ActionType.attack, // ActionType.attack
type: ActionType.attack,
risk: intent.risk,
target: EffectTarget.player, //
feedbackType: BattleFeedbackType.miss, // : MISS
),
target: EffectTarget.player,
feedbackType: BattleFeedbackType.miss,
attacker: enemy,
targetEntity: player,
isSuccess: false,
);
_effectEventController.sink.add(
event,
); // Send event for miss feedback
handleImpact(event); // Process impact via handleImpact for safety
}
}
} else if (!canAct) {
@ -559,6 +502,26 @@ class BattleProvider with ChangeNotifier {
_addLog("Enemy did nothing.");
}
// Wait for potential animations to finish before generating next intent
// If attacking, we need to wait for the attack animation + return
if (currentEnemyIntent?.type == EnemyActionType.attack &&
currentEnemyIntent?.isSuccess == true) {
int animDelay = GameConfig.animDelayNormal;
if (currentEnemyIntent!.risk == RiskLevel.safe)
animDelay = GameConfig.animDelaySafe;
if (currentEnemyIntent!.risk == RiskLevel.risky)
animDelay = GameConfig.animDelayRisky;
// Wait for impact (handled by UI) + Return time + small buffer
// Since we removed the pre-impact delay, the UI animation starts immediately.
// We want to generate intent AFTER the full animation cycle.
// Full cycle ~= 2 * animDelay (Forward + Reverse)
await Future.delayed(Duration(milliseconds: animDelay));
} else {
// For non-animating actions, a small pause is nice for pacing
await Future.delayed(const Duration(milliseconds: 500));
}
// Generate next intent
if (!player.isDead) {
_generateEnemyIntent();
@ -581,50 +544,6 @@ class BattleProvider with ChangeNotifier {
notifyListeners();
}
/// 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) {
final result = CombatCalculator.processStartTurnEffects(character);
int totalBleed = result['bleedDamage'];
bool isStunned = result['isStunned'];
// 1. Bleed 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
_damageEventController.sink.add(
DamageEvent(
damage: totalBleed,
target: (character == player) ? DamageTarget.player : DamageTarget.enemy,
type: DamageType.bleed,
),
);
}
// 2. Stun Check
if (isStunned) {
_addLog("${character.name} is stunned!");
}
return !isStunned;
}
/// Tries to apply status effects from attacker's equipment to the target.
void _tryApplyStatusEffects(Character attacker, Character target) {
List<StatusEffect> effectsToApply = CombatCalculator.getAppliedEffects(attacker);
for (var effect in effectsToApply) {
target.addStatusEffect(effect);
_addLog("Applied ${effect.type.name} to ${target.name}!");
}
}
void _addLog(String message) {
_logManager.addLog(message);
notifyListeners();
@ -634,7 +553,10 @@ class BattleProvider with ChangeNotifier {
// Calculate Gold Reward
// Base 10 + (Stage * 5) + Random variance
final random = Random();
int goldReward = GameConfig.baseGoldReward + (stage * GameConfig.goldRewardPerStage) + random.nextInt(GameConfig.goldRewardVariance);
int goldReward =
GameConfig.baseGoldReward +
(stage * GameConfig.goldRewardPerStage) +
random.nextInt(GameConfig.goldRewardVariance);
player.gold += goldReward;
_lastGoldReward = goldReward; // Store for UI display
@ -662,7 +584,8 @@ class BattleProvider with ChangeNotifier {
if (isTier1) {
// Tier 1 Elite: Guaranteed Rare
minRarity = ItemRarity.rare;
maxRarity = ItemRarity.rare; // Or allow higher? Request said "Guaranteed Rare 1 drop". Let's fix to Rare.
maxRarity = ItemRarity
.rare; // Or allow higher? Request said "Guaranteed Rare 1 drop". Let's fix to Rare.
} else {
// Tier 2/3 Elite: Guaranteed Legendary
minRarity = ItemRarity.legendary;
@ -681,7 +604,7 @@ class BattleProvider with ChangeNotifier {
ItemTemplate? item = ItemTable.getRandomItem(
tier: currentTier,
minRarity: minRarity,
maxRarity: maxRarity
maxRarity: maxRarity,
);
if (item != null) {
@ -905,4 +828,145 @@ class BattleProvider with ChangeNotifier {
}
notifyListeners();
}
// New public method to be called by UI at impact moment
void handleImpact(EffectEvent event) {
if (event.isSuccess == false || event.feedbackType != null) {
// If it's a miss/fail/feedback, just log and return
// Logging and feedback text should already be handled when event created
notifyListeners(); // Ensure UI updates for log
return;
}
// Only process actual attack or defend impacts here
_processAttackImpact(event);
}
// Refactored common attack impact logic
void _processAttackImpact(EffectEvent event) {
final attacker = event.attacker!;
final target = event.targetEntity!;
// Attack type needs detailed damage calculation
if (event.type == ActionType.attack) {
int incomingDamage = event.damageValue!;
// Calculate Damage to HP using CombatCalculator
int damageToHp = CombatCalculator.calculateDamageToHp(
incomingDamage: incomingDamage,
currentArmor: target.armor,
isVulnerable: target.hasStatus(StatusEffectType.vulnerable),
);
// Calculate Remaining Armor
int remainingArmor = CombatCalculator.calculateRemainingArmor(
incomingDamage: incomingDamage,
currentArmor: target.armor,
isVulnerable: target.hasStatus(StatusEffectType.vulnerable),
);
// Log details
if (target.armor > 0) {
int absorbed = target.armor - remainingArmor;
if (damageToHp == 0) {
_addLog("${target.name}'s armor absorbed all damage.");
} else {
_addLog("${target.name}'s armor absorbed $absorbed damage.");
}
}
target.armor = remainingArmor;
if (damageToHp > 0) {
target.hp -= damageToHp;
if (target.hp < 0) target.hp = 0;
_damageEventController.sink.add(
DamageEvent(
damage: damageToHp,
target: (target == player)
? DamageTarget.player
: DamageTarget.enemy,
type: target.hasStatus(StatusEffectType.vulnerable)
? DamageType.vulnerable
: DamageType.normal,
),
);
_addLog("${attacker.name} dealt $damageToHp damage to ${target.name}.");
} else {
_addLog("${attacker.name}'s attack was fully blocked by armor.");
}
// Try applying status effects
_tryApplyStatusEffects(attacker, target);
} else if (event.type == ActionType.defend) {
// Defense Impact is immediate (no anim delay from UI)
if (event.isSuccess!) {
// Check success again for clarity
int armorGained = event.armorGained!;
target.armor += armorGained;
_addLog("${target.name} gained $armorGained armor.");
} else {
// Failed Defense
_addLog("${target.name}'s defense failed!");
}
}
// Check for death after impact
if (target.isDead) {
if (target == player) {
_onDefeat();
} else {
_onVictory();
}
}
notifyListeners();
}
/// 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) {
final result = CombatCalculator.processStartTurnEffects(character);
int totalBleed = result['bleedDamage'];
bool isStunned = result['isStunned'];
// 1. Bleed 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
_damageEventController.sink.add(
DamageEvent(
damage: totalBleed,
target: (character == player)
? DamageTarget.player
: DamageTarget.enemy,
type: DamageType.bleed,
),
);
}
// 2. Stun Check
if (isStunned) {
_addLog("${character.name} is stunned!");
}
return !isStunned;
}
/// Tries to apply status effects from attacker's equipment to the target.
void _tryApplyStatusEffects(Character attacker, Character target) {
List<StatusEffect> effectsToApply = CombatCalculator.getAppliedEffects(
attacker,
);
for (var effect in effectsToApply) {
target.addStatusEffect(effect);
_addLog("Applied ${effect.type.name} to ${target.name}!");
}
}
/// Process effects that happen at the start of the turn (Bleed, Stun).
}

View File

@ -264,6 +264,9 @@ class _BattleScreenState extends State<BattleScreen> {
_playerAnimKey.currentState
?.animateAttack(offset, () {
showEffect(); // Show Effect at Impact!
// Trigger impact logic in provider
context.read<BattleProvider>().handleImpact(event);
// Shake and Explosion ONLY for Risky
if (event.risk == RiskLevel.risky) {
_shakeKey.currentState?.shake();
@ -300,6 +303,7 @@ class _BattleScreenState extends State<BattleScreen> {
if (!enableAnim) {
showEffect(); // Just show effect if anim disabled
context.read<BattleProvider>().handleImpact(event); // Process impact immediately if anim disabled
return;
}
@ -324,6 +328,8 @@ class _BattleScreenState extends State<BattleScreen> {
_enemyAnimKey.currentState
?.animateAttack(offset, () {
showEffect(); // Show Effect at Impact!
// Trigger impact logic in provider
context.read<BattleProvider>().handleImpact(event);
// Shake and Explosion ONLY for Risky (Enemy can also do risky attacks)
if (event.risk == RiskLevel.risky) {
@ -355,6 +361,8 @@ class _BattleScreenState extends State<BattleScreen> {
} else {
// Not a player/enemy attack movement, show immediately
showEffect();
// Also process impact immediately for non-animating effects (e.g., failed defense, stun)
context.read<BattleProvider>().handleImpact(event);
}
});
}

View File

@ -0,0 +1,20 @@
# 68. Global Animation Delay Adjustment
## 1. 목표 (Goal)
- 플레이어와 적 모두에게 적용되는 애니메이션 딜레이 설정(`GameConfig`)을 조정하여, 데미지 텍스트가 애니메이션 타격(Impact)보다 먼저 뜨는 현상을 근본적으로 해결합니다.
## 2. 원인 (Cause)
- `GameConfig`의 대기 시간이 `BattleAnimationWidget`의 재생 시간과 **완벽하게 동일(ms 단위)**하게 설정되어 있습니다.
- 코드 실행 컨텍스트 차이로 인해 `Timer`(로직 대기)가 `Animation`(UI 렌더링)보다 미세하게 먼저 완료될 수 있어, 텍스트가 먼저 뜨는 "경쟁 상태(Race Condition)"가 발생합니다.
## 3. 해결 방안 (Solution)
- `GameConfig``animDelay...` 값들을 기존 값에서 **+100ms** 증가시킵니다.
- 이는 플레이어와 적 모두에게 동일하게 적용되므로 로직의 일관성을 해치지 않으면서, 시각적으로 "타격 후 텍스트"라는 자연스러운 순서를 보장합니다.
## 4. 변경 값
- Safe: 500 -> 600
- Normal: 400 -> 500
- Risky: 1100 -> 1200
## 5. 기대 효과 (Expected Outcome)
- 플레이어와 적 모두 공격 시 애니메이션이 목표에 도달한 직후 데미지 텍스트가 표시됨.

View File

@ -0,0 +1,29 @@
# 69. Sync Damage to Animation Impact (UI-Driven)
## 1. 목표 (Goal)
- 플레이어와 적 모두의 공격 시 데미지 텍스트 출력이 애니메이션 타격(Impact) 순간과 완벽하게 동기화되도록 수정합니다.
## 2. 문제점 (Problem)
- 기존에는 `BattleProvider``Future.delayed`로 애니메이션 시간을 예측했으나, 이는 UI의 실제 Impact 시점과 미묘하게 어긋나 데미지 텍스트가 먼저 뜨는 현상이 발생했습니다.
## 3. 해결 방안 (Solution)
- **UI (BattleScreen)가 애니메이션 Impact 시점을 `BattleProvider`에게 직접 알려주어 데미지 처리를 트리거**하는 "UI 주도 Impact 처리" 방식으로 전환합니다.
## 4. 구현 계획 (Implementation Plan)
### A. `EffectEvent` 데이터 확장 (`lib/game/model/effect_event.dart`)
- `EffectEvent``attacker`, `target` (Character 객체), `damage`, `risk`, `isSuccess` 등 Impact 시점에 필요한 모든 정보를 담습니다.
### B. `BattleProvider` 수정 (`lib/providers/battle_provider.dart`)
1. **`Future.delayed` 제거:** `playerAction``_enemyTurn`의 공격 로직에서 `await Future.delayed(...)`를 제거합니다.
2. **데미지 처리 로직 추출:** 공격 Impact 시점에 발생해야 할 모든 로직(HP 감소, `DamageEvent` 전송, 로그 기록, 상태이상 적용)을 `_processAttackImpact`라는 `private` 또는 `public` 메서드로 추출합니다.
3. **새로운 `public` 메서드 추가:** `BattleProvider.handleAttackImpact(String eventId, Character attacker, Character target, int damage, RiskLevel risk, bool isSuccess)`와 같은 메서드를 만들어, `BattleScreen`에서 Impact 시점에 호출하도록 합니다.
### C. `BattleScreen` 수정 (`lib/screens/battle_screen.dart`)
1. `_addFloatingEffect` 메서드 내에서 `animateAttack`를 호출할 때:
- `onImpact` 콜백 내에서 `context.read<BattleProvider>().handleAttackImpact(...)`를 호출합니다.
- `EffectEvent`에 담긴 정보를 기반으로 `handleAttackImpact`에 인자를 전달합니다.
## 5. 기대 효과 (Expected Outcome)
- 애니메이션과 데미지 텍스트 출력이 완벽하게 동기화되어, 게임의 타격감이 대폭 향상됩니다.
- 로직과 UI 간의 역할 분리가 명확해집니다.

View File

@ -0,0 +1,16 @@
# 70. Fix Player Action Crash and Double Damage
## 1. 문제 (Problem)
- **Crash:** `BattleProvider.playerAction`에서 생성하는 `EffectEvent``attacker`, `targetEntity`, `damageValue` 등이 누락되어 있어, UI가 `handleImpact`를 호출할 때 `event.attacker!` 등에서 Null Pointer Exception이 발생함.
- **Double Damage:** `playerAction` 메서드 내부에 구버전의 데미지 적용 로직이 남아 있어, 이를 수정하지 않으면 데미지가 두 번 들어감.
## 2. 해결 방안 (Solution)
- `playerAction` 메서드를 대대적으로 수정하여 `_enemyTurn`과 동일한 패턴을 적용합니다.
- **즉시 데미지 적용 로직 삭제:** HP/Armor 감소 로직을 제거합니다.
- **`EffectEvent` 완전체 생성:** `attacker`, `targetEntity`, `damageValue` 등을 모두 채워서 이벤트를 생성합니다.
- **위임:** 모든 데미지 처리는 `EffectEvent`를 통해 `handleImpact` -> `_processAttackImpact`로 위임됩니다.
## 3. 기대 효과 (Expected Outcome)
- 앱 크래시 해결.
- 플레이어 공격 시 애니메이션과 데미지 적용 시점이 정확히 일치.
- 데미지 중복 적용 방지.

View File

@ -0,0 +1,12 @@
# 71. Fix Null Pointer Exception on Failed Actions
## 1. 문제 (Problem)
- `BattleProvider`에서 공격 실패(`Miss`) 또는 방어(`Defend`) 시 `_processAttackImpact`를 직접 호출하고 있음.
- `_processAttackImpact``damageValue!`와 같이 강제 언래핑을 수행하므로, 실패한 공격(`damageValue`가 null)일 경우 앱이 크래시됨(`Unexpected null value`).
## 2. 해결 방안 (Solution)
- `playerAction``_enemyTurn` 메서드에서 `_processAttackImpact` 직접 호출을 모두 `handleImpact(event)` 호출로 변경합니다.
- `handleImpact` 메서드 내부에 있는 `if (!event.isSuccess!) return;` 안전 장치를 활용하여 크래시를 방지합니다.
## 3. 기대 효과 (Expected Outcome)
- 공격 실패 시에도 앱이 정상적으로 동작하며, 로그만 출력되고 데미지 로직은 스킵됨.

View File

@ -0,0 +1,11 @@
# 72. Delay Enemy Intent Generation
## 1. 문제 (Problem)
- 적이 공격하는 도중에 `_generateEnemyIntent()`가 호출되어, 다음 턴 행동(예: 방어)이 미리 실행되고 이펙트가 겹쳐 보이는 현상 발생.
## 2. 해결 방안 (Solution)
- `BattleProvider._enemyTurn` 메서드에서 `_generateEnemyIntent()` 호출 전에 **충분한 대기 시간(`GameConfig.animDelayEnemyTurn` 또는 공격 애니메이션 시간)**을 둡니다.
- 이를 통해 적의 현재 행동 연출이 완전히 끝난 뒤에 다음 행동을 준비하도록 순서를 정리합니다.
## 3. 기대 효과 (Expected Outcome)
- 적 공격 -> 복귀 -> (잠시 후) -> 다음 턴 방어 준비(이펙트) 순서로 자연스럽게 진행됨.