- front_screen: 프론트 화면 UI 업데이트 - game_play_screen: 게임 플레이 화면 수정 - game_session_controller: 세션 관리 로직 개선 - mobile_carousel_layout: 모바일 캐러셀 레이아웃 개선 - enhanced_animation_panel: 애니메이션 패널 업데이트 - help_dialog: 도움말 다이얼로그 수정 - return_rewards_dialog: 복귀 보상 다이얼로그 개선 - new_character_screen: 새 캐릭터 화면 수정 - settings_screen: 설정 화면 업데이트
1242 lines
40 KiB
Dart
1242 lines
40 KiB
Dart
import 'package:flutter/foundation.dart' show kDebugMode;
|
|
import 'package:flutter/material.dart';
|
|
|
|
import 'package:asciineverdie/src/core/notification/notification_service.dart';
|
|
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
|
|
import 'package:asciineverdie/l10n/app_localizations.dart';
|
|
import 'package:asciineverdie/src/core/animation/ascii_animation_type.dart';
|
|
import 'package:asciineverdie/src/core/model/game_state.dart';
|
|
import 'package:asciineverdie/src/features/game/pages/character_sheet_page.dart';
|
|
import 'package:asciineverdie/src/features/game/pages/combat_log_page.dart';
|
|
import 'package:asciineverdie/src/features/game/pages/equipment_page.dart';
|
|
import 'package:asciineverdie/src/features/game/pages/inventory_page.dart';
|
|
import 'package:asciineverdie/src/features/game/pages/quest_page.dart';
|
|
import 'package:asciineverdie/src/features/game/pages/skills_page.dart';
|
|
import 'package:asciineverdie/src/features/game/pages/story_page.dart';
|
|
import 'package:asciineverdie/src/features/game/widgets/carousel_nav_bar.dart';
|
|
import 'package:asciineverdie/src/features/game/widgets/combat_log.dart';
|
|
import 'package:asciineverdie/src/features/game/widgets/enhanced_animation_panel.dart';
|
|
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
|
import 'package:asciineverdie/src/shared/widgets/retro_widgets.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,
|
|
this.bgmVolume = 0.7,
|
|
this.sfxVolume = 0.8,
|
|
this.onBgmVolumeChange,
|
|
this.onSfxVolumeChange,
|
|
this.onShowStatistics,
|
|
this.onShowHelp,
|
|
this.cheatsEnabled = false,
|
|
this.onCheatTask,
|
|
this.onCheatQuest,
|
|
this.onCheatPlot,
|
|
this.onCreateTestCharacter,
|
|
this.autoReviveEndMs,
|
|
this.speedBoostEndMs,
|
|
this.isPaidUser = false,
|
|
this.onSpeedBoostActivate,
|
|
this.onSetSpeed,
|
|
this.adSpeedMultiplier = 5,
|
|
this.has2xUnlocked = false,
|
|
});
|
|
|
|
final GameState state;
|
|
final List<CombatLogEntry> combatLogEntries;
|
|
final int speedMultiplier;
|
|
final VoidCallback onSpeedCycle;
|
|
|
|
/// 특정 속도로 직접 설정 (옵션 메뉴용)
|
|
final void Function(int speed)? onSetSpeed;
|
|
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;
|
|
|
|
/// BGM 볼륨 (0.0 ~ 1.0)
|
|
final double bgmVolume;
|
|
|
|
/// SFX 볼륨 (0.0 ~ 1.0)
|
|
final double sfxVolume;
|
|
|
|
/// BGM 볼륨 변경 콜백
|
|
final void Function(double volume)? onBgmVolumeChange;
|
|
|
|
/// SFX 볼륨 변경 콜백
|
|
final void Function(double volume)? onSfxVolumeChange;
|
|
|
|
/// 통계 표시 콜백
|
|
final VoidCallback? onShowStatistics;
|
|
|
|
/// 도움말 표시 콜백
|
|
final VoidCallback? onShowHelp;
|
|
|
|
/// 치트 모드 활성화 여부
|
|
final bool cheatsEnabled;
|
|
|
|
/// 치트: 태스크 완료
|
|
final VoidCallback? onCheatTask;
|
|
|
|
/// 치트: 퀘스트 완료
|
|
final VoidCallback? onCheatQuest;
|
|
|
|
/// 치트: 액트(플롯) 완료
|
|
final VoidCallback? onCheatPlot;
|
|
|
|
/// 테스트 캐릭터 생성 콜백 (디버그 모드 전용)
|
|
final Future<void> Function()? onCreateTestCharacter;
|
|
|
|
/// 자동부활 버프 종료 시점 (elapsedMs 기준)
|
|
final int? autoReviveEndMs;
|
|
|
|
/// 5배속 버프 종료 시점 (elapsedMs 기준)
|
|
final int? speedBoostEndMs;
|
|
|
|
/// 유료 유저 여부
|
|
final bool isPaidUser;
|
|
|
|
/// 광고 배속 활성화 콜백 (광고 시청)
|
|
final VoidCallback? onSpeedBoostActivate;
|
|
|
|
/// 광고 배속 배율 (릴리즈: 5x, 디버그빌드+디버그모드: 20x)
|
|
final int adSpeedMultiplier;
|
|
|
|
/// 2x 배속 해금 여부 (명예의 전당에 캐릭터가 있으면 true)
|
|
final bool has2xUnlocked;
|
|
|
|
@override
|
|
State<MobileCarouselLayout> createState() => _MobileCarouselLayoutState();
|
|
}
|
|
|
|
class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
|
|
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<void>(
|
|
context: context,
|
|
builder: (context) => _RetroSelectDialog(
|
|
title: l10n.menuLanguage.toUpperCase(),
|
|
children: [
|
|
_buildLanguageOption(context, 'en', l10n.languageEnglish, '🇺🇸'),
|
|
_buildLanguageOption(context, 'ko', l10n.languageKorean, '🇰🇷'),
|
|
_buildLanguageOption(context, 'ja', l10n.languageJapanese, '🇯🇵'),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildLanguageOption(
|
|
BuildContext context,
|
|
String locale,
|
|
String label,
|
|
String flag,
|
|
) {
|
|
final isSelected = l10n.currentGameLocale == locale;
|
|
return _RetroOptionItem(
|
|
label: label.toUpperCase(),
|
|
prefix: flag,
|
|
isSelected: isSelected,
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
widget.onLanguageChange(locale);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// 사운드 상태 텍스트 가져오기
|
|
String _getSoundStatus() {
|
|
final bgmPercent = (widget.bgmVolume * 100).round();
|
|
final sfxPercent = (widget.sfxVolume * 100).round();
|
|
if (bgmPercent == 0 && sfxPercent == 0) {
|
|
return l10n.uiSoundOff;
|
|
}
|
|
return 'BGM $bgmPercent% / SFX $sfxPercent%';
|
|
}
|
|
|
|
/// 사운드 설정 다이얼로그 표시
|
|
void _showSoundDialog(BuildContext context) {
|
|
var bgmVolume = widget.bgmVolume;
|
|
var sfxVolume = widget.sfxVolume;
|
|
|
|
showDialog<void>(
|
|
context: context,
|
|
builder: (context) => StatefulBuilder(
|
|
builder: (context, setDialogState) => _RetroSoundDialog(
|
|
bgmVolume: bgmVolume,
|
|
sfxVolume: sfxVolume,
|
|
onBgmChanged: (double value) {
|
|
setDialogState(() => bgmVolume = value);
|
|
widget.onBgmVolumeChange?.call(value);
|
|
},
|
|
onSfxChanged: (double value) {
|
|
setDialogState(() => sfxVolume = value);
|
|
widget.onSfxVolumeChange?.call(value);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 세이브 삭제 확인 다이얼로그 표시
|
|
void _showDeleteConfirmDialog(BuildContext context) {
|
|
showDialog<void>(
|
|
context: context,
|
|
builder: (context) => _RetroConfirmDialog(
|
|
title: l10n.confirmDeleteTitle.toUpperCase(),
|
|
message: l10n.confirmDeleteMessage,
|
|
confirmText: l10n.buttonConfirm.toUpperCase(),
|
|
cancelText: l10n.buttonCancel.toUpperCase(),
|
|
onConfirm: () {
|
|
Navigator.pop(context);
|
|
widget.onDeleteSaveAndNewGame();
|
|
},
|
|
onCancel: () => Navigator.pop(context),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 테스트 캐릭터 생성 확인 다이얼로그
|
|
Future<void> _showTestCharacterDialog(BuildContext context) async {
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => _RetroConfirmDialog(
|
|
title: 'CREATE TEST CHARACTER?',
|
|
message: '현재 캐릭터가 레벨 100으로 변환되어\n'
|
|
'명예의 전당에 등록됩니다.\n\n'
|
|
'⚠️ 현재 세이브 파일이 삭제됩니다.\n'
|
|
'이 작업은 되돌릴 수 없습니다.',
|
|
confirmText: 'CREATE',
|
|
cancelText: 'CANCEL',
|
|
onConfirm: () => Navigator.of(context).pop(true),
|
|
onCancel: () => Navigator.of(context).pop(false),
|
|
),
|
|
);
|
|
|
|
if (confirmed == true && mounted) {
|
|
await widget.onCreateTestCharacter?.call();
|
|
}
|
|
}
|
|
|
|
/// 옵션 메뉴 표시
|
|
void _showOptionsMenu(BuildContext context) {
|
|
final localizations = L10n.of(context);
|
|
final background = RetroColors.backgroundOf(context);
|
|
final gold = RetroColors.goldOf(context);
|
|
final border = RetroColors.borderOf(context);
|
|
|
|
showModalBottomSheet<void>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
backgroundColor: Colors.transparent,
|
|
constraints: BoxConstraints(
|
|
maxHeight: MediaQuery.of(context).size.height * 0.75,
|
|
),
|
|
builder: (context) => Container(
|
|
decoration: BoxDecoration(
|
|
color: background,
|
|
border: Border.all(color: border, width: 2),
|
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(4)),
|
|
),
|
|
child: SafeArea(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// 핸들 바
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 8, bottom: 4),
|
|
child: Container(
|
|
width: 60,
|
|
height: 4,
|
|
color: border,
|
|
),
|
|
),
|
|
// 헤더
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
width: double.infinity,
|
|
decoration: BoxDecoration(
|
|
color: RetroColors.panelBgOf(context),
|
|
border: Border(bottom: BorderSide(color: gold, width: 2)),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.settings, color: gold, size: 18),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
'OPTIONS',
|
|
style: TextStyle(
|
|
fontFamily: 'PressStart2P',
|
|
fontSize: 14,
|
|
color: gold,
|
|
),
|
|
),
|
|
const Spacer(),
|
|
RetroIconButton(
|
|
icon: Icons.close,
|
|
onPressed: () => Navigator.pop(context),
|
|
size: 28,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
// 메뉴 목록
|
|
Expanded(
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// === 게임 제어 ===
|
|
const _RetroMenuSection(title: 'CONTROL'),
|
|
const SizedBox(height: 8),
|
|
// 일시정지/재개
|
|
_RetroMenuItem(
|
|
icon: widget.isPaused ? Icons.play_arrow : Icons.pause,
|
|
iconColor: widget.isPaused
|
|
? RetroColors.expOf(context)
|
|
: RetroColors.warningOf(context),
|
|
label: widget.isPaused
|
|
? l10n.menuResume.toUpperCase()
|
|
: l10n.menuPause.toUpperCase(),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
widget.onPauseToggle();
|
|
},
|
|
),
|
|
const SizedBox(height: 8),
|
|
// 속도 조절
|
|
_RetroMenuItem(
|
|
icon: Icons.speed,
|
|
iconColor: gold,
|
|
label: l10n.menuSpeed.toUpperCase(),
|
|
trailing: _buildRetroSpeedSelector(context),
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// === 정보 ===
|
|
const _RetroMenuSection(title: 'INFO'),
|
|
const SizedBox(height: 8),
|
|
if (widget.onShowStatistics != null)
|
|
_RetroMenuItem(
|
|
icon: Icons.bar_chart,
|
|
iconColor: RetroColors.mpOf(context),
|
|
label: l10n.uiStatistics.toUpperCase(),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
widget.onShowStatistics?.call();
|
|
},
|
|
),
|
|
if (widget.onShowHelp != null) ...[
|
|
const SizedBox(height: 8),
|
|
_RetroMenuItem(
|
|
icon: Icons.help_outline,
|
|
iconColor: RetroColors.expOf(context),
|
|
label: l10n.uiHelp.toUpperCase(),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
widget.onShowHelp?.call();
|
|
},
|
|
),
|
|
],
|
|
const SizedBox(height: 16),
|
|
|
|
// === 설정 ===
|
|
const _RetroMenuSection(title: 'SETTINGS'),
|
|
const SizedBox(height: 8),
|
|
_RetroMenuItem(
|
|
icon: Icons.language,
|
|
iconColor: RetroColors.mpOf(context),
|
|
label: l10n.menuLanguage.toUpperCase(),
|
|
value: _getCurrentLanguageName(),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
_showLanguageDialog(context);
|
|
},
|
|
),
|
|
if (widget.onBgmVolumeChange != null ||
|
|
widget.onSfxVolumeChange != null) ...[
|
|
const SizedBox(height: 8),
|
|
_RetroMenuItem(
|
|
icon: widget.bgmVolume == 0 && widget.sfxVolume == 0
|
|
? Icons.volume_off
|
|
: Icons.volume_up,
|
|
iconColor: RetroColors.textMutedOf(context),
|
|
label: l10n.uiSound.toUpperCase(),
|
|
value: _getSoundStatus(),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
_showSoundDialog(context);
|
|
},
|
|
),
|
|
],
|
|
const SizedBox(height: 16),
|
|
|
|
// === 저장/종료 ===
|
|
const _RetroMenuSection(title: 'SAVE / EXIT'),
|
|
const SizedBox(height: 8),
|
|
_RetroMenuItem(
|
|
icon: Icons.save,
|
|
iconColor: RetroColors.mpOf(context),
|
|
label: l10n.menuSave.toUpperCase(),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
widget.onSave();
|
|
widget.notificationService.showGameSaved(
|
|
l10n.menuSaved,
|
|
);
|
|
},
|
|
),
|
|
const SizedBox(height: 8),
|
|
_RetroMenuItem(
|
|
icon: Icons.refresh,
|
|
iconColor: RetroColors.warningOf(context),
|
|
label: l10n.menuNewGame.toUpperCase(),
|
|
subtitle: l10n.menuDeleteSave,
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
_showDeleteConfirmDialog(context);
|
|
},
|
|
),
|
|
const SizedBox(height: 8),
|
|
_RetroMenuItem(
|
|
icon: Icons.exit_to_app,
|
|
iconColor: RetroColors.hpOf(context),
|
|
label: localizations.exitGame.toUpperCase(),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
widget.onExit();
|
|
},
|
|
),
|
|
|
|
// === 치트 섹션 (디버그 모드에서만) ===
|
|
if (widget.cheatsEnabled) ...[
|
|
const SizedBox(height: 16),
|
|
_RetroMenuSection(
|
|
title: 'DEBUG CHEATS',
|
|
color: RetroColors.hpOf(context),
|
|
),
|
|
const SizedBox(height: 8),
|
|
_RetroMenuItem(
|
|
icon: Icons.fast_forward,
|
|
iconColor: RetroColors.hpOf(context),
|
|
label: 'SKIP TASK (L+1)',
|
|
subtitle: '태스크 즉시 완료',
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
widget.onCheatTask?.call();
|
|
},
|
|
),
|
|
const SizedBox(height: 8),
|
|
_RetroMenuItem(
|
|
icon: Icons.skip_next,
|
|
iconColor: RetroColors.hpOf(context),
|
|
label: 'SKIP QUEST (Q!)',
|
|
subtitle: '퀘스트 즉시 완료',
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
widget.onCheatQuest?.call();
|
|
},
|
|
),
|
|
const SizedBox(height: 8),
|
|
_RetroMenuItem(
|
|
icon: Icons.double_arrow,
|
|
iconColor: RetroColors.hpOf(context),
|
|
label: 'SKIP ACT (P!)',
|
|
subtitle: '액트 즉시 완료',
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
widget.onCheatPlot?.call();
|
|
},
|
|
),
|
|
],
|
|
|
|
// === 디버그 도구 섹션 ===
|
|
if (kDebugMode && widget.onCreateTestCharacter != null) ...[
|
|
const SizedBox(height: 16),
|
|
_RetroMenuSection(
|
|
title: 'DEBUG TOOLS',
|
|
color: RetroColors.warningOf(context),
|
|
),
|
|
const SizedBox(height: 8),
|
|
_RetroMenuItem(
|
|
icon: Icons.science,
|
|
iconColor: RetroColors.warningOf(context),
|
|
label: 'CREATE TEST CHARACTER',
|
|
subtitle: '레벨 100 캐릭터를 명예의 전당에 등록',
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
_showTestCharacterDialog(context);
|
|
},
|
|
),
|
|
],
|
|
const SizedBox(height: 16),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 레트로 스타일 속도 선택기
|
|
Widget _buildRetroSpeedSelector(BuildContext context) {
|
|
final currentElapsedMs = widget.state.skillSystem.elapsedMs;
|
|
final speedBoostEndMs = widget.speedBoostEndMs ?? 0;
|
|
final isSpeedBoostActive =
|
|
speedBoostEndMs > currentElapsedMs || widget.isPaidUser;
|
|
final adSpeed = widget.adSpeedMultiplier;
|
|
|
|
void setSpeed(int speed) {
|
|
if (widget.onSetSpeed != null) {
|
|
widget.onSetSpeed!(speed);
|
|
} else {
|
|
widget.onSpeedCycle();
|
|
}
|
|
}
|
|
|
|
return Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// 1x 버튼
|
|
_RetroSpeedChip(
|
|
speed: 1,
|
|
isSelected: widget.speedMultiplier == 1 && !isSpeedBoostActive,
|
|
onTap: () {
|
|
setSpeed(1);
|
|
Navigator.pop(context);
|
|
},
|
|
),
|
|
// 2x 버튼 (명예의 전당 해금 시)
|
|
if (widget.has2xUnlocked) ...[
|
|
const SizedBox(width: 4),
|
|
_RetroSpeedChip(
|
|
speed: 2,
|
|
isSelected: widget.speedMultiplier == 2 && !isSpeedBoostActive,
|
|
onTap: () {
|
|
setSpeed(2);
|
|
Navigator.pop(context);
|
|
},
|
|
),
|
|
],
|
|
const SizedBox(width: 4),
|
|
// 광고배속 버튼
|
|
_RetroSpeedChip(
|
|
speed: adSpeed,
|
|
isSelected: isSpeedBoostActive,
|
|
isAdBased: !isSpeedBoostActive && !widget.isPaidUser,
|
|
onTap: () {
|
|
if (!isSpeedBoostActive) {
|
|
widget.onSpeedBoostActivate?.call();
|
|
}
|
|
Navigator.pop(context);
|
|
},
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final state = widget.state;
|
|
final background = RetroColors.backgroundOf(context);
|
|
final panelBg = RetroColors.panelBgOf(context);
|
|
final gold = RetroColors.goldOf(context);
|
|
|
|
return Scaffold(
|
|
backgroundColor: background,
|
|
appBar: AppBar(
|
|
backgroundColor: panelBg,
|
|
title: Text(
|
|
L10n.of(context).progressQuestTitle(state.traits.name),
|
|
style: TextStyle(
|
|
fontFamily: 'PressStart2P',
|
|
fontSize: 14,
|
|
color: gold,
|
|
),
|
|
),
|
|
actions: [
|
|
// 옵션 버튼
|
|
IconButton(
|
|
icon: Icon(Icons.settings, color: gold),
|
|
onPressed: () => _showOptionsMenu(context),
|
|
tooltip: l10n.menuOptions,
|
|
),
|
|
],
|
|
),
|
|
body: SafeArea(
|
|
top: false, // AppBar가 상단 처리
|
|
child: 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,
|
|
monsterGrade: state.progress.currentTask.monsterGrade,
|
|
monsterSize: state.progress.currentTask.monsterSize,
|
|
latestCombatEvent:
|
|
state.progress.currentCombat?.recentEvents.lastOrNull,
|
|
raceId: state.traits.raceId,
|
|
weaponRarity: state.equipment.weaponItem.rarity,
|
|
autoReviveEndMs: widget.autoReviveEndMs,
|
|
speedBoostEndMs: widget.speedBoostEndMs,
|
|
isPaidUser: widget.isPaidUser,
|
|
onSpeedBoostActivate: widget.onSpeedBoostActivate,
|
|
adSpeedMultiplier: widget.adSpeedMultiplier,
|
|
),
|
|
|
|
// 중앙: 캐로셀 (PageView)
|
|
Expanded(
|
|
child: PageView(
|
|
controller: _pageController,
|
|
onPageChanged: _onPageChanged,
|
|
children: [
|
|
// 0: 스킬
|
|
SkillsPage(
|
|
skillBook: state.skillBook,
|
|
skillSystem: state.skillSystem,
|
|
),
|
|
|
|
// 1: 인벤토리
|
|
InventoryPage(
|
|
inventory: state.inventory,
|
|
potionInventory: state.potionInventory,
|
|
encumbrance: state.progress.encumbrance,
|
|
),
|
|
|
|
// 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,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// 레트로 스타일 옵션 메뉴 위젯들
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
/// 메뉴 섹션 타이틀
|
|
class _RetroMenuSection extends StatelessWidget {
|
|
const _RetroMenuSection({required this.title, this.color});
|
|
|
|
final String title;
|
|
final Color? color;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final gold = color ?? RetroColors.goldOf(context);
|
|
return Row(
|
|
children: [
|
|
Container(width: 4, height: 14, color: gold),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
title,
|
|
style: TextStyle(
|
|
fontFamily: 'PressStart2P',
|
|
fontSize: 9,
|
|
color: gold,
|
|
letterSpacing: 1,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 메뉴 아이템
|
|
class _RetroMenuItem extends StatelessWidget {
|
|
const _RetroMenuItem({
|
|
required this.icon,
|
|
required this.iconColor,
|
|
required this.label,
|
|
this.value,
|
|
this.subtitle,
|
|
this.trailing,
|
|
this.onTap,
|
|
});
|
|
|
|
final IconData icon;
|
|
final Color iconColor;
|
|
final String label;
|
|
final String? value;
|
|
final String? subtitle;
|
|
final Widget? trailing;
|
|
final VoidCallback? onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final border = RetroColors.borderOf(context);
|
|
final panelBg = RetroColors.panelBgOf(context);
|
|
|
|
return GestureDetector(
|
|
onTap: onTap,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
|
decoration: BoxDecoration(
|
|
color: panelBg,
|
|
border: Border.all(color: border, width: 1),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(icon, size: 16, color: iconColor),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontFamily: 'PressStart2P',
|
|
fontSize: 16,
|
|
color: RetroColors.textPrimaryOf(context),
|
|
),
|
|
),
|
|
if (subtitle != null) ...[
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
subtitle!,
|
|
style: TextStyle(
|
|
fontFamily: 'PressStart2P',
|
|
fontSize: 12,
|
|
color: RetroColors.textMutedOf(context),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
if (value != null)
|
|
Text(
|
|
value!,
|
|
style: TextStyle(
|
|
fontFamily: 'PressStart2P',
|
|
fontSize: 14,
|
|
color: RetroColors.goldOf(context),
|
|
),
|
|
),
|
|
if (trailing != null) trailing!,
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 속도 선택 칩
|
|
class _RetroSpeedChip extends StatelessWidget {
|
|
const _RetroSpeedChip({
|
|
required this.speed,
|
|
required this.isSelected,
|
|
required this.onTap,
|
|
this.isAdBased = false,
|
|
});
|
|
|
|
final int speed;
|
|
final bool isSelected;
|
|
final VoidCallback onTap;
|
|
final bool isAdBased;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final gold = RetroColors.goldOf(context);
|
|
final warning = RetroColors.warningOf(context);
|
|
final border = RetroColors.borderOf(context);
|
|
|
|
final Color bgColor;
|
|
final Color textColor;
|
|
final Color borderColor;
|
|
|
|
if (isSelected) {
|
|
bgColor = isAdBased ? warning.withValues(alpha: 0.3) : gold.withValues(alpha: 0.3);
|
|
textColor = isAdBased ? warning : gold;
|
|
borderColor = isAdBased ? warning : gold;
|
|
} else if (isAdBased) {
|
|
bgColor = Colors.transparent;
|
|
textColor = warning;
|
|
borderColor = warning;
|
|
} else {
|
|
bgColor = Colors.transparent;
|
|
textColor = RetroColors.textMutedOf(context);
|
|
borderColor = border;
|
|
}
|
|
|
|
return GestureDetector(
|
|
onTap: onTap,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: bgColor,
|
|
border: Border.all(color: borderColor, width: isSelected ? 2 : 1),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (isAdBased && !isSelected)
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: 2),
|
|
child: Text(
|
|
'▶',
|
|
style: TextStyle(fontSize: 7, color: warning),
|
|
),
|
|
),
|
|
Text(
|
|
'${speed}x',
|
|
style: TextStyle(
|
|
fontFamily: 'PressStart2P',
|
|
fontSize: 8,
|
|
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
|
color: textColor,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 선택 다이얼로그
|
|
class _RetroSelectDialog extends StatelessWidget {
|
|
const _RetroSelectDialog({required this.title, required this.children});
|
|
|
|
final String title;
|
|
final List<Widget> children;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final background = RetroColors.backgroundOf(context);
|
|
final gold = RetroColors.goldOf(context);
|
|
|
|
return Dialog(
|
|
backgroundColor: Colors.transparent,
|
|
child: Container(
|
|
constraints: const BoxConstraints(maxWidth: 320),
|
|
decoration: BoxDecoration(
|
|
color: background,
|
|
border: Border.all(color: gold, width: 2),
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// 타이틀
|
|
Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(12),
|
|
color: gold.withValues(alpha: 0.2),
|
|
child: Text(
|
|
title,
|
|
style: TextStyle(
|
|
fontFamily: 'PressStart2P',
|
|
fontSize: 10,
|
|
color: gold,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
// 옵션 목록
|
|
Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(children: children),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 선택 옵션 아이템
|
|
class _RetroOptionItem extends StatelessWidget {
|
|
const _RetroOptionItem({
|
|
required this.label,
|
|
required this.isSelected,
|
|
required this.onTap,
|
|
this.prefix,
|
|
});
|
|
|
|
final String label;
|
|
final bool isSelected;
|
|
final VoidCallback onTap;
|
|
final String? prefix;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final gold = RetroColors.goldOf(context);
|
|
final border = RetroColors.borderOf(context);
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
child: GestureDetector(
|
|
onTap: onTap,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
|
decoration: BoxDecoration(
|
|
color: isSelected ? gold.withValues(alpha: 0.15) : Colors.transparent,
|
|
border: Border.all(
|
|
color: isSelected ? gold : border,
|
|
width: isSelected ? 2 : 1,
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
if (prefix != null) ...[
|
|
Text(prefix!, style: const TextStyle(fontSize: 16)),
|
|
const SizedBox(width: 12),
|
|
],
|
|
Expanded(
|
|
child: Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontFamily: 'PressStart2P',
|
|
fontSize: 18,
|
|
color: isSelected ? gold : RetroColors.textPrimaryOf(context),
|
|
),
|
|
),
|
|
),
|
|
if (isSelected) Icon(Icons.check, size: 16, color: gold),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 사운드 설정 다이얼로그
|
|
class _RetroSoundDialog extends StatelessWidget {
|
|
const _RetroSoundDialog({
|
|
required this.bgmVolume,
|
|
required this.sfxVolume,
|
|
required this.onBgmChanged,
|
|
required this.onSfxChanged,
|
|
});
|
|
|
|
final double bgmVolume;
|
|
final double sfxVolume;
|
|
final ValueChanged<double> onBgmChanged;
|
|
final ValueChanged<double> onSfxChanged;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final background = RetroColors.backgroundOf(context);
|
|
final gold = RetroColors.goldOf(context);
|
|
|
|
return Dialog(
|
|
backgroundColor: Colors.transparent,
|
|
child: Container(
|
|
constraints: const BoxConstraints(maxWidth: 360),
|
|
decoration: BoxDecoration(
|
|
color: background,
|
|
border: Border.all(color: gold, width: 2),
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// 타이틀
|
|
Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(12),
|
|
color: gold.withValues(alpha: 0.2),
|
|
child: Text(
|
|
'SOUND',
|
|
style: TextStyle(
|
|
fontFamily: 'PressStart2P',
|
|
fontSize: 10,
|
|
color: gold,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
// 슬라이더
|
|
Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
children: [
|
|
_buildVolumeSlider(
|
|
context,
|
|
icon: bgmVolume == 0 ? Icons.music_off : Icons.music_note,
|
|
label: 'BGM',
|
|
value: bgmVolume,
|
|
onChanged: onBgmChanged,
|
|
),
|
|
const SizedBox(height: 16),
|
|
_buildVolumeSlider(
|
|
context,
|
|
icon: sfxVolume == 0 ? Icons.volume_off : Icons.volume_up,
|
|
label: 'SFX',
|
|
value: sfxVolume,
|
|
onChanged: onSfxChanged,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
// 확인 버튼
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
|
child: SizedBox(
|
|
width: double.infinity,
|
|
child: RetroTextButton(
|
|
text: 'OK',
|
|
onPressed: () => Navigator.pop(context),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildVolumeSlider(
|
|
BuildContext context, {
|
|
required IconData icon,
|
|
required String label,
|
|
required double value,
|
|
required ValueChanged<double> onChanged,
|
|
}) {
|
|
final gold = RetroColors.goldOf(context);
|
|
final border = RetroColors.borderOf(context);
|
|
final percentage = (value * 100).round();
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(icon, size: 18, color: gold),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontFamily: 'PressStart2P',
|
|
fontSize: 16,
|
|
color: RetroColors.textPrimaryOf(context),
|
|
),
|
|
),
|
|
const Spacer(),
|
|
Text(
|
|
'$percentage%',
|
|
style: TextStyle(
|
|
fontFamily: 'PressStart2P',
|
|
fontSize: 16,
|
|
color: gold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
SliderTheme(
|
|
data: SliderThemeData(
|
|
trackHeight: 8,
|
|
activeTrackColor: gold,
|
|
inactiveTrackColor: border,
|
|
thumbColor: RetroColors.goldLightOf(context),
|
|
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8),
|
|
overlayColor: gold.withValues(alpha: 0.2),
|
|
trackShape: const RectangularSliderTrackShape(),
|
|
),
|
|
child: Slider(value: value, onChanged: onChanged, divisions: 10),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 확인 다이얼로그
|
|
class _RetroConfirmDialog extends StatelessWidget {
|
|
const _RetroConfirmDialog({
|
|
required this.title,
|
|
required this.message,
|
|
required this.confirmText,
|
|
required this.cancelText,
|
|
required this.onConfirm,
|
|
required this.onCancel,
|
|
});
|
|
|
|
final String title;
|
|
final String message;
|
|
final String confirmText;
|
|
final String cancelText;
|
|
final VoidCallback onConfirm;
|
|
final VoidCallback onCancel;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final background = RetroColors.backgroundOf(context);
|
|
final gold = RetroColors.goldOf(context);
|
|
|
|
return Dialog(
|
|
backgroundColor: Colors.transparent,
|
|
child: Container(
|
|
constraints: const BoxConstraints(maxWidth: 360),
|
|
decoration: BoxDecoration(
|
|
color: background,
|
|
border: Border.all(color: gold, width: 3),
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// 타이틀
|
|
Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(12),
|
|
color: gold.withValues(alpha: 0.2),
|
|
child: Text(
|
|
title,
|
|
style: TextStyle(
|
|
fontFamily: 'PressStart2P',
|
|
fontSize: 10,
|
|
color: gold,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
// 메시지
|
|
Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Text(
|
|
message,
|
|
style: TextStyle(
|
|
fontFamily: 'PressStart2P',
|
|
fontSize: 16,
|
|
color: RetroColors.textPrimaryOf(context),
|
|
height: 1.8,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
// 버튼
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: RetroTextButton(
|
|
text: cancelText,
|
|
isPrimary: false,
|
|
onPressed: onCancel,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: RetroTextButton(
|
|
text: confirmText,
|
|
onPressed: onConfirm,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|