feat(item): Phase 2 아이템 시스템 구현
- ItemStats, ItemRarity 클래스 추가 (아이템 스탯/희귀도) - EquipmentItem 클래스 추가 (개별 장비 아이템) - ItemService 추가 (아이템 생성/관리/무게 시스템) - Equipment 클래스 확장 (EquipmentItem 기반, 기존 API 호환) - CombatStats에서 장비 스탯 반영 - 레거시 세이브 파일 호환성 유지
This commit is contained in:
240
lib/src/core/engine/item_service.dart
Normal file
240
lib/src/core/engine/item_service.dart
Normal file
@@ -0,0 +1,240 @@
|
||||
import 'package:askiineverdie/src/core/model/equipment_item.dart';
|
||||
import 'package:askiineverdie/src/core/model/equipment_slot.dart';
|
||||
import 'package:askiineverdie/src/core/model/item_stats.dart';
|
||||
import 'package:askiineverdie/src/core/util/deterministic_random.dart';
|
||||
|
||||
/// 아이템 관리 서비스
|
||||
///
|
||||
/// 아이템 생성, 스탯 계산, 무게 관리, 자동 장착 로직을 담당.
|
||||
/// SRP 준수: 아이템 관련 로직만 처리.
|
||||
class ItemService {
|
||||
const ItemService({required this.rng});
|
||||
|
||||
final DeterministicRandom rng;
|
||||
|
||||
// ============================================================================
|
||||
// 무게 시스템
|
||||
// ============================================================================
|
||||
|
||||
/// STR 기반 최대 휴대 가능 무게
|
||||
///
|
||||
/// 기본 100 + STR당 10
|
||||
static int calculateMaxWeight(int str) {
|
||||
return 100 + str * 10;
|
||||
}
|
||||
|
||||
/// 장비 목록의 총 무게 계산
|
||||
static int calculateTotalWeight(List<EquipmentItem> items) {
|
||||
return items.fold(0, (sum, item) => sum + item.weight);
|
||||
}
|
||||
|
||||
/// 새 아이템 장착 가능 여부 (무게 기준)
|
||||
///
|
||||
/// [newItem] 장착하려는 아이템
|
||||
/// [currentItems] 현재 장착 중인 아이템 목록
|
||||
/// [str] 플레이어 STR
|
||||
/// [replacingSlot] 교체할 슬롯 (해당 슬롯 아이템 무게 제외)
|
||||
static bool canEquip({
|
||||
required EquipmentItem newItem,
|
||||
required List<EquipmentItem> currentItems,
|
||||
required int str,
|
||||
EquipmentSlot? replacingSlot,
|
||||
}) {
|
||||
final maxWeight = calculateMaxWeight(str);
|
||||
var currentWeight = calculateTotalWeight(currentItems);
|
||||
|
||||
// 교체할 슬롯이 있으면 해당 아이템 무게 제외
|
||||
if (replacingSlot != null) {
|
||||
final existingItem = currentItems.where((i) => i.slot == replacingSlot).firstOrNull;
|
||||
if (existingItem != null) {
|
||||
currentWeight -= existingItem.weight;
|
||||
}
|
||||
}
|
||||
|
||||
return currentWeight + newItem.weight <= maxWeight;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 희귀도 결정
|
||||
// ============================================================================
|
||||
|
||||
/// 희귀도 결정 (레벨 기반 확률)
|
||||
///
|
||||
/// 레벨이 높을수록 희귀한 아이템 확률 증가
|
||||
ItemRarity determineRarity(int level) {
|
||||
final roll = rng.nextInt(100);
|
||||
final legendaryChance = (level * 0.5).clamp(0, 5).toInt(); // 최대 5%
|
||||
final epicChance = (level * 1.0).clamp(0, 10).toInt(); // 최대 10%
|
||||
final rareChance = (level * 2.0).clamp(0, 20).toInt(); // 최대 20%
|
||||
final uncommonChance = (level * 3.0).clamp(0, 30).toInt(); // 최대 30%
|
||||
|
||||
if (roll < legendaryChance) return ItemRarity.legendary;
|
||||
if (roll < legendaryChance + epicChance) return ItemRarity.epic;
|
||||
if (roll < legendaryChance + epicChance + rareChance) return ItemRarity.rare;
|
||||
if (roll < legendaryChance + epicChance + rareChance + uncommonChance) {
|
||||
return ItemRarity.uncommon;
|
||||
}
|
||||
return ItemRarity.common;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 아이템 스탯 생성
|
||||
// ============================================================================
|
||||
|
||||
/// 아이템 스탯 생성 (레벨/희귀도/슬롯 기반)
|
||||
ItemStats generateItemStats({
|
||||
required int level,
|
||||
required ItemRarity rarity,
|
||||
required EquipmentSlot slot,
|
||||
}) {
|
||||
final baseValue = (level * 2 * rarity.multiplier).round();
|
||||
|
||||
return switch (slot) {
|
||||
EquipmentSlot.weapon => _generateWeaponStats(baseValue, rarity),
|
||||
EquipmentSlot.shield => _generateShieldStats(baseValue, rarity),
|
||||
_ => _generateArmorStats(baseValue, rarity, slot),
|
||||
};
|
||||
}
|
||||
|
||||
/// 무기 스탯 생성
|
||||
ItemStats _generateWeaponStats(int baseValue, ItemRarity rarity) {
|
||||
final criBonus = rarity.index >= ItemRarity.rare.index ? 0.02 + rarity.index * 0.01 : 0.0;
|
||||
final parryBonus = rarity.index >= ItemRarity.uncommon.index ? 0.01 + rarity.index * 0.005 : 0.0;
|
||||
|
||||
return ItemStats(
|
||||
atk: baseValue,
|
||||
criRate: criBonus,
|
||||
parryRate: parryBonus,
|
||||
);
|
||||
}
|
||||
|
||||
/// 방패 스탯 생성
|
||||
ItemStats _generateShieldStats(int baseValue, ItemRarity rarity) {
|
||||
final blockBonus = 0.05 + rarity.index * 0.02;
|
||||
|
||||
return ItemStats(
|
||||
def: baseValue ~/ 2,
|
||||
blockRate: blockBonus,
|
||||
);
|
||||
}
|
||||
|
||||
/// 방어구 스탯 생성
|
||||
ItemStats _generateArmorStats(int baseValue, ItemRarity rarity, EquipmentSlot slot) {
|
||||
// 슬롯별 방어력 가중치
|
||||
final defMultiplier = switch (slot) {
|
||||
EquipmentSlot.hauberk => 1.5, // 갑옷류 최고
|
||||
EquipmentSlot.helm => 1.2,
|
||||
EquipmentSlot.gambeson => 1.0,
|
||||
EquipmentSlot.cuisses => 0.9,
|
||||
EquipmentSlot.greaves => 0.8,
|
||||
EquipmentSlot.brassairts => 0.7,
|
||||
EquipmentSlot.vambraces => 0.7,
|
||||
EquipmentSlot.gauntlets => 0.6,
|
||||
EquipmentSlot.sollerets => 0.6,
|
||||
_ => 0.5,
|
||||
};
|
||||
|
||||
final def = (baseValue * defMultiplier).round();
|
||||
|
||||
// 희귀도에 따른 추가 보너스
|
||||
final hpBonus = rarity.index >= ItemRarity.rare.index ? baseValue ~/ 2 : 0;
|
||||
final evasionBonus = rarity.index >= ItemRarity.epic.index ? 0.01 : 0.0;
|
||||
|
||||
return ItemStats(
|
||||
def: def,
|
||||
hpBonus: hpBonus,
|
||||
evasion: evasionBonus,
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 아이템 생성
|
||||
// ============================================================================
|
||||
|
||||
/// 무게 계산 (레벨/슬롯 기반)
|
||||
int calculateWeight({
|
||||
required int level,
|
||||
required EquipmentSlot slot,
|
||||
}) {
|
||||
// 슬롯별 기본 무게
|
||||
final baseWeight = switch (slot) {
|
||||
EquipmentSlot.weapon => 10,
|
||||
EquipmentSlot.shield => 15,
|
||||
EquipmentSlot.helm => 8,
|
||||
EquipmentSlot.hauberk => 25,
|
||||
EquipmentSlot.brassairts => 6,
|
||||
EquipmentSlot.vambraces => 5,
|
||||
EquipmentSlot.gauntlets => 4,
|
||||
EquipmentSlot.gambeson => 12,
|
||||
EquipmentSlot.cuisses => 10,
|
||||
EquipmentSlot.greaves => 8,
|
||||
EquipmentSlot.sollerets => 6,
|
||||
};
|
||||
|
||||
// 레벨에 따라 무게 증가 (고급 아이템일수록 무거움)
|
||||
return baseWeight + (level ~/ 5);
|
||||
}
|
||||
|
||||
/// 장비 아이템 생성
|
||||
///
|
||||
/// [name] 아이템 이름
|
||||
/// [slot] 장착 슬롯
|
||||
/// [level] 아이템 레벨
|
||||
/// [rarity] 희귀도 (null이면 자동 결정)
|
||||
EquipmentItem generateEquipment({
|
||||
required String name,
|
||||
required EquipmentSlot slot,
|
||||
required int level,
|
||||
ItemRarity? rarity,
|
||||
}) {
|
||||
final itemRarity = rarity ?? determineRarity(level);
|
||||
final stats = generateItemStats(level: level, rarity: itemRarity, slot: slot);
|
||||
final weight = calculateWeight(level: level, slot: slot);
|
||||
|
||||
return EquipmentItem(
|
||||
name: name,
|
||||
slot: slot,
|
||||
level: level,
|
||||
weight: weight,
|
||||
stats: stats,
|
||||
rarity: itemRarity,
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 자동 장착
|
||||
// ============================================================================
|
||||
|
||||
/// 새 아이템이 현재 장비보다 좋은지 비교
|
||||
///
|
||||
/// 가중치 기준으로 비교하며, 무게 제한도 고려
|
||||
bool shouldEquip({
|
||||
required EquipmentItem newItem,
|
||||
required EquipmentItem currentItem,
|
||||
required List<EquipmentItem> allEquipped,
|
||||
required int str,
|
||||
}) {
|
||||
// 빈 슬롯이면 무조건 장착
|
||||
if (currentItem.isEmpty) {
|
||||
return canEquip(
|
||||
newItem: newItem,
|
||||
currentItems: allEquipped,
|
||||
str: str,
|
||||
replacingSlot: newItem.slot,
|
||||
);
|
||||
}
|
||||
|
||||
// 새 아이템이 더 좋은지 확인
|
||||
if (newItem.itemWeight <= currentItem.itemWeight) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 무게 제한 확인
|
||||
return canEquip(
|
||||
newItem: newItem,
|
||||
currentItems: allEquipped,
|
||||
str: str,
|
||||
replacingSlot: newItem.slot,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -198,55 +198,71 @@ class CombatStats {
|
||||
/// Stats와 Equipment에서 CombatStats 생성
|
||||
///
|
||||
/// [stats] 캐릭터 기본 스탯
|
||||
/// [equipment] 장착 장비 (향후 장비 스탯 적용 시 사용)
|
||||
/// [equipment] 장착 장비 (장비 스탯 적용)
|
||||
/// [level] 캐릭터 레벨 (스케일링용)
|
||||
factory CombatStats.fromStats({
|
||||
required Stats stats,
|
||||
required Equipment equipment,
|
||||
required int level,
|
||||
}) {
|
||||
// 기본 공격력: STR 기반 + 레벨 보정
|
||||
final baseAtk = stats.str * 2 + level;
|
||||
// 장비 총 스탯 가져오기
|
||||
final equipStats = equipment.totalStats;
|
||||
|
||||
// 기본 방어력: CON 기반 + 레벨 보정
|
||||
final baseDef = stats.con + (level ~/ 2);
|
||||
// 장비 보너스가 적용된 기본 스탯
|
||||
final effectiveStr = stats.str + equipStats.strBonus;
|
||||
final effectiveCon = stats.con + equipStats.conBonus;
|
||||
final effectiveDex = stats.dex + equipStats.dexBonus;
|
||||
final effectiveInt = stats.intelligence + equipStats.intBonus;
|
||||
final effectiveWis = stats.wis + equipStats.wisBonus;
|
||||
|
||||
// 마법 공격력: INT 기반
|
||||
final baseMagAtk = stats.intelligence * 2 + level;
|
||||
// 기본 공격력: STR 기반 + 레벨 보정 + 장비 ATK
|
||||
final baseAtk = effectiveStr * 2 + level + equipStats.atk;
|
||||
|
||||
// 마법 방어력: WIS 기반
|
||||
final baseMagDef = stats.wis + (level ~/ 2);
|
||||
// 기본 방어력: CON 기반 + 레벨 보정 + 장비 DEF
|
||||
final baseDef = effectiveCon + (level ~/ 2) + equipStats.def;
|
||||
|
||||
// 크리티컬 확률: DEX 기반 (0.05 ~ 0.5)
|
||||
final criRate = (0.05 + stats.dex * 0.005).clamp(0.05, 0.5);
|
||||
// 마법 공격력: INT 기반 + 장비 MAG_ATK
|
||||
final baseMagAtk = effectiveInt * 2 + level + equipStats.magAtk;
|
||||
|
||||
// 마법 방어력: WIS 기반 + 장비 MAG_DEF
|
||||
final baseMagDef = effectiveWis + (level ~/ 2) + equipStats.magDef;
|
||||
|
||||
// 크리티컬 확률: DEX 기반 + 장비 보너스 (0.05 ~ 0.5)
|
||||
final criRate = (0.05 + effectiveDex * 0.005 + equipStats.criRate).clamp(0.05, 0.5);
|
||||
|
||||
// 크리티컬 데미지: 기본 1.5배, DEX에 따라 증가 (최대 3.0)
|
||||
final criDamage = (1.5 + stats.dex * 0.01).clamp(1.5, 3.0);
|
||||
final criDamage = (1.5 + effectiveDex * 0.01).clamp(1.5, 3.0);
|
||||
|
||||
// 회피율: DEX 기반 (0.0 ~ 0.5)
|
||||
final evasion = (stats.dex * 0.005).clamp(0.0, 0.5);
|
||||
// 회피율: DEX 기반 + 장비 보너스 (0.0 ~ 0.5)
|
||||
final evasion = (effectiveDex * 0.005 + equipStats.evasion).clamp(0.0, 0.5);
|
||||
|
||||
// 명중률: DEX 기반 (0.8 ~ 1.0)
|
||||
final accuracy = (0.8 + stats.dex * 0.002).clamp(0.8, 1.0);
|
||||
final accuracy = (0.8 + effectiveDex * 0.002).clamp(0.8, 1.0);
|
||||
|
||||
// 방패 방어율: 방패 장착 여부에 따라 (0.0 ~ 0.4)
|
||||
// 방패 방어율: 방패 장착 시 기본 + CON 보정 + 장비 보너스 (0.0 ~ 0.5)
|
||||
final hasShield = equipment.shield.isNotEmpty;
|
||||
final blockRate = hasShield ? (0.1 + stats.con * 0.003).clamp(0.1, 0.4) : 0.0;
|
||||
final baseBlockRate = hasShield ? (0.1 + effectiveCon * 0.003) : 0.0;
|
||||
final blockRate = (baseBlockRate + equipStats.blockRate).clamp(0.0, 0.5);
|
||||
|
||||
// 무기 쳐내기: DEX + STR 기반 (0.0 ~ 0.3)
|
||||
final parryRate = ((stats.dex + stats.str) * 0.002).clamp(0.0, 0.3);
|
||||
// 무기 쳐내기: DEX + STR 기반 + 장비 보너스 (0.0 ~ 0.4)
|
||||
final baseParryRate = (effectiveDex + effectiveStr) * 0.002;
|
||||
final parryRate = (baseParryRate + equipStats.parryRate).clamp(0.0, 0.4);
|
||||
|
||||
// 공격 속도: DEX 기반 (기본 1000ms, 최소 357ms)
|
||||
final speedModifier = 1.0 + (stats.dex - 10) * 0.02;
|
||||
final speedModifier = 1.0 + (effectiveDex - 10) * 0.02;
|
||||
final attackDelayMs = (1000 / speedModifier).round().clamp(357, 1500);
|
||||
|
||||
// HP/MP: 기본 + 장비 보너스
|
||||
final totalHpMax = stats.hpMax + equipStats.hpBonus;
|
||||
final totalMpMax = stats.mpMax + equipStats.mpBonus;
|
||||
|
||||
return CombatStats(
|
||||
str: stats.str,
|
||||
con: stats.con,
|
||||
dex: stats.dex,
|
||||
intelligence: stats.intelligence,
|
||||
wis: stats.wis,
|
||||
cha: stats.cha,
|
||||
str: effectiveStr,
|
||||
con: effectiveCon,
|
||||
dex: effectiveDex,
|
||||
intelligence: effectiveInt,
|
||||
wis: effectiveWis,
|
||||
cha: stats.cha + equipStats.chaBonus,
|
||||
atk: baseAtk,
|
||||
def: baseDef,
|
||||
magAtk: baseMagAtk,
|
||||
@@ -258,10 +274,10 @@ class CombatStats {
|
||||
blockRate: blockRate,
|
||||
parryRate: parryRate,
|
||||
attackDelayMs: attackDelayMs,
|
||||
hpMax: stats.hpMax,
|
||||
hpCurrent: stats.hp,
|
||||
mpMax: stats.mpMax,
|
||||
mpCurrent: stats.mp,
|
||||
hpMax: totalHpMax,
|
||||
hpCurrent: stats.hp.clamp(0, totalHpMax),
|
||||
mpMax: totalMpMax,
|
||||
mpCurrent: stats.mp.clamp(0, totalMpMax),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
94
lib/src/core/model/equipment_item.dart
Normal file
94
lib/src/core/model/equipment_item.dart
Normal file
@@ -0,0 +1,94 @@
|
||||
import 'package:askiineverdie/src/core/model/equipment_slot.dart';
|
||||
import 'package:askiineverdie/src/core/model/item_stats.dart';
|
||||
|
||||
/// 장비 아이템
|
||||
///
|
||||
/// 개별 장비의 이름, 슬롯, 레벨, 스탯 등을 포함하는 클래스.
|
||||
/// 불변(immutable) 객체로 설계됨.
|
||||
class EquipmentItem {
|
||||
const EquipmentItem({
|
||||
required this.name,
|
||||
required this.slot,
|
||||
required this.level,
|
||||
required this.weight,
|
||||
required this.stats,
|
||||
required this.rarity,
|
||||
});
|
||||
|
||||
/// 아이템 이름 (예: "Flaming Sword of Doom")
|
||||
final String name;
|
||||
|
||||
/// 장착 슬롯
|
||||
final EquipmentSlot slot;
|
||||
|
||||
/// 아이템 레벨
|
||||
final int level;
|
||||
|
||||
/// 무게 (STR 기반 휴대 제한용)
|
||||
final int weight;
|
||||
|
||||
/// 아이템 스탯 보정치
|
||||
final ItemStats stats;
|
||||
|
||||
/// 희귀도
|
||||
final ItemRarity rarity;
|
||||
|
||||
/// 가중치 (자동 장착 비교용)
|
||||
///
|
||||
/// 가중치 = 기본값 + (레벨 * 10) + (희귀도 보너스) + (스탯 합계)
|
||||
int get itemWeight {
|
||||
const baseValue = 10;
|
||||
return baseValue + (level * 10) + rarity.weightBonus + stats.totalStatValue;
|
||||
}
|
||||
|
||||
/// 빈 아이템 여부
|
||||
bool get isEmpty => name.isEmpty;
|
||||
|
||||
/// 유효한 아이템 여부
|
||||
bool get isNotEmpty => name.isNotEmpty;
|
||||
|
||||
/// 빈 아이템 생성 (특정 슬롯)
|
||||
factory EquipmentItem.empty(EquipmentSlot slot) {
|
||||
return EquipmentItem(
|
||||
name: '',
|
||||
slot: slot,
|
||||
level: 0,
|
||||
weight: 0,
|
||||
stats: ItemStats.empty,
|
||||
rarity: ItemRarity.common,
|
||||
);
|
||||
}
|
||||
|
||||
/// 기본 무기 (Keyboard)
|
||||
factory EquipmentItem.defaultWeapon() {
|
||||
return const EquipmentItem(
|
||||
name: 'Keyboard',
|
||||
slot: EquipmentSlot.weapon,
|
||||
level: 1,
|
||||
weight: 5,
|
||||
stats: ItemStats(atk: 1),
|
||||
rarity: ItemRarity.common,
|
||||
);
|
||||
}
|
||||
|
||||
EquipmentItem copyWith({
|
||||
String? name,
|
||||
EquipmentSlot? slot,
|
||||
int? level,
|
||||
int? weight,
|
||||
ItemStats? stats,
|
||||
ItemRarity? rarity,
|
||||
}) {
|
||||
return EquipmentItem(
|
||||
name: name ?? this.name,
|
||||
slot: slot ?? this.slot,
|
||||
level: level ?? this.level,
|
||||
weight: weight ?? this.weight,
|
||||
stats: stats ?? this.stats,
|
||||
rarity: rarity ?? this.rarity,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => name.isEmpty ? '(empty)' : name;
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:askiineverdie/src/core/model/combat_state.dart';
|
||||
import 'package:askiineverdie/src/core/model/equipment_item.dart';
|
||||
import 'package:askiineverdie/src/core/model/equipment_slot.dart';
|
||||
import 'package:askiineverdie/src/core/model/item_stats.dart';
|
||||
import 'package:askiineverdie/src/core/util/deterministic_random.dart';
|
||||
|
||||
/// Minimal skeletal state to mirror Progress Quest structures.
|
||||
@@ -275,33 +278,17 @@ class Inventory {
|
||||
}
|
||||
|
||||
/// 장비 (원본 Main.dfm Equips ListView, 11개 슬롯)
|
||||
///
|
||||
/// Phase 2에서 EquipmentItem 기반으로 확장됨.
|
||||
/// 기존 문자열 API(weapon, shield 등)는 호환성을 위해 유지.
|
||||
class Equipment {
|
||||
const Equipment({
|
||||
required this.weapon,
|
||||
required this.shield,
|
||||
required this.helm,
|
||||
required this.hauberk,
|
||||
required this.brassairts,
|
||||
required this.vambraces,
|
||||
required this.gauntlets,
|
||||
required this.gambeson,
|
||||
required this.cuisses,
|
||||
required this.greaves,
|
||||
required this.sollerets,
|
||||
Equipment({
|
||||
required this.items,
|
||||
required this.bestIndex,
|
||||
});
|
||||
}) : assert(items.length == slotCount, 'Equipment must have $slotCount items');
|
||||
|
||||
final String weapon; // 0: 무기
|
||||
final String shield; // 1: 방패
|
||||
final String helm; // 2: 투구
|
||||
final String hauberk; // 3: 사슬갑옷
|
||||
final String brassairts; // 4: 상완갑
|
||||
final String vambraces; // 5: 전완갑
|
||||
final String gauntlets; // 6: 건틀릿
|
||||
final String gambeson; // 7: 갬비슨
|
||||
final String cuisses; // 8: 허벅지갑
|
||||
final String greaves; // 9: 정강이갑
|
||||
final String sollerets; // 10: 철제신발
|
||||
/// 장비 아이템 목록 (11개 슬롯)
|
||||
final List<EquipmentItem> items;
|
||||
|
||||
/// 최고 아이템 슬롯 인덱스 (원본 Equips.Tag, 0-10)
|
||||
final int bestIndex;
|
||||
@@ -309,83 +296,168 @@ class Equipment {
|
||||
/// 슬롯 개수
|
||||
static const slotCount = 11;
|
||||
|
||||
factory Equipment.empty() => const Equipment(
|
||||
weapon: 'Keyboard',
|
||||
shield: '',
|
||||
helm: '',
|
||||
hauberk: '',
|
||||
brassairts: '',
|
||||
vambraces: '',
|
||||
gauntlets: '',
|
||||
gambeson: '',
|
||||
cuisses: '',
|
||||
greaves: '',
|
||||
sollerets: '',
|
||||
bestIndex: 0,
|
||||
);
|
||||
// ============================================================================
|
||||
// 문자열 API (기존 코드 호환성)
|
||||
// ============================================================================
|
||||
|
||||
/// 인덱스로 슬롯 값 가져오기
|
||||
String getByIndex(int index) {
|
||||
return switch (index) {
|
||||
0 => weapon,
|
||||
1 => shield,
|
||||
2 => helm,
|
||||
3 => hauberk,
|
||||
4 => brassairts,
|
||||
5 => vambraces,
|
||||
6 => gauntlets,
|
||||
7 => gambeson,
|
||||
8 => cuisses,
|
||||
9 => greaves,
|
||||
10 => sollerets,
|
||||
_ => '',
|
||||
};
|
||||
String get weapon => items[0].name; // 0: 무기
|
||||
String get shield => items[1].name; // 1: 방패
|
||||
String get helm => items[2].name; // 2: 투구
|
||||
String get hauberk => items[3].name; // 3: 사슬갑옷
|
||||
String get brassairts => items[4].name; // 4: 상완갑
|
||||
String get vambraces => items[5].name; // 5: 전완갑
|
||||
String get gauntlets => items[6].name; // 6: 건틀릿
|
||||
String get gambeson => items[7].name; // 7: 갬비슨
|
||||
String get cuisses => items[8].name; // 8: 허벅지갑
|
||||
String get greaves => items[9].name; // 9: 정강이갑
|
||||
String get sollerets => items[10].name; // 10: 철제신발
|
||||
|
||||
// ============================================================================
|
||||
// EquipmentItem API
|
||||
// ============================================================================
|
||||
|
||||
EquipmentItem get weaponItem => items[0];
|
||||
EquipmentItem get shieldItem => items[1];
|
||||
EquipmentItem get helmItem => items[2];
|
||||
EquipmentItem get hauberkItem => items[3];
|
||||
EquipmentItem get brassairtsItem => items[4];
|
||||
EquipmentItem get vambracesItem => items[5];
|
||||
EquipmentItem get gauntletsItem => items[6];
|
||||
EquipmentItem get gambesonItem => items[7];
|
||||
EquipmentItem get cuissesItem => items[8];
|
||||
EquipmentItem get greavesItem => items[9];
|
||||
EquipmentItem get solleretsItem => items[10];
|
||||
|
||||
/// 모든 장비 스탯 합산
|
||||
ItemStats get totalStats {
|
||||
return items.fold(
|
||||
ItemStats.empty,
|
||||
(sum, item) => sum + item.stats,
|
||||
);
|
||||
}
|
||||
|
||||
/// 인덱스로 슬롯 값 설정한 새 Equipment 반환
|
||||
/// 모든 장비 무게 합산
|
||||
int get totalWeight {
|
||||
return items.fold(0, (sum, item) => sum + item.weight);
|
||||
}
|
||||
|
||||
/// 장착된 아이템 목록 (빈 슬롯 제외)
|
||||
List<EquipmentItem> get equippedItems {
|
||||
return items.where((item) => item.isNotEmpty).toList();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 팩토리 메서드
|
||||
// ============================================================================
|
||||
|
||||
factory Equipment.empty() {
|
||||
return Equipment(
|
||||
items: [
|
||||
EquipmentItem.defaultWeapon(), // 0: 무기 (Keyboard)
|
||||
EquipmentItem.empty(EquipmentSlot.shield), // 1: 방패
|
||||
EquipmentItem.empty(EquipmentSlot.helm), // 2: 투구
|
||||
EquipmentItem.empty(EquipmentSlot.hauberk), // 3: 사슬갑옷
|
||||
EquipmentItem.empty(EquipmentSlot.brassairts), // 4: 상완갑
|
||||
EquipmentItem.empty(EquipmentSlot.vambraces), // 5: 전완갑
|
||||
EquipmentItem.empty(EquipmentSlot.gauntlets), // 6: 건틀릿
|
||||
EquipmentItem.empty(EquipmentSlot.gambeson), // 7: 갬비슨
|
||||
EquipmentItem.empty(EquipmentSlot.cuisses), // 8: 허벅지갑
|
||||
EquipmentItem.empty(EquipmentSlot.greaves), // 9: 정강이갑
|
||||
EquipmentItem.empty(EquipmentSlot.sollerets), // 10: 철제신발
|
||||
],
|
||||
bestIndex: 0,
|
||||
);
|
||||
}
|
||||
|
||||
/// 레거시 문자열 기반 생성자 (세이브 파일 호환용)
|
||||
factory Equipment.fromStrings({
|
||||
required String weapon,
|
||||
required String shield,
|
||||
required String helm,
|
||||
required String hauberk,
|
||||
required String brassairts,
|
||||
required String vambraces,
|
||||
required String gauntlets,
|
||||
required String gambeson,
|
||||
required String cuisses,
|
||||
required String greaves,
|
||||
required String sollerets,
|
||||
required int bestIndex,
|
||||
}) {
|
||||
return Equipment(
|
||||
items: [
|
||||
_itemFromString(weapon, EquipmentSlot.weapon),
|
||||
_itemFromString(shield, EquipmentSlot.shield),
|
||||
_itemFromString(helm, EquipmentSlot.helm),
|
||||
_itemFromString(hauberk, EquipmentSlot.hauberk),
|
||||
_itemFromString(brassairts, EquipmentSlot.brassairts),
|
||||
_itemFromString(vambraces, EquipmentSlot.vambraces),
|
||||
_itemFromString(gauntlets, EquipmentSlot.gauntlets),
|
||||
_itemFromString(gambeson, EquipmentSlot.gambeson),
|
||||
_itemFromString(cuisses, EquipmentSlot.cuisses),
|
||||
_itemFromString(greaves, EquipmentSlot.greaves),
|
||||
_itemFromString(sollerets, EquipmentSlot.sollerets),
|
||||
],
|
||||
bestIndex: bestIndex,
|
||||
);
|
||||
}
|
||||
|
||||
/// 문자열에서 기본 EquipmentItem 생성 (레거시 호환)
|
||||
static EquipmentItem _itemFromString(String name, EquipmentSlot slot) {
|
||||
if (name.isEmpty) {
|
||||
return EquipmentItem.empty(slot);
|
||||
}
|
||||
// 레거시 아이템: 레벨 1, Common, 기본 스탯
|
||||
return EquipmentItem(
|
||||
name: name,
|
||||
slot: slot,
|
||||
level: 1,
|
||||
weight: 5,
|
||||
stats: ItemStats.empty,
|
||||
rarity: ItemRarity.common,
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 유틸리티 메서드
|
||||
// ============================================================================
|
||||
|
||||
/// 인덱스로 슬롯 이름 가져오기 (기존 API 호환)
|
||||
String getByIndex(int index) {
|
||||
if (index < 0 || index >= slotCount) return '';
|
||||
return items[index].name;
|
||||
}
|
||||
|
||||
/// 인덱스로 EquipmentItem 가져오기
|
||||
EquipmentItem getItemByIndex(int index) {
|
||||
if (index < 0 || index >= slotCount) {
|
||||
return EquipmentItem.empty(EquipmentSlot.weapon);
|
||||
}
|
||||
return items[index];
|
||||
}
|
||||
|
||||
/// 인덱스로 슬롯 값 설정 (문자열, 기존 API 호환)
|
||||
Equipment setByIndex(int index, String value) {
|
||||
return switch (index) {
|
||||
0 => copyWith(weapon: value),
|
||||
1 => copyWith(shield: value),
|
||||
2 => copyWith(helm: value),
|
||||
3 => copyWith(hauberk: value),
|
||||
4 => copyWith(brassairts: value),
|
||||
5 => copyWith(vambraces: value),
|
||||
6 => copyWith(gauntlets: value),
|
||||
7 => copyWith(gambeson: value),
|
||||
8 => copyWith(cuisses: value),
|
||||
9 => copyWith(greaves: value),
|
||||
10 => copyWith(sollerets: value),
|
||||
_ => this,
|
||||
};
|
||||
if (index < 0 || index >= slotCount) return this;
|
||||
final slot = EquipmentSlot.values[index];
|
||||
final newItem = _itemFromString(value, slot);
|
||||
return setItemByIndex(index, newItem);
|
||||
}
|
||||
|
||||
/// 인덱스로 EquipmentItem 설정
|
||||
Equipment setItemByIndex(int index, EquipmentItem item) {
|
||||
if (index < 0 || index >= slotCount) return this;
|
||||
final newItems = List<EquipmentItem>.from(items);
|
||||
newItems[index] = item;
|
||||
return Equipment(items: newItems, bestIndex: bestIndex);
|
||||
}
|
||||
|
||||
Equipment copyWith({
|
||||
String? weapon,
|
||||
String? shield,
|
||||
String? helm,
|
||||
String? hauberk,
|
||||
String? brassairts,
|
||||
String? vambraces,
|
||||
String? gauntlets,
|
||||
String? gambeson,
|
||||
String? cuisses,
|
||||
String? greaves,
|
||||
String? sollerets,
|
||||
List<EquipmentItem>? items,
|
||||
int? bestIndex,
|
||||
}) {
|
||||
return Equipment(
|
||||
weapon: weapon ?? this.weapon,
|
||||
shield: shield ?? this.shield,
|
||||
helm: helm ?? this.helm,
|
||||
hauberk: hauberk ?? this.hauberk,
|
||||
brassairts: brassairts ?? this.brassairts,
|
||||
vambraces: vambraces ?? this.vambraces,
|
||||
gauntlets: gauntlets ?? this.gauntlets,
|
||||
gambeson: gambeson ?? this.gambeson,
|
||||
cuisses: cuisses ?? this.cuisses,
|
||||
greaves: greaves ?? this.greaves,
|
||||
sollerets: sollerets ?? this.sollerets,
|
||||
items: items ?? List<EquipmentItem>.from(this.items),
|
||||
bestIndex: bestIndex ?? this.bestIndex,
|
||||
);
|
||||
}
|
||||
|
||||
182
lib/src/core/model/item_stats.dart
Normal file
182
lib/src/core/model/item_stats.dart
Normal file
@@ -0,0 +1,182 @@
|
||||
/// 아이템 희귀도
|
||||
enum ItemRarity {
|
||||
common,
|
||||
uncommon,
|
||||
rare,
|
||||
epic,
|
||||
legendary;
|
||||
|
||||
/// 희귀도 배율 (스탯 계산용)
|
||||
double get multiplier => switch (this) {
|
||||
common => 1.0,
|
||||
uncommon => 1.3,
|
||||
rare => 1.7,
|
||||
epic => 2.2,
|
||||
legendary => 3.0,
|
||||
};
|
||||
|
||||
/// 가중치 보너스
|
||||
int get weightBonus => switch (this) {
|
||||
common => 0,
|
||||
uncommon => 50,
|
||||
rare => 150,
|
||||
epic => 400,
|
||||
legendary => 1000,
|
||||
};
|
||||
}
|
||||
|
||||
/// 아이템 스탯 보정치
|
||||
///
|
||||
/// 장비 아이템이 제공하는 스탯 보너스.
|
||||
/// 모든 값은 기본 0이며, 장착 시 플레이어 스탯에 가산됨.
|
||||
class ItemStats {
|
||||
const ItemStats({
|
||||
this.atk = 0,
|
||||
this.def = 0,
|
||||
this.magAtk = 0,
|
||||
this.magDef = 0,
|
||||
this.criRate = 0.0,
|
||||
this.evasion = 0.0,
|
||||
this.blockRate = 0.0,
|
||||
this.parryRate = 0.0,
|
||||
this.hpBonus = 0,
|
||||
this.mpBonus = 0,
|
||||
this.strBonus = 0,
|
||||
this.conBonus = 0,
|
||||
this.dexBonus = 0,
|
||||
this.intBonus = 0,
|
||||
this.wisBonus = 0,
|
||||
this.chaBonus = 0,
|
||||
});
|
||||
|
||||
/// 물리 공격력 보정
|
||||
final int atk;
|
||||
|
||||
/// 물리 방어력 보정
|
||||
final int def;
|
||||
|
||||
/// 마법 공격력 보정
|
||||
final int magAtk;
|
||||
|
||||
/// 마법 방어력 보정
|
||||
final int magDef;
|
||||
|
||||
/// 크리티컬 확률 보정 (0.0 ~ 1.0)
|
||||
final double criRate;
|
||||
|
||||
/// 회피율 보정 (0.0 ~ 1.0)
|
||||
final double evasion;
|
||||
|
||||
/// 방패 방어율 (방패 전용, 0.0 ~ 1.0)
|
||||
final double blockRate;
|
||||
|
||||
/// 무기 쳐내기 확률 (무기 전용, 0.0 ~ 1.0)
|
||||
final double parryRate;
|
||||
|
||||
/// HP 보너스
|
||||
final int hpBonus;
|
||||
|
||||
/// MP 보너스
|
||||
final int mpBonus;
|
||||
|
||||
/// STR 보너스
|
||||
final int strBonus;
|
||||
|
||||
/// CON 보너스
|
||||
final int conBonus;
|
||||
|
||||
/// DEX 보너스
|
||||
final int dexBonus;
|
||||
|
||||
/// INT 보너스
|
||||
final int intBonus;
|
||||
|
||||
/// WIS 보너스
|
||||
final int wisBonus;
|
||||
|
||||
/// CHA 보너스
|
||||
final int chaBonus;
|
||||
|
||||
/// 스탯 합계 (가중치 계산용)
|
||||
int get totalStatValue {
|
||||
return atk +
|
||||
def +
|
||||
magAtk +
|
||||
magDef +
|
||||
(criRate * 100).round() +
|
||||
(evasion * 100).round() +
|
||||
(blockRate * 100).round() +
|
||||
(parryRate * 100).round() +
|
||||
hpBonus +
|
||||
mpBonus +
|
||||
strBonus * 5 +
|
||||
conBonus * 5 +
|
||||
dexBonus * 5 +
|
||||
intBonus * 5 +
|
||||
wisBonus * 5 +
|
||||
chaBonus * 5;
|
||||
}
|
||||
|
||||
/// 빈 스탯 (보너스 없음)
|
||||
static const empty = ItemStats();
|
||||
|
||||
/// 두 스탯 합산
|
||||
ItemStats operator +(ItemStats other) {
|
||||
return ItemStats(
|
||||
atk: atk + other.atk,
|
||||
def: def + other.def,
|
||||
magAtk: magAtk + other.magAtk,
|
||||
magDef: magDef + other.magDef,
|
||||
criRate: criRate + other.criRate,
|
||||
evasion: evasion + other.evasion,
|
||||
blockRate: blockRate + other.blockRate,
|
||||
parryRate: parryRate + other.parryRate,
|
||||
hpBonus: hpBonus + other.hpBonus,
|
||||
mpBonus: mpBonus + other.mpBonus,
|
||||
strBonus: strBonus + other.strBonus,
|
||||
conBonus: conBonus + other.conBonus,
|
||||
dexBonus: dexBonus + other.dexBonus,
|
||||
intBonus: intBonus + other.intBonus,
|
||||
wisBonus: wisBonus + other.wisBonus,
|
||||
chaBonus: chaBonus + other.chaBonus,
|
||||
);
|
||||
}
|
||||
|
||||
ItemStats copyWith({
|
||||
int? atk,
|
||||
int? def,
|
||||
int? magAtk,
|
||||
int? magDef,
|
||||
double? criRate,
|
||||
double? evasion,
|
||||
double? blockRate,
|
||||
double? parryRate,
|
||||
int? hpBonus,
|
||||
int? mpBonus,
|
||||
int? strBonus,
|
||||
int? conBonus,
|
||||
int? dexBonus,
|
||||
int? intBonus,
|
||||
int? wisBonus,
|
||||
int? chaBonus,
|
||||
}) {
|
||||
return ItemStats(
|
||||
atk: atk ?? this.atk,
|
||||
def: def ?? this.def,
|
||||
magAtk: magAtk ?? this.magAtk,
|
||||
magDef: magDef ?? this.magDef,
|
||||
criRate: criRate ?? this.criRate,
|
||||
evasion: evasion ?? this.evasion,
|
||||
blockRate: blockRate ?? this.blockRate,
|
||||
parryRate: parryRate ?? this.parryRate,
|
||||
hpBonus: hpBonus ?? this.hpBonus,
|
||||
mpBonus: mpBonus ?? this.mpBonus,
|
||||
strBonus: strBonus ?? this.strBonus,
|
||||
conBonus: conBonus ?? this.conBonus,
|
||||
dexBonus: dexBonus ?? this.dexBonus,
|
||||
intBonus: intBonus ?? this.intBonus,
|
||||
wisBonus: wisBonus ?? this.wisBonus,
|
||||
chaBonus: chaBonus ?? this.chaBonus,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -172,7 +172,7 @@ class GameSave {
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
equipment: Equipment(
|
||||
equipment: Equipment.fromStrings(
|
||||
weapon: equipmentJson['weapon'] as String? ?? 'Keyboard',
|
||||
shield: equipmentJson['shield'] as String? ?? '',
|
||||
helm: equipmentJson['helm'] as String? ?? '',
|
||||
|
||||
Reference in New Issue
Block a user