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

View File

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

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

View File

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

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

View File

@@ -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? ?? '',