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