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.
// 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();
}
}
