feat(ui): 화면 및 컨트롤러 수익화 연동
- 앱 초기화에 광고/IAP 서비스 추가 - 게임 세션 컨트롤러 수익화 상태 관리 - 캐릭터 생성 화면 굴리기 제한 UI - 설정 화면 광고 제거 구매 UI - 애니메이션 패널 개선
This commit is contained in:
@@ -17,6 +17,7 @@ class FrontScreen extends StatefulWidget {
|
||||
this.onLoadSave,
|
||||
this.onHallOfFame,
|
||||
this.onLocalArena,
|
||||
this.onSettings,
|
||||
this.hasSaveFile = false,
|
||||
this.savedGamePreview,
|
||||
this.hallOfFameCount = 0,
|
||||
@@ -36,6 +37,9 @@ class FrontScreen extends StatefulWidget {
|
||||
/// "Local Arena" 버튼 클릭 시 호출
|
||||
final void Function(BuildContext context)? onLocalArena;
|
||||
|
||||
/// "Settings" 버튼 클릭 시 호출 (언어, 테마, 사운드)
|
||||
final void Function(BuildContext context)? onSettings;
|
||||
|
||||
/// 세이브 파일 존재 여부 (새 캐릭터 시 경고용)
|
||||
final bool hasSaveFile;
|
||||
|
||||
@@ -147,6 +151,9 @@ class _FrontScreenState extends State<FrontScreen> with RouteAware {
|
||||
widget.hallOfFameCount >= 2
|
||||
? () => widget.onLocalArena!(context)
|
||||
: null,
|
||||
onSettings: widget.onSettings != null
|
||||
? () => widget.onSettings!(context)
|
||||
: null,
|
||||
savedGamePreview: widget.savedGamePreview,
|
||||
hallOfFameCount: widget.hallOfFameCount,
|
||||
),
|
||||
@@ -249,6 +256,7 @@ class _ActionButtons extends StatelessWidget {
|
||||
this.onLoadSave,
|
||||
this.onHallOfFame,
|
||||
this.onLocalArena,
|
||||
this.onSettings,
|
||||
this.savedGamePreview,
|
||||
this.hallOfFameCount = 0,
|
||||
});
|
||||
@@ -257,6 +265,7 @@ class _ActionButtons extends StatelessWidget {
|
||||
final VoidCallback? onLoadSave;
|
||||
final VoidCallback? onHallOfFame;
|
||||
final VoidCallback? onLocalArena;
|
||||
final VoidCallback? onSettings;
|
||||
final SavedGamePreview? savedGamePreview;
|
||||
final int hallOfFameCount;
|
||||
|
||||
@@ -306,6 +315,14 @@ class _ActionButtons extends StatelessWidget {
|
||||
onPressed: hallOfFameCount >= 2 ? onLocalArena : null,
|
||||
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/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/l10n/app_localizations.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/task_progress_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/settings/settings_screen.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/statistics_dialog.dart';
|
||||
@@ -246,6 +249,9 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
|
||||
// 오디오 볼륨 초기화
|
||||
_audioController.initVolumes();
|
||||
|
||||
// Phase 7: 복귀 보상 콜백 설정
|
||||
widget.controller.onReturnRewardAvailable = _showReturnRewardsDialog;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -262,6 +268,7 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
_storyService.dispose();
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
widget.controller.removeListener(_onControllerChanged);
|
||||
widget.controller.onReturnRewardAvailable = null; // Phase 7: 콜백 정리
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -399,6 +406,21 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
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) {
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
final state = widget.controller.state;
|
||||
@@ -603,6 +666,11 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
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)
|
||||
@@ -610,12 +678,8 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
deathInfo: state.deathInfo!,
|
||||
traits: state.traits,
|
||||
onResurrect: _handleResurrect,
|
||||
isAutoResurrectEnabled: widget.controller.autoResurrect,
|
||||
onToggleAutoResurrect: () {
|
||||
widget.controller.setAutoResurrect(
|
||||
!widget.controller.autoResurrect,
|
||||
);
|
||||
},
|
||||
onAdRevive: _handleAdRevive,
|
||||
isPaidUser: IAPService.instance.isAdRemovalPurchased,
|
||||
),
|
||||
// 승리 오버레이 (게임 클리어)
|
||||
if (widget.controller.isComplete)
|
||||
@@ -759,18 +823,14 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
],
|
||||
),
|
||||
|
||||
// Phase 4: 사망 오버레이 (Death Overlay)
|
||||
// 사망 오버레이
|
||||
if (state.isDead && state.deathInfo != null)
|
||||
DeathOverlay(
|
||||
deathInfo: state.deathInfo!,
|
||||
traits: state.traits,
|
||||
onResurrect: _handleResurrect,
|
||||
isAutoResurrectEnabled: widget.controller.autoResurrect,
|
||||
onToggleAutoResurrect: () {
|
||||
widget.controller.setAutoResurrect(
|
||||
!widget.controller.autoResurrect,
|
||||
);
|
||||
},
|
||||
onAdRevive: _handleAdRevive,
|
||||
isPaidUser: IAPService.instance.isAdRemovalPurchased,
|
||||
),
|
||||
// 승리 오버레이 (게임 클리어)
|
||||
if (widget.controller.isComplete)
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:asciineverdie/src/core/engine/ad_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/debug_settings_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/iap_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/progress_loop.dart';
|
||||
import 'package:asciineverdie/src/core/engine/progress_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/resurrection_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/return_rewards_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/shop_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/test_character_service.dart';
|
||||
import 'package:asciineverdie/src/core/model/combat_stats.dart';
|
||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||
import 'package:asciineverdie/src/core/model/game_statistics.dart';
|
||||
import 'package:asciineverdie/src/core/model/hall_of_fame.dart';
|
||||
import 'package:asciineverdie/src/core/model/monetization_state.dart';
|
||||
import 'package:asciineverdie/src/core/storage/hall_of_fame_storage.dart';
|
||||
import 'package:asciineverdie/src/core/storage/save_manager.dart';
|
||||
import 'package:asciineverdie/src/core/storage/statistics_storage.dart';
|
||||
@@ -54,6 +59,20 @@ class GameSessionController extends ChangeNotifier {
|
||||
// 자동 부활 (Auto-Resurrection) 상태
|
||||
bool _autoResurrect = false;
|
||||
|
||||
// 속도 부스트 상태 (Phase 6)
|
||||
bool _isSpeedBoostActive = false;
|
||||
Timer? _speedBoostTimer;
|
||||
int _speedBoostRemainingSeconds = 0;
|
||||
static const int _speedBoostDuration = 300; // 5분
|
||||
static const int _speedBoostMultiplier = 5; // 5x 속도
|
||||
|
||||
// 복귀 보상 상태 (Phase 7)
|
||||
MonetizationState _monetization = MonetizationState.initial();
|
||||
ReturnReward? _pendingReturnReward;
|
||||
|
||||
/// 복귀 보상 콜백 (UI에서 다이얼로그 표시용)
|
||||
void Function(ReturnReward reward)? onReturnRewardAvailable;
|
||||
|
||||
// 통계 관련 필드
|
||||
SessionStatistics _sessionStats = SessionStatistics.empty();
|
||||
CumulativeStatistics _cumulativeStats = CumulativeStatistics.empty();
|
||||
@@ -152,18 +171,14 @@ class GameSessionController extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 명예의 전당 상태에 따른 가용 배속 목록 반환
|
||||
/// - 디버그 모드(치트 활성화): [1, 5, 20] (터보 모드)
|
||||
/// - 명예의 전당에 캐릭터 없음: [1, 5]
|
||||
/// - 명예의 전당에 캐릭터 있음: [1, 2, 5]
|
||||
/// 가용 배속 목록 반환
|
||||
/// - 디버그 모드(치트 활성화): [1, 2, 20] (터보 모드 포함)
|
||||
/// - 일반 모드: [1, 2] (5x는 광고 버프로만 활성화)
|
||||
Future<List<int>> _getAvailableSpeeds() async {
|
||||
// 디버그 모드면 터보(20x) 추가
|
||||
if (_cheatsEnabled) {
|
||||
return [1, 5, 20];
|
||||
return [1, 2, 20];
|
||||
}
|
||||
|
||||
final hallOfFame = await _hallOfFameStorage.load();
|
||||
return hallOfFame.isEmpty ? [1, 5] : [1, 2, 5];
|
||||
return [1, 2];
|
||||
}
|
||||
|
||||
/// 이전 값 초기화 (통계 변화 추적용)
|
||||
@@ -241,9 +256,8 @@ class GameSessionController extends ChangeNotifier {
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
|
||||
final (outcome, loaded, savedCheatsEnabled) = await saveManager.loadState(
|
||||
fileName: fileName,
|
||||
);
|
||||
final (outcome, loaded, savedCheatsEnabled, savedMonetization) =
|
||||
await saveManager.loadState(fileName: fileName);
|
||||
if (!outcome.success || loaded == null) {
|
||||
_status = GameSessionStatus.error;
|
||||
_error = outcome.error ?? 'Unknown error';
|
||||
@@ -251,6 +265,12 @@ class GameSessionController extends ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
// 저장된 수익화 상태 복원
|
||||
_monetization = savedMonetization ?? MonetizationState.initial();
|
||||
|
||||
// 복귀 보상 체크 (Phase 7)
|
||||
_checkReturnRewards(loaded);
|
||||
|
||||
// 저장된 치트 모드 상태 복원
|
||||
await startNew(loaded, cheatsEnabled: savedCheatsEnabled, isNewGame: false);
|
||||
}
|
||||
@@ -312,8 +332,16 @@ class GameSessionController extends ChangeNotifier {
|
||||
_status = GameSessionStatus.dead;
|
||||
notifyListeners();
|
||||
|
||||
// 자동 부활이 활성화된 경우 잠시 후 자동으로 부활
|
||||
if (_autoResurrect) {
|
||||
// 자동 부활 조건 확인:
|
||||
// 1. 수동 토글 자동부활 (_autoResurrect)
|
||||
// 2. 유료 유저 (항상 자동부활)
|
||||
// 3. 광고 부활 버프 활성 (10분간)
|
||||
final elapsedMs = _state?.skillSystem.elapsedMs ?? 0;
|
||||
final shouldAutoResurrect = _autoResurrect ||
|
||||
IAPService.instance.isAdRemovalPurchased ||
|
||||
_monetization.isAutoReviveActive(elapsedMs);
|
||||
|
||||
if (shouldAutoResurrect) {
|
||||
_scheduleAutoResurrect();
|
||||
}
|
||||
}
|
||||
@@ -323,8 +351,15 @@ class GameSessionController extends ChangeNotifier {
|
||||
/// 사망 오버레이를 잠시 표시한 후 자동으로 부활 처리
|
||||
void _scheduleAutoResurrect() {
|
||||
Future.delayed(const Duration(milliseconds: 800), () async {
|
||||
// 상태가 여전히 dead이고, 자동 부활이 활성화된 경우에만 부활
|
||||
if (_status == GameSessionStatus.dead && _autoResurrect) {
|
||||
if (_status != GameSessionStatus.dead) return;
|
||||
|
||||
// 자동 부활 조건 재확인
|
||||
final elapsedMs = _state?.skillSystem.elapsedMs ?? 0;
|
||||
final shouldAutoResurrect = _autoResurrect ||
|
||||
IAPService.instance.isAdRemovalPurchased ||
|
||||
_monetization.isAutoReviveActive(elapsedMs);
|
||||
|
||||
if (shouldAutoResurrect) {
|
||||
await resurrect();
|
||||
await resumeAfterResurrection();
|
||||
}
|
||||
@@ -456,6 +491,7 @@ class GameSessionController extends ChangeNotifier {
|
||||
await saveManager.saveState(
|
||||
resurrectedState,
|
||||
cheatsEnabled: _cheatsEnabled,
|
||||
monetization: _monetization,
|
||||
);
|
||||
|
||||
notifyListeners();
|
||||
@@ -471,10 +507,266 @@ class GameSessionController extends ChangeNotifier {
|
||||
await startNew(_state!, cheatsEnabled: _cheatsEnabled, isNewGame: false);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 광고 부활 (HP 100% + 아이템 복구 + 10분 자동부활)
|
||||
// ===========================================================================
|
||||
|
||||
/// 광고 부활 (HP 100% + 아이템 복구 + 10분 자동부활 버프)
|
||||
///
|
||||
/// 유료 유저: 광고 없이 부활
|
||||
/// 무료 유저: 리워드 광고 시청 후 부활
|
||||
Future<void> adRevive() async {
|
||||
if (_state == null || !_state!.isDead) return;
|
||||
|
||||
final shopService = ShopService(rng: _state!.rng);
|
||||
final resurrectionService = ResurrectionService(shopService: shopService);
|
||||
|
||||
// 부활 처리 함수
|
||||
void processRevive() {
|
||||
_state = resurrectionService.processAdRevive(_state!);
|
||||
_status = GameSessionStatus.idle;
|
||||
|
||||
// 10분 자동부활 버프 활성화 (elapsedMs 기준)
|
||||
final buffEndMs = _state!.skillSystem.elapsedMs + 600000; // 10분 = 600,000ms
|
||||
_monetization = _monetization.copyWith(
|
||||
autoReviveEndMs: buffEndMs,
|
||||
);
|
||||
|
||||
debugPrint('[GameSession] Ad revive complete, auto-revive buff until $buffEndMs ms');
|
||||
}
|
||||
|
||||
// 유료 유저는 광고 없이 부활
|
||||
if (IAPService.instance.isAdRemovalPurchased) {
|
||||
processRevive();
|
||||
await saveManager.saveState(
|
||||
_state!,
|
||||
cheatsEnabled: _cheatsEnabled,
|
||||
monetization: _monetization,
|
||||
);
|
||||
notifyListeners();
|
||||
debugPrint('[GameSession] Ad revive (paid user)');
|
||||
return;
|
||||
}
|
||||
|
||||
// 무료 유저는 리워드 광고 필요
|
||||
final adResult = await AdService.instance.showRewardedAd(
|
||||
adType: AdType.rewardRevive,
|
||||
onRewarded: processRevive,
|
||||
);
|
||||
|
||||
if (adResult == AdResult.completed || adResult == AdResult.debugSkipped) {
|
||||
await saveManager.saveState(
|
||||
_state!,
|
||||
cheatsEnabled: _cheatsEnabled,
|
||||
monetization: _monetization,
|
||||
);
|
||||
notifyListeners();
|
||||
debugPrint('[GameSession] Ad revive (free user with ad)');
|
||||
} else {
|
||||
debugPrint('[GameSession] Ad revive failed: $adResult');
|
||||
}
|
||||
}
|
||||
|
||||
/// 사망 상태 여부
|
||||
bool get isDead =>
|
||||
_status == GameSessionStatus.dead || (_state?.isDead ?? false);
|
||||
|
||||
/// 게임 클리어 여부
|
||||
bool get isComplete => _status == GameSessionStatus.complete;
|
||||
|
||||
// ===========================================================================
|
||||
// 속도 부스트 (Phase 6)
|
||||
// ===========================================================================
|
||||
|
||||
/// 속도 부스트 활성화 여부
|
||||
bool get isSpeedBoostActive => _isSpeedBoostActive;
|
||||
|
||||
/// 속도 부스트 남은 시간 (초)
|
||||
int get speedBoostRemainingSeconds => _speedBoostRemainingSeconds;
|
||||
|
||||
/// 속도 부스트 배율
|
||||
int get speedBoostMultiplier => _speedBoostMultiplier;
|
||||
|
||||
/// 속도 부스트 지속 시간 (초)
|
||||
int get speedBoostDuration => _speedBoostDuration;
|
||||
|
||||
/// 현재 실제 배속 (부스트 적용 포함)
|
||||
int get currentSpeedMultiplier {
|
||||
if (_isSpeedBoostActive) return _speedBoostMultiplier;
|
||||
return _loop?.speedMultiplier ?? _savedSpeedMultiplier;
|
||||
}
|
||||
|
||||
/// 속도 부스트 활성화 (광고 시청 후)
|
||||
///
|
||||
/// 유료 유저: 무료 활성화
|
||||
/// 무료 유저: 인터스티셜 광고 시청 후 활성화
|
||||
/// Returns: 활성화 성공 여부
|
||||
Future<bool> activateSpeedBoost() async {
|
||||
if (_isSpeedBoostActive) return false; // 이미 활성화됨
|
||||
if (_loop == null) return false;
|
||||
|
||||
// 유료 유저는 무료 활성화
|
||||
if (IAPService.instance.isAdRemovalPurchased) {
|
||||
_startSpeedBoost();
|
||||
debugPrint('[GameSession] Speed boost activated (paid user)');
|
||||
return true;
|
||||
}
|
||||
|
||||
// 무료 유저는 인터스티셜 광고 필요
|
||||
bool activated = false;
|
||||
final adResult = await AdService.instance.showInterstitialAd(
|
||||
adType: AdType.interstitialSpeed,
|
||||
onComplete: () {
|
||||
_startSpeedBoost();
|
||||
activated = true;
|
||||
},
|
||||
);
|
||||
|
||||
if (adResult == AdResult.completed || adResult == AdResult.debugSkipped) {
|
||||
debugPrint('[GameSession] Speed boost activated (free user with ad)');
|
||||
return activated;
|
||||
}
|
||||
|
||||
debugPrint('[GameSession] Speed boost activation failed: $adResult');
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 속도 부스트 시작 (내부)
|
||||
void _startSpeedBoost() {
|
||||
if (_loop == null) return;
|
||||
|
||||
// 현재 배속 저장
|
||||
_savedSpeedMultiplier = _loop!.speedMultiplier;
|
||||
|
||||
// 부스트 배속 적용
|
||||
_isSpeedBoostActive = true;
|
||||
_speedBoostRemainingSeconds = _speedBoostDuration;
|
||||
|
||||
// monetization 상태에 종료 시점 저장 (UI 표시용)
|
||||
final currentElapsedMs = _state?.skillSystem.elapsedMs ?? 0;
|
||||
final endMs = currentElapsedMs + (_speedBoostDuration * 1000);
|
||||
_monetization = _monetization.copyWith(speedBoostEndMs: endMs);
|
||||
|
||||
// ProgressLoop에 직접 배속 설정
|
||||
_loop!.updateAvailableSpeeds([_speedBoostMultiplier]);
|
||||
|
||||
// 1초마다 남은 시간 감소
|
||||
_speedBoostTimer?.cancel();
|
||||
_speedBoostTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
_speedBoostRemainingSeconds--;
|
||||
notifyListeners();
|
||||
|
||||
if (_speedBoostRemainingSeconds <= 0) {
|
||||
_endSpeedBoost();
|
||||
}
|
||||
});
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 속도 부스트 종료 (내부)
|
||||
void _endSpeedBoost() {
|
||||
_speedBoostTimer?.cancel();
|
||||
_speedBoostTimer = null;
|
||||
_isSpeedBoostActive = false;
|
||||
_speedBoostRemainingSeconds = 0;
|
||||
|
||||
// monetization 상태 초기화 (UI 표시 제거)
|
||||
_monetization = _monetization.copyWith(speedBoostEndMs: null);
|
||||
|
||||
// 원래 배속 복원
|
||||
if (_loop != null) {
|
||||
_getAvailableSpeeds().then((speeds) {
|
||||
_loop!.updateAvailableSpeeds(speeds);
|
||||
});
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
debugPrint('[GameSession] Speed boost ended');
|
||||
}
|
||||
|
||||
/// 속도 부스트 수동 취소
|
||||
void cancelSpeedBoost() {
|
||||
if (_isSpeedBoostActive) {
|
||||
_endSpeedBoost();
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 복귀 보상 (Phase 7)
|
||||
// ===========================================================================
|
||||
|
||||
/// 현재 수익화 상태
|
||||
MonetizationState get monetization => _monetization;
|
||||
|
||||
/// 대기 중인 복귀 보상
|
||||
ReturnReward? get pendingReturnReward => _pendingReturnReward;
|
||||
|
||||
/// 복귀 보상 체크 (로드 시 호출)
|
||||
void _checkReturnRewards(GameState loaded) {
|
||||
final rewardsService = ReturnRewardsService.instance;
|
||||
final debugSettings = DebugSettingsService.instance;
|
||||
|
||||
// 디버그 모드: 오프라인 시간 시뮬레이션 적용
|
||||
final lastPlayTime = debugSettings.getSimulatedLastPlayTime(
|
||||
_monetization.lastPlayTime,
|
||||
);
|
||||
|
||||
final reward = rewardsService.calculateReward(
|
||||
lastPlayTime: lastPlayTime,
|
||||
currentTime: DateTime.now(),
|
||||
playerLevel: loaded.traits.level,
|
||||
);
|
||||
|
||||
if (reward.hasReward) {
|
||||
_pendingReturnReward = reward;
|
||||
debugPrint('[ReturnRewards] Reward available: ${reward.goldReward} gold, '
|
||||
'${reward.hoursAway} hours away');
|
||||
|
||||
// UI에서 다이얼로그 표시를 위해 콜백 호출
|
||||
// startNew 후에 호출하도록 딜레이
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
if (_pendingReturnReward != null) {
|
||||
onReturnRewardAvailable?.call(_pendingReturnReward!);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 복귀 보상 수령 완료 (골드 적용)
|
||||
///
|
||||
/// [totalGold] 수령한 총 골드 (기본 + 보너스)
|
||||
void applyReturnReward(int totalGold) {
|
||||
if (_state == null) return;
|
||||
if (totalGold <= 0) {
|
||||
// 보상 없이 건너뛴 경우
|
||||
_pendingReturnReward = null;
|
||||
debugPrint('[ReturnRewards] Reward skipped');
|
||||
return;
|
||||
}
|
||||
|
||||
// 골드 추가
|
||||
final updatedInventory = _state!.inventory.copyWith(
|
||||
gold: _state!.inventory.gold + totalGold,
|
||||
);
|
||||
_state = _state!.copyWith(inventory: updatedInventory);
|
||||
|
||||
// 저장
|
||||
unawaited(saveManager.saveState(
|
||||
_state!,
|
||||
cheatsEnabled: _cheatsEnabled,
|
||||
monetization: _monetization,
|
||||
));
|
||||
|
||||
_pendingReturnReward = null;
|
||||
notifyListeners();
|
||||
|
||||
debugPrint('[ReturnRewards] Reward applied: $totalGold gold');
|
||||
}
|
||||
|
||||
/// 복귀 보상 건너뛰기
|
||||
void skipReturnReward() {
|
||||
_pendingReturnReward = null;
|
||||
debugPrint('[ReturnRewards] Reward skipped by user');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,10 @@ class MobileCarouselLayout extends StatefulWidget {
|
||||
this.onCheatQuest,
|
||||
this.onCheatPlot,
|
||||
this.onCreateTestCharacter,
|
||||
this.autoReviveEndMs,
|
||||
this.speedBoostEndMs,
|
||||
this.isPaidUser = false,
|
||||
this.onSpeedBoostActivate,
|
||||
});
|
||||
|
||||
final GameState state;
|
||||
@@ -102,6 +106,18 @@ class MobileCarouselLayout extends StatefulWidget {
|
||||
/// 테스트 캐릭터 생성 콜백 (디버그 모드 전용)
|
||||
final Future<void> Function()? onCreateTestCharacter;
|
||||
|
||||
/// 자동부활 버프 종료 시점 (elapsedMs 기준)
|
||||
final int? autoReviveEndMs;
|
||||
|
||||
/// 5배속 버프 종료 시점 (elapsedMs 기준)
|
||||
final int? speedBoostEndMs;
|
||||
|
||||
/// 유료 유저 여부
|
||||
final bool isPaidUser;
|
||||
|
||||
/// 5배속 버프 활성화 콜백 (광고 시청)
|
||||
final VoidCallback? onSpeedBoostActivate;
|
||||
|
||||
@override
|
||||
State<MobileCarouselLayout> createState() => _MobileCarouselLayoutState();
|
||||
}
|
||||
@@ -456,27 +472,7 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
|
||||
ListTile(
|
||||
leading: const Icon(Icons.speed),
|
||||
title: Text(l10n.menuSpeed),
|
||||
trailing: Container(
|
||||
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);
|
||||
},
|
||||
trailing: _buildSpeedSelector(context),
|
||||
),
|
||||
|
||||
const Divider(),
|
||||
@@ -735,6 +731,10 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
|
||||
state.progress.currentCombat?.recentEvents.lastOrNull,
|
||||
raceId: state.traits.raceId,
|
||||
weaponRarity: state.equipment.weaponItem.rarity,
|
||||
autoReviveEndMs: widget.autoReviveEndMs,
|
||||
speedBoostEndMs: widget.speedBoostEndMs,
|
||||
isPaidUser: widget.isPaidUser,
|
||||
onSpeedBoostActivate: widget.onSpeedBoostActivate,
|
||||
),
|
||||
|
||||
// 중앙: 캐로셀 (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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,10 @@ class EnhancedAnimationPanel extends StatefulWidget {
|
||||
this.latestCombatEvent,
|
||||
this.raceId,
|
||||
this.weaponRarity,
|
||||
this.autoReviveEndMs,
|
||||
this.speedBoostEndMs,
|
||||
this.isPaidUser = false,
|
||||
this.onSpeedBoostActivate,
|
||||
});
|
||||
|
||||
final ProgressState progress;
|
||||
@@ -65,6 +69,18 @@ class EnhancedAnimationPanel extends StatefulWidget {
|
||||
/// 무기 희귀도 (Phase 9: 무기 등급별 이펙트 색상)
|
||||
final ItemRarity? weaponRarity;
|
||||
|
||||
/// 자동부활 버프 종료 시점 (elapsedMs 기준, null이면 비활성)
|
||||
final int? autoReviveEndMs;
|
||||
|
||||
/// 5배속 버프 종료 시점 (elapsedMs 기준, null이면 비활성)
|
||||
final int? speedBoostEndMs;
|
||||
|
||||
/// 유료 유저 여부 (5배속 항상 활성)
|
||||
final bool isPaidUser;
|
||||
|
||||
/// 5배속 버프 활성화 콜백 (광고 시청)
|
||||
final VoidCallback? onSpeedBoostActivate;
|
||||
|
||||
@override
|
||||
State<EnhancedAnimationPanel> createState() => _EnhancedAnimationPanelState();
|
||||
}
|
||||
@@ -190,6 +206,22 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
|
||||
int? get _currentMonsterHpMax =>
|
||||
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
|
||||
void dispose() {
|
||||
_hpFlashController.dispose();
|
||||
@@ -218,62 +250,94 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// ASCII 애니메이션 (기존 높이 120 유지)
|
||||
// ASCII 애니메이션 (기존 높이 120 유지) + 버프 오버레이
|
||||
SizedBox(
|
||||
height: 120,
|
||||
child: AsciiAnimationCard(
|
||||
taskType: widget.progress.currentTask.type,
|
||||
monsterBaseName: widget.progress.currentTask.monsterBaseName,
|
||||
specialAnimation: widget.specialAnimation,
|
||||
weaponName: widget.weaponName,
|
||||
shieldName: widget.shieldName,
|
||||
characterLevel: widget.characterLevel,
|
||||
monsterLevel: widget.monsterLevel,
|
||||
monsterGrade: widget.monsterGrade,
|
||||
monsterSize: widget.monsterSize,
|
||||
isPaused: widget.isPaused,
|
||||
isInCombat: isInCombat,
|
||||
monsterDied: _monsterDied,
|
||||
latestCombatEvent: widget.latestCombatEvent,
|
||||
raceId: widget.raceId,
|
||||
weaponRarity: widget.weaponRarity,
|
||||
child: Stack(
|
||||
children: [
|
||||
// ASCII 애니메이션
|
||||
AsciiAnimationCard(
|
||||
taskType: widget.progress.currentTask.type,
|
||||
monsterBaseName: widget.progress.currentTask.monsterBaseName,
|
||||
specialAnimation: widget.specialAnimation,
|
||||
weaponName: widget.weaponName,
|
||||
shieldName: widget.shieldName,
|
||||
characterLevel: widget.characterLevel,
|
||||
monsterLevel: widget.monsterLevel,
|
||||
monsterGrade: widget.monsterGrade,
|
||||
monsterSize: widget.monsterSize,
|
||||
isPaused: widget.isPaused,
|
||||
isInCombat: isInCombat,
|
||||
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),
|
||||
|
||||
// 상태 바 영역: HP/MP + 버프 아이콘 + 몬스터 HP
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 좌측: HP/MP 바
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Column(
|
||||
children: [
|
||||
_buildCompactHpBar(),
|
||||
const SizedBox(height: 4),
|
||||
_buildCompactMpBar(),
|
||||
],
|
||||
// 상태 바 영역: HP/MP (40%) + 컨트롤 (20%) + 몬스터 HP (40%)
|
||||
SizedBox(
|
||||
height: 48,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 좌측: HP/MP 바 (40%)
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
Expanded(child: _buildCompactHpBar()),
|
||||
const SizedBox(height: 4),
|
||||
Expanded(child: _buildCompactMpBar()),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
// 중앙: 컨트롤 버튼 (20%)
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: _buildControlButtons(),
|
||||
),
|
||||
|
||||
// 중앙: 활성 버프 아이콘 (최대 3개)
|
||||
_buildBuffIcons(),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// 우측: 몬스터 HP (전투 중) 또는 컨트롤 버튼
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: switch ((shouldShowMonsterHp, combat)) {
|
||||
(true, final c?) => _buildMonsterHpBar(c),
|
||||
_ => _buildControlButtons(),
|
||||
},
|
||||
),
|
||||
],
|
||||
// 우측: 몬스터 HP (전투 중) 또는 빈 공간 (40%)
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: switch ((shouldShowMonsterHp, combat)) {
|
||||
(true, final c?) => _buildMonsterHpBar(c),
|
||||
_ => const SizedBox.shrink(),
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 6),
|
||||
@@ -298,7 +362,6 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
|
||||
children: [
|
||||
// HP 바
|
||||
Container(
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
color: isLow
|
||||
? Colors.red.withValues(alpha: 0.2)
|
||||
@@ -330,13 +393,14 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
|
||||
borderRadius: const BorderRadius.horizontal(
|
||||
right: Radius.circular(3),
|
||||
),
|
||||
child: LinearProgressIndicator(
|
||||
value: ratio.clamp(0.0, 1.0),
|
||||
backgroundColor: Colors.red.withValues(alpha: 0.2),
|
||||
valueColor: AlwaysStoppedAnimation(
|
||||
isLow ? Colors.red : Colors.red.shade600,
|
||||
child: SizedBox.expand(
|
||||
child: LinearProgressIndicator(
|
||||
value: ratio.clamp(0.0, 1.0),
|
||||
backgroundColor: Colors.red.withValues(alpha: 0.2),
|
||||
valueColor: AlwaysStoppedAnimation(
|
||||
isLow ? Colors.red : Colors.red.shade600,
|
||||
),
|
||||
),
|
||||
minHeight: 20,
|
||||
),
|
||||
),
|
||||
// 숫자 오버레이 (바 중앙)
|
||||
@@ -402,7 +466,6 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Container(
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade800,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
@@ -431,13 +494,14 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
|
||||
borderRadius: const BorderRadius.horizontal(
|
||||
right: Radius.circular(3),
|
||||
),
|
||||
child: LinearProgressIndicator(
|
||||
value: ratio.clamp(0.0, 1.0),
|
||||
backgroundColor: Colors.blue.withValues(alpha: 0.2),
|
||||
valueColor: AlwaysStoppedAnimation(
|
||||
Colors.blue.shade600,
|
||||
child: SizedBox.expand(
|
||||
child: LinearProgressIndicator(
|
||||
value: ratio.clamp(0.0, 1.0),
|
||||
backgroundColor: Colors.blue.withValues(alpha: 0.2),
|
||||
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% 오버레이
|
||||
/// - 하단에 레벨.이름 표시
|
||||
@@ -562,7 +572,6 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Container(
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
@@ -572,52 +581,54 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// HP 바 (HP% 중앙 오버레이)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
// HP 바
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
child: LinearProgressIndicator(
|
||||
value: ratio.clamp(0.0, 1.0),
|
||||
backgroundColor: Colors.orange.withValues(
|
||||
alpha: 0.2,
|
||||
),
|
||||
valueColor: const AlwaysStoppedAnimation(
|
||||
Colors.orange,
|
||||
),
|
||||
minHeight: 16,
|
||||
),
|
||||
),
|
||||
// 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,
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 4, 4, 2),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
// HP 바
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
child: SizedBox.expand(
|
||||
child: LinearProgressIndicator(
|
||||
value: ratio.clamp(0.0, 1.0),
|
||||
backgroundColor: Colors.orange.withValues(
|
||||
alpha: 0.2,
|
||||
),
|
||||
valueColor: const AlwaysStoppedAnimation(
|
||||
Colors.orange,
|
||||
),
|
||||
),
|
||||
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: const EdgeInsets.symmetric(horizontal: 4),
|
||||
padding: const EdgeInsets.fromLTRB(4, 0, 4, 4),
|
||||
child: Text(
|
||||
'Lv.$monsterLevel $monsterName',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontSize: 11,
|
||||
color: Colors.orange,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
@@ -662,63 +673,91 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
|
||||
);
|
||||
}
|
||||
|
||||
/// 컨트롤 버튼 (비전투 시)
|
||||
/// 컨트롤 버튼 (중앙 영역)
|
||||
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(
|
||||
height: 40,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// 일시정지 버튼
|
||||
SizedBox(
|
||||
width: 40,
|
||||
height: 36,
|
||||
child: OutlinedButton(
|
||||
onPressed: widget.onPauseToggle,
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
visualDensity: VisualDensity.compact,
|
||||
side: BorderSide(
|
||||
color: widget.isPaused
|
||||
? Colors.orange.withValues(alpha: 0.7)
|
||||
: Theme.of(
|
||||
context,
|
||||
).colorScheme.outline.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
widget.isPaused ? Icons.play_arrow : Icons.pause,
|
||||
size: 18,
|
||||
color: widget.isPaused ? Colors.orange : null,
|
||||
width: 32,
|
||||
height: 22,
|
||||
child: OutlinedButton(
|
||||
onPressed: widget.onSpeedCycle,
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
visualDensity: VisualDensity.compact,
|
||||
side: BorderSide(
|
||||
color: isSpeedBoostActive
|
||||
? Colors.orange
|
||||
: widget.speedMultiplier > 1
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
isSpeedBoostActive ? '5x' : '${widget.speedMultiplier}x',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isSpeedBoostActive
|
||||
? Colors.orange
|
||||
: widget.speedMultiplier > 1
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/race_data.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/game_state.dart';
|
||||
import 'package:asciineverdie/src/core/model/race_traits.dart';
|
||||
@@ -52,10 +54,6 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
|
||||
int _wis = 0;
|
||||
int _cha = 0;
|
||||
|
||||
// 롤 이력 (Unroll 기능용) - 원본 OldRolls TListBox
|
||||
static const int _maxRollHistory = 20; // 최대 저장 개수
|
||||
final List<int> _rollHistory = [];
|
||||
|
||||
// 현재 RNG 시드 (Re-Roll 전 저장)
|
||||
int _currentSeed = 0;
|
||||
|
||||
@@ -68,10 +66,19 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
|
||||
// 굴리기 버튼 연속 클릭 방지
|
||||
bool _isRolling = false;
|
||||
|
||||
// 굴리기/되돌리기 서비스
|
||||
final CharacterRollService _rollService = CharacterRollService.instance;
|
||||
|
||||
// 서비스 초기화 완료 여부
|
||||
bool _isServiceInitialized = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// 서비스 초기화
|
||||
_initializeService();
|
||||
|
||||
// 초기 랜덤화
|
||||
final random = math.Random();
|
||||
_selectedRaceIndex = random.nextInt(_races.length);
|
||||
@@ -89,6 +96,16 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
|
||||
_scrollToSelectedItems();
|
||||
}
|
||||
|
||||
/// 서비스 초기화
|
||||
Future<void> _initializeService() async {
|
||||
await _rollService.initialize();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isServiceInitialized = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
@@ -144,12 +161,35 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
|
||||
if (_isRolling) return;
|
||||
_isRolling = true;
|
||||
|
||||
// 현재 시드를 이력에 저장
|
||||
_rollHistory.insert(0, _currentSeed);
|
||||
// 굴리기 가능 여부 확인
|
||||
if (!_rollService.canRoll) {
|
||||
_isRolling = false;
|
||||
_showRechargeDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
// 최대 개수 초과 시 가장 오래된 항목 제거
|
||||
if (_rollHistory.length > _maxRollHistory) {
|
||||
_rollHistory.removeLast();
|
||||
// 현재 상태를 서비스에 저장
|
||||
final currentStats = Stats(
|
||||
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() {
|
||||
if (_rollHistory.isEmpty) return;
|
||||
/// 굴리기 충전 다이얼로그
|
||||
Future<void> _showRechargeDialog() async {
|
||||
final isPaidUser = IAPService.instance.isAdRemovalPurchased;
|
||||
|
||||
setState(() {
|
||||
_currentSeed = _rollHistory.removeAt(0);
|
||||
});
|
||||
_rollStats();
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
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(),
|
||||
);
|
||||
|
||||
// 캐릭터 생성 완료 알림 (되돌리기 상태 초기화)
|
||||
_rollService.onCharacterCreated();
|
||||
|
||||
widget.onCharacterCreated?.call(initialState, testMode: _cheatsEnabled);
|
||||
}
|
||||
|
||||
@@ -493,34 +625,27 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
RetroTextButton(
|
||||
text: l10n.unroll,
|
||||
icon: Icons.undo,
|
||||
onPressed: _rollHistory.isEmpty ? null : _onUnroll,
|
||||
isPrimary: false,
|
||||
),
|
||||
_buildUndoButton(l10n),
|
||||
const SizedBox(width: 16),
|
||||
RetroTextButton(
|
||||
text: l10n.roll,
|
||||
icon: Icons.casino,
|
||||
onPressed: _onReroll,
|
||||
),
|
||||
_buildRollButton(l10n),
|
||||
],
|
||||
),
|
||||
if (_rollHistory.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Center(
|
||||
child: Text(
|
||||
game_l10n.uiRollHistory(_rollHistory.length),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 13,
|
||||
color: RetroColors.textDisabled,
|
||||
),
|
||||
// 남은 횟수 표시
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Center(
|
||||
child: Text(
|
||||
_rollService.canUndo
|
||||
? 'Undo: ${_rollService.availableUndos} | Rolls: ${_rollService.rollsRemaining}/5'
|
||||
: 'Rolls: ${_rollService.rollsRemaining}/5',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 11,
|
||||
color: RetroColors.textDisabled,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -790,4 +915,96 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
|
||||
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: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';
|
||||
|
||||
/// 통합 설정 화면
|
||||
@@ -75,9 +76,13 @@ class SettingsScreen extends StatefulWidget {
|
||||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
double _bgmVolume = 0.7;
|
||||
double _sfxVolume = 0.8;
|
||||
double _animationSpeed = 1.0;
|
||||
bool _isLoading = true;
|
||||
|
||||
// 디버그 설정 상태 (Phase 8)
|
||||
bool _debugAdEnabled = true;
|
||||
bool _debugIapSimulated = false;
|
||||
int _debugOfflineHours = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -87,13 +92,20 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
Future<void> _loadSettings() async {
|
||||
final bgm = await widget.settingsRepository.loadBgmVolume();
|
||||
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) {
|
||||
setState(() {
|
||||
_bgmVolume = bgm;
|
||||
_sfxVolume = sfx;
|
||||
_animationSpeed = speed;
|
||||
_debugAdEnabled = adEnabled;
|
||||
_debugIapSimulated = iapSimulated;
|
||||
_debugOfflineHours = offlineHours;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
@@ -181,17 +193,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 애니메이션 속도
|
||||
_buildSectionTitle(game_l10n.uiAnimationSpeed),
|
||||
_buildAnimationSpeedSlider(),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 정보
|
||||
_buildSectionTitle(game_l10n.uiAbout),
|
||||
_buildAboutCard(),
|
||||
|
||||
// 디버그 섹션 (디버그 모드에서만 표시)
|
||||
if (kDebugMode && widget.onCreateTestCharacter != null) ...[
|
||||
if (kDebugMode) ...[
|
||||
const SizedBox(height: 24),
|
||||
_buildSectionTitle('Debug'),
|
||||
_buildDebugSection(),
|
||||
@@ -205,52 +212,171 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
|
||||
Widget _buildDebugSection() {
|
||||
final theme = Theme.of(context);
|
||||
final errorColor = theme.colorScheme.error;
|
||||
|
||||
return Card(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.errorContainer.withValues(alpha: 0.3),
|
||||
color: theme.colorScheme.errorContainer.withValues(alpha: 0.3),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 헤더
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.bug_report,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
Icon(Icons.bug_report, color: errorColor),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Developer Tools',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
style: theme.textTheme.titleSmall?.copyWith(color: errorColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'현재 캐릭터를 레벨 100으로 수정하여 명예의 전당에 등록합니다. '
|
||||
'등록 후 현재 세이브 파일이 삭제됩니다.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 광고 ON/OFF 토글
|
||||
_buildDebugToggle(
|
||||
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),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _handleCreateTestCharacter,
|
||||
icon: const Icon(Icons.science),
|
||||
label: const Text('Create Test Character'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
foregroundColor: Theme.of(context).colorScheme.onError,
|
||||
|
||||
// IAP 시뮬레이션 토글
|
||||
_buildDebugToggle(
|
||||
icon: Icons.shopping_cart,
|
||||
label: 'IAP Purchased',
|
||||
description: 'ON: 유료 유저로 동작 (광고 제거)',
|
||||
value: _debugIapSimulated,
|
||||
onChanged: (value) async {
|
||||
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() {
|
||||
return Card(
|
||||
child: Padding(
|
||||
|
||||
Reference in New Issue
Block a user