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:
242
lib/src/core/engine/combat_calculator.dart
Normal file
242
lib/src/core/engine/combat_calculator.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user