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 애니메이션 프레임 데이터
class AsciiAnimationData {
const AsciiAnimationData({
required this.frames,
this.frameIntervalMs = 200,
});
const AsciiAnimationData({required this.frames, this.frameIntervalMs = 200});
/// 각 프레임 (문자열, 최소 5줄)
final List<String> 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,
);

View File

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

View File

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

View File

@@ -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([

View File

@@ -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);

View File

@@ -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;

View File

@@ -226,6 +226,11 @@ class _GamePlayScreenState extends State<GamePlayScreen>
},
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,

View File

@@ -104,6 +104,21 @@ class GameSessionController extends ChangeNotifier {
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
void dispose() {
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/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<AsciiAnimationCard> {
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
Widget build(BuildContext context) {
final brightness = Theme.of(context).brightness;
@@ -254,13 +301,8 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
_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<AsciiAnimationCard> {
.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<AsciiAnimationCard> {
height: 1.1,
letterSpacing: 0,
),
textAlign: TextAlign.center,
textAlign: TextAlign.left,
),
),
);

View File

@@ -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,