diff --git a/lib/src/features/front/front_screen.dart b/lib/src/features/front/front_screen.dart index 0da592b..c43bfc4 100644 --- a/lib/src/features/front/front_screen.dart +++ b/lib/src/features/front/front_screen.dart @@ -2,6 +2,7 @@ import 'dart:io' show Platform; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; +import 'package:package_info_plus/package_info_plus.dart'; import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n; import 'package:asciineverdie/l10n/app_localizations.dart'; @@ -18,9 +19,13 @@ class FrontScreen extends StatefulWidget { this.onHallOfFame, this.onLocalArena, this.onSettings, + this.onPurchaseRemoveAds, + this.onRestorePurchase, this.hasSaveFile = false, this.savedGamePreview, this.hallOfFameCount = 0, + this.isAdRemovalPurchased = false, + this.removeAdsPrice, this.routeObserver, this.onRefresh, }); @@ -40,6 +45,12 @@ class FrontScreen extends StatefulWidget { /// "Settings" 버튼 클릭 시 호출 (언어, 테마, 사운드) final void Function(BuildContext context)? onSettings; + /// "광고 제거" 구매 버튼 클릭 시 호출 + final Future Function(BuildContext context)? onPurchaseRemoveAds; + + /// "구매 복원" 버튼 클릭 시 호출 + final Future Function(BuildContext context)? onRestorePurchase; + /// 세이브 파일 존재 여부 (새 캐릭터 시 경고용) final bool hasSaveFile; @@ -49,6 +60,12 @@ class FrontScreen extends StatefulWidget { /// 명예의 전당 캐릭터 수 (아레나 활성화 조건: 2명 이상) final int hallOfFameCount; + /// 광고 제거 구매 여부 + final bool isAdRemovalPurchased; + + /// 광고 제거 상품 가격 (null이면 스토어 비활성) + final String? removeAdsPrice; + /// RouteObserver (화면 복귀 시 갱신용) final RouteObserver>? routeObserver; @@ -132,8 +149,6 @@ class _FrontScreenState extends State with RouteAware { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const _RetroHeader(), - const SizedBox(height: 16), const _AnimationPanel(), const SizedBox(height: 16), _ActionButtons( @@ -154,8 +169,17 @@ class _FrontScreenState extends State with RouteAware { onSettings: widget.onSettings != null ? () => widget.onSettings!(context) : null, + onPurchaseRemoveAds: + widget.onPurchaseRemoveAds != null + ? () => widget.onPurchaseRemoveAds!(context) + : null, + onRestorePurchase: widget.onRestorePurchase != null + ? () => widget.onRestorePurchase!(context) + : null, savedGamePreview: widget.savedGamePreview, hallOfFameCount: widget.hallOfFameCount, + isAdRemovalPurchased: widget.isAdRemovalPurchased, + removeAdsPrice: widget.removeAdsPrice, ), ], ), @@ -172,58 +196,7 @@ class _FrontScreenState extends State with RouteAware { } } -/// 레트로 스타일 헤더 (타이틀 + 태그) -class _RetroHeader extends StatelessWidget { - const _RetroHeader(); - - @override - Widget build(BuildContext context) { - final l10n = L10n.of(context); - return RetroGoldPanel( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), - child: Column( - children: [ - // 타이틀 (픽셀 폰트) - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.auto_awesome, color: RetroColors.gold, size: 20), - const SizedBox(width: 12), - Text( - l10n.appTitle, - style: const TextStyle( - fontFamily: 'PressStart2P', - fontSize: 13, - color: RetroColors.gold, - shadows: [ - Shadow(color: RetroColors.goldDark, offset: Offset(2, 2)), - ], - ), - ), - ], - ), - const SizedBox(height: 16), - // 태그 (레트로 스타일) - Wrap( - alignment: WrapAlignment.center, - spacing: 8, - runSpacing: 8, - children: [ - _RetroTag( - icon: Icons.cloud_off_outlined, - label: l10n.tagNoNetwork, - ), - _RetroTag(icon: Icons.timer_outlined, label: l10n.tagIdleRpg), - _RetroTag(icon: Icons.storage_rounded, label: l10n.tagLocalSaves), - ], - ), - ], - ), - ); - } -} - -/// 애니메이션 패널 +/// 애니메이션 패널 (금색 테두리 + 아이콘+타이틀) class _AnimationPanel extends StatelessWidget { const _AnimationPanel(); @@ -238,8 +211,25 @@ class _AnimationPanel extends StatelessWidget { @override Widget build(BuildContext context) { - return RetroPanel( - title: 'BATTLE', + return RetroGoldPanel( + titleWidget: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.auto_awesome, color: RetroColors.gold, size: 18), + const SizedBox(width: 10), + const Text( + 'ASCII NEVER DIE', + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 14, + color: RetroColors.gold, + shadows: [ + Shadow(color: RetroColors.goldDark, offset: Offset(2, 2)), + ], + ), + ), + ], + ), padding: const EdgeInsets.all(8), child: AspectRatio( aspectRatio: _getAspectRatio(), @@ -257,8 +247,12 @@ class _ActionButtons extends StatelessWidget { this.onHallOfFame, this.onLocalArena, this.onSettings, + this.onPurchaseRemoveAds, + this.onRestorePurchase, this.savedGamePreview, this.hallOfFameCount = 0, + this.isAdRemovalPurchased = false, + this.removeAdsPrice, }); final VoidCallback? onNewCharacter; @@ -266,8 +260,12 @@ class _ActionButtons extends StatelessWidget { final VoidCallback? onHallOfFame; final VoidCallback? onLocalArena; final VoidCallback? onSettings; + final VoidCallback? onPurchaseRemoveAds; + final VoidCallback? onRestorePurchase; final SavedGamePreview? savedGamePreview; final int hallOfFameCount; + final bool isAdRemovalPurchased; + final String? removeAdsPrice; @override Widget build(BuildContext context) { @@ -323,6 +321,24 @@ class _ActionButtons extends StatelessWidget { onPressed: onSettings, isPrimary: false, ), + // IAP 구매 (광고 제거) - 스토어 사용 가능하고 미구매 상태일 때만 표시 + if (removeAdsPrice != null && !isAdRemovalPurchased) ...[ + const SizedBox(height: 20), + const Divider(color: RetroColors.panelBorderInner, height: 1), + const SizedBox(height: 12), + _IapPurchaseButton( + price: removeAdsPrice!, + onPurchase: onPurchaseRemoveAds, + onRestore: onRestorePurchase, + ), + ], + // 이미 구매된 경우 표시 + if (isAdRemovalPurchased) ...[ + const SizedBox(height: 20), + const Divider(color: RetroColors.panelBorderInner, height: 1), + const SizedBox(height: 12), + _PurchasedBadge(), + ], ], ), ); @@ -370,50 +386,322 @@ class _CopyrightFooter extends StatelessWidget { Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(vertical: 12), - child: Text( - game_l10n.copyrightText, - textAlign: TextAlign.center, - style: const TextStyle( - fontFamily: 'PressStart2P', - fontSize: 7, - color: RetroColors.textDisabled, + child: FutureBuilder( + future: PackageInfo.fromPlatform(), + builder: (context, snapshot) { + final version = snapshot.data?.version ?? ''; + final versionSuffix = version.isNotEmpty ? ' v$version' : ''; + return Text( + '${game_l10n.copyrightText}$versionSuffix', + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'PressStart2P', + fontSize: 7, + color: RetroColors.textDisabled, + ), + ); + }, + ), + ); + } +} + +/// IAP 구매 버튼 (광고 제거) +class _IapPurchaseButton extends StatelessWidget { + const _IapPurchaseButton({ + required this.price, + this.onPurchase, + this.onRestore, + }); + + final String price; + final VoidCallback? onPurchase; + final VoidCallback? onRestore; + + void _showPurchaseDialog(BuildContext context) { + showDialog( + context: context, + builder: (dialogContext) => _IapPurchaseDialog( + price: price, + onPurchase: () { + Navigator.pop(dialogContext); + onPurchase?.call(); + }, + ), + ); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 구매 버튼 (클릭 시 팝업) + Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF4A3B2A), Color(0xFF3D2E1F)], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + border: Border.all(color: RetroColors.gold, width: 2), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => _showPurchaseDialog(context), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + child: Row( + children: [ + const Icon(Icons.block, color: RetroColors.gold, size: 24), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + game_l10n.iapRemoveAds, + style: const TextStyle( + fontFamily: 'PressStart2P', + fontSize: 12, + color: RetroColors.gold, + ), + ), + const SizedBox(height: 4), + Text( + game_l10n.iapRemoveAdsDesc, + style: const TextStyle( + fontFamily: 'PressStart2P', + fontSize: 9, + color: RetroColors.textDisabled, + ), + ), + ], + ), + ), + // 화살표 아이콘 (상세 보기) + const Icon( + Icons.arrow_forward_ios, + color: RetroColors.gold, + size: 16, + ), + ], + ), + ), + ), + ), + ), + const SizedBox(height: 8), + // 복원 버튼 + Center( + child: TextButton( + onPressed: onRestore, + child: Text( + game_l10n.iapRestorePurchase, + style: const TextStyle( + fontFamily: 'PressStart2P', + fontSize: 9, + color: RetroColors.textDisabled, + decoration: TextDecoration.underline, + ), + ), + ), + ), + ], + ); + } +} + +/// IAP 구매 팝업 다이얼로그 +class _IapPurchaseDialog extends StatelessWidget { + const _IapPurchaseDialog({required this.price, this.onPurchase}); + + final String price; + final VoidCallback? onPurchase; + + @override + Widget build(BuildContext context) { + return Dialog( + backgroundColor: RetroColors.deepBrown, + shape: RoundedRectangleBorder( + side: const BorderSide(color: RetroColors.gold, width: 2), + borderRadius: BorderRadius.circular(4), + ), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 360), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 타이틀 + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.star, color: RetroColors.gold, size: 20), + const SizedBox(width: 8), + Text( + game_l10n.iapBenefitTitle, + style: const TextStyle( + fontFamily: 'PressStart2P', + fontSize: 12, + color: RetroColors.gold, + ), + ), + const SizedBox(width: 8), + const Icon(Icons.star, color: RetroColors.gold, size: 20), + ], + ), + const SizedBox(height: 20), + // 혜택 목록 + _BenefitItem(icon: Icons.block, text: game_l10n.iapBenefit1), + const SizedBox(height: 8), + _BenefitItem(icon: Icons.flash_on, text: game_l10n.iapBenefit2), + const SizedBox(height: 8), + _BenefitItem(icon: Icons.undo, text: game_l10n.iapBenefit3), + const SizedBox(height: 8), + _BenefitItem(icon: Icons.casino, text: game_l10n.iapBenefit4), + const SizedBox(height: 8), + _BenefitItem(icon: Icons.speed, text: game_l10n.iapBenefit5), + const SizedBox(height: 8), + _BenefitItem(icon: Icons.inventory_2, text: game_l10n.iapBenefit6), + const SizedBox(height: 20), + // 가격 + 구매 버튼 + Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF5A4B3A), Color(0xFF4A3B2A)], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + border: Border.all(color: RetroColors.gold, width: 2), + borderRadius: BorderRadius.circular(4), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onPurchase, + borderRadius: BorderRadius.circular(2), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 14), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + game_l10n.iapPurchaseButton, + style: const TextStyle( + fontFamily: 'PressStart2P', + fontSize: 12, + color: RetroColors.gold, + ), + ), + const SizedBox(width: 12), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: RetroColors.gold, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + price, + style: const TextStyle( + fontFamily: 'PressStart2P', + fontSize: 11, + color: RetroColors.deepBrown, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + ), + ), + ), + const SizedBox(height: 12), + // 취소 버튼 + Center( + child: TextButton( + onPressed: () => Navigator.pop(context), + child: Text( + game_l10n.buttonCancel, + style: const TextStyle( + fontFamily: 'PressStart2P', + fontSize: 10, + color: RetroColors.textDisabled, + ), + ), + ), + ), + ], + ), ), ), ); } } -/// 레트로 태그 칩 -class _RetroTag extends StatelessWidget { - const _RetroTag({required this.icon, required this.label}); +/// 혜택 항목 위젯 +class _BenefitItem extends StatelessWidget { + const _BenefitItem({required this.icon, required this.text}); final IconData icon; - final String label; + final String text; @override Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: RetroColors.panelBgLight, - border: Border.all(color: RetroColors.panelBorderInner, width: 1), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, color: RetroColors.gold, size: 12), - const SizedBox(width: 6), - Text( - label, + return Row( + children: [ + Icon(icon, color: RetroColors.expGreen, size: 18), + const SizedBox(width: 12), + Expanded( + child: Text( + text, style: const TextStyle( fontFamily: 'PressStart2P', - fontSize: 11, + fontSize: 10, color: RetroColors.textLight, ), ), + ), + ], + ); + } +} + +/// 이미 구매됨 뱃지 +class _PurchasedBadge extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: RetroColors.panelBgLight, + border: Border.all(color: RetroColors.expGreen, width: 2), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.check_circle, color: RetroColors.expGreen, size: 20), + const SizedBox(width: 8), + Text( + game_l10n.iapAlreadyPurchased, + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 11, + color: RetroColors.expGreen, + ), + ), ], ), ); } } - diff --git a/lib/src/features/game/game_play_screen.dart b/lib/src/features/game/game_play_screen.dart index 372f0bb..aa7a0f7 100644 --- a/lib/src/features/game/game_play_screen.dart +++ b/lib/src/features/game/game_play_screen.dart @@ -7,7 +7,7 @@ import 'package:flutter/services.dart' show KeyDownEvent, LogicalKeyboardKey; import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n; import 'package:asciineverdie/data/skill_data.dart'; import 'package:asciineverdie/src/core/engine/iap_service.dart'; -import 'package:asciineverdie/src/core/engine/return_rewards_service.dart'; +import 'package:asciineverdie/src/core/model/treasure_chest.dart'; import 'package:asciineverdie/data/story_data.dart'; import 'package:asciineverdie/l10n/app_localizations.dart'; import 'package:asciineverdie/src/core/animation/ascii_animation_type.dart'; @@ -51,8 +51,6 @@ class GamePlayScreen extends StatefulWidget { this.audioService, this.forceCarouselLayout = false, this.forceDesktopLayout = false, - this.onThemeModeChange, - this.currentThemeMode = ThemeMode.system, }); final GameSessionController controller; @@ -66,12 +64,6 @@ class GamePlayScreen extends StatefulWidget { /// 테스트 모드: 모바일에서도 데스크톱 3패널 레이아웃 강제 사용 final bool forceDesktopLayout; - /// 테마 모드 변경 콜백 - final void Function(ThemeMode mode)? onThemeModeChange; - - /// 현재 테마 모드 - final ThemeMode currentThemeMode; - @override State createState() => _GamePlayScreenState(); } @@ -316,8 +308,6 @@ class _GamePlayScreenState extends State builder: (_) => GamePlayScreen( controller: widget.controller, audioService: widget.audioService, - currentThemeMode: widget.currentThemeMode, - onThemeModeChange: widget.onThemeModeChange, ), ), ); @@ -407,15 +397,19 @@ class _GamePlayScreenState extends State } /// 복귀 보상 다이얼로그 표시 (Phase 7) - void _showReturnRewardsDialog(ReturnReward reward) { + void _showReturnRewardsDialog(ReturnChestReward reward) { // 잠시 후 다이얼로그 표시 (게임 시작 후) WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; + final state = widget.controller.state; + if (state == null) return; + ReturnRewardsDialog.show( context, reward: reward, - onClaim: (totalGold) { - widget.controller.applyReturnReward(totalGold); + playerLevel: state.traits.level, + onClaim: (rewards) { + widget.controller.applyReturnReward(rewards); }, ); }); @@ -436,10 +430,6 @@ class _GamePlayScreenState extends State SettingsScreen.show( context, settingsRepository: settingsRepo, - currentThemeMode: widget.currentThemeMode, - onThemeModeChange: (mode) { - widget.onThemeModeChange?.call(mode); - }, onLocaleChange: (locale) async { // 안전한 언어 변경: 전체 화면 재생성 final navigator = Navigator.of(this.context); @@ -452,8 +442,6 @@ class _GamePlayScreenState extends State builder: (_) => GamePlayScreen( controller: widget.controller, audioService: widget.audioService, - currentThemeMode: widget.currentThemeMode, - onThemeModeChange: widget.onThemeModeChange, ), ), ); @@ -586,6 +574,10 @@ class _GamePlayScreenState extends State widget.controller.loop?.cycleSpeed(); setState(() {}); }, + onSetSpeed: (speed) { + widget.controller.loop?.setSpeed(speed); + setState(() {}); + }, // 특수 애니메이션 중에는 일시정지 상태로 표시하지 않음 isPaused: !widget.controller.isRunning && _specialAnimation == null, @@ -620,8 +612,6 @@ class _GamePlayScreenState extends State builder: (_) => GamePlayScreen( controller: widget.controller, audioService: widget.audioService, - currentThemeMode: widget.currentThemeMode, - onThemeModeChange: widget.onThemeModeChange, ), ), ); @@ -637,8 +627,6 @@ class _GamePlayScreenState extends State Navigator.of(context).pop(); } }, - currentThemeMode: widget.currentThemeMode, - onThemeModeChange: widget.onThemeModeChange, // 사운드 설정 bgmVolume: _audioController.bgmVolume, sfxVolume: _audioController.sfxVolume, @@ -666,11 +654,13 @@ class _GamePlayScreenState extends State navigator.popUntil((route) => route.isFirst); } }, - // 수익화 버프 (자동부활, 5배속) + // 수익화 버프 (자동부활, 광고배속) autoReviveEndMs: widget.controller.monetization.autoReviveEndMs, speedBoostEndMs: widget.controller.monetization.speedBoostEndMs, isPaidUser: widget.controller.monetization.isPaidUser, onSpeedBoostActivate: _handleSpeedBoost, + adSpeedMultiplier: widget.controller.adSpeedMultiplier, + has2xUnlocked: widget.controller.has2xUnlocked, ), // 사망 오버레이 if (state.isDead && state.deathInfo != null) diff --git a/lib/src/features/game/game_session_controller.dart b/lib/src/features/game/game_session_controller.dart index f4d4242..f7a2fcf 100644 --- a/lib/src/features/game/game_session_controller.dart +++ b/lib/src/features/game/game_session_controller.dart @@ -14,6 +14,7 @@ import 'package:asciineverdie/src/core/model/game_state.dart'; import 'package:asciineverdie/src/core/model/game_statistics.dart'; import 'package:asciineverdie/src/core/model/hall_of_fame.dart'; import 'package:asciineverdie/src/core/model/monetization_state.dart'; +import 'package:asciineverdie/src/core/model/treasure_chest.dart'; import 'package:asciineverdie/src/core/storage/hall_of_fame_storage.dart'; import 'package:asciineverdie/src/core/storage/save_manager.dart'; import 'package:asciineverdie/src/core/storage/statistics_storage.dart'; @@ -64,14 +65,16 @@ class GameSessionController extends ChangeNotifier { Timer? _speedBoostTimer; int _speedBoostRemainingSeconds = 0; static const int _speedBoostDuration = 300; // 5분 - static const int _speedBoostMultiplier = 5; // 5x 속도 + + /// 광고 배속 배율 (릴리즈: 5x, 디버그빌드+디버그모드: 20x) + int get _speedBoostMultiplier => (kDebugMode && _cheatsEnabled) ? 20 : 5; // 복귀 보상 상태 (Phase 7) MonetizationState _monetization = MonetizationState.initial(); - ReturnReward? _pendingReturnReward; + ReturnChestReward? _pendingReturnReward; /// 복귀 보상 콜백 (UI에서 다이얼로그 표시용) - void Function(ReturnReward reward)? onReturnRewardAvailable; + void Function(ReturnChestReward reward)? onReturnRewardAvailable; // 통계 관련 필드 SessionStatistics _sessionStats = SessionStatistics.empty(); @@ -105,6 +108,12 @@ class GameSessionController extends ChangeNotifier { /// 현재 ProgressLoop 인스턴스 (치트 기능용) ProgressLoop? get loop => _loop; + /// 광고 배속 배율 (릴리즈: 5x, 디버그빌드+디버그모드: 20x) + int get adSpeedMultiplier => _speedBoostMultiplier; + + /// 2x 배속 해금 여부 (명예의 전당에 캐릭터가 있으면 true) + bool get has2xUnlocked => _loop?.availableSpeeds.contains(2) ?? false; + Future startNew( GameState initialState, { bool cheatsEnabled = false, @@ -172,13 +181,16 @@ class GameSessionController extends ChangeNotifier { } /// 가용 배속 목록 반환 - /// - 디버그 모드(치트 활성화): [1, 2, 20] (터보 모드 포함) - /// - 일반 모드: [1, 2] (5x는 광고 버프로만 활성화) + /// + /// - 기본: [1] (1x만) + /// - 명예의 전당에 캐릭터 있으면: [1, 2] (2x 해금) + /// - 광고 배속(5x/20x)은 별도 버프로만 활성화 Future> _getAvailableSpeeds() async { - if (_cheatsEnabled) { - return [1, 2, 20]; + final hallOfFame = await _hallOfFameStorage.load(); + if (hallOfFame.entries.isNotEmpty) { + return [1, 2]; // 명예의 전당 캐릭터 있으면 2x 해금 } - return [1, 2]; + return [1]; // 기본: 1x만 } /// 이전 값 초기화 (통계 변화 추적용) @@ -700,7 +712,7 @@ class GameSessionController extends ChangeNotifier { MonetizationState get monetization => _monetization; /// 대기 중인 복귀 보상 - ReturnReward? get pendingReturnReward => _pendingReturnReward; + ReturnChestReward? get pendingReturnReward => _pendingReturnReward; /// 복귀 보상 체크 (로드 시 호출) void _checkReturnRewards(GameState loaded) { @@ -715,17 +727,17 @@ class GameSessionController extends ChangeNotifier { final reward = rewardsService.calculateReward( lastPlayTime: lastPlayTime, currentTime: DateTime.now(), - playerLevel: loaded.traits.level, + isPaidUser: _monetization.isPaidUser, ); if (reward.hasReward) { _pendingReturnReward = reward; - debugPrint('[ReturnRewards] Reward available: ${reward.goldReward} gold, ' + debugPrint('[ReturnRewards] Reward available: ${reward.chestCount} chests, ' '${reward.hoursAway} hours away'); // UI에서 다이얼로그 표시를 위해 콜백 호출 // startNew 후에 호출하도록 딜레이 - Future.delayed(const Duration(milliseconds: 500), () { + Future.delayed(const Duration(milliseconds: 500), () { if (_pendingReturnReward != null) { onReturnRewardAvailable?.call(_pendingReturnReward!); } @@ -733,23 +745,86 @@ class GameSessionController extends ChangeNotifier { } } - /// 복귀 보상 수령 완료 (골드 적용) + /// 복귀 보상 수령 완료 (상자 보상 적용) /// - /// [totalGold] 수령한 총 골드 (기본 + 보너스) - void applyReturnReward(int totalGold) { + /// [rewards] 오픈된 상자 보상 목록 + void applyReturnReward(List rewards) { if (_state == null) return; - if (totalGold <= 0) { + if (rewards.isEmpty) { // 보상 없이 건너뛴 경우 _pendingReturnReward = null; debugPrint('[ReturnRewards] Reward skipped'); return; } - // 골드 추가 - final updatedInventory = _state!.inventory.copyWith( - gold: _state!.inventory.gold + totalGold, - ); - _state = _state!.copyWith(inventory: updatedInventory); + var updatedState = _state!; + + // 보상 적용 + for (final reward in rewards) { + switch (reward.type) { + case ChestRewardType.equipment: + if (reward.equipment != null) { + // 현재 장비와 비교하여 더 좋으면 자동 장착 + final slotIndex = reward.equipment!.slot.index; + final currentItem = updatedState.equipment.getItemByIndex(slotIndex); + if (currentItem.isEmpty || + reward.equipment!.itemWeight > currentItem.itemWeight) { + updatedState = updatedState.copyWith( + equipment: updatedState.equipment.setItemByIndex( + slotIndex, + reward.equipment!, + ), + ); + debugPrint('[ReturnRewards] Equipped: ${reward.equipment!.name}'); + } else { + // 더 좋지 않으면 판매 (골드로 변환) + final sellPrice = + (reward.equipment!.level * 50 * 0.3).round().clamp(1, 99999); + updatedState = updatedState.copyWith( + inventory: updatedState.inventory.copyWith( + gold: updatedState.inventory.gold + sellPrice, + ), + ); + debugPrint('[ReturnRewards] Sold: ${reward.equipment!.name} ' + 'for $sellPrice gold'); + } + } + case ChestRewardType.potion: + if (reward.potionId != null) { + updatedState = updatedState.copyWith( + potionInventory: updatedState.potionInventory.addPotion( + reward.potionId!, + reward.potionCount ?? 1, + ), + ); + debugPrint('[ReturnRewards] Added potion: ${reward.potionId} ' + 'x${reward.potionCount}'); + } + case ChestRewardType.gold: + if (reward.gold != null && reward.gold! > 0) { + updatedState = updatedState.copyWith( + inventory: updatedState.inventory.copyWith( + gold: updatedState.inventory.gold + reward.gold!, + ), + ); + debugPrint('[ReturnRewards] Added gold: ${reward.gold}'); + } + case ChestRewardType.experience: + if (reward.experience != null && reward.experience! > 0) { + updatedState = updatedState.copyWith( + progress: updatedState.progress.copyWith( + exp: updatedState.progress.exp.copyWith( + position: + updatedState.progress.exp.position + reward.experience!, + ), + ), + ); + debugPrint('[ReturnRewards] Added experience: ${reward.experience}'); + } + } + } + + _state = updatedState; // 저장 unawaited(saveManager.saveState( @@ -761,7 +836,7 @@ class GameSessionController extends ChangeNotifier { _pendingReturnReward = null; notifyListeners(); - debugPrint('[ReturnRewards] Reward applied: $totalGold gold'); + debugPrint('[ReturnRewards] Rewards applied: ${rewards.length} items'); } /// 복귀 보상 건너뛰기 diff --git a/lib/src/features/game/layouts/mobile_carousel_layout.dart b/lib/src/features/game/layouts/mobile_carousel_layout.dart index 5925a1c..f24aa5b 100644 --- a/lib/src/features/game/layouts/mobile_carousel_layout.dart +++ b/lib/src/features/game/layouts/mobile_carousel_layout.dart @@ -17,6 +17,7 @@ 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'; /// 모바일 캐로셀 레이아웃 /// @@ -39,8 +40,6 @@ class MobileCarouselLayout extends StatefulWidget { required this.onLanguageChange, required this.onDeleteSaveAndNewGame, this.specialAnimation, - this.currentThemeMode = ThemeMode.system, - this.onThemeModeChange, this.bgmVolume = 0.7, this.sfxVolume = 0.8, this.onBgmVolumeChange, @@ -56,12 +55,18 @@ class MobileCarouselLayout extends StatefulWidget { this.speedBoostEndMs, this.isPaidUser = false, this.onSpeedBoostActivate, + this.onSetSpeed, + this.adSpeedMultiplier = 5, + this.has2xUnlocked = false, }); final GameState state; final List combatLogEntries; final int speedMultiplier; final VoidCallback onSpeedCycle; + + /// 특정 속도로 직접 설정 (옵션 메뉴용) + final void Function(int speed)? onSetSpeed; final bool isPaused; final VoidCallback onPauseToggle; final VoidCallback onSave; @@ -70,8 +75,6 @@ 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; /// BGM 볼륨 (0.0 ~ 1.0) final double bgmVolume; @@ -115,9 +118,15 @@ class MobileCarouselLayout extends StatefulWidget { /// 유료 유저 여부 final bool isPaidUser; - /// 5배속 버프 활성화 콜백 (광고 시청) + /// 광고 배속 활성화 콜백 (광고 시청) final VoidCallback? onSpeedBoostActivate; + /// 광고 배속 배율 (릴리즈: 5x, 디버그빌드+디버그모드: 20x) + final int adSpeedMultiplier; + + /// 2x 배속 해금 여부 (명예의 전당에 캐릭터가 있으면 true) + final bool has2xUnlocked; + @override State createState() => _MobileCarouselLayoutState(); } @@ -160,76 +169,17 @@ 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( context: context, - builder: (context) => AlertDialog( - title: Text(l10n.menuLanguage), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _buildLanguageOption(context, 'en', l10n.languageEnglish), - _buildLanguageOption(context, 'ko', l10n.languageKorean), - _buildLanguageOption(context, 'ja', l10n.languageJapanese), - ], - ), + builder: (context) => _RetroSelectDialog( + title: l10n.menuLanguage.toUpperCase(), + children: [ + _buildLanguageOption(context, 'en', l10n.languageEnglish, '🇺🇸'), + _buildLanguageOption(context, 'ko', l10n.languageKorean, '🇰🇷'), + _buildLanguageOption(context, 'ja', l10n.languageJapanese, '🇯🇵'), + ], ), ); } @@ -238,21 +188,15 @@ class _MobileCarouselLayoutState extends State { BuildContext context, String locale, String label, + String flag, ) { final isSelected = l10n.currentGameLocale == locale; - return ListTile( - leading: Icon( - isSelected ? Icons.radio_button_checked : Icons.radio_button_unchecked, - color: isSelected ? Theme.of(context).colorScheme.primary : null, - ), - title: Text( - label, - style: TextStyle( - fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, - ), - ), + return _RetroOptionItem( + label: label.toUpperCase(), + prefix: flag, + isSelected: isSelected, onTap: () { - Navigator.pop(context); // 다이얼로그 닫기 + Navigator.pop(context); widget.onLanguageChange(locale); }, ); @@ -270,91 +214,23 @@ class _MobileCarouselLayoutState extends State { /// 사운드 설정 다이얼로그 표시 void _showSoundDialog(BuildContext context) { - // StatefulBuilder를 사용하여 다이얼로그 내 상태 관리 var bgmVolume = widget.bgmVolume; var sfxVolume = widget.sfxVolume; showDialog( context: context, builder: (context) => StatefulBuilder( - builder: (context, setDialogState) => AlertDialog( - title: Text(l10n.uiSound), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // BGM 볼륨 - Row( - children: [ - Icon( - bgmVolume == 0 ? Icons.music_off : Icons.music_note, - color: Theme.of(context).colorScheme.primary, - ), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(l10n.uiBgmVolume), - Text('${(bgmVolume * 100).round()}%'), - ], - ), - Slider( - value: bgmVolume, - onChanged: (value) { - setDialogState(() => bgmVolume = value); - widget.onBgmVolumeChange?.call(value); - }, - divisions: 10, - ), - ], - ), - ), - ], - ), - const SizedBox(height: 8), - // SFX 볼륨 - Row( - children: [ - Icon( - sfxVolume == 0 ? Icons.volume_off : Icons.volume_up, - color: Theme.of(context).colorScheme.primary, - ), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(l10n.uiSfxVolume), - Text('${(sfxVolume * 100).round()}%'), - ], - ), - Slider( - value: sfxVolume, - onChanged: (value) { - setDialogState(() => sfxVolume = value); - widget.onSfxVolumeChange?.call(value); - }, - divisions: 10, - ), - ], - ), - ), - ], - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(l10n.buttonConfirm), - ), - ], + 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); + }, ), ), ); @@ -364,23 +240,16 @@ class _MobileCarouselLayoutState extends State { void _showDeleteConfirmDialog(BuildContext context) { showDialog( context: context, - builder: (context) => AlertDialog( - title: Text(l10n.confirmDeleteTitle), - content: Text(l10n.confirmDeleteMessage), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(l10n.buttonCancel), - ), - TextButton( - onPressed: () { - Navigator.pop(context); // 다이얼로그 닫기 - widget.onDeleteSaveAndNewGame(); - }, - style: TextButton.styleFrom(foregroundColor: Colors.red), - child: Text(l10n.buttonConfirm), - ), - ], + 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), ), ); } @@ -389,27 +258,16 @@ class _MobileCarouselLayoutState extends State { Future _showTestCharacterDialog(BuildContext context) async { final confirmed = await showDialog( context: context, - builder: (context) => AlertDialog( - title: const Text('Create Test Character?'), - content: const Text( - '현재 캐릭터가 레벨 100으로 변환되어 명예의 전당에 등록됩니다.\n\n' - '⚠️ 현재 세이브 파일이 삭제됩니다.\n' - '이 작업은 되돌릴 수 없습니다.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - ElevatedButton( - onPressed: () => Navigator.of(context).pop(true), - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.error, - foregroundColor: Theme.of(context).colorScheme.onError, - ), - child: const Text('Create'), - ), - ], + 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), ), ); @@ -421,257 +279,260 @@ class _MobileCarouselLayoutState extends State { /// 옵션 메뉴 표시 void _showOptionsMenu(BuildContext context) { final localizations = L10n.of(context); - final panelBg = RetroColors.panelBgOf(context); + final background = RetroColors.backgroundOf(context); final gold = RetroColors.goldOf(context); - final surface = RetroColors.surfaceOf(context); + final border = RetroColors.borderOf(context); showModalBottomSheet( context: context, isScrollControlled: true, - backgroundColor: panelBg, + backgroundColor: Colors.transparent, constraints: BoxConstraints( - maxHeight: MediaQuery.of(context).size.height * 0.7, + maxHeight: MediaQuery.of(context).size.height * 0.75, ), - builder: (context) => SafeArea( - child: SingleChildScrollView( + 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.all(16), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), width: double.infinity, decoration: BoxDecoration( - color: surface, + color: RetroColors.panelBgOf(context), border: Border(bottom: BorderSide(color: gold, width: 2)), ), - child: Text( - 'OPTIONS', - style: TextStyle( - fontFamily: 'PressStart2P', - fontSize: 15, - color: gold, - ), - ), - ), - - // 일시정지/재개 - ListTile( - leading: Icon( - widget.isPaused ? Icons.play_arrow : Icons.pause, - color: widget.isPaused ? Colors.green : Colors.orange, - ), - title: Text(widget.isPaused ? l10n.menuResume : l10n.menuPause), - onTap: () { - Navigator.pop(context); - widget.onPauseToggle(); - }, - ), - - // 속도 조절 - ListTile( - leading: const Icon(Icons.speed), - title: Text(l10n.menuSpeed), - trailing: _buildSpeedSelector(context), - ), - - const Divider(), - - // 통계 - if (widget.onShowStatistics != null) - ListTile( - leading: const Icon(Icons.bar_chart, color: Colors.blue), - title: Text(l10n.uiStatistics), - onTap: () { - Navigator.pop(context); - widget.onShowStatistics?.call(); - }, - ), - - // 도움말 - if (widget.onShowHelp != null) - ListTile( - leading: const Icon(Icons.help_outline, color: Colors.green), - title: Text(l10n.uiHelp), - onTap: () { - Navigator.pop(context); - widget.onShowHelp?.call(); - }, - ), - - const Divider(), - - // 언어 변경 - ListTile( - leading: const Icon(Icons.language, color: Colors.teal), - title: Text(l10n.menuLanguage), - trailing: Text( - _getCurrentLanguageName(), - style: TextStyle( - color: Theme.of(context).colorScheme.primary, - ), - ), - onTap: () { - Navigator.pop(context); - _showLanguageDialog(context); - }, - ), - - // 테마 변경 - 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, + child: Row( + children: [ + Icon(Icons.settings, color: gold, size: 18), + const SizedBox(width: 8), + Text( + 'OPTIONS', + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 14, + color: gold, + ), ), - ), - onTap: () { - Navigator.pop(context); - _showThemeDialog(context); - }, - ), - - // 사운드 설정 - if (widget.onBgmVolumeChange != null || - widget.onSfxVolumeChange != null) - ListTile( - leading: Icon( - widget.bgmVolume == 0 && widget.sfxVolume == 0 - ? Icons.volume_off - : Icons.volume_up, - color: Colors.indigo, - ), - title: Text(l10n.uiSound), - trailing: Text( - _getSoundStatus(), - style: TextStyle( - color: Theme.of(context).colorScheme.primary, + const Spacer(), + RetroIconButton( + icon: Icons.close, + onPressed: () => Navigator.pop(context), + size: 28, ), - ), - onTap: () { - Navigator.pop(context); - _showSoundDialog(context); - }, + ], ), - - const Divider(), - - // 저장 - ListTile( - leading: const Icon(Icons.save, color: Colors.blue), - title: Text(l10n.menuSave), - onTap: () { - Navigator.pop(context); - widget.onSave(); - widget.notificationService.showGameSaved(l10n.menuSaved); - }, ), + // 메뉴 목록 + 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), - // 새로하기 (세이브 삭제) - ListTile( - leading: const Icon(Icons.refresh, color: Colors.orange), - title: Text(l10n.menuNewGame), - subtitle: Text( - l10n.menuDeleteSave, - style: TextStyle( - fontSize: 17, - color: Theme.of(context).colorScheme.outline, + // === 정보 === + 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), + ], ), ), - onTap: () { - Navigator.pop(context); - _showDeleteConfirmDialog(context); - }, ), - - // 종료 - ListTile( - leading: const Icon(Icons.exit_to_app, color: Colors.red), - title: Text(localizations.exitGame), - onTap: () { - Navigator.pop(context); - widget.onExit(); - }, - ), - - // 치트 섹션 (디버그 모드에서만 표시) - if (widget.cheatsEnabled) ...[ - const Divider(), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - child: Text( - 'DEBUG CHEATS', - style: TextStyle( - fontFamily: 'PressStart2P', - fontSize: 13, - color: Colors.red.shade300, - ), - ), - ), - ListTile( - leading: const Icon(Icons.fast_forward, color: Colors.red), - title: const Text('Skip Task (L+1)'), - subtitle: const Text('태스크 즉시 완료'), - onTap: () { - Navigator.pop(context); - widget.onCheatTask?.call(); - }, - ), - ListTile( - leading: const Icon(Icons.skip_next, color: Colors.red), - title: const Text('Skip Quest (Q!)'), - subtitle: const Text('퀘스트 즉시 완료'), - onTap: () { - Navigator.pop(context); - widget.onCheatQuest?.call(); - }, - ), - ListTile( - leading: const Icon(Icons.double_arrow, color: Colors.red), - title: const Text('Skip Act (P!)'), - subtitle: const Text('액트 즉시 완료 (명예의 전당 테스트용)'), - onTap: () { - Navigator.pop(context); - widget.onCheatPlot?.call(); - }, - ), - ], - - // 디버그 도구 섹션 (kDebugMode에서만 표시) - if (kDebugMode && widget.onCreateTestCharacter != null) ...[ - const Divider(), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - child: Text( - 'DEBUG TOOLS', - style: TextStyle( - fontFamily: 'PressStart2P', - fontSize: 13, - color: Colors.orange.shade300, - ), - ), - ), - ListTile( - leading: const Icon(Icons.science, color: Colors.orange), - title: const Text('Create Test Character'), - subtitle: const Text('레벨 100 캐릭터를 명예의 전당에 등록'), - onTap: () { - Navigator.pop(context); - _showTestCharacterDialog(context); - }, - ), - ], - - const SizedBox(height: 8), ], ), ), @@ -679,6 +540,63 @@ class _MobileCarouselLayoutState extends State { ); } + /// 레트로 스타일 속도 선택기 + 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; @@ -735,6 +653,7 @@ class _MobileCarouselLayoutState extends State { speedBoostEndMs: widget.speedBoostEndMs, isPaidUser: widget.isPaidUser, onSpeedBoostActivate: widget.onSpeedBoostActivate, + adSpeedMultiplier: widget.adSpeedMultiplier, ), // 중앙: 캐로셀 (PageView) @@ -795,99 +714,156 @@ class _MobileCarouselLayoutState extends State { ); } - /// 속도 선택기 빌드 (옵션 메뉴용) - /// - /// - 1x, 2x: 무료 사이클 - /// - ▶5x: 광고 시청 후 버프 (또는 버프 활성 시) - /// - 20x: 디버그 모드 전용 - Widget _buildSpeedSelector(BuildContext context) { - final currentElapsedMs = widget.state.skillSystem.elapsedMs; - final speedBoostEndMs = widget.speedBoostEndMs ?? 0; - final isSpeedBoostActive = - speedBoostEndMs > currentElapsedMs || widget.isPaidUser; +} +// ═══════════════════════════════════════════════════════════════════════════ +// 레트로 스타일 옵션 메뉴 위젯들 +// ═══════════════════════════════════════════════════════════════════════════ + +/// 메뉴 섹션 타이틀 +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( - mainAxisSize: MainAxisSize.min, children: [ - // 1x 버튼 - _buildSpeedChip( - context, - speed: 1, - isSelected: widget.speedMultiplier == 1 && !isSpeedBoostActive, - onTap: () { - widget.onSpeedCycle(); - Navigator.pop(context); - }, - ), - const SizedBox(width: 4), - // 2x 버튼 - _buildSpeedChip( - context, - speed: 2, - isSelected: widget.speedMultiplier == 2 && !isSpeedBoostActive, - onTap: () { - widget.onSpeedCycle(); - Navigator.pop(context); - }, - ), - const SizedBox(width: 4), - // 5x 버튼 (광고 또는 버프 활성) - _buildSpeedChip( - context, - speed: 5, - isSelected: isSpeedBoostActive, - isAdBased: !isSpeedBoostActive && !widget.isPaidUser, - onTap: () { - if (!isSpeedBoostActive) { - widget.onSpeedBoostActivate?.call(); - } - Navigator.pop(context); - }, - ), - // 20x 버튼 (디버그 전용) - if (widget.cheatsEnabled) ...[ - const SizedBox(width: 4), - _buildSpeedChip( - context, - speed: 20, - isSelected: widget.speedMultiplier == 20, - isDebug: true, - onTap: () { - widget.onSpeedCycle(); - Navigator.pop(context); - }, + 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); - /// 속도 칩 빌드 - Widget _buildSpeedChip( - BuildContext context, { - required int speed, - required bool isSelected, - required VoidCallback onTap, - bool isAdBased = false, - bool isDebug = false, - }) { final Color bgColor; final Color textColor; + final Color borderColor; if (isSelected) { - bgColor = isDebug - ? Colors.red - : speed == 5 - ? Colors.orange - : Theme.of(context).colorScheme.primary; - textColor = Colors.white; + 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 = Theme.of(context).colorScheme.surfaceContainerHighest; - textColor = isAdBased - ? Colors.orange - : isDebug - ? Colors.red.withValues(alpha: 0.7) - : Theme.of(context).colorScheme.onSurface; + bgColor = Colors.transparent; + textColor = RetroColors.textMutedOf(context); + borderColor = border; } return GestureDetector( @@ -896,26 +872,24 @@ class _MobileCarouselLayoutState extends State { padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: bgColor, - borderRadius: BorderRadius.circular(12), - border: isAdBased && !isSelected - ? Border.all(color: Colors.orange) - : null, + border: Border.all(color: borderColor, width: isSelected ? 2 : 1), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ if (isAdBased && !isSelected) - const Padding( - padding: EdgeInsets.only(right: 2), + Padding( + padding: const EdgeInsets.only(right: 2), child: Text( '▶', - style: TextStyle(fontSize: 8, color: Colors.orange), + style: TextStyle(fontSize: 7, color: warning), ), ), Text( '${speed}x', style: TextStyle( - fontSize: 12, + fontFamily: 'PressStart2P', + fontSize: 8, fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, color: textColor, ), @@ -926,3 +900,342 @@ class _MobileCarouselLayoutState extends State { ); } } + +/// 선택 다이얼로그 +class _RetroSelectDialog extends StatelessWidget { + const _RetroSelectDialog({required this.title, required this.children}); + + final String title; + final List 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 onBgmChanged; + final ValueChanged 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 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, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/features/game/widgets/enhanced_animation_panel.dart b/lib/src/features/game/widgets/enhanced_animation_panel.dart index edd551e..7099c5f 100644 --- a/lib/src/features/game/widgets/enhanced_animation_panel.dart +++ b/lib/src/features/game/widgets/enhanced_animation_panel.dart @@ -41,6 +41,7 @@ class EnhancedAnimationPanel extends StatefulWidget { this.speedBoostEndMs, this.isPaidUser = false, this.onSpeedBoostActivate, + this.adSpeedMultiplier = 5, }); final ProgressState progress; @@ -75,12 +76,15 @@ class EnhancedAnimationPanel extends StatefulWidget { /// 5배속 버프 종료 시점 (elapsedMs 기준, null이면 비활성) final int? speedBoostEndMs; - /// 유료 유저 여부 (5배속 항상 활성) + /// 유료 유저 여부 (광고배속 항상 활성) final bool isPaidUser; - /// 5배속 버프 활성화 콜백 (광고 시청) + /// 광고 배속 활성화 콜백 (광고 시청) final VoidCallback? onSpeedBoostActivate; + /// 광고 배속 배율 (릴리즈: 5x, 디버그빌드+디버그모드: 20x) + final int adSpeedMultiplier; + @override State createState() => _EnhancedAnimationPanelState(); } @@ -284,14 +288,14 @@ class _EnhancedAnimationPanelState extends State color: Colors.green, ), ), - // 우상단: 5배속 버프 + // 우상단: 광고배속 버프 (버프 활성 시에만) if (_speedBoostRemainingMs > 0 || widget.isPaidUser) Positioned( right: 4, top: 4, child: _buildBuffChip( icon: '⚡', - label: '5x', + label: '${widget.adSpeedMultiplier}x', remainingMs: widget.isPaidUser ? -1 : _speedBoostRemainingMs, color: Colors.orange, isPermanent: widget.isPaidUser, @@ -303,7 +307,7 @@ class _EnhancedAnimationPanelState extends State const SizedBox(height: 8), - // 상태 바 영역: HP/MP (40%) + 컨트롤 (20%) + 몬스터 HP (40%) + // 상태 바 영역: HP/MP (40%) + 빈공간 (20%) + 몬스터 HP (40%) SizedBox( height: 48, child: Row( @@ -322,11 +326,8 @@ class _EnhancedAnimationPanelState extends State ), ), - // 중앙: 컨트롤 버튼 (20%) - Expanded( - flex: 1, - child: _buildControlButtons(), - ), + // 중앙: 빈 공간 (20%) + const Spacer(flex: 1), // 우측: 몬스터 HP (전투 중) 또는 빈 공간 (40%) Expanded( @@ -673,92 +674,85 @@ class _EnhancedAnimationPanelState extends State ); } - /// 컨트롤 버튼 (중앙 영역) - Widget _buildControlButtons() { - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // 상단: 속도 버튼 (1x ↔ 2x) - _buildCompactSpeedButton(), - const SizedBox(height: 2), - // 하단: 5x 광고 버튼 (2x일 때만 표시) - _buildAdSpeedButton(), - ], - ); - } - - /// 컴팩트 속도 버튼 (1x ↔ 2x 사이클) - Widget _buildCompactSpeedButton() { + /// 속도 컨트롤 버튼 (태스크 프로그레스 바 우측) + /// + /// - 일반배속: 1x (기본) ↔ 2x (명예의 전당 해금) + /// - 광고배속: 릴리즈 5x, 디버그빌드+디버그모드 20x + Widget _buildSpeedControls() { final isSpeedBoostActive = _speedBoostRemainingMs > 0 || widget.isPaidUser; - - return SizedBox( - width: 32, - height: 22, - child: OutlinedButton( - onPressed: widget.onSpeedCycle, - style: OutlinedButton.styleFrom( - padding: EdgeInsets.zero, - visualDensity: VisualDensity.compact, - side: BorderSide( - color: isSpeedBoostActive - ? Colors.orange - : widget.speedMultiplier > 1 - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), - ), - ), - child: Text( - isSpeedBoostActive ? '5x' : '${widget.speedMultiplier}x', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, - color: isSpeedBoostActive - ? Colors.orange - : widget.speedMultiplier > 1 - ? Theme.of(context).colorScheme.primary - : null, - ), - ), - ), - ); - } - - /// 5x 광고 버튼 (2x일 때만 표시) - Widget _buildAdSpeedButton() { - final isSpeedBoostActive = _speedBoostRemainingMs > 0 || widget.isPaidUser; - // 2x이고 5배속 버프 비활성이고 무료유저일 때만 표시 + final adSpeed = widget.adSpeedMultiplier; + // 2x일 때 광고 버튼 표시 (버프 비활성이고 무료유저) final showAdButton = widget.speedMultiplier == 2 && !isSpeedBoostActive && !widget.isPaidUser; - if (!showAdButton) { - return const SizedBox(height: 22); - } - - return SizedBox( - height: 22, - child: OutlinedButton( - onPressed: widget.onSpeedBoostActivate, - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 8), - visualDensity: VisualDensity.compact, - side: const BorderSide(color: Colors.orange), - ), - child: const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text('▶', style: TextStyle(fontSize: 8, color: Colors.orange)), - SizedBox(width: 2), - Text( - '5x', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, - color: Colors.orange, + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + // 속도 사이클 버튼 (1x ↔ 2x, 버프 활성시 광고배속) + SizedBox( + width: 44, + height: 32, + child: OutlinedButton( + onPressed: widget.onSpeedCycle, + style: OutlinedButton.styleFrom( + padding: EdgeInsets.zero, + side: BorderSide( + color: isSpeedBoostActive + ? Colors.orange + : widget.speedMultiplier > 1 + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.outline, + width: isSpeedBoostActive ? 2 : 1, ), ), - ], + child: Text( + isSpeedBoostActive ? '${adSpeed}x' : '${widget.speedMultiplier}x', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: isSpeedBoostActive + ? Colors.orange + : widget.speedMultiplier > 1 + ? Theme.of(context).colorScheme.primary + : null, + ), + ), + ), ), - ), + // 광고 배속 버튼 (2x일 때만 표시) + if (showAdButton) ...[ + const SizedBox(width: 4), + SizedBox( + width: 52, + height: 32, + child: OutlinedButton( + onPressed: widget.onSpeedBoostActivate, + style: OutlinedButton.styleFrom( + padding: EdgeInsets.zero, + side: const BorderSide(color: Colors.orange, width: 1.5), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + '▶', + style: TextStyle(fontSize: 9, color: Colors.orange), + ), + const SizedBox(width: 2), + Text( + '${adSpeed}x', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: Colors.orange, + ), + ), + ], + ), + ), + ), + ], + ], ); } @@ -775,7 +769,7 @@ class _EnhancedAnimationPanelState extends State return widget.progress.currentTask.caption; } - /// 태스크 프로그레스 바 + /// 태스크 프로그레스 바 + 속도 컨트롤 Widget _buildTaskProgress() { final task = widget.progress.task; final progressValue = task.max > 0 @@ -792,46 +786,56 @@ class _EnhancedAnimationPanelState extends State ? grade.displayColor : null; - return Column( + return Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - // 캡션 (등급에 따른 접두사 및 색상) - Text.rich( - TextSpan( + // 좌측: 캡션 + 프로그레스 바 + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - if (gradePrefix.isNotEmpty) + // 캡션 (등급에 따른 접두사 및 색상) + Text.rich( TextSpan( - text: gradePrefix, - style: TextStyle( - color: gradeColor, - fontWeight: FontWeight.bold, - ), + children: [ + if (gradePrefix.isNotEmpty) + TextSpan( + text: gradePrefix, + style: TextStyle( + color: gradeColor, + fontWeight: FontWeight.bold, + ), + ), + TextSpan( + text: _getStatusMessage(), + style: + gradeColor != null ? TextStyle(color: gradeColor) : null, + ), + ], ), - TextSpan( - text: _getStatusMessage(), - style: gradeColor != null ? TextStyle(color: gradeColor) : null, + style: Theme.of(context).textTheme.bodySmall, + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + // 프로그레스 바 + LinearProgressIndicator( + value: progressValue, + backgroundColor: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.2), + valueColor: AlwaysStoppedAnimation( + Theme.of(context).colorScheme.primary, + ), + minHeight: 10, ), ], ), - style: Theme.of(context).textTheme.bodySmall, - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 4), - // 프로그레스 바 - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: LinearProgressIndicator( - value: progressValue, - backgroundColor: Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.2), - valueColor: AlwaysStoppedAnimation( - Theme.of(context).colorScheme.primary, - ), - minHeight: 10, - ), ), + const SizedBox(width: 8), + // 우측: 속도 컨트롤 + _buildSpeedControls(), ], ); } diff --git a/lib/src/features/game/widgets/help_dialog.dart b/lib/src/features/game/widgets/help_dialog.dart index bf06acc..5c28155 100644 --- a/lib/src/features/game/widgets/help_dialog.dart +++ b/lib/src/features/game/widgets/help_dialog.dart @@ -108,13 +108,13 @@ class _BasicsHelpView extends StatelessWidget { ? 'ゲーム紹介' : 'About the Game', content: isKorean - ? 'Askii Never Die는 자동 진행 RPG입니다. 캐릭터가 자동으로 몬스터와 싸우고, ' - '퀘스트를 완료하며, 레벨업합니다. 여러분은 장비와 스킬을 관리하면 됩니다.' + ? 'Askii Never Die는 완전 자동 진행 RPG입니다. 캐릭터가 자동으로 몬스터와 싸우고, ' + '퀘스트를 완료하며, 레벨업합니다. 장비와 스킬도 자동으로 획득/장착됩니다.' : isJapanese - ? 'Askii Never Dieは自動進行RPGです。キャラクターが自動でモンスターと戦い、' - 'クエストを完了し、レベルアップします。装備とスキルの管理だけで大丈夫です。' - : 'Askii Never Die is an idle RPG. Your character automatically fights monsters, ' - 'completes quests, and levels up. You manage equipment and skills.', + ? 'Askii Never Dieは完全自動進行RPGです。キャラクターが自動でモンスターと戦い、' + 'クエストを完了し、レベルアップします。装備とスキルも自動で獲得・装着されます。' + : 'Askii Never Die is a fully automatic idle RPG. Your character automatically fights monsters, ' + 'completes quests, and levels up. Equipment and skills are auto-acquired and equipped.', ), const SizedBox(height: 12), _HelpSection( @@ -214,20 +214,26 @@ class _CombatHelpView extends StatelessWidget { ), const SizedBox(height: 12), _HelpSection( - icon: '♥', + icon: '♻', title: isKorean - ? '사망과 부활' + ? '부활 시스템' : isJapanese - ? '死亡と復活' - : 'Death & Revival', + ? '復活システム' + : 'Revival System', content: isKorean - ? 'HP가 0이 되면 사망합니다. 사망 시 장비 하나를 제물로 바쳐 부활할 수 있습니다. ' - '부활 후 HP/MP가 완전 회복되고 빈 장비 슬롯에 기본 장비가 지급됩니다.' + ? '사망 시 두 가지 부활 방법이 있습니다:\n' + '• 기본 부활: 장비 1개 제물, HP/MP 회복\n' + '• 광고 부활: 아이템 보존, HP 100%, 10분 자동부활\n' + '유료 유저는 항상 광고 없이 부활 가능합니다.' : isJapanese - ? 'HPが0になると死亡します。死亡時に装備1つを捧げて復活できます。' - '復活後HP/MPが完全回復し、空の装備スロットに基本装備が支給されます。' - : 'You die when HP reaches 0. Sacrifice one equipment piece to revive. ' - 'After revival, HP/MP fully restore and empty slots get basic equipment.', + ? '死亡時に2つの復活方法があります:\n' + '• 基本復活: 装備1つ消費、HP/MP回復\n' + '• 広告復活: アイテム保存、HP100%、10分自動復活\n' + '課金ユーザーは常に広告なしで復活可能です。' + : 'Two revival methods on death:\n' + '• Basic: Sacrifice 1 equipment, restore HP/MP\n' + '• Ad Revival: Keep items, 100% HP, 10-min auto-revive\n' + 'Paid users can always revive without ads.', ), ], ); @@ -306,21 +312,21 @@ class _SkillsHelpView extends StatelessWidget { ? 'スキルランク' : 'Skill Ranks', content: isKorean - ? '스킬은 I ~ IX 랭크가 있습니다. 랭크가 높을수록:\n' + ? '스킬 랭크는 I, II, III... 형태로 표시됩니다. 랭크가 높을수록:\n' '• 데미지/회복량 증가\n' - '• MP 소모량 증가\n' - '• 쿨타임 증가\n' + '• MP 소모량 감소\n' + '• 쿨타임 감소\n' '레벨업 시 랜덤하게 스킬을 배웁니다.' : isJapanese - ? 'スキルにはI~IXランクがあります。ランクが高いほど:\n' + ? 'スキルランクはI、II、III...の形式で表示されます。ランクが高いほど:\n' '• ダメージ/回復量増加\n' - '• MP消費量増加\n' - '• クールタイム増加\n' + '• MP消費量減少\n' + '• クールタイム減少\n' 'レベルアップ時にランダムでスキルを習得します。' - : 'Skills have ranks I~IX. Higher rank means:\n' + : 'Skill ranks are displayed as I, II, III... Higher rank means:\n' '• More damage/healing\n' - '• More MP cost\n' - '• Longer cooldown\n' + '• Less MP cost\n' + '• Shorter cooldown\n' 'Learn random skills on level up.', ), ], @@ -348,19 +354,31 @@ class _UIHelpView extends StatelessWidget { ? '画面構成' : 'Screen Layout', content: isKorean - ? '• 상단: 전투 애니메이션, 태스크 진행바\n' - '• 좌측: 캐릭터 정보, HP/MP, 스탯\n' - '• 중앙: 장비, 인벤토리\n' - '• 우측: 플롯/퀘스트 진행, 스펠북' + ? '모바일에서는 좌우 스와이프로 7개 페이지 탐색:\n' + '• 캐릭터: 이름, 레벨, 종족, 직업\n' + '• 스탯: STR, DEX, CON, INT 등\n' + '• 장비: 무기, 방어구, 액세서리\n' + '• 인벤토리: 보유 아이템, 골드\n' + '• 스킬북: 습득한 스킬 목록\n' + '• 퀘스트: 진행 중인 퀘스트\n' + '• 플롯: 스토리 진행 상황' : isJapanese - ? '• 上部: 戦闘アニメーション、タスク進行バー\n' - '• 左側: キャラクター情報、HP/MP、ステータス\n' - '• 中央: 装備、インベントリ\n' - '• 右側: プロット/クエスト進行、スペルブック' - : '• Top: Combat animation, task progress bar\n' - '• Left: Character info, HP/MP, stats\n' - '• Center: Equipment, inventory\n' - '• Right: Plot/quest progress, spellbook', + ? 'モバイルでは左右スワイプで7ページ切替:\n' + '• キャラクター: 名前、レベル、種族、職業\n' + '• ステータス: STR、DEX、CON、INT等\n' + '• 装備: 武器、防具、アクセサリー\n' + '• インベントリ: 所持アイテム、ゴールド\n' + '• スキルブック: 習得したスキル一覧\n' + '• クエスト: 進行中のクエスト\n' + '• プロット: ストーリー進行状況' + : 'On mobile, swipe left/right to browse 7 pages:\n' + '• Character: Name, level, race, class\n' + '• Stats: STR, DEX, CON, INT, etc.\n' + '• Equipment: Weapons, armor, accessories\n' + '• Inventory: Items, gold\n' + '• Skillbook: Learned skills\n' + '• Quests: Active quests\n' + '• Plot: Story progress', ), const SizedBox(height: 12), _HelpSection( @@ -371,22 +389,42 @@ class _UIHelpView extends StatelessWidget { ? '速度調整' : 'Speed Control', content: isKorean - ? '태스크 진행바 옆 속도 버튼으로 게임 속도를 조절할 수 있습니다:\n' + ? '게임 속도를 조절할 수 있습니다:\n' '• 1x: 기본 속도\n' - '• 2x: 2배 속도\n' - '• 5x: 5배 속도\n' - '• 10x: 10배 속도' + '• 2x: 명예의 전당 캐릭터 1명 이상 시 해금\n' + '• 5x: 광고 시청으로 5분간 부스트 (유료 유저 무료)' : isJapanese - ? 'タスク進行バー横の速度ボタンでゲーム速度を調整できます:\n' + ? 'ゲーム速度を調整できます:\n' '• 1x: 基本速度\n' - '• 2x: 2倍速\n' - '• 5x: 5倍速\n' - '• 10x: 10倍速' - : 'Use the speed button next to task bar to adjust game speed:\n' + '• 2x: 殿堂入り1人以上で解放\n' + '• 5x: 広告視聴で5分間ブースト(課金ユーザー無料)' + : 'Adjust game speed:\n' '• 1x: Normal speed\n' - '• 2x: 2x speed\n' - '• 5x: 5x speed\n' - '• 10x: 10x speed', + '• 2x: Unlocked with 1+ Hall of Fame character\n' + '• 5x: 5-min boost via ad (free for paid users)', + ), + const SizedBox(height: 12), + _HelpSection( + icon: '🏆', + title: isKorean + ? '명예의 전당' + : isJapanese + ? '殿堂入り' + : 'Hall of Fame', + content: isKorean + ? 'Act V를 클리어하면 캐릭터가 명예의 전당에 등록됩니다.\n' + '• 캐릭터 이름, 레벨, 스탯이 영구 기록됨\n' + '• 첫 등록 시 2x 속도 영구 해금\n' + '• 2명 이상 등록 시 로컬 아레나 기능 해금' + : isJapanese + ? 'Act Vクリアでキャラクターが殿堂入りします。\n' + '• キャラクター名、レベル、ステータスが永久記録\n' + '• 初登録で2倍速が永久解放\n' + '• 2人以上でローカルアリーナ機能解放' + : 'Characters enter Hall of Fame upon completing Act V.\n' + '• Name, level, stats are permanently recorded\n' + '• First entry permanently unlocks 2x speed\n' + '• 2+ entries unlock Local Arena feature', ), const SizedBox(height: 12), _HelpSection( diff --git a/lib/src/features/game/widgets/return_rewards_dialog.dart b/lib/src/features/game/widgets/return_rewards_dialog.dart index f5448e0..127f6de 100644 --- a/lib/src/features/game/widgets/return_rewards_dialog.dart +++ b/lib/src/features/game/widgets/return_rewards_dialog.dart @@ -1,37 +1,45 @@ import 'package:flutter/material.dart'; import 'package:asciineverdie/data/game_text_l10n.dart' as l10n; +import 'package:asciineverdie/data/potion_data.dart'; import 'package:asciineverdie/src/core/engine/iap_service.dart'; import 'package:asciineverdie/src/core/engine/return_rewards_service.dart'; +import 'package:asciineverdie/src/core/model/treasure_chest.dart'; import 'package:asciineverdie/src/shared/retro_colors.dart'; /// 복귀 보상 다이얼로그 (Phase 7) /// -/// 게임 복귀 시 보상을 표시하는 다이얼로그 +/// 게임 복귀 시 보물 상자 보상을 표시하는 다이얼로그 class ReturnRewardsDialog extends StatefulWidget { const ReturnRewardsDialog({ super.key, required this.reward, + required this.playerLevel, required this.onClaim, }); /// 복귀 보상 데이터 - final ReturnReward reward; + final ReturnChestReward reward; - /// 보상 수령 콜백 (totalGold) - final void Function(int totalGold) onClaim; + /// 플레이어 레벨 (상자 보상 스케일링용) + final int playerLevel; + + /// 보상 수령 콜백 (상자 보상 목록) + final void Function(List rewards) onClaim; /// 다이얼로그 표시 static Future show( BuildContext context, { - required ReturnReward reward, - required void Function(int totalGold) onClaim, + required ReturnChestReward reward, + required int playerLevel, + required void Function(List rewards) onClaim, }) async { return showDialog( context: context, barrierDismissible: false, builder: (context) => ReturnRewardsDialog( reward: reward, + playerLevel: playerLevel, onClaim: onClaim, ), ); @@ -41,27 +49,50 @@ class ReturnRewardsDialog extends StatefulWidget { State createState() => _ReturnRewardsDialogState(); } -class _ReturnRewardsDialogState extends State { - bool _basicClaimed = false; - bool _bonusClaimed = false; - bool _isClaimingBonus = false; - int _totalClaimed = 0; - +class _ReturnRewardsDialogState extends State + with SingleTickerProviderStateMixin { final _rewardsService = ReturnRewardsService.instance; + // 상태 + bool _basicOpened = false; + bool _bonusOpened = false; + bool _isOpeningBasic = false; + bool _isOpeningBonus = false; + List _basicRewards = []; + List _bonusRewards = []; + + // 애니메이션 + late AnimationController _animController; + late Animation _shakeAnimation; + + @override + void initState() { + super.initState(); + _animController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 500), + ); + _shakeAnimation = Tween(begin: 0, end: 1).animate( + CurvedAnimation(parent: _animController, curve: Curves.elasticOut), + ); + } + + @override + void dispose() { + _animController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final gold = RetroColors.goldOf(context); - final goldDark = RetroColors.goldDarkOf(context); final panelBg = RetroColors.panelBgOf(context); final borderColor = RetroColors.borderOf(context); - final expColor = RetroColors.expOf(context); - final isPaidUser = IAPService.instance.isAdRemovalPurchased; return Dialog( backgroundColor: Colors.transparent, child: Container( - constraints: const BoxConstraints(maxWidth: 360), + constraints: const BoxConstraints(maxWidth: 400), decoration: BoxDecoration( color: panelBg, border: Border( @@ -96,40 +127,41 @@ class _ReturnRewardsDialogState extends State { ), style: TextStyle( fontFamily: 'PressStart2P', - fontSize: 11, + fontSize: 10, color: gold.withValues(alpha: 0.8), ), textAlign: TextAlign.center, ), const SizedBox(height: 20), - // 기본 보상 - _buildRewardSection( + // 기본 상자 섹션 + _buildChestSection( context, - title: l10n.returnRewardBasic, - gold: widget.reward.goldReward, - color: gold, - colorDark: goldDark, - claimed: _basicClaimed, - onClaim: _claimBasic, - buttonText: l10n.returnRewardClaim, + title: l10n.returnRewardChests(widget.reward.chestCount), + chestCount: widget.reward.chestCount, + rewards: _basicRewards, + isOpened: _basicOpened, + isOpening: _isOpeningBasic, + onOpen: _openBasicChests, + isGold: true, ), + const SizedBox(height: 16), - // 보너스 보상 - _buildRewardSection( + // 보너스 상자 섹션 + _buildChestSection( context, - title: l10n.returnRewardBonus, - gold: widget.reward.bonusGold, - color: expColor, - colorDark: expColor.withValues(alpha: 0.6), - claimed: _bonusClaimed, - onClaim: _claimBonus, - buttonText: l10n.returnRewardClaimBonus, - showAdIcon: !isPaidUser, - isLoading: _isClaimingBonus, - enabled: _basicClaimed && !_bonusClaimed, + title: l10n.returnRewardBonusChests, + chestCount: widget.reward.bonusChestCount, + rewards: _bonusRewards, + isOpened: _bonusOpened, + isOpening: _isOpeningBonus, + onOpen: _openBonusChests, + isGold: false, + enabled: _basicOpened && !_bonusOpened, + showAdIcon: !IAPService.instance.isAdRemovalPurchased, ), + const SizedBox(height: 20), // 완료/건너뛰기 버튼 @@ -154,7 +186,7 @@ class _ReturnRewardsDialogState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text('🎁', style: TextStyle(fontSize: 20, color: gold)), + const Text('📦', style: TextStyle(fontSize: 20)), const SizedBox(width: 8), Text( l10n.returnRewardTitle, @@ -166,32 +198,40 @@ class _ReturnRewardsDialogState extends State { ), ), const SizedBox(width: 8), - Text('🎁', style: TextStyle(fontSize: 20, color: gold)), + const Text('📦', style: TextStyle(fontSize: 20)), ], ), ); } - Widget _buildRewardSection( + Widget _buildChestSection( BuildContext context, { required String title, - required int gold, - required Color color, - required Color colorDark, - required bool claimed, - required VoidCallback onClaim, - required String buttonText, - bool showAdIcon = false, - bool isLoading = false, + required int chestCount, + required List rewards, + required bool isOpened, + required bool isOpening, + required VoidCallback onOpen, + required bool isGold, bool enabled = true, + bool showAdIcon = false, }) { + final gold = RetroColors.goldOf(context); + final expColor = RetroColors.expOf(context); final muted = RetroColors.textMutedOf(context); + final color = isGold ? gold : expColor; + final isPaidUser = IAPService.instance.isAdRemovalPurchased; return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: color.withValues(alpha: 0.1), - border: Border.all(color: color.withValues(alpha: 0.5), width: 2), + border: Border.all( + color: (enabled || isOpened) + ? color.withValues(alpha: 0.5) + : muted.withValues(alpha: 0.3), + width: 2, + ), ), child: Column( children: [ @@ -201,104 +241,111 @@ class _ReturnRewardsDialogState extends State { style: TextStyle( fontFamily: 'PressStart2P', fontSize: 11, - color: color, + color: (enabled || isOpened) ? color : muted, ), ), - const SizedBox(height: 8), + const SizedBox(height: 12), - // 골드 표시 - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('💰', style: TextStyle(fontSize: 20)), - const SizedBox(width: 8), - Text( - l10n.returnRewardGold(gold), - style: TextStyle( - fontFamily: 'PressStart2P', - fontSize: 14, - color: claimed ? muted : color, - decoration: claimed ? TextDecoration.lineThrough : null, - ), - ), - if (claimed) ...[ - const SizedBox(width: 8), - Text( - '✓', - style: TextStyle( - fontSize: 18, - color: RetroColors.expOf(context), - fontWeight: FontWeight.bold, - ), - ), - ], - ], - ), + // 상자 아이콘들 또는 보상 목록 + if (isOpened) + _buildRewardsList(context, rewards) + else + _buildChestIcons(chestCount, color, enabled), - if (!claimed) ...[ + if (!isOpened) ...[ const SizedBox(height: 12), - // 수령 버튼 + // 오픈 버튼 GestureDetector( - onTap: enabled && !isLoading ? onClaim : null, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - decoration: BoxDecoration( - color: enabled - ? color.withValues(alpha: 0.3) - : muted.withValues(alpha: 0.2), - border: Border.all( - color: enabled ? color : muted, - width: 2, + onTap: enabled && !isOpening ? onOpen : null, + child: AnimatedBuilder( + animation: _shakeAnimation, + builder: (context, child) { + return Transform.translate( + offset: isOpening + ? Offset( + _shakeAnimation.value * 2 * + ((_animController.value * 10).round() % 2 == 0 + ? 1 + : -1), + 0, + ) + : Offset.zero, + child: child, + ); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (isLoading) ...[ - SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: color, - ), - ), - const SizedBox(width: 8), - ], - Text( - buttonText, - style: TextStyle( - fontFamily: 'PressStart2P', - fontSize: 11, - color: enabled ? color : muted, - ), + decoration: BoxDecoration( + color: enabled + ? color.withValues(alpha: 0.3) + : muted.withValues(alpha: 0.2), + border: Border.all( + color: enabled ? color : muted, + width: 2, ), - if (showAdIcon && !isLoading) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 2, - ), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - 'AD', - style: TextStyle( - fontFamily: 'PressStart2P', - fontSize: 8, - color: enabled ? Colors.white : muted, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isOpening) ...[ + SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: color, ), ), - ), + const SizedBox(width: 8), + Text( + l10n.returnRewardOpening, + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 10, + color: color, + ), + ), + ] else ...[ + Text( + isGold + ? l10n.returnRewardOpenChests + : (isPaidUser + ? l10n.returnRewardClaimBonusFree + : l10n.returnRewardClaimBonus), + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 10, + color: enabled ? color : muted, + ), + ), + if (showAdIcon && !isPaidUser) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'AD', + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 8, + color: enabled ? Colors.white : muted, + ), + ), + ), + ], + ], ], - ], + ), ), ), ), @@ -308,12 +355,118 @@ class _ReturnRewardsDialogState extends State { ); } + Widget _buildChestIcons(int count, Color color, bool enabled) { + final muted = RetroColors.textMutedOf(context); + return Wrap( + alignment: WrapAlignment.center, + spacing: 8, + runSpacing: 8, + children: List.generate( + count, + (index) => Text( + '📦', + style: TextStyle( + fontSize: 24, + color: enabled ? null : muted, + ), + ), + ), + ); + } + + Widget _buildRewardsList(BuildContext context, List rewards) { + if (rewards.isEmpty) { + return const Text( + '(empty)', + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 10, + color: Colors.grey, + ), + ); + } + + return Column( + children: rewards.map((reward) => _buildRewardItem(context, reward)).toList(), + ); + } + + Widget _buildRewardItem(BuildContext context, ChestReward reward) { + final gold = RetroColors.goldOf(context); + final expColor = RetroColors.expOf(context); + + String icon; + String text; + Color color; + + switch (reward.type) { + case ChestRewardType.equipment: + icon = '⚔️'; + text = reward.equipment?.name ?? 'Unknown'; + color = _getRarityColor(reward.equipment?.rarity); + break; + case ChestRewardType.potion: + final potion = PotionData.getById(reward.potionId ?? ''); + icon = potion?.type.name == 'hp' ? '❤️' : '💙'; + text = l10n.chestRewardPotionAmount( + potion?.name ?? 'Potion', + reward.potionCount ?? 1, + ); + color = Colors.white; + break; + case ChestRewardType.gold: + icon = '💰'; + text = l10n.chestRewardGoldAmount(reward.gold ?? 0); + color = gold; + break; + case ChestRewardType.experience: + icon = '⭐'; + text = l10n.chestRewardExpAmount(reward.experience ?? 0); + color = expColor; + break; + } + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(icon, style: const TextStyle(fontSize: 16)), + const SizedBox(width: 8), + Flexible( + child: Text( + text, + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 9, + color: color, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } + + Color _getRarityColor(dynamic rarity) { + if (rarity == null) return Colors.white; + return switch (rarity.toString()) { + 'ItemRarity.common' => Colors.grey, + 'ItemRarity.uncommon' => Colors.green, + 'ItemRarity.rare' => Colors.blue, + 'ItemRarity.epic' => Colors.purple, + 'ItemRarity.legendary' => Colors.orange, + _ => Colors.white, + }; + } + Widget _buildBottomButton(BuildContext context) { final gold = RetroColors.goldOf(context); final goldDark = RetroColors.goldDarkOf(context); final muted = RetroColors.textMutedOf(context); - final canComplete = _basicClaimed; + final canComplete = _basicOpened; final buttonColor = canComplete ? gold : muted; final buttonDark = canComplete ? goldDark : muted.withValues(alpha: 0.5); @@ -344,43 +497,70 @@ class _ReturnRewardsDialogState extends State { ); } - void _claimBasic() { - if (_basicClaimed) return; - - final claimed = _rewardsService.claimBasicReward(widget.reward); - setState(() { - _basicClaimed = true; - _totalClaimed += claimed; - }); - } - - Future _claimBonus() async { - if (_bonusClaimed || _isClaimingBonus) return; + Future _openBasicChests() async { + if (_basicOpened || _isOpeningBasic) return; setState(() { - _isClaimingBonus = true; + _isOpeningBasic = true; }); - final bonus = await _rewardsService.claimBonusReward(widget.reward); + // 애니메이션 시작 + _animController.repeat(); + + // 약간의 딜레이 후 상자 오픈 + await Future.delayed(const Duration(milliseconds: 800)); + + final rewards = _rewardsService.claimBasicReward( + widget.reward, + widget.playerLevel, + ); + + _animController.stop(); if (mounted) { setState(() { - _isClaimingBonus = false; - if (bonus > 0) { - _bonusClaimed = true; - _totalClaimed += bonus; + _isOpeningBasic = false; + _basicOpened = true; + _basicRewards = rewards; + }); + } + } + + Future _openBonusChests() async { + if (_bonusOpened || _isOpeningBonus) return; + + setState(() { + _isOpeningBonus = true; + }); + + _animController.repeat(); + + final rewards = await _rewardsService.claimBonusReward( + widget.reward, + widget.playerLevel, + ); + + _animController.stop(); + + if (mounted) { + setState(() { + _isOpeningBonus = false; + if (rewards.isNotEmpty) { + _bonusOpened = true; + _bonusRewards = rewards; } }); } } void _complete() { - widget.onClaim(_totalClaimed); + final allRewards = [..._basicRewards, ..._bonusRewards]; + widget.onClaim(allRewards); Navigator.of(context).pop(); } void _skip() { - widget.onClaim(0); + widget.onClaim([]); Navigator.of(context).pop(); } } diff --git a/lib/src/features/new_character/new_character_screen.dart b/lib/src/features/new_character/new_character_screen.dart index 3b891d4..ad0763f 100644 --- a/lib/src/features/new_character/new_character_screen.dart +++ b/lib/src/features/new_character/new_character_screen.dart @@ -196,14 +196,20 @@ class _NewCharacterScreenState extends State { final random = math.Random(); _currentSeed = random.nextInt(0x7FFFFFFF); - // 종족/클래스도 랜덤 선택 + // 종족/클래스 랜덤 선택 및 스탯 굴림 setState(() { _selectedRaceIndex = random.nextInt(_races.length); _selectedKlassIndex = random.nextInt(_klasses.length); + // 스탯 굴림 (setState 내에서 실행하여 UI 갱신 보장) + final rng = DeterministicRandom(_currentSeed); + _str = rollStat(rng); + _con = rollStat(rng); + _dex = rollStat(rng); + _int = rollStat(rng); + _wis = rollStat(rng); + _cha = rollStat(rng); }); - _rollStats(); - // 선택된 종족/직업으로 스크롤 _scrollToSelectedItems(); @@ -296,7 +302,10 @@ class _NewCharacterScreenState extends State { snapshot = await _rollService.undoFreeUser(); } - if (snapshot != null && mounted) { + // UI 상태 갱신 (성공/실패 여부와 관계없이 버튼 상태 업데이트) + if (!mounted) return; + + if (snapshot != null) { setState(() { _str = snapshot!.stats.str; _con = snapshot.stats.con; @@ -309,6 +318,9 @@ class _NewCharacterScreenState extends State { _currentSeed = snapshot.seed; }); _scrollToSelectedItems(); + } else { + // 광고 취소/실패 시에도 버튼 상태 갱신 + setState(() {}); } } @@ -495,14 +507,17 @@ class _NewCharacterScreenState extends State { : RetroColors.textDisabled, ), const SizedBox(width: 8), - Text( - 'DEBUG: TURBO MODE (20x)', - style: TextStyle( - fontFamily: 'PressStart2P', - fontSize: 13, - color: _cheatsEnabled - ? RetroColors.hpRed - : RetroColors.textDisabled, + Flexible( + child: Text( + 'DEBUG: TURBO (20x)', + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 11, + color: _cheatsEnabled + ? RetroColors.hpRed + : RetroColors.textDisabled, + ), ), ), ], diff --git a/lib/src/features/settings/settings_screen.dart b/lib/src/features/settings/settings_screen.dart index 992c0a4..6ae8d8b 100644 --- a/lib/src/features/settings/settings_screen.dart +++ b/lib/src/features/settings/settings_screen.dart @@ -4,16 +4,14 @@ import 'package:flutter/material.dart'; import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n; import 'package:asciineverdie/src/core/engine/debug_settings_service.dart'; import 'package:asciineverdie/src/core/storage/settings_repository.dart'; +import 'package:asciineverdie/src/shared/retro_colors.dart'; +import 'package:asciineverdie/src/shared/widgets/retro_widgets.dart'; -/// 통합 설정 화면 -/// -/// 언어, 테마, 사운드, 애니메이션 속도 등 모든 설정을 한 곳에서 관리 +/// 통합 설정 화면 (레트로 스타일) class SettingsScreen extends StatefulWidget { const SettingsScreen({ super.key, required this.settingsRepository, - required this.currentThemeMode, - required this.onThemeModeChange, this.onLocaleChange, this.onBgmVolumeChange, this.onSfxVolumeChange, @@ -21,19 +19,9 @@ class SettingsScreen extends StatefulWidget { }); final SettingsRepository settingsRepository; - final ThemeMode currentThemeMode; - final void Function(ThemeMode mode) onThemeModeChange; final void Function(String locale)? onLocaleChange; - - /// BGM 볼륨 변경 콜백 (AudioService 연동용) final void Function(double volume)? onBgmVolumeChange; - - /// SFX 볼륨 변경 콜백 (AudioService 연동용) final void Function(double volume)? onSfxVolumeChange; - - /// 테스트 캐릭터 생성 콜백 (디버그 모드 전용) - /// - /// 현재 캐릭터를 레벨 100으로 만들어 명예의 전당에 등록하고 세이브 삭제 final Future Function()? onCreateTestCharacter; @override @@ -43,8 +31,6 @@ class SettingsScreen extends StatefulWidget { static Future show( BuildContext context, { required SettingsRepository settingsRepository, - required ThemeMode currentThemeMode, - required void Function(ThemeMode mode) onThemeModeChange, void Function(String locale)? onLocaleChange, void Function(double volume)? onBgmVolumeChange, void Function(double volume)? onSfxVolumeChange, @@ -54,6 +40,7 @@ class SettingsScreen extends StatefulWidget { context: context, isScrollControlled: true, useSafeArea: true, + backgroundColor: Colors.transparent, builder: (context) => DraggableScrollableSheet( initialChildSize: 0.7, minChildSize: 0.5, @@ -61,8 +48,6 @@ class SettingsScreen extends StatefulWidget { expand: false, builder: (context, scrollController) => SettingsScreen( settingsRepository: settingsRepository, - currentThemeMode: currentThemeMode, - onThemeModeChange: onThemeModeChange, onLocaleChange: onLocaleChange, onBgmVolumeChange: onBgmVolumeChange, onSfxVolumeChange: onSfxVolumeChange, @@ -113,96 +98,61 @@ class _SettingsScreenState extends State { @override Widget build(BuildContext context) { - final theme = Theme.of(context); - if (_isLoading) { - return const Center(child: CircularProgressIndicator()); + return Container( + decoration: BoxDecoration( + color: RetroColors.backgroundOf(context), + borderRadius: const BorderRadius.vertical(top: Radius.circular(4)), + ), + child: Center( + child: Text( + 'LOADING...', + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 12, + color: RetroColors.goldOf(context), + ), + ), + ), + ); } return Container( decoration: BoxDecoration( - color: theme.scaffoldBackgroundColor, - borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + color: RetroColors.backgroundOf(context), + borderRadius: const BorderRadius.vertical(top: Radius.circular(4)), + border: Border.all(color: RetroColors.borderOf(context), width: 2), ), child: Column( children: [ - // 핸들 바 - Container( - margin: const EdgeInsets.only(top: 8), - width: 40, - height: 4, - decoration: BoxDecoration( - color: theme.colorScheme.onSurface.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(2), - ), - ), + // 핸들 바 (레트로 스타일) + _buildHandle(context), // 헤더 - Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Icon(Icons.settings, color: theme.colorScheme.primary), - const SizedBox(width: 8), - Text(game_l10n.uiSettings, style: theme.textTheme.titleLarge), - const Spacer(), - IconButton( - icon: const Icon(Icons.close), - onPressed: () => Navigator.of(context).pop(), - ), - ], - ), - ), - const Divider(height: 1), + _buildHeader(context), // 설정 목록 Expanded( child: ListView( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(12), children: [ - // 테마 설정 - _buildSectionTitle(game_l10n.uiTheme), - _buildThemeSelector(), - const SizedBox(height: 24), - // 언어 설정 - _buildSectionTitle(game_l10n.uiLanguage), - _buildLanguageSelector(), - const SizedBox(height: 24), + _RetroSectionTitle(title: game_l10n.uiLanguage), + const SizedBox(height: 8), + _buildLanguageSelector(context), + const SizedBox(height: 16), // 사운드 설정 - _buildSectionTitle(game_l10n.uiSound), - _buildVolumeSlider( - label: game_l10n.uiBgmVolume, - value: _bgmVolume, - icon: Icons.music_note, - onChanged: (value) { - setState(() => _bgmVolume = value); - widget.settingsRepository.saveBgmVolume(value); - widget.onBgmVolumeChange?.call(value); - }, - ), + _RetroSectionTitle(title: game_l10n.uiSound), const SizedBox(height: 8), - _buildVolumeSlider( - label: game_l10n.uiSfxVolume, - value: _sfxVolume, - icon: Icons.volume_up, - onChanged: (value) { - setState(() => _sfxVolume = value); - widget.settingsRepository.saveSfxVolume(value); - widget.onSfxVolumeChange?.call(value); - }, - ), - const SizedBox(height: 24), - - // 정보 - _buildSectionTitle(game_l10n.uiAbout), - _buildAboutCard(), + _buildSoundSettings(context), // 디버그 섹션 (디버그 모드에서만 표시) if (kDebugMode) ...[ - const SizedBox(height: 24), - _buildSectionTitle('Debug'), - _buildDebugSection(), + const SizedBox(height: 16), + const _RetroSectionTitle(title: 'DEBUG'), + const SizedBox(height: 8), + _buildDebugSection(context), ], + const SizedBox(height: 24), ], ), ), @@ -211,127 +161,227 @@ class _SettingsScreenState extends State { ); } - Widget _buildDebugSection() { - final theme = Theme.of(context); - final errorColor = theme.colorScheme.error; - - return Card( - color: theme.colorScheme.errorContainer.withValues(alpha: 0.3), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 헤더 - Row( - children: [ - Icon(Icons.bug_report, color: errorColor), - const SizedBox(width: 8), - Text( - 'Developer Tools', - style: theme.textTheme.titleSmall?.copyWith(color: errorColor), - ), - ], - ), - const SizedBox(height: 16), - - // 광고 ON/OFF 토글 - _buildDebugToggle( - icon: Icons.ad_units, - label: 'Ads Enabled', - description: 'OFF: 광고 버튼 클릭 시 바로 보상', - value: _debugAdEnabled, - onChanged: (value) async { - await DebugSettingsService.instance.setAdEnabled(value); - setState(() => _debugAdEnabled = value); - }, - ), - const SizedBox(height: 12), - - // IAP 시뮬레이션 토글 - _buildDebugToggle( - icon: Icons.shopping_cart, - label: 'IAP Purchased', - description: 'ON: 유료 유저로 동작 (광고 제거)', - value: _debugIapSimulated, - onChanged: (value) async { - await DebugSettingsService.instance.setIapSimulated(value); - setState(() => _debugIapSimulated = value); - }, - ), - const SizedBox(height: 12), - - // 오프라인 시간 시뮬레이션 - _buildOfflineHoursSelector(), - const SizedBox(height: 16), - - // 구분선 - Divider(color: errorColor.withValues(alpha: 0.3)), - const SizedBox(height: 12), - - // 테스트 캐릭터 생성 - if (widget.onCreateTestCharacter != null) ...[ - Text( - '현재 캐릭터를 레벨 100으로 수정하여 명예의 전당에 등록합니다. ' - '등록 후 현재 세이브 파일이 삭제됩니다.', - style: theme.textTheme.bodySmall, - ), - const SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: _handleCreateTestCharacter, - icon: const Icon(Icons.science), - label: const Text('Create Test Character'), - style: ElevatedButton.styleFrom( - backgroundColor: errorColor, - foregroundColor: theme.colorScheme.onError, - ), - ), - ), - ], - ], + Widget _buildHandle(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 8, bottom: 4), + child: Container( + width: 60, + height: 4, + decoration: BoxDecoration( + color: RetroColors.borderOf(context), + border: Border.all( + color: RetroColors.borderLightOf(context), + width: 1, + ), ), ), ); } - /// 디버그 토글 위젯 - Widget _buildDebugToggle({ - required IconData icon, - required String label, - required String description, - required bool value, - required void Function(bool) onChanged, - }) { - final theme = Theme.of(context); + Widget _buildHeader(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: RetroColors.panelBgOf(context), + border: Border( + bottom: BorderSide(color: RetroColors.borderOf(context), width: 2), + ), + ), + child: Row( + children: [ + Icon(Icons.settings, color: RetroColors.goldOf(context), size: 18), + const SizedBox(width: 8), + Text( + game_l10n.uiSettings.toUpperCase(), + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 14, + color: RetroColors.goldOf(context), + ), + ), + const Spacer(), + RetroIconButton( + icon: Icons.close, + onPressed: () => Navigator.of(context).pop(), + size: 28, + ), + ], + ), + ); + } - return Row( - children: [ - Icon(icon, size: 20, color: theme.colorScheme.onSurface), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + Widget _buildLanguageSelector(BuildContext context) { + final currentLocale = game_l10n.currentGameLocale; + final languages = [ + ('en', 'ENGLISH', '🇺🇸'), + ('ko', '한국어', '🇰🇷'), + ('ja', '日本語', '🇯🇵'), + ]; + + return RetroPanel( + padding: const EdgeInsets.all(8), + child: Column( + children: languages.map((lang) { + final isSelected = currentLocale == lang.$1; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: _RetroSelectableItem( + isSelected: isSelected, + onTap: () { + game_l10n.setGameLocale(lang.$1); + widget.settingsRepository.saveLocale(lang.$1); + widget.onLocaleChange?.call(lang.$1); + setState(() {}); + }, + child: Row( + children: [ + Text(lang.$3, style: const TextStyle(fontSize: 16)), + const SizedBox(width: 12), + Text( + lang.$2, + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 10, + color: isSelected + ? RetroColors.goldOf(context) + : RetroColors.textPrimaryOf(context), + ), + ), + const Spacer(), + if (isSelected) + Icon( + Icons.check, + size: 16, + color: RetroColors.goldOf(context), + ), + ], + ), + ), + ); + }).toList(), + ), + ); + } + + Widget _buildSoundSettings(BuildContext context) { + return RetroPanel( + padding: const EdgeInsets.all(12), + child: Column( + children: [ + _RetroVolumeSlider( + label: game_l10n.uiBgmVolume, + icon: Icons.music_note, + value: _bgmVolume, + onChanged: (value) { + setState(() => _bgmVolume = value); + widget.settingsRepository.saveBgmVolume(value); + widget.onBgmVolumeChange?.call(value); + }, + ), + const SizedBox(height: 12), + _RetroVolumeSlider( + label: game_l10n.uiSfxVolume, + icon: Icons.volume_up, + value: _sfxVolume, + onChanged: (value) { + setState(() => _sfxVolume = value); + widget.settingsRepository.saveSfxVolume(value); + widget.onSfxVolumeChange?.call(value); + }, + ), + ], + ), + ); + } + + Widget _buildDebugSection(BuildContext context) { + return RetroPanel( + padding: const EdgeInsets.all(12), + backgroundColor: RetroColors.hpOf(context).withValues(alpha: 0.1), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 헤더 + Row( children: [ - Text(label, style: theme.textTheme.bodyMedium), + Icon(Icons.bug_report, color: RetroColors.hpOf(context), size: 16), + const SizedBox(width: 8), Text( - description, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.outline, + 'DEVELOPER TOOLS', + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 10, + color: RetroColors.hpOf(context), ), ), ], ), - ), - Switch(value: value, onChanged: onChanged), - ], + const SizedBox(height: 16), + + // 광고 ON/OFF 토글 + _RetroDebugToggle( + icon: Icons.ad_units, + label: 'ADS ENABLED', + description: 'OFF: 광고 버튼 클릭 시 바로 보상', + value: _debugAdEnabled, + onChanged: (value) async { + await DebugSettingsService.instance.setAdEnabled(value); + setState(() => _debugAdEnabled = value); + }, + ), + const SizedBox(height: 12), + + // IAP 시뮬레이션 토글 + _RetroDebugToggle( + icon: Icons.shopping_cart, + label: 'IAP PURCHASED', + description: 'ON: 유료 유저로 동작 (광고 제거)', + value: _debugIapSimulated, + onChanged: (value) async { + await DebugSettingsService.instance.setIapSimulated(value); + setState(() => _debugIapSimulated = value); + }, + ), + const SizedBox(height: 12), + + // 오프라인 시간 시뮬레이션 + _buildOfflineHoursSelector(context), + const SizedBox(height: 16), + + // 구분선 + Container( + height: 1, + color: RetroColors.hpOf(context).withValues(alpha: 0.3), + ), + const SizedBox(height: 12), + + // 테스트 캐릭터 생성 + if (widget.onCreateTestCharacter != null) ...[ + Text( + '현재 캐릭터를 레벨 100으로 수정하여\n명예의 전당에 등록합니다.', + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 7, + color: RetroColors.textMutedOf(context), + height: 1.6, + ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: RetroTextButton( + text: 'CREATE TEST CHARACTER', + icon: Icons.science, + onPressed: _handleCreateTestCharacter, + ), + ), + ], + ], + ), ); } - /// 오프라인 시간 시뮬레이션 선택기 - Widget _buildOfflineHoursSelector() { - final theme = Theme.of(context); + Widget _buildOfflineHoursSelector(BuildContext context) { final options = DebugSettingsService.offlineHoursOptions; return Column( @@ -339,17 +389,31 @@ class _SettingsScreenState extends State { children: [ Row( children: [ - Icon(Icons.timer, size: 20, color: theme.colorScheme.onSurface), - const SizedBox(width: 12), + Icon( + Icons.timer, + size: 14, + color: RetroColors.textPrimaryOf(context), + ), + const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Offline Hours Simulation', style: theme.textTheme.bodyMedium), Text( - '복귀 보상 테스트용 (게임 재시작 시 적용)', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.outline, + 'OFFLINE HOURS', + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 8, + color: RetroColors.textPrimaryOf(context), + ), + ), + const SizedBox(height: 2), + Text( + '복귀 보상 테스트 (재시작 시 적용)', + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 6, + color: RetroColors.textMutedOf(context), ), ), ], @@ -359,19 +423,18 @@ class _SettingsScreenState extends State { ), const SizedBox(height: 8), Wrap( - spacing: 8, + spacing: 6, + runSpacing: 6, children: options.map((hours) { final isSelected = _debugOfflineHours == hours; - final label = hours == 0 ? 'OFF' : '${hours}h'; + final label = hours == 0 ? 'OFF' : '${hours}H'; - return ChoiceChip( - label: Text(label), - selected: isSelected, - onSelected: (selected) async { - if (selected) { - await DebugSettingsService.instance.setOfflineHours(hours); - setState(() => _debugOfflineHours = hours); - } + return _RetroChip( + label: label, + isSelected: isSelected, + onTap: () async { + await DebugSettingsService.instance.setOfflineHours(hours); + setState(() => _debugOfflineHours = hours); }, ); }).toList(), @@ -381,187 +444,390 @@ class _SettingsScreenState extends State { } Future _handleCreateTestCharacter() async { - // 확인 다이얼로그 표시 final confirmed = await showDialog( context: context, - builder: (context) => AlertDialog( - title: const Text('Create Test Character?'), - content: const Text( - '현재 캐릭터가 레벨 100으로 변환되어 명예의 전당에 등록됩니다.\n\n' - '⚠️ 현재 세이브 파일이 삭제됩니다.\n' - '이 작업은 되돌릴 수 없습니다.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - ElevatedButton( - onPressed: () => Navigator.of(context).pop(true), - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.error, - foregroundColor: Theme.of(context).colorScheme.onError, - ), - child: const Text('Create'), - ), - ], + builder: (context) => _RetroConfirmDialog( + title: 'CREATE TEST CHARACTER?', + message: '현재 캐릭터가 레벨 100으로 변환되어\n' + '명예의 전당에 등록됩니다.\n\n' + '⚠️ 현재 세이브 파일이 삭제됩니다.\n' + '이 작업은 되돌릴 수 없습니다.', + confirmText: 'CREATE', + cancelText: 'CANCEL', ), ); if (confirmed == true && mounted) { await widget.onCreateTestCharacter?.call(); if (mounted) { - Navigator.of(context).pop(); // 설정 화면 닫기 + Navigator.of(context).pop(); } } } +} - Widget _buildSectionTitle(String title) { - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Text( - title, - style: Theme.of( - context, - ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), +// ═══════════════════════════════════════════════════════════════════════════ +// 레트로 스타일 서브 위젯들 +// ═══════════════════════════════════════════════════════════════════════════ + +/// 섹션 타이틀 +class _RetroSectionTitle extends StatelessWidget { + const _RetroSectionTitle({required this.title}); + + final String title; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Container( + width: 4, + height: 14, + color: RetroColors.goldOf(context), + ), + const SizedBox(width: 8), + Text( + title.toUpperCase(), + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 10, + color: RetroColors.goldOf(context), + letterSpacing: 1, + ), + ), + ], + ); + } +} + +/// 선택 가능한 아이템 +class _RetroSelectableItem extends StatelessWidget { + const _RetroSelectableItem({ + required this.child, + required this.isSelected, + required this.onTap, + }); + + final Widget child; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: isSelected + ? RetroColors.goldOf(context).withValues(alpha: 0.15) + : Colors.transparent, + border: Border.all( + color: isSelected + ? RetroColors.goldOf(context) + : RetroColors.borderOf(context), + width: isSelected ? 2 : 1, + ), + ), + child: child, ), ); } +} - Widget _buildThemeSelector() { - return Card( - child: Padding( - padding: const EdgeInsets.all(8), - child: Row( +/// 볼륨 슬라이더 +class _RetroVolumeSlider extends StatelessWidget { + const _RetroVolumeSlider({ + required this.label, + required this.icon, + required this.value, + required this.onChanged, + }); + + final String label; + final IconData icon; + final double value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final percentage = (value * 100).round(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( children: [ - _buildThemeOption( - icon: Icons.light_mode, - label: game_l10n.uiThemeLight, - mode: ThemeMode.light, + Icon( + value == 0 ? Icons.volume_off : icon, + size: 14, + color: RetroColors.goldOf(context), ), - _buildThemeOption( - icon: Icons.dark_mode, - label: game_l10n.uiThemeDark, - mode: ThemeMode.dark, + const SizedBox(width: 8), + Text( + label.toUpperCase(), + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 8, + color: RetroColors.textPrimaryOf(context), + ), ), - _buildThemeOption( - icon: Icons.brightness_auto, - label: game_l10n.uiThemeSystem, - mode: ThemeMode.system, + const Spacer(), + Text( + '$percentage%', + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 8, + color: RetroColors.goldOf(context), + ), ), ], ), - ), + const SizedBox(height: 8), + // 레트로 스타일 슬라이더 + _RetroSlider(value: value, onChanged: onChanged), + ], ); } +} - Widget _buildThemeOption({ - required IconData icon, - required String label, - required ThemeMode mode, - }) { - final isSelected = widget.currentThemeMode == mode; - final theme = Theme.of(context); +/// 레트로 스타일 슬라이더 +class _RetroSlider extends StatelessWidget { + const _RetroSlider({required this.value, required this.onChanged}); - return Expanded( - child: InkWell( - onTap: () => widget.onThemeModeChange(mode), - borderRadius: BorderRadius.circular(8), - child: Container( - padding: const EdgeInsets.symmetric(vertical: 12), - decoration: BoxDecoration( - color: isSelected - ? theme.colorScheme.primaryContainer - : Colors.transparent, - borderRadius: BorderRadius.circular(8), - ), + final double value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return SliderTheme( + data: SliderThemeData( + trackHeight: 8, + activeTrackColor: RetroColors.goldOf(context), + inactiveTrackColor: RetroColors.borderOf(context), + thumbColor: RetroColors.goldLightOf(context), + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8), + overlayColor: RetroColors.goldOf(context).withValues(alpha: 0.2), + trackShape: const RectangularSliderTrackShape(), + ), + child: Slider(value: value, onChanged: onChanged, divisions: 10), + ); + } +} + +/// 디버그 토글 +class _RetroDebugToggle extends StatelessWidget { + const _RetroDebugToggle({ + required this.icon, + required this.label, + required this.description, + required this.value, + required this.onChanged, + }); + + final IconData icon; + final String label; + final String description; + final bool value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Icon(icon, size: 14, color: RetroColors.textPrimaryOf(context)), + const SizedBox(width: 8), + Expanded( child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon( - icon, - color: isSelected - ? theme.colorScheme.onPrimaryContainer - : theme.colorScheme.onSurface, - ), - const SizedBox(height: 4), Text( label, style: TextStyle( - fontSize: 17, - fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, - color: isSelected - ? theme.colorScheme.onPrimaryContainer - : theme.colorScheme.onSurface, + fontFamily: 'PressStart2P', + fontSize: 8, + color: RetroColors.textPrimaryOf(context), + ), + ), + Text( + description, + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 6, + color: RetroColors.textMutedOf(context), ), ), ], ), ), + // 레트로 스타일 토글 + _RetroToggle(value: value, onChanged: onChanged), + ], + ); + } +} + +/// 레트로 스타일 토글 +class _RetroToggle extends StatelessWidget { + const _RetroToggle({required this.value, required this.onChanged}); + + final bool value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => onChanged(!value), + child: Container( + width: 44, + height: 24, + decoration: BoxDecoration( + color: value + ? RetroColors.goldOf(context).withValues(alpha: 0.3) + : RetroColors.borderOf(context).withValues(alpha: 0.3), + border: Border.all( + color: value + ? RetroColors.goldOf(context) + : RetroColors.borderOf(context), + width: 2, + ), + ), + child: AnimatedAlign( + duration: const Duration(milliseconds: 150), + alignment: value ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + width: 18, + height: 18, + margin: const EdgeInsets.all(1), + color: value + ? RetroColors.goldOf(context) + : RetroColors.textMutedOf(context), + ), + ), ), ); } +} - Widget _buildLanguageSelector() { - final currentLocale = game_l10n.currentGameLocale; - final languages = [ - ('en', 'English', '🇺🇸'), - ('ko', '한국어', '🇰🇷'), - ('ja', '日本語', '🇯🇵'), - ]; +/// 레트로 스타일 칩 +class _RetroChip extends StatelessWidget { + const _RetroChip({ + required this.label, + required this.isSelected, + required this.onTap, + }); - return Card( - child: Column( - children: languages.map((lang) { - final isSelected = currentLocale == lang.$1; - return ListTile( - leading: Text(lang.$3, style: const TextStyle(fontSize: 24)), - title: Text(lang.$2), - trailing: isSelected - ? Icon( - Icons.check, - color: Theme.of(context).colorScheme.primary, - ) - : null, - onTap: () { - game_l10n.setGameLocale(lang.$1); - widget.settingsRepository.saveLocale(lang.$1); - widget.onLocaleChange?.call(lang.$1); - setState(() {}); - }, - ); - }).toList(), + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: isSelected + ? RetroColors.goldOf(context).withValues(alpha: 0.2) + : Colors.transparent, + border: Border.all( + color: isSelected + ? RetroColors.goldOf(context) + : RetroColors.borderOf(context), + width: isSelected ? 2 : 1, + ), + ), + child: Text( + label, + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 8, + color: isSelected + ? RetroColors.goldOf(context) + : RetroColors.textMutedOf(context), + ), + ), ), ); } +} - Widget _buildVolumeSlider({ - required String label, - required double value, - required IconData icon, - required void Function(double) onChanged, - }) { - final theme = Theme.of(context); - final percentage = (value * 100).round(); +/// 레트로 스타일 확인 다이얼로그 +class _RetroConfirmDialog extends StatelessWidget { + const _RetroConfirmDialog({ + required this.title, + required this.message, + required this.confirmText, + required this.cancelText, + }); - return Card( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( + final String title; + final String message; + final String confirmText; + final String cancelText; + + @override + Widget build(BuildContext context) { + return Dialog( + backgroundColor: Colors.transparent, + child: Container( + constraints: const BoxConstraints(maxWidth: 360), + decoration: BoxDecoration( + color: RetroColors.backgroundOf(context), + border: Border.all(color: RetroColors.goldOf(context), width: 3), + ), + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - Icon( - value == 0 ? Icons.volume_off : icon, - color: theme.colorScheme.primary, + // 타이틀 + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + color: RetroColors.goldOf(context).withValues(alpha: 0.2), + child: Text( + title, + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 10, + color: RetroColors.goldOf(context), + ), + textAlign: TextAlign.center, + ), ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + // 메시지 + Padding( + padding: const EdgeInsets.all(16), + child: Text( + message, + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 8, + color: RetroColors.textPrimaryOf(context), + height: 1.8, + ), + textAlign: TextAlign.center, + ), + ), + // 버튼 + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), + child: Row( children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [Text(label), Text('$percentage%')], + Expanded( + child: RetroTextButton( + text: cancelText, + isPrimary: false, + onPressed: () => Navigator.of(context).pop(false), + ), + ), + const SizedBox(width: 8), + Expanded( + child: RetroTextButton( + text: confirmText, + onPressed: () => Navigator.of(context).pop(true), + ), ), - Slider(value: value, onChanged: onChanged, divisions: 10), ], ), ), @@ -570,33 +836,4 @@ class _SettingsScreenState extends State { ), ); } - - Widget _buildAboutCard() { - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'ASCII NEVER DIE', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 8), - Text( - game_l10n.uiAboutDescription, - style: Theme.of(context).textTheme.bodySmall, - ), - const SizedBox(height: 8), - Text( - 'v1.0.0', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.outline, - ), - ), - ], - ), - ), - ); - } }