feat(ui): 게임 화면 및 설정 화면 개선

- GamePlayScreen 개선
- GameSessionController 확장
- MobileCarouselLayout 기능 추가
- SettingsScreen 테스트 기능 추가
This commit is contained in:
JiWoong Sul
2026-01-12 20:02:54 +09:00
parent 12f195bed7
commit 1d855b64a2
5 changed files with 275 additions and 32 deletions

View File

@@ -61,7 +61,7 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
super.initState(); super.initState();
const config = PqConfig(); const config = PqConfig();
final mutations = GameMutations(config); final mutations = GameMutations(config);
final rewards = RewardService(mutations); final rewards = RewardService(mutations, config);
_controller = GameSessionController( _controller = GameSessionController(
progressService: ProgressService( progressService: ProgressService(

View File

@@ -102,6 +102,9 @@ class _GamePlayScreenState extends State<GamePlayScreen>
// 사망/엔딩 상태 추적 (BGM 전환용) // 사망/엔딩 상태 추적 (BGM 전환용)
bool _wasDead = false; bool _wasDead = false;
// 사운드 디바운스 추적 (배속 시 사운드 누락 방지)
final Map<String, int> _lastSfxPlayTime = {};
bool _wasComplete = false; bool _wasComplete = false;
// 사운드 볼륨 상태 (모바일 설정 UI용) // 사운드 볼륨 상태 (모바일 설정 UI용)
@@ -344,47 +347,53 @@ class _GamePlayScreenState extends State<GamePlayScreen>
_wasInBattleTask = isInBattleTask; _wasInBattleTask = isInBattleTask;
} }
/// 전투 이벤트별 SFX 재생 (채널 분리) /// 전투 이벤트별 SFX 재생 (채널 분리 + 디바운스)
/// ///
/// 플레이어 이펙트와 몬스터 이펙트를 별도 채널에서 재생하여 /// 플레이어 이펙트와 몬스터 이펙트를 별도 채널에서 재생하여
/// 사운드 충돌을 방지하고 완료를 보장합니다. /// 사운드 충돌을 방지하고 완료를 보장합니다.
/// 배속 모드에서는 디바운스를 적용하여 사운드 누락을 방지합니다.
void _playCombatEventSfx(CombatEvent event) { void _playCombatEventSfx(CombatEvent event) {
final audio = widget.audioService; final audio = widget.audioService;
if (audio == null) return; if (audio == null) return;
switch (event.type) { // 사운드 이름 결정
// 플레이어 채널: 플레이어가 발생시키는 이펙트 final sfxName = switch (event.type) {
case CombatEventType.playerAttack: CombatEventType.playerAttack => 'attack',
audio.playPlayerSfx('attack'); CombatEventType.playerSkill => 'skill',
case CombatEventType.playerSkill: CombatEventType.playerHeal => 'item',
audio.playPlayerSfx('skill'); CombatEventType.playerPotion => 'item',
case CombatEventType.playerHeal: CombatEventType.potionDrop => 'item',
case CombatEventType.playerPotion: CombatEventType.playerBuff => 'skill',
case CombatEventType.potionDrop: CombatEventType.playerDebuff => 'skill',
audio.playPlayerSfx('item'); CombatEventType.monsterAttack => 'hit',
case CombatEventType.playerBuff: CombatEventType.playerEvade => 'evade',
case CombatEventType.playerDebuff: CombatEventType.monsterEvade => 'evade',
audio.playPlayerSfx('skill'); CombatEventType.playerBlock => 'block',
CombatEventType.playerParry => 'parry',
CombatEventType.dotTick => null, // DOT 틱은 SFX 없음
};
// 몬스터 채널: 몬스터가 발생시키는 이펙트 (플레이어 피격) if (sfxName == null) return;
case CombatEventType.monsterAttack:
audio.playMonsterSfx('hit');
// 회피/방어 SFX (Phase 11) // 디바운스 체크 (배속 시 같은 사운드 100ms 내 중복 재생 방지)
case CombatEventType.playerEvade: final now = DateTime.now().millisecondsSinceEpoch;
audio.playPlayerSfx('evade'); final lastTime = _lastSfxPlayTime[sfxName] ?? 0;
case CombatEventType.monsterEvade: final speedMultiplier = widget.controller.loop?.speedMultiplier ?? 1;
// 몬스터 회피 = 플레이어 공격 빗나감 (evade SFX)
audio.playPlayerSfx('evade');
case CombatEventType.playerBlock:
audio.playPlayerSfx('block');
case CombatEventType.playerParry:
audio.playPlayerSfx('parry');
// SFX 없음 // 배속이 높을수록 디바운스 간격 증가 (1x=50ms, 8x=150ms)
case CombatEventType.dotTick: final debounceMs = 50 + (speedMultiplier - 1) * 15;
// DOT 틱은 SFX 없음 (너무 자주 발생)
break; if (now - lastTime < debounceMs) {
return; // 디바운스 기간 내 → 스킵
}
_lastSfxPlayTime[sfxName] = now;
// 채널별 재생
final isMonsterSfx = event.type == CombatEventType.monsterAttack;
if (isMonsterSfx) {
audio.playMonsterSfx(sfxName);
} else {
audio.playPlayerSfx(sfxName);
} }
} }
@@ -743,6 +752,14 @@ class _GamePlayScreenState extends State<GamePlayScreen>
setState(() => _sfxVolume = volume); setState(() => _sfxVolume = volume);
widget.audioService?.setSfxVolume(volume); widget.audioService?.setSfxVolume(volume);
}, },
onCreateTestCharacter: () async {
final navigator = Navigator.of(context);
final success = await widget.controller.createTestCharacter();
if (success && mounted) {
// 프론트 화면으로 이동
navigator.popUntil((route) => route.isFirst);
}
},
); );
} }
@@ -886,6 +903,13 @@ class _GamePlayScreenState extends State<GamePlayScreen>
onCheatQuest: () => onCheatQuest: () =>
widget.controller.loop?.cheatCompleteQuest(), widget.controller.loop?.cheatCompleteQuest(),
onCheatPlot: () => widget.controller.loop?.cheatCompletePlot(), onCheatPlot: () => widget.controller.loop?.cheatCompletePlot(),
onCreateTestCharacter: () async {
final navigator = Navigator.of(context);
final success = await widget.controller.createTestCharacter();
if (success && mounted) {
navigator.popUntil((route) => route.isFirst);
}
},
), ),
// 사망 오버레이 // 사망 오버레이
if (state.isDead && state.deathInfo != null) if (state.isDead && state.deathInfo != null)

View File

@@ -4,6 +4,7 @@ import 'package:asciineverdie/src/core/engine/progress_loop.dart';
import 'package:asciineverdie/src/core/engine/progress_service.dart'; import 'package:asciineverdie/src/core/engine/progress_service.dart';
import 'package:asciineverdie/src/core/engine/resurrection_service.dart'; import 'package:asciineverdie/src/core/engine/resurrection_service.dart';
import 'package:asciineverdie/src/core/engine/shop_service.dart'; import 'package:asciineverdie/src/core/engine/shop_service.dart';
import 'package:asciineverdie/src/core/engine/test_character_service.dart';
import 'package:asciineverdie/src/core/model/combat_stats.dart'; import 'package:asciineverdie/src/core/model/combat_stats.dart';
import 'package:asciineverdie/src/core/model/game_state.dart'; import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/core/model/game_statistics.dart'; import 'package:asciineverdie/src/core/model/game_statistics.dart';
@@ -367,6 +368,58 @@ class GameSessionController extends ChangeNotifier {
} }
} }
/// 테스트 캐릭터 생성 (디버그 모드 전용)
///
/// 현재 캐릭터를 레벨 100, 고급 장비, 다수의 스킬을 가진
/// 캐릭터로 변환하여 명예의 전당에 등록하고 세이브를 삭제함.
Future<bool> createTestCharacter() async {
if (_state == null) {
debugPrint('[TestCharacter] _state is null');
return false;
}
try {
debugPrint('[TestCharacter] Creating test character...');
// 게임 일시정지
await _stopLoop(saveOnStop: false);
// TestCharacterService로 테스트 캐릭터 생성
final testService = TestCharacterService(
config: progressService.config,
rng: _state!.rng,
);
final entry = testService.createTestCharacter(_state!);
debugPrint(
'[TestCharacter] Entry created: ${entry.characterName} Lv.${entry.level}',
);
// 명예의 전당에 등록
final success = await _hallOfFameStorage.addEntry(entry);
debugPrint('[TestCharacter] HallOfFame save result: $success');
if (success) {
// 세이브 파일 삭제
final deleteResult = await saveManager.deleteSave();
debugPrint('[TestCharacter] Save deleted: ${deleteResult.success}');
}
// 상태 초기화
_state = null;
_status = GameSessionStatus.idle;
notifyListeners();
debugPrint('[TestCharacter] Complete');
return success;
} catch (e, st) {
debugPrint('[TestCharacter] ERROR: $e');
debugPrint('[TestCharacter] StackTrace: $st');
return false;
}
}
/// 플레이어 부활 처리 (상태만 업데이트, 게임 재개는 별도로) /// 플레이어 부활 처리 (상태만 업데이트, 게임 재개는 별도로)
/// ///
/// HP/MP 회복, 빈 슬롯에 장비 자동 구매 /// HP/MP 회복, 빈 슬롯에 장비 자동 구매

View File

@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart' show kDebugMode;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:asciineverdie/src/core/notification/notification_service.dart'; import 'package:asciineverdie/src/core/notification/notification_service.dart';
@@ -50,6 +51,7 @@ class MobileCarouselLayout extends StatefulWidget {
this.onCheatTask, this.onCheatTask,
this.onCheatQuest, this.onCheatQuest,
this.onCheatPlot, this.onCheatPlot,
this.onCreateTestCharacter,
}); });
final GameState state; final GameState state;
@@ -97,6 +99,9 @@ class MobileCarouselLayout extends StatefulWidget {
/// 치트: 액트(플롯) 완료 /// 치트: 액트(플롯) 완료
final VoidCallback? onCheatPlot; final VoidCallback? onCheatPlot;
/// 테스트 캐릭터 생성 콜백 (디버그 모드 전용)
final Future<void> Function()? onCreateTestCharacter;
@override @override
State<MobileCarouselLayout> createState() => _MobileCarouselLayoutState(); State<MobileCarouselLayout> createState() => _MobileCarouselLayoutState();
} }
@@ -364,6 +369,39 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
); );
} }
/// 테스트 캐릭터 생성 확인 다이얼로그
Future<void> _showTestCharacterDialog(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Create Test Character?'),
content: const Text(
'현재 캐릭터가 레벨 100으로 변환되어 명예의 전당에 등록됩니다.\n\n'
'⚠️ 현재 세이브 파일이 삭제됩니다.\n'
'이 작업은 되돌릴 수 없습니다.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.onError,
),
child: const Text('Create'),
),
],
),
);
if (confirmed == true && mounted) {
await widget.onCreateTestCharacter?.call();
}
}
/// 옵션 메뉴 표시 /// 옵션 메뉴 표시
void _showOptionsMenu(BuildContext context) { void _showOptionsMenu(BuildContext context) {
final localizations = L10n.of(context); final localizations = L10n.of(context);
@@ -609,6 +647,34 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
), ),
], ],
// 디버그 도구 섹션 (kDebugMode에서만 표시)
if (kDebugMode && widget.onCreateTestCharacter != null) ...[
const Divider(),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Text(
'DEBUG TOOLS',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 8,
color: Colors.orange.shade300,
),
),
),
ListTile(
leading: const Icon(Icons.science, color: Colors.orange),
title: const Text('Create Test Character'),
subtitle: const Text('레벨 100 캐릭터를 명예의 전당에 등록'),
onTap: () {
Navigator.pop(context);
_showTestCharacterDialog(context);
},
),
],
const SizedBox(height: 8), const SizedBox(height: 8),
], ],
), ),

View File

@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n; import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
@@ -15,6 +16,7 @@ class SettingsScreen extends StatefulWidget {
this.onLocaleChange, this.onLocaleChange,
this.onBgmVolumeChange, this.onBgmVolumeChange,
this.onSfxVolumeChange, this.onSfxVolumeChange,
this.onCreateTestCharacter,
}); });
final SettingsRepository settingsRepository; final SettingsRepository settingsRepository;
@@ -28,6 +30,11 @@ class SettingsScreen extends StatefulWidget {
/// SFX 볼륨 변경 콜백 (AudioService 연동용) /// SFX 볼륨 변경 콜백 (AudioService 연동용)
final void Function(double volume)? onSfxVolumeChange; final void Function(double volume)? onSfxVolumeChange;
/// 테스트 캐릭터 생성 콜백 (디버그 모드 전용)
///
/// 현재 캐릭터를 레벨 100으로 만들어 명예의 전당에 등록하고 세이브 삭제
final Future<void> Function()? onCreateTestCharacter;
@override @override
State<SettingsScreen> createState() => _SettingsScreenState(); State<SettingsScreen> createState() => _SettingsScreenState();
@@ -40,6 +47,7 @@ class SettingsScreen extends StatefulWidget {
void Function(String locale)? onLocaleChange, void Function(String locale)? onLocaleChange,
void Function(double volume)? onBgmVolumeChange, void Function(double volume)? onBgmVolumeChange,
void Function(double volume)? onSfxVolumeChange, void Function(double volume)? onSfxVolumeChange,
Future<void> Function()? onCreateTestCharacter,
}) { }) {
return showModalBottomSheet<void>( return showModalBottomSheet<void>(
context: context, context: context,
@@ -57,6 +65,7 @@ class SettingsScreen extends StatefulWidget {
onLocaleChange: onLocaleChange, onLocaleChange: onLocaleChange,
onBgmVolumeChange: onBgmVolumeChange, onBgmVolumeChange: onBgmVolumeChange,
onSfxVolumeChange: onSfxVolumeChange, onSfxVolumeChange: onSfxVolumeChange,
onCreateTestCharacter: onCreateTestCharacter,
), ),
), ),
); );
@@ -180,6 +189,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
// 정보 // 정보
_buildSectionTitle(game_l10n.uiAbout), _buildSectionTitle(game_l10n.uiAbout),
_buildAboutCard(), _buildAboutCard(),
// 디버그 섹션 (디버그 모드에서만 표시)
if (kDebugMode && widget.onCreateTestCharacter != null) ...[
const SizedBox(height: 24),
_buildSectionTitle('Debug'),
_buildDebugSection(),
],
], ],
), ),
), ),
@@ -188,6 +204,90 @@ class _SettingsScreenState extends State<SettingsScreen> {
); );
} }
Widget _buildDebugSection() {
return Card(
color: Theme.of(context).colorScheme.errorContainer.withValues(alpha: 0.3),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.bug_report,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(width: 8),
Text(
'Developer Tools',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Theme.of(context).colorScheme.error,
),
),
],
),
const SizedBox(height: 12),
Text(
'현재 캐릭터를 레벨 100으로 수정하여 명예의 전당에 등록합니다. '
'등록 후 현재 세이브 파일이 삭제됩니다.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _handleCreateTestCharacter,
icon: const Icon(Icons.science),
label: const Text('Create Test Character'),
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.onError,
),
),
),
],
),
),
);
}
Future<void> _handleCreateTestCharacter() async {
// 확인 다이얼로그 표시
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Create Test Character?'),
content: const Text(
'현재 캐릭터가 레벨 100으로 변환되어 명예의 전당에 등록됩니다.\n\n'
'⚠️ 현재 세이브 파일이 삭제됩니다.\n'
'이 작업은 되돌릴 수 없습니다.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.onError,
),
child: const Text('Create'),
),
],
),
);
if (confirmed == true && mounted) {
await widget.onCreateTestCharacter?.call();
if (mounted) {
Navigator.of(context).pop(); // 설정 화면 닫기
}
}
}
Widget _buildSectionTitle(String title) { Widget _buildSectionTitle(String title) {
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.only(bottom: 8),