Add Blazor WASM web build for itch.io browser playtesting

Compile the game to WebAssembly so it runs entirely client-side in the browser.
Uses xterm.js for terminal emulation and Spectre.Console off-screen rendering
for full ANSI output fidelity. Saves use localStorage. CI deploys to itch.io
via Butler on push to main.
This commit is contained in:
Samuel Bouchet 2026-03-16 14:52:40 +01:00
parent c5f0597d42
commit ea487cd332
16 changed files with 2284 additions and 3 deletions

28
.github/workflows/deploy-itch.yml vendored Normal file
View file

@ -0,0 +1,28 @@
name: Deploy to itch.io
on:
push:
branches: [main]
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Publish Blazor WASM
run: dotnet publish src/OpenTheBox.Web -c Release -o publish
- name: Deploy to itch.io
uses: manleydev/butler-publish-itchio-action@master
env:
BUTLER_CREDENTIALS: ${{ secrets.BUTLER_API_KEY }}
CHANNEL: html5
ITCH_GAME: openthebox
ITCH_USER: Lythom
PACKAGE: publish/wwwroot

View file

@ -1,6 +1,7 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/OpenTheBox/OpenTheBox.csproj" />
<Project Path="src/OpenTheBox.Web/OpenTheBox.Web.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/OpenTheBox.Tests/OpenTheBox.Tests.csproj" />

View file

@ -72,12 +72,24 @@ dotnet run --project src/OpenTheBox
## Distribute
### Desktop (self-contained)
```powershell
dotnet publish src/OpenTheBox -c Release -r win-x64 -o "builds/$(Get-Date -Format yyyyMMdd_HHmmss)_win"
dotnet publish src/OpenTheBox -c Release -r linux-x64-o "builds/$(Get-Date -Format yyyyMMdd_HHmmss)_linux"
dotnet publish src/OpenTheBox -c Release -r linux-x64 -o "builds/$(Get-Date -Format yyyyMMdd_HHmmss)_linux"
```
This produces a self-contained single-file executable in `publish/<runtime>/`. The target machine does **not** need .NET installed. Distribute the entire folder (exe + `content/`).
This produces a self-contained single-file executable. The target machine does **not** need .NET installed. Distribute the entire folder (exe + `content/`).
### Web (Blazor WASM → itch.io)
```powershell
dotnet publish src/OpenTheBox.Web -c Release -o publish
```
The `publish/wwwroot/` folder contains a static site playable in any browser. Upload it to itch.io as an HTML5 project.
**CI/CD:** The GitHub Actions workflow `.github/workflows/deploy-itch.yml` automatically publishes to itch.io (user: Lythom, channel: html5) on every push to `main`. Requires the `BUTLER_API_KEY` secret configured in the repository.
## Tests
@ -99,6 +111,14 @@ openthebox/
| +-- Adventures/ # Loreline integration
| +-- Persistence/ # Save/Load
| +-- Localization/ # JSON string manager
+-- src/OpenTheBox.Web/
| +-- Program.cs # Blazor WASM entry point
| +-- WebGameHost.cs # Async game loop for browser
| +-- WebTerminal.cs # xterm.js ↔ C# bridge
| +-- WebSaveManager.cs # localStorage saves
| +-- wwwroot/ # HTML, JS, CSS served to browser
+-- .github/workflows/
| +-- deploy-itch.yml # CI: build WASM + Butler push to itch.io
+-- content/
| +-- data/ # boxes.json, items.json, interactions.json, recipes.json
| +-- strings/ # en.json, fr.json

View file

@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>OpenTheBox.Web</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.3" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\OpenTheBox\OpenTheBox.csproj" />
</ItemGroup>
<!-- Copy game content files to wwwroot for HTTP loading -->
<ItemGroup>
<Content Include="..\..\content\data\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Link>wwwroot\content\data\%(RecursiveDir)%(Filename)%(Extension)</Link>
</Content>
<Content Include="..\..\content\adventures\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Link>wwwroot\content\adventures\%(RecursiveDir)%(Filename)%(Extension)</Link>
</Content>
<Content Include="..\..\content\strings\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Link>wwwroot\content\strings\%(RecursiveDir)%(Filename)%(Extension)</Link>
</Content>
</ItemGroup>
</Project>

View file

@ -0,0 +1,25 @@
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.JSInterop;
namespace OpenTheBox.Web;
public static class Program
{
public static async Task Main(string[] args)
{
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.Services.AddScoped(sp => new HttpClient
{
BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)
});
var host = builder.Build();
var js = host.Services.GetRequiredService<IJSRuntime>();
var http = host.Services.GetRequiredService<HttpClient>();
var gameHost = new WebGameHost(js, http);
await gameHost.RunAsync();
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,95 @@
using System.Text.Json;
using Microsoft.JSInterop;
using OpenTheBox.Core;
using OpenTheBox.Persistence;
namespace OpenTheBox.Web;
/// <summary>
/// Save manager that uses browser localStorage for persistence.
/// Stores save data as JSON strings with the key prefix "otb_save_".
/// </summary>
public sealed class WebSaveManager
{
private const string KeyPrefix = "otb_save_";
private readonly IJSRuntime _js;
public WebSaveManager(IJSRuntime js)
{
_js = js;
}
public async Task SaveAsync(GameState state, string slotName = "autosave")
{
var data = new SaveData
{
SavedAt = DateTime.UtcNow,
State = state
};
string json = JsonSerializer.Serialize(data, SaveJsonContext.Default.SaveData);
await _js.InvokeVoidAsync("localStorage.setItem", KeyPrefix + slotName, json);
}
public async Task<GameState?> LoadAsync(string slotName = "autosave")
{
string? json = await _js.InvokeAsync<string?>("localStorage.getItem", KeyPrefix + slotName);
if (string.IsNullOrEmpty(json))
return null;
json = SaveMigrator.MigrateJson(json);
var data = JsonSerializer.Deserialize(json, SaveJsonContext.Default.SaveData);
return data?.State;
}
public async Task<List<(string Name, DateTime SavedAt)>> ListSlotsAsync()
{
var slots = new List<(string Name, DateTime SavedAt)>();
// Get all localStorage keys that start with our prefix
int length = await _js.InvokeAsync<int>("eval", "localStorage.length");
for (int i = 0; i < length; i++)
{
string? key = await _js.InvokeAsync<string?>("localStorage.key", i);
if (key is null || !key.StartsWith(KeyPrefix))
continue;
string slotName = key[KeyPrefix.Length..];
try
{
string? json = await _js.InvokeAsync<string?>("localStorage.getItem", key);
if (json is not null)
{
var data = JsonSerializer.Deserialize(json, SaveJsonContext.Default.SaveData);
if (data is not null)
{
slots.Add((slotName, data.SavedAt));
continue;
}
}
}
catch (JsonException)
{
// Corrupted save
}
slots.Add((slotName, DateTime.MinValue));
}
return slots.OrderByDescending(s => s.SavedAt).ToList();
}
public async Task<bool> SlotExistsAsync(string slotName)
{
string? value = await _js.InvokeAsync<string?>("localStorage.getItem", KeyPrefix + slotName);
return value is not null;
}
public async Task DeleteSlotAsync(string slotName)
{
await _js.InvokeVoidAsync("localStorage.removeItem", KeyPrefix + slotName);
}
}

View file

@ -0,0 +1,384 @@
using System.Threading.Channels;
using Microsoft.JSInterop;
using Spectre.Console;
using Spectre.Console.Rendering;
namespace OpenTheBox.Web;
/// <summary>
/// Bridge between the game loop (C#) and xterm.js (browser).
/// Handles output (ANSI strings → xterm.js) and input (xterm.js key events → C#).
/// </summary>
public sealed class WebTerminal
{
private static WebTerminal? _instance;
private readonly IJSRuntime _js;
private readonly Channel<ConsoleKeyInfo> _keyChannel =
Channel.CreateUnbounded<ConsoleKeyInfo>(new UnboundedChannelOptions
{
SingleReader = true,
SingleWriter = true
});
public const int Width = 120;
public const int Height = 30;
public WebTerminal(IJSRuntime js)
{
_js = js;
_instance = this;
}
/// <summary>
/// Initializes the xterm.js terminal in the browser.
/// </summary>
public async Task InitAsync()
{
await _js.InvokeVoidAsync("terminalInterop.init");
}
// ── Output ───────────────────────────────────────────────────────────
/// <summary>
/// Writes raw text (including ANSI escape codes) to xterm.js.
/// </summary>
public async Task WriteAsync(string text)
{
await _js.InvokeVoidAsync("terminalInterop.write", text);
}
/// <summary>
/// Writes a line of text followed by \r\n.
/// </summary>
public async Task WriteLineAsync(string text = "")
{
await WriteAsync(text + "\r\n");
}
/// <summary>
/// Clears the terminal screen.
/// </summary>
public async Task ClearAsync()
{
await _js.InvokeVoidAsync("terminalInterop.clear");
}
/// <summary>
/// Renders a Spectre.Console IRenderable to ANSI string using off-screen rendering.
/// </summary>
public static string RenderToAnsi(IRenderable renderable)
{
var writer = new StringWriter();
var console = AnsiConsole.Create(new AnsiConsoleSettings
{
Out = new AnsiConsoleOutput(writer),
Ansi = AnsiSupport.Yes,
ColorSystem = ColorSystemSupport.TrueColor
});
console.Profile.Width = Width;
console.Write(renderable);
return writer.ToString();
}
/// <summary>
/// Renders a Spectre.Console IRenderable and writes it to xterm.js.
/// </summary>
public async Task WriteRenderableAsync(IRenderable renderable)
{
string ansi = RenderToAnsi(renderable);
// Convert \n to \r\n for xterm.js
ansi = ansi.Replace("\n", "\r\n");
await WriteAsync(ansi);
}
/// <summary>
/// Writes Spectre.Console markup text to the terminal.
/// </summary>
public async Task WriteMarkupLineAsync(string markup)
{
var writer = new StringWriter();
var console = AnsiConsole.Create(new AnsiConsoleSettings
{
Out = new AnsiConsoleOutput(writer),
Ansi = AnsiSupport.Yes,
ColorSystem = ColorSystemSupport.TrueColor
});
console.Profile.Width = Width;
console.MarkupLine(markup);
string ansi = writer.ToString().Replace("\n", "\r\n");
await WriteAsync(ansi);
}
// ── Input ────────────────────────────────────────────────────────────
/// <summary>
/// Called from JavaScript when a key is pressed in xterm.js.
/// </summary>
[JSInvokable("OnTerminalInput")]
public static void OnTerminalInput(string data)
{
if (_instance is null) return;
// Parse xterm.js input data into ConsoleKeyInfo events
var keys = ParseInput(data);
foreach (var key in keys)
{
_instance._keyChannel.Writer.TryWrite(key);
}
}
/// <summary>
/// Waits for and returns the next key press from xterm.js.
/// </summary>
public async Task<ConsoleKeyInfo> ReadKeyAsync()
{
return await _keyChannel.Reader.ReadAsync();
}
/// <summary>
/// Reads a full line of text input, echoing characters and handling backspace.
/// </summary>
public async Task<string> ReadLineAsync()
{
var buffer = new List<char>();
while (true)
{
var key = await ReadKeyAsync();
if (key.Key == ConsoleKey.Enter)
{
await WriteAsync("\r\n");
return new string(buffer.ToArray());
}
if (key.Key == ConsoleKey.Backspace)
{
if (buffer.Count > 0)
{
buffer.RemoveAt(buffer.Count - 1);
await WriteAsync("\b \b");
}
continue;
}
if (key.KeyChar >= 32) // printable
{
buffer.Add(key.KeyChar);
await WriteAsync(key.KeyChar.ToString());
}
}
}
/// <summary>
/// Waits for any key press.
/// </summary>
public async Task WaitForKeyAsync()
{
await ReadKeyAsync();
}
// ── High-level input methods ─────────────────────────────────────────
/// <summary>
/// Shows a numbered selection prompt and waits for the user to choose.
/// Supports arrow keys (when arrows are enabled) and number key shortcuts.
/// </summary>
public async Task<int> ShowSelectionAsync(string prompt, List<string> options, bool useArrows)
{
if (useArrows)
return await ShowArrowSelectionAsync(prompt, options);
// Numbered selection
if (prompt.Length > 0)
await WriteLineAsync(prompt);
for (int i = 0; i < options.Count; i++)
await WriteLineAsync($" {i + 1}. {options[i]}");
while (true)
{
await WriteAsync("> ");
string input = await ReadLineAsync();
if (int.TryParse(input, out int choice) && choice >= 1 && choice <= options.Count)
return choice - 1;
await WriteLineAsync($"Please enter a number between 1 and {options.Count}.");
}
}
/// <summary>
/// Arrow-key selection with highlight and number key shortcuts.
/// Mirrors SpectreRenderer.ShowArrowSelection.
/// </summary>
private async Task<int> ShowArrowSelectionAsync(string prompt, List<string> options)
{
int selected = 0;
int pageSize = Math.Min(10, options.Count);
while (true)
{
int scrollOffset = 0;
if (selected >= pageSize)
scrollOffset = selected - pageSize + 1;
int visibleEnd = Math.Min(scrollOffset + pageSize, options.Count);
// Render using Spectre off-screen for consistent styling
var writer = new StringWriter();
var console = AnsiConsole.Create(new AnsiConsoleSettings
{
Out = new AnsiConsoleOutput(writer),
Ansi = AnsiSupport.Yes,
ColorSystem = ColorSystemSupport.TrueColor
});
console.Profile.Width = Width;
if (prompt.Length > 0)
console.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)
console.MarkupLine($" [bold cyan]► {num,-3}[/] [bold]{text}[/]");
else
console.MarkupLine($" [dim]{num,-3}[/] {text}");
}
if (scrollOffset > 0)
console.MarkupLine("[dim] ▲ ...[/]");
if (visibleEnd < options.Count)
console.MarkupLine("[dim] ▼ ...[/]");
string rendered = writer.ToString().Replace("\n", "\r\n");
int lineCount = rendered.Split('\n').Length;
await WriteAsync(rendered);
var key = await ReadKeyAsync();
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:
// Clear the rendered selection before returning
await WriteAsync($"\x1b[{lineCount}A\x1b[J");
return selected;
case ConsoleKey.D1 or ConsoleKey.NumPad1: if (options.Count >= 1) { await WriteAsync($"\x1b[{lineCount}A\x1b[J"); return 0; } break;
case ConsoleKey.D2 or ConsoleKey.NumPad2: if (options.Count >= 2) { await WriteAsync($"\x1b[{lineCount}A\x1b[J"); return 1; } break;
case ConsoleKey.D3 or ConsoleKey.NumPad3: if (options.Count >= 3) { await WriteAsync($"\x1b[{lineCount}A\x1b[J"); return 2; } break;
case ConsoleKey.D4 or ConsoleKey.NumPad4: if (options.Count >= 4) { await WriteAsync($"\x1b[{lineCount}A\x1b[J"); return 3; } break;
case ConsoleKey.D5 or ConsoleKey.NumPad5: if (options.Count >= 5) { await WriteAsync($"\x1b[{lineCount}A\x1b[J"); return 4; } break;
case ConsoleKey.D6 or ConsoleKey.NumPad6: if (options.Count >= 6) { await WriteAsync($"\x1b[{lineCount}A\x1b[J"); return 5; } break;
case ConsoleKey.D7 or ConsoleKey.NumPad7: if (options.Count >= 7) { await WriteAsync($"\x1b[{lineCount}A\x1b[J"); return 6; } break;
case ConsoleKey.D8 or ConsoleKey.NumPad8: if (options.Count >= 8) { await WriteAsync($"\x1b[{lineCount}A\x1b[J"); return 7; } break;
case ConsoleKey.D9 or ConsoleKey.NumPad9: if (options.Count >= 9) { await WriteAsync($"\x1b[{lineCount}A\x1b[J"); return 8; } break;
}
// Clear and re-render
await WriteAsync($"\x1b[{lineCount}A\x1b[J");
}
}
// ── Key parsing ──────────────────────────────────────────────────────
/// <summary>
/// Parses xterm.js onData strings into ConsoleKeyInfo values.
/// xterm.js sends raw character sequences: single chars, ANSI escape sequences, etc.
/// </summary>
private static List<ConsoleKeyInfo> ParseInput(string data)
{
var keys = new List<ConsoleKeyInfo>();
int i = 0;
while (i < data.Length)
{
char c = data[i];
// ANSI escape sequence
if (c == '\x1b' && i + 1 < data.Length && data[i + 1] == '[')
{
// CSI sequence
i += 2;
string seq = "";
while (i < data.Length && data[i] is >= '0' and <= '9' or ';')
{
seq += data[i];
i++;
}
if (i < data.Length)
{
char final = data[i];
i++;
var key = (seq, final) switch
{
("", 'A') => MakeKey(ConsoleKey.UpArrow),
("", 'B') => MakeKey(ConsoleKey.DownArrow),
("", 'C') => MakeKey(ConsoleKey.RightArrow),
("", 'D') => MakeKey(ConsoleKey.LeftArrow),
("5", '~') => MakeKey(ConsoleKey.PageUp),
("6", '~') => MakeKey(ConsoleKey.PageDown),
("3", '~') => MakeKey(ConsoleKey.Delete),
_ => (ConsoleKeyInfo?)null
};
if (key.HasValue)
keys.Add(key.Value);
}
}
else if (c == '\x1b')
{
// Bare escape
keys.Add(MakeKey(ConsoleKey.Escape));
i++;
}
else if (c == '\r')
{
keys.Add(MakeKey(ConsoleKey.Enter, '\r'));
i++;
}
else if (c == '\x7f' || c == '\b')
{
keys.Add(MakeKey(ConsoleKey.Backspace, '\b'));
i++;
}
else if (c == '\t')
{
keys.Add(MakeKey(ConsoleKey.Tab, '\t'));
i++;
}
else
{
// Regular character
var consoleKey = c switch
{
>= '0' and <= '9' => ConsoleKey.D0 + (c - '0'),
>= 'a' and <= 'z' => ConsoleKey.A + (c - 'a'),
>= 'A' and <= 'Z' => ConsoleKey.A + (c - 'A'),
' ' => ConsoleKey.Spacebar,
_ => ConsoleKey.NoName
};
bool shift = c is >= 'A' and <= 'Z';
keys.Add(new ConsoleKeyInfo(c, consoleKey, shift, false, false));
i++;
}
}
return keys;
}
private static ConsoleKeyInfo MakeKey(ConsoleKey key, char keyChar = '\0')
{
return new ConsoleKeyInfo(keyChar, key, false, false, false);
}
}

View file

@ -0,0 +1,90 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #1a1a2e;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
width: 100vw;
overflow: hidden;
font-family: 'Cascadia Mono', 'Consolas', 'Courier New', monospace;
}
#terminal-container {
display: none;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
padding: 8px;
}
#terminal-container.active {
display: flex;
}
#terminal {
width: 100%;
height: 100%;
max-width: 960px;
max-height: 600px;
}
/* Loading screen */
#loading {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
color: #e0e0e0;
}
#loading.hidden {
display: none;
}
.loading-content {
text-align: center;
}
.loading-content h1 {
font-size: 2rem;
margin-bottom: 1rem;
color: #ffd700;
}
.loading-content p {
font-size: 1rem;
margin-bottom: 1.5rem;
color: #aaa;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid #333;
border-top: 4px solid #ffd700;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Hide scrollbars in xterm */
.xterm-viewport::-webkit-scrollbar {
display: none;
}
.xterm-viewport {
scrollbar-width: none;
}

View file

@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Open The Box</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css" />
<link rel="stylesheet" href="css/terminal.css" />
</head>
<body>
<div id="loading">
<div class="loading-content">
<h1>Open The Box</h1>
<p>Loading game...</p>
<div class="spinner"></div>
</div>
</div>
<div id="terminal-container">
<div id="terminal"></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
<script src="js/terminal-interop.js"></script>
<!-- Blazor WASM -->
<script src="_framework/blazor.webassembly.js" autostart="true"></script>
</body>
</html>

View file

@ -0,0 +1,68 @@
// Terminal interop bridge: xterm.js <-> .NET Blazor WASM
window.terminalInterop = {
term: null,
fitAddon: null,
init: function () {
const term = new Terminal({
cols: 120,
rows: 30,
fontFamily: "'Cascadia Mono', 'Consolas', 'Courier New', monospace",
fontSize: 14,
theme: {
background: '#1a1a2e',
foreground: '#e0e0e0',
cursor: '#ffd700',
cursorAccent: '#1a1a2e',
selectionBackground: 'rgba(255, 215, 0, 0.3)'
},
cursorBlink: true,
allowProposedApi: true,
scrollback: 1000
});
const fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
term.open(document.getElementById('terminal'));
fitAddon.fit();
// Forward key input to C#
term.onData(function (data) {
DotNet.invokeMethodAsync('OpenTheBox.Web', 'OnTerminalInput', data);
});
// Handle resize
window.addEventListener('resize', function () {
fitAddon.fit();
});
this.term = term;
this.fitAddon = fitAddon;
// Show terminal, hide loading
document.getElementById('loading').classList.add('hidden');
document.getElementById('terminal-container').classList.add('active');
term.focus();
},
write: function (text) {
if (this.term) {
this.term.write(text);
}
},
clear: function () {
if (this.term) {
this.term.clear();
this.term.write('\x1b[H\x1b[2J');
}
},
focus: function () {
if (this.term) {
this.term.focus();
}
}
};

View file

@ -177,6 +177,86 @@ public sealed class AdventureEngine
return events;
}
/// <summary>
/// Plays an adventure using pre-loaded script content instead of reading from files.
/// Used by the web (WASM) build where file system access is not available.
/// </summary>
/// <param name="importContents">
/// Dictionary mapping import file names to their content, for resolving Loreline imports.
/// </param>
public async Task<List<AdventureEvent>> PlayAdventureFromContent(
AdventureTheme theme, GameState state,
string scriptContent, string? translationContent = null,
Dictionary<string, string>? importContents = null)
{
var events = new List<AdventureEvent>();
string themeName = theme.ToString().ToLowerInvariant();
string adventureId = $"{themeName}/intro";
Script script = Engine.Parse(
scriptContent,
$"{themeName}/intro.lor",
(path, callback) =>
{
if (importContents is not null && importContents.TryGetValue(path, out string? content))
callback(content);
else
callback(string.Empty);
});
if (script is null)
{
_renderer.ShowError("Failed to parse adventure script.");
return [];
}
var options = Interpreter.InterpreterOptions.Default();
options.Functions = BuildCustomFunctions(state, events);
if (translationContent is not null)
{
Script translationScript = Engine.Parse(translationContent);
if (translationScript is not null)
{
options.Translations = Engine.ExtractTranslations(translationScript);
}
}
var tcs = new TaskCompletionSource<bool>();
bool hasSave = state.AdventureSaveData.TryGetValue(adventureId, out string? saveData)
&& !string.IsNullOrEmpty(saveData);
Loreline.Interpreter interpreter;
if (hasSave)
{
interpreter = Engine.Resume(
script,
dialogue => HandleDialogue(dialogue),
choice => HandleChoice(choice),
finish => HandleFinish(finish, tcs),
saveData!,
options: options);
}
else
{
interpreter = Engine.Play(
script,
dialogue => HandleDialogue(dialogue),
choice => HandleChoice(choice),
finish => HandleFinish(finish, tcs),
options: options);
}
await tcs.Task;
state.AdventureSaveData.Remove(adventureId);
state.CompletedAdventures.Add(theme.ToString());
return events;
}
/// <summary>
/// Saves the progress of the currently running adventure into the game state.
/// </summary>

View file

@ -90,4 +90,47 @@ public class ContentRegistry
return registry;
}
/// <summary>
/// Loads content definitions from JSON strings and returns a populated registry.
/// Used by the web (WASM) build where file system access is not available.
/// </summary>
public static ContentRegistry LoadFromStrings(
string itemsJson, string boxesJson, string interactionsJson, string? recipesJson = null)
{
var registry = new ContentRegistry();
var items = JsonSerializer.Deserialize(itemsJson, ContentJsonContext.Default.ListItemDefinition);
if (items is not null)
{
foreach (var item in items)
registry.RegisterItem(item);
}
var boxes = JsonSerializer.Deserialize(boxesJson, ContentJsonContext.Default.ListBoxDefinition);
if (boxes is not null)
{
foreach (var box in boxes)
registry.RegisterBox(box);
}
var rules = JsonSerializer.Deserialize(interactionsJson, ContentJsonContext.Default.ListInteractionRule);
if (rules is not null)
{
foreach (var rule in rules)
registry.RegisterInteractionRule(rule);
}
if (recipesJson is not null)
{
var recipes = JsonSerializer.Deserialize(recipesJson, ContentJsonContext.Default.ListRecipe);
if (recipes is not null)
{
foreach (var recipe in recipes)
registry.RegisterRecipe(recipe);
}
}
return registry;
}
}

View file

@ -77,4 +77,20 @@ public sealed class LocalizationManager
{
Load(locale);
}
/// <summary>
/// Loads the string table for the given locale from a JSON string.
/// Used by the web (WASM) build where file system access is not available.
/// </summary>
public void LoadFromString(Locale locale, string json)
{
CurrentLocale = locale;
_strings = [];
var parsed = JsonSerializer.Deserialize(json, LocalizationJsonContext.Default.DictionaryStringString);
if (parsed is not null)
{
_strings = parsed;
}
}
}

View file

@ -11,4 +11,4 @@ namespace OpenTheBox.Persistence;
WriteIndented = true,
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
UseStringEnumConverter = true)]
internal partial class SaveJsonContext : JsonSerializerContext;
public partial class SaveJsonContext : JsonSerializerContext;

View file

@ -26,6 +26,13 @@ public static class UnicodeSupport
/// </summary>
public static void Initialize()
{
// Browser (WASM) always supports UTF-8 via xterm.js
if (OperatingSystem.IsBrowser())
{
IsUtf8 = true;
return;
}
// Windows Terminal always sets WT_SESSION
if (Environment.GetEnvironmentVariable("WT_SESSION") is not null)
{