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.
This commit is contained in:
2026-08-24 13:24:47 +02:00
parent a193bdb3da
commit 9e8f08ba16
3 changed files with 117 additions and 42 deletions
+113
View File
@@ -0,0 +1,113 @@
"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 ControlButton({
binding,
label,
size,
}: {
binding: KeyBinding;
label: string;
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 text-3xl"
: size === "md"
? "h-14 w-14 text-lg"
: "h-12 w-14 font-mono text-[11px] tracking-widest";
return (
<button
type="button"
aria-label={binding.key === "Shift" ? `${label} flipper` : label}
onPointerDown={onDown}
onPointerUp={onUp}
onPointerCancel={onUp}
onContextMenu={(e) => e.preventDefault()}
className={`touch-none select-none rounded-full border-2 font-display 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"
}`}
>
{label}
</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 gap-2 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="◀" size="lg" />
<div className="flex shrink-0 items-center gap-2">
<ControlButton binding={INSERT_COIN} label="COIN" size="sm" />
<ControlButton binding={START_GAME} label="START" size="sm" />
<ControlButton binding={PLUNGER} label="⬆" size="md" />
</div>
<ControlButton binding={RIGHT_FLIPPER} label="▶" 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]);
}