1578 lines
61 KiB
C#
1578 lines
61 KiB
C#
using Microsoft.JSInterop;
|
|
using OpenTheBox.Adventures;
|
|
using OpenTheBox.Core;
|
|
using OpenTheBox.Core.Crafting;
|
|
using OpenTheBox.Core.Enums;
|
|
using OpenTheBox.Core.Items;
|
|
using OpenTheBox.Data;
|
|
using OpenTheBox.Localization;
|
|
using OpenTheBox.Rendering;
|
|
using OpenTheBox.Rendering.Panels;
|
|
using OpenTheBox.Simulation;
|
|
using OpenTheBox.Simulation.Actions;
|
|
using OpenTheBox.Simulation.Events;
|
|
using Spectre.Console;
|
|
using Spectre.Console.Rendering;
|
|
|
|
namespace OpenTheBox.Web;
|
|
|
|
/// <summary>
|
|
/// Async game host for the Blazor WASM build. Mirrors Program.cs but uses
|
|
/// WebTerminal for I/O instead of System.Console, and HttpClient for content loading.
|
|
/// </summary>
|
|
public sealed class WebGameHost
|
|
{
|
|
private readonly IJSRuntime _js;
|
|
private readonly HttpClient _http;
|
|
private WebTerminal _terminal = null!;
|
|
private WebSaveManager _saveManager = null!;
|
|
|
|
private GameState _state = null!;
|
|
private ContentRegistry _registry = null!;
|
|
private LocalizationManager _loc = null!;
|
|
private GameSimulation _simulation = null!;
|
|
private RenderContext _renderContext = null!;
|
|
private CraftingEngine _craftingEngine = null!;
|
|
private bool _appRunning = true;
|
|
private bool _gameRunning;
|
|
private DateTime _sessionStart;
|
|
|
|
// Pre-loaded content for adventures (theme -> (script, translation?))
|
|
private Dictionary<string, string> _adventureScripts = new();
|
|
private Dictionary<string, string> _adventureTranslations = new();
|
|
|
|
// Pre-loaded localization strings
|
|
private Dictionary<string, string> _locStrings = new();
|
|
|
|
public WebGameHost(IJSRuntime js, HttpClient http)
|
|
{
|
|
_js = js;
|
|
_http = http;
|
|
}
|
|
|
|
public async Task RunAsync()
|
|
{
|
|
UnicodeSupport.Initialize();
|
|
|
|
_terminal = new WebTerminal(_js);
|
|
_saveManager = new WebSaveManager(_js);
|
|
_loc = new LocalizationManager(Locale.EN);
|
|
|
|
// Pre-load all content
|
|
await LoadAllContentAsync();
|
|
|
|
_renderContext = new RenderContext();
|
|
|
|
// Initialize terminal
|
|
await _terminal.InitAsync();
|
|
|
|
await MainMenuLoop();
|
|
}
|
|
|
|
// ── Content loading ──────────────────────────────────────────────────
|
|
|
|
private async Task LoadAllContentAsync()
|
|
{
|
|
// Load localization strings
|
|
try
|
|
{
|
|
string enJson = await _http.GetStringAsync("content/strings/en.json");
|
|
_locStrings["en"] = enJson;
|
|
_loc.LoadFromString(Locale.EN, enJson);
|
|
}
|
|
catch (HttpRequestException) { }
|
|
|
|
try
|
|
{
|
|
string frJson = await _http.GetStringAsync("content/strings/fr.json");
|
|
_locStrings["fr"] = frJson;
|
|
}
|
|
catch (HttpRequestException) { }
|
|
|
|
// Pre-load adventure scripts
|
|
var themes = Enum.GetValues<AdventureTheme>();
|
|
foreach (var theme in themes)
|
|
{
|
|
string themeName = theme.ToString().ToLowerInvariant();
|
|
try
|
|
{
|
|
string script = await _http.GetStringAsync($"content/adventures/{themeName}/intro.lor");
|
|
_adventureScripts[themeName] = script;
|
|
}
|
|
catch (HttpRequestException) { }
|
|
|
|
try
|
|
{
|
|
string trans = await _http.GetStringAsync($"content/adventures/{themeName}/intro.fr.lor");
|
|
_adventureTranslations[themeName] = trans;
|
|
}
|
|
catch (HttpRequestException) { }
|
|
}
|
|
}
|
|
|
|
private async Task InitializeGameAsync()
|
|
{
|
|
string itemsJson = await _http.GetStringAsync("content/data/items.json");
|
|
string boxesJson = await _http.GetStringAsync("content/data/boxes.json");
|
|
string interactionsJson = await _http.GetStringAsync("content/data/interactions.json");
|
|
string recipesJson = await _http.GetStringAsync("content/data/recipes.json");
|
|
|
|
_registry = ContentRegistry.LoadFromStrings(itemsJson, boxesJson, interactionsJson, recipesJson);
|
|
_simulation = new GameSimulation(_registry);
|
|
_craftingEngine = new CraftingEngine();
|
|
_renderContext = RenderContext.FromGameState(_state);
|
|
}
|
|
|
|
private void ChangeLocale(Locale locale)
|
|
{
|
|
string key = locale.ToString().ToLowerInvariant();
|
|
if (_locStrings.TryGetValue(key, out string? json))
|
|
_loc.LoadFromString(locale, json);
|
|
else
|
|
_loc.LoadFromString(locale, "{}"); // no file I/O in WASM — use empty strings as fallback
|
|
}
|
|
|
|
// ── Main menu ────────────────────────────────────────────────────────
|
|
|
|
private async Task MainMenuLoop()
|
|
{
|
|
var existingSaves = await _saveManager.ListSlotsAsync();
|
|
|
|
if (existingSaves.Count > 0)
|
|
{
|
|
var recentState = await _saveManager.LoadAsync(existingSaves[0].Name);
|
|
if (recentState != null)
|
|
{
|
|
ChangeLocale(recentState.CurrentLocale);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
await _terminal.ClearAsync();
|
|
var langOptions = new List<string> { "English", "Français" };
|
|
int langChoice = await _terminal.ShowSelectionAsync("Language / Langue", langOptions, false);
|
|
var selectedLocale = langChoice == 0 ? Locale.EN : Locale.FR;
|
|
ChangeLocale(selectedLocale);
|
|
}
|
|
|
|
while (_appRunning)
|
|
{
|
|
await _terminal.ClearAsync();
|
|
await _terminal.WriteLineAsync("========================================");
|
|
await _terminal.WriteLineAsync(" OPEN THE BOX");
|
|
await _terminal.WriteLineAsync(" web");
|
|
await _terminal.WriteLineAsync("========================================");
|
|
await _terminal.WriteLineAsync("");
|
|
await _terminal.WriteLineAsync(_loc.Get("game.subtitle"));
|
|
await _terminal.WriteLineAsync("");
|
|
|
|
existingSaves = await _saveManager.ListSlotsAsync();
|
|
|
|
var options = new List<string>();
|
|
var actions = new List<string>();
|
|
|
|
if (existingSaves.Count > 0)
|
|
{
|
|
var recent = existingSaves[0];
|
|
var savedAt = recent.SavedAt.ToLocalTime();
|
|
options.Add($"{_loc.Get("menu.continue")} ({recent.Name} {savedAt:dd/MM/yyyy HH:mm})");
|
|
actions.Add("continue");
|
|
}
|
|
|
|
options.Add(_loc.Get("menu.new_game"));
|
|
actions.Add("new_game");
|
|
|
|
if (existingSaves.Count > 1)
|
|
{
|
|
options.Add(_loc.Get("menu.load_game"));
|
|
actions.Add("load_game");
|
|
}
|
|
|
|
options.Add(_loc.Get("menu.language"));
|
|
actions.Add("language");
|
|
|
|
int choice = await _terminal.ShowSelectionAsync("", options, _renderContext.HasArrowSelection);
|
|
|
|
switch (actions[choice])
|
|
{
|
|
case "continue":
|
|
await ContinueGame(existingSaves[0].Name);
|
|
break;
|
|
case "new_game":
|
|
await NewGame();
|
|
break;
|
|
case "load_game":
|
|
await LoadGame();
|
|
break;
|
|
case "language":
|
|
await ChangeLanguageAsync();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task ContinueGame(string slotName)
|
|
{
|
|
var loaded = await _saveManager.LoadAsync(slotName);
|
|
if (loaded == null)
|
|
{
|
|
await ShowErrorAsync("Failed to load save.");
|
|
await WaitForKeyAsync();
|
|
return;
|
|
}
|
|
|
|
_state = loaded;
|
|
ChangeLocale(_state.CurrentLocale);
|
|
await InitializeGameAsync();
|
|
|
|
await ShowAdaptiveWelcome();
|
|
await WaitForKeyAsync();
|
|
|
|
await GameLoop();
|
|
}
|
|
|
|
private async Task NewGame()
|
|
{
|
|
await _terminal.WriteAsync($"{_loc.Get("prompt.name")}: ");
|
|
string name = await _terminal.ReadLineAsync();
|
|
if (string.IsNullOrWhiteSpace(name)) name = "BoxOpener";
|
|
|
|
_state = GameState.Create(name, _loc.CurrentLocale);
|
|
await InitializeGameAsync();
|
|
|
|
var starterBox = ItemInstance.Create("box_starter");
|
|
_state.AddItem(starterBox);
|
|
|
|
await ShowMessageAsync("");
|
|
await ShowMessageAsync(_loc.Get("misc.welcome", name));
|
|
await ShowMessageAsync(_loc.Get("box.starter.desc"));
|
|
await WaitForKeyAsync();
|
|
|
|
await GameLoop();
|
|
}
|
|
|
|
private async Task LoadGame()
|
|
{
|
|
var slots = await _saveManager.ListSlotsAsync();
|
|
if (slots.Count == 0)
|
|
{
|
|
await ShowMessageAsync(_loc.Get("save.no_saves"));
|
|
await WaitForKeyAsync();
|
|
return;
|
|
}
|
|
|
|
var options = slots.Select(s => $"{s.Name} ({s.SavedAt:yyyy-MM-dd HH:mm})").ToList();
|
|
options.Add(_loc.Get("menu.back"));
|
|
|
|
int choice = await _terminal.ShowSelectionAsync(_loc.Get("save.choose_slot"), options,
|
|
_renderContext.HasArrowSelection);
|
|
if (choice >= slots.Count) return;
|
|
|
|
var loaded = await _saveManager.LoadAsync(slots[choice].Name);
|
|
if (loaded == null)
|
|
{
|
|
await ShowErrorAsync("Failed to load save.");
|
|
await WaitForKeyAsync();
|
|
return;
|
|
}
|
|
|
|
_state = loaded;
|
|
ChangeLocale(_state.CurrentLocale);
|
|
await InitializeGameAsync();
|
|
|
|
await ShowAdaptiveWelcome();
|
|
await WaitForKeyAsync();
|
|
|
|
await GameLoop();
|
|
}
|
|
|
|
private async Task 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)
|
|
};
|
|
await ShowMessageAsync(message);
|
|
}
|
|
|
|
private async Task ChangeLanguageAsync()
|
|
{
|
|
var options = new List<string> { "English", "Francais" };
|
|
int choice = await _terminal.ShowSelectionAsync(_loc.Get("menu.language"), options,
|
|
_renderContext.HasArrowSelection);
|
|
|
|
var newLocale = choice == 0 ? Locale.EN : Locale.FR;
|
|
ChangeLocale(newLocale);
|
|
|
|
if (_state != null)
|
|
_state.CurrentLocale = newLocale;
|
|
}
|
|
|
|
// ── Game loop ────────────────────────────────────────────────────────
|
|
|
|
private async Task GameLoop()
|
|
{
|
|
_sessionStart = DateTime.UtcNow;
|
|
_gameRunning = true;
|
|
|
|
while (_gameRunning)
|
|
{
|
|
_state.TotalPlayTime += DateTime.UtcNow - _sessionStart;
|
|
_sessionStart = DateTime.UtcNow;
|
|
|
|
if (_state.HasUIFeature(UIFeature.AutoSave))
|
|
await _saveManager.SaveAsync(_state, _state.PlayerName);
|
|
|
|
TickCraftingJobs();
|
|
|
|
if (_renderContext.HasFullLayout)
|
|
await _terminal.ClearAsync();
|
|
|
|
UpdateCompletionPercent();
|
|
await ShowGameStateAsync();
|
|
|
|
var actions = BuildActionList();
|
|
if (actions.Count == 0)
|
|
{
|
|
await ShowMessageAsync(_loc.Get("error.no_boxes"));
|
|
await WaitForKeyAsync();
|
|
break;
|
|
}
|
|
|
|
int choice = await _terminal.ShowSelectionAsync(
|
|
_loc.Get("prompt.choose_action"),
|
|
actions.Select(a => a.label).ToList(),
|
|
_renderContext.HasArrowSelection);
|
|
|
|
await ExecuteAction(actions[choice].action);
|
|
}
|
|
}
|
|
|
|
private List<(string label, string action)> BuildActionList()
|
|
{
|
|
var actions = new List<(string label, string action)>();
|
|
|
|
var boxes = _state.Inventory.Where(i => _registry.IsBox(i.DefinitionId)).ToList();
|
|
if (boxes.Count > 0)
|
|
actions.Add((_loc.Get("action.open_box") + $" ({boxes.Count})", "open_box"));
|
|
|
|
if (_state.Inventory.Count > 0)
|
|
actions.Add((_loc.Get("action.inventory"), "inventory"));
|
|
|
|
if (_state.UnlockedAdventures.Count > 0)
|
|
{
|
|
string adventureLabel = _loc.Get("action.adventure");
|
|
if (_state.CompletedAdventures.Count == 0)
|
|
adventureLabel = $"({_loc.Get("action.new")}) {adventureLabel}";
|
|
actions.Add((adventureLabel, "adventure"));
|
|
}
|
|
|
|
if (_state.UnlockedCosmetics.Count > 0)
|
|
actions.Add((_loc.Get("action.appearance"), "appearance"));
|
|
|
|
var completedJobs = _state.ActiveCraftingJobs.Where(j => j.IsComplete).ToList();
|
|
if (completedJobs.Count > 0)
|
|
actions.Add((_loc.Get("action.collect_crafting") + $" ({completedJobs.Count})", "collect_crafting"));
|
|
|
|
if (!_state.HasUIFeature(UIFeature.AutoSave))
|
|
actions.Add((_loc.Get("action.save"), "save"));
|
|
|
|
actions.Add((_loc.Get("action.quit"), "quit"));
|
|
|
|
return actions;
|
|
}
|
|
|
|
private async Task ExecuteAction(string action)
|
|
{
|
|
switch (action)
|
|
{
|
|
case "open_box": await OpenBoxAction(); break;
|
|
case "inventory": await ShowInventory(); break;
|
|
case "adventure": await StartAdventure(); break;
|
|
case "appearance": await ChangeAppearance(); break;
|
|
case "collect_crafting": await CollectCrafting(); break;
|
|
case "save": await SaveGame(); break;
|
|
case "quit": _gameRunning = false; break;
|
|
}
|
|
}
|
|
|
|
// ── Box opening ──────────────────────────────────────────────────────
|
|
|
|
private async Task OpenBoxAction()
|
|
{
|
|
var boxes = _state.Inventory.Where(i => _registry.IsBox(i.DefinitionId)).ToList();
|
|
if (boxes.Count == 0)
|
|
{
|
|
await ShowMessageAsync(_loc.Get("box.no_boxes"));
|
|
return;
|
|
}
|
|
|
|
var grouped = boxes.GroupBy(b => b.DefinitionId).ToList();
|
|
var boxNames = grouped.Select(g =>
|
|
{
|
|
var name = GetLocalizedName(g.Key);
|
|
return g.Count() > 1 ? $"{name} (x{g.Count()})" : name;
|
|
}).ToList();
|
|
boxNames.Add(_loc.Get("menu.back"));
|
|
|
|
int choice = await _terminal.ShowSelectionAsync(_loc.Get("prompt.choose_box"), boxNames,
|
|
_renderContext.HasArrowSelection);
|
|
if (choice >= grouped.Count) return;
|
|
|
|
var boxInstance = grouped[choice].First();
|
|
|
|
var openAction = new OpenBoxAction(boxInstance.Id)
|
|
{
|
|
BoxDefinitionId = boxInstance.DefinitionId
|
|
};
|
|
var events = _simulation.ProcessAction(openAction, _state);
|
|
|
|
await RenderEvents(events);
|
|
}
|
|
|
|
// ── Render events ────────────────────────────────────────────────────
|
|
|
|
private async Task RenderEvents(List<GameEvent> events)
|
|
{
|
|
var autoConsumedIds = events.OfType<ItemConsumedEvent>().Select(e => e.InstanceId).ToHashSet();
|
|
var allLoot = new List<(string name, string rarity, string category)>();
|
|
var deferredMessages = new List<string>();
|
|
var consumedItemNames = new Dictionary<Guid, string>();
|
|
bool primaryBoxShown = false;
|
|
|
|
foreach (var evt in events)
|
|
{
|
|
switch (evt)
|
|
{
|
|
case BoxOpenedEvent boxEvt:
|
|
var boxDef = _registry.GetBox(boxEvt.BoxId);
|
|
var boxName = _loc.Get(boxDef?.NameKey ?? boxEvt.BoxId);
|
|
if (!boxEvt.IsAutoOpen && !primaryBoxShown)
|
|
{
|
|
await ShowBoxOpening(boxName, boxDef?.Rarity.ToString() ?? "Common");
|
|
primaryBoxShown = true;
|
|
}
|
|
break;
|
|
|
|
case ItemReceivedEvent itemEvt:
|
|
if (autoConsumedIds.Contains(itemEvt.Item.Id))
|
|
break;
|
|
var itemDef = _registry.GetItem(itemEvt.Item.DefinitionId);
|
|
var itemBoxDef = itemDef is null ? _registry.GetBox(itemEvt.Item.DefinitionId) : null;
|
|
allLoot.Add((
|
|
GetLocalizedName(itemEvt.Item.DefinitionId),
|
|
(itemDef?.Rarity ?? itemBoxDef?.Rarity ?? ItemRarity.Common).ToString(),
|
|
(itemDef?.Category ?? ItemCategory.Box).ToString()
|
|
));
|
|
break;
|
|
|
|
case UIFeatureUnlockedEvent uiEvt:
|
|
_renderContext.Unlock(uiEvt.Feature);
|
|
var featureLabel = _loc.Get(GetUIFeatureLocKey(uiEvt.Feature));
|
|
await ShowUIFeatureUnlocked(featureLabel);
|
|
await WaitForKeyAsync();
|
|
break;
|
|
|
|
case ItemConsumedEvent consumedEvt:
|
|
consumedItemNames[consumedEvt.InstanceId] = GetLocalizedName(
|
|
_state.Inventory.FirstOrDefault(i => i.Id == consumedEvt.InstanceId)?.DefinitionId
|
|
?? events.OfType<ItemReceivedEvent>()
|
|
.FirstOrDefault(r => r.Item.Id == consumedEvt.InstanceId)?.Item.DefinitionId
|
|
?? "?");
|
|
break;
|
|
|
|
case InteractionTriggeredEvent interEvt:
|
|
string interMsg;
|
|
if (interEvt.TriggerItemId is not null && interEvt.PartnerItemId is not null)
|
|
{
|
|
var triggerName = GetLocalizedName(interEvt.TriggerItemId);
|
|
var partnerName = GetLocalizedName(interEvt.PartnerItemId);
|
|
interMsg = $"{triggerName} + {partnerName}: {_loc.Get(interEvt.DescriptionKey)}";
|
|
}
|
|
else
|
|
{
|
|
interMsg = _loc.Get(interEvt.DescriptionKey);
|
|
}
|
|
deferredMessages.Add(interMsg);
|
|
break;
|
|
|
|
case AdventureStartedEvent advEvt:
|
|
await ShowMessageAsync(_loc.Get("adventure.new_unlocked", GetAdventureName(advEvt.Theme)));
|
|
break;
|
|
|
|
case MusicPlayedEvent:
|
|
await ShowMessageAsync(_loc.Get("box.music.desc"));
|
|
break;
|
|
|
|
case CookieFortuneEvent cookieEvt:
|
|
await ShowMessageAsync("--- Fortune Cookie ---");
|
|
await ShowMessageAsync(_loc.Get(cookieEvt.MessageKey));
|
|
await ShowMessageAsync("----------------------");
|
|
break;
|
|
|
|
case CraftingStartedEvent craftEvt:
|
|
var recipeName = _registry.Recipes.TryGetValue(craftEvt.RecipeId, out var recDef)
|
|
? _loc.Get(recDef.NameKey) : craftEvt.RecipeId;
|
|
await ShowMessageAsync(_loc.Get("craft.started", recipeName, _loc.Get($"workstation.{craftEvt.Workstation}")));
|
|
break;
|
|
|
|
case CraftingCompletedEvent craftDoneEvt:
|
|
await ShowMessageAsync(_loc.Get("craft.completed", craftDoneEvt.Workstation.ToString()));
|
|
break;
|
|
|
|
case CraftingCollectedEvent:
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (allLoot.Count > 0)
|
|
await ShowLootReveal(allLoot);
|
|
|
|
foreach (var msg in deferredMessages)
|
|
{
|
|
await ShowMessageAsync("");
|
|
await ShowInteraction(msg);
|
|
}
|
|
|
|
await WaitForKeyAsync();
|
|
}
|
|
|
|
// ── Inventory ────────────────────────────────────────────────────────
|
|
|
|
private async Task ShowInventory()
|
|
{
|
|
if (_state.Inventory.Count == 0)
|
|
{
|
|
await ShowMessageAsync(_loc.Get("inventory.empty"));
|
|
await WaitForKeyAsync();
|
|
return;
|
|
}
|
|
|
|
if (!_renderContext.HasInventoryPanel)
|
|
{
|
|
await ShowRawInventory();
|
|
return;
|
|
}
|
|
|
|
var grouped = InventoryPanel.GetGroupedItems(_state, _registry);
|
|
int totalItems = grouped.Count;
|
|
int maxVisible = InventoryPanel.MaxVisibleRows;
|
|
int scrollOffset = 0;
|
|
int selectedIndex = 0;
|
|
int previousRenderedLines = 0;
|
|
|
|
await _terminal.ClearAsync();
|
|
|
|
while (true)
|
|
{
|
|
grouped = InventoryPanel.GetGroupedItems(_state, _registry);
|
|
totalItems = grouped.Count;
|
|
if (totalItems == 0) return;
|
|
int maxOffset = Math.Max(0, totalItems - maxVisible);
|
|
|
|
selectedIndex = Math.Clamp(selectedIndex, 0, totalItems - 1);
|
|
scrollOffset = Math.Clamp(scrollOffset, 0, maxOffset);
|
|
|
|
if (selectedIndex < scrollOffset)
|
|
scrollOffset = selectedIndex;
|
|
else if (selectedIndex >= scrollOffset + maxVisible)
|
|
scrollOffset = selectedIndex - maxVisible + 1;
|
|
|
|
// Render to buffer using Spectre off-screen
|
|
var writer = new StringWriter();
|
|
var bufferConsole = AnsiConsole.Create(new AnsiConsoleSettings
|
|
{
|
|
Out = new AnsiConsoleOutput(writer),
|
|
Ansi = AnsiSupport.Yes,
|
|
ColorSystem = ColorSystemSupport.TrueColor
|
|
});
|
|
bufferConsole.Profile.Width = WebTerminal.Width;
|
|
bufferConsole.Profile.Height = WebTerminal.Height;
|
|
|
|
bufferConsole.Write(InventoryPanel.Render(_state, _registry, _loc, scrollOffset, selectedIndex: selectedIndex));
|
|
|
|
var selectedGroup = grouped[selectedIndex];
|
|
var detailPanel = InventoryPanel.RenderDetailPanel(selectedGroup, _registry, _loc, _state);
|
|
if (detailPanel is not null)
|
|
bufferConsole.Write(detailPanel);
|
|
|
|
if (_renderContext.HasArrowSelection)
|
|
{
|
|
bool isUsable = selectedGroup.Category is ItemCategory.Consumable
|
|
&& selectedGroup.Def?.ResourceType is not null;
|
|
bool isLore = selectedGroup.Category is ItemCategory.LoreFragment;
|
|
string controls = isUsable
|
|
? _loc.Get("inventory.controls_use")
|
|
: isLore
|
|
? _loc.Get("inventory.controls_lore")
|
|
: _loc.Get("inventory.controls");
|
|
bufferConsole.MarkupLine($"[dim]{Markup.Escape(controls)}[/]");
|
|
}
|
|
|
|
string rendered = writer.ToString().Replace("\n", "\r\n");
|
|
int renderedLines = rendered.Count(c => c == '\n');
|
|
|
|
// Overwrite in place: move cursor up, write new content over old lines,
|
|
// then clear any leftover lines if new render is shorter. No clear-before-write = no flicker.
|
|
if (previousRenderedLines > 0)
|
|
await _terminal.WriteAsync($"\x1b[{previousRenderedLines}A\r");
|
|
await _terminal.WriteAsync(rendered);
|
|
if (previousRenderedLines > renderedLines)
|
|
await _terminal.WriteAsync("\x1b[J");
|
|
previousRenderedLines = renderedLines;
|
|
|
|
var key = await _terminal.ReadKeyAsync();
|
|
switch (key.Key)
|
|
{
|
|
case ConsoleKey.UpArrow:
|
|
selectedIndex = Math.Max(0, selectedIndex - 1);
|
|
break;
|
|
case ConsoleKey.DownArrow:
|
|
selectedIndex = Math.Min(totalItems - 1, selectedIndex + 1);
|
|
break;
|
|
case ConsoleKey.PageUp:
|
|
selectedIndex = Math.Max(0, selectedIndex - maxVisible);
|
|
break;
|
|
case ConsoleKey.PageDown:
|
|
selectedIndex = Math.Min(totalItems - 1, selectedIndex + maxVisible);
|
|
break;
|
|
case ConsoleKey.Enter:
|
|
await HandleInventoryAction(selectedGroup);
|
|
break;
|
|
case ConsoleKey.Escape:
|
|
case ConsoleKey.Q:
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task ShowRawInventory()
|
|
{
|
|
await ShowMessageAsync($"--- {_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);
|
|
await ShowMessageAsync(qty > 1 ? $" {name} (x{qty})" : $" {name}");
|
|
}
|
|
await ShowMessageAsync("");
|
|
await WaitForKeyAsync();
|
|
}
|
|
|
|
private async Task HandleInventoryAction(InventoryGroup item)
|
|
{
|
|
if (item.Def is null) return;
|
|
|
|
switch (item.Category)
|
|
{
|
|
case ItemCategory.Consumable when item.Def.ResourceType.HasValue:
|
|
var events = _simulation.ProcessAction(
|
|
new UseItemAction(item.FirstInstance.Id), _state);
|
|
foreach (var evt in events)
|
|
{
|
|
switch (evt)
|
|
{
|
|
case ResourceChangedEvent resEvt:
|
|
var resName = _loc.Get($"resource.{resEvt.Type.ToString().ToLower()}");
|
|
string itemName = _loc.Get(item.Def.NameKey);
|
|
int remaining = _state.Inventory
|
|
.Where(i => i.DefinitionId == item.DefId).Sum(i => i.Quantity);
|
|
string usedMsg = remaining > 0
|
|
? _loc.Get("inventory.item_used_qty", itemName, remaining.ToString())
|
|
: _loc.Get("inventory.item_used", itemName);
|
|
await ShowMessageAsync(usedMsg);
|
|
await ShowMessageAsync($"{resName}: {resEvt.OldValue} {UnicodeSupport.Arrow} {resEvt.NewValue}");
|
|
break;
|
|
case MessageEvent msgEvt:
|
|
await ShowMessageAsync(_loc.Get(msgEvt.MessageKey, msgEvt.Args ?? []));
|
|
break;
|
|
}
|
|
}
|
|
break;
|
|
|
|
case ItemCategory.Cookie:
|
|
var cookieEvents = _simulation.ProcessAction(
|
|
new UseItemAction(item.FirstInstance.Id), _state);
|
|
foreach (var evt in cookieEvents)
|
|
{
|
|
switch (evt)
|
|
{
|
|
case CookieFortuneEvent cookieEvt:
|
|
await ShowMessageAsync("--- Fortune Cookie ---");
|
|
await ShowMessageAsync(_loc.Get(cookieEvt.MessageKey));
|
|
await ShowMessageAsync("----------------------");
|
|
break;
|
|
case ResourceChangedEvent resEvt:
|
|
var cookieResName = _loc.Get($"resource.{resEvt.Type.ToString().ToLower()}");
|
|
await ShowMessageAsync($"{cookieResName}: {resEvt.OldValue} {UnicodeSupport.Arrow} {resEvt.NewValue}");
|
|
break;
|
|
case MessageEvent msgEvt:
|
|
await ShowMessageAsync(_loc.Get(msgEvt.MessageKey, msgEvt.Args ?? []));
|
|
break;
|
|
}
|
|
}
|
|
break;
|
|
|
|
case ItemCategory.LoreFragment:
|
|
await ShowLoreFragment(item);
|
|
break;
|
|
}
|
|
}
|
|
|
|
private async Task ShowLoreFragment(InventoryGroup item)
|
|
{
|
|
await _terminal.ClearAsync();
|
|
string name = _loc.Get(item.Def!.NameKey);
|
|
string loreKey = $"lore.fragment_{item.DefId.Replace("lore_", "")}";
|
|
string loreText = _loc.Get(loreKey);
|
|
|
|
var panel = new Panel($"[italic]{Markup.Escape(loreText)}[/]")
|
|
.Header($"[bold yellow]{Markup.Escape(name)}[/]")
|
|
.Border(BoxBorder.Double)
|
|
.BorderStyle(new Style(Color.Yellow))
|
|
.Padding(2, 1)
|
|
.Expand();
|
|
|
|
await _terminal.WriteRenderableAsync(panel);
|
|
await WaitForKeyAsync();
|
|
}
|
|
|
|
// ── Adventures ───────────────────────────────────────────────────────
|
|
|
|
private async Task StartAdventure()
|
|
{
|
|
var available = _state.UnlockedAdventures.ToList();
|
|
if (available.Count == 0)
|
|
{
|
|
await ShowMessageAsync(_loc.Get("adventure.none_available"));
|
|
await WaitForKeyAsync();
|
|
return;
|
|
}
|
|
|
|
var options = available.Select(a =>
|
|
{
|
|
bool completed = _state.CompletedAdventures.Contains(a.ToString());
|
|
string prefix = completed ? $"[{_loc.Get("adventure.done")}] " : "";
|
|
return prefix + GetAdventureName(a);
|
|
}).ToList();
|
|
options.Add(_loc.Get("menu.back"));
|
|
|
|
int choice = await _terminal.ShowSelectionAsync(_loc.Get("action.adventure"), options,
|
|
_renderContext.HasArrowSelection);
|
|
if (choice >= available.Count) return;
|
|
|
|
await RunAdventure(available[choice]);
|
|
}
|
|
|
|
private async Task RunAdventure(AdventureTheme theme)
|
|
{
|
|
try
|
|
{
|
|
string themeName = theme.ToString().ToLowerInvariant();
|
|
|
|
if (!_adventureScripts.TryGetValue(themeName, out string? scriptContent))
|
|
{
|
|
await ShowMessageAsync(_loc.Get("adventure.coming_soon", GetAdventureName(theme)));
|
|
await WaitForKeyAsync();
|
|
return;
|
|
}
|
|
|
|
string? translationContent = null;
|
|
if (_loc.CurrentLocale != Locale.EN)
|
|
{
|
|
_adventureTranslations.TryGetValue(themeName, out translationContent);
|
|
}
|
|
|
|
var events = await PlayAdventureWasmAsync(theme, scriptContent, translationContent);
|
|
|
|
foreach (var evt in events)
|
|
{
|
|
if (evt.Kind == GameEventKind.ItemGranted)
|
|
_state.AddItem(ItemInstance.Create(evt.TargetId, evt.Amount));
|
|
}
|
|
|
|
await ShowMessageAsync(_loc.Get("adventure.completed"));
|
|
|
|
if (theme == AdventureTheme.Destiny)
|
|
{
|
|
await WaitForKeyAsync();
|
|
await ShowMessageAsync(_loc.Get("destiny.epilogue"));
|
|
var endOptions = new List<string>
|
|
{
|
|
_loc.Get("destiny.continue"),
|
|
_loc.Get("destiny.quit")
|
|
};
|
|
int endChoice = await _terminal.ShowSelectionAsync("", endOptions,
|
|
_renderContext.HasArrowSelection);
|
|
if (endChoice == 1)
|
|
{
|
|
await ShowMessageAsync(_loc.Get("destiny.thanks"));
|
|
await WaitForKeyAsync();
|
|
_gameRunning = false;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
await ShowErrorAsync($"Adventure error: {ex.Message}");
|
|
}
|
|
await WaitForKeyAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// WASM-compatible adventure playback. Uses a queue-based pattern to bridge
|
|
/// Loreline's synchronous callbacks with async terminal I/O.
|
|
/// Loreline handlers store their continuation and signal the async loop,
|
|
/// which processes the action (render, wait for input) then resumes Loreline.
|
|
/// </summary>
|
|
private async Task<List<AdventureEvent>> PlayAdventureWasmAsync(
|
|
AdventureTheme theme, string scriptContent, string? translationContent)
|
|
{
|
|
var events = new List<AdventureEvent>();
|
|
string themeName = theme.ToString().ToLowerInvariant();
|
|
string adventureId = $"{themeName}/intro";
|
|
|
|
// Parse script
|
|
var script = Loreline.Engine.Parse(
|
|
scriptContent,
|
|
$"{themeName}/intro.lor",
|
|
(path, callback) => callback(string.Empty));
|
|
|
|
if (script is null)
|
|
{
|
|
await ShowErrorAsync("Failed to parse adventure script.");
|
|
return events;
|
|
}
|
|
|
|
// Build interpreter options
|
|
var options = Loreline.Interpreter.InterpreterOptions.Default();
|
|
|
|
// Build custom functions using AdventureEngine's public method.
|
|
// We use a dummy renderer that does nothing for ShowMessage calls inside custom functions
|
|
// (events are tracked in the events list and displayed elsewhere).
|
|
var dummyRenderer = new NoOpRenderer();
|
|
var engineHelper = new AdventureEngine(dummyRenderer, _loc);
|
|
options.Functions = engineHelper.BuildCustomFunctions(_state, events);
|
|
|
|
if (translationContent is not null)
|
|
{
|
|
var translationScript = Loreline.Engine.Parse(translationContent);
|
|
if (translationScript is not null)
|
|
options.Translations = Loreline.Engine.ExtractTranslations(translationScript);
|
|
}
|
|
|
|
// Queue-based async bridge for Loreline callbacks
|
|
// Each handler stores its data + continuation, then signals the loop
|
|
Action? pendingContinuation = null;
|
|
string? pendingDialogueChar = null;
|
|
string? pendingDialogueText = null;
|
|
List<string>? pendingChoiceOptions = null;
|
|
List<bool>? pendingChoiceEnabled = null;
|
|
List<string?>? pendingChoiceHints = null;
|
|
Action<int>? pendingChoiceCallback = null;
|
|
bool finished = false;
|
|
var actionReady = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
// Dialogue handler: store callback, DON'T call it — let the async loop handle it
|
|
void HandleDialogue(Loreline.Interpreter.Dialogue dialogue)
|
|
{
|
|
string? displayName = dialogue.Character;
|
|
if (displayName is not null)
|
|
{
|
|
string characterName = dialogue.Interpreter.GetCharacterField(displayName, "name") as string
|
|
?? displayName;
|
|
string key = $"character.{characterName.ToLowerInvariant().Replace(" ", "_").Replace("'", "")}";
|
|
string localized = _loc.Get(key);
|
|
displayName = !localized.StartsWith("[MISSING:") ? localized : characterName;
|
|
}
|
|
|
|
pendingDialogueChar = displayName;
|
|
pendingDialogueText = dialogue.Text;
|
|
pendingContinuation = () => dialogue.Callback();
|
|
actionReady.TrySetResult();
|
|
}
|
|
|
|
// Choice handler: store options + callback, DON'T call it
|
|
void HandleChoice(Loreline.Interpreter.Choice choice)
|
|
{
|
|
var opts = new List<string>();
|
|
var enabled = new List<bool>();
|
|
var hints = new List<string?>();
|
|
|
|
foreach (var opt in choice.Options)
|
|
{
|
|
var (text, hint) = HintSeparator.Parse(opt.Text);
|
|
if (opt.Enabled)
|
|
{
|
|
opts.Add(text);
|
|
enabled.Add(true);
|
|
hints.Add(null);
|
|
}
|
|
else
|
|
{
|
|
string prefix = hint ?? _loc.Get("adventure.unavailable");
|
|
opts.Add($"({prefix}) {text}");
|
|
enabled.Add(false);
|
|
hints.Add(hint);
|
|
}
|
|
}
|
|
|
|
pendingChoiceOptions = opts;
|
|
pendingChoiceEnabled = enabled;
|
|
pendingChoiceHints = hints;
|
|
pendingChoiceCallback = idx => choice.Callback(idx);
|
|
pendingContinuation = null; // signal that this is a choice, not a dialogue
|
|
actionReady.TrySetResult();
|
|
}
|
|
|
|
void HandleFinish(Loreline.Interpreter.Finish _)
|
|
{
|
|
finished = true;
|
|
actionReady.TrySetResult();
|
|
}
|
|
|
|
// Check for saved progress
|
|
bool hasSave = _state.AdventureSaveData.TryGetValue(adventureId, out string? saveData)
|
|
&& !string.IsNullOrEmpty(saveData);
|
|
|
|
Loreline.Interpreter interpreter;
|
|
if (hasSave)
|
|
{
|
|
interpreter = Loreline.Engine.Resume(
|
|
script, HandleDialogue, HandleChoice, HandleFinish,
|
|
saveData!, options: options);
|
|
}
|
|
else
|
|
{
|
|
interpreter = Loreline.Engine.Play(
|
|
script, HandleDialogue, HandleChoice, HandleFinish,
|
|
options: options);
|
|
}
|
|
|
|
// Async loop: process queued actions until the adventure finishes
|
|
while (!finished)
|
|
{
|
|
await actionReady.Task;
|
|
actionReady = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
if (finished) break;
|
|
|
|
if (pendingChoiceCallback is not null)
|
|
{
|
|
// Choice action: show options, get selection, resume Loreline
|
|
var choiceOpts = pendingChoiceOptions!;
|
|
var choiceEnabled = pendingChoiceEnabled!;
|
|
var choiceHints = pendingChoiceHints!;
|
|
var choiceCb = pendingChoiceCallback;
|
|
pendingChoiceCallback = null;
|
|
pendingChoiceOptions = null;
|
|
pendingChoiceEnabled = null;
|
|
pendingChoiceHints = null;
|
|
|
|
int selectedIndex;
|
|
while (true)
|
|
{
|
|
selectedIndex = await _terminal.ShowSelectionAsync(
|
|
"", choiceOpts, _renderContext.HasArrowSelection);
|
|
|
|
if (choiceEnabled[selectedIndex])
|
|
break;
|
|
|
|
if (choiceHints[selectedIndex] is { } hintText)
|
|
await _terminal.WriteMarkupLineAsync($"[dim italic]{Spectre.Console.Markup.Escape(hintText)}[/]");
|
|
|
|
await ShowErrorAsync(_loc.Get("adventure.unavailable"));
|
|
}
|
|
|
|
choiceCb(selectedIndex);
|
|
}
|
|
else if (pendingContinuation is not null)
|
|
{
|
|
// Dialogue action: show text, wait for key, resume Loreline
|
|
await ShowAdventureDialogueAsync(pendingDialogueChar, pendingDialogueText!);
|
|
await WaitForKeyAsync();
|
|
|
|
var cont = pendingContinuation;
|
|
pendingContinuation = null;
|
|
pendingDialogueChar = null;
|
|
pendingDialogueText = null;
|
|
|
|
cont();
|
|
}
|
|
}
|
|
|
|
// Adventure completed
|
|
_state.AdventureSaveData.Remove(adventureId);
|
|
_state.CompletedAdventures.Add(theme.ToString());
|
|
|
|
return events;
|
|
}
|
|
|
|
// ── Appearance ───────────────────────────────────────────────────────
|
|
|
|
private async Task ChangeAppearance()
|
|
{
|
|
var cosmeticItems = _state.Inventory
|
|
.Where(i =>
|
|
{
|
|
var def = _registry.GetItem(i.DefinitionId);
|
|
return def?.Category == ItemCategory.Cosmetic && def.CosmeticSlot.HasValue;
|
|
})
|
|
.GroupBy(i => i.DefinitionId)
|
|
.Select(g => g.First())
|
|
.ToList();
|
|
|
|
if (cosmeticItems.Count == 0)
|
|
{
|
|
await ShowMessageAsync(_loc.Get("cosmetic.no_cosmetics"));
|
|
await WaitForKeyAsync();
|
|
return;
|
|
}
|
|
|
|
var options = cosmeticItems.Select(i =>
|
|
{
|
|
var def = _registry.GetItem(i.DefinitionId);
|
|
var slotKey = $"cosmetic.slot.{def?.CosmeticSlot?.ToString().ToLower()}";
|
|
var slotName = _loc.Get(slotKey);
|
|
return $"[{slotName}] {GetLocalizedName(i.DefinitionId)}";
|
|
}).ToList();
|
|
options.Add(_loc.Get("menu.back"));
|
|
|
|
int choice = await _terminal.ShowSelectionAsync(_loc.Get("action.appearance"), options,
|
|
_renderContext.HasArrowSelection);
|
|
if (choice >= cosmeticItems.Count) return;
|
|
|
|
var action = new EquipCosmeticAction(cosmeticItems[choice].Id);
|
|
var events = _simulation.ProcessAction(action, _state);
|
|
foreach (var evt in events)
|
|
{
|
|
if (evt is CosmeticEquippedEvent cosEvt)
|
|
{
|
|
var slotKey = $"cosmetic.slot.{cosEvt.Slot.ToString().ToLower()}";
|
|
var slotName = _loc.Get(slotKey);
|
|
var cosmeticDef = _registry.Items.Values.FirstOrDefault(
|
|
d => d.CosmeticSlot == cosEvt.Slot &&
|
|
string.Equals(d.CosmeticValue, cosEvt.NewValue, StringComparison.OrdinalIgnoreCase));
|
|
var valueName = cosmeticDef is not null
|
|
? _loc.Get(cosmeticDef.NameKey)
|
|
: cosEvt.NewValue;
|
|
await ShowMessageAsync(_loc.Get("cosmetic.equipped", slotName, valueName));
|
|
}
|
|
else if (evt is MessageEvent msgEvt)
|
|
await ShowMessageAsync(_loc.Get(msgEvt.MessageKey, msgEvt.Args ?? []));
|
|
}
|
|
await WaitForKeyAsync();
|
|
}
|
|
|
|
// ── Crafting ─────────────────────────────────────────────────────────
|
|
|
|
private void TickCraftingJobs()
|
|
{
|
|
_craftingEngine?.TickJobs(_state);
|
|
}
|
|
|
|
private async Task CollectCrafting()
|
|
{
|
|
var events = _craftingEngine.CollectCompleted(_state, _registry);
|
|
|
|
var newItems = events.OfType<ItemReceivedEvent>().Select(e => e.Item).ToList();
|
|
var metaEngine = new MetaEngine();
|
|
events.AddRange(metaEngine.ProcessNewItems(newItems, _state, _registry));
|
|
|
|
events.AddRange(_craftingEngine.AutoCraftCheck(_state, _registry));
|
|
|
|
await RenderEvents(events);
|
|
}
|
|
|
|
private async Task SaveGame()
|
|
{
|
|
await ShowMessageAsync(_loc.Get("save.saving"));
|
|
await _saveManager.SaveAsync(_state, _state.PlayerName);
|
|
await ShowMessageAsync(_loc.Get("save.saved", _state.PlayerName));
|
|
await WaitForKeyAsync();
|
|
}
|
|
|
|
// ── Rendering helpers ────────────────────────────────────────────────
|
|
|
|
private async Task ShowGameStateAsync()
|
|
{
|
|
if (_renderContext.HasFullLayout)
|
|
{
|
|
await RenderFullLayout();
|
|
}
|
|
else
|
|
{
|
|
await RenderSequentialPanels();
|
|
}
|
|
}
|
|
|
|
private async Task RenderFullLayout()
|
|
{
|
|
// Top row: Stats (30) | Resources (30) | Completion (60) — total 120
|
|
var topRow = new Table().NoBorder().HideHeaders().Expand();
|
|
topRow.AddColumn(new TableColumn("c1").Width(30));
|
|
topRow.AddColumn(new TableColumn("c2").Width(30));
|
|
topRow.AddColumn(new TableColumn("c3").Width(60));
|
|
|
|
IRenderable statsPanel = _renderContext.HasStatsPanel
|
|
? StatsPanel.Render(_state, _loc)
|
|
: new Panel($"[dim italic]{Markup.Escape(_loc.Get("panel.locked.stats"))}[/]").Header("Stats").Expand();
|
|
|
|
IRenderable resourcePanel = _renderContext.HasResourcePanel
|
|
? ResourcePanel.Render(_state, _loc, barWidth: 8)
|
|
: new Panel($"[dim italic]{Markup.Escape(_loc.Get("panel.locked.resources"))}[/]")
|
|
.Header(Markup.Escape(_loc.Get("resource.title"))).Expand();
|
|
|
|
var completionItems = new List<IRenderable>();
|
|
if (_renderContext.HasCompletionTracker)
|
|
{
|
|
// Compact: single line with percent + hint joined
|
|
string completionLine = _loc.Get("ui.completion", _renderContext.CompletionPercent);
|
|
if (_renderContext.NextHints.Count > 0)
|
|
completionLine += " — " + _renderContext.NextHints[0];
|
|
completionItems.Add(new Markup($"[bold cyan]{Markup.Escape(completionLine)}[/]"));
|
|
// Additional hints on separate lines
|
|
for (int i = 1; i < _renderContext.NextHints.Count; i++)
|
|
completionItems.Add(new Markup($" [dim]{Markup.Escape(_renderContext.NextHints[i])}[/]"));
|
|
}
|
|
IRenderable completionPanel = completionItems.Count > 0
|
|
? new Rows(completionItems)
|
|
: new Text("");
|
|
|
|
topRow.AddRow(statsPanel, resourcePanel, completionPanel);
|
|
await _terminal.WriteRenderableAsync(topRow);
|
|
|
|
// Bottom row: Inventory (60) | Portrait + Crafting (60) — total 120
|
|
var botRow = new Table().NoBorder().HideHeaders().Expand();
|
|
botRow.AddColumn(new TableColumn("c1").Width(60));
|
|
botRow.AddColumn(new TableColumn("c2").Width(60));
|
|
|
|
IRenderable leftPanel = _renderContext.HasInventoryPanel
|
|
? InventoryPanel.Render(_state, _registry, _loc, compact: true)
|
|
: new Panel($"[dim italic]{Markup.Escape(_loc.Get("panel.locked.inventory"))}[/]")
|
|
.Header("Inventory").Expand();
|
|
|
|
var rightItems = new List<IRenderable>();
|
|
if (_renderContext.HasPortraitPanel)
|
|
rightItems.Add(PortraitPanel.Render(_state.Appearance, useColors: _renderContext.HasColors));
|
|
else
|
|
rightItems.Add(new Panel("[dim]?[/]").Header("Portrait").Expand());
|
|
if (_renderContext.HasCraftingPanel)
|
|
rightItems.Add(CraftingPanel.Render(_state, _registry, _loc));
|
|
|
|
botRow.AddRow(leftPanel, new Rows(rightItems));
|
|
await _terminal.WriteRenderableAsync(botRow);
|
|
}
|
|
|
|
private async Task RenderSequentialPanels()
|
|
{
|
|
var topPanels = new List<IRenderable>();
|
|
if (_renderContext.HasPortraitPanel) topPanels.Add(PortraitPanel.Render(_state.Appearance, useColors: _renderContext.HasColors));
|
|
if (_renderContext.HasStatsPanel) topPanels.Add(StatsPanel.Render(_state, _loc));
|
|
if (_renderContext.HasResourcePanel) topPanels.Add(ResourcePanel.Render(_state, _loc));
|
|
|
|
if (!_renderContext.HasStatsPanel)
|
|
{
|
|
string boxesLabel = _loc.Get("stats.boxes_opened");
|
|
if (_renderContext.HasColors)
|
|
await _terminal.WriteMarkupLineAsync($"[dim]{Markup.Escape(boxesLabel)}: {_state.TotalBoxesOpened}[/]");
|
|
else
|
|
await _terminal.WriteLineAsync($"{boxesLabel}: {_state.TotalBoxesOpened}");
|
|
}
|
|
|
|
if (topPanels.Count > 1)
|
|
{
|
|
var row = new Table().NoBorder().HideHeaders().Expand();
|
|
foreach (var _ in topPanels) row.AddColumn(new TableColumn("").NoWrap());
|
|
row.AddRow(topPanels.ToArray());
|
|
await _terminal.WriteRenderableAsync(row);
|
|
}
|
|
else if (topPanels.Count == 1)
|
|
{
|
|
await _terminal.WriteRenderableAsync(topPanels[0]);
|
|
}
|
|
|
|
if (_renderContext.HasInventoryPanel)
|
|
await _terminal.WriteRenderableAsync(InventoryPanel.Render(_state, _registry, _loc, compact: true));
|
|
|
|
if (_renderContext.HasCraftingPanel)
|
|
await _terminal.WriteRenderableAsync(CraftingPanel.Render(_state, _registry, _loc));
|
|
|
|
if (_renderContext.HasCompletionTracker)
|
|
{
|
|
await _terminal.WriteRenderableAsync(
|
|
new Rule($"[bold cyan]{Markup.Escape(_loc.Get("ui.completion", _renderContext.CompletionPercent))}[/]")
|
|
.RuleStyle("cyan"));
|
|
foreach (var hint in _renderContext.NextHints)
|
|
await _terminal.WriteMarkupLineAsync($" [dim]{Markup.Escape(hint)}[/]");
|
|
}
|
|
}
|
|
|
|
// ── Display methods (styled output) ──────────────────────────────────
|
|
|
|
internal async Task ShowMessageAsync(string message)
|
|
{
|
|
if (_renderContext.HasColors)
|
|
await _terminal.WriteMarkupLineAsync($"[green]{Markup.Escape(message)}[/]");
|
|
else
|
|
await _terminal.WriteLineAsync(message);
|
|
}
|
|
|
|
private async Task ShowErrorAsync(string message)
|
|
{
|
|
if (_renderContext.HasColors)
|
|
await _terminal.WriteMarkupLineAsync($"[bold red]ERROR:[/] [red]{Markup.Escape(message)}[/]");
|
|
else
|
|
await _terminal.WriteLineAsync($"ERROR: {message}");
|
|
}
|
|
|
|
private async Task ShowBoxOpening(string boxName, string rarity)
|
|
{
|
|
if (_renderContext.HasBoxAnimation)
|
|
{
|
|
string color = RarityColor(rarity);
|
|
await _terminal.WriteMarkupLineAsync(
|
|
$"[bold {color}]{Markup.Escape(_loc.Get("box.opening", boxName))}[/]");
|
|
await Task.Delay(1500);
|
|
await _terminal.WriteMarkupLineAsync(
|
|
$"[bold {color}]{Markup.Escape(_loc.Get("box.shimmer"))}[/]");
|
|
await Task.Delay(1000);
|
|
await _terminal.WriteMarkupLineAsync(
|
|
$"[bold {color}]{Markup.Escape(_loc.Get("box.opened_short", boxName))}[/]");
|
|
}
|
|
else if (_renderContext.HasColors)
|
|
{
|
|
string color = RarityColor(rarity);
|
|
await _terminal.WriteMarkupLineAsync(
|
|
$"[bold {color}]{Markup.Escape(_loc.Get("box.opening", boxName))}[/]");
|
|
await Task.Delay(800);
|
|
await _terminal.WriteMarkupLineAsync(
|
|
$"[bold {color}]{Markup.Escape(_loc.Get("box.opened_short", boxName))}[/]");
|
|
}
|
|
else
|
|
{
|
|
await _terminal.WriteLineAsync(_loc.Get("box.opening", boxName));
|
|
await Task.Delay(500);
|
|
await _terminal.WriteLineAsync(_loc.Get("box.opened_short", boxName));
|
|
}
|
|
}
|
|
|
|
private async Task ShowLootReveal(List<(string name, string rarity, string category)> items)
|
|
{
|
|
if (_renderContext.HasInventoryPanel)
|
|
{
|
|
var table = new Table()
|
|
.Border(TableBorder.Rounded)
|
|
.Title($"[bold yellow]{Markup.Escape(_loc.Get("loot.title"))}[/]")
|
|
.AddColumn(new TableColumn($"[bold]{Markup.Escape(_loc.Get("loot.name"))}[/]").Centered())
|
|
.AddColumn(new TableColumn($"[bold]{Markup.Escape(_loc.Get("loot.rarity"))}[/]").Centered());
|
|
|
|
foreach (var (name, rarity, category) in items)
|
|
{
|
|
string color = RarityColor(rarity);
|
|
string localizedRarity = _loc.Get($"rarity.{rarity.ToLower()}");
|
|
string stars = RarityStars(rarity);
|
|
string displayName = stars.Length > 0 ? $"{stars}{name}" : name;
|
|
table.AddRow(
|
|
$"[{color}]{Markup.Escape(displayName)}[/]",
|
|
$"[{color}]{Markup.Escape(localizedRarity)}[/]");
|
|
}
|
|
|
|
await _terminal.WriteRenderableAsync(table);
|
|
}
|
|
else if (_renderContext.HasColors)
|
|
{
|
|
await _terminal.WriteMarkupLineAsync($"[bold yellow]{Markup.Escape(_loc.Get("loot.received"))}[/]");
|
|
foreach (var (name, rarity, category) in items)
|
|
{
|
|
string color = RarityColor(rarity);
|
|
string localizedRarity = _loc.Get($"rarity.{rarity.ToLower()}");
|
|
await _terminal.WriteMarkupLineAsync(
|
|
$" - [{color}]{Markup.Escape(name)}[/] [{color}][[{Markup.Escape(localizedRarity)}]][/]");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
await _terminal.WriteLineAsync(_loc.Get("loot.received"));
|
|
foreach (var (name, rarity, category) in items)
|
|
{
|
|
string localizedRarity = _loc.Get($"rarity.{rarity.ToLower()}");
|
|
await _terminal.WriteLineAsync($" - {name} [{localizedRarity}]");
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task ShowUIFeatureUnlocked(string featureName)
|
|
{
|
|
if (_renderContext.HasColors)
|
|
{
|
|
var star = UnicodeSupport.Star;
|
|
var panel = new Panel($"[bold yellow]{star} {Markup.Escape(featureName)} {star}[/]")
|
|
.Border(BoxBorder.Double)
|
|
.BorderStyle(new Style(Color.Yellow))
|
|
.Padding(2, 0)
|
|
.Expand();
|
|
await _terminal.WriteRenderableAsync(panel);
|
|
}
|
|
else
|
|
{
|
|
await _terminal.WriteLineAsync("========================================");
|
|
await _terminal.WriteLineAsync($" {UnicodeSupport.Star} {featureName} {UnicodeSupport.Star}");
|
|
await _terminal.WriteLineAsync("========================================");
|
|
}
|
|
}
|
|
|
|
private async Task ShowInteraction(string description)
|
|
{
|
|
if (_renderContext.HasColors)
|
|
await _terminal.WriteMarkupLineAsync($"[italic silver]* {Markup.Escape(description)} *[/]");
|
|
else
|
|
await _terminal.WriteLineAsync($"* {description} *");
|
|
}
|
|
|
|
internal async Task ShowAdventureDialogueAsync(string? character, string text)
|
|
{
|
|
if (_renderContext.HasColors)
|
|
{
|
|
if (character is not null)
|
|
await _terminal.WriteMarkupLineAsync($"[bold aqua]{Markup.Escape(character)}[/]");
|
|
await _terminal.WriteMarkupLineAsync($" [italic]{Markup.Escape(text)}[/]");
|
|
await _terminal.WriteLineAsync();
|
|
}
|
|
else
|
|
{
|
|
if (character is not null)
|
|
await _terminal.WriteLineAsync($"[{character}]");
|
|
await _terminal.WriteLineAsync(text);
|
|
await _terminal.WriteLineAsync();
|
|
}
|
|
}
|
|
|
|
internal async Task ShowAdventureHintAsync(string hint)
|
|
{
|
|
if (_renderContext.HasColors)
|
|
await _terminal.WriteMarkupLineAsync($" [dim italic]{Markup.Escape(hint)}[/]");
|
|
else
|
|
await _terminal.WriteLineAsync($" ({hint})");
|
|
}
|
|
|
|
internal async Task<int> ShowAdventureChoiceAsync(List<string> options)
|
|
{
|
|
if (_renderContext.HasArrowSelection)
|
|
{
|
|
return await _terminal.ShowSelectionAsync(_loc.Get("prompt.what_do"), options, true);
|
|
}
|
|
return await _terminal.ShowSelectionAsync(_loc.Get("prompt.what_do"), options, false);
|
|
}
|
|
|
|
internal async Task WaitForKeyAsync(string? message = null)
|
|
{
|
|
string text = message ?? _loc.Get("prompt.press_key");
|
|
if (_renderContext.HasColors)
|
|
await _terminal.WriteMarkupLineAsync($"[dim]{Markup.Escape(text)}[/]");
|
|
else
|
|
await _terminal.WriteLineAsync(text);
|
|
|
|
await _terminal.WaitForKeyAsync();
|
|
await _terminal.WriteLineAsync();
|
|
}
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────
|
|
|
|
private void UpdateCompletionPercent()
|
|
{
|
|
if (!_renderContext.HasCompletionTracker) return;
|
|
|
|
var totalCosmetics = _registry.Items.Values.Count(i => i.CosmeticSlot.HasValue);
|
|
var totalAdventures = Enum.GetValues<AdventureTheme>().Length;
|
|
var totalUIFeatures = Enum.GetValues<UIFeature>().Length;
|
|
var totalResources = Enum.GetValues<ResourceType>().Length;
|
|
var totalStats = Enum.GetValues<StatType>().Length;
|
|
var total = totalCosmetics + totalAdventures + totalUIFeatures
|
|
+ totalResources + totalStats;
|
|
|
|
var unlocked = _state.UnlockedCosmetics.Count
|
|
+ _state.UnlockedAdventures.Count
|
|
+ _state.UnlockedUIFeatures.Count
|
|
+ _state.VisibleResources.Count
|
|
+ _state.VisibleStats.Count;
|
|
|
|
_renderContext.CompletionPercent = total > 0 ? (int)(unlocked * 100.0 / total) : 0;
|
|
|
|
var hints = new List<string>();
|
|
var totalLore = _registry.Items.Values.Count(i => i.Category == ItemCategory.LoreFragment);
|
|
var ownedLore = _state.Inventory.Count(i =>
|
|
_registry.GetItem(i.DefinitionId)?.Category == ItemCategory.LoreFragment);
|
|
if (ownedLore < totalLore)
|
|
hints.Add(_loc.Get("hint.lore", ownedLore, totalLore));
|
|
|
|
var completedAdv = _state.CompletedAdventures.Count;
|
|
var incompleteUnlocked = _state.UnlockedAdventures
|
|
.Count(a => !_state.CompletedAdventures.Contains(a.ToString()));
|
|
if (incompleteUnlocked > 0)
|
|
hints.Add(_loc.Get("hint.adventures", completedAdv, _state.UnlockedAdventures.Count));
|
|
|
|
if (!_state.UnlockedAdventures.Contains(AdventureTheme.Destiny))
|
|
hints.Add(_loc.Get("hint.destiny"));
|
|
|
|
_renderContext.NextHints = hints;
|
|
}
|
|
|
|
private static string GetUIFeatureLocKey(UIFeature feature) => feature switch
|
|
{
|
|
UIFeature.TextColors => "meta.colors",
|
|
UIFeature.ExtendedColors => "meta.extended_colors",
|
|
UIFeature.ArrowKeySelection => "meta.arrows",
|
|
UIFeature.InventoryPanel => "meta.inventory",
|
|
UIFeature.ResourcePanel => "meta.resources",
|
|
UIFeature.StatsPanel => "meta.stats",
|
|
UIFeature.PortraitPanel => "meta.portrait",
|
|
UIFeature.FullLayout => "meta.layout",
|
|
UIFeature.KeyboardShortcuts => "meta.shortcuts",
|
|
UIFeature.BoxAnimation => "meta.animation",
|
|
UIFeature.CraftingPanel => "meta.crafting",
|
|
UIFeature.CompletionTracker => "meta.completion",
|
|
UIFeature.AutoSave => "meta.autosave",
|
|
_ => $"meta.{feature.ToString().ToLower()}"
|
|
};
|
|
|
|
private string GetAdventureName(AdventureTheme theme)
|
|
{
|
|
string key = $"adventure.name.{theme}";
|
|
var name = _loc.Get(key);
|
|
return name.StartsWith("[MISSING:") ? theme.ToString() : name;
|
|
}
|
|
|
|
private string GetLocalizedName(string definitionId)
|
|
{
|
|
var itemDef = _registry.GetItem(definitionId);
|
|
if (itemDef is not null)
|
|
return _loc.Get(itemDef.NameKey);
|
|
|
|
var boxDef = _registry.GetBox(definitionId);
|
|
if (boxDef is not null)
|
|
return _loc.Get(boxDef.NameKey);
|
|
|
|
return _loc.Get(definitionId);
|
|
}
|
|
|
|
private static string RarityColor(string rarity) => rarity.ToLowerInvariant() switch
|
|
{
|
|
"common" => "white",
|
|
"uncommon" => "green",
|
|
"rare" => "blue",
|
|
"epic" => "purple",
|
|
"legendary" => "gold1",
|
|
"mythic" => "red",
|
|
_ => "white"
|
|
};
|
|
|
|
private static string RarityStars(string rarity)
|
|
{
|
|
var s = UnicodeSupport.Star;
|
|
return rarity.ToLowerInvariant() switch
|
|
{
|
|
"rare" => $"{s} ",
|
|
"epic" => $"{s}{s} ",
|
|
"legendary" => $"{s}{s}{s} ",
|
|
"mythic" => $"{s}{s}{s}{s} ",
|
|
_ => ""
|
|
};
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adapter that implements IRenderer by delegating to WebGameHost's async methods.
|
|
/// No-op renderer used only to satisfy AdventureEngine.BuildCustomFunctions() parameter.
|
|
/// Custom function messages (e.g. "You received X") are not shown in WASM — the adventure
|
|
/// dialogue provides the narrative, and events are tracked in the events list.
|
|
/// </summary>
|
|
internal sealed class NoOpRenderer : IRenderer
|
|
{
|
|
public void ShowMessage(string message) { }
|
|
public void ShowError(string message) { }
|
|
public void ShowBoxOpening(string boxName, string rarity) { }
|
|
public void ShowLootReveal(List<(string name, string rarity, string category)> items) { }
|
|
public void ShowGameState(GameState state, RenderContext context) { }
|
|
public void ShowUIFeatureUnlocked(string featureName) { }
|
|
public void ShowAdventureDialogue(string? character, string text) { }
|
|
public int ShowAdventureChoice(List<string> options) => 0;
|
|
public void ShowAdventureHint(string hint) { }
|
|
public void ShowInteraction(string description) { }
|
|
public void WaitForKeyPress(string? message = null) { }
|
|
public void Clear() { }
|
|
public int ShowSelection(string prompt, List<string> options) => 0;
|
|
public string ShowTextInput(string prompt) => "";
|
|
}
|
|
|
|
/// <summary>
|
|
/// [DEPRECATED] Sync→async bridge for IRenderer. Kept for reference but no longer used
|
|
/// by adventure playback (which now uses PlayAdventureWasmAsync directly).
|
|
/// </summary>
|
|
internal sealed class WebRendererAdapter : IRenderer
|
|
{
|
|
private readonly WebGameHost _host;
|
|
|
|
public WebRendererAdapter(WebGameHost host)
|
|
{
|
|
_host = host;
|
|
}
|
|
|
|
public void ShowMessage(string message)
|
|
{
|
|
_host.ShowMessageAsync(message).GetAwaiter().GetResult();
|
|
}
|
|
|
|
public void ShowError(string message)
|
|
{
|
|
_host.ShowMessageAsync($"ERROR: {message}").GetAwaiter().GetResult();
|
|
}
|
|
|
|
public void ShowBoxOpening(string boxName, string rarity) { }
|
|
public void ShowLootReveal(List<(string name, string rarity, string category)> items) { }
|
|
public void ShowGameState(GameState state, RenderContext context) { }
|
|
public void ShowUIFeatureUnlocked(string featureName) { }
|
|
|
|
public void ShowAdventureDialogue(string? character, string text)
|
|
{
|
|
_host.ShowAdventureDialogueAsync(character, text).GetAwaiter().GetResult();
|
|
}
|
|
|
|
public int ShowAdventureChoice(List<string> options)
|
|
{
|
|
return _host.ShowAdventureChoiceAsync(options).GetAwaiter().GetResult();
|
|
}
|
|
|
|
public void ShowAdventureHint(string hint)
|
|
{
|
|
_host.ShowAdventureHintAsync(hint).GetAwaiter().GetResult();
|
|
}
|
|
|
|
public void ShowInteraction(string description) { }
|
|
|
|
public void WaitForKeyPress(string? message = null)
|
|
{
|
|
_host.WaitForKeyAsync(message).GetAwaiter().GetResult();
|
|
}
|
|
|
|
public void Clear() { }
|
|
|
|
public int ShowSelection(string prompt, List<string> options)
|
|
{
|
|
// Not used by AdventureEngine
|
|
return 0;
|
|
}
|
|
|
|
public string ShowTextInput(string prompt)
|
|
{
|
|
// Not used by AdventureEngine
|
|
return "";
|
|
}
|
|
}
|