diff --git a/lib/src/features/arena/arena_battle_screen.dart b/lib/src/features/arena/arena_battle_screen.dart index 2e705ed..b09ae39 100644 --- a/lib/src/features/arena/arena_battle_screen.dart +++ b/lib/src/features/arena/arena_battle_screen.dart @@ -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 /// 현재 표시 중인 스킬 이름 String? _currentSkillName; + /// 전투 종료 여부 (결과 패널 표시용) + bool _isFinished = false; + @override void initState() { super.initState(); @@ -374,20 +379,19 @@ class _ArenaBattleScreenState extends State // 최종 결과 계산 _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 _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 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 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 _mirrorLines(List 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), diff --git a/lib/src/features/arena/widgets/arena_result_panel.dart b/lib/src/features/arena/widgets/arena_result_panel.dart new file mode 100644 index 0000000..b3ea5d2 --- /dev/null +++ b/lib/src/features/arena/widgets/arena_result_panel.dart @@ -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 createState() => _ArenaResultPanelState(); +} + +class _ArenaResultPanelState extends State + with SingleTickerProviderStateMixin { + late AnimationController _slideController; + late Animation _slideAnimation; + late Animation _fadeAnimation; + + @override + void initState() { + super.initState(); + _slideController = AnimationController( + duration: const Duration(milliseconds: 500), + vsync: this, + ); + + _slideAnimation = Tween( + begin: const Offset(0, 1), // 아래에서 위로 + end: Offset.zero, + ).animate(CurvedAnimation( + parent: _slideController, + curve: Curves.easeOutCubic, + )); + + _fadeAnimation = Tween( + 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? 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, + }; + } +} diff --git a/lib/src/features/arena/widgets/ascii_disintegrate_widget.dart b/lib/src/features/arena/widgets/ascii_disintegrate_widget.dart new file mode 100644 index 0000000..db113aa --- /dev/null +++ b/lib/src/features/arena/widgets/ascii_disintegrate_widget.dart @@ -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 characterLines; + + /// 문자 너비 (픽셀) + final double charWidth; + + /// 문자 높이 (픽셀) + final double charHeight; + + /// 애니메이션 지속 시간 + final Duration duration; + + /// 텍스트 색상 (null이면 테마 색상) + final Color? textColor; + + /// 완료 콜백 + final VoidCallback? onComplete; + + @override + State createState() => + _AsciiDisintegrateWidgetState(); +} + +class _AsciiDisintegrateWidgetState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late List _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 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; +}