← back to Animals

public/dogpark/rigs.js

457 lines

// Procedural rigged characters — humans + dogs — for the dog park.
// No flat sprites: every body part is real 3D geometry on a hierarchical
// rig so limbs can swing on walk/run cycles.
//
// HUMANS — 13-bone skeleton:
//   torso (root) → neck → head → hair-cap
//                ├─ shoulderL → upperArmL → elbowL → forearmL → handL
//                ├─ shoulderR → upperArmR → elbowR → forearmR → handR
//                ├─ hipL → thighL → kneeL → calfL → footL
//                └─ hipR → thighR → kneeR → calfR → footR
// DOGS — 8-bone quadruped:
//   body (root) → neck → head → snout, earL, earR
//                ├─ FL → frontUpperL → frontLowerL
//                ├─ FR → frontUpperR → frontLowerR
//                ├─ BL → backUpperL → backLowerL
//                ├─ BR → backUpperR → backLowerR
//                └─ tail
//
// Walk cycles drive joint rotations from a single phase value (0..2π).
// Run = same cycle, faster + larger amplitude. Idle = breathing only.

import * as THREE from 'three';

const skinMat = (hex) => new THREE.MeshStandardMaterial({ color: hex, roughness: 0.55, metalness: 0.0 });
const fabricMat = (hex) => new THREE.MeshLambertMaterial({ color: hex });
const hairMat = (hex) => new THREE.MeshLambertMaterial({ color: hex });

// ───────────────────────────── HUMANS ─────────────────────────────────────

export function buildHuman({ name, palette, hasGoatee = false, longHair = false }) {
  const p = palette;
  const root = new THREE.Group();      // root = pelvis pivot
  root.name = `human:${name}`;
  root.userData.kind = 'human';
  root.userData.bones = {};

  // Torso (chest box)
  const torso = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.62, 0.28), fabricMat(p.shirt));
  torso.position.y = 0.30;
  torso.castShadow = true;
  root.add(torso);

  // Hips (small box)
  const hips = new THREE.Mesh(new THREE.BoxGeometry(0.46, 0.18, 0.26), fabricMat(p.pants));
  hips.position.y = -0.09;
  hips.castShadow = true;
  root.add(hips);

  // Neck → head
  const neck = new THREE.Group();
  neck.position.y = 0.62;
  root.add(neck);
  const neckMesh = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.07, 0.10), skinMat(p.skin));
  neckMesh.position.y = 0.05; neck.add(neckMesh);

  const head = new THREE.Group();
  head.position.y = 0.18;
  neck.add(head);
  const headMesh = new THREE.Mesh(new THREE.SphereGeometry(0.16, 20, 16), skinMat(p.skin));
  headMesh.castShadow = true;
  head.add(headMesh);

  // Eyes (two tiny dark dots so the head doesn't look like a beach ball)
  const eyeMat = new THREE.MeshBasicMaterial({ color: 0x1a1a1a });
  for (const ex of [-0.05, 0.05]) {
    const eye = new THREE.Mesh(new THREE.SphereGeometry(0.018, 8, 6), eyeMat);
    eye.position.set(ex, 0.02, 0.145);
    head.add(eye);
  }

  // Hair cap — a hemisphere skull-cap that sits on top of the head
  const hairGeom = longHair
    ? new THREE.SphereGeometry(0.18, 24, 16, 0, Math.PI * 2, 0, Math.PI / 1.4) // longer drape
    : new THREE.SphereGeometry(0.165, 16, 12, 0, Math.PI * 2, 0, Math.PI / 2.2);
  const hair = new THREE.Mesh(hairGeom, hairMat(p.hair));
  hair.position.y = longHair ? -0.02 : 0.02;
  hair.castShadow = true;
  head.add(hair);

  // Optional facial hair patch
  if (hasGoatee) {
    const goatee = new THREE.Mesh(new THREE.SphereGeometry(0.05, 12, 8), hairMat(p.hair));
    goatee.scale.set(1, 0.6, 0.7);
    goatee.position.set(0, -0.08, 0.13);
    head.add(goatee);
  }

  // Arms — built as nested groups so we can rotate at shoulder & elbow
  function buildArm(side) {
    const sign = side === 'L' ? -1 : 1;
    const shoulder = new THREE.Group();
    shoulder.position.set(sign * 0.28, 0.55, 0);
    root.add(shoulder);
    // Upper arm hangs DOWN from shoulder pivot — pivot at top
    const upperArm = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.045, 0.32), skinMat(p.skin));
    upperArm.position.y = -0.16;
    upperArm.castShadow = true;
    shoulder.add(upperArm);
    // Elbow joint at end of upper arm
    const elbow = new THREE.Group();
    elbow.position.y = -0.32;
    shoulder.add(elbow);
    const forearm = new THREE.Mesh(new THREE.CylinderGeometry(0.045, 0.04, 0.30), skinMat(p.skin));
    forearm.position.y = -0.15;
    forearm.castShadow = true;
    elbow.add(forearm);
    // Hand
    const hand = new THREE.Mesh(new THREE.SphereGeometry(0.05, 12, 8), skinMat(p.skin));
    hand.position.y = -0.30;
    elbow.add(hand);
    return { shoulder, elbow };
  }
  root.userData.bones.shoulderL = buildArm('L').shoulder;
  root.userData.bones.shoulderR = buildArm('R').shoulder;
  // Save elbow refs (the most-recent shoulder children at index 1 are the elbow groups)
  root.userData.bones.elbowL = root.userData.bones.shoulderL.children[1];
  root.userData.bones.elbowR = root.userData.bones.shoulderR.children[1];

  // Legs
  function buildLeg(side) {
    const sign = side === 'L' ? -1 : 1;
    const hip = new THREE.Group();
    hip.position.set(sign * 0.12, -0.18, 0);
    root.add(hip);
    const thigh = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.06, 0.42), fabricMat(p.pants));
    thigh.position.y = -0.21;
    thigh.castShadow = true;
    hip.add(thigh);
    const knee = new THREE.Group();
    knee.position.y = -0.42;
    hip.add(knee);
    const calf = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.05, 0.40), fabricMat(p.pants));
    calf.position.y = -0.20;
    calf.castShadow = true;
    knee.add(calf);
    // Shoe
    const foot = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.06, 0.20), fabricMat(p.shoe));
    foot.position.set(0, -0.42, 0.04);
    foot.castShadow = true;
    knee.add(foot);
    return { hip, knee };
  }
  root.userData.bones.hipL = buildLeg('L').hip;
  root.userData.bones.hipR = buildLeg('R').hip;
  root.userData.bones.kneeL = root.userData.bones.hipL.children[1];
  root.userData.bones.kneeR = root.userData.bones.hipR.children[1];

  // Lift the entire root so feet rest at y=0 (root is at hip level ≈ 0.9)
  root.position.y = 0.9;

  // Name + face portrait label
  if (palette.portraitUrl || name) {
    const lbl = makePortraitLabel(name, palette.portraitUrl);
    lbl.position.y = 1.1;
    root.add(lbl);
    root.userData.bones.label = lbl;
  }

  return root;
}

// ───────────────────────────── DOGS ───────────────────────────────────────

export function buildDog({ name, palette, breedShape = 'lab' }) {
  const p = palette;
  const root = new THREE.Group();
  root.name = `dog:${name}`;
  root.userData.kind = 'dog';
  root.userData.bones = {};

  // Body proportions per breed shape (Lab = mid; Husky = leaner; Bulldog = wider+lower)
  const SHAPES = {
    lab:     { bodyLen: 0.75, bodyR: 0.20, legLen: 0.42, height: 0.55, headR: 0.16 },
    husky:   { bodyLen: 0.78, bodyR: 0.17, legLen: 0.46, height: 0.58, headR: 0.15 },
    bulldog: { bodyLen: 0.72, bodyR: 0.24, legLen: 0.30, height: 0.40, headR: 0.18 },
    chihuahua: { bodyLen: 0.45, bodyR: 0.10, legLen: 0.22, height: 0.30, headR: 0.10 },
  };
  const s = SHAPES[breedShape] || SHAPES.lab;

  const coatMat = new THREE.MeshLambertMaterial({ color: p.coat });
  const coatDeep = new THREE.MeshLambertMaterial({ color: shade(p.coat, -0.15) });
  const noseMat = new THREE.MeshLambertMaterial({ color: 0x1a1a1a });

  // Body (capsule, oriented along z)
  const body = new THREE.Mesh(new THREE.CapsuleGeometry(s.bodyR, s.bodyLen, 8, 16), coatMat);
  body.rotation.x = Math.PI / 2;
  body.position.y = s.height;
  body.castShadow = true;
  root.add(body);

  // White chest patch (Bowie has one)
  if (p.chestPatch) {
    const patch = new THREE.Mesh(new THREE.SphereGeometry(s.bodyR * 0.7, 12, 8), new THREE.MeshLambertMaterial({ color: 0xf6efe2 }));
    patch.scale.set(0.8, 0.6, 0.5);
    patch.position.set(0, s.height - 0.04, s.bodyLen * 0.5);
    root.add(patch);
  }

  // Neck pivot
  const neck = new THREE.Group();
  neck.position.set(0, s.height + s.bodyR * 0.4, s.bodyLen * 0.55);
  root.add(neck);
  const neckMesh = new THREE.Mesh(new THREE.CylinderGeometry(s.bodyR * 0.6, s.bodyR * 0.55, 0.18), coatMat);
  neckMesh.rotation.x = -0.5;
  neckMesh.position.set(0, 0.05, 0.05);
  neck.add(neckMesh);

  // Head
  const head = new THREE.Group();
  head.position.set(0, 0.12, 0.18);
  neck.add(head);
  const headMesh = new THREE.Mesh(new THREE.SphereGeometry(s.headR, 18, 14), coatMat);
  headMesh.castShadow = true;
  head.add(headMesh);

  // Snout
  const snout = new THREE.Mesh(new THREE.CylinderGeometry(s.headR * 0.5, s.headR * 0.65, s.headR * 0.9), coatMat);
  snout.rotation.x = Math.PI / 2;
  snout.position.set(0, -0.02, s.headR * 0.85);
  head.add(snout);

  // Nose
  const nose = new THREE.Mesh(new THREE.SphereGeometry(s.headR * 0.18, 10, 8), noseMat);
  nose.position.set(0, 0.005, s.headR * 1.3);
  head.add(nose);

  // Eyes
  const eyeMat = new THREE.MeshBasicMaterial({ color: 0x1a1a1a });
  for (const ex of [-0.05, 0.05]) {
    const eye = new THREE.Mesh(new THREE.SphereGeometry(0.018, 8, 6), eyeMat);
    eye.position.set(ex, 0.04, s.headR * 0.85);
    head.add(eye);
  }

  // Ears (floppy lab-style: cones drooping down)
  for (const sgn of [-1, 1]) {
    const earPivot = new THREE.Group();
    earPivot.position.set(sgn * s.headR * 0.7, s.headR * 0.5, 0);
    earPivot.rotation.z = sgn * 0.5;     // splay outward slightly
    earPivot.rotation.x = 0.4;            // droop forward/down
    head.add(earPivot);
    const ear = new THREE.Mesh(new THREE.ConeGeometry(s.headR * 0.4, s.headR * 1.4, 12), coatDeep);
    ear.position.y = -s.headR * 0.7;
    earPivot.add(ear);
  }

  // Tail
  const tailPivot = new THREE.Group();
  tailPivot.position.set(0, s.height + s.bodyR * 0.3, -s.bodyLen * 0.55);
  root.add(tailPivot);
  const tail = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.025, 0.32), coatMat);
  tail.position.set(0, 0.12, -0.06);
  tail.rotation.x = -0.6;
  tailPivot.add(tail);
  root.userData.bones.tail = tailPivot;

  // Legs (4 — front-left, front-right, back-left, back-right)
  // Each leg: hip pivot → thigh → knee pivot → calf
  function buildLeg(zPos, xSide) {
    const hip = new THREE.Group();
    hip.position.set(xSide * s.bodyR * 0.7, s.height - s.bodyR * 0.5, zPos);
    root.add(hip);
    const thigh = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.045, s.legLen * 0.5), coatMat);
    thigh.position.y = -s.legLen * 0.25;
    thigh.castShadow = true;
    hip.add(thigh);
    const knee = new THREE.Group();
    knee.position.y = -s.legLen * 0.5;
    hip.add(knee);
    const calf = new THREE.Mesh(new THREE.CylinderGeometry(0.045, 0.04, s.legLen * 0.5), coatMat);
    calf.position.y = -s.legLen * 0.25;
    calf.castShadow = true;
    knee.add(calf);
    const paw = new THREE.Mesh(new THREE.SphereGeometry(0.06, 12, 8), coatDeep);
    paw.scale.set(1.2, 0.5, 1.2);
    paw.position.y = -s.legLen * 0.5;
    knee.add(paw);
    return { hip, knee };
  }
  const FL = buildLeg(s.bodyLen * 0.35, -1);
  const FR = buildLeg(s.bodyLen * 0.35,  1);
  const BL = buildLeg(-s.bodyLen * 0.35, -1);
  const BR = buildLeg(-s.bodyLen * 0.35,  1);
  root.userData.bones.FL_hip = FL.hip; root.userData.bones.FL_knee = FL.knee;
  root.userData.bones.FR_hip = FR.hip; root.userData.bones.FR_knee = FR.knee;
  root.userData.bones.BL_hip = BL.hip; root.userData.bones.BL_knee = BL.knee;
  root.userData.bones.BR_hip = BR.hip; root.userData.bones.BR_knee = BR.knee;

  // Name + portrait label
  if (palette.portraitUrl || name) {
    const lbl = makePortraitLabel(name, palette.portraitUrl);
    lbl.position.y = s.height + 0.55;
    root.add(lbl);
    root.userData.bones.label = lbl;
  }

  return root;
}

// ───────────────────────── Walk cycle drivers ─────────────────────────────

// Drive a human's joints from a single time-based phase.
// stride: 0..1 (0 = idle, 1 = full stride). Increase for run.
export function animateHuman(rig, time, stride = 1.0, runMode = false) {
  const b = rig.userData.bones;
  const speed = runMode ? 8 : 4.5;
  const phase = time * speed;
  const amp = stride * (runMode ? 1.0 : 0.7);

  // Legs — swing fwd/back at the hip
  b.hipL.rotation.x = Math.sin(phase) * 0.7 * amp;
  b.hipR.rotation.x = Math.sin(phase + Math.PI) * 0.7 * amp;
  // Knees — bend more on the up-swing (avoid hyperextending forward)
  b.kneeL.rotation.x = Math.max(0, Math.sin(phase + Math.PI * 0.5)) * 0.9 * amp;
  b.kneeR.rotation.x = Math.max(0, Math.sin(phase + Math.PI * 1.5)) * 0.9 * amp;

  // Arms — opposite phase to legs, smaller amplitude
  b.shoulderL.rotation.x = Math.sin(phase + Math.PI) * 0.5 * amp;
  b.shoulderR.rotation.x = Math.sin(phase) * 0.5 * amp;
  // Elbows — bent ~25° always, more when arm forward (runners hold tighter)
  const baseBend = runMode ? -0.6 : -0.25;
  b.elbowL.rotation.x = baseBend - Math.max(0, Math.sin(phase + Math.PI)) * 0.4 * amp;
  b.elbowR.rotation.x = baseBend - Math.max(0, Math.sin(phase)) * 0.4 * amp;

  // Body bob (vertical bounce + slight side roll)
  rig.position.y = 0.9 + Math.abs(Math.sin(phase)) * 0.04 * amp;
  rig.rotation.z = Math.sin(phase) * 0.03 * amp;
}

// Drive a dog's quadruped trot/gallop cycle.
export function animateDog(rig, time, stride = 1.0, runMode = false) {
  const b = rig.userData.bones;
  const speed = runMode ? 12 : 7;
  const phase = time * speed;
  const amp = stride * (runMode ? 1.1 : 0.8);

  // Trot = diagonal pairs counter-phase: FL+BR vs FR+BL
  // Gallop = front pair together, back pair together, slight offset (~π/2)
  if (runMode) {
    // Gallop
    b.FL_hip.rotation.x = Math.sin(phase) * 0.9 * amp;
    b.FR_hip.rotation.x = Math.sin(phase + 0.4) * 0.9 * amp;
    b.BL_hip.rotation.x = Math.sin(phase + Math.PI) * 0.9 * amp;
    b.BR_hip.rotation.x = Math.sin(phase + Math.PI + 0.4) * 0.9 * amp;
  } else {
    // Trot
    b.FL_hip.rotation.x = Math.sin(phase) * 0.7 * amp;
    b.BR_hip.rotation.x = Math.sin(phase) * 0.7 * amp;
    b.FR_hip.rotation.x = Math.sin(phase + Math.PI) * 0.7 * amp;
    b.BL_hip.rotation.x = Math.sin(phase + Math.PI) * 0.7 * amp;
  }
  // Knees — bend on up-swing
  b.FL_knee.rotation.x = Math.max(0, Math.sin(phase + Math.PI * 0.5)) * 0.7 * amp;
  b.FR_knee.rotation.x = Math.max(0, Math.sin(phase + Math.PI * 1.5)) * 0.7 * amp;
  b.BL_knee.rotation.x = Math.max(0, Math.sin(phase + Math.PI * 1.5)) * 0.7 * amp;
  b.BR_knee.rotation.x = Math.max(0, Math.sin(phase + Math.PI * 0.5)) * 0.7 * amp;

  // Tail wag — proportional to excitement (stride)
  if (b.tail) {
    b.tail.rotation.y = Math.sin(time * 8) * 0.4 * Math.max(0.3, stride);
  }

  // Body bob (subtle)
  const dy = Math.abs(Math.sin(phase * 2)) * 0.03 * amp;
  rig.position.y = dy;
}

// ───────────────────────── Helpers ────────────────────────────────────────

// Portrait label sprite — a name pill with a small avatar circle next to the
// text. Reads the avatar PNG via TextureLoader so each name tag is recognizably
// "this is Steve / Bowie".
const _portraitTexCache = new Map();
function _getPortraitTex(url) {
  if (!url) return null;
  if (_portraitTexCache.has(url)) return _portraitTexCache.get(url);
  const t = new THREE.TextureLoader().load(url);
  t.colorSpace = THREE.SRGBColorSpace;
  _portraitTexCache.set(url, t);
  return t;
}

function makePortraitLabel(name, portraitUrl) {
  const c = document.createElement('canvas');
  c.width = 360; c.height = 88;
  const ctx = c.getContext('2d');
  // Pill background
  ctx.fillStyle = 'rgba(14,14,16,.75)';
  roundRect(ctx, 0, 0, 360, 88, 14);
  ctx.fill();
  ctx.strokeStyle = 'rgba(212,160,74,.5)';
  ctx.lineWidth = 2;
  roundRect(ctx, 1, 1, 358, 86, 14);
  ctx.stroke();
  // Avatar circle (placeholder fill — texture is drawn over via Sprite below if available)
  ctx.fillStyle = '#666';
  ctx.beginPath(); ctx.arc(44, 44, 30, 0, Math.PI * 2); ctx.fill();
  // Name text
  ctx.fillStyle = '#fff';
  ctx.font = '600 36px -apple-system, system-ui, sans-serif';
  ctx.textAlign = 'left';
  ctx.textBaseline = 'middle';
  ctx.fillText(name.slice(0, 16), 92, 46);
  const labelTex = new THREE.CanvasTexture(c);
  const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: labelTex, depthTest: false }));
  sprite.scale.set(1.6, 0.4, 1);

  // Composite the avatar PNG into the label by drawing it on the canvas after load
  if (portraitUrl) {
    const img = new Image();
    img.crossOrigin = 'anonymous';
    img.onload = () => {
      // Clip to circle then draw
      ctx.save();
      ctx.beginPath(); ctx.arc(44, 44, 30, 0, Math.PI * 2); ctx.clip();
      // The avatar PNGs are full-body, centered; sample the head area
      // (top quarter) to fit a face into the circle.
      ctx.drawImage(img, 0, 0, img.width, img.height * 0.45, 14, 14, 60, 60);
      ctx.restore();
      labelTex.needsUpdate = true;
    };
    img.src = portraitUrl;
  }
  return sprite;
}

function roundRect(ctx, x, y, w, h, r) {
  ctx.beginPath();
  ctx.moveTo(x + r, y);
  ctx.arcTo(x + w, y,     x + w, y + h, r);
  ctx.arcTo(x + w, y + h, x,     y + h, r);
  ctx.arcTo(x,     y + h, x,     y,     r);
  ctx.arcTo(x,     y,     x + w, y,     r);
}

function shade(hex, pct) {
  const c = new THREE.Color(hex);
  const hsl = { h: 0, s: 0, l: 0 };
  c.getHSL(hsl);
  hsl.l = Math.max(0, Math.min(1, hsl.l + pct));
  c.setHSL(hsl.h, hsl.s, hsl.l);
  return c.getHex();
}

// ───────────────────────── Cast palettes ──────────────────────────────────

export const CAST_PALETTES = {
  // Humans
  steve:   { skin: 0xf2cba6, hair: 0xc97847, shirt: 0x6aa9d1, pants: 0x2c3a5e, shoe: 0xf2efe6 },
  natalia: { skin: 0xe8c8a0, hair: 0xa05a3a, shirt: 0x5da064, pants: 0x6a8fb5, shoe: 0xf2efe6 },
  dylan:   { skin: 0xd8b896, hair: 0x2a1a14, shirt: 0x2c3e60, pants: 0x6a6e75, shoe: 0xf2efe6 },
  // Dogs
  bowie:    { coat: 0x1a1a1a, chestPatch: true },
  madison:  { coat: 0x4a2818 },
  humphrey: { coat: 0xeacc99 },
};