From e7fb8a4adbd38595b5b2eafb25d44351d4572780 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Mon, 15 Dec 2025 17:07:00 +0900 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=EC=9D=BC=EC=8B=9C=20=EC=A0=95?= =?UTF-8?q?=EC=A7=80=20=EB=B2=84=ED=8A=BC=20=EC=B6=94=EA=B0=80=20=EB=B0=8F?= =?UTF-8?q?=20=EB=B0=B0=EC=86=8D=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 게임 중 일시 정지/재개 버튼 추가 (테마 버튼 옆) - 5x 배속이 2x와 동일하게 작동하던 버그 수정 - progress_service.dart clamp 제한을 100ms에서 500ms로 확장 - ASCII 애니메이션 40x8 규격 통일 - townAnimation, walkingAnimation, levelUpAnimation 등 8줄로 통일 - 레거시 애니메이션 TextAlign.left로 정렬 문제 수정 - 캐릭터 프레임 구조 통일 (머리/몸통/다리 3줄) - 몬스터 크기 enum 실제 프레임 줄 수와 일치하도록 수정 --- .../core/animation/ascii_animation_data.dart | 678 +++++++++--------- lib/src/core/animation/background_data.dart | 4 +- lib/src/core/animation/battle_composer.dart | 28 +- lib/src/core/animation/character_frames.dart | 35 +- lib/src/core/animation/monster_size.dart | 29 +- lib/src/core/engine/progress_service.dart | 4 +- lib/src/features/game/game_play_screen.dart | 5 + .../game/game_session_controller.dart | 15 + .../game/widgets/ascii_animation_card.dart | 78 +- .../game/widgets/task_progress_panel.dart | 31 + 10 files changed, 529 insertions(+), 378 deletions(-) diff --git a/lib/src/core/animation/ascii_animation_data.dart b/lib/src/core/animation/ascii_animation_data.dart index 9293218..b4e61bb 100644 --- a/lib/src/core/animation/ascii_animation_data.dart +++ b/lib/src/core/animation/ascii_animation_data.dart @@ -4,10 +4,7 @@ import 'package:askiineverdie/src/core/animation/ascii_animation_type.dart'; /// ASCII 애니메이션 프레임 데이터 class AsciiAnimationData { - const AsciiAnimationData({ - required this.frames, - this.frameIntervalMs = 200, - }); + const AsciiAnimationData({required this.frames, this.frameIntervalMs = 200}); /// 각 프레임 (문자열, 최소 5줄) final List frames; @@ -46,26 +43,27 @@ class AsciiThemeColors { AsciiThemeColors getThemeColors(AsciiColorTheme theme, Brightness brightness) { return switch (theme) { AsciiColorTheme.green => const AsciiThemeColors( - textColor: Color(0xFF00FF00), - backgroundColor: Color(0xFF0D0D0D), - ), + textColor: Color(0xFF00FF00), + backgroundColor: Color(0xFF0D0D0D), + ), AsciiColorTheme.amber => const AsciiThemeColors( - textColor: Color(0xFFFFB000), - backgroundColor: Color(0xFF1A1000), - ), + 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), - ), + 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), + ), }; } @@ -182,93 +180,133 @@ const battleAnimationBeast = AsciiAnimationData( frames: [ // 프레임 1: 대치 ''' - o vs /\\_/\\ - /|\\ ( o.o ) - / \\ > ^ <''', - // 프레임 2: 공격 준비 + o /\\_/\\ + /|\\ ( o.o ) + / \\ > ^ <''', + // 프레임 2: 접근 ''' - o----o /\\_/\\ - /|\\ ( o.o ) - / \\ > ^ <''', - // 프레임 3: 공격 중 + o /\\_/\\ + /|\\ ( o.o ) + / \\ > ^ <''', + // 프레임 3: 공격 (근접) ''' - o o-----> /\\_/\\ - /|\\ ( X.X ) - / \\ > ^ <''', + o_/ /\\_/\\ + /| ( >.< ) + / \\ > ^ <''', // 프레임 4: 히트 ''' - o **** /\\_/\\ - /|\\ *** ( X.X ) *** - / \\ > ~ <''', - // 프레임 5: 복귀 + o **** /\\_/\\ + /|\\ *** ( X.X ) + / \\ > ~ <''', + // 프레임 5: 복귀 (승리 포즈) ''' - \\o/ /\\_/\\ - | ( -.-) - / \\ > ^ <''', + \\o/ /\\_/\\ + /|\\ ( -.-) + / \\ > ^ <''', ], frameIntervalMs: 220, ); -/// 마을/상점 애니메이션 (심플 3줄 캐릭터) +/// 마을/상점 애니메이션 (8줄 x 40자 고정) const townAnimation = AsciiAnimationData( frames: [ - // 프레임 1: 상점 앞에서 대기 - ''' - ___________ o - / SHOP \\/|\\ -~~|__|____|__|/ \\~~~~~~~~~~~~~''', - // 프레임 2: 상점으로 이동 - ''' - ___________ o - / SHOP \\/|\\ -~~|__|____|__|/ \\~~~~~~~~~~~~~''', + // 프레임 1: 상점 앞 대기 + ' \n' + ' ___________ \n' + ' / SHOP \\ o \n' + ' | [======] | /|\\ \n' + ' | @@@@ | / \\ \n' + ' | ITEMS | \n' + ' |___________| \n' + '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~', + // 프레임 2: 이동 중 + ' \n' + ' ___________ \n' + ' / SHOP \\ o \n' + ' | [======] | /|\\ \n' + ' | @@@@ | / \\ \n' + ' | ITEMS | \n' + ' |___________| \n' + '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~', // 프레임 3: 거래 시작 - ''' - ___________ o \$ - / SHOP \\/|\\ \$ -~~|__[ @@ ]__|/ \\ \$~~~~~~~~~~~''', + ' \n' + ' ___________ \n' + ' / SHOP \\ o \$ \n' + ' | [======] | /|\\ \$ \n' + ' | @@@@ | / \\ \$ \n' + ' | ITEMS | \n' + ' |___________| \n' + '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~', // 프레임 4: 거래 중 - ''' - ___________ o \$\$ - / SHOP \\/|\\ \$\$ -~~|__[ @@ ]__|/ \\ \$\$~~~~~~~~~~''', + ' \n' + ' ___________ \n' + ' / SHOP \\ o \$\$\$ \n' + ' | [<====>] | /|\\ \$\$\$ \n' + ' | @@@@ | / \\ \n' + ' | SOLD! | \n' + ' |___________| \n' + '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~', // 프레임 5: 거래 완료 - ''' - ___________ \\o/ + - / SHOP \\ | + -~~|__[ @@ ]__|/ \\ +~~~~~~~~~~~''', + ' \n' + ' ___________ \n' + ' / SHOP \\ \\o/ + \n' + ' | [======] | /|\\ + \n' + ' | @@@@ | / \\ \n' + ' | ITEMS | \n' + ' |___________| \n' + '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~', ], frameIntervalMs: 280, ); -/// 걷는 애니메이션 (심플 3줄 캐릭터 + 배경) +/// 걷는 애니메이션 (8줄 x 40자 고정) const walkingAnimation = AsciiAnimationData( frames: [ - // 프레임 1: 서있기 - ''' - ~~~~ o ~~~~ - ~~~~~~ /|\\ ~~~~~~ -~~~~~~~~ / \\ ~~~~~~~~''', - // 프레임 2: 왼발 앞 - ''' - ~~~~ o ~~~~ - ~~~~~~ /|\\ ~~~~~~ -~~~~~~~~ /| ~~~~~~~~''', - // 프레임 3: 이동 중 - ''' - ~~~~ o ~~~~ - ~~~~~~ /|\\ ~~~~~~ -~~~~~~~~ |\\ ~~~~~~~~''', - // 프레임 4: 오른발 앞 - ''' - ~~~~ o ~~~~ - ~~~~~~ /|\\ ~~~~~~ -~~~~~~~~ |/ ~~~~~~~~''', - // 프레임 5: 복귀 - ''' - ~~~~ o ~~~~ - ~~~~~~ /|\\ ~~~~~~ -~~~~~~~~ / \\ ~~~~~~~~''', + // 프레임 1: 양발 벌림 + ' \n' + ' \n' + ' \n' + ' \n' + ' o \n' + ' /|\\ \n' + ' / \\ \n' + '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~', + // 프레임 2: 왼발 앞으로 + ' \n' + ' \n' + ' \n' + ' \n' + ' o \n' + ' /|\\ \n' + ' /| \n' + '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~', + // 프레임 3: 두 발 모음 + ' \n' + ' \n' + ' \n' + ' \n' + ' o \n' + ' /|\\ \n' + ' || \n' + '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~', + // 프레임 4: 오른발 앞으로 + ' \n' + ' \n' + ' \n' + ' \n' + ' o \n' + ' /|\\ \n' + ' |\\ \n' + '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~', + // 프레임 5: 양발 벌림 (복귀) + ' \n' + ' \n' + ' \n' + ' \n' + ' o \n' + ' /|\\ \n' + ' / \\ \n' + '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~', ], frameIntervalMs: 180, ); @@ -278,29 +316,29 @@ const battleAnimationInsect = AsciiAnimationData( frames: [ // 프레임 1: 대치 ''' - o vs /\\_/\\ - /|\\ ( o o ) - / \\ /|=====|\\''', - // 프레임 2: 공격 준비 + o /\\_/\\ + /|\\ ( o o ) + / \\ /|=====|\\''', + // 프레임 2: 접근 ''' - o----o /\\_/\\ - /|\\ ( o o ) - / \\ /|=====|\\''', - // 프레임 3: 공격 중 + o /\\_/\\ + /|\\ ( o o ) + / \\ /|=====|\\''', + // 프레임 3: 공격 (근접) ''' - o o-----> /\\_/\\ - /|\\ ( X X ) - / \\ /|=====|\\''', + o_/ /\\_/\\ + /| ( >.< ) + / \\ /|=====|\\''', // 프레임 4: 히트 ''' - o **** /\\_/\\ - /|\\ *** ( X X ) *** - / \\ /|=====|\\''', - // 프레임 5: 복귀 + o **** /\\_/\\ + /|\\*** ( X X ) + / \\ /|=====|\\''', + // 프레임 5: 복귀 (승리 포즈) ''' - \\o/ /\\_/\\ - | ( - - ) - / \\ /|=====|\\''', + \\o/ /\\_/\\ + /|\\ ( - - ) + / \\ /|=====|\\''', ], frameIntervalMs: 220, ); @@ -310,29 +348,29 @@ const battleAnimationHumanoid = AsciiAnimationData( frames: [ // 프레임 1: 대치 ''' - o vs O - /|\\ /|\\ - / \\ / | \\''', - // 프레임 2: 공격 준비 + o O + /|\\ /|\\ + / \\ / | \\''', + // 프레임 2: 접근 ''' - o----o O - /|\\ /|\\ - / \\ / | \\''', - // 프레임 3: 공격 중 + o O + /|\\ /|\\ + / \\ / | \\''', + // 프레임 3: 공격 (근접) ''' - o o-----> O - /|\\ X|X - / \\ / | \\''', + o_/ O + /| X|X + / \\ / | \\''', // 프레임 4: 히트 ''' - o **** O - /|\\ *** X|X *** - / \\ / | \\''', - // 프레임 5: 복귀 + o **** O + /|\\ *** X|X + / \\ / | \\''', + // 프레임 5: 복귀 (승리 포즈) ''' - \\o/ O - | /|\\ - / \\ / | \\''', + \\o/ O + /|\\ /|\\ + / \\ / | \\''', ], frameIntervalMs: 220, ); @@ -342,29 +380,29 @@ const battleAnimationUndead = AsciiAnimationData( frames: [ // 프레임 1: 대치 ''' - o vs .-. - /|\\ (o.o) - / \\ |=|''', - // 프레임 2: 공격 준비 + o .-. + /|\\ (o.o) + / \\ |=|''', + // 프레임 2: 접근 ''' - o----o .-. - /|\\ (o.o) - / \\ |=|''', - // 프레임 3: 공격 중 + o .-. + /|\\ (o.o) + / \\ |=|''', + // 프레임 3: 공격 (근접) ''' - o o-----> .-. - /|\\ (X.X) - / \\ |=|''', + o_/ .-. + /| (>.>) + / \\ |=|''', // 프레임 4: 히트 ''' - o **** .-. - /|\\ *** (X.X) *** - / \\ |~|''', - // 프레임 5: 복귀 + o **** .-. + /|\\*** (X.X) + / \\ |~|''', + // 프레임 5: 복귀 (승리 포즈) ''' - \\o/ .-. - | (-.-) - / \\ |=|''', + \\o/ .-. + /|\\ (-.-) + / \\ |=|''', ], frameIntervalMs: 250, ); @@ -374,29 +412,29 @@ const battleAnimationDragon = AsciiAnimationData( frames: [ // 프레임 1: 대치 ''' - o vs __/\\__ - /|\\ < (O)(O) > - / \\ \\ \\/ /''', - // 프레임 2: 공격 준비 + o __/\\__ + /|\\ < (O)(O) > + / \\ \\ \\/ /''', + // 프레임 2: 접근 ''' - o----o __/\\__ - /|\\ < (O)(O) > - / \\ \\ \\/ /''', - // 프레임 3: 공격 중 + o __/\\__ + /|\\ < (O)(O) > + / \\ \\ \\/ /''', + // 프레임 3: 공격 (근접) ''' - o o-----> __/\\__ - /|\\ < (X)(X) > - / \\ \\ \\/ /''', + o_/ __/\\__ + /| < (X)(X) > + / \\ \\ \\/ /''', // 프레임 4: 히트 ''' - o **** __/\\__ - /|\\ *** < (X)(X) > *** - / \\ \\ ~~ /''', - // 프레임 5: 복귀 + o **** __/\\__ + /|\\***< (X)(X) > + / \\ \\ ~~ /''', + // 프레임 5: 복귀 (승리 포즈) ''' - \\o/ __/\\__ - | < (-)(-)> - / \\ \\ \\/ /''', + \\o/ __/\\__ + /|\\ < (-)(-)> + / \\ \\ \\/ /''', ], frameIntervalMs: 200, ); @@ -406,29 +444,29 @@ const battleAnimationSlime = AsciiAnimationData( frames: [ // 프레임 1: 대치 ''' - o vs .---. - /|\\ ( o o ) - / \\ ~~~~~''', - // 프레임 2: 공격 준비 + o .---. + /|\\ ( o o ) + / \\ ~~~~~''', + // 프레임 2: 접근 ''' - o----o .---. - /|\\ ( o o ) - / \\ ~~~~~''', - // 프레임 3: 공격 중 + o .---. + /|\\ ( o o ) + / \\ ~~~~~''', + // 프레임 3: 공격 (근접) ''' - o o-----> .---. - /|\\ ( X X ) - / \\ ~~~~~''', + o_/ .---. + /| ( >.< ) + / \\ ~~~~~''', // 프레임 4: 히트 ''' - o **** .---. - /|\\ *** ( X X ) *** - / \\ ~~~~~''', - // 프레임 5: 복귀 + o **** .---. + /|\\** ( X X ) + / \\ ~~~~~''', + // 프레임 5: 복귀 (승리 포즈) ''' - \\o/ .---. - | ( - - ) - / \\ ~~~~~''', + \\o/ .---. + /|\\ ( - - ) + / \\ ~~~~~''', ], frameIntervalMs: 280, ); @@ -438,29 +476,29 @@ const battleAnimationDemon = AsciiAnimationData( frames: [ // 프레임 1: 대치 ''' - o vs /\\ /\\ - /|\\ ( o V o ) - / \\ \\ ~~~ /''', - // 프레임 2: 공격 준비 + o /\\ /\\ + /|\\ ( o V o ) + / \\ \\ ~~~ /''', + // 프레임 2: 접근 ''' - o----o /\\ /\\ - /|\\ ( o V o ) - / \\ \\ ~~~ /''', - // 프레임 3: 공격 중 + o /\\ /\\ + /|\\ ( o V o ) + / \\ \\ ~~~ /''', + // 프레임 3: 공격 (근접) ''' - o o-----> /\\ /\\ - /|\\ ( X V X ) - / \\ \\ ~~~ /''', + o_/ /\\ /\\ + /| ( X V X ) + / \\ \\ ~~~ /''', // 프레임 4: 히트 ''' - o **** /\\ /\\ - /|\\ *** ( X V X ) *** - / \\ \\ ~~~ /''', - // 프레임 5: 복귀 + o ****/\\ /\\ + /|\\** ( X V X ) + / \\ \\ ~~~ /''', + // 프레임 5: 복귀 (승리 포즈) ''' - \\o/ /\\ /\\ - | ( - V - ) - / \\ \\ ~~~ /''', + \\o/ /\\ /\\ + /|\\ ( - V - ) + / \\ \\ ~~~ /''', ], frameIntervalMs: 200, ); @@ -478,158 +516,158 @@ AsciiAnimationData getBattleAnimation(MonsterCategory category) { }; } -/// 레벨업 축하 애니메이션 +/// 레벨업 축하 애니메이션 (8줄 x 40자 고정) const levelUpAnimation = AsciiAnimationData( frames: [ // 프레임 1: 시작 - ''' - * * * - * * * - \\O/ - * | * - / \\ - * * - ~~~~~~~~~~~~~~~~~~~~~''', + ' * * * \n' + ' * * * \n' + ' \n' + ' \\O/ \n' + ' * /|\\ * \n' + ' / \\ \n' + ' * * \n' + '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~', // 프레임 2: 별 확산 - ''' - * * * - * * - * \\O/ * - | - * / \\ * - * * - ~~~~~~~~~~~~~~~~~~~~~''', + ' * * * \n' + ' * * \n' + ' \n' + ' * \\O/ * \n' + ' /|\\ \n' + ' * / \\ * \n' + ' * * \n' + '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~', // 프레임 3: 레벨업 텍스트 - ''' - * L E V E L U P ! * - * * - * \\O/ * - | - * / \\ * - * * - ~~~~~~~~~~~~~~~~~~~~~''', + ' * L E V E L U P ! * \n' + ' * * \n' + ' \n' + ' * \\O/ * \n' + ' /|\\ \n' + ' * / \\ * \n' + ' * * \n' + '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~', // 프레임 4: 빛나는 캐릭터 - ''' - * * * * * - * * - * \\O/ * - * | * - * / \\ * - * * * * - ~~~~~~~~~~~~~~~~~~~~~''', + ' * * * * * \n' + ' * * \n' + ' \n' + ' * \\O/ * \n' + ' * /|\\ * \n' + ' * / \\ * \n' + ' * * * * \n' + '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~', // 프레임 5: 마무리 - ''' - + - +++ - +++++ - \\O/ - | - / \\ - ~~~~~~~~~~~~~~~~~~~~~''', + ' + \n' + ' +++ \n' + ' +++++ \n' + ' \\O/ \n' + ' /|\\ \n' + ' / \\ \n' + ' \n' + '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~', ], frameIntervalMs: 300, ); -/// 퀘스트 완료 애니메이션 +/// 퀘스트 완료 애니메이션 (8줄 x 40자 고정) const questCompleteAnimation = AsciiAnimationData( frames: [ // 프레임 1: 퀘스트 깃발 - ''' - [=======] - || || - || \\O/ || - || | || - || / \\ || - ||_____|| - ~~~~~~~~~~~~~~~~~~~''', + ' [=======] \n' + ' || || \n' + ' || \\O/ || \n' + ' || /|\\ || \n' + ' || / \\ || \n' + ' ||_____|| \n' + ' \n' + '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~', // 프레임 2: 승리 - ''' - [QUEST!] - || || - \\\\O// - \\|/ - / \\ - ||_____|| - ~~~~~~~~~~~~~~~~~~~''', + ' [QUEST!] \n' + ' || || \n' + ' \\\\O// \n' + ' /|\\ \n' + ' / \\ \n' + ' ||_____|| \n' + ' \n' + '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~', // 프레임 3: 보상 - ''' - COMPLETE! - - \\O/ \$\$\$ - | \$\$\$ - / \\ \$\$\$ - - ~~~~~~~~~~~~~~~~~~~''', + ' COMPLETE! \n' + ' \n' + ' \\O/ \$\$\$ \n' + ' /|\\ \$\$\$ \n' + ' / \\ \$\$\$ \n' + ' \n' + ' \n' + '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~', // 프레임 4: 축하 - ''' - * * * * * - \\O/ - | +EXP - / \\ +GOLD - * * * * * - - ~~~~~~~~~~~~~~~~~~~''', + ' * * * * * \n' + ' \\O/ \n' + ' /|\\ +EXP \n' + ' / \\ +GOLD \n' + ' * * * * * \n' + ' \n' + ' \n' + '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~', // 프레임 5: 마무리 - ''' - [ VICTORY! ] - - \\O/ - | - / \\ - - ~~~~~~~~~~~~~~~~~~~''', + ' [ VICTORY! ] \n' + ' \n' + ' \\O/ \n' + ' /|\\ \n' + ' / \\ \n' + ' \n' + ' \n' + '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~', ], frameIntervalMs: 350, ); -/// Act 완료 애니메이션 (플롯 진행) +/// Act 완료 애니메이션 (8줄 x 40자 고정) const actCompleteAnimation = AsciiAnimationData( frames: [ // 프레임 1: 커튼 - ''' - ____________________ - | | - | A C T | - | | - | C O M P L E T E | - | | - |____________________|''', + ' ______________________________ \n' + ' | | \n' + ' | A C T | \n' + ' | | \n' + ' | C O M P L E T E | \n' + ' | | \n' + ' |______________________________| \n' + '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~', // 프레임 2: 캐릭터 등장 - ''' - ____________________ - | * * * * * | - | \\O/ | - | | | - | / \\ | - | * * * * * | - |____________________|''', + ' ______________________________ \n' + ' | * * * * * | \n' + ' | \\O/ | \n' + ' | /|\\ | \n' + ' | / \\ | \n' + ' | * * * * * | \n' + ' |______________________________| \n' + '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~', // 프레임 3: 플롯 진행 표시 - ''' - ____________________ - | PROLOGUE --> ACT | - | \\O/ | - | | --> | - | / \\ | - | STORY CONTINUES | - |____________________|''', + ' ______________________________ \n' + ' | PROLOGUE --> ACT | \n' + ' | \\O/ | \n' + ' | /|\\ --> | \n' + ' | / \\ | \n' + ' | STORY CONTINUES | \n' + ' |______________________________| \n' + '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~', // 프레임 4: 축하 - ''' - ____________________ - | * * * * * | - | * \\O/ * | - | | | - | * / \\ * | - | * * * * * | - |____________________|''', + ' ______________________________ \n' + ' | * * * * * | \n' + ' | * \\O/ * | \n' + ' | /|\\ | \n' + ' | * / \\ * | \n' + ' | * * * * * | \n' + ' |______________________________| \n' + '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~', // 프레임 5: 마무리 - ''' - ____________________ - | +---------+ | - | | NEXT | | - | | CHAPTER | | - | +---------+ | - | \\O/ | - |____________________|''', + ' ______________________________ \n' + ' | +---------+ | \n' + ' | | NEXT | | \n' + ' | | CHAPTER | | \n' + ' | +---------+ | \n' + ' | \\O/ | \n' + ' |______________________________| \n' + '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~', ], frameIntervalMs: 400, ); diff --git a/lib/src/core/animation/background_data.dart b/lib/src/core/animation/background_data.dart index ec937f0..38dc7d9 100644 --- a/lib/src/core/animation/background_data.dart +++ b/lib/src/core/animation/background_data.dart @@ -61,9 +61,9 @@ const _forestLayers = [ scrollSpeed: 0.15, yStart: 1, ), - // 전경 - 풀/바닥 + // 전경 - 바닥 BackgroundLayer( - lines: [r'____||____||____||____||____||____||'], + lines: [r'______________________________________'], scrollSpeed: 0.3, yStart: 7, ), diff --git a/lib/src/core/animation/battle_composer.dart b/lib/src/core/animation/battle_composer.dart index 29a8b79..e764d1a 100644 --- a/lib/src/core/animation/battle_composer.dart +++ b/lib/src/core/animation/battle_composer.dart @@ -74,14 +74,23 @@ class BattleComposer { _drawBackgroundLayer(canvas, layer, globalTick); } - // 3. 캐릭터 프레임 (정규화하여 왼쪽 정렬) + // 3. 캐릭터 프레임 (페이즈에 따라 X 위치 변경 - 근접 전투) var charFrame = getCharacterFrame(phase, subFrame); if (hasShield) { charFrame = charFrame.withShield(); } final normalizedChar = _normalizeSprite(charFrame.lines, characterWidth); - final charY = frameHeight - normalizedChar.length; - _overlaySpriteWithSpaces(canvas, normalizedChar, 0, charY); + // 바닥 레이어(Y=7) 위에 서있도록 -1 + final charY = frameHeight - normalizedChar.length - 1; + // 페이즈별 캐릭터 X 위치 (몬스터에게 접근) + final charX = switch (phase) { + BattlePhase.idle => 0, + BattlePhase.prepare => 12, + BattlePhase.attack => 24, + BattlePhase.hit => 28, + BattlePhase.recover => 8, + }; + _overlaySpriteWithSpaces(canvas, normalizedChar, charX, charY); // 4. 몬스터 프레임 (정규화하여 오른쪽 정렬) final monsterFrames = @@ -89,22 +98,23 @@ class BattleComposer { final monsterFrame = monsterFrames[subFrame % monsterFrames.length]; final normalizedMonster = _normalizeSpriteRight(monsterFrame, monsterWidth); final monsterX = frameWidth - monsterWidth; - final monsterY = frameHeight - normalizedMonster.length; + // 바닥 레이어(Y=7) 위에 서있도록 -1 + final monsterY = frameHeight - normalizedMonster.length - 1; _overlaySpriteWithSpaces(canvas, normalizedMonster, monsterX, monsterY); - // 5. 멀티라인 이펙트 오버레이 (공격/히트/준비 페이즈) - if (phase == BattlePhase.prepare || - phase == BattlePhase.attack || - phase == BattlePhase.hit) { + // 5. 멀티라인 이펙트 오버레이 (공격/히트 페이즈) + if (phase == BattlePhase.attack || phase == BattlePhase.hit) { final effect = getWeaponEffect(weaponCategory); final effectLines = _getEffectLines(effect, phase, subFrame); if (effectLines.isNotEmpty) { // 이펙트 Y 위치: 캐릭터 팔 높이 (2번째 줄, 몸통) 기준 final effectY = charY + 1; + // 이펙트 X 위치: 캐릭터 오른쪽에 붙여서 표시 + final effectX = charX + 6; for (var i = 0; i < effectLines.length; i++) { final y = effectY + i; if (y >= 0 && y < frameHeight && effectLines[i].isNotEmpty) { - _overlayText(canvas, effectLines[i], characterWidth, y); + _overlayText(canvas, effectLines[i], effectX, y); } } } diff --git a/lib/src/core/animation/character_frames.dart b/lib/src/core/animation/character_frames.dart index 9d84613..29de8b8 100644 --- a/lib/src/core/animation/character_frames.dart +++ b/lib/src/core/animation/character_frames.dart @@ -59,6 +59,7 @@ CharacterFrame getCharacterFrame(BattlePhase phase, int subFrame) { // ============================================================================ // 대기 프레임 (숨쉬기 애니메이션) - 4프레임, 심플 3줄 스타일, 폭 6자 +// 구조: [머리, 몸통+팔, 다리] // ============================================================================ const _idleFrames = [ CharacterFrame([ @@ -85,41 +86,38 @@ const _idleFrames = [ // ============================================================================ // 준비 프레임 (무기 들기) - 3프레임, 심플 3줄 스타일, 폭 6자 +// 구조: [머리, 몸통+팔, 다리] // ============================================================================ const _prepareFrames = [ CharacterFrame([ - r' \o ', - r' |\ ', + r' o ', + r' \|\ ', r' / \ ', ]), CharacterFrame([ - r' _ ', - r' \o ', + r' o_ ', + r' \| ', r' / \ ', ]), CharacterFrame([ - r' \_ ', - r' \o/ ', + r' o/ ', + r' \| ', r' / \ ', ]), ]; // ============================================================================ // 공격 프레임 (전진 + 휘두르기) - 5프레임, 심플 3줄 스타일 +// 구조: [머리, 몸통+팔+무기, 다리] // ============================================================================ const _attackFrames = [ CharacterFrame([ - r' \_/ ', - r' o ', + r' o\ ', r' /| ', + r' / \ ', ]), CharacterFrame([ - r' _/ ', - r' o ', - r' /|\ ', - ]), - CharacterFrame([ - r' o-- ', + r' o- ', r' /| ', r' / \ ', ]), @@ -130,13 +128,19 @@ const _attackFrames = [ ]), CharacterFrame([ r' o ', - r' /|\_ ', + r' /|-=>', + r' / \ ', + ]), + CharacterFrame([ + r' o ', + r' /|\ ', r' / \ ', ]), ]; // ============================================================================ // 히트 프레임 (공격 명중) - 3프레임, 심플 3줄 스타일 +// 구조: [머리, 몸통+팔+이펙트, 다리] // ============================================================================ const _hitFrames = [ CharacterFrame([ @@ -158,6 +162,7 @@ const _hitFrames = [ // ============================================================================ // 복귀 프레임 - 3프레임, 심플 3줄 스타일 +// 구조: [머리, 몸통+팔, 다리] // ============================================================================ const _recoverFrames = [ CharacterFrame([ diff --git a/lib/src/core/animation/monster_size.dart b/lib/src/core/animation/monster_size.dart index be13a69..5ba0f88 100644 --- a/lib/src/core/animation/monster_size.dart +++ b/lib/src/core/animation/monster_size.dart @@ -2,27 +2,28 @@ // 몬스터 레벨에 따라 ASCII 아트 크기 결정 /// 몬스터 크기 enum +/// 실제 프레임 줄 수와 일치하도록 설정 enum MonsterSize { - /// 1줄 (레벨 1-5) - tiny(1), + /// 2줄 (레벨 1-5) + tiny(2), - /// 2줄 (레벨 6-10) - small(2), + /// 4줄 (레벨 6-10) + small(4), - /// 3줄 (레벨 11-15) - medium(3), + /// 6줄 (레벨 11-15) + medium(6), - /// 4줄 (레벨 16-25) - large(4), + /// 8줄 (레벨 16-25) + large(8), - /// 5줄 (레벨 26-35) - huge(5), + /// 8줄 (레벨 26-35) + huge(8), - /// 6줄 (레벨 36-50) - giant(6), + /// 8줄 (레벨 36-50) + giant(8), - /// 7줄 (레벨 51+, 보스급) - titanic(7); + /// 8줄 (레벨 51+, 보스급) + titanic(8); const MonsterSize(this.lines); diff --git a/lib/src/core/engine/progress_service.dart b/lib/src/core/engine/progress_service.dart index 4308663..3619c78 100644 --- a/lib/src/core/engine/progress_service.dart +++ b/lib/src/core/engine/progress_service.dart @@ -127,7 +127,9 @@ class ProgressService { /// Tick the timer loop (equivalent to Timer1Timer in the original code). ProgressTickResult tick(GameState state, int elapsedMillis) { - final int clamped = elapsedMillis.clamp(0, 100).toInt(); + // 500ms 제한: 5x 배속 (50ms * 5 = 250ms) + 여유 공간 + // 원본은 100ms 제한이었으나 배속 기능 지원을 위해 확장 + final int clamped = elapsedMillis.clamp(0, 500).toInt(); var progress = state.progress; var queue = state.queue; var nextState = state; diff --git a/lib/src/features/game/game_play_screen.dart b/lib/src/features/game/game_play_screen.dart index 9dd2980..ec686fd 100644 --- a/lib/src/features/game/game_play_screen.dart +++ b/lib/src/features/game/game_play_screen.dart @@ -226,6 +226,11 @@ class _GamePlayScreenState extends State }, colorTheme: _colorTheme, onThemeCycle: _cycleColorTheme, + isPaused: !widget.controller.isRunning, + onPauseToggle: () async { + await widget.controller.togglePause(); + setState(() {}); + }, specialAnimation: _specialAnimation, weaponName: state.equipment.weapon, shieldName: state.equipment.shield, diff --git a/lib/src/features/game/game_session_controller.dart b/lib/src/features/game/game_session_controller.dart index a5b2980..1c6e06b 100644 --- a/lib/src/features/game/game_session_controller.dart +++ b/lib/src/features/game/game_session_controller.dart @@ -104,6 +104,21 @@ class GameSessionController extends ChangeNotifier { notifyListeners(); } + /// 일시 정지 상태에서 재개 + Future resume() async { + if (_state == null || _status != GameSessionStatus.idle) return; + await startNew(_state!, cheatsEnabled: _cheatsEnabled, isNewGame: false); + } + + /// 일시 정지/재개 토글 + Future togglePause() async { + if (isRunning) { + await pause(saveOnStop: true); + } else if (_state != null && _status == GameSessionStatus.idle) { + await resume(); + } + } + @override void dispose() { final stop = _stopLoop(saveOnStop: false); diff --git a/lib/src/features/game/widgets/ascii_animation_card.dart b/lib/src/features/game/widgets/ascii_animation_card.dart index 0bea3ea..3bd2517 100644 --- a/lib/src/features/game/widgets/ascii_animation_card.dart +++ b/lib/src/features/game/widgets/ascii_animation_card.dart @@ -7,7 +7,6 @@ import 'package:askiineverdie/src/core/animation/ascii_animation_type.dart'; import 'package:askiineverdie/src/core/animation/background_layer.dart'; import 'package:askiineverdie/src/core/animation/battle_composer.dart'; import 'package:askiineverdie/src/core/animation/character_frames.dart'; -import 'package:askiineverdie/src/core/animation/monster_colors.dart'; import 'package:askiineverdie/src/core/animation/monster_size.dart'; import 'package:askiineverdie/src/core/animation/weapon_category.dart'; import 'package:askiineverdie/src/core/model/game_state.dart'; @@ -230,6 +229,54 @@ class _AsciiAnimationCardState extends State { super.dispose(); } + /// 이펙트 문자에 색상을 적용한 TextSpan 생성 + TextSpan _buildColoredTextSpan(String text, TextStyle baseStyle) { + final spans = []; + final buffer = StringBuffer(); + + // 이펙트 문자 정의 + const effectChars = {'*', '!', '=', '>', '<', '~'}; + + for (var i = 0; i < text.length; i++) { + final char = text[i]; + + if (effectChars.contains(char)) { + // 버퍼에 쌓인 일반 텍스트 추가 + if (buffer.isNotEmpty) { + spans.add(TextSpan(text: buffer.toString(), style: baseStyle)); + buffer.clear(); + } + + // 이펙트 문자에 색상 적용 + final effectColor = _getEffectColor(char); + spans.add(TextSpan( + text: char, + style: baseStyle.copyWith(color: effectColor), + )); + } else { + buffer.write(char); + } + } + + // 남은 일반 텍스트 추가 + if (buffer.isNotEmpty) { + spans.add(TextSpan(text: buffer.toString(), style: baseStyle)); + } + + return TextSpan(children: spans); + } + + /// 이펙트 문자별 색상 반환 + Color _getEffectColor(String char) { + return switch (char) { + '*' => Colors.orange, // 히트/폭발 + '!' => Colors.yellow, // 강조 + '=' || '>' || '<' => Colors.cyan, // 슬래시/찌르기 + '~' => Colors.purple, // 물결/마법 + _ => Colors.white, + }; + } + @override Widget build(BuildContext context) { final brightness = Theme.of(context).brightness; @@ -254,13 +301,8 @@ class _AsciiAnimationCardState extends State { _environment, _globalTick, ); - - // 히트 페이즈면 몬스터 색상 변경 - if (_battlePhase == BattlePhase.hit) { - final monsterColorCategory = - getMonsterColorCategory(widget.monsterBaseName); - textColor = getMonsterColors(monsterColorCategory).hit; - } + // 이펙트는 텍스트 자체로 구분 (*, !, =, ~ 등) + // 전체 색상 변경 제거 - 기본 테마 색상 유지 } else { // 기존 레거시 시스템 사용 final frameIndex = @@ -293,14 +335,16 @@ class _AsciiAnimationCardState extends State { .clamp(6.0, 14.0); return Center( - child: Text( - frameText, - style: TextStyle( - fontFamily: 'Courier', - fontSize: fontSize, - color: textColor, - height: 1.2, - letterSpacing: 0, + child: RichText( + text: _buildColoredTextSpan( + frameText, + TextStyle( + fontFamily: 'Courier', + fontSize: fontSize, + color: textColor, + height: 1.2, + letterSpacing: 0, + ), ), textAlign: TextAlign.left, ), @@ -317,7 +361,7 @@ class _AsciiAnimationCardState extends State { height: 1.1, letterSpacing: 0, ), - textAlign: TextAlign.center, + textAlign: TextAlign.left, ), ), ); diff --git a/lib/src/features/game/widgets/task_progress_panel.dart b/lib/src/features/game/widgets/task_progress_panel.dart index 76386ce..e53f820 100644 --- a/lib/src/features/game/widgets/task_progress_panel.dart +++ b/lib/src/features/game/widgets/task_progress_panel.dart @@ -15,6 +15,8 @@ class TaskProgressPanel extends StatelessWidget { required this.onSpeedCycle, required this.colorTheme, required this.onThemeCycle, + required this.isPaused, + required this.onPauseToggle, this.specialAnimation, this.weaponName, this.shieldName, @@ -28,6 +30,10 @@ class TaskProgressPanel extends StatelessWidget { final AsciiColorTheme colorTheme; final VoidCallback onThemeCycle; + /// 일시 정지 상태 + final bool isPaused; + final VoidCallback onPauseToggle; + /// 특수 애니메이션 (레벨업, 퀘스트 완료 등) final AsciiAnimationType? specialAnimation; @@ -70,6 +76,8 @@ class TaskProgressPanel extends StatelessWidget { Row( children: [ _buildThemeButton(context), + const SizedBox(width: 4), + _buildPauseButton(context), const SizedBox(width: 8), Expanded( child: Text( @@ -128,6 +136,29 @@ class TaskProgressPanel extends StatelessWidget { ); } + Widget _buildPauseButton(BuildContext context) { + return SizedBox( + height: 28, + child: OutlinedButton( + onPressed: onPauseToggle, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8), + visualDensity: VisualDensity.compact, + side: BorderSide( + color: isPaused + ? Colors.orange.withValues(alpha: 0.7) + : Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + ), + child: Icon( + isPaused ? Icons.play_arrow : Icons.pause, + size: 16, + color: isPaused ? Colors.orange : null, + ), + ), + ); + } + Widget _buildSpeedButton(BuildContext context) { return SizedBox( height: 28,