feat: add subweapon support, weapon type logic, and compact item card UI options
This commit is contained in:
@@ -8,7 +8,7 @@ class AnimationConfig {
|
||||
static const Duration fadeDuration = Duration(milliseconds: 200);
|
||||
|
||||
// Attack Animations
|
||||
static const Duration attackSafe = Duration(milliseconds: 200);
|
||||
static const Duration attackSafe = Duration(milliseconds: 500);
|
||||
static const Duration attackNormal = Duration(milliseconds: 400);
|
||||
static const Duration attackRiskyTotal = Duration(milliseconds: 1100);
|
||||
static const Duration attackRiskyScale = Duration(milliseconds: 600);
|
||||
@@ -17,7 +17,7 @@ class AnimationConfig {
|
||||
// Curves
|
||||
static const Curve floatingTextCurve = Curves.easeOut;
|
||||
static const Curve floatingEffectScaleCurve = Curves.elasticOut;
|
||||
static const Curve attackSafeCurve = Curves.elasticIn;
|
||||
static const Curve attackSafeCurve = Curves.easeOutQuad;
|
||||
static const Curve attackNormalCurve = Curves.easeOutQuad;
|
||||
static const Curve attackRiskyDashCurve = Curves.easeInExpo;
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import '../config/game_config.dart';
|
||||
import 'item_table.dart';
|
||||
|
||||
class EnemyTemplate {
|
||||
static const String fallbackImage = 'assets/images/enemies/Orc.png';
|
||||
|
||||
final String name;
|
||||
final int baseHp;
|
||||
final int baseAtk;
|
||||
@@ -27,19 +29,30 @@ class EnemyTemplate {
|
||||
this.tier = 1,
|
||||
});
|
||||
|
||||
factory EnemyTemplate.fromJson(Map<String, dynamic> json) {
|
||||
factory EnemyTemplate.fromJson(
|
||||
Map<String, dynamic> json, {
|
||||
Set<String>? availableAssets,
|
||||
}) {
|
||||
final imagePath = json['image'] as String?;
|
||||
|
||||
return EnemyTemplate(
|
||||
name: json['name'],
|
||||
baseHp: json['baseHp'] ?? 10,
|
||||
baseAtk: json['baseAtk'] ?? 1,
|
||||
baseDefense: json['baseDefense'] ?? 0,
|
||||
baseDodge: json['baseDodge'] ?? 1, // Parse from JSON or default to 1
|
||||
image: json['image'],
|
||||
image: _resolveImagePath(imagePath, availableAssets),
|
||||
equipmentIds: (json['equipment'] as List<dynamic>?)?.cast<String>() ?? [],
|
||||
tier: json['tier'] ?? 1,
|
||||
);
|
||||
}
|
||||
|
||||
static String? _resolveImagePath(String? imagePath, Set<String>? assets) {
|
||||
if (imagePath == null || imagePath.isEmpty) return null;
|
||||
if (assets == null || assets.contains(imagePath)) return imagePath;
|
||||
return fallbackImage;
|
||||
}
|
||||
|
||||
Character createCharacter({int stage = 1}) {
|
||||
// Stage-based scaling for enemy stats is removed to simplify balancing.
|
||||
// Enemy stats are now fixed as defined in the EnemyTemplate.
|
||||
@@ -79,15 +92,25 @@ class EnemyTable {
|
||||
'assets/data/enemies.json',
|
||||
);
|
||||
final Map<String, dynamic> data = jsonDecode(jsonString);
|
||||
final availableAssets = await _loadAvailableAssets();
|
||||
|
||||
normalEnemies = (data['normal'] as List)
|
||||
.map((e) => EnemyTemplate.fromJson(e))
|
||||
.map((e) => EnemyTemplate.fromJson(e, availableAssets: availableAssets))
|
||||
.toList();
|
||||
eliteEnemies = (data['elite'] as List)
|
||||
.map((e) => EnemyTemplate.fromJson(e))
|
||||
.map((e) => EnemyTemplate.fromJson(e, availableAssets: availableAssets))
|
||||
.toList();
|
||||
}
|
||||
|
||||
static Future<Set<String>?> _loadAvailableAssets() async {
|
||||
try {
|
||||
final manifest = await AssetManifest.loadFromAssetBundle(rootBundle);
|
||||
return manifest.listAssets().toSet();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a random enemy suitable for the current stage.
|
||||
static EnemyTemplate getRandomEnemy({
|
||||
required int stage,
|
||||
|
||||
@@ -23,6 +23,7 @@ class ItemTemplate {
|
||||
final int luck;
|
||||
final ItemRarity rarity;
|
||||
final ItemTier tier;
|
||||
final WeaponType? weaponType; // New: oneHanded or twoHanded
|
||||
|
||||
const ItemTemplate({
|
||||
required this.id,
|
||||
@@ -39,6 +40,7 @@ class ItemTemplate {
|
||||
this.luck = 0,
|
||||
this.rarity = ItemRarity.magic,
|
||||
this.tier = ItemTier.tier1,
|
||||
this.weaponType,
|
||||
});
|
||||
|
||||
factory ItemTemplate.fromJson(Map<String, dynamic> json) {
|
||||
@@ -57,7 +59,7 @@ class ItemTemplate {
|
||||
hpBonus: json['hpBonus'] ?? json['baseHp'] ?? 0,
|
||||
armorBonus: json['armorBonus'] ?? json['baseArmor'] ?? 0,
|
||||
dodge: json['dodge'] ?? 0,
|
||||
slot: EquipmentSlot.values.firstWhere((e) => e.name == json['slot']),
|
||||
slot: equipmentSlotFromName(json['slot']),
|
||||
effects: effectsList,
|
||||
price: json['price'] ?? 10,
|
||||
image: json['image'],
|
||||
@@ -68,6 +70,9 @@ class ItemTemplate {
|
||||
tier: json['tier'] != null
|
||||
? ItemTier.values.firstWhere((e) => e.name == json['tier'])
|
||||
: ItemTier.tier1,
|
||||
weaponType: json['weaponType'] != null
|
||||
? WeaponType.values.firstWhere((e) => e.name == json['weaponType'])
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -86,6 +91,7 @@ class ItemTable {
|
||||
static List<ItemTemplate> consumables = [];
|
||||
|
||||
static final Map<String, ItemTemplate> _items = {};
|
||||
static List<ItemTemplate> get subweapons => shields;
|
||||
|
||||
static void initialize() {
|
||||
// This function is now a placeholder. All loading is handled in load().
|
||||
@@ -95,16 +101,22 @@ class ItemTable {
|
||||
// Clear previous data
|
||||
_items.clear();
|
||||
|
||||
final String jsonString =
|
||||
await rootBundle.loadString('assets/data/items.json');
|
||||
final String jsonString = await rootBundle.loadString(
|
||||
'assets/data/items.json',
|
||||
);
|
||||
final Map<String, dynamic> data = jsonDecode(jsonString);
|
||||
|
||||
// Helper function to load and register items
|
||||
void _loadAndRegister(String key, List<ItemTemplate> list) {
|
||||
void loadAndRegister(
|
||||
String key,
|
||||
List<ItemTemplate> list, {
|
||||
bool clear = true,
|
||||
}) {
|
||||
if (data[key] != null) {
|
||||
list.clear();
|
||||
var loadedItems =
|
||||
(data[key] as List).map((e) => ItemTemplate.fromJson(e)).toList();
|
||||
if (clear) list.clear();
|
||||
var loadedItems = (data[key] as List)
|
||||
.map((e) => ItemTemplate.fromJson(e))
|
||||
.toList();
|
||||
list.addAll(loadedItems);
|
||||
for (var item in loadedItems) {
|
||||
_items[item.id] = item;
|
||||
@@ -112,11 +124,12 @@ class ItemTable {
|
||||
}
|
||||
}
|
||||
|
||||
_loadAndRegister('weapons', weapons);
|
||||
_loadAndRegister('armors', armors);
|
||||
_loadAndRegister('shields', shields);
|
||||
_loadAndRegister('accessories', accessories);
|
||||
_loadAndRegister('consumables', consumables);
|
||||
loadAndRegister('weapons', weapons);
|
||||
loadAndRegister('armors', armors);
|
||||
loadAndRegister('shields', shields);
|
||||
loadAndRegister('subweapons', shields, clear: false);
|
||||
loadAndRegister('accessories', accessories);
|
||||
loadAndRegister('consumables', consumables);
|
||||
}
|
||||
|
||||
static List<ItemTemplate> get allItems => _items.values.toList();
|
||||
@@ -124,6 +137,7 @@ class ItemTable {
|
||||
static ItemTemplate? get(String id) {
|
||||
return _items[id];
|
||||
}
|
||||
|
||||
static final Random _random = Random();
|
||||
|
||||
/// Returns all items matching the given tier.
|
||||
|
||||
@@ -34,6 +34,42 @@ enum StageType {
|
||||
|
||||
enum EquipmentSlot { weapon, armor, shield, accessory, consumable }
|
||||
|
||||
enum WeaponType { oneHanded, twoHanded }
|
||||
|
||||
EquipmentSlot equipmentSlotFromName(String name) {
|
||||
switch (name) {
|
||||
case 'mainWeapon':
|
||||
case 'weapon':
|
||||
return EquipmentSlot.weapon;
|
||||
case 'subweapon':
|
||||
case 'shield':
|
||||
return EquipmentSlot.shield;
|
||||
case 'armor':
|
||||
return EquipmentSlot.armor;
|
||||
case 'accessory':
|
||||
return EquipmentSlot.accessory;
|
||||
case 'consumable':
|
||||
return EquipmentSlot.consumable;
|
||||
default:
|
||||
return EquipmentSlot.weapon;
|
||||
}
|
||||
}
|
||||
|
||||
String equipmentSlotStorageName(EquipmentSlot slot) {
|
||||
switch (slot) {
|
||||
case EquipmentSlot.weapon:
|
||||
return 'mainWeapon';
|
||||
case EquipmentSlot.shield:
|
||||
return 'subweapon';
|
||||
case EquipmentSlot.armor:
|
||||
return 'armor';
|
||||
case EquipmentSlot.accessory:
|
||||
return 'accessory';
|
||||
case EquipmentSlot.consumable:
|
||||
return 'consumable';
|
||||
}
|
||||
}
|
||||
|
||||
enum DamageType { normal, bleed, vulnerable }
|
||||
|
||||
enum StatType { maxHp, atk, defense, luck, dodge }
|
||||
|
||||
@@ -4,7 +4,6 @@ import '../model/status_effect.dart';
|
||||
import '../enums.dart';
|
||||
import '../config/game_config.dart';
|
||||
import '../config/battle_config.dart'; // Import BattleConfig
|
||||
import '../model/damage_event.dart';
|
||||
|
||||
class CombatResult {
|
||||
final bool success;
|
||||
|
||||
@@ -28,6 +28,7 @@ class LootGenerator {
|
||||
luck: template.luck,
|
||||
rarity: template.rarity,
|
||||
tier: template.tier,
|
||||
weaponType: template.weaponType,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -167,6 +168,7 @@ class LootGenerator {
|
||||
luck: finalLuck,
|
||||
rarity: template.rarity,
|
||||
tier: template.tier,
|
||||
weaponType: template.weaponType,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,13 @@ class DamageEvent {
|
||||
final int damage;
|
||||
final DamageTarget target;
|
||||
final DamageType type;
|
||||
final RiskLevel? risk;
|
||||
|
||||
DamageEvent({
|
||||
required this.damage,
|
||||
required this.target,
|
||||
this.type = DamageType.normal,
|
||||
this.risk,
|
||||
});
|
||||
|
||||
Color get color {
|
||||
|
||||
+97
-28
@@ -51,7 +51,9 @@ class Character {
|
||||
'baseDodge': baseDodge,
|
||||
'gold': gold,
|
||||
'image': image,
|
||||
'equipment': equipment.map((key, value) => MapEntry(key.name, value.id)),
|
||||
'equipment': equipment.map(
|
||||
(key, value) => MapEntry(equipmentSlotStorageName(key), value.id),
|
||||
),
|
||||
'inventory': inventory.map((e) => e.id).toList(),
|
||||
'statusEffects': statusEffects.map((e) => e.toJson()).toList(),
|
||||
'permanentModifiers': permanentModifiers.map((e) => e.toJson()).toList(),
|
||||
@@ -76,11 +78,7 @@ class Character {
|
||||
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
|
||||
);
|
||||
final slot = equipmentSlotFromName(slotName);
|
||||
char.equipment[slot] = template.createItem();
|
||||
}
|
||||
});
|
||||
@@ -112,36 +110,61 @@ class Character {
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
// Check if effect exists
|
||||
var existing = statusEffects
|
||||
.where((e) => e.type == newEffect.type)
|
||||
.firstOrNull;
|
||||
|
||||
if (existing != null) {
|
||||
// Refresh duration if the new one is longer, or just reset it?
|
||||
// Let's max the duration for now.
|
||||
if (newEffect.duration > existing.duration) {
|
||||
if (existing.type == StatusEffectType.bleed) {
|
||||
// Stack bleed damage
|
||||
existing.value += newEffect.value;
|
||||
existing.stacks += 1;
|
||||
|
||||
// Cap at 30 (10 stacks of 3)
|
||||
if (existing.value > 30) {
|
||||
existing.value = 30;
|
||||
}
|
||||
if (existing.stacks > 10) {
|
||||
existing.stacks = 10;
|
||||
}
|
||||
|
||||
// Always refresh duration for bleed stacking
|
||||
existing.duration = newEffect.duration;
|
||||
} else {
|
||||
// For other effects, just refresh duration if longer
|
||||
if (newEffect.duration > existing.duration) {
|
||||
existing.duration = newEffect.duration;
|
||||
}
|
||||
}
|
||||
// Logic for 'value' (stacking bleed?) can be added here.
|
||||
} else {
|
||||
statusEffects.add(newEffect);
|
||||
// Create a copy of the new effect to avoid reference issues if it comes from an item template
|
||||
statusEffects.add(StatusEffect(
|
||||
type: newEffect.type,
|
||||
duration: newEffect.duration,
|
||||
value: newEffect.value,
|
||||
stacks: newEffect.stacks,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrements duration of all effects and removes expired ones.
|
||||
/// Returns a list of expired effects if needed for UI logs.
|
||||
void updateStatusEffects() {
|
||||
// Remove effects with 0 or less duration first (safety cleanup)
|
||||
statusEffects.removeWhere((e) => e.duration <= 0);
|
||||
|
||||
/// Decrements duration of start-of-turn effects (Bleed, Stun).
|
||||
void updateStartOfTurnStatusEffects() {
|
||||
for (var effect in statusEffects) {
|
||||
effect.duration--;
|
||||
if (effect.type == StatusEffectType.bleed || effect.type == StatusEffectType.stun) {
|
||||
effect.duration--;
|
||||
}
|
||||
}
|
||||
statusEffects.removeWhere((e) => e.duration <= 0);
|
||||
}
|
||||
|
||||
// Remove effects that just expired (duration went to 0 or -1)
|
||||
/// Decrements duration of end-of-turn effects (all others).
|
||||
void updateEndOfTurnStatusEffects() {
|
||||
for (var effect in statusEffects) {
|
||||
if (effect.type != StatusEffectType.bleed && effect.type != StatusEffectType.stun) {
|
||||
effect.duration--;
|
||||
}
|
||||
}
|
||||
statusEffects.removeWhere((e) => e.duration <= 0);
|
||||
}
|
||||
|
||||
@@ -210,23 +233,65 @@ class Character {
|
||||
// Equips an item (swapping if necessary)
|
||||
// Returns true if successful
|
||||
bool equip(Item newItem) {
|
||||
return equipToSlot(newItem, newItem.defaultEquipSlot);
|
||||
}
|
||||
|
||||
bool equipToSlot(Item newItem, EquipmentSlot targetSlot) {
|
||||
if (!inventory.contains(newItem)) return false;
|
||||
if (!newItem.canEquipTo(targetSlot)) return false;
|
||||
|
||||
// Check inventory capacity before unequipping multiple items
|
||||
int itemsToUnequip = 0;
|
||||
if (equipment.containsKey(targetSlot)) itemsToUnequip++;
|
||||
|
||||
if (targetSlot == EquipmentSlot.weapon && newItem.weaponType == WeaponType.twoHanded) {
|
||||
if (equipment.containsKey(EquipmentSlot.shield)) itemsToUnequip++;
|
||||
} else if (targetSlot == EquipmentSlot.shield) {
|
||||
if (equipment.containsKey(EquipmentSlot.weapon) && equipment[EquipmentSlot.weapon]!.weaponType == WeaponType.twoHanded) {
|
||||
itemsToUnequip++;
|
||||
}
|
||||
}
|
||||
|
||||
// newItem leaves inventory (-1), itemsToUnequip go to inventory (+itemsToUnequip)
|
||||
if (inventory.length - 1 + itemsToUnequip > maxInventorySize) {
|
||||
return false; // Not enough space
|
||||
}
|
||||
|
||||
// 1. Calculate current HP ratio before any changes
|
||||
double hpRatio = totalMaxHp > 0
|
||||
? hp / totalMaxHp
|
||||
: 0.0; // Avoid division by zero
|
||||
|
||||
// 2. Handle Swap: If slot is occupied, unequip the old item first
|
||||
if (equipment.containsKey(newItem.slot)) {
|
||||
Item oldItem = equipment[newItem.slot]!;
|
||||
equipment.remove(newItem.slot);
|
||||
// 2. Handle 2H / 1H rules
|
||||
if (targetSlot == EquipmentSlot.weapon && newItem.weaponType == WeaponType.twoHanded) {
|
||||
// If equipping 2H weapon, unequip shield slot if occupied
|
||||
if (equipment.containsKey(EquipmentSlot.shield)) {
|
||||
Item shieldItem = equipment[EquipmentSlot.shield]!;
|
||||
equipment.remove(EquipmentSlot.shield);
|
||||
inventory.add(shieldItem);
|
||||
}
|
||||
} else if (targetSlot == EquipmentSlot.shield) {
|
||||
// If equipping to shield slot, check if main weapon is 2H
|
||||
if (equipment.containsKey(EquipmentSlot.weapon)) {
|
||||
Item mainWeapon = equipment[EquipmentSlot.weapon]!;
|
||||
if (mainWeapon.weaponType == WeaponType.twoHanded) {
|
||||
// Unequip 2H weapon
|
||||
equipment.remove(EquipmentSlot.weapon);
|
||||
inventory.add(mainWeapon);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Handle Swap: If slot is occupied, unequip the old item first
|
||||
if (equipment.containsKey(targetSlot)) {
|
||||
Item oldItem = equipment[targetSlot]!;
|
||||
equipment.remove(targetSlot);
|
||||
inventory.add(oldItem);
|
||||
}
|
||||
|
||||
// 3. Move new item: Inventory -> Equipment
|
||||
// 4. Move new item: Inventory -> Equipment
|
||||
inventory.remove(newItem);
|
||||
equipment[newItem.slot] = newItem;
|
||||
equipment[targetSlot] = newItem;
|
||||
|
||||
// 4. Update current HP based on the new totalMaxHp and previous ratio
|
||||
hp = (totalMaxHp * hpRatio).toInt();
|
||||
@@ -244,13 +309,17 @@ class Character {
|
||||
bool unequip(Item item) {
|
||||
if (!equipment.containsValue(item)) return false;
|
||||
|
||||
final slot = equipment.entries
|
||||
.firstWhere((entry) => entry.value == item)
|
||||
.key;
|
||||
|
||||
// 1. Calculate current HP ratio before any changes
|
||||
double hpRatio = totalMaxHp > 0
|
||||
? hp / totalMaxHp
|
||||
: 0.0; // Avoid division by zero
|
||||
|
||||
if (inventory.length < maxInventorySize) {
|
||||
equipment.remove(item.slot);
|
||||
equipment.remove(slot);
|
||||
inventory.add(item);
|
||||
|
||||
// 2. Update current HP based on the new totalMaxHp and previous ratio
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
enum HealTarget { player, enemy }
|
||||
|
||||
class HealEvent {
|
||||
final int amount;
|
||||
final HealTarget target;
|
||||
|
||||
HealEvent({
|
||||
required this.amount,
|
||||
required this.target,
|
||||
});
|
||||
}
|
||||
@@ -32,7 +32,7 @@ class ItemEffect {
|
||||
String durationStr = "${duration}t";
|
||||
String valStr = value > 0 ? " ($value dmg)" : "";
|
||||
|
||||
return "$typeStr ${probability}% ($durationStr)$valStr";
|
||||
return "$typeStr $probability% ($durationStr)$valStr";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ class Item {
|
||||
final int luck; // Success rate bonus (e.g. 5 = 5%)
|
||||
final ItemRarity rarity;
|
||||
final ItemTier tier;
|
||||
final WeaponType? weaponType; // New: oneHanded or twoHanded
|
||||
|
||||
const Item({
|
||||
required this.id,
|
||||
@@ -67,6 +68,7 @@ class Item {
|
||||
this.luck = 0,
|
||||
this.rarity = ItemRarity.magic,
|
||||
this.tier = ItemTier.tier1,
|
||||
this.weaponType,
|
||||
});
|
||||
|
||||
String get typeName {
|
||||
@@ -76,11 +78,35 @@ class Item {
|
||||
case EquipmentSlot.armor:
|
||||
return "Armor";
|
||||
case EquipmentSlot.shield:
|
||||
return "Shield";
|
||||
return "Subweapon";
|
||||
case EquipmentSlot.accessory:
|
||||
return "Accessory";
|
||||
case EquipmentSlot.consumable:
|
||||
return "Potion";
|
||||
}
|
||||
}
|
||||
|
||||
EquipmentSlot get defaultEquipSlot => slot;
|
||||
|
||||
List<EquipmentSlot> get compatibleEquipSlots {
|
||||
switch (slot) {
|
||||
case EquipmentSlot.weapon:
|
||||
if (weaponType == WeaponType.oneHanded) {
|
||||
return const [EquipmentSlot.weapon, EquipmentSlot.shield];
|
||||
} else if (weaponType == WeaponType.twoHanded) {
|
||||
return const [EquipmentSlot.weapon];
|
||||
}
|
||||
return const [EquipmentSlot.weapon]; // Default fallback
|
||||
case EquipmentSlot.armor:
|
||||
case EquipmentSlot.shield:
|
||||
case EquipmentSlot.accessory:
|
||||
return [slot];
|
||||
case EquipmentSlot.consumable:
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
bool canEquipTo(EquipmentSlot targetSlot) {
|
||||
return compatibleEquipSlots.contains(targetSlot);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,15 +3,17 @@ import '../enums.dart';
|
||||
class StatusEffect {
|
||||
final StatusEffectType type;
|
||||
int duration; // Turns remaining
|
||||
final int value; // Intensity (e.g., bleed damage amount)
|
||||
int value; // Intensity (e.g., bleed damage amount, now mutable for stacking)
|
||||
int stacks; // Number of stacks
|
||||
|
||||
StatusEffect({required this.type, required this.duration, this.value = 0});
|
||||
StatusEffect({required this.type, required this.duration, this.value = 0, this.stacks = 1});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'type': type.name,
|
||||
'duration': duration,
|
||||
'value': value,
|
||||
'stacks': stacks,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,6 +22,7 @@ class StatusEffect {
|
||||
type: StatusEffectType.values.firstWhere((e) => e.name == json['type']),
|
||||
duration: json['duration'],
|
||||
value: json['value'],
|
||||
stacks: json['stacks'] ?? 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export 'model/damage_event.dart';
|
||||
export 'model/effect_event.dart';
|
||||
export 'model/heal_event.dart';
|
||||
export 'model/entity.dart';
|
||||
export 'model/item.dart';
|
||||
export 'model/stage.dart';
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'dart:convert';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../providers.dart';
|
||||
import 'model/entity.dart';
|
||||
import 'config/game_config.dart';
|
||||
|
||||
class SaveManager {
|
||||
|
||||
@@ -43,6 +43,9 @@ class EnemyIntent {
|
||||
}
|
||||
|
||||
class BattleProvider with ChangeNotifier {
|
||||
static final Duration _turnEffectVisualDelay =
|
||||
AnimationConfig.floatingTextDuration + const Duration(milliseconds: 100);
|
||||
|
||||
late Character player;
|
||||
late Character enemy; // Kept for compatibility, active during Battle/Elite
|
||||
|
||||
@@ -52,8 +55,6 @@ class BattleProvider with ChangeNotifier {
|
||||
final BattleLogManager _logManager = BattleLogManager();
|
||||
bool isPlayerTurn = true;
|
||||
int _turnTransactionId = 0; // To prevent async race conditions
|
||||
bool skipAnimations = false; // Sync with SettingsProvider
|
||||
|
||||
int stage = 1;
|
||||
int turnCount = 1;
|
||||
List<Item> rewardOptions = [];
|
||||
@@ -62,6 +63,19 @@ class BattleProvider with ChangeNotifier {
|
||||
|
||||
List<String> get logs => _logManager.logs;
|
||||
int get lastGoldReward => _lastGoldReward;
|
||||
StageType get nextStageType => getStageTypeFor(stage + 1);
|
||||
|
||||
StageType getStageTypeFor(int stageNumber) {
|
||||
if (stageNumber % GameConfig.eliteStageInterval == 0) {
|
||||
return StageType.elite;
|
||||
} else if (stageNumber % GameConfig.shopStageInterval == 0) {
|
||||
return StageType.shop;
|
||||
} else if (stageNumber % GameConfig.restStageInterval == 0) {
|
||||
return StageType.rest;
|
||||
}
|
||||
|
||||
return StageType.battle;
|
||||
}
|
||||
|
||||
void refreshUI() {
|
||||
notifyListeners();
|
||||
@@ -75,6 +89,10 @@ class BattleProvider with ChangeNotifier {
|
||||
final _effectEventController = StreamController<EffectEvent>.broadcast();
|
||||
Stream<EffectEvent> get effectStream => _effectEventController.stream;
|
||||
|
||||
// Heal Event Stream
|
||||
final _healEventController = StreamController<HealEvent>.broadcast();
|
||||
Stream<HealEvent> get healStream => _healEventController.stream;
|
||||
|
||||
// Dependency injection
|
||||
final ShopProvider shopProvider;
|
||||
final Random _random; // Injected Random instance
|
||||
@@ -88,6 +106,7 @@ class BattleProvider with ChangeNotifier {
|
||||
void dispose() {
|
||||
_damageEventController.close(); // StreamController 닫기
|
||||
_effectEventController.close();
|
||||
_healEventController.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -136,8 +155,8 @@ class BattleProvider with ChangeNotifier {
|
||||
player.gold = GameConfig.startingGold;
|
||||
|
||||
// Add new status effect items for testing
|
||||
player.addToInventory(ItemTable.weapons[3].createItem()); // Stunning Hammer
|
||||
player.addToInventory(ItemTable.weapons[4].createItem()); // Jagged Dagger
|
||||
player.addToInventory(ItemTable.weapons[6].createItem()); // Stunning Hammer
|
||||
player.addToInventory(ItemTable.weapons[9].createItem()); // Jagged Dagger
|
||||
player.addToInventory(ItemTable.weapons[5].createItem()); // Sunderer Axe
|
||||
player.addToInventory(ItemTable.shields[3].createItem()); // Cursed Shield
|
||||
|
||||
@@ -164,18 +183,7 @@ class BattleProvider with ChangeNotifier {
|
||||
// Reset Player Armor at start of new stage
|
||||
player.armor = 0;
|
||||
|
||||
StageType type;
|
||||
|
||||
// Stage Type Logic
|
||||
if (stage % GameConfig.eliteStageInterval == 0) {
|
||||
type = StageType.elite; // Every 10th stage is a Boss/Elite
|
||||
} else if (stage % GameConfig.shopStageInterval == 0) {
|
||||
type = StageType.shop; // Every 5th stage is a Shop (except 10, 20...)
|
||||
} else if (stage % GameConfig.restStageInterval == 0) {
|
||||
type = StageType.rest; // Every 8th stage is a Rest
|
||||
} else {
|
||||
type = StageType.battle;
|
||||
}
|
||||
StageType type = getStageTypeFor(stage);
|
||||
|
||||
// Prepare Data based on Type
|
||||
Character? newEnemy;
|
||||
@@ -239,8 +247,7 @@ class BattleProvider with ChangeNotifier {
|
||||
// 0. Ensure Pre-emptive Enemy Defense is applied (if not already via animation)
|
||||
applyPendingEnemyDefense();
|
||||
|
||||
// Update Enemy Status Effects at the start of Player's turn (user request)
|
||||
enemy.updateStatusEffects(); // 1. Check for Defense Forbidden status
|
||||
// 1. Check for Defense Forbidden status (Player)
|
||||
if (type == ActionType.defend &&
|
||||
player.hasStatus(StatusEffectType.defenseForbidden)) {
|
||||
_addLog("Cannot defend! You are under Defense Forbidden status.");
|
||||
@@ -262,7 +269,7 @@ class BattleProvider with ChangeNotifier {
|
||||
|
||||
// If a visual effect occurred (bleed, stun), wait a bit before action
|
||||
if (turnEffect.effectTriggered) {
|
||||
await Future.delayed(const Duration(milliseconds: 800));
|
||||
await Future.delayed(_turnEffectVisualDelay);
|
||||
}
|
||||
|
||||
if (!turnEffect.canAct) {
|
||||
@@ -390,7 +397,7 @@ class BattleProvider with ChangeNotifier {
|
||||
|
||||
void _endPlayerTurn() {
|
||||
// Update durations at end of turn
|
||||
player.updateStatusEffects();
|
||||
player.updateEndOfTurnStatusEffects();
|
||||
|
||||
// Check if enemy is dead from bleed
|
||||
if (enemy.isDead) {
|
||||
@@ -519,6 +526,9 @@ class BattleProvider with ChangeNotifier {
|
||||
effectTriggered = true;
|
||||
}
|
||||
|
||||
// 3. Update durations for start-of-turn effects immediately
|
||||
character.updateStartOfTurnStatusEffects();
|
||||
|
||||
return TurnEffectResult(
|
||||
canAct: !isStunned,
|
||||
effectTriggered: effectTriggered,
|
||||
@@ -550,7 +560,7 @@ class BattleProvider with ChangeNotifier {
|
||||
|
||||
// If a visual effect occurred (bleed, stun), wait a bit before action
|
||||
if (turnEffect.effectTriggered) {
|
||||
await Future.delayed(const Duration(milliseconds: 800));
|
||||
await Future.delayed(_turnEffectVisualDelay);
|
||||
}
|
||||
|
||||
if (turnEffect.canAct && currentEnemyIntent != null) {
|
||||
@@ -665,7 +675,7 @@ class BattleProvider with ChangeNotifier {
|
||||
if (player.isDead) return; // Game Over check
|
||||
|
||||
// Update enemy status at the end of their turn
|
||||
enemy.updateStatusEffects();
|
||||
enemy.updateEndOfTurnStatusEffects();
|
||||
|
||||
// Generate NEXT intent
|
||||
_generateEnemyIntent();
|
||||
@@ -765,16 +775,20 @@ class BattleProvider with ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
bool selectReward(Item item) {
|
||||
bool selectReward(Item item, {bool completeStage = true}) {
|
||||
if (item.id == "reward_skip") {
|
||||
_addLog("Skipped reward.");
|
||||
_completeStage();
|
||||
if (completeStage) {
|
||||
_completeStage();
|
||||
}
|
||||
return true;
|
||||
} else {
|
||||
bool added = player.addToInventory(item);
|
||||
if (added) {
|
||||
_addLog("Added ${item.name} to inventory.");
|
||||
_completeStage();
|
||||
if (completeStage) {
|
||||
_completeStage();
|
||||
}
|
||||
return true;
|
||||
} else {
|
||||
_addLog("Inventory is full! Could not take ${item.name}.");
|
||||
@@ -783,6 +797,10 @@ class BattleProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
void completeStage() {
|
||||
_completeStage();
|
||||
}
|
||||
|
||||
void _completeStage() {
|
||||
// Heal player after selecting reward
|
||||
int healAmount = GameMath.floor(
|
||||
@@ -802,9 +820,20 @@ class BattleProvider with ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void equipItem(Item item) {
|
||||
if (player.equip(item)) {
|
||||
_addLog("Equipped ${item.name}.");
|
||||
void equipItem(Item item, {EquipmentSlot? targetSlot}) {
|
||||
final success = targetSlot == null
|
||||
? player.equip(item)
|
||||
: player.equipToSlot(item, targetSlot);
|
||||
|
||||
if (success) {
|
||||
final slotName = targetSlot == null
|
||||
? item.typeName
|
||||
: targetSlot == EquipmentSlot.weapon
|
||||
? "Main Weapon"
|
||||
: targetSlot == EquipmentSlot.shield
|
||||
? "Subweapon"
|
||||
: targetSlot.name;
|
||||
_addLog("Equipped ${item.name} as $slotName.");
|
||||
} else {
|
||||
_addLog(
|
||||
"Failed to equip ${item.name}.",
|
||||
@@ -857,6 +886,7 @@ class BattleProvider with ChangeNotifier {
|
||||
int healedAmount = player.hp - currentHp;
|
||||
if (healedAmount > 0) {
|
||||
_addLog("Used ${item.name}. Recovered $healedAmount HP.");
|
||||
_healEventController.sink.add(HealEvent(amount: healedAmount, target: HealTarget.player));
|
||||
effectApplied = true;
|
||||
} else {
|
||||
_addLog("Used ${item.name}. HP is already full.");
|
||||
@@ -1095,6 +1125,7 @@ class BattleProvider with ChangeNotifier {
|
||||
type: target.hasStatus(StatusEffectType.vulnerable)
|
||||
? DamageType.vulnerable
|
||||
: DamageType.normal,
|
||||
risk: event.risk,
|
||||
),
|
||||
);
|
||||
_addLog("${attacker.name} dealt $damageToHp damage to ${target.name}.");
|
||||
|
||||
@@ -17,9 +17,9 @@ class ShopProvider with ChangeNotifier {
|
||||
|
||||
void generateShopItems(int stage) {
|
||||
ItemTier currentTier = ItemTier.tier1;
|
||||
if (stage > GameConfig.tier2StageMax)
|
||||
if (stage > GameConfig.tier2StageMax) {
|
||||
currentTier = ItemTier.tier3;
|
||||
else if (stage > GameConfig.tier1StageMax)
|
||||
} else if (stage > GameConfig.tier1StageMax)
|
||||
currentTier = ItemTier.tier2;
|
||||
|
||||
availableItems = [];
|
||||
|
||||
+551
-342
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,6 @@ import '../widgets.dart';
|
||||
import '../game/save_manager.dart';
|
||||
import '../providers.dart';
|
||||
import '../game/config.dart';
|
||||
import '../widgets/test/sprite_animation_widget.dart';
|
||||
|
||||
class MainMenuScreen extends StatefulWidget {
|
||||
const MainMenuScreen({super.key});
|
||||
|
||||
@@ -39,7 +39,7 @@ class SettingsScreen extends StatelessWidget {
|
||||
onChanged: (value) {
|
||||
settings.toggleEnemyAnimations(value);
|
||||
},
|
||||
activeColor: ThemeConfig.btnActionActive,
|
||||
activeThumbColor: ThemeConfig.btnActionActive,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Text(
|
||||
|
||||
@@ -33,6 +33,36 @@ class ItemUtils {
|
||||
}
|
||||
}
|
||||
|
||||
static String getSlotLabel(EquipmentSlot slot) {
|
||||
switch (slot) {
|
||||
case EquipmentSlot.weapon:
|
||||
return 'MAIN';
|
||||
case EquipmentSlot.shield:
|
||||
return 'SUB';
|
||||
case EquipmentSlot.armor:
|
||||
return 'ARMOR';
|
||||
case EquipmentSlot.accessory:
|
||||
return 'ACCESSORY';
|
||||
case EquipmentSlot.consumable:
|
||||
return 'ITEM';
|
||||
}
|
||||
}
|
||||
|
||||
static String getSlotName(EquipmentSlot slot) {
|
||||
switch (slot) {
|
||||
case EquipmentSlot.weapon:
|
||||
return 'Main Weapon';
|
||||
case EquipmentSlot.shield:
|
||||
return 'Subweapon';
|
||||
case EquipmentSlot.armor:
|
||||
return 'Armor';
|
||||
case EquipmentSlot.accessory:
|
||||
return 'Accessory';
|
||||
case EquipmentSlot.consumable:
|
||||
return 'Item';
|
||||
}
|
||||
}
|
||||
|
||||
static String getBorderPath(ItemRarity rarity) {
|
||||
switch (rarity) {
|
||||
case ItemRarity.normal:
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../providers/settings_provider.dart';
|
||||
import '../../game/enums.dart';
|
||||
import '../../game/config.dart';
|
||||
|
||||
class BattleAnimationWidget extends StatefulWidget {
|
||||
final Widget child;
|
||||
@@ -24,11 +25,11 @@ class BattleAnimationWidgetState extends State<BattleAnimationWidget>
|
||||
super.initState();
|
||||
_scaleController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 800),
|
||||
duration: AnimationConfig.attackRiskyScale,
|
||||
);
|
||||
_translateController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1000),
|
||||
duration: AnimationConfig.attackRiskyDash,
|
||||
);
|
||||
|
||||
_scaleAnimation = Tween<double>(
|
||||
@@ -58,80 +59,69 @@ class BattleAnimationWidgetState extends State<BattleAnimationWidget>
|
||||
VoidCallback? onAnimationMiddle,
|
||||
VoidCallback? onAnimationEnd,
|
||||
}) async {
|
||||
// onAnimationStart?.call(); // Start Phase
|
||||
_resetControllers();
|
||||
onAnimationStart?.call();
|
||||
|
||||
if (risk == RiskLevel.safe || risk == RiskLevel.normal) {
|
||||
// Safe & Normal: Dash/Wobble without scale
|
||||
final isSafe = risk == RiskLevel.safe;
|
||||
final duration = isSafe ? 500 : 400;
|
||||
final duration = AnimationConfig.getAttackDuration(risk);
|
||||
final offsetFactor = isSafe ? 0.2 : 0.5;
|
||||
final curve = isSafe
|
||||
? AnimationConfig.attackSafeCurve
|
||||
: AnimationConfig.attackNormalCurve;
|
||||
|
||||
_translateController.duration = Duration(milliseconds: duration);
|
||||
_translateAnimation =
|
||||
Tween<Offset>(
|
||||
begin: Offset.zero,
|
||||
end: targetOffset * offsetFactor,
|
||||
).animate(
|
||||
CurvedAnimation(
|
||||
parent: _translateController,
|
||||
curve: Curves.easeOutQuad,
|
||||
),
|
||||
);
|
||||
_translateController.duration = duration;
|
||||
_translateAnimation = Tween<Offset>(
|
||||
begin: Offset.zero,
|
||||
end: targetOffset * offsetFactor,
|
||||
).animate(CurvedAnimation(parent: _translateController, curve: curve));
|
||||
|
||||
await _translateController.forward();
|
||||
if (!mounted) return;
|
||||
|
||||
// onAnimationMiddle?.call(); // Middle Phase
|
||||
onAnimationEnd?.call();
|
||||
onImpact();
|
||||
|
||||
await _translateController.reverse();
|
||||
} else {
|
||||
onAnimationStart?.call(); // Start Phase
|
||||
// Risky: Scale + Heavy Dash
|
||||
final attackScale = context.read<SettingsProvider>().attackAnimScale;
|
||||
_scaleAnimation = Tween<double>(begin: 1.0, end: attackScale).animate(
|
||||
CurvedAnimation(parent: _scaleController, curve: Curves.easeOut),
|
||||
);
|
||||
|
||||
_scaleController.duration = const Duration(milliseconds: 600);
|
||||
_translateController.duration = const Duration(milliseconds: 500);
|
||||
_scaleController.duration = AnimationConfig.attackRiskyScale;
|
||||
_translateController.duration = AnimationConfig.attackRiskyDash;
|
||||
|
||||
// 1. Scale Up (Preparation)
|
||||
await _scaleController.forward();
|
||||
if (!mounted) return;
|
||||
|
||||
onAnimationMiddle?.call(); // Middle Phase
|
||||
onAnimationMiddle?.call();
|
||||
|
||||
// 2. Dash to Target (Impact)
|
||||
// Adjust offset to prevent complete overlap (stop slightly short) since both share the same layer stack
|
||||
final adjustedOffset = targetOffset * 0.5;
|
||||
|
||||
_translateAnimation =
|
||||
Tween<Offset>(begin: Offset.zero, end: adjustedOffset).animate(
|
||||
CurvedAnimation(
|
||||
parent: _translateController,
|
||||
curve: Curves.easeInExpo, // Heavy impact curve
|
||||
curve: AnimationConfig.attackRiskyDashCurve,
|
||||
),
|
||||
);
|
||||
|
||||
await _translateController.forward();
|
||||
if (!mounted) return;
|
||||
|
||||
// onAnimationEnd?.call(); // End Phase (Moved before Impact)
|
||||
|
||||
// 3. Impact Callback (Shake)
|
||||
onAnimationEnd?.call();
|
||||
onImpact();
|
||||
|
||||
// 4. Return (Reset)
|
||||
_scaleController.reverse();
|
||||
await _translateController.reverse();
|
||||
await Future.wait([
|
||||
_scaleController.reverse(),
|
||||
_translateController.reverse(),
|
||||
]);
|
||||
}
|
||||
|
||||
// onAnimationEnd removed from here
|
||||
}
|
||||
|
||||
Future<void> animateDefense(VoidCallback onImpact) async {
|
||||
// Defense: Wobble/Shake horizontally
|
||||
_resetControllers();
|
||||
_translateController.duration = const Duration(milliseconds: 800);
|
||||
|
||||
// Sequence: Left -> Right -> Center
|
||||
@@ -165,6 +155,17 @@ class BattleAnimationWidgetState extends State<BattleAnimationWidget>
|
||||
_translateController.reset();
|
||||
}
|
||||
|
||||
void _resetControllers() {
|
||||
if (_scaleController.isAnimating) {
|
||||
_scaleController.stop();
|
||||
}
|
||||
if (_translateController.isAnimating) {
|
||||
_translateController.stop();
|
||||
}
|
||||
_scaleController.reset();
|
||||
_translateController.reset();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
|
||||
@@ -84,7 +84,9 @@ class CharacterStatusCard extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
"${effect.type.name.toUpperCase()} (${effect.duration})",
|
||||
effect.stacks > 1
|
||||
? "${effect.type.name.toUpperCase()} x${effect.stacks} (${effect.duration})"
|
||||
: "${effect.type.name.toUpperCase()} (${effect.duration})",
|
||||
style: const TextStyle(
|
||||
color: ThemeConfig.effectText,
|
||||
fontSize: ThemeConfig.statusEffectFontSize,
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' as ui;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class SpriteEffect {
|
||||
final Offset position;
|
||||
final String assetPath;
|
||||
final int frameCount;
|
||||
final double tileWidth;
|
||||
final double tileHeight;
|
||||
final double scale;
|
||||
|
||||
ui.Image? image;
|
||||
int currentFrame = 0;
|
||||
bool isFinished = false;
|
||||
|
||||
SpriteEffect({
|
||||
required this.position,
|
||||
required this.assetPath,
|
||||
required this.frameCount,
|
||||
this.tileWidth = 100.0,
|
||||
this.tileHeight = 100.0,
|
||||
this.scale = 2.0,
|
||||
});
|
||||
}
|
||||
|
||||
class EffectSpriteWidget extends StatefulWidget {
|
||||
const EffectSpriteWidget({super.key});
|
||||
|
||||
@override
|
||||
EffectSpriteWidgetState createState() => EffectSpriteWidgetState();
|
||||
}
|
||||
|
||||
class EffectSpriteWidgetState extends State<EffectSpriteWidget>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
final List<SpriteEffect> _effects = [];
|
||||
final Map<String, ui.Image> _imageCache = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Approximately 10 FPS (100ms per frame)
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 100),
|
||||
);
|
||||
_controller.addStatusListener((status) {
|
||||
if (status == AnimationStatus.completed) {
|
||||
_updateFrames();
|
||||
if (_effects.isNotEmpty) {
|
||||
_controller.forward(from: 0);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> playEffect({
|
||||
required Offset position,
|
||||
required String assetPath,
|
||||
required int frameCount,
|
||||
double tileWidth = 100.0,
|
||||
double tileHeight = 100.0,
|
||||
double scale = 2.0,
|
||||
}) async {
|
||||
final effect = SpriteEffect(
|
||||
position: position,
|
||||
assetPath: assetPath,
|
||||
frameCount: frameCount,
|
||||
tileWidth: tileWidth,
|
||||
tileHeight: tileHeight,
|
||||
scale: scale,
|
||||
);
|
||||
|
||||
// Preload image if not cached
|
||||
if (!_imageCache.containsKey(assetPath)) {
|
||||
try {
|
||||
final ByteData data = await rootBundle.load(assetPath);
|
||||
final List<int> bytes = data.buffer.asUint8List();
|
||||
final Completer<ui.Image> completer = Completer();
|
||||
ui.decodeImageFromList(Uint8List.fromList(bytes), (ui.Image img) {
|
||||
completer.complete(img);
|
||||
});
|
||||
_imageCache[assetPath] = await completer.future;
|
||||
} catch (e) {
|
||||
debugPrint('Failed to load effect image $assetPath: $e');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
effect.image = _imageCache[assetPath];
|
||||
|
||||
setState(() {
|
||||
_effects.add(effect);
|
||||
if (!_controller.isAnimating) {
|
||||
_controller.forward(from: 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _updateFrames() {
|
||||
if (_effects.isEmpty) return;
|
||||
|
||||
setState(() {
|
||||
for (var i = _effects.length - 1; i >= 0; i--) {
|
||||
final effect = _effects[i];
|
||||
effect.currentFrame++;
|
||||
if (effect.currentFrame >= effect.frameCount) {
|
||||
effect.isFinished = true;
|
||||
_effects.removeAt(i);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_effects.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return IgnorePointer(
|
||||
child: CustomPaint(
|
||||
size: Size.infinite,
|
||||
painter: MultiSpriteEffectPainter(effects: _effects),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MultiSpriteEffectPainter extends CustomPainter {
|
||||
final List<SpriteEffect> effects;
|
||||
|
||||
MultiSpriteEffectPainter({required this.effects});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
for (final effect in effects) {
|
||||
if (effect.image == null) continue;
|
||||
|
||||
final double srcX = effect.currentFrame * effect.tileWidth;
|
||||
final double srcY = 0.0;
|
||||
final Rect src = Rect.fromLTWH(srcX, srcY, effect.tileWidth, effect.tileHeight);
|
||||
|
||||
final double drawWidth = effect.tileWidth * effect.scale;
|
||||
final double drawHeight = effect.tileHeight * effect.scale;
|
||||
// Center the effect on the position
|
||||
final Rect dst = Rect.fromLTWH(
|
||||
effect.position.dx - drawWidth / 2,
|
||||
effect.position.dy - drawHeight / 2,
|
||||
drawWidth,
|
||||
drawHeight,
|
||||
);
|
||||
|
||||
canvas.drawImageRect(
|
||||
effect.image!,
|
||||
src,
|
||||
dst,
|
||||
Paint()..filterQuality = FilterQuality.none,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant MultiSpriteEffectPainter oldDelegate) {
|
||||
return true; // Repaint constantly while animating
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,8 @@ class ExplosionWidgetState extends State<ExplosionWidget>
|
||||
final List<Particle> _particles = [];
|
||||
final Random _random = Random();
|
||||
|
||||
bool get isAnimating => _controller.isAnimating || _particles.isNotEmpty;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -127,7 +129,7 @@ class ExplosionPainter extends CustomPainter {
|
||||
void paint(Canvas canvas, Size size) {
|
||||
for (final p in particles) {
|
||||
final paint = Paint()
|
||||
..color = p.color.withOpacity(p.life.clamp(0.0, 1.0))
|
||||
..color = p.color.withValues(alpha: p.life.clamp(0.0, 1.0))
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
canvas.drawCircle(p.position, p.size, paint);
|
||||
|
||||
@@ -8,11 +8,11 @@ class FloatingDamageText extends StatefulWidget {
|
||||
final VoidCallback onRemove;
|
||||
|
||||
const FloatingDamageText({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.damage,
|
||||
required this.color,
|
||||
required this.onRemove,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
FloatingDamageTextState createState() => FloatingDamageTextState();
|
||||
@@ -111,12 +111,12 @@ class FloatingEffect extends StatefulWidget {
|
||||
final VoidCallback onRemove;
|
||||
|
||||
const FloatingEffect({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.size,
|
||||
required this.onRemove,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
FloatingEffectState createState() => FloatingEffectState();
|
||||
@@ -193,11 +193,11 @@ class FloatingFeedbackText extends StatefulWidget {
|
||||
final VoidCallback onRemove;
|
||||
|
||||
const FloatingFeedbackText({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.feedback,
|
||||
required this.color,
|
||||
required this.onRemove,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
FloatingFeedbackTextState createState() => FloatingFeedbackTextState();
|
||||
|
||||
@@ -9,6 +9,7 @@ class ItemCardWidget extends StatelessWidget {
|
||||
final VoidCallback? onTap;
|
||||
final bool showPrice;
|
||||
final bool canBuy;
|
||||
final bool compact;
|
||||
|
||||
const ItemCardWidget({
|
||||
super.key,
|
||||
@@ -16,6 +17,7 @@ class ItemCardWidget extends StatelessWidget {
|
||||
this.onTap,
|
||||
this.showPrice = false,
|
||||
this.canBuy = true,
|
||||
this.compact = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -38,12 +40,12 @@ class ItemCardWidget extends StatelessWidget {
|
||||
children: [
|
||||
// Background Watermark/Silhouette Icon (Top-Left)
|
||||
Positioned(
|
||||
left: 8,
|
||||
top: 8,
|
||||
left: compact ? 4 : 8,
|
||||
top: compact ? 4 : 8,
|
||||
child: Image.asset(
|
||||
ItemUtils.getIconPath(item.slot),
|
||||
width: 32,
|
||||
height: 32,
|
||||
width: compact ? 24 : 32,
|
||||
height: compact ? 24 : 32,
|
||||
fit: BoxFit.contain,
|
||||
color: Colors.black12, // Shadow silhouette
|
||||
),
|
||||
@@ -51,12 +53,12 @@ class ItemCardWidget extends StatelessWidget {
|
||||
// Main Content (Centered)
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(4.0),
|
||||
padding: EdgeInsets.all(compact ? 2.0 : 4.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
if (!compact) const SizedBox(height: 12),
|
||||
Text(
|
||||
item.name,
|
||||
maxLines: 1,
|
||||
@@ -65,15 +67,23 @@ class ItemCardWidget extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: ItemUtils.getRarityColor(item.rarity),
|
||||
fontSize: 12,
|
||||
fontSize: compact ? 10 : 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
if (item.weaponType != null)
|
||||
Text(
|
||||
item.weaponType == WeaponType.oneHanded ? "1-Handed" : "2-Handed",
|
||||
style: const TextStyle(fontSize: 9, color: ThemeConfig.textColorGrey),
|
||||
),
|
||||
SizedBox(height: compact ? 1 : 4),
|
||||
// Show Item Stats
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: _buildItemStatText(item),
|
||||
),
|
||||
if (compact)
|
||||
_buildCompactItemStatText(item)
|
||||
else
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: _buildItemStatText(item),
|
||||
),
|
||||
if (showPrice) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
@@ -98,12 +108,52 @@ class ItemCardWidget extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCompactItemStatText(Item item) {
|
||||
final stats = <String>[];
|
||||
|
||||
if (item.atkBonus != 0) {
|
||||
stats.add("${_sign(item.atkBonus)}${item.atkBonus}A");
|
||||
}
|
||||
if (item.hpBonus != 0) {
|
||||
stats.add("${_sign(item.hpBonus)}${item.hpBonus}H");
|
||||
}
|
||||
if (item.armorBonus != 0) {
|
||||
stats.add("${_sign(item.armorBonus)}${item.armorBonus}D");
|
||||
}
|
||||
if (item.luck != 0) {
|
||||
stats.add("${_sign(item.luck)}${item.luck}L");
|
||||
}
|
||||
|
||||
final effect = item.effects.isNotEmpty
|
||||
? item.effects.first.type.name.toUpperCase()
|
||||
: null;
|
||||
final text = [
|
||||
if (stats.isNotEmpty) stats.join(" "),
|
||||
if (effect != null) effect,
|
||||
].join(" ");
|
||||
|
||||
if (text.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Text(
|
||||
text,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: ThemeConfig.fontSizeTiny,
|
||||
color: ThemeConfig.statAtkColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _sign(int value) => value > 0 ? "+" : "";
|
||||
|
||||
Widget _buildItemStatText(Item item) {
|
||||
List<String> stats = [];
|
||||
|
||||
// Helper to format stat string
|
||||
String formatStat(int value, String label) {
|
||||
String sign = value > 0 ? "+" : ""; // Negative values already have '-'
|
||||
String sign = _sign(value); // Negative values already have '-'
|
||||
return "$sign$value $label";
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,14 @@ class EquippedItemsWidget extends StatelessWidget {
|
||||
.where((slot) => slot != EquipmentSlot.consumable)
|
||||
.map((slot) {
|
||||
final item = player.equipment[slot];
|
||||
bool isShieldLocked = false;
|
||||
if (slot == EquipmentSlot.shield) {
|
||||
final mainWeapon = player.equipment[EquipmentSlot.weapon];
|
||||
if (mainWeapon != null && mainWeapon.weaponType == WeaponType.twoHanded) {
|
||||
isShieldLocked = true;
|
||||
}
|
||||
}
|
||||
|
||||
return Expanded(
|
||||
child: InkWell(
|
||||
onTap: item != null
|
||||
@@ -45,7 +53,7 @@ class EquippedItemsWidget extends StatelessWidget {
|
||||
child: Card(
|
||||
color: item != null
|
||||
? ThemeConfig.equipmentCardBg
|
||||
: ThemeConfig.emptySlotBg,
|
||||
: (isShieldLocked ? Colors.black26 : ThemeConfig.emptySlotBg),
|
||||
shape:
|
||||
item != null && item.rarity != ItemRarity.magic
|
||||
? RoundedRectangleBorder(
|
||||
@@ -65,7 +73,7 @@ class EquippedItemsWidget extends StatelessWidget {
|
||||
right: 4,
|
||||
top: 4,
|
||||
child: Text(
|
||||
slot.name.toUpperCase(),
|
||||
ItemUtils.getSlotLabel(slot),
|
||||
style: const TextStyle(
|
||||
fontSize: ThemeConfig.fontSizeTiny,
|
||||
fontWeight: ThemeConfig.fontWeightBold,
|
||||
@@ -78,7 +86,7 @@ class EquippedItemsWidget extends StatelessWidget {
|
||||
left: 4,
|
||||
top: 4,
|
||||
child: Opacity(
|
||||
opacity: item != null ? 0.5 : 0.2,
|
||||
opacity: item != null ? 0.5 : (isShieldLocked ? 0.1 : 0.2),
|
||||
child: Image.asset(
|
||||
ItemUtils.getIconPath(slot),
|
||||
width: 40,
|
||||
@@ -100,7 +108,7 @@ class EquippedItemsWidget extends StatelessWidget {
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
item?.name ?? AppStrings.emptySlot,
|
||||
item?.name ?? (isShieldLocked ? "Locked (2H)" : AppStrings.emptySlot),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize:
|
||||
@@ -111,7 +119,7 @@ class EquippedItemsWidget extends StatelessWidget {
|
||||
? ItemUtils.getRarityColor(
|
||||
item.rarity,
|
||||
)
|
||||
: ThemeConfig.textColorGrey,
|
||||
: (isShieldLocked ? Colors.red.withOpacity(0.5) : ThemeConfig.textColorGrey),
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
@@ -181,6 +189,16 @@ class EquippedItemsWidget extends StatelessWidget {
|
||||
_buildStatChangeRow("Current HP", currentHp, newHp),
|
||||
_buildStatChangeRow(AppStrings.atk, currentAtk, newAtk),
|
||||
_buildStatChangeRow(AppStrings.def, currentDef, newDef),
|
||||
if (itemToUnequip.effects.isNotEmpty) ...[
|
||||
const Divider(color: ThemeConfig.textColorGrey, height: 16),
|
||||
...itemToUnequip.effects.map((e) => Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text("- ", style: TextStyle(color: ThemeConfig.textColorGrey, fontSize: 12)),
|
||||
Expanded(child: Text(e.description, style: const TextStyle(color: ThemeConfig.textColorGrey, fontSize: 12))),
|
||||
],
|
||||
)),
|
||||
],
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
|
||||
@@ -4,75 +4,123 @@ import '../../providers.dart';
|
||||
import '../../game/models.dart';
|
||||
import '../../game/enums.dart';
|
||||
import '../../game/config.dart';
|
||||
import '../../utils.dart';
|
||||
import '../common/item_card_widget.dart';
|
||||
|
||||
enum InventoryGridMode { normal, shop, equipmentSwap }
|
||||
|
||||
class InventoryGridWidget extends StatelessWidget {
|
||||
const InventoryGridWidget({super.key});
|
||||
final InventoryGridMode mode;
|
||||
final bool equipmentOnly;
|
||||
final bool showHeader;
|
||||
final int crossAxisCount;
|
||||
final EdgeInsetsGeometry gridPadding;
|
||||
final double childAspectRatio;
|
||||
|
||||
const InventoryGridWidget({
|
||||
super.key,
|
||||
this.mode = InventoryGridMode.normal,
|
||||
this.equipmentOnly = false,
|
||||
this.showHeader = true,
|
||||
this.crossAxisCount = 4,
|
||||
this.gridPadding = const EdgeInsets.all(16.0),
|
||||
this.childAspectRatio = 1.0,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<BattleProvider>(
|
||||
builder: (context, battleProvider, child) {
|
||||
final player = battleProvider.player;
|
||||
final items = equipmentOnly
|
||||
? player.inventory
|
||||
.where((item) => item.slot != EquipmentSlot.consumable)
|
||||
.toList()
|
||||
: player.inventory;
|
||||
final itemCount = mode == InventoryGridMode.equipmentSwap
|
||||
? items.length
|
||||
: player.maxInventorySize;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16.0,
|
||||
vertical: 8.0,
|
||||
),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
"${AppStrings.bag} (${player.inventory.length}/${player.maxInventorySize})",
|
||||
style: const TextStyle(
|
||||
fontSize: ThemeConfig.fontSizeHeader,
|
||||
fontWeight: ThemeConfig.fontWeightBold,
|
||||
if (showHeader)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16.0,
|
||||
vertical: 8.0,
|
||||
),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
"${equipmentOnly ? AppStrings.equipment : AppStrings.bag} (${items.length}/${player.maxInventorySize})",
|
||||
style: const TextStyle(
|
||||
fontSize: ThemeConfig.fontSizeHeader,
|
||||
fontWeight: ThemeConfig.fontWeightBold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: GridView.builder(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 4,
|
||||
crossAxisSpacing: 8.0,
|
||||
mainAxisSpacing: 8.0,
|
||||
),
|
||||
itemCount: player.maxInventorySize,
|
||||
itemBuilder: (context, index) {
|
||||
if (index < player.inventory.length) {
|
||||
final item = player.inventory[index];
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
_showItemActionDialog(context, battleProvider, item);
|
||||
child: itemCount == 0
|
||||
? const Center(
|
||||
child: Text(
|
||||
"No equipment",
|
||||
style: TextStyle(color: ThemeConfig.textColorGrey),
|
||||
),
|
||||
)
|
||||
: GridView.builder(
|
||||
padding: gridPadding,
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: crossAxisCount,
|
||||
crossAxisSpacing: 8.0,
|
||||
mainAxisSpacing: 8.0,
|
||||
childAspectRatio: childAspectRatio,
|
||||
),
|
||||
itemCount: itemCount,
|
||||
itemBuilder: (context, index) {
|
||||
if (index < items.length) {
|
||||
final item = items[index];
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
if (mode == InventoryGridMode.equipmentSwap) {
|
||||
_showEquipSlotDialog(
|
||||
context,
|
||||
battleProvider,
|
||||
item,
|
||||
);
|
||||
} else {
|
||||
_showItemActionDialog(
|
||||
context,
|
||||
battleProvider,
|
||||
item,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: ItemCardWidget(
|
||||
item: item,
|
||||
showPrice: false,
|
||||
canBuy: false,
|
||||
compact: mode == InventoryGridMode.equipmentSwap,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: ThemeConfig.textColorGrey,
|
||||
),
|
||||
color: ThemeConfig.emptySlotBg,
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
Icons.add_box,
|
||||
color: ThemeConfig.textColorGrey,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: ItemCardWidget(
|
||||
item: item,
|
||||
// Inventory items usually don't show price unless in sell mode,
|
||||
// but logic here implies standard view.
|
||||
// If needed, we can toggle showPrice based on context.
|
||||
showPrice: false,
|
||||
canBuy: false,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: ThemeConfig.textColorGrey),
|
||||
color: ThemeConfig.emptySlotBg,
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
Icons.add_box,
|
||||
color: ThemeConfig.textColorGrey,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -85,7 +133,11 @@ class InventoryGridWidget extends StatelessWidget {
|
||||
BattleProvider provider,
|
||||
Item item,
|
||||
) {
|
||||
bool isShop = provider.currentStage.type == StageType.shop;
|
||||
final isShop =
|
||||
mode == InventoryGridMode.shop ||
|
||||
(mode == InventoryGridMode.normal &&
|
||||
provider.currentStage.type == StageType.shop);
|
||||
final isEquipmentSwap = mode == InventoryGridMode.equipmentSwap;
|
||||
int sellPrice = (item.price * GameConfig.sellPriceMultiplier).floor();
|
||||
|
||||
showDialog(
|
||||
@@ -114,7 +166,7 @@ class InventoryGridWidget extends StatelessWidget {
|
||||
SimpleDialogOption(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
_showEquipConfirmationDialog(context, provider, item);
|
||||
_showEquipSlotDialog(context, provider, item);
|
||||
},
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8.0),
|
||||
@@ -147,27 +199,84 @@ class InventoryGridWidget extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
SimpleDialogOption(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
_showDiscardConfirmationDialog(context, provider, item);
|
||||
},
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.delete, color: ThemeConfig.btnActionActive),
|
||||
SizedBox(width: 10),
|
||||
Text(AppStrings.discard),
|
||||
],
|
||||
if (!isEquipmentSwap)
|
||||
SimpleDialogOption(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
_showDiscardConfirmationDialog(context, provider, item);
|
||||
},
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.delete, color: ThemeConfig.btnActionActive),
|
||||
SizedBox(width: 10),
|
||||
Text(AppStrings.discard),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showEquipSlotDialog(
|
||||
BuildContext context,
|
||||
BattleProvider provider,
|
||||
Item newItem,
|
||||
) {
|
||||
final compatibleSlots = newItem.compatibleEquipSlots;
|
||||
if (compatibleSlots.isEmpty) return;
|
||||
|
||||
if (compatibleSlots.length == 1) {
|
||||
_showEquipConfirmationDialog(
|
||||
context,
|
||||
provider,
|
||||
newItem,
|
||||
compatibleSlots.first,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => SimpleDialog(
|
||||
title: Text("Equip ${newItem.name}"),
|
||||
children: compatibleSlots
|
||||
.map(
|
||||
(slot) => SimpleDialogOption(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
_showEquipConfirmationDialog(
|
||||
context,
|
||||
provider,
|
||||
newItem,
|
||||
slot,
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Image.asset(
|
||||
ItemUtils.getIconPath(slot),
|
||||
width: ThemeConfig.itemIconSizeMedium,
|
||||
height: ThemeConfig.itemIconSizeMedium,
|
||||
color: ThemeConfig.textColorWhite,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(ItemUtils.getSlotName(slot)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showSellConfirmationDialog(
|
||||
BuildContext context,
|
||||
BattleProvider provider,
|
||||
@@ -237,9 +346,10 @@ class InventoryGridWidget extends StatelessWidget {
|
||||
BuildContext context,
|
||||
BattleProvider provider,
|
||||
Item newItem,
|
||||
EquipmentSlot targetSlot,
|
||||
) {
|
||||
final player = provider.player;
|
||||
final oldItem = player.equipment[newItem.slot];
|
||||
final oldItem = player.equipment[targetSlot];
|
||||
|
||||
final currentMaxHp = player.totalMaxHp;
|
||||
final currentAtk = player.totalAtk;
|
||||
@@ -263,7 +373,7 @@ class InventoryGridWidget extends StatelessWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
"${AppStrings.equip} ${newItem.name}?",
|
||||
"${AppStrings.equip} ${newItem.name} as ${ItemUtils.getSlotName(targetSlot)}?",
|
||||
style: const TextStyle(fontWeight: ThemeConfig.fontWeightBold),
|
||||
),
|
||||
if (oldItem != null)
|
||||
@@ -289,6 +399,25 @@ class InventoryGridWidget extends StatelessWidget {
|
||||
player.totalDodge,
|
||||
player.totalDodge - (oldItem?.dodge ?? 0) + newItem.dodge,
|
||||
),
|
||||
if (newItem.effects.isNotEmpty || (oldItem != null && oldItem.effects.isNotEmpty)) ...[
|
||||
const Divider(color: ThemeConfig.textColorGrey, height: 16),
|
||||
if (oldItem != null && oldItem.effects.isNotEmpty)
|
||||
...oldItem.effects.map((e) => Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text("- ", style: TextStyle(color: ThemeConfig.textColorGrey, fontSize: 12)),
|
||||
Expanded(child: Text(e.description, style: const TextStyle(color: ThemeConfig.textColorGrey, fontSize: 12))),
|
||||
],
|
||||
)),
|
||||
if (newItem.effects.isNotEmpty)
|
||||
...newItem.effects.map((e) => Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text("+ ", style: TextStyle(color: ThemeConfig.rarityLegendary, fontSize: 12, fontWeight: FontWeight.bold)),
|
||||
Expanded(child: Text(e.description, style: const TextStyle(color: ThemeConfig.rarityLegendary, fontSize: 12, fontWeight: FontWeight.bold))),
|
||||
],
|
||||
)),
|
||||
],
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
@@ -298,7 +427,7 @@ class InventoryGridWidget extends StatelessWidget {
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
provider.equipItem(newItem);
|
||||
provider.equipItem(newItem, targetSlot: targetSlot);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
child: const Text(AppStrings.confirm),
|
||||
|
||||
@@ -6,11 +6,11 @@ class ResponsiveContainer extends StatelessWidget {
|
||||
final double maxHeight;
|
||||
|
||||
const ResponsiveContainer({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.child,
|
||||
this.maxWidth = 600.0,
|
||||
this.maxHeight = 1000.0,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@@ -134,7 +134,10 @@ class ShopUI extends StatelessWidget {
|
||||
const Divider(color: ThemeConfig.textColorGrey),
|
||||
|
||||
// Player Inventory (Bottom Half)
|
||||
const Expanded(flex: 5, child: InventoryGridWidget()),
|
||||
const Expanded(
|
||||
flex: 5,
|
||||
child: InventoryGridWidget(mode: InventoryGridMode.shop),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user