diff --git a/lib/src/core/animation/ascii_animation_data.dart b/lib/src/core/animation/ascii_animation_data.dart new file mode 100644 index 0000000..47d49b1 --- /dev/null +++ b/lib/src/core/animation/ascii_animation_data.dart @@ -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 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, + }; +} diff --git a/lib/src/core/animation/ascii_animation_type.dart b/lib/src/core/animation/ascii_animation_type.dart new file mode 100644 index 0000000..a8f6bbc --- /dev/null +++ b/lib/src/core/animation/ascii_animation_type.dart @@ -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, + }; +} diff --git a/lib/src/core/storage/theme_preferences.dart b/lib/src/core/storage/theme_preferences.dart new file mode 100644 index 0000000..4431d8a --- /dev/null +++ b/lib/src/core/storage/theme_preferences.dart @@ -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 saveColorTheme(AsciiColorTheme theme) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_keyColorTheme, theme.index); + } + + /// 테마 설정 로드 (기본값: green) + static Future 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]; + } +} diff --git a/lib/src/features/game/game_play_screen.dart b/lib/src/features/game/game_play_screen.dart index 65c18fc..38507eb 100644 --- a/lib/src/features/game/game_play_screen.dart +++ b/lib/src/features/game/game_play_screen.dart @@ -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 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 _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 } void _onControllerChanged() { + final state = widget.controller.state; + if (state != null) { + _checkSpecialEvents(state); + } setState(() {}); } @@ -131,6 +213,19 @@ class _GamePlayScreenState extends State ), 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 ], ), ), - - // 하단: Task Progress - _buildBottomPanel(state), ], ), ), @@ -261,71 +353,6 @@ class _GamePlayScreenState extends State ); } - /// 하단 패널: 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), diff --git a/lib/src/features/game/widgets/ascii_animation_card.dart b/lib/src/features/game/widgets/ascii_animation_card.dart new file mode 100644 index 0000000..32167d2 --- /dev/null +++ b/lib/src/features/game/widgets/ascii_animation_card.dart @@ -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 createState() => _AsciiAnimationCardState(); +} + +class _AsciiAnimationCardState extends State { + 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, + ), + ), + ); + } +} diff --git a/lib/src/features/game/widgets/task_progress_panel.dart b/lib/src/features/game/widgets/task_progress_panel.dart new file mode 100644 index 0000000..576063a --- /dev/null +++ b/lib/src/features/game/widgets/task_progress_panel.dart @@ -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( + Theme.of(context).colorScheme.primary, + ), + minHeight: 12, + ), + ); + } +}