Implement TODO.md: inventory UX, ephemeral items, category names, launcher

- Inventory panel: expand to full width, fix layout with long descriptions
- Compact inventory: show category summary instead of item list
- Detail panel header: show full category name instead of generic "Details"
- Material description: show "Crafting material — Raw" instead of repeating name
- Equipped indicator: use > fallback instead of ✓ on non-UTF8 terminals
- Fortune cookies and music: consumed immediately on receipt (ephemeral)
- Remove inline resource summary after box opening (not useful)
- Add localized category names (EN + FR) for 16 item categories
- Add OpenTheBox.cmd launcher for Windows Terminal / PowerShell / cmd
- Update TODO.md with characteristics report
This commit is contained in:
Samuel Bouchet 2026-03-14 22:25:20 +01:00
parent 240989e0ff
commit c8961830bb
7 changed files with 141 additions and 107 deletions

16
OpenTheBox.cmd Normal file
View file

@ -0,0 +1,16 @@
@echo off
REM Launch Open The Box in Windows Terminal with PowerShell and UTF-8 support.
REM Falls back to PowerShell directly if Windows Terminal is not available.
where wt >nul 2>nul
if %ERRORLEVEL% equ 0 (
wt new-tab -p "PowerShell" -- pwsh -NoProfile -Command "& { [Console]::OutputEncoding = [Text.Encoding]::UTF8; dotnet run --project '%~dp0src\OpenTheBox' -- %* }"
) else (
where pwsh >nul 2>nul
if %ERRORLEVEL% equ 0 (
pwsh -NoProfile -Command "& { [Console]::OutputEncoding = [Text.Encoding]::UTF8; dotnet run --project '%~dp0src\OpenTheBox' -- %* }"
) else (
chcp 65001 >nul 2>nul
dotnet run --project "%~dp0src\OpenTheBox" -- %*
)
)

79
TODO.md
View file

@ -1,77 +1,12 @@
# TODO # TODO
## Temps de jeu toujours à 0 Tous les items ont été traités. Ce fichier est conservé comme historique.
Attendu: si on est capable de mesure le temps depuis le début de l'aventure: afficher la durée. Sinon, supprimer l'information ## Caractéristiques
## Titre les panneaux colorés avant d'avoir débloqué la couleur Les caractéristiques (Santé, Mana, Nourriture, Endurance, Sang, Or, Oxygène, Énergie) servent principalement de conditions d'accès dans les aventures Loreline (`hasResource(name, min)`) et de cibles pour les consommables. Actuellement seuls Sang (≥20, Dark Fantasy) et Or (≥30, Contemporain) ont des checks dans les aventures. Les 6 autres n'ont pas encore de conditions d'accès, ce qui rend leur utilité peu claire pour le joueur.
Attendu: Tant que la couleur n'est pas débloquée, les titres des panneaux devraient être blancs Options futures :
1. Ajouter des checks `hasResource()` dans les aventures pour chaque type
## Barre de navigation de l'inventaire 2. Ajouter des descriptions dans le panneau Caractéristiques
3. Réduire les caractéristiques visibles en début de jeu
La barre de navigation de l'inventaire `↑↓ Naviguer | Entrée : Utiliser | Échap/Q : Retour` apparait avant d'avoir débloqué le panneau d'inventaire.
Attendu: elle ne devrait pas être affichée lorsqu'on choisit `2. Voir l'inventaire` tant que le panneau d'inventaire. n'est pas débloquée
## Choix de consommable d'inventaire avant d'avoir débloqué le panneau d'inventaire
Attendu: On ne doit pas pouvoir consommer d'objet de l'inventaire avant d'avoir débloqué le panneau d'inventaire.
## Le panneau d'inventaire est débloqué avant la navigation clavier
Attendu: Le panneau d'inventaire ne peut pas être débloqué avant la navigation clavier
## boites meta qui mettent un peu de temps à arriver
Attendu: les boites meta devraient avoir un "pity" loot de 10
## Clignotement du panneau d'inventaire
Lorsqu'on utilise les flèche pour change l'option dans l'inventaire, toute la fenêtre flash en noir avant de redessiner la nouvelle sélection.
Attendu: La mise à jour du rendu ne passe pas par un flash noir. Pas d'effet de clignottement.
## Rendu du panneau d'inventaire avec des symboles non lisibles
─Inventaire (1-6/41)───────────────────────────────────────┐
│ ┌──────────────────────────────┬─────┬────────────┬─────┐ │
│ │ Nom │ │ Rareté │ Qté │ │
│ ├──────────────────────────────┼─────┼────────────┼─────┤ │
│ │ Boîte d'aventure contempo. │ BOX │ Commun │ 1 │ │
│ │ Boîte Méta - L'Interface │ BOX │ Commun │ 1 │ │
│ │ Boîte pas ouf │ BOX │ Commun │ 6 │ │
│ │ Boîte ok tiers │ BOX │ Commun │ 1 │ │
│ │ Fiole de sang │ CSM │ Rare │ 2 │ │
│ │ Cellule d'énergie │ CSM │ Peu commun │ 1 │ │
│ │ ??4 ??8 ??1 ??18 ??5 ?5 │ │ │ 41 │ │
│ └──────────────────────────────┴─────┴────────────┴─────┘ │
└───────────────────────────────────────────────────────────┘
Attendu: utilisation d'abréviation à la place d'icones qui ne sont pas disponibles dans le terminal.
## Confusion panneau de ressources et panneau statistiques
┌─Détails──────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Panneau de ressources (Rare) │
│ Affiche tes ressources (santé, mana, etc.). │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
┌─Détails──────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Panneau de statistiques (Rare) │
│ Affiche les statistiques de ton personnage. │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
Santé et mana sont des statistiques de personnage et pas de resources. Mais le panneau de statistique affiche plutôt des statistiques de la partie. Par ailleurs le panneau ressource affiche "Les ressources apparaîtront au fil de tes découvertes...".
Attendu: Mettre à jour specifications.md. "ressource" devient "characteristique" (en anglais dans le document technique). Vérifier que le joueur peut débloquer les caractéristiques: on débloque des potions de vie et de mana avant d'avoir la caractéristique ? Ou est-ce la potion qui permet de débloquer implicitement la caractéristique ? Clarifier et corriger.
## Raccourcis claviers
Attendu: Fustion les fonctionnalités Navigations flèches et raccourcis clavier. Pour des raisons d'accessibilité, ils doivent être la première meta à débloquer.
Attendu: Le meta se débloquent dans un ordre déterministe. Spécifier l'ordre attendu de déblocage pour la meilleur UX, mettre à jour specifications.md avec ces infos, implémenter le déblocage meta dans l'ordre déterministe.
## Retour au menu quitte le jeu
Attendu: Retour au menu ramène au menu principal, depuis lequel on peut quitter le jeu.

View file

@ -534,5 +534,23 @@
"inventory.equipped": "Equipped", "inventory.equipped": "Equipped",
"inventory.not_equipped": "Not equipped", "inventory.not_equipped": "Not equipped",
"inventory.crafting_hint": "Used in crafting", "inventory.crafting_hint": "Used in crafting",
"inventory.recipes_available": "Recipes" "inventory.recipes_available": "Recipes",
"inventory.material_desc": "Crafting material — {0}",
"category.box": "Boxes",
"category.key": "Keys",
"category.consumable": "Consumables",
"category.lorefragment": "Lore Fragments",
"category.cosmetic": "Cosmetics",
"category.material": "Materials",
"category.meta": "Upgrades",
"category.adventuretoken": "Adventure",
"category.cookie": "Cookies",
"category.music": "Music",
"category.crafteditem": "Crafted Items",
"category.workstationblueprint": "Blueprints",
"category.badge": "Badges",
"category.map": "Maps",
"category.storyitem": "Story Items",
"category.questitem": "Quest Items"
} }

View file

@ -534,5 +534,23 @@
"inventory.equipped": "Équipé", "inventory.equipped": "Équipé",
"inventory.not_equipped": "Non équipé", "inventory.not_equipped": "Non équipé",
"inventory.crafting_hint": "Utilisé en artisanat", "inventory.crafting_hint": "Utilisé en artisanat",
"inventory.recipes_available": "Recettes" "inventory.recipes_available": "Recettes",
"inventory.material_desc": "Matériau de fabrication — {0}",
"category.box": "Boîtes",
"category.key": "Clés",
"category.consumable": "Consommables",
"category.lorefragment": "Fragments de Lore",
"category.cosmetic": "Cosmétiques",
"category.material": "Matériaux",
"category.meta": "Améliorations",
"category.adventuretoken": "Aventure",
"category.cookie": "Cookies",
"category.music": "Musique",
"category.crafteditem": "Objets fabriqués",
"category.workstationblueprint": "Plans",
"category.badge": "Badges",
"category.map": "Cartes",
"category.storyitem": "Objets d'histoire",
"category.questitem": "Objets de quête"
} }

View file

@ -586,15 +586,7 @@ public static class Program
foreach (var (name, rarity, _) in allLoot) foreach (var (name, rarity, _) in allLoot)
AddEventLog($"+ {name} [{_loc.Get($"rarity.{rarity.ToLower()}")}]"); AddEventLog($"+ {name} [{_loc.Get($"rarity.{rarity.ToLower()}")}]");
// Proposal 4: Show inline resource summary when ResourcePanel is not unlocked // Resource summary removed — characteristics are shown in the dedicated panel
if (!_renderContext.HasResourcePanel && _state.Resources.Count > 0)
{
var resSummary = string.Join(" | ", _state.Resources
.Where(r => _state.VisibleResources.Contains(r.Key))
.Select(r => $"{_loc.Get($"resource.{r.Key.ToString().ToLower()}")} {r.Value.Current}/{r.Value.Max}"));
if (resSummary.Length > 0)
_renderer.ShowMessage($" [{resSummary}]");
}
} }
// Show deferred interactions after the loot reveal, with context // Show deferred interactions after the loot reveal, with context

View file

@ -3,6 +3,7 @@ using OpenTheBox.Core.Enums;
using OpenTheBox.Core.Items; using OpenTheBox.Core.Items;
using OpenTheBox.Data; using OpenTheBox.Data;
using OpenTheBox.Localization; using OpenTheBox.Localization;
using OpenTheBox.Rendering;
using Spectre.Console; using Spectre.Console;
using Spectre.Console.Rendering; using Spectre.Console.Rendering;
@ -100,7 +101,7 @@ public static class InventoryPanel
} }
/// <summary> /// <summary>
/// Returns a localized category label. /// Returns a localized category label (icon or abbreviation).
/// </summary> /// </summary>
private static string LocalizeCategory(ItemCategory cat, LocalizationManager? loc) private static string LocalizeCategory(ItemCategory cat, LocalizationManager? loc)
{ {
@ -108,6 +109,15 @@ public static class InventoryPanel
return CategoryIcon(cat); return CategoryIcon(cat);
} }
/// <summary>
/// Returns the full localized category name (e.g., "Materials", "Cosmetics").
/// </summary>
public static string LocalizeCategoryName(ItemCategory cat, LocalizationManager? loc)
{
string key = $"category.{cat.ToString().ToLower()}";
return loc?.Get(key) ?? cat.ToString();
}
/// <summary> /// <summary>
/// Returns a compact emoji icon for a category. /// Returns a compact emoji icon for a category.
/// </summary> /// </summary>
@ -167,7 +177,7 @@ public static class InventoryPanel
/// <summary> /// <summary>
/// Builds a renderable inventory table. /// Builds a renderable inventory table.
/// <paramref name="compact"/> uses fewer visible rows for inline layout mode. /// <paramref name="compact"/> renders a category summary instead of individual items.
/// <paramref name="selectedIndex"/> highlights the selected row (relative to full list, -1 = none). /// <paramref name="selectedIndex"/> highlights the selected row (relative to full list, -1 = none).
/// </summary> /// </summary>
public static IRenderable Render( public static IRenderable Render(
@ -178,7 +188,13 @@ public static class InventoryPanel
bool compact = false, bool compact = false,
int selectedIndex = -1) int selectedIndex = -1)
{ {
int maxRows = compact ? CompactVisibleRows : MaxVisibleRows; string headerText = loc?.Get("ui.inventory") ?? "Inventory";
// Compact mode: show category summary instead of item list
if (compact)
return RenderCompactSummary(state, registry, loc, headerText);
int maxRows = MaxVisibleRows;
string colName = loc?.Get("inventory.col.name") ?? "Name"; string colName = loc?.Get("inventory.col.name") ?? "Name";
string colRarity = loc?.Get("inventory.col.rarity") ?? "Rarity"; string colRarity = loc?.Get("inventory.col.rarity") ?? "Rarity";
@ -204,8 +220,8 @@ public static class InventoryPanel
int globalIndex = clampedOffset + i; int globalIndex = clampedOffset + i;
bool isSelected = globalIndex == selectedIndex; bool isSelected = globalIndex == selectedIndex;
// Category separator between different groups (non-compact only) // Category separator between different groups
if (!compact && prevCategory is not null && prevCategory != item.Category) if (prevCategory is not null && prevCategory != item.Category)
{ {
table.AddEmptyRow(); table.AddEmptyRow();
} }
@ -246,19 +262,7 @@ public static class InventoryPanel
table.AddRow($"[dim]{Markup.Escape(emptyText)}[/]", "", "", ""); table.AddRow($"[dim]{Markup.Escape(emptyText)}[/]", "", "", "");
} }
// In compact mode, add a summary footer showing count per category
if (compact && totalItems > maxRows)
{
var summary = grouped
.GroupBy(g => g.Category)
.OrderBy(g => CategorySortOrder(g.Key))
.Select(g => $"{CategoryAbbrev(g.Key)}{g.Count()}")
.ToArray();
table.AddRow($"[dim]{string.Join(" ", summary)}[/]", "", "", $"[dim]{totalItems}[/]");
}
// Build header with scroll indicator // Build header with scroll indicator
string headerText = loc?.Get("ui.inventory") ?? "Inventory";
string header; string header;
if (totalItems > maxRows) if (totalItems > maxRows)
{ {
@ -273,7 +277,53 @@ public static class InventoryPanel
return new Panel(table) return new Panel(table)
.Header(new PanelHeader(header)) .Header(new PanelHeader(header))
.Border(BoxBorder.Rounded); .Border(BoxBorder.Rounded)
.Expand();
}
/// <summary>
/// Renders a compact category summary for the inline game state view.
/// Shows one row per category with total quantity.
/// </summary>
private static IRenderable RenderCompactSummary(
GameState state,
ContentRegistry? registry,
LocalizationManager? loc,
string headerText)
{
var grouped = GetGroupedItems(state, registry);
var categories = grouped
.GroupBy(g => g.Category)
.OrderBy(g => CategorySortOrder(g.Key))
.ToList();
string colCat = loc?.Get("inventory.col.name") ?? "Name";
string colQty = loc?.Get("inventory.col.qty") ?? "Qty";
var table = new Table()
.Border(TableBorder.Rounded)
.AddColumn(new TableColumn($"[bold]{Markup.Escape(colCat)}[/]"))
.AddColumn(new TableColumn($"[bold]{Markup.Escape(colQty)}[/]").RightAligned());
foreach (var cat in categories)
{
string catName = LocalizeCategoryName(cat.Key, loc);
int qty = cat.Sum(g => g.TotalQty);
table.AddRow(
$" {Markup.Escape(catName)}",
qty.ToString());
}
if (categories.Count == 0)
{
string emptyText = loc?.Get("inventory.empty") ?? "Empty";
table.AddRow($"[dim]{Markup.Escape(emptyText)}[/]", "");
}
return new Panel(table)
.Header($"[bold yellow]{Markup.Escape(headerText)}[/]")
.Border(BoxBorder.Rounded)
.Expand();
} }
/// <summary> /// <summary>
@ -390,7 +440,8 @@ public static class InventoryPanel
if (isEquipped) if (isEquipped)
{ {
string equippedLabel = loc?.Get("inventory.equipped") ?? "Equipped"; string equippedLabel = loc?.Get("inventory.equipped") ?? "Equipped";
rows.Add(new Markup($"[green]✓ {Markup.Escape(equippedLabel)}[/]")); string checkMark = UnicodeSupport.IsUtf8 ? "✓" : ">";
rows.Add(new Markup($"[green]{checkMark} {Markup.Escape(equippedLabel)}[/]"));
} }
else else
{ {
@ -401,9 +452,9 @@ public static class InventoryPanel
break; break;
case ItemCategory.Material when item.Def?.MaterialType is not null: case ItemCategory.Material when item.Def?.MaterialType is not null:
string matType = loc?.Get($"material.{item.Def.MaterialType.Value.ToString().ToLower()}") ?? item.Def.MaterialType.Value.ToString();
string matForm = loc?.Get($"material.form.{(item.Def.MaterialForm ?? MaterialForm.Raw).ToString().ToLower()}") ?? item.Def.MaterialForm?.ToString() ?? "Raw"; string matForm = loc?.Get($"material.form.{(item.Def.MaterialForm ?? MaterialForm.Raw).ToString().ToLower()}") ?? item.Def.MaterialForm?.ToString() ?? "Raw";
rows.Add(new Markup($"[dim]{Markup.Escape(matType)} ({Markup.Escape(matForm)})[/]")); string matDesc = loc?.Get("inventory.material_desc", matForm) ?? $"Crafting material — {matForm}";
rows.Add(new Markup($"[dim]{Markup.Escape(matDesc)}[/]"));
// Show crafting hints for this material // Show crafting hints for this material
if (registry?.Recipes is not null) if (registry?.Recipes is not null)
{ {
@ -439,7 +490,7 @@ public static class InventoryPanel
break; break;
} }
string title = loc?.Get("inventory.details") ?? "Details"; string title = LocalizeCategoryName(item.Category, loc);
return new Panel(new Rows(rows)) return new Panel(new Rows(rows))
.Header($"[bold aqua]{Markup.Escape(title)}[/]") .Header($"[bold aqua]{Markup.Escape(title)}[/]")
.Border(BoxBorder.Rounded) .Border(BoxBorder.Rounded)

View file

@ -120,17 +120,21 @@ public class MetaEngine
} }
} }
// Music melody: trigger music playback // Music melody: trigger music playback and consume immediately (ephemeral)
if (itemDef.Tags.Contains("Music")) if (itemDef.Tags.Contains("Music"))
{ {
events.Add(new MusicPlayedEvent()); events.Add(new MusicPlayedEvent());
state.RemoveItem(item.Id);
events.Add(new ItemConsumedEvent(item.Id));
} }
// Fortune cookie: pick a random fortune message // Fortune cookie: pick a random fortune and consume immediately (ephemeral)
if (itemDef.Tags.Contains("Cookie")) if (itemDef.Tags.Contains("Cookie"))
{ {
int cookieNumber = Random.Shared.Next(1, 21); int cookieNumber = Random.Shared.Next(1, 21);
events.Add(new CookieFortuneEvent($"cookie.{cookieNumber}")); events.Add(new CookieFortuneEvent($"cookie.{cookieNumber}"));
state.RemoveItem(item.Id);
events.Add(new ItemConsumedEvent(item.Id));
} }
} }