// The cast with bodies. // // Identity gives the song a silhouette — sides, notches, hollows — and a solid // assembly (form) the shaders can march as SDF. This module gives the same song a // MESH: an ActorSpec that a ModelLayer can turn into BufferGeometry with // actorToGeometry, and later a library of named actors (Stage C) will grow on // top of it without changing the infra. // // Pure module: no three.js, no DOM, no wall-clock. Analytic like particles.js — // motion is f(t,seed), never integration, so seek === playback and preview === // export. Seeded off the look seed via rng.fork('actor:...'), so adding an actor // never shifts a decision made after it (same rule as rng.fork('form') in // Identity.js:311). // // Audio tilts the centre, seed picks within — same arrangement as // generatePersonality/generateIdentity: two songs land in different regions, // two seeds on one song land in different places inside one region. import { generateIdentity, SOLIDS, SYMMETRIES, FORM_OPS, MAX_FORM_PARTS } from '../look/Identity.js'; export const ACTOR_ARCHETYPES = ['monolith', 'swarm', 'walker', 'vehicle', 'structure']; /** * Which archetypes suit which section kind — as a per-track lean, not a rule. * Kept small and audio-tilted so every archetype stays reachable for every * track, the way directors.js keeps every director reachable. */ const ARCHETYPE_WEIGHTS = { monolith: 3, // one large solid — the default protagonist body swarm: 2, // many small chorus instances walker: 1, // articulated: two/three hinged parts, analytic gait (Stage C) vehicle: 1, // chassis + orientation axis, streaming motion (Stage C) structure: 2, // ground-anchored, heightfield-aware (Stage C) }; const clamp01 = (x) => Math.max(0, Math.min(1, x)); /** * Generate one actor — data, not scene graph. * * @param {object} opts.summary FeatureTrack.summary * @param {import('../engine/rng.js').Rng} opts.rng forked for this actor * @param {string} [opts.archetype] when absent, picked weighted by audio * @param {object} [opts.personality] look.personality — for shape reconciliation * @param {object} [opts.identity] look.personality.identity * @returns {object} ActorSpec — serialisable, hashable */ export function generateActor({ summary, rng, archetype = null, personality = null, identity = null }) { const s = summary || {}; const bright = s.meanCentroid ?? 0.5; const noisy = Math.min(1, (s.meanFlatness ?? 0.2) * 3); const fast = clamp01(((s.bpm ?? 120) - 80) / 80); const dynamic = clamp01(s.dynamicRange ?? 0.5); const sections = s.sections ?? 4; const busy = clamp01((sections - 2) / 5); // Audio sets the centre, seed picks within — mirrors Identity.generateIdentity. const angular = clamp01(noisy * 0.6 + fast * 0.3 + rng.range(-0.25, 0.25)); const intricate = clamp01(busy * 0.5 + bright * 0.3 + rng.range(-0.3, 0.3)); const solid = clamp01(0.5 - dynamic * 0.4 + rng.range(-0.25, 0.25)); if (!archetype) { const noisyW = 0.5 + noisy * 1.2; const weights = ACTOR_ARCHETYPES.map((a) => { let w = ARCHETYPE_WEIGHTS[a] || 1; if (a === 'walker' || a === 'vehicle') w *= 0.6 + noisyW * 0.4; if (a === 'structure') w *= 0.6 + (1 - noisy) * 0.6 + dynamic * 0.4; return w; }); archetype = rng.pickWeighted(ACTOR_ARCHETYPES, weights); } // The solid assembly — same rows the shaders march, so the mesh and the // impostor are the same character. Reuses Identity.generateForm via a // derived identity when one was not supplied (checks, unit tests). let form; if (identity && identity.form) { form = identity.form; } else { // Derive a throwaway identity just to get a form; forked so the main // identity stream is untouched when this path is used in isolation. const derived = generateIdentity(s, rng.fork('actor:form'), sections); form = derived.form; // Keep the cast family in sync with the supplied personality shape when // both exist — mirrors identityUniforms(identity, shape) reconciliation. if (personality && personality.shape && identity === null) { identity = derived; } } // Kit reference — Stage B. Null in Stage A, which uses primitives. const kitRef = null; // Rig — Stage C. Null until walker/vehicle get articulated. let rig = null; if (archetype === 'walker' || archetype === 'vehicle') { // Stub rig: one hinge, analytic gait params — enough to prove the // ActorSpec shape without requiring a skeleton system. const joints = archetype === 'walker' ? [ { parent: -1, axis: [0, 1, 0], range: rng.range(0.3, 0.9), phase: rng.range(0, Math.PI * 2), ratio: 1 }, { parent: 0, axis: [1, 0, 0], range: rng.range(0.2, 0.6), phase: rng.range(0, Math.PI * 2), ratio: 0.6 }, ] : [ { parent: -1, axis: [0, 1, 0], range: rng.range(0.15, 0.45), phase: rng.range(0, Math.PI * 2), ratio: 1 }, ]; rig = { joints, gait: archetype === 'walker' ? 'walk' : 'roll' }; } // Which palette entry each part reads — seeded, so two actors on one track // differ in colour rhythm even when their forms coincide. const paletteMap = form.parts.map(() => rng.int(0, 3)); // Scale reconciled with Identity.lattice.elementScale so mesh size agrees // with stageNode.z. Base is the song's elementScale-derived size; spread // is how much the actor's own parts vary. const elementScale = identity ? identity.lattice.elementScale : 0.35; const scale = { base: elementScale, spread: clamp01(0.15 + intricate * 0.6 + rng.range(-0.2, 0.25)), }; const placement = identity ? { latticeKind: identity.lattice.kind, spread: identity.lattice.spread, jitter: identity.lattice.jitter, } : { latticeKind: 'scatter', spread: 0.7, jitter: 0.3 }; const motion = { orbitRate: rng.range(0.08, 0.45), spin: rng.range(-0.6, 0.6), bobAmp: rng.range(0.005, 0.025), bobRate: rng.range(0.3, 1.2), }; return { archetype, seed: rng.seed >>> 0, form, kitRef, rig, paletteMap, scale, placement, motion, // Keep the audio-derived character alongside the spec so a HUD or // check can report why this actor looks the way it does. character: { angular, intricate, solid }, }; } /** * Generate the per-track actor set — one ActorSpec per archetype, each from * its own fork so the set is stable under reordering. * * @param {object} summary * @param {import('../engine/rng.js').Rng} rng parent (look seed fork) * @param {object} personality * @param {object} identity * @returns {Record} archetype -> ActorSpec */ export function generateActorSet(summary, rng, personality = null, identity = null) { const set = {}; for (const arch of ACTOR_ARCHETYPES) { set[arch] = generateActor({ summary, rng: rng.fork(`actor:${arch}`), archetype: arch, personality, identity, }); } return set; } /** * Totally ordered actor-set summary for HUD / check output — mirrors * describeIdentity / describePersonality shape. */ export function describeActor(actor) { if (!actor) return 'no actor'; const f = actor.form; const parts = f ? `${f.parts.length}-part/${f.symmetry}${f.symmetry !== 'none' ? f.symmetryN : ''}` : 'no form'; const rig = actor.rig ? ` · rig ${actor.rig.gait} ${actor.rig.joints.length}j` : ''; const kit = actor.kitRef ? ` · kit ${actor.kitRef.id}` : ''; return `${actor.archetype} ${parts}${rig}${kit} · scale ${actor.scale.base.toFixed(2)}`; } export function describeActorSet(set) { if (!set) return 'no actors'; return ACTOR_ARCHETYPES.map((a) => (set[a] ? describeActor(set[a]) : `${a}:—`)).join(' | '); } // Re-export for consumers that only need the constants without importing Identity. export { SOLIDS, SYMMETRIES, FORM_OPS, MAX_FORM_PARTS }; // Convenience: deterministic hash of an ActorSpec's visible content — for // determinism checks and census tooling. export function hashActorSpec(spec) { let h = 0x811c9dc5 >>> 0; const mix = (n) => { h ^= n & 0xff; h = Math.imul(h, 0x01000193) >>> 0; h ^= (n >>> 8) & 0xff; h = Math.imul(h, 0x01000193) >>> 0; }; mix(spec.seed); for (let i = 0; i < spec.archetype.length; i++) mix(spec.archetype.charCodeAt(i)); if (spec.form) { mix(spec.form.parts.length); for (const p of spec.form.parts) { mix(SOLIDS.indexOf(p.kind)); mix(Math.round(p.offset[0] * 100)); mix(Math.round(p.scale[0] * 100)); } } return h >>> 0; }