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:
JiWoong Sul
2025-12-17 16:31:52 +09:00
parent 9ad0cf4b74
commit c62687f7bd
7 changed files with 1148 additions and 7 deletions

View File

@@ -0,0 +1,242 @@
import 'dart:math' as math;
import 'package:askiineverdie/src/core/model/combat_result.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/util/deterministic_random.dart';
/// 전투 계산 서비스
///
/// 전투 데미지 계산, 명중/회피 판정 등 전투 관련 로직 전담.
/// SRP 원칙에 따라 계산 로직만 담당하며, 상태 관리는 외부에서 수행.
class CombatCalculator {
CombatCalculator({required this.rng});
/// 결정론적 난수 생성기 (재현 가능한 전투를 위해)
final DeterministicRandom rng;
// ============================================================================
// 공격 계산
// ============================================================================
/// 플레이어가 몬스터를 공격
///
/// [attacker] 공격자 (플레이어) 스탯
/// [defender] 방어자 (몬스터) 스탯
/// Returns: 공격 결과 및 업데이트된 몬스터 스탯
({AttackResult result, MonsterCombatStats updatedDefender}) playerAttackMonster({
required CombatStats attacker,
required MonsterCombatStats defender,
}) {
final result = _calculateAttack(
attackerAtk: attacker.atk,
attackerAccuracy: attacker.accuracy,
attackerCriRate: attacker.criRate,
attackerCriDamage: attacker.criDamage,
defenderDef: defender.def,
defenderEvasion: defender.evasion,
defenderBlockRate: 0.0, // 몬스터는 방패 없음
defenderParryRate: 0.0, // 몬스터는 쳐내기 없음
);
final updatedDefender = defender.applyDamage(result.damage);
return (result: result, updatedDefender: updatedDefender);
}
/// 몬스터가 플레이어를 공격
///
/// [attacker] 공격자 (몬스터) 스탯
/// [defender] 방어자 (플레이어) 스탯
/// Returns: 공격 결과 및 업데이트된 플레이어 스탯
({AttackResult result, CombatStats updatedDefender}) monsterAttackPlayer({
required MonsterCombatStats attacker,
required CombatStats defender,
}) {
final result = _calculateAttack(
attackerAtk: attacker.atk,
attackerAccuracy: attacker.accuracy,
attackerCriRate: attacker.criRate,
attackerCriDamage: attacker.criDamage,
defenderDef: defender.def,
defenderEvasion: defender.evasion,
defenderBlockRate: defender.blockRate,
defenderParryRate: defender.parryRate,
);
final updatedDefender = defender.applyDamage(result.damage);
return (result: result, updatedDefender: updatedDefender);
}
/// 범용 공격 계산
AttackResult _calculateAttack({
required int attackerAtk,
required double attackerAccuracy,
required double attackerCriRate,
required double attackerCriDamage,
required int defenderDef,
required double defenderEvasion,
required double defenderBlockRate,
required double defenderParryRate,
}) {
// 1. 명중 판정
final hitChance = attackerAccuracy - defenderEvasion;
final hitRoll = rng.nextDouble();
final isHit = hitRoll < hitChance;
if (!isHit) {
return AttackResult.miss();
}
// 2. 방어 판정
final blockRoll = rng.nextDouble();
final isBlocked = blockRoll < defenderBlockRate;
final parryRoll = rng.nextDouble();
final isParried = parryRoll < defenderParryRate;
// 3. 기본 데미지 계산 (0.8 ~ 1.2 변동)
final damageVariation = 0.8 + rng.nextDouble() * 0.4;
var baseDamage = (attackerAtk * damageVariation - defenderDef * 0.5);
// 4. 크리티컬 판정
final criRoll = rng.nextDouble();
final isCritical = criRoll < attackerCriRate;
if (isCritical) {
baseDamage *= attackerCriDamage;
}
// 5. 방어 적용
if (isBlocked) {
baseDamage *= 0.3; // 방패로 70% 감소
} else if (isParried) {
baseDamage *= 0.5; // 쳐내기로 50% 감소
}
// 6. 최종 데미지 (최소 1)
final finalDamage = math.max(1, baseDamage.round());
return AttackResult(
damage: finalDamage,
isCritical: isCritical,
isHit: true,
isBlocked: isBlocked,
isParried: isParried,
isEvaded: false,
);
}
// ============================================================================
// 전투 턴 처리
// ============================================================================
/// 전투 턴 실행
///
/// [playerStats] 플레이어 전투 스탯
/// [monsterStats] 몬스터 전투 스탯
/// [elapsedMs] 경과 시간 (밀리초)
/// [playerAttackAccumulatorMs] 플레이어 공격 누적 시간
/// [monsterAttackAccumulatorMs] 몬스터 공격 누적 시간
CombatTurnResult processTurn({
required CombatStats playerStats,
required MonsterCombatStats monsterStats,
required int elapsedMs,
required int playerAttackAccumulatorMs,
required int monsterAttackAccumulatorMs,
}) {
var currentPlayerStats = playerStats;
var currentMonsterStats = monsterStats;
AttackResult? playerAttackResult;
AttackResult? monsterAttackResult;
// 플레이어 공격 시간 체크
final newPlayerAccumulator = playerAttackAccumulatorMs + elapsedMs;
if (newPlayerAccumulator >= currentPlayerStats.attackDelayMs) {
// 플레이어 공격
final attackResult = playerAttackMonster(
attacker: currentPlayerStats,
defender: currentMonsterStats,
);
playerAttackResult = attackResult.result;
currentMonsterStats = attackResult.updatedDefender;
}
// 몬스터가 살아있으면 반격
if (currentMonsterStats.isAlive) {
final newMonsterAccumulator = monsterAttackAccumulatorMs + elapsedMs;
if (newMonsterAccumulator >= currentMonsterStats.attackDelayMs) {
// 몬스터 공격
final attackResult = monsterAttackPlayer(
attacker: currentMonsterStats,
defender: currentPlayerStats,
);
monsterAttackResult = attackResult.result;
currentPlayerStats = attackResult.updatedDefender;
}
}
// 전투 종료 체크
final isCombatOver = currentPlayerStats.isDead || currentMonsterStats.isDead;
final isPlayerVictory = isCombatOver && currentMonsterStats.isDead;
return CombatTurnResult(
playerAttack: playerAttackResult,
monsterAttack: monsterAttackResult,
updatedPlayerStats: currentPlayerStats,
updatedMonsterStats: currentMonsterStats,
isCombatOver: isCombatOver,
isPlayerVictory: isPlayerVictory,
);
}
// ============================================================================
// 유틸리티
// ============================================================================
/// 예상 전투 시간 계산 (밀리초)
///
/// 플레이어와 몬스터의 예상 DPS를 기반으로 대략적인 전투 시간 추정.
/// Idle 게임 특성상 태스크 바 진행 속도 결정에 사용.
int estimateCombatDurationMs({
required CombatStats player,
required MonsterCombatStats monster,
}) {
// 플레이어 DPS (초당 데미지)
final playerDamagePerHit = math.max(1, player.atk - monster.def * 0.5);
final playerHitsPerSecond = 1000 / player.attackDelayMs;
final playerDps = playerDamagePerHit * playerHitsPerSecond * player.accuracy;
// 몬스터를 처치하는 데 필요한 시간 (밀리초)
final timeToKillMonster = (monster.hpMax / playerDps * 1000).round();
// 최소 2초, 최대 30초
return timeToKillMonster.clamp(2000, 30000);
}
/// 전투 난이도 평가 (0.0 ~ 1.0)
///
/// 0.0 = 매우 쉬움, 0.5 = 적당, 1.0 = 매우 어려움
double evaluateDifficulty({
required CombatStats player,
required MonsterCombatStats monster,
}) {
// 플레이어 예상 생존 시간
final monsterDamagePerHit = math.max(1, monster.atk - player.def * 0.5);
final monsterHitsPerSecond = 1000 / monster.attackDelayMs;
final monsterDps = monsterDamagePerHit * monsterHitsPerSecond * monster.accuracy;
final playerSurvivalTime = player.hpCurrent / monsterDps;
// 몬스터 예상 생존 시간
final playerDamagePerHit = math.max(1, player.atk - monster.def * 0.5);
final playerHitsPerSecond = 1000 / player.attackDelayMs;
final playerDps = playerDamagePerHit * playerHitsPerSecond * player.accuracy;
final monsterSurvivalTime = monster.hpCurrent / playerDps;
// 난이도 = 몬스터 생존시간 / (플레이어 생존시간 + 몬스터 생존시간)
final difficulty = monsterSurvivalTime / (playerSurvivalTime + monsterSurvivalTime);
return difficulty.clamp(0.0, 1.0);
}
}

View File

@@ -1,9 +1,13 @@
import 'dart:math' as math; import 'dart:math' as math;
import 'package:askiineverdie/data/game_text_l10n.dart' as l10n; import 'package:askiineverdie/data/game_text_l10n.dart' as l10n;
import 'package:askiineverdie/src/core/engine/combat_calculator.dart';
import 'package:askiineverdie/src/core/engine/game_mutations.dart'; import 'package:askiineverdie/src/core/engine/game_mutations.dart';
import 'package:askiineverdie/src/core/engine/reward_service.dart'; import 'package:askiineverdie/src/core/engine/reward_service.dart';
import 'package:askiineverdie/src/core/model/combat_state.dart';
import 'package:askiineverdie/src/core/model/combat_stats.dart';
import 'package:askiineverdie/src/core/model/game_state.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/pq_config.dart'; import 'package:askiineverdie/src/core/model/pq_config.dart';
import 'package:askiineverdie/src/core/util/pq_logic.dart' as pq_logic; import 'package:askiineverdie/src/core/util/pq_logic.dart' as pq_logic;
@@ -143,8 +147,16 @@ class ProgressService {
final int newTaskPos = uncapped > progress.task.max final int newTaskPos = uncapped > progress.task.max
? progress.task.max ? progress.task.max
: uncapped; : uncapped;
// 킬 태스크 중 전투 진행
var updatedCombat = progress.currentCombat;
if (progress.currentTask.type == TaskType.kill && updatedCombat != null && updatedCombat.isActive) {
updatedCombat = _processCombatTick(nextState, updatedCombat, clamped);
}
progress = progress.copyWith( progress = progress.copyWith(
task: progress.task.copyWith(position: newTaskPos), task: progress.task.copyWith(position: newTaskPos),
currentCombat: updatedCombat,
); );
nextState = _recalculateEncumbrance( nextState = _recalculateEncumbrance(
nextState.copyWith(progress: progress), nextState.copyWith(progress: progress),
@@ -155,10 +167,24 @@ class ProgressService {
final gain = progress.currentTask.type == TaskType.kill; final gain = progress.currentTask.type == TaskType.kill;
final incrementSeconds = progress.task.max ~/ 1000; final incrementSeconds = progress.task.max ~/ 1000;
// 킬 태스크 완료 시 전리품 획득 (원본 Main.pas:625-630) // 킬 태스크 완료 시 전투 결과 반영 및 전리품 획득
if (gain) { if (gain) {
// 전투 결과에 따라 플레이어 HP 업데이트
final combat = progress.currentCombat;
if (combat != null && combat.isActive) {
// 전투 중 받은 데미지를 실제 Stats에 반영
final newHp = combat.playerStats.hpCurrent;
nextState = nextState.copyWith(
stats: nextState.stats.copyWith(hpCurrent: newHp),
);
}
// 전리품 획득 (원본 Main.pas:625-630)
nextState = _winLoot(nextState); nextState = _winLoot(nextState);
progress = nextState.progress;
// 전투 상태 초기화
progress = nextState.progress.copyWith(currentCombat: null);
nextState = nextState.copyWith(progress: progress);
} }
// 시장/판매/구매 태스크 완료 시 처리 (원본 Main.pas:631-649) // 시장/판매/구매 태스크 완료 시 처리 (원본 Main.pas:631-649)
@@ -367,11 +393,31 @@ class ProgressService {
questLevel, questLevel,
); );
// 태스크 지속시간 계산 (원본 682줄) // 전투 스탯 생성
// n := (2 * InventoryLabelAlsoGameStyle.Tag * n * 1000) div l; final playerCombatStats = CombatStats.fromStats(
// InventoryLabelAlsoGameStyle.Tag는 게임 스타일을 나타내는 값 (1이 기본) stats: state.stats,
const gameStyleTag = 1; equipment: state.equipment,
final durationMillis = (2 * gameStyleTag * level * 1000) ~/ level; level: level,
);
final monsterCombatStats = MonsterCombatStats.fromLevel(
name: monsterResult.displayName,
level: monsterResult.level,
speedType: MonsterCombatStats.inferSpeedType(monsterResult.baseName),
);
// 전투 상태 초기화
final combatState = CombatState.start(
playerStats: playerCombatStats,
monsterStats: monsterCombatStats,
);
// 태스크 지속시간 계산 (CombatCalculator 기반)
final combatCalculator = CombatCalculator(rng: state.rng);
final durationMillis = combatCalculator.estimateCombatDurationMs(
player: playerCombatStats,
monster: monsterCombatStats,
);
final taskResult = pq_logic.startTask( final taskResult = pq_logic.startTask(
progress, progress,
@@ -387,6 +433,7 @@ class ProgressService {
monsterPart: monsterResult.part, monsterPart: monsterResult.part,
monsterLevel: monsterResult.level, monsterLevel: monsterResult.level,
), ),
currentCombat: combatState,
); );
return (progress: progress, queue: queue); return (progress: progress, queue: queue);
@@ -743,4 +790,66 @@ class ProgressService {
continuesSelling: false, continuesSelling: false,
); );
} }
/// 전투 틱 처리
///
/// [state] 현재 게임 상태
/// [combat] 현재 전투 상태
/// [elapsedMs] 경과 시간 (밀리초)
/// Returns: 업데이트된 전투 상태
CombatState _processCombatTick(
GameState state,
CombatState combat,
int elapsedMs,
) {
if (!combat.isActive || combat.isCombatOver) {
return combat;
}
final calculator = CombatCalculator(rng: state.rng);
var playerStats = combat.playerStats;
var monsterStats = combat.monsterStats;
var playerAccumulator = combat.playerAttackAccumulatorMs + elapsedMs;
var monsterAccumulator = combat.monsterAttackAccumulatorMs + elapsedMs;
var totalDamageDealt = combat.totalDamageDealt;
var totalDamageTaken = combat.totalDamageTaken;
var turnsElapsed = combat.turnsElapsed;
// 플레이어 공격 체크
if (playerAccumulator >= playerStats.attackDelayMs) {
final attackResult = calculator.playerAttackMonster(
attacker: playerStats,
defender: monsterStats,
);
monsterStats = attackResult.updatedDefender;
totalDamageDealt += attackResult.result.damage;
playerAccumulator -= playerStats.attackDelayMs;
turnsElapsed++;
}
// 몬스터가 살아있으면 반격
if (monsterStats.isAlive && monsterAccumulator >= monsterStats.attackDelayMs) {
final attackResult = calculator.monsterAttackPlayer(
attacker: monsterStats,
defender: playerStats,
);
playerStats = attackResult.updatedDefender;
totalDamageTaken += attackResult.result.damage;
monsterAccumulator -= monsterStats.attackDelayMs;
}
// 전투 종료 체크
final isActive = playerStats.isAlive && monsterStats.isAlive;
return combat.copyWith(
playerStats: playerStats,
monsterStats: monsterStats,
playerAttackAccumulatorMs: playerAccumulator,
monsterAttackAccumulatorMs: monsterAccumulator,
totalDamageDealt: totalDamageDealt,
totalDamageTaken: totalDamageTaken,
turnsElapsed: turnsElapsed,
isActive: isActive,
);
}
} }

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

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

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

View File

@@ -1,4 +1,6 @@
import 'dart:collection'; import 'dart:collection';
import 'package:askiineverdie/src/core/model/combat_state.dart';
import 'package:askiineverdie/src/core/util/deterministic_random.dart'; import 'package:askiineverdie/src/core/util/deterministic_random.dart';
/// Minimal skeletal state to mirror Progress Quest structures. /// Minimal skeletal state to mirror Progress Quest structures.
@@ -476,6 +478,7 @@ class ProgressState {
this.plotHistory = const [], this.plotHistory = const [],
this.questHistory = const [], this.questHistory = const [],
this.currentQuestMonster, this.currentQuestMonster,
this.currentCombat,
}); });
final ProgressBarState task; final ProgressBarState task;
@@ -496,6 +499,9 @@ class ProgressState {
/// 현재 퀘스트 몬스터 정보 (Exterminate 타입용) /// 현재 퀘스트 몬스터 정보 (Exterminate 타입용)
final QuestMonsterInfo? currentQuestMonster; final QuestMonsterInfo? currentQuestMonster;
/// 현재 전투 상태 (킬 태스크 진행 중)
final CombatState? currentCombat;
factory ProgressState.empty() => ProgressState( factory ProgressState.empty() => ProgressState(
task: ProgressBarState.empty(), task: ProgressBarState.empty(),
quest: ProgressBarState.empty(), quest: ProgressBarState.empty(),
@@ -508,6 +514,7 @@ class ProgressState {
plotHistory: const [HistoryEntry(caption: 'Prologue', isComplete: false)], plotHistory: const [HistoryEntry(caption: 'Prologue', isComplete: false)],
questHistory: const [], questHistory: const [],
currentQuestMonster: null, currentQuestMonster: null,
currentCombat: null,
); );
ProgressState copyWith({ ProgressState copyWith({
@@ -522,6 +529,7 @@ class ProgressState {
List<HistoryEntry>? plotHistory, List<HistoryEntry>? plotHistory,
List<HistoryEntry>? questHistory, List<HistoryEntry>? questHistory,
QuestMonsterInfo? currentQuestMonster, QuestMonsterInfo? currentQuestMonster,
CombatState? currentCombat,
}) { }) {
return ProgressState( return ProgressState(
task: task ?? this.task, task: task ?? this.task,
@@ -535,6 +543,7 @@ class ProgressState {
plotHistory: plotHistory ?? this.plotHistory, plotHistory: plotHistory ?? this.plotHistory,
questHistory: questHistory ?? this.questHistory, questHistory: questHistory ?? this.questHistory,
currentQuestMonster: currentQuestMonster ?? this.currentQuestMonster, currentQuestMonster: currentQuestMonster ?? this.currentQuestMonster,
currentCombat: currentCombat ?? this.currentCombat,
); );
} }
} }

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