This commit is contained in:
2025-12-08 03:00:36 +09:00
parent 135bf26332
commit 9540dd22a3
8 changed files with 627 additions and 326 deletions
+276 -239
View File
@@ -50,10 +50,12 @@ class _BattleScreenState extends State<BattleScreen> {
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,
@@ -71,54 +73,52 @@ class _BattleScreenState extends State<BattleScreen> {
}
void _addFloatingDamageText(DamageEvent event) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (!mounted) return;
GlobalKey targetKey = event.target == DamageTarget.player
? _playerKey
: _enemyKey;
GlobalKey targetKey = event.target == DamageTarget.player
? _playerKey
: _enemyKey;
if (targetKey.currentContext == null) return;
RenderBox? renderBox =
targetKey.currentContext!.findRenderObject() as RenderBox?;
if (renderBox == null) return;
if (targetKey.currentContext == null) return;
RenderBox? renderBox =
targetKey.currentContext!.findRenderObject() as RenderBox?;
if (renderBox == null) return;
Offset position = renderBox.localToGlobal(Offset.zero);
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;
}
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);
position = position + Offset(renderBox.size.width / 2 - 20, -20);
final String id = UniqueKey().toString();
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);
});
}
},
),
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);
});
}
},
),
),
);
});
),
);
});
}
@@ -128,108 +128,92 @@ class _BattleScreenState extends State<BattleScreen> {
if (_processedEffectIds.contains(event.id)) {
return;
}
_processedEffectIds.add(event.id);
if (_processedEffectIds.length > 20) {
_processedEffectIds.remove(_processedEffectIds.first);
}
WidgetsBinding.instance.addPostFrameCallback((_) {
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;
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;
// feedbackType이 존재하면 해당 텍스트를 표시하고 기존 이펙트 아이콘은 건너뜜
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 = ""; // Should not happen with current enums
feedbackColor = ThemeConfig.textColorWhite;
}
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이 있으면 아이콘 이펙트는 표시하지 않음
// 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;
}
// 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();
// Prevent duplicate feedback texts for the same event ID (UI Level)
if (_floatingFeedbackTexts.any((e) => e.eventId == event.id)) {
return;
}
setState(() {
_floatingEffects.add(
FloatingEffectData(
_floatingFeedbackTexts.clear(); // Clear previous texts
_floatingFeedbackTexts.add(
FeedbackTextData(
id: id,
eventId: event.id,
widget: Positioned(
left: position.dx,
top: position.dy,
child: FloatingEffect(
child: FloatingFeedbackText(
key: ValueKey(id),
icon: icon,
color: color,
size: size,
feedback: "$feedbackText (${event.id.substring(0, 4)})",
color: feedbackColor,
onRemove: () {
if (mounted) {
setState(() {
_floatingEffects.removeWhere((e) => e.id == id);
_floatingFeedbackTexts.removeWhere((e) => e.id == id);
});
}
},
@@ -238,133 +222,179 @@ class _BattleScreenState extends State<BattleScreen> {
),
);
});
return; // Return early for feedback
}
// 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?;
// Handle Icon Effect
IconData icon = BattleConfig.getIcon(event.type);
Color color = BattleConfig.getColor(event.type, event.risk);
double size = BattleConfig.getSize(event.risk);
if (playerBox != null && enemyBox != null) {
final playerPos = playerBox.localToGlobal(Offset.zero);
final enemyPos = enemyBox.localToGlobal(Offset.zero);
final String id = UniqueKey().toString();
final offset = enemyPos - playerPos;
// Start Animation: Hide Stats
setState(() {
_isPlayerAttacking = true;
});
_playerAnimKey.currentState
?.animateAttack(offset, () {
showEffect(); // Show Effect at Impact!
// Trigger impact logic in provider
context.read<BattleProvider>().handleImpact(event);
// 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);
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);
}
}, event.risk)
.then((_) {
// End Animation: Show Stats
if (mounted) {
setState(() {
_isPlayerAttacking = false;
});
}
});
}
} else if (event.type == ActionType.attack &&
event.target == EffectTarget.player &&
event.feedbackType == null) {
// Check Settings
bool enableAnim = context.read<SettingsProvider>().enableEnemyAnimations;
}
}, 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(); // Just show effect if anim disabled
context.read<BattleProvider>().handleImpact(event); // Process impact immediately if anim disabled
return;
}
// 2. Enemy Attack Animation Trigger
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);
// Enemy moves TO Player
final offset = playerPos - enemyPos;
// Start Animation: Hide Stats
setState(() {
_isEnemyAttacking = true;
});
_enemyAnimKey.currentState
?.animateAttack(offset, () {
showEffect(); // Show Effect at Impact!
// Trigger impact logic in provider
context.read<BattleProvider>().handleImpact(event);
// Shake and Explosion ONLY for Risky (Enemy can also do risky attacks)
if (event.risk == RiskLevel.risky) {
_shakeKey.currentState?.shake();
// Explosion on Player
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);
}
}
}, event.risk)
.then((_) {
// End Animation: Show Stats
if (mounted) {
setState(() {
_isEnemyAttacking = false;
});
}
});
}
} else {
// Not a player/enemy attack movement, show immediately
if (!enableAnim) {
showEffect();
// Also process impact immediately for non-animating effects (e.g., failed defense, stun)
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) {
@@ -452,6 +482,11 @@ class _BattleScreenState extends State<BattleScreen> {
@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) {
@@ -616,7 +651,9 @@ class _BattleScreenState extends State<BattleScreen> {
child: SimpleDialog(
title: Row(
children: [
const Text("${AppStrings.victory} ${AppStrings.chooseReward}"),
const Text(
"${AppStrings.victory} ${AppStrings.chooseReward}",
),
const Spacer(),
Row(
mainAxisSize: MainAxisSize.min,
+19 -8
View File
@@ -24,7 +24,7 @@ class SettingsScreen extends StatelessWidget {
),
),
const SizedBox(height: 40),
// Enemy Animation Toggle
Consumer<SettingsProvider>(
builder: (context, settings, child) {
@@ -51,7 +51,7 @@ class SettingsScreen extends StatelessWidget {
style: TextStyle(color: ThemeConfig.textColorWhite),
),
const SizedBox(height: 40),
// Restart Button
ElevatedButton(
style: ElevatedButton.styleFrom(
@@ -69,7 +69,7 @@ class SettingsScreen extends StatelessWidget {
const SnackBar(content: Text('Game Restarted!')),
);
// Optionally switch tab back to Battle (index 0)
// But MainWrapper controls the index.
// But MainWrapper controls the index.
// We can't easily switch tab from here without a callback or Provider.
// For now, just restart logic is enough.
},
@@ -89,10 +89,13 @@ class SettingsScreen extends StatelessWidget {
_showConfirmationDialog(
context,
title: '${AppStrings.returnToMenu}?',
content: 'Unsaved progress may be lost. (Progress is saved automatically after each stage)',
content:
'Unsaved progress may be lost. (Progress is saved automatically after each stage)',
onConfirm: () {
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (context) => const MainMenuScreen()),
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(
builder: (context) => const MainMenuScreen(),
),
(route) => false,
);
},
@@ -105,7 +108,12 @@ class SettingsScreen extends StatelessWidget {
);
}
void _showConfirmationDialog(BuildContext context, {required String title, required String content, required VoidCallback onConfirm}) {
void _showConfirmationDialog(
BuildContext context, {
required String title,
required String content,
required VoidCallback onConfirm,
}) {
showDialog(
context: context,
builder: (context) => AlertDialog(
@@ -121,7 +129,10 @@ class SettingsScreen extends StatelessWidget {
Navigator.pop(context);
onConfirm();
},
child: const Text(AppStrings.confirm, style: TextStyle(color: Colors.red)),
child: const Text(
AppStrings.confirm,
style: TextStyle(color: Colors.red),
),
),
],
),