Compare commits

..

2 Commits

Author SHA1 Message Date
JiWoong Sul
d90543dd86 fix(speed): 배속 관련 버그 수정
- 광고 후 배속 적용 안됨: isShowingAd 플래그로 lifecycle reload 방지
- 배속 종료 후 복귀 안됨: setSpeed(_savedSpeedMultiplier) 추가
- 복귀 상자 장비 장착 안됨: _loop?.replaceState() 추가
- 세이브 로드 시 1배속 고정: 명예의 전당 해금 시 최소 2배속 보장
2026-01-19 19:39:32 +09:00
JiWoong Sul
03ff9c1ce8 refactor(ui): 배속 버튼 UI 단순화
- 1x/2x 사이클 버튼 + 광고배속 버튼 → 5x/20x 토글 버튼 하나로 변경
- 부스트 활성화 중: 반투명, 비활성 상태
- 부스트 비활성화: 불투명, 클릭 가능
- _RetroSpeedChip에 isDisabled 파라미터 추가
2026-01-19 19:39:25 +09:00
4 changed files with 109 additions and 129 deletions

View File

@@ -289,9 +289,12 @@ class _GamePlayScreenState extends State<GamePlayScreen>
} }
// 모바일: 앱이 포그라운드로 돌아올 때 전체 재로드 // 모바일: 앱이 포그라운드로 돌아올 때 전체 재로드
// (광고 표시 중에는 reload 건너뛰기 - 배속 부스트 등 상태 유지)
if (appState == AppLifecycleState.resumed && isMobile) { if (appState == AppLifecycleState.resumed && isMobile) {
_audioController.resumeAll(); _audioController.resumeAll();
_reloadGameScreen(); if (!widget.controller.isShowingAd) {
_reloadGameScreen();
}
} }
} }
@@ -659,6 +662,7 @@ class _GamePlayScreenState extends State<GamePlayScreen>
speedBoostEndMs: widget.controller.monetization.speedBoostEndMs, speedBoostEndMs: widget.controller.monetization.speedBoostEndMs,
isPaidUser: widget.controller.monetization.isPaidUser, isPaidUser: widget.controller.monetization.isPaidUser,
onSpeedBoostActivate: _handleSpeedBoost, onSpeedBoostActivate: _handleSpeedBoost,
isSpeedBoostActive: widget.controller.isSpeedBoostActive,
adSpeedMultiplier: widget.controller.adSpeedMultiplier, adSpeedMultiplier: widget.controller.adSpeedMultiplier,
has2xUnlocked: widget.controller.has2xUnlocked, has2xUnlocked: widget.controller.has2xUnlocked,
), ),

View File

@@ -66,6 +66,9 @@ class GameSessionController extends ChangeNotifier {
int _speedBoostRemainingSeconds = 0; int _speedBoostRemainingSeconds = 0;
static const int _speedBoostDuration = 300; // 5분 static const int _speedBoostDuration = 300; // 5분
// 광고 표시 중 플래그 (lifecycle reload 방지용)
bool _isShowingAd = false;
/// 광고 배속 배율 (릴리즈: 5x, 디버그빌드+디버그모드: 20x) /// 광고 배속 배율 (릴리즈: 5x, 디버그빌드+디버그모드: 20x)
int get _speedBoostMultiplier => (kDebugMode && _cheatsEnabled) ? 20 : 5; int get _speedBoostMultiplier => (kDebugMode && _cheatsEnabled) ? 20 : 5;
@@ -153,8 +156,16 @@ class GameSessionController extends ChangeNotifier {
// 명예의 전당 체크 → 가용 배속 결정 // 명예의 전당 체크 → 가용 배속 결정
final availableSpeeds = await _getAvailableSpeeds(); final availableSpeeds = await _getAvailableSpeeds();
// 새 게임이면 1배속, 재개/부활이면 기존 배속 유지 // 명예의 전당 해금 시 기본 2배속, 아니면 1배속
final initialSpeed = isNewGame ? 1 : previousSpeed; final hasHallOfFame = availableSpeeds.contains(2);
// 새 게임이면 기본 배속, 세이브 로드 시 명예의 전당 해금 시 최소 2배속 보장
final int initialSpeed;
if (isNewGame) {
initialSpeed = hasHallOfFame ? 2 : 1;
} else {
// 세이브 로드: 명예의 전당 해금 시 최소 2배속
initialSpeed = (hasHallOfFame && previousSpeed < 2) ? 2 : previousSpeed;
}
_loop = ProgressLoop( _loop = ProgressLoop(
initialState: state, initialState: state,
@@ -593,6 +604,9 @@ class GameSessionController extends ChangeNotifier {
/// 속도 부스트 활성화 여부 /// 속도 부스트 활성화 여부
bool get isSpeedBoostActive => _isSpeedBoostActive; bool get isSpeedBoostActive => _isSpeedBoostActive;
/// 광고 표시 중 여부 (lifecycle reload 방지용)
bool get isShowingAd => _isShowingAd;
/// 속도 부스트 남은 시간 (초) /// 속도 부스트 남은 시간 (초)
int get speedBoostRemainingSeconds => _speedBoostRemainingSeconds; int get speedBoostRemainingSeconds => _speedBoostRemainingSeconds;
@@ -626,6 +640,8 @@ class GameSessionController extends ChangeNotifier {
// 무료 유저는 인터스티셜 광고 필요 // 무료 유저는 인터스티셜 광고 필요
bool activated = false; bool activated = false;
_isShowingAd = true; // 광고 표시 시작 (lifecycle reload 방지)
final adResult = await AdService.instance.showInterstitialAd( final adResult = await AdService.instance.showInterstitialAd(
adType: AdType.interstitialSpeed, adType: AdType.interstitialSpeed,
onComplete: () { onComplete: () {
@@ -634,6 +650,8 @@ class GameSessionController extends ChangeNotifier {
}, },
); );
_isShowingAd = false; // 광고 표시 종료
if (adResult == AdResult.completed || adResult == AdResult.debugSkipped) { if (adResult == AdResult.completed || adResult == AdResult.debugSkipped) {
debugPrint('[GameSession] Speed boost activated (free user with ad)'); debugPrint('[GameSession] Speed boost activated (free user with ad)');
return activated; return activated;
@@ -690,6 +708,7 @@ class GameSessionController extends ChangeNotifier {
if (_loop != null) { if (_loop != null) {
_getAvailableSpeeds().then((speeds) { _getAvailableSpeeds().then((speeds) {
_loop!.updateAvailableSpeeds(speeds); _loop!.updateAvailableSpeeds(speeds);
_loop!.setSpeed(_savedSpeedMultiplier);
}); });
} }
@@ -825,6 +844,7 @@ class GameSessionController extends ChangeNotifier {
} }
_state = updatedState; _state = updatedState;
_loop?.replaceState(updatedState); // ProgressLoop 상태도 업데이트
// 저장 // 저장
unawaited(saveManager.saveState( unawaited(saveManager.saveState(

View File

@@ -55,6 +55,7 @@ class MobileCarouselLayout extends StatefulWidget {
this.speedBoostEndMs, this.speedBoostEndMs,
this.isPaidUser = false, this.isPaidUser = false,
this.onSpeedBoostActivate, this.onSpeedBoostActivate,
this.isSpeedBoostActive = false,
this.onSetSpeed, this.onSetSpeed,
this.adSpeedMultiplier = 5, this.adSpeedMultiplier = 5,
this.has2xUnlocked = false, this.has2xUnlocked = false,
@@ -121,6 +122,9 @@ class MobileCarouselLayout extends StatefulWidget {
/// 광고 배속 활성화 콜백 (광고 시청) /// 광고 배속 활성화 콜백 (광고 시청)
final VoidCallback? onSpeedBoostActivate; final VoidCallback? onSpeedBoostActivate;
/// 배속 부스트 활성화 여부
final bool isSpeedBoostActive;
/// 광고 배속 배율 (릴리즈: 5x, 디버그빌드+디버그모드: 20x) /// 광고 배속 배율 (릴리즈: 5x, 디버그빌드+디버그모드: 20x)
final int adSpeedMultiplier; final int adSpeedMultiplier;
@@ -541,59 +545,26 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
} }
/// 레트로 스타일 속도 선택기 /// 레트로 스타일 속도 선택기
///
/// - 5x/20x 토글 버튼 하나만 표시
/// - 부스트 활성화 중: 반투명, 비활성 (누를 수 없음)
/// - 부스트 비활성화: 불투명, 활성 (누를 수 있음)
Widget _buildRetroSpeedSelector(BuildContext context) { Widget _buildRetroSpeedSelector(BuildContext context) {
final currentElapsedMs = widget.state.skillSystem.elapsedMs; final isSpeedBoostActive = widget.isSpeedBoostActive;
final speedBoostEndMs = widget.speedBoostEndMs ?? 0;
final isSpeedBoostActive =
speedBoostEndMs > currentElapsedMs || widget.isPaidUser;
final adSpeed = widget.adSpeedMultiplier; final adSpeed = widget.adSpeedMultiplier;
void setSpeed(int speed) { return _RetroSpeedChip(
if (widget.onSetSpeed != null) { speed: adSpeed,
widget.onSetSpeed!(speed); isSelected: isSpeedBoostActive,
} else { isAdBased: !isSpeedBoostActive && !widget.isPaidUser,
widget.onSpeedCycle(); // 부스트 활성화 중이면 비활성 (반투명)
} isDisabled: isSpeedBoostActive,
} onTap: () {
if (!isSpeedBoostActive) {
return Row( widget.onSpeedBoostActivate?.call();
mainAxisSize: MainAxisSize.min, }
children: [ Navigator.pop(context);
// 1x 버튼 },
_RetroSpeedChip(
speed: 1,
isSelected: widget.speedMultiplier == 1 && !isSpeedBoostActive,
onTap: () {
setSpeed(1);
Navigator.pop(context);
},
),
// 2x 버튼 (명예의 전당 해금 시)
if (widget.has2xUnlocked) ...[
const SizedBox(width: 4),
_RetroSpeedChip(
speed: 2,
isSelected: widget.speedMultiplier == 2 && !isSpeedBoostActive,
onTap: () {
setSpeed(2);
Navigator.pop(context);
},
),
],
const SizedBox(width: 4),
// 광고배속 버튼
_RetroSpeedChip(
speed: adSpeed,
isSelected: isSpeedBoostActive,
isAdBased: !isSpeedBoostActive && !widget.isPaidUser,
onTap: () {
if (!isSpeedBoostActive) {
widget.onSpeedBoostActivate?.call();
}
Navigator.pop(context);
},
),
],
); );
} }
@@ -653,6 +624,7 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
speedBoostEndMs: widget.speedBoostEndMs, speedBoostEndMs: widget.speedBoostEndMs,
isPaidUser: widget.isPaidUser, isPaidUser: widget.isPaidUser,
onSpeedBoostActivate: widget.onSpeedBoostActivate, onSpeedBoostActivate: widget.onSpeedBoostActivate,
isSpeedBoostActive: widget.isSpeedBoostActive,
adSpeedMultiplier: widget.adSpeedMultiplier, adSpeedMultiplier: widget.adSpeedMultiplier,
), ),
@@ -835,6 +807,7 @@ class _RetroSpeedChip extends StatelessWidget {
required this.isSelected, required this.isSelected,
required this.onTap, required this.onTap,
this.isAdBased = false, this.isAdBased = false,
this.isDisabled = false,
}); });
final int speed; final int speed;
@@ -842,32 +815,41 @@ class _RetroSpeedChip extends StatelessWidget {
final VoidCallback onTap; final VoidCallback onTap;
final bool isAdBased; final bool isAdBased;
/// 비활성 상태 (반투명, 탭 무시)
final bool isDisabled;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final gold = RetroColors.goldOf(context); final gold = RetroColors.goldOf(context);
final warning = RetroColors.warningOf(context); final warning = RetroColors.warningOf(context);
final border = RetroColors.borderOf(context); final border = RetroColors.borderOf(context);
// 비활성 상태면 반투명 처리
final opacity = isDisabled ? 0.4 : 1.0;
final Color bgColor; final Color bgColor;
final Color textColor; final Color textColor;
final Color borderColor; final Color borderColor;
if (isSelected) { if (isSelected) {
bgColor = isAdBased ? warning.withValues(alpha: 0.3) : gold.withValues(alpha: 0.3); bgColor = isAdBased
textColor = isAdBased ? warning : gold; ? warning.withValues(alpha: 0.3 * opacity)
borderColor = isAdBased ? warning : gold; : gold.withValues(alpha: 0.3 * opacity);
textColor = (isAdBased ? warning : gold).withValues(alpha: opacity);
borderColor = (isAdBased ? warning : gold).withValues(alpha: opacity);
} else if (isAdBased) { } else if (isAdBased) {
bgColor = Colors.transparent; bgColor = Colors.transparent;
textColor = warning; textColor = warning.withValues(alpha: opacity);
borderColor = warning; borderColor = warning.withValues(alpha: opacity);
} else { } else {
bgColor = Colors.transparent; bgColor = Colors.transparent;
textColor = RetroColors.textMutedOf(context); textColor = RetroColors.textMutedOf(context).withValues(alpha: opacity);
borderColor = border; borderColor = border.withValues(alpha: opacity);
} }
return GestureDetector( return GestureDetector(
onTap: onTap, // 비활성 상태면 탭 무시
onTap: isDisabled ? null : onTap,
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -877,7 +859,7 @@ class _RetroSpeedChip extends StatelessWidget {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (isAdBased && !isSelected) if (isAdBased && !isSelected && !isDisabled)
Padding( Padding(
padding: const EdgeInsets.only(right: 2), padding: const EdgeInsets.only(right: 2),
child: Text( child: Text(

View File

@@ -41,6 +41,7 @@ class EnhancedAnimationPanel extends StatefulWidget {
this.speedBoostEndMs, this.speedBoostEndMs,
this.isPaidUser = false, this.isPaidUser = false,
this.onSpeedBoostActivate, this.onSpeedBoostActivate,
this.isSpeedBoostActive = false,
this.adSpeedMultiplier = 5, this.adSpeedMultiplier = 5,
}); });
@@ -82,6 +83,9 @@ class EnhancedAnimationPanel extends StatefulWidget {
/// 광고 배속 활성화 콜백 (광고 시청) /// 광고 배속 활성화 콜백 (광고 시청)
final VoidCallback? onSpeedBoostActivate; final VoidCallback? onSpeedBoostActivate;
/// 배속 부스트 활성화 여부
final bool isSpeedBoostActive;
/// 광고 배속 배율 (릴리즈: 5x, 디버그빌드+디버그모드: 20x) /// 광고 배속 배율 (릴리즈: 5x, 디버그빌드+디버그모드: 20x)
final int adSpeedMultiplier; final int adSpeedMultiplier;
@@ -676,83 +680,53 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
/// 속도 컨트롤 버튼 (태스크 프로그레스 바 우측) /// 속도 컨트롤 버튼 (태스크 프로그레스 바 우측)
/// ///
/// - 일반배속: 1x (기본) ↔ 2x (명예의 전당 해금) /// - 5x/20x 토글 버튼 하나만 표시
/// - 광고배속: 릴리즈 5x, 디버그빌드+디버그모드 20x /// - 부스트 활성화 중: 반투명, 비활성 (누를 수 없음)
/// - 부스트 비활성화: 불투명, 활성 (누를 수 있음)
Widget _buildSpeedControls() { Widget _buildSpeedControls() {
final isSpeedBoostActive = _speedBoostRemainingMs > 0 || widget.isPaidUser; final isSpeedBoostActive = widget.isSpeedBoostActive;
final adSpeed = widget.adSpeedMultiplier; final adSpeed = widget.adSpeedMultiplier;
// 2x일 때 광고 버튼 표시 (버프 비활성이고 무료유저) final isPaidUser = widget.isPaidUser;
final showAdButton =
widget.speedMultiplier == 2 && !isSpeedBoostActive && !widget.isPaidUser;
return Row( // 부스트 활성화 중이면 반투명
mainAxisSize: MainAxisSize.min, final opacity = isSpeedBoostActive ? 0.4 : 1.0;
children: [
// 속도 사이클 버튼 (1x ↔ 2x, 버프 활성시 광고배속) return SizedBox(
SizedBox( width: 52,
width: 44, height: 32,
height: 32, child: OutlinedButton(
child: OutlinedButton( // 부스트 활성화 중이면 누를 수 없음
onPressed: widget.onSpeedCycle, onPressed: isSpeedBoostActive ? null : widget.onSpeedBoostActivate,
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
side: BorderSide( side: BorderSide(
color: isSpeedBoostActive color: Colors.orange.withValues(alpha: opacity),
? Colors.orange width: isSpeedBoostActive ? 2 : 1.5,
: widget.speedMultiplier > 1 ),
? Theme.of(context).colorScheme.primary disabledForegroundColor: Colors.orange.withValues(alpha: 0.4),
: Theme.of(context).colorScheme.outline, ),
width: isSpeedBoostActive ? 2 : 1, child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 무료유저 + 비활성 상태면 AD 아이콘 표시
if (!isPaidUser && !isSpeedBoostActive) ...[
const Text(
'',
style: TextStyle(fontSize: 9, color: Colors.orange),
), ),
), const SizedBox(width: 2),
child: Text( ],
isSpeedBoostActive ? '${adSpeed}x' : '${widget.speedMultiplier}x', Text(
'${adSpeed}x',
style: TextStyle( style: TextStyle(
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: isSpeedBoostActive color: Colors.orange.withValues(alpha: opacity),
? Colors.orange
: widget.speedMultiplier > 1
? Theme.of(context).colorScheme.primary
: null,
), ),
), ),
), ],
), ),
// 광고 배속 버튼 (2x일 때만 표시) ),
if (showAdButton) ...[
const SizedBox(width: 4),
SizedBox(
width: 52,
height: 32,
child: OutlinedButton(
onPressed: widget.onSpeedBoostActivate,
style: OutlinedButton.styleFrom(
padding: EdgeInsets.zero,
side: const BorderSide(color: Colors.orange, width: 1.5),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'',
style: TextStyle(fontSize: 9, color: Colors.orange),
),
const SizedBox(width: 2),
Text(
'${adSpeed}x',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: Colors.orange,
),
),
],
),
),
),
],
],
); );
} }