Add metadata for new gacha assets and implement gacha system scaffolding.

This commit is contained in:
2026-07-06 22:10:31 +02:00
parent 4768c9b3e0
commit 22754e4106
56 changed files with 2442 additions and 30 deletions
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 15501d6fecab8d6c39720f46b2644bc9
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 1632132e32bfc31e8a293140f5908aa3
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 04f09e15db30a0756af561fca255e85b
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 4dfd40ca206341ba9a1a486d69994611
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 11e141b975f8ab190abf6c267067b575
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: b6e048c6b94f10920a31d4c7069ba20f
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 856019cbd78921a9c8846de8f81029b2
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 361650f1e83cad1119b747d294991569
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 99cfb62a9ed9a6566aec84be75999c1f
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 3cc3bbc086d6f0d3199af9094f9912d2
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 7b5e9a8f7e3bb8eb6865533dd2b9af2f
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 89c59c54d4bce36aaa836cfdc43f3c9f
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 07d51e6f24485d6aa9dfe38424e318ea
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: f3cf77257a59a88518167e7e19023304
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: caab84daf052361ab87b05d78d72eb4a
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 9347bcc42fb6834f1907a2c0b661ff95
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 1dbc28e480e52d97b9501262af30cc78
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 19c884fde6ba18438b976aa154a729df
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 26d4526631046cf5aad90867da18ea81
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 7819ca459d587093e942e1b497761d72
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: daae1c711cc8de216966b68adc63d240
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 76021ac16bc24526cbdacf1909a7a0cf
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 19deb73cec3d5dc42a5cc336222ce9df
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: ded62c69f70d38a49b21972d857df4e2
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d97481a7d54be2045a96165d3977c980
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
+3
View File
@@ -322,6 +322,9 @@ MonoBehaviour:
highQualityFiltering: highQualityFiltering:
m_OverrideState: 1 m_OverrideState: 1
m_Value: 0 m_Value: 0
filter:
m_OverrideState: 1
m_Value: 0
downscale: downscale:
m_OverrideState: 1 m_OverrideState: 1
m_Value: 0 m_Value: 0
+7
View File
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: e9acb3a424fb6f8bfada39dfd8292a93
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+7
View File
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: d10c95dab567850aaad42c0c5c999dde
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: fd9e459379f339742a14cf224cd91234
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9b1f51fb7c354203a2f93ad22fbeb101
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e003476e8a28025938acf6c377dfbaaa
+5
View File
@@ -7,6 +7,11 @@ namespace KernelPanic.Meta
{ {
public int Balance { get; private set; } public int Balance { get; private set; }
public void SetBalance(int amount)
{
Balance = amount < 0 ? 0 : amount;
}
public void Add(int amount) public void Add(int amount)
{ {
Balance += amount; Balance += amount;
+34
View File
@@ -0,0 +1,34 @@
using System;
using System.Collections.Generic;
namespace KernelPanic.Meta
{
/// <summary>
/// Stores per-banner counters that must persist between sessions.
/// </summary>
[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<string> guaranteedDistroIds = new();
public GachaBannerState()
{
}
public GachaBannerState(string bannerId)
{
this.bannerId = bannerId;
}
public void EnsureLists()
{
guaranteedDistroIds ??= new List<string>();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 29d7890153d140b2a25b1afb304581f9
+11
View File
@@ -0,0 +1,11 @@
namespace KernelPanic.Meta
{
/// <summary>
/// Identifies currencies spent on banner pulls.
/// </summary>
public enum GachaCurrencyType
{
StandardPull,
LimitedPull
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7262840a6d0b4569a70ca757cf4b5d19
+12
View File
@@ -0,0 +1,12 @@
namespace KernelPanic.Meta
{
/// <summary>
/// Describes the persistent reward category produced by a gacha pull.
/// </summary>
public enum GachaRewardType
{
Equipment,
Distro,
FutureStandardFiveStarDistro
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6ef2e97a60574b3896db93fd09ec5313
+561 -7
View File
@@ -5,16 +5,150 @@ using KernelPanic.Data;
namespace KernelPanic.Meta namespace KernelPanic.Meta
{ {
/// <summary> /// <summary>
/// Provides the contract for spending entropy on persistent unlock pulls. /// Provides persistent banner pull logic and currency accounting.
/// </summary> /// </summary>
public sealed class GachaService public sealed class GachaService
{ {
private readonly List<DistroDefinition> 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<DistroDefinition> bannerPool = new();
private readonly Dictionary<GachaCurrencyType, int> 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<DistroDefinition> BannerPool => bannerPool; public IReadOnlyList<DistroDefinition> BannerPool => bannerPool;
public GachaBannerState BeginnerState => beginnerState;
public bool IsBeginnerBannerAvailable => !beginnerState.exhausted && beginnerState.totalPulls < BeginnerMaxPulls && bannerPool.Count > 0;
public event Action BannerPoolChanged; 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) public void AddToBannerPool(DistroDefinition distro)
{ {
@@ -25,26 +159,446 @@ namespace KernelPanic.Meta
bannerPool.Add(distro); bannerPool.Add(distro);
BannerPoolChanged?.Invoke(); BannerPoolChanged?.Invoke();
Changed?.Invoke();
}
public void SetBeginnerGuaranteedDistros(IReadOnlyList<DistroDefinition> 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<GachaReward> 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) 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;
} }
} }
/// <summary> /// <summary>
/// Represents the result of a persistent unlock pull. /// Describes one reward entry from one pull.
/// </summary>
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;
}
}
/// <summary>
/// Represents the result of a banner pull request.
/// </summary> /// </summary>
public readonly struct GachaPullResult public readonly struct GachaPullResult
{ {
public GachaPullResult(bool success, string unlockId) public GachaPullResult(
bool success,
string bannerId,
GachaCurrencyType currencyType,
int currencySpent,
int entropySpent,
IReadOnlyList<GachaReward> rewards,
string failureReason)
{ {
Success = success; Success = success;
UnlockId = unlockId; BannerId = bannerId;
CurrencyType = currencyType;
CurrencySpent = currencySpent;
EntropySpent = entropySpent;
Rewards = rewards ?? Array.Empty<GachaReward>();
FailureReason = failureReason;
UnlockId = Rewards.Count > 0 && Rewards[0].Distro != null ? Rewards[0].Distro.Id : null;
} }
public bool Success { get; } public bool Success { get; }
public string BannerId { get; }
public GachaCurrencyType CurrencyType { get; }
public int CurrencySpent { get; }
public int EntropySpent { get; }
public IReadOnlyList<GachaReward> Rewards { get; }
public string FailureReason { get; }
public string UnlockId { get; } public string UnlockId { get; }
public static GachaPullResult Failed(string bannerId, string reason)
{
return new GachaPullResult(false, bannerId, GachaCurrencyType.StandardPull, 0, 0, Array.Empty<GachaReward>(), reason);
}
} }
} }
+21 -2
View File
@@ -13,7 +13,6 @@ namespace KernelPanic.Meta
private int featuredIndex; private int featuredIndex;
// TODO: Populate owned units from SaveService during bootstrap. // TODO: Populate owned units from SaveService during bootstrap.
// TODO: Add newly unlocked units from GachaService rewards when pulls exist.
public IReadOnlyList<DistroDefinition> OwnedUnits => ownedUnits; public IReadOnlyList<DistroDefinition> OwnedUnits => ownedUnits;
public DistroDefinition FeaturedUnit => ownedUnits.Count == 0 ? null : ownedUnits[Math.Clamp(featuredIndex, 0, ownedUnits.Count - 1)]; 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) public void Add(DistroDefinition unit)
{ {
if (unit == null || ownedUnits.Contains(unit)) if (unit == null)
{ {
return; return;
} }
@@ -32,6 +31,26 @@ namespace KernelPanic.Meta
Changed?.Invoke(); 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() public void SelectNextFeatured()
{ {
if (ownedUnits.Count < 2) if (ownedUnits.Count < 2)
+8
View File
@@ -58,8 +58,13 @@ namespace KernelPanic.Meta
public sealed class SaveData public sealed class SaveData
{ {
public bool starterChosen; public bool starterChosen;
public int entropyBalance;
public int rootCredits;
public int standardPullCurrency;
public int limitedPullCurrency;
public List<string> ownedUnitIds = new(); public List<string> ownedUnitIds = new();
public List<string> bannerPoolIds = new(); public List<string> bannerPoolIds = new();
public GachaBannerState beginnerBannerState = new(GachaService.BeginnerBannerId);
public LastRunLoadoutSaveEntry lastRunLoadout = new(); public LastRunLoadoutSaveEntry lastRunLoadout = new();
public static SaveData CreateDefault() public static SaveData CreateDefault()
@@ -71,6 +76,9 @@ namespace KernelPanic.Meta
{ {
ownedUnitIds ??= new List<string>(); ownedUnitIds ??= new List<string>();
bannerPoolIds ??= new List<string>(); bannerPoolIds ??= new List<string>();
beginnerBannerState ??= new GachaBannerState(GachaService.BeginnerBannerId);
beginnerBannerState.bannerId = GachaService.BeginnerBannerId;
beginnerBannerState.EnsureLists();
lastRunLoadout ??= new LastRunLoadoutSaveEntry(); lastRunLoadout ??= new LastRunLoadoutSaveEntry();
lastRunLoadout.EnsureLists(); lastRunLoadout.EnsureLists();
} }
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c6c581de5a6bcbc73a451ce3906547bc
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 73880a4e893003d719622694aa5ffc02
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4b02ec0c6f7df1d3aa8211567c3ec2a3
+487
View File
@@ -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
{
/// <summary>
/// Renders banner selection and pull controls for the gacha screen.
/// </summary>
[Serializable]
public sealed class GachaScreenController
{
private const string SelectedClassName = "selected";
private readonly List<VisualElement> 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<VisualElement>("GachaBannerList");
bannerDetail = root.Q<VisualElement>("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<ClickEvent>(_ => 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<Label>(className: "command-cursor").visible = selected;
}
RenderSelectedBanner();
}
private void RenderSelectedBanner()
{
bannerDetail.Clear();
string bannerId = bannerIds[selectedBannerIndex];
if (bannerId == GachaService.BeginnerBannerId)
{
RenderBeginnerBanner();
return;
}
RenderFutureBanner(bannerId);
}
private void RenderBeginnerBanner()
{
GachaBannerState state = gachaService.BeginnerState;
bannerDetail.Add(BuildBannerHero("beginner install media"));
Label title = new("beginner install media");
title.AddToClassList("gacha-detail-title");
bannerDetail.Add(title);
bannerDetail.Add(BuildInfoLine("entropy", wallet == null ? "--" : wallet.Balance.ToString()));
bannerDetail.Add(BuildInfoLine("pulls", $"{state.totalPulls}/{GachaService.BeginnerMaxPulls}"));
bannerDetail.Add(BuildInfoLine("4-star pity", $"{state.pityCounter}/{GachaService.FourStarHardPity}"));
bannerDetail.Add(BuildInfoLine("ten pull", $"{GachaService.BeginnerTenPullCost} stable-pull-tokens for 10 pulls"));
bannerDetail.Add(BuildInfoLine("guarantees", FormatBeginnerGuarantees()));
Label rules = new("Base result is a 3-star equipment cache. A 4-star equipment cache or starter distro can appear early, with one 4-star+ result guaranteed every 10 pulls.");
rules.AddToClassList("gacha-rules");
bannerDetail.Add(rules);
VisualElement actions = new();
actions.AddToClassList("gacha-actions");
Button singleButton = new(() => RequestPullSelectedBanner(1))
{
text = $"git pull --single\n1x {GachaService.FormatCurrencyName(GachaCurrencyType.StandardPull)}"
};
singleButton.AddToClassList("terminal-button");
singleButton.AddToClassList("gacha-action-button");
singleButton.SetEnabled(CanAttemptBeginnerPull(1));
actions.Add(singleButton);
Button tenButton = new(() => RequestPullSelectedBanner(10))
{
text = $"git pull --ten\n8x {GachaService.FormatCurrencyName(GachaCurrencyType.StandardPull)}"
};
tenButton.AddToClassList("terminal-button");
tenButton.AddToClassList("gacha-action-button");
tenButton.SetEnabled(CanAttemptBeginnerPull(10));
actions.Add(tenButton);
bannerDetail.Add(actions);
if (pendingPullCount > 0)
{
bannerDetail.Add(BuildEntropyPrompt());
}
if (resultBannerId == GachaService.BeginnerBannerId && !string.IsNullOrWhiteSpace(resultText))
{
Label result = new(resultText);
result.AddToClassList("gacha-result");
bannerDetail.Add(result);
}
}
private void RenderFutureBanner(string bannerId)
{
bannerDetail.Add(BuildBannerHero(GetBannerTitle(bannerId)));
Label title = new(GetBannerTitle(bannerId));
title.AddToClassList("gacha-detail-title");
bannerDetail.Add(title);
string currency = bannerId == GachaService.StandardBannerId
? GachaService.FormatCurrencyName(GachaCurrencyType.StandardPull)
: GachaService.FormatCurrencyName(GachaCurrencyType.LimitedPull);
bannerDetail.Add(BuildInfoLine("pull currency", currency));
bannerDetail.Add(BuildInfoLine("status", "not implemented yet"));
bannerDetail.Add(BuildInfoLine("pity", "own counter, 10-pull 4-star+ guarantee"));
bannerDetail.Add(new Label("Use the beginner implementation as the template: define the banner pool, persist a separate GachaBannerState, then route single/ten pulls through GachaService.") { name = "FutureBannerHint" });
bannerDetail.ElementAt(bannerDetail.childCount - 1).AddToClassList("gacha-rules");
if (resultBannerId == bannerId && !string.IsNullOrWhiteSpace(resultText))
{
Label result = new(resultText);
result.AddToClassList("gacha-result");
bannerDetail.Add(result);
}
}
private void PullSelectedBanner(int pullCount, int entropyTokenCount)
{
string bannerId = bannerIds[selectedBannerIndex];
if (bannerId != GachaService.BeginnerBannerId)
{
resultBannerId = bannerId;
resultText = $"git pull origin {bannerId}: remote not implemented yet";
RenderSelectedBanner();
return;
}
GachaPullResult result = gachaService.PerformBeginnerPull(pullCount, wallet, entropyTokenCount);
if (!result.Success)
{
resultBannerId = bannerId;
resultText = $"git pull failed: {result.FailureReason}";
RenderSelectedBanner();
return;
}
List<string> lines = new();
lines.Add($"git pull complete: spent {result.CurrencySpent} {GachaService.FormatCurrencyName(result.CurrencyType)}");
if (result.EntropySpent > 0)
{
lines.Add($"entropy fallback: spent {result.EntropySpent} entropy");
}
for (int i = 0; i < result.Rewards.Count; i++)
{
GachaReward reward = result.Rewards[i];
bool duplicate = reward.Distro != null && playerCollection.GetOwnedCount(reward.Distro.Id) > 0;
if (reward.Distro != null)
{
playerCollection.Add(reward.Distro);
}
string suffix = reward.Guaranteed ? " guaranteed" : reward.PityTriggered ? " pity" : string.Empty;
string duplicateText = duplicate ? " duplicate" : string.Empty;
lines.Add($"{i + 1:00}: {reward.StarRating}-star {reward.DisplayName}{suffix}{duplicateText}");
}
resultBannerId = bannerId;
resultText = string.Join("\n", lines);
onChanged?.Invoke();
Refresh();
}
private void RequestPullSelectedBanner(int pullCount)
{
string bannerId = bannerIds[selectedBannerIndex];
if (bannerId != GachaService.BeginnerBannerId)
{
PullSelectedBanner(pullCount, 0);
return;
}
int cost = gachaService.GetBeginnerPullCost(pullCount);
int missingTokens = gachaService.GetMissingPullTokens(GachaCurrencyType.StandardPull, cost);
if (missingTokens <= 0)
{
ClearEntropyPrompt();
PullSelectedBanner(pullCount, 0);
return;
}
if (!gachaService.CanCoverMissingPullTokensWithEntropy(wallet, missingTokens))
{
if (gachaService.RootCredits > 0)
{
requestRootCreditExchange?.Invoke();
return;
}
resultBannerId = bannerId;
resultText = $"git pull failed: need {missingTokens} {GachaService.FormatCurrencyName(GachaCurrencyType.StandardPull)} or {missingTokens * GachaService.EntropyPerPullToken} entropy";
RenderSelectedBanner();
return;
}
pendingPullCount = pullCount;
pendingMissingTokens = missingTokens;
resultBannerId = bannerId;
resultText = null;
RenderSelectedBanner();
}
private VisualElement BuildEntropyPrompt()
{
VisualElement prompt = new();
prompt.AddToClassList("gacha-entropy-prompt");
int entropyCost = pendingMissingTokens * GachaService.EntropyPerPullToken;
Label copy = new($"missing {pendingMissingTokens} stable-pull-token(s). spend {entropyCost} entropy to complete git pull?");
copy.AddToClassList("gacha-result");
prompt.Add(copy);
VisualElement actions = new();
actions.AddToClassList("gacha-actions");
Button cancel = new(() =>
{
ClearEntropyPrompt();
RenderSelectedBanner();
})
{
text = "git pull --abort"
};
cancel.AddToClassList("terminal-button");
cancel.AddToClassList("gacha-action-button");
actions.Add(cancel);
Button confirm = new(() =>
{
int pullCount = pendingPullCount;
int missingTokens = pendingMissingTokens;
ClearEntropyPrompt();
PullSelectedBanner(pullCount, missingTokens);
})
{
text = $"git pull --use-entropy={entropyCost}"
};
confirm.AddToClassList("terminal-button");
confirm.AddToClassList("gacha-action-button");
actions.Add(confirm);
prompt.Add(actions);
return prompt;
}
private bool CanAttemptBeginnerPull(int pullCount)
{
if (gachaService.CanPullBeginner(pullCount, out _))
{
return true;
}
if (!gachaService.IsBeginnerBannerAvailable || pullCount > GachaService.BeginnerMaxPulls - gachaService.BeginnerState.totalPulls)
{
return false;
}
int missingTokens = gachaService.GetMissingPullTokens(GachaCurrencyType.StandardPull, gachaService.GetBeginnerPullCost(pullCount));
return missingTokens > 0 &&
(gachaService.CanCoverMissingPullTokensWithEntropy(wallet, missingTokens) ||
gachaService.RootCredits > 0);
}
private void ClearEntropyPrompt()
{
pendingPullCount = 0;
pendingMissingTokens = 0;
}
private VisualElement BuildBannerHero(string bannerTitle)
{
VisualElement hero = new();
hero.AddToClassList("gacha-hero");
VisualElement artRow = new();
artRow.AddToClassList("gacha-hero-art-row");
IReadOnlyList<DistroDefinition> distros = distroDatabase == null ? Array.Empty<DistroDefinition>() : distroDatabase.AllDistros;
int artCount = Mathf.Min(3, distros.Count);
for (int i = 0; i < artCount; i++)
{
artRow.Add(BuildDistroArtCard(distros[i]));
}
if (artCount == 0)
{
Label empty = new("ascii art unavailable");
empty.AddToClassList("gacha-rules");
artRow.Add(empty);
}
Label title = new(bannerTitle);
title.AddToClassList("gacha-hero-title");
hero.Add(artRow);
hero.Add(title);
return hero;
}
private VisualElement BuildDistroArtCard(DistroDefinition distro)
{
VisualElement card = new();
card.AddToClassList("gacha-art-card");
VisualElement artShell = new();
artShell.AddToClassList("gacha-art-shell");
Label artLabel = new();
DistroArtPresenter.ConfigureArtLabel(artLabel, monospaceFont);
artLabel.AddToClassList("gacha-ascii-art");
VisualElement placeholder = DistroArtPresenter.CreatePlaceholder();
placeholder.AddToClassList("gacha-ascii-placeholder");
AsciiArtFitter artFitter = new(artLabel, monospaceFont);
artFitter.SetArt(DistroArtPresenter.Render(artLabel, placeholder, distro));
if (distro != null)
{
artLabel.style.color = new StyleColor(distro.AccentColor);
}
artShell.Add(placeholder);
artShell.Add(artLabel);
card.Add(artShell);
Label name = new(distro == null ? "--" : DistroPresentation.DisplayName(distro));
name.AddToClassList("gacha-art-name");
if (distro != null)
{
name.style.color = new StyleColor(distro.AccentColor);
}
card.Add(name);
return card;
}
private string FormatBeginnerGuarantees()
{
IReadOnlyList<string> ids = gachaService.BeginnerState.guaranteedDistroIds;
string first = ids.Count > 0 ? ids[0] : "--";
string second = ids.Count > 1 ? ids[1] : "--";
return $"20={first}, 40={second}, 50=future standard 5-star";
}
private static VisualElement BuildInfoLine(string key, string value)
{
VisualElement row = new();
row.AddToClassList("kv-row");
row.Add(new Label(key) { name = $"Gacha{key}Key" });
row.ElementAt(0).AddToClassList("kv-key");
row.Add(new Label(value) { name = $"Gacha{key}Value" });
row.ElementAt(1).AddToClassList("kv-value");
return row;
}
private string GetBannerStatus(string bannerId)
{
return bannerId switch
{
GachaService.BeginnerBannerId => gachaService.IsBeginnerBannerAvailable ? $"{gachaService.BeginnerState.totalPulls}/{GachaService.BeginnerMaxPulls}" : "closed",
GachaService.StandardBannerId => "soon",
GachaService.LimitedBannerId => "soon",
_ => "--"
};
}
private static string GetBannerTitle(string bannerId)
{
return bannerId switch
{
GachaService.BeginnerBannerId => "beginner install media",
GachaService.StandardBannerId => "stable branch",
GachaService.LimitedBannerId => "feature branch",
_ => bannerId
};
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0915991bbd2d4fb08d438e4fdae30935
+379 -17
View File
@@ -34,13 +34,16 @@ namespace KernelPanic.UI
bootIntroPlayed = false; bootIntroPlayed = false;
} }
[SerializeField] private FontAsset monospaceFont; // TODO: Assign a real monospace FontAsset when typography assets exist. [SerializeField]
private FontAsset monospaceFont; // TODO: Assign a real monospace FontAsset when typography assets exist.
[SerializeField] private string motdBody = "unstable userspace detected; keep a rollback shell open."; [SerializeField] private string motdBody = "unstable userspace detected; keep a rollback shell open.";
[SerializeField] private DistroDatabase distroDatabase; [SerializeField] private DistroDatabase distroDatabase;
[SerializeField] private CardDatabase cardDatabase; [SerializeField] private CardDatabase cardDatabase;
[SerializeField] private FeaturedUnitPanel featuredUnitPanel = new(); [SerializeField] private FeaturedUnitPanel featuredUnitPanel = new();
[SerializeField] private CollectionScreenController collectionScreen = new(); [SerializeField] private CollectionScreenController collectionScreen = new();
[SerializeField] private StarterSelectionController starterSelection = new(); [SerializeField] private StarterSelectionController starterSelection = new();
[SerializeField] private GachaScreenController gachaScreen = new();
private readonly List<CommandMenuEntry> commandEntries = new(); private readonly List<CommandMenuEntry> commandEntries = new();
private readonly List<VisualElement> packageRows = new(); private readonly List<VisualElement> packageRows = new();
@@ -66,6 +69,20 @@ namespace KernelPanic.UI
private Label bootIntroLogLabel; private Label bootIntroLogLabel;
private Label motdBodyLabel; private Label motdBodyLabel;
private VisualElement motdBlock; private VisualElement motdBlock;
private Button rootCreditsToEntropyButton;
private VisualElement rootCreditExchangeModal;
private Label rootCreditExchangeBalanceLabel;
private Label rootCreditExchangePreviewLabel;
private Label rootCreditExchangeMessageLabel;
private TextField rootCreditExchangeInput;
private Button rootCreditExchangeMinusHundredButton;
private Button rootCreditExchangeMinusOneButton;
private Button rootCreditExchangeNoneButton;
private Button rootCreditExchangePlusOneButton;
private Button rootCreditExchangePlusHundredButton;
private Button rootCreditExchangeMaxButton;
private Button rootCreditExchangeConfirmButton;
private Button rootCreditExchangeCancelButton;
private Button collectionUnitsButton; private Button collectionUnitsButton;
private Button collectionCardsButton; private Button collectionCardsButton;
private readonly ScreenFrameController runSetupFrame = new(); private readonly ScreenFrameController runSetupFrame = new();
@@ -89,7 +106,12 @@ namespace KernelPanic.UI
private bool cursorVisible; private bool cursorVisible;
private bool suppressNextClick; private bool suppressNextClick;
private bool warnedUnresolvedSaveId; private bool warnedUnresolvedSaveId;
private bool rootCreditExchangeOpen;
private string bootIntroCopy; private string bootIntroCopy;
private Action rootCreditExchangeMinusHundredClicked;
private Action rootCreditExchangeMinusOneClicked;
private Action rootCreditExchangePlusOneClicked;
private Action rootCreditExchangePlusHundredClicked;
private IVisualElementScheduledItem blinkSchedule; private IVisualElementScheduledItem blinkSchedule;
private IVisualElementScheduledItem bootIntroSchedule; private IVisualElementScheduledItem bootIntroSchedule;
@@ -104,7 +126,8 @@ namespace KernelPanic.UI
document = GetComponent<UIDocument>(); document = GetComponent<UIDocument>();
saveService = new SaveService(); saveService = new SaveService();
gachaService = new GachaService(); gachaService = new GachaService();
playerCollection = new PlayerCollection(); // TODO: Replace with persistent player-collection service composition. playerCollection =
new PlayerCollection(); // TODO: Replace with persistent player-collection service composition.
cardLoadout = new CardLoadout(playerCollection.OwnedUnits); cardLoadout = new CardLoadout(playerCollection.OwnedUnits);
Initialize(new EntropyWallet()); // TODO: Replace with persistent wallet service composition. Initialize(new EntropyWallet()); // TODO: Replace with persistent wallet service composition.
BindElements(); BindElements();
@@ -114,9 +137,11 @@ namespace KernelPanic.UI
collectionScreen.Bind(root, monospaceFont); collectionScreen.Bind(root, monospaceFont);
BindScreenFrames(); BindScreenFrames();
starterSelection.Bind(root, distroDatabase, HandleStarterConfirmed); starterSelection.Bind(root, distroDatabase, HandleStarterConfirmed);
gachaScreen.Bind(root, distroDatabase, monospaceFont, gachaService, playerCollection, wallet,
HandleMetaStateChanged, OpenRootCreditExchange);
LoadMetaState(); LoadMetaState();
playerCollection.Changed += HandleMetaStateChanged; playerCollection.Changed += HandleMetaStateChanged;
gachaService.BannerPoolChanged += HandleMetaStateChanged; gachaService.Changed += HandleMetaStateChanged;
ApplyOptionalFont(); ApplyOptionalFont();
} }
@@ -130,6 +155,7 @@ namespace KernelPanic.UI
RefreshEventBanner(); RefreshEventBanner();
featuredUnitPanel.Refresh(playerCollection.OwnedUnits, playerCollection.FeaturedUnit); featuredUnitPanel.Refresh(playerCollection.OwnedUnits, playerCollection.FeaturedUnit);
collectionScreen.RefreshUnits(playerCollection.OwnedUnits); collectionScreen.RefreshUnits(playerCollection.OwnedUnits);
gachaScreen.Refresh();
SelectCommand(0); SelectCommand(0);
ShowMainMenu(); ShowMainMenu();
StartAmbientSchedules(); StartAmbientSchedules();
@@ -170,15 +196,36 @@ namespace KernelPanic.UI
bootIntroLogLabel = root.Q<Label>("BootIntroLogLabel"); bootIntroLogLabel = root.Q<Label>("BootIntroLogLabel");
motdBodyLabel = root.Q<Label>("MotdBodyLabel"); motdBodyLabel = root.Q<Label>("MotdBodyLabel");
rootCreditsToEntropyButton = root.Q<Button>("RootCreditsToEntropyButton");
if (rootCreditsToEntropyButton != null)
{
rootCreditsToEntropyButton.focusable = false;
}
rootCreditExchangeModal = root.Q<VisualElement>("RootCreditExchangeModal");
rootCreditExchangeBalanceLabel = root.Q<Label>("RootCreditExchangeBalanceLabel");
rootCreditExchangePreviewLabel = root.Q<Label>("RootCreditExchangePreviewLabel");
rootCreditExchangeMessageLabel = root.Q<Label>("RootCreditExchangeMessageLabel");
rootCreditExchangeInput = root.Q<TextField>("RootCreditExchangeInput");
rootCreditExchangeMinusHundredButton = root.Q<Button>("RootCreditExchangeMinusHundredButton");
rootCreditExchangeMinusOneButton = root.Q<Button>("RootCreditExchangeMinusOneButton");
rootCreditExchangeNoneButton = root.Q<Button>("RootCreditExchangeNoneButton");
rootCreditExchangePlusOneButton = root.Q<Button>("RootCreditExchangePlusOneButton");
rootCreditExchangePlusHundredButton = root.Q<Button>("RootCreditExchangePlusHundredButton");
rootCreditExchangeMaxButton = root.Q<Button>("RootCreditExchangeMaxButton");
rootCreditExchangeConfirmButton = root.Q<Button>("RootCreditExchangeConfirmButton");
rootCreditExchangeCancelButton = root.Q<Button>("RootCreditExchangeCancelButton");
collectionUnitsButton = root.Q<Button>("CollectionUnitsButton"); collectionUnitsButton = root.Q<Button>("CollectionUnitsButton");
collectionCardsButton = root.Q<Button>("CollectionCardsButton"); collectionCardsButton = root.Q<Button>("CollectionCardsButton");
} }
private void BindScreenFrames() private void BindScreenFrames()
{ {
runSetupFrame.Bind(runSetupPanel, "$ ./start_run --configure", "[esc] back [arrows] navigate [enter] select [b] boot run", ShowMainMenu); runSetupFrame.Bind(runSetupPanel, "$ ./start_run --configure",
collectionFrame.Bind(collectionPanel, "$ ls ~/collection", "[esc] back [left/right] tabs [tab] tabs [arrows] navigate [enter] select", ShowMainMenu); "[esc] back [arrows] navigate [enter] select [b] boot run", ShowMainMenu);
gachaFrame.Bind(gachaPanel, "$ curl gacha.sh | sh", "[esc] back", ShowMainMenu); collectionFrame.Bind(collectionPanel, "$ ls ~/collection",
"[esc] back [left/right] tabs [tab] tabs [arrows] navigate [enter] select", ShowMainMenu);
gachaFrame.Bind(gachaPanel, "$ curl gacha.sh | sh", "[esc] back click banner click pull", ShowMainMenu);
settingsFrame.Bind(settingsPanel, "$ dpkg-reconfigure kernel-panic", "[esc] back", ShowMainMenu); settingsFrame.Bind(settingsPanel, "$ dpkg-reconfigure kernel-panic", "[esc] back", ShowMainMenu);
} }
@@ -206,6 +253,60 @@ namespace KernelPanic.UI
{ {
root.RegisterCallback<KeyDownEvent>(HandleKeyDown); root.RegisterCallback<KeyDownEvent>(HandleKeyDown);
root.RegisterCallback<PointerDownEvent>(HandlePointerDown); root.RegisterCallback<PointerDownEvent>(HandlePointerDown);
if (rootCreditsToEntropyButton != null)
{
rootCreditsToEntropyButton.clicked += HandleRootCreditsToEntropyClicked;
}
if (rootCreditExchangeInput != null)
{
rootCreditExchangeInput.RegisterValueChangedCallback(HandleRootCreditExchangeInputChanged);
}
if (rootCreditExchangeMinusHundredButton != null)
{
rootCreditExchangeMinusHundredClicked ??= () => AddRootCreditExchangeAmount(-100);
rootCreditExchangeMinusHundredButton.clicked += rootCreditExchangeMinusHundredClicked;
}
if (rootCreditExchangeMinusOneButton != null)
{
rootCreditExchangeMinusOneClicked ??= () => AddRootCreditExchangeAmount(-1);
rootCreditExchangeMinusOneButton.clicked += rootCreditExchangeMinusOneClicked;
}
if (rootCreditExchangeNoneButton != null)
{
rootCreditExchangeNoneButton.clicked += SetRootCreditExchangeNone;
}
if (rootCreditExchangePlusOneButton != null)
{
rootCreditExchangePlusOneClicked ??= () => AddRootCreditExchangeAmount(1);
rootCreditExchangePlusOneButton.clicked += rootCreditExchangePlusOneClicked;
}
if (rootCreditExchangePlusHundredButton != null)
{
rootCreditExchangePlusHundredClicked ??= () => AddRootCreditExchangeAmount(100);
rootCreditExchangePlusHundredButton.clicked += rootCreditExchangePlusHundredClicked;
}
if (rootCreditExchangeMaxButton != null)
{
rootCreditExchangeMaxButton.clicked += SetRootCreditExchangeMax;
}
if (rootCreditExchangeConfirmButton != null)
{
rootCreditExchangeConfirmButton.clicked += ConfirmRootCreditExchange;
}
if (rootCreditExchangeCancelButton != null)
{
rootCreditExchangeCancelButton.clicked += CloseRootCreditExchange;
}
collectionUnitsButton.RegisterCallback<ClickEvent>(HandleCollectionUnitsTabClicked); collectionUnitsButton.RegisterCallback<ClickEvent>(HandleCollectionUnitsTabClicked);
collectionCardsButton.RegisterCallback<ClickEvent>(HandleCollectionCardsTabClicked); collectionCardsButton.RegisterCallback<ClickEvent>(HandleCollectionCardsTabClicked);
} }
@@ -214,10 +315,194 @@ namespace KernelPanic.UI
{ {
root.UnregisterCallback<KeyDownEvent>(HandleKeyDown); root.UnregisterCallback<KeyDownEvent>(HandleKeyDown);
root.UnregisterCallback<PointerDownEvent>(HandlePointerDown); root.UnregisterCallback<PointerDownEvent>(HandlePointerDown);
if (rootCreditsToEntropyButton != null)
{
rootCreditsToEntropyButton.clicked -= HandleRootCreditsToEntropyClicked;
}
if (rootCreditExchangeInput != null)
{
rootCreditExchangeInput.UnregisterValueChangedCallback(HandleRootCreditExchangeInputChanged);
}
if (rootCreditExchangeMinusHundredButton != null)
{
rootCreditExchangeMinusHundredButton.clicked -= rootCreditExchangeMinusHundredClicked;
}
if (rootCreditExchangeMinusOneButton != null)
{
rootCreditExchangeMinusOneButton.clicked -= rootCreditExchangeMinusOneClicked;
}
if (rootCreditExchangeNoneButton != null)
{
rootCreditExchangeNoneButton.clicked -= SetRootCreditExchangeNone;
}
if (rootCreditExchangePlusOneButton != null)
{
rootCreditExchangePlusOneButton.clicked -= rootCreditExchangePlusOneClicked;
}
if (rootCreditExchangePlusHundredButton != null)
{
rootCreditExchangePlusHundredButton.clicked -= rootCreditExchangePlusHundredClicked;
}
if (rootCreditExchangeMaxButton != null)
{
rootCreditExchangeMaxButton.clicked -= SetRootCreditExchangeMax;
}
if (rootCreditExchangeConfirmButton != null)
{
rootCreditExchangeConfirmButton.clicked -= ConfirmRootCreditExchange;
}
if (rootCreditExchangeCancelButton != null)
{
rootCreditExchangeCancelButton.clicked -= CloseRootCreditExchange;
}
collectionUnitsButton.UnregisterCallback<ClickEvent>(HandleCollectionUnitsTabClicked); collectionUnitsButton.UnregisterCallback<ClickEvent>(HandleCollectionUnitsTabClicked);
collectionCardsButton.UnregisterCallback<ClickEvent>(HandleCollectionCardsTabClicked); collectionCardsButton.UnregisterCallback<ClickEvent>(HandleCollectionCardsTabClicked);
} }
private void HandleRootCreditsToEntropyClicked()
{
if (gachaService == null || wallet == null)
{
return;
}
OpenRootCreditExchange();
}
private void OpenRootCreditExchange()
{
if (rootCreditExchangeModal == null || gachaService == null || wallet == null)
{
return;
}
rootCreditExchangeOpen = true;
rootCreditExchangeModal.RemoveFromClassList(HiddenClassName);
rootCreditExchangeMessageLabel?.AddToClassList(HiddenClassName);
SetRootCreditExchangeAmount(Math.Min(100, gachaService.RootCredits));
RefreshRootCreditExchange();
rootCreditExchangeInput?.Focus();
}
private void CloseRootCreditExchange()
{
rootCreditExchangeOpen = false;
rootCreditExchangeModal?.AddToClassList(HiddenClassName);
root.Focus();
}
private void HandleRootCreditExchangeInputChanged(ChangeEvent<string> evt)
{
RefreshRootCreditExchange();
}
private void AddRootCreditExchangeAmount(int amount)
{
SetRootCreditExchangeAmount(GetRootCreditExchangeAmount() + amount);
}
private void SetRootCreditExchangeMax()
{
SetRootCreditExchangeAmount(gachaService == null ? 0 : gachaService.RootCredits);
}
private void SetRootCreditExchangeNone()
{
SetRootCreditExchangeAmount(0);
}
private void SetRootCreditExchangeAmount(int amount)
{
int max = gachaService == null ? 0 : gachaService.RootCredits;
int clamped = Mathf.Clamp(amount, 0, max);
if (rootCreditExchangeInput != null)
{
rootCreditExchangeInput.value = clamped.ToString();
}
RefreshRootCreditExchange();
}
private int GetRootCreditExchangeAmount()
{
if (rootCreditExchangeInput == null || string.IsNullOrWhiteSpace(rootCreditExchangeInput.value))
{
return 0;
}
if (!int.TryParse(rootCreditExchangeInput.value, out int amount))
{
return 0;
}
int max = gachaService == null ? 0 : gachaService.RootCredits;
return Mathf.Clamp(amount, 0, max);
}
private void RefreshRootCreditExchange()
{
if (gachaService == null)
{
return;
}
int amount = GetRootCreditExchangeAmount();
if (rootCreditExchangeInput != null && rootCreditExchangeInput.value != amount.ToString())
{
rootCreditExchangeInput.SetValueWithoutNotify(amount.ToString());
}
if (rootCreditExchangeBalanceLabel != null)
{
rootCreditExchangeBalanceLabel.text = gachaService.RootCredits.ToString();
}
if (rootCreditExchangePreviewLabel != null)
{
rootCreditExchangePreviewLabel.text = $"+{amount} entropy";
}
int max = gachaService.RootCredits;
rootCreditExchangeMinusHundredButton?.SetEnabled(amount > 0);
rootCreditExchangeMinusOneButton?.SetEnabled(amount > 0);
rootCreditExchangeNoneButton?.SetEnabled(amount > 0);
rootCreditExchangePlusOneButton?.SetEnabled(amount < max);
rootCreditExchangePlusHundredButton?.SetEnabled(amount < max);
rootCreditExchangeMaxButton?.SetEnabled(max > 0 && amount < max);
rootCreditExchangeConfirmButton?.SetEnabled(amount > 0);
}
private void ConfirmRootCreditExchange()
{
int amount = GetRootCreditExchangeAmount();
if (!gachaService.ConvertRootCreditsToEntropy(wallet, amount, out string failureReason))
{
if (rootCreditExchangeMessageLabel != null)
{
rootCreditExchangeMessageLabel.text = $"exchange failed: {failureReason}";
rootCreditExchangeMessageLabel.RemoveFromClassList(HiddenClassName);
}
RefreshRootCreditExchange();
return;
}
SaveCurrentState();
RefreshCurrencyReadouts();
gachaScreen.Refresh();
CloseRootCreditExchange();
}
private void HandleCollectionUnitsTabClicked(ClickEvent evt) private void HandleCollectionUnitsTabClicked(ClickEvent evt)
{ {
ShowCollectionUnits(); ShowCollectionUnits();
@@ -234,6 +519,7 @@ namespace KernelPanic.UI
{ {
saveData = saveService.Load(); saveData = saveService.Load();
saveData.EnsureLists(); saveData.EnsureLists();
wallet.SetBalance(saveData.entropyBalance);
for (int i = 0; i < saveData.ownedUnitIds.Count; i++) for (int i = 0; i < saveData.ownedUnitIds.Count; i++)
{ {
@@ -253,9 +539,35 @@ namespace KernelPanic.UI
} }
} }
gachaService.LoadProgress(saveData);
if (saveData.starterChosen)
{
AddAllFourStarDistrosToBeginnerBannerPool();
}
if (gachaService.BeginnerState.guaranteedDistroIds.Count == 0 && saveData.bannerPoolIds.Count > 0)
{
gachaService.BeginnerState.guaranteedDistroIds.AddRange(saveData.bannerPoolIds);
}
SaveCurrentState(); SaveCurrentState();
} }
private void AddAllFourStarDistrosToBeginnerBannerPool()
{
if (distroDatabase == null)
{
return;
}
IReadOnlyList<DistroDefinition> distros = distroDatabase.AllDistros;
for (int i = 0; i < distros.Count; i++)
{
gachaService.AddToBannerPool(distros[i]);
}
}
private DistroDefinition ResolveSavedDistro(string id) private DistroDefinition ResolveSavedDistro(string id)
{ {
DistroDefinition unit = distroDatabase == null ? null : distroDatabase.FindById(id); DistroDefinition unit = distroDatabase == null ? null : distroDatabase.FindById(id);
@@ -273,28 +585,31 @@ namespace KernelPanic.UI
SaveCurrentState(); SaveCurrentState();
featuredUnitPanel.Refresh(playerCollection.OwnedUnits, playerCollection.FeaturedUnit); featuredUnitPanel.Refresh(playerCollection.OwnedUnits, playerCollection.FeaturedUnit);
collectionScreen.RefreshUnits(playerCollection.OwnedUnits); collectionScreen.RefreshUnits(playerCollection.OwnedUnits);
gachaScreen.Refresh();
RefreshCurrencyReadouts();
} }
private void HandleStarterConfirmed(DistroDefinition picked, IReadOnlyList<DistroDefinition> remaining) private void HandleStarterConfirmed(DistroDefinition picked, IReadOnlyList<DistroDefinition> remaining)
{ {
playerCollection.Add(picked); playerCollection.Add(picked);
for (int i = 0; i < remaining.Count; i++) AddAllFourStarDistrosToBeginnerBannerPool();
{
gachaService.AddToBannerPool(remaining[i]);
}
gachaService.SetBeginnerGuaranteedDistros(remaining);
saveData.starterChosen = true; saveData.starterChosen = true;
SaveCurrentState(); SaveCurrentState();
featuredUnitPanel.Refresh(playerCollection.OwnedUnits, playerCollection.FeaturedUnit); featuredUnitPanel.Refresh(playerCollection.OwnedUnits, playerCollection.FeaturedUnit);
collectionScreen.RefreshUnits(playerCollection.OwnedUnits); collectionScreen.RefreshUnits(playerCollection.OwnedUnits);
gachaScreen.Refresh();
} }
private void SaveCurrentState() private void SaveCurrentState()
{ {
saveData ??= SaveData.CreateDefault(); saveData ??= SaveData.CreateDefault();
saveData.EnsureLists(); saveData.EnsureLists();
saveData.entropyBalance = wallet == null ? 0 : wallet.Balance;
saveData.ownedUnitIds.Clear(); saveData.ownedUnitIds.Clear();
saveData.bannerPoolIds.Clear(); saveData.bannerPoolIds.Clear();
gachaService.WriteProgress(saveData);
for (int i = 0; i < playerCollection.OwnedUnits.Count; i++) for (int i = 0; i < playerCollection.OwnedUnits.Count; i++)
{ {
@@ -358,7 +673,12 @@ namespace KernelPanic.UI
} }
entropyLabel.text = $"entropy={wallet.Balance}"; entropyLabel.text = $"entropy={wallet.Balance}";
pullTokensLabel.text = $"pulls={gachaService.PullTokens}"; rootCreditsToEntropyButton?.SetEnabled(true);
bool showPullTokens = IsGachaVisible();
pullTokensLabel.text = showPullTokens
? $"stable-pull-token={gachaService.PullTokens} feature-pull-token={gachaService.LimitedPullTokens}"
: string.Empty;
pullTokensLabel.EnableInClassList(HiddenClassName, !showPullTokens);
} }
private void RefreshEventBanner() private void RefreshEventBanner()
@@ -418,7 +738,8 @@ namespace KernelPanic.UI
private void UpdateBootIntro() private void UpdateBootIntro()
{ {
bootIntroElapsed += 0.016f; bootIntroElapsed += 0.016f;
int targetCount = Mathf.Clamp(Mathf.CeilToInt(bootIntroCopy.Length * (bootIntroElapsed / BootIntroSeconds)), 0, bootIntroCopy.Length); int targetCount = Mathf.Clamp(Mathf.CeilToInt(bootIntroCopy.Length * (bootIntroElapsed / BootIntroSeconds)),
0, bootIntroCopy.Length);
if (targetCount != bootIntroCharacterCount) if (targetCount != bootIntroCharacterCount)
{ {
bootIntroCharacterCount = targetCount; bootIntroCharacterCount = targetCount;
@@ -468,6 +789,26 @@ namespace KernelPanic.UI
return; return;
} }
if (rootCreditExchangeOpen)
{
if (evt.keyCode == KeyCode.Escape)
{
CloseRootCreditExchange();
evt.StopPropagation();
return;
}
if (evt.keyCode == KeyCode.Return || evt.keyCode == KeyCode.KeypadEnter)
{
ConfirmRootCreditExchange();
evt.StopPropagation();
return;
}
evt.StopPropagation();
return;
}
if (evt.keyCode == KeyCode.Escape && IsSubScreenVisible()) if (evt.keyCode == KeyCode.Escape && IsSubScreenVisible())
{ {
ShowMainMenu(); ShowMainMenu();
@@ -509,6 +850,12 @@ namespace KernelPanic.UI
} }
} }
if (IsGachaVisible())
{
gachaScreen.HandleKeyDown(evt);
return;
}
if (evt.keyCode == KeyCode.UpArrow) if (evt.keyCode == KeyCode.UpArrow)
{ {
SelectCommand(selectedCommandIndex - 1); SelectCommand(selectedCommandIndex - 1);
@@ -557,10 +904,15 @@ namespace KernelPanic.UI
return collectionPanel != null && !collectionPanel.ClassListContains(HiddenClassName); return collectionPanel != null && !collectionPanel.ClassListContains(HiddenClassName);
} }
private bool IsGachaVisible()
{
return gachaPanel != null && !gachaPanel.ClassListContains(HiddenClassName);
}
private bool IsSubScreenVisible() private bool IsSubScreenVisible()
{ {
return IsRunSetupVisible() || IsCollectionVisible() || return IsRunSetupVisible() || IsCollectionVisible() ||
(gachaPanel != null && !gachaPanel.ClassListContains(HiddenClassName)) || IsGachaVisible() ||
(settingsPanel != null && !settingsPanel.ClassListContains(HiddenClassName)); (settingsPanel != null && !settingsPanel.ClassListContains(HiddenClassName));
} }
@@ -581,7 +933,8 @@ namespace KernelPanic.UI
if (playerCollection.OwnedUnits.Count == 0) if (playerCollection.OwnedUnits.Count == 0)
{ {
runSetupDetail.Add(new Label("no units installed") { name = "RunSetupEmptyTitle" }); runSetupDetail.Add(new Label("no units installed") { name = "RunSetupEmptyTitle" });
runSetupDetail.Add(new Label("install a starter or summon a unit before starting a run") { name = "RunSetupEmptyHint" }); runSetupDetail.Add(new Label("install a starter or summon a unit before starting a run")
{ name = "RunSetupEmptyHint" });
return; return;
} }
@@ -611,7 +964,9 @@ namespace KernelPanic.UI
summary.Add(meta); summary.Add(meta);
row.Add(summary); row.Add(summary);
string passiveTitle = unit.Passive == null || string.IsNullOrWhiteSpace(unit.Passive.Name) ? "--" : unit.Passive.Name; string passiveTitle = unit.Passive == null || string.IsNullOrWhiteSpace(unit.Passive.Name)
? "--"
: unit.Passive.Name;
row.Add(new Label($"passive: {passiveTitle}") row.Add(new Label($"passive: {passiveTitle}")
{ {
name = $"RunSetupDescription{index}" name = $"RunSetupDescription{index}"
@@ -652,7 +1007,8 @@ namespace KernelPanic.UI
{ {
runSetupDetail?.Clear(); runSetupDetail?.Clear();
loadoutRows.Clear(); loadoutRows.Clear();
if (runSetupDetail == null || selectedPackageIndex < 0 || selectedPackageIndex >= playerCollection.OwnedUnits.Count) if (runSetupDetail == null || selectedPackageIndex < 0 ||
selectedPackageIndex >= playerCollection.OwnedUnits.Count)
{ {
return; return;
} }
@@ -678,7 +1034,10 @@ namespace KernelPanic.UI
if (equippedCardIds.Count < CardLoadout.MaxEquippedCards) if (equippedCardIds.Count < CardLoadout.MaxEquippedCards)
{ {
packageList.Add(new Label($"select {CardLoadout.MaxEquippedCards - equippedCardIds.Count} more card(s) before booting") { name = "RunSetupIncompleteNotice" }); packageList.Add(
new Label(
$"select {CardLoadout.MaxEquippedCards - equippedCardIds.Count} more card(s) before booting")
{ name = "RunSetupIncompleteNotice" });
packageList.ElementAt(packageList.childCount - 1).AddToClassList("package-notice"); packageList.ElementAt(packageList.childCount - 1).AddToClassList("package-notice");
} }
@@ -994,6 +1353,7 @@ namespace KernelPanic.UI
private void ShowGacha() private void ShowGacha()
{ {
gachaScreen.Open();
ShowPanel(gachaPanel); ShowPanel(gachaPanel);
} }
@@ -1022,6 +1382,8 @@ namespace KernelPanic.UI
{ {
eventBanner.AddToClassList(HiddenClassName); eventBanner.AddToClassList(HiddenClassName);
} }
RefreshCurrencyReadouts();
} }
private sealed class CommandMenuEntry private sealed class CommandMenuEntry
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6bfa65b8d497538499a5b84a4d7a4e9f
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 14c81f7c3ea7de425b5ba4adf237c460
+3 -2
View File
@@ -5,8 +5,9 @@
<ui:Label class="screen-frame-title" /> <ui:Label class="screen-frame-title" />
</ui:VisualElement> </ui:VisualElement>
<ui:VisualElement class="screen-frame-content"> <ui:VisualElement class="screen-frame-content">
<ui:VisualElement name="GachaContent" class="stub-box"> <ui:VisualElement name="GachaContent" class="gacha-shell">
<!-- TODO: Render summon options and results here when gacha logic exists. --> <ui:VisualElement name="GachaBannerList" class="gacha-banner-list" />
<ui:VisualElement name="GachaBannerDetail" class="gacha-banner-detail" />
</ui:VisualElement> </ui:VisualElement>
</ui:VisualElement> </ui:VisualElement>
<ui:VisualElement class="screen-frame-footer"> <ui:VisualElement class="screen-frame-footer">
+255
View File
@@ -125,6 +125,29 @@ Button {
margin-right: 18px; margin-right: 18px;
} }
.currency-plus-button {
width: 24px;
height: 22px;
margin-right: 12px;
padding-left: 0;
padding-right: 0;
border-left-width: 1px;
border-right-width: 1px;
border-top-width: 1px;
border-bottom-width: 1px;
border-left-color: var(--kp-amber);
border-right-color: var(--kp-amber);
border-top-color: var(--kp-amber);
border-bottom-color: var(--kp-amber);
background-color: var(--kp-bg-solid);
color: var(--kp-amber);
}
.currency-plus-button:hover {
background-color: var(--kp-amber);
color: var(--kp-bg);
}
.main-panel { .main-panel {
flex-grow: 1; flex-grow: 1;
justify-content: center; justify-content: center;
@@ -500,6 +523,175 @@ Button {
background-color: var(--kp-panel); background-color: var(--kp-panel);
} }
.gacha-shell {
flex-grow: 1;
min-height: 0;
flex-direction: row;
}
.gacha-banner-list {
width: 270px;
min-width: 270px;
margin-right: 16px;
padding-top: 4px;
border-left-width: 1px;
border-right-width: 1px;
border-top-width: 1px;
border-bottom-width: 1px;
border-left-color: var(--kp-border);
border-right-color: var(--kp-border);
border-top-color: var(--kp-border);
border-bottom-color: var(--kp-border);
background-color: var(--kp-panel);
}
.gacha-banner-detail {
flex-grow: 1;
min-width: 0;
padding: 16px;
border-left-width: 1px;
border-right-width: 1px;
border-top-width: 1px;
border-bottom-width: 1px;
border-left-color: var(--kp-border);
border-right-color: var(--kp-border);
border-top-color: var(--kp-border);
border-bottom-color: var(--kp-border);
background-color: var(--kp-panel);
}
.gacha-banner-detail .kv-value {
max-width: none;
min-width: 0;
}
.gacha-banner-detail .kv-key {
width: 170px;
min-width: 170px;
}
.gacha-banner-row {
min-height: 42px;
flex-direction: row;
align-items: center;
padding-left: 10px;
padding-right: 10px;
margin-top: 3px;
margin-bottom: 3px;
border-left-width: 2px;
border-left-color: transparent;
}
.gacha-banner-row:hover,
.gacha-banner-row.selected {
background-color: var(--kp-hover);
}
.gacha-banner-row.selected {
border-left-color: var(--kp-primary);
}
.gacha-detail-title {
color: var(--kp-primary);
font-size: 22px;
-unity-font-style: bold;
margin-bottom: 12px;
}
.gacha-hero {
min-height: 310px;
max-height: 340px;
margin-bottom: 18px;
padding: 14px;
border-left-width: 1px;
border-right-width: 1px;
border-top-width: 1px;
border-bottom-width: 1px;
border-left-color: var(--kp-primary-dim);
border-right-color: var(--kp-primary-dim);
border-top-color: var(--kp-primary-dim);
border-bottom-color: var(--kp-primary-dim);
background-color: var(--kp-bg-solid);
}
.gacha-hero-art-row {
flex-grow: 1;
min-height: 0;
flex-direction: row;
}
.gacha-art-card {
flex-grow: 1;
min-width: 0;
margin-right: 12px;
align-items: center;
}
.gacha-art-shell {
flex-grow: 1;
min-height: 220px;
width: 100%;
align-items: center;
justify-content: center;
overflow: hidden;
}
.gacha-art-card .ascii-art,
.gacha-ascii-art {
width: 100%;
min-width: 0;
height: 220px;
font-size: 18px;
}
.gacha-art-card .ascii-placeholder,
.gacha-ascii-placeholder {
width: 180px;
height: 210px;
}
.gacha-art-name {
min-height: 24px;
font-size: 16px;
-unity-font-style: bold;
-unity-text-align: middle-center;
}
.gacha-hero-title {
color: var(--kp-muted);
font-size: 15px;
margin-top: 8px;
-unity-text-align: middle-center;
}
.gacha-rules {
color: var(--kp-muted);
white-space: normal;
margin-top: 10px;
margin-bottom: 12px;
}
.gacha-actions {
flex-direction: row;
margin-top: 8px;
margin-bottom: 12px;
}
.gacha-action-button {
width: 220px;
min-height: 58px;
margin-top: 0;
margin-right: 12px;
white-space: normal;
-unity-text-align: middle-center;
}
.gacha-result {
color: var(--kp-amber);
white-space: normal;
margin-top: 8px;
}
.collection-content { .collection-content {
min-height: 280px; min-height: 280px;
} }
@@ -860,6 +1052,69 @@ ScrollView .unity-base-slider__dragger {
background-color: var(--kp-bg-solid); background-color: var(--kp-bg-solid);
} }
.root-credit-dialog {
width: 82%;
height: 72%;
padding: 28px;
justify-content: center;
border-left-width: 1px;
border-right-width: 1px;
border-top-width: 1px;
border-bottom-width: 1px;
border-left-color: var(--kp-amber);
border-right-color: var(--kp-amber);
border-top-color: var(--kp-amber);
border-bottom-color: var(--kp-amber);
background-color: var(--kp-bg-solid);
}
.exchange-summary {
margin-top: 8px;
margin-bottom: 18px;
}
.exchange-summary .kv-key {
width: 150px;
min-width: 150px;
}
.exchange-summary .kv-value {
max-width: none;
color: var(--kp-amber);
}
.exchange-input {
margin-bottom: 18px;
}
.exchange-input Label {
color: var(--kp-muted);
}
.exchange-input TextInput {
color: var(--kp-primary);
background-color: var(--kp-panel);
}
.exchange-step-row,
.exchange-confirm-row {
flex-direction: row;
margin-top: 10px;
}
.exchange-step-button {
width: 120px;
margin-right: 12px;
}
.exchange-confirm-button {
width: 320px;
min-height: 46px;
margin-right: 12px;
white-space: normal;
-unity-text-align: middle-center;
}
.starter-title { .starter-title {
color: var(--kp-primary); color: var(--kp-primary);
font-size: 24px; font-size: 24px;
+31
View File
@@ -16,6 +16,7 @@
<ui:Label name="AppIdLabel" class="status-text" /> <ui:Label name="AppIdLabel" class="status-text" />
<ui:VisualElement class="status-right"> <ui:VisualElement class="status-right">
<ui:Label name="EntropyLabel" class="currency-text" /> <ui:Label name="EntropyLabel" class="currency-text" />
<ui:Button name="RootCreditsToEntropyButton" text="+" class="currency-plus-button" focusable="false" />
<ui:Label name="PullTokensLabel" class="currency-text" /> <ui:Label name="PullTokensLabel" class="currency-text" />
<ui:Label text="uid=1000(player)" class="status-text" /> <ui:Label text="uid=1000(player)" class="status-text" />
</ui:VisualElement> </ui:VisualElement>
@@ -188,5 +189,35 @@
<ui:Label name="StarterConfirmLabel" class="starter-confirm hidden" /> <ui:Label name="StarterConfirmLabel" class="starter-confirm hidden" />
</ui:VisualElement> </ui:VisualElement>
</ui:VisualElement> </ui:VisualElement>
<ui:VisualElement name="RootCreditExchangeModal" class="modal-overlay hidden">
<ui:VisualElement class="root-credit-dialog">
<ui:Label text="exchange root-credits" class="starter-title" />
<ui:Label text="convert root-credits into entropy at 1:1. entropy cannot be converted back." class="starter-instruction" />
<ui:VisualElement class="exchange-summary">
<ui:VisualElement class="kv-row">
<ui:Label text="root-credits" class="kv-key" />
<ui:Label name="RootCreditExchangeBalanceLabel" class="kv-value" />
</ui:VisualElement>
<ui:VisualElement class="kv-row">
<ui:Label text="entropy gain" class="kv-key" />
<ui:Label name="RootCreditExchangePreviewLabel" class="kv-value" />
</ui:VisualElement>
</ui:VisualElement>
<ui:TextField name="RootCreditExchangeInput" label="amount" class="exchange-input" />
<ui:VisualElement class="exchange-step-row">
<ui:Button name="RootCreditExchangeMinusHundredButton" text="-100" class="terminal-button exchange-step-button" />
<ui:Button name="RootCreditExchangeMinusOneButton" text="-1" class="terminal-button exchange-step-button" />
<ui:Button name="RootCreditExchangeNoneButton" text="none" class="terminal-button exchange-step-button" />
<ui:Button name="RootCreditExchangePlusOneButton" text="+1" class="terminal-button exchange-step-button" />
<ui:Button name="RootCreditExchangePlusHundredButton" text="+100" class="terminal-button exchange-step-button" />
<ui:Button name="RootCreditExchangeMaxButton" text="max" class="terminal-button exchange-step-button" />
</ui:VisualElement>
<ui:Label name="RootCreditExchangeMessageLabel" class="starter-confirm hidden" />
<ui:VisualElement class="exchange-confirm-row">
<ui:Button name="RootCreditExchangeCancelButton" text="git merge --abort" class="terminal-button exchange-confirm-button" />
<ui:Button name="RootCreditExchangeConfirmButton" text="git merge root-credits --into entropy" class="terminal-button exchange-confirm-button" />
</ui:VisualElement>
</ui:VisualElement>
</ui:VisualElement>
</ui:VisualElement> </ui:VisualElement>
</ui:UXML> </ui:UXML>
+10
View File
@@ -0,0 +1,10 @@
fileFormatVersion: 2
guid: 324649d401531e204afc98d81700938e
ScriptedImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 2
userData:
assetBundleName:
assetBundleVariant:
script: {fileID: 13804, guid: 0000000000000000e000000000000000, type: 0}
@@ -0,0 +1,42 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &1
MonoBehaviour:
m_ObjectHideFlags: 53
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 13964, guid: 0000000000000000e000000000000000, type: 0}
m_Name:
m_EditorClassIdentifier: UnityEditor.dll::UnityEditor.PackageManager.UI.Internal.PackageManagerProjectSettings
m_EnablePreReleasePackages: 0
m_AdvancedSettingsExpanded: 1
m_ScopedRegistriesSettingsExpanded: 1
m_SeeAllPackageVersions: 0
m_DismissPreviewPackagesInUse: 0
oneTimeWarningShown: 0
oneTimePackageErrorsPopUpShown: 0
m_Registries:
- m_Id: main
m_Name:
m_Url: https://packages.unity.com
m_Scopes: []
m_IsDefault: 1
m_Capabilities: 7
m_ConfigSource: 0
m_Compliance:
m_Status: 0
m_Violations: []
m_UserSelectedRegistryName:
m_UserAddingNewScopedRegistry: 0
m_RegistryInfoDraft:
m_Modified: 0
m_ErrorMessage:
m_UserModificationsEntityId:
m_Data: -940
m_OriginalEntityId:
m_Data: -942
m_LoadAssets: 0
+4 -1
View File
@@ -298,7 +298,10 @@ PlayerSettings:
m_BuildTargetShaderSettings: [] m_BuildTargetShaderSettings: []
m_BuildTargetGraphicsJobs: [] m_BuildTargetGraphicsJobs: []
m_BuildTargetGraphicsJobMode: [] m_BuildTargetGraphicsJobMode: []
m_BuildTargetGraphicsAPIs: [] m_BuildTargetGraphicsAPIs:
- m_BuildTarget: LinuxStandaloneSupport
m_APIs: 1500000011000000
m_Automatic: 0
m_BuildTargetVRSettings: [] m_BuildTargetVRSettings: []
m_DefaultShaderChunkSizeInMB: 16 m_DefaultShaderChunkSizeInMB: 16
m_DefaultShaderChunkCount: 0 m_DefaultShaderChunkCount: 0
+337
View File
@@ -0,0 +1,337 @@
# Gacha Implementation Guide
This project currently has a working beginner banner and scaffolding for standard and limited banners.
The main files are:
- `Assets/Scripts/Meta/GachaService.cs`: pull costs, odds, pity, currency accounting, reward rolls.
- `Assets/Scripts/Meta/GachaBannerState.cs`: persistent per-banner counters.
- `Assets/Scripts/Meta/GachaCurrencyType.cs`: pull-token currency enum.
- `Assets/Scripts/Meta/SaveService.cs`: saved currency and banner progress fields.
- `Assets/Scripts/UI/GachaScreenController.cs`: banner list, banner names, pull buttons, pull prompts, result text.
- `Assets/Scripts/UI/MainMenuController.cs`: wallet/save refresh, root-credit exchange popup, gacha status readouts.
- `Assets/UI/MainMenu.uxml` and `Assets/UI/MainMenu.uss`: exchange popup and gacha/status styling.
## Adding A New Banner
1. Add a banner id in `GachaService.cs`.
Example:
```csharp
public const string EventBannerId = "event";
```
2. Add a persistent state field in `GachaService.cs`.
Standard and limited banners should each have their own `GachaBannerState`, separate from `beginnerState`, so pity does not leak between banners.
3. Add save fields in `SaveData` inside `SaveService.cs`.
Example:
```csharp
public GachaBannerState eventBannerState = new(GachaService.EventBannerId);
```
Then update `EnsureLists()`, `GachaService.LoadProgress()`, and `GachaService.WriteProgress()` to load and save that state.
4. Add the banner id to `bannerIds` in `GachaScreenController.cs`.
5. Add a title/status entry in `GetBannerTitle()` and `GetBannerStatus()` in `GachaScreenController.cs`.
6. Add rendering for the banner in `RenderSelectedBanner()`.
Use `RenderBeginnerBanner()` as the template, but use the new banner state, pull costs, and currency.
7. Add pull request and execution methods in `GachaService.cs`.
The beginner versions are:
- `GetBeginnerPullCost`
- `CanPullBeginner`
- `PerformBeginnerPull`
- `RollBeginnerReward`
- `CanPullBeginnerWithEntropy`
Copy this shape for the new banner, then change the currency, state, pity, and reward pool.
## Currency Rules
- `GachaCurrencyType.StandardPull` formats as `stable-pull-token`.
- `GachaCurrencyType.LimitedPull` formats as `feature-pull-token`.
- Beginner and standard banners should spend `StandardPull`.
- Limited and event banners should usually spend `LimitedPull`, unless the design says otherwise.
- `EntropyPerPullToken` controls the entropy fallback cost for each missing pull token.
- Root-credits convert to entropy through `GachaService.ConvertRootCreditsToEntropy()`.
To add a new pull currency:
1. Add a value to `GachaCurrencyType.cs`.
2. Initialize it in the `GachaService` constructor.
3. Add save fields in `SaveData`.
4. Load/save it in `GachaService.LoadProgress()` and `WriteProgress()`.
5. Add display text in `GachaService.FormatCurrencyName()`.
6. Update the gacha status readout in `MainMenuController.RefreshCurrencyReadouts()`.
## Adjusting Pull Costs
Beginner costs are constants in `GachaService.cs`:
```csharp
public const int BeginnerSinglePullCost = 1;
public const int BeginnerTenPullCost = 8;
```
For standard or limited banners, add equivalent constants:
```csharp
public const int StandardSinglePullCost = 1;
public const int StandardTenPullCost = 10;
public const int LimitedSinglePullCost = 1;
public const int LimitedTenPullCost = 10;
```
Then use those constants in the matching `Get...PullCost()` method and in the pull button labels in `GachaScreenController.cs`.
## Adjusting Odds
The current base roll order is:
1. Check for a 5-star result.
2. If no 5-star, check for a 4-star-or-better result.
3. If no 4-star-or-better result, award the base 3-star equipment cache.
The base non-pity tier rates are:
- 5-star item: `0.8%`
- 4-star item: `11.904%`
- 3-star equipment: `87.296%`
After a 4-star or 5-star item is rolled, the game then does a 50/50 split:
- equipment
- distro
The effective split rates are still useful for sanity checks, but they are not separate primary drop rates:
- `5-star equipment = 0.8% 5-star item * 50% equipment = 0.4%`
- `5-star distro = 0.8% 5-star item * 50% distro = 0.4%`
- `4-star item = 99.2% no 5-star * 12% 4-star item = 11.904%`
- `4-star equipment = 11.904% 4-star item * 50% equipment = 5.952%`
- `4-star distro = 11.904% 4-star item * 50% distro = 5.952%`
- `3-star equipment = 99.2% no 5-star * 88% no 4-star hit = 87.296%`
The current odds constants are in `GachaService.cs`:
```csharp
private const double FiveStarBaseChance = 0.008d;
private const double FourStarBaseChance = 0.12d;
private const double DistroChanceOnFeaturedTier = 0.5d;
```
`FiveStarBaseChance` is the total base 5-star chance.
`FourStarBaseChance` decides whether a non-5-star pull upgrades from the base 3-star equipment cache to a 4-star result.
`DistroChanceOnFeaturedTier` is the 50/50 distro-vs-equipment split after a 4-star or 5-star item has already been rolled.
## Five-Star Soft Pity
Five-star pity uses `fiveStarPityCounter` in `GachaBannerState`.
It is separate from the 4-star `pityCounter`.
Current 5-star constants:
```csharp
public const int FiveStarSoftPityStart = 66;
public const int FiveStarHardPity = 80;
private const double FiveStarBaseChance = 0.008d;
private const double FiveStarSoftPityStep = 0.07d;
```
The total 5-star chance table is:
- pity pulls 1-65: `0.8%`
- pity pull 66: `7.8%`
- pity pull 67: `14.8%`
- pity pull 68: `21.8%`
- pity pull 69: `28.8%`
- pity pull 70: `35.8%`
- pity pull 71: `42.8%`
- pity pull 72: `49.8%`
- pity pull 73: `56.8%`
- pity pull 74: `63.8%`
- pity pull 75: `70.8%`
- pity pull 76: `77.8%`
- pity pull 77: `84.8%`
- pity pull 78: `91.8%`
- pity pull 79: `98.8%`
- pity pull 80: guaranteed by hard pity
The raw formula reaches `105.8%` on pull 80, so `GetFiveStarChance()` clamps the effective chance to `100%`.
A random 5-star resets both:
- `fiveStarPityCounter`
- the 4-star `pityCounter`
The beginner banner's pull-50 capstone guarantee awards the placeholder 5-star but intentionally does not reset `fiveStarPityCounter`.
## Adjusting Pity
Current 4-star pity is shared as a constant:
```csharp
public const int FourStarHardPity = 10;
```
The pity check happens in `RollBeginnerReward()`:
```csharp
bool pityTriggered = beginnerState.pityCounter >= FourStarHardPity - 1;
```
When adding standard or limited banners:
1. Use that banner's own `GachaBannerState`.
2. Increment only that state's `pityCounter` after a non-upgraded result.
3. Reset only that state's `pityCounter` after a 4-star-or-better result.
4. Increment only that state's `fiveStarPityCounter` after a non-5-star result.
5. Reset only that state's `fiveStarPityCounter` after a random 5-star result.
6. Add separate constants if banners need different pity values.
Example:
```csharp
public const int LimitedFourStarHardPity = 10;
public const int LimitedFiveStarHardPity = 90;
```
Do not reuse `pityCounter` for 5-star pity. Use `fiveStarPityCounter`.
## Adjusting Rewards
Rewards are represented by `GachaReward` in `GachaService.cs`.
Current reward constructors are:
- `GachaReward.Equipment(starRating, displayName, pityTriggered, guaranteed)`
- `GachaReward.DistroReward(distro, starRating, pityTriggered, guaranteed, guaranteeReason)`
- `GachaReward.FutureStandardFiveStar(displayName, guaranteeReason)`
To add real equipment:
1. Create an equipment data type and database.
2. Add an equipment reference/id to `GachaReward`.
3. Add a reward constructor for the equipment item.
4. Update `GachaPullResult` and collection/inventory code to persist duplicates.
5. Update `GachaScreenController.PullSelectedBanner()` to add equipment to the player inventory and print the result.
To change possible distro rewards:
1. Change the pool selection in the banner's `Roll...Reward()` method.
2. For starter guarantees, update `SetBeginnerGuaranteedDistros()` and `RewardGuaranteedBeginnerDistro()`.
3. For standard/limited pools, prefer explicit pool methods over reusing the beginner `bannerPool` if the pools differ.
## Beginner Banner Guarantees
Beginner special cases currently live in `RollBeginnerReward()`:
- pull 20: guaranteed first unpicked starter distro
- pull 40: guaranteed second unpicked starter distro
- pull 50: placeholder future standard 5-star distro, without resetting 5-star pity
The guaranteed distro ids are stored in:
```csharp
beginnerState.guaranteedDistroIds
```
They are set from `MainMenuController.HandleStarterConfirmed()` through:
```csharp
gachaService.SetBeginnerGuaranteedDistros(remaining);
```
To change guarantee milestones, edit the pull-number checks in `RollBeginnerReward()` and update `FormatBeginnerGuarantees()` in `GachaScreenController.cs`.
## Limited Banner 5-Star Rule
Limited banners use a lenient featured guarantee:
- When a limited-banner 5-star item is rolled, first do the normal 50/50 equipment-vs-distro split.
- If the 50/50 lands on equipment, award 5-star equipment and do not touch the limited featured guarantee.
- If the 50/50 lands on a distro/character, there is a `1/3` chance it uses the standard 5-star pool.
- If that happens, `featuredFiveStarGuaranteed` is set on that banner's `GachaBannerState`.
- The next limited-banner 5-star distro/character ignores the `1/3` standard-pool chance and must use the limited featured pool.
- After the guaranteed featured 5-star is awarded, `featuredFiveStarGuaranteed` is cleared.
The helper for this is:
```csharp
ResolveLimitedFiveStarUsesStandardPool(limitedState)
```
It returns `true` when the 5-star should come from the standard pool and `false` when it should come from the featured limited pool.
Call it only after the pull has already rolled a 5-star on a limited banner and the 50/50 split landed on distro/character.
## Standard Banner 4-Star And 5-Star Rule
Standard banner 4-star and 5-star hits should split 50/50 between equipment and distros after the tier is rolled.
The helper for this is:
```csharp
ResolveFeaturedTierIsDistro()
```
It returns `true` for a distro and `false` for equipment.
Call it only after the banner has already rolled a 4-star or 5-star item.
## Banner Names And UI Text
Banner display names are in `GachaScreenController.GetBannerTitle()`.
Banner status text on the left list is in `GetBannerStatus()`.
Pull button labels are in the banner render method, currently `RenderBeginnerBanner()`.
Keep these in sync:
1. `GachaService` banner id constant.
2. `bannerIds` array in `GachaScreenController`.
3. `GetBannerTitle()`.
4. `GetBannerStatus()`.
5. Any result text in `PullSelectedBanner()`.
## Standard Banner Checklist
1. Add `standardState` in `GachaService`.
2. Save/load `standardBannerState` in `SaveData`.
3. Add `StandardSinglePullCost` and `StandardTenPullCost`.
4. Implement `GetStandardPullCost()`.
5. Implement `CanPullStandard()`.
6. Implement `PerformStandardPull()`.
7. Implement `RollStandardReward()`.
8. Use `GachaCurrencyType.StandardPull`.
9. On a 4-star or 5-star hit, call `ResolveFeaturedTierIsDistro()`.
10. If it returns `true`, pick from the matching standard distro pool.
11. If it returns `false`, award matching-rarity equipment.
12. Render real standard controls in `GachaScreenController` instead of `RenderFutureBanner()`.
13. Add result handling for standard rewards.
## Limited Banner Checklist
1. Add `limitedState` in `GachaService`.
2. Save/load `limitedBannerState` in `SaveData`.
3. Add `LimitedSinglePullCost` and `LimitedTenPullCost`.
4. Implement `GetLimitedPullCost()`.
5. Implement `CanPullLimited()`.
6. Implement `PerformLimitedPull()`.
7. Implement `RollLimitedReward()`.
8. Use `GachaCurrencyType.LimitedPull`.
9. On a 4-star or 5-star hit, call `ResolveFeaturedTierIsDistro()`.
10. If it returns `false`, award matching-rarity equipment.
11. If it returns `true` on a 4-star, pick from the limited 4-star distro pool.
12. If it returns `true` on a 5-star, call `ResolveLimitedFiveStarUsesStandardPool(limitedState)`.
13. If that returns `true`, pick from the standard 5-star pool.
14. If that returns `false`, pick from the featured limited pool.
15. Add featured-unit or rate-up pool configuration.
16. Render real limited controls in `GachaScreenController`.
## Testing Changes
After changing gacha code:
1. Compile the Unity runtime assembly.
2. Test single pull, ten pull, insufficient-token flow, entropy fallback, root-credit exchange, duplicate distro reward, pity reset, and saved progress reload.
3. For new banners, verify that pity counters are independent by pulling on one banner, switching banners, and checking the other banner state did not change.