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 } from './movers.js';
import type { Solid } from './types.js';
import type { World } from './world.js';
/**
* The garage's car boxes: wooden crates that splinter when hit, each holding a red
* open-top toy car big enough for a samurai to sit in and drive.
*/
const MATS = new Map<string, THREE.MeshStandardMaterial>();
function mat(color: number, metal = 0, rough = 0.6, emissive = 0): THREE.MeshStandardMaterial {
const key = `${color}:${metal}:${rough}:${emissive}`;
let m = MATS.get(key);
if (!m) {
m = new THREE.MeshStandardMaterial({ color, metalness: metal, roughness: rough, emissive, emissiveIntensity: emissive ? 0.9 : 0, flatShading: true });
MATS.set(key, m);
}
return m;
}
const BOX = new THREE.BoxGeometry(1, 1, 1);
const CYL = new THREE.CylinderGeometry(0.5, 0.5, 1, 14);
function box(parent: THREE.Object3D, x0: number, y0: number, z0: number, x1: number, y1: number, z1: number, m: THREE.Material): THREE.Mesh {
const mesh = new THREE.Mesh(BOX, m);
mesh.position.set((x0 + x1) / 2, (y0 + y1) / 2, (z0 + z1) / 2);
mesh.scale.set(x1 - x0, y1 - y0, z1 - z0);
mesh.castShadow = true;
mesh.receiveShadow = true;
parent.add(mesh);
return mesh;
}
// ---------------------------------------------------------------------------
// Crate
export const CRATE_SIZE = 2.2;
export class Crate {
readonly root = new THREE.Group();
readonly solid: Solid;
hp = 3;
/** Shakes for a moment after each hit. */
private shake = 0;
/** Who hit it last, and how long ago (one hit per swing). */
lastHitBy: unknown = null;
sinceHit = 99;
constructor(
readonly x: number,
readonly y: number,
readonly z: number,
) {
const h = CRATE_SIZE / 2;
this.solid = { minX: x - h, maxX: x + h, minY: y, maxY: y + CRATE_SIZE, minZ: z - h, maxZ: z + h, name: 'car-crate' };
this.root.position.set(x, y, z);
const wood = mat(0xc99a5e, 0, 0.85);
const dark = mat(0x8a6238, 0, 0.85);
const s = CRATE_SIZE;
box(this.root, -h, 0, -h, h, s, h, wood);
// Planks and corner battens on every side.
for (const y0 of [0.05, s - 0.25]) {
box(this.root, -h - 0.03, y0, -h - 0.03, h + 0.03, y0 + 0.2, -h + 0.03, dark);
box(this.root, -h - 0.03, y0, h - 0.03, h + 0.03, y0 + 0.2, h + 0.03, dark);
box(this.root, -h - 0.03, y0, -h - 0.03, -h + 0.03, y0 + 0.2, h + 0.03, dark);
box(this.root, h - 0.03, y0, -h - 0.03, h + 0.03, y0 + 0.2, h + 0.03, dark);
}
for (const [x0, z0] of [
[-h, -h],
[h - 0.2, -h],
[-h, h - 0.2],
[h - 0.2, h - 0.2],
])
box(this.root, x0 - 0.02, 0, z0 - 0.02, x0 + 0.22, s, z0 + 0.22, dark);
// A red car stencilled on two sides, so you know what's inside.
const red = mat(0xd8262b, 0, 0.7);
for (const side of [-1, 1]) {
const zf = side * (h + 0.035);
box(this.root, -0.6, 0.75, zf - 0.01, 0.6, 1.05, zf + 0.01, red);
box(this.root, -0.3, 1.05, zf - 0.01, 0.25, 1.25, zf + 0.01, red);
for (const wx of [-0.38, 0.38]) box(this.root, wx - 0.12, 0.62, zf - 0.012, wx + 0.12, 0.84, zf + 0.012, mat(0x1d1f24));
}
}
/** Took a blow. Returns true when it breaks open. */
hit(power: number): boolean {
this.hp -= power;
this.shake = 0.25;
return this.hp <= 0;
}
update(dt: number): void {
this.sinceHit += dt;
if (this.shake > 0) {
this.shake = Math.max(0, this.shake - dt);
const k = this.shake * 0.5;
this.root.rotation.set(Math.sin(this.shake * 90) * k * 0.3, 0, Math.cos(this.shake * 70) * k * 0.3);
} else this.root.rotation.set(0, 0, 0);
}
}
// ---------------------------------------------------------------------------
// Car
/** Top speed: three times as fast as a samurai runs (4.9). */
export const CAR_SPEED = 15;
const HALF_L = 1.0;
const HALF_W = 0.55;
const TOP = 0.7;
export class Car {
readonly root = new THREE.Group();
readonly body: Body;
readonly pos = new THREE.Vector3();
yaw: number;
/** Speed along its heading (negative when reversing). */
speed = 0;
/** Sideways slide across its heading: big while drifting on the handbrake. */
slip = 0;
/** The driver is pulling the handbrake. */
handbrake = false;
private vy = 0;
/** Who is at the wheel. */
driver: unknown = null;
/** The driver's pedals and wheel this frame: throttle (W +1, S -1) and steering (D +1 right, A -1 left). */
throttle = 0;
turning = 0;
private readonly wheels: THREE.Object3D[] = [];
/** The two front wheels, which steer. */
private readonly frontWheels: THREE.Object3D[] = [];
private readonly wheel = new THREE.Group();
constructor(x: number, y: number, z: number, yaw: number) {
this.pos.set(x, y, z);
this.yaw = yaw;
this.body = new Body([{ z0: -HALF_L, z1: HALF_L, hw: HALF_W, top: TOP, bottom: 0.05, edges: ['left', 'right', 'front', 'back'] }], { name: 'toy-car' });
this.build();
this.body.place(x, y, z, yaw);
this.body.begin();
this.sync();
}
private build(): void {
const g = this.root;
const red = mat(0xd8262b, 0.1, 0.35);
const redD = mat(0xa81c22, 0.1, 0.4);
const black = mat(0x1d1f24, 0, 0.8);
const chrome = mat(0xd0d6dc, 0.8, 0.25);
// Open tub: floor, low sides, a long bonnet in front and a short boot behind. No roof.
box(g, -HALF_W, 0.18, -HALF_L, HALF_W, 0.3, HALF_L, redD);
box(g, -HALF_W, 0.3, -HALF_L, -HALF_W + 0.08, 0.6, HALF_L, red);
box(g, HALF_W - 0.08, 0.3, -HALF_L, HALF_W, 0.6, HALF_L, red);
box(g, -HALF_W, 0.3, 0.35, HALF_W, 0.66, HALF_L, red); // bonnet
box(g, -HALF_W, 0.3, -HALF_L, HALF_W, 0.6, -0.7, red); // boot
box(g, -HALF_W - 0.02, 0.6, -HALF_L, HALF_W + 0.02, 0.66, -0.7, redD);
// Seat, windscreen frame, steering wheel.
box(g, -0.38, 0.3, -0.62, 0.38, 0.44, -0.15, black);
box(g, -0.38, 0.44, -0.66, 0.38, 0.85, -0.56, black);
box(g, -0.5, 0.66, 0.33, -0.46, 0.92, 0.37, chrome);
box(g, 0.46, 0.66, 0.33, 0.5, 0.92, 0.37, chrome);
box(g, -0.5, 0.9, 0.33, 0.5, 0.94, 0.37, chrome);
const glass = new THREE.Mesh(BOX, new THREE.MeshStandardMaterial({ color: 0xbfe6ff, transparent: true, opacity: 0.35, roughness: 0.1 }));
glass.position.set(0, 0.78, 0.35);
glass.scale.set(0.92, 0.24, 0.02);
g.add(glass);
this.wheel.position.set(0, 0.68, -0.04);
this.wheel.rotation.x = -0.9;
const ring = new THREE.Mesh(new THREE.TorusGeometry(0.13, 0.025, 5, 12), black);
this.wheel.add(ring);
box(this.wheel, -0.12, -0.012, -0.012, 0.12, 0.012, 0.012, black);
g.add(this.wheel);
// Lights and a grille.
for (const x of [-0.35, 0.35]) {
const lamp = new THREE.Mesh(CYL, mat(0xfff0b0, 0, 0.3, 0xffd27a));
lamp.rotation.x = Math.PI / 2;
lamp.position.set(x, 0.5, HALF_L + 0.01);
lamp.scale.set(0.16, 0.04, 0.16);
g.add(lamp);
box(g, x - 0.08, 0.42, -HALF_L - 0.02, x + 0.08, 0.5, -HALF_L, mat(0xff5a4a, 0, 0.4, 0xd8302a));
}
box(g, -0.2, 0.34, HALF_L, 0.2, 0.46, HALF_L + 0.02, chrome);
// Wheels.
for (const x of [-HALF_W - 0.04, HALF_W + 0.04])
for (const z of [-0.65, 0.62]) {
const w = new THREE.Group();
w.position.set(x, 0.25, z);
const tyre = new THREE.Mesh(CYL, black);
tyre.rotation.z = Math.PI / 2;
tyre.scale.set(0.5, 0.16, 0.5);
tyre.castShadow = true;
w.add(tyre);
const hub = new THREE.Mesh(CYL, chrome);
hub.rotation.z = Math.PI / 2;
hub.position.x = Math.sign(x) * 0.06;
hub.scale.set(0.22, 0.06, 0.22);
w.add(hub);
g.add(w);
this.wheels.push(w);
if (z > 0) {
// Steer first, then spin: the spin is about the steered axle.
w.rotation.order = 'YXZ';
this.frontWheels.push(w);
}
}
}
/** Where the driver sits (feet), in world space. */
seat(out: THREE.Vector3): THREE.Vector3 {
return out.set(this.pos.x - Math.sin(this.yaw) * 0.38, this.pos.y + 0.3, this.pos.z - Math.cos(this.yaw) * 0.38);
}
/** Closest point of the car's footprint to (x, z), and how far away it is. */
nearest(x: number, z: number, out: THREE.Vector3): number {
const { side, along } = this.body.local(x, z);
const s = Math.max(-HALF_W, Math.min(HALF_W, side));
const a = Math.max(-HALF_L, Math.min(HALF_L, along));
this.body.world(s, a, out);
return Math.hypot(out.x - x, out.z - z);
}
/** Is a body of radius r at (x, z) touching the car (from the side)? */
touches(x: number, z: number, r: number): boolean {
const { side, along } = this.body.local(x, z);
return Math.abs(side) < HALF_W + r && Math.abs(along) < HALF_L + r;
}
/**
* Would the car's body overlap anything solid here (more than a small lip it can roll
* up)? The body is covered by three squares along its length.
*/
private blockedAt(world: World, own: Set<Solid>, x: number, z: number, yaw: number): boolean {
const hx = Math.sin(yaw);
const hz = Math.cos(yaw);
for (const k of [-0.55, 0, 0.55]) {
const px = x + hx * k;
const pz = z + hz * k;
for (const s of world.solids) {
if (own.has(s)) continue;
if (s.maxY <= this.pos.y + 0.3 || s.minY >= this.pos.y + TOP) continue;
if (px + HALF_W <= s.minX || px - HALF_W >= s.maxX || pz + HALF_W <= s.minZ || pz - HALF_W >= s.maxZ) continue;
return true;
}
}
return false;
}
/** Move to the nearest spot (or heading) where the body fits, searching outward. */
private escape(world: World, own: Set<Solid>): void {
for (let r = 0.15; r <= 2.4; r += 0.15) {
for (let i = 0; i < 16; i++) {
const a = (i / 16) * Math.PI * 2;
const x = this.pos.x + Math.cos(a) * r;
const z = this.pos.z + Math.sin(a) * r;
if (this.blockedAt(world, own, x, z, this.yaw)) continue;
this.pos.x = x;
this.pos.z = z;
this.speed *= 0.3;
this.slip = 0;
return;
}
}
// Nowhere nearby fits at this heading: try turning it instead.
for (let i = 1; i < 12; i++) {
const yaw = this.yaw + (i / 12) * Math.PI;
if (!this.blockedAt(world, own, this.pos.x, this.pos.z, yaw)) {
this.yaw = yaw;
return;
}
}
}
/** Sliding sideways hard enough to smoke the tyres. */
get drifting(): boolean {
return Math.abs(this.slip) > 2.2;
}
/**
* Drive one step like a car: W accelerates, S brakes and then reverses, A and D steer
* (the wheels only turn it while it's rolling). With no pedal it coasts to a stop.
* The handbrake locks the back wheels: the tyres lose their sideways grip, the car
* turns sharper, and it slides out into a drift. Bumps off walls and furniture.
*/
update(dt: number, world: World, ownSolids: Set<Solid>): void {
this.body.begin();
const pedal = this.driver ? this.throttle : 0;
const brake = !!this.driver && this.handbrake;
if (brake && pedal <= 0.15) {
// Locked back wheels scrub off speed slowly.
this.speed *= Math.exp(-0.7 * dt);
} else if (pedal > 0.15) {
// On the handbrake the throttle still pushes (a power slide), just a little less hard.
const target = CAR_SPEED * Math.min(1, pedal / 0.6) * (brake ? 0.85 : 1);
const rate = (this.speed < 0 ? 18 : 11) * (brake ? 0.75 : 1);
this.speed = this.speed < target ? Math.min(target, this.speed + rate * dt) : this.speed * Math.exp(-0.7 * dt);
} else if (pedal < -0.15) {
// Brake first, then back up (more slowly).
const target = -CAR_SPEED * 0.4 * Math.min(1, -pedal / 0.6);
const rate = this.speed > 0 ? 18 : 8;
this.speed = Math.max(target, this.speed - rate * dt);
} else {
this.speed *= Math.exp(-2.2 * dt);
if (Math.abs(this.speed) < 0.05) this.speed = 0;
}
// Steering: right turns clockwise seen from above; backing up swings the nose the other way.
const wheel = this.driver ? Math.max(-1, Math.min(1, this.turning)) : 0;
const rolling = Math.min(1, Math.abs(this.speed) / 1.2);
// The way the car is actually travelling, before it turns.
const fx0 = Math.sin(this.yaw);
const fz0 = Math.cos(this.yaw);
const vx = fx0 * this.speed + fz0 * this.slip;
const vz = fz0 * this.speed - fx0 * this.slip;
// Something wedged into the car (the train pushed into it, a fall onto furniture):
// ease it out to the nearest free spot first.
if (this.blockedAt(world, ownSolids, this.pos.x, this.pos.z, this.yaw)) this.escape(world, ownSolids);
const oldYaw = this.yaw;
this.yaw -= wheel * (brake ? 3.6 : 2.4) * rolling * Math.sign(this.speed) * dt;
// The body can't swing into anything either.
if (this.yaw !== oldYaw && this.blockedAt(world, ownSolids, this.pos.x, this.pos.z, this.yaw)) this.yaw = oldYaw;
// Re-measure that travel against the new heading: what isn't along it is a slide,
// which the tyres scrub away fast when they grip and slowly on the handbrake.
const fx1 = Math.sin(this.yaw);
const fz1 = Math.cos(this.yaw);
this.speed = vx * fx1 + vz * fz1;
this.slip = (vx * fz1 - vz * fx1) * Math.exp(-(brake ? 1.4 : 14) * dt);
this.wheel.rotation.z = wheel * 0.9;
for (const w of this.frontWheels) w.rotation.y += (-wheel * 0.5 - w.rotation.y) * Math.min(1, dt * 12);
// Move, unless the bumper would hit something solid (it can bump up small lips).
const hx = Math.sin(this.yaw);
const hz = Math.cos(this.yaw);
const nx = this.pos.x + (hx * this.speed + hz * this.slip) * dt;
const nz = this.pos.z + (hz * this.speed - hx * this.slip) * dt;
if (this.speed !== 0 || this.slip !== 0) {
if (this.blockedAt(world, ownSolids, nx, nz, this.yaw)) {
this.speed *= -0.3;
this.slip *= -0.3;
} else {
this.pos.x = nx;
this.pos.z = nz;
}
}
// Stay on the floor, step up small lips, fall off edges.
let ground = -Infinity;
for (const s of world.solids) {
if (ownSolids.has(s) || s.moving) continue;
if (s.maxY > this.pos.y + 0.3) continue;
if (this.pos.x + 0.4 <= s.minX || this.pos.x - 0.4 >= s.maxX || this.pos.z + 0.4 <= s.minZ || this.pos.z - 0.4 >= s.maxZ) continue;
ground = Math.max(ground, s.maxY);
}
if (ground > -Infinity && this.pos.y - ground < 0.05 + Math.max(0, -this.vy) * dt) {
this.pos.y = ground;
this.vy = 0;
} else {
this.vy -= 28 * dt;
this.pos.y += this.vy * dt;
if (ground > -Infinity && this.pos.y < ground) {
this.pos.y = ground;
this.vy = 0;
}
}
for (const w of this.wheels) w.rotation.x += (this.speed * dt) / 0.25;
this.body.place(this.pos.x, this.pos.y, this.pos.z, this.yaw);
this.body.end(dt);
this.sync();
}
private sync(): void {
this.root.position.copy(this.pos);
this.root.rotation.y = this.yaw;
}
}
