diff --git a/CLAUDE.md b/CLAUDE.md index 6fdf79b..5564a9f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,8 +26,8 @@ See [specifications.md](specifications.md) for detailed content organization. ## Build & Run ``` dotnet build -dotnet run --project src/OpenTheBox # Classic Spectre.Console mode -dotnet run --project src/OpenTheBox -- --tui # Terminal.Gui panel layout +dotnet run --project src/OpenTheBox # Default: Terminal.Gui panel layout +dotnet run --project src/OpenTheBox -- --classic # Classic Spectre.Console sequential mode dotnet run --project src/OpenTheBox -- --snapshot 5 # Load snapshot save #5 ``` @@ -121,10 +121,10 @@ To capture rendering for a new panel or UI element: 2. Use `--logger "console;verbosity=detailed"` to see `Console.WriteLine()` output in the test runner 3. Tests should `Assert.True(true)` or use lightweight assertions — the goal is visual inspection of output -## Terminal.Gui Mode -Run with `--tui` for a tmux-like panel layout using Terminal.Gui: +## Classic Mode +Terminal.Gui panel layout is the default. Use `--classic` for the old sequential Spectre.Console renderer: ``` -dotnet run --project src/OpenTheBox -- --tui +dotnet run --project src/OpenTheBox -- --classic ``` ## Conventions diff --git a/content/strings/en.json b/content/strings/en.json index 63ea644..60bc7cd 100644 --- a/content/strings/en.json +++ b/content/strings/en.json @@ -386,6 +386,9 @@ "misc.boxes_opened": "Total boxes opened: {0}", "misc.play_time": "Play time: {0}", "misc.welcome_back": "Welcome back, {0}! Your boxes missed you.", + "misc.welcome_back_50": "Welcome back, {0}! The boxes have been waiting for you.", + "misc.welcome_back_200": "Welcome back, {0}! Your reputation precedes you. {1} boxes opened!", + "misc.welcome_back_500": "Welcome back, {0}! Legend walks among us. {1} boxes and counting...", "recipe.refine_wood": "Refine Wood", "recipe.smelt_bronze_ingot": "Smelt Bronze Ingot", @@ -516,5 +519,14 @@ "material.form.sheet": "Sheet", "material.form.thread": "Thread", "material.form.dust": "Dust", - "material.form.gem": "Gem" + "material.form.gem": "Gem", + + "resource.empty_hint": "Resources will appear as you discover them...", + "inventory.box_teaser": "What mysteries await inside?", + "inventory.cookie_teaser": "Crack open for wisdom...", + "stats.items_discovered": "Items discovered", + "stats.play_time": "Play time", + "panel.locked.stats": "Open more boxes to unlock...", + "panel.locked.resources": "Discoveries await...", + "panel.locked.inventory": "Your collection grows..." } diff --git a/content/strings/fr.json b/content/strings/fr.json index ebb8d29..631e7f0 100644 --- a/content/strings/fr.json +++ b/content/strings/fr.json @@ -386,6 +386,9 @@ "misc.boxes_opened": "Total de boîtes ouvertes : {0}", "misc.play_time": "Temps de jeu : {0}", "misc.welcome_back": "Bon retour, {0} ! Tes boîtes se sont ennuyées.", + "misc.welcome_back_50": "Bon retour, {0} ! Les boîtes t'attendaient.", + "misc.welcome_back_200": "Bon retour, {0} ! Ta réputation te précède. {1} boîtes ouvertes !", + "misc.welcome_back_500": "Bon retour, {0} ! La légende marche parmi nous. {1} boîtes et ça continue...", "recipe.refine_wood": "Raffiner le bois", "recipe.smelt_bronze_ingot": "Fondre un lingot de bronze", @@ -516,5 +519,14 @@ "material.form.sheet": "Feuille", "material.form.thread": "Fil", "material.form.dust": "Poudre", - "material.form.gem": "Gemme" + "material.form.gem": "Gemme", + + "resource.empty_hint": "Les ressources apparaîtront au fil de tes découvertes...", + "inventory.box_teaser": "Quels mystères se cachent à l'intérieur ?", + "inventory.cookie_teaser": "Ouvre-le pour une sagesse...", + "stats.items_discovered": "Objets découverts", + "stats.play_time": "Temps de jeu", + "panel.locked.stats": "Ouvre plus de boîtes pour débloquer...", + "panel.locked.resources": "Des découvertes t'attendent...", + "panel.locked.inventory": "Ta collection grandit..." } diff --git a/proposals.md b/proposals.md index 16f061f..2b86450 100644 --- a/proposals.md +++ b/proposals.md @@ -14,6 +14,12 @@ **Solution** : Avant ce déblocage, l'inventaire devrait être imprimé brut dans la console `Item (x qtt)`. Il manque plein de fonctionnalité pour l'inventaire mais elles seront rendues disponibles lors du déblocage de la meta adéquat. +## 3. Terminal.Gui Mode should be default + +**Constat** : CLAUDE.md indique "Run with `--tui` for a tmux-like panel layout using Terminal.Gui:". + +**Solution** : Le layout panel est une fonctionnalité meta in-game qui se débloque. Il ne devrait pas y avoir besoin de paramètre supplémentaire pour que les Terminal.Gui fonctionnent. + diff --git a/proposals_2.md b/proposals_2.md new file mode 100644 index 0000000..8ebc963 --- /dev/null +++ b/proposals_2.md @@ -0,0 +1,63 @@ +# Propositions d'améliorations du rendu (Round 2) + +Basé sur l'analyse des captures PlaythroughCapture et InventoryRenderCapture. + +## 1. Compteur de boîtes ouvertes visible en early-game + +**Constat** : Avant le déblocage du StatsPanel (souvent box ~18-26), le joueur n'a aucune indication de sa progression. Les 10-20 premières boîtes se jouent sans aucun feedback visuel du nombre de boîtes ouvertes. + +**Solution** : Afficher un simple compteur `Boxes opened: N` dans le BasicRenderer et dans les premiers niveaux du SpectreRenderer (avant StatsPanel), directement dans `ShowGameState()`. + +## 2. Panneau de ressources vide au déblocage + +**Constat** : Quand le ResourcePanel se débloque, il affiche "No resources visible yet." car aucune ressource n'est encore visible. C'est décevant comme premier contact avec un nouveau panneau. + +**Solution** : À la place d'un message vide, afficher un message encourageant comme "Resources will appear as you discover them..." et éventuellement ajouter une première ressource visible automatiquement au déblocage du panneau. + +## 3. Troncature des noms d'objets dans l'inventaire + +**Constat** : Les noms longs comme "Potion de Santé Moyenne" sont tronqués en "Potion de Santé Moyen." dans le tableau de l'inventaire. Les colonnes sont trop étroites pour certains noms localisés en français. + +**Solution** : Élargir la colonne `Nom` dans l'InventoryPanel ou implémenter un algorithme de troncature plus intelligent qui préserve les mots significatifs. + +## 4. Détail des boîtes dans le panneau de détail + +**Constat** : Quand un objet de type "Box" est sélectionné dans l'inventaire, le panneau de détail affiche la description de la boîte mais pas sa rareté colorée ni un indice sur son contenu potentiel. + +**Solution** : Ajouter dans le panneau de détail des boîtes un indicateur de rareté et un texte teaser sur le type de loot possible (ex: "May contain meta items", "Fashion and style await"). + +## 5. Fortune Cookie sans panneau de détail dédié + +**Constat** : Les Fortune Cookie dans l'inventaire n'ont pas de détail interactif. Leur catégorie est `Cookie` mais le panneau de détail est générique. + +**Solution** : Ajouter un détail spécifique pour les cookies montrant un aperçu du type "Crack open for wisdom..." et permettre de les consommer via Enter pour révéler un message de fortune. + +## 6. Panneau de stats trop spartiate en early-game + +**Constat** : Quand le StatsPanel se débloque, il n'affiche que "Boxes opened: N" sans aucune autre stat visible. C'est fonctionnel mais peu engageant. + +**Solution** : Ajouter des informations supplémentaires dans le StatsPanel : nombre total d'objets dans l'inventaire, nombre de types d'objets uniques découverts, et le temps de jeu. + +## 7. Texte encourageant dans les panneaux verrouillés + +**Constat** : Dans le mode séquentiel (avant FullLayout), les panneaux non encore débloqués ne sont pas visibles. Dans le FullLayout, les panneaux verrouillés affichent "[dim]???[/]" comme contenu placeholder. + +**Solution** : Remplacer les placeholders "???" par des messages thématiques et encourageants (ex: "Keep opening boxes to unlock this panel...") qui changent selon le panneau. + +## 8. Catégorie d'objet affichée comme icône illisible + +**Constat** : La colonne catégorie dans l'inventaire affiche des icônes emoji (📦, 🧪, etc.) qui se rendent en "??" dans les captures plain text et peuvent être illisibles selon le terminal. + +**Solution** : Proposer un fallback ASCII pour les icônes de catégorie quand les couleurs étendues ne sont pas disponibles, et s'assurer que le mode compact utilise des abréviations textuelles (BOX, CSM, MAT, etc.). + +## 9. Détail matériau trop minimal + +**Constat** : Le panneau de détail des matériaux affiche uniquement "Bronze (Lingot)" sans contexte. Le joueur ne sait pas à quoi sert ce matériau. + +**Solution** : Ajouter dans le détail des matériaux la liste des recettes de crafting connues qui utilisent ce matériau, ou au minimum un texte indiquant "Used in crafting" si des workstations sont débloqués. + +## 10. Adventure token sans indication d'aventure + +**Constat** : Les tokens d'aventure (comme les badges) sont dans l'inventaire mais leur panneau de détail ne montre pas clairement quelle aventure ils débloquent ou permettent. + +**Solution** : Améliorer le détail des adventure tokens pour afficher le nom de l'aventure associée et son statut (locked/unlocked/completed). diff --git a/src/OpenTheBox/Program.cs b/src/OpenTheBox/Program.cs index db68488..aa3a363 100644 --- a/src/OpenTheBox/Program.cs +++ b/src/OpenTheBox/Program.cs @@ -34,7 +34,8 @@ public static class Program public static async Task Main(string[] args) { - _useTui = args.Contains("--tui"); + // Terminal.Gui is the default mode; use --classic for the old sequential renderer + _useTui = !args.Contains("--classic"); // --snapshot N: directly load snapshot_N save and start playing int snapshotSlot = 0; @@ -228,12 +229,26 @@ public static class Program _loc.Change(_state.CurrentLocale); InitializeGame(); - _renderer.ShowMessage(_loc.Get("misc.welcome_back", _state.PlayerName)); + ShowAdaptiveWelcome(); _renderer.WaitForKeyPress(_loc.Get("prompt.press_key")); await GameLoop(); } + private static void ShowAdaptiveWelcome() + { + int boxes = _state.TotalBoxesOpened; + string name = _state.PlayerName; + string message = boxes switch + { + >= 500 => _loc.Get("misc.welcome_back_500", name, boxes.ToString()), + >= 200 => _loc.Get("misc.welcome_back_200", name, boxes.ToString()), + >= 50 => _loc.Get("misc.welcome_back_50", name), + _ => _loc.Get("misc.welcome_back", name) + }; + _renderer.ShowMessage(message); + } + private static async Task NewGame() { string name = _renderer.ShowTextInput(_loc.Get("prompt.name")); @@ -281,7 +296,7 @@ public static class Program _loc.Change(_state.CurrentLocale); InitializeGame(); - _renderer.ShowMessage(_loc.Get("misc.welcome_back", _state.PlayerName)); + ShowAdaptiveWelcome(); _renderer.WaitForKeyPress(_loc.Get("prompt.press_key")); await GameLoop(); @@ -607,6 +622,13 @@ public static class Program return; } + // Before InventoryPanel is unlocked, show a raw text list + if (!_renderContext.HasInventoryPanel) + { + ShowRawInventory(); + return; + } + var grouped = InventoryPanel.GetGroupedItems(_state, _registry); int totalItems = grouped.Count; int maxVisible = InventoryPanel.MaxVisibleRows; @@ -677,6 +699,63 @@ public static class Program } } + /// + /// Shows a minimal raw-text inventory before the InventoryPanel feature is unlocked. + /// + private static void ShowRawInventory() + { + _renderer.ShowMessage($"--- {_loc.Get("ui.inventory")} ---"); + var groups = _state.Inventory + .GroupBy(i => i.DefinitionId) + .ToList(); + foreach (var g in groups) + { + string name = GetLocalizedName(g.Key); + int qty = g.Sum(i => i.Quantity); + _renderer.ShowMessage(qty > 1 ? $" {name} (x{qty})" : $" {name}"); + } + _renderer.ShowMessage(""); + + // Allow using consumables even in raw mode + var consumables = groups + .Where(g => + { + var def = _registry.GetItem(g.Key); + return def?.Category == ItemCategory.Consumable && def.ResourceType.HasValue; + }) + .ToList(); + + if (consumables.Count > 0) + { + var useOptions = consumables.Select(g => + { + string name = GetLocalizedName(g.Key); + int qty = g.Sum(i => i.Quantity); + return qty > 1 ? $"{name} (x{qty})" : name; + }).ToList(); + useOptions.Add(_loc.Get("menu.back")); + + int choice = _renderer.ShowSelection(_loc.Get("inventory.controls_use"), useOptions); + if (choice < consumables.Count) + { + var instance = consumables[choice].First(); + var events = _simulation.ProcessAction(new UseItemAction(instance.Id), _state); + foreach (var evt in events) + { + if (evt is ResourceChangedEvent resEvt) + { + var resName = _loc.Get($"resource.{resEvt.Type.ToString().ToLower()}"); + _renderer.ShowMessage($"{resName}: {resEvt.OldValue} -> {resEvt.NewValue}"); + } + else if (evt is MessageEvent msgEvt) + _renderer.ShowMessage(_loc.Get(msgEvt.MessageKey, msgEvt.Args ?? [])); + } + } + } + + _renderer.WaitForKeyPress(_loc.Get("prompt.press_key")); + } + private static void HandleInventoryAction(InventoryGroup item) { if (item.Def is null) return; @@ -710,6 +789,30 @@ public static class Program // No WaitForKeyPress — return to inventory immediately for rapid consumption break; + case ItemCategory.Cookie: + // Use the cookie through the simulation (similar to Consumable) + var cookieEvents = _simulation.ProcessAction( + new UseItemAction(item.FirstInstance.Id), _state); + foreach (var evt in cookieEvents) + { + switch (evt) + { + case CookieFortuneEvent cookieEvt: + _renderer.ShowMessage("--- Fortune Cookie ---"); + _renderer.ShowMessage(_loc.Get(cookieEvt.MessageKey)); + _renderer.ShowMessage("----------------------"); + break; + case ResourceChangedEvent resEvt: + var cookieResName = _loc.Get($"resource.{resEvt.Type.ToString().ToLower()}"); + _renderer.ShowMessage($"{cookieResName}: {resEvt.OldValue} → {resEvt.NewValue}"); + break; + case MessageEvent msgEvt: + _renderer.ShowMessage(_loc.Get(msgEvt.MessageKey, msgEvt.Args ?? [])); + break; + } + } + break; + case ItemCategory.LoreFragment: // Display the full lore text in a dedicated panel ShowLoreFragment(item); diff --git a/src/OpenTheBox/Rendering/BasicRenderer.cs b/src/OpenTheBox/Rendering/BasicRenderer.cs index ba595b6..9e5bd30 100644 --- a/src/OpenTheBox/Rendering/BasicRenderer.cs +++ b/src/OpenTheBox/Rendering/BasicRenderer.cs @@ -66,6 +66,11 @@ public sealed class BasicRenderer(LocalizationManager loc) : IRenderer public void ShowGameState(GameState state, RenderContext context) { + if (!context.HasStatsPanel) + { + Console.WriteLine($"{loc.Get("stats.boxes_opened")}: {state.TotalBoxesOpened}"); + } + if (context.HasCompletionTracker) { Console.WriteLine(loc.Get("ui.completion", context.CompletionPercent)); diff --git a/src/OpenTheBox/Rendering/Panels/InventoryPanel.cs b/src/OpenTheBox/Rendering/Panels/InventoryPanel.cs index 18f3abe..9301fac 100644 --- a/src/OpenTheBox/Rendering/Panels/InventoryPanel.cs +++ b/src/OpenTheBox/Rendering/Panels/InventoryPanel.cs @@ -26,7 +26,7 @@ public sealed record InventoryGroup( /// public static class InventoryPanel { - private const int MaxNameWidth = 24; + private const int MaxNameWidth = 28; public const int MaxVisibleRows = 15; /// Compact row count for inline layout mode (fits in ~24-line terminals). public const int CompactVisibleRows = 6; @@ -276,16 +276,21 @@ public static class InventoryPanel switch (item.Category) { case ItemCategory.Box: - // Show box description if available + // Show box rarity, description, and teaser if (registry is not null) { var boxDef = registry.GetBox(item.DefId); if (boxDef is not null && loc is not null) { + string boxRarityLabel = loc.Get($"rarity.{boxDef.Rarity.ToString().ToLower()}"); + string boxRarityColor = RarityColor(boxDef.Rarity); + rows.Add(new Markup($"[{boxRarityColor}]{Markup.Escape(boxRarityLabel)}[/]")); string boxDesc = loc.Get(boxDef.DescriptionKey); rows.Add(new Markup($"[italic dim]{Markup.Escape(boxDesc)}[/]")); } } + string boxTeaser = loc?.Get("inventory.box_teaser") ?? "What mysteries await inside?"; + rows.Add(new Markup($"[dim yellow]{Markup.Escape(boxTeaser)}[/]")); break; case ItemCategory.Consumable when item.Def?.ResourceType is not null && item.Def.ResourceAmount is not null: @@ -328,6 +333,12 @@ public static class InventoryPanel rows.Add(new Markup($"[dim]{Markup.Escape(matType)} ({Markup.Escape(matForm)})[/]")); break; + case ItemCategory.Cookie: + string cookieTeaser = loc?.Get("inventory.cookie_teaser") ?? "Crack open for wisdom..."; + rows.Add(new Markup($"[dim yellow]{Markup.Escape(cookieTeaser)}[/]")); + rows.Add(new Markup($"[dim yellow]{Markup.Escape(loc?.Get("inventory.press_enter_use") ?? "Press Enter to use")}[/]")); + break; + case ItemCategory.AdventureToken when item.Def?.AdventureTheme is not null: string advName = loc?.Get($"adventure.name.{item.Def.AdventureTheme.Value}") ?? item.Def.AdventureTheme.Value.ToString(); string advLabel = loc?.Get("inventory.adventure") ?? "Adventure"; diff --git a/src/OpenTheBox/Rendering/Panels/ResourcePanel.cs b/src/OpenTheBox/Rendering/Panels/ResourcePanel.cs index fa4c608..ea57d15 100644 --- a/src/OpenTheBox/Rendering/Panels/ResourcePanel.cs +++ b/src/OpenTheBox/Rendering/Panels/ResourcePanel.cs @@ -1,5 +1,6 @@ using OpenTheBox.Core; using OpenTheBox.Core.Enums; +using OpenTheBox.Localization; using Spectre.Console; using Spectre.Console.Rendering; @@ -14,7 +15,7 @@ public static class ResourcePanel /// Builds a renderable resource display from the current game state. /// Only resources present in are shown. /// - public static IRenderable Render(GameState state) + public static IRenderable Render(GameState state, LocalizationManager? loc = null) { var rows = new List(); @@ -41,7 +42,8 @@ public static class ResourcePanel if (rows.Count == 0) { - rows.Add(new Markup("[dim]No resources visible yet.[/]")); + string emptyHint = loc?.Get("resource.empty_hint") ?? "Resources will appear as you discover them..."; + rows.Add(new Markup($"[dim italic]{Markup.Escape(emptyHint)}[/]")); } return new Panel(new Rows(rows)) diff --git a/src/OpenTheBox/Rendering/Panels/StatsPanel.cs b/src/OpenTheBox/Rendering/Panels/StatsPanel.cs index 4bd3c79..54635ad 100644 --- a/src/OpenTheBox/Rendering/Panels/StatsPanel.cs +++ b/src/OpenTheBox/Rendering/Panels/StatsPanel.cs @@ -34,6 +34,18 @@ public static class StatsPanel string boxesLabel = loc?.Get("stats.boxes_opened") ?? "Boxes Opened"; rows.Add(new Markup($" [silver]{Markup.Escape(boxesLabel)}:[/] [bold]{state.TotalBoxesOpened}[/]")); + // Total unique item types discovered + int uniqueItems = state.Inventory.Select(i => i.DefinitionId).Distinct().Count(); + string discoveredLabel = loc?.Get("stats.items_discovered") ?? "Items discovered"; + rows.Add(new Markup($" [silver]{Markup.Escape(discoveredLabel)}:[/] [bold]{uniqueItems}[/]")); + + // Play time + string playTimeLabel = loc?.Get("stats.play_time") ?? "Play time"; + string playTimeFormatted = state.TotalPlayTime.TotalHours >= 1 + ? $"{(int)state.TotalPlayTime.TotalHours}h {state.TotalPlayTime.Minutes:D2}m" + : $"{state.TotalPlayTime.Minutes}m {state.TotalPlayTime.Seconds:D2}s"; + rows.Add(new Markup($" [silver]{Markup.Escape(playTimeLabel)}:[/] [bold]{playTimeFormatted}[/]")); + string title = loc?.Get("stats.title") ?? "Stats"; return new Panel(new Rows(rows)) diff --git a/src/OpenTheBox/Rendering/SpectreRenderer.cs b/src/OpenTheBox/Rendering/SpectreRenderer.cs index 8cc1e4a..b477714 100644 --- a/src/OpenTheBox/Rendering/SpectreRenderer.cs +++ b/src/OpenTheBox/Rendering/SpectreRenderer.cs @@ -388,10 +388,10 @@ public sealed class SpectreRenderer : IRenderer PortraitPanel.Render(state.Appearance, context.HasPortraitPanel), context.HasStatsPanel ? StatsPanel.Render(state, _loc) - : new Panel("[dim]???[/]").Header("Stats").Expand(), + : new Panel($"[dim italic]{Markup.Escape(_loc.Get("panel.locked.stats"))}[/]").Header("Stats").Expand(), context.HasResourcePanel - ? ResourcePanel.Render(state) - : new Panel("[dim]???[/]").Header("Resources").Expand()); + ? ResourcePanel.Render(state, _loc) + : new Panel($"[dim italic]{Markup.Escape(_loc.Get("panel.locked.resources"))}[/]").Header("Resources").Expand()); AnsiConsole.Write(topRow); @@ -403,7 +403,7 @@ public sealed class SpectreRenderer : IRenderer // Left: Inventory IRenderable leftPanel = context.HasInventoryPanel ? InventoryPanel.Render(state, _registry, _loc, compact: true) - : new Panel("[dim]???[/]").Header("Inventory").Expand(); + : new Panel($"[dim italic]{Markup.Escape(_loc.Get("panel.locked.inventory"))}[/]").Header("Inventory").Expand(); // Right: stack Crafting + Chat + Completion var rightItems = new List(); @@ -435,7 +435,14 @@ public sealed class SpectreRenderer : IRenderer var topPanels = new List(); topPanels.Add(PortraitPanel.Render(state.Appearance, context.HasPortraitPanel)); if (context.HasStatsPanel) topPanels.Add(StatsPanel.Render(state, _loc)); - if (context.HasResourcePanel) topPanels.Add(ResourcePanel.Render(state)); + if (context.HasResourcePanel) topPanels.Add(ResourcePanel.Render(state, _loc)); + + // Show box count when StatsPanel is not yet unlocked + if (!context.HasStatsPanel) + { + string boxesLabel = _loc.Get("stats.boxes_opened"); + AnsiConsole.MarkupLine($"[dim]{Markup.Escape(boxesLabel)}: {state.TotalBoxesOpened}[/]"); + } if (topPanels.Count > 1) {