From fef803d064311a0c602e6275fed40708c3e93d71 Mon Sep 17 00:00:00 2001 From: Horoli Date: Sun, 7 Dec 2025 21:28:33 +0900 Subject: [PATCH] Fix: Delay Enemy Intent generation to allow attack animation to finish --- lib/game/config/game_config.dart | 6 +- lib/game/model/effect_event.dart | 13 + lib/providers/battle_provider.dart | 554 +++++++++++++++------------ lib/screens/battle_screen.dart | 8 + prompt/68_adjust_game_config.md | 20 + prompt/69_sync_damage_on_impact.md | 29 ++ prompt/70_fix_player_action_crash.md | 16 + prompt/71_fix_null_error.md | 12 + prompt/72_delay_enemy_intent.md | 11 + 9 files changed, 421 insertions(+), 248 deletions(-) create mode 100644 prompt/68_adjust_game_config.md create mode 100644 prompt/69_sync_damage_on_impact.md create mode 100644 prompt/70_fix_player_action_crash.md create mode 100644 prompt/71_fix_null_error.md create mode 100644 prompt/72_delay_enemy_intent.md diff --git a/lib/game/config/game_config.dart b/lib/game/config/game_config.dart index 1b0cfdf..6d10479 100644 --- a/lib/game/config/game_config.dart +++ b/lib/game/config/game_config.dart @@ -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 diff --git a/lib/game/model/effect_event.dart b/lib/game/model/effect_event.dart index 8b39497..31f77b3 100644 --- a/lib/game/model/effect_event.dart +++ b/lib/game/model/effect_event.dart @@ -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, }); } diff --git a/lib/providers/battle_provider.dart b/lib/providers/battle_provider.dart index 90516dd..ac642a2 100644 --- a/lib/providers/battle_provider.dart +++ b/lib/providers/battle_provider.dart @@ -202,8 +202,11 @@ 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 playerAction(ActionType type, RiskLevel risk) async { if (!isPlayerTurn || player.isDead || enemy.isDead || showRewardPopup) return; @@ -273,7 +275,7 @@ class BattleProvider with ChangeNotifier { // 2. Process Start-of-Turn Effects (Stun, Bleed) bool canAct = _processStartTurnEffects(player); - + if (player.isDead) { await _onDefeat(); return; @@ -287,139 +289,89 @@ 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 + risk: risk, + luck: player.totalLuck, + baseValue: baseValue, ); if (result.success) { if (type == ActionType.attack) { int damage = result.value; - final eventId = - DateTime.now().millisecondsSinceEpoch.toString() + - Random().nextInt(1000).toString(); + final event = EffectEvent( + id: + DateTime.now().millisecondsSinceEpoch.toString() + + Random().nextInt(1000).toString(), + type: ActionType.attack, + risk: risk, + target: EffectTarget.enemy, + feedbackType: null, + attacker: player, + targetEntity: enemy, + damageValue: damage, + isSuccess: true, + ); _effectEventController.sink.add( - EffectEvent( - id: eventId, - type: ActionType.attack, - risk: risk, - target: EffectTarget.enemy, - feedbackType: null, - ), - ); - - // 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); + event, + ); // No Future.delayed here, BattleScreen will trigger impact } else { - // Defense Success - _effectEventController.sink.add( - EffectEvent( - id: - DateTime.now().millisecondsSinceEpoch.toString() + - Random().nextInt(1000).toString(), - type: ActionType.defend, - risk: risk, - target: EffectTarget.player, - feedbackType: null, - ), + // Defense Success - Impact is immediate, so process it directly + final event = EffectEvent( + id: + DateTime.now().millisecondsSinceEpoch.toString() + + Random().nextInt(1000).toString(), + type: ActionType.defend, + 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: - DateTime.now().millisecondsSinceEpoch.toString() + - Random().nextInt(1000).toString(), - type: type, - risk: risk, - target: EffectTarget.enemy, - feedbackType: BattleFeedbackType.miss, - ), - ); - } 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, - ), - ); - } + final eventId = + DateTime.now().millisecondsSinceEpoch.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: eventTarget, + feedbackType: feedbackType, + attacker: player, + targetEntity: eventTargetEntity, + isSuccess: false, + ); + _effectEventController.sink.add( + 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( - id: - DateTime.now().millisecondsSinceEpoch.toString() + - Random().nextInt(1000).toString(), - type: ActionType.attack, - risk: intent.risk, - target: EffectTarget.player, - feedbackType: null, // 공격 성공이므로 feedbackType 없음 - ), + final event = EffectEvent( + id: + DateTime.now().millisecondsSinceEpoch.toString() + + Random().nextInt(1000).toString(), + type: ActionType.attack, + risk: intent.risk, + target: EffectTarget.player, + 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( - id: - DateTime.now().millisecondsSinceEpoch.toString() + - Random().nextInt(1000).toString(), - type: ActionType.attack, // 적의 공격이므로 ActionType.attack - risk: intent.risk, - target: EffectTarget.player, // 플레이어가 회피했으므로 플레이어 위치에 이펙트 - feedbackType: BattleFeedbackType.miss, // 변경: MISS 피드백 - ), + final event = EffectEvent( + id: + DateTime.now().millisecondsSinceEpoch.toString() + + Random().nextInt(1000).toString(), + type: ActionType.attack, + risk: intent.risk, + 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 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 @@ -648,7 +570,7 @@ class BattleProvider with ChangeNotifier { currentTier = ItemTier.tier2; rewardOptions = []; - + bool isElite = currentStage.type == StageType.elite; bool isTier1 = currentTier == ItemTier.tier1; @@ -662,13 +584,14 @@ 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; // maxRarity = ItemRarity.legendary; // Optional, but let's allow Unique too if weights permit, or fix to Legendary. Request said "Guaranteed Legendary". } - } + } // 2. Standard Reward Logic (Others) else { if (isTier1) { @@ -679,11 +602,11 @@ class BattleProvider with ChangeNotifier { } ItemTemplate? item = ItemTable.getRandomItem( - tier: currentTier, + tier: currentTier, minRarity: minRarity, - maxRarity: maxRarity + maxRarity: maxRarity, ); - + if (item != null) { rewardOptions.add(item.createItem(stage: stage)); } @@ -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 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). } diff --git a/lib/screens/battle_screen.dart b/lib/screens/battle_screen.dart index 46640f9..2f6e774 100644 --- a/lib/screens/battle_screen.dart +++ b/lib/screens/battle_screen.dart @@ -264,6 +264,9 @@ class _BattleScreenState extends State { _playerAnimKey.currentState ?.animateAttack(offset, () { showEffect(); // Show Effect at Impact! + // Trigger impact logic in provider + context.read().handleImpact(event); + // Shake and Explosion ONLY for Risky if (event.risk == RiskLevel.risky) { _shakeKey.currentState?.shake(); @@ -300,6 +303,7 @@ class _BattleScreenState extends State { if (!enableAnim) { showEffect(); // Just show effect if anim disabled + context.read().handleImpact(event); // Process impact immediately if anim disabled return; } @@ -324,6 +328,8 @@ class _BattleScreenState extends State { _enemyAnimKey.currentState ?.animateAttack(offset, () { showEffect(); // Show Effect at Impact! + // Trigger impact logic in provider + context.read().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 { } 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().handleImpact(event); } }); } diff --git a/prompt/68_adjust_game_config.md b/prompt/68_adjust_game_config.md new file mode 100644 index 0000000..9dc1ad6 --- /dev/null +++ b/prompt/68_adjust_game_config.md @@ -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) +- 플레이어와 적 모두 공격 시 애니메이션이 목표에 도달한 직후 데미지 텍스트가 표시됨. diff --git a/prompt/69_sync_damage_on_impact.md b/prompt/69_sync_damage_on_impact.md new file mode 100644 index 0000000..4d913f9 --- /dev/null +++ b/prompt/69_sync_damage_on_impact.md @@ -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().handleAttackImpact(...)`를 호출합니다. + - `EffectEvent`에 담긴 정보를 기반으로 `handleAttackImpact`에 인자를 전달합니다. + +## 5. 기대 효과 (Expected Outcome) +- 애니메이션과 데미지 텍스트 출력이 완벽하게 동기화되어, 게임의 타격감이 대폭 향상됩니다. +- 로직과 UI 간의 역할 분리가 명확해집니다. diff --git a/prompt/70_fix_player_action_crash.md b/prompt/70_fix_player_action_crash.md new file mode 100644 index 0000000..072c942 --- /dev/null +++ b/prompt/70_fix_player_action_crash.md @@ -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) +- 앱 크래시 해결. +- 플레이어 공격 시 애니메이션과 데미지 적용 시점이 정확히 일치. +- 데미지 중복 적용 방지. diff --git a/prompt/71_fix_null_error.md b/prompt/71_fix_null_error.md new file mode 100644 index 0000000..d82feeb --- /dev/null +++ b/prompt/71_fix_null_error.md @@ -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) +- 공격 실패 시에도 앱이 정상적으로 동작하며, 로그만 출력되고 데미지 로직은 스킵됨. diff --git a/prompt/72_delay_enemy_intent.md b/prompt/72_delay_enemy_intent.md new file mode 100644 index 0000000..7c8fa3c --- /dev/null +++ b/prompt/72_delay_enemy_intent.md @@ -0,0 +1,11 @@ +# 72. Delay Enemy Intent Generation + +## 1. 문제 (Problem) +- 적이 공격하는 도중에 `_generateEnemyIntent()`가 호출되어, 다음 턴 행동(예: 방어)이 미리 실행되고 이펙트가 겹쳐 보이는 현상 발생. + +## 2. 해결 방안 (Solution) +- `BattleProvider._enemyTurn` 메서드에서 `_generateEnemyIntent()` 호출 전에 **충분한 대기 시간(`GameConfig.animDelayEnemyTurn` 또는 공격 애니메이션 시간)**을 둡니다. +- 이를 통해 적의 현재 행동 연출이 완전히 끝난 뒤에 다음 행동을 준비하도록 순서를 정리합니다. + +## 3. 기대 효과 (Expected Outcome) +- 적 공격 -> 복귀 -> (잠시 후) -> 다음 턴 방어 준비(이펙트) 순서로 자연스럽게 진행됨.