feat(arena): 아레나 화면 구현
- ArenaScreen: 아레나 메인 화면 - ArenaSetupScreen: 전투 설정 화면 - ArenaBattleScreen: 전투 진행 화면 - 관련 위젯 추가
This commit is contained in:
518
lib/src/features/arena/arena_battle_screen.dart
Normal file
518
lib/src/features/arena/arena_battle_screen.dart
Normal file
@@ -0,0 +1,518 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.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/game_state.dart';
|
||||
import 'package:asciineverdie/src/core/model/hall_of_fame.dart';
|
||||
import 'package:asciineverdie/src/features/arena/widgets/arena_result_dialog.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/ascii_animation_card.dart';
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
|
||||
// 임시 문자열 (추후 l10n으로 이동)
|
||||
const _battleTitle = 'ARENA BATTLE';
|
||||
const _hpLabel = 'HP';
|
||||
const _turnLabel = 'TURN';
|
||||
|
||||
/// 아레나 전투 화면
|
||||
///
|
||||
/// ASCII 애니메이션 기반 턴제 전투 표시
|
||||
/// 레트로 RPG 스타일 HP 바 (세그먼트)
|
||||
class ArenaBattleScreen extends StatefulWidget {
|
||||
const ArenaBattleScreen({
|
||||
super.key,
|
||||
required this.match,
|
||||
required this.onBattleComplete,
|
||||
});
|
||||
|
||||
/// 대전 정보
|
||||
final ArenaMatch match;
|
||||
|
||||
/// 전투 완료 콜백
|
||||
final void Function(ArenaMatchResult) onBattleComplete;
|
||||
|
||||
@override
|
||||
State<ArenaBattleScreen> createState() => _ArenaBattleScreenState();
|
||||
}
|
||||
|
||||
class _ArenaBattleScreenState extends State<ArenaBattleScreen>
|
||||
with TickerProviderStateMixin {
|
||||
final ArenaService _arenaService = ArenaService();
|
||||
|
||||
/// 현재 턴
|
||||
int _currentTurn = 0;
|
||||
|
||||
/// 도전자 HP
|
||||
late int _challengerHp;
|
||||
late int _challengerHpMax;
|
||||
|
||||
/// 상대 HP
|
||||
late int _opponentHp;
|
||||
late int _opponentHpMax;
|
||||
|
||||
/// 전투 로그
|
||||
final List<String> _battleLog = [];
|
||||
|
||||
/// 전투 시뮬레이션 스트림 구독
|
||||
StreamSubscription<ArenaCombatTurn>? _combatSubscription;
|
||||
|
||||
/// 최종 결과
|
||||
ArenaMatchResult? _result;
|
||||
|
||||
// HP 변화 애니메이션
|
||||
late AnimationController _challengerFlashController;
|
||||
late AnimationController _opponentFlashController;
|
||||
late Animation<double> _challengerFlashAnimation;
|
||||
late Animation<double> _opponentFlashAnimation;
|
||||
|
||||
// 변화량 표시용
|
||||
int _challengerHpChange = 0;
|
||||
int _opponentHpChange = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// HP 초기화
|
||||
_challengerHpMax = widget.match.challenger.finalStats?.hpMax ?? 100;
|
||||
_challengerHp = _challengerHpMax;
|
||||
_opponentHpMax = widget.match.opponent.finalStats?.hpMax ?? 100;
|
||||
_opponentHp = _opponentHpMax;
|
||||
|
||||
// 플래시 애니메이션 초기화
|
||||
_challengerFlashController = AnimationController(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
vsync: this,
|
||||
);
|
||||
_challengerFlashAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
|
||||
CurvedAnimation(parent: _challengerFlashController, curve: Curves.easeOut),
|
||||
);
|
||||
|
||||
_opponentFlashController = AnimationController(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
vsync: this,
|
||||
);
|
||||
_opponentFlashAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
|
||||
CurvedAnimation(parent: _opponentFlashController, curve: Curves.easeOut),
|
||||
);
|
||||
|
||||
// 전투 시작 (딜레이 후)
|
||||
Future.delayed(const Duration(milliseconds: 500), _startBattle);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_combatSubscription?.cancel();
|
||||
_challengerFlashController.dispose();
|
||||
_opponentFlashController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startBattle() {
|
||||
_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;
|
||||
|
||||
// 도전자 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.challengerDamage != null) {
|
||||
final critText = turn.isChallengerCritical ? ' CRITICAL!' : '';
|
||||
final evadeText = turn.isOpponentEvaded ? ' (Evaded)' : '';
|
||||
final blockText = turn.isOpponentBlocked ? ' (Blocked)' : '';
|
||||
_battleLog.add(
|
||||
'${widget.match.challenger.characterName} deals '
|
||||
'${turn.challengerDamage}$critText$evadeText$blockText',
|
||||
);
|
||||
}
|
||||
|
||||
if (turn.opponentDamage != null) {
|
||||
final critText = turn.isOpponentCritical ? ' CRITICAL!' : '';
|
||||
final evadeText = turn.isChallengerEvaded ? ' (Evaded)' : '';
|
||||
final blockText = turn.isChallengerBlocked ? ' (Blocked)' : '';
|
||||
_battleLog.add(
|
||||
'${widget.match.opponent.characterName} deals '
|
||||
'${turn.opponentDamage}$critText$evadeText$blockText',
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _endBattle() {
|
||||
// 최종 결과 계산
|
||||
_result = _arenaService.executeCombat(widget.match);
|
||||
|
||||
// 결과 다이얼로그 표시
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
if (mounted && _result != null) {
|
||||
showArenaResultDialog(
|
||||
context,
|
||||
result: _result!,
|
||||
onClose: () {
|
||||
widget.onBattleComplete(_result!);
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: RetroColors.backgroundOf(context),
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
_battleTitle,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
backgroundColor: RetroColors.panelBgOf(context),
|
||||
automaticallyImplyLeading: false,
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// 턴 표시
|
||||
_buildTurnIndicator(),
|
||||
// HP 바 (레트로 세그먼트 스타일)
|
||||
_buildRetroHpBars(),
|
||||
// ASCII 애니메이션 (중앙) - 기존 AsciiAnimationCard 재사용
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: SizedBox(
|
||||
height: 120,
|
||||
child: AsciiAnimationCard(
|
||||
taskType: TaskType.kill,
|
||||
raceId: widget.match.challenger.race,
|
||||
shieldName: _hasShield(widget.match.challenger) ? 'shield' : null,
|
||||
opponentRaceId: widget.match.opponent.race,
|
||||
opponentHasShield: _hasShield(widget.match.opponent),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 로그 영역 (남은 공간 채움)
|
||||
Expanded(child: _buildBattleLog()),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 방패 장착 여부 확인
|
||||
bool _hasShield(HallOfFameEntry entry) {
|
||||
final equipment = entry.finalEquipment;
|
||||
if (equipment == null) return false;
|
||||
return equipment.any((item) => item.slot.name == 'shield');
|
||||
}
|
||||
|
||||
Widget _buildTurnIndicator() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
color: RetroColors.panelBgOf(context).withValues(alpha: 0.5),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.sports_kabaddi,
|
||||
color: RetroColors.goldOf(context),
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'$_turnLabel $_currentTurn',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 10,
|
||||
color: RetroColors.goldOf(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 레트로 스타일 HP 바 (좌우 대칭)
|
||||
Widget _buildRetroHpBars() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: RetroColors.panelBgOf(context),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: RetroColors.borderOf(context),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 도전자 HP (좌측, 파란색)
|
||||
Expanded(
|
||||
child: _buildRetroHpBar(
|
||||
name: widget.match.challenger.characterName,
|
||||
hp: _challengerHp,
|
||||
hpMax: _challengerHpMax,
|
||||
fillColor: RetroColors.mpBlue,
|
||||
accentColor: Colors.blue,
|
||||
flashAnimation: _challengerFlashAnimation,
|
||||
hpChange: _challengerHpChange,
|
||||
isReversed: false,
|
||||
),
|
||||
),
|
||||
// VS 구분자
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text(
|
||||
'VS',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 8,
|
||||
color: RetroColors.goldOf(context),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
// 상대 HP (우측, 빨간색)
|
||||
Expanded(
|
||||
child: _buildRetroHpBar(
|
||||
name: widget.match.opponent.characterName,
|
||||
hp: _opponentHp,
|
||||
hpMax: _opponentHpMax,
|
||||
fillColor: RetroColors.hpRed,
|
||||
accentColor: Colors.red,
|
||||
flashAnimation: _opponentFlashAnimation,
|
||||
hpChange: _opponentHpChange,
|
||||
isReversed: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 레트로 세그먼트 HP 바
|
||||
Widget _buildRetroHpBar({
|
||||
required String name,
|
||||
required int hp,
|
||||
required int hpMax,
|
||||
required Color fillColor,
|
||||
required Color accentColor,
|
||||
required Animation<double> flashAnimation,
|
||||
required int hpChange,
|
||||
required bool isReversed,
|
||||
}) {
|
||||
final hpRatio = hpMax > 0 ? hp / hpMax : 0.0;
|
||||
final isLow = hpRatio < 0.2 && hpRatio > 0;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: flashAnimation,
|
||||
builder: (context, child) {
|
||||
// 플래시 색상 (데미지=빨강)
|
||||
final isDamage = hpChange < 0;
|
||||
final flashColor = isDamage
|
||||
? RetroColors.hpRed.withValues(alpha: flashAnimation.value * 0.4)
|
||||
: RetroColors.expGreen.withValues(alpha: flashAnimation.value * 0.4);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: flashAnimation.value > 0.1
|
||||
? flashColor
|
||||
: accentColor.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: accentColor, width: 2),
|
||||
),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment:
|
||||
isReversed ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 이름
|
||||
Text(
|
||||
name,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 6,
|
||||
color: RetroColors.textPrimaryOf(context),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// HP 세그먼트 바
|
||||
_buildSegmentBar(
|
||||
ratio: hpRatio,
|
||||
fillColor: fillColor,
|
||||
isLow: isLow,
|
||||
isReversed: isReversed,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
// HP 수치
|
||||
Row(
|
||||
mainAxisAlignment:
|
||||
isReversed ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_hpLabel,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 5,
|
||||
color: accentColor.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'$hp/$hpMax',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 6,
|
||||
color: isLow ? RetroColors.hpRed : fillColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// 플로팅 데미지 텍스트
|
||||
if (hpChange != 0 && flashAnimation.value > 0.05)
|
||||
Positioned(
|
||||
left: isReversed ? null : 0,
|
||||
right: isReversed ? 0 : null,
|
||||
top: -12,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, -12 * (1 - flashAnimation.value)),
|
||||
child: Opacity(
|
||||
opacity: flashAnimation.value,
|
||||
child: Text(
|
||||
hpChange > 0 ? '+$hpChange' : '$hpChange',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 8,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isDamage ? RetroColors.hpRed : RetroColors.expGreen,
|
||||
shadows: const [
|
||||
Shadow(color: Colors.black, blurRadius: 3),
|
||||
Shadow(color: Colors.black, blurRadius: 6),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 세그먼트 바 (8-bit 스타일)
|
||||
Widget _buildSegmentBar({
|
||||
required double ratio,
|
||||
required Color fillColor,
|
||||
required bool isLow,
|
||||
required bool isReversed,
|
||||
}) {
|
||||
const segmentCount = 10;
|
||||
final filledSegments = (ratio.clamp(0.0, 1.0) * segmentCount).round();
|
||||
|
||||
final segments = List.generate(segmentCount, (index) {
|
||||
final isFilled = isReversed
|
||||
? index >= segmentCount - filledSegments
|
||||
: index < filledSegments;
|
||||
|
||||
return Expanded(
|
||||
child: Container(
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: isFilled
|
||||
? (isLow ? RetroColors.hpRed : fillColor)
|
||||
: fillColor.withValues(alpha: 0.2),
|
||||
border: Border(
|
||||
right: index < segmentCount - 1
|
||||
? BorderSide(
|
||||
color: RetroColors.borderOf(context).withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
)
|
||||
: BorderSide.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: RetroColors.borderOf(context),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: isReversed ? segments.reversed.toList() : segments,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBattleLog() {
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(12),
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: RetroColors.panelBgOf(context),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: RetroColors.borderOf(context)),
|
||||
),
|
||||
child: ListView.builder(
|
||||
reverse: true,
|
||||
itemCount: _battleLog.length,
|
||||
itemBuilder: (context, index) {
|
||||
final reversedIndex = _battleLog.length - 1 - index;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Text(
|
||||
_battleLog[reversedIndex],
|
||||
style: TextStyle(
|
||||
fontFamily: 'JetBrainsMono',
|
||||
fontSize: 7,
|
||||
color: RetroColors.textSecondaryOf(context),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user