diff --git a/Assets/Data/Cards.meta b/Assets/Data/Cards.meta
new file mode 100644
index 0000000..a587697
--- /dev/null
+++ b/Assets/Data/Cards.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 15501d6fecab8d6c39720f46b2644bc9
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Data/Cards/CardDatabase.asset.meta b/Assets/Data/Cards/CardDatabase.asset.meta
new file mode 100644
index 0000000..7e4898d
--- /dev/null
+++ b/Assets/Data/Cards/CardDatabase.asset.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 1632132e32bfc31e8a293140f5908aa3
+NativeFormatImporter:
+ externalObjects: {}
+ mainObjectFileID: 11400000
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Data/Cards/Fedora.meta b/Assets/Data/Cards/Fedora.meta
new file mode 100644
index 0000000..44c21c7
--- /dev/null
+++ b/Assets/Data/Cards/Fedora.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 04f09e15db30a0756af561fca255e85b
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Data/Cards/Fedora/BorrowChecker.asset.meta b/Assets/Data/Cards/Fedora/BorrowChecker.asset.meta
new file mode 100644
index 0000000..6819725
--- /dev/null
+++ b/Assets/Data/Cards/Fedora/BorrowChecker.asset.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 4dfd40ca206341ba9a1a486d69994611
+NativeFormatImporter:
+ externalObjects: {}
+ mainObjectFileID: 11400000
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Data/Cards/Fedora/CargoBuildRelease.asset.meta b/Assets/Data/Cards/Fedora/CargoBuildRelease.asset.meta
new file mode 100644
index 0000000..8828ae0
--- /dev/null
+++ b/Assets/Data/Cards/Fedora/CargoBuildRelease.asset.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 11e141b975f8ab190abf6c267067b575
+NativeFormatImporter:
+ externalObjects: {}
+ mainObjectFileID: 11400000
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Data/Cards/Fedora/DnfUpdate.asset.meta b/Assets/Data/Cards/Fedora/DnfUpdate.asset.meta
new file mode 100644
index 0000000..35b9a5d
--- /dev/null
+++ b/Assets/Data/Cards/Fedora/DnfUpdate.asset.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: b6e048c6b94f10920a31d4c7069ba20f
+NativeFormatImporter:
+ externalObjects: {}
+ mainObjectFileID: 11400000
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Data/Cards/Fedora/Rawhide.asset.meta b/Assets/Data/Cards/Fedora/Rawhide.asset.meta
new file mode 100644
index 0000000..f9a44be
--- /dev/null
+++ b/Assets/Data/Cards/Fedora/Rawhide.asset.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 856019cbd78921a9c8846de8f81029b2
+NativeFormatImporter:
+ externalObjects: {}
+ mainObjectFileID: 11400000
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Data/Cards/Fedora/SELinuxEnforcing.asset.meta b/Assets/Data/Cards/Fedora/SELinuxEnforcing.asset.meta
new file mode 100644
index 0000000..85ba5c9
--- /dev/null
+++ b/Assets/Data/Cards/Fedora/SELinuxEnforcing.asset.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 361650f1e83cad1119b747d294991569
+NativeFormatImporter:
+ externalObjects: {}
+ mainObjectFileID: 11400000
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Data/Cards/Mint.meta b/Assets/Data/Cards/Mint.meta
new file mode 100644
index 0000000..724d856
--- /dev/null
+++ b/Assets/Data/Cards/Mint.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 99cfb62a9ed9a6566aec84be75999c1f
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Data/Cards/Mint/Cinnamon.asset.meta b/Assets/Data/Cards/Mint/Cinnamon.asset.meta
new file mode 100644
index 0000000..f235e33
--- /dev/null
+++ b/Assets/Data/Cards/Mint/Cinnamon.asset.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 3cc3bbc086d6f0d3199af9094f9912d2
+NativeFormatImporter:
+ externalObjects: {}
+ mainObjectFileID: 11400000
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Data/Cards/Mint/FixBroken.asset.meta b/Assets/Data/Cards/Mint/FixBroken.asset.meta
new file mode 100644
index 0000000..79a5980
--- /dev/null
+++ b/Assets/Data/Cards/Mint/FixBroken.asset.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 7b5e9a8f7e3bb8eb6865533dd2b9af2f
+NativeFormatImporter:
+ externalObjects: {}
+ mainObjectFileID: 11400000
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Data/Cards/Mint/Nemo.asset.meta b/Assets/Data/Cards/Mint/Nemo.asset.meta
new file mode 100644
index 0000000..5431f36
--- /dev/null
+++ b/Assets/Data/Cards/Mint/Nemo.asset.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 89c59c54d4bce36aaa836cfdc43f3c9f
+NativeFormatImporter:
+ externalObjects: {}
+ mainObjectFileID: 11400000
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Data/Cards/Mint/TimeshiftSnapshot.asset.meta b/Assets/Data/Cards/Mint/TimeshiftSnapshot.asset.meta
new file mode 100644
index 0000000..83e1efc
--- /dev/null
+++ b/Assets/Data/Cards/Mint/TimeshiftSnapshot.asset.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 07d51e6f24485d6aa9dfe38424e318ea
+NativeFormatImporter:
+ externalObjects: {}
+ mainObjectFileID: 11400000
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Data/Cards/Mint/UpdateManager.asset.meta b/Assets/Data/Cards/Mint/UpdateManager.asset.meta
new file mode 100644
index 0000000..c64e516
--- /dev/null
+++ b/Assets/Data/Cards/Mint/UpdateManager.asset.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: f3cf77257a59a88518167e7e19023304
+NativeFormatImporter:
+ externalObjects: {}
+ mainObjectFileID: 11400000
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Data/Cards/Ubuntu.meta b/Assets/Data/Cards/Ubuntu.meta
new file mode 100644
index 0000000..2590df5
--- /dev/null
+++ b/Assets/Data/Cards/Ubuntu.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: caab84daf052361ab87b05d78d72eb4a
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Data/Cards/Ubuntu/AskUbuntu.asset.meta b/Assets/Data/Cards/Ubuntu/AskUbuntu.asset.meta
new file mode 100644
index 0000000..aba1c03
--- /dev/null
+++ b/Assets/Data/Cards/Ubuntu/AskUbuntu.asset.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 9347bcc42fb6834f1907a2c0b661ff95
+NativeFormatImporter:
+ externalObjects: {}
+ mainObjectFileID: 11400000
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Data/Cards/Ubuntu/DoReleaseUpgrade.asset.meta b/Assets/Data/Cards/Ubuntu/DoReleaseUpgrade.asset.meta
new file mode 100644
index 0000000..8e53edd
--- /dev/null
+++ b/Assets/Data/Cards/Ubuntu/DoReleaseUpgrade.asset.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 1dbc28e480e52d97b9501262af30cc78
+NativeFormatImporter:
+ externalObjects: {}
+ mainObjectFileID: 11400000
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Data/Cards/Ubuntu/SnapInstall.asset.meta b/Assets/Data/Cards/Ubuntu/SnapInstall.asset.meta
new file mode 100644
index 0000000..4df0ed6
--- /dev/null
+++ b/Assets/Data/Cards/Ubuntu/SnapInstall.asset.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 19c884fde6ba18438b976aa154a729df
+NativeFormatImporter:
+ externalObjects: {}
+ mainObjectFileID: 11400000
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Data/Cards/Ubuntu/SudoAptInstall.asset.meta b/Assets/Data/Cards/Ubuntu/SudoAptInstall.asset.meta
new file mode 100644
index 0000000..87441ea
--- /dev/null
+++ b/Assets/Data/Cards/Ubuntu/SudoAptInstall.asset.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 26d4526631046cf5aad90867da18ea81
+NativeFormatImporter:
+ externalObjects: {}
+ mainObjectFileID: 11400000
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Data/Cards/Ubuntu/UbuntuProTrial.asset.meta b/Assets/Data/Cards/Ubuntu/UbuntuProTrial.asset.meta
new file mode 100644
index 0000000..240b298
--- /dev/null
+++ b/Assets/Data/Cards/Ubuntu/UbuntuProTrial.asset.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 7819ca459d587093e942e1b497761d72
+NativeFormatImporter:
+ externalObjects: {}
+ mainObjectFileID: 11400000
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Data/Cards/Ubuntu/UnattendedUpgrades.asset.meta b/Assets/Data/Cards/Ubuntu/UnattendedUpgrades.asset.meta
new file mode 100644
index 0000000..bcaf4fb
--- /dev/null
+++ b/Assets/Data/Cards/Ubuntu/UnattendedUpgrades.asset.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: daae1c711cc8de216966b68adc63d240
+NativeFormatImporter:
+ externalObjects: {}
+ mainObjectFileID: 11400000
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Data/Passives.meta b/Assets/Data/Passives.meta
new file mode 100644
index 0000000..7d5c01f
--- /dev/null
+++ b/Assets/Data/Passives.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 76021ac16bc24526cbdacf1909a7a0cf
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Data/Passives/AptUpdate.asset.meta b/Assets/Data/Passives/AptUpdate.asset.meta
new file mode 100644
index 0000000..d8e753c
--- /dev/null
+++ b/Assets/Data/Passives/AptUpdate.asset.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 19deb73cec3d5dc42a5cc336222ce9df
+NativeFormatImporter:
+ externalObjects: {}
+ mainObjectFileID: 11400000
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Data/Passives/BleedingEdge.asset.meta b/Assets/Data/Passives/BleedingEdge.asset.meta
new file mode 100644
index 0000000..75c6083
--- /dev/null
+++ b/Assets/Data/Passives/BleedingEdge.asset.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: ded62c69f70d38a49b21972d857df4e2
+NativeFormatImporter:
+ externalObjects: {}
+ mainObjectFileID: 11400000
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Data/Passives/ItJustWorks.asset.meta b/Assets/Data/Passives/ItJustWorks.asset.meta
new file mode 100644
index 0000000..5eeb292
--- /dev/null
+++ b/Assets/Data/Passives/ItJustWorks.asset.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: d97481a7d54be2045a96165d3977c980
+NativeFormatImporter:
+ externalObjects: {}
+ mainObjectFileID: 11400000
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/DefaultVolumeProfile.asset b/Assets/DefaultVolumeProfile.asset
index 0cd0e1a..8cea4d2 100644
--- a/Assets/DefaultVolumeProfile.asset
+++ b/Assets/DefaultVolumeProfile.asset
@@ -322,6 +322,9 @@ MonoBehaviour:
highQualityFiltering:
m_OverrideState: 1
m_Value: 0
+ filter:
+ m_OverrideState: 1
+ m_Value: 0
downscale:
m_OverrideState: 1
m_Value: 0
diff --git a/Assets/Scenes/FightScene.unity.meta b/Assets/Scenes/FightScene.unity.meta
new file mode 100644
index 0000000..b8f5e19
--- /dev/null
+++ b/Assets/Scenes/FightScene.unity.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: e9acb3a424fb6f8bfada39dfd8292a93
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Scenes/GamblingScene.unity.meta b/Assets/Scenes/GamblingScene.unity.meta
new file mode 100644
index 0000000..8bf6f0f
--- /dev/null
+++ b/Assets/Scenes/GamblingScene.unity.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: d10c95dab567850aaad42c0c5c999dde
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Scripts/Data/CardDatabase.cs.meta b/Assets/Scripts/Data/CardDatabase.cs.meta
new file mode 100644
index 0000000..2c4d5fb
--- /dev/null
+++ b/Assets/Scripts/Data/CardDatabase.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: fd9e459379f339742a14cf224cd91234
diff --git a/Assets/Scripts/Data/PassiveDefinition.cs.meta b/Assets/Scripts/Data/PassiveDefinition.cs.meta
new file mode 100644
index 0000000..1faf515
--- /dev/null
+++ b/Assets/Scripts/Data/PassiveDefinition.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 9b1f51fb7c354203a2f93ad22fbeb101
diff --git a/Assets/Scripts/Meta/CardLoadout.cs.meta b/Assets/Scripts/Meta/CardLoadout.cs.meta
new file mode 100644
index 0000000..44bca39
--- /dev/null
+++ b/Assets/Scripts/Meta/CardLoadout.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: e003476e8a28025938acf6c377dfbaaa
\ No newline at end of file
diff --git a/Assets/Scripts/Meta/EntropyWallet.cs b/Assets/Scripts/Meta/EntropyWallet.cs
index 83aa4db..b59e6f2 100644
--- a/Assets/Scripts/Meta/EntropyWallet.cs
+++ b/Assets/Scripts/Meta/EntropyWallet.cs
@@ -7,6 +7,11 @@ namespace KernelPanic.Meta
{
public int Balance { get; private set; }
+ public void SetBalance(int amount)
+ {
+ Balance = amount < 0 ? 0 : amount;
+ }
+
public void Add(int amount)
{
Balance += amount;
diff --git a/Assets/Scripts/Meta/GachaBannerState.cs b/Assets/Scripts/Meta/GachaBannerState.cs
new file mode 100644
index 0000000..567191a
--- /dev/null
+++ b/Assets/Scripts/Meta/GachaBannerState.cs
@@ -0,0 +1,34 @@
+using System;
+using System.Collections.Generic;
+
+namespace KernelPanic.Meta
+{
+ ///
+ /// Stores per-banner counters that must persist between sessions.
+ ///
+ [Serializable]
+ public sealed class GachaBannerState
+ {
+ public string bannerId;
+ public int totalPulls;
+ public int pityCounter;
+ public int fiveStarPityCounter;
+ public bool exhausted;
+ public bool featuredFiveStarGuaranteed;
+ public List guaranteedDistroIds = new();
+
+ public GachaBannerState()
+ {
+ }
+
+ public GachaBannerState(string bannerId)
+ {
+ this.bannerId = bannerId;
+ }
+
+ public void EnsureLists()
+ {
+ guaranteedDistroIds ??= new List();
+ }
+ }
+}
diff --git a/Assets/Scripts/Meta/GachaBannerState.cs.meta b/Assets/Scripts/Meta/GachaBannerState.cs.meta
new file mode 100644
index 0000000..9c3861a
--- /dev/null
+++ b/Assets/Scripts/Meta/GachaBannerState.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 29d7890153d140b2a25b1afb304581f9
diff --git a/Assets/Scripts/Meta/GachaCurrencyType.cs b/Assets/Scripts/Meta/GachaCurrencyType.cs
new file mode 100644
index 0000000..4023677
--- /dev/null
+++ b/Assets/Scripts/Meta/GachaCurrencyType.cs
@@ -0,0 +1,11 @@
+namespace KernelPanic.Meta
+{
+ ///
+ /// Identifies currencies spent on banner pulls.
+ ///
+ public enum GachaCurrencyType
+ {
+ StandardPull,
+ LimitedPull
+ }
+}
diff --git a/Assets/Scripts/Meta/GachaCurrencyType.cs.meta b/Assets/Scripts/Meta/GachaCurrencyType.cs.meta
new file mode 100644
index 0000000..75e843f
--- /dev/null
+++ b/Assets/Scripts/Meta/GachaCurrencyType.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 7262840a6d0b4569a70ca757cf4b5d19
diff --git a/Assets/Scripts/Meta/GachaRewardType.cs b/Assets/Scripts/Meta/GachaRewardType.cs
new file mode 100644
index 0000000..a7dce16
--- /dev/null
+++ b/Assets/Scripts/Meta/GachaRewardType.cs
@@ -0,0 +1,12 @@
+namespace KernelPanic.Meta
+{
+ ///
+ /// Describes the persistent reward category produced by a gacha pull.
+ ///
+ public enum GachaRewardType
+ {
+ Equipment,
+ Distro,
+ FutureStandardFiveStarDistro
+ }
+}
diff --git a/Assets/Scripts/Meta/GachaRewardType.cs.meta b/Assets/Scripts/Meta/GachaRewardType.cs.meta
new file mode 100644
index 0000000..ac7c9b9
--- /dev/null
+++ b/Assets/Scripts/Meta/GachaRewardType.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 6ef2e97a60574b3896db93fd09ec5313
diff --git a/Assets/Scripts/Meta/GachaService.cs b/Assets/Scripts/Meta/GachaService.cs
index 4236f45..438b173 100644
--- a/Assets/Scripts/Meta/GachaService.cs
+++ b/Assets/Scripts/Meta/GachaService.cs
@@ -5,16 +5,150 @@ using KernelPanic.Data;
namespace KernelPanic.Meta
{
///
- /// Provides the contract for spending entropy on persistent unlock pulls.
+ /// Provides persistent banner pull logic and currency accounting.
///
public sealed class GachaService
{
- private readonly List bannerPool = new();
+ public const string BeginnerBannerId = "beginner";
+ public const string StandardBannerId = "standard";
+ public const string LimitedBannerId = "limited";
+ public const int BeginnerMaxPulls = 50;
+ public const int BeginnerSinglePullCost = 1;
+ public const int BeginnerTenPullCost = 8;
+ public const int FourStarHardPity = 10;
+ public const int FiveStarSoftPityStart = 66;
+ public const int FiveStarHardPity = 80;
+ public const int EntropyPerPullToken = 100;
- public int PullTokens => 0; // TODO: Replace with the real pull-token wallet when that meta system exists.
+ private const double FourStarBaseChance = 0.12d;
+ private const double FiveStarBaseChance = 0.008d;
+ private const double FiveStarSoftPityStep = 0.07d;
+ private const double DistroChanceOnFeaturedTier = 0.5d;
+ private const double LimitedStandardFiveStarChance = 1d / 3d;
+
+ private readonly List bannerPool = new();
+ private readonly Dictionary currencyBalances = new();
+ private readonly Random random;
+ private GachaBannerState beginnerState = new(BeginnerBannerId);
+
+ public GachaService()
+ : this(new Random())
+ {
+ }
+
+ public GachaService(Random random)
+ {
+ this.random = random ?? new Random();
+ currencyBalances[GachaCurrencyType.StandardPull] = 0;
+ currencyBalances[GachaCurrencyType.LimitedPull] = 0;
+ }
+
+ public int PullTokens => GetCurrencyBalance(GachaCurrencyType.StandardPull);
+ public int LimitedPullTokens => GetCurrencyBalance(GachaCurrencyType.LimitedPull);
+ public int RootCredits { get; private set; }
public IReadOnlyList BannerPool => bannerPool;
+ public GachaBannerState BeginnerState => beginnerState;
+ public bool IsBeginnerBannerAvailable => !beginnerState.exhausted && beginnerState.totalPulls < BeginnerMaxPulls && bannerPool.Count > 0;
public event Action BannerPoolChanged;
+ public event Action Changed;
+
+ public void LoadProgress(SaveData data)
+ {
+ if (data == null)
+ {
+ return;
+ }
+
+ data.EnsureLists();
+ RootCredits = Math.Max(0, data.rootCredits);
+ SetCurrencyBalanceSilently(GachaCurrencyType.StandardPull, data.standardPullCurrency);
+ SetCurrencyBalanceSilently(GachaCurrencyType.LimitedPull, data.limitedPullCurrency);
+ beginnerState = data.beginnerBannerState ?? new GachaBannerState(BeginnerBannerId);
+ beginnerState.bannerId = BeginnerBannerId;
+ beginnerState.EnsureLists();
+ if (beginnerState.totalPulls >= BeginnerMaxPulls)
+ {
+ beginnerState.exhausted = true;
+ }
+
+ beginnerState.fiveStarPityCounter = Math.Max(0, beginnerState.fiveStarPityCounter);
+
+ Changed?.Invoke();
+ }
+
+ public void WriteProgress(SaveData data)
+ {
+ if (data == null)
+ {
+ return;
+ }
+
+ data.EnsureLists();
+ data.rootCredits = RootCredits;
+ data.standardPullCurrency = GetCurrencyBalance(GachaCurrencyType.StandardPull);
+ data.limitedPullCurrency = GetCurrencyBalance(GachaCurrencyType.LimitedPull);
+ data.beginnerBannerState = CloneState(beginnerState);
+ }
+
+ public int GetCurrencyBalance(GachaCurrencyType currencyType)
+ {
+ return currencyBalances.TryGetValue(currencyType, out int balance) ? balance : 0;
+ }
+
+ public void SetCurrencyBalance(GachaCurrencyType currencyType, int amount)
+ {
+ SetCurrencyBalanceSilently(currencyType, amount);
+ Changed?.Invoke();
+ }
+
+ public void AddCurrency(GachaCurrencyType currencyType, int amount)
+ {
+ if (amount <= 0)
+ {
+ return;
+ }
+
+ SetCurrencyBalance(currencyType, GetCurrencyBalance(currencyType) + amount);
+ }
+
+ public void AddRootCredits(int amount)
+ {
+ if (amount <= 0)
+ {
+ return;
+ }
+
+ RootCredits += amount;
+ Changed?.Invoke();
+ }
+
+ public bool ConvertRootCreditsToEntropy(EntropyWallet wallet, int amount, out string failureReason)
+ {
+ if (wallet == null)
+ {
+ failureReason = "entropy wallet unavailable";
+ return false;
+ }
+
+ if (amount <= 0)
+ {
+ failureReason = "no root-credits selected";
+ return false;
+ }
+
+ if (RootCredits < amount)
+ {
+ failureReason = $"need {amount} root-credits";
+ return false;
+ }
+
+ RootCredits -= amount;
+ wallet.Add(amount);
+ failureReason = null;
+ Changed?.Invoke();
+ return true;
+ }
public void AddToBannerPool(DistroDefinition distro)
{
@@ -25,26 +159,446 @@ namespace KernelPanic.Meta
bannerPool.Add(distro);
BannerPoolChanged?.Invoke();
+ Changed?.Invoke();
+ }
+
+ public void SetBeginnerGuaranteedDistros(IReadOnlyList distros)
+ {
+ beginnerState.guaranteedDistroIds.Clear();
+ if (distros != null)
+ {
+ for (int i = 0; i < distros.Count; i++)
+ {
+ DistroDefinition distro = distros[i];
+ if (distro != null && !string.IsNullOrWhiteSpace(distro.Id))
+ {
+ beginnerState.guaranteedDistroIds.Add(distro.Id);
+ }
+ }
+ }
+
+ Changed?.Invoke();
+ }
+
+ public int GetBeginnerPullCost(int pullCount)
+ {
+ return pullCount == 10 ? BeginnerTenPullCost : BeginnerSinglePullCost;
+ }
+
+ public bool CanPullBeginner(int pullCount, out string failureReason)
+ {
+ if (pullCount != 1 && pullCount != 10)
+ {
+ failureReason = "only single and ten pulls are supported";
+ return false;
+ }
+
+ if (!IsBeginnerBannerAvailable)
+ {
+ failureReason = "beginner banner unavailable";
+ return false;
+ }
+
+ int remainingPulls = BeginnerMaxPulls - beginnerState.totalPulls;
+ if (pullCount > remainingPulls)
+ {
+ failureReason = $"only {remainingPulls} beginner pull(s) remain";
+ return false;
+ }
+
+ int cost = GetBeginnerPullCost(pullCount);
+ if (GetCurrencyBalance(GachaCurrencyType.StandardPull) < cost)
+ {
+ failureReason = $"need {cost} {FormatCurrencyName(GachaCurrencyType.StandardPull)}";
+ return false;
+ }
+
+ failureReason = null;
+ return true;
+ }
+
+ public int GetMissingPullTokens(GachaCurrencyType currencyType, int pullCost)
+ {
+ return Math.Max(0, pullCost - GetCurrencyBalance(currencyType));
+ }
+
+ public bool CanCoverMissingPullTokensWithEntropy(EntropyWallet wallet, int missingPullTokens)
+ {
+ return missingPullTokens <= 0 || (wallet != null && wallet.Balance >= missingPullTokens * EntropyPerPullToken);
+ }
+
+ public GachaPullResult PerformBeginnerPull(int pullCount)
+ {
+ return PerformBeginnerPull(pullCount, null, 0);
+ }
+
+ public GachaPullResult PerformBeginnerPull(int pullCount, EntropyWallet wallet, int entropyTokenCount)
+ {
+ if (!CanPullBeginnerWithEntropy(pullCount, wallet, entropyTokenCount, out string failureReason))
+ {
+ return GachaPullResult.Failed(BeginnerBannerId, failureReason);
+ }
+
+ int cost = GetBeginnerPullCost(pullCount);
+ int tokenSpend = cost - entropyTokenCount;
+ if (tokenSpend > 0)
+ {
+ SetCurrencyBalanceSilently(GachaCurrencyType.StandardPull, GetCurrencyBalance(GachaCurrencyType.StandardPull) - tokenSpend);
+ }
+
+ int entropySpent = entropyTokenCount * EntropyPerPullToken;
+ if (entropySpent > 0)
+ {
+ wallet.Spend(entropySpent);
+ }
+
+ List rewards = new();
+ for (int i = 0; i < pullCount; i++)
+ {
+ beginnerState.totalPulls++;
+ rewards.Add(RollBeginnerReward(beginnerState.totalPulls));
+ }
+
+ if (beginnerState.totalPulls >= BeginnerMaxPulls)
+ {
+ beginnerState.exhausted = true;
+ }
+
+ Changed?.Invoke();
+ return new GachaPullResult(true, BeginnerBannerId, GachaCurrencyType.StandardPull, tokenSpend, entropySpent, rewards, null);
+ }
+
+ public static string FormatCurrencyName(GachaCurrencyType currencyType)
+ {
+ return currencyType switch
+ {
+ GachaCurrencyType.LimitedPull => "feature-pull-token",
+ _ => "stable-pull-token"
+ };
+ }
+
+ public static double GetFiveStarChance(GachaBannerState state)
+ {
+ return GetFiveStarChance(state == null ? 0 : state.fiveStarPityCounter);
+ }
+
+ public static double GetFiveStarChance(int fiveStarPityCounter)
+ {
+ int pityPullNumber = Math.Max(0, fiveStarPityCounter) + 1;
+ if (pityPullNumber >= FiveStarHardPity)
+ {
+ return 1d;
+ }
+
+ if (pityPullNumber < FiveStarSoftPityStart)
+ {
+ return FiveStarBaseChance;
+ }
+
+ int softPitySteps = pityPullNumber - FiveStarSoftPityStart + 1;
+ return Math.Min(1d, FiveStarBaseChance + softPitySteps * FiveStarSoftPityStep);
}
public GachaPullResult PerformPull(EntropyWallet wallet, int cost)
{
- throw new NotImplementedException();
+ return PerformBeginnerPull(1);
+ }
+
+ private GachaReward RollBeginnerReward(int pullNumber)
+ {
+ if (pullNumber == 20)
+ {
+ AdvanceFiveStarPityAfterNonFiveStar(beginnerState);
+ return RewardGuaranteedBeginnerDistro(0, "20-pull starter guarantee");
+ }
+
+ if (pullNumber == 40)
+ {
+ AdvanceFiveStarPityAfterNonFiveStar(beginnerState);
+ return RewardGuaranteedBeginnerDistro(1, "40-pull starter guarantee");
+ }
+
+ if (pullNumber == 50)
+ {
+ beginnerState.pityCounter = 0;
+ return GachaReward.FutureStandardFiveStar("future standard 5-star distro", "50-pull beginner capstone");
+ }
+
+ if (TryRollFiveStar(beginnerState, out bool fiveStarPityTriggered))
+ {
+ return RewardRandomFiveStar(fiveStarPityTriggered);
+ }
+
+ bool pityTriggered = beginnerState.pityCounter >= FourStarHardPity - 1;
+ bool hitFourStarOrCharacter = pityTriggered || random.NextDouble() < FourStarBaseChance;
+ if (!hitFourStarOrCharacter)
+ {
+ beginnerState.pityCounter++;
+ return GachaReward.Equipment(3, "3-star equipment", false, false);
+ }
+
+ beginnerState.pityCounter = 0;
+ if (bannerPool.Count > 0 && random.NextDouble() < DistroChanceOnFeaturedTier)
+ {
+ DistroDefinition distro = bannerPool[random.Next(bannerPool.Count)];
+ return GachaReward.DistroReward(distro, 4, pityTriggered, false);
+ }
+
+ return GachaReward.Equipment(4, "4-star equipment", pityTriggered, false);
+ }
+
+ private bool TryRollFiveStar(GachaBannerState state, out bool pityTriggered)
+ {
+ state ??= beginnerState;
+ int pityPullNumber = Math.Max(0, state.fiveStarPityCounter) + 1;
+ pityTriggered = pityPullNumber >= FiveStarSoftPityStart;
+
+ double chance = GetFiveStarChance(state);
+ bool hit = chance >= 1d || random.NextDouble() < chance;
+ if (!hit)
+ {
+ state.fiveStarPityCounter++;
+ return false;
+ }
+
+ state.fiveStarPityCounter = 0;
+ state.pityCounter = 0;
+ return true;
+ }
+
+ private static void AdvanceFiveStarPityAfterNonFiveStar(GachaBannerState state)
+ {
+ if (state != null)
+ {
+ state.fiveStarPityCounter++;
+ }
+ }
+
+ private GachaReward RewardRandomFiveStar(bool pityTriggered)
+ {
+ if (bannerPool.Count == 0 || random.NextDouble() >= DistroChanceOnFeaturedTier)
+ {
+ return GachaReward.Equipment(5, "5-star equipment", pityTriggered, false);
+ }
+
+ DistroDefinition distro = bannerPool.Count == 0 ? null : bannerPool[random.Next(bannerPool.Count)];
+ return GachaReward.DistroReward(distro, 5, pityTriggered, false);
+ }
+
+ public bool ResolveFeaturedTierIsDistro()
+ {
+ return random.NextDouble() < DistroChanceOnFeaturedTier;
+ }
+
+ public bool ResolveLimitedFiveStarUsesStandardPool(GachaBannerState limitedState)
+ {
+ if (limitedState == null)
+ {
+ return false;
+ }
+
+ if (limitedState.featuredFiveStarGuaranteed)
+ {
+ limitedState.featuredFiveStarGuaranteed = false;
+ return false;
+ }
+
+ bool useStandardPool = random.NextDouble() < LimitedStandardFiveStarChance;
+ limitedState.featuredFiveStarGuaranteed = useStandardPool;
+ return useStandardPool;
+ }
+
+ private GachaReward RewardGuaranteedBeginnerDistro(int guaranteeIndex, string reason)
+ {
+ beginnerState.pityCounter = 0;
+ DistroDefinition distro = null;
+ if (guaranteeIndex >= 0 && guaranteeIndex < beginnerState.guaranteedDistroIds.Count)
+ {
+ distro = FindBannerPoolDistro(beginnerState.guaranteedDistroIds[guaranteeIndex]);
+ }
+
+ distro ??= bannerPool.Count == 0 ? null : bannerPool[Math.Min(guaranteeIndex, bannerPool.Count - 1)];
+ return distro == null
+ ? GachaReward.Equipment(4, "4-star equipment", true, true)
+ : GachaReward.DistroReward(distro, 4, true, true, reason);
+ }
+
+ private DistroDefinition FindBannerPoolDistro(string id)
+ {
+ if (string.IsNullOrWhiteSpace(id))
+ {
+ return null;
+ }
+
+ for (int i = 0; i < bannerPool.Count; i++)
+ {
+ DistroDefinition distro = bannerPool[i];
+ if (distro != null && string.Equals(distro.Id, id, StringComparison.OrdinalIgnoreCase))
+ {
+ return distro;
+ }
+ }
+
+ return null;
+ }
+
+ private void SetCurrencyBalanceSilently(GachaCurrencyType currencyType, int amount)
+ {
+ currencyBalances[currencyType] = Math.Max(0, amount);
+ }
+
+ private bool CanPullBeginnerWithEntropy(int pullCount, EntropyWallet wallet, int entropyTokenCount, out string failureReason)
+ {
+ if (pullCount != 1 && pullCount != 10)
+ {
+ failureReason = "only single and ten pulls are supported";
+ return false;
+ }
+
+ if (!IsBeginnerBannerAvailable)
+ {
+ failureReason = "beginner banner unavailable";
+ return false;
+ }
+
+ int remainingPulls = BeginnerMaxPulls - beginnerState.totalPulls;
+ if (pullCount > remainingPulls)
+ {
+ failureReason = $"only {remainingPulls} beginner pull(s) remain";
+ return false;
+ }
+
+ int cost = GetBeginnerPullCost(pullCount);
+ if (entropyTokenCount < 0 || entropyTokenCount > cost)
+ {
+ failureReason = "invalid entropy substitute amount";
+ return false;
+ }
+
+ int tokenSpend = cost - entropyTokenCount;
+ if (GetCurrencyBalance(GachaCurrencyType.StandardPull) < tokenSpend)
+ {
+ failureReason = $"need {tokenSpend} {FormatCurrencyName(GachaCurrencyType.StandardPull)}";
+ return false;
+ }
+
+ int entropySpend = entropyTokenCount * EntropyPerPullToken;
+ if (entropySpend > 0 && (wallet == null || wallet.Balance < entropySpend))
+ {
+ failureReason = $"need {entropySpend} entropy";
+ return false;
+ }
+
+ failureReason = null;
+ return true;
+ }
+
+ private static GachaBannerState CloneState(GachaBannerState state)
+ {
+ state ??= new GachaBannerState(BeginnerBannerId);
+ state.EnsureLists();
+ GachaBannerState clone = new(state.bannerId)
+ {
+ totalPulls = state.totalPulls,
+ pityCounter = state.pityCounter,
+ fiveStarPityCounter = state.fiveStarPityCounter,
+ exhausted = state.exhausted,
+ featuredFiveStarGuaranteed = state.featuredFiveStarGuaranteed
+ };
+ clone.guaranteedDistroIds.AddRange(state.guaranteedDistroIds);
+ return clone;
}
}
///
- /// Represents the result of a persistent unlock pull.
+ /// Describes one reward entry from one pull.
+ ///
+ public readonly struct GachaReward
+ {
+ private GachaReward(
+ GachaRewardType rewardType,
+ int starRating,
+ string displayName,
+ DistroDefinition distro,
+ bool pityTriggered,
+ bool guaranteed,
+ string guaranteeReason)
+ {
+ RewardType = rewardType;
+ StarRating = starRating;
+ DisplayName = displayName;
+ Distro = distro;
+ PityTriggered = pityTriggered;
+ Guaranteed = guaranteed;
+ GuaranteeReason = guaranteeReason;
+ }
+
+ public GachaRewardType RewardType { get; }
+ public int StarRating { get; }
+ public string DisplayName { get; }
+ public DistroDefinition Distro { get; }
+ public bool PityTriggered { get; }
+ public bool Guaranteed { get; }
+ public string GuaranteeReason { get; }
+
+ public static GachaReward Equipment(int starRating, string displayName, bool pityTriggered, bool guaranteed)
+ {
+ return new GachaReward(GachaRewardType.Equipment, starRating, displayName, null, pityTriggered, guaranteed, null);
+ }
+
+ public static GachaReward DistroReward(DistroDefinition distro, int starRating, bool pityTriggered, bool guaranteed, string guaranteeReason = null)
+ {
+ string displayName = distro == null ? "unknown distro" : DistroDisplayName(distro);
+ return new GachaReward(GachaRewardType.Distro, starRating, displayName, distro, pityTriggered, guaranteed, guaranteeReason);
+ }
+
+ public static GachaReward FutureStandardFiveStar(string displayName, string guaranteeReason)
+ {
+ return new GachaReward(GachaRewardType.FutureStandardFiveStarDistro, 5, displayName, null, true, true, guaranteeReason);
+ }
+
+ private static string DistroDisplayName(DistroDefinition distro)
+ {
+ return string.IsNullOrWhiteSpace(distro.DisplayName) ? distro.name : distro.DisplayName;
+ }
+ }
+
+ ///
+ /// Represents the result of a banner pull request.
///
public readonly struct GachaPullResult
{
- public GachaPullResult(bool success, string unlockId)
+ public GachaPullResult(
+ bool success,
+ string bannerId,
+ GachaCurrencyType currencyType,
+ int currencySpent,
+ int entropySpent,
+ IReadOnlyList rewards,
+ string failureReason)
{
Success = success;
- UnlockId = unlockId;
+ BannerId = bannerId;
+ CurrencyType = currencyType;
+ CurrencySpent = currencySpent;
+ EntropySpent = entropySpent;
+ Rewards = rewards ?? Array.Empty();
+ FailureReason = failureReason;
+ UnlockId = Rewards.Count > 0 && Rewards[0].Distro != null ? Rewards[0].Distro.Id : null;
}
public bool Success { get; }
+ public string BannerId { get; }
+ public GachaCurrencyType CurrencyType { get; }
+ public int CurrencySpent { get; }
+ public int EntropySpent { get; }
+ public IReadOnlyList Rewards { get; }
+ public string FailureReason { get; }
public string UnlockId { get; }
+
+ public static GachaPullResult Failed(string bannerId, string reason)
+ {
+ return new GachaPullResult(false, bannerId, GachaCurrencyType.StandardPull, 0, 0, Array.Empty(), reason);
+ }
}
}
diff --git a/Assets/Scripts/Meta/PlayerCollection.cs b/Assets/Scripts/Meta/PlayerCollection.cs
index a39c10d..69d9959 100644
--- a/Assets/Scripts/Meta/PlayerCollection.cs
+++ b/Assets/Scripts/Meta/PlayerCollection.cs
@@ -13,7 +13,6 @@ namespace KernelPanic.Meta
private int featuredIndex;
// TODO: Populate owned units from SaveService during bootstrap.
- // TODO: Add newly unlocked units from GachaService rewards when pulls exist.
public IReadOnlyList OwnedUnits => ownedUnits;
public DistroDefinition FeaturedUnit => ownedUnits.Count == 0 ? null : ownedUnits[Math.Clamp(featuredIndex, 0, ownedUnits.Count - 1)];
@@ -21,7 +20,7 @@ namespace KernelPanic.Meta
public void Add(DistroDefinition unit)
{
- if (unit == null || ownedUnits.Contains(unit))
+ if (unit == null)
{
return;
}
@@ -32,6 +31,26 @@ namespace KernelPanic.Meta
Changed?.Invoke();
}
+ public int GetOwnedCount(string unitId)
+ {
+ if (string.IsNullOrWhiteSpace(unitId))
+ {
+ return 0;
+ }
+
+ int count = 0;
+ for (int i = 0; i < ownedUnits.Count; i++)
+ {
+ DistroDefinition unit = ownedUnits[i];
+ if (unit != null && string.Equals(unit.Id, unitId, StringComparison.OrdinalIgnoreCase))
+ {
+ count++;
+ }
+ }
+
+ return count;
+ }
+
public void SelectNextFeatured()
{
if (ownedUnits.Count < 2)
diff --git a/Assets/Scripts/Meta/SaveService.cs b/Assets/Scripts/Meta/SaveService.cs
index 3aa4ae2..62d5f8c 100644
--- a/Assets/Scripts/Meta/SaveService.cs
+++ b/Assets/Scripts/Meta/SaveService.cs
@@ -58,8 +58,13 @@ namespace KernelPanic.Meta
public sealed class SaveData
{
public bool starterChosen;
+ public int entropyBalance;
+ public int rootCredits;
+ public int standardPullCurrency;
+ public int limitedPullCurrency;
public List ownedUnitIds = new();
public List bannerPoolIds = new();
+ public GachaBannerState beginnerBannerState = new(GachaService.BeginnerBannerId);
public LastRunLoadoutSaveEntry lastRunLoadout = new();
public static SaveData CreateDefault()
@@ -71,6 +76,9 @@ namespace KernelPanic.Meta
{
ownedUnitIds ??= new List();
bannerPoolIds ??= new List();
+ beginnerBannerState ??= new GachaBannerState(GachaService.BeginnerBannerId);
+ beginnerBannerState.bannerId = GachaService.BeginnerBannerId;
+ beginnerBannerState.EnsureLists();
lastRunLoadout ??= new LastRunLoadoutSaveEntry();
lastRunLoadout.EnsureLists();
}
diff --git a/Assets/Scripts/UI/CollectionScreenController.cs.meta b/Assets/Scripts/UI/CollectionScreenController.cs.meta
new file mode 100644
index 0000000..6cf970f
--- /dev/null
+++ b/Assets/Scripts/UI/CollectionScreenController.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: c6c581de5a6bcbc73a451ce3906547bc
\ No newline at end of file
diff --git a/Assets/Scripts/UI/CommandKeyBindings.cs.meta b/Assets/Scripts/UI/CommandKeyBindings.cs.meta
new file mode 100644
index 0000000..d3ce261
--- /dev/null
+++ b/Assets/Scripts/UI/CommandKeyBindings.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 73880a4e893003d719622694aa5ffc02
\ No newline at end of file
diff --git a/Assets/Scripts/UI/DistroPresentation.cs.meta b/Assets/Scripts/UI/DistroPresentation.cs.meta
new file mode 100644
index 0000000..0d31d51
--- /dev/null
+++ b/Assets/Scripts/UI/DistroPresentation.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 4b02ec0c6f7df1d3aa8211567c3ec2a3
\ No newline at end of file
diff --git a/Assets/Scripts/UI/GachaScreenController.cs b/Assets/Scripts/UI/GachaScreenController.cs
new file mode 100644
index 0000000..5f06901
--- /dev/null
+++ b/Assets/Scripts/UI/GachaScreenController.cs
@@ -0,0 +1,487 @@
+using System;
+using System.Collections.Generic;
+using KernelPanic.Data;
+using KernelPanic.Meta;
+using UnityEngine;
+using UnityEngine.TextCore.Text;
+using UnityEngine.UIElements;
+
+namespace KernelPanic.UI
+{
+ ///
+ /// Renders banner selection and pull controls for the gacha screen.
+ ///
+ [Serializable]
+ public sealed class GachaScreenController
+ {
+ private const string SelectedClassName = "selected";
+
+ private readonly List bannerRows = new();
+ private readonly string[] bannerIds =
+ {
+ GachaService.BeginnerBannerId,
+ GachaService.StandardBannerId,
+ GachaService.LimitedBannerId
+ };
+
+ private VisualElement bannerList;
+ private VisualElement bannerDetail;
+ private DistroDatabase distroDatabase;
+ private FontAsset monospaceFont;
+ private GachaService gachaService;
+ private PlayerCollection playerCollection;
+ private EntropyWallet wallet;
+ private Action onChanged;
+ private Action requestRootCreditExchange;
+ private int selectedBannerIndex;
+ private int pendingPullCount;
+ private int pendingMissingTokens;
+ private bool hasOpened;
+ private string resultBannerId;
+ private string resultText;
+
+ public void Bind(VisualElement root, DistroDatabase database, FontAsset artFont, GachaService service, PlayerCollection collection, EntropyWallet entropyWallet, Action changedCallback, Action rootCreditExchangeCallback)
+ {
+ distroDatabase = database;
+ monospaceFont = artFont;
+ gachaService = service;
+ playerCollection = collection;
+ wallet = entropyWallet;
+ onChanged = changedCallback;
+ requestRootCreditExchange = rootCreditExchangeCallback;
+ bannerList = root.Q("GachaBannerList");
+ bannerDetail = root.Q("GachaBannerDetail");
+ }
+
+ public void Open()
+ {
+ if (!hasOpened)
+ {
+ selectedBannerIndex = 0;
+ hasOpened = true;
+ }
+
+ Refresh();
+ }
+
+ public void Refresh()
+ {
+ if (bannerList == null || bannerDetail == null || gachaService == null)
+ {
+ return;
+ }
+
+ bannerRows.Clear();
+ bannerList.Clear();
+
+ for (int i = 0; i < bannerIds.Length; i++)
+ {
+ int index = i;
+ VisualElement row = BuildBannerRow(bannerIds[i]);
+ row.RegisterCallback(_ => SelectBanner(index));
+ bannerList.Add(row);
+ bannerRows.Add(row);
+ }
+
+ selectedBannerIndex = Mathf.Clamp(selectedBannerIndex, 0, bannerRows.Count - 1);
+ ApplySelectedBanner();
+ }
+
+ public void HandleKeyDown(KeyDownEvent evt)
+ {
+ // Pulls are deliberately click-only to avoid accidental currency spend.
+ }
+
+ private VisualElement BuildBannerRow(string bannerId)
+ {
+ VisualElement row = new();
+ row.AddToClassList("gacha-banner-row");
+
+ row.Add(new Label(">") { name = $"GachaBannerCursor_{bannerId}" });
+ row.ElementAt(0).AddToClassList("command-cursor");
+
+ row.Add(new Label(GetBannerTitle(bannerId)) { name = $"GachaBannerName_{bannerId}" });
+ row.ElementAt(1).AddToClassList("command-text");
+
+ row.Add(new Label(GetBannerStatus(bannerId)) { name = $"GachaBannerStatus_{bannerId}" });
+ row.ElementAt(2).AddToClassList("command-description");
+
+ return row;
+ }
+
+ private void SelectBanner(int index)
+ {
+ if (bannerRows.Count == 0)
+ {
+ return;
+ }
+
+ selectedBannerIndex = (index + bannerRows.Count) % bannerRows.Count;
+ ApplySelectedBanner();
+ }
+
+ private void ApplySelectedBanner()
+ {
+ for (int i = 0; i < bannerRows.Count; i++)
+ {
+ bool selected = i == selectedBannerIndex;
+ bannerRows[i].EnableInClassList(SelectedClassName, selected);
+ bannerRows[i].Q