diff --git a/CLAUDE.md b/CLAUDE.md index c32ab51f3..cba521e58 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,12 +4,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -Quest Viva is an open-source system for creating and playing text adventure games. It's a .NET 10.0 C# application, the modern successor to Quest 5. The main deliverable is a web-based game player built with ASP.NET Core and Blazor. +Quest Viva is an open-source system for creating and playing text adventure games. It's a .NET 10.0 C# application, the modern successor to Quest 5. There are two player implementations: **WebPlayer** (ASP.NET Core + Blazor Server, Docker-deployed) and **WasmPlayer** (pure browser-WASM, AOT-compiled, no server required — the long-term direction). ## Build & Test Commands ```bash -# Build +# Build (full solution) dotnet build --configuration Release # Run all tests @@ -21,8 +21,19 @@ dotnet test tests/EngineTests # Run a specific test dotnet test tests/EngineTests --filter "FullyQualifiedName~TestMethodName" -# Run with Docker +# Run WebPlayer with Docker docker compose up --build # WebPlayer on http://localhost:8080 + +# Build WasmPlayer (Debug — fast interpreter mode) +dotnet build src/WasmPlayer/WasmPlayer.csproj + +# Build WasmPlayer (Release — AOT compiled, ~15s) +dotnet build --configuration Release src/WasmPlayer/WasmPlayer.csproj + +# Run WasmPlayer dev server (requires COOP/COEP headers for SharedArrayBuffer) +node src/WasmPlayer/dev-server.mjs # Debug build +node src/WasmPlayer/dev-server.mjs --release # Release/AOT build +# Open: http://localhost:5175/?game=/examples/simple.aslx ``` Tests use MSTest with Moq (mocking) and Shouldly (assertions). @@ -45,7 +56,7 @@ The solution (`QuestViva.sln`) has a layered architecture: ``` WebPlayer (ASP.NET Core + Blazor Server) ─┐ -WasmPlayer (Blazor WebAssembly) ─┤ +WasmPlayer (browser-wasm, AOT) ─┤ ├─► PlayerCore ─► Engine ─► Utility ─► Common EditorCore ─────────────────────────────────┘ │ └─► Legacy @@ -60,7 +71,7 @@ EditorCore ─────────────────────── - **EditorCore** — Game editor logic (non-UI) - **Legacy** — Quest 4 (and earlier) backward-compatibility layer with embedded `.lib`/`.dat` files - **WebPlayer** — ASP.NET Core web app with Blazor Razor components (`Game.razor`, `Slots.razor`, debugger) -- **WasmPlayer** — WebAssembly variant of the player +- **WasmPlayer** — Pure browser-WASM player (`browser-wasm` target, AOT-compiled). Uses `JSImport`/`JSExport` for JS interop. Serves as a static site with no server-side .NET required. IL trimming is enabled; `WasmPlayer.linker.xml` preserves the Engine assembly (which uses reflection-based type discovery). **Test projects in `tests/`:** EngineTests, PlayerCoreTests, EditorCoreTests, UtilityTests, LegacyTests diff --git a/QuestViva.sln b/QuestViva.sln index b99a746cc..4d6287756 100644 --- a/QuestViva.sln +++ b/QuestViva.sln @@ -40,6 +40,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebPlayerTests", "tests\Web EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WasmEditor", "src\WasmEditor\WasmEditor.csproj", "{A84D0E9B-E449-456B-B82E-C83FA68DE623}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WasmPlayer", "src\WasmPlayer\WasmPlayer.csproj", "{C4E7B8F2-3A91-4D5E-B6A0-89C3F1D2E456}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -218,6 +220,18 @@ Global {A84D0E9B-E449-456B-B82E-C83FA68DE623}.Release|x86.Build.0 = Release|Any CPU {A84D0E9B-E449-456B-B82E-C83FA68DE623}.Release|x64.ActiveCfg = Release|Any CPU {A84D0E9B-E449-456B-B82E-C83FA68DE623}.Release|x64.Build.0 = Release|Any CPU + {C4E7B8F2-3A91-4D5E-B6A0-89C3F1D2E456}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C4E7B8F2-3A91-4D5E-B6A0-89C3F1D2E456}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C4E7B8F2-3A91-4D5E-B6A0-89C3F1D2E456}.Debug|x86.ActiveCfg = Debug|Any CPU + {C4E7B8F2-3A91-4D5E-B6A0-89C3F1D2E456}.Debug|x86.Build.0 = Debug|Any CPU + {C4E7B8F2-3A91-4D5E-B6A0-89C3F1D2E456}.Debug|x64.ActiveCfg = Debug|Any CPU + {C4E7B8F2-3A91-4D5E-B6A0-89C3F1D2E456}.Debug|x64.Build.0 = Debug|Any CPU + {C4E7B8F2-3A91-4D5E-B6A0-89C3F1D2E456}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C4E7B8F2-3A91-4D5E-B6A0-89C3F1D2E456}.Release|Any CPU.Build.0 = Release|Any CPU + {C4E7B8F2-3A91-4D5E-B6A0-89C3F1D2E456}.Release|x86.ActiveCfg = Release|Any CPU + {C4E7B8F2-3A91-4D5E-B6A0-89C3F1D2E456}.Release|x86.Build.0 = Release|Any CPU + {C4E7B8F2-3A91-4D5E-B6A0-89C3F1D2E456}.Release|x64.ActiveCfg = Release|Any CPU + {C4E7B8F2-3A91-4D5E-B6A0-89C3F1D2E456}.Release|x64.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -237,6 +251,7 @@ Global {CB4137F3-BE35-4A58-9552-B80E578BB1C0} = {DE73B1F0-E136-444D-97EF-B31B1592532F} {5F894C2F-4251-4594-B069-C1818D1BCD00} = {DE73B1F0-E136-444D-97EF-B31B1592532F} {A84D0E9B-E449-456B-B82E-C83FA68DE623} = {5FFA7F3F-12FE-4727-9359-3D493A4E9F86} + {C4E7B8F2-3A91-4D5E-B6A0-89C3F1D2E456} = {5FFA7F3F-12FE-4727-9359-3D493A4E9F86} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {18B8911F-786E-48E1-8F32-208060A4A2C6} diff --git a/docs/async-ncalc-wasm-plan.md b/docs/async-ncalc-wasm-plan.md deleted file mode 100644 index 86696be35..000000000 --- a/docs/async-ncalc-wasm-plan.md +++ /dev/null @@ -1,220 +0,0 @@ -# Async NCalc → No Threading → WASM Plan - -## Background - -Quest Viva's Engine currently uses thread-blocking to "pause" script execution while -waiting for player input (menus, questions, `wait` commands). This is done via -`Monitor.Wait()/Pulse()` in `WorldModel.cs`. The goal is to replace this with -`async/await`, which eliminates the need for background threads and makes the Engine -compatible with a browser-hosted WASM player. - -The NCalc → async NCalc → no threads → WASM chain is the right path. The expression -evaluator isn't where threads are primarily used, but async NCalc is a necessary link -because expressions can call Quest functions (via `EvaluateFunction`), and if any of -those functions need to pause (e.g. because they trigger a menu), the async-ness needs -to propagate up through the expression evaluator. - -The real unlock is: replace `Monitor.Wait()/Pulse()` with `TaskCompletionSource`, make -`IScript.Execute()` → `ExecuteAsync()`, and eliminate `DoInNewThreadAndWait()`. Once -everything is `async/await`, WASM works because the game coroutine yields to the browser -event loop at `await` points rather than blocking a thread. - -**Gating condition for starting this work**: NCalc confirmed stable in production. - ---- - -## Phase 1 — Async NCalc ✅ - -- Swapped `NCalcSync` for `NCalcAsync` (both redirect to `NCalc.Core`, which already has `EvaluateAsync`, `EvaluateAsyncFunction`, and `EvaluateBinaryAsync` events) -- Added `EvaluateAsync(Context c)` to `IExpressionEvaluator` and `IDynamicExpressionEvaluator` (removed `out` covariance from `IExpressionEvaluator` since `Task` is invariant) -- `NcalcExpressionEvaluator` registers async event handlers and implements `EvaluateAsync` using NCalc's async path with full async parameter evaluation (`EvaluateAslFunctionAsync`, `EvaluateFunctionFromTypeAsync`, `EvaluateBinaryAsync`) -- `FleeExpressionEvaluator` gets `Task.FromResult` shims since Flee has no async path — it stays working but is on the way out -- `Expression` and `ExpressionDynamic` expose `ExecuteAsync` methods -- Branch: `async-ncalc` - ---- - -## Phase 2 — Async script interface ✅ - -Chose **parallel interface** (Option A): `Task ExecuteAsync(Context c)` added to `IScript` -alongside `Execute()`, so tests and the editor continue working unchanged. - -- `ScriptBase` has a virtual default shim: `Execute(c); return Task.CompletedTask;` -- All control-flow scripts have proper async overrides: - - `MultiScript` — loops and `await`s each child - - `IfScript` — sync condition evaluation, async branches - - `ForScript`, `WhileScript`, `ForEachScript` — sync bounds/condition, async loop body - - `SwitchScript` (including inner `SwitchCases`) — sync expression, async matched case - - `FirstTimeScript` — async branch scripts - - `FunctionCallScript` — calls `RunProcedureAsync` - - `RunDelegateScript`, `DoActionScript`, `InvokeScript` — call `RunScriptAsync` - - `LazyLoadScript` — delegates to inner script's `ExecuteAsync` -- `WorldModel` gains `RunScriptAsync` and `RunProcedureAsync` parallel variants -- `NcalcExpressionEvaluator.EvaluateAslFunctionAsync` now uses `RunProcedureAsync` (no more `.GetAwaiter().GetResult()` in the async path) -- Pause-inducing scripts (`WaitScript`, `ShowMenuScript`, `AskScript`, etc.) still fall through to the sync shim — they'll be wired up properly in Phase 3 -- `AsyncScriptTests` added to exercise the async execution path end-to-end -- Branch: `async-ncalc` - ---- - -## Phase 2b — Flip the driver ✅ - -The async script path was exercised in tests but not in production. Rather than a "big -bang" switch, the production driver was flipped with a single-line change: - -```csharp -// WorldModel.cs — private core RunScript now delegates to RunScriptAsync -private object? RunScript(IScript script, Context c, bool expectResult) -{ - return RunScriptAsync(script, c, expectResult).GetAwaiter().GetResult(); -} -``` - -Every execution path (`RunProcedure`, all `RunScript` overloads, timer callbacks, -`SendEvent`, `TryFinishTurn`, callbacks) bottoms out at this private core, so a single -change routes all script execution through `ExecuteAsync`. - -Pause-inducing scripts still fall through to the sync shim — `Execute(c)` blocks on -`Monitor.Wait` as before, and returns `Task.CompletedTask` (already complete), so the -`await` resumes synchronously on the same thread. **No observable behaviour change**, but -now 99% of script execution — every `if`, loop, function call, and switch — runs through -`ExecuteAsync` in production. - -Why `.GetAwaiter().GetResult()` doesn't deadlock here: all tasks in the current chain -complete synchronously (the shim blocks the thread before returning, not after), so no -async continuation is ever posted to a synchronization context. Even on the ASP.NET Core -request thread (e.g. `SendEvent`), there is no thread switch and therefore no deadlock -risk. - -Branch: `async-ncalc` - ---- - -## Phase 3 — Replace Monitor-based blocking ✅ - -- **Pause scripts** (`WaitScript`, `ShowMenuScript`, `AskScript`, `GetInputScript`) now - have real `override async Task ExecuteAsync(Context c)` overrides that `await` a - `TaskCompletionSource` and call `SignalTurnSuspended()`. The sync shim fallback is no - longer reached on the live game path. -- **`ExpressionOwner`** functions (`ShowMenu`, `Ask`, `GetInput`) are now `async Task`, - so the async NCalc path can properly `await` them rather than blocking. -- **`SetExpressionScript`** gained an `ExecuteAsync` override that awaits the expression - result, covering the `result = ShowMenu(...)` pattern in ASLX. -- **`NcalcExpressionEvaluator`** sync event handlers (`EvaluateFunction`, `EvaluateBinary`) - removed; only async handlers remain. `Evaluate(c)` delegates to - `EvaluateAsync(c).GetAwaiter().GetResult()`. -- **Timer race fixed** — `TickAsyncInternal` no longer touches `_turnSuspendedTcs` (which - is owned by the response handlers); timer output is flushed naturally by `UiActionAsync`. - -**Remaining `GetAwaiter().GetResult()` calls** are intentional boundary shims for sync -callers (EditorCore, `IScript.Execute`). They are safe because the editor runs on a -desktop thread with no synchronization context. They are not part of the async game -execution path (`BeginInternalAsync` → `RunScriptAsync` → `ExecuteAsync`) and will be -eliminated as part of Phase 4. - ---- - -## Phase 4 — Async player surface ✅ - -- `IGame.SendCommand`, `IGame.SendEvent`, and `IGame.Tick` now return `Task` instead of - `void`, completing the async surface of the game interface -- `WorldModel.SendCommand` fires `HandleCommandAsyncInternal` as before but now returns - `_turnSuspendedTcs.Task` — the caller awaits this and only proceeds (flushing the JS - buffer) once the turn has suspended (either finished or waiting for player input) -- `WorldModel.SendEvent` uses `await RunProcedureAsync` instead of `.GetAwaiter().GetResult()` -- `WorldModel.Tick` returns the `TickAsyncInternal` task directly instead of fire-and-forgetting -- `Player.UiSendCommandAsync`, `UiTickAsync`, `UiSendEventAsync` now use the - `Func` overload of `UiActionAsync`, so `ClearBuffer()` is correctly deferred until - the awaited turn work completes -- `PlayerHelper.SendCommand` returns `Task` -- `WalkthroughRunner` awaits `SendCommand` and `SendEvent` calls -- `CallbackManager` removed — `AddOnReadyCallback` was never called, so the flush loops - in `TryFinishTurnAsync` / `TryRunOnFinallyScriptsAsync` were always no-ops; `Callback.cs` - deleted, `TryRunOnFinallyScriptsAsync` returns `Task.CompletedTask` -- `V4Game` (Legacy) keeps its sync/thread implementation but wraps each method to return - `Task.CompletedTask` after the blocking work completes — Phase 5 will replace the threads -- Tests in `EngineTests`, `LegacyTests` updated to `await` the new `Task`-returning calls - ---- - -## Phase 5 — Legacy asyncification ✅ - -Legacy (`src/Legacy/`) implements its own Q4 format interpreter (`V4Game.cs`) rather than -sharing the Engine's script execution pipeline. The entire interpreter was made async — no -background threads, no blocking waits — making it fully WASM-compatible. - -- **`TaskCompletionSource` trio** replaces all threading machinery: - - `_turnSuspendedTcs` — signals the IGame caller (SendCommand/Tick) that the turn has suspended - - `_waitTcs` — awaited by pause points (DoWait, Pause, ShowMenu, ExecuteIfAsk, PlayMedia sync) - - `_commandTcs` — awaited by `ExecuteEnter` (ASL `enter` keyword) -- **`SignalTurnSuspended()`** helper sets `_readyForCommand = true` and fires `_turnSuspendedTcs` -- **All `Monitor.Wait/Pulse`, `SemaphoreSlim`, `Task.Run`, `new Thread()`** removed -- **`ExecuteScript` and `ExecCommand`** are now `async Task` / `async Task` — the entire - recursive descent interpreter propagates async (55+ methods made async across V4Game.cs, - V4Game.Part2.cs, RoomExit.cs, RoomExits.cs) -- **Pause points** (`DoWaitAsync`, `PauseAsync`, `ShowMenuAsync`, `ExecuteIfAskAsync`, - `PlayMediaAsync`, `ExecuteEnterAsync`, `ExecuteWaitAsync`) create `_waitTcs`, call - `SignalTurnSuspended()`, then `await _waitTcs.Task` — exact same pattern as the Engine -- **IGame methods** (`SendCommand`, `Tick`, `FinishWait`, `SetMenuResponse`, `SetQuestionResponse`) - create `_turnSuspendedTcs`, fire the async work, `await _turnSuspendedTcs.Task` -- **`Begin()`** remains `void` / fire-and-forget (`_ = DoBeginAsync()`) matching WorldModel -- **`Legacy.csproj`** has `` — zero CA1416 warnings confirm - no remaining browser-incompatible calls - ---- - -## Phase 6 — WasmPlayer - -The UI approach should follow WasmEditor (pure JS + C# WASM DLL) rather than Blazor -WASM. Reasons: - -- **Consistent with WasmEditor** — same build pipeline, same `WasmConfig`, same - deployment model -- **Smaller and faster** — no Blazor framework overhead on top of the .NET WASM runtime -- **Game output is already HTML** — PlayerCore renders rich HTML output; the JS layer - just needs to route it into a container, not render it -- **CDN / Electron target** — a pure JS shell is portable to both; Blazor WASM is not - -### WasmPlayer project - -- `WasmPlayer` project targeting `browser-wasm`, analogous to `WasmEditor` -- `WasmPlayerBridge.cs` — `[JSExport]` entry points for starting a game, sending player - input, answering menus/questions -- Wire up with `WasmConfig` (already sets `UseNCalc = true`) -- Game loop runs on the browser's main thread, yielding at `await` on - `TaskCompletionSource` tasks when waiting for player input -- JS calls `SetMenuResponse()` etc. from UI events → `TCS.SetResult()` → game coroutine - resumes - -### Key design task: `[JSImport]` callback interface - -Unlike WasmEditor (where JS calls C# and C# returns data), the player's primary -communication direction is **C# → JS**: every line of output, sound, menu, and turn -completion needs to be pushed to the UI in real time. This requires `[JSImport]` -callbacks — not yet implemented in WasmEditor, but essential for the player. - -Define this interface upfront before building the bridge. Events to cover: - -- Text/HTML output (`Print`) -- Sound (`PlaySound`, `StopSound`) -- Show menu / ask question (pause game, await response) -- Turn begin / turn end (for UI state like disabling input mid-turn) -- Game over - -### JS frontend - -A lightweight Svelte (or vanilla JS) frontend analogous to `src/WebEditor/`: - -- Renders game output HTML into a scrolling panel -- Routes player text input and UI responses back to `[JSExport]` methods on the bridge -- Loads the game file via the same `FileAdapter` interface as WebEditor (reuse or extract - into a shared package) -- Handles audio via jPlayer (already embedded in PlayerCore) or a JS replacement - ---- - -## Notes and risks - -- **Tests now cover the async path** — `AsyncScriptTests` drives all control-flow scripts via `RunProcedureAsync`. The sync tests still pass unchanged (the `RunScript` core now routes through `RunScriptAsync` internally). -- **Callback manager overlap** — there's already a partial callback-based async mechanism (`CallbackManager`, `StartWaitAsync`). Phase 3 should decide whether to unify these or remove the old path outright. -- **SharedArrayBuffer** — even with Quest's own threading removed, the .NET WASM runtime uses `SharedArrayBuffer` internally (for the GC). The COOP/COEP headers required by WasmEditor will be needed for WasmPlayer too. Confirm the CDN target serves these headers. diff --git a/src/Engine/WorldModel.cs b/src/Engine/WorldModel.cs index 35263246c..0f324da94 100644 --- a/src/Engine/WorldModel.cs +++ b/src/Engine/WorldModel.cs @@ -63,6 +63,8 @@ public partial class WorldModel : IGame, IGameDebug private int _pendingCallbackCount; private readonly List<(IScript Script, Context Context)> _onReadyQueue = []; + private bool _reportingScriptError; + private Walkthroughs? _walkthroughs; static WorldModel() @@ -1020,7 +1022,18 @@ internal Task RunScriptAsync(IScript script, Context c) } catch (Exception ex) { - await PrintAsync("Error running script: " + Utility.SafeXML(ex.Message)); + if (!_reportingScriptError) + { + _reportingScriptError = true; + try + { + await PrintAsync("Error running script: " + Utility.SafeXML(ex.Message)); + } + finally + { + _reportingScriptError = false; + } + } LogException(ex); } diff --git a/src/PlayerCore/PlayerCore.csproj b/src/PlayerCore/PlayerCore.csproj index 9892f7e20..af3e773bd 100644 --- a/src/PlayerCore/PlayerCore.csproj +++ b/src/PlayerCore/PlayerCore.csproj @@ -16,6 +16,7 @@ + @@ -42,7 +43,6 @@ - \ No newline at end of file diff --git a/src/WebPlayer/wwwroot/player.js b/src/PlayerCore/Resources/player.js similarity index 96% rename from src/WebPlayer/wwwroot/player.js rename to src/PlayerCore/Resources/player.js index 4c2e7c1ff..b24a6d991 100644 --- a/src/WebPlayer/wwwroot/player.js +++ b/src/PlayerCore/Resources/player.js @@ -112,10 +112,9 @@ function sendCommand(text, metadata) { canSendCommand = false; markScrollPosition(); - // TODO: See if setTimeout is still needed here - window.setTimeout(async function () { - await WebPlayer.sendCommand(text, getTickCountAndStopTimer(), metadata); - }, 100); + window.setTimeout(function () { + WebPlayer.sendCommand(text, getTickCountAndStopTimer(), metadata); + }, 0); } function ASLEvent(event, parameter) { diff --git a/src/PlayerCore/Resources/playercore.js b/src/PlayerCore/Resources/playercore.js index fcefc6c71..80ff5d72f 100644 --- a/src/PlayerCore/Resources/playercore.js +++ b/src/PlayerCore/Resources/playercore.js @@ -162,7 +162,7 @@ function initPlayerUI() { gameContent.style.width = (gameWidth - 250) + "px"; gamePanel.style.width = (gameWidth - 220) + "px"; gridPanel.style.width = (gameWidth - 220) + "px"; - if (window.paper) paper.view.viewSize.width = gameWidth - 220; + if (window.paper && paper.view) paper.view.viewSize.width = gameWidth - 220; } else { if (wasWide) { sidebar.style.display = "none"; @@ -175,7 +175,7 @@ function initPlayerUI() { gameContent.style.width = "initial"; gamePanel.style.width = "100%"; gridPanel.style.width = "100%"; - if (window.paper) paper.view.viewSize.width = window.innerWidth; + if (window.paper && paper.view) paper.view.viewSize.width = window.innerWidth; } const newPanelImageMaxHeight = `${(window.innerHeight - 30) * 0.5}px`; diff --git a/src/WasmPlayer/Program.cs b/src/WasmPlayer/Program.cs new file mode 100644 index 000000000..127a35f5a --- /dev/null +++ b/src/WasmPlayer/Program.cs @@ -0,0 +1,7 @@ +using System.Runtime.Versioning; + +[assembly: SupportedOSPlatform("browser")] + +// WASM entry point — runs once when the module loads via runMain(). +// [JSExport] methods on WasmPlayerBridge are available to JS after this returns. +return; diff --git a/src/WasmPlayer/WasmPlayer.csproj b/src/WasmPlayer/WasmPlayer.csproj new file mode 100644 index 000000000..821797ea7 --- /dev/null +++ b/src/WasmPlayer/WasmPlayer.csproj @@ -0,0 +1,42 @@ + + + net10.0 + browser-wasm + Exe + QuestViva.WasmPlayer + true + enable + default + true + + + + + + + + + + + <_PCRes>$(MSBuildThisFileDirectory)..\PlayerCore\Resources\ + + + <_FlatAssets Include="$(_PCRes)playercore.htm"/> + <_FlatAssets Include="$(_PCRes)player.js"/> + <_FlatAssets Include="$(_PCRes)playercore.js"/> + <_FlatAssets Include="$(_PCRes)playercore.css"/> + <_FlatAssets Include="$(_PCRes)grid.js"/> + <_FlatAssets Include="$(MSBuildThisFileDirectory)wasm-player.js"/> + <_FlatAssets Include="$(MSBuildThisFileDirectory)index.html"/> + <_LibAssets Include="$(_PCRes)lib\jquery-2.1.1.min.js"/> + <_LibAssets Include="$(_PCRes)lib\jquery-ui.min.js"/> + <_LibAssets Include="$(_PCRes)lib\jquery-ui.min.css"/> + <_LibAssets Include="$(_PCRes)lib\jquery.multi-open-accordion-1.5.3.js"/> + <_LibAssets Include="$(_PCRes)lib\paper.js"/> + <_ImageAssets Include="$(_PCRes)lib\images\*.png"/> + + + + + + diff --git a/src/WasmPlayer/WasmPlayer.linker.xml b/src/WasmPlayer/WasmPlayer.linker.xml new file mode 100644 index 000000000..962d85488 --- /dev/null +++ b/src/WasmPlayer/WasmPlayer.linker.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/src/WasmPlayer/WasmPlayerBridge.cs b/src/WasmPlayer/WasmPlayerBridge.cs new file mode 100644 index 000000000..8550ae82e --- /dev/null +++ b/src/WasmPlayer/WasmPlayerBridge.cs @@ -0,0 +1,466 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices.JavaScript; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using QuestViva.Common; +using QuestViva.Engine; +using QuestViva.PlayerCore; + +namespace QuestViva.WasmPlayer; + +[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] +public partial class WasmPlayerBridge +{ + private static IGame? _game; + private static PlayerHelper? _helper; + private static WasmPlayerUi? _ui; + + // ── JS → C# exports ───────────────────────────────────────────────────── + + [JSExport] + public static async Task Initialise(byte[] gameFileBytes, string filename) + { + _game?.Finish(); + + var provider = new ByteArrayGameDataProvider(gameFileBytes, filename); + var gameData = await provider.GetData(); + if (gameData == null) return false; + + var launcher = new GameLauncher(new WorldModelFactory()); + _game = launcher.GetGame(gameData, null); + if (_game == null) return false; + + _ui = new WasmPlayerUi(gameData.GameId); + _helper = new PlayerHelper(_game, _ui); + _ui.SetHelper(_helper); + + foreach (var name in _game.GetResourceNames()) + { + var stream = _game.GetResourceStream(name); + if (stream == null) continue; + using var ms = new MemoryStream(); + await stream.CopyToAsync(ms); + var mimeType = PlayerHelper.GetContentType(name); + var dataUrl = JsRegisterResource(name, mimeType, Convert.ToBase64String(ms.ToArray())); + _ui.RegisterResourceUrl(name, dataUrl); + } + + _game.LogError += ex => + { + _ui.OutputText("[Sorry, an error occurred]"); + JsConsoleError(ex.Message); + }; + _game.UpdateList += (listType, items) => _ui.HandleUpdateList(listType, items); + _game.Finished += () => _ui.HandleFinished(); + _game.RequestNextTimerTick += seconds => JsRequestNextTimerTick(seconds); + + var (ok, errors) = await _helper.Initialise(_ui); + if (!ok) + { + _ui.OutputText(string.Join("
", errors) + "
"); + _ui.FlushText(); + return false; + } + + var scripts = _game.GetExternalScripts(); + if (scripts != null) + { + foreach (var script in scripts) + JsAddExternalScript(_ui.GetURL(script)); + } + + var stylesheets = _game.GetExternalStylesheets(); + if (stylesheets != null) + { + foreach (var stylesheet in stylesheets) + JsAddExternalStylesheet(stylesheet); + } + + return true; + } + + [JSExport] + public static string GetGameId() => _ui?.GameId ?? string.Empty; + + [JSExport] + public static void Begin() + { + _game?.Begin(); + _ui?.FlushText(); + } + + [JSExport] + public static async Task SendCommand(string command, int tickCount, string? metadataJson) + { + if (_game == null || _ui == null || _ui.IsFinished) return; + IDictionary metadata = string.IsNullOrEmpty(metadataJson) + ? new Dictionary() + : JsonSerializer.Deserialize(metadataJson, WasmJsonContext.Default.DictionaryStringString) + ?? new Dictionary(); + await _game.SendCommand(command, tickCount, metadata); + _ui.FlushText(); + } + + [JSExport] + public static async Task SendEvent(string eventName, string param) + { + if (_game == null || _ui == null || _ui.IsFinished) return; + await _game.SendEvent(eventName, param); + _ui.FlushText(); + } + + [JSExport] + public static async Task FinishWait() + { + if (_game == null || _ui == null) return; + await _game.FinishWait(); + _ui.FlushText(); + } + + [JSExport] + public static async Task FinishPause() + { + if (_game == null || _ui == null) return; + await _game.FinishPause(); + _ui.FlushText(); + } + + [JSExport] + public static async Task SetMenuResponse(string? response) + { + if (_game == null || _ui == null) return; + await _game.SetMenuResponse(response); + _ui.FlushText(); + } + + [JSExport] + public static async Task SetQuestionResponse(bool response) + { + if (_game == null || _ui == null) return; + await _game.SetQuestionResponse(response); + _ui.FlushText(); + } + + [JSExport] + public static async Task Tick(int elapsedTime) + { + if (_game == null || _ui == null || _ui.IsFinished) return; + await _game.Tick(elapsedTime); + _ui.FlushText(); + } + + [JSExport] + public static async Task SaveGame(string html) + { + if (_game == null) return string.Empty; + var bytes = await _game.SaveAsync(html); + return Convert.ToBase64String(bytes); + } + + // ── C# → JS imports ───────────────────────────────────────────────────── + + [JSImport("addTextAndScroll", "wasm-player")] + internal static partial void JsAddTextAndScroll(string html); + + [JSImport("createNewDiv", "wasm-player")] + internal static partial void JsCreateNewDiv(string alignment); + + [JSImport("bindMenu", "wasm-player")] + internal static partial void JsBindMenu(string linkId, string verbs, string text, string elementId); + + [JSImport("showMenu", "wasm-player")] + internal static partial void JsShowMenu(string caption, string optionsJson, bool allowCancel); + + [JSImport("showQuestion", "wasm-player")] + internal static partial void JsShowQuestion(string caption); + + [JSImport("beginWait", "wasm-player")] + internal static partial void JsBeginWait(); + + [JSImport("beginPause", "wasm-player")] + internal static partial void JsBeginPause(int ms); + + [JSImport("updateLocation", "wasm-player")] + internal static partial void JsUpdateLocation(string location); + + [JSImport("setGameName", "wasm-player")] + internal static partial void JsSetGameName(string name); + + [JSImport("clearScreen", "wasm-player")] + internal static partial void JsClearScreen(); + + [JSImport("panesVisible", "wasm-player")] + internal static partial void JsPanesVisible(bool visible); + + [JSImport("updateStatus", "wasm-player")] + internal static partial void JsUpdateStatus(string text); + + [JSImport("setBackground", "wasm-player")] + internal static partial void JsSetBackground(string colour); + + [JSImport("setForeground", "wasm-player")] + internal static partial void JsSetForeground(string colour); + + [JSImport("updateList", "wasm-player")] + internal static partial void JsUpdateList(string listName, string itemsJson); + + [JSImport("updateCompass", "wasm-player")] + internal static partial void JsUpdateCompass(string data); + + [JSImport("gameFinished", "wasm-player")] + internal static partial void JsGameFinished(); + + [JSImport("requestNextTimerTick", "wasm-player")] + internal static partial void JsRequestNextTimerTick(int seconds); + + [JSImport("registerResource", "wasm-player")] + internal static partial string JsRegisterResource(string filename, string mimeType, string base64); + + [JSImport("uiShow", "wasm-player")] + internal static partial void JsUiShow(string element); + + [JSImport("uiHide", "wasm-player")] + internal static partial void JsUiHide(string element); + + [JSImport("addExternalScript", "wasm-player")] + internal static partial void JsAddExternalScript(string url); + + [JSImport("addExternalStylesheet", "wasm-player")] + internal static partial void JsAddExternalStylesheet(string url); + + [JSImport("playSound", "wasm-player")] + internal static partial void JsPlaySound(string url, bool synchronous, bool looped); + + [JSImport("stopSound", "wasm-player")] + internal static partial void JsStopSound(); + + [JSImport("runScript", "wasm-player")] + internal static partial void JsRunScript(string call); + + [JSImport("setCompassDirections", "wasm-player")] + internal static partial void JsSetCompassDirections(string dirsJson); + + [JSImport("setInterfaceString", "wasm-player")] + internal static partial void JsSetInterfaceString(string name, string text); + + [JSImport("setPanelContents", "wasm-player")] + internal static partial void JsSetPanelContents(string html); + + [JSImport("consoleError", "wasm-player")] + internal static partial void JsConsoleError(string message); + + [JSImport("consoleLog", "wasm-player")] + internal static partial void JsConsoleLog(string message); + + // ── Inner UI class ─────────────────────────────────────────────────────── + + private static readonly Dictionary ElementMap = new() + { + { "Panes", "#gamePanes" }, + { "Location", "#location" }, + { "Command", "#txtCommandDiv" } + }; + + private sealed class WasmPlayerUi : IPlayerHelperUI + { + private readonly Dictionary _resourceUrls = new(StringComparer.OrdinalIgnoreCase); + private readonly ListHandler _listHandler; + private PlayerHelper? _helper; + + public string GameId { get; } + public bool IsFinished { get; private set; } + + public WasmPlayerUi(string gameId) + { + GameId = gameId; + _listHandler = new ListHandler((identifier, args) => + { + if (identifier == "updateList" + && args?.Length == 2 + && args[0] is string listName + && args[1] is Dictionary items) + { + JsUpdateList(listName, JsonSerializer.Serialize(items, WasmJsonContext.Default.DictionaryStringString)); + } + else if (identifier == "updateCompass" + && args?.Length == 1 + && args[0] is string compassData) + { + JsUpdateCompass(compassData); + } + }); + } + + public void SetHelper(PlayerHelper helper) => _helper = helper; + + public void RegisterResourceUrl(string filename, string dataUrl) => + _resourceUrls[filename] = dataUrl; + + public string GetURL(string filename) + { + var match = _resourceUrls.Keys + .FirstOrDefault(k => string.Equals(k, filename, System.StringComparison.OrdinalIgnoreCase)); + return match != null ? _resourceUrls[match] : filename; + } + + public void FlushText() + { + if (_helper == null) return; + OutputText(_helper.ClearBuffer()); + } + + public void HandleUpdateList(ListType listType, List items) => + _listHandler.UpdateList(listType, items); + + public void HandleFinished() + { + JsGameFinished(); + IsFinished = true; + } + + // IPlayerHelperUI + public void OutputText(string text) + { + if (text.Length == 0) return; + // NOTE: Some existing games depend on newlines being stripped here. + text = text.Replace("\n", "").Replace("\r", ""); + JsAddTextAndScroll(text); + } + + public void SetAlignment(string alignment) + { + if (alignment.Length == 0) alignment = "left"; + FlushText(); + JsCreateNewDiv(alignment); + } + + public void BindMenu(string linkid, string verbs, string text, string elementId) => + JsBindMenu(linkid, verbs, text, elementId); + + // IPlayer + void IPlayer.ShowMenu(MenuData menuData) => + JsShowMenu(menuData.Caption, + JsonSerializer.Serialize( + menuData.Options as Dictionary ?? new Dictionary(menuData.Options), + WasmJsonContext.Default.DictionaryStringString), + menuData.AllowCancel); + + void IPlayer.DoWait() => JsBeginWait(); + + void IPlayer.DoPause(int ms) => JsBeginPause(ms); + + void IPlayer.ShowQuestion(string caption) => JsShowQuestion(caption); + + void IPlayer.SetWindowMenu(MenuData menuData) { } + + void IPlayer.PlaySound(string filename, bool synchronous, bool looped) => + JsPlaySound(GetURL(filename), synchronous, looped); + + void IPlayer.StopSound() => JsStopSound(); + + void IPlayer.WriteHTML(string html) => OutputText(html); + + string IPlayer.GetURL(string filename) => GetURL(filename); + + void IPlayer.LocationUpdated(string location) => JsUpdateLocation(location); + + void IPlayer.UpdateGameName(string name) => JsSetGameName(name); + + void IPlayer.ClearScreen() + { + FlushText(); + JsClearScreen(); + } + + void IPlayer.ShowPicture(string filename) + { + FlushText(); + OutputText($"
"); + } + + void IPlayer.SetPanesVisible(string data) => JsPanesVisible(data == "on"); + + void IPlayer.SetStatusText(string text) => + JsUpdateStatus(text.Replace(System.Environment.NewLine, "
")); + + void IPlayer.SetBackground(string colour) => JsSetBackground(colour); + + void IPlayer.SetForeground(string colour) + { + JsSetForeground(colour); + _helper?.SetForeground(colour); + } + + void IPlayer.SetLinkForeground(string colour) => _helper?.SetLinkForeground(colour); + + void IPlayer.RunScript(string function, object[]? parameters) + { + FlushText(); + var serializedArgs = string.Join(',', + parameters?.Select(SerializeJsArg) ?? System.Array.Empty()); + JsRunScript($"{function}({serializedArgs})"); + } + + private static string SerializeJsArg(object? arg) => arg switch + { + null => "null", + string s => JsonSerializer.Serialize(s, WasmJsonContext.Default.String), + bool b => b ? "true" : "false", + int i => i.ToString(CultureInfo.InvariantCulture), + long l => l.ToString(CultureInfo.InvariantCulture), + double d => d.ToString(CultureInfo.InvariantCulture), + float f => f.ToString(CultureInfo.InvariantCulture), + IDictionary dict => JsonSerializer.Serialize( + dict as Dictionary ?? new Dictionary(dict), + WasmJsonContext.Default.DictionaryStringString), + IEnumerable list => JsonSerializer.Serialize(list.ToArray(), WasmJsonContext.Default.StringArray), + _ => JsonSerializer.Serialize(arg.ToString() ?? "", WasmJsonContext.Default.String), + }; + + void IPlayer.Quit() { } + + void IPlayer.SetFont(string fontName) => _helper?.SetFont(fontName); + + void IPlayer.SetFontSize(string fontSize) => _helper?.SetFontSize(fontSize); + + void IPlayer.Speak(string text) { } + + void IPlayer.RequestSave(string html) => JsRunScript("saveGame()"); + + void IPlayer.Show(string element) + { + if (ElementMap.TryGetValue(element, out var jsElement)) + JsUiShow(jsElement); + } + + void IPlayer.Hide(string element) + { + if (ElementMap.TryGetValue(element, out var jsElement)) + JsUiHide(jsElement); + } + + void IPlayer.SetCompassDirections(IEnumerable dirs) => + JsSetCompassDirections(JsonSerializer.Serialize(dirs.ToArray(), WasmJsonContext.Default.StringArray)); + + void IPlayer.SetInterfaceString(string name, string text) => + JsSetInterfaceString(name, text); + + void IPlayer.SetPanelContents(string html) => JsSetPanelContents(html); + + void IPlayer.Log(string text) => JsConsoleLog(text); + + string? IPlayer.GetUIOption(UIOption option) => + option is UIOption.UseGameColours or UIOption.UseGameFont ? "true" : null; + } +} + +[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(string[]))] +[JsonSerializable(typeof(string))] +internal partial class WasmJsonContext : JsonSerializerContext { } diff --git a/src/WasmPlayer/dev-server.mjs b/src/WasmPlayer/dev-server.mjs new file mode 100644 index 000000000..3fc370662 --- /dev/null +++ b/src/WasmPlayer/dev-server.mjs @@ -0,0 +1,73 @@ +#!/usr/bin/env node +// Dev server for WasmPlayer — serves the Debug AppBundle with required COOP/COEP headers. +// Run: node dev-server.mjs +// Then open: http://localhost:5175/?game=/examples/simple.aslx + +import http from 'node:http'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const isRelease = process.argv.includes('--release'); +const config = isRelease ? 'Release' : 'Debug'; +const appBundleDir = path.resolve(__dirname, `bin/${config}/net10.0/browser-wasm/AppBundle`); +const examplesDir = path.resolve(__dirname, '../../examples'); +const port = 5175; + +const mimeTypes = { + '.html': 'text/html', + '.js': 'application/javascript', + '.mjs': 'application/javascript', + '.wasm': 'application/wasm', + '.json': 'application/json', + '.css': 'text/css', + '.png': 'image/png', + '.dat': 'application/octet-stream', + '.blat': 'application/octet-stream', + '.aslx': 'application/xml', + '.asl': 'text/plain', +}; + +function serveFile(res, filePath) { + fs.readFile(filePath, (err, data) => { + if (err) { + res.writeHead(404); + res.end(`Not found: ${filePath}`); + return; + } + const ext = path.extname(filePath); + res.writeHead(200, { + 'Content-Type': mimeTypes[ext] ?? 'application/octet-stream', + 'Cache-Control': 'no-cache, no-store, must-revalidate', + }); + res.end(data); + }); +} + +const server = http.createServer((req, res) => { + // Required for SharedArrayBuffer used by the .NET WASM runtime + res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); + res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp'); + + let urlPath = req.url?.split('?')[0] ?? '/'; + if (urlPath === '/') urlPath = '/index.html'; + + if (urlPath.startsWith('/examples/')) { + const filePath = path.join(examplesDir, urlPath.slice('/examples/'.length)); + if (!filePath.startsWith(examplesDir)) { res.writeHead(403); res.end(); return; } + serveFile(res, filePath); + return; + } + + const filePath = path.join(appBundleDir, urlPath); + if (!filePath.startsWith(appBundleDir)) { res.writeHead(403); res.end(); return; } + serveFile(res, filePath); +}); + +server.listen(port, () => { + console.log(`WasmPlayer dev server (${config}) running at http://localhost:${port}/`); + console.log(`Serving: ${appBundleDir}`); + console.log(`Try: http://localhost:${port}/?game=/examples/simple.aslx`); + console.log('Press Ctrl+C to stop.'); +}); diff --git a/src/WasmPlayer/index.html b/src/WasmPlayer/index.html new file mode 100644 index 000000000..94517d75d --- /dev/null +++ b/src/WasmPlayer/index.html @@ -0,0 +1,19 @@ + + + + + + Quest Viva + + + + + + + + + + + + + diff --git a/src/WasmPlayer/wasm-player.js b/src/WasmPlayer/wasm-player.js new file mode 100644 index 000000000..e9166f14c --- /dev/null +++ b/src/WasmPlayer/wasm-player.js @@ -0,0 +1,207 @@ +// WasmPlayer JS bootstrap — replaces playerweb.js for the WASM-hosted player. +// Defines the WebPlayer object surface that playercore.js / player.js call into, +// then initialises the .NET WASM runtime and wires up [JSImport] callbacks. + +var platform = "wasmplayer"; + +var _audio = null; + +function ui_init() { } + +function sendEndWait() { + window.setTimeout(async function () { + await WebPlayer.uiEndWait(); + }, 100); + waitEnded(); +} + +function afterSendCommand() { } + +function playSound(url, synchronous, looped) { + stopSound(); + _audio = new Audio(url); + if (looped) _audio.loop = true; + if (synchronous) { + var showCmdDiv = isElementVisible("#txtCommandDiv"); + _waitingForSoundToFinish = true; + $("#txtCommandDiv").hide(); + _audio.addEventListener('ended', function () { finishSync(showCmdDiv); }); + } + _audio.play(); +} + +function stopSound() { + if (_audio !== null) { + _audio.pause(); + _audio.src = ''; + _audio = null; + } +} + +function finishSync(showCommandDiv) { + _waitingForSoundToFinish = false; + window.setTimeout(async function () { + if (showCommandDiv) $("#txtCommandDiv").show(); + await WebPlayer.uiEndWait(); + }, 100); +} + +// The WebPlayer object — same surface API as playerweb.js so that playercore.js +// and player.js can call into it without modification. +window.WebPlayer = { + gameId: null, + + initUI() { initPlayerUI(); }, + + setCanDebug(value) { + const cmdDebug = document.getElementById("cmdDebug"); + if (cmdDebug) cmdDebug.style.display = value ? "initial" : "none"; + }, + + setCanSave(value) { + const cmdSave = document.getElementById("cmdSave"); + if (cmdSave) cmdSave.style.display = value ? "initial" : "none"; + if (!value) window.saveGame = () => addText("Disabled"); + }, + + setAnimateScroll(value) { _animateScroll = value; }, + + runJs(scripts) { + const globalEval = window.eval; + for (const script of scripts) { + try { globalEval(script); } catch (e) { console.error(e); } + } + }, + + async sendCommand(command, tickCount, metadata) { + const metadataJson = metadata ? JSON.stringify(metadata) : null; + await Bridge.SendCommand(command, tickCount, metadataJson); + canSendCommand = true; + }, + + async uiChoice(choice) { await Bridge.SetMenuResponse(choice); }, + async uiChoiceCancel() { await Bridge.SetMenuResponse(null); }, + async uiTick(tickCount) { await Bridge.Tick(tickCount); }, + async uiEndWait() { await Bridge.FinishWait(); }, + async uiEndPause() { await Bridge.FinishPause(); }, + async uiSetQuestionResponse(response) { await Bridge.SetQuestionResponse(response); }, + + async uiSendEvent(eventName, param) { + await Bridge.SendEvent(eventName, param); + canSendCommand = true; + }, + + async uiSaveGame(html) { + const base64 = await Bridge.SaveGame(html); + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; + }, + + listSaves: async () => GameSaver.listSaves(), + loadSlot: async (slot) => GameSaver.load(slot), +}; + +// Exported bridge reference — set once WASM is loaded. +var Bridge; + +function addPaperScript() { + const canvas = document.getElementById('gridCanvas'); + const gridPanel = document.getElementById('gridPanel'); + if (canvas instanceof HTMLCanvasElement && gridPanel) { + gridPanel.appendChild(canvas); + canvas.style.display = ''; + if (window.paper && paper.view) { + paper.view.viewSize = new paper.Size(canvas.clientWidth, canvas.clientHeight); + } + } +} + +async function initWasmPlayer(gameFileUrl, filename) { + const [htmResponse, { dotnet }] = await Promise.all([ + fetch('playercore.htm'), + import('./_framework/dotnet.js'), + ]); + document.body.innerHTML = await htmResponse.text(); + + const runtime = await dotnet.create(); + const { setModuleImports, getAssemblyExports, getConfig } = runtime; + + setModuleImports("wasm-player", { + addTextAndScroll: (html) => addTextAndScroll(html), + createNewDiv: (alignment) => createNewDiv(alignment), + bindMenu: (linkId, verbs, text, elementId) => bindMenu(linkId, verbs, text, elementId), + showMenu: (caption, optionsJson, allowCancel) => showMenu(caption, JSON.parse(optionsJson), allowCancel), + showQuestion: (caption) => showQuestion(caption), + beginWait: () => beginWait(), + beginPause: (ms) => beginPause(ms), + updateLocation: (loc) => updateLocation(loc), + setGameName: (name) => setGameName(name), + clearScreen: () => clearScreen(), + panesVisible: (visible) => panesVisible(visible), + updateStatus: (text) => updateStatus(text), + setBackground: (colour) => setBackground(colour), + setForeground: (colour) => setForeground(colour), + updateList: (listName, itemsJson) => updateList(listName, JSON.parse(itemsJson)), + updateCompass: (data) => updateCompass(data), + gameFinished: () => gameFinished(), + requestNextTimerTick: (seconds) => requestNextTimerTick(seconds), + registerResource: (filename, mimeType, base64) => { + return `data:${mimeType || 'application/octet-stream'};base64,${base64}`; + }, + uiShow: (element) => uiShow(element), + uiHide: (element) => uiHide(element), + addExternalScript: (url) => addExternalScript(url), + addExternalStylesheet: (url) => addExternalStylesheet(url), + playSound: (url, synchronous, looped) => playSound(url, synchronous, looped), + stopSound: () => stopSound(), + runScript: (call) => { + const globalEval = window.eval; + try { globalEval(call); } catch (e) { console.error(e); } + }, + setCompassDirections: (dirsJson) => setCompassDirections(JSON.parse(dirsJson)), + setInterfaceString: (name, text) => setInterfaceString(name, text), + setPanelContents: (html) => setPanelContents(html), + consoleError: (msg) => console.error('[Quest]', msg), + consoleLog: (msg) => console.log('[Quest]', msg), + }); + + await runtime.runMain(); + + const config = getConfig(); + const exports = await getAssemblyExports(config.mainAssemblyName); + Bridge = exports.QuestViva.WasmPlayer.WasmPlayerBridge; + + const response = await fetch(gameFileUrl); + if (!response.ok) { + document.body.innerHTML = `

Failed to load game: ${response.statusText}

`; + return; + } + const gameBytes = new Uint8Array(await response.arrayBuffer()); + + const ok = await Bridge.Initialise(gameBytes, filename); + if (!ok) { + console.error('[Quest] Failed to initialise game'); + return; + } + + WebPlayer.gameId = Bridge.GetGameId(); + WebPlayer.initUI(); + WebPlayer.setCanSave(true); + + Bridge.Begin(); +} + +(function () { + const params = new URLSearchParams(window.location.search); + const gameUrl = params.get('game'); + if (!gameUrl) { + document.addEventListener('DOMContentLoaded', () => { + document.body.innerHTML = '

No game specified. Add ?game=path/to/game.aslx to the URL.

'; + }); + return; + } + const filename = gameUrl.split('/').pop() || 'game.aslx'; + initWasmPlayer(gameUrl, filename).catch(e => console.error('[Quest] Init failed:', e)); +})(); diff --git a/src/WebPlayer/Components/Game.razor b/src/WebPlayer/Components/Game.razor index df6f70720..a53c6eeef 100644 --- a/src/WebPlayer/Components/Game.razor +++ b/src/WebPlayer/Components/Game.razor @@ -17,7 +17,7 @@ - +