Files
asciinevrdie/lib/src/features/game/game_play_screen.dart
JiWoong Sul 19faa9ea39 feat(ui): 게임 화면 및 UI 컴포넌트 개선
- front_screen: 프론트 화면 UI 업데이트
- game_play_screen: 게임 플레이 화면 수정
- game_session_controller: 세션 관리 로직 개선
- mobile_carousel_layout: 모바일 캐러셀 레이아웃 개선
- enhanced_animation_panel: 애니메이션 패널 업데이트
- help_dialog: 도움말 다이얼로그 수정
- return_rewards_dialog: 복귀 보상 다이얼로그 개선
- new_character_screen: 새 캐릭터 화면 수정
- settings_screen: 설정 화면 업데이트
2026-01-19 15:50:35 +09:00

1551 lines
52 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/data/skill_data.dart';
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/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/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();
}
}
// 모바일: 앱이 포그라운드로 돌아올 때 전체 재로드
if (appState == AppLifecycleState.resumed && isMobile) {
_audioController.resumeAll();
_reloadGameScreen();
}
}
/// 모바일 재진입 시 전체 화면 재로드
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()));
}
// 로케일 변경 시 전체 위젯 트리 강제 리빌드를 위한 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(() {});
},
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 {
// 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<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,
adSpeedMultiplier: widget.controller.adSpeedMultiplier,
has2xUnlocked: widget.controller.has2xUnlocked,
),
// 사망 오버레이
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,
),
],
),
),
);
}
// 기존 데스크톱 레이아웃 (레트로 스타일)
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: 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),
),
],
),
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,
monsterSize: state.progress.currentTask.monsterSize,
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)),
],
),
),
],
),
// 사망 오버레이
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;
}
/// 좌측 패널: 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,
),
),
// 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: 13,
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: 13,
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: 13,
color: RetroColors.textDisabled,
),
),
),
Expanded(
child: Text(
t.$2,
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
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: 14,
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: 14,
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: 13,
color: RetroColors.gold,
),
),
),
Text(
'${state.inventory.gold}',
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
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: 13,
color: RetroColors.textLight,
),
overflow: TextOverflow.ellipsis,
),
),
Text(
'${item.count}',
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 13,
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: 14,
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: 13,
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: 14,
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: 13,
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: 16,
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: 16, 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);
}
}