game/lib/screens/battle_screen.dart

836 lines
31 KiB
Dart

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';
import '../game/config/app_strings.dart';
class BattleScreen extends StatefulWidget {
const BattleScreen({super.key});
@override
State<BattleScreen> createState() => _BattleScreenState();
}
class _BattleScreenState extends State<BattleScreen> {
final List<DamageTextData> _floatingDamageTexts = [];
final List<FloatingEffectData> _floatingEffects = [];
final List<FeedbackTextData> _floatingFeedbackTexts = [];
StreamSubscription<DamageEvent>? _damageSubscription;
StreamSubscription<EffectEvent>? _effectSubscription;
final GlobalKey _playerKey = GlobalKey();
final GlobalKey _enemyKey = GlobalKey();
final GlobalKey _stackKey = GlobalKey();
final GlobalKey<ShakeWidgetState> _shakeKey = GlobalKey<ShakeWidgetState>();
final GlobalKey<BattleAnimationWidgetState> _playerAnimKey =
GlobalKey<BattleAnimationWidgetState>();
final GlobalKey<BattleAnimationWidgetState> _enemyAnimKey =
GlobalKey<BattleAnimationWidgetState>(); // Added Enemy Anim Key
final GlobalKey<ExplosionWidgetState> _explosionKey =
GlobalKey<ExplosionWidgetState>();
bool _showLogs = false;
bool _isPlayerAttacking = false; // Player Attack Animation State
bool _isEnemyAttacking = false; // Enemy Attack Animation State
@override
void initState() {
super.initState();
final battleProvider = context.read<BattleProvider>();
_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<String> _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 if (event.type == ActionType.attack &&
event.target == EffectTarget.player &&
event.feedbackType == null) {
// 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!
// 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
showEffect();
}
});
}
void _showRiskLevelSelection(BuildContext context, ActionType actionType) {
final player = context.read<BattleProvider>().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<BattleProvider>().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<BattleProvider>(
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(
"${AppStrings.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: BattleAnimationWidget(
key: _enemyAnimKey,
child: CharacterStatusCard(
character: battleProvider.enemy,
isPlayer: false,
isTurn: !battleProvider.isPlayerTurn,
key: _enemyKey,
hideStats: _isEnemyAttacking,
),
),
),
// 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("${AppStrings.victory} ${AppStrings.chooseReward}"),
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(
"${AppStrings.inventoryFull} 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,
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: 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(
AppStrings.returnToMenu,
style: TextStyle(
color: ThemeConfig.textColorWhite,
fontSize: ThemeConfig.fontSizeHeader,
),
),
),
],
),
),
),
],
),
);
},
),
);
}
Widget _buildItemStatText(Item item) {
List<String> 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}");
List<String> 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,
filterQuality: FilterQuality.high,
),
);
}
}