Compare commits
2
Commits
a193bdb3da
...
0afa61cc5c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0afa61cc5c | ||
|
|
9e8f08ba16 |
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { usePinballInstance, type RomSelection } from "@/lib/pinball/usePinballInstance";
|
import { usePinballInstance, type RomSelection } from "@/lib/pinball/usePinballInstance";
|
||||||
import { useTouchControls } from "@/lib/pinball/useTouchControls";
|
|
||||||
import CoinDoorHud from "@/components/hud/CoinDoorHud";
|
import CoinDoorHud from "@/components/hud/CoinDoorHud";
|
||||||
|
import TouchControlBar from "@/components/hud/TouchControlBar";
|
||||||
import type { TableInfo } from "@/lib/pinball/types";
|
import type { TableInfo } from "@/lib/pinball/types";
|
||||||
|
|
||||||
export interface PinballCanvasImplProps {
|
export interface PinballCanvasImplProps {
|
||||||
@@ -14,16 +14,14 @@ export interface PinballCanvasImplProps {
|
|||||||
|
|
||||||
export default function PinballCanvasImpl({ tableData, rom, tableInfo }: PinballCanvasImplProps) {
|
export default function PinballCanvasImpl({ tableData, rom, tableInfo }: PinballCanvasImplProps) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const { status, progress, error, loadTimeMs, instanceRef, start } = usePinballInstance(
|
const { status, progress, error, loadTimeMs, instanceRef, start } = usePinballInstance(
|
||||||
canvasRef,
|
canvasRef,
|
||||||
tableData,
|
tableData,
|
||||||
rom,
|
rom,
|
||||||
);
|
);
|
||||||
useTouchControls(containerRef, status === "running");
|
|
||||||
|
|
||||||
return (
|
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
|
{/* SDL3's Emscripten backend resolves its own window/GL-context target via
|
||||||
document.querySelector("#canvas") independently of Module.canvas — the
|
document.querySelector("#canvas") independently of Module.canvas — the
|
||||||
id is required or context creation silently no-ops (findCanvasEventTarget
|
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} />
|
<CoinDoorHud instanceRef={instanceRef} tableInfo={tableInfo} loadTimeMs={loadTimeMs} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<TouchControlBar active={status === "running"} />
|
||||||
|
|
||||||
{status === "loading" && (
|
{status === "loading" && (
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-ink/90 text-paper">
|
<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>
|
<div className="font-display text-2xl tracking-wide text-marquee">LOADING TABLE</div>
|
||||||
|
|||||||
@@ -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]);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user