diff --git a/flow-state/src/venues/ride.js b/flow-state/src/venues/ride.js index 831544b..055daf1 100644 --- a/flow-state/src/venues/ride.js +++ b/flow-state/src/venues/ride.js @@ -284,15 +284,28 @@ export const ride = { roadside.name = 'roadside'; // 12 slots spaced 8 over [-84, +4]: house / sign / trees / billboard. // Billboard appears at most once, on one side, placed mid-span so it - // is on screen for a stretch, not a flash. + // is on screen for a stretch, not a flash. On ~40% of builds two + // slots instead hold actor figures — the track's own characters + // standing among the props (never the billboard slot). Static for + // the whole video: figures never pop in mid-shot, they stream past + // with their slot like every other prop. const billSlot = 3 + Math.floor(rng() * 6); const billSide = rng() < 0.5 ? -1 : 1; + const roadsideActors = rng() < 0.4; + const actorSlots = new Set(); + if (roadsideActors) { + while (actorSlots.size < 2) { + const s = Math.floor(rng() * 12); + if (s !== billSlot) actorSlots.add(s); + } + } const roadsideMeta = []; const roadsidePopulation = 12; for (let i = 0; i < roadsidePopulation; i++) { const g = new T.Group(); const side = i % 2 === 0 ? -1 : 1; const kind = i === billSlot ? 'billboard' + : actorSlots.has(i) ? 'actor' : i % 3 === 0 ? 'house' : i % 3 === 1 ? 'sign' : 'trees'; // Dressing lives far out (houses ±13-18, signs/trees ±9-13): near // items cross the frame in a blink, far items linger and let the @@ -330,8 +343,7 @@ export const ride = { diamond.rotation.z = Math.PI / 4; diamond.rotation.y = side > 0 ? -0.35 : 0.35; g.add(diamond); - } else if (kind === 'billboard') { - // Two posts + big panel carrying the viz (bound in + } else if (kind === 'billboard') { // Two posts + big panel carrying the viz (bound in // bindVizTexture alongside the radio). Faces the road. const pGeo = new T.CylinderGeometry(0.12, 0.12, 3.4, 6); for (const px of [-1.4, 1.4]) { @@ -349,6 +361,13 @@ export const ride = { panel.name = 'billboard'; g.add(panel); g.userData.billPanel = panel; + } else if (kind === 'actor') { + // A figure of the track's own cast, grown on first update + // when identity arrives (same pattern as the crew): until + // then the slot is an empty group, which never renders. + g.userData.actorSlot = true; + g.userData.actorBuilt = false; + g.userData.phase = rng() * Math.PI * 2; } else { // Trees: 2-3 cones on cylinders, dark green glow. const n = 2 + Math.floor(rng() * 2); @@ -542,9 +561,27 @@ export const ride = { // — the chassis above varies per track, this is what makes every // reading of it a convertible rather than a tractor. Painted // panels join the viz reflection feed (more surface mirroring the - // video); dark trim stays matte. All constants: identical on - // every track, every run. + // video); dark trim stays matte. + // + // Varied per track, not per run: a private rng fork (the main + // stream is untouched, so crew draws don't move) plus the track's + // shape and style. Discrete variants only ever swap whole parts + // that interpenetrate identically — counts never change mid-video + // (arrangement-static, cut-covered like everything structural). { + const trimRng = mulberry(((instance.seed >>> 0) ^ 0x7e33d21b) >>> 0); + const pick = (n) => Math.floor(trimRng() * n); + // Rear wing: none, lip, or full blade. + const spoilerKind = trimRng() < 0.3 ? 'none' : trimRng() < 0.72 ? 'blade' : 'lip'; + // Rally stripes in the accent colour, or bare paint. + const stripeKind = trimRng() < 0.4 ? 'none' : trimRng() < 0.7 ? 'center' : 'dual'; + // Hood intake scoop xor the spine (they share the same turf). + const scoop = trimRng() < 0.45; + // Exhausts: dual or quad. + const quad = trimRng() < 0.4; + // Chrome slats follow line weight; tilt rakes the long panels. + const slatCount = 2 + Math.min(2, Math.floor(styleWeight * 3)); + const rake = (shapeTilt / Math.PI - 0.5) * 0.06; const paint = cc(0); const paintMat = () => vizEnvMaterial(new T.MeshStandardMaterial({ color: new T.Color(paint[0] * 0.85, paint[1] * 0.85, paint[2] * 0.85), @@ -864,7 +901,7 @@ export const ride = { transparent: opacity < 0.999, opacity, })); m.userData.partIndex = src.userData.partIndex ?? 0; - } else { + } else { const col = cc(i + 1); m = new T.Mesh( new T.SphereGeometry(0.22, 12, 10), @@ -1074,6 +1111,70 @@ export const ride = { instance.roadside.children[i].visible = opacity > 0.001 && (i < showUpTo || i === forced); } } + // Roadside actors: figures of the track's cast standing among the + // props on some builds (seeded at build; static all video, so they + // stream past with their slot and never pop). Grown here, on first + // update, because identity only arrives via update — the crew pattern. + // They turn slowly to feel alive; analytic f(t), seek-stable. + if (instance.roadside && identity && actorSpec && actorSpec.form) { + for (const g of instance.roadside.children) { + if (!g.userData.actorSlot) continue; + if (!g.userData.actorBuilt) { + try { + const built = actorToGeometry(actorSpec, identity, T); + const fig = new T.Group(); + fig.name = 'figure'; + while (built.children.length) { + const m = built.children[0]; + built.remove(m); + const pIdx = m.userData.partIndex ?? 0; + const pPal = actorSpec.paletteMap + ? actorSpec.paletteMap[pIdx % actorSpec.paletteMap.length] : pIdx; + const c = pal[pPal % pal.length] || pal[0]; + m.material = new T.MeshStandardMaterial({ + color: new T.Color(c[0], c[1], c[2]), + roughness: 0.5, metalness: 0.08, + transparent: opacity < 0.999, opacity, + }); + fig.add(m); + } + // Feet on the ground, person-scale: normalize the form + // to ~1.6 units tall (houses run 2-4) and plant the + // lowest point at the roadside level regardless of + // the form's own extent or origin. Local box + // (unparented) over slot scale: exact. + if (fig.children.length) { + const _box = new T.Box3().setFromObject(fig); + const _h = _box.max.y - _box.min.y; + const _s = _h > 1e-6 ? 1.6 / _h : 1; + fig.scale.setScalar(_s); + fig.position.y = -1.15 / g.scale.x - _s * _box.min.y; + } + g.add(fig); + g.userData.actorFig = fig; + instance.needsCompile = true; + } catch { /* figure optional; the slot stays empty */ } + g.userData.actorBuilt = true; + } + const fig = g.userData.actorFig; + if (fig) { + // Glance around rather than spinning: a bystander + // watching the road, not a turntable. Analytic f(t). + fig.rotation.y = (g.userData.phase || 0) + Math.sin(t * 0.12) * 0.55; + for (const m of fig.children) { + if (!m.isMesh) continue; + const pIdx = m.userData.partIndex ?? 0; + const pPal = actorSpec.paletteMap + ? actorSpec.paletteMap[pIdx % actorSpec.paletteMap.length] : pIdx; + const c = pal[pPal % pal.length] || pal[0]; + m.material.color.setRGB(c[0], c[1], c[2]); + m.material.opacity = opacity; + m.material.transparent = opacity < 0.999; + m.visible = opacity > 0.01; + } + } + } + } // Dressing takes the palette tint so houses/trees belong to the song; // the billboard glows with the section light (its IMAGE is the viz). if (instance.dressMat) {