feat(game): 포션 시스템 및 UI 패널 추가

- 포션 시스템 구현 (PotionService, Potion 모델)
- 포션 인벤토리 패널 위젯
- 활성 버프 패널 위젯
- 장비 스탯 패널 위젯
- 스킬 시스템 확장
- 일본어 번역 추가
- 전투 이벤트/상태 모델 개선
This commit is contained in:
JiWoong Sul
2025-12-21 23:53:27 +09:00
parent eb71d2a199
commit 7cd8be88df
25 changed files with 5174 additions and 261 deletions

View File

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

View File

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

View 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,
}

View File

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

View File

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

View File

@@ -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',
);
}
}

View File

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

View File

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

View File

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

View File

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

View 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,
);
}
}

View File

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

View File

@@ -20,7 +20,10 @@ import 'package:askiineverdie/src/features/game/widgets/hp_mp_bar.dart';
import 'package:askiineverdie/src/features/game/widgets/notification_overlay.dart';
import 'package:askiineverdie/src/features/game/widgets/skill_panel.dart';
import 'package:askiineverdie/src/features/game/widgets/stats_panel.dart';
import 'package:askiineverdie/src/features/game/widgets/equipment_stats_panel.dart';
import 'package:askiineverdie/src/features/game/widgets/potion_inventory_panel.dart';
import 'package:askiineverdie/src/features/game/widgets/task_progress_panel.dart';
import 'package:askiineverdie/src/features/game/widgets/active_buff_panel.dart';
/// 게임 진행 화면 (Main.dfm 기반 3패널 레이아웃)
///
@@ -199,6 +202,18 @@ class _GamePlayScreenState extends State<GamePlayScreen>
'${event.skillName} activated!',
CombatLogType.buff,
),
CombatEventType.dotTick => (
'${event.skillName} ticks for ${event.damage} damage',
CombatLogType.dotTick,
),
CombatEventType.playerPotion => (
'${event.skillName}: +${event.healAmount} ${event.targetName}',
CombatLogType.potion,
),
CombatEventType.potionDrop => (
'Dropped: ${event.skillName}',
CombatLogType.potionDrop,
),
};
}
@@ -534,6 +549,15 @@ class _GamePlayScreenState extends State<GamePlayScreen>
// Phase 8: 스킬 (Skills with cooldown glow)
_buildSectionHeader('Skills'),
Expanded(flex: 2, child: SkillPanel(skillSystem: state.skillSystem)),
// 활성 버프 (Active Buffs)
_buildSectionHeader('Buffs'),
Expanded(
child: ActiveBuffPanel(
activeBuffs: state.skillSystem.activeBuffs,
currentMs: state.skillSystem.elapsedMs,
),
),
],
),
);
@@ -549,12 +573,25 @@ class _GamePlayScreenState extends State<GamePlayScreen>
children: [
_buildPanelHeader(l10n.equipment),
// Equipment 목록
Expanded(flex: 2, child: _buildEquipmentList(state)),
// Equipment 목록 (확장 가능 스탯 패널)
Expanded(
flex: 2,
child: EquipmentStatsPanel(equipment: state.equipment),
),
// Inventory
_buildPanelHeader(l10n.inventory),
Expanded(flex: 2, child: _buildInventoryList(state)),
Expanded(child: _buildInventoryList(state)),
// Potions (물약 인벤토리)
_buildSectionHeader('Potions'),
Expanded(
child: PotionInventoryPanel(
inventory: state.potionInventory,
usedInBattle:
state.progress.currentCombat?.usedPotionTypes ?? const {},
),
),
// Encumbrance 바
_buildSectionHeader(l10n.encumbrance),
@@ -729,58 +766,6 @@ class _GamePlayScreenState extends State<GamePlayScreen>
);
}
Widget _buildEquipmentList(GameState state) {
// 원본 Main.dfm Equips ListView - 11개 슬롯
// (슬롯 레이블, 장비 이름, 슬롯 인덱스) 튜플
final l10n = L10n.of(context);
final equipment = [
(l10n.equipWeapon, state.equipment.weapon, 0),
(l10n.equipShield, state.equipment.shield, 1),
(l10n.equipHelm, state.equipment.helm, 2),
(l10n.equipHauberk, state.equipment.hauberk, 3),
(l10n.equipBrassairts, state.equipment.brassairts, 4),
(l10n.equipVambraces, state.equipment.vambraces, 5),
(l10n.equipGauntlets, state.equipment.gauntlets, 6),
(l10n.equipGambeson, state.equipment.gambeson, 7),
(l10n.equipCuisses, state.equipment.cuisses, 8),
(l10n.equipGreaves, state.equipment.greaves, 9),
(l10n.equipSollerets, state.equipment.sollerets, 10),
];
return ListView.builder(
itemCount: equipment.length,
padding: const EdgeInsets.symmetric(horizontal: 8),
itemBuilder: (context, index) {
final equip = equipment[index];
// 장비 이름 번역 (슬롯 인덱스 사용)
final translatedName = equip.$2.isNotEmpty
? GameDataL10n.translateEquipString(context, equip.$2, equip.$3)
: '-';
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
SizedBox(
width: 60,
child: Text(equip.$1, style: const TextStyle(fontSize: 11)),
),
Expanded(
child: Text(
translatedName,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
);
},
);
}
Widget _buildInventoryList(GameState state) {
final l10n = L10n.of(context);
if (state.inventory.items.isEmpty) {

View File

@@ -0,0 +1,206 @@
import 'package:flutter/material.dart';
import 'package:askiineverdie/src/core/model/skill.dart';
/// 활성 버프 패널 위젯
///
/// 현재 적용 중인 버프 목록과 남은 시간을 표시.
class ActiveBuffPanel extends StatelessWidget {
const ActiveBuffPanel({
super.key,
required this.activeBuffs,
required this.currentMs,
});
final List<ActiveBuff> activeBuffs;
final int currentMs;
@override
Widget build(BuildContext context) {
if (activeBuffs.isEmpty) {
return const Center(
child: Text(
'No active buffs',
style: TextStyle(
fontSize: 11,
color: Colors.grey,
fontStyle: FontStyle.italic,
),
),
);
}
return ListView.builder(
itemCount: activeBuffs.length,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
itemBuilder: (context, index) {
final buff = activeBuffs[index];
return _BuffRow(buff: buff, currentMs: currentMs);
},
);
}
}
/// 개별 버프 행 위젯
class _BuffRow extends StatelessWidget {
const _BuffRow({
required this.buff,
required this.currentMs,
});
final ActiveBuff buff;
final int currentMs;
@override
Widget build(BuildContext context) {
final remainingMs = buff.remainingDuration(currentMs);
final remainingSec = (remainingMs / 1000).toStringAsFixed(1);
final progress = remainingMs / buff.effect.durationMs;
final modifiers = _buildModifierList();
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
// 버프 아이콘
const Icon(
Icons.trending_up,
size: 14,
color: Colors.lightBlue,
),
const SizedBox(width: 4),
// 버프 이름
Expanded(
child: Text(
buff.effect.name,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: Colors.lightBlue,
),
overflow: TextOverflow.ellipsis,
),
),
// 남은 시간
Text(
'${remainingSec}s',
style: TextStyle(
fontSize: 10,
color: remainingMs < 3000 ? Colors.orange : Colors.grey,
fontWeight:
remainingMs < 3000 ? FontWeight.bold : FontWeight.normal,
),
),
],
),
const SizedBox(height: 2),
// 남은 시간 프로그레스 바
ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
value: progress.clamp(0.0, 1.0),
minHeight: 3,
backgroundColor: Colors.grey.shade800,
valueColor: AlwaysStoppedAnimation(
progress > 0.3 ? Colors.lightBlue : Colors.orange,
),
),
),
// 효과 목록
if (modifiers.isNotEmpty) ...[
const SizedBox(height: 2),
Wrap(
spacing: 6,
runSpacing: 2,
children: modifiers,
),
],
],
),
);
}
/// 버프 효과 목록 생성
List<Widget> _buildModifierList() {
final modifiers = <Widget>[];
final effect = buff.effect;
if (effect.atkModifier != 0) {
modifiers.add(_ModifierChip(
label: 'ATK',
value: effect.atkModifier,
isPositive: effect.atkModifier > 0,
));
}
if (effect.defModifier != 0) {
modifiers.add(_ModifierChip(
label: 'DEF',
value: effect.defModifier,
isPositive: effect.defModifier > 0,
));
}
if (effect.criRateModifier != 0) {
modifiers.add(_ModifierChip(
label: 'CRI',
value: effect.criRateModifier,
isPositive: effect.criRateModifier > 0,
));
}
if (effect.evasionModifier != 0) {
modifiers.add(_ModifierChip(
label: 'EVA',
value: effect.evasionModifier,
isPositive: effect.evasionModifier > 0,
));
}
return modifiers;
}
}
/// 효과 칩 위젯
class _ModifierChip extends StatelessWidget {
const _ModifierChip({
required this.label,
required this.value,
required this.isPositive,
});
final String label;
final double value;
final bool isPositive;
@override
Widget build(BuildContext context) {
final color = isPositive ? Colors.green : Colors.red;
final sign = isPositive ? '+' : '';
final percent = (value * 100).round();
return Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(3),
),
child: Text(
'$label: $sign$percent%',
style: TextStyle(
fontSize: 8,
color: color,
fontWeight: FontWeight.w500,
),
),
);
}
}

View File

@@ -200,6 +200,15 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
// 회복/버프 → idle 페이즈 유지
CombatEventType.playerHeal => (BattlePhase.idle, false),
CombatEventType.playerBuff => (BattlePhase.idle, false),
// DOT 틱 → attack 페이즈 (지속 피해)
CombatEventType.dotTick => (BattlePhase.attack, false),
// 물약 사용 → idle 페이즈 유지
CombatEventType.playerPotion => (BattlePhase.idle, false),
// 물약 드랍 → idle 페이즈 유지
CombatEventType.potionDrop => (BattlePhase.idle, false),
};
setState(() {

View File

@@ -21,13 +21,16 @@ enum CombatLogType {
levelUp, // 레벨업
questComplete, // 퀘스트 완료
loot, // 전리품 획득
spell, // 주문 습득
spell, // 스킬 사용
critical, // 크리티컬 히트
evade, // 회피
block, // 방패 방어
parry, // 무기 쳐내기
monsterAttack, // 몬스터 공격
buff, // 버프 활성화
dotTick, // DOT 틱 데미지
potion, // 물약 사용
potionDrop, // 물약 드랍
}
/// 전투 로그 위젯 (Phase 8: 실시간 전투 이벤트 표시)
@@ -157,6 +160,9 @@ class _LogEntryTile extends StatelessWidget {
CombatLogType.parry => (Colors.teal.shade300, Icons.sports_kabaddi),
CombatLogType.monsterAttack => (Colors.deepOrange.shade300, Icons.dangerous),
CombatLogType.buff => (Colors.lightBlue.shade300, Icons.trending_up),
CombatLogType.dotTick => (Colors.deepPurple.shade300, Icons.whatshot),
CombatLogType.potion => (Colors.pink.shade300, Icons.local_drink),
CombatLogType.potionDrop => (Colors.lime.shade300, Icons.card_giftcard),
};
}
}

View File

@@ -426,6 +426,21 @@ class DeathOverlay extends StatelessWidget {
Colors.lightBlue.shade300,
'${event.skillName} activated',
),
CombatEventType.dotTick => (
Icons.whatshot,
Colors.deepOrange.shade300,
'${event.skillName} ticks for ${event.damage} damage',
),
CombatEventType.playerPotion => (
Icons.local_drink,
Colors.lightGreen.shade300,
'${event.skillName}: +${event.healAmount} ${event.targetName}',
),
CombatEventType.potionDrop => (
Icons.card_giftcard,
Colors.lime.shade300,
'Dropped: ${event.skillName}',
),
};
}
}

View File

@@ -0,0 +1,456 @@
import 'package:flutter/material.dart';
import 'package:askiineverdie/src/core/engine/item_service.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/game_state.dart';
import 'package:askiineverdie/src/core/model/item_stats.dart';
/// 장비 스탯 표시 패널
///
/// 각 장비 슬롯의 아이템과 스탯을 확장 가능한 형태로 표시.
/// 접힌 상태: 슬롯명 + 아이템명
/// 펼친 상태: 전체 스탯 및 점수
class EquipmentStatsPanel extends StatelessWidget {
const EquipmentStatsPanel({
super.key,
required this.equipment,
this.initiallyExpanded = false,
});
final Equipment equipment;
final bool initiallyExpanded;
@override
Widget build(BuildContext context) {
final totalScore = _calculateTotalScore();
final equippedCount = equipment.items.where((e) => e.isNotEmpty).length;
return ListView.builder(
// +1 for header
itemCount: equipment.items.length + 1,
padding: const EdgeInsets.all(4),
itemBuilder: (context, index) {
// 첫 번째 아이템은 총합 헤더
if (index == 0) {
return _TotalScoreHeader(
totalScore: totalScore,
equippedCount: equippedCount,
totalSlots: equipment.items.length,
);
}
final item = equipment.items[index - 1];
return _EquipmentSlotTile(
item: item,
initiallyExpanded: initiallyExpanded,
);
},
);
}
/// 모든 장비의 점수 합산
int _calculateTotalScore() {
var total = 0;
for (final item in equipment.items) {
if (item.isNotEmpty) {
total += ItemService.calculateEquipmentScore(item);
}
}
return total;
}
}
/// 개별 장비 슬롯 타일
class _EquipmentSlotTile extends StatelessWidget {
const _EquipmentSlotTile({
required this.item,
this.initiallyExpanded = false,
});
final EquipmentItem item;
final bool initiallyExpanded;
@override
Widget build(BuildContext context) {
if (item.isEmpty) {
return _EmptySlotTile(slot: item.slot);
}
final score = ItemService.calculateEquipmentScore(item);
final rarityColor = _getRarityColor(item.rarity);
return ExpansionTile(
initiallyExpanded: initiallyExpanded,
tilePadding: const EdgeInsets.symmetric(horizontal: 8),
childrenPadding: const EdgeInsets.only(left: 16, right: 8, bottom: 8),
dense: true,
title: Row(
children: [
_SlotIcon(slot: item.slot),
const SizedBox(width: 4),
Expanded(
child: Text(
item.name,
style: TextStyle(
fontSize: 11,
color: rarityColor,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
_ScoreBadge(score: score),
],
),
children: [
_StatsGrid(stats: item.stats, slot: item.slot),
const SizedBox(height: 4),
_ItemMetaRow(item: item),
],
);
}
Color _getRarityColor(ItemRarity rarity) {
return switch (rarity) {
ItemRarity.common => Colors.grey,
ItemRarity.uncommon => Colors.green,
ItemRarity.rare => Colors.blue,
ItemRarity.epic => Colors.purple,
ItemRarity.legendary => Colors.orange,
};
}
}
/// 빈 슬롯 타일
class _EmptySlotTile extends StatelessWidget {
const _EmptySlotTile({required this.slot});
final EquipmentSlot slot;
@override
Widget build(BuildContext context) {
return ListTile(
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
leading: _SlotIcon(slot: slot, isEmpty: true),
title: Text(
'[${_getSlotName(slot)}] (empty)',
style: TextStyle(
fontSize: 11,
color: Colors.grey.shade600,
fontStyle: FontStyle.italic,
),
),
);
}
}
/// 슬롯 아이콘
class _SlotIcon extends StatelessWidget {
const _SlotIcon({required this.slot, this.isEmpty = false});
final EquipmentSlot slot;
final bool isEmpty;
@override
Widget build(BuildContext context) {
final icon = switch (slot) {
EquipmentSlot.weapon => Icons.gavel,
EquipmentSlot.shield => Icons.shield,
EquipmentSlot.helm => Icons.sports_martial_arts,
EquipmentSlot.hauberk => Icons.checkroom,
EquipmentSlot.brassairts => Icons.back_hand,
EquipmentSlot.vambraces => Icons.front_hand,
EquipmentSlot.gauntlets => Icons.pan_tool,
EquipmentSlot.gambeson => Icons.dry_cleaning,
EquipmentSlot.cuisses => Icons.airline_seat_legroom_normal,
EquipmentSlot.greaves => Icons.snowshoeing,
EquipmentSlot.sollerets => Icons.do_not_step,
};
return Icon(
icon,
size: 16,
color: isEmpty ? Colors.grey.shade400 : Colors.grey.shade700,
);
}
}
/// 점수 배지
class _ScoreBadge extends StatelessWidget {
const _ScoreBadge({required this.score});
final int score;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.blueGrey.shade100,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'$score',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Colors.blueGrey.shade700,
),
),
);
}
}
/// 장비 점수 총합 헤더
class _TotalScoreHeader extends StatelessWidget {
const _TotalScoreHeader({
required this.totalScore,
required this.equippedCount,
required this.totalSlots,
});
final int totalScore;
final int equippedCount;
final int totalSlots;
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.blueGrey.shade700,
Colors.blueGrey.shade600,
],
),
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
// 장비 아이콘
const Icon(
Icons.shield,
size: 20,
color: Colors.white70,
),
const SizedBox(width: 8),
// 총합 점수
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Equipment Score',
style: TextStyle(
fontSize: 10,
color: Colors.white70,
),
),
Text(
'$totalScore',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
],
),
),
// 장착 현황
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'$equippedCount / $totalSlots',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: Colors.white,
),
),
),
],
),
);
}
}
/// 스탯 그리드
class _StatsGrid extends StatelessWidget {
const _StatsGrid({required this.stats, required this.slot});
final ItemStats stats;
final EquipmentSlot slot;
@override
Widget build(BuildContext context) {
final entries = <_StatEntry>[];
// 공격 스탯
if (stats.atk > 0) entries.add(_StatEntry('ATK', '+${stats.atk}'));
if (stats.magAtk > 0) entries.add(_StatEntry('MATK', '+${stats.magAtk}'));
if (stats.criRate > 0) {
entries.add(_StatEntry('CRI', '${(stats.criRate * 100).toStringAsFixed(1)}%'));
}
if (stats.parryRate > 0) {
entries.add(_StatEntry('PARRY', '${(stats.parryRate * 100).toStringAsFixed(1)}%'));
}
// 방어 스탯
if (stats.def > 0) entries.add(_StatEntry('DEF', '+${stats.def}'));
if (stats.magDef > 0) entries.add(_StatEntry('MDEF', '+${stats.magDef}'));
if (stats.blockRate > 0) {
entries.add(_StatEntry('BLOCK', '${(stats.blockRate * 100).toStringAsFixed(1)}%'));
}
if (stats.evasion > 0) {
entries.add(_StatEntry('EVA', '${(stats.evasion * 100).toStringAsFixed(1)}%'));
}
// 자원 스탯
if (stats.hpBonus > 0) entries.add(_StatEntry('HP', '+${stats.hpBonus}'));
if (stats.mpBonus > 0) entries.add(_StatEntry('MP', '+${stats.mpBonus}'));
// 능력치 보너스
if (stats.strBonus > 0) entries.add(_StatEntry('STR', '+${stats.strBonus}'));
if (stats.conBonus > 0) entries.add(_StatEntry('CON', '+${stats.conBonus}'));
if (stats.dexBonus > 0) entries.add(_StatEntry('DEX', '+${stats.dexBonus}'));
if (stats.intBonus > 0) entries.add(_StatEntry('INT', '+${stats.intBonus}'));
if (stats.wisBonus > 0) entries.add(_StatEntry('WIS', '+${stats.wisBonus}'));
if (stats.chaBonus > 0) entries.add(_StatEntry('CHA', '+${stats.chaBonus}'));
// 무기 공속
if (slot == EquipmentSlot.weapon && stats.attackSpeed > 0) {
entries.add(_StatEntry('SPEED', '${stats.attackSpeed}ms'));
}
if (entries.isEmpty) {
return const Text(
'No bonus stats',
style: TextStyle(fontSize: 10, color: Colors.grey),
);
}
return Wrap(
spacing: 8,
runSpacing: 4,
children: entries.map((e) => _StatChip(entry: e)).toList(),
);
}
}
/// 스탯 엔트리
class _StatEntry {
const _StatEntry(this.label, this.value);
final String label;
final String value;
}
/// 스탯 칩
class _StatChip extends StatelessWidget {
const _StatChip({required this.entry});
final _StatEntry entry;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${entry.label}: ',
style: TextStyle(fontSize: 9, color: Colors.grey.shade600),
),
Text(
entry.value,
style: const TextStyle(fontSize: 9, fontWeight: FontWeight.bold),
),
],
),
);
}
}
/// 아이템 메타 정보 행
class _ItemMetaRow extends StatelessWidget {
const _ItemMetaRow({required this.item});
final EquipmentItem item;
@override
Widget build(BuildContext context) {
final rarityName = item.rarity.name.toUpperCase();
return Row(
children: [
Text(
'Lv.${item.level}',
style: const TextStyle(fontSize: 9, color: Colors.grey),
),
const SizedBox(width: 8),
Text(
rarityName,
style: TextStyle(
fontSize: 9,
color: _getRarityColor(item.rarity),
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 8),
Text(
'Wt.${item.weight}',
style: const TextStyle(fontSize: 9, color: Colors.grey),
),
],
);
}
Color _getRarityColor(ItemRarity rarity) {
return switch (rarity) {
ItemRarity.common => Colors.grey,
ItemRarity.uncommon => Colors.green,
ItemRarity.rare => Colors.blue,
ItemRarity.epic => Colors.purple,
ItemRarity.legendary => Colors.orange,
};
}
}
/// 슬롯 이름 반환
String _getSlotName(EquipmentSlot slot) {
return switch (slot) {
EquipmentSlot.weapon => 'Weapon',
EquipmentSlot.shield => 'Shield',
EquipmentSlot.helm => 'Helm',
EquipmentSlot.hauberk => 'Hauberk',
EquipmentSlot.brassairts => 'Brassairts',
EquipmentSlot.vambraces => 'Vambraces',
EquipmentSlot.gauntlets => 'Gauntlets',
EquipmentSlot.gambeson => 'Gambeson',
EquipmentSlot.cuisses => 'Cuisses',
EquipmentSlot.greaves => 'Greaves',
EquipmentSlot.sollerets => 'Sollerets',
};
}

View File

@@ -0,0 +1,238 @@
import 'package:flutter/material.dart';
import 'package:askiineverdie/data/potion_data.dart';
import 'package:askiineverdie/src/core/model/potion.dart';
/// 물약 인벤토리 패널
///
/// 보유 중인 물약 목록과 수량을 표시.
/// HP 물약은 빨간색, MP 물약은 파란색으로 구분.
class PotionInventoryPanel extends StatelessWidget {
const PotionInventoryPanel({
super.key,
required this.inventory,
this.usedInBattle = const {},
});
final PotionInventory inventory;
final Set<PotionType> usedInBattle;
@override
Widget build(BuildContext context) {
final potionEntries = _buildPotionEntries();
if (potionEntries.isEmpty) {
return const Center(
child: Text(
'No potions',
style: TextStyle(
fontSize: 11,
color: Colors.grey,
fontStyle: FontStyle.italic,
),
),
);
}
return ListView.builder(
itemCount: potionEntries.length,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
itemBuilder: (context, index) {
final entry = potionEntries[index];
return _PotionRow(
potion: entry.potion,
quantity: entry.quantity,
isUsedThisBattle: usedInBattle.contains(entry.potion.type),
);
},
);
}
/// 물약 엔트리 목록 생성
///
/// HP 물약 먼저, MP 물약 나중에 정렬
List<_PotionEntry> _buildPotionEntries() {
final entries = <_PotionEntry>[];
for (final potionId in inventory.potions.keys) {
final quantity = inventory.potions[potionId] ?? 0;
if (quantity <= 0) continue;
final potion = PotionData.getById(potionId);
if (potion == null) continue;
entries.add(_PotionEntry(potion: potion, quantity: quantity));
}
// HP 물약 우선, 같은 타입 내에서는 티어순
entries.sort((a, b) {
final typeCompare = a.potion.type.index.compareTo(b.potion.type.index);
if (typeCompare != 0) return typeCompare;
return a.potion.tier.compareTo(b.potion.tier);
});
return entries;
}
}
/// 물약 엔트리
class _PotionEntry {
const _PotionEntry({required this.potion, required this.quantity});
final Potion potion;
final int quantity;
}
/// 물약 행 위젯
class _PotionRow extends StatelessWidget {
const _PotionRow({
required this.potion,
required this.quantity,
this.isUsedThisBattle = false,
});
final Potion potion;
final int quantity;
final bool isUsedThisBattle;
@override
Widget build(BuildContext context) {
final color = _getPotionColor();
final opacity = isUsedThisBattle ? 0.5 : 1.0;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Opacity(
opacity: opacity,
child: Row(
children: [
// 물약 아이콘
_PotionIcon(type: potion.type, tier: potion.tier),
const SizedBox(width: 4),
// 물약 이름
Expanded(
child: Text(
potion.name,
style: TextStyle(
fontSize: 11,
color: color,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
// 회복량 표시
_HealBadge(potion: potion),
const SizedBox(width: 4),
// 수량
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'x$quantity',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: color,
),
),
),
// 전투 중 사용 불가 표시
if (isUsedThisBattle) ...[
const SizedBox(width: 4),
const Icon(
Icons.block,
size: 12,
color: Colors.grey,
),
],
],
),
),
);
}
Color _getPotionColor() {
return switch (potion.type) {
PotionType.hp => Colors.red.shade700,
PotionType.mp => Colors.blue.shade700,
};
}
}
/// 물약 아이콘
class _PotionIcon extends StatelessWidget {
const _PotionIcon({required this.type, required this.tier});
final PotionType type;
final int tier;
@override
Widget build(BuildContext context) {
final color = type == PotionType.hp
? Colors.red.shade400
: Colors.blue.shade400;
// 티어에 따른 아이콘 크기 조절
final size = 12.0 + tier * 1.0;
return Container(
width: 18,
height: 18,
alignment: Alignment.center,
child: Icon(
type == PotionType.hp ? Icons.favorite : Icons.bolt,
size: size.clamp(12, 18),
color: color,
),
);
}
}
/// 회복량 배지
class _HealBadge extends StatelessWidget {
const _HealBadge({required this.potion});
final Potion potion;
@override
Widget build(BuildContext context) {
final healText = _buildHealText();
return Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(4),
),
child: Text(
healText,
style: TextStyle(
fontSize: 9,
color: Colors.grey.shade700,
),
),
);
}
String _buildHealText() {
final parts = <String>[];
if (potion.healAmount > 0) {
parts.add('+${potion.healAmount}');
}
if (potion.healPercent > 0) {
final percent = (potion.healPercent * 100).round();
parts.add('+$percent%');
}
return parts.join(' ');
}
}

View File

@@ -148,25 +148,58 @@ class _SkillRow extends StatelessWidget {
final skillIcon = _getSkillIcon(skill.type);
final skillColor = _getSkillColor(skill.type);
final elementColor = _getElementColor(skill.element);
final elementIcon = _getElementIcon(skill.element);
Widget row = Container(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
// 스킬 아이콘
// 스킬 타입 아이콘
Icon(skillIcon, size: 14, color: skillColor),
// 속성 아이콘 (있는 경우)
if (skill.element != null) ...[
const SizedBox(width: 2),
_ElementBadge(
icon: elementIcon,
color: elementColor,
isDot: skill.isDot,
),
],
const SizedBox(width: 4),
// 스킬 이름
// 스킬 이름 (속성 색상 적용)
Expanded(
child: Text(
skill.name,
style: TextStyle(
fontSize: 10,
color: isReady ? Colors.white : Colors.grey,
color: isReady
? (skill.element != null ? elementColor : Colors.white)
: Colors.grey,
),
overflow: TextOverflow.ellipsis,
),
),
// DOT 표시
if (skill.isDot) ...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 1),
decoration: BoxDecoration(
color: elementColor.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(3),
),
child: const Text(
'DOT',
style: TextStyle(fontSize: 7, color: Colors.white70),
),
),
const SizedBox(width: 4),
],
// 랭크
Text(
'Lv.$rank',
@@ -241,4 +274,69 @@ class _SkillRow extends StatelessWidget {
return Colors.purple;
}
}
/// 속성별 색상
Color _getElementColor(SkillElement? element) {
if (element == null) return Colors.grey;
return switch (element) {
SkillElement.logic => Colors.cyan,
SkillElement.memory => Colors.purple.shade300,
SkillElement.network => Colors.teal,
SkillElement.fire => Colors.orange,
SkillElement.ice => Colors.lightBlue.shade200,
SkillElement.lightning => Colors.yellow.shade600,
SkillElement.voidElement => Colors.deepPurple,
SkillElement.chaos => Colors.pink,
};
}
/// 속성별 아이콘
IconData _getElementIcon(SkillElement? element) {
if (element == null) return Icons.circle;
return switch (element) {
SkillElement.logic => Icons.code,
SkillElement.memory => Icons.memory,
SkillElement.network => Icons.lan,
SkillElement.fire => Icons.local_fire_department,
SkillElement.ice => Icons.ac_unit,
SkillElement.lightning => Icons.bolt,
SkillElement.voidElement => Icons.remove_circle_outline,
SkillElement.chaos => Icons.shuffle,
};
}
}
/// 속성 배지 위젯
class _ElementBadge extends StatelessWidget {
const _ElementBadge({
required this.icon,
required this.color,
this.isDot = false,
});
final IconData icon;
final Color color;
final bool isDot;
@override
Widget build(BuildContext context) {
return Container(
width: 14,
height: 14,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(3),
border: isDot
? Border.all(color: color.withValues(alpha: 0.7), width: 1)
: null,
),
child: Icon(
icon,
size: 10,
color: color,
),
);
}
}