Compare commits
12 Commits
306715ca26
...
d41dd0fb90
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d41dd0fb90 | ||
|
|
9f077d74a1 | ||
|
|
748160d543 | ||
|
|
c95e4de5a4 | ||
|
|
c95fb7f4b4 | ||
|
|
b6d5cd2abd | ||
|
|
b272ef8f08 | ||
|
|
37c118b0f8 | ||
|
|
28d3e53bab | ||
|
|
77f3f1d46b | ||
|
|
6662a5dcfb | ||
|
|
724f08f56d |
@@ -1,4 +1,7 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- IAP 결제 권한 -->
|
||||||
|
<uses-permission android:name="com.android.vending.BILLING" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="asciineverdie"
|
android:label="asciineverdie"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
@@ -29,6 +32,10 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<!-- AdMob App ID -->
|
||||||
|
<meta-data
|
||||||
|
android:name="com.google.android.gms.ads.APPLICATION_ID"
|
||||||
|
android:value="ca-app-pub-6691216385521068~8216990571"/>
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
<meta-data
|
<meta-data
|
||||||
|
|||||||
1
docs/app-ads.txt
Normal file
1
docs/app-ads.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
google.com, pub-6691216385521068, DIRECT, f08c47fec0942fa0
|
||||||
395
docs/plan_monetization_system.md
Normal file
395
docs/plan_monetization_system.md
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
# 수익화 시스템 설계
|
||||||
|
|
||||||
|
## 메타 정보
|
||||||
|
|
||||||
|
- **문서 버전**: 1.0
|
||||||
|
- **최종 수정**: 2026-01-16
|
||||||
|
- **상태**: 계획 단계
|
||||||
|
- **플랫폼**: Android/iOS (모바일 전용)
|
||||||
|
- **광고 SDK**: AdMob
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 리스트
|
||||||
|
|
||||||
|
### Phase 1: 데이터 구조 (선행 작업)
|
||||||
|
|
||||||
|
- [ ] `MonetizationState` 모델 생성
|
||||||
|
- [ ] `DeathInfo.lostItem: EquipmentItem?` 필드 추가
|
||||||
|
- [ ] `GameSave` v3 → v4 마이그레이션
|
||||||
|
- [ ] 장비 손실 확률 공식 변경: `20% + (level-1) * 8.89%`
|
||||||
|
- [ ] `ItemService.determineRarity()` 확률 수정: 34/40/20/5/1
|
||||||
|
|
||||||
|
### Phase 2: AdMob 연동
|
||||||
|
|
||||||
|
- [ ] `google_mobile_ads` 패키지 추가
|
||||||
|
- [ ] `AdService` 클래스 생성
|
||||||
|
- [ ] 리워드 광고 로드/표시/콜백 구현
|
||||||
|
- [ ] 인터스티셜 광고 로드/표시/콜백 구현
|
||||||
|
- [ ] 디버그 빌드 광고 ON/OFF 토글 구현
|
||||||
|
|
||||||
|
### Phase 3: IAP 연동
|
||||||
|
|
||||||
|
- [ ] `in_app_purchase` 패키지 추가
|
||||||
|
- [ ] 광고 제거 상품 등록 (Google Play / App Store)
|
||||||
|
- [ ] `IAPService` 클래스 생성
|
||||||
|
- [ ] 구매 처리 로직 구현
|
||||||
|
- [ ] 구매 복원 로직 구현
|
||||||
|
- [ ] 구매 상태 영구 저장
|
||||||
|
|
||||||
|
### Phase 4: 캐릭터 생성 광고
|
||||||
|
|
||||||
|
- [ ] 굴리기 횟수 상태 관리 (rollsRemaining)
|
||||||
|
- [ ] 굴리기 횟수 저장/로드 구현
|
||||||
|
- [ ] 되돌리기 히스토리 관리
|
||||||
|
- [ ] 되돌리기 횟수 상태 관리 (undoRemaining)
|
||||||
|
- [ ] 캐릭터 생성 UI 수정: 굴리기 버튼
|
||||||
|
- [ ] 캐릭터 생성 UI 수정: 되돌리기 버튼
|
||||||
|
|
||||||
|
### Phase 5: 부활 시스템
|
||||||
|
|
||||||
|
- [ ] 사망 시 `DeathInfo.lostItem` 저장 로직
|
||||||
|
- [ ] `reviveWithAdReward()` 함수 구현
|
||||||
|
- [ ] `reviveWithoutAd()` 함수 구현
|
||||||
|
- [ ] 자동부활 버프 상태 관리 (autoReviveEndMs)
|
||||||
|
- [ ] 자동부활 버프 중 사망 처리 로직
|
||||||
|
- [ ] 사망 화면 UI: 광고 부활 버튼
|
||||||
|
- [ ] 사망 화면 UI: 일반 부활 버튼
|
||||||
|
|
||||||
|
### Phase 6: 속도업
|
||||||
|
|
||||||
|
- [ ] 속도 상태 관리 (1x/2x/5x)
|
||||||
|
- [ ] 명예의 전당 캐릭터 존재 여부 확인 로직
|
||||||
|
- [ ] 5배속 버프 상태 관리 (speedBoostEndMs)
|
||||||
|
- [ ] 속도 UI: 버튼 표시 로직
|
||||||
|
- [ ] 속도 UI: 남은 시간 표시
|
||||||
|
|
||||||
|
### Phase 7: 복귀 보상
|
||||||
|
|
||||||
|
- [ ] `lastPlayTime` 저장/로드 구현
|
||||||
|
- [ ] 오프라인 시간 계산 로직
|
||||||
|
- [ ] 오프라인 진행 시뮬레이션 (1배/2배)
|
||||||
|
- [ ] 보물 상자 축적 로직
|
||||||
|
- [ ] 보물 상자 내용물 생성 로직
|
||||||
|
- [ ] 행운의 부적 버프 발동 로직
|
||||||
|
- [ ] 복귀 보상 UI: 환영 다이얼로그
|
||||||
|
- [ ] 복귀 보상 UI: 상자 오픈
|
||||||
|
|
||||||
|
### Phase 8: 디버그 기능
|
||||||
|
|
||||||
|
- [ ] 메인 메뉴: 디버그 옵션 섹션 (kDebugMode)
|
||||||
|
- [ ] 스타트 화면: 디버그 옵션 섹션
|
||||||
|
- [ ] 광고 ON/OFF 토글
|
||||||
|
- [ ] IAP 구매 시뮬레이션 토글
|
||||||
|
- [ ] 오프라인 시간 시뮬레이션
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 스펙 요약
|
||||||
|
|
||||||
|
### 광고 유형
|
||||||
|
|
||||||
|
| ID | 유형 | 길이 | 사용처 |
|
||||||
|
| -- | ---- | ---- | ------ |
|
||||||
|
| AD_REWARD_REVIVE | 리워드 | 30초 | 부활 |
|
||||||
|
| AD_REWARD_UNDO | 리워드 | 30초 | 캐릭터 생성 되돌리기 |
|
||||||
|
| AD_INTERSTITIAL_ROLL | 인터스티셜 | 6초 | 굴리기 횟수 충전 |
|
||||||
|
| AD_INTERSTITIAL_SPEED | 인터스티셜 | 6초 | 게임 속도업 |
|
||||||
|
|
||||||
|
### IAP 상품
|
||||||
|
|
||||||
|
| ID | 가격 | 유형 |
|
||||||
|
| -- | ---- | ---- |
|
||||||
|
| remove_ads | $9.99 | 비소모성 (1회 구매) |
|
||||||
|
|
||||||
|
### 무료 vs 구매 유저 비교
|
||||||
|
|
||||||
|
| 기능 | 무료 유저 | 구매 유저 |
|
||||||
|
| ---- | --------- | --------- |
|
||||||
|
| 광고 | 표시 | 제거 (버튼 클릭 시 바로 활성화) |
|
||||||
|
| 복귀 상자 최대 | 5개 | 10개 |
|
||||||
|
| 오프라인 진행 속도 | 1배 | 2배 |
|
||||||
|
| 행운 버프 발동 | 1시간당 5분 | 30분당 5분 |
|
||||||
|
| 캐릭터 되돌리기 | 1회 (광고) | 3회 (무료) |
|
||||||
|
| 굴리기 충전 | 광고 필요 | 무제한 |
|
||||||
|
| 속도업 | 광고 필요 | 무제한 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 상세 스펙
|
||||||
|
|
||||||
|
### 1. 캐릭터 생성 - 굴리기
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
rollsRemaining:
|
||||||
|
default: 5
|
||||||
|
min: 0
|
||||||
|
max: 5
|
||||||
|
recharge: +5 (인터스티셜 광고 시청 후)
|
||||||
|
persistence: 저장됨 (0회로 종료 시 재시작해도 0회)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 캐릭터 생성 - 되돌리기
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
undoRemaining:
|
||||||
|
free_user: 1
|
||||||
|
paid_user: 3
|
||||||
|
ad_required:
|
||||||
|
free_user: true (30초 리워드)
|
||||||
|
paid_user: false
|
||||||
|
range: 1단계 전만
|
||||||
|
reset: 새로 굴리기 시작 시 초기화
|
||||||
|
constraint: min(undoRemaining, rollHistory.length)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 장비 손실 확률
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
formula: 20 + (level - 1) * 80 / 9
|
||||||
|
level_1: 20%
|
||||||
|
level_5: 56%
|
||||||
|
level_10_plus: 100%
|
||||||
|
code: |
|
||||||
|
int calculateEquipmentLossChance(int level) {
|
||||||
|
if (level >= 10) return 100;
|
||||||
|
return 20 + ((level - 1) * 80 ~/ 9);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 부활 시스템
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
normal_revive:
|
||||||
|
ad: false
|
||||||
|
hp_recovery: 50%
|
||||||
|
equipment_loss: confirmed
|
||||||
|
sacrifice: required
|
||||||
|
|
||||||
|
ad_revive:
|
||||||
|
ad: true (30초 리워드)
|
||||||
|
hp_recovery: 100%
|
||||||
|
equipment_loss: cancelled (lostItem 복구)
|
||||||
|
sacrifice: none
|
||||||
|
auto_revive_buff:
|
||||||
|
duration: 600000ms (10분)
|
||||||
|
effect: 버프 중 사망 시 자동부활 + 장비 손실 없음
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 게임 속도
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
speed_levels:
|
||||||
|
- 1x: 기본
|
||||||
|
- 2x: 명예의 전당 캐릭터 1명 이상 시 해금
|
||||||
|
- 5x: 광고 시청 (5분간) 또는 IAP 구매 시 무제한
|
||||||
|
|
||||||
|
speed_boost:
|
||||||
|
duration: 300000ms (5분)
|
||||||
|
ad: 인터스티셜 6초
|
||||||
|
paid_user: 광고 없이 무제한
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 복귀 보상
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
offline_progress:
|
||||||
|
free_user: 1x
|
||||||
|
paid_user: 2x
|
||||||
|
|
||||||
|
treasure_chest:
|
||||||
|
rate: 1개/시간
|
||||||
|
max_free: 5개
|
||||||
|
max_paid: 10개
|
||||||
|
contents:
|
||||||
|
equipment: 50%
|
||||||
|
gold: 30%
|
||||||
|
potion: 15%
|
||||||
|
bonus_equipment: 5%
|
||||||
|
|
||||||
|
lucky_charm_buff:
|
||||||
|
free_user: 5분/오프라인1시간
|
||||||
|
paid_user: 5분/오프라인30분
|
||||||
|
max_duration: 30분
|
||||||
|
effect:
|
||||||
|
common: 34% → 28%
|
||||||
|
uncommon: 40% → 40%
|
||||||
|
rare: 20% → 20%
|
||||||
|
epic: 5% → 10%
|
||||||
|
legendary: 1% → 2%
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. 아이템 희귀도 (목표)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
rarity_distribution:
|
||||||
|
common: 34%
|
||||||
|
uncommon: 40%
|
||||||
|
rare: 20%
|
||||||
|
epic: 5%
|
||||||
|
legendary: 1%
|
||||||
|
note: 현재 코드와 다름. ItemService.determineRarity() 수정 필요
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 데이터 모델
|
||||||
|
|
||||||
|
### MonetizationState
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class MonetizationState {
|
||||||
|
final bool adRemovalPurchased; // IAP 구매 여부
|
||||||
|
final int rollsRemaining; // 굴리기 남은 횟수 (0-5)
|
||||||
|
final int undoRemaining; // 되돌리기 남은 횟수
|
||||||
|
final List<Stats>? rollHistory; // 되돌리기용 히스토리
|
||||||
|
final int? autoReviveEndMs; // 자동부활 버프 종료 시점
|
||||||
|
final int? speedBoostEndMs; // 5배속 종료 시점
|
||||||
|
final DateTime? lastPlayTime; // 마지막 플레이 시각
|
||||||
|
final int pendingChests; // 미개봉 상자 개수
|
||||||
|
final int? luckyCharmEndMs; // 행운 버프 종료 시점
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### DeathInfo 확장
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class DeathInfo {
|
||||||
|
// 기존 필드
|
||||||
|
final String? lostItemName;
|
||||||
|
final EquipmentSlot? lostItemSlot;
|
||||||
|
final ItemRarity? lostItemRarity;
|
||||||
|
|
||||||
|
// 신규 필드
|
||||||
|
final EquipmentItem? lostItem; // 복구용 전체 장비 정보
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GameSave 버전
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
current_version: 3
|
||||||
|
next_version: 4
|
||||||
|
migration:
|
||||||
|
- add: MonetizationState monetization
|
||||||
|
- add: DeathInfo.lostItem
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 엣지 케이스
|
||||||
|
|
||||||
|
| 케이스 | 조건 | 처리 |
|
||||||
|
| ------ | ---- | ---- |
|
||||||
|
| 광고 로드 실패 | 네트워크 오류 | 재시도 버튼 표시 |
|
||||||
|
| 오프라인 상태 | 네트워크 없음 | 광고 버튼 비활성화 |
|
||||||
|
| 광고 중 앱 종료 | 광고 미완료 | 보상 미지급 |
|
||||||
|
| IAP 복원 | 앱 재설치 | 구글/애플 구매기록 확인 |
|
||||||
|
| 자동부활 중 종료 | 버프 활성 중 | 남은 시간 저장 |
|
||||||
|
| 시간 조작 | lastPlayTime > now | 복귀 보상 없음 |
|
||||||
|
| 굴리기 0회 종료 | rollsRemaining == 0 | 0회 유지 |
|
||||||
|
| 되돌리기 초과 | undoRemaining > historyLength | min 적용 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 디버그 옵션 (kDebugMode 전용)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
debug_options:
|
||||||
|
ad_enabled:
|
||||||
|
type: bool
|
||||||
|
default: true
|
||||||
|
off_behavior: 광고 버튼 클릭 시 바로 보상
|
||||||
|
on_behavior: 실제 광고 재생
|
||||||
|
|
||||||
|
iap_simulated:
|
||||||
|
type: bool
|
||||||
|
default: false
|
||||||
|
off_behavior: 무료 유저로 동작
|
||||||
|
on_behavior: 구매 유저로 동작
|
||||||
|
|
||||||
|
offline_hours:
|
||||||
|
type: int
|
||||||
|
options: [0, 1, 5, 10]
|
||||||
|
default: 0
|
||||||
|
purpose: 복귀 보상 테스트
|
||||||
|
|
||||||
|
locations:
|
||||||
|
- 메인 메뉴 (광고 제거 버튼 아래)
|
||||||
|
- 스타트 화면
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI 요약
|
||||||
|
|
||||||
|
### 메인 메뉴
|
||||||
|
|
||||||
|
```
|
||||||
|
[ 새 게임 ]
|
||||||
|
[ 불러오기 ]
|
||||||
|
[ 설정 ]
|
||||||
|
[ 명예의 전당 ]
|
||||||
|
────────────
|
||||||
|
[ 광고 제거 - $9.99 ] ← 구매 후 비활성화
|
||||||
|
── Debug Only ──
|
||||||
|
[ 광고: ON/OFF ]
|
||||||
|
[ IAP: 미구매/구매 ]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 캐릭터 생성
|
||||||
|
|
||||||
|
```
|
||||||
|
STR: 14 CON: 12 DEX: 10
|
||||||
|
INT: 16 WIS: 8 CHA: 11
|
||||||
|
Total: 71
|
||||||
|
|
||||||
|
[ 굴리기 (N/5) ] ← 0회 시 🎬 표시
|
||||||
|
[ 🎬 이전으로 (N/M) ] ← 무료:1회, 구매:3회
|
||||||
|
```
|
||||||
|
|
||||||
|
### 게임 화면 - 속도
|
||||||
|
|
||||||
|
```
|
||||||
|
명예의 전당 없음: [ 1x ] [ 🎬 5x ]
|
||||||
|
명예의 전당 있음: [ 1x ] [ 2x ] [ 🎬 5x ]
|
||||||
|
5배속 중: [ 5x ⏱️ 4:32 ]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 사망 화면
|
||||||
|
|
||||||
|
```
|
||||||
|
☠️ You Died
|
||||||
|
Killed by: {monster}
|
||||||
|
Lost: {item}
|
||||||
|
|
||||||
|
[ 🎬 광고 부활 (30초) ]
|
||||||
|
- 장비 복구
|
||||||
|
- 제물 없음
|
||||||
|
- HP 100%
|
||||||
|
- 10분 자동부활
|
||||||
|
|
||||||
|
[ 일반 부활 ]
|
||||||
|
- 장비 손실
|
||||||
|
- 제물 소모
|
||||||
|
- HP 50%
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 참고
|
||||||
|
|
||||||
|
### 관련 파일
|
||||||
|
|
||||||
|
- `lib/src/core/model/game_state.dart`: GameState 모델
|
||||||
|
- `lib/src/core/engine/progress_service.dart`: 사망 처리 로직
|
||||||
|
- `lib/src/core/storage/`: 저장/로드 시스템
|
||||||
|
- `lib/src/core/engine/item_service.dart`: 아이템 희귀도 결정
|
||||||
|
|
||||||
|
### 의존성 패키지
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
dependencies:
|
||||||
|
google_mobile_ads: ^5.0.0 # AdMob
|
||||||
|
in_app_purchase: ^3.1.0 # IAP
|
||||||
|
```
|
||||||
@@ -141,14 +141,56 @@ String get deathNoSacrificeNeeded =>
|
|||||||
_l('No sacrifice needed', '희생 없이 부활', '犠牲なしで復活');
|
_l('No sacrifice needed', '희생 없이 부활', '犠牲なしで復活');
|
||||||
String get deathCoinRemaining => _l('Coin Remaining', '남은 코인', '残りコイン');
|
String get deathCoinRemaining => _l('Coin Remaining', '남은 코인', '残りコイン');
|
||||||
String get deathResurrect => _l('Resurrect', '부활', '復活');
|
String get deathResurrect => _l('Resurrect', '부활', '復活');
|
||||||
String get deathAutoResurrect => _l('Auto Resurrect', '자동 부활', '自動復活');
|
|
||||||
String get deathCombatLog => _l('Combat Log', '전투 기록', '戦闘ログ');
|
String get deathCombatLog => _l('Combat Log', '전투 기록', '戦闘ログ');
|
||||||
|
|
||||||
|
// 광고 부활 (통합)
|
||||||
|
String get deathAdRevive => _l('Watch Ad & Revive', '광고보고 부활', '広告視聴で復活');
|
||||||
|
String get deathAdReviveHp => _l('HP 100% Recovery', 'HP 100% 회복', 'HP 100%回復');
|
||||||
|
String get deathAdReviveItem => _l('Item Recovery', '아이템 복구', 'アイテム回収');
|
||||||
|
String get deathAdReviveAuto =>
|
||||||
|
_l('10min Auto-Revive Buff', '10분간 자동부활 버프', '10分間自動復活バフ');
|
||||||
|
String get deathAdRevivePaidDesc =>
|
||||||
|
_l('Premium: No ads required', '프리미엄: 광고 없이 이용', 'プレミアム: 広告不要');
|
||||||
|
|
||||||
String deathKilledBy(String killerName) =>
|
String deathKilledBy(String killerName) =>
|
||||||
_l('Killed by $killerName', '$killerName에게 사망', '$killerNameに倒された');
|
_l('Killed by $killerName', '$killerName에게 사망', '$killerNameに倒された');
|
||||||
String get deathEnvironmentalHazard =>
|
String get deathEnvironmentalHazard =>
|
||||||
_l('Environmental hazard', '환경 피해로 사망', '環境ダメージで死亡');
|
_l('Environmental hazard', '환경 피해로 사망', '環境ダメージで死亡');
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 속도 부스트 (Phase 6)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
String get speedBoostTitle =>
|
||||||
|
_l('Speed Boost', '속도 부스트', 'スピードブースト');
|
||||||
|
String get speedBoostActivate =>
|
||||||
|
_l('Activate 10x Speed', '10배속 활성화', '10倍速を有効化');
|
||||||
|
String speedBoostRemaining(int seconds) =>
|
||||||
|
_l('${seconds}s remaining', '${seconds}초 남음', '残り${seconds}秒');
|
||||||
|
String get speedBoostActive =>
|
||||||
|
_l('BOOST ACTIVE', '부스트 활성화', 'ブースト中');
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 복귀 보상 (Phase 7)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
String get returnRewardTitle =>
|
||||||
|
_l('Welcome Back!', '돌아오셨군요!', 'おかえりなさい!');
|
||||||
|
String returnRewardHoursAway(String time) =>
|
||||||
|
_l('You were away for $time', '$time 동안 떠나있었습니다', '$time 離れていました');
|
||||||
|
String get returnRewardBasic =>
|
||||||
|
_l('Basic Reward', '기본 보상', '基本報酬');
|
||||||
|
String get returnRewardBonus =>
|
||||||
|
_l('Bonus Reward', '보너스 보상', 'ボーナス報酬');
|
||||||
|
String returnRewardGold(int gold) =>
|
||||||
|
_l('+$gold Gold', '+$gold 골드', '+$gold ゴールド');
|
||||||
|
String get returnRewardClaim =>
|
||||||
|
_l('Claim', '받기', '受け取る');
|
||||||
|
String get returnRewardClaimBonus =>
|
||||||
|
_l('Claim Bonus', '보너스 받기', 'ボーナス受取');
|
||||||
|
String get returnRewardSkip =>
|
||||||
|
_l('Skip', '건너뛰기', 'スキップ');
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// UI 일반 메시지
|
// UI 일반 메시지
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
|
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
|
||||||
import 'package:asciineverdie/l10n/app_localizations.dart';
|
import 'package:asciineverdie/l10n/app_localizations.dart';
|
||||||
import 'package:asciineverdie/src/core/audio/audio_service.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/shared/retro_colors.dart';
|
||||||
import 'package:asciineverdie/src/core/engine/game_mutations.dart';
|
import 'package:asciineverdie/src/core/engine/game_mutations.dart';
|
||||||
import 'package:asciineverdie/src/core/engine/progress_service.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/game/widgets/notification_overlay.dart';
|
||||||
import 'package:asciineverdie/src/features/hall_of_fame/hall_of_fame_screen.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/new_character/new_character_screen.dart';
|
||||||
|
import 'package:asciineverdie/src/features/settings/settings_screen.dart';
|
||||||
|
|
||||||
class AskiiNeverDieApp extends StatefulWidget {
|
class AskiiNeverDieApp extends StatefulWidget {
|
||||||
const AskiiNeverDieApp({super.key});
|
const AskiiNeverDieApp({super.key});
|
||||||
@@ -81,6 +83,8 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
|||||||
// 초기 설정 및 오디오 서비스 로드
|
// 초기 설정 및 오디오 서비스 로드
|
||||||
_loadSettings();
|
_loadSettings();
|
||||||
_audioService.init();
|
_audioService.init();
|
||||||
|
// 디버그 설정 서비스 초기화 (Phase 8)
|
||||||
|
DebugSettingsService.instance.initialize();
|
||||||
// 세이브 파일 존재 여부 확인
|
// 세이브 파일 존재 여부 확인
|
||||||
_checkForExistingSave();
|
_checkForExistingSave();
|
||||||
// 명예의 전당 로드
|
// 명예의 전당 로드
|
||||||
@@ -118,7 +122,7 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
|||||||
|
|
||||||
if (exists) {
|
if (exists) {
|
||||||
// 세이브 파일에서 미리보기 정보 추출
|
// 세이브 파일에서 미리보기 정보 추출
|
||||||
final (outcome, state, _) = await _controller.saveManager.loadState();
|
final (outcome, state, _, _) = await _controller.saveManager.loadState();
|
||||||
if (outcome.success && state != null) {
|
if (outcome.success && state != null) {
|
||||||
final actName = _getActName(state.progress.plotStageCount);
|
final actName = _getActName(state.progress.plotStageCount);
|
||||||
preview = SavedGamePreview(
|
preview = SavedGamePreview(
|
||||||
@@ -465,6 +469,7 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
|||||||
onLoadSave: _loadSave,
|
onLoadSave: _loadSave,
|
||||||
onHallOfFame: _navigateToHallOfFame,
|
onHallOfFame: _navigateToHallOfFame,
|
||||||
onLocalArena: _navigateToArena,
|
onLocalArena: _navigateToArena,
|
||||||
|
onSettings: _showSettings,
|
||||||
hasSaveFile: _hasSave,
|
hasSaveFile: _hasSave,
|
||||||
savedGamePreview: _savedGamePreview,
|
savedGamePreview: _savedGamePreview,
|
||||||
hallOfFameCount: _hallOfFame.count,
|
hallOfFameCount: _hallOfFame.count,
|
||||||
@@ -602,6 +607,18 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
|||||||
_audioService.playBgm('title');
|
_audioService.playBgm('title');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 설정 화면 표시 (모달 바텀시트)
|
||||||
|
void _showSettings(BuildContext context) {
|
||||||
|
SettingsScreen.show(
|
||||||
|
context,
|
||||||
|
settingsRepository: _settingsRepository,
|
||||||
|
currentThemeMode: _themeMode,
|
||||||
|
onThemeModeChange: _changeThemeMode,
|
||||||
|
onBgmVolumeChange: _audioService.setBgmVolume,
|
||||||
|
onSfxVolumeChange: _audioService.setSfxVolume,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 스플래시 화면 (세이브 파일 확인 중) - 레트로 스타일
|
/// 스플래시 화면 (세이브 파일 확인 중) - 레트로 스타일
|
||||||
|
|||||||
359
lib/src/core/engine/ad_service.dart
Normal file
359
lib/src/core/engine/ad_service.dart
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
||||||
|
|
||||||
|
/// 광고 타입
|
||||||
|
enum AdType {
|
||||||
|
/// 부활용 리워드 광고 (30초)
|
||||||
|
rewardRevive,
|
||||||
|
|
||||||
|
/// 캐릭터 생성 되돌리기용 리워드 광고 (30초)
|
||||||
|
rewardUndo,
|
||||||
|
|
||||||
|
/// 굴리기 충전용 인터스티셜 광고 (6초)
|
||||||
|
interstitialRoll,
|
||||||
|
|
||||||
|
/// 속도업용 인터스티셜 광고 (6초)
|
||||||
|
interstitialSpeed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 광고 결과
|
||||||
|
enum AdResult {
|
||||||
|
/// 광고 시청 완료 (보상 지급)
|
||||||
|
completed,
|
||||||
|
|
||||||
|
/// 광고 시청 취소/스킵
|
||||||
|
cancelled,
|
||||||
|
|
||||||
|
/// 광고 로드 실패
|
||||||
|
failed,
|
||||||
|
|
||||||
|
/// 디버그 모드에서 광고 스킵
|
||||||
|
debugSkipped,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 광고 서비스
|
||||||
|
///
|
||||||
|
/// AdMob 리워드/인터스티셜 광고 로드 및 표시를 관리합니다.
|
||||||
|
/// 디버그 모드에서는 광고 ON/OFF 토글이 가능합니다.
|
||||||
|
class AdService {
|
||||||
|
AdService._();
|
||||||
|
|
||||||
|
static AdService? _instance;
|
||||||
|
|
||||||
|
/// 싱글톤 인스턴스
|
||||||
|
static AdService get instance {
|
||||||
|
_instance ??= AdService._();
|
||||||
|
return _instance!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 광고 단위 ID
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// 테스트 광고 ID (Google 공식 테스트 ID)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
static const String _testRewardedAndroid =
|
||||||
|
'ca-app-pub-3940256099942544/5224354917';
|
||||||
|
static const String _testRewardedIos =
|
||||||
|
'ca-app-pub-3940256099942544/1712485313';
|
||||||
|
static const String _testInterstitialAndroid =
|
||||||
|
'ca-app-pub-3940256099942544/1033173712';
|
||||||
|
static const String _testInterstitialIos =
|
||||||
|
'ca-app-pub-3940256099942544/4411468910';
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// 프로덕션 광고 ID (AdMob 콘솔에서 생성 후 교체)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// TODO: AdMob 콘솔에서 광고 단위 생성 후 아래 ID 교체
|
||||||
|
static const String _prodRewardedAndroid =
|
||||||
|
'ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX'; // Android 리워드 광고
|
||||||
|
static const String _prodRewardedIos =
|
||||||
|
'ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX'; // iOS 리워드 광고
|
||||||
|
static const String _prodInterstitialAndroid =
|
||||||
|
'ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX'; // Android 인터스티셜 광고
|
||||||
|
static const String _prodInterstitialIos =
|
||||||
|
'ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX'; // iOS 인터스티셜 광고
|
||||||
|
|
||||||
|
/// 리워드 광고 단위 ID (릴리즈 빌드: 프로덕션 ID, 디버그 빌드: 테스트 ID)
|
||||||
|
String get _rewardAdUnitId {
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
return kReleaseMode ? _prodRewardedAndroid : _testRewardedAndroid;
|
||||||
|
} else if (Platform.isIOS) {
|
||||||
|
return kReleaseMode ? _prodRewardedIos : _testRewardedIos;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 인터스티셜 광고 단위 ID (릴리즈 빌드: 프로덕션 ID, 디버그 빌드: 테스트 ID)
|
||||||
|
String get _interstitialAdUnitId {
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
return kReleaseMode ? _prodInterstitialAndroid : _testInterstitialAndroid;
|
||||||
|
} else if (Platform.isIOS) {
|
||||||
|
return kReleaseMode ? _prodInterstitialIos : _testInterstitialIos;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 상태
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
bool _isInitialized = false;
|
||||||
|
|
||||||
|
/// 디버그 모드에서 광고 활성화 여부
|
||||||
|
bool _debugAdEnabled = true;
|
||||||
|
|
||||||
|
/// 로드된 리워드 광고
|
||||||
|
RewardedAd? _rewardedAd;
|
||||||
|
|
||||||
|
/// 로드된 인터스티셜 광고
|
||||||
|
InterstitialAd? _interstitialAd;
|
||||||
|
|
||||||
|
/// 리워드 광고 로딩 중 여부
|
||||||
|
bool _isLoadingRewardedAd = false;
|
||||||
|
|
||||||
|
/// 인터스티셜 광고 로딩 중 여부
|
||||||
|
bool _isLoadingInterstitialAd = false;
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 초기화
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// AdMob SDK 초기화
|
||||||
|
Future<void> initialize() async {
|
||||||
|
if (_isInitialized) return;
|
||||||
|
|
||||||
|
// 모바일 플랫폼에서만 초기화
|
||||||
|
if (!Platform.isAndroid && !Platform.isIOS) {
|
||||||
|
debugPrint('[AdService] Non-mobile platform, skipping initialization');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await MobileAds.instance.initialize();
|
||||||
|
_isInitialized = true;
|
||||||
|
debugPrint('[AdService] Initialized');
|
||||||
|
|
||||||
|
// 초기 광고 로드
|
||||||
|
_loadRewardedAd();
|
||||||
|
_loadInterstitialAd();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 디버그 설정
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// 디버그 모드 광고 활성화 여부
|
||||||
|
bool get debugAdEnabled => _debugAdEnabled;
|
||||||
|
|
||||||
|
/// 디버그 모드 광고 토글
|
||||||
|
set debugAdEnabled(bool value) {
|
||||||
|
_debugAdEnabled = value;
|
||||||
|
debugPrint('[AdService] Debug ad enabled: $value');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 광고를 스킵할지 여부
|
||||||
|
///
|
||||||
|
/// 스킵 조건:
|
||||||
|
/// - 비모바일 플랫폼 (macOS, Windows, Linux, Web)
|
||||||
|
/// - 디버그 모드에서 광고 비활성화
|
||||||
|
bool get _shouldSkipAd {
|
||||||
|
// 웹에서는 항상 스킵
|
||||||
|
if (kIsWeb) return true;
|
||||||
|
// 비모바일 플랫폼(데스크톱)에서는 항상 스킵
|
||||||
|
if (!Platform.isAndroid && !Platform.isIOS) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// 디버그 모드에서 광고 비활성화 시 스킵
|
||||||
|
return kDebugMode && !_debugAdEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 리워드 광고
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// 리워드 광고 로드
|
||||||
|
void _loadRewardedAd() {
|
||||||
|
if (_isLoadingRewardedAd || _rewardedAd != null) return;
|
||||||
|
if (!_isInitialized) return;
|
||||||
|
|
||||||
|
_isLoadingRewardedAd = true;
|
||||||
|
debugPrint('[AdService] Loading rewarded ad...');
|
||||||
|
|
||||||
|
RewardedAd.load(
|
||||||
|
adUnitId: _rewardAdUnitId,
|
||||||
|
request: const AdRequest(),
|
||||||
|
rewardedAdLoadCallback: RewardedAdLoadCallback(
|
||||||
|
onAdLoaded: (ad) {
|
||||||
|
_rewardedAd = ad;
|
||||||
|
_isLoadingRewardedAd = false;
|
||||||
|
debugPrint('[AdService] Rewarded ad loaded');
|
||||||
|
},
|
||||||
|
onAdFailedToLoad: (error) {
|
||||||
|
_isLoadingRewardedAd = false;
|
||||||
|
debugPrint('[AdService] Rewarded ad failed to load: ${error.message}');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 리워드 광고 준비 여부
|
||||||
|
bool get isRewardedAdReady => _rewardedAd != null || _shouldSkipAd;
|
||||||
|
|
||||||
|
/// 리워드 광고 표시
|
||||||
|
///
|
||||||
|
/// [adType] 광고 타입 (로깅용)
|
||||||
|
/// [onRewarded] 보상 지급 콜백
|
||||||
|
/// Returns: 광고 결과
|
||||||
|
Future<AdResult> showRewardedAd({
|
||||||
|
required AdType adType,
|
||||||
|
required void Function() onRewarded,
|
||||||
|
}) async {
|
||||||
|
// 디버그 모드에서 광고 스킵
|
||||||
|
if (_shouldSkipAd) {
|
||||||
|
debugPrint('[AdService] Debug: Skipping $adType ad');
|
||||||
|
onRewarded();
|
||||||
|
return AdResult.debugSkipped;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 광고가 로드되지 않은 경우
|
||||||
|
if (_rewardedAd == null) {
|
||||||
|
debugPrint('[AdService] Rewarded ad not ready');
|
||||||
|
_loadRewardedAd();
|
||||||
|
return AdResult.failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
final ad = _rewardedAd!;
|
||||||
|
_rewardedAd = null;
|
||||||
|
|
||||||
|
// 결과 추적용
|
||||||
|
var result = AdResult.cancelled;
|
||||||
|
|
||||||
|
ad.fullScreenContentCallback = FullScreenContentCallback(
|
||||||
|
onAdDismissedFullScreenContent: (ad) {
|
||||||
|
debugPrint('[AdService] Rewarded ad dismissed');
|
||||||
|
ad.dispose();
|
||||||
|
_loadRewardedAd(); // 다음 광고 미리 로드
|
||||||
|
},
|
||||||
|
onAdFailedToShowFullScreenContent: (ad, error) {
|
||||||
|
debugPrint('[AdService] Rewarded ad failed to show: ${error.message}');
|
||||||
|
ad.dispose();
|
||||||
|
result = AdResult.failed;
|
||||||
|
_loadRewardedAd();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await ad.show(
|
||||||
|
onUserEarnedReward: (ad, reward) {
|
||||||
|
debugPrint('[AdService] User earned reward: ${reward.amount}');
|
||||||
|
result = AdResult.completed;
|
||||||
|
onRewarded();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 인터스티셜 광고
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// 인터스티셜 광고 로드
|
||||||
|
void _loadInterstitialAd() {
|
||||||
|
if (_isLoadingInterstitialAd || _interstitialAd != null) return;
|
||||||
|
if (!_isInitialized) return;
|
||||||
|
|
||||||
|
_isLoadingInterstitialAd = true;
|
||||||
|
debugPrint('[AdService] Loading interstitial ad...');
|
||||||
|
|
||||||
|
InterstitialAd.load(
|
||||||
|
adUnitId: _interstitialAdUnitId,
|
||||||
|
request: const AdRequest(),
|
||||||
|
adLoadCallback: InterstitialAdLoadCallback(
|
||||||
|
onAdLoaded: (ad) {
|
||||||
|
_interstitialAd = ad;
|
||||||
|
_isLoadingInterstitialAd = false;
|
||||||
|
debugPrint('[AdService] Interstitial ad loaded');
|
||||||
|
},
|
||||||
|
onAdFailedToLoad: (error) {
|
||||||
|
_isLoadingInterstitialAd = false;
|
||||||
|
debugPrint(
|
||||||
|
'[AdService] Interstitial ad failed to load: ${error.message}',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 인터스티셜 광고 준비 여부
|
||||||
|
bool get isInterstitialAdReady => _interstitialAd != null || _shouldSkipAd;
|
||||||
|
|
||||||
|
/// 인터스티셜 광고 표시
|
||||||
|
///
|
||||||
|
/// [adType] 광고 타입 (로깅용)
|
||||||
|
/// [onComplete] 광고 완료 콜백 (보상 지급)
|
||||||
|
/// Returns: 광고 결과
|
||||||
|
Future<AdResult> showInterstitialAd({
|
||||||
|
required AdType adType,
|
||||||
|
required void Function() onComplete,
|
||||||
|
}) async {
|
||||||
|
// 디버그 모드에서 광고 스킵
|
||||||
|
if (_shouldSkipAd) {
|
||||||
|
debugPrint('[AdService] Debug: Skipping $adType ad');
|
||||||
|
onComplete();
|
||||||
|
return AdResult.debugSkipped;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 광고가 로드되지 않은 경우
|
||||||
|
if (_interstitialAd == null) {
|
||||||
|
debugPrint('[AdService] Interstitial ad not ready');
|
||||||
|
_loadInterstitialAd();
|
||||||
|
return AdResult.failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
final ad = _interstitialAd!;
|
||||||
|
_interstitialAd = null;
|
||||||
|
|
||||||
|
// 결과 추적용
|
||||||
|
var result = AdResult.cancelled;
|
||||||
|
|
||||||
|
ad.fullScreenContentCallback = FullScreenContentCallback(
|
||||||
|
onAdDismissedFullScreenContent: (ad) {
|
||||||
|
debugPrint('[AdService] Interstitial ad dismissed');
|
||||||
|
ad.dispose();
|
||||||
|
result = AdResult.completed;
|
||||||
|
onComplete();
|
||||||
|
_loadInterstitialAd(); // 다음 광고 미리 로드
|
||||||
|
},
|
||||||
|
onAdFailedToShowFullScreenContent: (ad, error) {
|
||||||
|
debugPrint(
|
||||||
|
'[AdService] Interstitial ad failed to show: ${error.message}',
|
||||||
|
);
|
||||||
|
ad.dispose();
|
||||||
|
result = AdResult.failed;
|
||||||
|
_loadInterstitialAd();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await ad.show();
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 정리
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// 리소스 해제
|
||||||
|
void dispose() {
|
||||||
|
_rewardedAd?.dispose();
|
||||||
|
_rewardedAd = null;
|
||||||
|
|
||||||
|
_interstitialAd?.dispose();
|
||||||
|
_interstitialAd = null;
|
||||||
|
|
||||||
|
debugPrint('[AdService] Disposed');
|
||||||
|
}
|
||||||
|
}
|
||||||
282
lib/src/core/engine/character_roll_service.dart
Normal file
282
lib/src/core/engine/character_roll_service.dart
Normal file
@@ -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<RollSnapshot> _rollHistory = [];
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 초기화
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// 서비스 초기화
|
||||||
|
Future<void> initialize() async {
|
||||||
|
if (_isInitialized) return;
|
||||||
|
|
||||||
|
await _loadState();
|
||||||
|
_resetUndoForNewSession();
|
||||||
|
|
||||||
|
_isInitialized = true;
|
||||||
|
debugPrint('[CharacterRollService] Initialized: '
|
||||||
|
'rolls=$_rollsRemaining, undo=$_undoRemaining');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 저장된 상태 로드
|
||||||
|
Future<void> _loadState() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
_rollsRemaining = prefs.getInt(_rollsRemainingKey) ?? maxRolls;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 굴리기 횟수 저장
|
||||||
|
Future<void> _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<bool> 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<RollSnapshot?> 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<void> 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;
|
||||||
|
}
|
||||||
180
lib/src/core/engine/debug_settings_service.dart
Normal file
180
lib/src/core/engine/debug_settings_service.dart
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
/// 디버그 설정 서비스 (Phase 8)
|
||||||
|
///
|
||||||
|
/// 개발/테스트 중 사용하는 디버그 옵션을 통합 관리합니다.
|
||||||
|
/// kDebugMode에서만 활성화됩니다.
|
||||||
|
class DebugSettingsService {
|
||||||
|
DebugSettingsService._();
|
||||||
|
|
||||||
|
static DebugSettingsService? _instance;
|
||||||
|
|
||||||
|
/// 싱글톤 인스턴스
|
||||||
|
static DebugSettingsService get instance {
|
||||||
|
_instance ??= DebugSettingsService._();
|
||||||
|
return _instance!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 상수
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
static const String _keyAdEnabled = 'debug_ad_enabled';
|
||||||
|
static const String _keyIapSimulated = 'debug_iap_simulated';
|
||||||
|
static const String _keyOfflineHours = 'debug_offline_hours';
|
||||||
|
|
||||||
|
/// 오프라인 시간 시뮬레이션 옵션
|
||||||
|
static const List<int> offlineHoursOptions = [0, 1, 5, 10, 24];
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 상태
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
bool _isInitialized = false;
|
||||||
|
|
||||||
|
/// 광고 활성화 여부 (디버그 모드 전용)
|
||||||
|
bool _adEnabled = true;
|
||||||
|
|
||||||
|
/// IAP 구매 시뮬레이션 여부 (디버그 모드 전용)
|
||||||
|
bool _iapSimulated = false;
|
||||||
|
|
||||||
|
/// 오프라인 시간 시뮬레이션 (시간 단위)
|
||||||
|
int _offlineHours = 0;
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 초기화
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// 서비스 초기화 (저장된 설정 로드)
|
||||||
|
Future<void> initialize() async {
|
||||||
|
if (_isInitialized) return;
|
||||||
|
if (!kDebugMode) {
|
||||||
|
_isInitialized = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
_adEnabled = prefs.getBool(_keyAdEnabled) ?? true;
|
||||||
|
_iapSimulated = prefs.getBool(_keyIapSimulated) ?? false;
|
||||||
|
_offlineHours = prefs.getInt(_keyOfflineHours) ?? 0;
|
||||||
|
|
||||||
|
// 다른 서비스에 설정 동기화
|
||||||
|
_syncToServices();
|
||||||
|
|
||||||
|
_isInitialized = true;
|
||||||
|
debugPrint('[DebugSettings] Initialized: ad=$_adEnabled, '
|
||||||
|
'iap=$_iapSimulated, offline=$_offlineHours');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 설정을 다른 서비스에 동기화
|
||||||
|
void _syncToServices() {
|
||||||
|
AdService.instance.debugAdEnabled = _adEnabled;
|
||||||
|
IAPService.instance.debugIAPSimulated = _iapSimulated;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 디버그 모드 확인
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// 디버그 모드 활성화 여부
|
||||||
|
bool get isDebugMode => kDebugMode;
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 광고 설정
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// 광고 활성화 여부
|
||||||
|
///
|
||||||
|
/// - true: 실제 광고 표시
|
||||||
|
/// - false: 광고 버튼 클릭 시 바로 보상 지급
|
||||||
|
bool get adEnabled => _adEnabled;
|
||||||
|
|
||||||
|
/// 광고 활성화 토글
|
||||||
|
Future<void> setAdEnabled(bool value) async {
|
||||||
|
if (!kDebugMode) return;
|
||||||
|
|
||||||
|
_adEnabled = value;
|
||||||
|
AdService.instance.debugAdEnabled = value;
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_keyAdEnabled, value);
|
||||||
|
|
||||||
|
debugPrint('[DebugSettings] Ad enabled: $value');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// IAP 설정
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// IAP 구매 시뮬레이션 여부
|
||||||
|
///
|
||||||
|
/// - true: 유료 유저로 동작 (광고 제거됨)
|
||||||
|
/// - false: 무료 유저로 동작
|
||||||
|
bool get iapSimulated => _iapSimulated;
|
||||||
|
|
||||||
|
/// IAP 구매 시뮬레이션 토글
|
||||||
|
Future<void> setIapSimulated(bool value) async {
|
||||||
|
if (!kDebugMode) return;
|
||||||
|
|
||||||
|
_iapSimulated = value;
|
||||||
|
IAPService.instance.debugIAPSimulated = value;
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_keyIapSimulated, value);
|
||||||
|
|
||||||
|
debugPrint('[DebugSettings] IAP simulated: $value');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 오프라인 시간 시뮬레이션
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// 오프라인 시간 시뮬레이션 (시간 단위)
|
||||||
|
///
|
||||||
|
/// 복귀 보상 테스트용. 0이면 시뮬레이션 비활성화.
|
||||||
|
int get offlineHours => _offlineHours;
|
||||||
|
|
||||||
|
/// 오프라인 시간 시뮬레이션 설정
|
||||||
|
Future<void> setOfflineHours(int hours) async {
|
||||||
|
if (!kDebugMode) return;
|
||||||
|
|
||||||
|
_offlineHours = hours;
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setInt(_keyOfflineHours, hours);
|
||||||
|
|
||||||
|
debugPrint('[DebugSettings] Offline hours: $hours');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 시뮬레이션된 마지막 플레이 시간 계산
|
||||||
|
///
|
||||||
|
/// [actualLastPlayTime] 실제 마지막 플레이 시간
|
||||||
|
/// Returns: 시뮬레이션 적용된 마지막 플레이 시간
|
||||||
|
DateTime? getSimulatedLastPlayTime(DateTime? actualLastPlayTime) {
|
||||||
|
if (!kDebugMode || _offlineHours == 0) {
|
||||||
|
return actualLastPlayTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 시뮬레이션: 현재 시간에서 offlineHours만큼 뺀 시간을 마지막 플레이 시간으로
|
||||||
|
return DateTime.now().subtract(Duration(hours: _offlineHours));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 전체 초기화
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// 모든 디버그 설정 초기화
|
||||||
|
Future<void> resetAll() async {
|
||||||
|
if (!kDebugMode) return;
|
||||||
|
|
||||||
|
await setAdEnabled(true);
|
||||||
|
await setIapSimulated(false);
|
||||||
|
await setOfflineHours(0);
|
||||||
|
|
||||||
|
debugPrint('[DebugSettings] All settings reset');
|
||||||
|
}
|
||||||
|
}
|
||||||
341
lib/src/core/engine/iap_service.dart
Normal file
341
lib/src/core/engine/iap_service.dart
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:in_app_purchase/in_app_purchase.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
/// IAP 상품 ID
|
||||||
|
class IAPProductIds {
|
||||||
|
IAPProductIds._();
|
||||||
|
|
||||||
|
/// 광고 제거 상품 ID (비소모성)
|
||||||
|
/// TODO: Google Play Console / App Store Connect에서 상품 생성 후 ID 교체
|
||||||
|
static const String removeAds = 'remove_ads';
|
||||||
|
|
||||||
|
/// 모든 상품 ID 목록
|
||||||
|
static const Set<String> all = {removeAds};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// IAP 구매 결과
|
||||||
|
enum IAPResult {
|
||||||
|
/// 구매 성공
|
||||||
|
success,
|
||||||
|
|
||||||
|
/// 구매 취소
|
||||||
|
cancelled,
|
||||||
|
|
||||||
|
/// 구매 실패
|
||||||
|
failed,
|
||||||
|
|
||||||
|
/// 이미 구매됨
|
||||||
|
alreadyPurchased,
|
||||||
|
|
||||||
|
/// 상품을 찾을 수 없음
|
||||||
|
productNotFound,
|
||||||
|
|
||||||
|
/// 스토어 사용 불가
|
||||||
|
storeUnavailable,
|
||||||
|
|
||||||
|
/// 디버그 모드에서 시뮬레이션
|
||||||
|
debugSimulated,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// IAP 서비스
|
||||||
|
///
|
||||||
|
/// 인앱 구매 (광고 제거) 처리를 담당합니다.
|
||||||
|
/// shared_preferences를 사용하여 구매 상태를 영구 저장합니다.
|
||||||
|
class IAPService {
|
||||||
|
IAPService._();
|
||||||
|
|
||||||
|
static IAPService? _instance;
|
||||||
|
|
||||||
|
/// 싱글톤 인스턴스
|
||||||
|
static IAPService get instance {
|
||||||
|
_instance ??= IAPService._();
|
||||||
|
return _instance!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 상수
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// 구매 상태 저장 키
|
||||||
|
static const String _purchaseKey = 'iap_remove_ads_purchased';
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 상태
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
final InAppPurchase _iap = InAppPurchase.instance;
|
||||||
|
|
||||||
|
bool _isInitialized = false;
|
||||||
|
bool _isAvailable = false;
|
||||||
|
|
||||||
|
/// 상품 정보
|
||||||
|
ProductDetails? _removeAdsProduct;
|
||||||
|
|
||||||
|
/// 구매 스트림 구독
|
||||||
|
StreamSubscription<List<PurchaseDetails>>? _subscription;
|
||||||
|
|
||||||
|
/// 광고 제거 구매 여부 (캐시)
|
||||||
|
bool _adRemovalPurchased = false;
|
||||||
|
|
||||||
|
/// 디버그 모드에서 IAP 시뮬레이션 활성화 여부
|
||||||
|
bool _debugIAPSimulated = false;
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 초기화
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// IAP 서비스 초기화
|
||||||
|
Future<void> initialize() async {
|
||||||
|
if (_isInitialized) return;
|
||||||
|
|
||||||
|
// 모바일 플랫폼에서만 초기화
|
||||||
|
if (!Platform.isAndroid && !Platform.isIOS) {
|
||||||
|
debugPrint('[IAPService] Non-mobile platform, skipping initialization');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 저장된 구매 상태 로드
|
||||||
|
await _loadPurchaseState();
|
||||||
|
|
||||||
|
// 스토어 가용성 확인
|
||||||
|
_isAvailable = await _iap.isAvailable();
|
||||||
|
if (!_isAvailable) {
|
||||||
|
debugPrint('[IAPService] Store not available');
|
||||||
|
_isInitialized = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 구매 스트림 구독
|
||||||
|
_subscription = _iap.purchaseStream.listen(
|
||||||
|
_onPurchaseUpdate,
|
||||||
|
onError: (Object error) {
|
||||||
|
debugPrint('[IAPService] Purchase stream error: $error');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 상품 정보 로드
|
||||||
|
await _loadProducts();
|
||||||
|
|
||||||
|
_isInitialized = true;
|
||||||
|
debugPrint('[IAPService] Initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 상품 정보 로드
|
||||||
|
Future<void> _loadProducts() async {
|
||||||
|
final response = await _iap.queryProductDetails(IAPProductIds.all);
|
||||||
|
|
||||||
|
if (response.notFoundIDs.isNotEmpty) {
|
||||||
|
debugPrint(
|
||||||
|
'[IAPService] Products not found: ${response.notFoundIDs}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final product in response.productDetails) {
|
||||||
|
if (product.id == IAPProductIds.removeAds) {
|
||||||
|
_removeAdsProduct = product;
|
||||||
|
debugPrint(
|
||||||
|
'[IAPService] Product loaded: ${product.id} - ${product.price}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 저장된 구매 상태 로드
|
||||||
|
Future<void> _loadPurchaseState() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
_adRemovalPurchased = prefs.getBool(_purchaseKey) ?? false;
|
||||||
|
debugPrint('[IAPService] Loaded purchase state: $_adRemovalPurchased');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 구매 상태 저장
|
||||||
|
Future<void> _savePurchaseState(bool purchased) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_purchaseKey, purchased);
|
||||||
|
_adRemovalPurchased = purchased;
|
||||||
|
debugPrint('[IAPService] Saved purchase state: $purchased');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 디버그 설정
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// 디버그 모드 IAP 시뮬레이션 활성화 여부
|
||||||
|
bool get debugIAPSimulated => _debugIAPSimulated;
|
||||||
|
|
||||||
|
/// 디버그 모드 IAP 시뮬레이션 토글
|
||||||
|
set debugIAPSimulated(bool value) {
|
||||||
|
_debugIAPSimulated = value;
|
||||||
|
if (kDebugMode) {
|
||||||
|
_adRemovalPurchased = value;
|
||||||
|
debugPrint('[IAPService] Debug IAP simulated: $value');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 구매 상태
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// 광고 제거 구매 여부
|
||||||
|
bool get isAdRemovalPurchased {
|
||||||
|
if (kDebugMode && _debugIAPSimulated) return true;
|
||||||
|
return _adRemovalPurchased;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 스토어 가용성
|
||||||
|
bool get isStoreAvailable => _isAvailable;
|
||||||
|
|
||||||
|
/// 광고 제거 상품 정보
|
||||||
|
ProductDetails? get removeAdsProduct => _removeAdsProduct;
|
||||||
|
|
||||||
|
/// 광고 제거 상품 가격 문자열
|
||||||
|
String get removeAdsPrice {
|
||||||
|
return _removeAdsProduct?.price ?? '\$9.99';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 구매
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// 광고 제거 구매
|
||||||
|
Future<IAPResult> purchaseRemoveAds() async {
|
||||||
|
// 디버그 모드 시뮬레이션
|
||||||
|
if (kDebugMode && _debugIAPSimulated) {
|
||||||
|
debugPrint('[IAPService] Debug: Simulating purchase');
|
||||||
|
await _savePurchaseState(true);
|
||||||
|
return IAPResult.debugSimulated;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미 구매됨
|
||||||
|
if (_adRemovalPurchased) {
|
||||||
|
debugPrint('[IAPService] Already purchased');
|
||||||
|
return IAPResult.alreadyPurchased;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 스토어 사용 불가
|
||||||
|
if (!_isAvailable) {
|
||||||
|
debugPrint('[IAPService] Store not available');
|
||||||
|
return IAPResult.storeUnavailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상품을 찾을 수 없음
|
||||||
|
if (_removeAdsProduct == null) {
|
||||||
|
debugPrint('[IAPService] Product not found');
|
||||||
|
return IAPResult.productNotFound;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 구매 요청
|
||||||
|
final purchaseParam = PurchaseParam(
|
||||||
|
productDetails: _removeAdsProduct!,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final success = await _iap.buyNonConsumable(
|
||||||
|
purchaseParam: purchaseParam,
|
||||||
|
);
|
||||||
|
debugPrint('[IAPService] Purchase initiated: $success');
|
||||||
|
return success ? IAPResult.success : IAPResult.failed;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[IAPService] Purchase error: $e');
|
||||||
|
return IAPResult.failed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 구매 복원
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// 구매 복원
|
||||||
|
Future<IAPResult> restorePurchases() async {
|
||||||
|
// 디버그 모드 시뮬레이션
|
||||||
|
if (kDebugMode && _debugIAPSimulated) {
|
||||||
|
debugPrint('[IAPService] Debug: Simulating restore');
|
||||||
|
await _savePurchaseState(true);
|
||||||
|
return IAPResult.debugSimulated;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 스토어 사용 불가
|
||||||
|
if (!_isAvailable) {
|
||||||
|
debugPrint('[IAPService] Store not available');
|
||||||
|
return IAPResult.storeUnavailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _iap.restorePurchases();
|
||||||
|
debugPrint('[IAPService] Restore initiated');
|
||||||
|
return IAPResult.success;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[IAPService] Restore error: $e');
|
||||||
|
return IAPResult.failed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 구매 처리
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// 구매 업데이트 처리
|
||||||
|
void _onPurchaseUpdate(List<PurchaseDetails> purchases) {
|
||||||
|
for (final purchase in purchases) {
|
||||||
|
debugPrint(
|
||||||
|
'[IAPService] Purchase update: ${purchase.productID} - '
|
||||||
|
'${purchase.status}',
|
||||||
|
);
|
||||||
|
|
||||||
|
switch (purchase.status) {
|
||||||
|
case PurchaseStatus.pending:
|
||||||
|
// 구매 대기 중
|
||||||
|
debugPrint('[IAPService] Purchase pending');
|
||||||
|
|
||||||
|
case PurchaseStatus.purchased:
|
||||||
|
case PurchaseStatus.restored:
|
||||||
|
// 구매 완료 또는 복원
|
||||||
|
_handleSuccessfulPurchase(purchase);
|
||||||
|
|
||||||
|
case PurchaseStatus.error:
|
||||||
|
// 구매 실패
|
||||||
|
debugPrint('[IAPService] Purchase error: ${purchase.error}');
|
||||||
|
_completePurchase(purchase);
|
||||||
|
|
||||||
|
case PurchaseStatus.canceled:
|
||||||
|
// 구매 취소
|
||||||
|
debugPrint('[IAPService] Purchase canceled');
|
||||||
|
_completePurchase(purchase);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 구매 성공 처리
|
||||||
|
Future<void> _handleSuccessfulPurchase(PurchaseDetails purchase) async {
|
||||||
|
if (purchase.productID == IAPProductIds.removeAds) {
|
||||||
|
// 구매 검증 (서버 검증 생략, 로컬 저장)
|
||||||
|
await _savePurchaseState(true);
|
||||||
|
debugPrint('[IAPService] Ad removal purchased successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 구매 완료 처리
|
||||||
|
await _completePurchase(purchase);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 구매 완료 처리 (필수)
|
||||||
|
Future<void> _completePurchase(PurchaseDetails purchase) async {
|
||||||
|
if (purchase.pendingCompletePurchase) {
|
||||||
|
await _iap.completePurchase(purchase);
|
||||||
|
debugPrint('[IAPService] Purchase completed: ${purchase.productID}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 정리
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// 리소스 해제
|
||||||
|
void dispose() {
|
||||||
|
_subscription?.cancel();
|
||||||
|
_subscription = null;
|
||||||
|
debugPrint('[IAPService] Disposed');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,23 +60,26 @@ class ItemService {
|
|||||||
// 희귀도 결정
|
// 희귀도 결정
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/// 희귀도 결정 (레벨 기반 확률)
|
/// 희귀도 결정 (고정 확률)
|
||||||
///
|
///
|
||||||
/// 레벨이 높을수록 희귀한 아이템 확률 증가
|
/// 확률 분포:
|
||||||
|
/// - Common: 34%
|
||||||
|
/// - Uncommon: 40%
|
||||||
|
/// - Rare: 20%
|
||||||
|
/// - Epic: 5%
|
||||||
|
/// - Legendary: 1%
|
||||||
ItemRarity determineRarity(int level) {
|
ItemRarity determineRarity(int level) {
|
||||||
final roll = rng.nextInt(100);
|
final roll = rng.nextInt(100);
|
||||||
final legendaryChance = (level * 0.5).clamp(0, 5).toInt(); // 최대 5%
|
|
||||||
final epicChance = (level * 1.0).clamp(0, 10).toInt(); // 최대 10%
|
|
||||||
final rareChance = (level * 2.0).clamp(0, 20).toInt(); // 최대 20%
|
|
||||||
final uncommonChance = (level * 3.0).clamp(0, 30).toInt(); // 최대 30%
|
|
||||||
|
|
||||||
if (roll < legendaryChance) return ItemRarity.legendary;
|
// Legendary: 0-0 (1%)
|
||||||
if (roll < legendaryChance + epicChance) return ItemRarity.epic;
|
if (roll < 1) return ItemRarity.legendary;
|
||||||
if (roll < legendaryChance + epicChance + rareChance)
|
// Epic: 1-5 (5%)
|
||||||
return ItemRarity.rare;
|
if (roll < 6) return ItemRarity.epic;
|
||||||
if (roll < legendaryChance + epicChance + rareChance + uncommonChance) {
|
// Rare: 6-25 (20%)
|
||||||
return ItemRarity.uncommon;
|
if (roll < 26) return ItemRarity.rare;
|
||||||
}
|
// Uncommon: 26-65 (40%)
|
||||||
|
if (roll < 66) return ItemRarity.uncommon;
|
||||||
|
// Common: 66-99 (34%)
|
||||||
return ItemRarity.common;
|
return ItemRarity.common;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -972,12 +972,16 @@ class ProgressService {
|
|||||||
String? lostItemName;
|
String? lostItemName;
|
||||||
EquipmentSlot? lostItemSlot;
|
EquipmentSlot? lostItemSlot;
|
||||||
ItemRarity? lostItemRarity;
|
ItemRarity? lostItemRarity;
|
||||||
|
EquipmentItem? lostEquipmentItem; // 광고 부활 시 복구용
|
||||||
|
|
||||||
if (!isBossDeath) {
|
if (!isBossDeath) {
|
||||||
// 레벨 기반 장비 손실 확률 계산
|
// 레벨 기반 장비 손실 확률 계산
|
||||||
// Lv 1~5: 0%, Lv 6: 10%, Lv 10: 50%, Lv 15+: 100%
|
// Lv 1: 20%, Lv 5: ~56%, Lv 10+: 100%
|
||||||
|
// 공식: 20 + (level - 1) * 80 / 9
|
||||||
final level = state.traits.level;
|
final level = state.traits.level;
|
||||||
final lossChancePercent = ((level - 5) * 10).clamp(0, 100);
|
final lossChancePercent = level >= 10
|
||||||
|
? 100
|
||||||
|
: (20 + ((level - 1) * 80 ~/ 9)).clamp(0, 100);
|
||||||
final roll = state.rng.nextInt(100); // 0~99
|
final roll = state.rng.nextInt(100); // 0~99
|
||||||
final shouldLoseEquipment = roll < lossChancePercent;
|
final shouldLoseEquipment = roll < lossChancePercent;
|
||||||
|
|
||||||
@@ -1004,10 +1008,10 @@ class ProgressService {
|
|||||||
)];
|
)];
|
||||||
|
|
||||||
// 제물로 바칠 아이템 정보 저장
|
// 제물로 바칠 아이템 정보 저장
|
||||||
final lostItem = state.equipment.getItemByIndex(sacrificeIndex);
|
lostEquipmentItem = state.equipment.getItemByIndex(sacrificeIndex);
|
||||||
lostItemName = lostItem.name;
|
lostItemName = lostEquipmentItem.name;
|
||||||
lostItemSlot = EquipmentSlot.values[sacrificeIndex];
|
lostItemSlot = EquipmentSlot.values[sacrificeIndex];
|
||||||
lostItemRarity = lostItem.rarity;
|
lostItemRarity = lostEquipmentItem.rarity;
|
||||||
|
|
||||||
// 해당 슬롯을 빈 장비로 교체
|
// 해당 슬롯을 빈 장비로 교체
|
||||||
newEquipment = newEquipment.setItemByIndex(
|
newEquipment = newEquipment.setItemByIndex(
|
||||||
@@ -1029,6 +1033,7 @@ class ProgressService {
|
|||||||
lostItemName: lostItemName,
|
lostItemName: lostItemName,
|
||||||
lostItemSlot: lostItemSlot,
|
lostItemSlot: lostItemSlot,
|
||||||
lostItemRarity: lostItemRarity,
|
lostItemRarity: lostItemRarity,
|
||||||
|
lostItem: lostEquipmentItem, // 광고 부활 시 복구용
|
||||||
goldAtDeath: state.inventory.gold,
|
goldAtDeath: state.inventory.gold,
|
||||||
levelAtDeath: state.traits.level,
|
levelAtDeath: state.traits.level,
|
||||||
timestamp: state.skillSystem.elapsedMs,
|
timestamp: state.skillSystem.elapsedMs,
|
||||||
|
|||||||
@@ -240,6 +240,129 @@ class ResurrectionService {
|
|||||||
return state.inventory.gold >= cost;
|
return state.inventory.gold >= cost;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 광고 부활 (HP 100% + 아이템 복구)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// 광고 부활 처리
|
||||||
|
///
|
||||||
|
/// 1. 상실한 아이템 복구 (있는 경우)
|
||||||
|
/// 2. HP/MP 100% 회복
|
||||||
|
/// 3. 사망 상태 해제
|
||||||
|
/// 4. 안전 지역으로 이동 태스크 설정
|
||||||
|
///
|
||||||
|
/// Note: 10분 자동부활 버프는 GameSessionController에서 처리
|
||||||
|
GameState processAdRevive(GameState state) {
|
||||||
|
if (!state.isDead) return state;
|
||||||
|
|
||||||
|
var nextState = state;
|
||||||
|
|
||||||
|
// 1. 상실한 아이템 복구 (있는 경우)
|
||||||
|
if (canRecoverLostItem(state)) {
|
||||||
|
nextState = processItemRecovery(nextState);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 전체 HP/MP 계산 (장비 + 종족 + 클래스 보너스 포함)
|
||||||
|
final totalHpMax = _calculateTotalHpMax(nextState);
|
||||||
|
final totalMpMax = _calculateTotalMpMax(nextState);
|
||||||
|
|
||||||
|
// HP/MP 100% 회복 (장비 구매 없이)
|
||||||
|
nextState = nextState.copyWith(
|
||||||
|
stats: nextState.stats.copyWith(
|
||||||
|
hpCurrent: totalHpMax,
|
||||||
|
mpCurrent: totalMpMax,
|
||||||
|
),
|
||||||
|
clearDeathInfo: true, // 사망 상태 해제
|
||||||
|
);
|
||||||
|
|
||||||
|
// 스킬 쿨타임 초기화
|
||||||
|
nextState = nextState.copyWith(
|
||||||
|
skillSystem: SkillSystemState.empty().copyWith(
|
||||||
|
elapsedMs: nextState.skillSystem.elapsedMs,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. 부활 후 태스크 시퀀스 설정
|
||||||
|
final resurrectionQueue = <QueueEntry>[
|
||||||
|
QueueEntry(
|
||||||
|
kind: QueueKind.task,
|
||||||
|
durationMillis: 3000,
|
||||||
|
caption: l10n.taskReturningToTown,
|
||||||
|
taskType: TaskType.neutral,
|
||||||
|
),
|
||||||
|
QueueEntry(
|
||||||
|
kind: QueueKind.task,
|
||||||
|
durationMillis: 3000,
|
||||||
|
caption: l10n.taskRestockingAtShop,
|
||||||
|
taskType: TaskType.market,
|
||||||
|
),
|
||||||
|
QueueEntry(
|
||||||
|
kind: QueueKind.task,
|
||||||
|
durationMillis: 2000,
|
||||||
|
caption: l10n.taskHeadingToHuntingGrounds,
|
||||||
|
taskType: TaskType.neutral,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
final firstTask = resurrectionQueue.removeAt(0);
|
||||||
|
nextState = nextState.copyWith(
|
||||||
|
queue: QueueState(entries: resurrectionQueue),
|
||||||
|
progress: nextState.progress.copyWith(
|
||||||
|
currentTask: TaskInfo(
|
||||||
|
caption: firstTask.caption,
|
||||||
|
type: firstTask.taskType,
|
||||||
|
),
|
||||||
|
task: ProgressBarState(position: 0, max: firstTask.durationMillis),
|
||||||
|
currentCombat: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return nextState;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 아이템 복구 (Phase 5)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// 상실한 아이템 복구 가능 여부 확인
|
||||||
|
///
|
||||||
|
/// 사망 상태이고 상실한 아이템이 있을 때만 true
|
||||||
|
bool canRecoverLostItem(GameState state) {
|
||||||
|
if (!state.isDead) return false;
|
||||||
|
if (state.deathInfo == null) return false;
|
||||||
|
return state.deathInfo!.lostItem != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 상실한 아이템 복구 처리
|
||||||
|
///
|
||||||
|
/// 광고 시청 후 호출되며 상실한 아이템을 장비에 복원합니다.
|
||||||
|
/// Returns: 복구된 상태 (복구 불가 시 원본 상태 반환)
|
||||||
|
GameState processItemRecovery(GameState state) {
|
||||||
|
if (!canRecoverLostItem(state)) return state;
|
||||||
|
|
||||||
|
final deathInfo = state.deathInfo!;
|
||||||
|
final lostItem = deathInfo.lostItem!;
|
||||||
|
final lostSlot = deathInfo.lostItemSlot!;
|
||||||
|
|
||||||
|
// 해당 슬롯에 아이템 복원
|
||||||
|
final slotIndex = lostSlot.index;
|
||||||
|
final updatedEquipment = state.equipment.setItemByIndex(slotIndex, lostItem);
|
||||||
|
|
||||||
|
// DeathInfo에서 상실 아이템 정보 제거 (복구 완료)
|
||||||
|
final updatedDeathInfo = deathInfo.copyWith(
|
||||||
|
lostEquipmentCount: 0,
|
||||||
|
lostItemName: null,
|
||||||
|
lostItemSlot: null,
|
||||||
|
lostItemRarity: null,
|
||||||
|
lostItem: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
return state.copyWith(
|
||||||
|
equipment: updatedEquipment,
|
||||||
|
deathInfo: updatedDeathInfo,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// 장비 보존 아이템 적용 (향후 확장용)
|
/// 장비 보존 아이템 적용 (향후 확장용)
|
||||||
///
|
///
|
||||||
/// [protectedSlots] 보존할 슬롯 인덱스 목록
|
/// [protectedSlots] 보존할 슬롯 인덱스 목록
|
||||||
|
|||||||
178
lib/src/core/engine/return_rewards_service.dart
Normal file
178
lib/src/core/engine/return_rewards_service.dart
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import 'package:asciineverdie/src/core/engine/ad_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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 복귀 보상 서비스 (Phase 7)
|
||||||
|
///
|
||||||
|
/// 플레이어가 게임에 복귀했을 때 떠나있던 시간에 따라 보상을 제공합니다.
|
||||||
|
/// - 최소 복귀 시간: 1시간
|
||||||
|
/// - 최대 복귀 시간: 24시간 (그 이상은 24시간으로 계산)
|
||||||
|
/// - 기본 보상: 시간당 100골드
|
||||||
|
/// - 보너스 보상: 광고 시청 시 2배
|
||||||
|
class ReturnRewardsService {
|
||||||
|
ReturnRewardsService._();
|
||||||
|
|
||||||
|
static ReturnRewardsService? _instance;
|
||||||
|
|
||||||
|
/// 싱글톤 인스턴스
|
||||||
|
static ReturnRewardsService get instance {
|
||||||
|
_instance ??= ReturnRewardsService._();
|
||||||
|
return _instance!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 상수
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// 최소 복귀 시간 (시간)
|
||||||
|
static const int minHoursAway = 1;
|
||||||
|
|
||||||
|
/// 최대 복귀 시간 (시간) - 이 이상은 동일 보상
|
||||||
|
static const int maxHoursAway = 24;
|
||||||
|
|
||||||
|
/// 시간당 골드 보상
|
||||||
|
static const int goldPerHour = 100;
|
||||||
|
|
||||||
|
/// 레벨당 골드 보상 배수
|
||||||
|
static const double goldPerLevelMultiplier = 0.1;
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 보상 계산
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// 복귀 보상 계산
|
||||||
|
///
|
||||||
|
/// [lastPlayTime] 마지막 플레이 시각 (null이면 보상 없음)
|
||||||
|
/// [currentTime] 현재 시각
|
||||||
|
/// [playerLevel] 플레이어 레벨 (레벨 보너스 계산용)
|
||||||
|
/// Returns: 복귀 보상 데이터 (시간이 부족하면 hasReward = false)
|
||||||
|
ReturnReward calculateReward({
|
||||||
|
required DateTime? lastPlayTime,
|
||||||
|
required DateTime currentTime,
|
||||||
|
required int playerLevel,
|
||||||
|
}) {
|
||||||
|
// 마지막 플레이 시간이 없으면 보상 없음
|
||||||
|
if (lastPlayTime == null) {
|
||||||
|
debugPrint('[ReturnRewards] No lastPlayTime, no reward');
|
||||||
|
return const ReturnReward(hoursAway: 0, goldReward: 0, bonusGold: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 경과 시간 계산
|
||||||
|
final difference = currentTime.difference(lastPlayTime);
|
||||||
|
final hoursAway = difference.inHours;
|
||||||
|
|
||||||
|
// 최소 시간 미만이면 보상 없음
|
||||||
|
if (hoursAway < minHoursAway) {
|
||||||
|
debugPrint('[ReturnRewards] Only $hoursAway hours, need $minHoursAway');
|
||||||
|
return ReturnReward(hoursAway: hoursAway, goldReward: 0, bonusGold: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 최대 시간 초과 시 최대로 제한
|
||||||
|
final effectiveHours = hoursAway > maxHoursAway ? maxHoursAway : hoursAway;
|
||||||
|
|
||||||
|
// 골드 보상 계산 (레벨 보너스 포함)
|
||||||
|
final levelMultiplier = 1.0 + (playerLevel * goldPerLevelMultiplier);
|
||||||
|
final baseGold = (effectiveHours * goldPerHour * levelMultiplier).round();
|
||||||
|
|
||||||
|
// 보너스 골드 (광고 시청 시 100% 추가)
|
||||||
|
final bonusGold = baseGold;
|
||||||
|
|
||||||
|
debugPrint('[ReturnRewards] $hoursAway hours away, '
|
||||||
|
'base=$baseGold, bonus=$bonusGold, level=$playerLevel');
|
||||||
|
|
||||||
|
return ReturnReward(
|
||||||
|
hoursAway: hoursAway,
|
||||||
|
goldReward: baseGold,
|
||||||
|
bonusGold: bonusGold,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 보상 수령
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// 기본 보상 수령 (광고 없이)
|
||||||
|
///
|
||||||
|
/// [reward] 복귀 보상 데이터
|
||||||
|
/// Returns: 수령한 골드 양
|
||||||
|
int claimBasicReward(ReturnReward reward) {
|
||||||
|
if (!reward.hasReward) return 0;
|
||||||
|
debugPrint('[ReturnRewards] Basic reward claimed: ${reward.goldReward}');
|
||||||
|
return reward.goldReward;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 보너스 보상 수령 (광고 시청 후)
|
||||||
|
///
|
||||||
|
/// 유료 유저: 무료 보너스
|
||||||
|
/// 무료 유저: 리워드 광고 시청 후 보너스
|
||||||
|
/// [reward] 복귀 보상 데이터
|
||||||
|
/// Returns: 수령한 보너스 골드 양 (광고 실패 시 0)
|
||||||
|
Future<int> claimBonusReward(ReturnReward reward) async {
|
||||||
|
if (!reward.hasReward || reward.bonusGold <= 0) return 0;
|
||||||
|
|
||||||
|
// 유료 유저는 무료 보너스
|
||||||
|
if (IAPService.instance.isAdRemovalPurchased) {
|
||||||
|
debugPrint('[ReturnRewards] Bonus claimed (paid user): ${reward.bonusGold}');
|
||||||
|
return reward.bonusGold;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 무료 유저는 리워드 광고 필요
|
||||||
|
int bonus = 0;
|
||||||
|
final adResult = await AdService.instance.showRewardedAd(
|
||||||
|
adType: AdType.rewardRevive, // 복귀 보상용 리워드 광고
|
||||||
|
onRewarded: () {
|
||||||
|
bonus = reward.bonusGold;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (adResult == AdResult.completed || adResult == AdResult.debugSkipped) {
|
||||||
|
debugPrint('[ReturnRewards] Bonus claimed (free user with ad): $bonus');
|
||||||
|
return bonus;
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('[ReturnRewards] Bonus claim failed: $adResult');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 시간 포맷팅
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// 복귀 시간을 표시용 문자열로 변환
|
||||||
|
String formatHoursAway(int hours) {
|
||||||
|
if (hours < 24) {
|
||||||
|
return '${hours}h';
|
||||||
|
}
|
||||||
|
final days = hours ~/ 24;
|
||||||
|
final remainingHours = hours % 24;
|
||||||
|
if (remainingHours == 0) {
|
||||||
|
return '${days}d';
|
||||||
|
}
|
||||||
|
return '${days}d ${remainingHours}h';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -133,6 +133,7 @@ class DeathInfo {
|
|||||||
this.lostItemName,
|
this.lostItemName,
|
||||||
this.lostItemSlot,
|
this.lostItemSlot,
|
||||||
this.lostItemRarity,
|
this.lostItemRarity,
|
||||||
|
this.lostItem,
|
||||||
this.lastCombatEvents = const [],
|
this.lastCombatEvents = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -154,6 +155,9 @@ class DeathInfo {
|
|||||||
/// 제물로 바친 아이템 희귀도 (null이면 없음)
|
/// 제물로 바친 아이템 희귀도 (null이면 없음)
|
||||||
final ItemRarity? lostItemRarity;
|
final ItemRarity? lostItemRarity;
|
||||||
|
|
||||||
|
/// 상실한 장비 전체 정보 (광고 부활 시 복구용)
|
||||||
|
final EquipmentItem? lostItem;
|
||||||
|
|
||||||
/// 사망 시점 골드
|
/// 사망 시점 골드
|
||||||
final int goldAtDeath;
|
final int goldAtDeath;
|
||||||
|
|
||||||
@@ -173,6 +177,7 @@ class DeathInfo {
|
|||||||
String? lostItemName,
|
String? lostItemName,
|
||||||
EquipmentSlot? lostItemSlot,
|
EquipmentSlot? lostItemSlot,
|
||||||
ItemRarity? lostItemRarity,
|
ItemRarity? lostItemRarity,
|
||||||
|
EquipmentItem? lostItem,
|
||||||
int? goldAtDeath,
|
int? goldAtDeath,
|
||||||
int? levelAtDeath,
|
int? levelAtDeath,
|
||||||
int? timestamp,
|
int? timestamp,
|
||||||
@@ -185,6 +190,7 @@ class DeathInfo {
|
|||||||
lostItemName: lostItemName ?? this.lostItemName,
|
lostItemName: lostItemName ?? this.lostItemName,
|
||||||
lostItemSlot: lostItemSlot ?? this.lostItemSlot,
|
lostItemSlot: lostItemSlot ?? this.lostItemSlot,
|
||||||
lostItemRarity: lostItemRarity ?? this.lostItemRarity,
|
lostItemRarity: lostItemRarity ?? this.lostItemRarity,
|
||||||
|
lostItem: lostItem ?? this.lostItem,
|
||||||
goldAtDeath: goldAtDeath ?? this.goldAtDeath,
|
goldAtDeath: goldAtDeath ?? this.goldAtDeath,
|
||||||
levelAtDeath: levelAtDeath ?? this.levelAtDeath,
|
levelAtDeath: levelAtDeath ?? this.levelAtDeath,
|
||||||
timestamp: timestamp ?? this.timestamp,
|
timestamp: timestamp ?? this.timestamp,
|
||||||
|
|||||||
162
lib/src/core/model/monetization_state.dart
Normal file
162
lib/src/core/model/monetization_state.dart
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||||
|
|
||||||
|
part 'monetization_state.freezed.dart';
|
||||||
|
part 'monetization_state.g.dart';
|
||||||
|
|
||||||
|
/// 수익화 시스템 상태
|
||||||
|
///
|
||||||
|
/// IAP 구매 여부, 광고 관련 버프, 복귀 보상 등을 관리
|
||||||
|
@freezed
|
||||||
|
class MonetizationState with _$MonetizationState {
|
||||||
|
const MonetizationState._();
|
||||||
|
|
||||||
|
const factory MonetizationState({
|
||||||
|
/// IAP 광고 제거 구매 여부
|
||||||
|
@Default(false) bool adRemovalPurchased,
|
||||||
|
|
||||||
|
/// 캐릭터 생성 굴리기 남은 횟수 (0-5)
|
||||||
|
@Default(5) int rollsRemaining,
|
||||||
|
|
||||||
|
/// 되돌리기 남은 횟수
|
||||||
|
@Default(1) int undoRemaining,
|
||||||
|
|
||||||
|
/// 되돌리기용 스탯 히스토리 (JSON 변환 커스텀)
|
||||||
|
@JsonKey(fromJson: _statsListFromJson, toJson: _statsListToJson)
|
||||||
|
List<Stats>? rollHistory,
|
||||||
|
|
||||||
|
/// 자동부활 버프 종료 시점 (elapsedMs 기준)
|
||||||
|
int? autoReviveEndMs,
|
||||||
|
|
||||||
|
/// 5배속 버프 종료 시점 (elapsedMs 기준)
|
||||||
|
int? speedBoostEndMs,
|
||||||
|
|
||||||
|
/// 마지막 플레이 시각 (복귀 보상 계산용)
|
||||||
|
@JsonKey(fromJson: _dateTimeFromJson, toJson: _dateTimeToJson)
|
||||||
|
DateTime? lastPlayTime,
|
||||||
|
|
||||||
|
/// 미개봉 보물 상자 개수
|
||||||
|
@Default(0) int pendingChests,
|
||||||
|
|
||||||
|
/// 행운의 부적 버프 종료 시점 (elapsedMs 기준)
|
||||||
|
int? luckyCharmEndMs,
|
||||||
|
}) = _MonetizationState;
|
||||||
|
|
||||||
|
factory MonetizationState.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$MonetizationStateFromJson(json);
|
||||||
|
|
||||||
|
/// 기본 상태 생성 (신규 게임)
|
||||||
|
factory MonetizationState.initial({bool isPaidUser = false}) {
|
||||||
|
return MonetizationState(
|
||||||
|
adRemovalPurchased: isPaidUser,
|
||||||
|
rollsRemaining: 5,
|
||||||
|
undoRemaining: isPaidUser ? 3 : 1,
|
||||||
|
rollHistory: null,
|
||||||
|
pendingChests: 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 유틸리티 메서드
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// 유료 사용자 여부
|
||||||
|
bool get isPaidUser => adRemovalPurchased;
|
||||||
|
|
||||||
|
/// 무료 사용자 여부
|
||||||
|
bool get isFreeUser => !adRemovalPurchased;
|
||||||
|
|
||||||
|
/// 자동부활 버프 활성 여부 (elapsedMs 기준)
|
||||||
|
bool isAutoReviveActive(int elapsedMs) {
|
||||||
|
if (autoReviveEndMs == null) return false;
|
||||||
|
return elapsedMs < autoReviveEndMs!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 5배속 버프 활성 여부 (elapsedMs 기준)
|
||||||
|
/// 유료 사용자는 항상 활성
|
||||||
|
bool isSpeedBoostActive(int elapsedMs) {
|
||||||
|
if (isPaidUser) return true;
|
||||||
|
if (speedBoostEndMs == null) return false;
|
||||||
|
return elapsedMs < speedBoostEndMs!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 행운의 부적 버프 활성 여부 (elapsedMs 기준)
|
||||||
|
bool isLuckyCharmActive(int elapsedMs) {
|
||||||
|
if (luckyCharmEndMs == null) return false;
|
||||||
|
return elapsedMs < luckyCharmEndMs!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 굴리기 가능 여부
|
||||||
|
bool get canRoll => rollsRemaining > 0;
|
||||||
|
|
||||||
|
/// 되돌리기 가능 여부
|
||||||
|
bool canUndo(int historyLength) {
|
||||||
|
if (undoRemaining <= 0) return false;
|
||||||
|
if (rollHistory == null || rollHistory!.isEmpty) return false;
|
||||||
|
return historyLength > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 실제 사용 가능한 되돌리기 횟수
|
||||||
|
int availableUndos(int historyLength) {
|
||||||
|
if (rollHistory == null) return 0;
|
||||||
|
final historyAvailable = rollHistory!.length;
|
||||||
|
return undoRemaining < historyAvailable ? undoRemaining : historyAvailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 최대 보물 상자 개수
|
||||||
|
int get maxChests => isPaidUser ? 10 : 5;
|
||||||
|
|
||||||
|
/// 보물 상자가 가득 찼는지 여부
|
||||||
|
bool get isChestsFull => pendingChests >= maxChests;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// JSON 변환 헬퍼
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Stats 리스트 → JSON
|
||||||
|
List<Map<String, dynamic>>? _statsListToJson(List<Stats>? stats) {
|
||||||
|
if (stats == null) return null;
|
||||||
|
return stats
|
||||||
|
.map((s) => {
|
||||||
|
'str': s.str,
|
||||||
|
'con': s.con,
|
||||||
|
'dex': s.dex,
|
||||||
|
'int': s.intelligence,
|
||||||
|
'wis': s.wis,
|
||||||
|
'cha': s.cha,
|
||||||
|
'hpMax': s.hpMax,
|
||||||
|
'mpMax': s.mpMax,
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON → Stats 리스트
|
||||||
|
List<Stats>? _statsListFromJson(List<dynamic>? json) {
|
||||||
|
if (json == null) return null;
|
||||||
|
return json.map((e) {
|
||||||
|
final m = e as Map<String, dynamic>;
|
||||||
|
return Stats(
|
||||||
|
str: m['str'] as int? ?? 0,
|
||||||
|
con: m['con'] as int? ?? 0,
|
||||||
|
dex: m['dex'] as int? ?? 0,
|
||||||
|
intelligence: m['int'] as int? ?? 0,
|
||||||
|
wis: m['wis'] as int? ?? 0,
|
||||||
|
cha: m['cha'] as int? ?? 0,
|
||||||
|
hpMax: m['hpMax'] as int? ?? 0,
|
||||||
|
mpMax: m['mpMax'] as int? ?? 0,
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DateTime → JSON (밀리초 타임스탬프)
|
||||||
|
int? _dateTimeToJson(DateTime? dateTime) {
|
||||||
|
return dateTime?.millisecondsSinceEpoch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON → DateTime
|
||||||
|
DateTime? _dateTimeFromJson(int? timestamp) {
|
||||||
|
if (timestamp == null) return null;
|
||||||
|
return DateTime.fromMillisecondsSinceEpoch(timestamp);
|
||||||
|
}
|
||||||
444
lib/src/core/model/monetization_state.freezed.dart
Normal file
444
lib/src/core/model/monetization_state.freezed.dart
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
// coverage:ignore-file
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||||
|
|
||||||
|
part of 'monetization_state.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// FreezedGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
T _$identity<T>(T value) => value;
|
||||||
|
|
||||||
|
final _privateConstructorUsedError = UnsupportedError(
|
||||||
|
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models',
|
||||||
|
);
|
||||||
|
|
||||||
|
MonetizationState _$MonetizationStateFromJson(Map<String, dynamic> json) {
|
||||||
|
return _MonetizationState.fromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
mixin _$MonetizationState {
|
||||||
|
/// IAP 광고 제거 구매 여부
|
||||||
|
bool get adRemovalPurchased => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
|
/// 캐릭터 생성 굴리기 남은 횟수 (0-5)
|
||||||
|
int get rollsRemaining => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
|
/// 되돌리기 남은 횟수
|
||||||
|
int get undoRemaining => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
|
/// 되돌리기용 스탯 히스토리 (JSON 변환 커스텀)
|
||||||
|
@JsonKey(fromJson: _statsListFromJson, toJson: _statsListToJson)
|
||||||
|
List<Stats>? get rollHistory => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
|
/// 자동부활 버프 종료 시점 (elapsedMs 기준)
|
||||||
|
int? get autoReviveEndMs => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
|
/// 5배속 버프 종료 시점 (elapsedMs 기준)
|
||||||
|
int? get speedBoostEndMs => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
|
/// 마지막 플레이 시각 (복귀 보상 계산용)
|
||||||
|
@JsonKey(fromJson: _dateTimeFromJson, toJson: _dateTimeToJson)
|
||||||
|
DateTime? get lastPlayTime => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
|
/// 미개봉 보물 상자 개수
|
||||||
|
int get pendingChests => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
|
/// 행운의 부적 버프 종료 시점 (elapsedMs 기준)
|
||||||
|
int? get luckyCharmEndMs => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
|
/// Serializes this MonetizationState to a JSON map.
|
||||||
|
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
|
/// Create a copy of MonetizationState
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
$MonetizationStateCopyWith<MonetizationState> get copyWith =>
|
||||||
|
throw _privateConstructorUsedError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract class $MonetizationStateCopyWith<$Res> {
|
||||||
|
factory $MonetizationStateCopyWith(
|
||||||
|
MonetizationState value,
|
||||||
|
$Res Function(MonetizationState) then,
|
||||||
|
) = _$MonetizationStateCopyWithImpl<$Res, MonetizationState>;
|
||||||
|
@useResult
|
||||||
|
$Res call({
|
||||||
|
bool adRemovalPurchased,
|
||||||
|
int rollsRemaining,
|
||||||
|
int undoRemaining,
|
||||||
|
@JsonKey(fromJson: _statsListFromJson, toJson: _statsListToJson)
|
||||||
|
List<Stats>? rollHistory,
|
||||||
|
int? autoReviveEndMs,
|
||||||
|
int? speedBoostEndMs,
|
||||||
|
@JsonKey(fromJson: _dateTimeFromJson, toJson: _dateTimeToJson)
|
||||||
|
DateTime? lastPlayTime,
|
||||||
|
int pendingChests,
|
||||||
|
int? luckyCharmEndMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
class _$MonetizationStateCopyWithImpl<$Res, $Val extends MonetizationState>
|
||||||
|
implements $MonetizationStateCopyWith<$Res> {
|
||||||
|
_$MonetizationStateCopyWithImpl(this._value, this._then);
|
||||||
|
|
||||||
|
// ignore: unused_field
|
||||||
|
final $Val _value;
|
||||||
|
// ignore: unused_field
|
||||||
|
final $Res Function($Val) _then;
|
||||||
|
|
||||||
|
/// Create a copy of MonetizationState
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
@override
|
||||||
|
$Res call({
|
||||||
|
Object? adRemovalPurchased = null,
|
||||||
|
Object? rollsRemaining = null,
|
||||||
|
Object? undoRemaining = null,
|
||||||
|
Object? rollHistory = freezed,
|
||||||
|
Object? autoReviveEndMs = freezed,
|
||||||
|
Object? speedBoostEndMs = freezed,
|
||||||
|
Object? lastPlayTime = freezed,
|
||||||
|
Object? pendingChests = null,
|
||||||
|
Object? luckyCharmEndMs = freezed,
|
||||||
|
}) {
|
||||||
|
return _then(
|
||||||
|
_value.copyWith(
|
||||||
|
adRemovalPurchased: null == adRemovalPurchased
|
||||||
|
? _value.adRemovalPurchased
|
||||||
|
: adRemovalPurchased // ignore: cast_nullable_to_non_nullable
|
||||||
|
as bool,
|
||||||
|
rollsRemaining: null == rollsRemaining
|
||||||
|
? _value.rollsRemaining
|
||||||
|
: rollsRemaining // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int,
|
||||||
|
undoRemaining: null == undoRemaining
|
||||||
|
? _value.undoRemaining
|
||||||
|
: undoRemaining // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int,
|
||||||
|
rollHistory: freezed == rollHistory
|
||||||
|
? _value.rollHistory
|
||||||
|
: rollHistory // ignore: cast_nullable_to_non_nullable
|
||||||
|
as List<Stats>?,
|
||||||
|
autoReviveEndMs: freezed == autoReviveEndMs
|
||||||
|
? _value.autoReviveEndMs
|
||||||
|
: autoReviveEndMs // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int?,
|
||||||
|
speedBoostEndMs: freezed == speedBoostEndMs
|
||||||
|
? _value.speedBoostEndMs
|
||||||
|
: speedBoostEndMs // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int?,
|
||||||
|
lastPlayTime: freezed == lastPlayTime
|
||||||
|
? _value.lastPlayTime
|
||||||
|
: lastPlayTime // ignore: cast_nullable_to_non_nullable
|
||||||
|
as DateTime?,
|
||||||
|
pendingChests: null == pendingChests
|
||||||
|
? _value.pendingChests
|
||||||
|
: pendingChests // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int,
|
||||||
|
luckyCharmEndMs: freezed == luckyCharmEndMs
|
||||||
|
? _value.luckyCharmEndMs
|
||||||
|
: luckyCharmEndMs // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int?,
|
||||||
|
)
|
||||||
|
as $Val,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract class _$$MonetizationStateImplCopyWith<$Res>
|
||||||
|
implements $MonetizationStateCopyWith<$Res> {
|
||||||
|
factory _$$MonetizationStateImplCopyWith(
|
||||||
|
_$MonetizationStateImpl value,
|
||||||
|
$Res Function(_$MonetizationStateImpl) then,
|
||||||
|
) = __$$MonetizationStateImplCopyWithImpl<$Res>;
|
||||||
|
@override
|
||||||
|
@useResult
|
||||||
|
$Res call({
|
||||||
|
bool adRemovalPurchased,
|
||||||
|
int rollsRemaining,
|
||||||
|
int undoRemaining,
|
||||||
|
@JsonKey(fromJson: _statsListFromJson, toJson: _statsListToJson)
|
||||||
|
List<Stats>? rollHistory,
|
||||||
|
int? autoReviveEndMs,
|
||||||
|
int? speedBoostEndMs,
|
||||||
|
@JsonKey(fromJson: _dateTimeFromJson, toJson: _dateTimeToJson)
|
||||||
|
DateTime? lastPlayTime,
|
||||||
|
int pendingChests,
|
||||||
|
int? luckyCharmEndMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
class __$$MonetizationStateImplCopyWithImpl<$Res>
|
||||||
|
extends _$MonetizationStateCopyWithImpl<$Res, _$MonetizationStateImpl>
|
||||||
|
implements _$$MonetizationStateImplCopyWith<$Res> {
|
||||||
|
__$$MonetizationStateImplCopyWithImpl(
|
||||||
|
_$MonetizationStateImpl _value,
|
||||||
|
$Res Function(_$MonetizationStateImpl) _then,
|
||||||
|
) : super(_value, _then);
|
||||||
|
|
||||||
|
/// Create a copy of MonetizationState
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
@override
|
||||||
|
$Res call({
|
||||||
|
Object? adRemovalPurchased = null,
|
||||||
|
Object? rollsRemaining = null,
|
||||||
|
Object? undoRemaining = null,
|
||||||
|
Object? rollHistory = freezed,
|
||||||
|
Object? autoReviveEndMs = freezed,
|
||||||
|
Object? speedBoostEndMs = freezed,
|
||||||
|
Object? lastPlayTime = freezed,
|
||||||
|
Object? pendingChests = null,
|
||||||
|
Object? luckyCharmEndMs = freezed,
|
||||||
|
}) {
|
||||||
|
return _then(
|
||||||
|
_$MonetizationStateImpl(
|
||||||
|
adRemovalPurchased: null == adRemovalPurchased
|
||||||
|
? _value.adRemovalPurchased
|
||||||
|
: adRemovalPurchased // ignore: cast_nullable_to_non_nullable
|
||||||
|
as bool,
|
||||||
|
rollsRemaining: null == rollsRemaining
|
||||||
|
? _value.rollsRemaining
|
||||||
|
: rollsRemaining // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int,
|
||||||
|
undoRemaining: null == undoRemaining
|
||||||
|
? _value.undoRemaining
|
||||||
|
: undoRemaining // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int,
|
||||||
|
rollHistory: freezed == rollHistory
|
||||||
|
? _value._rollHistory
|
||||||
|
: rollHistory // ignore: cast_nullable_to_non_nullable
|
||||||
|
as List<Stats>?,
|
||||||
|
autoReviveEndMs: freezed == autoReviveEndMs
|
||||||
|
? _value.autoReviveEndMs
|
||||||
|
: autoReviveEndMs // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int?,
|
||||||
|
speedBoostEndMs: freezed == speedBoostEndMs
|
||||||
|
? _value.speedBoostEndMs
|
||||||
|
: speedBoostEndMs // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int?,
|
||||||
|
lastPlayTime: freezed == lastPlayTime
|
||||||
|
? _value.lastPlayTime
|
||||||
|
: lastPlayTime // ignore: cast_nullable_to_non_nullable
|
||||||
|
as DateTime?,
|
||||||
|
pendingChests: null == pendingChests
|
||||||
|
? _value.pendingChests
|
||||||
|
: pendingChests // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int,
|
||||||
|
luckyCharmEndMs: freezed == luckyCharmEndMs
|
||||||
|
? _value.luckyCharmEndMs
|
||||||
|
: luckyCharmEndMs // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int?,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
@JsonSerializable()
|
||||||
|
class _$MonetizationStateImpl extends _MonetizationState {
|
||||||
|
const _$MonetizationStateImpl({
|
||||||
|
this.adRemovalPurchased = false,
|
||||||
|
this.rollsRemaining = 5,
|
||||||
|
this.undoRemaining = 1,
|
||||||
|
@JsonKey(fromJson: _statsListFromJson, toJson: _statsListToJson)
|
||||||
|
final List<Stats>? rollHistory,
|
||||||
|
this.autoReviveEndMs,
|
||||||
|
this.speedBoostEndMs,
|
||||||
|
@JsonKey(fromJson: _dateTimeFromJson, toJson: _dateTimeToJson)
|
||||||
|
this.lastPlayTime,
|
||||||
|
this.pendingChests = 0,
|
||||||
|
this.luckyCharmEndMs,
|
||||||
|
}) : _rollHistory = rollHistory,
|
||||||
|
super._();
|
||||||
|
|
||||||
|
factory _$MonetizationStateImpl.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$$MonetizationStateImplFromJson(json);
|
||||||
|
|
||||||
|
/// IAP 광고 제거 구매 여부
|
||||||
|
@override
|
||||||
|
@JsonKey()
|
||||||
|
final bool adRemovalPurchased;
|
||||||
|
|
||||||
|
/// 캐릭터 생성 굴리기 남은 횟수 (0-5)
|
||||||
|
@override
|
||||||
|
@JsonKey()
|
||||||
|
final int rollsRemaining;
|
||||||
|
|
||||||
|
/// 되돌리기 남은 횟수
|
||||||
|
@override
|
||||||
|
@JsonKey()
|
||||||
|
final int undoRemaining;
|
||||||
|
|
||||||
|
/// 되돌리기용 스탯 히스토리 (JSON 변환 커스텀)
|
||||||
|
final List<Stats>? _rollHistory;
|
||||||
|
|
||||||
|
/// 되돌리기용 스탯 히스토리 (JSON 변환 커스텀)
|
||||||
|
@override
|
||||||
|
@JsonKey(fromJson: _statsListFromJson, toJson: _statsListToJson)
|
||||||
|
List<Stats>? get rollHistory {
|
||||||
|
final value = _rollHistory;
|
||||||
|
if (value == null) return null;
|
||||||
|
if (_rollHistory is EqualUnmodifiableListView) return _rollHistory;
|
||||||
|
// ignore: implicit_dynamic_type
|
||||||
|
return EqualUnmodifiableListView(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 자동부활 버프 종료 시점 (elapsedMs 기준)
|
||||||
|
@override
|
||||||
|
final int? autoReviveEndMs;
|
||||||
|
|
||||||
|
/// 5배속 버프 종료 시점 (elapsedMs 기준)
|
||||||
|
@override
|
||||||
|
final int? speedBoostEndMs;
|
||||||
|
|
||||||
|
/// 마지막 플레이 시각 (복귀 보상 계산용)
|
||||||
|
@override
|
||||||
|
@JsonKey(fromJson: _dateTimeFromJson, toJson: _dateTimeToJson)
|
||||||
|
final DateTime? lastPlayTime;
|
||||||
|
|
||||||
|
/// 미개봉 보물 상자 개수
|
||||||
|
@override
|
||||||
|
@JsonKey()
|
||||||
|
final int pendingChests;
|
||||||
|
|
||||||
|
/// 행운의 부적 버프 종료 시점 (elapsedMs 기준)
|
||||||
|
@override
|
||||||
|
final int? luckyCharmEndMs;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'MonetizationState(adRemovalPurchased: $adRemovalPurchased, rollsRemaining: $rollsRemaining, undoRemaining: $undoRemaining, rollHistory: $rollHistory, autoReviveEndMs: $autoReviveEndMs, speedBoostEndMs: $speedBoostEndMs, lastPlayTime: $lastPlayTime, pendingChests: $pendingChests, luckyCharmEndMs: $luckyCharmEndMs)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) ||
|
||||||
|
(other.runtimeType == runtimeType &&
|
||||||
|
other is _$MonetizationStateImpl &&
|
||||||
|
(identical(other.adRemovalPurchased, adRemovalPurchased) ||
|
||||||
|
other.adRemovalPurchased == adRemovalPurchased) &&
|
||||||
|
(identical(other.rollsRemaining, rollsRemaining) ||
|
||||||
|
other.rollsRemaining == rollsRemaining) &&
|
||||||
|
(identical(other.undoRemaining, undoRemaining) ||
|
||||||
|
other.undoRemaining == undoRemaining) &&
|
||||||
|
const DeepCollectionEquality().equals(
|
||||||
|
other._rollHistory,
|
||||||
|
_rollHistory,
|
||||||
|
) &&
|
||||||
|
(identical(other.autoReviveEndMs, autoReviveEndMs) ||
|
||||||
|
other.autoReviveEndMs == autoReviveEndMs) &&
|
||||||
|
(identical(other.speedBoostEndMs, speedBoostEndMs) ||
|
||||||
|
other.speedBoostEndMs == speedBoostEndMs) &&
|
||||||
|
(identical(other.lastPlayTime, lastPlayTime) ||
|
||||||
|
other.lastPlayTime == lastPlayTime) &&
|
||||||
|
(identical(other.pendingChests, pendingChests) ||
|
||||||
|
other.pendingChests == pendingChests) &&
|
||||||
|
(identical(other.luckyCharmEndMs, luckyCharmEndMs) ||
|
||||||
|
other.luckyCharmEndMs == luckyCharmEndMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(
|
||||||
|
runtimeType,
|
||||||
|
adRemovalPurchased,
|
||||||
|
rollsRemaining,
|
||||||
|
undoRemaining,
|
||||||
|
const DeepCollectionEquality().hash(_rollHistory),
|
||||||
|
autoReviveEndMs,
|
||||||
|
speedBoostEndMs,
|
||||||
|
lastPlayTime,
|
||||||
|
pendingChests,
|
||||||
|
luckyCharmEndMs,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Create a copy of MonetizationState
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@override
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
_$$MonetizationStateImplCopyWith<_$MonetizationStateImpl> get copyWith =>
|
||||||
|
__$$MonetizationStateImplCopyWithImpl<_$MonetizationStateImpl>(
|
||||||
|
this,
|
||||||
|
_$identity,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return _$$MonetizationStateImplToJson(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class _MonetizationState extends MonetizationState {
|
||||||
|
const factory _MonetizationState({
|
||||||
|
final bool adRemovalPurchased,
|
||||||
|
final int rollsRemaining,
|
||||||
|
final int undoRemaining,
|
||||||
|
@JsonKey(fromJson: _statsListFromJson, toJson: _statsListToJson)
|
||||||
|
final List<Stats>? rollHistory,
|
||||||
|
final int? autoReviveEndMs,
|
||||||
|
final int? speedBoostEndMs,
|
||||||
|
@JsonKey(fromJson: _dateTimeFromJson, toJson: _dateTimeToJson)
|
||||||
|
final DateTime? lastPlayTime,
|
||||||
|
final int pendingChests,
|
||||||
|
final int? luckyCharmEndMs,
|
||||||
|
}) = _$MonetizationStateImpl;
|
||||||
|
const _MonetizationState._() : super._();
|
||||||
|
|
||||||
|
factory _MonetizationState.fromJson(Map<String, dynamic> json) =
|
||||||
|
_$MonetizationStateImpl.fromJson;
|
||||||
|
|
||||||
|
/// IAP 광고 제거 구매 여부
|
||||||
|
@override
|
||||||
|
bool get adRemovalPurchased;
|
||||||
|
|
||||||
|
/// 캐릭터 생성 굴리기 남은 횟수 (0-5)
|
||||||
|
@override
|
||||||
|
int get rollsRemaining;
|
||||||
|
|
||||||
|
/// 되돌리기 남은 횟수
|
||||||
|
@override
|
||||||
|
int get undoRemaining;
|
||||||
|
|
||||||
|
/// 되돌리기용 스탯 히스토리 (JSON 변환 커스텀)
|
||||||
|
@override
|
||||||
|
@JsonKey(fromJson: _statsListFromJson, toJson: _statsListToJson)
|
||||||
|
List<Stats>? get rollHistory;
|
||||||
|
|
||||||
|
/// 자동부활 버프 종료 시점 (elapsedMs 기준)
|
||||||
|
@override
|
||||||
|
int? get autoReviveEndMs;
|
||||||
|
|
||||||
|
/// 5배속 버프 종료 시점 (elapsedMs 기준)
|
||||||
|
@override
|
||||||
|
int? get speedBoostEndMs;
|
||||||
|
|
||||||
|
/// 마지막 플레이 시각 (복귀 보상 계산용)
|
||||||
|
@override
|
||||||
|
@JsonKey(fromJson: _dateTimeFromJson, toJson: _dateTimeToJson)
|
||||||
|
DateTime? get lastPlayTime;
|
||||||
|
|
||||||
|
/// 미개봉 보물 상자 개수
|
||||||
|
@override
|
||||||
|
int get pendingChests;
|
||||||
|
|
||||||
|
/// 행운의 부적 버프 종료 시점 (elapsedMs 기준)
|
||||||
|
@override
|
||||||
|
int? get luckyCharmEndMs;
|
||||||
|
|
||||||
|
/// Create a copy of MonetizationState
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
_$$MonetizationStateImplCopyWith<_$MonetizationStateImpl> get copyWith =>
|
||||||
|
throw _privateConstructorUsedError;
|
||||||
|
}
|
||||||
35
lib/src/core/model/monetization_state.g.dart
Normal file
35
lib/src/core/model/monetization_state.g.dart
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'monetization_state.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
_$MonetizationStateImpl _$$MonetizationStateImplFromJson(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
) => _$MonetizationStateImpl(
|
||||||
|
adRemovalPurchased: json['adRemovalPurchased'] as bool? ?? false,
|
||||||
|
rollsRemaining: (json['rollsRemaining'] as num?)?.toInt() ?? 5,
|
||||||
|
undoRemaining: (json['undoRemaining'] as num?)?.toInt() ?? 1,
|
||||||
|
rollHistory: _statsListFromJson(json['rollHistory'] as List?),
|
||||||
|
autoReviveEndMs: (json['autoReviveEndMs'] as num?)?.toInt(),
|
||||||
|
speedBoostEndMs: (json['speedBoostEndMs'] as num?)?.toInt(),
|
||||||
|
lastPlayTime: _dateTimeFromJson((json['lastPlayTime'] as num?)?.toInt()),
|
||||||
|
pendingChests: (json['pendingChests'] as num?)?.toInt() ?? 0,
|
||||||
|
luckyCharmEndMs: (json['luckyCharmEndMs'] as num?)?.toInt(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$$MonetizationStateImplToJson(
|
||||||
|
_$MonetizationStateImpl instance,
|
||||||
|
) => <String, dynamic>{
|
||||||
|
'adRemovalPurchased': instance.adRemovalPurchased,
|
||||||
|
'rollsRemaining': instance.rollsRemaining,
|
||||||
|
'undoRemaining': instance.undoRemaining,
|
||||||
|
'rollHistory': _statsListToJson(instance.rollHistory),
|
||||||
|
'autoReviveEndMs': instance.autoReviveEndMs,
|
||||||
|
'speedBoostEndMs': instance.speedBoostEndMs,
|
||||||
|
'lastPlayTime': _dateTimeToJson(instance.lastPlayTime),
|
||||||
|
'pendingChests': instance.pendingChests,
|
||||||
|
'luckyCharmEndMs': instance.luckyCharmEndMs,
|
||||||
|
};
|
||||||
@@ -2,6 +2,7 @@ import 'dart:collection';
|
|||||||
|
|
||||||
import 'package:asciineverdie/src/core/model/equipment_item.dart';
|
import 'package:asciineverdie/src/core/model/equipment_item.dart';
|
||||||
import 'package:asciineverdie/src/core/model/equipment_slot.dart';
|
import 'package:asciineverdie/src/core/model/equipment_slot.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/monetization_state.dart';
|
||||||
import 'package:asciineverdie/src/core/model/monster_grade.dart';
|
import 'package:asciineverdie/src/core/model/monster_grade.dart';
|
||||||
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
|
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
|
||||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||||
@@ -9,7 +10,8 @@ import 'package:asciineverdie/src/core/model/game_state.dart';
|
|||||||
/// 세이브 파일 버전
|
/// 세이브 파일 버전
|
||||||
/// - v2: 장비 이름만 저장 (레거시)
|
/// - v2: 장비 이름만 저장 (레거시)
|
||||||
/// - v3: 장비 전체 정보 저장 (level, rarity, stats 포함)
|
/// - v3: 장비 전체 정보 저장 (level, rarity, stats 포함)
|
||||||
const int kSaveVersion = 3;
|
/// - v4: MonetizationState 추가, DeathInfo.lostItem 추가
|
||||||
|
const int kSaveVersion = 4;
|
||||||
|
|
||||||
class GameSave {
|
class GameSave {
|
||||||
GameSave({
|
GameSave({
|
||||||
@@ -23,9 +25,14 @@ class GameSave {
|
|||||||
required this.progress,
|
required this.progress,
|
||||||
required this.queue,
|
required this.queue,
|
||||||
this.cheatsEnabled = false,
|
this.cheatsEnabled = false,
|
||||||
|
this.monetization,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory GameSave.fromState(GameState state, {bool cheatsEnabled = false}) {
|
factory GameSave.fromState(
|
||||||
|
GameState state, {
|
||||||
|
bool cheatsEnabled = false,
|
||||||
|
MonetizationState? monetization,
|
||||||
|
}) {
|
||||||
return GameSave(
|
return GameSave(
|
||||||
version: kSaveVersion,
|
version: kSaveVersion,
|
||||||
rngState: state.rng.state,
|
rngState: state.rng.state,
|
||||||
@@ -37,6 +44,7 @@ class GameSave {
|
|||||||
progress: state.progress,
|
progress: state.progress,
|
||||||
queue: state.queue,
|
queue: state.queue,
|
||||||
cheatsEnabled: cheatsEnabled,
|
cheatsEnabled: cheatsEnabled,
|
||||||
|
monetization: monetization,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +59,9 @@ class GameSave {
|
|||||||
final QueueState queue;
|
final QueueState queue;
|
||||||
final bool cheatsEnabled;
|
final bool cheatsEnabled;
|
||||||
|
|
||||||
|
/// 수익화 시스템 상태 (v4+)
|
||||||
|
final MonetizationState? monetization;
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
return {
|
return {
|
||||||
'version': version,
|
'version': version,
|
||||||
@@ -132,6 +143,7 @@ class GameSave {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
|
if (monetization != null) 'monetization': monetization!.toJson(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,6 +256,9 @@ class GameSave {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
monetization: _monetizationFromJson(
|
||||||
|
json['monetization'] as Map<String, dynamic>?,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,3 +370,12 @@ Equipment _equipmentFromJson(Map<String, dynamic> json, int version) {
|
|||||||
bestIndex: json['bestIndex'] as int? ?? 0,
|
bestIndex: json['bestIndex'] as int? ?? 0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// MonetizationState 역직렬화 (v4+ 마이그레이션)
|
||||||
|
///
|
||||||
|
/// - v3 이하: null (기본값 사용)
|
||||||
|
/// - v4 이상: 저장된 상태 로드
|
||||||
|
MonetizationState? _monetizationFromJson(Map<String, dynamic>? json) {
|
||||||
|
if (json == null) return null;
|
||||||
|
return MonetizationState.fromJson(json);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/monetization_state.dart';
|
||||||
import 'package:asciineverdie/src/core/model/save_data.dart';
|
import 'package:asciineverdie/src/core/model/save_data.dart';
|
||||||
import 'package:asciineverdie/src/core/storage/save_repository.dart';
|
import 'package:asciineverdie/src/core/storage/save_repository.dart';
|
||||||
import 'package:asciineverdie/src/core/storage/save_service.dart'
|
import 'package:asciineverdie/src/core/storage/save_service.dart'
|
||||||
@@ -13,23 +14,36 @@ class SaveManager {
|
|||||||
|
|
||||||
/// Save current game state to disk. [fileName] may be absolute or relative.
|
/// Save current game state to disk. [fileName] may be absolute or relative.
|
||||||
/// Returns outcome with error on failure.
|
/// Returns outcome with error on failure.
|
||||||
|
///
|
||||||
|
/// [monetization] 저장 시 lastPlayTime을 현재 시간으로 자동 업데이트
|
||||||
Future<SaveOutcome> saveState(
|
Future<SaveOutcome> saveState(
|
||||||
GameState state, {
|
GameState state, {
|
||||||
String? fileName,
|
String? fileName,
|
||||||
bool cheatsEnabled = false,
|
bool cheatsEnabled = false,
|
||||||
|
MonetizationState? monetization,
|
||||||
}) {
|
}) {
|
||||||
final save = GameSave.fromState(state, cheatsEnabled: cheatsEnabled);
|
// lastPlayTime을 현재 시간으로 업데이트
|
||||||
|
final updatedMonetization = (monetization ?? MonetizationState.initial())
|
||||||
|
.copyWith(lastPlayTime: DateTime.now());
|
||||||
|
|
||||||
|
final save = GameSave.fromState(
|
||||||
|
state,
|
||||||
|
cheatsEnabled: cheatsEnabled,
|
||||||
|
monetization: updatedMonetization,
|
||||||
|
);
|
||||||
return _repo.save(save, fileName ?? defaultFileName);
|
return _repo.save(save, fileName ?? defaultFileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load game state from disk. [fileName] may be absolute (e.g., file picker).
|
/// Load game state from disk. [fileName] may be absolute (e.g., file picker).
|
||||||
/// Returns outcome + optional state + cheatsEnabled flag.
|
/// Returns outcome + optional state + cheatsEnabled flag + monetization state.
|
||||||
Future<(SaveOutcome, GameState?, bool)> loadState({String? fileName}) async {
|
Future<(SaveOutcome, GameState?, bool, MonetizationState?)> loadState({
|
||||||
|
String? fileName,
|
||||||
|
}) async {
|
||||||
final (outcome, save) = await _repo.load(fileName ?? defaultFileName);
|
final (outcome, save) = await _repo.load(fileName ?? defaultFileName);
|
||||||
if (!outcome.success || save == null) {
|
if (!outcome.success || save == null) {
|
||||||
return (outcome, null, false);
|
return (outcome, null, false, null);
|
||||||
}
|
}
|
||||||
return (outcome, save.toState(), save.cheatsEnabled);
|
return (outcome, save.toState(), save.cheatsEnabled, save.monetization);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 저장 파일 목록 조회
|
/// 저장 파일 목록 조회
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class FrontScreen extends StatefulWidget {
|
|||||||
this.onLoadSave,
|
this.onLoadSave,
|
||||||
this.onHallOfFame,
|
this.onHallOfFame,
|
||||||
this.onLocalArena,
|
this.onLocalArena,
|
||||||
|
this.onSettings,
|
||||||
this.hasSaveFile = false,
|
this.hasSaveFile = false,
|
||||||
this.savedGamePreview,
|
this.savedGamePreview,
|
||||||
this.hallOfFameCount = 0,
|
this.hallOfFameCount = 0,
|
||||||
@@ -36,6 +37,9 @@ class FrontScreen extends StatefulWidget {
|
|||||||
/// "Local Arena" 버튼 클릭 시 호출
|
/// "Local Arena" 버튼 클릭 시 호출
|
||||||
final void Function(BuildContext context)? onLocalArena;
|
final void Function(BuildContext context)? onLocalArena;
|
||||||
|
|
||||||
|
/// "Settings" 버튼 클릭 시 호출 (언어, 테마, 사운드)
|
||||||
|
final void Function(BuildContext context)? onSettings;
|
||||||
|
|
||||||
/// 세이브 파일 존재 여부 (새 캐릭터 시 경고용)
|
/// 세이브 파일 존재 여부 (새 캐릭터 시 경고용)
|
||||||
final bool hasSaveFile;
|
final bool hasSaveFile;
|
||||||
|
|
||||||
@@ -147,6 +151,9 @@ class _FrontScreenState extends State<FrontScreen> with RouteAware {
|
|||||||
widget.hallOfFameCount >= 2
|
widget.hallOfFameCount >= 2
|
||||||
? () => widget.onLocalArena!(context)
|
? () => widget.onLocalArena!(context)
|
||||||
: null,
|
: null,
|
||||||
|
onSettings: widget.onSettings != null
|
||||||
|
? () => widget.onSettings!(context)
|
||||||
|
: null,
|
||||||
savedGamePreview: widget.savedGamePreview,
|
savedGamePreview: widget.savedGamePreview,
|
||||||
hallOfFameCount: widget.hallOfFameCount,
|
hallOfFameCount: widget.hallOfFameCount,
|
||||||
),
|
),
|
||||||
@@ -249,6 +256,7 @@ class _ActionButtons extends StatelessWidget {
|
|||||||
this.onLoadSave,
|
this.onLoadSave,
|
||||||
this.onHallOfFame,
|
this.onHallOfFame,
|
||||||
this.onLocalArena,
|
this.onLocalArena,
|
||||||
|
this.onSettings,
|
||||||
this.savedGamePreview,
|
this.savedGamePreview,
|
||||||
this.hallOfFameCount = 0,
|
this.hallOfFameCount = 0,
|
||||||
});
|
});
|
||||||
@@ -257,6 +265,7 @@ class _ActionButtons extends StatelessWidget {
|
|||||||
final VoidCallback? onLoadSave;
|
final VoidCallback? onLoadSave;
|
||||||
final VoidCallback? onHallOfFame;
|
final VoidCallback? onHallOfFame;
|
||||||
final VoidCallback? onLocalArena;
|
final VoidCallback? onLocalArena;
|
||||||
|
final VoidCallback? onSettings;
|
||||||
final SavedGamePreview? savedGamePreview;
|
final SavedGamePreview? savedGamePreview;
|
||||||
final int hallOfFameCount;
|
final int hallOfFameCount;
|
||||||
|
|
||||||
@@ -306,6 +315,14 @@ class _ActionButtons extends StatelessWidget {
|
|||||||
onPressed: hallOfFameCount >= 2 ? onLocalArena : null,
|
onPressed: hallOfFameCount >= 2 ? onLocalArena : null,
|
||||||
isPrimary: false,
|
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 {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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/game_text_l10n.dart' as game_l10n;
|
||||||
import 'package:asciineverdie/data/skill_data.dart';
|
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/data/story_data.dart';
|
||||||
import 'package:asciineverdie/l10n/app_localizations.dart';
|
import 'package:asciineverdie/l10n/app_localizations.dart';
|
||||||
import 'package:asciineverdie/src/core/animation/ascii_animation_type.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/potion_inventory_panel.dart';
|
||||||
import 'package:asciineverdie/src/features/game/widgets/task_progress_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/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/game/layouts/mobile_carousel_layout.dart';
|
||||||
import 'package:asciineverdie/src/features/settings/settings_screen.dart';
|
import 'package:asciineverdie/src/features/settings/settings_screen.dart';
|
||||||
import 'package:asciineverdie/src/features/game/widgets/statistics_dialog.dart';
|
import 'package:asciineverdie/src/features/game/widgets/statistics_dialog.dart';
|
||||||
@@ -246,6 +249,9 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
|||||||
|
|
||||||
// 오디오 볼륨 초기화
|
// 오디오 볼륨 초기화
|
||||||
_audioController.initVolumes();
|
_audioController.initVolumes();
|
||||||
|
|
||||||
|
// Phase 7: 복귀 보상 콜백 설정
|
||||||
|
widget.controller.onReturnRewardAvailable = _showReturnRewardsDialog;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -262,6 +268,7 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
|||||||
_storyService.dispose();
|
_storyService.dispose();
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
widget.controller.removeListener(_onControllerChanged);
|
widget.controller.removeListener(_onControllerChanged);
|
||||||
|
widget.controller.onReturnRewardAvailable = null; // Phase 7: 콜백 정리
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,6 +406,21 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
|||||||
return platform == TargetPlatform.iOS || platform == TargetPlatform.android;
|
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) {
|
void _showStatisticsDialog(BuildContext context) {
|
||||||
StatisticsDialog.show(
|
StatisticsDialog.show(
|
||||||
@@ -486,6 +508,47 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 광고 부활 핸들러 (HP 100% + 아이템 복구 + 10분 자동부활)
|
||||||
|
Future<void> _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<void> _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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final state = widget.controller.state;
|
final state = widget.controller.state;
|
||||||
@@ -603,6 +666,11 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
|||||||
navigator.popUntil((route) => route.isFirst);
|
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)
|
if (state.isDead && state.deathInfo != null)
|
||||||
@@ -610,12 +678,8 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
|||||||
deathInfo: state.deathInfo!,
|
deathInfo: state.deathInfo!,
|
||||||
traits: state.traits,
|
traits: state.traits,
|
||||||
onResurrect: _handleResurrect,
|
onResurrect: _handleResurrect,
|
||||||
isAutoResurrectEnabled: widget.controller.autoResurrect,
|
onAdRevive: _handleAdRevive,
|
||||||
onToggleAutoResurrect: () {
|
isPaidUser: IAPService.instance.isAdRemovalPurchased,
|
||||||
widget.controller.setAutoResurrect(
|
|
||||||
!widget.controller.autoResurrect,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
// 승리 오버레이 (게임 클리어)
|
// 승리 오버레이 (게임 클리어)
|
||||||
if (widget.controller.isComplete)
|
if (widget.controller.isComplete)
|
||||||
@@ -759,18 +823,14 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
// Phase 4: 사망 오버레이 (Death Overlay)
|
// 사망 오버레이
|
||||||
if (state.isDead && state.deathInfo != null)
|
if (state.isDead && state.deathInfo != null)
|
||||||
DeathOverlay(
|
DeathOverlay(
|
||||||
deathInfo: state.deathInfo!,
|
deathInfo: state.deathInfo!,
|
||||||
traits: state.traits,
|
traits: state.traits,
|
||||||
onResurrect: _handleResurrect,
|
onResurrect: _handleResurrect,
|
||||||
isAutoResurrectEnabled: widget.controller.autoResurrect,
|
onAdRevive: _handleAdRevive,
|
||||||
onToggleAutoResurrect: () {
|
isPaidUser: IAPService.instance.isAdRemovalPurchased,
|
||||||
widget.controller.setAutoResurrect(
|
|
||||||
!widget.controller.autoResurrect,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
// 승리 오버레이 (게임 클리어)
|
// 승리 오버레이 (게임 클리어)
|
||||||
if (widget.controller.isComplete)
|
if (widget.controller.isComplete)
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
import 'dart:async';
|
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_loop.dart';
|
||||||
import 'package:asciineverdie/src/core/engine/progress_service.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/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/shop_service.dart';
|
||||||
import 'package:asciineverdie/src/core/engine/test_character_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/combat_stats.dart';
|
||||||
import 'package:asciineverdie/src/core/model/game_state.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/game_statistics.dart';
|
||||||
import 'package:asciineverdie/src/core/model/hall_of_fame.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/hall_of_fame_storage.dart';
|
||||||
import 'package:asciineverdie/src/core/storage/save_manager.dart';
|
import 'package:asciineverdie/src/core/storage/save_manager.dart';
|
||||||
import 'package:asciineverdie/src/core/storage/statistics_storage.dart';
|
import 'package:asciineverdie/src/core/storage/statistics_storage.dart';
|
||||||
@@ -54,6 +59,20 @@ class GameSessionController extends ChangeNotifier {
|
|||||||
// 자동 부활 (Auto-Resurrection) 상태
|
// 자동 부활 (Auto-Resurrection) 상태
|
||||||
bool _autoResurrect = false;
|
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();
|
SessionStatistics _sessionStats = SessionStatistics.empty();
|
||||||
CumulativeStatistics _cumulativeStats = CumulativeStatistics.empty();
|
CumulativeStatistics _cumulativeStats = CumulativeStatistics.empty();
|
||||||
@@ -152,18 +171,14 @@ class GameSessionController extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 명예의 전당 상태에 따른 가용 배속 목록 반환
|
/// 가용 배속 목록 반환
|
||||||
/// - 디버그 모드(치트 활성화): [1, 5, 20] (터보 모드)
|
/// - 디버그 모드(치트 활성화): [1, 2, 20] (터보 모드 포함)
|
||||||
/// - 명예의 전당에 캐릭터 없음: [1, 5]
|
/// - 일반 모드: [1, 2] (5x는 광고 버프로만 활성화)
|
||||||
/// - 명예의 전당에 캐릭터 있음: [1, 2, 5]
|
|
||||||
Future<List<int>> _getAvailableSpeeds() async {
|
Future<List<int>> _getAvailableSpeeds() async {
|
||||||
// 디버그 모드면 터보(20x) 추가
|
|
||||||
if (_cheatsEnabled) {
|
if (_cheatsEnabled) {
|
||||||
return [1, 5, 20];
|
return [1, 2, 20];
|
||||||
}
|
}
|
||||||
|
return [1, 2];
|
||||||
final hallOfFame = await _hallOfFameStorage.load();
|
|
||||||
return hallOfFame.isEmpty ? [1, 5] : [1, 2, 5];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 이전 값 초기화 (통계 변화 추적용)
|
/// 이전 값 초기화 (통계 변화 추적용)
|
||||||
@@ -241,9 +256,8 @@ class GameSessionController extends ChangeNotifier {
|
|||||||
_error = null;
|
_error = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
final (outcome, loaded, savedCheatsEnabled) = await saveManager.loadState(
|
final (outcome, loaded, savedCheatsEnabled, savedMonetization) =
|
||||||
fileName: fileName,
|
await saveManager.loadState(fileName: fileName);
|
||||||
);
|
|
||||||
if (!outcome.success || loaded == null) {
|
if (!outcome.success || loaded == null) {
|
||||||
_status = GameSessionStatus.error;
|
_status = GameSessionStatus.error;
|
||||||
_error = outcome.error ?? 'Unknown error';
|
_error = outcome.error ?? 'Unknown error';
|
||||||
@@ -251,6 +265,12 @@ class GameSessionController extends ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 저장된 수익화 상태 복원
|
||||||
|
_monetization = savedMonetization ?? MonetizationState.initial();
|
||||||
|
|
||||||
|
// 복귀 보상 체크 (Phase 7)
|
||||||
|
_checkReturnRewards(loaded);
|
||||||
|
|
||||||
// 저장된 치트 모드 상태 복원
|
// 저장된 치트 모드 상태 복원
|
||||||
await startNew(loaded, cheatsEnabled: savedCheatsEnabled, isNewGame: false);
|
await startNew(loaded, cheatsEnabled: savedCheatsEnabled, isNewGame: false);
|
||||||
}
|
}
|
||||||
@@ -312,8 +332,16 @@ class GameSessionController extends ChangeNotifier {
|
|||||||
_status = GameSessionStatus.dead;
|
_status = GameSessionStatus.dead;
|
||||||
notifyListeners();
|
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();
|
_scheduleAutoResurrect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -323,8 +351,15 @@ class GameSessionController extends ChangeNotifier {
|
|||||||
/// 사망 오버레이를 잠시 표시한 후 자동으로 부활 처리
|
/// 사망 오버레이를 잠시 표시한 후 자동으로 부활 처리
|
||||||
void _scheduleAutoResurrect() {
|
void _scheduleAutoResurrect() {
|
||||||
Future.delayed(const Duration(milliseconds: 800), () async {
|
Future.delayed(const Duration(milliseconds: 800), () async {
|
||||||
// 상태가 여전히 dead이고, 자동 부활이 활성화된 경우에만 부활
|
if (_status != GameSessionStatus.dead) return;
|
||||||
if (_status == GameSessionStatus.dead && _autoResurrect) {
|
|
||||||
|
// 자동 부활 조건 재확인
|
||||||
|
final elapsedMs = _state?.skillSystem.elapsedMs ?? 0;
|
||||||
|
final shouldAutoResurrect = _autoResurrect ||
|
||||||
|
IAPService.instance.isAdRemovalPurchased ||
|
||||||
|
_monetization.isAutoReviveActive(elapsedMs);
|
||||||
|
|
||||||
|
if (shouldAutoResurrect) {
|
||||||
await resurrect();
|
await resurrect();
|
||||||
await resumeAfterResurrection();
|
await resumeAfterResurrection();
|
||||||
}
|
}
|
||||||
@@ -456,6 +491,7 @@ class GameSessionController extends ChangeNotifier {
|
|||||||
await saveManager.saveState(
|
await saveManager.saveState(
|
||||||
resurrectedState,
|
resurrectedState,
|
||||||
cheatsEnabled: _cheatsEnabled,
|
cheatsEnabled: _cheatsEnabled,
|
||||||
|
monetization: _monetization,
|
||||||
);
|
);
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -471,10 +507,266 @@ class GameSessionController extends ChangeNotifier {
|
|||||||
await startNew(_state!, cheatsEnabled: _cheatsEnabled, isNewGame: false);
|
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 =>
|
bool get isDead =>
|
||||||
_status == GameSessionStatus.dead || (_state?.isDead ?? false);
|
_status == GameSessionStatus.dead || (_state?.isDead ?? false);
|
||||||
|
|
||||||
/// 게임 클리어 여부
|
/// 게임 클리어 여부
|
||||||
bool get isComplete => _status == GameSessionStatus.complete;
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ class MobileCarouselLayout extends StatefulWidget {
|
|||||||
this.onCheatQuest,
|
this.onCheatQuest,
|
||||||
this.onCheatPlot,
|
this.onCheatPlot,
|
||||||
this.onCreateTestCharacter,
|
this.onCreateTestCharacter,
|
||||||
|
this.autoReviveEndMs,
|
||||||
|
this.speedBoostEndMs,
|
||||||
|
this.isPaidUser = false,
|
||||||
|
this.onSpeedBoostActivate,
|
||||||
});
|
});
|
||||||
|
|
||||||
final GameState state;
|
final GameState state;
|
||||||
@@ -102,6 +106,18 @@ class MobileCarouselLayout extends StatefulWidget {
|
|||||||
/// 테스트 캐릭터 생성 콜백 (디버그 모드 전용)
|
/// 테스트 캐릭터 생성 콜백 (디버그 모드 전용)
|
||||||
final Future<void> Function()? onCreateTestCharacter;
|
final Future<void> Function()? onCreateTestCharacter;
|
||||||
|
|
||||||
|
/// 자동부활 버프 종료 시점 (elapsedMs 기준)
|
||||||
|
final int? autoReviveEndMs;
|
||||||
|
|
||||||
|
/// 5배속 버프 종료 시점 (elapsedMs 기준)
|
||||||
|
final int? speedBoostEndMs;
|
||||||
|
|
||||||
|
/// 유료 유저 여부
|
||||||
|
final bool isPaidUser;
|
||||||
|
|
||||||
|
/// 5배속 버프 활성화 콜백 (광고 시청)
|
||||||
|
final VoidCallback? onSpeedBoostActivate;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<MobileCarouselLayout> createState() => _MobileCarouselLayoutState();
|
State<MobileCarouselLayout> createState() => _MobileCarouselLayoutState();
|
||||||
}
|
}
|
||||||
@@ -456,27 +472,7 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
|
|||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.speed),
|
leading: const Icon(Icons.speed),
|
||||||
title: Text(l10n.menuSpeed),
|
title: Text(l10n.menuSpeed),
|
||||||
trailing: Container(
|
trailing: _buildSpeedSelector(context),
|
||||||
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);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
|
|
||||||
const Divider(),
|
const Divider(),
|
||||||
@@ -735,6 +731,10 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
|
|||||||
state.progress.currentCombat?.recentEvents.lastOrNull,
|
state.progress.currentCombat?.recentEvents.lastOrNull,
|
||||||
raceId: state.traits.raceId,
|
raceId: state.traits.raceId,
|
||||||
weaponRarity: state.equipment.weaponItem.rarity,
|
weaponRarity: state.equipment.weaponItem.rarity,
|
||||||
|
autoReviveEndMs: widget.autoReviveEndMs,
|
||||||
|
speedBoostEndMs: widget.speedBoostEndMs,
|
||||||
|
isPaidUser: widget.isPaidUser,
|
||||||
|
onSpeedBoostActivate: widget.onSpeedBoostActivate,
|
||||||
),
|
),
|
||||||
|
|
||||||
// 중앙: 캐로셀 (PageView)
|
// 중앙: 캐로셀 (PageView)
|
||||||
@@ -794,4 +794,135 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 속도 선택기 빌드 (옵션 메뉴용)
|
||||||
|
///
|
||||||
|
/// - 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,17 +8,19 @@ import 'package:asciineverdie/src/core/model/game_state.dart';
|
|||||||
import 'package:asciineverdie/src/core/model/item_stats.dart';
|
import 'package:asciineverdie/src/core/model/item_stats.dart';
|
||||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||||
|
|
||||||
/// 사망 오버레이 위젯 (Phase 4)
|
/// 사망 오버레이 위젯
|
||||||
///
|
///
|
||||||
/// 플레이어 사망 시 표시되는 전체 화면 오버레이
|
/// 플레이어 사망 시 표시되는 전체 화면 오버레이
|
||||||
|
/// - 무료 부활: HP 50%, 아이템 희생
|
||||||
|
/// - 광고 부활: HP 100%, 아이템 복구, 10분 자동부활 버프
|
||||||
class DeathOverlay extends StatelessWidget {
|
class DeathOverlay extends StatelessWidget {
|
||||||
const DeathOverlay({
|
const DeathOverlay({
|
||||||
super.key,
|
super.key,
|
||||||
required this.deathInfo,
|
required this.deathInfo,
|
||||||
required this.traits,
|
required this.traits,
|
||||||
required this.onResurrect,
|
required this.onResurrect,
|
||||||
this.isAutoResurrectEnabled = false,
|
this.onAdRevive,
|
||||||
this.onToggleAutoResurrect,
|
this.isPaidUser = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// 사망 정보
|
/// 사망 정보
|
||||||
@@ -27,14 +29,15 @@ class DeathOverlay extends StatelessWidget {
|
|||||||
/// 캐릭터 특성 (이름, 직업 등)
|
/// 캐릭터 특성 (이름, 직업 등)
|
||||||
final Traits traits;
|
final Traits traits;
|
||||||
|
|
||||||
/// 부활 버튼 콜백
|
/// 무료 부활 버튼 콜백 (HP 50%, 아이템 희생)
|
||||||
final VoidCallback onResurrect;
|
final VoidCallback onResurrect;
|
||||||
|
|
||||||
/// 자동 부활 활성화 여부
|
/// 광고 부활 버튼 콜백 (HP 100% + 아이템 복구 + 10분 자동부활)
|
||||||
final bool isAutoResurrectEnabled;
|
/// null이면 광고 부활 버튼 숨김
|
||||||
|
final VoidCallback? onAdRevive;
|
||||||
|
|
||||||
/// 자동 부활 토글 콜백
|
/// 유료 유저 여부 (광고 아이콘 표시용)
|
||||||
final VoidCallback? onToggleAutoResurrect;
|
final bool isPaidUser;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -135,13 +138,13 @@ class DeathOverlay extends StatelessWidget {
|
|||||||
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// 부활 버튼
|
// 일반 부활 버튼 (HP 50%, 아이템 희생)
|
||||||
_buildResurrectButton(context),
|
_buildResurrectButton(context),
|
||||||
|
|
||||||
// 자동 부활 버튼
|
// 광고 부활 버튼 (HP 100% + 아이템 복구 + 10분 자동부활)
|
||||||
if (onToggleAutoResurrect != null) ...[
|
if (onAdRevive != null) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_buildAutoResurrectButton(context),
|
_buildAdReviveButton(context),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -464,77 +467,149 @@ class DeathOverlay extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 자동 부활 토글 버튼
|
/// 광고 부활 버튼 (HP 100% + 아이템 복구 + 10분 자동부활)
|
||||||
Widget _buildAutoResurrectButton(BuildContext context) {
|
Widget _buildAdReviveButton(BuildContext context) {
|
||||||
final mpColor = RetroColors.mpOf(context);
|
final gold = RetroColors.goldOf(context);
|
||||||
final mpDark = RetroColors.mpDarkOf(context);
|
final goldDark = RetroColors.goldDarkOf(context);
|
||||||
final muted = RetroColors.textMutedOf(context);
|
final muted = RetroColors.textMutedOf(context);
|
||||||
|
final hasLostItem = deathInfo.lostItemName != null;
|
||||||
// 활성화 상태에 따른 색상
|
final itemRarityColor = _getRarityColor(deathInfo.lostItemRarity);
|
||||||
final buttonColor = isAutoResurrectEnabled ? mpColor : muted;
|
|
||||||
final buttonDark = isAutoResurrectEnabled
|
|
||||||
? mpDark
|
|
||||||
: muted.withValues(alpha: 0.5);
|
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: onToggleAutoResurrect,
|
onTap: onAdRevive,
|
||||||
child: Container(
|
child: Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: buttonColor.withValues(alpha: 0.15),
|
color: gold.withValues(alpha: 0.2),
|
||||||
border: Border(
|
border: Border(
|
||||||
top: BorderSide(color: buttonColor, width: 2),
|
top: BorderSide(color: gold, width: 3),
|
||||||
left: BorderSide(color: buttonColor, width: 2),
|
left: BorderSide(color: gold, width: 3),
|
||||||
bottom: BorderSide(
|
bottom: BorderSide(color: goldDark.withValues(alpha: 0.8), width: 3),
|
||||||
color: buttonDark.withValues(alpha: 0.8),
|
right: BorderSide(color: goldDark.withValues(alpha: 0.8), width: 3),
|
||||||
width: 2,
|
|
||||||
),
|
|
||||||
right: BorderSide(
|
|
||||||
color: buttonDark.withValues(alpha: 0.8),
|
|
||||||
width: 2,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
children: [
|
||||||
Text(
|
// 메인 버튼 텍스트
|
||||||
isAutoResurrectEnabled ? '◉' : '○',
|
Row(
|
||||||
style: TextStyle(
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
fontSize: 18,
|
children: [
|
||||||
color: buttonColor,
|
Text(
|
||||||
fontWeight: FontWeight.bold,
|
'✨',
|
||||||
),
|
style: TextStyle(fontSize: 20, color: gold),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
l10n.deathAdRevive.toUpperCase(),
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'PressStart2P',
|
||||||
|
fontSize: 14,
|
||||||
|
color: gold,
|
||||||
|
letterSpacing: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 광고 뱃지 (무료 유저만)
|
||||||
|
if (!isPaidUser) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 6,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.2),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'▶ AD',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'PressStart2P',
|
||||||
|
fontSize: 10,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
// 혜택 목록
|
||||||
l10n.deathAutoResurrect.toUpperCase(),
|
Column(
|
||||||
style: TextStyle(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
fontFamily: 'PressStart2P',
|
children: [
|
||||||
fontSize: 13,
|
// HP 100% 회복
|
||||||
color: buttonColor,
|
_buildBenefitRow(
|
||||||
letterSpacing: 1,
|
context,
|
||||||
),
|
icon: '♥',
|
||||||
|
text: l10n.deathAdReviveHp,
|
||||||
|
color: RetroColors.hpOf(context),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
// 아이템 복구 (잃은 아이템이 있을 때만)
|
||||||
|
if (hasLostItem) ...[
|
||||||
|
_buildBenefitRow(
|
||||||
|
context,
|
||||||
|
icon: '🔄',
|
||||||
|
text: '${l10n.deathAdReviveItem}: ${deathInfo.lostItemName}',
|
||||||
|
color: itemRarityColor,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
],
|
||||||
|
// 10분 자동부활
|
||||||
|
_buildBenefitRow(
|
||||||
|
context,
|
||||||
|
icon: '⏱',
|
||||||
|
text: l10n.deathAdReviveAuto,
|
||||||
|
color: RetroColors.mpOf(context),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
if (isAutoResurrectEnabled) ...[
|
const SizedBox(height: 6),
|
||||||
const SizedBox(width: 6),
|
// 유료 유저 설명
|
||||||
|
if (isPaidUser)
|
||||||
Text(
|
Text(
|
||||||
'ON',
|
l10n.deathAdRevivePaidDesc,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: 'PressStart2P',
|
fontFamily: 'PressStart2P',
|
||||||
fontSize: 13,
|
fontSize: 9,
|
||||||
color: mpColor,
|
color: muted,
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 혜택 항목 행
|
||||||
|
Widget _buildBenefitRow(
|
||||||
|
BuildContext context, {
|
||||||
|
required String icon,
|
||||||
|
required String text,
|
||||||
|
required Color color,
|
||||||
|
}) {
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(icon, style: TextStyle(fontSize: 14, color: color)),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'PressStart2P',
|
||||||
|
fontSize: 10,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// 사망 직전 전투 로그 표시
|
/// 사망 직전 전투 로그 표시
|
||||||
Widget _buildCombatLog(BuildContext context) {
|
Widget _buildCombatLog(BuildContext context) {
|
||||||
final events = deathInfo.lastCombatEvents;
|
final events = deathInfo.lastCombatEvents;
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ class EnhancedAnimationPanel extends StatefulWidget {
|
|||||||
this.latestCombatEvent,
|
this.latestCombatEvent,
|
||||||
this.raceId,
|
this.raceId,
|
||||||
this.weaponRarity,
|
this.weaponRarity,
|
||||||
|
this.autoReviveEndMs,
|
||||||
|
this.speedBoostEndMs,
|
||||||
|
this.isPaidUser = false,
|
||||||
|
this.onSpeedBoostActivate,
|
||||||
});
|
});
|
||||||
|
|
||||||
final ProgressState progress;
|
final ProgressState progress;
|
||||||
@@ -65,6 +69,18 @@ class EnhancedAnimationPanel extends StatefulWidget {
|
|||||||
/// 무기 희귀도 (Phase 9: 무기 등급별 이펙트 색상)
|
/// 무기 희귀도 (Phase 9: 무기 등급별 이펙트 색상)
|
||||||
final ItemRarity? weaponRarity;
|
final ItemRarity? weaponRarity;
|
||||||
|
|
||||||
|
/// 자동부활 버프 종료 시점 (elapsedMs 기준, null이면 비활성)
|
||||||
|
final int? autoReviveEndMs;
|
||||||
|
|
||||||
|
/// 5배속 버프 종료 시점 (elapsedMs 기준, null이면 비활성)
|
||||||
|
final int? speedBoostEndMs;
|
||||||
|
|
||||||
|
/// 유료 유저 여부 (5배속 항상 활성)
|
||||||
|
final bool isPaidUser;
|
||||||
|
|
||||||
|
/// 5배속 버프 활성화 콜백 (광고 시청)
|
||||||
|
final VoidCallback? onSpeedBoostActivate;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<EnhancedAnimationPanel> createState() => _EnhancedAnimationPanelState();
|
State<EnhancedAnimationPanel> createState() => _EnhancedAnimationPanelState();
|
||||||
}
|
}
|
||||||
@@ -190,6 +206,22 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
|
|||||||
int? get _currentMonsterHpMax =>
|
int? get _currentMonsterHpMax =>
|
||||||
widget.progress.currentCombat?.monsterStats.hpMax;
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_hpFlashController.dispose();
|
_hpFlashController.dispose();
|
||||||
@@ -218,62 +250,94 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// ASCII 애니메이션 (기존 높이 120 유지)
|
// ASCII 애니메이션 (기존 높이 120 유지) + 버프 오버레이
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 120,
|
height: 120,
|
||||||
child: AsciiAnimationCard(
|
child: Stack(
|
||||||
taskType: widget.progress.currentTask.type,
|
children: [
|
||||||
monsterBaseName: widget.progress.currentTask.monsterBaseName,
|
// ASCII 애니메이션
|
||||||
specialAnimation: widget.specialAnimation,
|
AsciiAnimationCard(
|
||||||
weaponName: widget.weaponName,
|
taskType: widget.progress.currentTask.type,
|
||||||
shieldName: widget.shieldName,
|
monsterBaseName: widget.progress.currentTask.monsterBaseName,
|
||||||
characterLevel: widget.characterLevel,
|
specialAnimation: widget.specialAnimation,
|
||||||
monsterLevel: widget.monsterLevel,
|
weaponName: widget.weaponName,
|
||||||
monsterGrade: widget.monsterGrade,
|
shieldName: widget.shieldName,
|
||||||
monsterSize: widget.monsterSize,
|
characterLevel: widget.characterLevel,
|
||||||
isPaused: widget.isPaused,
|
monsterLevel: widget.monsterLevel,
|
||||||
isInCombat: isInCombat,
|
monsterGrade: widget.monsterGrade,
|
||||||
monsterDied: _monsterDied,
|
monsterSize: widget.monsterSize,
|
||||||
latestCombatEvent: widget.latestCombatEvent,
|
isPaused: widget.isPaused,
|
||||||
raceId: widget.raceId,
|
isInCombat: isInCombat,
|
||||||
weaponRarity: widget.weaponRarity,
|
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),
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
// 상태 바 영역: HP/MP + 버프 아이콘 + 몬스터 HP
|
// 상태 바 영역: HP/MP (40%) + 컨트롤 (20%) + 몬스터 HP (40%)
|
||||||
Row(
|
SizedBox(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
height: 48,
|
||||||
children: [
|
child: Row(
|
||||||
// 좌측: HP/MP 바
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
Expanded(
|
children: [
|
||||||
flex: 3,
|
// 좌측: HP/MP 바 (40%)
|
||||||
child: Column(
|
Expanded(
|
||||||
children: [
|
flex: 2,
|
||||||
_buildCompactHpBar(),
|
child: Column(
|
||||||
const SizedBox(height: 4),
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
_buildCompactMpBar(),
|
children: [
|
||||||
],
|
Expanded(child: _buildCompactHpBar()),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Expanded(child: _buildCompactMpBar()),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(width: 8),
|
// 중앙: 컨트롤 버튼 (20%)
|
||||||
|
Expanded(
|
||||||
|
flex: 1,
|
||||||
|
child: _buildControlButtons(),
|
||||||
|
),
|
||||||
|
|
||||||
// 중앙: 활성 버프 아이콘 (최대 3개)
|
// 우측: 몬스터 HP (전투 중) 또는 빈 공간 (40%)
|
||||||
_buildBuffIcons(),
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
const SizedBox(width: 8),
|
child: switch ((shouldShowMonsterHp, combat)) {
|
||||||
|
(true, final c?) => _buildMonsterHpBar(c),
|
||||||
// 우측: 몬스터 HP (전투 중) 또는 컨트롤 버튼
|
_ => const SizedBox.shrink(),
|
||||||
Expanded(
|
},
|
||||||
flex: 2,
|
),
|
||||||
child: switch ((shouldShowMonsterHp, combat)) {
|
],
|
||||||
(true, final c?) => _buildMonsterHpBar(c),
|
),
|
||||||
_ => _buildControlButtons(),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
@@ -298,7 +362,6 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
|
|||||||
children: [
|
children: [
|
||||||
// HP 바
|
// HP 바
|
||||||
Container(
|
Container(
|
||||||
height: 20,
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isLow
|
color: isLow
|
||||||
? Colors.red.withValues(alpha: 0.2)
|
? Colors.red.withValues(alpha: 0.2)
|
||||||
@@ -330,13 +393,14 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
|
|||||||
borderRadius: const BorderRadius.horizontal(
|
borderRadius: const BorderRadius.horizontal(
|
||||||
right: Radius.circular(3),
|
right: Radius.circular(3),
|
||||||
),
|
),
|
||||||
child: LinearProgressIndicator(
|
child: SizedBox.expand(
|
||||||
value: ratio.clamp(0.0, 1.0),
|
child: LinearProgressIndicator(
|
||||||
backgroundColor: Colors.red.withValues(alpha: 0.2),
|
value: ratio.clamp(0.0, 1.0),
|
||||||
valueColor: AlwaysStoppedAnimation(
|
backgroundColor: Colors.red.withValues(alpha: 0.2),
|
||||||
isLow ? Colors.red : Colors.red.shade600,
|
valueColor: AlwaysStoppedAnimation(
|
||||||
|
isLow ? Colors.red : Colors.red.shade600,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
minHeight: 20,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// 숫자 오버레이 (바 중앙)
|
// 숫자 오버레이 (바 중앙)
|
||||||
@@ -402,7 +466,6 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
|
|||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
height: 20,
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.shade800,
|
color: Colors.grey.shade800,
|
||||||
borderRadius: BorderRadius.circular(3),
|
borderRadius: BorderRadius.circular(3),
|
||||||
@@ -431,13 +494,14 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
|
|||||||
borderRadius: const BorderRadius.horizontal(
|
borderRadius: const BorderRadius.horizontal(
|
||||||
right: Radius.circular(3),
|
right: Radius.circular(3),
|
||||||
),
|
),
|
||||||
child: LinearProgressIndicator(
|
child: SizedBox.expand(
|
||||||
value: ratio.clamp(0.0, 1.0),
|
child: LinearProgressIndicator(
|
||||||
backgroundColor: Colors.blue.withValues(alpha: 0.2),
|
value: ratio.clamp(0.0, 1.0),
|
||||||
valueColor: AlwaysStoppedAnimation(
|
backgroundColor: Colors.blue.withValues(alpha: 0.2),
|
||||||
Colors.blue.shade600,
|
valueColor: AlwaysStoppedAnimation(
|
||||||
|
Colors.blue.shade600,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
minHeight: 20,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// 숫자 오버레이 (바 중앙)
|
// 숫자 오버레이 (바 중앙)
|
||||||
@@ -491,60 +555,6 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 활성 버프 아이콘 (최대 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바 중앙에 HP% 오버레이
|
/// - HP바 중앙에 HP% 오버레이
|
||||||
/// - 하단에 레벨.이름 표시
|
/// - 하단에 레벨.이름 표시
|
||||||
@@ -562,7 +572,6 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
|
|||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
height: 52,
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.orange.withValues(alpha: 0.1),
|
color: Colors.orange.withValues(alpha: 0.1),
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
@@ -572,52 +581,54 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
// HP 바 (HP% 중앙 오버레이)
|
// HP 바 (HP% 중앙 오버레이)
|
||||||
Padding(
|
Expanded(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
child: Padding(
|
||||||
child: Stack(
|
padding: const EdgeInsets.fromLTRB(4, 4, 4, 2),
|
||||||
alignment: Alignment.center,
|
child: Stack(
|
||||||
children: [
|
alignment: Alignment.center,
|
||||||
// HP 바
|
children: [
|
||||||
ClipRRect(
|
// HP 바
|
||||||
borderRadius: BorderRadius.circular(2),
|
ClipRRect(
|
||||||
child: LinearProgressIndicator(
|
borderRadius: BorderRadius.circular(2),
|
||||||
value: ratio.clamp(0.0, 1.0),
|
child: SizedBox.expand(
|
||||||
backgroundColor: Colors.orange.withValues(
|
child: LinearProgressIndicator(
|
||||||
alpha: 0.2,
|
value: ratio.clamp(0.0, 1.0),
|
||||||
),
|
backgroundColor: Colors.orange.withValues(
|
||||||
valueColor: const AlwaysStoppedAnimation(
|
alpha: 0.2,
|
||||||
Colors.orange,
|
),
|
||||||
),
|
valueColor: const AlwaysStoppedAnimation(
|
||||||
minHeight: 16,
|
Colors.orange,
|
||||||
),
|
),
|
||||||
),
|
|
||||||
// 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),
|
),
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
// 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(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
padding: const EdgeInsets.fromLTRB(4, 0, 4, 4),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Lv.$monsterLevel $monsterName',
|
'Lv.$monsterLevel $monsterName',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 11,
|
||||||
color: Colors.orange,
|
color: Colors.orange,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
@@ -662,63 +673,91 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 컨트롤 버튼 (비전투 시)
|
/// 컨트롤 버튼 (중앙 영역)
|
||||||
Widget _buildControlButtons() {
|
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(
|
return SizedBox(
|
||||||
height: 40,
|
width: 32,
|
||||||
child: Row(
|
height: 22,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
child: OutlinedButton(
|
||||||
children: [
|
onPressed: widget.onSpeedCycle,
|
||||||
// 일시정지 버튼
|
style: OutlinedButton.styleFrom(
|
||||||
SizedBox(
|
padding: EdgeInsets.zero,
|
||||||
width: 40,
|
visualDensity: VisualDensity.compact,
|
||||||
height: 36,
|
side: BorderSide(
|
||||||
child: OutlinedButton(
|
color: isSpeedBoostActive
|
||||||
onPressed: widget.onPauseToggle,
|
? Colors.orange
|
||||||
style: OutlinedButton.styleFrom(
|
: widget.speedMultiplier > 1
|
||||||
padding: EdgeInsets.zero,
|
? Theme.of(context).colorScheme.primary
|
||||||
visualDensity: VisualDensity.compact,
|
: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5),
|
||||||
side: BorderSide(
|
),
|
||||||
color: widget.isPaused
|
),
|
||||||
? Colors.orange.withValues(alpha: 0.7)
|
child: Text(
|
||||||
: Theme.of(
|
isSpeedBoostActive ? '5x' : '${widget.speedMultiplier}x',
|
||||||
context,
|
style: TextStyle(
|
||||||
).colorScheme.outline.withValues(alpha: 0.5),
|
fontSize: 10,
|
||||||
),
|
fontWeight: FontWeight.bold,
|
||||||
),
|
color: isSpeedBoostActive
|
||||||
child: Icon(
|
? Colors.orange
|
||||||
widget.isPaused ? Icons.play_arrow : Icons.pause,
|
: widget.speedMultiplier > 1
|
||||||
size: 18,
|
? Theme.of(context).colorScheme.primary
|
||||||
color: widget.isPaused ? Colors.orange : null,
|
: 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<EnhancedAnimationPanel>
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 버프 칩 위젯 (좌상단/우상단 오버레이용)
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
386
lib/src/features/game/widgets/return_rewards_dialog.dart
Normal file
386
lib/src/features/game/widgets/return_rewards_dialog.dart
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
|
||||||
|
import 'package:asciineverdie/src/core/engine/iap_service.dart';
|
||||||
|
import 'package:asciineverdie/src/core/engine/return_rewards_service.dart';
|
||||||
|
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||||
|
|
||||||
|
/// 복귀 보상 다이얼로그 (Phase 7)
|
||||||
|
///
|
||||||
|
/// 게임 복귀 시 보상을 표시하는 다이얼로그
|
||||||
|
class ReturnRewardsDialog extends StatefulWidget {
|
||||||
|
const ReturnRewardsDialog({
|
||||||
|
super.key,
|
||||||
|
required this.reward,
|
||||||
|
required this.onClaim,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 복귀 보상 데이터
|
||||||
|
final ReturnReward reward;
|
||||||
|
|
||||||
|
/// 보상 수령 콜백 (totalGold)
|
||||||
|
final void Function(int totalGold) onClaim;
|
||||||
|
|
||||||
|
/// 다이얼로그 표시
|
||||||
|
static Future<void> show(
|
||||||
|
BuildContext context, {
|
||||||
|
required ReturnReward reward,
|
||||||
|
required void Function(int totalGold) onClaim,
|
||||||
|
}) async {
|
||||||
|
return showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (context) => ReturnRewardsDialog(
|
||||||
|
reward: reward,
|
||||||
|
onClaim: onClaim,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ReturnRewardsDialog> createState() => _ReturnRewardsDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ReturnRewardsDialogState extends State<ReturnRewardsDialog> {
|
||||||
|
bool _basicClaimed = false;
|
||||||
|
bool _bonusClaimed = false;
|
||||||
|
bool _isClaimingBonus = false;
|
||||||
|
int _totalClaimed = 0;
|
||||||
|
|
||||||
|
final _rewardsService = ReturnRewardsService.instance;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final gold = RetroColors.goldOf(context);
|
||||||
|
final goldDark = RetroColors.goldDarkOf(context);
|
||||||
|
final panelBg = RetroColors.panelBgOf(context);
|
||||||
|
final borderColor = RetroColors.borderOf(context);
|
||||||
|
final expColor = RetroColors.expOf(context);
|
||||||
|
final isPaidUser = IAPService.instance.isAdRemovalPurchased;
|
||||||
|
|
||||||
|
return Dialog(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
child: Container(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 360),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: panelBg,
|
||||||
|
border: Border(
|
||||||
|
top: BorderSide(color: gold, width: 4),
|
||||||
|
left: BorderSide(color: gold, width: 4),
|
||||||
|
bottom: BorderSide(color: borderColor, width: 4),
|
||||||
|
right: BorderSide(color: borderColor, width: 4),
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: gold.withValues(alpha: 0.4),
|
||||||
|
blurRadius: 20,
|
||||||
|
spreadRadius: 2,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// 헤더
|
||||||
|
_buildHeader(context, gold),
|
||||||
|
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// 떠나있던 시간
|
||||||
|
Text(
|
||||||
|
l10n.returnRewardHoursAway(
|
||||||
|
_rewardsService.formatHoursAway(widget.reward.hoursAway),
|
||||||
|
),
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'PressStart2P',
|
||||||
|
fontSize: 11,
|
||||||
|
color: gold.withValues(alpha: 0.8),
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// 기본 보상
|
||||||
|
_buildRewardSection(
|
||||||
|
context,
|
||||||
|
title: l10n.returnRewardBasic,
|
||||||
|
gold: widget.reward.goldReward,
|
||||||
|
color: gold,
|
||||||
|
colorDark: goldDark,
|
||||||
|
claimed: _basicClaimed,
|
||||||
|
onClaim: _claimBasic,
|
||||||
|
buttonText: l10n.returnRewardClaim,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// 보너스 보상
|
||||||
|
_buildRewardSection(
|
||||||
|
context,
|
||||||
|
title: l10n.returnRewardBonus,
|
||||||
|
gold: widget.reward.bonusGold,
|
||||||
|
color: expColor,
|
||||||
|
colorDark: expColor.withValues(alpha: 0.6),
|
||||||
|
claimed: _bonusClaimed,
|
||||||
|
onClaim: _claimBonus,
|
||||||
|
buttonText: l10n.returnRewardClaimBonus,
|
||||||
|
showAdIcon: !isPaidUser,
|
||||||
|
isLoading: _isClaimingBonus,
|
||||||
|
enabled: _basicClaimed && !_bonusClaimed,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// 완료/건너뛰기 버튼
|
||||||
|
_buildBottomButton(context),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeader(BuildContext context, Color gold) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: gold.withValues(alpha: 0.3),
|
||||||
|
border: Border(bottom: BorderSide(color: gold, width: 2)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text('🎁', style: TextStyle(fontSize: 20, color: gold)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
l10n.returnRewardTitle,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'PressStart2P',
|
||||||
|
fontSize: 14,
|
||||||
|
color: gold,
|
||||||
|
letterSpacing: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text('🎁', style: TextStyle(fontSize: 20, color: gold)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildRewardSection(
|
||||||
|
BuildContext context, {
|
||||||
|
required String title,
|
||||||
|
required int gold,
|
||||||
|
required Color color,
|
||||||
|
required Color colorDark,
|
||||||
|
required bool claimed,
|
||||||
|
required VoidCallback onClaim,
|
||||||
|
required String buttonText,
|
||||||
|
bool showAdIcon = false,
|
||||||
|
bool isLoading = false,
|
||||||
|
bool enabled = true,
|
||||||
|
}) {
|
||||||
|
final muted = RetroColors.textMutedOf(context);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: 0.1),
|
||||||
|
border: Border.all(color: color.withValues(alpha: 0.5), width: 2),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// 제목
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'PressStart2P',
|
||||||
|
fontSize: 11,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// 골드 표시
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Text('💰', style: TextStyle(fontSize: 20)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
l10n.returnRewardGold(gold),
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'PressStart2P',
|
||||||
|
fontSize: 14,
|
||||||
|
color: claimed ? muted : color,
|
||||||
|
decoration: claimed ? TextDecoration.lineThrough : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (claimed) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'✓',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
color: RetroColors.expOf(context),
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
if (!claimed) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// 수령 버튼
|
||||||
|
GestureDetector(
|
||||||
|
onTap: enabled && !isLoading ? onClaim : null,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: enabled
|
||||||
|
? color.withValues(alpha: 0.3)
|
||||||
|
: muted.withValues(alpha: 0.2),
|
||||||
|
border: Border.all(
|
||||||
|
color: enabled ? color : muted,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (isLoading) ...[
|
||||||
|
SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
Text(
|
||||||
|
buttonText,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'PressStart2P',
|
||||||
|
fontSize: 11,
|
||||||
|
color: enabled ? color : muted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (showAdIcon && !isLoading) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 4,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.2),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'AD',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'PressStart2P',
|
||||||
|
fontSize: 8,
|
||||||
|
color: enabled ? Colors.white : muted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBottomButton(BuildContext context) {
|
||||||
|
final gold = RetroColors.goldOf(context);
|
||||||
|
final goldDark = RetroColors.goldDarkOf(context);
|
||||||
|
final muted = RetroColors.textMutedOf(context);
|
||||||
|
|
||||||
|
final canComplete = _basicClaimed;
|
||||||
|
final buttonColor = canComplete ? gold : muted;
|
||||||
|
final buttonDark = canComplete ? goldDark : muted.withValues(alpha: 0.5);
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: canComplete ? _complete : _skip,
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: buttonColor.withValues(alpha: 0.2),
|
||||||
|
border: Border(
|
||||||
|
top: BorderSide(color: buttonColor, width: 2),
|
||||||
|
left: BorderSide(color: buttonColor, width: 2),
|
||||||
|
bottom: BorderSide(color: buttonDark, width: 2),
|
||||||
|
right: BorderSide(color: buttonDark, width: 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
canComplete ? 'OK' : l10n.returnRewardSkip,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'PressStart2P',
|
||||||
|
fontSize: 12,
|
||||||
|
color: buttonColor,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _claimBasic() {
|
||||||
|
if (_basicClaimed) return;
|
||||||
|
|
||||||
|
final claimed = _rewardsService.claimBasicReward(widget.reward);
|
||||||
|
setState(() {
|
||||||
|
_basicClaimed = true;
|
||||||
|
_totalClaimed += claimed;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _claimBonus() async {
|
||||||
|
if (_bonusClaimed || _isClaimingBonus) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isClaimingBonus = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
final bonus = await _rewardsService.claimBonusReward(widget.reward);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isClaimingBonus = false;
|
||||||
|
if (bonus > 0) {
|
||||||
|
_bonusClaimed = true;
|
||||||
|
_totalClaimed += bonus;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _complete() {
|
||||||
|
widget.onClaim(_totalClaimed);
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _skip() {
|
||||||
|
widget.onClaim(0);
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
156
lib/src/features/game/widgets/speed_boost_button.dart
Normal file
156
lib/src/features/game/widgets/speed_boost_button.dart
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
|
||||||
|
import 'package:asciineverdie/src/core/engine/iap_service.dart';
|
||||||
|
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||||
|
|
||||||
|
/// 속도 부스트 버튼 위젯 (Phase 6)
|
||||||
|
///
|
||||||
|
/// 게임 화면에 표시되는 속도 부스트 활성화 버튼
|
||||||
|
class SpeedBoostButton extends StatelessWidget {
|
||||||
|
const SpeedBoostButton({
|
||||||
|
super.key,
|
||||||
|
required this.isActive,
|
||||||
|
required this.remainingSeconds,
|
||||||
|
required this.onActivate,
|
||||||
|
this.boostMultiplier = 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 부스트 활성화 여부
|
||||||
|
final bool isActive;
|
||||||
|
|
||||||
|
/// 남은 시간 (초)
|
||||||
|
final int remainingSeconds;
|
||||||
|
|
||||||
|
/// 부스트 배율
|
||||||
|
final int boostMultiplier;
|
||||||
|
|
||||||
|
/// 활성화 콜백
|
||||||
|
final VoidCallback onActivate;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isPaidUser = IAPService.instance.isAdRemovalPurchased;
|
||||||
|
final gold = RetroColors.goldOf(context);
|
||||||
|
final goldDark = RetroColors.goldDarkOf(context);
|
||||||
|
final expColor = RetroColors.expOf(context);
|
||||||
|
|
||||||
|
// 부스트 활성화 중일 때
|
||||||
|
if (isActive) {
|
||||||
|
return _buildActiveButton(context, expColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 부스트 비활성화 상태
|
||||||
|
return _buildInactiveButton(context, gold, goldDark, isPaidUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildActiveButton(BuildContext context, Color expColor) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: expColor.withValues(alpha: 0.3),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: expColor, width: 2),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: expColor.withValues(alpha: 0.5),
|
||||||
|
blurRadius: 8,
|
||||||
|
spreadRadius: 1,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'⚡',
|
||||||
|
style: TextStyle(fontSize: 18, color: expColor),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'${boostMultiplier}x',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'PressStart2P',
|
||||||
|
fontSize: 14,
|
||||||
|
color: expColor,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
l10n.speedBoostRemaining(remainingSeconds),
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'PressStart2P',
|
||||||
|
fontSize: 10,
|
||||||
|
color: expColor.withValues(alpha: 0.8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInactiveButton(
|
||||||
|
BuildContext context,
|
||||||
|
Color gold,
|
||||||
|
Color goldDark,
|
||||||
|
bool isPaidUser,
|
||||||
|
) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onActivate,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: gold.withValues(alpha: 0.2),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: gold, width: 2),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'⚡',
|
||||||
|
style: TextStyle(fontSize: 18, color: gold),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'${boostMultiplier}x',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'PressStart2P',
|
||||||
|
fontSize: 12,
|
||||||
|
color: gold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 광고 아이콘 (무료 유저만)
|
||||||
|
if (!isPaidUser) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 4,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.2),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'AD',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'PressStart2P',
|
||||||
|
fontSize: 8,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/game_text_l10n.dart' as game_l10n;
|
||||||
import 'package:asciineverdie/data/race_data.dart';
|
import 'package:asciineverdie/data/race_data.dart';
|
||||||
import 'package:asciineverdie/l10n/app_localizations.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/class_traits.dart';
|
||||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||||
import 'package:asciineverdie/src/core/model/race_traits.dart';
|
import 'package:asciineverdie/src/core/model/race_traits.dart';
|
||||||
@@ -52,10 +54,6 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
|
|||||||
int _wis = 0;
|
int _wis = 0;
|
||||||
int _cha = 0;
|
int _cha = 0;
|
||||||
|
|
||||||
// 롤 이력 (Unroll 기능용) - 원본 OldRolls TListBox
|
|
||||||
static const int _maxRollHistory = 20; // 최대 저장 개수
|
|
||||||
final List<int> _rollHistory = [];
|
|
||||||
|
|
||||||
// 현재 RNG 시드 (Re-Roll 전 저장)
|
// 현재 RNG 시드 (Re-Roll 전 저장)
|
||||||
int _currentSeed = 0;
|
int _currentSeed = 0;
|
||||||
|
|
||||||
@@ -68,10 +66,19 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
|
|||||||
// 굴리기 버튼 연속 클릭 방지
|
// 굴리기 버튼 연속 클릭 방지
|
||||||
bool _isRolling = false;
|
bool _isRolling = false;
|
||||||
|
|
||||||
|
// 굴리기/되돌리기 서비스
|
||||||
|
final CharacterRollService _rollService = CharacterRollService.instance;
|
||||||
|
|
||||||
|
// 서비스 초기화 완료 여부
|
||||||
|
bool _isServiceInitialized = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
|
// 서비스 초기화
|
||||||
|
_initializeService();
|
||||||
|
|
||||||
// 초기 랜덤화
|
// 초기 랜덤화
|
||||||
final random = math.Random();
|
final random = math.Random();
|
||||||
_selectedRaceIndex = random.nextInt(_races.length);
|
_selectedRaceIndex = random.nextInt(_races.length);
|
||||||
@@ -89,6 +96,16 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
|
|||||||
_scrollToSelectedItems();
|
_scrollToSelectedItems();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 서비스 초기화
|
||||||
|
Future<void> _initializeService() async {
|
||||||
|
await _rollService.initialize();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isServiceInitialized = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_nameController.dispose();
|
_nameController.dispose();
|
||||||
@@ -144,12 +161,35 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
|
|||||||
if (_isRolling) return;
|
if (_isRolling) return;
|
||||||
_isRolling = true;
|
_isRolling = true;
|
||||||
|
|
||||||
// 현재 시드를 이력에 저장
|
// 굴리기 가능 여부 확인
|
||||||
_rollHistory.insert(0, _currentSeed);
|
if (!_rollService.canRoll) {
|
||||||
|
_isRolling = false;
|
||||||
|
_showRechargeDialog();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 최대 개수 초과 시 가장 오래된 항목 제거
|
// 현재 상태를 서비스에 저장
|
||||||
if (_rollHistory.length > _maxRollHistory) {
|
final currentStats = Stats(
|
||||||
_rollHistory.removeLast();
|
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<NewCharacterScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unroll 버튼 클릭 (이전 롤로 복원)
|
/// 굴리기 충전 다이얼로그
|
||||||
void _onUnroll() {
|
Future<void> _showRechargeDialog() async {
|
||||||
if (_rollHistory.isEmpty) return;
|
final isPaidUser = IAPService.instance.isAdRemovalPurchased;
|
||||||
|
|
||||||
setState(() {
|
final result = await showDialog<bool>(
|
||||||
_currentSeed = _rollHistory.removeAt(0);
|
context: context,
|
||||||
});
|
builder: (context) => AlertDialog(
|
||||||
_rollStats();
|
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<void> _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<NewCharacterScreen> {
|
|||||||
queue: QueueState.empty(),
|
queue: QueueState.empty(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 캐릭터 생성 완료 알림 (되돌리기 상태 초기화)
|
||||||
|
_rollService.onCharacterCreated();
|
||||||
|
|
||||||
widget.onCharacterCreated?.call(initialState, testMode: _cheatsEnabled);
|
widget.onCharacterCreated?.call(initialState, testMode: _cheatsEnabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,34 +625,27 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
|
|||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
RetroTextButton(
|
_buildUndoButton(l10n),
|
||||||
text: l10n.unroll,
|
|
||||||
icon: Icons.undo,
|
|
||||||
onPressed: _rollHistory.isEmpty ? null : _onUnroll,
|
|
||||||
isPrimary: false,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
RetroTextButton(
|
_buildRollButton(l10n),
|
||||||
text: l10n.roll,
|
|
||||||
icon: Icons.casino,
|
|
||||||
onPressed: _onReroll,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (_rollHistory.isNotEmpty)
|
// 남은 횟수 표시
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 8),
|
padding: const EdgeInsets.only(top: 8),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
game_l10n.uiRollHistory(_rollHistory.length),
|
_rollService.canUndo
|
||||||
style: const TextStyle(
|
? 'Undo: ${_rollService.availableUndos} | Rolls: ${_rollService.rollsRemaining}/5'
|
||||||
fontFamily: 'PressStart2P',
|
: 'Rolls: ${_rollService.rollsRemaining}/5',
|
||||||
fontSize: 13,
|
style: const TextStyle(
|
||||||
color: RetroColors.textDisabled,
|
fontFamily: 'PressStart2P',
|
||||||
),
|
fontSize: 11,
|
||||||
|
color: RetroColors.textDisabled,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -790,4 +915,96 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
|
|||||||
ClassPassiveType.firstStrikeBonus => passive.description,
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
|
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';
|
import 'package:asciineverdie/src/core/storage/settings_repository.dart';
|
||||||
|
|
||||||
/// 통합 설정 화면
|
/// 통합 설정 화면
|
||||||
@@ -75,9 +76,13 @@ class SettingsScreen extends StatefulWidget {
|
|||||||
class _SettingsScreenState extends State<SettingsScreen> {
|
class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
double _bgmVolume = 0.7;
|
double _bgmVolume = 0.7;
|
||||||
double _sfxVolume = 0.8;
|
double _sfxVolume = 0.8;
|
||||||
double _animationSpeed = 1.0;
|
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
|
|
||||||
|
// 디버그 설정 상태 (Phase 8)
|
||||||
|
bool _debugAdEnabled = true;
|
||||||
|
bool _debugIapSimulated = false;
|
||||||
|
int _debugOfflineHours = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -87,13 +92,20 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
Future<void> _loadSettings() async {
|
Future<void> _loadSettings() async {
|
||||||
final bgm = await widget.settingsRepository.loadBgmVolume();
|
final bgm = await widget.settingsRepository.loadBgmVolume();
|
||||||
final sfx = await widget.settingsRepository.loadSfxVolume();
|
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) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_bgmVolume = bgm;
|
_bgmVolume = bgm;
|
||||||
_sfxVolume = sfx;
|
_sfxVolume = sfx;
|
||||||
_animationSpeed = speed;
|
_debugAdEnabled = adEnabled;
|
||||||
|
_debugIapSimulated = iapSimulated;
|
||||||
|
_debugOfflineHours = offlineHours;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -181,17 +193,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// 애니메이션 속도
|
|
||||||
_buildSectionTitle(game_l10n.uiAnimationSpeed),
|
|
||||||
_buildAnimationSpeedSlider(),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
// 정보
|
// 정보
|
||||||
_buildSectionTitle(game_l10n.uiAbout),
|
_buildSectionTitle(game_l10n.uiAbout),
|
||||||
_buildAboutCard(),
|
_buildAboutCard(),
|
||||||
|
|
||||||
// 디버그 섹션 (디버그 모드에서만 표시)
|
// 디버그 섹션 (디버그 모드에서만 표시)
|
||||||
if (kDebugMode && widget.onCreateTestCharacter != null) ...[
|
if (kDebugMode) ...[
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
_buildSectionTitle('Debug'),
|
_buildSectionTitle('Debug'),
|
||||||
_buildDebugSection(),
|
_buildDebugSection(),
|
||||||
@@ -205,52 +212,171 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDebugSection() {
|
Widget _buildDebugSection() {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final errorColor = theme.colorScheme.error;
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
color: Theme.of(
|
color: theme.colorScheme.errorContainer.withValues(alpha: 0.3),
|
||||||
context,
|
|
||||||
).colorScheme.errorContainer.withValues(alpha: 0.3),
|
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
// 헤더
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(Icons.bug_report, color: errorColor),
|
||||||
Icons.bug_report,
|
|
||||||
color: Theme.of(context).colorScheme.error,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
'Developer Tools',
|
'Developer Tools',
|
||||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
style: theme.textTheme.titleSmall?.copyWith(color: errorColor),
|
||||||
color: Theme.of(context).colorScheme.error,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 16),
|
||||||
Text(
|
|
||||||
'현재 캐릭터를 레벨 100으로 수정하여 명예의 전당에 등록합니다. '
|
// 광고 ON/OFF 토글
|
||||||
'등록 후 현재 세이브 파일이 삭제됩니다.',
|
_buildDebugToggle(
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
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),
|
const SizedBox(height: 12),
|
||||||
SizedBox(
|
|
||||||
width: double.infinity,
|
// IAP 시뮬레이션 토글
|
||||||
child: ElevatedButton.icon(
|
_buildDebugToggle(
|
||||||
onPressed: _handleCreateTestCharacter,
|
icon: Icons.shopping_cart,
|
||||||
icon: const Icon(Icons.science),
|
label: 'IAP Purchased',
|
||||||
label: const Text('Create Test Character'),
|
description: 'ON: 유료 유저로 동작 (광고 제거)',
|
||||||
style: ElevatedButton.styleFrom(
|
value: _debugIapSimulated,
|
||||||
backgroundColor: Theme.of(context).colorScheme.error,
|
onChanged: (value) async {
|
||||||
foregroundColor: Theme.of(context).colorScheme.onError,
|
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<SettingsScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
Widget _buildAboutCard() {
|
||||||
return Card(
|
return Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
|
|||||||
@@ -6,13 +6,17 @@ import FlutterMacOS
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
import audio_session
|
import audio_session
|
||||||
|
import in_app_purchase_storekit
|
||||||
import just_audio
|
import just_audio
|
||||||
import path_provider_foundation
|
import path_provider_foundation
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
|
import webview_flutter_wkwebview
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin"))
|
AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin"))
|
||||||
|
InAppPurchasePlugin.register(with: registry.registrar(forPlugin: "InAppPurchasePlugin"))
|
||||||
JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin"))
|
JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin"))
|
||||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
|
WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ PODS:
|
|||||||
- audio_session (0.0.1):
|
- audio_session (0.0.1):
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
- FlutterMacOS (1.0.0)
|
- FlutterMacOS (1.0.0)
|
||||||
|
- in_app_purchase_storekit (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
- just_audio (0.0.1):
|
- just_audio (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
@@ -11,32 +14,43 @@ PODS:
|
|||||||
- shared_preferences_foundation (0.0.1):
|
- shared_preferences_foundation (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
- webview_flutter_wkwebview (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- audio_session (from `Flutter/ephemeral/.symlinks/plugins/audio_session/macos`)
|
- audio_session (from `Flutter/ephemeral/.symlinks/plugins/audio_session/macos`)
|
||||||
- FlutterMacOS (from `Flutter/ephemeral`)
|
- FlutterMacOS (from `Flutter/ephemeral`)
|
||||||
|
- in_app_purchase_storekit (from `Flutter/ephemeral/.symlinks/plugins/in_app_purchase_storekit/darwin`)
|
||||||
- just_audio (from `Flutter/ephemeral/.symlinks/plugins/just_audio/darwin`)
|
- just_audio (from `Flutter/ephemeral/.symlinks/plugins/just_audio/darwin`)
|
||||||
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
|
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
|
||||||
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
|
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||||
|
- webview_flutter_wkwebview (from `Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin`)
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
audio_session:
|
audio_session:
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/audio_session/macos
|
:path: Flutter/ephemeral/.symlinks/plugins/audio_session/macos
|
||||||
FlutterMacOS:
|
FlutterMacOS:
|
||||||
:path: Flutter/ephemeral
|
:path: Flutter/ephemeral
|
||||||
|
in_app_purchase_storekit:
|
||||||
|
:path: Flutter/ephemeral/.symlinks/plugins/in_app_purchase_storekit/darwin
|
||||||
just_audio:
|
just_audio:
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/just_audio/darwin
|
:path: Flutter/ephemeral/.symlinks/plugins/just_audio/darwin
|
||||||
path_provider_foundation:
|
path_provider_foundation:
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin
|
:path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin
|
||||||
shared_preferences_foundation:
|
shared_preferences_foundation:
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
|
:path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
|
||||||
|
webview_flutter_wkwebview:
|
||||||
|
:path: Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
audio_session: 728ae3823d914f809c485d390274861a24b0904e
|
audio_session: 728ae3823d914f809c485d390274861a24b0904e
|
||||||
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
||||||
|
in_app_purchase_storekit: 2342c0a5da86593124d08dd13d920f39a52b273a
|
||||||
just_audio: a42c63806f16995daf5b219ae1d679deb76e6a79
|
just_audio: a42c63806f16995daf5b219ae1d679deb76e6a79
|
||||||
path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba
|
path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba
|
||||||
shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
|
shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
|
||||||
|
webview_flutter_wkwebview: 29eb20d43355b48fe7d07113835b9128f84e3af4
|
||||||
|
|
||||||
PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009
|
PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009
|
||||||
|
|
||||||
|
|||||||
72
pubspec.lock
72
pubspec.lock
@@ -301,6 +301,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.3"
|
version: "2.1.3"
|
||||||
|
google_mobile_ads:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: google_mobile_ads
|
||||||
|
sha256: "0d4a3744b5e8ed1b8be6a1b452d309f811688855a497c6113fc4400f922db603"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.3.1"
|
||||||
graphs:
|
graphs:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -341,6 +349,38 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.7.2"
|
version: "4.7.2"
|
||||||
|
in_app_purchase:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: in_app_purchase
|
||||||
|
sha256: "5cddd7f463f3bddb1d37a72b95066e840d5822d66291331d7f8f05ce32c24b6c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.2.3"
|
||||||
|
in_app_purchase_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: in_app_purchase_android
|
||||||
|
sha256: abb254ae159a5a9d4f867795ecb076864faeba59ce015ab81d4cca380f23df45
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.4.0+8"
|
||||||
|
in_app_purchase_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: in_app_purchase_platform_interface
|
||||||
|
sha256: "1d353d38251da5b9fea6635c0ebfc6bb17a2d28d0e86ea5e083bf64244f1fb4c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.0"
|
||||||
|
in_app_purchase_storekit:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: in_app_purchase_storekit
|
||||||
|
sha256: f7cbbd7fb47ab5a4fb736fc3f20ae81a4f6def0af9297b3c525ca727761e2589
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.4.7"
|
||||||
intl:
|
intl:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -834,6 +874,38 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.3"
|
version: "3.0.3"
|
||||||
|
webview_flutter:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: webview_flutter
|
||||||
|
sha256: a3da219916aba44947d3a5478b1927876a09781174b5a2b67fa5be0555154bf9
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.13.1"
|
||||||
|
webview_flutter_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: webview_flutter_android
|
||||||
|
sha256: eeeb3fcd5f0ff9f8446c9f4bbc18a99b809e40297528a3395597d03aafb9f510
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.10.11"
|
||||||
|
webview_flutter_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: webview_flutter_platform_interface
|
||||||
|
sha256: "63d26ee3aca7256a83ccb576a50272edd7cfc80573a4305caa98985feb493ee0"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.14.0"
|
||||||
|
webview_flutter_wkwebview:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: webview_flutter_wkwebview
|
||||||
|
sha256: e49f378ed066efb13fc36186bbe0bd2425630d4ea0dbc71a18fdd0e4d8ed8ebc
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.23.5"
|
||||||
xdg_directories:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ dependencies:
|
|||||||
# Code generation annotations
|
# Code generation annotations
|
||||||
freezed_annotation: ^2.4.1
|
freezed_annotation: ^2.4.1
|
||||||
json_annotation: ^4.9.0
|
json_annotation: ^4.9.0
|
||||||
|
# AdMob 광고
|
||||||
|
google_mobile_ads: ^5.3.0
|
||||||
|
# IAP (인앱 결제)
|
||||||
|
in_app_purchase: ^3.2.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ void main() {
|
|||||||
test('loadAndStart surfaces save load errors', () {
|
test('loadAndStart surfaces save load errors', () {
|
||||||
fakeAsync((async) {
|
fakeAsync((async) {
|
||||||
final saveManager = FakeSaveManager()
|
final saveManager = FakeSaveManager()
|
||||||
..onLoad = (_) => (const SaveOutcome.failure('boom'), null, false);
|
..onLoad = (_) => (const SaveOutcome.failure('boom'), null, false, null);
|
||||||
final controller = buildController(async, saveManager);
|
final controller = buildController(async, saveManager);
|
||||||
|
|
||||||
controller.loadAndStart(fileName: 'bad.pqf');
|
controller.loadAndStart(fileName: 'bad.pqf');
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:asciineverdie/src/core/engine/reward_service.dart';
|
|||||||
import 'package:asciineverdie/src/core/model/combat_state.dart';
|
import 'package:asciineverdie/src/core/model/combat_state.dart';
|
||||||
import 'package:asciineverdie/src/core/model/combat_stats.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_state.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/monetization_state.dart';
|
||||||
import 'package:asciineverdie/src/core/model/monster_combat_stats.dart';
|
import 'package:asciineverdie/src/core/model/monster_combat_stats.dart';
|
||||||
import 'package:asciineverdie/src/core/model/pq_config.dart';
|
import 'package:asciineverdie/src/core/model/pq_config.dart';
|
||||||
import 'package:asciineverdie/src/core/storage/save_manager.dart';
|
import 'package:asciineverdie/src/core/storage/save_manager.dart';
|
||||||
@@ -23,7 +24,7 @@ class FakeSaveManager implements SaveManager {
|
|||||||
final List<GameState> savedStates = [];
|
final List<GameState> savedStates = [];
|
||||||
|
|
||||||
/// 커스텀 로드 동작 설정
|
/// 커스텀 로드 동작 설정
|
||||||
(SaveOutcome, GameState?, bool) Function(String?)? onLoad;
|
(SaveOutcome, GameState?, bool, MonetizationState?) Function(String?)? onLoad;
|
||||||
|
|
||||||
/// 저장 결과 설정 (기본: 성공)
|
/// 저장 결과 설정 (기본: 성공)
|
||||||
SaveOutcome saveOutcome = const SaveOutcome.success();
|
SaveOutcome saveOutcome = const SaveOutcome.success();
|
||||||
@@ -33,17 +34,20 @@ class FakeSaveManager implements SaveManager {
|
|||||||
GameState state, {
|
GameState state, {
|
||||||
String? fileName,
|
String? fileName,
|
||||||
bool cheatsEnabled = false,
|
bool cheatsEnabled = false,
|
||||||
|
MonetizationState? monetization,
|
||||||
}) async {
|
}) async {
|
||||||
savedStates.add(state);
|
savedStates.add(state);
|
||||||
return saveOutcome;
|
return saveOutcome;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<(SaveOutcome, GameState?, bool)> loadState({String? fileName}) async {
|
Future<(SaveOutcome, GameState?, bool, MonetizationState?)> loadState({
|
||||||
|
String? fileName,
|
||||||
|
}) async {
|
||||||
if (onLoad != null) {
|
if (onLoad != null) {
|
||||||
return onLoad!(fileName);
|
return onLoad!(fileName);
|
||||||
}
|
}
|
||||||
return (const SaveOutcome.success(), null, false);
|
return (const SaveOutcome.success(), null, false, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
Reference in New Issue
Block a user