Back to Pickle Rustlers
SOURCE / PINNED RELEASE

Made of little things.

Pickle Rustlers

Release
039e51f55e34…
Author-recorded commit
40608586a8a1…
License
LICENSE
Author’s source reference
nostr://npub12lm3rxsva8kfempyjkxupt09e02n3fezurkyadyqfejdc5e0r2kscqty6r/wss%3A%2F%2Fgit.napplet.soy%2F/n-bad44e7ab35

Archive hash verified: f38e99afa0992052…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/game/models.ts
// Low-poly models built from a few flat-shaded primitives. Local forward is +Z.
import * as THREE from 'three';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';

export const palette = {
  horse: 0x8b5a2b,
  horseDark: 0x4a2c17,
  shirt: 0xc0392b,
  jeans: 0x34558b,
  skin: 0xe0ac69,
  hat: 0xc8a165,
  hatBand: 0x5b3a1e,
  leather: 0x6b3e1f,
  steel: 0x2b2b2b,
  pickle: 0x5a9e2f,
  pickleDark: 0x3d6e1e,
  gold: 0xf2c230,
  onion: 0xa4508f,
  onionLight: 0xe7a4d4,
  sprout: 0x6fae3c,
  tank: 0xc9a96e,
  tankDark: 0x8c7448,
  enemyTank: 0x6c3483,
  enemyTankDark: 0x40204f,
  track: 0x333333,
  rope: 0xd1a660,
  sauce: 0xd62828,
  cream: 0xf3e3c3,
  glass: 0xcdeff0,
  brine: 0xa6c94a,
  lid: 0xd4a017,
};

const materials = new Map<string, THREE.MeshLambertMaterial>();

export function mat(color: number, extra: Partial<THREE.MeshLambertMaterialParameters> = {}): THREE.MeshLambertMaterial {
  const key = `${color}:${JSON.stringify(extra)}`;
  let m = materials.get(key);
  if (!m) {
    m = new THREE.MeshLambertMaterial({ color, flatShading: true, ...extra });
    materials.set(key, m);
  }
  return m;
}

function part(
  geo: THREE.BufferGeometry,
  color: number | THREE.Material,
  x = 0,
  y = 0,
  z = 0,
  parent?: THREE.Object3D,
): THREE.Mesh {
  const m = new THREE.Mesh(geo, typeof color === 'number' ? mat(color) : color);
  m.position.set(x, y, z);
  m.castShadow = true;
  parent?.add(m);
  return m;
}

const box = (w: number, h: number, d: number) => new THREE.BoxGeometry(w, h, d);
const cyl = (rt: number, rb: number, h: number, seg = 7) => new THREE.CylinderGeometry(rt, rb, h, seg);

export function cowboyHat(parent: THREE.Object3D, y: number, scale = 1, color = palette.hat): THREE.Group {
  const hat = new THREE.Group();
  hat.position.y = y;
  hat.scale.setScalar(scale);
  const brim = part(cyl(0.6, 0.6, 0.06, 9), color, 0, 0, 0, hat);
  brim.scale.z = 1.15;
  part(cyl(0.27, 0.33, 0.38, 7), color, 0, 0.2, 0, hat);
  part(cyl(0.335, 0.335, 0.08, 7), palette.hatBand, 0, 0.06, 0, hat);
  parent.add(hat);
  return hat;
}

export interface RiderModel {
  root: THREE.Group;
  body: THREE.Group;
  legs: THREE.Object3D[];
  head: THREE.Object3D;
  tail: THREE.Object3D;
  rider: THREE.Group;
  gun: THREE.Group;
  flash: THREE.Mesh;
  ropeAnchor: THREE.Object3D;
}

function createHorseAndRider(): RiderModel {
  const root = new THREE.Group();
  const body = new THREE.Group();
  root.add(body);
  part(box(1.0, 1.0, 2.3), palette.horse, 0, 1.75, 0, body);
  const neck = part(box(0.5, 1.2, 0.55), palette.horse, 0, 2.35, 1.15, body);
  neck.rotation.x = 0.55;
  const head = new THREE.Group();
  head.position.set(0, 2.9, 1.55);
  body.add(head);
  part(box(0.45, 0.45, 0.95), palette.horse, 0, 0, 0.25, head);
  part(box(0.4, 0.3, 0.3), palette.horseDark, 0, -0.08, 0.7, head);
  part(new THREE.ConeGeometry(0.08, 0.25, 4), palette.horseDark, 0.15, 0.32, -0.05, head);
  part(new THREE.ConeGeometry(0.08, 0.25, 4), palette.horseDark, -0.15, 0.32, -0.05, head);
  part(box(0.1, 0.06, 0.06), 0x111111, 0.23, 0.08, 0.2, head);
  part(box(0.1, 0.06, 0.06), 0x111111, -0.23, 0.08, 0.2, head);
  const mane = part(box(0.14, 1.1, 0.3), palette.horseDark, 0, 2.55, 0.95, body);
  mane.rotation.x = 0.55;
  const tail = new THREE.Group();
  tail.position.set(0, 2.05, -1.15);
  body.add(tail);
  const tailMesh = part(box(0.18, 1.1, 0.22), palette.horseDark, 0, -0.5, -0.15, tail);
  tailMesh.rotation.x = -0.3;
  const legs: THREE.Object3D[] = [];
  for (const [x, z] of [
    [0.32, 0.85],
    [-0.32, 0.85],
    [0.32, -0.85],
    [-0.32, -0.85],
  ]) {
    const leg = new THREE.Group();
    leg.position.set(x, 1.3, z);
    part(box(0.24, 1.25, 0.26), palette.horse, 0, -0.62, 0, leg);
    part(box(0.27, 0.2, 0.3), palette.horseDark, 0, -1.2, 0.02, leg);
    body.add(leg);
    legs.push(leg);
  }
  // Saddle and rope coil.
  part(box(1.1, 0.15, 0.9), palette.leather, 0, 2.3, -0.1, body);
  const coil = part(new THREE.TorusGeometry(0.28, 0.07, 4, 8), palette.rope, 0.6, 2.05, -0.3, body);
  coil.rotation.y = Math.PI / 2;

  const rider = new THREE.Group();
  rider.position.set(0, 2.35, -0.1);
  body.add(rider);
  part(box(0.25, 0.25, 0.7), palette.jeans, 0.3, 0.05, 0.15, rider);
  part(box(0.25, 0.25, 0.7), palette.jeans, -0.3, 0.05, 0.15, rider);
  part(box(0.22, 0.7, 0.25), palette.jeans, 0.42, -0.3, 0.45, rider);
  part(box(0.22, 0.7, 0.25), palette.jeans, -0.42, -0.3, 0.45, rider);
  part(box(0.7, 0.85, 0.42), palette.shirt, 0, 0.6, 0, rider);
  part(box(0.72, 0.5, 0.44), palette.leather, 0, 0.75, -0.01, rider).scale.set(1, 1, 1);
  const bandana = part(new THREE.ConeGeometry(0.25, 0.3, 3), 0xe63946, 0, 1.0, 0.18, rider);
  bandana.rotation.x = Math.PI;
  const riderHead = new THREE.Group();
  riderHead.position.y = 1.3;
  rider.add(riderHead);
  part(new THREE.IcosahedronGeometry(0.3, 0), palette.skin, 0, 0, 0, riderHead);
  part(box(0.36, 0.08, 0.1), 0x3b2412, 0, -0.08, 0.25, riderHead); // mustache
  cowboyHat(riderHead, 0.22, 1);
  part(box(0.2, 0.6, 0.2), palette.shirt, 0.42, 0.6, 0.2, rider).rotation.x = -0.9;
  part(box(0.2, 0.6, 0.2), palette.shirt, -0.42, 0.6, 0.2, rider).rotation.x = -0.9;
  const gun = new THREE.Group();
  gun.position.set(0.22, 0.75, 0.55);
  rider.add(gun);
  part(cyl(0.06, 0.06, 1.3, 5), palette.steel, 0.05, 0, 0.4, gun).rotation.x = Math.PI / 2;
  part(cyl(0.06, 0.06, 1.3, 5), palette.steel, -0.07, 0, 0.4, gun).rotation.x = Math.PI / 2;
  part(box(0.16, 0.22, 0.5), palette.leather, 0, -0.05, -0.35, gun);
  const flash = part(new THREE.OctahedronGeometry(0.45, 0), new THREE.MeshBasicMaterial({ color: 0xffd166 }), 0, 0, 1.3, gun);
  flash.visible = false;
  flash.castShadow = false;
  const ropeAnchor = new THREE.Object3D();
  ropeAnchor.position.set(0, 2.2, -1.0);
  body.add(ropeAnchor);
  return { root, body, legs, head, tail, rider, gun, flash, ropeAnchor };
}

export interface PickleModel {
  root: THREE.Group;
  body: THREE.Group;
}

function createPickle(golden: boolean): PickleModel {
  const root = new THREE.Group();
  const body = new THREE.Group();
  root.add(body);
  const color = golden ? palette.gold : palette.pickle;
  const dark = golden ? 0xb8860b : palette.pickleDark;
  const skin = golden ? mat(color, { emissive: 0x6b4f00 }) : mat(color);
  part(new THREE.CapsuleGeometry(0.45, 1.0, 2, 7), skin, 0, 0.95, 0, body);
  const bumps: [number, number, number][] = [
    [0.4, 1.3, 0.15],
    [-0.38, 0.7, 0.2],
    [0.2, 0.5, -0.38],
    [-0.25, 1.5, -0.3],
    [0.35, 0.9, -0.25],
  ];
  for (const [x, y, z] of bumps) part(new THREE.IcosahedronGeometry(0.1, 0), dark, x, y, z, body);
  for (const x of [0.17, -0.17]) {
    part(new THREE.IcosahedronGeometry(0.13, 0), 0xffffff, x, 1.35, 0.38, body);
    part(new THREE.IcosahedronGeometry(0.065, 0), 0x111111, x, 1.36, 0.5, body);
  }
  part(box(0.25, 0.06, 0.06), 0x2b1a0e, 0, 1.08, 0.44, body); // little mouth
  return { root, body };
}

export interface OnionModel {
  root: THREE.Group;
  body: THREE.Group;
  hat: THREE.Group;
}

function createOnion(): OnionModel {
  const root = new THREE.Group();
  const body = new THREE.Group();
  root.add(body);
  const bulb = part(new THREE.IcosahedronGeometry(0.85, 1), palette.onion, 0, 0.9, 0, body);
  bulb.scale.set(1, 0.95, 1);
  const tip = part(new THREE.ConeGeometry(0.35, 0.7, 6), palette.onion, 0, 1.85, 0, body);
  tip.scale.set(1, 1, 1);
  part(new THREE.ConeGeometry(0.08, 0.6, 4), palette.sprout, 0.08, 2.35, 0, body).rotation.z = -0.3;
  part(new THREE.ConeGeometry(0.08, 0.5, 4), palette.sprout, -0.1, 2.3, 0.05, body).rotation.z = 0.4;
  for (const [x, z] of [
    [0.1, 0],
    [-0.1, 0.05],
    [0, -0.1],
  ]) {
    part(box(0.04, 0.3, 0.04), palette.cream, x, 0.05, z, body);
  }
  // Light streaks so it reads as an onion, not a plum.
  for (const a of [0.4, -0.4]) {
    const streak = part(box(0.06, 1.2, 0.06), palette.onionLight, Math.sin(a) * 0.8, 0.95, Math.cos(a) * 0.8, body);
    streak.rotation.z = -a * 0.6;
  }
  for (const x of [0.25, -0.25]) {
    part(new THREE.IcosahedronGeometry(0.16, 0), 0xffffff, x, 1.1, 0.72, body);
    part(new THREE.IcosahedronGeometry(0.08, 0), 0x111111, x, 1.1, 0.86, body);
    const brow = part(box(0.32, 0.08, 0.08), 0x2b0f22, x, 1.32, 0.78, body);
    brow.rotation.z = x > 0 ? 0.45 : -0.45;
  }
  const bandana = part(new THREE.ConeGeometry(0.55, 0.45, 3), 0x1d3557, 0, 0.65, 0.55, body);
  bandana.rotation.x = Math.PI + 0.3;
  const hat = cowboyHat(body, 1.75, 1.4, 0x3b2412);
  hat.rotation.z = 0.15;
  return { root, body, hat };
}

export interface TankModel {
  root: THREE.Group;
  turret: THREE.Group;
  barrel: THREE.Group;
  muzzle: THREE.Object3D;
  wheels: THREE.Mesh[];
}

function createTank(enemy: boolean): TankModel {
  const hullColor = enemy ? palette.enemyTank : palette.tank;
  const darkColor = enemy ? palette.enemyTankDark : palette.tankDark;
  const root = new THREE.Group();
  part(box(3.0, 1.0, 6.4), hullColor, 0, 1.15, 0, root);
  const glacis = part(box(3.0, 0.5, 1.5), hullColor, 0, 1.3, 3.55, root);
  glacis.rotation.x = 0.5;
  const rear = part(box(3.0, 0.8, 0.6), darkColor, 0, 1.2, -3.3, root);
  rear.rotation.x = -0.2;
  const wheels: THREE.Mesh[] = [];
  for (const side of [1, -1]) {
    part(box(0.9, 1.0, 7.2), palette.track, side * 1.85, 0.55, 0, root);
    part(box(0.12, 0.65, 6.4), hullColor, side * 2.35, 1.15, 0.2, root);
    for (let i = 0; i < 6; i++) {
      const w = part(cyl(0.4, 0.4, 0.2, 7), 0x555555, side * 2.32, 0.5, -2.6 + i * 1.05, root);
      w.rotation.z = Math.PI / 2;
      wheels.push(w);
    }
  }
  const turret = new THREE.Group();
  turret.position.set(0, 1.65, -0.3);
  root.add(turret);
  const outline = new THREE.Shape();
  const pts: [number, number][] = [
    [-1.5, -1.9],
    [1.5, -1.9],
    [1.65, 0.6],
    [0.75, 2.1],
    [-0.75, 2.1],
    [-1.65, 0.6],
  ];
  outline.moveTo(pts[0][0], pts[0][1]);
  for (const [x, y] of pts.slice(1)) outline.lineTo(x, y);
  const turretGeo = new THREE.ExtrudeGeometry(outline, { depth: 0.85, bevelEnabled: false });
  turretGeo.rotateX(Math.PI / 2);
  turretGeo.translate(0, 0.85, 0);
  part(turretGeo, hullColor, 0, 0, 0, turret);
  part(box(3.4, 0.5, 1.2), darkColor, 0, 0.45, -2.3, turret); // bustle rack
  part(cyl(0.38, 0.4, 0.3, 7), darkColor, 0.65, 1.0, -0.5, turret); // hatch
  part(cyl(0.05, 0.05, 1.0, 4), palette.steel, 0.65, 1.3, 0.1, turret).rotation.x = Math.PI / 2;
  const barrel = new THREE.Group();
  barrel.position.set(0, 0.45, 2.0);
  turret.add(barrel);
  part(box(0.7, 0.6, 0.6), darkColor, 0, 0, 0.1, barrel);
  part(cyl(0.13, 0.15, 5.0, 6), darkColor, 0, 0, 2.6, barrel).rotation.x = Math.PI / 2;
  part(cyl(0.22, 0.22, 0.7, 6), darkColor, 0, 0, 2.2, barrel).rotation.x = Math.PI / 2;
  const muzzle = new THREE.Object3D();
  muzzle.position.set(0, 0, 5.2);
  barrel.add(muzzle);
  if (enemy) {
    // The onion commander pokes out of the hatch.
    const commander = buildOnion();
    commander.root.scale.setScalar(0.5);
    commander.root.position.set(-0.6, 0.75, -0.4);
    turret.add(commander.root);
  }
  return { root, turret, barrel, muzzle, wheels };
}

export interface JarModel {
  root: THREE.Group;
  brine: THREE.Mesh;
  floaters: THREE.Group;
}

export const JAR_RADIUS = 6;

export function buildJar(): JarModel {
  const root = new THREE.Group();
  const floaters = new THREE.Group();
  root.add(floaters);
  const brine = new THREE.Mesh(
    new THREE.CylinderGeometry(JAR_RADIUS - 0.3, JAR_RADIUS - 0.3, 1, 12),
    new THREE.MeshLambertMaterial({ color: palette.brine, transparent: true, opacity: 0.6, flatShading: true }),
  );
  brine.position.y = 0.5;
  root.add(brine);
  const glass = new THREE.Mesh(
    new THREE.CylinderGeometry(JAR_RADIUS, JAR_RADIUS, 10, 12, 1, true),
    new THREE.MeshLambertMaterial({
      color: palette.glass,
      transparent: true,
      opacity: 0.28,
      side: THREE.DoubleSide,
      depthWrite: false,
      flatShading: true,
    }),
  );
  glass.position.y = 5;
  glass.renderOrder = 2;
  root.add(glass);
  part(new THREE.CylinderGeometry(JAR_RADIUS + 0.1, JAR_RADIUS + 0.1, 0.3, 12), palette.glass, 0, 0.15, 0, root);
  const rim = part(new THREE.TorusGeometry(JAR_RADIUS, 0.3, 4, 12), palette.lid, 0, 10, 0, root);
  rim.rotation.x = Math.PI / 2;
  // The lid fell off when the jar tipped. It lies propped against the jar.
  const lid = part(new THREE.CylinderGeometry(JAR_RADIUS + 0.4, JAR_RADIUS + 0.4, 0.8, 12), palette.lid, JAR_RADIUS + 2.5, 0.9, -6, root);
  lid.rotation.set(0.12, 0.4, 0.18);
  lid.receiveShadow = true;
  // Wooden sign.
  part(box(0.25, 3, 0.25), palette.leather, -7.5, 1.5, 4, root);
  const sign = part(box(3.2, 1.3, 0.2), 0x9c6b3c, -7.5, 3, 4, root);
  sign.rotation.y = -0.6;
  part(box(2.6, 0.25, 0.22), palette.pickle, -7.5, 3.2, 4.02, root).rotation.y = -0.6;
  part(box(1.8, 0.2, 0.22), palette.cream, -7.5, 2.75, 4.02, root).rotation.y = -0.6;
  return { root, brine, floaters };
}

function createSauce(): THREE.Group {
  const root = new THREE.Group();
  const bottle = new THREE.Group();
  bottle.position.y = 1.2;
  root.add(bottle);
  part(cyl(0.38, 0.4, 1.1, 7), palette.sauce, 0, 0, 0, bottle);
  part(cyl(0.41, 0.41, 0.45, 7), palette.cream, 0, 0.05, 0, bottle);
  part(cyl(0.14, 0.32, 0.5, 6), palette.sauce, 0, 0.75, 0, bottle);
  part(cyl(0.16, 0.16, 0.25, 6), 0x2d6a4f, 0, 1.1, 0, bottle);
  const ring = new THREE.Mesh(
    new THREE.TorusGeometry(1.1, 0.08, 4, 14),
    new THREE.MeshBasicMaterial({ color: 0xff7b00, transparent: true, opacity: 0.8 }),
  );
  ring.rotation.x = Math.PI / 2;
  ring.position.y = 0.15;
  root.add(ring);
  return root;
}

export function buildCactus(rng: () => number): THREE.Group {
  const root = new THREE.Group();
  const h = 3.5 + rng() * 3;
  const green = rng() > 0.5 ? 0x4f7942 : 0x5e8c4a;
  part(cyl(0.45, 0.5, h, 7), green, 0, h / 2, 0, root);
  part(new THREE.IcosahedronGeometry(0.45, 0), green, 0, h, 0, root);
  const arms = 1 + Math.floor(rng() * 2);
  for (let i = 0; i < arms; i++) {
    const side = i === 0 ? 1 : -1;
    const y = h * (0.35 + rng() * 0.3);
    const up = 0.8 + rng() * 1.2;
    part(cyl(0.3, 0.3, 0.9, 6), green, side * 0.75, y, 0, root).rotation.z = Math.PI / 2;
    part(cyl(0.3, 0.3, up, 6), green, side * 1.15, y + up / 2, 0, root);
    part(new THREE.IcosahedronGeometry(0.3, 0), green, side * 1.15, y + up, 0, root);
  }
  root.rotation.y = rng() * Math.PI * 2;
  optimizeModel(root);
  return root;
}

export function buildRock(rng: () => number): THREE.Mesh {
  const r = 0.8 + rng() * 1.6;
  const rock = part(new THREE.DodecahedronGeometry(r, 0), rng() > 0.5 ? 0xa0522d : 0x8a6a4f);
  rock.scale.y = 0.6 + rng() * 0.4;
  rock.rotation.set(rng(), rng() * 6, rng());
  rock.receiveShadow = true;
  return rock;
}

export function buildMesa(rng: () => number): THREE.Group {
  const root = new THREE.Group();
  const layers = [0xc1440e, 0xd2691e, 0xe2725b, 0xb5541c];
  const r = 9 + rng() * 10;
  const h = 14 + rng() * 18;
  const seg = 6 + Math.floor(rng() * 3);
  let y = 0;
  const bands = 3;
  for (let i = 0; i < bands; i++) {
    const bh = h / bands;
    const top = r * (1 - (i + 1) * 0.08);
    const bottom = r * (1 - i * 0.08) + (i === 0 ? 3 : 0);
    const m = part(new THREE.CylinderGeometry(top, bottom, bh, seg), layers[(i + Math.floor(rng() * 4)) % 4], 0, y + bh / 2, 0, root);
    m.receiveShadow = true;
    y += bh;
  }
  root.rotation.y = rng() * 6;
  optimizeModel(root);
  return root;
}

export function buildTumbleweed(): THREE.Mesh {
  const m = new THREE.Mesh(
    new THREE.IcosahedronGeometry(0.9, 1),
    new THREE.MeshBasicMaterial({ color: 0x9c7a3c, wireframe: true }),
  );
  return m;
}

/** The Onion King: a big onion outlaw with a gold crown on his ten-gallon hat and a red cape. */
function createKing(): OnionModel {
  const king = buildOnion();
  const crown = new THREE.Group();
  crown.position.y = 0.42;
  king.hat.add(crown);
  part(cyl(0.3, 0.3, 0.16, 8), palette.gold, 0, 0, 0, crown);
  for (let i = 0; i < 5; i++) {
    const a = (i / 5) * Math.PI * 2;
    part(new THREE.ConeGeometry(0.08, 0.24, 4), palette.gold, Math.cos(a) * 0.26, 0.18, Math.sin(a) * 0.26, crown);
  }
  part(new THREE.OctahedronGeometry(0.07, 0), palette.sauce, 0, 0.05, 0.3, crown);
  const cape = part(box(1.5, 1.3, 0.08), palette.sauce, 0, 0.95, -0.72, king.body);
  cape.rotation.x = 0.25;
  part(box(1.55, 0.12, 0.12), palette.cream, 0, 1.58, -0.6, king.body); // ermine collar
  return king;
}

/** A floating arrow that points toward the jar while you tow pickles. */
export function buildGuideArrow(): THREE.Group {
  const root = new THREE.Group();
  const material = new THREE.MeshBasicMaterial({ color: 0x9be564 });
  const head = new THREE.Mesh(new THREE.ConeGeometry(0.5, 0.9, 4), material);
  head.rotation.x = Math.PI / 2;
  head.position.z = 0.6;
  const shaft = new THREE.Mesh(new THREE.BoxGeometry(0.22, 0.22, 0.9), material);
  shaft.position.z = -0.1;
  root.add(head, shaft);
  return root;
}

/** Ground ring that marks the pickle your lasso would grab. */
export function buildTargetRing(): THREE.Mesh {
  const ring = new THREE.Mesh(
    new THREE.RingGeometry(1.0, 1.35, 12),
    new THREE.MeshBasicMaterial({ color: 0xfff1a8, transparent: true, opacity: 0.85, side: THREE.DoubleSide, depthWrite: false }),
  );
  ring.rotation.x = -Math.PI / 2;
  return ring;
}

/** Merge only rigid siblings; animation pivots and explicitly animated meshes survive. */
function optimizeModel(root: THREE.Object3D, excluded: THREE.Object3D[] = []): void {
  for (const child of [...root.children]) optimizeModel(child, excluded);
  const buckets = new Map<THREE.Material, THREE.Mesh[]>();
  for (const child of root.children) {
    if (!(child instanceof THREE.Mesh) || excluded.includes(child) || Array.isArray(child.material)
      || child.material.transparent || !child.visible) continue;
    const list = buckets.get(child.material) ?? [];
    list.push(child);
    buckets.set(child.material, list);
  }
  for (const [material, meshes] of buckets) {
    if (meshes.length < 2) continue;
    // Separate shadow behaviors even when the material is shared.
    for (const flags of [0, 1, 2, 3]) {
      const parts = meshes.filter(m => Number(m.castShadow) + Number(m.receiveShadow) * 2 === flags);
      if (parts.length < 2) continue;
      const geometries = parts.map(m => {
        m.updateMatrix();
        const geometry = m.geometry.clone().applyMatrix4(m.matrix);
        if (!geometry.index) return geometry;
        const expanded = geometry.toNonIndexed();
        geometry.dispose();
        return expanded;
      });
      const geometry = mergeGeometries(geometries);
      for (const g of geometries) g.dispose();
      if (!geometry) throw new Error('Incompatible rigid model geometry');
      const merged = new THREE.Mesh(geometry, material);
      merged.castShadow = Boolean(flags & 1);
      merged.receiveShadow = Boolean(flags & 2);
      root.remove(...parts);
      root.add(merged);
    }
  }
  // Local transforms of fixed mesh parts never change; animated ancestors still propagate.
  for (const child of root.children) {
    if (child instanceof THREE.Mesh && !excluded.includes(child)) {
      child.updateMatrix();
      child.matrixAutoUpdate = false;
    }
  }
}

function cloneModel<T extends { root: THREE.Group }>(template: T): T {
  const root = template.root.clone(true);
  const originals: THREE.Object3D[] = [];
  const copies: THREE.Object3D[] = [];
  template.root.traverse(o => originals.push(o));
  root.traverse(o => copies.push(o));
  const mapping = new Map(originals.map((o, i) => [o, copies[i]]));
  return Object.fromEntries(Object.entries(template as Record<string, unknown>).map(([key, value]) => [key,
    value instanceof THREE.Object3D ? mapping.get(value) : Array.isArray(value) ? value.map(o => mapping.get(o)) : value,
  ])) as T;
}

let riderTemplate: RiderModel | undefined;
export function buildHorseAndRider(): RiderModel {
  if (!riderTemplate) {
    riderTemplate = createHorseAndRider();
    optimizeModel(riderTemplate.root, [riderTemplate.flash]);
  }
  return cloneModel(riderTemplate);
}
const pickleTemplates = new Map<boolean, PickleModel>();
export function buildPickle(golden: boolean): PickleModel {
  let template = pickleTemplates.get(golden);
  if (!template) {
    template = createPickle(golden);
    optimizeModel(template.root);
    pickleTemplates.set(golden, template);
  }
  return cloneModel(template);
}
let onionTemplate: OnionModel | undefined;
export function buildOnion(): OnionModel {
  if (!onionTemplate) {
    onionTemplate = createOnion();
    optimizeModel(onionTemplate.root);
  }
  return cloneModel(onionTemplate);
}
const tankTemplates = new Map<boolean, TankModel>();
export function buildTank(enemy: boolean): TankModel {
  let template = tankTemplates.get(enemy);
  if (!template) {
    template = createTank(enemy);
    optimizeModel(template.root, template.wheels);
    tankTemplates.set(enemy, template);
  }
  return cloneModel(template);
}
let sauceTemplate: THREE.Group | undefined;
export function buildSauce(): THREE.Group {
  if (!sauceTemplate) {
    sauceTemplate = createSauce();
    optimizeModel(sauceTemplate);
  }
  return sauceTemplate.clone(true);
}
let kingTemplate: OnionModel | undefined;
export function buildKing(): OnionModel {
  if (!kingTemplate) {
    kingTemplate = createKing();
    optimizeModel(kingTemplate.root);
  }
  return cloneModel(kingTemplate);
}