feat(ui): 일시 정지 버튼 추가 및 배속 버그 수정

- 게임 중 일시 정지/재개 버튼 추가 (테마 버튼 옆)
- 5x 배속이 2x와 동일하게 작동하던 버그 수정
  - progress_service.dart clamp 제한을 100ms에서 500ms로 확장
- ASCII 애니메이션 40x8 규격 통일
  - townAnimation, walkingAnimation, levelUpAnimation 등 8줄로 통일
  - 레거시 애니메이션 TextAlign.left로 정렬 문제 수정
- 캐릭터 프레임 구조 통일 (머리/몸통/다리 3줄)
- 몬스터 크기 enum 실제 프레임 줄 수와 일치하도록 수정
This commit is contained in:
JiWoong Sul
2025-12-15 17:07:00 +09:00
parent 598c25e4c9
commit e7fb8a4adb
10 changed files with 529 additions and 378 deletions

View File

@@ -4,10 +4,7 @@ import 'package:askiineverdie/src/core/animation/ascii_animation_type.dart';
/// ASCII 애니메이션 프레임 데이터 /// ASCII 애니메이션 프레임 데이터
class AsciiAnimationData { class AsciiAnimationData {
const AsciiAnimationData({ const AsciiAnimationData({required this.frames, this.frameIntervalMs = 200});
required this.frames,
this.frameIntervalMs = 200,
});
/// 각 프레임 (문자열, 최소 5줄) /// 각 프레임 (문자열, 최소 5줄)
final List<String> frames; final List<String> frames;
@@ -57,7 +54,8 @@ AsciiThemeColors getThemeColors(AsciiColorTheme theme, Brightness brightness) {
textColor: Color(0xFFE0E0E0), textColor: Color(0xFFE0E0E0),
backgroundColor: Color(0xFF121212), backgroundColor: Color(0xFF121212),
), ),
AsciiColorTheme.system => brightness == Brightness.dark AsciiColorTheme.system =>
brightness == Brightness.dark
? const AsciiThemeColors( ? const AsciiThemeColors(
textColor: Color(0xFFE0E0E0), textColor: Color(0xFFE0E0E0),
backgroundColor: Color(0xFF1E1E1E), backgroundColor: Color(0xFF1E1E1E),
@@ -182,93 +180,133 @@ const battleAnimationBeast = AsciiAnimationData(
frames: [ frames: [
// 프레임 1: 대치 // 프레임 1: 대치
''' '''
o vs /\\_/\\ o /\\_/\\
/|\\ ( o.o ) /|\\ ( o.o )
/ \\ > ^ <''', / \\ > ^ <''',
// 프레임 2: 공격 준비 // 프레임 2: 접근
''' '''
o----o /\\_/\\ o /\\_/\\
/|\\ ( o.o ) /|\\ ( o.o )
/ \\ > ^ <''', / \\ > ^ <''',
// 프레임 3: 공격 // 프레임 3: 공격 (근접)
''' '''
o o-----> /\\_/\\ o_/ /\\_/\\
/|\\ ( X.X ) /| ( >.< )
/ \\ > ^ <''', / \\ > ^ <''',
// 프레임 4: 히트 // 프레임 4: 히트
''' '''
o **** /\\_/\\ o **** /\\_/\\
/|\\ *** ( X.X ) *** /|\\ *** ( X.X )
/ \\ > ~ <''', / \\ > ~ <''',
// 프레임 5: 복귀 // 프레임 5: 복귀 (승리 포즈)
''' '''
\\o/ /\\_/\\ \\o/ /\\_/\\
| ( -.-) /|\\ ( -.-)
/ \\ > ^ <''', / \\ > ^ <''',
], ],
frameIntervalMs: 220, frameIntervalMs: 220,
); );
/// 마을/상점 애니메이션 (심플 3줄 캐릭터) /// 마을/상점 애니메이션 (8줄 x 40자 고정)
const townAnimation = AsciiAnimationData( const townAnimation = AsciiAnimationData(
frames: [ frames: [
// 프레임 1: 상점 앞에서 대기 // 프레임 1: 상점 앞 대기
''' ' \n'
___________ o ' ___________ \n'
/ SHOP \\/|\\ ' / SHOP \\ o \n'
~~|__|____|__|/ \\~~~~~~~~~~~~~''', ' | [======] | /|\\ \n'
// 프레임 2: 상점으로 이동 ' | @@@@ | / \\ \n'
''' ' | ITEMS | \n'
___________ o ' |___________| \n'
/ SHOP \\/|\\ '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
~~|__|____|__|/ \\~~~~~~~~~~~~~''', // 프레임 2: 이동 중
' \n'
' ___________ \n'
' / SHOP \\ o \n'
' | [======] | /|\\ \n'
' | @@@@ | / \\ \n'
' | ITEMS | \n'
' |___________| \n'
'~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
// 프레임 3: 거래 시작 // 프레임 3: 거래 시작
''' ' \n'
___________ o \$ ' ___________ \n'
/ SHOP \\/|\\ \$ ' / SHOP \\ o \$ \n'
~~|__[ @@ ]__|/ \\ \$~~~~~~~~~~~''', ' | [======] | /|\\ \$ \n'
' | @@@@ | / \\ \$ \n'
' | ITEMS | \n'
' |___________| \n'
'~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
// 프레임 4: 거래 중 // 프레임 4: 거래 중
''' ' \n'
___________ o \$\$ ' ___________ \n'
/ SHOP \\/|\\ \$\$ ' / SHOP \\ o \$\$\$ \n'
~~|__[ @@ ]__|/ \\ \$\$~~~~~~~~~~''', ' | [<====>] | /|\\ \$\$\$ \n'
' | @@@@ | / \\ \n'
' | SOLD! | \n'
' |___________| \n'
'~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
// 프레임 5: 거래 완료 // 프레임 5: 거래 완료
''' ' \n'
___________ \\o/ + ' ___________ \n'
/ SHOP \\ | + ' / SHOP \\ \\o/ + \n'
~~|__[ @@ ]__|/ \\ +~~~~~~~~~~~''', ' | [======] | /|\\ + \n'
' | @@@@ | / \\ \n'
' | ITEMS | \n'
' |___________| \n'
'~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
], ],
frameIntervalMs: 280, frameIntervalMs: 280,
); );
/// 걷는 애니메이션 (심플 3줄 캐릭터 + 배경) /// 걷는 애니메이션 (8줄 x 40자 고정)
const walkingAnimation = AsciiAnimationData( const walkingAnimation = AsciiAnimationData(
frames: [ frames: [
// 프레임 1: 서있기 // 프레임 1: 양발 벌림
''' ' \n'
~~~~ o ~~~~ ' \n'
~~~~~~ /|\\ ~~~~~~ ' \n'
~~~~~~~~ / \\ ~~~~~~~~''', ' \n'
// 프레임 2: 왼발 앞 ' o \n'
''' ' /|\\ \n'
~~~~ o ~~~~ ' / \\ \n'
~~~~~~ /|\\ ~~~~~~ '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
~~~~~~~~ /| ~~~~~~~~''', // 프레임 2: 왼발 앞으로
// 프레임 3: 이동 중 ' \n'
''' ' \n'
~~~~ o ~~~~ ' \n'
~~~~~~ /|\\ ~~~~~~ ' \n'
~~~~~~~~ |\\ ~~~~~~~~''', ' o \n'
// 프레임 4: 오른발 앞 ' /|\\ \n'
''' ' /| \n'
~~~~ o ~~~~ '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
~~~~~~ /|\\ ~~~~~~ // 프레임 3: 두 발 모음
~~~~~~~~ |/ ~~~~~~~~''', ' \n'
// 프레임 5: 복귀 ' \n'
''' ' \n'
~~~~ o ~~~~ ' \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, frameIntervalMs: 180,
); );
@@ -278,28 +316,28 @@ const battleAnimationInsect = AsciiAnimationData(
frames: [ frames: [
// 프레임 1: 대치 // 프레임 1: 대치
''' '''
o vs /\\_/\\ o /\\_/\\
/|\\ ( o o ) /|\\ ( o o )
/ \\ /|=====|\\''', / \\ /|=====|\\''',
// 프레임 2: 공격 준비 // 프레임 2: 접근
''' '''
o----o /\\_/\\ o /\\_/\\
/|\\ ( o o ) /|\\ ( o o )
/ \\ /|=====|\\''', / \\ /|=====|\\''',
// 프레임 3: 공격 // 프레임 3: 공격 (근접)
''' '''
o o-----> /\\_/\\ o_/ /\\_/\\
/|\\ ( X X ) /| ( >.< )
/ \\ /|=====|\\''', / \\ /|=====|\\''',
// 프레임 4: 히트 // 프레임 4: 히트
''' '''
o **** /\\_/\\ o **** /\\_/\\
/|\\ *** ( X X ) *** /|\\*** ( X X )
/ \\ /|=====|\\''', / \\ /|=====|\\''',
// 프레임 5: 복귀 // 프레임 5: 복귀 (승리 포즈)
''' '''
\\o/ /\\_/\\ \\o/ /\\_/\\
| ( - - ) /|\\ ( - - )
/ \\ /|=====|\\''', / \\ /|=====|\\''',
], ],
frameIntervalMs: 220, frameIntervalMs: 220,
@@ -310,28 +348,28 @@ const battleAnimationHumanoid = AsciiAnimationData(
frames: [ frames: [
// 프레임 1: 대치 // 프레임 1: 대치
''' '''
o vs O o O
/|\\ /|\\ /|\\ /|\\
/ \\ / | \\''', / \\ / | \\''',
// 프레임 2: 공격 준비 // 프레임 2: 접근
''' '''
o----o O o O
/|\\ /|\\ /|\\ /|\\
/ \\ / | \\''', / \\ / | \\''',
// 프레임 3: 공격 // 프레임 3: 공격 (근접)
''' '''
o o-----> O o_/ O
/|\\ X|X /| X|X
/ \\ / | \\''', / \\ / | \\''',
// 프레임 4: 히트 // 프레임 4: 히트
''' '''
o **** O o **** O
/|\\ *** X|X *** /|\\ *** X|X
/ \\ / | \\''', / \\ / | \\''',
// 프레임 5: 복귀 // 프레임 5: 복귀 (승리 포즈)
''' '''
\\o/ O \\o/ O
| /|\\ /|\\ /|\\
/ \\ / | \\''', / \\ / | \\''',
], ],
frameIntervalMs: 220, frameIntervalMs: 220,
@@ -342,28 +380,28 @@ const battleAnimationUndead = AsciiAnimationData(
frames: [ frames: [
// 프레임 1: 대치 // 프레임 1: 대치
''' '''
o vs .-. o .-.
/|\\ (o.o) /|\\ (o.o)
/ \\ |=|''', / \\ |=|''',
// 프레임 2: 공격 준비 // 프레임 2: 접근
''' '''
o----o .-. o .-.
/|\\ (o.o) /|\\ (o.o)
/ \\ |=|''', / \\ |=|''',
// 프레임 3: 공격 // 프레임 3: 공격 (근접)
''' '''
o o-----> .-. o_/ .-.
/|\\ (X.X) /| (>.>)
/ \\ |=|''', / \\ |=|''',
// 프레임 4: 히트 // 프레임 4: 히트
''' '''
o **** .-. o **** .-.
/|\\ *** (X.X) *** /|\\*** (X.X)
/ \\ |~|''', / \\ |~|''',
// 프레임 5: 복귀 // 프레임 5: 복귀 (승리 포즈)
''' '''
\\o/ .-. \\o/ .-.
| (-.-) /|\\ (-.-)
/ \\ |=|''', / \\ |=|''',
], ],
frameIntervalMs: 250, frameIntervalMs: 250,
@@ -374,28 +412,28 @@ const battleAnimationDragon = AsciiAnimationData(
frames: [ frames: [
// 프레임 1: 대치 // 프레임 1: 대치
''' '''
o vs __/\\__ o __/\\__
/|\\ < (O)(O) > /|\\ < (O)(O) >
/ \\ \\ \\/ /''', / \\ \\ \\/ /''',
// 프레임 2: 공격 준비 // 프레임 2: 접근
''' '''
o----o __/\\__ o __/\\__
/|\\ < (O)(O) > /|\\ < (O)(O) >
/ \\ \\ \\/ /''', / \\ \\ \\/ /''',
// 프레임 3: 공격 // 프레임 3: 공격 (근접)
''' '''
o o-----> __/\\__ o_/ __/\\__
/|\\ < (X)(X) > /| < (X)(X) >
/ \\ \\ \\/ /''', / \\ \\ \\/ /''',
// 프레임 4: 히트 // 프레임 4: 히트
''' '''
o **** __/\\__ o **** __/\\__
/|\\ *** < (X)(X) > *** /|\\***< (X)(X) >
/ \\ \\ ~~ /''', / \\ \\ ~~ /''',
// 프레임 5: 복귀 // 프레임 5: 복귀 (승리 포즈)
''' '''
\\o/ __/\\__ \\o/ __/\\__
| < (-)(-)> /|\\ < (-)(-)>
/ \\ \\ \\/ /''', / \\ \\ \\/ /''',
], ],
frameIntervalMs: 200, frameIntervalMs: 200,
@@ -406,28 +444,28 @@ const battleAnimationSlime = AsciiAnimationData(
frames: [ frames: [
// 프레임 1: 대치 // 프레임 1: 대치
''' '''
o vs .---. o .---.
/|\\ ( o o ) /|\\ ( o o )
/ \\ ~~~~~''', / \\ ~~~~~''',
// 프레임 2: 공격 준비 // 프레임 2: 접근
''' '''
o----o .---. o .---.
/|\\ ( o o ) /|\\ ( o o )
/ \\ ~~~~~''', / \\ ~~~~~''',
// 프레임 3: 공격 // 프레임 3: 공격 (근접)
''' '''
o o-----> .---. o_/ .---.
/|\\ ( X X ) /| ( >.< )
/ \\ ~~~~~''', / \\ ~~~~~''',
// 프레임 4: 히트 // 프레임 4: 히트
''' '''
o **** .---. o **** .---.
/|\\ *** ( X X ) *** /|\\** ( X X )
/ \\ ~~~~~''', / \\ ~~~~~''',
// 프레임 5: 복귀 // 프레임 5: 복귀 (승리 포즈)
''' '''
\\o/ .---. \\o/ .---.
| ( - - ) /|\\ ( - - )
/ \\ ~~~~~''', / \\ ~~~~~''',
], ],
frameIntervalMs: 280, frameIntervalMs: 280,
@@ -438,28 +476,28 @@ const battleAnimationDemon = AsciiAnimationData(
frames: [ frames: [
// 프레임 1: 대치 // 프레임 1: 대치
''' '''
o vs /\\ /\\ o /\\ /\\
/|\\ ( o V o ) /|\\ ( o V o )
/ \\ \\ ~~~ /''', / \\ \\ ~~~ /''',
// 프레임 2: 공격 준비 // 프레임 2: 접근
''' '''
o----o /\\ /\\ o /\\ /\\
/|\\ ( o V o ) /|\\ ( o V o )
/ \\ \\ ~~~ /''', / \\ \\ ~~~ /''',
// 프레임 3: 공격 // 프레임 3: 공격 (근접)
''' '''
o o-----> /\\ /\\ o_/ /\\ /\\
/|\\ ( X V X ) /| ( X V X )
/ \\ \\ ~~~ /''', / \\ \\ ~~~ /''',
// 프레임 4: 히트 // 프레임 4: 히트
''' '''
o ****/\\ /\\ o ****/\\ /\\
/|\\ *** ( X V X ) *** /|\\** ( X V X )
/ \\ \\ ~~~ /''', / \\ \\ ~~~ /''',
// 프레임 5: 복귀 // 프레임 5: 복귀 (승리 포즈)
''' '''
\\o/ /\\ /\\ \\o/ /\\ /\\
| ( - V - ) /|\\ ( - V - )
/ \\ \\ ~~~ /''', / \\ \\ ~~~ /''',
], ],
frameIntervalMs: 200, frameIntervalMs: 200,
@@ -478,158 +516,158 @@ AsciiAnimationData getBattleAnimation(MonsterCategory category) {
}; };
} }
/// 레벨업 축하 애니메이션 /// 레벨업 축하 애니메이션 (8줄 x 40자 고정)
const levelUpAnimation = AsciiAnimationData( const levelUpAnimation = AsciiAnimationData(
frames: [ frames: [
// 프레임 1: 시작 // 프레임 1: 시작
''' ' * * * \n'
* * * ' * * * \n'
* * * ' \n'
\\O/ ' \\O/ \n'
* | * ' * /|\\ * \n'
/ \\ ' / \\ \n'
* * ' * * \n'
~~~~~~~~~~~~~~~~~~~~~''', '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
// 프레임 2: 별 확산 // 프레임 2: 별 확산
''' ' * * * \n'
* * * ' * * \n'
* * ' \n'
* \\O/ * ' * \\O/ * \n'
| ' /|\\ \n'
* / \\ * ' * / \\ * \n'
* * ' * * \n'
~~~~~~~~~~~~~~~~~~~~~''', '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
// 프레임 3: 레벨업 텍스트 // 프레임 3: 레벨업 텍스트
''' ' * L E V E L U P ! * \n'
* L E V E L U P ! * ' * * \n'
* * ' \n'
* \\O/ * ' * \\O/ * \n'
| ' /|\\ \n'
* / \\ * ' * / \\ * \n'
* * ' * * \n'
~~~~~~~~~~~~~~~~~~~~~''', '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
// 프레임 4: 빛나는 캐릭터 // 프레임 4: 빛나는 캐릭터
''' ' * * * * * \n'
* * * * * ' * * \n'
* * ' \n'
* \\O/ * ' * \\O/ * \n'
* | * ' * /|\\ * \n'
* / \\ * ' * / \\ * \n'
* * * * ' * * * * \n'
~~~~~~~~~~~~~~~~~~~~~''', '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
// 프레임 5: 마무리 // 프레임 5: 마무리
''' ' + \n'
+ ' +++ \n'
+++ ' +++++ \n'
+++++ ' \\O/ \n'
\\O/ ' /|\\ \n'
| ' / \\ \n'
/ \\ ' \n'
~~~~~~~~~~~~~~~~~~~~~''', '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
], ],
frameIntervalMs: 300, frameIntervalMs: 300,
); );
/// 퀘스트 완료 애니메이션 /// 퀘스트 완료 애니메이션 (8줄 x 40자 고정)
const questCompleteAnimation = AsciiAnimationData( const questCompleteAnimation = AsciiAnimationData(
frames: [ frames: [
// 프레임 1: 퀘스트 깃발 // 프레임 1: 퀘스트 깃발
''' ' [=======] \n'
[=======] ' || || \n'
|| || ' || \\O/ || \n'
|| \\O/ || ' || /|\\ || \n'
|| | || ' || / \\ || \n'
|| / \\ || ' ||_____|| \n'
||_____|| ' \n'
~~~~~~~~~~~~~~~~~~~''', '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
// 프레임 2: 승리 // 프레임 2: 승리
''' ' [QUEST!] \n'
[QUEST!] ' || || \n'
|| || ' \\\\O// \n'
\\\\O// ' /|\\ \n'
\\|/ ' / \\ \n'
/ \\ ' ||_____|| \n'
||_____|| ' \n'
~~~~~~~~~~~~~~~~~~~''', '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
// 프레임 3: 보상 // 프레임 3: 보상
''' ' COMPLETE! \n'
COMPLETE! ' \n'
' \\O/ \$\$\$ \n'
\\O/ \$\$\$ ' /|\\ \$\$\$ \n'
| \$\$\$ ' / \\ \$\$\$ \n'
/ \\ \$\$\$ ' \n'
' \n'
~~~~~~~~~~~~~~~~~~~''', '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
// 프레임 4: 축하 // 프레임 4: 축하
''' ' * * * * * \n'
* * * * * ' \\O/ \n'
\\O/ ' /|\\ +EXP \n'
| +EXP ' / \\ +GOLD \n'
/ \\ +GOLD ' * * * * * \n'
* * * * * ' \n'
' \n'
~~~~~~~~~~~~~~~~~~~''', '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
// 프레임 5: 마무리 // 프레임 5: 마무리
''' ' [ VICTORY! ] \n'
[ VICTORY! ] ' \n'
' \\O/ \n'
\\O/ ' /|\\ \n'
| ' / \\ \n'
/ \\ ' \n'
' \n'
~~~~~~~~~~~~~~~~~~~''', '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
], ],
frameIntervalMs: 350, frameIntervalMs: 350,
); );
/// Act 완료 애니메이션 (플롯 진행) /// Act 완료 애니메이션 (8줄 x 40자 고정)
const actCompleteAnimation = AsciiAnimationData( const actCompleteAnimation = AsciiAnimationData(
frames: [ frames: [
// 프레임 1: 커튼 // 프레임 1: 커튼
''' ' ______________________________ \n'
____________________ ' | | \n'
| | ' | A C T | \n'
| A C T | ' | | \n'
| | ' | C O M P L E T E | \n'
| C O M P L E T E | ' | | \n'
| | ' |______________________________| \n'
|____________________|''', '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
// 프레임 2: 캐릭터 등장 // 프레임 2: 캐릭터 등장
''' ' ______________________________ \n'
____________________ ' | * * * * * | \n'
| * * * * * | ' | \\O/ | \n'
| \\O/ | ' | /|\\ | \n'
| | | ' | / \\ | \n'
| / \\ | ' | * * * * * | \n'
| * * * * * | ' |______________________________| \n'
|____________________|''', '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
// 프레임 3: 플롯 진행 표시 // 프레임 3: 플롯 진행 표시
''' ' ______________________________ \n'
____________________ ' | PROLOGUE --> ACT | \n'
| PROLOGUE --> ACT | ' | \\O/ | \n'
| \\O/ | ' | /|\\ --> | \n'
| | --> | ' | / \\ | \n'
| / \\ | ' | STORY CONTINUES | \n'
| STORY CONTINUES | ' |______________________________| \n'
|____________________|''', '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
// 프레임 4: 축하 // 프레임 4: 축하
''' ' ______________________________ \n'
____________________ ' | * * * * * | \n'
| * * * * * | ' | * \\O/ * | \n'
| * \\O/ * | ' | /|\\ | \n'
| | | ' | * / \\ * | \n'
| * / \\ * | ' | * * * * * | \n'
| * * * * * | ' |______________________________| \n'
|____________________|''', '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
// 프레임 5: 마무리 // 프레임 5: 마무리
''' ' ______________________________ \n'
____________________ ' | +---------+ | \n'
| +---------+ | ' | | NEXT | | \n'
| | NEXT | | ' | | CHAPTER | | \n'
| | CHAPTER | | ' | +---------+ | \n'
| +---------+ | ' | \\O/ | \n'
| \\O/ | ' |______________________________| \n'
|____________________|''', '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
], ],
frameIntervalMs: 400, frameIntervalMs: 400,
); );

View File

@@ -61,9 +61,9 @@ const _forestLayers = [
scrollSpeed: 0.15, scrollSpeed: 0.15,
yStart: 1, yStart: 1,
), ),
// 전경 - 풀/바닥 // 전경 - 바닥
BackgroundLayer( BackgroundLayer(
lines: [r'____||____||____||____||____||____||'], lines: [r'______________________________________'],
scrollSpeed: 0.3, scrollSpeed: 0.3,
yStart: 7, yStart: 7,
), ),

View File

@@ -74,14 +74,23 @@ class BattleComposer {
_drawBackgroundLayer(canvas, layer, globalTick); _drawBackgroundLayer(canvas, layer, globalTick);
} }
// 3. 캐릭터 프레임 (정규화하여 왼쪽 정렬) // 3. 캐릭터 프레임 (페이즈에 따라 X 위치 변경 - 근접 전투)
var charFrame = getCharacterFrame(phase, subFrame); var charFrame = getCharacterFrame(phase, subFrame);
if (hasShield) { if (hasShield) {
charFrame = charFrame.withShield(); charFrame = charFrame.withShield();
} }
final normalizedChar = _normalizeSprite(charFrame.lines, characterWidth); final normalizedChar = _normalizeSprite(charFrame.lines, characterWidth);
final charY = frameHeight - normalizedChar.length; // 바닥 레이어(Y=7) 위에 서있도록 -1
_overlaySpriteWithSpaces(canvas, normalizedChar, 0, charY); 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. 몬스터 프레임 (정규화하여 오른쪽 정렬) // 4. 몬스터 프레임 (정규화하여 오른쪽 정렬)
final monsterFrames = final monsterFrames =
@@ -89,22 +98,23 @@ class BattleComposer {
final monsterFrame = monsterFrames[subFrame % monsterFrames.length]; final monsterFrame = monsterFrames[subFrame % monsterFrames.length];
final normalizedMonster = _normalizeSpriteRight(monsterFrame, monsterWidth); final normalizedMonster = _normalizeSpriteRight(monsterFrame, monsterWidth);
final monsterX = frameWidth - monsterWidth; final monsterX = frameWidth - monsterWidth;
final monsterY = frameHeight - normalizedMonster.length; // 바닥 레이어(Y=7) 위에 서있도록 -1
final monsterY = frameHeight - normalizedMonster.length - 1;
_overlaySpriteWithSpaces(canvas, normalizedMonster, monsterX, monsterY); _overlaySpriteWithSpaces(canvas, normalizedMonster, monsterX, monsterY);
// 5. 멀티라인 이펙트 오버레이 (공격/히트/준비 페이즈) // 5. 멀티라인 이펙트 오버레이 (공격/히트 페이즈)
if (phase == BattlePhase.prepare || if (phase == BattlePhase.attack || phase == BattlePhase.hit) {
phase == BattlePhase.attack ||
phase == BattlePhase.hit) {
final effect = getWeaponEffect(weaponCategory); final effect = getWeaponEffect(weaponCategory);
final effectLines = _getEffectLines(effect, phase, subFrame); final effectLines = _getEffectLines(effect, phase, subFrame);
if (effectLines.isNotEmpty) { if (effectLines.isNotEmpty) {
// 이펙트 Y 위치: 캐릭터 팔 높이 (2번째 줄, 몸통) 기준 // 이펙트 Y 위치: 캐릭터 팔 높이 (2번째 줄, 몸통) 기준
final effectY = charY + 1; final effectY = charY + 1;
// 이펙트 X 위치: 캐릭터 오른쪽에 붙여서 표시
final effectX = charX + 6;
for (var i = 0; i < effectLines.length; i++) { for (var i = 0; i < effectLines.length; i++) {
final y = effectY + i; final y = effectY + i;
if (y >= 0 && y < frameHeight && effectLines[i].isNotEmpty) { if (y >= 0 && y < frameHeight && effectLines[i].isNotEmpty) {
_overlayText(canvas, effectLines[i], characterWidth, y); _overlayText(canvas, effectLines[i], effectX, y);
} }
} }
} }

View File

@@ -59,6 +59,7 @@ CharacterFrame getCharacterFrame(BattlePhase phase, int subFrame) {
// ============================================================================ // ============================================================================
// 대기 프레임 (숨쉬기 애니메이션) - 4프레임, 심플 3줄 스타일, 폭 6자 // 대기 프레임 (숨쉬기 애니메이션) - 4프레임, 심플 3줄 스타일, 폭 6자
// 구조: [머리, 몸통+팔, 다리]
// ============================================================================ // ============================================================================
const _idleFrames = [ const _idleFrames = [
CharacterFrame([ CharacterFrame([
@@ -85,41 +86,38 @@ const _idleFrames = [
// ============================================================================ // ============================================================================
// 준비 프레임 (무기 들기) - 3프레임, 심플 3줄 스타일, 폭 6자 // 준비 프레임 (무기 들기) - 3프레임, 심플 3줄 스타일, 폭 6자
// 구조: [머리, 몸통+팔, 다리]
// ============================================================================ // ============================================================================
const _prepareFrames = [ const _prepareFrames = [
CharacterFrame([ CharacterFrame([
r' \o ', r' o ',
r' |\ ', r' \|\ ',
r' / \ ', r' / \ ',
]), ]),
CharacterFrame([ CharacterFrame([
r' _ ', r' o_ ',
r' \o ', r' \| ',
r' / \ ', r' / \ ',
]), ]),
CharacterFrame([ CharacterFrame([
r' \_ ', r' o/ ',
r' \o/ ', r' \| ',
r' / \ ', r' / \ ',
]), ]),
]; ];
// ============================================================================ // ============================================================================
// 공격 프레임 (전진 + 휘두르기) - 5프레임, 심플 3줄 스타일 // 공격 프레임 (전진 + 휘두르기) - 5프레임, 심플 3줄 스타일
// 구조: [머리, 몸통+팔+무기, 다리]
// ============================================================================ // ============================================================================
const _attackFrames = [ const _attackFrames = [
CharacterFrame([ CharacterFrame([
r' \_/ ', r' o\ ',
r' o ',
r' /| ', r' /| ',
r' / \ ',
]), ]),
CharacterFrame([ CharacterFrame([
r' _/ ', r' o- ',
r' o ',
r' /|\ ',
]),
CharacterFrame([
r' o-- ',
r' /| ', r' /| ',
r' / \ ', r' / \ ',
]), ]),
@@ -130,13 +128,19 @@ const _attackFrames = [
]), ]),
CharacterFrame([ CharacterFrame([
r' o ', r' o ',
r' /|\_ ', r' /|-=>',
r' / \ ',
]),
CharacterFrame([
r' o ',
r' /|\ ',
r' / \ ', r' / \ ',
]), ]),
]; ];
// ============================================================================ // ============================================================================
// 히트 프레임 (공격 명중) - 3프레임, 심플 3줄 스타일 // 히트 프레임 (공격 명중) - 3프레임, 심플 3줄 스타일
// 구조: [머리, 몸통+팔+이펙트, 다리]
// ============================================================================ // ============================================================================
const _hitFrames = [ const _hitFrames = [
CharacterFrame([ CharacterFrame([
@@ -158,6 +162,7 @@ const _hitFrames = [
// ============================================================================ // ============================================================================
// 복귀 프레임 - 3프레임, 심플 3줄 스타일 // 복귀 프레임 - 3프레임, 심플 3줄 스타일
// 구조: [머리, 몸통+팔, 다리]
// ============================================================================ // ============================================================================
const _recoverFrames = [ const _recoverFrames = [
CharacterFrame([ CharacterFrame([

View File

@@ -2,27 +2,28 @@
// 몬스터 레벨에 따라 ASCII 아트 크기 결정 // 몬스터 레벨에 따라 ASCII 아트 크기 결정
/// 몬스터 크기 enum /// 몬스터 크기 enum
/// 실제 프레임 줄 수와 일치하도록 설정
enum MonsterSize { enum MonsterSize {
/// 1줄 (레벨 1-5) /// 2줄 (레벨 1-5)
tiny(1), tiny(2),
/// 2줄 (레벨 6-10) /// 4줄 (레벨 6-10)
small(2), small(4),
/// 3줄 (레벨 11-15) /// 6줄 (레벨 11-15)
medium(3), medium(6),
/// 4줄 (레벨 16-25) /// 8줄 (레벨 16-25)
large(4), large(8),
/// 5줄 (레벨 26-35) /// 8줄 (레벨 26-35)
huge(5), huge(8),
/// 6줄 (레벨 36-50) /// 8줄 (레벨 36-50)
giant(6), giant(8),
/// 7줄 (레벨 51+, 보스급) /// 8줄 (레벨 51+, 보스급)
titanic(7); titanic(8);
const MonsterSize(this.lines); const MonsterSize(this.lines);

View File

@@ -127,7 +127,9 @@ class ProgressService {
/// Tick the timer loop (equivalent to Timer1Timer in the original code). /// Tick the timer loop (equivalent to Timer1Timer in the original code).
ProgressTickResult tick(GameState state, int elapsedMillis) { 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 progress = state.progress;
var queue = state.queue; var queue = state.queue;
var nextState = state; var nextState = state;

View File

@@ -226,6 +226,11 @@ class _GamePlayScreenState extends State<GamePlayScreen>
}, },
colorTheme: _colorTheme, colorTheme: _colorTheme,
onThemeCycle: _cycleColorTheme, onThemeCycle: _cycleColorTheme,
isPaused: !widget.controller.isRunning,
onPauseToggle: () async {
await widget.controller.togglePause();
setState(() {});
},
specialAnimation: _specialAnimation, specialAnimation: _specialAnimation,
weaponName: state.equipment.weapon, weaponName: state.equipment.weapon,
shieldName: state.equipment.shield, shieldName: state.equipment.shield,

View File

@@ -104,6 +104,21 @@ class GameSessionController extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// 일시 정지 상태에서 재개
Future<void> resume() async {
if (_state == null || _status != GameSessionStatus.idle) return;
await startNew(_state!, cheatsEnabled: _cheatsEnabled, isNewGame: false);
}
/// 일시 정지/재개 토글
Future<void> togglePause() async {
if (isRunning) {
await pause(saveOnStop: true);
} else if (_state != null && _status == GameSessionStatus.idle) {
await resume();
}
}
@override @override
void dispose() { void dispose() {
final stop = _stopLoop(saveOnStop: false); final stop = _stopLoop(saveOnStop: false);

View File

@@ -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/background_layer.dart';
import 'package:askiineverdie/src/core/animation/battle_composer.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/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/monster_size.dart';
import 'package:askiineverdie/src/core/animation/weapon_category.dart'; import 'package:askiineverdie/src/core/animation/weapon_category.dart';
import 'package:askiineverdie/src/core/model/game_state.dart'; import 'package:askiineverdie/src/core/model/game_state.dart';
@@ -230,6 +229,54 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
super.dispose(); super.dispose();
} }
/// 이펙트 문자에 색상을 적용한 TextSpan 생성
TextSpan _buildColoredTextSpan(String text, TextStyle baseStyle) {
final spans = <TextSpan>[];
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final brightness = Theme.of(context).brightness; final brightness = Theme.of(context).brightness;
@@ -254,13 +301,8 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
_environment, _environment,
_globalTick, _globalTick,
); );
// 이펙트는 텍스트 자체로 구분 (*, !, =, ~ 등)
// 히트 페이즈면 몬스터 색상 변경 // 전체 색상 변경 제거 - 기본 테마 색상 유지
if (_battlePhase == BattlePhase.hit) {
final monsterColorCategory =
getMonsterColorCategory(widget.monsterBaseName);
textColor = getMonsterColors(monsterColorCategory).hit;
}
} else { } else {
// 기존 레거시 시스템 사용 // 기존 레거시 시스템 사용
final frameIndex = final frameIndex =
@@ -293,15 +335,17 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
.clamp(6.0, 14.0); .clamp(6.0, 14.0);
return Center( return Center(
child: Text( child: RichText(
text: _buildColoredTextSpan(
frameText, frameText,
style: TextStyle( TextStyle(
fontFamily: 'Courier', fontFamily: 'Courier',
fontSize: fontSize, fontSize: fontSize,
color: textColor, color: textColor,
height: 1.2, height: 1.2,
letterSpacing: 0, letterSpacing: 0,
), ),
),
textAlign: TextAlign.left, textAlign: TextAlign.left,
), ),
); );
@@ -317,7 +361,7 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
height: 1.1, height: 1.1,
letterSpacing: 0, letterSpacing: 0,
), ),
textAlign: TextAlign.center, textAlign: TextAlign.left,
), ),
), ),
); );

View File

@@ -15,6 +15,8 @@ class TaskProgressPanel extends StatelessWidget {
required this.onSpeedCycle, required this.onSpeedCycle,
required this.colorTheme, required this.colorTheme,
required this.onThemeCycle, required this.onThemeCycle,
required this.isPaused,
required this.onPauseToggle,
this.specialAnimation, this.specialAnimation,
this.weaponName, this.weaponName,
this.shieldName, this.shieldName,
@@ -28,6 +30,10 @@ class TaskProgressPanel extends StatelessWidget {
final AsciiColorTheme colorTheme; final AsciiColorTheme colorTheme;
final VoidCallback onThemeCycle; final VoidCallback onThemeCycle;
/// 일시 정지 상태
final bool isPaused;
final VoidCallback onPauseToggle;
/// 특수 애니메이션 (레벨업, 퀘스트 완료 등) /// 특수 애니메이션 (레벨업, 퀘스트 완료 등)
final AsciiAnimationType? specialAnimation; final AsciiAnimationType? specialAnimation;
@@ -70,6 +76,8 @@ class TaskProgressPanel extends StatelessWidget {
Row( Row(
children: [ children: [
_buildThemeButton(context), _buildThemeButton(context),
const SizedBox(width: 4),
_buildPauseButton(context),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( 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) { Widget _buildSpeedButton(BuildContext context) {
return SizedBox( return SizedBox(
height: 28, height: 28,