feat(front): 프론트 화면 개선 및 설정 저장소 추가

- front_screen_animation.dart: 프론트 화면 애니메이션 추가
- settings_repository.dart: 설정 저장소 구현
- front/widgets/: 프론트 화면 위젯 분리
- mobile_carousel_layout.dart: 모바일 레이아웃 개선
- app.dart: 앱 설정 개선
- game_text_l10n.dart: 텍스트 추가
This commit is contained in:
JiWoong Sul
2025-12-23 18:52:46 +09:00
parent e6af7dd91a
commit 549851f693
10 changed files with 722 additions and 183 deletions

View File

@@ -1659,3 +1659,62 @@ String get buttonCancel {
if (isJapaneseLocale) return 'キャンセル'; if (isJapaneseLocale) return 'キャンセル';
return 'Cancel'; 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...';
}

View File

@@ -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/notification/notification_service.dart';
import 'package:askiineverdie/src/core/storage/save_manager.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/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/front_screen.dart';
import 'package:askiineverdie/src/features/front/save_picker_dialog.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_play_screen.dart';
@@ -28,8 +29,10 @@ class AskiiNeverDieApp extends StatefulWidget {
class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> { class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
late final GameSessionController _controller; late final GameSessionController _controller;
late final NotificationService _notificationService; late final NotificationService _notificationService;
late final SettingsRepository _settingsRepository;
bool _isCheckingSave = true; bool _isCheckingSave = true;
bool _hasSave = false; bool _hasSave = false;
ThemeMode _themeMode = ThemeMode.system;
@override @override
void initState() { void initState() {
@@ -47,11 +50,28 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
saveManager: SaveManager(SaveRepository()), saveManager: SaveManager(SaveRepository()),
); );
_notificationService = NotificationService(); _notificationService = NotificationService();
_settingsRepository = SettingsRepository();
// 초기 설정 로드
_loadSettings();
// 세이브 파일 존재 여부 확인 // 세이브 파일 존재 여부 확인
_checkForExistingSave(); _checkForExistingSave();
} }
/// 저장된 설정 불러오기
Future<void> _loadSettings() async {
final themeMode = await _settingsRepository.loadThemeMode();
if (mounted) {
setState(() => _themeMode = themeMode);
}
}
/// 테마 모드 변경
void _changeThemeMode(ThemeMode mode) {
setState(() => _themeMode = mode);
_settingsRepository.saveThemeMode(mode);
}
/// 세이브 파일 존재 여부 확인 후 자동 로드 /// 세이브 파일 존재 여부 확인 후 자동 로드
Future<void> _checkForExistingSave() async { Future<void> _checkForExistingSave() async {
final exists = await _controller.saveManager.saveExists(); final exists = await _controller.saveManager.saveExists();
@@ -70,6 +90,32 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
super.dispose(); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return MaterialApp(
@@ -77,11 +123,9 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
localizationsDelegates: L10n.localizationsDelegates, localizationsDelegates: L10n.localizationsDelegates,
supportedLocales: L10n.supportedLocales, supportedLocales: L10n.supportedLocales,
theme: ThemeData( theme: _lightTheme,
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF234361)), darkTheme: _darkTheme,
scaffoldBackgroundColor: const Color(0xFFF4F5F7), themeMode: _themeMode,
useMaterial3: true,
),
builder: (context, child) { builder: (context, child) {
// 현재 로케일을 게임 텍스트 l10n 시스템에 동기화 // 현재 로케일을 게임 텍스트 l10n 시스템에 동기화
final locale = Localizations.localeOf(context); final locale = Localizations.localeOf(context);
@@ -110,6 +154,8 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
// 로드 실패 시 프론트 화면으로 // 로드 실패 시 프론트 화면으로
setState(() => _hasSave = false); setState(() => _hasSave = false);
}, },
currentThemeMode: _themeMode,
onThemeModeChange: _changeThemeMode,
); );
} }
@@ -118,6 +164,7 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
onNewCharacter: _navigateToNewCharacter, onNewCharacter: _navigateToNewCharacter,
onLoadSave: _loadSave, onLoadSave: _loadSave,
onHallOfFame: _navigateToHallOfFame, onHallOfFame: _navigateToHallOfFame,
hasSaveFile: _hasSave,
); );
} }
@@ -187,6 +234,8 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
builder: (context) => GamePlayScreen( builder: (context) => GamePlayScreen(
controller: _controller, controller: _controller,
forceCarouselLayout: testMode, forceCarouselLayout: testMode,
currentThemeMode: _themeMode,
onThemeModeChange: _changeThemeMode,
), ),
), ),
); );
@@ -196,7 +245,11 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
void _navigateToGame(BuildContext context) { void _navigateToGame(BuildContext context) {
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute<void>( MaterialPageRoute<void>(
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 { 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 GameSessionController controller;
final VoidCallback onLoadFailed; final VoidCallback onLoadFailed;
final ThemeMode currentThemeMode;
final void Function(ThemeMode mode) onThemeModeChange;
@override @override
State<_AutoLoadScreen> createState() => _AutoLoadScreenState(); State<_AutoLoadScreen> createState() => _AutoLoadScreenState();
@@ -262,7 +322,8 @@ class _AutoLoadScreenState extends State<_AutoLoadScreen> {
MaterialPageRoute<void>( MaterialPageRoute<void>(
builder: (context) => GamePlayScreen( builder: (context) => GamePlayScreen(
controller: widget.controller, controller: widget.controller,
// 자동 로드 시에는 플랫폼 기본값 사용 (모바일만 캐로셀) currentThemeMode: widget.currentThemeMode,
onThemeModeChange: widget.onThemeModeChange,
), ),
), ),
); );
@@ -274,19 +335,19 @@ class _AutoLoadScreenState extends State<_AutoLoadScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return const Scaffold( return Scaffold(
body: Center( body: Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Text( const Text(
'ASCII NEVER DIE', 'ASCII NEVER DIE',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
), ),
SizedBox(height: 16), const SizedBox(height: 16),
CircularProgressIndicator(), const CircularProgressIndicator(),
SizedBox(height: 16), const SizedBox(height: 16),
Text('Loading...'), Text(game_l10n.uiLoading),
], ],
), ),
), ),

View File

@@ -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;

View File

@@ -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<void> init() async {
_prefs ??= await SharedPreferences.getInstance();
}
/// 테마 모드 저장
Future<void> 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<ThemeMode> loadThemeMode() async {
await init();
final value = _prefs!.getString(_keyThemeMode);
return switch (value) {
'light' => ThemeMode.light,
'dark' => ThemeMode.dark,
'system' => ThemeMode.system,
_ => ThemeMode.system, // 기본값
};
}
/// 언어 설정 저장
Future<void> saveLocale(String locale) async {
await init();
await _prefs!.setString(_keyLocale, locale);
}
/// 언어 설정 불러오기
Future<String?> loadLocale() async {
await init();
return _prefs!.getString(_keyLocale);
}
}

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:askiineverdie/data/game_text_l10n.dart' as game_l10n; import 'package:askiineverdie/data/game_text_l10n.dart' as game_l10n;
import 'package:askiineverdie/l10n/app_localizations.dart'; import 'package:askiineverdie/l10n/app_localizations.dart';
import 'package:askiineverdie/src/features/front/widgets/hero_vs_boss_animation.dart';
class FrontScreen extends StatelessWidget { class FrontScreen extends StatelessWidget {
const FrontScreen({ const FrontScreen({
@@ -9,6 +10,7 @@ class FrontScreen extends StatelessWidget {
this.onNewCharacter, this.onNewCharacter,
this.onLoadSave, this.onLoadSave,
this.onHallOfFame, this.onHallOfFame,
this.hasSaveFile = false,
}); });
/// "New character" 버튼 클릭 시 호출 /// "New character" 버튼 클릭 시 호출
@@ -17,9 +19,45 @@ class FrontScreen extends StatelessWidget {
/// "Load save" 버튼 클릭 시 호출 /// "Load save" 버튼 클릭 시 호출
final Future<void> Function(BuildContext context)? onLoadSave; final Future<void> Function(BuildContext context)? onLoadSave;
/// "Hall of Fame" 버튼 클릭 시 호출 (Phase 10) /// "Hall of Fame" 버튼 클릭 시 호출
final void Function(BuildContext context)? onHallOfFame; 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<void>(
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
@@ -35,6 +73,10 @@ class FrontScreen extends StatelessWidget {
), ),
), ),
child: SafeArea( child: SafeArea(
child: Column(
children: [
// 스크롤 영역 (헤더, 애니메이션, 버튼)
Expanded(
child: Center( child: Center(
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 960), constraints: const BoxConstraints(maxWidth: 960),
@@ -44,41 +86,37 @@ class FrontScreen extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
_HeroHeader(theme: theme, colorScheme: colorScheme), _HeroHeader(theme: theme, colorScheme: colorScheme),
const SizedBox(height: 20),
const HeroVsBossAnimation(),
const SizedBox(height: 24), const SizedBox(height: 24),
_ActionRow( _ActionButtons(
onNewCharacter: onNewCharacter != null onNewCharacter: onNewCharacter != null
? () => onNewCharacter!(context) ? () => _handleNewCharacter(context)
: () => _showPlaceholder(context), : null,
onLoadSave: onLoadSave != null onLoadSave: onLoadSave != null
? () => onLoadSave!(context) ? () => onLoadSave!(context)
: () => _showPlaceholder(context), : null,
onHallOfFame: onHallOfFame != null onHallOfFame: onHallOfFame != null
? () => onHallOfFame!(context) ? () => onHallOfFame!(context)
: null, : null,
), ),
const SizedBox(height: 24),
const _StatusCards(),
], ],
), ),
), ),
), ),
), ),
), ),
// 카피라이트 푸터 (하단 고정)
const _CopyrightFooter(),
],
),
),
), ),
); );
} }
} }
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 { class _HeroHeader extends StatelessWidget {
const _HeroHeader({required this.theme, required this.colorScheme}); const _HeroHeader({required this.theme, required this.colorScheme});
@@ -109,17 +147,15 @@ class _HeroHeader extends StatelessWidget {
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
// 타이틀 (중앙 정렬)
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.auto_awesome, color: colorScheme.onPrimary), Icon(Icons.auto_awesome, color: colorScheme.onPrimary),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text( Text(
L10n.of(context).appTitle, L10n.of(context).appTitle,
style: theme.textTheme.headlineSmall?.copyWith( style: theme.textTheme.headlineSmall?.copyWith(
@@ -127,23 +163,15 @@ class _HeroHeader extends StatelessWidget {
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
), ),
const SizedBox(height: 6),
Text(
game_l10n.frontDescription,
style: theme.textTheme.titleMedium?.copyWith(
color: colorScheme.onPrimary.withValues(alpha: 0.9),
),
),
],
),
),
], ],
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
// 태그 (중앙 정렬)
Builder( Builder(
builder: (context) { builder: (context) {
final l10n = L10n.of(context); final l10n = L10n.of(context);
return Wrap( return Wrap(
alignment: WrapAlignment.center,
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
children: [ children: [
@@ -167,15 +195,16 @@ class _HeroHeader extends StatelessWidget {
} }
} }
class _ActionRow extends StatelessWidget { /// 액션 버튼 (세로 배치)
const _ActionRow({ class _ActionButtons extends StatelessWidget {
required this.onNewCharacter, const _ActionButtons({
required this.onLoadSave, this.onNewCharacter,
this.onLoadSave,
this.onHallOfFame, this.onHallOfFame,
}); });
final VoidCallback onNewCharacter; final VoidCallback? onNewCharacter;
final VoidCallback onLoadSave; final VoidCallback? onLoadSave;
final VoidCallback? onHallOfFame; final VoidCallback? onHallOfFame;
@override @override
@@ -183,148 +212,70 @@ class _ActionRow extends StatelessWidget {
final theme = Theme.of(context); final theme = Theme.of(context);
final l10n = L10n.of(context); final l10n = L10n.of(context);
return Wrap( return Column(
spacing: 12, crossAxisAlignment: CrossAxisAlignment.stretch,
runSpacing: 12,
children: [ children: [
// 새 캐릭터 (Primary)
FilledButton.icon( FilledButton.icon(
onPressed: onNewCharacter, onPressed: onNewCharacter,
icon: const Icon(Icons.casino_outlined), icon: const Icon(Icons.casino_outlined),
label: Text(l10n.newCharacter), label: Text(l10n.newCharacter),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
textStyle: theme.textTheme.titleMedium, textStyle: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
), ),
), ),
),
const SizedBox(height: 12),
// 불러오기 (Secondary)
OutlinedButton.icon( OutlinedButton.icon(
onPressed: onLoadSave, onPressed: onLoadSave,
icon: const Icon(Icons.folder_open), icon: const Icon(Icons.folder_open),
label: Text(l10n.loadSave), label: Text(l10n.loadSave),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
textStyle: theme.textTheme.titleMedium, textStyle: theme.textTheme.titleMedium,
), ),
), ),
TextButton.icon( const SizedBox(height: 12),
onPressed: () => _showPlaceholder(context), // 명예의 전당 (Tertiary)
icon: const Icon(Icons.menu_book_outlined),
label: Text(l10n.viewBuildPlan),
),
// Phase 10: 명예의 전당 버튼
if (onHallOfFame != null) if (onHallOfFame != null)
TextButton.icon( TextButton.icon(
onPressed: onHallOfFame, onPressed: onHallOfFame,
icon: const Icon(Icons.emoji_events_outlined), icon: const Icon(Icons.emoji_events_outlined),
label: Text(game_l10n.uiHallOfFame), 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(); class _CopyrightFooter extends StatelessWidget {
const _CopyrightFooter();
@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<String> points;
final IconData? icon;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; return Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
return Card( child: Text(
elevation: 3, game_l10n.copyrightText,
shadowColor: colorScheme.shadow.withValues(alpha: 0.2), textAlign: TextAlign.center,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), style: theme.textTheme.bodySmall?.copyWith(
child: Padding( color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
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),
),
],
),
),
),
],
), ),
), ),
); );
} }
} }
/// 태그 칩
class _Tag extends StatelessWidget { class _Tag extends StatelessWidget {
const _Tag({required this.icon, required this.label}); const _Tag({required this.icon, required this.label});

View File

@@ -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<HeroVsBossAnimation> createState() => _HeroVsBossAnimationState();
}
class _HeroVsBossAnimationState extends State<HeroVsBossAnimation> {
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),
),
],
);
}
}

View File

@@ -39,6 +39,8 @@ class GamePlayScreen extends StatefulWidget {
required this.controller, required this.controller,
this.forceCarouselLayout = false, this.forceCarouselLayout = false,
this.forceDesktopLayout = false, this.forceDesktopLayout = false,
this.onThemeModeChange,
this.currentThemeMode = ThemeMode.system,
}); });
final GameSessionController controller; final GameSessionController controller;
@@ -49,6 +51,12 @@ class GamePlayScreen extends StatefulWidget {
/// 테스트 모드: 모바일에서도 데스크톱 3패널 레이아웃 강제 사용 /// 테스트 모드: 모바일에서도 데스크톱 3패널 레이아웃 강제 사용
final bool forceDesktopLayout; final bool forceDesktopLayout;
/// 테마 모드 변경 콜백
final void Function(ThemeMode mode)? onThemeModeChange;
/// 현재 테마 모드
final ThemeMode currentThemeMode;
@override @override
State<GamePlayScreen> createState() => _GamePlayScreenState(); State<GamePlayScreen> createState() => _GamePlayScreenState();
} }
@@ -564,9 +572,24 @@ class _GamePlayScreenState extends State<GamePlayScreen>
}, },
notificationService: _notificationService, notificationService: _notificationService,
specialAnimation: _specialAnimation, specialAnimation: _specialAnimation,
onLanguageChange: (locale) { onLanguageChange: (locale) async {
// 1. 현재 상태 저장
await widget.controller.pause(saveOnStop: true);
// 2. 로케일 변경
game_l10n.setGameLocale(locale); game_l10n.setGameLocale(locale);
setState(() {}); // 3. 화면 재생성 (전체 UI 재구성)
if (context.mounted) {
await widget.controller.resume();
Navigator.of(context).pushReplacement(
MaterialPageRoute<void>(
builder: (_) => GamePlayScreen(
controller: widget.controller,
currentThemeMode: widget.currentThemeMode,
onThemeModeChange: widget.onThemeModeChange,
),
),
);
}
}, },
onDeleteSaveAndNewGame: () async { onDeleteSaveAndNewGame: () async {
// 게임 루프 중지 // 게임 루프 중지
@@ -578,6 +601,8 @@ class _GamePlayScreenState extends State<GamePlayScreen>
Navigator.of(context).pop(); Navigator.of(context).pop();
} }
}, },
currentThemeMode: widget.currentThemeMode,
onThemeModeChange: widget.onThemeModeChange,
), ),
// 사망 오버레이 // 사망 오버레이
if (state.isDead && state.deathInfo != null) if (state.isDead && state.deathInfo != null)

View File

@@ -37,6 +37,8 @@ class MobileCarouselLayout extends StatefulWidget {
required this.onLanguageChange, required this.onLanguageChange,
required this.onDeleteSaveAndNewGame, required this.onDeleteSaveAndNewGame,
this.specialAnimation, this.specialAnimation,
this.currentThemeMode = ThemeMode.system,
this.onThemeModeChange,
}); });
final GameState state; final GameState state;
@@ -51,6 +53,8 @@ class MobileCarouselLayout extends StatefulWidget {
final void Function(String locale) onLanguageChange; final void Function(String locale) onLanguageChange;
final VoidCallback onDeleteSaveAndNewGame; final VoidCallback onDeleteSaveAndNewGame;
final AsciiAnimationType? specialAnimation; final AsciiAnimationType? specialAnimation;
final ThemeMode currentThemeMode;
final void Function(ThemeMode mode)? onThemeModeChange;
@override @override
State<MobileCarouselLayout> createState() => _MobileCarouselLayoutState(); State<MobileCarouselLayout> createState() => _MobileCarouselLayoutState();
@@ -94,6 +98,66 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
return l10n.languageEnglish; 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<void>(
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) { void _showLanguageDialog(BuildContext context) {
showDialog<void>( showDialog<void>(
@@ -242,6 +306,24 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
}, },
), ),
// 테마 변경
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(), const Divider(),
// 저장 // 저장

View File

@@ -28,6 +28,8 @@ class NewCharacterScreen extends StatefulWidget {
class _NewCharacterScreenState extends State<NewCharacterScreen> { class _NewCharacterScreenState extends State<NewCharacterScreen> {
final TextEditingController _nameController = TextEditingController(); final TextEditingController _nameController = TextEditingController();
final ScrollController _raceScrollController = ScrollController();
final ScrollController _klassScrollController = ScrollController();
// 종족(races)과 직업(klasses) 목록 (Phase 5) // 종족(races)과 직업(klasses) 목록 (Phase 5)
final List<RaceTraits> _races = RaceData.all; final List<RaceTraits> _races = RaceData.all;
@@ -74,14 +76,47 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
// 초기 이름 생성 // 초기 이름 생성
_nameController.text = generateName(_nameRng); _nameController.text = generateName(_nameRng);
// 선택된 종족/직업으로 스크롤
_scrollToSelectedItems();
} }
@override @override
void dispose() { void dispose() {
_nameController.dispose(); _nameController.dispose();
_raceScrollController.dispose();
_klassScrollController.dispose();
super.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) /// 스탯 굴림 (3d6 × 6)
void _rollStats() { void _rollStats() {
final rng = DeterministicRandom(_currentSeed); final rng = DeterministicRandom(_currentSeed);
@@ -108,6 +143,9 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
// 새 시드로 굴림 // 새 시드로 굴림
_currentSeed = math.Random().nextInt(0x7FFFFFFF); _currentSeed = math.Random().nextInt(0x7FFFFFFF);
_rollStats(); _rollStats();
// 선택된 종족/직업으로 스크롤
_scrollToSelectedItems();
} }
/// Unroll 버튼 클릭 (이전 롤로 복원) /// Unroll 버튼 클릭 (이전 롤로 복원)
@@ -415,6 +453,7 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
SizedBox( SizedBox(
height: 300, height: 300,
child: ListView.builder( child: ListView.builder(
controller: _raceScrollController,
itemCount: _races.length, itemCount: _races.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final isSelected = index == _selectedRaceIndex; final isSelected = index == _selectedRaceIndex;
@@ -521,6 +560,7 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
SizedBox( SizedBox(
height: 300, height: 300,
child: ListView.builder( child: ListView.builder(
controller: _klassScrollController,
itemCount: _klasses.length, itemCount: _klasses.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final isSelected = index == _selectedKlassIndex; final isSelected = index == _selectedKlassIndex;

View File

@@ -1,7 +1,18 @@
import 'package:askiineverdie/src/app.dart'; import 'package:askiineverdie/src/app.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
void main() { void main() {
// SharedPreferences 모킹
setUpAll(() {
TestWidgetsFlutterBinding.ensureInitialized();
const MethodChannel('plugins.flutter.io/shared_preferences')
.setMockMethodCallHandler((call) async {
if (call.method == 'getAll') return <String, Object>{};
return null;
});
});
testWidgets('Front screen renders and navigates to new character', ( testWidgets('Front screen renders and navigates to new character', (
tester, tester,
) async { ) async {