Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ jobs:
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
dotnet-version: '10.0.x'

- name: Install wasm-tools workload
run: dotnet workload install wasm-tools

# Restore dependencies
- name: Restore dependencies
Expand Down
18 changes: 17 additions & 1 deletion QuestViva.sln
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
Microsoft Visual Studio Solution File, Format Version 12.00

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.10.35122.118
MinimumVisualStudioVersion = 10.0.40219.1
Expand Down Expand Up @@ -37,6 +38,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UtilityTests", "tests\Utili
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebPlayerTests", "tests\WebPlayerTests\WebPlayerTests.csproj", "{5F894C2F-4251-4594-B069-C1818D1BCD00}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WasmEditor", "src\WasmEditor\WasmEditor.csproj", "{A84D0E9B-E449-456B-B82E-C83FA68DE623}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -203,6 +206,18 @@ Global
{5F894C2F-4251-4594-B069-C1818D1BCD00}.Release|x86.Build.0 = Release|Any CPU
{5F894C2F-4251-4594-B069-C1818D1BCD00}.Release|x64.ActiveCfg = Release|Any CPU
{5F894C2F-4251-4594-B069-C1818D1BCD00}.Release|x64.Build.0 = Release|Any CPU
{A84D0E9B-E449-456B-B82E-C83FA68DE623}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A84D0E9B-E449-456B-B82E-C83FA68DE623}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A84D0E9B-E449-456B-B82E-C83FA68DE623}.Debug|x86.ActiveCfg = Debug|Any CPU
{A84D0E9B-E449-456B-B82E-C83FA68DE623}.Debug|x86.Build.0 = Debug|Any CPU
{A84D0E9B-E449-456B-B82E-C83FA68DE623}.Debug|x64.ActiveCfg = Debug|Any CPU
{A84D0E9B-E449-456B-B82E-C83FA68DE623}.Debug|x64.Build.0 = Debug|Any CPU
{A84D0E9B-E449-456B-B82E-C83FA68DE623}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A84D0E9B-E449-456B-B82E-C83FA68DE623}.Release|Any CPU.Build.0 = Release|Any CPU
{A84D0E9B-E449-456B-B82E-C83FA68DE623}.Release|x86.ActiveCfg = Release|Any CPU
{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
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -221,6 +236,7 @@ Global
{14713715-BBC2-4F0F-A247-A11994EC4E4F} = {DE73B1F0-E136-444D-97EF-B31B1592532F}
{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}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {18B8911F-786E-48E1-8F32-208060A4A2C6}
Expand Down
8 changes: 8 additions & 0 deletions build-editor.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -e

echo "Building WasmEditor..."
dotnet build src/WasmEditor/WasmEditor.csproj

echo ""
echo "Done. Refresh http://localhost:5174 to pick up the new build."
173 changes: 173 additions & 0 deletions docs/webeditor-wasm-svelte.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
# WebEditor: EditorCore WASM + SvelteKit

## Architecture

```
Browser
├── SvelteKit frontend (src/WebEditor/)
│ ├── TreePanel — game object hierarchy (Skeleton TreeView)
│ ├── PropertyEditor — attribute display (placeholder; full editors in Phase 3)
│ ├── Toolbar — save, undo/redo (Skeleton AppBar)
│ └── [future] ScriptEditor, StatusBar
└── .NET WASM module (src/WasmEditor/)
├── WasmEditorBridge — thin JSExport interop layer
└── EditorCore → Engine / Utility / Common
```

All data crossing the JS/WASM boundary is JSON. The Svelte layer calls `[JSExport]` methods on the bridge; events fire back synchronously during `Initialise` and are collected in C# lists before being returned via `GetTreeNodes()`.

---

## WasmEditor project

`src/WasmEditor/` targets `net10.0` with `<RuntimeIdentifier>browser-wasm</RuntimeIdentifier>`. It depends on `EditorCore` and `Common`.

### Implemented bridge API (`WasmEditorBridge.cs`)

```csharp
[JSExport] Task<bool> Initialise(byte[] gameFileBytes, string filename)
[JSExport] string GetTreeNodes() // JSON: List<{key, text, parent}>
[JSExport] string? GetEditorData(string key) // JSON: Dictionary<string, string?>
[JSExport] string Save() // returns XML
[JSExport] void Undo()
[JSExport] void Redo()
```

Tree nodes are collected during `Initialise` by subscribing to `EditorController.AddedNode`, then returned as a flat list via `GetTreeNodes()`. `GetEditorData` uses `IEditorDataExtendedAttributeInfo` to enumerate attributes for a given element key.

### Not yet implemented

- `SetAttribute` — needed for any editing; blocked on Phase 3
- Live JS callbacks (JSImport) — events currently captured into C# state at init time rather than pushed to JS as they occur

### Supporting types

- `WasmConfig` (`src/WasmEditor/WasmConfig.cs`) — `IConfig` implementation with `UseNCalc = true`
- `ByteArrayGameDataProvider` (`src/Common/`) — wraps a `byte[]` as a `MemoryStream` so `EditorController` can load a file passed in from the browser File API

### Build

```bash
dotnet build src/WasmEditor --configuration Debug
```

Output lands in `src/WasmEditor/bin/Debug/net10.0/browser-wasm/AppBundle/`. The Vite dev server serves this directory at `/AppBundle/` (see `vite.config.ts`).

---

## SvelteKit frontend

`src/WebEditor/` is a SvelteKit SPA (adapter-static, `fallback: 'index.html'`).

### Stack

- **Svelte 5** + **SvelteKit 2**
- **Tailwind CSS 4** via `@tailwindcss/vite`
- **Skeleton UI v4** (`@skeletonlabs/skeleton` + `@skeletonlabs/skeleton-svelte`) — cerberus theme
- **ESLint** with `typescript-eslint` + `eslint-plugin-svelte` (double quotes, semicolons, 4-space indent)

### File structure

```
src/WebEditor/
├── eslint.config.mjs
├── svelte.config.js # adapter-static, $components alias
├── vite.config.ts # AppBundle middleware, COOP/COEP headers
├── src/
│ ├── app.css # @import tailwindcss + skeleton + cerberus theme
│ ├── app.html # data-theme="cerberus"
│ ├── lib/
│ │ ├── wasm.ts # loads dotnet.js, exposes WasmBridge
│ │ ├── editor-store.ts # Svelte stores + wrappers for all WASM calls
│ │ └── types.ts # TreeNode, ElementAttributes
│ ├── components/
│ │ ├── Toolbar.svelte # AppBar: title, filename, undo/redo/save
│ │ ├── TreePanel.svelte # Skeleton TreeView (flat→hierarchy conversion)
│ │ └── PropertyEditor.svelte # Raw attribute grid (placeholder)
│ └── routes/
│ ├── +layout.svelte # imports app.css
│ ├── +layout.ts # prerendering config
│ ├── +page.svelte # Welcome: file picker → openGame() → /editor
│ └── editor/
│ └── +page.svelte # Editor layout: Toolbar + TreePanel + PropertyEditor
```

### Running the dev server

Requires a WasmEditor Debug build first (see above), then:

```bash
cd src/WebEditor
npm run dev # http://localhost:5174
```

The Vite dev server sets `Cross-Origin-Opener-Policy: same-origin` and `Cross-Origin-Embedder-Policy: require-corp` headers, which are required for the .NET WASM runtime's use of `SharedArrayBuffer`.

### WASM loading

`wasm.ts` uses `new Function("url", "return import(url)")` to load `dotnet.js` at runtime. This prevents Vite's import-analysis plugin from trying to resolve the URL at build time (the file only exists as a runtime-served AppBundle asset).

---

## File handling

The browser cannot read files from the local filesystem directly. Currently implemented: **Option A**.

**Option A (implemented)** — File picker open, download save
- User opens a file via `<input type="file">`
- JS reads it as `ArrayBuffer`, converts to `Uint8Array`, passes to `Initialise`
- Save triggers a download via a temporary `<a>` with an object URL

**Option B (future)** — OPFS (Origin Private File System)
- Game files live in the browser's private storage
- Read/write without prompts after initial import
- Enables auto-save

---

## Phased delivery

### Phase 1 — WASM proof of concept ✅
- Removed `System.Data.DataSetExtensions` from `EditorCore.csproj`
- Changed `EditorController.Initialise` to accept `IGameDataProvider` instead of a filename string
- Created `WasmEditor` project, compiles to WASM with zero warnings
- Exported minimal API: init, get tree, get element data, save, undo, redo
- `ByteArrayGameDataProvider` in `Common` wraps `byte[]` from JS
- Source-generated JSON serialisation (`JsonSerializerContext`) throughout

### Phase 2 — SvelteKit skeleton ✅
- SvelteKit SPA at `src/WebEditor/`; Vite AppBundle middleware for WASM serving
- File picker → bytes → `Initialise` → tree rendered with Skeleton `TreeView`
- Click a tree node → attribute list in `PropertyEditor`
- Toolbar: save (file download), undo, redo
- Skeleton UI v4 + Tailwind CSS 4 + ESLint configured

### Phase 3 — Property editors (next)

The current `PropertyEditor` is a raw key/value dump. Phase 3 replaces it with proper typed controls.

Steps:
1. Implement `SetAttribute(string element, string attribute, string jsonValue)` in `WasmEditorBridge`
2. Extend `GetEditorData` response to include the EditorCore control type for each attribute (dropdown, textbox, checkbox, script, etc.)
3. Map each control type to a Svelte component in `PropertyEditor`
4. Two-way binding: editing a control → `SetAttribute` → refresh affected state

### Phase 4 — Script editor
- Integrate Monaco or CodeMirror 6 for script attribute editing
- Wire up the script editor data API from EditorCore

### Phase 5 — Full feature parity
- New game (from templates)
- Publish/export
- OPFS persistence
- Test suite for the WASM bridge

---

## Open questions

- **EditorCore API cleanup**: `EditorMode` (Desktop/Web distinction) and `AddControlType`/`GetControlType` (.NET UI control registration) are dead weight now the only consumer is Svelte. Remove in a future cleanup pass.
- **Live C# → JS events**: Currently tree state is snapshot-based (collected at init). As editing adds/removes/renames nodes, the bridge will need to push updates to JS rather than relying on a full re-fetch.
- **Build integration**: Should `WasmEditor` output be served by `WebPlayer` (embedded SPA) or deployed separately?
- **Auth / server-side storage**: Is cloud save in scope? Affects whether the editor stays purely client-side.
14 changes: 14 additions & 0 deletions src/Common/ByteArrayGameDataProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using System.IO;
using System.Threading.Tasks;

namespace QuestViva.Common;

public class ByteArrayGameDataProvider(byte[] data, string filename) : IGameDataProvider
{
public Task<GameData?> GetData()
{
var stream = new MemoryStream(data);
var gameId = Path.GetFileName(filename);
return Task.FromResult<GameData?>(new GameData(stream, gameId, filename, this));
}
}
6 changes: 2 additions & 4 deletions src/EditorCore/EditorController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -328,13 +328,11 @@ internal InitialiseResults(bool success)
// newThread.Start();
// }

public async Task<bool> Initialise(IConfig config, string filename, bool partialInit = false)
public async Task<bool> Initialise(IConfig config, IGameDataProvider gameDataProvider, bool partialInit = false)
{
m_lastelementscutout = false;
m_filename = filename;
// TODO: ResourceProvider is probably not relevant here?
var gameDataProvider = new FileGameDataProvider(filename);
var gameData = await gameDataProvider.GetData();
m_filename = gameData?.Filename ?? string.Empty;
m_worldModel = new WorldModel(config, gameData, null);
m_scriptFactory = new ScriptFactory(m_worldModel);
m_worldModel.ElementFieldUpdated += m_worldModel_ElementFieldUpdated;
Expand Down
1 change: 0 additions & 1 deletion src/EditorCore/EditorCore.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
<ProjectReference Include="..\Engine\Engine.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="System.Data.DataSetExtensions" Version="4.5.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
</ItemGroup>
</Project>
7 changes: 7 additions & 0 deletions src/WasmEditor/Program.cs
Original file line number Diff line number Diff line change
@@ -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 WasmEditorBridge are available to JS after this returns.
return;
8 changes: 8 additions & 0 deletions src/WasmEditor/WasmConfig.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using QuestViva.Common;

namespace QuestViva.WasmEditor;

internal class WasmConfig : IConfig
{
public bool UseNCalc => true;
}
15 changes: 15 additions & 0 deletions src/WasmEditor/WasmEditor.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RuntimeIdentifier>browser-wasm</RuntimeIdentifier>
<OutputType>Exe</OutputType>
<RootNamespace>QuestViva.WasmEditor</RootNamespace>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Nullable>enable</Nullable>
<LangVersion>default</LangVersion>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\EditorCore\EditorCore.csproj" />
<ProjectReference Include="..\Common\Common.csproj" />
</ItemGroup>
</Project>
74 changes: 74 additions & 0 deletions src/WasmEditor/WasmEditorBridge.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.InteropServices.JavaScript;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using QuestViva.Common;
using QuestViva.EditorCore;

namespace QuestViva.WasmEditor;

internal record TreeNodeData(string Key, string Text, string? Parent);

[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(List<TreeNodeData>))]
[JsonSerializable(typeof(Dictionary<string, string>))]
internal partial class WasmEditorJsonContext : JsonSerializerContext { }

[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
public partial class WasmEditorBridge
{
private static EditorController? _controller;
private static readonly List<TreeNodeData> TreeNodes = [];

[JSExport]
public static async Task<bool> Initialise(byte[] gameFileBytes, string filename)
{
_controller?.Dispose();
TreeNodes.Clear();

_controller = new EditorController();
_controller.ClearTree += (_, _) => { };
_controller.BeginTreeUpdate += (_, _) => { };
_controller.EndTreeUpdate += (_, _) => { };
_controller.AddedNode += OnAddedNode;

var provider = new ByteArrayGameDataProvider(gameFileBytes, filename);
var ok = await _controller.Initialise(new WasmConfig(), provider);
if (ok) _controller.UpdateTree();
return ok;
}

[JSExport]
public static string GetTreeNodes() =>
JsonSerializer.Serialize(TreeNodes, WasmEditorJsonContext.Default.ListTreeNodeData);

[JSExport]
public static string? GetEditorData(string key)
{
var data = _controller?.GetEditorData(key);
if (data is not IEditorDataExtendedAttributeInfo extended) return null;

var attrs = new Dictionary<string, string?>();
foreach (var attr in extended.GetAttributeData())
{
attrs[attr.AttributeName] = data.GetAttribute(attr.AttributeName)?.ToString();
}
return JsonSerializer.Serialize(attrs, WasmEditorJsonContext.Default.DictionaryStringString);
}

[JSExport]
public static string Save() => _controller?.Save() ?? string.Empty;

[JSExport]
public static void Undo() => _controller?.Undo();

[JSExport]
public static void Redo() => _controller?.Redo();

private static void OnAddedNode(object? sender, EditorController.AddedNodeEventArgs e)
{
TreeNodes.Add(new TreeNodeData(e.Key, e.Text, e.Parent));
}
}
3 changes: 3 additions & 0 deletions src/WebEditor/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules
.svelte-kit
build
Loading
Loading