From 9540dd22a35ef7a8312eee0456d63c5f04e70fa8 Mon Sep 17 00:00:00 2001 From: Horoli Date: Mon, 8 Dec 2025 03:00:36 +0900 Subject: [PATCH] update --- lib/game/model/effect_event.dart | 2 + lib/providers/battle_provider.dart | 329 ++++++++--- lib/screens/battle_screen.dart | 515 ++++++++++-------- lib/screens/settings_screen.dart | 27 +- .../battle/battle_animation_widget.dart | 35 ++ lib/widgets/battle/character_status_card.dart | 2 +- lib/widgets/battle/floating_battle_texts.dart | 7 +- prompt/62_animation_and_feedback_sync.md | 36 ++ 8 files changed, 627 insertions(+), 326 deletions(-) create mode 100644 prompt/62_animation_and_feedback_sync.md diff --git a/lib/game/model/effect_event.dart b/lib/game/model/effect_event.dart index 31f77b3..efd38c9 100644 --- a/lib/game/model/effect_event.dart +++ b/lib/game/model/effect_event.dart @@ -16,6 +16,7 @@ class EffectEvent { final int? damageValue; // 공격 시 데미지 값 final bool? isSuccess; // 성공 여부 (Missed or Failed가 아닌 경우) final int? armorGained; // 방어 시 얻는 방어도 + final bool triggersTurnChange; // 턴 전환 트리거 여부 EffectEvent({ required this.id, @@ -28,5 +29,6 @@ class EffectEvent { this.damageValue, this.isSuccess, this.armorGained, + this.triggersTurnChange = true, }); } diff --git a/lib/providers/battle_provider.dart b/lib/providers/battle_provider.dart index c851371..d20e546 100644 --- a/lib/providers/battle_provider.dart +++ b/lib/providers/battle_provider.dart @@ -31,6 +31,7 @@ class EnemyIntent { final String description; final bool isSuccess; final int finalValue; + bool isApplied; // Mutable flag to prevent double execution EnemyIntent({ required this.type, @@ -39,6 +40,7 @@ class EnemyIntent { required this.description, required this.isSuccess, required this.finalValue, + this.isApplied = false, }); } @@ -51,6 +53,8 @@ class BattleProvider with ChangeNotifier { final BattleLogManager _logManager = BattleLogManager(); bool isPlayerTurn = true; + int _turnTransactionId = 0; // To prevent async race conditions + bool skipAnimations = false; // Sync with SettingsProvider int stage = 1; int turnCount = 1; @@ -88,6 +92,7 @@ class BattleProvider with ChangeNotifier { } void loadFromSave(Map data) { + _turnTransactionId++; // Invalidate previous timers stage = data['stage']; turnCount = data['turnCount']; player = Character.fromJson(data['player']); @@ -100,6 +105,7 @@ class BattleProvider with ChangeNotifier { } void initializeBattle() { + _turnTransactionId++; // Invalidate previous timers stage = 1; turnCount = 1; // Load player from PlayerTable @@ -180,6 +186,7 @@ class BattleProvider with ChangeNotifier { } void _prepareNextStage() { + _turnTransactionId++; // Invalidate previous timers // Save Game at the start of each stage SaveManager.saveGame(this); @@ -216,6 +223,7 @@ class BattleProvider with ChangeNotifier { showRewardPopup = false; _generateEnemyIntent(); // Generate first intent + _applyEnemyIntentEffects(); // Apply effects if it's a pre-emptive action (Defense) _addLog("Stage $stage ($type) started! A wild ${enemy.name} appeared."); } else if (type == StageType.shop) { @@ -258,10 +266,69 @@ class BattleProvider with ChangeNotifier { if (!isPlayerTurn || player.isDead || enemy.isDead || showRewardPopup) return; - // Update Enemy Status Effects at the start of Player's turn (user request) - enemy.updateStatusEffects(); + // 0. Apply Enemy Pre-emptive Defense (Just-in-Time) + if (currentEnemyIntent?.type == EnemyActionType.defend && + !currentEnemyIntent!.isApplied) { + final intent = currentEnemyIntent!; - // 1. Check for Defense Forbidden status + 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; + } + + // Update Enemy Status Effects at the start of Player's turn (user request) + + enemy.updateStatusEffects(); // 1. Check for Defense Forbidden status if (type == ActionType.defend && player.hasStatus(StatusEffectType.defenseForbidden)) { _addLog("Cannot defend! You are under Defense Forbidden status."); @@ -335,7 +402,7 @@ class BattleProvider with ChangeNotifier { isSuccess: true, ); _effectEventController.sink.add(event); - handleImpact(event); // Process impact via handleImpact for safety + // handleImpact(event); // REMOVED: Driven by UI } } else { // Failure @@ -366,16 +433,19 @@ class BattleProvider with ChangeNotifier { event, ); // Send event for miss/fail feedback _addLog("${player.name}'s ${type.name} ${feedbackType.name}!"); - handleImpact(event); // Process impact via handleImpact for safety + print("[Logic Debug] Player Action Failed Event"); // Debug Log + // handleImpact(event); // REMOVED: Driven by UI } - // Now check for enemy death (if applicable from bleed, or previous impacts) + // Now check for enemy death (if applicable from bleed, or previous impacts) + if (enemy.isDead) { + // Check enemy death after player's action + _onVictory(); + return; + } - // Removed redundant `if (enemy.isDead)` check as it's handled in `_processAttackImpact` - - _endPlayerTurn(); - - } + // _endPlayerTurn(); // REMOVED: Driven by UI via handleImpact + } void _endPlayerTurn() { // Update durations at end of turn @@ -387,49 +457,76 @@ class BattleProvider with ChangeNotifier { return; } + int tid = _turnTransactionId; Future.delayed( const Duration(milliseconds: GameConfig.animDelayEnemyTurn), - () => _enemyTurn(), + () { + if (tid != _turnTransactionId) return; + _startEnemyTurn(); + }, ); } - Future _enemyTurn() async { + // --- 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..."); - await Future.delayed( - const Duration(milliseconds: GameConfig.animDelayEnemyTurn), - ); + // REMOVED: Initial delay for faster pacing + // await Future.delayed( + // const Duration(milliseconds: GameConfig.animDelayEnemyTurn), + // ); - // Enemy Turn Start Logic // Armor decay if (enemy.armor > 0) { enemy.armor = (enemy.armor * GameConfig.armorDecayRate).toInt(); _addLog("Enemy's armor decayed to ${enemy.armor}."); } - // 1. Process Start-of-Turn Effects for Enemy + // Process Start-of-Turn Effects bool canAct = _processStartTurnEffects(enemy); - // Check death from bleed before acting if (enemy.isDead) { _onVictory(); return; } - if (canAct && currentEnemyIntent != null) { + if (canAct && currentEnemyIntent != null) { + final intent = currentEnemyIntent!; - final intent = currentEnemyIntent!; + if (intent.type == EnemyActionType.defend) { + // Defensive Action (Non-animating) - + // 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.type == EnemyActionType.defend) { - - // Already handled in _generateEnemyIntent - - _addLog("Enemy maintains defensive stance."); - - } else { // Attack Logic + if (intent.isSuccess) { + // ... (success logic) ... + } else { + // ... (failure logic) ... + } + // For defense (if not applied), we proceed manually + int tid = _turnTransactionId; + int delay = skipAnimations ? 500 : 1500; // Faster if animations off + Future.delayed(Duration(milliseconds: delay), () { + if (tid != _turnTransactionId) return; + _endEnemyTurn(); + }); + } else { + // Attack Action (Animating) if (intent.isSuccess) { final event = EffectEvent( id: @@ -445,8 +542,12 @@ class BattleProvider with ChangeNotifier { isSuccess: true, ); _effectEventController.sink.add(event); - // No Future.delayed here, BattleScreen will trigger impact + // 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 } else { + // Missed Attack _addLog("Enemy's ${intent.risk.name} attack missed!"); final event = EffectEvent( id: @@ -460,43 +561,53 @@ class BattleProvider with ChangeNotifier { targetEntity: player, isSuccess: false, ); - _effectEventController.sink.add( - event, - ); // Send event for miss feedback - handleImpact(event); // Process impact via handleImpact for safety + _effectEventController.sink.add(event); + return; // Exit _startEnemyTurn after emitting event for UI to handle } } } else if (!canAct) { _addLog("Enemy is stunned and cannot act!"); + int tid = _turnTransactionId; + Future.delayed(const Duration(milliseconds: 500), () { + if (tid != _turnTransactionId) return; + _endEnemyTurn(); + }); } else { _addLog("Enemy did nothing."); + int tid = _turnTransactionId; + Future.delayed(const Duration(milliseconds: 500), () { + if (tid != _turnTransactionId) return; + _endEnemyTurn(); + }); } + } - // 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; + // Phase 2: End Enemy Turn & Generate Next Intent + void _endEnemyTurn() { + if (player.isDead) return; // Game Over check - // 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 + _generateEnemyIntent(); - // Generate next intent - if (!player.isDead) { - _generateEnemyIntent(); - } + _processMiddleTurn(); + } + // Phase 3: Middle Turn (Apply Defense Effects) + Future _processMiddleTurn() async { + // Apply Intent Effects (Pre-emptive Defense) + int tid = _turnTransactionId; + await Future.delayed(const Duration(milliseconds: 500)); + 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) { @@ -505,7 +616,7 @@ class BattleProvider with ChangeNotifier { } if (player.isDead) { - await _onDefeat(); + _onDefeat(); return; } @@ -628,7 +739,6 @@ class BattleProvider with ChangeNotifier { stage++; showRewardPopup = false; - rewardOptions.clear(); // Clear options to prevent flash on next victory _prepareNextStage(); @@ -753,7 +863,7 @@ class BattleProvider with ChangeNotifier { // Defend Intent int baseDef = enemy.totalDefense; // Variance removed - int armor = (baseDef * 2 * efficiency).toInt(); + int armor = (baseDef * efficiency).toInt(); // Calculate success immediately bool success = false; @@ -778,39 +888,104 @@ class BattleProvider with ChangeNotifier { finalValue: armor, ); - // Apply defense immediately if successful - if (success) { - enemy.armor += armor; - _addLog("Enemy prepares defense! (+$armor Armor)"); - _effectEventController.sink.add( - EffectEvent( - id: - DateTime.now().millisecondsSinceEpoch.toString() + - Random().nextInt(1000).toString(), - type: ActionType.defend, - risk: risk, - target: EffectTarget.enemy, - feedbackType: null, // 방어 성공이므로 feedbackType 없음 - ), - ); - } else { - _addLog("Enemy tried to defend but fumbled!"); - } + // Note: Armor is NO LONGER applied here instantly. + // It is applied in _applyEnemyIntentEffects() which is called before Player turn. } notifyListeners(); } + /// 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; + } + } + // 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 + + // Even on failure, proceed to end turn logic + 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(); + } + } + return; + } + + // Special Case: Enemy Defense (Phase 3 & Phase 1) + // - Phase 3 Defense: Logic applied in _applyEnemyIntentEffects. Event is Visual Only. + // - Phase 1 Defense: Logic applied in _startEnemyTurn (if we add it there) or here? + // Wait, Phase 1 Defense is distinct. + // However, currently Phase 1 Defense also uses _effectEventController.sink.add(event). + // BUT Phase 1 Defense Logic is NOT applied in _startEnemyTurn yet (it just emits event). + // So Phase 1 Defense SHOULD go through _processAttackImpact? + // NO, because Phase 1 Defense uses the same ActionType.defend. + + // Let's look at _startEnemyTurn for Phase 1 Defense: + // It emits event with armorGained. It does NOT increase armor directly. + // So for Phase 1, we NEED handleImpact -> _processAttackImpact. + + // Let's look at _applyEnemyIntentEffects for Phase 3 Defense: + // It increases armor DIRECTLY: "enemy.armor += intent.finalValue;" + // AND it emits event. + + // This discrepancy is the root cause. + // We should standardize. + + // DECISION: Phase 3 Defense event should be flagged or handled as visual-only. + // Since we can't easily add flags to EffectEvent without changing other files, + // let's rely on the context. + + // Actually, simply removing the direct armor application in _applyEnemyIntentEffects + // and letting handleImpact do it is cleaner? + // NO, because Phase 3 needs armor applied BEFORE Player Turn starts, independent of UI speed. + // And _processMiddleTurn relies on the logic sequence. + + // So, we MUST block handleImpact for Phase 3 Defense. + // Phase 1 Defense (Rare, usually Attack) needs to work too. + + // BUT wait, _startEnemyTurn (Phase 1) code: + // if (intent.type == EnemyActionType.defend) { ... sink.add(event); ... } + // It does NOT apply armor. So Phase 1 relies on handleImpact. + + // PROBLEM: handleImpact cannot distinguish Phase 1 vs Phase 3 event easily. + + // 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; } // Only process actual attack or defend impacts here _processAttackImpact(event); + + // After processing impact, proceed to end turn logic + if (event.triggersTurnChange) { + if (event.attacker == player) { + _endPlayerTurn(); + } else if (event.attacker == enemy) { + _endEnemyTurn(); + } + } } // Refactored common attack impact logic diff --git a/lib/screens/battle_screen.dart b/lib/screens/battle_screen.dart index 2f6e774..2cdb57c 100644 --- a/lib/screens/battle_screen.dart +++ b/lib/screens/battle_screen.dart @@ -50,10 +50,12 @@ class _BattleScreenState extends State { bool _showLogs = false; bool _isPlayerAttacking = false; // Player Attack Animation State bool _isEnemyAttacking = false; // Enemy Attack Animation State + DateTime? _lastFeedbackTime; // Cooldown to prevent duplicate feedback texts @override void initState() { super.initState(); + print("[UI Debug] BattleScreen initialized: ${hashCode}"); // Debug Log final battleProvider = context.read(); _damageSubscription = battleProvider.damageStream.listen( _addFloatingDamageText, @@ -71,54 +73,52 @@ class _BattleScreenState extends State { } void _addFloatingDamageText(DamageEvent event) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; + if (!mounted) return; - GlobalKey targetKey = event.target == DamageTarget.player - ? _playerKey - : _enemyKey; + GlobalKey targetKey = event.target == DamageTarget.player + ? _playerKey + : _enemyKey; - if (targetKey.currentContext == null) return; - RenderBox? renderBox = - targetKey.currentContext!.findRenderObject() as RenderBox?; - if (renderBox == null) return; + if (targetKey.currentContext == null) return; + RenderBox? renderBox = + targetKey.currentContext!.findRenderObject() as RenderBox?; + if (renderBox == null) return; - Offset position = renderBox.localToGlobal(Offset.zero); + Offset position = renderBox.localToGlobal(Offset.zero); - RenderBox? stackRenderBox = - _stackKey.currentContext?.findRenderObject() as RenderBox?; - if (stackRenderBox != null) { - Offset stackOffset = stackRenderBox.localToGlobal(Offset.zero); - position = position - stackOffset; - } + RenderBox? stackRenderBox = + _stackKey.currentContext?.findRenderObject() as RenderBox?; + if (stackRenderBox != null) { + Offset stackOffset = stackRenderBox.localToGlobal(Offset.zero); + position = position - stackOffset; + } - position = position + Offset(renderBox.size.width / 2 - 20, -20); + position = position + Offset(renderBox.size.width / 2 - 20, -20); - final String id = UniqueKey().toString(); + final String id = UniqueKey().toString(); - setState(() { - _floatingDamageTexts.add( - DamageTextData( - id: id, - widget: Positioned( - left: position.dx, - top: position.dy, - child: FloatingDamageText( - key: ValueKey(id), - damage: event.damage.toString(), - color: event.color, - onRemove: () { - if (mounted) { - setState(() { - _floatingDamageTexts.removeWhere((e) => e.id == id); - }); - } - }, - ), + setState(() { + _floatingDamageTexts.add( + DamageTextData( + id: id, + widget: Positioned( + left: position.dx, + top: position.dy, + child: FloatingDamageText( + key: ValueKey(id), + damage: event.damage.toString(), + color: event.color, + onRemove: () { + if (mounted) { + setState(() { + _floatingDamageTexts.removeWhere((e) => e.id == id); + }); + } + }, ), ), - ); - }); + ), + ); }); } @@ -128,108 +128,92 @@ class _BattleScreenState extends State { if (_processedEffectIds.contains(event.id)) { return; } + _processedEffectIds.add(event.id); if (_processedEffectIds.length > 20) { _processedEffectIds.remove(_processedEffectIds.first); } - WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + + // Feedback Text Cooldown + if (event.feedbackType != null) { + print("[UI Debug] Feedback Event: ${event.id}, Type: ${event.feedbackType}"); + if (_lastFeedbackTime != null && + DateTime.now().difference(_lastFeedbackTime!).inMilliseconds < 300) { + return; // Skip if too soon + } + _lastFeedbackTime = DateTime.now(); + } + + GlobalKey targetKey = event.target == EffectTarget.player + ? _playerKey + : _enemyKey; + if (targetKey.currentContext == null) return; + + RenderBox? renderBox = + targetKey.currentContext!.findRenderObject() as RenderBox?; + if (renderBox == null) return; + + Offset position = renderBox.localToGlobal(Offset.zero); + + RenderBox? stackRenderBox = + _stackKey.currentContext?.findRenderObject() as RenderBox?; + if (stackRenderBox != null) { + Offset stackOffset = stackRenderBox.localToGlobal(Offset.zero); + position = position - stackOffset; + } + + position = + position + + Offset(renderBox.size.width / 2 - 30, renderBox.size.height / 2 - 30); + + // 0. Prepare Effect Function + void showEffect() { if (!mounted) return; - GlobalKey targetKey = event.target == EffectTarget.player - ? _playerKey - : _enemyKey; - if (targetKey.currentContext == null) return; - - RenderBox? renderBox = - targetKey.currentContext!.findRenderObject() as RenderBox?; - if (renderBox == null) return; - - Offset position = renderBox.localToGlobal(Offset.zero); - - RenderBox? stackRenderBox = - _stackKey.currentContext?.findRenderObject() as RenderBox?; - if (stackRenderBox != null) { - Offset stackOffset = stackRenderBox.localToGlobal(Offset.zero); - position = position - stackOffset; - } - - position = - position + - Offset(renderBox.size.width / 2 - 30, renderBox.size.height / 2 - 30); - - // 0. Prepare Effect Function - void showEffect() { - if (!mounted) return; - - // feedbackType이 존재하면 해당 텍스트를 표시하고 기존 이펙트 아이콘은 건너뜜 - if (event.feedbackType != null) { - String feedbackText; - Color feedbackColor; - switch (event.feedbackType) { - case BattleFeedbackType.miss: - feedbackText = "MISS"; - feedbackColor = ThemeConfig.missText; - break; - case BattleFeedbackType.failed: - feedbackText = "FAILED"; - feedbackColor = ThemeConfig.failedText; - break; - default: - feedbackText = ""; // Should not happen with current enums - feedbackColor = ThemeConfig.textColorWhite; - } - - final String id = UniqueKey().toString(); - setState(() { - _floatingFeedbackTexts.add( - FeedbackTextData( - id: id, - widget: Positioned( - left: position.dx, - top: position.dy, - child: FloatingFeedbackText( - key: ValueKey(id), - feedback: feedbackText, - color: feedbackColor, - onRemove: () { - if (mounted) { - setState(() { - _floatingFeedbackTexts.removeWhere((e) => e.id == id); - }); - } - }, - ), - ), - ), - ); - }); - return; // feedbackType이 있으면 아이콘 이펙트는 표시하지 않음 + // Handle Feedback Text (MISS / FAILED) + if (event.feedbackType != null) { + String feedbackText; + Color feedbackColor; + switch (event.feedbackType) { + case BattleFeedbackType.miss: + feedbackText = "MISS"; + feedbackColor = ThemeConfig.missText; + break; + case BattleFeedbackType.failed: + feedbackText = "FAILED"; + feedbackColor = ThemeConfig.failedText; + break; + default: + feedbackText = ""; + feedbackColor = ThemeConfig.textColorWhite; } - // Use BattleConfig for Icon, Color, and Size - IconData icon = BattleConfig.getIcon(event.type); - Color color = BattleConfig.getColor(event.type, event.risk); - double size = BattleConfig.getSize(event.risk); - final String id = UniqueKey().toString(); + // Prevent duplicate feedback texts for the same event ID (UI Level) + if (_floatingFeedbackTexts.any((e) => e.eventId == event.id)) { + return; + } + setState(() { - _floatingEffects.add( - FloatingEffectData( + _floatingFeedbackTexts.clear(); // Clear previous texts + _floatingFeedbackTexts.add( + FeedbackTextData( id: id, + eventId: event.id, widget: Positioned( left: position.dx, top: position.dy, - child: FloatingEffect( + child: FloatingFeedbackText( key: ValueKey(id), - icon: icon, - color: color, - size: size, + feedback: "$feedbackText (${event.id.substring(0, 4)})", + color: feedbackColor, onRemove: () { if (mounted) { setState(() { - _floatingEffects.removeWhere((e) => e.id == id); + _floatingFeedbackTexts.removeWhere((e) => e.id == id); }); } }, @@ -238,133 +222,179 @@ class _BattleScreenState extends State { ), ); }); + return; // Return early for feedback } - // 1. Attack Animation Trigger (All Risk Levels) - if (event.type == ActionType.attack && - event.target == EffectTarget.enemy && - event.feedbackType == null) { - // Calculate target position (Enemy) relative to Player - final RenderBox? playerBox = - _playerKey.currentContext?.findRenderObject() as RenderBox?; - final RenderBox? enemyBox = - _enemyKey.currentContext?.findRenderObject() as RenderBox?; + // Handle Icon Effect + IconData icon = BattleConfig.getIcon(event.type); + Color color = BattleConfig.getColor(event.type, event.risk); + double size = BattleConfig.getSize(event.risk); - if (playerBox != null && enemyBox != null) { - final playerPos = playerBox.localToGlobal(Offset.zero); - final enemyPos = enemyBox.localToGlobal(Offset.zero); + final String id = UniqueKey().toString(); - final offset = enemyPos - playerPos; - - // Start Animation: Hide Stats - setState(() { - _isPlayerAttacking = true; - }); - - _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(); - - RenderBox? stackBox = - _stackKey.currentContext?.findRenderObject() - as RenderBox?; - if (stackBox != null) { - Offset localEnemyPos = stackBox.globalToLocal(enemyPos); - // Center of the enemy card roughly - localEnemyPos += Offset( - enemyBox.size.width / 2, - enemyBox.size.height / 2, - ); - _explosionKey.currentState?.explode(localEnemyPos); + setState(() { + _floatingEffects.add( + FloatingEffectData( + id: id, + widget: Positioned( + left: position.dx, + top: position.dy, + child: FloatingEffect( + key: ValueKey(id), + icon: icon, + color: color, + size: size, + onRemove: () { + if (mounted) { + setState(() { + _floatingEffects.removeWhere((e) => e.id == id); + }); } + }, + ), + ), + ), + ); + }); + } + + // 1. Player Attack Animation Trigger (Success or Miss) + if (event.type == ActionType.attack && + event.target == EffectTarget.enemy) { + + final RenderBox? playerBox = + _playerKey.currentContext?.findRenderObject() as RenderBox?; + final RenderBox? enemyBox = + _enemyKey.currentContext?.findRenderObject() as RenderBox?; + + if (playerBox != null && enemyBox != null) { + final playerPos = playerBox.localToGlobal(Offset.zero); + final enemyPos = enemyBox.localToGlobal(Offset.zero); + final offset = enemyPos - playerPos; + + setState(() { + _isPlayerAttacking = true; + }); + + // Force SAFE animation for MISS, otherwise use event risk + final RiskLevel animRisk = event.feedbackType != null ? RiskLevel.safe : event.risk; + + _playerAnimKey.currentState + ?.animateAttack(offset, () { + showEffect(); + context.read().handleImpact(event); + + if (event.risk == RiskLevel.risky && event.feedbackType == null) { + _shakeKey.currentState?.shake(); + RenderBox? stackBox = + _stackKey.currentContext?.findRenderObject() as RenderBox?; + if (stackBox != null) { + Offset localEnemyPos = stackBox.globalToLocal(enemyPos); + localEnemyPos += Offset( + enemyBox.size.width / 2, + enemyBox.size.height / 2, + ); + _explosionKey.currentState?.explode(localEnemyPos); } - }, event.risk) - .then((_) { - // End Animation: Show Stats - if (mounted) { - setState(() { - _isPlayerAttacking = false; - }); - } - }); - } - } else if (event.type == ActionType.attack && - event.target == EffectTarget.player && - event.feedbackType == null) { - - // Check Settings - bool enableAnim = context.read().enableEnemyAnimations; + } + }, animRisk) + .then((_) { + if (mounted) { + setState(() { + _isPlayerAttacking = false; + }); + } + }); + } + } + // 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) { - showEffect(); // Just show effect if anim disabled - context.read().handleImpact(event); // Process impact immediately if anim disabled - return; - } - - // 2. Enemy Attack Animation Trigger - final RenderBox? playerBox = - _playerKey.currentContext?.findRenderObject() as RenderBox?; - final RenderBox? enemyBox = - _enemyKey.currentContext?.findRenderObject() as RenderBox?; - - if (playerBox != null && enemyBox != null) { - final playerPos = playerBox.localToGlobal(Offset.zero); - final enemyPos = enemyBox.localToGlobal(Offset.zero); - - // Enemy moves TO Player - final offset = playerPos - enemyPos; - - // Start Animation: Hide Stats - setState(() { - _isEnemyAttacking = true; - }); - - _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) { - _shakeKey.currentState?.shake(); - - // Explosion on Player - RenderBox? stackBox = - _stackKey.currentContext?.findRenderObject() - as RenderBox?; - if (stackBox != null) { - Offset localPlayerPos = stackBox.globalToLocal(playerPos); - localPlayerPos += Offset( - playerBox.size.width / 2, - playerBox.size.height / 2, - ); - _explosionKey.currentState?.explode(localPlayerPos); - } - } - }, event.risk) - .then((_) { - // End Animation: Show Stats - if (mounted) { - setState(() { - _isEnemyAttacking = false; - }); - } - }); - } - } else { - // Not a player/enemy attack movement, show immediately + if (!enableAnim) { showEffect(); - // Also process impact immediately for non-animating effects (e.g., failed defense, stun) context.read().handleImpact(event); + return; } - }); + + final RenderBox? playerBox = + _playerKey.currentContext?.findRenderObject() as RenderBox?; + final RenderBox? enemyBox = + _enemyKey.currentContext?.findRenderObject() as RenderBox?; + + if (playerBox != null && enemyBox != null) { + final playerPos = playerBox.localToGlobal(Offset.zero); + final enemyPos = enemyBox.localToGlobal(Offset.zero); + final offset = playerPos - enemyPos; + + setState(() { + _isEnemyAttacking = true; + }); + + // Force SAFE animation for MISS + final RiskLevel animRisk = event.feedbackType != null ? RiskLevel.safe : event.risk; + + _enemyAnimKey.currentState + ?.animateAttack(offset, () { + showEffect(); + context.read().handleImpact(event); + + if (event.risk == RiskLevel.risky && event.feedbackType == null) { + _shakeKey.currentState?.shake(); + RenderBox? stackBox = + _stackKey.currentContext?.findRenderObject() as RenderBox?; + if (stackBox != null) { + Offset localPlayerPos = stackBox.globalToLocal(playerPos); + localPlayerPos += Offset( + playerBox.size.width / 2, + playerBox.size.height / 2, + ); + _explosionKey.currentState?.explode(localPlayerPos); + } + } + }, animRisk) + .then((_) { + if (mounted) { + setState(() { + _isEnemyAttacking = false; + }); + } + }); + } + } + // 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 { + 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); + }); + } else { + // Success events (Icon) + context.read().handleImpact(event); + } + } } void _showRiskLevelSelection(BuildContext context, ActionType actionType) { @@ -452,6 +482,11 @@ class _BattleScreenState extends State { @override Widget build(BuildContext context) { + // Sync animation setting to provider logic + final settings = context.watch(); + context.read().skipAnimations = + !settings.enableEnemyAnimations; + return ResponsiveContainer( child: Consumer( builder: (context, battleProvider, child) { @@ -616,7 +651,9 @@ class _BattleScreenState extends State { child: SimpleDialog( title: Row( children: [ - const Text("${AppStrings.victory} ${AppStrings.chooseReward}"), + const Text( + "${AppStrings.victory} ${AppStrings.chooseReward}", + ), const Spacer(), Row( mainAxisSize: MainAxisSize.min, diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index f3f23b6..04837cb 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -24,7 +24,7 @@ class SettingsScreen extends StatelessWidget { ), ), const SizedBox(height: 40), - + // Enemy Animation Toggle Consumer( builder: (context, settings, child) { @@ -51,7 +51,7 @@ class SettingsScreen extends StatelessWidget { style: TextStyle(color: ThemeConfig.textColorWhite), ), const SizedBox(height: 40), - + // Restart Button ElevatedButton( style: ElevatedButton.styleFrom( @@ -69,7 +69,7 @@ class SettingsScreen extends StatelessWidget { const SnackBar(content: Text('Game Restarted!')), ); // Optionally switch tab back to Battle (index 0) - // But MainWrapper controls the index. + // But MainWrapper controls the index. // We can't easily switch tab from here without a callback or Provider. // For now, just restart logic is enough. }, @@ -89,10 +89,13 @@ class SettingsScreen extends StatelessWidget { _showConfirmationDialog( context, title: '${AppStrings.returnToMenu}?', - content: 'Unsaved progress may be lost. (Progress is saved automatically after each stage)', + content: + 'Unsaved progress may be lost. (Progress is saved automatically after each stage)', onConfirm: () { - Navigator.of(context).pushAndRemoveUntil( - MaterialPageRoute(builder: (context) => const MainMenuScreen()), + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (context) => const MainMenuScreen(), + ), (route) => false, ); }, @@ -105,7 +108,12 @@ class SettingsScreen extends StatelessWidget { ); } - void _showConfirmationDialog(BuildContext context, {required String title, required String content, required VoidCallback onConfirm}) { + void _showConfirmationDialog( + BuildContext context, { + required String title, + required String content, + required VoidCallback onConfirm, + }) { showDialog( context: context, builder: (context) => AlertDialog( @@ -121,7 +129,10 @@ class SettingsScreen extends StatelessWidget { Navigator.pop(context); onConfirm(); }, - child: const Text(AppStrings.confirm, style: TextStyle(color: Colors.red)), + child: const Text( + AppStrings.confirm, + style: TextStyle(color: Colors.red), + ), ), ], ), diff --git a/lib/widgets/battle/battle_animation_widget.dart b/lib/widgets/battle/battle_animation_widget.dart index 9e88ed5..4938252 100644 --- a/lib/widgets/battle/battle_animation_widget.dart +++ b/lib/widgets/battle/battle_animation_widget.dart @@ -105,6 +105,41 @@ class BattleAnimationWidgetState extends State } } + Future animateDefense(VoidCallback onImpact) async { + // Defense: Wobble/Shake horizontally + _translateController.duration = const Duration(milliseconds: 800); + + // Sequence: Left -> Right -> Center + _translateAnimation = + TweenSequence([ + TweenSequenceItem( + tween: Tween(begin: Offset.zero, end: const Offset(-10, 0)), + weight: 25, + ), + TweenSequenceItem( + tween: Tween( + begin: const Offset(-10, 0), + end: const Offset(10, 0), + ), + weight: 50, + ), + TweenSequenceItem( + tween: Tween(begin: const Offset(10, 0), end: Offset.zero), + weight: 25, + ), + ]).animate( + CurvedAnimation( + parent: _translateController, + curve: Curves.easeInOut, + ), + ); + + await _translateController.forward(); + if (!mounted) return; + onImpact(); + _translateController.reset(); + } + @override Widget build(BuildContext context) { return AnimatedBuilder( diff --git a/lib/widgets/battle/character_status_card.dart b/lib/widgets/battle/character_status_card.dart index 555070a..53348df 100644 --- a/lib/widgets/battle/character_status_card.dart +++ b/lib/widgets/battle/character_status_card.dart @@ -125,7 +125,7 @@ class CharacterStatusCard extends StatelessWidget { ), const SizedBox(height: 8), // 아이콘과 정보 사이 간격 - if (!isPlayer) + if (!isPlayer && !hideStats) Consumer( builder: (context, provider, child) { if (provider.currentEnemyIntent != null && !character.isDead) { diff --git a/lib/widgets/battle/floating_battle_texts.dart b/lib/widgets/battle/floating_battle_texts.dart index 6353360..dba70ce 100644 --- a/lib/widgets/battle/floating_battle_texts.dart +++ b/lib/widgets/battle/floating_battle_texts.dart @@ -286,6 +286,11 @@ class FloatingFeedbackTextState extends State class FeedbackTextData { final String id; final Widget widget; + final String eventId; // To prevent duplicates - FeedbackTextData({required this.id, required this.widget}); + FeedbackTextData({ + required this.id, + required this.widget, + required this.eventId, + }); } diff --git a/prompt/62_animation_and_feedback_sync.md b/prompt/62_animation_and_feedback_sync.md new file mode 100644 index 0000000..71a3762 --- /dev/null +++ b/prompt/62_animation_and_feedback_sync.md @@ -0,0 +1,36 @@ +# 62. 애니메이션 및 피드백 동기화 관련 이슈 진행 현황 + +## 1. 문제 발생 현상 +* 플레이어 공격 실패(`MISS`) 시, 화면에 `MISS` 텍스트가 두 번 올라오는 현상 발생. +* 적의 방어 실패(`FAILED`) 시에도 유사한 중복 텍스트 현상 발생. +* 로그상 (`[UI Debug] Feedback Event`)으로는 이벤트가 한 번만 발생했지만, UI에는 두 번 표시됨. +* `_addFloatingEffect` 함수 내부에 `eventId` 기반의 중복 체크 로직이 추가되었음에도 현상 지속. + +## 2. 진단 및 해결 시도 +### 2.1. 원인 가설 +1. **`BattleScreen` 인스턴스 중복:** 가장 유력한 가설. 하나의 `EffectEvent`가 발생했을 때, 여러 `BattleScreen` 인스턴스가 각자 이벤트를 받아 화면에 피드백 텍스트를 띄우는 경우. + * `[UI Debug] BattleScreen initialized: ${hashCode}` 로그로 확인 필요. (현재 확인되지 않음) +2. **`_addFloatingEffect` 내부의 `setState` 문제:** `setState` 호출 시 `_floatingFeedbackTexts` 리스트에 위젯이 중복으로 추가되거나, 위젯 렌더링 과정에서 불필요한 복제가 발생하는 경우. (리스트 `clear()` 로직 추가로 해결 시도 중) +3. **UI 렌더링 타이밍/시각적 착시:** `FloatingFeedbackText` 위젯의 생명주기가 꼬여서 이전 텍스트가 완전히 사라지기 전에 새 텍스트가 뜨거나, 애니메이션이 반복되는 것처럼 보이는 착시. + +### 2.2. 현재까지 적용된 주요 조치 +* **`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` 초기화 횟수 확인용) + * `[UI Debug] Feedback Event: ${event.id}, Type: ${event.feedbackType}` (`_addFloatingEffect` 호출 확인용) + * `FloatingFeedbackText`에 `event.id` 전체 표시 (화면상 ID 일치 여부 확인용) + +## 3. 남아있는 문제 (현재 진단) +* 로그상 `[UI Debug] Feedback Event`는 한 번만 찍히지만, **화면에는 `MISS` 텍스트가 두 번 표시됨.** + * 이는 **UI 레벨에서의 렌더링 문제**이거나, `_addFloatingEffect` 함수 **내부 로직 중 `setState`가 비정상적으로 두 번 호출**되는 문제일 가능성이 높습니다. + * `_floatingFeedbackTexts.clear()` 로직이 추가되었으므로, 같은 리스트에 두 번 추가되는 것은 막혔을 것입니다. + +## 4. 다음 단계 제안 +* **`[UI Debug] BattleScreen initialized: ...` 로그 결과 확인:** 이 로그가 두 번 이상 찍힌다면 `BattleScreen` 인스턴스가 중복된 것이므로, `MainWrapper`나 라우팅 구조를 점검해야 합니다. +* **화면상 `MISS` 텍스트의 ID 확인:** 화면에 보이는 두 개의 `MISS` 텍스트의 ID가 **정확히 동일한지** 확인 필요 (현재 `event.id` 전체를 표시하도록 수정됨). + * **ID가 동일하다면:** 하나의 `FeedbackTextData` 객체가 UI에 중복 렌더링되는 문제. (Key 문제, `Stack` 리빌드 문제 등) + * **ID가 다르다면:** `_addFloatingEffect` 자체가 두 번 호출된 것. (로그가 하나라는 것과 모순됨. 로그 시스템 확인 필요) + +**현재까지의 모든 문제 해결 노력은 `BattleProvider` 내의 로직 중복이나 타이밍 오류를 잡는 데 초점을 맞췄습니다. 하지만 `MISS` 텍스트 중복 문제는 `BattleScreen` (UI) 쪽에서 발생하는 현상으로 보입니다.**