feat(animation): 종족별 캐릭터 애니메이션 시스템 추가

- 21개 종족별 고유 ASCII 캐릭터 프레임 데이터 추가
  - 각 종족당 5가지 상태 애니메이션: idle, prepare, attack, hit, recover
  - 종족 특성에 맞는 시각적 차별화 (마법사 ~, 기사 ♦, 언데드 ☠ 등)
- 캐릭터 생성 화면 종족 미리보기 위젯 추가
- 프론트 화면 Hero vs Boss 애니메이션 개선
- 게임 플레이 화면 애니메이션 패널 연동 강화
This commit is contained in:
JiWoong Sul
2025-12-23 20:00:41 +09:00
parent 549851f693
commit 7219f58853
11 changed files with 1186 additions and 64 deletions

View File

@@ -3,12 +3,13 @@ import 'dart:math';
import 'package:flutter/material.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});
@@ -18,23 +19,30 @@ class HeroVsBossAnimation extends StatefulWidget {
class _HeroVsBossAnimationState extends State<HeroVsBossAnimation> {
int _currentFrame = 0;
Timer? _timer;
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() {
_timer?.cancel();
_animationTimer?.cancel();
_raceChangeTimer?.cancel();
super.dispose();
}
void _startAnimation() {
_timer = Timer.periodic(
_animationTimer = Timer.periodic(
const Duration(milliseconds: frontScreenAnimationIntervalMs),
(_) {
if (mounted) {
@@ -47,6 +55,26 @@ class _HeroVsBossAnimationState extends State<HeroVsBossAnimation> {
);
}
/// 8초마다 랜덤 종족 변경
void _startRaceChangeTimer() {
_raceChangeTimer = Timer.periodic(
const Duration(seconds: 8),
(_) => _changeRace(),
);
}
void _changeRace() {
if (!mounted) return;
final allRaces = RaceData.all;
if (allRaces.isEmpty) return;
setState(() {
_raceIndex = (_raceIndex + 1) % allRaces.length;
_currentRaceId = allRaces[_raceIndex].raceId;
});
}
/// 글리치 효과: 랜덤 문자 대체
String _applyGlitchEffect(String frame) {
// 10% 확률로 글리치 효과 적용
@@ -66,12 +94,52 @@ class _HeroVsBossAnimationState extends State<HeroVsBossAnimation> {
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(
@@ -93,23 +161,26 @@ class _HeroVsBossAnimationState extends State<HeroVsBossAnimation> {
),
child: Column(
children: [
// ASCII 애니메이션 (흰색 텍스트)
// ASCII 애니메이션 (컬러 적용)
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
frame,
style: const TextStyle(
fontFamily: 'JetBrainsMono',
fontSize: 10,
height: 1.1,
color: Colors.white,
letterSpacing: 0,
),
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),
),
),
],
),
);

View File

@@ -485,10 +485,24 @@ class _GamePlayScreenState extends State<GamePlayScreen>
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
),
onTap: () {
onTap: () async {
Navigator.pop(context); // 다이얼로그 닫기
// 안전한 언어 변경: 전체 화면 재생성
final navigator = Navigator.of(this.context);
await widget.controller.pause(saveOnStop: true);
game_l10n.setGameLocale(locale);
setState(() {});
if (mounted) {
await widget.controller.resume();
navigator.pushReplacement(
MaterialPageRoute<void>(
builder: (_) => GamePlayScreen(
controller: widget.controller,
currentThemeMode: widget.currentThemeMode,
onThemeModeChange: widget.onThemeModeChange,
),
),
);
}
},
);
}
@@ -573,6 +587,8 @@ class _GamePlayScreenState extends State<GamePlayScreen>
notificationService: _notificationService,
specialAnimation: _specialAnimation,
onLanguageChange: (locale) async {
// navigator 참조를 async gap 전에 저장
final navigator = Navigator.of(context);
// 1. 현재 상태 저장
await widget.controller.pause(saveOnStop: true);
// 2. 로케일 변경
@@ -580,7 +596,7 @@ class _GamePlayScreenState extends State<GamePlayScreen>
// 3. 화면 재생성 (전체 UI 재구성)
if (context.mounted) {
await widget.controller.resume();
Navigator.of(context).pushReplacement(
navigator.pushReplacement(
MaterialPageRoute<void>(
builder: (_) => GamePlayScreen(
controller: widget.controller,
@@ -689,6 +705,7 @@ class _GamePlayScreenState extends State<GamePlayScreen>
monsterLevel: state.progress.currentTask.monsterLevel,
latestCombatEvent:
state.progress.currentCombat?.recentEvents.lastOrNull,
raceId: state.traits.raceId,
),
// 메인 3패널 영역

View File

@@ -405,6 +405,7 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
monsterLevel: state.progress.currentTask.monsterLevel,
latestCombatEvent:
state.progress.currentCombat?.recentEvents.lastOrNull,
raceId: state.traits.raceId,
),
// 중앙: 캐로셀 (PageView)

View File

@@ -45,6 +45,7 @@ class AsciiAnimationCard extends StatefulWidget {
this.monsterLevel,
this.isPaused = false,
this.latestCombatEvent,
this.raceId,
});
final TaskType taskType;
@@ -75,6 +76,9 @@ class AsciiAnimationCard extends StatefulWidget {
/// 최근 전투 이벤트 (애니메이션 동기화용)
final CombatEvent? latestCombatEvent;
/// 종족 ID (Phase 4: 종족별 캐릭터 애니메이션)
final String? raceId;
@override
State<AsciiAnimationCard> createState() => _AsciiAnimationCardState();
}
@@ -168,7 +172,8 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
oldWidget.monsterBaseName != widget.monsterBaseName ||
oldWidget.weaponName != widget.weaponName ||
oldWidget.shieldName != widget.shieldName ||
oldWidget.monsterLevel != widget.monsterLevel) {
oldWidget.monsterLevel != widget.monsterLevel ||
oldWidget.raceId != widget.raceId) {
_updateAnimation();
}
}
@@ -391,6 +396,7 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
hasShield: hasShield,
monsterCategory: monsterCategory,
monsterSize: monsterSize,
raceId: widget.raceId,
);
// 환경 타입 추론

View File

@@ -30,6 +30,7 @@ class EnhancedAnimationPanel extends StatefulWidget {
this.characterLevel,
this.monsterLevel,
this.latestCombatEvent,
this.raceId,
});
final ProgressState progress;
@@ -46,6 +47,9 @@ class EnhancedAnimationPanel extends StatefulWidget {
final int? monsterLevel;
final CombatEvent? latestCombatEvent;
/// 종족 ID (Phase 4: 종족별 캐릭터 애니메이션)
final String? raceId;
@override
State<EnhancedAnimationPanel> createState() => _EnhancedAnimationPanelState();
}
@@ -183,6 +187,7 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
monsterLevel: widget.monsterLevel,
isPaused: widget.isPaused,
latestCombatEvent: widget.latestCombatEvent,
raceId: widget.raceId,
),
),

View File

@@ -23,6 +23,7 @@ class TaskProgressPanel extends StatelessWidget {
this.characterLevel,
this.monsterLevel,
this.latestCombatEvent,
this.raceId,
});
final ProgressState progress;
@@ -45,6 +46,9 @@ class TaskProgressPanel extends StatelessWidget {
/// 최근 전투 이벤트 (애니메이션 동기화용, Phase 5)
final CombatEvent? latestCombatEvent;
/// 종족 ID (Phase 4: 종족별 캐릭터 애니메이션)
final String? raceId;
@override
Widget build(BuildContext context) {
return Container(
@@ -71,6 +75,7 @@ class TaskProgressPanel extends StatelessWidget {
monsterLevel: monsterLevel,
isPaused: isPaused,
latestCombatEvent: latestCombatEvent,
raceId: raceId,
),
),
const SizedBox(height: 8),

View File

@@ -12,6 +12,7 @@ import 'package:askiineverdie/src/core/model/race_traits.dart';
import 'package:askiineverdie/src/core/util/deterministic_random.dart';
import 'package:askiineverdie/src/core/l10n/game_data_l10n.dart';
import 'package:askiineverdie/src/core/util/pq_logic.dart';
import 'package:askiineverdie/src/features/new_character/widgets/race_preview.dart';
/// 캐릭터 생성 화면 (NewGuy.pas 포팅)
class NewCharacterScreen extends StatefulWidget {
@@ -264,6 +265,14 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
_buildStatsSection(),
const SizedBox(height: 16),
// 종족 미리보기 (Phase 5: 종족별 캐릭터 애니메이션)
Center(
child: RacePreview(
raceId: _races[_selectedRaceIndex].raceId,
),
),
const SizedBox(height: 16),
// 종족/직업 선택 섹션
Row(
crossAxisAlignment: CrossAxisAlignment.start,

View File

@@ -0,0 +1,129 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:askiineverdie/src/core/animation/character_frames.dart';
import 'package:askiineverdie/src/core/animation/race_character_frames.dart';
/// 종족 미리보기 위젯
///
/// 새 캐릭터 생성 화면에서 선택한 종족의 idle 애니메이션을 보여줌.
/// RichText 기반 색상 적용.
class RacePreview extends StatefulWidget {
const RacePreview({
super.key,
required this.raceId,
});
/// 종족 ID (예: "byte_human", "kernel_giant")
final String raceId;
@override
State<RacePreview> createState() => _RacePreviewState();
}
class _RacePreviewState extends State<RacePreview> {
Timer? _timer;
int _currentFrame = 0;
@override
void initState() {
super.initState();
_startAnimation();
}
@override
void didUpdateWidget(RacePreview oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.raceId != widget.raceId) {
_currentFrame = 0;
}
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
void _startAnimation() {
_timer = Timer.periodic(const Duration(milliseconds: 400), (_) {
if (mounted) {
setState(() {
_currentFrame++;
});
}
});
}
@override
Widget build(BuildContext context) {
final raceData = RaceCharacterFrames.get(widget.raceId);
final frames = raceData?.idle ?? RaceCharacterFrames.defaultFrames.idle;
final frame = frames[_currentFrame % frames.length];
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 캐릭터 프레임
_buildColoredFrame(frame),
],
),
);
}
/// RichText 기반 색상 적용 프레임
Widget _buildColoredFrame(CharacterFrame frame) {
return Column(
mainAxisSize: MainAxisSize.min,
children: frame.lines.map((line) => _buildColoredLine(line)).toList(),
);
}
/// 한 줄을 색상 적용하여 렌더링
Widget _buildColoredLine(String line) {
final spans = <TextSpan>[];
for (var i = 0; i < line.length; i++) {
final char = line[i];
spans.add(
TextSpan(
text: char,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 18,
height: 1.2,
color: _getCharColor(char),
),
),
);
}
return RichText(
text: TextSpan(children: spans),
);
}
/// 문자별 색상 매핑
Color _getCharColor(String char) {
// 공격/이펙트 (시안)
if ('><=!+~'.contains(char)) return Colors.cyan;
// 데미지/글리치 (마젠타)
if ('*@#\$%&'.contains(char)) return const Color(0xFFFF00FF);
// 특수 문자 (노랑)
if ('☠◈◉'.contains(char)) return Colors.yellow;
// 대형 문자 (밝은 녹색)
if ('█▓░'.contains(char)) return Colors.lightGreen;
// 기본 (흰색)
return Colors.white;
}
}