From 1bd1746ea7b2d9798d3115869404d3e58483081c Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Sun, 28 Jun 2026 16:46:25 +0100 Subject: [PATCH 1/9] Add WasmPlayer: browser-WASM game player (Phase 6) - New `src/WasmPlayer/` project targeting `browser-wasm` with `[JSExport]` entry points for game control and `[JSImport]` callbacks for all UI events - Move `player.js` from `WebPlayer/wwwroot/` into `PlayerCore/Resources/` so it's shared between WebPlayer and WasmPlayer; update Game.razor src path - `index.html` embeds the playercore.htm UI inline for a standalone AppBundle - `wasm-player.js` replaces `playerweb.js` with WASM bridge calls behind the same `WebPlayer.*` surface API that playercore.js and player.js already use Co-Authored-By: Claude Sonnet 4.6 --- QuestViva.sln | 15 + docs/async-ncalc-wasm-plan.md | 2 +- src/PlayerCore/PlayerCore.csproj | 1 + .../Resources}/player.js | 0 src/WasmPlayer/Program.cs | 7 + src/WasmPlayer/WasmPlayer.csproj | 43 ++ src/WasmPlayer/WasmPlayerBridge.cs | 439 ++++++++++++++++++ src/WasmPlayer/index.html | 165 +++++++ src/WasmPlayer/wasm-player.js | 191 ++++++++ src/WebPlayer/Components/Game.razor | 2 +- 10 files changed, 863 insertions(+), 2 deletions(-) rename src/{WebPlayer/wwwroot => PlayerCore/Resources}/player.js (100%) create mode 100644 src/WasmPlayer/Program.cs create mode 100644 src/WasmPlayer/WasmPlayer.csproj create mode 100644 src/WasmPlayer/WasmPlayerBridge.cs create mode 100644 src/WasmPlayer/index.html create mode 100644 src/WasmPlayer/wasm-player.js 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 index 86696be35..76864f2b0 100644 --- a/docs/async-ncalc-wasm-plan.md +++ b/docs/async-ncalc-wasm-plan.md @@ -163,7 +163,7 @@ background threads, no blocking waits — making it fully WASM-compatible. --- -## Phase 6 — WasmPlayer +## Phase 6 — WasmPlayer 🚧 The UI approach should follow WasmEditor (pure JS + C# WASM DLL) rather than Blazor WASM. Reasons: diff --git a/src/PlayerCore/PlayerCore.csproj b/src/PlayerCore/PlayerCore.csproj index 9892f7e20..243665705 100644 --- a/src/PlayerCore/PlayerCore.csproj +++ b/src/PlayerCore/PlayerCore.csproj @@ -16,6 +16,7 @@ + diff --git a/src/WebPlayer/wwwroot/player.js b/src/PlayerCore/Resources/player.js similarity index 100% rename from src/WebPlayer/wwwroot/player.js rename to src/PlayerCore/Resources/player.js 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..3ba4def40 --- /dev/null +++ b/src/WasmPlayer/WasmPlayer.csproj @@ -0,0 +1,43 @@ + + + net10.0 + browser-wasm + Exe + QuestViva.WasmPlayer + true + enable + default + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/WasmPlayer/WasmPlayerBridge.cs b/src/WasmPlayer/WasmPlayerBridge.cs new file mode 100644 index 000000000..1bde58e66 --- /dev/null +++ b/src/WasmPlayer/WasmPlayerBridge.cs @@ -0,0 +1,439 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices.JavaScript; +using System.Text.Json; +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) + ?? 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)); + } + 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), 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(arg => JsonSerializer.Serialize(arg)) ?? System.Array.Empty()); + JsRunScript($"{function}({serializedArgs})"); + } + + 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)); + + 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; + } +} diff --git a/src/WasmPlayer/index.html b/src/WasmPlayer/index.html new file mode 100644 index 000000000..1c433623d --- /dev/null +++ b/src/WasmPlayer/index.html @@ -0,0 +1,165 @@ + + + + + + Quest Viva + + + + + + + + + + + + + +
+

+ +
+
+

+
+ + diff --git a/src/WasmPlayer/wasm-player.js b/src/WasmPlayer/wasm-player.js new file mode 100644 index 000000000..5d008ac2d --- /dev/null +++ b/src/WasmPlayer/wasm-player.js @@ -0,0 +1,191 @@ +// 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; + +async function initWasmPlayer(gameFileUrl, filename) { + const { dotnet } = await import('./_framework/dotnet.js'); + + 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 @@ - + - - -
-

- -
-
-

-
diff --git a/src/WasmPlayer/wasm-player.js b/src/WasmPlayer/wasm-player.js index 5d008ac2d..e9166f14c 100644 --- a/src/WasmPlayer/wasm-player.js +++ b/src/WasmPlayer/wasm-player.js @@ -106,8 +106,24 @@ window.WebPlayer = { // 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 { dotnet } = await import('./_framework/dotnet.js'); + 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;