From e6af7dd91ae9f69b32e55925944a7e9501ecfcfe Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Tue, 23 Dec 2025 17:52:43 +0900 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=EB=B0=98=EC=9D=91=ED=98=95=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=EB=B0=8F=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 반응형 레이아웃 - app.dart: 화면 크기별 레이아웃 분기 로직 추가 (+173 라인) - game_play_screen.dart: 반응형 UI 구조 개선 - layouts/, pages/ 디렉토리 추가 (새 레이아웃 시스템) - carousel_nav_bar.dart: 캐러셀 네비게이션 바 추가 - enhanced_animation_panel.dart: 향상된 애니메이션 패널 ## 저장 시스템 - save_manager.dart: 저장 관리 기능 확장 - save_repository.dart: 저장소 인터페이스 개선 - save_service.dart: 저장 서비스 로직 추가 ## UI 개선 - notification_service.dart: 알림 시스템 기능 확장 - notification_overlay.dart: 오버레이 UI 개선 - equipment_stats_panel.dart: 장비 스탯 패널 개선 - cinematic_view.dart: 시네마틱 뷰 개선 - new_character_screen.dart: 캐릭터 생성 화면 개선 ## 다국어 - game_text_l10n.dart: 텍스트 추가 (+182 라인) ## 테스트 - 관련 테스트 파일 업데이트 --- lib/data/game_text_l10n.dart | 182 ++++- lib/src/app.dart | 173 ++++- .../notification/notification_service.dart | 38 ++ lib/src/core/storage/save_manager.dart | 10 + lib/src/core/storage/save_repository.dart | 24 + lib/src/core/storage/save_service.dart | 16 + lib/src/features/front/front_screen.dart | 11 +- lib/src/features/game/game_play_screen.dart | 219 +++++- .../game/layouts/mobile_carousel_layout.dart | 386 +++++++++++ .../game/pages/character_sheet_page.dart | 133 ++++ .../features/game/pages/combat_log_page.dart | 44 ++ .../features/game/pages/equipment_page.dart | 45 ++ .../features/game/pages/inventory_page.dart | 166 +++++ lib/src/features/game/pages/quest_page.dart | 135 ++++ lib/src/features/game/pages/skills_page.dart | 162 +++++ lib/src/features/game/pages/story_page.dart | 156 +++++ .../game/widgets/carousel_nav_bar.dart | 121 ++++ .../features/game/widgets/cinematic_view.dart | 5 +- .../widgets/enhanced_animation_panel.dart | 642 ++++++++++++++++++ .../game/widgets/equipment_stats_panel.dart | 10 +- .../game/widgets/notification_overlay.dart | 22 +- .../features/game/widgets/stats_panel.dart | 2 + .../new_character/new_character_screen.dart | 31 +- test/core/engine/progress_loop_test.dart | 8 + test/features/game_play_screen_test.dart | 32 +- .../game_session_controller_test.dart | 8 + test/features/new_character_screen_test.dart | 18 +- test/widget_test.dart | 8 + 28 files changed, 2734 insertions(+), 73 deletions(-) create mode 100644 lib/src/features/game/layouts/mobile_carousel_layout.dart create mode 100644 lib/src/features/game/pages/character_sheet_page.dart create mode 100644 lib/src/features/game/pages/combat_log_page.dart create mode 100644 lib/src/features/game/pages/equipment_page.dart create mode 100644 lib/src/features/game/pages/inventory_page.dart create mode 100644 lib/src/features/game/pages/quest_page.dart create mode 100644 lib/src/features/game/pages/skills_page.dart create mode 100644 lib/src/features/game/pages/story_page.dart create mode 100644 lib/src/features/game/widgets/carousel_nav_bar.dart create mode 100644 lib/src/features/game/widgets/enhanced_animation_panel.dart diff --git a/lib/data/game_text_l10n.dart b/lib/data/game_text_l10n.dart index 6372494..47b953b 100644 --- a/lib/data/game_text_l10n.dart +++ b/lib/data/game_text_l10n.dart @@ -1042,7 +1042,29 @@ String translateItemNameL10n(String itemString) { // 2. 몬스터 드롭 형식: "{monster} {drop}" (예: "syntax error fragment") final words = itemString.split(' '); if (words.length >= 2) { - // 마지막 단어가 드롭 아이템인지 확인 + // 2-1. 마지막 2단어가 드롭 아이템인지 먼저 확인 (예: "outdated syntax") + if (words.length >= 3) { + final lastTwoWords = '${words[words.length - 2]} ${words.last}' + .toLowerCase(); + final dropKo2 = + dropItemTranslationsKo[lastTwoWords] ?? + additionalDropTranslationsKo[lastTwoWords]; + final dropJa2 = + dropItemTranslationsJa[lastTwoWords] ?? + additionalDropTranslationsJa[lastTwoWords]; + + if (dropKo2 != null || dropJa2 != null) { + final monsterPart = words.sublist(0, words.length - 2).join(' '); + final translatedMonster = translateMonster(monsterPart); + if (isKoreanLocale && dropKo2 != null) { + return '$translatedMonster $dropKo2'; + } else if (isJapaneseLocale && dropJa2 != null) { + return '$translatedMonsterの$dropJa2'; + } + } + } + + // 2-2. 마지막 단어가 드롭 아이템인지 확인 final lastWord = words.last.toLowerCase(); final dropKo = dropItemTranslationsKo[lastWord] ?? @@ -1479,3 +1501,161 @@ String get uiEnterName { if (isJapaneseLocale) return '名前を入力してください。'; return 'Please enter a name.'; } + +String get uiTestMode { + if (isKoreanLocale) return '테스트 모드'; + if (isJapaneseLocale) return 'テストモード'; + return 'Test Mode'; +} + +String get uiTestModeDesc { + if (isKoreanLocale) return '웹에서 모바일 레이아웃 사용'; + if (isJapaneseLocale) return 'Webでモバイルレイアウトを使用'; + return 'Use mobile layout on web'; +} + +// ============================================================================ +// 캐로셀 네비게이션 텍스트 +// ============================================================================ + +String get navSkills { + if (isKoreanLocale) return '스킬'; + if (isJapaneseLocale) return 'スキル'; + return 'Skills'; +} + +String get navInventory { + if (isKoreanLocale) return '인벤토리'; + if (isJapaneseLocale) return '所持品'; + return 'Inventory'; +} + +String get navEquipment { + if (isKoreanLocale) return '장비'; + if (isJapaneseLocale) return '装備'; + return 'Equip'; +} + +String get navCharacter { + if (isKoreanLocale) return '캐릭터'; + if (isJapaneseLocale) return 'キャラ'; + return 'Character'; +} + +String get navCombatLog { + if (isKoreanLocale) return '전투로그'; + if (isJapaneseLocale) return '戦闘ログ'; + return 'Combat'; +} + +String get navStory { + if (isKoreanLocale) return '스토리'; + if (isJapaneseLocale) return 'ストーリー'; + return 'Story'; +} + +String get navQuest { + if (isKoreanLocale) return '퀘스트'; + if (isJapaneseLocale) return 'クエスト'; + return 'Quest'; +} + +// ============================================================================ +// 옵션 메뉴 텍스트 +// ============================================================================ + +String get menuOptions { + if (isKoreanLocale) return '옵션'; + if (isJapaneseLocale) return 'オプション'; + return 'Options'; +} + +String get menuPause { + if (isKoreanLocale) return '일시정지'; + if (isJapaneseLocale) return '一時停止'; + return 'Pause'; +} + +String get menuResume { + if (isKoreanLocale) return '재개'; + if (isJapaneseLocale) return '再開'; + return 'Resume'; +} + +String get menuSpeed { + if (isKoreanLocale) return '속도'; + if (isJapaneseLocale) return '速度'; + return 'Speed'; +} + +String get menuSave { + if (isKoreanLocale) return '저장'; + if (isJapaneseLocale) return 'セーブ'; + return 'Save'; +} + +String get menuSaved { + if (isKoreanLocale) return '저장되었습니다'; + if (isJapaneseLocale) return '保存しました'; + return 'Game saved'; +} + +String get menuLanguage { + if (isKoreanLocale) return '언어'; + if (isJapaneseLocale) return '言語'; + return 'Language'; +} + +String get languageEnglish { + if (isKoreanLocale) return '영어'; + if (isJapaneseLocale) return '英語'; + return 'English'; +} + +String get languageKorean { + if (isKoreanLocale) return '한국어'; + if (isJapaneseLocale) return '韓国語'; + return 'Korean'; +} + +String get languageJapanese { + if (isKoreanLocale) return '일본어'; + if (isJapaneseLocale) return '日本語'; + return 'Japanese'; +} + +String get menuDeleteSave { + if (isKoreanLocale) return '세이브 삭제'; + if (isJapaneseLocale) return 'セーブ削除'; + return 'Delete Save'; +} + +String get menuNewGame { + if (isKoreanLocale) return '새로하기'; + if (isJapaneseLocale) return '新規ゲーム'; + return 'New Game'; +} + +String get confirmDeleteTitle { + if (isKoreanLocale) return '세이브 삭제'; + if (isJapaneseLocale) return 'セーブ削除'; + return 'Delete Save'; +} + +String get confirmDeleteMessage { + if (isKoreanLocale) return '정말 삭제하시겠습니까?\n모든 진행 상황이 사라집니다.'; + if (isJapaneseLocale) return '本当に削除しますか?\nすべての進行状況が失われます。'; + return 'Are you sure?\nAll progress will be lost.'; +} + +String get buttonConfirm { + if (isKoreanLocale) return '확인'; + if (isJapaneseLocale) return '確認'; + return 'Confirm'; +} + +String get buttonCancel { + if (isKoreanLocale) return '취소'; + if (isJapaneseLocale) return 'キャンセル'; + return 'Cancel'; +} diff --git a/lib/src/app.dart b/lib/src/app.dart index 3d85aa2..03cfebb 100644 --- a/lib/src/app.dart +++ b/lib/src/app.dart @@ -7,12 +7,14 @@ import 'package:askiineverdie/src/core/engine/progress_service.dart'; import 'package:askiineverdie/src/core/engine/reward_service.dart'; import 'package:askiineverdie/src/core/model/game_state.dart'; import 'package:askiineverdie/src/core/model/pq_config.dart'; +import 'package:askiineverdie/src/core/notification/notification_service.dart'; import 'package:askiineverdie/src/core/storage/save_manager.dart'; import 'package:askiineverdie/src/core/storage/save_repository.dart'; import 'package:askiineverdie/src/features/front/front_screen.dart'; import 'package:askiineverdie/src/features/front/save_picker_dialog.dart'; import 'package:askiineverdie/src/features/game/game_play_screen.dart'; import 'package:askiineverdie/src/features/game/game_session_controller.dart'; +import 'package:askiineverdie/src/features/game/widgets/notification_overlay.dart'; import 'package:askiineverdie/src/features/hall_of_fame/hall_of_fame_screen.dart'; import 'package:askiineverdie/src/features/new_character/new_character_screen.dart'; @@ -25,6 +27,9 @@ class AskiiNeverDieApp extends StatefulWidget { class _AskiiNeverDieAppState extends State { late final GameSessionController _controller; + late final NotificationService _notificationService; + bool _isCheckingSave = true; + bool _hasSave = false; @override void initState() { @@ -41,11 +46,27 @@ class _AskiiNeverDieAppState extends State { ), saveManager: SaveManager(SaveRepository()), ); + _notificationService = NotificationService(); + + // 세이브 파일 존재 여부 확인 + _checkForExistingSave(); + } + + /// 세이브 파일 존재 여부 확인 후 자동 로드 + Future _checkForExistingSave() async { + final exists = await _controller.saveManager.saveExists(); + if (mounted) { + setState(() { + _hasSave = exists; + _isCheckingSave = false; + }); + } } @override void dispose() { _controller.dispose(); + _notificationService.dispose(); super.dispose(); } @@ -67,20 +88,45 @@ class _AskiiNeverDieAppState extends State { game_l10n.setGameLocale(locale.languageCode); return child ?? const SizedBox.shrink(); }, - home: FrontScreen( - onNewCharacter: _navigateToNewCharacter, - onLoadSave: _loadSave, - onHallOfFame: _navigateToHallOfFame, + home: NotificationOverlay( + notificationService: _notificationService, + child: _buildHomeScreen(), ), ); } + /// 홈 화면 결정: 세이브 확인 중 → 스플래시, 세이브 있음 → 자동 로드, 없음 → 프론트 + Widget _buildHomeScreen() { + // 세이브 확인 중이면 로딩 스플래시 표시 + if (_isCheckingSave) { + return const _SplashScreen(); + } + + // 세이브 파일이 있으면 자동 로드 화면 + if (_hasSave) { + return _AutoLoadScreen( + controller: _controller, + onLoadFailed: () { + // 로드 실패 시 프론트 화면으로 + setState(() => _hasSave = false); + }, + ); + } + + // 세이브 파일이 없으면 기존 프론트 화면 + return FrontScreen( + onNewCharacter: _navigateToNewCharacter, + onLoadSave: _loadSave, + onHallOfFame: _navigateToHallOfFame, + ); + } + void _navigateToNewCharacter(BuildContext context) { Navigator.of(context).push( MaterialPageRoute( builder: (context) => NewCharacterScreen( - onCharacterCreated: (initialState) { - _startGame(context, initialState); + onCharacterCreated: (initialState, {bool testMode = false}) { + _startGame(context, initialState, testMode: testMode); }, ), ), @@ -97,9 +143,7 @@ class _AskiiNeverDieAppState extends State { if (saves.isEmpty) { // 저장 파일이 없으면 안내 메시지 - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(L10n.of(context).noSavedGames))); + _notificationService.showInfo(L10n.of(context).noSavedGames); return; } else if (saves.length == 1) { // 파일이 하나면 바로 선택 @@ -121,27 +165,29 @@ class _AskiiNeverDieAppState extends State { if (context.mounted) { _navigateToGame(context); } - } else if (_controller.status == GameSessionStatus.error) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - L10n.of(context).loadError(_controller.error ?? 'Unknown error'), - ), - ), - ); - } + } else if (_controller.status == GameSessionStatus.error && + context.mounted) { + _notificationService.showWarning( + L10n.of(context).loadError(_controller.error ?? 'Unknown error'), + ); } } - Future _startGame(BuildContext context, GameState initialState) async { + Future _startGame( + BuildContext context, + GameState initialState, { + bool testMode = false, + }) async { await _controller.startNew(initialState, cheatsEnabled: false); if (context.mounted) { // NewCharacterScreen을 pop하고 GamePlayScreen으로 이동 Navigator.of(context).pushReplacement( MaterialPageRoute( - builder: (context) => GamePlayScreen(controller: _controller), + builder: (context) => GamePlayScreen( + controller: _controller, + forceCarouselLayout: testMode, + ), ), ); } @@ -162,3 +208,88 @@ class _AskiiNeverDieAppState extends State { ); } } + +/// 스플래시 화면 (세이브 파일 확인 중) +class _SplashScreen extends StatelessWidget { + const _SplashScreen(); + + @override + Widget build(BuildContext context) { + return const Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'ASCII NEVER DIE', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + SizedBox(height: 16), + CircularProgressIndicator(), + ], + ), + ), + ); + } +} + +/// 자동 로드 화면 (세이브 파일 자동 로드) +class _AutoLoadScreen extends StatefulWidget { + const _AutoLoadScreen({required this.controller, required this.onLoadFailed}); + + final GameSessionController controller; + final VoidCallback onLoadFailed; + + @override + State<_AutoLoadScreen> createState() => _AutoLoadScreenState(); +} + +class _AutoLoadScreenState extends State<_AutoLoadScreen> { + @override + void initState() { + super.initState(); + _autoLoad(); + } + + Future _autoLoad() async { + await widget.controller.loadAndStart(cheatsEnabled: false); + + if (!mounted) return; + + if (widget.controller.status == GameSessionStatus.running) { + // 로드 성공 → 게임 화면으로 교체 + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (context) => GamePlayScreen( + controller: widget.controller, + // 자동 로드 시에는 플랫폼 기본값 사용 (모바일만 캐로셀) + ), + ), + ); + } else { + // 로드 실패 → 프론트 화면으로 돌아가기 + widget.onLoadFailed(); + } + } + + @override + Widget build(BuildContext context) { + return const Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'ASCII NEVER DIE', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + SizedBox(height: 16), + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Loading...'), + ], + ), + ), + ); + } +} diff --git a/lib/src/core/notification/notification_service.dart b/lib/src/core/notification/notification_service.dart index 10d47aa..a456c15 100644 --- a/lib/src/core/notification/notification_service.dart +++ b/lib/src/core/notification/notification_service.dart @@ -8,6 +8,9 @@ enum NotificationType { newSpell, // 새 주문 습득 newEquipment, // 새 장비 획득 bossDefeat, // 보스 처치 + gameSaved, // 게임 저장됨 + info, // 일반 정보 + warning, // 경고 } /// 게임 알림 데이터 (Game Notification) @@ -134,6 +137,41 @@ class NotificationService { ); } + /// 게임 저장 완료 알림 (Game Saved) + void showGameSaved(String message) { + show( + GameNotification( + type: NotificationType.gameSaved, + title: message, + duration: const Duration(seconds: 2), + ), + ); + } + + /// 일반 정보 알림 (Info) + void showInfo(String message, {String? subtitle}) { + show( + GameNotification( + type: NotificationType.info, + title: message, + subtitle: subtitle, + duration: const Duration(seconds: 2), + ), + ); + } + + /// 경고 알림 (Warning) + void showWarning(String message, {String? subtitle}) { + show( + GameNotification( + type: NotificationType.warning, + title: message, + subtitle: subtitle, + duration: const Duration(seconds: 3), + ), + ); + } + /// 큐 처리 (Process Queue) void _processQueue() { if (_isShowing || _queue.isEmpty) return; diff --git a/lib/src/core/storage/save_manager.dart b/lib/src/core/storage/save_manager.dart index 612f55c..b74928f 100644 --- a/lib/src/core/storage/save_manager.dart +++ b/lib/src/core/storage/save_manager.dart @@ -30,4 +30,14 @@ class SaveManager { /// 저장 파일 목록 조회 Future> listSaves() => _repo.listSaves(); + + /// 저장 파일 삭제 + Future deleteSave({String? fileName}) { + return _repo.deleteSave(fileName ?? defaultFileName); + } + + /// 저장 파일 존재 여부 확인 + Future saveExists({String? fileName}) { + return _repo.saveExists(fileName ?? defaultFileName); + } } diff --git a/lib/src/core/storage/save_repository.dart b/lib/src/core/storage/save_repository.dart index 726b123..6a3b139 100644 --- a/lib/src/core/storage/save_repository.dart +++ b/lib/src/core/storage/save_repository.dart @@ -61,4 +61,28 @@ class SaveRepository { return []; } } + + /// 저장 파일 삭제 + Future deleteSave(String fileName) async { + try { + await _ensureService(); + await _service!.deleteSave(fileName); + return const SaveOutcome.success(); + } on FileSystemException catch (e) { + final reason = e.osError?.message ?? e.message; + return SaveOutcome.failure('Unable to delete save: $reason'); + } catch (e) { + return SaveOutcome.failure(e.toString()); + } + } + + /// 저장 파일 존재 여부 확인 + Future saveExists(String fileName) async { + try { + await _ensureService(); + return await _service!.exists(fileName); + } catch (e) { + return false; + } + } } diff --git a/lib/src/core/storage/save_service.dart b/lib/src/core/storage/save_service.dart index ffcef13..014f2c1 100644 --- a/lib/src/core/storage/save_service.dart +++ b/lib/src/core/storage/save_service.dart @@ -37,6 +37,22 @@ class SaveService { return '${baseDir.path}/$normalized'; } + /// 저장 파일 삭제 + Future deleteSave(String fileName) async { + final path = _resolvePath(fileName); + final file = File(path); + if (await file.exists()) { + await file.delete(); + } + } + + /// 저장 파일 존재 여부 확인 + Future exists(String fileName) async { + final path = _resolvePath(fileName); + final file = File(path); + return file.exists(); + } + /// 저장 디렉토리의 모든 .pqf 파일 목록 반환 Future> listSaves() async { if (!await baseDir.exists()) { diff --git a/lib/src/features/front/front_screen.dart b/lib/src/features/front/front_screen.dart index 1831647..d11fa21 100644 --- a/lib/src/features/front/front_screen.dart +++ b/lib/src/features/front/front_screen.dart @@ -334,16 +334,15 @@ class _Tag extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + // 어두운 배경에 잘 보이도록 대비되는 색상 사용 + final tagColor = colorScheme.onPrimaryContainer; return Chip( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - backgroundColor: colorScheme.onPrimary.withValues(alpha: 0.14), - avatar: Icon(icon, color: colorScheme.onPrimary, size: 16), + backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.8), + avatar: Icon(icon, color: tagColor, size: 16), label: Text( label, - style: TextStyle( - color: colorScheme.onPrimary, - fontWeight: FontWeight.w600, - ), + style: TextStyle(color: tagColor, fontWeight: FontWeight.w600), ), side: BorderSide.none, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, diff --git a/lib/src/features/game/game_play_screen.dart b/lib/src/features/game/game_play_screen.dart index e641e3e..882aef5 100644 --- a/lib/src/features/game/game_play_screen.dart +++ b/lib/src/features/game/game_play_screen.dart @@ -1,3 +1,5 @@ +import 'package:flutter/foundation.dart' + show kIsWeb, defaultTargetPlatform, TargetPlatform; import 'package:flutter/material.dart'; import 'package:askiineverdie/data/game_text_l10n.dart' as game_l10n; @@ -26,15 +28,27 @@ import 'package:askiineverdie/src/features/game/widgets/equipment_stats_panel.da import 'package:askiineverdie/src/features/game/widgets/potion_inventory_panel.dart'; import 'package:askiineverdie/src/features/game/widgets/task_progress_panel.dart'; import 'package:askiineverdie/src/features/game/widgets/active_buff_panel.dart'; +import 'package:askiineverdie/src/features/game/layouts/mobile_carousel_layout.dart'; /// 게임 진행 화면 (Main.dfm 기반 3패널 레이아웃) /// /// Phase 7: colorTheme 제거됨, 고정 4색 팔레트 사용 class GamePlayScreen extends StatefulWidget { - const GamePlayScreen({super.key, required this.controller}); + const GamePlayScreen({ + super.key, + required this.controller, + this.forceCarouselLayout = false, + this.forceDesktopLayout = false, + }); final GameSessionController controller; + /// 테스트 모드: 웹에서도 모바일 캐로셀 레이아웃 강제 사용 + final bool forceCarouselLayout; + + /// 테스트 모드: 모바일에서도 데스크톱 3패널 레이아웃 강제 사용 + final bool forceDesktopLayout; + @override State createState() => _GamePlayScreenState(); } @@ -400,6 +414,102 @@ class _GamePlayScreenState extends State 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; + } + + /// 현재 언어명 가져오기 + String _getCurrentLanguageName() { + final locale = game_l10n.currentGameLocale; + if (locale == 'ko') return game_l10n.languageKorean; + if (locale == 'ja') return game_l10n.languageJapanese; + return game_l10n.languageEnglish; + } + + /// 언어 선택 다이얼로그 표시 + void _showLanguageDialog(BuildContext context) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(game_l10n.menuLanguage), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildLanguageOption(context, 'en', game_l10n.languageEnglish), + _buildLanguageOption(context, 'ko', game_l10n.languageKorean), + _buildLanguageOption(context, 'ja', game_l10n.languageJapanese), + ], + ), + ), + ); + } + + Widget _buildLanguageOption( + BuildContext context, + String locale, + String label, + ) { + final isSelected = game_l10n.currentGameLocale == locale; + return ListTile( + leading: Icon( + isSelected ? Icons.radio_button_checked : Icons.radio_button_unchecked, + color: isSelected ? Theme.of(context).colorScheme.primary : null, + ), + title: Text( + label, + style: TextStyle( + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + ), + ), + onTap: () { + Navigator.pop(context); // 다이얼로그 닫기 + game_l10n.setGameLocale(locale); + setState(() {}); + }, + ); + } + + /// 부활 처리 핸들러 + Future _handleResurrect() async { + // 1. 부활 처리 (HP/MP 회복, 장비 구매 - 게임 재개 없음) + await widget.controller.resurrect(); + + // 2. 부활 애니메이션 재생 + setState(() { + _specialAnimation = AsciiAnimationType.resurrection; + }); + + // 3. 애니메이션 종료 후 게임 재개 + final duration = getSpecialAnimationDuration( + AsciiAnimationType.resurrection, + ); + Future.delayed(Duration(milliseconds: duration), () async { + if (mounted) { + setState(() { + _specialAnimation = null; + }); + // 부활 후 게임 재개 (새 루프 시작) + await widget.controller.resumeAfterResurrection(); + } + }); + } + @override Widget build(BuildContext context) { final state = widget.controller.state; @@ -407,7 +517,84 @@ class _GamePlayScreenState extends State 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: _combatLogEntries, + speedMultiplier: widget.controller.loop?.speedMultiplier ?? 1, + onSpeedCycle: () { + widget.controller.loop?.cycleSpeed(); + setState(() {}); + }, + isPaused: !widget.controller.isRunning, + 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) { + game_l10n.setGameLocale(locale); + setState(() {}); + }, + onDeleteSaveAndNewGame: () async { + // 게임 루프 중지 + await widget.controller.pause(saveOnStop: false); + // 세이브 파일 삭제 + await widget.controller.saveManager.deleteSave(); + // 캐릭터 생성 화면으로 돌아가기 + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + ), + // 사망 오버레이 + if (state.isDead && state.deathInfo != null) + DeathOverlay( + deathInfo: state.deathInfo!, + traits: state.traits, + onResurrect: _handleResurrect, + ), + ], + ), + ), + ); + } + + // 기존 데스크톱 레이아웃 return NotificationOverlay( + key: localeKey, notificationService: _notificationService, child: PopScope( canPop: false, @@ -443,6 +630,12 @@ class _GamePlayScreenState extends State onPressed: () => widget.controller.loop?.cheatCompletePlot(), ), ], + // 언어 변경 버튼 + TextButton.icon( + onPressed: () => _showLanguageDialog(context), + icon: const Icon(Icons.language, size: 18), + label: Text(_getCurrentLanguageName()), + ), ], ), body: Stack( @@ -497,29 +690,7 @@ class _GamePlayScreenState extends State DeathOverlay( deathInfo: state.deathInfo!, traits: state.traits, - onResurrect: () async { - // 1. 부활 처리 (HP/MP 회복, 장비 구매 - 게임 재개 없음) - await widget.controller.resurrect(); - - // 2. 부활 애니메이션 재생 - setState(() { - _specialAnimation = AsciiAnimationType.resurrection; - }); - - // 3. 애니메이션 종료 후 게임 재개 - final duration = getSpecialAnimationDuration( - AsciiAnimationType.resurrection, - ); - Future.delayed(Duration(milliseconds: duration), () async { - if (mounted) { - setState(() { - _specialAnimation = null; - }); - // 부활 후 게임 재개 (새 루프 시작) - await widget.controller.resumeAfterResurrection(); - } - }); - }, + onResurrect: _handleResurrect, ), ], ), diff --git a/lib/src/features/game/layouts/mobile_carousel_layout.dart b/lib/src/features/game/layouts/mobile_carousel_layout.dart new file mode 100644 index 0000000..19c869b --- /dev/null +++ b/lib/src/features/game/layouts/mobile_carousel_layout.dart @@ -0,0 +1,386 @@ +import 'package:flutter/material.dart'; + +import 'package:askiineverdie/src/core/notification/notification_service.dart'; +import 'package:askiineverdie/data/game_text_l10n.dart' as l10n; +import 'package:askiineverdie/l10n/app_localizations.dart'; +import 'package:askiineverdie/src/core/animation/ascii_animation_type.dart'; +import 'package:askiineverdie/src/core/model/game_state.dart'; +import 'package:askiineverdie/src/features/game/pages/character_sheet_page.dart'; +import 'package:askiineverdie/src/features/game/pages/combat_log_page.dart'; +import 'package:askiineverdie/src/features/game/pages/equipment_page.dart'; +import 'package:askiineverdie/src/features/game/pages/inventory_page.dart'; +import 'package:askiineverdie/src/features/game/pages/quest_page.dart'; +import 'package:askiineverdie/src/features/game/pages/skills_page.dart'; +import 'package:askiineverdie/src/features/game/pages/story_page.dart'; +import 'package:askiineverdie/src/features/game/widgets/carousel_nav_bar.dart'; +import 'package:askiineverdie/src/features/game/widgets/combat_log.dart'; +import 'package:askiineverdie/src/features/game/widgets/enhanced_animation_panel.dart'; + +/// 모바일 캐로셀 레이아웃 +/// +/// 모바일 앱용 레이아웃: +/// - 상단: 확장 애니메이션 패널 (ASCII 애니메이션, HP/MP, 버프, 몬스터 HP) +/// - 중앙: 캐로셀 (7개 페이지: 스킬, 인벤토리, 장비, 캐릭터시트, 전투로그, 스토리, 퀘스트) +/// - 하단: 네비게이션 바 (7개 버튼) +class MobileCarouselLayout extends StatefulWidget { + const MobileCarouselLayout({ + super.key, + required this.state, + required this.combatLogEntries, + required this.speedMultiplier, + required this.onSpeedCycle, + required this.isPaused, + required this.onPauseToggle, + required this.onSave, + required this.onExit, + required this.notificationService, + required this.onLanguageChange, + required this.onDeleteSaveAndNewGame, + this.specialAnimation, + }); + + final GameState state; + final List combatLogEntries; + final int speedMultiplier; + final VoidCallback onSpeedCycle; + final bool isPaused; + final VoidCallback onPauseToggle; + final VoidCallback onSave; + final VoidCallback onExit; + final NotificationService notificationService; + final void Function(String locale) onLanguageChange; + final VoidCallback onDeleteSaveAndNewGame; + final AsciiAnimationType? specialAnimation; + + @override + State createState() => _MobileCarouselLayoutState(); +} + +class _MobileCarouselLayoutState extends State { + late PageController _pageController; + int _currentPage = CarouselPage.character.index; // 기본: 캐릭터시트 + + @override + void initState() { + super.initState(); + _pageController = PageController(initialPage: _currentPage); + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + void _onPageChanged(int page) { + setState(() { + _currentPage = page; + }); + } + + void _onNavPageSelected(int page) { + _pageController.animateToPage( + page, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + + /// 현재 언어명 가져오기 + String _getCurrentLanguageName() { + final locale = l10n.currentGameLocale; + if (locale == 'ko') return l10n.languageKorean; + if (locale == 'ja') return l10n.languageJapanese; + return l10n.languageEnglish; + } + + /// 언어 선택 다이얼로그 표시 + void _showLanguageDialog(BuildContext context) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(l10n.menuLanguage), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildLanguageOption(context, 'en', l10n.languageEnglish), + _buildLanguageOption(context, 'ko', l10n.languageKorean), + _buildLanguageOption(context, 'ja', l10n.languageJapanese), + ], + ), + ), + ); + } + + Widget _buildLanguageOption( + BuildContext context, + String locale, + String label, + ) { + final isSelected = l10n.currentGameLocale == locale; + return ListTile( + leading: Icon( + isSelected ? Icons.radio_button_checked : Icons.radio_button_unchecked, + color: isSelected ? Theme.of(context).colorScheme.primary : null, + ), + title: Text( + label, + style: TextStyle( + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + ), + ), + onTap: () { + Navigator.pop(context); // 다이얼로그 닫기 + widget.onLanguageChange(locale); + }, + ); + } + + /// 세이브 삭제 확인 다이얼로그 표시 + void _showDeleteConfirmDialog(BuildContext context) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(l10n.confirmDeleteTitle), + content: Text(l10n.confirmDeleteMessage), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(l10n.buttonCancel), + ), + TextButton( + onPressed: () { + Navigator.pop(context); // 다이얼로그 닫기 + widget.onDeleteSaveAndNewGame(); + }, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: Text(l10n.buttonConfirm), + ), + ], + ), + ); + } + + /// 옵션 메뉴 표시 + void _showOptionsMenu(BuildContext context) { + final localizations = L10n.of(context); + + showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 헤더 + Container( + padding: const EdgeInsets.all(16), + width: double.infinity, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + ), + child: Text( + l10n.menuOptions, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + ), + + // 일시정지/재개 + ListTile( + leading: Icon( + widget.isPaused ? Icons.play_arrow : Icons.pause, + color: widget.isPaused ? Colors.green : Colors.orange, + ), + title: Text(widget.isPaused ? l10n.menuResume : l10n.menuPause), + onTap: () { + Navigator.pop(context); + widget.onPauseToggle(); + }, + ), + + // 속도 조절 + ListTile( + leading: const Icon(Icons.speed), + title: Text(l10n.menuSpeed), + trailing: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '${widget.speedMultiplier}x', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + onTap: () { + widget.onSpeedCycle(); + Navigator.pop(context); + }, + ), + + // 언어 변경 + ListTile( + leading: const Icon(Icons.language, color: Colors.teal), + title: Text(l10n.menuLanguage), + trailing: Text( + _getCurrentLanguageName(), + style: TextStyle(color: Theme.of(context).colorScheme.primary), + ), + onTap: () { + Navigator.pop(context); + _showLanguageDialog(context); + }, + ), + + const Divider(), + + // 저장 + ListTile( + leading: const Icon(Icons.save, color: Colors.blue), + title: Text(l10n.menuSave), + onTap: () { + Navigator.pop(context); + widget.onSave(); + widget.notificationService.showGameSaved(l10n.menuSaved); + }, + ), + + // 새로하기 (세이브 삭제) + ListTile( + leading: const Icon(Icons.refresh, color: Colors.orange), + title: Text(l10n.menuNewGame), + subtitle: Text( + l10n.menuDeleteSave, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.outline, + ), + ), + onTap: () { + Navigator.pop(context); + _showDeleteConfirmDialog(context); + }, + ), + + // 종료 + ListTile( + leading: const Icon(Icons.exit_to_app, color: Colors.red), + title: Text(localizations.exitGame), + onTap: () { + Navigator.pop(context); + widget.onExit(); + }, + ), + + const SizedBox(height: 8), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final state = widget.state; + + return Scaffold( + appBar: AppBar( + title: Text(L10n.of(context).progressQuestTitle(state.traits.name)), + actions: [ + // 옵션 버튼 + IconButton( + icon: const Icon(Icons.more_vert), + onPressed: () => _showOptionsMenu(context), + tooltip: l10n.menuOptions, + ), + ], + ), + body: Column( + children: [ + // 상단: 확장 애니메이션 패널 + EnhancedAnimationPanel( + progress: state.progress, + stats: state.stats, + skillSystem: state.skillSystem, + speedMultiplier: widget.speedMultiplier, + onSpeedCycle: widget.onSpeedCycle, + isPaused: widget.isPaused, + onPauseToggle: widget.onPauseToggle, + specialAnimation: widget.specialAnimation, + weaponName: state.equipment.weapon, + shieldName: state.equipment.shield, + characterLevel: state.traits.level, + monsterLevel: state.progress.currentTask.monsterLevel, + latestCombatEvent: + state.progress.currentCombat?.recentEvents.lastOrNull, + ), + + // 중앙: 캐로셀 (PageView) + Expanded( + child: PageView( + controller: _pageController, + onPageChanged: _onPageChanged, + children: [ + // 0: 스킬 + SkillsPage( + spellBook: state.spellBook, + skillSystem: state.skillSystem, + ), + + // 1: 인벤토리 + InventoryPage( + inventory: state.inventory, + potionInventory: state.potionInventory, + encumbrance: state.progress.encumbrance, + usedPotionTypes: + state.progress.currentCombat?.usedPotionTypes ?? const {}, + ), + + // 2: 장비 + EquipmentPage(equipment: state.equipment), + + // 3: 캐릭터시트 (기본) + CharacterSheetPage( + traits: state.traits, + stats: state.stats, + exp: state.progress.exp, + ), + + // 4: 전투로그 + CombatLogPage(entries: widget.combatLogEntries), + + // 5: 퀘스트 + QuestPage( + questHistory: state.progress.questHistory, + quest: state.progress.quest, + ), + + // 6: 스토리 + StoryPage( + plotStageCount: state.progress.plotStageCount, + plot: state.progress.plot, + ), + ], + ), + ), + + // 하단: 네비게이션 바 + CarouselNavBar( + currentPage: _currentPage, + onPageSelected: _onNavPageSelected, + ), + ], + ), + ); + } +} diff --git a/lib/src/features/game/pages/character_sheet_page.dart b/lib/src/features/game/pages/character_sheet_page.dart new file mode 100644 index 0000000..221d9b3 --- /dev/null +++ b/lib/src/features/game/pages/character_sheet_page.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; + +import 'package:askiineverdie/l10n/app_localizations.dart'; +import 'package:askiineverdie/src/core/l10n/game_data_l10n.dart'; +import 'package:askiineverdie/src/core/model/game_state.dart'; +import 'package:askiineverdie/src/features/game/widgets/stats_panel.dart'; + +/// 캐릭터시트 페이지 (캐로셀 - 기본 페이지) +/// +/// 트레잇, 스탯, 경험치 표시. +class CharacterSheetPage extends StatelessWidget { + const CharacterSheetPage({ + super.key, + required this.traits, + required this.stats, + required this.exp, + }); + + final Traits traits; + final Stats stats; + final ProgressBarState exp; + + @override + Widget build(BuildContext context) { + final localizations = L10n.of(context); + + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 트레잇 + _buildSectionHeader(context, localizations.traits), + _buildTraitsList(context), + + // 스탯 + _buildSectionHeader(context, localizations.stats), + StatsPanel(stats: stats), + + // 경험치 + _buildSectionHeader(context, localizations.experience), + _buildExpBar(context), + ], + ), + ); + } + + Widget _buildSectionHeader(BuildContext context, String title) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + color: Theme.of(context).colorScheme.primaryContainer, + child: Text( + title, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 13, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + ); + } + + Widget _buildTraitsList(BuildContext context) { + final localizations = L10n.of(context); + final traitData = [ + (localizations.traitName, traits.name), + (localizations.traitRace, GameDataL10n.getRaceName(context, traits.race)), + ( + localizations.traitClass, + GameDataL10n.getKlassName(context, traits.klass), + ), + (localizations.traitLevel, '${traits.level}'), + ]; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Column( + children: traitData.map((t) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + SizedBox( + width: 60, + child: Text( + t.$1, + style: TextStyle(fontSize: 12, color: Colors.grey.shade600), + ), + ), + Expanded( + child: Text( + t.$2, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + }).toList(), + ), + ); + } + + Widget _buildExpBar(BuildContext context) { + final localizations = L10n.of(context); + final progress = exp.max > 0 + ? (exp.position / exp.max).clamp(0.0, 1.0) + : 0.0; + final remaining = exp.max - exp.position; + + return Padding( + padding: const EdgeInsets.all(12), + child: Column( + children: [ + LinearProgressIndicator( + value: progress, + backgroundColor: Colors.blue.withValues(alpha: 0.2), + valueColor: const AlwaysStoppedAnimation(Colors.blue), + minHeight: 12, + ), + const SizedBox(height: 4), + Text( + '$remaining ${localizations.xpNeededForNextLevel}', + style: TextStyle(fontSize: 10, color: Colors.grey.shade600), + ), + ], + ), + ); + } +} diff --git a/lib/src/features/game/pages/combat_log_page.dart b/lib/src/features/game/pages/combat_log_page.dart new file mode 100644 index 0000000..7acd694 --- /dev/null +++ b/lib/src/features/game/pages/combat_log_page.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +import 'package:askiineverdie/l10n/app_localizations.dart'; +import 'package:askiineverdie/src/features/game/widgets/combat_log.dart'; + +/// 전투 로그 페이지 (캐로셀) +/// +/// 전투 이벤트 로그 표시. +class CombatLogPage extends StatelessWidget { + const CombatLogPage({super.key, required this.entries}); + + final List entries; + + @override + Widget build(BuildContext context) { + final localizations = L10n.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 헤더 + _buildSectionHeader(context, localizations.combatLog), + + // 로그 (CombatLog 재사용) + Expanded(child: CombatLog(entries: entries)), + ], + ); + } + + Widget _buildSectionHeader(BuildContext context, String title) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + color: Theme.of(context).colorScheme.primaryContainer, + child: Text( + title, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 13, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + ); + } +} diff --git a/lib/src/features/game/pages/equipment_page.dart b/lib/src/features/game/pages/equipment_page.dart new file mode 100644 index 0000000..fa7fe8b --- /dev/null +++ b/lib/src/features/game/pages/equipment_page.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; + +import 'package:askiineverdie/l10n/app_localizations.dart'; +import 'package:askiineverdie/src/core/model/game_state.dart'; +import 'package:askiineverdie/src/features/game/widgets/equipment_stats_panel.dart'; + +/// 장비 페이지 (캐로셀) +/// +/// 현재 장착된 장비 목록과 스탯 표시. +class EquipmentPage extends StatelessWidget { + const EquipmentPage({super.key, required this.equipment}); + + final Equipment equipment; + + @override + Widget build(BuildContext context) { + final localizations = L10n.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 장비 헤더 + _buildSectionHeader(context, localizations.equipment), + + // 장비 목록 (EquipmentStatsPanel 재사용) + Expanded(child: EquipmentStatsPanel(equipment: equipment)), + ], + ); + } + + Widget _buildSectionHeader(BuildContext context, String title) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + color: Theme.of(context).colorScheme.primaryContainer, + child: Text( + title, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 13, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + ); + } +} diff --git a/lib/src/features/game/pages/inventory_page.dart b/lib/src/features/game/pages/inventory_page.dart new file mode 100644 index 0000000..f368a91 --- /dev/null +++ b/lib/src/features/game/pages/inventory_page.dart @@ -0,0 +1,166 @@ +import 'package:flutter/material.dart'; + +import 'package:askiineverdie/data/game_text_l10n.dart' as l10n; +import 'package:askiineverdie/l10n/app_localizations.dart'; +import 'package:askiineverdie/src/core/l10n/game_data_l10n.dart'; +import 'package:askiineverdie/src/core/model/game_state.dart'; +import 'package:askiineverdie/src/core/model/potion.dart'; +import 'package:askiineverdie/src/features/game/widgets/potion_inventory_panel.dart'; + +/// 인벤토리 페이지 (캐로셀) +/// +/// 골드, 아이템 목록, 물약 인벤토리, 무게 표시. +class InventoryPage extends StatelessWidget { + const InventoryPage({ + super.key, + required this.inventory, + required this.potionInventory, + required this.encumbrance, + this.usedPotionTypes = const {}, + }); + + final Inventory inventory; + final PotionInventory potionInventory; + final ProgressBarState encumbrance; + final Set usedPotionTypes; + + @override + Widget build(BuildContext context) { + final localizations = L10n.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 인벤토리 (아이템) + _buildSectionHeader(context, localizations.inventory), + Expanded(flex: 2, child: _buildInventoryList(context)), + + // 물약 + _buildSectionHeader(context, l10n.uiPotions), + Expanded( + flex: 2, + child: PotionInventoryPanel( + inventory: potionInventory, + usedInBattle: usedPotionTypes, + ), + ), + + // 무게 (Encumbrance) + _buildSectionHeader(context, localizations.encumbrance), + _buildProgressBar(context), + ], + ); + } + + Widget _buildSectionHeader(BuildContext context, String title) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + color: Theme.of(context).colorScheme.primaryContainer, + child: Text( + title, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 13, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + ); + } + + Widget _buildInventoryList(BuildContext context) { + final localizations = L10n.of(context); + + return ListView.builder( + itemCount: inventory.items.length + 1, // +1 for gold + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + itemBuilder: (context, index) { + if (index == 0) { + // 골드 표시 + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + children: [ + const Icon( + Icons.monetization_on, + size: 16, + color: Colors.amber, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + localizations.gold, + style: const TextStyle(fontSize: 13), + ), + ), + Text( + '${inventory.gold}', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: Colors.amber, + ), + ), + ], + ), + ); + } + + final item = inventory.items[index - 1]; + final translatedName = GameDataL10n.translateItemString( + context, + item.name, + ); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + children: [ + const Icon(Icons.inventory_2, size: 16, color: Colors.grey), + const SizedBox(width: 8), + Expanded( + child: Text( + translatedName, + style: const TextStyle(fontSize: 13), + overflow: TextOverflow.ellipsis, + ), + ), + Text( + '${item.count}', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ); + }, + ); + } + + Widget _buildProgressBar(BuildContext context) { + final progress = encumbrance.max > 0 + ? (encumbrance.position / encumbrance.max).clamp(0.0, 1.0) + : 0.0; + final percentage = (progress * 100).toInt(); + + return Padding( + padding: const EdgeInsets.all(12), + child: Column( + children: [ + LinearProgressIndicator( + value: progress, + backgroundColor: Colors.orange.withValues(alpha: 0.2), + valueColor: const AlwaysStoppedAnimation(Colors.orange), + minHeight: 12, + ), + const SizedBox(height: 4), + Text( + '$percentage%', + style: TextStyle(fontSize: 10, color: Colors.grey.shade600), + ), + ], + ), + ); + } +} diff --git a/lib/src/features/game/pages/quest_page.dart b/lib/src/features/game/pages/quest_page.dart new file mode 100644 index 0000000..246c086 --- /dev/null +++ b/lib/src/features/game/pages/quest_page.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; + +import 'package:askiineverdie/l10n/app_localizations.dart'; +import 'package:askiineverdie/src/core/model/game_state.dart'; + +/// 퀘스트 페이지 (캐로셀) +/// +/// 퀘스트 히스토리 및 현재 퀘스트 진행 상황 표시. +class QuestPage extends StatelessWidget { + const QuestPage({super.key, required this.questHistory, required this.quest}); + + final List questHistory; + final ProgressBarState quest; + + @override + Widget build(BuildContext context) { + final localizations = L10n.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 헤더 + _buildSectionHeader(context, localizations.quests), + + // 퀘스트 목록 + Expanded(child: _buildQuestList(context)), + + // 퀘스트 프로그레스 + _buildProgressSection(context), + ], + ); + } + + Widget _buildSectionHeader(BuildContext context, String title) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + color: Theme.of(context).colorScheme.primaryContainer, + child: Text( + title, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 13, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + ); + } + + Widget _buildQuestList(BuildContext context) { + final localizations = L10n.of(context); + + if (questHistory.isEmpty) { + return Center( + child: Text( + localizations.noActiveQuests, + style: const TextStyle(fontSize: 13, color: Colors.grey), + ), + ); + } + + return ListView.builder( + itemCount: questHistory.length, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + itemBuilder: (context, index) { + final entry = questHistory[index]; + final isCurrentQuest = + index == questHistory.length - 1 && !entry.isComplete; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + if (isCurrentQuest) + const Icon(Icons.arrow_right, size: 18, color: Colors.blue) + else + Icon( + entry.isComplete + ? Icons.check_box + : Icons.check_box_outline_blank, + size: 18, + color: entry.isComplete ? Colors.green : Colors.grey, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + entry.caption, + style: TextStyle( + fontSize: 13, + decoration: entry.isComplete + ? TextDecoration.lineThrough + : null, + color: isCurrentQuest + ? Colors.blue + : entry.isComplete + ? Colors.grey + : null, + fontWeight: isCurrentQuest ? FontWeight.bold : null, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + }, + ); + } + + Widget _buildProgressSection(BuildContext context) { + final localizations = L10n.of(context); + final progress = quest.max > 0 + ? (quest.position / quest.max).clamp(0.0, 1.0) + : 0.0; + final percentage = (progress * 100).toInt(); + + return Padding( + padding: const EdgeInsets.all(12), + child: Column( + children: [ + LinearProgressIndicator( + value: progress, + backgroundColor: Colors.green.withValues(alpha: 0.2), + valueColor: const AlwaysStoppedAnimation(Colors.green), + minHeight: 12, + ), + const SizedBox(height: 4), + Text( + localizations.percentComplete(percentage), + style: TextStyle(fontSize: 10, color: Colors.grey.shade600), + ), + ], + ), + ); + } +} diff --git a/lib/src/features/game/pages/skills_page.dart b/lib/src/features/game/pages/skills_page.dart new file mode 100644 index 0000000..9c42fab --- /dev/null +++ b/lib/src/features/game/pages/skills_page.dart @@ -0,0 +1,162 @@ +import 'package:flutter/material.dart'; + +import 'package:askiineverdie/data/game_text_l10n.dart' as l10n; +import 'package:askiineverdie/data/skill_data.dart'; +import 'package:askiineverdie/l10n/app_localizations.dart'; +import 'package:askiineverdie/src/core/l10n/game_data_l10n.dart'; +import 'package:askiineverdie/src/core/model/game_state.dart'; +import 'package:askiineverdie/src/core/model/skill.dart'; +import 'package:askiineverdie/src/features/game/widgets/active_buff_panel.dart'; + +/// 스킬 페이지 (캐로셀) +/// +/// SpellBook 기반 스킬 목록과 활성 버프 표시. +class SkillsPage extends StatelessWidget { + const SkillsPage({ + super.key, + required this.spellBook, + required this.skillSystem, + }); + + final SpellBook spellBook; + final SkillSystemState skillSystem; + + @override + Widget build(BuildContext context) { + final localizations = L10n.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 스킬 목록 + _buildSectionHeader(context, localizations.spellBook), + Expanded(flex: 3, child: _buildSkillsList(context)), + + // 활성 버프 + _buildSectionHeader(context, l10n.uiBuffs), + Expanded( + flex: 2, + child: ActiveBuffPanel( + activeBuffs: skillSystem.activeBuffs, + currentMs: skillSystem.elapsedMs, + ), + ), + ], + ); + } + + Widget _buildSectionHeader(BuildContext context, String title) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + color: Theme.of(context).colorScheme.primaryContainer, + child: Text( + title, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 13, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + ); + } + + Widget _buildSkillsList(BuildContext context) { + if (spellBook.spells.isEmpty) { + return Center( + child: Text( + L10n.of(context).noSpellsYet, + style: const TextStyle(fontSize: 12, color: Colors.grey), + ), + ); + } + + return ListView.builder( + itemCount: spellBook.spells.length, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + itemBuilder: (context, index) { + final spell = spellBook.spells[index]; + final skill = SkillData.getSkillBySpellName(spell.name); + final spellName = GameDataL10n.getSpellName(context, spell.name); + + // 쿨타임 상태 확인 + final skillState = skill != null + ? skillSystem.getSkillState(skill.id) + : null; + final isOnCooldown = + skillState != null && + !skillState.isReady(skillSystem.elapsedMs, skill!.cooldownMs); + + return _SkillRow( + spellName: spellName, + rank: spell.rank, + skill: skill, + isOnCooldown: isOnCooldown, + ); + }, + ); + } +} + +/// 스킬 행 위젯 +class _SkillRow extends StatelessWidget { + const _SkillRow({ + required this.spellName, + required this.rank, + required this.skill, + required this.isOnCooldown, + }); + + final String spellName; + final String rank; + final Skill? skill; + final bool isOnCooldown; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + children: [ + // 스킬 타입 아이콘 + _buildTypeIcon(), + const SizedBox(width: 8), + // 스킬 이름 + Expanded( + child: Text( + spellName, + style: TextStyle( + fontSize: 13, + color: isOnCooldown ? Colors.grey : null, + ), + overflow: TextOverflow.ellipsis, + ), + ), + // 쿨타임 표시 + if (isOnCooldown) + const Icon(Icons.hourglass_empty, size: 14, color: Colors.orange), + const SizedBox(width: 8), + // 랭크 + Text( + rank, + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold), + ), + ], + ), + ); + } + + Widget _buildTypeIcon() { + if (skill == null) { + return const SizedBox(width: 16); + } + + 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: 16, color: color); + } +} diff --git a/lib/src/features/game/pages/story_page.dart b/lib/src/features/game/pages/story_page.dart new file mode 100644 index 0000000..78c40fc --- /dev/null +++ b/lib/src/features/game/pages/story_page.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart'; + +import 'package:askiineverdie/l10n/app_localizations.dart'; +import 'package:askiineverdie/src/core/model/game_state.dart'; +import 'package:askiineverdie/src/core/util/pq_logic.dart' as pq_logic; + +/// 스토리 페이지 (캐로셀) +/// +/// Plot 진행 상황 표시. +class StoryPage extends StatelessWidget { + const StoryPage({ + super.key, + required this.plotStageCount, + required this.plot, + }); + + final int plotStageCount; + final ProgressBarState plot; + + @override + Widget build(BuildContext context) { + final localizations = L10n.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 헤더 + _buildSectionHeader(context, localizations.plotDevelopment), + + // Plot 목록 + Expanded(child: _buildPlotList(context)), + + // Plot 프로그레스 + _buildProgressSection(context), + ], + ); + } + + Widget _buildSectionHeader(BuildContext context, String title) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + color: Theme.of(context).colorScheme.primaryContainer, + child: Text( + title, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 13, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + ); + } + + Widget _buildPlotList(BuildContext context) { + final localizations = L10n.of(context); + + if (plotStageCount == 0) { + return Center( + child: Text( + localizations.prologue, + style: const TextStyle(fontSize: 13, color: Colors.grey), + ), + ); + } + + return ListView.builder( + itemCount: plotStageCount, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + itemBuilder: (context, index) { + final isCompleted = index < plotStageCount - 1; + final label = index == 0 + ? localizations.prologue + : localizations.actNumber(_toRoman(index)); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Icon( + isCompleted ? Icons.check_box : Icons.check_box_outline_blank, + size: 18, + color: isCompleted ? Colors.green : Colors.grey, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + label, + style: TextStyle( + fontSize: 13, + decoration: isCompleted ? TextDecoration.lineThrough : null, + color: isCompleted ? Colors.grey : null, + ), + ), + ), + ], + ), + ); + }, + ); + } + + Widget _buildProgressSection(BuildContext context) { + final progress = plot.max > 0 + ? (plot.position / plot.max).clamp(0.0, 1.0) + : 0.0; + final remaining = plot.max - plot.position; + final remainingTime = pq_logic.roughTime(remaining); + + return Padding( + padding: const EdgeInsets.all(12), + child: Column( + children: [ + LinearProgressIndicator( + value: progress, + backgroundColor: Colors.purple.withValues(alpha: 0.2), + valueColor: const AlwaysStoppedAnimation(Colors.purple), + minHeight: 12, + ), + const SizedBox(height: 4), + Text( + '$remainingTime remaining', + style: TextStyle(fontSize: 10, color: Colors.grey.shade600), + ), + ], + ), + ); + } + + 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; + } +} diff --git a/lib/src/features/game/widgets/carousel_nav_bar.dart b/lib/src/features/game/widgets/carousel_nav_bar.dart new file mode 100644 index 0000000..39a2cf8 --- /dev/null +++ b/lib/src/features/game/widgets/carousel_nav_bar.dart @@ -0,0 +1,121 @@ +import 'package:flutter/material.dart'; + +import 'package:askiineverdie/data/game_text_l10n.dart' as l10n; + +/// 캐로셀 페이지 인덱스 +enum CarouselPage { + skills, // 0: 스킬 + inventory, // 1: 인벤토리 + equipment, // 2: 장비 + character, // 3: 캐릭터시트 (기본) + combatLog, // 4: 전투로그 + quest, // 5: 퀘스트 + story, // 6: 스토리 +} + +/// 캐로셀 네비게이션 바 +/// +/// 7개의 페이지 버튼을 표시하고 현재 페이지를 하이라이트. +/// 버튼 탭 시 해당 페이지로 이동. +class CarouselNavBar extends StatelessWidget { + const CarouselNavBar({ + super.key, + required this.currentPage, + required this.onPageSelected, + }); + + final int currentPage; + final ValueChanged onPageSelected; + + @override + Widget build(BuildContext context) { + return Container( + height: 56, + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + border: Border(top: BorderSide(color: Theme.of(context).dividerColor)), + ), + child: Row( + children: CarouselPage.values.map((page) { + final isSelected = page.index == currentPage; + return Expanded( + child: _NavButton( + page: page, + isSelected: isSelected, + onTap: () => onPageSelected(page.index), + ), + ); + }).toList(), + ), + ); + } +} + +/// 개별 네비게이션 버튼 +class _NavButton extends StatelessWidget { + const _NavButton({ + required this.page, + required this.isSelected, + required this.onTap, + }); + + final CarouselPage page; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final (icon, label) = _getIconAndLabel(page); + final theme = Theme.of(context); + final color = isSelected + ? theme.colorScheme.primary + : theme.colorScheme.onSurfaceVariant; + + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 4), + decoration: isSelected + ? BoxDecoration( + color: theme.colorScheme.primaryContainer.withValues( + alpha: 0.5, + ), + borderRadius: BorderRadius.circular(8), + ) + : null, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 20, color: color), + const SizedBox(height: 2), + Text( + label, + style: TextStyle( + fontSize: 9, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + color: color, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ); + } + + /// 페이지별 아이콘과 라벨 + (IconData, String) _getIconAndLabel(CarouselPage page) { + return switch (page) { + CarouselPage.skills => (Icons.auto_fix_high, l10n.navSkills), + CarouselPage.inventory => (Icons.inventory_2, l10n.navInventory), + CarouselPage.equipment => (Icons.shield, l10n.navEquipment), + CarouselPage.character => (Icons.person, l10n.navCharacter), + CarouselPage.combatLog => (Icons.list_alt, l10n.navCombatLog), + CarouselPage.story => (Icons.auto_stories, l10n.navStory), + CarouselPage.quest => (Icons.flag, l10n.navQuest), + }; + } +} diff --git a/lib/src/features/game/widgets/cinematic_view.dart b/lib/src/features/game/widgets/cinematic_view.dart index d3a7d2d..95f8fa1 100644 --- a/lib/src/features/game/widgets/cinematic_view.dart +++ b/lib/src/features/game/widgets/cinematic_view.dart @@ -165,7 +165,10 @@ class _CinematicViewState extends State onPressed: _skip, child: Text( l10n.uiSkip, - style: const TextStyle(color: Colors.white54, fontSize: 14), + style: const TextStyle( + color: Colors.white54, + fontSize: 14, + ), ), ), ), diff --git a/lib/src/features/game/widgets/enhanced_animation_panel.dart b/lib/src/features/game/widgets/enhanced_animation_panel.dart new file mode 100644 index 0000000..0d0524b --- /dev/null +++ b/lib/src/features/game/widgets/enhanced_animation_panel.dart @@ -0,0 +1,642 @@ +import 'package:flutter/material.dart'; + +import 'package:askiineverdie/data/game_text_l10n.dart' as l10n; +import 'package:askiineverdie/src/core/animation/ascii_animation_type.dart'; +import 'package:askiineverdie/src/core/model/combat_event.dart'; +import 'package:askiineverdie/src/core/model/combat_state.dart'; +import 'package:askiineverdie/src/core/model/game_state.dart'; +import 'package:askiineverdie/src/features/game/widgets/ascii_animation_card.dart'; + +/// 모바일용 확장 애니메이션 패널 +/// +/// 캐로셀 레이아웃에서 상단 영역에 표시되는 통합 패널: +/// - ASCII 애니메이션 (기존 높이 유지) +/// - 플레이어 HP/MP 컴팩트 바 (플로팅 텍스트 포함) +/// - 활성 버프 아이콘 (최대 3개) +/// - 몬스터 HP 바 (전투 중) +class EnhancedAnimationPanel extends StatefulWidget { + const EnhancedAnimationPanel({ + super.key, + required this.progress, + required this.stats, + required this.skillSystem, + required this.speedMultiplier, + required this.onSpeedCycle, + required this.isPaused, + required this.onPauseToggle, + this.specialAnimation, + this.weaponName, + this.shieldName, + this.characterLevel, + this.monsterLevel, + this.latestCombatEvent, + }); + + final ProgressState progress; + final Stats stats; + final SkillSystemState skillSystem; + final int speedMultiplier; + final VoidCallback onSpeedCycle; + final bool isPaused; + final VoidCallback onPauseToggle; + final AsciiAnimationType? specialAnimation; + final String? weaponName; + final String? shieldName; + final int? characterLevel; + final int? monsterLevel; + final CombatEvent? latestCombatEvent; + + @override + State createState() => _EnhancedAnimationPanelState(); +} + +class _EnhancedAnimationPanelState extends State + with TickerProviderStateMixin { + // HP/MP 변화 애니메이션 + late AnimationController _hpFlashController; + late AnimationController _mpFlashController; + late AnimationController _monsterFlashController; + late Animation _hpFlashAnimation; + late Animation _mpFlashAnimation; + late Animation _monsterFlashAnimation; + + int _hpChange = 0; + int _mpChange = 0; + int _monsterHpChange = 0; + + // 이전 값 추적 + int _lastHp = 0; + int _lastMp = 0; + int _lastMonsterHp = 0; + + @override + void initState() { + super.initState(); + + _hpFlashController = AnimationController( + duration: const Duration(milliseconds: 500), + vsync: this, + ); + _mpFlashController = AnimationController( + duration: const Duration(milliseconds: 500), + vsync: this, + ); + _monsterFlashController = AnimationController( + duration: const Duration(milliseconds: 500), + vsync: this, + ); + + _hpFlashAnimation = Tween(begin: 1.0, end: 0.0).animate( + CurvedAnimation(parent: _hpFlashController, curve: Curves.easeOut), + ); + _mpFlashAnimation = Tween(begin: 1.0, end: 0.0).animate( + CurvedAnimation(parent: _mpFlashController, curve: Curves.easeOut), + ); + _monsterFlashAnimation = Tween(begin: 1.0, end: 0.0).animate( + CurvedAnimation(parent: _monsterFlashController, curve: Curves.easeOut), + ); + + // 초기값 설정 + _lastHp = _currentHp; + _lastMp = _currentMp; + _lastMonsterHp = _currentMonsterHp ?? 0; + } + + @override + void didUpdateWidget(EnhancedAnimationPanel oldWidget) { + super.didUpdateWidget(oldWidget); + + // HP 변화 감지 + final newHp = _currentHp; + if (newHp != _lastHp) { + _hpChange = newHp - _lastHp; + _hpFlashController.forward(from: 0.0); + _lastHp = newHp; + } + + // MP 변화 감지 + final newMp = _currentMp; + if (newMp != _lastMp) { + _mpChange = newMp - _lastMp; + _mpFlashController.forward(from: 0.0); + _lastMp = newMp; + } + + // 몬스터 HP 변화 감지 + final newMonsterHp = _currentMonsterHp; + if (newMonsterHp != null && newMonsterHp != _lastMonsterHp) { + _monsterHpChange = newMonsterHp - _lastMonsterHp; + _monsterFlashController.forward(from: 0.0); + _lastMonsterHp = newMonsterHp; + } else if (newMonsterHp == null) { + _lastMonsterHp = 0; + } + } + + int get _currentHp => + widget.progress.currentCombat?.playerStats.hpCurrent ?? widget.stats.hp; + int get _currentHpMax => + widget.progress.currentCombat?.playerStats.hpMax ?? widget.stats.hpMax; + int get _currentMp => + widget.progress.currentCombat?.playerStats.mpCurrent ?? widget.stats.mp; + int get _currentMpMax => + widget.progress.currentCombat?.playerStats.mpMax ?? widget.stats.mpMax; + int? get _currentMonsterHp => + widget.progress.currentCombat?.monsterStats.hpCurrent; + int? get _currentMonsterHpMax => + widget.progress.currentCombat?.monsterStats.hpMax; + + @override + void dispose() { + _hpFlashController.dispose(); + _mpFlashController.dispose(); + _monsterFlashController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final combat = widget.progress.currentCombat; + final isInCombat = combat != null && combat.isActive; + + return Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + border: Border( + bottom: BorderSide(color: Theme.of(context).dividerColor), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // ASCII 애니메이션 (기존 높이 120 유지) + SizedBox( + height: 120, + child: AsciiAnimationCard( + taskType: widget.progress.currentTask.type, + monsterBaseName: widget.progress.currentTask.monsterBaseName, + specialAnimation: widget.specialAnimation, + weaponName: widget.weaponName, + shieldName: widget.shieldName, + characterLevel: widget.characterLevel, + monsterLevel: widget.monsterLevel, + isPaused: widget.isPaused, + latestCombatEvent: widget.latestCombatEvent, + ), + ), + + const SizedBox(height: 8), + + // 상태 바 영역: HP/MP + 버프 아이콘 + 몬스터 HP + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 좌측: HP/MP 바 + Expanded( + flex: 3, + child: Column( + children: [ + _buildCompactHpBar(), + const SizedBox(height: 4), + _buildCompactMpBar(), + ], + ), + ), + + const SizedBox(width: 8), + + // 중앙: 활성 버프 아이콘 (최대 3개) + _buildBuffIcons(), + + const SizedBox(width: 8), + + // 우측: 몬스터 HP (전투 중) 또는 컨트롤 버튼 + Expanded( + flex: 2, + child: isInCombat + ? _buildMonsterHpBar(combat) + : _buildControlButtons(), + ), + ], + ), + + const SizedBox(height: 6), + + // 하단: 태스크 프로그레스 바 + 캡션 + _buildTaskProgress(), + ], + ), + ); + } + + /// 컴팩트 HP 바 + Widget _buildCompactHpBar() { + final ratio = _currentHpMax > 0 ? _currentHp / _currentHpMax : 0.0; + final isLow = ratio < 0.2 && ratio > 0; + + return AnimatedBuilder( + animation: _hpFlashAnimation, + builder: (context, child) { + return Stack( + clipBehavior: Clip.none, + children: [ + // HP 바 + Container( + height: 14, + decoration: BoxDecoration( + color: isLow + ? Colors.red.withValues(alpha: 0.2) + : Colors.grey.shade800, + borderRadius: BorderRadius.circular(3), + ), + child: Row( + children: [ + // 라벨 + Container( + width: 28, + alignment: Alignment.center, + child: Text( + l10n.statHp, + style: const TextStyle( + fontSize: 9, + fontWeight: FontWeight.bold, + color: Colors.white70, + ), + ), + ), + // 프로그레스 + Expanded( + child: ClipRRect( + borderRadius: const BorderRadius.horizontal( + right: Radius.circular(3), + ), + child: LinearProgressIndicator( + value: ratio.clamp(0.0, 1.0), + backgroundColor: Colors.red.withValues(alpha: 0.2), + valueColor: AlwaysStoppedAnimation( + isLow ? Colors.red : Colors.red.shade600, + ), + minHeight: 14, + ), + ), + ), + // 수치 + Container( + width: 48, + alignment: Alignment.center, + child: Text( + '$_currentHp/$_currentHpMax', + style: const TextStyle(fontSize: 8, color: Colors.white), + ), + ), + ], + ), + ), + + // 플로팅 변화량 + if (_hpChange != 0 && _hpFlashAnimation.value > 0.05) + Positioned( + right: 50, + top: -8, + child: Transform.translate( + offset: Offset(0, -10 * (1 - _hpFlashAnimation.value)), + child: Opacity( + opacity: _hpFlashAnimation.value, + child: Text( + _hpChange > 0 ? '+$_hpChange' : '$_hpChange', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: _hpChange < 0 ? Colors.red : Colors.green, + shadows: const [ + Shadow(color: Colors.black, blurRadius: 3), + ], + ), + ), + ), + ), + ), + ], + ); + }, + ); + } + + /// 컴팩트 MP 바 + Widget _buildCompactMpBar() { + final ratio = _currentMpMax > 0 ? _currentMp / _currentMpMax : 0.0; + + return AnimatedBuilder( + animation: _mpFlashAnimation, + builder: (context, child) { + return Stack( + clipBehavior: Clip.none, + children: [ + Container( + height: 14, + decoration: BoxDecoration( + color: Colors.grey.shade800, + borderRadius: BorderRadius.circular(3), + ), + child: Row( + children: [ + Container( + width: 28, + alignment: Alignment.center, + child: Text( + l10n.statMp, + style: const TextStyle( + fontSize: 9, + fontWeight: FontWeight.bold, + color: Colors.white70, + ), + ), + ), + Expanded( + child: ClipRRect( + borderRadius: const BorderRadius.horizontal( + right: Radius.circular(3), + ), + child: LinearProgressIndicator( + value: ratio.clamp(0.0, 1.0), + backgroundColor: Colors.blue.withValues(alpha: 0.2), + valueColor: AlwaysStoppedAnimation( + Colors.blue.shade600, + ), + minHeight: 14, + ), + ), + ), + Container( + width: 48, + alignment: Alignment.center, + child: Text( + '$_currentMp/$_currentMpMax', + style: const TextStyle(fontSize: 8, color: Colors.white), + ), + ), + ], + ), + ), + + if (_mpChange != 0 && _mpFlashAnimation.value > 0.05) + Positioned( + right: 50, + top: -8, + child: Transform.translate( + offset: Offset(0, -10 * (1 - _mpFlashAnimation.value)), + child: Opacity( + opacity: _mpFlashAnimation.value, + child: Text( + _mpChange > 0 ? '+$_mpChange' : '$_mpChange', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: _mpChange < 0 ? Colors.orange : Colors.cyan, + shadows: const [ + Shadow(color: Colors.black, blurRadius: 3), + ], + ), + ), + ), + ), + ), + ], + ); + }, + ); + } + + /// 활성 버프 아이콘 (최대 3개) + Widget _buildBuffIcons() { + final buffs = widget.skillSystem.activeBuffs; + final currentMs = widget.skillSystem.elapsedMs; + + if (buffs.isEmpty) { + return const SizedBox(width: 60); + } + + // 최대 3개만 표시 + final displayBuffs = buffs.take(3).toList(); + + return SizedBox( + width: 60, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: displayBuffs.map((buff) { + final remainingMs = buff.remainingDuration(currentMs); + final progress = remainingMs / buff.effect.durationMs; + final isExpiring = remainingMs < 3000; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Stack( + alignment: Alignment.center, + children: [ + // 진행률 원형 표시 + SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + value: progress.clamp(0.0, 1.0), + strokeWidth: 2, + backgroundColor: Colors.grey.shade700, + valueColor: AlwaysStoppedAnimation( + isExpiring ? Colors.orange : Colors.lightBlue, + ), + ), + ), + // 버프 아이콘 + Icon( + Icons.trending_up, + size: 10, + color: isExpiring ? Colors.orange : Colors.lightBlue, + ), + ], + ), + ); + }).toList(), + ), + ); + } + + /// 몬스터 HP 바 (전투 중) + Widget _buildMonsterHpBar(CombatState combat) { + final max = _currentMonsterHpMax ?? 1; + final current = _currentMonsterHp ?? 0; + final ratio = max > 0 ? current / max : 0.0; + + return AnimatedBuilder( + animation: _monsterFlashAnimation, + builder: (context, child) { + return Stack( + clipBehavior: Clip.none, + children: [ + Container( + height: 32, + decoration: BoxDecoration( + color: Colors.orange.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.orange.withValues(alpha: 0.3)), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // HP 바 + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator( + value: ratio.clamp(0.0, 1.0), + backgroundColor: Colors.orange.withValues(alpha: 0.2), + valueColor: const AlwaysStoppedAnimation(Colors.orange), + minHeight: 8, + ), + ), + ), + const SizedBox(height: 2), + // 퍼센트 + Text( + '${(ratio * 100).toInt()}%', + style: const TextStyle( + fontSize: 9, + color: Colors.orange, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + + // 플로팅 데미지 + if (_monsterHpChange != 0 && _monsterFlashAnimation.value > 0.05) + Positioned( + right: 10, + top: -10, + child: Transform.translate( + offset: Offset(0, -10 * (1 - _monsterFlashAnimation.value)), + child: Opacity( + opacity: _monsterFlashAnimation.value, + child: Text( + _monsterHpChange > 0 + ? '+$_monsterHpChange' + : '$_monsterHpChange', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: _monsterHpChange < 0 + ? Colors.yellow + : Colors.green, + shadows: const [ + Shadow(color: Colors.black, blurRadius: 3), + ], + ), + ), + ), + ), + ), + ], + ); + }, + ); + } + + /// 컨트롤 버튼 (비전투 시) + Widget _buildControlButtons() { + return SizedBox( + height: 32, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // 일시정지 버튼 + SizedBox( + width: 36, + height: 28, + child: OutlinedButton( + onPressed: widget.onPauseToggle, + style: OutlinedButton.styleFrom( + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + side: BorderSide( + color: widget.isPaused + ? Colors.orange.withValues(alpha: 0.7) + : Theme.of( + context, + ).colorScheme.outline.withValues(alpha: 0.5), + ), + ), + child: Icon( + widget.isPaused ? Icons.play_arrow : Icons.pause, + size: 14, + color: widget.isPaused ? Colors.orange : null, + ), + ), + ), + const SizedBox(width: 4), + // 속도 버튼 + SizedBox( + width: 36, + height: 28, + child: OutlinedButton( + onPressed: widget.onSpeedCycle, + style: OutlinedButton.styleFrom( + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + ), + child: Text( + '${widget.speedMultiplier}x', + style: TextStyle( + fontSize: 10, + fontWeight: widget.speedMultiplier > 1 + ? FontWeight.bold + : FontWeight.normal, + color: widget.speedMultiplier > 1 + ? Theme.of(context).colorScheme.primary + : null, + ), + ), + ), + ), + ], + ), + ); + } + + /// 태스크 프로그레스 바 + Widget _buildTaskProgress() { + final task = widget.progress.task; + final progressValue = task.max > 0 + ? (task.position / task.max).clamp(0.0, 1.0) + : 0.0; + + return Column( + children: [ + // 캡션 + Text( + widget.progress.currentTask.caption, + style: Theme.of(context).textTheme.bodySmall, + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + // 프로그레스 바 + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: LinearProgressIndicator( + value: progressValue, + backgroundColor: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.2), + valueColor: AlwaysStoppedAnimation( + Theme.of(context).colorScheme.primary, + ), + minHeight: 10, + ), + ), + ], + ); + } +} diff --git a/lib/src/features/game/widgets/equipment_stats_panel.dart b/lib/src/features/game/widgets/equipment_stats_panel.dart index 3a8d178..3a1d9b4 100644 --- a/lib/src/features/game/widgets/equipment_stats_panel.dart +++ b/lib/src/features/game/widgets/equipment_stats_panel.dart @@ -308,7 +308,10 @@ class _StatsGrid extends StatelessWidget { } if (stats.criRate > 0) { entries.add( - _StatEntry(l10n.statCri, '${(stats.criRate * 100).toStringAsFixed(1)}%'), + _StatEntry( + l10n.statCri, + '${(stats.criRate * 100).toStringAsFixed(1)}%', + ), ); } if (stats.parryRate > 0) { @@ -335,7 +338,10 @@ class _StatsGrid extends StatelessWidget { } if (stats.evasion > 0) { entries.add( - _StatEntry(l10n.statEva, '${(stats.evasion * 100).toStringAsFixed(1)}%'), + _StatEntry( + l10n.statEva, + '${(stats.evasion * 100).toStringAsFixed(1)}%', + ), ); } diff --git a/lib/src/features/game/widgets/notification_overlay.dart b/lib/src/features/game/widgets/notification_overlay.dart index a73a85f..e37690e 100644 --- a/lib/src/features/game/widgets/notification_overlay.dart +++ b/lib/src/features/game/widgets/notification_overlay.dart @@ -40,8 +40,9 @@ class _NotificationOverlayState extends State vsync: this, ); - _slideAnimation = - Tween(begin: const Offset(0, -1), end: Offset.zero).animate( + // 하단에서 슬라이드 인/아웃 + _slideAnimation = Tween(begin: const Offset(0, 1), end: Offset.zero) + .animate( CurvedAnimation( parent: _animationController, curve: Curves.easeOutBack, @@ -86,7 +87,7 @@ class _NotificationOverlayState extends State widget.child, if (_currentNotification != null) Positioned( - top: MediaQuery.of(context).padding.top + 16, + bottom: MediaQuery.of(context).padding.bottom + 80, left: 16, right: 16, child: SlideTransition( @@ -214,6 +215,21 @@ class _NotificationCard extends StatelessWidget { Icons.whatshot, Colors.redAccent, ), + NotificationType.gameSaved => ( + const Color(0xFF00695C), + Icons.save, + Colors.tealAccent, + ), + NotificationType.info => ( + const Color(0xFF0277BD), + Icons.info_outline, + Colors.lightBlueAccent, + ), + NotificationType.warning => ( + const Color(0xFFF57C00), + Icons.warning_amber, + Colors.amber, + ), }; } } diff --git a/lib/src/features/game/widgets/stats_panel.dart b/lib/src/features/game/widgets/stats_panel.dart index 40a9942..be79cc8 100644 --- a/lib/src/features/game/widgets/stats_panel.dart +++ b/lib/src/features/game/widgets/stats_panel.dart @@ -120,6 +120,8 @@ class _StatsPanelState extends State return ListView.builder( itemCount: stats.length, padding: const EdgeInsets.symmetric(horizontal: 8), + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), itemBuilder: (context, index) { final stat = stats[index]; final change = _statChanges[stat.$1]; diff --git a/lib/src/features/new_character/new_character_screen.dart b/lib/src/features/new_character/new_character_screen.dart index 04da1c4..804d82e 100644 --- a/lib/src/features/new_character/new_character_screen.dart +++ b/lib/src/features/new_character/new_character_screen.dart @@ -18,7 +18,9 @@ class NewCharacterScreen extends StatefulWidget { const NewCharacterScreen({super.key, this.onCharacterCreated}); /// 캐릭터 생성 완료 시 호출되는 콜백 - final void Function(GameState initialState)? onCharacterCreated; + /// testMode: 웹에서도 모바일 캐로셀 레이아웃 사용 + final void Function(GameState initialState, {bool testMode})? + onCharacterCreated; @override State createState() => _NewCharacterScreenState(); @@ -53,6 +55,9 @@ class _NewCharacterScreenState extends State { // 이름 생성용 RNG late DeterministicRandom _nameRng; + // 테스트 모드 (웹에서 모바일 캐로셀 레이아웃 활성화) + bool _testModeEnabled = false; + @override void initState() { super.initState(); @@ -198,7 +203,7 @@ class _NewCharacterScreenState extends State { queue: QueueState.empty(), ); - widget.onCharacterCreated?.call(initialState); + widget.onCharacterCreated?.call(initialState, testMode: _testModeEnabled); } @override @@ -230,6 +235,10 @@ class _NewCharacterScreenState extends State { Expanded(child: _buildKlassSection()), ], ), + const SizedBox(height: 16), + + // 테스트 모드 토글 (웹에서 모바일 레이아웃 테스트) + _buildTestModeToggle(), const SizedBox(height: 24), // Sold! 버튼 @@ -583,8 +592,9 @@ class _NewCharacterScreenState extends State { final percent = (passive.value * 100).round(); return switch (passive.type) { ClassPassiveType.hpBonus => game_l10n.passiveHpBonus(percent), - ClassPassiveType.physicalDamageBonus => - game_l10n.passivePhysicalBonus(percent), + ClassPassiveType.physicalDamageBonus => game_l10n.passivePhysicalBonus( + percent, + ), ClassPassiveType.defenseBonus => game_l10n.passiveDefenseBonus(percent), ClassPassiveType.magicDamageBonus => game_l10n.passiveMagicBonus(percent), ClassPassiveType.evasionBonus => game_l10n.passiveEvasionBonus(percent), @@ -595,4 +605,17 @@ class _NewCharacterScreenState extends State { ClassPassiveType.firstStrikeBonus => passive.description, }; } + + /// 테스트 모드 토글 위젯 + Widget _buildTestModeToggle() { + return Card( + child: SwitchListTile( + title: Text(game_l10n.uiTestMode), + subtitle: Text(game_l10n.uiTestModeDesc), + value: _testModeEnabled, + onChanged: (value) => setState(() => _testModeEnabled = value), + secondary: const Icon(Icons.phone_android), + ), + ); + } } diff --git a/test/core/engine/progress_loop_test.dart b/test/core/engine/progress_loop_test.dart index 4bc72d0..295ca65 100644 --- a/test/core/engine/progress_loop_test.dart +++ b/test/core/engine/progress_loop_test.dart @@ -25,6 +25,14 @@ class _FakeSaveManager implements SaveManager { @override Future> listSaves() async => []; + + @override + Future deleteSave({String? fileName}) async { + return const SaveOutcome.success(); + } + + @override + Future saveExists({String? fileName}) async => false; } void main() { diff --git a/test/features/game_play_screen_test.dart b/test/features/game_play_screen_test.dart index 749e548..dd28ca1 100644 --- a/test/features/game_play_screen_test.dart +++ b/test/features/game_play_screen_test.dart @@ -34,6 +34,14 @@ class _FakeSaveManager implements SaveManager { @override Future> listSaves() async => []; + + @override + Future deleteSave({String? fileName}) async { + return const SaveOutcome.success(); + } + + @override + Future saveExists({String? fileName}) async => false; } GameState _createTestState() { @@ -95,7 +103,9 @@ void main() { await controller.startNew(_createTestState(), isNewGame: false); await tester.pumpWidget( - _buildTestApp(GamePlayScreen(controller: controller)), + _buildTestApp( + GamePlayScreen(controller: controller, forceDesktopLayout: true), + ), ); // AppBar 타이틀 확인 (L10n 사용) - ASCII NEVER DIE @@ -121,7 +131,9 @@ void main() { await controller.startNew(_createTestState(), isNewGame: false); await tester.pumpWidget( - _buildTestApp(GamePlayScreen(controller: controller)), + _buildTestApp( + GamePlayScreen(controller: controller, forceDesktopLayout: true), + ), ); // Traits 섹션 확인 @@ -143,7 +155,9 @@ void main() { await controller.startNew(_createTestState(), isNewGame: false); await tester.pumpWidget( - _buildTestApp(GamePlayScreen(controller: controller)), + _buildTestApp( + GamePlayScreen(controller: controller, forceDesktopLayout: true), + ), ); // Stats 섹션 확인 (스크롤로 인해 화면 밖에 있을 수 있음) @@ -164,7 +178,9 @@ void main() { await controller.startNew(_createTestState(), isNewGame: false); await tester.pumpWidget( - _buildTestApp(GamePlayScreen(controller: controller)), + _buildTestApp( + GamePlayScreen(controller: controller, forceDesktopLayout: true), + ), ); // 현재 태스크 캡션 확인 (퀘스트 목록과 하단 패널에 표시됨) @@ -183,7 +199,9 @@ void main() { await controller.startNew(_createTestState(), isNewGame: false); await tester.pumpWidget( - _buildTestApp(GamePlayScreen(controller: controller)), + _buildTestApp( + GamePlayScreen(controller: controller, forceDesktopLayout: true), + ), ); // LinearProgressIndicator가 여러 개 표시되는지 확인 @@ -200,7 +218,9 @@ void main() { // 상태 없이 시작 (startNew 호출 안 함) await tester.pumpWidget( - _buildTestApp(GamePlayScreen(controller: controller)), + _buildTestApp( + GamePlayScreen(controller: controller, forceDesktopLayout: true), + ), ); // 로딩 인디케이터 확인 diff --git a/test/features/game_session_controller_test.dart b/test/features/game_session_controller_test.dart index bc34367..42ad55d 100644 --- a/test/features/game_session_controller_test.dart +++ b/test/features/game_session_controller_test.dart @@ -31,6 +31,14 @@ class FakeSaveManager implements SaveManager { @override Future> listSaves() async => []; + + @override + Future deleteSave({String? fileName}) async { + return const SaveOutcome.success(); + } + + @override + Future saveExists({String? fileName}) async => false; } void main() { diff --git a/test/features/new_character_screen_test.dart b/test/features/new_character_screen_test.dart index 4211035..25283c5 100644 --- a/test/features/new_character_screen_test.dart +++ b/test/features/new_character_screen_test.dart @@ -16,7 +16,9 @@ Widget _buildTestApp(Widget child) { void main() { testWidgets('NewCharacterScreen renders main sections', (tester) async { await tester.pumpWidget( - _buildTestApp(NewCharacterScreen(onCharacterCreated: (_) {})), + _buildTestApp( + NewCharacterScreen(onCharacterCreated: (_, {bool testMode = false}) {}), + ), ); // 화면 타이틀 확인 (l10n 적용됨) @@ -39,7 +41,9 @@ void main() { testWidgets('Unroll button exists and can be tapped', (tester) async { await tester.pumpWidget( - _buildTestApp(NewCharacterScreen(onCharacterCreated: (_) {})), + _buildTestApp( + NewCharacterScreen(onCharacterCreated: (_, {bool testMode = false}) {}), + ), ); // Unroll 버튼 확인 @@ -62,7 +66,7 @@ void main() { await tester.pumpWidget( _buildTestApp( NewCharacterScreen( - onCharacterCreated: (state) { + onCharacterCreated: (state, {bool testMode = false}) { createdState = state; }, ), @@ -91,7 +95,9 @@ void main() { testWidgets('Stats section displays all six stats', (tester) async { await tester.pumpWidget( - _buildTestApp(NewCharacterScreen(onCharacterCreated: (_) {})), + _buildTestApp( + NewCharacterScreen(onCharacterCreated: (_, {bool testMode = false}) {}), + ), ); // 능력치 라벨들이 표시되는지 확인 @@ -108,7 +114,9 @@ void main() { testWidgets('Name text field exists', (tester) async { await tester.pumpWidget( - _buildTestApp(NewCharacterScreen(onCharacterCreated: (_) {})), + _buildTestApp( + NewCharacterScreen(onCharacterCreated: (_, {bool testMode = false}) {}), + ), ); // TextField 확인 (이름 입력 필드) diff --git a/test/widget_test.dart b/test/widget_test.dart index c6bc0b6..f8e6c2c 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -7,8 +7,16 @@ void main() { ) async { await tester.pumpWidget(const AskiiNeverDieApp()); + // 세이브 파일 확인이 완료될 때까지 대기 (스플래시 → 프론트) + // runAsync로 비동기 파일 작업 완료 대기 + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 100)), + ); + await tester.pump(); // 상태 업데이트 반영 + // 프런트 화면이 렌더링되었는지 확인 expect(find.text('ASCII NEVER DIE'), findsOneWidget); + expect(find.text('New character'), findsOneWidget); // "New character" 버튼 탭 await tester.tap(find.text('New character'));