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/keyboard.ts
import { keys } from '@napplet/sdk';
import { runtimeHasDomain } from '../domain-availability.js';

export type KeyboardAction = 'shoot' | 'lasso' | 'pause';
const actions = [
  { name: 'shoot', label: 'Shoot / confirm', defaultKey: ' ' },
  { name: 'lasso', label: 'Throw lasso', defaultKey: 'e' },
  { name: 'pause', label: 'Pause / resume', defaultKey: 'p' },
] as const;
const aliases: Record<KeyboardAction, string[]> = {
  shoot: [' ', 'j', 'enter'], lasso: ['e', 'shift', 'k', 'l'], pause: ['p', 'escape'],
};

function editable(target: EventTarget | null): boolean {
  return target instanceof HTMLElement &&
    (target.isContentEditable || Boolean(target.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])')));
}

/** Local held movement plus optional shell-owned discrete actions. */
export class KeyboardInput {
  private held = new Map<string, string>();
  private pressed = new Set<KeyboardAction>();
  private registered = new Set<KeyboardAction>();
  private handles: Array<{ close(): void }> = [];
  private listeners = new AbortController();
  private currentEvent: KeyboardEvent | null = null;
  private disposed = false;

  constructor(private activity: () => void, private allowAction: (action: KeyboardAction) => boolean) {
    const signal = this.listeners.signal;
    // The injected shim runs later at document capture. Remember the event for
    // its parameterless callback, without intercepting forwarding or bindings.
    window.addEventListener('keydown', (event) => {
      this.currentEvent = event;
      // Trusted browser events may run microtasks between listeners. Keep this
      // through document capture, then release it after the dispatch task.
      setTimeout(() => { if (this.currentEvent === event) this.currentEvent = null; }, 0);
    }, { capture: true, signal });
    window.addEventListener('keydown', (event) => {
      try { this.keydown(event); } finally { this.currentEvent = null; }
    }, { signal });
    window.addEventListener('keyup', (event) => this.held.delete(event.code || event.key.toLowerCase()), { signal });
    window.addEventListener('blur', () => this.clear(), { signal });
    document.addEventListener('visibilitychange', () => this.clear(), { signal });
    if (runtimeHasDomain('keys')) void this.register();
  }

  private eligible(event: KeyboardEvent): boolean {
    return !event.isComposing && !editable(event.target);
  }

  private trigger(action: KeyboardAction): void {
    const event = this.currentEvent;
    if (this.disposed || document.hidden || !document.hasFocus() || editable(document.activeElement) ||
      (event && (event.repeat || !this.eligible(event))) || !this.allowAction(action)) return;
    this.pressed.add(action);
    this.activity();
  }

  private async register(): Promise<void> {
    await Promise.all(actions.map(async ({ name, label, defaultKey }) => {
      try {
        const id = `pickle-rustlers.${name}`;
        await keys.registerAction({ id, label, defaultKey });
        let handle: { close(): void };
        try {
          const subscription = keys.onAction(id, () => this.trigger(name));
          handle = { close: () => { try { subscription.close(); } finally { keys.unregisterAction(id); } } };
        } catch (error) {
          keys.unregisterAction(id);
          throw error;
        }
        if (this.disposed) { handle.close(); return; }
        this.handles.push(handle);
        this.registered.add(name);
        this.pressed.delete(name);
      } catch (error) {
        // A missing or denied optional operation must leave local controls usable.
        console.warn(`Keyboard action ${name} unavailable; using local controls`, error);
      }
    }));
  }

  private keydown(event: KeyboardEvent): void {
    if (event.defaultPrevented || !this.eligible(event) || event.ctrlKey || event.altKey || event.metaKey) return;
    const key = event.key.toLowerCase();
    if (['w', 'a', 's', 'd', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright'].includes(key)) {
      this.held.set(event.code || key, key);
      if (key.startsWith('arrow')) event.preventDefault();
      this.activity();
    }
    for (const { name } of actions) {
      if (!this.registered.has(name) && aliases[name].includes(key)) {
        if (key === ' ') event.preventDefault();
        this.trigger(name);
      }
    }
  }

  down(...names: string[]): boolean { return [...this.held.values()].some((key) => names.includes(key)); }
  read(): Set<KeyboardAction> {
    const result = new Set(this.pressed);
    this.pressed.clear();
    return result;
  }
  clear(): void { this.held.clear(); this.pressed.clear(); }
  dispose(): void {
    if (this.disposed) return;
    this.disposed = true;
    this.listeners.abort();
    this.clear();
    for (const handle of this.handles.splice(0)) {
      try { handle.close(); } catch (error) { console.warn('Could not release keyboard action', error); }
    }
    this.registered.clear();
  }
}