SOURCE / PINNED RELEASE
Made of little things.
Poly Dogfight
- Release
- b8511ee374e3…
- Author-recorded commit
- 5579434b9d8b…
- License
- LICENSE
- Author’s source reference
- nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.napplet.soy%2F/n-44f63e5422b
Archive hash verified: bcadd2430d8ea2bc…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
/**
* One input layer for keyboard, gamepad and touch. Poll once per animation
* frame; edges (camera, pause, ...) are reported once.
*/
import { createGamepadInput } from './gamepad.ts';
import { neutralInput, type ControlInput } from './sim/flight.ts';
import { clamp } from './sim/noise.ts';
/** One gamepad's controls this frame. */
export type PadInput = {
index: number;
control: ControlInput;
camera: boolean;
pause: boolean;
accept: boolean;
decline: boolean;
mode: boolean;
};
export type FrameInput = {
/** Keyboard and touch, which always fly pilot 1. */
local: ControlInput;
localCamera: boolean;
pads: PadInput[];
/** Controllers that are connected and mapped: each one is a pilot. */
activePads: number[];
pause: boolean;
mute: boolean;
/** Any button/key/tap this frame — used to start or resume. */
any: boolean;
/** A gamepad button was pressed this frame. */
padAny: boolean;
};
/** Strongest signal wins per axis; boost if either asks. */
export function mergeControls(a: ControlInput, b: ControlInput): ControlInput {
const pick = (x: number, y: number) => (Math.abs(y) > Math.abs(x) ? y : x);
return {
pitch: pick(a.pitch, b.pitch),
roll: pick(a.roll, b.roll),
yaw: pick(a.yaw, b.yaw),
throttleDelta: pick(a.throttleDelta, b.throttleDelta),
throttleSet: a.throttleSet ?? b.throttleSet,
boost: a.boost || b.boost,
fire: a.fire || b.fire,
rocket: a.rocket || b.rocket,
};
}
export type TouchElements = {
stick: HTMLElement;
stickKnob: HTMLElement;
throttle: HTMLElement;
boost: HTMLElement;
yawLeft: HTMLElement;
yawRight: HTMLElement;
fire: HTMLElement;
rocket: HTMLElement;
};
const FLIGHT_KEYS = new Set([
'KeyW', 'KeyA', 'KeyS', 'KeyD', 'KeyQ', 'KeyE', 'KeyR', 'KeyF', 'KeyC', 'KeyP', 'KeyM',
'KeyT', 'KeyG', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space', 'ShiftLeft', 'ShiftRight', 'Escape',
]);
export function createInput(touch: TouchElements) {
const held = new Set<string>();
const edges = new Set<string>();
let anyEdge = false;
// Smoothed digital axes so keyboard flying isn't twitchy.
const smooth = { pitch: 0, roll: 0, yaw: 0 };
const onKeyDown = (event: KeyboardEvent) => {
if (event.ctrlKey || event.metaKey || event.altKey) return;
if (FLIGHT_KEYS.has(event.code)) event.preventDefault();
if (!event.repeat) {
edges.add(event.code);
anyEdge = true;
}
held.add(event.code);
};
const onKeyUp = (event: KeyboardEvent) => held.delete(event.code);
const clearAll = () => {
held.clear();
touchState.stickX = 0;
touchState.stickY = 0;
touchState.boost = false;
touchState.fire = false;
touchState.rocket = false;
touchState.yaw = 0;
touchState.stickPointer = null;
touch.stickKnob.style.transform = '';
};
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
window.addEventListener('blur', clearAll);
document.addEventListener('visibilitychange', clearAll);
// --- Touch -----------------------------------------------------------------
const capture = (element: HTMLElement, pointerId: number) => {
try {
element.setPointerCapture(pointerId);
} catch {
// Synthetic or already-released pointers cannot be captured; input still works.
}
};
const touchState = {
stickPointer: null as number | null,
stickX: 0,
stickY: 0,
throttle: null as number | null,
boost: false,
fire: false,
rocket: false,
yaw: 0,
};
const stickMove = (event: PointerEvent) => {
if (event.pointerId !== touchState.stickPointer) return;
const rect = touch.stick.getBoundingClientRect();
const radius = rect.width / 2;
let dx = (event.clientX - (rect.left + radius)) / radius;
let dy = (event.clientY - (rect.top + radius)) / radius;
const len = Math.hypot(dx, dy);
if (len > 1) {
dx /= len;
dy /= len;
}
touchState.stickX = dx;
touchState.stickY = dy;
touch.stickKnob.style.transform = `translate(${dx * radius * 0.6}px, ${dy * radius * 0.6}px)`;
};
const stickEnd = (event: PointerEvent) => {
if (event.pointerId !== touchState.stickPointer) return;
touchState.stickPointer = null;
touchState.stickX = 0;
touchState.stickY = 0;
touch.stickKnob.style.transform = '';
};
touch.stick.addEventListener('pointerdown', (event) => {
event.preventDefault();
capture(touch.stick, event.pointerId);
touchState.stickPointer = event.pointerId;
anyEdge = true;
stickMove(event);
});
touch.stick.addEventListener('pointermove', stickMove);
touch.stick.addEventListener('pointerup', stickEnd);
touch.stick.addEventListener('pointercancel', stickEnd);
let throttlePointer: number | null = null;
const throttleMove = (event: PointerEvent) => {
if (event.pointerId !== throttlePointer) return;
const rect = touch.throttle.getBoundingClientRect();
touchState.throttle = clamp(1 - (event.clientY - rect.top) / rect.height, 0, 1);
};
touch.throttle.addEventListener('pointerdown', (event) => {
event.preventDefault();
capture(touch.throttle, event.pointerId);
throttlePointer = event.pointerId;
anyEdge = true;
throttleMove(event);
});
touch.throttle.addEventListener('pointermove', throttleMove);
const throttleEnd = (event: PointerEvent) => {
if (event.pointerId === throttlePointer) throttlePointer = null;
};
touch.throttle.addEventListener('pointerup', throttleEnd);
touch.throttle.addEventListener('pointercancel', throttleEnd);
const hold = (element: HTMLElement, set: (down: boolean) => void) => {
element.addEventListener('pointerdown', (event) => {
event.preventDefault();
capture(element, event.pointerId);
anyEdge = true;
set(true);
});
const release = () => set(false);
element.addEventListener('pointerup', release);
element.addEventListener('pointercancel', release);
element.addEventListener('lostpointercapture', release);
};
hold(touch.boost, (down) => (touchState.boost = down));
hold(touch.fire, (down) => (touchState.fire = down));
hold(touch.rocket, (down) => (touchState.rocket = down));
hold(touch.yawLeft, (down) => (touchState.yaw = down ? -1 : touchState.yaw === -1 ? 0 : touchState.yaw));
hold(touch.yawRight, (down) => (touchState.yaw = down ? 1 : touchState.yaw === 1 ? 0 : touchState.yaw));
// --- Gamepad ---------------------------------------------------------------
const gamepad = createGamepadInput(
{
roll: [{ axis: 0 }, { button: 14, scale: -1 }, { button: 15 }],
// Flight convention: stick back (down) = nose up.
pitch: [{ axis: 1 }, { button: 12, scale: -1 }, { button: 13 }],
// Triggers are the rudder pedals: LT left, RT right (right stick X too).
yaw: [{ button: 6, scale: -1 }, { button: 7 }, { axis: 2 }],
throttleUp: [{ button: 5 }],
throttleDown: [{ button: 4 }],
// Face buttons: A boost, B guns, Y rockets, X camera.
boost: [{ button: 0 }],
fire: [{ button: 1 }],
rocket: [{ button: 3 }],
camera: [{ button: 2 }],
pause: [{ button: 9 }],
accept: [{ button: 0 }],
decline: [{ button: 1 }],
mode: [{ button: 4 }],
},
{ deadZone: 0.15 },
);
const key = (...codes: string[]) => (codes.some((c) => held.has(c)) ? 1 : 0);
const approach = (current: number, target: number, rate: number, dt: number) => {
const step = rate * dt;
return Math.abs(target - current) <= step ? target : current + Math.sign(target - current) * step;
};
const finish = (control: ControlInput) => {
control.pitch = clamp(control.pitch, -1, 1);
control.roll = clamp(control.roll, -1, 1);
control.yaw = clamp(control.yaw, -1, 1);
return control;
};
return {
poll(dt: number): FrameInput {
const local = neutralInput();
const frame = gamepad.poll();
// Keyboard (smoothed).
const kPitch = key('KeyS', 'ArrowDown') - key('KeyW', 'ArrowUp');
const kRoll = key('KeyD', 'ArrowRight') - key('KeyA', 'ArrowLeft');
const kYaw = key('KeyE') - key('KeyQ');
smooth.pitch = approach(smooth.pitch, kPitch, kPitch ? 3.5 : 6, dt);
smooth.roll = approach(smooth.roll, kRoll, kRoll ? 4 : 8, dt);
smooth.yaw = approach(smooth.yaw, kYaw, 4, dt);
local.pitch = smooth.pitch;
local.roll = smooth.roll;
local.yaw = smooth.yaw;
local.throttleDelta = key('KeyT', 'ShiftLeft', 'ShiftRight') - key('KeyG');
local.boost = held.has('Space');
local.fire = held.has('KeyF') || held.has('Enter');
local.rocket = held.has('KeyR');
// Touch.
const pick = (a: number, b: number) => (Math.abs(b) > Math.abs(a) ? b : a);
local.pitch = pick(local.pitch, touchState.stickY);
local.roll = pick(local.roll, touchState.stickX);
local.yaw = pick(local.yaw, touchState.yaw);
local.boost ||= touchState.boost;
local.fire ||= touchState.fire;
local.rocket ||= touchState.rocket;
if (touchState.throttle !== null) {
local.throttleSet = touchState.throttle;
touchState.throttle = null;
}
let pause = edges.has('Escape') || edges.has('KeyP');
const mute = edges.has('KeyM');
let any = anyEdge;
let padAny = false;
const pads: PadInput[] = [];
for (const pad of frame.players) {
if (!pad.mapped) continue;
const a = pad.actions;
const control = neutralInput();
control.pitch = a.pitch?.value ?? 0;
control.roll = a.roll?.value ?? 0;
control.yaw = a.yaw?.value ?? 0;
control.throttleDelta = (a.throttleUp?.value ?? 0) - (a.throttleDown?.value ?? 0);
control.boost = !!a.boost?.down;
control.fire = !!a.fire?.down;
control.rocket = !!a.rocket?.down;
const camera = !!a.camera?.pressed;
const padPause = !!a.pause?.pressed;
pause ||= padPause;
padAny ||= Object.values(a).some(action => action.pressed);
pads.push({ index: pad.index, control: finish(control), camera, pause: padPause, accept: !!a.accept?.pressed, decline: !!a.decline?.pressed, mode: !!a.mode?.pressed });
}
any ||= padAny;
const localCamera = edges.has('KeyC');
edges.clear();
anyEdge = false;
const activePads = pads.map((pad) => pad.index);
return { local: finish(local), localCamera, pads, activePads, pause, mute, any, padAny };
},
clear: clearAll,
dispose() {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
window.removeEventListener('blur', clearAll);
document.removeEventListener('visibilitychange', clearAll);
gamepad.dispose();
},
};
}
