update : icon image

This commit is contained in:
Horoli 2025-12-07 16:48:03 +09:00
parent 8771f2c1af
commit d5609aff0f
21 changed files with 684 additions and 394 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 477 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

View File

@ -4,6 +4,7 @@ import 'game/data/item_table.dart';
import 'game/data/enemy_table.dart'; import 'game/data/enemy_table.dart';
import 'game/data/player_table.dart'; import 'game/data/player_table.dart';
import 'providers/battle_provider.dart'; import 'providers/battle_provider.dart';
import 'providers/shop_provider.dart'; // Import ShopProvider
import 'screens/main_menu_screen.dart'; import 'screens/main_menu_screen.dart';
void main() async { void main() async {
@ -20,7 +21,16 @@ class MyApp extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MultiProvider( return MultiProvider(
providers: [ChangeNotifierProvider(create: (_) => BattleProvider())], providers: [
ChangeNotifierProvider(create: (_) => ShopProvider()),
ChangeNotifierProxyProvider<ShopProvider, BattleProvider>(
create: (context) => BattleProvider(
shopProvider: Provider.of<ShopProvider>(context, listen: false),
),
update: (context, shopProvider, battleProvider) =>
battleProvider ?? BattleProvider(shopProvider: shopProvider),
),
],
child: MaterialApp( child: MaterialApp(
title: "Colosseum's Choice", title: "Colosseum's Choice",
theme: ThemeData.dark(), theme: ThemeData.dark(),

View File

@ -2,6 +2,7 @@ import 'dart:async'; // StreamController 사용을 위해 import
import 'dart:math'; import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; // For context.read in _prepareNextStage
import '../game/model/entity.dart'; import '../game/model/entity.dart';
import '../game/model/item.dart'; import '../game/model/item.dart';
import '../game/model/status_effect.dart'; import '../game/model/status_effect.dart';
@ -17,6 +18,7 @@ import '../game/model/effect_event.dart'; // EffectEvent import
import '../game/save_manager.dart'; import '../game/save_manager.dart';
import '../game/config/game_config.dart'; import '../game/config/game_config.dart';
import 'shop_provider.dart'; // Import ShopProvider
class EnemyIntent { class EnemyIntent {
final EnemyActionType type; final EnemyActionType type;
@ -63,7 +65,10 @@ class BattleProvider with ChangeNotifier {
final _effectEventController = StreamController<EffectEvent>.broadcast(); final _effectEventController = StreamController<EffectEvent>.broadcast();
Stream<EffectEvent> get effectStream => _effectEventController.stream; Stream<EffectEvent> get effectStream => _effectEventController.stream;
BattleProvider() { // Dependency injection
final ShopProvider shopProvider;
BattleProvider({required this.shopProvider}) {
// initializeBattle(); // Do not auto-start logic // initializeBattle(); // Do not auto-start logic
} }
@ -231,8 +236,9 @@ class BattleProvider with ChangeNotifier {
_addLog("Stage $stage ($type) started! A wild ${enemy.name} appeared."); _addLog("Stage $stage ($type) started! A wild ${enemy.name} appeared.");
} else if (type == StageType.shop) { } else if (type == StageType.shop) {
// Generate random items for shop // Generate random items for shop using ShopProvider
shopItems = _generateShopItems(); shopProvider.generateShopItems(stage);
shopItems = shopProvider.availableItems;
// Dummy enemy to prevent null errors in existing UI (until UI is fully updated) // Dummy enemy to prevent null errors in existing UI (until UI is fully updated)
enemy = Character(name: "Merchant", maxHp: 9999, armor: 0, atk: 0); enemy = Character(name: "Merchant", maxHp: 9999, armor: 0, atk: 0);
@ -247,60 +253,13 @@ class BattleProvider with ChangeNotifier {
currentStage = StageModel( currentStage = StageModel(
type: type, type: type,
enemy: newEnemy, enemy: newEnemy,
shopItems: shopItems, shopItems: shopItems, // Pass items from ShopProvider
); );
turnCount = 1; turnCount = 1;
notifyListeners(); notifyListeners();
} }
/// Generate 4 random items for the shop based on current stage tier // Shop-related methods are now handled by ShopProvider
List<Item> _generateShopItems() {
ItemTier currentTier = ItemTier.tier1;
if (stage > GameConfig.tier2StageMax)
currentTier = ItemTier.tier3;
else if (stage > GameConfig.tier1StageMax)
currentTier = ItemTier.tier2;
List<Item> items = [];
for (int i = 0; i < 4; i++) {
ItemTemplate? template = ItemTable.getRandomItem(tier: currentTier);
if (template != null) {
items.add(template.createItem(stage: stage));
}
}
return items;
}
void rerollShopItems() {
const int rerollCost = GameConfig.shopRerollCost;
if (player.gold >= rerollCost) {
player.gold -= rerollCost;
// Modify the existing list because shopItems is final
currentStage.shopItems.clear();
currentStage.shopItems.addAll(_generateShopItems());
_addLog("Shop items rerolled for $rerollCost G.");
notifyListeners();
} else {
_addLog("Not enough gold to reroll!");
}
}
void buyItem(Item item) {
if (player.gold >= item.price) {
bool added = player.addToInventory(item);
if (added) {
player.gold -= item.price;
currentStage.shopItems.remove(item); // Remove from shop
_addLog("Bought ${item.name} for ${item.price} G.");
} else {
_addLog("Inventory is full!");
}
notifyListeners();
} else {
_addLog("Not enough gold!");
}
}
// Replaces _spawnEnemy // Replaces _spawnEnemy
// void _spawnEnemy() { ... } - Removed // void _spawnEnemy() { ... } - Removed
@ -737,18 +696,25 @@ class BattleProvider with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void selectReward(Item item) { bool selectReward(Item item) {
if (item.id == "reward_skip") { if (item.id == "reward_skip") {
_addLog("Skipped reward."); _addLog("Skipped reward.");
_completeStage();
return true;
} else { } else {
bool added = player.addToInventory(item); bool added = player.addToInventory(item);
if (added) { if (added) {
_addLog("Added ${item.name} to inventory."); _addLog("Added ${item.name} to inventory.");
_completeStage();
return true;
} else { } else {
_addLog("Inventory is full! ${item.name} discarded."); _addLog("Inventory is full! Could not take ${item.name}.");
return false;
} }
} }
}
void _completeStage() {
// Heal player after selecting reward // Heal player after selecting reward
int healAmount = GameMath.floor(player.totalMaxHp * GameConfig.stageHealRatio); int healAmount = GameMath.floor(player.totalMaxHp * GameConfig.stageHealRatio);
player.heal(healAmount); player.heal(healAmount);

View File

@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import '../game/model/item.dart';
import '../game/model/entity.dart';
import '../game/data/item_table.dart';
import '../game/enums.dart';
import '../game/config/game_config.dart';
import '../utils/game_math.dart';
class ShopProvider with ChangeNotifier {
List<Item> availableItems = [];
String _lastShopMessage = '';
String get lastShopMessage => _lastShopMessage;
void clearMessage() {
_lastShopMessage = '';
notifyListeners();
}
void generateShopItems(int stage) {
ItemTier currentTier = ItemTier.tier1;
if (stage > GameConfig.tier2StageMax)
currentTier = ItemTier.tier3;
else if (stage > GameConfig.tier1StageMax)
currentTier = ItemTier.tier2;
availableItems = [];
for (int i = 0; i < 4; i++) { // Generate 4 items
ItemTemplate? template = ItemTable.getRandomItem(tier: currentTier);
if (template != null) {
availableItems.add(template.createItem(stage: stage));
}
}
notifyListeners();
}
bool rerollShopItems(Character player, int currentStageNumber) {
const int rerollCost = GameConfig.shopRerollCost;
if (player.gold >= rerollCost) {
player.gold -= rerollCost;
generateShopItems(currentStageNumber); // Regenerate based on current stage
_lastShopMessage = "Shop items rerolled for $rerollCost G.";
notifyListeners();
return true;
} else {
_lastShopMessage = "Not enough gold to reroll!";
notifyListeners();
return false;
}
}
bool buyItem(Item item, Character player) {
if (player.gold >= item.price) {
if (player.inventory.length < player.maxInventorySize) {
player.gold -= item.price;
player.addToInventory(item);
availableItems.remove(item); // Remove from shop
_lastShopMessage = "Bought ${item.name} for ${item.price} G.";
notifyListeners();
return true;
} else {
_lastShopMessage = "Inventory is full! Cannot buy ${item.name}.";
notifyListeners();
return false;
}
} else {
_lastShopMessage = "Not enough gold!";
notifyListeners();
return false;
}
}
}

View File

@ -13,7 +13,8 @@ import '../utils/item_utils.dart';
import '../widgets/battle/character_status_card.dart'; import '../widgets/battle/character_status_card.dart';
import '../widgets/battle/battle_log_overlay.dart'; import '../widgets/battle/battle_log_overlay.dart';
import '../widgets/battle/floating_battle_texts.dart'; import '../widgets/battle/floating_battle_texts.dart';
import '../widgets/battle/stage_ui.dart'; import '../widgets/stage/shop_ui.dart';
import '../widgets/stage/rest_ui.dart';
import '../widgets/battle/shake_widget.dart'; import '../widgets/battle/shake_widget.dart';
import '../widgets/battle/battle_animation_widget.dart'; import '../widgets/battle/battle_animation_widget.dart';
import '../widgets/battle/explosion_widget.dart'; import '../widgets/battle/explosion_widget.dart';
@ -489,7 +490,6 @@ class _BattleScreenState extends State<BattleScreen> {
_buildFloatingActionButton( _buildFloatingActionButton(
context, context,
"ATK", "ATK",
Icons.whatshot,
ThemeConfig.btnActionActive, ThemeConfig.btnActionActive,
ActionType.attack, ActionType.attack,
battleProvider.isPlayerTurn && battleProvider.isPlayerTurn &&
@ -501,7 +501,6 @@ class _BattleScreenState extends State<BattleScreen> {
_buildFloatingActionButton( _buildFloatingActionButton(
context, context,
"DEF", "DEF",
Icons.shield,
ThemeConfig.btnDefendActive, ThemeConfig.btnDefendActive,
ActionType.defend, ActionType.defend,
battleProvider.isPlayerTurn && battleProvider.isPlayerTurn &&
@ -564,7 +563,17 @@ class _BattleScreenState extends State<BattleScreen> {
bool isSkip = item.id == "reward_skip"; bool isSkip = item.id == "reward_skip";
return SimpleDialogOption( return SimpleDialogOption(
onPressed: () { onPressed: () {
battleProvider.selectReward(item); bool success = battleProvider.selectReward(item);
if (!success) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
"Inventory is full! Cannot take item.",
),
backgroundColor: Colors.red,
),
);
}
}, },
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -587,10 +596,11 @@ class _BattleScreenState extends State<BattleScreen> {
: ThemeConfig.rarityCommon, : ThemeConfig.rarityCommon,
), ),
), ),
child: Icon( child: Image.asset(
ItemUtils.getIcon(item.slot), ItemUtils.getIconPath(item.slot),
color: ItemUtils.getColor(item.slot), width: 24,
size: 24, height: 24,
fit: BoxFit.contain,
), ),
), ),
if (!isSkip) const SizedBox(width: 12), if (!isSkip) const SizedBox(width: 12),
@ -722,18 +732,30 @@ class _BattleScreenState extends State<BattleScreen> {
Widget _buildFloatingActionButton( Widget _buildFloatingActionButton(
BuildContext context, BuildContext context,
String label, String label,
IconData icon,
Color color, Color color,
ActionType actionType, ActionType actionType,
bool isEnabled, bool isEnabled,
) { ) {
String iconPath;
if (actionType == ActionType.attack) {
iconPath = 'assets/data/icon/icon_weapon.png';
} else {
iconPath = 'assets/data/icon/icon_shield.png';
}
return FloatingActionButton( return FloatingActionButton(
heroTag: label, heroTag: label,
onPressed: isEnabled onPressed: isEnabled
? () => _showRiskLevelSelection(context, actionType) ? () => _showRiskLevelSelection(context, actionType)
: null, : null,
backgroundColor: isEnabled ? color : ThemeConfig.btnDisabled, backgroundColor: isEnabled ? color : ThemeConfig.btnDisabled,
child: Icon(icon), child: Image.asset(
iconPath,
width: 32,
height: 32,
color: ThemeConfig.textColorWhite, // Tint icon white
fit: BoxFit.contain,
),
); );
} }
} }

View File

@ -125,13 +125,12 @@ class InventoryScreen extends StatelessWidget {
left: 4, left: 4,
top: 4, top: 4,
child: Opacity( child: Opacity(
opacity: item != null ? 0.2 : 0.1, opacity: item != null ? 0.5 : 0.2, // Increase opacity slightly for images
child: Icon( child: Image.asset(
ItemUtils.getIcon(slot), ItemUtils.getIconPath(slot),
size: 40, width: 40,
color: item != null height: 40,
? ItemUtils.getColor(slot) fit: BoxFit.contain,
: ThemeConfig.textColorGrey,
), ),
), ),
), ),
@ -238,11 +237,12 @@ class InventoryScreen extends StatelessWidget {
left: 4, left: 4,
top: 4, top: 4,
child: Opacity( child: Opacity(
opacity: 0.2, opacity: 0.5, // Adjusted opacity for image visibility
child: Icon( child: Image.asset(
ItemUtils.getIcon(item.slot), ItemUtils.getIconPath(item.slot),
size: 40, width: 40,
color: ItemUtils.getColor(item.slot), height: 40,
fit: BoxFit.contain,
), ),
), ),
), ),

View File

@ -37,6 +37,7 @@ class _MainMenuScreenState extends State<MainMenuScreen> {
Future<void> _continueGame() async { Future<void> _continueGame() async {
final data = await SaveManager.loadGame(); final data = await SaveManager.loadGame();
if (data != null && mounted) { if (data != null && mounted) {
// BattleProvider is already provided with ShopProvider via ProxyProvider in main.dart
context.read<BattleProvider>().loadFromSave(data); context.read<BattleProvider>().loadFromSave(data);
Navigator.pushReplacement( Navigator.pushReplacement(
context, context,

View File

@ -16,29 +16,16 @@ class ItemUtils {
} }
} }
static IconData getIcon(EquipmentSlot slot) { static String getIconPath(EquipmentSlot slot) {
switch (slot) { switch (slot) {
case EquipmentSlot.weapon: case EquipmentSlot.weapon:
return Icons.change_history; // Triangle return 'assets/data/icon/icon_weapon.png';
case EquipmentSlot.shield: case EquipmentSlot.shield:
return Icons.shield; return 'assets/data/icon/icon_shield.png';
case EquipmentSlot.armor: case EquipmentSlot.armor:
return Icons.checkroom; return 'assets/data/icon/icon_armor.png';
case EquipmentSlot.accessory: case EquipmentSlot.accessory:
return Icons.diamond; return 'assets/data/icon/icon_accessory.png';
}
}
static Color getColor(EquipmentSlot slot) {
switch (slot) {
case EquipmentSlot.weapon:
return Colors.red;
case EquipmentSlot.shield:
return Colors.blue;
case EquipmentSlot.armor:
return Colors.blue;
case EquipmentSlot.accessory:
return Colors.orange;
} }
} }
} }

View File

@ -1,277 +0,0 @@
import 'package:flutter/material.dart';
import '../../providers/battle_provider.dart';
import '../../game/model/item.dart';
import '../../utils/item_utils.dart';
import '../../game/enums.dart';
import '../../game/config/theme_config.dart';
class ShopUI extends StatelessWidget {
final BattleProvider battleProvider;
const ShopUI({super.key, required this.battleProvider});
@override
Widget build(BuildContext context) {
final player = battleProvider.player;
final shopItems = battleProvider.currentStage.shopItems;
return Container(
color: ThemeConfig.shopBg,
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
// Header: Merchant Icon & Player Gold
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Row(
children: [
Icon(Icons.store, size: 32, color: ThemeConfig.mainIconColor),
SizedBox(width: 8),
Text(
"Merchant",
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: ThemeConfig.textColorWhite),
),
],
),
Row(
children: [
const Icon(Icons.monetization_on, color: ThemeConfig.statGoldColor),
const SizedBox(width: 4),
Text(
"${player.gold} G",
style: const TextStyle(
color: ThemeConfig.statGoldColor,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
],
),
],
),
const Divider(color: ThemeConfig.textColorGrey),
const SizedBox(height: 16),
// Shop Items Grid
Expanded(
child: shopItems.isEmpty
? const Center(
child: Text(
"Sold Out",
style: TextStyle(color: ThemeConfig.textColorGrey, fontSize: 24),
),
)
: GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, // 2 columns
crossAxisSpacing: 16.0,
mainAxisSpacing: 16.0,
childAspectRatio: 0.8, // Taller cards
),
itemCount: shopItems.length,
itemBuilder: (context, index) {
final item = shopItems[index];
final canBuy = player.gold >= item.price;
return InkWell(
onTap: () => _showBuyConfirmation(context, item),
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: [
// Icon
Expanded(
flex: 2,
child: Center(
child: Icon(
ItemUtils.getIcon(item.slot),
size: 48,
color: ItemUtils.getColor(item.slot),
),
),
),
// Name
Expanded(
flex: 1,
child: Center(
child: Text(
item.name,
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: FontWeight.bold,
color: ItemUtils.getRarityColor(item.rarity),
fontSize: 12,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
),
// Stats
Expanded(
flex: 1,
child: _buildItemStatText(item),
),
// Price Button
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)
: null,
child: Text(
"${item.price} G",
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
),
],
),
),
),
);
},
),
),
const SizedBox(height: 16),
// Footer Buttons (Reroll & Leave)
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: ThemeConfig.btnRerollBg,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
onPressed: player.gold >= 50
? () => battleProvider.rerollShopItems()
: null,
icon: const Icon(Icons.refresh, color: ThemeConfig.textColorWhite),
label: const Text(
"Reroll (50 G)",
style: TextStyle(color: ThemeConfig.textColorWhite),
),
),
ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: ThemeConfig.btnLeaveBg,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
onPressed: () => battleProvider.proceedToNextStage(),
icon: const Icon(Icons.exit_to_app, color: ThemeConfig.textColorWhite),
label: const Text(
"Leave Shop",
style: TextStyle(color: ThemeConfig.textColorWhite),
),
),
],
),
],
),
);
}
void _showBuyConfirmation(BuildContext context, Item item) {
if (battleProvider.player.gold < item.price) return;
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text("Buy Item"),
content: Text("Buy ${item.name} for ${item.price} G?"),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text("Cancel"),
),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: ThemeConfig.statGoldColor),
onPressed: () {
battleProvider.buyItem(item);
Navigator.pop(ctx);
},
child: const Text("Buy", style: TextStyle(color: Colors.black)),
),
],
),
);
}
Widget _buildItemStatText(Item item) {
List<String> 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: 10, 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: 9, color: ThemeConfig.rarityLegendary),
textAlign: TextAlign.center,
),
],
);
}
}
class RestUI extends StatelessWidget {
final BattleProvider battleProvider;
const RestUI({super.key, required this.battleProvider});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.local_hotel, size: 64, color: ThemeConfig.btnRestBg),
const SizedBox(height: 16),
const Text("Rest Area", style: TextStyle(fontSize: 24, color: ThemeConfig.textColorWhite)),
const SizedBox(height: 8),
const Text("Take a breath and heal.", style: TextStyle(color: ThemeConfig.textColorWhite)),
const SizedBox(height: 32),
ElevatedButton(
onPressed: () {
battleProvider.player.heal(20);
battleProvider.proceedToNextStage();
},
child: const Text("Rest & Leave (+20 HP)"),
),
],
),
);
}
}

View File

@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import '../../../providers/battle_provider.dart';
import '../../../game/config/theme_config.dart';
class RestUI extends StatelessWidget {
final BattleProvider battleProvider;
const RestUI({super.key, required this.battleProvider});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.local_hotel, size: 64, color: ThemeConfig.btnRestBg),
const SizedBox(height: 16),
const Text("Rest Area", style: TextStyle(fontSize: 24, color: ThemeConfig.textColorWhite)),
const SizedBox(height: 8),
const Text("Take a breath and heal.", style: TextStyle(color: ThemeConfig.textColorWhite)),
const SizedBox(height: 32),
ElevatedButton(
onPressed: () {
// Use GameConfig for heal amount if possible, or keep hardcoded for now?
// Let's use GameConfig.stageHealRatio * 2 or fixed 20?
// Previous logic was hardcoded 20. Let's keep it simple for now or use a better logic.
// "Rest & Leave (+20 HP)" -> Hardcoded in text too.
battleProvider.player.heal(20);
battleProvider.proceedToNextStage();
},
child: const Text("Rest & Leave (+20 HP)"),
),
],
),
);
}
}

View File

@ -0,0 +1,333 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../providers/battle_provider.dart';
import '../../../providers/shop_provider.dart';
import '../../../game/model/item.dart';
import '../../../utils/item_utils.dart';
import '../../../game/enums.dart';
import '../../../game/config/theme_config.dart';
import '../../../game/config/game_config.dart';
import '../../../game/model/entity.dart';
class ShopUI extends StatelessWidget {
final BattleProvider battleProvider;
const ShopUI({super.key, required this.battleProvider});
@override
Widget build(BuildContext context) {
return Consumer<ShopProvider>(
builder: (context, shopProvider, child) {
final player = battleProvider.player;
final shopItems = shopProvider.availableItems;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (shopProvider.lastShopMessage.isNotEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(shopProvider.lastShopMessage),
backgroundColor: Colors.red,
),
);
shopProvider.clearMessage();
}
});
return Container(
color: ThemeConfig.shopBg,
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Row(
children: [
Icon(
Icons.store,
size: 32,
color: ThemeConfig.mainIconColor,
),
SizedBox(width: 8),
Text(
"Merchant",
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: ThemeConfig.textColorWhite,
),
),
],
),
Row(
children: [
const Icon(
Icons.monetization_on,
color: ThemeConfig.statGoldColor,
),
const SizedBox(width: 4),
Text(
"${player.gold} G",
style: const TextStyle(
color: ThemeConfig.statGoldColor,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
],
),
],
),
const Divider(color: ThemeConfig.textColorGrey),
const SizedBox(height: 16),
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,
),
),
),
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,
),
),
),
),
],
),
),
),
);
},
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: ThemeConfig.btnRerollBg,
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
),
onPressed: player.gold >= GameConfig.shopRerollCost
? () => shopProvider.rerollShopItems(
player,
battleProvider.stage,
)
: null,
icon: const Icon(
Icons.refresh,
color: ThemeConfig.textColorWhite,
),
label: Text(
"Reroll (${GameConfig.shopRerollCost} G)",
style: const TextStyle(color: ThemeConfig.textColorWhite),
),
),
ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: ThemeConfig.btnLeaveBg,
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
),
onPressed: () => battleProvider.proceedToNextStage(),
icon: const Icon(
Icons.exit_to_app,
color: ThemeConfig.textColorWhite,
),
label: const Text(
"Leave Shop",
style: TextStyle(color: ThemeConfig.textColorWhite),
),
),
],
),
],
),
);
},
);
}
void _showBuyConfirmation(
BuildContext context,
Item item,
ShopProvider shopProvider,
Character player,
) {
if (player.gold < item.price) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Not enough gold!"),
backgroundColor: Colors.red,
),
);
return;
}
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text("Buy Item"),
content: Text("Buy ${item.name} for ${item.price} G?"),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text("Cancel"),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: ThemeConfig.statGoldColor,
),
onPressed: () {
shopProvider.buyItem(item, player);
Navigator.pop(ctx);
},
child: const Text("Buy", style: TextStyle(color: Colors.black)),
),
],
),
);
}
Widget _buildItemStatText(Item item) {
List<String> 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,
),
],
);
}
}

View File

@ -114,6 +114,7 @@
## 4. 작업 컨벤션 (Working Conventions) ## 4. 작업 컨벤션 (Working Conventions)
- **Prompt Driven Development:** `prompt/XX_description.md` 유지. - **Prompt Driven Development:** `prompt/XX_description.md` 유지.
- **유사 작업 통합:** 작업 내용이 이전 프롬프트와 유사한 경우 새로운 프롬프트를 생성하지 않고 기존 프롬프트에 내용을 추가합니다.
- **Language:** **모든 프롬프트 파일(prompt/XX_...)은 반드시 한국어(Korean)로 작성해야 합니다.** - **Language:** **모든 프롬프트 파일(prompt/XX_...)은 반드시 한국어(Korean)로 작성해야 합니다.**
- **Config Management:** 하드코딩되는 값들은 `config` 폴더 내 파일들(`lib/game/config/` 등)에서 통합 관리할 수 있도록 작성해야 합니다. - **Config Management:** 하드코딩되는 값들은 `config` 폴더 내 파일들(`lib/game/config/` 등)에서 통합 관리할 수 있도록 작성해야 합니다.
- **State Management:** `Provider` + `Stream` (이벤트성 데이터). - **State Management:** `Provider` + `Stream` (이벤트성 데이터).
@ -153,10 +154,9 @@
## 7. 프롬프트 히스토리 (Prompt History) ## 7. 프롬프트 히스토리 (Prompt History)
- [x] 39_luck_system.md
- [x] 40_ui_update_summary.md
- [x] 41_refactoring_presets.md
- [x] 42_item_rarity_and_tier.md
- [x] 43_shop_system.md
- [x] 44_settings_and_local_storage.md
- [x] 45_config_refactoring.md - [x] 45_config_refactoring.md
- [x] 46_shop_refactoring.md
- [x] 47_inventory_full_handling.md
- [x] 48_refactor_stage_ui.md
- [x] 49_implement_item_icons.md

View File

@ -0,0 +1,30 @@
# 46. 상점 시스템 리팩토링 및 예외 처리 (Shop System Refactoring & Error Handling)
## 1. 목표 (Goal)
- `BattleProvider`에 집중된 상점 관련 로직을 `ShopProvider`로 분리하여 **관심사의 분리(Separation of Concerns)**를 실현합니다.
- 아이템 획득(보상/구매) 시 인벤토리 가득 참이나 골드 부족 등의 예외 상황에 대해 명확한 에러 메시지(UI 피드백)를 제공합니다.
## 2. 구현 상세 (Implementation Details)
### A. 상점 로직 분리 (`ShopProvider`)
- **파일:** `lib/providers/shop_provider.dart` 생성.
- **이동된 기능:**
- `generateShopItems`: 스테이지 티어에 따른 상점 아이템 목록 생성.
- `rerollShopItems`: 골드 소모 후 아이템 목록 갱신.
- `buyItem`: 골드 차감 및 인벤토리 추가 로직.
- **구조 변경:**
- `BattleProvider`는 더 이상 `BuildContext`를 직접 참조하거나 상점 상태를 관리하지 않습니다.
- `main.dart`에서 `ChangeNotifierProxyProvider`를 사용하여 `ShopProvider``BattleProvider`에 주입(Injection)합니다.
### B. 예외 처리 및 UI 피드백
- **반환값 변경 (`bool`):**
- `BattleProvider.selectReward`: 인벤토리 가득 찰 시 `false` 반환.
- `ShopProvider.buyItem`: 골드 부족 또는 인벤토리 가득 찰 시 `false` 반환.
- **UI 반영 (`BattleScreen`, `ShopUI`):**
- 메서드가 `false`를 반환할 경우 `ScaffoldMessenger`를 통해 붉은색 `SnackBar`로 에러 메시지를 출력합니다.
- 예: "Inventory is full! Cannot take item.", "Purchase failed! Check inventory or gold."
## 3. 결과 (Result)
- **코드 품질:** 거대해지던 `BattleProvider`의 책임을 분산시켜 유지보수성을 높였습니다.
- **안정성:** `BattleProvider``BuildContext` 의존성을 제거하여 잠재적인 컨텍스트 관련 오류를 해결했습니다.
- **사용자 경험:** 아이템 획득 실패 시 명확한 피드백을 제공하여 답답함을 해소했습니다.

View File

@ -0,0 +1,21 @@
# 47. 인벤토리 가득 참 처리 (Inventory Full Handling)
## 1. 목표 (Goal)
- 인벤토리가 가득 찬 상태(`maxInventorySize`)에서 보상 아이템 획득을 시도할 경우, 게임이 진행되지 않고 에러 메시지를 표시합니다.
- 보상 팝업이 닫히지 않도록 하여 사용자가 다른 행동(스킵 또는 인벤토리 관리)을 할 수 있게 합니다.
## 2. 구현 상세 (Implementation Details)
### `BattleProvider` 수정
- **`selectReward` 메서드 반환값 변경:** `void` -> `bool`.
- **성공 (아이템 획득 또는 스킵):** `true` 반환. 스테이지 클리어 로직 진행.
- **실패 (인벤토리 가득 참):** `false` 반환. 스테이지 클리어 로직 중단. 로그만 남김.
### `BattleScreen` 수정
- **보상 선택 로직:**
- `battleProvider.selectReward(item)`의 반환값을 확인.
- `false`일 경우 `ScaffoldMessenger`를 사용하여 "Inventory is full! Cannot take item." 스낵바 출력.
## 3. 결과 (Result)
- 인벤토리가 가득 찼을 때 실수로 아이템이 버려지거나 다음 스테이지로 강제 진행되는 문제를 방지했습니다.
- 사용자에게 명확한 피드백(에러 메시지)을 제공합니다.

View File

@ -0,0 +1,21 @@
# 48. Stage UI 구조 개선 (Refactor Stage UI Structure)
## 1. 목표 (Goal)
- `lib/widgets/battle/stage_ui.dart`에 혼재되어 있던 `ShopUI``RestUI`를 분리하여 `lib/widgets/stage/` 폴더 내의 독립적인 파일로 관리합니다.
- 코드의 가독성과 모듈화를 향상시킵니다.
## 2. 구현 상세 (Implementation Details)
### 폴더 및 파일 생성
- **폴더:** `lib/widgets/stage/`
- **파일 분리:**
- `lib/widgets/stage/shop_ui.dart`: 상점 UI 관련 코드 이동.
- `lib/widgets/stage/rest_ui.dart`: 휴식 UI 관련 코드 이동.
### 코드 수정 (Code Updates)
- **기존 파일 삭제:** `lib/widgets/battle/stage_ui.dart` 삭제.
- **참조 수정:** `BattleScreen` 등에서 `stage_ui.dart`를 참조하던 부분을 새로운 경로(`shop_ui.dart`, `rest_ui.dart`)로 업데이트.
## 3. 결과 (Result)
- 상점과 휴식 스테이지 UI가 물리적으로 분리되어 관리가 용이해졌습니다.
- 프로젝트의 위젯 구조가 기능별로 더욱 명확하게 정리되었습니다.

View File

@ -0,0 +1,31 @@
# 52. 아이템 아이콘 이미지 적용 (Item Icon Image Implementation)
## 1. 목표 (Goal)
- 기존의 머티리얼 아이콘(`IconData`) 대신 `assets/data/icon/`에 추가된 PNG 이미지 아이콘을 UI에 적용합니다.
- `ItemUtils`를 수정하여 아이콘 경로를 반환하도록 변경하고, 주요 UI 화면(`BattleScreen`, `InventoryScreen`, `ShopUI`)에서 `Image.asset`을 사용하도록 리팩토링합니다.
## 2. 구현 상세 (Implementation Details)
### 에셋 등록
- `pubspec.yaml``assets/data/icon/` 경로 추가.
### `ItemUtils` 수정
- `getIcon(EquipmentSlot)` 제거 (또는 사용처 변경).
- `getIconPath(EquipmentSlot)` 메서드 추가: 장비 슬롯별 이미지 파일 경로 반환.
- Weapon -> `icon_weapon.png`
- Shield -> `icon_shield.png`
- Armor -> `icon_armor.png`
- Accessory -> `icon_accessory.png`
### UI 수정 (Icon -> Image.asset)
- **`ShopUI` (`lib/widgets/stage/shop_ui.dart`):** 상점 아이템 카드의 아이콘 교체.
- **`InventoryScreen` (`lib/screens/inventory_screen.dart`):**
- 착용 중인 아이템 슬롯의 아이콘 교체.
- 인벤토리 그리드 내 아이템 아이콘 교체.
- **`BattleScreen` (`lib/screens/battle_screen.dart`):**
- 스테이지 클리어 보상 팝업의 아이템 아이콘 교체.
- **플로팅 액션 버튼(FAB):** ATK/DEF 버튼의 아이콘을 `icon_weapon.png``icon_shield.png`로 교체하고 흰색 틴트 적용.
## 3. 결과 (Result)
- 게임 내 아이템 표현이 단순 기호에서 구체적인 이미지로 변경되어 시각적 몰입도가 향상되었습니다.
- `ItemUtils`를 통해 아이콘 자원 관리가 중앙화되었습니다.

View File

@ -62,6 +62,7 @@ flutter:
# To add assets to your application, add an assets section, like this: # To add assets to your application, add an assets section, like this:
assets: assets:
- assets/data/ - assets/data/
- assets/data/icon/
# An image asset can refer to one or more resolution-specific "variants", see # An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images # https://flutter.dev/to/resolution-aware-images

View File

@ -1,5 +1,8 @@
import 'package:flutter/material.dart'; // For BuildContext in testWidgets
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart'; // For MultiProvider and ChangeNotifierProvider
import 'package:game_test/providers/battle_provider.dart'; import 'package:game_test/providers/battle_provider.dart';
import 'package:game_test/providers/shop_provider.dart'; // Required for BattleProvider's context
import 'package:game_test/game/data/enemy_table.dart'; import 'package:game_test/game/data/enemy_table.dart';
import 'package:game_test/game/data/item_table.dart'; import 'package:game_test/game/data/item_table.dart';
import 'package:game_test/game/enums.dart'; import 'package:game_test/game/enums.dart';
@ -14,29 +17,61 @@ void main() {
await EnemyTable.load(); await EnemyTable.load();
}); });
test('Enemy generates intent on spawn', () { // Helper widget to provide the necessary providers in the widget tree
final provider = BattleProvider(); Widget createTestApp() {
provider.initializeBattle(); return MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => ShopProvider()),
ChangeNotifierProxyProvider<ShopProvider, BattleProvider>(
create: (context) => BattleProvider(
shopProvider: Provider.of<ShopProvider>(context, listen: false),
),
update: (context, shopProvider, battleProvider) =>
battleProvider ?? BattleProvider(shopProvider: shopProvider),
),
],
child: const MaterialApp(
home: Scaffold(
body: Text('Test App'),
),
),
);
}
testWidgets('Enemy generates intent on spawn', (WidgetTester tester) async {
await tester.pumpWidget(createTestApp());
await tester.pumpAndSettle(); // Ensure providers are built and available
// Retrieve the BattleProvider instance from the context of the widget tree
final battleProvider = Provider.of<BattleProvider>(
tester.element(find.byType(MaterialApp)),
listen: false,
);
battleProvider.initializeBattle();
await tester.pumpAndSettle(); // Allow async operations in initializeBattle to complete
// Should have an enemy and an intent // Should have an enemy and an intent
expect(provider.enemy, isNotNull); expect(battleProvider.enemy, isNotNull);
expect(provider.currentEnemyIntent, isNotNull); expect(battleProvider.currentEnemyIntent, isNotNull);
print('Initial Intent: ${provider.currentEnemyIntent!.description}'); print('Initial Intent: ${battleProvider.currentEnemyIntent!.description}');
}); });
test('Enemy executes intent and generates new one', () async { testWidgets('Enemy executes intent and generates new one', (WidgetTester tester) async {
final provider = BattleProvider(); await tester.pumpWidget(createTestApp());
provider.initializeBattle(); await tester.pumpAndSettle(); // Ensure providers are built and available
// Force player turn to end to trigger enemy turn // Retrieve the BattleProvider instance from the context of the widget tree
// We can't easily call private methods, but we can simulate flow or check state final battleProvider = Provider.of<BattleProvider>(
// BattleProvider logic is tightly coupled with async delays in _enemyTurn, tester.element(find.byType(MaterialApp)),
// so unit testing the exact flow is tricky without mocking. listen: false,
// Instead, we will test the public state changes if possible or just rely on the fact that );
// initializeBattle calls _prepareNextStage which calls _generateEnemyIntent.
battleProvider.initializeBattle();
await tester.pumpAndSettle();
// Let's verify the intent structure // Let's verify the intent structure
final intent = provider.currentEnemyIntent!; final intent = battleProvider.currentEnemyIntent!;
expect(intent.value, greaterThan(0)); expect(intent.value, greaterThan(0));
expect(intent.type, anyOf(EnemyActionType.attack, EnemyActionType.defend)); expect(intent.type, anyOf(EnemyActionType.attack, EnemyActionType.defend));
expect( expect(