Files
vpinball/components/hud/TouchControlBar.tsx
T

160 lines
5.3 KiB
TypeScript
Raw Normal View History

"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>
);
}