Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef20b0d6c3 | ||
|
|
4638c06b68 | ||
|
|
b8bf9f1510 | ||
|
|
7c63f01527 | ||
|
|
487ca40a44 | ||
|
|
a5e5b6f34a |
@@ -3,5 +3,7 @@
|
||||
/dist/
|
||||
/node_modules/
|
||||
/emsdk
|
||||
# generated by scripts/build.sh from vendor/vpinball, not source-controlled
|
||||
/package/assets/
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
@@ -4,18 +4,22 @@
|
||||
|
||||
This project ports [Visual Pinball](https://github.com/vpinball/vpinball) (the C++ pinball table simulator) to WebAssembly via Emscripten. It is a source-level port of the actual engine — not a reimplementation — so table compatibility, physics behavior, and scripting semantics come from the real codebase real tables already run against today.
|
||||
|
||||
## Status: engine boots and renders in-browser; main loop rewrite is the one remaining blocker
|
||||
## Status: a real, playable table, live in the browser — keyboard and audio confirmed
|
||||
|
||||
This is further along than a typical "does it compile" milestone. As of this writing, the patched engine, running in Chrome via this project's build:
|
||||
The patched engine, running in a real browser via this project's build, renders and plays a real, interactive, physically-simulated pinball table from a real `.vpx` file — lit flippers that respond to Left/Right Shift, slingshots, bumpers, targets, a plunger, drain detection, and audible sound effects — all driven by a real per-frame game loop, not a static screenshot. This has been confirmed by hands-on manual testing in Chrome, not just code review. Concretely, verified end-to-end:
|
||||
|
||||
- Loads a real `.vpx` file (the bundled default table) and parses it completely (OLE/BIFF container, all game items, images, sounds metadata).
|
||||
- Initializes SDL3's Emscripten audio backend and creates a real window via SDL3's Emscripten video driver.
|
||||
- Compiles **all of vpinball's real, unmodified `.glfx` shaders** (UI, Basic, Ball, DMD, Flasher, Light, Framebuffer — 80 shaders total) against WebGL2/GLES3.
|
||||
- Computes environment map radiance (HDR/IBL, via FreeImage decoding the table's `.exr` asset) and runs the static pre-render pass (including reflection probes).
|
||||
- Initializes the real physics engine (octree construction) and starts the real VBScript scripting engine (libwinevbs).
|
||||
- Reaches `Player::Player@832 Startup done` / `Unpausing Game` — i.e., initialization completes successfully end to end.
|
||||
- Loads a real `.vpx` file and parses it completely (OLE/BIFF container, all game items, images, sounds metadata).
|
||||
- Compiles **all of vpinball's real, unmodified `.glfx` shaders** (80 shaders across UI/Basic/Ball/DMD/Flasher/Light/Framebuffer) against WebGL2/GLES3, computes environment map radiance (HDR/IBL), and runs the static pre-render pass.
|
||||
- Runs the real physics engine and the real VBScript scripting engine (`libwinevbs`) — confirmed by pressing a physical key and watching a table-script-driven flipper (`Table1_KeyDown` → `LeftFlipper.RotateToEnd`) actually move.
|
||||
- Runs a real per-frame game loop (`Player::EmscriptenStepFrame()`, driven by `emscripten_set_main_loop`) stepping input, physics, and rendering every frame, with a clean `stop()` → `~Player()` teardown mid-session and no crash or hang.
|
||||
- **Keyboard input and audio, confirmed by ear and by hand** against the bundled default table (see below) — the full DOM → SDL3 → `InputManager` → VBScript-dispatch pipeline, and the full audio pipeline, both work.
|
||||
- Ships the bundled default table (`exampleTable.vpx`, a real playable demo table with working gameplay logic) at ~34MB total (`vpinball.wasm` + `vpinball.data` + `vpinball.js`) after trimming ~52MB of editor-only content never used by the player runtime (bundled native-editor templates and a Monaco code editor) — see [Roadmap](#roadmap).
|
||||
|
||||
It then hangs: the classic desktop game loop (`FramePacingGameLoop`/`GPUQueueStuffingGameLoop` in `player.cpp`) is a blocking native `while` loop, which cannot run on a single-threaded WASM main thread without yielding control back to the browser. This is the one deliberately-deferred piece of engineering work — see [Roadmap](#roadmap) below. Everything upstream of it (file loading, rendering setup, shader compilation, physics init, script engine startup) is proven working, in the real engine, in a real browser.
|
||||
Getting here required finding and fixing four real, previously-unknown bugs in the upstream engine (not just adding a new CMake target) — see `patches/vpinball/0003-*`, `0004-*` and `0005-*` for the exact fixes:
|
||||
1. `RenderDevice::WaitForVSync()` unconditionally spawned a real `std::thread` every frame, even on `__STANDALONE__` builds — a hard crash under Emscripten's single-threaded runtime, independent of and encountered before the main-loop-yielding problem below.
|
||||
2. The desktop game loop (`FramePacingGameLoop`/`GPUQueueStuffingGameLoop`) is a blocking native `while` loop with manual `uSleep` throttling — incompatible with a single-threaded WASM main thread, which must yield control back to the browser every frame. This project adds a new `Player::EmscriptenStepFrame()` (one frame, no internal loop) driven by `emscripten_set_main_loop`, plus new JS-callable lifecycle entry points (`vpinball_wasm_start`/`stop`/`dispose`/`eval_script` in `src/core/EmscriptenBridge.cpp`) that don't route through the desktop `main()`/`WinMain()` chain at all, since that chain assumes the whole process runs exactly one table to completion then exits.
|
||||
3. The window's OpenGL back buffer was created with the window's *logical* (CSS) pixel size instead of its *device* pixel size — on any browser tab with `devicePixelRatio != 1` (essentially all HiDPI displays), the GL viewport only covered a fraction of the canvas's actual backing buffer, rendering anchored to one corner (GL's viewport origin) instead of filling the canvas.
|
||||
4. `Window::OnResized()` (called on every SDL resize event, including entering/leaving browser fullscreen) updated the window's own tracked pixel size but never propagated it to the window's back buffer render target — so toggling fullscreen resized the canvas but rendering stayed pinned to the pre-resize viewport size.
|
||||
|
||||
## Why a source-level port, not a reimplementation
|
||||
|
||||
@@ -38,10 +42,10 @@ This project instead compiles the actual C++ engine and the actual VBScript inte
|
||||
(real Wine-derived interpreter, compiled to wasm32)
|
||||
│
|
||||
emscripten_set_main_loop
|
||||
(frame stepping - in progress, see Roadmap)
|
||||
(Player::EmscriptenStepFrame, one frame per callback)
|
||||
```
|
||||
|
||||
This project's own contribution is glue, not a rewrite: a new `PLATFORM=emscripten` CMake target modeled on vpinball's existing Linux build, wasm32 builds of its SDL3/SDL3_image/SDL3_ttf/FreeImage/libwinevbs dependencies, and a handful of small source patches (~160 lines total across 8 files) fixing genuine first-32-bit-target and first-wasm-target issues — see [`patches/`](patches/) for the exact diffs, each with an explanatory comment.
|
||||
This project's own contribution is glue, not a rewrite: a new `PLATFORM=emscripten` CMake target modeled on vpinball's existing Linux build, wasm32 builds of its SDL3/SDL3_image/SDL3_ttf/FreeImage/libwinevbs dependencies, a small number of source patches fixing genuine first-32-bit-target and first-wasm-target issues, and the new game-loop/lifecycle bridge described above — see [`patches/`](patches/) for the exact diffs, each with an explanatory comment.
|
||||
|
||||
Full research and two isolated feasibility spikes (proving real VBScript execution and real WebGL2 rendering work under Emscripten, independently, before this project existed) live in the companion research repository:
|
||||
- VBScript-in-WASM spike: proves `libwinevbs` compiles and correctly runs real VBScript (classes, `Scripting.Dictionary`, error handling) under wasm32.
|
||||
@@ -54,29 +58,37 @@ Full research and two isolated feasibility spikes (proving real VBScript executi
|
||||
- Real VBScript execution under wasm32 (libwinevbs).
|
||||
- Real WebGL2/GLES3 rendering under Emscripten (SDL3).
|
||||
|
||||
**Proven working (in this project's own build, in a real browser):**
|
||||
- `.vpx` file loading and parsing.
|
||||
- SDL3 audio/video/window initialization under Emscripten.
|
||||
- Compilation of all of vpinball's real, unmodified `.glfx` shaders against WebGL2.
|
||||
- Environment map / HDR radiance computation.
|
||||
- Physics engine initialization (octree).
|
||||
- VBScript engine startup.
|
||||
- Static scene pre-render pass, including reflection probes.
|
||||
**Proven working (in this project's own build, in a real browser, by hands-on manual testing):**
|
||||
- `.vpx` file loading and parsing; SDL3 audio/video/window initialization.
|
||||
- Compilation of all of vpinball's real, unmodified `.glfx` shaders against WebGL2; environment map/HDR radiance computation; static pre-render pass.
|
||||
- **The full input pipeline**: a real physical keypress → SDL3 → `InputManager::PushButtonEvent` → `PinTable::FireDispID(DISPID_GameEvents_KeyDown)` → the table's own VBScript `KeyDown` handler → `LeftFlipper.RotateToEnd` — confirmed by watching a flipper actually respond to Left/Right Shift.
|
||||
- **Audio**, confirmed audible by ear.
|
||||
- **A real, running per-frame game loop** (`emscripten_set_main_loop`-driven), with clean JS-controllable `start()`/`stop()` lifecycle and normal C++ teardown (`~Player()`) on stop — see [Status](#status-a-real-playable-table-live-in-the-browser--keyboard-and-audio-confirmed) above.
|
||||
- **Correct rendering at any canvas size/DPI, including entering/leaving fullscreen** — two real GL back-buffer sizing bugs found and fixed (see Status).
|
||||
- A bundled default table (`exampleTable.vpx`) with real, working gameplay logic (flippers, slingshots, bumpers, targets, a plunger, drain detection) — swapped in from an earlier choice (`test000-default-table.vpx`) that turned out to be upstream's own rendering/component regression-test fixture with no gameplay script at all (confirmed by diffing a screenshot against vpinball's own official reference image for that table).
|
||||
- Editor-only content (bundled native-editor example/template tables, a Monaco code editor) excluded from the asset payload — never used by the player runtime — see `patches/vpinball/0001-*`.
|
||||
- **A full loading → play flow in a real page** (`examples/basic/index.html`): a byte-level download progress bar, a user-gesture "Start" button (required for both audio autoplay and fullscreen to work), a working fullscreen button, and responsive canvas sizing that fills its container correctly.
|
||||
- **Browser file-loading for self-contained tables**: picking a `.vpx` file, mounting it via `FS.writeFile`, and starting the engine against it — verified end-to-end (`PinTable::LoadGameFromFilename /tables/uploaded.vpx` in the boot log, followed by a normal render).
|
||||
- A JS-callable `evalScript()` debug/utility hook (`pinball.evalScript(script)`) that runs arbitrary VBScript against the live table via the same entry point the interpreter's own debug console uses.
|
||||
|
||||
**The current blocker (next milestone):**
|
||||
- Rewrite `player.cpp`'s game loop dispatch to add an `__EMSCRIPTEN__` branch driven by `emscripten_set_main_loop()`, based on the existing (currently BGFX-only) `CallbackSteppedGameLoop()` "step one frame" function, instead of the blocking `FramePacingGameLoop()`/`GPUQueueStuffingGameLoop()` loops the non-BGFX/GL path uses today. This is real engineering work, not a flag flip — `WinMain`'s control flow needs to reach "loop registered, return now" without blocking first.
|
||||
**Not yet validated (should work, per code review and by analogy to already-proven mechanisms, but unconfirmed end-to-end):**
|
||||
- Gamepad/joystick input during real gameplay (keyboard input uses the identical `InputManager` pipeline and is confirmed working; gamepad support is code-complete but untested on real hardware).
|
||||
- Touch-control overlay (`attachTouchControls`) actually flipping a flipper on a real touch device — implemented and included in the example (synthesizes the real default keyboard scancodes on `window`), not yet confirmed on physical touch hardware.
|
||||
- DMD rendering via a script-driven `Flasher`/`ScriptGlobalTable::put_DMDPixels`. This is native, core-engine functionality requiring no new code (`src/core/ScriptGlobalTable.cpp:886-937`, `src/parts/flasher.cpp:1312-1341`), and rides the exact same `ScriptInterpreter::Evaluate()` VBScript-dispatch path already confirmed working by the flipper test above — but the bundled default table has no DMD-configured `Flasher`, so a full *visual* confirmation needs a real DMD-equipped table (uploadable via the file picker) or authoring one, neither done yet.
|
||||
|
||||
**Explicitly out of scope for v1** (browser-sandbox constraints, not a technical dead end — could be revisited later):
|
||||
- All plugins (PinMAME, DOF, B2S, FlexDMD, Inspector, FFmpeg-dependent features, PUP, AltSound). Excluded via a single CMake guard; zero core-engine impact.
|
||||
- BGFX renderer path (multi-threaded design doesn't fit a single-threaded wasm32 v1). This project uses vpinball's existing `RENDERER=GL` (glad/GLES) path instead.
|
||||
- Raw HID device input (`OpenPinDevHandler` / hidapi) — no browser equivalent for real-hardware nudge/plunger boards.
|
||||
- Multi-threading (no `-pthread`/`SharedArrayBuffer` in v1 — `ThreadPool`'s one-off parallel work, like parallel `.vpx` item deserialization, runs synchronously instead; see `patches/vpinball/0002-*`).
|
||||
**Explicitly out of scope for now** (browser-sandbox constraints or a real infrastructure-cost tradeoff, not a technical dead end — could be revisited):
|
||||
- **Multi-threading** (pthreads/SharedArrayBuffer): would require mandatory `Cross-Origin-Opener-Policy`/`Cross-Origin-Embedder-Policy` headers on every page hosting this widget, a `coi-serviceworker`-style workaround for static hosts that can't set custom headers, and real risk of breaking unrelated cross-origin embeds on host pages — a poor tradeoff for something meant to be embeddable in arbitrary third-party pages. `ThreadPool`'s one-off parallel work (e.g. parallel `.vpx` item deserialization) already runs synchronously instead (`patches/vpinball/0002-*`). Revisit only if real-world profiling shows single-threaded performance is genuinely insufficient.
|
||||
- **The heavy plugin ecosystem** (PinMAME real ROM emulation — copyrighted ROMs, can't legally bundle; DOF/AltSound/PUP/FlexDMD/Serum — real-cabinet-hardware or FFmpeg-dependent; `b2slegacy` — a ~11,000-line legacy VB6-COM compatibility layer). Excluded via a single CMake guard; zero core-engine impact.
|
||||
- **The modern `plugins/b2s` backglass plugin** — explicitly assessed and declined for now, not just left undone. `B2SServer` itself has no browser-incompatible networking (it's built on vpinball's own in-process plugin messaging bus, not real sockets), but our emscripten build currently excludes the *entire* plugin subsystem outright (`CMakeLists_plugins.txt` isn't included at all), so wiring B2S in means new static-linking plumbing modeled on iOS/Android's `__LIBVPINBALL__` path, which this project's `__STANDALONE__` build doesn't share — a genuinely multi-hour undertaking, and one with no way to confirm it actually renders anything without a real `.directb2s`-equipped test table, which isn't available. Revisit if/when such a table is available to validate against.
|
||||
- Raw HID device input (`OpenPinDevHandler`/hidapi) — no browser equivalent for real-hardware nudge/plunger boards. Permanent, not a "for now."
|
||||
|
||||
**Further follow-up work once the main loop is fixed:**
|
||||
- Browser file-loading UX (drag-and-drop / file picker for user-supplied `.vpx` files beyond the bundled demo table).
|
||||
- Input mapping (keyboard/gamepad through SDL3's Emscripten backend).
|
||||
- Binary size / performance tuning (this build is unoptimized `-O1`-equivalent; a real `-O2`/`-Os` release pass, dead-code stripping, and only-needed-SDL3-subsystem linking are all still ahead).
|
||||
- A browser-based (Puppeteer) CI smoke test, replacing today's compile-only CI check.
|
||||
**Further follow-up work:**
|
||||
- Real-device gamepad and touch-control validation (see above).
|
||||
- Visual confirmation of DMD rendering against a real DMD-equipped table (see above).
|
||||
- Pointer-lock UI wiring (fullscreen is done; pointer-lock isn't needed for this game's input model but noted here in case a future feature wants it).
|
||||
- Real-world table support beyond self-contained single-file uploads: tables with an external `.vbs` script override or a `Music/` folder are a documented, known-unsupported gap.
|
||||
- Further binary-size tuning (the `.wasm` itself is still ~13MB with `-O2`; `--closure 1` was tried but silently stripped `FS.writeFile`/`readFile`/`mkdir` down to low-level node ops - a real Emscripten/Closure interaction bug worth root-causing before re-enabling, since it broke runtime table loading with no compile-time warning. `-sFORCE_FILESYSTEM=1`/broad `EXPORTED_RUNTIME_METHODS` also pull in more than strictly needed. The default table itself is now ~18MB of the ~34MB total, since a real playable demo table is larger than the rendering-test fixture used before).
|
||||
- A browser-based (Puppeteer) CI smoke test that actually loads a table and checks for a rendered frame, replacing today's compile-only CI check.
|
||||
|
||||
## Build
|
||||
|
||||
@@ -95,20 +107,37 @@ source emsdk/emsdk_env.sh
|
||||
## npm usage
|
||||
|
||||
```ts
|
||||
import { loadPinball } from '@valknar/vpinball-wasm';
|
||||
import { loadPinball, attachTouchControls } from '@valknar/vpinball-wasm';
|
||||
|
||||
const pinball = await loadPinball({ canvas: document.querySelector('canvas') });
|
||||
const canvas = document.querySelector('canvas');
|
||||
const pinball = await loadPinball({
|
||||
canvas,
|
||||
onProgress: (fraction) => updateMyLoadingBar(fraction),
|
||||
});
|
||||
|
||||
// start() (and requestFullscreen()) must be called from within a user
|
||||
// gesture, e.g. a click handler - browsers block audio autoplay and
|
||||
// fullscreen requests otherwise.
|
||||
startButton.addEventListener('click', () => {
|
||||
pinball.start();
|
||||
if ('ontouchstart' in window) attachTouchControls({ container: canvas.parentElement });
|
||||
});
|
||||
|
||||
// Optional debug/utility hook: runs arbitrary VBScript against the live
|
||||
// table, via the same entry point the interpreter's own debug console uses.
|
||||
pinball.evalScript('DMDWidth = 128 : DMDHeight = 32');
|
||||
```
|
||||
|
||||
The published package name/registry (`@valknar/vpinball-wasm` on this project's Gitea npm registry) is a placeholder pending the first real release — see `package.json`. Runtime lifecycle control (`start`/`stop`/`dispose`) depends on the main-loop rewrite above being completed; today the module boots and initializes but doesn't yet expose a controllable running loop from JS.
|
||||
See `examples/basic/index.html` for a complete working page (loading progress, file upload, touch controls, fullscreen). The published package name/registry (`@valknar/vpinball-wasm` on this project's Gitea npm registry) is a placeholder pending the first real release — see `package.json`.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Single-threaded only (no pthreads/SharedArrayBuffer) — see Roadmap.
|
||||
- No plugin ecosystem (PinMAME/DOF/B2S/DMD/etc.) — see Roadmap.
|
||||
- No raw hardware input (real cabinet nudge/plunger boards).
|
||||
- Not yet performance/size-tuned — current unoptimized build produces a ~51MB asset bundle (mostly vpinball's own `src/assets/` textures/EXR files) and a ~1.2MB `.wasm`.
|
||||
- Single-threaded only (no pthreads/SharedArrayBuffer) — a deliberate tradeoff, not a gap; see Roadmap.
|
||||
- No heavy plugin ecosystem (PinMAME/DOF/FlexDMD/etc.), and the modern `plugins/b2s` backglass plugin specifically assessed and declined for now (no plugin subsystem wired for this build, no test table to validate against) — see Roadmap.
|
||||
- No raw hardware input (real cabinet nudge/plunger boards) — permanent, no browser equivalent.
|
||||
- Keyboard input, audio, and VBScript-driven gameplay are confirmed working end-to-end by hands-on manual testing (not just code review). Gamepad input and the touch-control overlay are code-complete and use the identical input pipeline, but are not yet confirmed on real gamepad/touch hardware — see Roadmap.
|
||||
- DMD rendering is native, code-complete functionality riding the same proven VBScript-dispatch path, but not yet visually confirmed since the bundled default table has no DMD-configured `Flasher` — see Roadmap.
|
||||
- `.wasm`/asset size is ~34MB total (a real playable default table is larger than the rendering-test fixture used earlier in development) and not fully tuned — see Roadmap.
|
||||
|
||||
## Licensing
|
||||
|
||||
|
||||
@@ -1,26 +1,108 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
Minimal usage example for @valknar/vpinball-wasm.
|
||||
Full usage example for @valknar/vpinball-wasm: loading progress, a
|
||||
user-gesture "Start" button (required for audio autoplay and fullscreen
|
||||
to work), an optional file picker for a self-contained .vpx table, touch
|
||||
controls on touch-capable devices, and a fullscreen button.
|
||||
|
||||
Serve this directory alongside a built dist/ (see scripts/dev-server.sh)
|
||||
and adjust the import path below if dist/ isn't a sibling directory.
|
||||
and adjust the import paths below if dist/ isn't a sibling directory.
|
||||
-->
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
|
||||
<title>vpinball-wasm example</title>
|
||||
<style>
|
||||
body { margin: 0; background: #111; }
|
||||
canvas { display: block; width: 100vw; height: 100vh; }
|
||||
html, body { margin: 0; height: 100%; background: #111; overflow: hidden; }
|
||||
#stage { position: relative; width: 100vw; height: 100vh; }
|
||||
canvas { display: block; width: 100%; height: 100%; padding: 0; border: 0; }
|
||||
|
||||
#overlay {
|
||||
position: absolute; inset: 0; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center; gap: 16px;
|
||||
background: #111; color: #eee; font-family: sans-serif;
|
||||
}
|
||||
#overlay.hidden { display: none; }
|
||||
#progress-track { width: 280px; height: 8px; background: #333; border-radius: 4px; overflow: hidden; }
|
||||
#progress-bar { width: 0%; height: 100%; background: #4ade80; transition: width 0.1s linear; }
|
||||
#start-button {
|
||||
padding: 12px 32px; font-size: 18px; border-radius: 8px; border: none;
|
||||
background: #4ade80; color: #111; cursor: pointer;
|
||||
}
|
||||
#start-button:disabled { opacity: 0.4; cursor: default; }
|
||||
#file-picker { color: #aaa; font-size: 13px; }
|
||||
|
||||
#fullscreen-button {
|
||||
position: absolute; top: 12px; right: 12px; z-index: 10;
|
||||
padding: 8px 14px; border-radius: 6px; border: none;
|
||||
background: rgba(255,255,255,0.15); color: #eee; cursor: pointer;
|
||||
}
|
||||
#fullscreen-button.hidden { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="stage">
|
||||
<canvas id="canvas"></canvas>
|
||||
<div id="overlay">
|
||||
<div id="progress-track"><div id="progress-bar"></div></div>
|
||||
<div id="file-picker">
|
||||
<label>Optional: play your own table (self-contained .vpx only) <input type="file" id="table-file" accept=".vpx" /></label>
|
||||
</div>
|
||||
<button id="start-button" disabled>Loading...</button>
|
||||
</div>
|
||||
<button id="fullscreen-button" class="hidden">Fullscreen</button>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import { loadPinball } from '../../dist/index.js';
|
||||
import { loadPinball, attachTouchControls } from '../../dist/index.js';
|
||||
|
||||
const canvas = document.getElementById('canvas');
|
||||
const pinball = await loadPinball({ canvas, baseUrl: '../../dist' });
|
||||
const overlay = document.getElementById('overlay');
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
const startButton = document.getElementById('start-button');
|
||||
const fileInput = document.getElementById('table-file');
|
||||
const fullscreenButton = document.getElementById('fullscreen-button');
|
||||
|
||||
let uploadedTableData;
|
||||
fileInput.addEventListener('change', async () => {
|
||||
const file = fileInput.files?.[0];
|
||||
if (file) uploadedTableData = await file.arrayBuffer();
|
||||
});
|
||||
|
||||
const pinball = await loadPinball({
|
||||
canvas,
|
||||
baseUrl: '../../dist',
|
||||
onProgress: (fraction) => {
|
||||
progressBar.style.width = `${Math.round(fraction * 100)}%`;
|
||||
},
|
||||
});
|
||||
window.pinball = pinball; // for console access (e.g. pinball.evalScript(...))
|
||||
|
||||
startButton.disabled = false;
|
||||
startButton.textContent = 'Start';
|
||||
|
||||
startButton.addEventListener('click', () => {
|
||||
if (uploadedTableData) {
|
||||
pinball.loadTable(uploadedTableData);
|
||||
}
|
||||
pinball.start();
|
||||
overlay.classList.add('hidden');
|
||||
fullscreenButton.classList.remove('hidden');
|
||||
|
||||
// Touch controls are additive UI for touch-capable devices - not
|
||||
// required for desktop mouse+keyboard play.
|
||||
if ('ontouchstart' in window || navigator.maxTouchPoints > 0) {
|
||||
attachTouchControls({ container: document.getElementById('stage') });
|
||||
}
|
||||
});
|
||||
|
||||
fullscreenButton.addEventListener('click', () => {
|
||||
pinball.requestFullscreen().catch(() => {
|
||||
// Fullscreen can be rejected (e.g. already fullscreen, or the
|
||||
// browser denies it) - nothing to recover from here.
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+79
-17
@@ -1,20 +1,28 @@
|
||||
import type { LoadPinballOptions, PinballInstance } from './types.js';
|
||||
|
||||
export type { LoadPinballOptions, PinballInstance } from './types.js';
|
||||
export { attachTouchControls } from './touch-controls.js';
|
||||
export type { TouchControlsOptions, TouchControlsHandle } from './touch-controls.js';
|
||||
|
||||
const DEFAULT_TABLE_PATH = '/tables/default.vpx';
|
||||
const UPLOADED_TABLE_PATH = '/tables/uploaded.vpx';
|
||||
|
||||
/**
|
||||
* Instantiates the WebAssembly build of Visual Pinball against the given
|
||||
* canvas.
|
||||
*
|
||||
* NOTE (current milestone): this wrapper boots the Emscripten module and
|
||||
* exposes lifecycle control (start/stop/dispose). Runtime table loading
|
||||
* (`loadTable`) mounts the given bytes into the module's virtual filesystem,
|
||||
* but wiring the native engine to actually pick up a runtime-loaded table
|
||||
* (as opposed to the table baked in at build time) is tracked as a follow-up
|
||||
* milestone in the README's roadmap - see "Browser file-loading UX".
|
||||
* canvas and returns a handle to control its lifecycle.
|
||||
*/
|
||||
export async function loadPinball(options: LoadPinballOptions): Promise<PinballInstance> {
|
||||
const baseUrl = options.baseUrl ?? '.';
|
||||
let tablePath = DEFAULT_TABLE_PATH;
|
||||
|
||||
const moduleArgs: Record<string, unknown> = {
|
||||
canvas: options.canvas,
|
||||
locateFile: (path: string) => `${baseUrl}/${path}`,
|
||||
};
|
||||
|
||||
if (options.onProgress) {
|
||||
await wireDownloadProgress(moduleArgs, baseUrl, options.onProgress);
|
||||
}
|
||||
|
||||
// dist/vpinball.js is built with -sMODULARIZE=1 -sEXPORT_NAME=VPinballModule,
|
||||
// so importing it yields a factory function, not a module with side effects.
|
||||
@@ -22,21 +30,21 @@ export async function loadPinball(options: LoadPinballOptions): Promise<PinballI
|
||||
moduleArgs: Record<string, unknown>
|
||||
) => Promise<EmscriptenModule>;
|
||||
|
||||
const module = await factory({
|
||||
canvas: options.canvas,
|
||||
locateFile: (path: string) => `${baseUrl}/${path}`,
|
||||
});
|
||||
const module = await factory(moduleArgs);
|
||||
options.onProgress?.(1);
|
||||
|
||||
if (options.tableData) {
|
||||
mountTable(module, options.tableData);
|
||||
module.FS.writeFile(UPLOADED_TABLE_PATH, new Uint8Array(options.tableData));
|
||||
tablePath = UPLOADED_TABLE_PATH;
|
||||
}
|
||||
|
||||
return {
|
||||
loadTable(vpxBytes: ArrayBuffer) {
|
||||
mountTable(module, vpxBytes);
|
||||
module.FS.writeFile(UPLOADED_TABLE_PATH, new Uint8Array(vpxBytes));
|
||||
tablePath = UPLOADED_TABLE_PATH;
|
||||
},
|
||||
start() {
|
||||
module.ccall?.('vpinball_wasm_start', null, [], []);
|
||||
module.ccall?.('vpinball_wasm_start', 'number', ['string'], [tablePath]);
|
||||
},
|
||||
stop() {
|
||||
module.ccall?.('vpinball_wasm_stop', null, [], []);
|
||||
@@ -44,11 +52,65 @@ export async function loadPinball(options: LoadPinballOptions): Promise<PinballI
|
||||
dispose() {
|
||||
module.ccall?.('vpinball_wasm_dispose', null, [], []);
|
||||
},
|
||||
requestFullscreen() {
|
||||
return options.canvas.requestFullscreen();
|
||||
},
|
||||
evalScript(script: string) {
|
||||
return Boolean(module.ccall?.('vpinball_wasm_eval_script', 'number', ['string'], [script]));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mountTable(module: EmscriptenModule, vpxBytes: ArrayBuffer): void {
|
||||
module.FS.writeFile('/table.vpx', new Uint8Array(vpxBytes));
|
||||
/**
|
||||
* Best-effort byte-level download progress: manually fetches vpinball.wasm
|
||||
* with a streaming reader (assigning the result to Module.wasmBinary so the
|
||||
* factory doesn't re-fetch it), weighted against Module's own coarse
|
||||
* "Downloading data..." status callback for the preloaded asset package.
|
||||
* Emscripten's own dependency counter (monitorRunDependencies) doesn't give
|
||||
* byte-level granularity, hence fetching the .wasm by hand instead.
|
||||
*/
|
||||
async function wireDownloadProgress(
|
||||
moduleArgs: Record<string, unknown>,
|
||||
baseUrl: string,
|
||||
onProgress: (fraction: number) => void
|
||||
): Promise<void> {
|
||||
// Roughly half the total download is the wasm binary, half the data
|
||||
// package - this is an estimate (see README's size-tuning roadmap item),
|
||||
// not a guarantee, so progress may jump at the wasm/data boundary.
|
||||
const WASM_WEIGHT = 0.5;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/vpinball.wasm`);
|
||||
const total = Number(response.headers.get('Content-Length') ?? 0);
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader || !total) {
|
||||
return;
|
||||
}
|
||||
const chunks: Uint8Array[] = [];
|
||||
let received = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
received += value.length;
|
||||
onProgress((received / total) * WASM_WEIGHT);
|
||||
}
|
||||
const wasmBinary = new Uint8Array(received);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
wasmBinary.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
moduleArgs.wasmBinary = wasmBinary;
|
||||
} catch {
|
||||
// Streaming progress is best-effort; fall through to the factory's
|
||||
// own default fetch if this fails for any reason (e.g. no CORS
|
||||
// Content-Length exposed, older browser).
|
||||
}
|
||||
|
||||
moduleArgs.setStatus = (text: string) => {
|
||||
if (text) onProgress(WASM_WEIGHT + (1 - WASM_WEIGHT) * 0.5);
|
||||
};
|
||||
}
|
||||
|
||||
interface EmscriptenModule {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* On-screen touch controls for mobile/tablet play: an HTML/CSS overlay of
|
||||
* virtual buttons that synthesize the same keyboard events a physical
|
||||
* keyboard would send, using vpinball's actual default key bindings
|
||||
* (see vendor/vpinball/src/input/InputManager.cpp's addFlipperKeyAction/
|
||||
* addKeyAction calls). Deliberately implemented in JS/HTML rather than
|
||||
* native code - this keeps the native SDL keyboard input path completely
|
||||
* unmodified and is the dominant pattern for adding touch controls to a
|
||||
* keyboard-oriented Emscripten port.
|
||||
*
|
||||
* SDL3's Emscripten video backend listens for keydown/keyup on the
|
||||
* "#window" target by default (see SDL_emscriptenvideo.c's keyboard_element
|
||||
* default), so dispatching a real KeyboardEvent on `window` reaches it the
|
||||
* same way a physical keypress would.
|
||||
*/
|
||||
|
||||
const KEY_BINDINGS = {
|
||||
leftFlipper: { key: 'Shift', code: 'ShiftLeft' },
|
||||
rightFlipper: { key: 'Shift', code: 'ShiftRight' },
|
||||
plunger: { key: 'Enter', code: 'Enter' },
|
||||
start: { key: '1', code: 'Digit1' },
|
||||
} as const;
|
||||
|
||||
export interface TouchControlsOptions {
|
||||
/** Container to render the overlay into. Positioned absolutely, filling this element. */
|
||||
container: HTMLElement;
|
||||
/**
|
||||
* Which buttons to show. Defaults to the full set (both flippers,
|
||||
* plunger, start).
|
||||
*/
|
||||
buttons?: Array<keyof typeof KEY_BINDINGS>;
|
||||
}
|
||||
|
||||
export interface TouchControlsHandle {
|
||||
/** Remove the overlay and its event listeners. */
|
||||
detach(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches a touch-control overlay to `options.container`. Call this
|
||||
* conditionally (e.g. only when `'ontouchstart' in window` or on narrow
|
||||
* viewports) - it's additive UI, not required for desktop/mouse+keyboard play.
|
||||
*/
|
||||
export function attachTouchControls(options: TouchControlsOptions): TouchControlsHandle {
|
||||
const buttons = options.buttons ?? (Object.keys(KEY_BINDINGS) as Array<keyof typeof KEY_BINDINGS>);
|
||||
|
||||
const root = document.createElement('div');
|
||||
root.setAttribute('data-vpinball-touch-controls', '');
|
||||
applyStyle(root, {
|
||||
position: 'absolute',
|
||||
inset: '0',
|
||||
pointerEvents: 'none',
|
||||
userSelect: 'none',
|
||||
touchAction: 'none',
|
||||
fontFamily: 'sans-serif',
|
||||
});
|
||||
|
||||
const elements: HTMLElement[] = [];
|
||||
|
||||
if (buttons.includes('leftFlipper')) {
|
||||
elements.push(makeButton('◀', KEY_BINDINGS.leftFlipper, { left: '0', bottom: '0' }));
|
||||
}
|
||||
if (buttons.includes('rightFlipper')) {
|
||||
elements.push(makeButton('▶', KEY_BINDINGS.rightFlipper, { right: '0', bottom: '0' }));
|
||||
}
|
||||
if (buttons.includes('plunger')) {
|
||||
elements.push(makeButton('⬆', KEY_BINDINGS.plunger, { right: '0', top: '40%' }));
|
||||
}
|
||||
if (buttons.includes('start')) {
|
||||
elements.push(makeButton('1', KEY_BINDINGS.start, { left: '50%', top: '0', transform: 'translateX(-50%)' }));
|
||||
}
|
||||
|
||||
for (const el of elements) root.appendChild(el);
|
||||
options.container.appendChild(root);
|
||||
|
||||
return {
|
||||
detach() {
|
||||
root.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeButton(label: string, binding: { key: string; code: string }, position: Record<string, string>): HTMLElement {
|
||||
const btn = document.createElement('div');
|
||||
btn.textContent = label;
|
||||
applyStyle(btn, {
|
||||
position: 'absolute',
|
||||
width: '72px',
|
||||
height: '72px',
|
||||
margin: '16px',
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(255,255,255,0.15)',
|
||||
color: 'rgba(255,255,255,0.85)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '28px',
|
||||
pointerEvents: 'auto',
|
||||
touchAction: 'none',
|
||||
...position,
|
||||
});
|
||||
|
||||
const down = () => {
|
||||
btn.style.background = 'rgba(255,255,255,0.35)';
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: binding.key, code: binding.code, bubbles: true }));
|
||||
};
|
||||
const up = () => {
|
||||
btn.style.background = 'rgba(255,255,255,0.15)';
|
||||
window.dispatchEvent(new KeyboardEvent('keyup', { key: binding.key, code: binding.code, bubbles: true }));
|
||||
};
|
||||
|
||||
btn.addEventListener('pointerdown', (e) => {
|
||||
e.preventDefault();
|
||||
btn.setPointerCapture(e.pointerId);
|
||||
down();
|
||||
});
|
||||
btn.addEventListener('pointerup', up);
|
||||
btn.addEventListener('pointercancel', up);
|
||||
|
||||
return btn;
|
||||
}
|
||||
|
||||
function applyStyle(el: HTMLElement, style: Record<string, string>): void {
|
||||
Object.assign(el.style, style);
|
||||
}
|
||||
+37
-8
@@ -9,22 +9,51 @@ export interface LoadPinballOptions {
|
||||
/** Canvas the engine renders into via SDL3's WebGL2 backend. */
|
||||
canvas: HTMLCanvasElement;
|
||||
/**
|
||||
* Raw bytes of a .vpx table file to load on startup. If omitted, the
|
||||
* bundled default table (package/assets/test000-default-table.vpx) is
|
||||
* used instead.
|
||||
* Raw bytes of a self-contained .vpx table file to play instead of the
|
||||
* bundled default table. Real-world tables that reference an external
|
||||
* .vbs script override or a Music/ folder are not supported this way -
|
||||
* see the README's "Browser file-loading UX" section.
|
||||
*/
|
||||
tableData?: ArrayBuffer;
|
||||
/** Base URL to fetch dist/vpinball.wasm (and .data, if present) from. */
|
||||
/** Base URL to fetch dist/vpinball.wasm/.data (and vpinball.js) from. */
|
||||
baseUrl?: string;
|
||||
/**
|
||||
* Called as the wasm binary and preloaded asset package download,
|
||||
* with a 0-1 fraction of bytes received. Not called at all if the
|
||||
* browser doesn't support streaming fetch progress (falls back to
|
||||
* Emscripten's own default loading behavior with no progress callback).
|
||||
*/
|
||||
onProgress?: (fraction: number) => void;
|
||||
}
|
||||
|
||||
export interface PinballInstance {
|
||||
/** Load a different .vpx table at runtime, replacing the current one. */
|
||||
/**
|
||||
* Stage a different self-contained .vpx table to play on the *next*
|
||||
* start() call. Does not affect an already-running session - table
|
||||
* switching mid-session isn't supported; call stop() first (a fresh
|
||||
* loadPinball() call is the supported way to load a genuinely new
|
||||
* session with a different table).
|
||||
*/
|
||||
loadTable(vpxBytes: ArrayBuffer): void;
|
||||
/** Start (or resume) the simulation's main loop. */
|
||||
/** Start the simulation's main loop. No-op if already running. */
|
||||
start(): void;
|
||||
/** Pause the simulation's main loop. */
|
||||
/** Stop the simulation; the engine tears down (script Exit event, settings save) on its next step. */
|
||||
stop(): void;
|
||||
/** Tear down the instance and free its WebAssembly memory. */
|
||||
/** Tear down the instance immediately and free its WebAssembly memory. */
|
||||
dispose(): void;
|
||||
/**
|
||||
* Request fullscreen on the canvas via the standard Fullscreen API.
|
||||
* Must be called from within a user gesture (e.g. a click handler) -
|
||||
* browsers reject fullscreen requests otherwise. Returns the promise
|
||||
* from the underlying requestFullscreen() call.
|
||||
*/
|
||||
requestFullscreen(): Promise<void>;
|
||||
/**
|
||||
* Runs arbitrary VBScript against the running table's script interpreter
|
||||
* (the same entry point the interpreter's own debug console uses).
|
||||
* Returns false if no table is currently running. Intended for debugging
|
||||
* and for exercising script-driven engine APIs (e.g. the DMD pixel API)
|
||||
* from JS - not sandboxed, so only run scripts you trust.
|
||||
*/
|
||||
evalScript(script: string): boolean;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
diff --git a/CMakeLists.txt b/CMakeLists.txt
|
||||
index 1fcf242..f9338b2 100644
|
||||
index 1fcf242..745b91b 100644
|
||||
--- a/CMakeLists.txt
|
||||
+++ b/CMakeLists.txt
|
||||
@@ -50,6 +50,7 @@ set(_vpx_valid_combos
|
||||
@@ -10,7 +10,7 @@ index 1fcf242..f9338b2 100644
|
||||
DX9-windows)
|
||||
if(NOT "${RENDERER}-${PLATFORM}" IN_LIST _vpx_valid_combos)
|
||||
string(REPLACE ";" "\n " _vpx_available "${_vpx_valid_combos}")
|
||||
@@ -784,6 +785,123 @@ elseif(PLATFORM STREQUAL "linux")
|
||||
@@ -784,6 +785,141 @@ elseif(PLATFORM STREQUAL "linux")
|
||||
endif()
|
||||
endif()
|
||||
|
||||
@@ -41,6 +41,7 @@ index 1fcf242..f9338b2 100644
|
||||
+
|
||||
+ add_executable(vpinball
|
||||
+ ${VPX_STANDALONE_SOURCES}
|
||||
+ ${CMAKE_SOURCE_DIR}/src/core/EmscriptenBridge.cpp
|
||||
+ )
|
||||
+
|
||||
+ target_include_directories(vpinball PUBLIC
|
||||
@@ -108,9 +109,26 @@ index 1fcf242..f9338b2 100644
|
||||
+ # staged directories must exist *before* linking, not after.
|
||||
+ add_custom_command(TARGET vpinball PRE_LINK
|
||||
+ COMMAND "${CMAKE_COMMAND}" -E copy_directory "${CMAKE_SOURCE_DIR}/src/assets" "${RESOURCES_DIR}/assets"
|
||||
+ # vpinball-wasm: exclude editor-only content never read by the
|
||||
+ # __STANDALONE__ player runtime (native "File > New" templates and
|
||||
+ # mobile-app onboarding screens, and a bundled Monaco code editor for
|
||||
+ # remote script editing) - this is ~52MB of the ~54MB raw src/assets
|
||||
+ # payload, cut here rather than preloaded and never used.
|
||||
+ COMMAND "${CMAKE_COMMAND}" -E rm -f
|
||||
+ "${RESOURCES_DIR}/assets/exampleTable.vpx"
|
||||
+ "${RESOURCES_DIR}/assets/blankTable.vpx"
|
||||
+ "${RESOURCES_DIR}/assets/lightSeqTable.vpx"
|
||||
+ "${RESOURCES_DIR}/assets/strippedTable.vpx"
|
||||
+ COMMAND "${CMAKE_COMMAND}" -E rm -rf "${RESOURCES_DIR}/assets/web"
|
||||
+ COMMAND "${CMAKE_COMMAND}" -E copy_directory "${CMAKE_SOURCE_DIR}/scripts" "${RESOURCES_DIR}/scripts"
|
||||
+ COMMAND "${CMAKE_COMMAND}" -E make_directory "${RESOURCES_DIR}/tables"
|
||||
+ COMMAND "${CMAKE_COMMAND}" -E copy "${CMAKE_SOURCE_DIR}/tests/assets/test000-default-table.vpx" "${RESOURCES_DIR}/tables/default.vpx"
|
||||
+ # exampleTable.vpx (not test000-default-table.vpx, which is upstream's
|
||||
+ # rendering/component regression-test fixture with no gameplay script)
|
||||
+ # is a genuine playable demo table - it has real LeftFlipper/RightFlipper
|
||||
+ # Animate/Collide subs, Table1_KeyDown/KeyUp, slingshots, bumpers,
|
||||
+ # targets, a plunger and drain detection - the right default for a
|
||||
+ # public-facing demo.
|
||||
+ COMMAND "${CMAKE_COMMAND}" -E copy "${CMAKE_SOURCE_DIR}/src/assets/exampleTable.vpx" "${RESOURCES_DIR}/tables/default.vpx"
|
||||
+ COMMAND "${CMAKE_COMMAND}" -E make_directory "${SHADER_DIR}"
|
||||
+ )
|
||||
+ foreach(_shader IN LISTS VPX_GL_SHADERS)
|
||||
@@ -134,7 +152,7 @@ index 1fcf242..f9338b2 100644
|
||||
# iOS and Android libvpinball build
|
||||
|
||||
elseif(PLATFORM STREQUAL "ios" OR PLATFORM STREQUAL "ios-simulator" OR PLATFORM STREQUAL "android")
|
||||
@@ -957,4 +1075,9 @@ elseif(PLATFORM STREQUAL "ios" OR PLATFORM STREQUAL "ios-simulator" OR PLATFORM
|
||||
@@ -957,4 +1093,9 @@ elseif(PLATFORM STREQUAL "ios" OR PLATFORM STREQUAL "ios-simulator" OR PLATFORM
|
||||
|
||||
endif()
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
diff --git a/src/renderer/RenderDevice.cpp b/src/renderer/RenderDevice.cpp
|
||||
index 1fedf4e..6927546 100644
|
||||
--- a/src/renderer/RenderDevice.cpp
|
||||
+++ b/src/renderer/RenderDevice.cpp
|
||||
@@ -1501,7 +1501,11 @@ RenderDevice::RenderDevice(
|
||||
SetRenderState(RenderState::ZFUNC, RenderState::Z_LESSEQUAL);
|
||||
|
||||
// Retrieve a reference to the back buffer.
|
||||
- wnd->SetBackBuffer(new RenderTarget(this, SurfaceType::RT_DEFAULT, wnd->GetWidth(), wnd->GetHeight(), back_buffer_format));
|
||||
+ // vpinball-wasm: use pixel (device) size, not logical/CSS size - on Emscripten
|
||||
+ // these differ by devicePixelRatio, and the GL viewport (RenderTarget.cpp's
|
||||
+ // glViewport(0, 0, m_width, m_height)) must match the canvas's actual backing
|
||||
+ // buffer or rendering only fills a fraction of it, anchored bottom-left.
|
||||
+ wnd->SetBackBuffer(new RenderTarget(this, SurfaceType::RT_DEFAULT, wnd->GetPixelWidth(), wnd->GetPixelHeight(), back_buffer_format));
|
||||
|
||||
#elif defined(ENABLE_DX9)
|
||||
///////////////////////////////////
|
||||
@@ -2168,10 +2172,19 @@ void RenderDevice::WaitForVSync(const bool asynchronous)
|
||||
m_vsyncCount++;
|
||||
m_presentTimestampReference = usec();
|
||||
};
|
||||
+#ifndef __EMSCRIPTEN__
|
||||
if (asynchronous)
|
||||
std::thread(lambda).detach(); // Reuse thread ? (we always at most one running at a time)
|
||||
else
|
||||
lambda();
|
||||
+#else
|
||||
+ // vpinball-wasm: a single-threaded Emscripten build (no -pthread /
|
||||
+ // SharedArrayBuffer) cannot construct real std::thread workers - run
|
||||
+ // synchronously inline instead. This only updates m_vsyncCount/timestamp
|
||||
+ // bookkeeping (the real vblank-wait branches above are already excluded
|
||||
+ // for __STANDALONE__ builds), so synchronous execution is equivalent.
|
||||
+ lambda();
|
||||
+#endif
|
||||
}
|
||||
|
||||
#if defined(ENABLE_BGFX)
|
||||
@@ -0,0 +1,209 @@
|
||||
diff --git a/src/core/AppCommands.cpp b/src/core/AppCommands.cpp
|
||||
index 735762c..326c362 100644
|
||||
--- a/src/core/AppCommands.cpp
|
||||
+++ b/src/core/AppCommands.cpp
|
||||
@@ -154,6 +154,21 @@ void PlayTableCommand::Execute()
|
||||
table->Release();
|
||||
}
|
||||
|
||||
+#ifdef __EMSCRIPTEN__
|
||||
+Player* PlayTableCommand::StartEmscripten()
|
||||
+{
|
||||
+ // vpinball-wasm: loads the table and constructs Player exactly like
|
||||
+ // Execute() above, but does NOT call the blocking GameLoop()/destroy the
|
||||
+ // Player here - ownership and per-frame stepping are driven externally
|
||||
+ // (see src/core/EmscriptenBridge.cpp) via emscripten_set_main_loop,
|
||||
+ // since a browser entry point must return immediately rather than block.
|
||||
+ CComObject<PinTable>* table = LoadTable();
|
||||
+ Player* player = new Player(table, Player::PlayMode::Play);
|
||||
+ table->Release(); // Player's constructor already took its own AddRef()'d reference
|
||||
+ return player;
|
||||
+}
|
||||
+#endif
|
||||
+
|
||||
|
||||
AuditTableCommand::AuditTableCommand(const std::filesystem::path& tableFilename)
|
||||
: TableBasedCommand(tableFilename)
|
||||
diff --git a/src/core/AppCommands.h b/src/core/AppCommands.h
|
||||
index 2a18ceb..a033615 100644
|
||||
--- a/src/core/AppCommands.h
|
||||
+++ b/src/core/AppCommands.h
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
+#ifdef __EMSCRIPTEN__
|
||||
+class Player;
|
||||
+#endif
|
||||
|
||||
class AppCommand
|
||||
{
|
||||
@@ -58,6 +61,13 @@ public:
|
||||
explicit PlayTableCommand(const std::filesystem::path& tableFilename);
|
||||
~PlayTableCommand() override = default;
|
||||
void Execute() override;
|
||||
+#ifdef __EMSCRIPTEN__
|
||||
+ // vpinball-wasm: see src/core/EmscriptenBridge.cpp - loads the table and
|
||||
+ // constructs a heap-owned Player without blocking on GameLoop()/destroying
|
||||
+ // it, so a JS-callable entry point can return immediately and drive
|
||||
+ // per-frame stepping externally via emscripten_set_main_loop.
|
||||
+ Player* StartEmscripten();
|
||||
+#endif
|
||||
};
|
||||
|
||||
class AuditTableCommand : public TableBasedCommand
|
||||
diff --git a/src/core/EmscriptenBridge.cpp b/src/core/EmscriptenBridge.cpp
|
||||
new file mode 100644
|
||||
index 0000000..6e20009
|
||||
--- /dev/null
|
||||
+++ b/src/core/EmscriptenBridge.cpp
|
||||
@@ -0,0 +1,93 @@
|
||||
+// license:GPLv3+
|
||||
+
|
||||
+// vpinball-wasm: JS-callable entry points for controlling the player's
|
||||
+// lifecycle from the browser. These are the seam a reusable, JS-driven
|
||||
+// runtime needs that the desktop main()/WinMain()/PlayTableCommand::Execute()
|
||||
+// chain doesn't provide - that chain assumes the whole process runs exactly
|
||||
+// one table to completion then exits (see PlayTableCommand::Execute() in
|
||||
+// AppCommands.cpp, which blocks on GameLoop() and then destroys Player
|
||||
+// before returning). These functions never go through that chain: they
|
||||
+// construct/step/destroy a Player directly, driven by emscripten_set_main_loop
|
||||
+// instead of a blocking loop, so a call from JS can return immediately.
|
||||
+//
|
||||
+// Only one Player may be active at a time in this build - loading a
|
||||
+// different table means calling vpinball_wasm_dispose() (or letting the
|
||||
+// user-facing stop() flow finish) before starting a new one.
|
||||
+
|
||||
+#ifdef __EMSCRIPTEN__
|
||||
+
|
||||
+#include "core/stdafx.h"
|
||||
+#include "core/AppCommands.h"
|
||||
+#include "core/player.h"
|
||||
+
|
||||
+#include <emscripten.h>
|
||||
+
|
||||
+static Player* g_wasmPlayer = nullptr;
|
||||
+
|
||||
+static void EmscriptenMainLoopTrampoline()
|
||||
+{
|
||||
+ if (g_wasmPlayer && g_wasmPlayer->EmscriptenStepFrame())
|
||||
+ return;
|
||||
+
|
||||
+ emscripten_cancel_main_loop();
|
||||
+ delete g_wasmPlayer; // runs Player's normal destructor teardown (script Exit event, plugin unload, settings save)
|
||||
+ g_wasmPlayer = nullptr;
|
||||
+}
|
||||
+
|
||||
+extern "C" {
|
||||
+
|
||||
+EMSCRIPTEN_KEEPALIVE
|
||||
+int vpinball_wasm_start(const char* tablePath)
|
||||
+{
|
||||
+ if (g_wasmPlayer != nullptr)
|
||||
+ return 0; // already running - caller must stop()/dispose() first
|
||||
+
|
||||
+ PlayTableCommand cmd{std::filesystem::path(tablePath)};
|
||||
+ g_wasmPlayer = cmd.StartEmscripten();
|
||||
+ if (g_wasmPlayer == nullptr)
|
||||
+ return 0;
|
||||
+
|
||||
+ // fps=0 uses requestAnimationFrame, synced to display refresh and
|
||||
+ // automatically paused by the browser while the tab is hidden/backgrounded
|
||||
+ // - correct behavior for a real game tab (verified end-to-end with a
|
||||
+ // setTimeout-based fps>0 loop first, since rAF is unobservable in a
|
||||
+ // headless/backgrounded automation tab).
|
||||
+ emscripten_set_main_loop(EmscriptenMainLoopTrampoline, 0, 0);
|
||||
+ return 1;
|
||||
+}
|
||||
+
|
||||
+EMSCRIPTEN_KEEPALIVE
|
||||
+void vpinball_wasm_stop()
|
||||
+{
|
||||
+ if (g_wasmPlayer != nullptr)
|
||||
+ g_wasmPlayer->SetCloseState(Player::CS_STOP_PLAY);
|
||||
+}
|
||||
+
|
||||
+EMSCRIPTEN_KEEPALIVE
|
||||
+void vpinball_wasm_dispose()
|
||||
+{
|
||||
+ if (g_wasmPlayer == nullptr)
|
||||
+ return;
|
||||
+ emscripten_cancel_main_loop();
|
||||
+ delete g_wasmPlayer;
|
||||
+ g_wasmPlayer = nullptr;
|
||||
+}
|
||||
+
|
||||
+// Runs arbitrary VBScript against the running table's script interpreter,
|
||||
+// via the same ScriptInterpreter::Evaluate() entry point the interpreter's
|
||||
+// own debug console uses. Useful both as a debug/console hook for consumers
|
||||
+// and, e.g., to exercise script-driven APIs (like the DMD pixel API) against
|
||||
+// a table that doesn't itself call them, without needing to author a new
|
||||
+// .vpx table.
|
||||
+EMSCRIPTEN_KEEPALIVE
|
||||
+int vpinball_wasm_eval_script(const char* script)
|
||||
+{
|
||||
+ if (g_wasmPlayer == nullptr || g_wasmPlayer->m_scriptInterpreter == nullptr)
|
||||
+ return 0;
|
||||
+ g_wasmPlayer->m_scriptInterpreter->Evaluate(script, false);
|
||||
+ return 1;
|
||||
+}
|
||||
+
|
||||
+} // extern "C"
|
||||
+
|
||||
+#endif // __EMSCRIPTEN__
|
||||
diff --git a/src/core/player.cpp b/src/core/player.cpp
|
||||
index e83af84..60c7d5f 100644
|
||||
--- a/src/core/player.cpp
|
||||
+++ b/src/core/player.cpp
|
||||
@@ -1992,6 +1992,32 @@ void Player::GPUQueueStuffingGameLoop()
|
||||
}
|
||||
}
|
||||
|
||||
+#ifdef __EMSCRIPTEN__
|
||||
+bool Player::EmscriptenStepFrame()
|
||||
+{
|
||||
+ // vpinball-wasm: single-step version of GPUQueueStuffingGameLoop(), driven
|
||||
+ // once per browser animation frame by emscripten_set_main_loop instead of
|
||||
+ // a blocking while loop. Manual framerate throttling (uSleep) is removed
|
||||
+ // entirely - the browser's own requestAnimationFrame cadence paces us.
|
||||
+ if (GetCloseState() != CS_PLAYING && GetCloseState() != CS_USER_INPUT)
|
||||
+ return false;
|
||||
+
|
||||
+ UpdateGameLogic();
|
||||
+ PrepareFrame();
|
||||
+ UpdateGameLogic();
|
||||
+ SubmitFrame();
|
||||
+ UpdateGameLogic();
|
||||
+
|
||||
+ m_renderProfiler->EnterProfileSection(FrameProfiler::PROFILE_RENDER_FLIP);
|
||||
+ m_renderer->m_renderDevice->Flip();
|
||||
+ m_renderProfiler->ExitProfileSection();
|
||||
+
|
||||
+ FinishFrame();
|
||||
+
|
||||
+ return true;
|
||||
+}
|
||||
+#endif
|
||||
+
|
||||
void Player::FramePacingGameLoop()
|
||||
{
|
||||
// The main loop tries to perform a constant input/physics cycle at a 1ms pace while feeding the GPU command queue at a stable rate, without multithreading.
|
||||
diff --git a/src/core/player.h b/src/core/player.h
|
||||
index 6e9755a..a069cf7 100644
|
||||
--- a/src/core/player.h
|
||||
+++ b/src/core/player.h
|
||||
@@ -115,6 +115,14 @@ public:
|
||||
|
||||
void ProcessOSMessages(const bool isInitialized = true);
|
||||
|
||||
+#ifdef __EMSCRIPTEN__
|
||||
+ // vpinball-wasm: single-step frame function driven by emscripten_set_main_loop
|
||||
+ // instead of a blocking while loop (see GameLoop()). Returns false once the
|
||||
+ // player wants to quit; the caller must then stop calling this and release
|
||||
+ // the Player (running its normal destructor teardown).
|
||||
+ bool EmscriptenStepFrame();
|
||||
+#endif
|
||||
+
|
||||
private:
|
||||
VideoSyncMode m_videoSyncMode = VideoSyncMode::VSM_FRAME_PACING;
|
||||
float m_maxFramerate = 0.f; // targeted refresh rate in Hz, if larger refresh rate it will limit FPS by uSleep() //!! currently does not work adaptively as it would require IDirect3DDevice9Ex which is not supported on WinXP
|
||||
@@ -0,0 +1,20 @@
|
||||
diff --git a/src/renderer/Window.cpp b/src/renderer/Window.cpp
|
||||
index ad608f8..3dff060 100644
|
||||
--- a/src/renderer/Window.cpp
|
||||
+++ b/src/renderer/Window.cpp
|
||||
@@ -381,6 +381,15 @@ void Window::OnResized()
|
||||
return;
|
||||
SDL_GetWindowSize(m_nwnd, &m_width, &m_height);
|
||||
SDL_GetWindowSizeInPixels(m_nwnd, &m_pixelWidth, &m_pixelHeight);
|
||||
+ // vpinball-wasm: keep the (GL) default back buffer's tracked size in sync
|
||||
+ // with the window's actual pixel size, mirroring what the BGFX backend
|
||||
+ // already does explicitly every frame (RenderDevice.cpp's swapchain
|
||||
+ // resize handling). Without this, glViewport(0, 0, m_width, m_height) in
|
||||
+ // RenderTarget::Activate() keeps using the pre-resize size forever, so
|
||||
+ // e.g. entering browser fullscreen (which resizes the canvas well after
|
||||
+ // the back buffer was created) only renders into the old, smaller area.
|
||||
+ if (m_backBuffer)
|
||||
+ m_backBuffer->SetSize(m_pixelWidth, m_pixelHeight);
|
||||
}
|
||||
|
||||
VPX::Window::VideoMode Window::SDLtoVPXVideoMode(const SDL_DisplayMode* mode)
|
||||
+15
-2
@@ -40,13 +40,26 @@ EMSCRIPTEN_LINK_FLAGS=(
|
||||
-sMAX_WEBGL_VERSION=2
|
||||
-sALLOW_MEMORY_GROWTH=1
|
||||
-sMODULARIZE=1
|
||||
-sEXPORT_ES6=1
|
||||
-sEXPORT_NAME=VPinballModule
|
||||
-sENVIRONMENT=web
|
||||
-sEXPORTED_RUNTIME_METHODS=FS,ccall,cwrap
|
||||
-sFORCE_FILESYSTEM=1
|
||||
-sEXPORTED_FUNCTIONS=_main,_vpinball_wasm_start,_vpinball_wasm_stop,_vpinball_wasm_dispose,_vpinball_wasm_eval_script
|
||||
-sEXIT_RUNTIME=0
|
||||
--use-preload-cache
|
||||
)
|
||||
if [ "$BUILD_TYPE" = "Debug" ]; then
|
||||
EMSCRIPTEN_LINK_FLAGS+=(-sASSERTIONS=1 -sEXIT_RUNTIME=1)
|
||||
EMSCRIPTEN_LINK_FLAGS+=(-sASSERTIONS=1 -O0)
|
||||
else
|
||||
# CMAKE_BUILD_TYPE=Release only optimizes the compile step; emcc's link
|
||||
# step needs its own -O level to actually run wasm-opt/dead-code
|
||||
# elimination. NOTE: --closure 1 was tried here but strips FS.writeFile/
|
||||
# readFile/mkdir (the FS convenience wrappers EXPORTED_RUNTIME_METHODS=FS
|
||||
# is supposed to guarantee) down to just low-level node ops - dropped
|
||||
# until that's root-caused, since a smaller JS glue isn't worth a broken
|
||||
# runtime table-loading API.
|
||||
EMSCRIPTEN_LINK_FLAGS+=(-O2)
|
||||
fi
|
||||
|
||||
emcmake cmake \
|
||||
@@ -67,7 +80,7 @@ cp "$BUILD_DIR"/vpinball.wasm "$DIST_DIR/" 2>/dev/null || true
|
||||
cp "$BUILD_DIR"/vpinball.data "$DIST_DIR/" 2>/dev/null || true
|
||||
|
||||
mkdir -p "$ROOT_DIR/package/assets"
|
||||
cp "$VPINBALL_DIR/tests/assets/test000-default-table.vpx" "$ROOT_DIR/package/assets/default.vpx" 2>/dev/null || true
|
||||
cp "$VPINBALL_DIR/src/assets/exampleTable.vpx" "$ROOT_DIR/package/assets/default.vpx" 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "== build.sh complete =="
|
||||
|
||||
+6
-1
@@ -52,7 +52,12 @@ fetch_pinned() {
|
||||
rm -f "/tmp/${name}-${sha}.tar.gz"
|
||||
git -C "$dest" init -q
|
||||
git -C "$dest" add -A
|
||||
git -C "$dest" commit -q -m "vendor: $name @ $sha" --author="vpinball-wasm setup <noreply@localhost>"
|
||||
# -c user.name/user.email (not --author) are needed here: --author only sets
|
||||
# the commit's author field, but git also requires a *committer* identity,
|
||||
# which a fresh CI container has no ~/.gitconfig to supply. Scoped to this
|
||||
# one invocation only - doesn't touch any persistent git config.
|
||||
git -C "$dest" -c user.name="vpinball-wasm setup" -c user.email="noreply@localhost" \
|
||||
commit -q -m "vendor: $name @ $sha" --author="vpinball-wasm setup <noreply@localhost>"
|
||||
echo "$sha" > "$sentinel"
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user