import 'package:flutter/material.dart'; import 'package:game_test/game/model/item.dart'; import 'package:provider/provider.dart'; import '../providers/battle_provider.dart'; import '../game/model/entity.dart'; import '../game/enums.dart'; import '../game/model/damage_event.dart'; import '../game/model/effect_event.dart'; import 'dart:async'; // StreamSubscription class BattleScreen extends StatefulWidget { const BattleScreen({super.key}); @override State createState() => _BattleScreenState(); } class _BattleScreenState extends State { final ScrollController _scrollController = ScrollController(); final List<_DamageTextData> _floatingDamageTexts = []; final List<_FloatingEffectData> _floatingEffects = []; StreamSubscription? _damageSubscription; StreamSubscription? _effectSubscription; final GlobalKey _playerKey = GlobalKey(); final GlobalKey _enemyKey = GlobalKey(); @override void initState() { super.initState(); // Scroll to the bottom of the log when new messages are added WidgetsBinding.instance.addPostFrameCallback((_) { if (_scrollController.hasClients) { _scrollController.jumpTo(_scrollController.position.maxScrollExtent); } }); // Subscribe to Damage Stream final battleProvider = context.read(); _damageSubscription = battleProvider.damageStream.listen( _addFloatingDamageText, ); _effectSubscription = battleProvider.effectStream.listen( _addFloatingEffect, ); } @override void dispose() { _scrollController.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 = context.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); }); } }, ), ), ), ); }); }); } void _addFloatingEffect(EffectEvent event) { 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 = context.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); IconData icon; Color color; double size; if (event.type == ActionType.attack) { if (event.risk == RiskLevel.risky) { icon = Icons.whatshot; color = Colors.redAccent; size = 60.0; } else if (event.risk == RiskLevel.normal) { icon = Icons.flash_on; color = Colors.orangeAccent; size = 40.0; } else { icon = Icons.close; color = Colors.grey; size = 30.0; } } else { icon = Icons.shield; if (event.risk == RiskLevel.risky) { color = Colors.deepPurpleAccent; size = 60.0; } else if (event.risk == RiskLevel.normal) { color = Colors.blueAccent; size = 40.0; } else { color = Colors.greenAccent; size = 30.0; } } 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); }); } }, ), ), ), ); }); }); } 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 = Colors.green; break; case RiskLevel.normal: efficiency = 1.0; infoColor = Colors.blue; break; case RiskLevel.risky: efficiency = 2.0; infoColor = Colors.red; break; } expectedValue = (baseValue * efficiency).toInt(); String valueUnit = actionType == ActionType.attack ? "Dmg" : "Armor"; String successRate = ""; switch (risk) { case RiskLevel.safe: successRate = "100%"; break; case RiskLevel.normal: successRate = "80%"; break; case RiskLevel.risky: successRate = "40%"; break; } infoText = "Success: $successRate, Eff: ${(efficiency * 100).toInt()}% ($expectedValue $valueUnit)"; return SimpleDialogOption( onPressed: () { context.read().playerAction(actionType, risk); Navigator.pop(context); // Ensure the log scrolls to the bottom after action WidgetsBinding.instance.addPostFrameCallback((_) { _scrollController.animateTo( _scrollController.position.maxScrollExtent, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); }); }, 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 Scaffold( appBar: AppBar( title: Consumer( builder: (context, provider, child) => Text( "Colosseum - Stage ${provider.stage} (${provider.currentStage.type.name.toUpperCase()})", ), ), actions: [ IconButton( icon: const Icon(Icons.refresh), onPressed: () => context.read().initializeBattle(), ), ], ), body: Consumer( builder: (context, battleProvider, child) { // UI Switching based on Stage Type if (battleProvider.currentStage.type == StageType.shop) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon(Icons.store, size: 64, color: Colors.amber), const SizedBox(height: 16), const Text("Merchant Shop", style: TextStyle(fontSize: 24)), const SizedBox(height: 8), const Text("Buying/Selling feature coming soon!"), const SizedBox(height: 32), ElevatedButton( onPressed: () => battleProvider.proceedToNextStage(), child: const Text("Leave Shop"), ), ], ), ); } else if (battleProvider.currentStage.type == StageType.rest) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon(Icons.local_hotel, size: 64, color: Colors.blue), const SizedBox(height: 16), const Text("Rest Area", style: TextStyle(fontSize: 24)), const SizedBox(height: 8), const Text("Take a breath and heal."), const SizedBox(height: 32), ElevatedButton( onPressed: () { battleProvider.player.heal(20); // Simple heal battleProvider.proceedToNextStage(); }, child: const Text("Rest & Leave (+20 HP)"), ), ], ), ); } // Default: Battle UI (for Battle and Elite) return Stack( children: [ Column( children: [ // Top (Status Area) Padding( padding: const EdgeInsets.all(8.0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ _buildCharacterStatus( battleProvider.enemy, isEnemy: true, key: _enemyKey, ), _buildCharacterStatus( battleProvider.player, isEnemy: false, key: _playerKey, ), ], ), ), // Middle (Log Area) Expanded( child: Container( color: Colors.black87, padding: const EdgeInsets.all(8.0), child: ListView.builder( controller: _scrollController, itemCount: battleProvider.battleLogs.length, itemBuilder: (context, index) { return Text( battleProvider.battleLogs[index], style: const TextStyle( color: Colors.white, fontFamily: 'Monospace', fontSize: 12, ), ); }, ), ), ), // Bottom (Control Area) Padding( padding: const EdgeInsets.all(8.0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ _buildActionButton( context, "ATTACK", ActionType.attack, battleProvider.isPlayerTurn && !battleProvider.player.isDead && !battleProvider.enemy.isDead && !battleProvider.showRewardPopup, ), _buildActionButton( context, "DEFEND", ActionType.defend, battleProvider.isPlayerTurn && !battleProvider.player.isDead && !battleProvider.enemy.isDead && !battleProvider.showRewardPopup, ), ], ), ), ], ), if (battleProvider.showRewardPopup) Container( color: Colors.black54, child: Center( child: SimpleDialog( title: const Text("Victory! Choose a Reward"), children: battleProvider.rewardOptions.map((item) { return SimpleDialogOption( onPressed: () { battleProvider.selectReward(item); }, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( item.name, style: const TextStyle( fontWeight: FontWeight.bold, ), ), _buildItemStatText(item), // Display stats here Text( item.description, style: const TextStyle( fontSize: 12, color: Colors.grey, ), ), ], ), ); }).toList(), ), ), ), ..._floatingDamageTexts.map((e) => e.widget), ..._floatingEffects.map((e) => e.widget), ], ); }, ), ); } 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"); 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: 12, color: Colors.blueAccent), ), ), if (effectTexts.isNotEmpty) Padding( padding: const EdgeInsets.only(bottom: 4.0), child: Text( effectTexts.join(", "), style: const TextStyle(fontSize: 11, color: Colors.orangeAccent), ), ), ], ); } Widget _buildCharacterStatus( Character character, { bool isEnemy = false, Key? key, }) { return Column( key: key, children: [ Text( "${character.name}: HP ${character.hp}/${character.totalMaxHp}", style: TextStyle( color: character.isDead ? Colors.red : Colors.white, fontWeight: FontWeight.bold, ), ), SizedBox( width: 100, child: LinearProgressIndicator( value: character.totalMaxHp > 0 ? character.hp / character.totalMaxHp : 0, color: isEnemy ? Colors.red : Colors.green, backgroundColor: Colors.grey, ), ), // Display Active Status Effects if (character.statusEffects.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 4.0), child: Wrap( spacing: 4.0, children: character.statusEffects.map((effect) { return Container( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 2, ), decoration: BoxDecoration( color: Colors.deepOrange, borderRadius: BorderRadius.circular(4), ), child: Text( "${effect.type.name.toUpperCase()} (${effect.duration})", style: const TextStyle( color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold, ), ), ); }).toList(), ), ), if (isEnemy) Consumer( builder: (context, provider, child) { if (provider.currentEnemyIntent != null && !character.isDead) { final intent = provider.currentEnemyIntent!; return Padding( padding: const EdgeInsets.only(top: 8.0), child: Container( padding: const EdgeInsets.all(8.0), decoration: BoxDecoration( color: Colors.black54, borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.redAccent), ), child: Column( children: [ Text( "INTENT", style: TextStyle( color: Colors.redAccent, fontSize: 10, fontWeight: FontWeight.bold, ), ), Row( mainAxisSize: MainAxisSize.min, children: [ Icon( intent.type == EnemyActionType.attack ? Icons.flash_on : Icons.shield, color: Colors.yellow, size: 16, ), const SizedBox(width: 4), Text( intent.description, style: const TextStyle( color: Colors.white, fontSize: 12, ), ), ], ), ], ), ), ); } return const SizedBox.shrink(); }, ), if (!isEnemy) ...[ Text("Armor: ${character.armor}"), Text("ATK: ${character.totalAtk}"), Text("DEF: ${character.totalDefense}"), ], ], ); } Widget _buildActionButton( BuildContext context, String text, ActionType actionType, bool isEnabled, ) { return ElevatedButton( onPressed: isEnabled ? () => _showRiskLevelSelection(context, actionType) : null, style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 15), backgroundColor: Colors.blueGrey, foregroundColor: Colors.white, textStyle: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), child: Text(text), ); } } class _FloatingDamageText extends StatefulWidget { final String damage; final Color color; final VoidCallback onRemove; const _FloatingDamageText({ Key? key, required this.damage, required this.color, required this.onRemove, }) : super(key: key); @override __FloatingDamageTextState createState() => __FloatingDamageTextState(); } class __FloatingDamageTextState extends State<_FloatingDamageText> with SingleTickerProviderStateMixin { late AnimationController _controller; late Animation _offsetAnimation; late Animation _opacityAnimation; @override void initState() { super.initState(); _controller = AnimationController( duration: const Duration(milliseconds: 1000), vsync: this, ); _offsetAnimation = Tween( begin: const Offset(0.0, 0.0), end: const Offset(0.0, -1.5), // 위로 띄울 높이 ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut)); _opacityAnimation = Tween(begin: 1.0, end: 0.0).animate( CurvedAnimation( parent: _controller, curve: const Interval( 0.5, 1.0, curve: Curves.easeOut, ), // 절반 이후부터 투명도 감소 ), ); _controller.forward().then((_) { if (mounted) { widget.onRemove(); // 애니메이션 완료 후 콜백 호출하여 위젯 제거 요청 // _controller.dispose(); // 제거: dispose() 메서드에서 처리됨 } }); } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _controller, builder: (context, child) { return FractionalTranslation( translation: _offsetAnimation.value, child: Opacity( opacity: _opacityAnimation.value, child: Material( color: Colors.transparent, child: Text( widget.damage, style: TextStyle( color: widget.color, fontSize: 20, fontWeight: FontWeight.bold, shadows: const [ Shadow( blurRadius: 2.0, color: Colors.black, offset: Offset(1.0, 1.0), ), ], ), ), ), ), ); }, ); } } class _DamageTextData { final String id; final Widget widget; _DamageTextData({required this.id, required this.widget}); } class _FloatingEffect extends StatefulWidget { final IconData icon; final Color color; final double size; final VoidCallback onRemove; const _FloatingEffect({ Key? key, required this.icon, required this.color, required this.size, required this.onRemove, }) : super(key: key); @override __FloatingEffectState createState() => __FloatingEffectState(); } class __FloatingEffectState extends State<_FloatingEffect> with SingleTickerProviderStateMixin { late AnimationController _controller; late Animation _scaleAnimation; late Animation _opacityAnimation; @override void initState() { super.initState(); _controller = AnimationController( duration: const Duration(milliseconds: 800), vsync: this, ); _scaleAnimation = Tween( begin: 0.5, end: 1.5, ).animate(CurvedAnimation(parent: _controller, curve: Curves.elasticOut)); _opacityAnimation = Tween(begin: 1.0, end: 0.0).animate( CurvedAnimation( parent: _controller, curve: const Interval(0.5, 1.0, curve: Curves.easeOut), ), ); _controller.forward().then((_) { if (mounted) { widget.onRemove(); } }); } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _controller, builder: (context, child) { return Transform.scale( scale: _scaleAnimation.value, child: Opacity( opacity: _opacityAnimation.value, child: Icon(widget.icon, color: widget.color, size: widget.size), ), ); }, ); } } class _FloatingEffectData { final String id; final Widget widget; _FloatingEffectData({required this.id, required this.widget}); }