This commit is contained in:
2025-12-07 13:44:51 +09:00
parent 30c84a48ac
commit d3fca333cb
29 changed files with 1437 additions and 137 deletions
+13
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,
};
}
+6
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;
}
+7 -10
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);
}
+101
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"
];
}
+144 -9
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)];
}
}
+68
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";
}
}
+5 -1
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 }
+72
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) {
+4
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 {
+16
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'],
);
}
}
+46
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);
}
}
+123 -20
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
+50 -16
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(
+29 -2
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,
+115 -48
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"),
),
],
),
),
),
);
+10 -1
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',
),
],
),
),
+111
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)),
),
],
),
);
}
}
+14
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:
+227 -10
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 {