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 { closeRail, RoadDeck } from './road-surface.js';
export type Point = { x: number; y: number; z: number };
export type Knot = Point & {
width: number;
wall: boolean;
wallThickness: number;
};
export type Track = { version: 3; name: string; points: Knot[] };
export type Sample = Point & {
tx: number;
ty: number;
slope: number;
width: number;
leftEdge: Point;
rightEdge: Point;
leftNormal: Point;
rightNormal: Point;
wall: boolean;
wallThickness: number;
section: number;
u: number;
distance: number;
};
export const clamp = (v: number, lo: number, hi: number) =>
Math.max(lo, Math.min(hi, v));
export const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
export const wrapAngle = (a: number) => Math.atan2(Math.sin(a), Math.cos(a));
export const copyTrack = (t: Track): Track => structuredClone(t);
export const TRACK_HALF_SIZE = 130;
export const PLAN_SPAN = 290;
export const MIN_POINT_WIDTH = 5;
export const MAX_POINT_WIDTH = 20.8;
export const presets: Track[] = [
{
version: 3,
name: 'Pinefall Circuit',
points: [
[-34, -22, 3],
[-8, -32, 3],
[25, -26, 5],
[39, -6, 9],
[24, 9, 12],
[35, 29, 7],
[6, 34, 3],
[-24, 25, 3],
[-39, 8, 5],
[-24, -4, 7],
].map(([x, y, z], i) => ({
x,
y,
z,
width: [11.7, 15.6, 14.3, 10.4, 9.1, 13, 16.9, 13, 10.4, 9.1][i],
wall: ![3, 4, 7].includes(i),
wallThickness: [0.6, 0.6, 1, 0.5, 0.5, 0.8, 0.6, 0.4, 1, 0.8][i],
})),
},
{
version: 3,
name: 'Little Lagoon',
points: [
[-32, -22, 3],
[0, -30, 3],
[32, -22, 3],
[40, 4, 4],
[23, 28, 5],
[-9, 30, 4],
[-36, 18, 3],
].map(([x, y, z], i) => ({
x,
y,
z,
width: [14.3, 18.2, 15.6, 10.4, 9.1, 13, 15.6][i],
wall: ![1, 4].includes(i),
wallThickness: [0.6, 0.4, 0.8, 1.2, 0.5, 0.8, 0.6][i],
})),
},
{
version: 3,
name: 'Skyline Sprint',
points: [
[-38, -24, 3],
[-6, -29, 5],
[31, -25, 12],
[39, 0, 15],
[16, 8, 13],
[29, 28, 5],
[-4, 31, 3],
[-35, 21, 7],
[-28, 0, 11],
].map(([x, y, z], i) => ({
x,
y,
z,
width: [13, 15.6, 11.7, 9.1, 7.8, 11.7, 15.6, 10.4, 9.1][i],
wall: [0, 2, 3, 6].includes(i),
wallThickness: [0.6, 0.4, 1.2, 1, 0.5, 0.4, 0.8, 0.5, 0.6][i],
})),
},
{
version: 3,
name: 'Coastal Comet',
points: [
[-94, -58, 3, 15.6],
[-48, -86, 3, 19.5],
[20, -90, 3, 18.2],
[85, -70, 4, 14.3],
[110, -32, 5, 11.7],
[98, 7, 6, 10.4],
[66, 25, 7, 13],
[32, 18, 8, 10.4],
[12, 48, 7, 11.7],
[36, 79, 4, 13],
[-12, 97, 3, 18.2],
[-70, 81, 3, 15.6],
[-106, 42, 4, 11.7],
[-110, -9, 4, 13],
].map(([x, y, z, width], i) => ({
x,
y,
z,
width,
wall: ![1, 2, 7, 10].includes(i),
wallThickness: i % 4 === 0 ? 1.1 : 0.6,
})),
},
{
version: 3,
name: 'Switchback Summit',
points: [
[-108, -80, 3, 15.6],
[-68, -92, 3, 18.2],
[-10, -83, 5, 14.3],
[56, -94, 8, 13],
[104, -69, 12, 11.7],
[98, -26, 16, 10.4],
[58, -12, 19, 9.1],
[22, -26, 20, 10.4],
[-15, -34, 18, 11.7],
[-52, -9, 16, 10.4],
[-30, 18, 13, 11.7],
[16, 17, 10, 15.6],
[62, 30, 8, 13],
[78, 65, 6, 10.4],
[43, 98, 4, 11.7],
[-7, 89, 3, 15.6],
[-46, 60, 4, 13],
[-92, 73, 6, 11.7],
[-110, 29, 5, 10.4],
[-92, -24, 3, 14.3],
].map(([x, y, z, width], i) => ({
x,
y,
z,
width,
wall: ![1, 6, 7, 11, 15].includes(i),
wallThickness: i % 3 === 0 ? 1.2 : 0.7,
})),
},
{
version: 3,
name: 'Petal Park',
points: [
[-105, 0, 3, 14.3],
[-100, -40, 3, 15.6],
[-67, -61, 4, 13],
[-31, -44, 5, 9.1],
[-29, -83, 7, 11.7],
[0, -110, 9, 15.6],
[34, -84, 7, 13],
[37, -46, 5, 9.1],
[78, -57, 3, 13],
[107, -29, 3, 16.9],
[101, 13, 4, 13],
[66, 32, 6, 9.1],
[58, 77, 8, 11.7],
[18, 101, 7, 16.9],
[-24, 83, 5, 13],
[-40, 42, 3, 9.1],
[-76, 41, 3, 13],
].map(([x, y, z, width], i) => ({
x,
y,
z,
width,
wall: ![2, 5, 8, 12, 16].includes(i),
wallThickness: i % 4 === 3 ? 1.3 : 0.6,
})),
},
{
version: 3,
name: 'Skybridge Eight',
points: Array.from({ length: 20 }, (_, i) => {
const a = -Math.PI / 2 + (i * Math.PI) / 10;
return {
x: Math.round(108 * Math.sin(a)),
y: Math.round(82 * Math.sin(2 * a)),
z: Math.round((10.5 + 7.5 * Math.cos(a)) * 2) / 2,
width: [
14.3, 15.6, 18.2, 15.6, 13, 11.7, 13, 15.6, 18.2, 15.6, 14.3,
15.6, 16.9, 14.3, 10.4, 10.4, 11.7, 14.3, 16.9, 15.6,
][i],
wall: ![2, 3, 8, 12, 13, 18].includes(i),
wallThickness: i % 5 === 0 ? 1.2 : 0.7,
};
}),
},
];
export function validateTrack(v: unknown): v is Track {
if (!v || typeof v !== 'object') return false;
const t = v as Track;
return (
t.version === 3 &&
typeof t.name === 'string' &&
t.name.length > 0 &&
t.name.length <= 48 &&
Array.isArray(t.points) &&
t.points.length >= 4 &&
t.points.length <= 32 &&
t.points.every(
(p) =>
p &&
[p.x, p.y, p.z, p.width, p.wallThickness].every(Number.isFinite) &&
Math.abs(p.x) <= TRACK_HALF_SIZE &&
Math.abs(p.y) <= TRACK_HALF_SIZE &&
p.z >= 2 &&
p.z <= 24 &&
p.width >= MIN_POINT_WIDTH &&
p.width <= MAX_POINT_WIDTH &&
p.wallThickness >= 0.3 &&
p.wallThickness <= 2 &&
typeof p.wall === 'boolean',
) &&
t.points.every(
(p, i) =>
Math.hypot(
p.x - t.points[(i + 1) % t.points.length].x,
p.y - t.points[(i + 1) % t.points.length].y,
) >= 3,
)
);
}
// Keep the existing storage key: old drafts upgrade in memory and on the next save.
export function decodeTrack(value: unknown): Track | null {
if (validateTrack(value)) return copyTrack(value);
if (!value || typeof value !== 'object') return null;
const old = value as {
version?: number;
name?: unknown;
width?: unknown;
points?: unknown[];
};
if (![1, 2].includes(old.version ?? 0) || !Array.isArray(old.points))
return null;
const migrated = {
version: 3,
name: old.name,
points: old.points.map((p) =>
p && typeof p === 'object' && old.version === 1
? { ...p, width: old.width, wallThickness: 0.6 }
: p,
),
};
// Validate at the old scale before widening; malformed drafts cannot become valid
// through migration. Version 3 marks the one-time 30% increase, including v1 drafts.
if (!validateTrack(migrated) || migrated.points.some((p) => p.width > 16))
return null;
return {
...migrated,
points: migrated.points.map((p) => ({
...p,
width: Math.round(p.width * 1.3 * 1e6) / 1e6,
})),
};
}
export const WALL_HEIGHT = 1.2;
const mixPoint = (a: Point, b: Point, t: number): Point => ({
x: lerp(a.x, b.x, t),
y: lerp(a.y, b.y, t),
z: lerp(a.z, b.z, t),
});
export function roadEdge(s: Sample, offset: number): Point {
const half = s.width / 2;
if (offset > half) return roadBoundary(s, 1, offset - half);
if (offset < -half) return roadBoundary(s, -1, -offset - half);
return mixPoint(s.rightEdge, s.leftEdge, (offset + half) / s.width);
}
export function roadBoundary(s: Sample, side: number, outward = 0): Point {
const p = side > 0 ? s.leftEdge : s.rightEdge,
n = side > 0 ? s.leftNormal : s.rightNormal;
const length = Math.hypot(n.x, n.y) || 1;
return {
x: p.x + (n.x / length) * outward,
y: p.y + (n.y / length) * outward,
z: p.z,
};
}
export type WallPiece = {
corners: [Point, Point, Point, Point];
startCap: boolean;
endCap: boolean;
minX: number;
maxX: number;
minY: number;
maxY: number;
};
type WallEdge = { a: Point; b: Point; nx: number; ny: number };
function projection(x: number, y: number, a: Point, b: Point) {
return clamp(
((x - a.x) * (b.x - a.x) + (y - a.y) * (b.y - a.y)) /
((b.x - a.x) ** 2 + (b.y - a.y) ** 2 || 1),
0,
1,
);
}
function cubic(a: number, b: number, c: number, d: number, t: number): number {
return (
0.5 *
(2 * b +
(-a + c) * t +
(2 * a - 5 * b + 4 * c - d) * t * t +
(-a + 3 * b - 3 * c + d) * t * t * t)
);
}
export function spline(track: Track, u: number): Point {
const n = track.points.length,
s = ((u % n) + n) % n,
i = Math.floor(s),
t = s - i;
const a = track.points[(i + n - 1) % n],
b = track.points[i],
c = track.points[(i + 1) % n],
d = track.points[(i + 2) % n];
return {
x: cubic(a.x, b.x, c.x, d.x, t),
y: cubic(a.y, b.y, c.y, d.y, t),
z: cubic(a.z, b.z, c.z, d.z, t),
};
}
export class Road {
samples: Sample[] = [];
length = 0;
closedSections = new Set<number>();
deck: RoadDeck;
bounds = { minX: 0, maxX: 0, minY: 0, maxY: 0, maxZ: 0 };
walls: WallPiece[] = [];
private wallEdges: WallEdge[] = [];
private wallBuckets = new Map<
string,
{ walls: WallPiece[]; edges: WallEdge[] }
>();
constructor(public track: Track) {
// Keep section boundaries and half-points exact; add detail where long
// segments or fast-changing tangents need it. Both peers sample identically.
const tangent = (u: number) => {
const q = spline(track, u + 0.0005),
r = spline(track, u - 0.0005);
return Math.atan2(q.y - r.y, q.x - r.x);
};
const parameters = [0];
const subdivide = (a: number, b: number, depth: number) => {
const p = spline(track, a),
q = spline(track, b),
mid = (a + b) / 2;
if (
depth < 3 &&
(Math.hypot(q.x - p.x, q.y - p.y) > 1.5 ||
Math.abs(wrapAngle(tangent(mid) - tangent(a))) > 0.045 ||
Math.abs(wrapAngle(tangent(b) - tangent(mid))) > 0.045)
) {
subdivide(a, mid, depth + 1);
subdivide(mid, b, depth + 1);
} else parameters.push(b);
};
for (let i = 0; i < track.points.length * 28; i++)
subdivide(i / 28, (i + 1) / 28, 0);
for (const u of parameters) {
const p = spline(track, u),
q = spline(track, u + 0.0005),
r = spline(track, u - 0.0005);
const dl = Math.hypot(q.x - r.x, q.y - r.y) || 1;
const previous = this.samples.at(-1);
if (previous)
this.length += Math.hypot(p.x - previous.x, p.y - previous.y);
const section = Math.floor(u) % track.points.length,
a = track.points[section],
b = track.points[(section + 1) % track.points.length],
t = u - Math.floor(u),
width = lerp(a.width, b.width, t * t * (3 - 2 * t));
this.samples.push({
...p,
tx: (q.x - r.x) / dl,
ty: (q.y - r.y) / dl,
slope: (q.z - r.z) / dl,
width,
leftEdge: {
x: p.x - (((q.y - r.y) / dl) * width) / 2,
y: p.y + (((q.x - r.x) / dl) * width) / 2,
z: p.z,
},
rightEdge: {
x: p.x + (((q.y - r.y) / dl) * width) / 2,
y: p.y - (((q.x - r.x) / dl) * width) / 2,
z: p.z,
},
leftNormal: { x: -(q.y - r.y) / dl, y: (q.x - r.x) / dl, z: 0 },
rightNormal: { x: (q.y - r.y) / dl, y: -(q.x - r.x) / dl, z: 0 },
wall: a.wall,
wallThickness: a.wallThickness,
section,
u,
distance: this.length,
});
}
const n = this.samples.length - 1;
for (const side of [-1, 1]) {
const { rail, sections } = closeRail(this.samples, side);
for (const section of sections) this.closedSections.add(section);
for (let i = 0; i < n; i++) {
const previous = rail[(i + n - 1) % n],
next = rail[(i + 1) % n],
dx = next.x - previous.x,
dy = next.y - previous.y,
length = Math.hypot(dx, dy) || 1;
const sample = this.samples[i],
normal = { x: (-dy / length) * side, y: (dx / length) * side, z: 0 };
if (side > 0) {
sample.leftEdge = rail[i];
sample.leftNormal = normal;
} else {
sample.rightEdge = rail[i];
sample.rightNormal = normal;
}
}
}
Object.assign(this.samples[n], this.samples[0], {
u: track.points.length,
distance: this.length,
});
this.deck = new RoadDeck(this.samples);
const edges = this.samples.flatMap((s) => [
roadBoundary(s, -1, 1),
roadBoundary(s, 1, 1),
]);
this.bounds = {
minX: Math.min(...edges.map((p) => p.x)),
maxX: Math.max(...edges.map((p) => p.x)),
minY: Math.min(...edges.map((p) => p.y)),
maxY: Math.max(...edges.map((p) => p.y)),
maxZ: Math.max(...this.samples.map((p) => p.z)),
};
// Geometry and physics consume the same wall strips and exposed boundaries.
const ss = this.samples,
countEdges = ss.length - 1;
const addEdge = (a: Point, b: Point, inside: Point) => {
const length = Math.hypot(b.x - a.x, b.y - a.y) || 1;
let nx = -(b.y - a.y) / length,
ny = (b.x - a.x) / length;
if ((inside.x - a.x) * nx + (inside.y - a.y) * ny > 0) {
nx *= -1;
ny *= -1;
}
this.wallEdges.push({ a, b, nx, ny });
};
for (let i = 0; i < countEdges; i++) {
const a = ss[i],
b = ss[i + 1],
prev = ss[(i + countEdges - 1) % countEdges],
next = ss[(i + 1) % countEdges];
if (!a.wall) continue;
for (const side of [-1, 1]) {
const corner = (s: Sample, outer: number) =>
roadBoundary(s, side, (outer * a.wallThickness) / 2);
const corners: WallPiece['corners'] = [
corner(a, -1),
corner(b, -1),
corner(b, 1),
corner(a, 1),
];
const startCap = !prev.wall || prev.wallThickness !== a.wallThickness,
endCap = !next.wall || next.wallThickness !== a.wallThickness;
this.walls.push({
corners,
startCap,
endCap,
minX: Math.min(...corners.map((p) => p.x)),
maxX: Math.max(...corners.map((p) => p.x)),
minY: Math.min(...corners.map((p) => p.y)),
maxY: Math.max(...corners.map((p) => p.y)),
});
const center = {
x: (corners[0].x + corners[2].x) / 2,
y: (corners[0].y + corners[2].y) / 2,
z: 0,
};
addEdge(corners[0], corners[1], center);
addEdge(corners[2], corners[3], center);
// Only exposed steps are collidable when two walled sections meet.
for (const [s, neighbor, inner, outer] of [
[a, prev, corners[0], corners[3]],
[b, next, corners[1], corners[2]],
] as const) {
if (!neighbor.wall) addEdge(inner, outer, center);
else if (a.wallThickness > neighbor.wallThickness) {
addEdge(
inner,
roadBoundary(s, side, -neighbor.wallThickness / 2),
center,
);
addEdge(
roadBoundary(s, side, neighbor.wallThickness / 2),
outer,
center,
);
}
}
}
}
// Static broad phase: larger circuits should not scan every distant wall
// for each bumper, every physics tick. Preserve insertion order per cell.
const bucket = (
minX: number,
maxX: number,
minY: number,
maxY: number,
add: (b: { walls: WallPiece[]; edges: WallEdge[] }) => void,
) => {
for (
let x = Math.floor((minX - 2) / 8);
x <= Math.floor((maxX + 2) / 8);
x++
)
for (
let y = Math.floor((minY - 2) / 8);
y <= Math.floor((maxY + 2) / 8);
y++
) {
const key = `${x},${y}`;
let b = this.wallBuckets.get(key);
if (!b) {
b = { walls: [], edges: [] };
this.wallBuckets.set(key, b);
}
add(b);
}
};
for (const w of this.walls)
bucket(w.minX, w.maxX, w.minY, w.maxY, (b) => b.walls.push(w));
for (const e of this.wallEdges)
bucket(
Math.min(e.a.x, e.b.x),
Math.max(e.a.x, e.b.x),
Math.min(e.a.y, e.b.y),
Math.max(e.a.y, e.b.y),
(b) => b.edges.push(e),
);
}
wallContact(x: number, y: number, z: number, radius = 0.65) {
const bucket = this.wallBuckets.get(
`${Math.floor(x / 8)},${Math.floor(y / 8)}`,
);
const nearbyWalls = radius <= 2 ? (bucket?.walls ?? []) : this.walls;
const nearbyEdges = radius <= 2 ? (bucket?.edges ?? []) : this.wallEdges;
let inside = false;
for (const w of nearbyWalls) {
if (x < w.minX || x > w.maxX || y < w.minY || y > w.maxY) continue;
const [a, b] = w.corners,
t = projection(x, y, a, b),
height = lerp(a.z, b.z, t);
if (z < height - 0.3 || z > height + WALL_HEIGHT + 0.65) continue;
const signs = w.corners.map((p, i) => {
const q = w.corners[(i + 1) % 4];
return (q.x - p.x) * (y - p.y) - (q.y - p.y) * (x - p.x);
});
if (signs.every((v) => v >= -1e-8) || signs.every((v) => v <= 1e-8))
inside = true;
}
let best = Infinity,
contact: { nx: number; ny: number; depth: number } | null = null;
for (const { a, b, nx, ny } of nearbyEdges) {
if (
!inside &&
(x < Math.min(a.x, b.x) - radius ||
x > Math.max(a.x, b.x) + radius ||
y < Math.min(a.y, b.y) - radius ||
y > Math.max(a.y, b.y) + radius)
)
continue;
const t = projection(x, y, a, b),
height = lerp(a.z, b.z, t);
if (z < height - 0.3 || z > height + WALL_HEIGHT + 0.65) continue;
const dx = x - lerp(a.x, b.x, t),
dy = y - lerp(a.y, b.y, t),
d = Math.hypot(dx, dy);
if (d >= best || (!inside && d >= radius)) continue;
best = d;
contact = {
nx: inside || d < 1e-8 ? nx : dx / d,
ny: inside || d < 1e-8 ? ny : dy / d,
depth: inside ? radius + d : radius - d,
};
}
return contact;
}
nearest(x: number, y: number, z?: number) {
let best = Infinity,
index = 0,
t = 0;
for (let i = 0; i < this.samples.length - 1; i++) {
const a = this.samples[i],
b = this.samples[i + 1],
dx = b.x - a.x,
dy = b.y - a.y;
const u = clamp(
((x - a.x) * dx + (y - a.y) * dy) / (dx * dx + dy * dy || 1),
0,
1,
);
const d =
(x - lerp(a.x, b.x, u)) ** 2 +
(y - lerp(a.y, b.y, u)) ** 2 +
(z === undefined
? 0
: Math.max(0, Math.abs(z - lerp(a.z, b.z, u)) - 2) ** 2 * 0.6);
if (d < best) {
best = d;
index = i;
t = u;
}
}
const a = this.samples[index],
b = this.samples[index + 1];
const p = {
...a,
x: lerp(a.x, b.x, t),
y: lerp(a.y, b.y, t),
z: lerp(a.z, b.z, t),
tx: Math.cos(
Math.atan2(a.ty, a.tx) +
wrapAngle(Math.atan2(b.ty, b.tx) - Math.atan2(a.ty, a.tx)) * t,
),
ty: Math.sin(
Math.atan2(a.ty, a.tx) +
wrapAngle(Math.atan2(b.ty, b.tx) - Math.atan2(a.ty, a.tx)) * t,
),
slope: lerp(a.slope, b.slope, t),
width: lerp(a.width, b.width, t),
leftEdge: mixPoint(a.leftEdge, b.leftEdge, t),
rightEdge: mixPoint(a.rightEdge, b.rightEdge, t),
leftNormal: mixPoint(a.leftNormal, b.leftNormal, t),
rightNormal: mixPoint(a.rightNormal, b.rightNormal, t),
u: lerp(a.u, b.u, t),
distance: lerp(a.distance, b.distance, t),
};
return { ...p, index, offset: (x - p.x) * -p.ty + (y - p.y) * p.tx };
}
at(distance: number): Sample {
const d = ((distance % this.length) + this.length) % this.length;
let lo = 0,
hi = this.samples.length - 1;
while (lo + 1 < hi) {
const mid = (lo + hi) >> 1;
if (this.samples[mid].distance < d) lo = mid;
else hi = mid;
}
const a = this.samples[lo],
b = this.samples[hi],
t = (d - a.distance) / (b.distance - a.distance || 1);
return {
...a,
x: lerp(a.x, b.x, t),
y: lerp(a.y, b.y, t),
z: lerp(a.z, b.z, t),
tx: Math.cos(
Math.atan2(a.ty, a.tx) +
wrapAngle(Math.atan2(b.ty, b.tx) - Math.atan2(a.ty, a.tx)) * t,
),
ty: Math.sin(
Math.atan2(a.ty, a.tx) +
wrapAngle(Math.atan2(b.ty, b.tx) - Math.atan2(a.ty, a.tx)) * t,
),
slope: lerp(a.slope, b.slope, t),
width: lerp(a.width, b.width, t),
leftEdge: mixPoint(a.leftEdge, b.leftEdge, t),
rightEdge: mixPoint(a.rightEdge, b.rightEdge, t),
leftNormal: mixPoint(a.leftNormal, b.leftNormal, t),
rightNormal: mixPoint(a.rightNormal, b.rightNormal, t),
u: lerp(a.u, b.u, t),
distance: d,
};
}
}
