feat(arena): 결과 패널 및 소멸 애니메이션 위젯 추가
- ArenaResultPanel: 전투 결과 표시 패널 - AsciiDisintegrateWidget: ASCII 소멸 애니메이션 효과 - ArenaBattleScreen 개선
This commit is contained in:
@@ -7,7 +7,9 @@ import 'package:asciineverdie/src/core/model/arena_match.dart';
|
||||
import 'package:asciineverdie/src/core/model/combat_event.dart';
|
||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||
import 'package:asciineverdie/src/core/model/hall_of_fame.dart';
|
||||
import 'package:asciineverdie/src/features/arena/widgets/arena_result_dialog.dart';
|
||||
import 'package:asciineverdie/src/core/animation/race_character_frames.dart';
|
||||
import 'package:asciineverdie/src/features/arena/widgets/arena_result_panel.dart';
|
||||
import 'package:asciineverdie/src/features/arena/widgets/ascii_disintegrate_widget.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/ascii_animation_card.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/combat_log.dart';
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
@@ -88,6 +90,9 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
|
||||
/// 현재 표시 중인 스킬 이름
|
||||
String? _currentSkillName;
|
||||
|
||||
/// 전투 종료 여부 (결과 패널 표시용)
|
||||
bool _isFinished = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -374,20 +379,19 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
|
||||
// 최종 결과 계산
|
||||
_result = _arenaService.executeCombat(widget.match);
|
||||
|
||||
// 결과 다이얼로그 표시
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
if (mounted && _result != null) {
|
||||
showArenaResultDialog(
|
||||
context,
|
||||
result: _result!,
|
||||
onClose: () {
|
||||
widget.onBattleComplete(_result!);
|
||||
},
|
||||
);
|
||||
}
|
||||
// 전투 종료 상태로 전환 (인라인 결과 패널 표시)
|
||||
setState(() {
|
||||
_isFinished = true;
|
||||
});
|
||||
}
|
||||
|
||||
/// Continue 버튼 콜백
|
||||
void _handleContinue() {
|
||||
if (_result != null) {
|
||||
widget.onBattleComplete(_result!);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -413,23 +417,17 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
|
||||
_buildRetroHpBars(),
|
||||
// 전투 이벤트 아이콘 (HP 바와 애니메이션 사이)
|
||||
_buildCombatEventIcons(),
|
||||
// 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),
|
||||
latestCombatEvent: _latestCombatEvent,
|
||||
),
|
||||
),
|
||||
),
|
||||
// ASCII 애니메이션 (전투 중 / 종료 분기)
|
||||
_buildBattleArea(),
|
||||
// 로그 영역 (남은 공간 채움)
|
||||
Expanded(child: _buildBattleLog()),
|
||||
// 결과 패널 (전투 종료 시)
|
||||
if (_isFinished && _result != null)
|
||||
ArenaResultPanel(
|
||||
result: _result!,
|
||||
turnCount: _currentTurn,
|
||||
onContinue: _handleContinue,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -443,6 +441,141 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
|
||||
return equipment.any((item) => item.slot.name == 'shield');
|
||||
}
|
||||
|
||||
/// 전투 영역 (전투 중 / 종료 분기)
|
||||
Widget _buildBattleArea() {
|
||||
if (_isFinished && _result != null) {
|
||||
return _buildFinishedBattleArea();
|
||||
}
|
||||
return _buildActiveBattleArea();
|
||||
}
|
||||
|
||||
/// 활성 전투 영역 (기존 AsciiAnimationCard)
|
||||
Widget _buildActiveBattleArea() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: SizedBox(
|
||||
height: 120,
|
||||
child: AsciiAnimationCard(
|
||||
taskType: TaskType.kill,
|
||||
raceId: widget.match.challenger.race,
|
||||
shieldName: _hasShield(widget.match.challenger) ? 'shield' : null,
|
||||
opponentRaceId: widget.match.opponent.race,
|
||||
opponentHasShield: _hasShield(widget.match.opponent),
|
||||
latestCombatEvent: _latestCombatEvent,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 종료된 전투 영역 (승자 유지 + 패자 분해)
|
||||
Widget _buildFinishedBattleArea() {
|
||||
final isVictory = _result!.isVictory;
|
||||
final winnerRaceId =
|
||||
isVictory ? widget.match.challenger.race : widget.match.opponent.race;
|
||||
final loserRaceId =
|
||||
isVictory ? widget.match.opponent.race : widget.match.challenger.race;
|
||||
|
||||
// 패자 캐릭터 프레임 (idle 첫 프레임)
|
||||
final loserFrameData = RaceCharacterFrames.get(loserRaceId) ??
|
||||
RaceCharacterFrames.defaultFrames;
|
||||
final loserLines = loserFrameData.idle.first.lines;
|
||||
|
||||
// 승자 캐릭터 프레임 (idle 첫 프레임)
|
||||
final winnerFrameData = RaceCharacterFrames.get(winnerRaceId) ??
|
||||
RaceCharacterFrames.defaultFrames;
|
||||
final winnerLines = winnerFrameData.idle.first.lines;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: SizedBox(
|
||||
height: 120,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
// 좌측: 도전자 (승자면 유지, 패자면 분해)
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: isVictory
|
||||
? _buildStaticCharacter(winnerLines, false)
|
||||
: AsciiDisintegrateWidget(
|
||||
characterLines: _mirrorLines(loserLines),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 중앙 VS
|
||||
Text(
|
||||
'VS',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 10,
|
||||
color: RetroColors.goldOf(context).withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
// 우측: 상대 (승자면 유지, 패자면 분해)
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: isVictory
|
||||
? AsciiDisintegrateWidget(characterLines: loserLines)
|
||||
: _buildStaticCharacter(
|
||||
_mirrorLines(winnerLines),
|
||||
false,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 정적 ASCII 캐릭터 표시
|
||||
Widget _buildStaticCharacter(List<String> lines, bool mirrored) {
|
||||
final textColor = RetroColors.textPrimaryOf(context);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: lines
|
||||
.map((line) => Text(
|
||||
line,
|
||||
style: TextStyle(
|
||||
fontFamily: 'JetBrainsMono',
|
||||
fontSize: 10,
|
||||
color: textColor,
|
||||
height: 1.2,
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// ASCII 문자열 미러링 (좌우 대칭)
|
||||
List<String> _mirrorLines(List<String> lines) {
|
||||
return lines.map((line) {
|
||||
final chars = line.split('');
|
||||
return chars.reversed.map(_mirrorChar).join();
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// 개별 문자 미러링
|
||||
String _mirrorChar(String char) {
|
||||
return switch (char) {
|
||||
'/' => r'\',
|
||||
r'\' => '/',
|
||||
'(' => ')',
|
||||
')' => '(',
|
||||
'[' => ']',
|
||||
']' => '[',
|
||||
'{' => '}',
|
||||
'}' => '{',
|
||||
'<' => '>',
|
||||
'>' => '<',
|
||||
'd' => 'b',
|
||||
'b' => 'd',
|
||||
'q' => 'p',
|
||||
'p' => 'q',
|
||||
_ => char,
|
||||
};
|
||||
}
|
||||
|
||||
Widget _buildTurnIndicator() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
|
||||
479
lib/src/features/arena/widgets/arena_result_panel.dart
Normal file
479
lib/src/features/arena/widgets/arena_result_panel.dart
Normal file
@@ -0,0 +1,479 @@
|
||||
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 slot = widget.result.match.bettingSlot;
|
||||
final isVictory = widget.result.isVictory;
|
||||
|
||||
// 도전자의 교환 결과
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
217
lib/src/features/arena/widgets/ascii_disintegrate_widget.dart
Normal file
217
lib/src/features/arena/widgets/ascii_disintegrate_widget.dart
Normal file
@@ -0,0 +1,217 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// ASCII 문자 분해 파티클
|
||||
class AsciiParticle {
|
||||
AsciiParticle({
|
||||
required this.char,
|
||||
required this.initialX,
|
||||
required this.initialY,
|
||||
required this.vx,
|
||||
required this.vy,
|
||||
required this.delay,
|
||||
}) : x = initialX,
|
||||
y = initialY,
|
||||
opacity = 1.0;
|
||||
|
||||
final String char;
|
||||
final double initialX;
|
||||
final double initialY;
|
||||
final double vx; // X 속도
|
||||
final double vy; // Y 속도
|
||||
final double delay; // 분해 시작 지연 (0.0 ~ 0.3)
|
||||
|
||||
double x;
|
||||
double y;
|
||||
double opacity;
|
||||
|
||||
/// 진행도에 따라 파티클 상태 업데이트
|
||||
void update(double progress) {
|
||||
// 지연 적용
|
||||
final adjustedProgress = ((progress - delay) / (1.0 - delay)).clamp(0.0, 1.0);
|
||||
|
||||
if (adjustedProgress <= 0) {
|
||||
// 아직 분해 시작 전
|
||||
x = initialX;
|
||||
y = initialY;
|
||||
opacity = 1.0;
|
||||
return;
|
||||
}
|
||||
|
||||
// 이징 적용 (가속)
|
||||
final easedProgress = Curves.easeOutQuad.transform(adjustedProgress);
|
||||
|
||||
// 위치 업데이트 (초기 위치에서 이동)
|
||||
x = initialX + vx * easedProgress * 3.0;
|
||||
y = initialY + vy * easedProgress * 3.0;
|
||||
|
||||
// 중력 효과
|
||||
y += easedProgress * easedProgress * 2.0;
|
||||
|
||||
// 페이드 아웃 (후반부에 급격히)
|
||||
opacity = (1.0 - easedProgress * easedProgress).clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// ASCII 캐릭터 분해 애니메이션 위젯
|
||||
///
|
||||
/// 캐릭터의 각 ASCII 문자가 파티클로 분해되어 흩어지는 효과
|
||||
class AsciiDisintegrateWidget extends StatefulWidget {
|
||||
const AsciiDisintegrateWidget({
|
||||
super.key,
|
||||
required this.characterLines,
|
||||
this.charWidth = 8.0,
|
||||
this.charHeight = 12.0,
|
||||
this.duration = const Duration(milliseconds: 1500),
|
||||
this.textColor,
|
||||
this.onComplete,
|
||||
});
|
||||
|
||||
/// ASCII 캐릭터 문자열 (줄 단위)
|
||||
final List<String> characterLines;
|
||||
|
||||
/// 문자 너비 (픽셀)
|
||||
final double charWidth;
|
||||
|
||||
/// 문자 높이 (픽셀)
|
||||
final double charHeight;
|
||||
|
||||
/// 애니메이션 지속 시간
|
||||
final Duration duration;
|
||||
|
||||
/// 텍스트 색상 (null이면 테마 색상)
|
||||
final Color? textColor;
|
||||
|
||||
/// 완료 콜백
|
||||
final VoidCallback? onComplete;
|
||||
|
||||
@override
|
||||
State<AsciiDisintegrateWidget> createState() =>
|
||||
_AsciiDisintegrateWidgetState();
|
||||
}
|
||||
|
||||
class _AsciiDisintegrateWidgetState extends State<AsciiDisintegrateWidget>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late List<AsciiParticle> _particles;
|
||||
final Random _random = Random();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initParticles();
|
||||
_controller = AnimationController(
|
||||
duration: widget.duration,
|
||||
vsync: this,
|
||||
)
|
||||
..addListener(() => setState(() {}))
|
||||
..addStatusListener((status) {
|
||||
if (status == AnimationStatus.completed) {
|
||||
widget.onComplete?.call();
|
||||
}
|
||||
})
|
||||
..forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _initParticles() {
|
||||
_particles = [];
|
||||
|
||||
for (int y = 0; y < widget.characterLines.length; y++) {
|
||||
final line = widget.characterLines[y];
|
||||
for (int x = 0; x < line.length; x++) {
|
||||
final char = line[x];
|
||||
// 공백은 파티클로 변환하지 않음
|
||||
if (char != ' ') {
|
||||
_particles.add(AsciiParticle(
|
||||
char: char,
|
||||
initialX: x.toDouble(),
|
||||
initialY: y.toDouble(),
|
||||
// 랜덤 속도 (위쪽 + 좌우로 퍼짐)
|
||||
vx: (_random.nextDouble() - 0.5) * 4.0,
|
||||
vy: -_random.nextDouble() * 2.0 - 0.5, // 위쪽으로
|
||||
// 랜덤 지연 (안쪽에서 바깥쪽으로 분해)
|
||||
delay: _random.nextDouble() * 0.3,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 파티클 상태 업데이트
|
||||
for (final particle in _particles) {
|
||||
particle.update(_controller.value);
|
||||
}
|
||||
|
||||
final textColor =
|
||||
widget.textColor ?? Theme.of(context).textTheme.bodyMedium?.color;
|
||||
|
||||
return CustomPaint(
|
||||
size: Size(
|
||||
widget.characterLines.isNotEmpty
|
||||
? widget.characterLines
|
||||
.map((l) => l.length)
|
||||
.reduce((a, b) => a > b ? a : b) *
|
||||
widget.charWidth
|
||||
: 0,
|
||||
widget.characterLines.length * widget.charHeight,
|
||||
),
|
||||
painter: _DisintegratePainter(
|
||||
particles: _particles,
|
||||
charWidth: widget.charWidth,
|
||||
charHeight: widget.charHeight,
|
||||
textColor: textColor ?? Colors.white,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 분해 파티클 페인터
|
||||
class _DisintegratePainter extends CustomPainter {
|
||||
_DisintegratePainter({
|
||||
required this.particles,
|
||||
required this.charWidth,
|
||||
required this.charHeight,
|
||||
required this.textColor,
|
||||
});
|
||||
|
||||
final List<AsciiParticle> particles;
|
||||
final double charWidth;
|
||||
final double charHeight;
|
||||
final Color textColor;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
for (final particle in particles) {
|
||||
if (particle.opacity <= 0) continue;
|
||||
|
||||
final textPainter = TextPainter(
|
||||
text: TextSpan(
|
||||
text: particle.char,
|
||||
style: TextStyle(
|
||||
fontFamily: 'JetBrainsMono',
|
||||
fontSize: charHeight * 0.9,
|
||||
color: textColor.withValues(alpha: particle.opacity),
|
||||
),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
|
||||
final x = particle.x * charWidth;
|
||||
final y = particle.y * charHeight;
|
||||
|
||||
textPainter.paint(canvas, Offset(x, y));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _DisintegratePainter oldDelegate) => true;
|
||||
}
|
||||
Reference in New Issue
Block a user