feat(animation): ASCII 애니메이션 시스템 구현

- TaskType별 애니메이션 (전투, 마을, 걷기)
- 몬스터 카테고리별 전투 애니메이션 (7종)
- 특수 애니메이션 (레벨업, 퀘스트 완료, Act 완료)
- 색상 테마 옵션 (green, amber, white, system)
- 테마 설정 SharedPreferences 저장
- 프로그레스 바를 상단으로 이동
This commit is contained in:
JiWoong Sul
2025-12-11 16:49:02 +09:00
parent b450bf2600
commit 2b10deba5d
6 changed files with 1306 additions and 68 deletions

View File

@@ -1,8 +1,12 @@
import 'package:flutter/material.dart';
import 'package:askiineverdie/src/core/animation/ascii_animation_data.dart';
import 'package:askiineverdie/src/core/animation/ascii_animation_type.dart';
import 'package:askiineverdie/src/core/model/game_state.dart';
import 'package:askiineverdie/src/core/storage/theme_preferences.dart';
import 'package:askiineverdie/src/core/util/pq_logic.dart' as pq_logic;
import 'package:askiineverdie/src/features/game/game_session_controller.dart';
import 'package:askiineverdie/src/features/game/widgets/task_progress_panel.dart';
/// 게임 진행 화면 (Main.dfm 기반 3패널 레이아웃)
class GamePlayScreen extends StatefulWidget {
@@ -16,11 +20,85 @@ class GamePlayScreen extends StatefulWidget {
class _GamePlayScreenState extends State<GamePlayScreen>
with WidgetsBindingObserver {
AsciiColorTheme _colorTheme = AsciiColorTheme.green;
AsciiAnimationType? _specialAnimation;
// 이전 상태 추적 (레벨업/퀘스트/Act 완료 감지용)
int _lastLevel = 0;
int _lastQuestCount = 0;
int _lastPlotStageCount = 0;
void _cycleColorTheme() {
setState(() {
_colorTheme = switch (_colorTheme) {
AsciiColorTheme.green => AsciiColorTheme.amber,
AsciiColorTheme.amber => AsciiColorTheme.white,
AsciiColorTheme.white => AsciiColorTheme.system,
AsciiColorTheme.system => AsciiColorTheme.green,
};
});
// 테마 변경 시 저장
ThemePreferences.saveColorTheme(_colorTheme);
}
Future<void> _loadColorTheme() async {
final theme = await ThemePreferences.loadColorTheme();
if (mounted) {
setState(() {
_colorTheme = theme;
});
}
}
void _checkSpecialEvents(GameState state) {
// 레벨업 감지
if (state.traits.level > _lastLevel && _lastLevel > 0) {
_specialAnimation = AsciiAnimationType.levelUp;
_resetSpecialAnimationAfterFrame();
}
_lastLevel = state.traits.level;
// 퀘스트 완료 감지
if (state.progress.questCount > _lastQuestCount && _lastQuestCount > 0) {
_specialAnimation = AsciiAnimationType.questComplete;
_resetSpecialAnimationAfterFrame();
}
_lastQuestCount = state.progress.questCount;
// Act 완료 감지 (plotStageCount 증가)
if (state.progress.plotStageCount > _lastPlotStageCount &&
_lastPlotStageCount > 0) {
_specialAnimation = AsciiAnimationType.actComplete;
_resetSpecialAnimationAfterFrame();
}
_lastPlotStageCount = state.progress.plotStageCount;
}
void _resetSpecialAnimationAfterFrame() {
// 다음 프레임에서 리셋 (AsciiAnimationCard가 값을 받은 후)
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() {
_specialAnimation = null;
});
}
});
}
@override
void initState() {
super.initState();
widget.controller.addListener(_onControllerChanged);
WidgetsBinding.instance.addObserver(this);
_loadColorTheme();
// 초기 상태 설정
final state = widget.controller.state;
if (state != null) {
_lastLevel = state.traits.level;
_lastQuestCount = state.progress.questCount;
_lastPlotStageCount = state.progress.plotStageCount;
}
}
@override
@@ -83,6 +161,10 @@ class _GamePlayScreenState extends State<GamePlayScreen>
}
void _onControllerChanged() {
final state = widget.controller.state;
if (state != null) {
_checkSpecialEvents(state);
}
setState(() {});
}
@@ -131,6 +213,19 @@ class _GamePlayScreenState extends State<GamePlayScreen>
),
body: Column(
children: [
// 상단: ASCII 애니메이션 + Task Progress
TaskProgressPanel(
progress: state.progress,
speedMultiplier: widget.controller.loop?.speedMultiplier ?? 1,
onSpeedCycle: () {
widget.controller.loop?.cycleSpeed();
setState(() {});
},
colorTheme: _colorTheme,
onThemeCycle: _cycleColorTheme,
specialAnimation: _specialAnimation,
),
// 메인 3패널 영역
Expanded(
child: Row(
@@ -147,9 +242,6 @@ class _GamePlayScreenState extends State<GamePlayScreen>
],
),
),
// 하단: Task Progress
_buildBottomPanel(state),
],
),
),
@@ -261,71 +353,6 @@ class _GamePlayScreenState extends State<GamePlayScreen>
);
}
/// 하단 패널: Task Progress + Status
Widget _buildBottomPanel(GameState state) {
final speed = widget.controller.loop?.speedMultiplier ?? 1;
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
border: Border(top: BorderSide(color: Theme.of(context).dividerColor)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 상태 메시지 + 배속 버튼
Row(
children: [
Expanded(
child: Text(
state.progress.currentTask.caption.isNotEmpty
? state.progress.currentTask.caption
: 'Welcome to Progress Quest!',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
),
// 배속 버튼
SizedBox(
height: 28,
child: OutlinedButton(
onPressed: () {
widget.controller.loop?.cycleSpeed();
setState(() {});
},
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8),
visualDensity: VisualDensity.compact,
),
child: Text(
'${speed}x',
style: TextStyle(
fontWeight: speed > 1
? FontWeight.bold
: FontWeight.normal,
color: speed > 1
? Theme.of(context).colorScheme.primary
: null,
),
),
),
),
],
),
const SizedBox(height: 4),
// Task Progress 바
_buildProgressBar(
state.progress.task.position,
state.progress.task.max,
Theme.of(context).colorScheme.primary,
),
],
),
);
}
Widget _buildPanelHeader(String title) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),

View File

@@ -0,0 +1,168 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:askiineverdie/src/core/animation/ascii_animation_data.dart';
import 'package:askiineverdie/src/core/animation/ascii_animation_type.dart';
import 'package:askiineverdie/src/core/model/game_state.dart';
/// ASCII 애니메이션 카드 위젯
///
/// TaskType에 따라 다른 애니메이션을 표시.
/// 전투 시 몬스터 이름에 따라 다른 애니메이션 선택.
/// 특수 이벤트(레벨업, 퀘스트 완료) 시 오버라이드 애니메이션 재생.
/// 자체 타이머로 프레임 전환 (게임 틱과 독립).
class AsciiAnimationCard extends StatefulWidget {
const AsciiAnimationCard({
super.key,
required this.taskType,
this.monsterBaseName,
this.colorTheme = AsciiColorTheme.green,
this.specialAnimation,
});
final TaskType taskType;
/// 전투 중인 몬스터 기본 이름 (kill 타입일 때만 사용)
final String? monsterBaseName;
final AsciiColorTheme colorTheme;
/// 특수 애니메이션 오버라이드 (레벨업, 퀘스트 완료 등)
/// 설정되면 일반 애니메이션 대신 표시
final AsciiAnimationType? specialAnimation;
@override
State<AsciiAnimationCard> createState() => _AsciiAnimationCardState();
}
class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
Timer? _timer;
int _currentFrame = 0;
late AsciiAnimationData _animationData;
AsciiAnimationType? _currentSpecialAnimation;
@override
void initState() {
super.initState();
_updateAnimation();
}
@override
void didUpdateWidget(AsciiAnimationCard oldWidget) {
super.didUpdateWidget(oldWidget);
// 특수 애니메이션이 변경되었으면 업데이트
if (oldWidget.specialAnimation != widget.specialAnimation) {
_currentSpecialAnimation = widget.specialAnimation;
_updateAnimation();
return;
}
// 특수 애니메이션이 활성화되어 있으면 일반 업데이트 무시
if (_currentSpecialAnimation != null) {
return;
}
if (oldWidget.taskType != widget.taskType ||
oldWidget.monsterBaseName != widget.monsterBaseName) {
_updateAnimation();
}
}
void _updateAnimation() {
_timer?.cancel();
// 특수 애니메이션이 있으면 우선 적용
if (_currentSpecialAnimation != null) {
_animationData = getAnimationData(_currentSpecialAnimation!);
_currentFrame = 0;
// 특수 애니메이션은 한 번 재생 후 종료
_timer = Timer.periodic(
Duration(milliseconds: _animationData.frameIntervalMs),
(_) {
if (mounted) {
setState(() {
_currentFrame++;
// 마지막 프레임에 도달하면 특수 애니메이션 종료
if (_currentFrame >= _animationData.frames.length) {
_currentSpecialAnimation = null;
_updateAnimation();
}
});
}
},
);
return;
}
// 일반 애니메이션 처리
final animationType = taskTypeToAnimation(widget.taskType);
// 전투 타입이면 몬스터 카테고리에 따라 다른 애니메이션 선택
if (animationType == AsciiAnimationType.battle) {
final category = getMonsterCategory(widget.monsterBaseName);
_animationData = getBattleAnimation(category);
} else {
_animationData = getAnimationData(animationType);
}
_currentFrame = 0;
_timer = Timer.periodic(
Duration(milliseconds: _animationData.frameIntervalMs),
(_) {
if (mounted) {
setState(() {
_currentFrame = (_currentFrame + 1) % _animationData.frames.length;
});
}
},
);
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final brightness = Theme.of(context).brightness;
final colors = getThemeColors(widget.colorTheme, brightness);
// 특수 애니메이션 중이면 특별한 배경색 적용
final isSpecial = _currentSpecialAnimation != null;
final bgColor = isSpecial
? colors.backgroundColor.withValues(alpha: 0.95)
: colors.backgroundColor;
// 프레임 인덱스가 범위를 벗어나지 않도록 보정
final frameIndex = _currentFrame.clamp(0, _animationData.frames.length - 1);
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(4),
border: isSpecial
? Border.all(color: colors.textColor.withValues(alpha: 0.5))
: null,
),
child: Center(
child: Text(
_animationData.frames[frameIndex],
style: TextStyle(
fontFamily: 'monospace',
fontSize: 10,
color: colors.textColor,
height: 1.1,
letterSpacing: 0,
),
textAlign: TextAlign.center,
),
),
);
}
}

View File

@@ -0,0 +1,157 @@
import 'package:flutter/material.dart';
import 'package:askiineverdie/src/core/animation/ascii_animation_data.dart';
import 'package:askiineverdie/src/core/animation/ascii_animation_type.dart';
import 'package:askiineverdie/src/core/model/game_state.dart';
import 'package:askiineverdie/src/features/game/widgets/ascii_animation_card.dart';
/// 상단 패널: ASCII 애니메이션 + Task Progress 바
class TaskProgressPanel extends StatelessWidget {
const TaskProgressPanel({
super.key,
required this.progress,
required this.speedMultiplier,
required this.onSpeedCycle,
required this.colorTheme,
required this.onThemeCycle,
this.specialAnimation,
});
final ProgressState progress;
final int speedMultiplier;
final VoidCallback onSpeedCycle;
final AsciiColorTheme colorTheme;
final VoidCallback onThemeCycle;
/// 특수 애니메이션 (레벨업, 퀘스트 완료 등)
final AsciiAnimationType? specialAnimation;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
border: Border(
bottom: BorderSide(color: Theme.of(context).dividerColor),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// ASCII 애니메이션 카드
SizedBox(
height: 120,
child: AsciiAnimationCard(
taskType: progress.currentTask.type,
monsterBaseName: progress.currentTask.monsterBaseName,
colorTheme: colorTheme,
specialAnimation: specialAnimation,
),
),
const SizedBox(height: 8),
// 상태 메시지 + 버튼들
Row(
children: [
_buildThemeButton(context),
const SizedBox(width: 8),
Expanded(
child: Text(
progress.currentTask.caption.isNotEmpty
? progress.currentTask.caption
: 'Welcome to Progress Quest!',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
),
const SizedBox(width: 8),
_buildSpeedButton(context),
],
),
const SizedBox(height: 4),
// Task Progress 바
_buildProgressBar(context),
],
),
);
}
Widget _buildThemeButton(BuildContext context) {
final themeLabel = switch (colorTheme) {
AsciiColorTheme.green => 'G',
AsciiColorTheme.amber => 'A',
AsciiColorTheme.white => 'W',
AsciiColorTheme.system => 'S',
};
final themeColor = switch (colorTheme) {
AsciiColorTheme.green => const Color(0xFF00FF00),
AsciiColorTheme.amber => const Color(0xFFFFB000),
AsciiColorTheme.white => Colors.white,
AsciiColorTheme.system => Theme.of(context).colorScheme.primary,
};
return SizedBox(
height: 28,
child: OutlinedButton(
onPressed: onThemeCycle,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8),
visualDensity: VisualDensity.compact,
side: BorderSide(color: themeColor.withValues(alpha: 0.5)),
),
child: Text(
themeLabel,
style: TextStyle(
fontWeight: FontWeight.bold,
color: themeColor,
),
),
),
);
}
Widget _buildSpeedButton(BuildContext context) {
return SizedBox(
height: 28,
child: OutlinedButton(
onPressed: onSpeedCycle,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8),
visualDensity: VisualDensity.compact,
),
child: Text(
'${speedMultiplier}x',
style: TextStyle(
fontWeight:
speedMultiplier > 1 ? FontWeight.bold : FontWeight.normal,
color: speedMultiplier > 1
? Theme.of(context).colorScheme.primary
: null,
),
),
),
);
}
Widget _buildProgressBar(BuildContext context) {
final progressValue = progress.task.max > 0
? (progress.task.position / progress.task.max).clamp(0.0, 1.0)
: 0.0;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: LinearProgressIndicator(
value: progressValue,
backgroundColor:
Theme.of(context).colorScheme.primary.withValues(alpha: 0.2),
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),
minHeight: 12,
),
);
}
}