feat(core): 보물 상자 시스템 추가

- TreasureChest 모델 추가
- ChestService 서비스 추가
This commit is contained in:
JiWoong Sul
2026-01-19 15:49:26 +09:00
parent d41dd0fb90
commit f51bf8c540
2 changed files with 389 additions and 0 deletions

View File

@@ -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<ChestReward> openMultipleChests(int count, int playerLevel) {
final rewards = <ChestReward>[];
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);
}
}

View File

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