feat(combat): Phase 1 핵심 전투 시스템 구현
신규 파일: - combat_stats.dart: 플레이어 전투 파생 스탯 (ATK, DEF, CRI 등) - monster_combat_stats.dart: 몬스터 전투 스탯 (레벨 기반 스케일링) - combat_result.dart: 전투 결과 타입 (AttackResult, CombatTurnResult) - combat_state.dart: 전투 상태 관리 (HP, 누적 시간, 턴 수) - combat_calculator.dart: 전투 계산 서비스 (데미지, 명중, 크리티컬) 수정 파일: - game_state.dart: ProgressState에 currentCombat 필드 추가 - progress_service.dart: 킬 태스크 시 전투 로직 통합 - CombatStats/MonsterCombatStats 기반 전투 시간 계산 - 틱마다 전투 턴 처리 (_processCombatTick) - 전투 완료 시 플레이어 HP 반영
This commit is contained in:
143
lib/src/core/model/combat_result.dart
Normal file
143
lib/src/core/model/combat_result.dart
Normal file
@@ -0,0 +1,143 @@
|
||||
import 'package:askiineverdie/src/core/model/combat_stats.dart';
|
||||
import 'package:askiineverdie/src/core/model/monster_combat_stats.dart';
|
||||
|
||||
/// 단일 공격 결과
|
||||
class AttackResult {
|
||||
const AttackResult({
|
||||
required this.damage,
|
||||
required this.isCritical,
|
||||
required this.isHit,
|
||||
required this.isBlocked,
|
||||
required this.isParried,
|
||||
required this.isEvaded,
|
||||
});
|
||||
|
||||
/// 최종 데미지 (0이면 미스/회피)
|
||||
final int damage;
|
||||
|
||||
/// 크리티컬 히트 여부
|
||||
final bool isCritical;
|
||||
|
||||
/// 명중 여부
|
||||
final bool isHit;
|
||||
|
||||
/// 방패로 막음 여부
|
||||
final bool isBlocked;
|
||||
|
||||
/// 무기로 쳐냄 여부
|
||||
final bool isParried;
|
||||
|
||||
/// 회피 여부
|
||||
final bool isEvaded;
|
||||
|
||||
/// 공격 성공 여부 (데미지가 들어갔는지)
|
||||
bool get isSuccess => isHit && damage > 0;
|
||||
|
||||
/// 미스 (명중 실패 또는 회피)
|
||||
factory AttackResult.miss() => const AttackResult(
|
||||
damage: 0,
|
||||
isCritical: false,
|
||||
isHit: false,
|
||||
isBlocked: false,
|
||||
isParried: false,
|
||||
isEvaded: true,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
if (!isHit) return 'Miss (evaded)';
|
||||
if (isBlocked) return 'Blocked ($damage dmg)';
|
||||
if (isParried) return 'Parried ($damage dmg)';
|
||||
if (isCritical) return 'Critical! ($damage dmg)';
|
||||
return 'Hit ($damage dmg)';
|
||||
}
|
||||
}
|
||||
|
||||
/// 전투 턴 결과
|
||||
class CombatTurnResult {
|
||||
const CombatTurnResult({
|
||||
required this.playerAttack,
|
||||
required this.monsterAttack,
|
||||
required this.updatedPlayerStats,
|
||||
required this.updatedMonsterStats,
|
||||
required this.isCombatOver,
|
||||
required this.isPlayerVictory,
|
||||
});
|
||||
|
||||
/// 플레이어의 공격 결과 (null이면 공격 안 함)
|
||||
final AttackResult? playerAttack;
|
||||
|
||||
/// 몬스터의 공격 결과 (null이면 공격 안 함)
|
||||
final AttackResult? monsterAttack;
|
||||
|
||||
/// 업데이트된 플레이어 스탯
|
||||
final CombatStats updatedPlayerStats;
|
||||
|
||||
/// 업데이트된 몬스터 스탯
|
||||
final MonsterCombatStats updatedMonsterStats;
|
||||
|
||||
/// 전투 종료 여부
|
||||
final bool isCombatOver;
|
||||
|
||||
/// 플레이어 승리 여부 (전투 종료 시에만 의미 있음)
|
||||
final bool isPlayerVictory;
|
||||
|
||||
/// 플레이어 패배 여부
|
||||
bool get isPlayerDefeat => isCombatOver && !isPlayerVictory;
|
||||
}
|
||||
|
||||
/// 전투 전체 결과 (전투 종료 시)
|
||||
class CombatEndResult {
|
||||
const CombatEndResult({
|
||||
required this.isVictory,
|
||||
required this.expGained,
|
||||
required this.goldGained,
|
||||
required this.turnsElapsed,
|
||||
required this.totalDamageDealt,
|
||||
required this.totalDamageTaken,
|
||||
required this.finalPlayerStats,
|
||||
required this.finalMonsterStats,
|
||||
});
|
||||
|
||||
/// 승리 여부
|
||||
final bool isVictory;
|
||||
|
||||
/// 획득 경험치
|
||||
final int expGained;
|
||||
|
||||
/// 획득 골드 (향후 확장)
|
||||
final int goldGained;
|
||||
|
||||
/// 전투에 걸린 턴 수
|
||||
final int turnsElapsed;
|
||||
|
||||
/// 플레이어가 입힌 총 데미지
|
||||
final int totalDamageDealt;
|
||||
|
||||
/// 플레이어가 받은 총 데미지
|
||||
final int totalDamageTaken;
|
||||
|
||||
/// 최종 플레이어 스탯
|
||||
final CombatStats finalPlayerStats;
|
||||
|
||||
/// 최종 몬스터 스탯
|
||||
final MonsterCombatStats finalMonsterStats;
|
||||
|
||||
/// 패배 여부
|
||||
bool get isDefeat => !isVictory;
|
||||
}
|
||||
|
||||
/// 전투 상태
|
||||
enum CombatPhase {
|
||||
/// 전투 시작 전
|
||||
notStarted,
|
||||
|
||||
/// 전투 진행 중
|
||||
inProgress,
|
||||
|
||||
/// 플레이어 승리로 종료
|
||||
playerVictory,
|
||||
|
||||
/// 플레이어 패배로 종료
|
||||
playerDefeat,
|
||||
}
|
||||
121
lib/src/core/model/combat_state.dart
Normal file
121
lib/src/core/model/combat_state.dart
Normal file
@@ -0,0 +1,121 @@
|
||||
import 'package:askiineverdie/src/core/model/combat_stats.dart';
|
||||
import 'package:askiineverdie/src/core/model/monster_combat_stats.dart';
|
||||
|
||||
/// 현재 전투 상태
|
||||
///
|
||||
/// 킬 태스크 진행 중 전투 정보를 담는 클래스.
|
||||
/// Idle 게임 특성상 태스크 바와 동기화되어 진행됨.
|
||||
class CombatState {
|
||||
const CombatState({
|
||||
required this.playerStats,
|
||||
required this.monsterStats,
|
||||
required this.playerAttackAccumulatorMs,
|
||||
required this.monsterAttackAccumulatorMs,
|
||||
required this.totalDamageDealt,
|
||||
required this.totalDamageTaken,
|
||||
required this.turnsElapsed,
|
||||
required this.isActive,
|
||||
});
|
||||
|
||||
/// 플레이어 전투 스탯
|
||||
final CombatStats playerStats;
|
||||
|
||||
/// 몬스터 전투 스탯
|
||||
final MonsterCombatStats monsterStats;
|
||||
|
||||
/// 플레이어 공격 누적 시간 (밀리초)
|
||||
final int playerAttackAccumulatorMs;
|
||||
|
||||
/// 몬스터 공격 누적 시간 (밀리초)
|
||||
final int monsterAttackAccumulatorMs;
|
||||
|
||||
/// 플레이어가 입힌 총 데미지
|
||||
final int totalDamageDealt;
|
||||
|
||||
/// 플레이어가 받은 총 데미지
|
||||
final int totalDamageTaken;
|
||||
|
||||
/// 진행된 턴 수
|
||||
final int turnsElapsed;
|
||||
|
||||
/// 전투 활성화 여부
|
||||
final bool isActive;
|
||||
|
||||
// ============================================================================
|
||||
// 유틸리티
|
||||
// ============================================================================
|
||||
|
||||
/// 전투 종료 여부 (어느 한 쪽이 사망)
|
||||
bool get isCombatOver => playerStats.isDead || monsterStats.isDead;
|
||||
|
||||
/// 플레이어 승리 여부
|
||||
bool get isPlayerVictory => monsterStats.isDead && playerStats.isAlive;
|
||||
|
||||
/// 플레이어 패배 여부
|
||||
bool get isPlayerDefeat => playerStats.isDead;
|
||||
|
||||
/// 플레이어 HP 비율
|
||||
double get playerHpRatio => playerStats.hpRatio;
|
||||
|
||||
/// 몬스터 HP 비율
|
||||
double get monsterHpRatio => monsterStats.hpRatio;
|
||||
|
||||
CombatState copyWith({
|
||||
CombatStats? playerStats,
|
||||
MonsterCombatStats? monsterStats,
|
||||
int? playerAttackAccumulatorMs,
|
||||
int? monsterAttackAccumulatorMs,
|
||||
int? totalDamageDealt,
|
||||
int? totalDamageTaken,
|
||||
int? turnsElapsed,
|
||||
bool? isActive,
|
||||
}) {
|
||||
return CombatState(
|
||||
playerStats: playerStats ?? this.playerStats,
|
||||
monsterStats: monsterStats ?? this.monsterStats,
|
||||
playerAttackAccumulatorMs:
|
||||
playerAttackAccumulatorMs ?? this.playerAttackAccumulatorMs,
|
||||
monsterAttackAccumulatorMs:
|
||||
monsterAttackAccumulatorMs ?? this.monsterAttackAccumulatorMs,
|
||||
totalDamageDealt: totalDamageDealt ?? this.totalDamageDealt,
|
||||
totalDamageTaken: totalDamageTaken ?? this.totalDamageTaken,
|
||||
turnsElapsed: turnsElapsed ?? this.turnsElapsed,
|
||||
isActive: isActive ?? this.isActive,
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 팩토리 메서드
|
||||
// ============================================================================
|
||||
|
||||
/// 새 전투 시작
|
||||
factory CombatState.start({
|
||||
required CombatStats playerStats,
|
||||
required MonsterCombatStats monsterStats,
|
||||
}) {
|
||||
return CombatState(
|
||||
playerStats: playerStats,
|
||||
monsterStats: monsterStats,
|
||||
playerAttackAccumulatorMs: 0,
|
||||
monsterAttackAccumulatorMs: 0,
|
||||
totalDamageDealt: 0,
|
||||
totalDamageTaken: 0,
|
||||
turnsElapsed: 0,
|
||||
isActive: true,
|
||||
);
|
||||
}
|
||||
|
||||
/// 비활성 상태 (전투 없음)
|
||||
factory CombatState.inactive() {
|
||||
return CombatState(
|
||||
playerStats: CombatStats.empty(),
|
||||
monsterStats: MonsterCombatStats.empty(),
|
||||
playerAttackAccumulatorMs: 0,
|
||||
monsterAttackAccumulatorMs: 0,
|
||||
totalDamageDealt: 0,
|
||||
totalDamageTaken: 0,
|
||||
turnsElapsed: 0,
|
||||
isActive: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
292
lib/src/core/model/combat_stats.dart
Normal file
292
lib/src/core/model/combat_stats.dart
Normal file
@@ -0,0 +1,292 @@
|
||||
import 'package:askiineverdie/src/core/model/game_state.dart';
|
||||
|
||||
/// 전투용 파생 스탯
|
||||
///
|
||||
/// 기본 Stats와 Equipment를 기반으로 계산되는 전투 관련 수치.
|
||||
/// 불변(immutable) 객체로 설계되어 상태 변경 시 새 인스턴스 생성.
|
||||
class CombatStats {
|
||||
const CombatStats({
|
||||
// 기본 스탯 (Stats에서 복사)
|
||||
required this.str,
|
||||
required this.con,
|
||||
required this.dex,
|
||||
required this.intelligence,
|
||||
required this.wis,
|
||||
required this.cha,
|
||||
// 파생 스탯
|
||||
required this.atk,
|
||||
required this.def,
|
||||
required this.magAtk,
|
||||
required this.magDef,
|
||||
required this.criRate,
|
||||
required this.criDamage,
|
||||
required this.evasion,
|
||||
required this.accuracy,
|
||||
required this.blockRate,
|
||||
required this.parryRate,
|
||||
required this.attackDelayMs,
|
||||
// 자원
|
||||
required this.hpMax,
|
||||
required this.hpCurrent,
|
||||
required this.mpMax,
|
||||
required this.mpCurrent,
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// 기본 스탯
|
||||
// ============================================================================
|
||||
|
||||
/// 힘: 물리 공격력 보정
|
||||
final int str;
|
||||
|
||||
/// 체력: HP, 방어력 보정
|
||||
final int con;
|
||||
|
||||
/// 민첩: 회피율, 크리티컬율, 명중률, 공격 속도
|
||||
final int dex;
|
||||
|
||||
/// 지능: 마법 공격력, MP
|
||||
final int intelligence;
|
||||
|
||||
/// 지혜: 마법 방어력, MP 회복
|
||||
final int wis;
|
||||
|
||||
/// 매력: 상점 가격, 드롭률 보정
|
||||
final int cha;
|
||||
|
||||
// ============================================================================
|
||||
// 파생 스탯 (전투용)
|
||||
// ============================================================================
|
||||
|
||||
/// 물리 공격력
|
||||
final int atk;
|
||||
|
||||
/// 물리 방어력
|
||||
final int def;
|
||||
|
||||
/// 마법 공격력
|
||||
final int magAtk;
|
||||
|
||||
/// 마법 방어력
|
||||
final int magDef;
|
||||
|
||||
/// 크리티컬 확률 (0.0 ~ 1.0)
|
||||
final double criRate;
|
||||
|
||||
/// 크리티컬 데미지 배율 (1.5 ~ 3.0)
|
||||
final double criDamage;
|
||||
|
||||
/// 회피율 (0.0 ~ 0.5)
|
||||
final double evasion;
|
||||
|
||||
/// 명중률 (0.8 ~ 1.0)
|
||||
final double accuracy;
|
||||
|
||||
/// 방패 방어율 (0.0 ~ 0.4)
|
||||
final double blockRate;
|
||||
|
||||
/// 무기로 쳐내기 확률 (0.0 ~ 0.3)
|
||||
final double parryRate;
|
||||
|
||||
/// 공격 딜레이 (밀리초)
|
||||
final int attackDelayMs;
|
||||
|
||||
// ============================================================================
|
||||
// 자원
|
||||
// ============================================================================
|
||||
|
||||
/// 최대 HP
|
||||
final int hpMax;
|
||||
|
||||
/// 현재 HP
|
||||
final int hpCurrent;
|
||||
|
||||
/// 최대 MP
|
||||
final int mpMax;
|
||||
|
||||
/// 현재 MP
|
||||
final int mpCurrent;
|
||||
|
||||
// ============================================================================
|
||||
// 유틸리티
|
||||
// ============================================================================
|
||||
|
||||
/// HP 비율 (0.0 ~ 1.0)
|
||||
double get hpRatio => hpMax > 0 ? hpCurrent / hpMax : 0.0;
|
||||
|
||||
/// MP 비율 (0.0 ~ 1.0)
|
||||
double get mpRatio => mpMax > 0 ? mpCurrent / mpMax : 0.0;
|
||||
|
||||
/// 생존 여부
|
||||
bool get isAlive => hpCurrent > 0;
|
||||
|
||||
/// 사망 여부
|
||||
bool get isDead => hpCurrent <= 0;
|
||||
|
||||
/// HP 변경된 새 인스턴스 반환
|
||||
CombatStats withHp(int newHp) {
|
||||
return copyWith(hpCurrent: newHp.clamp(0, hpMax));
|
||||
}
|
||||
|
||||
/// MP 변경된 새 인스턴스 반환
|
||||
CombatStats withMp(int newMp) {
|
||||
return copyWith(mpCurrent: newMp.clamp(0, mpMax));
|
||||
}
|
||||
|
||||
/// 데미지 적용된 새 인스턴스 반환
|
||||
CombatStats applyDamage(int damage) {
|
||||
return withHp(hpCurrent - damage);
|
||||
}
|
||||
|
||||
/// 힐 적용된 새 인스턴스 반환
|
||||
CombatStats applyHeal(int amount) {
|
||||
return withHp(hpCurrent + amount);
|
||||
}
|
||||
|
||||
CombatStats copyWith({
|
||||
int? str,
|
||||
int? con,
|
||||
int? dex,
|
||||
int? intelligence,
|
||||
int? wis,
|
||||
int? cha,
|
||||
int? atk,
|
||||
int? def,
|
||||
int? magAtk,
|
||||
int? magDef,
|
||||
double? criRate,
|
||||
double? criDamage,
|
||||
double? evasion,
|
||||
double? accuracy,
|
||||
double? blockRate,
|
||||
double? parryRate,
|
||||
int? attackDelayMs,
|
||||
int? hpMax,
|
||||
int? hpCurrent,
|
||||
int? mpMax,
|
||||
int? mpCurrent,
|
||||
}) {
|
||||
return CombatStats(
|
||||
str: str ?? this.str,
|
||||
con: con ?? this.con,
|
||||
dex: dex ?? this.dex,
|
||||
intelligence: intelligence ?? this.intelligence,
|
||||
wis: wis ?? this.wis,
|
||||
cha: cha ?? this.cha,
|
||||
atk: atk ?? this.atk,
|
||||
def: def ?? this.def,
|
||||
magAtk: magAtk ?? this.magAtk,
|
||||
magDef: magDef ?? this.magDef,
|
||||
criRate: criRate ?? this.criRate,
|
||||
criDamage: criDamage ?? this.criDamage,
|
||||
evasion: evasion ?? this.evasion,
|
||||
accuracy: accuracy ?? this.accuracy,
|
||||
blockRate: blockRate ?? this.blockRate,
|
||||
parryRate: parryRate ?? this.parryRate,
|
||||
attackDelayMs: attackDelayMs ?? this.attackDelayMs,
|
||||
hpMax: hpMax ?? this.hpMax,
|
||||
hpCurrent: hpCurrent ?? this.hpCurrent,
|
||||
mpMax: mpMax ?? this.mpMax,
|
||||
mpCurrent: mpCurrent ?? this.mpCurrent,
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 팩토리 메서드
|
||||
// ============================================================================
|
||||
|
||||
/// Stats와 Equipment에서 CombatStats 생성
|
||||
///
|
||||
/// [stats] 캐릭터 기본 스탯
|
||||
/// [equipment] 장착 장비 (향후 장비 스탯 적용 시 사용)
|
||||
/// [level] 캐릭터 레벨 (스케일링용)
|
||||
factory CombatStats.fromStats({
|
||||
required Stats stats,
|
||||
required Equipment equipment,
|
||||
required int level,
|
||||
}) {
|
||||
// 기본 공격력: STR 기반 + 레벨 보정
|
||||
final baseAtk = stats.str * 2 + level;
|
||||
|
||||
// 기본 방어력: CON 기반 + 레벨 보정
|
||||
final baseDef = stats.con + (level ~/ 2);
|
||||
|
||||
// 마법 공격력: INT 기반
|
||||
final baseMagAtk = stats.intelligence * 2 + level;
|
||||
|
||||
// 마법 방어력: WIS 기반
|
||||
final baseMagDef = stats.wis + (level ~/ 2);
|
||||
|
||||
// 크리티컬 확률: DEX 기반 (0.05 ~ 0.5)
|
||||
final criRate = (0.05 + stats.dex * 0.005).clamp(0.05, 0.5);
|
||||
|
||||
// 크리티컬 데미지: 기본 1.5배, DEX에 따라 증가 (최대 3.0)
|
||||
final criDamage = (1.5 + stats.dex * 0.01).clamp(1.5, 3.0);
|
||||
|
||||
// 회피율: DEX 기반 (0.0 ~ 0.5)
|
||||
final evasion = (stats.dex * 0.005).clamp(0.0, 0.5);
|
||||
|
||||
// 명중률: DEX 기반 (0.8 ~ 1.0)
|
||||
final accuracy = (0.8 + stats.dex * 0.002).clamp(0.8, 1.0);
|
||||
|
||||
// 방패 방어율: 방패 장착 여부에 따라 (0.0 ~ 0.4)
|
||||
final hasShield = equipment.shield.isNotEmpty;
|
||||
final blockRate = hasShield ? (0.1 + stats.con * 0.003).clamp(0.1, 0.4) : 0.0;
|
||||
|
||||
// 무기 쳐내기: DEX + STR 기반 (0.0 ~ 0.3)
|
||||
final parryRate = ((stats.dex + stats.str) * 0.002).clamp(0.0, 0.3);
|
||||
|
||||
// 공격 속도: DEX 기반 (기본 1000ms, 최소 357ms)
|
||||
final speedModifier = 1.0 + (stats.dex - 10) * 0.02;
|
||||
final attackDelayMs = (1000 / speedModifier).round().clamp(357, 1500);
|
||||
|
||||
return CombatStats(
|
||||
str: stats.str,
|
||||
con: stats.con,
|
||||
dex: stats.dex,
|
||||
intelligence: stats.intelligence,
|
||||
wis: stats.wis,
|
||||
cha: stats.cha,
|
||||
atk: baseAtk,
|
||||
def: baseDef,
|
||||
magAtk: baseMagAtk,
|
||||
magDef: baseMagDef,
|
||||
criRate: criRate,
|
||||
criDamage: criDamage,
|
||||
evasion: evasion,
|
||||
accuracy: accuracy,
|
||||
blockRate: blockRate,
|
||||
parryRate: parryRate,
|
||||
attackDelayMs: attackDelayMs,
|
||||
hpMax: stats.hpMax,
|
||||
hpCurrent: stats.hp,
|
||||
mpMax: stats.mpMax,
|
||||
mpCurrent: stats.mp,
|
||||
);
|
||||
}
|
||||
|
||||
/// 테스트/디버그용 기본값
|
||||
factory CombatStats.empty() => const CombatStats(
|
||||
str: 10,
|
||||
con: 10,
|
||||
dex: 10,
|
||||
intelligence: 10,
|
||||
wis: 10,
|
||||
cha: 10,
|
||||
atk: 20,
|
||||
def: 10,
|
||||
magAtk: 20,
|
||||
magDef: 10,
|
||||
criRate: 0.05,
|
||||
criDamage: 1.5,
|
||||
evasion: 0.05,
|
||||
accuracy: 0.85,
|
||||
blockRate: 0.0,
|
||||
parryRate: 0.0,
|
||||
attackDelayMs: 1000,
|
||||
hpMax: 100,
|
||||
hpCurrent: 100,
|
||||
mpMax: 50,
|
||||
mpCurrent: 50,
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:askiineverdie/src/core/model/combat_state.dart';
|
||||
import 'package:askiineverdie/src/core/util/deterministic_random.dart';
|
||||
|
||||
/// Minimal skeletal state to mirror Progress Quest structures.
|
||||
@@ -476,6 +478,7 @@ class ProgressState {
|
||||
this.plotHistory = const [],
|
||||
this.questHistory = const [],
|
||||
this.currentQuestMonster,
|
||||
this.currentCombat,
|
||||
});
|
||||
|
||||
final ProgressBarState task;
|
||||
@@ -496,6 +499,9 @@ class ProgressState {
|
||||
/// 현재 퀘스트 몬스터 정보 (Exterminate 타입용)
|
||||
final QuestMonsterInfo? currentQuestMonster;
|
||||
|
||||
/// 현재 전투 상태 (킬 태스크 진행 중)
|
||||
final CombatState? currentCombat;
|
||||
|
||||
factory ProgressState.empty() => ProgressState(
|
||||
task: ProgressBarState.empty(),
|
||||
quest: ProgressBarState.empty(),
|
||||
@@ -508,6 +514,7 @@ class ProgressState {
|
||||
plotHistory: const [HistoryEntry(caption: 'Prologue', isComplete: false)],
|
||||
questHistory: const [],
|
||||
currentQuestMonster: null,
|
||||
currentCombat: null,
|
||||
);
|
||||
|
||||
ProgressState copyWith({
|
||||
@@ -522,6 +529,7 @@ class ProgressState {
|
||||
List<HistoryEntry>? plotHistory,
|
||||
List<HistoryEntry>? questHistory,
|
||||
QuestMonsterInfo? currentQuestMonster,
|
||||
CombatState? currentCombat,
|
||||
}) {
|
||||
return ProgressState(
|
||||
task: task ?? this.task,
|
||||
@@ -535,6 +543,7 @@ class ProgressState {
|
||||
plotHistory: plotHistory ?? this.plotHistory,
|
||||
questHistory: questHistory ?? this.questHistory,
|
||||
currentQuestMonster: currentQuestMonster ?? this.currentQuestMonster,
|
||||
currentCombat: currentCombat ?? this.currentCombat,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
225
lib/src/core/model/monster_combat_stats.dart
Normal file
225
lib/src/core/model/monster_combat_stats.dart
Normal file
@@ -0,0 +1,225 @@
|
||||
/// 몬스터 공격 속도 타입
|
||||
enum MonsterSpeedType {
|
||||
/// 빠름 (600ms)
|
||||
fast,
|
||||
|
||||
/// 보통 (1000ms)
|
||||
normal,
|
||||
|
||||
/// 느림 (1400ms)
|
||||
slow,
|
||||
}
|
||||
|
||||
/// 몬스터 전투 스탯
|
||||
///
|
||||
/// 플레이어 레벨과 몬스터 기본 레벨을 기반으로 계산되는 전투 관련 수치.
|
||||
class MonsterCombatStats {
|
||||
const MonsterCombatStats({
|
||||
required this.name,
|
||||
required this.level,
|
||||
required this.atk,
|
||||
required this.def,
|
||||
required this.hpMax,
|
||||
required this.hpCurrent,
|
||||
required this.criRate,
|
||||
required this.criDamage,
|
||||
required this.evasion,
|
||||
required this.accuracy,
|
||||
required this.attackDelayMs,
|
||||
required this.expReward,
|
||||
});
|
||||
|
||||
/// 몬스터 표시 이름 (형용사 포함)
|
||||
final String name;
|
||||
|
||||
/// 몬스터 레벨
|
||||
final int level;
|
||||
|
||||
/// 공격력
|
||||
final int atk;
|
||||
|
||||
/// 방어력
|
||||
final int def;
|
||||
|
||||
/// 최대 HP
|
||||
final int hpMax;
|
||||
|
||||
/// 현재 HP
|
||||
final int hpCurrent;
|
||||
|
||||
/// 크리티컬 확률 (0.0 ~ 0.3)
|
||||
final double criRate;
|
||||
|
||||
/// 크리티컬 데미지 배율 (1.3 ~ 2.0)
|
||||
final double criDamage;
|
||||
|
||||
/// 회피율 (0.0 ~ 0.3)
|
||||
final double evasion;
|
||||
|
||||
/// 명중률 (0.7 ~ 0.95)
|
||||
final double accuracy;
|
||||
|
||||
/// 공격 딜레이 (밀리초)
|
||||
final int attackDelayMs;
|
||||
|
||||
/// 처치 시 경험치 보상
|
||||
final int expReward;
|
||||
|
||||
// ============================================================================
|
||||
// 유틸리티
|
||||
// ============================================================================
|
||||
|
||||
/// HP 비율 (0.0 ~ 1.0)
|
||||
double get hpRatio => hpMax > 0 ? hpCurrent / hpMax : 0.0;
|
||||
|
||||
/// 생존 여부
|
||||
bool get isAlive => hpCurrent > 0;
|
||||
|
||||
/// 사망 여부
|
||||
bool get isDead => hpCurrent <= 0;
|
||||
|
||||
/// HP 변경된 새 인스턴스 반환
|
||||
MonsterCombatStats withHp(int newHp) {
|
||||
return copyWith(hpCurrent: newHp.clamp(0, hpMax));
|
||||
}
|
||||
|
||||
/// 데미지 적용된 새 인스턴스 반환
|
||||
MonsterCombatStats applyDamage(int damage) {
|
||||
return withHp(hpCurrent - damage);
|
||||
}
|
||||
|
||||
MonsterCombatStats copyWith({
|
||||
String? name,
|
||||
int? level,
|
||||
int? atk,
|
||||
int? def,
|
||||
int? hpMax,
|
||||
int? hpCurrent,
|
||||
double? criRate,
|
||||
double? criDamage,
|
||||
double? evasion,
|
||||
double? accuracy,
|
||||
int? attackDelayMs,
|
||||
int? expReward,
|
||||
}) {
|
||||
return MonsterCombatStats(
|
||||
name: name ?? this.name,
|
||||
level: level ?? this.level,
|
||||
atk: atk ?? this.atk,
|
||||
def: def ?? this.def,
|
||||
hpMax: hpMax ?? this.hpMax,
|
||||
hpCurrent: hpCurrent ?? this.hpCurrent,
|
||||
criRate: criRate ?? this.criRate,
|
||||
criDamage: criDamage ?? this.criDamage,
|
||||
evasion: evasion ?? this.evasion,
|
||||
accuracy: accuracy ?? this.accuracy,
|
||||
attackDelayMs: attackDelayMs ?? this.attackDelayMs,
|
||||
expReward: expReward ?? this.expReward,
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 팩토리 메서드
|
||||
// ============================================================================
|
||||
|
||||
/// 몬스터 레벨에서 전투 스탯 생성
|
||||
///
|
||||
/// [name] 몬스터 표시 이름
|
||||
/// [level] 몬스터 레벨 (원본 데이터 기준)
|
||||
/// [speedType] 공격 속도 타입 (기본: normal)
|
||||
factory MonsterCombatStats.fromLevel({
|
||||
required String name,
|
||||
required int level,
|
||||
MonsterSpeedType speedType = MonsterSpeedType.normal,
|
||||
}) {
|
||||
// 레벨 기반 스탯 스케일링
|
||||
// 레벨 1 기준으로 선형/비선형 증가
|
||||
|
||||
// HP: 레벨 * 15 + 기본값 20
|
||||
final hpMax = 20 + level * 15;
|
||||
|
||||
// 공격력: 레벨 * 3 + 기본값 5
|
||||
final atk = 5 + level * 3;
|
||||
|
||||
// 방어력: 레벨 * 1.5 + 기본값 2
|
||||
final def = 2 + (level * 1.5).round();
|
||||
|
||||
// 크리티컬 확률: 레벨에 따라 천천히 증가 (0.02 ~ 0.3)
|
||||
final criRate = (0.02 + level * 0.003).clamp(0.02, 0.3);
|
||||
|
||||
// 크리티컬 데미지: 고정 1.5배 (몬스터는 플레이어보다 낮음)
|
||||
const criDamage = 1.5;
|
||||
|
||||
// 회피율: 레벨에 따라 천천히 증가 (0.0 ~ 0.3)
|
||||
final evasion = (level * 0.003).clamp(0.0, 0.3);
|
||||
|
||||
// 명중률: 레벨에 따라 증가 (0.7 ~ 0.95)
|
||||
final accuracy = (0.7 + level * 0.003).clamp(0.7, 0.95);
|
||||
|
||||
// 공격 딜레이: 타입에 따라 결정
|
||||
final attackDelayMs = switch (speedType) {
|
||||
MonsterSpeedType.fast => 600,
|
||||
MonsterSpeedType.normal => 1000,
|
||||
MonsterSpeedType.slow => 1400,
|
||||
};
|
||||
|
||||
// 경험치 보상: 레벨 기반
|
||||
final expReward = 10 + level * 5;
|
||||
|
||||
return MonsterCombatStats(
|
||||
name: name,
|
||||
level: level,
|
||||
atk: atk,
|
||||
def: def,
|
||||
hpMax: hpMax,
|
||||
hpCurrent: hpMax,
|
||||
criRate: criRate,
|
||||
criDamage: criDamage,
|
||||
evasion: evasion,
|
||||
accuracy: accuracy,
|
||||
attackDelayMs: attackDelayMs,
|
||||
expReward: expReward,
|
||||
);
|
||||
}
|
||||
|
||||
/// 몬스터 이름에서 속도 타입 추론
|
||||
///
|
||||
/// 특정 키워드 기반으로 속도 결정 (향후 확장 가능)
|
||||
static MonsterSpeedType inferSpeedType(String monsterName) {
|
||||
final lowerName = monsterName.toLowerCase();
|
||||
|
||||
// 빠른 몬스터 키워드
|
||||
if (lowerName.contains('ghost') ||
|
||||
lowerName.contains('phantom') ||
|
||||
lowerName.contains('wisp') ||
|
||||
lowerName.contains('sprite')) {
|
||||
return MonsterSpeedType.fast;
|
||||
}
|
||||
|
||||
// 느린 몬스터 키워드
|
||||
if (lowerName.contains('golem') ||
|
||||
lowerName.contains('giant') ||
|
||||
lowerName.contains('titan') ||
|
||||
lowerName.contains('dragon')) {
|
||||
return MonsterSpeedType.slow;
|
||||
}
|
||||
|
||||
return MonsterSpeedType.normal;
|
||||
}
|
||||
|
||||
/// 테스트/디버그용 기본값
|
||||
factory MonsterCombatStats.empty() => const MonsterCombatStats(
|
||||
name: 'Test Monster',
|
||||
level: 1,
|
||||
atk: 8,
|
||||
def: 3,
|
||||
hpMax: 35,
|
||||
hpCurrent: 35,
|
||||
criRate: 0.02,
|
||||
criDamage: 1.5,
|
||||
evasion: 0.0,
|
||||
accuracy: 0.7,
|
||||
attackDelayMs: 1000,
|
||||
expReward: 15,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user