diff --git a/Assets/Scripts/Data/DistroDatabase.cs b/Assets/Scripts/Data/DistroDatabase.cs
index d388af3..aa325a1 100644
--- a/Assets/Scripts/Data/DistroDatabase.cs
+++ b/Assets/Scripts/Data/DistroDatabase.cs
@@ -31,5 +31,52 @@ namespace KernelPanic.Data
return null;
}
+
+ ///
+ /// Checks that every entry is assigned and has a unique, non-blank id. Used at both
+ /// edit time (OnValidate) and runtime, so misconfiguration surfaces before it can
+ /// silently disable a feature that depends on this database.
+ ///
+ public bool TryValidate(out string error)
+ {
+ for (int i = 0; i < allDistros.Count; i++)
+ {
+ if (allDistros[i] == null)
+ {
+ error = $"entry {i} is empty";
+ return false;
+ }
+ }
+
+ HashSet seenIds = new(StringComparer.OrdinalIgnoreCase);
+ for (int i = 0; i < allDistros.Count; i++)
+ {
+ string id = allDistros[i].Id;
+ if (string.IsNullOrWhiteSpace(id))
+ {
+ error = $"'{allDistros[i].name}' has a blank id";
+ return false;
+ }
+
+ if (!seenIds.Add(id))
+ {
+ error = $"duplicate distro id '{id}'";
+ return false;
+ }
+ }
+
+ error = null;
+ return true;
+ }
+
+#if UNITY_EDITOR
+ private void OnValidate()
+ {
+ if (!TryValidate(out string error))
+ {
+ Debug.LogError($"DistroDatabase '{name}': {error}.", this);
+ }
+ }
+#endif
}
}
diff --git a/Assets/Scripts/UI/CollectionScreenController.cs b/Assets/Scripts/UI/CollectionScreenController.cs
new file mode 100644
index 0000000..f3b4214
--- /dev/null
+++ b/Assets/Scripts/UI/CollectionScreenController.cs
@@ -0,0 +1,144 @@
+using System;
+using System.Collections.Generic;
+using KernelPanic.Data;
+using UnityEngine;
+using UnityEngine.TextCore.Text;
+using UnityEngine.UIElements;
+
+namespace KernelPanic.UI
+{
+ ///
+ /// Renders the owned-units list and detail readout on the collection screen.
+ ///
+ [Serializable]
+ public sealed class CollectionScreenController
+ {
+ private readonly List rows = new();
+
+ private VisualElement list;
+ private VisualElement detail;
+ private FontAsset monospaceFont;
+ private IReadOnlyList units = Array.Empty();
+ private int selectedIndex;
+
+ public void Bind(VisualElement root, FontAsset artFont)
+ {
+ monospaceFont = artFont;
+ list = root.Q("CollectionList");
+ detail = root.Q("CollectionDetail");
+ }
+
+ public void Refresh(IReadOnlyList ownedUnits)
+ {
+ units = ownedUnits ?? Array.Empty();
+ if (list == null || detail == null)
+ {
+ return;
+ }
+
+ rows.Clear();
+ list.Clear();
+
+ if (units.Count == 0)
+ {
+ detail.Clear();
+ detail.Add(new Label("no units installed") { name = "CollectionEmptyTitle" });
+ detail.Add(new Label("run: curl gacha.sh | sh") { name = "CollectionEmptyHint" });
+ return;
+ }
+
+ selectedIndex = Mathf.Clamp(selectedIndex, 0, units.Count - 1);
+ for (int i = 0; i < units.Count; i++)
+ {
+ int index = i;
+ DistroDefinition unit = units[i];
+ VisualElement row = new();
+ row.AddToClassList("collection-row");
+ row.RegisterCallback(_ => SelectUnit(index));
+ row.RegisterCallback(_ => SelectUnit(index));
+
+ Label name = new(DistroPresentation.DisplayName(unit));
+ name.AddToClassList("collection-row-name");
+ name.style.color = new StyleColor(unit.AccentColor);
+
+ Label languages = new(DistroPresentation.FormatLanguages(unit));
+ languages.AddToClassList("collection-row-languages");
+
+ row.Add(name);
+ row.Add(languages);
+ list.Add(row);
+ rows.Add(row);
+ }
+
+ SelectUnit(selectedIndex);
+ }
+
+ private void SelectUnit(int index)
+ {
+ if (units.Count == 0)
+ {
+ return;
+ }
+
+ selectedIndex = Mathf.Clamp(index, 0, units.Count - 1);
+ for (int i = 0; i < rows.Count; i++)
+ {
+ rows[i].EnableInClassList("selected", i == selectedIndex);
+ }
+
+ RenderDetail(units[selectedIndex]);
+ }
+
+ private void RenderDetail(DistroDefinition unit)
+ {
+ detail.Clear();
+
+ VisualElement readout = new();
+ readout.AddToClassList("collection-detail-readout");
+
+ Label artLabel = new();
+ DistroArtPresenter.ConfigureArtLabel(artLabel, monospaceFont);
+ AsciiArtFitter artFitter = new(artLabel, monospaceFont);
+ VisualElement artPlaceholder = DistroArtPresenter.CreatePlaceholder();
+ artFitter.SetArt(DistroArtPresenter.Render(artLabel, artPlaceholder, unit));
+ readout.Add(artPlaceholder);
+ readout.Add(artLabel);
+
+ VisualElement details = new();
+ details.AddToClassList("collection-detail-values");
+
+ Label name = new(DistroPresentation.DisplayName(unit));
+ name.AddToClassList("collection-detail-name");
+ name.style.color = new StyleColor(unit.AccentColor);
+ details.Add(name);
+
+ 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()));
+
+ readout.Add(details);
+ detail.Add(readout);
+
+ Label description = new(string.IsNullOrWhiteSpace(unit.Description) ? "--" : unit.Description);
+ description.AddToClassList("collection-detail-description");
+ detail.Add(description);
+ }
+
+ private static VisualElement BuildDetailLine(string key, string value)
+ {
+ VisualElement row = new();
+ row.AddToClassList("kv-row");
+
+ Label keyLabel = new(key);
+ keyLabel.AddToClassList("kv-key");
+ Label valueLabel = new(value);
+ valueLabel.AddToClassList("kv-value");
+
+ row.Add(keyLabel);
+ row.Add(valueLabel);
+ return row;
+ }
+ }
+}
diff --git a/Assets/Scripts/UI/CommandKeyBindings.cs b/Assets/Scripts/UI/CommandKeyBindings.cs
new file mode 100644
index 0000000..221ac13
--- /dev/null
+++ b/Assets/Scripts/UI/CommandKeyBindings.cs
@@ -0,0 +1,24 @@
+using UnityEngine;
+
+namespace KernelPanic.UI
+{
+ ///
+ /// Maps number-row/keypad digits to menu slot indices. Shared by the command menu and the
+ /// starter-selection modal so both keep the same 1-5 shortcut behavior.
+ ///
+ internal static class CommandKeyBindings
+ {
+ public static int GetDigitIndex(KeyCode keyCode)
+ {
+ return keyCode switch
+ {
+ KeyCode.Alpha1 or KeyCode.Keypad1 => 0,
+ KeyCode.Alpha2 or KeyCode.Keypad2 => 1,
+ KeyCode.Alpha3 or KeyCode.Keypad3 => 2,
+ KeyCode.Alpha4 or KeyCode.Keypad4 => 3,
+ KeyCode.Alpha5 or KeyCode.Keypad5 => 4,
+ _ => -1
+ };
+ }
+ }
+}
diff --git a/Assets/Scripts/UI/DistroPresentation.cs b/Assets/Scripts/UI/DistroPresentation.cs
new file mode 100644
index 0000000..d6665ec
--- /dev/null
+++ b/Assets/Scripts/UI/DistroPresentation.cs
@@ -0,0 +1,20 @@
+using KernelPanic.Data;
+
+namespace KernelPanic.UI
+{
+ ///
+ /// Shared text formatting for distros, used by every screen that lists or details them.
+ ///
+ public static class DistroPresentation
+ {
+ public static string DisplayName(DistroDefinition unit)
+ {
+ return string.IsNullOrWhiteSpace(unit.DisplayName) ? unit.name : unit.DisplayName;
+ }
+
+ public static string FormatLanguages(DistroDefinition unit)
+ {
+ return $"{unit.PrimaryLanguage} / {unit.SecondaryLanguage}";
+ }
+ }
+}
diff --git a/Assets/Scripts/UI/FeaturedUnitPanel.cs b/Assets/Scripts/UI/FeaturedUnitPanel.cs
index 0cbf9f1..b194212 100644
--- a/Assets/Scripts/UI/FeaturedUnitPanel.cs
+++ b/Assets/Scripts/UI/FeaturedUnitPanel.cs
@@ -100,11 +100,10 @@ namespace KernelPanic.UI
populatedState.RemoveFromClassList("hidden");
emptyState.AddToClassList("hidden");
- string displayName = string.IsNullOrWhiteSpace(unit.DisplayName) ? unit.name : unit.DisplayName;
titleLabel.text = "[ neofetch ]";
asciiFitter.SetArt(DistroArtPresenter.Render(asciiLabel, asciiPlaceholder, unit));
- unitNameLabel.text = displayName;
- languagesLabel.text = $"{unit.PrimaryLanguage} / {unit.SecondaryLanguage}";
+ unitNameLabel.text = DistroPresentation.DisplayName(unit);
+ languagesLabel.text = DistroPresentation.FormatLanguages(unit);
passiveLabel.text = string.IsNullOrWhiteSpace(unit.PassiveName) ? "--" : unit.PassiveName;
bestWaveLabel.text = "--"; // TODO: Bind best-wave stats from SaveService when stats exist.
SetAccent(ColorUtility.ToHtmlStringRGB(unit.AccentColor));
diff --git a/Assets/Scripts/UI/MainMenuController.cs b/Assets/Scripts/UI/MainMenuController.cs
index 049933d..19227b2 100644
--- a/Assets/Scripts/UI/MainMenuController.cs
+++ b/Assets/Scripts/UI/MainMenuController.cs
@@ -25,21 +25,24 @@ namespace KernelPanic.UI
private const float BootIntroSeconds = 1.5f;
private static bool bootIntroPlayed;
+ // Static fields don't reset between Play sessions when the Editor's "Reload Domain"
+ // option is disabled. RuntimeInitializeOnLoadMethod runs once per player/Play-mode
+ // startup regardless of that setting, so this keeps bootIntroPlayed session-scoped.
+ [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
+ private static void ResetSessionState()
+ {
+ bootIntroPlayed = false;
+ }
+
[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();
private readonly List commandEntries = new();
- private readonly List starterCards = new();
- private readonly List