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 type { Solid } from './types.js';
import { GRAB_MAX, GRAB_MIN, RADIUS, type Ledge } from './world.js';
/**
* Moving things samurai can stand on, ride and hang from: the toy train's cars, the
* ceiling fan's blades, the record player's platter, the toaster's toast and the
* grandfather clock's pendulum. Each is a Body: a few boxes fixed in the body's own
* frame (x across, z along, y up) that moves and turns about the vertical as one.
* Its colliders follow it, and whoever stands on it or hangs from its edges is
* carried along exactly.
*/
export type EdgeSide = 'left' | 'right' | 'front' | 'back';
/** One box of a body, in the body's frame. Heights are relative to the body's y. */
export interface BodyPart {
/** Extent along the body (its forward axis). */
z0: number;
z1: number;
/** Half its width across the body. */
hw: number;
top: number;
bottom?: number;
/** Round (a platter): its box doesn't grow as it turns. */
round?: boolean;
/** Edges that can be grabbed and hung from. */
edges?: EdgeSide[];
}
export interface BodyOptions {
name: string;
/** Nudges samurai standing in its way aside (the train). */
pushes?: boolean;
/** Round or slippery: nobody can stand on it. */
slide?: boolean;
}
export class Body {
readonly solids: Solid[];
x = 0;
y = 0;
z = 0;
yaw = 0;
/** Pose before this frame's move, for carrying riders along. */
px = 0;
py = 0;
pz = 0;
pyaw = 0;
/** Velocity of the body's origin this frame (units per second). */
readonly vel = new THREE.Vector3();
private dt = 0;
constructor(
readonly parts: BodyPart[],
readonly options: BodyOptions,
) {
this.solids = parts.map(
(): Solid => ({ minX: 0, minY: 0, minZ: 0, maxX: 0, maxY: 0, maxZ: 0, grab: false, moving: true, slide: options.slide || undefined, name: options.name }),
);
}
/** Start of a frame's move: remember where it was. */
begin(): void {
this.px = this.x;
this.py = this.y;
this.pz = this.z;
this.pyaw = this.yaw;
}
/** Put the body somewhere new and refit its colliders. */
place(x: number, y: number, z: number, yaw: number): void {
this.x = x;
this.y = y;
this.z = z;
this.yaw = yaw;
const cos = Math.abs(Math.cos(yaw));
const sin = Math.abs(Math.sin(yaw));
this.parts.forEach((part, i) => {
const s = this.solids[i]!;
const hl = (part.z1 - part.z0) / 2;
const mid = (part.z0 + part.z1) / 2;
const cx = x + mid * Math.sin(yaw);
const cz = z + mid * Math.cos(yaw);
const ex = part.round ? part.hw : part.hw * cos + hl * sin;
const ez = part.round ? part.hw : part.hw * sin + hl * cos;
s.minX = cx - ex;
s.maxX = cx + ex;
s.minZ = cz - ez;
s.maxZ = cz + ez;
s.minY = y + (part.bottom ?? 0.05);
s.maxY = y + part.top;
});
}
/** End of a frame's move: work out its velocity. */
end(dt: number): void {
this.dt = dt;
if (dt > 0) this.vel.set((this.x - this.px) / dt, (this.y - this.py) / dt, (this.z - this.pz) / dt);
}
/** How far it turned this frame. */
get turn(): number {
return Math.atan2(Math.sin(this.yaw - this.pyaw), Math.cos(this.yaw - this.pyaw));
}
/** Body-frame coordinates of a world point: `side` across, `along` toward its front. */
local(x: number, z: number): { side: number; along: number } {
const dx = x - this.x;
const dz = z - this.z;
const cos = Math.cos(this.yaw);
const sin = Math.sin(this.yaw);
return { side: dx * cos - dz * sin, along: dx * sin + dz * cos };
}
/** World point from body-frame coordinates (keeps out.y). */
world(side: number, along: number, out: THREE.Vector3): THREE.Vector3 {
const cos = Math.cos(this.yaw);
const sin = Math.sin(this.yaw);
return out.set(this.x + side * cos + along * sin, out.y, this.z - side * sin + along * cos);
}
/** The highest part under (or within `pad` of) a world point, if any. */
partAt(x: number, z: number, pad = 0): BodyPart | null {
const { side, along } = this.local(x, z);
let best: BodyPart | null = null;
for (const p of this.parts) {
if (Math.abs(side) > p.hw + pad || along < p.z0 - pad || along > p.z1 + pad) continue;
if (p.round && Math.hypot(side, along - (p.z0 + p.z1) / 2) > p.hw + pad) continue;
if (!best || p.top > best.top) best = p;
}
return best;
}
/** Move a point riding on the body from where the body was to where it is now. Returns how far it turned. */
carry(p: THREE.Vector3): number {
const dx = p.x - this.px;
const dz = p.z - this.pz;
const turn = this.turn;
const cos = Math.cos(turn);
const sin = Math.sin(turn);
p.x = this.x + dx * cos + dz * sin;
p.z = this.z - dx * sin + dz * cos;
p.y += this.y - this.py;
return turn;
}
/** Velocity of a point riding on the body (a spinning platter moves faster at its rim). */
pointVelocity(p: THREE.Vector3, out: THREE.Vector3): THREE.Vector3 {
if (this.dt <= 0) return out.set(0, 0, 0);
const before = _pv.copy(p);
// Where would a point that was at p last frame be now?
const turn = this.turn;
const dx = before.x - this.px;
const dz = before.z - this.pz;
const cos = Math.cos(turn);
const sin = Math.sin(turn);
const nx = this.x + dx * cos + dz * sin;
const nz = this.z - dx * sin + dz * cos;
return out.set((nx - p.x) / this.dt, (this.y - this.py) / this.dt, (nz - p.z) / this.dt);
}
/**
* A grabbable edge in front of an airborne samurai at `pos` reaching toward `dir`,
* scored like furniture ledges so the nearest edge wins.
*/
ledge(pos: THREE.Vector3, dir: THREE.Vector3): { ledge: Ledge; score: number } | null {
let best: { ledge: Ledge; score: number } | null = null;
const cos = Math.cos(this.yaw);
const sin = Math.sin(this.yaw);
const { side, along } = this.local(pos.x, pos.z);
const dSide = dir.x * cos - dir.z * sin;
const dAlong = dir.x * sin + dir.z * cos;
this.parts.forEach((p, i) => {
if (!p.edges?.length) return;
const top = this.y + p.top;
const rise = top - pos.y;
if (rise < GRAB_MIN || rise > GRAB_MAX) return;
for (const e of p.edges) {
// [normal across, normal along, distance out, position along the edge, edge ends]
const face: [number, number, number, number, number, number] =
e === 'right'
? [1, 0, side - p.hw, along, p.z0, p.z1]
: e === 'left'
? [-1, 0, -side - p.hw, along, p.z0, p.z1]
: e === 'front'
? [0, 1, along - p.z1, side, -p.hw, p.hw]
: [0, -1, p.z0 - along, side, -p.hw, p.hw];
const [ns, na, out, at, lo, hi] = face;
if (ns * dSide + na * dAlong > -0.35) continue;
if (out < RADIUS - 0.12 || out > RADIUS + 0.32) continue;
const min = lo + 0.14;
const max = hi - 0.14;
if (max <= min || at < min - 0.05 || at > max + 0.05) continue;
const a = Math.min(max, Math.max(min, at));
const score = Math.abs(out - RADIUS) + Math.abs(rise - 1.15) * 0.3;
if (best && score >= best.score) continue;
// Into world space: across the body is (cos, -sin), along it is (sin, cos).
const normal = new THREE.Vector3(ns * cos + na * sin, 0, -ns * sin + na * cos);
const sideEdge = na === 0;
const tangent = sideEdge ? new THREE.Vector3(sin, 0, cos) : new THREE.Vector3(cos, 0, -sin);
const point = new THREE.Vector3(0, top, 0);
if (sideEdge) this.world(ns * p.hw, a, point);
else this.world(a, na > 0 ? p.z1 : p.z0, point);
const base = point.dot(tangent) - a;
best = { ledge: { solid: this.solids[i]!, normal, tangent, point, min: base + min, max: base + max }, score };
}
});
return best;
}
}
const _pv = new THREE.Vector3();
/** Something in a room that moves bodies about each frame. */
export interface Mover {
readonly root: THREE.Object3D;
readonly bodies: Body[];
update(dt: number, events: MoverEvents): void;
}
/** What movers ask the game to do this frame. */
export interface MoverEvents {
/** Fling whoever stands on these solids (the toaster popping). */
launch(solids: Solid[], vel: THREE.Vector3): void;
/** Play a sound at a place (volume falls off with distance). */
sound(kind: 'chuff' | 'whistle' | 'pop' | 'tick' | 'whirr', at: THREE.Vector3): void;
}
