From a2e93efc97da9e88b1f3f50f5ffa865be911dda7 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Tue, 6 Jan 2026 17:55:02 +0900 Subject: [PATCH] =?UTF-8?q?feat(arena):=20=EC=95=84=EB=A0=88=EB=82=98=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ArenaScreen: 아레나 메인 화면 - ArenaSetupScreen: 전투 설정 화면 - ArenaBattleScreen: 전투 진행 화면 - 관련 위젯 추가 --- .../features/arena/arena_battle_screen.dart | 518 +++++++++++++++++ lib/src/features/arena/arena_screen.dart | 168 ++++++ .../features/arena/arena_setup_screen.dart | 447 ++++++++++++++ .../widgets/arena_equipment_compare_list.dart | 548 ++++++++++++++++++ .../arena/widgets/arena_idle_preview.dart | 181 ++++++ .../arena/widgets/arena_rank_card.dart | 208 +++++++ .../arena/widgets/arena_result_dialog.dart | 437 ++++++++++++++ 7 files changed, 2507 insertions(+) create mode 100644 lib/src/features/arena/arena_battle_screen.dart create mode 100644 lib/src/features/arena/arena_screen.dart create mode 100644 lib/src/features/arena/arena_setup_screen.dart create mode 100644 lib/src/features/arena/widgets/arena_equipment_compare_list.dart create mode 100644 lib/src/features/arena/widgets/arena_idle_preview.dart create mode 100644 lib/src/features/arena/widgets/arena_rank_card.dart create mode 100644 lib/src/features/arena/widgets/arena_result_dialog.dart diff --git a/lib/src/features/arena/arena_battle_screen.dart b/lib/src/features/arena/arena_battle_screen.dart new file mode 100644 index 0000000..121896a --- /dev/null +++ b/lib/src/features/arena/arena_battle_screen.dart @@ -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 createState() => _ArenaBattleScreenState(); +} + +class _ArenaBattleScreenState extends State + 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 _battleLog = []; + + /// 전투 시뮬레이션 스트림 구독 + StreamSubscription? _combatSubscription; + + /// 최종 결과 + ArenaMatchResult? _result; + + // HP 변화 애니메이션 + late AnimationController _challengerFlashController; + late AnimationController _opponentFlashController; + late Animation _challengerFlashAnimation; + late Animation _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(begin: 1.0, end: 0.0).animate( + CurvedAnimation(parent: _challengerFlashController, curve: Curves.easeOut), + ); + + _opponentFlashController = AnimationController( + duration: const Duration(milliseconds: 400), + vsync: this, + ); + _opponentFlashAnimation = Tween(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 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), + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/src/features/arena/arena_screen.dart b/lib/src/features/arena/arena_screen.dart new file mode 100644 index 0000000..6d62bf4 --- /dev/null +++ b/lib/src/features/arena/arena_screen.dart @@ -0,0 +1,168 @@ +import 'package:flutter/material.dart'; + +import 'package:asciineverdie/src/core/model/hall_of_fame.dart'; +import 'package:asciineverdie/src/core/storage/hall_of_fame_storage.dart'; +import 'package:asciineverdie/src/features/arena/arena_setup_screen.dart'; +import 'package:asciineverdie/src/features/arena/widgets/arena_rank_card.dart'; +import 'package:asciineverdie/src/shared/retro_colors.dart'; +import 'package:asciineverdie/src/shared/widgets/retro_panel.dart'; + +// 임시 문자열 (추후 l10n으로 이동) +const _arenaTitle = 'LOCAL ARENA'; +const _arenaSubtitle = 'SELECT YOUR FIGHTER'; +const _arenaEmpty = 'Not enough heroes'; +const _arenaEmptyHint = 'Clear the game with 2+ characters'; + +/// 로컬 아레나 메인 화면 +/// +/// 순위표 표시 및 도전하기 버튼 +class ArenaScreen extends StatefulWidget { + const ArenaScreen({super.key}); + + @override + State createState() => _ArenaScreenState(); +} + +class _ArenaScreenState extends State { + final HallOfFameStorage _storage = HallOfFameStorage(); + HallOfFame? _hallOfFame; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadHallOfFame(); + } + + Future _loadHallOfFame() async { + final hallOfFame = await _storage.load(); + + if (mounted) { + setState(() { + _hallOfFame = hallOfFame; + _isLoading = false; + }); + } + } + + /// 캐릭터 선택 시 바로 슬롯 선택 화면으로 이동 + void _selectChallenger(HallOfFameEntry challenger) { + if (_hallOfFame == null || _hallOfFame!.count < 2) return; + + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => ArenaSetupScreen( + hallOfFame: _hallOfFame!, + initialChallenger: challenger, + onBattleComplete: _onBattleComplete, + ), + ), + ); + } + + void _onBattleComplete(HallOfFame updatedHallOfFame) { + setState(() { + _hallOfFame = updatedHallOfFame; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: RetroColors.backgroundOf(context), + appBar: AppBar( + title: Text( + _arenaTitle, + style: const TextStyle( + fontFamily: 'PressStart2P', + fontSize: 12, + ), + ), + centerTitle: true, + backgroundColor: RetroColors.panelBgOf(context), + ), + body: SafeArea( + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _buildContent(), + ), + ); + } + + Widget _buildContent() { + final hallOfFame = _hallOfFame; + if (hallOfFame == null || hallOfFame.count < 2) { + return _buildEmptyState(); + } + + return Column( + children: [ + // 순위표 (캐릭터 선택) + Expanded(child: _buildRankingList(hallOfFame)), + ], + ); + } + + Widget _buildEmptyState() { + return Center( + child: RetroPanel( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.sports_kabaddi, + size: 64, + color: RetroColors.textMutedOf(context), + ), + const SizedBox(height: 16), + Text( + _arenaEmpty, + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 10, + color: RetroColors.textSecondaryOf(context), + ), + ), + const SizedBox(height: 8), + Text( + _arenaEmptyHint, + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 7, + color: RetroColors.textMutedOf(context), + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + Widget _buildRankingList(HallOfFame hallOfFame) { + final rankedEntries = hallOfFame.rankedEntries; + + return Padding( + padding: const EdgeInsets.all(12), + child: RetroGoldPanel( + title: _arenaSubtitle, + padding: const EdgeInsets.all(8), + child: ListView.builder( + itemCount: rankedEntries.length, + itemBuilder: (context, index) { + final entry = rankedEntries[index]; + final score = HallOfFameArenaX.calculateArenaScore(entry); + return ArenaRankCard( + entry: entry, + rank: index + 1, + score: score, + onTap: () => _selectChallenger(entry), + ); + }, + ), + ), + ); + } + +} diff --git a/lib/src/features/arena/arena_setup_screen.dart b/lib/src/features/arena/arena_setup_screen.dart new file mode 100644 index 0000000..427e71c --- /dev/null +++ b/lib/src/features/arena/arena_setup_screen.dart @@ -0,0 +1,447 @@ +import 'package:flutter/material.dart'; + +import 'package:asciineverdie/src/core/engine/arena_service.dart'; +import 'package:asciineverdie/src/core/engine/item_service.dart'; +import 'package:asciineverdie/src/core/model/arena_match.dart'; +import 'package:asciineverdie/src/core/model/equipment_item.dart'; +import 'package:asciineverdie/src/core/model/equipment_slot.dart'; +import 'package:asciineverdie/src/core/model/hall_of_fame.dart'; +import 'package:asciineverdie/src/core/storage/hall_of_fame_storage.dart'; +import 'package:asciineverdie/src/features/arena/arena_battle_screen.dart'; +import 'package:asciineverdie/src/features/arena/widgets/arena_equipment_compare_list.dart'; +import 'package:asciineverdie/src/features/arena/widgets/arena_idle_preview.dart'; +import 'package:asciineverdie/src/features/arena/widgets/arena_rank_card.dart'; +import 'package:asciineverdie/src/shared/retro_colors.dart'; + +// 임시 문자열 (추후 l10n으로 이동) +const _setupTitle = 'ARENA SETUP'; +const _selectCharacter = 'SELECT YOUR FIGHTER'; +const _startBattleLabel = 'START BATTLE'; + +/// 아레나 설정 화면 +/// +/// 캐릭터 선택 및 슬롯 선택 +class ArenaSetupScreen extends StatefulWidget { + const ArenaSetupScreen({ + super.key, + required this.hallOfFame, + required this.onBattleComplete, + this.initialChallenger, + }); + + /// 명예의 전당 + final HallOfFame hallOfFame; + + /// 전투 완료 콜백 (업데이트된 명예의 전당 전달) + final void Function(HallOfFame) onBattleComplete; + + /// 초기 도전자 (메인 화면에서 선택한 경우) + final HallOfFameEntry? initialChallenger; + + @override + State createState() => _ArenaSetupScreenState(); +} + +class _ArenaSetupScreenState extends State { + final ArenaService _arenaService = ArenaService(); + final HallOfFameStorage _storage = HallOfFameStorage(); + + /// 현재 단계 (0: 캐릭터 선택, 1: 슬롯 선택) + int _step = 0; + + /// 선택된 도전자 + HallOfFameEntry? _challenger; + + /// 자동 결정된 상대 + HallOfFameEntry? _opponent; + + /// 선택된 베팅 슬롯 + EquipmentSlot? _selectedSlot; + + @override + void initState() { + super.initState(); + + // 초기 도전자가 있으면 바로 슬롯 선택 단계로 이동 + if (widget.initialChallenger != null) { + _selectChallenger(widget.initialChallenger!); + } + } + + void _selectChallenger(HallOfFameEntry entry) { + final opponent = _arenaService.findOpponent(widget.hallOfFame, entry.id); + + setState(() { + _challenger = entry; + _opponent = opponent; + _step = 1; + }); + } + + void _startBattle() { + if (_challenger == null || + _opponent == null || + _selectedSlot == null) { + return; + } + + final match = ArenaMatch( + challenger: _challenger!, + opponent: _opponent!, + bettingSlot: _selectedSlot!, + ); + + final navigator = Navigator.of(context); + + navigator.push( + MaterialPageRoute( + builder: (ctx) => ArenaBattleScreen( + match: match, + onBattleComplete: (result) async { + // 결과 저장 + var updatedHallOfFame = widget.hallOfFame + .updateEntry(result.updatedChallenger) + .updateEntry(result.updatedOpponent); + + await _storage.save(updatedHallOfFame); + + widget.onBattleComplete(updatedHallOfFame); + + // 아레나 화면으로 돌아가기 + if (mounted) { + navigator.popUntil((route) => route.isFirst); + } + }, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: RetroColors.backgroundOf(context), + appBar: AppBar( + title: Text( + _setupTitle, + style: const TextStyle( + fontFamily: 'PressStart2P', + fontSize: 12, + ), + ), + centerTitle: true, + backgroundColor: RetroColors.panelBgOf(context), + ), + body: SafeArea( + child: _step == 0 ? _buildCharacterSelection() : _buildSlotSelection(), + ), + ); + } + + Widget _buildCharacterSelection() { + final rankedEntries = widget.hallOfFame.rankedEntries; + + return Column( + children: [ + // 헤더 + Padding( + padding: const EdgeInsets.all(16), + child: Text( + _selectCharacter, + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 10, + color: RetroColors.goldOf(context), + ), + ), + ), + // 캐릭터 목록 + Expanded( + child: ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 8), + itemCount: rankedEntries.length, + itemBuilder: (context, index) { + final entry = rankedEntries[index]; + final score = HallOfFameArenaX.calculateArenaScore(entry); + return ArenaRankCard( + entry: entry, + rank: index + 1, + score: score, + onTap: () => _selectChallenger(entry), + ); + }, + ), + ), + ], + ); + } + + Widget _buildSlotSelection() { + final recommendedSlot = _calculateRecommendedSlot(); + + return Column( + children: [ + // ASCII 캐릭터 미리보기 (좌: 도전자, 우: 상대 반전) + ArenaIdlePreview( + challengerRaceId: _challenger?.race, + opponentRaceId: _opponent?.race, + ), + // 상단 캐릭터 정보 (좌우 대칭) + _buildCharacterHeaders(), + // 장비 비교 리스트 + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: ArenaEquipmentCompareList( + myEquipment: _challenger?.finalEquipment, + enemyEquipment: _opponent?.finalEquipment, + selectedSlot: _selectedSlot, + recommendedSlot: recommendedSlot, + onSlotSelected: (slot) { + setState(() => _selectedSlot = slot); + }, + ), + ), + ), + // 하단 버튼 + _buildStartButton(), + ], + ); + } + + /// 추천 슬롯 계산 (점수 이득이 가장 큰 슬롯) + EquipmentSlot? _calculateRecommendedSlot() { + if (_challenger == null || _opponent == null) return null; + + EquipmentSlot? bestSlot; + int maxGain = 0; + + for (final slot in EquipmentSlot.values) { + final myItem = _findItem(slot, _challenger!.finalEquipment); + final enemyItem = _findItem(slot, _opponent!.finalEquipment); + + final myScore = + myItem != null ? ItemService.calculateEquipmentScore(myItem) : 0; + final enemyScore = + enemyItem != null ? ItemService.calculateEquipmentScore(enemyItem) : 0; + final gain = enemyScore - myScore; + + if (gain > maxGain) { + maxGain = gain; + bestSlot = slot; + } + } + + return bestSlot; + } + + /// 장비 찾기 헬퍼 + EquipmentItem? _findItem(EquipmentSlot slot, List? items) { + if (items == null) return null; + for (final item in items) { + if (item.slot == slot) return item; + } + return null; + } + + /// 상단 캐릭터 정보 헤더 (좌우 대칭) + Widget _buildCharacterHeaders() { + return Padding( + padding: const EdgeInsets.all(12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 내 캐릭터 + Expanded(child: _buildChallengerInfo()), + const SizedBox(width: 12), + // 상대 캐릭터 + Expanded(child: _buildOpponentInfo()), + ], + ), + ); + } + + /// 내 캐릭터 정보 카드 + Widget _buildChallengerInfo() { + if (_challenger == null) return const SizedBox.shrink(); + + final score = HallOfFameArenaX.calculateArenaScore(_challenger!); + final rank = widget.hallOfFame.getRank(_challenger!.id); + + return Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.blue, width: 2), + ), + child: Row( + children: [ + // 순위 배지 + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.2), + shape: BoxShape.circle, + border: Border.all(color: Colors.blue, width: 2), + ), + child: Center( + child: Text( + '$rank', + style: const TextStyle( + fontFamily: 'PressStart2P', + fontSize: 10, + color: Colors.blue, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + const SizedBox(width: 8), + // 정보 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _challenger!.characterName, + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 7, + color: RetroColors.textPrimaryOf(context), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + 'Lv.${_challenger!.level} • $score pt', + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 5, + color: RetroColors.textSecondaryOf(context), + ), + ), + ], + ), + ), + ], + ), + ); + } + + /// 시작 버튼 + Widget _buildStartButton() { + return Padding( + padding: const EdgeInsets.all(12), + child: SizedBox( + height: 48, + child: ElevatedButton( + onPressed: _selectedSlot != null ? _startBattle : null, + style: ElevatedButton.styleFrom( + backgroundColor: RetroColors.goldOf(context), + foregroundColor: RetroColors.backgroundOf(context), + disabledBackgroundColor: + RetroColors.borderOf(context).withValues(alpha: 0.5), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.sports_kabaddi, + color: _selectedSlot != null + ? RetroColors.backgroundOf(context) + : RetroColors.textMutedOf(context), + ), + const SizedBox(width: 8), + Text( + _startBattleLabel, + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 10, + color: _selectedSlot != null + ? RetroColors.backgroundOf(context) + : RetroColors.textMutedOf(context), + ), + ), + ], + ), + ), + ), + ); + } + + /// 상대 캐릭터 정보 카드 (대칭 스타일) + Widget _buildOpponentInfo() { + if (_opponent == null) return const SizedBox.shrink(); + + final score = HallOfFameArenaX.calculateArenaScore(_opponent!); + final rank = widget.hallOfFame.getRank(_opponent!.id); + + return Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.red, width: 2), + ), + child: Row( + children: [ + // 순위 배지 + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.2), + shape: BoxShape.circle, + border: Border.all(color: Colors.red, width: 2), + ), + child: Center( + child: Text( + '$rank', + style: const TextStyle( + fontFamily: 'PressStart2P', + fontSize: 10, + color: Colors.red, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + const SizedBox(width: 8), + // 정보 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _opponent!.characterName, + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 7, + color: RetroColors.textPrimaryOf(context), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + 'Lv.${_opponent!.level} • $score pt', + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 5, + color: RetroColors.textSecondaryOf(context), + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/features/arena/widgets/arena_equipment_compare_list.dart b/lib/src/features/arena/widgets/arena_equipment_compare_list.dart new file mode 100644 index 0000000..98f1441 --- /dev/null +++ b/lib/src/features/arena/widgets/arena_equipment_compare_list.dart @@ -0,0 +1,548 @@ +import 'package:flutter/material.dart'; + +import 'package:asciineverdie/src/core/engine/item_service.dart'; +import 'package:asciineverdie/src/core/model/equipment_item.dart'; +import 'package:asciineverdie/src/core/model/equipment_slot.dart'; +import 'package:asciineverdie/src/core/model/item_stats.dart'; +import 'package:asciineverdie/src/shared/retro_colors.dart'; + +// 임시 문자열 (추후 l10n으로 이동) +const _myEquipmentTitle = 'MY EQUIPMENT'; +const _enemyEquipmentTitle = 'ENEMY EQUIPMENT'; +const _selectSlotLabel = 'SELECT'; +const _recommendedLabel = 'BEST'; + +/// 좌우 대칭 장비 비교 리스트 +/// +/// 내 장비와 상대 장비를 나란히 표시하고, +/// 선택 시 인라인으로 비교 정보를 확장 +class ArenaEquipmentCompareList extends StatefulWidget { + const ArenaEquipmentCompareList({ + super.key, + required this.myEquipment, + required this.enemyEquipment, + required this.selectedSlot, + required this.onSlotSelected, + this.recommendedSlot, + }); + + /// 내 장비 목록 + final List? myEquipment; + + /// 상대 장비 목록 + final List? enemyEquipment; + + /// 현재 선택된 슬롯 + final EquipmentSlot? selectedSlot; + + /// 슬롯 선택 콜백 + final ValueChanged onSlotSelected; + + /// 추천 슬롯 (점수 이득이 가장 큰 슬롯) + final EquipmentSlot? recommendedSlot; + + @override + State createState() => + _ArenaEquipmentCompareListState(); +} + +class _ArenaEquipmentCompareListState extends State { + /// 현재 확장된 슬롯 (탭하여 비교 중인 슬롯) + EquipmentSlot? _expandedSlot; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + // 헤더 (좌우 타이틀) + _buildHeader(context), + const SizedBox(height: 8), + // 장비 리스트 + Expanded( + child: ListView.builder( + itemCount: EquipmentSlot.values.length, + itemBuilder: (context, index) { + final slot = EquipmentSlot.values[index]; + return _buildSlotRow(context, slot); + }, + ), + ), + ], + ); + } + + Widget _buildHeader(BuildContext context) { + return Row( + children: [ + // 내 장비 타이틀 + Expanded( + child: Container( + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.blue.withValues(alpha: 0.3)), + ), + child: Text( + _myEquipmentTitle, + style: const TextStyle( + fontFamily: 'PressStart2P', + fontSize: 6, + color: Colors.blue, + ), + textAlign: TextAlign.center, + ), + ), + ), + const SizedBox(width: 8), + // 상대 장비 타이틀 + Expanded( + child: Container( + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.red.withValues(alpha: 0.3)), + ), + child: Text( + _enemyEquipmentTitle, + style: const TextStyle( + fontFamily: 'PressStart2P', + fontSize: 6, + color: Colors.red, + ), + textAlign: TextAlign.center, + ), + ), + ), + ], + ); + } + + Widget _buildSlotRow(BuildContext context, EquipmentSlot slot) { + final myItem = _findItem(slot, widget.myEquipment); + final enemyItem = _findItem(slot, widget.enemyEquipment); + final isExpanded = _expandedSlot == slot; + final isSelected = widget.selectedSlot == slot; + final isRecommended = widget.recommendedSlot == slot; + + final myScore = + myItem != null ? ItemService.calculateEquipmentScore(myItem) : 0; + final enemyScore = + enemyItem != null ? ItemService.calculateEquipmentScore(enemyItem) : 0; + final scoreDiff = enemyScore - myScore; + + return Column( + children: [ + // 슬롯 행 (좌우 대칭) + GestureDetector( + onTap: () { + setState(() { + _expandedSlot = isExpanded ? null : slot; + }); + }, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4), + decoration: BoxDecoration( + color: isSelected + ? RetroColors.goldOf(context).withValues(alpha: 0.2) + : isRecommended + ? Colors.green.withValues(alpha: 0.1) + : isExpanded + ? RetroColors.panelBgOf(context) + : Colors.transparent, + border: Border( + bottom: BorderSide( + color: RetroColors.borderOf(context).withValues(alpha: 0.3), + ), + ), + ), + child: Row( + children: [ + // 내 장비 + Expanded(child: _buildEquipmentCell(context, myItem, myScore, Colors.blue)), + // 슬롯 아이콘 (중앙) + _buildSlotIndicator(context, slot, isSelected, isRecommended, scoreDiff), + // 상대 장비 + Expanded(child: _buildEquipmentCell(context, enemyItem, enemyScore, Colors.red)), + ], + ), + ), + ), + // 확장된 비교 패널 + if (isExpanded) + _buildExpandedPanel(context, slot, myItem, enemyItem, scoreDiff), + ], + ); + } + + /// 장비 셀 (한쪽) + Widget _buildEquipmentCell( + BuildContext context, + EquipmentItem? item, + int score, + Color accentColor, + ) { + final hasItem = item != null && item.isNotEmpty; + final rarityColor = hasItem ? _getRarityColor(item.rarity) : Colors.grey; + + return Row( + children: [ + // 아이템 이름 + Expanded( + child: Text( + hasItem ? item.name : '-', + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 5, + color: hasItem ? rarityColor : RetroColors.textMutedOf(context), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + // 점수 + Text( + '$score', + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 5, + color: hasItem + ? RetroColors.textSecondaryOf(context) + : RetroColors.textMutedOf(context), + ), + ), + ], + ); + } + + /// 슬롯 인디케이터 (중앙) + Widget _buildSlotIndicator( + BuildContext context, + EquipmentSlot slot, + bool isSelected, + bool isRecommended, + int scoreDiff, + ) { + final Color borderColor; + final Color bgColor; + + if (isSelected) { + borderColor = RetroColors.goldOf(context); + bgColor = RetroColors.goldOf(context).withValues(alpha: 0.3); + } else if (isRecommended) { + borderColor = Colors.green; + bgColor = Colors.green.withValues(alpha: 0.2); + } else { + borderColor = RetroColors.borderOf(context); + bgColor = RetroColors.panelBgOf(context); + } + + return Container( + width: 56, + margin: const EdgeInsets.symmetric(horizontal: 4), + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 2), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: borderColor, width: isSelected ? 2 : 1), + ), + child: Column( + children: [ + // 슬롯 아이콘 + Icon( + _getSlotIcon(slot), + size: 12, + color: isSelected + ? RetroColors.goldOf(context) + : RetroColors.textSecondaryOf(context), + ), + const SizedBox(height: 2), + // 점수 변화 + _buildScoreDiffBadge(context, scoreDiff, isRecommended), + ], + ), + ); + } + + /// 점수 변화 뱃지 + Widget _buildScoreDiffBadge( + BuildContext context, + int scoreDiff, + bool isRecommended, + ) { + final Color diffColor; + final String diffText; + + if (scoreDiff > 0) { + diffColor = Colors.green; + diffText = '+$scoreDiff'; + } else if (scoreDiff < 0) { + diffColor = Colors.red; + diffText = '$scoreDiff'; + } else { + diffColor = RetroColors.textMutedOf(context); + diffText = '±0'; + } + + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (isRecommended) ...[ + Text( + _recommendedLabel, + style: const TextStyle( + fontFamily: 'PressStart2P', + fontSize: 4, + color: Colors.green, + ), + ), + const SizedBox(width: 2), + ], + Text( + diffText, + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 5, + color: diffColor, + fontWeight: FontWeight.bold, + ), + ), + ], + ); + } + + /// 확장된 비교 패널 + Widget _buildExpandedPanel( + BuildContext context, + EquipmentSlot slot, + EquipmentItem? myItem, + EquipmentItem? enemyItem, + int scoreDiff, + ) { + final Color resultColor; + final String resultText; + final IconData resultIcon; + + if (scoreDiff > 0) { + resultColor = Colors.green; + resultText = 'You will GAIN +$scoreDiff'; + resultIcon = Icons.arrow_upward; + } else if (scoreDiff < 0) { + resultColor = Colors.red; + resultText = 'You will LOSE $scoreDiff'; + resultIcon = Icons.arrow_downward; + } else { + resultColor = RetroColors.textMutedOf(context); + resultText = 'Even trade'; + resultIcon = Icons.swap_horiz; + } + + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: resultColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: resultColor.withValues(alpha: 0.5)), + ), + child: Column( + children: [ + // 상세 비교 (좌우) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 내 아이템 상세 + Expanded(child: _buildItemDetail(context, myItem, Colors.blue)), + // VS 아이콘 + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Icon( + Icons.swap_horiz, + color: RetroColors.textMutedOf(context), + size: 20, + ), + ), + // 상대 아이템 상세 + Expanded(child: _buildItemDetail(context, enemyItem, Colors.red)), + ], + ), + const SizedBox(height: 10), + // 교환 결과 + Container( + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12), + decoration: BoxDecoration( + color: resultColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Icon(resultIcon, color: resultColor, size: 14), + const SizedBox(width: 6), + Text( + resultText, + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 6, + color: resultColor, + ), + ), + ], + ), + ), + const SizedBox(height: 10), + // 선택 버튼 + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () { + widget.onSlotSelected(slot); + setState(() => _expandedSlot = null); + }, + style: ElevatedButton.styleFrom( + backgroundColor: RetroColors.goldOf(context), + foregroundColor: RetroColors.backgroundOf(context), + padding: const EdgeInsets.symmetric(vertical: 8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + ), + ), + child: Text( + _selectSlotLabel, + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 7, + color: RetroColors.backgroundOf(context), + ), + ), + ), + ), + ], + ), + ); + } + + /// 아이템 상세 정보 + Widget _buildItemDetail( + BuildContext context, + EquipmentItem? item, + Color accentColor, + ) { + final hasItem = item != null && item.isNotEmpty; + final rarityColor = hasItem ? _getRarityColor(item.rarity) : Colors.grey; + final score = hasItem ? ItemService.calculateEquipmentScore(item) : 0; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 아이템 이름 + Text( + hasItem ? item.name : '(Empty)', + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 6, + color: hasItem ? rarityColor : RetroColors.textMutedOf(context), + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + // 점수 + Text( + 'Score: $score', + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 5, + color: RetroColors.textSecondaryOf(context), + ), + ), + // 스탯 + if (hasItem) ...[ + const SizedBox(height: 4), + _buildItemStats(context, item), + ], + ], + ); + } + + /// 아이템 스탯 표시 + Widget _buildItemStats(BuildContext context, EquipmentItem item) { + final stats = item.stats; + final statWidgets = []; + + if (stats.atk > 0) { + statWidgets.add(_buildStatChip('ATK', stats.atk, Colors.red)); + } + if (stats.def > 0) { + statWidgets.add(_buildStatChip('DEF', stats.def, Colors.blue)); + } + if (stats.hpBonus > 0) { + statWidgets.add(_buildStatChip('HP', stats.hpBonus, Colors.green)); + } + if (stats.mpBonus > 0) { + statWidgets.add(_buildStatChip('MP', stats.mpBonus, Colors.purple)); + } + + if (statWidgets.isEmpty) return const SizedBox.shrink(); + + return Wrap( + spacing: 3, + runSpacing: 3, + children: statWidgets, + ); + } + + Widget _buildStatChip(String label, int value, Color color) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 1), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(2), + ), + child: Text( + '$label +$value', + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 4, + color: color, + ), + ), + ); + } + + EquipmentItem? _findItem(EquipmentSlot slot, List? items) { + if (items == null) return null; + for (final item in items) { + if (item.slot == slot) return item; + } + return null; + } + + IconData _getSlotIcon(EquipmentSlot slot) { + return switch (slot) { + EquipmentSlot.weapon => Icons.gavel, + EquipmentSlot.shield => Icons.shield, + EquipmentSlot.helm => Icons.sports_mma, + EquipmentSlot.hauberk => Icons.checkroom, + EquipmentSlot.brassairts => Icons.front_hand, + EquipmentSlot.vambraces => Icons.back_hand, + EquipmentSlot.gauntlets => Icons.sports_handball, + EquipmentSlot.gambeson => Icons.dry_cleaning, + EquipmentSlot.cuisses => Icons.airline_seat_legroom_normal, + EquipmentSlot.greaves => Icons.snowshoeing, + EquipmentSlot.sollerets => Icons.do_not_step, + }; + } + + Color _getRarityColor(ItemRarity rarity) { + return switch (rarity) { + ItemRarity.common => Colors.grey.shade600, + ItemRarity.uncommon => Colors.green.shade600, + ItemRarity.rare => Colors.blue.shade600, + ItemRarity.epic => Colors.purple.shade600, + ItemRarity.legendary => Colors.orange.shade700, + }; + } +} diff --git a/lib/src/features/arena/widgets/arena_idle_preview.dart b/lib/src/features/arena/widgets/arena_idle_preview.dart new file mode 100644 index 0000000..ccba80f --- /dev/null +++ b/lib/src/features/arena/widgets/arena_idle_preview.dart @@ -0,0 +1,181 @@ +import 'dart:async'; + +import 'package:asciineverdie/src/core/animation/canvas/ascii_cell.dart'; +import 'package:asciineverdie/src/core/animation/canvas/ascii_canvas_widget.dart'; +import 'package:asciineverdie/src/core/animation/canvas/ascii_layer.dart'; +import 'package:asciineverdie/src/core/animation/character_frames.dart'; +import 'package:asciineverdie/src/core/animation/race_character_frames.dart'; +import 'package:flutter/material.dart'; + +/// 아레나 idle 상태 캐릭터 미리보기 위젯 +/// +/// 좌측에 도전자, 우측에 상대(좌우 반전)를 idle 상태로 표시 +class ArenaIdlePreview extends StatefulWidget { + const ArenaIdlePreview({ + super.key, + required this.challengerRaceId, + required this.opponentRaceId, + }); + + /// 도전자 종족 ID + final String? challengerRaceId; + + /// 상대 종족 ID + final String? opponentRaceId; + + @override + State createState() => _ArenaIdlePreviewState(); +} + +class _ArenaIdlePreviewState extends State { + /// 현재 idle 프레임 인덱스 (0~3) + int _frameIndex = 0; + + /// 애니메이션 타이머 + Timer? _timer; + + /// 레이어 버전 (변경 감지용) + int _layerVersion = 0; + + /// 캔버스 크기 + static const int _gridWidth = 32; + static const int _gridHeight = 5; + + /// 캐릭터 위치 + static const int _leftCharX = 4; + static const int _rightCharX = 22; + static const int _charY = 1; // 상단 여백 + + @override + void initState() { + super.initState(); + _startAnimation(); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + void _startAnimation() { + // 200ms마다 프레임 업데이트 (원본 틱 속도) + _timer = Timer.periodic(const Duration(milliseconds: 200), (_) { + setState(() { + _frameIndex = (_frameIndex + 1) % 4; + _layerVersion++; + }); + }); + } + + @override + Widget build(BuildContext context) { + final layers = _composeLayers(); + + return SizedBox( + height: 60, + child: AsciiCanvasWidget( + layers: layers, + gridWidth: _gridWidth, + gridHeight: _gridHeight, + backgroundOpacity: 0.3, + isAnimating: true, + layerVersion: _layerVersion, + ), + ); + } + + /// 레이어 합성 + List _composeLayers() { + final layers = []; + + // 도전자 캐릭터 (좌측, 정방향) + final challengerLayer = _createCharacterLayer( + widget.challengerRaceId, + _leftCharX, + mirrored: false, + ); + layers.add(challengerLayer); + + // 상대 캐릭터 (우측, 좌우 반전) + final opponentLayer = _createCharacterLayer( + widget.opponentRaceId, + _rightCharX, + mirrored: true, + ); + layers.add(opponentLayer); + + return layers; + } + + /// 캐릭터 레이어 생성 + AsciiLayer _createCharacterLayer( + String? raceId, + int xOffset, { + required bool mirrored, + }) { + // 종족별 idle 프레임 조회 + CharacterFrame frame; + if (raceId != null && raceId.isNotEmpty) { + final raceData = RaceCharacterFrames.get(raceId); + if (raceData != null) { + frame = raceData.idle[_frameIndex % raceData.idle.length]; + } else { + frame = getCharacterFrame(BattlePhase.idle, _frameIndex); + } + } else { + frame = getCharacterFrame(BattlePhase.idle, _frameIndex); + } + + // 미러링 적용 + final lines = mirrored ? _mirrorLines(frame.lines) : frame.lines; + + // 셀 변환 + final cells = _spriteToCells(lines); + + return AsciiLayer( + cells: cells, + zIndex: 1, + offsetX: xOffset, + offsetY: _charY, + ); + } + + /// 문자열 좌우 반전 + List _mirrorLines(List lines) { + return lines.map((line) { + final chars = line.split(''); + final mirrored = chars.reversed.map(_mirrorChar).toList(); + return mirrored.join(); + }).toList(); + } + + /// 개별 문자 미러링 (방향성 문자 변환) + String _mirrorChar(String char) { + return switch (char) { + '/' => r'\', + r'\' => '/', + '(' => ')', + ')' => '(', + '[' => ']', + ']' => '[', + '{' => '}', + '}' => '{', + '<' => '>', + '>' => '<', + '┘' => '└', + '└' => '┘', + '┐' => '┌', + '┌' => '┐', + 'λ' => 'λ', // 대칭 + _ => char, + }; + } + + /// 문자열 스프라이트를 AsciiCell 2D 배열로 변환 + List> _spriteToCells(List lines) { + return lines.map((line) { + return line.split('').map(AsciiCell.fromChar).toList(); + }).toList(); + } +} diff --git a/lib/src/features/arena/widgets/arena_rank_card.dart b/lib/src/features/arena/widgets/arena_rank_card.dart new file mode 100644 index 0000000..c51ecfc --- /dev/null +++ b/lib/src/features/arena/widgets/arena_rank_card.dart @@ -0,0 +1,208 @@ +import 'package:flutter/material.dart'; + +import 'package:asciineverdie/src/core/l10n/game_data_l10n.dart'; +import 'package:asciineverdie/src/core/model/hall_of_fame.dart'; +import 'package:asciineverdie/src/shared/retro_colors.dart'; + +/// 아레나 순위 카드 위젯 +/// +/// 명예의 전당 캐릭터를 순위와 함께 표시 +class ArenaRankCard extends StatelessWidget { + const ArenaRankCard({ + super.key, + required this.entry, + required this.rank, + required this.score, + this.isSelected = false, + this.isHighlighted = false, + this.compact = false, + this.onTap, + }); + + /// 캐릭터 엔트리 + final HallOfFameEntry entry; + + /// 순위 (1-based) + final int rank; + + /// 아레나 점수 + final int score; + + /// 선택 상태 (상대로 선택됨) + final bool isSelected; + + /// 하이라이트 상태 (내 캐릭터 표시) + final bool isHighlighted; + + /// 컴팩트 모드 (작은 사이즈) + final bool compact; + + /// 탭 콜백 + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final rankColor = _getRankColor(rank); + final rankIcon = _getRankIcon(rank); + + // 배경색 결정 + Color bgColor; + Color borderColor; + if (isSelected) { + bgColor = Colors.red.withValues(alpha: 0.15); + borderColor = Colors.red; + } else if (isHighlighted) { + bgColor = Colors.blue.withValues(alpha: 0.15); + borderColor = Colors.blue; + } else { + bgColor = RetroColors.panelBgOf(context); + borderColor = RetroColors.borderOf(context); + } + + return Card( + margin: EdgeInsets.symmetric( + vertical: compact ? 2 : 4, + horizontal: compact ? 0 : 8, + ), + color: bgColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(compact ? 6 : 8), + side: BorderSide( + color: borderColor, + width: (isSelected || isHighlighted) ? 2 : 1, + ), + ), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(compact ? 6 : 8), + child: Padding( + padding: EdgeInsets.all(compact ? 8 : 12), + child: Row( + children: [ + // 순위 배지 + _buildRankBadge(rankColor, rankIcon), + SizedBox(width: compact ? 8 : 12), + // 캐릭터 정보 + Expanded(child: _buildCharacterInfo(context)), + // 점수 + if (!compact) _buildScoreColumn(context), + ], + ), + ), + ), + ); + } + + Widget _buildRankBadge(Color color, IconData? icon) { + final size = compact ? 24.0 : 36.0; + final iconSize = compact ? 12.0 : 18.0; + final fontSize = compact ? 7.0 : 10.0; + + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.2), + shape: BoxShape.circle, + border: Border.all(color: color, width: compact ? 1.5 : 2), + ), + child: Center( + child: icon != null + ? Icon(icon, color: color, size: iconSize) + : Text( + '$rank', + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: fontSize, + color: color, + fontWeight: FontWeight.bold, + ), + ), + ), + ); + } + + Widget _buildCharacterInfo(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 이름 + Text( + entry.characterName, + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: compact ? 6 : 9, + color: RetroColors.textPrimaryOf(context), + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + SizedBox(height: compact ? 2 : 4), + // 종족/클래스 + 레벨 + Text( + compact + ? 'Lv.${entry.level}' + : '${GameDataL10n.getRaceName(context, entry.race)} ' + '${GameDataL10n.getKlassName(context, entry.klass)} ' + 'Lv.${entry.level}', + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: compact ? 5 : 7, + color: RetroColors.textSecondaryOf(context), + ), + ), + ], + ); + } + + Widget _buildScoreColumn(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '$score', + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 10, + color: RetroColors.goldOf(context), + fontWeight: FontWeight.bold, + ), + ), + Text( + 'SCORE', + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 6, + color: RetroColors.textMutedOf(context), + ), + ), + ], + ); + } + + Color _getRankColor(int rank) { + switch (rank) { + case 1: + return Colors.amber.shade700; + case 2: + return Colors.grey.shade500; + case 3: + return Colors.brown.shade400; + default: + return Colors.blue.shade400; + } + } + + IconData? _getRankIcon(int rank) { + switch (rank) { + case 1: + return Icons.emoji_events; + case 2: + return Icons.workspace_premium; + case 3: + return Icons.military_tech; + default: + return null; + } + } +} diff --git a/lib/src/features/arena/widgets/arena_result_dialog.dart b/lib/src/features/arena/widgets/arena_result_dialog.dart new file mode 100644 index 0000000..e026812 --- /dev/null +++ b/lib/src/features/arena/widgets/arena_result_dialog.dart @@ -0,0 +1,437 @@ +import 'package:flutter/material.dart'; + +import 'package:asciineverdie/data/game_text_l10n.dart' as l10n; +import 'package:asciineverdie/src/core/engine/item_service.dart'; +import 'package:asciineverdie/src/core/model/arena_match.dart'; +import 'package:asciineverdie/src/core/model/equipment_item.dart'; +import 'package:asciineverdie/src/core/model/equipment_slot.dart'; +import 'package:asciineverdie/src/core/model/item_stats.dart'; +import 'package:asciineverdie/src/shared/retro_colors.dart'; + +// 아레나 관련 임시 문자열 (추후 l10n으로 이동) +const _arenaVictory = 'VICTORY!'; +const _arenaDefeat = 'DEFEAT...'; +const _arenaExchange = 'EQUIPMENT EXCHANGE'; + +/// 아레나 결과 다이얼로그 +/// +/// 전투 승패 및 장비 교환 결과 표시 +class ArenaResultDialog extends StatelessWidget { + const ArenaResultDialog({ + super.key, + required this.result, + required this.onClose, + }); + + /// 대전 결과 + final ArenaMatchResult result; + + /// 닫기 콜백 + final VoidCallback onClose; + + @override + Widget build(BuildContext context) { + final isVictory = result.isVictory; + final resultColor = isVictory ? Colors.amber : Colors.red; + final slot = result.match.bettingSlot; + + return AlertDialog( + backgroundColor: RetroColors.panelBgOf(context), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: resultColor, width: 2), + ), + title: _buildTitle(context, isVictory, resultColor), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 350), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 전투 정보 + _buildBattleInfo(context), + const SizedBox(height: 16), + const Divider(), + const SizedBox(height: 16), + // 장비 교환 결과 + _buildExchangeResult(context, slot), + ], + ), + ), + actions: [ + FilledButton( + onPressed: onClose, + style: FilledButton.styleFrom( + backgroundColor: resultColor, + ), + child: Text( + l10n.buttonConfirm, + style: const TextStyle( + fontFamily: 'PressStart2P', + fontSize: 8, + ), + ), + ), + ], + ); + } + + Widget _buildTitle(BuildContext context, bool isVictory, Color color) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + isVictory ? Icons.emoji_events : Icons.sentiment_very_dissatisfied, + color: color, + size: 28, + ), + const SizedBox(width: 8), + Text( + isVictory ? _arenaVictory : _arenaDefeat, + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 12, + color: color, + ), + ), + const SizedBox(width: 8), + Icon( + isVictory ? Icons.emoji_events : Icons.sentiment_very_dissatisfied, + color: color, + size: 28, + ), + ], + ); + } + + Widget _buildBattleInfo(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + // 도전자 정보 + _buildFighterInfo( + context, + result.match.challenger.characterName, + result.isVictory, + ), + // VS + Text( + 'VS', + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 10, + color: RetroColors.textMutedOf(context), + ), + ), + // 상대 정보 + _buildFighterInfo( + context, + result.match.opponent.characterName, + !result.isVictory, + ), + ], + ); + } + + Widget _buildFighterInfo(BuildContext context, String name, bool isWinner) { + return Column( + children: [ + Icon( + isWinner ? Icons.emoji_events : Icons.close, + color: isWinner ? Colors.amber : Colors.grey, + size: 24, + ), + const SizedBox(height: 4), + Text( + name, + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 7, + color: isWinner + ? RetroColors.goldOf(context) + : RetroColors.textMutedOf(context), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + isWinner ? 'WINNER' : 'LOSER', + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 6, + color: isWinner ? Colors.amber : Colors.grey, + ), + ), + ], + ); + } + + Widget _buildExchangeResult(BuildContext context, EquipmentSlot slot) { + // 교환된 장비 찾기 + final challengerOldItem = _findItem( + result.match.challenger.finalEquipment, + slot, + ); + final opponentOldItem = _findItem( + result.match.opponent.finalEquipment, + slot, + ); + + final challengerNewItem = _findItem( + result.updatedChallenger.finalEquipment, + slot, + ); + final opponentNewItem = _findItem( + result.updatedOpponent.finalEquipment, + slot, + ); + + return Column( + children: [ + // 교환 타이틀 + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.swap_horiz, + color: RetroColors.goldOf(context), + size: 20, + ), + const SizedBox(width: 8), + Text( + _arenaExchange, + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 8, + color: RetroColors.goldOf(context), + ), + ), + ], + ), + const SizedBox(height: 12), + // 슬롯 정보 + Text( + _getSlotLabel(slot), + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 7, + color: RetroColors.textSecondaryOf(context), + ), + ), + const SizedBox(height: 12), + // 내 캐릭터 장비 변경 + _buildExchangeRow( + context, + result.match.challenger.characterName, + challengerOldItem, + challengerNewItem, + result.isVictory, + ), + const SizedBox(height: 8), + // 상대 장비 변경 + _buildExchangeRow( + context, + result.match.opponent.characterName, + opponentOldItem, + opponentNewItem, + !result.isVictory, + ), + ], + ); + } + + Widget _buildExchangeRow( + BuildContext context, + String name, + EquipmentItem? oldItem, + EquipmentItem? newItem, + bool isWinner, + ) { + final oldScore = + oldItem != null ? ItemService.calculateEquipmentScore(oldItem) : 0; + final newScore = + newItem != null ? ItemService.calculateEquipmentScore(newItem) : 0; + final scoreDiff = newScore - oldScore; + final isGain = scoreDiff > 0; + + return Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: isWinner + ? Colors.green.withValues(alpha: 0.1) + : Colors.red.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isWinner + ? Colors.green.withValues(alpha: 0.3) + : Colors.red.withValues(alpha: 0.3), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 이름 + Text( + name, + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 7, + color: RetroColors.textPrimaryOf(context), + ), + ), + const SizedBox(height: 4), + // 장비 변경 + Row( + children: [ + // 이전 장비 + Expanded( + child: _buildItemChip( + context, + oldItem, + oldScore, + isOld: true, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Icon( + Icons.arrow_forward, + size: 14, + color: RetroColors.textMutedOf(context), + ), + ), + // 새 장비 + Expanded( + child: _buildItemChip( + context, + newItem, + newScore, + isOld: false, + ), + ), + ], + ), + const SizedBox(height: 4), + // 점수 변화 + Align( + alignment: Alignment.centerRight, + child: Text( + '${isGain ? '+' : ''}$scoreDiff pt', + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 7, + color: isGain ? Colors.green : Colors.red, + ), + ), + ), + ], + ), + ); + } + + Widget _buildItemChip( + BuildContext context, + EquipmentItem? item, + int score, { + required bool isOld, + }) { + if (item == null || item.isEmpty) { + return Text( + '(empty)', + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 6, + color: RetroColors.textMutedOf(context), + ), + ); + } + + final rarityColor = _getRarityColor(item.rarity); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + decoration: BoxDecoration( + color: rarityColor.withValues(alpha: isOld ? 0.1 : 0.2), + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: rarityColor.withValues(alpha: 0.5), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.name, + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 5, + color: rarityColor, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + '$score pt', + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 5, + color: RetroColors.textMutedOf(context), + ), + ), + ], + ), + ); + } + + EquipmentItem? _findItem(List? equipment, EquipmentSlot slot) { + if (equipment == null) return null; + for (final item in equipment) { + if (item.slot == slot) return item; + } + return null; + } + + String _getSlotLabel(EquipmentSlot slot) { + return switch (slot) { + EquipmentSlot.weapon => l10n.slotWeapon, + EquipmentSlot.shield => l10n.slotShield, + EquipmentSlot.helm => l10n.slotHelm, + EquipmentSlot.hauberk => l10n.slotHauberk, + EquipmentSlot.brassairts => l10n.slotBrassairts, + EquipmentSlot.vambraces => l10n.slotVambraces, + EquipmentSlot.gauntlets => l10n.slotGauntlets, + EquipmentSlot.gambeson => l10n.slotGambeson, + EquipmentSlot.cuisses => l10n.slotCuisses, + EquipmentSlot.greaves => l10n.slotGreaves, + EquipmentSlot.sollerets => l10n.slotSollerets, + }; + } + + Color _getRarityColor(ItemRarity rarity) { + return switch (rarity) { + ItemRarity.common => Colors.grey.shade600, + ItemRarity.uncommon => Colors.green.shade600, + ItemRarity.rare => Colors.blue.shade600, + ItemRarity.epic => Colors.purple.shade600, + ItemRarity.legendary => Colors.orange.shade700, + }; + } +} + +/// 아레나 결과 다이얼로그 표시 +Future showArenaResultDialog( + BuildContext context, { + required ArenaMatchResult result, + required VoidCallback onClose, +}) { + return showDialog( + context: context, + barrierDismissible: false, + builder: (context) => ArenaResultDialog( + result: result, + onClose: () { + Navigator.of(context).pop(); + onClose(); + }, + ), + ); +}