feat(ui): 게임 화면 및 설정 화면 개선
- GamePlayScreen 개선 - GameSessionController 확장 - MobileCarouselLayout 기능 추가 - SettingsScreen 테스트 기능 추가
This commit is contained in:
@@ -61,7 +61,7 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
||||
super.initState();
|
||||
const config = PqConfig();
|
||||
final mutations = GameMutations(config);
|
||||
final rewards = RewardService(mutations);
|
||||
final rewards = RewardService(mutations, config);
|
||||
|
||||
_controller = GameSessionController(
|
||||
progressService: ProgressService(
|
||||
|
||||
@@ -102,6 +102,9 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
|
||||
// 사망/엔딩 상태 추적 (BGM 전환용)
|
||||
bool _wasDead = false;
|
||||
|
||||
// 사운드 디바운스 추적 (배속 시 사운드 누락 방지)
|
||||
final Map<String, int> _lastSfxPlayTime = {};
|
||||
bool _wasComplete = false;
|
||||
|
||||
// 사운드 볼륨 상태 (모바일 설정 UI용)
|
||||
@@ -344,47 +347,53 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
_wasInBattleTask = isInBattleTask;
|
||||
}
|
||||
|
||||
/// 전투 이벤트별 SFX 재생 (채널 분리)
|
||||
/// 전투 이벤트별 SFX 재생 (채널 분리 + 디바운스)
|
||||
///
|
||||
/// 플레이어 이펙트와 몬스터 이펙트를 별도 채널에서 재생하여
|
||||
/// 사운드 충돌을 방지하고 완료를 보장합니다.
|
||||
/// 배속 모드에서는 디바운스를 적용하여 사운드 누락을 방지합니다.
|
||||
void _playCombatEventSfx(CombatEvent event) {
|
||||
final audio = widget.audioService;
|
||||
if (audio == null) return;
|
||||
|
||||
switch (event.type) {
|
||||
// 플레이어 채널: 플레이어가 발생시키는 이펙트
|
||||
case CombatEventType.playerAttack:
|
||||
audio.playPlayerSfx('attack');
|
||||
case CombatEventType.playerSkill:
|
||||
audio.playPlayerSfx('skill');
|
||||
case CombatEventType.playerHeal:
|
||||
case CombatEventType.playerPotion:
|
||||
case CombatEventType.potionDrop:
|
||||
audio.playPlayerSfx('item');
|
||||
case CombatEventType.playerBuff:
|
||||
case CombatEventType.playerDebuff:
|
||||
audio.playPlayerSfx('skill');
|
||||
// 사운드 이름 결정
|
||||
final sfxName = switch (event.type) {
|
||||
CombatEventType.playerAttack => 'attack',
|
||||
CombatEventType.playerSkill => 'skill',
|
||||
CombatEventType.playerHeal => 'item',
|
||||
CombatEventType.playerPotion => 'item',
|
||||
CombatEventType.potionDrop => 'item',
|
||||
CombatEventType.playerBuff => 'skill',
|
||||
CombatEventType.playerDebuff => 'skill',
|
||||
CombatEventType.monsterAttack => 'hit',
|
||||
CombatEventType.playerEvade => 'evade',
|
||||
CombatEventType.monsterEvade => 'evade',
|
||||
CombatEventType.playerBlock => 'block',
|
||||
CombatEventType.playerParry => 'parry',
|
||||
CombatEventType.dotTick => null, // DOT 틱은 SFX 없음
|
||||
};
|
||||
|
||||
// 몬스터 채널: 몬스터가 발생시키는 이펙트 (플레이어 피격)
|
||||
case CombatEventType.monsterAttack:
|
||||
audio.playMonsterSfx('hit');
|
||||
if (sfxName == null) return;
|
||||
|
||||
// 회피/방어 SFX (Phase 11)
|
||||
case CombatEventType.playerEvade:
|
||||
audio.playPlayerSfx('evade');
|
||||
case CombatEventType.monsterEvade:
|
||||
// 몬스터 회피 = 플레이어 공격 빗나감 (evade SFX)
|
||||
audio.playPlayerSfx('evade');
|
||||
case CombatEventType.playerBlock:
|
||||
audio.playPlayerSfx('block');
|
||||
case CombatEventType.playerParry:
|
||||
audio.playPlayerSfx('parry');
|
||||
// 디바운스 체크 (배속 시 같은 사운드 100ms 내 중복 재생 방지)
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
final lastTime = _lastSfxPlayTime[sfxName] ?? 0;
|
||||
final speedMultiplier = widget.controller.loop?.speedMultiplier ?? 1;
|
||||
|
||||
// SFX 없음
|
||||
case CombatEventType.dotTick:
|
||||
// DOT 틱은 SFX 없음 (너무 자주 발생)
|
||||
break;
|
||||
// 배속이 높을수록 디바운스 간격 증가 (1x=50ms, 8x=150ms)
|
||||
final debounceMs = 50 + (speedMultiplier - 1) * 15;
|
||||
|
||||
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);
|
||||
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: () =>
|
||||
widget.controller.loop?.cheatCompleteQuest(),
|
||||
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)
|
||||
|
||||
@@ -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/resurrection_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/game_state.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 회복, 빈 슬롯에 장비 자동 구매
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:flutter/foundation.dart' show kDebugMode;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/src/core/notification/notification_service.dart';
|
||||
@@ -50,6 +51,7 @@ class MobileCarouselLayout extends StatefulWidget {
|
||||
this.onCheatTask,
|
||||
this.onCheatQuest,
|
||||
this.onCheatPlot,
|
||||
this.onCreateTestCharacter,
|
||||
});
|
||||
|
||||
final GameState state;
|
||||
@@ -97,6 +99,9 @@ class MobileCarouselLayout extends StatefulWidget {
|
||||
/// 치트: 액트(플롯) 완료
|
||||
final VoidCallback? onCheatPlot;
|
||||
|
||||
/// 테스트 캐릭터 생성 콜백 (디버그 모드 전용)
|
||||
final Future<void> Function()? onCreateTestCharacter;
|
||||
|
||||
@override
|
||||
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) {
|
||||
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),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
|
||||
@@ -15,6 +16,7 @@ class SettingsScreen extends StatefulWidget {
|
||||
this.onLocaleChange,
|
||||
this.onBgmVolumeChange,
|
||||
this.onSfxVolumeChange,
|
||||
this.onCreateTestCharacter,
|
||||
});
|
||||
|
||||
final SettingsRepository settingsRepository;
|
||||
@@ -28,6 +30,11 @@ class SettingsScreen extends StatefulWidget {
|
||||
/// SFX 볼륨 변경 콜백 (AudioService 연동용)
|
||||
final void Function(double volume)? onSfxVolumeChange;
|
||||
|
||||
/// 테스트 캐릭터 생성 콜백 (디버그 모드 전용)
|
||||
///
|
||||
/// 현재 캐릭터를 레벨 100으로 만들어 명예의 전당에 등록하고 세이브 삭제
|
||||
final Future<void> Function()? onCreateTestCharacter;
|
||||
|
||||
@override
|
||||
State<SettingsScreen> createState() => _SettingsScreenState();
|
||||
|
||||
@@ -40,6 +47,7 @@ class SettingsScreen extends StatefulWidget {
|
||||
void Function(String locale)? onLocaleChange,
|
||||
void Function(double volume)? onBgmVolumeChange,
|
||||
void Function(double volume)? onSfxVolumeChange,
|
||||
Future<void> Function()? onCreateTestCharacter,
|
||||
}) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
@@ -57,6 +65,7 @@ class SettingsScreen extends StatefulWidget {
|
||||
onLocaleChange: onLocaleChange,
|
||||
onBgmVolumeChange: onBgmVolumeChange,
|
||||
onSfxVolumeChange: onSfxVolumeChange,
|
||||
onCreateTestCharacter: onCreateTestCharacter,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -180,6 +189,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
// 정보
|
||||
_buildSectionTitle(game_l10n.uiAbout),
|
||||
_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) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
|
||||
Reference in New Issue
Block a user