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