From 7b9f1f87a692e3601b0dbfbde597f61e173d1c7a Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Tue, 20 Jan 2026 18:13:40 +0900 Subject: [PATCH] =?UTF-8?q?fix(monetization):=20=EB=B2=84=ED=94=84=20?= =?UTF-8?q?=EC=A2=85=EB=A3=8C=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?(=EA=B2=8C=EC=9E=84=20=EC=8B=9C=EA=B0=84=20=EA=B8=B0=EC=A4=80?= =?UTF-8?q?=20=ED=86=B5=EC=9D=BC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 배속 부스트: 실시간 타이머 → 게임 시간(elapsedMs) 기준 종료 - 자동부활 버프: 만료 시 autoReviveEndMs null 초기화 추가 - 매 틱마다 _checkSpeedBoostExpiry(), _checkAutoReviveExpiry() 호출 - 광고 직후 앱 resume 시 reload 방지 (isRecentlyShowedAd) - 앱 pause/reload와 무관하게 버프 정상 종료 --- lib/src/features/game/game_play_screen.dart | 8 +- .../game/game_session_controller.dart | 142 ++++++++++++------ 2 files changed, 103 insertions(+), 47 deletions(-) diff --git a/lib/src/features/game/game_play_screen.dart b/lib/src/features/game/game_play_screen.dart index 890112e..acae2e6 100644 --- a/lib/src/features/game/game_play_screen.dart +++ b/lib/src/features/game/game_play_screen.dart @@ -289,11 +289,15 @@ class _GamePlayScreenState extends State } // 모바일: 앱이 포그라운드로 돌아올 때 전체 재로드 - // (광고 표시 중에는 reload 건너뛰기 - 배속 부스트 등 상태 유지) + // (광고 표시 중 또는 최근 광고 시청 후에는 reload 건너뛰기) if (appState == AppLifecycleState.resumed && isMobile) { _audioController.resumeAll(); - if (!widget.controller.isShowingAd) { + if (!widget.controller.isShowingAd && + !widget.controller.isRecentlyShowedAd) { _reloadGameScreen(); + } else { + // 광고 직후: 게임 재개만 (reload 없이 상태 유지) + widget.controller.resume(); } } } diff --git a/lib/src/features/game/game_session_controller.dart b/lib/src/features/game/game_session_controller.dart index d0e8d51..5dd8e03 100644 --- a/lib/src/features/game/game_session_controller.dart +++ b/lib/src/features/game/game_session_controller.dart @@ -61,13 +61,13 @@ class GameSessionController extends ChangeNotifier { bool _autoResurrect = false; // 속도 부스트 상태 (Phase 6) + // 실시간 타이머 대신 게임 시간(elapsedMs) 기준으로 종료 판정 bool _isSpeedBoostActive = false; - Timer? _speedBoostTimer; - int _speedBoostRemainingSeconds = 0; - static const int _speedBoostDuration = 300; // 5분 + static const int _speedBoostDuration = 300; // 5분 (게임 시간 기준) // 광고 표시 중 플래그 (lifecycle reload 방지용) bool _isShowingAd = false; + int _adEndTimeMs = 0; // 광고 종료 시점 (밀리초) /// 광고 배속 배율 (릴리즈: 5x, 디버그빌드+디버그모드: 20x) int get _speedBoostMultiplier => (kDebugMode && _cheatsEnabled) ? 20 : 5; @@ -153,18 +153,31 @@ class GameSessionController extends ChangeNotifier { } _initPreviousValues(state); - // 명예의 전당 체크 → 가용 배속 결정 - final availableSpeeds = await _getAvailableSpeeds(); + // 명예의 전당 체크 → 기본 가용 배속 결정 + final baseAvailableSpeeds = await _getAvailableSpeeds(); + final hasHallOfFame = baseAvailableSpeeds.contains(2); - // 명예의 전당 해금 시 기본 2배속, 아니면 1배속 - final hasHallOfFame = availableSpeeds.contains(2); - // 새 게임이면 기본 배속, 세이브 로드 시 명예의 전당 해금 시 최소 2배속 보장 - final int initialSpeed; + // 기본 배속 결정 (부스트 미적용 시) + final int baseSpeed; if (isNewGame) { - initialSpeed = hasHallOfFame ? 2 : 1; + baseSpeed = hasHallOfFame ? 2 : 1; } else { - // 세이브 로드: 명예의 전당 해금 시 최소 2배속 - initialSpeed = (hasHallOfFame && previousSpeed < 2) ? 2 : previousSpeed; + baseSpeed = (hasHallOfFame && previousSpeed < 2) ? 2 : previousSpeed; + } + + // 배속 부스트 활성화 상태면 부스트 배속 적용, 아니면 기본 배속 + final List finalAvailableSpeeds; + final int finalInitialSpeed; + + if (_isSpeedBoostActive) { + // 부스트 상태: 부스트 배속만 사용, 기본 배속 저장 + finalAvailableSpeeds = [_speedBoostMultiplier]; + finalInitialSpeed = _speedBoostMultiplier; + _savedSpeedMultiplier = baseSpeed; // 종료 시 복원할 배속 저장 + } else { + // 일반 상태: 기본 배속 사용 + finalAvailableSpeeds = baseAvailableSpeeds; + finalInitialSpeed = baseSpeed; } _loop = ProgressLoop( @@ -177,11 +190,15 @@ class GameSessionController extends ChangeNotifier { cheatsEnabled: cheatsEnabled, onPlayerDied: _onPlayerDied, onGameComplete: _onGameComplete, - availableSpeeds: availableSpeeds, - initialSpeedMultiplier: initialSpeed, + availableSpeeds: finalAvailableSpeeds, + initialSpeedMultiplier: finalInitialSpeed, ); _subscription = _loop!.stream.listen((next) { + final elapsedMs = next.skillSystem.elapsedMs; + // 버프 만료 체크 (게임 시간 기준) + _checkSpeedBoostExpiry(elapsedMs); + _checkAutoReviveExpiry(elapsedMs); _updateStatistics(next); _state = next; notifyListeners(); @@ -307,6 +324,7 @@ class GameSessionController extends ChangeNotifier { /// 일시 정지 상태에서 재개 Future resume() async { if (_state == null || _status != GameSessionStatus.idle) return; + // 배속 부스트 상태는 startNew() 내에서 자동 처리됨 await startNew(_state!, cheatsEnabled: _cheatsEnabled, isNewGame: false); } @@ -607,8 +625,22 @@ class GameSessionController extends ChangeNotifier { /// 광고 표시 중 여부 (lifecycle reload 방지용) bool get isShowingAd => _isShowingAd; - /// 속도 부스트 남은 시간 (초) - int get speedBoostRemainingSeconds => _speedBoostRemainingSeconds; + /// 최근 광고를 시청했는지 여부 (1초 이내) + /// 광고 종료 후 resumed 이벤트가 늦게 발생하는 경우를 처리 + bool get isRecentlyShowedAd { + if (_adEndTimeMs == 0) return false; + return DateTime.now().millisecondsSinceEpoch - _adEndTimeMs < 1000; + } + + /// 속도 부스트 남은 시간 (초) - 게임 시간(elapsedMs) 기준 계산 + int get speedBoostRemainingSeconds { + if (!_isSpeedBoostActive) return 0; + final endMs = _monetization.speedBoostEndMs; + if (endMs == null) return 0; + final currentMs = _state?.skillSystem.elapsedMs ?? 0; + final remainingMs = endMs - currentMs; + return remainingMs > 0 ? (remainingMs / 1000).ceil() : 0; + } /// 속도 부스트 배율 int get speedBoostMultiplier => _speedBoostMultiplier; @@ -651,6 +683,7 @@ class GameSessionController extends ChangeNotifier { ); _isShowingAd = false; // 광고 표시 종료 + _adEndTimeMs = DateTime.now().millisecondsSinceEpoch; // 종료 시점 기록 if (adResult == AdResult.completed || adResult == AdResult.debugSkipped) { debugPrint('[GameSession] Speed boost activated (free user with ad)'); @@ -662,53 +695,72 @@ class GameSessionController extends ChangeNotifier { } /// 속도 부스트 시작 (내부) + /// + /// 게임 시간(elapsedMs) 기준으로 종료 시점 설정. + /// 실시간 타이머 대신 매 틱에서 종료 여부 체크. void _startSpeedBoost() { - if (_loop == null) return; - - // 현재 배속 저장 - _savedSpeedMultiplier = _loop!.speedMultiplier; - - // 부스트 배속 적용 _isSpeedBoostActive = true; - _speedBoostRemainingSeconds = _speedBoostDuration; - // monetization 상태에 종료 시점 저장 (UI 표시용) + // loop가 있으면 현재 배속 저장 및 즉시 적용 + if (_loop != null) { + _savedSpeedMultiplier = _loop!.speedMultiplier; + _loop!.updateAvailableSpeeds([_speedBoostMultiplier]); + } + + // 종료 시점 저장 (게임 시간 기준) 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(); - } - }); - + debugPrint('[GameSession] Speed boost started, ends at $endMs ms'); notifyListeners(); } + /// 매 틱마다 부스트 만료 체크 + /// + /// 게임 시간(elapsedMs)이 종료 시점을 넘으면 자동 종료. + void _checkSpeedBoostExpiry(int elapsedMs) { + if (!_isSpeedBoostActive) return; + + final endMs = _monetization.speedBoostEndMs; + if (endMs != null && elapsedMs >= endMs) { + _endSpeedBoost(); + } + } + + /// 매 틱마다 자동부활 버프 만료 체크 + /// + /// 게임 시간(elapsedMs)이 종료 시점을 넘으면 버프 상태 초기화. + void _checkAutoReviveExpiry(int elapsedMs) { + final endMs = _monetization.autoReviveEndMs; + if (endMs == null) return; + + if (elapsedMs >= endMs) { + _monetization = _monetization.copyWith(autoReviveEndMs: null); + debugPrint('[GameSession] Auto-revive buff expired'); + notifyListeners(); + } + } + /// 속도 부스트 종료 (내부) void _endSpeedBoost() { - _speedBoostTimer?.cancel(); - _speedBoostTimer = null; _isSpeedBoostActive = false; - _speedBoostRemainingSeconds = 0; - // monetization 상태 초기화 (UI 표시 제거) + // monetization 상태 초기화 _monetization = _monetization.copyWith(speedBoostEndMs: null); // 원래 배속 복원 - if (_loop != null) { + final currentLoop = _loop; + final savedSpeed = _savedSpeedMultiplier; + + if (currentLoop != null) { _getAvailableSpeeds().then((speeds) { - _loop!.updateAvailableSpeeds(speeds); - _loop!.setSpeed(_savedSpeedMultiplier); + // 콜백 실행 시점에 loop가 변경되지 않았는지 확인 + if (_loop == currentLoop) { + currentLoop.updateAvailableSpeeds(speeds); + currentLoop.setSpeed(savedSpeed); + debugPrint('[GameSession] Speed restored to ${savedSpeed}x'); + } }); }