Back to Pickle Rustlers
SOURCE / PINNED RELEASE

Made of little things.

Pickle Rustlers

Release
039e51f55e34…
Author-recorded commit
40608586a8a1…
License
LICENSE
Author’s source reference
nostr://npub12lm3rxsva8kfempyjkxupt09e02n3fezurkyadyqfejdc5e0r2kscqty6r/wss%3A%2F%2Fgit.napplet.soy%2F/n-bad44e7ab35

Archive hash verified: f38e99afa0992052…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/game/gamepad-source.ts
import { gamepad, type GamepadApi, type GamepadState } from '@napplet/sdk';
import { runtimeHasDomain } from '../domain-availability.js';
import type { GamepadSample } from './gamepad.js';

interface ControllerSource {
  read: () => ArrayLike<GamepadSample | null>;
  active: () => boolean;
  dispose(): void;
}

/**
 * Hosts without NAP-GAMEPAD (soyLI 0.31 included) still let the frame read the
 * browser Gamepad API, which soyLI's controller guide recommends. Only an absent
 * domain falls back here; a host that injects the domain but denies it stays denied.
 */
function createNativeGamepadSource(): ControllerSource {
  return {
    read: () => {
      if (typeof navigator === 'undefined' || !navigator.getGamepads) throw new Error('Gamepad API unavailable');
      return navigator.getGamepads();
    },
    active: () => typeof document !== 'undefined' && !document.hidden && document.hasFocus(),
    dispose() {},
  };
}

/** Cache shell snapshots; rendering never sends controller requests. */
export function createShellGamepadSource(
  reset: () => void,
  supported = runtimeHasDomain('gamepad'),
  api: GamepadApi = gamepad,
): ControllerSource {
  if (!supported) return createNativeGamepadSource();
  let state: GamepadState = { available: false, focused: false, pads: [] };
  let subscription: { close(): void } | undefined;
  let closed = false;
  const update = (next: GamepadState) => {
    if (closed) return;
    const before = new Map(state.pads.filter(pad => pad !== null).map(pad => [pad.index, pad]));
    const after = new Map(next.pads.filter(pad => pad !== null).map(pad => [pad.index, pad]));
    const changed = before.size !== after.size || [...before].some(([index, pad]) =>
      after.get(index)?.id !== pad.id || after.get(index)?.mapping !== pad.mapping);
    // Observe focus loss even when loss and regain happen between render frames.
    if (changed || state.focused !== next.focused || state.available !== next.available) reset();
    state = next;
  };
  try {
    update({ available: api.available, focused: api.focused, pads: api.getGamepads() });
    subscription = api.onChange(update);
  } catch {
    update({ available: false, reason: 'unavailable', focused: false, pads: [] });
  }
  return {
    read: () => {
      if (!state.available) {
        const error = new Error('Controller input unavailable');
        if (state.reason === 'blocked') error.name = 'SecurityError';
        throw error;
      }
      return state.pads;
    },
    active: () => !closed && state.available && state.focused,
    dispose() {
      if (closed) return;
      closed = true;
      state = { available: false, focused: false, pads: [] };
      try { subscription?.close(); } catch { /* The host may already have ended the session. */ }
      reset();
    },
  };
}