import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/battle_provider.dart'; import '../game/enums.dart'; import '../game/model/item.dart'; import '../game/model/damage_event.dart'; import '../game/model/effect_event.dart'; import 'dart:async'; import '../widgets/responsive_container.dart'; import '../utils/item_utils.dart'; import '../widgets/battle/character_status_card.dart'; import '../widgets/battle/battle_log_overlay.dart'; import '../widgets/battle/floating_battle_texts.dart'; import '../widgets/stage/shop_ui.dart'; import '../widgets/stage/rest_ui.dart'; import '../widgets/battle/shake_widget.dart'; import '../widgets/battle/battle_animation_widget.dart'; import '../widgets/battle/explosion_widget.dart'; import 'main_menu_screen.dart'; import '../game/config/battle_config.dart'; import '../game/config/theme_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 _explosionKey = GlobalKey(); bool _showLogs = true; bool _isPlayerAttacking = false; // Player Attack Animation State @override void initState() { super.initState(); 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) { WidgetsBinding.instance.addPostFrameCallback((_) { 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 - 20, -20); 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); }); } }, ), ), ), ); }); }); } final Set _processedEffectIds = {}; void _addFloatingEffect(EffectEvent event) { if (_processedEffectIds.contains(event.id)) { return; } _processedEffectIds.add(event.id); if (_processedEffectIds.length > 20) { _processedEffectIds.remove(_processedEffectIds.first); } WidgetsBinding.instance.addPostFrameCallback((_) { 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이 있으면 아이콘 이펙트는 표시하지 않음 } // 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(); 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. 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?; if (playerBox != null && enemyBox != null) { final playerPos = playerBox.localToGlobal(Offset.zero); final enemyPos = enemyBox.localToGlobal(Offset.zero); final offset = enemyPos - playerPos; // Start Animation: Hide Stats setState(() { _isPlayerAttacking = true; }); _playerAnimKey.currentState ?.animateAttack(offset, () { showEffect(); // Show Effect at Impact! // 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); } } }, event.risk) .then((_) { // End Animation: Show Stats if (mounted) { setState(() { _isPlayerAttacking = false; }); } }); } } else { // Not a player attack, show immediately showEffect(); } }); } void _showRiskLevelSelection(BuildContext context, ActionType actionType) { final player = context.read().player; final baseValue = actionType == ActionType.attack ? player.totalAtk : player.totalDefense; showDialog( context: context, builder: (BuildContext context) { return SimpleDialog( title: Text("Select Risk Level for ${actionType.name}"), children: RiskLevel.values.map((risk) { String infoText = ""; Color infoColor = Colors.black; double efficiency = 0.0; int expectedValue = 0; switch (risk) { case RiskLevel.safe: efficiency = 0.5; infoColor = ThemeConfig.riskSafe; break; case RiskLevel.normal: efficiency = 1.0; infoColor = ThemeConfig.riskNormal; break; case RiskLevel.risky: efficiency = 2.0; infoColor = ThemeConfig.riskRisky; break; } expectedValue = (baseValue * efficiency).toInt(); String valueUnit = actionType == ActionType.attack ? "Dmg" : "Armor"; String successRate = ""; double baseChance = 0.0; switch (risk) { case RiskLevel.safe: baseChance = 1.0; break; case RiskLevel.normal: baseChance = 0.8; break; case RiskLevel.risky: baseChance = 0.4; break; } double finalChance = baseChance + (player.totalLuck / 100.0); if (finalChance > 1.0) finalChance = 1.0; successRate = "${(finalChance * 100).toInt()}%"; infoText = "Success: $successRate, Eff: ${(efficiency * 100).toInt()}% ($expectedValue $valueUnit)"; return SimpleDialogOption( onPressed: () { context.read().playerAction(actionType, risk); Navigator.pop(context); }, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( risk.name, style: const TextStyle(fontWeight: FontWeight.bold), ), Text( infoText, style: TextStyle(fontSize: 12, color: infoColor), ), ], ), ); }).toList(), ); }, ); } @override Widget build(BuildContext context) { 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 Padding( padding: const EdgeInsets.all(8.0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Flexible( child: FittedBox( fit: BoxFit.scaleDown, child: Text( "Stage ${battleProvider.stage}", style: const TextStyle( color: ThemeConfig.textColorWhite, fontSize: ThemeConfig.fontSizeHeader, fontWeight: ThemeConfig.fontWeightBold, ), ), ), ), Flexible( child: FittedBox( fit: BoxFit.scaleDown, child: Text( "Turn ${battleProvider.turnCount}", style: const TextStyle( color: ThemeConfig.textColorWhite, fontSize: ThemeConfig.fontSizeHeader, ), ), ), ), ], ), ), // Battle Area (Characters) - Expanded to fill available space Expanded( child: Padding( padding: const EdgeInsets.all(16.0), child: Stack( children: [ // Enemy (Top Right) Positioned( top: 0, right: 0, child: CharacterStatusCard( character: battleProvider.enemy, isPlayer: false, isTurn: !battleProvider.isPlayerTurn, key: _enemyKey, ), ), // Player (Bottom Left) Positioned( bottom: 80, // Space for FABs left: 0, child: CharacterStatusCard( character: battleProvider.player, isPlayer: true, isTurn: battleProvider.isPlayerTurn, key: _playerKey, animationKey: _playerAnimKey, hideStats: _isPlayerAttacking, ), ), ], ), ), ), ], ), // 3. Logs Overlay if (_showLogs && battleProvider.logs.isNotEmpty) Positioned( top: 60, left: 16, right: 16, height: 150, child: BattleLogOverlay(logs: battleProvider.logs), ), // 4. Floating Action Buttons (Bottom Right) Positioned( bottom: 20, right: 20, child: Column( mainAxisSize: MainAxisSize.min, children: [ _buildFloatingActionButton( context, "ATK", ThemeConfig.btnActionActive, ActionType.attack, battleProvider.isPlayerTurn && !battleProvider.player.isDead && !battleProvider.enemy.isDead && !battleProvider.showRewardPopup, ), const SizedBox(height: 16), _buildFloatingActionButton( context, "DEF", ThemeConfig.btnDefendActive, ActionType.defend, battleProvider.isPlayerTurn && !battleProvider.player.isDead && !battleProvider.enemy.isDead && !battleProvider.showRewardPopup, ), ], ), ), // 5. Log Toggle Button (Bottom Left) Positioned( bottom: 20, left: 20, child: FloatingActionButton( heroTag: "logToggle", mini: true, backgroundColor: Colors.grey[800], 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("Victory! Choose a Reward"), const Spacer(), Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.monetization_on, color: ThemeConfig.statGoldColor, size: 18), 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) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text( "Inventory is full! Cannot take item.", ), backgroundColor: Colors.red, ), ); } }, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ if (!isSkip) Container( padding: const EdgeInsets.all(4), decoration: BoxDecoration( color: Colors.blueGrey[700], 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: 24, height: 24, fit: BoxFit.contain, ), ), 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( "DEFEAT", style: TextStyle( color: ThemeConfig.statHpColor, fontSize: ThemeConfig.fontSizeHuge, fontWeight: ThemeConfig.fontWeightBold, letterSpacing: 4.0, ), ), const SizedBox(height: 32), ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: Colors.grey[800], padding: const EdgeInsets.symmetric( horizontal: 32, vertical: 16, ), ), onPressed: () { Navigator.of(context).pushAndRemoveUntil( MaterialPageRoute( builder: (context) => const MainMenuScreen(), ), (route) => false, ); }, child: const Text( "Return to Main Menu", style: TextStyle( color: ThemeConfig.textColorWhite, fontSize: ThemeConfig.fontSizeHeader, ), ), ), ], ), ), ), ], ), ); }, ), ); } Widget _buildItemStatText(Item item) { List stats = []; if (item.atkBonus > 0) stats.add("+${item.atkBonus} ATK"); if (item.hpBonus > 0) stats.add("+${item.hpBonus} HP"); if (item.armorBonus > 0) stats.add("+${item.armorBonus} DEF"); if (item.luck > 0) stats.add("+${item.luck} Luck"); 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 ), ), ], ); } Widget _buildFloatingActionButton( BuildContext context, String label, Color color, ActionType actionType, bool isEnabled, ) { String iconPath; if (actionType == ActionType.attack) { iconPath = 'assets/data/icon/icon_weapon.png'; } else { iconPath = 'assets/data/icon/icon_shield.png'; } return FloatingActionButton( heroTag: label, onPressed: isEnabled ? () => _showRiskLevelSelection(context, actionType) : null, backgroundColor: isEnabled ? color : ThemeConfig.btnDisabled, child: Image.asset( iconPath, width: 32, height: 32, color: ThemeConfig.textColorWhite, // Tint icon white fit: BoxFit.contain, ), ); } }