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