feat(game): 포션 시스템 및 UI 패널 추가
- 포션 시스템 구현 (PotionService, Potion 모델) - 포션 인벤토리 패널 위젯 - 활성 버프 패널 위젯 - 장비 스탯 패널 위젯 - 스킬 시스템 확장 - 일본어 번역 추가 - 전투 이벤트/상태 모델 개선
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import 'package:askiineverdie/src/core/engine/item_service.dart';
|
||||
import 'package:askiineverdie/src/core/model/equipment_slot.dart';
|
||||
import 'package:askiineverdie/src/core/model/game_state.dart';
|
||||
import 'package:askiineverdie/src/core/model/pq_config.dart';
|
||||
@@ -10,12 +11,24 @@ class GameMutations {
|
||||
final PqConfig config;
|
||||
|
||||
/// 장비 획득 (원본 Main.pas:791-830 WinEquip)
|
||||
///
|
||||
/// [slotIndex]: 0-10 (원본 Equips.Items.Count = 11)
|
||||
/// ItemService를 사용하여 스탯과 공속을 가진 장비 생성
|
||||
GameState winEquipByIndex(GameState state, int level, int slotIndex) {
|
||||
final rng = state.rng;
|
||||
final name = pq_logic.winEquip(config, rng, level, slotIndex);
|
||||
final slot = EquipmentSlot.values[slotIndex];
|
||||
|
||||
// ItemService로 스탯이 있는 장비 생성
|
||||
final itemService = ItemService(rng: rng);
|
||||
final newItem = itemService.generateEquipment(
|
||||
name: name,
|
||||
slot: slot,
|
||||
level: level,
|
||||
);
|
||||
|
||||
final updatedEquip = state.equipment
|
||||
.setByIndex(slotIndex, name)
|
||||
.setItemByIndex(slotIndex, newItem)
|
||||
.copyWith(bestIndex: slotIndex);
|
||||
return state.copyWith(rng: rng, equipment: updatedEquip);
|
||||
}
|
||||
|
||||
@@ -97,14 +97,35 @@ class ItemService {
|
||||
}
|
||||
|
||||
/// 무기 스탯 생성
|
||||
///
|
||||
/// 공속(attackSpeed)과 공격력(atk)은 역비례 관계:
|
||||
/// - 느린 무기 (1500ms): atk × 1.4
|
||||
/// - 기본 무기 (1000ms): atk × 1.0
|
||||
/// - 빠른 무기 (600ms): atk × 0.7
|
||||
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;
|
||||
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;
|
||||
|
||||
// 공속 결정 (600ms ~ 1500ms 범위)
|
||||
// 희귀도가 높을수록 공속 변동 폭 증가
|
||||
final speedVariance = 300 + rarity.index * 100; // Common: 300, Legendary: 700
|
||||
final speedOffset = rng.nextInt(speedVariance * 2) - speedVariance;
|
||||
final attackSpeed = (1000 + speedOffset).clamp(600, 1500);
|
||||
|
||||
// 공속-데미지 역비례 계산
|
||||
// 기준: 1000ms = 1.0x, 600ms = 0.7x, 1500ms = 1.4x
|
||||
final speedMultiplier = 0.3 + (attackSpeed / 1000) * 0.7;
|
||||
final adjustedAtk = (baseValue * speedMultiplier).round();
|
||||
|
||||
return ItemStats(
|
||||
atk: baseValue,
|
||||
atk: adjustedAtk,
|
||||
criRate: criBonus,
|
||||
parryRate: parryBonus,
|
||||
attackSpeed: attackSpeed,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -201,13 +222,67 @@ class ItemService {
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 장비 점수 계산
|
||||
// ============================================================================
|
||||
|
||||
/// 장비 점수 계산 (Equipment Score)
|
||||
///
|
||||
/// 슬롯별 스탯 가중치를 적용하여 점수 산출.
|
||||
/// - 무기: 공격력, 크리티컬, 공속 역비례 반영
|
||||
/// - 방어구: 방어력, HP 보너스
|
||||
/// - 액세서리: 스탯 보너스
|
||||
static int calculateEquipmentScore(EquipmentItem item) {
|
||||
var score = 0;
|
||||
final stats = item.stats;
|
||||
|
||||
// 공격 스탯 (무기 중심)
|
||||
score += stats.atk * 2;
|
||||
score += stats.magAtk * 2;
|
||||
score += (stats.criRate * 200).round();
|
||||
score += (stats.parryRate * 150).round();
|
||||
|
||||
// 방어 스탯
|
||||
score += (stats.def * 1.5).round();
|
||||
score += (stats.magDef * 1.5).round();
|
||||
score += (stats.blockRate * 150).round();
|
||||
score += (stats.evasion * 150).round();
|
||||
|
||||
// 자원 스탯
|
||||
score += stats.hpBonus;
|
||||
score += stats.mpBonus;
|
||||
|
||||
// 능력치 보너스 (가중치 5배)
|
||||
score += (stats.strBonus +
|
||||
stats.conBonus +
|
||||
stats.dexBonus +
|
||||
stats.intBonus +
|
||||
stats.wisBonus +
|
||||
stats.chaBonus) *
|
||||
5;
|
||||
|
||||
// 무기 공속 보정 (느린 무기 = 높은 데미지 → 높은 점수)
|
||||
// 기준 1000ms, 느린 무기(1500ms)는 +25점, 빠른 무기(600ms)는 -20점
|
||||
if (item.slot == EquipmentSlot.weapon && stats.attackSpeed > 0) {
|
||||
score += ((stats.attackSpeed - 1000) * 0.05).round();
|
||||
}
|
||||
|
||||
// 희귀도 배율 적용
|
||||
score = (score * item.rarity.multiplier).round();
|
||||
|
||||
// 레벨 기본 점수
|
||||
score += item.level * 5;
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 자동 장착
|
||||
// ============================================================================
|
||||
|
||||
/// 새 아이템이 현재 장비보다 좋은지 비교
|
||||
/// 새 아이템이 현재 장비보다 좋은지 비교 (점수 기반)
|
||||
///
|
||||
/// 가중치 기준으로 비교하며, 무게 제한도 고려
|
||||
/// 장비 점수 기준으로 비교하며, 무게 제한도 고려
|
||||
bool shouldEquip({
|
||||
required EquipmentItem newItem,
|
||||
required EquipmentItem currentItem,
|
||||
@@ -224,8 +299,11 @@ class ItemService {
|
||||
);
|
||||
}
|
||||
|
||||
// 새 아이템이 더 좋은지 확인
|
||||
if (newItem.itemWeight <= currentItem.itemWeight) {
|
||||
// 점수 비교 (새 아이템이 더 높아야 함)
|
||||
final newScore = calculateEquipmentScore(newItem);
|
||||
final currentScore = calculateEquipmentScore(currentItem);
|
||||
|
||||
if (newScore <= currentScore) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
497
lib/src/core/engine/potion_service.dart
Normal file
497
lib/src/core/engine/potion_service.dart
Normal file
@@ -0,0 +1,497 @@
|
||||
import 'package:askiineverdie/data/potion_data.dart';
|
||||
import 'package:askiineverdie/src/core/model/potion.dart';
|
||||
|
||||
/// 물약 서비스
|
||||
///
|
||||
/// 물약 사용, 자동 사용 알고리즘, 인벤토리 관리
|
||||
class PotionService {
|
||||
const PotionService();
|
||||
|
||||
/// 긴급 물약 사용 HP 임계치 (30%)
|
||||
static const double emergencyHpThreshold = 0.30;
|
||||
|
||||
/// 긴급 물약 사용 MP 임계치 (20%)
|
||||
static const double emergencyMpThreshold = 0.20;
|
||||
|
||||
// ============================================================================
|
||||
// 물약 사용 가능 여부
|
||||
// ============================================================================
|
||||
|
||||
/// 물약 사용 가능 여부 체크
|
||||
///
|
||||
/// [potionId] 물약 ID
|
||||
/// [inventory] 물약 인벤토리
|
||||
/// Returns: (사용 가능 여부, 실패 사유)
|
||||
(bool, PotionUseFailReason?) canUsePotion(
|
||||
String potionId,
|
||||
PotionInventory inventory,
|
||||
) {
|
||||
// 물약 데이터 존재 체크
|
||||
final potion = PotionData.getById(potionId);
|
||||
if (potion == null) {
|
||||
return (false, PotionUseFailReason.potionNotFound);
|
||||
}
|
||||
|
||||
// 보유 수량 체크
|
||||
if (!inventory.hasPotion(potionId)) {
|
||||
return (false, PotionUseFailReason.outOfStock);
|
||||
}
|
||||
|
||||
// 전투당 종류별 1회 제한 체크
|
||||
if (!inventory.canUseType(potion.type)) {
|
||||
return (false, PotionUseFailReason.alreadyUsedThisBattle);
|
||||
}
|
||||
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 물약 사용
|
||||
// ============================================================================
|
||||
|
||||
/// 물약 사용
|
||||
///
|
||||
/// [potionId] 물약 ID
|
||||
/// [inventory] 물약 인벤토리
|
||||
/// [currentHp] 현재 HP
|
||||
/// [maxHp] 최대 HP
|
||||
/// [currentMp] 현재 MP
|
||||
/// [maxMp] 최대 MP
|
||||
PotionUseResult usePotion({
|
||||
required String potionId,
|
||||
required PotionInventory inventory,
|
||||
required int currentHp,
|
||||
required int maxHp,
|
||||
required int currentMp,
|
||||
required int maxMp,
|
||||
}) {
|
||||
final (canUse, failReason) = canUsePotion(potionId, inventory);
|
||||
if (!canUse) {
|
||||
return PotionUseResult.failed(failReason!);
|
||||
}
|
||||
|
||||
final potion = PotionData.getById(potionId)!;
|
||||
int healedAmount = 0;
|
||||
int newHp = currentHp;
|
||||
int newMp = currentMp;
|
||||
|
||||
if (potion.isHpPotion) {
|
||||
healedAmount = potion.calculateHeal(maxHp);
|
||||
newHp = (currentHp + healedAmount).clamp(0, maxHp);
|
||||
healedAmount = newHp - currentHp; // 실제 회복량
|
||||
} else if (potion.isMpPotion) {
|
||||
healedAmount = potion.calculateHeal(maxMp);
|
||||
newMp = (currentMp + healedAmount).clamp(0, maxMp);
|
||||
healedAmount = newMp - currentMp; // 실제 회복량
|
||||
}
|
||||
|
||||
final newInventory = inventory.usePotion(potionId, potion.type);
|
||||
|
||||
return PotionUseResult(
|
||||
success: true,
|
||||
potion: potion,
|
||||
healedAmount: healedAmount,
|
||||
newHp: newHp,
|
||||
newMp: newMp,
|
||||
newInventory: newInventory,
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 긴급 물약 자동 사용
|
||||
// ============================================================================
|
||||
|
||||
/// 긴급 HP 물약 선택
|
||||
///
|
||||
/// HP가 임계치 이하일 때 사용할 최적의 물약 선택
|
||||
/// [currentHp] 현재 HP
|
||||
/// [maxHp] 최대 HP
|
||||
/// [inventory] 물약 인벤토리
|
||||
/// [playerLevel] 플레이어 레벨 (적정 티어 판단용)
|
||||
Potion? selectEmergencyHpPotion({
|
||||
required int currentHp,
|
||||
required int maxHp,
|
||||
required PotionInventory inventory,
|
||||
required int playerLevel,
|
||||
}) {
|
||||
// 임계치 체크
|
||||
final hpRatio = currentHp / maxHp;
|
||||
if (hpRatio > emergencyHpThreshold) return null;
|
||||
|
||||
// 전투 중 이미 HP 물약 사용했으면 불가
|
||||
if (!inventory.canUseType(PotionType.hp)) return null;
|
||||
|
||||
// 적정 티어 계산
|
||||
final targetTier = PotionData.tierForLevel(playerLevel);
|
||||
|
||||
// 적정 티어부터 낮은 티어 순으로 검색
|
||||
for (var tier = targetTier; tier >= 1; tier--) {
|
||||
final potion = PotionData.getHpPotionByTier(tier);
|
||||
if (potion != null && inventory.hasPotion(potion.id)) {
|
||||
return potion;
|
||||
}
|
||||
}
|
||||
|
||||
// 적정 티어 이상도 검색
|
||||
for (var tier = targetTier + 1; tier <= 5; tier++) {
|
||||
final potion = PotionData.getHpPotionByTier(tier);
|
||||
if (potion != null && inventory.hasPotion(potion.id)) {
|
||||
return potion;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 긴급 MP 물약 선택
|
||||
///
|
||||
/// MP가 임계치 이하일 때 사용할 최적의 물약 선택
|
||||
Potion? selectEmergencyMpPotion({
|
||||
required int currentMp,
|
||||
required int maxMp,
|
||||
required PotionInventory inventory,
|
||||
required int playerLevel,
|
||||
}) {
|
||||
// 임계치 체크
|
||||
final mpRatio = currentMp / maxMp;
|
||||
if (mpRatio > emergencyMpThreshold) return null;
|
||||
|
||||
// 전투 중 이미 MP 물약 사용했으면 불가
|
||||
if (!inventory.canUseType(PotionType.mp)) return null;
|
||||
|
||||
// 적정 티어 계산
|
||||
final targetTier = PotionData.tierForLevel(playerLevel);
|
||||
|
||||
// 적정 티어부터 낮은 티어 순으로 검색
|
||||
for (var tier = targetTier; tier >= 1; tier--) {
|
||||
final potion = PotionData.getMpPotionByTier(tier);
|
||||
if (potion != null && inventory.hasPotion(potion.id)) {
|
||||
return potion;
|
||||
}
|
||||
}
|
||||
|
||||
// 적정 티어 이상도 검색
|
||||
for (var tier = targetTier + 1; tier <= 5; tier++) {
|
||||
final potion = PotionData.getMpPotionByTier(tier);
|
||||
if (potion != null && inventory.hasPotion(potion.id)) {
|
||||
return potion;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 인벤토리 관리
|
||||
// ============================================================================
|
||||
|
||||
/// 전투 종료 시 사용 기록 초기화
|
||||
PotionInventory resetBattleUsage(PotionInventory inventory) {
|
||||
return inventory.resetBattleUsage();
|
||||
}
|
||||
|
||||
/// 물약 드랍 추가
|
||||
PotionInventory addPotionDrop(
|
||||
PotionInventory inventory,
|
||||
String potionId, [
|
||||
int count = 1,
|
||||
]) {
|
||||
return inventory.addPotion(potionId, count);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 물약 구매 시스템
|
||||
// ============================================================================
|
||||
|
||||
/// 물약 구매 가능 여부 체크
|
||||
///
|
||||
/// [potionId] 물약 ID
|
||||
/// [gold] 보유 골드
|
||||
/// Returns: (구매 가능 여부, 실패 사유)
|
||||
(bool, PotionPurchaseFailReason?) canPurchasePotion(
|
||||
String potionId,
|
||||
int gold,
|
||||
) {
|
||||
final potion = PotionData.getById(potionId);
|
||||
if (potion == null) {
|
||||
return (false, PotionPurchaseFailReason.potionNotFound);
|
||||
}
|
||||
|
||||
if (gold < potion.price) {
|
||||
return (false, PotionPurchaseFailReason.insufficientGold);
|
||||
}
|
||||
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
/// 물약 구매
|
||||
///
|
||||
/// [potionId] 물약 ID
|
||||
/// [inventory] 현재 물약 인벤토리
|
||||
/// [gold] 보유 골드
|
||||
/// [count] 구매 수량 (기본 1)
|
||||
PotionPurchaseResult purchasePotion({
|
||||
required String potionId,
|
||||
required PotionInventory inventory,
|
||||
required int gold,
|
||||
int count = 1,
|
||||
}) {
|
||||
final potion = PotionData.getById(potionId);
|
||||
if (potion == null) {
|
||||
return PotionPurchaseResult.failed(PotionPurchaseFailReason.potionNotFound);
|
||||
}
|
||||
|
||||
final totalCost = potion.price * count;
|
||||
if (gold < totalCost) {
|
||||
return PotionPurchaseResult.failed(PotionPurchaseFailReason.insufficientGold);
|
||||
}
|
||||
|
||||
final newInventory = inventory.addPotion(potionId, count);
|
||||
final newGold = gold - totalCost;
|
||||
|
||||
return PotionPurchaseResult(
|
||||
success: true,
|
||||
potion: potion,
|
||||
quantity: count,
|
||||
totalCost: totalCost,
|
||||
newGold: newGold,
|
||||
newInventory: newInventory,
|
||||
);
|
||||
}
|
||||
|
||||
/// 레벨에 맞는 물약 자동 구매
|
||||
///
|
||||
/// 골드의 일정 비율을 물약 구매에 사용
|
||||
/// [playerLevel] 플레이어 레벨
|
||||
/// [inventory] 현재 물약 인벤토리
|
||||
/// [gold] 보유 골드
|
||||
/// [spendRatio] 골드 사용 비율 (기본 20%)
|
||||
PotionPurchaseResult autoPurchasePotions({
|
||||
required int playerLevel,
|
||||
required PotionInventory inventory,
|
||||
required int gold,
|
||||
double spendRatio = 0.20,
|
||||
}) {
|
||||
final tier = PotionData.tierForLevel(playerLevel);
|
||||
final hpPotion = PotionData.getHpPotionByTier(tier);
|
||||
final mpPotion = PotionData.getMpPotionByTier(tier);
|
||||
|
||||
if (hpPotion == null && mpPotion == null) {
|
||||
return PotionPurchaseResult.failed(PotionPurchaseFailReason.potionNotFound);
|
||||
}
|
||||
|
||||
// 사용 가능 골드
|
||||
final spendableGold = (gold * spendRatio).floor();
|
||||
if (spendableGold <= 0) {
|
||||
return PotionPurchaseResult.failed(PotionPurchaseFailReason.insufficientGold);
|
||||
}
|
||||
|
||||
var currentInventory = inventory;
|
||||
var currentGold = gold;
|
||||
var totalSpent = 0;
|
||||
var hpPurchased = 0;
|
||||
var mpPurchased = 0;
|
||||
|
||||
// HP 물약 우선 구매 (60%), MP 물약 (40%)
|
||||
final hpBudget = (spendableGold * 0.6).floor();
|
||||
final mpBudget = spendableGold - hpBudget;
|
||||
|
||||
// HP 물약 구매
|
||||
if (hpPotion != null && hpBudget >= hpPotion.price) {
|
||||
final count = hpBudget ~/ hpPotion.price;
|
||||
final cost = count * hpPotion.price;
|
||||
currentInventory = currentInventory.addPotion(hpPotion.id, count);
|
||||
currentGold -= cost;
|
||||
totalSpent += cost;
|
||||
hpPurchased = count;
|
||||
}
|
||||
|
||||
// MP 물약 구매
|
||||
if (mpPotion != null && mpBudget >= mpPotion.price) {
|
||||
final count = mpBudget ~/ mpPotion.price;
|
||||
final cost = count * mpPotion.price;
|
||||
currentInventory = currentInventory.addPotion(mpPotion.id, count);
|
||||
currentGold -= cost;
|
||||
totalSpent += cost;
|
||||
mpPurchased = count;
|
||||
}
|
||||
|
||||
if (totalSpent == 0) {
|
||||
return PotionPurchaseResult.failed(PotionPurchaseFailReason.insufficientGold);
|
||||
}
|
||||
|
||||
return PotionPurchaseResult(
|
||||
success: true,
|
||||
potion: hpPotion ?? mpPotion,
|
||||
quantity: hpPurchased + mpPurchased,
|
||||
totalCost: totalSpent,
|
||||
newGold: currentGold,
|
||||
newInventory: currentInventory,
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 물약 드랍 시스템
|
||||
// ============================================================================
|
||||
|
||||
/// 기본 물약 드랍 확률 (15%)
|
||||
static const double baseDropChance = 0.15;
|
||||
|
||||
/// 레벨당 드랍 확률 증가 (0.5%씩)
|
||||
static const double dropChancePerLevel = 0.005;
|
||||
|
||||
/// 최대 드랍 확률 (35%)
|
||||
static const double maxDropChance = 0.35;
|
||||
|
||||
/// 물약 드랍 시도
|
||||
///
|
||||
/// 전투 승리 시 물약 드랍 여부 결정 및 물약 획득
|
||||
/// [playerLevel] 플레이어 레벨 (드랍 확률 및 티어 결정)
|
||||
/// [inventory] 현재 물약 인벤토리
|
||||
/// [roll] 0~99 범위의 난수 (드랍 확률 판정)
|
||||
/// [typeRoll] 0~99 범위의 난수 (HP/MP 결정)
|
||||
/// Returns: (업데이트된 인벤토리, 드랍된 물약 또는 null)
|
||||
(PotionInventory, Potion?) tryPotionDrop({
|
||||
required int playerLevel,
|
||||
required PotionInventory inventory,
|
||||
required int roll,
|
||||
required int typeRoll,
|
||||
}) {
|
||||
// 드랍 확률 계산
|
||||
final dropChance = (baseDropChance + playerLevel * dropChancePerLevel)
|
||||
.clamp(baseDropChance, maxDropChance);
|
||||
final dropThreshold = (dropChance * 100).round();
|
||||
|
||||
// 드랍 실패
|
||||
if (roll >= dropThreshold) {
|
||||
return (inventory, null);
|
||||
}
|
||||
|
||||
// 물약 타입 결정 (60% HP, 40% MP)
|
||||
final isHpPotion = typeRoll < 60;
|
||||
|
||||
// 레벨 기반 티어 결정
|
||||
final tier = PotionData.tierForLevel(playerLevel);
|
||||
|
||||
// 물약 선택
|
||||
final Potion? potion;
|
||||
if (isHpPotion) {
|
||||
potion = PotionData.getHpPotionByTier(tier);
|
||||
} else {
|
||||
potion = PotionData.getMpPotionByTier(tier);
|
||||
}
|
||||
|
||||
if (potion == null) {
|
||||
return (inventory, null);
|
||||
}
|
||||
|
||||
// 인벤토리에 추가
|
||||
final updatedInventory = inventory.addPotion(potion.id);
|
||||
return (updatedInventory, potion);
|
||||
}
|
||||
}
|
||||
|
||||
/// 물약 사용 결과
|
||||
class PotionUseResult {
|
||||
const PotionUseResult({
|
||||
required this.success,
|
||||
this.potion,
|
||||
this.healedAmount = 0,
|
||||
this.newHp = 0,
|
||||
this.newMp = 0,
|
||||
this.newInventory,
|
||||
this.failReason,
|
||||
});
|
||||
|
||||
/// 성공 여부
|
||||
final bool success;
|
||||
|
||||
/// 사용한 물약
|
||||
final Potion? potion;
|
||||
|
||||
/// 실제 회복량
|
||||
final int healedAmount;
|
||||
|
||||
/// 사용 후 HP
|
||||
final int newHp;
|
||||
|
||||
/// 사용 후 MP
|
||||
final int newMp;
|
||||
|
||||
/// 업데이트된 인벤토리
|
||||
final PotionInventory? newInventory;
|
||||
|
||||
/// 실패 사유
|
||||
final PotionUseFailReason? failReason;
|
||||
|
||||
/// 실패 결과 생성
|
||||
factory PotionUseResult.failed(PotionUseFailReason reason) {
|
||||
return PotionUseResult(
|
||||
success: false,
|
||||
failReason: reason,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 물약 사용 실패 사유
|
||||
enum PotionUseFailReason {
|
||||
/// 물약 없음 (데이터 없음)
|
||||
potionNotFound,
|
||||
|
||||
/// 보유 물약 없음 (재고 부족)
|
||||
outOfStock,
|
||||
|
||||
/// 이번 전투에서 이미 해당 종류 물약 사용
|
||||
alreadyUsedThisBattle,
|
||||
}
|
||||
|
||||
/// 물약 구매 결과
|
||||
class PotionPurchaseResult {
|
||||
const PotionPurchaseResult({
|
||||
required this.success,
|
||||
this.potion,
|
||||
this.quantity = 0,
|
||||
this.totalCost = 0,
|
||||
this.newGold = 0,
|
||||
this.newInventory,
|
||||
this.failReason,
|
||||
});
|
||||
|
||||
/// 성공 여부
|
||||
final bool success;
|
||||
|
||||
/// 구매한 물약
|
||||
final Potion? potion;
|
||||
|
||||
/// 구매 수량
|
||||
final int quantity;
|
||||
|
||||
/// 총 비용
|
||||
final int totalCost;
|
||||
|
||||
/// 구매 후 골드
|
||||
final int newGold;
|
||||
|
||||
/// 업데이트된 인벤토리
|
||||
final PotionInventory? newInventory;
|
||||
|
||||
/// 실패 사유
|
||||
final PotionPurchaseFailReason? failReason;
|
||||
|
||||
/// 실패 결과 생성
|
||||
factory PotionPurchaseResult.failed(PotionPurchaseFailReason reason) {
|
||||
return PotionPurchaseResult(
|
||||
success: false,
|
||||
failReason: reason,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 물약 구매 실패 사유
|
||||
enum PotionPurchaseFailReason {
|
||||
/// 물약 없음 (데이터 없음)
|
||||
potionNotFound,
|
||||
|
||||
/// 골드 부족
|
||||
insufficientGold,
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'package:askiineverdie/data/game_text_l10n.dart' as l10n;
|
||||
import 'package:askiineverdie/data/skill_data.dart';
|
||||
import 'package:askiineverdie/src/core/engine/combat_calculator.dart';
|
||||
import 'package:askiineverdie/src/core/engine/game_mutations.dart';
|
||||
import 'package:askiineverdie/src/core/engine/potion_service.dart';
|
||||
import 'package:askiineverdie/src/core/engine/reward_service.dart';
|
||||
import 'package:askiineverdie/src/core/engine/skill_service.dart';
|
||||
import 'package:askiineverdie/src/core/model/combat_event.dart';
|
||||
@@ -13,7 +14,9 @@ import 'package:askiineverdie/src/core/model/equipment_item.dart';
|
||||
import 'package:askiineverdie/src/core/model/equipment_slot.dart';
|
||||
import 'package:askiineverdie/src/core/model/game_state.dart';
|
||||
import 'package:askiineverdie/src/core/model/monster_combat_stats.dart';
|
||||
import 'package:askiineverdie/src/core/model/potion.dart';
|
||||
import 'package:askiineverdie/src/core/model/pq_config.dart';
|
||||
import 'package:askiineverdie/src/core/model/skill.dart';
|
||||
import 'package:askiineverdie/src/core/util/pq_logic.dart' as pq_logic;
|
||||
|
||||
class ProgressTickResult {
|
||||
@@ -186,9 +189,10 @@ class ProgressService {
|
||||
? progress.task.max
|
||||
: uncapped;
|
||||
|
||||
// 킬 태스크 중 전투 진행 (스킬 자동 사용 포함)
|
||||
// 킬 태스크 중 전투 진행 (스킬 자동 사용, DOT, 물약 포함)
|
||||
var updatedCombat = progress.currentCombat;
|
||||
var updatedSkillSystem = nextState.skillSystem;
|
||||
var updatedPotionInventory = nextState.potionInventory;
|
||||
if (progress.currentTask.type == TaskType.kill && updatedCombat != null && updatedCombat.isActive) {
|
||||
final combatResult = _processCombatTickWithSkills(
|
||||
nextState,
|
||||
@@ -198,6 +202,9 @@ class ProgressService {
|
||||
);
|
||||
updatedCombat = combatResult.combat;
|
||||
updatedSkillSystem = combatResult.skillSystem;
|
||||
if (combatResult.potionInventory != null) {
|
||||
updatedPotionInventory = combatResult.potionInventory!;
|
||||
}
|
||||
|
||||
// Phase 4: 플레이어 사망 체크
|
||||
if (!updatedCombat.playerStats.isAlive) {
|
||||
@@ -216,7 +223,11 @@ class ProgressService {
|
||||
currentCombat: updatedCombat,
|
||||
);
|
||||
nextState = _recalculateEncumbrance(
|
||||
nextState.copyWith(progress: progress, skillSystem: updatedSkillSystem),
|
||||
nextState.copyWith(
|
||||
progress: progress,
|
||||
skillSystem: updatedSkillSystem,
|
||||
potionInventory: updatedPotionInventory,
|
||||
),
|
||||
);
|
||||
return ProgressTickResult(state: nextState);
|
||||
}
|
||||
@@ -245,11 +256,33 @@ class ProgressService {
|
||||
}
|
||||
|
||||
// 전리품 획득 (원본 Main.pas:625-630)
|
||||
nextState = _winLoot(nextState);
|
||||
final lootResult = _winLoot(nextState);
|
||||
nextState = lootResult.state;
|
||||
|
||||
// 전투 상태 초기화
|
||||
progress = nextState.progress.copyWith(currentCombat: null);
|
||||
nextState = nextState.copyWith(progress: progress);
|
||||
// 물약 드랍 시 전투 로그에 이벤트 추가
|
||||
var combatForReset = progress.currentCombat;
|
||||
if (lootResult.droppedPotion != null && combatForReset != null) {
|
||||
final potionDropEvent = CombatEvent.potionDrop(
|
||||
timestamp: nextState.skillSystem.elapsedMs,
|
||||
potionName: lootResult.droppedPotion!.name,
|
||||
isHp: lootResult.droppedPotion!.isHpPotion,
|
||||
);
|
||||
final updatedEvents = [...combatForReset.recentEvents, potionDropEvent];
|
||||
combatForReset = combatForReset.copyWith(
|
||||
recentEvents: updatedEvents.length > 10
|
||||
? updatedEvents.sublist(updatedEvents.length - 10)
|
||||
: updatedEvents,
|
||||
);
|
||||
progress = progress.copyWith(currentCombat: combatForReset);
|
||||
}
|
||||
|
||||
// 전투 상태 초기화 및 물약 사용 기록 초기화
|
||||
progress = progress.copyWith(currentCombat: null);
|
||||
final resetPotionInventory = nextState.potionInventory.resetBattleUsage();
|
||||
nextState = nextState.copyWith(
|
||||
progress: progress,
|
||||
potionInventory: resetPotionInventory,
|
||||
);
|
||||
}
|
||||
|
||||
// 시장/판매/구매 태스크 완료 시 처리 (원본 Main.pas:631-649)
|
||||
@@ -728,39 +761,60 @@ class ProgressService {
|
||||
}
|
||||
|
||||
/// 킬 태스크 완료 시 전리품 획득 (원본 Main.pas:625-630)
|
||||
GameState _winLoot(GameState state) {
|
||||
/// 전리품 획득 결과
|
||||
///
|
||||
/// [state] 업데이트된 게임 상태
|
||||
/// [droppedPotion] 드랍된 물약 (없으면 null)
|
||||
({GameState state, Potion? droppedPotion}) _winLoot(GameState state) {
|
||||
final taskInfo = state.progress.currentTask;
|
||||
final monsterPart = taskInfo.monsterPart ?? '';
|
||||
final monsterBaseName = taskInfo.monsterBaseName ?? '';
|
||||
|
||||
var resultState = state;
|
||||
|
||||
// 부위가 '*'이면 WinItem 호출 (특수 아이템)
|
||||
if (monsterPart == '*') {
|
||||
return mutations.winItem(state);
|
||||
}
|
||||
resultState = mutations.winItem(resultState);
|
||||
} else if (monsterPart.isNotEmpty && monsterBaseName.isNotEmpty) {
|
||||
// 원본: Add(Inventory, LowerCase(Split(fTask.Caption,1) + ' ' +
|
||||
// ProperCase(Split(fTask.Caption,3))), 1);
|
||||
// 예: "goblin Claw" 형태로 인벤토리 추가
|
||||
final itemName =
|
||||
'${monsterBaseName.toLowerCase()} ${_properCase(monsterPart)}';
|
||||
|
||||
// 부위가 비어있으면 전리품 없음
|
||||
if (monsterPart.isEmpty || monsterBaseName.isEmpty) {
|
||||
return state;
|
||||
}
|
||||
// 인벤토리에 추가
|
||||
final items = [...resultState.inventory.items];
|
||||
final existing = items.indexWhere((e) => e.name == itemName);
|
||||
if (existing >= 0) {
|
||||
items[existing] = items[existing].copyWith(
|
||||
count: items[existing].count + 1,
|
||||
);
|
||||
} else {
|
||||
items.add(InventoryEntry(name: itemName, count: 1));
|
||||
}
|
||||
|
||||
// 원본: Add(Inventory, LowerCase(Split(fTask.Caption,1) + ' ' +
|
||||
// ProperCase(Split(fTask.Caption,3))), 1);
|
||||
// 예: "goblin Claw" 형태로 인벤토리 추가
|
||||
final itemName =
|
||||
'${monsterBaseName.toLowerCase()} ${_properCase(monsterPart)}';
|
||||
|
||||
// 인벤토리에 추가
|
||||
final items = [...state.inventory.items];
|
||||
final existing = items.indexWhere((e) => e.name == itemName);
|
||||
if (existing >= 0) {
|
||||
items[existing] = items[existing].copyWith(
|
||||
count: items[existing].count + 1,
|
||||
resultState = resultState.copyWith(
|
||||
inventory: resultState.inventory.copyWith(items: items),
|
||||
);
|
||||
} else {
|
||||
items.add(InventoryEntry(name: itemName, count: 1));
|
||||
}
|
||||
|
||||
return state.copyWith(inventory: state.inventory.copyWith(items: items));
|
||||
// 물약 드랍 시도
|
||||
final potionService = const PotionService();
|
||||
final rng = resultState.rng;
|
||||
final (updatedPotionInventory, droppedPotion) = potionService.tryPotionDrop(
|
||||
playerLevel: resultState.traits.level,
|
||||
inventory: resultState.potionInventory,
|
||||
roll: rng.nextInt(100),
|
||||
typeRoll: rng.nextInt(100),
|
||||
);
|
||||
|
||||
return (
|
||||
state: resultState.copyWith(
|
||||
rng: rng,
|
||||
potionInventory: updatedPotionInventory,
|
||||
),
|
||||
droppedPotion: droppedPotion,
|
||||
);
|
||||
}
|
||||
|
||||
/// 첫 글자만 대문자로 변환 (원본 ProperCase)
|
||||
@@ -796,6 +850,22 @@ class ProgressService {
|
||||
final slotIndex = nextState.rng.nextInt(Equipment.slotCount);
|
||||
nextState = mutations.winEquipByIndex(nextState, level, slotIndex);
|
||||
|
||||
// 물약 자동 구매 (남은 골드의 20% 사용)
|
||||
final potionService = const PotionService();
|
||||
final purchaseResult = potionService.autoPurchasePotions(
|
||||
playerLevel: level,
|
||||
inventory: nextState.potionInventory,
|
||||
gold: nextState.inventory.gold,
|
||||
spendRatio: 0.20,
|
||||
);
|
||||
|
||||
if (purchaseResult.success && purchaseResult.newInventory != null) {
|
||||
nextState = nextState.copyWith(
|
||||
inventory: nextState.inventory.copyWith(gold: purchaseResult.newGold),
|
||||
potionInventory: purchaseResult.newInventory,
|
||||
);
|
||||
}
|
||||
|
||||
return nextState;
|
||||
}
|
||||
|
||||
@@ -864,25 +934,30 @@ class ProgressService {
|
||||
);
|
||||
}
|
||||
|
||||
/// 전투 틱 처리 (스킬 자동 사용 포함)
|
||||
/// 전투 틱 처리 (스킬 자동 사용, DOT, 물약 포함)
|
||||
///
|
||||
/// [state] 현재 게임 상태
|
||||
/// [combat] 현재 전투 상태
|
||||
/// [skillSystem] 스킬 시스템 상태
|
||||
/// [elapsedMs] 경과 시간 (밀리초)
|
||||
/// Returns: 업데이트된 전투 상태 및 스킬 시스템 상태
|
||||
({CombatState combat, SkillSystemState skillSystem}) _processCombatTickWithSkills(
|
||||
/// Returns: 업데이트된 전투 상태, 스킬 시스템 상태, 물약 인벤토리
|
||||
({
|
||||
CombatState combat,
|
||||
SkillSystemState skillSystem,
|
||||
PotionInventory? potionInventory,
|
||||
}) _processCombatTickWithSkills(
|
||||
GameState state,
|
||||
CombatState combat,
|
||||
SkillSystemState skillSystem,
|
||||
int elapsedMs,
|
||||
) {
|
||||
if (!combat.isActive || combat.isCombatOver) {
|
||||
return (combat: combat, skillSystem: skillSystem);
|
||||
return (combat: combat, skillSystem: skillSystem, potionInventory: null);
|
||||
}
|
||||
|
||||
final calculator = CombatCalculator(rng: state.rng);
|
||||
final skillService = SkillService(rng: state.rng);
|
||||
final potionService = const PotionService();
|
||||
var playerStats = combat.playerStats;
|
||||
var monsterStats = combat.monsterStats;
|
||||
var playerAccumulator = combat.playerAttackAccumulatorMs + elapsedMs;
|
||||
@@ -891,11 +966,90 @@ class ProgressService {
|
||||
var totalDamageTaken = combat.totalDamageTaken;
|
||||
var turnsElapsed = combat.turnsElapsed;
|
||||
var updatedSkillSystem = skillSystem;
|
||||
var activeDoTs = [...combat.activeDoTs];
|
||||
var usedPotionTypes = {...combat.usedPotionTypes};
|
||||
PotionInventory? updatedPotionInventory;
|
||||
|
||||
// 새 전투 이벤트 수집
|
||||
final newEvents = <CombatEvent>[];
|
||||
final timestamp = updatedSkillSystem.elapsedMs;
|
||||
|
||||
// =========================================================================
|
||||
// DOT 틱 처리
|
||||
// =========================================================================
|
||||
var dotDamageThisTick = 0;
|
||||
final updatedDoTs = <DotEffect>[];
|
||||
|
||||
for (final dot in activeDoTs) {
|
||||
final (updatedDot, ticksTriggered) = dot.tick(elapsedMs);
|
||||
|
||||
if (ticksTriggered > 0) {
|
||||
final damage = dot.damagePerTick * ticksTriggered;
|
||||
dotDamageThisTick += damage;
|
||||
|
||||
// DOT 데미지 이벤트 생성
|
||||
newEvents.add(CombatEvent.dotTick(
|
||||
timestamp: timestamp,
|
||||
skillName: dot.skillId,
|
||||
damage: damage,
|
||||
targetName: monsterStats.name,
|
||||
));
|
||||
}
|
||||
|
||||
// 만료되지 않은 DOT만 유지
|
||||
if (updatedDot.isActive) {
|
||||
updatedDoTs.add(updatedDot);
|
||||
}
|
||||
}
|
||||
|
||||
// DOT 데미지 적용
|
||||
if (dotDamageThisTick > 0 && monsterStats.isAlive) {
|
||||
final newMonsterHp = (monsterStats.hpCurrent - dotDamageThisTick)
|
||||
.clamp(0, monsterStats.hpMax);
|
||||
monsterStats = monsterStats.copyWith(hpCurrent: newMonsterHp);
|
||||
totalDamageDealt += dotDamageThisTick;
|
||||
}
|
||||
|
||||
activeDoTs = updatedDoTs;
|
||||
|
||||
// =========================================================================
|
||||
// 긴급 물약 자동 사용 (HP < 30%)
|
||||
// =========================================================================
|
||||
final hpRatio = playerStats.hpCurrent / playerStats.hpMax;
|
||||
if (hpRatio <= PotionService.emergencyHpThreshold) {
|
||||
final emergencyPotion = potionService.selectEmergencyHpPotion(
|
||||
currentHp: playerStats.hpCurrent,
|
||||
maxHp: playerStats.hpMax,
|
||||
inventory: state.potionInventory,
|
||||
playerLevel: state.traits.level,
|
||||
);
|
||||
|
||||
if (emergencyPotion != null &&
|
||||
!usedPotionTypes.contains(PotionType.hp)) {
|
||||
final result = potionService.usePotion(
|
||||
potionId: emergencyPotion.id,
|
||||
inventory: state.potionInventory,
|
||||
currentHp: playerStats.hpCurrent,
|
||||
maxHp: playerStats.hpMax,
|
||||
currentMp: playerStats.mpCurrent,
|
||||
maxMp: playerStats.mpMax,
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
playerStats = playerStats.copyWith(hpCurrent: result.newHp);
|
||||
usedPotionTypes = {...usedPotionTypes, PotionType.hp};
|
||||
updatedPotionInventory = result.newInventory;
|
||||
|
||||
newEvents.add(CombatEvent.playerPotion(
|
||||
timestamp: timestamp,
|
||||
potionName: emergencyPotion.name,
|
||||
healAmount: result.healedAmount,
|
||||
isHp: true,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 플레이어 공격 체크
|
||||
if (playerAccumulator >= playerStats.attackDelayMs) {
|
||||
// 스킬 자동 선택
|
||||
@@ -912,6 +1066,7 @@ class ProgressService {
|
||||
monster: monsterStats,
|
||||
skillSystem: updatedSkillSystem,
|
||||
availableSkillIds: availableSkillIds,
|
||||
activeDoTs: activeDoTs,
|
||||
);
|
||||
|
||||
if (selectedSkill != null && selectedSkill.isAttack) {
|
||||
@@ -934,6 +1089,30 @@ class ProgressService {
|
||||
damage: skillResult.result.damage,
|
||||
targetName: monsterStats.name,
|
||||
));
|
||||
} else if (selectedSkill != null && selectedSkill.isDot) {
|
||||
// DOT 스킬 사용
|
||||
final skillResult = skillService.useDotSkill(
|
||||
skill: selectedSkill,
|
||||
player: playerStats,
|
||||
skillSystem: updatedSkillSystem,
|
||||
playerInt: state.stats.intelligence,
|
||||
playerWis: state.stats.wis,
|
||||
);
|
||||
playerStats = skillResult.updatedPlayer;
|
||||
updatedSkillSystem = skillResult.updatedSkillSystem;
|
||||
|
||||
// DOT 효과 추가
|
||||
if (skillResult.dotEffect != null) {
|
||||
activeDoTs.add(skillResult.dotEffect!);
|
||||
}
|
||||
|
||||
// DOT 스킬 사용 이벤트 생성
|
||||
newEvents.add(CombatEvent.playerSkill(
|
||||
timestamp: timestamp,
|
||||
skillName: selectedSkill.name,
|
||||
damage: skillResult.result.damage,
|
||||
targetName: monsterStats.name,
|
||||
));
|
||||
} else if (selectedSkill != null && selectedSkill.isHeal) {
|
||||
// 회복 스킬 사용
|
||||
final skillResult = skillService.useHealSkill(
|
||||
@@ -1053,8 +1232,11 @@ class ProgressService {
|
||||
turnsElapsed: turnsElapsed,
|
||||
isActive: isActive,
|
||||
recentEvents: recentEvents,
|
||||
activeDoTs: activeDoTs,
|
||||
usedPotionTypes: usedPotionTypes,
|
||||
),
|
||||
skillSystem: updatedSkillSystem,
|
||||
potionInventory: updatedPotionInventory,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -181,6 +181,60 @@ class SkillService {
|
||||
);
|
||||
}
|
||||
|
||||
/// DOT 스킬 사용
|
||||
///
|
||||
/// DOT 효과를 생성하여 반환. 호출자가 전투 상태의 activeDoTs에 추가해야 함.
|
||||
/// INT → 틱당 데미지 보정, WIS → 틱 간격 보정
|
||||
({
|
||||
SkillUseResult result,
|
||||
CombatStats updatedPlayer,
|
||||
SkillSystemState updatedSkillSystem,
|
||||
DotEffect? dotEffect,
|
||||
}) useDotSkill({
|
||||
required Skill skill,
|
||||
required CombatStats player,
|
||||
required SkillSystemState skillSystem,
|
||||
required int playerInt,
|
||||
required int playerWis,
|
||||
}) {
|
||||
if (!skill.isDot) {
|
||||
return (
|
||||
result: SkillUseResult.failed(skill, SkillFailReason.invalidState),
|
||||
updatedPlayer: player,
|
||||
updatedSkillSystem: skillSystem,
|
||||
dotEffect: null,
|
||||
);
|
||||
}
|
||||
|
||||
// DOT 효과 생성 (INT/WIS 보정 적용)
|
||||
final dotEffect = DotEffect.fromSkill(
|
||||
skill,
|
||||
playerInt: playerInt,
|
||||
playerWis: playerWis,
|
||||
);
|
||||
|
||||
// MP 소모
|
||||
var updatedPlayer = player.withMp(player.mpCurrent - skill.mpCost);
|
||||
|
||||
// 스킬 상태 업데이트 (쿨타임 시작)
|
||||
final updatedSkillSystem = _updateSkillCooldown(skillSystem, skill.id);
|
||||
|
||||
// 예상 총 데미지 계산 (틱 수 × 틱당 데미지)
|
||||
final expectedTicks = dotEffect.totalDurationMs ~/ dotEffect.tickIntervalMs;
|
||||
final expectedDamage = expectedTicks * dotEffect.damagePerTick;
|
||||
|
||||
return (
|
||||
result: SkillUseResult(
|
||||
skill: skill,
|
||||
success: true,
|
||||
damage: expectedDamage,
|
||||
),
|
||||
updatedPlayer: updatedPlayer,
|
||||
updatedSkillSystem: updatedSkillSystem,
|
||||
dotEffect: dotEffect,
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 자동 스킬 선택
|
||||
// ============================================================================
|
||||
@@ -189,14 +243,16 @@ class SkillService {
|
||||
///
|
||||
/// 우선순위:
|
||||
/// 1. HP < 30% → 회복 스킬
|
||||
/// 2. 보스전 (레벨 차이 10 이상) → 가장 강력한 공격 스킬
|
||||
/// 3. 일반 전투 → MP 효율이 좋은 스킬
|
||||
/// 4. MP < 20% → null (일반 공격)
|
||||
/// 2. 몬스터 HP > 50% & DOT 없음 → DOT 스킬 (장기전 유리)
|
||||
/// 3. 보스전 (레벨 차이 10 이상) → 가장 강력한 공격 스킬
|
||||
/// 4. 일반 전투 → MP 효율이 좋은 스킬
|
||||
/// 5. MP < 20% → null (일반 공격)
|
||||
Skill? selectAutoSkill({
|
||||
required CombatStats player,
|
||||
required MonsterCombatStats monster,
|
||||
required SkillSystemState skillSystem,
|
||||
required List<String> availableSkillIds,
|
||||
List<DotEffect> activeDoTs = const [],
|
||||
}) {
|
||||
final currentMp = player.mpCurrent;
|
||||
final mpRatio = player.mpRatio;
|
||||
@@ -225,6 +281,12 @@ class SkillService {
|
||||
if (healSkill != null) return healSkill;
|
||||
}
|
||||
|
||||
// 몬스터 HP > 50% & 활성 DOT 없음 → DOT 스킬 사용
|
||||
if (monster.hpRatio > 0.5 && activeDoTs.isEmpty) {
|
||||
final dotSkill = _findBestDotSkill(availableSkills, currentMp);
|
||||
if (dotSkill != null) return dotSkill;
|
||||
}
|
||||
|
||||
// 보스전 판단 (몬스터 레벨이 높음)
|
||||
final isBossFight = monster.level >= 10 && monster.hpRatio > 0.5;
|
||||
|
||||
@@ -237,6 +299,28 @@ class SkillService {
|
||||
return _findEfficientAttackSkill(availableSkills);
|
||||
}
|
||||
|
||||
/// 가장 좋은 DOT 스킬 찾기
|
||||
///
|
||||
/// 예상 총 데미지 (틱 × 데미지) 기준으로 선택
|
||||
Skill? _findBestDotSkill(List<Skill> skills, int currentMp) {
|
||||
final dotSkills = skills
|
||||
.where((s) => s.isDot && s.mpCost <= currentMp)
|
||||
.toList();
|
||||
|
||||
if (dotSkills.isEmpty) return null;
|
||||
|
||||
// 예상 총 데미지 기준 정렬
|
||||
dotSkills.sort((a, b) {
|
||||
final aTotal = (a.baseDotDamage ?? 0) *
|
||||
((a.baseDotDurationMs ?? 0) ~/ (a.baseDotTickMs ?? 1000));
|
||||
final bTotal = (b.baseDotDamage ?? 0) *
|
||||
((b.baseDotDurationMs ?? 0) ~/ (b.baseDotTickMs ?? 1000));
|
||||
return bTotal.compareTo(aTotal);
|
||||
});
|
||||
|
||||
return dotSkills.first;
|
||||
}
|
||||
|
||||
/// 가장 좋은 회복 스킬 찾기
|
||||
Skill? _findBestHealSkill(List<Skill> skills, int currentMp) {
|
||||
final healSkills = skills
|
||||
|
||||
@@ -26,6 +26,15 @@ enum CombatEventType {
|
||||
|
||||
/// 플레이어 버프
|
||||
playerBuff,
|
||||
|
||||
/// DOT 틱 데미지
|
||||
dotTick,
|
||||
|
||||
/// 물약 사용
|
||||
playerPotion,
|
||||
|
||||
/// 물약 드랍
|
||||
potionDrop,
|
||||
}
|
||||
|
||||
/// 전투 이벤트 (Combat Event)
|
||||
@@ -188,4 +197,51 @@ class CombatEvent {
|
||||
skillName: skillName,
|
||||
);
|
||||
}
|
||||
|
||||
/// DOT 틱 이벤트 생성
|
||||
factory CombatEvent.dotTick({
|
||||
required int timestamp,
|
||||
required String skillName,
|
||||
required int damage,
|
||||
required String targetName,
|
||||
}) {
|
||||
return CombatEvent(
|
||||
type: CombatEventType.dotTick,
|
||||
timestamp: timestamp,
|
||||
skillName: skillName,
|
||||
damage: damage,
|
||||
targetName: targetName,
|
||||
);
|
||||
}
|
||||
|
||||
/// 물약 사용 이벤트 생성
|
||||
factory CombatEvent.playerPotion({
|
||||
required int timestamp,
|
||||
required String potionName,
|
||||
required int healAmount,
|
||||
required bool isHp,
|
||||
}) {
|
||||
return CombatEvent(
|
||||
type: CombatEventType.playerPotion,
|
||||
timestamp: timestamp,
|
||||
skillName: potionName,
|
||||
healAmount: healAmount,
|
||||
// isHp를 구분하기 위해 targetName 사용 (HP/MP)
|
||||
targetName: isHp ? 'HP' : 'MP',
|
||||
);
|
||||
}
|
||||
|
||||
/// 물약 드랍 이벤트 생성
|
||||
factory CombatEvent.potionDrop({
|
||||
required int timestamp,
|
||||
required String potionName,
|
||||
required bool isHp,
|
||||
}) {
|
||||
return CombatEvent(
|
||||
type: CombatEventType.potionDrop,
|
||||
timestamp: timestamp,
|
||||
skillName: potionName,
|
||||
targetName: isHp ? 'HP' : 'MP',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:askiineverdie/src/core/model/combat_event.dart';
|
||||
import 'package:askiineverdie/src/core/model/combat_stats.dart';
|
||||
import 'package:askiineverdie/src/core/model/monster_combat_stats.dart';
|
||||
import 'package:askiineverdie/src/core/model/potion.dart';
|
||||
import 'package:askiineverdie/src/core/model/skill.dart';
|
||||
|
||||
/// 현재 전투 상태
|
||||
///
|
||||
@@ -17,6 +19,8 @@ class CombatState {
|
||||
required this.turnsElapsed,
|
||||
required this.isActive,
|
||||
this.recentEvents = const [],
|
||||
this.activeDoTs = const [],
|
||||
this.usedPotionTypes = const {},
|
||||
});
|
||||
|
||||
/// 플레이어 전투 스탯
|
||||
@@ -46,6 +50,12 @@ class CombatState {
|
||||
/// 최근 전투 이벤트 목록 (최대 10개)
|
||||
final List<CombatEvent> recentEvents;
|
||||
|
||||
/// 활성 DOT 효과 목록
|
||||
final List<DotEffect> activeDoTs;
|
||||
|
||||
/// 이번 전투에서 사용한 물약 종류 (종류별 1회 제한)
|
||||
final Set<PotionType> usedPotionTypes;
|
||||
|
||||
// ============================================================================
|
||||
// 유틸리티
|
||||
// ============================================================================
|
||||
@@ -65,6 +75,19 @@ class CombatState {
|
||||
/// 몬스터 HP 비율
|
||||
double get monsterHpRatio => monsterStats.hpRatio;
|
||||
|
||||
/// 특정 종류 물약 사용 가능 여부
|
||||
bool canUsePotionType(PotionType type) => !usedPotionTypes.contains(type);
|
||||
|
||||
/// 활성 DOT 존재 여부
|
||||
bool get hasActiveDoTs => activeDoTs.isNotEmpty;
|
||||
|
||||
/// DOT 총 예상 데미지
|
||||
int get totalDotDamageRemaining {
|
||||
return activeDoTs.fold(0, (sum, dot) {
|
||||
return sum + (dot.damagePerTick * dot.remainingTicks);
|
||||
});
|
||||
}
|
||||
|
||||
CombatState copyWith({
|
||||
CombatStats? playerStats,
|
||||
MonsterCombatStats? monsterStats,
|
||||
@@ -75,6 +98,8 @@ class CombatState {
|
||||
int? turnsElapsed,
|
||||
bool? isActive,
|
||||
List<CombatEvent>? recentEvents,
|
||||
List<DotEffect>? activeDoTs,
|
||||
Set<PotionType>? usedPotionTypes,
|
||||
}) {
|
||||
return CombatState(
|
||||
playerStats: playerStats ?? this.playerStats,
|
||||
@@ -88,6 +113,8 @@ class CombatState {
|
||||
turnsElapsed: turnsElapsed ?? this.turnsElapsed,
|
||||
isActive: isActive ?? this.isActive,
|
||||
recentEvents: recentEvents ?? this.recentEvents,
|
||||
activeDoTs: activeDoTs ?? this.activeDoTs,
|
||||
usedPotionTypes: usedPotionTypes ?? this.usedPotionTypes,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -270,9 +270,13 @@ class CombatStats {
|
||||
final baseParryRate = (effectiveDex + effectiveStr) * 0.002;
|
||||
final parryRate = (baseParryRate + equipStats.parryRate).clamp(0.0, 0.4);
|
||||
|
||||
// 공격 속도: DEX 기반 (기본 1000ms, 최소 357ms)
|
||||
// 공격 속도: 무기 기본 공속 + DEX 보정
|
||||
// 무기 attackSpeed가 0이면 기본값 1000ms 사용
|
||||
final weaponItem = equipment.items[0]; // 무기 슬롯
|
||||
final weaponSpeed = weaponItem.stats.attackSpeed;
|
||||
final baseAttackSpeed = weaponSpeed > 0 ? weaponSpeed : 1000;
|
||||
final speedModifier = 1.0 + (effectiveDex - 10) * 0.02;
|
||||
final attackDelayMs = (1000 / speedModifier).round().clamp(357, 1500);
|
||||
final attackDelayMs = (baseAttackSpeed / speedModifier).round().clamp(300, 2000);
|
||||
|
||||
// HP/MP: 기본 + 장비 보너스
|
||||
var totalHpMax = stats.hpMax + equipStats.hpBonus;
|
||||
|
||||
@@ -5,6 +5,7 @@ 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/model/potion.dart';
|
||||
import 'package:askiineverdie/src/core/model/skill.dart';
|
||||
import 'package:askiineverdie/src/core/util/deterministic_random.dart';
|
||||
|
||||
@@ -23,6 +24,7 @@ class GameState {
|
||||
ProgressState? progress,
|
||||
QueueState? queue,
|
||||
SkillSystemState? skillSystem,
|
||||
PotionInventory? potionInventory,
|
||||
this.deathInfo,
|
||||
}) : rng = DeterministicRandom.clone(rng),
|
||||
traits = traits ?? Traits.empty(),
|
||||
@@ -32,7 +34,8 @@ class GameState {
|
||||
spellBook = spellBook ?? SpellBook.empty(),
|
||||
progress = progress ?? ProgressState.empty(),
|
||||
queue = queue ?? QueueState.empty(),
|
||||
skillSystem = skillSystem ?? SkillSystemState.empty();
|
||||
skillSystem = skillSystem ?? SkillSystemState.empty(),
|
||||
potionInventory = potionInventory ?? const PotionInventory();
|
||||
|
||||
factory GameState.withSeed({
|
||||
required int seed,
|
||||
@@ -44,6 +47,7 @@ class GameState {
|
||||
ProgressState? progress,
|
||||
QueueState? queue,
|
||||
SkillSystemState? skillSystem,
|
||||
PotionInventory? potionInventory,
|
||||
DeathInfo? deathInfo,
|
||||
}) {
|
||||
return GameState(
|
||||
@@ -56,6 +60,7 @@ class GameState {
|
||||
progress: progress,
|
||||
queue: queue,
|
||||
skillSystem: skillSystem,
|
||||
potionInventory: potionInventory,
|
||||
deathInfo: deathInfo,
|
||||
);
|
||||
}
|
||||
@@ -72,6 +77,9 @@ class GameState {
|
||||
/// 스킬 시스템 상태 (Phase 3)
|
||||
final SkillSystemState skillSystem;
|
||||
|
||||
/// 물약 인벤토리
|
||||
final PotionInventory potionInventory;
|
||||
|
||||
/// 사망 정보 (Phase 4, null이면 생존 중)
|
||||
final DeathInfo? deathInfo;
|
||||
|
||||
@@ -88,6 +96,7 @@ class GameState {
|
||||
ProgressState? progress,
|
||||
QueueState? queue,
|
||||
SkillSystemState? skillSystem,
|
||||
PotionInventory? potionInventory,
|
||||
DeathInfo? deathInfo,
|
||||
bool clearDeathInfo = false,
|
||||
}) {
|
||||
@@ -101,6 +110,7 @@ class GameState {
|
||||
progress: progress ?? this.progress,
|
||||
queue: queue ?? this.queue,
|
||||
skillSystem: skillSystem ?? this.skillSystem,
|
||||
potionInventory: potionInventory ?? this.potionInventory,
|
||||
deathInfo: clearDeathInfo ? null : (deathInfo ?? this.deathInfo),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ class ItemStats {
|
||||
this.intBonus = 0,
|
||||
this.wisBonus = 0,
|
||||
this.chaBonus = 0,
|
||||
this.attackSpeed = 0,
|
||||
});
|
||||
|
||||
/// 물리 공격력 보정
|
||||
@@ -97,6 +98,12 @@ class ItemStats {
|
||||
/// CHA 보너스
|
||||
final int chaBonus;
|
||||
|
||||
/// 무기 공격속도 (밀리초, 무기 전용)
|
||||
///
|
||||
/// 0이면 기본값(1000ms) 사용, 값이 클수록 느린 공격.
|
||||
/// 느린 무기는 높은 기본 데미지를 가짐.
|
||||
final int attackSpeed;
|
||||
|
||||
/// 스탯 합계 (가중치 계산용)
|
||||
int get totalStatValue {
|
||||
return atk +
|
||||
@@ -121,6 +128,8 @@ class ItemStats {
|
||||
static const empty = ItemStats();
|
||||
|
||||
/// 두 스탯 합산
|
||||
///
|
||||
/// attackSpeed는 합산 대상 아님 (무기 슬롯 단일 값)
|
||||
ItemStats operator +(ItemStats other) {
|
||||
return ItemStats(
|
||||
atk: atk + other.atk,
|
||||
@@ -139,6 +148,7 @@ class ItemStats {
|
||||
intBonus: intBonus + other.intBonus,
|
||||
wisBonus: wisBonus + other.wisBonus,
|
||||
chaBonus: chaBonus + other.chaBonus,
|
||||
// attackSpeed는 무기에서만 직접 참조
|
||||
);
|
||||
}
|
||||
|
||||
@@ -159,6 +169,7 @@ class ItemStats {
|
||||
int? intBonus,
|
||||
int? wisBonus,
|
||||
int? chaBonus,
|
||||
int? attackSpeed,
|
||||
}) {
|
||||
return ItemStats(
|
||||
atk: atk ?? this.atk,
|
||||
@@ -177,6 +188,7 @@ class ItemStats {
|
||||
intBonus: intBonus ?? this.intBonus,
|
||||
wisBonus: wisBonus ?? this.wisBonus,
|
||||
chaBonus: chaBonus ?? this.chaBonus,
|
||||
attackSpeed: attackSpeed ?? this.attackSpeed,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
136
lib/src/core/model/potion.dart
Normal file
136
lib/src/core/model/potion.dart
Normal file
@@ -0,0 +1,136 @@
|
||||
/// 물약 종류
|
||||
enum PotionType {
|
||||
/// HP 회복 물약
|
||||
hp,
|
||||
|
||||
/// MP 회복 물약
|
||||
mp,
|
||||
}
|
||||
|
||||
/// 물약 아이템
|
||||
///
|
||||
/// 전투 중 사용 가능한 소모품.
|
||||
/// 전투당 종류별 1회만 사용 가능.
|
||||
class Potion {
|
||||
const Potion({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.type,
|
||||
required this.tier,
|
||||
this.healAmount = 0,
|
||||
this.healPercent = 0.0,
|
||||
this.price = 0,
|
||||
});
|
||||
|
||||
/// 물약 ID
|
||||
final String id;
|
||||
|
||||
/// 물약 이름
|
||||
final String name;
|
||||
|
||||
/// 물약 종류 (hp / mp)
|
||||
final PotionType type;
|
||||
|
||||
/// 물약 티어 (1~5, 높을수록 강력)
|
||||
final int tier;
|
||||
|
||||
/// 고정 회복량
|
||||
final int healAmount;
|
||||
|
||||
/// 비율 회복량 (0.0 ~ 1.0)
|
||||
final double healPercent;
|
||||
|
||||
/// 구매 가격 (골드)
|
||||
final int price;
|
||||
|
||||
/// HP 물약 여부
|
||||
bool get isHpPotion => type == PotionType.hp;
|
||||
|
||||
/// MP 물약 여부
|
||||
bool get isMpPotion => type == PotionType.mp;
|
||||
|
||||
/// 실제 회복량 계산
|
||||
///
|
||||
/// [maxValue] 최대 HP 또는 MP
|
||||
int calculateHeal(int maxValue) {
|
||||
final percentHeal = (maxValue * healPercent).round();
|
||||
return healAmount + percentHeal;
|
||||
}
|
||||
}
|
||||
|
||||
/// 물약 인벤토리 상태
|
||||
///
|
||||
/// 보유 물약 수량 및 전투 중 사용 기록 관리
|
||||
class PotionInventory {
|
||||
const PotionInventory({
|
||||
this.potions = const {},
|
||||
this.usedInBattle = const {},
|
||||
});
|
||||
|
||||
/// 보유 물약 (물약 ID → 수량)
|
||||
final Map<String, int> potions;
|
||||
|
||||
/// 현재 전투에서 사용한 물약 종류
|
||||
final Set<PotionType> usedInBattle;
|
||||
|
||||
/// 물약 보유 여부
|
||||
bool hasPotion(String potionId) => (potions[potionId] ?? 0) > 0;
|
||||
|
||||
/// 물약 수량 조회
|
||||
int getQuantity(String potionId) => potions[potionId] ?? 0;
|
||||
|
||||
/// 특정 종류 물약 사용 가능 여부
|
||||
///
|
||||
/// 전투당 종류별 1회 제한 체크
|
||||
bool canUseType(PotionType type) => !usedInBattle.contains(type);
|
||||
|
||||
/// 물약 추가
|
||||
PotionInventory addPotion(String potionId, [int count = 1]) {
|
||||
final newPotions = Map<String, int>.from(potions);
|
||||
newPotions[potionId] = (newPotions[potionId] ?? 0) + count;
|
||||
return PotionInventory(
|
||||
potions: newPotions,
|
||||
usedInBattle: usedInBattle,
|
||||
);
|
||||
}
|
||||
|
||||
/// 물약 사용 (수량 감소)
|
||||
PotionInventory usePotion(String potionId, PotionType type) {
|
||||
final currentQty = potions[potionId] ?? 0;
|
||||
if (currentQty <= 0) return this;
|
||||
|
||||
final newPotions = Map<String, int>.from(potions);
|
||||
newPotions[potionId] = currentQty - 1;
|
||||
if (newPotions[potionId] == 0) {
|
||||
newPotions.remove(potionId);
|
||||
}
|
||||
|
||||
final newUsed = Set<PotionType>.from(usedInBattle)..add(type);
|
||||
|
||||
return PotionInventory(
|
||||
potions: newPotions,
|
||||
usedInBattle: newUsed,
|
||||
);
|
||||
}
|
||||
|
||||
/// 전투 종료 시 사용 기록 초기화
|
||||
PotionInventory resetBattleUsage() {
|
||||
return PotionInventory(
|
||||
potions: potions,
|
||||
usedInBattle: const {},
|
||||
);
|
||||
}
|
||||
|
||||
/// 빈 인벤토리
|
||||
static const empty = PotionInventory();
|
||||
|
||||
PotionInventory copyWith({
|
||||
Map<String, int>? potions,
|
||||
Set<PotionType>? usedInBattle,
|
||||
}) {
|
||||
return PotionInventory(
|
||||
potions: potions ?? this.potions,
|
||||
usedInBattle: usedInBattle ?? this.usedInBattle,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,42 @@ enum SkillType {
|
||||
debuff,
|
||||
}
|
||||
|
||||
/// 스킬 속성 (하이브리드: 코드 + 시스템)
|
||||
enum SkillElement {
|
||||
/// 논리 (Logic) - 순수 데미지
|
||||
logic,
|
||||
|
||||
/// 메모리 (Memory) - DoT 특화
|
||||
memory,
|
||||
|
||||
/// 네트워크 (Network) - 다중 타격
|
||||
network,
|
||||
|
||||
/// 화염 (Overheat) - 높은 순간 데미지
|
||||
fire,
|
||||
|
||||
/// 빙결 (Freeze) - 슬로우 효과
|
||||
ice,
|
||||
|
||||
/// 전기 (Surge) - 빠른 연속 타격
|
||||
lightning,
|
||||
|
||||
/// 공허 (Null) - 방어 무시
|
||||
voidElement,
|
||||
|
||||
/// 혼돈 (Glitch) - 랜덤 효과
|
||||
chaos,
|
||||
}
|
||||
|
||||
/// 공격 방식
|
||||
enum AttackMode {
|
||||
/// 단발성 - 즉시 데미지
|
||||
instant,
|
||||
|
||||
/// 지속 피해 - N초간 틱당 데미지
|
||||
dot,
|
||||
}
|
||||
|
||||
/// 버프 효과
|
||||
class BuffEffect {
|
||||
const BuffEffect({
|
||||
@@ -62,6 +98,11 @@ class Skill {
|
||||
this.buff,
|
||||
this.selfDamagePercent = 0.0,
|
||||
this.targetDefReduction = 0.0,
|
||||
this.element,
|
||||
this.attackMode = AttackMode.instant,
|
||||
this.baseDotDamage,
|
||||
this.baseDotDurationMs,
|
||||
this.baseDotTickMs,
|
||||
});
|
||||
|
||||
/// 스킬 ID
|
||||
@@ -100,6 +141,21 @@ class Skill {
|
||||
/// 적 방어력 감소 % (일부 공격 스킬)
|
||||
final double targetDefReduction;
|
||||
|
||||
/// 스킬 속성 (element) - 하이브리드 시스템
|
||||
final SkillElement? element;
|
||||
|
||||
/// 공격 방식 (instant: 단발성, dot: 지속 피해)
|
||||
final AttackMode attackMode;
|
||||
|
||||
/// DOT 기본 틱당 데미지 (스킬 레벨로 결정)
|
||||
final int? baseDotDamage;
|
||||
|
||||
/// DOT 기본 지속시간 (밀리초, 스킬 레벨로 결정)
|
||||
final int? baseDotDurationMs;
|
||||
|
||||
/// DOT 기본 틱 간격 (밀리초, 스킬 레벨로 결정)
|
||||
final int? baseDotTickMs;
|
||||
|
||||
/// 공격 스킬 여부
|
||||
bool get isAttack => type == SkillType.attack;
|
||||
|
||||
@@ -112,6 +168,9 @@ class Skill {
|
||||
/// 디버프 스킬 여부
|
||||
bool get isDebuff => type == SkillType.debuff;
|
||||
|
||||
/// DOT 스킬 여부
|
||||
bool get isDot => attackMode == AttackMode.dot;
|
||||
|
||||
/// MP 효율 (데미지 당 MP 비용)
|
||||
double get mpEfficiency {
|
||||
if (type != SkillType.attack || damageMultiplier <= 0) return 0;
|
||||
@@ -265,3 +324,140 @@ enum SkillFailReason {
|
||||
/// 사용 불가 상태
|
||||
invalidState,
|
||||
}
|
||||
|
||||
/// DOT (지속 피해) 효과
|
||||
///
|
||||
/// 스킬 사용 시 생성되어 전투 중 틱마다 데미지를 적용.
|
||||
/// - INT: 틱당 데미지 증가
|
||||
/// - WIS: 틱 간격 감소 (더 빠른 피해)
|
||||
/// - 스킬 레벨: 기본 데미지, 지속시간, 틱 간격 결정
|
||||
class DotEffect {
|
||||
const DotEffect({
|
||||
required this.skillId,
|
||||
required this.baseDamage,
|
||||
required this.damagePerTick,
|
||||
required this.tickIntervalMs,
|
||||
required this.totalDurationMs,
|
||||
this.remainingDurationMs = 0,
|
||||
this.tickAccumulatorMs = 0,
|
||||
this.element,
|
||||
});
|
||||
|
||||
/// 원본 스킬 ID
|
||||
final String skillId;
|
||||
|
||||
/// 스킬 기본 데미지 (스킬 레벨로 결정)
|
||||
final int baseDamage;
|
||||
|
||||
/// INT 보정 적용된 틱당 실제 데미지
|
||||
final int damagePerTick;
|
||||
|
||||
/// WIS 보정 적용된 틱 간격 (밀리초)
|
||||
final int tickIntervalMs;
|
||||
|
||||
/// 총 지속시간 (밀리초, 스킬 레벨로 결정)
|
||||
final int totalDurationMs;
|
||||
|
||||
/// 남은 지속시간 (밀리초)
|
||||
final int remainingDurationMs;
|
||||
|
||||
/// 다음 틱까지 누적 시간 (밀리초)
|
||||
final int tickAccumulatorMs;
|
||||
|
||||
/// 속성 (선택)
|
||||
final SkillElement? element;
|
||||
|
||||
/// DOT 만료 여부
|
||||
bool get isExpired => remainingDurationMs <= 0;
|
||||
|
||||
/// DOT 활성 여부
|
||||
bool get isActive => remainingDurationMs > 0;
|
||||
|
||||
/// 예상 남은 틱 수
|
||||
int get remainingTicks {
|
||||
if (tickIntervalMs <= 0) return 0;
|
||||
return (remainingDurationMs / tickIntervalMs).ceil();
|
||||
}
|
||||
|
||||
/// 스킬과 플레이어 스탯으로 DotEffect 생성
|
||||
///
|
||||
/// [skill] DOT 스킬
|
||||
/// [playerInt] 플레이어 INT (틱당 데미지 보정)
|
||||
/// [playerWis] 플레이어 WIS (틱 간격 보정)
|
||||
factory DotEffect.fromSkill(Skill skill, {int playerInt = 10, int playerWis = 10}) {
|
||||
assert(skill.isDot, 'DOT 스킬만 DotEffect 생성 가능');
|
||||
assert(skill.baseDotDamage != null, 'baseDotDamage 필수');
|
||||
assert(skill.baseDotDurationMs != null, 'baseDotDurationMs 필수');
|
||||
assert(skill.baseDotTickMs != null, 'baseDotTickMs 필수');
|
||||
|
||||
// INT → 데미지 보정 (INT 10 기준, ±3%/포인트)
|
||||
final intMod = 1.0 + (playerInt - 10) * 0.03;
|
||||
final actualDamage = (skill.baseDotDamage! * intMod).round();
|
||||
|
||||
// WIS → 틱 간격 보정 (WIS 10 기준, ±2%/포인트, 빨라짐)
|
||||
final wisMod = 1.0 + (playerWis - 10) * 0.02;
|
||||
final actualTickMs = (skill.baseDotTickMs! / wisMod).clamp(200, 2000).round();
|
||||
|
||||
return DotEffect(
|
||||
skillId: skill.id,
|
||||
baseDamage: skill.baseDotDamage!,
|
||||
damagePerTick: actualDamage.clamp(1, 9999),
|
||||
tickIntervalMs: actualTickMs,
|
||||
totalDurationMs: skill.baseDotDurationMs!,
|
||||
remainingDurationMs: skill.baseDotDurationMs!,
|
||||
tickAccumulatorMs: 0,
|
||||
element: skill.element,
|
||||
);
|
||||
}
|
||||
|
||||
/// 시간 경과 후 새 DotEffect 반환
|
||||
///
|
||||
/// [elapsedMs] 경과 시간 (밀리초)
|
||||
/// Returns: (새 DotEffect, 이번에 발생한 틱 수)
|
||||
(DotEffect, int) tick(int elapsedMs) {
|
||||
var newAccumulator = tickAccumulatorMs + elapsedMs;
|
||||
var newRemaining = remainingDurationMs - elapsedMs;
|
||||
var ticksTriggered = 0;
|
||||
|
||||
// 틱 발생 체크
|
||||
while (newAccumulator >= tickIntervalMs && newRemaining > 0) {
|
||||
newAccumulator -= tickIntervalMs;
|
||||
ticksTriggered++;
|
||||
}
|
||||
|
||||
final updated = DotEffect(
|
||||
skillId: skillId,
|
||||
baseDamage: baseDamage,
|
||||
damagePerTick: damagePerTick,
|
||||
tickIntervalMs: tickIntervalMs,
|
||||
totalDurationMs: totalDurationMs,
|
||||
remainingDurationMs: newRemaining.clamp(0, totalDurationMs),
|
||||
tickAccumulatorMs: newRemaining > 0 ? newAccumulator : 0,
|
||||
element: element,
|
||||
);
|
||||
|
||||
return (updated, ticksTriggered);
|
||||
}
|
||||
|
||||
DotEffect copyWith({
|
||||
String? skillId,
|
||||
int? baseDamage,
|
||||
int? damagePerTick,
|
||||
int? tickIntervalMs,
|
||||
int? totalDurationMs,
|
||||
int? remainingDurationMs,
|
||||
int? tickAccumulatorMs,
|
||||
SkillElement? element,
|
||||
}) {
|
||||
return DotEffect(
|
||||
skillId: skillId ?? this.skillId,
|
||||
baseDamage: baseDamage ?? this.baseDamage,
|
||||
damagePerTick: damagePerTick ?? this.damagePerTick,
|
||||
tickIntervalMs: tickIntervalMs ?? this.tickIntervalMs,
|
||||
totalDurationMs: totalDurationMs ?? this.totalDurationMs,
|
||||
remainingDurationMs: remainingDurationMs ?? this.remainingDurationMs,
|
||||
tickAccumulatorMs: tickAccumulatorMs ?? this.tickAccumulatorMs,
|
||||
element: element ?? this.element,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user