import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/battle_provider.dart'; import '../game/model/entity.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'; 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 = []; final List<_FeedbackTextData> _floatingFeedbackTexts = []; StreamSubscription? _damageSubscription; StreamSubscription? _effectSubscription; final GlobalKey _playerKey = GlobalKey(); final GlobalKey _enemyKey = GlobalKey(); final GlobalKey _stackKey = GlobalKey(); @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { if (_scrollController.hasClients) { _scrollController.jumpTo(_scrollController.position.maxScrollExtent); } }); 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 = _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); }); } }, ), ), ), ); }); }); } 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 = _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); // feedbackType이 존재하면 해당 텍스트를 표시하고 기존 이펙트 아이콘은 건너뜜 if (event.feedbackType != null) { String feedbackText; Color feedbackColor; switch (event.feedbackType) { case BattleFeedbackType.miss: feedbackText = "MISS"; feedbackColor = Colors.grey; break; case BattleFeedbackType.failed: feedbackText = "FAILED"; feedbackColor = Colors.redAccent; break; default: feedbackText = ""; // Should not happen with current enums feedbackColor = Colors.white; } 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이 있으면 아이콘 이펙트는 표시하지 않음 } 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); 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 ResponsiveContainer( child: Consumer( builder: (context, battleProvider, child) { if (battleProvider.currentStage.type == StageType.shop) { return _buildShopUI(context, battleProvider); } else if (battleProvider.currentStage.type == StageType.rest) { return _buildRestUI(context, battleProvider); } return Stack( key: _stackKey, children: [ Container(color: Colors.black87), Column( children: [ // Top Bar Padding( padding: const EdgeInsets.all(8.0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( "Stage ${battleProvider.stage}", style: const TextStyle( color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold, ), ), Text( "Turn ${battleProvider.turnCount}", style: const TextStyle( color: Colors.white, fontSize: 18, ), ), ], ), ), // Battle Area Expanded( child: Padding( // padding: const EdgeInsets.symmetric(horizontal: 40.0), padding: const EdgeInsets.all(70.0), child: Column( children: [ // 적 영역 (우측 상단) Expanded( child: Align( alignment: Alignment.topRight, child: _buildCharacterStatus( battleProvider.enemy, isPlayer: false, isTurn: !battleProvider.isPlayerTurn, key: _enemyKey, ), ), ), // 플레이어 영역 (좌측 하단) Expanded( child: Align( alignment: Alignment.bottomLeft, child: _buildCharacterStatus( battleProvider.player, isPlayer: true, isTurn: battleProvider.isPlayerTurn, key: _playerKey, ), ), ), ], ), ), ), // Action Buttons Padding( padding: const EdgeInsets.all(16.0), child: Column( children: [ if (battleProvider.logs.isNotEmpty) Container( height: 60, padding: const EdgeInsets.all(8.0), decoration: BoxDecoration( color: Colors.black54, borderRadius: BorderRadius.circular(8), ), child: ListView.builder( reverse: true, itemCount: battleProvider.logs.length, itemBuilder: (context, index) { final logIndex = battleProvider.logs.length - 1 - index; return Text( battleProvider.logs[logIndex], style: const TextStyle( color: Colors.white70, fontSize: 12, ), ); }, ), ), const SizedBox(height: 16), Card( color: Colors.grey[900], child: 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: [ Row( children: [ Container( padding: const EdgeInsets.all(4), decoration: BoxDecoration( color: Colors.blueGrey[700], borderRadius: BorderRadius.circular(4), border: Border.all(color: Colors.grey), ), child: Icon( ItemUtils.getIcon(item.slot), color: ItemUtils.getColor(item.slot), size: 24, ), ), const SizedBox(width: 12), Text( item.name, style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 16, ), ), ], ), _buildItemStatText(item), Text( item.description, style: const TextStyle( fontSize: 12, color: Colors.grey, ), ), ], ), ); }).toList(), ), ), ), ..._floatingDamageTexts.map((e) => e.widget), ..._floatingEffects.map((e) => e.widget), ..._floatingFeedbackTexts.map((e) => e.widget), // 새로운 피드백 텍스트 추가 ], ); }, ), ); } Widget _buildShopUI(BuildContext context, BattleProvider battleProvider) { 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"), ), ], ), ); } Widget _buildRestUI(BuildContext context, BattleProvider battleProvider) { 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); battleProvider.proceedToNextStage(); }, child: const Text("Rest & Leave (+20 HP)"), ), ], ), ); } 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 isPlayer = false, bool isTurn = false, Key? key, }) { return Column( key: key, children: [ Text("Armor: ${character.armor}"), 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: !isPlayer ? Colors.red : Colors.green, backgroundColor: Colors.grey, ), ), 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(), ), ), Text("ATK: ${character.totalAtk}"), Text("DEF: ${character.totalDefense}"), // 캐릭터 아이콘/이미지 영역 추가 Container( width: 100, // 임시 크기 height: 100, // 임시 크기 decoration: BoxDecoration( color: isPlayer ? Colors.lightBlue : Colors.deepOrange, // 플레이어/적 구분 색상 borderRadius: BorderRadius.circular(8), ), child: Center( child: isPlayer ? const Icon( Icons.person, size: 60, color: Colors.white, ) // 플레이어 아이콘 : const Icon( Icons.psychology, size: 60, color: Colors.white, ), // 적 아이콘 (몬스터 대신) ), ), const SizedBox(height: 8), // 아이콘과 정보 사이 간격 if (!isPlayer) 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(); }, ), ], ); } 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(); } }); } @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}); } // 새로운 _FloatingFeedbackText 위젯 class _FloatingFeedbackText extends StatefulWidget { final String feedback; final Color color; final VoidCallback onRemove; const _FloatingFeedbackText({ Key? key, required this.feedback, required this.color, required this.onRemove, }) : super(key: key); @override __FloatingFeedbackTextState createState() => __FloatingFeedbackTextState(); } class __FloatingFeedbackTextState extends State<_FloatingFeedbackText> 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(); } }); } @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.feedback, 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 _FeedbackTextData { final String id; final Widget widget; _FeedbackTextData({required this.id, required this.widget}); }