implement main screen

This commit is contained in:
2026-07-03 13:59:00 +02:00
parent d98d34ec3f
commit 2fc8a05617
35 changed files with 1637 additions and 89 deletions
+30
View File
@@ -0,0 +1,30 @@
using UnityEngine.SceneManagement;
namespace KernelPanic.Core
{
/// <summary>
/// Provides scene navigation entry points for the runtime shell.
/// </summary>
public static class SceneLoader
{
public const string MainMenuSceneName = "MainMenuScene";
public const string GameSceneName = "GameScene";
public static void LoadMainMenu()
{
LoadScene(MainMenuSceneName);
}
public static void LoadGame()
{
LoadScene(GameSceneName);
}
// Static for now because menu navigation has no instance state; a loading-screen service can replace this hook later.
private static void LoadScene(string sceneName)
{
// TODO: Route async progress into a loading screen once the UI shell has one.
SceneManager.LoadSceneAsync(sceneName);
}
}
}
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0d860bbf8e7f4df2a91e2e1e9892d904
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+4
View File
@@ -13,6 +13,8 @@ namespace KernelPanic.Data
[SerializeField] private string displayName;
[SerializeField] private Language primaryLanguage;
[SerializeField] private Language secondaryLanguage;
[SerializeField] private string passiveName;
[SerializeField] private Color accentColor = new(0.36f, 1f, 0.57f);
[SerializeField] private int baseUptime;
[SerializeField] private int baseRam;
[SerializeField] private int baseCyclesPerTurn;
@@ -22,6 +24,8 @@ namespace KernelPanic.Data
public string DisplayName => displayName;
public Language PrimaryLanguage => primaryLanguage;
public Language SecondaryLanguage => secondaryLanguage;
public string PassiveName => passiveName;
public Color AccentColor => accentColor;
public int BaseUptime => baseUptime;
public int BaseRam => baseRam;
public int BaseCyclesPerTurn => baseCyclesPerTurn;
+2
View File
@@ -7,6 +7,8 @@ namespace KernelPanic.Meta
/// </summary>
public sealed class GachaService
{
public int PullTokens => 0; // TODO: Replace with the real pull-token wallet when that meta system exists.
public GachaPullResult PerformPull(EntropyWallet wallet, int cost)
{
throw new NotImplementedException();
+27
View File
@@ -0,0 +1,27 @@
using System;
namespace KernelPanic.Meta
{
/// <summary>
/// Provides optional live-event banner copy for shell surfaces.
/// </summary>
public interface IEventBannerSource
{
EventBannerContent GetBanner();
}
/// <summary>
/// Describes an optional event banner message and remaining duration.
/// </summary>
public sealed class EventBannerContent
{
public EventBannerContent(string text, TimeSpan? remainingTime)
{
Text = text;
RemainingTime = remainingTime;
}
public string Text { get; }
public TimeSpan? RemainingTime { get; }
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 542dd3144a914ea5b1e0c0e21db5ec6b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 84a74d1de7c84009a2cf84394e8a9fa2
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+22
View File
@@ -0,0 +1,22 @@
namespace KernelPanic.UI
{
/// <summary>
/// Contains cosmetic terminal boot-log copy for the main menu shell.
/// </summary>
public static class BootLogCopy
{
public static readonly string[] Lines =
{
"[ 0.000000] kernel-panic: booting userland shell",
"[ 0.071004] cpu: isolating run scheduler",
"[ 0.184331] entropy: wallet device mounted readonly",
"[ 0.264700] tty1: spawning command menu",
"[ 0.391226] units: probing installed distros",
"[ 0.447801] collection: no render pass requested",
"[ 0.521332] gacha: token api unavailable",
"[ 0.684012] events: banner source disconnected",
"[ 0.812445] ui: reduced motion preference loaded",
"[ 1.000000] shell: ready"
};
}
}
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e05527a70e234c16af2f9089012721b8
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+108
View File
@@ -0,0 +1,108 @@
using System;
using System.Collections.Generic;
using KernelPanic.Data;
using UnityEngine;
using UnityEngine.UIElements;
namespace KernelPanic.UI
{
/// <summary>
/// Renders the main menu's neofetch-style featured unit panel.
/// </summary>
[Serializable]
public sealed class FeaturedUnitPanel
{
private const string EmptyAccent = "5cff91";
private const string EmptyAscii = " _\n | |\n ___| |__\n / __| '_ \\\n \\__ \\ | | |\n |___/_| |_|";
[SerializeField] private List<DistroDefinition> featuredUnits = new();
private VisualElement panel;
private Label titleLabel;
private Label asciiLabel;
private Label unitNameLabel;
private Label languagesLabel;
private Label passiveLabel;
private Label bestWaveLabel;
private Label emptyTitleLabel;
private Label emptyHintLabel;
private VisualElement populatedState;
private VisualElement emptyState;
private int selectedIndex;
public void Bind(VisualElement root)
{
panel = root.Q<VisualElement>("FeaturedUnitPanel");
titleLabel = root.Q<Label>("FeaturedUnitTitle");
asciiLabel = root.Q<Label>("FeaturedUnitAscii");
unitNameLabel = root.Q<Label>("FeaturedUnitName");
languagesLabel = root.Q<Label>("FeaturedUnitLanguages");
passiveLabel = root.Q<Label>("FeaturedUnitPassive");
bestWaveLabel = root.Q<Label>("FeaturedUnitBestWave");
emptyTitleLabel = root.Q<Label>("FeaturedUnitEmptyTitle");
emptyHintLabel = root.Q<Label>("FeaturedUnitEmptyHint");
populatedState = root.Q<VisualElement>("FeaturedUnitPopulatedState");
emptyState = root.Q<VisualElement>("FeaturedUnitEmptyState");
}
public void Refresh()
{
if (featuredUnits == null || featuredUnits.Count == 0)
{
ShowEmptyState();
return;
}
selectedIndex = Mathf.Clamp(selectedIndex, 0, featuredUnits.Count - 1);
ShowUnit(featuredUnits[selectedIndex]);
}
public void SelectNext()
{
if (featuredUnits == null || featuredUnits.Count < 2)
{
return;
}
selectedIndex = (selectedIndex + 1) % featuredUnits.Count;
Refresh();
}
private void ShowEmptyState()
{
populatedState.AddToClassList("hidden");
emptyState.RemoveFromClassList("hidden");
titleLabel.text = "neofetch";
emptyTitleLabel.text = "no units installed";
emptyHintLabel.text = "run: curl gacha.sh | sh";
SetAccent(EmptyAccent);
}
private void ShowUnit(DistroDefinition unit)
{
populatedState.RemoveFromClassList("hidden");
emptyState.AddToClassList("hidden");
string displayName = string.IsNullOrWhiteSpace(unit.DisplayName) ? unit.name : unit.DisplayName;
titleLabel.text = displayName;
asciiLabel.text = EmptyAscii; // TODO: Replace with unit-specific ASCII copy when unit presentation data exists.
unitNameLabel.text = displayName;
languagesLabel.text = $"{unit.PrimaryLanguage} / {unit.SecondaryLanguage}";
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));
}
private void SetAccent(string htmlColor)
{
string color = $"#{htmlColor}";
Color parsedColor = ColorUtility.TryParseHtmlString(color, out Color parsed) ? parsed : Color.green;
panel.style.borderTopColor = new StyleColor(parsedColor);
panel.style.borderRightColor = new StyleColor(parsedColor);
panel.style.borderBottomColor = new StyleColor(parsedColor);
panel.style.borderLeftColor = new StyleColor(parsedColor);
titleLabel.style.color = new StyleColor(parsedColor);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c7f7ef4e16694ed9bd746278b0ddc4d8
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+454
View File
@@ -0,0 +1,454 @@
using System;
using System.Collections.Generic;
using KernelPanic.Core;
using KernelPanic.Meta;
using UnityEngine;
using UnityEngine.TextCore.Text;
using UnityEngine.UIElements;
#if UNITY_EDITOR
using UnityEditor;
#endif
namespace KernelPanic.UI
{
/// <summary>
/// Binds the main menu terminal UI document and routes command activation between panels.
/// </summary>
[RequireComponent(typeof(UIDocument))]
public sealed class MainMenuController : MonoBehaviour
{
private const string HiddenClassName = "hidden";
private const string SelectedClassName = "selected";
private const string CursorOnClassName = "cursor-on";
private const float BootIntroSeconds = 1.5f;
private static bool bootIntroPlayed;
[SerializeField] private FontAsset monospaceFont; // TODO: Assign a real monospace FontAsset when typography assets exist.
[SerializeField] private FeaturedUnitPanel featuredUnitPanel = new();
private readonly List<CommandMenuEntry> commandEntries = new();
private UIDocument document;
private VisualElement root;
private VisualElement shellRoot;
private VisualElement bootIntroPanel;
private VisualElement mainMenuPanel;
private VisualElement collectionPanel;
private VisualElement gachaPanel;
private VisualElement settingsPanel;
private VisualElement eventBanner;
private VisualElement backgroundLogLayer;
private Label appIdLabel;
private Label entropyLabel;
private Label pullTokensLabel;
private Label titleCursorLabel;
private Label promptCursorLabel;
private Label bootIntroLogLabel;
private Label backgroundLogLabel;
private Button collectionBackButton;
private Button gachaBackButton;
private Button settingsBackButton;
private EntropyWallet wallet;
private GachaService gachaService;
private IEventBannerSource eventBannerSource;
private int selectedCommandIndex;
private float backgroundScrollOffset;
private float bootIntroElapsed;
private int bootIntroCharacterCount;
private bool suppressNextClick;
private string bootIntroCopy;
private IVisualElementScheduledItem blinkSchedule;
private IVisualElementScheduledItem backgroundSchedule;
private IVisualElementScheduledItem bootIntroSchedule;
public void Initialize(EntropyWallet initializedWallet)
{
wallet = initializedWallet ?? new EntropyWallet();
RefreshCurrencyReadouts();
}
private void Awake()
{
document = GetComponent<UIDocument>();
gachaService = new GachaService();
Initialize(new EntropyWallet()); // TODO: Replace with persistent wallet service composition.
BindElements();
BindCommandEntries();
RegisterCommandEntryCallbacks();
featuredUnitPanel.Bind(root);
ApplyOptionalFont();
}
private void OnEnable()
{
RegisterCallbacks();
root.Focus();
shellRoot.EnableInClassList("reduced-motion", UIPreferences.ReducedMotion);
RefreshStaticText();
RefreshCurrencyReadouts();
RefreshEventBanner();
featuredUnitPanel.Refresh();
SelectCommand(0);
ShowMainMenu();
StartAmbientSchedules();
PlayBootIntroIfNeeded();
}
private void OnDisable()
{
UnregisterCallbacks();
blinkSchedule?.Pause();
backgroundSchedule?.Pause();
bootIntroSchedule?.Pause();
}
private void BindElements()
{
root = document.rootVisualElement;
shellRoot = root.Q<VisualElement>("ShellRoot");
bootIntroPanel = root.Q<VisualElement>("BootIntroPanel");
mainMenuPanel = root.Q<VisualElement>("MainMenuPanel");
collectionPanel = root.Q<VisualElement>("CollectionPanel");
gachaPanel = root.Q<VisualElement>("GachaPanel");
settingsPanel = root.Q<VisualElement>("SettingsPanel");
eventBanner = root.Q<VisualElement>("EventBanner");
backgroundLogLayer = root.Q<VisualElement>("BackgroundLogLayer");
appIdLabel = root.Q<Label>("AppIdLabel");
entropyLabel = root.Q<Label>("EntropyLabel");
pullTokensLabel = root.Q<Label>("PullTokensLabel");
titleCursorLabel = root.Q<Label>("TitleCursorLabel");
promptCursorLabel = root.Q<Label>("PromptCursorLabel");
bootIntroLogLabel = root.Q<Label>("BootIntroLogLabel");
backgroundLogLabel = root.Q<Label>("BackgroundLogLabel");
collectionBackButton = root.Q<Button>("CollectionBackButton");
gachaBackButton = root.Q<Button>("GachaBackButton");
settingsBackButton = root.Q<Button>("SettingsBackButton");
}
private void BindCommandEntries()
{
commandEntries.Clear();
commandEntries.Add(new CommandMenuEntry(root.Q<VisualElement>("CommandStartRun"), HandleStartRunClicked));
commandEntries.Add(new CommandMenuEntry(root.Q<VisualElement>("CommandCollection"), ShowCollection));
commandEntries.Add(new CommandMenuEntry(root.Q<VisualElement>("CommandGacha"), ShowGacha));
commandEntries.Add(new CommandMenuEntry(root.Q<VisualElement>("CommandSettings"), ShowSettings));
commandEntries.Add(new CommandMenuEntry(root.Q<VisualElement>("CommandQuit"), HandleQuitClicked));
}
private void ApplyOptionalFont()
{
if (monospaceFont == null)
{
return;
}
root.style.unityFontDefinition = new StyleFontDefinition(monospaceFont);
}
private void RegisterCallbacks()
{
root.RegisterCallback<KeyDownEvent>(HandleKeyDown);
root.RegisterCallback<PointerDownEvent>(HandlePointerDown);
collectionBackButton.clicked += ShowMainMenu;
gachaBackButton.clicked += ShowMainMenu;
settingsBackButton.clicked += ShowMainMenu;
}
private void UnregisterCallbacks()
{
root.UnregisterCallback<KeyDownEvent>(HandleKeyDown);
root.UnregisterCallback<PointerDownEvent>(HandlePointerDown);
collectionBackButton.clicked -= ShowMainMenu;
gachaBackButton.clicked -= ShowMainMenu;
settingsBackButton.clicked -= ShowMainMenu;
}
private void RefreshStaticText()
{
appIdLabel.text = $"kernel-panic v{Application.version} - tty1";
backgroundLogLabel.text = BuildBackgroundLog();
bootIntroCopy = string.Join("\n", BootLogCopy.Lines);
}
private void RegisterCommandEntryCallbacks()
{
for (int i = 0; i < commandEntries.Count; i++)
{
int index = i;
commandEntries[i].Row.RegisterCallback<PointerEnterEvent>(_ => SelectCommand(index));
commandEntries[i].Row.RegisterCallback<ClickEvent>(_ => HandleCommandClicked(index));
}
}
private void RefreshCurrencyReadouts()
{
if (entropyLabel == null || pullTokensLabel == null || gachaService == null || wallet == null)
{
return;
}
entropyLabel.text = $"entropy={wallet.Balance}";
pullTokensLabel.text = $"pulls={gachaService.PullTokens}";
}
private void RefreshEventBanner()
{
EventBannerContent banner = eventBannerSource?.GetBanner();
if (banner == null || string.IsNullOrWhiteSpace(banner.Text))
{
eventBanner.AddToClassList(HiddenClassName);
return;
}
eventBanner.RemoveFromClassList(HiddenClassName);
root.Q<Label>("EventBannerLabel").text = banner.RemainingTime.HasValue
? $"{banner.Text} {banner.RemainingTime.Value:g}"
: banner.Text;
}
private void StartAmbientSchedules()
{
if (UIPreferences.ReducedMotion)
{
titleCursorLabel.AddToClassList(CursorOnClassName);
promptCursorLabel.AddToClassList(CursorOnClassName);
return;
}
bool cursorVisible = true;
blinkSchedule = root.schedule.Execute(() =>
{
cursorVisible = !cursorVisible;
titleCursorLabel.EnableInClassList(CursorOnClassName, cursorVisible);
promptCursorLabel.EnableInClassList(CursorOnClassName, cursorVisible);
}).Every(480);
backgroundSchedule = root.schedule.Execute(() =>
{
backgroundScrollOffset = (backgroundScrollOffset + 0.35f) % 80f;
backgroundLogLayer.style.translate = new Translate(0, -backgroundScrollOffset, 0);
}).Every(33);
}
private void PlayBootIntroIfNeeded()
{
if (bootIntroPlayed || UIPreferences.ReducedMotion)
{
CompleteBootIntro();
return;
}
bootIntroPlayed = true;
bootIntroElapsed = 0f;
bootIntroCharacterCount = 0;
bootIntroLogLabel.text = string.Empty;
bootIntroPanel.RemoveFromClassList(HiddenClassName);
shellRoot.AddToClassList("boot-hidden");
bootIntroSchedule = root.schedule.Execute(UpdateBootIntro).Every(16);
}
private void UpdateBootIntro()
{
bootIntroElapsed += 0.016f;
int targetCount = Mathf.Clamp(Mathf.CeilToInt(bootIntroCopy.Length * (bootIntroElapsed / BootIntroSeconds)), 0, bootIntroCopy.Length);
if (targetCount != bootIntroCharacterCount)
{
bootIntroCharacterCount = targetCount;
bootIntroLogLabel.text = bootIntroCopy.Substring(0, bootIntroCharacterCount);
}
if (bootIntroElapsed >= BootIntroSeconds)
{
CompleteBootIntro();
}
}
private void CompleteBootIntro()
{
bootIntroSchedule?.Pause();
bootIntroPanel.AddToClassList(HiddenClassName);
shellRoot.RemoveFromClassList("boot-hidden");
shellRoot.AddToClassList("boot-visible");
root.Focus();
}
private bool SkipBootIntro()
{
if (bootIntroPanel.ClassListContains(HiddenClassName))
{
return false;
}
CompleteBootIntro();
return true;
}
private void HandleKeyDown(KeyDownEvent evt)
{
if (SkipBootIntro())
{
evt.StopPropagation();
return;
}
if (evt.keyCode == KeyCode.UpArrow)
{
SelectCommand(selectedCommandIndex - 1);
evt.StopPropagation();
return;
}
if (evt.keyCode == KeyCode.DownArrow)
{
SelectCommand(selectedCommandIndex + 1);
evt.StopPropagation();
return;
}
if (evt.keyCode == KeyCode.Return || evt.keyCode == KeyCode.KeypadEnter || evt.keyCode == KeyCode.Space)
{
ActivateCommand(selectedCommandIndex);
evt.StopPropagation();
return;
}
if (evt.keyCode == KeyCode.Tab)
{
featuredUnitPanel.SelectNext();
evt.StopPropagation();
return;
}
int digitIndex = GetDigitCommandIndex(evt.keyCode);
if (digitIndex >= 0)
{
SelectCommand(digitIndex);
ActivateCommand(digitIndex);
evt.StopPropagation();
}
}
private void HandlePointerDown(PointerDownEvent evt)
{
root.Focus();
suppressNextClick = SkipBootIntro();
}
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;
for (int i = 0; i < commandEntries.Count; i++)
{
commandEntries[i].SetSelected(i == selectedCommandIndex);
}
}
private void ActivateCommand(int index)
{
commandEntries[index].Activate();
}
private void HandleCommandClicked(int index)
{
if (suppressNextClick)
{
suppressNextClick = false;
return;
}
ActivateCommand(index);
}
private void HandleStartRunClicked()
{
SceneLoader.LoadGame();
}
private void HandleQuitClicked()
{
#if UNITY_EDITOR
EditorApplication.isPlaying = false;
#else
Application.Quit();
#endif
}
private void ShowMainMenu()
{
ShowPanel(mainMenuPanel);
root.Focus();
}
private void ShowCollection()
{
ShowPanel(collectionPanel);
}
private void ShowGacha()
{
ShowPanel(gachaPanel);
}
private void ShowSettings()
{
ShowPanel(settingsPanel);
}
// TODO: Replace this direct toggle with a screen-stack/router when menu flows need history or transitions.
private void ShowPanel(VisualElement activePanel)
{
mainMenuPanel.EnableInClassList(HiddenClassName, mainMenuPanel != activePanel);
collectionPanel.EnableInClassList(HiddenClassName, collectionPanel != activePanel);
gachaPanel.EnableInClassList(HiddenClassName, gachaPanel != activePanel);
settingsPanel.EnableInClassList(HiddenClassName, settingsPanel != activePanel);
}
private static string BuildBackgroundLog()
{
string block = string.Join("\n", BootLogCopy.Lines);
return $"{block}\n{block}\n{block}\n{block}";
}
private sealed class CommandMenuEntry
{
private readonly Label cursor;
private readonly Action action;
public CommandMenuEntry(VisualElement row, Action action)
{
Row = row;
this.action = action;
cursor = row.Q<Label>(className: "command-cursor");
}
public VisualElement Row { get; }
public void SetSelected(bool selected)
{
Row.EnableInClassList(SelectedClassName, selected);
cursor.visible = selected;
}
public void Activate()
{
action.Invoke();
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 27a7f29e93cb4c55beac52f1e32a1bf5
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+10
View File
@@ -0,0 +1,10 @@
namespace KernelPanic.UI
{
/// <summary>
/// Stores UI-level accessibility preferences for runtime presentation.
/// </summary>
public static class UIPreferences
{
public static bool ReducedMotion { get; set; } // TODO: Persist with settings once settings storage exists.
}
}
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a1efef5d7a1b485493c2f30d64644898
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: