feat(animation): ASCII 애니메이션 시스템 구현
- TaskType별 애니메이션 (전투, 마을, 걷기) - 몬스터 카테고리별 전투 애니메이션 (7종) - 특수 애니메이션 (레벨업, 퀘스트 완료, Act 완료) - 색상 테마 옵션 (green, amber, white, system) - 테마 설정 SharedPreferences 저장 - 프로그레스 바를 상단으로 이동
This commit is contained in:
@@ -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),
|
||||
|
||||
168
lib/src/features/game/widgets/ascii_animation_card.dart
Normal file
168
lib/src/features/game/widgets/ascii_animation_card.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
157
lib/src/features/game/widgets/task_progress_panel.dart
Normal file
157
lib/src/features/game/widgets/task_progress_panel.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user