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