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에서 CombatStats 생성
|
||||||
///
|
///
|
||||||
/// [stats] 캐릭터 기본 스탯
|
/// [stats] 캐릭터 기본 스탯
|
||||||
/// [equipment] 장착 장비 (향후 장비 스탯 적용 시 사용)
|
/// [equipment] 장착 장비 (장비 스탯 적용)
|
||||||
/// [level] 캐릭터 레벨 (스케일링용)
|
/// [level] 캐릭터 레벨 (스케일링용)
|
||||||
factory CombatStats.fromStats({
|
factory CombatStats.fromStats({
|
||||||
required Stats stats,
|
required Stats stats,
|
||||||
required Equipment equipment,
|
required Equipment equipment,
|
||||||
required int level,
|
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 기반
|
// 기본 공격력: STR 기반 + 레벨 보정 + 장비 ATK
|
||||||
final baseMagAtk = stats.intelligence * 2 + level;
|
final baseAtk = effectiveStr * 2 + level + equipStats.atk;
|
||||||
|
|
||||||
// 마법 방어력: WIS 기반
|
// 기본 방어력: CON 기반 + 레벨 보정 + 장비 DEF
|
||||||
final baseMagDef = stats.wis + (level ~/ 2);
|
final baseDef = effectiveCon + (level ~/ 2) + equipStats.def;
|
||||||
|
|
||||||
// 크리티컬 확률: DEX 기반 (0.05 ~ 0.5)
|
// 마법 공격력: INT 기반 + 장비 MAG_ATK
|
||||||
final criRate = (0.05 + stats.dex * 0.005).clamp(0.05, 0.5);
|
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)
|
// 크리티컬 데미지: 기본 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)
|
// 회피율: DEX 기반 + 장비 보너스 (0.0 ~ 0.5)
|
||||||
final evasion = (stats.dex * 0.005).clamp(0.0, 0.5);
|
final evasion = (effectiveDex * 0.005 + equipStats.evasion).clamp(0.0, 0.5);
|
||||||
|
|
||||||
// 명중률: DEX 기반 (0.8 ~ 1.0)
|
// 명중률: 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 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)
|
// 무기 쳐내기: DEX + STR 기반 + 장비 보너스 (0.0 ~ 0.4)
|
||||||
final parryRate = ((stats.dex + stats.str) * 0.002).clamp(0.0, 0.3);
|
final baseParryRate = (effectiveDex + effectiveStr) * 0.002;
|
||||||
|
final parryRate = (baseParryRate + equipStats.parryRate).clamp(0.0, 0.4);
|
||||||
|
|
||||||
// 공격 속도: DEX 기반 (기본 1000ms, 최소 357ms)
|
// 공격 속도: 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);
|
final attackDelayMs = (1000 / speedModifier).round().clamp(357, 1500);
|
||||||
|
|
||||||
|
// HP/MP: 기본 + 장비 보너스
|
||||||
|
final totalHpMax = stats.hpMax + equipStats.hpBonus;
|
||||||
|
final totalMpMax = stats.mpMax + equipStats.mpBonus;
|
||||||
|
|
||||||
return CombatStats(
|
return CombatStats(
|
||||||
str: stats.str,
|
str: effectiveStr,
|
||||||
con: stats.con,
|
con: effectiveCon,
|
||||||
dex: stats.dex,
|
dex: effectiveDex,
|
||||||
intelligence: stats.intelligence,
|
intelligence: effectiveInt,
|
||||||
wis: stats.wis,
|
wis: effectiveWis,
|
||||||
cha: stats.cha,
|
cha: stats.cha + equipStats.chaBonus,
|
||||||
atk: baseAtk,
|
atk: baseAtk,
|
||||||
def: baseDef,
|
def: baseDef,
|
||||||
magAtk: baseMagAtk,
|
magAtk: baseMagAtk,
|
||||||
@@ -258,10 +274,10 @@ class CombatStats {
|
|||||||
blockRate: blockRate,
|
blockRate: blockRate,
|
||||||
parryRate: parryRate,
|
parryRate: parryRate,
|
||||||
attackDelayMs: attackDelayMs,
|
attackDelayMs: attackDelayMs,
|
||||||
hpMax: stats.hpMax,
|
hpMax: totalHpMax,
|
||||||
hpCurrent: stats.hp,
|
hpCurrent: stats.hp.clamp(0, totalHpMax),
|
||||||
mpMax: stats.mpMax,
|
mpMax: totalMpMax,
|
||||||
mpCurrent: stats.mp,
|
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 'dart:collection';
|
||||||
|
|
||||||
import 'package:askiineverdie/src/core/model/combat_state.dart';
|
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';
|
import 'package:askiineverdie/src/core/util/deterministic_random.dart';
|
||||||
|
|
||||||
/// Minimal skeletal state to mirror Progress Quest structures.
|
/// Minimal skeletal state to mirror Progress Quest structures.
|
||||||
@@ -275,33 +278,17 @@ class Inventory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 장비 (원본 Main.dfm Equips ListView, 11개 슬롯)
|
/// 장비 (원본 Main.dfm Equips ListView, 11개 슬롯)
|
||||||
|
///
|
||||||
|
/// Phase 2에서 EquipmentItem 기반으로 확장됨.
|
||||||
|
/// 기존 문자열 API(weapon, shield 등)는 호환성을 위해 유지.
|
||||||
class Equipment {
|
class Equipment {
|
||||||
const Equipment({
|
Equipment({
|
||||||
required this.weapon,
|
required this.items,
|
||||||
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,
|
|
||||||
required this.bestIndex,
|
required this.bestIndex,
|
||||||
});
|
}) : assert(items.length == slotCount, 'Equipment must have $slotCount items');
|
||||||
|
|
||||||
final String weapon; // 0: 무기
|
/// 장비 아이템 목록 (11개 슬롯)
|
||||||
final String shield; // 1: 방패
|
final List<EquipmentItem> items;
|
||||||
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: 철제신발
|
|
||||||
|
|
||||||
/// 최고 아이템 슬롯 인덱스 (원본 Equips.Tag, 0-10)
|
/// 최고 아이템 슬롯 인덱스 (원본 Equips.Tag, 0-10)
|
||||||
final int bestIndex;
|
final int bestIndex;
|
||||||
@@ -309,83 +296,168 @@ class Equipment {
|
|||||||
/// 슬롯 개수
|
/// 슬롯 개수
|
||||||
static const slotCount = 11;
|
static const slotCount = 11;
|
||||||
|
|
||||||
factory Equipment.empty() => const Equipment(
|
// ============================================================================
|
||||||
weapon: 'Keyboard',
|
// 문자열 API (기존 코드 호환성)
|
||||||
shield: '',
|
// ============================================================================
|
||||||
helm: '',
|
|
||||||
hauberk: '',
|
|
||||||
brassairts: '',
|
|
||||||
vambraces: '',
|
|
||||||
gauntlets: '',
|
|
||||||
gambeson: '',
|
|
||||||
cuisses: '',
|
|
||||||
greaves: '',
|
|
||||||
sollerets: '',
|
|
||||||
bestIndex: 0,
|
|
||||||
);
|
|
||||||
|
|
||||||
/// 인덱스로 슬롯 값 가져오기
|
String get weapon => items[0].name; // 0: 무기
|
||||||
String getByIndex(int index) {
|
String get shield => items[1].name; // 1: 방패
|
||||||
return switch (index) {
|
String get helm => items[2].name; // 2: 투구
|
||||||
0 => weapon,
|
String get hauberk => items[3].name; // 3: 사슬갑옷
|
||||||
1 => shield,
|
String get brassairts => items[4].name; // 4: 상완갑
|
||||||
2 => helm,
|
String get vambraces => items[5].name; // 5: 전완갑
|
||||||
3 => hauberk,
|
String get gauntlets => items[6].name; // 6: 건틀릿
|
||||||
4 => brassairts,
|
String get gambeson => items[7].name; // 7: 갬비슨
|
||||||
5 => vambraces,
|
String get cuisses => items[8].name; // 8: 허벅지갑
|
||||||
6 => gauntlets,
|
String get greaves => items[9].name; // 9: 정강이갑
|
||||||
7 => gambeson,
|
String get sollerets => items[10].name; // 10: 철제신발
|
||||||
8 => cuisses,
|
|
||||||
9 => greaves,
|
// ============================================================================
|
||||||
10 => sollerets,
|
// 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) {
|
Equipment setByIndex(int index, String value) {
|
||||||
return switch (index) {
|
if (index < 0 || index >= slotCount) return this;
|
||||||
0 => copyWith(weapon: value),
|
final slot = EquipmentSlot.values[index];
|
||||||
1 => copyWith(shield: value),
|
final newItem = _itemFromString(value, slot);
|
||||||
2 => copyWith(helm: value),
|
return setItemByIndex(index, newItem);
|
||||||
3 => copyWith(hauberk: value),
|
}
|
||||||
4 => copyWith(brassairts: value),
|
|
||||||
5 => copyWith(vambraces: value),
|
/// 인덱스로 EquipmentItem 설정
|
||||||
6 => copyWith(gauntlets: value),
|
Equipment setItemByIndex(int index, EquipmentItem item) {
|
||||||
7 => copyWith(gambeson: value),
|
if (index < 0 || index >= slotCount) return this;
|
||||||
8 => copyWith(cuisses: value),
|
final newItems = List<EquipmentItem>.from(items);
|
||||||
9 => copyWith(greaves: value),
|
newItems[index] = item;
|
||||||
10 => copyWith(sollerets: value),
|
return Equipment(items: newItems, bestIndex: bestIndex);
|
||||||
_ => this,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Equipment copyWith({
|
Equipment copyWith({
|
||||||
String? weapon,
|
List<EquipmentItem>? items,
|
||||||
String? shield,
|
|
||||||
String? helm,
|
|
||||||
String? hauberk,
|
|
||||||
String? brassairts,
|
|
||||||
String? vambraces,
|
|
||||||
String? gauntlets,
|
|
||||||
String? gambeson,
|
|
||||||
String? cuisses,
|
|
||||||
String? greaves,
|
|
||||||
String? sollerets,
|
|
||||||
int? bestIndex,
|
int? bestIndex,
|
||||||
}) {
|
}) {
|
||||||
return Equipment(
|
return Equipment(
|
||||||
weapon: weapon ?? this.weapon,
|
items: items ?? List<EquipmentItem>.from(this.items),
|
||||||
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,
|
|
||||||
bestIndex: bestIndex ?? this.bestIndex,
|
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(),
|
.toList(),
|
||||||
),
|
),
|
||||||
equipment: Equipment(
|
equipment: Equipment.fromStrings(
|
||||||
weapon: equipmentJson['weapon'] as String? ?? 'Keyboard',
|
weapon: equipmentJson['weapon'] as String? ?? 'Keyboard',
|
||||||
shield: equipmentJson['shield'] as String? ?? '',
|
shield: equipmentJson['shield'] as String? ?? '',
|
||||||
helm: equipmentJson['helm'] as String? ?? '',
|
helm: equipmentJson['helm'] as String? ?? '',
|
||||||
|
|||||||
Reference in New Issue
Block a user