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 { RollerDef, Solid } from './types.js';
/**
* Round things that roll when they're hit or walked into: the big rubber ball, tin
* cans and cotton reels lying on their side. A rolling one knocks samurai over. Each
* is a moving solid nobody can stand on (they slide off it).
*/
const MATS = new Map<string, THREE.MeshStandardMaterial>();
function mat(color: number, metal = 0, rough = 0.6): THREE.MeshStandardMaterial {
const key = `${color}:${metal}:${rough}`;
let m = MATS.get(key);
if (!m) {
m = new THREE.MeshStandardMaterial({ color, metalness: metal, roughness: rough, flatShading: true });
MATS.set(key, m);
}
return m;
}
const SIZES: Record<RollerDef['kind'], { r: number; len: number; drag: number }> = {
ball: { r: 0.9, len: 0, drag: 0.45 },
can: { r: 0.55, len: 1.3, drag: 0.9 },
reel: { r: 0.6, len: 1.1, drag: 1.0 },
};
const _axis = new THREE.Vector3();
const _q = new THREE.Quaternion();
export class Roller {
readonly root = new THREE.Group();
/** Spins as it rolls. */
private readonly spinner = new THREE.Group();
readonly pos = new THREE.Vector3();
readonly vel = new THREE.Vector3();
readonly solid: Solid;
readonly r: number;
/** Length of a can or reel (along its axis); 0 for the ball. */
readonly len: number;
private readonly drag: number;
/** Which way a can or reel's axis points (it rolls square to it). */
private axisYaw = 0;
/** Who hit it last, and how long ago (so it doesn't flatten the one who kicked it). */
lastHitBy: unknown = null;
sinceHit = 99;
onGround = false;
constructor(readonly def: RollerDef) {
const size = SIZES[def.kind];
this.r = size.r;
this.len = size.len;
this.drag = size.drag;
this.pos.set(def.x, def.y + size.r, def.z);
this.axisYaw = (def.x * 7.1 + def.z * 3.3) % Math.PI;
this.solid = { minX: 0, minY: 0, minZ: 0, maxX: 0, maxY: 0, maxZ: 0, grab: false, slide: true, moving: true, name: def.kind };
this.root.add(this.spinner);
this.build();
this.fit();
}
private build(): void {
const s = this.spinner;
if (this.def.kind === 'ball') {
const ball = new THREE.Mesh(new THREE.IcosahedronGeometry(this.r, 1), mat(0xc8473a, 0, 0.5));
ball.castShadow = true;
s.add(ball);
// A stripe round it so the rolling shows.
const band = new THREE.Mesh(new THREE.TorusGeometry(this.r * 0.99, 0.12, 4, 16), mat(0xf2c14e, 0, 0.5));
band.rotation.x = Math.PI / 2;
s.add(band);
return;
}
// Cans and reels lie along local X and roll about it.
const along = (geo: THREE.BufferGeometry, color: number, metal = 0): THREE.Mesh => {
const m = new THREE.Mesh(geo, mat(color, metal, metal ? 0.35 : 0.7));
m.rotation.z = Math.PI / 2;
m.castShadow = true;
s.add(m);
return m;
};
if (this.def.kind === 'can') {
along(new THREE.CylinderGeometry(this.r, this.r, this.len, 14), 0xc9ccd1, 0.7);
along(new THREE.CylinderGeometry(this.r * 1.01, this.r * 1.01, this.len * 0.62, 14), 0xc8262b);
const label = along(new THREE.CylinderGeometry(this.r * 1.02, this.r * 1.02, this.len * 0.12, 14), 0xf5f0e2);
label.position.x = 0.1;
} else {
along(new THREE.CylinderGeometry(this.r * 0.55, this.r * 0.55, this.len, 12), 0x3e6ea8);
for (const x of [-this.len / 2 + 0.08, this.len / 2 - 0.08]) along(new THREE.CylinderGeometry(this.r, this.r, 0.16, 14), 0xd9b77a).position.x = x;
// The thread wound round the middle.
along(new THREE.CylinderGeometry(this.r * 0.78, this.r * 0.78, this.len - 0.36, 12), 0xd8443a);
}
}
/** Half its size along X and Z (a can lying at an angle covers more). */
private extent(): [number, number] {
if (!this.len) return [this.r, this.r];
const c = Math.abs(Math.cos(this.axisYaw));
const sn = Math.abs(Math.sin(this.axisYaw));
const h = this.len / 2;
return [h * c + this.r * sn, h * sn + this.r * c];
}
private fit(): void {
const [ex, ez] = this.extent();
const s = this.solid;
s.minX = this.pos.x - ex;
s.maxX = this.pos.x + ex;
s.minZ = this.pos.z - ez;
s.maxZ = this.pos.z + ez;
s.minY = this.pos.y - this.r;
s.maxY = this.pos.y + this.r;
this.root.position.copy(this.pos);
this.root.rotation.y = this.axisYaw;
}
/** Give it a shove (a hit or a kick). */
push(vx: number, vz: number, up = 0): void {
this.vel.x += vx;
this.vel.z += vz;
if (up > 0) {
this.vel.y = Math.max(this.vel.y, up);
this.onGround = false;
}
}
get speed(): number {
return Math.hypot(this.vel.x, this.vel.z);
}
/** Roll, bounce off furniture and walls, and settle on whatever is underneath. */
update(dt: number, statics: Solid[]): void {
this.sinceHit += dt;
// A can or reel only rolls square to its axis; sliding along it is draggy.
if (this.len) {
const ax = Math.cos(this.axisYaw);
const az = -Math.sin(this.axisYaw);
const along = this.vel.x * ax + this.vel.z * az;
const slide = along * (1 - Math.exp(-6 * dt));
this.vel.x -= ax * slide;
this.vel.z -= az * slide;
}
this.vel.y -= 26 * dt;
if (this.onGround) {
const f = Math.exp(-this.drag * dt);
this.vel.x *= f;
this.vel.z *= f;
if (this.speed < 0.05) this.vel.x = this.vel.z = 0;
}
const moveX = this.vel.x * dt;
const moveZ = this.vel.z * dt;
this.step('x', moveX, statics);
this.step('z', moveZ, statics);
// Down onto the floor or furniture.
this.pos.y += this.vel.y * dt;
this.onGround = false;
const [ex, ez] = this.extent();
for (const s of statics) {
if (this.pos.x + ex <= s.minX || this.pos.x - ex >= s.maxX || this.pos.z + ez <= s.minZ || this.pos.z - ez >= s.maxZ) continue;
const bottom = this.pos.y - this.r;
if (bottom < s.maxY && bottom > s.maxY - 0.6 && this.vel.y <= 0) {
this.pos.y = s.maxY + this.r;
this.vel.y = this.vel.y < -4 ? -this.vel.y * 0.3 : 0;
this.onGround = this.vel.y === 0;
}
}
// Spin to match the roll.
const dist = Math.hypot(moveX, moveZ);
if (dist > 1e-5) {
if (!this.len) {
// Ball: about the horizontal axis square to its travel (in the root's turned frame).
_axis.set(moveZ, 0, -moveX).normalize().applyAxisAngle(THREE.Object3D.DEFAULT_UP, -this.axisYaw);
this.spinner.quaternion.premultiply(_q.setFromAxisAngle(_axis, dist / this.r));
} else {
const ax = Math.cos(this.axisYaw);
const az = -Math.sin(this.axisYaw);
// Rolling forward (square to the axis) turns it about its own axis.
const roll = moveX * az - moveZ * ax;
this.spinner.rotateX(-roll / this.r);
}
}
this.fit();
}
/** Is it pressed into a wall or furniture where it stands now? */
stuck(statics: Solid[]): boolean {
const [ex, ez] = this.extent();
const bottom = this.pos.y - this.r + 0.15;
for (const s of statics) {
if (this.pos.x + ex <= s.minX || this.pos.x - ex >= s.maxX || this.pos.z + ez <= s.minZ || this.pos.z - ez >= s.maxZ) continue;
if (s.maxY <= bottom || s.minY >= this.pos.y + this.r) continue;
return true;
}
return false;
}
/** Move along one axis, bouncing off anything solid that's taller than a little lip. */
private step(axis: 'x' | 'z', delta: number, statics: Solid[]): void {
if (!delta) return;
this.pos[axis] += delta;
const [ex, ez] = this.extent();
const bottom = this.pos.y - this.r + 0.15;
for (const s of statics) {
if (this.pos.x + ex <= s.minX || this.pos.x - ex >= s.maxX || this.pos.z + ez <= s.minZ || this.pos.z - ez >= s.maxZ) continue;
if (s.maxY <= bottom || s.minY >= this.pos.y + this.r) continue;
const e = axis === 'x' ? ex : ez;
const min = axis === 'x' ? s.minX : s.minZ;
const max = axis === 'x' ? s.maxX : s.maxZ;
this.pos[axis] = this.pos[axis] - delta < (min + max) / 2 ? min - e - 1e-3 : max + e + 1e-3;
this.vel[axis] *= -0.55;
}
}
}
