diff --git a/lib/src/core/engine/chest_service.dart b/lib/src/core/engine/chest_service.dart new file mode 100644 index 0000000..2ad2897 --- /dev/null +++ b/lib/src/core/engine/chest_service.dart @@ -0,0 +1,274 @@ +import 'package:flutter/foundation.dart'; + +import 'package:asciineverdie/data/potion_data.dart'; +import 'package:asciineverdie/src/core/model/equipment_item.dart'; +import 'package:asciineverdie/src/core/model/equipment_slot.dart'; +import 'package:asciineverdie/src/core/model/item_stats.dart'; +import 'package:asciineverdie/src/core/model/treasure_chest.dart'; +import 'package:asciineverdie/src/core/util/deterministic_random.dart'; + +/// 보물 상자 서비스 +/// +/// 상자 내용물 생성 및 오픈 로직 담당 +class ChestService { + ChestService({DeterministicRandom? rng}) + : _rng = rng ?? DeterministicRandom(DateTime.now().millisecondsSinceEpoch); + + final DeterministicRandom _rng; + + // ========================================================================== + // 상수 + // ========================================================================== + + /// 보상 타입별 확률 (%) + static const int _equipmentChance = 40; // 장비 40% + static const int _potionChance = 30; // 포션 30% + static const int _goldChance = 20; // 골드 20% + // 경험치 10% (나머지) + + /// 골드 보상 범위 (레벨 * 배율) + static const int _goldPerLevel = 50; + static const int _goldVariance = 20; + + /// 경험치 보상 범위 (레벨 * 배율) + static const int _expPerLevel = 100; + static const int _expVariance = 30; + + /// 포션 수량 범위 + static const int _minPotionCount = 1; + static const int _maxPotionCount = 3; + + // ========================================================================== + // 상자 오픈 + // ========================================================================== + + /// 상자 오픈하여 보상 생성 + /// + /// [playerLevel] 플레이어 레벨 (보상 스케일링용) + ChestReward openChest(int playerLevel) { + final roll = _rng.nextInt(100); + + if (roll < _equipmentChance) { + // 40%: 장비 + return _generateEquipmentReward(playerLevel); + } else if (roll < _equipmentChance + _potionChance) { + // 30%: 포션 + return _generatePotionReward(playerLevel); + } else if (roll < _equipmentChance + _potionChance + _goldChance) { + // 20%: 골드 + return _generateGoldReward(playerLevel); + } else { + // 10%: 경험치 + return _generateExperienceReward(playerLevel); + } + } + + /// 여러 상자 오픈 + List openMultipleChests(int count, int playerLevel) { + final rewards = []; + for (var i = 0; i < count; i++) { + rewards.add(openChest(playerLevel)); + } + return rewards; + } + + // ========================================================================== + // 보상 생성 + // ========================================================================== + + /// 장비 보상 생성 + ChestReward _generateEquipmentReward(int playerLevel) { + // 랜덤 슬롯 선택 + final slotIndex = _rng.nextInt(EquipmentSlot.values.length); + final slot = EquipmentSlot.values[slotIndex]; + + // 희귀도 결정 (상자는 좀 더 좋은 확률) + final rarity = _rollChestRarity(); + + // 아이템 레벨: 플레이어 레벨 ±2 + final minLevel = (playerLevel - 2).clamp(1, 999); + final maxLevel = playerLevel + 2; + final itemLevel = minLevel + _rng.nextInt(maxLevel - minLevel + 1); + + // 아이템 생성 + final item = EquipmentItem( + name: _generateItemName(slot, rarity, itemLevel), + slot: slot, + level: itemLevel, + weight: _calculateWeight(slot, itemLevel), + stats: _generateItemStats(slot, itemLevel, rarity), + rarity: rarity, + ); + + debugPrint('[ChestService] Equipment reward: ${item.name} (${rarity.name})'); + return ChestReward.equipment(item); + } + + /// 포션 보상 생성 + ChestReward _generatePotionReward(int playerLevel) { + // 레벨에 맞는 티어 선택 + final tier = PotionData.tierForLevel(playerLevel); + + // HP/MP 랜덤 선택 + final isHp = _rng.nextInt(2) == 0; + final potion = isHp + ? PotionData.getHpPotionByTier(tier) + : PotionData.getMpPotionByTier(tier); + + if (potion == null) { + // 폴백: 기본 포션 + return ChestReward.potion('minor_health_patch', 1); + } + + // 수량 결정 + final count = + _minPotionCount + _rng.nextInt(_maxPotionCount - _minPotionCount + 1); + + debugPrint('[ChestService] Potion reward: ${potion.name} x$count'); + return ChestReward.potion(potion.id, count); + } + + /// 골드 보상 생성 + ChestReward _generateGoldReward(int playerLevel) { + final baseGold = playerLevel * _goldPerLevel; + final variance = _rng.nextInt(_goldVariance * 2 + 1) - _goldVariance; + final gold = (baseGold + (baseGold * variance / 100)).round().clamp(10, 99999); + + debugPrint('[ChestService] Gold reward: $gold'); + return ChestReward.gold(gold); + } + + /// 경험치 보상 생성 + ChestReward _generateExperienceReward(int playerLevel) { + final baseExp = playerLevel * _expPerLevel; + final variance = _rng.nextInt(_expVariance * 2 + 1) - _expVariance; + final exp = (baseExp + (baseExp * variance / 100)).round().clamp(10, 999999); + + debugPrint('[ChestService] Experience reward: $exp'); + return ChestReward.experience(exp); + } + + // ========================================================================== + // 헬퍼 메서드 + // ========================================================================== + + /// 상자 희귀도 롤 (일반 샵보다 좋은 확률) + /// Common 50%, Uncommon 30%, Rare 15%, Epic 4%, Legendary 1% + ItemRarity _rollChestRarity() { + final roll = _rng.nextInt(100); + if (roll < 50) return ItemRarity.common; + if (roll < 80) return ItemRarity.uncommon; + if (roll < 95) return ItemRarity.rare; + if (roll < 99) return ItemRarity.epic; + return ItemRarity.legendary; + } + + /// 아이템 이름 생성 + String _generateItemName(EquipmentSlot slot, ItemRarity rarity, int level) { + final prefix = _getRarityPrefix(rarity); + final baseName = _getSlotBaseName(slot); + final suffix = level > 10 ? ' +${level ~/ 10}' : ''; + return '$prefix$baseName$suffix'.trim(); + } + + String _getRarityPrefix(ItemRarity rarity) { + return switch (rarity) { + ItemRarity.common => '', + ItemRarity.uncommon => 'Fine ', + ItemRarity.rare => 'Superior ', + ItemRarity.epic => 'Epic ', + ItemRarity.legendary => 'Legendary ', + }; + } + + String _getSlotBaseName(EquipmentSlot slot) { + return switch (slot) { + EquipmentSlot.weapon => 'Keyboard', + EquipmentSlot.shield => 'Firewall Shield', + EquipmentSlot.helm => 'Neural Headset', + EquipmentSlot.hauberk => 'Server Rack Armor', + EquipmentSlot.brassairts => 'Cable Brassairts', + EquipmentSlot.vambraces => 'USB Vambraces', + EquipmentSlot.gauntlets => 'Typing Gauntlets', + EquipmentSlot.gambeson => 'Padded Gambeson', + EquipmentSlot.cuisses => 'Circuit Cuisses', + EquipmentSlot.greaves => 'Copper Greaves', + EquipmentSlot.sollerets => 'Static Boots', + }; + } + + /// 스탯 생성 + ItemStats _generateItemStats( + EquipmentSlot slot, + int level, + ItemRarity rarity, + ) { + final multiplier = rarity.multiplier; + final baseValue = (level * multiplier).round(); + + return switch (slot) { + EquipmentSlot.weapon => ItemStats( + atk: baseValue * 2, + criRate: 0.01 * (level ~/ 5), + parryRate: 0.005 * level, + ), + EquipmentSlot.shield => ItemStats( + def: baseValue, + blockRate: 0.02 * (level ~/ 3).clamp(1, 10), + ), + EquipmentSlot.helm => ItemStats( + def: baseValue ~/ 2, + magDef: baseValue ~/ 2, + intBonus: level ~/ 10, + ), + EquipmentSlot.hauberk => ItemStats(def: baseValue, hpBonus: level * 2), + EquipmentSlot.brassairts => ItemStats( + def: baseValue ~/ 2, + strBonus: level ~/ 15, + ), + EquipmentSlot.vambraces => ItemStats( + def: baseValue ~/ 2, + dexBonus: level ~/ 15, + ), + EquipmentSlot.gauntlets => ItemStats( + atk: baseValue ~/ 2, + def: baseValue ~/ 4, + ), + EquipmentSlot.gambeson => ItemStats( + def: baseValue ~/ 2, + conBonus: level ~/ 15, + ), + EquipmentSlot.cuisses => ItemStats( + def: baseValue ~/ 2, + evasion: 0.005 * level, + ), + EquipmentSlot.greaves => ItemStats( + def: baseValue ~/ 2, + evasion: 0.003 * level, + ), + EquipmentSlot.sollerets => ItemStats( + def: baseValue ~/ 3, + evasion: 0.002 * level, + dexBonus: level ~/ 20, + ), + }; + } + + /// 무게 계산 + int _calculateWeight(EquipmentSlot slot, int level) { + final baseWeight = switch (slot) { + EquipmentSlot.weapon => 5, + EquipmentSlot.shield => 8, + EquipmentSlot.helm => 4, + EquipmentSlot.hauberk => 15, + EquipmentSlot.brassairts => 3, + EquipmentSlot.vambraces => 3, + EquipmentSlot.gauntlets => 2, + EquipmentSlot.gambeson => 6, + EquipmentSlot.cuisses => 5, + EquipmentSlot.greaves => 4, + EquipmentSlot.sollerets => 3, + }; + return baseWeight + (level ~/ 10); + } +} diff --git a/lib/src/core/model/treasure_chest.dart b/lib/src/core/model/treasure_chest.dart new file mode 100644 index 0000000..1f3990e --- /dev/null +++ b/lib/src/core/model/treasure_chest.dart @@ -0,0 +1,115 @@ +import 'package:asciineverdie/src/core/model/equipment_item.dart'; + +/// 상자 보상 타입 +enum ChestRewardType { + /// 장비 아이템 + equipment, + + /// 포션 + potion, + + /// 골드 + gold, + + /// 경험치 + experience, +} + +/// 상자 내용물 (개봉 결과) +class ChestReward { + const ChestReward._({ + required this.type, + this.equipment, + this.potionId, + this.potionCount, + this.gold, + this.experience, + }); + + /// 장비 보상 생성 + factory ChestReward.equipment(EquipmentItem item) { + return ChestReward._( + type: ChestRewardType.equipment, + equipment: item, + ); + } + + /// 포션 보상 생성 + factory ChestReward.potion(String potionId, int count) { + return ChestReward._( + type: ChestRewardType.potion, + potionId: potionId, + potionCount: count, + ); + } + + /// 골드 보상 생성 + factory ChestReward.gold(int amount) { + return ChestReward._( + type: ChestRewardType.gold, + gold: amount, + ); + } + + /// 경험치 보상 생성 + factory ChestReward.experience(int amount) { + return ChestReward._( + type: ChestRewardType.experience, + experience: amount, + ); + } + + /// 보상 타입 + final ChestRewardType type; + + /// 장비 (type == equipment일 때) + final EquipmentItem? equipment; + + /// 포션 ID (type == potion일 때) + final String? potionId; + + /// 포션 수량 (type == potion일 때) + final int? potionCount; + + /// 골드 (type == gold일 때) + final int? gold; + + /// 경험치 (type == experience일 때) + final int? experience; + + /// 장비 보상인지 여부 + bool get isEquipment => type == ChestRewardType.equipment; + + /// 포션 보상인지 여부 + bool get isPotion => type == ChestRewardType.potion; + + /// 골드 보상인지 여부 + bool get isGold => type == ChestRewardType.gold; + + /// 경험치 보상인지 여부 + bool get isExperience => type == ChestRewardType.experience; +} + +/// 복귀 보상 상자 데이터 +class ReturnChestReward { + const ReturnChestReward({ + required this.hoursAway, + required this.chestCount, + required this.bonusChestCount, + }); + + /// 떠나있던 시간 (시간 단위) + final int hoursAway; + + /// 기본 상자 개수 + final int chestCount; + + /// 보너스 상자 개수 (광고 시청 시 추가) + final int bonusChestCount; + + /// 총 상자 개수 (광고 포함) + int get totalChests => chestCount + bonusChestCount; + + /// 보상이 있는지 여부 + bool get hasReward => chestCount > 0; +}