feat(arena): 아레나 서비스 추가
- 아레나 전투 로직 처리 - 명예의 전당 연동
This commit is contained in:
314
lib/src/core/engine/arena_service.dart
Normal file
314
lib/src/core/engine/arena_service.dart
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
import 'package:asciineverdie/src/core/engine/combat_calculator.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/hall_of_fame.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/monster_combat_stats.dart';
|
||||||
|
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
|
||||||
|
|
||||||
|
/// 아레나 서비스
|
||||||
|
///
|
||||||
|
/// 로컬 아레나 대전 시스템의 핵심 로직 담당:
|
||||||
|
/// - 순위 계산 및 상대 결정
|
||||||
|
/// - 전투 실행
|
||||||
|
/// - 장비 교환
|
||||||
|
class ArenaService {
|
||||||
|
ArenaService({DeterministicRandom? rng})
|
||||||
|
: _rng = rng ?? DeterministicRandom(DateTime.now().millisecondsSinceEpoch);
|
||||||
|
|
||||||
|
final DeterministicRandom _rng;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 상대 결정
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// 상대 결정 (바로 위 순위, 1위면 2위와 대결)
|
||||||
|
///
|
||||||
|
/// [hallOfFame] 명예의 전당
|
||||||
|
/// [challengerId] 도전자 캐릭터 ID
|
||||||
|
/// Returns: 상대 캐릭터 (없으면 null)
|
||||||
|
HallOfFameEntry? findOpponent(HallOfFame hallOfFame, String challengerId) {
|
||||||
|
final ranked = hallOfFame.rankedEntries;
|
||||||
|
if (ranked.length < 2) return null;
|
||||||
|
|
||||||
|
final currentRank = hallOfFame.getRank(challengerId);
|
||||||
|
if (currentRank <= 0) return null;
|
||||||
|
|
||||||
|
// 1위면 2위와 대결
|
||||||
|
if (currentRank == 1) {
|
||||||
|
return ranked[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 그 외는 바로 위 순위와 대결
|
||||||
|
return ranked[currentRank - 2];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 전투 실행
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// 아레나 전투 실행
|
||||||
|
///
|
||||||
|
/// [match] 대전 정보
|
||||||
|
/// Returns: 대전 결과 (승패, 장비 교환 후 캐릭터)
|
||||||
|
ArenaMatchResult executeCombat(ArenaMatch match) {
|
||||||
|
final calculator = CombatCalculator(rng: _rng);
|
||||||
|
|
||||||
|
// 도전자 스탯 (풀 HP로 시작)
|
||||||
|
final challengerStats = match.challenger.finalStats;
|
||||||
|
final opponentStats = match.opponent.finalStats;
|
||||||
|
|
||||||
|
if (challengerStats == null || opponentStats == null) {
|
||||||
|
// 스탯이 없으면 도전자 패배 처리
|
||||||
|
return ArenaMatchResult(
|
||||||
|
match: match,
|
||||||
|
isVictory: false,
|
||||||
|
turns: 0,
|
||||||
|
updatedChallenger: match.challenger,
|
||||||
|
updatedOpponent: match.opponent,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 플레이어 스탯 (풀 HP로 초기화)
|
||||||
|
var playerCombatStats = challengerStats.copyWith(
|
||||||
|
hpCurrent: challengerStats.hpMax,
|
||||||
|
mpCurrent: challengerStats.mpMax,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 상대를 몬스터 형태로 변환
|
||||||
|
var opponentMonsterStats = MonsterCombatStats.fromCombatStats(
|
||||||
|
opponentStats,
|
||||||
|
match.opponent.characterName,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 전투 시뮬레이션
|
||||||
|
int turns = 0;
|
||||||
|
int playerAccum = 0;
|
||||||
|
int opponentAccum = 0;
|
||||||
|
const tickMs = 200;
|
||||||
|
|
||||||
|
while (playerCombatStats.isAlive && opponentMonsterStats.isAlive) {
|
||||||
|
playerAccum += tickMs;
|
||||||
|
opponentAccum += tickMs;
|
||||||
|
|
||||||
|
// 플레이어 공격
|
||||||
|
if (playerAccum >= playerCombatStats.attackDelayMs) {
|
||||||
|
final result = calculator.playerAttackMonster(
|
||||||
|
attacker: playerCombatStats,
|
||||||
|
defender: opponentMonsterStats,
|
||||||
|
);
|
||||||
|
opponentMonsterStats = result.updatedDefender;
|
||||||
|
playerAccum = 0;
|
||||||
|
turns++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상대 공격 (살아있을 때만)
|
||||||
|
if (opponentMonsterStats.isAlive &&
|
||||||
|
opponentAccum >= opponentMonsterStats.attackDelayMs) {
|
||||||
|
final result = calculator.monsterAttackPlayer(
|
||||||
|
attacker: opponentMonsterStats,
|
||||||
|
defender: playerCombatStats,
|
||||||
|
);
|
||||||
|
playerCombatStats = result.updatedDefender;
|
||||||
|
opponentAccum = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 무한 루프 방지
|
||||||
|
if (turns > 1000) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
final isVictory = playerCombatStats.isAlive;
|
||||||
|
|
||||||
|
// 장비 교환
|
||||||
|
final (updatedChallenger, updatedOpponent) = _exchangeEquipment(
|
||||||
|
match: match,
|
||||||
|
isVictory: isVictory,
|
||||||
|
);
|
||||||
|
|
||||||
|
return ArenaMatchResult(
|
||||||
|
match: match,
|
||||||
|
isVictory: isVictory,
|
||||||
|
turns: turns,
|
||||||
|
updatedChallenger: updatedChallenger,
|
||||||
|
updatedOpponent: updatedOpponent,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 전투 시뮬레이션 (애니메이션용 스트림)
|
||||||
|
///
|
||||||
|
/// [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;
|
||||||
|
}
|
||||||
|
|
||||||
|
var playerCombatStats = challengerStats.copyWith(
|
||||||
|
hpCurrent: challengerStats.hpMax,
|
||||||
|
mpCurrent: challengerStats.mpMax,
|
||||||
|
);
|
||||||
|
|
||||||
|
var opponentMonsterStats = MonsterCombatStats.fromCombatStats(
|
||||||
|
opponentStats,
|
||||||
|
match.opponent.characterName,
|
||||||
|
);
|
||||||
|
|
||||||
|
int playerAccum = 0;
|
||||||
|
int opponentAccum = 0;
|
||||||
|
const tickMs = 200;
|
||||||
|
int turns = 0;
|
||||||
|
|
||||||
|
// 초기 상태 전송
|
||||||
|
yield ArenaCombatTurn(
|
||||||
|
challengerHp: playerCombatStats.hpCurrent,
|
||||||
|
opponentHp: opponentMonsterStats.hpCurrent,
|
||||||
|
challengerHpMax: playerCombatStats.hpMax,
|
||||||
|
opponentHpMax: opponentMonsterStats.hpMax,
|
||||||
|
);
|
||||||
|
|
||||||
|
while (playerCombatStats.isAlive && opponentMonsterStats.isAlive) {
|
||||||
|
playerAccum += tickMs;
|
||||||
|
opponentAccum += tickMs;
|
||||||
|
|
||||||
|
int? challengerDamage;
|
||||||
|
int? opponentDamage;
|
||||||
|
bool isChallengerCritical = false;
|
||||||
|
bool isOpponentCritical = false;
|
||||||
|
bool isChallengerEvaded = false;
|
||||||
|
bool isOpponentEvaded = false;
|
||||||
|
bool isChallengerBlocked = false;
|
||||||
|
bool isOpponentBlocked = false;
|
||||||
|
|
||||||
|
// 플레이어 공격
|
||||||
|
if (playerAccum >= playerCombatStats.attackDelayMs) {
|
||||||
|
final result = calculator.playerAttackMonster(
|
||||||
|
attacker: playerCombatStats,
|
||||||
|
defender: opponentMonsterStats,
|
||||||
|
);
|
||||||
|
opponentMonsterStats = result.updatedDefender;
|
||||||
|
playerAccum = 0;
|
||||||
|
|
||||||
|
if (result.result.isHit) {
|
||||||
|
challengerDamage = result.result.damage;
|
||||||
|
isChallengerCritical = result.result.isCritical;
|
||||||
|
} else {
|
||||||
|
isOpponentEvaded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상대 공격
|
||||||
|
if (opponentMonsterStats.isAlive &&
|
||||||
|
opponentAccum >= opponentMonsterStats.attackDelayMs) {
|
||||||
|
final result = calculator.monsterAttackPlayer(
|
||||||
|
attacker: opponentMonsterStats,
|
||||||
|
defender: playerCombatStats,
|
||||||
|
);
|
||||||
|
playerCombatStats = result.updatedDefender;
|
||||||
|
opponentAccum = 0;
|
||||||
|
|
||||||
|
if (result.result.isHit) {
|
||||||
|
opponentDamage = result.result.damage;
|
||||||
|
isOpponentCritical = result.result.isCritical;
|
||||||
|
isChallengerBlocked = result.result.isBlocked;
|
||||||
|
} else {
|
||||||
|
isChallengerEvaded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 공격이 발생했을 때만 턴 전송
|
||||||
|
if (challengerDamage != null || opponentDamage != null) {
|
||||||
|
turns++;
|
||||||
|
yield ArenaCombatTurn(
|
||||||
|
challengerDamage: challengerDamage,
|
||||||
|
opponentDamage: opponentDamage,
|
||||||
|
challengerHp: playerCombatStats.hpCurrent,
|
||||||
|
opponentHp: opponentMonsterStats.hpCurrent,
|
||||||
|
challengerHpMax: playerCombatStats.hpMax,
|
||||||
|
opponentHpMax: opponentMonsterStats.hpMax,
|
||||||
|
isChallengerCritical: isChallengerCritical,
|
||||||
|
isOpponentCritical: isOpponentCritical,
|
||||||
|
isChallengerEvaded: isChallengerEvaded,
|
||||||
|
isOpponentEvaded: isOpponentEvaded,
|
||||||
|
isChallengerBlocked: isChallengerBlocked,
|
||||||
|
isOpponentBlocked: isOpponentBlocked,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 애니메이션을 위한 딜레이
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 무한 루프 방지
|
||||||
|
if (turns > 1000) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 장비 교환
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// 장비 교환 (같은 슬롯끼리)
|
||||||
|
///
|
||||||
|
/// 승자가 선택한 슬롯의 장비를 서로 교환
|
||||||
|
(HallOfFameEntry, HallOfFameEntry) _exchangeEquipment({
|
||||||
|
required ArenaMatch match,
|
||||||
|
required bool isVictory,
|
||||||
|
}) {
|
||||||
|
final slot = match.bettingSlot;
|
||||||
|
|
||||||
|
// 도전자 장비 목록 복사
|
||||||
|
final challengerEquipment =
|
||||||
|
List<EquipmentItem>.from(match.challenger.finalEquipment ?? []);
|
||||||
|
|
||||||
|
// 상대 장비 목록 복사
|
||||||
|
final opponentEquipment =
|
||||||
|
List<EquipmentItem>.from(match.opponent.finalEquipment ?? []);
|
||||||
|
|
||||||
|
// 해당 슬롯의 장비 찾기
|
||||||
|
final challengerItem = _findItemBySlot(challengerEquipment, slot);
|
||||||
|
final opponentItem = _findItemBySlot(opponentEquipment, slot);
|
||||||
|
|
||||||
|
// 장비 교환
|
||||||
|
_replaceItemInList(challengerEquipment, slot, opponentItem);
|
||||||
|
_replaceItemInList(opponentEquipment, slot, challengerItem);
|
||||||
|
|
||||||
|
// 업데이트된 엔트리 생성
|
||||||
|
final updatedChallenger = match.challenger.copyWith(
|
||||||
|
finalEquipment: challengerEquipment,
|
||||||
|
);
|
||||||
|
final updatedOpponent = match.opponent.copyWith(
|
||||||
|
finalEquipment: opponentEquipment,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (updatedChallenger, updatedOpponent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 슬롯으로 장비 찾기
|
||||||
|
EquipmentItem _findItemBySlot(
|
||||||
|
List<EquipmentItem> equipment, EquipmentSlot slot) {
|
||||||
|
for (final item in equipment) {
|
||||||
|
if (item.slot == slot) return item;
|
||||||
|
}
|
||||||
|
return EquipmentItem.empty(slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 장비 목록에서 특정 슬롯의 아이템 교체
|
||||||
|
void _replaceItemInList(
|
||||||
|
List<EquipmentItem> equipment,
|
||||||
|
EquipmentSlot slot,
|
||||||
|
EquipmentItem newItem,
|
||||||
|
) {
|
||||||
|
for (var i = 0; i < equipment.length; i++) {
|
||||||
|
if (equipment[i].slot == slot) {
|
||||||
|
equipment[i] = newItem;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 슬롯이 없으면 추가
|
||||||
|
equipment.add(newItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user