feat(core): 보물 상자 시스템 추가
- TreasureChest 모델 추가 - ChestService 서비스 추가
This commit is contained in:
274
lib/src/core/engine/chest_service.dart
Normal file
274
lib/src/core/engine/chest_service.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
115
lib/src/core/model/treasure_chest.dart
Normal file
115
lib/src/core/model/treasure_chest.dart
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user