From c62687f7bd15be5bb9546b1b0a9199e77833cacf Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Wed, 17 Dec 2025 16:31:52 +0900 Subject: [PATCH] =?UTF-8?q?feat(combat):=20Phase=201=20=ED=95=B5=EC=8B=AC?= =?UTF-8?q?=20=EC=A0=84=ED=88=AC=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 신규 파일: - 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 반영 --- lib/src/core/engine/combat_calculator.dart | 242 +++++++++++++++ lib/src/core/engine/progress_service.dart | 123 +++++++- lib/src/core/model/combat_result.dart | 143 +++++++++ lib/src/core/model/combat_state.dart | 121 ++++++++ lib/src/core/model/combat_stats.dart | 292 +++++++++++++++++++ lib/src/core/model/game_state.dart | 9 + lib/src/core/model/monster_combat_stats.dart | 225 ++++++++++++++ 7 files changed, 1148 insertions(+), 7 deletions(-) create mode 100644 lib/src/core/engine/combat_calculator.dart create mode 100644 lib/src/core/model/combat_result.dart create mode 100644 lib/src/core/model/combat_state.dart create mode 100644 lib/src/core/model/combat_stats.dart create mode 100644 lib/src/core/model/monster_combat_stats.dart diff --git a/lib/src/core/engine/combat_calculator.dart b/lib/src/core/engine/combat_calculator.dart new file mode 100644 index 0000000..ee34680 --- /dev/null +++ b/lib/src/core/engine/combat_calculator.dart @@ -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); + } +} diff --git a/lib/src/core/engine/progress_service.dart b/lib/src/core/engine/progress_service.dart index 3619c78..50dca80 100644 --- a/lib/src/core/engine/progress_service.dart +++ b/lib/src/core/engine/progress_service.dart @@ -1,9 +1,13 @@ import 'dart:math' as math; 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/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/monster_combat_stats.dart'; import 'package:askiineverdie/src/core/model/pq_config.dart'; 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 ? progress.task.max : uncapped; + + // 킬 태스크 중 전투 진행 + var updatedCombat = progress.currentCombat; + if (progress.currentTask.type == TaskType.kill && updatedCombat != null && updatedCombat.isActive) { + updatedCombat = _processCombatTick(nextState, updatedCombat, clamped); + } + progress = progress.copyWith( task: progress.task.copyWith(position: newTaskPos), + currentCombat: updatedCombat, ); nextState = _recalculateEncumbrance( nextState.copyWith(progress: progress), @@ -155,10 +167,24 @@ class ProgressService { final gain = progress.currentTask.type == TaskType.kill; final incrementSeconds = progress.task.max ~/ 1000; - // 킬 태스크 완료 시 전리품 획득 (원본 Main.pas:625-630) + // 킬 태스크 완료 시 전투 결과 반영 및 전리품 획득 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); - progress = nextState.progress; + + // 전투 상태 초기화 + progress = nextState.progress.copyWith(currentCombat: null); + nextState = nextState.copyWith(progress: progress); } // 시장/판매/구매 태스크 완료 시 처리 (원본 Main.pas:631-649) @@ -367,11 +393,31 @@ class ProgressService { questLevel, ); - // 태스크 지속시간 계산 (원본 682줄) - // n := (2 * InventoryLabelAlsoGameStyle.Tag * n * 1000) div l; - // InventoryLabelAlsoGameStyle.Tag는 게임 스타일을 나타내는 값 (1이 기본) - const gameStyleTag = 1; - final durationMillis = (2 * gameStyleTag * level * 1000) ~/ level; + // 전투 스탯 생성 + final playerCombatStats = CombatStats.fromStats( + stats: state.stats, + equipment: state.equipment, + 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( progress, @@ -387,6 +433,7 @@ class ProgressService { monsterPart: monsterResult.part, monsterLevel: monsterResult.level, ), + currentCombat: combatState, ); return (progress: progress, queue: queue); @@ -743,4 +790,66 @@ class ProgressService { 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, + ); + } } diff --git a/lib/src/core/model/combat_result.dart b/lib/src/core/model/combat_result.dart new file mode 100644 index 0000000..886e664 --- /dev/null +++ b/lib/src/core/model/combat_result.dart @@ -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, +} diff --git a/lib/src/core/model/combat_state.dart b/lib/src/core/model/combat_state.dart new file mode 100644 index 0000000..c7c33a2 --- /dev/null +++ b/lib/src/core/model/combat_state.dart @@ -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, + ); + } +} diff --git a/lib/src/core/model/combat_stats.dart b/lib/src/core/model/combat_stats.dart new file mode 100644 index 0000000..e813411 --- /dev/null +++ b/lib/src/core/model/combat_stats.dart @@ -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, + ); +} diff --git a/lib/src/core/model/game_state.dart b/lib/src/core/model/game_state.dart index 6b04d0d..8e0fa3e 100644 --- a/lib/src/core/model/game_state.dart +++ b/lib/src/core/model/game_state.dart @@ -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? plotHistory, List? 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, ); } } diff --git a/lib/src/core/model/monster_combat_stats.dart b/lib/src/core/model/monster_combat_stats.dart new file mode 100644 index 0000000..98efd29 --- /dev/null +++ b/lib/src/core/model/monster_combat_stats.dart @@ -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, + ); +}