import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers.dart'; import '../game/enums.dart'; import '../game/models.dart'; import 'dart:async'; import '../widgets.dart'; import '../utils.dart'; import 'main_menu_screen.dart'; import '../game/config.dart'; class BattleScreen extends StatefulWidget { const BattleScreen({super.key}); @override State createState() => _BattleScreenState(); } class _BattleScreenState extends State { final List _floatingDamageTexts = []; final List _floatingEffects = []; final List _floatingFeedbackTexts = []; StreamSubscription? _damageSubscription; StreamSubscription? _effectSubscription; final GlobalKey _playerKey = GlobalKey(); final GlobalKey _enemyKey = GlobalKey(); final GlobalKey _stackKey = GlobalKey(); final GlobalKey _shakeKey = GlobalKey(); final GlobalKey _playerAnimKey = GlobalKey(); final GlobalKey _enemyAnimKey = GlobalKey(); // Added Enemy Anim Key final GlobalKey _explosionKey = GlobalKey(); 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 // New State for Interactive Defense Animation int _lastTurnCount = -1; bool _hasShownEnemyDefense = false; @override void initState() { super.initState(); print("[UI Debug] BattleScreen initialized: ${hashCode}"); // Debug Log final battleProvider = context.read(); _damageSubscription = battleProvider.damageStream.listen( _addFloatingDamageText, ); _effectSubscription = battleProvider.effectStream.listen( _addFloatingEffect, ); } @override void dispose() { _damageSubscription?.cancel(); _effectSubscription?.cancel(); super.dispose(); } void _addFloatingDamageText(DamageEvent event) { if (!mounted) return; GlobalKey targetKey = event.target == DamageTarget.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 + BattleConfig.damageTextOffsetX, BattleConfig.damageTextOffsetY, ); final String id = UniqueKey().toString(); // Scale based on risk if available in event? // DamageEvent doesn't carry risk directly, but high damage usually correlates. // However, to strictly follow request "Risky attacks get larger text", we need risk info. // Currently DamageEvent (model/damage_event.dart) does NOT have risk field. // We can infer or add it. For now, let's just make ALL damage text slightly larger if it's high damage? // OR better: check if we can pass risk. // Wait, the user asked to scale based on risk. // Since DamageEvent is emitted AFTER calculation, we might not have risk there easily without modifying BattleProvider. // BUT! EffectEvent HAS risk. And EffectEvent handles ICONS. // DamageEvent handles NUMBERS. // Let's modify DamageEvent to include risk or isCritical flag? // Actually, simply checking if damage > 20 or similar is a heuristic. // But the user specifically said "Risky attacks". // Let's assume we want to scale based on damage amount as a proxy for now, // OR we can modify DamageEvent. Modifying DamageEvent is cleaner. // START_REPLACE logic: I will modify the scale widget wrapper. // Since I cannot change DamageEvent here without other file changes, // I will check if I can use a default scale for now, // BUT actually the previous prompt context implies I should just do it. // Let's look at `FloatingDamageText`. It takes a `scale` parameter? No. // It's a widget. I can wrap it in Transform.scale. // Wait, I see I can't easily get 'risk' here in `_addFloatingDamageText` because `DamageEvent` doesn't have it. // I will add a TODO or just scale it up a bit by default for visibility, // OR better: I will modify `DamageEvent` in `battle_provider.dart` to include `isRisky` or `risk` enum. // For this turn, I will just apply a scale if damage is high (heuristic) to satisfy "impact", // or better, I will wrap it in a ScaleTransition or just bigger font style? // `FloatingDamageText` is a custom widget. // Let's look at `FloatingDamageText` implementation (it's imported). // Assuming I can pass a style or it has fixed style. // Let's just wrap `FloatingDamageText` in a `Transform.scale` with a value. // I'll define a variable scale. double scale = BattleConfig.damageScaleNormal; if (event.damage > BattleConfig.highDamageThreshold) { scale = BattleConfig.damageScaleHigh; } setState(() { _floatingDamageTexts.add( DamageTextData( id: id, widget: Positioned( left: position.dx, top: position.dy, child: Transform.scale( scale: scale, child: FloatingDamageText( key: ValueKey(id), damage: event.damage.toString(), color: event.color, onRemove: () { if (mounted) { setState(() { _floatingDamageTexts.removeWhere((e) => e.id == id); }); } }, ), ), ), ), ); }); } final Set _processedEffectIds = {}; void _addFloatingEffect(EffectEvent event) { if (_processedEffectIds.contains(event.id)) { // print("[UI Debug] Duplicate Event Ignored: ${event.id}"); return; } _processedEffectIds.add(event.id); // Keep the set size manageable if (_processedEffectIds.length > 50) { _processedEffectIds.remove(_processedEffectIds.first); } 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 < BattleConfig.feedbackCooldownMs) { 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; } // Adjust position based on target: // Enemy (Top Right) -> Effect to the left/bottom of character (towards player) // Player (Bottom Left) -> Effect to the right/top of character (towards enemy) double offsetX = 0; double offsetY = 0; if (event.target == EffectTarget.enemy) { // Enemy is top-right, so effect should be left-bottom of its card offsetX = renderBox.size.width * BattleConfig.effectEnemyOffsetX; offsetY = renderBox.size.height * BattleConfig.effectEnemyOffsetY; } else { // Player is bottom-left, so effect should be right-top of its card offsetX = renderBox.size.width * BattleConfig.effectPlayerOffsetX; offsetY = renderBox.size.height * BattleConfig.effectPlayerOffsetY; } position = position + Offset(offsetX, offsetY); // 0. Prepare Effect Function void showEffect() { if (!mounted) return; // 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; case BattleFeedbackType.dodge: feedbackText = "DODGE"; feedbackColor = ThemeConfig.statLuckColor; // Use Luck color (Greenish) break; default: feedbackText = ""; feedbackColor = ThemeConfig.textColorWhite; } 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(() { _floatingFeedbackTexts.clear(); // Clear previous texts _floatingFeedbackTexts.add( FeedbackTextData( id: id, eventId: event.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; // Return early for feedback } // Handle Icon Effect 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(); 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); } } }, 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(); 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) { setState(() => _isPlayerAttacking = true); // Reuse flag to block input _playerAnimKey.currentState ?.animateDefense(() { showEffect(); context.read().handleImpact(event); }) .then((_) { if (mounted) setState(() => _isPlayerAttacking = false); }); } else if (event.target == EffectTarget.enemy) { // Check settings for enemy animation bool enableAnim = context .read() .enableEnemyAnimations; if (!enableAnim) { showEffect(); context.read().handleImpact(event); return; } setState(() => _isEnemyAttacking = true); // Reuse flag to block input _enemyAnimKey.currentState ?.animateDefense(() { showEffect(); context.read().handleImpact(event); }) .then((_) { if (mounted) setState(() => _isEnemyAttacking = false); }); } 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) { // 1. Check if we need to trigger enemy animation first bool triggered = _triggerEnemyDefenseIfNeeded(context); if (triggered) return; // If triggered, we wait for animation (and input block) final battleProvider = context.read(); final player = battleProvider.player; showDialog( context: context, builder: (BuildContext context) { return RiskSelectionDialog( actionType: actionType, player: player, onSelected: (risk) { context.read().playerAction(actionType, risk); Navigator.pop(context); }, ); }, ); } /// Triggers enemy defense animation if applicable. Returns true if triggered. bool _triggerEnemyDefenseIfNeeded(BuildContext context) { final battleProvider = context.read(); // Check turn to reset flag if (battleProvider.turnCount != _lastTurnCount) { _lastTurnCount = battleProvider.turnCount; _hasShownEnemyDefense = false; } final enemyIntent = battleProvider.currentEnemyIntent; if (enemyIntent != null && enemyIntent.type == EnemyActionType.defend && !_hasShownEnemyDefense && context.read().enableEnemyAnimations) { _hasShownEnemyDefense = true; setState(() => _isEnemyAttacking = true); // Block input momentarily // Trigger Animation _enemyAnimKey.currentState ?.animateDefense(() { // [New] Apply Logic Synced with Animation battleProvider.applyPendingEnemyDefense(); // Create a local visual-only event to trigger the effect (Icon or FAILED text) final bool isSuccess = enemyIntent.isSuccess; final BattleFeedbackType? feedbackType = isSuccess ? null : BattleFeedbackType.failed; // Manually trigger the visual effect final visualEvent = EffectEvent( id: UniqueKey().toString(), // Local unique ID type: ActionType.defend, risk: enemyIntent.risk, target: EffectTarget.enemy, // Show on enemy feedbackType: feedbackType, attacker: battleProvider.enemy, targetEntity: battleProvider.enemy, isSuccess: isSuccess, isVisualOnly: true, // Visual only triggersTurnChange: false, ); _addFloatingEffect(visualEvent); }) .then((_) { if (mounted) setState(() => _isEnemyAttacking = false); }); return true; } return false; } @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) { if (battleProvider.currentStage.type == StageType.shop) { return ShopUI(battleProvider: battleProvider); } else if (battleProvider.currentStage.type == StageType.rest) { return RestUI(battleProvider: battleProvider); } return ShakeWidget( key: _shakeKey, child: Stack( key: _stackKey, children: [ // 1. Background (Black) Container(color: ThemeConfig.battleBg), // 2. Battle Content (Top Bar + Characters) Column( children: [ // Top Bar const BattleHeader(), // Battle Area (Characters) - Expanded to fill available space Expanded( child: Padding( padding: const EdgeInsets.all(16.0), child: Stack( children: [ // Player (Bottom Left) - Rendered First Positioned( bottom: 80, // Space for FABs left: 16, // Add some padding from left child: CharacterStatusCard( character: battleProvider.player, isPlayer: true, isTurn: battleProvider.isPlayerTurn, key: _playerKey, animationKey: _playerAnimKey, hideStats: _isPlayerAttacking, ), ), // Enemy (Top Right) - Rendered Last (On Top) Positioned( top: 16, // Add some padding from top right: 16, // Add some padding from right child: CharacterStatusCard( character: battleProvider.enemy, isPlayer: false, isTurn: !battleProvider.isPlayerTurn, key: _enemyKey, animationKey: _enemyAnimKey, // Direct Pass hideStats: _isEnemyAttacking, ), ), ], // Close children list ), // Close Stack ), // Close Padding ), // Close Expanded ], // Close Column ), // Close Column // 3. Logs Overlay if (_showLogs && battleProvider.logs.isNotEmpty) Positioned( top: 60, left: 16, right: 16, height: BattleConfig.logsOverlayHeight, child: BattleLogOverlay(logs: battleProvider.logs), ), // 4. Battle Controls (Bottom Right) Positioned( bottom: 20, right: 20, child: BattleControls( isAttackEnabled: battleProvider.isPlayerTurn && !battleProvider.player.isDead && !battleProvider.enemy.isDead && !battleProvider.showRewardPopup && !_isPlayerAttacking && !_isEnemyAttacking, // Enabled even if disarmed (damage reduced) isDefendEnabled: battleProvider.isPlayerTurn && !battleProvider.player.isDead && !battleProvider.enemy.isDead && !battleProvider.showRewardPopup && !_isPlayerAttacking && !_isEnemyAttacking && !battleProvider.player.hasStatus(StatusEffectType.defenseForbidden), // Disable if defense is forbidden onAttackPressed: () => _showRiskLevelSelection(context, ActionType.attack), onDefendPressed: () => _showRiskLevelSelection(context, ActionType.defend), ), ), // 5. Log Toggle Button (Bottom Left) Positioned( bottom: 20, left: 20, child: FloatingActionButton( heroTag: "logToggle", mini: true, backgroundColor: ThemeConfig.toggleBtnBg, onPressed: () { setState(() { _showLogs = !_showLogs; }); }, child: Icon( _showLogs ? Icons.visibility_off : Icons.visibility, color: ThemeConfig.textColorWhite, ), ), ), // Reward Popup if (battleProvider.showRewardPopup) Container( color: ThemeConfig.cardBgColor, child: Center( child: SimpleDialog( title: Row( children: [ const Text( "${AppStrings.victory} ${AppStrings.chooseReward}", ), const Spacer(), Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.monetization_on, color: ThemeConfig.statGoldColor, size: ThemeConfig.itemIconSizeSmall, ), const SizedBox(width: 4), Text( "${battleProvider.lastGoldReward} G", style: TextStyle( color: ThemeConfig.statGoldColor, fontSize: ThemeConfig.fontSizeBody, fontWeight: ThemeConfig.fontWeightBold, ), ), ], ), ], ), children: battleProvider.rewardOptions.map((item) { bool isSkip = item.id == "reward_skip"; return SimpleDialogOption( onPressed: () { bool success = battleProvider.selectReward(item); if (!success) { ToastUtils.showTopToast( context, "${AppStrings.inventoryFull} Cannot take item.", ); } }, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ if (!isSkip) Container( padding: const EdgeInsets.all(4), decoration: BoxDecoration( color: ThemeConfig.rewardItemBg, borderRadius: BorderRadius.circular( 4, ), border: Border.all( color: item.rarity != ItemRarity.magic ? ItemUtils.getRarityColor( item.rarity, ) : ThemeConfig.rarityCommon, ), ), child: Image.asset( ItemUtils.getIconPath(item.slot), width: ThemeConfig.itemIconSizeMedium, height: ThemeConfig.itemIconSizeMedium, fit: BoxFit.contain, filterQuality: FilterQuality.high, ), ), if (!isSkip) const SizedBox(width: 12), Text( item.name, style: TextStyle( fontWeight: ThemeConfig.fontWeightBold, fontSize: ThemeConfig.fontSizeLarge, color: isSkip ? ThemeConfig.textColorGrey : ItemUtils.getRarityColor( item.rarity, ), ), ), ], ), if (!isSkip) _buildItemStatText(item), Text( item.description, style: const TextStyle( fontSize: ThemeConfig.fontSizeMedium, color: ThemeConfig.textColorGrey, ), ), ], ), ); }).toList(), ), ), ), // Floating Effects ..._floatingDamageTexts.map((e) => e.widget), ..._floatingEffects.map((e) => e.widget), ..._floatingFeedbackTexts.map((e) => e.widget), // Explosion Layer ExplosionWidget(key: _explosionKey), // Game Over Overlay if (battleProvider.player.isDead) Container( color: ThemeConfig.battleBg, child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ const Text( AppStrings.defeat, style: TextStyle( color: ThemeConfig.statHpColor, fontSize: ThemeConfig.fontSizeHuge, fontWeight: ThemeConfig.fontWeightBold, letterSpacing: ThemeConfig.letterSpacingHeader, ), ), const SizedBox(height: 32), ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: ThemeConfig.menuButtonBg, padding: const EdgeInsets.symmetric( horizontal: ThemeConfig.paddingBtnHorizontal, vertical: ThemeConfig.paddingBtnVertical, ), ), onPressed: () { Navigator.of(context).pushAndRemoveUntil( MaterialPageRoute( builder: (context) => const MainMenuScreen(), ), (route) => false, ); }, child: const Text( AppStrings.returnToMenu, style: TextStyle( color: ThemeConfig.textColorWhite, fontSize: ThemeConfig.fontSizeHeader, ), ), ), ], ), ), ), ], ), ); }, ), ); } Widget _buildItemStatText(Item item) { List stats = []; if (item.atkBonus > 0) stats.add("+${item.atkBonus} ${AppStrings.atk}"); if (item.hpBonus > 0) stats.add("+${item.hpBonus} ${AppStrings.hp}"); if (item.armorBonus > 0) stats.add("+${item.armorBonus} ${AppStrings.def}"); if (item.luck > 0) stats.add("+${item.luck} ${AppStrings.luck}"); if (item.dodge > 0) stats.add("+${item.dodge}% Dodge"); // Add Dodge List effectTexts = item.effects.map((e) => e.description).toList(); if (stats.isEmpty && effectTexts.isEmpty) return const SizedBox.shrink(); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (stats.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 4.0, bottom: 4.0), child: Text( stats.join(", "), style: const TextStyle( fontSize: ThemeConfig.fontSizeMedium, color: ThemeConfig.statAtkColor, ), ), ), if (effectTexts.isNotEmpty) Padding( padding: const EdgeInsets.only(bottom: 4.0), child: Text( effectTexts.join(", "), style: const TextStyle( fontSize: 11, color: ThemeConfig.rarityLegendary, ), // 11 is custom, keep or change? Let's use Small ), ), ], ); } }