From 37c118b0f82a701e89ffcc05149991b2f6bea0d2 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Fri, 16 Jan 2026 20:09:16 +0900 Subject: [PATCH] =?UTF-8?q?feat(character):=20=EC=BA=90=EB=A6=AD=ED=84=B0?= =?UTF-8?q?=20=EB=A1=A4=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 굴리기 횟수 제한 및 충전 - 스탯 히스토리 기반 되돌리기 - 광고 시청으로 굴리기 충전 --- .../core/engine/character_roll_service.dart | 282 ++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 lib/src/core/engine/character_roll_service.dart diff --git a/lib/src/core/engine/character_roll_service.dart b/lib/src/core/engine/character_roll_service.dart new file mode 100644 index 0000000..42f2434 --- /dev/null +++ b/lib/src/core/engine/character_roll_service.dart @@ -0,0 +1,282 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:asciineverdie/src/core/engine/ad_service.dart'; +import 'package:asciineverdie/src/core/engine/iap_service.dart'; +import 'package:asciineverdie/src/core/model/game_state.dart'; + +/// 캐릭터 생성 굴리기/되돌리기 서비스 +/// +/// 굴리기 횟수 제한과 되돌리기 기능을 관리합니다. +/// - 굴리기: 5회 (0회 시 광고 시청으로 충전) +/// - 되돌리기: 무료 유저 1회(광고), 유료 유저 3회(무료) +class CharacterRollService { + CharacterRollService._(); + + static CharacterRollService? _instance; + + /// 싱글톤 인스턴스 + static CharacterRollService get instance { + _instance ??= CharacterRollService._(); + return _instance!; + } + + // =========================================================================== + // 상수 + // =========================================================================== + + /// 저장 키 + static const String _rollsRemainingKey = 'char_rolls_remaining'; + + /// 최대 굴리기 횟수 + static const int maxRolls = 5; + + /// 최대 되돌리기 횟수 (무료 유저) + static const int maxUndoFreeUser = 1; + + /// 최대 되돌리기 횟수 (유료 유저) + static const int maxUndoPaidUser = 3; + + // =========================================================================== + // 상태 + // =========================================================================== + + bool _isInitialized = false; + + /// 남은 굴리기 횟수 + int _rollsRemaining = maxRolls; + + /// 남은 되돌리기 횟수 + int _undoRemaining = maxUndoFreeUser; + + /// 되돌리기용 스탯 히스토리 + final List _rollHistory = []; + + // =========================================================================== + // 초기화 + // =========================================================================== + + /// 서비스 초기화 + Future initialize() async { + if (_isInitialized) return; + + await _loadState(); + _resetUndoForNewSession(); + + _isInitialized = true; + debugPrint('[CharacterRollService] Initialized: ' + 'rolls=$_rollsRemaining, undo=$_undoRemaining'); + } + + /// 저장된 상태 로드 + Future _loadState() async { + final prefs = await SharedPreferences.getInstance(); + _rollsRemaining = prefs.getInt(_rollsRemainingKey) ?? maxRolls; + } + + /// 굴리기 횟수 저장 + Future _saveRollsRemaining() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_rollsRemainingKey, _rollsRemaining); + debugPrint('[CharacterRollService] Saved rolls: $_rollsRemaining'); + } + + /// 새 세션 시작 시 되돌리기 초기화 + void _resetUndoForNewSession() { + _undoRemaining = _isPaidUser ? maxUndoPaidUser : maxUndoFreeUser; + _rollHistory.clear(); + } + + // =========================================================================== + // 유료 사용자 확인 + // =========================================================================== + + /// 유료 사용자 여부 + bool get _isPaidUser => IAPService.instance.isAdRemovalPurchased; + + // =========================================================================== + // 굴리기 + // =========================================================================== + + /// 남은 굴리기 횟수 + int get rollsRemaining => _rollsRemaining; + + /// 굴리기 가능 여부 + bool get canRoll => _rollsRemaining > 0; + + /// 굴리기 실행 + /// + /// [currentStats] 현재 스탯 (되돌리기용 저장) + /// [currentRaceIndex] 현재 종족 인덱스 + /// [currentKlassIndex] 현재 직업 인덱스 + /// [currentSeed] 현재 RNG 시드 + /// Returns: 굴리기 성공 여부 + bool roll({ + required Stats currentStats, + required int currentRaceIndex, + required int currentKlassIndex, + required int currentSeed, + }) { + if (!canRoll) { + debugPrint('[CharacterRollService] Cannot roll: no rolls remaining'); + return false; + } + + // 현재 상태를 히스토리에 저장 + _rollHistory.insert( + 0, + RollSnapshot( + stats: currentStats, + raceIndex: currentRaceIndex, + klassIndex: currentKlassIndex, + seed: currentSeed, + ), + ); + + // 최대 히스토리 개수 제한 (되돌리기 가능 횟수만큼) + final maxHistory = _isPaidUser ? maxUndoPaidUser : maxUndoFreeUser; + while (_rollHistory.length > maxHistory) { + _rollHistory.removeLast(); + } + + // 굴리기 횟수 감소 + _rollsRemaining--; + _saveRollsRemaining(); + + debugPrint('[CharacterRollService] Rolled: remaining=$_rollsRemaining, ' + 'history=${_rollHistory.length}'); + + return true; + } + + /// 광고 시청 후 굴리기 충전 + Future rechargeRollsWithAd() async { + // 유료 사용자는 광고 없이 충전 + if (_isPaidUser) { + _rollsRemaining = maxRolls; + await _saveRollsRemaining(); + debugPrint('[CharacterRollService] Recharged (paid user): $maxRolls'); + return true; + } + + // 인터스티셜 광고 표시 + final result = await AdService.instance.showInterstitialAd( + adType: AdType.interstitialRoll, + onComplete: () { + _rollsRemaining = maxRolls; + _saveRollsRemaining(); + }, + ); + + if (result == AdResult.completed || result == AdResult.debugSkipped) { + debugPrint('[CharacterRollService] Recharged with ad: $maxRolls'); + return true; + } + + debugPrint('[CharacterRollService] Recharge failed: $result'); + return false; + } + + // =========================================================================== + // 되돌리기 + // =========================================================================== + + /// 남은 되돌리기 횟수 + int get undoRemaining => _undoRemaining; + + /// 되돌리기 히스토리 길이 + int get historyLength => _rollHistory.length; + + /// 실제 사용 가능한 되돌리기 횟수 + /// min(undoRemaining, historyLength) + int get availableUndos { + final available = _undoRemaining < _rollHistory.length + ? _undoRemaining + : _rollHistory.length; + return available; + } + + /// 되돌리기 가능 여부 + bool get canUndo => availableUndos > 0; + + /// 되돌리기 실행 (유료 사용자) + /// + /// Returns: 복원된 스냅샷 (null이면 실패) + RollSnapshot? undoPaidUser() { + if (!_isPaidUser) return null; + if (!canUndo) return null; + + final snapshot = _rollHistory.removeAt(0); + _undoRemaining--; + + debugPrint('[CharacterRollService] Undo (paid): ' + 'remaining=$_undoRemaining, history=${_rollHistory.length}'); + + return snapshot; + } + + /// 되돌리기 실행 (무료 사용자 - 광고 필요) + /// + /// [onSuccess] 광고 시청 완료 후 콜백 + Future undoFreeUser() async { + if (_isPaidUser) return undoPaidUser(); + if (!canUndo) return null; + + // 리워드 광고 표시 + RollSnapshot? result; + + final adResult = await AdService.instance.showRewardedAd( + adType: AdType.rewardUndo, + onRewarded: () { + if (_rollHistory.isNotEmpty) { + result = _rollHistory.removeAt(0); + _undoRemaining--; + } + }, + ); + + if (adResult == AdResult.completed || adResult == AdResult.debugSkipped) { + debugPrint('[CharacterRollService] Undo (free with ad): ' + 'remaining=$_undoRemaining, history=${_rollHistory.length}'); + return result; + } + + debugPrint('[CharacterRollService] Undo failed: $adResult'); + return null; + } + + // =========================================================================== + // 캐릭터 생성 완료 + // =========================================================================== + + /// 캐릭터 생성 완료 시 호출 + /// + /// 되돌리기 상태만 초기화 (굴리기 횟수는 유지) + void onCharacterCreated() { + _resetUndoForNewSession(); + debugPrint('[CharacterRollService] Character created, undo reset'); + } + + /// 굴리기 횟수 완전 초기화 (디버그용) + Future resetRolls() async { + _rollsRemaining = maxRolls; + await _saveRollsRemaining(); + _resetUndoForNewSession(); + debugPrint('[CharacterRollService] Reset all'); + } +} + +/// 굴리기 스냅샷 (되돌리기용) +class RollSnapshot { + const RollSnapshot({ + required this.stats, + required this.raceIndex, + required this.klassIndex, + required this.seed, + }); + + final Stats stats; + final int raceIndex; + final int klassIndex; + final int seed; +}