Back to Pursuit PIT
SOURCE / PINNED RELEASE

Made of little things.

Pursuit PIT

Release
565f4981bdc3…
Author-recorded commit
197afdbed44b…
License
LICENSE
Author’s source reference
nostr://npub12lm3rxsva8kfempyjkxupt09e02n3fezurkyadyqfejdc5e0r2kscqty6r/wss%3A%2F%2Fgit.napplet.soy%2F/n-951414e7fd7

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

src/garage-menu.ts
// The garage screen's panel: pick the patrol car's or the getaway car's options. The car
// itself is drawn by the renderer (Renderer.renderGarage) behind the panel.
import { BODIES, LIGHT_BARS, LIVERIES, PAINTS, WHEELS, type GarageSpec } from './game/garage.js';

export type GarageCar = 'police' | 'getaway';

interface Row {
  label: string;
  count: number;
  get(): number;
  set(i: number): void;
  name(i: number): string;
  chip?(i: number): string; // a colour swatch shown with the value
  swatches?: boolean; // a grid of colours instead of arrows
}

const hex = (c: number) => `#${c.toString(16).padStart(6, '0')}`;

export class GarageMenu {
  which: GarageCar = 'police';
  private row = 0;
  private rowsEl: HTMLElement;
  private noteEl: HTMLElement;
  private tabs: Record<GarageCar, HTMLButtonElement>;

  constructor(
    private root: HTMLElement,
    private spec: GarageSpec,
    private changed: (spec: GarageSpec) => void,
    private done: () => void,
  ) {
    this.rowsEl = root.querySelector('#garageRows')!;
    this.noteEl = root.querySelector('#garageNote')!;
    this.tabs = { police: root.querySelector('#tabPolice')!, getaway: root.querySelector('#tabGetaway')! };
    for (const which of ['police', 'getaway'] as const) this.tabs[which].addEventListener('click', () => this.show(which));
    root.querySelector('#garageDone')!.addEventListener('click', () => this.done());
  }

  open(): void {
    this.root.hidden = false;
    this.show(this.which);
    this.tabs[this.which].focus({ preventScroll: true });
  }

  close(): void {
    this.root.hidden = true;
  }

  private rows(): Row[] {
    const p = this.spec.police, g = this.spec.getaway;
    if (this.which === 'police') {
      return [
        { label: 'Livery', count: LIVERIES.length, get: () => p.livery, set: (i) => (p.livery = i), name: (i) => LIVERIES[i].name, chip: (i) => hex(LIVERIES[i].body) },
        { label: 'Light bar', count: LIGHT_BARS.length, get: () => p.lights, set: (i) => (p.lights = i), name: (i) => LIGHT_BARS[i].name,
          chip: (i) => `linear-gradient(90deg, ${hex(LIGHT_BARS[i].a)} 50%, ${hex(LIGHT_BARS[i].b)} 50%)` },
        { label: 'Wheels', count: WHEELS.length, get: () => p.wheels, set: (i) => (p.wheels = i), name: (i) => WHEELS[i].name, chip: (i) => hex(WHEELS[i].color) },
        { label: 'Push bar', count: 2, get: () => (p.pushBar ? 1 : 0), set: (i) => (p.pushBar = i === 1), name: (i) => (i ? 'Fitted' : 'Off') },
      ];
    }
    return [
      { label: 'Body', count: BODIES.length, get: () => g.body, set: (i) => (g.body = i), name: (i) => BODIES[i].name },
      { label: 'Paint', count: PAINTS.length, get: () => g.paint, set: (i) => (g.paint = i), name: (i) => PAINTS[i].name, swatches: true },
      { label: 'Wheels', count: WHEELS.length, get: () => g.wheels, set: (i) => (g.wheels = i), name: (i) => WHEELS[i].name, chip: (i) => hex(WHEELS[i].color) },
    ];
  }

  private show(which: GarageCar): void {
    if (which !== this.which) this.row = 0;
    this.which = which;
    for (const w of ['police', 'getaway'] as const) this.tabs[w].setAttribute('aria-selected', String(w === which));
    this.draw();
  }

  private pick(row: Row, i: number): void {
    const next = ((i % row.count) + row.count) % row.count;
    // Confirming an already-selected swatch should retain focus and avoid a redundant save.
    if (next === row.get()) return;
    row.set(next);
    this.changed(this.spec);
    this.draw();
  }

  private draw(): void {
    const rows = this.rows();
    this.row = Math.min(this.row, rows.length - 1);
    this.rowsEl.replaceChildren(...rows.map((row, r) => {
      const el = document.createElement('div');
      el.className = 'garage-row' + (r === this.row ? ' current' : '');
      el.addEventListener('pointerdown', () => {
        this.row = r;
        for (const [k, other] of [...this.rowsEl.children].entries()) other.classList.toggle('current', k === r);
      });
      const label = document.createElement('span');
      label.className = 'garage-label';
      label.textContent = row.label;
      el.append(label);
      if (row.swatches) {
        const grid = document.createElement('div');
        grid.className = 'swatches';
        for (let i = 0; i < row.count; i++) {
          const b = document.createElement('button');
          b.type = 'button';
          b.title = row.name(i);
          b.setAttribute('aria-label', row.name(i));
          b.setAttribute('aria-pressed', String(i === row.get()));
          b.style.background = hex(PAINTS[i].color);
          b.addEventListener('click', () => this.pick(row, i));
          grid.append(b);
        }
        el.append(grid);
        return el;
      }
      const arrow = (dir: number, text: string) => {
        const b = document.createElement('button');
        b.type = 'button';
        b.className = 'arrow';
        b.textContent = text;
        b.setAttribute('aria-label', `${dir < 0 ? 'Previous' : 'Next'} ${row.label.toLowerCase()}`);
        b.addEventListener('click', () => this.pick(row, row.get() + dir));
        return b;
      };
      const value = document.createElement('span');
      value.className = 'garage-value';
      if (row.chip) {
        const chip = document.createElement('i');
        chip.style.background = row.chip(row.get());
        value.append(chip);
      }
      value.append(row.name(row.get()));
      el.append(arrow(-1, '◀'), value, arrow(1, '▶'));
      return el;
    }));
    this.noteEl.textContent = this.which === 'police'
      ? 'Your patrol car in Pursuit and Free drive. In a Getaway, the police chasing you drive it.'
      : BODIES[this.spec.getaway.body].note;
  }

  /** Semantic navigation shared by keyboard and controller. */
  navigate(direction: 'up' | 'down' | 'left' | 'right'): void {
    const rows = this.rows();
    if (direction === 'up' || direction === 'down') {
      this.row = (this.row + (direction === 'up' ? rows.length - 1 : 1)) % rows.length;
      this.draw();
    } else {
      const row = rows[this.row];
      this.pick(row, row.get() + (direction === 'left' ? -1 : 1));
    }
    const current = this.rowsEl.querySelector('.current');
    const selected = current?.querySelector<HTMLButtonElement>('button[aria-pressed="true"]')
      ?? current?.querySelector<HTMLButtonElement>('button');
    selected?.focus({ preventScroll: true });
    current?.scrollIntoView({ block: 'nearest' });
  }

  switchCar(): void {
    this.show(this.which === 'police' ? 'getaway' : 'police');
  }

  confirm(): void {
    const focused = document.activeElement;
    if (focused instanceof HTMLButtonElement && this.root.contains(focused)) focused.click();
  }

  /** Keyboard: ↑↓ pick a row, ←→ change it, Tab swaps cars, Enter or Esc leaves. */
  key(event: KeyboardEvent): void {
    switch (event.code) {
      case 'ArrowUp': case 'KeyW': this.navigate('up'); break;
      case 'ArrowDown': case 'KeyS': this.navigate('down'); break;
      case 'ArrowLeft': case 'KeyA': this.navigate('left'); break;
      case 'ArrowRight': case 'KeyD': this.navigate('right'); break;
      case 'Tab': case 'KeyQ': case 'KeyE': this.switchCar(); break;
      case 'Escape': case 'Enter':
        if (event.code === 'Enter' && document.activeElement instanceof HTMLButtonElement && this.root.contains(document.activeElement)) return;
        this.done();
        break;
      default: return;
    }
    event.preventDefault();
  }
}