02b83be58e
Add -webkit-touch-callout: none and -webkit-user-select: none inline styles to the GameControls container. The existing Tailwind select-none class (user-select: none) is not sufficient on iOS, where WKWebView still triggers the long-press callout/highlight gesture on held buttons.
187 lines
7.0 KiB
TypeScript
187 lines
7.0 KiB
TypeScript
import { useCallback, useRef } from 'react';
|
|
import { cn } from '@/lib/utils';
|
|
import { impactLight } from '@/lib/haptics';
|
|
import type { WebxdcHandle } from '@/components/Webxdc';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface GameControlsProps {
|
|
webxdcHandle: WebxdcHandle | null;
|
|
className?: string;
|
|
}
|
|
|
|
// Key mappings for each button.
|
|
const KEY_MAP = {
|
|
up: { key: 'ArrowUp', code: 'ArrowUp', keyCode: 38 },
|
|
down: { key: 'ArrowDown', code: 'ArrowDown', keyCode: 40 },
|
|
left: { key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37 },
|
|
right: { key: 'ArrowRight', code: 'ArrowRight', keyCode: 39 },
|
|
a: { key: 'x', code: 'KeyX', keyCode: 88 },
|
|
b: { key: 'z', code: 'KeyZ', keyCode: 90 },
|
|
start: { key: 'Enter', code: 'Enter', keyCode: 13 },
|
|
select: { key: 'Shift', code: 'ShiftRight', keyCode: 16 },
|
|
} as const;
|
|
|
|
type GameButton = keyof typeof KEY_MAP;
|
|
|
|
/** Buttons that trigger haptic feedback on press. */
|
|
const HAPTIC_BUTTONS = new Set<GameButton>(['a', 'b']);
|
|
|
|
/** Trigger a short vibration via the native haptic engine. */
|
|
function haptic() {
|
|
impactLight();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Component
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Virtual gamepad: d-pad + A/B + Start/Select. Buttons send synthetic key
|
|
* events into the webxdc iframe via `webxdc.keyboard` postMessage.
|
|
*/
|
|
export function GameControls({ webxdcHandle, className }: GameControlsProps) {
|
|
const activeKeys = useRef(new Set<string>());
|
|
|
|
const sendKey = useCallback(
|
|
(type: 'keydown' | 'keyup', button: GameButton) => {
|
|
if (!webxdcHandle) return;
|
|
const { key, code, keyCode } = KEY_MAP[button];
|
|
|
|
if (type === 'keydown') {
|
|
if (activeKeys.current.has(code)) return;
|
|
activeKeys.current.add(code);
|
|
if (HAPTIC_BUTTONS.has(button)) haptic();
|
|
} else {
|
|
activeKeys.current.delete(code);
|
|
}
|
|
|
|
webxdcHandle.postMessage({
|
|
jsonrpc: '2.0',
|
|
method: 'webxdc.keyboard',
|
|
params: { type, key, code, keyCode },
|
|
});
|
|
},
|
|
[webxdcHandle],
|
|
);
|
|
|
|
const handlers = (button: GameButton) => ({
|
|
onPointerDown: (e: React.PointerEvent) => {
|
|
e.preventDefault();
|
|
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
|
sendKey('keydown', button);
|
|
},
|
|
onPointerUp: (e: React.PointerEvent) => {
|
|
e.preventDefault();
|
|
sendKey('keyup', button);
|
|
},
|
|
onPointerCancel: (e: React.PointerEvent) => {
|
|
e.preventDefault();
|
|
sendKey('keyup', button);
|
|
},
|
|
onContextMenu: (e: React.SyntheticEvent) => e.preventDefault(),
|
|
});
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'flex flex-col gap-2 px-4 pb-4 pt-2 select-none touch-none',
|
|
className,
|
|
)}
|
|
style={{ WebkitTouchCallout: 'none', WebkitUserSelect: 'none' }}
|
|
>
|
|
{/* Main controls row: D-pad on left, A/B on right */}
|
|
<div className="flex items-center justify-between">
|
|
{/* D-Pad */}
|
|
<div className="relative size-[132px]">
|
|
{/* Cross background */}
|
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
|
<div className="w-11 h-full rounded-lg bg-muted/80 backdrop-blur-sm border border-border/50" />
|
|
<div className="absolute w-full h-11 rounded-lg bg-muted/80 backdrop-blur-sm border border-border/50" />
|
|
</div>
|
|
{/* Up */}
|
|
<button
|
|
className="absolute top-0 left-1/2 -translate-x-1/2 w-11 h-11 flex items-center justify-center rounded-lg active:bg-primary/20 transition-colors z-10"
|
|
aria-label="D-pad up"
|
|
{...handlers('up')}
|
|
>
|
|
<svg width="16" height="10" viewBox="0 0 16 10" className="text-foreground/70">
|
|
<path d="M8 1L14 9H2L8 1Z" fill="currentColor" />
|
|
</svg>
|
|
</button>
|
|
{/* Down */}
|
|
<button
|
|
className="absolute bottom-0 left-1/2 -translate-x-1/2 w-11 h-11 flex items-center justify-center rounded-lg active:bg-primary/20 transition-colors z-10"
|
|
aria-label="D-pad down"
|
|
{...handlers('down')}
|
|
>
|
|
<svg width="16" height="10" viewBox="0 0 16 10" className="text-foreground/70">
|
|
<path d="M8 9L2 1H14L8 9Z" fill="currentColor" />
|
|
</svg>
|
|
</button>
|
|
{/* Left */}
|
|
<button
|
|
className="absolute left-0 top-1/2 -translate-y-1/2 w-11 h-11 flex items-center justify-center rounded-lg active:bg-primary/20 transition-colors z-10"
|
|
aria-label="D-pad left"
|
|
{...handlers('left')}
|
|
>
|
|
<svg width="10" height="16" viewBox="0 0 10 16" className="text-foreground/70">
|
|
<path d="M1 8L9 2V14L1 8Z" fill="currentColor" />
|
|
</svg>
|
|
</button>
|
|
{/* Right */}
|
|
<button
|
|
className="absolute right-0 top-1/2 -translate-y-1/2 w-11 h-11 flex items-center justify-center rounded-lg active:bg-primary/20 transition-colors z-10"
|
|
aria-label="D-pad right"
|
|
{...handlers('right')}
|
|
>
|
|
<svg width="10" height="16" viewBox="0 0 10 16" className="text-foreground/70">
|
|
<path d="M9 8L1 14V2L9 8Z" fill="currentColor" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{/* A / B buttons */}
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
className="size-14 rounded-full bg-muted/80 backdrop-blur-sm border border-border/50 flex items-center justify-center active:bg-primary/20 active:scale-95 transition-all text-sm font-bold text-foreground/70"
|
|
aria-label="B button"
|
|
{...handlers('b')}
|
|
>
|
|
B
|
|
</button>
|
|
<button
|
|
className="size-14 rounded-full bg-muted/80 backdrop-blur-sm border border-border/50 flex items-center justify-center active:bg-primary/20 active:scale-95 transition-all text-sm font-bold text-foreground/70 -mt-8"
|
|
aria-label="A button"
|
|
{...handlers('a')}
|
|
>
|
|
A
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Start / Select row */}
|
|
<div className="flex items-center justify-center gap-6">
|
|
<button
|
|
className="h-8 px-4 rounded-full bg-muted/80 backdrop-blur-sm border border-border/50 flex items-center justify-center active:bg-primary/20 active:scale-95 transition-all text-[10px] font-bold uppercase tracking-wider text-foreground/60"
|
|
aria-label="Select"
|
|
{...handlers('select')}
|
|
>
|
|
Select
|
|
</button>
|
|
<button
|
|
className="h-8 px-4 rounded-full bg-muted/80 backdrop-blur-sm border border-border/50 flex items-center justify-center active:bg-primary/20 active:scale-95 transition-all text-[10px] font-bold uppercase tracking-wider text-foreground/60"
|
|
aria-label="Start"
|
|
{...handlers('start')}
|
|
>
|
|
Start
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default GameControls;
|