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/throwables.ts
import * as THREE from 'three';
import type { RoomId, ThrowDef } from './types';

/*
 * Throwable household objects. Scale: 1 unit = 10 cm, real objects are scaled
 * down ~0.45x so a 1-unit samurai can lift them over its head with both hands.
 *
 * Mesh convention (see types.ts): origin at the centre of the object's BOTTOM
 * face, upright along +Y up to y = height, footprint within `radius`.
 * Exception: the big wrench lies along X and its mesh extends to about ±0.8.
 */

// ---------------------------------------------------------------------------
// Materials and primitive helpers (local copies of the items.ts style)
// ---------------------------------------------------------------------------

interface MatOpts {
  roughness?: number;
  metalness?: number;
  double?: boolean;
  /** < 1 makes the material transparent (glass). */
  opacity?: number;
}

const METAL: MatOpts = { metalness: 0.6, roughness: 0.35 };
const PLASTIC: MatOpts = { metalness: 0, roughness: 0.55 };
const GLOSSY: MatOpts = { metalness: 0, roughness: 0.3 };
const WOOD: MatOpts = { metalness: 0, roughness: 0.85 };
const SOFT: MatOpts = { metalness: 0, roughness: 0.95 };
const GLASS: MatOpts = { metalness: 0.1, roughness: 0.15, opacity: 0.55 };
const CLEAR: MatOpts = { metalness: 0.1, roughness: 0.1, opacity: 0.3 };

const matCache = new Map<string, THREE.MeshStandardMaterial>();

function mat(color: number, opts: MatOpts = PLASTIC): THREE.MeshStandardMaterial {
  const roughness = opts.roughness ?? 0.6;
  const metalness = opts.metalness ?? 0;
  const double = opts.double ?? false;
  const opacity = opts.opacity ?? 1;
  const key = `${color}|${roughness}|${metalness}|${double}|${opacity}`;
  let m = matCache.get(key);
  if (!m) {
    m = new THREE.MeshStandardMaterial({
      color,
      flatShading: true,
      roughness,
      metalness,
      side: double ? THREE.DoubleSide : THREE.FrontSide,
      transparent: opacity < 1,
      opacity,
      depthWrite: opacity >= 1,
    });
    matCache.set(key, m);
  }
  return m;
}

function put(
  group: THREE.Group,
  geo: THREE.BufferGeometry,
  material: THREE.Material,
  x = 0,
  y = 0,
  z = 0,
  rx = 0,
  ry = 0,
  rz = 0,
): THREE.Mesh {
  const m = new THREE.Mesh(geo, material);
  m.position.set(x, y, z);
  m.rotation.set(rx, ry, rz);
  m.castShadow = true;
  m.receiveShadow = true;
  group.add(m);
  return m;
}

/** Box centred at (x, y, z). */
function box(
  g: THREE.Group,
  w: number,
  h: number,
  d: number,
  material: THREE.Material,
  x = 0,
  y = 0,
  z = 0,
  rx = 0,
  ry = 0,
  rz = 0,
): THREE.Mesh {
  return put(g, new THREE.BoxGeometry(w, h, d), material, x, y, z, rx, ry, rz);
}

/** Vertical cylinder spanning y0..y1. */
function rod(
  g: THREE.Group,
  rBottom: number,
  rTop: number,
  y0: number,
  y1: number,
  material: THREE.Material,
  seg = 8,
  x = 0,
  z = 0,
): THREE.Mesh {
  const h = y1 - y0;
  return put(g, new THREE.CylinderGeometry(rTop, rBottom, h, seg), material, x, (y0 + y1) / 2, z);
}

/** Vertical cone with its base at y0 and its point at y1 (y1 > y0 points up). */
function cone(
  g: THREE.Group,
  r: number,
  y0: number,
  y1: number,
  material: THREE.Material,
  seg = 8,
  x = 0,
  z = 0,
): THREE.Mesh {
  const h = Math.abs(y1 - y0);
  const m = put(g, new THREE.ConeGeometry(r, h, seg), material, x, (y0 + y1) / 2, z);
  if (y1 < y0) m.rotation.x = Math.PI;
  return m;
}

// ---------------------------------------------------------------------------
// Builders
// ---------------------------------------------------------------------------

function buildMug(): THREE.Group {
  const g = new THREE.Group();
  const glaze = mat(0x3f7fc4, GLOSSY);
  rod(g, 0.16, 0.17, 0, 0.45, glaze, 10);
  rod(g, 0.172, 0.172, 0.3, 0.36, mat(0xf4efe4, GLOSSY), 10); // cream stripe
  rod(g, 0.15, 0.15, 0.43, 0.445, mat(0x4a2a17, GLOSSY), 10); // coffee
  // handle: half torus bulging to +X
  const handle = new THREE.TorusGeometry(0.09, 0.028, 5, 8, Math.PI);
  put(g, handle, glaze, 0.165, 0.23, 0, 0, 0, -Math.PI / 2);
  return g;
}

function buildBottle(): THREE.Group {
  const g = new THREE.Group();
  const glass = mat(0x2f9a4a, GLASS);
  rod(g, 0.15, 0.15, 0, 0.5, glass, 10);
  rod(g, 0.15, 0.065, 0.5, 0.66, glass, 10);
  rod(g, 0.065, 0.055, 0.66, 0.83, glass, 10);
  rod(g, 0.07, 0.07, 0.82, 0.85, glass, 10); // lip
  rod(g, 0.07, 0.065, 0.85, 0.9, mat(0xd8342c, METAL), 10); // crown cap
  rod(g, 0.157, 0.157, 0.18, 0.38, mat(0xf6e7b8, PLASTIC), 10); // label
  rod(g, 0.159, 0.159, 0.25, 0.31, mat(0xd8342c, PLASTIC), 10); // label stripe
  rod(g, 0.12, 0.12, 0.02, 0.3, mat(0x7a3b1a, GLOSSY), 8); // soda inside
  return g;
}

function buildCan(): THREE.Group {
  const g = new THREE.Group();
  const tin = mat(0xc9ccd1, METAL);
  rod(g, 0.15, 0.15, 0.02, 0.48, tin, 10);
  rod(g, 0.157, 0.157, 0, 0.035, tin, 10); // bottom rim
  rod(g, 0.157, 0.157, 0.465, 0.5, tin, 10); // top rim
  rod(g, 0.153, 0.153, 0.06, 0.44, mat(0xc8262b, PLASTIC), 10); // label
  rod(g, 0.155, 0.155, 0.13, 0.25, mat(0xf5f0e2, PLASTIC), 10); // white band
  rod(g, 0.156, 0.156, 0.17, 0.21, mat(0xd9a520, METAL), 10); // gold medallion strip
  rod(g, 0.12, 0.12, 0.5, 0.505, tin, 10); // lid
  return g;
}

function buildApple(): THREE.Group {
  const g = new THREE.Group();
  const skin = put(g, new THREE.SphereGeometry(0.19, 9, 7), mat(0xc8202a, GLOSSY), 0, 0.175, 0);
  skin.scale.set(1, 0.9, 1);
  put(g, new THREE.SphereGeometry(0.1, 7, 5), mat(0xe8503a, GLOSSY), 0.06, 0.22, 0.1).scale.set(1, 0.9, 0.7); // blush
  rod(g, 0.05, 0.02, 0.31, 0.335, mat(0x6a1218, SOFT), 7); // dimple
  rod(g, 0.014, 0.012, 0.3, 0.42, mat(0x5a3a1c, WOOD), 5); // stem
  const leaf = put(g, new THREE.SphereGeometry(0.06, 6, 4), mat(0x4caf3a, SOFT), 0.06, 0.39, 0, 0, 0, -0.5);
  leaf.scale.set(1, 0.18, 0.5);
  return g;
}

function buildSalt(): THREE.Group {
  const g = new THREE.Group();
  rod(g, 0.13, 0.115, 0, 0.36, mat(0xdff2f5, GLASS), 8);
  rod(g, 0.105, 0.1, 0.02, 0.24, mat(0xffffff, SOFT), 8); // salt
  const cap = mat(0xb9bec6, METAL);
  rod(g, 0.122, 0.122, 0.34, 0.44, cap, 10);
  const dome = put(g, new THREE.SphereGeometry(0.122, 10, 4, 0, Math.PI * 2, 0, Math.PI / 2), cap, 0, 0.44, 0);
  dome.scale.set(1, 0.4, 1);
  // holes suggested by dark dots on the dome
  const hole = new THREE.CylinderGeometry(0.014, 0.014, 0.012, 6);
  const dark = mat(0x22252a, SOFT);
  put(g, hole, dark, 0, 0.489, 0);
  for (let i = 0; i < 5; i++) {
    const a = (i / 5) * Math.PI * 2;
    put(g, hole, dark, Math.cos(a) * 0.055, 0.482, Math.sin(a) * 0.055);
  }
  return g;
}

function buildSoap(): THREE.Group {
  const g = new THREE.Group();
  const pink = mat(0xf6b3c8, SOFT);
  // stadium-shaped bar: centre box + two rounded ends
  box(g, 0.24, 0.2, 0.3, pink, 0, 0.1, 0);
  const end = new THREE.CylinderGeometry(0.15, 0.15, 0.2, 10);
  put(g, end, pink, 0.12, 0.1, 0);
  put(g, end, pink, -0.12, 0.1, 0);
  // softer top bevel
  box(g, 0.24, 0.04, 0.24, pink, 0, 0.22, 0);
  const topEnd = new THREE.CylinderGeometry(0.12, 0.15, 0.04, 10);
  put(g, topEnd, pink, 0.12, 0.22, 0);
  put(g, topEnd, pink, -0.12, 0.22, 0);
  // stamped indent
  box(g, 0.22, 0.012, 0.1, mat(0xe48aa6, SOFT), 0, 0.238, 0);
  return g;
}

function buildShampoo(): THREE.Group {
  const g = new THREE.Group();
  const plastic = mat(0x6ec6d8, GLOSSY);
  rod(g, 0.17, 0.17, 0, 0.55, plastic, 10).scale.z = 0.6;
  rod(g, 0.17, 0.08, 0.55, 0.63, plastic, 10).scale.z = 0.6;
  rod(g, 0.172, 0.172, 0.14, 0.42, mat(0xffffff, PLASTIC), 10).scale.z = 0.62; // label
  rod(g, 0.174, 0.174, 0.24, 0.3, mat(0xf27aa0, PLASTIC), 10).scale.z = 0.63; // label stripe
  const capMat = mat(0xf7f3ea, PLASTIC);
  rod(g, 0.08, 0.075, 0.63, 0.72, capMat, 10);
  box(g, 0.13, 0.025, 0.08, capMat, -0.02, 0.735, 0, 0, 0, 0.12); // flip lid, slightly open
  rod(g, 0.015, 0.015, 0.72, 0.74, mat(0x3a7f8c, PLASTIC), 6, 0.04); // nozzle
  return g;
}

function buildCup(): THREE.Group {
  const g = new THREE.Group();
  rod(g, 0.13, 0.16, 0, 0.42, mat(0x7ad3b5, GLOSSY), 10);
  rod(g, 0.165, 0.165, 0.4, 0.43, mat(0xa9e8d2, GLOSSY), 10); // rim
  rod(g, 0.145, 0.145, 0.405, 0.41, mat(0x2d5a4d, SOFT), 10); // dark inside
  // toothbrush leaning out of the cup
  const tb = new THREE.Group();
  box(tb, 0.035, 0.42, 0.025, mat(0xf2a33a, GLOSSY), 0, 0.21, 0);
  box(tb, 0.045, 0.1, 0.03, mat(0xf2a33a, GLOSSY), 0, 0.45, 0);
  box(tb, 0.04, 0.08, 0.035, mat(0xffffff, SOFT), 0, 0.45, 0.03); // bristles
  tb.position.set(-0.04, 0.12, 0);
  tb.rotation.z = -0.25;
  g.add(tb);
  return g;
}

function buildSponge(): THREE.Group {
  const g = new THREE.Group();
  box(g, 0.4, 0.19, 0.26, mat(0xf5d33f, SOFT), 0, 0.095, 0);
  box(g, 0.4, 0.07, 0.26, mat(0x3f9a4a, SOFT), 0, 0.225, 0);
  // pores
  const pore = new THREE.BoxGeometry(0.03, 0.03, 0.01);
  const dark = mat(0xc9a226, SOFT);
  const spots: [number, number][] = [
    [-0.13, 0.06],
    [-0.04, 0.13],
    [0.06, 0.07],
    [0.14, 0.14],
  ];
  for (const [x, y] of spots) {
    put(g, pore, dark, x, y, 0.131);
    put(g, pore, dark, -x, y + 0.01, -0.131);
  }
  return g;
}

function buildAlarmClock(): THREE.Group {
  const g = new THREE.Group();
  const red = mat(0xd8332c, GLOSSY);
  const brass = mat(0xd4a83a, METAL);
  // round body facing +Z
  put(g, new THREE.CylinderGeometry(0.22, 0.22, 0.14, 12), red, 0, 0.32, 0, Math.PI / 2);
  put(g, new THREE.CylinderGeometry(0.18, 0.18, 0.02, 12), mat(0xfbf6e8, PLASTIC), 0, 0.32, 0.07, Math.PI / 2);
  const black = mat(0x1d1d22, PLASTIC);
  box(g, 0.02, 0.13, 0.01, black, 0, 0.375, 0.085); // minute hand
  box(g, 0.09, 0.022, 0.01, black, 0.04, 0.32, 0.087); // hour hand
  put(g, new THREE.CylinderGeometry(0.02, 0.02, 0.02, 6), brass, 0, 0.32, 0.09, Math.PI / 2);
  // twin bells
  const bell = new THREE.SphereGeometry(0.1, 10, 4, 0, Math.PI * 2, 0, Math.PI / 2);
  put(g, bell, brass, 0.14, 0.5, 0, 0, 0, -0.5);
  put(g, bell, brass, -0.14, 0.5, 0, 0, 0, 0.5);
  // hammer
  rod(g, 0.012, 0.012, 0.52, 0.64, brass, 5);
  put(g, new THREE.SphereGeometry(0.03, 6, 4), brass, 0, 0.66, 0);
  // little legs
  const leg = new THREE.CylinderGeometry(0.035, 0.02, 0.13, 6);
  put(g, leg, brass, 0.13, 0.06, 0, 0, 0, 0.3);
  put(g, leg, brass, -0.13, 0.06, 0, 0, 0, -0.3);
  return g;
}

function buildToyCar(): THREE.Group {
  const g = new THREE.Group();
  const paint = mat(0x2f6fd8, GLOSSY);
  box(g, 0.6, 0.14, 0.3, paint, 0, 0.14, 0);
  box(g, 0.32, 0.13, 0.26, paint, -0.03, 0.275, 0); // cabin
  box(g, 0.33, 0.08, 0.27, mat(0x9fd8ee, GLASS), -0.03, 0.285, 0); // windows
  const wheel = new THREE.CylinderGeometry(0.08, 0.08, 0.06, 8);
  const tyre = mat(0x222226, SOFT);
  for (const x of [-0.19, 0.19]) {
    for (const z of [-0.14, 0.14]) put(g, wheel, tyre, x, 0.08, z, Math.PI / 2);
  }
  const lamp = new THREE.BoxGeometry(0.02, 0.04, 0.06);
  const yellow = mat(0xffe066, GLOSSY);
  put(g, lamp, yellow, 0.3, 0.17, 0.09);
  put(g, lamp, yellow, 0.3, 0.17, -0.09);
  box(g, 0.08, 0.02, 0.31, mat(0xffffff, GLOSSY), 0.15, 0.212, 0); // racing stripe
  return g;
}

function buildDice(): THREE.Group {
  const g = new THREE.Group();
  const s = 0.4;
  const h = s / 2;
  box(g, s, s, s, mat(0xd9a86a, WOOD), 0, h, 0);
  const pip = new THREE.CylinderGeometry(0.035, 0.035, 0.012, 8);
  const ink = mat(0x3a2210, SOFT);
  const o = 0.1;
  const e = h + 0.003;
  // top: 1
  put(g, pip, ink, 0, s + 0.003, 0);
  // front (+Z): 2
  put(g, pip, ink, -o, h + o, e, Math.PI / 2);
  put(g, pip, ink, o, h - o, e, Math.PI / 2);
  // right (+X): 3
  put(g, pip, ink, e, h + o, -o, 0, 0, Math.PI / 2);
  put(g, pip, ink, e, h, 0, 0, 0, Math.PI / 2);
  put(g, pip, ink, e, h - o, o, 0, 0, Math.PI / 2);
  // back (-Z): 5
  for (const [x, y] of [
    [-o, o],
    [o, o],
    [0, 0],
    [-o, -o],
    [o, -o],
  ] as [number, number][]) {
    put(g, pip, ink, x, h + y, -e, Math.PI / 2);
  }
  // left (-X): 4
  for (const [y, z] of [
    [o, -o],
    [o, o],
    [-o, -o],
    [-o, o],
  ] as [number, number][]) {
    put(g, pip, ink, -e, h + y, z, 0, 0, Math.PI / 2);
  }
  // bottom: 6
  for (const [x, z] of [
    [-o, -o],
    [-o, 0],
    [-o, o],
    [o, -o],
    [o, 0],
    [o, o],
  ] as [number, number][]) {
    put(g, pip, ink, x, -0.003, z);
  }
  return g;
}

function buildSnowGlobe(): THREE.Group {
  const g = new THREE.Group();
  rod(g, 0.24, 0.2, 0, 0.15, mat(0x7a4a26, WOOD), 10);
  rod(g, 0.205, 0.205, 0.15, 0.18, mat(0xd4a83a, METAL), 10);
  const snow = mat(0xffffff, SOFT);
  put(g, new THREE.SphereGeometry(0.16, 10, 4, 0, Math.PI * 2, 0, Math.PI / 2), snow, 0, 0.18, 0).scale.set(1, 0.35, 1);
  rod(g, 0.02, 0.02, 0.2, 0.25, mat(0x5a3a1c, WOOD), 5); // trunk
  cone(g, 0.085, 0.24, 0.36, mat(0x2f7d3a, SOFT), 7);
  cone(g, 0.06, 0.32, 0.43, mat(0x3a9447, SOFT), 7);
  put(g, new THREE.OctahedronGeometry(0.025), mat(0xffd84a, GLOSSY), 0, 0.445, 0); // star
  const flake = new THREE.OctahedronGeometry(0.012);
  put(g, flake, snow, 0.09, 0.42, 0.05);
  put(g, flake, snow, -0.1, 0.36, -0.04);
  put(g, flake, snow, 0.03, 0.5, -0.09);
  put(g, new THREE.SphereGeometry(0.2, 12, 9), mat(0xd8f0ff, CLEAR), 0, 0.36, 0); // glass dome
  return g;
}

function buildBigWrench(): THREE.Group {
  const g = new THREE.Group();
  const steel = mat(0xaeb5bd, METAL);
  const y = 0.125;
  // handle (stands on its edge, flat faces toward ±Z)
  box(g, 1.1, 0.12, 0.07, steel, 0, y, 0);
  box(g, 0.8, 0.06, 0.075, mat(0x8d949c, METAL), 0, y, 0); // recessed panel
  box(g, 0.24, 0.07, 0.078, mat(0xc8312b, PLASTIC), 0, y, 0); // red size stamp
  // open-end jaw at +X (C shape)
  box(g, 0.1, 0.25, 0.08, steel, 0.6, y, 0);
  box(g, 0.18, 0.065, 0.08, steel, 0.72, y + 0.09, 0, 0, 0, 0.12);
  box(g, 0.18, 0.065, 0.08, steel, 0.72, y - 0.09, 0, 0, 0, -0.12);
  // ring end at -X
  put(g, new THREE.TorusGeometry(0.085, 0.04, 6, 10), steel, -0.66, y, 0);
  return g;
}

function buildRemote(): THREE.Group {
  const g = new THREE.Group();
  box(g, 0.18, 0.08, 0.54, mat(0x26272c, PLASTIC), 0, 0.04, 0);
  box(g, 0.15, 0.02, 0.5, mat(0x34363d, PLASTIC), 0, 0.09, 0);
  box(g, 0.08, 0.03, 0.02, mat(0x8a1a1a, GLOSSY), 0, 0.05, -0.27); // IR window
  rod(g, 0.025, 0.025, 0.095, 0.11, mat(0xe0352c, GLOSSY), 8, 0, -0.2); // power
  rod(g, 0.05, 0.05, 0.095, 0.11, mat(0x8b9099, PLASTIC), 8, 0, -0.08); // d-pad
  const btn = new THREE.BoxGeometry(0.035, 0.015, 0.03);
  const grey = mat(0xc4c8cf, PLASTIC);
  for (let r = 0; r < 3; r++) {
    for (const x of [-0.035, 0.035]) put(g, btn, grey, x, 0.105, 0.04 + r * 0.06);
  }
  return g;
}

// ---------------------------------------------------------------------------
// Marble, rubber band, crayon box, water balloon
// ---------------------------------------------------------------------------

function buildMarble(): THREE.Group {
  const g = new THREE.Group();
  put(g, new THREE.IcosahedronGeometry(0.2, 2), mat(0x7fc8f0, GLASS), 0, 0.2, 0);
  // The coloured swirl inside.
  put(g, new THREE.TorusGeometry(0.1, 0.035, 5, 10), mat(0xf2a33a, GLOSSY), 0, 0.2, 0, 0.6, 0.3, 0);
  put(g, new THREE.TorusGeometry(0.08, 0.03, 5, 10), mat(0xd8443a, GLOSSY), 0, 0.2, 0, -0.5, 1.2, 0);
  return g;
}

function buildRubberband(): THREE.Group {
  const g = new THREE.Group();
  const band = put(g, new THREE.TorusGeometry(0.26, 0.035, 5, 20), mat(0xe86f4a, SOFT), 0, 0.04, 0, Math.PI / 2, 0, 0);
  band.scale.set(1, 0.7, 1);
  return g;
}

function buildCrayonbox(): THREE.Group {
  const g = new THREE.Group();
  box(g, 0.5, 0.55, 0.2, mat(0xf2c14e, PLASTIC), 0, 0.275, 0);
  box(g, 0.52, 0.14, 0.21, mat(0x2f8f4a, PLASTIC), 0, 0.42, 0);
  // Crayon tips poking out of the top.
  const colors = [0xd8443a, 0x3e6ea8, 0x5aa04a, 0x8e4fb0, 0xf28a2e];
  colors.forEach((c, i) => {
    const x = -0.2 + i * 0.1;
    rod(g, 0.035, 0.035, 0.55, 0.66, mat(c, SOFT), 6, x, 0);
    put(g, new THREE.ConeGeometry(0.035, 0.07, 6), mat(c, SOFT), x, 0.695, 0);
  });
  return g;
}

function buildWaterballoon(): THREE.Group {
  const g = new THREE.Group();
  const water = put(g, new THREE.IcosahedronGeometry(0.3, 2), mat(0x4aa8e8, GLASS), 0, 0.3, 0);
  water.scale.set(1, 1.08, 1);
  put(g, new THREE.ConeGeometry(0.06, 0.1, 6), mat(0x2f86c8, PLASTIC), 0, 0.64, 0);
  put(g, new THREE.TorusGeometry(0.035, 0.015, 4, 8), mat(0x2f86c8, PLASTIC), 0, 0.7, 0, Math.PI / 2, 0, 0);
  return g;
}

// ---------------------------------------------------------------------------
// Pick-ups: sugar cube, thimble, button
// ---------------------------------------------------------------------------

function buildSugar(): THREE.Group {
  const g = new THREE.Group();
  box(g, 0.32, 0.32, 0.32, mat(0xfbfbf6, { metalness: 0, roughness: 0.4 }), 0, 0.16, 0);
  // Glinting crystals.
  for (const [x, y, z] of [
    [0.1, 0.33, 0.05],
    [-0.08, 0.2, 0.165],
    [0.165, 0.12, -0.06],
  ])
    put(g, new THREE.OctahedronGeometry(0.03), mat(0xffffff, GLOSSY), x, y, z);
  return g;
}

/** A metal thimble: worn as a helmet, it stops one hit. */
export function buildThimble(): THREE.Group {
  const g = new THREE.Group();
  const steel = mat(0xc9d1d6, METAL);
  rod(g, 0.15, 0.13, 0, 0.3, steel, 12);
  put(g, new THREE.SphereGeometry(0.13, 12, 6, 0, Math.PI * 2, 0, Math.PI / 2), steel, 0, 0.3, 0);
  rod(g, 0.158, 0.158, 0, 0.04, mat(0xd4a83a, METAL), 12);
  // Dimples.
  const dim = mat(0x8d949c, METAL);
  for (let i = 0; i < 6; i++) {
    const a = (i / 6) * Math.PI * 2;
    put(g, new THREE.SphereGeometry(0.018, 4, 3), dim, Math.cos(a) * 0.14, 0.18, Math.sin(a) * 0.14);
  }
  return g;
}

/** A big coat button: strapped to the arm, it turns hits from the front. */
export function buildButton(): THREE.Group {
  const g = new THREE.Group();
  rod(g, 0.3, 0.3, 0, 0.08, mat(0xc8323c, GLOSSY), 18);
  rod(g, 0.24, 0.24, 0.08, 0.1, mat(0xa82830, GLOSSY), 18);
  const hole = mat(0x3a1518, PLASTIC);
  for (const [x, z] of [
    [-0.06, -0.06],
    [0.06, -0.06],
    [-0.06, 0.06],
    [0.06, 0.06],
  ])
    rod(g, 0.03, 0.03, 0.095, 0.105, hole, 6, x, z);
  return g;
}

// ---------------------------------------------------------------------------
// Definitions
// ---------------------------------------------------------------------------

function def(d: ThrowDef): [string, ThrowDef] {
  return [d.id, d];
}

export const THROWABLES: Record<string, ThrowDef> = Object.fromEntries([
  def({ id: 'mug', name: 'Coffee mug', damage: 12, knock: 4, weight: 0.9, breaks: true, height: 0.45, radius: 0.28, chips: [0x3f7fc4, 0xf4efe4, 0x4a2a17], build: buildMug }),
  def({ id: 'bottle', name: 'Soda bottle', damage: 14, knock: 4, weight: 1.1, breaks: true, height: 0.9, radius: 0.16, chips: [0x2f9a4a, 0x8fd49c, 0x7a3b1a], build: buildBottle }),
  def({ id: 'can', name: 'Soup can', damage: 10, knock: 3.5, weight: 0.9, breaks: false, height: 0.5, radius: 0.16, chips: [0xc9ccd1, 0xc8262b, 0xf5f0e2], build: buildCan }),
  def({ id: 'apple', name: 'Apple', damage: 7, knock: 2.5, weight: 0.7, breaks: true, height: 0.42, radius: 0.19, chips: [0xc8202a, 0xf3e6b0, 0x4caf3a], build: buildApple }),
  def({ id: 'salt', name: 'Salt shaker', damage: 8, knock: 3, weight: 0.7, breaks: true, height: 0.5, radius: 0.14, chips: [0xdff2f5, 0xffffff, 0xb9bec6], build: buildSalt }),
  def({ id: 'soap', name: 'Bar of soap', damage: 8, knock: 3, weight: 0.6, breaks: false, height: 0.25, radius: 0.28, chips: [0xf6b3c8, 0xffffff], build: buildSoap }),
  def({ id: 'shampoo', name: 'Shampoo bottle', damage: 9, knock: 3.5, weight: 0.9, breaks: false, height: 0.75, radius: 0.18, chips: [0x6ec6d8, 0xffffff, 0xf27aa0], build: buildShampoo }),
  def({ id: 'cup', name: 'Toothbrush cup', damage: 6, knock: 2.5, weight: 0.5, breaks: false, height: 0.6, radius: 0.17, chips: [0x7ad3b5, 0xf2a33a], build: buildCup }),
  def({ id: 'sponge', name: 'Sponge', damage: 3, knock: 1.5, weight: 0.5, breaks: false, height: 0.26, radius: 0.24, chips: [0xf5d33f, 0x3f9a4a], build: buildSponge }),
  def({ id: 'alarmclock', name: 'Alarm clock', damage: 13, knock: 4, weight: 1.0, breaks: false, height: 0.69, radius: 0.26, chips: [0xd8332c, 0xd4a83a, 0xfbf6e8], build: buildAlarmClock }),
  def({ id: 'toycar', name: 'Toy car', damage: 10, knock: 4, weight: 0.9, breaks: false, height: 0.34, radius: 0.34, chips: [0x2f6fd8, 0x9fd8ee, 0x222226], build: buildToyCar }),
  def({ id: 'dice', name: 'Wooden die', damage: 11, knock: 4, weight: 1.0, breaks: false, anyFace: true, height: 0.4, radius: 0.29, chips: [0xd9a86a, 0x3a2210], build: buildDice }),
  def({ id: 'snowglobe', name: 'Snow globe', damage: 15, knock: 4.5, weight: 1.2, breaks: true, height: 0.56, radius: 0.24, chips: [0xd8f0ff, 0xffffff, 0x2f7d3a], build: buildSnowGlobe }),
  // Lies along X: the mesh extends to about ±0.8 in X despite radius 0.35.
  def({ id: 'bigwrench', name: 'Big wrench', damage: 20, knock: 6, weight: 1.5, breaks: false, height: 0.25, radius: 0.35, chips: [0xaeb5bd, 0x8d949c], build: buildBigWrench }),
  def({ id: 'remote', name: 'TV remote', damage: 8, knock: 3, weight: 0.7, breaks: false, height: 0.11, radius: 0.28, chips: [0x26272c, 0xe0352c, 0xc4c8cf], build: buildRemote }),
  def({ id: 'marble', name: 'Marble', damage: 6, knock: 3, weight: 0.5, breaks: false, rolls: true, height: 0.4, radius: 0.2, chips: [0x7fc8f0, 0xf2a33a], build: buildMarble }),
  def({ id: 'rubberband', name: 'Rubber band', damage: 5, knock: 2.5, weight: 0.5, breaks: false, flick: 22, height: 0.08, radius: 0.28, chips: [0xe86f4a], build: buildRubberband }),
  def({ id: 'crayonbox', name: 'Box of crayons', damage: 9, knock: 3.5, weight: 0.8, breaks: true, burst: ['crayon', 'crayon', 'crayon'], height: 0.7, radius: 0.26, chips: [0xf2c14e, 0x2f8f4a, 0xd8443a, 0x3e6ea8], build: buildCrayonbox }),
  def({ id: 'waterballoon', name: 'Water balloon', damage: 6, knock: 3, weight: 0.9, breaks: true, splash: 1.9, height: 0.74, radius: 0.3, chips: [0x4aa8e8, 0xbfe6ff, 0xffffff], build: buildWaterballoon }),
]);

/** Pick-ups: grabbing one does something right away. */
export const POWERUPS: ThrowDef[] = [
  { id: 'sugar', name: 'Sugar cube', power: 'sugar', damage: 4, knock: 2, weight: 0.5, breaks: true, height: 0.32, radius: 0.2, chips: [0xffffff], build: buildSugar },
  { id: 'thimble', name: 'Thimble', power: 'thimble', damage: 6, knock: 2, weight: 0.6, breaks: false, height: 0.4, radius: 0.16, chips: [0xc9d1d6], build: buildThimble },
  { id: 'button', name: 'Button', power: 'button', damage: 5, knock: 2, weight: 0.6, breaks: false, height: 0.1, radius: 0.3, chips: [0xc8323c], build: buildButton },
];

export function throwById(id: string): ThrowDef {
  return THROWABLES[id] ?? THROWABLES.soap;
}

/** Throwable ids that appear in each room. */
export const THROWS_BY_ROOM: Record<RoomId, string[]> = {
  kitchen: ['mug', 'bottle', 'can', 'apple', 'salt', 'waterballoon'],
  bathroom: ['soap', 'shampoo', 'cup', 'sponge', 'waterballoon'],
  bedroom: ['alarmclock', 'toycar', 'dice', 'snowglobe', 'marble', 'crayonbox', 'rubberband'],
  living: ['bigwrench', 'remote', 'mug', 'can', 'toycar', 'marble', 'rubberband'],
  dining: ['mug', 'bottle', 'apple', 'salt', 'can', 'waterballoon'],
  garage: ['bigwrench', 'can', 'rubberband', 'marble', 'waterballoon'],
  house: ['bigwrench', 'remote', 'toycar', 'mug', 'dice', 'alarmclock', 'marble', 'rubberband', 'crayonbox', 'waterballoon'],
};