633 lines
21 KiB
Dart
633 lines
21 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/battle/stage_ui.dart';
|
|
import 'main_menu_screen.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();
|
|
bool _showLogs = true;
|
|
|
|
@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);
|
|
});
|
|
}
|
|
},
|
|
),
|
|
),
|
|
),
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
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<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 = 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<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 Stack(
|
|
key: _stackKey,
|
|
children: [
|
|
// 1. Background (Black)
|
|
Container(color: Colors.black87),
|
|
|
|
// 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: Colors.white,
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Flexible(
|
|
child: FittedBox(
|
|
fit: BoxFit.scaleDown,
|
|
child: Text(
|
|
"Turn ${battleProvider.turnCount}",
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 18,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// 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,
|
|
),
|
|
),
|
|
// Player (Bottom Left)
|
|
Positioned(
|
|
bottom: 80, // Space for FABs
|
|
left: 0,
|
|
child: CharacterStatusCard(
|
|
character: battleProvider.player,
|
|
isPlayer: true,
|
|
isTurn: battleProvider.isPlayerTurn,
|
|
key: _playerKey,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
|
|
// 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",
|
|
Icons.whatshot,
|
|
Colors.redAccent,
|
|
ActionType.attack,
|
|
battleProvider.isPlayerTurn &&
|
|
!battleProvider.player.isDead &&
|
|
!battleProvider.enemy.isDead &&
|
|
!battleProvider.showRewardPopup,
|
|
),
|
|
const SizedBox(height: 16),
|
|
_buildFloatingActionButton(
|
|
context,
|
|
"DEF",
|
|
Icons.shield,
|
|
Colors.blueAccent,
|
|
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: Colors.white,
|
|
),
|
|
),
|
|
),
|
|
|
|
// Reward Popup
|
|
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(),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Floating Effects
|
|
..._floatingDamageTexts.map((e) => e.widget),
|
|
..._floatingEffects.map((e) => e.widget),
|
|
..._floatingFeedbackTexts.map((e) => e.widget),
|
|
|
|
// Game Over Overlay
|
|
if (battleProvider.player.isDead)
|
|
Container(
|
|
color: Colors.black87,
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Text(
|
|
"DEFEAT",
|
|
style: TextStyle(
|
|
color: Colors.red,
|
|
fontSize: 48,
|
|
fontWeight: FontWeight.bold,
|
|
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(
|
|
"Return to Main Menu",
|
|
style: TextStyle(color: Colors.white, fontSize: 18),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildItemStatText(Item item) {
|
|
List<String> 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<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: 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 _buildFloatingActionButton(
|
|
BuildContext context,
|
|
String label,
|
|
IconData icon,
|
|
Color color,
|
|
ActionType actionType,
|
|
bool isEnabled,
|
|
) {
|
|
return FloatingActionButton(
|
|
heroTag: label,
|
|
onPressed: isEnabled
|
|
? () => _showRiskLevelSelection(context, actionType)
|
|
: null,
|
|
backgroundColor: isEnabled ? color : Colors.grey,
|
|
child: Icon(icon),
|
|
);
|
|
}
|
|
}
|