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/cars.ts
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;
  }
}