From d7cd938403febda1e2975e5c18e8a1bc88701837 Mon Sep 17 00:00:00 2001 From: Horoli Date: Fri, 5 Dec 2025 16:48:00 +0900 Subject: [PATCH] update --- assets/data/items.json | 27 +- lib/game/config/animation_config.dart | 34 + lib/game/config/battle_config.dart | 64 ++ lib/game/config/theme_config.dart | 36 + lib/game/data/item_table.dart | 6 +- lib/game/model/effect_event.dart | 2 + lib/providers/battle_provider.dart | 33 +- lib/screens/battle_screen.dart | 677 ++++++++++-------- lib/screens/inventory_screen.dart | 5 + .../battle/battle_animation_widget.dart | 123 ++++ lib/widgets/battle/character_status_card.dart | 173 +++-- lib/widgets/battle/explosion_widget.dart | 141 ++++ lib/widgets/battle/floating_battle_texts.dart | 51 +- lib/widgets/battle/shake_widget.dart | 63 ++ prompt/00_project_context_restore.md | 23 +- prompt/40_ui_update_summary.md | 50 ++ prompt/41_refactoring_presets.md | 40 ++ 17 files changed, 1137 insertions(+), 411 deletions(-) create mode 100644 lib/game/config/animation_config.dart create mode 100644 lib/game/config/battle_config.dart create mode 100644 lib/game/config/theme_config.dart create mode 100644 lib/widgets/battle/battle_animation_widget.dart create mode 100644 lib/widgets/battle/explosion_widget.dart create mode 100644 lib/widgets/battle/shake_widget.dart create mode 100644 prompt/40_ui_update_summary.md create mode 100644 prompt/41_refactoring_presets.md diff --git a/assets/data/items.json b/assets/data/items.json index 0951a5e..7a6a0cb 100644 --- a/assets/data/items.json +++ b/assets/data/items.json @@ -155,22 +155,24 @@ { "id": "old_ring", "name": "Old Ring", - "description": "A tarnished ring.", + "description": "A tarnished ring. Might bring a little luck.", "baseAtk": 1, "baseHp": 5, "slot": "accessory", "price": 25, - "image": "assets/images/items/old_ring.png" + "image": "assets/images/items/old_ring.png", + "luck": 5 }, { "id": "copper_ring", "name": "Copper Ring", - "description": "A simple ring", + "description": "A simple ring.", "baseAtk": 1, "baseHp": 5, "slot": "accessory", "price": 25, - "image": "assets/images/items/copper_ring.png" + "image": "assets/images/items/copper_ring.png", + "luck": 3 }, { "id": "ruby_amulet", @@ -180,7 +182,8 @@ "baseHp": 15, "slot": "accessory", "price": 80, - "image": "assets/images/items/ruby_amulet.png" + "image": "assets/images/items/ruby_amulet.png", + "luck": 7 }, { "id": "heros_badge", @@ -191,7 +194,19 @@ "baseArmor": 1, "slot": "accessory", "price": 150, - "image": "assets/images/items/heros_badge.png" + "image": "assets/images/items/heros_badge.png", + "luck": 10 + }, + { + "id": "lucky_charm", + "name": "Lucky Charm", + "description": "A four-leaf clover encased in amber.", + "baseAtk": 0, + "baseHp": 10, + "slot": "accessory", + "price": 200, + "image": "assets/images/items/lucky_charm.png", + "luck": 25 } ] } diff --git a/lib/game/config/animation_config.dart b/lib/game/config/animation_config.dart new file mode 100644 index 0000000..55dd322 --- /dev/null +++ b/lib/game/config/animation_config.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import '../enums.dart'; + +class AnimationConfig { + // Durations + static const Duration floatingTextDuration = Duration(milliseconds: 1000); + static const Duration floatingEffectDuration = Duration(milliseconds: 800); + static const Duration fadeDuration = Duration(milliseconds: 200); + + // Attack Animations + static const Duration attackSafe = Duration(milliseconds: 200); + static const Duration attackNormal = Duration(milliseconds: 400); + static const Duration attackRiskyTotal = Duration(milliseconds: 1100); + static const Duration attackRiskyScale = Duration(milliseconds: 600); + static const Duration attackRiskyDash = Duration(milliseconds: 500); + + // Curves + static const Curve floatingTextCurve = Curves.easeOut; + static const Curve floatingEffectScaleCurve = Curves.elasticOut; + static const Curve attackSafeCurve = Curves.elasticIn; + static const Curve attackNormalCurve = Curves.easeOutQuad; + static const Curve attackRiskyDashCurve = Curves.easeInExpo; + + static Duration getAttackDuration(RiskLevel risk) { + switch (risk) { + case RiskLevel.safe: + return attackSafe; + case RiskLevel.normal: + return attackNormal; + case RiskLevel.risky: + return attackRiskyTotal; + } + } +} diff --git a/lib/game/config/battle_config.dart b/lib/game/config/battle_config.dart new file mode 100644 index 0000000..0e62505 --- /dev/null +++ b/lib/game/config/battle_config.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import '../enums.dart'; + +class BattleConfig { + // Icons + static const IconData attackIcon = Icons.flash_on; + static const IconData defendIcon = Icons.shield; + + // Colors + static const Color riskyColor = Colors.redAccent; + static const Color normalColor = Colors.orangeAccent; + static const Color safeColor = Colors.grey; + + static const Color defendRiskyColor = Colors.deepPurpleAccent; + static const Color defendNormalColor = Colors.blueAccent; + static const Color defendSafeColor = Colors.greenAccent; + + // Sizes + static const double sizeRisky = 80.0; // User increased this in previous edit + static const double sizeNormal = 60.0; + static const double sizeSafe = 40.0; + + static IconData getIcon(ActionType type) { + switch (type) { + case ActionType.attack: + return attackIcon; + case ActionType.defend: + return defendIcon; + } + } + + static Color getColor(ActionType type, RiskLevel risk) { + if (type == ActionType.attack) { + switch (risk) { + case RiskLevel.risky: + return riskyColor; + case RiskLevel.normal: + return normalColor; + case RiskLevel.safe: + return safeColor; + } + } else { + switch (risk) { + case RiskLevel.risky: + return defendRiskyColor; + case RiskLevel.normal: + return defendNormalColor; + case RiskLevel.safe: + return defendSafeColor; + } + } + } + + static double getSize(RiskLevel risk) { + switch (risk) { + case RiskLevel.risky: + return sizeRisky; + case RiskLevel.normal: + return sizeNormal; + case RiskLevel.safe: + return sizeSafe; + } + } +} diff --git a/lib/game/config/theme_config.dart b/lib/game/config/theme_config.dart new file mode 100644 index 0000000..e27cb47 --- /dev/null +++ b/lib/game/config/theme_config.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +class ThemeConfig { + // Stat Colors + static const Color statHpColor = Colors.red; + static const Color statHpPlayerColor = Colors.green; + static const Color statHpEnemyColor = Colors.red; + static const Color statAtkColor = Colors.blueAccent; + static const Color statDefColor = + Colors.green; // Or Blue depending on context + static const Color statLuckColor = Colors.green; + static const Color statGoldColor = Colors.amber; + + // UI Colors + static const Color textColorWhite = Colors.white; + static const Color textColorGrey = Colors.grey; + static const Color cardBgColor = Colors.black54; + static const Color inventoryCardBg = Color( + 0xFF455A64, + ); // Colors.blueGrey[700] + static const Color equipmentCardBg = Color( + 0xFF546E7A, + ); // Colors.blueGrey[600] + static const Color emptySlotBg = Color(0xFF424242); // Colors.grey[800] + + // Feedback Colors + static const Color damageTextDefault = Colors.red; + static const Color healText = Colors.green; + static const Color missText = Colors.grey; + static const Color failedText = Colors.redAccent; + static const Color feedbackShadow = Colors.black; + + // Status Effect Colors + static const Color effectBg = Colors.deepOrange; + static const Color effectText = Colors.white; +} diff --git a/lib/game/data/item_table.dart b/lib/game/data/item_table.dart index 0427176..8c36ae3 100644 --- a/lib/game/data/item_table.dart +++ b/lib/game/data/item_table.dart @@ -42,9 +42,9 @@ class ItemTemplate { id: json['id'], name: json['name'], description: json['description'], - atkBonus: json['atkBonus'] ?? 0, - hpBonus: json['hpBonus'] ?? 0, - armorBonus: json['armorBonus'] ?? 0, + atkBonus: json['atkBonus'] ?? json['baseAtk'] ?? 0, + hpBonus: json['hpBonus'] ?? json['baseHp'] ?? 0, + armorBonus: json['armorBonus'] ?? json['baseArmor'] ?? 0, slot: EquipmentSlot.values.firstWhere((e) => e.name == json['slot']), effects: effectsList, price: json['price'] ?? 10, diff --git a/lib/game/model/effect_event.dart b/lib/game/model/effect_event.dart index 2fe4f11..8b39497 100644 --- a/lib/game/model/effect_event.dart +++ b/lib/game/model/effect_event.dart @@ -3,12 +3,14 @@ import '../enums.dart'; enum EffectTarget { player, enemy } class EffectEvent { + final String id; final ActionType type; // attack, defend final RiskLevel risk; final EffectTarget target; // 이펙트가 표시될 위치의 대상 final BattleFeedbackType? feedbackType; // 새로운 피드백 타입 EffectEvent({ + required this.id, required this.type, required this.risk, required this.target, diff --git a/lib/providers/battle_provider.dart b/lib/providers/battle_provider.dart index f993755..536d1a3 100644 --- a/lib/providers/battle_provider.dart +++ b/lib/providers/battle_provider.dart @@ -243,7 +243,7 @@ class BattleProvider with ChangeNotifier { /// Handle player's action choice - void playerAction(ActionType type, RiskLevel risk) { + Future playerAction(ActionType type, RiskLevel risk) async { if (!isPlayerTurn || player.isDead || enemy.isDead || showRewardPopup) return; @@ -303,8 +303,12 @@ class BattleProvider with ChangeNotifier { if (type == ActionType.attack) { int damage = (player.totalAtk * efficiency).toInt(); + final eventId = + DateTime.now().millisecondsSinceEpoch.toString() + + Random().nextInt(1000).toString(); _effectEventController.sink.add( EffectEvent( + id: eventId, type: ActionType.attack, risk: risk, target: EffectTarget.enemy, @@ -312,6 +316,15 @@ class BattleProvider with ChangeNotifier { ), ); + // Animation Delays to sync with Impact + if (risk == RiskLevel.safe) { + await Future.delayed(const Duration(milliseconds: 500)); + } else if (risk == RiskLevel.normal) { + await Future.delayed(const Duration(milliseconds: 400)); + } else if (risk == RiskLevel.risky) { + await Future.delayed(const Duration(milliseconds: 1100)); + } + int damageToHp = 0; if (enemy.armor > 0) { if (enemy.armor >= damage) { @@ -339,6 +352,9 @@ class BattleProvider with ChangeNotifier { } else { _effectEventController.sink.add( EffectEvent( + id: + DateTime.now().millisecondsSinceEpoch.toString() + + Random().nextInt(1000).toString(), type: ActionType.defend, risk: risk, target: EffectTarget.player, @@ -355,6 +371,9 @@ class BattleProvider with ChangeNotifier { _addLog("Player's attack missed!"); _effectEventController.sink.add( EffectEvent( + id: + DateTime.now().millisecondsSinceEpoch.toString() + + Random().nextInt(1000).toString(), type: type, risk: risk, target: EffectTarget.enemy, // 공격 실패는 적 위치에 MISS @@ -365,6 +384,9 @@ class BattleProvider with ChangeNotifier { _addLog("Player's defense failed!"); _effectEventController.sink.add( EffectEvent( + id: + DateTime.now().millisecondsSinceEpoch.toString() + + Random().nextInt(1000).toString(), type: type, risk: risk, target: EffectTarget.player, // 방어 실패는 내 위치에 FAILED @@ -429,6 +451,9 @@ class BattleProvider with ChangeNotifier { if (intent.isSuccess) { _effectEventController.sink.add( EffectEvent( + id: + DateTime.now().millisecondsSinceEpoch.toString() + + Random().nextInt(1000).toString(), type: ActionType.attack, risk: intent.risk, target: EffectTarget.player, @@ -465,6 +490,9 @@ class BattleProvider with ChangeNotifier { _addLog("Enemy's ${intent.risk.name} attack missed!"); _effectEventController.sink.add( EffectEvent( + id: + DateTime.now().millisecondsSinceEpoch.toString() + + Random().nextInt(1000).toString(), type: ActionType.attack, // 적의 공격이므로 ActionType.attack risk: intent.risk, target: EffectTarget.player, // 플레이어가 회피했으므로 플레이어 위치에 이펙트 @@ -777,6 +805,9 @@ class BattleProvider with ChangeNotifier { _addLog("Enemy prepares defense! (+$armor Armor)"); _effectEventController.sink.add( EffectEvent( + id: + DateTime.now().millisecondsSinceEpoch.toString() + + Random().nextInt(1000).toString(), type: ActionType.defend, risk: risk, target: EffectTarget.enemy, diff --git a/lib/screens/battle_screen.dart b/lib/screens/battle_screen.dart index 12ce5bc..5f9d2d3 100644 --- a/lib/screens/battle_screen.dart +++ b/lib/screens/battle_screen.dart @@ -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 { final GlobalKey _playerKey = GlobalKey(); final GlobalKey _enemyKey = GlobalKey(); final GlobalKey _stackKey = GlobalKey(); + final GlobalKey _shakeKey = GlobalKey(); + final GlobalKey _playerAnimKey = + GlobalKey(); + final GlobalKey _explosionKey = + GlobalKey(); bool _showLogs = true; + bool _isPlayerAttacking = false; // Player Attack Animation State @override void initState() { @@ -104,7 +115,17 @@ class _BattleScreenState extends State { }); } + final Set _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 { 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 { ), ); }); - 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 { 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 { 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 effectTexts = item.effects.map((e) => e.description).toList(); diff --git a/lib/screens/inventory_screen.dart b/lib/screens/inventory_screen.dart index 806bd7c..042fc2f 100644 --- a/lib/screens/inventory_screen.dart +++ b/lib/screens/inventory_screen.dart @@ -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: [ diff --git a/lib/widgets/battle/battle_animation_widget.dart b/lib/widgets/battle/battle_animation_widget.dart new file mode 100644 index 0000000..48b35f9 --- /dev/null +++ b/lib/widgets/battle/battle_animation_widget.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import '../../game/enums.dart'; + +class BattleAnimationWidget extends StatefulWidget { + final Widget child; + + const BattleAnimationWidget({super.key, required this.child}); + + @override + BattleAnimationWidgetState createState() => BattleAnimationWidgetState(); +} + +class BattleAnimationWidgetState extends State + with TickerProviderStateMixin { + late AnimationController _scaleController; + late AnimationController _translateController; + late Animation _scaleAnimation; + late Animation _translateAnimation; + + @override + void initState() { + super.initState(); + _scaleController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 800), + ); + _translateController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1000), + ); + + _scaleAnimation = Tween( + begin: 1.0, + end: 1.2, + ).animate(CurvedAnimation(parent: _scaleController, curve: Curves.easeOut)); + + // Default translation, will be updated on animateAttack + _translateAnimation = Tween( + begin: Offset.zero, + end: Offset.zero, + ).animate(_translateController); + } + + @override + void dispose() { + _scaleController.dispose(); + _translateController.dispose(); + super.dispose(); + } + + Future animateAttack( + Offset targetOffset, + VoidCallback onImpact, + RiskLevel risk, + ) async { + if (risk == RiskLevel.safe || risk == RiskLevel.normal) { + // Safe & Normal: Dash/Wobble without scale + final isSafe = risk == RiskLevel.safe; + final duration = isSafe ? 500 : 400; + final offsetFactor = isSafe ? 0.2 : 0.5; + + _translateController.duration = Duration(milliseconds: duration); + _translateAnimation = + Tween( + begin: Offset.zero, + end: targetOffset * offsetFactor, + ).animate( + CurvedAnimation( + parent: _translateController, + curve: Curves.easeOutQuad, + ), + ); + + await _translateController.forward(); + if (!mounted) return; + onImpact(); + await _translateController.reverse(); + } else { + // Risky: Scale + Heavy Dash + _scaleController.duration = const Duration(milliseconds: 600); + _translateController.duration = const Duration(milliseconds: 500); + + // 1. Scale Up (Preparation) + await _scaleController.forward(); + if (!mounted) return; + + // 2. Dash to Target (Impact) + _translateAnimation = Tween(begin: Offset.zero, end: targetOffset) + .animate( + CurvedAnimation( + parent: _translateController, + curve: Curves.easeInExpo, // Heavy impact curve + ), + ); + + await _translateController.forward(); + if (!mounted) return; + + // 3. Impact Callback (Shake) + onImpact(); + + // 4. Return (Reset) + _scaleController.reverse(); + _translateController.reverse(); + } + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: Listenable.merge([_scaleController, _translateController]), + builder: (context, child) { + return Transform.translate( + offset: _translateAnimation.value, + child: Transform.scale( + scale: _scaleAnimation.value, + child: widget.child, + ), + ); + }, + ); + } +} diff --git a/lib/widgets/battle/character_status_card.dart b/lib/widgets/battle/character_status_card.dart index ba5bc5d..b64a113 100644 --- a/lib/widgets/battle/character_status_card.dart +++ b/lib/widgets/battle/character_status_card.dart @@ -3,101 +3,124 @@ import 'package:provider/provider.dart'; import '../../game/model/entity.dart'; import '../../game/enums.dart'; import '../../providers/battle_provider.dart'; +import 'battle_animation_widget.dart'; +import '../../game/config/theme_config.dart'; +import '../../game/config/animation_config.dart'; class CharacterStatusCard extends StatelessWidget { final Character character; final bool isPlayer; final bool isTurn; + final GlobalKey? animationKey; + final bool hideStats; const CharacterStatusCard({ super.key, required this.character, this.isPlayer = false, this.isTurn = false, + this.animationKey, + this.hideStats = false, }); @override Widget build(BuildContext context) { return Column( children: [ - FittedBox( - fit: BoxFit.scaleDown, - child: Text( - "Armor: ${character.armor}", - style: const TextStyle(color: Colors.white), + AnimatedOpacity( + opacity: hideStats ? 0.0 : 1.0, + duration: AnimationConfig.fadeDuration, + child: Column( + children: [ + FittedBox( + fit: BoxFit.scaleDown, + child: Text( + "Armor: ${character.armor}", + style: const TextStyle(color: ThemeConfig.textColorWhite), + ), + ), + FittedBox( + fit: BoxFit.scaleDown, + child: Text( + "${character.name}: HP ${character.hp}/${character.totalMaxHp}", + style: TextStyle( + color: character.isDead + ? ThemeConfig.statHpEnemyColor + : ThemeConfig.textColorWhite, + fontWeight: FontWeight.bold, + ), + ), + ), + SizedBox( + width: 100, + child: LinearProgressIndicator( + value: character.totalMaxHp > 0 + ? character.hp / character.totalMaxHp + : 0, + color: !isPlayer + ? ThemeConfig.statHpEnemyColor + : ThemeConfig.statHpPlayerColor, + backgroundColor: ThemeConfig.textColorGrey, + ), + ), + if (character.statusEffects.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Wrap( + spacing: 4.0, + children: character.statusEffects.map((effect) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: ThemeConfig.effectBg, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + "${effect.type.name.toUpperCase()} (${effect.duration})", + style: const TextStyle( + color: ThemeConfig.effectText, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ); + }).toList(), + ), + ), + Text("ATK: ${character.totalAtk}"), + Text("DEF: ${character.totalDefense}"), + Text("LUCK: ${character.totalLuck}"), + ], ), ), - FittedBox( - fit: BoxFit.scaleDown, - child: Text( - "${character.name}: HP ${character.hp}/${character.totalMaxHp}", - style: TextStyle( - color: character.isDead ? Colors.red : Colors.white, - fontWeight: FontWeight.bold, - ), - ), - ), - SizedBox( - width: 100, - child: LinearProgressIndicator( - value: character.totalMaxHp > 0 - ? character.hp / character.totalMaxHp - : 0, - color: !isPlayer ? Colors.red : Colors.green, - backgroundColor: Colors.grey, - ), - ), - if (character.statusEffects.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 4.0), - child: Wrap( - spacing: 4.0, - children: character.statusEffects.map((effect) { - return Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: Colors.deepOrange, - borderRadius: BorderRadius.circular(4), - ), - child: Text( - "${effect.type.name.toUpperCase()} (${effect.duration})", - style: const TextStyle( - color: Colors.white, - fontSize: 10, - fontWeight: FontWeight.bold, - ), - ), - ); - }).toList(), - ), - ), - Text("ATK: ${character.totalAtk}"), - Text("DEF: ${character.totalDefense}"), // 캐릭터 아이콘/이미지 영역 추가 - Container( - width: 100, // 임시 크기 - height: 100, // 임시 크기 - decoration: BoxDecoration( - color: isPlayer - ? Colors.lightBlue - : Colors.deepOrange, // 플레이어/적 구분 색상 - borderRadius: BorderRadius.circular(8), - ), - child: Center( - child: isPlayer - ? const Icon( - Icons.person, - size: 60, - color: Colors.white, - ) // 플레이어 아이콘 - : const Icon( - Icons.psychology, - size: 60, - color: Colors.white, - ), // 적 아이콘 (몬스터 대신) + BattleAnimationWidget( + key: animationKey, + child: Container( + width: 100, // 임시 크기 + height: 100, // 임시 크기 + decoration: BoxDecoration( + color: isPlayer + ? Colors.lightBlue + : Colors.deepOrange, // 플레이어/적 구분 색상 + borderRadius: BorderRadius.circular(8), + ), + child: Center( + child: isPlayer + ? const Icon( + Icons.person, + size: 60, + color: ThemeConfig.textColorWhite, + ) // 플레이어 아이콘 + : const Icon( + Icons.psychology, + size: 60, + color: ThemeConfig.textColorWhite, + ), // 적 아이콘 (몬스터 대신) + ), ), ), const SizedBox(height: 8), // 아이콘과 정보 사이 간격 diff --git a/lib/widgets/battle/explosion_widget.dart b/lib/widgets/battle/explosion_widget.dart new file mode 100644 index 0000000..7c74519 --- /dev/null +++ b/lib/widgets/battle/explosion_widget.dart @@ -0,0 +1,141 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; + +class Particle { + Offset position; + Offset velocity; + Color color; + double size; + double life; // 1.0 to 0.0 + double decay; + + Particle({ + required this.position, + required this.velocity, + required this.color, + required this.size, + required this.life, + required this.decay, + }); +} + +class ExplosionWidget extends StatefulWidget { + const ExplosionWidget({super.key}); + + @override + ExplosionWidgetState createState() => ExplosionWidgetState(); +} + +class ExplosionWidgetState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + final List _particles = []; + final Random _random = Random(); + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1000), + ); + _controller.addListener(_updateParticles); + } + + @override + void dispose() { + _controller.removeListener(_updateParticles); + _controller.dispose(); + super.dispose(); + } + + void _updateParticles() { + if (_particles.isEmpty) return; + + for (var i = _particles.length - 1; i >= 0; i--) { + final p = _particles[i]; + p.position += p.velocity; + p.velocity += Offset(0, 0.5); // Gravity + p.life -= p.decay; + if (p.life <= 0) { + _particles.removeAt(i); + } + } + + if (_particles.isEmpty) { + _controller.stop(); + } + setState(() {}); + } + + void explode(Offset position) { + // Clear old particles if any (optional, or just add more) + // _particles.clear(); + + // Create new particles + for (int i = 0; i < 30; i++) { + final double angle = _random.nextDouble() * 2 * pi; + final double speed = _random.nextDouble() * 5 + 2; + final double dx = cos(angle) * speed; + final double dy = sin(angle) * speed; + + // Random colors for fire/explosion effect + Color color; + final r = _random.nextDouble(); + if (r < 0.33) { + color = Colors.redAccent; + } else if (r < 0.66) { + color = Colors.orangeAccent; + } else { + color = Colors.yellowAccent; + } + + _particles.add( + Particle( + position: position, + velocity: Offset(dx, dy), + color: color, + size: _random.nextDouble() * 4 + 2, + life: 1.0, + decay: _random.nextDouble() * 0.02 + 0.01, + ), + ); + } + + if (!_controller.isAnimating) { + _controller.repeat(); // Use repeat to keep loop running until empty + } + } + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: CustomPaint( + painter: ExplosionPainter(_particles), + size: Size.infinite, + ), + ); + } +} + +class ExplosionPainter extends CustomPainter { + final List particles; + + ExplosionPainter(this.particles); + + @override + void paint(Canvas canvas, Size size) { + for (final p in particles) { + final paint = Paint() + ..color = p.color.withOpacity(p.life.clamp(0.0, 1.0)) + ..style = PaintingStyle.fill; + + canvas.drawCircle(p.position, p.size, paint); + } + } + + @override + bool shouldRepaint(covariant ExplosionPainter oldDelegate) { + return true; // Always repaint when animating + } +} diff --git a/lib/widgets/battle/floating_battle_texts.dart b/lib/widgets/battle/floating_battle_texts.dart index 22fc329..6353360 100644 --- a/lib/widgets/battle/floating_battle_texts.dart +++ b/lib/widgets/battle/floating_battle_texts.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; +import '../../game/config/theme_config.dart'; +import '../../game/config/animation_config.dart'; + class FloatingDamageText extends StatefulWidget { final String damage; final Color color; @@ -26,14 +29,20 @@ class FloatingDamageTextState extends State void initState() { super.initState(); _controller = AnimationController( - duration: const Duration(milliseconds: 1000), + duration: AnimationConfig.floatingTextDuration, vsync: this, ); - _offsetAnimation = Tween( - begin: const Offset(0.0, 0.0), - end: const Offset(0.0, -1.5), - ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut)); + _offsetAnimation = + Tween( + begin: const Offset(0.0, 0.0), + end: const Offset(0.0, -1.5), + ).animate( + CurvedAnimation( + parent: _controller, + curve: AnimationConfig.floatingTextCurve, + ), + ); _opacityAnimation = Tween(begin: 1.0, end: 0.0).animate( CurvedAnimation( @@ -75,7 +84,7 @@ class FloatingDamageTextState extends State shadows: const [ Shadow( blurRadius: 2.0, - color: Colors.black, + color: ThemeConfig.feedbackShadow, offset: Offset(1.0, 1.0), ), ], @@ -124,14 +133,16 @@ class FloatingEffectState extends State void initState() { super.initState(); _controller = AnimationController( - duration: const Duration(milliseconds: 800), + duration: AnimationConfig.floatingEffectDuration, vsync: this, ); - _scaleAnimation = Tween( - begin: 0.5, - end: 1.5, - ).animate(CurvedAnimation(parent: _controller, curve: Curves.elasticOut)); + _scaleAnimation = Tween(begin: 0.5, end: 1.5).animate( + CurvedAnimation( + parent: _controller, + curve: AnimationConfig.floatingEffectScaleCurve, + ), + ); _opacityAnimation = Tween(begin: 1.0, end: 0.0).animate( CurvedAnimation( @@ -203,14 +214,20 @@ class FloatingFeedbackTextState extends State void initState() { super.initState(); _controller = AnimationController( - duration: const Duration(milliseconds: 1000), + duration: AnimationConfig.floatingTextDuration, vsync: this, ); - _offsetAnimation = Tween( - begin: const Offset(0.0, 0.0), - end: const Offset(0.0, -1.5), - ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut)); + _offsetAnimation = + Tween( + begin: const Offset(0.0, 0.0), + end: const Offset(0.0, -1.5), + ).animate( + CurvedAnimation( + parent: _controller, + curve: AnimationConfig.floatingTextCurve, + ), + ); _opacityAnimation = Tween(begin: 1.0, end: 0.0).animate( CurvedAnimation( @@ -252,7 +269,7 @@ class FloatingFeedbackTextState extends State shadows: const [ Shadow( blurRadius: 2.0, - color: Colors.black, + color: ThemeConfig.feedbackShadow, offset: Offset(1.0, 1.0), ), ], diff --git a/lib/widgets/battle/shake_widget.dart b/lib/widgets/battle/shake_widget.dart new file mode 100644 index 0000000..7456a4a --- /dev/null +++ b/lib/widgets/battle/shake_widget.dart @@ -0,0 +1,63 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; + +class ShakeWidget extends StatefulWidget { + final Widget child; + final double shakeOffset; + final int shakeCount; + final Duration duration; + + const ShakeWidget({ + super.key, + required this.child, + this.shakeOffset = 10.0, + this.shakeCount = 3, + this.duration = const Duration(milliseconds: 400), + }); + + @override + ShakeWidgetState createState() => ShakeWidgetState(); +} + +class ShakeWidgetState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController(vsync: this, duration: widget.duration); + _controller.addStatusListener((status) { + if (status == AnimationStatus.completed) { + _controller.reset(); + } + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void shake() { + _controller.forward(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + final double sineValue = sin( + widget.shakeCount * 2 * pi * _controller.value, + ); + return Transform.translate( + offset: Offset(sineValue * widget.shakeOffset, 0), + child: child, + ); + }, + child: widget.child, + ); + } +} diff --git a/prompt/00_project_context_restore.md b/prompt/00_project_context_restore.md index 4857f5c..2a4a833 100644 --- a/prompt/00_project_context_restore.md +++ b/prompt/00_project_context_restore.md @@ -39,6 +39,10 @@ - **Floating Text:** 데미지 발생 시 캐릭터 위에 데미지 수치가 떠오름 (일반: 빨강, 출혈: 보라, 취약: 주황). - **Action Feedback:** 공격 빗나감(MISS - 적 위치/회색) 및 방어 실패(FAILED - 내 위치/빨강) 텍스트 오버레이. - **Effect Icons:** 공격/방어 및 리스크 레벨에 따른 아이콘 애니메이션 출력. + - **Advanced Animations:** + - **Risk-Based:** Safe(Wobble), Normal(Dash), Risky(Scale Up + Heavy Dash + Shake + Explosion). + - **Icon-Only:** 공격 시 캐릭터 아이콘만 이동하며, 스탯 정보(HP/Armor)는 일시적으로 숨김 처리. + - **Impact Sync:** 타격 이펙트와 데미지 텍스트가 애니메이션 타격 시점에 정확히 동기화됨. - **상태이상:** `Stun`, `Bleed`, `Vulnerable`, `DefenseForbidden`. - **행운 시스템 (Luck System):** - 아이템 옵션으로 `luck` 스탯 제공. @@ -80,7 +84,9 @@ - **Streams:** `damageStream`, `effectStream`을 통해 UI(`BattleScreen`)에 비동기 이벤트 전달. - **`lib/game/enums.dart`:** 프로젝트 전반의 Enum 통합 관리 (`ActionType`, `RiskLevel`, `StageType` 등). - **`lib/utils/item_utils.dart`:** 아이템 타입별 아이콘 및 색상 로직 중앙화. -- **`lib/widgets/battle/`:** `BattleScreen`에서 분리된 재사용 가능한 위젯들 (`CharacterStatusCard`, `BattleLogOverlay`, `FloatingBattleTexts`, `StageUI`). +- **`lib/widgets/battle/`:** `BattleScreen`에서 분리된 재사용 가능한 위젯들. + - **UI Components:** `CharacterStatusCard`, `BattleLogOverlay`, `FloatingBattleTexts`, `StageUI`. + - **Effects:** `BattleAnimationWidget` (공격 애니메이션), `ExplosionWidget` (파티클), `ShakeWidget` (화면 흔들림). - **`lib/widgets/responsive_container.dart`:** 반응형 레이아웃 컨테이너. - **`lib/game/model/`:** - `damage_event.dart`, `effect_event.dart`: 이벤트 모델. @@ -107,9 +113,9 @@ - [ ] **출혈 상태 이상 조건 변경:** 공격 시 상대방의 방어도에 의해 공격이 완전히 막힐 경우, 출혈 상태 이상이 적용되지 않도록 로직 변경. - [ ] **장비 분해 시스템 (적 장비):** 플레이어 장비의 옵션으로 상대방의 장비를 분해하여 '언암드' 상태로 만들 수 있는 시스템 구현. - [ ] **플레이어 공격 데미지 분산(Variance) 적용 여부 검토:** 현재 적에게만 적용된 +/- 20% 데미지 분산을 플레이어에게도 적용할지 결정. -- [ ] **애니메이션 및 타격감 고도화:** - - 캐릭터별 이미지 추가 및 하스스톤 스타일의 공격 모션(대상에게 돌진 후 타격) 구현. - - **Risky 공격:** 하스스톤의 7데미지 이상 타격감(화면 흔들림, 강렬한 파티클 및 임팩트) 재현. +- [x] **애니메이션 및 타격감 고도화:** + - 캐릭터별 이미지 추가 및 하스스톤 스타일의 공격 모션(대상에게 돌진 후 타격) 구현 완료 (Icon-Only Animation). + - **Risky 공격:** 하스스톤의 7데미지 이상 타격감(화면 흔들림, 강렬한 파티클 및 임팩트) 구현 완료. - [ ] **체력 조건부 특수 능력:** 캐릭터의 체력이 30% 미만일 때 발동 가능한 특수 능력 시스템 구현. - [ ] **영구 스탯 수정자 로직 적용 (필수):** - 현재 `Character` 클래스에 `permanentModifiers` 필드만 선언되어 있음. @@ -118,7 +124,16 @@ - Firebase Auth 등을 활용한 구글 로그인 구현. - Firestore 또는 Realtime Database에 유저 계정 정보(진행 상황, 재화 등) 저장 및 불러오기 기능 추가. - _Note: 이 기능은 게임의 핵심 로직이 안정화된 후, 완전 나중에 진행할 예정입니다._ +- [ ] **설정 페이지 (Settings Page) 구현 (Priority: Very Low):** + - **이펙트 강도 조절 (Effect Intensity):** 1 ~ 999 범위로 설정 가능. + - **Easter Egg:** 강도를 999로 설정하고 Risky 공격 성공 시, "심각한 오류로 프로세스가 종료되었습니다" 같은 페이크 시스템 팝업 출력. --- **이 프롬프트를 읽은 AI 에이전트는 위 내용을 바탕으로 즉시 개발을 이어가십시오.** + +## 7. 프롬프트 히스토리 (Prompt History) + +- [x] 39_luck_system.md +- [x] 40_ui_update_summary.md +- [x] 41_refactoring_presets.md diff --git a/prompt/40_ui_update_summary.md b/prompt/40_ui_update_summary.md new file mode 100644 index 0000000..0e78abc --- /dev/null +++ b/prompt/40_ui_update_summary.md @@ -0,0 +1,50 @@ +# 40. UI Update Summary (Risky Attack Visual Effects) + +39번 (Luck System) 이후 작업된 UI 및 시각 효과 관련 변경 사항 정리입니다. + +## 1. Attack Animation & Visual Effects + +공격 유형(Risk Level)에 따라 차별화된 애니메이션과 시각 효과를 구현했습니다. + +### BattleAnimationWidget (`lib/widgets/battle/battle_animation_widget.dart`) + +`animateAttack` 메서드가 `RiskLevel`을 인자로 받아 각기 다른 동작을 수행합니다. + +- **Safe Attack**: + - **동작**: 제자리에서 좌우로 살짝 흔들리는(Wobble) 애니메이션. + - **느낌**: 신중함, 머뭇거림. + - **구현**: `Curves.elasticIn`을 사용한 짧은 X축 이동 (200ms). +- **Normal Attack**: + - **동작**: 적에게 다가가서 가볍게 부딪히는(Dash) 애니메이션. + - **느낌**: 일반적인 타격. + - **구현**: 확대(Scale Up) 없이 `Curves.easeOutQuad`로 이동 후 복귀 (400ms). +- **Risky Attack**: + - **동작**: 몸을 크게 부풀린 후(Scale Up) 강하게 돌진(Heavy Dash). + - **느낌**: 강력한 한 방, 높은 리스크. + - **구현**: 1.2배 확대(600ms) -> `Curves.easeInExpo` 가속 돌진(500ms) -> 타격 -> 복귀. + +### ExplosionWidget (`lib/widgets/battle/explosion_widget.dart`) [NEW] + +- **기능**: 타격 지점에서 파편(Particle)이 사방으로 튀는 효과. +- **구현**: `CustomPainter`를 사용하여 다수의 파티클을 효율적으로 렌더링. +- **트리거**: **Risky Attack** 적중 시에만 발동. + +### ShakeWidget (`lib/widgets/battle/shake_widget.dart`) + +- **기능**: 화면(또는 위젯)을 흔드는 효과. +- **적용**: **Risky Attack** 타격 시에만 발동. + +## 2. BattleScreen Integration (`lib/screens/battle_screen.dart`) + +- `BattleAnimationWidget`, `ExplosionWidget`, `ShakeWidget`을 조합하여 전투 연출 구성. +- `_addFloatingEffect`에서 공격 이벤트 수신 시 `RiskLevel`에 따라 적절한 애니메이션 메서드 호출. +- **Risky Attack**의 경우: `Scale Up` -> `Dash` -> `Impact` (Shake + Explosion) -> `Return`의 시퀀스로 동작. +- **Timing Sync**: 데미지 텍스트 표시 타이밍을 애니메이션 타격 시점(Safe: 200ms, Normal: 400ms, Risky: 1100ms)에 맞게 조정. +- **Icon-Only Animation**: 공격 시 전체 카드가 아닌 **캐릭터 아이콘만** 적에게 날아가도록 변경. +- **Hide Stats**: 공격 애니메이션 진행 중에는 HP, Armor 등 스탯 정보를 숨겨 시각적 혼란 방지. + +## 3. 기타 UI 개선 + +- **Floating Effects**: 데미지 텍스트 및 이펙트 아이콘 위치 조정. +- **Risk Level Selection**: 다이얼로그 UI 개선. +- **CharacterStatusCard**: 애니메이션 키(`animationKey`)와 스탯 숨김(`hideStats`) 속성 추가. diff --git a/prompt/41_refactoring_presets.md b/prompt/41_refactoring_presets.md new file mode 100644 index 0000000..d2ffc4e --- /dev/null +++ b/prompt/41_refactoring_presets.md @@ -0,0 +1,40 @@ +# 41. Refactoring: Presets & Configs (리팩토링: 설정 중앙화) + +## 개요 (Overview) + +프로젝트 전반에 산재되어 있던 하드코딩된 값들(색상, 아이콘, 애니메이션 시간 등)을 중앙 집중식 설정 파일(`Config`)로 분리하여 유지보수성과 일관성을 향상시켰습니다. + +## 변경 사항 (Changes) + +### 1. 설정 파일 생성 (New Config Files) + +`lib/game/config/` 디렉토리에 다음 파일들을 생성했습니다. + +- **`battle_config.dart`**: 전투 관련 아이콘, 색상, 크기 정의 (공격/방어, 리스크 레벨별). +- **`theme_config.dart`**: UI 전반의 색상 테마 정의. + - **Stat Colors**: HP(Player/Enemy), ATK, DEF, LUCK, Gold 등. + - **UI Colors**: 카드 배경, 텍스트(White/Grey), 등급별 색상 등. + - **Feedback Colors**: 데미지, 회복, 미스 텍스트 색상 및 그림자. + - **Effect Colors**: 상태이상 배지 배경 및 텍스트. +- **`animation_config.dart`**: 애니메이션 관련 상수 정의. + - **Durations**: Floating Text(1000ms), Fade(200ms), Attack(Risk Level별 상이). + - **Curves**: `easeOut`, `elasticOut`, `elasticIn` 등 애니메이션 커브. + +### 2. 코드 리팩토링 (Refactoring) + +기존 하드코딩된 값을 `Config` 클래스의 상수로 대체했습니다. + +- **`lib/screens/battle_screen.dart`**: + - `BattleConfig`를 사용하여 공격/방어 아이콘 및 이펙트 색상 결정. +- **`lib/widgets/battle/character_status_card.dart`**: + - `ThemeConfig`를 사용하여 HP/Armor/Stat 텍스트 및 게이지 색상 적용. + - `AnimationConfig`를 사용하여 스탯 숨김/표시 Fade 애니메이션 시간 적용. +- **`lib/widgets/battle/floating_battle_texts.dart`**: + - `ThemeConfig`를 사용하여 데미지 텍스트 그림자 색상 적용. + - `AnimationConfig`를 사용하여 텍스트 부양 및 아이콘 스케일 애니메이션의 시간과 커브 적용. + +## 기대 효과 (Benefits) + +- **유지보수 용이성**: 색상이나 애니메이션 속도를 변경할 때 단일 파일만 수정하면 프로젝트 전체에 일괄 적용됩니다. +- **일관성 유지**: UI 요소 간의 색상 및 동작 통일성을 보장합니다. +- **확장성**: 추후 '다크 모드'나 '테마 변경', '게임 속도 조절' 등의 기능을 구현하기 위한 기반이 마련되었습니다.