From dcfb8ab9debb08faccd567dd08d6058b6d888bbb Mon Sep 17 00:00:00 2001 From: Horoli Date: Sun, 7 Dec 2025 17:58:00 +0900 Subject: [PATCH] update --- assets/data/enemies.json | 42 ++++- assets/data/items.json | 22 +++ lib/game/config/item_config.dart | 7 +- lib/game/config/theme_config.dart | 7 +- lib/game/data/enemy_table.dart | 36 +++++ lib/game/data/item_prefix_table.dart | 13 +- lib/game/data/item_table.dart | 82 ++++++++-- lib/game/enums.dart | 2 +- lib/providers/battle_provider.dart | 212 ++++++++++++++----------- lib/screens/battle_screen.dart | 2 + lib/screens/inventory_screen.dart | 103 +++++++++--- lib/utils/item_utils.dart | 2 + lib/widgets/stage/shop_ui.dart | 63 +++++--- prompt/00_project_context_restore.md | 13 +- prompt/49_implement_item_icons.md | 1 + prompt/50_expand_item_pool.md | 31 ++++ prompt/51_refactor_prefix_table.md | 27 ++++ prompt/52_round_based_enemy_pool.md | 27 ++++ prompt/53_refine_stage_rewards.md | 24 +++ prompt/54_fix_shop_logic.md | 22 +++ prompt/55_fix_shop_ui_sync.md | 18 +++ prompt/56_permadeath_implementation.md | 21 +++ 22 files changed, 612 insertions(+), 165 deletions(-) create mode 100644 prompt/50_expand_item_pool.md create mode 100644 prompt/51_refactor_prefix_table.md create mode 100644 prompt/52_round_based_enemy_pool.md create mode 100644 prompt/53_refine_stage_rewards.md create mode 100644 prompt/54_fix_shop_logic.md create mode 100644 prompt/55_fix_shop_ui_sync.md create mode 100644 prompt/56_permadeath_implementation.md diff --git a/assets/data/enemies.json b/assets/data/enemies.json index 5fa3236..9c29903 100644 --- a/assets/data/enemies.json +++ b/assets/data/enemies.json @@ -6,7 +6,8 @@ "baseAtk": 5, "baseDefense": 5, "image": "assets/images/enemies/goblin.png", - "equipment": ["rusty_dagger"] + "equipment": ["rusty_dagger"], + "tier": 1 }, { "name": "Slime", @@ -14,7 +15,8 @@ "baseAtk": 3, "baseDefense": 5, "image": "assets/images/enemies/slime.png", - "equipment": ["rusty_dagger"] + "equipment": ["rusty_dagger"], + "tier": 1 }, { "name": "Wolf", @@ -22,7 +24,8 @@ "baseAtk": 7, "baseDefense": 5, "image": "assets/images/enemies/wolf.png", - "equipment": ["rusty_dagger"] + "equipment": ["rusty_dagger"], + "tier": 1 }, { "name": "Bandit", @@ -30,7 +33,8 @@ "baseAtk": 6, "baseDefense": 5, "image": "assets/images/enemies/bandit.png", - "equipment": ["rusty_dagger"] + "equipment": ["rusty_dagger"], + "tier": 2 }, { "name": "Skeleton", @@ -38,7 +42,26 @@ "baseAtk": 8, "baseDefense": 5, "image": "assets/images/enemies/skeleton.png", - "equipment": ["rusty_dagger"] + "equipment": ["rusty_dagger"], + "tier": 2 + }, + { + "name": "Shadow Assassin", + "baseHp": 40, + "baseAtk": 10, + "baseDefense": 2, + "image": "assets/images/enemies/shadow_assassin.png", + "equipment": ["jagged_dagger"], + "tier": 3 + }, + { + "name": "Armored Bear", + "baseHp": 60, + "baseAtk": 8, + "baseDefense": 8, + "image": "assets/images/enemies/armored_bear.png", + "equipment": ["iron_sword"], + "tier": 3 } ], "elite": [ @@ -48,7 +71,8 @@ "baseAtk": 12, "baseDefense": 3, "image": "assets/images/enemies/orc_warrior.png", - "equipment": ["battle_axe", "leather_vest"] + "equipment": ["battle_axe", "leather_vest"], + "tier": 1 }, { "name": "Giant Spider", @@ -56,7 +80,8 @@ "baseAtk": 15, "baseDefense": 2, "image": "assets/images/enemies/giant_spider.png", - "equipment": ["jagged_dagger"] + "equipment": ["jagged_dagger"], + "tier": 2 }, { "name": "Dark Knight", @@ -64,7 +89,8 @@ "baseAtk": 10, "baseDefense": 5, "image": "assets/images/enemies/dark_knight.png", - "equipment": ["stunning_hammer", "kite_shield"] + "equipment": ["stunning_hammer", "kite_shield"], + "tier": 3 } ] } diff --git a/assets/data/items.json b/assets/data/items.json index 6f131ee..616d7b9 100644 --- a/assets/data/items.json +++ b/assets/data/items.json @@ -1,5 +1,27 @@ { "weapons": [ + { + "id": "short_bow", + "name": "Short Bow", + "description": "A basic bow for beginners.", + "baseAtk": 2, + "slot": "weapon", + "price": 15, + "image": "assets/images/items/short_bow.png", + "rarity": "normal", + "tier": "tier1" + }, + { + "id": "long_sword", + "name": "Long Sword", + "description": "A versatile blade.", + "baseAtk": 6, + "slot": "weapon", + "price": 50, + "image": "assets/images/items/long_sword.png", + "rarity": "normal", + "tier": "tier2" + }, { "id": "rusty_dagger", "name": "Rusty Dagger", diff --git a/lib/game/config/item_config.dart b/lib/game/config/item_config.dart index 954090e..e492d32 100644 --- a/lib/game/config/item_config.dart +++ b/lib/game/config/item_config.dart @@ -5,9 +5,10 @@ class ItemConfig { /// 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.normal: 50, + ItemRarity.magic: 30, + ItemRarity.rare: 15, + ItemRarity.legendary: 4, ItemRarity.unique: 1, }; } diff --git a/lib/game/config/theme_config.dart b/lib/game/config/theme_config.dart index f29bf61..1b62192 100644 --- a/lib/game/config/theme_config.dart +++ b/lib/game/config/theme_config.dart @@ -23,7 +23,7 @@ class ThemeConfig { static const Color mainTitleColor = Colors.white; static const Color subTitleColor = Colors.grey; static const Color mainIconColor = Colors.amber; - + // Button Colors static const Color btnNewGameBg = Color(0xFFFFA000); // Colors.amber[700] static const Color btnNewGameText = Colors.black; @@ -37,14 +37,14 @@ class ThemeConfig { static const Color btnDisabled = Colors.grey; static const Color btnRestartBg = Colors.orange; static const Color btnReturnMenuBg = Colors.red; - + // Stat Colors static const Color statHpColor = Colors.red; static const Color statHpPlayerColor = Colors.green; static const Color statHpEnemyColor = Colors.red; static const Color statAtkColor = Colors.blueAccent; static const Color statDefColor = - Colors.green; // Or Blue depending on context + Colors.blueAccent; // Or Blue depending on context static const Color statLuckColor = Colors.green; static const Color statGoldColor = Colors.amber; @@ -82,6 +82,7 @@ class ThemeConfig { static const Color effectText = Colors.white; // Rarity Colors + static const Color rarityNormal = Colors.white; static const Color rarityMagic = Colors.blueAccent; static const Color rarityRare = Colors.yellow; static const Color rarityLegendary = Colors.orange; diff --git a/lib/game/data/enemy_table.dart b/lib/game/data/enemy_table.dart index 7ea8068..18edbeb 100644 --- a/lib/game/data/enemy_table.dart +++ b/lib/game/data/enemy_table.dart @@ -1,6 +1,8 @@ import 'dart:convert'; +import 'dart:math'; import 'package:flutter/services.dart'; import '../model/entity.dart'; +import '../config/game_config.dart'; import 'item_table.dart'; @@ -11,6 +13,7 @@ class EnemyTemplate { final int baseDefense; final String? image; final List equipmentIds; + final int tier; const EnemyTemplate({ required this.name, @@ -19,6 +22,7 @@ class EnemyTemplate { required this.baseDefense, this.image, this.equipmentIds = const [], + this.tier = 1, }); factory EnemyTemplate.fromJson(Map json) { @@ -29,6 +33,7 @@ class EnemyTemplate { baseDefense: json['baseDefense'] ?? 0, image: json['image'], equipmentIds: (json['equipment'] as List?)?.cast() ?? [], + tier: json['tier'] ?? 1, ); } @@ -63,6 +68,7 @@ class EnemyTemplate { class EnemyTable { static List normalEnemies = []; static List eliteEnemies = []; + static final Random _random = Random(); static Future load() async { final String jsonString = await rootBundle.loadString( @@ -77,4 +83,34 @@ class EnemyTable { .map((e) => EnemyTemplate.fromJson(e)) .toList(); } + + /// Returns a random enemy suitable for the current stage. + static EnemyTemplate getRandomEnemy({required int stage, bool isElite = false}) { + int targetTier = 1; + if (stage > GameConfig.tier2StageMax) { + targetTier = 3; + } else if (stage > GameConfig.tier1StageMax) { + targetTier = 2; + } + + List pool = isElite ? eliteEnemies : normalEnemies; + + // Filter by tier + var tierPool = pool.where((e) => e.tier == targetTier).toList(); + + // Fallback: If no enemies found for this tier, use lower tiers (or any) + if (tierPool.isEmpty) { + tierPool = pool.where((e) => e.tier <= targetTier).toList(); + } + if (tierPool.isEmpty) { + tierPool = pool; // Absolute fallback + } + + if (tierPool.isEmpty) { + // Should not happen if JSON is correct + return const EnemyTemplate(name: "Fallback Enemy", baseHp: 10, baseAtk: 1, baseDefense: 0); + } + + return tierPool[_random.nextInt(tierPool.length)]; + } } diff --git a/lib/game/data/item_prefix_table.dart b/lib/game/data/item_prefix_table.dart index c1b9c8f..d250bca 100644 --- a/lib/game/data/item_prefix_table.dart +++ b/lib/game/data/item_prefix_table.dart @@ -4,15 +4,26 @@ class ItemModifier { final String prefix; final Map statChanges; final List? allowedSlots; // Null means allowed for all slots + final double multiplier; // For percent-based modifiers (Normal rarity) + final int weight; // Selection weight const ItemModifier({ required this.prefix, - required this.statChanges, + this.statChanges = const {}, this.allowedSlots, + this.multiplier = 1.0, + this.weight = 1, }); } class ItemPrefixTable { + static const List normalPrefixes = [ + ItemModifier(prefix: "Crude", multiplier: 0.9, weight: 25), + ItemModifier(prefix: "Old", multiplier: 0.95, weight: 25), + ItemModifier(prefix: "", multiplier: 1.0, weight: 25), // Standard + ItemModifier(prefix: "High-quality", multiplier: 1.1, weight: 25), + ]; + static const List magicPrefixes = [ // Weapons ItemModifier( diff --git a/lib/game/data/item_table.dart b/lib/game/data/item_table.dart index d476ed7..486b290 100644 --- a/lib/game/data/item_table.dart +++ b/lib/game/data/item_table.dart @@ -6,6 +6,7 @@ import '../enums.dart'; import '../config/item_config.dart'; import 'item_prefix_table.dart'; // Import prefix table import 'name_generator.dart'; // Import name generator +import '../../utils/game_math.dart'; class ItemTemplate { final String id; @@ -79,8 +80,40 @@ class ItemTemplate { final random = Random(); + // 0. Normal Rarity: Prefix logic for base stat variations + if (rarity == ItemRarity.normal) { + // Weighted Random Selection + final prefixes = ItemPrefixTable.normalPrefixes; + int totalWeight = prefixes.fold(0, (sum, p) => sum + p.weight); + int roll = random.nextInt(totalWeight); + + ItemModifier? selectedModifier; + int currentSum = 0; + for (var mod in prefixes) { + currentSum += mod.weight; + if (roll < currentSum) { + selectedModifier = mod; + break; + } + } + + if (selectedModifier != null) { + if (selectedModifier.prefix.isNotEmpty) { + finalName = "${selectedModifier.prefix} $name"; + } + + double mult = selectedModifier.multiplier; + if (mult != 1.0) { + finalAtk = (finalAtk * mult).floor(); + finalHp = (finalHp * mult).floor(); + finalArmor = (finalArmor * mult).floor(); + // Luck usually isn't scaled by small multipliers, but let's keep it consistent or skip. + // Skipping luck scaling for normal prefixes to avoid 0. + } + } + } // 1. Magic Rarity: 50% chance to get a Magic Prefix (1 stat change) - if (rarity == ItemRarity.magic) { + else if (rarity == ItemRarity.magic) { if (random.nextBool()) { // 50% chance // Filter valid prefixes for this slot final validPrefixes = ItemPrefixTable.magicPrefixes.where((p) { @@ -208,11 +241,14 @@ class ItemTable { /// [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. + /// [minRarity]: Optional. Minimum rarity to consider (inclusive). + /// [maxRarity]: Optional. Maximum rarity to consider (inclusive). static ItemTemplate? getRandomItem({ required ItemTier tier, EquipmentSlot? slot, Map? weights, + ItemRarity? minRarity, + ItemRarity? maxRarity, }) { // 1. Filter by Tier and Slot (if provided) var candidates = allItems.where((item) => item.tier == tier); @@ -222,16 +258,37 @@ class ItemTable { if (candidates.isEmpty) return null; - // 2. Determine Target Rarity based on weights - final rarityWeights = weights ?? ItemConfig.defaultRarityWeights; + // 2. Prepare Rarity Weights (Filtered by min/max) + Map activeWeights = Map.from(weights ?? ItemConfig.defaultRarityWeights); - int totalWeight = rarityWeights.values.fold(0, (sum, w) => sum + w); + if (minRarity != null) { + activeWeights.removeWhere((r, w) => r.index < minRarity.index); + } + if (maxRarity != null) { + activeWeights.removeWhere((r, w) => r.index > maxRarity.index); + } + + if (activeWeights.isEmpty) { + // Fallback: If weights eliminated all options (e.g. misconfiguration), + // try to find ANY item within rarity range from candidates. + if (minRarity != null) { + candidates = candidates.where((item) => item.rarity.index >= minRarity.index); + } + if (maxRarity != null) { + candidates = candidates.where((item) => item.rarity.index <= maxRarity.index); + } + if (candidates.isEmpty) return null; + return candidates.toList()[_random.nextInt(candidates.length)]; + } + + // 3. Determine Target Rarity based on filtered weights + int totalWeight = activeWeights.values.fold(0, (sum, w) => sum + w); int roll = _random.nextInt(totalWeight); ItemRarity? selectedRarity; int currentSum = 0; - for (var entry in rarityWeights.entries) { + for (var entry in activeWeights.entries) { currentSum += entry.value; if (roll < currentSum) { selectedRarity = entry.key; @@ -239,15 +296,22 @@ class ItemTable { } } - // 3. Filter candidates by Selected Rarity + // 4. 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 + // 5. Fallback: If no items of selected rarity, use any item from the filtered candidates (respecting min/max) if (rarityCandidates.isEmpty) { + if (minRarity != null) { + candidates = candidates.where((item) => item.rarity.index >= minRarity.index); + } + if (maxRarity != null) { + candidates = candidates.where((item) => item.rarity.index <= maxRarity.index); + } + if (candidates.isEmpty) return null; return candidates.toList()[_random.nextInt(candidates.length)]; } - // 5. Pick random item + // 6. Pick random item return rarityCandidates[_random.nextInt(rarityCandidates.length)]; } } diff --git a/lib/game/enums.dart b/lib/game/enums.dart index c34ee3a..86a7c7e 100644 --- a/lib/game/enums.dart +++ b/lib/game/enums.dart @@ -35,6 +35,6 @@ enum DamageType { normal, bleed, vulnerable } enum StatType { maxHp, atk, defense, luck } -enum ItemRarity { magic, rare, legendary, unique } +enum ItemRarity { normal, magic, rare, legendary, unique } enum ItemTier { tier1, tier2, tier3 } diff --git a/lib/providers/battle_provider.dart b/lib/providers/battle_provider.dart index 034cd1e..ab888e2 100644 --- a/lib/providers/battle_provider.dart +++ b/lib/providers/battle_provider.dart @@ -57,6 +57,10 @@ class BattleProvider with ChangeNotifier { List get logs => battleLogs; int get lastGoldReward => _lastGoldReward; + void refreshUI() { + notifyListeners(); + } + // Damage Event Stream final _damageEventController = StreamController.broadcast(); Stream get damageStream => _damageEventController.stream; @@ -83,10 +87,10 @@ class BattleProvider with ChangeNotifier { stage = data['stage']; turnCount = data['turnCount']; player = Character.fromJson(data['player']); - + battleLogs.clear(); _addLog("Game Loaded! Resuming Stage $stage"); - + _prepareNextStage(); notifyListeners(); } @@ -113,51 +117,51 @@ class BattleProvider with ChangeNotifier { player.gold = GameConfig.startingGold; // Provide starter equipment - final starterSword = Item( - id: "starter_sword", - name: "Wooden Sword", - description: "A basic sword", - atkBonus: 5, - hpBonus: 0, - slot: EquipmentSlot.weapon, - ); - final starterArmor = Item( - id: "starter_armor", - name: "Leather Armor", - description: "Basic protection", - atkBonus: 0, - hpBonus: 20, - slot: EquipmentSlot.armor, - ); - final starterShield = Item( - id: "starter_shield", - name: "Wooden Shield", - description: "A small shield", - atkBonus: 0, - hpBonus: 0, - armorBonus: 3, - slot: EquipmentSlot.shield, - ); - final starterRing = Item( - id: "starter_ring", - name: "Copper Ring", - description: "A simple ring", - atkBonus: 1, - hpBonus: 5, - slot: EquipmentSlot.accessory, - ); + // final starterSword = Item( + // id: "starter_sword", + // name: "Wooden Sword", + // description: "A basic sword", + // atkBonus: 5, + // hpBonus: 0, + // slot: EquipmentSlot.weapon, + // ); + // final starterArmor = Item( + // id: "starter_armor", + // name: "Leather Armor", + // description: "Basic protection", + // atkBonus: 0, + // hpBonus: 20, + // slot: EquipmentSlot.armor, + // ); + // final starterShield = Item( + // id: "starter_shield", + // name: "Wooden Shield", + // description: "A small shield", + // atkBonus: 0, + // hpBonus: 0, + // armorBonus: 3, + // slot: EquipmentSlot.shield, + // ); + // final starterRing = Item( + // id: "starter_ring", + // name: "Copper Ring", + // description: "A simple ring", + // atkBonus: 1, + // hpBonus: 5, + // slot: EquipmentSlot.accessory, + // ); - player.addToInventory(starterSword); - player.equip(starterSword); + // player.addToInventory(starterSword); + // player.equip(starterSword); - player.addToInventory(starterArmor); - player.equip(starterArmor); + // player.addToInventory(starterArmor); + // player.equip(starterArmor); - player.addToInventory(starterShield); - player.equip(starterShield); + // player.addToInventory(starterShield); + // player.equip(starterShield); - player.addToInventory(starterRing); - player.equip(starterRing); + // player.addToInventory(starterRing); + // player.equip(starterRing); // Add new status effect items for testing player.addToInventory(ItemTable.weapons[3].createItem()); // Stunning Hammer @@ -194,36 +198,8 @@ class BattleProvider with ChangeNotifier { if (type == StageType.battle || type == StageType.elite) { bool isElite = type == StageType.elite; - // Select random enemy template - final random = Random(); - EnemyTemplate template; - if (isElite) { - if (EnemyTable.eliteEnemies.isNotEmpty) { - template = EnemyTable - .eliteEnemies[random.nextInt(EnemyTable.eliteEnemies.length)]; - } else { - // Fallback if no elite enemies loaded - template = const EnemyTemplate( - name: "Elite Guardian", - baseHp: 50, - baseAtk: 10, - baseDefense: 2, - ); - } - } else { - if (EnemyTable.normalEnemies.isNotEmpty) { - template = EnemyTable - .normalEnemies[random.nextInt(EnemyTable.normalEnemies.length)]; - } else { - // Fallback - template = const EnemyTemplate( - name: "Enemy", - baseHp: 20, - baseAtk: 5, - baseDefense: 0, - ); - } - } + + EnemyTemplate template = EnemyTable.getRandomEnemy(stage: stage, isElite: isElite); newEnemy = template.createCharacter(stage: stage); @@ -264,6 +240,12 @@ class BattleProvider with ChangeNotifier { // Replaces _spawnEnemy // void _spawnEnemy() { ... } - Removed + Future _onDefeat() async { + _addLog("Player defeated! Enemy wins!"); + await SaveManager.clearSaveData(); + notifyListeners(); + } + /// Handle player's action choice Future playerAction(ActionType type, RiskLevel risk) async { @@ -287,6 +269,12 @@ class BattleProvider with ChangeNotifier { // 2. Process Start-of-Turn Effects (Stun, Bleed) bool canAct = _processStartTurnEffects(player); + + if (player.isDead) { + await _onDefeat(); + return; + } + if (!canAct) { _endPlayerTurn(); // Skip turn if stunned return; @@ -341,11 +329,17 @@ class BattleProvider with ChangeNotifier { // Animation Delays to sync with Impact if (risk == RiskLevel.safe) { - await Future.delayed(const Duration(milliseconds: GameConfig.animDelaySafe)); + await Future.delayed( + const Duration(milliseconds: GameConfig.animDelaySafe), + ); } else if (risk == RiskLevel.normal) { - await Future.delayed(const Duration(milliseconds: GameConfig.animDelayNormal)); + await Future.delayed( + const Duration(milliseconds: GameConfig.animDelayNormal), + ); } else if (risk == RiskLevel.risky) { - await Future.delayed(const Duration(milliseconds: GameConfig.animDelayRisky)); + await Future.delayed( + const Duration(milliseconds: GameConfig.animDelayRisky), + ); } int damageToHp = 0; @@ -437,14 +431,19 @@ class BattleProvider with ChangeNotifier { return; } - Future.delayed(const Duration(milliseconds: GameConfig.animDelayEnemyTurn), () => _enemyTurn()); + Future.delayed( + const Duration(milliseconds: GameConfig.animDelayEnemyTurn), + () => _enemyTurn(), + ); } Future _enemyTurn() async { if (!isPlayerTurn && (player.isDead || enemy.isDead)) return; _addLog("Enemy's turn..."); - await Future.delayed(const Duration(milliseconds: GameConfig.animDelayEnemyTurn)); + await Future.delayed( + const Duration(milliseconds: GameConfig.animDelayEnemyTurn), + ); // Enemy Turn Start Logic // Armor decay @@ -543,7 +542,8 @@ class BattleProvider with ChangeNotifier { } if (player.isDead) { - _addLog("Player defeated! Enemy wins!"); + await _onDefeat(); + return; } isPlayerTurn = true; @@ -654,15 +654,6 @@ class BattleProvider with ChangeNotifier { _addLog("Enemy defeated! Gained $goldReward Gold."); _addLog("Choose a reward."); - List allTemplates = List.from(ItemTable.allItems); - allTemplates.shuffle(random); // Shuffle to randomize selection - - // 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 > GameConfig.tier2StageMax) currentTier = ItemTier.tier3; @@ -670,9 +661,42 @@ class BattleProvider with ChangeNotifier { currentTier = ItemTier.tier2; rewardOptions = []; - // Get 3 distinct items if possible + + bool isElite = currentStage.type == StageType.elite; + bool isTier1 = currentTier == ItemTier.tier1; + + // Get 3 distinct items for (int i = 0; i < 3; i++) { - ItemTemplate? item = ItemTable.getRandomItem(tier: currentTier); + ItemRarity? minRarity; + ItemRarity? maxRarity; + + // 1. Elite Reward Logic (First Item only) + if (isElite && i == 0) { + if (isTier1) { + // Tier 1 Elite: Guaranteed Rare + minRarity = ItemRarity.rare; + maxRarity = ItemRarity.rare; // Or allow higher? Request said "Guaranteed Rare 1 drop". Let's fix to Rare. + } else { + // Tier 2/3 Elite: Guaranteed Legendary + minRarity = ItemRarity.legendary; + // maxRarity = ItemRarity.legendary; // Optional, but let's allow Unique too if weights permit, or fix to Legendary. Request said "Guaranteed Legendary". + } + } + // 2. Standard Reward Logic (Others) + else { + if (isTier1) { + // Tier 1 Normal/Other Rewards: Max Magic (No Rare+) + maxRarity = ItemRarity.magic; + } + // Tier 2/3 Normal: No extra restrictions + } + + ItemTemplate? item = ItemTable.getRandomItem( + tier: currentTier, + minRarity: minRarity, + maxRarity: maxRarity + ); + if (item != null) { rewardOptions.add(item.createItem(stage: stage)); } @@ -716,7 +740,9 @@ class BattleProvider with ChangeNotifier { void _completeStage() { // Heal player after selecting reward - int healAmount = GameMath.floor(player.totalMaxHp * GameConfig.stageHealRatio); + int healAmount = GameMath.floor( + player.totalMaxHp * GameConfig.stageHealRatio, + ); player.heal(healAmount); _addLog("Stage Cleared! Recovered $healAmount HP."); @@ -760,7 +786,9 @@ class BattleProvider with ChangeNotifier { void sellItem(Item item) { if (player.inventory.remove(item)) { - int sellPrice = GameMath.floor(item.price * GameConfig.sellPriceMultiplier); + int sellPrice = GameMath.floor( + item.price * GameConfig.sellPriceMultiplier, + ); player.gold += sellPrice; _addLog("Sold ${item.name} for $sellPrice G."); notifyListeners(); diff --git a/lib/screens/battle_screen.dart b/lib/screens/battle_screen.dart index 6595926..7ed57ae 100644 --- a/lib/screens/battle_screen.dart +++ b/lib/screens/battle_screen.dart @@ -601,6 +601,7 @@ class _BattleScreenState extends State { width: 24, height: 24, fit: BoxFit.contain, + filterQuality: FilterQuality.high, ), ), if (!isSkip) const SizedBox(width: 12), @@ -755,6 +756,7 @@ class _BattleScreenState extends State { height: 32, color: ThemeConfig.textColorWhite, // Tint icon white fit: BoxFit.contain, + filterQuality: FilterQuality.high, ), ); } diff --git a/lib/screens/inventory_screen.dart b/lib/screens/inventory_screen.dart index 43b64f5..47c522d 100644 --- a/lib/screens/inventory_screen.dart +++ b/lib/screens/inventory_screen.dart @@ -39,20 +39,29 @@ class InventoryScreen extends StatelessWidget { _buildStatItem( "HP", "${player.hp}/${player.totalMaxHp}", + color: ThemeConfig.statHpColor, ), - _buildStatItem("ATK", "${player.totalAtk}"), - _buildStatItem("DEF", "${player.totalDefense}"), - _buildStatItem("Shield", "${player.armor}"), _buildStatItem( - "Gold", - "${player.gold} G", - color: ThemeConfig.statGoldColor, + "ATK", + "${player.totalAtk}", + color: ThemeConfig.statAtkColor, ), + _buildStatItem( + "DEF", + "${player.totalDefense}", + color: ThemeConfig.statDefColor, + ), + _buildStatItem("Shield", "${player.armor}"), _buildStatItem( "Luck", "${player.totalLuck}", color: ThemeConfig.statLuckColor, ), + _buildStatItem( + "Gold", + "${player.gold} G", + color: ThemeConfig.statGoldColor, + ), ], ), ], @@ -94,12 +103,14 @@ class InventoryScreen extends StatelessWidget { color: item != null ? ThemeConfig.equipmentCardBg : ThemeConfig.emptySlotBg, - shape: item != null && + shape: + item != null && item.rarity != ItemRarity.magic ? RoundedRectangleBorder( side: BorderSide( - color: - ItemUtils.getRarityColor(item.rarity), + color: ItemUtils.getRarityColor( + item.rarity, + ), width: 2.0, ), borderRadius: BorderRadius.circular(4.0), @@ -125,12 +136,15 @@ class InventoryScreen extends StatelessWidget { left: 4, top: 4, child: Opacity( - opacity: item != null ? 0.5 : 0.2, // Increase opacity slightly for images + opacity: item != null + ? 0.5 + : 0.2, // Increase opacity slightly for images child: Image.asset( ItemUtils.getIconPath(slot), width: 40, height: 40, fit: BoxFit.contain, + filterQuality: FilterQuality.high, ), ), ), @@ -151,8 +165,10 @@ class InventoryScreen extends StatelessWidget { item?.name ?? "Empty", textAlign: TextAlign.center, style: TextStyle( - fontSize: ThemeConfig.fontSizeSmall, - fontWeight: ThemeConfig.fontWeightBold, + fontSize: + ThemeConfig.fontSizeSmall, + fontWeight: + ThemeConfig.fontWeightBold, color: item != null ? ItemUtils.getRarityColor( item.rarity, @@ -237,12 +253,14 @@ class InventoryScreen extends StatelessWidget { left: 4, top: 4, child: Opacity( - opacity: 0.5, // Adjusted opacity for image visibility + opacity: + 0.5, // Adjusted opacity for image visibility child: Image.asset( ItemUtils.getIconPath(item.slot), width: 40, height: 40, fit: BoxFit.contain, + filterQuality: FilterQuality.high, ), ), ), @@ -260,7 +278,8 @@ class InventoryScreen extends StatelessWidget { textAlign: TextAlign.center, style: TextStyle( fontSize: ThemeConfig.fontSizeSmall, - fontWeight: ThemeConfig.fontWeightBold, + fontWeight: + ThemeConfig.fontWeightBold, color: ItemUtils.getRarityColor( item.rarity, ), @@ -289,7 +308,10 @@ class InventoryScreen extends StatelessWidget { color: ThemeConfig.emptySlotBg, ), child: const Center( - child: Icon(Icons.add_box, color: ThemeConfig.textColorGrey), + child: Icon( + Icons.add_box, + color: ThemeConfig.textColorGrey, + ), ), ); } @@ -306,7 +328,13 @@ class InventoryScreen extends StatelessWidget { Widget _buildStatItem(String label, String value, {Color? color}) { return Column( children: [ - Text(label, style: const TextStyle(color: ThemeConfig.textColorGrey, fontSize: 12)), + Text( + label, + style: const TextStyle( + color: ThemeConfig.textColorGrey, + fontSize: 12, + ), + ), Text( value, style: TextStyle( @@ -358,7 +386,10 @@ class InventoryScreen extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: 8.0), child: Row( children: [ - const Icon(Icons.attach_money, color: ThemeConfig.statGoldColor), + const Icon( + Icons.attach_money, + color: ThemeConfig.statGoldColor, + ), const SizedBox(width: 10), Text("Sell (${item.price} G)"), ], @@ -402,7 +433,9 @@ class InventoryScreen extends StatelessWidget { child: const Text("Cancel"), ), ElevatedButton( - style: ElevatedButton.styleFrom(backgroundColor: ThemeConfig.statGoldColor), + style: ElevatedButton.styleFrom( + backgroundColor: ThemeConfig.statGoldColor, + ), onPressed: () { provider.sellItem(item); Navigator.pop(ctx); @@ -430,7 +463,9 @@ class InventoryScreen extends StatelessWidget { child: const Text("Cancel"), ), ElevatedButton( - style: ElevatedButton.styleFrom(backgroundColor: ThemeConfig.btnActionActive), + style: ElevatedButton.styleFrom( + backgroundColor: ThemeConfig.btnActionActive, + ), onPressed: () { provider.discardItem(item); Navigator.pop(ctx); @@ -481,7 +516,10 @@ class InventoryScreen extends StatelessWidget { if (oldItem != null) Text( "Replaces ${oldItem.name}", - style: const TextStyle(fontSize: 12, color: ThemeConfig.textColorGrey), + style: const TextStyle( + fontSize: 12, + color: ThemeConfig.textColorGrey, + ), ), const SizedBox(height: 16), _buildStatChangeRow("Max HP", currentMaxHp, newMaxHp), @@ -575,7 +613,9 @@ class InventoryScreen extends StatelessWidget { int diff = newVal - oldVal; Color color = diff > 0 ? ThemeConfig.statDiffPositive - : (diff < 0 ? ThemeConfig.statDiffNegative : ThemeConfig.statDiffNeutral); + : (diff < 0 + ? ThemeConfig.statDiffNegative + : ThemeConfig.statDiffNeutral); String diffText = diff > 0 ? "(+$diff)" : (diff < 0 ? "($diff)" : ""); return Padding( @@ -586,8 +626,15 @@ class InventoryScreen extends StatelessWidget { Text(label), Row( children: [ - Text("$oldVal", style: const TextStyle(color: ThemeConfig.textColorGrey)), - const Icon(Icons.arrow_right, size: 16, color: ThemeConfig.textColorGrey), + Text( + "$oldVal", + style: const TextStyle(color: ThemeConfig.textColorGrey), + ), + const Icon( + Icons.arrow_right, + size: 16, + color: ThemeConfig.textColorGrey, + ), Text( "$newVal", style: const TextStyle(fontWeight: ThemeConfig.fontWeightBold), @@ -627,7 +674,10 @@ class InventoryScreen extends StatelessWidget { padding: const EdgeInsets.only(top: 2.0, bottom: 2.0), child: Text( stats.join(", "), - style: const TextStyle(fontSize: ThemeConfig.fontSizeSmall, color: ThemeConfig.statAtkColor), + style: const TextStyle( + fontSize: ThemeConfig.fontSizeSmall, + color: ThemeConfig.statAtkColor, + ), textAlign: TextAlign.center, ), ), @@ -636,7 +686,10 @@ class InventoryScreen extends StatelessWidget { padding: const EdgeInsets.only(bottom: 2.0), child: Text( effectTexts.join("\n"), - style: const TextStyle(fontSize: ThemeConfig.fontSizeTiny, color: ThemeConfig.rarityLegendary), + style: const TextStyle( + fontSize: ThemeConfig.fontSizeTiny, + color: ThemeConfig.rarityLegendary, + ), ), ), ], diff --git a/lib/utils/item_utils.dart b/lib/utils/item_utils.dart index ce1e48f..19212be 100644 --- a/lib/utils/item_utils.dart +++ b/lib/utils/item_utils.dart @@ -5,6 +5,8 @@ import '../game/config/theme_config.dart'; class ItemUtils { static Color getRarityColor(ItemRarity rarity) { switch (rarity) { + case ItemRarity.normal: + return ThemeConfig.rarityNormal; case ItemRarity.magic: return ThemeConfig.rarityMagic; case ItemRarity.rare: diff --git a/lib/widgets/stage/shop_ui.dart b/lib/widgets/stage/shop_ui.dart index 8dbcfed..c66e023 100644 --- a/lib/widgets/stage/shop_ui.dart +++ b/lib/widgets/stage/shop_ui.dart @@ -21,18 +21,6 @@ class ShopUI extends StatelessWidget { 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), @@ -139,6 +127,7 @@ class ShopUI extends StatelessWidget { width: 48, height: 48, fit: BoxFit.contain, + filterQuality: FilterQuality.high, ), ), ), @@ -154,7 +143,8 @@ class ShopUI extends StatelessWidget { color: ItemUtils.getRarityColor( item.rarity, ), - fontSize: ThemeConfig.fontSizeMedium, + fontSize: + ThemeConfig.fontSizeMedium, ), maxLines: 2, overflow: TextOverflow.ellipsis, @@ -215,10 +205,20 @@ class ShopUI extends StatelessWidget { ), ), onPressed: player.gold >= GameConfig.shopRerollCost - ? () => shopProvider.rerollShopItems( - player, - battleProvider.stage, - ) + ? () { + bool success = shopProvider.rerollShopItems( + player, + battleProvider.stage, + ); + if (!success) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Not enough gold to reroll!"), + backgroundColor: Colors.red, + ), + ); + } + } : null, icon: const Icon( Icons.refresh, @@ -287,8 +287,33 @@ class ShopUI extends StatelessWidget { backgroundColor: ThemeConfig.statGoldColor, ), onPressed: () { - shopProvider.buyItem(item, player); - Navigator.pop(ctx); + bool success = shopProvider.buyItem(item, player); + Navigator.pop(ctx); // Close dialog first + + if (success) { + // Refresh BattleProvider to update UI (Gold, Inventory) since player object is owned by BattleProvider + // and ShopProvider modifies it directly without BattleProvider knowing. + // Ideally, ShopProvider should notify, but since we don't have a direct link back or a shared PlayerProvider, + // we trigger it from the UI. + // Alternatively, we could add refreshUI to BattleProvider. + // Assuming BattleProvider has refreshUI or we can just use notifyListeners if we had access, but we don't. + // Wait, we have battleProvider instance passed to ShopUI. + battleProvider.refreshUI(); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text("Bought ${item.name}"), + backgroundColor: Colors.green, + ), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(shopProvider.lastShopMessage), + backgroundColor: Colors.red, + ), + ); + } }, child: const Text("Buy", style: TextStyle(color: Colors.black)), ), diff --git a/prompt/00_project_context_restore.md b/prompt/00_project_context_restore.md index 6ca2720..81a1ac5 100644 --- a/prompt/00_project_context_restore.md +++ b/prompt/00_project_context_restore.md @@ -73,8 +73,7 @@ ### E. 스테이지 시스템 (`StageModel`) - **타입:** Battle, Shop, Rest, Elite. -- **적 생성:** 스테이지 레벨에 따른 스탯 스케일링 적용. -- **적 등장 테이블 (Enemy Pull):** 적 조우 시, 현재 스테이지(라운드)에 따라 등장 가능한 적 목록(`enemy_table_pull`)을 설정하여 해당 범위 내에서 적을 랜덤 생성해야 함. +- **적 등장 테이블 (Enemy Pool):** 적 조우 시, 현재 스테이지(라운드)에 따라 등장 가능한 적 목록(`enemy_table_pool`)을 설정하여 해당 범위 내에서 적을 랜덤 생성해야 함. - **게임 구조 (Game Structure):** - **총 3라운드 (3 Rounds):** 각 라운드는 12개의 스테이지로 구성 (12/12/12). - **라운드 구성:** @@ -115,7 +114,7 @@ - **Prompt Driven Development:** `prompt/XX_description.md` 유지. - **유사 작업 통합:** 작업 내용이 이전 프롬프트와 유사한 경우 새로운 프롬프트를 생성하지 않고 기존 프롬프트에 내용을 추가합니다. -- **Language:** **모든 프롬프트 파일(prompt/XX_...)은 반드시 한국어(Korean)로 작성해야 합니다.** +- **Language:** **모든 프롬프트 파일(prompt/XX\_...)은 반드시 한국어(Korean)로 작성해야 합니다.** - **Config Management:** 하드코딩되는 값들은 `config` 폴더 내 파일들(`lib/game/config/` 등)에서 통합 관리할 수 있도록 작성해야 합니다. - **State Management:** `Provider` + `Stream` (이벤트성 데이터). - **Data:** JSON 기반. @@ -159,4 +158,10 @@ - [x] 47_inventory_full_handling.md - [x] 48_refactor_stage_ui.md - [x] 49_implement_item_icons.md - +- [x] 50_expand_item_pool.md +- [x] 51_refactor_prefix_table.md +- [x] 52_round_based_enemy_pool.md +- [x] 53_refine_stage_rewards.md +- [x] 54_fix_shop_logic.md +- [x] 55_fix_shop_ui_sync.md +- [x] 56_permadeath_implementation.md diff --git a/prompt/49_implement_item_icons.md b/prompt/49_implement_item_icons.md index a782293..1cef87f 100644 --- a/prompt/49_implement_item_icons.md +++ b/prompt/49_implement_item_icons.md @@ -25,6 +25,7 @@ - **`BattleScreen` (`lib/screens/battle_screen.dart`):** - 스테이지 클리어 보상 팝업의 아이템 아이콘 교체. - **플로팅 액션 버튼(FAB):** ATK/DEF 버튼의 아이콘을 `icon_weapon.png`와 `icon_shield.png`로 교체하고 흰색 틴트 적용. +- **고해상도 이미지 처리:** 모든 `Image.asset` 위젯에 `filterQuality: FilterQuality.high`를 적용하여 이미지 축소 시 발생하는 앨리어싱(깨짐) 현상 완화. ## 3. 결과 (Result) - 게임 내 아이템 표현이 단순 기호에서 구체적인 이미지로 변경되어 시각적 몰입도가 향상되었습니다. diff --git a/prompt/50_expand_item_pool.md b/prompt/50_expand_item_pool.md new file mode 100644 index 0000000..756b8e6 --- /dev/null +++ b/prompt/50_expand_item_pool.md @@ -0,0 +1,31 @@ +# 53. 아이템 풀 확장 및 접두사 시스템 (Item Pool Expansion & Prefix System) + +## 1. 목표 (Goal) +- 아이템 풀을 다양화하기 위해 `ItemRarity.normal`(일반 등급)을 추가합니다. +- 일반 등급 아이템의 드랍 확률을 가장 높게 설정하고, 새로운 무기 데이터(Short Bow, Long Sword)를 추가합니다. +- 일반 등급 아이템 생성 시 확률적으로 접두사(Crude, Old, High-quality)를 부여하여 스탯이 변동되는 시스템을 구현합니다. + +## 2. 구현 상세 (Implementation Details) + +### Enum 및 설정 업데이트 +- **`ItemRarity`:** `normal` 등급 추가 (가장 낮은 등급). +- **`ThemeConfig` & `ItemUtils`:** `normal` 등급의 색상(흰색) 매핑 추가. +- **`ItemConfig`:** `defaultRarityWeights`를 수정하여 Normal 등급이 가장 높은 확률(50%)을 가지도록 조정. + +### 데이터 추가 (`items.json`) +- **신규 무기:** + - `short_bow` (Tier 1, Normal) + - `long_sword` (Tier 2, Normal) + +### 로직 구현 (`ItemTemplate`) +- **`createItem` 메서드 수정:** + - **Normal 등급 로직:** 0~100 주사위를 굴려 접두사 부여. + - **0-25 (Crude/조잡한):** 이름에 "Crude " 추가, 모든 스탯 -10%. + - **26-50 (Old/낡은):** 이름에 "Old " 추가, 모든 스탯 -5%. + - **51-75 (Base):** 변동 없음. + - **76-100 (High-quality/상급):** 이름에 "High-quality " 추가, 모든 스탯 +10%. + - 스탯 계산 시 `GameMath` 또는 `floor`를 사용하여 정수형 유지. + +## 3. 결과 (Result) +- 초반부 아이템 획득의 다양성이 증가하고, 같은 아이템이라도 접두사에 따라 성능 차이가 발생하여 파밍의 재미가 추가되었습니다. +- `normal` 등급 아이템이 자주 등장하여 기본적인 장비 수급이 원활해졌습니다. diff --git a/prompt/51_refactor_prefix_table.md b/prompt/51_refactor_prefix_table.md new file mode 100644 index 0000000..b02bb4f --- /dev/null +++ b/prompt/51_refactor_prefix_table.md @@ -0,0 +1,27 @@ +# 54. 접두사 테이블 리팩토링 (Prefix Table Refactoring) + +## 1. 목표 (Goal) +- `ItemTable`에 하드코딩되어 있던 Normal 등급 접두사 로직(이름, 배율, 확률)을 `ItemPrefixTable`로 이동하여 데이터 기반으로 관리합니다. +- `ItemModifier` 클래스를 확장하여 스탯 배율(`multiplier`)과 가중치(`weight`)를 지원하도록 개선합니다. + +## 2. 구현 상세 (Implementation Details) + +### `ItemPrefixTable` 개선 +- **`ItemModifier` 구조 변경:** + - `multiplier`: 퍼센트 기반 스탯 변경을 위한 필드 추가 (기본값 1.0). + - `weight`: 랜덤 선택 가중치를 위한 필드 추가 (기본값 1). +- **`normalPrefixes` 데이터 추가:** + - Crude (0.9, weight 25) + - Old (0.95, weight 25) + - Standard (1.0, weight 25, empty prefix) + - High-quality (1.1, weight 25) + +### `ItemTable` 로직 수정 +- **`createItem` 메서드:** + - 하드코딩된 `if-else` 확률 로직을 제거. + - `ItemPrefixTable.normalPrefixes`를 사용하여 가중치 기반 랜덤 선택(Weighted Random Selection) 알고리즘 구현. + - 선택된 Modifier의 `multiplier`를 적용하여 스탯 계산. + +## 3. 결과 (Result) +- 접두사 데이터 추가 및 밸런스 조정이 `ItemPrefixTable` 수정만으로 가능해졌습니다. +- 코드 중복이 줄어들고 확장성이 향상되었습니다. diff --git a/prompt/52_round_based_enemy_pool.md b/prompt/52_round_based_enemy_pool.md new file mode 100644 index 0000000..11e51f6 --- /dev/null +++ b/prompt/52_round_based_enemy_pool.md @@ -0,0 +1,27 @@ +# 52. 라운드별 적 등장 시스템 (Round-based Enemy Pool) + +## 1. 목표 (Goal) +- 게임 진행도(라운드)에 따라 등장하는 적들을 다르게 설정하여 난이도 곡선을 구현합니다. +- `enemies.json` 데이터에 `tier` 필드를 추가하고, 스테이지에 맞는 적을 소환하는 로직을 `EnemyTable`에 구현합니다. + +## 2. 구현 상세 (Implementation Details) + +### 데이터 구조 변경 (`enemies.json` & `EnemyTemplate`) +- **JSON:** `tier` 필드 추가 (1, 2, 3). + - **Tier 1:** Goblin, Slime, Wolf, Orc Warrior(Elite). + - **Tier 2:** Bandit, Skeleton, Giant Spider(Elite). + - **Tier 3:** Shadow Assassin, Armored Bear, Dark Knight(Elite). +- **Template:** `tier` 필드를 파싱하고 저장하도록 클래스 업데이트. + +### 로직 구현 (`EnemyTable`) +- **`getRandomEnemy(int stage)` 메서드 추가:** + - 현재 스테이지에 따라 목표 Tier를 결정 (1: 1~12, 2: 13~24, 3: 25+). + - 해당 Tier의 적 목록에서 랜덤 선택. + - 해당 Tier의 적이 없을 경우 하위 Tier 또는 전체 풀에서 선택하는 폴백 로직 포함. + +### 전투 연동 (`BattleProvider`) +- `_prepareNextStage`에서 적 생성 시 `EnemyTable.getRandomEnemy`를 호출하여 스테이지에 적합한 적이 등장하도록 변경. + +## 3. 결과 (Result) +- 게임 초반에는 약한 적, 후반에는 강한 적이 등장하여 단계적인 난이도 상승을 경험할 수 있습니다. +- 데이터 주도적으로 적의 등장 시기를 제어할 수 있게 되었습니다. diff --git a/prompt/53_refine_stage_rewards.md b/prompt/53_refine_stage_rewards.md new file mode 100644 index 0000000..2368847 --- /dev/null +++ b/prompt/53_refine_stage_rewards.md @@ -0,0 +1,24 @@ +# 55. 스테이지 보상 로직 개선 (Refine Stage Reward Logic) + +## 1. 목표 (Goal) +- 스테이지 티어(1~3) 및 타입(일반/엘리트)에 따라 아이템 보상의 희귀도(`Rarity`)를 조정하여 밸런스를 맞춥니다. +- **Tier 1:** 일반 전투에서는 Rare 등급 이상 등장 불가 (최대 Magic). 엘리트 전투 승리 시 첫 번째 보상으로 Rare 등급 확정. +- **Tier 2/3:** 엘리트 전투 승리 시 첫 번째 보상으로 Legendary 등급 확정. + +## 2. 구현 상세 (Implementation Details) + +### `ItemTable` 개선 +- **`getRandomItem` 메서드 확장:** + - `minRarity`, `maxRarity` 선택적 매개변수 추가. + - 가중치 랜덤 선택 전에 희귀도 범위를 기반으로 후보군 및 가중치를 필터링하는 로직 구현. + +### `BattleProvider` 수정 +- **`_onVictory` 메서드 로직 변경:** + - 현재 스테이지의 `Tier`와 `isElite` 여부를 확인. + - **Tier 1 Normal:** `maxRarity`를 `Magic`으로 제한. + - **Tier 1 Elite:** 첫 번째 보상 생성 시 `minRarity`와 `maxRarity`를 `Rare`로 고정 (확정 Rare). + - **Tier 2/3 Elite:** 첫 번째 보상 생성 시 `minRarity`를 `Legendary`로 설정 (확정 Legendary). + - 나머지 보상 슬롯(2, 3번)은 해당 티어의 일반적인 규칙(Tier 1은 Magic 제한)을 따름. + +## 3. 결과 (Result) +- 초반(Tier 1)에 지나치게 강력한 아이템이 나오는 것을 방지하고, 엘리트 몬스터 처치에 대한 확실한 보상(확정 Rare/Legendary)을 제공하여 도전 욕구를 고취시켰습니다. diff --git a/prompt/54_fix_shop_logic.md b/prompt/54_fix_shop_logic.md new file mode 100644 index 0000000..7c583a7 --- /dev/null +++ b/prompt/54_fix_shop_logic.md @@ -0,0 +1,22 @@ +# 56. 상점 UI 및 로직 디버깅 (Shop UI & Logic Debugging) + +## 1. 목표 (Goal) +- 상점 구매 기능이 정상적으로 작동하지 않는 문제(성공 시에도 빨간색 에러 메시지 표시 등)를 해결합니다. +- `ShopUI`에서 `ShopProvider`와의 연동 로직을 개선하여 사용자 피드백(SnackBar)을 명확하게 만듭니다. + +## 2. 구현 상세 (Implementation Details) + +### `ShopUI` 수정 +- **구매 확인 다이얼로그 (`_showBuyConfirmation`):** + - 기존: `buyItem` 호출 후 결과 확인 없이 다이얼로그 닫음 + `ShopProvider`의 메시지 상태에 의존하여 `build` 메서드에서 스낵바 출력 (타이밍 이슈 및 색상 고정 문제 발생). + - **변경:** `shopProvider.buyItem(item, player)`의 `bool` 반환값을 직접 확인. + - **성공 (`true`):** 다이얼로그 닫고 **초록색** "Bought [Item Name]" 스낵바 출력. + - **실패 (`false`):** 다이얼로그 닫고 **빨간색** 에러 메시지(Provider의 `lastShopMessage`) 스낵바 출력. +- **불필요한 코드 제거:** `build` 메서드 내의 `WidgetsBinding.instance.addPostFrameCallback` 블록 삭제. + +### `ShopProvider` 확인 +- `buyItem` 메서드는 이미 성공/실패 여부를 `bool`로 반환하고, 실패 시 `_lastShopMessage`를 설정하도록 잘 구현되어 있음. (수정 불필요) + +## 3. 결과 (Result) +- 상점 아이템 구매 성공 시 정상적으로 초록색 메시지가 뜨고, 골드 부족이나 인벤토리 가득 참 등의 실패 시에는 빨간색 에러 메시지가 뜹니다. +- 구매 로직과 UI 피드백이 동기화되어 사용자 혼란을 방지했습니다. diff --git a/prompt/55_fix_shop_ui_sync.md b/prompt/55_fix_shop_ui_sync.md new file mode 100644 index 0000000..457d703 --- /dev/null +++ b/prompt/55_fix_shop_ui_sync.md @@ -0,0 +1,18 @@ +# 57. 상점 구매 UI 동기화 버그 수정 (Fix Shop Purchase UI Sync Bug) + +## 1. 목표 (Goal) +- 상점에서 아이템 구매 성공 시, 인벤토리 및 골드 상태 변화가 UI에 즉시 반영되지 않는 문제를 해결합니다. +- `ShopProvider`가 `BattleProvider` 소유의 `player` 객체를 수정했을 때, `BattleProvider`를 구독하는 위젯들이 갱신되도록 강제합니다. + +## 2. 구현 상세 (Implementation Details) + +### `BattleProvider` +- `refreshUI()` 메서드 추가: 단순히 `notifyListeners()`를 호출하여 `BattleProvider`의 상태 변경을 알리는 public 메서드. + +### `ShopUI` +- **`_showBuyConfirmation` 수정:** + - `shopProvider.buyItem` 호출 후 성공(`true`) 시, `battleProvider.refreshUI()`를 호출. + - 이를 통해 `InventoryScreen`이나 상단 바의 골드 표시 등 `BattleProvider`를 구독하는 모든 UI가 재빌드되어, 변경된 인벤토리와 골드 상태를 즉시 반영. + +## 3. 결과 (Result) +- 상점 구매 직후 인벤토리에 아이템이 정상적으로 표시되고, 소모된 골드가 UI에 즉시 업데이트됩니다. diff --git a/prompt/56_permadeath_implementation.md b/prompt/56_permadeath_implementation.md new file mode 100644 index 0000000..87b0fbd --- /dev/null +++ b/prompt/56_permadeath_implementation.md @@ -0,0 +1,21 @@ +# 58. 패배 시 저장 데이터 삭제 (Permadeath Implementation) + +## 1. 목표 (Goal) +- 로그라이크 장르 특성에 맞춰 플레이어 패배(사망) 시 저장된 진행 데이터를 즉시 삭제하여 영구적인 죽음(Permadeath)을 구현합니다. + +## 2. 구현 상세 (Implementation Details) + +### `BattleProvider` +- **`_onDefeat` 메서드 추가:** + - 비동기(`async`) 메서드. + - "Player defeated! Enemy wins!" 로그 추가. + - `SaveManager.clearSaveData()` 호출하여 저장 파일 삭제. + - `notifyListeners()` 호출하여 UI 갱신. +- **패배 조건 체크 추가:** + - `playerAction`: 턴 시작 시 상태이상(출혈 등)으로 인한 사망 체크. + - `_enemyTurn`: 적 공격 후 및 턴 종료 시 사망 체크. + - 사망 확인 시 `_onDefeat` 호출. + +## 3. 결과 (Result) +- 플레이어가 게임에서 패배하면 메인 메뉴로 돌아가더라도 '이어하기' 버튼이 활성화되지 않습니다(저장 데이터 삭제됨). +- 긴장감 있는 게임 플레이 환경이 조성되었습니다.