refactor(engine): 엔진 서비스 분리 및 리팩토링
- progress_service에서 death_handler, loot_handler, task_generator 분리 - combat_tick_service에서 player_attack_processor 분리 - arena_service에서 arena_combat_simulator 분리 - skill_service에서 skill_auto_selector 분리
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
|
||||
import 'package:asciineverdie/src/core/animation/monster_size.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/monster_size.dart';
|
||||
import 'package:asciineverdie/src/core/engine/combat_calculator.dart';
|
||||
import 'package:asciineverdie/src/core/model/combat_state.dart';
|
||||
import 'package:asciineverdie/src/core/model/combat_stats.dart';
|
||||
|
||||
@@ -71,15 +71,14 @@ class AdService {
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// 프로덕션 광고 ID (AdMob 콘솔에서 생성 후 교체)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// TODO: AdMob 콘솔에서 광고 단위 생성 후 아래 ID 교체
|
||||
static const String _prodRewardedAndroid =
|
||||
'ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX'; // Android 리워드 광고
|
||||
'ca-app-pub-6691216385521068/3457464395'; // Android 리워드 광고
|
||||
static const String _prodRewardedIos =
|
||||
'ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX'; // iOS 리워드 광고
|
||||
'ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX'; // TODO: iOS 리워드 광고 ID 교체
|
||||
static const String _prodInterstitialAndroid =
|
||||
'ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX'; // Android 인터스티셜 광고
|
||||
'ca-app-pub-6691216385521068/1625507977'; // Android 인터스티셜 광고
|
||||
static const String _prodInterstitialIos =
|
||||
'ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX'; // iOS 인터스티셜 광고
|
||||
'ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX'; // TODO: iOS 인터스티셜 광고 ID 교체
|
||||
|
||||
/// 리워드 광고 단위 ID (릴리즈 빌드: 프로덕션 ID, 디버그 빌드: 테스트 ID)
|
||||
String get _rewardAdUnitId {
|
||||
|
||||
497
lib/src/core/engine/arena_combat_simulator.dart
Normal file
497
lib/src/core/engine/arena_combat_simulator.dart
Normal file
@@ -0,0 +1,497 @@
|
||||
import 'package:asciineverdie/data/skill_data.dart';
|
||||
import 'package:asciineverdie/src/core/engine/combat_calculator.dart';
|
||||
import 'package:asciineverdie/src/core/engine/skill_service.dart';
|
||||
import 'package:asciineverdie/src/core/model/arena_match.dart';
|
||||
import 'package:asciineverdie/src/core/model/combat_stats.dart';
|
||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||
import 'package:asciineverdie/src/core/model/hall_of_fame.dart';
|
||||
import 'package:asciineverdie/src/core/model/monster_combat_stats.dart';
|
||||
import 'package:asciineverdie/src/core/model/skill.dart';
|
||||
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
|
||||
|
||||
/// 아레나 전투 시뮬레이터
|
||||
///
|
||||
/// ArenaService에서 분리된 전투 시뮬레이션 로직.
|
||||
/// 스킬 시스템을 포함한 턴 기반 전투를 처리한다.
|
||||
class ArenaCombatSimulator {
|
||||
ArenaCombatSimulator({required DeterministicRandom rng})
|
||||
: _rng = rng,
|
||||
_skillService = SkillService(rng: rng);
|
||||
|
||||
final DeterministicRandom _rng;
|
||||
final SkillService _skillService;
|
||||
|
||||
/// 전투 시뮬레이션 (애니메이션용 스트림)
|
||||
Stream<ArenaCombatTurn> simulateCombat(ArenaMatch match) async* {
|
||||
final challengerStats = match.challenger.finalStats;
|
||||
final opponentStats = match.opponent.finalStats;
|
||||
|
||||
if (challengerStats == null || opponentStats == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final calculator = CombatCalculator(rng: _rng);
|
||||
|
||||
// 스킬 ID 목록 로드
|
||||
var challengerSkillIds = _getSkillIdsFromEntry(match.challenger);
|
||||
var opponentSkillIds = _getSkillIdsFromEntry(match.opponent);
|
||||
if (challengerSkillIds.isEmpty) {
|
||||
challengerSkillIds = SkillData.defaultSkillIds;
|
||||
}
|
||||
if (opponentSkillIds.isEmpty) {
|
||||
opponentSkillIds = SkillData.defaultSkillIds;
|
||||
}
|
||||
|
||||
// 스킬 시스템 상태 초기화
|
||||
var challengerSkillSystem = SkillSystemState.empty();
|
||||
var opponentSkillSystem = SkillSystemState.empty();
|
||||
|
||||
// DOT 및 디버프 추적
|
||||
var challengerDoTs = <DotEffect>[];
|
||||
var opponentDoTs = <DotEffect>[];
|
||||
var challengerDebuffs = <ActiveBuff>[];
|
||||
var opponentDebuffs = <ActiveBuff>[];
|
||||
|
||||
var playerCombatStats = challengerStats.copyWith(
|
||||
hpCurrent: challengerStats.hpMax,
|
||||
mpCurrent: challengerStats.mpMax,
|
||||
);
|
||||
|
||||
var opponentCombatStats = opponentStats.copyWith(
|
||||
hpCurrent: opponentStats.hpMax,
|
||||
mpCurrent: opponentStats.mpMax,
|
||||
);
|
||||
|
||||
int playerAccum = 0;
|
||||
int opponentAccum = 0;
|
||||
int elapsedMs = 0;
|
||||
const tickMs = 200;
|
||||
int turns = 0;
|
||||
|
||||
// 초기 상태 전송
|
||||
yield ArenaCombatTurn(
|
||||
challengerHp: playerCombatStats.hpCurrent,
|
||||
opponentHp: opponentCombatStats.hpCurrent,
|
||||
challengerHpMax: playerCombatStats.hpMax,
|
||||
opponentHpMax: opponentCombatStats.hpMax,
|
||||
challengerMp: playerCombatStats.mpCurrent,
|
||||
opponentMp: opponentCombatStats.mpCurrent,
|
||||
challengerMpMax: playerCombatStats.mpMax,
|
||||
opponentMpMax: opponentCombatStats.mpMax,
|
||||
);
|
||||
|
||||
while (playerCombatStats.isAlive && opponentCombatStats.hpCurrent > 0) {
|
||||
playerAccum += tickMs;
|
||||
opponentAccum += tickMs;
|
||||
elapsedMs += tickMs;
|
||||
|
||||
// 스킬 시스템 시간 업데이트
|
||||
challengerSkillSystem = challengerSkillSystem.copyWith(
|
||||
elapsedMs: elapsedMs,
|
||||
);
|
||||
opponentSkillSystem = opponentSkillSystem.copyWith(elapsedMs: elapsedMs);
|
||||
|
||||
int? challengerDamage;
|
||||
int? opponentDamage;
|
||||
bool isChallengerCritical = false;
|
||||
bool isOpponentCritical = false;
|
||||
bool isChallengerEvaded = false;
|
||||
bool isOpponentEvaded = false;
|
||||
bool isChallengerBlocked = false;
|
||||
bool isOpponentBlocked = false;
|
||||
String? challengerSkillUsed;
|
||||
String? opponentSkillUsed;
|
||||
int? challengerHealAmount;
|
||||
int? opponentHealAmount;
|
||||
|
||||
// DOT 틱 처리
|
||||
final dotResult = _processDotTicks(
|
||||
challengerDoTs: challengerDoTs,
|
||||
opponentDoTs: opponentDoTs,
|
||||
playerStats: playerCombatStats,
|
||||
opponentStats: opponentCombatStats,
|
||||
tickMs: tickMs,
|
||||
);
|
||||
challengerDoTs = dotResult.challengerDoTs;
|
||||
opponentDoTs = dotResult.opponentDoTs;
|
||||
playerCombatStats = dotResult.playerStats;
|
||||
opponentCombatStats = dotResult.opponentStats;
|
||||
|
||||
// 만료된 디버프 정리
|
||||
challengerDebuffs = challengerDebuffs
|
||||
.where((ActiveBuff d) => !d.isExpired(elapsedMs))
|
||||
.toList();
|
||||
opponentDebuffs = opponentDebuffs
|
||||
.where((ActiveBuff d) => !d.isExpired(elapsedMs))
|
||||
.toList();
|
||||
|
||||
// 도전자 턴
|
||||
if (playerAccum >= playerCombatStats.attackDelayMs) {
|
||||
playerAccum = 0;
|
||||
|
||||
var opponentMonsterStats = MonsterCombatStats.fromCombatStats(
|
||||
opponentCombatStats,
|
||||
match.opponent.characterName,
|
||||
);
|
||||
|
||||
final turnResult = _processCharacterTurn(
|
||||
player: playerCombatStats,
|
||||
target: opponentCombatStats,
|
||||
targetMonster: opponentMonsterStats,
|
||||
targetName: match.opponent.characterName,
|
||||
entry: match.challenger,
|
||||
skillIds: challengerSkillIds,
|
||||
skillSystem: challengerSkillSystem,
|
||||
activeDoTs: challengerDoTs,
|
||||
activeDebuffs: opponentDebuffs,
|
||||
calculator: calculator,
|
||||
elapsedMs: elapsedMs,
|
||||
);
|
||||
|
||||
playerCombatStats = turnResult.player;
|
||||
opponentCombatStats = turnResult.target;
|
||||
challengerSkillSystem = turnResult.skillSystem;
|
||||
challengerDoTs = turnResult.activeDoTs;
|
||||
opponentDebuffs = turnResult.targetDebuffs;
|
||||
challengerDamage = turnResult.damage;
|
||||
isChallengerCritical = turnResult.isCritical;
|
||||
isOpponentEvaded = turnResult.isTargetEvaded;
|
||||
challengerSkillUsed = turnResult.skillUsed;
|
||||
challengerHealAmount = turnResult.healAmount;
|
||||
}
|
||||
|
||||
// 상대 턴
|
||||
if (opponentCombatStats.hpCurrent > 0 &&
|
||||
opponentAccum >= opponentCombatStats.attackDelayMs) {
|
||||
opponentAccum = 0;
|
||||
|
||||
var challengerMonsterStats = MonsterCombatStats.fromCombatStats(
|
||||
playerCombatStats,
|
||||
match.challenger.characterName,
|
||||
);
|
||||
|
||||
final turnResult = _processCharacterTurn(
|
||||
player: opponentCombatStats,
|
||||
target: playerCombatStats,
|
||||
targetMonster: challengerMonsterStats,
|
||||
targetName: match.challenger.characterName,
|
||||
entry: match.opponent,
|
||||
skillIds: opponentSkillIds,
|
||||
skillSystem: opponentSkillSystem,
|
||||
activeDoTs: opponentDoTs,
|
||||
activeDebuffs: challengerDebuffs,
|
||||
calculator: calculator,
|
||||
elapsedMs: elapsedMs,
|
||||
);
|
||||
|
||||
opponentCombatStats = turnResult.player;
|
||||
playerCombatStats = turnResult.target;
|
||||
opponentSkillSystem = turnResult.skillSystem;
|
||||
opponentDoTs = turnResult.activeDoTs;
|
||||
challengerDebuffs = turnResult.targetDebuffs;
|
||||
opponentDamage = turnResult.damage;
|
||||
isOpponentCritical = turnResult.isCritical;
|
||||
isChallengerEvaded = turnResult.isTargetEvaded;
|
||||
isChallengerBlocked = turnResult.isTargetBlocked;
|
||||
opponentSkillUsed = turnResult.skillUsed;
|
||||
opponentHealAmount = turnResult.healAmount;
|
||||
}
|
||||
|
||||
// 액션이 발생했을 때만 턴 전송
|
||||
final hasAction =
|
||||
challengerDamage != null ||
|
||||
opponentDamage != null ||
|
||||
challengerHealAmount != null ||
|
||||
opponentHealAmount != null ||
|
||||
challengerSkillUsed != null ||
|
||||
opponentSkillUsed != null;
|
||||
|
||||
if (hasAction) {
|
||||
turns++;
|
||||
yield ArenaCombatTurn(
|
||||
challengerDamage: challengerDamage,
|
||||
opponentDamage: opponentDamage,
|
||||
challengerHp: playerCombatStats.hpCurrent,
|
||||
opponentHp: opponentCombatStats.hpCurrent,
|
||||
challengerHpMax: playerCombatStats.hpMax,
|
||||
opponentHpMax: opponentCombatStats.hpMax,
|
||||
challengerMp: playerCombatStats.mpCurrent,
|
||||
opponentMp: opponentCombatStats.mpCurrent,
|
||||
challengerMpMax: playerCombatStats.mpMax,
|
||||
opponentMpMax: opponentCombatStats.mpMax,
|
||||
isChallengerCritical: isChallengerCritical,
|
||||
isOpponentCritical: isOpponentCritical,
|
||||
isChallengerEvaded: isChallengerEvaded,
|
||||
isOpponentEvaded: isOpponentEvaded,
|
||||
isChallengerBlocked: isChallengerBlocked,
|
||||
isOpponentBlocked: isOpponentBlocked,
|
||||
challengerSkillUsed: challengerSkillUsed,
|
||||
opponentSkillUsed: opponentSkillUsed,
|
||||
challengerHealAmount: challengerHealAmount,
|
||||
opponentHealAmount: opponentHealAmount,
|
||||
);
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
|
||||
if (turns > 1000) break;
|
||||
}
|
||||
}
|
||||
|
||||
/// DOT 틱 처리 (양측)
|
||||
({
|
||||
List<DotEffect> challengerDoTs,
|
||||
List<DotEffect> opponentDoTs,
|
||||
CombatStats playerStats,
|
||||
CombatStats opponentStats,
|
||||
})
|
||||
_processDotTicks({
|
||||
required List<DotEffect> challengerDoTs,
|
||||
required List<DotEffect> opponentDoTs,
|
||||
required CombatStats playerStats,
|
||||
required CombatStats opponentStats,
|
||||
required int tickMs,
|
||||
}) {
|
||||
var updatedPlayerStats = playerStats;
|
||||
var updatedOpponentStats = opponentStats;
|
||||
|
||||
// 도전자 -> 상대에게 적용된 DOT
|
||||
var dotDamageToOpponent = 0;
|
||||
final updatedChallengerDoTs = <DotEffect>[];
|
||||
for (final dot in challengerDoTs) {
|
||||
final (updatedDot, ticksTriggered) = dot.tick(tickMs);
|
||||
if (ticksTriggered > 0) {
|
||||
dotDamageToOpponent += dot.damagePerTick * ticksTriggered;
|
||||
}
|
||||
if (updatedDot.isActive) updatedChallengerDoTs.add(updatedDot);
|
||||
}
|
||||
if (dotDamageToOpponent > 0 && updatedOpponentStats.hpCurrent > 0) {
|
||||
updatedOpponentStats = updatedOpponentStats.copyWith(
|
||||
hpCurrent: (updatedOpponentStats.hpCurrent - dotDamageToOpponent).clamp(
|
||||
0,
|
||||
updatedOpponentStats.hpMax,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 상대 -> 도전자에게 적용된 DOT
|
||||
var dotDamageToChallenger = 0;
|
||||
final updatedOpponentDoTs = <DotEffect>[];
|
||||
for (final dot in opponentDoTs) {
|
||||
final (updatedDot, ticksTriggered) = dot.tick(tickMs);
|
||||
if (ticksTriggered > 0) {
|
||||
dotDamageToChallenger += dot.damagePerTick * ticksTriggered;
|
||||
}
|
||||
if (updatedDot.isActive) updatedOpponentDoTs.add(updatedDot);
|
||||
}
|
||||
if (dotDamageToChallenger > 0 && updatedPlayerStats.isAlive) {
|
||||
updatedPlayerStats = updatedPlayerStats.copyWith(
|
||||
hpCurrent: (updatedPlayerStats.hpCurrent - dotDamageToChallenger).clamp(
|
||||
0,
|
||||
updatedPlayerStats.hpMax,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
challengerDoTs: updatedChallengerDoTs,
|
||||
opponentDoTs: updatedOpponentDoTs,
|
||||
playerStats: updatedPlayerStats,
|
||||
opponentStats: updatedOpponentStats,
|
||||
);
|
||||
}
|
||||
|
||||
/// 캐릭터 턴 처리 (도전자/상대 공통)
|
||||
({
|
||||
CombatStats player,
|
||||
CombatStats target,
|
||||
SkillSystemState skillSystem,
|
||||
List<DotEffect> activeDoTs,
|
||||
List<ActiveBuff> targetDebuffs,
|
||||
int? damage,
|
||||
bool isCritical,
|
||||
bool isTargetEvaded,
|
||||
bool isTargetBlocked,
|
||||
String? skillUsed,
|
||||
int? healAmount,
|
||||
})
|
||||
_processCharacterTurn({
|
||||
required CombatStats player,
|
||||
required CombatStats target,
|
||||
required MonsterCombatStats targetMonster,
|
||||
required String targetName,
|
||||
required HallOfFameEntry entry,
|
||||
required List<String> skillIds,
|
||||
required SkillSystemState skillSystem,
|
||||
required List<DotEffect> activeDoTs,
|
||||
required List<ActiveBuff> activeDebuffs,
|
||||
required CombatCalculator calculator,
|
||||
required int elapsedMs,
|
||||
}) {
|
||||
int? damage;
|
||||
bool isCritical = false;
|
||||
bool isTargetEvaded = false;
|
||||
bool isTargetBlocked = false;
|
||||
String? skillUsed;
|
||||
int? healAmount;
|
||||
var updatedPlayer = player;
|
||||
var updatedTarget = target;
|
||||
var updatedSkillSystem = skillSystem;
|
||||
var updatedDoTs = [...activeDoTs];
|
||||
var updatedDebuffs = [...activeDebuffs];
|
||||
|
||||
final selectedSkill = _skillService.selectAutoSkill(
|
||||
player: updatedPlayer,
|
||||
monster: targetMonster,
|
||||
skillSystem: updatedSkillSystem,
|
||||
availableSkillIds: skillIds,
|
||||
activeDoTs: updatedDoTs,
|
||||
activeDebuffs: updatedDebuffs,
|
||||
);
|
||||
|
||||
if (selectedSkill != null && selectedSkill.isAttack) {
|
||||
final skillRank = _getSkillRankFromEntry(entry, selectedSkill.id);
|
||||
final skillResult = _skillService.useAttackSkillWithRank(
|
||||
skill: selectedSkill,
|
||||
player: updatedPlayer,
|
||||
monster: targetMonster,
|
||||
skillSystem: updatedSkillSystem,
|
||||
rank: skillRank,
|
||||
);
|
||||
updatedPlayer = skillResult.updatedPlayer;
|
||||
updatedTarget = updatedTarget.copyWith(
|
||||
hpCurrent: skillResult.updatedMonster.hpCurrent,
|
||||
);
|
||||
updatedSkillSystem = skillResult.updatedSkillSystem;
|
||||
skillUsed = selectedSkill.name;
|
||||
damage = skillResult.result.damage;
|
||||
} else if (selectedSkill != null && selectedSkill.isDot) {
|
||||
final skillResult = _skillService.useDotSkill(
|
||||
skill: selectedSkill,
|
||||
player: updatedPlayer,
|
||||
skillSystem: updatedSkillSystem,
|
||||
playerInt: updatedPlayer.atk ~/ 10,
|
||||
playerWis: updatedPlayer.def ~/ 10,
|
||||
);
|
||||
updatedPlayer = skillResult.updatedPlayer;
|
||||
updatedSkillSystem = skillResult.updatedSkillSystem;
|
||||
if (skillResult.dotEffect != null) {
|
||||
updatedDoTs.add(skillResult.dotEffect!);
|
||||
}
|
||||
skillUsed = selectedSkill.name;
|
||||
} else if (selectedSkill != null && selectedSkill.isHeal) {
|
||||
final skillResult = _skillService.useHealSkill(
|
||||
skill: selectedSkill,
|
||||
player: updatedPlayer,
|
||||
skillSystem: updatedSkillSystem,
|
||||
);
|
||||
updatedPlayer = skillResult.updatedPlayer;
|
||||
updatedSkillSystem = skillResult.updatedSkillSystem;
|
||||
skillUsed = selectedSkill.name;
|
||||
healAmount = skillResult.result.healedAmount;
|
||||
} else if (selectedSkill != null && selectedSkill.isBuff) {
|
||||
final skillResult = _skillService.useBuffSkill(
|
||||
skill: selectedSkill,
|
||||
player: updatedPlayer,
|
||||
skillSystem: updatedSkillSystem,
|
||||
);
|
||||
updatedPlayer = skillResult.updatedPlayer;
|
||||
updatedSkillSystem = skillResult.updatedSkillSystem;
|
||||
skillUsed = selectedSkill.name;
|
||||
} else if (selectedSkill != null && selectedSkill.isDebuff) {
|
||||
final skillResult = _skillService.useDebuffSkill(
|
||||
skill: selectedSkill,
|
||||
player: updatedPlayer,
|
||||
skillSystem: updatedSkillSystem,
|
||||
currentDebuffs: updatedDebuffs,
|
||||
);
|
||||
updatedPlayer = skillResult.updatedPlayer;
|
||||
updatedSkillSystem = skillResult.updatedSkillSystem;
|
||||
final debuffEffect = skillResult.debuffEffect;
|
||||
if (debuffEffect != null) {
|
||||
updatedDebuffs =
|
||||
updatedDebuffs
|
||||
.where((ActiveBuff d) => d.effect.id != debuffEffect.effect.id)
|
||||
.toList()
|
||||
..add(debuffEffect);
|
||||
}
|
||||
skillUsed = selectedSkill.name;
|
||||
} else {
|
||||
// 일반 공격
|
||||
final opponentMonsterStats = MonsterCombatStats.fromCombatStats(
|
||||
updatedTarget,
|
||||
targetName,
|
||||
);
|
||||
final result = calculator.playerAttackMonster(
|
||||
attacker: updatedPlayer,
|
||||
defender: opponentMonsterStats,
|
||||
);
|
||||
updatedTarget = updatedTarget.copyWith(
|
||||
hpCurrent: result.updatedDefender.hpCurrent,
|
||||
);
|
||||
|
||||
if (result.result.isHit) {
|
||||
damage = result.result.damage;
|
||||
isCritical = result.result.isCritical;
|
||||
} else {
|
||||
isTargetEvaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
player: updatedPlayer,
|
||||
target: updatedTarget,
|
||||
skillSystem: updatedSkillSystem,
|
||||
activeDoTs: updatedDoTs,
|
||||
targetDebuffs: updatedDebuffs,
|
||||
damage: damage,
|
||||
isCritical: isCritical,
|
||||
isTargetEvaded: isTargetEvaded,
|
||||
isTargetBlocked: isTargetBlocked,
|
||||
skillUsed: skillUsed,
|
||||
healAmount: healAmount,
|
||||
);
|
||||
}
|
||||
|
||||
/// 스킬 ID 목록 추출 (HallOfFameEntry에서)
|
||||
List<String> _getSkillIdsFromEntry(HallOfFameEntry entry) {
|
||||
final skillData = entry.finalSkills;
|
||||
if (skillData == null || skillData.isEmpty) return [];
|
||||
|
||||
final skillIds = <String>[];
|
||||
for (final data in skillData) {
|
||||
final skillName = data['name'];
|
||||
if (skillName != null) {
|
||||
final skill = SkillData.getSkillBySpellName(skillName);
|
||||
if (skill != null) {
|
||||
skillIds.add(skill.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return skillIds;
|
||||
}
|
||||
|
||||
/// 스킬 랭크 조회 (HallOfFameEntry의 finalSkills에서)
|
||||
int _getSkillRankFromEntry(HallOfFameEntry entry, String skillId) {
|
||||
final skill = SkillData.getSkillById(skillId);
|
||||
if (skill == null) return 1;
|
||||
|
||||
final skillData = entry.finalSkills;
|
||||
if (skillData == null || skillData.isEmpty) return 1;
|
||||
|
||||
for (final data in skillData) {
|
||||
if (data['name'] == skill.name) {
|
||||
final rankStr = data['rank'] ?? 'I';
|
||||
return switch (rankStr) {
|
||||
'I' => 1,
|
||||
'II' => 2,
|
||||
'III' => 3,
|
||||
'IV' => 4,
|
||||
'V' => 5,
|
||||
_ => 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,11 @@
|
||||
import 'package:asciineverdie/data/skill_data.dart';
|
||||
import 'package:asciineverdie/src/core/engine/arena_combat_simulator.dart';
|
||||
import 'package:asciineverdie/src/core/engine/combat_calculator.dart';
|
||||
import 'package:asciineverdie/src/core/engine/item_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/skill_service.dart';
|
||||
import 'package:asciineverdie/src/core/model/arena_match.dart';
|
||||
import 'package:asciineverdie/src/core/model/equipment_item.dart';
|
||||
import 'package:asciineverdie/src/core/model/equipment_slot.dart';
|
||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||
import 'package:asciineverdie/src/core/model/hall_of_fame.dart';
|
||||
import 'package:asciineverdie/src/core/model/monster_combat_stats.dart';
|
||||
import 'package:asciineverdie/src/core/model/skill.dart';
|
||||
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
|
||||
|
||||
/// 아레나 서비스
|
||||
@@ -23,64 +20,6 @@ class ArenaService {
|
||||
|
||||
final DeterministicRandom _rng;
|
||||
|
||||
late final SkillService _skillService = SkillService(rng: _rng);
|
||||
|
||||
// ============================================================================
|
||||
// 스킬 시스템 헬퍼
|
||||
// ============================================================================
|
||||
|
||||
/// HallOfFameEntry의 finalSkills에서 Skill 목록 추출
|
||||
List<Skill> _getSkillsFromEntry(HallOfFameEntry entry) {
|
||||
final skillData = entry.finalSkills;
|
||||
if (skillData == null || skillData.isEmpty) return [];
|
||||
|
||||
final skills = <Skill>[];
|
||||
for (final data in skillData) {
|
||||
final skillName = data['name'];
|
||||
if (skillName != null) {
|
||||
final skill = SkillData.getSkillBySpellName(skillName);
|
||||
if (skill != null) {
|
||||
skills.add(skill);
|
||||
}
|
||||
}
|
||||
}
|
||||
return skills;
|
||||
}
|
||||
|
||||
/// 스킬 ID 목록 추출 (HallOfFameEntry에서)
|
||||
List<String> _getSkillIdsFromEntry(HallOfFameEntry entry) {
|
||||
return _getSkillsFromEntry(entry).map((s) => s.id).toList();
|
||||
}
|
||||
|
||||
/// 스킬 랭크 조회 (HallOfFameEntry의 finalSkills에서)
|
||||
int _getSkillRankFromEntry(HallOfFameEntry entry, String skillId) {
|
||||
final skill = SkillData.getSkillById(skillId);
|
||||
if (skill == null) return 1;
|
||||
|
||||
final skillData = entry.finalSkills;
|
||||
if (skillData == null || skillData.isEmpty) return 1;
|
||||
|
||||
for (final data in skillData) {
|
||||
if (data['name'] == skill.name) {
|
||||
final rankStr = data['rank'] ?? 'I';
|
||||
return _romanToInt(rankStr);
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
/// 로마 숫자 → 정수 변환
|
||||
int _romanToInt(String roman) {
|
||||
return switch (roman) {
|
||||
'I' => 1,
|
||||
'II' => 2,
|
||||
'III' => 3,
|
||||
'IV' => 4,
|
||||
'V' => 5,
|
||||
_ => 1,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 상대 결정
|
||||
// ============================================================================
|
||||
@@ -230,452 +169,10 @@ class ArenaService {
|
||||
|
||||
/// 전투 시뮬레이션 (애니메이션용 스트림)
|
||||
///
|
||||
/// progress_service._processCombatTickWithSkills()와 동일한 로직 사용
|
||||
/// [match] 대전 정보
|
||||
/// Returns: 턴별 전투 상황 스트림
|
||||
Stream<ArenaCombatTurn> simulateCombat(ArenaMatch match) async* {
|
||||
final calculator = CombatCalculator(rng: _rng);
|
||||
|
||||
final challengerStats = match.challenger.finalStats;
|
||||
final opponentStats = match.opponent.finalStats;
|
||||
|
||||
if (challengerStats == null || opponentStats == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 스킬 ID 목록 로드 (SkillBook과 동일한 방식)
|
||||
var challengerSkillIds = _getSkillIdsFromEntry(match.challenger);
|
||||
var opponentSkillIds = _getSkillIdsFromEntry(match.opponent);
|
||||
|
||||
// 스킬이 없으면 기본 스킬 사용
|
||||
if (challengerSkillIds.isEmpty) {
|
||||
challengerSkillIds = SkillData.defaultSkillIds;
|
||||
}
|
||||
if (opponentSkillIds.isEmpty) {
|
||||
opponentSkillIds = SkillData.defaultSkillIds;
|
||||
}
|
||||
|
||||
// 스킬 시스템 상태 초기화
|
||||
var challengerSkillSystem = SkillSystemState.empty();
|
||||
var opponentSkillSystem = SkillSystemState.empty();
|
||||
|
||||
// DOT 및 디버프 추적 (일반 전투와 동일)
|
||||
var challengerDoTs = <DotEffect>[];
|
||||
var opponentDoTs = <DotEffect>[];
|
||||
var challengerDebuffs = <ActiveBuff>[];
|
||||
var opponentDebuffs = <ActiveBuff>[];
|
||||
|
||||
var playerCombatStats = challengerStats.copyWith(
|
||||
hpCurrent: challengerStats.hpMax,
|
||||
mpCurrent: challengerStats.mpMax,
|
||||
);
|
||||
|
||||
var opponentCombatStats = opponentStats.copyWith(
|
||||
hpCurrent: opponentStats.hpMax,
|
||||
mpCurrent: opponentStats.mpMax,
|
||||
);
|
||||
|
||||
var opponentMonsterStats = MonsterCombatStats.fromCombatStats(
|
||||
opponentCombatStats,
|
||||
match.opponent.characterName,
|
||||
);
|
||||
|
||||
var challengerMonsterStats = MonsterCombatStats.fromCombatStats(
|
||||
playerCombatStats,
|
||||
match.challenger.characterName,
|
||||
);
|
||||
|
||||
int playerAccum = 0;
|
||||
int opponentAccum = 0;
|
||||
int elapsedMs = 0;
|
||||
const tickMs = 200;
|
||||
int turns = 0;
|
||||
|
||||
// 초기 상태 전송
|
||||
yield ArenaCombatTurn(
|
||||
challengerHp: playerCombatStats.hpCurrent,
|
||||
opponentHp: opponentCombatStats.hpCurrent,
|
||||
challengerHpMax: playerCombatStats.hpMax,
|
||||
opponentHpMax: opponentCombatStats.hpMax,
|
||||
challengerMp: playerCombatStats.mpCurrent,
|
||||
opponentMp: opponentCombatStats.mpCurrent,
|
||||
challengerMpMax: playerCombatStats.mpMax,
|
||||
opponentMpMax: opponentCombatStats.mpMax,
|
||||
);
|
||||
|
||||
while (playerCombatStats.isAlive && opponentCombatStats.hpCurrent > 0) {
|
||||
playerAccum += tickMs;
|
||||
opponentAccum += tickMs;
|
||||
elapsedMs += tickMs;
|
||||
|
||||
// 스킬 시스템 시간 업데이트
|
||||
challengerSkillSystem = challengerSkillSystem.copyWith(
|
||||
elapsedMs: elapsedMs,
|
||||
);
|
||||
opponentSkillSystem = opponentSkillSystem.copyWith(elapsedMs: elapsedMs);
|
||||
|
||||
int? challengerDamage;
|
||||
int? opponentDamage;
|
||||
bool isChallengerCritical = false;
|
||||
bool isOpponentCritical = false;
|
||||
bool isChallengerEvaded = false;
|
||||
bool isOpponentEvaded = false;
|
||||
bool isChallengerBlocked = false;
|
||||
bool isOpponentBlocked = false;
|
||||
String? challengerSkillUsed;
|
||||
String? opponentSkillUsed;
|
||||
int? challengerHealAmount;
|
||||
int? opponentHealAmount;
|
||||
|
||||
// =========================================================================
|
||||
// DOT 틱 처리 (도전자 → 상대에게 적용된 DOT)
|
||||
// =========================================================================
|
||||
var dotDamageToOpponent = 0;
|
||||
final updatedChallengerDoTs = <DotEffect>[];
|
||||
for (final dot in challengerDoTs) {
|
||||
final (updatedDot, ticksTriggered) = dot.tick(tickMs);
|
||||
if (ticksTriggered > 0) {
|
||||
dotDamageToOpponent += dot.damagePerTick * ticksTriggered;
|
||||
}
|
||||
if (updatedDot.isActive) {
|
||||
updatedChallengerDoTs.add(updatedDot);
|
||||
}
|
||||
}
|
||||
challengerDoTs = updatedChallengerDoTs;
|
||||
|
||||
if (dotDamageToOpponent > 0 && opponentCombatStats.hpCurrent > 0) {
|
||||
opponentCombatStats = opponentCombatStats.copyWith(
|
||||
hpCurrent: (opponentCombatStats.hpCurrent - dotDamageToOpponent)
|
||||
.clamp(0, opponentCombatStats.hpMax),
|
||||
);
|
||||
}
|
||||
|
||||
// DOT 틱 처리 (상대 → 도전자에게 적용된 DOT)
|
||||
var dotDamageToChallenger = 0;
|
||||
final updatedOpponentDoTs = <DotEffect>[];
|
||||
for (final dot in opponentDoTs) {
|
||||
final (updatedDot, ticksTriggered) = dot.tick(tickMs);
|
||||
if (ticksTriggered > 0) {
|
||||
dotDamageToChallenger += dot.damagePerTick * ticksTriggered;
|
||||
}
|
||||
if (updatedDot.isActive) {
|
||||
updatedOpponentDoTs.add(updatedDot);
|
||||
}
|
||||
}
|
||||
opponentDoTs = updatedOpponentDoTs;
|
||||
|
||||
if (dotDamageToChallenger > 0 && playerCombatStats.isAlive) {
|
||||
playerCombatStats = playerCombatStats.copyWith(
|
||||
hpCurrent: (playerCombatStats.hpCurrent - dotDamageToChallenger)
|
||||
.clamp(0, playerCombatStats.hpMax),
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 만료된 디버프 정리
|
||||
// =========================================================================
|
||||
challengerDebuffs = challengerDebuffs
|
||||
.where((ActiveBuff d) => !d.isExpired(elapsedMs))
|
||||
.toList();
|
||||
opponentDebuffs = opponentDebuffs
|
||||
.where((ActiveBuff d) => !d.isExpired(elapsedMs))
|
||||
.toList();
|
||||
|
||||
// =========================================================================
|
||||
// 도전자 턴 (selectAutoSkill 사용 - 일반 전투와 동일)
|
||||
// =========================================================================
|
||||
if (playerAccum >= playerCombatStats.attackDelayMs) {
|
||||
playerAccum = 0;
|
||||
|
||||
// 상대 몬스터 스탯 동기화
|
||||
opponentMonsterStats = MonsterCombatStats.fromCombatStats(
|
||||
opponentCombatStats,
|
||||
match.opponent.characterName,
|
||||
);
|
||||
|
||||
// 스킬 자동 선택 (progress_service와 동일한 로직)
|
||||
final selectedSkill = _skillService.selectAutoSkill(
|
||||
player: playerCombatStats,
|
||||
monster: opponentMonsterStats,
|
||||
skillSystem: challengerSkillSystem,
|
||||
availableSkillIds: challengerSkillIds,
|
||||
activeDoTs: challengerDoTs,
|
||||
activeDebuffs: opponentDebuffs,
|
||||
);
|
||||
|
||||
if (selectedSkill != null && selectedSkill.isAttack) {
|
||||
// 스킬 랭크 조회 및 적용
|
||||
final skillRank = _getSkillRankFromEntry(
|
||||
match.challenger,
|
||||
selectedSkill.id,
|
||||
);
|
||||
final skillResult = _skillService.useAttackSkillWithRank(
|
||||
skill: selectedSkill,
|
||||
player: playerCombatStats,
|
||||
monster: opponentMonsterStats,
|
||||
skillSystem: challengerSkillSystem,
|
||||
rank: skillRank,
|
||||
);
|
||||
playerCombatStats = skillResult.updatedPlayer;
|
||||
opponentCombatStats = opponentCombatStats.copyWith(
|
||||
hpCurrent: skillResult.updatedMonster.hpCurrent,
|
||||
);
|
||||
challengerSkillSystem = skillResult.updatedSkillSystem;
|
||||
challengerSkillUsed = selectedSkill.name;
|
||||
challengerDamage = skillResult.result.damage;
|
||||
} else if (selectedSkill != null && selectedSkill.isDot) {
|
||||
// DOT 스킬 사용
|
||||
final skillResult = _skillService.useDotSkill(
|
||||
skill: selectedSkill,
|
||||
player: playerCombatStats,
|
||||
skillSystem: challengerSkillSystem,
|
||||
playerInt: playerCombatStats.atk ~/ 10,
|
||||
playerWis: playerCombatStats.def ~/ 10,
|
||||
);
|
||||
playerCombatStats = skillResult.updatedPlayer;
|
||||
challengerSkillSystem = skillResult.updatedSkillSystem;
|
||||
if (skillResult.dotEffect != null) {
|
||||
challengerDoTs.add(skillResult.dotEffect!);
|
||||
}
|
||||
challengerSkillUsed = selectedSkill.name;
|
||||
} else if (selectedSkill != null && selectedSkill.isHeal) {
|
||||
// 회복 스킬 사용
|
||||
final skillResult = _skillService.useHealSkill(
|
||||
skill: selectedSkill,
|
||||
player: playerCombatStats,
|
||||
skillSystem: challengerSkillSystem,
|
||||
);
|
||||
playerCombatStats = skillResult.updatedPlayer;
|
||||
challengerSkillSystem = skillResult.updatedSkillSystem;
|
||||
challengerSkillUsed = selectedSkill.name;
|
||||
challengerHealAmount = skillResult.result.healedAmount;
|
||||
} else if (selectedSkill != null && selectedSkill.isBuff) {
|
||||
// 버프 스킬 사용
|
||||
final skillResult = _skillService.useBuffSkill(
|
||||
skill: selectedSkill,
|
||||
player: playerCombatStats,
|
||||
skillSystem: challengerSkillSystem,
|
||||
);
|
||||
playerCombatStats = skillResult.updatedPlayer;
|
||||
challengerSkillSystem = skillResult.updatedSkillSystem;
|
||||
challengerSkillUsed = selectedSkill.name;
|
||||
} else if (selectedSkill != null && selectedSkill.isDebuff) {
|
||||
// 디버프 스킬 사용
|
||||
final skillResult = _skillService.useDebuffSkill(
|
||||
skill: selectedSkill,
|
||||
player: playerCombatStats,
|
||||
skillSystem: challengerSkillSystem,
|
||||
currentDebuffs: opponentDebuffs,
|
||||
);
|
||||
playerCombatStats = skillResult.updatedPlayer;
|
||||
challengerSkillSystem = skillResult.updatedSkillSystem;
|
||||
final debuffEffect = skillResult.debuffEffect;
|
||||
if (debuffEffect != null) {
|
||||
opponentDebuffs =
|
||||
opponentDebuffs
|
||||
.where(
|
||||
(ActiveBuff d) => d.effect.id != debuffEffect.effect.id,
|
||||
)
|
||||
.toList()
|
||||
..add(debuffEffect);
|
||||
}
|
||||
challengerSkillUsed = selectedSkill.name;
|
||||
} else {
|
||||
// 일반 공격
|
||||
final result = calculator.playerAttackMonster(
|
||||
attacker: playerCombatStats,
|
||||
defender: opponentMonsterStats,
|
||||
);
|
||||
opponentCombatStats = opponentCombatStats.copyWith(
|
||||
hpCurrent: result.updatedDefender.hpCurrent,
|
||||
);
|
||||
|
||||
if (result.result.isHit) {
|
||||
challengerDamage = result.result.damage;
|
||||
isChallengerCritical = result.result.isCritical;
|
||||
} else {
|
||||
isOpponentEvaded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 상대 턴 (selectAutoSkill 사용 - 일반 전투와 동일)
|
||||
// =========================================================================
|
||||
if (opponentCombatStats.hpCurrent > 0 &&
|
||||
opponentAccum >= opponentCombatStats.attackDelayMs) {
|
||||
opponentAccum = 0;
|
||||
|
||||
// 도전자 몬스터 스탯 동기화
|
||||
challengerMonsterStats = MonsterCombatStats.fromCombatStats(
|
||||
playerCombatStats,
|
||||
match.challenger.characterName,
|
||||
);
|
||||
|
||||
// 스킬 자동 선택 (progress_service와 동일한 로직)
|
||||
final selectedSkill = _skillService.selectAutoSkill(
|
||||
player: opponentCombatStats,
|
||||
monster: challengerMonsterStats,
|
||||
skillSystem: opponentSkillSystem,
|
||||
availableSkillIds: opponentSkillIds,
|
||||
activeDoTs: opponentDoTs,
|
||||
activeDebuffs: challengerDebuffs,
|
||||
);
|
||||
|
||||
if (selectedSkill != null && selectedSkill.isAttack) {
|
||||
// 스킬 랭크 조회 및 적용
|
||||
final skillRank = _getSkillRankFromEntry(
|
||||
match.opponent,
|
||||
selectedSkill.id,
|
||||
);
|
||||
final skillResult = _skillService.useAttackSkillWithRank(
|
||||
skill: selectedSkill,
|
||||
player: opponentCombatStats,
|
||||
monster: challengerMonsterStats,
|
||||
skillSystem: opponentSkillSystem,
|
||||
rank: skillRank,
|
||||
);
|
||||
opponentCombatStats = skillResult.updatedPlayer;
|
||||
playerCombatStats = playerCombatStats.copyWith(
|
||||
hpCurrent: skillResult.updatedMonster.hpCurrent,
|
||||
);
|
||||
opponentSkillSystem = skillResult.updatedSkillSystem;
|
||||
opponentSkillUsed = selectedSkill.name;
|
||||
opponentDamage = skillResult.result.damage;
|
||||
} else if (selectedSkill != null && selectedSkill.isDot) {
|
||||
// DOT 스킬 사용
|
||||
final skillResult = _skillService.useDotSkill(
|
||||
skill: selectedSkill,
|
||||
player: opponentCombatStats,
|
||||
skillSystem: opponentSkillSystem,
|
||||
playerInt: opponentCombatStats.atk ~/ 10,
|
||||
playerWis: opponentCombatStats.def ~/ 10,
|
||||
);
|
||||
opponentCombatStats = skillResult.updatedPlayer;
|
||||
opponentSkillSystem = skillResult.updatedSkillSystem;
|
||||
if (skillResult.dotEffect != null) {
|
||||
opponentDoTs.add(skillResult.dotEffect!);
|
||||
}
|
||||
opponentSkillUsed = selectedSkill.name;
|
||||
} else if (selectedSkill != null && selectedSkill.isHeal) {
|
||||
// 회복 스킬 사용
|
||||
final skillResult = _skillService.useHealSkill(
|
||||
skill: selectedSkill,
|
||||
player: opponentCombatStats,
|
||||
skillSystem: opponentSkillSystem,
|
||||
);
|
||||
opponentCombatStats = skillResult.updatedPlayer;
|
||||
opponentSkillSystem = skillResult.updatedSkillSystem;
|
||||
opponentSkillUsed = selectedSkill.name;
|
||||
opponentHealAmount = skillResult.result.healedAmount;
|
||||
} else if (selectedSkill != null && selectedSkill.isBuff) {
|
||||
// 버프 스킬 사용
|
||||
final skillResult = _skillService.useBuffSkill(
|
||||
skill: selectedSkill,
|
||||
player: opponentCombatStats,
|
||||
skillSystem: opponentSkillSystem,
|
||||
);
|
||||
opponentCombatStats = skillResult.updatedPlayer;
|
||||
opponentSkillSystem = skillResult.updatedSkillSystem;
|
||||
opponentSkillUsed = selectedSkill.name;
|
||||
} else if (selectedSkill != null && selectedSkill.isDebuff) {
|
||||
// 디버프 스킬 사용
|
||||
final skillResult = _skillService.useDebuffSkill(
|
||||
skill: selectedSkill,
|
||||
player: opponentCombatStats,
|
||||
skillSystem: opponentSkillSystem,
|
||||
currentDebuffs: challengerDebuffs,
|
||||
);
|
||||
opponentCombatStats = skillResult.updatedPlayer;
|
||||
opponentSkillSystem = skillResult.updatedSkillSystem;
|
||||
final debuffEffect = skillResult.debuffEffect;
|
||||
if (debuffEffect != null) {
|
||||
challengerDebuffs =
|
||||
challengerDebuffs
|
||||
.where(
|
||||
(ActiveBuff d) => d.effect.id != debuffEffect.effect.id,
|
||||
)
|
||||
.toList()
|
||||
..add(debuffEffect);
|
||||
}
|
||||
opponentSkillUsed = selectedSkill.name;
|
||||
} else {
|
||||
// 일반 공격 (디버프 효과 적용)
|
||||
var debuffedOpponent = opponentCombatStats;
|
||||
if (challengerDebuffs.isNotEmpty) {
|
||||
double atkMod = 0;
|
||||
for (final debuff in challengerDebuffs) {
|
||||
if (!debuff.isExpired(elapsedMs)) {
|
||||
atkMod += debuff.effect.atkModifier;
|
||||
}
|
||||
}
|
||||
final newAtk = (opponentCombatStats.atk * (1 + atkMod))
|
||||
.round()
|
||||
.clamp(opponentCombatStats.atk ~/ 10, opponentCombatStats.atk);
|
||||
debuffedOpponent = opponentCombatStats.copyWith(atk: newAtk);
|
||||
}
|
||||
|
||||
opponentMonsterStats = MonsterCombatStats.fromCombatStats(
|
||||
debuffedOpponent,
|
||||
match.opponent.characterName,
|
||||
);
|
||||
final result = calculator.monsterAttackPlayer(
|
||||
attacker: opponentMonsterStats,
|
||||
defender: playerCombatStats,
|
||||
);
|
||||
playerCombatStats = result.updatedDefender;
|
||||
|
||||
if (result.result.isHit) {
|
||||
opponentDamage = result.result.damage;
|
||||
isOpponentCritical = result.result.isCritical;
|
||||
isChallengerBlocked = result.result.isBlocked;
|
||||
} else {
|
||||
isChallengerEvaded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 액션이 발생했을 때만 턴 전송
|
||||
final hasAction =
|
||||
challengerDamage != null ||
|
||||
opponentDamage != null ||
|
||||
challengerHealAmount != null ||
|
||||
opponentHealAmount != null ||
|
||||
challengerSkillUsed != null ||
|
||||
opponentSkillUsed != null;
|
||||
|
||||
if (hasAction) {
|
||||
turns++;
|
||||
yield ArenaCombatTurn(
|
||||
challengerDamage: challengerDamage,
|
||||
opponentDamage: opponentDamage,
|
||||
challengerHp: playerCombatStats.hpCurrent,
|
||||
opponentHp: opponentCombatStats.hpCurrent,
|
||||
challengerHpMax: playerCombatStats.hpMax,
|
||||
opponentHpMax: opponentCombatStats.hpMax,
|
||||
challengerMp: playerCombatStats.mpCurrent,
|
||||
opponentMp: opponentCombatStats.mpCurrent,
|
||||
challengerMpMax: playerCombatStats.mpMax,
|
||||
opponentMpMax: opponentCombatStats.mpMax,
|
||||
isChallengerCritical: isChallengerCritical,
|
||||
isOpponentCritical: isOpponentCritical,
|
||||
isChallengerEvaded: isChallengerEvaded,
|
||||
isOpponentEvaded: isOpponentEvaded,
|
||||
isChallengerBlocked: isChallengerBlocked,
|
||||
isOpponentBlocked: isOpponentBlocked,
|
||||
challengerSkillUsed: challengerSkillUsed,
|
||||
opponentSkillUsed: opponentSkillUsed,
|
||||
challengerHealAmount: challengerHealAmount,
|
||||
opponentHealAmount: opponentHealAmount,
|
||||
);
|
||||
|
||||
// 애니메이션을 위한 딜레이
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
|
||||
// 무한 루프 방지
|
||||
if (turns > 1000) break;
|
||||
}
|
||||
/// ArenaCombatSimulator에 위임하여 턴별 전투 상황을 스트림으로 반환.
|
||||
Stream<ArenaCombatTurn> simulateCombat(ArenaMatch match) {
|
||||
final simulator = ArenaCombatSimulator(rng: _rng);
|
||||
return simulator.simulateCombat(match);
|
||||
}
|
||||
// ============================================================================
|
||||
// AI 베팅 슬롯 선택
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:asciineverdie/data/class_data.dart';
|
||||
import 'package:asciineverdie/data/skill_data.dart';
|
||||
import 'package:asciineverdie/src/core/engine/combat_calculator.dart';
|
||||
import 'package:asciineverdie/src/core/model/class_traits.dart';
|
||||
import 'package:asciineverdie/src/core/engine/player_attack_processor.dart';
|
||||
import 'package:asciineverdie/src/core/engine/potion_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/skill_service.dart';
|
||||
import 'package:asciineverdie/src/core/model/combat_event.dart';
|
||||
@@ -126,7 +127,8 @@ class CombatTickService {
|
||||
|
||||
// 플레이어 공격 체크
|
||||
if (playerAccumulator >= playerStats.attackDelayMs) {
|
||||
final attackResult = _processPlayerAttack(
|
||||
final attackProcessor = PlayerAttackProcessor(rng: rng);
|
||||
final attackResult = attackProcessor.processAttack(
|
||||
state: state,
|
||||
playerStats: playerStats,
|
||||
monsterStats: monsterStats,
|
||||
@@ -363,249 +365,6 @@ class CombatTickService {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 플레이어 공격 처리
|
||||
({
|
||||
CombatStats playerStats,
|
||||
MonsterCombatStats monsterStats,
|
||||
SkillSystemState skillSystem,
|
||||
List<DotEffect> activeDoTs,
|
||||
List<ActiveBuff> activeDebuffs,
|
||||
int totalDamageDealt,
|
||||
List<CombatEvent> events,
|
||||
bool isFirstPlayerAttack,
|
||||
})
|
||||
_processPlayerAttack({
|
||||
required GameState state,
|
||||
required CombatStats playerStats,
|
||||
required MonsterCombatStats monsterStats,
|
||||
required SkillSystemState updatedSkillSystem,
|
||||
required List<DotEffect> activeDoTs,
|
||||
required List<ActiveBuff> activeDebuffs,
|
||||
required int totalDamageDealt,
|
||||
required int timestamp,
|
||||
required CombatCalculator calculator,
|
||||
required SkillService skillService,
|
||||
required bool isFirstPlayerAttack,
|
||||
required double firstStrikeBonus,
|
||||
required bool hasMultiAttack,
|
||||
double healingMultiplier = 1.0,
|
||||
}) {
|
||||
final events = <CombatEvent>[];
|
||||
var newPlayerStats = playerStats;
|
||||
var newMonsterStats = monsterStats;
|
||||
var newSkillSystem = updatedSkillSystem;
|
||||
var newActiveDoTs = [...activeDoTs];
|
||||
var newActiveBuffs = [...activeDebuffs];
|
||||
var newTotalDamageDealt = totalDamageDealt;
|
||||
|
||||
// 장착된 스킬 슬롯에서 사용 가능한 스킬 ID 목록 조회
|
||||
var availableSkillIds = state.skillSystem.equippedSkills.allSkills
|
||||
.map((s) => s.id)
|
||||
.toList();
|
||||
// 장착된 스킬이 없으면 기본 스킬 사용
|
||||
if (availableSkillIds.isEmpty) {
|
||||
availableSkillIds = SkillData.defaultSkillIds;
|
||||
}
|
||||
|
||||
final selectedSkill = skillService.selectAutoSkill(
|
||||
player: newPlayerStats,
|
||||
monster: newMonsterStats,
|
||||
skillSystem: newSkillSystem,
|
||||
availableSkillIds: availableSkillIds,
|
||||
activeDoTs: newActiveDoTs,
|
||||
activeDebuffs: newActiveBuffs,
|
||||
);
|
||||
|
||||
if (selectedSkill != null && selectedSkill.isAttack) {
|
||||
// 스킬 랭크 조회
|
||||
final skillRank = skillService.getSkillRankFromSkillBook(
|
||||
state.skillBook,
|
||||
selectedSkill.id,
|
||||
);
|
||||
// 랭크 스케일링 적용된 공격 스킬 사용
|
||||
final skillResult = skillService.useAttackSkillWithRank(
|
||||
skill: selectedSkill,
|
||||
player: newPlayerStats,
|
||||
monster: newMonsterStats,
|
||||
skillSystem: newSkillSystem,
|
||||
rank: skillRank,
|
||||
);
|
||||
newPlayerStats = skillResult.updatedPlayer;
|
||||
newMonsterStats = skillResult.updatedMonster;
|
||||
newTotalDamageDealt += skillResult.result.damage;
|
||||
newSkillSystem = skillResult.updatedSkillSystem.startGlobalCooldown();
|
||||
|
||||
events.add(
|
||||
CombatEvent.playerSkill(
|
||||
timestamp: timestamp,
|
||||
skillName: selectedSkill.name,
|
||||
damage: skillResult.result.damage,
|
||||
targetName: newMonsterStats.name,
|
||||
attackDelayMs: newPlayerStats.attackDelayMs,
|
||||
),
|
||||
);
|
||||
} else if (selectedSkill != null && selectedSkill.isDot) {
|
||||
final skillResult = skillService.useDotSkill(
|
||||
skill: selectedSkill,
|
||||
player: newPlayerStats,
|
||||
skillSystem: newSkillSystem,
|
||||
playerInt: state.stats.intelligence,
|
||||
playerWis: state.stats.wis,
|
||||
);
|
||||
newPlayerStats = skillResult.updatedPlayer;
|
||||
newSkillSystem = skillResult.updatedSkillSystem.startGlobalCooldown();
|
||||
|
||||
if (skillResult.dotEffect != null) {
|
||||
newActiveDoTs.add(skillResult.dotEffect!);
|
||||
}
|
||||
|
||||
events.add(
|
||||
CombatEvent.playerSkill(
|
||||
timestamp: timestamp,
|
||||
skillName: selectedSkill.name,
|
||||
damage: skillResult.result.damage,
|
||||
targetName: newMonsterStats.name,
|
||||
attackDelayMs: newPlayerStats.attackDelayMs,
|
||||
),
|
||||
);
|
||||
} else if (selectedSkill != null && selectedSkill.isHeal) {
|
||||
final skillResult = skillService.useHealSkill(
|
||||
skill: selectedSkill,
|
||||
player: newPlayerStats,
|
||||
skillSystem: newSkillSystem,
|
||||
healingMultiplier: healingMultiplier,
|
||||
);
|
||||
newPlayerStats = skillResult.updatedPlayer;
|
||||
newSkillSystem = skillResult.updatedSkillSystem.startGlobalCooldown();
|
||||
|
||||
events.add(
|
||||
CombatEvent.playerHeal(
|
||||
timestamp: timestamp,
|
||||
healAmount: skillResult.result.healedAmount,
|
||||
skillName: selectedSkill.name,
|
||||
),
|
||||
);
|
||||
} else if (selectedSkill != null && selectedSkill.isBuff) {
|
||||
final skillResult = skillService.useBuffSkill(
|
||||
skill: selectedSkill,
|
||||
player: newPlayerStats,
|
||||
skillSystem: newSkillSystem,
|
||||
);
|
||||
newPlayerStats = skillResult.updatedPlayer;
|
||||
newSkillSystem = skillResult.updatedSkillSystem.startGlobalCooldown();
|
||||
|
||||
events.add(
|
||||
CombatEvent.playerBuff(
|
||||
timestamp: timestamp,
|
||||
skillName: selectedSkill.name,
|
||||
),
|
||||
);
|
||||
} else if (selectedSkill != null && selectedSkill.isDebuff) {
|
||||
final skillResult = skillService.useDebuffSkill(
|
||||
skill: selectedSkill,
|
||||
player: newPlayerStats,
|
||||
skillSystem: newSkillSystem,
|
||||
currentDebuffs: newActiveBuffs,
|
||||
);
|
||||
newPlayerStats = skillResult.updatedPlayer;
|
||||
newSkillSystem = skillResult.updatedSkillSystem.startGlobalCooldown();
|
||||
|
||||
if (skillResult.debuffEffect != null) {
|
||||
newActiveBuffs =
|
||||
newActiveBuffs
|
||||
.where(
|
||||
(d) => d.effect.id != skillResult.debuffEffect!.effect.id,
|
||||
)
|
||||
.toList()
|
||||
..add(skillResult.debuffEffect!);
|
||||
}
|
||||
|
||||
events.add(
|
||||
CombatEvent.playerDebuff(
|
||||
timestamp: timestamp,
|
||||
skillName: selectedSkill.name,
|
||||
targetName: newMonsterStats.name,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// 일반 공격
|
||||
final attackResult = calculator.playerAttackMonster(
|
||||
attacker: newPlayerStats,
|
||||
defender: newMonsterStats,
|
||||
);
|
||||
newMonsterStats = attackResult.updatedDefender;
|
||||
|
||||
// 첫 공격 배율 적용 (예: Pointer Assassin 1.5배)
|
||||
var damage = attackResult.result.damage;
|
||||
if (isFirstPlayerAttack && firstStrikeBonus > 1.0) {
|
||||
damage = (damage * firstStrikeBonus).round();
|
||||
// 첫 공격 배율이 적용된 데미지로 몬스터 HP 재계산
|
||||
final extraDamage = damage - attackResult.result.damage;
|
||||
if (extraDamage > 0) {
|
||||
final newHp = (newMonsterStats.hpCurrent - extraDamage).clamp(
|
||||
0,
|
||||
newMonsterStats.hpMax,
|
||||
);
|
||||
newMonsterStats = newMonsterStats.copyWith(hpCurrent: newHp);
|
||||
}
|
||||
}
|
||||
newTotalDamageDealt += damage;
|
||||
|
||||
final result = attackResult.result;
|
||||
if (result.isEvaded) {
|
||||
events.add(
|
||||
CombatEvent.monsterEvade(
|
||||
timestamp: timestamp,
|
||||
targetName: newMonsterStats.name,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
events.add(
|
||||
CombatEvent.playerAttack(
|
||||
timestamp: timestamp,
|
||||
damage: damage,
|
||||
targetName: newMonsterStats.name,
|
||||
isCritical: result.isCritical,
|
||||
attackDelayMs: newPlayerStats.attackDelayMs,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 연속 공격 (Refactor Monk 패시브) - 30% 확률로 추가 공격
|
||||
if (hasMultiAttack && newMonsterStats.isAlive && rng.nextDouble() < 0.3) {
|
||||
final extraAttack = calculator.playerAttackMonster(
|
||||
attacker: newPlayerStats,
|
||||
defender: newMonsterStats,
|
||||
);
|
||||
newMonsterStats = extraAttack.updatedDefender;
|
||||
newTotalDamageDealt += extraAttack.result.damage;
|
||||
|
||||
if (!extraAttack.result.isEvaded) {
|
||||
events.add(
|
||||
CombatEvent.playerAttack(
|
||||
timestamp: timestamp,
|
||||
damage: extraAttack.result.damage,
|
||||
targetName: newMonsterStats.name,
|
||||
isCritical: extraAttack.result.isCritical,
|
||||
attackDelayMs: newPlayerStats.attackDelayMs,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
playerStats: newPlayerStats,
|
||||
monsterStats: newMonsterStats,
|
||||
skillSystem: newSkillSystem,
|
||||
activeDoTs: newActiveDoTs,
|
||||
activeDebuffs: newActiveBuffs,
|
||||
totalDamageDealt: newTotalDamageDealt,
|
||||
events: events,
|
||||
isFirstPlayerAttack: false, // 첫 공격 이후에는 false
|
||||
);
|
||||
}
|
||||
|
||||
/// 몬스터 공격 처리
|
||||
({CombatStats playerStats, int totalDamageTaken, List<CombatEvent> events})
|
||||
_processMonsterAttack({
|
||||
|
||||
172
lib/src/core/engine/death_handler.dart
Normal file
172
lib/src/core/engine/death_handler.dart
Normal file
@@ -0,0 +1,172 @@
|
||||
import 'package:asciineverdie/src/core/model/equipment_item.dart';
|
||||
import 'package:asciineverdie/src/core/model/equipment_slot.dart';
|
||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||
import 'package:asciineverdie/src/core/model/item_stats.dart';
|
||||
|
||||
/// 플레이어 사망 처리 서비스
|
||||
///
|
||||
/// ProgressService에서 분리된 사망 관련 로직 담당:
|
||||
/// - 장비 손실 계산
|
||||
/// - 사망 정보 기록
|
||||
/// - 보스전 레벨링 모드 진입
|
||||
class DeathHandler {
|
||||
const DeathHandler();
|
||||
|
||||
/// 플레이어 사망 처리 (Phase 4)
|
||||
///
|
||||
/// 모든 장비 상실 및 사망 정보 기록.
|
||||
/// 보스전 사망 시: 장비 보호 + 5분 레벨링 모드 진입.
|
||||
GameState processPlayerDeath(
|
||||
GameState state, {
|
||||
required String killerName,
|
||||
required DeathCause cause,
|
||||
}) {
|
||||
// 사망 직전 전투 이벤트 저장 (최대 10개)
|
||||
final lastCombatEvents =
|
||||
state.progress.currentCombat?.recentEvents ?? const [];
|
||||
|
||||
// 보스전 사망 여부 확인 (최종 보스 fighting 상태)
|
||||
final isBossDeath =
|
||||
state.progress.finalBossState == FinalBossState.fighting;
|
||||
|
||||
// 보스전 사망이 아닐 경우에만 장비 손실
|
||||
var newEquipment = state.equipment;
|
||||
var lostCount = 0;
|
||||
String? lostItemName;
|
||||
EquipmentSlot? lostItemSlot;
|
||||
ItemRarity? lostItemRarity;
|
||||
EquipmentItem? lostEquipmentItem; // 광고 부활 시 복구용
|
||||
|
||||
if (!isBossDeath) {
|
||||
final lossResult = _calculateEquipmentLoss(state);
|
||||
newEquipment = lossResult.equipment;
|
||||
lostCount = lossResult.lostCount;
|
||||
lostItemName = lossResult.lostItemName;
|
||||
lostItemSlot = lossResult.lostItemSlot;
|
||||
lostItemRarity = lossResult.lostItemRarity;
|
||||
lostEquipmentItem = lossResult.lostItem;
|
||||
}
|
||||
|
||||
// 사망 정보 생성 (전투 로그 포함)
|
||||
final deathInfo = DeathInfo(
|
||||
cause: cause,
|
||||
killerName: killerName,
|
||||
lostEquipmentCount: lostCount,
|
||||
lostItemName: lostItemName,
|
||||
lostItemSlot: lostItemSlot,
|
||||
lostItemRarity: lostItemRarity,
|
||||
lostItem: lostEquipmentItem,
|
||||
goldAtDeath: state.inventory.gold,
|
||||
levelAtDeath: state.traits.level,
|
||||
timestamp: state.skillSystem.elapsedMs,
|
||||
lastCombatEvents: lastCombatEvents,
|
||||
);
|
||||
|
||||
// 보스전 사망 시 5분 레벨링 모드 진입
|
||||
final bossLevelingEndTime = isBossDeath
|
||||
? DateTime.now().millisecondsSinceEpoch +
|
||||
(5 * 60 * 1000) // 5분
|
||||
: null;
|
||||
|
||||
// 전투 상태 초기화 및 사망 횟수 증가
|
||||
final progress = state.progress.copyWith(
|
||||
currentCombat: null,
|
||||
deathCount: state.progress.deathCount + 1,
|
||||
bossLevelingEndTime: bossLevelingEndTime,
|
||||
);
|
||||
|
||||
return state.copyWith(
|
||||
equipment: newEquipment,
|
||||
progress: progress,
|
||||
deathInfo: deathInfo,
|
||||
);
|
||||
}
|
||||
|
||||
/// 장비 손실 계산
|
||||
({
|
||||
Equipment equipment,
|
||||
int lostCount,
|
||||
String? lostItemName,
|
||||
EquipmentSlot? lostItemSlot,
|
||||
ItemRarity? lostItemRarity,
|
||||
EquipmentItem? lostItem,
|
||||
})
|
||||
_calculateEquipmentLoss(GameState state) {
|
||||
var newEquipment = state.equipment;
|
||||
|
||||
// 레벨 기반 장비 손실 확률 계산
|
||||
// Lv 1: 20%, Lv 5: ~56%, Lv 10+: 100%
|
||||
// 공식: 20 + (level - 1) * 80 / 9
|
||||
final level = state.traits.level;
|
||||
final lossChancePercent = level >= 10
|
||||
? 100
|
||||
: (20 + ((level - 1) * 80 ~/ 9)).clamp(0, 100);
|
||||
final roll = state.rng.nextInt(100); // 0~99
|
||||
final shouldLoseEquipment = roll < lossChancePercent;
|
||||
|
||||
// ignore: avoid_print
|
||||
print(
|
||||
'[Death] Lv$level lossChance=$lossChancePercent% roll=$roll '
|
||||
'shouldLose=$shouldLoseEquipment',
|
||||
);
|
||||
|
||||
if (!shouldLoseEquipment) {
|
||||
return (
|
||||
equipment: newEquipment,
|
||||
lostCount: 0,
|
||||
lostItemName: null,
|
||||
lostItemSlot: null,
|
||||
lostItemRarity: null,
|
||||
lostItem: null,
|
||||
);
|
||||
}
|
||||
|
||||
// 무기(슬롯 0)를 제외한 장착된 장비 중 1개를 제물로 삭제
|
||||
final equippedNonWeaponSlots = <int>[];
|
||||
for (var i = 1; i < Equipment.slotCount; i++) {
|
||||
final item = state.equipment.getItemByIndex(i);
|
||||
if (item.isNotEmpty) {
|
||||
equippedNonWeaponSlots.add(i);
|
||||
}
|
||||
}
|
||||
|
||||
if (equippedNonWeaponSlots.isEmpty) {
|
||||
return (
|
||||
equipment: newEquipment,
|
||||
lostCount: 0,
|
||||
lostItemName: null,
|
||||
lostItemSlot: null,
|
||||
lostItemRarity: null,
|
||||
lostItem: null,
|
||||
);
|
||||
}
|
||||
|
||||
// 랜덤하게 1개 슬롯 선택
|
||||
final sacrificeIndex =
|
||||
equippedNonWeaponSlots[state.rng.nextInt(
|
||||
equippedNonWeaponSlots.length,
|
||||
)];
|
||||
|
||||
// 제물로 바칠 아이템 정보 저장
|
||||
final lostItem = state.equipment.getItemByIndex(sacrificeIndex);
|
||||
final lostItemSlot = EquipmentSlot.values[sacrificeIndex];
|
||||
|
||||
// 해당 슬롯을 빈 장비로 교체
|
||||
newEquipment = newEquipment.setItemByIndex(
|
||||
sacrificeIndex,
|
||||
EquipmentItem.empty(lostItemSlot),
|
||||
);
|
||||
|
||||
// ignore: avoid_print
|
||||
print('[Death] Lost item: ${lostItem.name} (slot: $lostItemSlot)');
|
||||
|
||||
return (
|
||||
equipment: newEquipment,
|
||||
lostCount: 1,
|
||||
lostItemName: lostItem.name,
|
||||
lostItemSlot: lostItemSlot,
|
||||
lostItemRarity: lostItem.rarity,
|
||||
lostItem: lostItem,
|
||||
);
|
||||
}
|
||||
}
|
||||
80
lib/src/core/engine/loot_handler.dart
Normal file
80
lib/src/core/engine/loot_handler.dart
Normal file
@@ -0,0 +1,80 @@
|
||||
import 'package:asciineverdie/src/core/engine/game_mutations.dart';
|
||||
import 'package:asciineverdie/src/core/engine/potion_service.dart';
|
||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||
import 'package:asciineverdie/src/core/model/monster_grade.dart';
|
||||
import 'package:asciineverdie/src/core/model/potion.dart';
|
||||
|
||||
/// 전리품 처리 서비스
|
||||
///
|
||||
/// ProgressService에서 분리된 전리품 획득 로직 담당:
|
||||
/// - 몬스터 부위 아이템 인벤토리 추가
|
||||
/// - 특수 아이템 획득 (WinItem)
|
||||
/// - 물약 드랍
|
||||
class LootHandler {
|
||||
const LootHandler({required this.mutations});
|
||||
|
||||
final GameMutations mutations;
|
||||
|
||||
/// 킬 태스크 완료 시 전리품 획득 (원본 Main.pas:625-630)
|
||||
({GameState state, Potion? droppedPotion}) winLoot(GameState state) {
|
||||
final taskInfo = state.progress.currentTask;
|
||||
final monsterPart = taskInfo.monsterPart ?? '';
|
||||
final monsterBaseName = taskInfo.monsterBaseName ?? '';
|
||||
|
||||
var resultState = state;
|
||||
|
||||
// 부위가 '*'이면 WinItem 호출 (특수 아이템)
|
||||
if (monsterPart == '*') {
|
||||
resultState = mutations.winItem(resultState);
|
||||
} else if (monsterPart.isNotEmpty && monsterBaseName.isNotEmpty) {
|
||||
// 원본: Add(Inventory, LowerCase(Split(fTask.Caption,1) + ' ' +
|
||||
// ProperCase(Split(fTask.Caption,3))), 1);
|
||||
// 예: "goblin Claw" 형태로 인벤토리 추가
|
||||
final itemName =
|
||||
'${monsterBaseName.toLowerCase()} ${_properCase(monsterPart)}';
|
||||
|
||||
// 인벤토리에 추가
|
||||
final items = [...resultState.inventory.items];
|
||||
final existing = items.indexWhere((e) => e.name == itemName);
|
||||
if (existing >= 0) {
|
||||
items[existing] = items[existing].copyWith(
|
||||
count: items[existing].count + 1,
|
||||
);
|
||||
} else {
|
||||
items.add(InventoryEntry(name: itemName, count: 1));
|
||||
}
|
||||
|
||||
resultState = resultState.copyWith(
|
||||
inventory: resultState.inventory.copyWith(items: items),
|
||||
);
|
||||
}
|
||||
|
||||
// 물약 드랍 시도
|
||||
final potionService = const PotionService();
|
||||
final rng = resultState.rng;
|
||||
final monsterLevel = taskInfo.monsterLevel ?? resultState.traits.level;
|
||||
final monsterGrade = taskInfo.monsterGrade ?? MonsterGrade.normal;
|
||||
final (updatedPotionInventory, droppedPotion) = potionService.tryPotionDrop(
|
||||
playerLevel: resultState.traits.level,
|
||||
monsterLevel: monsterLevel,
|
||||
monsterGrade: monsterGrade,
|
||||
inventory: resultState.potionInventory,
|
||||
roll: rng.nextInt(100),
|
||||
typeRoll: rng.nextInt(100),
|
||||
);
|
||||
|
||||
return (
|
||||
state: resultState.copyWith(
|
||||
rng: rng,
|
||||
potionInventory: updatedPotionInventory,
|
||||
),
|
||||
droppedPotion: droppedPotion,
|
||||
);
|
||||
}
|
||||
|
||||
/// 첫 글자만 대문자로 변환 (원본 ProperCase)
|
||||
String _properCase(String s) {
|
||||
if (s.isEmpty) return s;
|
||||
return s[0].toUpperCase() + s.substring(1);
|
||||
}
|
||||
}
|
||||
411
lib/src/core/engine/player_attack_processor.dart
Normal file
411
lib/src/core/engine/player_attack_processor.dart
Normal file
@@ -0,0 +1,411 @@
|
||||
import 'package:asciineverdie/data/skill_data.dart';
|
||||
import 'package:asciineverdie/src/core/engine/combat_calculator.dart';
|
||||
import 'package:asciineverdie/src/core/engine/skill_service.dart';
|
||||
import 'package:asciineverdie/src/core/model/combat_event.dart';
|
||||
import 'package:asciineverdie/src/core/model/combat_stats.dart';
|
||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||
import 'package:asciineverdie/src/core/model/monster_combat_stats.dart';
|
||||
import 'package:asciineverdie/src/core/model/skill.dart';
|
||||
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
|
||||
|
||||
/// 플레이어 공격 처리 결과
|
||||
typedef PlayerAttackResult = ({
|
||||
CombatStats playerStats,
|
||||
MonsterCombatStats monsterStats,
|
||||
SkillSystemState skillSystem,
|
||||
List<DotEffect> activeDoTs,
|
||||
List<ActiveBuff> activeDebuffs,
|
||||
int totalDamageDealt,
|
||||
List<CombatEvent> events,
|
||||
bool isFirstPlayerAttack,
|
||||
});
|
||||
|
||||
/// 플레이어 공격 처리 서비스
|
||||
///
|
||||
/// CombatTickService에서 분리된 플레이어 공격 로직 담당:
|
||||
/// - 스킬 자동 선택 및 사용
|
||||
/// - 일반 공격 처리
|
||||
/// - 첫 공격 보너스
|
||||
/// - 연속 공격 (Multi-attack)
|
||||
class PlayerAttackProcessor {
|
||||
PlayerAttackProcessor({required this.rng});
|
||||
|
||||
final DeterministicRandom rng;
|
||||
|
||||
/// 플레이어 공격 처리
|
||||
PlayerAttackResult processAttack({
|
||||
required GameState state,
|
||||
required CombatStats playerStats,
|
||||
required MonsterCombatStats monsterStats,
|
||||
required SkillSystemState updatedSkillSystem,
|
||||
required List<DotEffect> activeDoTs,
|
||||
required List<ActiveBuff> activeDebuffs,
|
||||
required int totalDamageDealt,
|
||||
required int timestamp,
|
||||
required CombatCalculator calculator,
|
||||
required SkillService skillService,
|
||||
required bool isFirstPlayerAttack,
|
||||
required double firstStrikeBonus,
|
||||
required bool hasMultiAttack,
|
||||
double healingMultiplier = 1.0,
|
||||
}) {
|
||||
final events = <CombatEvent>[];
|
||||
var newPlayerStats = playerStats;
|
||||
var newMonsterStats = monsterStats;
|
||||
var newSkillSystem = updatedSkillSystem;
|
||||
var newActiveDoTs = [...activeDoTs];
|
||||
var newActiveBuffs = [...activeDebuffs];
|
||||
var newTotalDamageDealt = totalDamageDealt;
|
||||
|
||||
// 장착된 스킬 슬롯에서 사용 가능한 스킬 ID 목록 조회
|
||||
var availableSkillIds = state.skillSystem.equippedSkills.allSkills
|
||||
.map((s) => s.id)
|
||||
.toList();
|
||||
// 장착된 스킬이 없으면 기본 스킬 사용
|
||||
if (availableSkillIds.isEmpty) {
|
||||
availableSkillIds = SkillData.defaultSkillIds;
|
||||
}
|
||||
|
||||
final selectedSkill = skillService.selectAutoSkill(
|
||||
player: newPlayerStats,
|
||||
monster: newMonsterStats,
|
||||
skillSystem: newSkillSystem,
|
||||
availableSkillIds: availableSkillIds,
|
||||
activeDoTs: newActiveDoTs,
|
||||
activeDebuffs: newActiveBuffs,
|
||||
);
|
||||
|
||||
if (selectedSkill != null && selectedSkill.isAttack) {
|
||||
final result = _useAttackSkill(
|
||||
state: state,
|
||||
skill: selectedSkill,
|
||||
playerStats: newPlayerStats,
|
||||
monsterStats: newMonsterStats,
|
||||
skillSystem: newSkillSystem,
|
||||
skillService: skillService,
|
||||
timestamp: timestamp,
|
||||
);
|
||||
newPlayerStats = result.playerStats;
|
||||
newMonsterStats = result.monsterStats;
|
||||
newTotalDamageDealt += result.damage;
|
||||
newSkillSystem = result.skillSystem;
|
||||
events.add(result.event);
|
||||
} else if (selectedSkill != null && selectedSkill.isDot) {
|
||||
final result = _useDotSkill(
|
||||
state: state,
|
||||
skill: selectedSkill,
|
||||
playerStats: newPlayerStats,
|
||||
skillSystem: newSkillSystem,
|
||||
skillService: skillService,
|
||||
monsterName: newMonsterStats.name,
|
||||
timestamp: timestamp,
|
||||
);
|
||||
newPlayerStats = result.playerStats;
|
||||
newSkillSystem = result.skillSystem;
|
||||
if (result.dotEffect != null) newActiveDoTs.add(result.dotEffect!);
|
||||
events.add(result.event);
|
||||
} else if (selectedSkill != null && selectedSkill.isHeal) {
|
||||
final result = _useHealSkill(
|
||||
skill: selectedSkill,
|
||||
playerStats: newPlayerStats,
|
||||
skillSystem: newSkillSystem,
|
||||
skillService: skillService,
|
||||
healingMultiplier: healingMultiplier,
|
||||
timestamp: timestamp,
|
||||
);
|
||||
newPlayerStats = result.playerStats;
|
||||
newSkillSystem = result.skillSystem;
|
||||
events.add(result.event);
|
||||
} else if (selectedSkill != null && selectedSkill.isBuff) {
|
||||
final result = skillService.useBuffSkill(
|
||||
skill: selectedSkill,
|
||||
player: newPlayerStats,
|
||||
skillSystem: newSkillSystem,
|
||||
);
|
||||
newPlayerStats = result.updatedPlayer;
|
||||
newSkillSystem = result.updatedSkillSystem.startGlobalCooldown();
|
||||
events.add(
|
||||
CombatEvent.playerBuff(
|
||||
timestamp: timestamp,
|
||||
skillName: selectedSkill.name,
|
||||
),
|
||||
);
|
||||
} else if (selectedSkill != null && selectedSkill.isDebuff) {
|
||||
final result = _useDebuffSkill(
|
||||
skill: selectedSkill,
|
||||
playerStats: newPlayerStats,
|
||||
skillSystem: newSkillSystem,
|
||||
skillService: skillService,
|
||||
activeDebuffs: newActiveBuffs,
|
||||
monsterName: newMonsterStats.name,
|
||||
timestamp: timestamp,
|
||||
);
|
||||
newPlayerStats = result.playerStats;
|
||||
newSkillSystem = result.skillSystem;
|
||||
newActiveBuffs = result.activeDebuffs;
|
||||
events.add(result.event);
|
||||
} else {
|
||||
// 일반 공격
|
||||
final result = _processNormalAttack(
|
||||
playerStats: newPlayerStats,
|
||||
monsterStats: newMonsterStats,
|
||||
calculator: calculator,
|
||||
isFirstPlayerAttack: isFirstPlayerAttack,
|
||||
firstStrikeBonus: firstStrikeBonus,
|
||||
hasMultiAttack: hasMultiAttack,
|
||||
timestamp: timestamp,
|
||||
);
|
||||
newMonsterStats = result.monsterStats;
|
||||
newTotalDamageDealt += result.totalDamage;
|
||||
events.addAll(result.events);
|
||||
}
|
||||
|
||||
return (
|
||||
playerStats: newPlayerStats,
|
||||
monsterStats: newMonsterStats,
|
||||
skillSystem: newSkillSystem,
|
||||
activeDoTs: newActiveDoTs,
|
||||
activeDebuffs: newActiveBuffs,
|
||||
totalDamageDealt: newTotalDamageDealt,
|
||||
events: events,
|
||||
isFirstPlayerAttack: false,
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 스킬 사용 헬퍼
|
||||
// ============================================================================
|
||||
|
||||
({
|
||||
CombatStats playerStats,
|
||||
MonsterCombatStats monsterStats,
|
||||
int damage,
|
||||
SkillSystemState skillSystem,
|
||||
CombatEvent event,
|
||||
})
|
||||
_useAttackSkill({
|
||||
required GameState state,
|
||||
required Skill skill,
|
||||
required CombatStats playerStats,
|
||||
required MonsterCombatStats monsterStats,
|
||||
required SkillSystemState skillSystem,
|
||||
required SkillService skillService,
|
||||
required int timestamp,
|
||||
}) {
|
||||
final skillRank = skillService.getSkillRankFromSkillBook(
|
||||
state.skillBook,
|
||||
skill.id,
|
||||
);
|
||||
final skillResult = skillService.useAttackSkillWithRank(
|
||||
skill: skill,
|
||||
player: playerStats,
|
||||
monster: monsterStats,
|
||||
skillSystem: skillSystem,
|
||||
rank: skillRank,
|
||||
);
|
||||
return (
|
||||
playerStats: skillResult.updatedPlayer,
|
||||
monsterStats: skillResult.updatedMonster,
|
||||
damage: skillResult.result.damage,
|
||||
skillSystem: skillResult.updatedSkillSystem.startGlobalCooldown(),
|
||||
event: CombatEvent.playerSkill(
|
||||
timestamp: timestamp,
|
||||
skillName: skill.name,
|
||||
damage: skillResult.result.damage,
|
||||
targetName: monsterStats.name,
|
||||
attackDelayMs: playerStats.attackDelayMs,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
({
|
||||
CombatStats playerStats,
|
||||
SkillSystemState skillSystem,
|
||||
DotEffect? dotEffect,
|
||||
CombatEvent event,
|
||||
})
|
||||
_useDotSkill({
|
||||
required GameState state,
|
||||
required Skill skill,
|
||||
required CombatStats playerStats,
|
||||
required SkillSystemState skillSystem,
|
||||
required SkillService skillService,
|
||||
required String monsterName,
|
||||
required int timestamp,
|
||||
}) {
|
||||
final skillResult = skillService.useDotSkill(
|
||||
skill: skill,
|
||||
player: playerStats,
|
||||
skillSystem: skillSystem,
|
||||
playerInt: state.stats.intelligence,
|
||||
playerWis: state.stats.wis,
|
||||
);
|
||||
return (
|
||||
playerStats: skillResult.updatedPlayer,
|
||||
skillSystem: skillResult.updatedSkillSystem.startGlobalCooldown(),
|
||||
dotEffect: skillResult.dotEffect,
|
||||
event: CombatEvent.playerSkill(
|
||||
timestamp: timestamp,
|
||||
skillName: skill.name,
|
||||
damage: skillResult.result.damage,
|
||||
targetName: monsterName,
|
||||
attackDelayMs: playerStats.attackDelayMs,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
({CombatStats playerStats, SkillSystemState skillSystem, CombatEvent event})
|
||||
_useHealSkill({
|
||||
required Skill skill,
|
||||
required CombatStats playerStats,
|
||||
required SkillSystemState skillSystem,
|
||||
required SkillService skillService,
|
||||
required double healingMultiplier,
|
||||
required int timestamp,
|
||||
}) {
|
||||
final skillResult = skillService.useHealSkill(
|
||||
skill: skill,
|
||||
player: playerStats,
|
||||
skillSystem: skillSystem,
|
||||
healingMultiplier: healingMultiplier,
|
||||
);
|
||||
return (
|
||||
playerStats: skillResult.updatedPlayer,
|
||||
skillSystem: skillResult.updatedSkillSystem.startGlobalCooldown(),
|
||||
event: CombatEvent.playerHeal(
|
||||
timestamp: timestamp,
|
||||
healAmount: skillResult.result.healedAmount,
|
||||
skillName: skill.name,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
({
|
||||
CombatStats playerStats,
|
||||
SkillSystemState skillSystem,
|
||||
List<ActiveBuff> activeDebuffs,
|
||||
CombatEvent event,
|
||||
})
|
||||
_useDebuffSkill({
|
||||
required Skill skill,
|
||||
required CombatStats playerStats,
|
||||
required SkillSystemState skillSystem,
|
||||
required SkillService skillService,
|
||||
required List<ActiveBuff> activeDebuffs,
|
||||
required String monsterName,
|
||||
required int timestamp,
|
||||
}) {
|
||||
final skillResult = skillService.useDebuffSkill(
|
||||
skill: skill,
|
||||
player: playerStats,
|
||||
skillSystem: skillSystem,
|
||||
currentDebuffs: activeDebuffs,
|
||||
);
|
||||
var newDebuffs = activeDebuffs;
|
||||
if (skillResult.debuffEffect != null) {
|
||||
newDebuffs =
|
||||
activeDebuffs
|
||||
.where((d) => d.effect.id != skillResult.debuffEffect!.effect.id)
|
||||
.toList()
|
||||
..add(skillResult.debuffEffect!);
|
||||
}
|
||||
return (
|
||||
playerStats: skillResult.updatedPlayer,
|
||||
skillSystem: skillResult.updatedSkillSystem.startGlobalCooldown(),
|
||||
activeDebuffs: newDebuffs,
|
||||
event: CombatEvent.playerDebuff(
|
||||
timestamp: timestamp,
|
||||
skillName: skill.name,
|
||||
targetName: monsterName,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 일반 공격
|
||||
// ============================================================================
|
||||
|
||||
({MonsterCombatStats monsterStats, int totalDamage, List<CombatEvent> events})
|
||||
_processNormalAttack({
|
||||
required CombatStats playerStats,
|
||||
required MonsterCombatStats monsterStats,
|
||||
required CombatCalculator calculator,
|
||||
required bool isFirstPlayerAttack,
|
||||
required double firstStrikeBonus,
|
||||
required bool hasMultiAttack,
|
||||
required int timestamp,
|
||||
}) {
|
||||
final events = <CombatEvent>[];
|
||||
var newMonsterStats = monsterStats;
|
||||
var totalDamage = 0;
|
||||
|
||||
final attackResult = calculator.playerAttackMonster(
|
||||
attacker: playerStats,
|
||||
defender: newMonsterStats,
|
||||
);
|
||||
newMonsterStats = attackResult.updatedDefender;
|
||||
|
||||
// 첫 공격 배율 적용
|
||||
var damage = attackResult.result.damage;
|
||||
if (isFirstPlayerAttack && firstStrikeBonus > 1.0) {
|
||||
damage = (damage * firstStrikeBonus).round();
|
||||
final extraDamage = damage - attackResult.result.damage;
|
||||
if (extraDamage > 0) {
|
||||
final newHp = (newMonsterStats.hpCurrent - extraDamage).clamp(
|
||||
0,
|
||||
newMonsterStats.hpMax,
|
||||
);
|
||||
newMonsterStats = newMonsterStats.copyWith(hpCurrent: newHp);
|
||||
}
|
||||
}
|
||||
totalDamage += damage;
|
||||
|
||||
final result = attackResult.result;
|
||||
if (result.isEvaded) {
|
||||
events.add(
|
||||
CombatEvent.monsterEvade(
|
||||
timestamp: timestamp,
|
||||
targetName: newMonsterStats.name,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
events.add(
|
||||
CombatEvent.playerAttack(
|
||||
timestamp: timestamp,
|
||||
damage: damage,
|
||||
targetName: newMonsterStats.name,
|
||||
isCritical: result.isCritical,
|
||||
attackDelayMs: playerStats.attackDelayMs,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 연속 공격 (Refactor Monk 패시브) - 30% 확률로 추가 공격
|
||||
if (hasMultiAttack && newMonsterStats.isAlive && rng.nextDouble() < 0.3) {
|
||||
final extraAttack = calculator.playerAttackMonster(
|
||||
attacker: playerStats,
|
||||
defender: newMonsterStats,
|
||||
);
|
||||
newMonsterStats = extraAttack.updatedDefender;
|
||||
totalDamage += extraAttack.result.damage;
|
||||
|
||||
if (!extraAttack.result.isEvaded) {
|
||||
events.add(
|
||||
CombatEvent.playerAttack(
|
||||
timestamp: timestamp,
|
||||
damage: extraAttack.result.damage,
|
||||
targetName: newMonsterStats.name,
|
||||
isCritical: extraAttack.result.isCritical,
|
||||
attackDelayMs: playerStats.attackDelayMs,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
monsterStats: newMonsterStats,
|
||||
totalDamage: totalDamage,
|
||||
events: events,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,18 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:asciineverdie/data/class_data.dart';
|
||||
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
|
||||
import 'package:asciineverdie/data/race_data.dart';
|
||||
import 'package:asciineverdie/src/core/model/class_traits.dart';
|
||||
import 'package:asciineverdie/src/core/animation/monster_size.dart';
|
||||
import 'package:asciineverdie/src/core/engine/act_progression_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/combat_calculator.dart';
|
||||
import 'package:asciineverdie/src/core/engine/combat_tick_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/death_handler.dart';
|
||||
import 'package:asciineverdie/src/core/engine/game_mutations.dart';
|
||||
import 'package:asciineverdie/src/core/engine/loot_handler.dart';
|
||||
import 'package:asciineverdie/src/core/engine/market_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/potion_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/reward_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/skill_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/task_generator.dart';
|
||||
import 'package:asciineverdie/src/core/model/combat_event.dart';
|
||||
import 'package:asciineverdie/src/core/model/combat_state.dart';
|
||||
import 'package:asciineverdie/src/core/model/combat_stats.dart';
|
||||
import 'package:asciineverdie/src/core/model/equipment_item.dart';
|
||||
import 'package:asciineverdie/src/core/model/equipment_slot.dart';
|
||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||
import 'package:asciineverdie/src/core/model/item_stats.dart';
|
||||
import 'package:asciineverdie/src/core/model/monster_combat_stats.dart';
|
||||
import 'package:asciineverdie/src/core/model/monster_grade.dart';
|
||||
import 'package:asciineverdie/src/core/model/potion.dart';
|
||||
import 'package:asciineverdie/src/core/model/pq_config.dart';
|
||||
import 'package:asciineverdie/src/core/util/balance_constants.dart';
|
||||
import 'package:asciineverdie/src/core/util/pq_logic.dart' as pq_logic;
|
||||
@@ -58,12 +48,17 @@ class ProgressService {
|
||||
required this.config,
|
||||
required this.mutations,
|
||||
required this.rewards,
|
||||
});
|
||||
}) : _taskGenerator = TaskGenerator(config: config),
|
||||
_lootHandler = LootHandler(mutations: mutations);
|
||||
|
||||
final PqConfig config;
|
||||
final GameMutations mutations;
|
||||
final RewardService rewards;
|
||||
|
||||
final TaskGenerator _taskGenerator;
|
||||
final LootHandler _lootHandler;
|
||||
static const _deathHandler = DeathHandler();
|
||||
|
||||
/// 새 게임 초기화 (원본 GoButtonClick, Main.pas:741-767)
|
||||
/// Prologue 태스크들을 큐에 추가하고 첫 태스크 시작
|
||||
GameState initializeNewGame(GameState state) {
|
||||
@@ -318,7 +313,7 @@ class ProgressService {
|
||||
// 플레이어 사망 체크
|
||||
if (!updatedCombat.playerStats.isAlive) {
|
||||
final monsterName = updatedCombat.monsterStats.name;
|
||||
nextState = _processPlayerDeath(
|
||||
nextState = _deathHandler.processPlayerDeath(
|
||||
state,
|
||||
killerName: monsterName,
|
||||
cause: DeathCause.monster,
|
||||
@@ -380,7 +375,7 @@ class ProgressService {
|
||||
}
|
||||
|
||||
// 전리품 획득
|
||||
final lootResult = _winLoot(nextState);
|
||||
final lootResult = _lootHandler.winLoot(nextState);
|
||||
nextState = lootResult.state;
|
||||
|
||||
// 물약 드랍 로그 추가
|
||||
@@ -636,7 +631,7 @@ class ProgressService {
|
||||
}
|
||||
} else {
|
||||
nextState = nextState.copyWith(progress: progress, queue: queue);
|
||||
final newTaskResult = _generateNextTask(nextState);
|
||||
final newTaskResult = _taskGenerator.generateNextTask(nextState);
|
||||
progress = newTaskResult.progress;
|
||||
queue = newTaskResult.queue;
|
||||
}
|
||||
@@ -650,241 +645,6 @@ class ProgressService {
|
||||
);
|
||||
}
|
||||
|
||||
/// 큐가 비어있을 때 다음 태스크 생성 (원본 Dequeue 667-684줄)
|
||||
({ProgressState progress, QueueState queue}) _generateNextTask(
|
||||
GameState state,
|
||||
) {
|
||||
var progress = state.progress;
|
||||
final queue = state.queue;
|
||||
final oldTaskType = progress.currentTask.type;
|
||||
|
||||
// 1. Encumbrance 초과 시 시장 이동
|
||||
if (_shouldGoToMarket(progress)) {
|
||||
return _createMarketTask(progress, queue);
|
||||
}
|
||||
|
||||
// 2. 전환 태스크 (buying/heading)
|
||||
if (_needsTransitionTask(oldTaskType)) {
|
||||
return _createTransitionTask(state, progress, queue);
|
||||
}
|
||||
|
||||
// 3. Act Boss 리트라이
|
||||
if (state.progress.pendingActCompletion) {
|
||||
return _createActBossRetryTask(state, progress, queue);
|
||||
}
|
||||
|
||||
// 4. 최종 보스 전투
|
||||
if (state.progress.finalBossState == FinalBossState.fighting &&
|
||||
!state.progress.isInBossLevelingMode) {
|
||||
if (state.progress.bossLevelingEndTime != null) {
|
||||
progress = progress.copyWith(clearBossLevelingEndTime: true);
|
||||
}
|
||||
final actProgressionService = ActProgressionService(config: config);
|
||||
return actProgressionService.startFinalBossFight(state, progress, queue);
|
||||
}
|
||||
|
||||
// 5. 일반 몬스터 전투
|
||||
return _createMonsterTask(state, progress, queue);
|
||||
}
|
||||
|
||||
/// 시장 이동 조건 확인
|
||||
bool _shouldGoToMarket(ProgressState progress) {
|
||||
return progress.encumbrance.position >= progress.encumbrance.max &&
|
||||
progress.encumbrance.max > 0;
|
||||
}
|
||||
|
||||
/// 전환 태스크 필요 여부 확인
|
||||
bool _needsTransitionTask(TaskType oldTaskType) {
|
||||
return oldTaskType != TaskType.kill &&
|
||||
oldTaskType != TaskType.neutral &&
|
||||
oldTaskType != TaskType.buying;
|
||||
}
|
||||
|
||||
/// 시장 이동 태스크 생성
|
||||
({ProgressState progress, QueueState queue}) _createMarketTask(
|
||||
ProgressState progress,
|
||||
QueueState queue,
|
||||
) {
|
||||
final taskResult = pq_logic.startTask(
|
||||
progress,
|
||||
l10n.taskHeadingToMarket(),
|
||||
4 * 1000,
|
||||
);
|
||||
final updatedProgress = taskResult.progress.copyWith(
|
||||
currentTask: TaskInfo(caption: taskResult.caption, type: TaskType.market),
|
||||
currentCombat: null,
|
||||
);
|
||||
return (progress: updatedProgress, queue: queue);
|
||||
}
|
||||
|
||||
/// 전환 태스크 생성 (buying 또는 heading)
|
||||
({ProgressState progress, QueueState queue}) _createTransitionTask(
|
||||
GameState state,
|
||||
ProgressState progress,
|
||||
QueueState queue,
|
||||
) {
|
||||
final gold = state.inventory.gold;
|
||||
final equipPrice = state.traits.level * 50;
|
||||
|
||||
// Gold 충분 시 장비 구매
|
||||
if (gold > equipPrice) {
|
||||
final taskResult = pq_logic.startTask(
|
||||
progress,
|
||||
l10n.taskUpgradingHardware(),
|
||||
5 * 1000,
|
||||
);
|
||||
final updatedProgress = taskResult.progress.copyWith(
|
||||
currentTask: TaskInfo(
|
||||
caption: taskResult.caption,
|
||||
type: TaskType.buying,
|
||||
),
|
||||
currentCombat: null,
|
||||
);
|
||||
return (progress: updatedProgress, queue: queue);
|
||||
}
|
||||
|
||||
// Gold 부족 시 전장 이동
|
||||
final taskResult = pq_logic.startTask(
|
||||
progress,
|
||||
l10n.taskEnteringDebugZone(),
|
||||
4 * 1000,
|
||||
);
|
||||
final updatedProgress = taskResult.progress.copyWith(
|
||||
currentTask: TaskInfo(
|
||||
caption: taskResult.caption,
|
||||
type: TaskType.neutral,
|
||||
),
|
||||
currentCombat: null,
|
||||
);
|
||||
return (progress: updatedProgress, queue: queue);
|
||||
}
|
||||
|
||||
/// Act Boss 재도전 태스크 생성
|
||||
({ProgressState progress, QueueState queue}) _createActBossRetryTask(
|
||||
GameState state,
|
||||
ProgressState progress,
|
||||
QueueState queue,
|
||||
) {
|
||||
final actProgressionService = ActProgressionService(config: config);
|
||||
final actBoss = actProgressionService.createActBoss(state);
|
||||
final combatCalculator = CombatCalculator(rng: state.rng);
|
||||
final durationMillis = combatCalculator.estimateCombatDurationMs(
|
||||
player: actBoss.playerStats,
|
||||
monster: actBoss.monsterStats,
|
||||
);
|
||||
|
||||
final taskResult = pq_logic.startTask(
|
||||
progress,
|
||||
l10n.taskDebugging(actBoss.monsterStats.name),
|
||||
durationMillis,
|
||||
);
|
||||
|
||||
final updatedProgress = taskResult.progress.copyWith(
|
||||
currentTask: TaskInfo(
|
||||
caption: taskResult.caption,
|
||||
type: TaskType.kill,
|
||||
monsterBaseName: actBoss.monsterStats.name,
|
||||
monsterPart: '*',
|
||||
monsterLevel: actBoss.monsterStats.level,
|
||||
monsterGrade: MonsterGrade.boss,
|
||||
monsterSize: getBossSizeForAct(state.progress.plotStageCount),
|
||||
),
|
||||
currentCombat: actBoss,
|
||||
);
|
||||
|
||||
return (progress: updatedProgress, queue: queue);
|
||||
}
|
||||
|
||||
/// 일반 몬스터 전투 태스크 생성
|
||||
({ProgressState progress, QueueState queue}) _createMonsterTask(
|
||||
GameState state,
|
||||
ProgressState progress,
|
||||
QueueState queue,
|
||||
) {
|
||||
final level = state.traits.level;
|
||||
|
||||
// 퀘스트 몬스터 데이터 확인
|
||||
final questMonster = state.progress.currentQuestMonster;
|
||||
final questMonsterData = questMonster?.monsterData;
|
||||
final questLevel = questMonsterData != null
|
||||
? int.tryParse(questMonsterData.split('|').elementAtOrNull(1) ?? '') ??
|
||||
0
|
||||
: null;
|
||||
|
||||
// 몬스터 생성
|
||||
final monsterResult = pq_logic.monsterTask(
|
||||
config,
|
||||
state.rng,
|
||||
level,
|
||||
questMonsterData,
|
||||
questLevel,
|
||||
);
|
||||
|
||||
// 몬스터 레벨 조정 (밸런스)
|
||||
final actMinLevel = ActMonsterLevel.forPlotStage(
|
||||
state.progress.plotStageCount,
|
||||
);
|
||||
final baseLevel = math.max(level, actMinLevel);
|
||||
final effectiveMonsterLevel = monsterResult.level
|
||||
.clamp(math.max(1, baseLevel - 3), baseLevel + 3)
|
||||
.toInt();
|
||||
|
||||
// 전투 스탯 생성
|
||||
final playerCombatStats = CombatStats.fromStats(
|
||||
stats: state.stats,
|
||||
equipment: state.equipment,
|
||||
level: level,
|
||||
monsterLevel: effectiveMonsterLevel,
|
||||
);
|
||||
|
||||
final monsterCombatStats = MonsterCombatStats.fromLevel(
|
||||
name: monsterResult.displayName,
|
||||
level: effectiveMonsterLevel,
|
||||
speedType: MonsterCombatStats.inferSpeedType(monsterResult.baseName),
|
||||
plotStageCount: state.progress.plotStageCount,
|
||||
);
|
||||
|
||||
// 전투 상태 및 지속시간
|
||||
final combatState = CombatState.start(
|
||||
playerStats: playerCombatStats,
|
||||
monsterStats: monsterCombatStats,
|
||||
);
|
||||
|
||||
final combatCalculator = CombatCalculator(rng: state.rng);
|
||||
final durationMillis = combatCalculator.estimateCombatDurationMs(
|
||||
player: playerCombatStats,
|
||||
monster: monsterCombatStats,
|
||||
);
|
||||
|
||||
final taskResult = pq_logic.startTask(
|
||||
progress,
|
||||
l10n.taskDebugging(monsterResult.displayName),
|
||||
durationMillis,
|
||||
);
|
||||
|
||||
// 몬스터 사이즈 결정
|
||||
final monsterSize = getMonsterSizeForAct(
|
||||
plotStageCount: state.progress.plotStageCount,
|
||||
grade: monsterResult.grade,
|
||||
rng: state.rng,
|
||||
);
|
||||
|
||||
final updatedProgress = taskResult.progress.copyWith(
|
||||
currentTask: TaskInfo(
|
||||
caption: taskResult.caption,
|
||||
type: TaskType.kill,
|
||||
monsterBaseName: monsterResult.baseName,
|
||||
monsterPart: monsterResult.part,
|
||||
monsterLevel: effectiveMonsterLevel,
|
||||
monsterGrade: monsterResult.grade,
|
||||
monsterSize: monsterSize,
|
||||
),
|
||||
currentCombat: combatState,
|
||||
);
|
||||
|
||||
return (progress: updatedProgress, queue: queue);
|
||||
}
|
||||
|
||||
/// Advances quest completion, applies reward, and enqueues next quest task.
|
||||
GameState completeQuest(GameState state) {
|
||||
final result = pq_logic.completeQuest(
|
||||
@@ -1069,184 +829,4 @@ class ProgressService {
|
||||
final progress = state.progress.copyWith(encumbrance: encumBar);
|
||||
return state.copyWith(progress: progress);
|
||||
}
|
||||
|
||||
/// 킬 태스크 완료 시 전리품 획득 (원본 Main.pas:625-630)
|
||||
/// 전리품 획득 결과
|
||||
///
|
||||
/// [state] 업데이트된 게임 상태
|
||||
/// [droppedPotion] 드랍된 물약 (없으면 null)
|
||||
({GameState state, Potion? droppedPotion}) _winLoot(GameState state) {
|
||||
final taskInfo = state.progress.currentTask;
|
||||
final monsterPart = taskInfo.monsterPart ?? '';
|
||||
final monsterBaseName = taskInfo.monsterBaseName ?? '';
|
||||
|
||||
var resultState = state;
|
||||
|
||||
// 부위가 '*'이면 WinItem 호출 (특수 아이템)
|
||||
if (monsterPart == '*') {
|
||||
resultState = mutations.winItem(resultState);
|
||||
} else if (monsterPart.isNotEmpty && monsterBaseName.isNotEmpty) {
|
||||
// 원본: Add(Inventory, LowerCase(Split(fTask.Caption,1) + ' ' +
|
||||
// ProperCase(Split(fTask.Caption,3))), 1);
|
||||
// 예: "goblin Claw" 형태로 인벤토리 추가
|
||||
final itemName =
|
||||
'${monsterBaseName.toLowerCase()} ${_properCase(monsterPart)}';
|
||||
|
||||
// 인벤토리에 추가
|
||||
final items = [...resultState.inventory.items];
|
||||
final existing = items.indexWhere((e) => e.name == itemName);
|
||||
if (existing >= 0) {
|
||||
items[existing] = items[existing].copyWith(
|
||||
count: items[existing].count + 1,
|
||||
);
|
||||
} else {
|
||||
items.add(InventoryEntry(name: itemName, count: 1));
|
||||
}
|
||||
|
||||
resultState = resultState.copyWith(
|
||||
inventory: resultState.inventory.copyWith(items: items),
|
||||
);
|
||||
}
|
||||
|
||||
// 물약 드랍 시도
|
||||
final potionService = const PotionService();
|
||||
final rng = resultState.rng;
|
||||
final monsterLevel = taskInfo.monsterLevel ?? resultState.traits.level;
|
||||
final monsterGrade = taskInfo.monsterGrade ?? MonsterGrade.normal;
|
||||
final (updatedPotionInventory, droppedPotion) = potionService.tryPotionDrop(
|
||||
playerLevel: resultState.traits.level,
|
||||
monsterLevel: monsterLevel,
|
||||
monsterGrade: monsterGrade,
|
||||
inventory: resultState.potionInventory,
|
||||
roll: rng.nextInt(100),
|
||||
typeRoll: rng.nextInt(100),
|
||||
);
|
||||
|
||||
return (
|
||||
state: resultState.copyWith(
|
||||
rng: rng,
|
||||
potionInventory: updatedPotionInventory,
|
||||
),
|
||||
droppedPotion: droppedPotion,
|
||||
);
|
||||
}
|
||||
|
||||
/// 첫 글자만 대문자로 변환 (원본 ProperCase)
|
||||
String _properCase(String s) {
|
||||
if (s.isEmpty) return s;
|
||||
return s[0].toUpperCase() + s.substring(1);
|
||||
}
|
||||
|
||||
/// 플레이어 사망 처리 (Phase 4)
|
||||
///
|
||||
/// 모든 장비 상실 및 사망 정보 기록
|
||||
/// 보스전 사망 시: 장비 보호 + 5분 레벨링 모드 진입
|
||||
GameState _processPlayerDeath(
|
||||
GameState state, {
|
||||
required String killerName,
|
||||
required DeathCause cause,
|
||||
}) {
|
||||
// 사망 직전 전투 이벤트 저장 (최대 10개)
|
||||
final lastCombatEvents =
|
||||
state.progress.currentCombat?.recentEvents ?? const [];
|
||||
|
||||
// 보스전 사망 여부 확인 (최종 보스 fighting 상태)
|
||||
final isBossDeath =
|
||||
state.progress.finalBossState == FinalBossState.fighting;
|
||||
|
||||
// 보스전 사망이 아닐 경우에만 장비 손실
|
||||
var newEquipment = state.equipment;
|
||||
var lostCount = 0;
|
||||
String? lostItemName;
|
||||
EquipmentSlot? lostItemSlot;
|
||||
ItemRarity? lostItemRarity;
|
||||
EquipmentItem? lostEquipmentItem; // 광고 부활 시 복구용
|
||||
|
||||
if (!isBossDeath) {
|
||||
// 레벨 기반 장비 손실 확률 계산
|
||||
// Lv 1: 20%, Lv 5: ~56%, Lv 10+: 100%
|
||||
// 공식: 20 + (level - 1) * 80 / 9
|
||||
final level = state.traits.level;
|
||||
final lossChancePercent = level >= 10
|
||||
? 100
|
||||
: (20 + ((level - 1) * 80 ~/ 9)).clamp(0, 100);
|
||||
final roll = state.rng.nextInt(100); // 0~99
|
||||
final shouldLoseEquipment = roll < lossChancePercent;
|
||||
|
||||
// ignore: avoid_print
|
||||
print(
|
||||
'[Death] Lv$level lossChance=$lossChancePercent% roll=$roll '
|
||||
'shouldLose=$shouldLoseEquipment',
|
||||
);
|
||||
|
||||
if (shouldLoseEquipment) {
|
||||
// 무기(슬롯 0)를 제외한 장착된 장비 중 1개를 제물로 삭제
|
||||
final equippedNonWeaponSlots = <int>[];
|
||||
for (var i = 1; i < Equipment.slotCount; i++) {
|
||||
final item = state.equipment.getItemByIndex(i);
|
||||
if (item.isNotEmpty) {
|
||||
equippedNonWeaponSlots.add(i);
|
||||
}
|
||||
}
|
||||
|
||||
if (equippedNonWeaponSlots.isNotEmpty) {
|
||||
lostCount = 1;
|
||||
// 랜덤하게 1개 슬롯 선택
|
||||
final sacrificeIndex =
|
||||
equippedNonWeaponSlots[state.rng.nextInt(
|
||||
equippedNonWeaponSlots.length,
|
||||
)];
|
||||
|
||||
// 제물로 바칠 아이템 정보 저장
|
||||
lostEquipmentItem = state.equipment.getItemByIndex(sacrificeIndex);
|
||||
lostItemName = lostEquipmentItem.name;
|
||||
lostItemSlot = EquipmentSlot.values[sacrificeIndex];
|
||||
lostItemRarity = lostEquipmentItem.rarity;
|
||||
|
||||
// 해당 슬롯을 빈 장비로 교체
|
||||
newEquipment = newEquipment.setItemByIndex(
|
||||
sacrificeIndex,
|
||||
EquipmentItem.empty(lostItemSlot),
|
||||
);
|
||||
|
||||
// ignore: avoid_print
|
||||
print('[Death] Lost item: $lostItemName (slot: $lostItemSlot)');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 사망 정보 생성 (전투 로그 포함)
|
||||
final deathInfo = DeathInfo(
|
||||
cause: cause,
|
||||
killerName: killerName,
|
||||
lostEquipmentCount: lostCount,
|
||||
lostItemName: lostItemName,
|
||||
lostItemSlot: lostItemSlot,
|
||||
lostItemRarity: lostItemRarity,
|
||||
lostItem: lostEquipmentItem, // 광고 부활 시 복구용
|
||||
goldAtDeath: state.inventory.gold,
|
||||
levelAtDeath: state.traits.level,
|
||||
timestamp: state.skillSystem.elapsedMs,
|
||||
lastCombatEvents: lastCombatEvents,
|
||||
);
|
||||
|
||||
// 보스전 사망 시 5분 레벨링 모드 진입
|
||||
final bossLevelingEndTime = isBossDeath
|
||||
? DateTime.now().millisecondsSinceEpoch +
|
||||
(5 * 60 * 1000) // 5분
|
||||
: null;
|
||||
|
||||
// 전투 상태 초기화 및 사망 횟수 증가
|
||||
final progress = state.progress.copyWith(
|
||||
currentCombat: null,
|
||||
deathCount: state.progress.deathCount + 1,
|
||||
bossLevelingEndTime: bossLevelingEndTime,
|
||||
);
|
||||
|
||||
return state.copyWith(
|
||||
equipment: newEquipment,
|
||||
progress: progress,
|
||||
deathInfo: deathInfo,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
200
lib/src/core/engine/skill_auto_selector.dart
Normal file
200
lib/src/core/engine/skill_auto_selector.dart
Normal file
@@ -0,0 +1,200 @@
|
||||
import 'package:asciineverdie/data/skill_data.dart';
|
||||
import 'package:asciineverdie/src/core/model/combat_stats.dart';
|
||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||
import 'package:asciineverdie/src/core/model/monster_combat_stats.dart';
|
||||
import 'package:asciineverdie/src/core/model/skill.dart';
|
||||
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
|
||||
|
||||
/// 스킬 자동 선택 AI
|
||||
///
|
||||
/// SkillService에서 분리된 전투 중 스킬 자동 선택 로직.
|
||||
/// 상황별 우선순위에 따라 최적의 스킬을 선택한다.
|
||||
class SkillAutoSelector {
|
||||
const SkillAutoSelector({required this.rng});
|
||||
|
||||
final DeterministicRandom rng;
|
||||
|
||||
/// 전투 중 자동 스킬 선택
|
||||
///
|
||||
/// 우선순위:
|
||||
/// 1. HP < 30% -> 회복 스킬 (최우선)
|
||||
/// 2. 70% 확률로 일반 공격 (MP 절약, 기본 전투)
|
||||
/// 3. 30% 확률로 스킬 사용:
|
||||
/// - 버프: HP > 80% & MP > 60% & 활성 버프 없음
|
||||
/// - 디버프: 몬스터 HP > 80% & 활성 디버프 없음
|
||||
/// - DOT: 몬스터 HP > 60% & 활성 DOT 없음
|
||||
/// - 공격: 보스전이면 강력한 스킬, 일반전이면 효율적 스킬
|
||||
/// 4. MP < 20% -> 일반 공격
|
||||
Skill? selectAutoSkill({
|
||||
required CombatStats player,
|
||||
required MonsterCombatStats monster,
|
||||
required SkillSystemState skillSystem,
|
||||
required List<String> availableSkillIds,
|
||||
required bool Function(Skill) canUse,
|
||||
List<DotEffect> activeDoTs = const [],
|
||||
List<ActiveBuff> activeDebuffs = const [],
|
||||
}) {
|
||||
final mpRatio = player.mpRatio;
|
||||
final hpRatio = player.hpRatio;
|
||||
|
||||
// MP 20% 미만이면 일반 공격
|
||||
if (mpRatio < 0.2) return null;
|
||||
|
||||
// 사용 가능한 스킬 필터링
|
||||
final availableSkills = availableSkillIds
|
||||
.map((id) => SkillData.getSkillById(id))
|
||||
.whereType<Skill>()
|
||||
.where(canUse)
|
||||
.toList();
|
||||
|
||||
if (availableSkills.isEmpty) return null;
|
||||
|
||||
// HP < 30% -> 회복 스킬 최우선 (생존)
|
||||
if (hpRatio < 0.3) {
|
||||
final healSkill = _findBestHealSkill(availableSkills, player.mpCurrent);
|
||||
if (healSkill != null) return healSkill;
|
||||
}
|
||||
|
||||
// 70% 확률로 일반 공격 (스킬은 특별한 상황에서만)
|
||||
final useNormalAttack = rng.nextInt(100) < 70;
|
||||
if (useNormalAttack) return null;
|
||||
|
||||
// === 아래부터 30% 확률로 스킬 사용 ===
|
||||
|
||||
// 버프: HP > 80% & MP > 60% (매우 안전할 때만)
|
||||
if (hpRatio > 0.8 && mpRatio > 0.6) {
|
||||
final hasActiveBuff = skillSystem.activeBuffs.isNotEmpty;
|
||||
if (!hasActiveBuff) {
|
||||
final buffSkill = _findBestBuffSkill(availableSkills, player.mpCurrent);
|
||||
if (buffSkill != null) return buffSkill;
|
||||
}
|
||||
}
|
||||
|
||||
// 디버프: 몬스터 HP > 80% & 활성 디버프 없음 (전투 초반)
|
||||
if (monster.hpRatio > 0.8 && activeDebuffs.isEmpty) {
|
||||
final debuffSkill = _findBestDebuffSkill(
|
||||
availableSkills,
|
||||
player.mpCurrent,
|
||||
);
|
||||
if (debuffSkill != null) return debuffSkill;
|
||||
}
|
||||
|
||||
// DOT: 몬스터 HP > 60% & 활성 DOT 없음 (장기전 유리)
|
||||
if (monster.hpRatio > 0.6 && activeDoTs.isEmpty) {
|
||||
final dotSkill = _findBestDotSkill(availableSkills, player.mpCurrent);
|
||||
if (dotSkill != null) return dotSkill;
|
||||
}
|
||||
|
||||
// 보스전 판단 (몬스터 레벨 20 이상 & HP 50% 이상)
|
||||
final isBossFight = monster.level >= 20 && monster.hpRatio > 0.5;
|
||||
|
||||
if (isBossFight) {
|
||||
return _findStrongestAttackSkill(availableSkills);
|
||||
}
|
||||
|
||||
// 일반 전투 -> MP 효율 좋은 공격 스킬
|
||||
return _findEfficientAttackSkill(availableSkills);
|
||||
}
|
||||
|
||||
/// 가장 좋은 DOT 스킬 찾기
|
||||
Skill? _findBestDotSkill(List<Skill> skills, int currentMp) {
|
||||
final dotSkills = skills
|
||||
.where((s) => s.isDot && s.mpCost <= currentMp)
|
||||
.toList();
|
||||
|
||||
if (dotSkills.isEmpty) return null;
|
||||
|
||||
dotSkills.sort((a, b) {
|
||||
final aTotal =
|
||||
(a.baseDotDamage ?? 0) *
|
||||
((a.baseDotDurationMs ?? 0) ~/ (a.baseDotTickMs ?? 1000));
|
||||
final bTotal =
|
||||
(b.baseDotDamage ?? 0) *
|
||||
((b.baseDotDurationMs ?? 0) ~/ (b.baseDotTickMs ?? 1000));
|
||||
return bTotal.compareTo(aTotal);
|
||||
});
|
||||
|
||||
return dotSkills.first;
|
||||
}
|
||||
|
||||
/// 가장 좋은 회복 스킬 찾기
|
||||
Skill? _findBestHealSkill(List<Skill> skills, int currentMp) {
|
||||
final healSkills = skills
|
||||
.where((s) => s.isHeal && s.mpCost <= currentMp)
|
||||
.toList();
|
||||
|
||||
if (healSkills.isEmpty) return null;
|
||||
|
||||
healSkills.sort((a, b) {
|
||||
final aValue = a.healPercent * 100 + a.healAmount;
|
||||
final bValue = b.healPercent * 100 + b.healAmount;
|
||||
return bValue.compareTo(aValue);
|
||||
});
|
||||
|
||||
return healSkills.first;
|
||||
}
|
||||
|
||||
/// 가장 강력한 공격 스킬 찾기
|
||||
Skill? _findStrongestAttackSkill(List<Skill> skills) {
|
||||
final attackSkills = skills.where((s) => s.isAttack).toList();
|
||||
if (attackSkills.isEmpty) return null;
|
||||
|
||||
attackSkills.sort(
|
||||
(a, b) => b.damageMultiplier.compareTo(a.damageMultiplier),
|
||||
);
|
||||
return attackSkills.first;
|
||||
}
|
||||
|
||||
/// MP 효율 좋은 공격 스킬 찾기
|
||||
Skill? _findEfficientAttackSkill(List<Skill> skills) {
|
||||
final attackSkills = skills.where((s) => s.isAttack).toList();
|
||||
if (attackSkills.isEmpty) return null;
|
||||
|
||||
attackSkills.sort((a, b) => b.mpEfficiency.compareTo(a.mpEfficiency));
|
||||
return attackSkills.first;
|
||||
}
|
||||
|
||||
/// 가장 좋은 버프 스킬 찾기
|
||||
Skill? _findBestBuffSkill(List<Skill> skills, int currentMp) {
|
||||
final buffSkills = skills
|
||||
.where((s) => s.isBuff && s.mpCost <= currentMp && s.buff != null)
|
||||
.toList();
|
||||
|
||||
if (buffSkills.isEmpty) return null;
|
||||
|
||||
buffSkills.sort((a, b) {
|
||||
final aValue =
|
||||
(a.buff?.atkModifier ?? 0) +
|
||||
(a.buff?.defModifier ?? 0) * 0.5 +
|
||||
(a.buff?.criRateModifier ?? 0) * 0.3;
|
||||
final bValue =
|
||||
(b.buff?.atkModifier ?? 0) +
|
||||
(b.buff?.defModifier ?? 0) * 0.5 +
|
||||
(b.buff?.criRateModifier ?? 0) * 0.3;
|
||||
return bValue.compareTo(aValue);
|
||||
});
|
||||
|
||||
return buffSkills.first;
|
||||
}
|
||||
|
||||
/// 가장 좋은 디버프 스킬 찾기
|
||||
Skill? _findBestDebuffSkill(List<Skill> skills, int currentMp) {
|
||||
final debuffSkills = skills
|
||||
.where((s) => s.isDebuff && s.mpCost <= currentMp && s.buff != null)
|
||||
.toList();
|
||||
|
||||
if (debuffSkills.isEmpty) return null;
|
||||
|
||||
debuffSkills.sort((a, b) {
|
||||
final aValue =
|
||||
(a.buff?.atkModifier ?? 0).abs() +
|
||||
(a.buff?.defModifier ?? 0).abs() * 0.5;
|
||||
final bValue =
|
||||
(b.buff?.atkModifier ?? 0).abs() +
|
||||
(b.buff?.defModifier ?? 0).abs() * 0.5;
|
||||
return bValue.compareTo(aValue);
|
||||
});
|
||||
|
||||
return debuffSkills.first;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:asciineverdie/data/skill_data.dart';
|
||||
import 'package:asciineverdie/src/core/engine/skill_auto_selector.dart';
|
||||
import 'package:asciineverdie/src/core/model/combat_stats.dart';
|
||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||
import 'package:asciineverdie/src/core/model/monster_combat_stats.dart';
|
||||
@@ -309,20 +310,12 @@ class SkillService {
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 자동 스킬 선택
|
||||
// 자동 스킬 선택 (SkillAutoSelector에 위임)
|
||||
// ============================================================================
|
||||
|
||||
/// 전투 중 자동 스킬 선택
|
||||
///
|
||||
/// 우선순위:
|
||||
/// 1. HP < 30% → 회복 스킬 (최우선)
|
||||
/// 2. 70% 확률로 일반 공격 (MP 절약, 기본 전투)
|
||||
/// 3. 30% 확률로 스킬 사용:
|
||||
/// - 버프: HP > 80% & MP > 60% & 활성 버프 없음
|
||||
/// - 디버프: 몬스터 HP > 80% & 활성 디버프 없음
|
||||
/// - DOT: 몬스터 HP > 60% & 활성 DOT 없음
|
||||
/// - 공격: 보스전이면 강력한 스킬, 일반전이면 효율적 스킬
|
||||
/// 4. MP < 20% → 일반 공격
|
||||
/// 세부 로직은 SkillAutoSelector에 위임.
|
||||
Skill? selectAutoSkill({
|
||||
required CombatStats player,
|
||||
required MonsterCombatStats monster,
|
||||
@@ -331,186 +324,22 @@ class SkillService {
|
||||
List<DotEffect> activeDoTs = const [],
|
||||
List<ActiveBuff> activeDebuffs = const [],
|
||||
}) {
|
||||
final currentMp = player.mpCurrent;
|
||||
final mpRatio = player.mpRatio;
|
||||
final hpRatio = player.hpRatio;
|
||||
|
||||
// MP 20% 미만이면 일반 공격
|
||||
if (mpRatio < 0.2) return null;
|
||||
|
||||
// 사용 가능한 스킬 필터링
|
||||
final availableSkills = availableSkillIds
|
||||
.map((id) => SkillData.getSkillById(id))
|
||||
.whereType<Skill>()
|
||||
.where(
|
||||
(skill) =>
|
||||
final selector = SkillAutoSelector(rng: rng);
|
||||
return selector.selectAutoSkill(
|
||||
player: player,
|
||||
monster: monster,
|
||||
skillSystem: skillSystem,
|
||||
availableSkillIds: availableSkillIds,
|
||||
canUse: (skill) =>
|
||||
canUseSkill(
|
||||
skill: skill,
|
||||
currentMp: currentMp,
|
||||
currentMp: player.mpCurrent,
|
||||
skillSystem: skillSystem,
|
||||
) ==
|
||||
null,
|
||||
)
|
||||
.toList();
|
||||
|
||||
if (availableSkills.isEmpty) return null;
|
||||
|
||||
// HP < 30% → 회복 스킬 최우선 (생존)
|
||||
if (hpRatio < 0.3) {
|
||||
final healSkill = _findBestHealSkill(availableSkills, currentMp);
|
||||
if (healSkill != null) return healSkill;
|
||||
}
|
||||
|
||||
// 70% 확률로 일반 공격 (스킬은 특별한 상황에서만)
|
||||
final useNormalAttack = rng.nextInt(100) < 70;
|
||||
if (useNormalAttack) return null;
|
||||
|
||||
// === 아래부터 30% 확률로 스킬 사용 ===
|
||||
|
||||
// 버프: HP > 80% & MP > 60% (매우 안전할 때만)
|
||||
// 활성 버프가 있으면 건너뜀 (중복 방지)
|
||||
if (hpRatio > 0.8 && mpRatio > 0.6) {
|
||||
final hasActiveBuff = skillSystem.activeBuffs.isNotEmpty;
|
||||
if (!hasActiveBuff) {
|
||||
final buffSkill = _findBestBuffSkill(availableSkills, currentMp);
|
||||
if (buffSkill != null) return buffSkill;
|
||||
}
|
||||
}
|
||||
|
||||
// 디버프: 몬스터 HP > 80% & 활성 디버프 없음 (전투 초반)
|
||||
if (monster.hpRatio > 0.8 && activeDebuffs.isEmpty) {
|
||||
final debuffSkill = _findBestDebuffSkill(availableSkills, currentMp);
|
||||
if (debuffSkill != null) return debuffSkill;
|
||||
}
|
||||
|
||||
// DOT: 몬스터 HP > 60% & 활성 DOT 없음 (장기전 유리)
|
||||
if (monster.hpRatio > 0.6 && activeDoTs.isEmpty) {
|
||||
final dotSkill = _findBestDotSkill(availableSkills, currentMp);
|
||||
if (dotSkill != null) return dotSkill;
|
||||
}
|
||||
|
||||
// 보스전 판단 (몬스터 레벨 20 이상 & HP 50% 이상)
|
||||
final isBossFight = monster.level >= 20 && monster.hpRatio > 0.5;
|
||||
|
||||
if (isBossFight) {
|
||||
// 가장 강력한 공격 스킬
|
||||
return _findStrongestAttackSkill(availableSkills);
|
||||
}
|
||||
|
||||
// 일반 전투 → MP 효율 좋은 공격 스킬
|
||||
return _findEfficientAttackSkill(availableSkills);
|
||||
}
|
||||
|
||||
/// 가장 좋은 DOT 스킬 찾기
|
||||
///
|
||||
/// 예상 총 데미지 (틱 × 데미지) 기준으로 선택
|
||||
Skill? _findBestDotSkill(List<Skill> skills, int currentMp) {
|
||||
final dotSkills = skills
|
||||
.where((s) => s.isDot && s.mpCost <= currentMp)
|
||||
.toList();
|
||||
|
||||
if (dotSkills.isEmpty) return null;
|
||||
|
||||
// 예상 총 데미지 기준 정렬
|
||||
dotSkills.sort((a, b) {
|
||||
final aTotal =
|
||||
(a.baseDotDamage ?? 0) *
|
||||
((a.baseDotDurationMs ?? 0) ~/ (a.baseDotTickMs ?? 1000));
|
||||
final bTotal =
|
||||
(b.baseDotDamage ?? 0) *
|
||||
((b.baseDotDurationMs ?? 0) ~/ (b.baseDotTickMs ?? 1000));
|
||||
return bTotal.compareTo(aTotal);
|
||||
});
|
||||
|
||||
return dotSkills.first;
|
||||
}
|
||||
|
||||
/// 가장 좋은 회복 스킬 찾기
|
||||
Skill? _findBestHealSkill(List<Skill> skills, int currentMp) {
|
||||
final healSkills = skills
|
||||
.where((s) => s.isHeal && s.mpCost <= currentMp)
|
||||
.toList();
|
||||
|
||||
if (healSkills.isEmpty) return null;
|
||||
|
||||
// 회복량 기준 정렬 (% 회복 > 고정 회복)
|
||||
healSkills.sort((a, b) {
|
||||
final aValue = a.healPercent * 100 + a.healAmount;
|
||||
final bValue = b.healPercent * 100 + b.healAmount;
|
||||
return bValue.compareTo(aValue);
|
||||
});
|
||||
|
||||
return healSkills.first;
|
||||
}
|
||||
|
||||
/// 가장 강력한 공격 스킬 찾기
|
||||
Skill? _findStrongestAttackSkill(List<Skill> skills) {
|
||||
final attackSkills = skills.where((s) => s.isAttack).toList();
|
||||
if (attackSkills.isEmpty) return null;
|
||||
|
||||
attackSkills.sort(
|
||||
(a, b) => b.damageMultiplier.compareTo(a.damageMultiplier),
|
||||
activeDoTs: activeDoTs,
|
||||
activeDebuffs: activeDebuffs,
|
||||
);
|
||||
return attackSkills.first;
|
||||
}
|
||||
|
||||
/// MP 효율 좋은 공격 스킬 찾기
|
||||
Skill? _findEfficientAttackSkill(List<Skill> skills) {
|
||||
final attackSkills = skills.where((s) => s.isAttack).toList();
|
||||
if (attackSkills.isEmpty) return null;
|
||||
|
||||
attackSkills.sort((a, b) => b.mpEfficiency.compareTo(a.mpEfficiency));
|
||||
return attackSkills.first;
|
||||
}
|
||||
|
||||
/// 가장 좋은 버프 스킬 찾기
|
||||
///
|
||||
/// ATK 증가 버프 우선, 그 다음 복합 버프
|
||||
Skill? _findBestBuffSkill(List<Skill> skills, int currentMp) {
|
||||
final buffSkills = skills
|
||||
.where((s) => s.isBuff && s.mpCost <= currentMp && s.buff != null)
|
||||
.toList();
|
||||
|
||||
if (buffSkills.isEmpty) return null;
|
||||
|
||||
// ATK 증가량 기준 정렬
|
||||
buffSkills.sort((a, b) {
|
||||
final aValue =
|
||||
(a.buff?.atkModifier ?? 0) +
|
||||
(a.buff?.defModifier ?? 0) * 0.5 +
|
||||
(a.buff?.criRateModifier ?? 0) * 0.3;
|
||||
final bValue =
|
||||
(b.buff?.atkModifier ?? 0) +
|
||||
(b.buff?.defModifier ?? 0) * 0.5 +
|
||||
(b.buff?.criRateModifier ?? 0) * 0.3;
|
||||
return bValue.compareTo(aValue);
|
||||
});
|
||||
|
||||
return buffSkills.first;
|
||||
}
|
||||
|
||||
/// 가장 좋은 디버프 스킬 찾기
|
||||
///
|
||||
/// 적 ATK 감소 디버프 우선
|
||||
Skill? _findBestDebuffSkill(List<Skill> skills, int currentMp) {
|
||||
final debuffSkills = skills
|
||||
.where((s) => s.isDebuff && s.mpCost <= currentMp && s.buff != null)
|
||||
.toList();
|
||||
|
||||
if (debuffSkills.isEmpty) return null;
|
||||
|
||||
// 디버프 효과 크기 기준 정렬 (음수 값이므로 절대값으로 비교)
|
||||
debuffSkills.sort((a, b) {
|
||||
final aValue =
|
||||
(a.buff?.atkModifier ?? 0).abs() +
|
||||
(a.buff?.defModifier ?? 0).abs() * 0.5;
|
||||
final bValue =
|
||||
(b.buff?.atkModifier ?? 0).abs() +
|
||||
(b.buff?.defModifier ?? 0).abs() * 0.5;
|
||||
return bValue.compareTo(aValue);
|
||||
});
|
||||
|
||||
return debuffSkills.first;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
259
lib/src/core/engine/task_generator.dart
Normal file
259
lib/src/core/engine/task_generator.dart
Normal file
@@ -0,0 +1,259 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
|
||||
import 'package:asciineverdie/src/shared/animation/monster_size.dart';
|
||||
import 'package:asciineverdie/src/core/engine/act_progression_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/combat_calculator.dart';
|
||||
import 'package:asciineverdie/src/core/model/combat_state.dart';
|
||||
import 'package:asciineverdie/src/core/model/combat_stats.dart';
|
||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||
import 'package:asciineverdie/src/core/model/monster_combat_stats.dart';
|
||||
import 'package:asciineverdie/src/core/model/monster_grade.dart';
|
||||
import 'package:asciineverdie/src/core/model/pq_config.dart';
|
||||
import 'package:asciineverdie/src/core/util/balance_constants.dart';
|
||||
import 'package:asciineverdie/src/core/util/pq_logic.dart' as pq_logic;
|
||||
|
||||
/// 태스크 생성 서비스
|
||||
///
|
||||
/// ProgressService에서 분리된 다음 태스크 생성 로직 담당:
|
||||
/// - 시장 이동, 전환 태스크, 보스 리트라이, 몬스터 전투 생성
|
||||
class TaskGenerator {
|
||||
const TaskGenerator({required this.config});
|
||||
|
||||
final PqConfig config;
|
||||
|
||||
/// 큐가 비어있을 때 다음 태스크 생성 (원본 Dequeue 667-684줄)
|
||||
({ProgressState progress, QueueState queue}) generateNextTask(
|
||||
GameState state,
|
||||
) {
|
||||
var progress = state.progress;
|
||||
final queue = state.queue;
|
||||
final oldTaskType = progress.currentTask.type;
|
||||
|
||||
// 1. Encumbrance 초과 시 시장 이동
|
||||
if (_shouldGoToMarket(progress)) {
|
||||
return _createMarketTask(progress, queue);
|
||||
}
|
||||
|
||||
// 2. 전환 태스크 (buying/heading)
|
||||
if (_needsTransitionTask(oldTaskType)) {
|
||||
return _createTransitionTask(state, progress, queue);
|
||||
}
|
||||
|
||||
// 3. Act Boss 리트라이
|
||||
if (state.progress.pendingActCompletion) {
|
||||
return _createActBossRetryTask(state, progress, queue);
|
||||
}
|
||||
|
||||
// 4. 최종 보스 전투
|
||||
if (state.progress.finalBossState == FinalBossState.fighting &&
|
||||
!state.progress.isInBossLevelingMode) {
|
||||
if (state.progress.bossLevelingEndTime != null) {
|
||||
progress = progress.copyWith(clearBossLevelingEndTime: true);
|
||||
}
|
||||
final actProgressionService = ActProgressionService(config: config);
|
||||
return actProgressionService.startFinalBossFight(state, progress, queue);
|
||||
}
|
||||
|
||||
// 5. 일반 몬스터 전투
|
||||
return _createMonsterTask(state, progress, queue);
|
||||
}
|
||||
|
||||
/// 시장 이동 조건 확인
|
||||
bool _shouldGoToMarket(ProgressState progress) {
|
||||
return progress.encumbrance.position >= progress.encumbrance.max &&
|
||||
progress.encumbrance.max > 0;
|
||||
}
|
||||
|
||||
/// 전환 태스크 필요 여부 확인
|
||||
bool _needsTransitionTask(TaskType oldTaskType) {
|
||||
return oldTaskType != TaskType.kill &&
|
||||
oldTaskType != TaskType.neutral &&
|
||||
oldTaskType != TaskType.buying;
|
||||
}
|
||||
|
||||
/// 시장 이동 태스크 생성
|
||||
({ProgressState progress, QueueState queue}) _createMarketTask(
|
||||
ProgressState progress,
|
||||
QueueState queue,
|
||||
) {
|
||||
final taskResult = pq_logic.startTask(
|
||||
progress,
|
||||
l10n.taskHeadingToMarket(),
|
||||
4 * 1000,
|
||||
);
|
||||
final updatedProgress = taskResult.progress.copyWith(
|
||||
currentTask: TaskInfo(caption: taskResult.caption, type: TaskType.market),
|
||||
currentCombat: null,
|
||||
);
|
||||
return (progress: updatedProgress, queue: queue);
|
||||
}
|
||||
|
||||
/// 전환 태스크 생성 (buying 또는 heading)
|
||||
({ProgressState progress, QueueState queue}) _createTransitionTask(
|
||||
GameState state,
|
||||
ProgressState progress,
|
||||
QueueState queue,
|
||||
) {
|
||||
final gold = state.inventory.gold;
|
||||
final equipPrice = state.traits.level * 50;
|
||||
|
||||
// Gold 충분 시 장비 구매
|
||||
if (gold > equipPrice) {
|
||||
final taskResult = pq_logic.startTask(
|
||||
progress,
|
||||
l10n.taskUpgradingHardware(),
|
||||
5 * 1000,
|
||||
);
|
||||
final updatedProgress = taskResult.progress.copyWith(
|
||||
currentTask: TaskInfo(
|
||||
caption: taskResult.caption,
|
||||
type: TaskType.buying,
|
||||
),
|
||||
currentCombat: null,
|
||||
);
|
||||
return (progress: updatedProgress, queue: queue);
|
||||
}
|
||||
|
||||
// Gold 부족 시 전장 이동
|
||||
final taskResult = pq_logic.startTask(
|
||||
progress,
|
||||
l10n.taskEnteringDebugZone(),
|
||||
4 * 1000,
|
||||
);
|
||||
final updatedProgress = taskResult.progress.copyWith(
|
||||
currentTask: TaskInfo(
|
||||
caption: taskResult.caption,
|
||||
type: TaskType.neutral,
|
||||
),
|
||||
currentCombat: null,
|
||||
);
|
||||
return (progress: updatedProgress, queue: queue);
|
||||
}
|
||||
|
||||
/// Act Boss 재도전 태스크 생성
|
||||
({ProgressState progress, QueueState queue}) _createActBossRetryTask(
|
||||
GameState state,
|
||||
ProgressState progress,
|
||||
QueueState queue,
|
||||
) {
|
||||
final actProgressionService = ActProgressionService(config: config);
|
||||
final actBoss = actProgressionService.createActBoss(state);
|
||||
final combatCalculator = CombatCalculator(rng: state.rng);
|
||||
final durationMillis = combatCalculator.estimateCombatDurationMs(
|
||||
player: actBoss.playerStats,
|
||||
monster: actBoss.monsterStats,
|
||||
);
|
||||
|
||||
final taskResult = pq_logic.startTask(
|
||||
progress,
|
||||
l10n.taskDebugging(actBoss.monsterStats.name),
|
||||
durationMillis,
|
||||
);
|
||||
|
||||
final updatedProgress = taskResult.progress.copyWith(
|
||||
currentTask: TaskInfo(
|
||||
caption: taskResult.caption,
|
||||
type: TaskType.kill,
|
||||
monsterBaseName: actBoss.monsterStats.name,
|
||||
monsterPart: '*',
|
||||
monsterLevel: actBoss.monsterStats.level,
|
||||
monsterGrade: MonsterGrade.boss,
|
||||
monsterSize: getBossSizeForAct(state.progress.plotStageCount),
|
||||
),
|
||||
currentCombat: actBoss,
|
||||
);
|
||||
|
||||
return (progress: updatedProgress, queue: queue);
|
||||
}
|
||||
|
||||
/// 일반 몬스터 전투 태스크 생성
|
||||
({ProgressState progress, QueueState queue}) _createMonsterTask(
|
||||
GameState state,
|
||||
ProgressState progress,
|
||||
QueueState queue,
|
||||
) {
|
||||
final level = state.traits.level;
|
||||
|
||||
// 퀘스트 몬스터 데이터 확인
|
||||
final questMonster = state.progress.currentQuestMonster;
|
||||
final questMonsterData = questMonster?.monsterData;
|
||||
final questLevel = questMonsterData != null
|
||||
? int.tryParse(questMonsterData.split('|').elementAtOrNull(1) ?? '') ??
|
||||
0
|
||||
: null;
|
||||
|
||||
// 몬스터 생성
|
||||
final monsterResult = pq_logic.monsterTask(
|
||||
config,
|
||||
state.rng,
|
||||
level,
|
||||
questMonsterData,
|
||||
questLevel,
|
||||
);
|
||||
|
||||
// 몬스터 레벨 조정 (밸런스)
|
||||
final actMinLevel = ActMonsterLevel.forPlotStage(
|
||||
state.progress.plotStageCount,
|
||||
);
|
||||
final baseLevel = math.max(level, actMinLevel);
|
||||
final effectiveMonsterLevel = monsterResult.level
|
||||
.clamp(math.max(1, baseLevel - 3), baseLevel + 3)
|
||||
.toInt();
|
||||
|
||||
// 전투 스탯 생성
|
||||
final playerCombatStats = CombatStats.fromStats(
|
||||
stats: state.stats,
|
||||
equipment: state.equipment,
|
||||
level: level,
|
||||
monsterLevel: effectiveMonsterLevel,
|
||||
);
|
||||
|
||||
final monsterCombatStats = MonsterCombatStats.fromLevel(
|
||||
name: monsterResult.displayName,
|
||||
level: effectiveMonsterLevel,
|
||||
speedType: MonsterCombatStats.inferSpeedType(monsterResult.baseName),
|
||||
plotStageCount: state.progress.plotStageCount,
|
||||
);
|
||||
|
||||
// 전투 상태 및 지속시간
|
||||
final combatState = CombatState.start(
|
||||
playerStats: playerCombatStats,
|
||||
monsterStats: monsterCombatStats,
|
||||
);
|
||||
|
||||
final combatCalculator = CombatCalculator(rng: state.rng);
|
||||
final durationMillis = combatCalculator.estimateCombatDurationMs(
|
||||
player: playerCombatStats,
|
||||
monster: monsterCombatStats,
|
||||
);
|
||||
|
||||
final taskResult = pq_logic.startTask(
|
||||
progress,
|
||||
l10n.taskDebugging(monsterResult.displayName),
|
||||
durationMillis,
|
||||
);
|
||||
|
||||
// 몬스터 사이즈 결정
|
||||
final monsterSize = getMonsterSizeForAct(
|
||||
plotStageCount: state.progress.plotStageCount,
|
||||
grade: monsterResult.grade,
|
||||
rng: state.rng,
|
||||
);
|
||||
|
||||
final updatedProgress = taskResult.progress.copyWith(
|
||||
currentTask: TaskInfo(
|
||||
caption: taskResult.caption,
|
||||
type: TaskType.kill,
|
||||
monsterBaseName: monsterResult.baseName,
|
||||
monsterPart: monsterResult.part,
|
||||
monsterLevel: effectiveMonsterLevel,
|
||||
monsterGrade: monsterResult.grade,
|
||||
monsterSize: monsterSize,
|
||||
),
|
||||
currentCombat: combatState,
|
||||
);
|
||||
|
||||
return (progress: updatedProgress, queue: queue);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user