Controller::Stop() (real desktop vpinball code, unmodified until now) busy-waits for PinmameIsRunning() to clear after calling PinmameStop(), in a sleep loop meant for a real OS thread to notice the quit flag and finish stopping on its own. Under Emscripten there is no such thread - PinmameStop() there only sets the quit flag - so the loop spun forever on the single available thread, with nothing left to ever clear it. This fires from Player::~Player()'s GameEvents_Exit script event (controller.vbs's default Exit handler calls Controller.Stop), which runs synchronously inside dispose() - hanging any consumer's teardown for a ROM-based table, most visibly React StrictMode/unmount cleanup calling stop() then dispose() shortly after. Fixed by driving one more (now-instant, since the quit flag is already set) PinmameEmscriptenStep() call directly under __EMSCRIPTEN__ instead of busy-waiting - it runs cpu_post_run() and OnStateChange(0) synchronously right there. Also adds a "Stop & Dispose" button to the basic example, exercising the same stop()-then-wait-two-frames-then-dispose() sequence consumers use, to make this kind of regression visible without a separate app. Confirmed fixed by hands-on testing: dispose() on a running ROM-based table now returns immediately instead of hanging the tab. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
154 lines
5.8 KiB
HTML
154 lines
5.8 KiB
HTML
<!doctype html>
|
|
<!--
|
|
Full usage example for 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 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>
|
|
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; }
|
|
|
|
#dispose-button {
|
|
position: absolute; top: 12px; left: 12px; z-index: 10;
|
|
padding: 8px 14px; border-radius: 6px; border: none;
|
|
background: rgba(220,38,38,0.85); color: #fff; cursor: pointer;
|
|
}
|
|
#dispose-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>
|
|
<br />
|
|
<label>Optional: ROM zip for a real-hardware table (e.g. hvymetal.zip) <input type="file" id="rom-file" accept=".zip" /></label>
|
|
</div>
|
|
<button id="start-button" disabled>Loading...</button>
|
|
</div>
|
|
<button id="fullscreen-button" class="hidden">Fullscreen</button>
|
|
<button id="dispose-button" class="hidden">Stop & Dispose</button>
|
|
</div>
|
|
|
|
<script type="module">
|
|
import { loadPinball, attachTouchControls } from '../../dist/index.js';
|
|
|
|
const canvas = document.getElementById('canvas');
|
|
const overlay = document.getElementById('overlay');
|
|
const progressBar = document.getElementById('progress-bar');
|
|
const startButton = document.getElementById('start-button');
|
|
const fileInput = document.getElementById('table-file');
|
|
const romInput = document.getElementById('rom-file');
|
|
const fullscreenButton = document.getElementById('fullscreen-button');
|
|
const disposeButton = document.getElementById('dispose-button');
|
|
|
|
let uploadedTableData;
|
|
fileInput.addEventListener('change', async () => {
|
|
const file = fileInput.files?.[0];
|
|
if (file) uploadedTableData = await file.arrayBuffer();
|
|
});
|
|
|
|
// ROM zips are copyrighted - only pick one you're legally entitled to
|
|
// use. Named after its own short ROM/game name by convention (e.g.
|
|
// hvymetal.zip), which loadRom() below relies on to derive gameName.
|
|
let uploadedRomFile;
|
|
romInput.addEventListener('change', () => {
|
|
uploadedRomFile = romInput.files?.[0];
|
|
});
|
|
|
|
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', async () => {
|
|
if (uploadedTableData) {
|
|
pinball.loadTable(uploadedTableData);
|
|
}
|
|
if (uploadedRomFile) {
|
|
const gameName = uploadedRomFile.name.replace(/\.zip$/i, '');
|
|
pinball.loadRom(gameName, await uploadedRomFile.arrayBuffer());
|
|
}
|
|
pinball.start();
|
|
overlay.classList.add('hidden');
|
|
fullscreenButton.classList.remove('hidden');
|
|
disposeButton.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.
|
|
});
|
|
});
|
|
|
|
// Mirrors the stop()-then-wait-a-couple-frames-then-dispose() pattern
|
|
// consumers need: stop() only takes effect on the engine's next
|
|
// internal step, so disposing synchronously right after can race it.
|
|
disposeButton.addEventListener('click', () => {
|
|
console.log('[dispose test] calling stop()...');
|
|
pinball.stop();
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => {
|
|
console.log('[dispose test] calling dispose()...');
|
|
pinball.dispose();
|
|
console.log('[dispose test] dispose() returned - no hang');
|
|
disposeButton.classList.add('hidden');
|
|
fullscreenButton.classList.add('hidden');
|
|
overlay.classList.remove('hidden');
|
|
startButton.textContent = 'Disposed (reload page to restart)';
|
|
startButton.disabled = true;
|
|
});
|
|
});
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|