← 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 },
};