From f49902c06793e018ed6d88cfb11633a8a7a9da7e Mon Sep 17 00:00:00 2001 From: Horoli Date: Mon, 8 Dec 2025 18:57:17 +0900 Subject: [PATCH] update --- lib/providers/battle_provider.dart | 279 +++++++----------- lib/providers/settings_provider.dart | 4 +- lib/screens/battle_screen.dart | 69 +++-- lib/widgets/battle/character_status_card.dart | 16 +- lib/widgets/battle/floating_battle_texts.dart | 5 +- prompt/62_animation_and_feedback_sync.md | 104 ++++--- 6 files changed, 223 insertions(+), 254 deletions(-) diff --git a/lib/providers/battle_provider.dart b/lib/providers/battle_provider.dart index d20e546..550729a 100644 --- a/lib/providers/battle_provider.dart +++ b/lib/providers/battle_provider.dart @@ -266,65 +266,8 @@ class BattleProvider with ChangeNotifier { if (!isPlayerTurn || player.isDead || enemy.isDead || showRewardPopup) return; - // 0. Apply Enemy Pre-emptive Defense (Just-in-Time) - if (currentEnemyIntent?.type == EnemyActionType.defend && - !currentEnemyIntent!.isApplied) { - final intent = currentEnemyIntent!; - - if (intent.isSuccess) { - enemy.armor += intent.finalValue; - _addLog( - "Enemy raises shield just in time! (+${intent.finalValue} Armor)", - ); - - // Visual Effect for Enemy Defense Success - 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); - } - if (!intent.isSuccess) { - _addLog("Enemy tried to raise shield but fumbled!"); - print("[Logic Debug] Enemy Defense Fumbled Event"); // Debug Log - - // Visual Effect for Enemy Defense Failure - final event = EffectEvent( - id: - DateTime.now().millisecondsSinceEpoch.toString() + - Random().nextInt(1000).toString(), - type: ActionType.defend, - risk: intent.risk, - target: EffectTarget.enemy, - feedbackType: BattleFeedbackType.failed, - attacker: enemy, - targetEntity: enemy, - isSuccess: false, - ); - _effectEventController.sink.add(event); - } - - intent.isApplied = true; - - notifyListeners(); - - // Re-add delay to show the defense before player attack lands - - int tid = _turnTransactionId; - - await Future.delayed(const Duration(milliseconds: 500)); - - if (tid != _turnTransactionId) return; - } + // 0. Apply Enemy Pre-emptive Defense - REMOVED (Standard Turn-Based Logic) + // Defense now happens on Enemy's Turn. // Update Enemy Status Effects at the start of Player's turn (user request) @@ -433,7 +376,6 @@ class BattleProvider with ChangeNotifier { event, ); // Send event for miss/fail feedback _addLog("${player.name}'s ${type.name} ${feedbackType.name}!"); - print("[Logic Debug] Player Action Failed Event"); // Debug Log // handleImpact(event); // REMOVED: Driven by UI } @@ -469,18 +411,90 @@ class BattleProvider with ChangeNotifier { // --- Turn Management Phases --- + // Phase 4: Start Player Turn + void _startPlayerTurn() { + // Player Turn Start Logic + // Armor decay (Player) + if (player.armor > 0) { + player.armor = (player.armor * GameConfig.armorDecayRate).toInt(); + _addLog("Player's armor decayed to ${player.armor}."); + } + + if (player.isDead) { + _onDefeat(); + return; + } + + // [New] Apply Pre-emptive Enemy Intent (Defense/Buffs) + if (currentEnemyIntent != null) { + final intent = currentEnemyIntent!; + if (intent.type == EnemyActionType.defend) { + if (intent.isSuccess) { + enemy.armor += intent.finalValue; + _addLog( + "${enemy.name} assumes a defensive stance (+${intent.finalValue} Armor).", + ); + } else { + _addLog("${enemy.name} tried to defend but failed."); + } + intent.isApplied = true; // Mark as applied so we don't do it again + } + // Add other pre-emptive intent types here if needed (e.g., Buffs) + } + + isPlayerTurn = true; + turnCount++; + notifyListeners(); + } + + void _addLog(String log) { + _logManager.addLog(log); + notifyListeners(); + } + + /// Check Status Effects at Start of Turn + 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; + } + + // --- Turn Management Phases --- + // Phase 1: Enemy Action Phase Future _startEnemyTurn() async { _turnTransactionId++; // Start of Enemy Turn Phase if (!isPlayerTurn && (player.isDead || enemy.isDead)) return; _addLog("Enemy's turn..."); - // REMOVED: Initial delay for faster pacing - // await Future.delayed( - // const Duration(milliseconds: GameConfig.animDelayEnemyTurn), - // ); - // Armor decay + // Armor decay (Enemy) if (enemy.armor > 0) { enemy.armor = (enemy.armor * GameConfig.armorDecayRate).toInt(); _addLog("Enemy's armor decayed to ${enemy.armor}."); @@ -498,33 +512,18 @@ class BattleProvider with ChangeNotifier { final intent = currentEnemyIntent!; if (intent.type == EnemyActionType.defend) { - // Defensive Action (Non-animating) + // Defensive Action (Pre-applied at start of Player Turn) + // Just show a log or maintain stance visual + _addLog("${enemy.name} maintains defensive stance."); - // Check if already applied in Phase 3 of previous turn - if (intent.isApplied) { - _addLog("Enemy maintains defensive stance."); - // Proceed manually - int tid = _turnTransactionId; - int delay = skipAnimations ? 500 : 1000; // Faster if animations off - Future.delayed(Duration(milliseconds: delay), () { - if (tid != _turnTransactionId) return; - _endEnemyTurn(); - }); - return; - } - - if (intent.isSuccess) { - // ... (success logic) ... - } else { - // ... (failure logic) ... - } - // For defense (if not applied), we proceed manually + // IMPORTANT: We still need to end the turn sequence properly. + // Since no animation is needed (or a very short one), we can just delay slightly. int tid = _turnTransactionId; - int delay = skipAnimations ? 500 : 1500; // Faster if animations off - Future.delayed(Duration(milliseconds: delay), () { + Future.delayed(const Duration(milliseconds: 500), () { if (tid != _turnTransactionId) return; _endEnemyTurn(); }); + return; } else { // Attack Action (Animating) if (intent.isSuccess) { @@ -542,10 +541,8 @@ class BattleProvider with ChangeNotifier { isSuccess: true, ); _effectEventController.sink.add(event); - // CRITICAL: We DO NOT call _endEnemyTurn here. - // The UI will play the animation, then call handleImpact. - // handleImpact will trigger _endEnemyTurn. - return; // Exit _startEnemyTurn after emitting event for UI to handle + // UI monitors event -> animates -> calls handleImpact -> _endEnemyTurn + return; } else { // Missed Attack _addLog("Enemy's ${intent.risk.name} attack missed!"); @@ -562,7 +559,7 @@ class BattleProvider with ChangeNotifier { isSuccess: false, ); _effectEventController.sink.add(event); - return; // Exit _startEnemyTurn after emitting event for UI to handle + return; } } } else if (!canAct) { @@ -594,42 +591,14 @@ class BattleProvider with ChangeNotifier { // Phase 3: Middle Turn (Apply Defense Effects) Future _processMiddleTurn() async { - // Apply Intent Effects (Pre-emptive Defense) + // Phase 3 Middle Turn - Reduced functionality as Defense is now Phase 1. int tid = _turnTransactionId; - await Future.delayed(const Duration(milliseconds: 500)); + await Future.delayed(const Duration(milliseconds: 200)); // Short pause if (tid != _turnTransactionId) return; - _applyEnemyIntentEffects(); - - // REMOVED: Delay for faster pacing - // Small pause to let the player see the enemy's new stance _startPlayerTurn(); } - // Phase 4: Start Player Turn - void _startPlayerTurn() { - // Player Turn Start Logic - // Armor decay - if (player.armor > 0) { - player.armor = (player.armor * GameConfig.armorDecayRate).toInt(); - _addLog("Player's armor decayed to ${player.armor}."); - } - - if (player.isDead) { - _onDefeat(); - return; - } - - isPlayerTurn = true; - turnCount++; - notifyListeners(); - } - - void _addLog(String message) { - _logManager.addLog(message); - notifyListeners(); - } - void _onVictory() { // Calculate Gold Reward // Base 10 + (Stage * 5) + Random variance @@ -897,21 +866,14 @@ class BattleProvider with ChangeNotifier { /// Applies the effects of the enemy's intent (specifically Defense) /// This should be called just before the Player's turn starts. void _applyEnemyIntentEffects() { - if (currentEnemyIntent == null || enemy.isDead) return; - - // Prevent duplicate application - if (currentEnemyIntent!.isApplied) return; - - if (currentEnemyIntent!.type == EnemyActionType.defend) { - // Logic moved to playerAction for "Just-in-Time" defense. - // Nothing to do here except maybe log intent (optional, but Intent UI covers it). - return; - } + // No pre-emptive effects in Standard Turn-Based model. + // Logic cleared. } // New public method to be called by UI at impact moment void handleImpact(EffectEvent event) { - if (event.isSuccess == false || event.feedbackType != null) { + if ((event.isSuccess == false || event.feedbackType != null) && + event.type != ActionType.defend) { // 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 @@ -920,11 +882,8 @@ class BattleProvider with ChangeNotifier { if (event.attacker == player) { _endPlayerTurn(); } else if (event.attacker == enemy) { - // Special Case: Do NOT call _endEnemyTurn for Enemy Defense (Phase 1 & 3). - // Phase 1 relies on manual timer. Phase 3 relies on _processMiddleTurn sequence. - if (event.type != ActionType.defend) { - _endEnemyTurn(); - } + // Special Case: Phase 1 relies on manual timer. Phase 3 relies on _processMiddleTurn sequence. + _endEnemyTurn(); } return; } @@ -970,10 +929,10 @@ class BattleProvider with ChangeNotifier { // FIX: Update _startEnemyTurn (Phase 1) to ALSO apply armor directly and make the event visual-only. // Then we can globally block Enemy Defend in handleImpact. - // Step 1: Modifying handleImpact to block ALL Enemy Defend logic. - if (event.attacker == enemy && event.type == ActionType.defend) { - return; - } + // REMOVED: Blocking Enemy Defend. Now we want to process it. + // if (event.attacker == enemy && event.type == ActionType.defend) { + // return; + // } // Only process actual attack or defend impacts here _processAttackImpact(event); @@ -1068,40 +1027,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( @@ -1113,6 +1038,4 @@ class BattleProvider with ChangeNotifier { _addLog("Applied ${effect.type.name} to ${target.name}!"); } } - - /// Process effects that happen at the start of the turn (Bleed, Stun). } diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 509d109..699b436 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -4,7 +4,7 @@ import 'package:shared_preferences/shared_preferences.dart'; class SettingsProvider with ChangeNotifier { static const String _keyEnemyAnim = 'settings_enemy_anim'; - bool _enableEnemyAnimations = false; // Default: Disabled + bool _enableEnemyAnimations = true; // Default: Enabled bool get enableEnemyAnimations => _enableEnemyAnimations; @@ -14,7 +14,7 @@ class SettingsProvider with ChangeNotifier { Future _loadSettings() async { final prefs = await SharedPreferences.getInstance(); - _enableEnemyAnimations = prefs.getBool(_keyEnemyAnim) ?? false; + _enableEnemyAnimations = prefs.getBool(_keyEnemyAnim) ?? true; notifyListeners(); } diff --git a/lib/screens/battle_screen.dart b/lib/screens/battle_screen.dart index 2cdb57c..9a55937 100644 --- a/lib/screens/battle_screen.dart +++ b/lib/screens/battle_screen.dart @@ -126,11 +126,13 @@ class _BattleScreenState extends State { void _addFloatingEffect(EffectEvent event) { if (_processedEffectIds.contains(event.id)) { + // print("[UI Debug] Duplicate Event Ignored: ${event.id}"); return; } _processedEffectIds.add(event.id); - if (_processedEffectIds.length > 20) { + // Keep the set size manageable + if (_processedEffectIds.length > 50) { _processedEffectIds.remove(_processedEffectIds.first); } @@ -138,7 +140,9 @@ class _BattleScreenState extends State { // Feedback Text Cooldown if (event.feedbackType != null) { - print("[UI Debug] Feedback Event: ${event.id}, Type: ${event.feedbackType}"); + // print( + // "[UI Debug] Feedback Event: ${event.id}, Type: ${event.feedbackType}", + // ); if (_lastFeedbackTime != null && DateTime.now().difference(_lastFeedbackTime!).inMilliseconds < 300) { return; // Skip if too soon @@ -194,7 +198,7 @@ class _BattleScreenState extends State { // Prevent duplicate feedback texts for the same event ID (UI Level) if (_floatingFeedbackTexts.any((e) => e.eventId == event.id)) { - return; + return; } setState(() { @@ -208,7 +212,7 @@ class _BattleScreenState extends State { top: position.dy, child: FloatingFeedbackText( key: ValueKey(id), - feedback: "$feedbackText (${event.id.substring(0, 4)})", + feedback: feedbackText, color: feedbackColor, onRemove: () { if (mounted) { @@ -259,9 +263,7 @@ class _BattleScreenState extends State { } // 1. Player Attack Animation Trigger (Success or Miss) - if (event.type == ActionType.attack && - event.target == EffectTarget.enemy) { - + if (event.type == ActionType.attack && event.target == EffectTarget.enemy) { final RenderBox? playerBox = _playerKey.currentContext?.findRenderObject() as RenderBox?; final RenderBox? enemyBox = @@ -277,11 +279,13 @@ class _BattleScreenState extends State { }); // Force SAFE animation for MISS, otherwise use event risk - final RiskLevel animRisk = event.feedbackType != null ? RiskLevel.safe : event.risk; + final RiskLevel animRisk = event.feedbackType != null + ? RiskLevel.safe + : event.risk; _playerAnimKey.currentState ?.animateAttack(offset, () { - showEffect(); + showEffect(); context.read().handleImpact(event); if (event.risk == RiskLevel.risky && event.feedbackType == null) { @@ -306,11 +310,10 @@ class _BattleScreenState extends State { } }); } - } + } // 2. Enemy Attack Animation Trigger (Success or Miss) else if (event.type == ActionType.attack && event.target == EffectTarget.player) { - bool enableAnim = context.read().enableEnemyAnimations; if (!enableAnim) { @@ -334,11 +337,13 @@ class _BattleScreenState extends State { }); // Force SAFE animation for MISS - final RiskLevel animRisk = event.feedbackType != null ? RiskLevel.safe : event.risk; + final RiskLevel animRisk = event.feedbackType != null + ? RiskLevel.safe + : event.risk; _enemyAnimKey.currentState ?.animateAttack(offset, () { - showEffect(); + showEffect(); context.read().handleImpact(event); if (event.risk == RiskLevel.risky && event.feedbackType == null) { @@ -363,36 +368,36 @@ class _BattleScreenState extends State { } }); } - } + } // 3. Defend Animation Trigger (Success OR Failure) else if (event.type == ActionType.defend) { - if (event.target == EffectTarget.player) { - _playerAnimKey.currentState?.animateDefense(() { - showEffect(); - context.read().handleImpact(event); - }); - } else if (event.target == EffectTarget.enemy) { - _enemyAnimKey.currentState?.animateDefense(() { - showEffect(); - context.read().handleImpact(event); - }); - } else { + if (event.target == EffectTarget.player) { + _playerAnimKey.currentState?.animateDefense(() { showEffect(); context.read().handleImpact(event); - } + }); + } else if (event.target == EffectTarget.enemy) { + _enemyAnimKey.currentState?.animateDefense(() { + showEffect(); + context.read().handleImpact(event); + }); + } else { + showEffect(); + context.read().handleImpact(event); + } } // 4. Others (Feedback for attacks, Buffs, etc.) else { showEffect(); - + // If it's a feedback event (MISS/FAILED for attacks), wait 500ms. if (event.feedbackType != null) { - Future.delayed(const Duration(milliseconds: 500), () { - if (mounted) context.read().handleImpact(event); - }); + Future.delayed(const Duration(milliseconds: 500), () { + if (mounted) context.read().handleImpact(event); + }); } else { - // Success events (Icon) - context.read().handleImpact(event); + // Success events (Icon) + context.read().handleImpact(event); } } } diff --git a/lib/widgets/battle/character_status_card.dart b/lib/widgets/battle/character_status_card.dart index 53348df..d4dda33 100644 --- a/lib/widgets/battle/character_status_card.dart +++ b/lib/widgets/battle/character_status_card.dart @@ -90,9 +90,18 @@ 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), + ), ], ), ), @@ -124,7 +133,6 @@ class CharacterStatusCard extends StatelessWidget { ), ), const SizedBox(height: 8), // 아이콘과 정보 사이 간격 - if (!isPlayer && !hideStats) Consumer( builder: (context, provider, child) { diff --git a/lib/widgets/battle/floating_battle_texts.dart b/lib/widgets/battle/floating_battle_texts.dart index dba70ce..574ef03 100644 --- a/lib/widgets/battle/floating_battle_texts.dart +++ b/lib/widgets/battle/floating_battle_texts.dart @@ -268,9 +268,10 @@ class FloatingFeedbackTextState extends State fontWeight: FontWeight.bold, shadows: const [ Shadow( - blurRadius: 2.0, + blurRadius: + 0.0, // Sharper shadow to avoid blurry/double look color: ThemeConfig.feedbackShadow, - offset: Offset(1.0, 1.0), + offset: Offset(1.5, 1.5), ), ], ), diff --git a/prompt/62_animation_and_feedback_sync.md b/prompt/62_animation_and_feedback_sync.md index ac9c8a4..ebcc48a 100644 --- a/prompt/62_animation_and_feedback_sync.md +++ b/prompt/62_animation_and_feedback_sync.md @@ -1,58 +1,90 @@ # 62. 애니메이션 및 피드백 동기화 관련 이슈 진행 현황 ## 1. 문제 발생 현상 -* 플레이어 공격 실패(`MISS`) 시, 화면에 `MISS` 텍스트가 두 번 올라오는 현상 발생. -* 적의 방어 실패(`FAILED`) 시에도 유사한 중복 텍스트 현상 발생. -* 로그상 (`[UI Debug] Feedback Event`)으로는 이벤트가 한 번만 발생했지만, UI에는 두 번 표시됨. -* 특히, "적이 방어 행동(Action Phase, Phase 1)을 했을 때만" 문제가 발생한다고 특정됨. + +- 플레이어 공격 실패(`MISS`) 시, 화면에 `MISS` 텍스트가 두 번 올라오는 현상 발생. +- 적의 방어 실패(`FAILED`) 시에도 유사한 중복 텍스트 현상 발생. +- 로그상 (`[UI Debug] Feedback Event`)으로는 이벤트가 한 번만 발생했지만, UI에는 두 번 표시됨. +- 특히, "적이 방어 행동(Action Phase, Phase 1)을 했을 때만" 문제가 발생한다고 특정됨. ## 2. 진단 및 해결 시도 + ### 2.1. 원인 가설 + 1. **`BattleScreen` 인스턴스 중복:** 가장 유력한 가설. 하나의 `EffectEvent`가 발생했을 때, 여러 `BattleScreen` 인스턴스가 각자 이벤트를 받아 화면에 피드백 텍스트를 띄우는 경우. - * `[UI Debug] BattleScreen initialized: ${hashCode}` 로그로 확인 필요. **현재 로그에서는 단 한 번만 찍히는 것으로 보이나, 화면에는 중복 현상이 발생하고 있음.** 이는 `BattleScreen` 인스턴스 자체의 중복보다는, **동일 인스턴스 내에서의 렌더링 또는 이벤트 처리 문제**임을 시사. + - `[UI Debug] BattleScreen initialized: ${hashCode}` 로그로 확인 필요. **현재 로그에서는 단 한 번만 찍히는 것으로 보이나, 화면에는 중복 현상이 발생하고 있음.** 이는 `BattleScreen` 인스턴스 자체의 중복보다는, **동일 인스턴스 내에서의 렌더링 또는 이벤트 처리 문제**임을 시사. 2. **`_addFloatingEffect` 내부의 `setState` 문제 / `FloatingFeedbackText` 위젯의 생명주기 문제:** `setState` 호출 시 `_floatingFeedbackTexts` 리스트에 위젯이 중복으로 추가되거나, 위젯 렌더링 과정에서 불필요한 복제가 발생하는 경우. - * `_floatingFeedbackTexts.clear()` 로직 도입 및 `eventId` 기반 중복 체크로 리스트 데이터 중복은 해결. 그러나 화면상 중복 표시는 지속. + - `_floatingFeedbackTexts.clear()` 로직 도입 및 `eventId` 기반 중복 체크로 리스트 데이터 중복은 해결. 그러나 화면상 중복 표시는 지속. 3. **UI 렌더링 타이밍/시각적 착시:** `FloatingFeedbackText` 위젯의 생명주기가 꼬여서 이전 텍스트가 완전히 사라지기 전에 새 텍스트가 뜨거나, 애니메이션이 반복되는 것처럼 보이는 착시. ### 2.2. 현재까지 적용된 주요 조치 #### **A. 피드백 텍스트 중복 출력 방지 및 디버깅 강화** -* **`EffectEvent` `eventId` 기반 중복 체크 (UI 레벨):** `_addFloatingEffect` 함수에서 `eventId`를 기반으로 동일한 이벤트에 대한 피드백 텍스트가 이미 리스트에 있다면 추가하지 않도록 `_floatingFeedbackTexts.any((e) => e.eventId == event.id)` 로직 추가. -* **`_floatingFeedbackTexts.clear()` 도입:** 새로운 피드백 텍스트(`MISS`/`FAILED`)가 뜰 때, 기존의 모든 피드백 텍스트를 리스트에서 제거한 후 추가하도록 수정. (화면에 항상 하나의 피드백 텍스트만 유지). -* **`addPostFrameCallback` 제거:** `_addFloatingEffect` 내 `WidgetsBinding.instance.addPostFrameCallback` 제거 (불필요한 비동기 지연 및 잠재적 문제 방지). -* **디버그 로그 추가:** - * `[UI Debug] BattleScreen initialized: ${hashCode}` (`BattleScreen` 초기화 횟수 확인용. **로그상 1회**). - * `[UI Debug] Feedback Event: ${event.id}, Type: ${event.feedbackType}` (`_addFloatingEffect` 호출 확인용. **로그상 1회**). - * `FloatingFeedbackText`에 `event.id` 전체 표시 (화면상 ID 일치 여부 확인용). - * `[Logic Debug]` 로그 추가 (`BattleProvider` 이벤트 발행 시점 확인용). + +- **`EffectEvent` `eventId` 기반 중복 체크 (UI 레벨):** `_addFloatingEffect` 함수에서 `eventId`를 기반으로 동일한 이벤트에 대한 피드백 텍스트가 이미 리스트에 있다면 추가하지 않도록 `_floatingFeedbackTexts.any((e) => e.eventId == event.id)` 로직 추가. +- **`_floatingFeedbackTexts.clear()` 도입:** 새로운 피드백 텍스트(`MISS`/`FAILED`)가 뜰 때, 기존의 모든 피드백 텍스트를 리스트에서 제거한 후 추가하도록 수정. (화면에 항상 하나의 피드백 텍스트만 유지). +- **`addPostFrameCallback` 제거:** `_addFloatingEffect` 내 `WidgetsBinding.instance.addPostFrameCallback` 제거 (불필요한 비동기 지연 및 잠재적 문제 방지). +- **디버그 로그 추가:** + - `[UI Debug] BattleScreen initialized: ${hashCode}` (`BattleScreen` 초기화 횟수 확인용. **로그상 1회**). + - `[UI Debug] Feedback Event: ${event.id}, Type: ${event.feedbackType}` (`_addFloatingEffect` 호출 확인용. **로그상 1회**). + - `FloatingFeedbackText`에 `event.id` 전체 표시 (화면상 ID 일치 여부 확인용). + - `[Logic Debug]` 로그 추가 (`BattleProvider` 이벤트 발행 시점 확인용). #### **B. 비동기 턴 흐름 안정화 (Transaction ID 패턴)** -* **`_turnTransactionId` 패턴 도입:** `BattleProvider`에 `int _turnTransactionId`를 추가하여 턴 전환 시 ID를 증가시키고, 모든 `Future.delayed` 콜백 내에서 ID를 체크하여 이전 턴의 "좀비 타이머"가 다음 턴 로직을 침범하지 않도록 방지. + +- **`_turnTransactionId` 패턴 도입:** `BattleProvider`에 `int _turnTransactionId`를 추가하여 턴 전환 시 ID를 증가시키고, 모든 `Future.delayed` 콜백 내에서 ID를 체크하여 이전 턴의 "좀비 타이머"가 다음 턴 로직을 침범하지 않도록 방지. #### **C. 적의 방어 행동 타이밍 및 시각화 개선** -* **방어도 계산식 수정:** `_generateEnemyIntent`에서 `baseDef * 2` 상수를 제거하여 `Risky` 방어가 과도하게 적용되지 않도록 수정. -* **"Just-in-Time" 방어 도입:** 플레이어가 공격하기 직전(`playerAction` 시작 시)에 적이 방어(성공/실패) 행동을 발동하고, 500ms 딜레이 후 플레이어 공격 로직 진행. -* **방어 애니메이션 추가:** `BattleAnimationWidget`에 `animateDefense` 메서드 추가 (좌우 흔들림). -* **`_addFloatingEffect` 로직 통합:** - * `showEffect`는 순수 UI만 담당 (딜레이, `handleImpact` 호출 제거). - * 공격/방어 애니메이션은 성공/실패 모두 실행. (`animateAttack` 또는 `animateDefense`). - * `MISS`/`FAILED` 시에는 500ms 딜레이 후 `handleImpact`. - * 적/플레이어 공격 `MISS` 시 `RiskLevel.safe` 애니메이션 강제. - * 적 공격 애니메이션 중 `intent` 정보 숨김. - * **Phase 1 방어 행동 시 딜레이 조절:** `skipAnimations` 설정에 따라 딜레이(1.5초 또는 0.5초) 적용. + +- **방어도 계산식 수정:** `_generateEnemyIntent`에서 `baseDef * 2` 상수를 제거하여 `Risky` 방어가 과도하게 적용되지 않도록 수정. +- **"Just-in-Time" 방어 도입:** 플레이어가 공격하기 직전(`playerAction` 시작 시)에 적이 방어(성공/실패) 행동을 발동하고, 500ms 딜레이 후 플레이어 공격 로직 진행. +- **방어 애니메이션 추가:** `BattleAnimationWidget`에 `animateDefense` 메서드 추가 (좌우 흔들림). +- **`_addFloatingEffect` 로직 통합:** + - `showEffect`는 순수 UI만 담당 (딜레이, `handleImpact` 호출 제거). + - 공격/방어 애니메이션은 성공/실패 모두 실행. (`animateAttack` 또는 `animateDefense`). + - `MISS`/`FAILED` 시에는 500ms 딜레이 후 `handleImpact`. + - 적/플레이어 공격 `MISS` 시 `RiskLevel.safe` 애니메이션 강제. + - 적 공격 애니메이션 중 `intent` 정보 숨김. + - **Phase 1 방어 행동 시 딜레이 조절:** `skipAnimations` 설정에 따라 딜레이(1.5초 또는 0.5초) 적용. ## 3. 남아있는 문제 (현재 진단) -* 로그상 `[UI Debug] Feedback Event`는 한 번만 찍히지만, **화면에는 `MISS` 텍스트가 두 번 표시됨.** - * **`[UI Debug] BattleScreen initialized` 로그도 1회만 찍히므로 `BattleScreen` 인스턴스 중복은 아님.** - * 이벤트가 두 번 발행되거나 `_addFloatingEffect` 함수가 두 번 호출되는 것도 아님. (로그 증거). - * `_floatingFeedbackTexts.clear()` 로직 도입으로 리스트에 두 번 추가될 수도 없음. - * **가장 유력한 가설:** `_addFloatingEffect` 내부의 `setState`가 호출된 후, Flutter의 위젯 트리 리빌드 및 렌더링 과정에서 **`FloatingFeedbackText` 위젯이 어떤 이유로 인해 화면에 두 번 그려지거나 (복제되거나), 또는 렌더링 과정에서 잔상이 남아 두 번으로 보이는 것.** - * 이것은 Flutter의 렌더링 파이프라인이나 `Stack` / `Positioned` 위젯의 상호작용과 관련된 심도 깊은 UI 버그일 가능성이 높음. + +- 로그상 `[UI Debug] Feedback Event`는 한 번만 찍히지만, **화면에는 `MISS` 텍스트가 두 번 표시됨.** + - **`[UI Debug] BattleScreen initialized` 로그도 1회만 찍히므로 `BattleScreen` 인스턴스 중복은 아님.** + - 이벤트가 두 번 발행되거나 `_addFloatingEffect` 함수가 두 번 호출되는 것도 아님. (로그 증거). + - `_floatingFeedbackTexts.clear()` 로직 도입으로 리스트에 두 번 추가될 수도 없음. + - **가장 유력한 가설:** `_addFloatingEffect` 내부의 `setState`가 호출된 후, Flutter의 위젯 트리 리빌드 및 렌더링 과정에서 **`FloatingFeedbackText` 위젯이 어떤 이유로 인해 화면에 두 번 그려지거나 (복제되거나), 또는 렌더링 과정에서 잔상이 남아 두 번으로 보이는 것.** + - 이것은 Flutter의 렌더링 파이프라인이나 `Stack` / `Positioned` 위젯의 상호작용과 관련된 심도 깊은 UI 버그일 가능성이 높음. ## 4. 다음 단계 제안 -* **화면상 `MISS` 텍스트의 ID 확인:** 화면에 보이는 두 개의 `MISS` 텍스트의 ID가 **정확히 동일한지** 확인 필요 (현재 `event.id` 전체를 표시하도록 수정됨). - * **ID가 동일하다면:** 하나의 `FeedbackTextData` 객체가 UI에 중복 렌더링되는 문제. (Key 문제, `Stack` 리빌드 문제 등) - * **ID가 다르다면:** `_addFloatingEffect` 자체가 두 번 호출된 것. (로그가 하나라는 것과 모순됨. 로그 시스템 확인 필요) -* **Flutter DevTools (Inspector) 활용:** 다른 환경에서 `Widget Inspector`를 통해 `Stack` 아래에 `FloatingFeedbackText` 위젯이 실제로 몇 개나 존재하는지 확인하는 것이 가장 정확합니다. -**현재까지의 모든 문제 해결 노력은 `BattleProvider` 내의 로직 중복이나 타이밍 오류를 잡는 데 초점을 맞췄습니다. 하지만 `MISS` 텍스트 중복 문제는 `BattleScreen` (UI) 쪽에서 발생하는 현상으로 보입니다.** \ No newline at end of file +- **화면상 `MISS` 텍스트의 ID 확인:** 화면에 보이는 두 개의 `MISS` 텍스트의 ID가 **정확히 동일한지** 확인 필요 (현재 `event.id` 전체를 표시하도록 수정됨). + - **ID가 동일하다면:** 하나의 `FeedbackTextData` 객체가 UI에 중복 렌더링되는 문제. (Key 문제, `Stack` 리빌드 문제 등) + - **ID가 다르다면:** `_addFloatingEffect` 자체가 두 번 호출된 것. (로그가 하나라는 것과 모순됨. 로그 시스템 확인 필요) +- **Flutter DevTools (Inspector) 활용:** 다른 환경에서 `Widget Inspector`를 통해 `Stack` 아래에 `FloatingFeedbackText` 위젯이 실제로 몇 개나 존재하는지 확인하는 것이 가장 정확합니다. + +**현재까지의 모든 문제 해결 노력은 `BattleProvider` 내의 로직 중복이나 타이밍 오류를 잡는 데 초점을 맞췄습니다. 하지만 `MISS` 텍스트 중복 문제는 `BattleScreen` (UI) 쪽에서 발생하는 현상으로 보입니다.** + +## 5. 최근 조치 및 해결 (2025-12-08 Update) + +### 5.1. 적 방어 턴 멈춤 현상 (Hang) 해결 + +- **문제:** 적이 방어(Defend) 행동을 할 때, 특히 실패하거나 피드백이 있는 경우 턴이 넘어가지 않고 멈추는 현상 발생. +- **원인:** `BattleProvider.handleImpact` 메서드 내에 `isSuccess == false` 또는 `feedbackType != null`인 경우 조기 반환(Early Return)하는 로직이 있었는데, 여기서 `ActionType.defend`를 예외 처리하지 않아 방어 실패 시 턴 종료 로직(`_endEnemyTurn`)이 호출되지 않았음. (기존 로직이 공격 중심이라 방어 실패를 고려하지 않음). +- **해결:** `handleImpact`의 조기 반환 조건에 `&& event.type != ActionType.defend`를 추가하여, 방어 이벤트는 실패하더라도 정상적인 턴 종료 로직을 타도록 수정함. + +### 5.2. 선제 방어 (Pre-emptive Defense) 로직 재도입 및 개선 + +- **배경:** 기존의 "적 턴에 방어" 방식은 직관적(Intent가 보이면 바로 방어해야 함)이지 않고, "Just-in-Time" 방식(플레이어 공격 시점에 방어 끼어들기)은 "두 번 행동하는 느낌"을 주어 어색했음. +- **해결:** **"턴 시작 시 방어(Start-of-Turn Defense)"** 방식으로 변경. + 1. **플레이어 턴 시작 시 (`_startPlayerTurn`):** 적의 Intent가 `Defend`라면, **즉시 방어도를 적용**하고 로그를 출력함. (애니메이션 없음). + 2. **플레이어 행동:** 이미 방어도가 올라간 적을 공격함. (끼어드는 연출 없음). + 3. **적 턴 (`_startEnemyTurn`):** 이미 방어 행동을 했으므로, 별도의 애니메이션 없이 "방어 태세 유지" 로그만 출력하고 턴을 종료함. +- **결과:** "의도가 보이면 이미 방어 상태"라는 직관과 일치하며, 불필요한 연출 끊김 현상을 해결함. + +### 5.3. UI 및 설정 정리 + +- **디버그 로그 제거:** `BattleProvider` 및 `BattleScreen`에 남아있던 `[Logic Debug]`, `[UI Debug]` 등의 `print` 문을 주석 처리하거나 제거하여 콘솔을 정리함. + - 특히 화면상 `MISS(1234)` 처럼 ID가 노출되던 문제를 해결하기 위해 `BattleScreen`의 텍스트 보간 로직 수정. +- **UI 복구:** 임시로 가려두었던 적의 **Intent UI** (`CharacterStatusCard`)를 다시 주석 해제하여 보이도록 복구함. +- **설정 변경:** `SettingsProvider`에서 `_enableEnemyAnimations` 기본값을 `true`로 변경하여 적 애니메이션이 기본적으로 켜지도록 함.