game/lib/screens/battle_screen.dart

1229 lines
42 KiB
Dart

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';
import '../widgets/battle/effect_sprite_widget.dart';
enum AnimationPhase { none, start, middle, end }
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;
StreamSubscription<HealEvent>? _healSubscription;
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>();
final GlobalKey<EffectSpriteWidgetState> _effectSpriteKey =
GlobalKey<EffectSpriteWidgetState>();
bool _showLogs = false;
bool _isPlayerAttacking = false; // Player Attack Animation State
bool _isEnemyAttacking = false; // Enemy Attack Animation State
bool _showEquipmentSwapPanel = false;
bool _isCompletingReward = false;
DateTime? _lastFeedbackTime; // Cooldown to prevent duplicate feedback texts
// New State for Interactive Defense Animation
int _lastTurnCount = -1;
bool _hasShownEnemyDefense = false;
AnimationPhase _playerAnimPhase = AnimationPhase.none;
String? _getOverrideImage(bool isPlayer) {
if (!isPlayer) {
return null; // Enemy animation image logic can be added later
}
if (_playerAnimPhase == AnimationPhase.start) {
return "assets/images/character/warrior_attack_1.png";
} else if (_playerAnimPhase == AnimationPhase.middle) {
return null; // Middle phase now uses default image or another image
} else if (_playerAnimPhase == AnimationPhase.end) {
return "assets/images/character/warrior_attack_2.png";
}
return null;
}
@override
void initState() {
super.initState();
final battleProvider = context.read<BattleProvider>();
_damageSubscription = battleProvider.damageStream.listen(
_addFloatingDamageText,
);
_effectSubscription = battleProvider.effectStream.listen(
_addFloatingEffect,
);
_healSubscription = battleProvider.healStream.listen(_onHealEvent);
}
@override
void dispose() {
_damageSubscription?.cancel();
_effectSubscription?.cancel();
_healSubscription?.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();
final double scale =
event.risk == RiskLevel.risky ||
(event.risk == null &&
event.damage > BattleConfig.highDamageThreshold)
? BattleConfig.damageScaleHigh
: BattleConfig.damageScaleNormal;
setState(() {
_floatingDamageTexts.add(
DamageTextData(
id: id,
widget: Positioned(
key: ValueKey('pos_$id'),
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);
});
}
},
),
),
),
),
);
});
}
void _onHealEvent(HealEvent event) {
if (!mounted) return;
// Find position: Default to center of screen
Offset position = Offset(
MediaQuery.of(context).size.width / 2,
MediaQuery.of(context).size.height / 2,
);
// Try to get player's position if visible (in Battle UI)
if (event.target == HealTarget.player &&
_playerKey.currentContext != null) {
RenderBox? renderBox =
_playerKey.currentContext!.findRenderObject() as RenderBox?;
if (renderBox != null) {
position = renderBox.localToGlobal(
Offset(renderBox.size.width / 2, renderBox.size.height / 2),
);
}
}
// Play visual effect (heal.png has 4 frames)
_effectSpriteKey.currentState?.playEffect(
position: position,
assetPath: 'assets/images/effects/heal.png',
frameCount: 4,
tileWidth: 100.0, // Assuming each frame is 100x100
tileHeight: 100.0,
scale: 2.0,
);
// Play floating text
final String id = UniqueKey().toString();
setState(() {
_floatingDamageTexts.add(
DamageTextData(
id: id,
widget: Positioned(
key: ValueKey('pos_$id'),
left: position.dx + BattleConfig.damageTextOffsetX,
top: position.dy + BattleConfig.damageTextOffsetY,
child: FloatingDamageText(
key: ValueKey(id),
damage: "+${event.amount}",
color: ThemeConfig.statHpPlayerColor, // Green color for heal
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);
// Keep the set size manageable
if (_processedEffectIds.length > 50) {
_processedEffectIds.remove(_processedEffectIds.first);
}
if (!mounted) return;
bool shouldShowFeedback = true;
if (event.feedbackType != null) {
final now = DateTime.now();
if (_lastFeedbackTime != null &&
now.difference(_lastFeedbackTime!).inMilliseconds <
BattleConfig.feedbackCooldownMs) {
shouldShowFeedback = false;
} else {
_lastFeedbackTime = 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) {
if (!shouldShowFeedback) return;
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(
key: ValueKey('pos_$id'),
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(
key: ValueKey('pos_$id'),
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.isVisualOnly) {
showEffect();
context.read<BattleProvider>().handleImpact(event);
} else 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<BattleProvider>().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,
onAnimationStart: () {
if (mounted) {
setState(() => _playerAnimPhase = AnimationPhase.start);
}
},
onAnimationMiddle: () {
if (mounted) {
setState(() => _playerAnimPhase = AnimationPhase.middle);
}
},
onAnimationEnd: () {
if (mounted) {
setState(() => _playerAnimPhase = AnimationPhase.end);
}
},
)
.then((_) {
if (mounted) {
setState(() {
_isPlayerAttacking = false;
_playerAnimPhase = AnimationPhase.none;
});
}
});
}
}
// 2. Enemy Attack Animation Trigger (Success or Miss)
else if (event.type == ActionType.attack &&
event.target == EffectTarget.player) {
bool enableAnim = context.read<SettingsProvider>().enableEnemyAnimations;
if (!enableAnim) {
showEffect();
context.read<BattleProvider>().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<BattleProvider>().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<BattleProvider>().handleImpact(event);
})
.then((_) {
if (mounted) setState(() => _isPlayerAttacking = false);
});
} else if (event.target == EffectTarget.enemy) {
// Check settings for enemy animation
bool enableAnim = context
.read<SettingsProvider>()
.enableEnemyAnimations;
if (!enableAnim) {
showEffect();
context.read<BattleProvider>().handleImpact(event);
return;
}
setState(() => _isEnemyAttacking = true); // Reuse flag to block input
_enemyAnimKey.currentState
?.animateDefense(() {
showEffect();
context.read<BattleProvider>().handleImpact(event);
})
.then((_) {
if (mounted) setState(() => _isEnemyAttacking = false);
});
} else {
showEffect();
context.read<BattleProvider>().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<BattleProvider>().handleImpact(event);
});
} else {
// Success events (Icon)
context.read<BattleProvider>().handleImpact(event);
}
}
}
void _showRiskLevelSelection(BuildContext context, ActionType actionType) {
if (_showEquipmentSwapPanel) {
setState(() => _showEquipmentSwapPanel = false);
}
// 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<BattleProvider>();
final player = battleProvider.player;
showDialog(
context: context,
builder: (BuildContext context) {
return RiskSelectionDialog(
actionType: actionType,
player: player,
onSelected: (risk) {
context.read<BattleProvider>().playerAction(actionType, risk);
Navigator.pop(context);
},
);
},
);
}
/// Triggers enemy defense animation if applicable. Returns true if triggered.
bool _triggerEnemyDefenseIfNeeded(BuildContext context) {
final battleProvider = context.read<BattleProvider>();
// 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<SettingsProvider>().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;
}
void _showInventoryDialog(BuildContext context) {
if (_showEquipmentSwapPanel) {
setState(() => _showEquipmentSwapPanel = false);
}
final battleProvider = context.read<BattleProvider>();
final List<Item> consumables = battleProvider.player.inventory
.where((item) => item.slot == EquipmentSlot.consumable)
.toList();
if (consumables.isEmpty) {
ToastUtils.showTopToast(context, "No consumable items!");
return;
}
showDialog(
context: context,
builder: (context) {
return SimpleDialog(
title: const Text("Use Item"),
children: consumables.map((item) {
return SimpleDialogOption(
onPressed: () {
battleProvider.useConsumable(item);
Navigator.pop(context);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: ThemeConfig.rewardItemBg,
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: ItemUtils.getRarityColor(item.rarity),
),
),
child: Image.asset(
ItemUtils.getIconPath(item.slot),
width: ThemeConfig.itemIconSizeMedium,
height: ThemeConfig.itemIconSizeMedium,
fit: BoxFit.contain,
),
),
const SizedBox(width: 12),
Text(
item.name,
style: TextStyle(
fontWeight: ThemeConfig.fontWeightBold,
fontSize: ThemeConfig.fontSizeLarge,
color: ItemUtils.getRarityColor(item.rarity),
),
),
],
),
_buildItemStatText(item),
],
),
);
}).toList(),
);
},
);
}
void _toggleEquipmentSwapPanel() {
setState(() {
_showEquipmentSwapPanel = !_showEquipmentSwapPanel;
});
}
Widget _buildEquipmentSwapPanel() {
return Material(
color: Colors.transparent,
child: Container(
height: 236,
decoration: BoxDecoration(
color: ThemeConfig.battleBg.withValues(alpha: 0.92),
border: Border.all(color: ThemeConfig.textColorGrey),
borderRadius: BorderRadius.circular(8),
boxShadow: const [
BoxShadow(
color: Colors.black54,
blurRadius: 10,
offset: Offset(0, 4),
),
],
),
child: Column(
children: [
SizedBox(
height: 40,
child: Row(
children: [
const SizedBox(width: 10),
const Icon(
Icons.swap_horiz,
color: ThemeConfig.mainIconColor,
size: 20,
),
const SizedBox(width: 6),
const Expanded(
child: Text(
"Equipment",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: ThemeConfig.textColorWhite,
fontSize: ThemeConfig.fontSizeBody,
fontWeight: ThemeConfig.fontWeightBold,
),
),
),
IconButton(
visualDensity: VisualDensity.compact,
onPressed: () {
setState(() => _showEquipmentSwapPanel = false);
},
icon: const Icon(
Icons.close,
color: ThemeConfig.textColorWhite,
size: 18,
),
),
],
),
),
const Divider(height: 1, color: ThemeConfig.textColorGrey),
const Expanded(
child: InventoryGridWidget(
mode: InventoryGridMode.equipmentSwap,
equipmentOnly: true,
showHeader: false,
gridPadding: EdgeInsets.all(8.0),
childAspectRatio: 1.05,
),
),
],
),
),
);
}
bool _canUseEquipmentSwap(BattleProvider battleProvider) {
return battleProvider.isPlayerTurn &&
!battleProvider.player.isDead &&
!battleProvider.enemy.isDead &&
!battleProvider.showRewardPopup &&
!_isPlayerAttacking &&
!_isEnemyAttacking;
}
Widget _buildEquipmentSwapButton(BattleProvider battleProvider) {
final canUse = _canUseEquipmentSwap(battleProvider);
return FloatingActionButton(
heroTag: "equipmentSwap",
mini: true,
backgroundColor: canUse
? ThemeConfig.toggleBtnBg
: ThemeConfig.btnDisabled,
onPressed: canUse ? _toggleEquipmentSwapPanel : null,
child: Icon(
_showEquipmentSwapPanel && canUse ? Icons.close : Icons.swap_horiz,
color: ThemeConfig.textColorWhite,
),
);
}
bool get _hasPendingBattleAnimations {
return _isPlayerAttacking ||
_isEnemyAttacking ||
_floatingDamageTexts.isNotEmpty ||
_floatingEffects.isNotEmpty ||
_floatingFeedbackTexts.isNotEmpty ||
(_explosionKey.currentState?.isAnimating ?? false);
}
Future<void> _waitForBattleAnimationsToSettle() async {
final deadline = DateTime.now().add(
AnimationConfig.attackRiskyTotal +
AnimationConfig.floatingTextDuration +
const Duration(milliseconds: 400),
);
while (mounted &&
_hasPendingBattleAnimations &&
DateTime.now().isBefore(deadline)) {
await Future<void>.delayed(const Duration(milliseconds: 50));
}
}
Future<void> _selectRewardAfterAnimationsIfNeeded(Item item) async {
if (_isCompletingReward) return;
final battleProvider = context.read<BattleProvider>();
final shouldWaitForShop =
battleProvider.nextStageType == StageType.shop &&
_hasPendingBattleAnimations;
setState(() => _isCompletingReward = true);
final success = battleProvider.selectReward(item, completeStage: false);
if (!success) {
if (mounted) {
setState(() => _isCompletingReward = false);
ToastUtils.showTopToast(
context,
"${AppStrings.inventoryFull} Cannot take item.",
);
}
return;
}
if (shouldWaitForShop) {
await _waitForBattleAnimationsToSettle();
}
if (!mounted) return;
context.read<BattleProvider>().completeStage();
setState(() => _isCompletingReward = false);
}
@override
Widget build(BuildContext context) {
return ResponsiveContainer(
child: Stack(
children: [
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 _buildBattleUI(battleProvider);
},
),
EffectSpriteWidget(key: _effectSpriteKey),
],
),
);
}
Widget _buildBattleUI(BattleProvider battleProvider) {
return ShakeWidget(
key: _shakeKey,
child: Stack(
key: _stackKey,
children: [
// 1. Background Image
Container(
decoration: const BoxDecoration(
image: DecorationImage(
image: AssetImage('assets/images/background/tier_1.jpg'),
fit: BoxFit.cover,
),
),
),
// 1.1 Opacity Layer
Container(color: Colors.black.withValues(alpha: 0.7)),
// 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,
overrideImage: _getOverrideImage(true),
),
),
// 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),
onItemPressed: () => _showInventoryDialog(context),
),
),
if (_showEquipmentSwapPanel && _canUseEquipmentSwap(battleProvider))
Positioned(
bottom: 20,
right: 96,
width: 260,
child: _buildEquipmentSwapPanel(),
),
// 5. Log Toggle Button (Bottom Left)
Positioned(
bottom: 20,
left: 20,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildEquipmentSwapButton(battleProvider),
const SizedBox(height: 12),
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: _isCompletingReward
? null
: () => _selectRewardAfterAnimationsIfNeeded(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<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}");
if (item.dodge > 0) stats.add("+${item.dodge}% Dodge"); // Add Dodge
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
),
),
],
);
}
}