Back to Napplet Machines V8
SOURCE / PINNED RELEASE

Made of little things.

Napplet Machines V8

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

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

src/physics.ts
import { paint } from './art.js';
import { resolveCars, yawInertia } from './contacts.js';
import { Road, clamp, wrapAngle, roadEdge } from './track.js';
export const DT = 1 / 60;
export const cars = [
  {
    name: 'Comet',
    tag: 'THE ALL-ROUNDER',
    color: paint[0],
    mass: 1,
    accel: 16,
    speed: 23,
    grip: 7.8,
    steer: 2.45,
    stats: [4, 3, 4, 3],
  },
  {
    name: 'Bumble',
    tag: 'SMALL & SCRAPPY',
    color: paint[1],
    mass: 0.78,
    accel: 20,
    speed: 21,
    grip: 9,
    steer: 2.75,
    stats: [5, 2, 5, 2],
  },
  {
    name: 'Bruiser',
    tag: 'MAKE SOME ROOM',
    color: paint[2],
    mass: 1.55,
    accel: 12.5,
    speed: 22,
    grip: 7,
    steer: 2.05,
    stats: [2, 3, 3, 5],
  },
  {
    name: 'Mantis',
    tag: 'LATE BRAKER',
    color: paint[3],
    mass: 0.95,
    accel: 14.5,
    speed: 27,
    grip: 6.7,
    steer: 2.35,
    stats: [3, 5, 2, 3],
  },
];
export type Input = {
  throttle: number;
  steer: number;
  brake: boolean;
  reset: boolean;
};
export const idleInput = (): Input => ({
  throttle: 0,
  steer: 0,
  brake: false,
  reset: false,
});
export type Driver = { id: string; name: string; car: number; ai: boolean };
export type Body = Driver & {
  x: number;
  y: number;
  z: number;
  vx: number;
  vy: number;
  vz: number;
  angle: number;
  spin: number;
  steering: number;
  handbrake: number;
  grounded: boolean;
  progress: number;
  lastDistance: number;
  safeDistance: number;
  lap: number;
  finished: number;
  falls: number;
  respawn: number;
  boost: number;
  pitch: number;
  roll: number;
  ack: number;
  nextGate: number;
  stuck: number;
};
export type World = {
  tick: number;
  time: number;
  countdown: number;
  bodies: Body[];
  ended: boolean;
};
export function spawn(driver: Driver, road: Road, index: number): Body {
  const narrowGrid = Array.from({ length: 6 }, (_, i) =>
    road.at(-3 - i * 3.5),
  ).some((p) => p.width - (p.wall ? p.wallThickness : 0) < 5.2);
  const d = -3 - (narrowGrid ? index : Math.floor(index / 2)) * 3.5,
    p = road.at(d),
    side = narrowGrid
      ? 0
      : (index % 2 ? 1 : -1) *
        Math.max(
          0,
          Math.min(
            1.65,
            p.width / 2 - (p.wall ? p.wallThickness / 2 : 0) - 0.9,
          ),
        );
  return {
    ...driver,
    ...roadEdge(p, side),
    z: roadEdge(p, side).z + 0.65,
    vx: 0,
    vy: 0,
    vz: 0,
    angle: Math.atan2(p.ty, p.tx),
    spin: 0,
    steering: 0,
    handbrake: 0,
    grounded: true,
    progress: d,
    lastDistance: road.length + d,
    safeDistance: d,
    lap: 0,
    finished: 0,
    falls: 0,
    respawn: 0,
    boost: 0,
    pitch: Math.atan(p.slope),
    roll: 0,
    ack: 0,
    nextGate: 0,
    stuck: 0,
  };
}
export function createWorld(drivers: Driver[], road: Road): World {
  return {
    tick: 0,
    time: 0,
    countdown: 3,
    bodies: drivers.map((d, i) => spawn(d, road, i)),
    ended: false,
  };
}
export function respawnBody(b: Body, road: Road) {
  const p = road.at(b.safeDistance);
  const center = roadEdge(p, 0);
  b.x = center.x;
  b.y = center.y;
  b.z = center.z + 0.8;
  b.vx = p.tx * 4;
  b.vy = p.ty * 4;
  b.vz = p.slope * 4;
  b.angle = Math.atan2(p.ty, p.tx);
  b.spin = 0;
  b.steering = 0;
  b.handbrake = 0;
  b.pitch = Math.atan(p.slope);
  b.roll = 0;
  b.respawn = 1.4;
  b.grounded = true;
  b.lastDistance = p.distance;
  b.falls++;
  b.stuck = 0;
}
// Surface gradient expressed in the chassis frame. Travel direction is irrelevant:
// reversing and standing still use exactly the same road plane.
export function surfacePose(
  p: { tx: number; ty: number; slope: number },
  angle: number,
) {
  const c = Math.cos(angle),
    s = Math.sin(angle);
  const longitudinal = p.slope * (p.tx * c + p.ty * s);
  const lateral = p.slope * (-p.tx * s + p.ty * c);
  return {
    pitch: Math.atan(longitudinal),
    roll: Math.atan(lateral / Math.sqrt(1 + longitudinal ** 2)),
  };
}
export function slipAngle(b: Body) {
  const forward = b.vx * Math.cos(b.angle) + b.vy * Math.sin(b.angle);
  const sideways = -b.vx * Math.sin(b.angle) + b.vy * Math.cos(b.angle);
  return Math.atan2(sideways, Math.max(2, Math.abs(forward)));
}
function driveTires(b: Body, input: Input, dt: number) {
  const car = cars[b.car],
    c = Math.cos(b.angle),
    s = Math.sin(b.angle);
  const forward = b.vx * c + b.vy * s;
  const speed = Math.hypot(b.vx, b.vy);
  // Steering takes time and has less lock at speed, but never rotates the body directly.
  const lock = (car.steer * 0.25) / (1 + speed * 0.055);
  b.steering += clamp(input.steer * lock - b.steering, -2.6 * dt, 2.6 * dt);
  b.handbrake +=
    ((input.brake ? 1 : 0) - b.handbrake) *
    Math.min(1, dt * (input.brake ? 14 : 6));
  const braking = input.throttle * forward < -0.5;
  let drive = braking
    ? input.throttle * 25
    : input.throttle *
      car.accel *
      Math.max(0, 1 - (Math.abs(forward) / car.speed) ** 2) *
      (input.throttle < 0 ? 0.55 : 1);
  drive -=
    Math.sign(forward) * Math.min(Math.abs(forward) / dt, b.handbrake * 4.5);
  let fx = c * drive,
    fy = s * drive,
    torque = 0;
  for (const axle of [-0.83, 0.83]) {
    const wheelAngle = b.angle + (axle > 0 ? b.steering : 0);
    const wc = Math.cos(wheelAngle),
      ws = Math.sin(wheelAngle);
    const vx = b.vx - b.spin * s * axle,
      vy = b.vy + b.spin * c * axle;
    const longitudinal = vx * wc + vy * ws,
      sideways = -vx * ws + vy * wc;
    const slip = Math.atan2(sideways, Math.max(2.5, Math.abs(longitudinal)));
    // Rear brake reduces rear traction progressively. The front still steers;
    // releasing it lets the tires catch the slide rather than snapping velocity.
    const grip = car.grip * 2.25 * (axle < 0 ? 1 - b.handbrake * 0.76 : 1);
    const available = Math.sqrt(
      Math.max(grip * grip - drive * drive * 0.12, grip * grip * 0.35),
    );
    const force = -clamp(slip * 72, -available, available) * car.mass * 0.5;
    fx += (-ws * force) / car.mass;
    fy += (wc * force) / car.mass;
    torque += axle * (c * wc + s * ws) * force;
  }
  b.vx += fx * dt;
  b.vy += fy * dt;
  const drag = 0.16 + speed * 0.003;
  b.vx *= Math.exp(-drag * dt);
  b.vy *= Math.exp(-drag * dt);
  b.spin += (torque / yawInertia(car.mass)) * dt;
  b.spin *= Math.exp(-0.35 * dt);
  return { longitudinal: fx * c + fy * s, lateral: -fx * s + fy * c };
}
export function advanceBody(b: Body, input: Input, road: Road, dt = DT) {
  if (b.finished) input = { ...idleInput(), throttle: 0.15 };
  b.respawn = Math.max(0, b.respawn - dt);
  if (input.reset && b.respawn === 0) {
    respawnBody(b, road);
    return;
  }
  b.stuck = Math.hypot(b.vx, b.vy) < 2 ? b.stuck + dt : 0;
  const car = cars[b.car],
    p = road.nearest(b.x, b.y, b.z - 0.65),
    speed = Math.hypot(b.vx, b.vy);
  const surface = road.deck.at(b.x, b.y, b.z - 0.65);
  const onRoad = surface !== null,
    height = surface?.z ?? p.z;
  const gx = surface?.gx ?? p.tx * p.slope,
    gy = surface?.gy ?? p.ty * p.slope;
  const roadVz = gx * b.vx + gy * b.vy;
  const relativeVz = b.vz - roadVz;
  b.grounded =
    onRoad && b.z >= height - 0.5 && b.z <= height + 1.05 && relativeVz < 4;
  if (b.grounded) {
    // Damping is relative to the rising/falling road, not to world zero.
    // A sufficiently fast crest outruns the suspension and becomes a real jump.
    const spring = (height + 0.65 - b.z) * 180 - relativeVz * 20;
    b.vz += clamp(spring, -18, 90) * dt;
    const forces = driveTires(b, input, dt);
    b.vx -= ((gx * 9.81) / (1 + gx * gx + gy * gy)) * dt;
    b.vy -= ((gy * 9.81) / (1 + gx * gx + gy * gy)) * dt;
    const pose = surfacePose({ tx: gx, ty: gy, slope: 1 }, b.angle);
    b.pitch +=
      (pose.pitch +
        clamp(forces.longitudinal * 0.0025, -0.05, 0.05) -
        b.pitch) *
      Math.min(1, dt * 16);
    b.roll +=
      (pose.roll + clamp(forces.lateral * 0.004, -0.08, 0.08) - b.roll) *
      Math.min(1, dt * 12);
  } else {
    b.vz -= 18 * dt;
    b.spin *= Math.exp(-0.12 * dt);
    b.pitch +=
      (clamp(Math.atan2(b.vz, Math.max(5, speed)), -0.8, 0.8) - b.pitch) *
      dt *
      1.8;
    b.roll *= Math.exp(-dt);
  }
  b.angle = wrapAngle(b.angle + b.spin * dt);
  b.x += b.vx * dt;
  b.y += b.vy * dt;
  b.z += b.vz * dt;
  // Two rounded chassis ends contact walls. Torque comes from the actual
  // lever arm, never from a guessed left/right track direction.
  for (const axle of [-0.6, 0.6]) {
    const rx = Math.cos(b.angle) * axle,
      ry = Math.sin(b.angle) * axle;
    const contact = road.wallContact(b.x + rx, b.y + ry, b.z, 0.72);
    if (!contact) continue;
    const { nx, ny, depth } = contact;
    b.x += nx * depth;
    b.y += ny * depth;
    const vn = (b.vx - b.spin * ry) * nx + (b.vy + b.spin * rx) * ny;
    if (vn < 0) {
      const lever = rx * ny - ry * nx,
        inertia = yawInertia(car.mass);
      const impulse = (-1.25 * vn) / (1 / car.mass + (lever * lever) / inertia);
      b.vx += (nx * impulse) / car.mass;
      b.vy += (ny * impulse) / car.mass;
      b.spin += (lever * impulse) / inertia;
    }
  }
  if (
    b.z < -5 ||
    b.x < road.bounds.minX - 24 ||
    b.x > road.bounds.maxX + 24 ||
    b.y < road.bounds.minY - 24 ||
    b.y > road.bounds.maxY + 24
  ) {
    respawnBody(b, road);
    return;
  }
  if (onRoad && b.grounded) {
    let delta = p.distance - b.lastDistance;
    if (delta > road.length / 2) delta -= road.length;
    if (delta < -road.length / 2) delta += road.length;
    if (Math.abs(delta) < 4) b.progress += delta;
    b.lastDistance = p.distance;
    // Ordered quarter-track gates prohibit jumping across the course to gain laps.
    if (
      b.progress >= (b.nextGate * road.length) / 4 &&
      b.progress < (b.nextGate * road.length) / 4 + road.length * 0.08
    )
      b.nextGate++;
    b.lap = Math.max(0, Math.floor((b.nextGate - 1) / 4));
    if (
      b.progress >= b.safeDistance &&
      Math.abs(p.offset) <
        p.width / 2 - (p.wall ? p.wallThickness / 2 : 0) - 1.15
    )
      b.safeDistance = p.distance;
  }
}
export function collide(a: Body, b: Body) {
  if (a.respawn > 0 || b.respawn > 0 || Math.abs(a.z - b.z) > 1.4) return;
  resolveCars(a, b, cars[a.car].mass, cars[b.car].mass);
}

export function aiInput(b: Body, road: Road, index: number): Input {
  const p = road.nearest(b.x, b.y, b.z - 0.65),
    speed = Math.hypot(b.vx, b.vy);
  const look = 5 + speed * 0.48,
    ahead = road.at(p.distance + look);
  const lane =
    Math.sin(index * 2.7) *
    (ahead.width - (ahead.wall ? ahead.wallThickness : 0)) *
    0.1;
  const aim = roadEdge(ahead, lane);
  const target = Math.atan2(aim.y - b.y, aim.x - b.x);
  const error = wrapAngle(target - b.angle);
  const far = road.at(p.distance + look + 6);
  const bend =
    Math.abs(wrapAngle(Math.atan2(far.ty, far.tx) - Math.atan2(p.ty, p.tx))) /
    (look + 6);
  const desired = Math.min(
    cars[b.car].speed * 0.82,
    Math.sqrt(11 / Math.max(0.025, bend)),
  );
  return {
    throttle: clamp((desired - speed) * 0.6, -1, 1),
    steer: clamp(error * 2.6 - b.spin * 0.25 - slipAngle(b) * 0.4, -1, 1),
    brake: false,
    reset: b.stuck > 2.5,
  };
}

export function stepWorld(
  w: World,
  road: Road,
  inputs: Map<string, Input>,
  remoteStep?: (b: Body) => boolean,
) {
  w.tick++;
  if (w.countdown > 0) {
    w.countdown = Math.max(0, w.countdown - DT);
    return;
  }
  if (w.ended) return;
  w.time += DT;
  w.bodies.forEach((b, i) => {
    if (!b.ai && remoteStep?.(b)) return;
    advanceBody(
      b,
      b.ai ? aiInput(b, road, i) : inputs.get(b.id) || idleInput(),
      road,
    );
  });
  for (let i = 0; i < w.bodies.length; i++)
    for (let j = i + 1; j < w.bodies.length; j++)
      collide(w.bodies[i], w.bodies[j]);
  for (const b of w.bodies) if (b.lap >= 3 && !b.finished) b.finished = w.time;
  w.ended =
    w.bodies.filter((b) => !b.ai).every((b) => b.finished > 0) || w.time > 300;
}
export function ranking(w: World): Body[] {
  return [...w.bodies].sort((a, b) =>
    a.finished && b.finished
      ? a.finished - b.finished
      : a.finished
        ? -1
        : b.finished
          ? 1
          : b.progress - a.progress,
  );
}