873 lines
30 KiB
Dart
873 lines
30 KiB
Dart
import 'package:flutter/foundation.dart'
|
|
show kIsWeb, defaultTargetPlatform, TargetPlatform;
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/scheduler.dart' show SchedulerBinding, SchedulerPhase;
|
|
import 'package:flutter/services.dart' show KeyDownEvent, LogicalKeyboardKey;
|
|
|
|
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
|
|
import 'package:asciineverdie/src/core/engine/iap_service.dart';
|
|
import 'package:asciineverdie/src/core/model/treasure_chest.dart';
|
|
import 'package:asciineverdie/data/story_data.dart';
|
|
import 'package:asciineverdie/l10n/app_localizations.dart';
|
|
import 'package:asciineverdie/src/shared/animation/ascii_animation_type.dart';
|
|
import 'package:asciineverdie/src/core/engine/story_service.dart';
|
|
import 'package:asciineverdie/src/core/model/game_state.dart';
|
|
import 'package:asciineverdie/src/core/notification/notification_service.dart';
|
|
import 'package:asciineverdie/src/features/game/game_session_controller.dart';
|
|
import 'package:asciineverdie/src/features/hall_of_fame/hall_of_fame_screen.dart';
|
|
import 'package:asciineverdie/src/features/game/widgets/cinematic_view.dart';
|
|
import 'package:asciineverdie/src/features/game/widgets/death_overlay.dart';
|
|
import 'package:asciineverdie/src/features/game/widgets/desktop_character_panel.dart';
|
|
import 'package:asciineverdie/src/features/game/widgets/desktop_equipment_panel.dart';
|
|
import 'package:asciineverdie/src/features/game/widgets/desktop_quest_panel.dart';
|
|
import 'package:asciineverdie/src/features/game/widgets/victory_overlay.dart';
|
|
import 'package:asciineverdie/src/features/game/widgets/notification_overlay.dart';
|
|
import 'package:asciineverdie/src/features/game/widgets/task_progress_panel.dart';
|
|
import 'package:asciineverdie/src/features/game/widgets/return_rewards_dialog.dart';
|
|
import 'package:asciineverdie/src/features/game/layouts/mobile_carousel_layout.dart';
|
|
import 'package:asciineverdie/src/features/settings/settings_screen.dart';
|
|
import 'package:asciineverdie/src/features/game/widgets/statistics_dialog.dart';
|
|
import 'package:asciineverdie/src/features/game/widgets/help_dialog.dart';
|
|
import 'package:asciineverdie/src/core/storage/settings_repository.dart';
|
|
import 'package:asciineverdie/src/core/audio/audio_service.dart';
|
|
import 'package:asciineverdie/src/features/game/controllers/combat_log_controller.dart';
|
|
import 'package:asciineverdie/src/features/game/controllers/game_audio_controller.dart';
|
|
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
|
|
|
/// 게임 진행 화면 (Main.dfm 기반 3패널 레이아웃)
|
|
///
|
|
/// Phase 7: colorTheme 제거됨, 고정 4색 팔레트 사용
|
|
class GamePlayScreen extends StatefulWidget {
|
|
const GamePlayScreen({
|
|
super.key,
|
|
required this.controller,
|
|
this.audioService,
|
|
this.forceCarouselLayout = false,
|
|
this.forceDesktopLayout = false,
|
|
});
|
|
|
|
final GameSessionController controller;
|
|
|
|
/// 오디오 서비스 (BGM/SFX 재생)
|
|
final AudioService? audioService;
|
|
|
|
/// 테스트 모드: 웹에서도 모바일 캐로셀 레이아웃 강제 사용
|
|
final bool forceCarouselLayout;
|
|
|
|
/// 테스트 모드: 모바일에서도 데스크톱 3패널 레이아웃 강제 사용
|
|
final bool forceDesktopLayout;
|
|
|
|
@override
|
|
State<GamePlayScreen> createState() => _GamePlayScreenState();
|
|
}
|
|
|
|
class _GamePlayScreenState extends State<GamePlayScreen>
|
|
with WidgetsBindingObserver {
|
|
AsciiAnimationType? _specialAnimation;
|
|
|
|
// Phase 8: 알림 서비스 (Notification Service)
|
|
late final NotificationService _notificationService;
|
|
|
|
// Phase 9: 스토리 서비스 (Story Service)
|
|
late final StoryService _storyService;
|
|
StoryAct _lastAct = StoryAct.prologue;
|
|
bool _showingCinematic = false;
|
|
|
|
// 이전 상태 추적 (레벨업/퀘스트/Act 완료 감지용)
|
|
int _lastLevel = 0;
|
|
int _lastQuestCount = 0;
|
|
int _lastPlotStageCount = 0;
|
|
|
|
// Phase 2.4: 오디오 컨트롤러
|
|
late final GameAudioController _audioController;
|
|
|
|
// Phase 2.5: 전투 로그 컨트롤러
|
|
late final CombatLogController _combatLogController;
|
|
|
|
void _checkSpecialEvents(GameState state) {
|
|
// Phase 8: 태스크 변경 시 로그 추가
|
|
_combatLogController.onTaskChanged(state.progress.currentTask.caption);
|
|
|
|
// 전투 이벤트 처리 (Combat Events)
|
|
_combatLogController.processCombatEvents(state.progress.currentCombat);
|
|
|
|
// 오디오: TaskType 변경 시 BGM 전환 (애니메이션과 동기화)
|
|
_audioController.updateBgmForTaskType(state);
|
|
|
|
// 레벨업 감지
|
|
if (state.traits.level > _lastLevel && _lastLevel > 0) {
|
|
_specialAnimation = AsciiAnimationType.levelUp;
|
|
_notificationService.showLevelUp(state.traits.level);
|
|
_combatLogController.addLevelUpLog(state.traits.level);
|
|
// 오디오: 레벨업 SFX (플레이어 채널)
|
|
_audioController.playLevelUpSfx();
|
|
_resetSpecialAnimationAfterFrame();
|
|
|
|
// Phase 9: Act 변경 감지 (레벨 기반)
|
|
final newAct = getActForLevel(state.traits.level);
|
|
if (newAct != _lastAct && !_showingCinematic) {
|
|
_lastAct = newAct;
|
|
|
|
// 엔딩은 controller.isComplete 상태에서 VictoryOverlay로 처리
|
|
// 일반 Act 전환 시에만 시네마틱 표시
|
|
if (newAct != StoryAct.ending) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) _showCinematicForAct(newAct);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
_lastLevel = state.traits.level;
|
|
|
|
// 퀘스트 완료 감지
|
|
if (state.progress.questCount > _lastQuestCount && _lastQuestCount > 0) {
|
|
_specialAnimation = AsciiAnimationType.questComplete;
|
|
// 완료된 퀘스트 이름 가져오기
|
|
final completedQuest = state.progress.questHistory
|
|
.where((q) => q.isComplete)
|
|
.lastOrNull;
|
|
if (completedQuest != null) {
|
|
_notificationService.showQuestComplete(completedQuest.caption);
|
|
_combatLogController.addQuestCompleteLog(completedQuest.caption);
|
|
}
|
|
// 오디오: 퀘스트 완료 SFX (플레이어 채널)
|
|
_audioController.playQuestCompleteSfx();
|
|
_resetSpecialAnimationAfterFrame();
|
|
}
|
|
_lastQuestCount = state.progress.questCount;
|
|
|
|
// Act 완료 감지 (plotStageCount 증가)
|
|
// plotStageCount: 1=프롤로그 진행, 2=프롤로그 완료, 3=Act1 완료...
|
|
// 완료된 스테이지 인덱스 = plotStageCount - 2 (0=프롤로그, 1=Act1, ...)
|
|
if (state.progress.plotStageCount > _lastPlotStageCount &&
|
|
_lastPlotStageCount > 0) {
|
|
_specialAnimation = AsciiAnimationType.actComplete;
|
|
_notificationService.showActComplete(state.progress.plotStageCount - 2);
|
|
_resetSpecialAnimationAfterFrame();
|
|
}
|
|
_lastPlotStageCount = state.progress.plotStageCount;
|
|
|
|
// 사망/엔딩 BGM 전환 (Death/Ending BGM Transition)
|
|
_audioController.updateDeathEndingBgm(
|
|
state,
|
|
isGameComplete: widget.controller.isComplete,
|
|
);
|
|
}
|
|
|
|
/// Phase 9: Act 시네마틱 표시 (Show Act Cinematic)
|
|
Future<void> _showCinematicForAct(StoryAct act) async {
|
|
if (_showingCinematic) return;
|
|
|
|
_showingCinematic = true;
|
|
// 게임 일시 정지
|
|
await widget.controller.pause(saveOnStop: false);
|
|
|
|
// 시네마틱 BGM 재생
|
|
_audioController.playCinematicBgm();
|
|
|
|
if (mounted) {
|
|
await showActCinematic(context, act);
|
|
}
|
|
|
|
// 게임 재개 (BGM은 _updateBgmForTaskType에서 복원됨)
|
|
if (mounted) {
|
|
await widget.controller.resume();
|
|
}
|
|
_showingCinematic = false;
|
|
}
|
|
|
|
/// VictoryOverlay 완료 후 명예의 전당 화면으로 이동
|
|
void _handleVictoryComplete() {
|
|
Navigator.of(context).pushReplacement(
|
|
MaterialPageRoute<void>(builder: (context) => const HallOfFameScreen()),
|
|
);
|
|
}
|
|
|
|
void _resetSpecialAnimationAfterFrame() {
|
|
// 다음 프레임에서 리셋 (AsciiAnimationCard가 값을 받은 후)
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_specialAnimation = null;
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_notificationService = NotificationService();
|
|
_storyService = StoryService();
|
|
|
|
// 오디오 컨트롤러 초기화
|
|
_audioController = GameAudioController(
|
|
audioService: widget.audioService,
|
|
getSpeedMultiplier: () => widget.controller.loop?.speedMultiplier ?? 1,
|
|
);
|
|
|
|
// 전투 로그 컨트롤러 초기화
|
|
_combatLogController = CombatLogController(
|
|
onCombatEvent: (event) => _audioController.playCombatEventSfx(event),
|
|
);
|
|
|
|
widget.controller.addListener(_onControllerChanged);
|
|
WidgetsBinding.instance.addObserver(this);
|
|
|
|
// 초기 상태 설정
|
|
final state = widget.controller.state;
|
|
if (state != null) {
|
|
_lastLevel = state.traits.level;
|
|
_lastQuestCount = state.progress.questCount;
|
|
_lastPlotStageCount = state.progress.plotStageCount;
|
|
_lastAct = getActForLevel(state.traits.level);
|
|
|
|
// 초기 BGM 재생 (TaskType 기반)
|
|
_audioController.playInitialBgm(state);
|
|
} else {
|
|
// 상태가 없으면 기본 마을 BGM
|
|
widget.audioService?.playBgm('town');
|
|
}
|
|
|
|
// 누적 통계 로드
|
|
widget.controller.loadCumulativeStats();
|
|
|
|
// 오디오 볼륨 초기화
|
|
_audioController.initVolumes();
|
|
|
|
// Phase 7: 복귀 보상 콜백 설정
|
|
widget.controller.onReturnRewardAvailable = _showReturnRewardsDialog;
|
|
}
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
// 로케일 변경 시 게임 l10n 동기화 (시네마틱 번역 등에 필수)
|
|
final locale = Localizations.localeOf(context);
|
|
game_l10n.setGameLocale(locale.languageCode);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_notificationService.dispose();
|
|
_storyService.dispose();
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
widget.controller.removeListener(_onControllerChanged);
|
|
widget.controller.onReturnRewardAvailable = null; // Phase 7: 콜백 정리
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
void didChangeAppLifecycleState(AppLifecycleState appState) {
|
|
super.didChangeAppLifecycleState(appState);
|
|
|
|
// 모바일 환경 확인 (iOS/Android)
|
|
final isMobile =
|
|
!kIsWeb &&
|
|
(defaultTargetPlatform == TargetPlatform.iOS ||
|
|
defaultTargetPlatform == TargetPlatform.android);
|
|
|
|
// 앱이 백그라운드로 가거나 비활성화될 때
|
|
if (appState == AppLifecycleState.paused ||
|
|
appState == AppLifecycleState.inactive ||
|
|
appState == AppLifecycleState.detached) {
|
|
// 저장
|
|
_saveGameState();
|
|
|
|
// 모바일: 게임 일시정지 + 전체 오디오 정지
|
|
if (isMobile) {
|
|
widget.controller.pause(saveOnStop: false);
|
|
_audioController.pauseAll();
|
|
}
|
|
}
|
|
|
|
// 모바일: 앱이 포그라운드로 돌아올 때 전체 재로드
|
|
// (광고 표시 중 또는 최근 광고 시청 후에는 reload 건너뛰기)
|
|
if (appState == AppLifecycleState.resumed && isMobile) {
|
|
_audioController.resumeAll();
|
|
if (!widget.controller.isShowingAd &&
|
|
!widget.controller.isRecentlyShowedAd) {
|
|
_reloadGameScreen();
|
|
} else {
|
|
// 광고 직후: 게임 재개만 (reload 없이 상태 유지)
|
|
widget.controller.resume();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 모바일 재진입 시 전체 화면 재로드
|
|
Future<void> _reloadGameScreen() async {
|
|
// 세이브 파일에서 다시 로드 (치트 모드는 저장된 상태에서 복원)
|
|
await widget.controller.loadAndStart();
|
|
|
|
if (!mounted) return;
|
|
|
|
// 화면 재생성 (상태 초기화)
|
|
Navigator.of(context).pushReplacement(
|
|
MaterialPageRoute<void>(
|
|
builder: (_) => GamePlayScreen(
|
|
controller: widget.controller,
|
|
audioService: widget.audioService,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _saveGameState() async {
|
|
final currentState = widget.controller.state;
|
|
if (currentState == null || !widget.controller.isRunning) return;
|
|
|
|
await widget.controller.saveManager.saveState(
|
|
currentState,
|
|
cheatsEnabled: widget.controller.cheatsEnabled,
|
|
);
|
|
}
|
|
|
|
/// 뒤로가기 시 저장 확인 다이얼로그
|
|
Future<bool> _onPopInvoked() async {
|
|
final l10n = L10n.of(context);
|
|
final shouldPop = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text(l10n.exitGame),
|
|
content: Text(l10n.saveProgressQuestion),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(false),
|
|
child: Text(l10n.cancel),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.of(context).pop(true);
|
|
},
|
|
child: Text(l10n.exitWithoutSaving),
|
|
),
|
|
FilledButton(
|
|
onPressed: () async {
|
|
await _saveGameState();
|
|
if (context.mounted) {
|
|
Navigator.of(context).pop(true);
|
|
}
|
|
},
|
|
child: Text(l10n.saveAndExit),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
return shouldPop ?? false;
|
|
}
|
|
|
|
void _onControllerChanged() {
|
|
final state = widget.controller.state;
|
|
if (state != null) {
|
|
_checkSpecialEvents(state);
|
|
}
|
|
|
|
// WASM 안정성: 프레임 빌드 중이면 다음 프레임까지 대기
|
|
if (SchedulerBinding.instance.schedulerPhase ==
|
|
SchedulerPhase.persistentCallbacks) {
|
|
SchedulerBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) {
|
|
setState(() {});
|
|
}
|
|
});
|
|
} else {
|
|
setState(() {});
|
|
}
|
|
}
|
|
|
|
/// 캐로셀 레이아웃 사용 여부 판단
|
|
///
|
|
/// - forceDesktopLayout (테스트 모드) 활성화 시 데스크톱 레이아웃 사용
|
|
/// - forceCarouselLayout (테스트 모드) 활성화 시 캐로셀 레이아웃 사용
|
|
/// - 실제 모바일 플랫폼 (iOS/Android) 시 캐로셀 사용
|
|
bool _shouldUseCarouselLayout(BuildContext context) {
|
|
// 테스트 모드: 데스크톱 레이아웃 강제
|
|
if (widget.forceDesktopLayout) return false;
|
|
|
|
// 테스트 모드: 캐로셀 레이아웃 강제
|
|
if (widget.forceCarouselLayout) return true;
|
|
|
|
// 웹에서는 3패널 레이아웃 사용 (테스트 모드가 아닌 경우)
|
|
if (kIsWeb) return false;
|
|
|
|
// 모바일 플랫폼(iOS/Android)에서는 캐로셀 사용
|
|
final platform = defaultTargetPlatform;
|
|
return platform == TargetPlatform.iOS || platform == TargetPlatform.android;
|
|
}
|
|
|
|
/// 복귀 보상 다이얼로그 표시 (Phase 7)
|
|
void _showReturnRewardsDialog(ReturnChestReward reward) {
|
|
// 잠시 후 다이얼로그 표시 (게임 시작 후)
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
final state = widget.controller.state;
|
|
if (state == null) return;
|
|
|
|
ReturnRewardsDialog.show(
|
|
context,
|
|
reward: reward,
|
|
playerLevel: state.traits.level,
|
|
onClaim: (rewards) {
|
|
widget.controller.applyReturnReward(rewards);
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
/// 통계 다이얼로그 표시
|
|
void _showStatisticsDialog(BuildContext context) {
|
|
StatisticsDialog.show(
|
|
context,
|
|
session: widget.controller.sessionStats,
|
|
cumulative: widget.controller.cumulativeStats,
|
|
);
|
|
}
|
|
|
|
/// 설정 화면 표시
|
|
void _showSettingsScreen(BuildContext context) {
|
|
final settingsRepo = SettingsRepository();
|
|
SettingsScreen.show(
|
|
context,
|
|
settingsRepository: settingsRepo,
|
|
onLocaleChange: (locale) async {
|
|
// 안전한 언어 변경: 전체 화면 재생성
|
|
final navigator = Navigator.of(this.context);
|
|
await widget.controller.pause(saveOnStop: true);
|
|
game_l10n.setGameLocale(locale);
|
|
if (mounted) {
|
|
await widget.controller.resume();
|
|
navigator.pushReplacement(
|
|
MaterialPageRoute<void>(
|
|
builder: (_) => GamePlayScreen(
|
|
controller: widget.controller,
|
|
audioService: widget.audioService,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
},
|
|
onBgmVolumeChange: (volume) {
|
|
_audioController.setBgmVolume(volume);
|
|
setState(() {});
|
|
},
|
|
onSfxVolumeChange: (volume) {
|
|
_audioController.setSfxVolume(volume);
|
|
setState(() {});
|
|
},
|
|
onCreateTestCharacter: () async {
|
|
final navigator = Navigator.of(context);
|
|
final success = await widget.controller.createTestCharacter();
|
|
if (success && mounted) {
|
|
// 프론트 화면으로 이동
|
|
navigator.popUntil((route) => route.isFirst);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
/// 부활 처리 핸들러
|
|
Future<void> _handleResurrect() async {
|
|
// 1. 부활 애니메이션 먼저 설정 (DeathOverlay 사라지기 전에)
|
|
setState(() {
|
|
_specialAnimation = AsciiAnimationType.resurrection;
|
|
});
|
|
|
|
// 2. 부활 처리 (HP/MP 회복, 장비 구매 - 게임 재개 없음)
|
|
// 이 시점에서 isDead가 false가 되고 DeathOverlay가 사라지지만,
|
|
// _specialAnimation이 이미 설정되어 있어 부활 애니메이션이 표시됨
|
|
await widget.controller.resurrect();
|
|
|
|
// 3. 애니메이션 종료 후 게임 재개
|
|
final duration = getSpecialAnimationDuration(
|
|
AsciiAnimationType.resurrection,
|
|
);
|
|
Future.delayed(Duration(milliseconds: duration), () async {
|
|
if (mounted) {
|
|
// 먼저 게임 재개 (status를 running으로 변경)
|
|
// 이렇게 해야 setState 시 UI가 '일시정지' 상태로 보이지 않음
|
|
await widget.controller.resumeAfterResurrection();
|
|
if (mounted) {
|
|
setState(() {
|
|
_specialAnimation = null;
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// 광고 부활 핸들러 (HP 100% + 아이템 복구 + 10분 자동부활)
|
|
Future<void> _handleAdRevive() async {
|
|
// 1. 부활 애니메이션 먼저 설정 (DeathOverlay 사라지기 전에)
|
|
setState(() {
|
|
_specialAnimation = AsciiAnimationType.resurrection;
|
|
});
|
|
|
|
// 2. 광고 부활 처리 (HP 100%, 아이템 복구, 10분 자동부활 버프)
|
|
await widget.controller.adRevive();
|
|
|
|
// 3. 애니메이션 종료 후 게임 재개
|
|
final duration = getSpecialAnimationDuration(
|
|
AsciiAnimationType.resurrection,
|
|
);
|
|
Future.delayed(Duration(milliseconds: duration), () async {
|
|
if (mounted) {
|
|
await widget.controller.resumeAfterResurrection();
|
|
if (mounted) {
|
|
setState(() {
|
|
_specialAnimation = null;
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// 속도 부스트 활성화 핸들러 (Phase 6)
|
|
Future<void> _handleSpeedBoost() async {
|
|
final activated = await widget.controller.activateSpeedBoost();
|
|
if (activated && mounted) {
|
|
_notificationService.show(
|
|
GameNotification(
|
|
type: NotificationType.info,
|
|
title: game_l10n.speedBoostActive,
|
|
duration: const Duration(seconds: 2),
|
|
),
|
|
);
|
|
setState(() {});
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final state = widget.controller.state;
|
|
if (state == null) {
|
|
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
|
}
|
|
|
|
final localeKey = ValueKey(game_l10n.currentGameLocale);
|
|
|
|
if (_shouldUseCarouselLayout(context)) {
|
|
return _buildMobileLayout(context, state, localeKey);
|
|
}
|
|
|
|
return _buildDesktopLayout(context, state, localeKey);
|
|
}
|
|
|
|
/// 모바일 캐로셀 레이아웃
|
|
Widget _buildMobileLayout(
|
|
BuildContext context,
|
|
GameState state,
|
|
ValueKey<String> localeKey,
|
|
) {
|
|
return NotificationOverlay(
|
|
key: localeKey,
|
|
notificationService: _notificationService,
|
|
child: PopScope(
|
|
canPop: false,
|
|
onPopInvokedWithResult: (didPop, result) async {
|
|
if (didPop) return;
|
|
final shouldPop = await _onPopInvoked();
|
|
if (shouldPop && context.mounted) {
|
|
await widget.controller.pause(saveOnStop: false);
|
|
if (context.mounted) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
}
|
|
},
|
|
child: Stack(
|
|
children: [
|
|
MobileCarouselLayout(
|
|
state: state,
|
|
combatLogEntries: _combatLogController.entries,
|
|
speedMultiplier: widget.controller.loop?.speedMultiplier ?? 1,
|
|
onSpeedCycle: () {
|
|
widget.controller.loop?.cycleSpeed();
|
|
setState(() {});
|
|
},
|
|
onSetSpeed: (speed) {
|
|
widget.controller.loop?.setSpeed(speed);
|
|
setState(() {});
|
|
},
|
|
isPaused:
|
|
!widget.controller.isRunning && _specialAnimation == null,
|
|
onPauseToggle: () async {
|
|
await widget.controller.togglePause();
|
|
setState(() {});
|
|
},
|
|
onSave: _saveGameState,
|
|
onExit: () async {
|
|
final shouldExit = await _onPopInvoked();
|
|
if (shouldExit && context.mounted) {
|
|
await widget.controller.pause(saveOnStop: false);
|
|
if (context.mounted) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
}
|
|
},
|
|
notificationService: _notificationService,
|
|
specialAnimation: _specialAnimation,
|
|
onLanguageChange: (locale) async {
|
|
final navigator = Navigator.of(context);
|
|
await widget.controller.pause(saveOnStop: true);
|
|
game_l10n.setGameLocale(locale);
|
|
if (context.mounted) {
|
|
await widget.controller.resume();
|
|
navigator.pushReplacement(
|
|
MaterialPageRoute<void>(
|
|
builder: (_) => GamePlayScreen(
|
|
controller: widget.controller,
|
|
audioService: widget.audioService,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
},
|
|
onDeleteSaveAndNewGame: () async {
|
|
await widget.controller.pause(saveOnStop: false);
|
|
await widget.controller.saveManager.deleteSave();
|
|
if (context.mounted) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
},
|
|
bgmVolume: _audioController.bgmVolume,
|
|
sfxVolume: _audioController.sfxVolume,
|
|
onBgmVolumeChange: (volume) {
|
|
_audioController.setBgmVolume(volume);
|
|
setState(() {});
|
|
},
|
|
onSfxVolumeChange: (volume) {
|
|
_audioController.setSfxVolume(volume);
|
|
setState(() {});
|
|
},
|
|
onShowStatistics: () => _showStatisticsDialog(context),
|
|
onShowHelp: () => HelpDialog.show(context),
|
|
cheatsEnabled: widget.controller.cheatsEnabled,
|
|
onCheatTask: () => widget.controller.loop?.cheatCompleteTask(),
|
|
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);
|
|
}
|
|
},
|
|
autoReviveEndMs: widget.controller.monetization.autoReviveEndMs,
|
|
speedBoostEndMs: widget.controller.monetization.speedBoostEndMs,
|
|
isPaidUser: widget.controller.monetization.isPaidUser,
|
|
onSpeedBoostActivate: _handleSpeedBoost,
|
|
isSpeedBoostActive: widget.controller.isSpeedBoostActive,
|
|
adSpeedMultiplier: widget.controller.adSpeedMultiplier,
|
|
has2xUnlocked: widget.controller.has2xUnlocked,
|
|
),
|
|
..._buildOverlays(state),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 데스크톱 3패널 레이아웃
|
|
Widget _buildDesktopLayout(
|
|
BuildContext context,
|
|
GameState state,
|
|
ValueKey<String> localeKey,
|
|
) {
|
|
return NotificationOverlay(
|
|
key: localeKey,
|
|
notificationService: _notificationService,
|
|
child: PopScope(
|
|
canPop: false,
|
|
onPopInvokedWithResult: (didPop, result) async {
|
|
if (didPop) return;
|
|
final shouldPop = await _onPopInvoked();
|
|
if (shouldPop && context.mounted) {
|
|
await widget.controller.pause(saveOnStop: false);
|
|
if (context.mounted) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
}
|
|
},
|
|
child: Focus(
|
|
autofocus: true,
|
|
onKeyEvent: (node, event) => _handleKeyboardShortcut(event, context),
|
|
child: Scaffold(
|
|
backgroundColor: RetroColors.deepBrown,
|
|
appBar: _buildDesktopAppBar(context, state),
|
|
body: Stack(
|
|
children: [
|
|
_buildDesktopMainContent(state),
|
|
..._buildOverlays(state),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 데스크톱 앱바
|
|
PreferredSizeWidget _buildDesktopAppBar(
|
|
BuildContext context,
|
|
GameState state,
|
|
) {
|
|
return AppBar(
|
|
backgroundColor: RetroColors.darkBrown,
|
|
title: Text(
|
|
L10n.of(context).progressQuestTitle(state.traits.name),
|
|
style: const TextStyle(
|
|
fontFamily: 'PressStart2P',
|
|
fontSize: 15,
|
|
color: RetroColors.gold,
|
|
),
|
|
),
|
|
actions: [
|
|
if (widget.controller.cheatsEnabled) ...[
|
|
IconButton(
|
|
icon: const Text('L+1'),
|
|
tooltip: L10n.of(context).levelUp,
|
|
onPressed: () => widget.controller.loop?.cheatCompleteTask(),
|
|
),
|
|
IconButton(
|
|
icon: const Text('Q!'),
|
|
tooltip: L10n.of(context).completeQuest,
|
|
onPressed: () => widget.controller.loop?.cheatCompleteQuest(),
|
|
),
|
|
IconButton(
|
|
icon: const Text('P!'),
|
|
tooltip: L10n.of(context).completePlot,
|
|
onPressed: () => widget.controller.loop?.cheatCompletePlot(),
|
|
),
|
|
],
|
|
IconButton(
|
|
icon: const Icon(Icons.bar_chart),
|
|
tooltip: game_l10n.uiStatistics,
|
|
onPressed: () => _showStatisticsDialog(context),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.help_outline),
|
|
tooltip: game_l10n.uiHelp,
|
|
onPressed: () => HelpDialog.show(context),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.settings),
|
|
tooltip: game_l10n.uiSettings,
|
|
onPressed: () => _showSettingsScreen(context),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// 데스크톱 메인 컨텐츠 (3패널)
|
|
Widget _buildDesktopMainContent(GameState state) {
|
|
return Column(
|
|
children: [
|
|
TaskProgressPanel(
|
|
progress: state.progress,
|
|
speedMultiplier: widget.controller.loop?.speedMultiplier ?? 1,
|
|
onSpeedCycle: () {
|
|
widget.controller.loop?.cycleSpeed();
|
|
setState(() {});
|
|
},
|
|
isPaused: !widget.controller.isRunning && _specialAnimation == null,
|
|
onPauseToggle: () async {
|
|
await widget.controller.togglePause();
|
|
setState(() {});
|
|
},
|
|
specialAnimation: _specialAnimation,
|
|
weaponName: state.equipment.weapon,
|
|
shieldName: state.equipment.shield,
|
|
characterLevel: state.traits.level,
|
|
monsterLevel: state.progress.currentTask.monsterLevel,
|
|
monsterGrade: state.progress.currentTask.monsterGrade,
|
|
monsterSize: state.progress.currentTask.monsterSize,
|
|
latestCombatEvent:
|
|
state.progress.currentCombat?.recentEvents.lastOrNull,
|
|
raceId: state.traits.raceId,
|
|
),
|
|
Expanded(
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Expanded(flex: 2, child: DesktopCharacterPanel(state: state)),
|
|
Expanded(
|
|
flex: 3,
|
|
child: DesktopEquipmentPanel(
|
|
state: state,
|
|
combatLogEntries: _combatLogController.entries,
|
|
),
|
|
),
|
|
Expanded(flex: 2, child: DesktopQuestPanel(state: state)),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// 공통 오버레이 (사망, 승리)
|
|
List<Widget> _buildOverlays(GameState state) {
|
|
return [
|
|
if (state.isDead && state.deathInfo != null)
|
|
DeathOverlay(
|
|
deathInfo: state.deathInfo!,
|
|
traits: state.traits,
|
|
onResurrect: _handleResurrect,
|
|
onAdRevive: _handleAdRevive,
|
|
isPaidUser: IAPService.instance.isAdRemovalPurchased,
|
|
),
|
|
if (widget.controller.isComplete)
|
|
VictoryOverlay(
|
|
traits: state.traits,
|
|
stats: state.stats,
|
|
progress: state.progress,
|
|
elapsedMs: state.skillSystem.elapsedMs,
|
|
onComplete: _handleVictoryComplete,
|
|
),
|
|
];
|
|
}
|
|
|
|
/// 키보드 단축키 핸들러 (웹/데스크톱)
|
|
/// Space: 일시정지/재개, S: 속도 변경, H: 도움말, Esc: 설정
|
|
KeyEventResult _handleKeyboardShortcut(KeyEvent event, BuildContext context) {
|
|
// KeyDown 이벤트만 처리
|
|
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
|
|
|
final key = event.logicalKey;
|
|
|
|
// Space: 일시정지/재개
|
|
if (key == LogicalKeyboardKey.space) {
|
|
widget.controller.togglePause();
|
|
setState(() {});
|
|
return KeyEventResult.handled;
|
|
}
|
|
|
|
// S: 속도 변경
|
|
if (key == LogicalKeyboardKey.keyS) {
|
|
widget.controller.loop?.cycleSpeed();
|
|
setState(() {});
|
|
return KeyEventResult.handled;
|
|
}
|
|
|
|
// H 또는 F1: 도움말
|
|
if (key == LogicalKeyboardKey.keyH || key == LogicalKeyboardKey.f1) {
|
|
HelpDialog.show(context);
|
|
return KeyEventResult.handled;
|
|
}
|
|
|
|
// Escape: 설정
|
|
if (key == LogicalKeyboardKey.escape) {
|
|
_showSettingsScreen(context);
|
|
return KeyEventResult.handled;
|
|
}
|
|
|
|
// I: 통계
|
|
if (key == LogicalKeyboardKey.keyI) {
|
|
_showStatisticsDialog(context);
|
|
return KeyEventResult.handled;
|
|
}
|
|
|
|
return KeyEventResult.ignored;
|
|
}
|
|
}
|