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/data/skill_data.dart'; import 'package:asciineverdie/data/story_data.dart'; import 'package:asciineverdie/l10n/app_localizations.dart'; import 'package:asciineverdie/src/core/animation/ascii_animation_type.dart'; import 'package:asciineverdie/src/core/engine/story_service.dart'; import 'package:asciineverdie/src/core/l10n/game_data_l10n.dart'; import 'package:asciineverdie/src/core/model/game_state.dart'; import 'package:asciineverdie/src/core/model/skill.dart'; import 'package:asciineverdie/src/core/notification/notification_service.dart'; import 'package:asciineverdie/src/core/util/pq_logic.dart' as pq_logic; 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/combat_log.dart'; import 'package:asciineverdie/src/features/game/widgets/death_overlay.dart'; import 'package:asciineverdie/src/features/game/widgets/victory_overlay.dart'; import 'package:asciineverdie/src/features/game/widgets/hp_mp_bar.dart'; import 'package:asciineverdie/src/features/game/widgets/notification_overlay.dart'; import 'package:asciineverdie/src/features/game/widgets/stats_panel.dart'; import 'package:asciineverdie/src/features/game/widgets/equipment_stats_panel.dart'; import 'package:asciineverdie/src/features/game/widgets/potion_inventory_panel.dart'; import 'package:asciineverdie/src/features/game/widgets/task_progress_panel.dart'; import 'package:asciineverdie/src/features/game/widgets/active_buff_panel.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, this.onThemeModeChange, this.currentThemeMode = ThemeMode.system, }); final GameSessionController controller; /// 오디오 서비스 (BGM/SFX 재생) final AudioService? audioService; /// 테스트 모드: 웹에서도 모바일 캐로셀 레이아웃 강제 사용 final bool forceCarouselLayout; /// 테스트 모드: 모바일에서도 데스크톱 3패널 레이아웃 강제 사용 final bool forceDesktopLayout; /// 테마 모드 변경 콜백 final void Function(ThemeMode mode)? onThemeModeChange; /// 현재 테마 모드 final ThemeMode currentThemeMode; @override State createState() => _GamePlayScreenState(); } class _GamePlayScreenState extends State 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 _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(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(); } @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); 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(); } } // 모바일: 앱이 포그라운드로 돌아올 때 전체 재로드 if (appState == AppLifecycleState.resumed && isMobile) { _audioController.resumeAll(); _reloadGameScreen(); } } /// 모바일 재진입 시 전체 화면 재로드 Future _reloadGameScreen() async { // 세이브 파일에서 다시 로드 (치트 모드는 저장된 상태에서 복원) await widget.controller.loadAndStart(); if (!mounted) return; // 화면 재생성 (상태 초기화) Navigator.of(context).pushReplacement( MaterialPageRoute( builder: (_) => GamePlayScreen( controller: widget.controller, audioService: widget.audioService, currentThemeMode: widget.currentThemeMode, onThemeModeChange: widget.onThemeModeChange, ), ), ); } Future _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 _onPopInvoked() async { final l10n = L10n.of(context); final shouldPop = await showDialog( 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; } /// 통계 다이얼로그 표시 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, currentThemeMode: widget.currentThemeMode, onThemeModeChange: (mode) { widget.onThemeModeChange?.call(mode); }, 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( builder: (_) => GamePlayScreen( controller: widget.controller, audioService: widget.audioService, currentThemeMode: widget.currentThemeMode, onThemeModeChange: widget.onThemeModeChange, ), ), ); } }, 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 _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; }); } } }); } @override Widget build(BuildContext context) { final state = widget.controller.state; if (state == null) { return const Scaffold(body: Center(child: CircularProgressIndicator())); } // 로케일 변경 시 전체 위젯 트리 강제 리빌드를 위한 Key final localeKey = ValueKey(game_l10n.currentGameLocale); // 캐로셀 레이아웃 사용 여부 확인 if (_shouldUseCarouselLayout(context)) { 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(() {}); }, // 특수 애니메이션 중에는 일시정지 상태로 표시하지 않음 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 { // navigator 참조를 async gap 전에 저장 final navigator = Navigator.of(context); // 1. 현재 상태 저장 await widget.controller.pause(saveOnStop: true); // 2. 로케일 변경 game_l10n.setGameLocale(locale); // 3. 화면 재생성 (전체 UI 재구성) if (context.mounted) { await widget.controller.resume(); navigator.pushReplacement( MaterialPageRoute( builder: (_) => GamePlayScreen( controller: widget.controller, audioService: widget.audioService, currentThemeMode: widget.currentThemeMode, onThemeModeChange: widget.onThemeModeChange, ), ), ); } }, onDeleteSaveAndNewGame: () async { // 게임 루프 중지 await widget.controller.pause(saveOnStop: false); // 세이브 파일 삭제 await widget.controller.saveManager.deleteSave(); // 캐릭터 생성 화면으로 돌아가기 if (context.mounted) { Navigator.of(context).pop(); } }, currentThemeMode: widget.currentThemeMode, onThemeModeChange: widget.onThemeModeChange, // 사운드 설정 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); } }, ), // 사망 오버레이 if (state.isDead && state.deathInfo != null) DeathOverlay( deathInfo: state.deathInfo!, traits: state.traits, onResurrect: _handleResurrect, isAutoResurrectEnabled: widget.controller.autoResurrect, onToggleAutoResurrect: () { widget.controller.setAutoResurrect( !widget.controller.autoResurrect, ); }, ), // 승리 오버레이 (게임 클리어) if (widget.controller.isComplete) VictoryOverlay( traits: state.traits, stats: state.stats, progress: state.progress, elapsedMs: state.skillSystem.elapsedMs, onComplete: _handleVictoryComplete, ), ], ), ), ); } // 기존 데스크톱 레이아웃 (레트로 스타일) 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: AppBar( backgroundColor: RetroColors.darkBrown, title: Text( L10n.of(context).progressQuestTitle(state.traits.name), style: const TextStyle( fontFamily: 'PressStart2P', fontSize: 12, 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), ), ], ), body: Stack( children: [ // 메인 게임 UI Column( children: [ // 상단: ASCII 애니메이션 + Task Progress (Phase 7: 고정 4색 팔레트) 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, latestCombatEvent: state.progress.currentCombat?.recentEvents.lastOrNull, raceId: state.traits.raceId, ), // 메인 3패널 영역 Expanded( child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // 좌측 패널: Character Sheet Expanded(flex: 2, child: _buildCharacterPanel(state)), // 중앙 패널: Equipment/Inventory Expanded(flex: 3, child: _buildEquipmentPanel(state)), // 우측 패널: Plot/Quest Expanded(flex: 2, child: _buildQuestPanel(state)), ], ), ), ], ), // Phase 4: 사망 오버레이 (Death Overlay) if (state.isDead && state.deathInfo != null) DeathOverlay( deathInfo: state.deathInfo!, traits: state.traits, onResurrect: _handleResurrect, isAutoResurrectEnabled: widget.controller.autoResurrect, onToggleAutoResurrect: () { widget.controller.setAutoResurrect( !widget.controller.autoResurrect, ); }, ), // 승리 오버레이 (게임 클리어) 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; } /// 좌측 패널: Character Sheet (Traits, Stats, Experience, Spells) Widget _buildCharacterPanel(GameState state) { final l10n = L10n.of(context); return Card( margin: const EdgeInsets.all(4), color: RetroColors.panelBg, shape: RoundedRectangleBorder( side: const BorderSide(color: RetroColors.panelBorderOuter, width: 2), borderRadius: BorderRadius.circular(0), ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _buildPanelHeader(l10n.characterSheet), // Traits 목록 _buildSectionHeader(l10n.traits), _buildTraitsList(state), // Stats 목록 (Phase 8: 애니메이션 변화 표시) _buildSectionHeader(l10n.stats), Expanded(flex: 2, child: StatsPanel(stats: state.stats)), // Phase 8: HP/MP 바 (사망 위험 시 깜빡임, 전투 중 몬스터 HP 표시) // 전투 중에는 전투 스탯의 HP/MP 사용, 비전투 시 기본 스탯 사용 HpMpBar( hpCurrent: state.progress.currentCombat?.playerStats.hpCurrent ?? state.stats.hp, hpMax: state.progress.currentCombat?.playerStats.hpMax ?? state.stats.hpMax, mpCurrent: state.progress.currentCombat?.playerStats.mpCurrent ?? state.stats.mp, mpMax: state.progress.currentCombat?.playerStats.mpMax ?? state.stats.mpMax, // 전투 중일 때 몬스터 HP 정보 전달 monsterHpCurrent: state.progress.currentCombat?.monsterStats.hpCurrent, monsterHpMax: state.progress.currentCombat?.monsterStats.hpMax, monsterName: state.progress.currentCombat?.monsterStats.name, monsterLevel: state.progress.currentCombat?.monsterStats.level, ), // Experience 바 _buildSectionHeader(l10n.experience), _buildProgressBar( state.progress.exp.position, state.progress.exp.max, Colors.blue, tooltip: '${state.progress.exp.position} / ${state.progress.exp.max}', ), // 스킬 (Skills - SpellBook 기반) _buildSectionHeader(l10n.spellBook), Expanded(flex: 3, child: _buildSkillsList(state)), // 활성 버프 (Active Buffs) _buildSectionHeader(game_l10n.uiBuffs), Expanded( child: ActiveBuffPanel( activeBuffs: state.skillSystem.activeBuffs, currentMs: state.skillSystem.elapsedMs, ), ), ], ), ); } /// 중앙 패널: Equipment/Inventory Widget _buildEquipmentPanel(GameState state) { final l10n = L10n.of(context); return Card( margin: const EdgeInsets.all(4), color: RetroColors.panelBg, shape: RoundedRectangleBorder( side: const BorderSide(color: RetroColors.panelBorderOuter, width: 2), borderRadius: BorderRadius.circular(0), ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _buildPanelHeader(l10n.equipment), // Equipment 목록 (확장 가능 스탯 패널) Expanded( flex: 2, child: EquipmentStatsPanel(equipment: state.equipment), ), // Inventory _buildPanelHeader(l10n.inventory), Expanded(child: _buildInventoryList(state)), // Potions (물약 인벤토리) _buildSectionHeader(game_l10n.uiPotions), Expanded( child: PotionInventoryPanel( inventory: state.potionInventory, usedInBattle: state.progress.currentCombat?.usedPotionTypes ?? const {}, ), ), // Encumbrance 바 _buildSectionHeader(l10n.encumbrance), _buildProgressBar( state.progress.encumbrance.position, state.progress.encumbrance.max, Colors.orange, ), // Phase 8: 전투 로그 (Combat Log) _buildPanelHeader(l10n.combatLog), Expanded(flex: 2, child: CombatLog(entries: _combatLogController.entries)), ], ), ); } /// 우측 패널: Plot/Quest Widget _buildQuestPanel(GameState state) { final l10n = L10n.of(context); return Card( margin: const EdgeInsets.all(4), color: RetroColors.panelBg, shape: RoundedRectangleBorder( side: const BorderSide(color: RetroColors.panelBorderOuter, width: 2), borderRadius: BorderRadius.circular(0), ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _buildPanelHeader(l10n.plotDevelopment), // Plot 목록 Expanded(child: _buildPlotList(state)), // Plot 바 _buildProgressBar( state.progress.plot.position, state.progress.plot.max, Colors.purple, tooltip: state.progress.plot.max > 0 ? '${pq_logic.roughTime(state.progress.plot.max - state.progress.plot.position)} remaining' : null, ), _buildPanelHeader(l10n.quests), // Quest 목록 Expanded(child: _buildQuestList(state)), // Quest 바 _buildProgressBar( state.progress.quest.position, state.progress.quest.max, Colors.green, tooltip: state.progress.quest.max > 0 ? l10n.percentComplete( 100 * state.progress.quest.position ~/ state.progress.quest.max, ) : null, ), ], ), ); } Widget _buildPanelHeader(String title) { return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), decoration: const BoxDecoration( color: RetroColors.darkBrown, border: Border(bottom: BorderSide(color: RetroColors.gold, width: 2)), ), child: Text( title.toUpperCase(), style: const TextStyle( fontFamily: 'PressStart2P', fontSize: 8, fontWeight: FontWeight.bold, color: RetroColors.gold, ), ), ); } Widget _buildSectionHeader(String title) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: Text( title.toUpperCase(), style: const TextStyle( fontFamily: 'PressStart2P', fontSize: 8, color: RetroColors.textDisabled, ), ), ); } /// 레트로 스타일 세그먼트 프로그레스 바 Widget _buildProgressBar( int position, int max, Color color, { String? tooltip, }) { final progress = max > 0 ? (position / max).clamp(0.0, 1.0) : 0.0; const segmentCount = 20; final filledSegments = (progress * segmentCount).round(); final bar = Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: Container( height: 12, decoration: BoxDecoration( color: color.withValues(alpha: 0.15), border: Border.all(color: RetroColors.panelBorderOuter, width: 1), ), child: Row( children: List.generate(segmentCount, (index) { final isFilled = index < filledSegments; return Expanded( child: Container( decoration: BoxDecoration( color: isFilled ? color : color.withValues(alpha: 0.1), border: Border( right: index < segmentCount - 1 ? BorderSide( color: RetroColors.panelBorderOuter.withValues( alpha: 0.3, ), width: 1, ) : BorderSide.none, ), ), ), ); }), ), ), ); if (tooltip != null && tooltip.isNotEmpty) { return Tooltip(message: tooltip, child: bar); } return bar; } Widget _buildTraitsList(GameState state) { final l10n = L10n.of(context); final traits = [ (l10n.traitName, state.traits.name), (l10n.traitRace, GameDataL10n.getRaceName(context, state.traits.race)), (l10n.traitClass, GameDataL10n.getKlassName(context, state.traits.klass)), (l10n.traitLevel, '${state.traits.level}'), ]; return Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), child: Column( children: traits.map((t) { return Padding( padding: const EdgeInsets.symmetric(vertical: 1), child: Row( children: [ SizedBox( width: 50, child: Text( t.$1.toUpperCase(), style: const TextStyle( fontFamily: 'PressStart2P', fontSize: 8, color: RetroColors.textDisabled, ), ), ), Expanded( child: Text( t.$2, style: const TextStyle( fontFamily: 'PressStart2P', fontSize: 9, color: RetroColors.textLight, ), overflow: TextOverflow.ellipsis, ), ), ], ), ); }).toList(), ), ); } /// 통합 스킬 목록 (SkillBook 기반) /// /// 스킬 이름, 랭크, 스킬 타입, 쿨타임 표시 Widget _buildSkillsList(GameState state) { if (state.skillBook.skills.isEmpty) { return Center( child: Text( L10n.of(context).noSpellsYet, style: const TextStyle( fontFamily: 'PressStart2P', fontSize: 9, color: RetroColors.textDisabled, ), ), ); } return ListView.builder( itemCount: state.skillBook.skills.length, padding: const EdgeInsets.symmetric(horizontal: 8), itemBuilder: (context, index) { final skillEntry = state.skillBook.skills[index]; final skill = SkillData.getSkillBySpellName(skillEntry.name); final skillName = GameDataL10n.getSpellName(context, skillEntry.name); // 쿨타임 상태 확인 final skillState = skill != null ? state.skillSystem.getSkillState(skill.id) : null; final isOnCooldown = skillState != null && !skillState.isReady(state.skillSystem.elapsedMs, skill!.cooldownMs); return _SkillRow( skillName: skillName, rank: skillEntry.rank, skill: skill, isOnCooldown: isOnCooldown, ); }, ); } Widget _buildInventoryList(GameState state) { final l10n = L10n.of(context); if (state.inventory.items.isEmpty) { return Center( child: Text( l10n.goldAmount(state.inventory.gold), style: const TextStyle( fontFamily: 'PressStart2P', fontSize: 9, color: RetroColors.gold, ), ), ); } return ListView.builder( itemCount: state.inventory.items.length + 1, // +1 for gold padding: const EdgeInsets.symmetric(horizontal: 8), itemBuilder: (context, index) { if (index == 0) { return Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: Row( children: [ const Icon( Icons.monetization_on, size: 10, color: RetroColors.gold, ), const SizedBox(width: 4), Expanded( child: Text( l10n.gold.toUpperCase(), style: const TextStyle( fontFamily: 'PressStart2P', fontSize: 8, color: RetroColors.gold, ), ), ), Text( '${state.inventory.gold}', style: const TextStyle( fontFamily: 'PressStart2P', fontSize: 9, color: RetroColors.gold, ), ), ], ), ); } final item = state.inventory.items[index - 1]; // 아이템 이름 번역 final translatedName = GameDataL10n.translateItemString( context, item.name, ); return Padding( padding: const EdgeInsets.symmetric(vertical: 1), child: Row( children: [ Expanded( child: Text( translatedName, style: const TextStyle( fontFamily: 'PressStart2P', fontSize: 8, color: RetroColors.textLight, ), overflow: TextOverflow.ellipsis, ), ), Text( '${item.count}', style: const TextStyle( fontFamily: 'PressStart2P', fontSize: 8, color: RetroColors.cream, ), ), ], ), ); }, ); } Widget _buildPlotList(GameState state) { // 플롯 단계를 표시 (Act I, Act II, ...) final l10n = L10n.of(context); final plotCount = state.progress.plotStageCount; if (plotCount == 0) { return Center( child: Text( l10n.prologue.toUpperCase(), style: const TextStyle( fontFamily: 'PressStart2P', fontSize: 9, color: RetroColors.textDisabled, ), ), ); } return ListView.builder( itemCount: plotCount, padding: const EdgeInsets.symmetric(horizontal: 8), itemBuilder: (context, index) { final isCompleted = index < plotCount - 1; final isCurrent = index == plotCount - 1; return Padding( padding: const EdgeInsets.symmetric(vertical: 1), child: Row( children: [ Icon( isCompleted ? Icons.check_box : (isCurrent ? Icons.arrow_right : Icons.check_box_outline_blank), size: 12, color: isCompleted ? RetroColors.expGreen : (isCurrent ? RetroColors.gold : RetroColors.textDisabled), ), const SizedBox(width: 4), Expanded( child: Text( index == 0 ? l10n.prologue : l10n.actNumber(_toRoman(index)), style: TextStyle( fontFamily: 'PressStart2P', fontSize: 8, color: isCompleted ? RetroColors.textDisabled : (isCurrent ? RetroColors.gold : RetroColors.textLight), decoration: isCompleted ? TextDecoration.lineThrough : TextDecoration.none, ), ), ), ], ), ); }, ); } Widget _buildQuestList(GameState state) { final l10n = L10n.of(context); final questHistory = state.progress.questHistory; if (questHistory.isEmpty) { return Center( child: Text( l10n.noActiveQuests.toUpperCase(), style: const TextStyle( fontFamily: 'PressStart2P', fontSize: 9, color: RetroColors.textDisabled, ), ), ); } // 원본처럼 퀘스트 히스토리를 리스트로 표시 // 완료된 퀘스트는 체크박스, 현재 퀘스트는 화살표 return ListView.builder( itemCount: questHistory.length, padding: const EdgeInsets.symmetric(horizontal: 8), itemBuilder: (context, index) { final quest = questHistory[index]; final isCurrentQuest = index == questHistory.length - 1 && !quest.isComplete; return Padding( padding: const EdgeInsets.symmetric(vertical: 1), child: Row( children: [ Icon( isCurrentQuest ? Icons.arrow_right : (quest.isComplete ? Icons.check_box : Icons.check_box_outline_blank), size: 12, color: isCurrentQuest ? RetroColors.gold : (quest.isComplete ? RetroColors.expGreen : RetroColors.textDisabled), ), const SizedBox(width: 4), Expanded( child: Text( quest.caption, style: TextStyle( fontFamily: 'PressStart2P', fontSize: 8, color: isCurrentQuest ? RetroColors.gold : (quest.isComplete ? RetroColors.textDisabled : RetroColors.textLight), decoration: quest.isComplete ? TextDecoration.lineThrough : TextDecoration.none, ), overflow: TextOverflow.ellipsis, ), ), ], ), ); }, ); } /// 로마 숫자 변환 (간단 버전) String _toRoman(int number) { const romanNumerals = [ (1000, 'M'), (900, 'CM'), (500, 'D'), (400, 'CD'), (100, 'C'), (90, 'XC'), (50, 'L'), (40, 'XL'), (10, 'X'), (9, 'IX'), (5, 'V'), (4, 'IV'), (1, 'I'), ]; var result = ''; var remaining = number; for (final (value, numeral) in romanNumerals) { while (remaining >= value) { result += numeral; remaining -= value; } } return result; } } /// 스킬 행 위젯 /// /// 스킬 이름, 랭크, 스킬 타입 아이콘, 쿨타임 상태 표시 class _SkillRow extends StatelessWidget { const _SkillRow({ required this.skillName, required this.rank, required this.skill, required this.isOnCooldown, }); final String skillName; final String rank; final Skill? skill; final bool isOnCooldown; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(vertical: 1), child: Row( children: [ // 스킬 타입 아이콘 _buildTypeIcon(), const SizedBox(width: 4), // 스킬 이름 Expanded( child: Text( skillName, style: TextStyle( fontSize: 11, color: isOnCooldown ? Colors.grey : null, ), overflow: TextOverflow.ellipsis, ), ), // 쿨타임 표시 if (isOnCooldown) const Icon(Icons.hourglass_empty, size: 10, color: Colors.orange), const SizedBox(width: 4), // 랭크 Text( rank, style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold), ), ], ), ); } /// 스킬 타입별 아이콘 Widget _buildTypeIcon() { if (skill == null) { return const SizedBox(width: 12); } final (IconData icon, Color color) = switch (skill!.type) { SkillType.attack => (Icons.flash_on, Colors.red), SkillType.heal => (Icons.favorite, Colors.green), SkillType.buff => (Icons.arrow_upward, Colors.blue), SkillType.debuff => (Icons.arrow_downward, Colors.purple), }; return Icon(icon, size: 12, color: color); } }