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

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