This commit is contained in:
Horoli 2025-12-07 13:44:51 +09:00
parent 30c84a48ac
commit d3fca333cb
29 changed files with 1437 additions and 137 deletions

View File

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

View File

@ -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<ItemRarity, int> defaultRarityWeights = {
ItemRarity.magic: 60,
ItemRarity.rare: 30,
ItemRarity.legendary: 9,
ItemRarity.unique: 1,
};
}

View File

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

View File

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

View File

@ -0,0 +1,101 @@
import '../enums.dart';
class ItemModifier {
final String prefix;
final Map<StatType, int> statChanges;
final List<EquipmentSlot>? allowedSlots; // Null means allowed for all slots
const ItemModifier({
required this.prefix,
required this.statChanges,
this.allowedSlots,
});
}
class ItemPrefixTable {
static const List<ItemModifier> 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<ItemModifier> 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<String> rareNames = [
"Earthshatter", "Soulrender", "Widowmaker", "Lightbringer"
];
}

View File

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

View File

@ -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<String> _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<String> _weaponNouns = [
"Fang", "Claw", "Reaper", "Breaker", "Slayer", "Edge",
"Blade", "Spike", "Crusher", "Whisper", "Howl", "Strike",
"Bane", "Fury", "Vengeance", "Thorn"
];
// Nouns specifically for Armor
static const List<String> _armorNouns = [
"Guard", "Wall", "Shelter", "Skin", "Scale", "Plate",
"Bulwark", "Veil", "Shroud", "Ward", "Barrier", "Bastion",
"Mantle", "Aegis", "Carapace"
];
// Nouns specifically for Shields
static const List<String> _shieldNouns = [
"Wall", "Barrier", "Aegis", "Defender", "Blockade",
"Resolve", "Sanctuary", "Buckler", "Tower", "Gate"
];
// Nouns specifically for Accessories
static const List<String> _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";
}
}

View File

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

View File

@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic>;
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<dynamic>;
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) {

View File

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

View File

@ -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<String, dynamic> toJson() {
return {
'type': type.name,
'duration': duration,
'value': value,
};
}
factory StatusEffect.fromJson(Map<String, dynamic> json) {
return StatusEffect(
type: StatusEffectType.values.firstWhere((e) => e.name == json['type']),
duration: json['duration'],
value: json['value'],
);
}
}

View File

@ -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<void> 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<Map<String, dynamic>?> 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<bool> hasSaveData() async {
final prefs = await SharedPreferences.getInstance();
return prefs.containsKey(_saveKey);
}
static Future<void> clearSaveData() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_saveKey);
}
}

View File

@ -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<Item> rewardOptions = [];
bool showRewardPopup = false;
int _lastGoldReward = 0; // New: Stores gold gained from last victory
List<String> get logs => battleLogs;
int get lastGoldReward => _lastGoldReward;
// Damage Event Stream
final _damageEventController = StreamController<DamageEvent>.broadcast();
@ -69,6 +73,18 @@ class BattleProvider with ChangeNotifier {
super.dispose();
}
void loadFromSave(Map<String, dynamic> 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<ItemTemplate> 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<Item> _generateShopItems() {
ItemTier currentTier = ItemTier.tier1;
if (stage > 24)
currentTier = ItemTier.tier3;
else if (stage > 12)
currentTier = ItemTier.tier2;
List<Item> items = [];
for (int i = 0; i < 4; i++) {
ItemTemplate? template = ItemTable.getRandomItem(tier: currentTier);
if (template != null) {
items.add(template.createItem(stage: stage));
}
}
return items;
}
void rerollShopItems() {
const int rerollCost = 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<ItemTemplate> 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<Item>.
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

View File

@ -538,8 +538,29 @@ class _BattleScreenState extends State<BattleScreen> {
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<BattleScreen> {
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(

View File

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

View File

@ -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<MainMenuScreen> createState() => _MainMenuScreenState();
}
class _MainMenuScreenState extends State<MainMenuScreen> {
bool _hasSave = false;
bool _isLoading = true;
@override
void initState() {
super.initState();
_checkSaveData();
}
Future<void> _checkSaveData() async {
final hasSave = await SaveManager.hasSaveData();
if (mounted) {
setState(() {
_hasSave = hasSave;
_isLoading = false;
});
}
}
Future<void> _continueGame() async {
final data = await SaveManager.loadGame();
if (data != null && mounted) {
context.read<BattleProvider>().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"),
),
],
),
),
),
);

View File

@ -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<MainWrapper> {
int _currentIndex = 0;
final List<Widget> _screens = [const BattleScreen(), const InventoryScreen()];
final List<Widget> _screens = [
const BattleScreen(),
const InventoryScreen(),
const SettingsScreen(),
];
@override
Widget build(BuildContext context) {
@ -39,6 +44,10 @@ class _MainWrapperState extends State<MainWrapper> {
icon: Icon(Icons.backpack),
label: 'Inventory',
),
BottomNavigationBarItem(
icon: Icon(Icons.settings),
label: 'Settings',
),
],
),
),

View File

@ -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<BattleProvider>().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)),
),
],
),
);
}
}

View File

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

View File

@ -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<String> stats = [];
if (item.atkBonus > 0) stats.add("ATK +${item.atkBonus}");
if (item.hpBonus > 0) stats.add("HP +${item.hpBonus}");
if (item.armorBonus > 0) stats.add("DEF +${item.armorBonus}");
if (item.luck > 0) stats.add("LUCK +${item.luck}");
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (stats.isNotEmpty)
Text(
stats.join(", "),
style: const TextStyle(fontSize: 10, color: Colors.white70),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (item.effects.isNotEmpty)
Text(
item.effects.first.type.name.toUpperCase(),
style: const TextStyle(fontSize: 9, color: Colors.orangeAccent),
textAlign: TextAlign.center,
),
],
);
}
}
class RestUI extends StatelessWidget {

View File

@ -5,6 +5,8 @@
import FlutterMacOS
import Foundation
import shared_preferences_foundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
}

View File

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

View File

@ -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`) 업데이트 및 검증 완료.

31
prompt/43_shop_system.md Normal file
View File

@ -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번째 스테이지**마다 등장하도록 복구.

View File

@ -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 데이터로부터 상태 복구.

View File

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

View File

@ -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();
});

View File

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

View File

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