import 'package:flutter/material.dart'; import 'package:askiineverdie/l10n/app_localizations.dart'; import 'package:askiineverdie/src/core/animation/ascii_animation_data.dart'; import 'package:askiineverdie/src/core/animation/ascii_animation_type.dart'; import 'package:askiineverdie/src/core/model/game_state.dart'; import 'package:askiineverdie/src/core/storage/theme_preferences.dart'; import 'package:askiineverdie/src/core/util/pq_logic.dart' as pq_logic; import 'package:askiineverdie/src/features/game/game_session_controller.dart'; import 'package:askiineverdie/src/features/game/widgets/task_progress_panel.dart'; /// 게임 진행 화면 (Main.dfm 기반 3패널 레이아웃) class GamePlayScreen extends StatefulWidget { const GamePlayScreen({super.key, required this.controller}); final GameSessionController controller; @override State createState() => _GamePlayScreenState(); } class _GamePlayScreenState extends State with WidgetsBindingObserver { AsciiColorTheme _colorTheme = AsciiColorTheme.green; AsciiAnimationType? _specialAnimation; // 이전 상태 추적 (레벨업/퀘스트/Act 완료 감지용) int _lastLevel = 0; int _lastQuestCount = 0; int _lastPlotStageCount = 0; void _cycleColorTheme() { setState(() { _colorTheme = switch (_colorTheme) { AsciiColorTheme.green => AsciiColorTheme.amber, AsciiColorTheme.amber => AsciiColorTheme.white, AsciiColorTheme.white => AsciiColorTheme.system, AsciiColorTheme.system => AsciiColorTheme.green, }; }); // 테마 변경 시 저장 ThemePreferences.saveColorTheme(_colorTheme); } Future _loadColorTheme() async { final theme = await ThemePreferences.loadColorTheme(); if (mounted) { setState(() { _colorTheme = theme; }); } } void _checkSpecialEvents(GameState state) { // 레벨업 감지 if (state.traits.level > _lastLevel && _lastLevel > 0) { _specialAnimation = AsciiAnimationType.levelUp; _resetSpecialAnimationAfterFrame(); } _lastLevel = state.traits.level; // 퀘스트 완료 감지 if (state.progress.questCount > _lastQuestCount && _lastQuestCount > 0) { _specialAnimation = AsciiAnimationType.questComplete; _resetSpecialAnimationAfterFrame(); } _lastQuestCount = state.progress.questCount; // Act 완료 감지 (plotStageCount 증가) if (state.progress.plotStageCount > _lastPlotStageCount && _lastPlotStageCount > 0) { _specialAnimation = AsciiAnimationType.actComplete; _resetSpecialAnimationAfterFrame(); } _lastPlotStageCount = state.progress.plotStageCount; } void _resetSpecialAnimationAfterFrame() { // 다음 프레임에서 리셋 (AsciiAnimationCard가 값을 받은 후) WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { setState(() { _specialAnimation = null; }); } }); } @override void initState() { super.initState(); widget.controller.addListener(_onControllerChanged); WidgetsBinding.instance.addObserver(this); _loadColorTheme(); // 초기 상태 설정 final state = widget.controller.state; if (state != null) { _lastLevel = state.traits.level; _lastQuestCount = state.progress.questCount; _lastPlotStageCount = state.progress.plotStageCount; } } @override void dispose() { WidgetsBinding.instance.removeObserver(this); widget.controller.removeListener(_onControllerChanged); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { super.didChangeAppLifecycleState(state); // 앱이 백그라운드로 가거나 비활성화될 때 자동 저장 if (state == AppLifecycleState.paused || state == AppLifecycleState.inactive || state == AppLifecycleState.detached) { _saveGameState(); } } Future _saveGameState() async { final currentState = widget.controller.state; if (currentState == null || !widget.controller.isRunning) return; await widget.controller.saveManager.saveState(currentState); } /// 뒤로가기 시 저장 확인 다이얼로그 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); } setState(() {}); } @override Widget build(BuildContext context) { final state = widget.controller.state; if (state == null) { return const Scaffold(body: Center(child: CircularProgressIndicator())); } return 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: Scaffold( appBar: AppBar( title: Text(L10n.of(context).progressQuestTitle(state.traits.name)), 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(), ), ], ], ), body: Column( children: [ // 상단: ASCII 애니메이션 + Task Progress TaskProgressPanel( progress: state.progress, speedMultiplier: widget.controller.loop?.speedMultiplier ?? 1, onSpeedCycle: () { widget.controller.loop?.cycleSpeed(); setState(() {}); }, colorTheme: _colorTheme, onThemeCycle: _cycleColorTheme, specialAnimation: _specialAnimation, ), // 메인 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)), ], ), ), ], ), ), ); } /// 좌측 패널: Character Sheet (Traits, Stats, Experience, Spells) Widget _buildCharacterPanel(GameState state) { final l10n = L10n.of(context); return Card( margin: const EdgeInsets.all(4), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _buildPanelHeader(l10n.characterSheet), // Traits 목록 _buildSectionHeader(l10n.traits), _buildTraitsList(state), // Stats 목록 _buildSectionHeader(l10n.stats), Expanded(flex: 2, child: _buildStatsList(state)), // Experience 바 _buildSectionHeader(l10n.experience), _buildProgressBar( state.progress.exp.position, state.progress.exp.max, Colors.blue, tooltip: '${state.progress.exp.max - state.progress.exp.position} ' '${l10n.xpNeededForNextLevel}', ), // Spell Book _buildSectionHeader(l10n.spellBook), Expanded(flex: 2, child: _buildSpellsList(state)), ], ), ); } /// 중앙 패널: Equipment/Inventory Widget _buildEquipmentPanel(GameState state) { final l10n = L10n.of(context); return Card( margin: const EdgeInsets.all(4), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _buildPanelHeader(l10n.equipment), // Equipment 목록 Expanded(flex: 2, child: _buildEquipmentList(state)), // Inventory _buildPanelHeader(l10n.inventory), Expanded(flex: 3, child: _buildInventoryList(state)), // Encumbrance 바 _buildSectionHeader(l10n.encumbrance), _buildProgressBar( state.progress.encumbrance.position, state.progress.encumbrance.max, Colors.orange, ), ], ), ); } /// 우측 패널: Plot/Quest Widget _buildQuestPanel(GameState state) { final l10n = L10n.of(context); return Card( margin: const EdgeInsets.all(4), 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: 4), color: Theme.of(context).colorScheme.primaryContainer, child: Text( title, style: TextStyle( fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onPrimaryContainer, ), ), ); } Widget _buildSectionHeader(String title) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), child: Text(title, style: Theme.of(context).textTheme.labelSmall), ); } Widget _buildProgressBar( int position, int max, Color color, { String? tooltip, }) { final progress = max > 0 ? (position / max).clamp(0.0, 1.0) : 0.0; final bar = Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: LinearProgressIndicator( value: progress, backgroundColor: color.withValues(alpha: 0.2), valueColor: AlwaysStoppedAnimation(color), minHeight: 12, ), ); 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, state.traits.race), (l10n.traitClass, state.traits.klass), (l10n.traitLevel, '${state.traits.level}'), ]; return Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: Column( children: traits.map((t) { return Row( children: [ SizedBox( width: 50, child: Text(t.$1, style: const TextStyle(fontSize: 11)), ), Expanded( child: Text( t.$2, style: const TextStyle( fontSize: 11, fontWeight: FontWeight.bold, ), overflow: TextOverflow.ellipsis, ), ), ], ); }).toList(), ), ); } Widget _buildStatsList(GameState state) { final l10n = L10n.of(context); final stats = [ (l10n.statStr, state.stats.str), (l10n.statCon, state.stats.con), (l10n.statDex, state.stats.dex), (l10n.statInt, state.stats.intelligence), (l10n.statWis, state.stats.wis), (l10n.statCha, state.stats.cha), (l10n.statHpMax, state.stats.hpMax), (l10n.statMpMax, state.stats.mpMax), ]; return ListView.builder( itemCount: stats.length, padding: const EdgeInsets.symmetric(horizontal: 8), itemBuilder: (context, index) { final stat = stats[index]; return Row( children: [ SizedBox( width: 50, child: Text(stat.$1, style: const TextStyle(fontSize: 11)), ), Text( '${stat.$2}', style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold), ), ], ); }, ); } Widget _buildSpellsList(GameState state) { if (state.spellBook.spells.isEmpty) { return Center( child: Text(L10n.of(context).noSpellsYet, style: const TextStyle(fontSize: 11)), ); } return ListView.builder( itemCount: state.spellBook.spells.length, padding: const EdgeInsets.symmetric(horizontal: 8), itemBuilder: (context, index) { final spell = state.spellBook.spells[index]; return Row( children: [ Expanded( child: Text( spell.name, style: const TextStyle(fontSize: 11), overflow: TextOverflow.ellipsis, ), ), Text( spell.rank, style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold), ), ], ); }, ); } Widget _buildEquipmentList(GameState state) { // 원본 Main.dfm Equips ListView - 11개 슬롯 final l10n = L10n.of(context); final equipment = [ (l10n.equipWeapon, state.equipment.weapon), (l10n.equipShield, state.equipment.shield), (l10n.equipHelm, state.equipment.helm), (l10n.equipHauberk, state.equipment.hauberk), (l10n.equipBrassairts, state.equipment.brassairts), (l10n.equipVambraces, state.equipment.vambraces), (l10n.equipGauntlets, state.equipment.gauntlets), (l10n.equipGambeson, state.equipment.gambeson), (l10n.equipCuisses, state.equipment.cuisses), (l10n.equipGreaves, state.equipment.greaves), (l10n.equipSollerets, state.equipment.sollerets), ]; return ListView.builder( itemCount: equipment.length, padding: const EdgeInsets.symmetric(horizontal: 8), itemBuilder: (context, index) { final equip = equipment[index]; return Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: Row( children: [ SizedBox( width: 60, child: Text(equip.$1, style: const TextStyle(fontSize: 11)), ), Expanded( child: Text( equip.$2.isNotEmpty ? equip.$2 : '-', style: const TextStyle( fontSize: 11, fontWeight: FontWeight.bold, ), overflow: TextOverflow.ellipsis, ), ), ], ), ); }, ); } 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(fontSize: 11), ), ); } 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 Row( children: [ Expanded( child: Text(l10n.gold, style: const TextStyle(fontSize: 11)), ), Text( '${state.inventory.gold}', style: const TextStyle( fontSize: 11, fontWeight: FontWeight.bold, ), ), ], ); } final item = state.inventory.items[index - 1]; return Row( children: [ Expanded( child: Text( item.name, style: const TextStyle(fontSize: 11), overflow: TextOverflow.ellipsis, ), ), Text( '${item.count}', style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold), ), ], ); }, ); } 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, style: const TextStyle(fontSize: 11)), ); } return ListView.builder( itemCount: plotCount, padding: const EdgeInsets.symmetric(horizontal: 8), itemBuilder: (context, index) { final isCompleted = index < plotCount - 1; return Row( children: [ Icon( isCompleted ? Icons.check_box : Icons.check_box_outline_blank, size: 14, color: isCompleted ? Colors.green : Colors.grey, ), const SizedBox(width: 4), Text( index == 0 ? l10n.prologue : l10n.actNumber(_toRoman(index)), style: TextStyle( fontSize: 11, decoration: isCompleted ? TextDecoration.lineThrough : TextDecoration.none, ), ), ], ); }, ); } Widget _buildQuestList(GameState state) { final l10n = L10n.of(context); final questCount = state.progress.questCount; if (questCount == 0) { return Center( child: Text(l10n.noActiveQuests, style: const TextStyle(fontSize: 11)), ); } // 현재 퀘스트 캡션이 있으면 표시 final currentTask = state.progress.currentTask; return ListView( padding: const EdgeInsets.symmetric(horizontal: 8), children: [ Row( children: [ const Icon(Icons.arrow_right, size: 14), Expanded( child: Text( currentTask.caption.isNotEmpty ? currentTask.caption : l10n.questNumber(questCount), style: const TextStyle(fontSize: 11), 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; } }