406 lines
13 KiB
Dart
406 lines
13 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import 'package:provider/provider.dart';
|
|
import '../providers.dart';
|
|
|
|
import '../game/enums.dart';
|
|
import '../game/models.dart';
|
|
import '../widgets.dart';
|
|
import '../utils.dart';
|
|
import '../game/config.dart';
|
|
import 'battle_visual_handler.dart';
|
|
|
|
class BattleScreen extends StatefulWidget {
|
|
const BattleScreen({super.key});
|
|
|
|
@override
|
|
State<BattleScreen> createState() => _BattleScreenState();
|
|
}
|
|
|
|
class _BattleScreenState extends State<BattleScreen> with BattleVisualHandler {
|
|
bool _showLogs = false;
|
|
bool _showEquipmentSwapPanel = false;
|
|
|
|
// New State for Interactive Defense Animation
|
|
int _lastTurnCount = -1;
|
|
bool _hasShownEnemyDefense = false;
|
|
|
|
String? _getOverrideImage(bool isPlayer) {
|
|
if (!isPlayer) {
|
|
return null; // Enemy animation image logic can be added later
|
|
}
|
|
|
|
if (playerAnimPhase == AnimationPhase.block) {
|
|
return "assets/images/character/Knight-Block.png";
|
|
}
|
|
|
|
if (playerAnimPhase == AnimationPhase.hurt) {
|
|
return "assets/images/character/Knight-Hurt.png";
|
|
}
|
|
|
|
if (playerAnimPhase == AnimationPhase.start ||
|
|
playerAnimPhase == AnimationPhase.middle ||
|
|
playerAnimPhase == AnimationPhase.end) {
|
|
if (!isAttackSuccess) return null; // Idle for failed attacks
|
|
|
|
if (activeRiskLevel == RiskLevel.safe) {
|
|
return "assets/images/character/Knight-Attack01.png";
|
|
} else if (activeRiskLevel == RiskLevel.normal) {
|
|
return "assets/images/character/Knight-Attack02.png";
|
|
} else if (activeRiskLevel == RiskLevel.risky) {
|
|
return "assets/images/character/Knight-Attack03.png";
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
setupVisualListeners(context.read<BattleProvider>());
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
disposeVisualListeners();
|
|
super.dispose();
|
|
}
|
|
|
|
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,
|
|
);
|
|
|
|
onEffectEvent(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),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
ItemStatWidget(item: 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,
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ResponsiveContainer(
|
|
child: Stack(
|
|
key: rootStackKey,
|
|
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 Stack(
|
|
children: [
|
|
Column(
|
|
children: [
|
|
const BattleHeader(),
|
|
Expanded(
|
|
child: BattleArena(
|
|
battleProvider: battleProvider,
|
|
playerKey: playerKey,
|
|
enemyKey: enemyKey,
|
|
playerAnimKey: playerAnimKey,
|
|
enemyAnimKey: enemyAnimKey,
|
|
shakeKey: shakeKey,
|
|
stackKey: stackKey,
|
|
isPlayerAttacking: isPlayerAttacking,
|
|
isEnemyAttacking: isEnemyAttacking,
|
|
playerOverrideImage: _getOverrideImage(true),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
BattleBottomSection(
|
|
battleProvider: battleProvider,
|
|
showLogs: _showLogs,
|
|
isPlayerAttacking: isPlayerAttacking,
|
|
isEnemyAttacking: isEnemyAttacking,
|
|
onToggleLogs: () => setState(() => _showLogs = !_showLogs),
|
|
onAttackPressed: () =>
|
|
_showRiskLevelSelection(context, ActionType.attack),
|
|
onDefendPressed: () =>
|
|
_showRiskLevelSelection(context, ActionType.defend),
|
|
onItemPressed: () => _showInventoryDialog(context),
|
|
equipmentSwapButton: _buildEquipmentSwapButton(battleProvider),
|
|
equipmentSwapPanel:
|
|
_showEquipmentSwapPanel && _canUseEquipmentSwap(battleProvider)
|
|
? _buildEquipmentSwapPanel()
|
|
: null,
|
|
),
|
|
|
|
// Reward Popup
|
|
if (battleProvider.showRewardPopup)
|
|
BattleRewardOverlay(battleProvider: battleProvider),
|
|
|
|
// 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) const BattleDefeatOverlay(),
|
|
],
|
|
);
|
|
}
|
|
}
|