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

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