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 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;
}
