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

@@ -0,0 +1,827 @@
import 'package:flutter/material.dart';
import 'package:askiineverdie/src/core/animation/ascii_animation_type.dart';
/// ASCII 애니메이션 프레임 데이터
class AsciiAnimationData {
const AsciiAnimationData({
required this.frames,
this.frameIntervalMs = 200,
});
/// 각 프레임 (문자열, 최소 5줄)
final List<String> frames;
/// 프레임 간격 (밀리초)
final int frameIntervalMs;
}
/// 터미널 색상 테마
enum AsciiColorTheme {
/// 클래식 녹색 터미널
green,
/// 엠버 (호박색) 터미널
amber,
/// 화이트 온 블랙
white,
/// 시스템 테마 (라이트/다크 모드 따름)
system,
}
/// 테마별 색상 데이터
class AsciiThemeColors {
const AsciiThemeColors({
required this.textColor,
required this.backgroundColor,
});
final Color textColor;
final Color backgroundColor;
}
/// 테마별 색상 반환
AsciiThemeColors getThemeColors(AsciiColorTheme theme, Brightness brightness) {
return switch (theme) {
AsciiColorTheme.green => const AsciiThemeColors(
textColor: Color(0xFF00FF00),
backgroundColor: Color(0xFF0D0D0D),
),
AsciiColorTheme.amber => const AsciiThemeColors(
textColor: Color(0xFFFFB000),
backgroundColor: Color(0xFF1A1000),
),
AsciiColorTheme.white => const AsciiThemeColors(
textColor: Color(0xFFE0E0E0),
backgroundColor: Color(0xFF121212),
),
AsciiColorTheme.system => brightness == Brightness.dark
? const AsciiThemeColors(
textColor: Color(0xFFE0E0E0),
backgroundColor: Color(0xFF1E1E1E),
)
: const AsciiThemeColors(
textColor: Color(0xFF1E1E1E),
backgroundColor: Color(0xFFF5F5F5),
),
};
}
/// 몬스터 카테고리
enum MonsterCategory {
/// 기본 (고양이 모양)
beast,
/// 곤충/벌레류
insect,
/// 인간형 (고블린, 오크 등)
humanoid,
/// 언데드 (스켈레톤, 좀비)
undead,
/// 드래곤/비행 생물
dragon,
/// 슬라임/젤리
slime,
/// 악마/마법 생물
demon,
}
/// 몬스터 이름으로 카테고리 결정
MonsterCategory getMonsterCategory(String? monsterBaseName) {
if (monsterBaseName == null || monsterBaseName.isEmpty) {
return MonsterCategory.beast;
}
final name = monsterBaseName.toLowerCase();
// 곤충/벌레류
if (name.contains('ant') ||
name.contains('centipede') ||
name.contains('spider') ||
name.contains('beetle') ||
name.contains('crawler') ||
name.contains('crayfish') ||
name.contains('anhkheg')) {
return MonsterCategory.insect;
}
// 인간형
if (name.contains('goblin') ||
name.contains('orc') ||
name.contains('troll') ||
name.contains('ogre') ||
name.contains('giant') ||
name.contains('scout') ||
name.contains('bugbear') ||
name.contains('gnoll') ||
name.contains('kobold') ||
name.contains('hobgoblin')) {
return MonsterCategory.humanoid;
}
// 언데드
if (name.contains('skeleton') ||
name.contains('zombie') ||
name.contains('ghoul') ||
name.contains('ghost') ||
name.contains('wight') ||
name.contains('wraith') ||
name.contains('vampire') ||
name.contains('lich') ||
name.contains('mummy')) {
return MonsterCategory.undead;
}
// 드래곤/비행
if (name.contains('dragon') ||
name.contains('wyvern') ||
name.contains('cockatrice') ||
name.contains('griffin') ||
name.contains('roc') ||
name.contains('harpy') ||
name.contains('couatl')) {
return MonsterCategory.dragon;
}
// 슬라임/젤리
if (name.contains('slime') ||
name.contains('pudding') ||
name.contains('ooze') ||
name.contains('jelly') ||
name.contains('boogie') ||
name.contains('blob') ||
name.contains('jubilex')) {
return MonsterCategory.slime;
}
// 악마/마법 생물
if (name.contains('demon') ||
name.contains('devil') ||
name.contains('succubus') ||
name.contains('beholder') ||
name.contains('demogorgon') ||
name.contains('orcus') ||
name.contains('vrock') ||
name.contains('hezrou') ||
name.contains('glabrezu')) {
return MonsterCategory.demon;
}
return MonsterCategory.beast;
}
/// 기본 전투 애니메이션 (beast - 고양이 모양)
const battleAnimationBeast = AsciiAnimationData(
frames: [
// 프레임 1: 대치
'''
,O, /\\___/\\
/( )\\ ( o o )
/ \\ vs ( =^= )
_| |_ /| |\\
| | / | | \\
_| |_ | |_____| |
|_________| |___| |___|''',
// 프레임 2: 공격 준비
'''
O /\\___/\\
/|\\----o ( o o )
/ \\ ( =^= )
_| |_ /| |\\
| | / | | \\
_| |_ | |_____| |
|_________| |___| |___|''',
// 프레임 3: 공격 중
'''
O o--->/\\___/\\
/|\\-----------> ( X X )
/ \\ ( =^= )
_| |_ /| |\\
| | / | | \\
_| |_ | |_____| |
|_________| |___| |___|''',
// 프레임 4: 히트
'''
O /\\___/\\
/|\\ **** ( X X ) ****
/ \\ ** ( =^= ) **
_| |_ /| |\\
| | / | | \\
_| |_ | |_____| |
|_________| |___| |___|''',
// 프레임 5: 복귀
'''
\\O/ /\\___/\\
| ( - - )
/ \\ ( =^= )
_| |_ /| |\\
| | / | | \\
_| |_ | |_____| |
|_________| |___| |___|''',
],
frameIntervalMs: 220,
);
/// 마을/상점 애니메이션 (7줄)
const townAnimation = AsciiAnimationData(
frames: [
// 프레임 1: 상점 앞에서 대기
'''
_______________
/ \\ O
| SHOP | /|\\
| [=====] | / \\
| | | | |
|___|_____|______| _|_
~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~''',
// 프레임 2: 상점으로 이동
'''
_______________
/ \\ O
| SHOP | /|\\
| [=====] | / \\
| | | | |
|___|_____|______| _|_
~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~''',
// 프레임 3: 상점 앞 도착
'''
_______________
/ \\ O
| SHOP | /|\\
| [=====] | / \\
| | | | |
|___|_____|______| _|_
~~~~~~~~~~~~~~~~~~~~~~~~~~~''',
// 프레임 4: 거래 중
'''
_______________
/ \\ O \$
| SHOP | /|\\ \$
| [=====] | /\\\$
| | @ | | |
|___|_____|______| _|_
~~~~~~~~~~~~~~~~~~~~~~~~~~~''',
// 프레임 5: 거래 완료
'''
_______________
/ \\ \\O/
| SHOP | | +
| [=====] | / \\ +
| | @ | | | +
|___|_____|______| _|_
~~~~~~~~~~~~~~~~~~~~~~~~~~~''',
],
frameIntervalMs: 280,
);
/// 걷는 애니메이션 (7줄, 배경 포함)
const walkingAnimation = AsciiAnimationData(
frames: [
// 프레임 1: 서있기
'''
O
/|\\
/ \\
~~ | ~~
~~~~ _|_ ~~~~
~~~~~~ ~~~~~~~~ ~~~~~~
~~~~~~~~~~~~~~~~~~~~~~~~~~~~''',
// 프레임 2: 왼발 앞
'''
O
/|\\
/|
~~ / \\ ~~
~~~~ _|_ ~~~~
~~~~~~ ~~~~~~~~ ~~~~~~
~~~~~~~~~~~~~~~~~~~~~~~~~~~~''',
// 프레임 3: 이동 중
'''
O
/|\\
|\\
~~ / \\ ~~
~~~~ _|_ ~~~~
~~~~~~ ~~~~~~~~ ~~~~~~
~~~~~~~~~~~~~~~~~~~~~~~~~~~~''',
// 프레임 4: 오른발 앞
'''
O
/|\\
|/
~~ / \\ ~~
~~~~ _|_ ~~~~
~~~~~~ ~~~~~~~~ ~~~~~~
~~~~~~~~~~~~~~~~~~~~~~~~~~~~''',
// 프레임 5: 복귀
'''
O
/|\\
/ \\
~~ | ~~
~~~~ _|_ ~~~~
~~~~~~ ~~~~~~~~ ~~~~~~
~~~~~~~~~~~~~~~~~~~~~~~~~~~~''',
],
frameIntervalMs: 180,
);
/// 곤충 전투 애니메이션
const battleAnimationInsect = AsciiAnimationData(
frames: [
// 프레임 1: 대치
'''
,O, /\\_/\\
/( )\\ ( o o )
/ \\ vs /|=====|\\
_| |_ < | | >
| | \\|_____|/
_| |_ / \\
|_________| /_______\\''',
// 프레임 2: 공격 준비
'''
O /\\_/\\
/|\\----o ( o o )
/ \\ /|=====|\\
_| |_ < | | >
| | \\|_____|/
_| |_ / \\
|_________| /_______\\''',
// 프레임 3: 공격 중
'''
O o-->/\\_/\\
/|\\----------> ( o o )
/ \\ /|=====|\\
_| |_ < | | >
| | \\|_____|/
_| |_ / \\
|_________| /_______\\''',
// 프레임 4: 히트
'''
O /\\_/\\
/|\\ **** ( X X ) ****
/ \\ ** /|=====|\\ **
_| |_ < | | >
| | \\|_____|/
_| |_ / \\
|_________| /_______\\''',
// 프레임 5: 복귀
'''
\\O/ /\\_/\\
| ( - - )
/ \\ /|=====|\\
_| |_ < | | >
| | \\|_____|/
_| |_ / \\
|_________| /_______\\''',
],
frameIntervalMs: 220,
);
/// 인간형 전투 애니메이션
const battleAnimationHumanoid = AsciiAnimationData(
frames: [
// 프레임 1: 대치
'''
,O, O
/( )\\ /|\\
/ \\ vs / | \\
_| |_ ___|___
| | | |
_| |_ | orc |
|_________| |_______|''',
// 프레임 2: 공격 준비
'''
O O
/|\\----o /|\\
/ \\ / | \\
_| |_ ___|___
| | | |
_| |_ | orc |
|_________| |_______|''',
// 프레임 3: 공격 중
'''
O o----> O
/|\\-----------> /|\\
/ \\ / | \\
_| |_ ___|___
| | | |
_| |_ | orc |
|_________| |_______|''',
// 프레임 4: 히트
'''
O O
/|\\ **** X|X ****
/ \\ ** / | \\ **
_| |_ ___|___
| | | |
_| |_ | orc |
|_________| |_______|''',
// 프레임 5: 복귀
'''
\\O/ O
| /|\\
/ \\ / | \\
_| |_ ___|___
| | | |
_| |_ | orc |
|_________| |_______|''',
],
frameIntervalMs: 220,
);
/// 언데드 전투 애니메이션
const battleAnimationUndead = AsciiAnimationData(
frames: [
// 프레임 1: 대치
'''
,O, .-.
/( )\\ (o.o)
/ \\ vs |=|
_| |_ /|X|\\
| | / | | \\
_| |_ \\_|_|_/
|_________| _/ \\_''',
// 프레임 2: 공격 준비
'''
O .-.
/|\\----o (o.o)
/ \\ |=|
_| |_ /|X|\\
| | / | | \\
_| |_ \\_|_|_/
|_________| _/ \\_''',
// 프레임 3: 공격 중
'''
O o--->.-.
/|\\-----------> (o.o)
/ \\ |=|
_| |_ /|X|\\
| | / | | \\
_| |_ \\_|_|_/
|_________| _/ \\_''',
// 프레임 4: 히트
'''
O .-.
/|\\ **** (X.X) ****
/ \\ ** |=| **
_| |_ /|X|\\
| | / | | \\
_| |_ \\_|_|_/
|_________| _/ \\_''',
// 프레임 5: 복귀
'''
\\O/ .-.
| (-.-)
/ \\ |=|
_| |_ /|X|\\
| | / | | \\
_| |_ \\_|_|_/
|_________| _/ \\_''',
],
frameIntervalMs: 250,
);
/// 드래곤 전투 애니메이션
const battleAnimationDragon = AsciiAnimationData(
frames: [
// 프레임 1: 대치
'''
,O, __/\\__
/( )\\ / \\
/ \\ vs < (O)(O) >
_| |_ \\ \\/ /
| | \\ /
_| |_ /|\\~~~/|\\
|_________| /_________\\''',
// 프레임 2: 공격 준비
'''
O __/\\__
/|\\----o / \\
/ \\ < (O)(O) >
_| |_ \\ \\/ /
| | \\ /
_| |_ /|\\~~~/|\\
|_________| /_________\\''',
// 프레임 3: 공격 중
'''
O o--->__/\\__
/|\\---------> / \\
/ \\ < (O)(O) >
_| |_ \\ \\/ /
| | \\ /
_| |_ /|\\~~~/|\\
|_________| /_________\\''',
// 프레임 4: 히트
'''
O __/\\__
/|\\ **** / >< \\ ****
/ \\ ** < (X)(X) > **
_| |_ \\ \\/ /
| | \\ /
_| |_ /|\\~~~/|\\
|_________| /_________\\''',
// 프레임 5: 복귀
'''
\\O/ __/\\__
| / \\
/ \\ < (-)(-)>
_| |_ \\ \\/ /
| | \\ /
_| |_ /|\\~~~/|\\
|_________| /_________\\''',
],
frameIntervalMs: 200,
);
/// 슬라임 전투 애니메이션
const battleAnimationSlime = AsciiAnimationData(
frames: [
// 프레임 1: 대치
'''
,O, .---.
/( )\\ / \\
/ \\ vs ( o o )
_| |_ \\ ~ /
| | '---'
_| |_ ~~~~~~~
|_________| ~~~~~~~~~''',
// 프레임 2: 공격 준비
'''
O .---.
/|\\----o / \\
/ \\ ( o o )
_| |_ \\ ~ /
| | '---'
_| |_ ~~~~~~~
|_________| ~~~~~~~~~''',
// 프레임 3: 공격 중
'''
O o--->.---.
/|\\---------> / \\
/ \\ ( o o )
_| |_ \\ ~ /
| | '---'
_| |_ ~~~~~~~
|_________| ~~~~~~~~~''',
// 프레임 4: 히트
'''
O .---.
/|\\ **** / X X \\ ****
/ \\ ** ( ~ ) **
_| |_ \\ /
| | '---'
_| |_ ~~~~~~~
|_________| ~~~~~~~~~''',
// 프레임 5: 복귀
'''
\\O/ .---.
| / \\
/ \\ ( - - )
_| |_ \\ ~ /
| | '---'
_| |_ ~~~~~~~
|_________| ~~~~~~~~~''',
],
frameIntervalMs: 280,
);
/// 악마 전투 애니메이션
const battleAnimationDemon = AsciiAnimationData(
frames: [
// 프레임 1: 대치
'''
,O, /\\ /\\
/( )\\ ( \\ / )
/ \\ vs \\ o o /
_| |_ | V |
| | | ~~~ |
_| |_ /| |\\
|_________| /___|___|_\\''',
// 프레임 2: 공격 준비
'''
O /\\ /\\
/|\\----o ( \\ / )
/ \\ \\ o o /
_| |_ | V |
| | | ~~~ |
_| |_ /| |\\
|_________| /___|___|_\\''',
// 프레임 3: 공격 중
'''
O o--->/\\ /\\
/|\\--------> ( \\ / )
/ \\ \\ o o /
_| |_ | V |
| | | ~~~ |
_| |_ /| |\\
|_________| /___|___|_\\''',
// 프레임 4: 히트
'''
O /\\ /\\
/|\\ **** ( X X ) ****
/ \\ ** \\ X X / **
_| |_ | V |
| | | ~~~ |
_| |_ /| |\\
|_________| /___|___|_\\''',
// 프레임 5: 복귀
'''
\\O/ /\\ /\\
| ( \\ / )
/ \\ \\ - - /
_| |_ | V |
| | | ~~~ |
_| |_ /| |\\
|_________| /___|___|_\\''',
],
frameIntervalMs: 200,
);
/// 몬스터 카테고리별 전투 애니메이션 반환
AsciiAnimationData getBattleAnimation(MonsterCategory category) {
return switch (category) {
MonsterCategory.beast => battleAnimationBeast,
MonsterCategory.insect => battleAnimationInsect,
MonsterCategory.humanoid => battleAnimationHumanoid,
MonsterCategory.undead => battleAnimationUndead,
MonsterCategory.dragon => battleAnimationDragon,
MonsterCategory.slime => battleAnimationSlime,
MonsterCategory.demon => battleAnimationDemon,
};
}
/// 레벨업 축하 애니메이션
const levelUpAnimation = AsciiAnimationData(
frames: [
// 프레임 1: 시작
'''
* * *
* * *
\\O/
* | *
/ \\
* *
~~~~~~~~~~~~~~~~~~~~~''',
// 프레임 2: 별 확산
'''
* * *
* *
* \\O/ *
|
* / \\ *
* *
~~~~~~~~~~~~~~~~~~~~~''',
// 프레임 3: 레벨업 텍스트
'''
* L E V E L U P ! *
* *
* \\O/ *
|
* / \\ *
* *
~~~~~~~~~~~~~~~~~~~~~''',
// 프레임 4: 빛나는 캐릭터
'''
* * * * *
* *
* \\O/ *
* | *
* / \\ *
* * * *
~~~~~~~~~~~~~~~~~~~~~''',
// 프레임 5: 마무리
'''
+
+++
+++++
\\O/
|
/ \\
~~~~~~~~~~~~~~~~~~~~~''',
],
frameIntervalMs: 300,
);
/// 퀘스트 완료 애니메이션
const questCompleteAnimation = AsciiAnimationData(
frames: [
// 프레임 1: 퀘스트 깃발
'''
[=======]
|| ||
|| \\O/ ||
|| | ||
|| / \\ ||
||_____||
~~~~~~~~~~~~~~~~~~~''',
// 프레임 2: 승리
'''
[QUEST!]
|| ||
\\\\O//
\\|/
/ \\
||_____||
~~~~~~~~~~~~~~~~~~~''',
// 프레임 3: 보상
'''
COMPLETE!
\\O/ \$\$\$
| \$\$\$
/ \\ \$\$\$
~~~~~~~~~~~~~~~~~~~''',
// 프레임 4: 축하
'''
* * * * *
\\O/
| +EXP
/ \\ +GOLD
* * * * *
~~~~~~~~~~~~~~~~~~~''',
// 프레임 5: 마무리
'''
[ VICTORY! ]
\\O/
|
/ \\
~~~~~~~~~~~~~~~~~~~''',
],
frameIntervalMs: 350,
);
/// Act 완료 애니메이션 (플롯 진행)
const actCompleteAnimation = AsciiAnimationData(
frames: [
// 프레임 1: 커튼
'''
____________________
| |
| A C T |
| |
| C O M P L E T E |
| |
|____________________|''',
// 프레임 2: 캐릭터 등장
'''
____________________
| * * * * * |
| \\O/ |
| | |
| / \\ |
| * * * * * |
|____________________|''',
// 프레임 3: 플롯 진행 표시
'''
____________________
| PROLOGUE --> ACT |
| \\O/ |
| | --> |
| / \\ |
| STORY CONTINUES |
|____________________|''',
// 프레임 4: 축하
'''
____________________
| * * * * * |
| * \\O/ * |
| | |
| * / \\ * |
| * * * * * |
|____________________|''',
// 프레임 5: 마무리
'''
____________________
| +---------+ |
| | NEXT | |
| | CHAPTER | |
| +---------+ |
| \\O/ |
|____________________|''',
],
frameIntervalMs: 400,
);
/// 타입별 애니메이션 데이터 반환 (기본 전투는 beast)
AsciiAnimationData getAnimationData(AsciiAnimationType type) {
return switch (type) {
AsciiAnimationType.battle => battleAnimationBeast,
AsciiAnimationType.town => townAnimation,
AsciiAnimationType.walking => walkingAnimation,
AsciiAnimationType.levelUp => levelUpAnimation,
AsciiAnimationType.questComplete => questCompleteAnimation,
AsciiAnimationType.actComplete => actCompleteAnimation,
};
}

View File

@@ -0,0 +1,35 @@
import 'package:askiineverdie/src/core/model/game_state.dart';
/// ASCII 애니메이션 타입 (TaskType과 매핑)
enum AsciiAnimationType {
/// 전투 장면 (캐릭터 vs 몬스터)
battle,
/// 마을/상점 장면
town,
/// 걷는 캐릭터
walking,
/// 레벨업 축하
levelUp,
/// 퀘스트 완료
questComplete,
/// Act 완료 (플롯 진행)
actComplete,
}
/// TaskType을 AsciiAnimationType으로 변환
AsciiAnimationType taskTypeToAnimation(TaskType taskType) {
return switch (taskType) {
TaskType.kill => AsciiAnimationType.battle,
TaskType.market ||
TaskType.sell ||
TaskType.buying => AsciiAnimationType.town,
TaskType.neutral ||
TaskType.load ||
TaskType.plot => AsciiAnimationType.walking,
};
}

View File

@@ -0,0 +1,24 @@
import 'package:shared_preferences/shared_preferences.dart';
import 'package:askiineverdie/src/core/animation/ascii_animation_data.dart';
/// 테마 설정 저장/로드 서비스
class ThemePreferences {
static const _keyColorTheme = 'ascii_color_theme';
/// 테마 설정 저장
static Future<void> saveColorTheme(AsciiColorTheme theme) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_keyColorTheme, theme.index);
}
/// 테마 설정 로드 (기본값: green)
static Future<AsciiColorTheme> loadColorTheme() async {
final prefs = await SharedPreferences.getInstance();
final index = prefs.getInt(_keyColorTheme);
if (index == null || index < 0 || index >= AsciiColorTheme.values.length) {
return AsciiColorTheme.green;
}
return AsciiColorTheme.values[index];
}
}

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,
),
);
}
}