feat(arena): 아레나 화면 구현

- ArenaScreen: 아레나 메인 화면
- ArenaSetupScreen: 전투 설정 화면
- ArenaBattleScreen: 전투 진행 화면
- 관련 위젯 추가
This commit is contained in:
JiWoong Sul
2026-01-06 17:55:02 +09:00
parent 58cf4739fe
commit a2e93efc97
7 changed files with 2507 additions and 0 deletions

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

View File

@@ -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<ArenaScreen> createState() => _ArenaScreenState();
}
class _ArenaScreenState extends State<ArenaScreen> {
final HallOfFameStorage _storage = HallOfFameStorage();
HallOfFame? _hallOfFame;
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadHallOfFame();
}
Future<void> _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<void>(
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),
);
},
),
),
);
}
}

View File

@@ -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<ArenaSetupScreen> createState() => _ArenaSetupScreenState();
}
class _ArenaSetupScreenState extends State<ArenaSetupScreen> {
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<void>(
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<EquipmentItem>? 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),
),
),
],
),
),
],
),
);
}
}

View File

@@ -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<EquipmentItem>? myEquipment;
/// 상대 장비 목록
final List<EquipmentItem>? enemyEquipment;
/// 현재 선택된 슬롯
final EquipmentSlot? selectedSlot;
/// 슬롯 선택 콜백
final ValueChanged<EquipmentSlot> onSlotSelected;
/// 추천 슬롯 (점수 이득이 가장 큰 슬롯)
final EquipmentSlot? recommendedSlot;
@override
State<ArenaEquipmentCompareList> createState() =>
_ArenaEquipmentCompareListState();
}
class _ArenaEquipmentCompareListState extends State<ArenaEquipmentCompareList> {
/// 현재 확장된 슬롯 (탭하여 비교 중인 슬롯)
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 = <Widget>[];
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<EquipmentItem>? 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,
};
}
}

View File

@@ -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<ArenaIdlePreview> createState() => _ArenaIdlePreviewState();
}
class _ArenaIdlePreviewState extends State<ArenaIdlePreview> {
/// 현재 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<AsciiLayer> _composeLayers() {
final layers = <AsciiLayer>[];
// 도전자 캐릭터 (좌측, 정방향)
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<String> _mirrorLines(List<String> 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<List<AsciiCell>> _spriteToCells(List<String> lines) {
return lines.map((line) {
return line.split('').map(AsciiCell.fromChar).toList();
}).toList();
}
}

View File

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

View File

@@ -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<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,
};
}
}
/// 아레나 결과 다이얼로그 표시
Future<void> 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();
},
),
);
}