Files
asciinevrdie/lib/src/features/arena/widgets/arena_result_panel.dart
JiWoong Sul 6667de56d3 feat(arena): 아레나 화면 및 위젯 개선
- 장비 비교 리스트 UI 개선
- 결과 패널/다이얼로그 업데이트
- 설정 화면 개선
2026-01-07 20:21:54 +09:00

486 lines
14 KiB
Dart

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';
// 임시 문자열
const _victory = 'VICTORY!';
const _defeat = 'DEFEAT...';
const _exchange = 'EQUIPMENT EXCHANGE';
const _turns = 'TURNS';
/// 아레나 결과 패널 (인라인)
///
/// 전투 로그 하단에 표시되는 플로팅 결과 패널
class ArenaResultPanel extends StatefulWidget {
const ArenaResultPanel({
super.key,
required this.result,
required this.turnCount,
required this.onContinue,
});
/// 대전 결과
final ArenaMatchResult result;
/// 총 턴 수
final int turnCount;
/// Continue 콜백
final VoidCallback onContinue;
@override
State<ArenaResultPanel> createState() => _ArenaResultPanelState();
}
class _ArenaResultPanelState extends State<ArenaResultPanel>
with SingleTickerProviderStateMixin {
late AnimationController _slideController;
late Animation<Offset> _slideAnimation;
late Animation<double> _fadeAnimation;
@override
void initState() {
super.initState();
_slideController = AnimationController(
duration: const Duration(milliseconds: 500),
vsync: this,
);
_slideAnimation = Tween<Offset>(
begin: const Offset(0, 1), // 아래에서 위로
end: Offset.zero,
).animate(CurvedAnimation(
parent: _slideController,
curve: Curves.easeOutCubic,
));
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _slideController,
curve: Curves.easeOut,
));
// 약간 지연 후 애니메이션 시작 (분해 애니메이션과 동기화)
Future.delayed(const Duration(milliseconds: 800), () {
if (mounted) {
_slideController.forward();
}
});
}
@override
void dispose() {
_slideController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final isVictory = widget.result.isVictory;
final resultColor = isVictory ? Colors.amber : Colors.red.shade400;
final panelColor = isVictory
? RetroColors.goldOf(context).withValues(alpha: 0.15)
: Colors.red.withValues(alpha: 0.1);
final borderColor = isVictory
? RetroColors.goldOf(context)
: Colors.red.shade400;
return SlideTransition(
position: _slideAnimation,
child: FadeTransition(
opacity: _fadeAnimation,
child: Container(
margin: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: RetroColors.panelBgOf(context),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: borderColor, width: 2),
boxShadow: [
BoxShadow(
color: borderColor.withValues(alpha: 0.3),
blurRadius: 8,
spreadRadius: 1,
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 타이틀 배너
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: panelColor,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(6),
),
),
child: _buildTitle(context, isVictory, resultColor),
),
// 내용
Padding(
padding: const EdgeInsets.all(12),
child: Column(
children: [
// 전투 요약 (턴 수)
_buildBattleSummary(context),
const SizedBox(height: 12),
// 장비 교환
_buildExchangeSection(context),
const SizedBox(height: 12),
// Continue 버튼
_buildContinueButton(context, resultColor),
],
),
),
],
),
),
),
);
}
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: 20,
),
const SizedBox(width: 8),
Text(
isVictory ? _victory : _defeat,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 10,
color: color,
),
),
const SizedBox(width: 8),
Icon(
isVictory ? Icons.emoji_events : Icons.sentiment_very_dissatisfied,
color: color,
size: 20,
),
],
);
}
Widget _buildBattleSummary(BuildContext context) {
final winner = widget.result.isVictory
? widget.result.match.challenger.characterName
: widget.result.match.opponent.characterName;
final loser = widget.result.isVictory
? widget.result.match.opponent.characterName
: widget.result.match.challenger.characterName;
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 승자
Text(
winner,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 7,
color: RetroColors.goldOf(context),
),
),
Text(
' defeated ',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 6,
color: RetroColors.textMutedOf(context),
),
),
// 패자
Text(
loser,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 7,
color: RetroColors.textSecondaryOf(context),
),
),
Text(
' in ',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 6,
color: RetroColors.textMutedOf(context),
),
),
// 턴 수
Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
decoration: BoxDecoration(
color: RetroColors.goldOf(context).withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
),
child: Text(
'${widget.turnCount} $_turns',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 6,
color: RetroColors.goldOf(context),
),
),
),
],
);
}
Widget _buildExchangeSection(BuildContext context) {
final isVictory = widget.result.isVictory;
// 승패에 따라 교환 슬롯 결정
// 승리: 도전자가 선택한 슬롯(상대에게서 약탈)
// 패배: 상대가 선택한 슬롯(도전자에게서 약탈당함)
final slot = isVictory
? widget.result.match.challengerBettingSlot
: widget.result.match.opponentBettingSlot;
// 도전자의 교환 결과
final oldItem = _findItem(
widget.result.match.challenger.finalEquipment,
slot,
);
final newItem = _findItem(
widget.result.updatedChallenger.finalEquipment,
slot,
);
final oldScore =
oldItem != null ? ItemService.calculateEquipmentScore(oldItem) : 0;
final newScore =
newItem != null ? ItemService.calculateEquipmentScore(newItem) : 0;
final scoreDiff = newScore - oldScore;
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: isVictory
? Colors.green.withValues(alpha: 0.1)
: Colors.red.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: isVictory
? Colors.green.withValues(alpha: 0.3)
: Colors.red.withValues(alpha: 0.3),
),
),
child: Column(
children: [
// 교환 타이틀
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.swap_horiz,
color: RetroColors.goldOf(context),
size: 14,
),
const SizedBox(width: 4),
Text(
_exchange,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 6,
color: RetroColors.goldOf(context),
),
),
],
),
const SizedBox(height: 8),
// 슬롯
Text(
_getSlotLabel(slot),
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 6,
color: RetroColors.textMutedOf(context),
),
),
const SizedBox(height: 8),
// 교환 내용
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 이전 아이템
_buildItemBadge(context, oldItem, oldScore),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Icon(
Icons.arrow_forward,
size: 14,
color: RetroColors.textMutedOf(context),
),
),
// 새 아이템
_buildItemBadge(context, newItem, newScore),
const SizedBox(width: 8),
// 점수 변화
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: scoreDiff >= 0
? Colors.green.withValues(alpha: 0.2)
: Colors.red.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
scoreDiff >= 0 ? Icons.arrow_upward : Icons.arrow_downward,
size: 10,
color: scoreDiff >= 0 ? Colors.green : Colors.red,
),
Text(
'${scoreDiff >= 0 ? '+' : ''}$scoreDiff',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 6,
color: scoreDiff >= 0 ? Colors.green : Colors.red,
),
),
],
),
),
],
),
],
),
);
}
Widget _buildItemBadge(
BuildContext context,
EquipmentItem? item,
int score,
) {
if (item == null || item.isEmpty) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
decoration: BoxDecoration(
color: Colors.grey.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.grey.withValues(alpha: 0.3)),
),
child: Text(
'(empty)',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 5,
color: RetroColors.textMutedOf(context),
),
),
);
}
final rarityColor = _getRarityColor(item.rarity);
return Container(
constraints: const BoxConstraints(maxWidth: 80),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
decoration: BoxDecoration(
color: rarityColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: rarityColor.withValues(alpha: 0.5)),
),
child: Column(
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),
),
),
],
),
);
}
Widget _buildContinueButton(BuildContext context, Color color) {
return SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: widget.onContinue,
style: FilledButton.styleFrom(
backgroundColor: color,
padding: const EdgeInsets.symmetric(vertical: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
),
),
child: Text(
l10n.buttonConfirm,
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 8,
color: Colors.black,
),
),
),
);
}
EquipmentItem? _findItem(List<EquipmentItem>? 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,
};
}
}