Back to Napplet Machines V8
SOURCE / PINNED RELEASE

Made of little things.

Napplet Machines V8

Release
a48ffab2ae1e…
Author-recorded commit
2a817e4d389e…
License
LICENSE
Author’s source reference
nostr://npub182jczunncwe0jn6frpqwq3e0qjws7yqqnc3auccqv9nte2dnd63scjm4rf/wss%3A%2F%2Fgit.napplet.soy%2F/n-b5572362d4a

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

src/main.ts
import './napplet-settings.js';
import { storage } from '@napplet/sdk';
import { runtimeHasDomain } from './domain-availability.js';
import {
  Road,
  presets,
  copyTrack,
  MIN_POINT_WIDTH,
  MAX_POINT_WIDTH,
  validateTrack,
  decodeTrack,
  clamp,
  lerp,
  wrapAngle,
} from './track.js';
import { carPortrait, courseStyle } from './art.js';
import { Renderer } from './render.js';
import { TrackEditor } from './editor.js';
import { ControllerDriving, TouchDriving, mixDriving } from './controls.js';
import { assignControllers, controllerDriving, inputHints, inputMethods, keyboardDriving, localRoster, type InputMethod, type LocalPlayer } from './local-play.js';
import type { Controller } from '../docs/examples/gamepad.js';
import {
  DT,
  cars,
  createWorld,
  advanceBody,
  stepWorld,
  ranking,
  idleInput,
  slipAngle,
  type Body,
  type World,
  type Input,
  type Driver,
} from './physics.js';
import { Multiplayer, errorText, type Start, type Command } from './network.js';
import './styles.css';
const $ = <T extends HTMLElement = HTMLElement>(id: string) =>
  document.getElementById(id) as T;
const button = (id: string, fn: () => void | Promise<void>) =>
  $(id).addEventListener('click', () => {
    void action(fn);
  });
const widthInput = $<HTMLInputElement>('widthInput');
widthInput.min = String(MIN_POINT_WIDTH);
widthInput.max = String(MAX_POINT_WIDTH);
const status = (message: string, error = false) => {
  $('status').textContent = message;
  $('status').classList.toggle('error', error);
};
async function action(fn: () => void | Promise<void>) {
  try {
    await fn();
  } catch (e) {
    status(errorText(e), true);
  }
}
let track = copyTrack(presets[0]),
  road = new Road(track),
  car = 0,
  view = 'garage',
  world: World | null = null,
  localId = 'you',
  paused = false,
  online = false,
  follow = true;
let frameId = 0,
  last = performance.now(),
  accumulator = 0,
  hudAt = 0,
  sendAt = 0,
  sequence = 0,
  lastSnapshot = -1,
  lastPacket = 0,
  anchorTick = 0,
  anchorTime = 0;
let pending: Command[] = [],
  snapshots: { world: World; at: number }[] = [],
  remoteQueues = new Map<string, Command[]>(),
  prediction: Body | null = null;
let rendered: Body[] = [];
const pressed = new Set<string>();
const controllers = new ControllerDriving();
const localPlayers: LocalPlayer[] = [{ id: 'you', name: 'Player 1', car: 0, method: 'wasd', controller: null }];
let padPlayers: Controller[] = [];
const recoverPlayers = new Set<string>();
let touchBoostHeld = false;
let controllerDrive = idleInput(),
  resetRequested = false,
  controllerLost = false;
const multiplayer = new Multiplayer();
let renderer: Renderer;
try {
  renderer = new Renderer($<HTMLCanvasElement>('scene'));
} catch (e) {
  status(errorText(e), true);
  $('raceButton').setAttribute('disabled', '');
  throw e;
}
const editor = new TrackEditor($<HTMLCanvasElement>('plan'), track);
const formatTime = (t: number) =>
  `${Math.floor(t / 60)
    .toString()
    .padStart(2, '0')}:${(t % 60).toFixed(1).padStart(4, '0')}`;
function selectCar(index: number) {
  car = index;
  localPlayers[0].car = index;
  document.querySelectorAll<HTMLElement>('[data-car]').forEach((el) => {
    el.classList.toggle('selected', Number(el.dataset.car) === car);
    el.setAttribute('aria-pressed', String(Number(el.dataset.car) === car));
  });
  const c = cars[car];
  $('carDetail').style.setProperty('--car', c.color);
  $('carDetail').innerHTML =
    `<div class="ride-hero"><span class="ride-number">0${car + 1}</span>${carPortrait(car)}<div class="car-name">${c.name}</div><div class="car-tag">${c.tag}</div></div>${['Acceleration', 'Top speed', 'Grip', 'Weight'].map((s, i) => `<div class="stat"><span>${s}</span><span class="bars" aria-label="${c.stats[i]} out of 5">${Array.from({ length: 5 }, (_, j) => `<i class="${j < c.stats[i] ? 'filled' : ''}"></i>`).join('')}</span></div>`).join('')}`;
  updateLocalSetup();
}
function updateLocalSetup() {
  $('localPlayers').replaceChildren(...localPlayers.map((player, i) => {
    const row = document.createElement('div');
    row.className = 'local-player';
    row.dataset.player = String(i + 1);
    const heading = document.createElement('strong');
    heading.textContent = `P${i + 1}`;
    const label = document.createElement('label');
    label.textContent = 'Controls';
    const select = document.createElement('select');
    select.id = `inputPlayer${i + 1}`;
    select.setAttribute('aria-label', `Player ${i + 1} input method`);
    for (const [method, name] of Object.entries(inputMethods)) {
      const option = new Option(localPlayers.length === 1 && method === 'wasd' ? 'WASD / arrows' : name, method);
      option.disabled = method !== 'controller' && localPlayers.some(p => p !== player && p.method === method);
      select.add(option);
    }
    select.value = player.method;
    select.addEventListener('change', () => {
      player.method = select.value as InputMethod;
      player.controller = null;
      controllers.selected = null;
      controllers.reset();
      updateLocalSetup();
    });
    label.append(select);
    const carLabel = document.createElement('label');
    carLabel.textContent = 'Car';
    const carSelect = document.createElement('select');
    carSelect.id = `carPlayer${i + 1}`;
    carSelect.setAttribute('aria-label', `Player ${i + 1} car`);
    cars.forEach((c, j) => carSelect.add(new Option(c.name, String(j))));
    carSelect.value = String(player.car);
    carSelect.addEventListener('change', () => {
      player.car = Number(carSelect.value);
      if (i === 0) selectCar(player.car);
    });
    carLabel.append(carSelect);
    const hint = document.createElement('small');
    hint.textContent = inputHints[player.method];
    if (player.method === 'controller') {
      hint.textContent = player.controller === null ? 'Click the game, then press A / ✕ on your controller to join.' : `Pad ${player.controller + 1} connected · ${hint.textContent}`;
    }
    row.append(heading, label, carLabel, hint);
    if (i > 0) {
      const remove = document.createElement('button');
      remove.id = 'removePlayer';
      remove.className = 'remove-player';
      remove.textContent = '×';
      remove.setAttribute('aria-label', 'Remove player 2');
      remove.addEventListener('click', () => { localPlayers.splice(i, 1); controllers.selected = localPlayers[0].controller; updateLocalSetup(); });
      row.append(remove);
    }
    return row;
  }));
  $<HTMLButtonElement>('addPlayer').disabled = localPlayers.length >= 2;
  $('raceButton').innerHTML = localPlayers.length > 1 ? 'Race together <span>↗</span>' : 'Race with AI <span>↗</span>';
}
button('addPlayer', () => {
  if (localPlayers.length >= 2) return;
  localPlayers.push({ id: 'local-2', name: 'Player 2', car: (car + 1) % cars.length, method: localPlayers[0].method === 'arrows' ? 'wasd' : 'arrows', controller: null });
  updateLocalSetup();
});
cars.forEach((c, i) => {
  const b = document.createElement('button');
  b.dataset.car = String(i);
  b.style.setProperty('--car', c.color);
  b.innerHTML = '<i class="car-icon"></i>';
  b.setAttribute('aria-label', `Choose ${c.name}`);
  b.title = c.name;
  b.onclick = () => selectCar(i);
  $('carPicker').append(b);
});
selectCar(0);
presets.forEach((p, i) =>
  $<HTMLSelectElement>('trackSelect').add(new Option(p.name, String(i))),
);
function updateRoad() {
  road = new Road(track);
  renderer.setRoad(road);
  editor.track = track;
  editor.road = road;
  $('sceneTitle').textContent = track.name;
  $('sceneSubtitle').textContent = courseStyle(track.name).subtitle;
  $('curveNotice').hidden = road.closedSections.size === 0;
  $('curveNotice').textContent =
    'Tight inside corners are filled and rounded; walls follow the closed edge.';
  $('scene').dataset.closedSections = String(road.closedSections.size);
  $('scene').dataset.roadSamples = String(road.samples.length);
  $('trackLength').textContent = `${Math.round(road.length)} M`;
  $('wallFact').textContent = track.points.every((p) => p.wall)
    ? 'WALLED'
    : 'OPEN EDGES';
  $<HTMLInputElement>('trackName').value = track.name;
  editor.draw();
  updatePoint();
}
function updatePoint() {
  const p = track.points[editor.selected];
  $('pointLabel').textContent = p
    ? `POINT ${String(editor.selected + 1).padStart(2, '0')}`
    : 'SELECT A POINT';
  $('pointCount').textContent = `${track.points.length} / 32`;
  $<HTMLInputElement>('heightInput').disabled = !p;
  $<HTMLInputElement>('widthInput').disabled = !p;
  $<HTMLInputElement>('wallThicknessInput').disabled = !p || !p.wall;
  $('sectionLabel').textContent = p
    ? `SECTION ${editor.selected + 1} → ${((editor.selected + 1) % track.points.length) + 1}`
    : 'SELECT A SECTION';
  $<HTMLInputElement>('segmentWall').disabled = !p;
  $<HTMLButtonElement>('removePoint').disabled = !p || track.points.length <= 4;
  $<HTMLButtonElement>('addPoint').disabled = track.points.length >= 32;
  if (p) {
    $<HTMLInputElement>('heightInput').value = String(p.z);
    $('heightValue').textContent = `Z ${p.z.toFixed(1)} m`;
    $<HTMLInputElement>('segmentWall').checked = p.wall;
    $<HTMLInputElement>('widthInput').value = String(p.width);
    $('widthValue').textContent = `${Number(p.width.toFixed(2))} m`;
    $<HTMLInputElement>('wallThicknessInput').value = String(p.wallThickness);
    $('wallThicknessValue').textContent = `${p.wallThickness.toFixed(1)} m`;
  }
}
let roadDirty = false;
editor.onChange = () => {
  roadDirty = true;
  $('saveState').textContent = 'UNSAVED';
};
editor.onSelect = updatePoint;
function setView(next: string) {
  if (world) return;
  view = next;
  $('app').classList.toggle('editor', next === 'editor');
  document
    .querySelectorAll<HTMLElement>('[data-view]')
    .forEach((b) => b.classList.toggle('active', b.dataset.view === next));
  for (const v of ['garage', 'rooms', 'editor'])
    $(v + 'Panel').hidden = v !== next;
  $('planWrap').hidden = next !== 'editor';
  if (next === 'editor') {
    editor.draw();
    updatePoint();
  }
  if (next === 'rooms' && !multiplayer.room) void refreshRooms();
}
document
  .querySelectorAll<HTMLElement>('[data-view]')
  .forEach((b) => b.addEventListener('click', () => setView(b.dataset.view!)));
$<HTMLSelectElement>('trackSelect').addEventListener('change', (e) => {
  track = copyTrack(presets[Number((e.target as HTMLSelectElement).value)]);
  editor.selected = 0;
  editor.track = track;
  editor.fit();
  updateRoad();
});
$<HTMLInputElement>('trackName').addEventListener('input', (e) => {
  track.name =
    (e.target as HTMLInputElement).value.slice(0, 48) || 'Untitled circuit';
  $('sceneTitle').textContent = track.name;
  $('sceneSubtitle').textContent = courseStyle(track.name).subtitle;
  $('saveState').textContent = 'UNSAVED';
});
for (const id of [
  'heightInput',
  'widthInput',
  'segmentWall',
  'wallThicknessInput',
])
  $(id).addEventListener('input', () => {
    const p = track.points[editor.selected];
    if (id === 'heightInput' && p) p.z = Number($<HTMLInputElement>(id).value);
    if (id === 'widthInput' && p)
      p.width = Number($<HTMLInputElement>(id).value);
    if (id === 'wallThicknessInput' && p)
      p.wallThickness = Number($<HTMLInputElement>(id).value);
    if (id === 'segmentWall' && p) p.wall = $<HTMLInputElement>(id).checked;
    editor.onChange();
    updatePoint();
  });
button('addPoint', () => editor.setTool('add'));
button('removePoint', () => editor.remove());
button('allWalls', () => {
  track.points.forEach((p) => (p.wall = true));
  editor.onChange();
});
button('noWalls', () => {
  track.points.forEach((p) => (p.wall = false));
  editor.onChange();
});
editor.onNotice = (message) => status(message);
editor.onTool = () => {
  for (const [id, tool] of [
    ['drawTool', 'draw'],
    ['moveTool', 'move'],
    ['addPoint', 'add'],
    ['wallTool', 'walls'],
  ]) {
    $(id).classList.toggle('active', editor.tool === tool);
    $(id).setAttribute('aria-pressed', String(editor.tool === tool));
  }
  $('planMode').textContent = {
    move: 'PLAN / DRAG POINTS',
    draw: 'PLAN / DRAW LOOP',
    add: 'PLAN / PLACE POINTS',
    walls: 'PLAN / SELECT SECTION',
  }[editor.tool];
  $('editorHint').textContent = {
    move: 'Drag a point. Width and elevation apply to that point; the curve closes automatically.',
    draw: 'Draw one loop on the plan, then release. At least four points.',
    add: 'Click anywhere on the plan to place a point in the nearest section. Esc finishes.',
    walls:
      'Click a section in the plan, then set its walls and thickness below.',
  }[editor.tool];
};
button('drawTool', () => editor.setTool('draw'));
button('moveTool', () => editor.setTool('move'));
button('wallTool', () => editor.setTool('walls'));
window.addEventListener('keydown', (e) => {
  if (e.code === 'Escape' && view === 'editor' && !world)
    editor.setTool('move');
});
editor.onTool();
button('saveTrack', async () => {
  if (!runtimeHasDomain('storage'))
    throw new Error(
      'Saving is unavailable in this host; your current track is still playable.',
    );
  if (!validateTrack(track))
    throw new Error('Track needs 4–32 separated points and a name.');
  await storage.setItem('pocket-circuit.track.v1', JSON.stringify(track));
  $('saveState').textContent = 'SAVED';
  status('Track saved. Load saved brings it back after reopening.');
});
button('loadTrack', async () => {
  if (!runtimeHasDomain('storage'))
    throw new Error('Saving is unavailable in this host.');
  const saved = await storage.getItem('pocket-circuit.track.v1');
  if (!saved)
    throw new Error('No saved track yet. Draw a circuit and save it first.');
  const parsed = decodeTrack(JSON.parse(saved));
  if (!parsed)
    throw new Error('Saved track has an unsupported or damaged format.');
  track = parsed;
  editor.setTool('move');
  editor.selected = 0;
  editor.track = track;
  editor.fit();
  updateRoad();
  $('saveState').textContent = 'SAVED';
  status('Saved track loaded.');
});
function roster(count: number): Driver[] {
  return localRoster(localPlayers, count);
}
function begin(w: World, isOnline: boolean) {
  world = w;
  scene.dataset.countdown = String(w.countdown);
  scene.dataset.racing = 'true';
  online = isOnline;
  if (!isOnline && localPlayers.length > 1) follow = true;
  $('app').classList.toggle('local-multiplayer', !isOnline && localPlayers.length > 1);
  $('playerTwoHud').hidden = isOnline || localPlayers.length < 2;
  $('playerOneLabel').textContent = isOnline ? 'YOU' : 'P1';
  localId = isOnline ? multiplayer.actor : 'you';
  paused = false;
  controllerLost = false;
  pending = [];
  snapshots = [];
  remoteQueues.clear();
  sequence = w.bodies.find((body) => body.id === localId)?.ack || 0;
  if (isOnline && w.bodies.find((body) => body.id === localId)?.ai)
    localId = `spectator:${multiplayer.actor}`;
  lastSnapshot = -1;
  lastPacket = performance.now();
  prediction = null;
  accumulator = 0;
  sendAt = 0;
  $('app').classList.remove('editor');
  $('app').classList.add('racing');
  $('raceHud').hidden = false;
  $('raceActions').hidden = false;
  $('sceneInfo').hidden = true;
  $('sceneBottom').hidden = true;
  $('planWrap').hidden = true;
  $('results').hidden = true;
  $('touchControls').hidden = false;
  $('pauseButton').hidden = isOnline;
  $('reconnectRace').hidden = !isOnline;
  $('pauseButton').textContent = 'Pause';
  $('cameraButton').textContent = follow ? 'Overview' : 'Follow';
  $('modeTag').textContent = isOnline
    ? 'ONLINE / ' + w.bodies.filter((b) => !b.ai).length + ' PLAYERS'
    : `LOCAL / ${localPlayers.length} PLAYERS / ${w.bodies.length} CARS`;
  clearInput();
  $<HTMLCanvasElement>('scene').focus();
  status(
    isOnline
      ? 'Race connected. The host judges collisions and the finish.'
      : localPlayers.map((p, i) => `P${i + 1}: ${inputHints[p.method]}`).join(' · '),
  );
}
function startLocal() {
  if (multiplayer.room)
    throw new Error('Leave your online room before starting a local race.');
  if (localPlayers.some(p => p.method === 'controller' && p.controller === null))
    throw new Error('Press A / ✕ on each selected controller before racing, or choose keyboard / touch.');
  updateRoad();
  begin(
    createWorld(roster(Number($<HTMLSelectElement>('gridSize').value)), road),
    false,
  );
}
button('raceButton', startLocal);
button('testTrack', startLocal);
function exit() {
  scene.dataset.racing = 'false';
  world = null;
  online = false;
  prediction = null;
  clearInput();
  $('app').classList.remove('racing');
  $('app').classList.remove('local-multiplayer');
  $('raceHud').hidden = true;
  $('raceActions').hidden = true;
  $('results').hidden = true;
  $('countdown').hidden = true;
  $('touchControls').hidden = true;
  $('sceneInfo').hidden = false;
  $('sceneBottom').hidden = false;
  renderer.zoom = 1;
  $('modeTag').textContent = 'LOCAL / 6 CARS';
  setView(multiplayer.room ? 'rooms' : view);
}
button('exitRace', async () => {
  if (online && multiplayer.room) {
    await multiplayer.leave();
    status('Left the online race.');
  }
  exit();
});
button('againButton', async () => {
  if (online && multiplayer.isHost) {
    await multiplayer.sendControl({ type: 'return' });
    multiplayer.start = null;
  } else if (online) {
    await multiplayer.leave();
  }
  exit();
});
button('pauseButton', () => {
  paused = !paused;
  controllerLost = false;
  clearInput();
  $('pauseButton').textContent = paused ? 'Resume' : 'Pause';
});
button('reconnectRace', () => multiplayer.retry());
button('recoverButton', () => {
  if (!world || paused) return;
  if (online) resetRequested = true;
  else recoverPlayers.add((localPlayers.find(p => p.method === 'touch') || localPlayers[0]).id);
});
button('cameraButton', () => {
  follow = !follow;
  $('cameraButton').textContent = follow ? 'Overview' : 'Follow';
});
button('orbitLeft', () => {
  renderer.azimuth -= 0.25;
});
button('orbitRight', () => {
  renderer.azimuth += 0.25;
});
button('fitPlan', () => {
  editor.fit();
  editor.draw();
});
button('fullPlan', () => {
  editor.zoom = 1;
  editor.draw();
});
button('zoomIn', () => {
  renderer.zoom = clamp(renderer.zoom + 0.15, 0.6, 2);
});
button('zoomOut', () => {
  renderer.zoom = clamp(renderer.zoom - 0.15, 0.6, 2);
});
let orbit: { x: number; y: number; id: number } | null = null;
const scene = $<HTMLCanvasElement>('scene');
scene.addEventListener('pointerdown', (e) => {
  if (world) return;
  scene.setPointerCapture(e.pointerId);
  orbit = { x: e.clientX, y: e.clientY, id: e.pointerId };
});
scene.addEventListener('pointermove', (e) => {
  if (!orbit || orbit.id !== e.pointerId) return;
  renderer.azimuth += (e.clientX - orbit.x) * 0.008;
  renderer.elevation = clamp(
    renderer.elevation + (e.clientY - orbit.y) * 0.006,
    0.35,
    1.3,
  );
  orbit = { x: e.clientX, y: e.clientY, id: e.pointerId };
});
scene.addEventListener('pointerup', () => (orbit = null));
scene.addEventListener('pointercancel', () => (orbit = null));
const touchDriving = new TouchDriving($('steeringPad'), $('pedalPad'));
const touchBoost = $('touchBoost');
const setTouchBoost = (held: boolean) => {
  touchBoostHeld = held;
  touchBoost.classList.toggle('pressed', held);
  touchBoost.setAttribute('aria-pressed', String(held));
};
let boostPointer: number | null = null;
touchBoost.addEventListener('pointerdown', e => {
  if (boostPointer !== null) return;
  e.preventDefault();
  boostPointer = e.pointerId;
  touchBoost.setPointerCapture(e.pointerId);
  setTouchBoost(true);
});
for (const event of ['pointerup', 'pointercancel', 'lostpointercapture']) {
  touchBoost.addEventListener(event, e => {
    if ((e as PointerEvent).pointerId !== boostPointer) return;
    boostPointer = null;
    setTouchBoost(false);
  });
}
touchBoost.addEventListener('keydown', e => {
  if (e.code !== 'Space' && e.code !== 'Enter') return;
  e.preventDefault(); e.stopPropagation(); setTouchBoost(true);
});
touchBoost.addEventListener('keyup', e => {
  if (e.code !== 'Space' && e.code !== 'Enter') return;
  e.preventDefault(); e.stopPropagation(); setTouchBoost(false);
});
touchBoost.addEventListener('blur', () => { if (boostPointer === null) setTouchBoost(false); });
function clearInput() {
  pressed.clear();
  touchDriving.clear();
  controllerDrive = idleInput();
  resetRequested = false;
  recoverPlayers.clear();
  touchBoostHeld = false;
  if (boostPointer !== null && touchBoost.hasPointerCapture(boostPointer)) touchBoost.releasePointerCapture(boostPointer);
  boostPointer = null;
  $('touchBoost').classList.remove('pressed');
  $('touchBoost').setAttribute('aria-pressed', 'false');
  scene.dataset.inputSteer = '0';
  scene.dataset.inputThrottle = '0';
  scene.dataset.inputBrake = 'false';
  scene.dataset.inputBoost = 'false';
  scene.dataset.inputTwoThrottle = '0';
  scene.dataset.inputTwoSteer = '0';
  scene.dataset.inputTwoBrake = 'false';
  scene.dataset.inputTwoBoost = 'false';
  controllers.reset();
}
window.addEventListener('resize', clearInput);
window.addEventListener('keydown', (e) => {
  if (
    !world ||
    e.target instanceof HTMLInputElement ||
    e.target instanceof HTMLSelectElement
  )
    return;
  if (
    [
      'ArrowUp',
      'ArrowDown',
      'ArrowLeft',
      'ArrowRight',
      'KeyW',
      'KeyA',
      'KeyS',
      'KeyD',
      'Space',
      'KeyR',
      'Backspace', 'Enter', 'ControlLeft', 'ControlRight', 'ShiftLeft', 'ShiftRight',
    ].includes(e.code)
  ) {
    e.preventDefault();
    if (e.code === 'KeyR' || e.code === 'Backspace') {
      if (!e.repeat && !paused) {
        if (online) resetRequested = true;
        else for (const p of localPlayers) {
          if ((p.method === 'wasd' && e.code === 'KeyR') || (p.method === 'arrows' && e.code === 'Backspace') || localPlayers.length === 1) recoverPlayers.add(p.id);
        }
      }
    } else pressed.add(e.code);
  }
  if (e.code === 'Escape' && !e.repeat && !online) {
    paused = !paused;
    controllerLost = false;
    clearInput();
    $('pauseButton').textContent = paused ? 'Resume' : 'Pause';
  }
});
window.addEventListener('keyup', (e) => pressed.delete(e.code));
window.addEventListener('blur', () => {
  clearInput();
  if (world && !online) {
    paused = true;
    $('pauseButton').textContent = 'Resume';
  }
});
document.addEventListener('visibilitychange', () => {
  clearInput();
  accumulator = 0;
  last = performance.now();
  if (document.hidden && world && !online) {
    paused = true;
    $('pauseButton').textContent = 'Resume';
  }
});
function input(id = localId): Input {
  const boostTouch = { ...idleInput(), throttle: touchBoostHeld ? 1 : 0, boost: touchBoostHeld };
  if (online) {
    const keyboard = keyboardDriving(pressed, 'wasd', true);
    keyboard.reset = resetRequested;
    resetRequested = false;
    return mixDriving(keyboard, touchDriving.value, boostTouch, controllerDrive);
  }
  const p = localPlayers.find(p => p.id === id)!;
  const solo = localPlayers.length === 1;
  const keyboard = keyboardDriving(pressed, solo && p.method === 'controller' && p.controller === null ? 'wasd' : p.method, solo);
  const controller = p.method === 'controller' ? controllerDriving(padPlayers.find(pad => pad.index === p.controller)) : idleInput();
  const touchOwner = localPlayers.find(p => p.method === 'touch') || localPlayers[0];
  const useTouch = p === touchOwner;
  const value = mixDriving(keyboard, controller, ...(useTouch ? [touchDriving.value, boostTouch] : []));
  value.reset = recoverPlayers.delete(id) || (id === localId && resetRequested);
  if (id === localId) resetRequested = false;
  return value;
}
function pollController() {
  const pad = controllers.poll(online || localPlayers.length === 1);
  padPlayers = pad.players;
  controllerDrive = pad.drive;
  let localLost = false, localJoined = false;
  if (!online) {
    if (localPlayers.length === 1 && pad.joined) {
      localPlayers[0].method = 'controller';
      localPlayers[0].controller = pad.selected;
      localJoined = true;
    }
    const assignments = assignControllers(localPlayers, { players: pad.players, disconnected: pad.disconnectedSlots });
    localLost = assignments.lost.length > 0;
    localJoined ||= assignments.joined.length > 0;
    if (localLost || localJoined) updateLocalSetup();
  }
  const label =
    !online && localPlayers.length > 1 && localPlayers.some(p => p.controller !== null)
      ? localPlayers.filter(p => p.controller !== null).map(p => `PAD ${p.controller! + 1}`).join(' / ')
      : pad.selected !== null
      ? `PAD ${pad.selected + 1}`
      : pad.unmapped
        ? 'UNMAPPED PAD'
        : pad.status === 'blocked'
          ? 'PAD BLOCKED'
          : pad.status === 'unavailable'
            ? 'NO GAMEPAD API'
            : 'PRESS A / ✕';
  if ($('controllerState').textContent !== label)
    $('controllerState').textContent = label;
  $('controllerState').title = pad.unmapped
    ? 'This controller has no standard mapping. Keyboard and touch remain available.'
    : inputHints.controller + ' · Menu pause';
  $('controllerState').dataset.state =
    (online ? pad.selected !== null : localPlayers.some(p => p.controller !== null)) ? 'connected' : 'waiting';
  if (online ? pad.disconnected : localLost) {
    clearInput();
    controllerLost = true;
    status(
      'Controller disconnected. Press A / ✕ to reconnect, or use touch / keyboard.',
    );
    if (world && !online) {
      paused = true;
      $('pauseButton').textContent = 'Resume';
    }
    return;
  }
  if (pad.joined || localJoined) controllerLost = false;
  if (pad.status !== 'ready' || pad.joined || localJoined) return;
  if (!online && localPlayers.length > 1) {
    for (const p of localPlayers) {
      if (p.method !== 'controller') continue;
      const actions = pad.players.find(pad => pad.index === p.controller)?.actions;
      if (!actions) continue;
      if (world) {
        if (world.ended && actions.confirm.pressed) $('againButton').click();
        else if (actions.pause.pressed) { $('pauseButton').click(); return; }
        else if (paused && actions.drift.pressed) { $('exitRace').click(); return; }
        else if (!paused && actions.recover.pressed) recoverPlayers.add(p.id);
      } else if (view === 'garage') {
        const step = Number(actions.nextCar.pressed) - Number(actions.previousCar.pressed);
        if (step) { p.car = (p.car + step + cars.length) % cars.length; if (p === localPlayers[0]) selectCar(p.car); else updateLocalSetup(); }
        if (actions.confirm.pressed) $('raceButton').click();
      }
    }
    return;
  }
  if (world) {
    if (world.ended && pad.confirm) $('againButton').click();
    else if (pad.pause && !online) $('pauseButton').click();
    else if (paused && pad.back) $('exitRace').click();
    else if (!paused && pad.recover) resetRequested = true;
  } else if (view === 'garage') {
    if (pad.carStep) selectCar((car + pad.carStep + cars.length) % cars.length);
    if (pad.trackStep) {
      const select = $<HTMLSelectElement>('trackSelect');
      select.selectedIndex =
        (select.selectedIndex + pad.trackStep + presets.length) %
        presets.length;
      select.dispatchEvent(new Event('change'));
    }
    if (pad.confirm) $('raceButton').click();
  } else if (view === 'rooms' && pad.confirm) {
    const target = multiplayer.room
      ? multiplayer.isHost
        ? 'startRoom'
        : 'readyButton'
      : 'hostButton';
    const control = $<HTMLButtonElement>(target);
    if (control && !control.disabled) control.click();
  }
}
let audio: AudioContext | null = null,
  osc: OscillatorNode | null = null,
  gain: GainNode | null = null,
  sound = false;
button('soundButton', async () => {
  sound = !sound;
  if (sound) {
    audio ??= new AudioContext();
    await audio.resume();
    if (!osc) {
      osc = audio.createOscillator();
      gain = audio.createGain();
      osc.type = 'sawtooth';
      osc.connect(gain);
      gain.connect(audio.destination);
      gain.gain.value = 0;
      osc.start();
    }
  }
  $('soundButton').textContent = sound ? '♪ On' : '♪ Off';
  $('soundButton').setAttribute(
    'aria-label',
    sound ? 'Disable sound' : 'Enable sound',
  );
});
function showResults() {
  if (!world) return;
  $('results').hidden = false;
  const order = ranking(world),
    position = order.findIndex((b) => b.id === localId) + 1;
  $('resultTitle').textContent =
    position === 1
      ? 'Small car. Big win.'
      : `Finished ${position || '—'}${position === 2 ? 'nd' : position === 3 ? 'rd' : 'th'}.`;
  $('resultOrder').replaceChildren(
    ...order.map((b, i) => {
      const li = document.createElement('li'),
        n = document.createElement('span'),
        t = document.createElement('span');
      n.textContent = `${i + 1}. ${b.name}${online && b.id === localId ? ' · YOU' : ''}`;
      t.textContent = b.finished ? formatTime(b.finished) : 'DNF';
      li.append(n, t);
      return li;
    }),
  );
}
function updateHud() {
  if (!world) return;
  const own = world.bodies.find((b) => b.id === localId),
    pos = ranking(world).findIndex((b) => b.id === localId) + 1;
  $('position').innerHTML = `${pos || '—'}<span>/${world.bodies.length}</span>`;
  $('lap').textContent = `LAP ${Math.min(3, (own?.lap || 0) + 1)} / 3`;
  $('raceTime').textContent = formatTime(world.time);
  const body = rendered.find((b) => b.id === localId) || own;
  $('speed').textContent = String(
    Math.round(Math.hypot(body?.vx || 0, body?.vy || 0) * 3.6),
  );
  const boostHud = (b: Body | undefined, suffix: string, held: boolean) => {
    $<HTMLProgressElement>('boostMeter' + suffix).value = b?.boost ?? 0;
    $('boostState' + suffix).textContent = b?.boosting ? 'BOOSTING' : (b?.boost ?? 0) >= 0.99 ? 'BOOST READY' : paused ? `BOOST ${Math.round((b?.boost ?? 0) * 100)}%` : held ? 'RELEASE TO RECHARGE' : 'RECHARGING';
  };
  boostHud(body, '', scene.dataset.inputBoost === 'true');
  $('playerOneLabel').textContent = online ? 'YOU' : `P1 · ${Math.round(Math.hypot(body?.vx || 0, body?.vy || 0) * 3.6)} KM/H`;
  if (!online && localPlayers.length > 1) {
    const second = rendered.find(b => b.id === localPlayers[1].id);
    const place = ranking(world).findIndex(b => b.id === localPlayers[1].id) + 1;
    $('positionTwo').textContent = `${place} / ${world.bodies.length}`;
    $('lapTwo').textContent = second?.finished ? 'FINISHED' : `LAP ${Math.min(3, (second?.lap || 0) + 1)} / 3`;
    $('speedTwo').textContent = String(Math.round(Math.hypot(second?.vx || 0, second?.vy || 0) * 3.6));
    boostHud(second, 'Two', scene.dataset.inputTwoBoost === 'true');
  }
  if (own?.finished) $('lap').textContent = 'FINISHED';
  $('driftState').hidden = !(
    body?.grounded &&
    Math.hypot(body.vx, body.vy) > 5 &&
    Math.abs(slipAngle(body)) > 0.22
  );
  $('raceOrder').replaceChildren(
    ...ranking(world).map((b, i) => {
      const li = document.createElement('li'),
        dot = document.createElement('i'),
        text = document.createElement('span');
      dot.style.background = cars[b.car].color;
      const player = online ? -1 : localPlayers.findIndex(p => p.id === b.id);
      text.textContent = `${i + 1}  ${b.name}${online && b.id === localId ? ' · YOU' : player >= 0 ? ` · P${player + 1}` : ''}`;
      li.classList.toggle('you', b.id === localId);
      li.classList.toggle('player-two', player === 1);
      li.append(dot, text);
      return li;
    }),
  );
  if (world.ended) showResults();
}
async function refreshRooms() {
  if (multiplayer.room) return;
  $('roomList').textContent = 'Finding rooms…';
  try {
    const rooms = await multiplayer.list();
    $('roomList').replaceChildren();
    if (!rooms.length)
      $('roomList').textContent = 'No rooms yet. Start the first grid.';
    for (const r of rooms) {
      const b = document.createElement('button'),
        name = document.createElement('span'),
        count = document.createElement('small');
      b.className = 'room-item';
      name.textContent = r.name;
      count.textContent = `${r.peers.length}/${r.capacity} · Join`;
      b.append(name, count);
      b.disabled = r.peers.length >= r.capacity;
      b.onclick = () => void action(() => joinRoom(r.room));
      $('roomList').append(b);
    }
    status('Room list updated.');
  } catch (e) {
    $('roomList').textContent = 'Rooms unavailable. Refresh to retry.';
    status(errorText(e), true);
  }
}
function configureProfile() {
  multiplayer.profile = {
    name:
      $<HTMLInputElement>('playerName').value.trim().slice(0, 18) || 'Racer',
    car,
    ready: false,
  };
}
let roomBusy = false;
async function joinRoom(code: string) {
  if (roomBusy || multiplayer.room) return;
  roomBusy = true;
  configureProfile();
  try {
    status('Joining room…');
    await multiplayer.join(code);
  } finally {
    roomBusy = false;
  }
}
button('refreshRooms', refreshRooms);
button('joinCode', () => joinRoom($<HTMLInputElement>('roomCode').value));
button('hostButton', async () => {
  if (roomBusy || multiplayer.room) return;
  roomBusy = true;
  configureProfile();
  $<HTMLButtonElement>('hostButton').disabled = true;
  try {
    status('Creating room…');
    await multiplayer.host(
      $<HTMLInputElement>('roomName').value,
      Number($<HTMLSelectElement>('roomCapacity').value),
    );
  } finally {
    roomBusy = false;
    $<HTMLButtonElement>('hostButton').disabled = false;
  }
});
button('leaveRoom', async () => {
  await multiplayer.leave();
  status('Left the room.');
  void refreshRooms();
});
button('readyButton', async () => {
  multiplayer.profile.ready = !multiplayer.profile.ready;
  await multiplayer.hello();
  updateRoom();
});
const retry = document.createElement('button');
retry.id = 'reconnect';
retry.textContent = 'Reconnect';
retry.onclick = () => void action(() => multiplayer.retry());
$('activeRoom').append(retry);
button('startRoom', async () => {
  if (!multiplayer.room || !multiplayer.isHost) return;
  const drivers = multiplayer.roster(),
    count = multiplayer.room.capacity;
  for (let i = drivers.length; i < count; i++)
    drivers.push({
      id: `ai-${i}`,
      name: ['Pip', 'Mochi', 'Rivet', 'Nori', 'Bolt', 'Pip'][i],
      car: i % 4,
      ai: true,
    });
  await multiplayer.begin(track, createWorld(drivers, road));
});
function updateRoom() {
  const r = multiplayer.room;
  $('browseRooms').hidden = !!r;
  $('activeRoom').hidden = !r;
  if (!r) return;
  $('roomTitle').textContent = r.name;
  $<HTMLInputElement>('activeCode').value = r.room;
  $('startRoom').hidden = !multiplayer.isHost;
  $('readyButton').hidden = multiplayer.isHost;
  $('readyButton').textContent = multiplayer.profile.ready
    ? 'Ready ✓'
    : 'Ready up';
  const expected = multiplayer.isHost ? r.peers.length - 1 : 1,
    connected = multiplayer.connected.size;
  const all = connected >= expected;
  $('connectionState').textContent = all
    ? expected
      ? 'Connected'
      : 'Waiting for players'
    : `Connecting · ${connected}/${expected} peers`;
  const canStart = r.peers.every(
    (id) =>
      id === multiplayer.actor ||
      (multiplayer.members.get(id)?.ready && multiplayer.connected.has(id)),
  );
  $<HTMLButtonElement>('startRoom').disabled = !canStart;
  $('members').replaceChildren(
    ...r.peers.map((id) => {
      const p = multiplayer.members.get(id),
        row = document.createElement('div'),
        name = document.createElement('span'),
        state = document.createElement('small'),
        dot = document.createElement('b');
      row.className = 'member';
      dot.style.background = cars[p?.car || 0].color;
      name.append(
        dot,
        document.createTextNode(
          (p?.name || 'Connecting…') +
            (id === multiplayer.actor ? ' · YOU' : ''),
        ),
      );
      state.textContent =
        id === multiplayer.authority
          ? 'HOST'
          : p?.ready
            ? 'READY'
            : 'NOT READY';
      row.append(name, state);
      return row;
    }),
  );
}
multiplayer.onUpdate = () => {
  updateRoom();
  if (world && online && multiplayer.isHost && multiplayer.room)
    for (const b of world.bodies)
      if (!b.ai && !multiplayer.room.peers.includes(b.id)) {
        b.ai = true;
        b.name = b.name.slice(0, 12) + ' · AI';
      }
};
multiplayer.onError = (m) => status(m, true);
multiplayer.onEnded = (m) => {
  exit();
  status(m);
};
multiplayer.onStart = (s: Start) => {
  track = copyTrack(s.track);
  updateRoad();
  begin(structuredClone(s.world), true);
};
multiplayer.onInput = (from, commands) => {
  const b = world?.bodies.find((b) => b.id === from);
  if (!b || b.ai || !world) return;
  const q = remoteQueues.get(from) || [],
    seen = new Set(q.map((c) => c.seq));
  for (const c of commands)
    if (c.seq > b.ack && c.seq <= b.ack + 120 && !seen.has(c.seq)) {
      q.push(c);
      seen.add(c.seq);
    }
  q.sort((a, b) => a.seq - b.seq);
  remoteQueues.set(from, q.slice(0, 120));
};
multiplayer.onSnapshot = (w) => {
  if (!world || multiplayer.isHost || w.tick <= lastSnapshot) return;
  if (
    w.bodies.length !== world.bodies.length ||
    w.bodies.some((b, i) => b.id !== world!.bodies[i].id)
  )
    return;
  const own = w.bodies.find((b) => b.id === localId);
  if (own && own.ack > sequence) return;
  const now = performance.now();
  lastPacket = now;
  lastSnapshot = w.tick;
  anchorTick = w.tick;
  anchorTime = now;
  world = structuredClone(w);
  if (own) {
    pending = pending.filter((c) => c.seq > own.ack);
    prediction = structuredClone(own);
    if (!w.ended && w.countdown === 0)
      for (const c of pending) advanceBody(prediction, c.input, road);
  }
  snapshots.push({ world: structuredClone(w), at: now });
  if (snapshots.length > 24) snapshots.shift();
};
function guestStep(inp: Input) {
  if (!world) return;
  const b = prediction || world.bodies.find((b) => b.id === localId);
  if (!b) return;
  if (!prediction) prediction = structuredClone(b);
  if (world.countdown > 0 || world.ended) return;
  if (pending.length >= 120) {
    status('Waiting for host · input history full', true);
    return;
  }
  const command = { seq: ++sequence, input: { ...inp } };
  pending.push(command);
  advanceBody(prediction!, inp, road);
}
function remoteStep(b: Body) {
  if (b.id === localId) return false;
  const q = remoteQueues.get(b.id);
  if (q?.length) {
    const command = q.shift()!;
    if (command.seq > b.ack) {
      advanceBody(b, command.input, road);
      b.ack = command.seq;
    }
  } else advanceBody(b, idleInput(), road);
  return true;
}
function interpolated(now: number): Body[] {
  if (!world) return [];
  if (!online || multiplayer.isHost) return world.bodies;
  const tick = anchorTick + (now - anchorTime) / 1000 / DT - 6;
  let a = snapshots[0]?.world,
    b = a;
  for (let i = 1; i < snapshots.length; i++) {
    b = snapshots[i].world;
    if (b.tick >= tick) break;
    a = b;
  }
  if (!a || !b)
    return prediction
      ? world.bodies.map((x) => (x.id === localId ? prediction! : x))
      : world.bodies;
  const t = clamp((tick - a.tick) / (b.tick - a.tick || 1), 0, 1);
  return b.bodies.map((body, i) => {
    if (body.id === localId && prediction) return prediction;
    const prev = a!.bodies[i];
    if (
      !prev ||
      prev.falls !== body.falls ||
      Math.hypot(prev.x - body.x, prev.y - body.y) > 8
    )
      return body;
    return {
      ...body,
      x: lerp(prev.x, body.x, t),
      y: lerp(prev.y, body.y, t),
      z: lerp(prev.z, body.z, t),
      angle: prev.angle + wrapAngle(body.angle - prev.angle) * t,
      pitch: lerp(prev.pitch, body.pitch, t),
      roll: lerp(prev.roll, body.roll, t),
      steering: lerp(prev.steering, body.steering, t),
      handbrake: lerp(prev.handbrake, body.handbrake, t),
    };
  });
}
function tick(now: number) {
  frameId = requestAnimationFrame(tick);
  const elapsed = Math.min(0.1, (now - last) / 1000);
  last = now;
  pollController();
  if (roadDirty) {
    roadDirty = false;
    updateRoad();
  }
  if (view === 'editor' && !world) editor.draw();
  if (world) {
    accumulator += elapsed;
    if (!paused) {
      let steps = 0;
      while (accumulator >= DT && steps++ < 6) {
        const inp = input();
        const inputs = new Map([[localId, inp]]);
        if (!online) for (const p of localPlayers.slice(1)) {
          const second = input(p.id);
          inputs.set(p.id, second);
          scene.dataset.inputTwoSteer = second.steer.toFixed(3);
          scene.dataset.inputTwoThrottle = second.throttle.toFixed(3);
          scene.dataset.inputTwoBrake = String(second.brake);
          scene.dataset.inputTwoBoost = String(second.boost);
        }
        scene.dataset.inputSteer = inp.steer.toFixed(3);
        scene.dataset.inputThrottle = inp.throttle.toFixed(3);
        scene.dataset.inputBrake = String(inp.brake);
        scene.dataset.inputBoost = String(inp.boost);
        if (online && !multiplayer.isHost) guestStep(inp);
        else
          stepWorld(
            world,
            road,
            inputs,
            online ? remoteStep : undefined,
          );
        accumulator -= DT;
      }
    } else accumulator = 0;
    if (online && now >= sendAt) {
      sendAt = Math.max(sendAt + (multiplayer.isHost ? 50 : 1000 / 30), now);
      if (multiplayer.isHost) {
        if (multiplayer.start) multiplayer.start.world = structuredClone(world);
        void multiplayer.sendLatest({
          type: 'world',
          race: multiplayer.start?.race,
          world,
        });
      } else if (pending.length)
        void multiplayer.sendLatest({
          type: 'input',
          race: multiplayer.start?.race,
          commands: pending.slice(0, 120),
        });
    }
    rendered = interpolated(now);
    const own = rendered.find((b) => b.id === localId);
    const locals = online ? [localId] : localPlayers.map(p => p.id);
    const cameraPlayers = rendered.filter(b => locals.includes(b.id));
    renderer.render(rendered, follow ? cameraPlayers.length > 1 ? cameraPlayers : own : undefined, locals);
    scene.dataset.localPlayers = String(locals.length);
    scene.dataset.localBoost = (own?.boost ?? 0).toFixed(4);
    scene.dataset.localBoosting = String(own?.boosting ?? false);
    const second = cameraPlayers[1];
    scene.dataset.playerTwoX = (second?.x ?? 0).toFixed(4);
    scene.dataset.playerTwoY = (second?.y ?? 0).toFixed(4);
    scene.dataset.playerTwoSpeed = Math.hypot(second?.vx || 0, second?.vy || 0).toFixed(4);
    scene.dataset.playerTwoBoost = (second?.boost ?? 0).toFixed(4);
    scene.dataset.playerTwoBoosting = String(second?.boosting ?? false);
    // Test/diagnostic projections are in the actual canvas viewport.
    for (const [i, b] of cameraPlayers.entries()) {
      const projected = renderer.project({ x: b.x, y: b.y, z: b.z });
      scene.dataset[`player${i + 1}ScreenX`] = projected[0].toFixed(2);
      scene.dataset[`player${i + 1}ScreenY`] = projected[1].toFixed(2);
    }
    scene.dataset.localX = (own?.x || 0).toFixed(4);
    scene.dataset.localY = (own?.y || 0).toFixed(4);
    scene.dataset.localSpeed = Math.hypot(own?.vx || 0, own?.vy || 0).toFixed(
      4,
    );
    scene.dataset.localSlip = (own ? slipAngle(own) : 0).toFixed(4);
    scene.dataset.localPitch = (own?.pitch || 0).toFixed(4);
    scene.dataset.localRoll = (own?.roll || 0).toFixed(4);
    scene.dataset.localSteering = (own?.steering || 0).toFixed(4);
    scene.dataset.localHandbrake = (own?.handbrake || 0).toFixed(4);
    const remote = rendered.find((body) => body.id === multiplayer.authority);
    scene.dataset.remoteSteering = (remote?.steering || 0).toFixed(4);
    scene.dataset.remotePitch = (remote?.pitch || 0).toFixed(4);
    scene.dataset.remoteHandbrake = (remote?.handbrake || 0).toFixed(4);
    scene.dataset.remoteX = (remote?.x || 0).toFixed(4);
    scene.dataset.remoteY = (remote?.y || 0).toFixed(4);
    scene.dataset.tick = String(world.tick);
    scene.dataset.ack = String(
      world.bodies.find((b) => b.id === localId)?.ack || 0,
    );
    scene.dataset.pending = String(pending.length);
    scene.dataset.countdown = String(world.countdown);
    scene.dataset.racing = 'true';
    const countdown =
      world.countdown > 0
        ? String(Math.ceil(world.countdown))
        : paused
          ? controllerLost
            ? 'PAD LOST'
            : 'PAUSED'
          : online && !multiplayer.isHost && now - lastPacket > 2500
            ? 'WAITING'
            : '';
    $('countdown').hidden = !countdown;
    $('countdown').textContent = countdown;
    if (now > hudAt) {
      hudAt = now + 120;
      updateHud();
    }
    if (gain && audio && osc) {
      gain.gain.setTargetAtTime(
        sound && !paused ? 0.018 : 0,
        audio.currentTime,
        0.15,
      );
      osc.frequency.setTargetAtTime(
        45 + Math.hypot(own?.vx || 0, own?.vy || 0) * 5,
        audio.currentTime,
        0.08,
      );
    }
  } else {
    const demos = createWorld(roster(4), road).bodies;
    renderer.render(demos);
    scene.dataset.racing = 'false';
    if (gain && audio) gain.gain.setTargetAtTime(0, audio.currentTime, 0.1);
  }
}
updateRoad();
frameId = requestAnimationFrame(tick);
if (!multiplayer.available) {
  $('networkBadge').textContent = 'UNAVAILABLE';
  $<HTMLButtonElement>('hostButton').disabled = true;
  $<HTMLButtonElement>('joinCode').disabled = true;
}
window.addEventListener('pagehide', () => {
  cancelAnimationFrame(frameId);
  controllers.dispose();
  touchDriving.clear();
  void multiplayer.leave();
  renderer.dispose();
  void audio?.close();
});