Compare commits

...

2 Commits

Author SHA1 Message Date
JiWoong Sul
306715ca26 feat(balance): 레벨 기반 장비 손실 확률 시스템
- 저레벨 사망 스파이럴 방지
- 장비 손실 확률 = (레벨 - 5) * 10%
  - Lv 1~5: 0% (절대 안전)
  - Lv 6: 10%
  - Lv 10: 50%
  - Lv 15+: 100%
- 디버그 로그 추가
2026-01-16 00:17:08 +09:00
JiWoong Sul
9e5472728f refactor(potion): 물약 자동 사용 조건 변경
- 임계치 기반 → 소모량 기반 조건 전환
- HP/MP 소모량 >= 물약 회복량일 때 사용
- emergencyHpThreshold, emergencyMpThreshold 상수 제거
- 우선순위 HP > MP 유지
2026-01-16 00:15:38 +09:00
3 changed files with 116 additions and 108 deletions

View File

@@ -265,79 +265,73 @@ class CombatTickService {
return null; return null;
} }
// 우선순위 1: HP 물약 (HP <= 30%) // 우선순위 1: HP 물약 (소모된 HP >= 물약 회복량)
final hpRatio = playerStats.hpCurrent / playerStats.hpMax; final hpPotion = potionService.selectEmergencyHpPotion(
if (hpRatio <= PotionService.emergencyHpThreshold) { currentHp: playerStats.hpCurrent,
final hpPotion = potionService.selectEmergencyHpPotion( maxHp: playerStats.hpMax,
inventory: potionInventory,
playerLevel: playerLevel,
);
if (hpPotion != null) {
final result = potionService.usePotion(
potionId: hpPotion.id,
inventory: potionInventory,
currentHp: playerStats.hpCurrent, currentHp: playerStats.hpCurrent,
maxHp: playerStats.hpMax, maxHp: playerStats.hpMax,
inventory: potionInventory, currentMp: playerStats.mpCurrent,
playerLevel: playerLevel, maxMp: playerStats.mpMax,
); );
if (hpPotion != null) { if (result.success) {
final result = potionService.usePotion( return (
potionId: hpPotion.id, playerStats: playerStats.copyWith(hpCurrent: result.newHp),
inventory: potionInventory, lastPotionUsedMs: timestamp,
currentHp: playerStats.hpCurrent, potionInventory: result.newInventory!,
maxHp: playerStats.hpMax, events: [
currentMp: playerStats.mpCurrent, CombatEvent.playerPotion(
maxMp: playerStats.mpMax, timestamp: timestamp,
potionName: hpPotion.name,
healAmount: result.healedAmount,
isHp: true,
),
],
); );
if (result.success) {
return (
playerStats: playerStats.copyWith(hpCurrent: result.newHp),
lastPotionUsedMs: timestamp,
potionInventory: result.newInventory!,
events: [
CombatEvent.playerPotion(
timestamp: timestamp,
potionName: hpPotion.name,
healAmount: result.healedAmount,
isHp: true,
),
],
);
}
} }
} }
// 우선순위 2: MP 물약 (MP <= 50%) // 우선순위 2: MP 물약 (소모된 MP >= 물약 회복량)
final mpRatio = playerStats.mpCurrent / playerStats.mpMax; final mpPotion = potionService.selectEmergencyMpPotion(
if (mpRatio <= PotionService.emergencyMpThreshold) { currentMp: playerStats.mpCurrent,
final mpPotion = potionService.selectEmergencyMpPotion( maxMp: playerStats.mpMax,
inventory: potionInventory,
playerLevel: playerLevel,
);
if (mpPotion != null) {
final result = potionService.usePotion(
potionId: mpPotion.id,
inventory: potionInventory,
currentHp: playerStats.hpCurrent,
maxHp: playerStats.hpMax,
currentMp: playerStats.mpCurrent, currentMp: playerStats.mpCurrent,
maxMp: playerStats.mpMax, maxMp: playerStats.mpMax,
inventory: potionInventory,
playerLevel: playerLevel,
); );
if (mpPotion != null) { if (result.success) {
final result = potionService.usePotion( return (
potionId: mpPotion.id, playerStats: playerStats.copyWith(mpCurrent: result.newMp),
inventory: potionInventory, lastPotionUsedMs: timestamp,
currentHp: playerStats.hpCurrent, potionInventory: result.newInventory!,
maxHp: playerStats.hpMax, events: [
currentMp: playerStats.mpCurrent, CombatEvent.playerPotion(
maxMp: playerStats.mpMax, timestamp: timestamp,
potionName: mpPotion.name,
healAmount: result.healedAmount,
isHp: false,
),
],
); );
if (result.success) {
return (
playerStats: playerStats.copyWith(mpCurrent: result.newMp),
lastPotionUsedMs: timestamp,
potionInventory: result.newInventory!,
events: [
CombatEvent.playerPotion(
timestamp: timestamp,
potionName: mpPotion.name,
healAmount: result.healedAmount,
isHp: false,
),
],
);
}
} }
} }

View File

@@ -13,12 +13,6 @@ class PotionService {
/// 글로벌 물약 쿨타임 (1배속 기준 3초) /// 글로벌 물약 쿨타임 (1배속 기준 3초)
static const int globalPotionCooldownMs = 3000; static const int globalPotionCooldownMs = 3000;
/// 긴급 물약 사용 HP 임계치 (30%)
static const double emergencyHpThreshold = 0.30;
/// 긴급 물약 사용 MP 임계치 (50%)
static const double emergencyMpThreshold = 0.50;
// ============================================================================ // ============================================================================
// 물약 사용 가능 여부 // 물약 사용 가능 여부
// ============================================================================ // ============================================================================
@@ -104,7 +98,7 @@ class PotionService {
/// 긴급 HP 물약 선택 /// 긴급 HP 물약 선택
/// ///
/// HP가 임계치 이하일 때 사용할 최적의 물약 선택 /// 소모된 HP >= 물약 회복량이면 사용할 최적의 물약 선택
/// [currentHp] 현재 HP /// [currentHp] 현재 HP
/// [maxHp] 최대 HP /// [maxHp] 최대 HP
/// [inventory] 물약 인벤토리 /// [inventory] 물약 인벤토리
@@ -115,9 +109,8 @@ class PotionService {
required PotionInventory inventory, required PotionInventory inventory,
required int playerLevel, required int playerLevel,
}) { }) {
// 임계치 체크 final hpLost = maxHp - currentHp;
final hpRatio = currentHp / maxHp; if (hpLost <= 0) return null;
if (hpRatio > emergencyHpThreshold) return null;
// 적정 티어 계산 // 적정 티어 계산
final targetTier = PotionData.tierForLevel(playerLevel); final targetTier = PotionData.tierForLevel(playerLevel);
@@ -126,7 +119,10 @@ class PotionService {
for (var tier = targetTier; tier >= 1; tier--) { for (var tier = targetTier; tier >= 1; tier--) {
final potion = PotionData.getHpPotionByTier(tier); final potion = PotionData.getHpPotionByTier(tier);
if (potion != null && inventory.hasPotion(potion.id)) { if (potion != null && inventory.hasPotion(potion.id)) {
return potion; final healAmount = potion.calculateHeal(maxHp);
if (hpLost >= healAmount) {
return potion;
}
} }
} }
@@ -134,7 +130,10 @@ class PotionService {
for (var tier = targetTier + 1; tier <= 5; tier++) { for (var tier = targetTier + 1; tier <= 5; tier++) {
final potion = PotionData.getHpPotionByTier(tier); final potion = PotionData.getHpPotionByTier(tier);
if (potion != null && inventory.hasPotion(potion.id)) { if (potion != null && inventory.hasPotion(potion.id)) {
return potion; final healAmount = potion.calculateHeal(maxHp);
if (hpLost >= healAmount) {
return potion;
}
} }
} }
@@ -143,16 +142,15 @@ class PotionService {
/// 긴급 MP 물약 선택 /// 긴급 MP 물약 선택
/// ///
/// MP가 임계치 이하일 때 사용할 최적의 물약 선택 /// 소모된 MP >= 물약 회복량이면 사용할 최적의 물약 선택
Potion? selectEmergencyMpPotion({ Potion? selectEmergencyMpPotion({
required int currentMp, required int currentMp,
required int maxMp, required int maxMp,
required PotionInventory inventory, required PotionInventory inventory,
required int playerLevel, required int playerLevel,
}) { }) {
// 임계치 체크 final mpLost = maxMp - currentMp;
final mpRatio = currentMp / maxMp; if (mpLost <= 0) return null;
if (mpRatio > emergencyMpThreshold) return null;
// 적정 티어 계산 // 적정 티어 계산
final targetTier = PotionData.tierForLevel(playerLevel); final targetTier = PotionData.tierForLevel(playerLevel);
@@ -161,7 +159,10 @@ class PotionService {
for (var tier = targetTier; tier >= 1; tier--) { for (var tier = targetTier; tier >= 1; tier--) {
final potion = PotionData.getMpPotionByTier(tier); final potion = PotionData.getMpPotionByTier(tier);
if (potion != null && inventory.hasPotion(potion.id)) { if (potion != null && inventory.hasPotion(potion.id)) {
return potion; final healAmount = potion.calculateHeal(maxMp);
if (mpLost >= healAmount) {
return potion;
}
} }
} }
@@ -169,7 +170,10 @@ class PotionService {
for (var tier = targetTier + 1; tier <= 5; tier++) { for (var tier = targetTier + 1; tier <= 5; tier++) {
final potion = PotionData.getMpPotionByTier(tier); final potion = PotionData.getMpPotionByTier(tier);
if (potion != null && inventory.hasPotion(potion.id)) { if (potion != null && inventory.hasPotion(potion.id)) {
return potion; final healAmount = potion.calculateHeal(maxMp);
if (mpLost >= healAmount) {
return potion;
}
} }
} }

View File

@@ -974,40 +974,50 @@ class ProgressService {
ItemRarity? lostItemRarity; ItemRarity? lostItemRarity;
if (!isBossDeath) { if (!isBossDeath) {
// 무기(슬롯 0)를 제외한 장착된 장비 중 1개를 제물로 삭제 // 레벨 기반 장비 손실 확률 계산
final equippedNonWeaponSlots = <int>[]; // Lv 1~5: 0%, Lv 6: 10%, Lv 10: 50%, Lv 15+: 100%
for (var i = 1; i < Equipment.slotCount; i++) { final level = state.traits.level;
final item = state.equipment.getItemByIndex(i); final lossChancePercent = ((level - 5) * 10).clamp(0, 100);
// 디버그: 장비 슬롯 상태 확인 final roll = state.rng.nextInt(100); // 0~99
// ignore: avoid_print final shouldLoseEquipment = roll < lossChancePercent;
print('[Death] Slot $i: "${item.name}" isEmpty=${item.isEmpty}');
if (item.isNotEmpty) {
equippedNonWeaponSlots.add(i);
}
}
// 디버그: 장착된 슬롯 목록
// ignore: avoid_print // ignore: avoid_print
print('[Death] equippedNonWeaponSlots: $equippedNonWeaponSlots'); print('[Death] Lv$level lossChance=$lossChancePercent% roll=$roll '
'shouldLose=$shouldLoseEquipment');
if (equippedNonWeaponSlots.isNotEmpty) { if (shouldLoseEquipment) {
lostCount = 1; // 무기(슬롯 0)를 제외한 장착된 장비 중 1개를 제물로 삭제
// 랜덤하게 1개 슬롯 선택 final equippedNonWeaponSlots = <int>[];
final sacrificeIndex = for (var i = 1; i < Equipment.slotCount; i++) {
equippedNonWeaponSlots[state.rng.nextInt( final item = state.equipment.getItemByIndex(i);
equippedNonWeaponSlots.length, if (item.isNotEmpty) {
)]; equippedNonWeaponSlots.add(i);
}
}
// 제물로 바칠 아이템 정보 저장 if (equippedNonWeaponSlots.isNotEmpty) {
final lostItem = state.equipment.getItemByIndex(sacrificeIndex); lostCount = 1;
lostItemName = lostItem.name; // 랜덤하게 1개 슬롯 선택
lostItemSlot = EquipmentSlot.values[sacrificeIndex]; final sacrificeIndex =
lostItemRarity = lostItem.rarity; equippedNonWeaponSlots[state.rng.nextInt(
equippedNonWeaponSlots.length,
)];
// 해당 슬롯을 빈 장비로 교체 // 제물로 바칠 아이템 정보 저장
newEquipment = newEquipment.setItemByIndex( final lostItem = state.equipment.getItemByIndex(sacrificeIndex);
sacrificeIndex, lostItemName = lostItem.name;
EquipmentItem.empty(lostItemSlot), lostItemSlot = EquipmentSlot.values[sacrificeIndex];
); lostItemRarity = lostItem.rarity;
// 해당 슬롯을 빈 장비로 교체
newEquipment = newEquipment.setItemByIndex(
sacrificeIndex,
EquipmentItem.empty(lostItemSlot),
);
// ignore: avoid_print
print('[Death] Lost item: $lostItemName (slot: $lostItemSlot)');
}
} }
} }