Implement TODO.md: deterministic meta order, pity system, UX fixes

- Play time tracking: accumulate TotalPlayTime each game loop iteration
- Return to menu: split _running into _appRunning/_gameRunning so quit
  returns to main menu instead of exiting the app
- Deterministic meta unlock order: ArrowKeySelection first (accessibility),
  then TextColors, AutoSave, InventoryPanel, etc. (13-step sequence)
- Merge KeyboardShortcuts into ArrowKeySelection
- Meta box pity system: guarantee a meta box every 10 openings
- Inventory flickering: buffer-based rendering with ANSI cursor repositioning
- Non-readable symbols: use ASCII abbreviations instead of emoji in footer
- Resource/stats confusion: rename Resource Panel to Characteristics Panel
  in all user-facing text (EN/FR), update specs and descriptions
- Block consumable use before inventory panel unlock
- Remove obsolete proposal/suggestion files, clean up Spectre-only renderer
This commit is contained in:
Samuel Bouchet 2026-03-14 20:21:18 +01:00
parent 92ce67684d
commit 83270ed4a0
23 changed files with 394 additions and 1252 deletions

View file

@ -26,8 +26,7 @@ See [specifications.md](specifications.md) for detailed content organization.
## Build & Run
```
dotnet build
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 # Spectre.Console sequential mode
dotnet run --project src/OpenTheBox -- --snapshot 5 # Load snapshot save #5
```
@ -121,12 +120,6 @@ 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
## Classic Mode
Terminal.Gui panel layout is the default. Use `--classic` for the old sequential Spectre.Console renderer:
```
dotnet run --project src/OpenTheBox -- --classic
```
## Conventions
- C# 12 with file-scoped namespaces, primary constructors where appropriate
- Immutable records for value types, sealed classes for services

View file

@ -117,6 +117,10 @@ openthebox/
| Serialization | System.Text.Json |
| Localization | JSON string tables + Loreline translation files |
## Terminal GUI
All content is expected to be renderer in a 120x30 characters frame.
## License
All rights reserved.

77
TODO.md Normal file
View file

@ -0,0 +1,77 @@
# TODO
## Temps de jeu toujours à 0
Attendu: si on est capable de mesure le temps depuis le début de l'aventure: afficher la durée. Sinon, supprimer l'information
## Titre les panneaux colorés avant d'avoir débloqué la couleur
Attendu: Tant que la couleur n'est pas débloquée, les titres des panneaux devraient être blancs
## Barre de navigation de l'inventaire
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

@ -211,6 +211,7 @@
"guaranteedRolls": ["box_of_boxes"],
"rollCount": 1,
"entries": [
{"itemDefinitionId": "meta_portrait", "weight": 7},
{"itemDefinitionId": "meta_colors", "weight": 6},
{"itemDefinitionId": "meta_stats", "weight": 4},
{"itemDefinitionId": "meta_inventory", "weight": 4},
@ -229,9 +230,7 @@
"guaranteedRolls": ["box_of_boxes"],
"rollCount": 1,
"entries": [
{"itemDefinitionId": "meta_portrait", "weight": 3},
{"itemDefinitionId": "meta_arrows", "weight": 3},
{"itemDefinitionId": "meta_shortcuts", "weight": 3},
{"itemDefinitionId": "meta_layout", "weight": 2},
{"itemDefinitionId": "box_meta_deep", "weight": 1}
]

View file

@ -112,7 +112,7 @@
"meta.extended_colors": "Extended Color Palette",
"meta.arrows": "Arrow Key Navigation",
"meta.inventory": "Inventory Panel",
"meta.resources": "Resource Panel",
"meta.resources": "Characteristics Panel",
"meta.stats": "Stats Panel",
"meta.portrait": "Portrait Panel",
"meta.chat": "Chat Panel",
@ -468,6 +468,7 @@
"ui.inventory": "Inventory",
"stats.boxes_opened": "Boxes Opened",
"stats.title": "Stats",
"resource.title": "Characteristics",
"misc.welcome": "Welcome, {0}!",
"destiny.epilogue": "The lid closes. The box remembers.",
"destiny.continue": "Keep opening boxes (free play)",
@ -500,8 +501,8 @@
"meta.extended_colors.desc": "More colors! More variety!",
"meta.arrows.desc": "Navigate menus with arrow keys instead of typing numbers.",
"meta.inventory.desc": "Shows a panel listing all your items.",
"meta.resources.desc": "Displays your resources (health, mana, etc.).",
"meta.stats.desc": "Shows your character statistics.",
"meta.resources.desc": "Displays your character's characteristics (health, mana, etc.).",
"meta.stats.desc": "Shows your progression stats and character attributes.",
"meta.portrait.desc": "Displays your character's visual appearance.",
"meta.chat.desc": "Shows an event log of recent actions.",
"meta.layout.desc": "Arranges all panels into a full dashboard layout.",
@ -521,7 +522,7 @@
"material.form.dust": "Dust",
"material.form.gem": "Gem",
"resource.empty_hint": "Resources will appear as you discover them...",
"resource.empty_hint": "Characteristics 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",

View file

@ -112,7 +112,7 @@
"meta.extended_colors": "Palette de couleurs étendue",
"meta.arrows": "Navigation avec les flèches",
"meta.inventory": "Panneau d'inventaire",
"meta.resources": "Panneau de ressources",
"meta.resources": "Panneau de caractéristiques",
"meta.stats": "Panneau de statistiques",
"meta.portrait": "Panneau portrait",
"meta.chat": "Panneau de discussion",
@ -468,6 +468,7 @@
"ui.inventory": "Inventaire",
"stats.boxes_opened": "Boîtes ouvertes",
"stats.title": "Stats",
"resource.title": "Caractéristiques",
"misc.welcome": "Bienvenue, {0} !",
"destiny.epilogue": "Le couvercle se referme. La boîte se souvient.",
"destiny.continue": "Continuer à ouvrir des boîtes (jeu libre)",
@ -500,8 +501,8 @@
"meta.extended_colors.desc": "Plus de couleurs ! Plus de variété !",
"meta.arrows.desc": "Navigue dans les menus avec les flèches au lieu de taper des chiffres.",
"meta.inventory.desc": "Affiche un panneau listant tous tes objets.",
"meta.resources.desc": "Affiche tes ressources (santé, mana, etc.).",
"meta.stats.desc": "Affiche les statistiques de ton personnage.",
"meta.resources.desc": "Affiche les caractéristiques de ton personnage (santé, mana, etc.).",
"meta.stats.desc": "Affiche ta progression et les attributs de ton personnage.",
"meta.portrait.desc": "Affiche l'apparence visuelle de ton personnage.",
"meta.chat.desc": "Affiche un journal des événements récents.",
"meta.layout.desc": "Organise tous les panneaux en tableau de bord complet.",
@ -521,7 +522,7 @@
"material.form.dust": "Poudre",
"material.form.gem": "Gemme",
"resource.empty_hint": "Les ressources apparaîtront au fil de tes découvertes...",
"resource.empty_hint": "Les caractéristiques 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",

View file

@ -1,27 +0,0 @@
# Propositions d'améliorations du rendu
## 1. Message de bienvenue adaptatif
**Constat** : Le message de bienvenue est identique que le joueur ait 0 ou 500 boîtes ouvertes. C'est une occasion manquée de donner du feedback.
**Solution** : Adapter le message d'accueil selon la progression.
## 2. Inventaire en mode panneau sans avoir débloqué les panneaux
**Constat** : L'Inventaire apparait en mode panneau sans avoir débloqué les layouts et panels ui.
**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.

View file

@ -1,63 +0,0 @@
# 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).

View file

@ -1,63 +0,0 @@
# Propositions d'améliorations du rendu (Round 3)
Basé sur l'analyse des captures PlaythroughCapture et InventoryRenderCapture après implémentation de proposals_2.md.
## 1. Rareté des boîtes mal présentée dans le détail
**Constat** : Le panneau de détail des boîtes affiche la rareté comme texte brut ("Légendaire", "Rare") sur une ligne isolée sans label. De plus, la rareté affichée provient de la définition de box (Rare, Legendary) tandis que la première ligne montre "(Commun)" — qui est la rareté item. C'est confus.
**Solution** : Afficher la rareté de la boîte avec un label clair, par exemple "Rarity: Legendary" avec la couleur appropriée, et supprimer la rareté item "(Commun)" de la première ligne pour les boîtes, ou la remplacer par la rareté box.
## 2. Catégorie emoji illisible en plain text ("??")
**Constat** : Les icônes emoji de catégorie (📦, 🧪, etc.) se rendent en "??" dans les captures plain text et dans certains terminaux non-Unicode. Cela rend la colonne catégorie inutile.
**Solution** : Utiliser des abréviations ASCII comme fallback quand le rendu est en mode NoColors/plain : BOX, CSM, LOR, ADV, KEY, COS, MAT, CRF, MET, COK, MUS. Garder les emojis pour le mode couleur.
## 3. Séparateur visuel entre catégories d'inventaire
**Constat** : L'inventaire affiche tous les objets dans une liste plate. Quand le joueur a 80+ types d'objets, il est difficile de distinguer visuellement les boîtes des consommables des matériaux.
**Solution** : Ajouter une ligne séparatrice légère ou un en-tête de groupe quand la catégorie change dans le tableau d'inventaire (ex: une ligne vide ou un séparateur "---" entre les groupes).
## 4. Détail cosmétique sans statut d'équipement
**Constat** : Le panneau de détail des cosmétiques affiche seulement "Emplacement: Yeux" sans indiquer si l'objet est actuellement équipé ou non.
**Solution** : Ajouter un indicateur "[Equipped]" / "[Équipé]" quand le cosmétique correspond à l'apparence actuelle du joueur. Clés : `"inventory.equipped": "Equipped"`, `"inventory.not_equipped": "Not equipped"`.
## 5. Détail matériau avec recettes de crafting
**Constat** : Le détail des matériaux affiche uniquement "Bronze (Lingot)" sans contexte. Le joueur ne sait pas si ce matériau sera utile.
**Solution** : Si des workstations sont débloquées, afficher les recettes connues qui utilisent ce matériau. Sinon, afficher "Used in crafting" / "Utilisé en artisanat" comme hint.
## 6. Résumé compact des ressources dans le loot reveal
**Constat** : L'inline resource summary (Proposal 4 original) fonctionne mais n'est pas localisé proprement. Le format `[Health 5/10 | Gold 3/5]` utilise les noms en anglais plutôt que les noms localisés.
**Solution** : S'assurer que le résumé inline des ressources utilise `_loc.Get()` pour les noms de ressources (déjà fait dans le code, vérifier que les noms sont bien localisés dans toutes les situations).
## 7. Icône de rareté dans le loot reveal
**Constat** : Le loot reveal en mode tableau affiche le nom et la rareté mais sans indicateur visuel distinctif pour les drops rares/épiques/légendaires. Tout se ressemble.
**Solution** : Ajouter un symbole devant les items de rareté élevée dans le loot reveal : ★ pour Rare, ★★ pour Epic, ★★★ pour Legendary, ★★★★ pour Mythic.
## 8. Nombre total de boîtes ouvertes dans le header de l'inventaire
**Constat** : L'en-tête de l'inventaire montre "Inventaire (1-15/83)" avec le nombre de types d'objets mais pas le nombre total de boîtes ouvertes, ce qui est une métrique de progression clé.
**Solution** : Ajouter le compteur de boîtes dans le sous-titre de l'inventaire quand il est en mode compact (dans le full layout) : "Inventaire (15 types) | 200 boxes".
## 9. Temps de jeu formaté de manière plus lisible
**Constat** : Le temps de jeu dans le StatsPanel affiche "0m 00s" en test, mais en jeu réel il affichera "45m 23s" ou "2h 15m". Le format pourrait être plus lisible.
**Solution** : Utiliser un format adaptatif : "23s" pour < 1min, "5m 23s" pour < 1h, "2h 15m" pour < 24h, "1d 2h" pour >= 24h. Supprimer les zéros non significatifs.
## 10. Indication du nombre de recettes disponibles pour un matériau
**Constat** : Avec la proposition 5 ci-dessus, on peut afficher les recettes, mais le joueur devrait aussi savoir combien d'ingrédients il a vs. combien sont nécessaires.
**Solution** : Dans le détail matériau, afficher pour chaque recette connue si le joueur a suffisamment d'ingrédients ou combien il lui manque : "Bronze Ingot (2/3 needed)".

View file

@ -104,11 +104,37 @@ Space, Medieval, Pirate, Contemporary, Sentimental, Prehistoric, Cosmic, Microsc
### StatType
Strength, Intelligence, Luck, Charisma, Dexterity, Wisdom
### ResourceType
### ResourceType (Characteristics)
Health, Mana, Food, Stamina, Blood, Gold, Oxygen, Energy
Note: internally called "Resource" in code, displayed as "Characteristics" to the player.
Characteristics represent the character's attributes with current/max values (e.g., Health 40/100).
They are implicitly unlocked when the player receives an item referencing that resource type (e.g., a Health Potion reveals Health).
### CosmeticSlot
Hair, Eyes, Body, Legs, Arms
### Rarity
Common, Uncommon, Rare, Epic, Legendary, Mythic
## Meta Unlock Order (Deterministic)
UI features are unlocked in a fixed order regardless of which meta box drops. The loot tables control *when* a meta item drops (via conditions/weights), but *which* feature is unlocked is always the next in this sequence:
1. **ArrowKeySelection** — Arrow key + keyboard navigation (first for accessibility)
2. **TextColors** — Basic 8-color ANSI text
3. **AutoSave** — Automatic saving
4. **InventoryPanel** — Interactive inventory table
5. **StatsPanel** — Progression stats and character attributes
6. **ResourcePanel** — Characteristics bars (health, mana, etc.)
7. **PortraitPanel** — ASCII art portrait
8. **ChatPanel** — Event log panel
9. **ExtendedColors** — 256-color palette
10. **BoxAnimation** — Animated box openings
11. **CraftingPanel** — Crafting system
12. **CompletionTracker** — Completion percentage
13. **FullLayout** — Multi-panel grid layout
Note: KeyboardShortcuts is merged into ArrowKeySelection.
### Pity System
If the player opens 10 boxes without receiving a meta box, one is guaranteed on the next opening.

View file

@ -30,6 +30,11 @@ public sealed class GameState
public required HashSet<ResourceType> VisibleResources { get; set; }
public required HashSet<StatType> VisibleStats { get; set; }
public required int TotalBoxesOpened { get; set; }
/// <summary>
/// Number of boxes opened since the last meta box was received.
/// Used for pity system: guarantees a meta box every 10 openings.
/// </summary>
public int BoxesSinceLastMeta { get; set; }
public required Locale CurrentLocale { get; set; }
public required DateTime CreatedAt { get; set; }
public required TimeSpan TotalPlayTime { get; set; }

View file

@ -14,9 +14,19 @@
<TrimMode>full</TrimMode>
</PropertyGroup>
<!-- Embed git commit hash as InformationalVersion at build time -->
<Target Name="SetSourceRevisionId" BeforeTargets="InitializeSourceControlInformation">
<Exec Command="git describe --always --dirty --abbrev=8" ConsoleToMSBuild="true" IgnoreExitCode="true">
<Output TaskParameter="ConsoleOutput" PropertyName="SourceRevisionId" />
</Exec>
</Target>
<PropertyGroup>
<SourceRevisionId Condition="'$(SourceRevisionId)' == ''">unknown</SourceRevisionId>
<IncludeSourceRevisionInInformationalVersion>true</IncludeSourceRevisionInInformationalVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Spectre.Console" Version="0.54.0" />
<PackageReference Include="Terminal.Gui" Version="2.*" />
</ItemGroup>
<ItemGroup>

View file

@ -12,6 +12,7 @@ using Spectre.Console;
using OpenTheBox.Simulation.Actions;
using OpenTheBox.Simulation.Events;
using OpenTheBox.Adventures;
using System.Reflection;
namespace OpenTheBox;
@ -25,18 +26,15 @@ public static class Program
private static RenderContext _renderContext = null!;
private static IRenderer _renderer = null!;
private static CraftingEngine _craftingEngine = null!;
private static bool _running = true;
private static bool _appRunning = true;
private static bool _gameRunning;
private static DateTime _sessionStart;
private static readonly string LogFilePath = Path.Combine(
AppContext.BaseDirectory, "openthebox-error.log");
private static bool _useTui;
public static async Task Main(string[] args)
{
// 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;
var snapshotIdx = Array.IndexOf(args, "--snapshot");
@ -49,39 +47,13 @@ public static class Program
_saveManager = new SaveManager();
_loc = new LocalizationManager(Locale.EN);
_renderContext = new RenderContext();
if (_useTui)
{
var tuiRenderer = new TerminalGuiRenderer(_renderContext, _loc, _registry);
_renderer = tuiRenderer;
tuiRenderer.Initialize();
// Run game loop on a background thread; Terminal.Gui owns the main thread
_ = Task.Run(async () =>
{
try
{
if (snapshotSlot > 0)
await LoadSnapshot(snapshotSlot);
else
await MainMenuLoop();
}
catch (Exception ex) { LogError(ex); }
finally { Terminal.Gui.Application.Invoke(() => Terminal.Gui.Application.RequestStop()); }
});
tuiRenderer.Run();
tuiRenderer.Dispose();
}
else
{
RefreshRenderer();
if (snapshotSlot > 0)
await LoadSnapshot(snapshotSlot);
else
await MainMenuLoop();
}
}
catch (Exception ex)
{
LogError(ex);
@ -94,6 +66,17 @@ public static class Program
}
}
/// <summary>
/// Returns the version string from the assembly's InformationalVersion attribute,
/// which includes the git commit hash embedded at build time.
/// </summary>
private static string GetVersionString()
{
var info = typeof(Program).Assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>();
return info?.InformationalVersion ?? "dev";
}
private static void LogError(Exception ex)
{
try
@ -108,22 +91,12 @@ public static class Program
}
/// <summary>
/// In TUI mode, updates the existing renderer context instead of creating a new one.
/// In classic mode, creates a new renderer via the factory.
/// Creates or refreshes the renderer based on current context.
/// </summary>
private static void RefreshRenderer()
{
if (_useTui && _renderer is TerminalGuiRenderer tui)
{
tui.UpdateContext(_renderContext);
if (_registry is not null)
tui.UpdateRegistry(_registry);
}
else if (!_useTui)
{
_renderer = RendererFactory.Create(_renderContext, _loc, _registry);
}
}
private static async Task MainMenuLoop()
{
@ -152,11 +125,12 @@ public static class Program
RefreshRenderer();
}
while (_running)
while (_appRunning)
{
_renderer.Clear();
_renderer.ShowMessage("========================================");
_renderer.ShowMessage(" OPEN THE BOX");
_renderer.ShowMessage($" {GetVersionString()}");
_renderer.ShowMessage("========================================");
_renderer.ShowMessage("");
_renderer.ShowMessage(_loc.Get("game.subtitle"));
@ -209,7 +183,7 @@ public static class Program
ChangeLanguage();
break;
case "quit":
_running = false;
_appRunning = false;
break;
}
}
@ -362,8 +336,14 @@ public static class Program
private static async Task GameLoop()
{
while (_running)
_sessionStart = DateTime.UtcNow;
_gameRunning = true;
while (_gameRunning)
{
// Update play time from this session
_state.TotalPlayTime += DateTime.UtcNow - _sessionStart;
_sessionStart = DateTime.UtcNow;
// Auto-save when returning to the hub (if the feature is unlocked)
if (_state.HasUIFeature(UIFeature.AutoSave))
{
@ -436,7 +416,7 @@ public static class Program
case "appearance": ChangeAppearance(); break;
case "collect_crafting": await CollectCrafting(); break;
case "save": SaveGame(); break;
case "quit": _running = false; break;
case "quit": _gameRunning = false; break;
}
}
@ -613,15 +593,6 @@ public static class Program
return;
}
if (_useTui)
{
// In TUI mode, the inventory is always visible in its panel.
// Just show a message and wait — the panel already displays the items.
_renderer.ShowMessage(_loc.Get("ui.inventory"));
_renderer.WaitForKeyPress(_loc.Get("prompt.press_key"));
return;
}
// Before InventoryPanel is unlocked, show a raw text list
if (!_renderContext.HasInventoryPanel)
{
@ -635,6 +606,9 @@ public static class Program
int maxOffset = Math.Max(0, totalItems - maxVisible);
int scrollOffset = 0;
int selectedIndex = 0;
int previousRenderedLines = 0;
_renderer.Clear();
while (true)
{
@ -654,16 +628,27 @@ public static class Program
else if (selectedIndex >= scrollOffset + maxVisible)
scrollOffset = selectedIndex - maxVisible + 1;
_renderer.Clear();
AnsiConsole.Write(InventoryPanel.Render(_state, _registry, _loc, scrollOffset, selectedIndex: selectedIndex));
// Render to buffer to avoid flicker
var writer = new StringWriter();
var bufferConsole = AnsiConsole.Create(new AnsiConsoleSettings
{
Out = new AnsiConsoleOutput(writer),
Ansi = AnsiSupport.Detect,
ColorSystem = ColorSystemSupport.Detect
});
bufferConsole.Profile.Width = SpectreRenderer.RefWidth;
bufferConsole.Write(InventoryPanel.Render(_state, _registry, _loc, scrollOffset, selectedIndex: selectedIndex));
// Detail panel for selected item
var selectedGroup = grouped[selectedIndex];
var detailPanel = InventoryPanel.RenderDetailPanel(selectedGroup, _registry, _loc, _state);
if (detailPanel is not null)
AnsiConsole.Write(detailPanel);
bufferConsole.Write(detailPanel);
// Controls hint
if (_renderContext.HasArrowSelection)
{
bool isUsable = selectedGroup.Category is ItemCategory.Consumable
&& selectedGroup.Def?.ResourceType is not null;
bool isLore = selectedGroup.Category is ItemCategory.LoreFragment;
@ -672,7 +657,19 @@ public static class Program
: isLore
? _loc.Get("inventory.controls_lore")
: _loc.Get("inventory.controls");
AnsiConsole.MarkupLine($"[dim]{Markup.Escape(controls)}[/]");
bufferConsole.MarkupLine($"[dim]{Markup.Escape(controls)}[/]");
}
string rendered = writer.ToString();
int renderedLines = rendered.Split('\n').Length;
// Move cursor to top of previous render and overwrite
if (previousRenderedLines > 0)
{
Console.Write($"\x1b[{previousRenderedLines}A\x1b[J");
}
Console.Write(rendered);
previousRenderedLines = renderedLines;
var key = Console.ReadKey(intercept: true);
switch (key.Key)
@ -715,44 +712,6 @@ public static class Program
_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"));
}
@ -892,7 +851,7 @@ public static class Program
{
_renderer.ShowMessage(_loc.Get("destiny.thanks"));
_renderer.WaitForKeyPress(_loc.Get("prompt.press_key"));
_running = false;
_gameRunning = false;
return;
}
}

View file

@ -252,7 +252,7 @@ public static class InventoryPanel
var summary = grouped
.GroupBy(g => g.Category)
.OrderBy(g => CategorySortOrder(g.Key))
.Select(g => $"{CategoryIcon(g.Key)}{g.Count()}")
.Select(g => $"{CategoryAbbrev(g.Key)}{g.Count()}")
.ToArray();
table.AddRow($"[dim]{string.Join(" ", summary)}[/]", "", "", $"[dim]{totalItems}[/]");
}

View file

@ -14,42 +14,34 @@ public static class PortraitPanel
/// <summary>
/// Builds a renderable ASCII art box portrait from the player's appearance settings.
/// </summary>
public static IRenderable Render(PlayerAppearance appearance, bool showCosmetics = true)
public static IRenderable Render(PlayerAppearance appearance, bool useColors = true)
{
if (!showCosmetics)
{
// Default bare box portrait before PortraitPanel is unlocked
string barePortrait = string.Join(Environment.NewLine,
"[white] [/]",
"[white] +------+ [/]",
"[white] | ? ? | [/]",
"[white] | | [/]",
"[white] +------+ [/]",
"[white] [/]",
"[white] [/]");
return new Panel(new Markup(barePortrait))
.Header("[bold green]Portrait[/]")
.Border(BoxBorder.Rounded)
.Padding(1, 0);
}
string hair = GetHairArt(appearance.HairStyle);
string eyes = GetEyeArt(appearance.EyeStyle);
string body = GetBodyArt(appearance.BodyStyle);
string legs = GetLegArt(appearance.LegStyle);
string arms = GetArmArt(appearance.ArmStyle);
string hairColor, eyeColor, bodyColor, legColor, armColor;
if (useColors)
{
// Use tint if set, otherwise use style-specific intrinsic color
string hairColor = appearance.HairTint != TintColor.None
hairColor = appearance.HairTint != TintColor.None
? TintToColor(appearance.HairTint)
: HairStyleColor(appearance.HairStyle);
string eyeColor = EyeStyleColor(appearance.EyeStyle);
string bodyColor = appearance.BodyTint != TintColor.None
eyeColor = EyeStyleColor(appearance.EyeStyle);
bodyColor = appearance.BodyTint != TintColor.None
? TintToColor(appearance.BodyTint)
: BodyStyleColor(appearance.BodyStyle);
string legColor = LegStyleColor(appearance.LegStyle);
string armColor = ArmStyleColor(appearance.ArmStyle);
legColor = LegStyleColor(appearance.LegStyle);
armColor = ArmStyleColor(appearance.ArmStyle);
}
else
{
// Monochrome white until TextColors is unlocked
hairColor = eyeColor = bodyColor = legColor = armColor = "white";
}
string portrait = string.Join(Environment.NewLine,
$"[{hairColor}]{Markup.Escape(hair)}[/]",
@ -60,8 +52,10 @@ public static class PortraitPanel
$"[{legColor}]{Markup.Escape(legs)}[/]",
$"[{armColor}]{Markup.Escape(arms)}[/]");
string headerStyle = useColors ? "[bold green]Portrait[/]" : "Portrait";
return new Panel(new Markup(portrait))
.Header("[bold green]Portrait[/]")
.Header(headerStyle)
.Border(BoxBorder.Rounded)
.Padding(1, 0);
}

View file

@ -46,8 +46,9 @@ public static class ResourcePanel
rows.Add(new Markup($"[dim italic]{Markup.Escape(emptyHint)}[/]"));
}
string title = loc?.Get("resource.title") ?? "Characteristics";
return new Panel(new Rows(rows))
.Header("[bold cyan]Resources[/]")
.Header($"[bold cyan]{Markup.Escape(title)}[/]")
.Border(BoxBorder.Rounded);
}

View file

@ -126,15 +126,7 @@ public sealed class SpectreRenderer : IRenderer
{
if (_context.HasArrowSelection)
{
// Number each option and escape so square brackets are not parsed as Spectre markup
var numbered = options.Select((o, i) => $"{i + 1}. {Markup.Escape(o)}").ToList();
string selected = AnsiConsole.Prompt(
new SelectionPrompt<string>()
.Title(Markup.Escape(prompt))
.PageSize(10)
.AddChoices(numbered));
return numbered.IndexOf(selected);
return ShowArrowSelection(prompt, options);
}
if (_context.HasColors)
@ -175,6 +167,78 @@ public sealed class SpectreRenderer : IRenderer
}
}
/// <summary>
/// Custom arrow-key selection that also supports number keys (1-9) for direct selection.
/// Replaces Spectre.Console's SelectionPrompt which doesn't handle number key shortcuts.
/// </summary>
private int ShowArrowSelection(string prompt, List<string> options)
{
int selected = 0;
int pageSize = Math.Min(10, options.Count);
while (true)
{
// Calculate visible window
int scrollOffset = 0;
if (selected >= pageSize)
scrollOffset = selected - pageSize + 1;
int visibleEnd = Math.Min(scrollOffset + pageSize, options.Count);
// Render
AnsiConsole.Cursor.Show(false);
if (prompt.Length > 0)
AnsiConsole.MarkupLine($"[bold]{Markup.Escape(prompt)}[/]");
for (int i = scrollOffset; i < visibleEnd; i++)
{
string num = $"{i + 1}.";
string text = Markup.Escape(options[i]);
if (i == selected)
AnsiConsole.MarkupLine($" [bold cyan]► {num,-3}[/] [bold]{text}[/]");
else
AnsiConsole.MarkupLine($" [dim]{num,-3}[/] {text}");
}
if (scrollOffset > 0)
AnsiConsole.MarkupLine("[dim] ▲ ...[/]");
if (visibleEnd < options.Count)
AnsiConsole.MarkupLine("[dim] ▼ ...[/]");
var key = Console.ReadKey(intercept: true);
AnsiConsole.Cursor.Show(true);
switch (key.Key)
{
case ConsoleKey.UpArrow:
selected = (selected - 1 + options.Count) % options.Count;
break;
case ConsoleKey.DownArrow:
selected = (selected + 1) % options.Count;
break;
case ConsoleKey.Enter:
return selected;
case ConsoleKey.D1 or ConsoleKey.NumPad1: if (options.Count >= 1) return 0; break;
case ConsoleKey.D2 or ConsoleKey.NumPad2: if (options.Count >= 2) return 1; break;
case ConsoleKey.D3 or ConsoleKey.NumPad3: if (options.Count >= 3) return 2; break;
case ConsoleKey.D4 or ConsoleKey.NumPad4: if (options.Count >= 4) return 3; break;
case ConsoleKey.D5 or ConsoleKey.NumPad5: if (options.Count >= 5) return 4; break;
case ConsoleKey.D6 or ConsoleKey.NumPad6: if (options.Count >= 6) return 5; break;
case ConsoleKey.D7 or ConsoleKey.NumPad7: if (options.Count >= 7) return 6; break;
case ConsoleKey.D8 or ConsoleKey.NumPad8: if (options.Count >= 8) return 7; break;
case ConsoleKey.D9 or ConsoleKey.NumPad9: if (options.Count >= 9) return 8; break;
}
// Clear the rendered lines to redraw
int linesToClear = (visibleEnd - scrollOffset) + (prompt.Length > 0 ? 1 : 0)
+ (scrollOffset > 0 ? 1 : 0) + (visibleEnd < options.Count ? 1 : 0);
for (int i = 0; i < linesToClear; i++)
{
AnsiConsole.Cursor.MoveUp();
AnsiConsole.Write("\r" + new string(' ', RefWidth) + "\r");
}
}
}
public string ShowTextInput(string prompt)
{
if (_context.HasColors)
@ -399,13 +463,15 @@ public sealed class SpectreRenderer : IRenderer
topRow.AddColumn(new TableColumn("c3").NoWrap());
topRow.AddRow(
PortraitPanel.Render(state.Appearance, context.HasPortraitPanel),
context.HasPortraitPanel
? PortraitPanel.Render(state.Appearance, useColors: context.HasColors)
: new Panel("[dim]?[/]").Header("Portrait").Expand(),
context.HasStatsPanel
? StatsPanel.Render(state, _loc)
: new Panel($"[dim italic]{Markup.Escape(_loc.Get("panel.locked.stats"))}[/]").Header("Stats").Expand(),
context.HasResourcePanel
? ResourcePanel.Render(state, _loc)
: new Panel($"[dim italic]{Markup.Escape(_loc.Get("panel.locked.resources"))}[/]").Header("Resources").Expand());
: new Panel($"[dim italic]{Markup.Escape(_loc.Get("panel.locked.resources"))}[/]").Header(Markup.Escape(_loc.Get("resource.title"))).Expand());
AnsiConsole.Write(topRow);
@ -447,7 +513,7 @@ public sealed class SpectreRenderer : IRenderer
{
// Row 1: group top panels side by side when more than one exists
var topPanels = new List<IRenderable>();
topPanels.Add(PortraitPanel.Render(state.Appearance, context.HasPortraitPanel));
if (context.HasPortraitPanel) topPanels.Add(PortraitPanel.Render(state.Appearance, useColors: context.HasColors));
if (context.HasStatsPanel) topPanels.Add(StatsPanel.Render(state, _loc));
if (context.HasResourcePanel) topPanels.Add(ResourcePanel.Render(state, _loc));

View file

@ -1,688 +0,0 @@
using OpenTheBox.Core;
using OpenTheBox.Core.Characters;
using OpenTheBox.Core.Enums;
using OpenTheBox.Core.Items;
using OpenTheBox.Data;
using OpenTheBox.Localization;
using OpenTheBox.Rendering.Panels;
using Terminal.Gui;
namespace OpenTheBox.Rendering;
/// <summary>
/// Full-featured renderer using Terminal.Gui for tmux-like panel layout.
/// The game loop runs on a background thread; UI interactions are marshalled
/// to the Terminal.Gui main loop via <see cref="Application.Invoke"/>.
/// Blocking calls (ShowSelection, WaitForKeyPress, etc.) use
/// <see cref="ManualResetEventSlim"/> to synchronize.
/// </summary>
public sealed class TerminalGuiRenderer : IRenderer, IDisposable
{
private RenderContext _context;
private readonly LocalizationManager _loc;
private ContentRegistry? _registry;
// ── Layout views ─────────────────────────────────────────────────
private Toplevel? _top;
private FrameView? _portraitFrame;
private FrameView? _statsFrame;
private FrameView? _resourcesFrame;
private FrameView? _inventoryFrame;
private FrameView? _craftingFrame;
private FrameView? _chatFrame;
private FrameView? _actionFrame;
private Label? _completionLabel;
private TextView? _messageLog;
// ── Synchronization ──────────────────────────────────────────────
private readonly ManualResetEventSlim _selectionDone = new(false);
private readonly ManualResetEventSlim _keyPressDone = new(false);
private readonly ManualResetEventSlim _textInputDone = new(false);
private int _selectedIndex = -1;
private string _textInputResult = "";
private readonly List<string> _chatMessages = [];
public TerminalGuiRenderer(RenderContext context, LocalizationManager loc, ContentRegistry? registry = null)
{
_context = context;
_loc = loc;
_registry = registry;
}
/// <summary>
/// Initializes Terminal.Gui and builds the layout. Must be called from the main thread.
/// </summary>
public void Initialize()
{
Application.Init();
_top = Application.Top;
BuildLayout();
}
/// <summary>
/// Runs the Terminal.Gui application loop. Blocks until Application.RequestStop().
/// </summary>
public void Run()
{
if (_top is not null)
Application.Run(_top);
}
public void UpdateContext(RenderContext context) => _context = context;
public void UpdateRegistry(ContentRegistry registry) => _registry = registry;
// ── IRenderer: Messages ──────────────────────────────────────────
public void ShowMessage(string message)
{
AppendChat(null, message);
}
public void ShowError(string message)
{
AppendChat(null, $"ERROR: {message}");
}
// ── IRenderer: Box opening ───────────────────────────────────────
public void ShowBoxOpening(string boxName, string rarity)
{
AppendChat(null, _loc.Get("box.opening", boxName));
Thread.Sleep(_context.HasBoxAnimation ? 1500 : 500);
AppendChat(null, _loc.Get("box.opened_short", boxName));
}
// ── IRenderer: Loot reveal ───────────────────────────────────────
public void ShowLootReveal(List<(string name, string rarity, string category)> items)
{
AppendChat(null, _loc.Get("loot.received"));
foreach (var (name, rarity, _) in items)
{
AppendChat(null, $" - {name} [{rarity}]");
}
}
// ── IRenderer: Selection ─────────────────────────────────────────
public int ShowSelection(string prompt, List<string> options)
{
_selectionDone.Reset();
_selectedIndex = 0;
Application.Invoke(() =>
{
if (_actionFrame is null || _top is null) return;
_actionFrame.RemoveAll();
var promptLabel = new Label
{
Text = prompt,
X = 0,
Y = 0,
Width = Dim.Fill(),
ColorScheme = Colors.ColorSchemes["TopLevel"]
};
var list = new ListView
{
X = 0,
Y = 1,
Width = Dim.Fill(),
Height = Dim.Fill(),
CanFocus = true
};
list.SetSource(new System.Collections.ObjectModel.ObservableCollection<string>(
options.Select((o, i) => $" {i + 1}. {o}")));
list.SelectedItem = 0;
list.OpenSelectedItem += (_, args) =>
{
_selectedIndex = args.Item;
_selectionDone.Set();
};
// Also accept Enter key
list.KeyDown += (_, args) =>
{
if (args.KeyCode == KeyCode.Enter)
{
_selectedIndex = list.SelectedItem;
_selectionDone.Set();
args.Handled = true;
}
// Number keys for direct selection
else if (args.KeyCode >= KeyCode.D1 && args.KeyCode <= KeyCode.D9)
{
int idx = (int)(args.KeyCode - KeyCode.D1);
if (idx < options.Count)
{
_selectedIndex = idx;
_selectionDone.Set();
args.Handled = true;
}
}
};
_actionFrame.Add(promptLabel, list);
_actionFrame.Title = _loc.Get("prompt.choose_action");
list.SetFocus();
});
_selectionDone.Wait();
return _selectedIndex;
}
// ── IRenderer: Text input ────────────────────────────────────────
public string ShowTextInput(string prompt)
{
_textInputDone.Reset();
_textInputResult = "";
Application.Invoke(() =>
{
if (_actionFrame is null) return;
_actionFrame.RemoveAll();
var label = new Label
{
Text = prompt,
X = 0,
Y = 0,
Width = Dim.Fill()
};
var field = new TextField
{
X = 0,
Y = 1,
Width = Dim.Fill(),
CanFocus = true
};
field.KeyDown += (_, args) =>
{
if (args.KeyCode == KeyCode.Enter)
{
_textInputResult = field.Text ?? "";
_textInputDone.Set();
args.Handled = true;
}
};
_actionFrame.Add(label, field);
_actionFrame.Title = prompt;
field.SetFocus();
});
_textInputDone.Wait();
return _textInputResult;
}
// ── IRenderer: Game state ────────────────────────────────────────
public void ShowGameState(GameState state, RenderContext context)
{
Application.Invoke(() =>
{
UpdatePortrait(state);
UpdateStats(state);
UpdateResources(state);
UpdateInventory(state);
UpdateCrafting(state);
if (_completionLabel is not null && context.HasCompletionTracker)
{
_completionLabel.Text = _loc.Get("ui.completion", context.CompletionPercent);
_completionLabel.Visible = true;
}
else if (_completionLabel is not null)
{
_completionLabel.Visible = false;
}
// Show/hide panels based on context
SetFrameVisible(_portraitFrame, context.HasPortraitPanel);
SetFrameVisible(_statsFrame, context.HasStatsPanel);
SetFrameVisible(_resourcesFrame, context.HasResourcePanel);
SetFrameVisible(_inventoryFrame, context.HasInventoryPanel);
SetFrameVisible(_craftingFrame, context.HasCraftingPanel);
});
}
// ── IRenderer: Adventure ─────────────────────────────────────────
public void ShowAdventureDialogue(string? character, string text)
{
AppendChat(character, text);
}
public int ShowAdventureChoice(List<string> options)
{
return ShowSelection(_loc.Get("prompt.what_do"), options);
}
public void ShowAdventureHint(string hint)
{
AppendChat(null, $" ({hint})");
}
// ── IRenderer: UI feature unlock ─────────────────────────────────
public void ShowUIFeatureUnlocked(string featureName)
{
AppendChat(null, $"★ {_loc.Get("ui.feature_unlocked", featureName)} ★");
}
// ── IRenderer: Interaction ───────────────────────────────────────
public void ShowInteraction(string description)
{
AppendChat(null, $"* {description} *");
}
// ── IRenderer: Wait ──────────────────────────────────────────────
public void WaitForKeyPress(string? message = null)
{
string text = message ?? _loc.Get("prompt.press_key");
AppendChat(null, text);
_keyPressDone.Reset();
Application.Invoke(() =>
{
if (_actionFrame is null) return;
_actionFrame.RemoveAll();
var label = new Label
{
Text = text,
X = Pos.Center(),
Y = Pos.Center(),
Width = Dim.Fill(),
CanFocus = true
};
label.KeyDown += (_, args) =>
{
_keyPressDone.Set();
args.Handled = true;
};
_actionFrame.Add(label);
label.SetFocus();
});
_keyPressDone.Wait();
}
// ── IRenderer: Clear ─────────────────────────────────────────────
public void Clear()
{
// In Terminal.Gui mode, we don't clear — the layout persists.
// Instead, update panels on next ShowGameState.
}
// ── Layout building ──────────────────────────────────────────────
private void BuildLayout()
{
if (_top is null) return;
// Top row: Portrait | Stats | Resources (height ~10)
_portraitFrame = new FrameView
{
Title = "Portrait",
X = 0,
Y = 0,
Width = 18,
Height = 12,
Visible = false
};
_statsFrame = new FrameView
{
Title = _loc.Get("stats.title"),
X = 18,
Y = 0,
Width = 25,
Height = 12,
Visible = false
};
_resourcesFrame = new FrameView
{
Title = "Resources",
X = 43,
Y = 0,
Width = Dim.Fill(),
Height = 12,
Visible = false
};
// Middle row: Inventory | Chat (flexible height)
_inventoryFrame = new FrameView
{
Title = _loc.Get("ui.inventory") ?? "Inventory",
X = 0,
Y = 12,
Width = Dim.Percent(50),
Height = 18,
Visible = false
};
_chatFrame = new FrameView
{
Title = "Chat",
X = Pos.Percent(50),
Y = 12,
Width = Dim.Fill(),
Height = 18
};
_messageLog = new TextView
{
X = 0,
Y = 0,
Width = Dim.Fill(),
Height = Dim.Fill(),
ReadOnly = true,
WordWrap = true,
CanFocus = false
};
_chatFrame.Add(_messageLog);
// Bottom row: Crafting + Completion
_craftingFrame = new FrameView
{
Title = _loc.Get("craft.panel.title") ?? "Workshops",
X = 0,
Y = 30,
Width = Dim.Fill(),
Height = 5,
Visible = false
};
_completionLabel = new Label
{
X = 0,
Y = 35,
Width = Dim.Fill(),
Visible = false
};
// Action frame: where selections, prompts, inputs go
_actionFrame = new FrameView
{
Title = "Actions",
X = 0,
Y = Pos.AnchorEnd(10),
Width = Dim.Fill(),
Height = 10
};
_top.Add(
_portraitFrame, _statsFrame, _resourcesFrame,
_inventoryFrame, _chatFrame,
_craftingFrame, _completionLabel,
_actionFrame);
}
// ── Panel update helpers ─────────────────────────────────────────
private void UpdatePortrait(GameState state)
{
if (_portraitFrame is null) return;
_portraitFrame.RemoveAll();
var appearance = state.Appearance;
string art = string.Join("\n",
GetHairArt(appearance.HairStyle),
" +------+",
GetEyeArt(appearance.EyeStyle),
GetBodyArt(appearance.BodyStyle),
" +------+",
GetLegArt(appearance.LegStyle),
GetArmArt(appearance.ArmStyle));
_portraitFrame.Add(new Label
{
Text = art,
X = 0,
Y = 0,
Width = Dim.Fill(),
Height = Dim.Fill()
});
}
private void UpdateStats(GameState state)
{
if (_statsFrame is null) return;
_statsFrame.RemoveAll();
var lines = new List<string>();
foreach (var statType in state.VisibleStats.OrderBy(s => s.ToString()))
{
if (state.Stats.TryGetValue(statType, out int value))
lines.Add($" {statType}: {value}");
}
string boxesLabel = _loc.Get("stats.boxes_opened");
lines.Add($" {boxesLabel}: {state.TotalBoxesOpened}");
_statsFrame.Add(new Label
{
Text = string.Join("\n", lines),
X = 0, Y = 0,
Width = Dim.Fill(),
Height = Dim.Fill()
});
}
private void UpdateResources(GameState state)
{
if (_resourcesFrame is null) return;
_resourcesFrame.RemoveAll();
var lines = new List<string>();
foreach (var rt in state.VisibleResources.OrderBy(r => r.ToString()))
{
if (!state.Resources.TryGetValue(rt, out var res)) continue;
int barW = 20;
int filled = res.Max > 0 ? (int)Math.Round((double)res.Current / res.Max * barW) : 0;
filled = Math.Clamp(filled, 0, barW);
string bar = new string('#', filled) + new string('-', barW - filled);
lines.Add($" {rt}: [{bar}] {res.Current}/{res.Max}");
}
if (lines.Count == 0)
lines.Add(" No resources visible yet.");
_resourcesFrame.Add(new Label
{
Text = string.Join("\n", lines),
X = 0, Y = 0,
Width = Dim.Fill(),
Height = Dim.Fill()
});
}
private void UpdateInventory(GameState state)
{
if (_inventoryFrame is null || _registry is null) return;
_inventoryFrame.RemoveAll();
var grouped = state.Inventory
.GroupBy(i => i.DefinitionId)
.Select(g =>
{
var def = _registry.GetItem(g.Key);
string name = def is not null ? _loc.Get(def.NameKey) : g.Key;
string rarity = (def?.Rarity ?? ItemRarity.Common).ToString();
string category = (def?.Category ?? ItemCategory.Box).ToString();
int qty = g.Sum(i => i.Quantity);
return $" {name,-24} {category,-12} {rarity,-10} x{qty}";
})
.ToList();
if (grouped.Count == 0)
grouped.Add($" {_loc.Get("inventory.empty")}");
var list = new ListView
{
X = 0, Y = 0,
Width = Dim.Fill(),
Height = Dim.Fill(),
CanFocus = false
};
list.SetSource(new System.Collections.ObjectModel.ObservableCollection<string>(grouped));
_inventoryFrame.Add(list);
string title = _loc.Get("ui.inventory") ?? "Inventory";
_inventoryFrame.Title = $"{title} ({state.Inventory.GroupBy(i => i.DefinitionId).Count()})";
}
private void UpdateCrafting(GameState state)
{
if (_craftingFrame is null) return;
_craftingFrame.RemoveAll();
var lines = new List<string>();
foreach (var job in state.ActiveCraftingJobs.OrderBy(j => j.StartedAt))
{
string name = job.RecipeId;
if (_registry?.Recipes.TryGetValue(job.RecipeId, out var recipe) == true)
name = _loc.Get(recipe.NameKey);
string station = job.Workstation.ToString();
if (job.IsComplete)
lines.Add($" ✓ {station}: {name} — {_loc.Get("craft.done")}");
else
{
int pct = (int)job.ProgressPercent;
int barW = 20;
int filled = barW * pct / 100;
string bar = new string('#', filled) + new string('-', barW - filled);
lines.Add($" {station}: {name} [{bar}] {pct}%");
}
}
if (lines.Count == 0)
lines.Add($" {_loc.Get("craft.panel.empty")}");
_craftingFrame.Add(new Label
{
Text = string.Join("\n", lines),
X = 0, Y = 0,
Width = Dim.Fill(),
Height = Dim.Fill()
});
}
// ── Chat/message log ─────────────────────────────────────────────
private void AppendChat(string? character, string text)
{
string line = character is not null ? $"[{character}] {text}" : text;
_chatMessages.Add(line);
// Keep only last 100 messages
if (_chatMessages.Count > 100)
_chatMessages.RemoveRange(0, _chatMessages.Count - 100);
Application.Invoke(() =>
{
if (_messageLog is null) return;
_messageLog.Text = string.Join("\n", _chatMessages);
// Scroll to bottom
_messageLog.MoveEnd();
});
}
// ── Helpers ──────────────────────────────────────────────────────
private static void SetFrameVisible(FrameView? frame, bool visible)
{
if (frame is not null) frame.Visible = visible;
}
// ── Box ASCII art (duplicated from PortraitPanel for Terminal.Gui text) ──
private static string GetHairArt(HairStyle style) => style switch
{
HairStyle.None => " ",
HairStyle.Short => " ~~~~ ",
HairStyle.Long => " ~~~~~~ ",
HairStyle.Ponytail => " ~~~~\\ ",
HairStyle.Braided => " ///\\\\\\ ",
HairStyle.Cyberpunk => " /\\/\\/\\ ",
HairStyle.Fire => " /||\\ ",
HairStyle.StardustLegendary => " *.***.*. ",
_ => " "
};
private static string GetEyeArt(EyeStyle style) => style switch
{
EyeStyle.None => " | o o | ",
EyeStyle.Blue => " | O O | ",
EyeStyle.Green => " | - . | ",
EyeStyle.RedOrange => " | > < | ",
EyeStyle.Brown => " | ^ o | ",
EyeStyle.Black => " | - - | ",
EyeStyle.Sunglasses => " | B ) | ",
EyeStyle.PilotGlasses => " |[B )]| ",
EyeStyle.AircraftGlasses => " |{B )}| ",
EyeStyle.CyberneticEyes => " | * * | ",
EyeStyle.MagicianGlasses => " | @ @ | ",
_ => " | ? ? | "
};
private static string GetBodyArt(BodyStyle style) => style switch
{
BodyStyle.Naked => " | | ",
BodyStyle.RegularTShirt => " | ==== | ",
BodyStyle.SexyTShirt => " | ~<<~ | ",
BodyStyle.Suit => " | #### | ",
BodyStyle.Armored => " |{####}| ",
BodyStyle.Robotic => " |[0110]| ",
_ => " | | "
};
private static string GetLegArt(LegStyle style) => style switch
{
LegStyle.None => " ",
LegStyle.Naked => " | | ",
LegStyle.Slip => " | | ",
LegStyle.Short => " || || ",
LegStyle.Panty => " | | ",
LegStyle.RocketBoots => " [| |] ",
LegStyle.PegLeg => " | / ",
LegStyle.Tentacles => " }{| |}{ ",
_ => " | | "
};
private static string GetArmArt(ArmStyle style) => style switch
{
ArmStyle.None => " ",
ArmStyle.Short => " / \\ ",
ArmStyle.Regular => " _/ \\_ ",
ArmStyle.Long => " __/ \\__ ",
ArmStyle.Mechanical => " ~/ \\~ ",
ArmStyle.Wings => " </ \\> ",
ArmStyle.ExtraPair => " <// \\\\> ",
_ => " "
};
// ── Dispose ──────────────────────────────────────────────────────
public void Dispose()
{
_selectionDone.Dispose();
_keyPressDone.Dispose();
_textInputDone.Dispose();
Application.Shutdown();
}
}

View file

@ -66,11 +66,38 @@ public class BoxEngine(ContentRegistry registry)
}
}
// Pity system: if no meta box dropped and pity counter hits threshold, inject one
bool hasMeta = droppedItemDefIds.Any(id =>
Array.IndexOf(MetaBoxChain, id) >= 0 ||
registry.GetItem(id)?.MetaUnlock is not null);
if (hasMeta)
{
state.BoxesSinceLastMeta = 0;
}
else if (!isAutoOpen)
{
state.BoxesSinceLastMeta++;
if (state.BoxesSinceLastMeta >= 10 && MetaEngine.GetNextMetaItemId(state, registry) is not null)
{
droppedItemDefIds.Add("box_meta_basics");
state.BoxesSinceLastMeta = 0;
}
}
events.Add(new BoxOpenedEvent(boxDefId, droppedItemDefIds, isAutoOpen));
// Create item instances for each dropped item
foreach (var itemDefId in droppedItemDefIds)
foreach (var rawItemDefId in droppedItemDefIds)
{
// Auto-upgrade meta boxes at loot time so the player receives
// the correct tier directly in their inventory (TODO 4)
var itemDefId = ResolveMetaBoxUpgrade(rawItemDefId, state);
// Deterministic meta UI unlock: if this item unlocks a UIFeature,
// replace it with the next one in the deterministic sequence
itemDefId = ResolveDeterministicMeta(itemDefId, state);
var instance = ItemInstance.Create(itemDefId);
state.AddItem(instance);
events.Add(new ItemReceivedEvent(instance));
@ -206,6 +233,20 @@ public class BoxEngine(ContentRegistry registry)
return false;
}
/// <summary>
/// If the item unlocks a UIFeature, replace it with the next item in the
/// deterministic meta unlock sequence so unlocks happen in a fixed order.
/// </summary>
private string ResolveDeterministicMeta(string itemDefId, GameState state)
{
var itemDef = registry.GetItem(itemDefId);
if (itemDef?.MetaUnlock is null)
return itemDefId;
var next = MetaEngine.GetNextMetaItemId(state, registry);
return next ?? itemDefId;
}
/// <summary>
/// If the box is part of the meta box chain and all its non-box loot items are already
/// obtained, upgrades it to the next tier. Repeats until a tier with obtainable loot is

View file

@ -13,6 +13,42 @@ namespace OpenTheBox.Simulation;
/// </summary>
public class MetaEngine
{
/// <summary>
/// Deterministic unlock order for UI feature meta items.
/// ArrowKeySelection is first for accessibility. KeyboardShortcuts is merged into it.
/// </summary>
public static readonly string[] MetaUnlockSequence =
[
"meta_arrows", // ArrowKeySelection (+ keyboard shortcuts)
"meta_colors", // TextColors
"meta_autosave", // AutoSave
"meta_inventory", // InventoryPanel
"meta_stats", // StatsPanel
"meta_resources", // ResourcePanel
"meta_portrait", // PortraitPanel
"meta_chat", // ChatPanel
"meta_extended_colors", // ExtendedColors
"meta_animation", // BoxAnimation
"meta_crafting", // CraftingPanel
"meta_completion", // CompletionTracker
"meta_layout", // FullLayout
];
/// <summary>
/// Returns the next meta UI feature item ID that the player has not yet unlocked,
/// following the deterministic sequence. Returns null if all are unlocked.
/// </summary>
public static string? GetNextMetaItemId(GameState state, ContentRegistry registry)
{
foreach (var itemId in MetaUnlockSequence)
{
var def = registry.GetItem(itemId);
if (def?.MetaUnlock is { } feature && !state.UnlockedUIFeatures.Contains(feature))
return itemId;
}
return null;
}
/// <summary>
/// Processes newly received items and applies any meta-unlock effects to the game state.
/// </summary>
@ -30,6 +66,10 @@ public class MetaEngine
if (itemDef.MetaUnlock.HasValue && state.UnlockedUIFeatures.Add(itemDef.MetaUnlock.Value))
{
events.Add(new UIFeatureUnlockedEvent(itemDef.MetaUnlock.Value));
// ArrowKeySelection implicitly includes KeyboardShortcuts
if (itemDef.MetaUnlock.Value == UIFeature.ArrowKeySelection)
state.UnlockedUIFeatures.Add(UIFeature.KeyboardShortcuts);
}
// Make resource type visible if this item references a resource

View file

@ -1,78 +0,0 @@
# Suggestions d'amélioration #2
Basées sur l'analyse des rendus InventoryRenderCapture et PlaythroughCapture.
| # | Suggestion | Priorité | Statut |
|---|-----------|----------|--------|
| 1 | Noms localisés pour les boîtes dans l'inventaire | ★★★★★ | ✅ DONE |
| 2 | Tri de l'inventaire : consommables et lore avant matériaux | ★★★★★ | ✅ DONE |
| 3 | Colonnes Category/Rarity localisées | ★★★★☆ | ✅ DONE |
| 4 | Panneau de détail pour les boîtes (contenu possible) | ★★★★☆ | ✅ DONE |
| 5 | Compteur de lore collectés (N/10) dans le panneau détails | ★★★★☆ | ✅ DONE |
| 6 | Icônes/emojis par catégorie dans l'inventaire | ★★★☆☆ |
| 7 | Résumé de l'inventaire dans le panneau compact (nombre par catégorie) | ★★★☆☆ |
| 8 | Panneau de détail pour les items Meta (explication du feature) | ★★★☆☆ |
| 9 | Message de bienvenue adaptatif au nombre de boîtes ouvertes | ★★☆☆☆ |
| 10 | Indicateur visuel de scroll restant (↑↓ en haut/bas du tableau) | ★★☆☆☆ |
---
## 1. Noms localisés pour les boîtes dans l'inventaire — ★★★★★
**Constat** : Les boîtes apparaissent avec leur ID technique (`box_not_great`, `box_legendhair`, `box_meta_basics`) au lieu de leur nom localisé. C'est le défaut visuel le plus flagrant dans l'inventaire.
**Solution** : Dans `InventoryPanel.GetGroupedItems()` ou `Render()`, quand l'item n'a pas de `ItemDefinition` (c'est une boîte), résoudre le nom via le `BoxDefinition.NameKey` du registre.
## 2. Tri de l'inventaire : consommables et lore avant matériaux — ★★★★★
**Constat** : L'ordre actuel (Box, Cosmetic, Material, Consumable, Meta, LoreFragment…) met les items interactifs (consommables, lore) loin dans la liste, après des dizaines de cosmétiques et matériaux non-actionnables.
**Solution** : Modifier l'ordre de tri dans `GetGroupedItems()` pour prioriser : Box → Consumable → LoreFragment → Cosmetic → Material → Meta → reste.
## 3. Colonnes Category/Rarity localisées — ★★★★☆
**Constat** : Les colonnes "Cat." et "Rarity" affichent les valeurs enum en anglais (`Consumable`, `Uncommon`, `Material`) même en locale FR. Ça casse l'immersion.
**Solution** : Ajouter des clés de localisation `category.box`, `category.consumable`, `rarity.common`, `rarity.uncommon`, etc. et les utiliser dans `Render()`.
## 4. Panneau de détail pour les boîtes (contenu possible) — ★★★★☆
**Constat** : Sélectionner une boîte dans l'inventaire ne montre aucun détail utile. Le joueur ne sait pas ce qu'elle peut contenir.
**Solution** : Ajouter un cas `ItemCategory.Box` dans `RenderDetailPanel()` qui affiche le nom localisé de la boîte et un indice sur son contenu (rareté, thème).
## 5. Compteur de lore collectés (N/10) dans le panneau détails — ★★★★☆
**Constat** : Quand on sélectionne un fragment de lore, on ne sait pas combien on en a collecté sur le total (10). C'est une mécanique de collection qui bénéficierait d'un indicateur de progression.
**Solution** : Compter les `lore_*` distincts dans l'inventaire et afficher "Fragments : 3/10" dans le panneau de détail des lore.
## 6. Icônes/emojis par catégorie dans l'inventaire — ★★★☆☆
**Constat** : La colonne catégorie est textuelle et prend de la place. Des icônes compactes seraient plus lisibles.
**Solution** : Remplacer le texte de catégorie par des emojis (📦 Box, 🧪 Consumable, 📜 Lore, 👗 Cosmetic, 🔩 Material, ⚙ Meta).
## 7. Résumé de l'inventaire dans le panneau compact — ★★★☆☆
**Constat** : En mode compact (FullLayout), seuls 6 items sont visibles, sans indication du nombre total par catégorie.
**Solution** : Ajouter une ligne de pied de tableau montrant un résumé : "📦3 🧪5 📜2 👗12 🔩8".
## 8. Panneau de détail pour les items Meta — ★★★☆☆
**Constat** : Les items Meta (ex: "Couleurs de texte") n'ont aucune description dans le panneau de détails.
**Solution** : Ajouter des `descriptionKey` pour les items meta dans items.json et les afficher dans le panneau détails.
## 9. Message de bienvenue adaptatif — ★★☆☆☆
**Constat** : Le message de bienvenue est identique que le joueur ait 0 ou 500 boîtes ouvertes. C'est une occasion manquée de donner du feedback.
**Solution** : Adapter le message d'accueil selon la progression.
## 10. Indicateur visuel de scroll dans l'inventaire — ★★☆☆☆
**Constat** : Quand l'inventaire est scrollable, le joueur ne voit que "(1-15/55)" dans le titre. Il n'y a pas d'indication visuelle dans le tableau lui-même.
**Solution** : Ajouter des symboles ▲/▼ en première/dernière ligne quand il y a du contenu au-dessus/en-dessous.

View file

@ -1,78 +0,0 @@
# Suggestions d'amélioration #3
Basées sur l'analyse des rendus PlaythroughCapture et InventoryRenderCapture après suggestions_2.
| # | Suggestion | Priorité | Statut |
|---|-----------|----------|--------|
| 1 | Loot reveal : afficher la rareté localisée | ★★★★★ | ✅ DONE |
| 2 | Matériaux : afficher le nom de forme localisé | ★★★★★ | ✅ DONE |
| 3 | Inventaire compact avec résumé catégories en footer | ★★★★☆ | ✅ DONE |
| 4 | Multi-use consommables : consommer en lot sans quitter l'inventaire | ★★★★☆ | ✅ DONE |
| 5 | Panneau de détail Meta : expliquer le feature débloqué | ★★★★☆ | ✅ DONE |
| 6 | Panneau "???" localisé dans le FullLayout | ★★★☆☆ | |
| 7 | Compteur de boîtes dans l'en-tête du loot reveal | ★★★☆☆ | |
| 8 | Tronquer le nom "Potion de Santé Moyenne" qui casse le tableau | ★★★☆☆ | |
| 9 | Indicateur visuel des ressources modifiées lors du loot | ★★☆☆☆ | |
| 10 | Feedback sonore optionnel pour les items rares+ | ★★☆☆☆ | |
---
## 1. Loot reveal : afficher la rareté localisée — ★★★★★
**Constat** : Dans `ShowLootReveal`, la rareté affichée est le nom enum brut anglais (`Common`, `Uncommon`, `Rare`), pas la version localisée. Incohérent avec l'inventaire qui est maintenant localisé.
**Solution** : Localiser la rareté dans `RenderEvents` avant de l'envoyer au loot reveal, ou dans `ShowLootReveal` directement.
## 2. Matériaux : afficher le nom de forme localisé — ★★★★★
**Constat** : Dans le panneau de détails des matériaux, on voit "Bronze (Ingot)" avec le nom de forme en anglais. Il devrait être localisé ("Lingot").
**Solution** : Ajouter des clés de localisation `material.form.raw`, `material.form.ingot`, `material.form.sheet`, etc.
## 3. Inventaire compact avec résumé catégories en footer — ★★★★☆
**Constat** : En mode compact (FullLayout), seuls 6 items sont visibles et on ne sait pas combien d'items de chaque catégorie on possède.
**Solution** : Ajouter une ligne de pied résumant les catégories : "📦3 🧪5 📜2 👗12 🔩8".
## 4. Multi-use consommables — ★★★★☆
**Constat** : Pour utiliser 5 potions de santé, il faut faire Entrée → message → touche → Entrée → message → touche × 5. C'est fastidieux.
**Solution** : Après avoir utilisé un consommable, si il en reste, revenir automatiquement à l'inventaire sans quitter. Le joueur peut continuer à appuyer sur Entrée ou naviguer ailleurs.
## 5. Panneau de détail Meta : expliquer le feature débloqué — ★★★★☆
**Constat** : Les items Meta ("Couleurs de texte", "Panneau de statistiques") n'ont aucune description dans le panneau de détails.
**Solution** : Ajouter des `descriptionKey` dans items.json pour tous les meta items et les localiser.
## 6. Panneau "???" localisé dans le FullLayout — ★★★☆☆
**Constat** : Les panneaux non encore débloqués dans le FullLayout affichent "[dim]???[/]" avec des headers anglais "Stats", "Resources", "Inventory".
**Solution** : Utiliser les clés de localisation existantes pour ces headers.
## 7. Compteur de boîtes dans l'en-tête du loot reveal — ★★★☆☆
**Constat** : La table de loot n'indique pas combien de boîtes le joueur a ouvertes au total.
**Solution** : Ajouter le compteur dans le titre du loot reveal.
## 8. Tronquer le nom "Potion de Santé Moyenne" — ★★★☆☆
**Constat** : Le nom "Potion de Santé Moyenne" déborde sur 2 lignes dans la table d'inventaire, ce qui casse l'alignement.
**Solution** : La troncature à MaxNameWidth existe déjà (24 chars) mais le nom fait exactement 24 chars et n'est pas tronqué. Vérifier/ajuster le padding avec l'indicateur "►" et le préfixe " ".
## 9. Indicateur visuel des ressources lors du loot — ★★☆☆☆
**Constat** : Quand un loot contient des consommables qui pourraient être utiles, le joueur ne voit pas l'état de ses ressources.
**Solution** : Optionnellement afficher un mini-résumé des ressources après le loot.
## 10. Feedback sonore pour items rares+ — ★★☆☆☆
**Constat** : Tous les items apparaissent de la même façon dans le loot, pas de feedback distinct pour les items rares.
**Solution** : Console.Beep optionnel pour les Rare+ sur Windows.

View file

@ -1,78 +0,0 @@
# Suggestions d'amélioration #4
Basées sur l'analyse des rendus après suggestions_2 et suggestions_3.
| # | Suggestion | Priorité | Statut |
|---|-----------|----------|--------|
| 1 | Localiser les noms de boîtes dans les événements du PlaythroughCapture | ★★★★★ | ✅ DONE |
| 2 | Tronquer les noms trop longs incluant le préfixe "► " (26 chars réels) | ★★★★★ | ✅ DONE |
| 3 | Panneau détails : ajouter un cas AdventureToken avec le nom d'aventure | ★★★★☆ | ✅ DONE |
| 4 | Afficher la quantité restante dans le message de consommation | ★★★★☆ | ✅ DONE |
| 5 | Localize the "Name" and "Qty" column headers in the inventory table | ★★★★☆ | ✅ DONE |
| 6 | Panneau "???" avec headers localisés dans FullLayout | ★★★☆☆ | |
| 7 | Afficher la progression globale dans le header de l'inventaire interactif | ★★★☆☆ | |
| 8 | Grouper les boîtes identiques dans le loot reveal (× N) | ★★☆☆☆ | |
| 9 | Ajouter un son de notification pour les lore fragments | ★★☆☆☆ | |
| 10 | Indicateur d'items actionnables dans la liste (★ à côté des consommables/lore) | ★★☆☆☆ | |
---
## 1. Localiser les noms de boîtes dans les événements du PlaythroughCapture — ★★★★★
**Constat** : Le test PlaythroughCapture affiche `Received [Common] "box_of_boxes"` au lieu du nom localisé. Le code de test utilise `ire.Item.DefinitionId` directement pour les boîtes au lieu de résoudre via le registre.
**Solution** : Utiliser le même pattern que `GetLocalizedName()` dans le test PlaythroughCapture.
## 2. Tronquer les noms incluant le préfixe "► " — ★★★★★
**Constat** : Le nom sélectionné a un préfixe "► " (2 chars de plus), mais la troncature se fait à MaxNameWidth (24) sur le nom seul. Avec le préfixe, le nom effectif est 26 chars, ce qui peut déborder de la colonne.
**Solution** : Ajuster la troncature pour tenir compte du préfixe dans la colonne, ou augmenter la largeur de la colonne Name.
## 3. Panneau détails AdventureToken — ★★★★☆
**Constat** : Les items AdventureToken (badge spatial, boussole pirate, etc.) n'ont pas d'information contextuelle dans le panneau de détails. L'aventure associée n'est pas mentionnée.
**Solution** : Ajouter un cas `AdventureToken` dans `RenderDetailPanel()` qui affiche le nom de l'aventure liée.
## 4. Afficher la quantité restante après consommation — ★★★★☆
**Constat** : Quand on consomme un item, le message dit juste "Ration alimentaire utilisé !". On ne sait pas combien il en reste.
**Solution** : Ajouter "× N restant(s)" au message de consommation dans HandleInventoryAction.
## 5. Localiser les en-têtes de colonnes de l'inventaire — ★★★★☆
**Constat** : Les colonnes "Name", "Rarity" et "Qty" sont en anglais dans la table d'inventaire.
**Solution** : Utiliser des clés de localisation `inventory.col.name`, `inventory.col.rarity`, `inventory.col.qty`.
## 6. Panneau "???" localisé dans FullLayout — ★★★☆☆
**Constat** : Les panneaux pas encore débloqués dans le FullLayout affichent un header anglais.
**Solution** : Localiser les headers "Stats", "Resources", "Inventory" dans les panels placeholder.
## 7. Progression globale dans l'inventaire interactif — ★★★☆☆
**Constat** : L'inventaire interactif ne montre pas de contexte sur la progression globale du joueur.
**Solution** : Optionnel — afficher le nombre de boîtes ouvertes ou le % de complétion dans le header.
## 8. Grouper les boîtes dans le loot reveal — ★★☆☆☆
**Constat** : Quand plusieurs boîtes identiques sont obtenues, elles apparaissent chacune sur une ligne séparée.
**Solution** : Grouper par nom et ajouter un "× N" pour les doublons.
## 9. Son pour les lore fragments — ★★☆☆☆
**Constat** : Recevoir un fragment de lore est un moment clé de l'expérience narrative mais il passe inaperçu.
**Solution** : Console.Beep sur Windows comme pour les Music items.
## 10. Indicateur d'items actionnables — ★★☆☆☆
**Constat** : Dans l'inventaire, rien ne distingue visuellement les items sur lesquels on peut agir (consommables, lore) des items passifs.
**Solution** : Ajouter un petit marqueur (★ ou ►) à côté des items actionnables.