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