Files
asciinevrdie/lib/src/features/front/widgets/hero_vs_boss_animation.dart
JiWoong Sul ee7dcd270e fix(animation): WASM 모드 안정성 개선
- SchedulerBinding으로 프레임 빌드 중 setState 방지
- persistentCallbacks 단계에서 addPostFrameCallback으로 지연 처리
2025-12-26 16:11:57 +09:00

267 lines
7.2 KiB
Dart

import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:askiineverdie/data/race_data.dart';
import 'package:askiineverdie/src/core/animation/front_screen_animation.dart';
/// 프론트 화면용 Hero vs Glitch God ASCII 애니메이션 위젯
///
/// 작은 용사가 거대한 Glitch God에 맞서는 루프 애니메이션
/// RichText로 문자별 색상 적용, 랜덤 종족 변경 지원
class HeroVsBossAnimation extends StatefulWidget {
const HeroVsBossAnimation({super.key});
@override
State<HeroVsBossAnimation> createState() => _HeroVsBossAnimationState();
}
class _HeroVsBossAnimationState extends State<HeroVsBossAnimation> {
int _currentFrame = 0;
Timer? _animationTimer;
Timer? _raceChangeTimer;
final Random _random = Random();
// 현재 종족 (랜덤 변경)
String _currentRaceId = 'byte_human';
int _raceIndex = 0;
@override
void initState() {
super.initState();
_startAnimation();
_startRaceChangeTimer();
}
@override
void dispose() {
_animationTimer?.cancel();
_raceChangeTimer?.cancel();
super.dispose();
}
void _startAnimation() {
_animationTimer = Timer.periodic(
const Duration(milliseconds: frontScreenAnimationIntervalMs),
(_) {
if (!mounted) return;
// 프레임 빌드 중이면 다음 프레임까지 대기
if (SchedulerBinding.instance.schedulerPhase ==
SchedulerPhase.persistentCallbacks) {
SchedulerBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() {
_currentFrame =
(_currentFrame + 1) % frontScreenAnimationFrameCount;
});
}
});
} else {
setState(() {
_currentFrame =
(_currentFrame + 1) % frontScreenAnimationFrameCount;
});
}
},
);
}
/// 8초마다 랜덤 종족 변경
void _startRaceChangeTimer() {
_raceChangeTimer = Timer.periodic(
const Duration(seconds: 8),
(_) => _changeRace(),
);
}
void _changeRace() {
if (!mounted) return;
final allRaces = RaceData.all;
if (allRaces.isEmpty) return;
// 프레임 빌드 중이면 다음 프레임까지 대기
if (SchedulerBinding.instance.schedulerPhase ==
SchedulerPhase.persistentCallbacks) {
SchedulerBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() {
_raceIndex = (_raceIndex + 1) % allRaces.length;
_currentRaceId = allRaces[_raceIndex].raceId;
});
}
});
} else {
setState(() {
_raceIndex = (_raceIndex + 1) % allRaces.length;
_currentRaceId = allRaces[_raceIndex].raceId;
});
}
}
/// 글리치 효과: 랜덤 문자 대체
String _applyGlitchEffect(String frame) {
// 10% 확률로 글리치 효과 적용
if (_random.nextDouble() > 0.1) return frame;
const glitchChars = '@#\$%&*!?~';
final chars = frame.split('');
final glitchCount = _random.nextInt(5) + 1;
for (var i = 0; i < glitchCount; i++) {
final pos = _random.nextInt(chars.length);
if (chars[pos] != ' ' && chars[pos] != '\n') {
chars[pos] = glitchChars[_random.nextInt(glitchChars.length)];
}
}
return chars.join();
}
/// 문자별 색상 결정
Color _getCharColor(String char) {
// 공격/이펙트 (시안)
if ('><=!+'.contains(char)) return Colors.cyan;
// 데미지/글리치 (마젠타)
if ('*~@#\$%&'.contains(char)) return const Color(0xFFFF00FF);
// 보스 눈 (빨강)
if ('◈◉X'.contains(char)) return Colors.red;
// 보스 타이틀 텍스트 (시안)
if ('GLITCH'.contains(char) || 'GOD'.contains(char)) return Colors.cyan;
// 기본 (흰색)
return Colors.white;
}
/// 문자열을 색상별 TextSpan으로 변환
TextSpan _buildColoredTextSpan(String text) {
final spans = <TextSpan>[];
for (final char in text.split('')) {
final color = _getCharColor(char);
spans.add(
TextSpan(
text: char,
style: TextStyle(
fontFamily: 'JetBrainsMono',
fontSize: 10,
height: 1.1,
color: color,
letterSpacing: 0,
),
),
);
}
return TextSpan(children: spans);
}
@override
Widget build(BuildContext context) {
final frame = _applyGlitchEffect(
frontScreenAnimationFrames[_currentFrame],
);
// 현재 종족 이름 (UI 표시용)
final raceName = RaceData.findById(_currentRaceId)?.name ?? 'Hero';
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
decoration: BoxDecoration(
// 항상 검은 배경
color: Colors.black,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.white24,
width: 1,
),
// 은은한 글로우 효과
boxShadow: [
BoxShadow(
color: Colors.cyan.withValues(alpha: 0.15),
blurRadius: 20,
spreadRadius: 2,
),
],
),
child: Column(
children: [
// ASCII 애니메이션 (컬러 적용)
FittedBox(
fit: BoxFit.scaleDown,
child: RichText(
text: _buildColoredTextSpan(frame),
),
),
const SizedBox(height: 8),
// 하단 효과 바 (컬러)
_buildEffectBar(),
const SizedBox(height: 4),
// 현재 종족 표시
Text(
'$raceName',
style: TextStyle(
fontFamily: 'JetBrainsMono',
fontSize: 9,
color: Colors.cyan.withValues(alpha: 0.7),
),
),
],
),
);
}
/// 하단 효과 바: 글리치/전투 효과 시각화
Widget _buildEffectBar() {
// 프레임에 따라 색상 변화
final colors = [
Colors.cyan,
Colors.purple,
Colors.red,
Colors.cyan,
Colors.yellow,
Colors.purple,
];
final currentColor = colors[_currentFrame % colors.length];
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 왼쪽 검 아이콘 (용사)
Icon(
Icons.flash_on,
size: 14,
color: Colors.yellow.withValues(alpha: 0.8),
),
const SizedBox(width: 8),
// 중앙 효과 바
Expanded(
child: Container(
height: 3,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.transparent,
currentColor.withValues(alpha: 0.7),
Colors.white.withValues(alpha: 0.9),
currentColor.withValues(alpha: 0.7),
Colors.transparent,
],
),
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(width: 8),
// 오른쪽 보스 아이콘
Icon(
Icons.whatshot,
size: 14,
color: Colors.red.withValues(alpha: 0.8),
),
],
);
}
}