Merge pull request #1 from Fretuks/claude/error-cause-analysis-lqyuxv

Refactor UI controllers into dedicated, reusable components
This commit is contained in:
2026-07-06 12:46:26 +02:00
committed by GitHub
9 changed files with 573 additions and 777 deletions
+47
View File
@@ -31,5 +31,52 @@ namespace KernelPanic.Data
return null;
}
/// <summary>
/// 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.
/// </summary>
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<string> 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
}
}
@@ -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
{
/// <summary>
/// Renders the owned-units list and detail readout on the collection screen.
/// </summary>
[Serializable]
public sealed class CollectionScreenController
{
private readonly List<VisualElement> rows = new();
private VisualElement list;
private VisualElement detail;
private FontAsset monospaceFont;
private IReadOnlyList<DistroDefinition> units = Array.Empty<DistroDefinition>();
private int selectedIndex;
public void Bind(VisualElement root, FontAsset artFont)
{
monospaceFont = artFont;
list = root.Q<VisualElement>("CollectionList");
detail = root.Q<VisualElement>("CollectionDetail");
}
public void Refresh(IReadOnlyList<DistroDefinition> ownedUnits)
{
units = ownedUnits ?? Array.Empty<DistroDefinition>();
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<PointerEnterEvent>(_ => SelectUnit(index));
row.RegisterCallback<ClickEvent>(_ => 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;
}
}
}
+24
View File
@@ -0,0 +1,24 @@
using UnityEngine;
namespace KernelPanic.UI
{
/// <summary>
/// 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.
/// </summary>
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
};
}
}
}
+20
View File
@@ -0,0 +1,20 @@
using KernelPanic.Data;
namespace KernelPanic.UI
{
/// <summary>
/// Shared text formatting for distros, used by every screen that lists or details them.
/// </summary>
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}";
}
}
}
+2 -3
View File
@@ -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));
+30 -774
View File
@@ -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<CommandMenuEntry> commandEntries = new();
private readonly List<VisualElement> starterCards = new();
private readonly List<Label> starterNames = new();
private readonly List<Label> starterLanguages = new();
private readonly List<Label> starterDescriptions = new();
private readonly List<VisualElement> collectionRows = new();
private readonly List<VisualElement> cardRows = new();
private readonly List<VisualElement> runSetupRows = new();
private readonly List<VisualElement> packageRows = new();
private UIDocument document;
private VisualElement root;
private VisualElement shellRoot;
@@ -51,11 +54,6 @@ namespace KernelPanic.UI
private VisualElement settingsPanel;
private VisualElement eventBanner;
private VisualElement backgroundLogLayer;
private VisualElement starterModal;
private VisualElement collectionList;
private VisualElement collectionDetail;
private VisualElement runSetupList;
private VisualElement runSetupDetail;
private Label appIdLabel;
private Label entropyLabel;
private Label pullTokensLabel;
@@ -63,7 +61,6 @@ namespace KernelPanic.UI
private Label promptCursorLabel;
private Label bootIntroLogLabel;
private Label motdBodyLabel;
private Label starterConfirmLabel;
private VisualElement motdBlock;
private Button collectionBackButton;
private Button collectionUnitsButton;
@@ -80,25 +77,15 @@ namespace KernelPanic.UI
private BackgroundLogRingBuffer backgroundLog;
private IEventBannerSource eventBannerSource;
private int selectedCommandIndex;
private int selectedStarterIndex;
private int selectedCollectionIndex;
private int selectedCardIndex;
private int selectedRunSetupIndex;
private int selectedPackageIndex;
private float bootIntroElapsed;
private int bootIntroCharacterCount;
private bool cursorVisible;
private bool suppressNextClick;
private bool starterModalActive;
private bool starterConfirming;
private bool collectionShowingCards;
private bool warnedUnresolvedSaveId;
private bool warnedInvalidLoadoutId;
private string bootIntroCopy;
private IVisualElementScheduledItem blinkSchedule;
private IVisualElementScheduledItem bootIntroSchedule;
private IVisualElementScheduledItem starterCloseSchedule;
private IVisualElementScheduledItem packageNoticeSchedule;
public void Initialize(EntropyWallet initializedWallet)
{
@@ -117,8 +104,9 @@ namespace KernelPanic.UI
BindElements();
BindCommandEntries();
RegisterCommandEntryCallbacks();
RegisterStarterCallbacks();
featuredUnitPanel.Bind(root, monospaceFont);
collectionScreen.Bind(root, monospaceFont);
starterSelection.Bind(root, distroDatabase, HandleStarterConfirmed);
LoadMetaState();
playerCollection.Changed += HandleMetaStateChanged;
gachaService.BannerPoolChanged += HandleMetaStateChanged;
@@ -134,7 +122,7 @@ namespace KernelPanic.UI
RefreshCurrencyReadouts();
RefreshEventBanner();
featuredUnitPanel.Refresh(playerCollection.OwnedUnits);
RefreshCollection();
collectionScreen.Refresh(playerCollection.OwnedUnits);
SelectCommand(0);
ShowMainMenu();
StartAmbientSchedules();
@@ -147,8 +135,7 @@ namespace KernelPanic.UI
blinkSchedule?.Pause();
backgroundLog?.Stop();
bootIntroSchedule?.Pause();
starterCloseSchedule?.Pause();
packageNoticeSchedule?.Pause();
starterSelection.PauseSchedules();
}
private void BindElements()
@@ -165,11 +152,6 @@ namespace KernelPanic.UI
motdBlock = root.Q<VisualElement>("MotdBlock");
backgroundLogLayer = root.Q<VisualElement>("BackgroundLogLayer");
backgroundLog = new BackgroundLogRingBuffer(backgroundLogLayer, BootLogCopy.Lines);
starterModal = root.Q<VisualElement>("StarterModal");
collectionList = root.Q<VisualElement>("CollectionList");
collectionDetail = root.Q<VisualElement>("CollectionDetail");
runSetupList = root.Q<VisualElement>("RunSetupList");
runSetupDetail = root.Q<VisualElement>("RunSetupDetail");
appIdLabel = root.Q<Label>("AppIdLabel");
entropyLabel = root.Q<Label>("EntropyLabel");
@@ -178,15 +160,6 @@ namespace KernelPanic.UI
promptCursorLabel = root.Q<Label>("PromptCursorLabel");
bootIntroLogLabel = root.Q<Label>("BootIntroLogLabel");
motdBodyLabel = root.Q<Label>("MotdBodyLabel");
starterConfirmLabel = root.Q<Label>("StarterConfirmLabel");
for (int i = 0; i < 3; i++)
{
starterCards.Add(root.Q<VisualElement>($"StarterOption{i}"));
starterNames.Add(root.Q<Label>($"StarterName{i}"));
starterLanguages.Add(root.Q<Label>($"StarterLanguages{i}"));
starterDescriptions.Add(root.Q<Label>($"StarterDescription{i}"));
}
collectionBackButton = root.Q<Button>("CollectionBackButton");
collectionUnitsButton = root.Q<Button>("CollectionUnitsButton");
@@ -332,16 +305,21 @@ namespace KernelPanic.UI
EnsureLoadoutsForOwnedUnits();
SaveCurrentState();
featuredUnitPanel.Refresh(playerCollection.OwnedUnits);
RefreshCollection();
RefreshRunSetup();
collectionScreen.Refresh(playerCollection.OwnedUnits);
}
private void EnsureLoadoutsForOwnedUnits()
private void HandleStarterConfirmed(DistroDefinition picked, IReadOnlyList<DistroDefinition> remaining)
{
for (int i = 0; i < playerCollection.OwnedUnits.Count; i++)
playerCollection.Add(picked);
for (int i = 0; i < remaining.Count; i++)
{
cardLoadout.EnsureDefaultLoadout(playerCollection.OwnedUnits[i]);
gachaService.AddToBannerPool(remaining[i]);
}
saveData.starterChosen = true;
SaveCurrentState();
featuredUnitPanel.Refresh(playerCollection.OwnedUnits);
collectionScreen.Refresh(playerCollection.OwnedUnits);
}
private void SaveCurrentState()
@@ -392,20 +370,6 @@ namespace KernelPanic.UI
}
}
private void RegisterStarterCallbacks()
{
for (int i = 0; i < starterCards.Count; i++)
{
int index = i;
starterCards[i].RegisterCallback<PointerEnterEvent>(_ => SelectStarter(index));
starterCards[i].RegisterCallback<ClickEvent>(_ =>
{
SelectStarter(index);
ConfirmStarterSelectionIntent();
});
}
}
private void RefreshCurrencyReadouts()
{
if (entropyLabel == null || pullTokensLabel == null || gachaService == null || wallet == null)
@@ -432,525 +396,6 @@ namespace KernelPanic.UI
: banner.Text;
}
private void RefreshCollection()
{
if (collectionList == null || collectionDetail == null)
{
return;
}
if (collectionShowingCards)
{
RefreshCollectionCards();
return;
}
RefreshCollectionUnits();
}
private void RefreshCollectionUnits()
{
collectionRows.Clear();
collectionList.Clear();
collectionDetail.Clear();
collectionUnitsButton?.AddToClassList(SelectedClassName);
collectionCardsButton?.RemoveFromClassList(SelectedClassName);
IReadOnlyList<DistroDefinition> units = playerCollection.OwnedUnits;
if (units.Count == 0)
{
collectionDetail.Clear();
collectionDetail.Add(new Label("no units installed") { name = "CollectionEmptyTitle" });
collectionDetail.Add(new Label("run: curl gacha.sh | sh") { name = "CollectionEmptyHint" });
return;
}
selectedCollectionIndex = Mathf.Clamp(selectedCollectionIndex, 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<PointerEnterEvent>(_ => SelectCollectionUnit(index));
row.RegisterCallback<ClickEvent>(_ => SelectCollectionUnit(index));
Label name = new(DisplayName(unit));
name.AddToClassList("collection-row-name");
name.style.color = new StyleColor(unit.AccentColor);
Label languages = new(FormatLanguages(unit));
languages.AddToClassList("collection-row-languages");
row.Add(name);
row.Add(languages);
collectionList.Add(row);
collectionRows.Add(row);
}
SelectCollectionUnit(selectedCollectionIndex);
}
private void RefreshCollectionCards()
{
cardRows.Clear();
collectionList.Clear();
collectionDetail.Clear();
collectionUnitsButton?.RemoveFromClassList(SelectedClassName);
collectionCardsButton?.AddToClassList(SelectedClassName);
IReadOnlyList<CardDefinition> cards = cardDatabase == null ? Array.Empty<CardDefinition>() : cardDatabase.AllCards;
if (cards.Count == 0)
{
collectionDetail.Add(new Label("no cards indexed") { name = "CollectionEmptyTitle" });
collectionDetail.Add(new Label("assign a CardDatabase to browse card rules here") { name = "CollectionEmptyHint" });
return;
}
for (int i = 0; i < cards.Count; i++)
{
CardDefinition card = cards[i];
if (card == null)
{
continue;
}
int index = cardRows.Count;
VisualElement row = new();
row.AddToClassList("collection-row");
row.RegisterCallback<PointerEnterEvent>(_ => SelectCollectionCard(index));
row.RegisterCallback<ClickEvent>(_ => SelectCollectionCard(index));
Label name = new(DisplayName(card));
name.AddToClassList("collection-row-name");
Label meta = new($"{card.Language} {card.CycleCost}c");
meta.AddToClassList("collection-row-languages");
row.Add(name);
row.Add(meta);
collectionList.Add(row);
cardRows.Add(row);
}
if (cardRows.Count == 0)
{
collectionDetail.Add(new Label("no cards indexed") { name = "CollectionEmptyTitle" });
return;
}
selectedCardIndex = Mathf.Clamp(selectedCardIndex, 0, cardRows.Count - 1);
SelectCollectionCard(selectedCardIndex);
}
private void SelectCollectionUnit(int index)
{
IReadOnlyList<DistroDefinition> units = playerCollection.OwnedUnits;
if (units.Count == 0)
{
return;
}
selectedCollectionIndex = Mathf.Clamp(index, 0, units.Count - 1);
for (int i = 0; i < collectionRows.Count; i++)
{
collectionRows[i].EnableInClassList(SelectedClassName, i == selectedCollectionIndex);
}
RenderCollectionDetail(units[selectedCollectionIndex]);
}
private void SelectCollectionCard(int index)
{
if (cardRows.Count == 0)
{
return;
}
selectedCardIndex = Mathf.Clamp(index, 0, cardRows.Count - 1);
for (int i = 0; i < cardRows.Count; i++)
{
cardRows[i].EnableInClassList(SelectedClassName, i == selectedCardIndex);
}
RenderCardDetail(GetVisibleCard(selectedCardIndex));
}
private void RenderCollectionDetail(DistroDefinition unit)
{
collectionDetail.Clear();
packageRows.Clear();
selectedPackageIndex = 0;
// TODO: Unify this readout with FeaturedUnitPanel when unit presentation grows beyond labels.
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(DisplayName(unit));
name.AddToClassList("collection-detail-name");
name.style.color = new StyleColor(unit.AccentColor);
details.Add(name);
details.Add(BuildDetailLine("lang", 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);
collectionDetail.Add(readout);
Label description = new(string.IsNullOrWhiteSpace(unit.Description) ? "--" : unit.Description);
description.AddToClassList("collection-detail-description");
collectionDetail.Add(description);
}
private void RenderCardDetail(CardDefinition card)
{
collectionDetail.Clear();
if (card == null)
{
return;
}
Label name = new(DisplayName(card));
name.AddToClassList("collection-detail-name");
collectionDetail.Add(name);
collectionDetail.Add(BuildDetailLine("language", card.Language.ToString()));
collectionDetail.Add(BuildDetailLine("rarity", card.Rarity.ToString()));
collectionDetail.Add(BuildDetailLine("cost", $"{card.CycleCost} cycles"));
collectionDetail.Add(BuildDetailLine("track", card.ResolutionTrack.ToString()));
collectionDetail.Add(BuildDetailLine("type", card.DistroExclusive ? "distro exclusive" : "standard"));
Label description = new(string.IsNullOrWhiteSpace(card.Description) ? "--" : card.Description);
description.AddToClassList("collection-detail-description");
collectionDetail.Add(description);
}
private void RefreshRunSetup()
{
if (runSetupList == null || runSetupDetail == null)
{
return;
}
runSetupRows.Clear();
runSetupList.Clear();
runSetupDetail.Clear();
IReadOnlyList<DistroDefinition> units = playerCollection.OwnedUnits;
if (units.Count == 0)
{
runSetupDetail.Add(new Label("no units installed") { name = "RunSetupEmptyTitle" });
runSetupDetail.Add(new Label("run: curl gacha.sh | sh") { name = "RunSetupEmptyHint" });
return;
}
selectedRunSetupIndex = Mathf.Clamp(selectedRunSetupIndex, 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<PointerEnterEvent>(_ => SelectRunSetupUnit(index));
row.RegisterCallback<ClickEvent>(_ => SelectRunSetupUnit(index));
Label name = new(DisplayName(unit));
name.AddToClassList("collection-row-name");
name.style.color = new StyleColor(unit.AccentColor);
Label languages = new(FormatLanguages(unit));
languages.AddToClassList("collection-row-languages");
row.Add(name);
row.Add(languages);
runSetupList.Add(row);
runSetupRows.Add(row);
}
SelectRunSetupUnit(selectedRunSetupIndex);
}
private void SelectRunSetupUnit(int index)
{
IReadOnlyList<DistroDefinition> units = playerCollection.OwnedUnits;
if (units.Count == 0)
{
return;
}
selectedRunSetupIndex = Mathf.Clamp(index, 0, units.Count - 1);
for (int i = 0; i < runSetupRows.Count; i++)
{
runSetupRows[i].EnableInClassList(SelectedClassName, i == selectedRunSetupIndex);
}
RenderRunSetupDetail(units[selectedRunSetupIndex]);
}
private void RenderRunSetupDetail(DistroDefinition unit)
{
runSetupDetail.Clear();
packageRows.Clear();
selectedPackageIndex = 0;
Label name = new(DisplayName(unit));
name.AddToClassList("collection-detail-name");
name.style.color = new StyleColor(unit.AccentColor);
runSetupDetail.Add(name);
runSetupDetail.Add(BuildDetailLine("lang", FormatLanguages(unit)));
runSetupDetail.Add(BuildDetailLine("loadout", $"pick {CardLoadout.MaxEquippedCards} cards"));
BuildPackageList(unit, runSetupDetail);
}
private void BuildPackageList(DistroDefinition unit, VisualElement parent)
{
VisualElement packageSection = new();
packageSection.AddToClassList("package-list");
Label header = new("dpkg -l (pick 4)");
header.AddToClassList("package-header");
packageSection.Add(header);
Label notice = new();
notice.name = "PackageNotice";
notice.AddToClassList("package-notice");
notice.AddToClassList(HiddenClassName);
packageSection.Add(notice);
if (unit.ExclusiveCards.Count == 0)
{
Label empty = new("no distro packages installed");
empty.AddToClassList("package-empty");
packageSection.Add(empty);
parent.Add(packageSection);
return;
}
IReadOnlyList<string> equippedIds = cardLoadout.GetEquippedCardIds(unit.Id);
for (int i = 0; i < unit.ExclusiveCards.Count; i++)
{
CardDefinition card = unit.ExclusiveCards[i];
if (card == null || card.IsToken)
{
continue;
}
int index = packageRows.Count;
VisualElement row = BuildPackageRow(unit, card, equippedIds, index);
packageRows.Add(row);
packageSection.Add(row);
}
parent.Add(packageSection);
RefreshPackageSelection();
}
private VisualElement BuildPackageRow(DistroDefinition unit, CardDefinition card, IReadOnlyList<string> equippedIds, int index)
{
bool equipped = ContainsId(equippedIds, card.Id);
bool loadoutFull = equippedIds.Count >= CardLoadout.MaxEquippedCards;
VisualElement row = new();
row.AddToClassList("package-row");
row.EnableInClassList("equipped", equipped);
row.EnableInClassList("dimmed", !equipped && loadoutFull);
row.RegisterCallback<PointerEnterEvent>(_ =>
{
selectedPackageIndex = index;
RefreshPackageSelection();
});
row.RegisterCallback<ClickEvent>(_ => TogglePackage(unit, card));
VisualElement summary = new();
summary.AddToClassList("package-summary");
Label marker = new(equipped ? "[x]" : "[ ]");
marker.AddToClassList("package-marker");
marker.style.color = new StyleColor(equipped ? unit.AccentColor : Color.gray);
Label name = new(DisplayName(card));
name.AddToClassList("package-name");
Label meta = new($"{card.Language} {card.CycleCost}c");
meta.AddToClassList("package-meta");
summary.Add(marker);
summary.Add(name);
summary.Add(meta);
Label cardDescription = new(string.IsNullOrWhiteSpace(card.Description) ? "--" : card.Description);
cardDescription.AddToClassList("package-description");
row.Add(summary);
row.Add(cardDescription);
return row;
}
private void RefreshPackageSelection()
{
for (int i = 0; i < packageRows.Count; i++)
{
packageRows[i].EnableInClassList(SelectedClassName, i == selectedPackageIndex);
}
}
private void ToggleSelectedPackage()
{
IReadOnlyList<DistroDefinition> units = playerCollection.OwnedUnits;
if (units.Count == 0 || selectedRunSetupIndex >= units.Count)
{
return;
}
DistroDefinition unit = units[selectedRunSetupIndex];
CardDefinition card = GetVisibleExclusiveCard(unit, selectedPackageIndex);
if (card != null)
{
TogglePackage(unit, card);
}
}
private void TogglePackage(DistroDefinition unit, CardDefinition card)
{
IReadOnlyList<string> equippedIds = cardLoadout.GetEquippedCardIds(unit.Id);
bool changed = ContainsId(equippedIds, card.Id)
? cardLoadout.TryUnequip(unit.Id, card.Id, out CardLoadoutFailureReason reason)
: cardLoadout.TryEquip(unit.Id, card.Id, out reason);
if (!changed)
{
ShowPackageNotice(reason);
return;
}
SaveCurrentState();
RenderRunSetupDetail(unit);
}
private void ShowPackageNotice(CardLoadoutFailureReason reason)
{
Label notice = runSetupDetail.Q<Label>("PackageNotice");
if (notice == null)
{
return;
}
notice.text = reason == CardLoadoutFailureReason.Full
? "dpkg: dependency limit reached (unequip a package first)"
: $"dpkg: package toggle failed ({reason})";
notice.RemoveFromClassList(HiddenClassName);
packageNoticeSchedule?.Pause();
if (UIPreferences.ReducedMotion)
{
return;
}
packageNoticeSchedule = root.schedule.Execute(() => notice.AddToClassList(HiddenClassName)).StartingIn(2000);
}
private static CardDefinition GetVisibleExclusiveCard(DistroDefinition unit, int visibleIndex)
{
int currentIndex = 0;
for (int i = 0; i < unit.ExclusiveCards.Count; i++)
{
CardDefinition card = unit.ExclusiveCards[i];
if (card == null || card.IsToken)
{
continue;
}
if (currentIndex == visibleIndex)
{
return card;
}
currentIndex++;
}
return null;
}
private CardDefinition GetVisibleCard(int visibleIndex)
{
IReadOnlyList<CardDefinition> cards = cardDatabase == null ? Array.Empty<CardDefinition>() : cardDatabase.AllCards;
int currentIndex = 0;
for (int i = 0; i < cards.Count; i++)
{
CardDefinition card = cards[i];
if (card == null)
{
continue;
}
if (currentIndex == visibleIndex)
{
return card;
}
currentIndex++;
}
return null;
}
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;
}
private static string DisplayName(DistroDefinition unit)
{
return string.IsNullOrWhiteSpace(unit.DisplayName) ? unit.name : unit.DisplayName;
}
private static string DisplayName(CardDefinition card)
{
return string.IsNullOrWhiteSpace(card.DisplayName) ? card.name : card.DisplayName;
}
private static bool ContainsId(IReadOnlyList<string> ids, string id)
{
for (int i = 0; i < ids.Count; i++)
{
if (string.Equals(ids[i], id, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private static string FormatLanguages(DistroDefinition unit)
{
return $"{unit.PrimaryLanguage} / {unit.SecondaryLanguage}";
}
private void StartAmbientSchedules()
{
blinkSchedule?.Pause();
@@ -1013,7 +458,7 @@ namespace KernelPanic.UI
shellRoot.RemoveFromClassList("boot-hidden");
shellRoot.AddToClassList("boot-visible");
backgroundLog?.Start(UIPreferences.ReducedMotion);
ShowStarterModalIfNeeded();
starterSelection.ShowIfNeeded(saveData == null || saveData.starterChosen);
root.Focus();
}
@@ -1036,9 +481,9 @@ namespace KernelPanic.UI
return;
}
if (starterModalActive)
if (starterSelection.IsActive)
{
HandleStarterKeyDown(evt);
starterSelection.HandleKeyDown(evt);
evt.StopPropagation();
return;
}
@@ -1095,7 +540,7 @@ namespace KernelPanic.UI
return;
}
int digitIndex = GetDigitCommandIndex(evt.keyCode);
int digitIndex = CommandKeyBindings.GetDigitIndex(evt.keyCode);
if (digitIndex >= 0)
{
SelectCommand(digitIndex);
@@ -1126,194 +571,6 @@ namespace KernelPanic.UI
suppressNextClick = SkipBootIntro();
}
private void ShowStarterModalIfNeeded()
{
if (saveData == null || saveData.starterChosen)
{
return;
}
IReadOnlyList<DistroDefinition> starters = GetStarterDistros();
if (starters.Count < 3)
{
Debug.LogWarning("Starter distro selection needs three distros in DistroDatabase.");
return;
}
starterModalActive = true;
starterConfirming = false;
starterConfirmLabel.AddToClassList(HiddenClassName);
starterModal.RemoveFromClassList(HiddenClassName);
for (int i = 0; i < starterCards.Count; i++)
{
DistroDefinition unit = starters[i];
starterNames[i].text = DisplayName(unit);
starterNames[i].style.color = new StyleColor(unit.AccentColor);
starterLanguages[i].text = FormatLanguages(unit);
starterDescriptions[i].text = unit.Description;
}
SelectStarter(0);
root.Focus();
}
private IReadOnlyList<DistroDefinition> GetStarterDistros()
{
return distroDatabase == null ? Array.Empty<DistroDefinition>() : distroDatabase.AllDistros;
}
private void SelectStarter(int index)
{
IReadOnlyList<DistroDefinition> starters = GetStarterDistros();
if (starters.Count == 0)
{
return;
}
selectedStarterIndex = (index + Mathf.Min(starters.Count, starterCards.Count)) % Mathf.Min(starters.Count, starterCards.Count);
starterConfirming = false;
starterConfirmLabel.AddToClassList(HiddenClassName);
for (int i = 0; i < starterCards.Count; i++)
{
bool selected = i == selectedStarterIndex;
starterCards[i].EnableInClassList(SelectedClassName, selected);
if (i < starters.Count && selected)
{
StyleColor accent = new(starters[i].AccentColor);
starterCards[i].style.borderTopColor = accent;
starterCards[i].style.borderRightColor = accent;
starterCards[i].style.borderBottomColor = accent;
starterCards[i].style.borderLeftColor = accent;
}
else
{
starterCards[i].style.borderTopColor = StyleKeyword.Null;
starterCards[i].style.borderRightColor = StyleKeyword.Null;
starterCards[i].style.borderBottomColor = StyleKeyword.Null;
starterCards[i].style.borderLeftColor = StyleKeyword.Null;
}
}
}
private void HandleStarterKeyDown(KeyDownEvent evt)
{
if (evt.keyCode == KeyCode.LeftArrow)
{
SelectStarter(selectedStarterIndex - 1);
return;
}
if (evt.keyCode == KeyCode.RightArrow)
{
SelectStarter(selectedStarterIndex + 1);
return;
}
int digitIndex = GetDigitCommandIndex(evt.keyCode);
if (digitIndex >= 0 && digitIndex < 3)
{
SelectStarter(digitIndex);
return;
}
if (evt.keyCode == KeyCode.Return || evt.keyCode == KeyCode.KeypadEnter)
{
if (starterConfirming)
{
ConfirmStarterSelection();
return;
}
ConfirmStarterSelectionIntent();
return;
}
if (evt.keyCode == KeyCode.Y && starterConfirming)
{
ConfirmStarterSelection();
return;
}
if (evt.keyCode == KeyCode.Escape || evt.keyCode == KeyCode.N)
{
starterConfirming = false;
starterConfirmLabel.AddToClassList(HiddenClassName);
}
}
private void ConfirmStarterSelectionIntent()
{
IReadOnlyList<DistroDefinition> starters = GetStarterDistros();
if (selectedStarterIndex >= starters.Count)
{
return;
}
starterConfirming = true;
starterConfirmLabel.text = $"install {DisplayName(starters[selectedStarterIndex])}? this cannot be undone [Y/n]";
starterConfirmLabel.RemoveFromClassList(HiddenClassName);
}
private void ConfirmStarterSelection()
{
IReadOnlyList<DistroDefinition> starters = GetStarterDistros();
if (selectedStarterIndex >= starters.Count)
{
return;
}
DistroDefinition picked = starters[selectedStarterIndex];
playerCollection.Add(picked);
for (int i = 0; i < starters.Count; i++)
{
if (i != selectedStarterIndex)
{
gachaService.AddToBannerPool(starters[i]);
}
}
saveData.starterChosen = true;
SaveCurrentState();
featuredUnitPanel.Refresh(playerCollection.OwnedUnits);
RefreshCollection();
starterConfirmLabel.text = $"installing {picked.Id} (1/1)... done";
starterConfirmLabel.RemoveFromClassList(HiddenClassName);
if (UIPreferences.ReducedMotion)
{
CloseStarterModal();
return;
}
starterCloseSchedule?.Pause();
starterCloseSchedule = root.schedule.Execute(CloseStarterModal).StartingIn(600);
}
private void CloseStarterModal()
{
starterCloseSchedule?.Pause();
starterCloseSchedule = null;
starterModalActive = false;
starterConfirming = false;
starterModal.AddToClassList(HiddenClassName);
root.Focus();
}
private static int GetDigitCommandIndex(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
};
}
private void SelectCommand(int index)
{
selectedCommandIndex = (index + commandEntries.Count) % commandEntries.Count;
@@ -1362,8 +619,7 @@ namespace KernelPanic.UI
private void ShowCollection()
{
collectionShowingCards = false;
RefreshCollection();
collectionScreen.Refresh(playerCollection.OwnedUnits);
ShowPanel(collectionPanel);
}
@@ -0,0 +1,299 @@
using System;
using System.Collections.Generic;
using KernelPanic.Data;
using UnityEngine;
using UnityEngine.UIElements;
namespace KernelPanic.UI
{
/// <summary>
/// Drives the starter-distro selection modal: presents the choices, confirms the pick, and
/// - if the DistroDatabase isn't usable - shows a dismissible in-UI error instead of leaving
/// the modal silently hidden.
/// </summary>
[Serializable]
public sealed class StarterSelectionController
{
private const int StarterCount = 3;
private readonly List<VisualElement> cards = new();
private readonly List<Label> names = new();
private readonly List<Label> languages = new();
private readonly List<Label> descriptions = new();
private VisualElement root;
private VisualElement modal;
private VisualElement options;
private Label confirmLabel;
private Label errorLabel;
private DistroDatabase database;
private Action<DistroDefinition, IReadOnlyList<DistroDefinition>> onConfirmed;
private IReadOnlyList<DistroDefinition> activeStarters = Array.Empty<DistroDefinition>();
private int selectedIndex;
private bool active;
private bool showingError;
private bool confirming;
private IVisualElementScheduledItem closeSchedule;
public bool IsActive => active;
public void Bind(VisualElement documentRoot, DistroDatabase distroDatabase, Action<DistroDefinition, IReadOnlyList<DistroDefinition>> onStarterConfirmed)
{
root = documentRoot;
database = distroDatabase;
onConfirmed = onStarterConfirmed;
modal = root.Q<VisualElement>("StarterModal");
options = root.Q<VisualElement>("StarterOptions");
confirmLabel = root.Q<Label>("StarterConfirmLabel");
errorLabel = root.Q<Label>("StarterErrorLabel");
for (int i = 0; i < StarterCount; i++)
{
cards.Add(root.Q<VisualElement>($"StarterOption{i}"));
names.Add(root.Q<Label>($"StarterName{i}"));
languages.Add(root.Q<Label>($"StarterLanguages{i}"));
descriptions.Add(root.Q<Label>($"StarterDescription{i}"));
}
for (int i = 0; i < cards.Count; i++)
{
int index = i;
cards[i].RegisterCallback<PointerEnterEvent>(_ => Select(index));
cards[i].RegisterCallback<ClickEvent>(_ =>
{
Select(index);
ConfirmIntent();
});
}
}
public void ShowIfNeeded(bool starterAlreadyChosen)
{
if (starterAlreadyChosen)
{
return;
}
if (!TryGetStarters(out IReadOnlyList<DistroDefinition> starters, out string error))
{
Debug.LogError($"Cannot show starter selection: {error}.");
ShowConfigError(error);
return;
}
activeStarters = starters;
active = true;
showingError = false;
confirming = false;
errorLabel.AddToClassList("hidden");
options.RemoveFromClassList("hidden");
confirmLabel.AddToClassList("hidden");
modal.RemoveFromClassList("hidden");
for (int i = 0; i < cards.Count; i++)
{
DistroDefinition unit = starters[i];
names[i].text = DistroPresentation.DisplayName(unit);
names[i].style.color = new StyleColor(unit.AccentColor);
languages[i].text = DistroPresentation.FormatLanguages(unit);
descriptions[i].text = unit.Description;
}
Select(0);
root.Focus();
}
public void HandleKeyDown(KeyDownEvent evt)
{
if (showingError)
{
if (evt.keyCode == KeyCode.Return || evt.keyCode == KeyCode.KeypadEnter ||
evt.keyCode == KeyCode.Escape || evt.keyCode == KeyCode.Space)
{
Close();
}
return;
}
if (evt.keyCode == KeyCode.LeftArrow)
{
Select(selectedIndex - 1);
return;
}
if (evt.keyCode == KeyCode.RightArrow)
{
Select(selectedIndex + 1);
return;
}
int digitIndex = CommandKeyBindings.GetDigitIndex(evt.keyCode);
if (digitIndex >= 0 && digitIndex < StarterCount)
{
Select(digitIndex);
return;
}
if (evt.keyCode == KeyCode.Return || evt.keyCode == KeyCode.KeypadEnter)
{
if (confirming)
{
Confirm();
return;
}
ConfirmIntent();
return;
}
if (evt.keyCode == KeyCode.Y && confirming)
{
Confirm();
return;
}
if (evt.keyCode == KeyCode.Escape || evt.keyCode == KeyCode.N)
{
confirming = false;
confirmLabel.AddToClassList("hidden");
}
}
public void PauseSchedules()
{
closeSchedule?.Pause();
}
private bool TryGetStarters(out IReadOnlyList<DistroDefinition> starters, out string error)
{
if (database == null)
{
starters = Array.Empty<DistroDefinition>();
error = "no DistroDatabase is assigned";
return false;
}
if (!database.TryValidate(out error))
{
starters = Array.Empty<DistroDefinition>();
return false;
}
IReadOnlyList<DistroDefinition> allDistros = database.AllDistros;
if (allDistros.Count < StarterCount)
{
starters = Array.Empty<DistroDefinition>();
error = $"DistroDatabase needs at least {StarterCount} distros (found {allDistros.Count})";
return false;
}
starters = allDistros;
error = null;
return true;
}
private void ShowConfigError(string error)
{
active = true;
showingError = true;
confirming = false;
options.AddToClassList("hidden");
confirmLabel.AddToClassList("hidden");
errorLabel.text = $"starter selection unavailable: {error}. press enter to continue.";
errorLabel.RemoveFromClassList("hidden");
modal.RemoveFromClassList("hidden");
root.Focus();
}
private void Select(int index)
{
if (activeStarters.Count == 0)
{
return;
}
int slotCount = Mathf.Min(activeStarters.Count, cards.Count);
selectedIndex = (index + slotCount) % slotCount;
confirming = false;
confirmLabel.AddToClassList("hidden");
for (int i = 0; i < cards.Count; i++)
{
bool selected = i == selectedIndex;
cards[i].EnableInClassList("selected", selected);
if (i < activeStarters.Count && selected)
{
StyleColor accent = new(activeStarters[i].AccentColor);
cards[i].style.borderTopColor = accent;
cards[i].style.borderRightColor = accent;
cards[i].style.borderBottomColor = accent;
cards[i].style.borderLeftColor = accent;
}
else
{
cards[i].style.borderTopColor = StyleKeyword.Null;
cards[i].style.borderRightColor = StyleKeyword.Null;
cards[i].style.borderBottomColor = StyleKeyword.Null;
cards[i].style.borderLeftColor = StyleKeyword.Null;
}
}
}
private void ConfirmIntent()
{
if (selectedIndex >= activeStarters.Count)
{
return;
}
confirming = true;
confirmLabel.text = $"install {DistroPresentation.DisplayName(activeStarters[selectedIndex])}? this cannot be undone [Y/n]";
confirmLabel.RemoveFromClassList("hidden");
}
private void Confirm()
{
if (selectedIndex >= activeStarters.Count)
{
return;
}
DistroDefinition picked = activeStarters[selectedIndex];
List<DistroDefinition> remaining = new();
for (int i = 0; i < activeStarters.Count; i++)
{
if (i != selectedIndex)
{
remaining.Add(activeStarters[i]);
}
}
onConfirmed?.Invoke(picked, remaining);
confirmLabel.text = $"installing {picked.Id} (1/1)... done";
confirmLabel.RemoveFromClassList("hidden");
if (UIPreferences.ReducedMotion)
{
Close();
return;
}
closeSchedule?.Pause();
closeSchedule = root.schedule.Execute(Close).StartingIn(600);
}
private void Close()
{
closeSchedule?.Pause();
closeSchedule = null;
active = false;
confirming = false;
showingError = false;
modal.AddToClassList("hidden");
root.Focus();
}
}
}
+6
View File
@@ -735,6 +735,12 @@ Button {
color: var(--kp-amber);
}
.starter-error {
margin-bottom: 18px;
color: var(--kp-amber);
white-space: normal;
}
.terminal-button {
height: 42px;
margin-top: 24px;
+1
View File
@@ -158,6 +158,7 @@
<ui:VisualElement class="starter-dialog">
<ui:Label text="select starter distro" class="starter-title" />
<ui:Label text="choose one base system to install into persistent userspace" class="starter-instruction" />
<ui:Label name="StarterErrorLabel" class="starter-error hidden" />
<ui:VisualElement name="StarterOptions" class="starter-options">
<ui:VisualElement name="StarterOption0" class="starter-card">
<ui:Label name="StarterName0" class="starter-name" />