From ac4df0265491e1e3e77cd5a648acfd6b5ad27b18 Mon Sep 17 00:00:00 2001 From: Horoli Date: Tue, 9 Dec 2025 11:47:37 +0900 Subject: [PATCH] update - adjust risky animation offset - show inventory in shop --- lib/providers/battle_provider.dart | 94 ++-- lib/screens/battle_screen.dart | 31 +- lib/screens/inventory_screen.dart | 416 +---------------- .../battle/battle_animation_widget.dart | 7 +- .../inventory/character_stats_widget.dart | 88 ++++ .../inventory/inventory_grid_widget.dart | 424 ++++++++++++++++++ lib/widgets/stage/shop_ui.dart | 279 +++++------- ...63_inventory_refactor_and_enemy_z_order.md | 36 ++ 8 files changed, 726 insertions(+), 649 deletions(-) create mode 100644 lib/widgets/inventory/character_stats_widget.dart create mode 100644 lib/widgets/inventory/inventory_grid_widget.dart create mode 100644 prompt/63_inventory_refactor_and_enemy_z_order.md diff --git a/lib/providers/battle_provider.dart b/lib/providers/battle_provider.dart index e9e9780..4e63c7e 100644 --- a/lib/providers/battle_provider.dart +++ b/lib/providers/battle_provider.dart @@ -2,7 +2,7 @@ import 'dart:async'; // StreamController 사용을 위해 import import 'dart:math'; import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; // For context.read in _prepareNextStage + import '../game/model/entity.dart'; import '../game/model/item.dart'; import '../game/model/status_effect.dart'; @@ -214,11 +214,10 @@ class BattleProvider with ChangeNotifier { if (!isPlayerTurn || player.isDead || enemy.isDead || showRewardPopup) return; - // 0. Apply Enemy Pre-emptive Defense - REMOVED (Standard Turn-Based Logic) - // Defense now happens on Enemy's Turn. + // 0. Ensure Pre-emptive Enemy Defense is applied (if not already via animation) + applyPendingEnemyDefense(); // Update Enemy Status Effects at the start of Player's turn (user request) - enemy.updateStatusEffects(); // 1. Check for Defense Forbidden status if (type == ActionType.defend && player.hasStatus(StatusEffectType.defenseForbidden)) { @@ -375,23 +374,10 @@ class BattleProvider with ChangeNotifier { } // [New] Apply Pre-emptive Enemy Intent (Defense/Buffs) + // MOVED: Logic moved to applyPendingEnemyDefense() to sync with animation. + // We just check intent existence here but do NOT apply effects yet. if (currentEnemyIntent != null) { - final intent = currentEnemyIntent!; - if (intent.type == EnemyActionType.defend) { - if (intent.isSuccess) { - enemy.armor += intent.finalValue; - _addLog( - "${enemy.name} assumes a defensive stance (+${intent.finalValue} Armor).", - ); - } else { - _addLog("${enemy.name} tried to defend but failed."); - - // Optional: Emit failed defense visual? - // For now, let's keep it simple as log only for failure, or add visual later. - } - intent.isApplied = true; // Mark as applied so we don't do it again - } - // Add other pre-emptive intent types here if needed (e.g., Buffs) + // Intent generated, waiting for player interaction or action to apply. } isPlayerTurn = true; @@ -774,6 +760,26 @@ class BattleProvider with ChangeNotifier { notifyListeners(); } + /// Ensure the enemy's pending defense is applied. + /// Called manually by UI during animation, or auto-called by playerAction as fallback. + void applyPendingEnemyDefense() { + if (currentEnemyIntent != null && + currentEnemyIntent!.type == EnemyActionType.defend && + !currentEnemyIntent!.isApplied) { + final intent = currentEnemyIntent!; + if (intent.isSuccess) { + enemy.armor += intent.finalValue; + _addLog( + "${enemy.name} assumes a defensive stance (+${intent.finalValue} Armor).", + ); + } else { + _addLog("${enemy.name} tried to defend but failed."); + } + intent.isApplied = true; + notifyListeners(); + } + } + /// Applies the effects of the enemy's intent (specifically Defense) /// This should be called just before the Player's turn starts. void _applyEnemyIntentEffects() { @@ -806,52 +812,6 @@ class BattleProvider with ChangeNotifier { return; } - // Special Case: Enemy Defense (Phase 3 & Phase 1) - // - Phase 3 Defense: Logic applied in _applyEnemyIntentEffects. Event is Visual Only. - // - Phase 1 Defense: Logic applied in _startEnemyTurn (if we add it there) or here? - // Wait, Phase 1 Defense is distinct. - // However, currently Phase 1 Defense also uses _effectEventController.sink.add(event). - // BUT Phase 1 Defense Logic is NOT applied in _startEnemyTurn yet (it just emits event). - // So Phase 1 Defense SHOULD go through _processAttackImpact? - // NO, because Phase 1 Defense uses the same ActionType.defend. - - // Let's look at _startEnemyTurn for Phase 1 Defense: - // It emits event with armorGained. It does NOT increase armor directly. - // So for Phase 1, we NEED handleImpact -> _processAttackImpact. - - // Let's look at _applyEnemyIntentEffects for Phase 3 Defense: - // It increases armor DIRECTLY: "enemy.armor += intent.finalValue;" - // AND it emits event. - - // This discrepancy is the root cause. - // We should standardize. - - // DECISION: Phase 3 Defense event should be flagged or handled as visual-only. - // Since we can't easily add flags to EffectEvent without changing other files, - // let's rely on the context. - - // Actually, simply removing the direct armor application in _applyEnemyIntentEffects - // and letting handleImpact do it is cleaner? - // NO, because Phase 3 needs armor applied BEFORE Player Turn starts, independent of UI speed. - // And _processMiddleTurn relies on the logic sequence. - - // So, we MUST block handleImpact for Phase 3 Defense. - // Phase 1 Defense (Rare, usually Attack) needs to work too. - - // BUT wait, _startEnemyTurn (Phase 1) code: - // if (intent.type == EnemyActionType.defend) { ... sink.add(event); ... } - // It does NOT apply armor. So Phase 1 relies on handleImpact. - - // PROBLEM: handleImpact cannot distinguish Phase 1 vs Phase 3 event easily. - - // FIX: Update _startEnemyTurn (Phase 1) to ALSO apply armor directly and make the event visual-only. - // Then we can globally block Enemy Defend in handleImpact. - - // REMOVED: Blocking Enemy Defend. Now we want to process it. - // if (event.attacker == enemy && event.type == ActionType.defend) { - // return; - // } - // Only process actual attack or defend impacts here _processAttackImpact(event); @@ -945,7 +905,7 @@ class BattleProvider with ChangeNotifier { notifyListeners(); } - /// Tries to apply status effects from attacker's equipment to the target. + /// Tries to applyStatus effects from attacker's equipment to the target. void _tryApplyStatusEffects(Character attacker, Character target) { List effectsToApply = CombatCalculator.getAppliedEffects( attacker, diff --git a/lib/screens/battle_screen.dart b/lib/screens/battle_screen.dart index 5f8e7c0..87ad26d 100644 --- a/lib/screens/battle_screen.dart +++ b/lib/screens/battle_screen.dart @@ -510,6 +510,9 @@ class _BattleScreenState extends State { // Trigger Animation _enemyAnimKey.currentState ?.animateDefense(() { + // [New] Apply Logic Synced with Animation + battleProvider.applyPendingEnemyDefense(); + // Create a local visual-only event to trigger the effect (Icon or FAILED text) final bool isSuccess = enemyIntent.isSuccess; final BattleFeedbackType? feedbackType = isSuccess @@ -701,20 +704,7 @@ class _BattleScreenState extends State { padding: const EdgeInsets.all(16.0), child: Stack( children: [ - // Enemy (Top Right) - Positioned( - top: 16, // Add some padding from top - right: 16, // Add some padding from right - child: CharacterStatusCard( - character: battleProvider.enemy, - isPlayer: false, - isTurn: !battleProvider.isPlayerTurn, - key: _enemyKey, - animationKey: _enemyAnimKey, // Direct Pass - hideStats: _isEnemyAttacking, - ), - ), - // Player (Bottom Left) + // Player (Bottom Left) - Rendered First Positioned( bottom: 80, // Space for FABs left: 16, // Add some padding from left @@ -727,6 +717,19 @@ class _BattleScreenState extends State { hideStats: _isPlayerAttacking, ), ), + // Enemy (Top Right) - Rendered Last (On Top) + Positioned( + top: 16, // Add some padding from top + right: 16, // Add some padding from right + child: CharacterStatusCard( + character: battleProvider.enemy, + isPlayer: false, + isTurn: !battleProvider.isPlayerTurn, + key: _enemyKey, + animationKey: _enemyAnimKey, // Direct Pass + hideStats: _isEnemyAttacking, + ), + ), ], // Close children list ), // Close Stack ), // Close Padding diff --git a/lib/screens/inventory_screen.dart b/lib/screens/inventory_screen.dart index ef09d65..b05d455 100644 --- a/lib/screens/inventory_screen.dart +++ b/lib/screens/inventory_screen.dart @@ -6,6 +6,8 @@ import '../game/enums.dart'; import '../utils/item_utils.dart'; import '../game/config/theme_config.dart'; import '../game/config/app_strings.dart'; +import '../widgets/inventory/character_stats_widget.dart'; +import '../widgets/inventory/inventory_grid_widget.dart'; class InventoryScreen extends StatelessWidget { const InventoryScreen({super.key}); @@ -20,57 +22,10 @@ class InventoryScreen extends StatelessWidget { return Column( children: [ - // Player Stats Header - Card( - margin: const EdgeInsets.all(16.0), - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - children: [ - Text( - player.name, - style: Theme.of(context).textTheme.headlineSmall, - ), - const SizedBox(height: 8), - Text("Stage: ${battleProvider.stage}"), - const Divider(), - Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _buildStatItem( - AppStrings.hp, - "${player.hp}/${player.totalMaxHp}", - color: ThemeConfig.statHpColor, - ), - _buildStatItem( - AppStrings.atk, - "${player.totalAtk}", - color: ThemeConfig.statAtkColor, - ), - _buildStatItem( - AppStrings.def, - "${player.totalDefense}", - color: ThemeConfig.statDefColor, - ), - _buildStatItem(AppStrings.armor, "${player.armor}"), - _buildStatItem( - AppStrings.luck, - "${player.totalLuck}", - color: ThemeConfig.statLuckColor, - ), - _buildStatItem( - AppStrings.gold, - "${player.gold} G", - color: ThemeConfig.statGoldColor, - ), - ], - ), - ], - ), - ), - ), + // 1. Modularized Stats Widget + const CharacterStatsWidget(), - // Equipped Items Section (Slot based) + // 2. Equipped Items Section (Kept here for now) Padding( padding: const EdgeInsets.symmetric( horizontal: 16.0, @@ -119,7 +74,7 @@ class InventoryScreen extends StatelessWidget { : null, child: Stack( children: [ - // Slot Name (Top Right) + // Slot Name Positioned( right: 4, top: 4, @@ -132,14 +87,12 @@ class InventoryScreen extends StatelessWidget { ), ), ), - // Faded Icon (Top Left) + // Faded Icon Positioned( left: 4, top: 4, child: Opacity( - opacity: item != null - ? 0.5 - : 0.2, // Increase opacity slightly for images + opacity: item != null ? 0.5 : 0.2, child: Image.asset( ItemUtils.getIconPath(slot), width: 40, @@ -157,13 +110,12 @@ class InventoryScreen extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ - const SizedBox( - height: 12, - ), // Spacing for top elements + const SizedBox(height: 12), FittedBox( fit: BoxFit.scaleDown, child: Text( - item?.name ?? AppStrings.emptySlot, + item?.name ?? + AppStrings.emptySlot, textAlign: TextAlign.center, style: TextStyle( fontSize: @@ -200,125 +152,8 @@ class InventoryScreen extends StatelessWidget { ), ), - // Inventory (Bag) Section - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 8.0, - ), - child: Align( - alignment: Alignment.centerLeft, - child: Text( - "${AppStrings.bag} (${player.inventory.length}/${player.maxInventorySize})", - style: const TextStyle( - fontSize: ThemeConfig.fontSizeHeader, - fontWeight: ThemeConfig.fontWeightBold, - ), - ), - ), - ), - Expanded( - child: GridView.builder( - padding: const EdgeInsets.all(16.0), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 4, - crossAxisSpacing: 8.0, - mainAxisSpacing: 8.0, - ), - itemCount: player.maxInventorySize, - itemBuilder: (context, index) { - if (index < player.inventory.length) { - final item = player.inventory[index]; - return InkWell( - onTap: () { - // Show Action Dialog instead of direct Equip - _showItemActionDialog(context, battleProvider, item); - }, - child: Card( - color: ThemeConfig.inventoryCardBg, - shape: item.rarity != ItemRarity.magic - ? RoundedRectangleBorder( - side: BorderSide( - color: ItemUtils.getRarityColor( - item.rarity, - ), - width: 2.0, - ), - borderRadius: BorderRadius.circular(4.0), - ) - : null, - child: Stack( - children: [ - // Faded Icon in Top-Left - Positioned( - left: 4, - top: 4, - child: Opacity( - opacity: - 0.5, // Adjusted opacity for image visibility - child: Image.asset( - ItemUtils.getIconPath(item.slot), - width: 40, - height: 40, - fit: BoxFit.contain, - filterQuality: FilterQuality.high, - ), - ), - ), - // Centered Content - Center( - child: Padding( - padding: const EdgeInsets.all(4.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - FittedBox( - fit: BoxFit.scaleDown, - child: Text( - item.name, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: ThemeConfig.fontSizeSmall, - fontWeight: - ThemeConfig.fontWeightBold, - color: ItemUtils.getRarityColor( - item.rarity, - ), - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - FittedBox( - fit: BoxFit.scaleDown, - child: _buildItemStatText(item), - ), - ], - ), - ), - ), - ], - ), - ), - ); - } else { - // Empty slot - return Container( - decoration: BoxDecoration( - border: Border.all(color: ThemeConfig.textColorGrey), - color: ThemeConfig.emptySlotBg, - ), - child: const Center( - child: Icon( - Icons.add_box, - color: ThemeConfig.textColorGrey, - ), - ), - ); - } - }, - ), - ), + // 3. Modularized Inventory Grid + const Expanded(child: InventoryGridWidget()), ], ); }, @@ -326,230 +161,7 @@ class InventoryScreen extends StatelessWidget { ); } - Widget _buildStatItem(String label, String value, {Color? color}) { - return Column( - children: [ - Text( - label, - style: const TextStyle( - color: ThemeConfig.textColorGrey, - fontSize: 12, - ), - ), - Text( - value, - style: TextStyle( - fontWeight: ThemeConfig.fontWeightBold, - fontSize: 16, - color: color, - ), - ), - ], - ); - } - - /// Shows a menu with actions for the selected item (Equip, Discard, etc.) - void _showItemActionDialog( - BuildContext context, - BattleProvider provider, - Item item, - ) { - bool isShop = provider.currentStage.type == StageType.shop; - - showDialog( - context: context, - builder: (ctx) => SimpleDialog( - title: Text("${item.name} Actions"), - children: [ - SimpleDialogOption( - onPressed: () { - Navigator.pop(ctx); - _showEquipConfirmationDialog(context, provider, item); - }, - child: const Padding( - padding: EdgeInsets.symmetric(vertical: 8.0), - child: Row( - children: [ - Icon(Icons.shield, color: ThemeConfig.btnDefendActive), - SizedBox(width: 10), - Text(AppStrings.equip), - ], - ), - ), - ), - if (isShop) - SimpleDialogOption( - onPressed: () { - Navigator.pop(ctx); - _showSellConfirmationDialog(context, provider, item); - }, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: Row( - children: [ - const Icon( - Icons.attach_money, - color: ThemeConfig.statGoldColor, - ), - const SizedBox(width: 10), - Text("${AppStrings.sell} (${item.price} G)"), - ], - ), - ), - ), - SimpleDialogOption( - onPressed: () { - Navigator.pop(ctx); - _showDiscardConfirmationDialog(context, provider, item); - }, - child: const Padding( - padding: EdgeInsets.symmetric(vertical: 8.0), - child: Row( - children: [ - Icon(Icons.delete, color: ThemeConfig.btnActionActive), - SizedBox(width: 10), - Text(AppStrings.discard), - ], - ), - ), - ), - ], - ), - ); - } - - void _showSellConfirmationDialog( - BuildContext context, - BattleProvider provider, - Item item, - ) { - showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text("Sell Item"), - content: Text("Sell ${item.name} for ${item.price} G?"), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text(AppStrings.cancel), - ), - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: ThemeConfig.statGoldColor, - ), - onPressed: () { - provider.sellItem(item); - Navigator.pop(ctx); - }, - child: const Text(AppStrings.sell, style: TextStyle(color: Colors.black)), - ), - ], - ), - ); - } - - void _showDiscardConfirmationDialog( - BuildContext context, - BattleProvider provider, - Item item, - ) { - showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text("Discard Item"), - content: Text("Are you sure you want to discard ${item.name}?"), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text(AppStrings.cancel), - ), - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: ThemeConfig.btnActionActive, - ), - onPressed: () { - provider.discardItem(item); - Navigator.pop(ctx); - }, - child: const Text(AppStrings.discard), - ), - ], - ), - ); - } - - void _showEquipConfirmationDialog( - BuildContext context, - BattleProvider provider, - Item newItem, - ) { - final player = provider.player; - final oldItem = player.equipment[newItem.slot]; - - // Calculate predicted stats - final currentMaxHp = player.totalMaxHp; - final currentAtk = player.totalAtk; - final currentDef = player.totalDefense; - final currentHp = player.hp; - - // Predict new stats - int newMaxHp = currentMaxHp - (oldItem?.hpBonus ?? 0) + newItem.hpBonus; - int newAtk = currentAtk - (oldItem?.atkBonus ?? 0) + newItem.atkBonus; - int newDef = currentDef - (oldItem?.armorBonus ?? 0) + newItem.armorBonus; - - // Predict HP (Percentage Logic) - double ratio = currentMaxHp > 0 ? currentHp / currentMaxHp : 0.0; - int newHp = (newMaxHp * ratio).toInt(); - if (newHp < 0) newHp = 0; - if (newHp > newMaxHp) newHp = newMaxHp; - - showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text("Change Equipment"), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - "${AppStrings.equip} ${newItem.name}?", - style: const TextStyle(fontWeight: ThemeConfig.fontWeightBold), - ), - if (oldItem != null) - Text( - "Replaces ${oldItem.name}", - style: const TextStyle( - fontSize: 12, - color: ThemeConfig.textColorGrey, - ), - ), - const SizedBox(height: 16), - _buildStatChangeRow("Max HP", currentMaxHp, newMaxHp), - _buildStatChangeRow("Current HP", currentHp, newHp), - _buildStatChangeRow(AppStrings.atk, currentAtk, newAtk), - _buildStatChangeRow(AppStrings.def, currentDef, newDef), - _buildStatChangeRow( - "LUCK", - player.totalLuck, - player.totalLuck - (oldItem?.luck ?? 0) + newItem.luck, - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text(AppStrings.cancel), - ), - ElevatedButton( - onPressed: () { - provider.equipItem(newItem); - Navigator.pop(ctx); - }, - child: const Text(AppStrings.confirm), - ), - ], - ), - ); - } + // --- Helper Methods for Equipped Items Section --- void _showUnequipConfirmationDialog( BuildContext context, diff --git a/lib/widgets/battle/battle_animation_widget.dart b/lib/widgets/battle/battle_animation_widget.dart index 4938252..8dfba15 100644 --- a/lib/widgets/battle/battle_animation_widget.dart +++ b/lib/widgets/battle/battle_animation_widget.dart @@ -85,8 +85,11 @@ class BattleAnimationWidgetState extends State if (!mounted) return; // 2. Dash to Target (Impact) - _translateAnimation = Tween(begin: Offset.zero, end: targetOffset) - .animate( + // Adjust offset to prevent complete overlap (stop slightly short) since both share the same layer stack + final adjustedOffset = targetOffset * 0.5; + + _translateAnimation = + Tween(begin: Offset.zero, end: adjustedOffset).animate( CurvedAnimation( parent: _translateController, curve: Curves.easeInExpo, // Heavy impact curve diff --git a/lib/widgets/inventory/character_stats_widget.dart b/lib/widgets/inventory/character_stats_widget.dart new file mode 100644 index 0000000..acca58d --- /dev/null +++ b/lib/widgets/inventory/character_stats_widget.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../providers/battle_provider.dart'; +import '../../game/config/theme_config.dart'; +import '../../game/config/app_strings.dart'; + +class CharacterStatsWidget extends StatelessWidget { + const CharacterStatsWidget({super.key}); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, battleProvider, child) { + final player = battleProvider.player; + return Card( + margin: const EdgeInsets.all(16.0), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + Text( + player.name, + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 8), + Text("Stage: ${battleProvider.stage}"), + const Divider(), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildStatItem( + AppStrings.hp, + "${player.hp}/${player.totalMaxHp}", + color: ThemeConfig.statHpColor, + ), + _buildStatItem( + AppStrings.atk, + "${player.totalAtk}", + color: ThemeConfig.statAtkColor, + ), + _buildStatItem( + AppStrings.def, + "${player.totalDefense}", + color: ThemeConfig.statDefColor, + ), + _buildStatItem(AppStrings.armor, "${player.armor}"), + _buildStatItem( + AppStrings.luck, + "${player.totalLuck}", + color: ThemeConfig.statLuckColor, + ), + _buildStatItem( + AppStrings.gold, + "${player.gold} G", + color: ThemeConfig.statGoldColor, + ), + ], + ), + ], + ), + ), + ); + }, + ); + } + + Widget _buildStatItem(String label, String value, {Color? color}) { + return Column( + children: [ + Text( + label, + style: const TextStyle( + color: ThemeConfig.textColorGrey, + fontSize: 12, + ), + ), + Text( + value, + style: TextStyle( + fontWeight: ThemeConfig.fontWeightBold, + fontSize: 16, + color: color, + ), + ), + ], + ); + } +} diff --git a/lib/widgets/inventory/inventory_grid_widget.dart b/lib/widgets/inventory/inventory_grid_widget.dart new file mode 100644 index 0000000..a2deb3b --- /dev/null +++ b/lib/widgets/inventory/inventory_grid_widget.dart @@ -0,0 +1,424 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../providers/battle_provider.dart'; +import '../../game/model/item.dart'; +import '../../game/enums.dart'; +import '../../utils/item_utils.dart'; +import '../../game/config/theme_config.dart'; +import '../../game/config/app_strings.dart'; + +class InventoryGridWidget extends StatelessWidget { + const InventoryGridWidget({super.key}); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, battleProvider, child) { + final player = battleProvider.player; + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8.0, + ), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + "${AppStrings.bag} (${player.inventory.length}/${player.maxInventorySize})", + style: const TextStyle( + fontSize: ThemeConfig.fontSizeHeader, + fontWeight: ThemeConfig.fontWeightBold, + ), + ), + ), + ), + Expanded( + child: GridView.builder( + padding: const EdgeInsets.all(16.0), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + crossAxisSpacing: 8.0, + mainAxisSpacing: 8.0, + ), + itemCount: player.maxInventorySize, + itemBuilder: (context, index) { + if (index < player.inventory.length) { + final item = player.inventory[index]; + return InkWell( + onTap: () { + _showItemActionDialog(context, battleProvider, item); + }, + child: Card( + color: ThemeConfig.inventoryCardBg, + shape: item.rarity != ItemRarity.magic + ? RoundedRectangleBorder( + side: BorderSide( + color: ItemUtils.getRarityColor(item.rarity), + width: 2.0, + ), + borderRadius: BorderRadius.circular(4.0), + ) + : null, + child: Stack( + children: [ + Positioned( + left: 4, + top: 4, + child: Opacity( + opacity: 0.5, + child: Image.asset( + ItemUtils.getIconPath(item.slot), + width: 40, + height: 40, + fit: BoxFit.contain, + filterQuality: FilterQuality.high, + ), + ), + ), + Center( + child: Padding( + padding: const EdgeInsets.all(4.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FittedBox( + fit: BoxFit.scaleDown, + child: Text( + item.name, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: ThemeConfig.fontSizeSmall, + fontWeight: + ThemeConfig.fontWeightBold, + color: ItemUtils.getRarityColor( + item.rarity, + ), + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + FittedBox( + fit: BoxFit.scaleDown, + child: _buildItemStatText(item), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } else { + return Container( + decoration: BoxDecoration( + border: Border.all(color: ThemeConfig.textColorGrey), + color: ThemeConfig.emptySlotBg, + ), + child: const Center( + child: Icon( + Icons.add_box, + color: ThemeConfig.textColorGrey, + ), + ), + ); + } + }, + ), + ), + ], + ); + }, + ); + } + + void _showItemActionDialog( + BuildContext context, + BattleProvider provider, + Item item, + ) { + bool isShop = provider.currentStage.type == StageType.shop; + + showDialog( + context: context, + builder: (ctx) => SimpleDialog( + title: Text("${item.name} Actions"), + children: [ + SimpleDialogOption( + onPressed: () { + Navigator.pop(ctx); + _showEquipConfirmationDialog(context, provider, item); + }, + child: const Padding( + padding: EdgeInsets.symmetric(vertical: 8.0), + child: Row( + children: [ + Icon(Icons.shield, color: ThemeConfig.btnDefendActive), + SizedBox(width: 10), + Text(AppStrings.equip), + ], + ), + ), + ), + if (isShop) + SimpleDialogOption( + onPressed: () { + Navigator.pop(ctx); + _showSellConfirmationDialog(context, provider, item); + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Row( + children: [ + const Icon( + Icons.attach_money, + color: ThemeConfig.statGoldColor, + ), + const SizedBox(width: 10), + Text("${AppStrings.sell} (${item.price} G)"), + ], + ), + ), + ), + SimpleDialogOption( + onPressed: () { + Navigator.pop(ctx); + _showDiscardConfirmationDialog(context, provider, item); + }, + child: const Padding( + padding: EdgeInsets.symmetric(vertical: 8.0), + child: Row( + children: [ + Icon(Icons.delete, color: ThemeConfig.btnActionActive), + SizedBox(width: 10), + Text(AppStrings.discard), + ], + ), + ), + ), + ], + ), + ); + } + + void _showSellConfirmationDialog( + BuildContext context, + BattleProvider provider, + Item item, + ) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text("Sell Item"), + content: Text("Sell ${item.name} for ${item.price} G?"), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text(AppStrings.cancel), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: ThemeConfig.statGoldColor, + ), + onPressed: () { + provider.sellItem(item); + Navigator.pop(ctx); + }, + child: const Text( + AppStrings.sell, + style: TextStyle(color: Colors.black), + ), + ), + ], + ), + ); + } + + void _showDiscardConfirmationDialog( + BuildContext context, + BattleProvider provider, + Item item, + ) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text("Discard Item"), + content: Text("Are you sure you want to discard ${item.name}?"), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text(AppStrings.cancel), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: ThemeConfig.btnActionActive, + ), + onPressed: () { + provider.discardItem(item); + Navigator.pop(ctx); + }, + child: const Text(AppStrings.discard), + ), + ], + ), + ); + } + + void _showEquipConfirmationDialog( + BuildContext context, + BattleProvider provider, + Item newItem, + ) { + final player = provider.player; + final oldItem = player.equipment[newItem.slot]; + + final currentMaxHp = player.totalMaxHp; + final currentAtk = player.totalAtk; + final currentDef = player.totalDefense; + final currentHp = player.hp; + + int newMaxHp = currentMaxHp - (oldItem?.hpBonus ?? 0) + newItem.hpBonus; + int newAtk = currentAtk - (oldItem?.atkBonus ?? 0) + newItem.atkBonus; + int newDef = currentDef - (oldItem?.armorBonus ?? 0) + newItem.armorBonus; + + double ratio = currentMaxHp > 0 ? currentHp / currentMaxHp : 0.0; + int newHp = (newMaxHp * ratio).toInt(); + if (newHp < 0) newHp = 0; + if (newHp > newMaxHp) newHp = newMaxHp; + + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text("Change Equipment"), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "${AppStrings.equip} ${newItem.name}?", + style: const TextStyle(fontWeight: ThemeConfig.fontWeightBold), + ), + if (oldItem != null) + Text( + "Replaces ${oldItem.name}", + style: const TextStyle( + fontSize: 12, + color: ThemeConfig.textColorGrey, + ), + ), + const SizedBox(height: 16), + _buildStatChangeRow("Max HP", currentMaxHp, newMaxHp), + _buildStatChangeRow("Current HP", currentHp, newHp), + _buildStatChangeRow(AppStrings.atk, currentAtk, newAtk), + _buildStatChangeRow(AppStrings.def, currentDef, newDef), + _buildStatChangeRow( + "LUCK", + player.totalLuck, + player.totalLuck - (oldItem?.luck ?? 0) + newItem.luck, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text(AppStrings.cancel), + ), + ElevatedButton( + onPressed: () { + provider.equipItem(newItem); + Navigator.pop(ctx); + }, + child: const Text(AppStrings.confirm), + ), + ], + ), + ); + } + + Widget _buildStatChangeRow(String label, int oldVal, int newVal) { + int diff = newVal - oldVal; + Color color = diff > 0 + ? ThemeConfig.statDiffPositive + : (diff < 0 + ? ThemeConfig.statDiffNegative + : ThemeConfig.statDiffNeutral); + String diffText = diff > 0 ? "(+$diff)" : (diff < 0 ? "($diff)" : ""); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + Row( + children: [ + Text( + "$oldVal", + style: const TextStyle(color: ThemeConfig.textColorGrey), + ), + const Icon( + Icons.arrow_right, + size: 16, + color: ThemeConfig.textColorGrey, + ), + Text( + "$newVal", + style: const TextStyle(fontWeight: ThemeConfig.fontWeightBold), + ), + const SizedBox(width: 4), + Text( + diffText, + style: TextStyle( + color: color, + fontSize: 12, + fontWeight: ThemeConfig.fontWeightBold, + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildItemStatText(Item item) { + List stats = []; + if (item.atkBonus > 0) stats.add("+${item.atkBonus} ${AppStrings.atk}"); + if (item.hpBonus > 0) stats.add("+${item.hpBonus} ${AppStrings.hp}"); + if (item.armorBonus > 0) stats.add("+${item.armorBonus} ${AppStrings.def}"); + if (item.luck > 0) stats.add("+${item.luck} ${AppStrings.luck}"); + + List effectTexts = item.effects.map((e) => e.description).toList(); + + if (stats.isEmpty && effectTexts.isEmpty) return const SizedBox.shrink(); + + return Column( + children: [ + if (stats.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 2.0, bottom: 2.0), + child: Text( + stats.join(", "), + style: const TextStyle( + fontSize: ThemeConfig.fontSizeSmall, + color: ThemeConfig.statAtkColor, + ), + textAlign: TextAlign.center, + ), + ), + if (effectTexts.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 2.0), + child: Text( + effectTexts.join("\n"), + style: const TextStyle( + fontSize: ThemeConfig.fontSizeTiny, + color: ThemeConfig.rarityLegendary, + ), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/stage/shop_ui.dart b/lib/widgets/stage/shop_ui.dart index c66e023..5618ede 100644 --- a/lib/widgets/stage/shop_ui.dart +++ b/lib/widgets/stage/shop_ui.dart @@ -8,6 +8,7 @@ import '../../../game/enums.dart'; import '../../../game/config/theme_config.dart'; import '../../../game/config/game_config.dart'; import '../../../game/model/entity.dart'; +import '../inventory/inventory_grid_widget.dart'; class ShopUI extends StatelessWidget { final BattleProvider battleProvider; @@ -26,6 +27,7 @@ class ShopUI extends StatelessWidget { padding: const EdgeInsets.all(16.0), child: Column( children: [ + // Header Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -67,132 +69,123 @@ class ShopUI extends StatelessWidget { ], ), const Divider(color: ThemeConfig.textColorGrey), - const SizedBox(height: 16), + const SizedBox(height: 8), + // Shop Items Grid (Top Half) Expanded( - child: shopItems.isEmpty - ? const Center( - child: Text( - "Sold Out", - style: TextStyle( - color: ThemeConfig.textColorGrey, - fontSize: 24, - ), - ), - ) - : GridView.builder( - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - crossAxisSpacing: 16.0, - mainAxisSpacing: 16.0, - childAspectRatio: 0.8, - ), - itemCount: shopItems.length, - itemBuilder: (context, index) { - final item = shopItems[index]; - final canBuy = player.gold >= item.price; - - return InkWell( - onTap: () => _showBuyConfirmation( - context, - item, - shopProvider, - player, - ), - child: Card( - color: ThemeConfig.shopItemCardBg, - shape: item.rarity != ItemRarity.magic - ? RoundedRectangleBorder( - side: BorderSide( - color: ItemUtils.getRarityColor( - item.rarity, - ), - width: 2.0, - ), - borderRadius: BorderRadius.circular(8.0), - ) - : null, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - crossAxisAlignment: - CrossAxisAlignment.stretch, - children: [ - Expanded( - flex: 2, - child: Center( - child: Image.asset( - ItemUtils.getIconPath(item.slot), - width: 48, - height: 48, - fit: BoxFit.contain, - filterQuality: FilterQuality.high, - ), - ), - ), - Expanded( - flex: 1, - child: Center( - child: Text( - item.name, - textAlign: TextAlign.center, - style: TextStyle( - fontWeight: - ThemeConfig.fontWeightBold, - color: ItemUtils.getRarityColor( - item.rarity, - ), - fontSize: - ThemeConfig.fontSizeMedium, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - ), - Expanded( - flex: 1, - child: _buildItemStatText(item), - ), - SizedBox( - height: 32, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: canBuy - ? ThemeConfig.statGoldColor - : ThemeConfig.btnDisabled, - foregroundColor: Colors.black, - padding: EdgeInsets.zero, - ), - onPressed: canBuy - ? () => _showBuyConfirmation( - context, - item, - shopProvider, - player, - ) - : null, - child: Text( - "${item.price} G", - style: const TextStyle( - fontWeight: - ThemeConfig.fontWeightBold, - ), - ), - ), - ), - ], + flex: 5, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "Shop Items", + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: ThemeConfig.textColorWhite, + ), + ), + const SizedBox(height: 8), + Expanded( + child: shopItems.isEmpty + ? const Center( + child: Text( + "Sold Out", + style: TextStyle( + color: ThemeConfig.textColorGrey, + fontSize: 24, ), ), + ) + : GridView.builder( + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + crossAxisSpacing: 8.0, + mainAxisSpacing: 8.0, + childAspectRatio: 0.8, + ), + itemCount: shopItems.length, + itemBuilder: (context, index) { + final item = shopItems[index]; + final canBuy = player.gold >= item.price; + + return InkWell( + onTap: () => _showBuyConfirmation( + context, + item, + shopProvider, + player, + ), + child: Card( + color: ThemeConfig.shopItemCardBg, + shape: item.rarity != ItemRarity.magic + ? RoundedRectangleBorder( + side: BorderSide( + color: ItemUtils.getRarityColor( + item.rarity, + ), + width: 2.0, + ), + borderRadius: BorderRadius.circular( + 8.0, + ), + ) + : null, + child: Padding( + padding: const EdgeInsets.all(4.0), + child: Column( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + Expanded( + child: Image.asset( + ItemUtils.getIconPath(item.slot), + fit: BoxFit.contain, + ), + ), + Text( + item.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontWeight: FontWeight.bold, + color: ItemUtils.getRarityColor( + item.rarity, + ), + fontSize: 12, + ), + ), + Text( + "${item.price} G", + style: TextStyle( + color: canBuy + ? ThemeConfig.statGoldColor + : ThemeConfig.textColorGrey, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ], + ), + ), + ), + ); + }, ), - ); - }, - ), + ), + ], + ), ), - const SizedBox(height: 16), + const Divider(color: ThemeConfig.textColorGrey), + // Player Inventory (Bottom Half) + const Expanded(flex: 5, child: InventoryGridWidget()), + + const SizedBox(height: 8), + + // Action Buttons Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ @@ -200,7 +193,7 @@ class ShopUI extends StatelessWidget { style: ElevatedButton.styleFrom( backgroundColor: ThemeConfig.btnRerollBg, padding: const EdgeInsets.symmetric( - horizontal: 24, + horizontal: 16, vertical: 12, ), ), @@ -233,7 +226,7 @@ class ShopUI extends StatelessWidget { style: ElevatedButton.styleFrom( backgroundColor: ThemeConfig.btnLeaveBg, padding: const EdgeInsets.symmetric( - horizontal: 24, + horizontal: 16, vertical: 12, ), ), @@ -288,18 +281,10 @@ class ShopUI extends StatelessWidget { ), onPressed: () { bool success = shopProvider.buyItem(item, player); - Navigator.pop(ctx); // Close dialog first + Navigator.pop(ctx); if (success) { - // Refresh BattleProvider to update UI (Gold, Inventory) since player object is owned by BattleProvider - // and ShopProvider modifies it directly without BattleProvider knowing. - // Ideally, ShopProvider should notify, but since we don't have a direct link back or a shared PlayerProvider, - // we trigger it from the UI. - // Alternatively, we could add refreshUI to BattleProvider. - // Assuming BattleProvider has refreshUI or we can just use notifyListeners if we had access, but we don't. - // Wait, we have battleProvider instance passed to ShopUI. - battleProvider.refreshUI(); - + battleProvider.refreshUI(); // Update UI ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text("Bought ${item.name}"), @@ -321,38 +306,4 @@ class ShopUI extends StatelessWidget { ), ); } - - Widget _buildItemStatText(Item item) { - List stats = []; - if (item.atkBonus > 0) stats.add("ATK +${item.atkBonus}"); - if (item.hpBonus > 0) stats.add("HP +${item.hpBonus}"); - if (item.armorBonus > 0) stats.add("DEF +${item.armorBonus}"); - if (item.luck > 0) stats.add("LUCK +${item.luck}"); - - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (stats.isNotEmpty) - Text( - stats.join(", "), - style: const TextStyle( - fontSize: ThemeConfig.fontSizeSmall, - color: Colors.white70, - ), - textAlign: TextAlign.center, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - if (item.effects.isNotEmpty) - Text( - item.effects.first.type.name.toUpperCase(), - style: const TextStyle( - fontSize: ThemeConfig.fontSizeTiny, - color: ThemeConfig.rarityLegendary, - ), - textAlign: TextAlign.center, - ), - ], - ); - } } diff --git a/prompt/63_inventory_refactor_and_enemy_z_order.md b/prompt/63_inventory_refactor_and_enemy_z_order.md new file mode 100644 index 0000000..ca07f6c --- /dev/null +++ b/prompt/63_inventory_refactor_and_enemy_z_order.md @@ -0,0 +1,36 @@ +# 63. 인벤토리/스탯 모듈화 및 상점 UI, 전투 Z-Index 수정 + +## 1. 작업 개요 + +- **InventoryScreen 모듈화**: 스탯 표시부와 인벤토리 그리드/리스트를 별도 위젯으로 분리하여 재사용성 확보. +- **Shop UI 개선**: 상점 화면 하단에 분리한 인벤토리 위젯을 배치하여 보유 아이템 확인 및 판매 용이성 증대. +- **Battle Z-Index 수정**: 적 캐릭터 카드가 플레이어 카드보다 **상위 레이어(Z-Index)**에 위치하도록 수정. + - 단, 적의 Risky Attack 등 이동 폭이 큰 애니메이션 시, 플레이어 카드를 과도하게 가리지 않도록 오프셋(Offset) 조정 함께 진행. + +## 2. 세부 작업 항목 + +### 2.1. 인벤토리/스탯 모듈화 + +- `InventoryScreen.dart` 분석 후 다음 위젯 추출: + - `CharacterStatsWidget`: 플레이어 스탯 정보 표시. + - `InventoryGridWidget` (또는 List): 아이템 목록 표시. (클릭 시 동작 등 콜백 처리). +- `lib/widgets/inventory/` 폴더에 생성. + +### 2.2. 상점 UI (ShopUI) 수정 + +- `ShopUI.dart` 하단에 `InventoryGridWidget` 배치. +- 상점 모드일 경우 아이템 클릭 시 '판매' 팝업 또는 동작 연결. + +### 2.3. BattleScreen Z-Index 및 애니메이션 + +- `BattleScreen.dart`의 `Stack` 구조에서 `Positioned` 순서 변경 (Enemy를 Player보다 뒤에 배치하여 위로 오게 함). +- `BattleAnimationWidget` 또는 `BattleScreen` 내 애니메이션 오프셋 로직 점검. + - Enemy Risky Attack 시 이동 거리가 너무 길어 플레이어를 완전히 덮어버린다면 오프셋 줄이기. + +## 3. 진행 상황 + +- [ ] 모듈화 설계 및 위젯 분리 +- [ ] InventoryScreen 리팩토링 적용 +- [ ] ShopUI 인벤토리 추가 +- [ ] BattleScreen Z-Index 수정 +- [ ] 애니메이션 오프셋 조정 및 테스트