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
+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 void SetBalance(int amount)
{
Balance = amount < 0 ? 0 : amount;
}
public void Add(int 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
{
/// <summary>
/// Provides the contract for spending entropy on persistent unlock pulls.
/// Provides persistent banner pull logic and currency accounting.
/// </summary>
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 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<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)
{
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>
/// 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>
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;
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 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 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;
// 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 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)
+8
View File
@@ -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<string> ownedUnitIds = new();
public List<string> 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<string>();
bannerPoolIds ??= new List<string>();
beginnerBannerState ??= new GachaBannerState(GachaService.BeginnerBannerId);
beginnerBannerState.bannerId = GachaService.BeginnerBannerId;
beginnerBannerState.EnsureLists();
lastRunLoadout ??= new LastRunLoadoutSaveEntry();
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
+380 -18
View File
@@ -34,13 +34,16 @@ namespace KernelPanic.UI
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 DistroDatabase distroDatabase;
[SerializeField] private CardDatabase cardDatabase;
[SerializeField] private FeaturedUnitPanel featuredUnitPanel = new();
[SerializeField] private CollectionScreenController collectionScreen = new();
[SerializeField] private StarterSelectionController starterSelection = new();
[SerializeField] private GachaScreenController gachaScreen = new();
private readonly List<CommandMenuEntry> commandEntries = new();
private readonly List<VisualElement> packageRows = new();
@@ -66,6 +69,20 @@ namespace KernelPanic.UI
private Label bootIntroLogLabel;
private Label motdBodyLabel;
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 collectionCardsButton;
private readonly ScreenFrameController runSetupFrame = new();
@@ -89,7 +106,12 @@ namespace KernelPanic.UI
private bool cursorVisible;
private bool suppressNextClick;
private bool warnedUnresolvedSaveId;
private bool rootCreditExchangeOpen;
private string bootIntroCopy;
private Action rootCreditExchangeMinusHundredClicked;
private Action rootCreditExchangeMinusOneClicked;
private Action rootCreditExchangePlusOneClicked;
private Action rootCreditExchangePlusHundredClicked;
private IVisualElementScheduledItem blinkSchedule;
private IVisualElementScheduledItem bootIntroSchedule;
@@ -104,7 +126,8 @@ namespace KernelPanic.UI
document = GetComponent<UIDocument>();
saveService = new SaveService();
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);
Initialize(new EntropyWallet()); // TODO: Replace with persistent wallet service composition.
BindElements();
@@ -114,9 +137,11 @@ namespace KernelPanic.UI
collectionScreen.Bind(root, monospaceFont);
BindScreenFrames();
starterSelection.Bind(root, distroDatabase, HandleStarterConfirmed);
gachaScreen.Bind(root, distroDatabase, monospaceFont, gachaService, playerCollection, wallet,
HandleMetaStateChanged, OpenRootCreditExchange);
LoadMetaState();
playerCollection.Changed += HandleMetaStateChanged;
gachaService.BannerPoolChanged += HandleMetaStateChanged;
gachaService.Changed += HandleMetaStateChanged;
ApplyOptionalFont();
}
@@ -130,6 +155,7 @@ namespace KernelPanic.UI
RefreshEventBanner();
featuredUnitPanel.Refresh(playerCollection.OwnedUnits, playerCollection.FeaturedUnit);
collectionScreen.RefreshUnits(playerCollection.OwnedUnits);
gachaScreen.Refresh();
SelectCommand(0);
ShowMainMenu();
StartAmbientSchedules();
@@ -170,15 +196,36 @@ namespace KernelPanic.UI
bootIntroLogLabel = root.Q<Label>("BootIntroLogLabel");
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");
collectionCardsButton = root.Q<Button>("CollectionCardsButton");
}
private void BindScreenFrames()
{
runSetupFrame.Bind(runSetupPanel, "$ ./start_run --configure", "[esc] back [arrows] navigate [enter] select [b] boot run", 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", ShowMainMenu);
runSetupFrame.Bind(runSetupPanel, "$ ./start_run --configure",
"[esc] back [arrows] navigate [enter] select [b] boot run", 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);
}
@@ -206,6 +253,60 @@ namespace KernelPanic.UI
{
root.RegisterCallback<KeyDownEvent>(HandleKeyDown);
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);
collectionCardsButton.RegisterCallback<ClickEvent>(HandleCollectionCardsTabClicked);
}
@@ -214,10 +315,194 @@ namespace KernelPanic.UI
{
root.UnregisterCallback<KeyDownEvent>(HandleKeyDown);
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);
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)
{
ShowCollectionUnits();
@@ -234,6 +519,7 @@ namespace KernelPanic.UI
{
saveData = saveService.Load();
saveData.EnsureLists();
wallet.SetBalance(saveData.entropyBalance);
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();
}
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)
{
DistroDefinition unit = distroDatabase == null ? null : distroDatabase.FindById(id);
@@ -273,28 +585,31 @@ namespace KernelPanic.UI
SaveCurrentState();
featuredUnitPanel.Refresh(playerCollection.OwnedUnits, playerCollection.FeaturedUnit);
collectionScreen.RefreshUnits(playerCollection.OwnedUnits);
gachaScreen.Refresh();
RefreshCurrencyReadouts();
}
private void HandleStarterConfirmed(DistroDefinition picked, IReadOnlyList<DistroDefinition> remaining)
{
playerCollection.Add(picked);
for (int i = 0; i < remaining.Count; i++)
{
gachaService.AddToBannerPool(remaining[i]);
}
AddAllFourStarDistrosToBeginnerBannerPool();
gachaService.SetBeginnerGuaranteedDistros(remaining);
saveData.starterChosen = true;
SaveCurrentState();
featuredUnitPanel.Refresh(playerCollection.OwnedUnits, playerCollection.FeaturedUnit);
collectionScreen.RefreshUnits(playerCollection.OwnedUnits);
gachaScreen.Refresh();
}
private void SaveCurrentState()
{
saveData ??= SaveData.CreateDefault();
saveData.EnsureLists();
saveData.entropyBalance = wallet == null ? 0 : wallet.Balance;
saveData.ownedUnitIds.Clear();
saveData.bannerPoolIds.Clear();
gachaService.WriteProgress(saveData);
for (int i = 0; i < playerCollection.OwnedUnits.Count; i++)
{
@@ -358,7 +673,12 @@ namespace KernelPanic.UI
}
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()
@@ -418,7 +738,8 @@ namespace KernelPanic.UI
private void UpdateBootIntro()
{
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)
{
bootIntroCharacterCount = targetCount;
@@ -468,6 +789,26 @@ namespace KernelPanic.UI
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())
{
ShowMainMenu();
@@ -509,6 +850,12 @@ namespace KernelPanic.UI
}
}
if (IsGachaVisible())
{
gachaScreen.HandleKeyDown(evt);
return;
}
if (evt.keyCode == KeyCode.UpArrow)
{
SelectCommand(selectedCommandIndex - 1);
@@ -557,10 +904,15 @@ namespace KernelPanic.UI
return collectionPanel != null && !collectionPanel.ClassListContains(HiddenClassName);
}
private bool IsGachaVisible()
{
return gachaPanel != null && !gachaPanel.ClassListContains(HiddenClassName);
}
private bool IsSubScreenVisible()
{
return IsRunSetupVisible() || IsCollectionVisible() ||
(gachaPanel != null && !gachaPanel.ClassListContains(HiddenClassName)) ||
IsGachaVisible() ||
(settingsPanel != null && !settingsPanel.ClassListContains(HiddenClassName));
}
@@ -581,7 +933,8 @@ namespace KernelPanic.UI
if (playerCollection.OwnedUnits.Count == 0)
{
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;
}
@@ -611,7 +964,9 @@ namespace KernelPanic.UI
summary.Add(meta);
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}")
{
name = $"RunSetupDescription{index}"
@@ -652,7 +1007,8 @@ namespace KernelPanic.UI
{
runSetupDetail?.Clear();
loadoutRows.Clear();
if (runSetupDetail == null || selectedPackageIndex < 0 || selectedPackageIndex >= playerCollection.OwnedUnits.Count)
if (runSetupDetail == null || selectedPackageIndex < 0 ||
selectedPackageIndex >= playerCollection.OwnedUnits.Count)
{
return;
}
@@ -678,7 +1034,10 @@ namespace KernelPanic.UI
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");
}
@@ -994,6 +1353,7 @@ namespace KernelPanic.UI
private void ShowGacha()
{
gachaScreen.Open();
ShowPanel(gachaPanel);
}
@@ -1022,6 +1382,8 @@ namespace KernelPanic.UI
{
eventBanner.AddToClassList(HiddenClassName);
}
RefreshCurrencyReadouts();
}
private sealed class CommandMenuEntry
@@ -1050,4 +1412,4 @@ namespace KernelPanic.UI
}
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6bfa65b8d497538499a5b84a4d7a4e9f
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 14c81f7c3ea7de425b5ba4adf237c460