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/world.ts
import * as THREE from 'three';
import type { Solid } from './types.js';

export const RADIUS = 0.24;
export const HEIGHT = 1.0;
export const CROUCH_HEIGHT = 0.66;
export const STEP_UP = 0.3;
/** Ledge tops this far above the feet can be grabbed while airborne. */
export const GRAB_MIN = 0.75;
export const GRAB_MAX = 1.75;
/** Feet sit this far below the ledge while hanging. */
export const HANG_DROP = 1.0;
/** Root stands this far out from the wall while hanging (more than RADIUS, so the body never overlaps the ledge). */
export const HANG_OUT = 0.27;

export interface Body {
  pos: THREE.Vector3;
  vel: THREE.Vector3;
  height: number;
  onGround: boolean;
  support: Solid | null;
}

function overlaps(s: Solid, x: number, y: number, z: number, h: number, r: number): boolean {
  if (!(x + r > s.minX && x - r < s.maxX && z + r > s.minZ && z - r < s.maxZ && y + h > s.minY && y < s.maxY)) return false;
  if (!s.round) return true;
  const R = (s.maxX - s.minX) / 2;
  return Math.hypot(x - (s.minX + s.maxX) / 2, z - (s.minZ + s.maxZ) / 2) < R + r;
}

/** Does a footprint of radius r at (x, z) reach over a solid's top? */
function under(s: Solid, x: number, z: number, r: number): boolean {
  if (x + r <= s.minX || x - r >= s.maxX || z + r <= s.minZ || z - r >= s.maxZ) return false;
  if (!s.round) return true;
  return Math.hypot(x - (s.minX + s.maxX) / 2, z - (s.minZ + s.maxZ) / 2) < (s.maxX - s.minX) / 2 + r;
}

/** Push a body of radius r at p out of a round solid, straight away from its centre. */
function pushRound(s: Solid, p: THREE.Vector3, r: number): void {
  const cx = (s.minX + s.maxX) / 2;
  const cz = (s.minZ + s.maxZ) / 2;
  let dx = p.x - cx;
  let dz = p.z - cz;
  const d = Math.hypot(dx, dz);
  if (d < 1e-5) {
    dx = 1;
    dz = 0;
  } else {
    dx /= d;
    dz /= d;
  }
  const out = (s.maxX - s.minX) / 2 + r + 1e-4;
  p.x = cx + dx * out;
  p.z = cz + dz * out;
}

export interface Ledge {
  solid: Solid;
  /** Outward face normal (horizontal unit vector). */
  normal: THREE.Vector3;
  /**
   * Direction along the edge (+X or +Z for furniture; any level direction on the train).
   * `min`/`max` are positions along it, measured as dot(point, tangent).
   */
  tangent: THREE.Vector3;
  /** Point on the top edge closest to the fighter. */
  point: THREE.Vector3;
  /** Allowed range of the edge coordinate along the tangent. */
  min: number;
  max: number;
}

export class World {
  /** Extra ledges that aren't boxes on the axes (the edges of the moving train cars). */
  movingLedges: ((pos: THREE.Vector3, dir: THREE.Vector3) => { ledge: Ledge; score: number } | null) | null = null;

  constructor(readonly solids: Solid[]) {}

  /** Move a body through the world, resolving each axis against the boxes. */
  move(body: Body, dt: number): { hitWall: boolean; landed: boolean; landSpeed: number } {
    const steps = Math.max(1, Math.ceil((Math.abs(body.vel.x) + Math.abs(body.vel.y) + Math.abs(body.vel.z)) * dt / 0.12));
    const h = dt / steps;
    let hitWall = false;
    let landed = false;
    let landSpeed = 0;
    this.depenetrate(body);
    const wasGround = body.onGround;
    body.onGround = false;
    for (let i = 0; i < steps; i++) {
      hitWall = this.axis(body, 'x', body.vel.x * h, wasGround) || hitWall;
      hitWall = this.axis(body, 'z', body.vel.z * h, wasGround) || hitWall;
      const vy = body.vel.y;
      const r = this.vertical(body, vy * h);
      if (r === 'floor') {
        if (!wasGround && !landed) {
          landed = true;
          landSpeed = -vy;
        }
      }
    }
    // Stay glued to the floor when walking down tiny steps.
    if (!body.onGround && wasGround && body.vel.y <= 0) {
      const below = this.groundBelow(body.pos.x, body.pos.y + 0.01, body.pos.z, RADIUS * 0.8);
      if (below && body.pos.y - below.maxY < STEP_UP) {
        body.pos.y = below.maxY;
        body.onGround = true;
        body.support = below;
        body.vel.y = 0;
      }
    }
    return { hitWall, landed, landSpeed };
  }

  /**
   * If the body already overlaps a box (after hanging, climbing or being shoved),
   * push it out along the shallowest direction instead of letting a later axis
   * step fling it across the box.
   */
  private depenetrate(body: Body): void {
    const p = body.pos;
    for (let iter = 0; iter < 3; iter++) {
      let moved = false;
      for (const s of this.solids) {
        if (!overlaps(s, p.x, p.y + 0.001, p.z, body.height, RADIUS)) continue;
        if (s.round) {
          const R = (s.maxX - s.minX) / 2;
          const d = Math.hypot(p.x - (s.minX + s.maxX) / 2, p.z - (s.minZ + s.maxZ) / 2);
          if (s.maxY - p.y <= STEP_UP && s.maxY - p.y < R + RADIUS - d) p.y = s.maxY;
          else pushRound(s, p, RADIUS);
          moved = true;
          continue;
        }
        const options: [number, 'x' | 'y' | 'z', number][] = [
          [p.x + RADIUS - s.minX, 'x', s.minX - RADIUS - 1e-4],
          [s.maxX - (p.x - RADIUS), 'x', s.maxX + RADIUS + 1e-4],
          [p.z + RADIUS - s.minZ, 'z', s.minZ - RADIUS - 1e-4],
          [s.maxZ - (p.z - RADIUS), 'z', s.maxZ + RADIUS + 1e-4],
          // Stepping up onto a low lip is fine; never pop on top of something tall.
          [s.maxY - p.y <= STEP_UP ? s.maxY - p.y : Infinity, 'y', s.maxY],
        ];
        options.sort((a, b) => a[0] - b[0]);
        const [, axis, value] = options[0]!;
        p[axis] = value;
        if (axis !== 'y') body.vel[axis] = 0;
        moved = true;
      }
      if (!moved) break;
    }
  }

  private axis(body: Body, axis: 'x' | 'z', delta: number, grounded: boolean): boolean {
    if (delta === 0) return false;
    const p = body.pos;
    p[axis] += delta;
    let hit = false;
    for (const s of this.solids) {
      if (!overlaps(s, p.x, p.y + 0.001, p.z, body.height, RADIUS)) continue;
      // Small lips are stepped onto rather than blocking.
      if (grounded && s.maxY - p.y <= STEP_UP && !this.blocked(p.x, s.maxY, p.z, body.height)) {
        p.y = s.maxY;
        continue;
      }
      if (s.round) {
        pushRound(s, p, RADIUS);
        hit = true;
        continue;
      }
      // Push back out on the side we came from (judged by where the centre was before
      // this step), never through to the far side of the box.
      const before = p[axis] - delta;
      const min = axis === 'x' ? s.minX : s.minZ;
      const max = axis === 'x' ? s.maxX : s.maxZ;
      p[axis] = before < (min + max) / 2 ? min - RADIUS - 1e-4 : max + RADIUS + 1e-4;
      body.vel[axis] = 0;
      hit = true;
    }
    return hit;
  }

  private vertical(body: Body, delta: number): 'floor' | 'ceiling' | null {
    const p = body.pos;
    p.y += delta;
    let result: 'floor' | 'ceiling' | null = null;
    for (const s of this.solids) {
      if (!overlaps(s, p.x, p.y, p.z, body.height, RADIUS * 0.92)) continue;
      if (delta <= 0) {
        p.y = s.maxY;
        body.vel.y = 0;
        body.onGround = true;
        body.support = s;
        result = 'floor';
      } else {
        p.y = s.minY - body.height - 1e-4;
        body.vel.y = 0;
        result = 'ceiling';
      }
    }
    if (delta <= 0 && result === null) {
      // Resting exactly on a surface: keep the ground flag without jitter.
      const s = this.groundBelow(p.x, p.y + 1e-3, p.z, RADIUS * 0.92);
      if (s && p.y - s.maxY < 1e-3) {
        body.onGround = true;
        body.support = s;
        result = 'floor';
      }
    }
    return result;
  }

  /** Is a standing box at (x, y, z) obstructed? */
  blocked(x: number, y: number, z: number, h: number, r = RADIUS): boolean {
    for (const s of this.solids) if (overlaps(s, x, y + 0.002, z, h - 0.004, r)) return true;
    return false;
  }

  /** Like blocked(), ignoring things that move (the toy train). */
  blockedStatic(x: number, y: number, z: number, h: number, r = RADIUS): boolean {
    for (const s of this.solids) if (!s.moving && overlaps(s, x, y + 0.002, z, h - 0.004, r)) return true;
    return false;
  }

  /** Highest solid top at or below y under the given footprint. */
  groundBelow(x: number, y: number, z: number, r = 0.05): Solid | null {
    let best: Solid | null = null;
    for (const s of this.solids) {
      if (!under(s, x, z, r)) continue;
      if (s.maxY > y + 1e-3) continue;
      if (!best || s.maxY > best.maxY) best = s;
    }
    return best;
  }

  /** Height of the floor under a point (used for shadows, items and markers). */
  heightAt(x: number, y: number, z: number): number {
    return this.groundBelow(x, y, z)?.maxY ?? 0;
  }

  /**
   * Look for a grabbable ledge in front of an airborne fighter at `pos`
   * reaching toward `dir` (horizontal unit vector).
   */
  findLedge(pos: THREE.Vector3, dir: THREE.Vector3): Ledge | null {
    let best: Ledge | null = null;
    let bestScore = Infinity;
    for (const s of this.solids) {
      if (s.grab === false) continue;
      const rise = s.maxY - pos.y;
      if (rise < GRAB_MIN || rise > GRAB_MAX) continue;
      if (s.round) {
        // The rim of a round top: the ledge is the point of the rim nearest the samurai.
        const cx = (s.minX + s.maxX) / 2;
        const cz = (s.minZ + s.maxZ) / 2;
        const R = (s.maxX - s.minX) / 2;
        const d = Math.hypot(pos.x - cx, pos.z - cz);
        if (d < 1e-4) continue;
        const nx = (pos.x - cx) / d;
        const nz = (pos.z - cz) / d;
        if (nx * dir.x + nz * dir.z > -0.35) continue;
        const out = d - R;
        if (out < RADIUS - 0.12 || out > RADIUS + 0.32) continue;
        const px = cx + nx * R;
        const pz = cz + nz * R;
        if (this.blocked(px - nx * 0.32, s.maxY, pz - nz * 0.32, 0.66, 0.2)) continue;
        if (this.blockedExcept(px + nx * HANG_OUT, s.maxY - HANG_DROP, pz + nz * HANG_OUT, HANG_DROP - 0.05, RADIUS - 0.01, s)) continue;
        const score = Math.abs(out - RADIUS) + Math.abs(rise - 1.15) * 0.3;
        if (score < bestScore) {
          bestScore = score;
          const tangent = new THREE.Vector3(-nz, 0, nx);
          const point = new THREE.Vector3(px, s.maxY, pz);
          const at = point.dot(tangent);
          // A curved rim can't be shimmied along a straight line: the hold is a single spot.
          best = { solid: s, normal: new THREE.Vector3(nx, 0, nz), tangent, point, min: at, max: at };
        }
        continue;
      }
      // Each vertical face: outward normal, distance from the fighter to the face plane.
      const faces: [number, number, number, number, number][] = [
        // nx, nz, plane, range min, range max (along the tangent axis)
        [-1, 0, s.minX, s.minZ, s.maxZ],
        [1, 0, s.maxX, s.minZ, s.maxZ],
        [0, -1, s.minZ, s.minX, s.maxX],
        [0, 1, s.maxZ, s.minX, s.maxX],
      ];
      for (const [nx, nz, plane, lo, hi] of faces) {
        if (nx * dir.x + nz * dir.z > -0.35) continue; // must be reaching into the face
        const out = nx !== 0 ? (pos.x - plane) * nx : (pos.z - plane) * nz;
        if (out < RADIUS - 0.12 || out > RADIUS + 0.32) continue;
        const along = nx !== 0 ? pos.z : pos.x;
        const min = lo + 0.14;
        const max = hi - 0.14;
        if (max <= min || along < min - 0.05 || along > max + 0.05) continue;
        const a = Math.min(max, Math.max(min, along));
        const px = nx !== 0 ? plane : a;
        const pz = nx !== 0 ? a : plane;
        // Room above the ledge to climb onto it, and room for the body to hang.
        const inX = px - nx * 0.32;
        const inZ = pz - nz * 0.32;
        if (this.blocked(inX, s.maxY, inZ, 0.66, 0.2)) continue;
        const hx = px + nx * HANG_OUT;
        const hz = pz + nz * HANG_OUT;
        if (this.blockedExcept(hx, s.maxY - HANG_DROP, hz, HANG_DROP - 0.05, RADIUS - 0.01, s)) continue;
        const score = Math.abs(out - RADIUS) + Math.abs(rise - 1.15) * 0.3;
        if (score < bestScore) {
          bestScore = score;
          best = {
            solid: s,
            normal: new THREE.Vector3(nx, 0, nz),
            tangent: nx !== 0 ? new THREE.Vector3(0, 0, 1) : new THREE.Vector3(1, 0, 0),
            point: new THREE.Vector3(px, s.maxY, pz),
            min,
            max,
          };
        }
      }
    }
    const moving = this.movingLedges?.(pos, dir);
    if (moving && moving.score < bestScore) return moving.ledge;
    return best;
  }

  private blockedExcept(x: number, y: number, z: number, h: number, r: number, skip: Solid): boolean {
    for (const s of this.solids) if (s !== skip && overlaps(s, x, y, z, h, r)) return true;
    return false;
  }

  /** Line-of-sight test between two points (true if a solid lies between them). */
  occluded(a: THREE.Vector3, b: THREE.Vector3): boolean {
    _ray.set(a, _dir.subVectors(b, a).normalize());
    const len = a.distanceTo(b);
    for (const s of this.solids) {
      _box.min.set(s.minX, s.minY, s.minZ);
      _box.max.set(s.maxX, s.maxY, s.maxZ);
      const hit = _ray.intersectBox(_box, _hit);
      if (hit && hit.distanceTo(a) < len - 0.05) return true;
    }
    return false;
  }
}

const _ray = new THREE.Ray();
const _dir = new THREE.Vector3();
const _box = new THREE.Box3();
const _hit = new THREE.Vector3();