clone

an endless marble-race generator in the browser; the url is the seed, so every track can be shared

main
marbles / src / client / peers.ts
peers.ts 6.0K
1/**2 * Peer marble ring — render layer. Owns the Three.js meshes for the3 * PeerRingCore's peers: tinted marble-kit variants riding the session's4 * centerline with a small seeded lateral offset + wobble (so passes read5 * as side-by-side overtakes, not superimpositions), rolling spin, and6 * scale-in/out on spawn/despawn.7 */8import * as THREE from "three";9import type { Assets } from "./assets.ts";10import { MARBLE_SCALE } from "./physics/engine.ts";11import {12 IN_TIME,13 OUT_TIME,14 type Peer,15 PeerRingCore,16 slotOffsets,17} from "./peersCore.ts";1819/** Samples the session's global centerline at arc length s. */20export type FrameSampler = (21 s: number,22 pos: THREE.Vector3,23 tan: THREE.Vector3,24) => boolean;2526const FALLBACK_MESH = "marble-center-high";2728interface PeerVisual {29 peer: Peer;30 obj: THREE.Group;31 pivot: THREE.Group;32 radius: number;33 /** Cloned (tinted) materials to dispose with the peer. */34 materials: THREE.Material[];35 baseScale: number;36}3738export class PeerRing {39 private readonly core: PeerRingCore;40 private readonly visuals = new Map<string, PeerVisual>();41 private readonly point = new THREE.Vector3();42 private readonly tangent = new THREE.Vector3();43 private readonly axis = new THREE.Vector3();44 private readonly spinQ = new THREE.Quaternion();45 private readonly up = new THREE.Vector3(0, 1, 0);4647 constructor(48 seed: string,49 private readonly assets: Assets,50 private readonly parent: THREE.Object3D,51 count?: number,52 ) {53 this.core = count === undefined54 ? new PeerRingCore(seed)55 : new PeerRingCore(seed, slotOffsets(count));56 }5758 get liveCount(): number {59 return this.core.liveCount;60 }6162 /** Peer arc positions relative to the player (CUA/debug). */63 stats(playerS: number): { slot: number; rel: number; speed: number }[] {64 return this.core.peers.flatMap((p) =>65 p66 ? [{67 slot: p.slot,68 rel: Number((p.s - playerS).toFixed(1)),69 speed: Number(p.speed.toFixed(2)),70 }]71 : []72 );73 }7475 update(76 dt: number,77 playerS: number,78 band: readonly [number, number],79 sample: FrameSampler,80 ): void {81 const events = this.core.update(82 dt,83 playerS,84 band,85 performance.now(),86 (s) => sample(s, this.point, this.tangent) ? this.tangent.y : null,87 );88 for (const peer of events.spawned) this.buildVisual(peer);89 for (const peer of events.removed) this.destroyVisual(peer);9091 for (const vis of this.visuals.values()) {92 const { peer } = vis;93 if (!sample(peer.s, this.point, this.tangent)) continue;9495 // Lateral offset + wobble: perpendicular to travel in the horizontal96 // plane, faded out where the tangent goes vertical (helix dives).97 const nl = Math.hypot(this.tangent.z, this.tangent.x);98 if (nl > 1e-4) {99 const off = peer.identity.lateral +100 Math.sin(101 peer.age * peer.identity.wobbleHz * Math.PI * 2 +102 peer.identity.wobblePhase,103 ) * peer.identity.wobbleAmp;104 const f = Math.min(1, nl / 0.3);105 this.point.x += (this.tangent.z / nl) * off * f;106 this.point.z += (-this.tangent.x / nl) * off * f;107 }108 vis.obj.position.copy(this.point);109110 // Rolling spin, same law as the player marble.111 if (peer.phase !== "out") {112 this.axis.copy(this.up).cross(this.tangent);113 if (this.axis.lengthSq() > 1e-8) {114 this.axis.normalize();115 this.spinQ.setFromAxisAngle(116 this.axis,117 (peer.speed * dt) / Math.max(vis.radius, 1e-3),118 );119 vis.pivot.quaternion.premultiply(this.spinQ);120 }121 }122123 // Spawn/despawn scale envelope (easeOutBack in, ease-in out).124 let k = 1;125 if (peer.phase === "in") {126 const t = Math.min(1, peer.phaseT / IN_TIME);127 const c = 1.70158;128 const u = t - 1;129 k = 1 + (c + 1) * u * u * u + c * u * u;130 } else if (peer.phase === "out") {131 const t = Math.min(1, peer.phaseT / OUT_TIME);132 k = Math.max(1e-3, 1 - t * t);133 }134 vis.obj.scale.setScalar(k * vis.baseScale);135 }136 }137138 dispose(): void {139 for (const peer of this.core.clear()) this.destroyVisual(peer);140 }141142 private buildVisual(peer: Peer): void {143 const obj = new THREE.Group();144 obj.name = `peer:${peer.label}`;145 const pivot = new THREE.Group();146 obj.add(pivot);147 const mesh = this.assets.getPiece(peer.identity.meshId) ??148 this.assets.getPiece(FALLBACK_MESH);149 let radius = 0.1;150 const materials: THREE.Material[] = [];151 if (mesh) {152 mesh.scale.setScalar(MARBLE_SCALE * peer.identity.scale);153 if (peer.identity.tint) {154 const [r, g, b] = peer.identity.tint;155 mesh.traverse((child: THREE.Object3D) => {156 const m = child as THREE.Mesh;157 if (!m.isMesh) return;158 const tintOne = (mat: THREE.Material): THREE.Material => {159 const clone = (mat as THREE.MeshStandardMaterial).clone();160 clone.color.setRGB(r / 255, g / 255, b / 255);161 materials.push(clone);162 return clone;163 };164 m.material = Array.isArray(m.material)165 ? m.material.map(tintOne)166 : tintOne(m.material);167 });168 }169 const box = new THREE.Box3().setFromObject(mesh);170 const center = box.getCenter(new THREE.Vector3());171 const size = box.getSize(new THREE.Vector3());172 radius = Math.max(size.x, size.y, size.z) / 2 || 0.1;173 pivot.position.copy(center);174 mesh.position.sub(center);175 pivot.add(mesh);176 }177 obj.scale.setScalar(1e-3); // grows in via the scale envelope178 this.parent.add(obj);179 this.visuals.set(peer.label, {180 peer,181 obj,182 pivot,183 radius,184 materials,185 baseScale: 1,186 });187 }188189 private destroyVisual(peer: Peer): void {190 const vis = this.visuals.get(peer.label);191 if (!vis) return;192 this.visuals.delete(peer.label);193 this.parent.remove(vis.obj);194 vis.obj.clear();195 for (const m of vis.materials) m.dispose();196 }197}