diff --git a/lib/data/game_text_l10n.dart b/lib/data/game_text_l10n.dart index 47b953b..0427a58 100644 --- a/lib/data/game_text_l10n.dart +++ b/lib/data/game_text_l10n.dart @@ -1659,3 +1659,62 @@ String get buttonCancel { if (isJapaneseLocale) return 'キャンセル'; return 'Cancel'; } + +// ============================================================================ +// 프론트 화면 경고/푸터 텍스트 +// ============================================================================ + +String get uiWarning { + if (isKoreanLocale) return '경고'; + if (isJapaneseLocale) return '警告'; + return 'Warning'; +} + +String get warningDeleteSave { + if (isKoreanLocale) return '기존 저장 파일이 삭제됩니다. 계속하시겠습니까?'; + if (isJapaneseLocale) return '既存のセーブファイルが削除されます。続行しますか?'; + return 'Existing save file will be deleted. Continue?'; +} + +String get copyrightText { + // 카피라이트 텍스트는 언어에 따라 변하지 않음 + return '© 2025 naturebridgeai & cclabs all rights reserved'; +} + +// ============================================================================ +// 테마 설정 텍스트 +// ============================================================================ + +String get menuTheme { + if (isKoreanLocale) return '테마'; + if (isJapaneseLocale) return 'テーマ'; + return 'Theme'; +} + +String get themeLight { + if (isKoreanLocale) return '라이트'; + if (isJapaneseLocale) return 'ライト'; + return 'Light'; +} + +String get themeDark { + if (isKoreanLocale) return '다크'; + if (isJapaneseLocale) return 'ダーク'; + return 'Dark'; +} + +String get themeSystem { + if (isKoreanLocale) return '시스템'; + if (isJapaneseLocale) return 'システム'; + return 'System'; +} + +// ============================================================================ +// 로딩 텍스트 +// ============================================================================ + +String get uiLoading { + if (isKoreanLocale) return '불러오는 중...'; + if (isJapaneseLocale) return '読み込み中...'; + return 'Loading...'; +} diff --git a/lib/src/app.dart b/lib/src/app.dart index 03cfebb..4ac5c54 100644 --- a/lib/src/app.dart +++ b/lib/src/app.dart @@ -10,6 +10,7 @@ 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/core/storage/settings_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'; @@ -28,8 +29,10 @@ class AskiiNeverDieApp extends StatefulWidget { class _AskiiNeverDieAppState extends State { late final GameSessionController _controller; late final NotificationService _notificationService; + late final SettingsRepository _settingsRepository; bool _isCheckingSave = true; bool _hasSave = false; + ThemeMode _themeMode = ThemeMode.system; @override void initState() { @@ -47,11 +50,28 @@ class _AskiiNeverDieAppState extends State { saveManager: SaveManager(SaveRepository()), ); _notificationService = NotificationService(); + _settingsRepository = SettingsRepository(); + // 초기 설정 로드 + _loadSettings(); // 세이브 파일 존재 여부 확인 _checkForExistingSave(); } + /// 저장된 설정 불러오기 + Future _loadSettings() async { + final themeMode = await _settingsRepository.loadThemeMode(); + if (mounted) { + setState(() => _themeMode = themeMode); + } + } + + /// 테마 모드 변경 + void _changeThemeMode(ThemeMode mode) { + setState(() => _themeMode = mode); + _settingsRepository.saveThemeMode(mode); + } + /// 세이브 파일 존재 여부 확인 후 자동 로드 Future _checkForExistingSave() async { final exists = await _controller.saveManager.saveExists(); @@ -70,6 +90,32 @@ class _AskiiNeverDieAppState extends State { super.dispose(); } + /// 라이트 테마 + ThemeData get _lightTheme => ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF234361)), + scaffoldBackgroundColor: const Color(0xFFF4F5F7), + useMaterial3: true, + ); + + /// 다크 테마 (OLED 저전력 모드 - 순수 검정) + ThemeData get _darkTheme => ThemeData( + colorScheme: ColorScheme.dark( + surface: Colors.black, + primary: const Color(0xFF4FC3F7), // 시안 + secondary: const Color(0xFFFF4081), // 마젠타 + onSurface: Colors.white70, + primaryContainer: const Color(0xFF1A3A4A), + onPrimaryContainer: Colors.white, + ), + scaffoldBackgroundColor: Colors.black, + useMaterial3: true, + // 카드/다이얼로그도 검정 배경 사용 + cardColor: const Color(0xFF121212), + dialogTheme: const DialogThemeData( + backgroundColor: Color(0xFF121212), + ), + ); + @override Widget build(BuildContext context) { return MaterialApp( @@ -77,11 +123,9 @@ class _AskiiNeverDieAppState extends State { debugShowCheckedModeBanner: false, localizationsDelegates: L10n.localizationsDelegates, supportedLocales: L10n.supportedLocales, - theme: ThemeData( - colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF234361)), - scaffoldBackgroundColor: const Color(0xFFF4F5F7), - useMaterial3: true, - ), + theme: _lightTheme, + darkTheme: _darkTheme, + themeMode: _themeMode, builder: (context, child) { // 현재 로케일을 게임 텍스트 l10n 시스템에 동기화 final locale = Localizations.localeOf(context); @@ -110,6 +154,8 @@ class _AskiiNeverDieAppState extends State { // 로드 실패 시 프론트 화면으로 setState(() => _hasSave = false); }, + currentThemeMode: _themeMode, + onThemeModeChange: _changeThemeMode, ); } @@ -118,6 +164,7 @@ class _AskiiNeverDieAppState extends State { onNewCharacter: _navigateToNewCharacter, onLoadSave: _loadSave, onHallOfFame: _navigateToHallOfFame, + hasSaveFile: _hasSave, ); } @@ -187,6 +234,8 @@ class _AskiiNeverDieAppState extends State { builder: (context) => GamePlayScreen( controller: _controller, forceCarouselLayout: testMode, + currentThemeMode: _themeMode, + onThemeModeChange: _changeThemeMode, ), ), ); @@ -196,7 +245,11 @@ class _AskiiNeverDieAppState extends State { void _navigateToGame(BuildContext context) { Navigator.of(context).push( MaterialPageRoute( - builder: (context) => GamePlayScreen(controller: _controller), + builder: (context) => GamePlayScreen( + controller: _controller, + currentThemeMode: _themeMode, + onThemeModeChange: _changeThemeMode, + ), ), ); } @@ -235,10 +288,17 @@ class _SplashScreen extends StatelessWidget { /// 자동 로드 화면 (세이브 파일 자동 로드) class _AutoLoadScreen extends StatefulWidget { - const _AutoLoadScreen({required this.controller, required this.onLoadFailed}); + const _AutoLoadScreen({ + required this.controller, + required this.onLoadFailed, + required this.currentThemeMode, + required this.onThemeModeChange, + }); final GameSessionController controller; final VoidCallback onLoadFailed; + final ThemeMode currentThemeMode; + final void Function(ThemeMode mode) onThemeModeChange; @override State<_AutoLoadScreen> createState() => _AutoLoadScreenState(); @@ -262,7 +322,8 @@ class _AutoLoadScreenState extends State<_AutoLoadScreen> { MaterialPageRoute( builder: (context) => GamePlayScreen( controller: widget.controller, - // 자동 로드 시에는 플랫폼 기본값 사용 (모바일만 캐로셀) + currentThemeMode: widget.currentThemeMode, + onThemeModeChange: widget.onThemeModeChange, ), ), ); @@ -274,19 +335,19 @@ class _AutoLoadScreenState extends State<_AutoLoadScreen> { @override Widget build(BuildContext context) { - return const Scaffold( + return Scaffold( body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text( + const Text( 'ASCII NEVER DIE', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), ), - SizedBox(height: 16), - CircularProgressIndicator(), - SizedBox(height: 16), - Text('Loading...'), + const SizedBox(height: 16), + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text(game_l10n.uiLoading), ], ), ), diff --git a/lib/src/core/animation/front_screen_animation.dart b/lib/src/core/animation/front_screen_animation.dart new file mode 100644 index 0000000..c78e23d --- /dev/null +++ b/lib/src/core/animation/front_screen_animation.dart @@ -0,0 +1,89 @@ +// 프론트 화면용 Hero vs Glitch God ASCII 애니메이션 데이터 +// 작은 용사가 거대한 Glitch God에 맞서는 장면을 표현 + +/// 애니메이션 프레임 (10줄, 6프레임 루프) +const frontScreenAnimationFrames = [ + // 프레임 0: 대치 상태 (방패 들고 대기) + ''' + ░░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░ + ░▓▓ G L I T C H ▓▓░ + ░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░ + ░▓▓▓ ◈◈ ◈◈ ▓▓▓░ + ░▓▓▓▓ ▼▼▼ ▓▓▓▓░ + ░▓▓▓▓▓ ████████ ▓▓▓▓▓░ + o ░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░ + []|- ░▓▓▓ G O D ▓▓▓░ + / \\ ░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░ +~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~''', + + // 프레임 1: 용사 전진 (방패 앞으로) + ''' + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓ G L I T C H ▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓ ◉◉ ◉◉ ▓▓▓▓ + ▓▓▓▓▓ ~~~ ▓▓▓▓▓ + o ▓▓▓▓▓▓ ████████ ▓▓▓▓▓▓ + []|> ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + / \\ ▓▓▓▓ G O D ▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ +~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~''', + + // 프레임 2: 용사 공격 준비 (방패 방어 자세) + ''' + ▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒ + ▒▓ G L I T C H ▓▒ + ▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒ + ▒▓▓▓ ◈◈ ◈◈ ▓▓▓▒ + ▒▓▓▓▓ ▼▼▼ ▓▓▓▓▒ + o\\ ▒▓▓▓▓▓ ████████ ▓▓▓▓▓▒ + []=|== ▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒ + / \\ ▒▓▓▓ G O D ▓▓▓▒ + ▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒ +~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~''', + + // 프레임 3: 용사 공격 (글리치 갓 데미지) + ''' + ░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░ + ░▓ G#L@I*T&C!H ▓░ + ░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░ + ░▓▓▓ X X X X ▓▓▓░ + o\\ ░▓▓▓▓ !!! ▓▓▓▓░ + []=|===> ░▓▓▓▓▓ ████████ ▓▓▓▓▓░ + / \\ ░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░ + ░▓▓▓ G O D ▓▓▓░ + ░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░ +~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~''', + + // 프레임 4: 글리치 갓 반격 준비 (방패로 방어) + ''' + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓ G L I T C H ▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓ @@ @@ ▓▓▓▓ + ▓▓▓▓▓ <=== ▓▓▓▓▓ + []o ▓▓▓▓▓▓ ████████ ▓▓▓▓▓▓ + |\\ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + / \\ ▓▓▓▓ G O D ▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ +~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~''', + + // 프레임 5: 글리치 갓 공격 (용사 방패로 막기) + ''' + ▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒ + ▒▓ G L I T C H ▓▒ + ▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒ + ▒▓▓▓ ◉◉ ◉◉ ▓▓▓▒ + ▒▓▓▓▓ <====== ▓▓▓▓▒ + []o * ▒▓▓▓▓▓ ████████ ▓▓▓▓▓▒ + |/ ▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒ + |\\ ▒▓▓▓ G O D ▓▓▓▒ + ▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒ +~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~''', +]; + +/// 애니메이션 프레임 간격 (밀리초) +const frontScreenAnimationIntervalMs = 400; + +/// 애니메이션 총 프레임 수 +const frontScreenAnimationFrameCount = 6; diff --git a/lib/src/core/storage/settings_repository.dart b/lib/src/core/storage/settings_repository.dart new file mode 100644 index 0000000..2f9fe94 --- /dev/null +++ b/lib/src/core/storage/settings_repository.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// 앱 설정 저장소 (SharedPreferences 기반) +/// +/// 테마, 언어 등 사용자 설정을 로컬에 저장 +class SettingsRepository { + static const _keyThemeMode = 'theme_mode'; + static const _keyLocale = 'locale'; + + SharedPreferences? _prefs; + + /// SharedPreferences 초기화 + Future init() async { + _prefs ??= await SharedPreferences.getInstance(); + } + + /// 테마 모드 저장 + Future saveThemeMode(ThemeMode mode) async { + await init(); + final value = switch (mode) { + ThemeMode.light => 'light', + ThemeMode.dark => 'dark', + ThemeMode.system => 'system', + }; + await _prefs!.setString(_keyThemeMode, value); + } + + /// 테마 모드 불러오기 + Future loadThemeMode() async { + await init(); + final value = _prefs!.getString(_keyThemeMode); + return switch (value) { + 'light' => ThemeMode.light, + 'dark' => ThemeMode.dark, + 'system' => ThemeMode.system, + _ => ThemeMode.system, // 기본값 + }; + } + + /// 언어 설정 저장 + Future saveLocale(String locale) async { + await init(); + await _prefs!.setString(_keyLocale, locale); + } + + /// 언어 설정 불러오기 + Future loadLocale() async { + await init(); + return _prefs!.getString(_keyLocale); + } +} diff --git a/lib/src/features/front/front_screen.dart b/lib/src/features/front/front_screen.dart index d11fa21..bc960f1 100644 --- a/lib/src/features/front/front_screen.dart +++ b/lib/src/features/front/front_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:askiineverdie/data/game_text_l10n.dart' as game_l10n; import 'package:askiineverdie/l10n/app_localizations.dart'; +import 'package:askiineverdie/src/features/front/widgets/hero_vs_boss_animation.dart'; class FrontScreen extends StatelessWidget { const FrontScreen({ @@ -9,6 +10,7 @@ class FrontScreen extends StatelessWidget { this.onNewCharacter, this.onLoadSave, this.onHallOfFame, + this.hasSaveFile = false, }); /// "New character" 버튼 클릭 시 호출 @@ -17,9 +19,45 @@ class FrontScreen extends StatelessWidget { /// "Load save" 버튼 클릭 시 호출 final Future Function(BuildContext context)? onLoadSave; - /// "Hall of Fame" 버튼 클릭 시 호출 (Phase 10) + /// "Hall of Fame" 버튼 클릭 시 호출 final void Function(BuildContext context)? onHallOfFame; + /// 세이브 파일 존재 여부 (새 캐릭터 시 경고용) + final bool hasSaveFile; + + /// 새 캐릭터 생성 시 세이브 파일 존재하면 경고 표시 + void _handleNewCharacter(BuildContext context) { + if (hasSaveFile) { + _showDeleteWarningDialog(context); + } else { + onNewCharacter?.call(context); + } + } + + /// 세이브 삭제 경고 다이얼로그 + void _showDeleteWarningDialog(BuildContext context) { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(game_l10n.uiWarning), + content: Text(game_l10n.warningDeleteSave), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(game_l10n.buttonCancel), + ), + FilledButton( + onPressed: () { + Navigator.pop(dialogContext); + onNewCharacter?.call(context); + }, + child: Text(game_l10n.buttonConfirm), + ), + ], + ), + ); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -35,33 +73,42 @@ class FrontScreen extends StatelessWidget { ), ), child: SafeArea( - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 960), - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _HeroHeader(theme: theme, colorScheme: colorScheme), - const SizedBox(height: 24), - _ActionRow( - onNewCharacter: onNewCharacter != null - ? () => onNewCharacter!(context) - : () => _showPlaceholder(context), - onLoadSave: onLoadSave != null - ? () => onLoadSave!(context) - : () => _showPlaceholder(context), - onHallOfFame: onHallOfFame != null - ? () => onHallOfFame!(context) - : null, + child: Column( + children: [ + // 스크롤 영역 (헤더, 애니메이션, 버튼) + Expanded( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 960), + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _HeroHeader(theme: theme, colorScheme: colorScheme), + const SizedBox(height: 20), + const HeroVsBossAnimation(), + const SizedBox(height: 24), + _ActionButtons( + onNewCharacter: onNewCharacter != null + ? () => _handleNewCharacter(context) + : null, + onLoadSave: onLoadSave != null + ? () => onLoadSave!(context) + : null, + onHallOfFame: onHallOfFame != null + ? () => onHallOfFame!(context) + : null, + ), + ], + ), ), - const SizedBox(height: 24), - const _StatusCards(), - ], + ), ), ), - ), + // 카피라이트 푸터 (하단 고정) + const _CopyrightFooter(), + ], ), ), ), @@ -69,16 +116,7 @@ class FrontScreen extends StatelessWidget { } } -void _showPlaceholder(BuildContext context) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Core gameplay loop is coming next. See doc/progress-quest-flutter-plan.md for milestones.', - ), - ), - ); -} - +/// 헤더 (타이틀 + 태그) - 중앙 정렬 class _HeroHeader extends StatelessWidget { const _HeroHeader({required this.theme, required this.colorScheme}); @@ -109,41 +147,31 @@ class _HeroHeader extends StatelessWidget { child: Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, children: [ + // 타이틀 (중앙 정렬) Row( - crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.auto_awesome, color: colorScheme.onPrimary), const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - L10n.of(context).appTitle, - style: theme.textTheme.headlineSmall?.copyWith( - color: colorScheme.onPrimary, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 6), - Text( - game_l10n.frontDescription, - style: theme.textTheme.titleMedium?.copyWith( - color: colorScheme.onPrimary.withValues(alpha: 0.9), - ), - ), - ], + Text( + L10n.of(context).appTitle, + style: theme.textTheme.headlineSmall?.copyWith( + color: colorScheme.onPrimary, + fontWeight: FontWeight.w700, ), ), ], ), const SizedBox(height: 14), + // 태그 (중앙 정렬) Builder( builder: (context) { final l10n = L10n.of(context); return Wrap( + alignment: WrapAlignment.center, spacing: 8, runSpacing: 8, children: [ @@ -167,15 +195,16 @@ class _HeroHeader extends StatelessWidget { } } -class _ActionRow extends StatelessWidget { - const _ActionRow({ - required this.onNewCharacter, - required this.onLoadSave, +/// 액션 버튼 (세로 배치) +class _ActionButtons extends StatelessWidget { + const _ActionButtons({ + this.onNewCharacter, + this.onLoadSave, this.onHallOfFame, }); - final VoidCallback onNewCharacter; - final VoidCallback onLoadSave; + final VoidCallback? onNewCharacter; + final VoidCallback? onLoadSave; final VoidCallback? onHallOfFame; @override @@ -183,148 +212,70 @@ class _ActionRow extends StatelessWidget { final theme = Theme.of(context); final l10n = L10n.of(context); - return Wrap( - spacing: 12, - runSpacing: 12, + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + // 새 캐릭터 (Primary) FilledButton.icon( onPressed: onNewCharacter, icon: const Icon(Icons.casino_outlined), label: Text(l10n.newCharacter), style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14), - textStyle: theme.textTheme.titleMedium, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + textStyle: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), ), ), + const SizedBox(height: 12), + // 불러오기 (Secondary) OutlinedButton.icon( onPressed: onLoadSave, icon: const Icon(Icons.folder_open), label: Text(l10n.loadSave), style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), textStyle: theme.textTheme.titleMedium, ), ), - TextButton.icon( - onPressed: () => _showPlaceholder(context), - icon: const Icon(Icons.menu_book_outlined), - label: Text(l10n.viewBuildPlan), - ), - // Phase 10: 명예의 전당 버튼 + const SizedBox(height: 12), + // 명예의 전당 (Tertiary) if (onHallOfFame != null) TextButton.icon( onPressed: onHallOfFame, icon: const Icon(Icons.emoji_events_outlined), label: Text(game_l10n.uiHallOfFame), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), + textStyle: theme.textTheme.titleMedium, + ), ), ], ); } } -class _StatusCards extends StatelessWidget { - const _StatusCards(); - - @override - Widget build(BuildContext context) { - final l10n = L10n.of(context); - return Column( - children: [ - _InfoCard( - icon: Icons.route_outlined, - title: l10n.buildRoadmap, - points: [ - 'Port PQ 6.4 data set (Config.dfm) into Dart constants.', - 'Recreate quest/task loop with deterministic RNG + saves.', - 'Deliver offline-first storage (GZip JSON) across platforms.', - ], - ), - SizedBox(height: 16), - _InfoCard( - icon: Icons.auto_fix_high_outlined, - title: l10n.techStack, - points: [ - 'Flutter (Material 3) with multiplatform targets enabled.', - 'path_provider + shared_preferences for local storage hooks.', - 'Strict lints with package imports enforced from day one.', - ], - ), - SizedBox(height: 16), - _InfoCard( - icon: Icons.checklist_rtl, - title: game_l10n.frontTodayFocus, - points: [ - 'Set up scaffold + lints.', - 'Wire seed theme and initial navigation shell.', - 'Keep reference assets under example/pq for parity.', - ], - ), - ], - ); - } -} - -class _InfoCard extends StatelessWidget { - const _InfoCard({required this.title, required this.points, this.icon}); - - final String title; - final List points; - final IconData? icon; +/// 카피라이트 푸터 +class _CopyrightFooter extends StatelessWidget { + const _CopyrightFooter(); @override Widget build(BuildContext context) { final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - - return Card( - elevation: 3, - shadowColor: colorScheme.shadow.withValues(alpha: 0.2), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - child: Padding( - padding: const EdgeInsets.all(18), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - if (icon != null) ...[ - Icon(icon, color: colorScheme.primary), - const SizedBox(width: 10), - ], - Text( - title, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - ], - ), - const SizedBox(height: 10), - ...points.map( - (point) => Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Padding( - padding: EdgeInsets.only(top: 3), - child: Icon(Icons.check_circle_outline, size: 18), - ), - const SizedBox(width: 10), - Expanded( - child: Text(point, style: theme.textTheme.bodyMedium), - ), - ], - ), - ), - ), - ], + return Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Text( + game_l10n.copyrightText, + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.5), ), ), ); } } +/// 태그 칩 class _Tag extends StatelessWidget { const _Tag({required this.icon, required this.label}); diff --git a/lib/src/features/front/widgets/hero_vs_boss_animation.dart b/lib/src/features/front/widgets/hero_vs_boss_animation.dart new file mode 100644 index 0000000..8645ca2 --- /dev/null +++ b/lib/src/features/front/widgets/hero_vs_boss_animation.dart @@ -0,0 +1,169 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/material.dart'; + +import 'package:askiineverdie/src/core/animation/front_screen_animation.dart'; + +/// 프론트 화면용 Hero vs Glitch God ASCII 애니메이션 위젯 +/// +/// 작은 용사가 거대한 Glitch God에 맞서는 루프 애니메이션 +/// 항상 검은 배경에 흰색 텍스트, 특수 효과만 컬러로 표시 +class HeroVsBossAnimation extends StatefulWidget { + const HeroVsBossAnimation({super.key}); + + @override + State createState() => _HeroVsBossAnimationState(); +} + +class _HeroVsBossAnimationState extends State { + int _currentFrame = 0; + Timer? _timer; + final Random _random = Random(); + + @override + void initState() { + super.initState(); + _startAnimation(); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + void _startAnimation() { + _timer = Timer.periodic( + const Duration(milliseconds: frontScreenAnimationIntervalMs), + (_) { + if (mounted) { + setState(() { + _currentFrame = + (_currentFrame + 1) % frontScreenAnimationFrameCount; + }); + } + }, + ); + } + + /// 글리치 효과: 랜덤 문자 대체 + String _applyGlitchEffect(String frame) { + // 10% 확률로 글리치 효과 적용 + if (_random.nextDouble() > 0.1) return frame; + + const glitchChars = '@#\$%&*!?~'; + final chars = frame.split(''); + final glitchCount = _random.nextInt(5) + 1; + + for (var i = 0; i < glitchCount; i++) { + final pos = _random.nextInt(chars.length); + if (chars[pos] != ' ' && chars[pos] != '\n') { + chars[pos] = glitchChars[_random.nextInt(glitchChars.length)]; + } + } + + return chars.join(); + } + + @override + Widget build(BuildContext context) { + final frame = _applyGlitchEffect( + frontScreenAnimationFrames[_currentFrame], + ); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16), + decoration: BoxDecoration( + // 항상 검은 배경 + color: Colors.black, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.white24, + width: 1, + ), + // 은은한 글로우 효과 + boxShadow: [ + BoxShadow( + color: Colors.cyan.withValues(alpha: 0.15), + blurRadius: 20, + spreadRadius: 2, + ), + ], + ), + child: Column( + children: [ + // ASCII 애니메이션 (흰색 텍스트) + FittedBox( + fit: BoxFit.scaleDown, + child: Text( + frame, + style: const TextStyle( + fontFamily: 'JetBrainsMono', + fontSize: 10, + height: 1.1, + color: Colors.white, + letterSpacing: 0, + ), + ), + ), + const SizedBox(height: 8), + // 하단 효과 바 (컬러) + _buildEffectBar(), + ], + ), + ); + } + + /// 하단 효과 바: 글리치/전투 효과 시각화 + Widget _buildEffectBar() { + // 프레임에 따라 색상 변화 + final colors = [ + Colors.cyan, + Colors.purple, + Colors.red, + Colors.cyan, + Colors.yellow, + Colors.purple, + ]; + final currentColor = colors[_currentFrame % colors.length]; + + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // 왼쪽 검 아이콘 (용사) + Icon( + Icons.flash_on, + size: 14, + color: Colors.yellow.withValues(alpha: 0.8), + ), + const SizedBox(width: 8), + // 중앙 효과 바 + Expanded( + child: Container( + height: 3, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Colors.transparent, + currentColor.withValues(alpha: 0.7), + Colors.white.withValues(alpha: 0.9), + currentColor.withValues(alpha: 0.7), + Colors.transparent, + ], + ), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(width: 8), + // 오른쪽 보스 아이콘 + Icon( + Icons.whatshot, + size: 14, + color: Colors.red.withValues(alpha: 0.8), + ), + ], + ); + } +} diff --git a/lib/src/features/game/game_play_screen.dart b/lib/src/features/game/game_play_screen.dart index 882aef5..aee184b 100644 --- a/lib/src/features/game/game_play_screen.dart +++ b/lib/src/features/game/game_play_screen.dart @@ -39,6 +39,8 @@ class GamePlayScreen extends StatefulWidget { required this.controller, this.forceCarouselLayout = false, this.forceDesktopLayout = false, + this.onThemeModeChange, + this.currentThemeMode = ThemeMode.system, }); final GameSessionController controller; @@ -49,6 +51,12 @@ class GamePlayScreen extends StatefulWidget { /// 테스트 모드: 모바일에서도 데스크톱 3패널 레이아웃 강제 사용 final bool forceDesktopLayout; + /// 테마 모드 변경 콜백 + final void Function(ThemeMode mode)? onThemeModeChange; + + /// 현재 테마 모드 + final ThemeMode currentThemeMode; + @override State createState() => _GamePlayScreenState(); } @@ -564,9 +572,24 @@ class _GamePlayScreenState extends State }, notificationService: _notificationService, specialAnimation: _specialAnimation, - onLanguageChange: (locale) { + onLanguageChange: (locale) async { + // 1. 현재 상태 저장 + await widget.controller.pause(saveOnStop: true); + // 2. 로케일 변경 game_l10n.setGameLocale(locale); - setState(() {}); + // 3. 화면 재생성 (전체 UI 재구성) + if (context.mounted) { + await widget.controller.resume(); + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (_) => GamePlayScreen( + controller: widget.controller, + currentThemeMode: widget.currentThemeMode, + onThemeModeChange: widget.onThemeModeChange, + ), + ), + ); + } }, onDeleteSaveAndNewGame: () async { // 게임 루프 중지 @@ -578,6 +601,8 @@ class _GamePlayScreenState extends State Navigator.of(context).pop(); } }, + currentThemeMode: widget.currentThemeMode, + onThemeModeChange: widget.onThemeModeChange, ), // 사망 오버레이 if (state.isDead && state.deathInfo != null) diff --git a/lib/src/features/game/layouts/mobile_carousel_layout.dart b/lib/src/features/game/layouts/mobile_carousel_layout.dart index 19c869b..a2bc2b7 100644 --- a/lib/src/features/game/layouts/mobile_carousel_layout.dart +++ b/lib/src/features/game/layouts/mobile_carousel_layout.dart @@ -37,6 +37,8 @@ class MobileCarouselLayout extends StatefulWidget { required this.onLanguageChange, required this.onDeleteSaveAndNewGame, this.specialAnimation, + this.currentThemeMode = ThemeMode.system, + this.onThemeModeChange, }); final GameState state; @@ -51,6 +53,8 @@ class MobileCarouselLayout extends StatefulWidget { final void Function(String locale) onLanguageChange; final VoidCallback onDeleteSaveAndNewGame; final AsciiAnimationType? specialAnimation; + final ThemeMode currentThemeMode; + final void Function(ThemeMode mode)? onThemeModeChange; @override State createState() => _MobileCarouselLayoutState(); @@ -94,6 +98,66 @@ class _MobileCarouselLayoutState extends State { return l10n.languageEnglish; } + /// 현재 테마명 가져오기 + String _getCurrentThemeName() { + return switch (widget.currentThemeMode) { + ThemeMode.light => l10n.themeLight, + ThemeMode.dark => l10n.themeDark, + ThemeMode.system => l10n.themeSystem, + }; + } + + /// 테마 아이콘 가져오기 + IconData _getThemeIcon() { + return switch (widget.currentThemeMode) { + ThemeMode.light => Icons.light_mode, + ThemeMode.dark => Icons.dark_mode, + ThemeMode.system => Icons.brightness_auto, + }; + } + + /// 테마 선택 다이얼로그 표시 + void _showThemeDialog(BuildContext context) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(l10n.menuTheme), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildThemeOption(context, ThemeMode.system, l10n.themeSystem), + _buildThemeOption(context, ThemeMode.light, l10n.themeLight), + _buildThemeOption(context, ThemeMode.dark, l10n.themeDark), + ], + ), + ), + ); + } + + Widget _buildThemeOption( + BuildContext context, + ThemeMode mode, + String label, + ) { + final isSelected = widget.currentThemeMode == mode; + 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.onThemeModeChange?.call(mode); + }, + ); + } + /// 언어 선택 다이얼로그 표시 void _showLanguageDialog(BuildContext context) { showDialog( @@ -242,6 +306,24 @@ class _MobileCarouselLayoutState extends State { }, ), + // 테마 변경 + if (widget.onThemeModeChange != null) + ListTile( + leading: Icon( + _getThemeIcon(), + color: Colors.purple, + ), + title: Text(l10n.menuTheme), + trailing: Text( + _getCurrentThemeName(), + style: TextStyle(color: Theme.of(context).colorScheme.primary), + ), + onTap: () { + Navigator.pop(context); + _showThemeDialog(context); + }, + ), + const Divider(), // 저장 diff --git a/lib/src/features/new_character/new_character_screen.dart b/lib/src/features/new_character/new_character_screen.dart index 804d82e..baefe09 100644 --- a/lib/src/features/new_character/new_character_screen.dart +++ b/lib/src/features/new_character/new_character_screen.dart @@ -28,6 +28,8 @@ class NewCharacterScreen extends StatefulWidget { class _NewCharacterScreenState extends State { final TextEditingController _nameController = TextEditingController(); + final ScrollController _raceScrollController = ScrollController(); + final ScrollController _klassScrollController = ScrollController(); // 종족(races)과 직업(klasses) 목록 (Phase 5) final List _races = RaceData.all; @@ -74,14 +76,47 @@ class _NewCharacterScreenState extends State { // 초기 이름 생성 _nameController.text = generateName(_nameRng); + + // 선택된 종족/직업으로 스크롤 + _scrollToSelectedItems(); } @override void dispose() { _nameController.dispose(); + _raceScrollController.dispose(); + _klassScrollController.dispose(); super.dispose(); } + /// 선택된 종족/직업 위치로 스크롤 + void _scrollToSelectedItems() { + // ListTile 높이 약 48px (dense 모드) + const itemHeight = 48.0; + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_raceScrollController.hasClients) { + final raceOffset = _selectedRaceIndex * itemHeight; + _raceScrollController.animateTo( + raceOffset.clamp(0.0, _raceScrollController.position.maxScrollExtent), + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + if (_klassScrollController.hasClients) { + final klassOffset = _selectedKlassIndex * itemHeight; + _klassScrollController.animateTo( + klassOffset.clamp( + 0.0, + _klassScrollController.position.maxScrollExtent, + ), + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + }); + } + /// 스탯 굴림 (3d6 × 6) void _rollStats() { final rng = DeterministicRandom(_currentSeed); @@ -108,6 +143,9 @@ class _NewCharacterScreenState extends State { // 새 시드로 굴림 _currentSeed = math.Random().nextInt(0x7FFFFFFF); _rollStats(); + + // 선택된 종족/직업으로 스크롤 + _scrollToSelectedItems(); } /// Unroll 버튼 클릭 (이전 롤로 복원) @@ -415,6 +453,7 @@ class _NewCharacterScreenState extends State { SizedBox( height: 300, child: ListView.builder( + controller: _raceScrollController, itemCount: _races.length, itemBuilder: (context, index) { final isSelected = index == _selectedRaceIndex; @@ -521,6 +560,7 @@ class _NewCharacterScreenState extends State { SizedBox( height: 300, child: ListView.builder( + controller: _klassScrollController, itemCount: _klasses.length, itemBuilder: (context, index) { final isSelected = index == _selectedKlassIndex; diff --git a/test/widget_test.dart b/test/widget_test.dart index f8e6c2c..6256cb1 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,7 +1,18 @@ import 'package:askiineverdie/src/app.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + // SharedPreferences 모킹 + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + const MethodChannel('plugins.flutter.io/shared_preferences') + .setMockMethodCallHandler((call) async { + if (call.method == 'getAll') return {}; + return null; + }); + }); + testWidgets('Front screen renders and navigates to new character', ( tester, ) async {