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];
}
}