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/machines.ts
import * as THREE from 'three';
import { Body, type Mover, type MoverEvents } from './movers.js';
import { Train } from './train.js';
import type { MachineDef, RoomData } from './types.js';

/**
 * The house's moving machines, built from a room's MachineDefs: the ceiling fan, the
 * record player, the toaster and the pendulums. Their still parts (the fan's motor,
 * the record player's case, the toaster's body, the clock's case) are ordinary room
 * furniture; what moves here is Bodies, so samurai can ride and hang from them.
 */

const GEO = {
  box: new THREE.BoxGeometry(1, 1, 1),
  cyl: new THREE.CylinderGeometry(0.5, 0.5, 1, 24),
  cyl8: new THREE.CylinderGeometry(0.5, 0.5, 1, 8),
  ball: new THREE.IcosahedronGeometry(0.5, 1),
};
const MATS = new Map<string, THREE.MeshStandardMaterial>();

function mat(color: number, emissive = 0, metal = 0, rough = 0.75): THREE.MeshStandardMaterial {
  const key = `${color}:${emissive}:${metal}:${rough}`;
  let m = MATS.get(key);
  if (!m) {
    m = new THREE.MeshStandardMaterial({ color, emissive, emissiveIntensity: emissive ? 0.9 : 0, flatShading: true, roughness: rough, metalness: metal });
    MATS.set(key, m);
  }
  return m;
}

function box(parent: THREE.Object3D, x0: number, y0: number, z0: number, x1: number, y1: number, z1: number, color: number, metal = 0): THREE.Mesh {
  const m = new THREE.Mesh(GEO.box, mat(color, 0, 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;
}

function disc(parent: THREE.Object3D, x: number, y0: number, z: number, r: number, h: number, color: number, metal = 0, geo = GEO.cyl): THREE.Mesh {
  const m = new THREE.Mesh(geo, mat(color, 0, metal));
  m.position.set(x, y0 + h / 2, z);
  m.scale.set(r * 2, h, r * 2);
  m.castShadow = true;
  m.receiveShadow = true;
  parent.add(m);
  return m;
}

const _at = new THREE.Vector3();

// ---------------------------------------------------------------------------
// Ceiling fan: four long wooden blades turning slowly. Ride a blade round or hang off its edge.

class CeilingFan implements Mover {
  readonly root = new THREE.Group();
  readonly bodies: Body[] = [];
  private readonly spinner = new THREE.Group();
  private angle = 0;

  constructor(private readonly def: Extract<MachineDef, { kind: 'fan' }>) {
    const { x, y, z, r } = def;
    this.root.position.set(x, y, z);
    this.root.add(this.spinner);
    const r0 = 1.15;
    // Short pieces, so the turning colliders stay close to the blade.
    const seg = Math.max(4, Math.ceil((r - r0) / 1.4));
    const step = (r - r0) / seg;
    for (let k = 0; k < 4; k++) {
      // Each blade is a few boxes end to end along it, so its colliders hug it as it turns.
      const parts = Array.from({ length: seg }, (_, i) => ({
        z0: r0 + i * step,
        z1: r0 + (i + 1) * step,
        hw: 0.8,
        bottom: 0,
        top: 0.26,
        edges: (i === seg - 1 ? ['left', 'right', 'front'] : ['left', 'right']) as ('left' | 'right' | 'front')[],
      }));
      this.bodies.push(new Body(parts, { name: 'fan-blade' }));
      // The blade mesh: a long board with a darker edge band and a brass bracket at the hub.
      const arm = new THREE.Group();
      arm.rotation.y = (k * Math.PI) / 2;
      box(arm, -0.8, 0, r0, 0.8, 0.26, r, 0x8a5a36);
      box(arm, -0.82, 0.02, r - 0.35, 0.82, 0.24, r, 0x6b4428);
      box(arm, -0.25, 0.26, 0.6, 0.25, 0.4, r0 + 1.2, 0xc9a24a, 0.6);
      this.spinner.add(arm);
    }
    disc(this.spinner, 0, -0.1, 0, 1.15, 0.5, 0xc9a24a, 0.6, GEO.cyl8);
    disc(this.spinner, 0, -0.45, 0, 0.5, 0.35, 0xfbe7b5, 0, GEO.cyl8);
    this.place();
    for (const b of this.bodies) b.begin();
  }

  update(dt: number): void {
    this.angle += this.def.speed * dt;
    for (const b of this.bodies) b.begin();
    this.place();
    for (const b of this.bodies) b.end(dt);
  }

  private place(): void {
    this.spinner.rotation.y = this.angle;
    this.bodies.forEach((b, k) => b.place(this.def.x, this.def.y, this.def.z, this.angle + (k * Math.PI) / 2));
  }
}

// ---------------------------------------------------------------------------
// Record player: a slowly turning platter with a record on it.

class Turntable implements Mover {
  readonly root = new THREE.Group();
  readonly bodies: Body[] = [];
  private readonly platter = new THREE.Group();
  private angle = 0;

  constructor(private readonly def: Extract<MachineDef, { kind: 'turntable' }>) {
    const { x, y, z, r } = def;
    this.root.position.set(x, 0, z);
    this.platter.position.y = y;
    this.root.add(this.platter);
    this.bodies.push(new Body([{ z0: -r, z1: r, hw: r, bottom: -0.3, top: 0, round: true }], { name: 'record' }));
    disc(this.platter, 0, -0.3, 0, r, 0.22, 0x9aa1a8, 0.6);
    disc(this.platter, 0, -0.08, 0, r * 0.97, 0.06, 0x1b1b1f, 0.1);
    // Grooves and the label, so the turning shows.
    for (const k of [0.45, 0.65, 0.85]) disc(this.platter, 0, -0.025, 0, r * k, 0.01, 0x2a2a30, 0.1);
    disc(this.platter, 0, -0.02, 0, r * 0.3, 0.02, 0xc8323c);
    box(this.platter, -0.05, -0.0, r * 0.12, 0.05, 0.005, r * 0.28, 0xf2c14e);
    disc(this.platter, 0, -0.02, 0, 0.08, 0.35, 0xc9d1d6, 0.7, GEO.cyl8);
    this.place();
    for (const b of this.bodies) b.begin();
  }

  update(dt: number): void {
    this.angle += this.def.speed * dt;
    for (const b of this.bodies) b.begin();
    this.place();
    for (const b of this.bodies) b.end(dt);
  }

  private place(): void {
    this.platter.rotation.y = this.angle;
    this.bodies[0]!.place(this.def.x, this.def.y, this.def.z, this.angle);
  }
}

// ---------------------------------------------------------------------------
// Toaster: two slices of toast that pop up every so often, flinging whoever stands on top.

class Toaster implements Mover {
  readonly root = new THREE.Group();
  readonly bodies: Body[] = [];
  private readonly slices: THREE.Group[] = [];
  private clock: number;
  /** Toast height above its lowest: 0 (down in the slots) .. 1 (popped up). */
  private lift = 0;
  private readonly topSolid: RoomData['solids'][number] | null;

  constructor(
    private readonly def: Extract<MachineDef, { kind: 'toaster' }>,
    room: RoomData,
  ) {
    this.clock = def.period * 0.6;
    // The toaster's own top (static furniture): standing on it gets you flung too.
    this.topSolid =
      room.solids.find((s) => s.name === 'toaster' && def.x > s.minX && def.x < s.maxX && def.z > s.minZ && def.z < s.maxZ && Math.abs(s.maxY - def.y) < 0.05) ?? null;
    for (const side of [-0.7, 0.7]) {
      const body = new Body([{ z0: -0.55, z1: 0.55, hw: 0.14, bottom: 0, top: 1.1 }], { name: 'toast' });
      this.bodies.push(body);
      const slice = new THREE.Group();
      box(slice, -0.14, 0, -0.55, 0.14, 1.1, 0.55, 0xc98a3e);
      box(slice, -0.15, 0.04, -0.5, 0.15, 1.05, 0.5, 0xe8b867);
      box(slice, -0.16, 0.95, -0.56, 0.16, 1.12, 0.56, 0x9a5a26);
      slice.userData.side = side;
      this.root.add(slice);
      this.slices.push(slice);
    }
    this.place();
    for (const b of this.bodies) b.begin();
  }

  update(dt: number, events: MoverEvents): void {
    const { period } = this.def;
    const before = this.clock;
    this.clock = (this.clock + dt) % period;
    // Down in the slots most of the time; SPRING up, sit there a moment, then sink back.
    const up = period - 2.6;
    const t = this.clock;
    this.lift = t < up ? 0 : t < up + 0.08 ? (t - up) / 0.08 : t < up + 2.1 ? 1 : t < up + 2.5 ? 1 - (t - up - 2.1) / 0.4 : 0;
    for (const b of this.bodies) b.begin();
    this.place();
    for (const b of this.bodies) b.end(dt);
    if (before < up && this.clock >= up) {
      const solids = this.bodies.flatMap((b) => b.solids);
      if (this.topSolid) solids.push(this.topSolid);
      events.launch(solids, _at.set(0, 15, 0));
      events.sound('pop', _at.set(this.def.x, this.def.y, this.def.z));
    }
  }

  private place(): void {
    const { x, y, z, yaw } = this.def;
    const cos = Math.cos(yaw);
    const sin = Math.sin(yaw);
    // Lowest: toast tops just inside the slots. Popped: well clear of the top.
    const base = y - 1.15 + this.lift * 0.8;
    this.bodies.forEach((b, i) => {
      const side = this.slices[i]!.userData.side as number;
      const bx = x + side * cos;
      const bz = z - side * sin;
      b.place(bx, base, bz, yaw);
      const slice = this.slices[i]!;
      slice.position.set(bx, base, bz);
      slice.rotation.y = yaw;
    });
  }
}

// ---------------------------------------------------------------------------
// Pendulum: the grandfather clock's brass bob.

class Pendulum implements Mover {
  readonly root = new THREE.Group();
  readonly bodies: Body[] = [];
  private readonly arm = new THREE.Group();
  private time = 0;
  private lastSide = 0;

  constructor(private readonly def: Extract<MachineDef, { kind: 'pendulum' }>) {
    const { x, y, z, yaw, length } = def;
    this.root.position.set(x, y, z);
    this.root.rotation.y = yaw;
    this.root.add(this.arm);
    this.bodies.push(new Body([{ z0: -0.2, z1: 0.2, hw: 0.9, bottom: -0.45, top: 0.45, edges: ['front'] }], { name: 'pendulum' }));
    box(this.arm, -0.08, -length, -0.05, 0.08, 0, 0.05, 0xc9a24a, 0.6);
    const bob = new THREE.Mesh(GEO.cyl, mat(0xd9b04a, 0, 0.7, 0.35));
    bob.rotation.x = Math.PI / 2;
    bob.position.set(0, -length, 0);
    bob.scale.set(1.8, 0.4, 1.8);
    bob.castShadow = true;
    this.arm.add(bob);
    const face = new THREE.Mesh(GEO.cyl, mat(0xf2d27a, 0, 0.8, 0.25));
    face.rotation.x = Math.PI / 2;
    face.position.set(0, -length, 0.21);
    face.scale.set(1.2, 0.04, 1.2);
    this.arm.add(face);
    this.place();
    for (const b of this.bodies) b.begin();
  }

  update(dt: number, events: MoverEvents): void {
    this.time += dt;
    for (const b of this.bodies) b.begin();
    this.place();
    for (const b of this.bodies) b.end(dt);
    // The clock ticks at each end of its swing.
    const side = Math.sign(Math.cos((this.time / this.def.period) * Math.PI * 2));
    if (side !== this.lastSide) {
      this.lastSide = side;
      events.sound('tick', _at.set(this.def.x, this.def.y, this.def.z));
    }
  }

  private place(): void {
    const { x, y, z, yaw, length, swing, period } = this.def;
    const theta = swing * Math.sin((this.time / period) * Math.PI * 2);
    this.arm.rotation.z = theta;
    // Swinging across the front: sideways is (cos yaw, -sin yaw).
    const across = Math.sin(theta) * length;
    const drop = Math.cos(theta) * length;
    this.bodies[0]!.place(x + Math.cos(yaw) * across, y - drop, z - Math.sin(yaw) * across, yaw);
  }
}

/** Every mover in a room: its machines, and the toy train if it has track. */
export function buildMachines(room: RoomData): Mover[] {
  const movers: Mover[] = [];
  if (room.track) movers.push(new Train(room.track));
  for (const def of room.machines ?? []) {
    if (def.kind === 'fan') movers.push(new CeilingFan(def));
    else if (def.kind === 'turntable') movers.push(new Turntable(def));
    else if (def.kind === 'toaster') movers.push(new Toaster(def, room));
    else movers.push(new Pendulum(def));
  }
  return movers;
}