refactor(arena): arena_battle_screen 759줄 → 155줄 분할

- arena_battle_controller.dart: 전투 로직 (스트림, 턴, 로그)
- arena_battle_area.dart: 전투 영역 위젯
- arena_turn_indicator.dart: 경과 시간 위젯
- arena_combat_event_icons.dart: 전투 이벤트 아이콘 위젯
This commit is contained in:
JiWoong Sul
2026-03-30 20:40:55 +09:00
parent 5d38bac79e
commit 9be0dd3e4f
5 changed files with 787 additions and 643 deletions

View File

@@ -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<CombatLogEntry> 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<CombatLogEntry> _battleLog = [];
ArenaMatchResult? _result;
CombatEvent? _latestCombatEvent;
CombatEventType? _currentEventIcon;
String? _currentSkillName;
int _challengerHpChange = 0;
int _opponentHpChange = 0;
bool _isFinished = false;
StreamSubscription<ArenaCombatTurn>? _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();
}
}

View File

@@ -1,23 +1,17 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:asciineverdie/l10n/app_localizations.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/arena_match.dart';
import 'package:asciineverdie/src/core/model/combat_event.dart'; import 'package:asciineverdie/src/features/arena/arena_battle_controller.dart';
import 'package:asciineverdie/src/core/model/game_state.dart'; import 'package:asciineverdie/src/features/arena/widgets/arena_battle_area.dart';
import 'package:asciineverdie/src/core/model/hall_of_fame.dart'; import 'package:asciineverdie/src/features/arena/widgets/arena_combat_event_icons.dart';
import 'package:asciineverdie/src/shared/animation/race_character_frames.dart';
import 'package:asciineverdie/src/features/arena/widgets/arena_combat_log.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_hp_bar.dart';
import 'package:asciineverdie/src/features/arena/widgets/arena_result_panel.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/arena/widgets/arena_turn_indicator.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/shared/retro_colors.dart'; import 'package:asciineverdie/src/shared/retro_colors.dart';
/// 아레나 전투 화면 /// 아레나 전투 화면 (Arena Battle Screen)
/// ///
/// ASCII 애니메이션 기반 턴제 전투 표시 /// ASCII 애니메이션 기반 턴제 전투 표시
/// 레트로 RPG 스타일 HP 바 (세그먼트) /// 레트로 RPG 스타일 HP 바 (세그먼트)
@@ -40,73 +34,25 @@ class ArenaBattleScreen extends StatefulWidget {
class _ArenaBattleScreenState extends State<ArenaBattleScreen> class _ArenaBattleScreenState extends State<ArenaBattleScreen>
with TickerProviderStateMixin { with TickerProviderStateMixin {
final ArenaService _arenaService = ArenaService(); late final ArenaBattleController _controller;
/// 현재 턴 // HP 변화 애니메이션 (Animation)
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<CombatLogEntry> _battleLog = [];
/// 전투 시뮬레이션 스트림 구독
StreamSubscription<ArenaCombatTurn>? _combatSubscription;
/// 최종 결과
ArenaMatchResult? _result;
// HP 변화 애니메이션
late AnimationController _challengerFlashController; late AnimationController _challengerFlashController;
late AnimationController _opponentFlashController; late AnimationController _opponentFlashController;
late Animation<double> _challengerFlashAnimation; late Animation<double> _challengerFlashAnimation;
late Animation<double> _opponentFlashAnimation; late Animation<double> _opponentFlashAnimation;
// 변화량 표시용
int _challengerHpChange = 0;
int _opponentHpChange = 0;
/// 최신 전투 이벤트 (테두리 이펙트용)
CombatEvent? _latestCombatEvent;
/// 전투 이벤트 아이콘 타이머 (페이드 아웃용)
Timer? _eventIconTimer;
/// 현재 표시 중인 이벤트 아이콘 타입
CombatEventType? _currentEventIcon;
/// 현재 표시 중인 스킬 이름
String? _currentSkillName;
/// 전투 종료 여부 (결과 패널 표시용)
bool _isFinished = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// HP/MP 초기화 // 컨트롤러 초기화
_challengerHpMax = widget.match.challenger.finalStats?.hpMax ?? 100; _controller = ArenaBattleController(match: widget.match);
_challengerHp = _challengerHpMax; _controller.initialize();
_challengerMpMax = widget.match.challenger.finalStats?.mpMax ?? 50; _controller.onStateChanged = () {
_challengerMp = _challengerMpMax; if (mounted) setState(() {});
_opponentHpMax = widget.match.opponent.finalStats?.hpMax ?? 100; };
_opponentHp = _opponentHpMax; _controller.onHpChanged = _handleHpChanged;
_opponentMpMax = widget.match.opponent.finalStats?.mpMax ?? 50;
_opponentMp = _opponentMpMax;
// 플래시 애니메이션 초기화 // 플래시 애니메이션 초기화
_challengerFlashController = AnimationController( _challengerFlashController = AnimationController(
@@ -129,309 +75,38 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
); );
// 전투 시작 (딜레이 후) // 전투 시작 (딜레이 후)
Future.delayed(const Duration(milliseconds: 500), _startBattle); Future.delayed(const Duration(milliseconds: 500), _controller.startBattle);
} }
@override @override
void dispose() { void dispose() {
_combatSubscription?.cancel(); _controller.dispose();
_eventIconTimer?.cancel();
_challengerFlashController.dispose(); _challengerFlashController.dispose();
_opponentFlashController.dispose(); _opponentFlashController.dispose();
super.dispose(); super.dispose();
} }
void _startBattle() { /// HP 변화 시 플래시 애니메이션 트리거
_battleStartTime = DateTime.now(); void _handleHpChanged(bool isChallenger) {
_combatSubscription = _arenaService if (isChallenger) {
.simulateCombat(widget.match) _challengerFlashController.forward(from: 0.0);
.listen( } else {
(turn) { _opponentFlashController.forward(from: 0.0);
_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;
} }
_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 버튼 콜백 /// Continue 버튼 콜백
void _handleContinue() { void _handleContinue() {
if (_result != null) { final result = _controller.state.result;
widget.onBattleComplete(_result!); if (result != null) {
widget.onBattleComplete(result);
} }
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final s = _controller.state;
return Scaffold( return Scaffold(
backgroundColor: RetroColors.backgroundOf(context), backgroundColor: RetroColors.backgroundOf(context),
appBar: AppBar( appBar: AppBar(
@@ -447,313 +122,56 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
child: Column( child: Column(
children: [ children: [
// 턴 표시 // 턴 표시
_buildTurnIndicator(), ArenaTurnIndicator(battleStartTime: s.battleStartTime),
// HP 바 (레트로 세그먼트 스타일) // HP 바 (레트로 세그먼트 스타일)
ArenaHpBars( ArenaHpBars(
challengerName: widget.match.challenger.characterName, challengerName: widget.match.challenger.characterName,
challengerHp: _challengerHp, challengerHp: s.challengerHp,
challengerHpMax: _challengerHpMax, challengerHpMax: s.challengerHpMax,
challengerFlashAnimation: _challengerFlashAnimation, challengerFlashAnimation: _challengerFlashAnimation,
challengerHpChange: _challengerHpChange, challengerHpChange: s.challengerHpChange,
opponentName: widget.match.opponent.characterName, opponentName: widget.match.opponent.characterName,
opponentHp: _opponentHp, opponentHp: s.opponentHp,
opponentHpMax: _opponentHpMax, opponentHpMax: s.opponentHpMax,
opponentFlashAnimation: _opponentFlashAnimation, opponentFlashAnimation: _opponentFlashAnimation,
opponentHpChange: _opponentHpChange, opponentHpChange: s.opponentHpChange,
),
// 전투 이벤트 아이콘
ArenaCombatEventIcons(
currentEventIcon: s.currentEventIcon,
currentSkillName: s.currentSkillName,
latestCombatEvent: s.latestCombatEvent,
), ),
// 전투 이벤트 아이콘 (HP 바와 애니메이션 사이)
_buildCombatEventIcons(),
// ASCII 애니메이션 (전투 중 / 종료 분기) // 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( ArenaResultPanel(
result: _result!, result: s.result!,
turnCount: _currentTurn, turnCount: s.currentTurn,
onContinue: _handleContinue, 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<String> 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<String> _mirrorLines(List<String> 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),
};
}
} }

View File

@@ -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<String> 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<String> _mirrorLines(List<String> 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,
};
}
}

View File

@@ -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),
};
}
}

View File

@@ -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),
),
),
],
),
);
}
}