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>
128 lines
5.5 KiB
JavaScript
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 };
|
|
}
|