// 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 }; }