SOURCE / PINNED RELEASE
Made of little things.
Supersonic RC Revive
- Release
- 1ba42f1ca1d6…
- Author-recorded commit
- baecad10b1cd…
- License
- LICENSE
- Author’s source reference
- nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.napplet.soy%2F/n-143146b0d6f
Archive hash verified: 90d22b206672eba4…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
// Director keyPressed() emulation. The Lingo uses Mac key codes for the
// arrows (123 left, 124 right, 125 down, 126 up) and Escape (53), and
// characters for the rest (" ", "s", "1").
import { createGamepadInput, type GamepadBindings, type Controller, type ControllerFrame } from '../../docs/examples/gamepad.js';
export type DirectorKey = 123 | 124 | 125 | 126 | 53 | ' ' | 's' | '1';
/** A controller press outside a race: focus moves, A (select), B (back), Menu (start). */
export type MenuIntent = 'up' | 'down' | 'left' | 'right' | 'select' | 'back' | 'start';
/**
* Standard-mapping controller layout (Xbox lettering): A accelerates, X brakes
* or reverses, B is the handbrake, the D-pad steers like the arrow keys, the
* left stick steers by how far it is pushed (steerAxis) and Menu quits the
* race. The buttons are on/off like keys.
*/
const PAD_BINDINGS: GamepadBindings = {
left: [{ button: 14 }],
right: [{ button: 15 }],
up: [{ button: 12 }],
down: [{ button: 13 }],
// One signed axis each: the helper's `down` ignores the sign, so the
// directions are read from `value`.
stickX: [{ axis: 0 }],
stickY: [{ axis: 1 }],
accelerate: [{ button: 0 }],
brake: [{ button: 2 }],
handbrake: [{ button: 1 }],
menu: [{ button: 9 }],
};
const PAD_KEYS: Record<string, DirectorKey> = { left: 123, right: 124, accelerate: 126, brake: 125, handbrake: ' ', menu: 53 };
const PAD_MENU: Record<string, MenuIntent> = { up: 'up', down: 'down', left: 'left', right: 'right', accelerate: 'select', handbrake: 'back', menu: 'start' };
const DIRECTIONS = ['up', 'down', 'left', 'right'] as const;
/** How far the stick must lean to move the menu highlight. */
const STICK_MENU = 0.5;
/** Held-direction repeat in menus. */
const REPEAT_DELAY_MS = 400;
const REPEAT_MS = 150;
export interface PadPoll {
intents: MenuIntent[];
frame?: ControllerFrame;
/** A controller is connected but the frame is not focused, so its input is withheld. */
waiting: boolean;
}
const CODE_TO_KEY: Record<string, DirectorKey> = {
ArrowLeft: 123,
ArrowRight: 124,
ArrowDown: 125,
ArrowUp: 126,
KeyA: 123,
KeyD: 124,
KeyS: 125,
KeyW: 126,
Escape: 53,
Space: ' ',
KeyM: 's', // sound toggle; "s" is taken by WASD steering
Digit1: '1',
};
export class Input {
private readonly held = new Set<DirectorKey>();
/** Virtual (touch/gamepad) sources merged in each poll. */
private readonly virtual = new Set<DirectorKey>();
/** Short pulses from shell-triggered NAP-KEYS actions. */
private readonly actions = new Set<DirectorKey>();
private readonly actionTimers = new Map<DirectorKey, ReturnType<typeof setTimeout>>();
/** Keys held on a controller during a race, rebuilt each poll. */
private readonly pad = new Set<DirectorKey>();
private gamepad: ReturnType<typeof createGamepadInput> | null | undefined;
/** When each held menu direction next repeats. */
private readonly repeatAt = new Map<MenuIntent, number>();
/** Menu directions held at the last poll (the stick's edges are made here). */
private dirsDown = new Set<string>();
/** After clear(): directions already held give no press. */
private dirsSuppressed = true;
/** The left stick's lean during a race, -1 (full left) to 1 (full right), past the dead zone. */
private stick = 0;
/** Analog steering from a controller stick: -1 full left … 1 full right; 0 without one. */
steerAxis(): number {
return this.stick;
}
keyPressed(key: DirectorKey): boolean {
return this.held.has(key) || this.virtual.has(key) || this.actions.has(key) || this.pad.has(key);
}
/**
* Read the controllers once per animation frame. In a race their buttons
* become held keys; elsewhere their presses come back as menu intents.
*/
pollGamepad(racing: boolean, now = performance.now(), separate = false): PadPoll {
this.pad.clear();
this.stick = 0;
if (this.gamepad === undefined) {
this.gamepad = typeof navigator !== 'undefined' && typeof navigator.getGamepads === 'function'
? createGamepadInput(PAD_BINDINGS, { deadZone: 0.25, buttonThreshold: 0.5 })
: null;
}
if (!this.gamepad) return { intents: [], waiting: false, frame: { status: 'unavailable', players: [], connected: [], disconnected: [] } };
const frame = this.gamepad.poll();
for (const index of frame.connected) {
const controller = frame.players.find((c) => c.index === index);
console.log('[controllers] connected', { index, id: controller?.id, mapping: controller?.mapping, mapped: controller?.mapped });
}
for (const index of frame.disconnected) console.log('[controllers] disconnected', { index, status: frame.status });
const intents: MenuIntent[] = [];
if (frame.status === 'inactive') {
this.repeatAt.clear();
this.dirsSuppressed = true;
return { intents, waiting: frame.players.length > 0, frame };
}
if (separate) return { intents, waiting: false, frame };
const down = new Set<string>();
const pressed = new Set<string>();
let stick = 0;
for (const player of frame.players) {
for (const [name, action] of Object.entries(player.actions)) {
if (name === 'stickX' || name === 'stickY') continue;
if (action.down) down.add(name);
if (action.pressed) pressed.add(name);
}
const { stickX, stickY } = player.actions;
// The strongest lean of any controller steers.
if (Math.abs(stickX.value) > Math.abs(stick)) stick = stickX.value;
if (stickX.value <= -STICK_MENU) down.add('left');
if (stickX.value >= STICK_MENU) down.add('right');
if (stickY.value <= -STICK_MENU) down.add('up');
if (stickY.value >= STICK_MENU) down.add('down');
}
// Direction edges, from the D-pad and the stick alike.
for (const dir of DIRECTIONS) {
if (down.has(dir) && !this.dirsDown.has(dir) && !this.dirsSuppressed) pressed.add(dir);
else pressed.delete(dir);
}
this.dirsDown = new Set(DIRECTIONS.filter((d) => down.has(d)));
this.dirsSuppressed = false;
if (racing) {
this.stick = stick;
// Buttons only: the D-pad steers like the arrow keys, and the stick's
// lean goes to steerAxis rather than to 123/124.
for (const player of frame.players) {
for (const [name, action] of Object.entries(player.actions)) {
const key = PAD_KEYS[name];
if (action.down && key !== undefined) this.pad.add(key);
}
}
this.repeatAt.clear();
return { intents, waiting: false, frame };
}
for (const name of pressed) {
const intent = PAD_MENU[name];
if (intent) intents.push(intent);
}
for (const dir of DIRECTIONS) {
if (!down.has(dir)) this.repeatAt.delete(dir);
else if (pressed.has(dir)) this.repeatAt.set(dir, now + REPEAT_DELAY_MS);
else {
const at = this.repeatAt.get(dir);
if (at === undefined) this.repeatAt.set(dir, now + REPEAT_DELAY_MS);
else if (now >= at) {
intents.push(dir);
this.repeatAt.set(dir, now + REPEAT_MS);
}
}
}
return { intents, waiting: false, frame };
}
/** A local player's snapshot; this never polls the browser a second time. */
applyController(controller: Controller): void {
this.pad.clear();
this.stick = controller.actions.stickX?.value ?? 0;
for (const [name, action] of Object.entries(controller.actions)) {
const key = PAD_KEYS[name];
if (name !== 'menu' && action.down && key !== undefined) this.pad.add(key);
}
}
setVirtual(key: DirectorKey, down: boolean): void {
if (down) this.virtual.add(key);
else this.virtual.delete(key);
}
/** Turn a shell-triggered action into a brief control pulse. Key repeat extends it. */
pulseAction(key: DirectorKey, durationMs = 160): void {
const previous = this.actionTimers.get(key);
if (previous !== undefined) clearTimeout(previous);
this.actions.add(key);
this.actionTimers.set(
key,
setTimeout(() => {
this.actions.delete(key);
this.actionTimers.delete(key);
}, durationMs),
);
}
clear(): void {
this.held.clear();
this.virtual.clear();
this.actions.clear();
this.pad.clear();
this.stick = 0;
this.repeatAt.clear();
this.dirsSuppressed = true;
this.gamepad?.reset();
for (const timer of this.actionTimers.values()) clearTimeout(timer);
this.actionTimers.clear();
}
attach(target: Window): () => void {
const down = (e: KeyboardEvent) => {
// Typing in a text field (the pack link) is not driving.
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
const key = CODE_TO_KEY[e.code];
if (key === undefined) return;
this.held.add(key);
if (e.code.startsWith('Arrow') || e.code === 'Space') e.preventDefault();
};
const up = (e: KeyboardEvent) => {
const key = CODE_TO_KEY[e.code];
if (key !== undefined) this.held.delete(key);
};
const blur = () => this.clear();
target.addEventListener('keydown', down);
target.addEventListener('keyup', up);
target.addEventListener('blur', blur);
return () => {
target.removeEventListener('keydown', down);
target.removeEventListener('keyup', up);
target.removeEventListener('blur', blur);
};
}
}
