feat(item): Phase 2 아이템 시스템 구현

- ItemStats, ItemRarity 클래스 추가 (아이템 스탯/희귀도)
- EquipmentItem 클래스 추가 (개별 장비 아이템)
- ItemService 추가 (아이템 생성/관리/무게 시스템)
- Equipment 클래스 확장 (EquipmentItem 기반, 기존 API 호환)
- CombatStats에서 장비 스탯 반영
- 레거시 세이브 파일 호환성 유지
This commit is contained in:
JiWoong Sul
2025-12-17 16:57:23 +09:00
parent c62687f7bd
commit 6a696ecd57
6 changed files with 726 additions and 122 deletions

View 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,
);
}
}