From ffc19c7ca6d2fb69515112ed862a352fd05363bc Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Mon, 19 Jan 2026 15:50:18 +0900 Subject: [PATCH] =?UTF-8?q?refactor(core):=20=ED=95=B5=EC=8B=AC=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - audio_service: 오디오 처리 로직 수정 - ad_service: 광고 서비스 개선 - character_roll_service: 캐릭터 롤 로직 수정 - iap_service: 인앱 결제 로직 개선 - progress_loop: 진행 루프 업데이트 - return_rewards_service: 복귀 보상 로직 개선 - settings_repository: 설정 저장소 수정 --- lib/src/core/audio/audio_service.dart | 12 +- lib/src/core/engine/ad_service.dart | 61 ++++++-- .../core/engine/character_roll_service.dart | 8 +- lib/src/core/engine/iap_service.dart | 12 +- lib/src/core/engine/progress_loop.dart | 8 ++ .../core/engine/return_rewards_service.dart | 133 ++++++++++-------- lib/src/core/storage/settings_repository.dart | 27 +--- 7 files changed, 161 insertions(+), 100 deletions(-) diff --git a/lib/src/core/audio/audio_service.dart b/lib/src/core/audio/audio_service.dart index ae1f20c..0903f1c 100644 --- a/lib/src/core/audio/audio_service.dart +++ b/lib/src/core/audio/audio_service.dart @@ -85,6 +85,9 @@ class AudioService { // 오디오 일시정지 상태 (앱 백그라운드 시) bool _isPaused = false; + // 일시정지 전 재생 중이던 BGM (복귀 시 재개용) + String? _pausedBgm; + // BGM 작업 진행 중 여부 (동시 호출 방지) bool _isBgmBusy = false; @@ -357,17 +360,24 @@ class AudioService { /// 전체 오디오 일시정지 (앱 백그라운드 시) Future pauseAll() async { _isPaused = true; + _pausedBgm = _currentBgm; // 복귀 시 재개를 위해 저장 try { await _staticBgmPlayer?.stop(); } catch (_) {} _currentBgm = null; - debugPrint('[AudioService] All audio paused'); + debugPrint('[AudioService] All audio paused (was playing: $_pausedBgm)'); } /// 전체 오디오 재개 (앱 포그라운드 복귀 시) Future resumeAll() async { _isPaused = false; debugPrint('[AudioService] Audio resumed'); + // 일시정지 전 재생 중이던 BGM 재개 + if (_pausedBgm != null) { + final bgmToResume = _pausedBgm!; + _pausedBgm = null; + await playBgm(bgmToResume); + } } // ───────────────────────────────────────────────────────────────────────── diff --git a/lib/src/core/engine/ad_service.dart b/lib/src/core/engine/ad_service.dart index a9a3135..e5f9c13 100644 --- a/lib/src/core/engine/ad_service.dart +++ b/lib/src/core/engine/ad_service.dart @@ -1,6 +1,8 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:google_mobile_ads/google_mobile_ads.dart'; /// 광고 타입 @@ -228,32 +230,53 @@ class AdService { final ad = _rewardedAd!; _rewardedAd = null; - // 결과 추적용 - var result = AdResult.cancelled; + // Completer를 사용하여 광고 종료까지 대기 + final completer = Completer(); + var rewarded = false; + + // 광고 표시 전 전체 화면 모드로 전환 (상단 UI 숨김) + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); ad.fullScreenContentCallback = FullScreenContentCallback( onAdDismissedFullScreenContent: (ad) { debugPrint('[AdService] Rewarded ad dismissed'); + // 광고 종료 후 UI 복원 + SystemChrome.setEnabledSystemUIMode( + SystemUiMode.manual, + overlays: SystemUiOverlay.values, + ); ad.dispose(); _loadRewardedAd(); // 다음 광고 미리 로드 + // 보상 수령 여부에 따라 결과 반환 + if (!completer.isCompleted) { + completer.complete(rewarded ? AdResult.completed : AdResult.cancelled); + } }, onAdFailedToShowFullScreenContent: (ad, error) { debugPrint('[AdService] Rewarded ad failed to show: ${error.message}'); + // 광고 실패 시에도 UI 복원 + SystemChrome.setEnabledSystemUIMode( + SystemUiMode.manual, + overlays: SystemUiOverlay.values, + ); ad.dispose(); - result = AdResult.failed; _loadRewardedAd(); + if (!completer.isCompleted) { + completer.complete(AdResult.failed); + } }, ); await ad.show( onUserEarnedReward: (ad, reward) { debugPrint('[AdService] User earned reward: ${reward.amount}'); - result = AdResult.completed; + rewarded = true; onRewarded(); }, ); - return result; + // 광고가 종료될 때까지 대기 + return completer.future; } // =========================================================================== @@ -316,30 +339,48 @@ class AdService { final ad = _interstitialAd!; _interstitialAd = null; - // 결과 추적용 - var result = AdResult.cancelled; + // Completer를 사용하여 광고 종료까지 대기 + final completer = Completer(); + + // 광고 표시 전 전체 화면 모드로 전환 (상단 UI 숨김) + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); ad.fullScreenContentCallback = FullScreenContentCallback( onAdDismissedFullScreenContent: (ad) { debugPrint('[AdService] Interstitial ad dismissed'); + // 광고 종료 후 UI 복원 + SystemChrome.setEnabledSystemUIMode( + SystemUiMode.manual, + overlays: SystemUiOverlay.values, + ); ad.dispose(); - result = AdResult.completed; onComplete(); _loadInterstitialAd(); // 다음 광고 미리 로드 + if (!completer.isCompleted) { + completer.complete(AdResult.completed); + } }, onAdFailedToShowFullScreenContent: (ad, error) { debugPrint( '[AdService] Interstitial ad failed to show: ${error.message}', ); + // 광고 실패 시에도 UI 복원 + SystemChrome.setEnabledSystemUIMode( + SystemUiMode.manual, + overlays: SystemUiOverlay.values, + ); ad.dispose(); - result = AdResult.failed; _loadInterstitialAd(); + if (!completer.isCompleted) { + completer.complete(AdResult.failed); + } }, ); await ad.show(); - return result; + // 광고가 종료될 때까지 대기 + return completer.future; } // =========================================================================== diff --git a/lib/src/core/engine/character_roll_service.dart b/lib/src/core/engine/character_roll_service.dart index 42f2434..0380546 100644 --- a/lib/src/core/engine/character_roll_service.dart +++ b/lib/src/core/engine/character_roll_service.dart @@ -143,8 +143,14 @@ class CharacterRollService { _rollsRemaining--; _saveRollsRemaining(); + // 무료 유저: 새 굴리기마다 되돌리기 기회 1회 부여 (광고 시청 필요) + // 유료 유저: 세션당 최대 횟수 유지 + if (!_isPaidUser && _undoRemaining < maxUndoFreeUser) { + _undoRemaining = maxUndoFreeUser; + } + debugPrint('[CharacterRollService] Rolled: remaining=$_rollsRemaining, ' - 'history=${_rollHistory.length}'); + 'history=${_rollHistory.length}, undo=$_undoRemaining'); return true; } diff --git a/lib/src/core/engine/iap_service.dart b/lib/src/core/engine/iap_service.dart index f0caa76..205a833 100644 --- a/lib/src/core/engine/iap_service.dart +++ b/lib/src/core/engine/iap_service.dart @@ -5,6 +5,8 @@ import 'package:flutter/foundation.dart'; import 'package:in_app_purchase/in_app_purchase.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n; + /// IAP 상품 ID class IAPProductIds { IAPProductIds._(); @@ -193,7 +195,15 @@ class IAPService { /// 광고 제거 상품 가격 문자열 String get removeAdsPrice { - return _removeAdsProduct?.price ?? '\$9.99'; + if (_removeAdsProduct != null) { + return _removeAdsProduct!.price; + } + // 스토어 미연결 시 로케일별 대체 가격 + return switch (game_l10n.currentGameLocale) { + 'ko' => '₩9,900', + 'ja' => '¥990', + _ => '\$9.99', + }; } // =========================================================================== diff --git a/lib/src/core/engine/progress_loop.dart b/lib/src/core/engine/progress_loop.dart index 036f32a..0d125a0 100644 --- a/lib/src/core/engine/progress_loop.dart +++ b/lib/src/core/engine/progress_loop.dart @@ -89,6 +89,14 @@ class ProgressLoop { _speedMultiplier = _availableSpeeds[nextIndex]; } + /// 특정 배속으로 직접 설정 + /// 가용 배속 목록에 있는 경우에만 설정 + void setSpeed(int speed) { + if (_availableSpeeds.contains(speed)) { + _speedMultiplier = speed; + } + } + /// 가용 배속 목록 업데이트 (명예의 전당 상태 변경 시) void updateAvailableSpeeds(List speeds) { if (speeds.isEmpty) return; diff --git a/lib/src/core/engine/return_rewards_service.dart b/lib/src/core/engine/return_rewards_service.dart index 51c6014..92a50aa 100644 --- a/lib/src/core/engine/return_rewards_service.dart +++ b/lib/src/core/engine/return_rewards_service.dart @@ -1,41 +1,19 @@ import 'package:flutter/foundation.dart'; import 'package:asciineverdie/src/core/engine/ad_service.dart'; +import 'package:asciineverdie/src/core/engine/chest_service.dart'; import 'package:asciineverdie/src/core/engine/iap_service.dart'; - -/// 복귀 보상 데이터 (Phase 7) -class ReturnReward { - const ReturnReward({ - required this.hoursAway, - required this.goldReward, - required this.bonusGold, - }); - - /// 떠나있던 시간 (시간 단위) - final int hoursAway; - - /// 기본 골드 보상 - final int goldReward; - - /// 보너스 골드 (광고 시청 시 추가) - final int bonusGold; - - /// 총 보상 (광고 포함) - int get totalGold => goldReward + bonusGold; - - /// 보상이 있는지 여부 - bool get hasReward => goldReward > 0; -} +import 'package:asciineverdie/src/core/model/treasure_chest.dart'; /// 복귀 보상 서비스 (Phase 7) /// -/// 플레이어가 게임에 복귀했을 때 떠나있던 시간에 따라 보상을 제공합니다. +/// 플레이어가 게임에 복귀했을 때 떠나있던 시간에 따라 보물 상자를 제공합니다. /// - 최소 복귀 시간: 1시간 /// - 최대 복귀 시간: 24시간 (그 이상은 24시간으로 계산) -/// - 기본 보상: 시간당 100골드 -/// - 보너스 보상: 광고 시청 시 2배 +/// - 기본 보상: 4시간당 1상자 +/// - 보너스 보상: 광고 시청 시 상자 2배 class ReturnRewardsService { - ReturnRewardsService._(); + ReturnRewardsService._() : _chestService = ChestService(); static ReturnRewardsService? _instance; @@ -45,6 +23,8 @@ class ReturnRewardsService { return _instance!; } + final ChestService _chestService; + // =========================================================================== // 상수 // =========================================================================== @@ -55,11 +35,14 @@ class ReturnRewardsService { /// 최대 복귀 시간 (시간) - 이 이상은 동일 보상 static const int maxHoursAway = 24; - /// 시간당 골드 보상 - static const int goldPerHour = 100; + /// 상자 1개당 필요 시간 (시간) + static const int hoursPerChest = 4; - /// 레벨당 골드 보상 배수 - static const double goldPerLevelMultiplier = 0.1; + /// 최대 상자 개수 (무료 유저) + static const int maxChestsFree = 5; + + /// 최대 상자 개수 (유료 유저) + static const int maxChestsPaid = 10; // =========================================================================== // 보상 계산 @@ -69,17 +52,21 @@ class ReturnRewardsService { /// /// [lastPlayTime] 마지막 플레이 시각 (null이면 보상 없음) /// [currentTime] 현재 시각 - /// [playerLevel] 플레이어 레벨 (레벨 보너스 계산용) + /// [isPaidUser] 유료 유저 여부 (최대 상자 개수 결정) /// Returns: 복귀 보상 데이터 (시간이 부족하면 hasReward = false) - ReturnReward calculateReward({ + ReturnChestReward calculateReward({ required DateTime? lastPlayTime, required DateTime currentTime, - required int playerLevel, + required bool isPaidUser, }) { // 마지막 플레이 시간이 없으면 보상 없음 if (lastPlayTime == null) { debugPrint('[ReturnRewards] No lastPlayTime, no reward'); - return const ReturnReward(hoursAway: 0, goldReward: 0, bonusGold: 0); + return const ReturnChestReward( + hoursAway: 0, + chestCount: 0, + bonusChestCount: 0, + ); } // 경과 시간 계산 @@ -89,29 +76,46 @@ class ReturnRewardsService { // 최소 시간 미만이면 보상 없음 if (hoursAway < minHoursAway) { debugPrint('[ReturnRewards] Only $hoursAway hours, need $minHoursAway'); - return ReturnReward(hoursAway: hoursAway, goldReward: 0, bonusGold: 0); + return ReturnChestReward( + hoursAway: hoursAway, + chestCount: 0, + bonusChestCount: 0, + ); } // 최대 시간 초과 시 최대로 제한 final effectiveHours = hoursAway > maxHoursAway ? maxHoursAway : hoursAway; - // 골드 보상 계산 (레벨 보너스 포함) - final levelMultiplier = 1.0 + (playerLevel * goldPerLevelMultiplier); - final baseGold = (effectiveHours * goldPerHour * levelMultiplier).round(); + // 상자 개수 계산 + final maxChests = isPaidUser ? maxChestsPaid : maxChestsFree; + final rawChestCount = effectiveHours ~/ hoursPerChest; + final chestCount = rawChestCount.clamp(0, maxChests); - // 보너스 골드 (광고 시청 시 100% 추가) - final bonusGold = baseGold; + // 보너스 상자 (광고 시청 시 동일 개수 추가) + final bonusChestCount = chestCount; debugPrint('[ReturnRewards] $hoursAway hours away, ' - 'base=$baseGold, bonus=$bonusGold, level=$playerLevel'); + 'chests=$chestCount, bonus=$bonusChestCount, paid=$isPaidUser'); - return ReturnReward( + return ReturnChestReward( hoursAway: hoursAway, - goldReward: baseGold, - bonusGold: bonusGold, + chestCount: chestCount, + bonusChestCount: bonusChestCount, ); } + // =========================================================================== + // 상자 오픈 + // =========================================================================== + + /// 상자 오픈하여 보상 생성 + /// + /// [count] 오픈할 상자 개수 + /// [playerLevel] 플레이어 레벨 (보상 스케일링용) + List openChests(int count, int playerLevel) { + return _chestService.openMultipleChests(count, playerLevel); + } + // =========================================================================== // 보상 수령 // =========================================================================== @@ -119,11 +123,12 @@ class ReturnRewardsService { /// 기본 보상 수령 (광고 없이) /// /// [reward] 복귀 보상 데이터 - /// Returns: 수령한 골드 양 - int claimBasicReward(ReturnReward reward) { - if (!reward.hasReward) return 0; - debugPrint('[ReturnRewards] Basic reward claimed: ${reward.goldReward}'); - return reward.goldReward; + /// [playerLevel] 플레이어 레벨 + /// Returns: 오픈된 상자 보상 목록 + List claimBasicReward(ReturnChestReward reward, int playerLevel) { + if (!reward.hasReward) return []; + debugPrint('[ReturnRewards] Basic reward claimed: ${reward.chestCount} chests'); + return openChests(reward.chestCount, playerLevel); } /// 보너스 보상 수령 (광고 시청 후) @@ -131,32 +136,38 @@ class ReturnRewardsService { /// 유료 유저: 무료 보너스 /// 무료 유저: 리워드 광고 시청 후 보너스 /// [reward] 복귀 보상 데이터 - /// Returns: 수령한 보너스 골드 양 (광고 실패 시 0) - Future claimBonusReward(ReturnReward reward) async { - if (!reward.hasReward || reward.bonusGold <= 0) return 0; + /// [playerLevel] 플레이어 레벨 + /// Returns: 오픈된 보너스 상자 보상 목록 (광고 실패 시 빈 목록) + Future> claimBonusReward( + ReturnChestReward reward, + int playerLevel, + ) async { + if (!reward.hasReward || reward.bonusChestCount <= 0) return []; // 유료 유저는 무료 보너스 if (IAPService.instance.isAdRemovalPurchased) { - debugPrint('[ReturnRewards] Bonus claimed (paid user): ${reward.bonusGold}'); - return reward.bonusGold; + debugPrint('[ReturnRewards] Bonus claimed (paid user): ' + '${reward.bonusChestCount} chests'); + return openChests(reward.bonusChestCount, playerLevel); } // 무료 유저는 리워드 광고 필요 - int bonus = 0; + List bonusRewards = []; final adResult = await AdService.instance.showRewardedAd( adType: AdType.rewardRevive, // 복귀 보상용 리워드 광고 onRewarded: () { - bonus = reward.bonusGold; + bonusRewards = openChests(reward.bonusChestCount, playerLevel); }, ); if (adResult == AdResult.completed || adResult == AdResult.debugSkipped) { - debugPrint('[ReturnRewards] Bonus claimed (free user with ad): $bonus'); - return bonus; + debugPrint('[ReturnRewards] Bonus claimed (free user with ad): ' + '${bonusRewards.length} chests'); + return bonusRewards; } debugPrint('[ReturnRewards] Bonus claim failed: $adResult'); - return 0; + return []; } // =========================================================================== diff --git a/lib/src/core/storage/settings_repository.dart b/lib/src/core/storage/settings_repository.dart index d1bf935..d36549b 100644 --- a/lib/src/core/storage/settings_repository.dart +++ b/lib/src/core/storage/settings_repository.dart @@ -1,11 +1,9 @@ -import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; /// 앱 설정 저장소 (SharedPreferences 기반) /// -/// 테마, 언어, 사운드 등 사용자 설정을 로컬에 저장 +/// 언어, 사운드 등 사용자 설정을 로컬에 저장 class SettingsRepository { - static const _keyThemeMode = 'theme_mode'; static const _keyLocale = 'locale'; static const _keyBgmVolume = 'bgm_volume'; static const _keySfxVolume = 'sfx_volume'; @@ -18,29 +16,6 @@ class SettingsRepository { _prefs ??= await SharedPreferences.getInstance(); } - /// 테마 모드 저장 - Future saveThemeMode(ThemeMode mode) async { - await init(); - final value = switch (mode) { - ThemeMode.light => 'light', - ThemeMode.dark => 'dark', - ThemeMode.system => 'system', - }; - await _prefs!.setString(_keyThemeMode, value); - } - - /// 테마 모드 불러오기 - Future loadThemeMode() async { - await init(); - final value = _prefs!.getString(_keyThemeMode); - return switch (value) { - 'light' => ThemeMode.light, - 'dark' => ThemeMode.dark, - 'system' => ThemeMode.system, - _ => ThemeMode.system, // 기본값 - }; - } - /// 언어 설정 저장 Future saveLocale(String locale) async { await init();