diff --git a/components/hud/TouchControlBar.tsx b/components/hud/TouchControlBar.tsx new file mode 100644 index 0000000..a0d97f1 --- /dev/null +++ b/components/hud/TouchControlBar.tsx @@ -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) => { + 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 ( + + ); +} + +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 ( +
+ + +
+ + + +
+ + +
+ ); +} diff --git a/components/pinball/PinballCanvasImpl.tsx b/components/pinball/PinballCanvasImpl.tsx index 8686ef5..cd01ff0 100644 --- a/components/pinball/PinballCanvasImpl.tsx +++ b/components/pinball/PinballCanvasImpl.tsx @@ -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(null); - const containerRef = useRef(null); const { status, progress, error, loadTimeMs, instanceRef, start } = usePinballInstance( canvasRef, tableData, rom, ); - useTouchControls(containerRef, status === "running"); return ( -
+
{/* 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 )} + + {status === "loading" && (
LOADING TABLE
diff --git a/lib/pinball/useTouchControls.ts b/lib/pinball/useTouchControls.ts deleted file mode 100644 index 49f9d6c..0000000 --- a/lib/pinball/useTouchControls.ts +++ /dev/null @@ -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, - 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]); -}