From d3fca333cbe5afc41c4074a9046ce2455e851275 Mon Sep 17 00:00:00 2001 From: Horoli Date: Sun, 7 Dec 2025 13:44:51 +0900 Subject: [PATCH] update --- assets/data/items.json | 74 ++++-- lib/game/config/item_config.dart | 13 + lib/game/config/theme_config.dart | 6 + lib/game/data/enemy_table.dart | 17 +- lib/game/data/item_prefix_table.dart | 101 ++++++++ lib/game/data/item_table.dart | 153 ++++++++++- lib/game/data/name_generator.dart | 68 +++++ lib/game/enums.dart | 6 +- lib/game/model/entity.dart | 72 ++++++ lib/game/model/item.dart | 4 + lib/game/model/status_effect.dart | 16 ++ lib/game/save_manager.dart | 46 ++++ lib/providers/battle_provider.dart | 143 +++++++++-- lib/screens/battle_screen.dart | 66 +++-- lib/screens/inventory_screen.dart | 31 ++- lib/screens/main_menu_screen.dart | 163 ++++++++---- lib/screens/main_wrapper.dart | 11 +- lib/screens/settings_screen.dart | 111 ++++++++ lib/utils/item_utils.dart | 14 ++ lib/widgets/battle/stage_ui.dart | 237 +++++++++++++++++- macos/Flutter/GeneratedPluginRegistrant.swift | 2 + prompt/00_project_context_restore.md | 16 +- prompt/42_item_rarity_and_tier.md | 52 ++++ prompt/43_shop_system.md | 31 +++ prompt/44_settings_and_local_storage.md | 40 +++ pubspec.yaml | 1 + test/enemy_intent_test.dart | 2 + test/item_random_test.dart | 49 ++++ test/item_rarity_tier_test.dart | 29 +++ 29 files changed, 1437 insertions(+), 137 deletions(-) create mode 100644 lib/game/config/item_config.dart create mode 100644 lib/game/data/item_prefix_table.dart create mode 100644 lib/game/data/name_generator.dart create mode 100644 lib/game/save_manager.dart create mode 100644 lib/screens/settings_screen.dart create mode 100644 prompt/42_item_rarity_and_tier.md create mode 100644 prompt/43_shop_system.md create mode 100644 prompt/44_settings_and_local_storage.md create mode 100644 test/item_random_test.dart create mode 100644 test/item_rarity_tier_test.dart diff --git a/assets/data/items.json b/assets/data/items.json index 7a6a0cb..6f131ee 100644 --- a/assets/data/items.json +++ b/assets/data/items.json @@ -7,7 +7,9 @@ "baseAtk": 3, "slot": "weapon", "price": 30, - "image": "assets/images/items/rusty_dagger.png" + "image": "assets/images/items/rusty_dagger.png", + "rarity": "magic", + "tier": "tier1" }, { "id": "iron_sword", @@ -16,7 +18,9 @@ "baseAtk": 8, "slot": "weapon", "price": 80, - "image": "assets/images/items/iron_sword.png" + "image": "assets/images/items/iron_sword.png", + "rarity": "magic", + "tier": "tier2" }, { "id": "battle_axe", @@ -25,7 +29,9 @@ "baseAtk": 12, "slot": "weapon", "price": 120, - "image": "assets/images/items/battle_axe.png" + "image": "assets/images/items/battle_axe.png", + "rarity": "magic", + "tier": "tier2" }, { "id": "stunning_hammer", @@ -41,7 +47,9 @@ "probability": 20, "duration": 1 } - ] + ], + "rarity": "rare", + "tier": "tier2" }, { "id": "jagged_dagger", @@ -58,7 +66,9 @@ "duration": 3, "value": 30 } - ] + ], + "rarity": "rare", + "tier": "tier1" }, { "id": "sunderer_axe", @@ -74,7 +84,9 @@ "probability": 100, "duration": 2 } - ] + ], + "rarity": "legendary", + "tier": "tier3" } ], "armors": [ @@ -85,7 +97,9 @@ "baseHp": 10, "slot": "armor", "price": 20, - "image": "assets/images/items/torn_tunic.png" + "image": "assets/images/items/torn_tunic.png", + "rarity": "magic", + "tier": "tier1" }, { "id": "leather_vest", @@ -94,7 +108,9 @@ "baseHp": 30, "slot": "armor", "price": 60, - "image": "assets/images/items/leather_vest.png" + "image": "assets/images/items/leather_vest.png", + "rarity": "magic", + "tier": "tier2" }, { "id": "chainmail", @@ -103,7 +119,9 @@ "baseHp": 60, "slot": "armor", "price": 120, - "image": "assets/images/items/chainmail.png" + "image": "assets/images/items/chainmail.png", + "rarity": "magic", + "tier": "tier3" } ], "shields": [ @@ -114,7 +132,9 @@ "baseArmor": 1, "slot": "shield", "price": 10, - "image": "assets/images/items/pot_lid.png" + "image": "assets/images/items/pot_lid.png", + "rarity": "magic", + "tier": "tier1" }, { "id": "wooden_shield", @@ -123,7 +143,9 @@ "baseArmor": 3, "slot": "shield", "price": 40, - "image": "assets/images/items/wooden_shield.png" + "image": "assets/images/items/wooden_shield.png", + "rarity": "magic", + "tier": "tier1" }, { "id": "kite_shield", @@ -132,7 +154,9 @@ "baseArmor": 6, "slot": "shield", "price": 100, - "image": "assets/images/items/kite_shield.png" + "image": "assets/images/items/kite_shield.png", + "rarity": "magic", + "tier": "tier2" }, { "id": "cursed_shield", @@ -148,7 +172,9 @@ "probability": 100, "duration": 999 } - ] + ], + "rarity": "legendary", + "tier": "tier2" } ], "accessories": [ @@ -161,7 +187,9 @@ "slot": "accessory", "price": 25, "image": "assets/images/items/old_ring.png", - "luck": 5 + "luck": 5, + "rarity": "magic", + "tier": "tier1" }, { "id": "copper_ring", @@ -172,7 +200,9 @@ "slot": "accessory", "price": 25, "image": "assets/images/items/copper_ring.png", - "luck": 3 + "luck": 3, + "rarity": "magic", + "tier": "tier1" }, { "id": "ruby_amulet", @@ -183,7 +213,9 @@ "slot": "accessory", "price": 80, "image": "assets/images/items/ruby_amulet.png", - "luck": 7 + "luck": 7, + "rarity": "rare", + "tier": "tier2" }, { "id": "heros_badge", @@ -195,7 +227,9 @@ "slot": "accessory", "price": 150, "image": "assets/images/items/heros_badge.png", - "luck": 10 + "luck": 10, + "rarity": "legendary", + "tier": "tier3" }, { "id": "lucky_charm", @@ -206,7 +240,9 @@ "slot": "accessory", "price": 200, "image": "assets/images/items/lucky_charm.png", - "luck": 25 + "luck": 25, + "rarity": "unique", + "tier": "tier3" } ] -} +} \ No newline at end of file diff --git a/lib/game/config/item_config.dart b/lib/game/config/item_config.dart new file mode 100644 index 0000000..954090e --- /dev/null +++ b/lib/game/config/item_config.dart @@ -0,0 +1,13 @@ +import '../enums.dart'; + +class ItemConfig { + /// Default weights for item rarity generation. + /// Used when selecting random items in Shop or Rewards. + /// Higher weight = Higher chance. + static const Map defaultRarityWeights = { + ItemRarity.magic: 60, + ItemRarity.rare: 30, + ItemRarity.legendary: 9, + ItemRarity.unique: 1, + }; +} diff --git a/lib/game/config/theme_config.dart b/lib/game/config/theme_config.dart index e27cb47..7b8a25c 100644 --- a/lib/game/config/theme_config.dart +++ b/lib/game/config/theme_config.dart @@ -33,4 +33,10 @@ class ThemeConfig { // Status Effect Colors static const Color effectBg = Colors.deepOrange; static const Color effectText = Colors.white; + + // Rarity Colors + static const Color rarityMagic = Colors.blueAccent; + static const Color rarityRare = Colors.yellow; + static const Color rarityLegendary = Colors.orange; + static const Color rarityUnique = Colors.purple; } diff --git a/lib/game/data/enemy_table.dart b/lib/game/data/enemy_table.dart index 2189338..7ea8068 100644 --- a/lib/game/data/enemy_table.dart +++ b/lib/game/data/enemy_table.dart @@ -33,16 +33,14 @@ class EnemyTemplate { } Character createCharacter({int stage = 1}) { - // Simple additive scaling - int scaledHp = baseHp + (stage - 1) * 5; - int scaledAtk = baseAtk + (stage - 1); - int scaledDefense = baseDefense + (stage ~/ 5); // +1 defense every 5 stages + // Stage-based scaling for enemy stats is removed to simplify balancing. + // Enemy stats are now fixed as defined in the EnemyTemplate. final character = Character( name: name, - maxHp: scaledHp, - atk: scaledAtk, - baseDefense: scaledDefense, + maxHp: baseHp, + atk: baseAtk, + baseDefense: baseDefense, armor: 0, image: image, ); @@ -51,9 +49,8 @@ class EnemyTemplate { for (final itemId in equipmentIds) { final itemTemplate = ItemTable.get(itemId); if (itemTemplate != null) { - // Create item scaled to stage (optional, currently stage 1) - // Enemies might get stronger items at higher stages - final item = itemTemplate.createItem(stage: stage); + // Items no longer scale by stage, pass no stage parameter + final item = itemTemplate.createItem(); character.addToInventory(item); character.equip(item); } diff --git a/lib/game/data/item_prefix_table.dart b/lib/game/data/item_prefix_table.dart new file mode 100644 index 0000000..c1b9c8f --- /dev/null +++ b/lib/game/data/item_prefix_table.dart @@ -0,0 +1,101 @@ +import '../enums.dart'; + +class ItemModifier { + final String prefix; + final Map statChanges; + final List? allowedSlots; // Null means allowed for all slots + + const ItemModifier({ + required this.prefix, + required this.statChanges, + this.allowedSlots, + }); +} + +class ItemPrefixTable { + static const List magicPrefixes = [ + // Weapons + ItemModifier( + prefix: "Sharp", + statChanges: {StatType.atk: 2}, + allowedSlots: [EquipmentSlot.weapon, EquipmentSlot.accessory] + ), + ItemModifier( + prefix: "Heavy", + statChanges: {StatType.atk: 3}, + allowedSlots: [EquipmentSlot.weapon] + ), + ItemModifier( + prefix: "Swift", + statChanges: {StatType.atk: 1}, + allowedSlots: [EquipmentSlot.weapon, EquipmentSlot.accessory] + ), + + // Armor / Shield + ItemModifier( + prefix: "Sturdy", + statChanges: {StatType.maxHp: 10}, + allowedSlots: [EquipmentSlot.armor, EquipmentSlot.shield, EquipmentSlot.accessory] + ), + ItemModifier( + prefix: "Hard", + statChanges: {StatType.defense: 1}, + allowedSlots: [EquipmentSlot.armor, EquipmentSlot.shield] + ), + + // General / Accessory + ItemModifier( + prefix: "Lucky", + statChanges: {StatType.luck: 5}, + allowedSlots: null // All slots (e.g. Lucky Sword is fine) + ), + ]; + + static const List rarePrefixes = [ + // Offensive (Weapons/Accessories) + ItemModifier( + prefix: "Deadly", + statChanges: {StatType.atk: 5, StatType.luck: 5}, + allowedSlots: [EquipmentSlot.weapon, EquipmentSlot.accessory] + ), + ItemModifier( + prefix: "Vicious", + statChanges: {StatType.atk: 6, StatType.maxHp: -5}, + allowedSlots: [EquipmentSlot.weapon] + ), + ItemModifier( + prefix: "Brutal", + statChanges: {StatType.atk: 8}, + allowedSlots: [EquipmentSlot.weapon] + ), + + // Defensive (Armor/Shields/Accessories) + ItemModifier( + prefix: "Guardian's", + statChanges: {StatType.defense: 3, StatType.maxHp: 20}, + allowedSlots: [EquipmentSlot.armor, EquipmentSlot.shield, EquipmentSlot.accessory] + ), + ItemModifier( + prefix: "Ancient", + statChanges: {StatType.defense: 2, StatType.maxHp: 15}, + allowedSlots: [EquipmentSlot.armor, EquipmentSlot.shield] + ), + ItemModifier( + prefix: "Divine", + statChanges: {StatType.defense: 2, StatType.maxHp: 30, StatType.luck: 5}, + allowedSlots: [EquipmentSlot.armor, EquipmentSlot.shield, EquipmentSlot.accessory] + ), + + // Versatile + ItemModifier( + prefix: "Heroic", + statChanges: {StatType.atk: 2, StatType.defense: 2, StatType.maxHp: 10}, + allowedSlots: null + ), + ]; + + // Special names for Rare/Unique replacements (Optional usage) + static const List rareNames = [ + "Earthshatter", "Soulrender", "Widowmaker", "Lightbringer" + ]; +} diff --git a/lib/game/data/item_table.dart b/lib/game/data/item_table.dart index 8c36ae3..d476ed7 100644 --- a/lib/game/data/item_table.dart +++ b/lib/game/data/item_table.dart @@ -1,7 +1,11 @@ import 'dart:convert'; +import 'dart:math'; import 'package:flutter/services.dart'; import '../model/item.dart'; import '../enums.dart'; +import '../config/item_config.dart'; +import 'item_prefix_table.dart'; // Import prefix table +import 'name_generator.dart'; // Import name generator class ItemTemplate { final String id; @@ -15,6 +19,8 @@ class ItemTemplate { final int price; final String? image; final int luck; + final ItemRarity rarity; + final ItemTier tier; const ItemTemplate({ required this.id, @@ -28,6 +34,8 @@ class ItemTemplate { required this.price, this.image, this.luck = 0, + this.rarity = ItemRarity.magic, + this.tier = ItemTier.tier1, }); factory ItemTemplate.fromJson(Map json) { @@ -50,27 +58,99 @@ class ItemTemplate { price: json['price'] ?? 10, image: json['image'], luck: json['luck'] ?? 0, + rarity: json['rarity'] != null + ? ItemRarity.values.firstWhere((e) => e.name == json['rarity']) + : ItemRarity.magic, + tier: json['tier'] != null + ? ItemTier.values.firstWhere((e) => e.name == json['tier']) + : ItemTier.tier1, ); } Item createItem({int stage = 1}) { - // Scale stats based on stage - int scaledAtk = (atkBonus * (1 + (stage - 1) * 0.1)).toInt(); - int scaledHp = (hpBonus * (1 + (stage - 1) * 0.1)).toInt(); - int scaledArmor = (armorBonus * (1 + (stage - 1) * 0.1)).toInt(); + // Stage-based scaling is removed. + // Apply Prefix Logic based on Rarity. + + String finalName = name; + int finalAtk = atkBonus; + int finalHp = hpBonus; + int finalArmor = armorBonus; + int finalLuck = luck; + + final random = Random(); + + // 1. Magic Rarity: 50% chance to get a Magic Prefix (1 stat change) + if (rarity == ItemRarity.magic) { + if (random.nextBool()) { // 50% chance + // Filter valid prefixes for this slot + final validPrefixes = ItemPrefixTable.magicPrefixes.where((p) { + return p.allowedSlots == null || p.allowedSlots!.contains(slot); + }).toList(); + + if (validPrefixes.isNotEmpty) { + final modifier = validPrefixes[random.nextInt(validPrefixes.length)]; + finalName = "${modifier.prefix} $name"; + + modifier.statChanges.forEach((stat, value) { + switch(stat) { + case StatType.atk: finalAtk += value; break; + case StatType.maxHp: finalHp += value; break; + case StatType.defense: finalArmor += value; break; + case StatType.luck: finalLuck += value; break; + } + }); + } + } + } + // 2. Rare Rarity: Always get a Rare Prefix (2 stat changes or stronger) + else if (rarity == ItemRarity.rare) { + bool nameChanged = false; + + // Always generate a completely new cool name for Rare items + finalName = NameGenerator.generateName(slot); + nameChanged = true; + + // Filter valid prefixes for this slot + final validPrefixes = ItemPrefixTable.rarePrefixes.where((p) { + return p.allowedSlots == null || p.allowedSlots!.contains(slot); + }).toList(); + + if (validPrefixes.isNotEmpty) { + final modifier = validPrefixes[random.nextInt(validPrefixes.length)]; + + // If name wasn't already changed by NameGenerator, apply prefix to name + if (!nameChanged) { + finalName = "${modifier.prefix} $name"; + } + // Even if name changed, we STILL apply the stats from the prefix modifier! + // Because NameGenerator is just visual flavor, stats come from the modifier. + + modifier.statChanges.forEach((stat, value) { + switch(stat) { + case StatType.atk: finalAtk += value; break; + case StatType.maxHp: finalHp += value; break; + case StatType.defense: finalArmor += value; break; + case StatType.luck: finalLuck += value; break; + } + }); + } + } + // Legendary/Unique items usually keep their original names/stats as they are special. return Item( id: id, - name: "$name${stage > 1 ? ' +${stage - 1}' : ''}", + name: finalName, description: description, - atkBonus: scaledAtk, - hpBonus: scaledHp, - armorBonus: scaledArmor, + atkBonus: finalAtk, + hpBonus: finalHp, + armorBonus: finalArmor, slot: slot, effects: effects, price: price, image: image, - luck: luck, + luck: finalLuck, + rarity: rarity, + tier: tier, ); } } @@ -115,4 +195,59 @@ class ItemTable { return null; } } + + static final Random _random = Random(); + + /// Returns all items matching the given tier. + static List getItemsByTier(ItemTier tier) { + return allItems.where((item) => item.tier == tier).toList(); + } + + /// Returns a random item based on Tier and Rarity weights. + /// + /// [tier]: The tier of items to select from. + /// [slot]: Optional. If provided, only items of this slot are considered. + /// [weights]: Optional map of rarity weights. Key: Rarity, Value: Weight. + /// Default weights: Common: 60, Rare: 30, Epic: 9, Legendary: 1. + static ItemTemplate? getRandomItem({ + required ItemTier tier, + EquipmentSlot? slot, + Map? weights, + }) { + // 1. Filter by Tier and Slot (if provided) + var candidates = allItems.where((item) => item.tier == tier); + if (slot != null) { + candidates = candidates.where((item) => item.slot == slot); + } + + if (candidates.isEmpty) return null; + + // 2. Determine Target Rarity based on weights + final rarityWeights = weights ?? ItemConfig.defaultRarityWeights; + + int totalWeight = rarityWeights.values.fold(0, (sum, w) => sum + w); + int roll = _random.nextInt(totalWeight); + + ItemRarity? selectedRarity; + int currentSum = 0; + + for (var entry in rarityWeights.entries) { + currentSum += entry.value; + if (roll < currentSum) { + selectedRarity = entry.key; + break; + } + } + + // 3. Filter candidates by Selected Rarity + var rarityCandidates = candidates.where((item) => item.rarity == selectedRarity).toList(); + + // 4. Fallback: If no items of selected rarity, use any item from the filtered candidates + if (rarityCandidates.isEmpty) { + return candidates.toList()[_random.nextInt(candidates.length)]; + } + + // 5. Pick random item + return rarityCandidates[_random.nextInt(rarityCandidates.length)]; + } } diff --git a/lib/game/data/name_generator.dart b/lib/game/data/name_generator.dart new file mode 100644 index 0000000..ecb01f2 --- /dev/null +++ b/lib/game/data/name_generator.dart @@ -0,0 +1,68 @@ +import 'dart:math'; +import '../enums.dart'; + +class NameGenerator { + static final Random _random = Random(); + + // Adjectives suitable for powerful items + static const List _adjectives = [ + "Crimson", "Shadow", "Azure", "Burning", "Frozen", "Ancient", + "Cursed", "Blessed", "Savage", "Eternal", "Dark", "Holy", + "Storm", "Void", "Crystal", "Iron", "Blood", "Night" + ]; + + // Nouns specifically for Weapons + static const List _weaponNouns = [ + "Fang", "Claw", "Reaper", "Breaker", "Slayer", "Edge", + "Blade", "Spike", "Crusher", "Whisper", "Howl", "Strike", + "Bane", "Fury", "Vengeance", "Thorn" + ]; + + // Nouns specifically for Armor + static const List _armorNouns = [ + "Guard", "Wall", "Shelter", "Skin", "Scale", "Plate", + "Bulwark", "Veil", "Shroud", "Ward", "Barrier", "Bastion", + "Mantle", "Aegis", "Carapace" + ]; + + // Nouns specifically for Shields + static const List _shieldNouns = [ + "Wall", "Barrier", "Aegis", "Defender", "Blockade", + "Resolve", "Sanctuary", "Buckler", "Tower", "Gate" + ]; + + // Nouns specifically for Accessories + static const List _accessoryNouns = [ + "Heart", "Soul", "Eye", "Tear", "Spark", "Ember", + "Drop", "Mark", "Sign", "Omen", "Wish", "Star" + ]; + + static String generateName(EquipmentSlot slot) { + String adjective = _adjectives[_random.nextInt(_adjectives.length)]; + String noun = ""; + + switch (slot) { + case EquipmentSlot.weapon: + noun = _weaponNouns[_random.nextInt(_weaponNouns.length)]; + break; + case EquipmentSlot.armor: + noun = _armorNouns[_random.nextInt(_armorNouns.length)]; + break; + case EquipmentSlot.shield: + noun = _shieldNouns[_random.nextInt(_shieldNouns.length)]; + break; + case EquipmentSlot.accessory: + noun = _accessoryNouns[_random.nextInt(_accessoryNouns.length)]; + break; + } + + // 20% Chance for "Noun of Noun" format (e.g. "Fang of Shadow") + if (_random.nextDouble() < 0.2) { + String suffixNoun = _adjectives[_random.nextInt(_adjectives.length)]; // Reuse adjectives as nouns sometimes works (e.g. "of Blood") + // Better: use a subset of adjectives that work as nouns or generic nouns + return "$noun of $suffixNoun"; + } + + return "$adjective $noun"; + } +} diff --git a/lib/game/enums.dart b/lib/game/enums.dart index 0125ba0..c34ee3a 100644 --- a/lib/game/enums.dart +++ b/lib/game/enums.dart @@ -33,4 +33,8 @@ enum EquipmentSlot { weapon, armor, shield, accessory } enum DamageType { normal, bleed, vulnerable } -enum StatType { maxHp, atk, defense } +enum StatType { maxHp, atk, defense, luck } + +enum ItemRarity { magic, rare, legendary, unique } + +enum ItemTier { tier1, tier2, tier3 } diff --git a/lib/game/model/entity.dart b/lib/game/model/entity.dart index 3cf9efe..deb1728 100644 --- a/lib/game/model/entity.dart +++ b/lib/game/model/entity.dart @@ -2,6 +2,7 @@ import 'item.dart'; import 'status_effect.dart'; import 'stat_modifier.dart'; import '../enums.dart'; +import '../data/item_table.dart'; class Character { String name; @@ -36,6 +37,77 @@ class Character { baseAtk = atk, hp = hp ?? maxHp; + Map toJson() { + return { + 'name': name, + 'hp': hp, + 'baseMaxHp': baseMaxHp, + 'armor': armor, + 'baseAtk': baseAtk, + 'baseDefense': baseDefense, + 'gold': gold, + 'image': image, + 'equipment': equipment.map( + (key, value) => MapEntry(key.name, value.id), + ), + 'inventory': inventory.map((e) => e.id).toList(), + 'statusEffects': statusEffects.map((e) => e.toJson()).toList(), + 'permanentModifiers': permanentModifiers.map((e) => e.toJson()).toList(), + }; + } + + factory Character.fromJson(Map json) { + final char = Character( + name: json['name'], + hp: json['hp'], + maxHp: json['baseMaxHp'], + armor: json['armor'], + atk: json['baseAtk'], + baseDefense: json['baseDefense'], + gold: json['gold'], + image: json['image'], + ); + + // Restore Equipment + final equipMap = json['equipment'] as Map; + equipMap.forEach((slotName, itemId) { + final template = ItemTable.get(itemId); + if (template != null) { + // Find slot enum + final slot = EquipmentSlot.values.firstWhere( + (e) => e.name == slotName, + orElse: () => EquipmentSlot.weapon, // Fallback + ); + char.equipment[slot] = template.createItem(); + } + }); + + // Restore Inventory + final invList = json['inventory'] as List; + for (var itemId in invList) { + final template = ItemTable.get(itemId); + if (template != null) { + char.inventory.add(template.createItem()); + } + } + + // Restore Status Effects + if (json['statusEffects'] != null) { + char.statusEffects = (json['statusEffects'] as List) + .map((e) => StatusEffect.fromJson(e)) + .toList(); + } + + // Restore Permanent Modifiers + if (json['permanentModifiers'] != null) { + char.permanentModifiers = (json['permanentModifiers'] as List) + .map((e) => PermanentStatModifier.fromJson(e)) + .toList(); + } + + return char; + } + /// Adds a status effect. If it already exists, it refreshes duration or stacks based on logic. /// For now, we'll implement a simple refresh/overwrite logic. void addStatusEffect(StatusEffect newEffect) { diff --git a/lib/game/model/item.dart b/lib/game/model/item.dart index bf20229..6100ac1 100644 --- a/lib/game/model/item.dart +++ b/lib/game/model/item.dart @@ -47,6 +47,8 @@ class Item { final int price; // New: Sell/Buy value final String? image; // New: Image path final int luck; // Success rate bonus (e.g. 5 = 5%) + final ItemRarity rarity; + final ItemTier tier; const Item({ required this.id, @@ -60,6 +62,8 @@ class Item { this.price = 0, this.image, this.luck = 0, + this.rarity = ItemRarity.magic, + this.tier = ItemTier.tier1, }); String get typeName { diff --git a/lib/game/model/status_effect.dart b/lib/game/model/status_effect.dart index d968837..9f1b913 100644 --- a/lib/game/model/status_effect.dart +++ b/lib/game/model/status_effect.dart @@ -6,4 +6,20 @@ class StatusEffect { final int value; // Intensity (e.g., bleed damage amount) StatusEffect({required this.type, required this.duration, this.value = 0}); + + Map toJson() { + return { + 'type': type.name, + 'duration': duration, + 'value': value, + }; + } + + factory StatusEffect.fromJson(Map json) { + return StatusEffect( + type: StatusEffectType.values.firstWhere((e) => e.name == json['type']), + duration: json['duration'], + value: json['value'], + ); + } } diff --git a/lib/game/save_manager.dart b/lib/game/save_manager.dart new file mode 100644 index 0000000..a7c69b1 --- /dev/null +++ b/lib/game/save_manager.dart @@ -0,0 +1,46 @@ +import 'dart:convert'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../providers/battle_provider.dart'; +import 'model/entity.dart'; + +class SaveManager { + static const String _saveKey = 'game_save_data'; + + static Future saveGame(BattleProvider provider) async { + final prefs = await SharedPreferences.getInstance(); + + final saveData = { + 'stage': provider.stage, + 'turnCount': provider.turnCount, + 'player': provider.player.toJson(), + 'timestamp': DateTime.now().toIso8601String(), + }; + + await prefs.setString(_saveKey, jsonEncode(saveData)); + } + + static Future?> loadGame() async { + final prefs = await SharedPreferences.getInstance(); + if (!prefs.containsKey(_saveKey)) return null; + + final jsonStr = prefs.getString(_saveKey); + if (jsonStr == null) return null; + + try { + return jsonDecode(jsonStr); + } catch (e) { + print("Error loading save data: $e"); + return null; + } + } + + static Future hasSaveData() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.containsKey(_saveKey); + } + + static Future clearSaveData() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_saveKey); + } +} diff --git a/lib/providers/battle_provider.dart b/lib/providers/battle_provider.dart index 536d1a3..5b3a458 100644 --- a/lib/providers/battle_provider.dart +++ b/lib/providers/battle_provider.dart @@ -15,6 +15,8 @@ import '../game/enums.dart'; import '../game/model/damage_event.dart'; // DamageEvent import import '../game/model/effect_event.dart'; // EffectEvent import +import '../game/save_manager.dart'; + class EnemyIntent { final EnemyActionType type; final int value; @@ -47,8 +49,10 @@ class BattleProvider with ChangeNotifier { int turnCount = 1; List rewardOptions = []; bool showRewardPopup = false; + int _lastGoldReward = 0; // New: Stores gold gained from last victory List get logs => battleLogs; + int get lastGoldReward => _lastGoldReward; // Damage Event Stream final _damageEventController = StreamController.broadcast(); @@ -69,6 +73,18 @@ class BattleProvider with ChangeNotifier { super.dispose(); } + void loadFromSave(Map data) { + stage = data['stage']; + turnCount = data['turnCount']; + player = Character.fromJson(data['player']); + + battleLogs.clear(); + _addLog("Game Loaded! Resuming Stage $stage"); + + _prepareNextStage(); + notifyListeners(); + } + void initializeBattle() { stage = 1; turnCount = 1; @@ -87,6 +103,9 @@ class BattleProvider with ChangeNotifier { ); } + // Give test gold + player.gold = 50; + // Provide starter equipment final starterSword = Item( id: "starter_sword", @@ -147,6 +166,9 @@ class BattleProvider with ChangeNotifier { } void _prepareNextStage() { + // Save Game at the start of each stage + SaveManager.saveGame(this); + StageType type; // Stage Type Logic @@ -209,15 +231,7 @@ class BattleProvider with ChangeNotifier { _addLog("Stage $stage ($type) started! A wild ${enemy.name} appeared."); } else if (type == StageType.shop) { // Generate random items for shop - final random = Random(); - List allTemplates = List.from(ItemTable.allItems); - allTemplates.shuffle(random); - - int count = min(4, allTemplates.length); - shopItems = allTemplates - .sublist(0, count) - .map((t) => t.createItem(stage: stage)) - .toList(); + shopItems = _generateShopItems(); // Dummy enemy to prevent null errors in existing UI (until UI is fully updated) enemy = Character(name: "Merchant", maxHp: 9999, armor: 0, atk: 0); @@ -238,6 +252,55 @@ class BattleProvider with ChangeNotifier { notifyListeners(); } + /// Generate 4 random items for the shop based on current stage tier + List _generateShopItems() { + ItemTier currentTier = ItemTier.tier1; + if (stage > 24) + currentTier = ItemTier.tier3; + else if (stage > 12) + currentTier = ItemTier.tier2; + + List 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 = 50; + 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 // void _spawnEnemy() { ... } - Removed @@ -621,28 +684,68 @@ class BattleProvider with ChangeNotifier { } void _onVictory() { - _addLog("Enemy defeated! Choose a reward."); - + // Calculate Gold Reward + // Base 10 + (Stage * 5) + Random variance final random = Random(); + int goldReward = 10 + (stage * 5) + random.nextInt(10); + + player.gold += goldReward; + _lastGoldReward = goldReward; // Store for UI display + _addLog("Enemy defeated! Gained $goldReward Gold."); + _addLog("Choose a reward."); + List allTemplates = List.from(ItemTable.allItems); allTemplates.shuffle(random); // Shuffle to randomize selection - // Take first 3 items (ensure distinct templates if possible, though list is small now) - int count = min(3, allTemplates.length); - rewardOptions = allTemplates.sublist(0, count).map((template) { - return template.createItem(stage: stage); - }).toList(); + // Item Rewards + // Logic: Get random items based on current round tier? For now just random. + // Ideally should use ItemTable.getRandomItem() with Tier logic. + // Let's use our new weighted random logic if available, or fallback to simple shuffle for now to keep it simple. + // Since we just refactored ItemTable, let's use getRandomItem! + + ItemTier currentTier = ItemTier.tier1; + if (stage > 24) + currentTier = ItemTier.tier3; + else if (stage > 12) + currentTier = ItemTier.tier2; + + rewardOptions = []; + // Get 3 distinct items if possible + for (int i = 0; i < 3; i++) { + ItemTemplate? item = ItemTable.getRandomItem(tier: currentTier); + if (item != null) { + rewardOptions.add(item.createItem(stage: stage)); + } + } + + // Add "None" (Skip) Option + // We can represent "None" as a null or a special Item. + // Using a special Item with ID "reward_skip" is safer for List. + rewardOptions.add( + Item( + id: "reward_skip", + name: "Skip Reward", + description: "Take nothing and move on.", + atkBonus: 0, + hpBonus: 0, + slot: EquipmentSlot.accessory, + ), + ); showRewardPopup = true; notifyListeners(); } void selectReward(Item item) { - bool added = player.addToInventory(item); - if (added) { - _addLog("Added ${item.name} to inventory."); + if (item.id == "reward_skip") { + _addLog("Skipped reward."); } else { - _addLog("Inventory is full! ${item.name} discarded."); + bool added = player.addToInventory(item); + if (added) { + _addLog("Added ${item.name} to inventory."); + } else { + _addLog("Inventory is full! ${item.name} discarded."); + } } // Heal player after selecting reward diff --git a/lib/screens/battle_screen.dart b/lib/screens/battle_screen.dart index 5f9d2d3..ae55379 100644 --- a/lib/screens/battle_screen.dart +++ b/lib/screens/battle_screen.dart @@ -538,8 +538,29 @@ class _BattleScreenState extends State { color: Colors.black54, child: Center( child: SimpleDialog( - title: const Text("Victory! Choose a Reward"), + title: Row( + children: [ + const Text("Victory! Choose a Reward"), + const Spacer(), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.monetization_on, color: Colors.amber, size: 18), + const SizedBox(width: 4), + Text( + "${battleProvider.lastGoldReward} G", + style: TextStyle( + color: Colors.amber, + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ], + ), children: battleProvider.rewardOptions.map((item) { + bool isSkip = item.id == "reward_skip"; return SimpleDialogOption( onPressed: () { battleProvider.selectReward(item); @@ -549,30 +570,43 @@ class _BattleScreenState extends State { 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), + if (!isSkip) + Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.blueGrey[700], + borderRadius: BorderRadius.circular( + 4), + border: Border.all( + color: item.rarity != + ItemRarity.magic + ? ItemUtils.getRarityColor( + item.rarity, + ) + : Colors.grey, + ), + ), + child: Icon( + ItemUtils.getIcon(item.slot), + color: ItemUtils.getColor(item.slot), + size: 24, + ), ), - child: Icon( - ItemUtils.getIcon(item.slot), - color: ItemUtils.getColor(item.slot), - size: 24, - ), - ), - const SizedBox(width: 12), + if (!isSkip) const SizedBox(width: 12), Text( item.name, - style: const TextStyle( + style: TextStyle( fontWeight: FontWeight.bold, fontSize: 16, + color: isSkip + ? Colors.grey + : ItemUtils.getRarityColor( + item.rarity), ), ), ], ), - _buildItemStatText(item), + if (!isSkip) _buildItemStatText(item), Text( item.description, style: const TextStyle( diff --git a/lib/screens/inventory_screen.dart b/lib/screens/inventory_screen.dart index 042fc2f..b818e8f 100644 --- a/lib/screens/inventory_screen.dart +++ b/lib/screens/inventory_screen.dart @@ -93,6 +93,17 @@ class InventoryScreen extends StatelessWidget { color: item != null ? Colors.blueGrey[600] : Colors.grey[800], + shape: item != null && + item.rarity != ItemRarity.magic + ? RoundedRectangleBorder( + side: BorderSide( + color: + ItemUtils.getRarityColor(item.rarity), + width: 2.0, + ), + borderRadius: BorderRadius.circular(4.0), + ) + : null, child: Stack( children: [ // Slot Name (Top Right) @@ -143,7 +154,9 @@ class InventoryScreen extends StatelessWidget { fontSize: 11, fontWeight: FontWeight.bold, color: item != null - ? Colors.white + ? ItemUtils.getRarityColor( + item.rarity, + ) : Colors.grey, ), maxLines: 2, @@ -206,6 +219,17 @@ class InventoryScreen extends StatelessWidget { }, child: Card( color: Colors.blueGrey[700], + 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 @@ -233,9 +257,12 @@ class InventoryScreen extends StatelessWidget { child: Text( item.name, textAlign: TextAlign.center, - style: const TextStyle( + style: TextStyle( fontSize: 11, fontWeight: FontWeight.bold, + color: ItemUtils.getRarityColor( + item.rarity, + ), ), maxLines: 2, overflow: TextOverflow.ellipsis, diff --git a/lib/screens/main_menu_screen.dart b/lib/screens/main_menu_screen.dart index 95ffc9f..d45ea44 100644 --- a/lib/screens/main_menu_screen.dart +++ b/lib/screens/main_menu_screen.dart @@ -1,10 +1,54 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import 'character_selection_screen.dart'; +import 'main_wrapper.dart'; import '../widgets/responsive_container.dart'; +import '../game/save_manager.dart'; +import '../providers/battle_provider.dart'; -class MainMenuScreen extends StatelessWidget { +class MainMenuScreen extends StatefulWidget { const MainMenuScreen({super.key}); + @override + State createState() => _MainMenuScreenState(); +} + +class _MainMenuScreenState extends State { + bool _hasSave = false; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _checkSaveData(); + } + + Future _checkSaveData() async { + final hasSave = await SaveManager.hasSaveData(); + if (mounted) { + setState(() { + _hasSave = hasSave; + _isLoading = false; + }); + } + } + + Future _continueGame() async { + final data = await SaveManager.loadGame(); + if (data != null && mounted) { + context.read().loadFromSave(data); + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (context) => const MainWrapper()), + ); + } else { + // Handle load error + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Failed to load save data.')), + ); + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -18,55 +62,78 @@ class MainMenuScreen extends StatelessWidget { ), ), child: ResponsiveContainer( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.gavel, size: 100, color: Colors.amber), - const SizedBox(height: 20), - const Text( - "COLOSSEUM'S CHOICE", - style: TextStyle( - fontSize: 32, - fontWeight: FontWeight.bold, - letterSpacing: 2.0, - color: Colors.white, - ), - ), - const SizedBox(height: 10), - const Text( - "Rise as a Legend", - style: TextStyle( - fontSize: 16, - color: Colors.grey, - fontStyle: FontStyle.italic, - ), - ), - const SizedBox(height: 60), - ElevatedButton( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const CharacterSelectionScreen(), + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.gavel, size: 100, color: Colors.amber), + const SizedBox(height: 20), + const Text( + "COLOSSEUM'S CHOICE", + style: TextStyle( + fontSize: 32, + fontWeight: FontWeight.bold, + letterSpacing: 2.0, + color: Colors.white, + ), ), - ); - }, - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 50, - vertical: 15, - ), - backgroundColor: Colors.amber[700], - foregroundColor: Colors.black, - textStyle: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), + const SizedBox(height: 10), + const Text( + "Rise as a Legend", + style: TextStyle( + fontSize: 16, + color: Colors.grey, + fontStyle: FontStyle.italic, + ), + ), + const SizedBox(height: 60), + if (_hasSave) ...[ + ElevatedButton( + onPressed: _continueGame, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 50, + vertical: 15, + ), + backgroundColor: Colors.blueAccent, + foregroundColor: Colors.white, + textStyle: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + child: const Text("CONTINUE"), + ), + const SizedBox(height: 20), + ], + ElevatedButton( + onPressed: () { + // Warn if save exists? Or just overwrite on save. + // For now, simpler flow. + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const CharacterSelectionScreen(), + ), + ); + }, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 50, + vertical: 15, + ), + backgroundColor: Colors.amber[700], + foregroundColor: Colors.black, + textStyle: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + child: const Text("NEW GAME"), + ), + ], ), - child: const Text("START GAME"), - ), - ], - ), ), ), ); diff --git a/lib/screens/main_wrapper.dart b/lib/screens/main_wrapper.dart index dad326e..d6fe601 100644 --- a/lib/screens/main_wrapper.dart +++ b/lib/screens/main_wrapper.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'battle_screen.dart'; import 'inventory_screen.dart'; +import 'settings_screen.dart'; import '../widgets/responsive_container.dart'; class MainWrapper extends StatefulWidget { @@ -13,7 +14,11 @@ class MainWrapper extends StatefulWidget { class _MainWrapperState extends State { int _currentIndex = 0; - final List _screens = [const BattleScreen(), const InventoryScreen()]; + final List _screens = [ + const BattleScreen(), + const InventoryScreen(), + const SettingsScreen(), + ]; @override Widget build(BuildContext context) { @@ -39,6 +44,10 @@ class _MainWrapperState extends State { icon: Icon(Icons.backpack), label: 'Inventory', ), + BottomNavigationBarItem( + icon: Icon(Icons.settings), + label: 'Settings', + ), ], ), ), diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart new file mode 100644 index 0000000..d4f83ae --- /dev/null +++ b/lib/screens/settings_screen.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/battle_provider.dart'; +import 'main_menu_screen.dart'; + +class SettingsScreen extends StatelessWidget { + const SettingsScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + 'Settings', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + const SizedBox(height: 40), + // Placeholder for future settings + const Text( + 'Effect Intensity: Normal', + style: TextStyle(color: Colors.white70), + ), + const SizedBox(height: 20), + const Text( + 'Volume: 100%', + style: TextStyle(color: Colors.white70), + ), + const SizedBox(height: 40), + + // Restart Button + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.orange, + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12), + ), + onPressed: () { + _showConfirmationDialog( + context, + title: 'Restart Game?', + content: 'All progress will be lost. Are you sure?', + onConfirm: () { + context.read().initializeBattle(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Game Restarted!')), + ); + // Optionally switch tab back to Battle (index 0) + // But MainWrapper controls the index. + // We can't easily switch tab from here without a callback or Provider. + // For now, just restart logic is enough. + }, + ); + }, + child: const Text('Restart Game'), + ), + const SizedBox(height: 20), + + // Return to Main Menu Button + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12), + ), + onPressed: () { + _showConfirmationDialog( + context, + title: 'Return to Main Menu?', + content: 'Unsaved progress may be lost. (Progress is saved automatically after each stage)', + onConfirm: () { + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute(builder: (context) => const MainMenuScreen()), + (route) => false, + ); + }, + ); + }, + child: const Text('Return to Main Menu'), + ), + ], + ), + ); + } + + void _showConfirmationDialog(BuildContext context, {required String title, required String content, required VoidCallback onConfirm}) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(title), + content: Text(content), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + Navigator.pop(context); + onConfirm(); + }, + child: const Text('Confirm', style: TextStyle(color: Colors.red)), + ), + ], + ), + ); + } +} diff --git a/lib/utils/item_utils.dart b/lib/utils/item_utils.dart index d9f8686..852a545 100644 --- a/lib/utils/item_utils.dart +++ b/lib/utils/item_utils.dart @@ -1,7 +1,21 @@ import 'package:flutter/material.dart'; import '../game/enums.dart'; +import '../game/config/theme_config.dart'; class ItemUtils { + static Color getRarityColor(ItemRarity rarity) { + switch (rarity) { + case ItemRarity.magic: + return ThemeConfig.rarityMagic; + case ItemRarity.rare: + return ThemeConfig.rarityRare; + case ItemRarity.legendary: + return ThemeConfig.rarityLegendary; + case ItemRarity.unique: + return ThemeConfig.rarityUnique; + } + } + static IconData getIcon(EquipmentSlot slot) { switch (slot) { case EquipmentSlot.weapon: diff --git a/lib/widgets/battle/stage_ui.dart b/lib/widgets/battle/stage_ui.dart index a2c823f..c41bbe3 100644 --- a/lib/widgets/battle/stage_ui.dart +++ b/lib/widgets/battle/stage_ui.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; import '../../providers/battle_provider.dart'; +import '../../game/model/item.dart'; +import '../../utils/item_utils.dart'; +import '../../game/enums.dart'; class ShopUI extends StatelessWidget { final BattleProvider battleProvider; @@ -8,24 +11,238 @@ class ShopUI extends StatelessWidget { @override Widget build(BuildContext context) { - return Center( + final player = battleProvider.player; + final shopItems = battleProvider.currentStage.shopItems; + + return Container( + color: Colors.black87, + padding: const EdgeInsets.all(16.0), child: Column( - mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.store, size: 64, color: Colors.amber), + // Header: Merchant Icon & Player Gold + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Row( + children: [ + Icon(Icons.store, size: 32, color: Colors.amber), + SizedBox(width: 8), + Text( + "Merchant", + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.white), + ), + ], + ), + Row( + children: [ + const Icon(Icons.monetization_on, color: Colors.amber), + const SizedBox(width: 4), + Text( + "${player.gold} G", + style: const TextStyle( + color: Colors.amber, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ], + ), + const Divider(color: Colors.grey), const SizedBox(height: 16), - const Text("Merchant Shop", style: TextStyle(fontSize: 24)), - const SizedBox(height: 8), - const Text("Buying/Selling feature coming soon!"), - const SizedBox(height: 32), - ElevatedButton( - onPressed: () => battleProvider.proceedToNextStage(), - child: const Text("Leave Shop"), + + // Shop Items Grid + Expanded( + child: shopItems.isEmpty + ? const Center( + child: Text( + "Sold Out", + style: TextStyle(color: Colors.grey, 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: Colors.blueGrey[800], + 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 ? Colors.amber : Colors.grey, + 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: Colors.blueGrey, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + ), + onPressed: player.gold >= 50 + ? () => battleProvider.rerollShopItems() + : null, + icon: const Icon(Icons.refresh, color: Colors.white), + label: const Text( + "Reroll (50 G)", + style: TextStyle(color: Colors.white), + ), + ), + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.redAccent, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + ), + onPressed: () => battleProvider.proceedToNextStage(), + icon: const Icon(Icons.exit_to_app, color: Colors.white), + label: const Text( + "Leave Shop", + style: TextStyle(color: Colors.white), + ), + ), + ], ), ], ), ); } + + 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: Colors.amber), + onPressed: () { + battleProvider.buyItem(item); + Navigator.pop(ctx); + }, + child: const Text("Buy", style: TextStyle(color: Colors.black)), + ), + ], + ), + ); + } + + 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: 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: Colors.orangeAccent), + textAlign: TextAlign.center, + ), + ], + ); + } } class RestUI extends StatelessWidget { diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index cccf817..724bb2a 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,8 @@ import FlutterMacOS import Foundation +import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) } diff --git a/prompt/00_project_context_restore.md b/prompt/00_project_context_restore.md index 8115ae5..dfa2f84 100644 --- a/prompt/00_project_context_restore.md +++ b/prompt/00_project_context_restore.md @@ -82,6 +82,16 @@ 2. **2라운드:** 콜로세움 (Colosseum) 3. **3라운드:** 왕의 투기장 (King's Arena) - 최종 보스(Final Boss) 등장. +### F. 시스템 및 설정 (System & Settings) + +- **설정 페이지 (Settings Screen):** + - 게임 재시작 (Restart Game) 및 메인 메뉴로 돌아가기 (Return to Main Menu) 기능. + - 하단 네비게이션 바(BottomNavigationBar)에 설정 탭 추가. +- **로컬 저장 (Local Storage):** + - `shared_preferences`를 사용하여 스테이지 클리어 시 자동 저장. + - 메인 메뉴에서 '이어하기 (CONTINUE)' 버튼을 통해 저장된 시점부터 게임 재개 가능. + - 저장 데이터: 스테이지 진행도, 턴 수, 플레이어 상태(체력, 장비, 인벤토리 등). + ## 3. 핵심 파일 및 아키텍처 - **`lib/providers/battle_provider.dart`:** @@ -104,13 +114,14 @@ ## 4. 작업 컨벤션 (Working Conventions) - **Prompt Driven Development:** `prompt/XX_description.md` 유지. +- **Language:** **모든 프롬프트 파일(prompt/XX_...)은 반드시 한국어(Korean)로 작성해야 합니다.** - **State Management:** `Provider` + `Stream` (이벤트성 데이터). - **Data:** JSON 기반. ## 5. 다음 단계 작업 (Next Steps) 1. **아이템 시스템 고도화:** `items.json`에 `rarity`, `tier` 필드 추가 및 `ItemTable` 로직 수정. -2. **상점 구매 기능:** `Shop` 스테이지 구매 UI 구현 (Tier/Rarity 기반 목록 생성). +2. **[x] 상점 구매 기능:** `Shop` 스테이지 구매 UI 구현 (Tier/Rarity 기반 목록 생성). 3. **적 등장 테이블 구현:** 스테이지별 등장 가능한 적 목록(`enemy_table_pool`) 설정 및 적용. 4. **이미지 리소스 적용:** JSON 경로에 맞는 실제 이미지 파일 추가 및 UI 표시. 5. **밸런싱 및 콘텐츠 확장:** 아이템/적 데이터 추가 및 밸런스 조정. @@ -144,3 +155,6 @@ - [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 diff --git a/prompt/42_item_rarity_and_tier.md b/prompt/42_item_rarity_and_tier.md new file mode 100644 index 0000000..ba29952 --- /dev/null +++ b/prompt/42_item_rarity_and_tier.md @@ -0,0 +1,52 @@ +# 42. 아이템 시스템 고도화: 희귀도, 티어 및 랜덤 생성 (Item System Enhancement: Rarity, Tier & Random Generation) + +## 목표 (Objective) +아이템 시스템을 전면적으로 개편하여 게임의 깊이와 파밍의 재미를 더합니다. 단순한 스탯 증가가 아닌, 희귀도(Rarity)에 따른 **접두사(Prefix) 시스템**과 **랜덤 이름 생성(Random Name Generation)**을 도입하여 아이템의 다양성을 확보하고, 보상 시스템을 개선하여 플레이어에게 더 나은 경험을 제공합니다. + +## 주요 변경 사항 (Key Changes) + +### 1. 희귀도 시스템 (Rarity System) +- **`ItemRarity` Enum 정의:** + - **`magic` (파랑):** 기본 등급. 50% 확률로 접두사(Prefix)가 붙어 소폭의 스탯 보너스를 받음. + - **`rare` (노랑):** 상위 등급. **100% 확률로 랜덤 이름(Name Generator)이 생성**되며, 강력한 접두사 효과(다중 스탯 보너스)가 적용됨. + - **`legendary` (주황):** 전설 등급. 고유한 이름과 강력한 효과 유지. + - **`unique` (보라):** 최상위 등급. 게임을 뒤집을 수 있는 특수 능력 보유. +- **설정:** `ThemeConfig` 및 `ItemConfig`에서 색상 및 등장 확률(60/30/9/1) 관리. + +### 2. 티어 및 스케일링 (Tier & Scaling) +- **티어 구분 (`ItemTier`):** + - `tier1`: 1라운드 (지하 불법 투기장) + - `tier2`: 2라운드 (콜로세움) + - `tier3`: 3라운드 (왕의 투기장) +- **스케일링 제거:** 밸런스 조정을 용이하게 하기 위해, 기존의 '스테이지 비례 스탯 자동 증가' 로직을 제거하고, 티어별 고정 스탯과 접두사 시스템으로 대체함. + +### 3. 접두사 시스템 (Prefix System) +- **데이터 (`ItemPrefixTable`):** + - **Magic 접두사:** "Sharp"(공+2), "Sturdy"(체+10) 등 단일 스탯 강화. + - **Rare 접두사:** "Deadly"(공+5, 운+5), "Guardian's"(방+3, 체+20) 등 복합/강력 스탯 강화. +- **슬롯 필터링:** 접두사마다 적용 가능한 장비 슬롯(`allowedSlots`)을 지정하여, 방패에 '치명적인'이 붙는 등의 어색함 방지. + +### 4. 랜덤 이름 생성기 (Name Generator) +- **목적:** Rare 등급 아이템의 특별함을 강조하기 위해 기존 이름 대신 멋진 랜덤 이름을 부여. +- **구조 (`NameGenerator`):** + - **형용사 + 명사 조합:** "Crimson Reaper", "Shadow Guard" 등. + - **슬롯별 명사 풀:** 무기(Fang, Blade), 방어구(Wall, Plate), 방패(Aegis, Barrier) 등 부위에 맞는 명사 사용. +- **적용:** Rare 아이템 생성 시 100% 확률로 이름이 변경됨 (스탯은 Rare 접두사 효과를 따름). + +### 5. 보상 시스템 (Reward System) +- **골드 보상:** + - 전투 승리 시 골드(10 + 스테이지*5 + 랜덤) 자동 획득. + - 보상 팝업 헤더 우측 상단에 획득 골드량 표시. +- **아이템 보상:** + - 현재 스테이지 티어에 맞는 아이템 3종 랜덤 제시. + - **스킵 옵션 ("Skip Reward"):** 원하지 않는 아이템을 받지 않고 넘어갈 수 있는 선택지 추가. +- **UI 개선:** 희귀도별 텍스트 색상 및 테두리 적용으로 시각적 정보 강화. + +## 구현 완료 항목 (Implementation Status) +- [x] `ItemRarity`, `ItemTier` Enum 및 데이터 모델 업데이트. +- [x] `items.json` 데이터 마이그레이션 (Common->Magic, Epic->Legendary 등). +- [x] `ItemTable.getRandomItem` 구현 (가중치 기반 랜덤 선택). +- [x] `ItemPrefixTable` 및 `NameGenerator` 구현. +- [x] `ItemTemplate.createItem` 로직 수정 (접두사 및 랜덤 이름 적용). +- [x] `InventoryScreen` 및 `BattleScreen` UI 업데이트 (희귀도 색상, 보상 팝업 개선). +- [x] 테스트 코드(`item_rarity_tier_test.dart`, `item_random_test.dart`) 업데이트 및 검증 완료. \ No newline at end of file diff --git a/prompt/43_shop_system.md b/prompt/43_shop_system.md new file mode 100644 index 0000000..578ad43 --- /dev/null +++ b/prompt/43_shop_system.md @@ -0,0 +1,31 @@ +# 43. 상점 시스템 구현 (Shop System Implementation) + +## 목표 (Objective) +플레이어가 획득한 골드를 소비하여 장비를 구매하고 전력을 강화할 수 있는 상점 시스템을 구현합니다. 상점은 랜덤하게 생성된 아이템을 제공하며, 리롤(새로고침) 기능을 통해 전략적인 아이템 파밍을 지원합니다. + +## 구현 내용 (Implementation Details) + +### 1. 데이터 및 로직 (`BattleProvider`) +- **`_generateShopItems()`:** 현재 스테이지 티어에 맞는 랜덤 아이템 4개를 생성하는 로직 분리. +- **`rerollShopItems()`:** 50골드를 지불하고 상점 아이템 목록을 새로고침하는 기능. + - **수정 사항:** `StageModel`의 `shopItems` 필드가 `final`이므로, 리스트 자체를 재할당하는 대신 기존 리스트의 내용을 `clear()` 후 새로운 아이템으로 `addAll()`하는 방식으로 변경하여 런타임 에러를 방지했습니다. +- **`buyItem(Item item)`:** 골드를 차감하고 아이템을 인벤토리에 추가하며, 상점 목록에서 해당 아이템을 제거하는 기능. + +### 2. UI (`ShopUI` in `stage_ui.dart`) +- **헤더:** 'Merchant' 타이틀과 현재 보유 골드 표시 (황금색 강조). +- **아이템 목록 (GridView):** + - 2열 카드 형태의 그리드 레이아웃. + - 희귀도(Rarity)에 따른 이름 색상 및 테두리 적용. + - 아이템 아이콘, 이름, 주요 스탯 요약 표시. + - **가격 버튼:** 보유 골드에 따라 활성/비활성화. 클릭 시 구매 확인 팝업 출력. +- **하단 버튼:** + - **Reroll (50 G):** 목록 새로고침 버튼 (골드 부족 시 비활성화). + - **Leave Shop:** 상점을 떠나 다음 스테이지로 이동. + +### 3. 테스트 및 복구 (Test & Restoration) +- **임시 테스트 설정 (완료):** + - 초기 골드 200G로 시작. + - 첫 스테이지를 강제로 상점(Shop)으로 설정. +- **정상 설정으로 복구 완료:** + - 초기 골드 **50G**로 시작. + - 상점은 **매 5번째 스테이지**마다 등장하도록 복구. \ No newline at end of file diff --git a/prompt/44_settings_and_local_storage.md b/prompt/44_settings_and_local_storage.md new file mode 100644 index 0000000..6515174 --- /dev/null +++ b/prompt/44_settings_and_local_storage.md @@ -0,0 +1,40 @@ +# 44. 설정 페이지 및 로컬 저장 (Settings Page & Local Storage) + +## 1. 목표 (Goal) +- `BottomNavigationBar`에 "설정 (Settings)" 페이지를 추가합니다. +- 설정 페이지에 "메인 메뉴로 나가기 (Return to Main Menu)" 및 "다시 시작하기 (Restart Game)" 버튼을 추가합니다. +- 스테이지 클리어 시 진행 상황(스테이지, 턴, 플레이어 상태)을 로컬 스토리지(`shared_preferences`)에 자동 저장하는 기능을 구현합니다. +- 메인 메뉴에 저장된 데이터가 있을 경우 "이어하기 (CONTINUE)" 버튼을 표시하고 기능을 연결합니다. + +## 2. 구현 상세 (Implementation Details) + +### 의존성 (Dependencies) +- `shared_preferences` 패키지 추가. + +### 로컬 저장소 (`SaveManager`) +- **파일:** `lib/game/save_manager.dart` +- **기능:** + - `saveGame`: `BattleProvider`의 상태(스테이지, 턴, 플레이어 스탯, 인벤토리, 장비)를 JSON으로 직렬화하여 SharedPreferences에 저장. + - `loadGame`: 저장된 데이터를 불러와 역직렬화. + - `hasSaveData`: 저장 파일 존재 여부 확인. + - `clearSaveData`: 저장 데이터 삭제 (리셋/디버그 용). + +### 데이터 직렬화 (Data Serialization) +- **수정된 클래스:** `Character`, `StatusEffect`. +- **메서드:** `toJson()` 및 `fromJson()` 메서드 추가. +- **아이템 처리:** `Item` 객체 자체를 저장하는 대신 ID를 저장하고, 로드 시 `ItemTable`을 통해 복구. + +### UI 변경 사항 (UI Changes) +- **설정 화면 (`SettingsScreen`):** + - `lib/screens/settings_screen.dart` 생성. + - 버튼: "게임 재시작 (Restart Game)", "메인 메뉴로 (Return to Main Menu)". +- **메인 래퍼 (`MainWrapper`):** + - `BottomNavigationBar`에 "Settings" 탭 추가. +- **메인 메뉴 (`MainMenuScreen`):** + - `StatefulWidget`으로 변환. + - 초기화 시 저장 데이터 확인. + - 저장 데이터 존재 시 "이어하기 (CONTINUE)" 버튼 표시. + +### 전투 로직 통합 (Battle Logic Integration) +- **자동 저장:** `_prepareNextStage` (스테이지 클리어 후 실행) 내부에서 `SaveManager.saveGame` 호출. +- **불러오기:** `BattleProvider`에 `loadFromSave` 메서드를 추가하여 JSON 데이터로부터 상태 복구. \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 3fc8dd1..d1aa358 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,6 +35,7 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 provider: ^6.0.5 + shared_preferences: ^2.5.3 dev_dependencies: flutter_test: diff --git a/test/enemy_intent_test.dart b/test/enemy_intent_test.dart index f333a9c..8fbf51f 100644 --- a/test/enemy_intent_test.dart +++ b/test/enemy_intent_test.dart @@ -3,11 +3,13 @@ import 'package:game_test/providers/battle_provider.dart'; import 'package:game_test/game/data/enemy_table.dart'; import 'package:game_test/game/data/item_table.dart'; import 'package:game_test/game/enums.dart'; +import 'package:shared_preferences/shared_preferences.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); setUpAll(() async { + SharedPreferences.setMockInitialValues({}); await ItemTable.load(); await EnemyTable.load(); }); diff --git a/test/item_random_test.dart b/test/item_random_test.dart new file mode 100644 index 0000000..5551b7a --- /dev/null +++ b/test/item_random_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:game_test/game/data/item_table.dart'; +import 'package:game_test/game/enums.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('ItemTable.getRandomItem returns items correctly', () async { + await ItemTable.load(); + + // Test 1: Get Tier 1 item (Common/Rare/etc logic applies, but we just check if it returns *something* valid) + final item = ItemTable.getRandomItem(tier: ItemTier.tier1); + expect(item, isNotNull); + expect(item!.tier, equals(ItemTier.tier1)); + print("Random Tier 1 Item: ${item.name} (${item.rarity.name})"); + + // Test 2: Get Tier 3 item + final item3 = ItemTable.getRandomItem(tier: ItemTier.tier3); + expect(item3, isNotNull); + expect(item3!.tier, equals(ItemTier.tier3)); + print("Random Tier 3 Item: ${item3.name} (${item3.rarity.name})"); + + // Test 3: Get specific slot (Shield) from Tier 1 + // We know 'pot_lid' and 'wooden_shield' are Tier 1 shields + final shield = ItemTable.getRandomItem(tier: ItemTier.tier1, slot: EquipmentSlot.shield); + expect(shield, isNotNull); + expect(shield!.slot, equals(EquipmentSlot.shield)); + expect(shield.tier, equals(ItemTier.tier1)); + + // Test 4: Verify Rarity weights (Statistical test) + // We'll run 1000 times and expect roughly correct distribution. + // Tier 1 has Common and Rare items mostly. + int magicCount = 0; + int rareCount = 0; + + for (int i = 0; i < 1000; i++) { + // Tier 1 items: + // Magic: Rusty Dagger, Torn Tunic, Pot Lid, Wooden Shield, Old Ring, Copper Ring + // Rare: Jagged Dagger + // So both exist. + final randItem = ItemTable.getRandomItem(tier: ItemTier.tier1); + if (randItem!.rarity == ItemRarity.magic) magicCount++; + if (randItem.rarity == ItemRarity.rare) rareCount++; + } + + print("Tier 1 Stats (1000 runs): Magic=$magicCount, Rare=$rareCount"); + expect(magicCount, greaterThan(rareCount)); // Should be significantly more magic items + }); +} diff --git a/test/item_rarity_tier_test.dart b/test/item_rarity_tier_test.dart new file mode 100644 index 0000000..499961e --- /dev/null +++ b/test/item_rarity_tier_test.dart @@ -0,0 +1,29 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:game_test/game/data/item_table.dart'; +import 'package:game_test/game/enums.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('ItemTable loads rarity and tier correctly', () async { + await ItemTable.load(); + + // Check Rusty Dagger (Common, Tier 1) + final rustyDagger = ItemTable.get('rusty_dagger'); + expect(rustyDagger, isNotNull); + expect(rustyDagger!.rarity, equals(ItemRarity.magic)); + expect(rustyDagger.tier, equals(ItemTier.tier1)); + + // Check Sunderer Axe (Epic, Tier 3) + final sundererAxe = ItemTable.get('sunderer_axe'); + expect(sundererAxe, isNotNull); + expect(sundererAxe!.rarity, equals(ItemRarity.legendary)); + expect(sundererAxe.tier, equals(ItemTier.tier3)); + + // Check Lucky Charm (Legendary, Tier 3) + final luckyCharm = ItemTable.get('lucky_charm'); + expect(luckyCharm, isNotNull); + expect(luckyCharm!.rarity, equals(ItemRarity.unique)); + expect(luckyCharm.tier, equals(ItemTier.tier3)); + }); +}