diff --git a/lib/src/features/arena/arena_battle_controller.dart b/lib/src/features/arena/arena_battle_controller.dart new file mode 100644 index 0000000..faff5f6 --- /dev/null +++ b/lib/src/features/arena/arena_battle_controller.dart @@ -0,0 +1,403 @@ +import 'dart:async'; + +import 'package:asciineverdie/src/core/engine/arena_service.dart'; +import 'package:asciineverdie/src/core/model/arena_match.dart'; +import 'package:asciineverdie/src/core/model/combat_event.dart'; +import 'package:asciineverdie/src/features/game/widgets/combat_log.dart'; + +/// 아레나 전투 상태 (Arena Battle State) +/// +/// 컨트롤러가 관리하는 전투 상태 스냅샷 +class ArenaBattleState { + ArenaBattleState({ + required this.currentTurn, + required this.challengerHp, + required this.challengerHpMax, + required this.challengerMp, + required this.challengerMpMax, + required this.opponentHp, + required this.opponentHpMax, + required this.opponentMp, + required this.opponentMpMax, + required this.battleLog, + required this.isFinished, + this.result, + this.latestCombatEvent, + this.currentEventIcon, + this.currentSkillName, + this.challengerHpChange = 0, + this.opponentHpChange = 0, + this.battleStartTime, + }); + + final int currentTurn; + final int challengerHp; + final int challengerHpMax; + final int challengerMp; + final int challengerMpMax; + final int opponentHp; + final int opponentHpMax; + final int opponentMp; + final int opponentMpMax; + final List battleLog; + final bool isFinished; + final ArenaMatchResult? result; + final CombatEvent? latestCombatEvent; + final CombatEventType? currentEventIcon; + final String? currentSkillName; + final int challengerHpChange; + final int opponentHpChange; + final DateTime? battleStartTime; +} + +/// 아레나 전투 컨트롤러 (Arena Battle Controller) +/// +/// 전투 시뮬레이션 스트림 구독, 턴 처리, 로그 생성을 담당 +class ArenaBattleController { + ArenaBattleController({required this.match}); + + final ArenaMatch match; + final ArenaService _arenaService = ArenaService(); + + // 상태 (State) + int _currentTurn = 0; + DateTime? _battleStartTime; + late int _challengerHp; + late int _challengerHpMax; + late int _challengerMp; + late int _challengerMpMax; + late int _opponentHp; + late int _opponentHpMax; + late int _opponentMp; + late int _opponentMpMax; + final List _battleLog = []; + ArenaMatchResult? _result; + CombatEvent? _latestCombatEvent; + CombatEventType? _currentEventIcon; + String? _currentSkillName; + int _challengerHpChange = 0; + int _opponentHpChange = 0; + bool _isFinished = false; + + StreamSubscription? _combatSubscription; + Timer? _eventIconTimer; + + /// 상태 변경 콜백 (setState 대체) + void Function()? onStateChanged; + + /// HP 변화 콜백 (애니메이션 트리거용) + /// challenger: true = 도전자, false = 상대 + void Function(bool challenger)? onHpChanged; + + /// 현재 상태 스냅샷 + ArenaBattleState get state => ArenaBattleState( + currentTurn: _currentTurn, + challengerHp: _challengerHp, + challengerHpMax: _challengerHpMax, + challengerMp: _challengerMp, + challengerMpMax: _challengerMpMax, + opponentHp: _opponentHp, + opponentHpMax: _opponentHpMax, + opponentMp: _opponentMp, + opponentMpMax: _opponentMpMax, + battleLog: _battleLog, + isFinished: _isFinished, + result: _result, + latestCombatEvent: _latestCombatEvent, + currentEventIcon: _currentEventIcon, + currentSkillName: _currentSkillName, + challengerHpChange: _challengerHpChange, + opponentHpChange: _opponentHpChange, + battleStartTime: _battleStartTime, + ); + + /// HP/MP 초기화 + void initialize() { + _challengerHpMax = match.challenger.finalStats?.hpMax ?? 100; + _challengerHp = _challengerHpMax; + _challengerMpMax = match.challenger.finalStats?.mpMax ?? 50; + _challengerMp = _challengerMpMax; + _opponentHpMax = match.opponent.finalStats?.hpMax ?? 100; + _opponentHp = _opponentHpMax; + _opponentMpMax = match.opponent.finalStats?.mpMax ?? 50; + _opponentMp = _opponentMpMax; + } + + /// 전투 시작 + void startBattle() { + _battleStartTime = DateTime.now(); + _combatSubscription = _arenaService + .simulateCombat(match) + .listen(_processTurn, onDone: _endBattle); + } + + /// 턴 처리 (Turn Processing) + void _processTurn(ArenaCombatTurn turn) { + final oldChallengerHp = _challengerHp; + final oldOpponentHp = _opponentHp; + + _currentTurn++; + _challengerHp = turn.challengerHp; + _opponentHp = turn.opponentHp; + _challengerMp = turn.challengerMp ?? _challengerMp; + _opponentMp = turn.opponentMp ?? _opponentMp; + + // 도전자 HP 변화 감지 + if (oldChallengerHp != _challengerHp) { + _challengerHpChange = _challengerHp - oldChallengerHp; + onHpChanged?.call(true); + } + + // 상대 HP 변화 감지 + if (oldOpponentHp != _opponentHp) { + _opponentHpChange = _opponentHp - oldOpponentHp; + onHpChanged?.call(false); + } + + // 전투 로그 생성 + _addTurnLogs(turn); + + // 전투 이벤트 생성 (테두리 이펙트용) + _latestCombatEvent = _createCombatEvent(turn); + + // 전투 이벤트 아이콘 표시 + _showEventIcon(turn); + + onStateChanged?.call(); + } + + /// 턴 로그 생성 (Turn Log Generation) + void _addTurnLogs(ArenaCombatTurn turn) { + final challengerName = match.challenger.characterName; + final opponentName = match.opponent.characterName; + + // 도전자 스킬 사용 로그 + if (turn.challengerSkillUsed != null) { + _battleLog.add( + CombatLogEntry( + message: '$challengerName uses ${turn.challengerSkillUsed}!', + timestamp: DateTime.now(), + type: CombatLogType.skill, + ), + ); + } + + // 도전자 회복 로그 + if (turn.challengerHealAmount != null && turn.challengerHealAmount! > 0) { + _battleLog.add( + CombatLogEntry( + message: '$challengerName heals ${turn.challengerHealAmount} HP!', + timestamp: DateTime.now(), + type: CombatLogType.heal, + ), + ); + } + + // 도전자 데미지 로그 + if (turn.challengerDamage != null) { + final type = turn.isChallengerCritical + ? CombatLogType.critical + : CombatLogType.damage; + final critText = turn.isChallengerCritical ? ' CRITICAL!' : ''; + final skillText = turn.challengerSkillUsed != null ? '' : ''; + _battleLog.add( + CombatLogEntry( + message: + '$challengerName deals ${turn.challengerDamage}' + '$critText$skillText', + timestamp: DateTime.now(), + type: type, + ), + ); + } + + // 상대 회피/블록 이벤트 + if (turn.isOpponentEvaded) { + _battleLog.add( + CombatLogEntry( + message: '$opponentName evaded!', + timestamp: DateTime.now(), + type: CombatLogType.evade, + ), + ); + } + if (turn.isOpponentBlocked) { + _battleLog.add( + CombatLogEntry( + message: '$opponentName blocked!', + timestamp: DateTime.now(), + type: CombatLogType.block, + ), + ); + } + + // 상대 스킬 사용 로그 + if (turn.opponentSkillUsed != null) { + _battleLog.add( + CombatLogEntry( + message: '$opponentName uses ${turn.opponentSkillUsed}!', + timestamp: DateTime.now(), + type: CombatLogType.skill, + ), + ); + } + + // 상대 회복 로그 + if (turn.opponentHealAmount != null && turn.opponentHealAmount! > 0) { + _battleLog.add( + CombatLogEntry( + message: '$opponentName heals ${turn.opponentHealAmount} HP!', + timestamp: DateTime.now(), + type: CombatLogType.heal, + ), + ); + } + + // 상대 데미지 로그 + if (turn.opponentDamage != null) { + final type = turn.isOpponentCritical + ? CombatLogType.critical + : CombatLogType.monsterAttack; + final critText = turn.isOpponentCritical ? ' CRITICAL!' : ''; + _battleLog.add( + CombatLogEntry( + message: '$opponentName deals ${turn.opponentDamage}$critText', + timestamp: DateTime.now(), + type: type, + ), + ); + } + + // 도전자 회피/블록 이벤트 + if (turn.isChallengerEvaded) { + _battleLog.add( + CombatLogEntry( + message: '$challengerName evaded!', + timestamp: DateTime.now(), + type: CombatLogType.evade, + ), + ); + } + if (turn.isChallengerBlocked) { + _battleLog.add( + CombatLogEntry( + message: '$challengerName blocked!', + timestamp: DateTime.now(), + type: CombatLogType.block, + ), + ); + } + } + + /// 전투 이벤트 아이콘 표시 (일정 시간 후 사라짐) + void _showEventIcon(ArenaCombatTurn turn) { + _eventIconTimer?.cancel(); + + _currentSkillName = turn.challengerSkillUsed ?? turn.opponentSkillUsed; + + // 이벤트 타입 결정 (우선순위: 스킬 > 크리티컬 > 블록 > 회피 > 일반공격) + CombatEventType? eventType; + if (_currentSkillName != null) { + eventType = CombatEventType.playerSkill; + } else if (turn.isChallengerCritical || turn.isOpponentCritical) { + eventType = CombatEventType.playerAttack; + } else if (turn.isChallengerBlocked || turn.isOpponentBlocked) { + eventType = CombatEventType.playerBlock; + } else if (turn.isChallengerEvaded || turn.isOpponentEvaded) { + eventType = CombatEventType.playerEvade; + } else if (turn.challengerDamage != null || turn.opponentDamage != null) { + eventType = CombatEventType.playerAttack; + } + + _currentEventIcon = eventType; + + // 800ms 후 아이콘 숨김 + _eventIconTimer = Timer(const Duration(milliseconds: 800), () { + _currentEventIcon = null; + _currentSkillName = null; + onStateChanged?.call(); + }); + } + + /// ArenaCombatTurn에서 CombatEvent 생성 (테두리 이펙트용) + CombatEvent? _createCombatEvent(ArenaCombatTurn turn) { + final timestamp = DateTime.now().millisecondsSinceEpoch; + final challengerName = match.challenger.characterName; + final opponentName = match.opponent.characterName; + + // 도전자 스킬 사용 (보라색 테두리) + if (turn.challengerSkillUsed != null && turn.challengerDamage != null) { + return CombatEvent.playerSkill( + timestamp: timestamp, + skillName: turn.challengerSkillUsed!, + damage: turn.challengerDamage!, + targetName: opponentName, + isCritical: turn.isChallengerCritical, + ); + } + + // 도전자 공격 이벤트 + if (turn.challengerDamage != null) { + return CombatEvent.playerAttack( + timestamp: timestamp, + damage: turn.challengerDamage!, + targetName: opponentName, + isCritical: turn.isChallengerCritical, + ); + } + + // 도전자 회복 이벤트 + if (turn.challengerHealAmount != null && turn.challengerSkillUsed != null) { + return CombatEvent.playerHeal( + timestamp: timestamp, + healAmount: turn.challengerHealAmount!, + skillName: turn.challengerSkillUsed, + ); + } + + // 도전자 방어 이벤트 (회피/블록) + if (turn.isChallengerEvaded) { + return CombatEvent.playerEvade( + timestamp: timestamp, + attackerName: opponentName, + ); + } + if (turn.isChallengerBlocked) { + return CombatEvent.playerBlock( + timestamp: timestamp, + reducedDamage: turn.opponentDamage ?? 0, + attackerName: opponentName, + ); + } + + // 상대 공격 이벤트 + if (turn.opponentDamage != null) { + return CombatEvent.monsterAttack( + timestamp: timestamp, + damage: turn.opponentDamage!, + attackerName: challengerName, + ); + } + + return null; + } + + /// 전투 종료 처리 + void _endBattle() { + _result = _arenaService.createResultFromSimulation( + match: match, + challengerHp: _challengerHp, + opponentHp: _opponentHp, + turns: _currentTurn, + ); + + _isFinished = true; + onStateChanged?.call(); + } + + /// 리소스 해제 + void dispose() { + _combatSubscription?.cancel(); + _eventIconTimer?.cancel(); + } +} diff --git a/lib/src/features/arena/arena_battle_screen.dart b/lib/src/features/arena/arena_battle_screen.dart index 8e257dd..cc16cd1 100644 --- a/lib/src/features/arena/arena_battle_screen.dart +++ b/lib/src/features/arena/arena_battle_screen.dart @@ -1,23 +1,17 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:asciineverdie/l10n/app_localizations.dart'; -import 'package:asciineverdie/src/core/engine/arena_service.dart'; import 'package:asciineverdie/src/core/model/arena_match.dart'; -import 'package:asciineverdie/src/core/model/combat_event.dart'; -import 'package:asciineverdie/src/core/model/game_state.dart'; -import 'package:asciineverdie/src/core/model/hall_of_fame.dart'; -import 'package:asciineverdie/src/shared/animation/race_character_frames.dart'; +import 'package:asciineverdie/src/features/arena/arena_battle_controller.dart'; +import 'package:asciineverdie/src/features/arena/widgets/arena_battle_area.dart'; +import 'package:asciineverdie/src/features/arena/widgets/arena_combat_event_icons.dart'; import 'package:asciineverdie/src/features/arena/widgets/arena_combat_log.dart'; import 'package:asciineverdie/src/features/arena/widgets/arena_hp_bar.dart'; import 'package:asciineverdie/src/features/arena/widgets/arena_result_panel.dart'; -import 'package:asciineverdie/src/shared/widgets/ascii_disintegrate_widget.dart'; -import 'package:asciineverdie/src/features/game/widgets/ascii_animation_card.dart'; -import 'package:asciineverdie/src/features/game/widgets/combat_log.dart'; +import 'package:asciineverdie/src/features/arena/widgets/arena_turn_indicator.dart'; import 'package:asciineverdie/src/shared/retro_colors.dart'; -/// 아레나 전투 화면 +/// 아레나 전투 화면 (Arena Battle Screen) /// /// ASCII 애니메이션 기반 턴제 전투 표시 /// 레트로 RPG 스타일 HP 바 (세그먼트) @@ -40,73 +34,25 @@ class ArenaBattleScreen extends StatefulWidget { class _ArenaBattleScreenState extends State with TickerProviderStateMixin { - final ArenaService _arenaService = ArenaService(); + late final ArenaBattleController _controller; - /// 현재 턴 - int _currentTurn = 0; - - /// 전투 시작 시간 (경과 시간 계산용) - DateTime? _battleStartTime; - - /// 도전자 HP/MP - late int _challengerHp; - late int _challengerHpMax; - late int _challengerMp; - late int _challengerMpMax; - - /// 상대 HP/MP - late int _opponentHp; - late int _opponentHpMax; - late int _opponentMp; - late int _opponentMpMax; - - /// 전투 로그 (CombatLogEntry 사용) - final List _battleLog = []; - - /// 전투 시뮬레이션 스트림 구독 - StreamSubscription? _combatSubscription; - - /// 최종 결과 - ArenaMatchResult? _result; - - // HP 변화 애니메이션 + // HP 변화 애니메이션 (Animation) late AnimationController _challengerFlashController; late AnimationController _opponentFlashController; late Animation _challengerFlashAnimation; late Animation _opponentFlashAnimation; - // 변화량 표시용 - int _challengerHpChange = 0; - int _opponentHpChange = 0; - - /// 최신 전투 이벤트 (테두리 이펙트용) - CombatEvent? _latestCombatEvent; - - /// 전투 이벤트 아이콘 타이머 (페이드 아웃용) - Timer? _eventIconTimer; - - /// 현재 표시 중인 이벤트 아이콘 타입 - CombatEventType? _currentEventIcon; - - /// 현재 표시 중인 스킬 이름 - String? _currentSkillName; - - /// 전투 종료 여부 (결과 패널 표시용) - bool _isFinished = false; - @override void initState() { super.initState(); - // HP/MP 초기화 - _challengerHpMax = widget.match.challenger.finalStats?.hpMax ?? 100; - _challengerHp = _challengerHpMax; - _challengerMpMax = widget.match.challenger.finalStats?.mpMax ?? 50; - _challengerMp = _challengerMpMax; - _opponentHpMax = widget.match.opponent.finalStats?.hpMax ?? 100; - _opponentHp = _opponentHpMax; - _opponentMpMax = widget.match.opponent.finalStats?.mpMax ?? 50; - _opponentMp = _opponentMpMax; + // 컨트롤러 초기화 + _controller = ArenaBattleController(match: widget.match); + _controller.initialize(); + _controller.onStateChanged = () { + if (mounted) setState(() {}); + }; + _controller.onHpChanged = _handleHpChanged; // 플래시 애니메이션 초기화 _challengerFlashController = AnimationController( @@ -129,309 +75,38 @@ class _ArenaBattleScreenState extends State ); // 전투 시작 (딜레이 후) - Future.delayed(const Duration(milliseconds: 500), _startBattle); + Future.delayed(const Duration(milliseconds: 500), _controller.startBattle); } @override void dispose() { - _combatSubscription?.cancel(); - _eventIconTimer?.cancel(); + _controller.dispose(); _challengerFlashController.dispose(); _opponentFlashController.dispose(); super.dispose(); } - void _startBattle() { - _battleStartTime = DateTime.now(); - _combatSubscription = _arenaService - .simulateCombat(widget.match) - .listen( - (turn) { - _processTurn(turn); - }, - onDone: () { - _endBattle(); - }, - ); - } - - void _processTurn(ArenaCombatTurn turn) { - final oldChallengerHp = _challengerHp; - final oldOpponentHp = _opponentHp; - - setState(() { - _currentTurn++; - _challengerHp = turn.challengerHp; - _opponentHp = turn.opponentHp; - _challengerMp = turn.challengerMp ?? _challengerMp; - _opponentMp = turn.opponentMp ?? _opponentMp; - - // 도전자 HP 변화 감지 - if (oldChallengerHp != _challengerHp) { - _challengerHpChange = _challengerHp - oldChallengerHp; - _challengerFlashController.forward(from: 0.0); - } - - // 상대 HP 변화 감지 - if (oldOpponentHp != _opponentHp) { - _opponentHpChange = _opponentHp - oldOpponentHp; - _opponentFlashController.forward(from: 0.0); - } - - // 도전자 스킬 사용 로그 - if (turn.challengerSkillUsed != null) { - _battleLog.add( - CombatLogEntry( - message: - '${widget.match.challenger.characterName} uses ' - '${turn.challengerSkillUsed}!', - timestamp: DateTime.now(), - type: CombatLogType.skill, - ), - ); - } - - // 도전자 회복 로그 - if (turn.challengerHealAmount != null && turn.challengerHealAmount! > 0) { - _battleLog.add( - CombatLogEntry( - message: - '${widget.match.challenger.characterName} heals ' - '${turn.challengerHealAmount} HP!', - timestamp: DateTime.now(), - type: CombatLogType.heal, - ), - ); - } - - // 로그 추가 (CombatLogEntry 사용) - if (turn.challengerDamage != null) { - final type = turn.isChallengerCritical - ? CombatLogType.critical - : CombatLogType.damage; - final critText = turn.isChallengerCritical ? ' CRITICAL!' : ''; - final skillText = turn.challengerSkillUsed != null ? '' : ''; - _battleLog.add( - CombatLogEntry( - message: - '${widget.match.challenger.characterName} deals ' - '${turn.challengerDamage}$critText$skillText', - timestamp: DateTime.now(), - type: type, - ), - ); - } - - // 상대 회피/블록 이벤트 - if (turn.isOpponentEvaded) { - _battleLog.add( - CombatLogEntry( - message: '${widget.match.opponent.characterName} evaded!', - timestamp: DateTime.now(), - type: CombatLogType.evade, - ), - ); - } - if (turn.isOpponentBlocked) { - _battleLog.add( - CombatLogEntry( - message: '${widget.match.opponent.characterName} blocked!', - timestamp: DateTime.now(), - type: CombatLogType.block, - ), - ); - } - - // 상대 스킬 사용 로그 - if (turn.opponentSkillUsed != null) { - _battleLog.add( - CombatLogEntry( - message: - '${widget.match.opponent.characterName} uses ' - '${turn.opponentSkillUsed}!', - timestamp: DateTime.now(), - type: CombatLogType.skill, - ), - ); - } - - // 상대 회복 로그 - if (turn.opponentHealAmount != null && turn.opponentHealAmount! > 0) { - _battleLog.add( - CombatLogEntry( - message: - '${widget.match.opponent.characterName} heals ' - '${turn.opponentHealAmount} HP!', - timestamp: DateTime.now(), - type: CombatLogType.heal, - ), - ); - } - - if (turn.opponentDamage != null) { - final type = turn.isOpponentCritical - ? CombatLogType.critical - : CombatLogType.monsterAttack; - final critText = turn.isOpponentCritical ? ' CRITICAL!' : ''; - _battleLog.add( - CombatLogEntry( - message: - '${widget.match.opponent.characterName} deals ' - '${turn.opponentDamage}$critText', - timestamp: DateTime.now(), - type: type, - ), - ); - } - - // 도전자 회피/블록 이벤트 - if (turn.isChallengerEvaded) { - _battleLog.add( - CombatLogEntry( - message: '${widget.match.challenger.characterName} evaded!', - timestamp: DateTime.now(), - type: CombatLogType.evade, - ), - ); - } - if (turn.isChallengerBlocked) { - _battleLog.add( - CombatLogEntry( - message: '${widget.match.challenger.characterName} blocked!', - timestamp: DateTime.now(), - type: CombatLogType.block, - ), - ); - } - - // 전투 이벤트 생성 (테두리 이펙트용) - _latestCombatEvent = _createCombatEvent(turn); - - // 전투 이벤트 아이콘 표시 - _showEventIcon(turn); - }); - } - - /// 전투 이벤트 아이콘 표시 (일정 시간 후 사라짐) - void _showEventIcon(ArenaCombatTurn turn) { - // 이전 타이머 취소 - _eventIconTimer?.cancel(); - - // 스킬 이름 저장 - _currentSkillName = turn.challengerSkillUsed ?? turn.opponentSkillUsed; - - // 이벤트 타입 결정 (우선순위: 스킬 > 크리티컬 > 블록 > 회피 > 일반공격) - CombatEventType? eventType; - if (_currentSkillName != null) { - eventType = CombatEventType.playerSkill; - } else if (turn.isChallengerCritical || turn.isOpponentCritical) { - eventType = CombatEventType.playerAttack; // 크리티컬 표시용 - } else if (turn.isChallengerBlocked || turn.isOpponentBlocked) { - eventType = CombatEventType.playerBlock; - } else if (turn.isChallengerEvaded || turn.isOpponentEvaded) { - eventType = CombatEventType.playerEvade; - } else if (turn.challengerDamage != null || turn.opponentDamage != null) { - eventType = CombatEventType.playerAttack; + /// HP 변화 시 플래시 애니메이션 트리거 + void _handleHpChanged(bool isChallenger) { + if (isChallenger) { + _challengerFlashController.forward(from: 0.0); + } else { + _opponentFlashController.forward(from: 0.0); } - - _currentEventIcon = eventType; - - // 1초 후 아이콘 숨김 - _eventIconTimer = Timer(const Duration(milliseconds: 800), () { - if (mounted) { - setState(() { - _currentEventIcon = null; - _currentSkillName = null; - }); - } - }); - } - - /// ArenaCombatTurn에서 CombatEvent 생성 (테두리 이펙트용) - CombatEvent? _createCombatEvent(ArenaCombatTurn turn) { - final timestamp = DateTime.now().millisecondsSinceEpoch; - - // 도전자 스킬 사용 (보라색 테두리) - if (turn.challengerSkillUsed != null && turn.challengerDamage != null) { - return CombatEvent.playerSkill( - timestamp: timestamp, - skillName: turn.challengerSkillUsed!, - damage: turn.challengerDamage!, - targetName: widget.match.opponent.characterName, - isCritical: turn.isChallengerCritical, - ); - } - - // 도전자 공격 이벤트 (우선순위: 크리티컬 > 일반 공격) - if (turn.challengerDamage != null) { - return CombatEvent.playerAttack( - timestamp: timestamp, - damage: turn.challengerDamage!, - targetName: widget.match.opponent.characterName, - isCritical: turn.isChallengerCritical, - ); - } - - // 도전자 회복 이벤트 - if (turn.challengerHealAmount != null && turn.challengerSkillUsed != null) { - return CombatEvent.playerHeal( - timestamp: timestamp, - healAmount: turn.challengerHealAmount!, - skillName: turn.challengerSkillUsed, - ); - } - - // 도전자 방어 이벤트 (회피/블록) - if (turn.isChallengerEvaded) { - return CombatEvent.playerEvade( - timestamp: timestamp, - attackerName: widget.match.opponent.characterName, - ); - } - if (turn.isChallengerBlocked) { - return CombatEvent.playerBlock( - timestamp: timestamp, - reducedDamage: turn.opponentDamage ?? 0, - attackerName: widget.match.opponent.characterName, - ); - } - - // 상대 공격 이벤트 (몬스터 공격으로 처리) - if (turn.opponentDamage != null) { - return CombatEvent.monsterAttack( - timestamp: timestamp, - damage: turn.opponentDamage!, - attackerName: widget.match.opponent.characterName, - ); - } - - return null; - } - - void _endBattle() { - // 시뮬레이션 HP 결과를 기반으로 최종 결과 계산 - _result = _arenaService.createResultFromSimulation( - match: widget.match, - challengerHp: _challengerHp, - opponentHp: _opponentHp, - turns: _currentTurn, - ); - - // 전투 종료 상태로 전환 (인라인 결과 패널 표시) - setState(() { - _isFinished = true; - }); } /// Continue 버튼 콜백 void _handleContinue() { - if (_result != null) { - widget.onBattleComplete(_result!); + final result = _controller.state.result; + if (result != null) { + widget.onBattleComplete(result); } } @override Widget build(BuildContext context) { + final s = _controller.state; + return Scaffold( backgroundColor: RetroColors.backgroundOf(context), appBar: AppBar( @@ -447,313 +122,56 @@ class _ArenaBattleScreenState extends State child: Column( children: [ // 턴 표시 - _buildTurnIndicator(), + ArenaTurnIndicator(battleStartTime: s.battleStartTime), // HP 바 (레트로 세그먼트 스타일) ArenaHpBars( challengerName: widget.match.challenger.characterName, - challengerHp: _challengerHp, - challengerHpMax: _challengerHpMax, + challengerHp: s.challengerHp, + challengerHpMax: s.challengerHpMax, challengerFlashAnimation: _challengerFlashAnimation, - challengerHpChange: _challengerHpChange, + challengerHpChange: s.challengerHpChange, opponentName: widget.match.opponent.characterName, - opponentHp: _opponentHp, - opponentHpMax: _opponentHpMax, + opponentHp: s.opponentHp, + opponentHpMax: s.opponentHpMax, opponentFlashAnimation: _opponentFlashAnimation, - opponentHpChange: _opponentHpChange, + opponentHpChange: s.opponentHpChange, + ), + // 전투 이벤트 아이콘 + ArenaCombatEventIcons( + currentEventIcon: s.currentEventIcon, + currentSkillName: s.currentSkillName, + latestCombatEvent: s.latestCombatEvent, ), - // 전투 이벤트 아이콘 (HP 바와 애니메이션 사이) - _buildCombatEventIcons(), // ASCII 애니메이션 (전투 중 / 종료 분기) - _buildBattleArea(), + ArenaBattleArea( + match: widget.match, + isFinished: s.isFinished, + result: s.result, + latestCombatEvent: s.latestCombatEvent, + ), // 로그 영역 (남은 공간 채움) - Expanded(child: _buildBattleLog()), + Expanded( + child: Container( + margin: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: RetroColors.panelBgOf(context), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: RetroColors.borderOf(context)), + ), + child: ArenaCombatLog(entries: s.battleLog), + ), + ), // 결과 패널 (전투 종료 시) - if (_isFinished && _result != null) + if (s.isFinished && s.result != null) ArenaResultPanel( - result: _result!, - turnCount: _currentTurn, + result: s.result!, + turnCount: s.currentTurn, onContinue: _handleContinue, - battleLog: _battleLog, + battleLog: s.battleLog, ), ], ), ), ); } - - /// 방패 장착 여부 확인 - bool _hasShield(HallOfFameEntry entry) { - final equipment = entry.finalEquipment; - if (equipment == null) return false; - return equipment.any((item) => item.slot.name == 'shield'); - } - - /// 전투 영역 (전투 중 / 종료 분기) - Widget _buildBattleArea() { - if (_isFinished && _result != null) { - return _buildFinishedBattleArea(); - } - return _buildActiveBattleArea(); - } - - /// 활성 전투 영역 (기존 AsciiAnimationCard) - Widget _buildActiveBattleArea() { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: SizedBox( - height: 120, - child: AsciiAnimationCard( - taskType: TaskType.kill, - raceId: widget.match.challenger.raceId, - shieldName: _hasShield(widget.match.challenger) ? 'shield' : null, - opponentRaceId: widget.match.opponent.raceId, - opponentHasShield: _hasShield(widget.match.opponent), - latestCombatEvent: _latestCombatEvent, - ), - ), - ); - } - - /// 종료된 전투 영역 (승자 유지 + 패자 분해) - Widget _buildFinishedBattleArea() { - final isVictory = _result!.isVictory; - final winnerRaceId = isVictory - ? widget.match.challenger.raceId - : widget.match.opponent.raceId; - final loserRaceId = isVictory - ? widget.match.opponent.raceId - : widget.match.challenger.raceId; - - // 패자 캐릭터 프레임 (idle 첫 프레임) - final loserFrameData = - RaceCharacterFrames.get(loserRaceId) ?? - RaceCharacterFrames.defaultFrames; - final loserLines = loserFrameData.idle.first.lines; - - // 승자 캐릭터 프레임 (idle 첫 프레임) - final winnerFrameData = - RaceCharacterFrames.get(winnerRaceId) ?? - RaceCharacterFrames.defaultFrames; - final winnerLines = winnerFrameData.idle.first.lines; - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: SizedBox( - height: 120, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - // 좌측: 도전자 (승자면 유지, 패자면 분해) - Expanded( - child: Center( - child: isVictory - ? _buildStaticCharacter(winnerLines, false) - : AsciiDisintegrateWidget( - characterLines: _mirrorLines(loserLines), - ), - ), - ), - // 중앙 VS - Text( - 'VS', - style: TextStyle( - fontFamily: 'PressStart2P', - fontSize: 14, - color: RetroColors.goldOf(context).withValues(alpha: 0.5), - ), - ), - // 우측: 상대 (승자면 유지, 패자면 분해) - Expanded( - child: Center( - child: isVictory - ? AsciiDisintegrateWidget(characterLines: loserLines) - : _buildStaticCharacter(_mirrorLines(winnerLines), false), - ), - ), - ], - ), - ), - ); - } - - /// 정적 ASCII 캐릭터 표시 - Widget _buildStaticCharacter(List lines, bool mirrored) { - final textColor = RetroColors.textPrimaryOf(context); - return Column( - mainAxisSize: MainAxisSize.min, - children: lines - .map( - (line) => Text( - line, - style: TextStyle( - fontFamily: 'JetBrainsMono', - fontSize: 15, - color: textColor, - height: 1.2, - ), - ), - ) - .toList(), - ); - } - - /// ASCII 문자열 미러링 (좌우 대칭) - List _mirrorLines(List lines) { - return lines.map((line) { - final chars = line.split(''); - return chars.reversed.map(_mirrorChar).join(); - }).toList(); - } - - /// 개별 문자 미러링 - String _mirrorChar(String char) { - return switch (char) { - '/' => r'\', - r'\' => '/', - '(' => ')', - ')' => '(', - '[' => ']', - ']' => '[', - '{' => '}', - '}' => '{', - '<' => '>', - '>' => '<', - 'd' => 'b', - 'b' => 'd', - 'q' => 'p', - 'p' => 'q', - _ => char, - }; - } - - Widget _buildTurnIndicator() { - // 경과 시간 계산 (분:초 형식) - String elapsedTime = '00:00'; - if (_battleStartTime != null) { - final elapsed = DateTime.now().difference(_battleStartTime!); - final minutes = elapsed.inMinutes; - final seconds = elapsed.inSeconds % 60; - elapsedTime = - '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; - } - - return Container( - padding: const EdgeInsets.symmetric(vertical: 8), - color: RetroColors.panelBgOf(context).withValues(alpha: 0.5), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.timer, color: RetroColors.goldOf(context), size: 16), - const SizedBox(width: 8), - Text( - elapsedTime, - style: TextStyle( - fontFamily: 'PressStart2P', - fontSize: 14, - color: RetroColors.goldOf(context), - ), - ), - ], - ), - ); - } - - Widget _buildBattleLog() { - return Container( - margin: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: RetroColors.panelBgOf(context), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: RetroColors.borderOf(context)), - ), - child: ArenaCombatLog(entries: _battleLog), - ); - } - - /// 전투 이벤트 아이콘 영역 (HP 바와 애니메이션 사이) - /// - /// 메인 게임의 _buildBuffIcons() 스타일을 따름 - /// 스킬 사용, 크리티컬, 블록, 회피 표시 - Widget _buildCombatEventIcons() { - // 스킬 사용 또는 특수 액션만 표시 - final hasSpecialEvent = - _currentSkillName != null || - _latestCombatEvent?.isCritical == true || - _currentEventIcon == CombatEventType.playerBlock || - _currentEventIcon == CombatEventType.playerEvade || - _currentEventIcon == CombatEventType.playerParry || - _currentEventIcon == CombatEventType.playerSkill; - - if (!hasSpecialEvent) { - return const SizedBox(height: 28); - } - - // 이벤트 타입에 따른 아이콘/색상 결정 - final (icon, color) = _getEventIconData(); - - return AnimatedOpacity( - opacity: _currentEventIcon != null ? 1.0 : 0.0, - duration: const Duration(milliseconds: 200), - child: SizedBox( - height: 28, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // 버프 아이콘 스타일 (CircularProgressIndicator) - Stack( - alignment: Alignment.center, - children: [ - // 원형 진행률 표시 (펄스 효과용) - SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - value: 1.0, - strokeWidth: 2, - backgroundColor: Colors.grey.shade700, - valueColor: AlwaysStoppedAnimation(color), - ), - ), - // 아이콘 - Icon(icon, size: 12, color: color), - ], - ), - // 스킬 이름 표시 - if (_currentSkillName != null) ...[ - const SizedBox(width: 6), - Text( - _currentSkillName!, - style: TextStyle( - fontFamily: 'PressStart2P', - fontSize: 12, - color: color, - ), - ), - ], - ], - ), - ), - ); - } - - /// 이벤트 타입에 따른 아이콘, 색상 반환 - (IconData, Color) _getEventIconData() { - // 스킬 사용 - if (_currentSkillName != null || - _currentEventIcon == CombatEventType.playerSkill) { - return (Icons.auto_fix_high, Colors.purple); - } - - // 크리티컬 체크 (latestCombatEvent에서) - if (_latestCombatEvent?.isCritical == true) { - return (Icons.flash_on, Colors.yellow.shade600); - } - - return switch (_currentEventIcon) { - CombatEventType.playerBlock => (Icons.shield, Colors.blue), - CombatEventType.playerEvade => (Icons.directions_run, Colors.cyan), - CombatEventType.playerParry => (Icons.sports_kabaddi, Colors.purple), - _ => (Icons.trending_up, Colors.lightBlue), - }; - } } diff --git a/lib/src/features/arena/widgets/arena_battle_area.dart b/lib/src/features/arena/widgets/arena_battle_area.dart new file mode 100644 index 0000000..9b48397 --- /dev/null +++ b/lib/src/features/arena/widgets/arena_battle_area.dart @@ -0,0 +1,173 @@ +import 'package:flutter/material.dart'; + +import 'package:asciineverdie/src/core/model/arena_match.dart'; +import 'package:asciineverdie/src/core/model/combat_event.dart'; +import 'package:asciineverdie/src/core/model/game_state.dart'; +import 'package:asciineverdie/src/core/model/hall_of_fame.dart'; +import 'package:asciineverdie/src/shared/animation/race_character_frames.dart'; +import 'package:asciineverdie/src/shared/widgets/ascii_disintegrate_widget.dart'; +import 'package:asciineverdie/src/features/game/widgets/ascii_animation_card.dart'; +import 'package:asciineverdie/src/shared/retro_colors.dart'; + +/// 아레나 전투 영역 위젯 (Arena Battle Area) +/// +/// 활성 전투 중 ASCII 애니메이션 표시, 종료 시 승자/패자 분기 처리 +class ArenaBattleArea extends StatelessWidget { + const ArenaBattleArea({ + super.key, + required this.match, + required this.isFinished, + this.result, + this.latestCombatEvent, + }); + + final ArenaMatch match; + final bool isFinished; + final ArenaMatchResult? result; + final CombatEvent? latestCombatEvent; + + @override + Widget build(BuildContext context) { + if (isFinished && result != null) { + return _buildFinishedArea(context); + } + return _buildActiveArea(context); + } + + /// 방패 장착 여부 확인 + bool _hasShield(HallOfFameEntry entry) { + final equipment = entry.finalEquipment; + if (equipment == null) return false; + return equipment.any((item) => item.slot.name == 'shield'); + } + + /// 활성 전투 영역 (기존 AsciiAnimationCard) + Widget _buildActiveArea(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: SizedBox( + height: 120, + child: AsciiAnimationCard( + taskType: TaskType.kill, + raceId: match.challenger.raceId, + shieldName: _hasShield(match.challenger) ? 'shield' : null, + opponentRaceId: match.opponent.raceId, + opponentHasShield: _hasShield(match.opponent), + latestCombatEvent: latestCombatEvent, + ), + ), + ); + } + + /// 종료된 전투 영역 (승자 유지 + 패자 분해) + Widget _buildFinishedArea(BuildContext context) { + final isVictory = result!.isVictory; + final winnerRaceId = isVictory + ? match.challenger.raceId + : match.opponent.raceId; + final loserRaceId = isVictory + ? match.opponent.raceId + : match.challenger.raceId; + + // 패자 캐릭터 프레임 (idle 첫 프레임) + final loserFrameData = + RaceCharacterFrames.get(loserRaceId) ?? + RaceCharacterFrames.defaultFrames; + final loserLines = loserFrameData.idle.first.lines; + + // 승자 캐릭터 프레임 (idle 첫 프레임) + final winnerFrameData = + RaceCharacterFrames.get(winnerRaceId) ?? + RaceCharacterFrames.defaultFrames; + final winnerLines = winnerFrameData.idle.first.lines; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: SizedBox( + height: 120, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + // 좌측: 도전자 (승자면 유지, 패자면 분해) + Expanded( + child: Center( + child: isVictory + ? _buildStaticCharacter(context, winnerLines) + : AsciiDisintegrateWidget( + characterLines: _mirrorLines(loserLines), + ), + ), + ), + // 중앙 VS + Text( + 'VS', + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 14, + color: RetroColors.goldOf(context).withValues(alpha: 0.5), + ), + ), + // 우측: 상대 (승자면 유지, 패자면 분해) + Expanded( + child: Center( + child: isVictory + ? AsciiDisintegrateWidget(characterLines: loserLines) + : _buildStaticCharacter(context, _mirrorLines(winnerLines)), + ), + ), + ], + ), + ), + ); + } + + /// 정적 ASCII 캐릭터 표시 + Widget _buildStaticCharacter(BuildContext context, List lines) { + final textColor = RetroColors.textPrimaryOf(context); + return Column( + mainAxisSize: MainAxisSize.min, + children: lines + .map( + (line) => Text( + line, + style: TextStyle( + fontFamily: 'JetBrainsMono', + fontSize: 15, + color: textColor, + height: 1.2, + ), + ), + ) + .toList(), + ); + } + + /// ASCII 문자열 미러링 (좌우 대칭) + static List _mirrorLines(List lines) { + return lines.map((line) { + final chars = line.split(''); + return chars.reversed.map(_mirrorChar).join(); + }).toList(); + } + + /// 개별 문자 미러링 + static String _mirrorChar(String char) { + return switch (char) { + '/' => r'\', + r'\' => '/', + '(' => ')', + ')' => '(', + '[' => ']', + ']' => '[', + '{' => '}', + '}' => '{', + '<' => '>', + '>' => '<', + 'd' => 'b', + 'b' => 'd', + 'q' => 'p', + 'p' => 'q', + _ => char, + }; + } +} diff --git a/lib/src/features/arena/widgets/arena_combat_event_icons.dart b/lib/src/features/arena/widgets/arena_combat_event_icons.dart new file mode 100644 index 0000000..e4f706b --- /dev/null +++ b/lib/src/features/arena/widgets/arena_combat_event_icons.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; + +import 'package:asciineverdie/src/core/model/combat_event.dart'; + +/// 아레나 전투 이벤트 아이콘 위젯 (Arena Combat Event Icons) +/// +/// 스킬 사용, 크리티컬, 블록, 회피 등 특수 이벤트를 아이콘으로 표시 +class ArenaCombatEventIcons extends StatelessWidget { + const ArenaCombatEventIcons({ + super.key, + this.currentEventIcon, + this.currentSkillName, + this.latestCombatEvent, + }); + + /// 현재 표시 중인 이벤트 아이콘 타입 + final CombatEventType? currentEventIcon; + + /// 현재 표시 중인 스킬 이름 + final String? currentSkillName; + + /// 최신 전투 이벤트 (크리티컬 체크용) + final CombatEvent? latestCombatEvent; + + @override + Widget build(BuildContext context) { + final hasSpecialEvent = + currentSkillName != null || + latestCombatEvent?.isCritical == true || + currentEventIcon == CombatEventType.playerBlock || + currentEventIcon == CombatEventType.playerEvade || + currentEventIcon == CombatEventType.playerParry || + currentEventIcon == CombatEventType.playerSkill; + + if (!hasSpecialEvent) { + return const SizedBox(height: 28); + } + + final (icon, color) = _getEventIconData(); + + return AnimatedOpacity( + opacity: currentEventIcon != null ? 1.0 : 0.0, + duration: const Duration(milliseconds: 200), + child: SizedBox( + height: 28, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // 버프 아이콘 스타일 (CircularProgressIndicator) + Stack( + alignment: Alignment.center, + children: [ + SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + value: 1.0, + strokeWidth: 2, + backgroundColor: Colors.grey.shade700, + valueColor: AlwaysStoppedAnimation(color), + ), + ), + Icon(icon, size: 12, color: color), + ], + ), + // 스킬 이름 표시 + if (currentSkillName != null) ...[ + const SizedBox(width: 6), + Text( + currentSkillName!, + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 12, + color: color, + ), + ), + ], + ], + ), + ), + ); + } + + /// 이벤트 타입에 따른 아이콘, 색상 반환 + (IconData, Color) _getEventIconData() { + // 스킬 사용 + if (currentSkillName != null || + currentEventIcon == CombatEventType.playerSkill) { + return (Icons.auto_fix_high, Colors.purple); + } + + // 크리티컬 체크 + if (latestCombatEvent?.isCritical == true) { + return (Icons.flash_on, Colors.yellow.shade600); + } + + return switch (currentEventIcon) { + CombatEventType.playerBlock => (Icons.shield, Colors.blue), + CombatEventType.playerEvade => (Icons.directions_run, Colors.cyan), + CombatEventType.playerParry => (Icons.sports_kabaddi, Colors.purple), + _ => (Icons.trending_up, Colors.lightBlue), + }; + } +} diff --git a/lib/src/features/arena/widgets/arena_turn_indicator.dart b/lib/src/features/arena/widgets/arena_turn_indicator.dart new file mode 100644 index 0000000..47ae767 --- /dev/null +++ b/lib/src/features/arena/widgets/arena_turn_indicator.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; + +import 'package:asciineverdie/src/shared/retro_colors.dart'; + +/// 아레나 턴 표시 위젯 (Arena Turn Indicator) +/// +/// 경과 시간을 분:초 형식으로 표시 +class ArenaTurnIndicator extends StatelessWidget { + const ArenaTurnIndicator({super.key, this.battleStartTime}); + + /// 전투 시작 시간 (경과 시간 계산용) + final DateTime? battleStartTime; + + @override + Widget build(BuildContext context) { + String elapsedTime = '00:00'; + if (battleStartTime != null) { + final elapsed = DateTime.now().difference(battleStartTime!); + final minutes = elapsed.inMinutes; + final seconds = elapsed.inSeconds % 60; + elapsedTime = + '${minutes.toString().padLeft(2, '0')}:' + '${seconds.toString().padLeft(2, '0')}'; + } + + return Container( + padding: const EdgeInsets.symmetric(vertical: 8), + color: RetroColors.panelBgOf(context).withValues(alpha: 0.5), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.timer, color: RetroColors.goldOf(context), size: 16), + const SizedBox(width: 8), + Text( + elapsedTime, + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 14, + color: RetroColors.goldOf(context), + ), + ), + ], + ), + ); + } +}