feat(ui): 화면 및 컨트롤러 수익화 연동

- 앱 초기화에 광고/IAP 서비스 추가
- 게임 세션 컨트롤러 수익화 상태 관리
- 캐릭터 생성 화면 굴리기 제한 UI
- 설정 화면 광고 제거 구매 UI
- 애니메이션 패널 개선
This commit is contained in:
JiWoong Sul
2026-01-16 20:10:43 +09:00
parent c95e4de5a4
commit 748160d543
8 changed files with 1288 additions and 373 deletions

View File

@@ -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<List<int>> _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<void> 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<bool> 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');
}
}