This commit is contained in:
2025-12-05 16:48:00 +09:00
parent d3fd9680c2
commit d7cd938403
17 changed files with 1137 additions and 411 deletions
+372 -305
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/battle_provider.dart';
@@ -13,7 +14,11 @@ 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 '../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';
class BattleScreen extends StatefulWidget {
const BattleScreen({super.key});
@@ -31,7 +36,13 @@ class _BattleScreenState extends State<BattleScreen> {
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<ExplosionWidgetState> _explosionKey =
GlobalKey<ExplosionWidgetState>();
bool _showLogs = true;
bool _isPlayerAttacking = false; // Player Attack Animation State
@override
void initState() {
@@ -104,7 +115,17 @@ class _BattleScreenState extends State<BattleScreen> {
});
}
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;
@@ -130,40 +151,78 @@ class _BattleScreenState extends State<BattleScreen> {
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;
// 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 = 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이 있으면 아이콘 이펙트는 표시하지 않음
}
// 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(() {
_floatingFeedbackTexts.add(
FeedbackTextData(
_floatingEffects.add(
FloatingEffectData(
id: id,
widget: Positioned(
left: position.dx,
top: position.dy,
child: FloatingFeedbackText(
child: FloatingEffect(
key: ValueKey(id),
feedback: feedbackText,
color: feedbackColor,
icon: icon,
color: color,
size: size,
onRemove: () {
if (mounted) {
setState(() {
_floatingFeedbackTexts.removeWhere((e) => e.id == id);
_floatingEffects.removeWhere((e) => e.id == id);
});
}
},
@@ -172,67 +231,63 @@ class _BattleScreenState extends State<BattleScreen> {
),
);
});
return; // feedbackType이 있으면 아이콘 이펙트는 표시하지 않음
}
IconData icon;
Color color;
double size;
// 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 (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;
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 {
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;
}
// Not a player attack, show immediately
showEffect();
}
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);
});
}
},
),
),
),
);
});
});
}
@@ -330,254 +385,265 @@ class _BattleScreenState extends State<BattleScreen> {
return RestUI(battleProvider: battleProvider);
}
return Stack(
key: _stackKey,
children: [
// 1. Background (Black)
Container(color: Colors.black87),
return ShakeWidget(
key: _shakeKey,
child: 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,
// 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,
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,
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",
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(),
),
),
),
// Battle Area (Characters) - Expanded to fill available space
Expanded(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Stack(
// 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: Colors.black87,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Enemy (Top Right)
Positioned(
top: 0,
right: 0,
child: CharacterStatusCard(
character: battleProvider.enemy,
isPlayer: false,
isTurn: !battleProvider.isPlayerTurn,
key: _enemyKey,
const Text(
"DEFEAT",
style: TextStyle(
color: Colors.red,
fontSize: 48,
fontWeight: FontWeight.bold,
letterSpacing: 4.0,
),
),
// Player (Bottom Left)
Positioned(
bottom: 80, // Space for FABs
left: 0,
child: CharacterStatusCard(
character: battleProvider.player,
isPlayer: true,
isTurn: battleProvider.isPlayerTurn,
key: _playerKey,
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,
),
),
),
],
),
),
),
],
),
// 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),
),
),
],
),
),
),
],
],
),
);
},
),
@@ -589,6 +655,7 @@ class _BattleScreenState extends State<BattleScreen> {
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");
if (item.luck > 0) stats.add("+${item.luck} Luck");
List<String> effectTexts = item.effects.map((e) => e.description).toList();
+5
View File
@@ -460,6 +460,11 @@ class InventoryScreen extends StatelessWidget {
_buildStatChangeRow("Current HP", currentHp, newHp),
_buildStatChangeRow("ATK", currentAtk, newAtk),
_buildStatChangeRow("DEF", currentDef, newDef),
_buildStatChangeRow(
"LUCK",
player.totalLuck,
player.totalLuck - (oldItem?.luck ?? 0) + newItem.luck,
),
],
),
actions: [