- SchedulerBinding으로 프레임 빌드 중 setState 방지 - persistentCallbacks 단계에서 addPostFrameCallback으로 지연 처리
267 lines
7.2 KiB
Dart
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),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|