diff --git a/Assets/Data/Cards/CardDatabase.asset b/Assets/Data/Cards/CardDatabase.asset index 9973287..8eb4db5 100644 --- a/Assets/Data/Cards/CardDatabase.asset +++ b/Assets/Data/Cards/CardDatabase.asset @@ -29,3 +29,9 @@ MonoBehaviour: - {fileID: 11400000, guid: c1a50000000000000000000000000003, type: 2} - {fileID: 11400000, guid: c1a50000000000000000000000000004, type: 2} - {fileID: 11400000, guid: c1a50000000000000000000000000005, type: 2} + - {fileID: 11400000, guid: a7d40000000000000000000000000001, type: 2} + - {fileID: 11400000, guid: a7d40000000000000000000000000002, type: 2} + - {fileID: 11400000, guid: a7d40000000000000000000000000003, type: 2} + - {fileID: 11400000, guid: a7d40000000000000000000000000004, type: 2} + - {fileID: 11400000, guid: a7d40000000000000000000000000005, type: 2} + - {fileID: 11400000, guid: a7d40000000000000000000000000006, type: 2} diff --git a/Assets/Scenes/MainMenuScene.unity b/Assets/Scenes/MainMenuScene.unity index 71fcc0f..cb37e52 100644 --- a/Assets/Scenes/MainMenuScene.unity +++ b/Assets/Scenes/MainMenuScene.unity @@ -307,4 +307,5 @@ MonoBehaviour: motdBody: unstable userspace detected; keep a rollback shell open. distroDatabase: {fileID: 11400000, guid: 4d88ca5446fa4d80b2fbd4b12697cb7c, type: 2} cardDatabase: {fileID: 11400000, guid: 6a8a9f37c98a4a7a9e67ab15d2d22208, type: 2} + languageDeckDatabase: {fileID: 11400000, guid: a7d20000000000000000000000000001, type: 2} featuredUnitPanel: {} diff --git a/Assets/Scripts/Data/CardDefinition.cs b/Assets/Scripts/Data/CardDefinition.cs index 199fed4..20693cc 100644 --- a/Assets/Scripts/Data/CardDefinition.cs +++ b/Assets/Scripts/Data/CardDefinition.cs @@ -20,6 +20,7 @@ namespace KernelPanic.Data [SerializeField] private bool distroExclusive; [SerializeField] private bool isToken; [SerializeField, TextArea] private string designNotes; + [SerializeField] private List requiresSystem = new(); [SerializeField] private CardDefinition upgradeTarget; [SerializeField] private List effects = new(); @@ -34,6 +35,7 @@ namespace KernelPanic.Data public bool DistroExclusive => distroExclusive; public bool IsToken => isToken; public string DesignNotes => designNotes; + public IReadOnlyList RequiresSystem => requiresSystem; public CardDefinition UpgradeTarget => upgradeTarget; public IReadOnlyList Effects => effects; } diff --git a/Assets/Scripts/UI/CollectionScreenController.cs b/Assets/Scripts/UI/CollectionScreenController.cs index 6dadf76..e9c4d99 100644 --- a/Assets/Scripts/UI/CollectionScreenController.cs +++ b/Assets/Scripts/UI/CollectionScreenController.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; +using KernelPanic.Core; using KernelPanic.Data; +using KernelPanic.Meta; using UnityEngine; using UnityEngine.TextCore.Text; using UnityEngine.UIElements; @@ -8,7 +10,7 @@ using UnityEngine.UIElements; namespace KernelPanic.UI { /// - /// Renders the owned-units list and detail readout on the collection screen. + /// Renders Collection tabs and the pushed read-only card subview. /// [Serializable] public sealed class CollectionScreenController @@ -18,20 +20,116 @@ namespace KernelPanic.UI private VisualElement list; private VisualElement detail; private FontAsset monospaceFont; + private LanguageDeckDatabase languageDeckDatabase; + private CardDatabase cardDatabase; + private PlayerCollection playerCollection; private IReadOnlyList units = Array.Empty(); + private IReadOnlyList languages = LanguageCatalog.All; private IReadOnlyList cards = Array.Empty(); - private int selectedIndex; + private CollectionMode mode; + private CollectionMode returnMode; + private int selectedUnitIndex; + private int selectedLanguageIndex; + private int selectedCardIndex; + private string emptyCardMessage = "no cards installed"; - public void Bind(VisualElement root, FontAsset artFont) + public event Action ViewChanged; + + public void Bind(VisualElement root, FontAsset artFont, LanguageDeckDatabase deckDatabase, CardDatabase cardsDatabase, PlayerCollection collection) { monospaceFont = artFont; + languageDeckDatabase = deckDatabase; + cardDatabase = cardsDatabase; + playerCollection = collection; list = root.Q("CollectionList"); detail = root.Q("CollectionDetail"); } + public bool IsCardSubview => mode == CollectionMode.CardSubview; + + public string CurrentTitle { get; private set; } = "$ ls ~/collection"; + public string CurrentHint { get; private set; } = "[esc] back [left/right] tabs [tab] tabs [arrows] navigate [enter] select"; + public void RefreshUnits(IReadOnlyList ownedUnits) { units = ownedUnits ?? Array.Empty(); + ShowUnits(); + } + + public void ShowUnits() + { + mode = CollectionMode.Units; + CurrentTitle = "$ ls ~/collection"; + CurrentHint = "[esc] back [left/right] tabs [tab] tabs [arrows] navigate [enter] select"; + RenderUnits(); + ViewChanged?.Invoke(); + } + + public void ShowLanguages() + { + mode = CollectionMode.Languages; + CurrentTitle = "$ ls ~/collection/languages"; + CurrentHint = "[esc] back [left/right] tabs [tab] tabs [arrows] navigate [enter] select"; + RenderLanguages(); + ViewChanged?.Invoke(); + } + + public bool BackFromSubview() + { + if (!IsCardSubview) + { + return false; + } + + if (returnMode == CollectionMode.Languages) + { + ShowLanguages(); + } + else + { + ShowUnits(); + } + + return true; + } + + public void SelectRelative(int delta) + { + if (mode == CollectionMode.Units) + { + SelectUnit(selectedUnitIndex + delta); + return; + } + + if (mode == CollectionMode.Languages) + { + SelectLanguage(selectedLanguageIndex + delta); + return; + } + + SelectCard(selectedCardIndex + delta); + } + + public void ActivateSelected() + { + if (mode == CollectionMode.Units && units.Count > 0) + { + OpenUnitCards(units[selectedUnitIndex]); + return; + } + + if (mode == CollectionMode.Languages && languages.Count > 0) + { + LanguageCatalogEntry language = languages[selectedLanguageIndex]; + if (LanguageUnlock.IsUnlocked(language.Language, playerCollection)) + { + OpenLanguageDeck(language); + } + } + } + + private void RenderUnits() + { if (list == null || detail == null) { return; @@ -48,7 +146,7 @@ namespace KernelPanic.UI return; } - selectedIndex = Mathf.Clamp(selectedIndex, 0, units.Count - 1); + selectedUnitIndex = Mathf.Clamp(selectedUnitIndex, 0, units.Count - 1); for (int i = 0; i < units.Count; i++) { int index = i; @@ -62,21 +160,20 @@ namespace KernelPanic.UI name.AddToClassList("collection-row-name"); name.style.color = new StyleColor(unit.AccentColor); - Label languages = new(DistroPresentation.FormatLanguages(unit)); - languages.AddToClassList("collection-row-languages"); + Label unitLanguages = new(DistroPresentation.FormatLanguages(unit)); + unitLanguages.AddToClassList("collection-row-languages"); row.Add(name); - row.Add(languages); + row.Add(unitLanguages); list.Add(row); rows.Add(row); } - SelectUnit(selectedIndex); + SelectUnit(selectedUnitIndex); } - public void RefreshCards(DistroDefinition featuredUnit) + private void RenderLanguages() { - cards = BuildCardEntries(featuredUnit); if (list == null || detail == null) { return; @@ -84,43 +181,32 @@ namespace KernelPanic.UI rows.Clear(); list.Clear(); + selectedLanguageIndex = Mathf.Clamp(selectedLanguageIndex, 0, languages.Count - 1); - if (cards.Count == 0) - { - detail.Clear(); - detail.Add(new Label("no cards installed") { name = "CollectionEmptyTitle" }); - detail.Add(new Label("featured distro cards appear here") { name = "CollectionEmptyHint" }); - return; - } - - selectedIndex = Mathf.Clamp(selectedIndex, 0, cards.Count - 1); - for (int i = 0; i < cards.Count; i++) + for (int i = 0; i < languages.Count; i++) { int index = i; - CardEntry entry = cards[i]; + LanguageCatalogEntry language = languages[i]; + bool unlocked = LanguageUnlock.IsUnlocked(language.Language, playerCollection); VisualElement row = new(); row.AddToClassList("collection-row"); - row.RegisterCallback(_ => SelectCard(index)); - row.RegisterCallback(_ => SelectCard(index)); + row.EnableInClassList("locked", !unlocked); + row.RegisterCallback(_ => SelectLanguage(index)); + row.RegisterCallback(_ => SelectLanguage(index)); - Label name = new(GetCardDisplayName(entry.Card)); + Label name = new(unlocked ? language.DisplayName : $"{language.DisplayName} [locked]"); name.AddToClassList("collection-row-name"); - Label meta = new($"{DistroPresentation.DisplayName(entry.Owner)} / {FormatCardMeta(entry.Card)}"); - meta.AddToClassList("collection-row-languages"); + Label tag = new(language.IdentityTag); + tag.AddToClassList("collection-row-languages"); row.Add(name); - row.Add(meta); + row.Add(tag); list.Add(row); rows.Add(row); } - SelectCard(selectedIndex); - } - - public void Refresh(IReadOnlyList ownedUnits) - { - RefreshUnits(ownedUnits); + SelectLanguage(selectedLanguageIndex); } private void SelectUnit(int index) @@ -130,13 +216,16 @@ namespace KernelPanic.UI return; } - selectedIndex = Mathf.Clamp(index, 0, units.Count - 1); - for (int i = 0; i < rows.Count; i++) - { - rows[i].EnableInClassList("selected", i == selectedIndex); - } + selectedUnitIndex = Mathf.Clamp(index, 0, units.Count - 1); + ApplySelection(selectedUnitIndex); + RenderUnitDetail(units[selectedUnitIndex]); + } - RenderDetail(units[selectedIndex]); + private void SelectLanguage(int index) + { + selectedLanguageIndex = Mathf.Clamp(index, 0, languages.Count - 1); + ApplySelection(selectedLanguageIndex); + RenderLanguageDetail(languages[selectedLanguageIndex]); } private void SelectCard(int index) @@ -146,16 +235,20 @@ namespace KernelPanic.UI return; } - selectedIndex = Mathf.Clamp(index, 0, cards.Count - 1); + selectedCardIndex = Mathf.Clamp(index, 0, cards.Count - 1); + ApplySelection(selectedCardIndex); + RenderCardDetail(cards[selectedCardIndex]); + } + + private void ApplySelection(int selectedIndex) + { for (int i = 0; i < rows.Count; i++) { rows[i].EnableInClassList("selected", i == selectedIndex); } - - RenderCardDetail(cards[selectedIndex]); } - private void RenderDetail(DistroDefinition unit) + private void RenderUnitDetail(DistroDefinition unit) { detail.Clear(); @@ -182,7 +275,6 @@ namespace KernelPanic.UI VisualElement details = new(); details.AddToClassList("collection-detail-values"); - details.Add(BuildDetailLine("lang", DistroPresentation.FormatLanguages(unit))); details.Add(BuildDetailLine("uptime", unit.BaseUptime.ToString())); details.Add(BuildDetailLine("ram", unit.BaseRam.ToString())); @@ -190,6 +282,118 @@ namespace KernelPanic.UI readout.Add(details); detail.Add(readout); + detail.Add(BuildSubviewCommand($"> cat ~/units/{unit.Id}/cards", "exclusive packages", HasListableCards(unit), () => OpenUnitCards(unit), "no packages installed")); + } + + private void RenderLanguageDetail(LanguageCatalogEntry language) + { + detail.Clear(); + bool unlocked = LanguageUnlock.IsUnlocked(language.Language, playerCollection); + + Label name = new(language.DisplayName); + name.AddToClassList("collection-detail-name"); + detail.Add(name); + detail.Add(BuildDetailLine("track", language.ResolutionTrack.ToString())); + + Label how = new(language.HowItWorks); + how.AddToClassList("collection-detail-description"); + detail.Add(how); + + if (!unlocked) + { + string hintText = string.IsNullOrWhiteSpace(language.UnlockHint) + ? $"unlock: own a distro that speaks {language.DisplayName} ({FormatSupportingDistros(language)})" + : language.UnlockHint; + Label hint = new(hintText); + hint.AddToClassList("package-notice"); + detail.Add(hint); + return; + } + + detail.Add(BuildSubviewCommand($"> cat ~/lang/{GetLanguageId(language.Language)}/deck", "starter deck", true, () => OpenLanguageDeck(language), null)); + } + + private void OpenUnitCards(DistroDefinition unit) + { + if (!HasListableCards(unit)) + { + return; + } + + returnMode = CollectionMode.Units; + cards = BuildUnitCardEntries(unit); + emptyCardMessage = "no packages installed"; + CurrentTitle = $"$ cat ~/units/{unit.Id}/cards"; + CurrentHint = "[esc] back [arrows] navigate"; + RenderCardSubview(); + ViewChanged?.Invoke(); + } + + private void OpenLanguageDeck(LanguageCatalogEntry language) + { + returnMode = CollectionMode.Languages; + cards = BuildStarterDeckEntries(language); + emptyCardMessage = "starter deck not yet available"; + CurrentTitle = $"$ cat ~/lang/{GetLanguageId(language.Language)}/deck"; + CurrentHint = "[esc] back [arrows] navigate"; + RenderCardSubview(); + ViewChanged?.Invoke(); + } + + private void RenderCardSubview() + { + mode = CollectionMode.CardSubview; + rows.Clear(); + list.Clear(); + detail.Clear(); + selectedCardIndex = Mathf.Clamp(selectedCardIndex, 0, Math.Max(0, cards.Count - 1)); + + if (cards.Count == 0) + { + detail.Add(new Label(emptyCardMessage) { name = "CollectionEmptyTitle" }); + detail.Add(new Label("check back after deck data is installed") { name = "CollectionEmptyHint" }); + return; + } + + for (int i = 0; i < cards.Count; i++) + { + int index = i; + CardEntry entry = cards[i]; + VisualElement row = BuildCardRow(entry); + row.RegisterCallback(_ => SelectCard(index)); + row.RegisterCallback(_ => SelectCard(index)); + list.Add(row); + rows.Add(row); + } + + SelectCard(selectedCardIndex); + } + + private VisualElement BuildCardRow(CardEntry entry) + { + VisualElement row = new(); + row.AddToClassList("package-row"); + row.AddToClassList("collection-card-row"); + + VisualElement summary = new(); + summary.AddToClassList("package-summary"); + + Label count = new(entry.Count > 1 ? $"x{entry.Count}" : string.Empty); + count.AddToClassList("package-marker"); + Label name = new(GetCardDisplayName(entry.Card)); + name.AddToClassList("package-name"); + Label meta = new(FormatCardMeta(entry.Card)); + meta.AddToClassList("package-meta"); + + summary.Add(count); + summary.Add(name); + summary.Add(meta); + row.Add(summary); + + Label description = new(entry.Card == null || string.IsNullOrWhiteSpace(entry.Card.Description) ? "--" : entry.Card.Description); + description.AddToClassList("package-description"); + row.Add(description); + return row; } private void RenderCardDetail(CardEntry entry) @@ -204,6 +408,7 @@ 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); @@ -211,11 +416,10 @@ namespace KernelPanic.UI detail.Add(flavor); } - detail.Add(BuildDetailLine("owner", DistroPresentation.DisplayName(entry.Owner))); detail.Add(BuildDetailLine("lang", card == null ? "--" : card.Language.ToString())); detail.Add(BuildDetailLine("rarity", card == null ? "--" : card.Rarity.ToString())); detail.Add(BuildDetailLine("cost", card == null ? "--" : card.CycleCost.ToString())); - detail.Add(BuildDetailLine("type", card != null && card.IsToken ? "token" : "loadout")); + detail.Add(BuildDetailLine("track", card == null ? "--" : card.ResolutionTrack.ToString())); } private static VisualElement BuildDetailLine(string key, string value) @@ -233,6 +437,32 @@ namespace KernelPanic.UI return row; } + private VisualElement BuildSubviewCommand(string command, string description, bool enabled, Action action, string disabledDescription) + { + VisualElement row = new(); + row.AddToClassList("command-row"); + row.AddToClassList("collection-command-row"); + row.EnableInClassList("disabled-row", !enabled); + + Label cursor = new(">"); + cursor.AddToClassList("command-cursor"); + Label commandLabel = new(command); + commandLabel.AddToClassList("command-text"); + Label descriptionLabel = new(enabled ? description : disabledDescription); + descriptionLabel.AddToClassList("command-description"); + + row.Add(cursor); + row.Add(commandLabel); + row.Add(descriptionLabel); + + if (enabled) + { + row.RegisterCallback(_ => action?.Invoke()); + } + + return row; + } + private static void AddPassiveDetails(VisualElement target, DistroDefinition unit) { if (unit.Passive == null) @@ -256,28 +486,105 @@ namespace KernelPanic.UI } } - private static IReadOnlyList BuildCardEntries(DistroDefinition featuredUnit) + private static IReadOnlyList BuildUnitCardEntries(DistroDefinition unit) { List entries = new(); - if (featuredUnit == null) + if (unit == null) { return entries; } - for (int cardIndex = 0; cardIndex < featuredUnit.ExclusiveCards.Count; cardIndex++) + for (int cardIndex = 0; cardIndex < unit.ExclusiveCards.Count; cardIndex++) { - CardDefinition card = featuredUnit.ExclusiveCards[cardIndex]; + CardDefinition card = unit.ExclusiveCards[cardIndex]; + if (card == null || card.IsToken) + { + continue; + } + + entries.Add(new CardEntry(unit, card, 1)); + } + + return entries; + } + + private IReadOnlyList BuildStarterDeckEntries(LanguageCatalogEntry language) + { + LanguageDeckDefinition deck = languageDeckDatabase == null ? null : languageDeckDatabase.FindByLanguage(language.Language); + if (deck == null) + { + return BuildRegisteredStarterDeckEntries(language.Language); + } + + List entries = new(); + for (int i = 0; i < deck.Entries.Count; i++) + { + LanguageDeckDefinition.LanguageDeckEntry deckEntry = deck.Entries[i]; + if (deckEntry.Card == null || deckEntry.Count <= 0) + { + continue; + } + + entries.Add(new CardEntry(null, deckEntry.Card, deckEntry.Count)); + } + + return entries; + } + + private IReadOnlyList BuildRegisteredStarterDeckEntries(Language language) + { + if (cardDatabase == null) + { + return Array.Empty(); + } + + return language switch + { + Language.Python => BuildRegisteredStarterDeckEntries( + ("lang_py_print", 2), + ("lang_py_import_antigravity", 1), + ("lang_py_for_loop", 1)), + Language.JavaScript => BuildRegisteredStarterDeckEntries( + ("lang_js_console_log", 2), + ("lang_js_fetch", 1), + ("lang_js_typeof", 1)), + _ => Array.Empty() + }; + } + + private IReadOnlyList BuildRegisteredStarterDeckEntries(params (string Id, int Count)[] cardRefs) + { + List entries = new(); + for (int i = 0; i < cardRefs.Length; i++) + { + CardDefinition card = cardDatabase.FindById(cardRefs[i].Id); if (card == null) { continue; } - entries.Add(new CardEntry(featuredUnit, card)); + entries.Add(new CardEntry(null, card, cardRefs[i].Count)); } return entries; } + private static bool HasListableCards(DistroDefinition unit) + { + return BuildUnitCardEntries(unit).Count > 0; + } + + private string FormatSupportingDistros(LanguageCatalogEntry language) + { + List names = new(); + for (int i = 0; i < language.SupportingDistros.Count; i++) + { + names.Add(language.SupportingDistros[i].DisplayName); + } + + return string.Join(", ", names); + } + private static string GetCardDisplayName(CardDefinition card) { if (card == null) @@ -293,16 +600,36 @@ namespace KernelPanic.UI return card == null ? "--" : $"{card.Language} / {card.CycleCost}c"; } + private static string GetLanguageId(Language language) + { + return language switch + { + Language.CPlusPlus => "cpp", + Language.JavaScript => "javascript", + Language.TypeScript => "typescript", + _ => language.ToString().ToLowerInvariant() + }; + } + + private enum CollectionMode + { + Units, + Languages, + CardSubview + } + private readonly struct CardEntry { - public CardEntry(DistroDefinition owner, CardDefinition card) + public CardEntry(DistroDefinition owner, CardDefinition card, int count) { Owner = owner; Card = card; + Count = count; } public DistroDefinition Owner { get; } public CardDefinition Card { get; } + public int Count { get; } } } } diff --git a/Assets/Scripts/UI/MainMenuController.cs b/Assets/Scripts/UI/MainMenuController.cs index 51692d1..4a0ffef 100644 --- a/Assets/Scripts/UI/MainMenuController.cs +++ b/Assets/Scripts/UI/MainMenuController.cs @@ -38,6 +38,7 @@ namespace KernelPanic.UI [SerializeField] private string motdBody = "unstable userspace detected; keep a rollback shell open."; [SerializeField] private DistroDatabase distroDatabase; [SerializeField] private CardDatabase cardDatabase; + [SerializeField] private LanguageDeckDatabase languageDeckDatabase; [SerializeField] private FeaturedUnitPanel featuredUnitPanel = new(); [SerializeField] private CollectionScreenController collectionScreen = new(); [SerializeField] private StarterSelectionController starterSelection = new(); @@ -45,6 +46,7 @@ namespace KernelPanic.UI private readonly List commandEntries = new(); private readonly List packageRows = new(); private readonly List loadoutRows = new(); + private readonly List selectedRunLanguages = new(); private UIDocument document; private VisualElement root; private VisualElement shellRoot; @@ -58,6 +60,7 @@ namespace KernelPanic.UI private VisualElement backgroundLogLayer; private ScrollView runSetupList; private VisualElement runSetupDetail; + private ScrollView runSetupPackageScroll; private Label appIdLabel; private Label entropyLabel; private Label pullTokensLabel; @@ -67,7 +70,7 @@ namespace KernelPanic.UI private Label motdBodyLabel; private VisualElement motdBlock; private Button collectionUnitsButton; - private Button collectionCardsButton; + private Button collectionLanguagesButton; private readonly ScreenFrameController runSetupFrame = new(); private readonly ScreenFrameController collectionFrame = new(); private readonly ScreenFrameController gachaFrame = new(); @@ -83,7 +86,7 @@ namespace KernelPanic.UI private int selectedCommandIndex; private int selectedPackageIndex; private string runSetupNotice; - private bool collectionShowingCards; + private bool collectionShowingLanguages; private float bootIntroElapsed; private int bootIntroCharacterCount; private bool cursorVisible; @@ -111,7 +114,7 @@ namespace KernelPanic.UI BindCommandEntries(); RegisterCommandEntryCallbacks(); featuredUnitPanel.Bind(root, monospaceFont); - collectionScreen.Bind(root, monospaceFont); + collectionScreen.Bind(root, monospaceFont, languageDeckDatabase, cardDatabase, playerCollection); BindScreenFrames(); starterSelection.Bind(root, distroDatabase, HandleStarterConfirmed); LoadMetaState(); @@ -171,13 +174,13 @@ namespace KernelPanic.UI motdBodyLabel = root.Q