SOURCE / PINNED RELEASE
Made of little things.
Sickman Survival
- Release
- 7335c9fdf0a8…
- Author-recorded commit
- cbd2eeafa70d…
- License
- LICENSE
- Author’s source reference
- nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.napplet.soy%2F/n-8804151df7f
Archive hash verified: 591bab4addcbe5fc…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import { NO_CONTROLS, type Controls } from './game.ts';
import { createGamepadInput, type ControllerFrame, type GamepadBindings } from './gamepad.ts';
import type { InputSource } from './roster.ts';
type Action = 'left' | 'right' | 'jump' | 'down' | 'crouch';
interface RumbleActuator {
playEffect?(type: 'dual-rumble', params: {
duration: number;
strongMagnitude: number;
weakMagnitude: number;
}): Promise<unknown> | undefined;
}
const KEYS: Record<string, Action> = {
ArrowLeft: 'left',
KeyA: 'left',
ArrowRight: 'right',
KeyD: 'right',
ArrowUp: 'jump',
KeyW: 'jump',
Space: 'jump',
ArrowDown: 'down',
KeyS: 'down',
KeyC: 'crouch',
ShiftLeft: 'crouch',
ShiftRight: 'crouch',
};
const PAD_BINDINGS: GamepadBindings = {
moveX: [{ axis: 0 }, { button: 14, scale: -1 }, { button: 15 }],
moveY: [{ axis: 1 }, { button: 13 }],
jump: [{ button: 0 }, { button: 1 }, { button: 12 }],
crouch: [{ button: 2 }, { button: 6 }],
start: [{ button: 9 }],
select: [{ button: 8 }],
};
interface PadState {
left: boolean;
right: boolean;
jump: boolean;
down: boolean;
crouch: boolean;
jumpQueued: boolean;
}
/**
* Keyboard, multi-touch pads and every gamepad, read either merged (`'solo'`)
* or one gamepad at a time for split-screen players.
*/
export class Input {
private readonly keys = new Set<Action>();
private readonly touches = new Map<number, Action>();
private readonly pads = new Map<number, PadState>();
private readonly gamepads = createGamepadInput(PAD_BINDINGS, {
deadZone: 0.25,
// Hosts rarely focus the frame before a pad is used; only a hidden page silences pads.
active: () => !document.hidden,
});
private jumpQueued = false;
private startQueued = false;
private selectQueued = false;
private anyWasDown = false;
private activePad = -1;
/** Whether any gamepad is connected, as of the last poll. */
padConnected = false;
/** Called on the first button press of a gamepad gesture (audio unlock, hints). */
onGamepadPress: (() => void) | null = null;
/** Set when any touch input is seen, to reveal the touch pads in "auto" mode. */
onTouchSeen: (() => void) | null = null;
attachKeyboard(target: Window): void {
target.addEventListener('keydown', (event) => {
const action = KEYS[event.code];
if (!action) return;
event.preventDefault();
if (action === 'jump' && !event.repeat && !this.keys.has('jump')) this.jumpQueued = true;
this.keys.add(action);
});
target.addEventListener('keyup', (event) => {
const action = KEYS[event.code];
if (action) this.keys.delete(action);
});
target.addEventListener('blur', () => this.reset());
}
/** Buttons carry `data-action`; each pointer holds its own button. */
attachTouchPad(root: HTMLElement): void {
const release = (event: PointerEvent): void => {
this.touches.delete(event.pointerId);
};
for (const button of root.querySelectorAll<HTMLElement>('[data-action]')) {
const action = button.dataset.action as Action;
button.addEventListener('pointerdown', (event) => {
event.preventDefault();
button.setPointerCapture(event.pointerId);
if (action === 'jump' && !this.isHeld('jump')) this.jumpQueued = true;
this.touches.set(event.pointerId, action);
});
button.addEventListener('pointerup', release);
button.addEventListener('pointercancel', release);
button.addEventListener('lostpointercapture', release);
button.addEventListener('contextmenu', (event) => event.preventDefault());
}
window.addEventListener('pointerdown', (event) => {
if (event.pointerType === 'touch') this.onTouchSeen?.();
}, { capture: true });
}
/**
* Poll every gamepad once per frame: stick or D-pad runs, A/B or D-pad up jumps,
* stick/D-pad down drops through planks, holding X or the left trigger crouches, Start pauses, starts and restarts, Select/Back toggles sound.
* Hosts may withhold the gamepad permission from the frame; that never stops the game loop.
*/
poll(): ControllerFrame {
const frame = this.gamepads.poll();
// Any pad that plugs in plays, whatever its mapping: read it in the standard layout.
for (const controller of frame.players) {
if (!controller.mapped && frame.connected.includes(controller.index)) {
this.gamepads.remap(controller.index, PAD_BINDINGS);
}
}
for (const index of frame.disconnected) this.pads.delete(index);
let any = false;
for (const c of frame.players) {
const a = c.actions;
const state = this.pads.get(c.index) ?? { left: false, right: false, jump: false, down: false, crouch: false, jumpQueued: false };
const x = a.moveX?.value ?? 0;
const y = a.moveY?.value ?? 0;
state.left = x < -0.2;
state.right = x > 0.2;
state.down = y > 0.45;
state.jump = Boolean(a.jump?.down);
state.crouch = Boolean(a.crouch?.down);
if (a.jump?.pressed) {
state.jumpQueued = true;
this.jumpQueued = true;
}
if (a.start?.pressed) this.startQueued = true;
if (a.select?.pressed) this.selectQueued = true;
this.pads.set(c.index, state);
if (c.buttons.some((b) => b >= 0.5)) {
any = true;
this.activePad = c.index;
}
}
if (any && !this.anyWasDown) this.onGamepadPress?.();
this.anyWasDown = any;
this.padConnected = frame.players.length > 0;
return frame;
}
/** True once per press of Start. */
takeStart(): boolean {
const queued = this.startQueued;
this.startQueued = false;
return queued;
}
/** True once per press of Select/Back. */
takeSelect(): boolean {
const queued = this.selectQueued;
this.selectQueued = false;
return queued;
}
/** Rumble one source's pad, where the browser supports it. `strength` is 0–1. */
rumble(source: InputSource, strength: number, ms: number): void {
const index = source === 'solo' ? this.activePad : source;
if (strength <= 0.02 || index < 0) return;
try {
const gp = navigator.getGamepads()[index];
const actuator = (gp as (Gamepad & { vibrationActuator?: RumbleActuator }) | null)?.vibrationActuator;
void actuator?.playEffect?.('dual-rumble', {
duration: ms,
strongMagnitude: Math.min(1, strength),
weakMagnitude: Math.min(1, strength * 0.7 + 0.1),
})?.catch(() => {});
} catch {
// No rumble here; nothing to do.
}
}
private isHeld(action: Action): boolean {
if (this.keys.has(action)) return true;
for (const pad of this.pads.values()) if (pad[action]) return true;
for (const held of this.touches.values()) if (held === action) return true;
return false;
}
/** Controls for one source; its jump edge is consumed by this call. */
controlsFor(source: InputSource): Controls {
if (source === 'solo') {
const controls: Controls = {
left: this.isHeld('left'),
right: this.isHeld('right'),
jump: this.isHeld('jump'),
down: this.isHeld('down'),
crouch: this.isHeld('crouch'),
jumpPressed: this.jumpQueued,
};
this.jumpQueued = false;
for (const pad of this.pads.values()) pad.jumpQueued = false;
return controls;
}
const pad = this.pads.get(source);
if (!pad) return { ...NO_CONTROLS };
const controls: Controls = { left: pad.left, right: pad.right, jump: pad.jump, down: pad.down, crouch: pad.crouch, jumpPressed: pad.jumpQueued };
pad.jumpQueued = false;
return controls;
}
/** True once per jump press from any keyboard, touch pad or gamepad (starting a run, resuming). */
takeAnyJump(): boolean {
let queued = this.jumpQueued;
this.jumpQueued = false;
for (const pad of this.pads.values()) {
queued ||= pad.jumpQueued;
pad.jumpQueued = false;
}
return queued;
}
/** Drop all held input, e.g. on focus loss or pause. */
reset(): void {
this.keys.clear();
this.touches.clear();
this.jumpQueued = false;
this.startQueued = false;
this.selectQueued = false;
for (const pad of this.pads.values()) pad.jumpQueued = false;
}
}
