feat(ui): 게임 화면 및 UI 컴포넌트 개선

- front_screen: 프론트 화면 UI 업데이트
- game_play_screen: 게임 플레이 화면 수정
- game_session_controller: 세션 관리 로직 개선
- mobile_carousel_layout: 모바일 캐러셀 레이아웃 개선
- enhanced_animation_panel: 애니메이션 패널 업데이트
- help_dialog: 도움말 다이얼로그 수정
- return_rewards_dialog: 복귀 보상 다이얼로그 개선
- new_character_screen: 새 캐릭터 화면 수정
- settings_screen: 설정 화면 업데이트
This commit is contained in:
JiWoong Sul
2026-01-19 15:50:35 +09:00
parent ffc19c7ca6
commit 19faa9ea39
9 changed files with 2495 additions and 1355 deletions

View File

@@ -2,6 +2,7 @@ import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
import 'package:asciineverdie/l10n/app_localizations.dart';
@@ -18,9 +19,13 @@ class FrontScreen extends StatefulWidget {
this.onHallOfFame,
this.onLocalArena,
this.onSettings,
this.onPurchaseRemoveAds,
this.onRestorePurchase,
this.hasSaveFile = false,
this.savedGamePreview,
this.hallOfFameCount = 0,
this.isAdRemovalPurchased = false,
this.removeAdsPrice,
this.routeObserver,
this.onRefresh,
});
@@ -40,6 +45,12 @@ class FrontScreen extends StatefulWidget {
/// "Settings" 버튼 클릭 시 호출 (언어, 테마, 사운드)
final void Function(BuildContext context)? onSettings;
/// "광고 제거" 구매 버튼 클릭 시 호출
final Future<void> Function(BuildContext context)? onPurchaseRemoveAds;
/// "구매 복원" 버튼 클릭 시 호출
final Future<void> Function(BuildContext context)? onRestorePurchase;
/// 세이브 파일 존재 여부 (새 캐릭터 시 경고용)
final bool hasSaveFile;
@@ -49,6 +60,12 @@ class FrontScreen extends StatefulWidget {
/// 명예의 전당 캐릭터 수 (아레나 활성화 조건: 2명 이상)
final int hallOfFameCount;
/// 광고 제거 구매 여부
final bool isAdRemovalPurchased;
/// 광고 제거 상품 가격 (null이면 스토어 비활성)
final String? removeAdsPrice;
/// RouteObserver (화면 복귀 시 갱신용)
final RouteObserver<ModalRoute<void>>? routeObserver;
@@ -132,8 +149,6 @@ class _FrontScreenState extends State<FrontScreen> with RouteAware {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const _RetroHeader(),
const SizedBox(height: 16),
const _AnimationPanel(),
const SizedBox(height: 16),
_ActionButtons(
@@ -154,8 +169,17 @@ class _FrontScreenState extends State<FrontScreen> with RouteAware {
onSettings: widget.onSettings != null
? () => widget.onSettings!(context)
: null,
onPurchaseRemoveAds:
widget.onPurchaseRemoveAds != null
? () => widget.onPurchaseRemoveAds!(context)
: null,
onRestorePurchase: widget.onRestorePurchase != null
? () => widget.onRestorePurchase!(context)
: null,
savedGamePreview: widget.savedGamePreview,
hallOfFameCount: widget.hallOfFameCount,
isAdRemovalPurchased: widget.isAdRemovalPurchased,
removeAdsPrice: widget.removeAdsPrice,
),
],
),
@@ -172,58 +196,7 @@ class _FrontScreenState extends State<FrontScreen> with RouteAware {
}
}
/// 레트로 스타일 헤더 (타이틀 + 태그)
class _RetroHeader extends StatelessWidget {
const _RetroHeader();
@override
Widget build(BuildContext context) {
final l10n = L10n.of(context);
return RetroGoldPanel(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
child: Column(
children: [
// 타이틀 (픽셀 폰트)
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.auto_awesome, color: RetroColors.gold, size: 20),
const SizedBox(width: 12),
Text(
l10n.appTitle,
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 13,
color: RetroColors.gold,
shadows: [
Shadow(color: RetroColors.goldDark, offset: Offset(2, 2)),
],
),
),
],
),
const SizedBox(height: 16),
// 태그 (레트로 스타일)
Wrap(
alignment: WrapAlignment.center,
spacing: 8,
runSpacing: 8,
children: [
_RetroTag(
icon: Icons.cloud_off_outlined,
label: l10n.tagNoNetwork,
),
_RetroTag(icon: Icons.timer_outlined, label: l10n.tagIdleRpg),
_RetroTag(icon: Icons.storage_rounded, label: l10n.tagLocalSaves),
],
),
],
),
);
}
}
/// 애니메이션 패널
/// 애니메이션 패널 (금색 테두리 + 아이콘+타이틀)
class _AnimationPanel extends StatelessWidget {
const _AnimationPanel();
@@ -238,8 +211,25 @@ class _AnimationPanel extends StatelessWidget {
@override
Widget build(BuildContext context) {
return RetroPanel(
title: 'BATTLE',
return RetroGoldPanel(
titleWidget: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.auto_awesome, color: RetroColors.gold, size: 18),
const SizedBox(width: 10),
const Text(
'ASCII NEVER DIE',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: RetroColors.gold,
shadows: [
Shadow(color: RetroColors.goldDark, offset: Offset(2, 2)),
],
),
),
],
),
padding: const EdgeInsets.all(8),
child: AspectRatio(
aspectRatio: _getAspectRatio(),
@@ -257,8 +247,12 @@ class _ActionButtons extends StatelessWidget {
this.onHallOfFame,
this.onLocalArena,
this.onSettings,
this.onPurchaseRemoveAds,
this.onRestorePurchase,
this.savedGamePreview,
this.hallOfFameCount = 0,
this.isAdRemovalPurchased = false,
this.removeAdsPrice,
});
final VoidCallback? onNewCharacter;
@@ -266,8 +260,12 @@ class _ActionButtons extends StatelessWidget {
final VoidCallback? onHallOfFame;
final VoidCallback? onLocalArena;
final VoidCallback? onSettings;
final VoidCallback? onPurchaseRemoveAds;
final VoidCallback? onRestorePurchase;
final SavedGamePreview? savedGamePreview;
final int hallOfFameCount;
final bool isAdRemovalPurchased;
final String? removeAdsPrice;
@override
Widget build(BuildContext context) {
@@ -323,6 +321,24 @@ class _ActionButtons extends StatelessWidget {
onPressed: onSettings,
isPrimary: false,
),
// IAP 구매 (광고 제거) - 스토어 사용 가능하고 미구매 상태일 때만 표시
if (removeAdsPrice != null && !isAdRemovalPurchased) ...[
const SizedBox(height: 20),
const Divider(color: RetroColors.panelBorderInner, height: 1),
const SizedBox(height: 12),
_IapPurchaseButton(
price: removeAdsPrice!,
onPurchase: onPurchaseRemoveAds,
onRestore: onRestorePurchase,
),
],
// 이미 구매된 경우 표시
if (isAdRemovalPurchased) ...[
const SizedBox(height: 20),
const Divider(color: RetroColors.panelBorderInner, height: 1),
const SizedBox(height: 12),
_PurchasedBadge(),
],
],
),
);
@@ -370,50 +386,322 @@ class _CopyrightFooter extends StatelessWidget {
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Text(
game_l10n.copyrightText,
child: FutureBuilder<PackageInfo>(
future: PackageInfo.fromPlatform(),
builder: (context, snapshot) {
final version = snapshot.data?.version ?? '';
final versionSuffix = version.isNotEmpty ? ' v$version' : '';
return Text(
'${game_l10n.copyrightText}$versionSuffix',
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 7,
color: RetroColors.textDisabled,
),
);
},
),
);
}
}
/// 레트로 태그 칩
class _RetroTag extends StatelessWidget {
const _RetroTag({required this.icon, required this.label});
/// IAP 구매 버튼 (광고 제거)
class _IapPurchaseButton extends StatelessWidget {
const _IapPurchaseButton({
required this.price,
this.onPurchase,
this.onRestore,
});
final IconData icon;
final String label;
final String price;
final VoidCallback? onPurchase;
final VoidCallback? onRestore;
void _showPurchaseDialog(BuildContext context) {
showDialog<void>(
context: context,
builder: (dialogContext) => _IapPurchaseDialog(
price: price,
onPurchase: () {
Navigator.pop(dialogContext);
onPurchase?.call();
},
),
);
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 구매 버튼 (클릭 시 팝업)
Container(
decoration: BoxDecoration(
color: RetroColors.panelBgLight,
border: Border.all(color: RetroColors.panelBorderInner, width: 1),
gradient: const LinearGradient(
colors: [Color(0xFF4A3B2A), Color(0xFF3D2E1F)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
border: Border.all(color: RetroColors.gold, width: 2),
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () => _showPurchaseDialog(context),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: RetroColors.gold, size: 12),
const SizedBox(width: 6),
const Icon(Icons.block, color: RetroColors.gold, size: 24),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
game_l10n.iapRemoveAds,
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 12,
color: RetroColors.gold,
),
),
const SizedBox(height: 4),
Text(
game_l10n.iapRemoveAdsDesc,
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 9,
color: RetroColors.textDisabled,
),
),
],
),
),
// 화살표 아이콘 (상세 보기)
const Icon(
Icons.arrow_forward_ios,
color: RetroColors.gold,
size: 16,
),
],
),
),
),
),
),
const SizedBox(height: 8),
// 복원 버튼
Center(
child: TextButton(
onPressed: onRestore,
child: Text(
game_l10n.iapRestorePurchase,
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 9,
color: RetroColors.textDisabled,
decoration: TextDecoration.underline,
),
),
),
),
],
);
}
}
/// IAP 구매 팝업 다이얼로그
class _IapPurchaseDialog extends StatelessWidget {
const _IapPurchaseDialog({required this.price, this.onPurchase});
final String price;
final VoidCallback? onPurchase;
@override
Widget build(BuildContext context) {
return Dialog(
backgroundColor: RetroColors.deepBrown,
shape: RoundedRectangleBorder(
side: const BorderSide(color: RetroColors.gold, width: 2),
borderRadius: BorderRadius.circular(4),
),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 360),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 타이틀
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.star, color: RetroColors.gold, size: 20),
const SizedBox(width: 8),
Text(
game_l10n.iapBenefitTitle,
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 12,
color: RetroColors.gold,
),
),
const SizedBox(width: 8),
const Icon(Icons.star, color: RetroColors.gold, size: 20),
],
),
const SizedBox(height: 20),
// 혜택 목록
_BenefitItem(icon: Icons.block, text: game_l10n.iapBenefit1),
const SizedBox(height: 8),
_BenefitItem(icon: Icons.flash_on, text: game_l10n.iapBenefit2),
const SizedBox(height: 8),
_BenefitItem(icon: Icons.undo, text: game_l10n.iapBenefit3),
const SizedBox(height: 8),
_BenefitItem(icon: Icons.casino, text: game_l10n.iapBenefit4),
const SizedBox(height: 8),
_BenefitItem(icon: Icons.speed, text: game_l10n.iapBenefit5),
const SizedBox(height: 8),
_BenefitItem(icon: Icons.inventory_2, text: game_l10n.iapBenefit6),
const SizedBox(height: 20),
// 가격 + 구매 버튼
Container(
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF5A4B3A), Color(0xFF4A3B2A)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
border: Border.all(color: RetroColors.gold, width: 2),
borderRadius: BorderRadius.circular(4),
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onPurchase,
borderRadius: BorderRadius.circular(2),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
game_l10n.iapPurchaseButton,
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 12,
color: RetroColors.gold,
),
),
const SizedBox(width: 12),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: RetroColors.gold,
borderRadius: BorderRadius.circular(4),
),
child: Text(
price,
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 11,
color: RetroColors.deepBrown,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
),
),
),
const SizedBox(height: 12),
// 취소 버튼
Center(
child: TextButton(
onPressed: () => Navigator.pop(context),
child: Text(
game_l10n.buttonCancel,
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 10,
color: RetroColors.textDisabled,
),
),
),
),
],
),
),
),
);
}
}
/// 혜택 항목 위젯
class _BenefitItem extends StatelessWidget {
const _BenefitItem({required this.icon, required this.text});
final IconData icon;
final String text;
@override
Widget build(BuildContext context) {
return Row(
children: [
Icon(icon, color: RetroColors.expGreen, size: 18),
const SizedBox(width: 12),
Expanded(
child: Text(
text,
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 10,
color: RetroColors.textLight,
),
),
),
],
);
}
}
/// 이미 구매됨 뱃지
class _PurchasedBadge extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: RetroColors.panelBgLight,
border: Border.all(color: RetroColors.expGreen, width: 2),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.check_circle, color: RetroColors.expGreen, size: 20),
const SizedBox(width: 8),
Text(
game_l10n.iapAlreadyPurchased,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 11,
color: RetroColors.expGreen,
),
),
],
),
);
}
}

View File

@@ -7,7 +7,7 @@ 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/src/core/model/treasure_chest.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';
@@ -51,8 +51,6 @@ class GamePlayScreen extends StatefulWidget {
this.audioService,
this.forceCarouselLayout = false,
this.forceDesktopLayout = false,
this.onThemeModeChange,
this.currentThemeMode = ThemeMode.system,
});
final GameSessionController controller;
@@ -66,12 +64,6 @@ class GamePlayScreen extends StatefulWidget {
/// 테스트 모드: 모바일에서도 데스크톱 3패널 레이아웃 강제 사용
final bool forceDesktopLayout;
/// 테마 모드 변경 콜백
final void Function(ThemeMode mode)? onThemeModeChange;
/// 현재 테마 모드
final ThemeMode currentThemeMode;
@override
State<GamePlayScreen> createState() => _GamePlayScreenState();
}
@@ -316,8 +308,6 @@ class _GamePlayScreenState extends State<GamePlayScreen>
builder: (_) => GamePlayScreen(
controller: widget.controller,
audioService: widget.audioService,
currentThemeMode: widget.currentThemeMode,
onThemeModeChange: widget.onThemeModeChange,
),
),
);
@@ -407,15 +397,19 @@ class _GamePlayScreenState extends State<GamePlayScreen>
}
/// 복귀 보상 다이얼로그 표시 (Phase 7)
void _showReturnRewardsDialog(ReturnReward reward) {
void _showReturnRewardsDialog(ReturnChestReward reward) {
// 잠시 후 다이얼로그 표시 (게임 시작 후)
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final state = widget.controller.state;
if (state == null) return;
ReturnRewardsDialog.show(
context,
reward: reward,
onClaim: (totalGold) {
widget.controller.applyReturnReward(totalGold);
playerLevel: state.traits.level,
onClaim: (rewards) {
widget.controller.applyReturnReward(rewards);
},
);
});
@@ -436,10 +430,6 @@ class _GamePlayScreenState extends State<GamePlayScreen>
SettingsScreen.show(
context,
settingsRepository: settingsRepo,
currentThemeMode: widget.currentThemeMode,
onThemeModeChange: (mode) {
widget.onThemeModeChange?.call(mode);
},
onLocaleChange: (locale) async {
// 안전한 언어 변경: 전체 화면 재생성
final navigator = Navigator.of(this.context);
@@ -452,8 +442,6 @@ class _GamePlayScreenState extends State<GamePlayScreen>
builder: (_) => GamePlayScreen(
controller: widget.controller,
audioService: widget.audioService,
currentThemeMode: widget.currentThemeMode,
onThemeModeChange: widget.onThemeModeChange,
),
),
);
@@ -586,6 +574,10 @@ class _GamePlayScreenState extends State<GamePlayScreen>
widget.controller.loop?.cycleSpeed();
setState(() {});
},
onSetSpeed: (speed) {
widget.controller.loop?.setSpeed(speed);
setState(() {});
},
// 특수 애니메이션 중에는 일시정지 상태로 표시하지 않음
isPaused:
!widget.controller.isRunning && _specialAnimation == null,
@@ -620,8 +612,6 @@ class _GamePlayScreenState extends State<GamePlayScreen>
builder: (_) => GamePlayScreen(
controller: widget.controller,
audioService: widget.audioService,
currentThemeMode: widget.currentThemeMode,
onThemeModeChange: widget.onThemeModeChange,
),
),
);
@@ -637,8 +627,6 @@ class _GamePlayScreenState extends State<GamePlayScreen>
Navigator.of(context).pop();
}
},
currentThemeMode: widget.currentThemeMode,
onThemeModeChange: widget.onThemeModeChange,
// 사운드 설정
bgmVolume: _audioController.bgmVolume,
sfxVolume: _audioController.sfxVolume,
@@ -666,11 +654,13 @@ 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,
adSpeedMultiplier: widget.controller.adSpeedMultiplier,
has2xUnlocked: widget.controller.has2xUnlocked,
),
// 사망 오버레이
if (state.isDead && state.deathInfo != null)

View File

@@ -14,6 +14,7 @@ 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/model/treasure_chest.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';
@@ -64,14 +65,16 @@ class GameSessionController extends ChangeNotifier {
Timer? _speedBoostTimer;
int _speedBoostRemainingSeconds = 0;
static const int _speedBoostDuration = 300; // 5분
static const int _speedBoostMultiplier = 5; // 5x 속도
/// 광고 배속 배율 (릴리즈: 5x, 디버그빌드+디버그모드: 20x)
int get _speedBoostMultiplier => (kDebugMode && _cheatsEnabled) ? 20 : 5;
// 복귀 보상 상태 (Phase 7)
MonetizationState _monetization = MonetizationState.initial();
ReturnReward? _pendingReturnReward;
ReturnChestReward? _pendingReturnReward;
/// 복귀 보상 콜백 (UI에서 다이얼로그 표시용)
void Function(ReturnReward reward)? onReturnRewardAvailable;
void Function(ReturnChestReward reward)? onReturnRewardAvailable;
// 통계 관련 필드
SessionStatistics _sessionStats = SessionStatistics.empty();
@@ -105,6 +108,12 @@ class GameSessionController extends ChangeNotifier {
/// 현재 ProgressLoop 인스턴스 (치트 기능용)
ProgressLoop? get loop => _loop;
/// 광고 배속 배율 (릴리즈: 5x, 디버그빌드+디버그모드: 20x)
int get adSpeedMultiplier => _speedBoostMultiplier;
/// 2x 배속 해금 여부 (명예의 전당에 캐릭터가 있으면 true)
bool get has2xUnlocked => _loop?.availableSpeeds.contains(2) ?? false;
Future<void> startNew(
GameState initialState, {
bool cheatsEnabled = false,
@@ -172,13 +181,16 @@ class GameSessionController extends ChangeNotifier {
}
/// 가용 배속 목록 반환
/// - 디버그 모드(치트 활성화): [1, 2, 20] (터보 모드 포함)
/// - 일반 모드: [1, 2] (5x는 광고 버프로만 활성화)
///
/// - 기본: [1] (1x만)
/// - 명예의 전당에 캐릭터 있으면: [1, 2] (2x 해금)
/// - 광고 배속(5x/20x)은 별도 버프로만 활성화
Future<List<int>> _getAvailableSpeeds() async {
if (_cheatsEnabled) {
return [1, 2, 20];
final hallOfFame = await _hallOfFameStorage.load();
if (hallOfFame.entries.isNotEmpty) {
return [1, 2]; // 명예의 전당 캐릭터 있으면 2x 해금
}
return [1, 2];
return [1]; // 기본: 1x만
}
/// 이전 값 초기화 (통계 변화 추적용)
@@ -700,7 +712,7 @@ class GameSessionController extends ChangeNotifier {
MonetizationState get monetization => _monetization;
/// 대기 중인 복귀 보상
ReturnReward? get pendingReturnReward => _pendingReturnReward;
ReturnChestReward? get pendingReturnReward => _pendingReturnReward;
/// 복귀 보상 체크 (로드 시 호출)
void _checkReturnRewards(GameState loaded) {
@@ -715,17 +727,17 @@ class GameSessionController extends ChangeNotifier {
final reward = rewardsService.calculateReward(
lastPlayTime: lastPlayTime,
currentTime: DateTime.now(),
playerLevel: loaded.traits.level,
isPaidUser: _monetization.isPaidUser,
);
if (reward.hasReward) {
_pendingReturnReward = reward;
debugPrint('[ReturnRewards] Reward available: ${reward.goldReward} gold, '
debugPrint('[ReturnRewards] Reward available: ${reward.chestCount} chests, '
'${reward.hoursAway} hours away');
// UI에서 다이얼로그 표시를 위해 콜백 호출
// startNew 후에 호출하도록 딜레이
Future.delayed(const Duration(milliseconds: 500), () {
Future<void>.delayed(const Duration(milliseconds: 500), () {
if (_pendingReturnReward != null) {
onReturnRewardAvailable?.call(_pendingReturnReward!);
}
@@ -733,23 +745,86 @@ class GameSessionController extends ChangeNotifier {
}
}
/// 복귀 보상 수령 완료 (골드 적용)
/// 복귀 보상 수령 완료 (상자 보상 적용)
///
/// [totalGold] 수령한 총 골드 (기본 + 보너스)
void applyReturnReward(int totalGold) {
/// [rewards] 오픈된 상자 보상 목록
void applyReturnReward(List<ChestReward> rewards) {
if (_state == null) return;
if (totalGold <= 0) {
if (rewards.isEmpty) {
// 보상 없이 건너뛴 경우
_pendingReturnReward = null;
debugPrint('[ReturnRewards] Reward skipped');
return;
}
// 골드 추가
final updatedInventory = _state!.inventory.copyWith(
gold: _state!.inventory.gold + totalGold,
var updatedState = _state!;
// 보상 적용
for (final reward in rewards) {
switch (reward.type) {
case ChestRewardType.equipment:
if (reward.equipment != null) {
// 현재 장비와 비교하여 더 좋으면 자동 장착
final slotIndex = reward.equipment!.slot.index;
final currentItem = updatedState.equipment.getItemByIndex(slotIndex);
if (currentItem.isEmpty ||
reward.equipment!.itemWeight > currentItem.itemWeight) {
updatedState = updatedState.copyWith(
equipment: updatedState.equipment.setItemByIndex(
slotIndex,
reward.equipment!,
),
);
_state = _state!.copyWith(inventory: updatedInventory);
debugPrint('[ReturnRewards] Equipped: ${reward.equipment!.name}');
} else {
// 더 좋지 않으면 판매 (골드로 변환)
final sellPrice =
(reward.equipment!.level * 50 * 0.3).round().clamp(1, 99999);
updatedState = updatedState.copyWith(
inventory: updatedState.inventory.copyWith(
gold: updatedState.inventory.gold + sellPrice,
),
);
debugPrint('[ReturnRewards] Sold: ${reward.equipment!.name} '
'for $sellPrice gold');
}
}
case ChestRewardType.potion:
if (reward.potionId != null) {
updatedState = updatedState.copyWith(
potionInventory: updatedState.potionInventory.addPotion(
reward.potionId!,
reward.potionCount ?? 1,
),
);
debugPrint('[ReturnRewards] Added potion: ${reward.potionId} '
'x${reward.potionCount}');
}
case ChestRewardType.gold:
if (reward.gold != null && reward.gold! > 0) {
updatedState = updatedState.copyWith(
inventory: updatedState.inventory.copyWith(
gold: updatedState.inventory.gold + reward.gold!,
),
);
debugPrint('[ReturnRewards] Added gold: ${reward.gold}');
}
case ChestRewardType.experience:
if (reward.experience != null && reward.experience! > 0) {
updatedState = updatedState.copyWith(
progress: updatedState.progress.copyWith(
exp: updatedState.progress.exp.copyWith(
position:
updatedState.progress.exp.position + reward.experience!,
),
),
);
debugPrint('[ReturnRewards] Added experience: ${reward.experience}');
}
}
}
_state = updatedState;
// 저장
unawaited(saveManager.saveState(
@@ -761,7 +836,7 @@ class GameSessionController extends ChangeNotifier {
_pendingReturnReward = null;
notifyListeners();
debugPrint('[ReturnRewards] Reward applied: $totalGold gold');
debugPrint('[ReturnRewards] Rewards applied: ${rewards.length} items');
}
/// 복귀 보상 건너뛰기

File diff suppressed because it is too large Load Diff

View File

@@ -41,6 +41,7 @@ class EnhancedAnimationPanel extends StatefulWidget {
this.speedBoostEndMs,
this.isPaidUser = false,
this.onSpeedBoostActivate,
this.adSpeedMultiplier = 5,
});
final ProgressState progress;
@@ -75,12 +76,15 @@ class EnhancedAnimationPanel extends StatefulWidget {
/// 5배속 버프 종료 시점 (elapsedMs 기준, null이면 비활성)
final int? speedBoostEndMs;
/// 유료 유저 여부 (5배속 항상 활성)
/// 유료 유저 여부 (광고배속 항상 활성)
final bool isPaidUser;
/// 5배속 버프 활성화 콜백 (광고 시청)
/// 광고 배속 활성화 콜백 (광고 시청)
final VoidCallback? onSpeedBoostActivate;
/// 광고 배속 배율 (릴리즈: 5x, 디버그빌드+디버그모드: 20x)
final int adSpeedMultiplier;
@override
State<EnhancedAnimationPanel> createState() => _EnhancedAnimationPanelState();
}
@@ -284,14 +288,14 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
color: Colors.green,
),
),
// 우상단: 5배속 버프
// 우상단: 광고배속 버프 (버프 활성 시에만)
if (_speedBoostRemainingMs > 0 || widget.isPaidUser)
Positioned(
right: 4,
top: 4,
child: _buildBuffChip(
icon: '',
label: '5x',
label: '${widget.adSpeedMultiplier}x',
remainingMs: widget.isPaidUser ? -1 : _speedBoostRemainingMs,
color: Colors.orange,
isPermanent: widget.isPaidUser,
@@ -303,7 +307,7 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
const SizedBox(height: 8),
// 상태 바 영역: HP/MP (40%) + 컨트롤 (20%) + 몬스터 HP (40%)
// 상태 바 영역: HP/MP (40%) + 빈공간 (20%) + 몬스터 HP (40%)
SizedBox(
height: 48,
child: Row(
@@ -322,11 +326,8 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
),
),
// 중앙: 컨트롤 버튼 (20%)
Expanded(
flex: 1,
child: _buildControlButtons(),
),
// 중앙: 빈 공간 (20%)
const Spacer(flex: 1),
// 우측: 몬스터 HP (전투 중) 또는 빈 공간 (40%)
Expanded(
@@ -673,44 +674,41 @@ 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() {
/// 속도 컨트롤 버튼 (태스크 프로그레스 바 우측)
///
/// - 일반배속: 1x (기본) ↔ 2x (명예의 전당 해금)
/// - 광고배속: 릴리즈 5x, 디버그빌드+디버그모드 20x
Widget _buildSpeedControls() {
final isSpeedBoostActive = _speedBoostRemainingMs > 0 || widget.isPaidUser;
final adSpeed = widget.adSpeedMultiplier;
// 2x일 때 광고 버튼 표시 (버프 비활성이고 무료유저)
final showAdButton =
widget.speedMultiplier == 2 && !isSpeedBoostActive && !widget.isPaidUser;
return SizedBox(
width: 32,
height: 22,
return Row(
mainAxisSize: MainAxisSize.min,
children: [
// 속도 사이클 버튼 (1x ↔ 2x, 버프 활성시 광고배속)
SizedBox(
width: 44,
height: 32,
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),
: Theme.of(context).colorScheme.outline,
width: isSpeedBoostActive ? 2 : 1,
),
),
child: Text(
isSpeedBoostActive ? '5x' : '${widget.speedMultiplier}x',
isSpeedBoostActive ? '${adSpeed}x' : '${widget.speedMultiplier}x',
style: TextStyle(
fontSize: 10,
fontSize: 13,
fontWeight: FontWeight.bold,
color: isSpeedBoostActive
? Colors.orange
@@ -720,38 +718,31 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
),
),
),
);
}
/// 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,
),
// 광고 배속 버튼 (2x일 때만 표시)
if (showAdButton) ...[
const SizedBox(width: 4),
SizedBox(
width: 52,
height: 32,
child: OutlinedButton(
onPressed: widget.onSpeedBoostActivate,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8),
visualDensity: VisualDensity.compact,
side: const BorderSide(color: Colors.orange),
padding: EdgeInsets.zero,
side: const BorderSide(color: Colors.orange, width: 1.5),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('', style: TextStyle(fontSize: 8, color: Colors.orange)),
SizedBox(width: 2),
const Text(
'',
style: TextStyle(fontSize: 9, color: Colors.orange),
),
const SizedBox(width: 2),
Text(
'5x',
style: TextStyle(
fontSize: 10,
'${adSpeed}x',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: Colors.orange,
),
@@ -759,6 +750,9 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
],
),
),
),
],
],
);
}
@@ -775,7 +769,7 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
return widget.progress.currentTask.caption;
}
/// 태스크 프로그레스 바
/// 태스크 프로그레스 바 + 속도 컨트롤
Widget _buildTaskProgress() {
final task = widget.progress.task;
final progressValue = task.max > 0
@@ -792,7 +786,13 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
? grade.displayColor
: null;
return Column(
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// 좌측: 캡션 + 프로그레스 바
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 캡션 (등급에 따른 접두사 및 색상)
Text.rich(
@@ -808,7 +808,8 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
),
TextSpan(
text: _getStatusMessage(),
style: gradeColor != null ? TextStyle(color: gradeColor) : null,
style:
gradeColor != null ? TextStyle(color: gradeColor) : null,
),
],
),
@@ -819,9 +820,7 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
),
const SizedBox(height: 4),
// 프로그레스 바
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: LinearProgressIndicator(
LinearProgressIndicator(
value: progressValue,
backgroundColor: Theme.of(
context,
@@ -831,7 +830,12 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
),
minHeight: 10,
),
],
),
),
const SizedBox(width: 8),
// 우측: 속도 컨트롤
_buildSpeedControls(),
],
);
}

View File

@@ -108,13 +108,13 @@ class _BasicsHelpView extends StatelessWidget {
? 'ゲーム紹介'
: 'About the Game',
content: isKorean
? 'Askii Never Die는 자동 진행 RPG입니다. 캐릭터가 자동으로 몬스터와 싸우고, '
'퀘스트를 완료하며, 레벨업합니다. 여러분은 장비와 스킬을 관리하면 됩니다.'
? 'Askii Never Die는 완전 자동 진행 RPG입니다. 캐릭터가 자동으로 몬스터와 싸우고, '
'퀘스트를 완료하며, 레벨업합니다. 장비와 스킬도 자동으로 획득/장착됩니다.'
: isJapanese
? 'Askii Never Dieは自動進行RPGです。キャラクターが自動でモンスターと戦い、'
'クエストを完了し、レベルアップします。装備とスキルの管理だけで大丈夫です。'
: 'Askii Never Die is an idle RPG. Your character automatically fights monsters, '
'completes quests, and levels up. You manage equipment and skills.',
? 'Askii Never Dieは完全自動進行RPGです。キャラクターが自動でモンスターと戦い、'
'クエストを完了し、レベルアップします。装備とスキルも自動で獲得・装着されます。'
: 'Askii Never Die is a fully automatic idle RPG. Your character automatically fights monsters, '
'completes quests, and levels up. Equipment and skills are auto-acquired and equipped.',
),
const SizedBox(height: 12),
_HelpSection(
@@ -214,20 +214,26 @@ class _CombatHelpView extends StatelessWidget {
),
const SizedBox(height: 12),
_HelpSection(
icon: '',
icon: '',
title: isKorean
? '사망과 부활'
? '부활 시스템'
: isJapanese
? '死亡と復活'
: 'Death & Revival',
? '復活システム'
: 'Revival System',
content: isKorean
? 'HP가 0이 되면 사망합니다. 사망 시 장비 하나를 제물로 바쳐 부활할 수 있습니다. '
'부활 후 HP/MP가 완전 회복되고 빈 장비 슬롯에 기본 장비가 지급됩니다.'
? '사망 시 두 가지 부활 방법이 있습니다:\n'
'• 기본 부활: 장비 1개 제물, HP/MP 회복\n'
'• 광고 부활: 아이템 보존, HP 100%, 10분 자동부활\n'
'유료 유저는 항상 광고 없이 부활 가능합니다.'
: isJapanese
? 'HPが0になると死亡します。死亡時に装備1つを捧げて復活できます。'
'復活後HP/MPが完全回復し、空の装備スロットに基本装備が支給されます。'
: 'You die when HP reaches 0. Sacrifice one equipment piece to revive. '
'After revival, HP/MP fully restore and empty slots get basic equipment.',
? '死亡時に2つの復活方法があります\n'
'• 基本復活: 装備1つ消費、HP/MP回復\n'
'• 広告復活: アイテム保存、HP100%、10分自動復活\n'
'課金ユーザーは常に広告なしで復活可能です。'
: 'Two revival methods on death:\n'
'• Basic: Sacrifice 1 equipment, restore HP/MP\n'
'• Ad Revival: Keep items, 100% HP, 10-min auto-revive\n'
'Paid users can always revive without ads.',
),
],
);
@@ -306,21 +312,21 @@ class _SkillsHelpView extends StatelessWidget {
? 'スキルランク'
: 'Skill Ranks',
content: isKorean
? '스킬은 I ~ IX 랭크가 있습니다. 랭크가 높을수록:\n'
? '스킬 랭크는 I, II, III... 형태로 표시됩니다. 랭크가 높을수록:\n'
'• 데미지/회복량 증가\n'
'• MP 소모량 증가\n'
'• 쿨타임 증가\n'
'• MP 소모량 감소\n'
'• 쿨타임 감소\n'
'레벨업 시 랜덤하게 스킬을 배웁니다.'
: isJapanese
? 'スキルにはI~IXランクがあります。ランクが高いほど:\n'
? 'スキルランクはI、II、III...の形式で表示されます。ランクが高いほど:\n'
'• ダメージ/回復量増加\n'
'• MP消費量増加\n'
'• クールタイム増加\n'
'• MP消費量減少\n'
'• クールタイム減少\n'
'レベルアップ時にランダムでスキルを習得します。'
: 'Skills have ranks I~IX. Higher rank means:\n'
: 'Skill ranks are displayed as I, II, III... Higher rank means:\n'
'• More damage/healing\n'
'More MP cost\n'
'Longer cooldown\n'
'Less MP cost\n'
'Shorter cooldown\n'
'Learn random skills on level up.',
),
],
@@ -348,19 +354,31 @@ class _UIHelpView extends StatelessWidget {
? '画面構成'
: 'Screen Layout',
content: isKorean
? '• 상단: 전투 애니메이션, 태스크 진행바\n'
'좌측: 캐릭터 정보, HP/MP, 스탯\n'
'중앙: 장비, 인벤토리\n'
'우측: 플롯/퀘스트 진행, 스펠북'
? '모바일에서는 좌우 스와이프로 7개 페이지 탐색:\n'
'캐릭터: 이름, 레벨, 종족, 직업\n'
'스탯: STR, DEX, CON, INT 등\n'
'장비: 무기, 방어구, 액세서리\n'
'• 인벤토리: 보유 아이템, 골드\n'
'• 스킬북: 습득한 스킬 목록\n'
'• 퀘스트: 진행 중인 퀘스트\n'
'• 플롯: 스토리 진행 상황'
: isJapanese
? '• 上部: 戦闘アニメーション、タスク進行バー\n'
' 左側: キャラクター情報、HP/MP、ステータス\n'
'中央: 装備、インベントリ\n'
'右側: プロット/クエスト進行、スペルブック'
: 'Top: Combat animation, task progress bar\n'
'Left: Character info, HP/MP, stats\n'
'Center: Equipment, inventory\n'
'Right: Plot/quest progress, spellbook',
? 'モバイルでは左右スワイプで7ページ切替\n'
'• キャラクター: 名前、レベル、種族、職業\n'
'ステータス: STR、DEX、CON、INT等\n'
'装備: 武器、防具、アクセサリー\n'
'インベントリ: 所持アイテム、ゴールド\n'
'スキルブック: 習得したスキル一覧\n'
'クエスト: 進行中のクエスト\n'
'プロット: ストーリー進行状況'
: 'On mobile, swipe left/right to browse 7 pages:\n'
'• Character: Name, level, race, class\n'
'• Stats: STR, DEX, CON, INT, etc.\n'
'• Equipment: Weapons, armor, accessories\n'
'• Inventory: Items, gold\n'
'• Skillbook: Learned skills\n'
'• Quests: Active quests\n'
'• Plot: Story progress',
),
const SizedBox(height: 12),
_HelpSection(
@@ -371,22 +389,42 @@ class _UIHelpView extends StatelessWidget {
? '速度調整'
: 'Speed Control',
content: isKorean
? '태스크 진행바 옆 속도 버튼으로 게임 속도를 조절할 수 있습니다:\n'
? '게임 속도를 조절할 수 있습니다:\n'
'• 1x: 기본 속도\n'
'• 2x: 2배 속도\n'
'• 5x: 5배 속도\n'
'• 10x: 10배 속도'
'• 2x: 명예의 전당 캐릭터 1명 이상 시 해금\n'
'• 5x: 광고 시청으로 5분간 부스트 (유료 유저 무료)'
: isJapanese
? 'タスク進行バー横の速度ボタンでゲーム速度を調整できます:\n'
? 'ゲーム速度を調整できます:\n'
'• 1x: 基本速度\n'
'• 2x: 2倍速\n'
'• 5x: 5倍速\n'
'• 10x: 10倍速'
: 'Use the speed button next to task bar to adjust game speed:\n'
'• 2x: 殿堂入り1人以上で解放\n'
'• 5x: 広告視聴で5分間ブースト課金ユーザー無料'
: 'Adjust game speed:\n'
'• 1x: Normal speed\n'
'• 2x: 2x speed\n'
'• 5x: 5x speed\n'
'• 10x: 10x speed',
'• 2x: Unlocked with 1+ Hall of Fame character\n'
'• 5x: 5-min boost via ad (free for paid users)',
),
const SizedBox(height: 12),
_HelpSection(
icon: '🏆',
title: isKorean
? '명예의 전당'
: isJapanese
? '殿堂入り'
: 'Hall of Fame',
content: isKorean
? 'Act V를 클리어하면 캐릭터가 명예의 전당에 등록됩니다.\n'
'• 캐릭터 이름, 레벨, 스탯이 영구 기록됨\n'
'• 첫 등록 시 2x 속도 영구 해금\n'
'• 2명 이상 등록 시 로컬 아레나 기능 해금'
: isJapanese
? 'Act Vクリアでキャラクターが殿堂入りします。\n'
'• キャラクター名、レベル、ステータスが永久記録\n'
'• 初登録で2倍速が永久解放\n'
'• 2人以上でローカルアリーナ機能解放'
: 'Characters enter Hall of Fame upon completing Act V.\n'
'• Name, level, stats are permanently recorded\n'
'• First entry permanently unlocks 2x speed\n'
'• 2+ entries unlock Local Arena feature',
),
const SizedBox(height: 12),
_HelpSection(

View File

@@ -1,37 +1,45 @@
import 'package:flutter/material.dart';
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
import 'package:asciineverdie/data/potion_data.dart';
import 'package:asciineverdie/src/core/engine/iap_service.dart';
import 'package:asciineverdie/src/core/engine/return_rewards_service.dart';
import 'package:asciineverdie/src/core/model/treasure_chest.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart';
/// 복귀 보상 다이얼로그 (Phase 7)
///
/// 게임 복귀 시 보상을 표시하는 다이얼로그
/// 게임 복귀 시 보물 상자 보상을 표시하는 다이얼로그
class ReturnRewardsDialog extends StatefulWidget {
const ReturnRewardsDialog({
super.key,
required this.reward,
required this.playerLevel,
required this.onClaim,
});
/// 복귀 보상 데이터
final ReturnReward reward;
final ReturnChestReward reward;
/// 보상 수령 콜백 (totalGold)
final void Function(int totalGold) onClaim;
/// 플레이어 레벨 (상자 보상 스케일링용)
final int playerLevel;
/// 보상 수령 콜백 (상자 보상 목록)
final void Function(List<ChestReward> rewards) onClaim;
/// 다이얼로그 표시
static Future<void> show(
BuildContext context, {
required ReturnReward reward,
required void Function(int totalGold) onClaim,
required ReturnChestReward reward,
required int playerLevel,
required void Function(List<ChestReward> rewards) onClaim,
}) async {
return showDialog<void>(
context: context,
barrierDismissible: false,
builder: (context) => ReturnRewardsDialog(
reward: reward,
playerLevel: playerLevel,
onClaim: onClaim,
),
);
@@ -41,27 +49,50 @@ class ReturnRewardsDialog extends StatefulWidget {
State<ReturnRewardsDialog> createState() => _ReturnRewardsDialogState();
}
class _ReturnRewardsDialogState extends State<ReturnRewardsDialog> {
bool _basicClaimed = false;
bool _bonusClaimed = false;
bool _isClaimingBonus = false;
int _totalClaimed = 0;
class _ReturnRewardsDialogState extends State<ReturnRewardsDialog>
with SingleTickerProviderStateMixin {
final _rewardsService = ReturnRewardsService.instance;
// 상태
bool _basicOpened = false;
bool _bonusOpened = false;
bool _isOpeningBasic = false;
bool _isOpeningBonus = false;
List<ChestReward> _basicRewards = [];
List<ChestReward> _bonusRewards = [];
// 애니메이션
late AnimationController _animController;
late Animation<double> _shakeAnimation;
@override
void initState() {
super.initState();
_animController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 500),
);
_shakeAnimation = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(parent: _animController, curve: Curves.elasticOut),
);
}
@override
void dispose() {
_animController.dispose();
super.dispose();
}
@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),
constraints: const BoxConstraints(maxWidth: 400),
decoration: BoxDecoration(
color: panelBg,
border: Border(
@@ -96,40 +127,41 @@ class _ReturnRewardsDialogState extends State<ReturnRewardsDialog> {
),
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 11,
fontSize: 10,
color: gold.withValues(alpha: 0.8),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
// 기본
_buildRewardSection(
// 기본 상자 섹션
_buildChestSection(
context,
title: l10n.returnRewardBasic,
gold: widget.reward.goldReward,
color: gold,
colorDark: goldDark,
claimed: _basicClaimed,
onClaim: _claimBasic,
buttonText: l10n.returnRewardClaim,
title: l10n.returnRewardChests(widget.reward.chestCount),
chestCount: widget.reward.chestCount,
rewards: _basicRewards,
isOpened: _basicOpened,
isOpening: _isOpeningBasic,
onOpen: _openBasicChests,
isGold: true,
),
const SizedBox(height: 16),
// 보너스
_buildRewardSection(
// 보너스 상자 섹션
_buildChestSection(
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,
title: l10n.returnRewardBonusChests,
chestCount: widget.reward.bonusChestCount,
rewards: _bonusRewards,
isOpened: _bonusOpened,
isOpening: _isOpeningBonus,
onOpen: _openBonusChests,
isGold: false,
enabled: _basicOpened && !_bonusOpened,
showAdIcon: !IAPService.instance.isAdRemovalPurchased,
),
const SizedBox(height: 20),
// 완료/건너뛰기 버튼
@@ -154,7 +186,7 @@ class _ReturnRewardsDialogState extends State<ReturnRewardsDialog> {
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('🎁', style: TextStyle(fontSize: 20, color: gold)),
const Text('📦', style: TextStyle(fontSize: 20)),
const SizedBox(width: 8),
Text(
l10n.returnRewardTitle,
@@ -166,32 +198,40 @@ class _ReturnRewardsDialogState extends State<ReturnRewardsDialog> {
),
),
const SizedBox(width: 8),
Text('🎁', style: TextStyle(fontSize: 20, color: gold)),
const Text('📦', style: TextStyle(fontSize: 20)),
],
),
);
}
Widget _buildRewardSection(
Widget _buildChestSection(
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,
required int chestCount,
required List<ChestReward> rewards,
required bool isOpened,
required bool isOpening,
required VoidCallback onOpen,
required bool isGold,
bool enabled = true,
bool showAdIcon = false,
}) {
final gold = RetroColors.goldOf(context);
final expColor = RetroColors.expOf(context);
final muted = RetroColors.textMutedOf(context);
final color = isGold ? gold : expColor;
final isPaidUser = IAPService.instance.isAdRemovalPurchased;
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),
border: Border.all(
color: (enabled || isOpened)
? color.withValues(alpha: 0.5)
: muted.withValues(alpha: 0.3),
width: 2,
),
),
child: Column(
children: [
@@ -201,46 +241,39 @@ class _ReturnRewardsDialogState extends State<ReturnRewardsDialog> {
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 11,
color: color,
color: (enabled || isOpened) ? color : muted,
),
),
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),
// 수령 버튼
// 상자 아이콘들 또는 보상 목록
if (isOpened)
_buildRewardsList(context, rewards)
else
_buildChestIcons(chestCount, color, enabled),
if (!isOpened) ...[
const SizedBox(height: 12),
// 오픈 버튼
GestureDetector(
onTap: enabled && !isLoading ? onClaim : null,
onTap: enabled && !isOpening ? onOpen : null,
child: AnimatedBuilder(
animation: _shakeAnimation,
builder: (context, child) {
return Transform.translate(
offset: isOpening
? Offset(
_shakeAnimation.value * 2 *
((_animController.value * 10).round() % 2 == 0
? 1
: -1),
0,
)
: Offset.zero,
child: child,
);
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
@@ -258,26 +291,38 @@ class _ReturnRewardsDialogState extends State<ReturnRewardsDialog> {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isLoading) ...[
if (isOpening) ...[
SizedBox(
width: 16,
height: 16,
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: color,
),
),
const SizedBox(width: 8),
],
Text(
buttonText,
l10n.returnRewardOpening,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 11,
fontSize: 10,
color: color,
),
),
] else ...[
Text(
isGold
? l10n.returnRewardOpenChests
: (isPaidUser
? l10n.returnRewardClaimBonusFree
: l10n.returnRewardClaimBonus),
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 10,
color: enabled ? color : muted,
),
),
if (showAdIcon && !isLoading) ...[
if (showAdIcon && !isPaidUser) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
@@ -299,6 +344,8 @@ class _ReturnRewardsDialogState extends State<ReturnRewardsDialog> {
),
],
],
],
),
),
),
),
@@ -308,12 +355,118 @@ class _ReturnRewardsDialogState extends State<ReturnRewardsDialog> {
);
}
Widget _buildChestIcons(int count, Color color, bool enabled) {
final muted = RetroColors.textMutedOf(context);
return Wrap(
alignment: WrapAlignment.center,
spacing: 8,
runSpacing: 8,
children: List.generate(
count,
(index) => Text(
'📦',
style: TextStyle(
fontSize: 24,
color: enabled ? null : muted,
),
),
),
);
}
Widget _buildRewardsList(BuildContext context, List<ChestReward> rewards) {
if (rewards.isEmpty) {
return const Text(
'(empty)',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 10,
color: Colors.grey,
),
);
}
return Column(
children: rewards.map((reward) => _buildRewardItem(context, reward)).toList(),
);
}
Widget _buildRewardItem(BuildContext context, ChestReward reward) {
final gold = RetroColors.goldOf(context);
final expColor = RetroColors.expOf(context);
String icon;
String text;
Color color;
switch (reward.type) {
case ChestRewardType.equipment:
icon = '⚔️';
text = reward.equipment?.name ?? 'Unknown';
color = _getRarityColor(reward.equipment?.rarity);
break;
case ChestRewardType.potion:
final potion = PotionData.getById(reward.potionId ?? '');
icon = potion?.type.name == 'hp' ? '❤️' : '💙';
text = l10n.chestRewardPotionAmount(
potion?.name ?? 'Potion',
reward.potionCount ?? 1,
);
color = Colors.white;
break;
case ChestRewardType.gold:
icon = '💰';
text = l10n.chestRewardGoldAmount(reward.gold ?? 0);
color = gold;
break;
case ChestRewardType.experience:
icon = '';
text = l10n.chestRewardExpAmount(reward.experience ?? 0);
color = expColor;
break;
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(icon, style: const TextStyle(fontSize: 16)),
const SizedBox(width: 8),
Flexible(
child: Text(
text,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 9,
color: color,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
Color _getRarityColor(dynamic rarity) {
if (rarity == null) return Colors.white;
return switch (rarity.toString()) {
'ItemRarity.common' => Colors.grey,
'ItemRarity.uncommon' => Colors.green,
'ItemRarity.rare' => Colors.blue,
'ItemRarity.epic' => Colors.purple,
'ItemRarity.legendary' => Colors.orange,
_ => Colors.white,
};
}
Widget _buildBottomButton(BuildContext context) {
final gold = RetroColors.goldOf(context);
final goldDark = RetroColors.goldDarkOf(context);
final muted = RetroColors.textMutedOf(context);
final canComplete = _basicClaimed;
final canComplete = _basicOpened;
final buttonColor = canComplete ? gold : muted;
final buttonDark = canComplete ? goldDark : muted.withValues(alpha: 0.5);
@@ -344,43 +497,70 @@ class _ReturnRewardsDialogState extends State<ReturnRewardsDialog> {
);
}
void _claimBasic() {
if (_basicClaimed) return;
final claimed = _rewardsService.claimBasicReward(widget.reward);
setState(() {
_basicClaimed = true;
_totalClaimed += claimed;
});
}
Future<void> _claimBonus() async {
if (_bonusClaimed || _isClaimingBonus) return;
Future<void> _openBasicChests() async {
if (_basicOpened || _isOpeningBasic) return;
setState(() {
_isClaimingBonus = true;
_isOpeningBasic = true;
});
final bonus = await _rewardsService.claimBonusReward(widget.reward);
// 애니메이션 시작
_animController.repeat();
// 약간의 딜레이 후 상자 오픈
await Future<void>.delayed(const Duration(milliseconds: 800));
final rewards = _rewardsService.claimBasicReward(
widget.reward,
widget.playerLevel,
);
_animController.stop();
if (mounted) {
setState(() {
_isClaimingBonus = false;
if (bonus > 0) {
_bonusClaimed = true;
_totalClaimed += bonus;
_isOpeningBasic = false;
_basicOpened = true;
_basicRewards = rewards;
});
}
}
Future<void> _openBonusChests() async {
if (_bonusOpened || _isOpeningBonus) return;
setState(() {
_isOpeningBonus = true;
});
_animController.repeat();
final rewards = await _rewardsService.claimBonusReward(
widget.reward,
widget.playerLevel,
);
_animController.stop();
if (mounted) {
setState(() {
_isOpeningBonus = false;
if (rewards.isNotEmpty) {
_bonusOpened = true;
_bonusRewards = rewards;
}
});
}
}
void _complete() {
widget.onClaim(_totalClaimed);
final allRewards = [..._basicRewards, ..._bonusRewards];
widget.onClaim(allRewards);
Navigator.of(context).pop();
}
void _skip() {
widget.onClaim(0);
widget.onClaim([]);
Navigator.of(context).pop();
}
}

View File

@@ -196,14 +196,20 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
final random = math.Random();
_currentSeed = random.nextInt(0x7FFFFFFF);
// 종족/클래스 랜덤 선택
// 종족/클래스 랜덤 선택 및 스탯 굴림
setState(() {
_selectedRaceIndex = random.nextInt(_races.length);
_selectedKlassIndex = random.nextInt(_klasses.length);
// 스탯 굴림 (setState 내에서 실행하여 UI 갱신 보장)
final rng = DeterministicRandom(_currentSeed);
_str = rollStat(rng);
_con = rollStat(rng);
_dex = rollStat(rng);
_int = rollStat(rng);
_wis = rollStat(rng);
_cha = rollStat(rng);
});
_rollStats();
// 선택된 종족/직업으로 스크롤
_scrollToSelectedItems();
@@ -296,7 +302,10 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
snapshot = await _rollService.undoFreeUser();
}
if (snapshot != null && mounted) {
// UI 상태 갱신 (성공/실패 여부와 관계없이 버튼 상태 업데이트)
if (!mounted) return;
if (snapshot != null) {
setState(() {
_str = snapshot!.stats.str;
_con = snapshot.stats.con;
@@ -309,6 +318,9 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
_currentSeed = snapshot.seed;
});
_scrollToSelectedItems();
} else {
// 광고 취소/실패 시에도 버튼 상태 갱신
setState(() {});
}
}
@@ -495,16 +507,19 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
: RetroColors.textDisabled,
),
const SizedBox(width: 8),
Text(
'DEBUG: TURBO MODE (20x)',
Flexible(
child: Text(
'DEBUG: TURBO (20x)',
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 13,
fontSize: 11,
color: _cheatsEnabled
? RetroColors.hpRed
: RetroColors.textDisabled,
),
),
),
],
),
),

File diff suppressed because it is too large Load Diff