SOURCE / PINNED RELEASE
Made of little things.
Mini Samurai
- Release
- a7beb798adbc…
- Author-recorded commit
- 4f97be1515b8…
- License
- LICENSE
- Author’s source reference
- nostr://npub12lm3rxsva8kfempyjkxupt09e02n3fezurkyadyqfejdc5e0r2kscqty6r/wss%3A%2F%2Fgit.napplet.soy%2F/n-3b28f0efc5b
Archive hash verified: 401ec9347ab8f8cd…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import * as THREE from 'three';
import { Body, type BodyPart, type Mover, type MoverEvents } from './movers.js';
import type { TrackDef } from './types.js';
/**
* The toy train in the house: a wind-up engine and three cars running round a loop
* of track on the floor. Each car is a moving Body, so samurai can jump on, ride and
* hang off its edges. Nothing can knock the train off its track: it only ever
* follows the loop.
*/
// ---------------------------------------------------------------------------
// Track path
type Segment =
| { kind: 'line'; x: number; z: number; dx: number; dz: number; len: number }
| { kind: 'arc'; cx: number; cz: number; a0: number; r: number; sweep: 1 | -1; len: number };
export class TrackPath {
readonly segments: Segment[] = [];
readonly length: number;
/** A loop through `points` (square corners, in order), each corner rounded with radius `r`. */
constructor(t: TrackDef) {
const pts = t.points;
const r = t.r;
const n = pts.length;
const dirOf = (a: [number, number], b: [number, number]): [number, number] => {
const len = Math.hypot(b[0] - a[0], b[1] - a[1]);
return [(b[0] - a[0]) / len, (b[1] - a[1]) / len];
};
// Each corner: the bend from the incoming straight to the outgoing one.
const corners = pts.map((p, i) => {
const din = dirOf(pts[(i + n - 1) % n]!, p);
const dout = dirOf(p, pts[(i + 1) % n]!);
const sx = p[0] - din[0] * r;
const sz = p[1] - din[1] * r;
const cx = sx + dout[0] * r;
const cz = sz + dout[1] * r;
const sweep: 1 | -1 = din[0] * dout[1] - din[1] * dout[0] > 0 ? 1 : -1;
return { start: [sx, sz], end: [p[0] + dout[0] * r, p[1] + dout[1] * r], cx, cz, sweep, a0: Math.atan2(sz - cz, sx - cx) };
});
for (let i = 0; i < n; i++) {
const c = corners[i]!;
const next = corners[(i + 1) % n]!;
const [ex, ez] = c.end as [number, number];
const [nx, nz] = next.start as [number, number];
const len = Math.hypot(nx - ex, nz - ez);
this.segments.push({ kind: 'arc', cx: c.cx, cz: c.cz, a0: c.a0, r, sweep: c.sweep, len: (r * Math.PI) / 2 });
if (len > 1e-6) this.segments.push({ kind: 'line', x: ex, z: ez, dx: (nx - ex) / len, dz: (nz - ez) / len, len });
}
this.length = this.segments.reduce((t, seg) => t + seg.len, 0);
}
/** Point at distance `s` along the loop (wrapping), with the direction of travel in `dir`. */
at(s: number, out: THREE.Vector3, dir?: THREE.Vector3): THREE.Vector3 {
let d = ((s % this.length) + this.length) % this.length;
for (const seg of this.segments) {
if (d > seg.len) {
d -= seg.len;
continue;
}
if (seg.kind === 'line') {
out.set(seg.x + seg.dx * d, 0, seg.z + seg.dz * d);
dir?.set(seg.dx, 0, seg.dz);
} else {
const a = seg.a0 + (seg.sweep * d) / seg.r;
out.set(seg.cx + Math.cos(a) * seg.r, 0, seg.cz + Math.sin(a) * seg.r);
dir?.set(-Math.sin(a) * seg.sweep, 0, Math.cos(a) * seg.sweep);
}
return out;
}
return out.set(0, 0, 0);
}
}
// ---------------------------------------------------------------------------
// Meshes
const GEO = {
box: new THREE.BoxGeometry(1, 1, 1),
cyl: new THREE.CylinderGeometry(0.5, 0.5, 1, 12),
ball: new THREE.IcosahedronGeometry(0.5, 1),
puff: new THREE.SphereGeometry(0.5, 10, 8),
};
const MATS = new Map<string, THREE.MeshStandardMaterial>();
function mat(color: number, emissive = 0, metal = 0): THREE.MeshStandardMaterial {
const key = `${color}:${emissive}:${metal}`;
let m = MATS.get(key);
if (!m) {
m = new THREE.MeshStandardMaterial({ color, emissive, emissiveIntensity: emissive ? 0.9 : 0, flatShading: true, roughness: metal ? 0.45 : 0.75, metalness: metal });
MATS.set(key, m);
}
return m;
}
/** Box mesh filling (x0..x1, y0..y1, z0..z1) in the parent's space. */
function box(parent: THREE.Object3D, x0: number, y0: number, z0: number, x1: number, y1: number, z1: number, color: number, emissive = 0, metal = 0): THREE.Mesh {
const m = new THREE.Mesh(GEO.box, mat(color, emissive, metal));
m.position.set((x0 + x1) / 2, (y0 + y1) / 2, (z0 + z1) / 2);
m.scale.set(x1 - x0, y1 - y0, z1 - z0);
m.castShadow = true;
m.receiveShadow = true;
parent.add(m);
return m;
}
/** Cylinder lying along Z (a boiler or a band round it). */
function tube(parent: THREE.Object3D, y: number, z0: number, z1: number, r: number, color: number, metal = 0): THREE.Mesh {
const m = new THREE.Mesh(GEO.cyl, mat(color, 0, metal));
m.rotation.x = Math.PI / 2;
m.position.set(0, y, (z0 + z1) / 2);
m.scale.set(r * 2, z1 - z0, r * 2);
m.castShadow = true;
m.receiveShadow = true;
parent.add(m);
return m;
}
/** Upright cylinder (chimney, dome). */
function post(parent: THREE.Object3D, x: number, y0: number, z: number, r: number, h: number, color: number, metal = 0): THREE.Mesh {
const m = new THREE.Mesh(GEO.cyl, mat(color, 0, metal));
m.position.set(x, y0 + h / 2, z);
m.scale.set(r * 2, h, r * 2);
m.castShadow = true;
parent.add(m);
return m;
}
const RED = 0xc8323c;
const RED_D = 0x8f1f28;
const BLACK = 0x26282b;
const GOLD = 0xe0b23a;
const STEEL = 0xb8bec6;
/** Spinning wheel on one side of a car: a disc with a hub and a crank pin, so the turning shows. */
function wheel(parent: THREE.Object3D, side: number, z: number, r: number, color: number): THREE.Object3D {
const pivot = new THREE.Group();
pivot.position.set(side * 0.9, r + 0.02, z);
parent.add(pivot);
const disc = new THREE.Mesh(GEO.cyl, mat(color));
disc.rotation.z = Math.PI / 2;
disc.scale.set(r * 2, 0.16, r * 2);
disc.castShadow = true;
pivot.add(disc);
const hub = new THREE.Mesh(GEO.cyl, mat(GOLD, 0, 0.6));
hub.rotation.z = Math.PI / 2;
hub.position.x = side * 0.07;
hub.scale.set(r * 0.7, 0.08, r * 0.7);
pivot.add(hub);
const pin = new THREE.Mesh(GEO.box, mat(GOLD, 0, 0.6));
pin.position.set(side * 0.1, r * 0.55, 0);
pin.scale.set(0.06, r * 0.25, r * 0.25);
pivot.add(pin);
return pivot;
}
/** Hook-and-loop coupling sticking out of one end of a car. */
function coupler(parent: THREE.Object3D, z: number, dir: number): void {
box(parent, -0.08, 0.38, Math.min(z, z + dir * 0.32), 0.08, 0.5, Math.max(z, z + dir * 0.32), BLACK, 0, 0.3);
}
// ---------------------------------------------------------------------------
// Cars
export interface Car {
readonly body: Body;
readonly group: THREE.Group;
readonly halfL: number;
/** Distance of the car's centre behind the front of the engine. */
readonly offset: number;
readonly wheels: { pivot: THREE.Object3D; r: number }[];
}
interface CarDef {
halfL: number;
parts: BodyPart[];
build(g: THREE.Group, wheels: Car['wheels']): void;
}
const ENGINE: CarDef = {
halfL: 2.5,
parts: [
{ z0: -0.7, z1: 2.45, hw: 0.95, top: 1.2, edges: ['left', 'right', 'front'] },
{ z0: -2.45, z1: -0.7, hw: 1.05, top: 1.95, edges: ['left', 'right'] },
],
build(g, wheels) {
box(g, -0.85, 0.3, -2.4, 0.85, 0.55, 2.3, BLACK);
box(g, -1, 0.28, 2.3, 1, 0.62, 2.45, RED_D);
box(g, -0.75, 0.04, 2.45, 0.75, 0.34, 2.6, GOLD, 0, 0.5);
// Boiler between two saddle tanks, banded in brass, with a dark smokebox at the front.
tube(g, 0.7, -0.7, 2.15, 0.52, RED);
tube(g, 0.7, 2.15, 2.4, 0.48, BLACK);
for (const z of [0.25, 1.3]) tube(g, 0.7, z - 0.05, z + 0.05, 0.55, GOLD, 0.6);
for (const s of [-1, 1]) box(g, s > 0 ? 0.45 : -0.95, 0.55, -0.7, s > 0 ? 0.95 : -0.45, 1.18, 1.55, RED);
for (const s of [-1, 1]) box(g, s > 0 ? 0.45 : -0.97, 1.12, -0.7, s > 0 ? 0.97 : -0.45, 1.2, 1.55, GOLD, 0, 0.5);
post(g, 0, 1.1, 1.85, 0.17, 0.62, BLACK);
post(g, 0, 1.68, 1.85, 0.26, 0.14, BLACK);
const dome = new THREE.Mesh(GEO.ball, mat(GOLD, 0, 0.6));
dome.position.set(0, 1.18, 0.75);
dome.scale.set(0.5, 0.42, 0.5);
g.add(dome);
box(g, -0.16, 1.02, 2.38, 0.16, 1.26, 2.5, 0xfff0b0, 0xffd27a);
// Cab with windows and an overhanging roof.
box(g, -1, 0.55, -2.4, 1, 1.85, -0.7, RED);
for (const s of [-1, 1]) box(g, s * 1.0 - 0.02, 1.2, -2.0, s * 1.0 + 0.02, 1.6, -1.15, 0x2b3d4a, 0x0c1b26);
for (const s of [-1, 1]) box(g, s * 0.68 - 0.18, 1.3, -0.72, s * 0.68 + 0.18, 1.62, -0.66, 0x2b3d4a, 0x0c1b26);
box(g, -1.1, 1.85, -2.55, 1.1, 1.97, -0.58, BLACK);
// Big driving wheels joined by a coupling rod, a small leading pair.
for (const s of [-1, 1]) {
for (const z of [-1.55, -0.45, 0.65]) wheels.push({ pivot: wheel(g, s, z, 0.4, RED), r: 0.4 });
wheels.push({ pivot: wheel(g, s, 1.85, 0.26, RED), r: 0.26 });
box(g, s * 1.02 - 0.03, 0.36, -1.6, s * 1.02 + 0.03, 0.44, 0.7, STEEL, 0, 0.6);
}
coupler(g, -2.4, -1);
},
};
const TENDER: CarDef = {
halfL: 1.4,
parts: [{ z0: -1.4, z1: 1.4, hw: 0.95, top: 1.15, edges: ['left', 'right'] }],
build(g, wheels) {
box(g, -0.85, 0.3, -1.35, 0.85, 0.45, 1.35, BLACK);
box(g, -0.95, 0.45, -1.4, 0.95, 1.05, 1.4, 0x2f6b4a);
box(g, -0.97, 0.98, -1.42, 0.97, 1.08, 1.42, GOLD, 0, 0.5);
// A heap of coal lumps.
const rnd = [0.31, 0.77, 0.12, 0.58, 0.93, 0.44, 0.69, 0.05, 0.86, 0.23];
for (let i = 0; i < 10; i++) {
const lump = new THREE.Mesh(GEO.ball, mat(0x1d1f24));
lump.position.set((rnd[i]! - 0.5) * 1.3, 0.98, ((rnd[(i + 3) % 10]! - 0.5) * 2.2));
lump.scale.setScalar(0.32 + rnd[(i + 5) % 10]! * 0.18);
lump.rotation.set(i, i * 2, 0);
g.add(lump);
}
for (const s of [-1, 1]) for (const z of [-0.8, 0.8]) wheels.push({ pivot: wheel(g, s, z, 0.3, BLACK), r: 0.3 });
coupler(g, 1.4, 1);
coupler(g, -1.4, -1);
},
};
const WAGON: CarDef = {
halfL: 1.7,
parts: [{ z0: -1.7, z1: 1.7, hw: 0.95, top: 1.0, edges: ['left', 'right'] }],
build(g, wheels) {
const BLUE = 0x3e6ea8;
box(g, -0.85, 0.3, -1.65, 0.85, 0.45, 1.65, BLACK);
box(g, -0.95, 0.45, -1.7, 0.95, 0.55, 1.7, BLUE);
box(g, -0.95, 0.55, -1.7, -0.85, 1.0, 1.7, BLUE);
box(g, 0.85, 0.55, -1.7, 0.95, 1.0, 1.7, BLUE);
box(g, -0.85, 0.55, -1.7, 0.85, 1.0, -1.6, BLUE);
box(g, -0.85, 0.55, 1.6, 0.85, 1.0, 1.7, BLUE);
// Load of wooden toy blocks.
const colors = [0xd8443a, 0xf2c14e, 0x5aa04a, 0xf4ecd6, 0x3e5c8a, 0xe86f8a];
let k = 0;
for (const x of [-0.42, 0.42])
for (const z of [-1.1, -0.35, 0.4, 1.15]) {
const c = box(g, x - 0.3, 0.55, z - 0.3, x + 0.3, 0.98, z + 0.3, colors[k++ % colors.length]!);
c.rotation.y = (k % 3) * 0.2;
}
for (const s of [-1, 1]) for (const z of [-1.05, 1.05]) wheels.push({ pivot: wheel(g, s, z, 0.3, BLACK), r: 0.3 });
coupler(g, 1.7, 1);
coupler(g, -1.7, -1);
},
};
const CABOOSE: CarDef = {
halfL: 1.5,
parts: [{ z0: -1.5, z1: 1.5, hw: 1.0, top: 1.62, edges: ['left', 'right', 'back'] }],
build(g, wheels) {
const YELLOW = 0xf2c14e;
box(g, -0.85, 0.3, -1.45, 0.85, 0.45, 1.45, BLACK);
box(g, -0.95, 0.45, -1.3, 0.95, 1.5, 1.3, YELLOW);
box(g, -1.05, 1.5, -1.6, 1.05, 1.62, 1.6, RED);
for (const s of [-1, 1]) for (const z of [-0.6, 0.6]) box(g, s * 0.95 - 0.02, 0.95, z - 0.3, s * 0.95 + 0.02, 1.3, z + 0.3, 0x2b3d4a, 0x0c1b26);
// Open end platforms with rails, and a red tail lamp.
for (const e of [-1, 1]) {
box(g, -0.95, 0.45, e > 0 ? 1.3 : -1.5, 0.95, 0.52, e > 0 ? 1.5 : -1.3, 0x6b4a2a);
box(g, -0.95, 0.85, e > 0 ? 1.46 : -1.5, 0.95, 0.9, e > 0 ? 1.5 : -1.46, BLACK);
}
box(g, -0.15, 1.1, -1.55, 0.15, 1.35, -1.5, 0xff6a5a, 0xd8302a);
post(g, 0.5, 1.62, 0.6, 0.08, 0.3, BLACK);
for (const s of [-1, 1]) for (const z of [-1.0, 1.0]) wheels.push({ pivot: wheel(g, s, z, 0.3, BLACK), r: 0.3 });
coupler(g, 1.5, 1);
},
};
const GAP = 0.6;
// ---------------------------------------------------------------------------
// Train
const _p = new THREE.Vector3();
const _q = new THREE.Vector3();
export class Train implements Mover {
readonly root = new THREE.Group();
readonly cars: Car[] = [];
readonly bodies: Body[] = [];
readonly path: TrackPath;
/** Units per second along the track. */
speed = 4.5;
/** Distance of the front of the engine along the loop. */
private s = 0;
private chuffClock = 0;
private readonly puffs: { mesh: THREE.Mesh; age: number }[] = [];
private puffClock = 0;
constructor(track: TrackDef) {
this.path = new TrackPath(track);
this.root.name = 'toy-train';
let offset = 0;
for (const def of [ENGINE, TENDER, WAGON, CABOOSE]) {
offset += def.halfL;
const group = new THREE.Group();
const wheels: Car['wheels'] = [];
def.build(group, wheels);
this.root.add(group);
const body = new Body(def.parts, { name: 'toy-train', pushes: true });
this.cars.push({ body, group, halfL: def.halfL, offset, wheels });
this.bodies.push(body);
offset += def.halfL + GAP;
}
for (let i = 0; i < 10; i++) {
const mesh = new THREE.Mesh(GEO.puff, new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0, depthWrite: false }));
mesh.visible = false;
this.root.add(mesh);
this.puffs.push({ mesh, age: 99 });
}
// Start with the engine at the head of the dining-room straight.
this.s = this.cars[this.cars.length - 1]!.offset + 6;
this.place(0);
for (const c of this.cars) c.body.begin();
}
update(dt: number, events: MoverEvents): void {
const before = this.s;
this.s += this.speed * dt;
for (const c of this.cars) c.body.begin();
this.place(this.speed * dt);
for (const c of this.cars) c.body.end(dt);
const e = this.engine;
this.chuffClock += dt;
if (this.chuffClock > 0.42) {
this.chuffClock = 0;
events.sound('chuff', _p.set(e.x, 1, e.z));
}
// A toot each time round, as it sets off down the dining-room straight.
const L = this.path.length;
if (Math.floor(before / L) !== Math.floor(this.s / L)) events.sound('whistle', _p.set(e.x, 1, e.z));
this.updatePuffs(dt);
}
/** The engine (for sound and steam). */
get engine(): Body {
return this.cars[0]!.body;
}
/** Put every car on the track: each sits between its two bogies, so it turns smoothly round the bends. */
private place(moved: number): void {
for (const c of this.cars) {
const centre = this.s - c.offset;
const bogie = c.halfL * 0.6;
this.path.at(centre + bogie, _p);
this.path.at(centre - bogie, _q);
const x = (_p.x + _q.x) / 2;
const z = (_p.z + _q.z) / 2;
const yaw = Math.atan2(_p.x - _q.x, _p.z - _q.z);
c.body.place(x, 0, z, yaw);
c.group.position.set(x, 0, z);
c.group.rotation.y = yaw;
for (const w of c.wheels) w.pivot.rotation.x += moved / w.r;
}
}
/** Little steam puffs from the chimney that rise, swell and fade. */
private updatePuffs(dt: number): void {
this.puffClock += dt;
if (this.puffClock > 0.3) {
this.puffClock = 0;
const free = this.puffs.reduce((a, b) => (b.age > a.age ? b : a));
const e = this.engine;
free.age = 0;
free.mesh.position.set(e.x + Math.sin(e.yaw) * 1.85, 1.9, e.z + Math.cos(e.yaw) * 1.85);
free.mesh.visible = true;
}
for (const p of this.puffs) {
if (p.age > 1.6) {
p.mesh.visible = false;
continue;
}
p.age += dt;
const t = p.age / 1.6;
p.mesh.position.y += dt * 1.1;
p.mesh.scale.setScalar(0.2 + t * 0.6);
(p.mesh.material as THREE.MeshBasicMaterial).opacity = 0.55 * (1 - t) * Math.min(1, t * 6);
}
}
}
// ---------------------------------------------------------------------------
// Track mesh (static scenery, built with the house)
/** Two steel rails on wooden sleepers following the loop. Purely decorative: it lies flat on the floor. */
export function buildTrack(t: TrackDef): THREE.Group {
const path = new TrackPath(t);
const g = new THREE.Group();
g.name = 'train-track';
const GAUGE = 0.62;
// Sleepers, one instanced mesh.
const count = Math.floor(path.length / 0.9);
const sleepers = new THREE.InstancedMesh(GEO.box, mat(0x7a5232), count);
const m = new THREE.Matrix4();
const q = new THREE.Quaternion();
const up = new THREE.Vector3(0, 1, 0);
const dir = new THREE.Vector3();
const p = new THREE.Vector3();
for (let i = 0; i < count; i++) {
path.at((i + 0.5) * (path.length / count), p, dir);
q.setFromAxisAngle(up, Math.atan2(dir.x, dir.z));
m.compose(p.setY(0.06), q, new THREE.Vector3(1.8, 0.08, 0.34));
sleepers.setMatrixAt(i, m);
}
sleepers.instanceMatrix.needsUpdate = true;
sleepers.receiveShadow = true;
g.add(sleepers);
// Rails: straight lengths, and short chords round the bends.
const railMat = mat(STEEL, 0, 0.7);
const rail = (ax: number, az: number, bx: number, bz: number): void => {
const r = new THREE.Mesh(GEO.box, railMat);
const len = Math.hypot(bx - ax, bz - az);
r.position.set((ax + bx) / 2, 0.16, (az + bz) / 2);
r.rotation.y = Math.atan2(bx - ax, bz - az);
r.scale.set(0.12, 0.12, len + 0.03);
r.receiveShadow = true;
g.add(r);
};
for (const seg of path.segments) {
if (seg.kind === 'line') {
for (const side of [-GAUGE, GAUGE]) {
const ox = -seg.dz * side;
const oz = seg.dx * side;
rail(seg.x + ox, seg.z + oz, seg.x + seg.dx * seg.len + ox, seg.z + seg.dz * seg.len + oz);
}
} else {
const n = 10;
for (const rr of [seg.r - GAUGE, seg.r + GAUGE]) {
for (let i = 0; i < n; i++) {
const a = seg.a0 + seg.sweep * (i / n) * (Math.PI / 2);
const b = seg.a0 + seg.sweep * ((i + 1) / n) * (Math.PI / 2);
rail(seg.cx + Math.cos(a) * rr, seg.cz + Math.sin(a) * rr, seg.cx + Math.cos(b) * rr, seg.cz + Math.sin(b) * rr);
}
}
}
}
return g;
}
