feat(arena): 아레나 서비스 추가

- 아레나 전투 로직 처리
- 명예의 전당 연동
This commit is contained in:
JiWoong Sul
2026-01-06 17:54:56 +09:00
parent 4c68b3c7fb
commit 58cf4739fe

View 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);
}
}