894 lines
32 KiB
Dart
894 lines
32 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';
|
|
import '../providers/settings_provider.dart'; // Import SettingsProvider
|
|
|
|
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
|
|
DateTime? _lastFeedbackTime; // Cooldown to prevent duplicate feedback texts
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
print("[UI Debug] BattleScreen initialized: ${hashCode}"); // Debug Log
|
|
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) {
|
|
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)) {
|
|
// print("[UI Debug] Duplicate Event Ignored: ${event.id}");
|
|
return;
|
|
}
|
|
|
|
_processedEffectIds.add(event.id);
|
|
// Keep the set size manageable
|
|
if (_processedEffectIds.length > 50) {
|
|
_processedEffectIds.remove(_processedEffectIds.first);
|
|
}
|
|
|
|
if (!mounted) return;
|
|
|
|
// Feedback Text Cooldown
|
|
if (event.feedbackType != null) {
|
|
// print(
|
|
// "[UI Debug] Feedback Event: ${event.id}, Type: ${event.feedbackType}",
|
|
// );
|
|
if (_lastFeedbackTime != null &&
|
|
DateTime.now().difference(_lastFeedbackTime!).inMilliseconds < 300) {
|
|
return; // Skip if too soon
|
|
}
|
|
_lastFeedbackTime = DateTime.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;
|
|
}
|
|
|
|
position =
|
|
position +
|
|
Offset(renderBox.size.width / 2 - 30, renderBox.size.height / 2 - 30);
|
|
|
|
// 0. Prepare Effect Function
|
|
void showEffect() {
|
|
if (!mounted) return;
|
|
|
|
// Handle Feedback Text (MISS / FAILED)
|
|
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 = "";
|
|
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(
|
|
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(
|
|
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.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)
|
|
.then((_) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_isPlayerAttacking = false;
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
// 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) {
|
|
_playerAnimKey.currentState?.animateDefense(() {
|
|
showEffect();
|
|
context.read<BattleProvider>().handleImpact(event);
|
|
});
|
|
} else if (event.target == EffectTarget.enemy) {
|
|
_enemyAnimKey.currentState?.animateDefense(() {
|
|
showEffect();
|
|
context.read<BattleProvider>().handleImpact(event);
|
|
});
|
|
} 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) {
|
|
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) {
|
|
// Sync animation setting to provider logic
|
|
final settings = context.watch<SettingsProvider>();
|
|
context.read<BattleProvider>().skipAnimations =
|
|
!settings.enableEnemyAnimations;
|
|
|
|
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: CharacterStatusCard(
|
|
character: battleProvider.enemy,
|
|
isPlayer: false,
|
|
isTurn: !battleProvider.isPlayerTurn,
|
|
key: _enemyKey,
|
|
animationKey: _enemyAnimKey, // Direct Pass
|
|
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,
|
|
),
|
|
);
|
|
}
|
|
}
|