Compare commits
6 Commits
d41dd0fb90
...
94c2ed1ca1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94c2ed1ca1 | ||
|
|
19faa9ea39 | ||
|
|
ffc19c7ca6 | ||
|
|
724de9a63c | ||
|
|
03aa117710 | ||
|
|
f51bf8c540 |
@@ -84,37 +84,31 @@ String get taskCompiling => _l('Compiling', '컴파일 중', 'コンパイル中
|
||||
String get taskPrologue => _l('Prologue', '프롤로그', 'プロローグ');
|
||||
|
||||
String taskHeadingToMarket() => _l(
|
||||
'Heading to the Data Market to trade loot',
|
||||
'전리품을 팔기 위해 데이터 마켓으로 이동 중',
|
||||
'戦利品を売るためデータマーケットへ移動中',
|
||||
);
|
||||
'Heading to the Data Market to trade loot',
|
||||
'전리품을 팔기 위해 데이터 마켓으로 이동 중',
|
||||
'戦利品を売るためデータマーケットへ移動中',
|
||||
);
|
||||
|
||||
String taskUpgradingHardware() => _l(
|
||||
'Upgrading hardware at the Tech Shop',
|
||||
'테크 샵에서 하드웨어 업그레이드 중',
|
||||
'テックショップでハードウェアをアップグレード中',
|
||||
);
|
||||
'Upgrading hardware at the Tech Shop',
|
||||
'테크 샵에서 하드웨어 업그레이드 중',
|
||||
'テックショップでハードウェアをアップグレード中',
|
||||
);
|
||||
|
||||
String taskEnteringDebugZone() =>
|
||||
_l('Entering the Debug Zone', '디버그 존 진입 중', 'デバッグゾーンに進入中');
|
||||
|
||||
String taskDebugging(String monsterName) => _l(
|
||||
'Debugging $monsterName',
|
||||
'$monsterName 디버깅 중',
|
||||
'$monsterName をデバッグ中',
|
||||
);
|
||||
String taskDebugging(String monsterName) =>
|
||||
_l('Debugging $monsterName', '$monsterName 디버깅 중', '$monsterName をデバッグ中');
|
||||
|
||||
String taskFinalBoss(String bossName) => _l(
|
||||
'Final Battle: $bossName',
|
||||
'최종 보스와 대결: $bossName',
|
||||
'最終ボスと対決: $bossName',
|
||||
);
|
||||
String taskFinalBoss(String bossName) =>
|
||||
_l('Final Battle: $bossName', '최종 보스와 대결: $bossName', '最終ボスと対決: $bossName');
|
||||
|
||||
String taskSelling(String itemDescription) => _l(
|
||||
'Selling $itemDescription',
|
||||
'$itemDescription 판매 중',
|
||||
'$itemDescription を販売中',
|
||||
);
|
||||
'Selling $itemDescription',
|
||||
'$itemDescription 판매 중',
|
||||
'$itemDescription を販売中',
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// 부활 시퀀스 메시지
|
||||
@@ -161,35 +155,48 @@ String get deathEnvironmentalHazard =>
|
||||
// 속도 부스트 (Phase 6)
|
||||
// ============================================================================
|
||||
|
||||
String get speedBoostTitle =>
|
||||
_l('Speed Boost', '속도 부스트', 'スピードブースト');
|
||||
String get speedBoostTitle => _l('Speed Boost', '속도 부스트', 'スピードブースト');
|
||||
String get speedBoostActivate =>
|
||||
_l('Activate 10x Speed', '10배속 활성화', '10倍速を有効化');
|
||||
String speedBoostRemaining(int seconds) =>
|
||||
_l('${seconds}s remaining', '${seconds}초 남음', '残り${seconds}秒');
|
||||
String get speedBoostActive =>
|
||||
_l('BOOST ACTIVE', '부스트 활성화', 'ブースト中');
|
||||
String get speedBoostActive => _l('BOOST ACTIVE', '부스트 활성화', 'ブースト中');
|
||||
|
||||
// ============================================================================
|
||||
// 복귀 보상 (Phase 7)
|
||||
// ============================================================================
|
||||
|
||||
String get returnRewardTitle =>
|
||||
_l('Welcome Back!', '돌아오셨군요!', 'おかえりなさい!');
|
||||
String get returnRewardTitle => _l('Welcome Back!', '돌아오셨군요!', 'おかえりなさい!');
|
||||
String returnRewardHoursAway(String time) =>
|
||||
_l('You were away for $time', '$time 동안 떠나있었습니다', '$time 離れていました');
|
||||
String get returnRewardBasic =>
|
||||
_l('Basic Reward', '기본 보상', '基本報酬');
|
||||
String get returnRewardBonus =>
|
||||
_l('Bonus Reward', '보너스 보상', 'ボーナス報酬');
|
||||
String returnRewardGold(int gold) =>
|
||||
_l('+$gold Gold', '+$gold 골드', '+$gold ゴールド');
|
||||
String get returnRewardClaim =>
|
||||
_l('Claim', '받기', '受け取る');
|
||||
String returnRewardChests(int count) =>
|
||||
_l('$count Treasure Chest(s)', '보물 상자 $count개', '宝箱 $count個');
|
||||
String get returnRewardOpenChests => _l('Open Chests', '상자 열기', '宝箱を開ける');
|
||||
String get returnRewardBonusChests =>
|
||||
_l('Bonus Chests', '보너스 상자', 'ボーナス宝箱');
|
||||
String get returnRewardClaimBonus =>
|
||||
_l('Claim Bonus', '보너스 받기', 'ボーナス受取');
|
||||
String get returnRewardSkip =>
|
||||
_l('Skip', '건너뛰기', 'スキップ');
|
||||
_l('Get Bonus (AD)', '보너스 받기 (광고)', 'ボーナス受取 (広告)');
|
||||
String get returnRewardClaimBonusFree =>
|
||||
_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 일반 메시지
|
||||
@@ -198,10 +205,8 @@ String get returnRewardSkip =>
|
||||
String get uiNoPotions => _l('No potions', '포션 없음', 'ポーションなし');
|
||||
String get uiTapToContinue => _l('Tap to continue', '탭하여 계속', 'タップして続行');
|
||||
String get uiNoSkills => _l('No skills', '습득한 스킬이 없습니다', 'スキルなし');
|
||||
String get uiNoBonusStats =>
|
||||
_l('No bonus stats', '추가 스탯 없음', 'ボーナスステータスなし');
|
||||
String get uiNoActiveBuffs =>
|
||||
_l('No active buffs', '활성 버프 없음', 'アクティブバフなし');
|
||||
String get uiNoBonusStats => _l('No bonus stats', '추가 스탯 없음', 'ボーナスステータスなし');
|
||||
String get uiNoActiveBuffs => _l('No active buffs', '활성 버프 없음', 'アクティブバフなし');
|
||||
String get uiReady => _l('Ready', '준비', '準備完了');
|
||||
String get uiPotions => _l('Potions', '포션', 'ポーション');
|
||||
String get uiBuffs => _l('Buffs', '버프', 'バフ');
|
||||
@@ -236,87 +241,91 @@ String passiveEvasionBonus(int percent) =>
|
||||
String passiveCritBonus(int percent) =>
|
||||
_l('Critical +$percent%', '크리티컬 +$percent%', 'クリティカル +$percent%');
|
||||
String passiveHpRegen(int percent) => _l(
|
||||
'Recover $percent% HP after combat',
|
||||
'전투 후 HP $percent% 회복',
|
||||
'戦闘後HP $percent%回復',
|
||||
);
|
||||
'Recover $percent% HP after combat',
|
||||
'전투 후 HP $percent% 회복',
|
||||
'戦闘後HP $percent%回復',
|
||||
);
|
||||
String passiveMpRegen(int percent) => _l(
|
||||
'Recover $percent% MP after combat',
|
||||
'전투 후 MP $percent% 회복',
|
||||
'戦闘後MP $percent%回復',
|
||||
);
|
||||
'Recover $percent% MP after combat',
|
||||
'전투 후 MP $percent% 회복',
|
||||
'戦闘後MP $percent%回復',
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// 전투 로그 메시지
|
||||
// ============================================================================
|
||||
|
||||
String combatYouHit(String targetName, int damage) => _l(
|
||||
'You hit $targetName for $damage damage',
|
||||
'$targetName에게 $damage 데미지',
|
||||
'$targetNameに$damageダメージ',
|
||||
);
|
||||
'You hit $targetName for $damage damage',
|
||||
'$targetName에게 $damage 데미지',
|
||||
'$targetNameに$damageダメージ',
|
||||
);
|
||||
String combatYouEvaded(String targetName) => _l(
|
||||
'You evaded $targetName\'s attack!',
|
||||
'$targetName의 공격 회피!',
|
||||
'$targetNameの攻撃を回避!',
|
||||
);
|
||||
'You evaded $targetName\'s attack!',
|
||||
'$targetName의 공격 회피!',
|
||||
'$targetNameの攻撃を回避!',
|
||||
);
|
||||
String combatEvadedAttackFrom(String targetName) => _l(
|
||||
'Evaded attack from $targetName',
|
||||
'$targetName의 공격 회피',
|
||||
'$targetNameの攻撃を回避',
|
||||
);
|
||||
'Evaded attack from $targetName',
|
||||
'$targetName의 공격 회피',
|
||||
'$targetNameの攻撃を回避',
|
||||
);
|
||||
String combatHealedFor(int amount) =>
|
||||
_l('Healed for $amount HP', 'HP $amount 회복', 'HP $amount回復');
|
||||
String combatCritical(int damage, String targetName) => _l(
|
||||
'CRITICAL! $damage damage to $targetName!',
|
||||
'크리티컬! $targetName에게 $damage 데미지!',
|
||||
'クリティカル! $targetNameに$damageダメージ!',
|
||||
);
|
||||
'CRITICAL! $damage damage to $targetName!',
|
||||
'크리티컬! $targetName에게 $damage 데미지!',
|
||||
'クリティカル! $targetNameに$damageダメージ!',
|
||||
);
|
||||
String combatMonsterHitsYou(String monsterName, int damage) => _l(
|
||||
'$monsterName hits you for $damage damage',
|
||||
'$monsterName이(가) $damage 데미지',
|
||||
'$monsterNameが$damageダメージを与えた',
|
||||
);
|
||||
'$monsterName hits you for $damage damage',
|
||||
'$monsterName이(가) $damage 데미지',
|
||||
'$monsterNameが$damageダメージを与えた',
|
||||
);
|
||||
String combatMonsterEvaded(String monsterName) => _l(
|
||||
'$monsterName evaded your attack!',
|
||||
'$monsterName이(가) 공격 회피!',
|
||||
'$monsterNameが攻撃を回避!',
|
||||
);
|
||||
'$monsterName evaded your attack!',
|
||||
'$monsterName이(가) 공격 회피!',
|
||||
'$monsterNameが攻撃を回避!',
|
||||
);
|
||||
String combatBlocked(int damage) => _l(
|
||||
'Blocked! Reduced to $damage damage',
|
||||
'방어! $damage 데미지로 감소',
|
||||
'ブロック! $damageダメージに軽減',
|
||||
);
|
||||
'Blocked! Reduced to $damage damage',
|
||||
'방어! $damage 데미지로 감소',
|
||||
'ブロック! $damageダメージに軽減',
|
||||
);
|
||||
|
||||
String combatParried(int damage) => _l(
|
||||
'Parried! Reduced to $damage damage',
|
||||
'패리! $damage 데미지로 감소',
|
||||
'パリィ! $damageダメージに軽減',
|
||||
);
|
||||
'Parried! Reduced to $damage damage',
|
||||
'패리! $damage 데미지로 감소',
|
||||
'パリィ! $damageダメージに軽減',
|
||||
);
|
||||
|
||||
// 스킬 관련 전투 메시지
|
||||
String combatSkillCritical(String skillName, int damage) => _l(
|
||||
'CRITICAL $skillName! $damage damage!',
|
||||
'크리티컬 $skillName! $damage 데미지!',
|
||||
'クリティカル$skillName! $damageダメージ!',
|
||||
);
|
||||
String combatSkillDamage(String skillName, int damage) =>
|
||||
_l('$skillName: $damage damage', '$skillName: $damage 데미지', '$skillName: $damageダメージ');
|
||||
'CRITICAL $skillName! $damage damage!',
|
||||
'크리티컬 $skillName! $damage 데미지!',
|
||||
'クリティカル$skillName! $damageダメージ!',
|
||||
);
|
||||
String combatSkillDamage(String skillName, int damage) => _l(
|
||||
'$skillName: $damage damage',
|
||||
'$skillName: $damage 데미지',
|
||||
'$skillName: $damageダメージ',
|
||||
);
|
||||
// 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 combatBuffActivated(String skillName) =>
|
||||
_l('$skillName activated!', '$skillName 발동!', '$skillName 発動!');
|
||||
String combatDebuffApplied(String skillName, String targetName) => _l(
|
||||
'$skillName applied to $targetName!',
|
||||
'$skillName → $targetName에 적용!',
|
||||
'$skillName → $targetNameに適用!',
|
||||
);
|
||||
'$skillName applied to $targetName!',
|
||||
'$skillName → $targetName에 적용!',
|
||||
'$skillName → $targetNameに適用!',
|
||||
);
|
||||
String combatDotTick(String skillName, int damage) => _l(
|
||||
'$skillName ticks for $damage damage',
|
||||
'$skillName: $damage 지속 데미지',
|
||||
'$skillName: $damage 継続ダメージ',
|
||||
);
|
||||
'$skillName ticks for $damage damage',
|
||||
'$skillName: $damage 지속 데미지',
|
||||
'$skillName: $damage 継続ダメージ',
|
||||
);
|
||||
// 포션 형식이 동일하므로 단순화
|
||||
String combatPotionUsed(String potionName, int amount, String statName) =>
|
||||
'$potionName: +$amount $statName';
|
||||
@@ -325,15 +334,15 @@ String combatPotionDrop(String potionName) =>
|
||||
|
||||
// 사망 화면 전투 로그 (death overlay)
|
||||
String combatBlockedAttack(String monsterName, int reducedDamage) => _l(
|
||||
'Blocked $monsterName\'s attack ($reducedDamage reduced)',
|
||||
'$monsterName의 공격 방어 ($reducedDamage 감소)',
|
||||
'$monsterNameの攻撃を防御 ($reducedDamage軽減)',
|
||||
);
|
||||
'Blocked $monsterName\'s attack ($reducedDamage reduced)',
|
||||
'$monsterName의 공격 방어 ($reducedDamage 감소)',
|
||||
'$monsterNameの攻撃を防御 ($reducedDamage軽減)',
|
||||
);
|
||||
String combatParriedAttack(String monsterName, int reducedDamage) => _l(
|
||||
'Parried $monsterName\'s attack ($reducedDamage reduced)',
|
||||
'$monsterName의 공격 패리 ($reducedDamage 감소)',
|
||||
'$monsterNameの攻撃をパリィ ($reducedDamage軽減)',
|
||||
);
|
||||
'Parried $monsterName\'s attack ($reducedDamage reduced)',
|
||||
'$monsterName의 공격 패리 ($reducedDamage 감소)',
|
||||
'$monsterNameの攻撃をパリィ ($reducedDamage軽減)',
|
||||
);
|
||||
String get deathSelfInflicted =>
|
||||
_l('Self-inflicted damage', '자해 데미지로 사망', '自傷ダメージで死亡');
|
||||
|
||||
@@ -343,8 +352,7 @@ String get deathSelfInflicted =>
|
||||
|
||||
String questPatch(String name) =>
|
||||
_l('Patch $name', '$name 패치하기', '$name をパッチする');
|
||||
String questLocate(String item) =>
|
||||
_l('Locate $item', '$item 찾기', '$item を探す');
|
||||
String questLocate(String item) => _l('Locate $item', '$item 찾기', '$item を探す');
|
||||
String questTransfer(String item) =>
|
||||
_l('Transfer this $item', '이 $item 전송하기', 'この$item を転送する');
|
||||
String questDownload(String item) =>
|
||||
@@ -364,100 +372,100 @@ String actTitle(String romanNumeral) =>
|
||||
// ============================================================================
|
||||
|
||||
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(
|
||||
'You reconnect with old allies and fork new ones',
|
||||
'옛 동맹들과 재연결하고 새로운 동료들을 포크하다',
|
||||
'古い同盟者と再接続し、新しい仲間をフォークする',
|
||||
);
|
||||
'You reconnect with old allies and fork new ones',
|
||||
'옛 동맹들과 재연결하고 새로운 동료들을 포크하다',
|
||||
'古い同盟者と再接続し、新しい仲間をフォークする',
|
||||
);
|
||||
String cinematicCacheZone3() => _l(
|
||||
'You attend a council of the Debugger Knights',
|
||||
'디버거 기사단 회의에 참석하다',
|
||||
'デバッガー騎士団の会議に参加する',
|
||||
);
|
||||
'You attend a council of the Debugger Knights',
|
||||
'디버거 기사단 회의에 참석하다',
|
||||
'デバッガー騎士団の会議に参加する',
|
||||
);
|
||||
String cinematicCacheZone4() => _l(
|
||||
'Many bugs await. You are chosen to patch them!',
|
||||
'많은 버그들이 기다린다. 당신이 패치하도록 선택되었다!',
|
||||
'多くのバグが待っている。あなたがパッチを当てるよう選ばれた!',
|
||||
);
|
||||
'Many bugs await. You are chosen to patch them!',
|
||||
'많은 버그들이 기다린다. 당신이 패치하도록 선택되었다!',
|
||||
'多くのバグが待っている。あなたがパッチを当てるよう選ばれた!',
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// 시네마틱 텍스트 - 시나리오 2: 전투
|
||||
// ============================================================================
|
||||
|
||||
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(
|
||||
'A desperate debugging session begins with $nemesis',
|
||||
'$nemesis와의 필사적인 디버깅 세션이 시작되다',
|
||||
'$nemesisとの必死のデバッグセッションが始まる',
|
||||
);
|
||||
'A desperate debugging session begins with $nemesis',
|
||||
'$nemesis와의 필사적인 디버깅 세션이 시작되다',
|
||||
'$nemesisとの必死のデバッグセッションが始まる',
|
||||
);
|
||||
String cinematicCombatLocked(String nemesis) => _l(
|
||||
'Locked in intense debugging with $nemesis',
|
||||
'$nemesis와 치열한 디버깅 중',
|
||||
'$nemesisと激しいデバッグ中',
|
||||
);
|
||||
'Locked in intense debugging with $nemesis',
|
||||
'$nemesis와 치열한 디버깅 중',
|
||||
'$nemesisと激しいデバッグ中',
|
||||
);
|
||||
String cinematicCombatCorrupts(String nemesis) => _l(
|
||||
'$nemesis corrupts your stack trace',
|
||||
'$nemesis가 당신의 스택 트레이스를 손상시키다',
|
||||
'$nemesisがあなたのスタックトレースを破損させる',
|
||||
);
|
||||
'$nemesis corrupts your stack trace',
|
||||
'$nemesis가 당신의 스택 트레이스를 손상시키다',
|
||||
'$nemesisがあなたのスタックトレースを破損させる',
|
||||
);
|
||||
String cinematicCombatWorking(String nemesis) => _l(
|
||||
'Your patch seems to be working against $nemesis',
|
||||
'당신의 패치가 $nemesis에게 효과를 보이는 것 같다',
|
||||
'あなたのパッチが$nemesisに効いているようだ',
|
||||
);
|
||||
'Your patch seems to be working against $nemesis',
|
||||
'당신의 패치가 $nemesis에게 효과를 보이는 것 같다',
|
||||
'あなたのパッチが$nemesisに効いているようだ',
|
||||
);
|
||||
String cinematicCombatVictory(String nemesis) => _l(
|
||||
'Victory! $nemesis is patched! System reboots for recovery',
|
||||
'승리! $nemesis가 패치되었다! 복구를 위해 시스템이 재부팅된다',
|
||||
'勝利!$nemesisはパッチされた!復旧のためシステムが再起動する',
|
||||
);
|
||||
'Victory! $nemesis is patched! System reboots for recovery',
|
||||
'승리! $nemesis가 패치되었다! 복구를 위해 시스템이 재부팅된다',
|
||||
'勝利!$nemesisはパッチされた!復旧のためシステムが再起動する',
|
||||
);
|
||||
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: 배신
|
||||
// ============================================================================
|
||||
|
||||
String cinematicBetrayal1(String guy) => _l(
|
||||
'What relief! You reach the secure server of $guy',
|
||||
'안도감! $guy의 보안 서버에 도착하다',
|
||||
'安堵!$guyのセキュアサーバーに到着する',
|
||||
);
|
||||
'What relief! You reach the secure server of $guy',
|
||||
'안도감! $guy의 보안 서버에 도착하다',
|
||||
'安堵!$guyのセキュアサーバーに到着する',
|
||||
);
|
||||
String cinematicBetrayal2(String guy) => _l(
|
||||
'There is celebration, and a suspicious private handshake with $guy',
|
||||
'축하가 이어지고, $guy와 수상한 비밀 핸드셰이크를 나누다',
|
||||
'祝賀が続き、$guyと怪しい秘密のハンドシェイクを交わす',
|
||||
);
|
||||
'There is celebration, and a suspicious private handshake with $guy',
|
||||
'축하가 이어지고, $guy와 수상한 비밀 핸드셰이크를 나누다',
|
||||
'祝賀が続き、$guyと怪しい秘密のハンドシェイクを交わす',
|
||||
);
|
||||
String cinematicBetrayal3(String item) => _l(
|
||||
'You forget your $item and go back to retrieve it',
|
||||
'$item을 잊고 다시 가져오러 돌아가다',
|
||||
'$itemを忘れて取りに戻る',
|
||||
);
|
||||
'You forget your $item and go back to retrieve it',
|
||||
'$item을 잊고 다시 가져오러 돌아가다',
|
||||
'$itemを忘れて取りに戻る',
|
||||
);
|
||||
String cinematicBetrayal4() => _l(
|
||||
'What is this!? You intercept a corrupted packet!',
|
||||
'이게 뭐지!? 손상된 패킷을 가로채다!',
|
||||
'これは何だ!?破損したパケットを傍受する!',
|
||||
);
|
||||
'What is this!? You intercept a corrupted packet!',
|
||||
'이게 뭐지!? 손상된 패킷을 가로채다!',
|
||||
'これは何だ!?破損したパケットを傍受する!',
|
||||
);
|
||||
String cinematicBetrayal5(String guy) => _l(
|
||||
'Could $guy be a backdoor for the Glitch God?',
|
||||
'$guy가 글리치 신의 백도어일 수 있을까?',
|
||||
'$guyはグリッチゴッドのバックドアなのか?',
|
||||
);
|
||||
'Could $guy be a backdoor for the Glitch God?',
|
||||
'$guy가 글리치 신의 백도어일 수 있을까?',
|
||||
'$guyはグリッチゴッドのバックドアなのか?',
|
||||
);
|
||||
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 modifierCrippled(String s) => _l('twisted $s', '흉측한 $s', '歪んだ$s');
|
||||
String modifierSick(String s) => _l('tainted $s', '오염된 $s', '汚染された$s');
|
||||
String modifierUndernourished(String s) =>
|
||||
_l('ravenous $s', '굶주린 $s', '飢えた$s');
|
||||
String modifierUndernourished(String s) => _l('ravenous $s', '굶주린 $s', '飢えた$s');
|
||||
String modifierFoetal(String s) => _l('nascent $s', '태동기 $s', '胎動期$s');
|
||||
String modifierBaby(String s) => _l('fledgling $s', '초기형 $s', '初期型$s');
|
||||
String modifierPreadolescent(String s) =>
|
||||
_l('evolving $s', '진화 중인 $s', '進化中の$s');
|
||||
String modifierTeenage(String s) => _l('lesser $s', '하급 $s', '下級$s');
|
||||
String modifierUnderage(String s) =>
|
||||
_l('incomplete $s', '불완전한 $s', '不完全な$s');
|
||||
String modifierUnderage(String s) => _l('incomplete $s', '불완전한 $s', '不完全な$s');
|
||||
String modifierGreater(String s) => _l('greater $s', '상위 $s', '上位$s');
|
||||
String modifierMassive(String s) => _l('massive $s', '거대한 $s', '巨大な$s');
|
||||
String modifierEnormous(String s) => _l('enormous $s', '초거대 $s', '超巨大$s');
|
||||
String modifierGiant(String s) =>
|
||||
_l('giant $s', '자이언트 $s', 'ジャイアント$s');
|
||||
String modifierGiant(String s) => _l('giant $s', '자이언트 $s', 'ジャイアント$s');
|
||||
|
||||
String modifierTitanic(String s) =>
|
||||
_l('titanic $s', '타이타닉 $s', 'タイタニック$s');
|
||||
String modifierVeteran(String s) =>
|
||||
_l('veteran $s', '베테랑 $s', 'ベテラン$s');
|
||||
String modifierTitanic(String s) => _l('titanic $s', '타이타닉 $s', 'タイタニック$s');
|
||||
String modifierVeteran(String s) => _l('veteran $s', '베테랑 $s', 'ベテラン$s');
|
||||
String modifierBattle(String s) => _l('Battle-$s', '전투-$s', '戦闘-$s');
|
||||
String modifierCursed(String s) =>
|
||||
_l('cursed $s', '저주받은 $s', '呪われた$s');
|
||||
String modifierCursed(String s) => _l('cursed $s', '저주받은 $s', '呪われた$s');
|
||||
String modifierWarrior(String s) => _l('warrior $s', '전사 $s', '戦士$s');
|
||||
String modifierWere(String s) => _l('Were-$s', '늑대인간-$s', '狼男-$s');
|
||||
String modifierUndead(String s) =>
|
||||
_l('undead $s', '언데드 $s', 'アンデッド$s');
|
||||
String modifierUndead(String s) => _l('undead $s', '언데드 $s', 'アンデッド$s');
|
||||
String modifierDemon(String s) => _l('demon $s', '데몬 $s', 'デーモン$s');
|
||||
String modifierMessianic(String s) =>
|
||||
_l('messianic $s', '메시아닉 $s', 'メシアニック$s');
|
||||
String modifierImaginary(String s) =>
|
||||
_l('imaginary $s', '상상의 $s', '想像上の$s');
|
||||
String modifierPassing(String s) =>
|
||||
_l('passing $s', '지나가는 $s', '通りすがりの$s');
|
||||
String modifierMessianic(String s) => _l('messianic $s', '메시아닉 $s', 'メシアニック$s');
|
||||
String modifierImaginary(String 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秒');
|
||||
String roughTimeMinutes(int minutes) =>
|
||||
_l('$minutes minutes', '$minutes분', '$minutes分');
|
||||
String roughTimeHours(int hours) =>
|
||||
_l('$hours hours', '$hours시간', '$hours時間');
|
||||
String roughTimeHours(int hours) => _l('$hours hours', '$hours시간', '$hours時間');
|
||||
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 uiLocalArena => _l('Local Arena', '로컬 아레나', 'ローカルアリーナ');
|
||||
String get frontDescription => _l(
|
||||
'A retro-style offline single-player RPG',
|
||||
'레트로 감성의 오프라인 싱글플레이어 RPG',
|
||||
'レトロ感のあるオフラインシングルプレイヤーRPG',
|
||||
);
|
||||
String get frontTodayFocus =>
|
||||
_l("Today's focus", '오늘의 중점', '今日のフォーカス');
|
||||
'A retro-style offline single-player RPG',
|
||||
'레트로 감성의 오프라인 싱글플레이어 RPG',
|
||||
'レトロ感のあるオフラインシングルプレイヤーRPG',
|
||||
);
|
||||
String get frontTodayFocus => _l("Today's focus", '오늘의 중점', '今日のフォーカス');
|
||||
|
||||
// ============================================================================
|
||||
// 명예의 전당 화면 텍스트
|
||||
// ============================================================================
|
||||
|
||||
String get hofNoHeroes =>
|
||||
_l('No heroes yet', '영웅이 아직 없습니다', 'まだ英雄がいません');
|
||||
String get hofNoHeroes => _l('No heroes yet', '영웅이 아직 없습니다', 'まだ英雄がいません');
|
||||
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 hofDefeatedGlitchGod => _l(
|
||||
'You have defeated the Glitch God!',
|
||||
'글리치 신을 처치했습니다!',
|
||||
'グリッチゴッドを倒しました!',
|
||||
);
|
||||
String get hofDefeatedGlitchGod =>
|
||||
_l('You have defeated the Glitch God!', '글리치 신을 처치했습니다!', 'グリッチゴッドを倒しました!');
|
||||
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 =>
|
||||
_l('View Hall of Fame', '명예의 전당 보기', '栄誉の殿堂を見る');
|
||||
String get hofNewGame => _l('New Game', '새 게임', '新しいゲーム');
|
||||
@@ -962,17 +954,20 @@ String get uiSkip => _l('SKIP', '건너뛰기', 'スキップ');
|
||||
// ============================================================================
|
||||
|
||||
String get uiLevelUp => _l('Level Up!', '레벨 업!', 'レベルアップ!');
|
||||
String uiQuestComplete(String questName) =>
|
||||
_l('Quest Complete: $questName', '퀘스트 완료: $questName', 'クエスト完了: $questName');
|
||||
String uiQuestComplete(String questName) => _l(
|
||||
'Quest Complete: $questName',
|
||||
'퀘스트 완료: $questName',
|
||||
'クエスト完了: $questName',
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// 장비 패널 텍스트
|
||||
// ============================================================================
|
||||
|
||||
String get uiEquipmentScore =>
|
||||
_l('Equipment Score', '장비 점수', '装備スコア');
|
||||
String get uiEquipmentScore => _l('Equipment Score', '장비 점수', '装備スコア');
|
||||
String get uiEmpty => _l('(empty)', '(비어있음)', '(空)');
|
||||
String uiWeight(int weight) => _l('Wt.$weight', '무게 $weight', '重量 $weight');
|
||||
|
||||
/// 남은 시간 표시
|
||||
String uiTimeRemaining(String time) =>
|
||||
_l('$time remaining', '$time 남음', '残り$time');
|
||||
@@ -1026,17 +1021,11 @@ String get rarityLegendary => _l('LEGENDARY', '전설', 'レジェンダリー')
|
||||
|
||||
String uiRollHistory(int count) =>
|
||||
_l('$count roll(s) in history', '리롤 기록: $count회', 'リロール履歴: $count回');
|
||||
String get uiEnterName => _l(
|
||||
'Please enter a name.',
|
||||
'이름을 입력해주세요.',
|
||||
'名前を入力してください。',
|
||||
);
|
||||
String get uiEnterName =>
|
||||
_l('Please enter a name.', '이름을 입력해주세요.', '名前を入力してください。');
|
||||
String get uiTestMode => _l('Test Mode', '테스트 모드', 'テストモード');
|
||||
String get uiTestModeDesc => _l(
|
||||
'Use mobile layout on web',
|
||||
'웹에서 모바일 레이아웃 사용',
|
||||
'Webでモバイルレイアウトを使用',
|
||||
);
|
||||
String get uiTestModeDesc =>
|
||||
_l('Use mobile layout on web', '웹에서 모바일 레이아웃 사용', 'Webでモバイルレイアウトを使用');
|
||||
|
||||
// ============================================================================
|
||||
// 캐로셀 네비게이션 텍스트
|
||||
@@ -1069,10 +1058,10 @@ String get menuNewGame => _l('New Game', '새로하기', '新規ゲーム');
|
||||
String get confirmDeleteTitle => _l('Delete Save', '세이브 삭제', 'セーブ削除');
|
||||
|
||||
String get confirmDeleteMessage => _l(
|
||||
'Are you sure?\nAll progress will be lost.',
|
||||
'정말 삭제하시겠습니까?\n모든 진행 상황이 사라집니다.',
|
||||
'本当に削除しますか?\nすべての進行状況が失われます。',
|
||||
);
|
||||
'Are you sure?\nAll progress will be lost.',
|
||||
'정말 삭제하시겠습니까?\n모든 진행 상황이 사라집니다.',
|
||||
'本当に削除しますか?\nすべての進行状況が失われます。',
|
||||
);
|
||||
String get buttonConfirm => _l('Confirm', '확인', '確認');
|
||||
String get buttonCancel => _l('Cancel', '취소', 'キャンセル');
|
||||
|
||||
@@ -1082,12 +1071,13 @@ String get buttonCancel => _l('Cancel', '취소', 'キャンセル');
|
||||
|
||||
String get uiWarning => _l('Warning', '경고', '警告');
|
||||
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 uiSfxVolume => _l('SFX Volume', '효과음 볼륨', '効果音音量');
|
||||
String get uiSoundOff => _l('Muted', '음소거', 'ミュート');
|
||||
String get uiAnimationSpeed =>
|
||||
_l('Animation Speed', '애니메이션 속도', 'アニメーション速度');
|
||||
String get uiAnimationSpeed => _l('Animation Speed', '애니메이션 속도', 'アニメーション速度');
|
||||
String get uiSpeedSlow => _l('Slow', '느림', '遅い');
|
||||
String get uiSpeedNormal => _l('Normal', '보통', '普通');
|
||||
String get uiSpeedFast => _l('Fast', '빠름', '速い');
|
||||
String get uiAbout => _l('About', '정보', '情報');
|
||||
String get uiAboutDescription => _l(
|
||||
'An offline single-player RPG with ASCII art and retro vibes.',
|
||||
'ASCII 아트와 레트로 감성의 오프라인 싱글플레이어 RPG입니다.',
|
||||
'ASCIIアートとレトロ感のあるオフラインシングルプレイヤーRPGです。',
|
||||
);
|
||||
'An offline single-player RPG with ASCII art and retro vibes.',
|
||||
'ASCII 아트와 레트로 감성의 오프라인 싱글플레이어 RPG입니다.',
|
||||
'ASCIIアートとレトロ感のあるオフラインシングルプレイヤーRPGです。',
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// 공통 UI 액션 텍스트
|
||||
@@ -1143,11 +1132,51 @@ String get uiDelete => _l('Delete', '삭제', '削除');
|
||||
String get uiConfirmDelete =>
|
||||
_l('Are you sure you want to delete?', '정말로 삭제하시겠습니까?', '本当に削除しますか?');
|
||||
String get uiDeleted => _l('Deleted', '삭제되었습니다', '削除されました');
|
||||
String get uiError =>
|
||||
_l('An error occurred', '오류가 발생했습니다', 'エラーが発生しました');
|
||||
String get uiError => _l('An error occurred', '오류가 발생했습니다', 'エラーが発生しました');
|
||||
String get uiSaved => _l('Saved', '저장됨', '保存しました');
|
||||
String get uiSaveBattleLog =>
|
||||
_l('Save Battle Log', '배틀로그 저장', 'バトルログ保存');
|
||||
String get uiSaveBattleLog => _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)
|
||||
|
||||
@@ -227,8 +227,8 @@
|
||||
"total": "Total",
|
||||
"@total": { "description": "Total label for stats" },
|
||||
|
||||
"unroll": "Unroll",
|
||||
"@unroll": { "description": "Unroll button" },
|
||||
"unroll": "Undo",
|
||||
"@unroll": { "description": "Undo button for stat reroll" },
|
||||
|
||||
"roll": "Roll",
|
||||
"@roll": { "description": "Roll button" },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"@@locale": "ja",
|
||||
|
||||
"appTitle": "ASCII NEVER DIE",
|
||||
"appTitle": "アスキー ネバー ダイ",
|
||||
"tagNoNetwork": "No network",
|
||||
"tagIdleRpg": "Idle RPG loop",
|
||||
"tagLocalSaves": "Local saves",
|
||||
@@ -68,7 +68,7 @@
|
||||
"name": "Name",
|
||||
"generateName": "Generate Name",
|
||||
"total": "Total",
|
||||
"unroll": "Unroll",
|
||||
"unroll": "元に戻す",
|
||||
"roll": "Roll",
|
||||
"race": "Race",
|
||||
"classTitle": "Class",
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
"name": "이름",
|
||||
"generateName": "이름 생성",
|
||||
"total": "합계",
|
||||
"unroll": "펼치기",
|
||||
"unroll": "되돌리기",
|
||||
"roll": "굴리기",
|
||||
"race": "종족",
|
||||
"classTitle": "직업",
|
||||
|
||||
@@ -503,10 +503,10 @@ abstract class L10n {
|
||||
/// **'Total'**
|
||||
String get total;
|
||||
|
||||
/// Unroll button
|
||||
/// Undo button for stat reroll
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Unroll'**
|
||||
/// **'Undo'**
|
||||
String get unroll;
|
||||
|
||||
/// Roll button
|
||||
|
||||
@@ -220,7 +220,7 @@ class L10nEn extends L10n {
|
||||
String get total => 'Total';
|
||||
|
||||
@override
|
||||
String get unroll => 'Unroll';
|
||||
String get unroll => 'Undo';
|
||||
|
||||
@override
|
||||
String get roll => 'Roll';
|
||||
|
||||
@@ -9,7 +9,7 @@ class L10nJa extends L10n {
|
||||
L10nJa([String locale = 'ja']) : super(locale);
|
||||
|
||||
@override
|
||||
String get appTitle => 'ASCII NEVER DIE';
|
||||
String get appTitle => 'アスキー ネバー ダイ';
|
||||
|
||||
@override
|
||||
String get tagNoNetwork => 'No network';
|
||||
@@ -220,7 +220,7 @@ class L10nJa extends L10n {
|
||||
String get total => 'Total';
|
||||
|
||||
@override
|
||||
String get unroll => 'Unroll';
|
||||
String get unroll => '元に戻す';
|
||||
|
||||
@override
|
||||
String get roll => 'Roll';
|
||||
|
||||
@@ -220,7 +220,7 @@ class L10nKo extends L10n {
|
||||
String get total => '합계';
|
||||
|
||||
@override
|
||||
String get unroll => '펼치기';
|
||||
String get unroll => '되돌리기';
|
||||
|
||||
@override
|
||||
String get roll => '굴리기';
|
||||
|
||||
@@ -220,7 +220,7 @@ class L10nZh extends L10n {
|
||||
String get total => 'Total';
|
||||
|
||||
@override
|
||||
String get unroll => 'Unroll';
|
||||
String get unroll => '撤销';
|
||||
|
||||
@override
|
||||
String get roll => 'Roll';
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
"name": "Name",
|
||||
"generateName": "Generate Name",
|
||||
"total": "Total",
|
||||
"unroll": "Unroll",
|
||||
"unroll": "撤销",
|
||||
"roll": "Roll",
|
||||
"race": "Race",
|
||||
"classTitle": "Class",
|
||||
|
||||
282
lib/src/app.dart
282
lib/src/app.dart
@@ -3,7 +3,9 @@ import 'package:flutter/material.dart';
|
||||
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
|
||||
import 'package:asciineverdie/l10n/app_localizations.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/iap_service.dart';
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
import 'package:asciineverdie/src/core/engine/game_mutations.dart';
|
||||
import 'package:asciineverdie/src/core/engine/progress_service.dart';
|
||||
@@ -46,7 +48,8 @@ class SavedGamePreview {
|
||||
final String actName;
|
||||
}
|
||||
|
||||
class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
||||
class _AskiiNeverDieAppState extends State<AskiiNeverDieApp>
|
||||
with WidgetsBindingObserver {
|
||||
late final GameSessionController _controller;
|
||||
late final NotificationService _notificationService;
|
||||
late final SettingsRepository _settingsRepository;
|
||||
@@ -57,12 +60,15 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
||||
bool _isCheckingSave = true;
|
||||
bool _hasSave = false;
|
||||
SavedGamePreview? _savedGamePreview;
|
||||
ThemeMode _themeMode = ThemeMode.system;
|
||||
HallOfFame _hallOfFame = HallOfFame.empty();
|
||||
Locale? _locale; // 사용자 선택 로케일 (null이면 시스템 기본값)
|
||||
bool _isAdRemovalPurchased = false;
|
||||
String? _removeAdsPrice;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
const config = PqConfig();
|
||||
final mutations = GameMutations(config);
|
||||
final rewards = RewardService(mutations, config);
|
||||
@@ -83,14 +89,33 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
||||
// 초기 설정 및 오디오 서비스 로드
|
||||
_loadSettings();
|
||||
_audioService.init();
|
||||
// 디버그 설정 서비스 초기화 (Phase 8)
|
||||
DebugSettingsService.instance.initialize();
|
||||
// IAP 서비스 초기화
|
||||
_initIAP();
|
||||
// 세이브 파일 존재 여부 확인
|
||||
_checkForExistingSave();
|
||||
// 명예의 전당 로드
|
||||
_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 {
|
||||
final hallOfFame = await _hallOfFameStorage.load();
|
||||
@@ -103,16 +128,26 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
||||
|
||||
/// 저장된 설정 불러오기
|
||||
Future<void> _loadSettings() async {
|
||||
final themeMode = await _settingsRepository.loadThemeMode();
|
||||
// 디버그 설정 먼저 초기화 (광고/IAP 시뮬레이션 설정 동기화)
|
||||
await DebugSettingsService.instance.initialize();
|
||||
|
||||
final localeCode = await _settingsRepository.loadLocale();
|
||||
if (mounted) {
|
||||
setState(() => _themeMode = themeMode);
|
||||
setState(() {
|
||||
// 저장된 로케일이 있으면 적용
|
||||
if (localeCode != null) {
|
||||
_locale = Locale(localeCode);
|
||||
game_l10n.setGameLocale(localeCode);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 테마 모드 변경
|
||||
void _changeThemeMode(ThemeMode mode) {
|
||||
setState(() => _themeMode = mode);
|
||||
_settingsRepository.saveThemeMode(mode);
|
||||
/// 로케일 변경
|
||||
void _changeLocale(String localeCode) {
|
||||
setState(() {
|
||||
_locale = Locale(localeCode);
|
||||
});
|
||||
}
|
||||
|
||||
/// 세이브 파일 존재 여부 확인 및 미리보기 정보 로드
|
||||
@@ -139,8 +174,11 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
||||
_savedGamePreview = preview;
|
||||
_isCheckingSave = false;
|
||||
});
|
||||
// 세이브 확인 완료 후 타이틀 BGM 재생
|
||||
_audioService.playBgm('title');
|
||||
// 세이브 확인 완료 후 타이틀 BGM 재생 (앱이 포그라운드일 때만)
|
||||
final lifecycleState = WidgetsBinding.instance.lifecycleState;
|
||||
if (lifecycleState == AppLifecycleState.resumed) {
|
||||
_audioService.playBgm('title');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,148 +197,32 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_controller.dispose();
|
||||
_notificationService.dispose();
|
||||
_audioService.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 라이트 테마 (Classic Parchment 스타일)
|
||||
ThemeData get _lightTheme => ThemeData(
|
||||
colorScheme: RetroColors.lightColorScheme,
|
||||
scaffoldBackgroundColor: const Color(0xFFFAF4ED),
|
||||
useMaterial3: true,
|
||||
// 카드/다이얼로그 레트로 배경
|
||||
cardColor: const Color(0xFFF2E8DC),
|
||||
dialogTheme: const DialogThemeData(
|
||||
backgroundColor: Color(0xFFF2E8DC),
|
||||
titleTextStyle: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 15,
|
||||
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),
|
||||
),
|
||||
);
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
super.didChangeAppLifecycleState(state);
|
||||
// 앱이 백그라운드로 내려가면 오디오 정지
|
||||
if (state == AppLifecycleState.paused ||
|
||||
state == AppLifecycleState.inactive) {
|
||||
_audioService.pauseAll();
|
||||
} else if (state == AppLifecycleState.resumed) {
|
||||
_audioService.resumeAll().then((_) {
|
||||
// 복귀 후 BGM이 없고 시작 화면이면 타이틀 BGM 재생
|
||||
if (_audioService.currentBgm == null && !_isCheckingSave) {
|
||||
_audioService.playBgm('title');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 다크 테마 (Dark Fantasy 스타일)
|
||||
ThemeData get _darkTheme => ThemeData(
|
||||
/// 앱 테마 (Dark Fantasy 스타일)
|
||||
ThemeData get _theme => ThemeData(
|
||||
colorScheme: RetroColors.darkColorScheme,
|
||||
scaffoldBackgroundColor: RetroColors.deepBrown,
|
||||
useMaterial3: true,
|
||||
@@ -440,9 +362,8 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
||||
debugShowCheckedModeBanner: false,
|
||||
localizationsDelegates: L10n.localizationsDelegates,
|
||||
supportedLocales: L10n.supportedLocales,
|
||||
theme: _lightTheme,
|
||||
darkTheme: _darkTheme,
|
||||
themeMode: _themeMode,
|
||||
locale: _locale, // 사용자 선택 로케일 (null이면 시스템 기본값)
|
||||
theme: _theme,
|
||||
navigatorObservers: [_routeObserver],
|
||||
builder: (context, child) {
|
||||
// 현재 로케일을 게임 텍스트 l10n 시스템에 동기화
|
||||
@@ -470,13 +391,18 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
||||
onHallOfFame: _navigateToHallOfFame,
|
||||
onLocalArena: _navigateToArena,
|
||||
onSettings: _showSettings,
|
||||
onPurchaseRemoveAds: _purchaseRemoveAds,
|
||||
onRestorePurchase: _restorePurchase,
|
||||
hasSaveFile: _hasSave,
|
||||
savedGamePreview: _savedGamePreview,
|
||||
hallOfFameCount: _hallOfFame.count,
|
||||
isAdRemovalPurchased: _isAdRemovalPurchased,
|
||||
removeAdsPrice: _removeAdsPrice,
|
||||
routeObserver: _routeObserver,
|
||||
onRefresh: () {
|
||||
_checkForExistingSave();
|
||||
_loadHallOfFame();
|
||||
_updateIAPState();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -551,8 +477,6 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
||||
controller: _controller,
|
||||
audioService: _audioService,
|
||||
forceCarouselLayout: testMode,
|
||||
currentThemeMode: _themeMode,
|
||||
onThemeModeChange: _changeThemeMode,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -568,8 +492,6 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
||||
audioService: _audioService,
|
||||
// 디버그 모드로 저장된 게임 로드 시 캐로셀 레이아웃 강제
|
||||
forceCarouselLayout: _controller.cheatsEnabled,
|
||||
currentThemeMode: _themeMode,
|
||||
onThemeModeChange: _changeThemeMode,
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -613,12 +535,60 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
||||
SettingsScreen.show(
|
||||
context,
|
||||
settingsRepository: _settingsRepository,
|
||||
currentThemeMode: _themeMode,
|
||||
onThemeModeChange: _changeThemeMode,
|
||||
onLocaleChange: _changeLocale,
|
||||
onBgmVolumeChange: _audioService.setBgmVolume,
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 스플래시 화면 (세이브 파일 확인 중) - 레트로 스타일
|
||||
|
||||
@@ -85,6 +85,9 @@ class AudioService {
|
||||
// 오디오 일시정지 상태 (앱 백그라운드 시)
|
||||
bool _isPaused = false;
|
||||
|
||||
// 일시정지 전 재생 중이던 BGM (복귀 시 재개용)
|
||||
String? _pausedBgm;
|
||||
|
||||
// BGM 작업 진행 중 여부 (동시 호출 방지)
|
||||
bool _isBgmBusy = false;
|
||||
|
||||
@@ -357,17 +360,24 @@ class AudioService {
|
||||
/// 전체 오디오 일시정지 (앱 백그라운드 시)
|
||||
Future<void> pauseAll() async {
|
||||
_isPaused = true;
|
||||
_pausedBgm = _currentBgm; // 복귀 시 재개를 위해 저장
|
||||
try {
|
||||
await _staticBgmPlayer?.stop();
|
||||
} catch (_) {}
|
||||
_currentBgm = null;
|
||||
debugPrint('[AudioService] All audio paused');
|
||||
debugPrint('[AudioService] All audio paused (was playing: $_pausedBgm)');
|
||||
}
|
||||
|
||||
/// 전체 오디오 재개 (앱 포그라운드 복귀 시)
|
||||
Future<void> resumeAll() async {
|
||||
_isPaused = false;
|
||||
debugPrint('[AudioService] Audio resumed');
|
||||
// 일시정지 전 재생 중이던 BGM 재개
|
||||
if (_pausedBgm != null) {
|
||||
final bgmToResume = _pausedBgm!;
|
||||
_pausedBgm = null;
|
||||
await playBgm(bgmToResume);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
||||
|
||||
/// 광고 타입
|
||||
@@ -228,32 +230,53 @@ class AdService {
|
||||
final ad = _rewardedAd!;
|
||||
_rewardedAd = null;
|
||||
|
||||
// 결과 추적용
|
||||
var result = AdResult.cancelled;
|
||||
// Completer를 사용하여 광고 종료까지 대기
|
||||
final completer = Completer<AdResult>();
|
||||
var rewarded = false;
|
||||
|
||||
// 광고 표시 전 전체 화면 모드로 전환 (상단 UI 숨김)
|
||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
||||
|
||||
ad.fullScreenContentCallback = FullScreenContentCallback(
|
||||
onAdDismissedFullScreenContent: (ad) {
|
||||
debugPrint('[AdService] Rewarded ad dismissed');
|
||||
// 광고 종료 후 UI 복원
|
||||
SystemChrome.setEnabledSystemUIMode(
|
||||
SystemUiMode.manual,
|
||||
overlays: SystemUiOverlay.values,
|
||||
);
|
||||
ad.dispose();
|
||||
_loadRewardedAd(); // 다음 광고 미리 로드
|
||||
// 보상 수령 여부에 따라 결과 반환
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete(rewarded ? AdResult.completed : AdResult.cancelled);
|
||||
}
|
||||
},
|
||||
onAdFailedToShowFullScreenContent: (ad, error) {
|
||||
debugPrint('[AdService] Rewarded ad failed to show: ${error.message}');
|
||||
// 광고 실패 시에도 UI 복원
|
||||
SystemChrome.setEnabledSystemUIMode(
|
||||
SystemUiMode.manual,
|
||||
overlays: SystemUiOverlay.values,
|
||||
);
|
||||
ad.dispose();
|
||||
result = AdResult.failed;
|
||||
_loadRewardedAd();
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete(AdResult.failed);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await ad.show(
|
||||
onUserEarnedReward: (ad, reward) {
|
||||
debugPrint('[AdService] User earned reward: ${reward.amount}');
|
||||
result = AdResult.completed;
|
||||
rewarded = true;
|
||||
onRewarded();
|
||||
},
|
||||
);
|
||||
|
||||
return result;
|
||||
// 광고가 종료될 때까지 대기
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
@@ -316,30 +339,48 @@ class AdService {
|
||||
final ad = _interstitialAd!;
|
||||
_interstitialAd = null;
|
||||
|
||||
// 결과 추적용
|
||||
var result = AdResult.cancelled;
|
||||
// Completer를 사용하여 광고 종료까지 대기
|
||||
final completer = Completer<AdResult>();
|
||||
|
||||
// 광고 표시 전 전체 화면 모드로 전환 (상단 UI 숨김)
|
||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
||||
|
||||
ad.fullScreenContentCallback = FullScreenContentCallback(
|
||||
onAdDismissedFullScreenContent: (ad) {
|
||||
debugPrint('[AdService] Interstitial ad dismissed');
|
||||
// 광고 종료 후 UI 복원
|
||||
SystemChrome.setEnabledSystemUIMode(
|
||||
SystemUiMode.manual,
|
||||
overlays: SystemUiOverlay.values,
|
||||
);
|
||||
ad.dispose();
|
||||
result = AdResult.completed;
|
||||
onComplete();
|
||||
_loadInterstitialAd(); // 다음 광고 미리 로드
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete(AdResult.completed);
|
||||
}
|
||||
},
|
||||
onAdFailedToShowFullScreenContent: (ad, error) {
|
||||
debugPrint(
|
||||
'[AdService] Interstitial ad failed to show: ${error.message}',
|
||||
);
|
||||
// 광고 실패 시에도 UI 복원
|
||||
SystemChrome.setEnabledSystemUIMode(
|
||||
SystemUiMode.manual,
|
||||
overlays: SystemUiOverlay.values,
|
||||
);
|
||||
ad.dispose();
|
||||
result = AdResult.failed;
|
||||
_loadInterstitialAd();
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete(AdResult.failed);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await ad.show();
|
||||
|
||||
return result;
|
||||
// 광고가 종료될 때까지 대기
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
|
||||
@@ -143,8 +143,14 @@ class CharacterRollService {
|
||||
_rollsRemaining--;
|
||||
_saveRollsRemaining();
|
||||
|
||||
// 무료 유저: 새 굴리기마다 되돌리기 기회 1회 부여 (광고 시청 필요)
|
||||
// 유료 유저: 세션당 최대 횟수 유지
|
||||
if (!_isPaidUser && _undoRemaining < maxUndoFreeUser) {
|
||||
_undoRemaining = maxUndoFreeUser;
|
||||
}
|
||||
|
||||
debugPrint('[CharacterRollService] Rolled: remaining=$_rollsRemaining, '
|
||||
'history=${_rollHistory.length}');
|
||||
'history=${_rollHistory.length}, undo=$_undoRemaining');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
274
lib/src/core/engine/chest_service.dart
Normal file
274
lib/src/core/engine/chest_service.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:in_app_purchase/in_app_purchase.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
|
||||
|
||||
/// IAP 상품 ID
|
||||
class IAPProductIds {
|
||||
IAPProductIds._();
|
||||
@@ -193,7 +195,15 @@ class IAPService {
|
||||
|
||||
/// 광고 제거 상품 가격 문자열
|
||||
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',
|
||||
};
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
|
||||
@@ -89,6 +89,14 @@ class ProgressLoop {
|
||||
_speedMultiplier = _availableSpeeds[nextIndex];
|
||||
}
|
||||
|
||||
/// 특정 배속으로 직접 설정
|
||||
/// 가용 배속 목록에 있는 경우에만 설정
|
||||
void setSpeed(int speed) {
|
||||
if (_availableSpeeds.contains(speed)) {
|
||||
_speedMultiplier = speed;
|
||||
}
|
||||
}
|
||||
|
||||
/// 가용 배속 목록 업데이트 (명예의 전당 상태 변경 시)
|
||||
void updateAvailableSpeeds(List<int> speeds) {
|
||||
if (speeds.isEmpty) return;
|
||||
|
||||
@@ -1,41 +1,19 @@
|
||||
import 'package:flutter/foundation.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';
|
||||
|
||||
/// 복귀 보상 데이터 (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;
|
||||
}
|
||||
import 'package:asciineverdie/src/core/model/treasure_chest.dart';
|
||||
|
||||
/// 복귀 보상 서비스 (Phase 7)
|
||||
///
|
||||
/// 플레이어가 게임에 복귀했을 때 떠나있던 시간에 따라 보상을 제공합니다.
|
||||
/// 플레이어가 게임에 복귀했을 때 떠나있던 시간에 따라 보물 상자를 제공합니다.
|
||||
/// - 최소 복귀 시간: 1시간
|
||||
/// - 최대 복귀 시간: 24시간 (그 이상은 24시간으로 계산)
|
||||
/// - 기본 보상: 시간당 100골드
|
||||
/// - 보너스 보상: 광고 시청 시 2배
|
||||
/// - 기본 보상: 4시간당 1상자
|
||||
/// - 보너스 보상: 광고 시청 시 상자 2배
|
||||
class ReturnRewardsService {
|
||||
ReturnRewardsService._();
|
||||
ReturnRewardsService._() : _chestService = ChestService();
|
||||
|
||||
static ReturnRewardsService? _instance;
|
||||
|
||||
@@ -45,6 +23,8 @@ class ReturnRewardsService {
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
final ChestService _chestService;
|
||||
|
||||
// ===========================================================================
|
||||
// 상수
|
||||
// ===========================================================================
|
||||
@@ -55,11 +35,14 @@ class ReturnRewardsService {
|
||||
/// 최대 복귀 시간 (시간) - 이 이상은 동일 보상
|
||||
static const int maxHoursAway = 24;
|
||||
|
||||
/// 시간당 골드 보상
|
||||
static const int goldPerHour = 100;
|
||||
/// 상자 1개당 필요 시간 (시간)
|
||||
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이면 보상 없음)
|
||||
/// [currentTime] 현재 시각
|
||||
/// [playerLevel] 플레이어 레벨 (레벨 보너스 계산용)
|
||||
/// [isPaidUser] 유료 유저 여부 (최대 상자 개수 결정)
|
||||
/// Returns: 복귀 보상 데이터 (시간이 부족하면 hasReward = false)
|
||||
ReturnReward calculateReward({
|
||||
ReturnChestReward calculateReward({
|
||||
required DateTime? lastPlayTime,
|
||||
required DateTime currentTime,
|
||||
required int playerLevel,
|
||||
required bool isPaidUser,
|
||||
}) {
|
||||
// 마지막 플레이 시간이 없으면 보상 없음
|
||||
if (lastPlayTime == null) {
|
||||
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) {
|
||||
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 levelMultiplier = 1.0 + (playerLevel * goldPerLevelMultiplier);
|
||||
final baseGold = (effectiveHours * goldPerHour * levelMultiplier).round();
|
||||
// 상자 개수 계산
|
||||
final maxChests = isPaidUser ? maxChestsPaid : maxChestsFree;
|
||||
final rawChestCount = effectiveHours ~/ hoursPerChest;
|
||||
final chestCount = rawChestCount.clamp(0, maxChests);
|
||||
|
||||
// 보너스 골드 (광고 시청 시 100% 추가)
|
||||
final bonusGold = baseGold;
|
||||
// 보너스 상자 (광고 시청 시 동일 개수 추가)
|
||||
final bonusChestCount = chestCount;
|
||||
|
||||
debugPrint('[ReturnRewards] $hoursAway hours away, '
|
||||
'base=$baseGold, bonus=$bonusGold, level=$playerLevel');
|
||||
'chests=$chestCount, bonus=$bonusChestCount, paid=$isPaidUser');
|
||||
|
||||
return ReturnReward(
|
||||
return ReturnChestReward(
|
||||
hoursAway: hoursAway,
|
||||
goldReward: baseGold,
|
||||
bonusGold: bonusGold,
|
||||
chestCount: chestCount,
|
||||
bonusChestCount: bonusChestCount,
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 상자 오픈
|
||||
// ===========================================================================
|
||||
|
||||
/// 상자 오픈하여 보상 생성
|
||||
///
|
||||
/// [count] 오픈할 상자 개수
|
||||
/// [playerLevel] 플레이어 레벨 (보상 스케일링용)
|
||||
List<ChestReward> openChests(int count, int playerLevel) {
|
||||
return _chestService.openMultipleChests(count, playerLevel);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 보상 수령
|
||||
// ===========================================================================
|
||||
@@ -119,11 +123,12 @@ class ReturnRewardsService {
|
||||
/// 기본 보상 수령 (광고 없이)
|
||||
///
|
||||
/// [reward] 복귀 보상 데이터
|
||||
/// Returns: 수령한 골드 양
|
||||
int claimBasicReward(ReturnReward reward) {
|
||||
if (!reward.hasReward) return 0;
|
||||
debugPrint('[ReturnRewards] Basic reward claimed: ${reward.goldReward}');
|
||||
return reward.goldReward;
|
||||
/// [playerLevel] 플레이어 레벨
|
||||
/// Returns: 오픈된 상자 보상 목록
|
||||
List<ChestReward> claimBasicReward(ReturnChestReward reward, int playerLevel) {
|
||||
if (!reward.hasReward) return [];
|
||||
debugPrint('[ReturnRewards] Basic reward claimed: ${reward.chestCount} chests');
|
||||
return openChests(reward.chestCount, playerLevel);
|
||||
}
|
||||
|
||||
/// 보너스 보상 수령 (광고 시청 후)
|
||||
@@ -131,32 +136,38 @@ class ReturnRewardsService {
|
||||
/// 유료 유저: 무료 보너스
|
||||
/// 무료 유저: 리워드 광고 시청 후 보너스
|
||||
/// [reward] 복귀 보상 데이터
|
||||
/// Returns: 수령한 보너스 골드 양 (광고 실패 시 0)
|
||||
Future<int> claimBonusReward(ReturnReward reward) async {
|
||||
if (!reward.hasReward || reward.bonusGold <= 0) return 0;
|
||||
/// [playerLevel] 플레이어 레벨
|
||||
/// Returns: 오픈된 보너스 상자 보상 목록 (광고 실패 시 빈 목록)
|
||||
Future<List<ChestReward>> claimBonusReward(
|
||||
ReturnChestReward reward,
|
||||
int playerLevel,
|
||||
) async {
|
||||
if (!reward.hasReward || reward.bonusChestCount <= 0) return [];
|
||||
|
||||
// 유료 유저는 무료 보너스
|
||||
if (IAPService.instance.isAdRemovalPurchased) {
|
||||
debugPrint('[ReturnRewards] Bonus claimed (paid user): ${reward.bonusGold}');
|
||||
return reward.bonusGold;
|
||||
debugPrint('[ReturnRewards] Bonus claimed (paid user): '
|
||||
'${reward.bonusChestCount} chests');
|
||||
return openChests(reward.bonusChestCount, playerLevel);
|
||||
}
|
||||
|
||||
// 무료 유저는 리워드 광고 필요
|
||||
int bonus = 0;
|
||||
List<ChestReward> bonusRewards = [];
|
||||
final adResult = await AdService.instance.showRewardedAd(
|
||||
adType: AdType.rewardRevive, // 복귀 보상용 리워드 광고
|
||||
onRewarded: () {
|
||||
bonus = reward.bonusGold;
|
||||
bonusRewards = openChests(reward.bonusChestCount, playerLevel);
|
||||
},
|
||||
);
|
||||
|
||||
if (adResult == AdResult.completed || adResult == AdResult.debugSkipped) {
|
||||
debugPrint('[ReturnRewards] Bonus claimed (free user with ad): $bonus');
|
||||
return bonus;
|
||||
debugPrint('[ReturnRewards] Bonus claimed (free user with ad): '
|
||||
'${bonusRewards.length} chests');
|
||||
return bonusRewards;
|
||||
}
|
||||
|
||||
debugPrint('[ReturnRewards] Bonus claim failed: $adResult');
|
||||
return 0;
|
||||
return [];
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
|
||||
115
lib/src/core/model/treasure_chest.dart
Normal file
115
lib/src/core/model/treasure_chest.dart
Normal 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;
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// 앱 설정 저장소 (SharedPreferences 기반)
|
||||
///
|
||||
/// 테마, 언어, 사운드 등 사용자 설정을 로컬에 저장
|
||||
/// 언어, 사운드 등 사용자 설정을 로컬에 저장
|
||||
class SettingsRepository {
|
||||
static const _keyThemeMode = 'theme_mode';
|
||||
static const _keyLocale = 'locale';
|
||||
static const _keyBgmVolume = 'bgm_volume';
|
||||
static const _keySfxVolume = 'sfx_volume';
|
||||
@@ -18,29 +16,6 @@ class SettingsRepository {
|
||||
_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 {
|
||||
await init();
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
|
||||
import 'package:asciineverdie/l10n/app_localizations.dart';
|
||||
@@ -18,9 +19,13 @@ class FrontScreen extends StatefulWidget {
|
||||
this.onHallOfFame,
|
||||
this.onLocalArena,
|
||||
this.onSettings,
|
||||
this.onPurchaseRemoveAds,
|
||||
this.onRestorePurchase,
|
||||
this.hasSaveFile = false,
|
||||
this.savedGamePreview,
|
||||
this.hallOfFameCount = 0,
|
||||
this.isAdRemovalPurchased = false,
|
||||
this.removeAdsPrice,
|
||||
this.routeObserver,
|
||||
this.onRefresh,
|
||||
});
|
||||
@@ -40,6 +45,12 @@ class FrontScreen extends StatefulWidget {
|
||||
/// "Settings" 버튼 클릭 시 호출 (언어, 테마, 사운드)
|
||||
final void Function(BuildContext context)? onSettings;
|
||||
|
||||
/// "광고 제거" 구매 버튼 클릭 시 호출
|
||||
final Future<void> Function(BuildContext context)? onPurchaseRemoveAds;
|
||||
|
||||
/// "구매 복원" 버튼 클릭 시 호출
|
||||
final Future<void> Function(BuildContext context)? onRestorePurchase;
|
||||
|
||||
/// 세이브 파일 존재 여부 (새 캐릭터 시 경고용)
|
||||
final bool hasSaveFile;
|
||||
|
||||
@@ -49,6 +60,12 @@ class FrontScreen extends StatefulWidget {
|
||||
/// 명예의 전당 캐릭터 수 (아레나 활성화 조건: 2명 이상)
|
||||
final int hallOfFameCount;
|
||||
|
||||
/// 광고 제거 구매 여부
|
||||
final bool isAdRemovalPurchased;
|
||||
|
||||
/// 광고 제거 상품 가격 (null이면 스토어 비활성)
|
||||
final String? removeAdsPrice;
|
||||
|
||||
/// RouteObserver (화면 복귀 시 갱신용)
|
||||
final RouteObserver<ModalRoute<void>>? routeObserver;
|
||||
|
||||
@@ -132,8 +149,6 @@ class _FrontScreenState extends State<FrontScreen> with RouteAware {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const _RetroHeader(),
|
||||
const SizedBox(height: 16),
|
||||
const _AnimationPanel(),
|
||||
const SizedBox(height: 16),
|
||||
_ActionButtons(
|
||||
@@ -154,8 +169,17 @@ class _FrontScreenState extends State<FrontScreen> with RouteAware {
|
||||
onSettings: widget.onSettings != null
|
||||
? () => widget.onSettings!(context)
|
||||
: null,
|
||||
onPurchaseRemoveAds:
|
||||
widget.onPurchaseRemoveAds != null
|
||||
? () => widget.onPurchaseRemoveAds!(context)
|
||||
: null,
|
||||
onRestorePurchase: widget.onRestorePurchase != null
|
||||
? () => widget.onRestorePurchase!(context)
|
||||
: null,
|
||||
savedGamePreview: widget.savedGamePreview,
|
||||
hallOfFameCount: widget.hallOfFameCount,
|
||||
isAdRemovalPurchased: widget.isAdRemovalPurchased,
|
||||
removeAdsPrice: widget.removeAdsPrice,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -172,58 +196,7 @@ class _FrontScreenState extends State<FrontScreen> with RouteAware {
|
||||
}
|
||||
}
|
||||
|
||||
/// 레트로 스타일 헤더 (타이틀 + 태그)
|
||||
class _RetroHeader extends StatelessWidget {
|
||||
const _RetroHeader();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = L10n.of(context);
|
||||
return RetroGoldPanel(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
|
||||
child: Column(
|
||||
children: [
|
||||
// 타이틀 (픽셀 폰트)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.auto_awesome, color: RetroColors.gold, size: 20),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
l10n.appTitle,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 13,
|
||||
color: RetroColors.gold,
|
||||
shadows: [
|
||||
Shadow(color: RetroColors.goldDark, offset: Offset(2, 2)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// 태그 (레트로 스타일)
|
||||
Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_RetroTag(
|
||||
icon: Icons.cloud_off_outlined,
|
||||
label: l10n.tagNoNetwork,
|
||||
),
|
||||
_RetroTag(icon: Icons.timer_outlined, label: l10n.tagIdleRpg),
|
||||
_RetroTag(icon: Icons.storage_rounded, label: l10n.tagLocalSaves),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 애니메이션 패널
|
||||
/// 애니메이션 패널 (금색 테두리 + 아이콘+타이틀)
|
||||
class _AnimationPanel extends StatelessWidget {
|
||||
const _AnimationPanel();
|
||||
|
||||
@@ -238,8 +211,25 @@ class _AnimationPanel extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return RetroPanel(
|
||||
title: 'BATTLE',
|
||||
return RetroGoldPanel(
|
||||
titleWidget: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.auto_awesome, color: RetroColors.gold, size: 18),
|
||||
const SizedBox(width: 10),
|
||||
const Text(
|
||||
'ASCII NEVER DIE',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: RetroColors.gold,
|
||||
shadows: [
|
||||
Shadow(color: RetroColors.goldDark, offset: Offset(2, 2)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: AspectRatio(
|
||||
aspectRatio: _getAspectRatio(),
|
||||
@@ -257,8 +247,12 @@ class _ActionButtons extends StatelessWidget {
|
||||
this.onHallOfFame,
|
||||
this.onLocalArena,
|
||||
this.onSettings,
|
||||
this.onPurchaseRemoveAds,
|
||||
this.onRestorePurchase,
|
||||
this.savedGamePreview,
|
||||
this.hallOfFameCount = 0,
|
||||
this.isAdRemovalPurchased = false,
|
||||
this.removeAdsPrice,
|
||||
});
|
||||
|
||||
final VoidCallback? onNewCharacter;
|
||||
@@ -266,8 +260,12 @@ class _ActionButtons extends StatelessWidget {
|
||||
final VoidCallback? onHallOfFame;
|
||||
final VoidCallback? onLocalArena;
|
||||
final VoidCallback? onSettings;
|
||||
final VoidCallback? onPurchaseRemoveAds;
|
||||
final VoidCallback? onRestorePurchase;
|
||||
final SavedGamePreview? savedGamePreview;
|
||||
final int hallOfFameCount;
|
||||
final bool isAdRemovalPurchased;
|
||||
final String? removeAdsPrice;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -323,6 +321,24 @@ class _ActionButtons extends StatelessWidget {
|
||||
onPressed: onSettings,
|
||||
isPrimary: false,
|
||||
),
|
||||
// IAP 구매 (광고 제거) - 스토어 사용 가능하고 미구매 상태일 때만 표시
|
||||
if (removeAdsPrice != null && !isAdRemovalPurchased) ...[
|
||||
const SizedBox(height: 20),
|
||||
const Divider(color: RetroColors.panelBorderInner, height: 1),
|
||||
const SizedBox(height: 12),
|
||||
_IapPurchaseButton(
|
||||
price: removeAdsPrice!,
|
||||
onPurchase: onPurchaseRemoveAds,
|
||||
onRestore: onRestorePurchase,
|
||||
),
|
||||
],
|
||||
// 이미 구매된 경우 표시
|
||||
if (isAdRemovalPurchased) ...[
|
||||
const SizedBox(height: 20),
|
||||
const Divider(color: RetroColors.panelBorderInner, height: 1),
|
||||
const SizedBox(height: 12),
|
||||
_PurchasedBadge(),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -370,50 +386,322 @@ class _CopyrightFooter extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Text(
|
||||
game_l10n.copyrightText,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 7,
|
||||
color: RetroColors.textDisabled,
|
||||
child: FutureBuilder<PackageInfo>(
|
||||
future: PackageInfo.fromPlatform(),
|
||||
builder: (context, snapshot) {
|
||||
final version = snapshot.data?.version ?? '';
|
||||
final versionSuffix = version.isNotEmpty ? ' v$version' : '';
|
||||
return Text(
|
||||
'${game_l10n.copyrightText}$versionSuffix',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 7,
|
||||
color: RetroColors.textDisabled,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
const _RetroTag({required this.icon, required this.label});
|
||||
/// 혜택 항목 위젯
|
||||
class _BenefitItem extends StatelessWidget {
|
||||
const _BenefitItem({required this.icon, required this.text});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: RetroColors.panelBgLight,
|
||||
border: Border.all(color: RetroColors.panelBorderInner, width: 1),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, color: RetroColors.gold, size: 12),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, color: RetroColors.expGreen, size: 18),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 11,
|
||||
fontSize: 10,
|
||||
color: RetroColors.textLight,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 이미 구매됨 뱃지
|
||||
class _PurchasedBadge extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: RetroColors.panelBgLight,
|
||||
border: Border.all(color: RetroColors.expGreen, width: 2),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.check_circle, color: RetroColors.expGreen, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
game_l10n.iapAlreadyPurchased,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 11,
|
||||
color: RetroColors.expGreen,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'package:flutter/services.dart' show KeyDownEvent, LogicalKeyboardKey;
|
||||
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
|
||||
import 'package:asciineverdie/data/skill_data.dart';
|
||||
import 'package:asciineverdie/src/core/engine/iap_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/return_rewards_service.dart';
|
||||
import 'package:asciineverdie/src/core/model/treasure_chest.dart';
|
||||
import 'package:asciineverdie/data/story_data.dart';
|
||||
import 'package:asciineverdie/l10n/app_localizations.dart';
|
||||
import 'package:asciineverdie/src/core/animation/ascii_animation_type.dart';
|
||||
@@ -51,8 +51,6 @@ class GamePlayScreen extends StatefulWidget {
|
||||
this.audioService,
|
||||
this.forceCarouselLayout = false,
|
||||
this.forceDesktopLayout = false,
|
||||
this.onThemeModeChange,
|
||||
this.currentThemeMode = ThemeMode.system,
|
||||
});
|
||||
|
||||
final GameSessionController controller;
|
||||
@@ -66,12 +64,6 @@ class GamePlayScreen extends StatefulWidget {
|
||||
/// 테스트 모드: 모바일에서도 데스크톱 3패널 레이아웃 강제 사용
|
||||
final bool forceDesktopLayout;
|
||||
|
||||
/// 테마 모드 변경 콜백
|
||||
final void Function(ThemeMode mode)? onThemeModeChange;
|
||||
|
||||
/// 현재 테마 모드
|
||||
final ThemeMode currentThemeMode;
|
||||
|
||||
@override
|
||||
State<GamePlayScreen> createState() => _GamePlayScreenState();
|
||||
}
|
||||
@@ -316,8 +308,6 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
builder: (_) => GamePlayScreen(
|
||||
controller: widget.controller,
|
||||
audioService: widget.audioService,
|
||||
currentThemeMode: widget.currentThemeMode,
|
||||
onThemeModeChange: widget.onThemeModeChange,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -407,15 +397,19 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
}
|
||||
|
||||
/// 복귀 보상 다이얼로그 표시 (Phase 7)
|
||||
void _showReturnRewardsDialog(ReturnReward reward) {
|
||||
void _showReturnRewardsDialog(ReturnChestReward reward) {
|
||||
// 잠시 후 다이얼로그 표시 (게임 시작 후)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final state = widget.controller.state;
|
||||
if (state == null) return;
|
||||
|
||||
ReturnRewardsDialog.show(
|
||||
context,
|
||||
reward: reward,
|
||||
onClaim: (totalGold) {
|
||||
widget.controller.applyReturnReward(totalGold);
|
||||
playerLevel: state.traits.level,
|
||||
onClaim: (rewards) {
|
||||
widget.controller.applyReturnReward(rewards);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -436,10 +430,6 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
SettingsScreen.show(
|
||||
context,
|
||||
settingsRepository: settingsRepo,
|
||||
currentThemeMode: widget.currentThemeMode,
|
||||
onThemeModeChange: (mode) {
|
||||
widget.onThemeModeChange?.call(mode);
|
||||
},
|
||||
onLocaleChange: (locale) async {
|
||||
// 안전한 언어 변경: 전체 화면 재생성
|
||||
final navigator = Navigator.of(this.context);
|
||||
@@ -452,8 +442,6 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
builder: (_) => GamePlayScreen(
|
||||
controller: widget.controller,
|
||||
audioService: widget.audioService,
|
||||
currentThemeMode: widget.currentThemeMode,
|
||||
onThemeModeChange: widget.onThemeModeChange,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -586,6 +574,10 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
widget.controller.loop?.cycleSpeed();
|
||||
setState(() {});
|
||||
},
|
||||
onSetSpeed: (speed) {
|
||||
widget.controller.loop?.setSpeed(speed);
|
||||
setState(() {});
|
||||
},
|
||||
// 특수 애니메이션 중에는 일시정지 상태로 표시하지 않음
|
||||
isPaused:
|
||||
!widget.controller.isRunning && _specialAnimation == null,
|
||||
@@ -620,8 +612,6 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
builder: (_) => GamePlayScreen(
|
||||
controller: widget.controller,
|
||||
audioService: widget.audioService,
|
||||
currentThemeMode: widget.currentThemeMode,
|
||||
onThemeModeChange: widget.onThemeModeChange,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -637,8 +627,6 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
currentThemeMode: widget.currentThemeMode,
|
||||
onThemeModeChange: widget.onThemeModeChange,
|
||||
// 사운드 설정
|
||||
bgmVolume: _audioController.bgmVolume,
|
||||
sfxVolume: _audioController.sfxVolume,
|
||||
@@ -666,11 +654,13 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
navigator.popUntil((route) => route.isFirst);
|
||||
}
|
||||
},
|
||||
// 수익화 버프 (자동부활, 5배속)
|
||||
// 수익화 버프 (자동부활, 광고배속)
|
||||
autoReviveEndMs: widget.controller.monetization.autoReviveEndMs,
|
||||
speedBoostEndMs: widget.controller.monetization.speedBoostEndMs,
|
||||
isPaidUser: widget.controller.monetization.isPaidUser,
|
||||
onSpeedBoostActivate: _handleSpeedBoost,
|
||||
adSpeedMultiplier: widget.controller.adSpeedMultiplier,
|
||||
has2xUnlocked: widget.controller.has2xUnlocked,
|
||||
),
|
||||
// 사망 오버레이
|
||||
if (state.isDead && state.deathInfo != null)
|
||||
|
||||
@@ -14,6 +14,7 @@ import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||
import 'package:asciineverdie/src/core/model/game_statistics.dart';
|
||||
import 'package:asciineverdie/src/core/model/hall_of_fame.dart';
|
||||
import 'package:asciineverdie/src/core/model/monetization_state.dart';
|
||||
import 'package:asciineverdie/src/core/model/treasure_chest.dart';
|
||||
import 'package:asciineverdie/src/core/storage/hall_of_fame_storage.dart';
|
||||
import 'package:asciineverdie/src/core/storage/save_manager.dart';
|
||||
import 'package:asciineverdie/src/core/storage/statistics_storage.dart';
|
||||
@@ -64,14 +65,16 @@ class GameSessionController extends ChangeNotifier {
|
||||
Timer? _speedBoostTimer;
|
||||
int _speedBoostRemainingSeconds = 0;
|
||||
static const int _speedBoostDuration = 300; // 5분
|
||||
static const int _speedBoostMultiplier = 5; // 5x 속도
|
||||
|
||||
/// 광고 배속 배율 (릴리즈: 5x, 디버그빌드+디버그모드: 20x)
|
||||
int get _speedBoostMultiplier => (kDebugMode && _cheatsEnabled) ? 20 : 5;
|
||||
|
||||
// 복귀 보상 상태 (Phase 7)
|
||||
MonetizationState _monetization = MonetizationState.initial();
|
||||
ReturnReward? _pendingReturnReward;
|
||||
ReturnChestReward? _pendingReturnReward;
|
||||
|
||||
/// 복귀 보상 콜백 (UI에서 다이얼로그 표시용)
|
||||
void Function(ReturnReward reward)? onReturnRewardAvailable;
|
||||
void Function(ReturnChestReward reward)? onReturnRewardAvailable;
|
||||
|
||||
// 통계 관련 필드
|
||||
SessionStatistics _sessionStats = SessionStatistics.empty();
|
||||
@@ -105,6 +108,12 @@ class GameSessionController extends ChangeNotifier {
|
||||
/// 현재 ProgressLoop 인스턴스 (치트 기능용)
|
||||
ProgressLoop? get loop => _loop;
|
||||
|
||||
/// 광고 배속 배율 (릴리즈: 5x, 디버그빌드+디버그모드: 20x)
|
||||
int get adSpeedMultiplier => _speedBoostMultiplier;
|
||||
|
||||
/// 2x 배속 해금 여부 (명예의 전당에 캐릭터가 있으면 true)
|
||||
bool get has2xUnlocked => _loop?.availableSpeeds.contains(2) ?? false;
|
||||
|
||||
Future<void> startNew(
|
||||
GameState initialState, {
|
||||
bool cheatsEnabled = false,
|
||||
@@ -172,13 +181,16 @@ class GameSessionController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
/// 가용 배속 목록 반환
|
||||
/// - 디버그 모드(치트 활성화): [1, 2, 20] (터보 모드 포함)
|
||||
/// - 일반 모드: [1, 2] (5x는 광고 버프로만 활성화)
|
||||
///
|
||||
/// - 기본: [1] (1x만)
|
||||
/// - 명예의 전당에 캐릭터 있으면: [1, 2] (2x 해금)
|
||||
/// - 광고 배속(5x/20x)은 별도 버프로만 활성화
|
||||
Future<List<int>> _getAvailableSpeeds() async {
|
||||
if (_cheatsEnabled) {
|
||||
return [1, 2, 20];
|
||||
final hallOfFame = await _hallOfFameStorage.load();
|
||||
if (hallOfFame.entries.isNotEmpty) {
|
||||
return [1, 2]; // 명예의 전당 캐릭터 있으면 2x 해금
|
||||
}
|
||||
return [1, 2];
|
||||
return [1]; // 기본: 1x만
|
||||
}
|
||||
|
||||
/// 이전 값 초기화 (통계 변화 추적용)
|
||||
@@ -700,7 +712,7 @@ class GameSessionController extends ChangeNotifier {
|
||||
MonetizationState get monetization => _monetization;
|
||||
|
||||
/// 대기 중인 복귀 보상
|
||||
ReturnReward? get pendingReturnReward => _pendingReturnReward;
|
||||
ReturnChestReward? get pendingReturnReward => _pendingReturnReward;
|
||||
|
||||
/// 복귀 보상 체크 (로드 시 호출)
|
||||
void _checkReturnRewards(GameState loaded) {
|
||||
@@ -715,17 +727,17 @@ class GameSessionController extends ChangeNotifier {
|
||||
final reward = rewardsService.calculateReward(
|
||||
lastPlayTime: lastPlayTime,
|
||||
currentTime: DateTime.now(),
|
||||
playerLevel: loaded.traits.level,
|
||||
isPaidUser: _monetization.isPaidUser,
|
||||
);
|
||||
|
||||
if (reward.hasReward) {
|
||||
_pendingReturnReward = reward;
|
||||
debugPrint('[ReturnRewards] Reward available: ${reward.goldReward} gold, '
|
||||
debugPrint('[ReturnRewards] Reward available: ${reward.chestCount} chests, '
|
||||
'${reward.hoursAway} hours away');
|
||||
|
||||
// UI에서 다이얼로그 표시를 위해 콜백 호출
|
||||
// startNew 후에 호출하도록 딜레이
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
Future<void>.delayed(const Duration(milliseconds: 500), () {
|
||||
if (_pendingReturnReward != null) {
|
||||
onReturnRewardAvailable?.call(_pendingReturnReward!);
|
||||
}
|
||||
@@ -733,23 +745,86 @@ class GameSessionController extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// 복귀 보상 수령 완료 (골드 적용)
|
||||
/// 복귀 보상 수령 완료 (상자 보상 적용)
|
||||
///
|
||||
/// [totalGold] 수령한 총 골드 (기본 + 보너스)
|
||||
void applyReturnReward(int totalGold) {
|
||||
/// [rewards] 오픈된 상자 보상 목록
|
||||
void applyReturnReward(List<ChestReward> rewards) {
|
||||
if (_state == null) return;
|
||||
if (totalGold <= 0) {
|
||||
if (rewards.isEmpty) {
|
||||
// 보상 없이 건너뛴 경우
|
||||
_pendingReturnReward = null;
|
||||
debugPrint('[ReturnRewards] Reward skipped');
|
||||
return;
|
||||
}
|
||||
|
||||
// 골드 추가
|
||||
final updatedInventory = _state!.inventory.copyWith(
|
||||
gold: _state!.inventory.gold + totalGold,
|
||||
);
|
||||
_state = _state!.copyWith(inventory: updatedInventory);
|
||||
var updatedState = _state!;
|
||||
|
||||
// 보상 적용
|
||||
for (final reward in rewards) {
|
||||
switch (reward.type) {
|
||||
case ChestRewardType.equipment:
|
||||
if (reward.equipment != null) {
|
||||
// 현재 장비와 비교하여 더 좋으면 자동 장착
|
||||
final slotIndex = reward.equipment!.slot.index;
|
||||
final currentItem = updatedState.equipment.getItemByIndex(slotIndex);
|
||||
if (currentItem.isEmpty ||
|
||||
reward.equipment!.itemWeight > currentItem.itemWeight) {
|
||||
updatedState = updatedState.copyWith(
|
||||
equipment: updatedState.equipment.setItemByIndex(
|
||||
slotIndex,
|
||||
reward.equipment!,
|
||||
),
|
||||
);
|
||||
debugPrint('[ReturnRewards] Equipped: ${reward.equipment!.name}');
|
||||
} else {
|
||||
// 더 좋지 않으면 판매 (골드로 변환)
|
||||
final sellPrice =
|
||||
(reward.equipment!.level * 50 * 0.3).round().clamp(1, 99999);
|
||||
updatedState = updatedState.copyWith(
|
||||
inventory: updatedState.inventory.copyWith(
|
||||
gold: updatedState.inventory.gold + sellPrice,
|
||||
),
|
||||
);
|
||||
debugPrint('[ReturnRewards] Sold: ${reward.equipment!.name} '
|
||||
'for $sellPrice gold');
|
||||
}
|
||||
}
|
||||
case ChestRewardType.potion:
|
||||
if (reward.potionId != null) {
|
||||
updatedState = updatedState.copyWith(
|
||||
potionInventory: updatedState.potionInventory.addPotion(
|
||||
reward.potionId!,
|
||||
reward.potionCount ?? 1,
|
||||
),
|
||||
);
|
||||
debugPrint('[ReturnRewards] Added potion: ${reward.potionId} '
|
||||
'x${reward.potionCount}');
|
||||
}
|
||||
case ChestRewardType.gold:
|
||||
if (reward.gold != null && reward.gold! > 0) {
|
||||
updatedState = updatedState.copyWith(
|
||||
inventory: updatedState.inventory.copyWith(
|
||||
gold: updatedState.inventory.gold + reward.gold!,
|
||||
),
|
||||
);
|
||||
debugPrint('[ReturnRewards] Added gold: ${reward.gold}');
|
||||
}
|
||||
case ChestRewardType.experience:
|
||||
if (reward.experience != null && reward.experience! > 0) {
|
||||
updatedState = updatedState.copyWith(
|
||||
progress: updatedState.progress.copyWith(
|
||||
exp: updatedState.progress.exp.copyWith(
|
||||
position:
|
||||
updatedState.progress.exp.position + reward.experience!,
|
||||
),
|
||||
),
|
||||
);
|
||||
debugPrint('[ReturnRewards] Added experience: ${reward.experience}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_state = updatedState;
|
||||
|
||||
// 저장
|
||||
unawaited(saveManager.saveState(
|
||||
@@ -761,7 +836,7 @@ class GameSessionController extends ChangeNotifier {
|
||||
_pendingReturnReward = null;
|
||||
notifyListeners();
|
||||
|
||||
debugPrint('[ReturnRewards] Reward applied: $totalGold gold');
|
||||
debugPrint('[ReturnRewards] Rewards applied: ${rewards.length} items');
|
||||
}
|
||||
|
||||
/// 복귀 보상 건너뛰기
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -41,6 +41,7 @@ class EnhancedAnimationPanel extends StatefulWidget {
|
||||
this.speedBoostEndMs,
|
||||
this.isPaidUser = false,
|
||||
this.onSpeedBoostActivate,
|
||||
this.adSpeedMultiplier = 5,
|
||||
});
|
||||
|
||||
final ProgressState progress;
|
||||
@@ -75,12 +76,15 @@ class EnhancedAnimationPanel extends StatefulWidget {
|
||||
/// 5배속 버프 종료 시점 (elapsedMs 기준, null이면 비활성)
|
||||
final int? speedBoostEndMs;
|
||||
|
||||
/// 유료 유저 여부 (5배속 항상 활성)
|
||||
/// 유료 유저 여부 (광고배속 항상 활성)
|
||||
final bool isPaidUser;
|
||||
|
||||
/// 5배속 버프 활성화 콜백 (광고 시청)
|
||||
/// 광고 배속 활성화 콜백 (광고 시청)
|
||||
final VoidCallback? onSpeedBoostActivate;
|
||||
|
||||
/// 광고 배속 배율 (릴리즈: 5x, 디버그빌드+디버그모드: 20x)
|
||||
final int adSpeedMultiplier;
|
||||
|
||||
@override
|
||||
State<EnhancedAnimationPanel> createState() => _EnhancedAnimationPanelState();
|
||||
}
|
||||
@@ -284,14 +288,14 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
|
||||
color: Colors.green,
|
||||
),
|
||||
),
|
||||
// 우상단: 5배속 버프
|
||||
// 우상단: 광고배속 버프 (버프 활성 시에만)
|
||||
if (_speedBoostRemainingMs > 0 || widget.isPaidUser)
|
||||
Positioned(
|
||||
right: 4,
|
||||
top: 4,
|
||||
child: _buildBuffChip(
|
||||
icon: '⚡',
|
||||
label: '5x',
|
||||
label: '${widget.adSpeedMultiplier}x',
|
||||
remainingMs: widget.isPaidUser ? -1 : _speedBoostRemainingMs,
|
||||
color: Colors.orange,
|
||||
isPermanent: widget.isPaidUser,
|
||||
@@ -303,7 +307,7 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// 상태 바 영역: HP/MP (40%) + 컨트롤 (20%) + 몬스터 HP (40%)
|
||||
// 상태 바 영역: HP/MP (40%) + 빈공간 (20%) + 몬스터 HP (40%)
|
||||
SizedBox(
|
||||
height: 48,
|
||||
child: Row(
|
||||
@@ -322,11 +326,8 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
|
||||
),
|
||||
),
|
||||
|
||||
// 중앙: 컨트롤 버튼 (20%)
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: _buildControlButtons(),
|
||||
),
|
||||
// 중앙: 빈 공간 (20%)
|
||||
const Spacer(flex: 1),
|
||||
|
||||
// 우측: 몬스터 HP (전투 중) 또는 빈 공간 (40%)
|
||||
Expanded(
|
||||
@@ -673,92 +674,85 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
|
||||
);
|
||||
}
|
||||
|
||||
/// 컨트롤 버튼 (중앙 영역)
|
||||
Widget _buildControlButtons() {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// 상단: 속도 버튼 (1x ↔ 2x)
|
||||
_buildCompactSpeedButton(),
|
||||
const SizedBox(height: 2),
|
||||
// 하단: 5x 광고 버튼 (2x일 때만 표시)
|
||||
_buildAdSpeedButton(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 컴팩트 속도 버튼 (1x ↔ 2x 사이클)
|
||||
Widget _buildCompactSpeedButton() {
|
||||
/// 속도 컨트롤 버튼 (태스크 프로그레스 바 우측)
|
||||
///
|
||||
/// - 일반배속: 1x (기본) ↔ 2x (명예의 전당 해금)
|
||||
/// - 광고배속: 릴리즈 5x, 디버그빌드+디버그모드 20x
|
||||
Widget _buildSpeedControls() {
|
||||
final isSpeedBoostActive = _speedBoostRemainingMs > 0 || widget.isPaidUser;
|
||||
|
||||
return SizedBox(
|
||||
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 adSpeed = widget.adSpeedMultiplier;
|
||||
// 2x일 때 광고 버튼 표시 (버프 비활성이고 무료유저)
|
||||
final showAdButton =
|
||||
widget.speedMultiplier == 2 && !isSpeedBoostActive && !widget.isPaidUser;
|
||||
|
||||
if (!showAdButton) {
|
||||
return const SizedBox(height: 22);
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
height: 22,
|
||||
child: OutlinedButton(
|
||||
onPressed: widget.onSpeedBoostActivate,
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
visualDensity: VisualDensity.compact,
|
||||
side: const BorderSide(color: Colors.orange),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('▶', style: TextStyle(fontSize: 8, color: Colors.orange)),
|
||||
SizedBox(width: 2),
|
||||
Text(
|
||||
'5x',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.orange,
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 속도 사이클 버튼 (1x ↔ 2x, 버프 활성시 광고배속)
|
||||
SizedBox(
|
||||
width: 44,
|
||||
height: 32,
|
||||
child: OutlinedButton(
|
||||
onPressed: widget.onSpeedCycle,
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
side: BorderSide(
|
||||
color: isSpeedBoostActive
|
||||
? Colors.orange
|
||||
: widget.speedMultiplier > 1
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.outline,
|
||||
width: isSpeedBoostActive ? 2 : 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
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;
|
||||
}
|
||||
|
||||
/// 태스크 프로그레스 바
|
||||
/// 태스크 프로그레스 바 + 속도 컨트롤
|
||||
Widget _buildTaskProgress() {
|
||||
final task = widget.progress.task;
|
||||
final progressValue = task.max > 0
|
||||
@@ -792,46 +786,56 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
|
||||
? grade.displayColor
|
||||
: null;
|
||||
|
||||
return Column(
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// 캡션 (등급에 따른 접두사 및 색상)
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
// 좌측: 캡션 + 프로그레스 바
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (gradePrefix.isNotEmpty)
|
||||
// 캡션 (등급에 따른 접두사 및 색상)
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
text: gradePrefix,
|
||||
style: TextStyle(
|
||||
color: gradeColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
children: [
|
||||
if (gradePrefix.isNotEmpty)
|
||||
TextSpan(
|
||||
text: gradePrefix,
|
||||
style: TextStyle(
|
||||
color: gradeColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: _getStatusMessage(),
|
||||
style:
|
||||
gradeColor != null ? TextStyle(color: gradeColor) : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
TextSpan(
|
||||
text: _getStatusMessage(),
|
||||
style: gradeColor != null ? TextStyle(color: gradeColor) : null,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
textAlign: TextAlign.center,
|
||||
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(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -108,13 +108,13 @@ class _BasicsHelpView extends StatelessWidget {
|
||||
? 'ゲーム紹介'
|
||||
: 'About the Game',
|
||||
content: isKorean
|
||||
? 'Askii Never Die는 자동 진행 RPG입니다. 캐릭터가 자동으로 몬스터와 싸우고, '
|
||||
'퀘스트를 완료하며, 레벨업합니다. 여러분은 장비와 스킬을 관리하면 됩니다.'
|
||||
? 'Askii Never Die는 완전 자동 진행 RPG입니다. 캐릭터가 자동으로 몬스터와 싸우고, '
|
||||
'퀘스트를 완료하며, 레벨업합니다. 장비와 스킬도 자동으로 획득/장착됩니다.'
|
||||
: isJapanese
|
||||
? 'Askii Never Dieは自動進行RPGです。キャラクターが自動でモンスターと戦い、'
|
||||
'クエストを完了し、レベルアップします。装備とスキルの管理だけで大丈夫です。'
|
||||
: 'Askii Never Die is an idle RPG. Your character automatically fights monsters, '
|
||||
'completes quests, and levels up. You manage equipment and skills.',
|
||||
? 'Askii Never Dieは完全自動進行RPGです。キャラクターが自動でモンスターと戦い、'
|
||||
'クエストを完了し、レベルアップします。装備とスキルも自動で獲得・装着されます。'
|
||||
: 'Askii Never Die is a fully automatic idle RPG. Your character automatically fights monsters, '
|
||||
'completes quests, and levels up. Equipment and skills are auto-acquired and equipped.',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_HelpSection(
|
||||
@@ -214,20 +214,26 @@ class _CombatHelpView extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_HelpSection(
|
||||
icon: '♥',
|
||||
icon: '♻',
|
||||
title: isKorean
|
||||
? '사망과 부활'
|
||||
? '부활 시스템'
|
||||
: isJapanese
|
||||
? '死亡と復活'
|
||||
: 'Death & Revival',
|
||||
? '復活システム'
|
||||
: 'Revival System',
|
||||
content: isKorean
|
||||
? 'HP가 0이 되면 사망합니다. 사망 시 장비 하나를 제물로 바쳐 부활할 수 있습니다. '
|
||||
'부활 후 HP/MP가 완전 회복되고 빈 장비 슬롯에 기본 장비가 지급됩니다.'
|
||||
? '사망 시 두 가지 부활 방법이 있습니다:\n'
|
||||
'• 기본 부활: 장비 1개 제물, HP/MP 회복\n'
|
||||
'• 광고 부활: 아이템 보존, HP 100%, 10분 자동부활\n'
|
||||
'유료 유저는 항상 광고 없이 부활 가능합니다.'
|
||||
: isJapanese
|
||||
? 'HPが0になると死亡します。死亡時に装備1つを捧げて復活できます。'
|
||||
'復活後HP/MPが完全回復し、空の装備スロットに基本装備が支給されます。'
|
||||
: 'You die when HP reaches 0. Sacrifice one equipment piece to revive. '
|
||||
'After revival, HP/MP fully restore and empty slots get basic equipment.',
|
||||
? '死亡時に2つの復活方法があります:\n'
|
||||
'• 基本復活: 装備1つ消費、HP/MP回復\n'
|
||||
'• 広告復活: アイテム保存、HP100%、10分自動復活\n'
|
||||
'課金ユーザーは常に広告なしで復活可能です。'
|
||||
: 'Two revival methods on death:\n'
|
||||
'• Basic: Sacrifice 1 equipment, restore HP/MP\n'
|
||||
'• Ad Revival: Keep items, 100% HP, 10-min auto-revive\n'
|
||||
'Paid users can always revive without ads.',
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -306,21 +312,21 @@ class _SkillsHelpView extends StatelessWidget {
|
||||
? 'スキルランク'
|
||||
: 'Skill Ranks',
|
||||
content: isKorean
|
||||
? '스킬은 I ~ IX 랭크가 있습니다. 랭크가 높을수록:\n'
|
||||
? '스킬 랭크는 I, II, III... 형태로 표시됩니다. 랭크가 높을수록:\n'
|
||||
'• 데미지/회복량 증가\n'
|
||||
'• MP 소모량 증가\n'
|
||||
'• 쿨타임 증가\n'
|
||||
'• MP 소모량 감소\n'
|
||||
'• 쿨타임 감소\n'
|
||||
'레벨업 시 랜덤하게 스킬을 배웁니다.'
|
||||
: isJapanese
|
||||
? 'スキルにはI~IXランクがあります。ランクが高いほど:\n'
|
||||
? 'スキルランクはI、II、III...の形式で表示されます。ランクが高いほど:\n'
|
||||
'• ダメージ/回復量増加\n'
|
||||
'• MP消費量増加\n'
|
||||
'• クールタイム増加\n'
|
||||
'• MP消費量減少\n'
|
||||
'• クールタイム減少\n'
|
||||
'レベルアップ時にランダムでスキルを習得します。'
|
||||
: 'Skills have ranks I~IX. Higher rank means:\n'
|
||||
: 'Skill ranks are displayed as I, II, III... Higher rank means:\n'
|
||||
'• More damage/healing\n'
|
||||
'• More MP cost\n'
|
||||
'• Longer cooldown\n'
|
||||
'• Less MP cost\n'
|
||||
'• Shorter cooldown\n'
|
||||
'Learn random skills on level up.',
|
||||
),
|
||||
],
|
||||
@@ -348,19 +354,31 @@ class _UIHelpView extends StatelessWidget {
|
||||
? '画面構成'
|
||||
: 'Screen Layout',
|
||||
content: isKorean
|
||||
? '• 상단: 전투 애니메이션, 태스크 진행바\n'
|
||||
'• 좌측: 캐릭터 정보, HP/MP, 스탯\n'
|
||||
'• 중앙: 장비, 인벤토리\n'
|
||||
'• 우측: 플롯/퀘스트 진행, 스펠북'
|
||||
? '모바일에서는 좌우 스와이프로 7개 페이지 탐색:\n'
|
||||
'• 캐릭터: 이름, 레벨, 종족, 직업\n'
|
||||
'• 스탯: STR, DEX, CON, INT 등\n'
|
||||
'• 장비: 무기, 방어구, 액세서리\n'
|
||||
'• 인벤토리: 보유 아이템, 골드\n'
|
||||
'• 스킬북: 습득한 스킬 목록\n'
|
||||
'• 퀘스트: 진행 중인 퀘스트\n'
|
||||
'• 플롯: 스토리 진행 상황'
|
||||
: isJapanese
|
||||
? '• 上部: 戦闘アニメーション、タスク進行バー\n'
|
||||
'• 左側: キャラクター情報、HP/MP、ステータス\n'
|
||||
'• 中央: 装備、インベントリ\n'
|
||||
'• 右側: プロット/クエスト進行、スペルブック'
|
||||
: '• Top: Combat animation, task progress bar\n'
|
||||
'• Left: Character info, HP/MP, stats\n'
|
||||
'• Center: Equipment, inventory\n'
|
||||
'• Right: Plot/quest progress, spellbook',
|
||||
? 'モバイルでは左右スワイプで7ページ切替:\n'
|
||||
'• キャラクター: 名前、レベル、種族、職業\n'
|
||||
'• ステータス: STR、DEX、CON、INT等\n'
|
||||
'• 装備: 武器、防具、アクセサリー\n'
|
||||
'• インベントリ: 所持アイテム、ゴールド\n'
|
||||
'• スキルブック: 習得したスキル一覧\n'
|
||||
'• クエスト: 進行中のクエスト\n'
|
||||
'• プロット: ストーリー進行状況'
|
||||
: 'On mobile, swipe left/right to browse 7 pages:\n'
|
||||
'• Character: Name, level, race, class\n'
|
||||
'• Stats: STR, DEX, CON, INT, etc.\n'
|
||||
'• Equipment: Weapons, armor, accessories\n'
|
||||
'• Inventory: Items, gold\n'
|
||||
'• Skillbook: Learned skills\n'
|
||||
'• Quests: Active quests\n'
|
||||
'• Plot: Story progress',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_HelpSection(
|
||||
@@ -371,22 +389,42 @@ class _UIHelpView extends StatelessWidget {
|
||||
? '速度調整'
|
||||
: 'Speed Control',
|
||||
content: isKorean
|
||||
? '태스크 진행바 옆 속도 버튼으로 게임 속도를 조절할 수 있습니다:\n'
|
||||
? '게임 속도를 조절할 수 있습니다:\n'
|
||||
'• 1x: 기본 속도\n'
|
||||
'• 2x: 2배 속도\n'
|
||||
'• 5x: 5배 속도\n'
|
||||
'• 10x: 10배 속도'
|
||||
'• 2x: 명예의 전당 캐릭터 1명 이상 시 해금\n'
|
||||
'• 5x: 광고 시청으로 5분간 부스트 (유료 유저 무료)'
|
||||
: isJapanese
|
||||
? 'タスク進行バー横の速度ボタンでゲーム速度を調整できます:\n'
|
||||
? 'ゲーム速度を調整できます:\n'
|
||||
'• 1x: 基本速度\n'
|
||||
'• 2x: 2倍速\n'
|
||||
'• 5x: 5倍速\n'
|
||||
'• 10x: 10倍速'
|
||||
: 'Use the speed button next to task bar to adjust game speed:\n'
|
||||
'• 2x: 殿堂入り1人以上で解放\n'
|
||||
'• 5x: 広告視聴で5分間ブースト(課金ユーザー無料)'
|
||||
: 'Adjust game speed:\n'
|
||||
'• 1x: Normal speed\n'
|
||||
'• 2x: 2x speed\n'
|
||||
'• 5x: 5x speed\n'
|
||||
'• 10x: 10x speed',
|
||||
'• 2x: Unlocked with 1+ Hall of Fame character\n'
|
||||
'• 5x: 5-min boost via ad (free for paid users)',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_HelpSection(
|
||||
icon: '🏆',
|
||||
title: isKorean
|
||||
? '명예의 전당'
|
||||
: isJapanese
|
||||
? '殿堂入り'
|
||||
: 'Hall of Fame',
|
||||
content: isKorean
|
||||
? 'Act V를 클리어하면 캐릭터가 명예의 전당에 등록됩니다.\n'
|
||||
'• 캐릭터 이름, 레벨, 스탯이 영구 기록됨\n'
|
||||
'• 첫 등록 시 2x 속도 영구 해금\n'
|
||||
'• 2명 이상 등록 시 로컬 아레나 기능 해금'
|
||||
: isJapanese
|
||||
? 'Act Vクリアでキャラクターが殿堂入りします。\n'
|
||||
'• キャラクター名、レベル、ステータスが永久記録\n'
|
||||
'• 初登録で2倍速が永久解放\n'
|
||||
'• 2人以上でローカルアリーナ機能解放'
|
||||
: 'Characters enter Hall of Fame upon completing Act V.\n'
|
||||
'• Name, level, stats are permanently recorded\n'
|
||||
'• First entry permanently unlocks 2x speed\n'
|
||||
'• 2+ entries unlock Local Arena feature',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_HelpSection(
|
||||
|
||||
@@ -1,37 +1,45 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
|
||||
import 'package:asciineverdie/data/potion_data.dart';
|
||||
import 'package:asciineverdie/src/core/engine/iap_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/return_rewards_service.dart';
|
||||
import 'package:asciineverdie/src/core/model/treasure_chest.dart';
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
|
||||
/// 복귀 보상 다이얼로그 (Phase 7)
|
||||
///
|
||||
/// 게임 복귀 시 보상을 표시하는 다이얼로그
|
||||
/// 게임 복귀 시 보물 상자 보상을 표시하는 다이얼로그
|
||||
class ReturnRewardsDialog extends StatefulWidget {
|
||||
const ReturnRewardsDialog({
|
||||
super.key,
|
||||
required this.reward,
|
||||
required this.playerLevel,
|
||||
required this.onClaim,
|
||||
});
|
||||
|
||||
/// 복귀 보상 데이터
|
||||
final ReturnReward reward;
|
||||
final ReturnChestReward reward;
|
||||
|
||||
/// 보상 수령 콜백 (totalGold)
|
||||
final void Function(int totalGold) onClaim;
|
||||
/// 플레이어 레벨 (상자 보상 스케일링용)
|
||||
final int playerLevel;
|
||||
|
||||
/// 보상 수령 콜백 (상자 보상 목록)
|
||||
final void Function(List<ChestReward> rewards) onClaim;
|
||||
|
||||
/// 다이얼로그 표시
|
||||
static Future<void> show(
|
||||
BuildContext context, {
|
||||
required ReturnReward reward,
|
||||
required void Function(int totalGold) onClaim,
|
||||
required ReturnChestReward reward,
|
||||
required int playerLevel,
|
||||
required void Function(List<ChestReward> rewards) onClaim,
|
||||
}) async {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => ReturnRewardsDialog(
|
||||
reward: reward,
|
||||
playerLevel: playerLevel,
|
||||
onClaim: onClaim,
|
||||
),
|
||||
);
|
||||
@@ -41,27 +49,50 @@ class ReturnRewardsDialog extends StatefulWidget {
|
||||
State<ReturnRewardsDialog> createState() => _ReturnRewardsDialogState();
|
||||
}
|
||||
|
||||
class _ReturnRewardsDialogState extends State<ReturnRewardsDialog> {
|
||||
bool _basicClaimed = false;
|
||||
bool _bonusClaimed = false;
|
||||
bool _isClaimingBonus = false;
|
||||
int _totalClaimed = 0;
|
||||
|
||||
class _ReturnRewardsDialogState extends State<ReturnRewardsDialog>
|
||||
with SingleTickerProviderStateMixin {
|
||||
final _rewardsService = ReturnRewardsService.instance;
|
||||
|
||||
// 상태
|
||||
bool _basicOpened = false;
|
||||
bool _bonusOpened = false;
|
||||
bool _isOpeningBasic = false;
|
||||
bool _isOpeningBonus = false;
|
||||
List<ChestReward> _basicRewards = [];
|
||||
List<ChestReward> _bonusRewards = [];
|
||||
|
||||
// 애니메이션
|
||||
late AnimationController _animController;
|
||||
late Animation<double> _shakeAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 500),
|
||||
);
|
||||
_shakeAnimation = Tween<double>(begin: 0, end: 1).animate(
|
||||
CurvedAnimation(parent: _animController, curve: Curves.elasticOut),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final gold = RetroColors.goldOf(context);
|
||||
final goldDark = RetroColors.goldDarkOf(context);
|
||||
final panelBg = RetroColors.panelBgOf(context);
|
||||
final borderColor = RetroColors.borderOf(context);
|
||||
final expColor = RetroColors.expOf(context);
|
||||
final isPaidUser = IAPService.instance.isAdRemovalPurchased;
|
||||
|
||||
return Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 360),
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
decoration: BoxDecoration(
|
||||
color: panelBg,
|
||||
border: Border(
|
||||
@@ -96,40 +127,41 @@ class _ReturnRewardsDialogState extends State<ReturnRewardsDialog> {
|
||||
),
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 11,
|
||||
fontSize: 10,
|
||||
color: gold.withValues(alpha: 0.8),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 기본 보상
|
||||
_buildRewardSection(
|
||||
// 기본 상자 섹션
|
||||
_buildChestSection(
|
||||
context,
|
||||
title: l10n.returnRewardBasic,
|
||||
gold: widget.reward.goldReward,
|
||||
color: gold,
|
||||
colorDark: goldDark,
|
||||
claimed: _basicClaimed,
|
||||
onClaim: _claimBasic,
|
||||
buttonText: l10n.returnRewardClaim,
|
||||
title: l10n.returnRewardChests(widget.reward.chestCount),
|
||||
chestCount: widget.reward.chestCount,
|
||||
rewards: _basicRewards,
|
||||
isOpened: _basicOpened,
|
||||
isOpening: _isOpeningBasic,
|
||||
onOpen: _openBasicChests,
|
||||
isGold: true,
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 보너스 보상
|
||||
_buildRewardSection(
|
||||
// 보너스 상자 섹션
|
||||
_buildChestSection(
|
||||
context,
|
||||
title: l10n.returnRewardBonus,
|
||||
gold: widget.reward.bonusGold,
|
||||
color: expColor,
|
||||
colorDark: expColor.withValues(alpha: 0.6),
|
||||
claimed: _bonusClaimed,
|
||||
onClaim: _claimBonus,
|
||||
buttonText: l10n.returnRewardClaimBonus,
|
||||
showAdIcon: !isPaidUser,
|
||||
isLoading: _isClaimingBonus,
|
||||
enabled: _basicClaimed && !_bonusClaimed,
|
||||
title: l10n.returnRewardBonusChests,
|
||||
chestCount: widget.reward.bonusChestCount,
|
||||
rewards: _bonusRewards,
|
||||
isOpened: _bonusOpened,
|
||||
isOpening: _isOpeningBonus,
|
||||
onOpen: _openBonusChests,
|
||||
isGold: false,
|
||||
enabled: _basicOpened && !_bonusOpened,
|
||||
showAdIcon: !IAPService.instance.isAdRemovalPurchased,
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 완료/건너뛰기 버튼
|
||||
@@ -154,7 +186,7 @@ class _ReturnRewardsDialogState extends State<ReturnRewardsDialog> {
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('🎁', style: TextStyle(fontSize: 20, color: gold)),
|
||||
const Text('📦', style: TextStyle(fontSize: 20)),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.returnRewardTitle,
|
||||
@@ -166,32 +198,40 @@ class _ReturnRewardsDialogState extends State<ReturnRewardsDialog> {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text('🎁', style: TextStyle(fontSize: 20, color: gold)),
|
||||
const Text('📦', style: TextStyle(fontSize: 20)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRewardSection(
|
||||
Widget _buildChestSection(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required int gold,
|
||||
required Color color,
|
||||
required Color colorDark,
|
||||
required bool claimed,
|
||||
required VoidCallback onClaim,
|
||||
required String buttonText,
|
||||
bool showAdIcon = false,
|
||||
bool isLoading = false,
|
||||
required int chestCount,
|
||||
required List<ChestReward> rewards,
|
||||
required bool isOpened,
|
||||
required bool isOpening,
|
||||
required VoidCallback onOpen,
|
||||
required bool isGold,
|
||||
bool enabled = true,
|
||||
bool showAdIcon = false,
|
||||
}) {
|
||||
final gold = RetroColors.goldOf(context);
|
||||
final expColor = RetroColors.expOf(context);
|
||||
final muted = RetroColors.textMutedOf(context);
|
||||
final color = isGold ? gold : expColor;
|
||||
final isPaidUser = IAPService.instance.isAdRemovalPurchased;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
border: Border.all(color: color.withValues(alpha: 0.5), width: 2),
|
||||
border: Border.all(
|
||||
color: (enabled || isOpened)
|
||||
? color.withValues(alpha: 0.5)
|
||||
: muted.withValues(alpha: 0.3),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
@@ -201,104 +241,111 @@ class _ReturnRewardsDialogState extends State<ReturnRewardsDialog> {
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 11,
|
||||
color: color,
|
||||
color: (enabled || isOpened) ? color : muted,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 골드 표시
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text('💰', style: TextStyle(fontSize: 20)),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.returnRewardGold(gold),
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: claimed ? muted : color,
|
||||
decoration: claimed ? TextDecoration.lineThrough : null,
|
||||
),
|
||||
),
|
||||
if (claimed) ...[
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'✓',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: RetroColors.expOf(context),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
// 상자 아이콘들 또는 보상 목록
|
||||
if (isOpened)
|
||||
_buildRewardsList(context, rewards)
|
||||
else
|
||||
_buildChestIcons(chestCount, color, enabled),
|
||||
|
||||
if (!claimed) ...[
|
||||
if (!isOpened) ...[
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 수령 버튼
|
||||
// 오픈 버튼
|
||||
GestureDetector(
|
||||
onTap: enabled && !isLoading ? onClaim : null,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: enabled
|
||||
? color.withValues(alpha: 0.3)
|
||||
: muted.withValues(alpha: 0.2),
|
||||
border: Border.all(
|
||||
color: enabled ? color : muted,
|
||||
width: 2,
|
||||
onTap: enabled && !isOpening ? onOpen : null,
|
||||
child: AnimatedBuilder(
|
||||
animation: _shakeAnimation,
|
||||
builder: (context, child) {
|
||||
return Transform.translate(
|
||||
offset: isOpening
|
||||
? Offset(
|
||||
_shakeAnimation.value * 2 *
|
||||
((_animController.value * 10).round() % 2 == 0
|
||||
? 1
|
||||
: -1),
|
||||
0,
|
||||
)
|
||||
: Offset.zero,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isLoading) ...[
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Text(
|
||||
buttonText,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 11,
|
||||
color: enabled ? color : muted,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: enabled
|
||||
? color.withValues(alpha: 0.3)
|
||||
: muted.withValues(alpha: 0.2),
|
||||
border: Border.all(
|
||||
color: enabled ? color : muted,
|
||||
width: 2,
|
||||
),
|
||||
if (showAdIcon && !isLoading) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'AD',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 8,
|
||||
color: enabled ? Colors.white : muted,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isOpening) ...[
|
||||
SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
),
|
||||
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) {
|
||||
final gold = RetroColors.goldOf(context);
|
||||
final goldDark = RetroColors.goldDarkOf(context);
|
||||
final muted = RetroColors.textMutedOf(context);
|
||||
|
||||
final canComplete = _basicClaimed;
|
||||
final canComplete = _basicOpened;
|
||||
final buttonColor = canComplete ? gold : muted;
|
||||
final buttonDark = canComplete ? goldDark : muted.withValues(alpha: 0.5);
|
||||
|
||||
@@ -344,43 +497,70 @@ class _ReturnRewardsDialogState extends State<ReturnRewardsDialog> {
|
||||
);
|
||||
}
|
||||
|
||||
void _claimBasic() {
|
||||
if (_basicClaimed) return;
|
||||
|
||||
final claimed = _rewardsService.claimBasicReward(widget.reward);
|
||||
setState(() {
|
||||
_basicClaimed = true;
|
||||
_totalClaimed += claimed;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _claimBonus() async {
|
||||
if (_bonusClaimed || _isClaimingBonus) return;
|
||||
Future<void> _openBasicChests() async {
|
||||
if (_basicOpened || _isOpeningBasic) return;
|
||||
|
||||
setState(() {
|
||||
_isClaimingBonus = true;
|
||||
_isOpeningBasic = true;
|
||||
});
|
||||
|
||||
final bonus = await _rewardsService.claimBonusReward(widget.reward);
|
||||
// 애니메이션 시작
|
||||
_animController.repeat();
|
||||
|
||||
// 약간의 딜레이 후 상자 오픈
|
||||
await Future<void>.delayed(const Duration(milliseconds: 800));
|
||||
|
||||
final rewards = _rewardsService.claimBasicReward(
|
||||
widget.reward,
|
||||
widget.playerLevel,
|
||||
);
|
||||
|
||||
_animController.stop();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isClaimingBonus = false;
|
||||
if (bonus > 0) {
|
||||
_bonusClaimed = true;
|
||||
_totalClaimed += bonus;
|
||||
_isOpeningBasic = false;
|
||||
_basicOpened = true;
|
||||
_basicRewards = rewards;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openBonusChests() async {
|
||||
if (_bonusOpened || _isOpeningBonus) return;
|
||||
|
||||
setState(() {
|
||||
_isOpeningBonus = true;
|
||||
});
|
||||
|
||||
_animController.repeat();
|
||||
|
||||
final rewards = await _rewardsService.claimBonusReward(
|
||||
widget.reward,
|
||||
widget.playerLevel,
|
||||
);
|
||||
|
||||
_animController.stop();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isOpeningBonus = false;
|
||||
if (rewards.isNotEmpty) {
|
||||
_bonusOpened = true;
|
||||
_bonusRewards = rewards;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _complete() {
|
||||
widget.onClaim(_totalClaimed);
|
||||
final allRewards = [..._basicRewards, ..._bonusRewards];
|
||||
widget.onClaim(allRewards);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
void _skip() {
|
||||
widget.onClaim(0);
|
||||
widget.onClaim([]);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,14 +196,20 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
|
||||
final random = math.Random();
|
||||
_currentSeed = random.nextInt(0x7FFFFFFF);
|
||||
|
||||
// 종족/클래스도 랜덤 선택
|
||||
// 종족/클래스 랜덤 선택 및 스탯 굴림
|
||||
setState(() {
|
||||
_selectedRaceIndex = random.nextInt(_races.length);
|
||||
_selectedKlassIndex = random.nextInt(_klasses.length);
|
||||
// 스탯 굴림 (setState 내에서 실행하여 UI 갱신 보장)
|
||||
final rng = DeterministicRandom(_currentSeed);
|
||||
_str = rollStat(rng);
|
||||
_con = rollStat(rng);
|
||||
_dex = rollStat(rng);
|
||||
_int = rollStat(rng);
|
||||
_wis = rollStat(rng);
|
||||
_cha = rollStat(rng);
|
||||
});
|
||||
|
||||
_rollStats();
|
||||
|
||||
// 선택된 종족/직업으로 스크롤
|
||||
_scrollToSelectedItems();
|
||||
|
||||
@@ -296,7 +302,10 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
|
||||
snapshot = await _rollService.undoFreeUser();
|
||||
}
|
||||
|
||||
if (snapshot != null && mounted) {
|
||||
// UI 상태 갱신 (성공/실패 여부와 관계없이 버튼 상태 업데이트)
|
||||
if (!mounted) return;
|
||||
|
||||
if (snapshot != null) {
|
||||
setState(() {
|
||||
_str = snapshot!.stats.str;
|
||||
_con = snapshot.stats.con;
|
||||
@@ -309,6 +318,9 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
|
||||
_currentSeed = snapshot.seed;
|
||||
});
|
||||
_scrollToSelectedItems();
|
||||
} else {
|
||||
// 광고 취소/실패 시에도 버튼 상태 갱신
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,14 +507,17 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
|
||||
: RetroColors.textDisabled,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'DEBUG: TURBO MODE (20x)',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 13,
|
||||
color: _cheatsEnabled
|
||||
? RetroColors.hpRed
|
||||
: RetroColors.textDisabled,
|
||||
Flexible(
|
||||
child: Text(
|
||||
'DEBUG: TURBO (20x)',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 11,
|
||||
color: _cheatsEnabled
|
||||
? RetroColors.hpRed
|
||||
: RetroColors.textDisabled,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,11 @@ class RetroPanel extends StatelessWidget {
|
||||
this.borderWidth = 3.0,
|
||||
this.useGoldBorder = false,
|
||||
this.title,
|
||||
});
|
||||
this.titleWidget,
|
||||
}) : assert(
|
||||
title == null || titleWidget == null,
|
||||
'title과 titleWidget 중 하나만 사용 가능',
|
||||
);
|
||||
|
||||
/// 패널 내부 컨텐츠
|
||||
final Widget child;
|
||||
@@ -34,6 +38,9 @@ class RetroPanel extends StatelessWidget {
|
||||
/// 패널 타이틀 (상단에 표시)
|
||||
final String? title;
|
||||
|
||||
/// 커스텀 타이틀 위젯 (title 대신 사용)
|
||||
final Widget? titleWidget;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final painter = useGoldBorder
|
||||
@@ -46,16 +53,24 @@ class RetroPanel extends StatelessWidget {
|
||||
fillColor: backgroundColor,
|
||||
);
|
||||
|
||||
final hasTitle = title != null || titleWidget != null;
|
||||
|
||||
return CustomPaint(
|
||||
painter: painter,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(borderWidth).add(padding),
|
||||
child: title != null
|
||||
child: hasTitle
|
||||
? Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_PanelTitle(title: title!, useGoldBorder: useGoldBorder),
|
||||
if (titleWidget != null)
|
||||
_PanelTitleContainer(
|
||||
useGoldBorder: useGoldBorder,
|
||||
child: titleWidget!,
|
||||
)
|
||||
else
|
||||
_PanelTitle(title: title!, useGoldBorder: useGoldBorder),
|
||||
const SizedBox(height: 8),
|
||||
Flexible(child: child),
|
||||
],
|
||||
@@ -73,6 +88,33 @@ class _PanelTitle extends StatelessWidget {
|
||||
final String title;
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
@@ -90,15 +132,7 @@ class _PanelTitle extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
title.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: useGoldBorder ? RetroColors.gold : RetroColors.textLight,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -110,11 +144,16 @@ class RetroGoldPanel extends StatelessWidget {
|
||||
required this.child,
|
||||
this.padding = const EdgeInsets.all(12),
|
||||
this.title,
|
||||
});
|
||||
this.titleWidget,
|
||||
}) : assert(
|
||||
title == null || titleWidget == null,
|
||||
'title과 titleWidget 중 하나만 사용 가능',
|
||||
);
|
||||
|
||||
final Widget child;
|
||||
final EdgeInsets padding;
|
||||
final String? title;
|
||||
final Widget? titleWidget;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -122,6 +161,7 @@ class RetroGoldPanel extends StatelessWidget {
|
||||
useGoldBorder: true,
|
||||
padding: padding,
|
||||
title: title,
|
||||
titleWidget: titleWidget,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import Foundation
|
||||
import audio_session
|
||||
import in_app_purchase_storekit
|
||||
import just_audio
|
||||
import package_info_plus
|
||||
import path_provider_foundation
|
||||
import shared_preferences_foundation
|
||||
import webview_flutter_wkwebview
|
||||
@@ -16,6 +17,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin"))
|
||||
InAppPurchasePlugin.register(with: registry.registrar(forPlugin: "InAppPurchasePlugin"))
|
||||
JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin"))
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin"))
|
||||
|
||||
@@ -8,6 +8,8 @@ PODS:
|
||||
- just_audio (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- package_info_plus (0.0.1):
|
||||
- FlutterMacOS
|
||||
- path_provider_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
@@ -23,6 +25,7 @@ DEPENDENCIES:
|
||||
- FlutterMacOS (from `Flutter/ephemeral`)
|
||||
- in_app_purchase_storekit (from `Flutter/ephemeral/.symlinks/plugins/in_app_purchase_storekit/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`)
|
||||
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/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
|
||||
just_audio:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/just_audio/darwin
|
||||
package_info_plus:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos
|
||||
path_provider_foundation:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin
|
||||
shared_preferences_foundation:
|
||||
@@ -48,6 +53,7 @@ SPEC CHECKSUMS:
|
||||
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
||||
in_app_purchase_storekit: 2342c0a5da86593124d08dd13d920f39a52b273a
|
||||
just_audio: a42c63806f16995daf5b219ae1d679deb76e6a79
|
||||
package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b
|
||||
path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba
|
||||
shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
|
||||
webview_flutter_wkwebview: 29eb20d43355b48fe7d07113835b9128f84e3af4
|
||||
|
||||
24
pubspec.lock
24
pubspec.lock
@@ -525,6 +525,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -906,6 +922,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.23.5"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32
|
||||
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.15.0"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -45,6 +45,8 @@ dependencies:
|
||||
google_mobile_ads: ^5.3.0
|
||||
# IAP (인앱 결제)
|
||||
in_app_purchase: ^3.2.0
|
||||
# 앱 버전 정보
|
||||
package_info_plus: ^8.3.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
Reference in New Issue
Block a user