Add actual ASCII-Art for distros, add first three distros and being able to choose a starting-distro. Update UI to fix multiple small bugs.

This commit is contained in:
2026-07-04 00:12:03 +02:00
parent 36ddc8fa1c
commit 00e990ac18
36 changed files with 1421 additions and 13 deletions
+433 -1
View File
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using KernelPanic.Core;
using KernelPanic.Data;
using KernelPanic.Meta;
using UnityEngine;
using UnityEngine.TextCore.Text;
@@ -26,9 +27,15 @@ namespace KernelPanic.UI
[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 FeaturedUnitPanel featuredUnitPanel = 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 UIDocument document;
private VisualElement root;
private VisualElement shellRoot;
@@ -39,6 +46,9 @@ namespace KernelPanic.UI
private VisualElement settingsPanel;
private VisualElement eventBanner;
private VisualElement backgroundLogLayer;
private VisualElement starterModal;
private VisualElement collectionList;
private VisualElement collectionDetail;
private Label appIdLabel;
private Label entropyLabel;
private Label pullTokensLabel;
@@ -46,23 +56,32 @@ namespace KernelPanic.UI
private Label promptCursorLabel;
private Label bootIntroLogLabel;
private Label motdBodyLabel;
private Label starterConfirmLabel;
private VisualElement motdBlock;
private Button collectionBackButton;
private Button gachaBackButton;
private Button settingsBackButton;
private SaveService saveService;
private SaveData saveData;
private EntropyWallet wallet;
private GachaService gachaService;
private PlayerCollection playerCollection;
private BackgroundLogRingBuffer backgroundLog;
private IEventBannerSource eventBannerSource;
private int selectedCommandIndex;
private int selectedStarterIndex;
private int selectedCollectionIndex;
private float bootIntroElapsed;
private int bootIntroCharacterCount;
private bool cursorVisible;
private bool suppressNextClick;
private bool starterModalActive;
private bool starterConfirming;
private bool warnedUnresolvedSaveId;
private string bootIntroCopy;
private IVisualElementScheduledItem blinkSchedule;
private IVisualElementScheduledItem bootIntroSchedule;
private IVisualElementScheduledItem starterCloseSchedule;
public void Initialize(EntropyWallet initializedWallet)
{
@@ -73,13 +92,18 @@ namespace KernelPanic.UI
private void Awake()
{
document = GetComponent<UIDocument>();
saveService = new SaveService();
gachaService = new GachaService();
playerCollection = new PlayerCollection(); // TODO: Replace with persistent player-collection service composition.
Initialize(new EntropyWallet()); // TODO: Replace with persistent wallet service composition.
BindElements();
BindCommandEntries();
RegisterCommandEntryCallbacks();
featuredUnitPanel.Bind(root);
RegisterStarterCallbacks();
featuredUnitPanel.Bind(root, monospaceFont);
LoadMetaState();
playerCollection.Changed += HandleMetaStateChanged;
gachaService.BannerPoolChanged += HandleMetaStateChanged;
ApplyOptionalFont();
}
@@ -92,6 +116,7 @@ namespace KernelPanic.UI
RefreshCurrencyReadouts();
RefreshEventBanner();
featuredUnitPanel.Refresh(playerCollection.OwnedUnits);
RefreshCollection();
SelectCommand(0);
ShowMainMenu();
StartAmbientSchedules();
@@ -104,6 +129,7 @@ namespace KernelPanic.UI
blinkSchedule?.Pause();
backgroundLog?.Stop();
bootIntroSchedule?.Pause();
starterCloseSchedule?.Pause();
}
private void BindElements()
@@ -119,6 +145,9 @@ 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");
appIdLabel = root.Q<Label>("AppIdLabel");
entropyLabel = root.Q<Label>("EntropyLabel");
@@ -127,6 +156,15 @@ 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");
gachaBackButton = root.Q<Button>("GachaBackButton");
@@ -171,6 +209,77 @@ namespace KernelPanic.UI
settingsBackButton.clicked -= ShowMainMenu;
}
private void LoadMetaState()
{
saveData = saveService.Load();
saveData.EnsureLists();
for (int i = 0; i < saveData.ownedUnitIds.Count; i++)
{
DistroDefinition unit = ResolveSavedDistro(saveData.ownedUnitIds[i]);
if (unit != null)
{
playerCollection.Add(unit);
}
}
for (int i = 0; i < saveData.bannerPoolIds.Count; i++)
{
DistroDefinition unit = ResolveSavedDistro(saveData.bannerPoolIds[i]);
if (unit != null)
{
gachaService.AddToBannerPool(unit);
}
}
}
private DistroDefinition ResolveSavedDistro(string id)
{
DistroDefinition unit = distroDatabase == null ? null : distroDatabase.FindById(id);
if (unit == null && !warnedUnresolvedSaveId)
{
warnedUnresolvedSaveId = true;
Debug.LogWarning($"Save references a distro id that is not in DistroDatabase: {id}");
}
return unit;
}
private void HandleMetaStateChanged()
{
SaveCurrentState();
featuredUnitPanel.Refresh(playerCollection.OwnedUnits);
RefreshCollection();
}
private void SaveCurrentState()
{
saveData ??= SaveData.CreateDefault();
saveData.EnsureLists();
saveData.ownedUnitIds.Clear();
saveData.bannerPoolIds.Clear();
for (int i = 0; i < playerCollection.OwnedUnits.Count; i++)
{
DistroDefinition unit = playerCollection.OwnedUnits[i];
if (unit != null && !string.IsNullOrWhiteSpace(unit.Id))
{
saveData.ownedUnitIds.Add(unit.Id);
}
}
for (int i = 0; i < gachaService.BannerPool.Count; i++)
{
DistroDefinition unit = gachaService.BannerPool[i];
if (unit != null && !string.IsNullOrWhiteSpace(unit.Id))
{
saveData.bannerPoolIds.Add(unit.Id);
}
}
saveService.Save(saveData);
}
private void RefreshStaticText()
{
appIdLabel.text = $"kernel-panic v{Application.version} - tty1";
@@ -189,6 +298,20 @@ 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)
@@ -215,6 +338,131 @@ namespace KernelPanic.UI
: banner.Text;
}
private void RefreshCollection()
{
if (collectionList == null || collectionDetail == null)
{
return;
}
collectionRows.Clear();
collectionList.Clear();
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 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 RenderCollectionDetail(DistroDefinition unit)
{
collectionDetail.Clear();
// 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 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 FormatLanguages(DistroDefinition unit)
{
return $"{unit.PrimaryLanguage} / {unit.SecondaryLanguage}";
}
private void StartAmbientSchedules()
{
blinkSchedule?.Pause();
@@ -277,6 +525,7 @@ namespace KernelPanic.UI
shellRoot.RemoveFromClassList("boot-hidden");
shellRoot.AddToClassList("boot-visible");
backgroundLog?.Start(UIPreferences.ReducedMotion);
ShowStarterModalIfNeeded();
root.Focus();
}
@@ -299,6 +548,13 @@ namespace KernelPanic.UI
return;
}
if (starterModalActive)
{
HandleStarterKeyDown(evt);
evt.StopPropagation();
return;
}
if (evt.keyCode == KeyCode.UpArrow)
{
SelectCommand(selectedCommandIndex - 1);
@@ -342,6 +598,181 @@ 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
@@ -402,6 +833,7 @@ namespace KernelPanic.UI
private void ShowCollection()
{
RefreshCollection();
ShowPanel(collectionPanel);
}