-
+
WASM-4 web runtime dev
diff --git a/runtimes/web/package-lock.json b/runtimes/web/package-lock.json
index b818720f..c05bf4e7 100644
--- a/runtimes/web/package-lock.json
+++ b/runtimes/web/package-lock.json
@@ -10,7 +10,6 @@
"lit": "^3.1.2"
},
"devDependencies": {
- "@types/node": "^20.11.16",
"@typescript-eslint/eslint-plugin": "^6.20.0",
"@typescript-eslint/parser": "^6.20.0",
"concurrently": "8.2.2",
@@ -31,7 +30,6 @@
"lodash-es": "^4.17.21"
},
"devDependencies": {
- "@types/node": "^20.11.16",
"prettier": "3.2.4",
"rimraf": "5.0.5",
"rollup-plugin-postcss-lit": "^2.1.0",
diff --git a/runtimes/web/package.json b/runtimes/web/package.json
index fcc9e0cf..83ef1d97 100644
--- a/runtimes/web/package.json
+++ b/runtimes/web/package.json
@@ -20,7 +20,6 @@
"lit": "^3.1.2"
},
"devDependencies": {
- "@types/node": "^20.11.16",
"@typescript-eslint/eslint-plugin": "^6.20.0",
"@typescript-eslint/parser": "^6.20.0",
"concurrently": "8.2.2",
diff --git a/runtimes/web/public/favicon.ico b/runtimes/web/public/favicon.ico
new file mode 120000
index 00000000..8fa2ea42
--- /dev/null
+++ b/runtimes/web/public/favicon.ico
@@ -0,0 +1 @@
+../../../site/static/img/favicon.ico
\ No newline at end of file
diff --git a/runtimes/web/public/index.html b/runtimes/web/public/index.html
index 5b9c3bb8..1cdffaec 100644
--- a/runtimes/web/public/index.html
+++ b/runtimes/web/public/index.html
@@ -3,7 +3,7 @@
-
+
WASM-4 Cart
diff --git a/runtimes/web/src/apu.ts b/runtimes/web/src/apu.ts
index 820eed6f..5ca527af 100644
--- a/runtimes/web/src/apu.ts
+++ b/runtimes/web/src/apu.ts
@@ -12,6 +12,7 @@ export class APU {
audioCtx: AudioContext;
processor!: APUProcessor;
processorPort!: MessagePort;
+ paused: boolean = false;
constructor () {
this.audioCtx = new (window.AudioContext || window.webkitAudioContext)({
@@ -73,7 +74,8 @@ export class APU {
}
}
- unlockAudio () {
+ unpauseAudio () {
+ this.paused = false;
const audioCtx = this.audioCtx;
if (audioCtx.state == "suspended") {
audioCtx.resume();
@@ -81,9 +83,20 @@ export class APU {
}
pauseAudio () {
+ this.paused = true;
const audioCtx = this.audioCtx;
if (audioCtx.state == "running") {
audioCtx.suspend();
}
}
+
+ // Most web browsers won't play audio until the user has interacted with the web page.
+ // Even if there are already pending Promises from an AudioContext.resume() call when the
+ // page gets interaction, apparently the AudioContext still needs to be poked with a
+ // resume() call to start playing.
+ pokeAudio () {
+ if (!this.paused) {
+ this.audioCtx.resume();
+ }
+ }
}
diff --git a/runtimes/web/src/runtime.ts b/runtimes/web/src/runtime.ts
index 515cc151..d573c026 100644
--- a/runtimes/web/src/runtime.ts
+++ b/runtimes/web/src/runtime.ts
@@ -90,14 +90,18 @@ export class Runtime {
return this.data.getUint8(constants.ADDR_SYSTEM_FLAGS) & mask;
}
- unlockAudio () {
- this.apu.unlockAudio();
+ unpauseAudio () {
+ this.apu.unpauseAudio();
}
- pauseAudio() {
+ pauseAudio () {
this.apu.pauseAudio();
}
+ pokeAudio () {
+ this.apu.pokeAudio();
+ }
+
reset (zeroMemory?: boolean) {
// Initialize default color table and palette
const mem32 = new Uint32Array(this.memory.buffer);
diff --git a/runtimes/web/src/ui/app.ts b/runtimes/web/src/ui/app.ts
index 0b3d1274..25cbf9b7 100644
--- a/runtimes/web/src/ui/app.ts
+++ b/runtimes/web/src/ui/app.ts
@@ -8,6 +8,7 @@ import * as z85 from "../z85";
import { Netplay, DEV_NETPLAY } from "../netplay";
import { Runtime } from "../runtime";
import { State } from "../state";
+import { callAt60Hz } from "../update-timing";
import { MenuOverlay } from "./menu-overlay";
import { Notifications } from "./notifications";
@@ -83,7 +84,7 @@ export class App extends LitElement {
}
// Try to begin playing audio
- this.runtime.unlockAudio();
+ this.runtime.pokeAudio();
}
constructor () {
@@ -261,8 +262,8 @@ export class App extends LitElement {
const down = (event.type == "keydown");
- // Poke WebAudio
- runtime.unlockAudio();
+ // Try to begin playing audio
+ runtime.pokeAudio();
// We're using the keyboard now, hide the mouse cursor for extra immersion
document.body.style.cursor = "none";
@@ -427,13 +428,7 @@ export class App extends LitElement {
}
}
- // When we should perform the next update
- let timeNextUpdate = performance.now();
- // Track the timestamp of the last frame
- let lastTimeFrameStart = timeNextUpdate;
-
- const onFrame = (timeFrameStart: number) => {
- requestAnimationFrame(onFrame);
+ const doUpdate = (interFrameTime: number | null) => {
pollPhysicalGamepads();
let input = this.inputState;
@@ -450,45 +445,28 @@ export class App extends LitElement {
}
}
- let calledUpdate = false;
-
- // Prevent timeFrameStart from getting too far ahead and death spiralling
- if (timeFrameStart - timeNextUpdate >= 200) {
- timeNextUpdate = timeFrameStart;
- }
-
- while (timeFrameStart >= timeNextUpdate) {
- timeNextUpdate += 1000/60;
-
- if (this.netplay) {
- if (this.netplay.update(input.gamepad[0])) {
- calledUpdate = true;
- }
-
- } else {
- // Pass inputs into runtime memory
- for (let playerIdx = 0; playerIdx < 4; ++playerIdx) {
- runtime.setGamepad(playerIdx, input.gamepad[playerIdx]);
- }
- runtime.setMouse(input.mouseX, input.mouseY, input.mouseButtons);
- runtime.update();
- calledUpdate = true;
+ if (this.netplay) {
+ this.netplay.update(input.gamepad[0]);
+ } else {
+ // Pass inputs into runtime memory
+ for (let playerIdx = 0; playerIdx < 4; ++playerIdx) {
+ runtime.setGamepad(playerIdx, input.gamepad[playerIdx]);
}
+ runtime.setMouse(input.mouseX, input.mouseY, input.mouseButtons);
+ runtime.update();
}
- if (calledUpdate) {
- this.hideGamepadOverlay = !!runtime.getSystemFlag(constants.SYSTEM_HIDE_GAMEPAD_OVERLAY);
+ this.hideGamepadOverlay = !!runtime.getSystemFlag(constants.SYSTEM_HIDE_GAMEPAD_OVERLAY);
- runtime.composite();
+ runtime.composite();
- if (constants.GAMEDEV_MODE) {
- // FIXED(2023-12-13): Pass the correct FPS for display
- devtoolsManager.updateCompleted(runtime, timeFrameStart - lastTimeFrameStart);
- lastTimeFrameStart = timeFrameStart;
+ if (constants.GAMEDEV_MODE) {
+ if (interFrameTime !== null) {
+ devtoolsManager.updateCompleted(runtime, interFrameTime);
}
}
}
- requestAnimationFrame(onFrame);
+ callAt60Hz(doUpdate);
}
onMenuButtonPressed () {
@@ -497,12 +475,19 @@ export class App extends LitElement {
this.inputState.gamepad[0] |= constants.BUTTON_X;
} else {
this.showMenu = true;
+ // During netplay, opening the menu doesn't pause the game, so shouldn't
+ // pause the audio either. Also, when audio is paused, the browser is
+ // more likely to throttle our update rate when we're in the background.
+ if (!this.netplay) {
+ this.runtime.pauseAudio();
+ }
}
}
closeMenu () {
if (this.showMenu) {
this.showMenu = false;
+ this.runtime.unpauseAudio();
// Kind of a hack to prevent the button press to close the menu from being passed
// through to the game
diff --git a/runtimes/web/src/update-timing.ts b/runtimes/web/src/update-timing.ts
new file mode 100644
index 00000000..a96b0212
--- /dev/null
+++ b/runtimes/web/src/update-timing.ts
@@ -0,0 +1,153 @@
+export function callAt60Hz(callback: (interFrameTime: number | null) => void) {
+ if (_callback) {
+ throw new Error("can only have one update function");
+ }
+ _callback = callback;
+ requestAnimationFrame(onVsync);
+}
+
+let _callback: ((interFrameTime: number | null) => void) | undefined;
+
+let previousFrameStartTime: number | null = null;
+function doUpdate() {
+ let frameStartTime = performance.now();
+ let interFrameTime = previousFrameStartTime === null ? null : frameStartTime - previousFrameStartTime;
+ _callback!(interFrameTime);
+ previousFrameStartTime = frameStartTime;
+}
+
+
+// We use a scheme to switch between a vsync-based and timer-based update timing, depending on
+// the vsync rate. This keeps the updates smooth and regular on a 60 fps monitor (or multiple thereof),
+// but still at the correct update rate for other framerates.
+
+const idealIntervalMs = 1000 / 60;
+
+type TimerMode = {
+ vsyncMode: false,
+ timerID: number,
+}
+
+type VsyncMode = {
+ vsyncMode: true,
+ vsyncTimeoutID: number,
+}
+
+type UpdateTimingMode = TimerMode | VsyncMode;
+
+let updateTimingMode: UpdateTimingMode = {
+ vsyncMode: true,
+ // It's safe to just set this to 0 because that's never a valid timer ID, and clearing
+ // a non-existant ID does nothing.
+ vsyncTimeoutID: 0,
+}
+
+let previousVsyncTime: number | null = null;
+let smoothedVsyncInterval = 60;
+let vsyncDividerCounter = 0;
+// A requestAnimationFrame callback generally happens once soon after vsync, and the time passed
+// to it is essentially the vsync time. Roughly speaking, this is a vsync callback.
+// Switching between timing modes is controlled from this function.
+function onVsync(vsyncTime: number) {
+ requestAnimationFrame(onVsync);
+
+ if (previousVsyncTime !== null) {
+ let vsyncInterval = (vsyncTime - previousVsyncTime);
+ const a = 0.3;
+ smoothedVsyncInterval = (1-a)*smoothedVsyncInterval + a*vsyncInterval;
+ }
+ previousVsyncTime = vsyncTime;
+
+
+ let framerateRatio = idealIntervalMs / smoothedVsyncInterval;
+ let roundedFramerateRatio = Math.round(framerateRatio);
+ let fractionalFramerateRatio = framerateRatio % 1;
+ if (roundedFramerateRatio >= 1 && (fractionalFramerateRatio < 0.01 || fractionalFramerateRatio > 0.99)) {
+ // The framerate is near to a multiple of 60, so we go to (or stay in) Vsync mode, and do an update.
+
+ // In case requestAnimationFrame callbacks suddenly stop happening as often or stop altogether
+ // (e.g. when a desktop user puts the browser window in the background, moves the window to a monitor
+ // with a different framerate, etc.), we use a timeout that will rapidly switch to timer mode.
+
+ if (updateTimingMode.vsyncMode) {
+ clearTimeout(updateTimingMode.vsyncTimeoutID);
+ } else {
+ clearTimeout(updateTimingMode.timerID);
+ }
+
+ updateTimingMode = {
+ vsyncMode: true,
+ vsyncTimeoutID: setTimeout(onTimer, 1.2*idealIntervalMs),
+ }
+
+ vsyncDividerCounter++;
+ if (vsyncDividerCounter >= roundedFramerateRatio) {
+ vsyncDividerCounter = 0;
+ doUpdate();
+ }
+ } else {
+ // Switch to (or stay in) timer mode.
+ // We need to be able to handle going to either a lower vsync rate like 30 per second,
+ // or a higher one like 90 per second.
+ if (updateTimingMode.vsyncMode) {
+ clearTimeout(updateTimingMode.vsyncTimeoutID);
+
+ let timeout;
+ let now = performance.now();
+ if (previousFrameStartTime !== null) {
+ target = previousFrameStartTime + idealIntervalMs;
+ } else {
+ target = now;
+ }
+ timeout = target - now;
+ updateTimingMode = {
+ vsyncMode: false,
+ timerID: setTimeout(onTimer, timeout)
+ };
+ }
+ }
+}
+
+// For framerates that aren't a multiple of 60, a setTimeout() solution is used.
+// This is especially necessary when requestAnimationFrame callbacks happen at less
+// than 60 times a second, to ensure that audio is updated at a uniform interval of 60 times per second.
+// This could happen e.g. when the device only has a 30 fps screen, or on a desktop when the browser
+// window is put in the background. The runtime also falls into timer mode when update calls are taking
+// too long.
+// setTimeout() is used over setInterval() because setInterval rounds down 16.66ms to 16ms and some browsers
+// run setInterval late whereas others try to keep it at the correct frequency on average. Overall, careful use
+// of setTimeout() gives better control of timing.
+let target = 0;
+function onTimer() {
+ let now = performance.now();
+
+ if (updateTimingMode.vsyncMode) {
+ // The vsync timeout has triggered.
+ target = now;
+ }
+
+ // If it's been too long since our target time, don't try to catch up on lost time and frames.
+ // Just accept that there was lag and continue at normal pace from now.
+ // For this reason, the value chosen for this should be only just large enough to absorb timer jitter.
+ // I've chosen a conservatively large value of 16.6 milliseconds.
+ if (now - target > idealIntervalMs) {
+ target = now + idealIntervalMs;
+ } else {
+ // By setting a target that increases at 60 fps and aiming next frame for it, various timer
+ // innaccuracies are corrected for and averaged out, including: the jitter added to performance.now()
+ // for security purposes, intrinsic lateness of setTimeout() callbacks, setTimeout() only taking
+ // an integer number of milliseconds and removing any fractional part (1000/60 = 16.666ms becomes 16ms
+ // on major browsers, which corresponds to 62.5 updates per second, a noticable speedup).
+ target += idealIntervalMs;
+ }
+
+ updateTimingMode = {
+ vsyncMode: false,
+ // Calling setTimeout before doUpdate means that the browser clamping the timeout to a minimum of 4ms
+ // isn't a problem. If we called it after, we would get slowdown at high load, when update ends less
+ // than 4ms before the start of the next frame.
+ timerID: setTimeout(onTimer, target-now)
+ };
+
+ doUpdate();
+}
\ No newline at end of file
diff --git a/runtimes/web/tsconfig.json b/runtimes/web/tsconfig.json
index 7c9ebc81..57d686f8 100644
--- a/runtimes/web/tsconfig.json
+++ b/runtimes/web/tsconfig.json
@@ -14,7 +14,8 @@
"forceConsistentCasingInFileNames": true,
"alwaysStrict": true,
"useDefineForClassFields": false,
- "isolatedModules": true
+ "isolatedModules": true,
+ "types": []
},
"include": ["src/**/*.ts", "src/**/*.js"],
"exclude": []
diff --git a/runtimes/web/vite.config.ts b/runtimes/web/vite.config.ts
index d656c411..2d9e4548 100644
--- a/runtimes/web/vite.config.ts
+++ b/runtimes/web/vite.config.ts
@@ -10,6 +10,12 @@ export default defineConfig(({ mode }) => {
server: {
port: 3000,
open: '/?url=cart.wasm',
+ headers: {
+ // These COOP and COEP headers allow us to get high-precision time.
+ // https://developer.mozilla.org/en-US/docs/Web/API/Performance/now#security_requirements
+ "Cross-Origin-Opener-Policy": "same-origin",
+ "Cross-Origin-Embedder-Policy": "require-corp", // "credentialless" is also a possibility
+ },
},
build: {
sourcemap: gamedev_build,
diff --git a/site/src/components/NetplayCart.js b/site/src/components/NetplayCart.js
index da067387..86fef4e5 100644
--- a/site/src/components/NetplayCart.js
+++ b/site/src/components/NetplayCart.js
@@ -39,9 +39,6 @@ export default function NetplayCart () {
FAQ
- The game seems to be paused or incredibly slow?
-
Make sure you're not playing in a background tab! Most browsers heavily throttle pages in a background tab. To test netplay, open the game in two separate, visible windows.
-
How do I check our connection quality?
You can see your ping to each of the other players on the pause menu by pressing Enter. For fast-paced action games, ping below 200 ms is recommended for best results.