114 lines
3.7 KiB
TypeScript
114 lines
3.7 KiB
TypeScript
"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>
|
||
|
|
);
|
||
|
|
}
|