Implement more UX and cards for other distros

This commit is contained in:
2026-07-06 15:37:12 +02:00
parent 8b6c6235c2
commit 68dc13c60d
31 changed files with 759 additions and 16 deletions
+35
View File
@@ -0,0 +1,35 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace KernelPanic.Data
{
/// <summary>
/// Resolves persisted card ids to immutable card definitions.
/// </summary>
public sealed class CardDatabase : ScriptableObject
{
[SerializeField] private List<CardDefinition> allCards = new();
public IReadOnlyList<CardDefinition> AllCards => allCards;
public CardDefinition FindById(string id)
{
if (string.IsNullOrWhiteSpace(id))
{
return null;
}
for (int i = 0; i < allCards.Count; i++)
{
CardDefinition card = allCards[i];
if (card != null && string.Equals(card.Id, id, StringComparison.OrdinalIgnoreCase))
{
return card;
}
}
return null;
}
}
}
+2
View File
@@ -12,6 +12,7 @@ namespace KernelPanic.Data
[SerializeField] private string id;
[SerializeField] private string displayName;
[SerializeField] private string description;
[SerializeField] private string flavorText;
[SerializeField] private Language language;
[SerializeField] private Rarity rarity;
[SerializeField] private int cycleCost;
@@ -25,6 +26,7 @@ namespace KernelPanic.Data
public string Id => id;
public string DisplayName => displayName;
public string Description => description;
public string FlavorText => flavorText;
public Language Language => language;
public Rarity Rarity => rarity;
public int CycleCost => cycleCost;
+16 -2
View File
@@ -9,12 +9,24 @@ namespace KernelPanic.Data
/// </summary>
public sealed class DistroDefinition : ScriptableObject
{
[System.Serializable]
public struct LanguageBlurb
{
[SerializeField] private Language lang;
[SerializeField] private string blurb;
public Language Lang => lang;
public string Blurb => blurb;
}
[SerializeField] private string id;
[SerializeField] private string displayName;
[SerializeField] private Language primaryLanguage;
[SerializeField] private Language secondaryLanguage;
[SerializeField] private string passiveName;
[SerializeField] private PassiveDefinition passive;
[SerializeField, TextArea] private string description;
[SerializeField, TextArea] private string playstyleSummary;
[SerializeField] private List<LanguageBlurb> languageBlurbs = new();
[SerializeField] private TextAsset asciiArt;
[SerializeField] private Color accentColor = new(0.36f, 1f, 0.57f);
[SerializeField] private int baseUptime;
@@ -26,8 +38,10 @@ namespace KernelPanic.Data
public string DisplayName => displayName;
public Language PrimaryLanguage => primaryLanguage;
public Language SecondaryLanguage => secondaryLanguage;
public string PassiveName => passiveName;
public PassiveDefinition Passive => passive;
public string Description => description;
public string PlaystyleSummary => playstyleSummary;
public IReadOnlyList<LanguageBlurb> LanguageBlurbs => languageBlurbs;
public TextAsset AsciiArt => asciiArt;
public Color AccentColor => accentColor;
public int BaseUptime => baseUptime;
+22
View File
@@ -0,0 +1,22 @@
using UnityEngine;
namespace KernelPanic.Data
{
/// <summary>
/// Defines immutable passive rules and presentation text for a distro.
/// </summary>
public sealed class PassiveDefinition : ScriptableObject
{
[SerializeField] private string id;
[SerializeField] private string displayName;
[SerializeField, TextArea] private string rulesText;
[SerializeField] private string flavorText;
[SerializeField, TextArea] private string designNotes;
public string Id => id;
public string Name => displayName;
public string RulesText => rulesText;
public string FlavorText => flavorText;
public string DesignNotes => designNotes;
}
}
+204
View File
@@ -0,0 +1,204 @@
using System;
using System.Collections.Generic;
using KernelPanic.Data;
namespace KernelPanic.Meta
{
public enum CardLoadoutFailureReason
{
None,
Full,
NotOwned,
Token,
Duplicate,
NotEquipped
}
/// <summary>
/// Tracks equipped exclusive cards per owned distro without persistence or logging.
/// </summary>
public sealed class CardLoadout
{
public const int MaxEquippedCards = 4;
private readonly IReadOnlyList<DistroDefinition> ownedUnits;
private readonly Dictionary<string, List<string>> equippedByDistro = new(StringComparer.OrdinalIgnoreCase);
public CardLoadout(IReadOnlyList<DistroDefinition> ownedUnits)
{
this.ownedUnits = ownedUnits ?? Array.Empty<DistroDefinition>();
}
public IReadOnlyList<string> GetEquippedCardIds(string distroId)
{
return equippedByDistro.TryGetValue(distroId, out List<string> equipped)
? equipped
: Array.Empty<string>();
}
public bool HasLoadout(string distroId)
{
return !string.IsNullOrWhiteSpace(distroId) && equippedByDistro.ContainsKey(distroId);
}
public void EnsureLoadout(DistroDefinition distro)
{
if (distro == null || string.IsNullOrWhiteSpace(distro.Id) || HasLoadout(distro.Id))
{
return;
}
equippedByDistro[distro.Id] = new List<string>();
}
public void ClearAll()
{
equippedByDistro.Clear();
}
public void ClearLoadout(DistroDefinition distro)
{
if (distro == null || string.IsNullOrWhiteSpace(distro.Id))
{
return;
}
equippedByDistro[distro.Id] = new List<string>();
}
public bool TryLoad(string distroId, IEnumerable<string> cardIds, out bool skippedInvalid)
{
skippedInvalid = false;
DistroDefinition distro = FindOwnedDistro(distroId);
if (distro == null)
{
skippedInvalid = true;
return false;
}
List<string> equipped = new();
foreach (string cardId in cardIds ?? Array.Empty<string>())
{
CardDefinition card = FindExclusiveCard(distro, cardId);
if (card == null || card.IsToken || equipped.Contains(card.Id))
{
skippedInvalid = true;
continue;
}
if (equipped.Count < MaxEquippedCards)
{
equipped.Add(card.Id);
}
else
{
skippedInvalid = true;
}
}
equippedByDistro[distro.Id] = equipped;
return true;
}
public bool TryEquip(string distroId, string cardId, out CardLoadoutFailureReason reason)
{
DistroDefinition distro = FindOwnedDistro(distroId);
if (distro == null)
{
reason = CardLoadoutFailureReason.NotOwned;
return false;
}
CardDefinition card = FindExclusiveCard(distro, cardId);
if (card == null)
{
reason = CardLoadoutFailureReason.NotOwned;
return false;
}
if (card.IsToken)
{
reason = CardLoadoutFailureReason.Token;
return false;
}
EnsureLoadout(distro);
List<string> equipped = equippedByDistro[distro.Id];
if (equipped.Contains(card.Id))
{
reason = CardLoadoutFailureReason.Duplicate;
return false;
}
if (equipped.Count >= MaxEquippedCards)
{
reason = CardLoadoutFailureReason.Full;
return false;
}
equipped.Add(card.Id);
reason = CardLoadoutFailureReason.None;
return true;
}
public bool TryUnequip(string distroId, string cardId, out CardLoadoutFailureReason reason)
{
DistroDefinition distro = FindOwnedDistro(distroId);
if (distro == null)
{
reason = CardLoadoutFailureReason.NotOwned;
return false;
}
EnsureLoadout(distro);
List<string> equipped = equippedByDistro[distro.Id];
if (!equipped.Remove(cardId))
{
reason = CardLoadoutFailureReason.NotEquipped;
return false;
}
reason = CardLoadoutFailureReason.None;
return true;
}
private DistroDefinition FindOwnedDistro(string distroId)
{
if (string.IsNullOrWhiteSpace(distroId))
{
return null;
}
for (int i = 0; i < ownedUnits.Count; i++)
{
DistroDefinition distro = ownedUnits[i];
if (distro != null && string.Equals(distro.Id, distroId, StringComparison.OrdinalIgnoreCase))
{
return distro;
}
}
return null;
}
private static CardDefinition FindExclusiveCard(DistroDefinition distro, string cardId)
{
if (distro == null || string.IsNullOrWhiteSpace(cardId))
{
return null;
}
for (int i = 0; i < distro.ExclusiveCards.Count; i++)
{
CardDefinition card = distro.ExclusiveCards[i];
if (card != null && string.Equals(card.Id, cardId, StringComparison.OrdinalIgnoreCase))
{
return card;
}
}
return null;
}
}
}
@@ -167,6 +167,7 @@ namespace KernelPanic.UI
Label description = new(string.IsNullOrWhiteSpace(unit.Description) ? "--" : unit.Description);
description.AddToClassList("collection-detail-description");
detail.Add(description);
AddPassiveDetails(detail, unit);
VisualElement readout = new();
readout.AddToClassList("collection-detail-readout");
@@ -183,7 +184,6 @@ namespace KernelPanic.UI
details.AddToClassList("collection-detail-values");
details.Add(BuildDetailLine("lang", DistroPresentation.FormatLanguages(unit)));
details.Add(BuildDetailLine("passive", string.IsNullOrWhiteSpace(unit.PassiveName) ? "--" : unit.PassiveName));
details.Add(BuildDetailLine("uptime", unit.BaseUptime.ToString()));
details.Add(BuildDetailLine("ram", unit.BaseRam.ToString()));
details.Add(BuildDetailLine("cycles", unit.BaseCyclesPerTurn.ToString()));
@@ -204,6 +204,12 @@ namespace KernelPanic.UI
Label description = new(card == null || string.IsNullOrWhiteSpace(card.Description) ? "--" : card.Description);
description.AddToClassList("collection-detail-description");
detail.Add(description);
if (card != null && !string.IsNullOrWhiteSpace(card.FlavorText))
{
Label flavor = new(card.FlavorText);
flavor.AddToClassList("flavor-text");
detail.Add(flavor);
}
detail.Add(BuildDetailLine("owner", DistroPresentation.DisplayName(entry.Owner)));
detail.Add(BuildDetailLine("lang", card == null ? "--" : card.Language.ToString()));
@@ -227,6 +233,29 @@ namespace KernelPanic.UI
return row;
}
private static void AddPassiveDetails(VisualElement target, DistroDefinition unit)
{
if (unit.Passive == null)
{
return;
}
Label passiveTitle = new(unit.Passive.Name);
passiveTitle.AddToClassList("detail-section-title");
target.Add(passiveTitle);
Label passiveRules = new(unit.Passive.RulesText);
passiveRules.AddToClassList("collection-detail-description");
target.Add(passiveRules);
if (!string.IsNullOrWhiteSpace(unit.Passive.FlavorText))
{
Label passiveFlavor = new(unit.Passive.FlavorText);
passiveFlavor.AddToClassList("flavor-text");
target.Add(passiveFlavor);
}
}
private static IReadOnlyList<CardEntry> BuildCardEntries(DistroDefinition featuredUnit)
{
List<CardEntry> entries = new();
+1 -1
View File
@@ -130,7 +130,7 @@ namespace KernelPanic.UI
asciiFitter.SetArt(DistroArtPresenter.Render(asciiLabel, asciiPlaceholder, unit));
unitNameLabel.text = DistroPresentation.DisplayName(unit);
languagesLabel.text = DistroPresentation.FormatLanguages(unit);
passiveLabel.text = string.IsNullOrWhiteSpace(unit.PassiveName) ? "--" : unit.PassiveName;
passiveLabel.text = unit.Passive == null || string.IsNullOrWhiteSpace(unit.Passive.Name) ? "--" : unit.Passive.Name;
bestWaveLabel.text = "--"; // TODO: Bind best-wave stats from SaveService when stats exist.
SetAccent(ColorUtility.ToHtmlStringRGB(unit.AccentColor));
}
+60 -5
View File
@@ -206,16 +206,28 @@ namespace KernelPanic.UI
{
root.RegisterCallback<KeyDownEvent>(HandleKeyDown);
root.RegisterCallback<PointerDownEvent>(HandlePointerDown);
collectionUnitsButton.clicked += ShowCollectionUnits;
collectionCardsButton.clicked += ShowCollectionCards;
collectionUnitsButton.RegisterCallback<ClickEvent>(HandleCollectionUnitsTabClicked);
collectionCardsButton.RegisterCallback<ClickEvent>(HandleCollectionCardsTabClicked);
}
private void UnregisterCallbacks()
{
root.UnregisterCallback<KeyDownEvent>(HandleKeyDown);
root.UnregisterCallback<PointerDownEvent>(HandlePointerDown);
collectionUnitsButton.clicked -= ShowCollectionUnits;
collectionCardsButton.clicked -= ShowCollectionCards;
collectionUnitsButton.UnregisterCallback<ClickEvent>(HandleCollectionUnitsTabClicked);
collectionCardsButton.UnregisterCallback<ClickEvent>(HandleCollectionCardsTabClicked);
}
private void HandleCollectionUnitsTabClicked(ClickEvent evt)
{
ShowCollectionUnits();
evt.StopPropagation();
}
private void HandleCollectionCardsTabClicked(ClickEvent evt)
{
ShowCollectionCards();
evt.StopPropagation();
}
private void LoadMetaState()
@@ -599,7 +611,8 @@ namespace KernelPanic.UI
summary.Add(meta);
row.Add(summary);
row.Add(new Label(string.IsNullOrWhiteSpace(unit.PassiveName) ? "passive: --" : $"passive: {unit.PassiveName}")
string passiveTitle = unit.Passive == null || string.IsNullOrWhiteSpace(unit.Passive.Name) ? "--" : unit.Passive.Name;
row.Add(new Label($"passive: {passiveTitle}")
{
name = $"RunSetupDescription{index}"
});
@@ -724,10 +737,46 @@ namespace KernelPanic.UI
stats.AddToClassList("collection-detail-muted");
copy.Add(stats);
AddDistroTeachingDetails(copy, unit);
readout.Add(copy);
return readout;
}
private static void AddDistroTeachingDetails(VisualElement target, DistroDefinition unit)
{
if (unit.Passive != null)
{
Label passiveTitle = new(unit.Passive.Name);
passiveTitle.AddToClassList("detail-section-title");
target.Add(passiveTitle);
Label passiveRules = new(unit.Passive.RulesText);
passiveRules.AddToClassList("collection-detail-description");
target.Add(passiveRules);
if (!string.IsNullOrWhiteSpace(unit.Passive.FlavorText))
{
Label passiveFlavor = new(unit.Passive.FlavorText);
passiveFlavor.AddToClassList("flavor-text");
target.Add(passiveFlavor);
}
}
if (!string.IsNullOrWhiteSpace(unit.PlaystyleSummary))
{
Label playstyle = new(unit.PlaystyleSummary);
playstyle.AddToClassList("collection-detail-description");
target.Add(playstyle);
}
for (int i = 0; i < unit.LanguageBlurbs.Count; i++)
{
DistroDefinition.LanguageBlurb blurb = unit.LanguageBlurbs[i];
target.Add(BuildDetailLine(blurb.Lang.ToString(), blurb.Blurb));
}
}
private static VisualElement BuildDetailLine(string key, string value)
{
VisualElement row = new();
@@ -767,6 +816,12 @@ namespace KernelPanic.UI
row.ElementAt(1).AddToClassList("package-description");
}
if (!string.IsNullOrWhiteSpace(card.FlavorText))
{
row.Add(new Label(card.FlavorText) { name = "RunSetupCardFlavor" });
row.ElementAt(row.childCount - 1).AddToClassList("package-flavor");
}
return row;
}
@@ -0,0 +1,47 @@
using System;
using UnityEngine.UIElements;
namespace KernelPanic.UI
{
/// <summary>
/// Binds shared command-style chrome used by terminal sub-screens.
/// </summary>
public sealed class ScreenFrameController
{
private Label titleLabel;
private Label hintLabel;
public void Bind(VisualElement panel, string title, string hint, Action onBack)
{
titleLabel = panel.Q<Label>(className: "screen-frame-title");
hintLabel = panel.Q<Label>(className: "screen-frame-hint");
if (titleLabel != null)
{
titleLabel.text = title;
}
if (hintLabel != null)
{
hintLabel.text = hint;
hintLabel.RegisterCallback<ClickEvent>(_ => onBack?.Invoke());
}
}
public void SetHint(string hint)
{
if (hintLabel != null)
{
hintLabel.text = hint;
}
}
public void SetTitle(string title)
{
if (titleLabel != null)
{
titleLabel.text = title;
}
}
}
}