diff --git a/lib/src/app.dart b/lib/src/app.dart index f0fa772..fd51c6a 100644 --- a/lib/src/app.dart +++ b/lib/src/app.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n; import 'package:asciineverdie/l10n/app_localizations.dart'; import 'package:asciineverdie/src/core/audio/audio_service.dart'; +import 'package:asciineverdie/src/core/engine/debug_settings_service.dart'; import 'package:asciineverdie/src/shared/retro_colors.dart'; import 'package:asciineverdie/src/core/engine/game_mutations.dart'; import 'package:asciineverdie/src/core/engine/progress_service.dart'; @@ -23,6 +24,7 @@ import 'package:asciineverdie/src/features/game/game_session_controller.dart'; import 'package:asciineverdie/src/features/game/widgets/notification_overlay.dart'; import 'package:asciineverdie/src/features/hall_of_fame/hall_of_fame_screen.dart'; import 'package:asciineverdie/src/features/new_character/new_character_screen.dart'; +import 'package:asciineverdie/src/features/settings/settings_screen.dart'; class AskiiNeverDieApp extends StatefulWidget { const AskiiNeverDieApp({super.key}); @@ -81,6 +83,8 @@ class _AskiiNeverDieAppState extends State { // 초기 설정 및 오디오 서비스 로드 _loadSettings(); _audioService.init(); + // 디버그 설정 서비스 초기화 (Phase 8) + DebugSettingsService.instance.initialize(); // 세이브 파일 존재 여부 확인 _checkForExistingSave(); // 명예의 전당 로드 @@ -118,7 +122,7 @@ class _AskiiNeverDieAppState extends State { if (exists) { // 세이브 파일에서 미리보기 정보 추출 - final (outcome, state, _) = await _controller.saveManager.loadState(); + final (outcome, state, _, _) = await _controller.saveManager.loadState(); if (outcome.success && state != null) { final actName = _getActName(state.progress.plotStageCount); preview = SavedGamePreview( @@ -465,6 +469,7 @@ class _AskiiNeverDieAppState extends State { onLoadSave: _loadSave, onHallOfFame: _navigateToHallOfFame, onLocalArena: _navigateToArena, + onSettings: _showSettings, hasSaveFile: _hasSave, savedGamePreview: _savedGamePreview, hallOfFameCount: _hallOfFame.count, @@ -602,6 +607,18 @@ class _AskiiNeverDieAppState extends State { _audioService.playBgm('title'); }); } + + /// 설정 화면 표시 (모달 바텀시트) + void _showSettings(BuildContext context) { + SettingsScreen.show( + context, + settingsRepository: _settingsRepository, + currentThemeMode: _themeMode, + onThemeModeChange: _changeThemeMode, + onBgmVolumeChange: _audioService.setBgmVolume, + onSfxVolumeChange: _audioService.setSfxVolume, + ); + } } /// 스플래시 화면 (세이브 파일 확인 중) - 레트로 스타일 diff --git a/lib/src/features/front/front_screen.dart b/lib/src/features/front/front_screen.dart index 6caf0e6..0da592b 100644 --- a/lib/src/features/front/front_screen.dart +++ b/lib/src/features/front/front_screen.dart @@ -17,6 +17,7 @@ class FrontScreen extends StatefulWidget { this.onLoadSave, this.onHallOfFame, this.onLocalArena, + this.onSettings, this.hasSaveFile = false, this.savedGamePreview, this.hallOfFameCount = 0, @@ -36,6 +37,9 @@ class FrontScreen extends StatefulWidget { /// "Local Arena" 버튼 클릭 시 호출 final void Function(BuildContext context)? onLocalArena; + /// "Settings" 버튼 클릭 시 호출 (언어, 테마, 사운드) + final void Function(BuildContext context)? onSettings; + /// 세이브 파일 존재 여부 (새 캐릭터 시 경고용) final bool hasSaveFile; @@ -147,6 +151,9 @@ class _FrontScreenState extends State with RouteAware { widget.hallOfFameCount >= 2 ? () => widget.onLocalArena!(context) : null, + onSettings: widget.onSettings != null + ? () => widget.onSettings!(context) + : null, savedGamePreview: widget.savedGamePreview, hallOfFameCount: widget.hallOfFameCount, ), @@ -249,6 +256,7 @@ class _ActionButtons extends StatelessWidget { this.onLoadSave, this.onHallOfFame, this.onLocalArena, + this.onSettings, this.savedGamePreview, this.hallOfFameCount = 0, }); @@ -257,6 +265,7 @@ class _ActionButtons extends StatelessWidget { final VoidCallback? onLoadSave; final VoidCallback? onHallOfFame; final VoidCallback? onLocalArena; + final VoidCallback? onSettings; final SavedGamePreview? savedGamePreview; final int hallOfFameCount; @@ -306,6 +315,14 @@ class _ActionButtons extends StatelessWidget { onPressed: hallOfFameCount >= 2 ? onLocalArena : null, isPrimary: false, ), + // 설정 + const SizedBox(height: 12), + RetroTextButton( + text: game_l10n.uiSettings, + icon: Icons.settings, + onPressed: onSettings, + isPrimary: false, + ), ], ), ); @@ -399,3 +416,4 @@ class _RetroTag extends StatelessWidget { ); } } + diff --git a/lib/src/features/game/game_play_screen.dart b/lib/src/features/game/game_play_screen.dart index c878a90..372f0bb 100644 --- a/lib/src/features/game/game_play_screen.dart +++ b/lib/src/features/game/game_play_screen.dart @@ -6,6 +6,8 @@ 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/data/story_data.dart'; import 'package:asciineverdie/l10n/app_localizations.dart'; import 'package:asciineverdie/src/core/animation/ascii_animation_type.dart'; @@ -28,6 +30,7 @@ import 'package:asciineverdie/src/features/game/widgets/equipment_stats_panel.da import 'package:asciineverdie/src/features/game/widgets/potion_inventory_panel.dart'; import 'package:asciineverdie/src/features/game/widgets/task_progress_panel.dart'; import 'package:asciineverdie/src/features/game/widgets/active_buff_panel.dart'; +import 'package:asciineverdie/src/features/game/widgets/return_rewards_dialog.dart'; import 'package:asciineverdie/src/features/game/layouts/mobile_carousel_layout.dart'; import 'package:asciineverdie/src/features/settings/settings_screen.dart'; import 'package:asciineverdie/src/features/game/widgets/statistics_dialog.dart'; @@ -246,6 +249,9 @@ class _GamePlayScreenState extends State // 오디오 볼륨 초기화 _audioController.initVolumes(); + + // Phase 7: 복귀 보상 콜백 설정 + widget.controller.onReturnRewardAvailable = _showReturnRewardsDialog; } @override @@ -262,6 +268,7 @@ class _GamePlayScreenState extends State _storyService.dispose(); WidgetsBinding.instance.removeObserver(this); widget.controller.removeListener(_onControllerChanged); + widget.controller.onReturnRewardAvailable = null; // Phase 7: 콜백 정리 super.dispose(); } @@ -399,6 +406,21 @@ class _GamePlayScreenState extends State return platform == TargetPlatform.iOS || platform == TargetPlatform.android; } + /// 복귀 보상 다이얼로그 표시 (Phase 7) + void _showReturnRewardsDialog(ReturnReward reward) { + // 잠시 후 다이얼로그 표시 (게임 시작 후) + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + ReturnRewardsDialog.show( + context, + reward: reward, + onClaim: (totalGold) { + widget.controller.applyReturnReward(totalGold); + }, + ); + }); + } + /// 통계 다이얼로그 표시 void _showStatisticsDialog(BuildContext context) { StatisticsDialog.show( @@ -486,6 +508,47 @@ class _GamePlayScreenState extends State }); } + /// 광고 부활 핸들러 (HP 100% + 아이템 복구 + 10분 자동부활) + Future _handleAdRevive() async { + // 1. 부활 애니메이션 먼저 설정 (DeathOverlay 사라지기 전에) + setState(() { + _specialAnimation = AsciiAnimationType.resurrection; + }); + + // 2. 광고 부활 처리 (HP 100%, 아이템 복구, 10분 자동부활 버프) + await widget.controller.adRevive(); + + // 3. 애니메이션 종료 후 게임 재개 + final duration = getSpecialAnimationDuration( + AsciiAnimationType.resurrection, + ); + Future.delayed(Duration(milliseconds: duration), () async { + if (mounted) { + await widget.controller.resumeAfterResurrection(); + if (mounted) { + setState(() { + _specialAnimation = null; + }); + } + } + }); + } + + /// 속도 부스트 활성화 핸들러 (Phase 6) + Future _handleSpeedBoost() async { + final activated = await widget.controller.activateSpeedBoost(); + if (activated && mounted) { + _notificationService.show( + GameNotification( + type: NotificationType.info, + title: game_l10n.speedBoostActive, + duration: const Duration(seconds: 2), + ), + ); + setState(() {}); + } + } + @override Widget build(BuildContext context) { final state = widget.controller.state; @@ -603,6 +666,11 @@ 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, ), // 사망 오버레이 if (state.isDead && state.deathInfo != null) @@ -610,12 +678,8 @@ class _GamePlayScreenState extends State deathInfo: state.deathInfo!, traits: state.traits, onResurrect: _handleResurrect, - isAutoResurrectEnabled: widget.controller.autoResurrect, - onToggleAutoResurrect: () { - widget.controller.setAutoResurrect( - !widget.controller.autoResurrect, - ); - }, + onAdRevive: _handleAdRevive, + isPaidUser: IAPService.instance.isAdRemovalPurchased, ), // 승리 오버레이 (게임 클리어) if (widget.controller.isComplete) @@ -759,18 +823,14 @@ class _GamePlayScreenState extends State ], ), - // Phase 4: 사망 오버레이 (Death Overlay) + // 사망 오버레이 if (state.isDead && state.deathInfo != null) DeathOverlay( deathInfo: state.deathInfo!, traits: state.traits, onResurrect: _handleResurrect, - isAutoResurrectEnabled: widget.controller.autoResurrect, - onToggleAutoResurrect: () { - widget.controller.setAutoResurrect( - !widget.controller.autoResurrect, - ); - }, + onAdRevive: _handleAdRevive, + isPaidUser: IAPService.instance.isAdRemovalPurchased, ), // 승리 오버레이 (게임 클리어) if (widget.controller.isComplete) diff --git a/lib/src/features/game/game_session_controller.dart b/lib/src/features/game/game_session_controller.dart index 76fba55..f4d4242 100644 --- a/lib/src/features/game/game_session_controller.dart +++ b/lib/src/features/game/game_session_controller.dart @@ -1,14 +1,19 @@ import 'dart:async'; +import 'package:asciineverdie/src/core/engine/ad_service.dart'; +import 'package:asciineverdie/src/core/engine/debug_settings_service.dart'; +import 'package:asciineverdie/src/core/engine/iap_service.dart'; import 'package:asciineverdie/src/core/engine/progress_loop.dart'; import 'package:asciineverdie/src/core/engine/progress_service.dart'; import 'package:asciineverdie/src/core/engine/resurrection_service.dart'; +import 'package:asciineverdie/src/core/engine/return_rewards_service.dart'; import 'package:asciineverdie/src/core/engine/shop_service.dart'; import 'package:asciineverdie/src/core/engine/test_character_service.dart'; import 'package:asciineverdie/src/core/model/combat_stats.dart'; 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/storage/hall_of_fame_storage.dart'; import 'package:asciineverdie/src/core/storage/save_manager.dart'; import 'package:asciineverdie/src/core/storage/statistics_storage.dart'; @@ -54,6 +59,20 @@ class GameSessionController extends ChangeNotifier { // 자동 부활 (Auto-Resurrection) 상태 bool _autoResurrect = false; + // 속도 부스트 상태 (Phase 6) + bool _isSpeedBoostActive = false; + Timer? _speedBoostTimer; + int _speedBoostRemainingSeconds = 0; + static const int _speedBoostDuration = 300; // 5분 + static const int _speedBoostMultiplier = 5; // 5x 속도 + + // 복귀 보상 상태 (Phase 7) + MonetizationState _monetization = MonetizationState.initial(); + ReturnReward? _pendingReturnReward; + + /// 복귀 보상 콜백 (UI에서 다이얼로그 표시용) + void Function(ReturnReward reward)? onReturnRewardAvailable; + // 통계 관련 필드 SessionStatistics _sessionStats = SessionStatistics.empty(); CumulativeStatistics _cumulativeStats = CumulativeStatistics.empty(); @@ -152,18 +171,14 @@ class GameSessionController extends ChangeNotifier { notifyListeners(); } - /// 명예의 전당 상태에 따른 가용 배속 목록 반환 - /// - 디버그 모드(치트 활성화): [1, 5, 20] (터보 모드) - /// - 명예의 전당에 캐릭터 없음: [1, 5] - /// - 명예의 전당에 캐릭터 있음: [1, 2, 5] + /// 가용 배속 목록 반환 + /// - 디버그 모드(치트 활성화): [1, 2, 20] (터보 모드 포함) + /// - 일반 모드: [1, 2] (5x는 광고 버프로만 활성화) Future> _getAvailableSpeeds() async { - // 디버그 모드면 터보(20x) 추가 if (_cheatsEnabled) { - return [1, 5, 20]; + return [1, 2, 20]; } - - final hallOfFame = await _hallOfFameStorage.load(); - return hallOfFame.isEmpty ? [1, 5] : [1, 2, 5]; + return [1, 2]; } /// 이전 값 초기화 (통계 변화 추적용) @@ -241,9 +256,8 @@ class GameSessionController extends ChangeNotifier { _error = null; notifyListeners(); - final (outcome, loaded, savedCheatsEnabled) = await saveManager.loadState( - fileName: fileName, - ); + final (outcome, loaded, savedCheatsEnabled, savedMonetization) = + await saveManager.loadState(fileName: fileName); if (!outcome.success || loaded == null) { _status = GameSessionStatus.error; _error = outcome.error ?? 'Unknown error'; @@ -251,6 +265,12 @@ class GameSessionController extends ChangeNotifier { return; } + // 저장된 수익화 상태 복원 + _monetization = savedMonetization ?? MonetizationState.initial(); + + // 복귀 보상 체크 (Phase 7) + _checkReturnRewards(loaded); + // 저장된 치트 모드 상태 복원 await startNew(loaded, cheatsEnabled: savedCheatsEnabled, isNewGame: false); } @@ -312,8 +332,16 @@ class GameSessionController extends ChangeNotifier { _status = GameSessionStatus.dead; notifyListeners(); - // 자동 부활이 활성화된 경우 잠시 후 자동으로 부활 - if (_autoResurrect) { + // 자동 부활 조건 확인: + // 1. 수동 토글 자동부활 (_autoResurrect) + // 2. 유료 유저 (항상 자동부활) + // 3. 광고 부활 버프 활성 (10분간) + final elapsedMs = _state?.skillSystem.elapsedMs ?? 0; + final shouldAutoResurrect = _autoResurrect || + IAPService.instance.isAdRemovalPurchased || + _monetization.isAutoReviveActive(elapsedMs); + + if (shouldAutoResurrect) { _scheduleAutoResurrect(); } } @@ -323,8 +351,15 @@ class GameSessionController extends ChangeNotifier { /// 사망 오버레이를 잠시 표시한 후 자동으로 부활 처리 void _scheduleAutoResurrect() { Future.delayed(const Duration(milliseconds: 800), () async { - // 상태가 여전히 dead이고, 자동 부활이 활성화된 경우에만 부활 - if (_status == GameSessionStatus.dead && _autoResurrect) { + if (_status != GameSessionStatus.dead) return; + + // 자동 부활 조건 재확인 + final elapsedMs = _state?.skillSystem.elapsedMs ?? 0; + final shouldAutoResurrect = _autoResurrect || + IAPService.instance.isAdRemovalPurchased || + _monetization.isAutoReviveActive(elapsedMs); + + if (shouldAutoResurrect) { await resurrect(); await resumeAfterResurrection(); } @@ -456,6 +491,7 @@ class GameSessionController extends ChangeNotifier { await saveManager.saveState( resurrectedState, cheatsEnabled: _cheatsEnabled, + monetization: _monetization, ); notifyListeners(); @@ -471,10 +507,266 @@ class GameSessionController extends ChangeNotifier { await startNew(_state!, cheatsEnabled: _cheatsEnabled, isNewGame: false); } + // =========================================================================== + // 광고 부활 (HP 100% + 아이템 복구 + 10분 자동부활) + // =========================================================================== + + /// 광고 부활 (HP 100% + 아이템 복구 + 10분 자동부활 버프) + /// + /// 유료 유저: 광고 없이 부활 + /// 무료 유저: 리워드 광고 시청 후 부활 + Future adRevive() async { + if (_state == null || !_state!.isDead) return; + + final shopService = ShopService(rng: _state!.rng); + final resurrectionService = ResurrectionService(shopService: shopService); + + // 부활 처리 함수 + void processRevive() { + _state = resurrectionService.processAdRevive(_state!); + _status = GameSessionStatus.idle; + + // 10분 자동부활 버프 활성화 (elapsedMs 기준) + final buffEndMs = _state!.skillSystem.elapsedMs + 600000; // 10분 = 600,000ms + _monetization = _monetization.copyWith( + autoReviveEndMs: buffEndMs, + ); + + debugPrint('[GameSession] Ad revive complete, auto-revive buff until $buffEndMs ms'); + } + + // 유료 유저는 광고 없이 부활 + if (IAPService.instance.isAdRemovalPurchased) { + processRevive(); + await saveManager.saveState( + _state!, + cheatsEnabled: _cheatsEnabled, + monetization: _monetization, + ); + notifyListeners(); + debugPrint('[GameSession] Ad revive (paid user)'); + return; + } + + // 무료 유저는 리워드 광고 필요 + final adResult = await AdService.instance.showRewardedAd( + adType: AdType.rewardRevive, + onRewarded: processRevive, + ); + + if (adResult == AdResult.completed || adResult == AdResult.debugSkipped) { + await saveManager.saveState( + _state!, + cheatsEnabled: _cheatsEnabled, + monetization: _monetization, + ); + notifyListeners(); + debugPrint('[GameSession] Ad revive (free user with ad)'); + } else { + debugPrint('[GameSession] Ad revive failed: $adResult'); + } + } + /// 사망 상태 여부 bool get isDead => _status == GameSessionStatus.dead || (_state?.isDead ?? false); /// 게임 클리어 여부 bool get isComplete => _status == GameSessionStatus.complete; + + // =========================================================================== + // 속도 부스트 (Phase 6) + // =========================================================================== + + /// 속도 부스트 활성화 여부 + bool get isSpeedBoostActive => _isSpeedBoostActive; + + /// 속도 부스트 남은 시간 (초) + int get speedBoostRemainingSeconds => _speedBoostRemainingSeconds; + + /// 속도 부스트 배율 + int get speedBoostMultiplier => _speedBoostMultiplier; + + /// 속도 부스트 지속 시간 (초) + int get speedBoostDuration => _speedBoostDuration; + + /// 현재 실제 배속 (부스트 적용 포함) + int get currentSpeedMultiplier { + if (_isSpeedBoostActive) return _speedBoostMultiplier; + return _loop?.speedMultiplier ?? _savedSpeedMultiplier; + } + + /// 속도 부스트 활성화 (광고 시청 후) + /// + /// 유료 유저: 무료 활성화 + /// 무료 유저: 인터스티셜 광고 시청 후 활성화 + /// Returns: 활성화 성공 여부 + Future activateSpeedBoost() async { + if (_isSpeedBoostActive) return false; // 이미 활성화됨 + if (_loop == null) return false; + + // 유료 유저는 무료 활성화 + if (IAPService.instance.isAdRemovalPurchased) { + _startSpeedBoost(); + debugPrint('[GameSession] Speed boost activated (paid user)'); + return true; + } + + // 무료 유저는 인터스티셜 광고 필요 + bool activated = false; + final adResult = await AdService.instance.showInterstitialAd( + adType: AdType.interstitialSpeed, + onComplete: () { + _startSpeedBoost(); + activated = true; + }, + ); + + if (adResult == AdResult.completed || adResult == AdResult.debugSkipped) { + debugPrint('[GameSession] Speed boost activated (free user with ad)'); + return activated; + } + + debugPrint('[GameSession] Speed boost activation failed: $adResult'); + return false; + } + + /// 속도 부스트 시작 (내부) + void _startSpeedBoost() { + if (_loop == null) return; + + // 현재 배속 저장 + _savedSpeedMultiplier = _loop!.speedMultiplier; + + // 부스트 배속 적용 + _isSpeedBoostActive = true; + _speedBoostRemainingSeconds = _speedBoostDuration; + + // monetization 상태에 종료 시점 저장 (UI 표시용) + final currentElapsedMs = _state?.skillSystem.elapsedMs ?? 0; + final endMs = currentElapsedMs + (_speedBoostDuration * 1000); + _monetization = _monetization.copyWith(speedBoostEndMs: endMs); + + // ProgressLoop에 직접 배속 설정 + _loop!.updateAvailableSpeeds([_speedBoostMultiplier]); + + // 1초마다 남은 시간 감소 + _speedBoostTimer?.cancel(); + _speedBoostTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + _speedBoostRemainingSeconds--; + notifyListeners(); + + if (_speedBoostRemainingSeconds <= 0) { + _endSpeedBoost(); + } + }); + + notifyListeners(); + } + + /// 속도 부스트 종료 (내부) + void _endSpeedBoost() { + _speedBoostTimer?.cancel(); + _speedBoostTimer = null; + _isSpeedBoostActive = false; + _speedBoostRemainingSeconds = 0; + + // monetization 상태 초기화 (UI 표시 제거) + _monetization = _monetization.copyWith(speedBoostEndMs: null); + + // 원래 배속 복원 + if (_loop != null) { + _getAvailableSpeeds().then((speeds) { + _loop!.updateAvailableSpeeds(speeds); + }); + } + + notifyListeners(); + debugPrint('[GameSession] Speed boost ended'); + } + + /// 속도 부스트 수동 취소 + void cancelSpeedBoost() { + if (_isSpeedBoostActive) { + _endSpeedBoost(); + } + } + + // =========================================================================== + // 복귀 보상 (Phase 7) + // =========================================================================== + + /// 현재 수익화 상태 + MonetizationState get monetization => _monetization; + + /// 대기 중인 복귀 보상 + ReturnReward? get pendingReturnReward => _pendingReturnReward; + + /// 복귀 보상 체크 (로드 시 호출) + void _checkReturnRewards(GameState loaded) { + final rewardsService = ReturnRewardsService.instance; + final debugSettings = DebugSettingsService.instance; + + // 디버그 모드: 오프라인 시간 시뮬레이션 적용 + final lastPlayTime = debugSettings.getSimulatedLastPlayTime( + _monetization.lastPlayTime, + ); + + final reward = rewardsService.calculateReward( + lastPlayTime: lastPlayTime, + currentTime: DateTime.now(), + playerLevel: loaded.traits.level, + ); + + if (reward.hasReward) { + _pendingReturnReward = reward; + debugPrint('[ReturnRewards] Reward available: ${reward.goldReward} gold, ' + '${reward.hoursAway} hours away'); + + // UI에서 다이얼로그 표시를 위해 콜백 호출 + // startNew 후에 호출하도록 딜레이 + Future.delayed(const Duration(milliseconds: 500), () { + if (_pendingReturnReward != null) { + onReturnRewardAvailable?.call(_pendingReturnReward!); + } + }); + } + } + + /// 복귀 보상 수령 완료 (골드 적용) + /// + /// [totalGold] 수령한 총 골드 (기본 + 보너스) + void applyReturnReward(int totalGold) { + if (_state == null) return; + if (totalGold <= 0) { + // 보상 없이 건너뛴 경우 + _pendingReturnReward = null; + debugPrint('[ReturnRewards] Reward skipped'); + return; + } + + // 골드 추가 + final updatedInventory = _state!.inventory.copyWith( + gold: _state!.inventory.gold + totalGold, + ); + _state = _state!.copyWith(inventory: updatedInventory); + + // 저장 + unawaited(saveManager.saveState( + _state!, + cheatsEnabled: _cheatsEnabled, + monetization: _monetization, + )); + + _pendingReturnReward = null; + notifyListeners(); + + debugPrint('[ReturnRewards] Reward applied: $totalGold gold'); + } + + /// 복귀 보상 건너뛰기 + void skipReturnReward() { + _pendingReturnReward = null; + debugPrint('[ReturnRewards] Reward skipped by user'); + } } diff --git a/lib/src/features/game/layouts/mobile_carousel_layout.dart b/lib/src/features/game/layouts/mobile_carousel_layout.dart index 08e6fb7..5925a1c 100644 --- a/lib/src/features/game/layouts/mobile_carousel_layout.dart +++ b/lib/src/features/game/layouts/mobile_carousel_layout.dart @@ -52,6 +52,10 @@ class MobileCarouselLayout extends StatefulWidget { this.onCheatQuest, this.onCheatPlot, this.onCreateTestCharacter, + this.autoReviveEndMs, + this.speedBoostEndMs, + this.isPaidUser = false, + this.onSpeedBoostActivate, }); final GameState state; @@ -102,6 +106,18 @@ class MobileCarouselLayout extends StatefulWidget { /// 테스트 캐릭터 생성 콜백 (디버그 모드 전용) final Future Function()? onCreateTestCharacter; + /// 자동부활 버프 종료 시점 (elapsedMs 기준) + final int? autoReviveEndMs; + + /// 5배속 버프 종료 시점 (elapsedMs 기준) + final int? speedBoostEndMs; + + /// 유료 유저 여부 + final bool isPaidUser; + + /// 5배속 버프 활성화 콜백 (광고 시청) + final VoidCallback? onSpeedBoostActivate; + @override State createState() => _MobileCarouselLayoutState(); } @@ -456,27 +472,7 @@ class _MobileCarouselLayoutState extends State { ListTile( leading: const Icon(Icons.speed), title: Text(l10n.menuSpeed), - trailing: Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 4, - ), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(12), - ), - child: Text( - '${widget.speedMultiplier}x', - style: TextStyle( - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primary, - ), - ), - ), - onTap: () { - widget.onSpeedCycle(); - Navigator.pop(context); - }, + trailing: _buildSpeedSelector(context), ), const Divider(), @@ -735,6 +731,10 @@ class _MobileCarouselLayoutState extends State { state.progress.currentCombat?.recentEvents.lastOrNull, raceId: state.traits.raceId, weaponRarity: state.equipment.weaponItem.rarity, + autoReviveEndMs: widget.autoReviveEndMs, + speedBoostEndMs: widget.speedBoostEndMs, + isPaidUser: widget.isPaidUser, + onSpeedBoostActivate: widget.onSpeedBoostActivate, ), // 중앙: 캐로셀 (PageView) @@ -794,4 +794,135 @@ 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; + + 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); + }, + ), + ], + ], + ); + } + + /// 속도 칩 빌드 + 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; + + if (isSelected) { + bgColor = isDebug + ? Colors.red + : speed == 5 + ? Colors.orange + : Theme.of(context).colorScheme.primary; + textColor = Colors.white; + } else { + bgColor = Theme.of(context).colorScheme.surfaceContainerHighest; + textColor = isAdBased + ? Colors.orange + : isDebug + ? Colors.red.withValues(alpha: 0.7) + : Theme.of(context).colorScheme.onSurface; + } + + return GestureDetector( + onTap: onTap, + child: Container( + 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, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isAdBased && !isSelected) + const Padding( + padding: EdgeInsets.only(right: 2), + child: Text( + '▶', + style: TextStyle(fontSize: 8, color: Colors.orange), + ), + ), + Text( + '${speed}x', + style: TextStyle( + fontSize: 12, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + color: textColor, + ), + ), + ], + ), + ), + ); + } } diff --git a/lib/src/features/game/widgets/enhanced_animation_panel.dart b/lib/src/features/game/widgets/enhanced_animation_panel.dart index 6846c7f..edd551e 100644 --- a/lib/src/features/game/widgets/enhanced_animation_panel.dart +++ b/lib/src/features/game/widgets/enhanced_animation_panel.dart @@ -37,6 +37,10 @@ class EnhancedAnimationPanel extends StatefulWidget { this.latestCombatEvent, this.raceId, this.weaponRarity, + this.autoReviveEndMs, + this.speedBoostEndMs, + this.isPaidUser = false, + this.onSpeedBoostActivate, }); final ProgressState progress; @@ -65,6 +69,18 @@ class EnhancedAnimationPanel extends StatefulWidget { /// 무기 희귀도 (Phase 9: 무기 등급별 이펙트 색상) final ItemRarity? weaponRarity; + /// 자동부활 버프 종료 시점 (elapsedMs 기준, null이면 비활성) + final int? autoReviveEndMs; + + /// 5배속 버프 종료 시점 (elapsedMs 기준, null이면 비활성) + final int? speedBoostEndMs; + + /// 유료 유저 여부 (5배속 항상 활성) + final bool isPaidUser; + + /// 5배속 버프 활성화 콜백 (광고 시청) + final VoidCallback? onSpeedBoostActivate; + @override State createState() => _EnhancedAnimationPanelState(); } @@ -190,6 +206,22 @@ class _EnhancedAnimationPanelState extends State int? get _currentMonsterHpMax => widget.progress.currentCombat?.monsterStats.hpMax; + /// 자동부활 버프 남은 시간 (ms) + int get _autoReviveRemainingMs { + final endMs = widget.autoReviveEndMs; + if (endMs == null) return 0; + final remaining = endMs - widget.skillSystem.elapsedMs; + return remaining > 0 ? remaining : 0; + } + + /// 5배속 버프 남은 시간 (ms) + int get _speedBoostRemainingMs { + final endMs = widget.speedBoostEndMs; + if (endMs == null) return 0; + final remaining = endMs - widget.skillSystem.elapsedMs; + return remaining > 0 ? remaining : 0; + } + @override void dispose() { _hpFlashController.dispose(); @@ -218,62 +250,94 @@ class _EnhancedAnimationPanelState extends State child: Column( mainAxisSize: MainAxisSize.min, children: [ - // ASCII 애니메이션 (기존 높이 120 유지) + // ASCII 애니메이션 (기존 높이 120 유지) + 버프 오버레이 SizedBox( height: 120, - child: AsciiAnimationCard( - taskType: widget.progress.currentTask.type, - monsterBaseName: widget.progress.currentTask.monsterBaseName, - specialAnimation: widget.specialAnimation, - weaponName: widget.weaponName, - shieldName: widget.shieldName, - characterLevel: widget.characterLevel, - monsterLevel: widget.monsterLevel, - monsterGrade: widget.monsterGrade, - monsterSize: widget.monsterSize, - isPaused: widget.isPaused, - isInCombat: isInCombat, - monsterDied: _monsterDied, - latestCombatEvent: widget.latestCombatEvent, - raceId: widget.raceId, - weaponRarity: widget.weaponRarity, + child: Stack( + children: [ + // ASCII 애니메이션 + AsciiAnimationCard( + taskType: widget.progress.currentTask.type, + monsterBaseName: widget.progress.currentTask.monsterBaseName, + specialAnimation: widget.specialAnimation, + weaponName: widget.weaponName, + shieldName: widget.shieldName, + characterLevel: widget.characterLevel, + monsterLevel: widget.monsterLevel, + monsterGrade: widget.monsterGrade, + monsterSize: widget.monsterSize, + isPaused: widget.isPaused, + isInCombat: isInCombat, + monsterDied: _monsterDied, + latestCombatEvent: widget.latestCombatEvent, + raceId: widget.raceId, + weaponRarity: widget.weaponRarity, + ), + // 좌상단: 자동부활 버프 + if (_autoReviveRemainingMs > 0) + Positioned( + left: 4, + top: 4, + child: _buildBuffChip( + icon: '↺', + remainingMs: _autoReviveRemainingMs, + color: Colors.green, + ), + ), + // 우상단: 5배속 버프 + if (_speedBoostRemainingMs > 0 || widget.isPaidUser) + Positioned( + right: 4, + top: 4, + child: _buildBuffChip( + icon: '⚡', + label: '5x', + remainingMs: widget.isPaidUser ? -1 : _speedBoostRemainingMs, + color: Colors.orange, + isPermanent: widget.isPaidUser, + ), + ), + ], ), ), const SizedBox(height: 8), - // 상태 바 영역: HP/MP + 버프 아이콘 + 몬스터 HP - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 좌측: HP/MP 바 - Expanded( - flex: 3, - child: Column( - children: [ - _buildCompactHpBar(), - const SizedBox(height: 4), - _buildCompactMpBar(), - ], + // 상태 바 영역: HP/MP (40%) + 컨트롤 (20%) + 몬스터 HP (40%) + SizedBox( + height: 48, + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 좌측: HP/MP 바 (40%) + Expanded( + flex: 2, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Expanded(child: _buildCompactHpBar()), + const SizedBox(height: 4), + Expanded(child: _buildCompactMpBar()), + ], + ), ), - ), - const SizedBox(width: 8), + // 중앙: 컨트롤 버튼 (20%) + Expanded( + flex: 1, + child: _buildControlButtons(), + ), - // 중앙: 활성 버프 아이콘 (최대 3개) - _buildBuffIcons(), - - const SizedBox(width: 8), - - // 우측: 몬스터 HP (전투 중) 또는 컨트롤 버튼 - Expanded( - flex: 2, - child: switch ((shouldShowMonsterHp, combat)) { - (true, final c?) => _buildMonsterHpBar(c), - _ => _buildControlButtons(), - }, - ), - ], + // 우측: 몬스터 HP (전투 중) 또는 빈 공간 (40%) + Expanded( + flex: 2, + child: switch ((shouldShowMonsterHp, combat)) { + (true, final c?) => _buildMonsterHpBar(c), + _ => const SizedBox.shrink(), + }, + ), + ], + ), ), const SizedBox(height: 6), @@ -298,7 +362,6 @@ class _EnhancedAnimationPanelState extends State children: [ // HP 바 Container( - height: 20, decoration: BoxDecoration( color: isLow ? Colors.red.withValues(alpha: 0.2) @@ -330,13 +393,14 @@ class _EnhancedAnimationPanelState extends State borderRadius: const BorderRadius.horizontal( right: Radius.circular(3), ), - child: LinearProgressIndicator( - value: ratio.clamp(0.0, 1.0), - backgroundColor: Colors.red.withValues(alpha: 0.2), - valueColor: AlwaysStoppedAnimation( - isLow ? Colors.red : Colors.red.shade600, + child: SizedBox.expand( + child: LinearProgressIndicator( + value: ratio.clamp(0.0, 1.0), + backgroundColor: Colors.red.withValues(alpha: 0.2), + valueColor: AlwaysStoppedAnimation( + isLow ? Colors.red : Colors.red.shade600, + ), ), - minHeight: 20, ), ), // 숫자 오버레이 (바 중앙) @@ -402,7 +466,6 @@ class _EnhancedAnimationPanelState extends State clipBehavior: Clip.none, children: [ Container( - height: 20, decoration: BoxDecoration( color: Colors.grey.shade800, borderRadius: BorderRadius.circular(3), @@ -431,13 +494,14 @@ class _EnhancedAnimationPanelState extends State borderRadius: const BorderRadius.horizontal( right: Radius.circular(3), ), - child: LinearProgressIndicator( - value: ratio.clamp(0.0, 1.0), - backgroundColor: Colors.blue.withValues(alpha: 0.2), - valueColor: AlwaysStoppedAnimation( - Colors.blue.shade600, + child: SizedBox.expand( + child: LinearProgressIndicator( + value: ratio.clamp(0.0, 1.0), + backgroundColor: Colors.blue.withValues(alpha: 0.2), + valueColor: AlwaysStoppedAnimation( + Colors.blue.shade600, + ), ), - minHeight: 20, ), ), // 숫자 오버레이 (바 중앙) @@ -491,60 +555,6 @@ class _EnhancedAnimationPanelState extends State ); } - /// 활성 버프 아이콘 (최대 3개) - /// - /// Wrap 위젯을 사용하여 공간 부족 시 자동 줄바꿈 처리 - Widget _buildBuffIcons() { - final buffs = widget.skillSystem.activeBuffs; - final currentMs = widget.skillSystem.elapsedMs; - - if (buffs.isEmpty) { - return const SizedBox(width: 60); - } - - // 최대 3개만 표시 - final displayBuffs = buffs.take(3).toList(); - - return ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 72, minWidth: 60), - child: Wrap( - alignment: WrapAlignment.center, - spacing: 2, - runSpacing: 2, - children: displayBuffs.map((buff) { - final remainingMs = buff.remainingDuration(currentMs); - final progress = remainingMs / buff.effect.durationMs; - final isExpiring = remainingMs < 3000; - - return Stack( - alignment: Alignment.center, - children: [ - // 진행률 원형 표시 - SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - value: progress.clamp(0.0, 1.0), - strokeWidth: 2, - backgroundColor: Colors.grey.shade700, - valueColor: AlwaysStoppedAnimation( - isExpiring ? Colors.orange : Colors.lightBlue, - ), - ), - ), - // 버프 아이콘 - Icon( - Icons.trending_up, - size: 10, - color: isExpiring ? Colors.orange : Colors.lightBlue, - ), - ], - ); - }).toList(), - ), - ); - } - /// 몬스터 HP 바 (전투 중) /// - HP바 중앙에 HP% 오버레이 /// - 하단에 레벨.이름 표시 @@ -562,7 +572,6 @@ class _EnhancedAnimationPanelState extends State clipBehavior: Clip.none, children: [ Container( - height: 52, decoration: BoxDecoration( color: Colors.orange.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(4), @@ -572,52 +581,54 @@ class _EnhancedAnimationPanelState extends State mainAxisAlignment: MainAxisAlignment.center, children: [ // HP 바 (HP% 중앙 오버레이) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: Stack( - alignment: Alignment.center, - children: [ - // HP 바 - ClipRRect( - borderRadius: BorderRadius.circular(2), - child: LinearProgressIndicator( - value: ratio.clamp(0.0, 1.0), - backgroundColor: Colors.orange.withValues( - alpha: 0.2, - ), - valueColor: const AlwaysStoppedAnimation( - Colors.orange, - ), - minHeight: 16, - ), - ), - // HP% 중앙 오버레이 - Text( - '${(ratio * 100).toInt()}%', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: Colors.white, - shadows: [ - Shadow( - color: Colors.black.withValues(alpha: 0.8), - blurRadius: 2, + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(4, 4, 4, 2), + child: Stack( + alignment: Alignment.center, + children: [ + // HP 바 + ClipRRect( + borderRadius: BorderRadius.circular(2), + child: SizedBox.expand( + child: LinearProgressIndicator( + value: ratio.clamp(0.0, 1.0), + backgroundColor: Colors.orange.withValues( + alpha: 0.2, + ), + valueColor: const AlwaysStoppedAnimation( + Colors.orange, + ), ), - const Shadow(color: Colors.black, blurRadius: 4), - ], + ), ), - ), - ], + // HP% 중앙 오버레이 + Text( + '${(ratio * 100).toInt()}%', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Colors.white, + shadows: [ + Shadow( + color: Colors.black.withValues(alpha: 0.8), + blurRadius: 2, + ), + const Shadow(color: Colors.black, blurRadius: 4), + ], + ), + ), + ], + ), ), ), - const SizedBox(height: 2), // 레벨.이름 표시 Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), + padding: const EdgeInsets.fromLTRB(4, 0, 4, 4), child: Text( 'Lv.$monsterLevel $monsterName', style: const TextStyle( - fontSize: 12, + fontSize: 11, color: Colors.orange, fontWeight: FontWeight.bold, ), @@ -662,63 +673,91 @@ 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() { + final isSpeedBoostActive = _speedBoostRemainingMs > 0 || widget.isPaidUser; + return SizedBox( - height: 40, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // 일시정지 버튼 - SizedBox( - width: 40, - height: 36, - child: OutlinedButton( - onPressed: widget.onPauseToggle, - style: OutlinedButton.styleFrom( - padding: EdgeInsets.zero, - visualDensity: VisualDensity.compact, - side: BorderSide( - color: widget.isPaused - ? Colors.orange.withValues(alpha: 0.7) - : Theme.of( - context, - ).colorScheme.outline.withValues(alpha: 0.5), - ), - ), - child: Icon( - widget.isPaused ? Icons.play_arrow : Icons.pause, - size: 18, - color: widget.isPaused ? Colors.orange : null, + 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 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, ), ), - ), - const SizedBox(width: 4), - // 속도 버튼 - SizedBox( - width: 44, - height: 36, - child: OutlinedButton( - onPressed: widget.onSpeedCycle, - style: OutlinedButton.styleFrom( - padding: EdgeInsets.zero, - visualDensity: VisualDensity.compact, - ), - child: Text( - '${widget.speedMultiplier}x', - style: TextStyle( - fontSize: 12, - fontWeight: widget.speedMultiplier > 1 - ? FontWeight.bold - : FontWeight.normal, - color: widget.speedMultiplier > 1 - ? Theme.of(context).colorScheme.primary - : null, - ), - ), - ), - ), - ], + ], + ), ), ); } @@ -796,4 +835,64 @@ class _EnhancedAnimationPanelState extends State ], ); } + + /// 버프 칩 위젯 (좌상단/우상단 오버레이용) + Widget _buildBuffChip({ + required String icon, + required int remainingMs, + required Color color, + String? label, + bool isPermanent = false, + }) { + // 남은 시간 포맷 (분:초) + String timeText; + if (isPermanent) { + timeText = '∞'; + } else { + final seconds = (remainingMs / 1000).ceil(); + final min = seconds ~/ 60; + final sec = seconds % 60; + timeText = '$min:${sec.toString().padLeft(2, '0')}'; + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: color.withValues(alpha: 0.7), width: 1), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + icon, + style: TextStyle(fontSize: 12, color: color), + ), + if (label != null) ...[ + const SizedBox(width: 2), + Text( + label, + style: TextStyle( + fontFamily: 'JetBrainsMono', + fontSize: 10, + color: color, + fontWeight: FontWeight.bold, + ), + ), + ], + const SizedBox(width: 3), + Text( + timeText, + style: TextStyle( + fontFamily: 'JetBrainsMono', + fontSize: 10, + color: color, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ); + } } diff --git a/lib/src/features/new_character/new_character_screen.dart b/lib/src/features/new_character/new_character_screen.dart index a1bd9b4..3b891d4 100644 --- a/lib/src/features/new_character/new_character_screen.dart +++ b/lib/src/features/new_character/new_character_screen.dart @@ -8,6 +8,8 @@ import 'package:asciineverdie/data/class_data.dart'; import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n; import 'package:asciineverdie/data/race_data.dart'; import 'package:asciineverdie/l10n/app_localizations.dart'; +import 'package:asciineverdie/src/core/engine/character_roll_service.dart'; +import 'package:asciineverdie/src/core/engine/iap_service.dart'; import 'package:asciineverdie/src/core/model/class_traits.dart'; import 'package:asciineverdie/src/core/model/game_state.dart'; import 'package:asciineverdie/src/core/model/race_traits.dart'; @@ -52,10 +54,6 @@ class _NewCharacterScreenState extends State { int _wis = 0; int _cha = 0; - // 롤 이력 (Unroll 기능용) - 원본 OldRolls TListBox - static const int _maxRollHistory = 20; // 최대 저장 개수 - final List _rollHistory = []; - // 현재 RNG 시드 (Re-Roll 전 저장) int _currentSeed = 0; @@ -68,10 +66,19 @@ class _NewCharacterScreenState extends State { // 굴리기 버튼 연속 클릭 방지 bool _isRolling = false; + // 굴리기/되돌리기 서비스 + final CharacterRollService _rollService = CharacterRollService.instance; + + // 서비스 초기화 완료 여부 + bool _isServiceInitialized = false; + @override void initState() { super.initState(); + // 서비스 초기화 + _initializeService(); + // 초기 랜덤화 final random = math.Random(); _selectedRaceIndex = random.nextInt(_races.length); @@ -89,6 +96,16 @@ class _NewCharacterScreenState extends State { _scrollToSelectedItems(); } + /// 서비스 초기화 + Future _initializeService() async { + await _rollService.initialize(); + if (mounted) { + setState(() { + _isServiceInitialized = true; + }); + } + } + @override void dispose() { _nameController.dispose(); @@ -144,12 +161,35 @@ class _NewCharacterScreenState extends State { if (_isRolling) return; _isRolling = true; - // 현재 시드를 이력에 저장 - _rollHistory.insert(0, _currentSeed); + // 굴리기 가능 여부 확인 + if (!_rollService.canRoll) { + _isRolling = false; + _showRechargeDialog(); + return; + } - // 최대 개수 초과 시 가장 오래된 항목 제거 - if (_rollHistory.length > _maxRollHistory) { - _rollHistory.removeLast(); + // 현재 상태를 서비스에 저장 + final currentStats = Stats( + str: _str, + con: _con, + dex: _dex, + intelligence: _int, + wis: _wis, + cha: _cha, + hpMax: 0, + mpMax: 0, + ); + + final success = _rollService.roll( + currentStats: currentStats, + currentRaceIndex: _selectedRaceIndex, + currentKlassIndex: _selectedKlassIndex, + currentSeed: _currentSeed, + ); + + if (!success) { + _isRolling = false; + return; } // 새 시드로 굴림 @@ -173,14 +213,103 @@ class _NewCharacterScreenState extends State { }); } - /// Unroll 버튼 클릭 (이전 롤로 복원) - void _onUnroll() { - if (_rollHistory.isEmpty) return; + /// 굴리기 충전 다이얼로그 + Future _showRechargeDialog() async { + final isPaidUser = IAPService.instance.isAdRemovalPurchased; - setState(() { - _currentSeed = _rollHistory.removeAt(0); - }); - _rollStats(); + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: RetroColors.panelBg, + title: const Text( + 'RECHARGE ROLLS', + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 14, + color: RetroColors.gold, + ), + ), + content: Text( + isPaidUser + ? 'Recharge 5 rolls for free?' + : 'Watch an ad to recharge 5 rolls?', + style: const TextStyle( + fontFamily: 'PressStart2P', + fontSize: 12, + color: RetroColors.textLight, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text( + 'CANCEL', + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 11, + color: RetroColors.textDisabled, + ), + ), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (!isPaidUser) ...[ + const Icon(Icons.play_circle, size: 14, color: RetroColors.gold), + const SizedBox(width: 4), + ], + const Text( + 'RECHARGE', + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 11, + color: RetroColors.gold, + ), + ), + ], + ), + ), + ], + ), + ); + + if (result == true && mounted) { + final success = await _rollService.rechargeRollsWithAd(); + if (success && mounted) { + setState(() {}); + } + } + } + + /// Unroll 버튼 클릭 (이전 롤로 복원) + Future _onUnroll() async { + if (!_rollService.canUndo) return; + + final isPaidUser = IAPService.instance.isAdRemovalPurchased; + RollSnapshot? snapshot; + + if (isPaidUser) { + snapshot = _rollService.undoPaidUser(); + } else { + snapshot = await _rollService.undoFreeUser(); + } + + if (snapshot != null && mounted) { + setState(() { + _str = snapshot!.stats.str; + _con = snapshot.stats.con; + _dex = snapshot.stats.dex; + _int = snapshot.stats.intelligence; + _wis = snapshot.stats.wis; + _cha = snapshot.stats.cha; + _selectedRaceIndex = snapshot.raceIndex; + _selectedKlassIndex = snapshot.klassIndex; + _currentSeed = snapshot.seed; + }); + _scrollToSelectedItems(); + } } /// 이름 생성 버튼 클릭 @@ -266,6 +395,9 @@ class _NewCharacterScreenState extends State { queue: QueueState.empty(), ); + // 캐릭터 생성 완료 알림 (되돌리기 상태 초기화) + _rollService.onCharacterCreated(); + widget.onCharacterCreated?.call(initialState, testMode: _cheatsEnabled); } @@ -493,34 +625,27 @@ class _NewCharacterScreenState extends State { Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - RetroTextButton( - text: l10n.unroll, - icon: Icons.undo, - onPressed: _rollHistory.isEmpty ? null : _onUnroll, - isPrimary: false, - ), + _buildUndoButton(l10n), const SizedBox(width: 16), - RetroTextButton( - text: l10n.roll, - icon: Icons.casino, - onPressed: _onReroll, - ), + _buildRollButton(l10n), ], ), - if (_rollHistory.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Center( - child: Text( - game_l10n.uiRollHistory(_rollHistory.length), - style: const TextStyle( - fontFamily: 'PressStart2P', - fontSize: 13, - color: RetroColors.textDisabled, - ), + // 남은 횟수 표시 + Padding( + padding: const EdgeInsets.only(top: 8), + child: Center( + child: Text( + _rollService.canUndo + ? 'Undo: ${_rollService.availableUndos} | Rolls: ${_rollService.rollsRemaining}/5' + : 'Rolls: ${_rollService.rollsRemaining}/5', + style: const TextStyle( + fontFamily: 'PressStart2P', + fontSize: 11, + color: RetroColors.textDisabled, ), ), ), + ), ], ), ); @@ -790,4 +915,96 @@ class _NewCharacterScreenState extends State { ClassPassiveType.firstStrikeBonus => passive.description, }; } + + // =========================================================================== + // 굴리기/되돌리기 버튼 위젯 + // =========================================================================== + + /// 되돌리기 버튼 + Widget _buildUndoButton(L10n l10n) { + final canUndo = _rollService.canUndo; + final isPaidUser = IAPService.instance.isAdRemovalPurchased; + + return GestureDetector( + onTap: canUndo ? _onUnroll : null, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: canUndo + ? RetroColors.panelBgLight + : RetroColors.panelBg.withValues(alpha: 0.5), + border: Border.all( + color: canUndo ? RetroColors.panelBorderInner : RetroColors.panelBg, + width: 2, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // 무료 유저는 광고 아이콘 표시 + if (!isPaidUser && canUndo) ...[ + const Icon( + Icons.play_circle, + size: 14, + color: RetroColors.gold, + ), + const SizedBox(width: 4), + ], + Icon( + Icons.undo, + size: 14, + color: canUndo ? RetroColors.textLight : RetroColors.textDisabled, + ), + const SizedBox(width: 4), + Text( + l10n.unroll.toUpperCase(), + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 11, + color: canUndo ? RetroColors.textLight : RetroColors.textDisabled, + ), + ), + ], + ), + ), + ); + } + + /// 굴리기 버튼 + Widget _buildRollButton(L10n l10n) { + final canRoll = _rollService.canRoll; + + return GestureDetector( + onTap: _onReroll, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: RetroColors.panelBgLight, + border: Border.all(color: RetroColors.gold, width: 2), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // 0회일 때 광고 아이콘 표시 + if (!canRoll) ...[ + const Icon(Icons.play_circle, size: 14, color: RetroColors.gold), + const SizedBox(width: 4), + ], + const Icon(Icons.casino, size: 14, color: RetroColors.gold), + const SizedBox(width: 4), + Text( + canRoll + ? '${l10n.roll.toUpperCase()} (${_rollService.rollsRemaining})' + : l10n.roll.toUpperCase(), + style: const TextStyle( + fontFamily: 'PressStart2P', + fontSize: 11, + color: RetroColors.gold, + ), + ), + ], + ), + ), + ); + } } diff --git a/lib/src/features/settings/settings_screen.dart b/lib/src/features/settings/settings_screen.dart index 4ec814f..992c0a4 100644 --- a/lib/src/features/settings/settings_screen.dart +++ b/lib/src/features/settings/settings_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart'; 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'; /// 통합 설정 화면 @@ -75,9 +76,13 @@ class SettingsScreen extends StatefulWidget { class _SettingsScreenState extends State { double _bgmVolume = 0.7; double _sfxVolume = 0.8; - double _animationSpeed = 1.0; bool _isLoading = true; + // 디버그 설정 상태 (Phase 8) + bool _debugAdEnabled = true; + bool _debugIapSimulated = false; + int _debugOfflineHours = 0; + @override void initState() { super.initState(); @@ -87,13 +92,20 @@ class _SettingsScreenState extends State { Future _loadSettings() async { final bgm = await widget.settingsRepository.loadBgmVolume(); final sfx = await widget.settingsRepository.loadSfxVolume(); - final speed = await widget.settingsRepository.loadAnimationSpeed(); + + // 디버그 설정 로드 (Phase 8) + final debugSettings = DebugSettingsService.instance; + final adEnabled = debugSettings.adEnabled; + final iapSimulated = debugSettings.iapSimulated; + final offlineHours = debugSettings.offlineHours; if (mounted) { setState(() { _bgmVolume = bgm; _sfxVolume = sfx; - _animationSpeed = speed; + _debugAdEnabled = adEnabled; + _debugIapSimulated = iapSimulated; + _debugOfflineHours = offlineHours; _isLoading = false; }); } @@ -181,17 +193,12 @@ class _SettingsScreenState extends State { ), const SizedBox(height: 24), - // 애니메이션 속도 - _buildSectionTitle(game_l10n.uiAnimationSpeed), - _buildAnimationSpeedSlider(), - const SizedBox(height: 24), - // 정보 _buildSectionTitle(game_l10n.uiAbout), _buildAboutCard(), // 디버그 섹션 (디버그 모드에서만 표시) - if (kDebugMode && widget.onCreateTestCharacter != null) ...[ + if (kDebugMode) ...[ const SizedBox(height: 24), _buildSectionTitle('Debug'), _buildDebugSection(), @@ -205,52 +212,171 @@ class _SettingsScreenState extends State { } Widget _buildDebugSection() { + final theme = Theme.of(context); + final errorColor = theme.colorScheme.error; + return Card( - color: Theme.of( - context, - ).colorScheme.errorContainer.withValues(alpha: 0.3), + 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: Theme.of(context).colorScheme.error, - ), + Icon(Icons.bug_report, color: errorColor), const SizedBox(width: 8), Text( 'Developer Tools', - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: Theme.of(context).colorScheme.error, - ), + style: theme.textTheme.titleSmall?.copyWith(color: errorColor), ), ], ), - const SizedBox(height: 12), - Text( - '현재 캐릭터를 레벨 100으로 수정하여 명예의 전당에 등록합니다. ' - '등록 후 현재 세이브 파일이 삭제됩니다.', - style: Theme.of(context).textTheme.bodySmall, + 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), - SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: _handleCreateTestCharacter, - icon: const Icon(Icons.science), - label: const Text('Create Test Character'), - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.error, - foregroundColor: Theme.of(context).colorScheme.onError, + + // 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 _buildDebugToggle({ + required IconData icon, + required String label, + required String description, + required bool value, + required void Function(bool) onChanged, + }) { + final theme = Theme.of(context); + + return Row( + children: [ + Icon(icon, size: 20, color: theme.colorScheme.onSurface), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: theme.textTheme.bodyMedium), + Text( + description, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.outline, + ), + ), + ], + ), + ), + Switch(value: value, onChanged: onChanged), + ], + ); + } + + /// 오프라인 시간 시뮬레이션 선택기 + Widget _buildOfflineHoursSelector() { + final theme = Theme.of(context); + final options = DebugSettingsService.offlineHoursOptions; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.timer, size: 20, color: theme.colorScheme.onSurface), + const SizedBox(width: 12), + 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, + ), + ), + ], + ), ), ], ), - ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: options.map((hours) { + final isSelected = _debugOfflineHours == hours; + 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); + } + }, + ); + }).toList(), + ), + ], ); } @@ -445,51 +571,6 @@ class _SettingsScreenState extends State { ); } - Widget _buildAnimationSpeedSlider() { - final theme = Theme.of(context); - final speedLabel = switch (_animationSpeed) { - <= 0.6 => game_l10n.uiSpeedSlow, - >= 1.4 => game_l10n.uiSpeedFast, - _ => game_l10n.uiSpeedNormal, - }; - - return Card( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - children: [ - Icon(Icons.speed, color: theme.colorScheme.primary), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(game_l10n.uiAnimationSpeed), - Text(speedLabel), - ], - ), - Slider( - value: _animationSpeed, - min: 0.5, - max: 2.0, - divisions: 6, - onChanged: (value) { - setState(() => _animationSpeed = value); - widget.settingsRepository.saveAnimationSpeed(value); - }, - ), - ], - ), - ), - ], - ), - ), - ); - } - Widget _buildAboutCard() { return Card( child: Padding(