Compare commits

...
6 Commits
Author SHA1 Message Date
valknar 5423ca70f4 Fix stale/mismatched engine cache causing "ASM_CONSTS[...] is not a function" live
The service worker's engine cache key relied on a manual CACHE_VERSION
bump that was forgotten across the last two @valknar/vpinball-wasm
version bumps, so returning visitors kept getting served old cached
vpinball.js/.wasm/.data via cache-first with no revalidation — the origin
server itself was serving a correct, matching 0.3.4 set the whole time
(verified via headers/sizes against the local build).

Split CACHE_VERSION into a manually-bumped SHELL_VERSION and an
ENGINE_VERSION that copy-engine-assets.mjs now writes automatically from
the installed engine's package.json on every build, so the engine cache
always busts in lockstep with the dependency version — no step to forget.
2026-08-24 14:42:38 +02:00
valknar 89d4d9b071 Bump @valknar/vpinball-wasm to 0.3.4
0.3.4 skips SDL_SetWindowIcon on the Emscripten target, so the engine no
longer hijacks the browser tab's favicon (and title) via a blob: URL
when a table loads — verified live, no app-side workaround needed.
2026-08-24 14:08:19 +02:00
valknar a2b929dbec Revert "Stop the pinball engine from hijacking the browser tab's favicon"
This reverts commit 4dd6d60457.
2026-08-24 13:52:38 +02:00
valknar 4dd6d60457 Stop the pinball engine from hijacking the browser tab's favicon
vpinball.js sets a table-supplied image as the page favicon via a blob:
URL as a table loads. Revert any blob: href on the icon <link> the
moment it appears, via a MutationObserver, instead of patching the
compiled engine.
2026-08-24 13:46:26 +02:00
valknar 0afa61cc5c Use proper SVG icons in the touch control bar, center the middle cluster absolutely
Swaps the unicode glyphs (◀ ▶ ⬆) for hand-drawn stroke/fill SVG icons
(chevrons, an eject-style plunger glyph, a coin glyph, a play triangle),
and positions the coin/start/plunger cluster with absolute + translate
so it sits on the bar's true center regardless of the flipper buttons.
2026-08-24 13:28:45 +02:00
valknar 9e8f08ba16 Replace mobile touch overlay with a coin-door-styled bottom control bar
The engine's default attachTouchControls() overlay was generic floating
circles; this swaps in an app-themed bottom bar (left/right flipper,
plunger, insert coin, start game) anchored to the screen edges instead.
2026-08-24 13:24:47 +02:00
9 changed files with 206 additions and 63 deletions
+159
View File
@@ -0,0 +1,159 @@
"use client";
import { useCallback, useState } from "react";
interface KeyBinding {
key: string;
code: string;
}
const LEFT_FLIPPER: KeyBinding = { key: "Shift", code: "ShiftLeft" };
const RIGHT_FLIPPER: KeyBinding = { key: "Shift", code: "ShiftRight" };
const PLUNGER: KeyBinding = { key: "Enter", code: "Enter" };
const INSERT_COIN: KeyBinding = { key: "4", code: "Digit4" };
const START_GAME: KeyBinding = { key: "1", code: "Digit1" };
function dispatchKey(type: "keydown" | "keyup", binding: KeyBinding) {
// SDL3's Emscripten video backend listens on the default "#window" target,
// so a real KeyboardEvent dispatched on window reaches the engine the same
// way a physical keypress would (see @valknar/vpinball-wasm's own
// touch-controls.js, which this bar replaces with app-styled UI).
window.dispatchEvent(new KeyboardEvent(type, { key: binding.key, code: binding.code, bubbles: true }));
}
function isTouchDevice(): boolean {
return typeof window !== "undefined" && ("ontouchstart" in window || navigator.maxTouchPoints > 0);
}
function FlipperLeftIcon() {
return (
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" strokeWidth="3">
<polyline points="15 5 8 12 15 19" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function FlipperRightIcon() {
return (
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" strokeWidth="3">
<polyline points="9 5 16 12 9 19" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
/** Eject-style glyph (triangle over a bar) for the plunger/launch control. */
function PlungerIcon() {
return (
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M12 4.5 19 15H5l7-10.5Z" />
<rect x="5" y="17.5" width="14" height="2.5" rx="1" />
</svg>
);
}
function CoinIcon() {
return (
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.75">
<circle cx="12" cy="12" r="8.5" />
<path
d="M14.5 9.3c-.4-.8-1.3-1.3-2.5-1.3-1.7 0-3 .9-3 2.1 0 2.9 5.5 1.3 5.5 4.1 0 1.2-1.3 2.1-3 2.1-1.2 0-2.1-.5-2.5-1.3M12 6.8v1.2M12 16v1.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function PlayIcon() {
return (
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M7 4.5v15l13-7.5-13-7.5Z" />
</svg>
);
}
function ControlButton({
binding,
label,
icon,
size,
}: {
binding: KeyBinding;
label: string;
icon: React.ReactNode;
size: "lg" | "md" | "sm";
}) {
const [pressed, setPressed] = useState(false);
const onDown = useCallback(
(e: React.PointerEvent<HTMLButtonElement>) => {
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
setPressed(true);
dispatchKey("keydown", binding);
},
[binding],
);
const onUp = useCallback(() => {
setPressed(false);
dispatchKey("keyup", binding);
}, [binding]);
const sizeClasses = size === "lg" ? "h-20 w-20" : size === "md" ? "h-14 w-14" : "h-12 w-12";
return (
<button
type="button"
aria-label={label}
onPointerDown={onDown}
onPointerUp={onUp}
onPointerCancel={onUp}
onContextMenu={(e) => e.preventDefault()}
className={`flex touch-none select-none items-center justify-center rounded-full border-2 transition ${sizeClasses} ${
pressed
? "border-marquee bg-marquee/30 text-marquee shadow-[0_0_16px_rgba(255,122,41,0.5)]"
: "border-chrome/40 bg-black/40 text-chrome"
}`}
>
{icon}
</button>
);
}
export interface TouchControlBarProps {
active: boolean;
}
/**
* Coin-door-styled bottom control bar for mobile play. Replaces
* @valknar/vpinball-wasm's default floating overlay buttons with UI that
* matches the cabinet look and puts the flippers at the screen's bottom
* corners, in thumb reach when holding the device landscape.
*/
export default function TouchControlBar({ active }: TouchControlBarProps) {
// Computed once at mount (matches usePinballInstance's hasWebGL2 pattern) —
// touch support doesn't change over a session, so this doesn't need an
// effect/listener, just a lazy initial state.
const [isTouch] = useState(isTouchDevice);
if (!active || !isTouch) return null;
return (
<div
data-hud
className="absolute inset-x-0 bottom-0 z-10 flex items-center justify-between border-t border-chrome/30 bg-ash/90 px-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3 backdrop-blur sm:hidden"
>
<ControlButton binding={LEFT_FLIPPER} label="Left flipper" icon={<FlipperLeftIcon />} size="lg" />
{/* Positioned absolutely so it sits on the bar's true center, independent
of the (equal-width, so already symmetric) flipper buttons on either side. */}
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center gap-3">
<ControlButton binding={INSERT_COIN} label="Insert coin" icon={<CoinIcon />} size="sm" />
<ControlButton binding={START_GAME} label="Start game" icon={<PlayIcon />} size="sm" />
<ControlButton binding={PLUNGER} label="Plunger" icon={<PlungerIcon />} size="md" />
</div>
<ControlButton binding={RIGHT_FLIPPER} label="Right flipper" icon={<FlipperRightIcon />} size="lg" />
</div>
);
}
+4 -4
View File
@@ -2,8 +2,8 @@
import { useRef } from "react";
import { usePinballInstance, type RomSelection } from "@/lib/pinball/usePinballInstance";
import { useTouchControls } from "@/lib/pinball/useTouchControls";
import CoinDoorHud from "@/components/hud/CoinDoorHud";
import TouchControlBar from "@/components/hud/TouchControlBar";
import type { TableInfo } from "@/lib/pinball/types";
export interface PinballCanvasImplProps {
@@ -14,16 +14,14 @@ export interface PinballCanvasImplProps {
export default function PinballCanvasImpl({ tableData, rom, tableInfo }: PinballCanvasImplProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const { status, progress, error, loadTimeMs, instanceRef, start } = usePinballInstance(
canvasRef,
tableData,
rom,
);
useTouchControls(containerRef, status === "running");
return (
<div ref={containerRef} className="relative h-full w-full overflow-hidden bg-black">
<div className="relative h-full w-full overflow-hidden bg-black">
{/* SDL3's Emscripten backend resolves its own window/GL-context target via
document.querySelector("#canvas") independently of Module.canvas — the
id is required or context creation silently no-ops (findCanvasEventTarget
@@ -35,6 +33,8 @@ export default function PinballCanvasImpl({ tableData, rom, tableInfo }: Pinball
<CoinDoorHud instanceRef={instanceRef} tableInfo={tableInfo} loadTimeMs={loadTimeMs} />
)}
<TouchControlBar active={status === "running"} />
{status === "loading" && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-ink/90 text-paper">
<div className="font-display text-2xl tracking-wide text-marquee">LOADING TABLE</div>
-38
View File
@@ -1,38 +0,0 @@
"use client";
import { useEffect } from "react";
import type { attachTouchControls as AttachTouchControls } from "@valknar/vpinball-wasm";
const ENGINE_BASE_URL = "/vendor/vpinball-wasm";
function isTouchDevice(): boolean {
return typeof window !== "undefined" && ("ontouchstart" in window || navigator.maxTouchPoints > 0);
}
/** Attaches the engine's on-screen touch overlay to `containerRef` while `active` is true. */
export function useTouchControls(
containerRef: React.RefObject<HTMLElement | null>,
active: boolean,
): void {
useEffect(() => {
if (!active || !isTouchDevice()) return;
const container = containerRef.current;
if (!container) return;
let handle: { detach: () => void } | undefined;
let cancelled = false;
(async () => {
const { attachTouchControls } = (await import(
/* webpackIgnore: true */ `${ENGINE_BASE_URL}/index.js`
)) as { attachTouchControls: typeof AttachTouchControls };
if (cancelled) return;
handle = attachTouchControls({ container });
})();
return () => {
cancelled = true;
handle?.detach();
};
}, [containerRef, active]);
}
+1 -1
View File
@@ -12,7 +12,7 @@
"lint": "eslint"
},
"dependencies": {
"@valknar/vpinball-wasm": "0.3.3",
"@valknar/vpinball-wasm": "0.3.4",
"next": "16.3.2",
"react": "19.2.8",
"react-dom": "19.2.8"
+5 -5
View File
@@ -9,8 +9,8 @@ importers:
.:
dependencies:
'@valknar/vpinball-wasm':
specifier: 0.3.3
version: 0.3.3
specifier: 0.3.4
version: 0.3.4
next:
specifier: 16.3.2
version: 16.3.2(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@20.19.43)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@@ -754,8 +754,8 @@ packages:
cpu: [x64]
os: [win32]
'@valknar/vpinball-wasm@0.3.3':
resolution: {integrity: sha512-It2VO8FrmBa7u+aom4m+JCWaVv6ZOhRn0x8BqqrFbGo2ptTb64G8SgqRXuGU8GldhIvgl3/ug+PsSg3EhETZpg==, tarball: https://dev.pivoine.art/api/packages/valknar/npm/%40valknar%2Fvpinball-wasm/-/0.3.3/vpinball-wasm-0.3.3.tgz}
'@valknar/vpinball-wasm@0.3.4':
resolution: {integrity: sha512-ULJ8jF3Q8sxmkkSdC70SSKzkvaNHORPZEFuJzQbmfH9aeqh4kYmqj59KX7bNywmAxLz4yLtr8r4i/oWEo8bbSw==, tarball: https://dev.pivoine.art/api/packages/valknar/npm/%40valknar%2Fvpinball-wasm/-/0.3.4/vpinball-wasm-0.3.4.tgz}
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
@@ -2645,7 +2645,7 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.12.2':
optional: true
'@valknar/vpinball-wasm@0.3.3': {}
'@valknar/vpinball-wasm@0.3.4': {}
acorn-jsx@5.3.2(acorn@8.18.0):
dependencies:
+1 -1
View File
@@ -2,5 +2,5 @@ allowBuilds:
sharp: false
unrs-resolver: false
minimumReleaseAgeExclude:
- '@valknar/vpinball-wasm@0.2.0 || 0.3.1 || 0.3.2 || 0.3.3'
- '@valknar/vpinball-wasm@0.2.0 || 0.3.1 || 0.3.2 || 0.3.3 || 0.3.4'
updateNotifier: false
+12 -5
View File
@@ -1,9 +1,16 @@
// Bump this on every release that changes the app shell or the vendored
// engine build — it's the only thing that invalidates old caches, since
// Bump manually (via `pnpm sw:bump`) whenever SHELL_ASSETS or the shell's
// caching behavior changes — nothing else invalidates the shell cache, since
// none of the cached URLs below are content-hashed by us.
const CACHE_VERSION = "v2";
const SHELL_CACHE = `shell-${CACHE_VERSION}`;
const ENGINE_CACHE = `engine-${CACHE_VERSION}`;
const SHELL_VERSION = "v2";
// Written automatically by scripts/copy-engine-assets.mjs from the installed
// @valknar/vpinball-wasm version — do not edit by hand. Ties the engine
// cache key to the actual engine build, so a version bump always busts
// stale caches instead of relying on a human to remember a separate step
// (which is exactly how returning visitors ended up on a stale, and
// possibly internally inconsistent, engine bundle before this existed).
const ENGINE_VERSION = "0.3.4";
const SHELL_CACHE = `shell-${SHELL_VERSION}`;
const ENGINE_CACHE = `engine-${ENGINE_VERSION}`;
const SHELL_ASSETS = [
"/",
+8 -7
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env node
// Bumps public/sw.js's CACHE_VERSION so a deploy invalidates old caches —
// run this before cutting a release that changes the app shell or updates
// the vendored vpinball-wasm engine build.
// Bumps public/sw.js's SHELL_VERSION so a deploy invalidates old shell
// caches — run this before cutting a release that changes SHELL_ASSETS or
// the shell's caching behavior. (The engine cache busts itself automatically
// from the installed vpinball-wasm version — see copy-engine-assets.mjs.)
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -10,12 +11,12 @@ const rootDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const swPath = path.join(rootDir, "public", "sw.js");
const contents = await readFile(swPath, "utf8");
const match = contents.match(/CACHE_VERSION = "v(\d+)"/);
const match = contents.match(/SHELL_VERSION = "v(\d+)"/);
if (!match) {
console.error(`Couldn't find CACHE_VERSION in ${swPath}`);
console.error(`Couldn't find SHELL_VERSION in ${swPath}`);
process.exit(1);
}
const next = Number(match[1]) + 1;
const updated = contents.replace(/CACHE_VERSION = "v\d+"/, `CACHE_VERSION = "v${next}"`);
const updated = contents.replace(/SHELL_VERSION = "v\d+"/, `SHELL_VERSION = "v${next}"`);
await writeFile(swPath, updated);
console.log(`Bumped service worker CACHE_VERSION to v${next}`);
console.log(`Bumped service worker SHELL_VERSION to v${next}`);
+16 -2
View File
@@ -3,13 +3,14 @@
// so it's served as plain static files (see lib/pinball/usePinballInstance.ts for why:
// the package's own loadPinball() does a dynamic import() of its glue script that must
// never pass through webpack's module graph).
import { cp, mkdir, rm } from "node:fs/promises";
import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const rootDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const pkgDistDir = path.join(rootDir, "node_modules", "@valknar", "vpinball-wasm", "dist");
const pkgDir = path.join(rootDir, "node_modules", "@valknar", "vpinball-wasm");
const pkgDistDir = path.join(pkgDir, "dist");
const targetDir = path.join(rootDir, "public", "vendor", "vpinball-wasm");
if (!existsSync(pkgDistDir)) {
@@ -24,3 +25,16 @@ await mkdir(targetDir, { recursive: true });
await cp(pkgDistDir, targetDir, { recursive: true });
console.log(`Copied vpinball-wasm engine assets to ${path.relative(rootDir, targetDir)}/`);
// Keep the service worker's engine cache key in lockstep with the installed
// engine version, so a version bump always busts stale caches for returning
// visitors — see sw.js's ENGINE_VERSION comment for why this must not be a
// manual step.
const { version: engineVersion } = JSON.parse(await readFile(path.join(pkgDir, "package.json"), "utf8"));
const swPath = path.join(rootDir, "public", "sw.js");
const swContents = await readFile(swPath, "utf8");
const updatedSw = swContents.replace(/ENGINE_VERSION = "[^"]*"/, `ENGINE_VERSION = "${engineVersion}"`);
if (updatedSw !== swContents) {
await writeFile(swPath, updatedSw);
console.log(`Set sw.js ENGINE_VERSION to ${engineVersion}`);
}