Compare commits

...

6 Commits

Author SHA1 Message Date
JiWoong Sul
94c2ed1ca1 refactor(app): 앱 설정 및 공유 위젯 업데이트
- app.dart: MaterialApp 설정 개선
- retro_panel: 레트로 패널 위젯 수정
2026-01-19 15:50:49 +09:00
JiWoong Sul
19faa9ea39 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: 설정 화면 업데이트
2026-01-19 15:50:35 +09:00
JiWoong Sul
ffc19c7ca6 refactor(core): 핵심 서비스 로직 개선
- audio_service: 오디오 처리 로직 수정
- ad_service: 광고 서비스 개선
- character_roll_service: 캐릭터 롤 로직 수정
- iap_service: 인앱 결제 로직 개선
- progress_loop: 진행 루프 업데이트
- return_rewards_service: 복귀 보상 로직 개선
- settings_repository: 설정 저장소 수정
2026-01-19 15:50:18 +09:00
JiWoong Sul
724de9a63c feat(l10n): 다국어 텍스트 업데이트
- 영어, 한국어, 일본어, 중국어 번역 업데이트
- game_text_l10n 데이터 개선
2026-01-19 15:50:02 +09:00
JiWoong Sul
03aa117710 chore(deps): package_info_plus 패키지 추가
- 앱 버전 정보 표시를 위한 패키지 추가
- macos 플랫폼 설정 업데이트
2026-01-19 15:49:48 +09:00
JiWoong Sul
f51bf8c540 feat(core): 보물 상자 시스템 추가
- TreasureChest 모델 추가
- ChestService 서비스 추가
2026-01-19 15:49:26 +09:00
34 changed files with 3552 additions and 1889 deletions

View File

@@ -84,37 +84,31 @@ String get taskCompiling => _l('Compiling', '컴파일 중', 'コンパイル中
String get taskPrologue => _l('Prologue', '프롤로그', 'プロローグ'); String get taskPrologue => _l('Prologue', '프롤로그', 'プロローグ');
String taskHeadingToMarket() => _l( String taskHeadingToMarket() => _l(
'Heading to the Data Market to trade loot', 'Heading to the Data Market to trade loot',
'전리품을 팔기 위해 데이터 마켓으로 이동 중', '전리품을 팔기 위해 데이터 마켓으로 이동 중',
'戦利品を売るためデータマーケットへ移動中', '戦利品を売るためデータマーケットへ移動中',
); );
String taskUpgradingHardware() => _l( String taskUpgradingHardware() => _l(
'Upgrading hardware at the Tech Shop', 'Upgrading hardware at the Tech Shop',
'테크 샵에서 하드웨어 업그레이드 중', '테크 샵에서 하드웨어 업그레이드 중',
'テックショップでハードウェアをアップグレード中', 'テックショップでハードウェアをアップグレード中',
); );
String taskEnteringDebugZone() => String taskEnteringDebugZone() =>
_l('Entering the Debug Zone', '디버그 존 진입 중', 'デバッグゾーンに進入中'); _l('Entering the Debug Zone', '디버그 존 진입 중', 'デバッグゾーンに進入中');
String taskDebugging(String monsterName) => _l( String taskDebugging(String monsterName) =>
'Debugging $monsterName', _l('Debugging $monsterName', '$monsterName 디버깅 중', '$monsterName をデバッグ中');
'$monsterName 디버깅 중',
'$monsterName をデバッグ中',
);
String taskFinalBoss(String bossName) => _l( String taskFinalBoss(String bossName) =>
'Final Battle: $bossName', _l('Final Battle: $bossName', '최종 보스와 대결: $bossName', '最終ボスと対決: $bossName');
'최종 보스와 대결: $bossName',
'最終ボスと対決: $bossName',
);
String taskSelling(String itemDescription) => _l( String taskSelling(String itemDescription) => _l(
'Selling $itemDescription', 'Selling $itemDescription',
'$itemDescription 판매 중', '$itemDescription 판매 중',
'$itemDescription を販売中', '$itemDescription を販売中',
); );
// ============================================================================ // ============================================================================
// 부활 시퀀스 메시지 // 부활 시퀀스 메시지
@@ -161,35 +155,48 @@ String get deathEnvironmentalHazard =>
// 속도 부스트 (Phase 6) // 속도 부스트 (Phase 6)
// ============================================================================ // ============================================================================
String get speedBoostTitle => String get speedBoostTitle => _l('Speed Boost', '속도 부스트', 'スピードブースト');
_l('Speed Boost', '속도 부스트', 'スピードブースト');
String get speedBoostActivate => String get speedBoostActivate =>
_l('Activate 10x Speed', '10배속 활성화', '10倍速を有効化'); _l('Activate 10x Speed', '10배속 활성화', '10倍速を有効化');
String speedBoostRemaining(int seconds) => String speedBoostRemaining(int seconds) =>
_l('${seconds}s remaining', '${seconds}초 남음', '残り${seconds}'); _l('${seconds}s remaining', '${seconds}초 남음', '残り${seconds}');
String get speedBoostActive => String get speedBoostActive => _l('BOOST ACTIVE', '부스트 활성화', 'ブースト中');
_l('BOOST ACTIVE', '부스트 활성화', 'ブースト中');
// ============================================================================ // ============================================================================
// 복귀 보상 (Phase 7) // 복귀 보상 (Phase 7)
// ============================================================================ // ============================================================================
String get returnRewardTitle => String get returnRewardTitle => _l('Welcome Back!', '돌아오셨군요!', 'おかえりなさい!');
_l('Welcome Back!', '돌아오셨군요!', 'おかえりなさい!');
String returnRewardHoursAway(String time) => String returnRewardHoursAway(String time) =>
_l('You were away for $time', '$time 동안 떠나있었습니다', '$time 離れていました'); _l('You were away for $time', '$time 동안 떠나있었습니다', '$time 離れていました');
String get returnRewardBasic => String returnRewardChests(int count) =>
_l('Basic Reward', '기본 보상', '基本報酬'); _l('$count Treasure Chest(s)', '보물 상자 $count개', '宝箱 $count個');
String get returnRewardBonus => String get returnRewardOpenChests => _l('Open Chests', '상자 열기', '宝箱を開ける');
_l('Bonus Reward', '보너스 보상', 'ボーナス報酬'); String get returnRewardBonusChests =>
String returnRewardGold(int gold) => _l('Bonus Chests', '보너스 상자', 'ボーナス宝箱');
_l('+$gold Gold', '+$gold 골드', '+$gold ゴールド');
String get returnRewardClaim =>
_l('Claim', '받기', '受け取る');
String get returnRewardClaimBonus => String get returnRewardClaimBonus =>
_l('Claim Bonus', '보너스 받기', 'ボーナス受取'); _l('Get Bonus (AD)', '보너스 받기 (광고)', 'ボーナス受取 (広告)');
String get returnRewardSkip => String get returnRewardClaimBonusFree =>
_l('Skip', '건너뛰기', 'スキップ'); _l('Get Bonus (Free)', '보너스 받기 (무료)', 'ボーナス受取 (無料)');
String get returnRewardSkip => _l('Skip', '건너뛰기', 'スキップ');
String get returnRewardOpening => _l('Opening...', '여는 중...', '開封中...');
String get returnRewardComplete => _l('Complete!', '완료!', '完了!');
// 상자 보상 타입
String get chestRewardEquipment => _l('Equipment', '장비', '装備');
String get chestRewardPotion => _l('Potion', '포션', 'ポーション');
String get chestRewardGold => _l('Gold', '골드', 'ゴールド');
String get chestRewardExperience => _l('Experience', '경험치', '経験値');
String chestRewardGoldAmount(int gold) =>
_l('+$gold Gold', '+$gold 골드', '+$gold ゴールド');
String chestRewardExpAmount(int exp) =>
_l('+$exp EXP', '+$exp 경험치', '+$exp 経験値');
String chestRewardPotionAmount(String name, int count) =>
_l('$name x$count', '$name x$count', '$name x$count');
String get chestRewardEquipped =>
_l('Equipped!', '장착됨!', '装備しました!');
String get chestRewardBetterItem =>
_l('Better than current!', '현재보다 좋습니다!', '現在より良い!');
// ============================================================================ // ============================================================================
// UI 일반 메시지 // UI 일반 메시지
@@ -198,10 +205,8 @@ String get returnRewardSkip =>
String get uiNoPotions => _l('No potions', '포션 없음', 'ポーションなし'); String get uiNoPotions => _l('No potions', '포션 없음', 'ポーションなし');
String get uiTapToContinue => _l('Tap to continue', '탭하여 계속', 'タップして続行'); String get uiTapToContinue => _l('Tap to continue', '탭하여 계속', 'タップして続行');
String get uiNoSkills => _l('No skills', '습득한 스킬이 없습니다', 'スキルなし'); String get uiNoSkills => _l('No skills', '습득한 스킬이 없습니다', 'スキルなし');
String get uiNoBonusStats => String get uiNoBonusStats => _l('No bonus stats', '추가 스탯 없음', 'ボーナスステータスなし');
_l('No bonus stats', '추가 스탯 없음', 'ボーナスステータスなし'); String get uiNoActiveBuffs => _l('No active buffs', '활성 버프 없음', 'アクティブバフなし');
String get uiNoActiveBuffs =>
_l('No active buffs', '활성 버프 없음', 'アクティブバフなし');
String get uiReady => _l('Ready', '준비', '準備完了'); String get uiReady => _l('Ready', '준비', '準備完了');
String get uiPotions => _l('Potions', '포션', 'ポーション'); String get uiPotions => _l('Potions', '포션', 'ポーション');
String get uiBuffs => _l('Buffs', '버프', 'バフ'); String get uiBuffs => _l('Buffs', '버프', 'バフ');
@@ -236,87 +241,91 @@ String passiveEvasionBonus(int percent) =>
String passiveCritBonus(int percent) => String passiveCritBonus(int percent) =>
_l('Critical +$percent%', '크리티컬 +$percent%', 'クリティカル +$percent%'); _l('Critical +$percent%', '크리티컬 +$percent%', 'クリティカル +$percent%');
String passiveHpRegen(int percent) => _l( String passiveHpRegen(int percent) => _l(
'Recover $percent% HP after combat', 'Recover $percent% HP after combat',
'전투 후 HP $percent% 회복', '전투 후 HP $percent% 회복',
'戦闘後HP $percent%回復', '戦闘後HP $percent%回復',
); );
String passiveMpRegen(int percent) => _l( String passiveMpRegen(int percent) => _l(
'Recover $percent% MP after combat', 'Recover $percent% MP after combat',
'전투 후 MP $percent% 회복', '전투 후 MP $percent% 회복',
'戦闘後MP $percent%回復', '戦闘後MP $percent%回復',
); );
// ============================================================================ // ============================================================================
// 전투 로그 메시지 // 전투 로그 메시지
// ============================================================================ // ============================================================================
String combatYouHit(String targetName, int damage) => _l( String combatYouHit(String targetName, int damage) => _l(
'You hit $targetName for $damage damage', 'You hit $targetName for $damage damage',
'$targetName에게 $damage 데미지', '$targetName에게 $damage 데미지',
'$targetNameに$damageダメージ', '$targetNameに$damageダメージ',
); );
String combatYouEvaded(String targetName) => _l( String combatYouEvaded(String targetName) => _l(
'You evaded $targetName\'s attack!', 'You evaded $targetName\'s attack!',
'$targetName의 공격 회피!', '$targetName의 공격 회피!',
'$targetNameの攻撃を回避!', '$targetNameの攻撃を回避!',
); );
String combatEvadedAttackFrom(String targetName) => _l( String combatEvadedAttackFrom(String targetName) => _l(
'Evaded attack from $targetName', 'Evaded attack from $targetName',
'$targetName의 공격 회피', '$targetName의 공격 회피',
'$targetNameの攻撃を回避', '$targetNameの攻撃を回避',
); );
String combatHealedFor(int amount) => String combatHealedFor(int amount) =>
_l('Healed for $amount HP', 'HP $amount 회복', 'HP $amount回復'); _l('Healed for $amount HP', 'HP $amount 회복', 'HP $amount回復');
String combatCritical(int damage, String targetName) => _l( String combatCritical(int damage, String targetName) => _l(
'CRITICAL! $damage damage to $targetName!', 'CRITICAL! $damage damage to $targetName!',
'크리티컬! $targetName에게 $damage 데미지!', '크리티컬! $targetName에게 $damage 데미지!',
'クリティカル! $targetNameに$damageダメージ!', 'クリティカル! $targetNameに$damageダメージ!',
); );
String combatMonsterHitsYou(String monsterName, int damage) => _l( String combatMonsterHitsYou(String monsterName, int damage) => _l(
'$monsterName hits you for $damage damage', '$monsterName hits you for $damage damage',
'$monsterName이(가) $damage 데미지', '$monsterName이(가) $damage 데미지',
'$monsterNameが$damageダメージを与えた', '$monsterNameが$damageダメージを与えた',
); );
String combatMonsterEvaded(String monsterName) => _l( String combatMonsterEvaded(String monsterName) => _l(
'$monsterName evaded your attack!', '$monsterName evaded your attack!',
'$monsterName이(가) 공격 회피!', '$monsterName이(가) 공격 회피!',
'$monsterNameが攻撃を回避!', '$monsterNameが攻撃を回避!',
); );
String combatBlocked(int damage) => _l( String combatBlocked(int damage) => _l(
'Blocked! Reduced to $damage damage', 'Blocked! Reduced to $damage damage',
'방어! $damage 데미지로 감소', '방어! $damage 데미지로 감소',
'ブロック! $damageダメージに軽減', 'ブロック! $damageダメージに軽減',
); );
String combatParried(int damage) => _l( String combatParried(int damage) => _l(
'Parried! Reduced to $damage damage', 'Parried! Reduced to $damage damage',
'패리! $damage 데미지로 감소', '패리! $damage 데미지로 감소',
'パリィ! $damageダメージに軽減', 'パリィ! $damageダメージに軽減',
); );
// 스킬 관련 전투 메시지 // 스킬 관련 전투 메시지
String combatSkillCritical(String skillName, int damage) => _l( String combatSkillCritical(String skillName, int damage) => _l(
'CRITICAL $skillName! $damage damage!', 'CRITICAL $skillName! $damage damage!',
'크리티컬 $skillName! $damage 데미지!', '크리티컬 $skillName! $damage 데미지!',
'クリティカル$skillName! $damageダメージ!', 'クリティカル$skillName! $damageダメージ!',
); );
String combatSkillDamage(String skillName, int damage) => String combatSkillDamage(String skillName, int damage) => _l(
_l('$skillName: $damage damage', '$skillName: $damage 데미지', '$skillName: $damageダメージ'); '$skillName: $damage damage',
'$skillName: $damage 데미지',
'$skillName: $damageダメージ',
);
// HP 형식이 동일하므로 단순화 // HP 형식이 동일하므로 단순화
String combatSkillHeal(String skillName, int amount) => '$skillName: +$amount HP'; String combatSkillHeal(String skillName, int amount) =>
'$skillName: +$amount HP';
String get uiHeal => _l('Heal', '', 'ヒール'); String get uiHeal => _l('Heal', '', 'ヒール');
String combatBuffActivated(String skillName) => String combatBuffActivated(String skillName) =>
_l('$skillName activated!', '$skillName 발동!', '$skillName 発動!'); _l('$skillName activated!', '$skillName 발동!', '$skillName 発動!');
String combatDebuffApplied(String skillName, String targetName) => _l( String combatDebuffApplied(String skillName, String targetName) => _l(
'$skillName applied to $targetName!', '$skillName applied to $targetName!',
'$skillName$targetName에 적용!', '$skillName$targetName에 적용!',
'$skillName$targetNameに適用!', '$skillName$targetNameに適用!',
); );
String combatDotTick(String skillName, int damage) => _l( String combatDotTick(String skillName, int damage) => _l(
'$skillName ticks for $damage damage', '$skillName ticks for $damage damage',
'$skillName: $damage 지속 데미지', '$skillName: $damage 지속 데미지',
'$skillName: $damage 継続ダメージ', '$skillName: $damage 継続ダメージ',
); );
// 포션 형식이 동일하므로 단순화 // 포션 형식이 동일하므로 단순화
String combatPotionUsed(String potionName, int amount, String statName) => String combatPotionUsed(String potionName, int amount, String statName) =>
'$potionName: +$amount $statName'; '$potionName: +$amount $statName';
@@ -325,15 +334,15 @@ String combatPotionDrop(String potionName) =>
// 사망 화면 전투 로그 (death overlay) // 사망 화면 전투 로그 (death overlay)
String combatBlockedAttack(String monsterName, int reducedDamage) => _l( String combatBlockedAttack(String monsterName, int reducedDamage) => _l(
'Blocked $monsterName\'s attack ($reducedDamage reduced)', 'Blocked $monsterName\'s attack ($reducedDamage reduced)',
'$monsterName의 공격 방어 ($reducedDamage 감소)', '$monsterName의 공격 방어 ($reducedDamage 감소)',
'$monsterNameの攻撃を防御 ($reducedDamage軽減)', '$monsterNameの攻撃を防御 ($reducedDamage軽減)',
); );
String combatParriedAttack(String monsterName, int reducedDamage) => _l( String combatParriedAttack(String monsterName, int reducedDamage) => _l(
'Parried $monsterName\'s attack ($reducedDamage reduced)', 'Parried $monsterName\'s attack ($reducedDamage reduced)',
'$monsterName의 공격 패리 ($reducedDamage 감소)', '$monsterName의 공격 패리 ($reducedDamage 감소)',
'$monsterNameの攻撃をパリィ ($reducedDamage軽減)', '$monsterNameの攻撃をパリィ ($reducedDamage軽減)',
); );
String get deathSelfInflicted => String get deathSelfInflicted =>
_l('Self-inflicted damage', '자해 데미지로 사망', '自傷ダメージで死亡'); _l('Self-inflicted damage', '자해 데미지로 사망', '自傷ダメージで死亡');
@@ -343,8 +352,7 @@ String get deathSelfInflicted =>
String questPatch(String name) => String questPatch(String name) =>
_l('Patch $name', '$name 패치하기', '$name をパッチする'); _l('Patch $name', '$name 패치하기', '$name をパッチする');
String questLocate(String item) => String questLocate(String item) => _l('Locate $item', '$item 찾기', '$item を探す');
_l('Locate $item', '$item 찾기', '$item を探す');
String questTransfer(String item) => String questTransfer(String item) =>
_l('Transfer this $item', '$item 전송하기', 'この$item を転送する'); _l('Transfer this $item', '$item 전송하기', 'この$item を転送する');
String questDownload(String item) => String questDownload(String item) =>
@@ -364,100 +372,100 @@ String actTitle(String romanNumeral) =>
// ============================================================================ // ============================================================================
String cinematicCacheZone1() => _l( String cinematicCacheZone1() => _l(
'Exhausted, you reach a safe Cache Zone in the corrupted network', 'Exhausted, you reach a safe Cache Zone in the corrupted network',
'지쳐서 손상된 네트워크의 안전한 캐시 존에 도착하다', '지쳐서 손상된 네트워크의 안전한 캐시 존에 도착하다',
'疲れ果てて、破損したネットワークの安全なキャッシュゾーンに到着する', '疲れ果てて、破損したネットワークの安全なキャッシュゾーンに到着する',
); );
String cinematicCacheZone2() => _l( String cinematicCacheZone2() => _l(
'You reconnect with old allies and fork new ones', 'You reconnect with old allies and fork new ones',
'옛 동맹들과 재연결하고 새로운 동료들을 포크하다', '옛 동맹들과 재연결하고 새로운 동료들을 포크하다',
'古い同盟者と再接続し、新しい仲間をフォークする', '古い同盟者と再接続し、新しい仲間をフォークする',
); );
String cinematicCacheZone3() => _l( String cinematicCacheZone3() => _l(
'You attend a council of the Debugger Knights', 'You attend a council of the Debugger Knights',
'디버거 기사단 회의에 참석하다', '디버거 기사단 회의에 참석하다',
'デバッガー騎士団の会議に参加する', 'デバッガー騎士団の会議に参加する',
); );
String cinematicCacheZone4() => _l( String cinematicCacheZone4() => _l(
'Many bugs await. You are chosen to patch them!', 'Many bugs await. You are chosen to patch them!',
'많은 버그들이 기다린다. 당신이 패치하도록 선택되었다!', '많은 버그들이 기다린다. 당신이 패치하도록 선택되었다!',
'多くのバグが待っている。あなたがパッチを当てるよう選ばれた!', '多くのバグが待っている。あなたがパッチを当てるよう選ばれた!',
); );
// ============================================================================ // ============================================================================
// 시네마틱 텍스트 - 시나리오 2: 전투 // 시네마틱 텍스트 - 시나리오 2: 전투
// ============================================================================ // ============================================================================
String cinematicCombat1() => _l( String cinematicCombat1() => _l(
'Your target is in sight, but a critical bug blocks your path!', 'Your target is in sight, but a critical bug blocks your path!',
'목표가 눈앞에 있지만, 치명적인 버그가 길을 막는다!', '목표가 눈앞에 있지만, 치명적인 버그가 길을 막는다!',
'ターゲットは目の前だが、致命的なバグが道を塞ぐ!', 'ターゲットは目の前だが、致命的なバグが道を塞ぐ!',
); );
String cinematicCombat2(String nemesis) => _l( String cinematicCombat2(String nemesis) => _l(
'A desperate debugging session begins with $nemesis', 'A desperate debugging session begins with $nemesis',
'$nemesis와의 필사적인 디버깅 세션이 시작되다', '$nemesis와의 필사적인 디버깅 세션이 시작되다',
'$nemesisとの必死のデバッグセッションが始まる', '$nemesisとの必死のデバッグセッションが始まる',
); );
String cinematicCombatLocked(String nemesis) => _l( String cinematicCombatLocked(String nemesis) => _l(
'Locked in intense debugging with $nemesis', 'Locked in intense debugging with $nemesis',
'$nemesis와 치열한 디버깅 중', '$nemesis와 치열한 디버깅 중',
'$nemesisと激しいデバッグ中', '$nemesisと激しいデバッグ中',
); );
String cinematicCombatCorrupts(String nemesis) => _l( String cinematicCombatCorrupts(String nemesis) => _l(
'$nemesis corrupts your stack trace', '$nemesis corrupts your stack trace',
'$nemesis가 당신의 스택 트레이스를 손상시키다', '$nemesis가 당신의 스택 트레이스를 손상시키다',
'$nemesisがあなたのスタックトレースを破損させる', '$nemesisがあなたのスタックトレースを破損させる',
); );
String cinematicCombatWorking(String nemesis) => _l( String cinematicCombatWorking(String nemesis) => _l(
'Your patch seems to be working against $nemesis', 'Your patch seems to be working against $nemesis',
'당신의 패치가 $nemesis에게 효과를 보이는 것 같다', '당신의 패치가 $nemesis에게 효과를 보이는 것 같다',
'あなたのパッチが$nemesisに効いているようだ', 'あなたのパッチが$nemesisに効いているようだ',
); );
String cinematicCombatVictory(String nemesis) => _l( String cinematicCombatVictory(String nemesis) => _l(
'Victory! $nemesis is patched! System reboots for recovery', 'Victory! $nemesis is patched! System reboots for recovery',
'승리! $nemesis가 패치되었다! 복구를 위해 시스템이 재부팅된다', '승리! $nemesis가 패치되었다! 복구를 위해 시스템이 재부팅된다',
'勝利!$nemesisはパッチされた!復旧のためシステムが再起動する', '勝利!$nemesisはパッチされた!復旧のためシステムが再起動する',
); );
String cinematicCombatWakeUp() => _l( String cinematicCombatWakeUp() => _l(
'You wake up in a Safe Mode, but the kernel awaits', 'You wake up in a Safe Mode, but the kernel awaits',
'안전 모드에서 깨어나지만, 커널이 기다린다', '안전 모드에서 깨어나지만, 커널이 기다린다',
'セーフモードで目覚めるが、カーネルが待ち構えている', 'セーフモードで目覚めるが、カーネルが待ち構えている',
); );
// ============================================================================ // ============================================================================
// 시네마틱 텍스트 - 시나리오 3: 배신 // 시네마틱 텍스트 - 시나리오 3: 배신
// ============================================================================ // ============================================================================
String cinematicBetrayal1(String guy) => _l( String cinematicBetrayal1(String guy) => _l(
'What relief! You reach the secure server of $guy', 'What relief! You reach the secure server of $guy',
'안도감! $guy의 보안 서버에 도착하다', '안도감! $guy의 보안 서버에 도착하다',
'安堵!$guyのセキュアサーバーに到着する', '安堵!$guyのセキュアサーバーに到着する',
); );
String cinematicBetrayal2(String guy) => _l( String cinematicBetrayal2(String guy) => _l(
'There is celebration, and a suspicious private handshake with $guy', 'There is celebration, and a suspicious private handshake with $guy',
'축하가 이어지고, $guy와 수상한 비밀 핸드셰이크를 나누다', '축하가 이어지고, $guy와 수상한 비밀 핸드셰이크를 나누다',
'祝賀が続き、$guyと怪しい秘密のハンドシェイクを交わす', '祝賀が続き、$guyと怪しい秘密のハンドシェイクを交わす',
); );
String cinematicBetrayal3(String item) => _l( String cinematicBetrayal3(String item) => _l(
'You forget your $item and go back to retrieve it', 'You forget your $item and go back to retrieve it',
'$item을 잊고 다시 가져오러 돌아가다', '$item을 잊고 다시 가져오러 돌아가다',
'$itemを忘れて取りに戻る', '$itemを忘れて取りに戻る',
); );
String cinematicBetrayal4() => _l( String cinematicBetrayal4() => _l(
'What is this!? You intercept a corrupted packet!', 'What is this!? You intercept a corrupted packet!',
'이게 뭐지!? 손상된 패킷을 가로채다!', '이게 뭐지!? 손상된 패킷을 가로채다!',
'これは何だ!?破損したパケットを傍受する!', 'これは何だ!?破損したパケットを傍受する!',
); );
String cinematicBetrayal5(String guy) => _l( String cinematicBetrayal5(String guy) => _l(
'Could $guy be a backdoor for the Glitch God?', 'Could $guy be a backdoor for the Glitch God?',
'$guy가 글리치 신의 백도어일 수 있을까?', '$guy가 글리치 신의 백도어일 수 있을까?',
'$guyはグリッチゴッドのバックドアなのか', '$guyはグリッチゴッドのバックドアなのか',
); );
String cinematicBetrayal6() => _l( String cinematicBetrayal6() => _l(
'Who can be trusted with this intel!? -- The Binary Temple, of course', 'Who can be trusted with this intel!? -- The Binary Temple, of course',
'이 정보를 누구에게 맡길 수 있을까!? -- 바이너리 신전이다', '이 정보를 누구에게 맡길 수 있을까!? -- 바이너리 신전이다',
'この情報を誰に託せるか!? -- バイナリ神殿だ', 'この情報を誰に託せるか!? -- バイナリ神殿だ',
); );
// ============================================================================ // ============================================================================
// 몬스터 수식어 // 몬스터 수식어
@@ -467,39 +475,29 @@ String modifierDead(String s) => _l('fallen $s', '쓰러진 $s', '倒れた$s');
String modifierComatose(String s) => _l('lurking $s', '잠복하는 $s', '潜む$s'); String modifierComatose(String s) => _l('lurking $s', '잠복하는 $s', '潜む$s');
String modifierCrippled(String s) => _l('twisted $s', '흉측한 $s', '歪んだ$s'); String modifierCrippled(String s) => _l('twisted $s', '흉측한 $s', '歪んだ$s');
String modifierSick(String s) => _l('tainted $s', '오염된 $s', '汚染された$s'); String modifierSick(String s) => _l('tainted $s', '오염된 $s', '汚染された$s');
String modifierUndernourished(String s) => String modifierUndernourished(String s) => _l('ravenous $s', '굶주린 $s', '飢えた$s');
_l('ravenous $s', '굶주린 $s', '飢えた$s');
String modifierFoetal(String s) => _l('nascent $s', '태동기 $s', '胎動期$s'); String modifierFoetal(String s) => _l('nascent $s', '태동기 $s', '胎動期$s');
String modifierBaby(String s) => _l('fledgling $s', '초기형 $s', '初期型$s'); String modifierBaby(String s) => _l('fledgling $s', '초기형 $s', '初期型$s');
String modifierPreadolescent(String s) => String modifierPreadolescent(String s) =>
_l('evolving $s', '진화 중인 $s', '進化中の$s'); _l('evolving $s', '진화 중인 $s', '進化中の$s');
String modifierTeenage(String s) => _l('lesser $s', '하급 $s', '下級$s'); String modifierTeenage(String s) => _l('lesser $s', '하급 $s', '下級$s');
String modifierUnderage(String s) => String modifierUnderage(String s) => _l('incomplete $s', '불완전한 $s', '不完全な$s');
_l('incomplete $s', '불완전한 $s', '不完全な$s');
String modifierGreater(String s) => _l('greater $s', '상위 $s', '上位$s'); String modifierGreater(String s) => _l('greater $s', '상위 $s', '上位$s');
String modifierMassive(String s) => _l('massive $s', '거대한 $s', '巨大な$s'); String modifierMassive(String s) => _l('massive $s', '거대한 $s', '巨大な$s');
String modifierEnormous(String s) => _l('enormous $s', '초거대 $s', '超巨大$s'); String modifierEnormous(String s) => _l('enormous $s', '초거대 $s', '超巨大$s');
String modifierGiant(String s) => String modifierGiant(String s) => _l('giant $s', '자이언트 $s', 'ジャイアント$s');
_l('giant $s', '자이언트 $s', 'ジャイアント$s');
String modifierTitanic(String s) => String modifierTitanic(String s) => _l('titanic $s', '타이타닉 $s', 'タイタニック$s');
_l('titanic $s', '타이타닉 $s', 'タイタニック$s'); String modifierVeteran(String s) => _l('veteran $s', '베테랑 $s', 'ベテラン$s');
String modifierVeteran(String s) =>
_l('veteran $s', '베테랑 $s', 'ベテラン$s');
String modifierBattle(String s) => _l('Battle-$s', '전투-$s', '戦闘-$s'); String modifierBattle(String s) => _l('Battle-$s', '전투-$s', '戦闘-$s');
String modifierCursed(String s) => String modifierCursed(String s) => _l('cursed $s', '저주받은 $s', '呪われた$s');
_l('cursed $s', '저주받은 $s', '呪われた$s');
String modifierWarrior(String s) => _l('warrior $s', '전사 $s', '戦士$s'); String modifierWarrior(String s) => _l('warrior $s', '전사 $s', '戦士$s');
String modifierWere(String s) => _l('Were-$s', '늑대인간-$s', '狼男-$s'); String modifierWere(String s) => _l('Were-$s', '늑대인간-$s', '狼男-$s');
String modifierUndead(String s) => String modifierUndead(String s) => _l('undead $s', '언데드 $s', 'アンデッド$s');
_l('undead $s', '언데드 $s', 'アンデッド$s');
String modifierDemon(String s) => _l('demon $s', '데몬 $s', 'デーモン$s'); String modifierDemon(String s) => _l('demon $s', '데몬 $s', 'デーモン$s');
String modifierMessianic(String s) => String modifierMessianic(String s) => _l('messianic $s', '메시아닉 $s', 'メシアニック$s');
_l('messianic $s', '메시아닉 $s', 'メシアニック$s'); String modifierImaginary(String s) => _l('imaginary $s', '상상의 $s', '想像上の$s');
String modifierImaginary(String s) => String modifierPassing(String s) => _l('passing $s', '지나가는 $s', '通りすがりの$s');
_l('imaginary $s', '상상의 $s', '想像上の$s');
String modifierPassing(String s) =>
_l('passing $s', '지나가는 $s', '通りすがりの$s');
// ============================================================================ // ============================================================================
// 시간 표시 // 시간 표시
@@ -509,8 +507,7 @@ String roughTimeSeconds(int seconds) =>
_l('$seconds seconds', '$seconds초', '$seconds秒'); _l('$seconds seconds', '$seconds초', '$seconds秒');
String roughTimeMinutes(int minutes) => String roughTimeMinutes(int minutes) =>
_l('$minutes minutes', '$minutes분', '$minutes分'); _l('$minutes minutes', '$minutes분', '$minutes分');
String roughTimeHours(int hours) => String roughTimeHours(int hours) => _l('$hours hours', '$hours시간', '$hours時間');
_l('$hours hours', '$hours시간', '$hours時間');
String roughTimeDays(int days) => _l('$days days', '$days일', '$days日'); String roughTimeDays(int days) => _l('$days days', '$days일', '$days日');
// ============================================================================ // ============================================================================
@@ -902,35 +899,30 @@ String translateSpell(String englishName) {
String get uiHallOfFame => _l('Hall of Fame', '명예의 전당', '栄誉の殿堂'); String get uiHallOfFame => _l('Hall of Fame', '명예의 전당', '栄誉の殿堂');
String get uiLocalArena => _l('Local Arena', '로컬 아레나', 'ローカルアリーナ'); String get uiLocalArena => _l('Local Arena', '로컬 아레나', 'ローカルアリーナ');
String get frontDescription => _l( String get frontDescription => _l(
'A retro-style offline single-player RPG', 'A retro-style offline single-player RPG',
'레트로 감성의 오프라인 싱글플레이어 RPG', '레트로 감성의 오프라인 싱글플레이어 RPG',
'レトロ感のあるオフラインシングルプレイヤーRPG', 'レトロ感のあるオフラインシングルプレイヤーRPG',
); );
String get frontTodayFocus => String get frontTodayFocus => _l("Today's focus", '오늘의 중점', '今日のフォーカス');
_l("Today's focus", '오늘의 중점', '今日のフォーカス');
// ============================================================================ // ============================================================================
// 명예의 전당 화면 텍스트 // 명예의 전당 화면 텍스트
// ============================================================================ // ============================================================================
String get hofNoHeroes => String get hofNoHeroes => _l('No heroes yet', '영웅이 아직 없습니다', 'まだ英雄がいません');
_l('No heroes yet', '영웅이 아직 없습니다', 'まだ英雄がいません');
String get hofDefeatGlitchGod => _l( String get hofDefeatGlitchGod => _l(
'Defeat the Glitch God to enshrine your legend!', 'Defeat the Glitch God to enshrine your legend!',
'글리치 신을 처치하여 전설을 남기세요!', '글리치 신을 처치하여 전설을 남기세요!',
'グリッチゴッドを倒して伝説を刻もう!', 'グリッチゴッドを倒して伝説を刻もう!',
); );
String get hofVictory => _l('VICTORY!', '승리!', '勝利!'); String get hofVictory => _l('VICTORY!', '승리!', '勝利!');
String get hofDefeatedGlitchGod => _l( String get hofDefeatedGlitchGod =>
'You have defeated the Glitch God!', _l('You have defeated the Glitch God!', '글리치 신을 처치했습니다!', 'グリッチゴッドを倒しました!');
'글리치 신을 처치했습니다!',
'グリッチゴッドを倒しました!',
);
String get hofLegendEnshrined => _l( String get hofLegendEnshrined => _l(
'Your legend has been enshrined in the Hall of Fame!', 'Your legend has been enshrined in the Hall of Fame!',
'당신의 전설이 명예의 전당에 기록되었습니다!', '당신의 전설이 명예의 전당에 기록되었습니다!',
'あなたの伝説が栄誉の殿堂に刻まれました!', 'あなたの伝説が栄誉の殿堂に刻まれました!',
); );
String get hofViewHallOfFame => String get hofViewHallOfFame =>
_l('View Hall of Fame', '명예의 전당 보기', '栄誉の殿堂を見る'); _l('View Hall of Fame', '명예의 전당 보기', '栄誉の殿堂を見る');
String get hofNewGame => _l('New Game', '새 게임', '新しいゲーム'); String get hofNewGame => _l('New Game', '새 게임', '新しいゲーム');
@@ -962,17 +954,20 @@ String get uiSkip => _l('SKIP', '건너뛰기', 'スキップ');
// ============================================================================ // ============================================================================
String get uiLevelUp => _l('Level Up!', '레벨 업!', 'レベルアップ!'); String get uiLevelUp => _l('Level Up!', '레벨 업!', 'レベルアップ!');
String uiQuestComplete(String questName) => String uiQuestComplete(String questName) => _l(
_l('Quest Complete: $questName', '퀘스트 완료: $questName', 'クエスト完了: $questName'); 'Quest Complete: $questName',
'퀘스트 완료: $questName',
'クエスト完了: $questName',
);
// ============================================================================ // ============================================================================
// 장비 패널 텍스트 // 장비 패널 텍스트
// ============================================================================ // ============================================================================
String get uiEquipmentScore => String get uiEquipmentScore => _l('Equipment Score', '장비 점수', '装備スコア');
_l('Equipment Score', '장비 점수', '装備スコア');
String get uiEmpty => _l('(empty)', '(비어있음)', '(空)'); String get uiEmpty => _l('(empty)', '(비어있음)', '(空)');
String uiWeight(int weight) => _l('Wt.$weight', '무게 $weight', '重量 $weight'); String uiWeight(int weight) => _l('Wt.$weight', '무게 $weight', '重量 $weight');
/// 남은 시간 표시 /// 남은 시간 표시
String uiTimeRemaining(String time) => String uiTimeRemaining(String time) =>
_l('$time remaining', '$time 남음', '残り$time'); _l('$time remaining', '$time 남음', '残り$time');
@@ -1026,17 +1021,11 @@ String get rarityLegendary => _l('LEGENDARY', '전설', 'レジェンダリー')
String uiRollHistory(int count) => String uiRollHistory(int count) =>
_l('$count roll(s) in history', '리롤 기록: $count회', 'リロール履歴: $count回'); _l('$count roll(s) in history', '리롤 기록: $count회', 'リロール履歴: $count回');
String get uiEnterName => _l( String get uiEnterName =>
'Please enter a name.', _l('Please enter a name.', '이름을 입력해주세요.', '名前を入力してください。');
'이름을 입력해주세요.',
'名前を入力してください。',
);
String get uiTestMode => _l('Test Mode', '테스트 모드', 'テストモード'); String get uiTestMode => _l('Test Mode', '테스트 모드', 'テストモード');
String get uiTestModeDesc => _l( String get uiTestModeDesc =>
'Use mobile layout on web', _l('Use mobile layout on web', '웹에서 모바일 레이아웃 사용', 'Webでモバイルレイアウトを使用');
'웹에서 모바일 레이아웃 사용',
'Webでモバイルレイアウトを使用',
);
// ============================================================================ // ============================================================================
// 캐로셀 네비게이션 텍스트 // 캐로셀 네비게이션 텍스트
@@ -1069,10 +1058,10 @@ String get menuNewGame => _l('New Game', '새로하기', '新規ゲーム');
String get confirmDeleteTitle => _l('Delete Save', '세이브 삭제', 'セーブ削除'); String get confirmDeleteTitle => _l('Delete Save', '세이브 삭제', 'セーブ削除');
String get confirmDeleteMessage => _l( String get confirmDeleteMessage => _l(
'Are you sure?\nAll progress will be lost.', 'Are you sure?\nAll progress will be lost.',
'정말 삭제하시겠습니까?\n모든 진행 상황이 사라집니다.', '정말 삭제하시겠습니까?\n모든 진행 상황이 사라집니다.',
'本当に削除しますか?\nすべての進行状況が失われます。', '本当に削除しますか?\nすべての進行状況が失われます。',
); );
String get buttonConfirm => _l('Confirm', '확인', '確認'); String get buttonConfirm => _l('Confirm', '확인', '確認');
String get buttonCancel => _l('Cancel', '취소', 'キャンセル'); String get buttonCancel => _l('Cancel', '취소', 'キャンセル');
@@ -1082,12 +1071,13 @@ String get buttonCancel => _l('Cancel', '취소', 'キャンセル');
String get uiWarning => _l('Warning', '경고', '警告'); String get uiWarning => _l('Warning', '경고', '警告');
String get warningDeleteSave => _l( String get warningDeleteSave => _l(
'Existing save file will be deleted. Continue?', 'Existing save file will be deleted. Continue?',
'기존 저장 파일이 삭제됩니다. 계속하시겠습니까?', '기존 저장 파일이 삭제됩니다. 계속하시겠습니까?',
'既存のセーブファイルが削除されます。続行しますか?', '既存のセーブファイルが削除されます。続行しますか?',
); );
// 카피라이트 텍스트는 언어에 따라 변하지 않음 // 카피라이트 텍스트는 언어에 따라 변하지 않음
String get copyrightText => '© 2025 NatureBridgeAi & cclabs all rights reserved'; String get copyrightText =>
'© 2025 NatureBridgeAi & cclabs all rights reserved.';
// ============================================================================ // ============================================================================
// 테마 설정 텍스트 // 테마 설정 텍스트
@@ -1120,17 +1110,16 @@ String get uiSound => _l('Sound', '사운드', 'サウンド');
String get uiBgmVolume => _l('BGM Volume', 'BGM 볼륨', 'BGM音量'); String get uiBgmVolume => _l('BGM Volume', 'BGM 볼륨', 'BGM音量');
String get uiSfxVolume => _l('SFX Volume', '효과음 볼륨', '効果音音量'); String get uiSfxVolume => _l('SFX Volume', '효과음 볼륨', '効果音音量');
String get uiSoundOff => _l('Muted', '음소거', 'ミュート'); String get uiSoundOff => _l('Muted', '음소거', 'ミュート');
String get uiAnimationSpeed => String get uiAnimationSpeed => _l('Animation Speed', '애니메이션 속도', 'アニメーション速度');
_l('Animation Speed', '애니메이션 속도', 'アニメーション速度');
String get uiSpeedSlow => _l('Slow', '느림', '遅い'); String get uiSpeedSlow => _l('Slow', '느림', '遅い');
String get uiSpeedNormal => _l('Normal', '보통', '普通'); String get uiSpeedNormal => _l('Normal', '보통', '普通');
String get uiSpeedFast => _l('Fast', '빠름', '速い'); String get uiSpeedFast => _l('Fast', '빠름', '速い');
String get uiAbout => _l('About', '정보', '情報'); String get uiAbout => _l('About', '정보', '情報');
String get uiAboutDescription => _l( String get uiAboutDescription => _l(
'An offline single-player RPG with ASCII art and retro vibes.', 'An offline single-player RPG with ASCII art and retro vibes.',
'ASCII 아트와 레트로 감성의 오프라인 싱글플레이어 RPG입니다.', 'ASCII 아트와 레트로 감성의 오프라인 싱글플레이어 RPG입니다.',
'ASCIIアートとレトロ感のあるオフラインシングルプレイヤーRPGです。', 'ASCIIアートとレトロ感のあるオフラインシングルプレイヤーRPGです。',
); );
// ============================================================================ // ============================================================================
// 공통 UI 액션 텍스트 // 공통 UI 액션 텍스트
@@ -1143,11 +1132,51 @@ String get uiDelete => _l('Delete', '삭제', '削除');
String get uiConfirmDelete => String get uiConfirmDelete =>
_l('Are you sure you want to delete?', '정말로 삭제하시겠습니까?', '本当に削除しますか?'); _l('Are you sure you want to delete?', '정말로 삭제하시겠습니까?', '本当に削除しますか?');
String get uiDeleted => _l('Deleted', '삭제되었습니다', '削除されました'); String get uiDeleted => _l('Deleted', '삭제되었습니다', '削除されました');
String get uiError => String get uiError => _l('An error occurred', '오류가 발생했습니다', 'エラーが発生しました');
_l('An error occurred', '오류가 발생했습니다', 'エラーが発生しました');
String get uiSaved => _l('Saved', '저장됨', '保存しました'); String get uiSaved => _l('Saved', '저장됨', '保存しました');
String get uiSaveBattleLog => String get uiSaveBattleLog => _l('Save Battle Log', '배틀로그 저장', 'バトルログ保存');
_l('Save Battle Log', '배틀로그 저장', 'バトルログ保存');
// ============================================================================
// IAP 구매 텍스트
// ============================================================================
String get iapRemoveAds => _l('Remove Ads', '광고 제거', '広告削除');
String get iapRemoveAdsDesc =>
_l('Enjoy ad-free experience', '광고 없이 플레이', '広告なしでプレイ');
String get iapBenefitTitle =>
_l('Premium Benefits', '프리미엄 혜택', 'プレミアム特典');
String get iapBenefit1 =>
_l('Ad-free gameplay', '광고 없는 쾌적한 플레이', '広告なしの快適プレイ');
String get iapBenefit2 =>
_l('Unlimited speed boost', '속도 부스트 무제한', 'スピードブースト無制限');
String get iapBenefit3 => _l(
'Stat reroll undo: 3 times',
'신규 캐릭터 스탯 가챠 되돌리기 3회',
'新キャラステ振り直し3回',
);
String get iapBenefit4 =>
_l('Unlimited rerolls', '굴리기 무제한', 'リロール無制限');
String get iapBenefit5 => _l(
'2x offline time credited',
'오프라인 시간 2배 인정',
'オフライン時間2倍適用',
);
String get iapBenefit6 =>
_l('Return chests: 10 max', '복귀 상자 최대 10개', '帰還ボックス最大10個');
String get iapPurchaseButton => _l('Purchase', '구매하기', '購入する');
String get iapAlreadyPurchased => _l('Already purchased', '이미 구매됨', '購入済み');
String get iapPurchaseSuccess => _l('Purchase successful!', '구매 완료!', '購入完了!');
String get iapPurchaseFailed => _l(
'Purchase failed. Please try again.',
'구매 실패. 다시 시도해주세요.',
'購入失敗。もう一度お試しください。',
);
String get iapStoreUnavailable =>
_l('Store unavailable', '스토어 사용 불가', 'ストア利用不可');
String get iapRestorePurchase => _l('Restore Purchase', '구매 복원', '購入を復元');
String get iapRestoreSuccess =>
_l('Purchase restored!', '구매 복원 완료!', '購入を復元しました!');
String get iapRestoreFailed => _l('Restore failed', '복원 실패', '復元失敗');
// ============================================================================ // ============================================================================
// 스킬 상세 정보 라벨 (Skill Detail Labels) // 스킬 상세 정보 라벨 (Skill Detail Labels)

View File

@@ -227,8 +227,8 @@
"total": "Total", "total": "Total",
"@total": { "description": "Total label for stats" }, "@total": { "description": "Total label for stats" },
"unroll": "Unroll", "unroll": "Undo",
"@unroll": { "description": "Unroll button" }, "@unroll": { "description": "Undo button for stat reroll" },
"roll": "Roll", "roll": "Roll",
"@roll": { "description": "Roll button" }, "@roll": { "description": "Roll button" },

View File

@@ -1,7 +1,7 @@
{ {
"@@locale": "ja", "@@locale": "ja",
"appTitle": "ASCII NEVER DIE", "appTitle": "アスキー ネバー ダイ",
"tagNoNetwork": "No network", "tagNoNetwork": "No network",
"tagIdleRpg": "Idle RPG loop", "tagIdleRpg": "Idle RPG loop",
"tagLocalSaves": "Local saves", "tagLocalSaves": "Local saves",
@@ -68,7 +68,7 @@
"name": "Name", "name": "Name",
"generateName": "Generate Name", "generateName": "Generate Name",
"total": "Total", "total": "Total",
"unroll": "Unroll", "unroll": "元に戻す",
"roll": "Roll", "roll": "Roll",
"race": "Race", "race": "Race",
"classTitle": "Class", "classTitle": "Class",

View File

@@ -68,7 +68,7 @@
"name": "이름", "name": "이름",
"generateName": "이름 생성", "generateName": "이름 생성",
"total": "합계", "total": "합계",
"unroll": "펼치기", "unroll": "되돌리기",
"roll": "굴리기", "roll": "굴리기",
"race": "종족", "race": "종족",
"classTitle": "직업", "classTitle": "직업",

View File

@@ -503,10 +503,10 @@ abstract class L10n {
/// **'Total'** /// **'Total'**
String get total; String get total;
/// Unroll button /// Undo button for stat reroll
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Unroll'** /// **'Undo'**
String get unroll; String get unroll;
/// Roll button /// Roll button

View File

@@ -220,7 +220,7 @@ class L10nEn extends L10n {
String get total => 'Total'; String get total => 'Total';
@override @override
String get unroll => 'Unroll'; String get unroll => 'Undo';
@override @override
String get roll => 'Roll'; String get roll => 'Roll';

View File

@@ -9,7 +9,7 @@ class L10nJa extends L10n {
L10nJa([String locale = 'ja']) : super(locale); L10nJa([String locale = 'ja']) : super(locale);
@override @override
String get appTitle => 'ASCII NEVER DIE'; String get appTitle => 'アスキー ネバー ダイ';
@override @override
String get tagNoNetwork => 'No network'; String get tagNoNetwork => 'No network';
@@ -220,7 +220,7 @@ class L10nJa extends L10n {
String get total => 'Total'; String get total => 'Total';
@override @override
String get unroll => 'Unroll'; String get unroll => '元に戻す';
@override @override
String get roll => 'Roll'; String get roll => 'Roll';

View File

@@ -220,7 +220,7 @@ class L10nKo extends L10n {
String get total => '합계'; String get total => '합계';
@override @override
String get unroll => '펼치'; String get unroll => '되돌리';
@override @override
String get roll => '굴리기'; String get roll => '굴리기';

View File

@@ -220,7 +220,7 @@ class L10nZh extends L10n {
String get total => 'Total'; String get total => 'Total';
@override @override
String get unroll => 'Unroll'; String get unroll => '撤销';
@override @override
String get roll => 'Roll'; String get roll => 'Roll';

View File

@@ -68,7 +68,7 @@
"name": "Name", "name": "Name",
"generateName": "Generate Name", "generateName": "Generate Name",
"total": "Total", "total": "Total",
"unroll": "Unroll", "unroll": "撤销",
"roll": "Roll", "roll": "Roll",
"race": "Race", "race": "Race",
"classTitle": "Class", "classTitle": "Class",

View File

@@ -3,7 +3,9 @@ import 'package:flutter/material.dart';
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n; import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
import 'package:asciineverdie/l10n/app_localizations.dart'; import 'package:asciineverdie/l10n/app_localizations.dart';
import 'package:asciineverdie/src/core/audio/audio_service.dart'; import 'package:asciineverdie/src/core/audio/audio_service.dart';
import 'package:asciineverdie/src/core/engine/ad_service.dart';
import 'package:asciineverdie/src/core/engine/debug_settings_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/shared/retro_colors.dart'; import 'package:asciineverdie/src/shared/retro_colors.dart';
import 'package:asciineverdie/src/core/engine/game_mutations.dart'; import 'package:asciineverdie/src/core/engine/game_mutations.dart';
import 'package:asciineverdie/src/core/engine/progress_service.dart'; import 'package:asciineverdie/src/core/engine/progress_service.dart';
@@ -46,7 +48,8 @@ class SavedGamePreview {
final String actName; final String actName;
} }
class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> { class _AskiiNeverDieAppState extends State<AskiiNeverDieApp>
with WidgetsBindingObserver {
late final GameSessionController _controller; late final GameSessionController _controller;
late final NotificationService _notificationService; late final NotificationService _notificationService;
late final SettingsRepository _settingsRepository; late final SettingsRepository _settingsRepository;
@@ -57,12 +60,15 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
bool _isCheckingSave = true; bool _isCheckingSave = true;
bool _hasSave = false; bool _hasSave = false;
SavedGamePreview? _savedGamePreview; SavedGamePreview? _savedGamePreview;
ThemeMode _themeMode = ThemeMode.system;
HallOfFame _hallOfFame = HallOfFame.empty(); HallOfFame _hallOfFame = HallOfFame.empty();
Locale? _locale; // 사용자 선택 로케일 (null이면 시스템 기본값)
bool _isAdRemovalPurchased = false;
String? _removeAdsPrice;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this);
const config = PqConfig(); const config = PqConfig();
final mutations = GameMutations(config); final mutations = GameMutations(config);
final rewards = RewardService(mutations, config); final rewards = RewardService(mutations, config);
@@ -83,14 +89,33 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
// 초기 설정 및 오디오 서비스 로드 // 초기 설정 및 오디오 서비스 로드
_loadSettings(); _loadSettings();
_audioService.init(); _audioService.init();
// 디버그 설정 서비스 초기화 (Phase 8) // IAP 서비스 초기화
DebugSettingsService.instance.initialize(); _initIAP();
// 세이브 파일 존재 여부 확인 // 세이브 파일 존재 여부 확인
_checkForExistingSave(); _checkForExistingSave();
// 명예의 전당 로드 // 명예의 전당 로드
_loadHallOfFame(); _loadHallOfFame();
} }
/// IAP 및 광고 서비스 초기화
Future<void> _initIAP() async {
await IAPService.instance.initialize();
await AdService.instance.initialize();
_updateIAPState();
}
/// IAP 상태 업데이트 (구매 여부, 가격)
void _updateIAPState() {
if (mounted) {
setState(() {
_isAdRemovalPurchased = IAPService.instance.isAdRemovalPurchased;
_removeAdsPrice = IAPService.instance.isStoreAvailable
? IAPService.instance.removeAdsPrice
: null;
});
}
}
/// 명예의 전당 로드 /// 명예의 전당 로드
Future<void> _loadHallOfFame() async { Future<void> _loadHallOfFame() async {
final hallOfFame = await _hallOfFameStorage.load(); final hallOfFame = await _hallOfFameStorage.load();
@@ -103,16 +128,26 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
/// 저장된 설정 불러오기 /// 저장된 설정 불러오기
Future<void> _loadSettings() async { Future<void> _loadSettings() async {
final themeMode = await _settingsRepository.loadThemeMode(); // 디버그 설정 먼저 초기화 (광고/IAP 시뮬레이션 설정 동기화)
await DebugSettingsService.instance.initialize();
final localeCode = await _settingsRepository.loadLocale();
if (mounted) { if (mounted) {
setState(() => _themeMode = themeMode); setState(() {
// 저장된 로케일이 있으면 적용
if (localeCode != null) {
_locale = Locale(localeCode);
game_l10n.setGameLocale(localeCode);
}
});
} }
} }
/// 테마 모드 변경 /// 로케일 변경
void _changeThemeMode(ThemeMode mode) { void _changeLocale(String localeCode) {
setState(() => _themeMode = mode); setState(() {
_settingsRepository.saveThemeMode(mode); _locale = Locale(localeCode);
});
} }
/// 세이브 파일 존재 여부 확인 및 미리보기 정보 로드 /// 세이브 파일 존재 여부 확인 및 미리보기 정보 로드
@@ -139,8 +174,11 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
_savedGamePreview = preview; _savedGamePreview = preview;
_isCheckingSave = false; _isCheckingSave = false;
}); });
// 세이브 확인 완료 후 타이틀 BGM 재생 // 세이브 확인 완료 후 타이틀 BGM 재생 (앱이 포그라운드일 때만)
_audioService.playBgm('title'); final lifecycleState = WidgetsBinding.instance.lifecycleState;
if (lifecycleState == AppLifecycleState.resumed) {
_audioService.playBgm('title');
}
} }
} }
@@ -159,148 +197,32 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
@override @override
void dispose() { void dispose() {
WidgetsBinding.instance.removeObserver(this);
_controller.dispose(); _controller.dispose();
_notificationService.dispose(); _notificationService.dispose();
_audioService.dispose(); _audioService.dispose();
super.dispose(); super.dispose();
} }
/// 라이트 테마 (Classic Parchment 스타일) @override
ThemeData get _lightTheme => ThemeData( void didChangeAppLifecycleState(AppLifecycleState state) {
colorScheme: RetroColors.lightColorScheme, super.didChangeAppLifecycleState(state);
scaffoldBackgroundColor: const Color(0xFFFAF4ED), // 앱이 백그라운드로 내려가면 오디오 정지
useMaterial3: true, if (state == AppLifecycleState.paused ||
// 카드/다이얼로그 레트로 배경 state == AppLifecycleState.inactive) {
cardColor: const Color(0xFFF2E8DC), _audioService.pauseAll();
dialogTheme: const DialogThemeData( } else if (state == AppLifecycleState.resumed) {
backgroundColor: Color(0xFFF2E8DC), _audioService.resumeAll().then((_) {
titleTextStyle: TextStyle( // 복귀 후 BGM이 없고 시작 화면이면 타이틀 BGM 재생
fontFamily: 'PressStart2P', if (_audioService.currentBgm == null && !_isCheckingSave) {
fontSize: 15, _audioService.playBgm('title');
color: Color(0xFFB8860B), }
), });
), }
// 앱바 레트로 스타일 }
appBarTheme: const AppBarTheme(
backgroundColor: Color(0xFFF2E8DC),
foregroundColor: Color(0xFF1F1F28),
titleTextStyle: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 15,
color: Color(0xFFB8860B),
),
),
// 버튼 테마
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFFE8DDD0),
foregroundColor: const Color(0xFF1F1F28),
textStyle: const TextStyle(
inherit: false,
fontFamily: 'PressStart2P',
fontSize: 14,
color: Color(0xFF1F1F28),
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFFB8860B),
side: const BorderSide(color: Color(0xFFB8860B), width: 2),
textStyle: const TextStyle(
inherit: false,
fontFamily: 'PressStart2P',
fontSize: 14,
color: Color(0xFFB8860B),
),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: const Color(0xFF4A4458),
textStyle: const TextStyle(
inherit: false,
fontFamily: 'PressStart2P',
fontSize: 14,
color: Color(0xFF4A4458),
),
),
),
// 텍스트 테마
textTheme: const TextTheme(
headlineLarge: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 20,
color: Color(0xFFB8860B),
),
headlineMedium: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 16,
color: Color(0xFFB8860B),
),
headlineSmall: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 15,
color: Color(0xFFB8860B),
),
titleLarge: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 15,
color: Color(0xFF1F1F28),
),
titleMedium: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: Color(0xFF1F1F28),
),
titleSmall: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: Color(0xFF1F1F28),
),
bodyLarge: TextStyle(fontSize: 18, color: Color(0xFF1F1F28)),
bodyMedium: TextStyle(fontSize: 17, color: Color(0xFF1F1F28)),
bodySmall: TextStyle(fontSize: 15, color: Color(0xFF1F1F28)),
labelLarge: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: Color(0xFF1F1F28),
),
labelMedium: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: Color(0xFF1F1F28),
),
labelSmall: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 13,
color: Color(0xFF1F1F28),
),
),
// 칩 테마
chipTheme: const ChipThemeData(
backgroundColor: Color(0xFFE8DDD0),
labelStyle: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: Color(0xFF1F1F28),
),
side: BorderSide(color: Color(0xFF8B7355)),
),
// 리스트 타일 테마
listTileTheme: const ListTileThemeData(
textColor: Color(0xFF1F1F28),
iconColor: Color(0xFFB8860B),
),
// 프로그레스 인디케이터
progressIndicatorTheme: const ProgressIndicatorThemeData(
color: Color(0xFFB8860B),
linearTrackColor: Color(0xFFD4C4B0),
),
);
/// 다크 테마 (Dark Fantasy 스타일) /// 테마 (Dark Fantasy 스타일)
ThemeData get _darkTheme => ThemeData( ThemeData get _theme => ThemeData(
colorScheme: RetroColors.darkColorScheme, colorScheme: RetroColors.darkColorScheme,
scaffoldBackgroundColor: RetroColors.deepBrown, scaffoldBackgroundColor: RetroColors.deepBrown,
useMaterial3: true, useMaterial3: true,
@@ -440,9 +362,8 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
localizationsDelegates: L10n.localizationsDelegates, localizationsDelegates: L10n.localizationsDelegates,
supportedLocales: L10n.supportedLocales, supportedLocales: L10n.supportedLocales,
theme: _lightTheme, locale: _locale, // 사용자 선택 로케일 (null이면 시스템 기본값)
darkTheme: _darkTheme, theme: _theme,
themeMode: _themeMode,
navigatorObservers: [_routeObserver], navigatorObservers: [_routeObserver],
builder: (context, child) { builder: (context, child) {
// 현재 로케일을 게임 텍스트 l10n 시스템에 동기화 // 현재 로케일을 게임 텍스트 l10n 시스템에 동기화
@@ -470,13 +391,18 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
onHallOfFame: _navigateToHallOfFame, onHallOfFame: _navigateToHallOfFame,
onLocalArena: _navigateToArena, onLocalArena: _navigateToArena,
onSettings: _showSettings, onSettings: _showSettings,
onPurchaseRemoveAds: _purchaseRemoveAds,
onRestorePurchase: _restorePurchase,
hasSaveFile: _hasSave, hasSaveFile: _hasSave,
savedGamePreview: _savedGamePreview, savedGamePreview: _savedGamePreview,
hallOfFameCount: _hallOfFame.count, hallOfFameCount: _hallOfFame.count,
isAdRemovalPurchased: _isAdRemovalPurchased,
removeAdsPrice: _removeAdsPrice,
routeObserver: _routeObserver, routeObserver: _routeObserver,
onRefresh: () { onRefresh: () {
_checkForExistingSave(); _checkForExistingSave();
_loadHallOfFame(); _loadHallOfFame();
_updateIAPState();
}, },
); );
} }
@@ -551,8 +477,6 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
controller: _controller, controller: _controller,
audioService: _audioService, audioService: _audioService,
forceCarouselLayout: testMode, forceCarouselLayout: testMode,
currentThemeMode: _themeMode,
onThemeModeChange: _changeThemeMode,
), ),
), ),
); );
@@ -568,8 +492,6 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
audioService: _audioService, audioService: _audioService,
// 디버그 모드로 저장된 게임 로드 시 캐로셀 레이아웃 강제 // 디버그 모드로 저장된 게임 로드 시 캐로셀 레이아웃 강제
forceCarouselLayout: _controller.cheatsEnabled, forceCarouselLayout: _controller.cheatsEnabled,
currentThemeMode: _themeMode,
onThemeModeChange: _changeThemeMode,
), ),
), ),
) )
@@ -613,12 +535,60 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
SettingsScreen.show( SettingsScreen.show(
context, context,
settingsRepository: _settingsRepository, settingsRepository: _settingsRepository,
currentThemeMode: _themeMode, onLocaleChange: _changeLocale,
onThemeModeChange: _changeThemeMode,
onBgmVolumeChange: _audioService.setBgmVolume, onBgmVolumeChange: _audioService.setBgmVolume,
onSfxVolumeChange: _audioService.setSfxVolume, onSfxVolumeChange: _audioService.setSfxVolume,
); );
} }
/// 광고 제거 구매
Future<void> _purchaseRemoveAds(BuildContext context) async {
final result = await IAPService.instance.purchaseRemoveAds();
_updateIAPState();
if (!context.mounted) return;
switch (result) {
case IAPResult.success:
case IAPResult.debugSimulated:
_notificationService.showInfo(game_l10n.iapPurchaseSuccess);
case IAPResult.alreadyPurchased:
_notificationService.showInfo(game_l10n.iapAlreadyPurchased);
case IAPResult.cancelled:
// 취소는 무시
break;
case IAPResult.storeUnavailable:
_notificationService.showWarning(game_l10n.iapStoreUnavailable);
case IAPResult.productNotFound:
case IAPResult.failed:
_notificationService.showWarning(game_l10n.iapPurchaseFailed);
}
}
/// 구매 복원
Future<void> _restorePurchase(BuildContext context) async {
final result = await IAPService.instance.restorePurchases();
_updateIAPState();
if (!context.mounted) return;
switch (result) {
case IAPResult.success:
case IAPResult.debugSimulated:
if (_isAdRemovalPurchased) {
_notificationService.showInfo(game_l10n.iapRestoreSuccess);
} else {
_notificationService.showInfo(game_l10n.iapRestoreFailed);
}
case IAPResult.storeUnavailable:
_notificationService.showWarning(game_l10n.iapStoreUnavailable);
case IAPResult.alreadyPurchased:
case IAPResult.cancelled:
case IAPResult.productNotFound:
case IAPResult.failed:
_notificationService.showWarning(game_l10n.iapRestoreFailed);
}
}
} }
/// 스플래시 화면 (세이브 파일 확인 중) - 레트로 스타일 /// 스플래시 화면 (세이브 파일 확인 중) - 레트로 스타일

View File

@@ -85,6 +85,9 @@ class AudioService {
// 오디오 일시정지 상태 (앱 백그라운드 시) // 오디오 일시정지 상태 (앱 백그라운드 시)
bool _isPaused = false; bool _isPaused = false;
// 일시정지 전 재생 중이던 BGM (복귀 시 재개용)
String? _pausedBgm;
// BGM 작업 진행 중 여부 (동시 호출 방지) // BGM 작업 진행 중 여부 (동시 호출 방지)
bool _isBgmBusy = false; bool _isBgmBusy = false;
@@ -357,17 +360,24 @@ class AudioService {
/// 전체 오디오 일시정지 (앱 백그라운드 시) /// 전체 오디오 일시정지 (앱 백그라운드 시)
Future<void> pauseAll() async { Future<void> pauseAll() async {
_isPaused = true; _isPaused = true;
_pausedBgm = _currentBgm; // 복귀 시 재개를 위해 저장
try { try {
await _staticBgmPlayer?.stop(); await _staticBgmPlayer?.stop();
} catch (_) {} } catch (_) {}
_currentBgm = null; _currentBgm = null;
debugPrint('[AudioService] All audio paused'); debugPrint('[AudioService] All audio paused (was playing: $_pausedBgm)');
} }
/// 전체 오디오 재개 (앱 포그라운드 복귀 시) /// 전체 오디오 재개 (앱 포그라운드 복귀 시)
Future<void> resumeAll() async { Future<void> resumeAll() async {
_isPaused = false; _isPaused = false;
debugPrint('[AudioService] Audio resumed'); debugPrint('[AudioService] Audio resumed');
// 일시정지 전 재생 중이던 BGM 재개
if (_pausedBgm != null) {
final bgmToResume = _pausedBgm!;
_pausedBgm = null;
await playBgm(bgmToResume);
}
} }
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────

View File

@@ -1,6 +1,8 @@
import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart'; import 'package:google_mobile_ads/google_mobile_ads.dart';
/// 광고 타입 /// 광고 타입
@@ -228,32 +230,53 @@ class AdService {
final ad = _rewardedAd!; final ad = _rewardedAd!;
_rewardedAd = null; _rewardedAd = null;
// 결과 추적용 // Completer를 사용하여 광고 종료까지 대기
var result = AdResult.cancelled; final completer = Completer<AdResult>();
var rewarded = false;
// 광고 표시 전 전체 화면 모드로 전환 (상단 UI 숨김)
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
ad.fullScreenContentCallback = FullScreenContentCallback( ad.fullScreenContentCallback = FullScreenContentCallback(
onAdDismissedFullScreenContent: (ad) { onAdDismissedFullScreenContent: (ad) {
debugPrint('[AdService] Rewarded ad dismissed'); debugPrint('[AdService] Rewarded ad dismissed');
// 광고 종료 후 UI 복원
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,
);
ad.dispose(); ad.dispose();
_loadRewardedAd(); // 다음 광고 미리 로드 _loadRewardedAd(); // 다음 광고 미리 로드
// 보상 수령 여부에 따라 결과 반환
if (!completer.isCompleted) {
completer.complete(rewarded ? AdResult.completed : AdResult.cancelled);
}
}, },
onAdFailedToShowFullScreenContent: (ad, error) { onAdFailedToShowFullScreenContent: (ad, error) {
debugPrint('[AdService] Rewarded ad failed to show: ${error.message}'); debugPrint('[AdService] Rewarded ad failed to show: ${error.message}');
// 광고 실패 시에도 UI 복원
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,
);
ad.dispose(); ad.dispose();
result = AdResult.failed;
_loadRewardedAd(); _loadRewardedAd();
if (!completer.isCompleted) {
completer.complete(AdResult.failed);
}
}, },
); );
await ad.show( await ad.show(
onUserEarnedReward: (ad, reward) { onUserEarnedReward: (ad, reward) {
debugPrint('[AdService] User earned reward: ${reward.amount}'); debugPrint('[AdService] User earned reward: ${reward.amount}');
result = AdResult.completed; rewarded = true;
onRewarded(); onRewarded();
}, },
); );
return result; // 광고가 종료될 때까지 대기
return completer.future;
} }
// =========================================================================== // ===========================================================================
@@ -316,30 +339,48 @@ class AdService {
final ad = _interstitialAd!; final ad = _interstitialAd!;
_interstitialAd = null; _interstitialAd = null;
// 결과 추적용 // Completer를 사용하여 광고 종료까지 대기
var result = AdResult.cancelled; final completer = Completer<AdResult>();
// 광고 표시 전 전체 화면 모드로 전환 (상단 UI 숨김)
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
ad.fullScreenContentCallback = FullScreenContentCallback( ad.fullScreenContentCallback = FullScreenContentCallback(
onAdDismissedFullScreenContent: (ad) { onAdDismissedFullScreenContent: (ad) {
debugPrint('[AdService] Interstitial ad dismissed'); debugPrint('[AdService] Interstitial ad dismissed');
// 광고 종료 후 UI 복원
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,
);
ad.dispose(); ad.dispose();
result = AdResult.completed;
onComplete(); onComplete();
_loadInterstitialAd(); // 다음 광고 미리 로드 _loadInterstitialAd(); // 다음 광고 미리 로드
if (!completer.isCompleted) {
completer.complete(AdResult.completed);
}
}, },
onAdFailedToShowFullScreenContent: (ad, error) { onAdFailedToShowFullScreenContent: (ad, error) {
debugPrint( debugPrint(
'[AdService] Interstitial ad failed to show: ${error.message}', '[AdService] Interstitial ad failed to show: ${error.message}',
); );
// 광고 실패 시에도 UI 복원
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,
);
ad.dispose(); ad.dispose();
result = AdResult.failed;
_loadInterstitialAd(); _loadInterstitialAd();
if (!completer.isCompleted) {
completer.complete(AdResult.failed);
}
}, },
); );
await ad.show(); await ad.show();
return result; // 광고가 종료될 때까지 대기
return completer.future;
} }
// =========================================================================== // ===========================================================================

View File

@@ -143,8 +143,14 @@ class CharacterRollService {
_rollsRemaining--; _rollsRemaining--;
_saveRollsRemaining(); _saveRollsRemaining();
// 무료 유저: 새 굴리기마다 되돌리기 기회 1회 부여 (광고 시청 필요)
// 유료 유저: 세션당 최대 횟수 유지
if (!_isPaidUser && _undoRemaining < maxUndoFreeUser) {
_undoRemaining = maxUndoFreeUser;
}
debugPrint('[CharacterRollService] Rolled: remaining=$_rollsRemaining, ' debugPrint('[CharacterRollService] Rolled: remaining=$_rollsRemaining, '
'history=${_rollHistory.length}'); 'history=${_rollHistory.length}, undo=$_undoRemaining');
return true; return true;
} }

View File

@@ -0,0 +1,274 @@
import 'package:flutter/foundation.dart';
import 'package:asciineverdie/data/potion_data.dart';
import 'package:asciineverdie/src/core/model/equipment_item.dart';
import 'package:asciineverdie/src/core/model/equipment_slot.dart';
import 'package:asciineverdie/src/core/model/item_stats.dart';
import 'package:asciineverdie/src/core/model/treasure_chest.dart';
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
/// 보물 상자 서비스
///
/// 상자 내용물 생성 및 오픈 로직 담당
class ChestService {
ChestService({DeterministicRandom? rng})
: _rng = rng ?? DeterministicRandom(DateTime.now().millisecondsSinceEpoch);
final DeterministicRandom _rng;
// ==========================================================================
// 상수
// ==========================================================================
/// 보상 타입별 확률 (%)
static const int _equipmentChance = 40; // 장비 40%
static const int _potionChance = 30; // 포션 30%
static const int _goldChance = 20; // 골드 20%
// 경험치 10% (나머지)
/// 골드 보상 범위 (레벨 * 배율)
static const int _goldPerLevel = 50;
static const int _goldVariance = 20;
/// 경험치 보상 범위 (레벨 * 배율)
static const int _expPerLevel = 100;
static const int _expVariance = 30;
/// 포션 수량 범위
static const int _minPotionCount = 1;
static const int _maxPotionCount = 3;
// ==========================================================================
// 상자 오픈
// ==========================================================================
/// 상자 오픈하여 보상 생성
///
/// [playerLevel] 플레이어 레벨 (보상 스케일링용)
ChestReward openChest(int playerLevel) {
final roll = _rng.nextInt(100);
if (roll < _equipmentChance) {
// 40%: 장비
return _generateEquipmentReward(playerLevel);
} else if (roll < _equipmentChance + _potionChance) {
// 30%: 포션
return _generatePotionReward(playerLevel);
} else if (roll < _equipmentChance + _potionChance + _goldChance) {
// 20%: 골드
return _generateGoldReward(playerLevel);
} else {
// 10%: 경험치
return _generateExperienceReward(playerLevel);
}
}
/// 여러 상자 오픈
List<ChestReward> openMultipleChests(int count, int playerLevel) {
final rewards = <ChestReward>[];
for (var i = 0; i < count; i++) {
rewards.add(openChest(playerLevel));
}
return rewards;
}
// ==========================================================================
// 보상 생성
// ==========================================================================
/// 장비 보상 생성
ChestReward _generateEquipmentReward(int playerLevel) {
// 랜덤 슬롯 선택
final slotIndex = _rng.nextInt(EquipmentSlot.values.length);
final slot = EquipmentSlot.values[slotIndex];
// 희귀도 결정 (상자는 좀 더 좋은 확률)
final rarity = _rollChestRarity();
// 아이템 레벨: 플레이어 레벨 ±2
final minLevel = (playerLevel - 2).clamp(1, 999);
final maxLevel = playerLevel + 2;
final itemLevel = minLevel + _rng.nextInt(maxLevel - minLevel + 1);
// 아이템 생성
final item = EquipmentItem(
name: _generateItemName(slot, rarity, itemLevel),
slot: slot,
level: itemLevel,
weight: _calculateWeight(slot, itemLevel),
stats: _generateItemStats(slot, itemLevel, rarity),
rarity: rarity,
);
debugPrint('[ChestService] Equipment reward: ${item.name} (${rarity.name})');
return ChestReward.equipment(item);
}
/// 포션 보상 생성
ChestReward _generatePotionReward(int playerLevel) {
// 레벨에 맞는 티어 선택
final tier = PotionData.tierForLevel(playerLevel);
// HP/MP 랜덤 선택
final isHp = _rng.nextInt(2) == 0;
final potion = isHp
? PotionData.getHpPotionByTier(tier)
: PotionData.getMpPotionByTier(tier);
if (potion == null) {
// 폴백: 기본 포션
return ChestReward.potion('minor_health_patch', 1);
}
// 수량 결정
final count =
_minPotionCount + _rng.nextInt(_maxPotionCount - _minPotionCount + 1);
debugPrint('[ChestService] Potion reward: ${potion.name} x$count');
return ChestReward.potion(potion.id, count);
}
/// 골드 보상 생성
ChestReward _generateGoldReward(int playerLevel) {
final baseGold = playerLevel * _goldPerLevel;
final variance = _rng.nextInt(_goldVariance * 2 + 1) - _goldVariance;
final gold = (baseGold + (baseGold * variance / 100)).round().clamp(10, 99999);
debugPrint('[ChestService] Gold reward: $gold');
return ChestReward.gold(gold);
}
/// 경험치 보상 생성
ChestReward _generateExperienceReward(int playerLevel) {
final baseExp = playerLevel * _expPerLevel;
final variance = _rng.nextInt(_expVariance * 2 + 1) - _expVariance;
final exp = (baseExp + (baseExp * variance / 100)).round().clamp(10, 999999);
debugPrint('[ChestService] Experience reward: $exp');
return ChestReward.experience(exp);
}
// ==========================================================================
// 헬퍼 메서드
// ==========================================================================
/// 상자 희귀도 롤 (일반 샵보다 좋은 확률)
/// Common 50%, Uncommon 30%, Rare 15%, Epic 4%, Legendary 1%
ItemRarity _rollChestRarity() {
final roll = _rng.nextInt(100);
if (roll < 50) return ItemRarity.common;
if (roll < 80) return ItemRarity.uncommon;
if (roll < 95) return ItemRarity.rare;
if (roll < 99) return ItemRarity.epic;
return ItemRarity.legendary;
}
/// 아이템 이름 생성
String _generateItemName(EquipmentSlot slot, ItemRarity rarity, int level) {
final prefix = _getRarityPrefix(rarity);
final baseName = _getSlotBaseName(slot);
final suffix = level > 10 ? ' +${level ~/ 10}' : '';
return '$prefix$baseName$suffix'.trim();
}
String _getRarityPrefix(ItemRarity rarity) {
return switch (rarity) {
ItemRarity.common => '',
ItemRarity.uncommon => 'Fine ',
ItemRarity.rare => 'Superior ',
ItemRarity.epic => 'Epic ',
ItemRarity.legendary => 'Legendary ',
};
}
String _getSlotBaseName(EquipmentSlot slot) {
return switch (slot) {
EquipmentSlot.weapon => 'Keyboard',
EquipmentSlot.shield => 'Firewall Shield',
EquipmentSlot.helm => 'Neural Headset',
EquipmentSlot.hauberk => 'Server Rack Armor',
EquipmentSlot.brassairts => 'Cable Brassairts',
EquipmentSlot.vambraces => 'USB Vambraces',
EquipmentSlot.gauntlets => 'Typing Gauntlets',
EquipmentSlot.gambeson => 'Padded Gambeson',
EquipmentSlot.cuisses => 'Circuit Cuisses',
EquipmentSlot.greaves => 'Copper Greaves',
EquipmentSlot.sollerets => 'Static Boots',
};
}
/// 스탯 생성
ItemStats _generateItemStats(
EquipmentSlot slot,
int level,
ItemRarity rarity,
) {
final multiplier = rarity.multiplier;
final baseValue = (level * multiplier).round();
return switch (slot) {
EquipmentSlot.weapon => ItemStats(
atk: baseValue * 2,
criRate: 0.01 * (level ~/ 5),
parryRate: 0.005 * level,
),
EquipmentSlot.shield => ItemStats(
def: baseValue,
blockRate: 0.02 * (level ~/ 3).clamp(1, 10),
),
EquipmentSlot.helm => ItemStats(
def: baseValue ~/ 2,
magDef: baseValue ~/ 2,
intBonus: level ~/ 10,
),
EquipmentSlot.hauberk => ItemStats(def: baseValue, hpBonus: level * 2),
EquipmentSlot.brassairts => ItemStats(
def: baseValue ~/ 2,
strBonus: level ~/ 15,
),
EquipmentSlot.vambraces => ItemStats(
def: baseValue ~/ 2,
dexBonus: level ~/ 15,
),
EquipmentSlot.gauntlets => ItemStats(
atk: baseValue ~/ 2,
def: baseValue ~/ 4,
),
EquipmentSlot.gambeson => ItemStats(
def: baseValue ~/ 2,
conBonus: level ~/ 15,
),
EquipmentSlot.cuisses => ItemStats(
def: baseValue ~/ 2,
evasion: 0.005 * level,
),
EquipmentSlot.greaves => ItemStats(
def: baseValue ~/ 2,
evasion: 0.003 * level,
),
EquipmentSlot.sollerets => ItemStats(
def: baseValue ~/ 3,
evasion: 0.002 * level,
dexBonus: level ~/ 20,
),
};
}
/// 무게 계산
int _calculateWeight(EquipmentSlot slot, int level) {
final baseWeight = switch (slot) {
EquipmentSlot.weapon => 5,
EquipmentSlot.shield => 8,
EquipmentSlot.helm => 4,
EquipmentSlot.hauberk => 15,
EquipmentSlot.brassairts => 3,
EquipmentSlot.vambraces => 3,
EquipmentSlot.gauntlets => 2,
EquipmentSlot.gambeson => 6,
EquipmentSlot.cuisses => 5,
EquipmentSlot.greaves => 4,
EquipmentSlot.sollerets => 3,
};
return baseWeight + (level ~/ 10);
}
}

View File

@@ -5,6 +5,8 @@ import 'package:flutter/foundation.dart';
import 'package:in_app_purchase/in_app_purchase.dart'; import 'package:in_app_purchase/in_app_purchase.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
/// IAP 상품 ID /// IAP 상품 ID
class IAPProductIds { class IAPProductIds {
IAPProductIds._(); IAPProductIds._();
@@ -193,7 +195,15 @@ class IAPService {
/// 광고 제거 상품 가격 문자열 /// 광고 제거 상품 가격 문자열
String get removeAdsPrice { String get removeAdsPrice {
return _removeAdsProduct?.price ?? '\$9.99'; if (_removeAdsProduct != null) {
return _removeAdsProduct!.price;
}
// 스토어 미연결 시 로케일별 대체 가격
return switch (game_l10n.currentGameLocale) {
'ko' => '₩9,900',
'ja' => '¥990',
_ => '\$9.99',
};
} }
// =========================================================================== // ===========================================================================

View File

@@ -89,6 +89,14 @@ class ProgressLoop {
_speedMultiplier = _availableSpeeds[nextIndex]; _speedMultiplier = _availableSpeeds[nextIndex];
} }
/// 특정 배속으로 직접 설정
/// 가용 배속 목록에 있는 경우에만 설정
void setSpeed(int speed) {
if (_availableSpeeds.contains(speed)) {
_speedMultiplier = speed;
}
}
/// 가용 배속 목록 업데이트 (명예의 전당 상태 변경 시) /// 가용 배속 목록 업데이트 (명예의 전당 상태 변경 시)
void updateAvailableSpeeds(List<int> speeds) { void updateAvailableSpeeds(List<int> speeds) {
if (speeds.isEmpty) return; if (speeds.isEmpty) return;

View File

@@ -1,41 +1,19 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:asciineverdie/src/core/engine/ad_service.dart'; import 'package:asciineverdie/src/core/engine/ad_service.dart';
import 'package:asciineverdie/src/core/engine/chest_service.dart';
import 'package:asciineverdie/src/core/engine/iap_service.dart'; import 'package:asciineverdie/src/core/engine/iap_service.dart';
import 'package:asciineverdie/src/core/model/treasure_chest.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) /// 복귀 보상 서비스 (Phase 7)
/// ///
/// 플레이어가 게임에 복귀했을 때 떠나있던 시간에 따라 보상을 제공합니다. /// 플레이어가 게임에 복귀했을 때 떠나있던 시간에 따라 보물 상자를 제공합니다.
/// - 최소 복귀 시간: 1시간 /// - 최소 복귀 시간: 1시간
/// - 최대 복귀 시간: 24시간 (그 이상은 24시간으로 계산) /// - 최대 복귀 시간: 24시간 (그 이상은 24시간으로 계산)
/// - 기본 보상: 시간당 100골드 /// - 기본 보상: 4시간당 1상자
/// - 보너스 보상: 광고 시청 시 2배 /// - 보너스 보상: 광고 시청 시 상자 2배
class ReturnRewardsService { class ReturnRewardsService {
ReturnRewardsService._(); ReturnRewardsService._() : _chestService = ChestService();
static ReturnRewardsService? _instance; static ReturnRewardsService? _instance;
@@ -45,6 +23,8 @@ class ReturnRewardsService {
return _instance!; return _instance!;
} }
final ChestService _chestService;
// =========================================================================== // ===========================================================================
// 상수 // 상수
// =========================================================================== // ===========================================================================
@@ -55,11 +35,14 @@ class ReturnRewardsService {
/// 최대 복귀 시간 (시간) - 이 이상은 동일 보상 /// 최대 복귀 시간 (시간) - 이 이상은 동일 보상
static const int maxHoursAway = 24; static const int maxHoursAway = 24;
/// 시간당 골드 보상 /// 상자 1개당 필요 시간 (시간)
static const int goldPerHour = 100; static const int hoursPerChest = 4;
/// 레벨당 골드 보상 배수 /// 최대 상자 개수 (무료 유저)
static const double goldPerLevelMultiplier = 0.1; static const int maxChestsFree = 5;
/// 최대 상자 개수 (유료 유저)
static const int maxChestsPaid = 10;
// =========================================================================== // ===========================================================================
// 보상 계산 // 보상 계산
@@ -69,17 +52,21 @@ class ReturnRewardsService {
/// ///
/// [lastPlayTime] 마지막 플레이 시각 (null이면 보상 없음) /// [lastPlayTime] 마지막 플레이 시각 (null이면 보상 없음)
/// [currentTime] 현재 시각 /// [currentTime] 현재 시각
/// [playerLevel] 플레이어 레벨 (레벨 보너스 계산용) /// [isPaidUser] 유료 유저 여부 (최대 상자 개수 결정)
/// Returns: 복귀 보상 데이터 (시간이 부족하면 hasReward = false) /// Returns: 복귀 보상 데이터 (시간이 부족하면 hasReward = false)
ReturnReward calculateReward({ ReturnChestReward calculateReward({
required DateTime? lastPlayTime, required DateTime? lastPlayTime,
required DateTime currentTime, required DateTime currentTime,
required int playerLevel, required bool isPaidUser,
}) { }) {
// 마지막 플레이 시간이 없으면 보상 없음 // 마지막 플레이 시간이 없으면 보상 없음
if (lastPlayTime == null) { if (lastPlayTime == null) {
debugPrint('[ReturnRewards] No lastPlayTime, no reward'); debugPrint('[ReturnRewards] No lastPlayTime, no reward');
return const ReturnReward(hoursAway: 0, goldReward: 0, bonusGold: 0); return const ReturnChestReward(
hoursAway: 0,
chestCount: 0,
bonusChestCount: 0,
);
} }
// 경과 시간 계산 // 경과 시간 계산
@@ -89,29 +76,46 @@ class ReturnRewardsService {
// 최소 시간 미만이면 보상 없음 // 최소 시간 미만이면 보상 없음
if (hoursAway < minHoursAway) { if (hoursAway < minHoursAway) {
debugPrint('[ReturnRewards] Only $hoursAway hours, need $minHoursAway'); debugPrint('[ReturnRewards] Only $hoursAway hours, need $minHoursAway');
return ReturnReward(hoursAway: hoursAway, goldReward: 0, bonusGold: 0); return ReturnChestReward(
hoursAway: hoursAway,
chestCount: 0,
bonusChestCount: 0,
);
} }
// 최대 시간 초과 시 최대로 제한 // 최대 시간 초과 시 최대로 제한
final effectiveHours = hoursAway > maxHoursAway ? maxHoursAway : hoursAway; final effectiveHours = hoursAway > maxHoursAway ? maxHoursAway : hoursAway;
// 골드 보상 계산 (레벨 보너스 포함) // 상자 개수 계산
final levelMultiplier = 1.0 + (playerLevel * goldPerLevelMultiplier); final maxChests = isPaidUser ? maxChestsPaid : maxChestsFree;
final baseGold = (effectiveHours * goldPerHour * levelMultiplier).round(); final rawChestCount = effectiveHours ~/ hoursPerChest;
final chestCount = rawChestCount.clamp(0, maxChests);
// 보너스 골드 (광고 시청 시 100% 추가) // 보너스 상자 (광고 시청 시 동일 개수 추가)
final bonusGold = baseGold; final bonusChestCount = chestCount;
debugPrint('[ReturnRewards] $hoursAway hours away, ' debugPrint('[ReturnRewards] $hoursAway hours away, '
'base=$baseGold, bonus=$bonusGold, level=$playerLevel'); 'chests=$chestCount, bonus=$bonusChestCount, paid=$isPaidUser');
return ReturnReward( return ReturnChestReward(
hoursAway: hoursAway, hoursAway: hoursAway,
goldReward: baseGold, chestCount: chestCount,
bonusGold: bonusGold, bonusChestCount: bonusChestCount,
); );
} }
// ===========================================================================
// 상자 오픈
// ===========================================================================
/// 상자 오픈하여 보상 생성
///
/// [count] 오픈할 상자 개수
/// [playerLevel] 플레이어 레벨 (보상 스케일링용)
List<ChestReward> openChests(int count, int playerLevel) {
return _chestService.openMultipleChests(count, playerLevel);
}
// =========================================================================== // ===========================================================================
// 보상 수령 // 보상 수령
// =========================================================================== // ===========================================================================
@@ -119,11 +123,12 @@ class ReturnRewardsService {
/// 기본 보상 수령 (광고 없이) /// 기본 보상 수령 (광고 없이)
/// ///
/// [reward] 복귀 보상 데이터 /// [reward] 복귀 보상 데이터
/// Returns: 수령한 골드 양 /// [playerLevel] 플레이어 레벨
int claimBasicReward(ReturnReward reward) { /// Returns: 오픈된 상자 보상 목록
if (!reward.hasReward) return 0; List<ChestReward> claimBasicReward(ReturnChestReward reward, int playerLevel) {
debugPrint('[ReturnRewards] Basic reward claimed: ${reward.goldReward}'); if (!reward.hasReward) return [];
return reward.goldReward; debugPrint('[ReturnRewards] Basic reward claimed: ${reward.chestCount} chests');
return openChests(reward.chestCount, playerLevel);
} }
/// 보너스 보상 수령 (광고 시청 후) /// 보너스 보상 수령 (광고 시청 후)
@@ -131,32 +136,38 @@ class ReturnRewardsService {
/// 유료 유저: 무료 보너스 /// 유료 유저: 무료 보너스
/// 무료 유저: 리워드 광고 시청 후 보너스 /// 무료 유저: 리워드 광고 시청 후 보너스
/// [reward] 복귀 보상 데이터 /// [reward] 복귀 보상 데이터
/// Returns: 수령한 보너스 골드 양 (광고 실패 시 0) /// [playerLevel] 플레이어 레벨
Future<int> claimBonusReward(ReturnReward reward) async { /// Returns: 오픈된 보너스 상자 보상 목록 (광고 실패 시 빈 목록)
if (!reward.hasReward || reward.bonusGold <= 0) return 0; Future<List<ChestReward>> claimBonusReward(
ReturnChestReward reward,
int playerLevel,
) async {
if (!reward.hasReward || reward.bonusChestCount <= 0) return [];
// 유료 유저는 무료 보너스 // 유료 유저는 무료 보너스
if (IAPService.instance.isAdRemovalPurchased) { if (IAPService.instance.isAdRemovalPurchased) {
debugPrint('[ReturnRewards] Bonus claimed (paid user): ${reward.bonusGold}'); debugPrint('[ReturnRewards] Bonus claimed (paid user): '
return reward.bonusGold; '${reward.bonusChestCount} chests');
return openChests(reward.bonusChestCount, playerLevel);
} }
// 무료 유저는 리워드 광고 필요 // 무료 유저는 리워드 광고 필요
int bonus = 0; List<ChestReward> bonusRewards = [];
final adResult = await AdService.instance.showRewardedAd( final adResult = await AdService.instance.showRewardedAd(
adType: AdType.rewardRevive, // 복귀 보상용 리워드 광고 adType: AdType.rewardRevive, // 복귀 보상용 리워드 광고
onRewarded: () { onRewarded: () {
bonus = reward.bonusGold; bonusRewards = openChests(reward.bonusChestCount, playerLevel);
}, },
); );
if (adResult == AdResult.completed || adResult == AdResult.debugSkipped) { if (adResult == AdResult.completed || adResult == AdResult.debugSkipped) {
debugPrint('[ReturnRewards] Bonus claimed (free user with ad): $bonus'); debugPrint('[ReturnRewards] Bonus claimed (free user with ad): '
return bonus; '${bonusRewards.length} chests');
return bonusRewards;
} }
debugPrint('[ReturnRewards] Bonus claim failed: $adResult'); debugPrint('[ReturnRewards] Bonus claim failed: $adResult');
return 0; return [];
} }
// =========================================================================== // ===========================================================================

View File

@@ -0,0 +1,115 @@
import 'package:asciineverdie/src/core/model/equipment_item.dart';
/// 상자 보상 타입
enum ChestRewardType {
/// 장비 아이템
equipment,
/// 포션
potion,
/// 골드
gold,
/// 경험치
experience,
}
/// 상자 내용물 (개봉 결과)
class ChestReward {
const ChestReward._({
required this.type,
this.equipment,
this.potionId,
this.potionCount,
this.gold,
this.experience,
});
/// 장비 보상 생성
factory ChestReward.equipment(EquipmentItem item) {
return ChestReward._(
type: ChestRewardType.equipment,
equipment: item,
);
}
/// 포션 보상 생성
factory ChestReward.potion(String potionId, int count) {
return ChestReward._(
type: ChestRewardType.potion,
potionId: potionId,
potionCount: count,
);
}
/// 골드 보상 생성
factory ChestReward.gold(int amount) {
return ChestReward._(
type: ChestRewardType.gold,
gold: amount,
);
}
/// 경험치 보상 생성
factory ChestReward.experience(int amount) {
return ChestReward._(
type: ChestRewardType.experience,
experience: amount,
);
}
/// 보상 타입
final ChestRewardType type;
/// 장비 (type == equipment일 때)
final EquipmentItem? equipment;
/// 포션 ID (type == potion일 때)
final String? potionId;
/// 포션 수량 (type == potion일 때)
final int? potionCount;
/// 골드 (type == gold일 때)
final int? gold;
/// 경험치 (type == experience일 때)
final int? experience;
/// 장비 보상인지 여부
bool get isEquipment => type == ChestRewardType.equipment;
/// 포션 보상인지 여부
bool get isPotion => type == ChestRewardType.potion;
/// 골드 보상인지 여부
bool get isGold => type == ChestRewardType.gold;
/// 경험치 보상인지 여부
bool get isExperience => type == ChestRewardType.experience;
}
/// 복귀 보상 상자 데이터
class ReturnChestReward {
const ReturnChestReward({
required this.hoursAway,
required this.chestCount,
required this.bonusChestCount,
});
/// 떠나있던 시간 (시간 단위)
final int hoursAway;
/// 기본 상자 개수
final int chestCount;
/// 보너스 상자 개수 (광고 시청 시 추가)
final int bonusChestCount;
/// 총 상자 개수 (광고 포함)
int get totalChests => chestCount + bonusChestCount;
/// 보상이 있는지 여부
bool get hasReward => chestCount > 0;
}

View File

@@ -1,11 +1,9 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
/// 앱 설정 저장소 (SharedPreferences 기반) /// 앱 설정 저장소 (SharedPreferences 기반)
/// ///
/// 테마, 언어, 사운드 등 사용자 설정을 로컬에 저장 /// 언어, 사운드 등 사용자 설정을 로컬에 저장
class SettingsRepository { class SettingsRepository {
static const _keyThemeMode = 'theme_mode';
static const _keyLocale = 'locale'; static const _keyLocale = 'locale';
static const _keyBgmVolume = 'bgm_volume'; static const _keyBgmVolume = 'bgm_volume';
static const _keySfxVolume = 'sfx_volume'; static const _keySfxVolume = 'sfx_volume';
@@ -18,29 +16,6 @@ class SettingsRepository {
_prefs ??= await SharedPreferences.getInstance(); _prefs ??= await SharedPreferences.getInstance();
} }
/// 테마 모드 저장
Future<void> saveThemeMode(ThemeMode mode) async {
await init();
final value = switch (mode) {
ThemeMode.light => 'light',
ThemeMode.dark => 'dark',
ThemeMode.system => 'system',
};
await _prefs!.setString(_keyThemeMode, value);
}
/// 테마 모드 불러오기
Future<ThemeMode> loadThemeMode() async {
await init();
final value = _prefs!.getString(_keyThemeMode);
return switch (value) {
'light' => ThemeMode.light,
'dark' => ThemeMode.dark,
'system' => ThemeMode.system,
_ => ThemeMode.system, // 기본값
};
}
/// 언어 설정 저장 /// 언어 설정 저장
Future<void> saveLocale(String locale) async { Future<void> saveLocale(String locale) async {
await init(); await init();

View File

@@ -2,6 +2,7 @@ import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart'; 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/data/game_text_l10n.dart' as game_l10n;
import 'package:asciineverdie/l10n/app_localizations.dart'; import 'package:asciineverdie/l10n/app_localizations.dart';
@@ -18,9 +19,13 @@ class FrontScreen extends StatefulWidget {
this.onHallOfFame, this.onHallOfFame,
this.onLocalArena, this.onLocalArena,
this.onSettings, this.onSettings,
this.onPurchaseRemoveAds,
this.onRestorePurchase,
this.hasSaveFile = false, this.hasSaveFile = false,
this.savedGamePreview, this.savedGamePreview,
this.hallOfFameCount = 0, this.hallOfFameCount = 0,
this.isAdRemovalPurchased = false,
this.removeAdsPrice,
this.routeObserver, this.routeObserver,
this.onRefresh, this.onRefresh,
}); });
@@ -40,6 +45,12 @@ class FrontScreen extends StatefulWidget {
/// "Settings" 버튼 클릭 시 호출 (언어, 테마, 사운드) /// "Settings" 버튼 클릭 시 호출 (언어, 테마, 사운드)
final void Function(BuildContext context)? onSettings; final void Function(BuildContext context)? onSettings;
/// "광고 제거" 구매 버튼 클릭 시 호출
final Future<void> Function(BuildContext context)? onPurchaseRemoveAds;
/// "구매 복원" 버튼 클릭 시 호출
final Future<void> Function(BuildContext context)? onRestorePurchase;
/// 세이브 파일 존재 여부 (새 캐릭터 시 경고용) /// 세이브 파일 존재 여부 (새 캐릭터 시 경고용)
final bool hasSaveFile; final bool hasSaveFile;
@@ -49,6 +60,12 @@ class FrontScreen extends StatefulWidget {
/// 명예의 전당 캐릭터 수 (아레나 활성화 조건: 2명 이상) /// 명예의 전당 캐릭터 수 (아레나 활성화 조건: 2명 이상)
final int hallOfFameCount; final int hallOfFameCount;
/// 광고 제거 구매 여부
final bool isAdRemovalPurchased;
/// 광고 제거 상품 가격 (null이면 스토어 비활성)
final String? removeAdsPrice;
/// RouteObserver (화면 복귀 시 갱신용) /// RouteObserver (화면 복귀 시 갱신용)
final RouteObserver<ModalRoute<void>>? routeObserver; final RouteObserver<ModalRoute<void>>? routeObserver;
@@ -132,8 +149,6 @@ class _FrontScreenState extends State<FrontScreen> with RouteAware {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const _RetroHeader(),
const SizedBox(height: 16),
const _AnimationPanel(), const _AnimationPanel(),
const SizedBox(height: 16), const SizedBox(height: 16),
_ActionButtons( _ActionButtons(
@@ -154,8 +169,17 @@ class _FrontScreenState extends State<FrontScreen> with RouteAware {
onSettings: widget.onSettings != null onSettings: widget.onSettings != null
? () => widget.onSettings!(context) ? () => widget.onSettings!(context)
: null, : null,
onPurchaseRemoveAds:
widget.onPurchaseRemoveAds != null
? () => widget.onPurchaseRemoveAds!(context)
: null,
onRestorePurchase: widget.onRestorePurchase != null
? () => widget.onRestorePurchase!(context)
: null,
savedGamePreview: widget.savedGamePreview, savedGamePreview: widget.savedGamePreview,
hallOfFameCount: widget.hallOfFameCount, 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 { class _AnimationPanel extends StatelessWidget {
const _AnimationPanel(); const _AnimationPanel();
@@ -238,8 +211,25 @@ class _AnimationPanel extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return RetroPanel( return RetroGoldPanel(
title: 'BATTLE', 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), padding: const EdgeInsets.all(8),
child: AspectRatio( child: AspectRatio(
aspectRatio: _getAspectRatio(), aspectRatio: _getAspectRatio(),
@@ -257,8 +247,12 @@ class _ActionButtons extends StatelessWidget {
this.onHallOfFame, this.onHallOfFame,
this.onLocalArena, this.onLocalArena,
this.onSettings, this.onSettings,
this.onPurchaseRemoveAds,
this.onRestorePurchase,
this.savedGamePreview, this.savedGamePreview,
this.hallOfFameCount = 0, this.hallOfFameCount = 0,
this.isAdRemovalPurchased = false,
this.removeAdsPrice,
}); });
final VoidCallback? onNewCharacter; final VoidCallback? onNewCharacter;
@@ -266,8 +260,12 @@ class _ActionButtons extends StatelessWidget {
final VoidCallback? onHallOfFame; final VoidCallback? onHallOfFame;
final VoidCallback? onLocalArena; final VoidCallback? onLocalArena;
final VoidCallback? onSettings; final VoidCallback? onSettings;
final VoidCallback? onPurchaseRemoveAds;
final VoidCallback? onRestorePurchase;
final SavedGamePreview? savedGamePreview; final SavedGamePreview? savedGamePreview;
final int hallOfFameCount; final int hallOfFameCount;
final bool isAdRemovalPurchased;
final String? removeAdsPrice;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -323,6 +321,24 @@ class _ActionButtons extends StatelessWidget {
onPressed: onSettings, onPressed: onSettings,
isPrimary: false, 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) { Widget build(BuildContext context) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 12), padding: const EdgeInsets.symmetric(vertical: 12),
child: Text( child: FutureBuilder<PackageInfo>(
game_l10n.copyrightText, future: PackageInfo.fromPlatform(),
textAlign: TextAlign.center, builder: (context, snapshot) {
style: const TextStyle( final version = snapshot.data?.version ?? '';
fontFamily: 'PressStart2P', final versionSuffix = version.isNotEmpty ? ' v$version' : '';
fontSize: 7, return Text(
color: RetroColors.textDisabled, '${game_l10n.copyrightText}$versionSuffix',
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 7,
color: RetroColors.textDisabled,
),
);
},
),
);
}
}
/// IAP 구매 버튼 (광고 제거)
class _IapPurchaseButton extends StatelessWidget {
const _IapPurchaseButton({
required this.price,
this.onPurchase,
this.onRestore,
});
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 Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 구매 버튼 (클릭 시 팝업)
Container(
decoration: BoxDecoration(
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(
children: [
const Icon(Icons.block, color: RetroColors.gold, size: 24),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
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 _RetroTag extends StatelessWidget { class _BenefitItem extends StatelessWidget {
const _RetroTag({required this.icon, required this.label}); const _BenefitItem({required this.icon, required this.text});
final IconData icon; final IconData icon;
final String label; final String text;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Row(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), children: [
decoration: BoxDecoration( Icon(icon, color: RetroColors.expGreen, size: 18),
color: RetroColors.panelBgLight, const SizedBox(width: 12),
border: Border.all(color: RetroColors.panelBorderInner, width: 1), Expanded(
), child: Text(
child: Row( text,
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: RetroColors.gold, size: 12),
const SizedBox(width: 6),
Text(
label,
style: const TextStyle( style: const TextStyle(
fontFamily: 'PressStart2P', fontFamily: 'PressStart2P',
fontSize: 11, fontSize: 10,
color: RetroColors.textLight, 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/game_text_l10n.dart' as game_l10n;
import 'package:asciineverdie/data/skill_data.dart'; import 'package:asciineverdie/data/skill_data.dart';
import 'package:asciineverdie/src/core/engine/iap_service.dart'; import 'package:asciineverdie/src/core/engine/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/data/story_data.dart';
import 'package:asciineverdie/l10n/app_localizations.dart'; import 'package:asciineverdie/l10n/app_localizations.dart';
import 'package:asciineverdie/src/core/animation/ascii_animation_type.dart'; import 'package:asciineverdie/src/core/animation/ascii_animation_type.dart';
@@ -51,8 +51,6 @@ class GamePlayScreen extends StatefulWidget {
this.audioService, this.audioService,
this.forceCarouselLayout = false, this.forceCarouselLayout = false,
this.forceDesktopLayout = false, this.forceDesktopLayout = false,
this.onThemeModeChange,
this.currentThemeMode = ThemeMode.system,
}); });
final GameSessionController controller; final GameSessionController controller;
@@ -66,12 +64,6 @@ class GamePlayScreen extends StatefulWidget {
/// 테스트 모드: 모바일에서도 데스크톱 3패널 레이아웃 강제 사용 /// 테스트 모드: 모바일에서도 데스크톱 3패널 레이아웃 강제 사용
final bool forceDesktopLayout; final bool forceDesktopLayout;
/// 테마 모드 변경 콜백
final void Function(ThemeMode mode)? onThemeModeChange;
/// 현재 테마 모드
final ThemeMode currentThemeMode;
@override @override
State<GamePlayScreen> createState() => _GamePlayScreenState(); State<GamePlayScreen> createState() => _GamePlayScreenState();
} }
@@ -316,8 +308,6 @@ class _GamePlayScreenState extends State<GamePlayScreen>
builder: (_) => GamePlayScreen( builder: (_) => GamePlayScreen(
controller: widget.controller, controller: widget.controller,
audioService: widget.audioService, audioService: widget.audioService,
currentThemeMode: widget.currentThemeMode,
onThemeModeChange: widget.onThemeModeChange,
), ),
), ),
); );
@@ -407,15 +397,19 @@ class _GamePlayScreenState extends State<GamePlayScreen>
} }
/// 복귀 보상 다이얼로그 표시 (Phase 7) /// 복귀 보상 다이얼로그 표시 (Phase 7)
void _showReturnRewardsDialog(ReturnReward reward) { void _showReturnRewardsDialog(ReturnChestReward reward) {
// 잠시 후 다이얼로그 표시 (게임 시작 후) // 잠시 후 다이얼로그 표시 (게임 시작 후)
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; if (!mounted) return;
final state = widget.controller.state;
if (state == null) return;
ReturnRewardsDialog.show( ReturnRewardsDialog.show(
context, context,
reward: reward, reward: reward,
onClaim: (totalGold) { playerLevel: state.traits.level,
widget.controller.applyReturnReward(totalGold); onClaim: (rewards) {
widget.controller.applyReturnReward(rewards);
}, },
); );
}); });
@@ -436,10 +430,6 @@ class _GamePlayScreenState extends State<GamePlayScreen>
SettingsScreen.show( SettingsScreen.show(
context, context,
settingsRepository: settingsRepo, settingsRepository: settingsRepo,
currentThemeMode: widget.currentThemeMode,
onThemeModeChange: (mode) {
widget.onThemeModeChange?.call(mode);
},
onLocaleChange: (locale) async { onLocaleChange: (locale) async {
// 안전한 언어 변경: 전체 화면 재생성 // 안전한 언어 변경: 전체 화면 재생성
final navigator = Navigator.of(this.context); final navigator = Navigator.of(this.context);
@@ -452,8 +442,6 @@ class _GamePlayScreenState extends State<GamePlayScreen>
builder: (_) => GamePlayScreen( builder: (_) => GamePlayScreen(
controller: widget.controller, controller: widget.controller,
audioService: widget.audioService, audioService: widget.audioService,
currentThemeMode: widget.currentThemeMode,
onThemeModeChange: widget.onThemeModeChange,
), ),
), ),
); );
@@ -586,6 +574,10 @@ class _GamePlayScreenState extends State<GamePlayScreen>
widget.controller.loop?.cycleSpeed(); widget.controller.loop?.cycleSpeed();
setState(() {}); setState(() {});
}, },
onSetSpeed: (speed) {
widget.controller.loop?.setSpeed(speed);
setState(() {});
},
// 특수 애니메이션 중에는 일시정지 상태로 표시하지 않음 // 특수 애니메이션 중에는 일시정지 상태로 표시하지 않음
isPaused: isPaused:
!widget.controller.isRunning && _specialAnimation == null, !widget.controller.isRunning && _specialAnimation == null,
@@ -620,8 +612,6 @@ class _GamePlayScreenState extends State<GamePlayScreen>
builder: (_) => GamePlayScreen( builder: (_) => GamePlayScreen(
controller: widget.controller, controller: widget.controller,
audioService: widget.audioService, audioService: widget.audioService,
currentThemeMode: widget.currentThemeMode,
onThemeModeChange: widget.onThemeModeChange,
), ),
), ),
); );
@@ -637,8 +627,6 @@ class _GamePlayScreenState extends State<GamePlayScreen>
Navigator.of(context).pop(); Navigator.of(context).pop();
} }
}, },
currentThemeMode: widget.currentThemeMode,
onThemeModeChange: widget.onThemeModeChange,
// 사운드 설정 // 사운드 설정
bgmVolume: _audioController.bgmVolume, bgmVolume: _audioController.bgmVolume,
sfxVolume: _audioController.sfxVolume, sfxVolume: _audioController.sfxVolume,
@@ -666,11 +654,13 @@ class _GamePlayScreenState extends State<GamePlayScreen>
navigator.popUntil((route) => route.isFirst); navigator.popUntil((route) => route.isFirst);
} }
}, },
// 수익화 버프 (자동부활, 5배속) // 수익화 버프 (자동부활, 광고배속)
autoReviveEndMs: widget.controller.monetization.autoReviveEndMs, autoReviveEndMs: widget.controller.monetization.autoReviveEndMs,
speedBoostEndMs: widget.controller.monetization.speedBoostEndMs, speedBoostEndMs: widget.controller.monetization.speedBoostEndMs,
isPaidUser: widget.controller.monetization.isPaidUser, isPaidUser: widget.controller.monetization.isPaidUser,
onSpeedBoostActivate: _handleSpeedBoost, onSpeedBoostActivate: _handleSpeedBoost,
adSpeedMultiplier: widget.controller.adSpeedMultiplier,
has2xUnlocked: widget.controller.has2xUnlocked,
), ),
// 사망 오버레이 // 사망 오버레이
if (state.isDead && state.deathInfo != null) 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/game_statistics.dart';
import 'package:asciineverdie/src/core/model/hall_of_fame.dart'; import 'package:asciineverdie/src/core/model/hall_of_fame.dart';
import 'package:asciineverdie/src/core/model/monetization_state.dart'; import 'package:asciineverdie/src/core/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/hall_of_fame_storage.dart';
import 'package:asciineverdie/src/core/storage/save_manager.dart'; import 'package:asciineverdie/src/core/storage/save_manager.dart';
import 'package:asciineverdie/src/core/storage/statistics_storage.dart'; import 'package:asciineverdie/src/core/storage/statistics_storage.dart';
@@ -64,14 +65,16 @@ class GameSessionController extends ChangeNotifier {
Timer? _speedBoostTimer; Timer? _speedBoostTimer;
int _speedBoostRemainingSeconds = 0; int _speedBoostRemainingSeconds = 0;
static const int _speedBoostDuration = 300; // 5분 static const int _speedBoostDuration = 300; // 5분
static const int _speedBoostMultiplier = 5; // 5x 속도
/// 광고 배속 배율 (릴리즈: 5x, 디버그빌드+디버그모드: 20x)
int get _speedBoostMultiplier => (kDebugMode && _cheatsEnabled) ? 20 : 5;
// 복귀 보상 상태 (Phase 7) // 복귀 보상 상태 (Phase 7)
MonetizationState _monetization = MonetizationState.initial(); MonetizationState _monetization = MonetizationState.initial();
ReturnReward? _pendingReturnReward; ReturnChestReward? _pendingReturnReward;
/// 복귀 보상 콜백 (UI에서 다이얼로그 표시용) /// 복귀 보상 콜백 (UI에서 다이얼로그 표시용)
void Function(ReturnReward reward)? onReturnRewardAvailable; void Function(ReturnChestReward reward)? onReturnRewardAvailable;
// 통계 관련 필드 // 통계 관련 필드
SessionStatistics _sessionStats = SessionStatistics.empty(); SessionStatistics _sessionStats = SessionStatistics.empty();
@@ -105,6 +108,12 @@ class GameSessionController extends ChangeNotifier {
/// 현재 ProgressLoop 인스턴스 (치트 기능용) /// 현재 ProgressLoop 인스턴스 (치트 기능용)
ProgressLoop? get loop => _loop; ProgressLoop? get loop => _loop;
/// 광고 배속 배율 (릴리즈: 5x, 디버그빌드+디버그모드: 20x)
int get adSpeedMultiplier => _speedBoostMultiplier;
/// 2x 배속 해금 여부 (명예의 전당에 캐릭터가 있으면 true)
bool get has2xUnlocked => _loop?.availableSpeeds.contains(2) ?? false;
Future<void> startNew( Future<void> startNew(
GameState initialState, { GameState initialState, {
bool cheatsEnabled = false, 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 { Future<List<int>> _getAvailableSpeeds() async {
if (_cheatsEnabled) { final hallOfFame = await _hallOfFameStorage.load();
return [1, 2, 20]; 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; MonetizationState get monetization => _monetization;
/// 대기 중인 복귀 보상 /// 대기 중인 복귀 보상
ReturnReward? get pendingReturnReward => _pendingReturnReward; ReturnChestReward? get pendingReturnReward => _pendingReturnReward;
/// 복귀 보상 체크 (로드 시 호출) /// 복귀 보상 체크 (로드 시 호출)
void _checkReturnRewards(GameState loaded) { void _checkReturnRewards(GameState loaded) {
@@ -715,17 +727,17 @@ class GameSessionController extends ChangeNotifier {
final reward = rewardsService.calculateReward( final reward = rewardsService.calculateReward(
lastPlayTime: lastPlayTime, lastPlayTime: lastPlayTime,
currentTime: DateTime.now(), currentTime: DateTime.now(),
playerLevel: loaded.traits.level, isPaidUser: _monetization.isPaidUser,
); );
if (reward.hasReward) { if (reward.hasReward) {
_pendingReturnReward = reward; _pendingReturnReward = reward;
debugPrint('[ReturnRewards] Reward available: ${reward.goldReward} gold, ' debugPrint('[ReturnRewards] Reward available: ${reward.chestCount} chests, '
'${reward.hoursAway} hours away'); '${reward.hoursAway} hours away');
// UI에서 다이얼로그 표시를 위해 콜백 호출 // UI에서 다이얼로그 표시를 위해 콜백 호출
// startNew 후에 호출하도록 딜레이 // startNew 후에 호출하도록 딜레이
Future.delayed(const Duration(milliseconds: 500), () { Future<void>.delayed(const Duration(milliseconds: 500), () {
if (_pendingReturnReward != null) { if (_pendingReturnReward != null) {
onReturnRewardAvailable?.call(_pendingReturnReward!); onReturnRewardAvailable?.call(_pendingReturnReward!);
} }
@@ -733,23 +745,86 @@ class GameSessionController extends ChangeNotifier {
} }
} }
/// 복귀 보상 수령 완료 (골드 적용) /// 복귀 보상 수령 완료 (상자 보상 적용)
/// ///
/// [totalGold] 수령한 총 골드 (기본 + 보너스) /// [rewards] 오픈된 상자 보상 목록
void applyReturnReward(int totalGold) { void applyReturnReward(List<ChestReward> rewards) {
if (_state == null) return; if (_state == null) return;
if (totalGold <= 0) { if (rewards.isEmpty) {
// 보상 없이 건너뛴 경우 // 보상 없이 건너뛴 경우
_pendingReturnReward = null; _pendingReturnReward = null;
debugPrint('[ReturnRewards] Reward skipped'); debugPrint('[ReturnRewards] Reward skipped');
return; return;
} }
// 골드 추가 var updatedState = _state!;
final updatedInventory = _state!.inventory.copyWith(
gold: _state!.inventory.gold + totalGold, // 보상 적용
); for (final reward in rewards) {
_state = _state!.copyWith(inventory: updatedInventory); 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!,
),
);
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( unawaited(saveManager.saveState(
@@ -761,7 +836,7 @@ class GameSessionController extends ChangeNotifier {
_pendingReturnReward = null; _pendingReturnReward = null;
notifyListeners(); 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.speedBoostEndMs,
this.isPaidUser = false, this.isPaidUser = false,
this.onSpeedBoostActivate, this.onSpeedBoostActivate,
this.adSpeedMultiplier = 5,
}); });
final ProgressState progress; final ProgressState progress;
@@ -75,12 +76,15 @@ class EnhancedAnimationPanel extends StatefulWidget {
/// 5배속 버프 종료 시점 (elapsedMs 기준, null이면 비활성) /// 5배속 버프 종료 시점 (elapsedMs 기준, null이면 비활성)
final int? speedBoostEndMs; final int? speedBoostEndMs;
/// 유료 유저 여부 (5배속 항상 활성) /// 유료 유저 여부 (광고배속 항상 활성)
final bool isPaidUser; final bool isPaidUser;
/// 5배속 버프 활성화 콜백 (광고 시청) /// 광고 배속 활성화 콜백 (광고 시청)
final VoidCallback? onSpeedBoostActivate; final VoidCallback? onSpeedBoostActivate;
/// 광고 배속 배율 (릴리즈: 5x, 디버그빌드+디버그모드: 20x)
final int adSpeedMultiplier;
@override @override
State<EnhancedAnimationPanel> createState() => _EnhancedAnimationPanelState(); State<EnhancedAnimationPanel> createState() => _EnhancedAnimationPanelState();
} }
@@ -284,14 +288,14 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
color: Colors.green, color: Colors.green,
), ),
), ),
// 우상단: 5배속 버프 // 우상단: 광고배속 버프 (버프 활성 시에만)
if (_speedBoostRemainingMs > 0 || widget.isPaidUser) if (_speedBoostRemainingMs > 0 || widget.isPaidUser)
Positioned( Positioned(
right: 4, right: 4,
top: 4, top: 4,
child: _buildBuffChip( child: _buildBuffChip(
icon: '', icon: '',
label: '5x', label: '${widget.adSpeedMultiplier}x',
remainingMs: widget.isPaidUser ? -1 : _speedBoostRemainingMs, remainingMs: widget.isPaidUser ? -1 : _speedBoostRemainingMs,
color: Colors.orange, color: Colors.orange,
isPermanent: widget.isPaidUser, isPermanent: widget.isPaidUser,
@@ -303,7 +307,7 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
const SizedBox(height: 8), const SizedBox(height: 8),
// 상태 바 영역: HP/MP (40%) + 컨트롤 (20%) + 몬스터 HP (40%) // 상태 바 영역: HP/MP (40%) + 빈공간 (20%) + 몬스터 HP (40%)
SizedBox( SizedBox(
height: 48, height: 48,
child: Row( child: Row(
@@ -322,11 +326,8 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
), ),
), ),
// 중앙: 컨트롤 버튼 (20%) // 중앙: 빈 공간 (20%)
Expanded( const Spacer(flex: 1),
flex: 1,
child: _buildControlButtons(),
),
// 우측: 몬스터 HP (전투 중) 또는 빈 공간 (40%) // 우측: 몬스터 HP (전투 중) 또는 빈 공간 (40%)
Expanded( Expanded(
@@ -673,92 +674,85 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
); );
} }
/// 컨트롤 버튼 (중앙 영역) /// 속도 컨트롤 버튼 (태스크 프로그레스 바 우측)
Widget _buildControlButtons() { ///
return Column( /// - 일반배속: 1x (기본) ↔ 2x (명예의 전당 해금)
mainAxisAlignment: MainAxisAlignment.center, /// - 광고배속: 릴리즈 5x, 디버그빌드+디버그모드 20x
children: [ Widget _buildSpeedControls() {
// 상단: 속도 버튼 (1x ↔ 2x)
_buildCompactSpeedButton(),
const SizedBox(height: 2),
// 하단: 5x 광고 버튼 (2x일 때만 표시)
_buildAdSpeedButton(),
],
);
}
/// 컴팩트 속도 버튼 (1x ↔ 2x 사이클)
Widget _buildCompactSpeedButton() {
final isSpeedBoostActive = _speedBoostRemainingMs > 0 || widget.isPaidUser; final isSpeedBoostActive = _speedBoostRemainingMs > 0 || widget.isPaidUser;
final adSpeed = widget.adSpeedMultiplier;
return SizedBox( // 2x일 때 광고 버튼 표시 (버프 비활성이고 무료유저)
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 = final showAdButton =
widget.speedMultiplier == 2 && !isSpeedBoostActive && !widget.isPaidUser; widget.speedMultiplier == 2 && !isSpeedBoostActive && !widget.isPaidUser;
if (!showAdButton) { return Row(
return const SizedBox(height: 22); mainAxisSize: MainAxisSize.min,
} children: [
// 속도 사이클 버튼 (1x ↔ 2x, 버프 활성시 광고배속)
return SizedBox( SizedBox(
height: 22, width: 44,
child: OutlinedButton( height: 32,
onPressed: widget.onSpeedBoostActivate, child: OutlinedButton(
style: OutlinedButton.styleFrom( onPressed: widget.onSpeedCycle,
padding: const EdgeInsets.symmetric(horizontal: 8), style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact, padding: EdgeInsets.zero,
side: const BorderSide(color: Colors.orange), side: BorderSide(
), color: isSpeedBoostActive
child: const Row( ? Colors.orange
mainAxisSize: MainAxisSize.min, : widget.speedMultiplier > 1
children: [ ? Theme.of(context).colorScheme.primary
Text('', style: TextStyle(fontSize: 8, color: Colors.orange)), : Theme.of(context).colorScheme.outline,
SizedBox(width: 2), width: isSpeedBoostActive ? 2 : 1,
Text(
'5x',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Colors.orange,
), ),
), ),
], child: Text(
isSpeedBoostActive ? '${adSpeed}x' : '${widget.speedMultiplier}x',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: isSpeedBoostActive
? 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,
),
),
],
),
),
),
],
],
); );
} }
@@ -775,7 +769,7 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
return widget.progress.currentTask.caption; return widget.progress.currentTask.caption;
} }
/// 태스크 프로그레스 바 /// 태스크 프로그레스 바 + 속도 컨트롤
Widget _buildTaskProgress() { Widget _buildTaskProgress() {
final task = widget.progress.task; final task = widget.progress.task;
final progressValue = task.max > 0 final progressValue = task.max > 0
@@ -792,46 +786,56 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
? grade.displayColor ? grade.displayColor
: null; : null;
return Column( return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
// 캡션 (등급에 따른 접두사 및 색상) // 좌측: 캡션 + 프로그레스 바
Text.rich( Expanded(
TextSpan( child: Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
if (gradePrefix.isNotEmpty) // 캡션 (등급에 따른 접두사 및 색상)
Text.rich(
TextSpan( TextSpan(
text: gradePrefix, children: [
style: TextStyle( if (gradePrefix.isNotEmpty)
color: gradeColor, TextSpan(
fontWeight: FontWeight.bold, text: gradePrefix,
), style: TextStyle(
color: gradeColor,
fontWeight: FontWeight.bold,
),
),
TextSpan(
text: _getStatusMessage(),
style:
gradeColor != null ? TextStyle(color: gradeColor) : null,
),
],
), ),
TextSpan( style: Theme.of(context).textTheme.bodySmall,
text: _getStatusMessage(), textAlign: TextAlign.center,
style: gradeColor != null ? TextStyle(color: gradeColor) : null, maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
// 프로그레스 바
LinearProgressIndicator(
value: progressValue,
backgroundColor: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.2),
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),
minHeight: 10,
), ),
], ],
), ),
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
// 프로그레스 바
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: LinearProgressIndicator(
value: progressValue,
backgroundColor: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.2),
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),
minHeight: 10,
),
), ),
const SizedBox(width: 8),
// 우측: 속도 컨트롤
_buildSpeedControls(),
], ],
); );
} }

View File

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

View File

@@ -1,37 +1,45 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n; 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/iap_service.dart';
import 'package:asciineverdie/src/core/engine/return_rewards_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'; import 'package:asciineverdie/src/shared/retro_colors.dart';
/// 복귀 보상 다이얼로그 (Phase 7) /// 복귀 보상 다이얼로그 (Phase 7)
/// ///
/// 게임 복귀 시 보상을 표시하는 다이얼로그 /// 게임 복귀 시 보물 상자 보상을 표시하는 다이얼로그
class ReturnRewardsDialog extends StatefulWidget { class ReturnRewardsDialog extends StatefulWidget {
const ReturnRewardsDialog({ const ReturnRewardsDialog({
super.key, super.key,
required this.reward, required this.reward,
required this.playerLevel,
required this.onClaim, 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( static Future<void> show(
BuildContext context, { BuildContext context, {
required ReturnReward reward, required ReturnChestReward reward,
required void Function(int totalGold) onClaim, required int playerLevel,
required void Function(List<ChestReward> rewards) onClaim,
}) async { }) async {
return showDialog<void>( return showDialog<void>(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (context) => ReturnRewardsDialog( builder: (context) => ReturnRewardsDialog(
reward: reward, reward: reward,
playerLevel: playerLevel,
onClaim: onClaim, onClaim: onClaim,
), ),
); );
@@ -41,27 +49,50 @@ class ReturnRewardsDialog extends StatefulWidget {
State<ReturnRewardsDialog> createState() => _ReturnRewardsDialogState(); State<ReturnRewardsDialog> createState() => _ReturnRewardsDialogState();
} }
class _ReturnRewardsDialogState extends State<ReturnRewardsDialog> { class _ReturnRewardsDialogState extends State<ReturnRewardsDialog>
bool _basicClaimed = false; with SingleTickerProviderStateMixin {
bool _bonusClaimed = false;
bool _isClaimingBonus = false;
int _totalClaimed = 0;
final _rewardsService = ReturnRewardsService.instance; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final gold = RetroColors.goldOf(context); final gold = RetroColors.goldOf(context);
final goldDark = RetroColors.goldDarkOf(context);
final panelBg = RetroColors.panelBgOf(context); final panelBg = RetroColors.panelBgOf(context);
final borderColor = RetroColors.borderOf(context); final borderColor = RetroColors.borderOf(context);
final expColor = RetroColors.expOf(context);
final isPaidUser = IAPService.instance.isAdRemovalPurchased;
return Dialog( return Dialog(
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
child: Container( child: Container(
constraints: const BoxConstraints(maxWidth: 360), constraints: const BoxConstraints(maxWidth: 400),
decoration: BoxDecoration( decoration: BoxDecoration(
color: panelBg, color: panelBg,
border: Border( border: Border(
@@ -96,40 +127,41 @@ class _ReturnRewardsDialogState extends State<ReturnRewardsDialog> {
), ),
style: TextStyle( style: TextStyle(
fontFamily: 'PressStart2P', fontFamily: 'PressStart2P',
fontSize: 11, fontSize: 10,
color: gold.withValues(alpha: 0.8), color: gold.withValues(alpha: 0.8),
), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
// 기본 // 기본 상자 섹션
_buildRewardSection( _buildChestSection(
context, context,
title: l10n.returnRewardBasic, title: l10n.returnRewardChests(widget.reward.chestCount),
gold: widget.reward.goldReward, chestCount: widget.reward.chestCount,
color: gold, rewards: _basicRewards,
colorDark: goldDark, isOpened: _basicOpened,
claimed: _basicClaimed, isOpening: _isOpeningBasic,
onClaim: _claimBasic, onOpen: _openBasicChests,
buttonText: l10n.returnRewardClaim, isGold: true,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// 보너스 // 보너스 상자 섹션
_buildRewardSection( _buildChestSection(
context, context,
title: l10n.returnRewardBonus, title: l10n.returnRewardBonusChests,
gold: widget.reward.bonusGold, chestCount: widget.reward.bonusChestCount,
color: expColor, rewards: _bonusRewards,
colorDark: expColor.withValues(alpha: 0.6), isOpened: _bonusOpened,
claimed: _bonusClaimed, isOpening: _isOpeningBonus,
onClaim: _claimBonus, onOpen: _openBonusChests,
buttonText: l10n.returnRewardClaimBonus, isGold: false,
showAdIcon: !isPaidUser, enabled: _basicOpened && !_bonusOpened,
isLoading: _isClaimingBonus, showAdIcon: !IAPService.instance.isAdRemovalPurchased,
enabled: _basicClaimed && !_bonusClaimed,
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
// 완료/건너뛰기 버튼 // 완료/건너뛰기 버튼
@@ -154,7 +186,7 @@ class _ReturnRewardsDialogState extends State<ReturnRewardsDialog> {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Text('🎁', style: TextStyle(fontSize: 20, color: gold)), const Text('📦', style: TextStyle(fontSize: 20)),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
l10n.returnRewardTitle, l10n.returnRewardTitle,
@@ -166,32 +198,40 @@ class _ReturnRewardsDialogState extends State<ReturnRewardsDialog> {
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text('🎁', style: TextStyle(fontSize: 20, color: gold)), const Text('📦', style: TextStyle(fontSize: 20)),
], ],
), ),
); );
} }
Widget _buildRewardSection( Widget _buildChestSection(
BuildContext context, { BuildContext context, {
required String title, required String title,
required int gold, required int chestCount,
required Color color, required List<ChestReward> rewards,
required Color colorDark, required bool isOpened,
required bool claimed, required bool isOpening,
required VoidCallback onClaim, required VoidCallback onOpen,
required String buttonText, required bool isGold,
bool showAdIcon = false,
bool isLoading = false,
bool enabled = true, bool enabled = true,
bool showAdIcon = false,
}) { }) {
final gold = RetroColors.goldOf(context);
final expColor = RetroColors.expOf(context);
final muted = RetroColors.textMutedOf(context); final muted = RetroColors.textMutedOf(context);
final color = isGold ? gold : expColor;
final isPaidUser = IAPService.instance.isAdRemovalPurchased;
return Container( return Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: color.withValues(alpha: 0.1), 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( child: Column(
children: [ children: [
@@ -201,104 +241,111 @@ class _ReturnRewardsDialogState extends State<ReturnRewardsDialog> {
style: TextStyle( style: TextStyle(
fontFamily: 'PressStart2P', fontFamily: 'PressStart2P',
fontSize: 11, fontSize: 11,
color: color, color: (enabled || isOpened) ? color : muted,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 12),
// 골드 표시 // 상자 아이콘들 또는 보상 목록
Row( if (isOpened)
mainAxisAlignment: MainAxisAlignment.center, _buildRewardsList(context, rewards)
children: [ else
const Text('💰', style: TextStyle(fontSize: 20)), _buildChestIcons(chestCount, color, enabled),
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) ...[ if (!isOpened) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
// 수령 버튼 // 오픈 버튼
GestureDetector( GestureDetector(
onTap: enabled && !isLoading ? onClaim : null, onTap: enabled && !isOpening ? onOpen : null,
child: Container( child: AnimatedBuilder(
padding: const EdgeInsets.symmetric( animation: _shakeAnimation,
horizontal: 16, builder: (context, child) {
vertical: 8, return Transform.translate(
), offset: isOpening
decoration: BoxDecoration( ? Offset(
color: enabled _shakeAnimation.value * 2 *
? color.withValues(alpha: 0.3) ((_animController.value * 10).round() % 2 == 0
: muted.withValues(alpha: 0.2), ? 1
border: Border.all( : -1),
color: enabled ? color : muted, 0,
width: 2, )
: Offset.zero,
child: child,
);
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
), ),
), decoration: BoxDecoration(
child: Row( color: enabled
mainAxisSize: MainAxisSize.min, ? color.withValues(alpha: 0.3)
children: [ : muted.withValues(alpha: 0.2),
if (isLoading) ...[ border: Border.all(
SizedBox( color: enabled ? color : muted,
width: 16, width: 2,
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), child: Row(
Container( mainAxisSize: MainAxisSize.min,
padding: const EdgeInsets.symmetric( children: [
horizontal: 4, if (isOpening) ...[
vertical: 2, SizedBox(
), width: 14,
decoration: BoxDecoration( height: 14,
color: Colors.white.withValues(alpha: 0.2), child: CircularProgressIndicator(
borderRadius: BorderRadius.circular(4), strokeWidth: 2,
), color: color,
child: Text(
'AD',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 8,
color: enabled ? Colors.white : muted,
), ),
), ),
), const SizedBox(width: 8),
Text(
l10n.returnRewardOpening,
style: TextStyle(
fontFamily: 'PressStart2P',
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 && !isPaidUser) ...[
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,
),
),
),
],
],
], ],
], ),
), ),
), ),
), ),
@@ -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) { Widget _buildBottomButton(BuildContext context) {
final gold = RetroColors.goldOf(context); final gold = RetroColors.goldOf(context);
final goldDark = RetroColors.goldDarkOf(context); final goldDark = RetroColors.goldDarkOf(context);
final muted = RetroColors.textMutedOf(context); final muted = RetroColors.textMutedOf(context);
final canComplete = _basicClaimed; final canComplete = _basicOpened;
final buttonColor = canComplete ? gold : muted; final buttonColor = canComplete ? gold : muted;
final buttonDark = canComplete ? goldDark : muted.withValues(alpha: 0.5); final buttonDark = canComplete ? goldDark : muted.withValues(alpha: 0.5);
@@ -344,43 +497,70 @@ class _ReturnRewardsDialogState extends State<ReturnRewardsDialog> {
); );
} }
void _claimBasic() { Future<void> _openBasicChests() async {
if (_basicClaimed) return; if (_basicOpened || _isOpeningBasic) return;
final claimed = _rewardsService.claimBasicReward(widget.reward);
setState(() {
_basicClaimed = true;
_totalClaimed += claimed;
});
}
Future<void> _claimBonus() async {
if (_bonusClaimed || _isClaimingBonus) return;
setState(() { 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) { if (mounted) {
setState(() { setState(() {
_isClaimingBonus = false; _isOpeningBasic = false;
if (bonus > 0) { _basicOpened = true;
_bonusClaimed = true; _basicRewards = rewards;
_totalClaimed += bonus; });
}
}
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() { void _complete() {
widget.onClaim(_totalClaimed); final allRewards = [..._basicRewards, ..._bonusRewards];
widget.onClaim(allRewards);
Navigator.of(context).pop(); Navigator.of(context).pop();
} }
void _skip() { void _skip() {
widget.onClaim(0); widget.onClaim([]);
Navigator.of(context).pop(); Navigator.of(context).pop();
} }
} }

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,11 @@ class RetroPanel extends StatelessWidget {
this.borderWidth = 3.0, this.borderWidth = 3.0,
this.useGoldBorder = false, this.useGoldBorder = false,
this.title, this.title,
}); this.titleWidget,
}) : assert(
title == null || titleWidget == null,
'title과 titleWidget 중 하나만 사용 가능',
);
/// 패널 내부 컨텐츠 /// 패널 내부 컨텐츠
final Widget child; final Widget child;
@@ -34,6 +38,9 @@ class RetroPanel extends StatelessWidget {
/// 패널 타이틀 (상단에 표시) /// 패널 타이틀 (상단에 표시)
final String? title; final String? title;
/// 커스텀 타이틀 위젯 (title 대신 사용)
final Widget? titleWidget;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final painter = useGoldBorder final painter = useGoldBorder
@@ -46,16 +53,24 @@ class RetroPanel extends StatelessWidget {
fillColor: backgroundColor, fillColor: backgroundColor,
); );
final hasTitle = title != null || titleWidget != null;
return CustomPaint( return CustomPaint(
painter: painter, painter: painter,
child: Padding( child: Padding(
padding: EdgeInsets.all(borderWidth).add(padding), padding: EdgeInsets.all(borderWidth).add(padding),
child: title != null child: hasTitle
? Column( ? Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
_PanelTitle(title: title!, useGoldBorder: useGoldBorder), if (titleWidget != null)
_PanelTitleContainer(
useGoldBorder: useGoldBorder,
child: titleWidget!,
)
else
_PanelTitle(title: title!, useGoldBorder: useGoldBorder),
const SizedBox(height: 8), const SizedBox(height: 8),
Flexible(child: child), Flexible(child: child),
], ],
@@ -73,6 +88,33 @@ class _PanelTitle extends StatelessWidget {
final String title; final String title;
final bool useGoldBorder; final bool useGoldBorder;
@override
Widget build(BuildContext context) {
return _PanelTitleContainer(
useGoldBorder: useGoldBorder,
child: Text(
title.toUpperCase(),
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: useGoldBorder ? RetroColors.gold : RetroColors.textLight,
letterSpacing: 1,
),
),
);
}
}
/// 패널 타이틀 컨테이너 (커스텀 위젯용)
class _PanelTitleContainer extends StatelessWidget {
const _PanelTitleContainer({
required this.useGoldBorder,
required this.child,
});
final bool useGoldBorder;
final Widget child;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
@@ -90,15 +132,7 @@ class _PanelTitle extends StatelessWidget {
), ),
), ),
), ),
child: Text( child: child,
title.toUpperCase(),
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: useGoldBorder ? RetroColors.gold : RetroColors.textLight,
letterSpacing: 1,
),
),
); );
} }
} }
@@ -110,11 +144,16 @@ class RetroGoldPanel extends StatelessWidget {
required this.child, required this.child,
this.padding = const EdgeInsets.all(12), this.padding = const EdgeInsets.all(12),
this.title, this.title,
}); this.titleWidget,
}) : assert(
title == null || titleWidget == null,
'title과 titleWidget 중 하나만 사용 가능',
);
final Widget child; final Widget child;
final EdgeInsets padding; final EdgeInsets padding;
final String? title; final String? title;
final Widget? titleWidget;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -122,6 +161,7 @@ class RetroGoldPanel extends StatelessWidget {
useGoldBorder: true, useGoldBorder: true,
padding: padding, padding: padding,
title: title, title: title,
titleWidget: titleWidget,
child: child, child: child,
); );
} }

View File

@@ -8,6 +8,7 @@ import Foundation
import audio_session import audio_session
import in_app_purchase_storekit import in_app_purchase_storekit
import just_audio import just_audio
import package_info_plus
import path_provider_foundation import path_provider_foundation
import shared_preferences_foundation import shared_preferences_foundation
import webview_flutter_wkwebview import webview_flutter_wkwebview
@@ -16,6 +17,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin"))
InAppPurchasePlugin.register(with: registry.registrar(forPlugin: "InAppPurchasePlugin")) InAppPurchasePlugin.register(with: registry.registrar(forPlugin: "InAppPurchasePlugin"))
JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin")) JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin")) WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin"))

View File

@@ -8,6 +8,8 @@ PODS:
- just_audio (0.0.1): - just_audio (0.0.1):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
- package_info_plus (0.0.1):
- FlutterMacOS
- path_provider_foundation (0.0.1): - path_provider_foundation (0.0.1):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
@@ -23,6 +25,7 @@ DEPENDENCIES:
- FlutterMacOS (from `Flutter/ephemeral`) - FlutterMacOS (from `Flutter/ephemeral`)
- in_app_purchase_storekit (from `Flutter/ephemeral/.symlinks/plugins/in_app_purchase_storekit/darwin`) - in_app_purchase_storekit (from `Flutter/ephemeral/.symlinks/plugins/in_app_purchase_storekit/darwin`)
- just_audio (from `Flutter/ephemeral/.symlinks/plugins/just_audio/darwin`) - just_audio (from `Flutter/ephemeral/.symlinks/plugins/just_audio/darwin`)
- package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`)
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
- webview_flutter_wkwebview (from `Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin`) - webview_flutter_wkwebview (from `Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin`)
@@ -36,6 +39,8 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/in_app_purchase_storekit/darwin :path: Flutter/ephemeral/.symlinks/plugins/in_app_purchase_storekit/darwin
just_audio: just_audio:
:path: Flutter/ephemeral/.symlinks/plugins/just_audio/darwin :path: Flutter/ephemeral/.symlinks/plugins/just_audio/darwin
package_info_plus:
:path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos
path_provider_foundation: path_provider_foundation:
:path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin
shared_preferences_foundation: shared_preferences_foundation:
@@ -48,6 +53,7 @@ SPEC CHECKSUMS:
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
in_app_purchase_storekit: 2342c0a5da86593124d08dd13d920f39a52b273a in_app_purchase_storekit: 2342c0a5da86593124d08dd13d920f39a52b273a
just_audio: a42c63806f16995daf5b219ae1d679deb76e6a79 just_audio: a42c63806f16995daf5b219ae1d679deb76e6a79
package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b
path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba
shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6 shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
webview_flutter_wkwebview: 29eb20d43355b48fe7d07113835b9128f84e3af4 webview_flutter_wkwebview: 29eb20d43355b48fe7d07113835b9128f84e3af4

View File

@@ -525,6 +525,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" version: "2.2.0"
package_info_plus:
dependency: "direct main"
description:
name: package_info_plus
sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968"
url: "https://pub.dev"
source: hosted
version: "8.3.1"
package_info_plus_platform_interface:
dependency: transitive
description:
name: package_info_plus_platform_interface
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
url: "https://pub.dev"
source: hosted
version: "3.2.1"
path: path:
dependency: transitive dependency: transitive
description: description:
@@ -906,6 +922,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.23.5" version: "3.23.5"
win32:
dependency: transitive
description:
name: win32
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
url: "https://pub.dev"
source: hosted
version: "5.15.0"
xdg_directories: xdg_directories:
dependency: transitive dependency: transitive
description: description:

View File

@@ -45,6 +45,8 @@ dependencies:
google_mobile_ads: ^5.3.0 google_mobile_ads: ^5.3.0
# IAP (인앱 결제) # IAP (인앱 결제)
in_app_purchase: ^3.2.0 in_app_purchase: ^3.2.0
# 앱 버전 정보
package_info_plus: ^8.3.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: