PROCITY/web/js/citizens/placeholder.js
m3ultra 9eb1acc61b Lane D (Citizens): deterministic LOD crowd — rigs/impostors/keepers + test page
Populate the town at city scale, on budget, deterministic per-citizen identity:
- rigs.js — ported 90sDJsim rig stack: mixamorig canonicalisation so one shared
  walk clip drives all 19 peds, head-bone height-normalise, feet-plant, pooled
  near-tier actors with walk<->idle crossfade
- impostor.js — 4-yaw sprite-atlas baker + instanced billboards: whole mid crowd
  in 1 draw call, tone-matched to the ACES near rigs
- sim.js — deterministic roster, footpath lanes off the street graph, near/mid/far
  LOD with hysteresis + hard 24-cap, staggered mixer budget, time-of-day density
- placeholder.js — seeded low-poly box humanoids (frame-one population + ?noassets)
- keepers.js — one keeper per shop at the counter, idle + greet-turn
- 21 ped GLBs (byte-identical from 90sDJsim), citizens_test.html harness,
  LANE_D_NOTES.md, 5 beauty shots

Verified in-browser: 200 citizens at max mixer 0.2ms (budget 2ms), near capped 24,
determinism holds (200 identities match seed), no T-pose, ?noassets crash-free.
Consumes Lane A plan.streets with zero adapter. Six adversarial-review bugs fixed
(non-deterministic fleet order, shared-resource disposal on pool eviction,
impostor colour-management).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 13:43:05 +10:00

128 lines
5.5 KiB
JavaScript

// PROCITY Lane D — placeholder figures.
// Low-poly chunky humanoids built from boxes (the POLY style lock), so the town is populated
// from frame one and stays populated in ?noassets mode. A placeholder hot-swaps to a real rig
// when the ped fleet finishes loading (upgradeStreetPeople pattern) — but on its own it walks,
// idles, and bakes into impostors just like a rig. Feet planted at local y=0, head at +height.
//
// Deterministic: every colour + proportion is drawn from a seeded stream (core/prng), so the
// same citizen id yields the same body twice. No Math.random() here.
import * as THREE from 'three';
import { frange, pick } from '../core/prng.js';
// muted 90s-Australian palette (shirts / pants / skin) — reads well under warm cinematic light
const SHIRTS = ['#8d9db6', '#b6564e', '#d8b23a', '#5f7a52', '#c8794a', '#7a5c8d', '#c9c2b0',
'#3f6b6b', '#a94f6b', '#4d6a94', '#d0d0c8', '#8a6d3b'];
const PANTS = ['#3a3f4a', '#4a3b2e', '#2e3d33', '#5a5148', '#38414d', '#6b5b4a', '#2f2f38'];
const SKINS = ['#e6c0a0', '#d8a878', '#c68a5e', '#a8683e', '#8a5230', '#f0d4b8'];
const HAIRS = ['#2a221c', '#4a3526', '#6b4a2e', '#111013', '#8a7a5a', '#b0a090', '#7a2e1e'];
let _sharedGeo = null; // one box geometry shared across every placeholder part (cheap)
function geo() { return (_sharedGeo ||= new THREE.BoxGeometry(1, 1, 1)); }
function part(mat, w, h, d, x, y, z) {
const m = new THREE.Mesh(geo(), mat);
m.scale.set(w, h, d);
m.position.set(x, y, z);
m.castShadow = false;
m.frustumCulled = false;
return m;
}
// makePlaceholder(r, { height }) → { fig, tick(dt, moving), dispose }
// r : a seeded mulberry32 stream (rng(citySeed,'citizen',id))
// height : target standing height in metres (feet at y=0)
// The figure exposes two hip pivots + two shoulder pivots that swing for a chunky walk/idle.
export function makePlaceholder(r, { height = 1.75 } = {}) {
const H = height;
const skin = pick(r, SKINS), shirt = pick(r, SHIRTS), pant = pick(r, PANTS), hair = pick(r, HAIRS);
const mSkin = new THREE.MeshStandardMaterial({ color: new THREE.Color(skin), roughness: 0.9 });
const mShirt = new THREE.MeshStandardMaterial({ color: new THREE.Color(shirt), roughness: 0.85 });
const mPant = new THREE.MeshStandardMaterial({ color: new THREE.Color(pant), roughness: 0.85 });
const mHair = new THREE.MeshStandardMaterial({ color: new THREE.Color(hair), roughness: 0.95 });
// proportions (fractions of H), lightly jittered per person so the crowd isn't a clone army
const build = frange(r, 0.9, 1.12); // slim ↔ stocky
const legH = H * 0.46;
const torsoH = H * 0.30;
const headH = H * 0.13;
const shoulderW = H * (0.16 * build);
const hipW = H * (0.11 * build);
const fig = new THREE.Group();
// legs — pivot at hip so they swing from the top
const legLen = legH;
const mkLeg = (side) => {
const pivot = new THREE.Group();
pivot.position.set(side * hipW * 0.5, legLen, 0); // hip height
const leg = part(mPant, hipW * 0.9, legLen, hipW * 0.9, 0, -legLen * 0.5, 0);
// little foot for grounding read
// toes point -Z so the placeholder "faces" -Z like the GLB rigs (uniform facing math)
const foot = part(mPant, hipW * 0.95, legLen * 0.12, hipW * 1.6, 0, -legLen + legLen * 0.06, -hipW * 0.4);
pivot.add(leg, foot);
fig.add(pivot);
return pivot;
};
const legL = mkLeg(-1), legR = mkLeg(1);
// torso
const torsoY = legH + torsoH * 0.5;
fig.add(part(mShirt, shoulderW, torsoH, shoulderW * 0.55, 0, torsoY, 0));
// arms — pivot at shoulder
const armLen = torsoH * 1.02;
const mkArm = (side) => {
const pivot = new THREE.Group();
pivot.position.set(side * shoulderW * 0.5, legH + torsoH * 0.92, 0);
const arm = part(mShirt, shoulderW * 0.24, armLen, shoulderW * 0.24, 0, -armLen * 0.5, 0);
const hand = part(mSkin, shoulderW * 0.26, armLen * 0.14, shoulderW * 0.26, 0, -armLen + armLen * 0.07, 0);
pivot.add(arm, hand);
fig.add(pivot);
return pivot;
};
const armL = mkArm(-1), armR = mkArm(1);
// head + hair (a "head" node so the sim can do the keeper greet head-turn on placeholders too)
const headPivot = new THREE.Group();
headPivot.name = 'head';
headPivot.position.set(0, legH + torsoH, 0);
const head = part(mSkin, headH * 0.85, headH, headH * 0.85, 0, headH * 0.5, 0);
const cap = part(mHair, headH * 0.92, headH * 0.4, headH * 0.92, 0, headH * 0.9, 0);
headPivot.add(head, cap);
fig.add(headPivot);
fig.userData.isPlaceholder = true;
// gait state
let phase = frange(r, 0, Math.PI * 2); // desync
const swing = 0.9; // radians of leg swing when walking
let breathe = frange(r, 0, Math.PI * 2);
function tick(dt, moving) {
if (moving) {
phase += dt * 7.5; // stride cadence
const s = Math.sin(phase) * swing;
legL.rotation.x = s;
legR.rotation.x = -s;
armL.rotation.x = -s * 0.7;
armR.rotation.x = s * 0.7;
} else {
// idle: settle limbs, gentle breathing sway
breathe += dt * 1.6;
const ease = 0.86;
legL.rotation.x *= ease; legR.rotation.x *= ease;
const b = Math.sin(breathe) * 0.04;
armL.rotation.x = armL.rotation.x * ease - b;
armR.rotation.x = armR.rotation.x * ease - b;
headPivot.rotation.z = Math.sin(breathe * 0.5) * 0.02;
}
}
function dispose() {
fig.traverse(o => { if (o.isMesh && o.material) o.material.dispose(); });
}
return { fig, tick, dispose, head: headPivot };
}