From b272ef8f0827c63ebc84d7f5ade55027ff701c27 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Fri, 16 Jan 2026 20:09:32 +0900 Subject: [PATCH] =?UTF-8?q?feat(rewards):=20=EB=B3=B5=EA=B7=80=20=EB=B3=B4?= =?UTF-8?q?=EC=83=81=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 시간 경과에 따른 골드 보상 계산 - 광고 시청 시 2배 보너스 - 복귀 보상 다이얼로그 UI --- .../core/engine/return_rewards_service.dart | 178 ++++++++ .../game/widgets/return_rewards_dialog.dart | 386 ++++++++++++++++++ 2 files changed, 564 insertions(+) create mode 100644 lib/src/core/engine/return_rewards_service.dart create mode 100644 lib/src/features/game/widgets/return_rewards_dialog.dart diff --git a/lib/src/core/engine/return_rewards_service.dart b/lib/src/core/engine/return_rewards_service.dart new file mode 100644 index 0000000..51c6014 --- /dev/null +++ b/lib/src/core/engine/return_rewards_service.dart @@ -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 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'; + } +} diff --git a/lib/src/features/game/widgets/return_rewards_dialog.dart b/lib/src/features/game/widgets/return_rewards_dialog.dart new file mode 100644 index 0000000..f5448e0 --- /dev/null +++ b/lib/src/features/game/widgets/return_rewards_dialog.dart @@ -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 show( + BuildContext context, { + required ReturnReward reward, + required void Function(int totalGold) onClaim, + }) async { + return showDialog( + context: context, + barrierDismissible: false, + builder: (context) => ReturnRewardsDialog( + reward: reward, + onClaim: onClaim, + ), + ); + } + + @override + State createState() => _ReturnRewardsDialogState(); +} + +class _ReturnRewardsDialogState extends State { + 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 _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(); + } +}