feat(ui): 화면 및 공통 위젯 개선

- FrontScreen 개선
- GamePlayScreen, GameSessionController 업데이트
- ArenaBattleScreen, NewCharacterScreen 정리
- AsciiDisintegrateWidget 추가
This commit is contained in:
JiWoong Sul
2026-01-14 00:18:16 +09:00
parent f65bab6312
commit 1da377c127
7 changed files with 302 additions and 30 deletions

View File

@@ -0,0 +1,219 @@
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;
}