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