This commit is contained in:
Horoli 2025-12-07 17:58:00 +09:00
parent d5609aff0f
commit dcfb8ab9de
22 changed files with 612 additions and 165 deletions

View File

@ -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
}
]
}

View File

@ -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",

View File

@ -5,9 +5,10 @@ class ItemConfig {
/// Used when selecting random items in Shop or Rewards.
/// Higher weight = Higher chance.
static const Map<ItemRarity, int> 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,
};
}

View File

@ -44,7 +44,7 @@ class ThemeConfig {
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;

View File

@ -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<String> 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<String, dynamic> json) {
@ -29,6 +33,7 @@ class EnemyTemplate {
baseDefense: json['baseDefense'] ?? 0,
image: json['image'],
equipmentIds: (json['equipment'] as List<dynamic>?)?.cast<String>() ?? [],
tier: json['tier'] ?? 1,
);
}
@ -63,6 +68,7 @@ class EnemyTemplate {
class EnemyTable {
static List<EnemyTemplate> normalEnemies = [];
static List<EnemyTemplate> eliteEnemies = [];
static final Random _random = Random();
static Future<void> 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<EnemyTemplate> 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)];
}
}

View File

@ -4,15 +4,26 @@ class ItemModifier {
final String prefix;
final Map<StatType, int> statChanges;
final List<EquipmentSlot>? 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<ItemModifier> 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<ItemModifier> magicPrefixes = [
// Weapons
ItemModifier(

View File

@ -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<ItemRarity, int>? 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<ItemRarity, int> 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)];
}
}

View File

@ -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 }

View File

@ -57,6 +57,10 @@ class BattleProvider with ChangeNotifier {
List<String> get logs => battleLogs;
int get lastGoldReward => _lastGoldReward;
void refreshUI() {
notifyListeners();
}
// Damage Event Stream
final _damageEventController = StreamController<DamageEvent>.broadcast();
Stream<DamageEvent> get damageStream => _damageEventController.stream;
@ -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<void> _onDefeat() async {
_addLog("Player defeated! Enemy wins!");
await SaveManager.clearSaveData();
notifyListeners();
}
/// Handle player's action choice
Future<void> 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<void> _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<ItemTemplate> 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();

View File

@ -601,6 +601,7 @@ class _BattleScreenState extends State<BattleScreen> {
width: 24,
height: 24,
fit: BoxFit.contain,
filterQuality: FilterQuality.high,
),
),
if (!isSkip) const SizedBox(width: 12),
@ -755,6 +756,7 @@ class _BattleScreenState extends State<BattleScreen> {
height: 32,
color: ThemeConfig.textColorWhite, // Tint icon white
fit: BoxFit.contain,
filterQuality: FilterQuality.high,
),
);
}

View File

@ -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,
),
),
),
],

View File

@ -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:

View File

@ -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)),
),

View File

@ -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

View File

@ -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)
- 게임 내 아이템 표현이 단순 기호에서 구체적인 이미지로 변경되어 시각적 몰입도가 향상되었습니다.

View File

@ -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` 등급 아이템이 자주 등장하여 기본적인 장비 수급이 원활해졌습니다.

View File

@ -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` 수정만으로 가능해졌습니다.
- 코드 중복이 줄어들고 확장성이 향상되었습니다.

View File

@ -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)
- 게임 초반에는 약한 적, 후반에는 강한 적이 등장하여 단계적인 난이도 상승을 경험할 수 있습니다.
- 데이터 주도적으로 적의 등장 시기를 제어할 수 있게 되었습니다.

View File

@ -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)을 제공하여 도전 욕구를 고취시켰습니다.

View File

@ -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 피드백이 동기화되어 사용자 혼란을 방지했습니다.

View File

@ -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에 즉시 업데이트됩니다.

View File

@ -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)
- 플레이어가 게임에서 패배하면 메인 메뉴로 돌아가더라도 '이어하기' 버튼이 활성화되지 않습니다(저장 데이터 삭제됨).
- 긴장감 있는 게임 플레이 환경이 조성되었습니다.