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

const _a = new THREE.Vector3();
const _dir = new THREE.Vector3();
const _min = new THREE.Vector3();
const _max = new THREE.Vector3();
const _f = new THREE.Vector3();
const _r = new THREE.Vector3();
const _u = new THREE.Vector3();
const _a2 = new THREE.Vector3();

/**
 * Fighting-game camera: frames every living fighter from above and to the side,
 * swinging slowly to stay side-on to the main duel. Walls and furniture that
 * block the view are hidden or faded.
 */
export class FightCamera {
  readonly camera = new THREE.PerspectiveCamera(40, 1, 0.1, 400);
  yaw = 0;
  private yawTarget = 0;
  private turning = false;
  /** Set once the player steers the camera; stops the automatic side-on swing. */
  manual = false;
  /** Mouse-look speed multiplier (the in-game Mouse speed slider). */
  sensitivity = 1;
  pitch = 0.6;
  private dist = 9;
  private readonly focus = new THREE.Vector3();
  private shakeAmt = 0;
  private shakeTime = 0;
  /** Automatic slow orbit (menu backdrop). */
  orbit = false;
  /** Closest the camera may come (pulled back further when exploring). */
  minDist = 6.5;
  /** Shift the subject right by this fraction of the frame width (to clear a side menu). */
  offsetX = 0;
  shakeEnabled = true;
  /** Furniture this camera sees through: its own see-through copy of each material, swapped in only while it renders. */
  private readonly faded = new Map<THREE.Mesh, { original: THREE.Material | THREE.Material[]; clone: THREE.Material; fade: number }>();
  private readonly raycaster = new THREE.Raycaster();

  constructor(private room: RoomData | null = null) {}

  setRoom(room: RoomData, yaw: number): void {
    this.restoreFaded();
    this.room = room;
    this.yaw = this.yawTarget = yaw;
    this.wallFade.clear();
    // Wall meshes fade as whole walls; the furniture fader leaves them alone.
    for (const w of room.cutaway) w.mesh.traverse((o) => (o.userData.wall = true));
  }

  /** How solid each wall is for this camera: 1 = solid, low = see-through. */
  private readonly wallFade = new Map<THREE.Object3D, number>();
  /** See-through copies of wall materials, by material and opacity step. */
  private readonly wallMats = new Map<string, THREE.Material>();
  private readonly swapped: { mesh: THREE.Mesh; material: THREE.Material | THREE.Material[] }[] = [];

  shake(amount: number): void {
    if (this.shakeEnabled) this.shakeAmt = Math.min(0.5, this.shakeAmt + amount);
  }

  /** Place the camera instantly (round start), with `left` on the left of the screen. */
  snap(points: THREE.Vector3[], aspect: number, left?: THREE.Vector3, right?: THREE.Vector3): void {
    if (left && right) {
      this.yaw = this.yawTarget = Math.atan2(right.x - left.x, right.z - left.z) - Math.PI / 2;
      this.turning = false;
    }
    this.update(0, points, null, aspect, true);
  }

  /** Manually orbit the camera. Positive turns the view left. */
  turn(amount: number): void {
    this.yaw += amount;
    this.yawTarget = this.yaw;
    this.turning = false;
    this.manual = true;
  }

  /** Mouse look: horizontal pixels orbit, vertical pixels tilt. */
  look(dx: number, dy: number): void {
    if (dx) this.turn(-dx * 0.0045 * this.sensitivity);
    if (dy) {
      this.pitch = Math.min(1.2, Math.max(0.15, this.pitch + dy * 0.003 * this.sensitivity));
      this.manual = true;
    }
  }

  /** Tilt the view: positive looks down from higher up (like moving the mouse down). */
  tilt(amount: number): void {
    this.pitch = Math.min(1.2, Math.max(0.15, this.pitch + amount));
    this.manual = true;
  }

  /** Horizontal forward and right vectors, for camera-relative controls. */
  basis(forward: THREE.Vector3, right: THREE.Vector3): void {
    forward.set(-Math.sin(this.yaw), 0, -Math.cos(this.yaw));
    right.set(-forward.z, 0, forward.x);
  }

  update(dt: number, points: THREE.Vector3[], duel: [THREE.Vector3, THREE.Vector3] | null, aspect: number, instant = false): void {
    const cam = this.camera;
    cam.aspect = aspect;
    // Narrow frames need a wider lens to fit the fighters.
    cam.fov = aspect < 0.8 ? 58 : aspect < 1.2 ? 48 : 40;
    if (this.offsetX) cam.setViewOffset(1000 * aspect, 1000, -this.offsetX * 1000 * aspect, 0, 1000 * aspect, 1000);
    else cam.clearViewOffset();
    cam.updateProjectionMatrix();

    // Focus on the middle of everyone's bounding box.
    _min.set(Infinity, Infinity, Infinity);
    _max.set(-Infinity, -Infinity, -Infinity);
    for (const p of points) {
      _min.min(p);
      _max.max(p);
    }
    const center = points.length ? _a.addVectors(_min, _max).multiplyScalar(0.5) : _a.set(0, 0, 0);
    center.y += 0.55;

    if (this.orbit) {
      this.yawTarget = this.yaw + dt * 0.12;
      this.turning = true;
    } else if (duel && !this.manual) {
      // Side-on to the line between the duellists, picking the side we're already on.
      const lx = duel[1].x - duel[0].x;
      const lz = duel[1].z - duel[0].z;
      if (Math.hypot(lx, lz) > 0.6) {
        const base = Math.atan2(lx, lz) + Math.PI / 2;
        const a = wrap(base - this.yaw);
        const b = wrap(base + Math.PI - this.yaw);
        const want = this.yaw + (Math.abs(a) < Math.abs(b) ? a : b);
        if (Math.abs(wrap(want - this.yaw)) > 0.55) this.turning = true;
        if (this.turning) this.yawTarget = want;
        if (Math.abs(wrap(this.yawTarget - this.yaw)) < 0.06) this.turning = false;
      }
    }
    const yawStep = wrap(this.yawTarget - this.yaw);
    this.yaw += instant ? yawStep : Math.sign(yawStep) * Math.min(Math.abs(yawStep), dt * (0.25 + Math.abs(yawStep) * 0.9));

    const wantDist = Math.min(30, Math.max(aspect < 1 ? this.minDist + 1.5 : this.minDist, this.fitDistance(points, center, aspect)));
    const k = instant ? 1 : 1 - Math.exp(-dt * 3.5);
    this.dist += (wantDist - this.dist) * (instant ? 1 : 1 - Math.exp(-dt * 1.6));
    this.focus.lerp(center, k);
    if (instant) this.focus.copy(center);

    const cp = Math.cos(this.pitch);
    cam.position.set(
      this.focus.x + Math.sin(this.yaw) * cp * this.dist,
      this.focus.y + Math.sin(this.pitch) * this.dist,
      this.focus.z + Math.cos(this.yaw) * cp * this.dist,
    );
    cam.lookAt(this.focus);
    if (this.shakeAmt > 0.001) {
      this.shakeTime += dt * 60;
      const s = this.shakeAmt * 0.12;
      cam.position.x += Math.sin(this.shakeTime * 1.7) * s;
      cam.position.y += Math.sin(this.shakeTime * 2.3) * s;
      cam.rotateZ(Math.sin(this.shakeTime * 1.3) * s * 0.15);
      this.shakeAmt *= Math.exp(-dt * 9);
    }
    this.fadeOccluders(points, dt);
    this.cutaway(dt);
  }

  /**
   * Smallest distance at which every fighter (feet to head, plus a margin)
   * fits inside the frame for the current yaw and pitch.
   */
  private fitDistance(points: THREE.Vector3[], center: THREE.Vector3, aspect: number): number {
    const cp = Math.cos(this.pitch);
    const sp = Math.sin(this.pitch);
    const f = _f.set(-Math.sin(this.yaw) * cp, -sp, -Math.cos(this.yaw) * cp);
    const r = _r.set(Math.cos(this.yaw), 0, -Math.sin(this.yaw));
    const u = _u.crossVectors(r, f);
    const tanV = Math.tan((this.camera.fov * Math.PI) / 360);
    const tanH = tanV * aspect;
    let need = 0;
    for (const p of points) {
      for (const lift of [-0.1, 1.25]) {
        const rel = _a2.set(p.x, p.y + lift, p.z).sub(center);
        const along = rel.dot(f);
        // Leave room for the HUD across the top of the frame.
        need = Math.max(need, Math.abs(rel.dot(r)) / (tanH * 0.82) - along, rel.dot(u) / (tanV * 0.62) - along, -rel.dot(u) / (tanV * 0.85) - along);
      }
    }
    return need + 1.5;
  }

  /**
   * Fade the walls on the camera's side of the room (their bottom strip stays, so the
   * room never looks open to the outside), and bring them back when it moves on.
   */
  private cutaway(dt: number): void {
    if (!this.room) return;
    const hidden = this.room.areas ? this.houseCutaway() : this.roomCutaway();
    const k = dt > 0 ? 1 - Math.exp(-dt * 10) : 1;
    for (const w of this.room.cutaway) {
      const want = hidden.has(w.mesh) ? 0.12 : 1;
      const now = this.wallFade.get(w.mesh) ?? 1;
      this.wallFade.set(w.mesh, now + (want - now) * k);
    }
  }

  private roomCutaway(): Set<THREE.Object3D> {
    const out = new Set<THREE.Object3D>();
    this.camera.getWorldDirection(_dir);
    _dir.y = 0;
    _dir.normalize();
    for (const w of this.room!.cutaway) if (w.normal.dot(_dir) >= 0.25) out.add(w.mesh);
    return out;
  }

  /**
   * In the house: hide every wall standing between the camera and what it looks at,
   * so the rooms beyond (and the walls behind the fight) stay up.
   */
  private houseCutaway(): Set<THREE.Object3D> {
    const out = new Set<THREE.Object3D>();
    const c = this.camera.position;
    const f = this.focus;
    for (const w of this.room!.cutaway) {
      const p = w.plane;
      if (!p) continue;
      const ca = p.axis === 'x' ? c.x : c.z;
      const fa = p.axis === 'x' ? f.x : f.z;
      const cu = p.axis === 'x' ? c.z : c.x;
      const fu = p.axis === 'x' ? f.z : f.x;
      const pad = p.pad ?? 7;
      // Right up against it, or looking through it.
      let hide = Math.abs(ca - p.at) < 2 && cu > p.u0 - pad && cu < p.u1 + pad;
      if (!hide && (ca - p.at) * (fa - p.at) < 0) {
        // Where the sight line crosses the wall's plane.
        const u = cu + ((fu - cu) * (p.at - ca)) / (fa - ca);
        hide = u > p.u0 - pad && u < p.u1 + pad;
      }
      if (hide) out.add(w.mesh);
    }
    return out;
  }

  /** Fade furniture standing between the camera and any fighter. */
  private fadeOccluders(points: THREE.Vector3[], dt: number): void {
    if (!this.room) return;
    const hits = new Set<THREE.Mesh>();
    const from = this.camera.position;
    for (const p of points) {
      for (const lift of [0.3, 0.8]) {
        const target = _a.set(p.x, p.y + lift, p.z);
        _dir.subVectors(target, from);
        const len = _dir.length();
        this.raycaster.set(from, _dir.divideScalar(len));
        this.raycaster.far = len - 0.35;
        for (const hit of this.raycaster.intersectObject(this.room.root, true)) {
          const m = hit.object;
          if (m instanceof THREE.Mesh && !(m instanceof THREE.InstancedMesh) && m.visible && !m.userData.wall) hits.add(m);
        }
      }
    }
    for (const m of hits) {
      if (!this.faded.has(m)) {
        const original = m.material;
        const clone = (Array.isArray(original) ? original[0]! : original).clone();
        clone.transparent = true;
        clone.depthWrite = false;
        this.faded.set(m, { original, clone, fade: 1 });
      }
    }
    for (const [m, state] of this.faded) {
      const want = hits.has(m) ? 0.14 : 1;
      state.fade += (want - state.fade) * Math.min(1, dt * 10);
      state.clone.opacity = state.fade;
      if (want === 1 && state.fade > 0.97) {
        state.clone.dispose();
        this.faded.delete(m);
      }
    }
  }

  /**
   * Apply this camera's view of the room (hidden walls, see-through furniture) just
   * before rendering with it; endRender puts everything back for the next camera.
   */
  beginRender(): void {
    for (const [m, state] of this.faded) m.material = state.clone;
    if (!this.room) return;
    for (const w of this.room.cutaway) {
      const fade = this.wallFade.get(w.mesh) ?? 1;
      if (fade > 0.98) continue;
      const step = Math.max(1, Math.round(fade * 20));
      w.mesh.traverse((o) => {
        if (!(o instanceof THREE.Mesh) || Array.isArray(o.material)) return;
        const key = `${o.material.id}:${step}`;
        let see = this.wallMats.get(key);
        if (!see) {
          const made = o.material.clone();
          made.transparent = true;
          made.depthWrite = false;
          made.opacity = step / 20;
          this.wallMats.set(key, made);
          see = made;
        }
        this.swapped.push({ mesh: o, material: o.material });
        o.material = see;
      });
    }
  }

  endRender(): void {
    for (const [m, state] of this.faded) m.material = state.original;
    for (const s of this.swapped) s.mesh.material = s.material;
    this.swapped.length = 0;
  }

  restoreFaded(): void {
    for (const state of this.faded.values()) state.clone.dispose();
    this.faded.clear();
    for (const m of this.wallMats.values()) m.dispose();
    this.wallMats.clear();
  }
}

function wrap(a: number): number {
  return Math.atan2(Math.sin(a), Math.cos(a));
}