update
This commit is contained in:
parent
30c84a48ac
commit
d3fca333cb
|
|
@ -7,7 +7,9 @@
|
||||||
"baseAtk": 3,
|
"baseAtk": 3,
|
||||||
"slot": "weapon",
|
"slot": "weapon",
|
||||||
"price": 30,
|
"price": 30,
|
||||||
"image": "assets/images/items/rusty_dagger.png"
|
"image": "assets/images/items/rusty_dagger.png",
|
||||||
|
"rarity": "magic",
|
||||||
|
"tier": "tier1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "iron_sword",
|
"id": "iron_sword",
|
||||||
|
|
@ -16,7 +18,9 @@
|
||||||
"baseAtk": 8,
|
"baseAtk": 8,
|
||||||
"slot": "weapon",
|
"slot": "weapon",
|
||||||
"price": 80,
|
"price": 80,
|
||||||
"image": "assets/images/items/iron_sword.png"
|
"image": "assets/images/items/iron_sword.png",
|
||||||
|
"rarity": "magic",
|
||||||
|
"tier": "tier2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "battle_axe",
|
"id": "battle_axe",
|
||||||
|
|
@ -25,7 +29,9 @@
|
||||||
"baseAtk": 12,
|
"baseAtk": 12,
|
||||||
"slot": "weapon",
|
"slot": "weapon",
|
||||||
"price": 120,
|
"price": 120,
|
||||||
"image": "assets/images/items/battle_axe.png"
|
"image": "assets/images/items/battle_axe.png",
|
||||||
|
"rarity": "magic",
|
||||||
|
"tier": "tier2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "stunning_hammer",
|
"id": "stunning_hammer",
|
||||||
|
|
@ -41,7 +47,9 @@
|
||||||
"probability": 20,
|
"probability": 20,
|
||||||
"duration": 1
|
"duration": 1
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"rarity": "rare",
|
||||||
|
"tier": "tier2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "jagged_dagger",
|
"id": "jagged_dagger",
|
||||||
|
|
@ -58,7 +66,9 @@
|
||||||
"duration": 3,
|
"duration": 3,
|
||||||
"value": 30
|
"value": 30
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"rarity": "rare",
|
||||||
|
"tier": "tier1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "sunderer_axe",
|
"id": "sunderer_axe",
|
||||||
|
|
@ -74,7 +84,9 @@
|
||||||
"probability": 100,
|
"probability": 100,
|
||||||
"duration": 2
|
"duration": 2
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"rarity": "legendary",
|
||||||
|
"tier": "tier3"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"armors": [
|
"armors": [
|
||||||
|
|
@ -85,7 +97,9 @@
|
||||||
"baseHp": 10,
|
"baseHp": 10,
|
||||||
"slot": "armor",
|
"slot": "armor",
|
||||||
"price": 20,
|
"price": 20,
|
||||||
"image": "assets/images/items/torn_tunic.png"
|
"image": "assets/images/items/torn_tunic.png",
|
||||||
|
"rarity": "magic",
|
||||||
|
"tier": "tier1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "leather_vest",
|
"id": "leather_vest",
|
||||||
|
|
@ -94,7 +108,9 @@
|
||||||
"baseHp": 30,
|
"baseHp": 30,
|
||||||
"slot": "armor",
|
"slot": "armor",
|
||||||
"price": 60,
|
"price": 60,
|
||||||
"image": "assets/images/items/leather_vest.png"
|
"image": "assets/images/items/leather_vest.png",
|
||||||
|
"rarity": "magic",
|
||||||
|
"tier": "tier2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "chainmail",
|
"id": "chainmail",
|
||||||
|
|
@ -103,7 +119,9 @@
|
||||||
"baseHp": 60,
|
"baseHp": 60,
|
||||||
"slot": "armor",
|
"slot": "armor",
|
||||||
"price": 120,
|
"price": 120,
|
||||||
"image": "assets/images/items/chainmail.png"
|
"image": "assets/images/items/chainmail.png",
|
||||||
|
"rarity": "magic",
|
||||||
|
"tier": "tier3"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"shields": [
|
"shields": [
|
||||||
|
|
@ -114,7 +132,9 @@
|
||||||
"baseArmor": 1,
|
"baseArmor": 1,
|
||||||
"slot": "shield",
|
"slot": "shield",
|
||||||
"price": 10,
|
"price": 10,
|
||||||
"image": "assets/images/items/pot_lid.png"
|
"image": "assets/images/items/pot_lid.png",
|
||||||
|
"rarity": "magic",
|
||||||
|
"tier": "tier1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "wooden_shield",
|
"id": "wooden_shield",
|
||||||
|
|
@ -123,7 +143,9 @@
|
||||||
"baseArmor": 3,
|
"baseArmor": 3,
|
||||||
"slot": "shield",
|
"slot": "shield",
|
||||||
"price": 40,
|
"price": 40,
|
||||||
"image": "assets/images/items/wooden_shield.png"
|
"image": "assets/images/items/wooden_shield.png",
|
||||||
|
"rarity": "magic",
|
||||||
|
"tier": "tier1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "kite_shield",
|
"id": "kite_shield",
|
||||||
|
|
@ -132,7 +154,9 @@
|
||||||
"baseArmor": 6,
|
"baseArmor": 6,
|
||||||
"slot": "shield",
|
"slot": "shield",
|
||||||
"price": 100,
|
"price": 100,
|
||||||
"image": "assets/images/items/kite_shield.png"
|
"image": "assets/images/items/kite_shield.png",
|
||||||
|
"rarity": "magic",
|
||||||
|
"tier": "tier2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "cursed_shield",
|
"id": "cursed_shield",
|
||||||
|
|
@ -148,7 +172,9 @@
|
||||||
"probability": 100,
|
"probability": 100,
|
||||||
"duration": 999
|
"duration": 999
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"rarity": "legendary",
|
||||||
|
"tier": "tier2"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"accessories": [
|
"accessories": [
|
||||||
|
|
@ -161,7 +187,9 @@
|
||||||
"slot": "accessory",
|
"slot": "accessory",
|
||||||
"price": 25,
|
"price": 25,
|
||||||
"image": "assets/images/items/old_ring.png",
|
"image": "assets/images/items/old_ring.png",
|
||||||
"luck": 5
|
"luck": 5,
|
||||||
|
"rarity": "magic",
|
||||||
|
"tier": "tier1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "copper_ring",
|
"id": "copper_ring",
|
||||||
|
|
@ -172,7 +200,9 @@
|
||||||
"slot": "accessory",
|
"slot": "accessory",
|
||||||
"price": 25,
|
"price": 25,
|
||||||
"image": "assets/images/items/copper_ring.png",
|
"image": "assets/images/items/copper_ring.png",
|
||||||
"luck": 3
|
"luck": 3,
|
||||||
|
"rarity": "magic",
|
||||||
|
"tier": "tier1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "ruby_amulet",
|
"id": "ruby_amulet",
|
||||||
|
|
@ -183,7 +213,9 @@
|
||||||
"slot": "accessory",
|
"slot": "accessory",
|
||||||
"price": 80,
|
"price": 80,
|
||||||
"image": "assets/images/items/ruby_amulet.png",
|
"image": "assets/images/items/ruby_amulet.png",
|
||||||
"luck": 7
|
"luck": 7,
|
||||||
|
"rarity": "rare",
|
||||||
|
"tier": "tier2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "heros_badge",
|
"id": "heros_badge",
|
||||||
|
|
@ -195,7 +227,9 @@
|
||||||
"slot": "accessory",
|
"slot": "accessory",
|
||||||
"price": 150,
|
"price": 150,
|
||||||
"image": "assets/images/items/heros_badge.png",
|
"image": "assets/images/items/heros_badge.png",
|
||||||
"luck": 10
|
"luck": 10,
|
||||||
|
"rarity": "legendary",
|
||||||
|
"tier": "tier3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "lucky_charm",
|
"id": "lucky_charm",
|
||||||
|
|
@ -206,7 +240,9 @@
|
||||||
"slot": "accessory",
|
"slot": "accessory",
|
||||||
"price": 200,
|
"price": 200,
|
||||||
"image": "assets/images/items/lucky_charm.png",
|
"image": "assets/images/items/lucky_charm.png",
|
||||||
"luck": 25
|
"luck": 25,
|
||||||
|
"rarity": "unique",
|
||||||
|
"tier": "tier3"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -33,4 +33,10 @@ class ThemeConfig {
|
||||||
// Status Effect Colors
|
// Status Effect Colors
|
||||||
static const Color effectBg = Colors.deepOrange;
|
static const Color effectBg = Colors.deepOrange;
|
||||||
static const Color effectText = Colors.white;
|
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,16 +33,14 @@ class EnemyTemplate {
|
||||||
}
|
}
|
||||||
|
|
||||||
Character createCharacter({int stage = 1}) {
|
Character createCharacter({int stage = 1}) {
|
||||||
// Simple additive scaling
|
// Stage-based scaling for enemy stats is removed to simplify balancing.
|
||||||
int scaledHp = baseHp + (stage - 1) * 5;
|
// Enemy stats are now fixed as defined in the EnemyTemplate.
|
||||||
int scaledAtk = baseAtk + (stage - 1);
|
|
||||||
int scaledDefense = baseDefense + (stage ~/ 5); // +1 defense every 5 stages
|
|
||||||
|
|
||||||
final character = Character(
|
final character = Character(
|
||||||
name: name,
|
name: name,
|
||||||
maxHp: scaledHp,
|
maxHp: baseHp,
|
||||||
atk: scaledAtk,
|
atk: baseAtk,
|
||||||
baseDefense: scaledDefense,
|
baseDefense: baseDefense,
|
||||||
armor: 0,
|
armor: 0,
|
||||||
image: image,
|
image: image,
|
||||||
);
|
);
|
||||||
|
|
@ -51,9 +49,8 @@ class EnemyTemplate {
|
||||||
for (final itemId in equipmentIds) {
|
for (final itemId in equipmentIds) {
|
||||||
final itemTemplate = ItemTable.get(itemId);
|
final itemTemplate = ItemTable.get(itemId);
|
||||||
if (itemTemplate != null) {
|
if (itemTemplate != null) {
|
||||||
// Create item scaled to stage (optional, currently stage 1)
|
// Items no longer scale by stage, pass no stage parameter
|
||||||
// Enemies might get stronger items at higher stages
|
final item = itemTemplate.createItem();
|
||||||
final item = itemTemplate.createItem(stage: stage);
|
|
||||||
character.addToInventory(item);
|
character.addToInventory(item);
|
||||||
character.equip(item);
|
character.equip(item);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:math';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import '../model/item.dart';
|
import '../model/item.dart';
|
||||||
import '../enums.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 {
|
class ItemTemplate {
|
||||||
final String id;
|
final String id;
|
||||||
|
|
@ -15,6 +19,8 @@ class ItemTemplate {
|
||||||
final int price;
|
final int price;
|
||||||
final String? image;
|
final String? image;
|
||||||
final int luck;
|
final int luck;
|
||||||
|
final ItemRarity rarity;
|
||||||
|
final ItemTier tier;
|
||||||
|
|
||||||
const ItemTemplate({
|
const ItemTemplate({
|
||||||
required this.id,
|
required this.id,
|
||||||
|
|
@ -28,6 +34,8 @@ class ItemTemplate {
|
||||||
required this.price,
|
required this.price,
|
||||||
this.image,
|
this.image,
|
||||||
this.luck = 0,
|
this.luck = 0,
|
||||||
|
this.rarity = ItemRarity.magic,
|
||||||
|
this.tier = ItemTier.tier1,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory ItemTemplate.fromJson(Map<String, dynamic> json) {
|
factory ItemTemplate.fromJson(Map<String, dynamic> json) {
|
||||||
|
|
@ -50,27 +58,99 @@ class ItemTemplate {
|
||||||
price: json['price'] ?? 10,
|
price: json['price'] ?? 10,
|
||||||
image: json['image'],
|
image: json['image'],
|
||||||
luck: json['luck'] ?? 0,
|
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}) {
|
Item createItem({int stage = 1}) {
|
||||||
// Scale stats based on stage
|
// Stage-based scaling is removed.
|
||||||
int scaledAtk = (atkBonus * (1 + (stage - 1) * 0.1)).toInt();
|
// Apply Prefix Logic based on Rarity.
|
||||||
int scaledHp = (hpBonus * (1 + (stage - 1) * 0.1)).toInt();
|
|
||||||
int scaledArmor = (armorBonus * (1 + (stage - 1) * 0.1)).toInt();
|
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(
|
return Item(
|
||||||
id: id,
|
id: id,
|
||||||
name: "$name${stage > 1 ? ' +${stage - 1}' : ''}",
|
name: finalName,
|
||||||
description: description,
|
description: description,
|
||||||
atkBonus: scaledAtk,
|
atkBonus: finalAtk,
|
||||||
hpBonus: scaledHp,
|
hpBonus: finalHp,
|
||||||
armorBonus: scaledArmor,
|
armorBonus: finalArmor,
|
||||||
slot: slot,
|
slot: slot,
|
||||||
effects: effects,
|
effects: effects,
|
||||||
price: price,
|
price: price,
|
||||||
image: image,
|
image: image,
|
||||||
luck: luck,
|
luck: finalLuck,
|
||||||
|
rarity: rarity,
|
||||||
|
tier: tier,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -115,4 +195,59 @@ class ItemTable {
|
||||||
return null;
|
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)];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -33,4 +33,8 @@ enum EquipmentSlot { weapon, armor, shield, accessory }
|
||||||
|
|
||||||
enum DamageType { normal, bleed, vulnerable }
|
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 }
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'item.dart';
|
||||||
import 'status_effect.dart';
|
import 'status_effect.dart';
|
||||||
import 'stat_modifier.dart';
|
import 'stat_modifier.dart';
|
||||||
import '../enums.dart';
|
import '../enums.dart';
|
||||||
|
import '../data/item_table.dart';
|
||||||
|
|
||||||
class Character {
|
class Character {
|
||||||
String name;
|
String name;
|
||||||
|
|
@ -36,6 +37,77 @@ class Character {
|
||||||
baseAtk = atk,
|
baseAtk = atk,
|
||||||
hp = hp ?? maxHp;
|
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.
|
/// 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.
|
/// For now, we'll implement a simple refresh/overwrite logic.
|
||||||
void addStatusEffect(StatusEffect newEffect) {
|
void addStatusEffect(StatusEffect newEffect) {
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,8 @@ class Item {
|
||||||
final int price; // New: Sell/Buy value
|
final int price; // New: Sell/Buy value
|
||||||
final String? image; // New: Image path
|
final String? image; // New: Image path
|
||||||
final int luck; // Success rate bonus (e.g. 5 = 5%)
|
final int luck; // Success rate bonus (e.g. 5 = 5%)
|
||||||
|
final ItemRarity rarity;
|
||||||
|
final ItemTier tier;
|
||||||
|
|
||||||
const Item({
|
const Item({
|
||||||
required this.id,
|
required this.id,
|
||||||
|
|
@ -60,6 +62,8 @@ class Item {
|
||||||
this.price = 0,
|
this.price = 0,
|
||||||
this.image,
|
this.image,
|
||||||
this.luck = 0,
|
this.luck = 0,
|
||||||
|
this.rarity = ItemRarity.magic,
|
||||||
|
this.tier = ItemTier.tier1,
|
||||||
});
|
});
|
||||||
|
|
||||||
String get typeName {
|
String get typeName {
|
||||||
|
|
|
||||||
|
|
@ -6,4 +6,20 @@ class StatusEffect {
|
||||||
final int value; // Intensity (e.g., bleed damage amount)
|
final int value; // Intensity (e.g., bleed damage amount)
|
||||||
|
|
||||||
StatusEffect({required this.type, required this.duration, this.value = 0});
|
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'],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,8 @@ import '../game/enums.dart';
|
||||||
import '../game/model/damage_event.dart'; // DamageEvent import
|
import '../game/model/damage_event.dart'; // DamageEvent import
|
||||||
import '../game/model/effect_event.dart'; // EffectEvent import
|
import '../game/model/effect_event.dart'; // EffectEvent import
|
||||||
|
|
||||||
|
import '../game/save_manager.dart';
|
||||||
|
|
||||||
class EnemyIntent {
|
class EnemyIntent {
|
||||||
final EnemyActionType type;
|
final EnemyActionType type;
|
||||||
final int value;
|
final int value;
|
||||||
|
|
@ -47,8 +49,10 @@ class BattleProvider with ChangeNotifier {
|
||||||
int turnCount = 1;
|
int turnCount = 1;
|
||||||
List<Item> rewardOptions = [];
|
List<Item> rewardOptions = [];
|
||||||
bool showRewardPopup = false;
|
bool showRewardPopup = false;
|
||||||
|
int _lastGoldReward = 0; // New: Stores gold gained from last victory
|
||||||
|
|
||||||
List<String> get logs => battleLogs;
|
List<String> get logs => battleLogs;
|
||||||
|
int get lastGoldReward => _lastGoldReward;
|
||||||
|
|
||||||
// Damage Event Stream
|
// Damage Event Stream
|
||||||
final _damageEventController = StreamController<DamageEvent>.broadcast();
|
final _damageEventController = StreamController<DamageEvent>.broadcast();
|
||||||
|
|
@ -69,6 +73,18 @@ class BattleProvider with ChangeNotifier {
|
||||||
super.dispose();
|
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() {
|
void initializeBattle() {
|
||||||
stage = 1;
|
stage = 1;
|
||||||
turnCount = 1;
|
turnCount = 1;
|
||||||
|
|
@ -87,6 +103,9 @@ class BattleProvider with ChangeNotifier {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Give test gold
|
||||||
|
player.gold = 50;
|
||||||
|
|
||||||
// Provide starter equipment
|
// Provide starter equipment
|
||||||
final starterSword = Item(
|
final starterSword = Item(
|
||||||
id: "starter_sword",
|
id: "starter_sword",
|
||||||
|
|
@ -147,6 +166,9 @@ class BattleProvider with ChangeNotifier {
|
||||||
}
|
}
|
||||||
|
|
||||||
void _prepareNextStage() {
|
void _prepareNextStage() {
|
||||||
|
// Save Game at the start of each stage
|
||||||
|
SaveManager.saveGame(this);
|
||||||
|
|
||||||
StageType type;
|
StageType type;
|
||||||
|
|
||||||
// Stage Type Logic
|
// Stage Type Logic
|
||||||
|
|
@ -209,15 +231,7 @@ class BattleProvider with ChangeNotifier {
|
||||||
_addLog("Stage $stage ($type) started! A wild ${enemy.name} appeared.");
|
_addLog("Stage $stage ($type) started! A wild ${enemy.name} appeared.");
|
||||||
} else if (type == StageType.shop) {
|
} else if (type == StageType.shop) {
|
||||||
// Generate random items for shop
|
// Generate random items for shop
|
||||||
final random = Random();
|
shopItems = _generateShopItems();
|
||||||
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();
|
|
||||||
|
|
||||||
// Dummy enemy to prevent null errors in existing UI (until UI is fully updated)
|
// Dummy enemy to prevent null errors in existing UI (until UI is fully updated)
|
||||||
enemy = Character(name: "Merchant", maxHp: 9999, armor: 0, atk: 0);
|
enemy = Character(name: "Merchant", maxHp: 9999, armor: 0, atk: 0);
|
||||||
|
|
@ -238,6 +252,55 @@ class BattleProvider with ChangeNotifier {
|
||||||
notifyListeners();
|
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
|
// Replaces _spawnEnemy
|
||||||
// void _spawnEnemy() { ... } - Removed
|
// void _spawnEnemy() { ... } - Removed
|
||||||
|
|
||||||
|
|
@ -621,29 +684,69 @@ class BattleProvider with ChangeNotifier {
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onVictory() {
|
void _onVictory() {
|
||||||
_addLog("Enemy defeated! Choose a reward.");
|
// Calculate Gold Reward
|
||||||
|
// Base 10 + (Stage * 5) + Random variance
|
||||||
final random = Random();
|
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);
|
List<ItemTemplate> allTemplates = List.from(ItemTable.allItems);
|
||||||
allTemplates.shuffle(random); // Shuffle to randomize selection
|
allTemplates.shuffle(random); // Shuffle to randomize selection
|
||||||
|
|
||||||
// Take first 3 items (ensure distinct templates if possible, though list is small now)
|
// Item Rewards
|
||||||
int count = min(3, allTemplates.length);
|
// Logic: Get random items based on current round tier? For now just random.
|
||||||
rewardOptions = allTemplates.sublist(0, count).map((template) {
|
// Ideally should use ItemTable.getRandomItem() with Tier logic.
|
||||||
return template.createItem(stage: stage);
|
// Let's use our new weighted random logic if available, or fallback to simple shuffle for now to keep it simple.
|
||||||
}).toList();
|
// 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;
|
showRewardPopup = true;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void selectReward(Item item) {
|
void selectReward(Item item) {
|
||||||
|
if (item.id == "reward_skip") {
|
||||||
|
_addLog("Skipped reward.");
|
||||||
|
} else {
|
||||||
bool added = player.addToInventory(item);
|
bool added = player.addToInventory(item);
|
||||||
if (added) {
|
if (added) {
|
||||||
_addLog("Added ${item.name} to inventory.");
|
_addLog("Added ${item.name} to inventory.");
|
||||||
} else {
|
} else {
|
||||||
_addLog("Inventory is full! ${item.name} discarded.");
|
_addLog("Inventory is full! ${item.name} discarded.");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Heal player after selecting reward
|
// Heal player after selecting reward
|
||||||
int healAmount = GameMath.floor(player.totalMaxHp * 0.1);
|
int healAmount = GameMath.floor(player.totalMaxHp * 0.1);
|
||||||
|
|
|
||||||
|
|
@ -538,8 +538,29 @@ class _BattleScreenState extends State<BattleScreen> {
|
||||||
color: Colors.black54,
|
color: Colors.black54,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: SimpleDialog(
|
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) {
|
children: battleProvider.rewardOptions.map((item) {
|
||||||
|
bool isSkip = item.id == "reward_skip";
|
||||||
return SimpleDialogOption(
|
return SimpleDialogOption(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
battleProvider.selectReward(item);
|
battleProvider.selectReward(item);
|
||||||
|
|
@ -549,12 +570,21 @@ class _BattleScreenState extends State<BattleScreen> {
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
|
if (!isSkip)
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(4),
|
padding: const EdgeInsets.all(4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.blueGrey[700],
|
color: Colors.blueGrey[700],
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(
|
||||||
border: Border.all(color: Colors.grey),
|
4),
|
||||||
|
border: Border.all(
|
||||||
|
color: item.rarity !=
|
||||||
|
ItemRarity.magic
|
||||||
|
? ItemUtils.getRarityColor(
|
||||||
|
item.rarity,
|
||||||
|
)
|
||||||
|
: Colors.grey,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
ItemUtils.getIcon(item.slot),
|
ItemUtils.getIcon(item.slot),
|
||||||
|
|
@ -562,17 +592,21 @@ class _BattleScreenState extends State<BattleScreen> {
|
||||||
size: 24,
|
size: 24,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
if (!isSkip) const SizedBox(width: 12),
|
||||||
Text(
|
Text(
|
||||||
item.name,
|
item.name,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
|
color: isSkip
|
||||||
|
? Colors.grey
|
||||||
|
: ItemUtils.getRarityColor(
|
||||||
|
item.rarity),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
_buildItemStatText(item),
|
if (!isSkip) _buildItemStatText(item),
|
||||||
Text(
|
Text(
|
||||||
item.description,
|
item.description,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,17 @@ class InventoryScreen extends StatelessWidget {
|
||||||
color: item != null
|
color: item != null
|
||||||
? Colors.blueGrey[600]
|
? Colors.blueGrey[600]
|
||||||
: Colors.grey[800],
|
: 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(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
// Slot Name (Top Right)
|
// Slot Name (Top Right)
|
||||||
|
|
@ -143,7 +154,9 @@ class InventoryScreen extends StatelessWidget {
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: item != null
|
color: item != null
|
||||||
? Colors.white
|
? ItemUtils.getRarityColor(
|
||||||
|
item.rarity,
|
||||||
|
)
|
||||||
: Colors.grey,
|
: Colors.grey,
|
||||||
),
|
),
|
||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
|
|
@ -206,6 +219,17 @@ class InventoryScreen extends StatelessWidget {
|
||||||
},
|
},
|
||||||
child: Card(
|
child: Card(
|
||||||
color: Colors.blueGrey[700],
|
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(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
// Faded Icon in Top-Left
|
// Faded Icon in Top-Left
|
||||||
|
|
@ -233,9 +257,12 @@ class InventoryScreen extends StatelessWidget {
|
||||||
child: Text(
|
child: Text(
|
||||||
item.name,
|
item.name,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
|
color: ItemUtils.getRarityColor(
|
||||||
|
item.rarity,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,54 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
import 'character_selection_screen.dart';
|
import 'character_selection_screen.dart';
|
||||||
|
import 'main_wrapper.dart';
|
||||||
import '../widgets/responsive_container.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});
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
|
@ -18,7 +62,9 @@ class MainMenuScreen extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: ResponsiveContainer(
|
child: ResponsiveContainer(
|
||||||
child: Column(
|
child: _isLoading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.gavel, size: 100, color: Colors.amber),
|
const Icon(Icons.gavel, size: 100, color: Colors.amber),
|
||||||
|
|
@ -42,8 +88,29 @@ class MainMenuScreen extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 60),
|
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(
|
ElevatedButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
// Warn if save exists? Or just overwrite on save.
|
||||||
|
// For now, simpler flow.
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
|
|
@ -63,7 +130,7 @@ class MainMenuScreen extends StatelessWidget {
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: const Text("START GAME"),
|
child: const Text("NEW GAME"),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'battle_screen.dart';
|
import 'battle_screen.dart';
|
||||||
import 'inventory_screen.dart';
|
import 'inventory_screen.dart';
|
||||||
|
import 'settings_screen.dart';
|
||||||
import '../widgets/responsive_container.dart';
|
import '../widgets/responsive_container.dart';
|
||||||
|
|
||||||
class MainWrapper extends StatefulWidget {
|
class MainWrapper extends StatefulWidget {
|
||||||
|
|
@ -13,7 +14,11 @@ class MainWrapper extends StatefulWidget {
|
||||||
class _MainWrapperState extends State<MainWrapper> {
|
class _MainWrapperState extends State<MainWrapper> {
|
||||||
int _currentIndex = 0;
|
int _currentIndex = 0;
|
||||||
|
|
||||||
final List<Widget> _screens = [const BattleScreen(), const InventoryScreen()];
|
final List<Widget> _screens = [
|
||||||
|
const BattleScreen(),
|
||||||
|
const InventoryScreen(),
|
||||||
|
const SettingsScreen(),
|
||||||
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -39,6 +44,10 @@ class _MainWrapperState extends State<MainWrapper> {
|
||||||
icon: Icon(Icons.backpack),
|
icon: Icon(Icons.backpack),
|
||||||
label: 'Inventory',
|
label: 'Inventory',
|
||||||
),
|
),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.settings),
|
||||||
|
label: 'Settings',
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,21 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../game/enums.dart';
|
import '../game/enums.dart';
|
||||||
|
import '../game/config/theme_config.dart';
|
||||||
|
|
||||||
class ItemUtils {
|
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) {
|
static IconData getIcon(EquipmentSlot slot) {
|
||||||
switch (slot) {
|
switch (slot) {
|
||||||
case EquipmentSlot.weapon:
|
case EquipmentSlot.weapon:
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../providers/battle_provider.dart';
|
import '../../providers/battle_provider.dart';
|
||||||
|
import '../../game/model/item.dart';
|
||||||
|
import '../../utils/item_utils.dart';
|
||||||
|
import '../../game/enums.dart';
|
||||||
|
|
||||||
class ShopUI extends StatelessWidget {
|
class ShopUI extends StatelessWidget {
|
||||||
final BattleProvider battleProvider;
|
final BattleProvider battleProvider;
|
||||||
|
|
@ -8,22 +11,236 @@ class ShopUI extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.store, size: 64, color: Colors.amber),
|
// Header: Merchant Icon & Player Gold
|
||||||
const SizedBox(height: 16),
|
Row(
|
||||||
const Text("Merchant Shop", style: TextStyle(fontSize: 24)),
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
const SizedBox(height: 8),
|
children: [
|
||||||
const Text("Buying/Selling feature coming soon!"),
|
const Row(
|
||||||
const SizedBox(height: 32),
|
children: [
|
||||||
ElevatedButton(
|
Icon(Icons.store, size: 32, color: Colors.amber),
|
||||||
onPressed: () => battleProvider.proceedToNextStage(),
|
SizedBox(width: 8),
|
||||||
child: const Text("Leave Shop"),
|
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),
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@
|
||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
import shared_preferences_foundation
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,16 @@
|
||||||
2. **2라운드:** 콜로세움 (Colosseum)
|
2. **2라운드:** 콜로세움 (Colosseum)
|
||||||
3. **3라운드:** 왕의 투기장 (King's Arena) - 최종 보스(Final Boss) 등장.
|
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. 핵심 파일 및 아키텍처
|
## 3. 핵심 파일 및 아키텍처
|
||||||
|
|
||||||
- **`lib/providers/battle_provider.dart`:**
|
- **`lib/providers/battle_provider.dart`:**
|
||||||
|
|
@ -104,13 +114,14 @@
|
||||||
## 4. 작업 컨벤션 (Working Conventions)
|
## 4. 작업 컨벤션 (Working Conventions)
|
||||||
|
|
||||||
- **Prompt Driven Development:** `prompt/XX_description.md` 유지.
|
- **Prompt Driven Development:** `prompt/XX_description.md` 유지.
|
||||||
|
- **Language:** **모든 프롬프트 파일(prompt/XX_...)은 반드시 한국어(Korean)로 작성해야 합니다.**
|
||||||
- **State Management:** `Provider` + `Stream` (이벤트성 데이터).
|
- **State Management:** `Provider` + `Stream` (이벤트성 데이터).
|
||||||
- **Data:** JSON 기반.
|
- **Data:** JSON 기반.
|
||||||
|
|
||||||
## 5. 다음 단계 작업 (Next Steps)
|
## 5. 다음 단계 작업 (Next Steps)
|
||||||
|
|
||||||
1. **아이템 시스템 고도화:** `items.json`에 `rarity`, `tier` 필드 추가 및 `ItemTable` 로직 수정.
|
1. **아이템 시스템 고도화:** `items.json`에 `rarity`, `tier` 필드 추가 및 `ItemTable` 로직 수정.
|
||||||
2. **상점 구매 기능:** `Shop` 스테이지 구매 UI 구현 (Tier/Rarity 기반 목록 생성).
|
2. **[x] 상점 구매 기능:** `Shop` 스테이지 구매 UI 구현 (Tier/Rarity 기반 목록 생성).
|
||||||
3. **적 등장 테이블 구현:** 스테이지별 등장 가능한 적 목록(`enemy_table_pool`) 설정 및 적용.
|
3. **적 등장 테이블 구현:** 스테이지별 등장 가능한 적 목록(`enemy_table_pool`) 설정 및 적용.
|
||||||
4. **이미지 리소스 적용:** JSON 경로에 맞는 실제 이미지 파일 추가 및 UI 표시.
|
4. **이미지 리소스 적용:** JSON 경로에 맞는 실제 이미지 파일 추가 및 UI 표시.
|
||||||
5. **밸런싱 및 콘텐츠 확장:** 아이템/적 데이터 추가 및 밸런스 조정.
|
5. **밸런싱 및 콘텐츠 확장:** 아이템/적 데이터 추가 및 밸런스 조정.
|
||||||
|
|
@ -144,3 +155,6 @@
|
||||||
- [x] 39_luck_system.md
|
- [x] 39_luck_system.md
|
||||||
- [x] 40_ui_update_summary.md
|
- [x] 40_ui_update_summary.md
|
||||||
- [x] 41_refactoring_presets.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
|
||||||
|
|
|
||||||
|
|
@ -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`) 업데이트 및 검증 완료.
|
||||||
|
|
@ -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번째 스테이지**마다 등장하도록 복구.
|
||||||
|
|
@ -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 데이터로부터 상태 복구.
|
||||||
|
|
@ -35,6 +35,7 @@ dependencies:
|
||||||
# Use with the CupertinoIcons class for iOS style icons.
|
# Use with the CupertinoIcons class for iOS style icons.
|
||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
provider: ^6.0.5
|
provider: ^6.0.5
|
||||||
|
shared_preferences: ^2.5.3
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
||||||
|
|
@ -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/enemy_table.dart';
|
||||||
import 'package:game_test/game/data/item_table.dart';
|
import 'package:game_test/game/data/item_table.dart';
|
||||||
import 'package:game_test/game/enums.dart';
|
import 'package:game_test/game/enums.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
setUpAll(() async {
|
setUpAll(() async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
await ItemTable.load();
|
await ItemTable.load();
|
||||||
await EnemyTable.load();
|
await EnemyTable.load();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue