Back to Mini Samurai
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.

src/game/train.ts
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;
}