1/**2 * Endless mode client: the void line, streamed.3 *4 * One EndlessWorld per seed generates segments ahead of the marble; this5 * module builds their render groups (track pieces + island masses/decor in6 * one group per segment), chains their open centerline curves into a global7 * arc-length line, and disposes groups behind the marble. The render window8 * (BEHIND..AHEAD) sits strictly inside the generator's KEEP_SEGMENTS, so9 * everything visible is co-live in the occupancy — nothing on screen can10 * overlap.11 *12 * Scene dressing: deep-space background + fog, a camera-following starfield.13 * No ground plane — islands are the only land.14 */15import * as THREE from "three";16import { fitShadowsTo, type SceneCtx } from "./scene.ts";17import type { Assets } from "./assets.ts";18import { initSeed, newSeed } from "./seed.ts";19import { instantiatePlacements, type Spinner } from "./track.ts";20import { buildOpenCurve } from "./centerline.ts";21import { createTrail } from "./trail.ts";22import { type Cameras, type CamMode, createCameras } from "./camera.ts";23import { EndlessWorld, KEEP_SEGMENTS } from "../gen/endless/world.ts";24import { TRACK_SCALE } from "../gen/endless/scale.ts";25import { ExitAnim, SpawnAnim } from "./streamAnim.ts";26import {27 FIXED_DT,28 MARBLE_RADIUS,29 MARBLE_SCALE,30 PhysicsEngine,31 type PhysicsModule,32} from "./physics/engine.ts";33import {34 attachFrames,35 hasAltBranch,36 hasBraid,37 type Rail,38 railForSegment,39 railFrameAt,40 type RailStationSource,41 stationForce,42 stationGravityDelta,43 stationRestitution,44} from "./physics/rail.ts";45import {46 autopilotForce,47 type BoostState,48 boostStep,49 braidLaneAlt,50 DRAFT_GAIN,51 draftFactor,52 FULL_TANK,53 type Neighbor,54 type Pilot,55 type PilotDebug,56 pilotFor,57 pilotOffset,58 rubberMod,59 temperHeat,60 temperStep,61 wantJump,62} from "./physics/autopilot.ts";63import { racerIdentity, type Rival, rivalFor } from "./peersCore.ts";64import { rngFamily } from "../gen/rng.ts";65import {66 addMarbleCore,67 applyTunnelVariants,68 disposeMarbleOwnedVisuals,69 fresnelShellMaterial,70 glassShellMaterial,71 marbleEnv,72} from "./marbleGlass.ts";73import type { Placement } from "../shared/types.ts";74import {75 affineApply,76 affineUnapply,77 frameUp,78 quatRotate,79 quatUnrotate,80 type SegmentFrame,81 segmentFrameAt,82 twistParam,83} from "../shared/frame.ts";84import {85 bumperProxies,86 islandProxies,87 segmentProxies,88 worldifyProxies,89} from "../gen/endless/proxies.ts";90import { buildIslandInstanced, type InstancedBuild } from "./islandRender.ts";91import { FanField, fanVolumesOf } from "./fanField.ts";92import { buildChuteMesh, extractChannelSection } from "./twistChute.ts";93import { Atmosphere, drawZone } from "./atmosphere.ts";94// Zone cadence lives with the climate now (M21 — a generation input).95import { ZONE_SEGMENTS } from "../gen/climate.ts";96import { buildVoidDecor, type FloatBuild } from "./voidRender.ts";97import type { DecorCatalog } from "../gen/decor.ts";98import { genVersion } from "../gen/version.ts";99import { drawPipeColor } from "./pipeColor.ts";100import { PeerRing } from "./peers.ts";101import { installPhotoKey } from "./photo.ts";102import { AmbientAudio } from "./audio.ts";103import { milestoneFor, MilestoneToast, PlaceHud, racePlace } from "./hud.ts";104import { installCua } from "./cua.ts";105import { editingControl } from "./runControls.ts";106107// Per-tile physics modifiers + planner zones live in physics/rail.ts now108// (M11): ONE rail law shared with the headless survivability tests.109110/** Segments built ahead of / kept behind the marble's segment. */111const AHEAD = 3;112const BEHIND = 2;113114/** Ice-pale pipe recolor for icy zones (zones must READ). */115const ICE_PIPE: readonly [number, number, number] = [178, 224, 240];116/** Violet-charged pipe recolor for gravity-rotation zones. */117const GRAVITY_PIPE: readonly [number, number, number] = [186, 148, 255];118/** Boost surfaces (M19b): sprint zones and braid boost lanes read warm119 * amber — the lane contrast must READ (zones-must-read law). */120const BOOST_PIPE: readonly [number, number, number] = [255, 178, 64];121122/** Peer count above which peers render as one InstancedMesh (per-racer123 * kit meshes + cloned materials would swamp the scene). */124const INSTANCED_AT = 32;125126// Marble feel — same constants as the finite loop (marble.ts).127const MARBLE_PIECE = "marble-center-high";128const BASE_SPEED = 2.2;129const MIN_SPEED = 1.2;130const MAX_SPEED = 7;131const GRAVITY = 6;132const DRAG = 0.9;133134interface BuiltSegment {135 index: number;136 group: THREE.Group;137 curve: THREE.CatmullRomCurve3;138 startS: number;139 length: number;140 spinners: Spinner[];141 islands: number;142 /** Entry pose, kept for the exit animation's wave direction. */143 entry: { x: number; y: number; z: number; dir: number };144 /** Per-build GPU state (island instance buffers + cloned materials). */145 disposables: InstancedBuild[];146 /** Floating void decor builds (M18b) — ticked in finishFrame once the147 * spawn wave is done; disposed with the segment. */148 floats: FloatBuild[];149 /** Lowest channel base (PLAN-space y, scaled) — fall detection compares150 * in plan space (a reoriented racer "falls" along its local down). */151 lowY: number;152 /** Presentation frame (M16 gravity reorientation) — undefined = identity. */153 frame?: SegmentFrame;154 /** Exit-framed half of a twist segment (placements past the run). */155 postGroup?: THREE.Group;156 /** Authored channel pieces that can swallow the follow camera. */157 cameraOccluders: THREE.Object3D[];158 /** Glass/wire tunnel shells that fade only while containing the lens. */159 cameraFadeMeshes: THREE.Mesh[];160}161162interface UiRefs {163 seedEl: HTMLElement | null;164 statsEl: HTMLElement | null;165 newTrackBtn: HTMLButtonElement | null;166 camBtn: HTMLButtonElement | null;167 onError?: (err: unknown) => void;168 onReady?: () => void;169}170171export function bootEndless(172 ctx: SceneCtx,173 assets: Assets,174 catalog: DecorCatalog,175 ui: UiRefs,176 physics: PhysicsModule | null = null,177): void {178 // The void: stars pinned to the camera; sky/fog/light/weather belong to179 // the per-seed Atmosphere (a generative family of moods, drawn per zone).180 const stars = makeStarfield();181 ctx.scene.add(stars);182 ctx.onFrame.add(() => {183 stars.position.copy(ctx.camera.position);184 });185186 // Slalom-scale pieces would swallow the default follow cam — ride187 // higher and farther back; island masses are occluders (the follow cam188 // slides over shoulders instead of clipping through them).189 const cams = createCameras(ctx, null, null, {190 trail: 7,191 height: 3.1,192 occluders: () => session?.cameraOccluders() ?? [],193 // Gravity reorientation (M16): the lens rolls with the subject's194 // local gravity frame (eased inside camera.ts).195 up: () => session?.cameraUp() ?? [0, 1, 0],196 });197 const setCamLabel = (mode: CamMode) => {198 if (ui.camBtn) {199 ui.camBtn.textContent = mode === "orbit" ? "Follow cam" : "Orbit cam";200 ui.camBtn.setAttribute("aria-pressed", String(mode === "follow"));201 }202 };203 cams.onModeChange = setCamLabel;204205 let session: Session | null = null;206 const leaderBtn = document.getElementById("leader-cam");207 const toggleLeader = () => {208 if (!session?.canFollowLeader) return;209 session?.toggleLeaderCam();210 const active = session?.raceStats()?.leaderCam ?? false;211 if (leaderBtn) {212 leaderBtn.textContent = active ? "Race leader" : "Your marble";213 leaderBtn.setAttribute("aria-pressed", String(active));214 }215 };216 leaderBtn?.addEventListener("click", toggleLeader);217218 // Ambient audio (M13): procedural mood pad + rolling voice + event219 // cues. Muted until the user's toggle gesture (autoplay policy); the220 // M key or the HUD button.221 const audio = new AmbientAudio();222 const audioBtn = document.getElementById(223 "audio-toggle",224 ) as HTMLButtonElement | null;225 const setAudioLabel = () => {226 if (audioBtn) {227 audioBtn.textContent = audio.enabled ? "audio: on" : "audio: off";228 }229 };230 const toggleAudio = () => {231 audio.toggle();232 if (audio.enabled && session) audio.setMood(session.atmosphere.name);233 setAudioLabel();234 };235 if (audioBtn) {236 audioBtn.addEventListener("click", toggleAudio);237 }238 addEventListener("keydown", (e) => {239 if (240 editingControl(e.target) || e.repeat || e.metaKey || e.ctrlKey || e.altKey241 ) return;242 if (e.key === "m" || e.key === "M") toggleAudio();243 // SPACE elects leader-cam (M15): the camera chases the race leader244 // instead of the hero marble. Hero-follow is the default — a camera245 // that silently switches subjects mid-run reads as confusing.246 if (e.code === "Space" && session?.canFollowLeader) {247 if ((e.target as HTMLElement | null)?.closest("button, summary")) return;248 e.preventDefault();249 toggleLeader();250 }251 });252253 // P = live photo export (user rule: never freeze the run).254 const photo = installPhotoKey(255 ctx,256 () =>257 `marbles-${session?.seed ?? "void"}-${258 Math.round(session?.marbleS ?? 0)259 }u`,260 );261262 ctx.onFrame.add((dt) => {263 session?.update(dt);264 cams.update(dt);265 session?.fadeCameraTunnels(dt);266 });267268 initSeed((seed) => {269 session?.dispose();270 session = null;271 try {272 session = new Session(273 seed,274 ctx,275 assets,276 catalog,277 cams,278 ui,279 stars,280 physics,281 audio,282 );283 if (audio.enabled) audio.setMood(session.atmosphere.name);284 } catch (err) {285 console.error(err);286 if (ui.statsEl) {287 ui.statsEl.textContent = `error: ${288 err instanceof Error ? err.message : String(err)289 }`;290 }291 ui.onError?.(err);292 return;293 }294 // Endless is a journey — follow by default.295 cams.setMode("follow");296 setCamLabel(cams.mode);297 if (leaderBtn) {298 leaderBtn.hidden = !session.canFollowLeader;299 leaderBtn.textContent = "Your marble";300 leaderBtn.setAttribute("aria-pressed", "false");301 }302 for (303 const hint of document.querySelectorAll<HTMLElement>(".endless-help")304 ) {305 hint.hidden = !session.canFollowLeader;306 }307 ui.onReady?.();308 });309310 ui.newTrackBtn?.addEventListener("click", () => newSeed());311 ui.camBtn?.addEventListener("click", () => cams.toggle());312 ui.seedEl?.addEventListener("click", () => {313 navigator.clipboard?.writeText(location.href).catch(() => {});314 });315316 // Debug handle (dev tooling, same spirit as __ctx): harness probes317 // reach the live session for camera/sampling forensics.318 Object.defineProperty(globalThis, "__session", { get: () => session });319 installCua(ctx, () => ({320 mode: "endless",321 seed: session?.seed ?? "",322 version: genVersion(),323 camMode: cams.mode,324 marble: session325 ? {326 s: Number(session.marbleS.toFixed(2)),327 speed: Number(session.speed.toFixed(2)),328 pos: session.marbleObj.position.toArray().map((v: number) =>329 Number(v.toFixed(2))330 ),331 segment: session.currentIndex,332 }333 : null,334 // The race (M15): every real racer's progress + the current leader.335 race: session ? session.raceStats() : null,336 stream: session337 ? {338 built: session.builtRange(),339 generated: session.world.generatedCount,340 islandsSeen: session.islandsSeen,341 occupancyCells: session.world.occupancySize,342 theme: session.world.style.theme,343 // Leak gate: total scene nodes must stay bounded over any drive —344 // an exit wave that never finishes would show up here.345 sceneNodes: countNodes(ctx.scene),346 // Animation lifecycle, for rule-based verification.347 spawningSegments: session.spawningCount,348 exitingSegments: session.exitingCount,349 atmosphere: session.atmosphere.name,350 weather: session.atmosphere.weatherKind,351 physics: session.physicsActive,352 railDebug: session.railDebugState,353 railDebugSummary: session.railDebugSummary,354 deaths: session.deaths,355 // Peer ring lifecycle (marble ring): live count + relative arcs.356 peers: session.peerRing.liveCount,357 peerRing: session.peerRing.stats(session.marbleS),358 milestone: session.toast.last,359 place: session.placeHud.last,360 photos: photo.count,361 audio: audio.enabled,362 // Instancing gate: island pieces share draw calls now.363 drawCalls: ctx.renderer.info.render.calls,364 }365 : null,366 }));367}368369/** Place-readout refresh period (seconds) — 4Hz; the count is O(N). */370const PLACE_REFRESH = 0.25;371372/** Rubber-banding EMA time constant (seconds) — rank order flaps in a373 * tight pack; skill modulation follows the smoothed position. */374const RUBBER_TAU = 2;375376// Overtake commitment (M20a). OVERTAKE_OFF stays strictly below the377// autopilot's CORRIDOR_DEAD (0.3) so the corridor spring never fights378// the held line and the channel wall never enters play.379const OVERTAKE_OFF = 0.27;380const OTK_ENGAGE = 3.5;381const OTK_DISENGAGE = 5;382const OTK_SLEW_TAU = 0.4;383/** Rider draft cap (M20a): riders chained nose-to-tail must never384 * compound into a glide that outruns the hero-anchored stream. */385const RIDER_DRAFT_CAP = 1.08;386387// Rivalry expression (M20b).388/** Contact-proxy distance for the temper trigger (~2.5 marble radii). */389const CONTACT_NEAR = 0.25;390/** Defensive-block trigger range (world units, scaled by aggro and391 * grudge intensity — mirrors BOOST_DEFEND's shape). */392const BLOCK_RANGE = 4;393/** Blocking line offset — inside CORRIDOR_DEAD like the overtake. */394const BLOCK_OFF = 0.25;395/** Braid lane mirroring engages when the rival is this close (world u). */396const RIVAL_MIRROR_S = 10;397/** A marble-static knock this fast leaves a mark (M25 world memory). */398const SCAR_SPEED = 4.5;399400// --- physics LOD (pack-scale realtime) -------------------------------------401//402// N=9999 ran at ~0.27x realtime: the 10k-body Box3D step was the wall.403// Racers far from the camera subject demote to KINEMATIC RIDERS — no404// body, they glide along the rail at a personality-flavored cruise —405// and promote back to real bodies as the subject nears (spawned on the406// rail with a short ghost window so re-entry never detonates the pack).407// The hero and the camera subject are always real.408409/** LOD only engages above this pack size (small packs stay fully real). */410const LOD_ENGAGE_N = 300;411/** Max real bodies (the nearest-by-progress band around the subject). */412const LIVE_CAP = 240;413/** Progress window around the subject (world units): promote inside414 * PROMOTE, demote outside DEMOTE — the gap is the hysteresis. */415const LOD_PROMOTE_S = 42;416const LOD_DEMOTE_S = 55;417/** Re-evaluate the live set every this many fixed steps. */418const LOD_PERIOD = 30;419420/** Respawn spawn-in envelope (seconds): the visual eases ~0.1 → 1 instead421 * of popping (the initial grid avalanche keeps its pop — it reads fine). */422const SPAWN_IN_T = 0.4;423/** Respawn collision immunity (seconds): a fresh marble ghosts through424 * neighbors (track collisions stay real) until it is clear of the pack. */425const GHOST_T = 2;426/** Ghost hard cap (seconds) — a still-overlapped marble resolidifies here427 * regardless, so nobody ghosts forever. */428const GHOST_MAX = 4;429/** Resolidify clearance: no other racer within ~2.5 marble radii — expiry430 * while overlapped fires the violent depenetration pop the ghost exists431 * to avoid. */432const GHOST_CLEAR_D = MARBLE_RADIUS * 2.5;433434/** One physics racer (M15): the hero (idx 0) or a real peer marble. */435interface Racer {436 idx: number;437 pilot: Pilot;438 /** Effective pilot this decision (rubber-banding writes skill-modulated439 * bravery/eagerness here each decide; `pilot` is the base identity). */440 pilotEff: Pilot;441 /** Physics LOD: "live" = real Box3D body; "rider" = kinematic glide442 * along the rail (far from the camera subject at pack scale). */443 lod: "live" | "rider";444 /** Smoothed race-position fraction (0 = leading, 1 = last) — rank order445 * flaps in a tight pack, so the modulation follows a ~2s EMA. */446 rubber: number;447 mass: number;448 /** Scene objects — null for instanced peers (big packs render through449 * one InstancedMesh instead of per-racer groups). */450 obj: THREE.Object3D | null;451 pivot: THREE.Object3D | null;452 materials: THREE.Material[];453 nearest: { seg: number; idx: number };454 /** Braid lane (M17): when `laneBraid` names the racer's CURRENT braid455 * segment and `laneAlt` is set, its rail is that segment's alt rail —456 * the pack visibly splits and rejoins. Choice is per-racer determinism457 * (braidLaneAlt — each racer's own label-derived stream); collisions458 * that physically shove a marble onto the other lane flip it there459 * (decide()'s recovery law). */460 laneAlt: boolean;461 laneBraid: number;462 s: number;463 speed: number;464 simT: number;465 lastProgressS: number;466 stallClock: number;467 stallHopped: boolean;468 wasHop: boolean;469 /** Respawn spawn-in clock (seconds left; 0 = done) — physicsMove eases470 * the visual scale while it runs. */471 spawnAnim: number;472 /** Respawn ghost clock (seconds until eligible to resolidify; 0 =473 * solid). Runs negative toward the hard cap while still overlapped. */474 ghostT: number;475 /** Fuel-boost economy (competition round). */476 boost: BoostState;477 wasBoosting: boolean;478 /** Last force step's introspection — headroom feeds the boost law. */479 dbg: PilotDebug;480 /** Persistent state scratch (filled from the batched readback each481 * step — no per-step allocation at pack scale). */482 pos: [number, number, number];483 vel: [number, number, number];484 quat: [number, number, number, number];485 /** Cached control force between decision steps (large packs decimate486 * the DECISION rate round-robin; the force still applies every step). */487 cachedF: [number, number, number] | null;488 /** Ember phenotype baseline — boost-glow flameout restores it. */489 baseEmissive: THREE.Color | null;490 /** Draft multiplier this decision (M20a) — 1 outside a wake. Reset on491 * LOD transitions and respawns (state-clean replays). */492 draft: number;493 /** Overtake-commitment blend 0..1 (M20a): eases the offset between the494 * wander line and the chirality passing line (slew kills offset495 * flapping against separation steering). */496 otkBlend: number;497 /** Progress rank this step (index into the s-sorted pack) — written in498 * the sort pass; riders read their leader through it. */499 rank: number;500 /** Last ice-scuff sim time (M25 world memory rate limit). */501 lastScuffT: number;502 /** Seeded directed relationship (M20b) — resolved once at pack build. */503 rival: Rival | null;504 /** Post-contact temper window remaining (seconds; 0 = cool). */505 temperClock: number;506 /** Defensive-block blend 0..1 (M20b) — same slew law as otkBlend;507 * overtake > block > wander on the single offset actuator. */508 blockBlend: number;509}510511type RailDebugReason = "lod-distance" | "fall" | "stall" | "stream-cull";512513interface RailDebugTile {514 segment: number;515 station: number;516 window: number | null;517 placementIndex: number | null;518 pieceId: string | null;519 family: string | null;520 zone: string | null;521 alt: boolean;522 part: number | null;523}524525interface RailDebugEvent {526 kind: "rail-handoff" | "recovery";527 reason: RailDebugReason;528 tileCandidate: boolean;529 count: number;530 seed: string;531 racer: number;532 s: number;533 from: RailDebugTile;534 to?: RailDebugTile;535}536537interface RailDebugPieceStat {538 pieceId: string;539 family: string | null;540 events: number;541 falls: number;542 stalls: number;543 generatedWindows: number;544}545546interface RailDebugSummary {547 candidateEvents: number;548 generatedWindows: number;549 byPiece: RailDebugPieceStat[];550}551552/** Reusable neighbor slots (separation steering) — mutated in place. */553interface NeighborSlot {554 pos: [number, number, number];555 bravery: number;556}557558class Session {559 readonly world: EndlessWorld;560 readonly seed: string;561 marbleS = 0;562 speed = BASE_SPEED;563 islandsSeen = 0;564 currentIndex = 0;565 readonly marbleObj: THREE.Group;566567 private readonly root = new THREE.Group();568 private readonly cameraLocal = new THREE.Vector3();569 private readonly built = new Map<number, BuiltSegment>();570 /** Segments assembling in (index → wave) and sinking out. */571 private readonly spawning = new Map<number, SpawnAnim>();572 private exiting: {573 group: THREE.Group;574 anim: ExitAnim;575 disposables: InstancedBuild[];576 }[] = [];577 /** The session's first fill builds instantly — the opening view should578 * exist, not assemble around the spawning marble. */579 private initialBuild = true;580 /** Cumulative curve arc lengths, kept for every index ever built. */581 private readonly sMeta = new Map<number, { start: number; len: number }>();582 private readonly pivot: THREE.Group;583 private radius = 0.1;584 private statsClock = 0;585586 readonly atmosphere: Atmosphere;587 /** The revolving ring of seeded NPC marbles around the player. */588 readonly peerRing: PeerRing;589 /** Distance milestone toast (thresholds pure in hud.ts). */590 readonly toast = new MilestoneToast();591 /** Minimal leaderboard: `#place/N` (counting pure in hud.ts). */592 readonly placeHud = new PlaceHud();593 /** Place refresh throttle — the O(N) count runs at 4Hz, not 60. */594 private placeClock = PLACE_REFRESH;595 /** Box3D driver (null → kinematic rail rider, ?phys=0). */596 private engine: PhysicsEngine | null = null;597 /** The per-step control law: the autopilot (M14a) unless ?autopilot=0598 * falls back to the old assist choreographer for A/B comparison. */599 private readonly useAutopilot =600 new URLSearchParams(location.search).get("autopilot") !== "0";601 /** The racers (M15): index 0 is the hero (existing marble visuals +602 * checkpoint escalation); the rest are real peer marbles with their own603 * pilots, respawning behind the pack tail. Empty in kinematic mode. */604 private racers: Racer[] = [];605 /** Real racer-peer count (0 outside physics-autopilot mode). */606 private realPeerCount = 0;607 /** Decorative-ring size when real racers are off. */608 private ringPeerCount: number | undefined = undefined;609 /** Leader-cam (M15): OFF by default — the camera follows the hero (who610 * carries the trail); SPACE elects chasing the race leader instead. */611 private leaderCam = false;612 /** The pivot the trail rides — whichever marble the camera follows. */613 private followedPivot: THREE.Object3D;614 /** Racer idx the camera currently follows (leader-cam hysteresis). */615 private followedIdx = 0;616 /** Progress-sorted racer order (rebuilt per fixed step, reused). */617 private readonly sorted: Racer[] = [];618 /** Separation-partner scratch (reused per decision). */619 private readonly othersScratch: Neighbor[] = [];620 private stepCounter = 0;621 /** Instanced peer rendering (packs > INSTANCED_AT): opaque core mesh622 * plus an additive fresnel glass shell mesh. */623 private peerMesh: THREE.InstancedMesh | null = null;624 private peerShellMesh: THREE.InstancedMesh | null = null;625 private readonly peerColors: THREE.Color[] = [];626 /** Camera/trail stand-in when the followed racer is instanced. */627 private readonly instProxy = new THREE.Object3D();628 private readonly instMatrix = new THREE.Matrix4();629 private readonly instPos = new THREE.Vector3();630 private readonly instQuat = new THREE.Quaternion();631 private readonly instScale = new THREE.Vector3(1, 1, 1);632 private readonly rails = new Map<number, Rail>();633 /** ?debug=1 provenance for every physics→rail/recovery handoff. Falls634 * and stalls are tile candidates; distance LOD and stream culling are635 * called out separately so optimization does not masquerade as bad flow. */636 private readonly railDebug =637 new URLSearchParams(location.search).get("debug") === "1";638 private readonly railDebugCounts = new Map<string, number>();639 private readonly railDebugRecent: RailDebugEvent[] = [];640 private railDebugCandidateEvents = 0;641 private readonly railDebugPieces = new Map<string, RailDebugPieceStat>();642 private railDebugGeneratedWindows = 0;643 /** Braid alt-lane rails (M17): built alongside `rails` for every644 * segment carrying an alt branch; per-racer lane choice picks between645 * them on braid segments (railFor). */646 private readonly altRails = new Map<number, Rail>();647 /** Built segment indices hosting a braid — the only segments where648 * per-racer lane choice engages (diamonds/spurs keep branchOf). */649 private readonly braidSegs = new Set<number>();650 /** Fan wind volumes, binned per segment (M16) — fans push marbles. */651 private readonly fanField = new FanField();652 /** V3: continuation segment → its fork segment (lane-label carrier). */653 private readonly braidContOf = new Map<number, number>();654 /** Accumulated sim time for pulsed wind (M19a gust walls). */655 private windT = 0;656 /** Respawn timestamps inside the trailing 2s (M20a pile-up telemetry). */657 private readonly respawnTimes: number[] = [];658 /** Racers hit (marble-marble, above threshold) since their last decide659 * — the temper trigger's REAL contact source (contact-events round). */660 private readonly hitFlags = new Set<number>();661 /** Hard static knocks awaiting world-memory marks (M25). */662 private readonly pendingScars: {663 x: number;664 y: number;665 z: number;666 speed: number;667 idx: number;668 }[] = [];669 /** M25 world memory: shared scar geometry/material (never disposed per670 * mark) + per-segment mark counts + furniture mid-tip animations. */671 private scarGeo: THREE.SphereGeometry | null = null;672 private scarMat: THREE.MeshStandardMaterial | null = null;673 private scuffMat: THREE.MeshStandardMaterial | null = null;674 private readonly scarCount = new Map<number, number>();675 private readonly tipping: {676 obj: THREE.Object3D;677 axis: THREE.Vector3;678 target: number;679 t: number;680 }[] = [];681 /** Grudge-gated boost ignitions (M20b declustering telemetry). */682 private rivalIgnitions = 0;683 /** Rival HUD token flag (M20b, ?rival=1) — default off until review. */684 private readonly rivalHud =685 new URLSearchParams(location.search).get("rival") === "1";686 /** Scratch for the per-decision wind force (allocation-free). */687 private readonly windScratch: [number, number, number] = [0, 0, 0];688 /** M16 conjugation scratch (reoriented segments): marble state maps689 * into plan space for the control laws, forces map back out. */690 private readonly planPos: [number, number, number] = [0, 0, 0];691 private readonly planVel: [number, number, number] = [0, 0, 0];692 private readonly planF: [number, number, number] = [0, 0, 0];693 private readonly frameScratch: [number, number, number] = [0, 0, 0];694 /** Pooled neighbor slots (separation partners conjugate per-racer). */695 private readonly neighborPool: {696 pos: [number, number, number];697 bravery: number;698 }[] = Array.from({ length: 4 }, () => ({699 pos: [0, 0, 0] as [number, number, number],700 bravery: 1,701 }));702 /** Local up of the camera subject (follow-cam rolls with the frame);703 * target + eased value — one-frame frame flips must never slam it. */704 private readonly camUp: [number, number, number] = [0, 1, 0];705 private readonly camUpTarget: [number, number, number] = [0, 1, 0];706 /** Active diamond branch per segment — respawns alternate it, so a707 * fall's retry may ride the OTHER branch (respawn variety). */708 private readonly branchOf = new Map<number, "main" | "alt">();709 /** Pipe color per island chapter (drawn once, cached). */710 private readonly chapterColors = new Map<711 number,712 readonly [number, number, number] | null713 >();714715 private chapterColor(716 chapter: number,717 ): readonly [number, number, number] | null {718 let c = this.chapterColors.get(chapter);719 if (c === undefined) {720 c = drawPipeColor(this.seed, chapter);721 this.chapterColors.set(chapter, c);722 }723 return c;724 }725 private nearest: { seg: number; idx: number } = { seg: 0, idx: 0 };726 deaths = 0;727 /** Stall net: no rail progress for a while → respawn. Progress must728 * move forward NO MATTER WHAT (user rule) — falls and wedges alike. */729 private stallClock = 0;730 private lastProgressS = 0;731 /** Escalation: repeated deaths at one checkpoint skip forward past the732 * problem geometry (each such spot is a future named scenario). */733 private respawnsHere = 0;734 private lastRespawnSeg = -1;735 private zone = 0;736 /** Audio cue edge detectors (launch/land/boost transitions). */737 private wasFlight = false;738 private wasSurge = false;739 /** One escape hop per stall episode (M14b): a parked marble tries a740 * jump before the respawn net takes it. */741 private stallHopped = false;742 private focus: { center: THREE.Vector3; radius: number } | null = null;743744 private readonly trail = createTrail();745 private readonly boostTrail = createTrail(0xff5a24, {746 maxPoints: 28,747 headAlpha: 1,748 additive: true,749 renderOrder: 6,750 });751 private boostTrailFade = 0;752 private boostTrailActive = false;753 private readonly trailPoint = new THREE.Vector3();754 private readonly point = new THREE.Vector3();755 private readonly tangent = new THREE.Vector3();756 private readonly axis = new THREE.Vector3();757 private readonly spin = new THREE.Quaternion();758 private readonly up = new THREE.Vector3(0, 1, 0);759760 constructor(761 seed: string,762 private readonly ctx: SceneCtx,763 private readonly assets: Assets,764 catalog: DecorCatalog,765 private readonly cams: Cameras,766 private readonly ui: UiRefs,767 stars: THREE.Points,768 physics: PhysicsModule | null = null,769 private readonly audio: AmbientAudio = new AmbientAudio(),770 ) {771 this.seed = seed;772 if (physics) this.engine = new PhysicsEngine(physics);773 this.world = new EndlessWorld(seed, catalog);774 // Base palette stays stock — pipe color is PER ISLAND CHAPTER (user775 // rule), applied per placement at build time.776 assets.applyPalette(this.world.style.paletteIndex);777 this.root.name = `endless:${seed}`;778 ctx.scene.add(this.root);779 this.atmosphere = new Atmosphere(780 ctx,781 stars,782 drawZone(seed, 0, this.zoneBiome(0)),783 );784785 // Session peer sizing first — the hero's shell material depends on it786 // (transmission glass only below pack scale; see cloneOne).787 {788 const peersParam = new URLSearchParams(location.search).get("peers");789 const peerCount = peersParam === null790 ? undefined791 : Math.max(0, Math.min(9998, Number.parseInt(peersParam, 10) || 0));792 const realRace = this.engine !== null && this.useAutopilot;793 this.realPeerCount = realRace ? Math.min(9998, peerCount ?? 5) : 0;794 this.ringPeerCount = realRace ? 0 : peerCount;795 }796797 // Marble (same grounded-mesh-in-pivot construction as the loop mode).798 this.marbleObj = new THREE.Group();799 this.marbleObj.name = "marble";800 this.pivot = new THREE.Group();801 this.marbleObj.add(this.pivot);802 const mesh = assets.getPiece(MARBLE_PIECE);803 const heroMaterials: THREE.Material[] = [];804 if (mesh) {805 // Half the kit's authored size (user: "shrink the ball 50%") —806 // matches the physics MARBLE_RADIUS.807 mesh.scale.setScalar(MARBLE_SCALE);808 // The kit shell is empty glass — inject the opaque core the glass809 // refracts (hero: soft white) so the marble reads SOLID, not as a810 // washed-out bubble (user report). Added before the material pass811 // so boost glow reaches it.812 addMarbleCore(mesh);813 // Own materials (boost glow writes emissive; shared kit materials814 // would light every piece using them).815 mesh.traverse((child: THREE.Object3D) => {816 const m = child as THREE.Mesh;817 if (!m.isMesh) return;818 const cloneOne = (mat: THREE.Material): THREE.Material => {819 // Hero shell: clear real glass (neutral phenotype); stable820 // transparent order (see buildRacerVisual). At PACK scale the821 // transmission buffer (opaques only) erases the hundreds of822 // transparent-shelled peers behind the hero — glitch city — so823 // the hero drops to plain alpha glass there.824 if (mat.name === "glass" && physics) {825 m.renderOrder = 10;826 if (this.realPeerCount > INSTANCED_AT) {827 const g = new THREE.MeshPhysicalMaterial({828 color: 0xe8f2fc,829 transparent: true,830 opacity: 0.3,831 roughness: 0.05,832 metalness: 0,833 // Same one-layer, half-strength reflection law as the834 // transmission shell (see glassShellMaterial) — a hot env plus835 // a stacked clearcoat washed the marble to a pale blue.836 envMap: marbleEnv(ctx.renderer),837 envMapIntensity: 0.5,838 depthWrite: false,839 });840 heroMaterials.push(g);841 return g;842 }843 const g = glassShellMaterial(ctx.renderer, {844 meshId: MARBLE_PIECE,845 tint: null,846 shell: [1, 1, 1],847 effect: { kind: "none", intensity: 0 },848 });849 heroMaterials.push(g);850 return g;851 }852 const clone = mat.clone();853 heroMaterials.push(clone);854 return clone;855 };856 m.material = Array.isArray(m.material)857 ? m.material.map(cloneOne)858 : cloneOne(m.material);859 });860 const box = new THREE.Box3().setFromObject(mesh);861 const center = box.getCenter(new THREE.Vector3());862 const size = box.getSize(new THREE.Vector3());863 this.radius = Math.max(size.x, size.y, size.z) / 2 || 0.1;864 this.pivot.position.copy(center);865 mesh.position.sub(center);866 this.pivot.add(mesh);867 }868 this.trail.setWidth(this.radius * 0.6);869 this.boostTrail.setWidth(this.radius * 1.5);870 this.boostTrail.obj.name = "boost-trail";871 this.followedPivot = this.pivot;872 ctx.scene.add(this.marbleObj);873 ctx.scene.add(this.trail.obj);874 ctx.scene.add(this.boostTrail.obj);875 // Decorative ring only outside physics-autopilot mode (real racers876 // replace it there) — sizing computed above, before the hero mesh.877 this.peerRing = new PeerRing(seed, assets, ctx.scene, this.ringPeerCount);878879 this.ensure();880 this.initialBuild = false;881 const first = this.built.get(0)!;882 first.curve.getPointAt(0, this.point);883 this.marbleObj.position.copy(this.point);884 if (this.engine) {885 const rail = this.rails.get(0)!;886 const t = rail.tans[0];887 this.engine.spawnMarble(888 0,889 [rail.pts[0][0], rail.pts[0][1] + 0.4, rail.pts[0][2]],890 [t[0] * 2.2, 0, t[2] * 2.2],891 );892 this.racers.push({893 idx: 0,894 pilot: pilotFor(seed, 0),895 pilotEff: pilotFor(seed, 0),896 lod: "live",897 rubber: 0.5,898 mass: this.engine.massOf(0),899 obj: this.marbleObj,900 pivot: this.pivot,901 materials: heroMaterials,902 nearest: { seg: 0, idx: 0 },903 laneAlt: false,904 laneBraid: -1,905 s: 0,906 speed: 0,907 simT: 0,908 lastProgressS: 0,909 stallClock: 0,910 stallHopped: false,911 wasHop: false,912 spawnAnim: 0,913 ghostT: 0,914 boost: { ...FULL_TANK },915 wasBoosting: false,916 dbg: {},917 pos: [0, 0, 0],918 vel: [0, 0, 0],919 quat: [0, 0, 0, 1],920 cachedF: null,921 baseEmissive: null,922 draft: 1,923 otkBlend: 0,924 rank: 0,925 rival: null,926 temperClock: 0,927 blockBlend: 0,928 lastScuffT: 0,929 });930 // Racer peers (M15): real bodies on a wrapped grid start —931 // stations × 3 lanes, then stacked tiers above (a huge pack rains932 // in as a marble avalanche). Kit meshes up to INSTANCED_AT peers;933 // one InstancedMesh beyond.934 const instanced = this.realPeerCount > INSTANCED_AT;935 if (instanced) {936 // Opaque colored core inside an additive fresnel glass rim (the937 // instanced glass illusion — order-independent, sorts never).938 const geo = new THREE.SphereGeometry(MARBLE_RADIUS * 0.82, 12, 8);939 const mat = new THREE.MeshStandardMaterial({940 roughness: 0.35,941 metalness: 0.05,942 });943 this.peerMesh = new THREE.InstancedMesh(geo, mat, this.realPeerCount);944 this.peerMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);945 this.peerMesh.frustumCulled = false;946 ctx.scene.add(this.peerMesh);947 const shellGeo = new THREE.SphereGeometry(MARBLE_RADIUS, 12, 8);948 this.peerShellMesh = new THREE.InstancedMesh(949 shellGeo,950 fresnelShellMaterial(),951 this.realPeerCount,952 );953 this.peerShellMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);954 this.peerShellMesh.frustumCulled = false;955 ctx.scene.add(this.peerShellMesh);956 }957 const span = Math.max(2, Math.min(rail.pts.length - 2, 120));958 const LANES = [-0.26, 0, 0.26];959 // Grid slots for hero + peers: the hero draws a MID-PACK slot from960 // the designed distribution (uniform over the 20th–80th percentile,961 // label rng — user rule: not first, not last, seed-varied); peers962 // fill the remaining slots in order.963 const totalSlots = this.realPeerCount + 1;964 const heroSlot = Math.min(965 totalSlots - 1,966 Math.floor(967 (0.2 + rngFamily(seed, "grid/hero").float() * 0.6) * totalSlots,968 ),969 );970 const slotPos = (971 k: number,972 ): { at: number; lane: number; tier: number } => ({973 at: 1 + (k % span),974 lane: LANES[k % LANES.length],975 tier: Math.floor(k / (span * LANES.length)),976 });977 {978 const hs = slotPos(heroSlot);979 const tt = rail.tans[hs.at];980 const nl = Math.hypot(tt[2], tt[0]) || 1;981 this.engine.spawnMarble(0, [982 rail.pts[hs.at][0] + (tt[2] / nl) * hs.lane,983 rail.pts[hs.at][1] + 0.4 + hs.tier * 0.35,984 rail.pts[hs.at][2] + (-tt[0] / nl) * hs.lane,985 ], [tt[0] * 2.2, 0, tt[2] * 2.2]);986 const hero = this.racers[0];987 hero.nearest = { seg: 0, idx: hs.at };988 hero.s = rail.s[hs.at];989 hero.lastProgressS = hero.s;990 this.marbleS = hero.s;991 }992 for (let i = 1; i <= this.realPeerCount; i++) {993 const k = i - 1 < heroSlot ? i - 1 : i;994 const { at, lane, tier } = slotPos(k);995 const tt = rail.tans[at];996 const nl = Math.hypot(tt[2], tt[0]) || 1;997 const gridPos: [number, number, number] = [998 rail.pts[at][0] + (tt[2] / nl) * lane,999 rail.pts[at][1] + 0.4 + tier * 0.35,1000 rail.pts[at][2] + (-tt[0] / nl) * lane,1001 ];1002 const gridVel: [number, number, number] = [1003 tt[0] * 2.2,1004 0,1005 tt[2] * 2.2,1006 ];1007 const gf = railFrameAt(rail, at);1008 if (gf) {1009 affineApply(gf, gridPos, gridPos);1010 quatRotate(gf.q, gridVel, gridVel);1011 }1012 this.engine.spawnMarble(i, gridPos, gridVel);1013 let obj: THREE.Group | null = null;1014 let pivot: THREE.Group | null = null;1015 let materials: THREE.Material[] = [];1016 let baseEmissive: THREE.Color | null = null;1017 if (!instanced) {1018 ({ obj, pivot, materials, baseEmissive } = this.buildRacerVisual(i));1019 } else {1020 // Instanced path: the phenotype folds into the instance color1021 // (one material — shell multiplies, effects approximate).1022 const identity = racerIdentity(seed, i);1023 const color = identity.tint1024 ? new THREE.Color(1025 identity.tint[0] / 255,1026 identity.tint[1] / 255,1027 identity.tint[2] / 255,1028 )1029 : new THREE.Color(0xdfe8f2);1030 color.multiply(1031 new THREE.Color(1032 identity.shell[0],1033 identity.shell[1],1034 identity.shell[2],1035 ),1036 );1037 const ei = identity.effect.intensity;1038 if (identity.effect.kind === "ember") {1039 color.multiplyScalar(1 + 0.5 * ei);1040 } else if (identity.effect.kind === "pearl") {1041 color.lerp(new THREE.Color(1, 1, 1), 0.3 * ei);1042 } else if (identity.effect.kind === "ghost") {1043 color.multiplyScalar(0.55);1044 }1045 this.peerColors.push(color);1046 this.peerMesh!.setColorAt(i - 1, color);1047 // Shell rim tint: the phenotype shell (near-white = clear).1048 this.peerShellMesh!.setColorAt(1049 i - 1,1050 new THREE.Color(1051 identity.shell[0],1052 identity.shell[1],1053 identity.shell[2],1054 ),1055 );1056 }1057 this.racers.push({1058 idx: i,1059 pilot: pilotFor(seed, i),1060 pilotEff: pilotFor(seed, i),1061 lod: "live",1062 rubber: 0.5,1063 mass: this.engine.massOf(i),1064 obj,1065 pivot,1066 materials,1067 nearest: { seg: 0, idx: at },1068 laneAlt: false,1069 laneBraid: -1,1070 s: rail.s[at],1071 speed: 0,1072 simT: 0,1073 lastProgressS: rail.s[at],1074 stallClock: 0,1075 stallHopped: false,1076 wasHop: false,1077 spawnAnim: 0,1078 ghostT: 0,1079 boost: { ...FULL_TANK },1080 wasBoosting: false,1081 dbg: {},1082 pos: [0, 0, 0],1083 vel: [0, 0, 0],1084 quat: [0, 0, 0, 1],1085 cachedF: null,1086 baseEmissive,1087 draft: 1,1088 otkBlend: 0,1089 rank: 0,1090 rival: null,1091 temperClock: 0,1092 blockBlend: 0,1093 lastScuffT: 0,1094 });1095 }1096 if (this.peerMesh?.instanceColor) {1097 this.peerMesh.instanceColor.needsUpdate = true;1098 }1099 if (this.peerShellMesh?.instanceColor) {1100 this.peerShellMesh.instanceColor.needsUpdate = true;1101 }1102 }11031104 // Rivalries (M20b): resolved once the pack size is final. Directed,1105 // label-pure per racer — n enters only at the target mod.1106 for (const r of this.racers) {1107 r.rival = rivalFor(seed, r.idx, this.racers.length);1108 }11091110 this.cameraProxy = {1111 obj: this.marbleObj,1112 s: this.marbleS,1113 speed: this.speed,1114 radius: this.radius,1115 update: () => {},1116 };1117 this.cams.retargetPath(1118 (s, out) => this.samplePoint(s, out),1119 this.cameraProxy,1120 );1121 if (this.ui.seedEl) this.ui.seedEl.textContent = seed;1122 this.refreshStats();1123 }11241125 /** Race telemetry for CUA/debug: per-racer progress + the leader. */1126 raceStats(): {1127 n: number;1128 leader: number;1129 leaderCam: boolean;1130 respawnBurst: number;1131 spreadP10P90: number;1132 rival: { target: number; valence: string } | null;1133 rivalIgnitions: number;1134 maxTrain: number;1135 trains: number;1136 /** World-memory marks currently standing (M25). */1137 marks: number;1138 racers: {1139 idx: number;1140 s: number;1141 speed: number;1142 fuel: number;1143 boosting: boolean;1144 draft: number;1145 }[];1146 } | null {1147 if (this.racers.length === 0) return null;1148 let leader = this.racers[0];1149 for (const r of this.racers) if (r.s > leader.s) leader = r;1150 const top = [...this.racers].sort((a, b) => b.s - a.s).slice(0, 12);1151 // Pack-spread percentile gap (M20a telemetry): the liveliness-law1152 // tripwire — a draft compression wave would collapse it.1153 const byS = [...this.racers].sort((a, b) => a.s - b.s);1154 // Draft trains (M22): maximal chains of s-consecutive racers whose1155 // followers are all in a wake this decision.1156 let maxTrain = 1;1157 let trains = 0;1158 let run = 1;1159 for (let k = 1; k < byS.length; k++) {1160 if (byS[k].draft > 1 || byS[k - 1].draft > 1) run++;1161 else {1162 if (run >= 3) trains++;1163 maxTrain = Math.max(maxTrain, run);1164 run = 1;1165 }1166 }1167 if (run >= 3) trains++;1168 maxTrain = Math.max(maxTrain, run);1169 const p10 = byS[Math.floor((byS.length - 1) * 0.1)].s;1170 const p90 = byS[Math.floor((byS.length - 1) * 0.9)].s;1171 const heroRival = this.racers[0]?.rival ?? null;1172 return {1173 n: this.racers.length,1174 leader: leader.idx,1175 leaderCam: this.leaderCam,1176 respawnBurst: this.respawnTimes.filter((t) => t > this.windT - 2).length,1177 spreadP10P90: Number((p90 - p10).toFixed(1)),1178 rival: heroRival1179 ? { target: heroRival.target, valence: heroRival.valence }1180 : null,1181 rivalIgnitions: this.rivalIgnitions,1182 maxTrain,1183 trains,1184 marks: [...this.scarCount.values()].reduce((a, b) => a + b, 0),1185 racers: top.map((r) => ({1186 idx: r.idx,1187 s: Number(r.s.toFixed(1)),1188 speed: Number(r.speed.toFixed(2)),1189 fuel: Number(r.boost.fuel.toFixed(2)),1190 boosting: r.boost.boosting,1191 draft: Number(r.draft.toFixed(3)),1192 })),1193 };1194 }11951196 builtRange(): [number, number] {1197 const keys = [...this.built.keys()];1198 return [Math.min(...keys), Math.max(...keys)];1199 }12001201 get spawningCount(): number {1202 return this.spawning.size;1203 }12041205 get exitingCount(): number {1206 return this.exiting.length;1207 }12081209 get physicsActive(): boolean {1210 return this.engine !== null;1211 }12121213 get railDebugState(): readonly RailDebugEvent[] | null {1214 return this.railDebug ? this.railDebugRecent : null;1215 }12161217 get railDebugSummary(): RailDebugSummary | null {1218 if (!this.railDebug) return null;1219 return {1220 candidateEvents: this.railDebugCandidateEvents,1221 generatedWindows: this.railDebugGeneratedWindows,1222 byPiece: [...this.railDebugPieces.values()].sort((a, b) =>1223 b.events - a.events || a.pieceId.localeCompare(b.pieceId)1224 ),1225 };1226 }12271228 /** Apply a built segment's presentation frame to a plan-space sample1229 * (camera path, trail, decorative riders live in world space). */1230 private presentSample(1231 seg: BuiltSegment,1232 pos: THREE.Vector3,1233 tan: THREE.Vector3 | null,1234 ): void {1235 const fr = seg.frame;1236 if (!fr) return;1237 this.frameScratch[0] = pos.x;1238 this.frameScratch[1] = pos.y;1239 this.frameScratch[2] = pos.z;1240 const f = fr.twist1241 ? segmentFrameAt(fr, twistParam(fr, this.frameScratch))1242 : fr.entry;1243 affineApply(f, this.frameScratch, this.frameScratch);1244 pos.set(this.frameScratch[0], this.frameScratch[1], this.frameScratch[2]);1245 if (tan) {1246 this.frameScratch[0] = tan.x;1247 this.frameScratch[1] = tan.y;1248 this.frameScratch[2] = tan.z;1249 quatRotate(f.q, this.frameScratch, this.frameScratch);1250 tan.set(1251 this.frameScratch[0],1252 this.frameScratch[1],1253 this.frameScratch[2],1254 );1255 }1256 }12571258 /** World point at global arc length s (clamped to the built window). */1259 samplePoint(s: number, out: THREE.Vector3): boolean {1260 const seg = this.segmentAt(s);1261 if (!seg) return false;1262 const u = Math.min(1263 1,1264 Math.max(0, (s - seg.startS) / seg.length),1265 );1266 seg.curve.getPointAt(u, out);1267 this.presentSample(seg, out, null);1268 return true;1269 }12701271 /** Point + tangent at global arc length s (peer riders). */1272 sampleFrame(s: number, pos: THREE.Vector3, tan: THREE.Vector3): boolean {1273 const seg = this.segmentAt(s);1274 if (!seg) return false;1275 const u = Math.min(1, Math.max(0, (s - seg.startS) / seg.length));1276 seg.curve.getPointAt(u, pos);1277 seg.curve.getTangentAt(u, tan);1278 this.presentSample(seg, pos, tan);1279 return true;1280 }12811282 /** Local up of the camera subject (M16 — the follow cam rolls with1283 * the subject's gravity frame). */1284 cameraUp(): readonly [number, number, number] {1285 return this.camUp;1286 }12871288 /** Solid channel pieces plus island MASS meshes of the built window.1289 * Supports/vegetation/decor stay out: a crossing tree frond or thin1290 * scaffold should not yank the follow boom. */1291 cameraOccluders(): THREE.Object3D[] {1292 const out: THREE.Object3D[] = [];1293 for (const seg of this.built.values()) {1294 out.push(...seg.cameraOccluders);1295 for (const d of seg.disposables) {1296 for (const m of d.meshes) {1297 if (m.userData.occluder) out.push(m);1298 }1299 }1300 }1301 return out;1302 }13031304 /** A tunnel is useful context until its shell contains the lens. Fade1305 * only that local shell; the track, marbles and next opening stay visible. */1306 fadeCameraTunnels(dt: number): void {1307 const a = 1 - Math.exp(-12 * Math.max(dt, 1e-4));1308 for (const seg of this.built.values()) {1309 for (const mesh of seg.cameraFadeMeshes) {1310 const box = mesh.geometry.boundingBox;1311 if (!box) continue;1312 this.cameraLocal.copy(this.ctx.camera.position);1313 mesh.worldToLocal(this.cameraLocal);1314 const inside = box.containsPoint(this.cameraLocal);1315 const materials = Array.isArray(mesh.material)1316 ? mesh.material1317 : [mesh.material];1318 for (const material of materials) {1319 const base = mesh.userData.cameraBaseOpacity as number;1320 const target = inside ? base * 0.06 : base;1321 material.opacity += (target - material.opacity) * a;1322 }1323 }1324 }1325 }13261327 /** Arc-length range currently sampleable (the built window). */1328 private builtBand(): [number, number] {1329 let lo = Infinity;1330 let hi = -Infinity;1331 for (const seg of this.built.values()) {1332 lo = Math.min(lo, seg.startS);1333 hi = Math.max(hi, seg.startS + seg.length);1334 }1335 return lo <= hi ? [lo, hi] : [0, 0];1336 }13371338 private segmentAt(s: number): BuiltSegment | null {1339 let best: BuiltSegment | null = null;1340 for (const seg of this.built.values()) {1341 if (s >= seg.startS - 1e-9 && s <= seg.startS + seg.length + 1e-9) {1342 return seg;1343 }1344 // Clamp: remember the nearest built segment for out-of-window asks.1345 if (!best || Math.abs(s - seg.startS) < Math.abs(s - best.startS)) {1346 best = seg;1347 }1348 }1349 return best;1350 }13511352 private build(i: number): BuiltSegment {1353 const data = this.world.get(i);1354 this.recordRailDebugExposure(data);1355 const fr = data.frame;1356 const group = new THREE.Group();1357 group.name = `segment:${i}`;1358 // Gravity reorientation (M16): all segment data is PLAN-space; the1359 // scene groups carry the presentation frame. A twist splits the1360 // segment into an entry-framed group and an exit-framed group (the1361 // run's own kit pieces were dropped — the corkscrew chute below is1362 // the run's geometry).1363 let postGroup: THREE.Group | null = null;1364 if (fr) {1365 group.quaternion.set(...fr.entry.q);1366 group.position.set(...fr.entry.t);1367 if (fr.twist) {1368 postGroup = new THREE.Group();1369 postGroup.name = `segment:${i}:post`;1370 postGroup.quaternion.set(...fr.exit.q);1371 postGroup.position.set(...fr.exit.t);1372 }1373 }1374 const groupFor = (pos: readonly [number, number, number]): THREE.Group =>1375 postGroup && fr!.twist && twistParam(fr!, pos) > fr!.twist.len / 21376 ? postGroup1377 : group;1378 const spinners: Spinner[] = [];1379 const missing = new Set<string>();1380 const cameraOccluders: THREE.Object3D[] = [];1381 const cameraFadeMeshes: THREE.Mesh[] = [];1382 const pipeColorOf = (p: Placement) =>1383 // Zone surfaces READ as their own pipe color regardless of1384 // chapter color (icy = ice-pale, gravity = violet-charged).1385 p.zone === "icy"1386 ? ICE_PIPE1387 : p.zone === "gravity"1388 ? GRAVITY_PIPE1389 : p.zone === "boost"1390 ? BOOST_PIPE1391 : p.chapter === undefined1392 ? null1393 : this.chapterColor(p.chapter);1394 if (postGroup) {1395 instantiatePlacements(1396 data.placements.filter((p) => groupFor(p.pos) === group),1397 this.assets,1398 group,1399 spinners,1400 missing,1401 pipeColorOf,1402 );1403 instantiatePlacements(1404 data.placements.filter((p) => groupFor(p.pos) === postGroup),1405 this.assets,1406 postGroup,1407 spinners,1408 missing,1409 pipeColorOf,1410 );1411 } else {1412 instantiatePlacements(1413 data.placements,1414 this.assets,1415 group,1416 spinners,1417 missing,1418 pipeColorOf,1419 );1420 }1421 // Islands render INSTANCED with per-instance color variation (strata,1422 // moss/rust accents, per-island foliage lean) — islandRender.ts. An1423 // island lives wholly on one side of a twist (the run cells are1424 // exclusively claimed), so its whole build lands in one group.1425 const disposables = data.islands.map((isle) =>1426 buildIslandInstanced(1427 isle,1428 this.assets,1429 isle.placements.length > 01430 ? groupFor(isle.placements[0].pos as [number, number, number])1431 : group,1432 missing,1433 )1434 );1435 // Floating void decor (M18b): instanced, flat in the frame-correct1436 // group (the waves see direct children), physics/occlusion/shadow1437 // free. Split across twist halves per anchor like placements.1438 const floats: FloatBuild[] = [];1439 const voidByGroup = new Map<THREE.Group, Placement[]>();1440 for (const p of data.voidDecor) {1441 const g = groupFor(p.pos as [number, number, number]);1442 voidByGroup.set(g, [...(voidByGroup.get(g) ?? []), p]);1443 }1444 for (const [g, list] of voidByGroup) {1445 const fb = buildVoidDecor(list, this.assets, g, missing);1446 if (fb) floats.push(fb);1447 }1448 // Tunnel render variants (glass / wire) — position-hashed, visual1449 // only, deterministic per seed.1450 const entryTunnels = applyTunnelVariants(group, this.ctx.renderer);1451 disposables.push(entryTunnels);1452 cameraFadeMeshes.push(...entryTunnels.cameraFadeMeshes);1453 if (postGroup) {1454 const exitTunnels = applyTunnelVariants(postGroup, this.ctx.renderer);1455 disposables.push(exitTunnels);1456 cameraFadeMeshes.push(...exitTunnels.cameraFadeMeshes);1457 }1458 for (const id of missing) {1459 console.warn(`[endless] unknown pieceId "${id}" — skipped`);1460 }1461 this.root.add(group);1462 if (postGroup) this.root.add(postGroup);14631464 const curve = buildOpenCurve(data.path);1465 const length = curve.getLength();1466 if (this.engine) {1467 let proxies = [1468 // The shared channel builder (T7 parity — bench/tests/production1469 // one truth), including the braid alt lane when present.1470 ...segmentProxies(data),1471 ...data.islands.flatMap((isle) => islandProxies(isle.placements)),1472 ...bumperProxies(data.placements),1473 ];1474 // Present in world space: the twist-run ribbon corkscrews between1475 // the frames; rigid downstream shapes rotate wholesale.1476 if (fr) proxies = worldifyProxies(proxies, fr);1477 this.engine.addSegment(i, proxies);1478 // Dense rail (points/tangents/s/mods) for nearest-point progress and1479 // the assist force — the shared law (rail.ts), one truth with the1480 // headless tests.1481 const rail = railForSegment(data, this.branchOf.get(i) === "alt");1482 if (fr) attachFrames(rail, fr);1483 this.rails.set(i, rail);1484 // Both rails live for alt-carrying segments (M17): per-racer lane1485 // choice needs the other lane resident, not rebuilt on demand.1486 if (hasAltBranch(data)) {1487 const altRail = railForSegment(data, true);1488 if (fr) attachFrames(altRail, fr);1489 this.altRails.set(i, altRail);1490 if (hasBraid(data)) this.braidSegs.add(i);1491 if (data.braidContOf !== undefined) {1492 this.braidContOf.set(i, data.braidContOf);1493 }1494 }1495 // Fan wind capsules (M16): derived from the fan placements —1496 // deterministic, no rng. Ledge fans face the channel, so their1497 // capsules cross it (lateral gusts); wind-hinted fans carry their1498 // own tuning (boost corridors, lip updrafts). Reoriented segments1499 // blow their wind in the presented frame.1500 let volumes = fanVolumesOf(data.placements);1501 if (fr) {1502 volumes = volumes.map((v) => {1503 const vf = fr.twist1504 ? segmentFrameAt(fr, twistParam(fr, v.origin))1505 : fr.entry;1506 return {1507 ...v,1508 origin: affineApply(vf, v.origin) as [number, number, number],1509 dir: quatRotate(vf.q, v.dir) as [number, number, number],1510 };1511 });1512 }1513 this.fanField.addSegment(i, volumes);1514 // The twist chute visual: the SAME cross-section law as the1515 // collision ribbon, extruded along the rail's framed stations —1516 // what you ride is what you hit. Violet-charged like gravity1517 // zones (the set-piece vocabulary already reads that way).1518 if (fr?.twist) {1519 // Outer shell wears the chapter the line ENTERED the twist with1520 // (the nearest pre-twist placement's chapter).1521 let chapter: number | undefined;1522 let bestT = -Infinity;1523 for (const p of data.placements) {1524 const t = twistParam(fr, p.pos);1525 if (t < 0.5 && t > bestT && p.chapter !== undefined) {1526 bestT = t;1527 chapter = p.chapter;1528 }1529 }1530 const mesh = this.buildTwistChute(1531 rail,1532 fr,1533 chapter !== undefined ? this.chapterColor(chapter) : null,1534 );1535 if (mesh) {1536 mesh.userData.cameraOccluder = true;1537 cameraOccluders.push(mesh);1538 this.root.add(mesh);1539 disposables.push({1540 meshes: [],1541 dispose: () => {1542 // Geometry only — the chute SHARES the kit's materials1543 // (that's what makes it indistinguishable); disposing them1544 // would strip every placed piece.1545 this.root.remove(mesh);1546 mesh.geometry.dispose();1547 },1548 } as unknown as InstancedBuild);1549 }1550 }1551 }1552 const prev = this.sMeta.get(i - 1);1553 const startS = i === 0 ? 0 : (prev ? prev.start + prev.len : 0);1554 if (i > 0 && !prev) {1555 throw new Error(`segment ${i} built before its predecessor`);1556 }1557 this.sMeta.set(i, { start: startS, len: length });15581559 const seg: BuiltSegment = {1560 index: i,1561 group,1562 curve,1563 startS,1564 length,1565 spinners,1566 islands: data.islands.length,1567 entry: {1568 x: data.plan.entry.x,1569 y: data.plan.entry.y,1570 z: data.plan.entry.z,1571 dir: data.plan.entry.dir,1572 },1573 disposables,1574 floats,1575 lowY: Math.min(...data.plan.cells.map((c) => c.baseY)) * 2,1576 cameraOccluders,1577 cameraFadeMeshes,1578 ...(fr ? { frame: fr } : {}),1579 ...(postGroup ? { postGroup } : {}),1580 };1581 for (const g of postGroup ? [group, postGroup] : [group]) {1582 for (const child of g.children) {1583 if (child.userData.placementRole === "track") {1584 cameraOccluders.push(child);1585 }1586 }1587 }1588 this.built.set(i, seg);1589 this.islandsSeen += data.islands.length;1590 // Streamed-in segments assemble as a wave flowing along the travel1591 // direction; the session's opening fill appears whole.1592 if (!this.initialBuild) {1593 const entry = data.plan.entry;1594 const step = [[1, 0], [0, 1], [-1, 0], [0, -1]][entry.dir];1595 this.spawning.set(1596 i,1597 new SpawnAnim(1598 seg.group,1599 new THREE.Vector3(1600 entry.x * TRACK_SCALE,1601 entry.y * TRACK_SCALE,1602 entry.z * TRACK_SCALE,1603 ),1604 new THREE.Vector3(step[0], 0, step[1]),1605 ),1606 );1607 }1608 return seg;1609 }16101611 /** The reorient run's chute: the kit channel's SLICED cross-section1612 * (twistChute.ts — the kit `straight` piece's own section, UVs and1613 * shared materials) extruded along the rail's FRAMED stations. Rows1614 * end EXACTLY on the run's boundary stations so the chute sits flush1615 * against the neighboring kit pieces (an overlap would z-fight — the1616 * surfaces are identical by construction). Chapter pipe recolor1617 * applies like any placed piece. */1618 private buildTwistChute(1619 rail: Rail,1620 fr: SegmentFrame,1621 shellRgb: readonly [number, number, number] | null,1622 ): THREE.Mesh | null {1623 const section = extractChannelSection(this.assets);1624 if (!section) return null;1625 const tw = fr.twist!;1626 const frames = rail.frames;1627 if (!Array.isArray(frames)) return null;1628 const rows: number[] = [];1629 for (let i = 0; i < rail.pts.length; i++) {1630 const t = twistParam(fr, rail.pts[i]);1631 if (t < -1e-6 || t > tw.len + 1e-6) continue;1632 rows.push(i);1633 }1634 if (rows.length < 2) return null;1635 const S = TRACK_SCALE;1636 const scratch: [number, number, number] = [0, 0, 0];1637 const mesh = buildChuteMesh(section, rows.length, (r, x, y, out) => {1638 const i = rows[r];1639 const p = rail.pts[i];1640 const tan = rail.tans[i];1641 // The run is straight and flat in plan space — lateral is the1642 // horizontal perp of the tangent; the rail line rides the channel1643 // floor, so the section drops by its own measured floor height.1644 const nl = Math.hypot(tan[0], tan[2]) || 1;1645 const ux = tan[2] / nl;1646 const uz = -tan[0] / nl;1647 scratch[0] = p[0] + ux * x * S;1648 scratch[1] = p[1] + (y - section.floorY) * S;1649 scratch[2] = p[2] + uz * x * S;1650 const f = frames[i] ?? fr.entry;1651 affineApply(f, scratch, scratch);1652 out.set(scratch[0], scratch[1], scratch[2]);1653 });1654 if (shellRgb) this.assets.applyPipeColor(mesh, shellRgb);1655 return mesh;1656 }16571658 private disposeSegment(seg: BuiltSegment): void {1659 this.built.delete(seg.index);1660 this.spawning.delete(seg.index);1661 this.engine?.removeSegment(seg.index);1662 this.rails.delete(seg.index);1663 this.altRails.delete(seg.index);1664 this.braidSegs.delete(seg.index);1665 this.braidContOf.delete(seg.index);1666 this.scarCount.delete(seg.index);1667 this.fanField.removeSegment(seg.index);1668 // Sink away rather than pop out; the group leaves the scene when the1669 // exit wave finishes. (Occupancy claims release much later — the1670 // generator's keep window — so nothing can build into a sinking spot.)1671 const entry = seg.entry;1672 const step = [[1, 0], [0, 1], [-1, 0], [0, -1]][entry.dir];1673 this.exiting.push({1674 group: seg.group,1675 anim: new ExitAnim(1676 seg.group,1677 new THREE.Vector3(1678 entry.x * TRACK_SCALE,1679 entry.y * TRACK_SCALE,1680 entry.z * TRACK_SCALE,1681 ),1682 new THREE.Vector3(step[0], 0, step[1]),1683 ),1684 // Float builds ride along: ticking stops here (the segment leaves1685 // this.built — ExitAnim owns the matrices), disposal happens when1686 // the wave completes.1687 disposables: [...seg.disposables, ...seg.floats],1688 });1689 if (seg.postGroup) {1690 this.exiting.push({1691 group: seg.postGroup,1692 anim: new ExitAnim(1693 seg.postGroup,1694 new THREE.Vector3(1695 entry.x * TRACK_SCALE,1696 entry.y * TRACK_SCALE,1697 entry.z * TRACK_SCALE,1698 ),1699 new THREE.Vector3(step[0], 0, step[1]),1700 ),1701 disposables: [],1702 });1703 }1704 }17051706 /** Keep the render window [cur−BEHIND, cur+AHEAD] built. */1707 private ensure(): void {1708 const cur = this.currentIndex;1709 const lo = Math.max(0, cur - BEHIND);1710 const hi = cur + AHEAD;1711 if (hi - lo + 2 > KEEP_SEGMENTS) {1712 throw new Error("render window exceeds generator keep window");1713 }1714 let added = false;1715 for (let i = lo; i <= hi; i++) {1716 if (!this.built.has(i)) {1717 this.build(i);1718 added = true;1719 }1720 }1721 for (const seg of [...this.built.values()]) {1722 if (seg.index < lo || seg.index > hi) this.disposeSegment(seg);1723 }1724 if (added) {1725 // Fit box skips void decor (M18b): a far-band shoal 40u off the1726 // line would inflate the shadow fit and the camera focus radius.1727 const box = new THREE.Box3();1728 this.root.updateWorldMatrix(true, true);1729 const expand = (o: THREE.Object3D): void => {1730 if (o.userData.voidDecor) return;1731 if ((o as THREE.Mesh).isMesh) box.expandByObject(o);1732 for (const c of o.children) expand(c);1733 };1734 expand(this.root);1735 if (!box.isEmpty()) {1736 fitShadowsTo(this.ctx.sun, box);1737 this.cams.frame(box);1738 const size = box.getSize(new THREE.Vector3());1739 this.focus = {1740 center: box.getCenter(new THREE.Vector3()),1741 radius: Math.max(size.x, size.z) * 0.75 + 4,1742 };1743 }1744 }1745 }17461747 update(dt: number): void {1748 if (dt <= 0) return;1749 // Gust clock (M19a): pulsed wind gates on accumulated SIM time — the1750 // same dt stream advanceTime drives, so replays agree. Advance before1751 // the movers so this frame's decisions see this frame's phase.1752 this.windT += dt;1753 this.fanField.setTime(this.windT);1754 const prevS = this.marbleS;1755 if (this.engine) this.physicsMove(dt);1756 else this.kinematicMove(dt);17571758 const milestone = milestoneFor(prevS, this.marbleS);1759 if (milestone !== null) {1760 this.toast.show(1761 `${milestone.toLocaleString("en-US")}u\n ` +1762 `${this.islandsSeen} islands`,1763 );1764 this.audio.cue("milestone");1765 }17661767 const next = this.segmentAt(this.marbleS);1768 if (next && next.index !== this.currentIndex) {1769 this.currentIndex = next.index;1770 this.audio.cue("checkpoint");1771 // Atmosphere zones: every ZONE_SEGMENTS segments the void's climate1772 // crossfades to the next drawn mood+weather combo.1773 const zone = Math.floor(this.currentIndex / ZONE_SEGMENTS);1774 if (zone !== this.zone) {1775 this.zone = zone;1776 const za = drawZone(this.seed, zone, this.zoneBiome(zone));1777 this.atmosphere.blendTo(za);1778 this.audio.setMood(za.mood.name);1779 }1780 }1781 this.ensure();1782 this.atmosphere.update(dt, this.focus);1783 this.peerRing.update(1784 dt,1785 this.marbleS,1786 this.builtBand(),1787 (s, pos, tan) => this.sampleFrame(s, pos, tan),1788 );1789 this.finishFrame(dt);1790 }17911792 /** Dominant island biome across a zone's segment span (M18a) — biases1793 * the atmosphere draw toward the stretch's climate. Generation is a1794 * pure function of the seed, so peeking ≤ZONE_SEGMENTS ahead of the1795 * marble is deterministic and harmless; ensure() will build the same1796 * segments moments later. Ties break toward the FIRST biome seen (the1797 * one the marble meets first). */1798 private zoneBiome(zone: number): string | undefined {1799 const counts = new Map<string, number>();1800 for (let i = zone * ZONE_SEGMENTS; i < (zone + 1) * ZONE_SEGMENTS; i++) {1801 if (i < 0) continue;1802 try {1803 for (const isle of this.world.get(i).islands) {1804 counts.set(isle.biome, (counts.get(isle.biome) ?? 0) + 1);1805 }1806 } catch {1807 break; // never let atmosphere bias surface a planner throw1808 }1809 }1810 // Max count; ties break toward the biome seen first (Map order).1811 let best: string | undefined;1812 let bestN = 0;1813 for (const [b, n] of counts) {1814 if (n > bestN) {1815 best = b;1816 bestN = n;1817 }1818 }1819 return best;1820 }18211822 /** Kinematic rail rider (?phys=0): the pre-M10 driver. */1823 private kinematicMove(dt: number): void {1824 const seg = this.segmentAt(this.marbleS);1825 if (!seg) return;1826 const u = Math.min(1827 1,1828 Math.max(0, (this.marbleS - seg.startS) / seg.length),1829 );1830 seg.curve.getTangentAt(u, this.tangent);1831 this.speed +=1832 (-this.tangent.y * GRAVITY - DRAG * (this.speed - BASE_SPEED)) *1833 dt;1834 this.speed = Math.min(MAX_SPEED, Math.max(MIN_SPEED, this.speed));1835 this.marbleS += this.speed * dt;1836 if (!this.samplePoint(this.marbleS, this.point)) return;1837 this.marbleObj.position.copy(this.point);1838 this.axis.copy(this.up).cross(this.tangent);1839 if (this.axis.lengthSq() > 1e-8) {1840 this.axis.normalize();1841 this.spin.setFromAxisAngle(this.axis, (this.speed * dt) / this.radius);1842 this.pivot.quaternion.premultiply(this.spin);1843 }1844 }18451846 // --- physics LOD (pack-scale realtime) -----------------------------------18471848 /** Racers the last LOD pass kept as real bodies (reused set). */1849 private readonly liveSet = new Set<Racer>();1850 private liveCount = 0;1851 private lodClock = 0;18521853 /** Re-elect the real-body band around the camera subject: nearest1854 * LIVE_CAP racers by progress within the retention window. Hysteresis1855 * lives in the two radii (promote < PROMOTE_S, retain to DEMOTE_S). */1856 private lodUpdate(): void {1857 const n = this.racers.length;1858 if (n <= LOD_ENGAGE_N || !this.engine) {1859 this.liveCount = n;1860 return;1861 }1862 const subj = this.racers[this.followedIdx] ?? this.racers[0];1863 const subjS = subj.s;1864 // sorted is ascending in s — find the subject's rank, expand outward.1865 let lo = 0;1866 let hi = this.sorted.length;1867 while (lo < hi) {1868 const mid = (lo + hi) >> 1;1869 if (this.sorted[mid].s < subjS) lo = mid + 1;1870 else hi = mid;1871 }1872 this.liveSet.clear();1873 let a = lo - 1;1874 let b = lo;1875 while (this.liveSet.size < LIVE_CAP) {1876 const da = a >= 0 ? subjS - this.sorted[a].s : Infinity;1877 const db = b < this.sorted.length ? this.sorted[b].s - subjS : Infinity;1878 if (da === Infinity && db === Infinity) break;1879 if (da <= db) {1880 if (da > LOD_DEMOTE_S) a = -1;1881 else this.liveSet.add(this.sorted[a--]);1882 } else {1883 if (db > LOD_DEMOTE_S) b = this.sorted.length;1884 else this.liveSet.add(this.sorted[b++]);1885 }1886 }1887 for (const r of this.racers) {1888 const keep = this.liveSet.has(r) || r.idx === 0 ||1889 r.idx === this.followedIdx;1890 if (r.lod === "live" && !keep) this.demoteRacer(r);1891 else if (1892 r.lod === "rider" && keep &&1893 Math.abs(r.s - subjS) < LOD_PROMOTE_S1894 ) this.promoteRacer(r);1895 else if (1896 r.lod === "rider" && (r.idx === 0 || r.idx === this.followedIdx)1897 ) {1898 this.promoteRacer(r);1899 }1900 }1901 this.liveCount = 0;1902 for (const r of this.racers) if (r.lod === "live") this.liveCount++;1903 }19041905 /** Real body → kinematic rider: the far racer keeps racing on rails. */1906 private demoteRacer(r: Racer): void {1907 this.logRailDebug("lod-distance", r, this.railDebugTile(r));1908 this.engine!.destroyMarble(r.idx);1909 r.lod = "rider";1910 r.cachedF = null;1911 r.ghostT = 0;1912 r.stallClock = 0;1913 r.draft = 1;1914 r.otkBlend = 0;1915 r.blockBlend = 0;1916 r.temperClock = 0;1917 if (r.boost.boosting) r.boost = { fuel: r.boost.fuel, boosting: false };1918 if (r.wasBoosting) {1919 r.wasBoosting = false;1920 if (this.peerMesh && r.idx > 0) {1921 this.peerMesh.setColorAt(r.idx - 1, this.peerColors[r.idx - 1]);1922 if (this.peerMesh.instanceColor) {1923 this.peerMesh.instanceColor.needsUpdate = true;1924 }1925 }1926 }1927 }19281929 /** Rider → real body: spawned on its rail line at its glide speed,1930 * briefly ghosted so re-entry into the pack never detonates it. */1931 private promoteRacer(r: Racer): void {1932 const engine = this.engine;1933 if (!engine) return;1934 const seg = this.segmentAt(r.s);1935 const rail = seg ? this.railFor(r, seg.index) : undefined;1936 if (!seg || !rail) return;1937 const local = r.s - seg.startS;1938 let idx = 0;1939 while (idx < rail.s.length - 1 && rail.s[idx] < local) idx++;1940 const t = rail.tans[idx];1941 const nl = Math.hypot(t[2], t[0]) || 1;1942 const lane = ((r.idx % 5) - 2) * 0.13;1943 const pos: [number, number, number] = [1944 rail.pts[idx][0] + (t[2] / nl) * lane,1945 rail.pts[idx][1] + 0.3,1946 rail.pts[idx][2] + (-t[0] / nl) * lane,1947 ];1948 const vel: [number, number, number] = [1949 t[0] * r.speed,1950 t[1] * r.speed,1951 t[2] * r.speed,1952 ];1953 const f = railFrameAt(rail, idx);1954 if (f) {1955 affineApply(f, pos, pos);1956 quatRotate(f.q, vel, vel);1957 }1958 engine.spawnMarble(r.idx, pos, vel);1959 engine.setGhost(r.idx, true);1960 r.ghostT = GHOST_T - 1.2; // short window — it spawns ON its own line1961 r.lod = "live";1962 r.nearest = { seg: seg.index, idx };1963 r.lastProgressS = r.s;1964 r.stallClock = 0;1965 r.stallHopped = false;1966 r.draft = 1;1967 r.otkBlend = 0;1968 r.blockBlend = 0;1969 r.temperClock = 0;1970 }19711972 /** Kinematic rider glide: advance progress at a personality cruise and1973 * sit the marble on its (presented) rail line. Cheap by construction —1974 * this is what lets N=9999 run realtime. */1975 private riderUpdate(r: Racer, dt: number): void {1976 const h = (((r.idx * 2654435761) >>> 0) % 1024) / 1024;1977 let target = 3.0 + h * 1.2;1978 // Rider draft fake (M20a): the far pack expresses the same wake law1979 // kinematically, CAPPED — chained riders must never compound into a1980 // runaway glide (the streaming anchor is the hero alone).1981 const ahead = this.sorted[r.rank + 1];1982 if (ahead && ahead !== r) {1983 target *= Math.min(1984 draftFactor(ahead.s - r.s, r.pilot.wake),1985 RIDER_DRAFT_CAP,1986 );1987 }1988 r.speed += (target - r.speed) * Math.min(1, dt * 0.5);1989 r.s += r.speed * dt;1990 const seg = this.segmentAt(r.s);1991 if (!seg) return;1992 // Braid entry (M20a): riders draw the SAME per-racer lane law as1993 // live racers, so the fork split ratio holds at LOD distance. V3:1994 // a continuation carries the fork's lane across the boundary.1995 if (this.braidSegs.has(seg.index) && r.laneBraid !== seg.index) {1996 const contOf = this.braidContOf.get(seg.index);1997 if (contOf === undefined || r.laneBraid !== contOf) {1998 r.laneAlt = braidLaneAlt(1999 this.seed,2000 r.idx,2001 contOf ?? seg.index,2002 r.pilot.lanePref,2003 );2004 }2005 r.laneBraid = seg.index;2006 r.nearest = { seg: seg.index, idx: 0 };2007 }2008 const rail = this.railFor(r, seg.index);2009 if (!rail) return;2010 const local = r.s - seg.startS;2011 let idx = r.nearest.seg === seg.index ? r.nearest.idx : 0;2012 while (idx < rail.s.length - 1 && rail.s[idx] < local) idx++;2013 while (idx > 0 && rail.s[idx - 1] > local) idx--;2014 r.nearest = { seg: seg.index, idx };2015 const t = rail.tans[idx];2016 const nl = Math.hypot(t[2], t[0]) || 1;2017 const lane = ((r.idx % 5) - 2) * 0.13;2018 const p = this.frameScratch;2019 p[0] = rail.pts[idx][0] + (t[2] / nl) * lane;2020 p[1] = rail.pts[idx][1] + 0.1;2021 p[2] = rail.pts[idx][2] + (-t[0] / nl) * lane;2022 const f = railFrameAt(rail, idx);2023 if (f) affineApply(f, p, p);2024 r.pos[0] = p[0];2025 r.pos[1] = p[1];2026 r.pos[2] = p[2];2027 }20282029 /** One control decision for a racer (called at the decimated cadence):2030 * nearest station, boost economy, autopilot force, jump — the result2031 * lands in r.cachedF. `K` is the decision period in fixed steps. */2032 private decide(r: Racer, K: number): void {2033 const engine = this.engine!;2034 if (r.lod !== "live") {2035 r.cachedF = null;2036 return;2037 }2038 this.updateNearestFor(r, r.pos);2039 this.braidLaneFor(r);2040 const rail = this.railFor(r, r.nearest.seg);2041 if (!rail) {2042 r.cachedF = null;2043 return;2044 }2045 engine.setTrackSegment(r.idx, r.nearest.seg);2046 engine.setRestitution(r.idx, stationRestitution(rail, r.nearest.idx));2047 const nextRail = this.railFor(r, r.nearest.seg + 1) ?? null;2048 // M16 conjugation: on a reoriented segment the control laws run in2049 // PLAN space — marble state maps in through the station frame, the2050 // decided force maps back out. The autopilot never sees the wall.2051 const frame = railFrameAt(rail, r.nearest.idx);2052 let cpos: readonly [number, number, number] = r.pos;2053 let cvel: readonly [number, number, number] = r.vel;2054 if (frame) {2055 cpos = affineUnapply(frame, r.pos, this.planPos);2056 cvel = quatUnrotate(frame.q, r.vel, this.planVel);2057 }2058 let f: [number, number, number] | null;2059 if (this.useAutopilot) {2060 r.simT += FIXED_DT * K;2061 // Rank-window rivals: nearest distinct racer ahead/behind, and up2062 // to four separation partners within 2 units of progress.2063 const k0 = this.sorted.indexOf(r);2064 // Rubber-banding through pilot SKILL (never thrust): the smoothed2065 // race-position fraction modulates corner bravery + jump eagerness2066 // on the scratch pilot. sorted is ascending in s — index 0 trails.2067 const n2 = this.sorted.length;2068 const behind = n2 > 1 ? 1 - k0 / (n2 - 1) : 0.5;2069 r.rubber += (behind - r.rubber) *2070 Math.min(1, (FIXED_DT * K) / RUBBER_TAU);2071 rubberMod(r.pilot, r.rubber, r.pilotEff);2072 let gapAhead: number | null = null;2073 let gapBehind: number | null = null;2074 let aheadRacer: Racer | null = null;2075 let behindRacer: Racer | null = null;2076 const grudge = r.rival?.valence === "grudge" ? r.rival : null;2077 const ally = r.rival?.valence === "ally" ? r.rival : null;2078 this.othersScratch.length = 0;2079 const addNeighbor = (o: Racer) => {2080 const slot = this.neighborPool[this.othersScratch.length];2081 if (frame) {2082 affineUnapply(frame, o.pos, slot.pos);2083 } else {2084 slot.pos[0] = o.pos[0];2085 slot.pos[1] = o.pos[1];2086 slot.pos[2] = o.pos[2];2087 }2088 // Separation asymmetry (M20b): the pilot shoves through its2089 // grudge target more readily — the asymmetry lives entirely in2090 // what the caller REPORTS; the autopilot compare is unchanged.2091 slot.bravery = grudge && o.idx === grudge.target2092 ? o.pilotEff.bravery - 0.1 * grudge.intensity2093 : o.pilotEff.bravery;2094 this.othersScratch.push(slot);2095 };2096 for (let k = k0 + 1; k < this.sorted.length && k <= k0 + 3; k++) {2097 const o = this.sorted[k];2098 const d = o.s - r.s;2099 if (gapAhead === null && d > 0.05) {2100 gapAhead = d;2101 aheadRacer = o;2102 }2103 if (d < 2 && this.othersScratch.length < 4) addNeighbor(o);2104 }2105 for (let k = k0 - 1; k >= 0 && k >= k0 - 3; k--) {2106 const o = this.sorted[k];2107 const d = r.s - o.s;2108 if (gapBehind === null && d > 0.05) {2109 gapBehind = d;2110 behindRacer = o;2111 }2112 if (d < 2 && this.othersScratch.length < 4) addNeighbor(o);2113 }2114 // Temper (M20b): no contact events in the shim — the deterministic2115 // proxy is a peer inside contact distance this decision (plan2116 // space, the same slots separation sees).2117 // Real solver hits first (contact-events round), the proximity2118 // proxy as the sub-threshold fallback.2119 let contactNear = this.hitFlags.delete(r.idx);2120 if (!contactNear) {2121 for (const slot of this.othersScratch) {2122 const dx = slot.pos[0] - cpos[0];2123 const dy = slot.pos[1] - cpos[1];2124 const dz = slot.pos[2] - cpos[2];2125 if (dx * dx + dy * dy + dz * dz < CONTACT_NEAR * CONTACT_NEAR) {2126 contactNear = true;2127 break;2128 }2129 }2130 }2131 r.temperClock = temperStep(r.temperClock, FIXED_DT * K, contactNear);2132 const heat = temperHeat(r.temperClock, r.pilot.temper);2133 r.pilotEff.aggro *= heat.aggro;2134 r.pilotEff.bravery *= heat.bravery;2135 const headroom =2136 r.dbg.vSafe !== undefined && r.dbg.wishspeed !== undefined2137 ? r.dbg.vSafe - r.dbg.wishspeed2138 : null;2139 // Rivalry boost bias (M20b), levers only — boostStep unchanged:2140 // a grudge target ahead reads CLOSER (widened attack range); an2141 // ally ahead never triggers an attack (defend/overflow untouched).2142 const aheadIsGrudge = grudge !== null && aheadRacer !== null &&2143 aheadRacer.idx === grudge.target;2144 const gapAheadEff = aheadIsGrudge2145 ? gapAhead! / (1 + grudge!.intensity)2146 : ally !== null && aheadRacer !== null && aheadRacer.idx === ally.target2147 ? null2148 : gapAhead;2149 // Drafting (M20a): wake bonus behind the SAME-LANE leader — on a2150 // braid, a wake across lanes is fiction; elsewhere lanes coincide.2151 // Held at decision cadence (the boost-state 1-step-lag contract).2152 const sameLane = aheadRacer === null ||2153 !this.braidSegs.has(r.nearest.seg) ||2154 (aheadRacer.laneAlt === r.laneAlt &&2155 aheadRacer.laneBraid === r.laneBraid);2156 r.draft = sameLane ? draftFactor(gapAhead, r.pilot.wake) : 1;2157 // M22 slipstream economy: wake time pays fuel (boostStep refill).2158 const draftDepth = (r.draft - 1) / DRAFT_GAIN;2159 const wasBoostingNow = r.boost.boosting;2160 r.boost = boostStep(2161 r.boost,2162 r.pilotEff,2163 FIXED_DT * K,2164 headroom,2165 gapAheadEff,2166 gapBehind,2167 draftDepth,2168 );2169 if (!wasBoostingNow && r.boost.boosting && aheadIsGrudge) {2170 this.rivalIgnitions++;2171 }2172 // Overtake commitment (M20a): while the burn is an ATTACK (rival2173 // ahead in range), hold an off-center passing line on the pilot's2174 // chirality side. Hysteresis (engage < 3.5u, release > 5u) plus a2175 // ~0.4s slew keep the single offset actuator from flapping against2176 // separation steering; the offset stays inside CORRIDOR_DEAD so2177 // the corridor spring never fights it. Scratch pilot only.2178 const otkEngaged = r.boost.boosting && gapAhead !== null &&2179 (r.otkBlend > 0 ? gapAhead < OTK_DISENGAGE : gapAhead < OTK_ENGAGE);2180 r.otkBlend += ((otkEngaged ? 1 : 0) - r.otkBlend) *2181 Math.min(1, (FIXED_DT * K) / OTK_SLEW_TAU);2182 if (r.otkBlend < 0.02 && !otkEngaged) r.otkBlend = 0;2183 // Defensive blocking (M20b): the grudge target chasing close gets2184 // its line stood in — offset biased toward the chaser's side, same2185 // hysteresis + slew, strictly BELOW the overtake in priority (you2186 // can't block while attacking; one offset actuator, one owner).2187 let blockOff = 0;2188 const chaserIsGrudge = grudge !== null && behindRacer !== null &&2189 behindRacer.idx === grudge.target;2190 const blockEngaged = !otkEngaged && chaserIsGrudge &&2191 gapBehind !== null &&2192 gapBehind <2193 BLOCK_RANGE * r.pilotEff.aggro * (1 + grudge!.intensity) *2194 (r.blockBlend > 0 ? 1.3 : 1);2195 if (chaserIsGrudge || r.blockBlend > 0) {2196 const t = rail.tans[r.nearest.idx];2197 if (behindRacer && t) {2198 const cp = frame2199 ? affineUnapply(frame, behindRacer.pos, this.frameScratch)2200 : behindRacer.pos;2201 const side = (cp[0] - cpos[0]) * t[2] - (cp[2] - cpos[2]) * t[0];2202 blockOff = (side >= 0 ? 1 : -1) * BLOCK_OFF;2203 }2204 }2205 r.blockBlend += ((blockEngaged ? 1 : 0) - r.blockBlend) *2206 Math.min(1, (FIXED_DT * K) / OTK_SLEW_TAU);2207 if (r.blockBlend < 0.02 && !blockEngaged) r.blockBlend = 0;2208 const wander = pilotOffset(r.pilot, r.simT);2209 let offset = wander * (1 - r.blockBlend) + blockOff * r.blockBlend;2210 offset = offset * (1 - r.otkBlend) +2211 r.pilot.chir * OVERTAKE_OFF * r.otkBlend;2212 r.pilotEff.offset = offset;2213 f = autopilotForce(2214 rail,2215 r.nearest.idx,2216 cpos,2217 cvel,2218 r.mass,2219 nextRail,2220 r.pilotEff,2221 r.dbg,2222 this.othersScratch,2223 r.boost.boosting,2224 r.draft,2225 );2226 // M25 ice scuffs: hard braking on an icy surface leaves marks —2227 // rate-limited per racer, deterministic (sim state only).2228 if (2229 r.dbg.mode === "brake" &&2230 rail.mods[r.nearest.idx]?.icy === true &&2231 r.simT - r.lastScuffT > 0.52232 ) {2233 r.lastScuffT = r.simT;2234 this.pendingScars.push({2235 x: r.pos[0],2236 y: r.pos[1],2237 z: r.pos[2],2238 speed: 0, // scuff, not knock2239 idx: r.idx,2240 });2241 if (this.pendingScars.length > 64) this.pendingScars.shift();2242 }2243 const j = wantJump(2244 rail,2245 r.nearest.idx,2246 cpos,2247 cvel,2248 nextRail,2249 r.pilotEff,2250 );2251 if (j) {2252 const n = frame ? quatRotate(frame.q, j[0], this.frameScratch) : j[0];2253 engine.jump(r.idx, n, j[1]);2254 if (!r.wasHop && r.idx === 0) this.audio.cue("launch");2255 r.wasHop = true;2256 } else r.wasHop = false;2257 } else {2258 f = stationForce(2259 rail,2260 r.nearest.idx,2261 cpos,2262 cvel,2263 r.mass,2264 nextRail,2265 );2266 }2267 // Rotate the plan-space control force into the presented world.2268 if (f && frame) {2269 f = quatRotate(frame.q, f, this.planF) as [number, number, number];2270 }2271 // Gravity zones as per-marble delta forces (world gravity is global2272 // and fixed — one vector cannot serve N marbles).2273 const gd = stationGravityDelta(rail, r.nearest.idx, r.mass);2274 // Fan wind (M16): fans are force-field volumes — accumulate the2275 // capsule forces near the racer's segment (O(nearby), no alloc).2276 const w = this.windScratch;2277 w[0] = 0;2278 w[1] = 0;2279 w[2] = 0;2280 this.fanField.forceNear(r.nearest.seg, r.pos, r.mass, w);2281 const fx = (f?.[0] ?? 0) + (gd?.[0] ?? 0) + w[0];2282 const fy = (f?.[1] ?? 0) + (gd?.[1] ?? 0) + w[1];2283 const fz = (f?.[2] ?? 0) + (gd?.[2] ?? 0) + w[2];2284 r.cachedF = fx !== 0 || fy !== 0 || fz !== 0 ? [fx, fy, fz] : null;2285 }22862287 /** Box3D driver (M15): N real marbles — the hero plus racer peers —2288 * each running its own autopilot; real falls, per-marble respawn,2289 * leader-cam. */2290 private physicsMove(dt: number): void {2291 const engine = this.engine!;2292 const n = this.racers.length;2293 // Pack-scale controls: DECISIONS (nearest scan, sweep, boost, jump)2294 // recompute round-robin every K steps while the cached force applies2295 // every step. With physics LOD the cadence keys on the LIVE body2296 // count (riders cost nothing) — feel near the camera stays crisp.2297 const liveN = this.liveCount || n;2298 const K = liveN <= 64 ? 1 : liveN <= 256 ? 2 : liveN <= 1024 ? 4 : 8;2299 engine.step(dt, () => {2300 // Contact hit events (contact-events round): real knocks from the2301 // solver, per step (events clear on the next step — read them2302 // inside the callback). Marble-marble hits arm the TEMPER trigger2303 // (upgrading the proximity proxy); marble-static hits above the2304 // scar threshold queue world-memory marks (M25).2305 const hits = engine.hits();2306 for (let h = 0; h < hits.count; h++) {2307 const a = hits.data[h * 6];2308 const b = hits.data[h * 6 + 1];2309 const speed = hits.data[h * 6 + 2];2310 if (a >= 0 && b >= 0) {2311 this.hitFlags.add(a);2312 this.hitFlags.add(b);2313 } else if (speed >= SCAR_SPEED) {2314 const solo = a >= 0 ? a : b;2315 this.pendingScars.push({2316 x: hits.data[h * 6 + 3],2317 y: hits.data[h * 6 + 4],2318 z: hits.data[h * 6 + 5],2319 speed,2320 idx: solo,2321 });2322 if (this.pendingScars.length > 64) this.pendingScars.shift();2323 }2324 }2325 // ONE wasm call for the whole pack, copied into persistent2326 // per-racer scratch (no per-step allocation). Riders keep their2327 // kinematic state — their slots read back zeros.2328 const flat = engine.statesAll(n);2329 for (let i = 0; i < n; i++) {2330 const r = this.racers[i];2331 if (r.lod !== "live") continue;2332 const b = i * 10;2333 r.pos[0] = flat[b];2334 r.pos[1] = flat[b + 1];2335 r.pos[2] = flat[b + 2];2336 r.vel[0] = flat[b + 3];2337 r.vel[1] = flat[b + 4];2338 r.vel[2] = flat[b + 5];2339 r.quat[0] = flat[b + 6];2340 r.quat[1] = flat[b + 7];2341 r.quat[2] = flat[b + 8];2342 r.quat[3] = flat[b + 9];2343 }2344 // Progress-sorted order: rival gaps + separation partners come2345 // from rank neighbors — O(N log N), not O(N²).2346 this.sorted.length = 0;2347 for (const r of this.racers) this.sorted.push(r);2348 this.sorted.sort((a, b2) => a.s - b2.s);2349 // Rank stamp (M20a): riders read their leader through it — one int2350 // store per racer, no extra alloc.2351 for (let ri = 0; ri < this.sorted.length; ri++) {2352 this.sorted[ri].rank = ri;2353 }2354 this.stepCounter++;2355 // Physics LOD: re-elect the real-body band around the subject.2356 if (++this.lodClock >= LOD_PERIOD) {2357 this.lodClock = 0;2358 this.lodUpdate();2359 }2360 for (let i = 0; i < n; i++) {2361 const r = this.racers[i];2362 if (r.lod !== "live") continue;2363 if ((this.stepCounter + i) % K === 0) {2364 this.decide(r, K);2365 }2366 const cf = r.cachedF;2367 if (cf && (cf[0] !== 0 || cf[1] !== 0 || cf[2] !== 0)) {2368 engine.force(r.idx, cf[0], cf[1], cf[2]);2369 }2370 }2371 });23722373 let lowest = Infinity;2374 for (const b of this.built.values()) {2375 lowest = Math.min(lowest, b.lowY);2376 }2377 const windowLo = Math.min(...this.built.keys());2378 const windowLoS = this.built.get(windowLo)?.startS ?? 0;23792380 let heroFell = false;2381 const flat = engine.statesAll(this.racers.length);2382 for (const [ri, r] of this.racers.entries()) {2383 const b = ri * 10;2384 // Kinematic riders (physics LOD): glide the rail, write the2385 // matrix, redistribute if the window dropped them — none of the2386 // body bookkeeping below applies.2387 if (r.lod === "rider") {2388 this.riderUpdate(r, dt);2389 if (this.peerMesh && r.idx > 0) {2390 this.instPos.set(r.pos[0], r.pos[1], r.pos[2]);2391 this.instQuat.set(r.quat[0], r.quat[1], r.quat[2], r.quat[3]);2392 this.instScale.setScalar(1);2393 this.instMatrix.compose(this.instPos, this.instQuat, this.instScale);2394 this.peerMesh.setMatrixAt(r.idx - 1, this.instMatrix);2395 this.peerShellMesh?.setMatrixAt(r.idx - 1, this.instMatrix);2396 }2397 if (r.s < windowLoS - 2) this.respawnPeer(r, "stream-cull");2398 continue;2399 }2400 r.pos[0] = flat[b];2401 r.pos[1] = flat[b + 1];2402 r.pos[2] = flat[b + 2];2403 r.speed = Math.hypot(flat[b + 3], flat[b + 4], flat[b + 5]);2404 // Respawn spawn-in: ease the visual ~0.1 → 1 instead of popping.2405 // The group path scales r.obj (the kit mesh inside keeps its own2406 // MARBLE_SCALE); the instanced path folds the scale into the2407 // instance matrix. Allocation-free — scalar math only.2408 let spawnScale = 1;2409 const spawnAnimating = r.spawnAnim > 0;2410 if (spawnAnimating) {2411 r.spawnAnim = Math.max(0, r.spawnAnim - dt);2412 const k = 1 - r.spawnAnim / SPAWN_IN_T;2413 spawnScale = 0.1 + 0.9 * (1 - (1 - k) * (1 - k) * (1 - k));2414 }2415 if (r.obj && r.pivot) {2416 r.obj.position.set(flat[b], flat[b + 1], flat[b + 2]);2417 if (spawnAnimating) r.obj.scale.setScalar(spawnScale);2418 r.pivot.quaternion.set(2419 flat[b + 6],2420 flat[b + 7],2421 flat[b + 8],2422 flat[b + 9],2423 );2424 } else if (this.peerMesh) {2425 // Instanced peer: one matrix write into the shared mesh.2426 this.instPos.set(flat[b], flat[b + 1], flat[b + 2]);2427 this.instQuat.set(2428 flat[b + 6],2429 flat[b + 7],2430 flat[b + 8],2431 flat[b + 9],2432 );2433 // instScale is shared scratch — set it EVERY racer (1 when not2434 // animating), or one spawning racer shrinks the next N instances.2435 this.instScale.setScalar(spawnScale);2436 this.instMatrix.compose(this.instPos, this.instQuat, this.instScale);2437 this.peerMesh.setMatrixAt(r.idx - 1, this.instMatrix);2438 this.peerShellMesh?.setMatrixAt(r.idx - 1, this.instMatrix);2439 }2440 // Respawn immunity: ghosted marbles pass through neighbors (track2441 // stays solid). Tick down; resolidify only once CLEAR of the pack,2442 // with a hard cap (GHOST_MAX) so nobody ghosts forever.2443 if (r.ghostT !== 0) {2444 r.ghostT -= dt;2445 if (2446 r.ghostT <= GHOST_T - GHOST_MAX ||2447 (r.ghostT <= 0 && this.clearOfPack(r))2448 ) {2449 engine.setGhost(r.idx, false);2450 r.ghostT = 0;2451 }2452 }2453 const seg = this.built.get(r.nearest.seg);2454 const rail = this.railFor(r, r.nearest.seg);2455 if (seg && rail) {2456 // Alt-lane s runs on the alt rail's own arc length (≈ main ±2457 // wander) — leaderboard tolerance is fine (brief rule).2458 r.s = Math.max(r.s, seg.startS + rail.s[r.nearest.idx]);2459 }24602461 // Boost visual is a separate hot trail on the camera subject. Keep2462 // the marble's authored materials intact (no full-sphere color filter).2463 if (r.boost.boosting !== r.wasBoosting) {2464 r.wasBoosting = r.boost.boosting;2465 if (r.boost.boosting && r.idx === this.followedIdx) {2466 this.audio.cue("boost");2467 }2468 }24692470 if (r.idx === 0) {2471 // Hero cues off the station modifier transitions (rule-derived).2472 const mod = rail?.mods[r.nearest.idx] ?? null;2473 const flight = !!mod?.flight;2474 if (flight && !this.wasFlight) this.audio.cue("launch");2475 if (!flight && this.wasFlight) this.audio.cue("land");2476 this.wasFlight = flight;2477 const surge = (mod?.speed !== undefined && mod.speed > 1) ||2478 mod?.tilt !== undefined;2479 if (surge && !this.wasSurge) this.audio.cue("boost");2480 this.wasSurge = surge;2481 this.nearest = r.nearest;2482 this.marbleS = Math.max(this.marbleS, r.s);2483 }24842485 // Falls + stalls, per marble. The hero keeps the checkpoint2486 // escalation; peers respawn behind the pack tail. Reoriented2487 // racers fall along their LOCAL down — compare in plan space2488 // (lowY is plan-space by construction).2489 const fFall = rail ? railFrameAt(rail, r.nearest.idx) : null;2490 const planY = fFall2491 ? affineUnapply(fFall, r.pos, this.frameScratch)[1]2492 : r.pos[1];2493 const fell = planY < lowest - 10;2494 if (r.idx === 0) {2495 if (fell) {2496 heroFell = true;2497 continue;2498 }2499 if (this.marbleS > this.lastProgressS + 0.4) {2500 this.lastProgressS = this.marbleS;2501 this.stallClock = 0;2502 this.stallHopped = false;2503 } else {2504 this.stallClock += dt;2505 // Escape hop (M14b): one visible attempt before the net.2506 if (2507 this.stallClock > 2.5 && !this.stallHopped && this.useAutopilot2508 ) {2509 this.stallHopped = true;2510 engine.jump(2511 0,2512 fFall ? frameUp(fFall, this.frameScratch) : [0, 1, 0],2513 4.2,2514 );2515 this.audio.cue("launch");2516 }2517 if (this.stallClock > 6) this.respawn("stall");2518 }2519 continue;2520 }2521 // Peer: fell, wedged, or dropped out of the keep window.2522 if (r.s > r.lastProgressS + 0.4) {2523 r.lastProgressS = r.s;2524 r.stallClock = 0;2525 r.stallHopped = false;2526 } else {2527 r.stallClock += dt;2528 if (r.stallClock > 2.5 && !r.stallHopped && this.useAutopilot) {2529 r.stallHopped = true;2530 engine.jump(2531 r.idx,2532 fFall ? frameUp(fFall, this.frameScratch) : [0, 1, 0],2533 4.2,2534 );2535 }2536 }2537 if (fell || r.stallClock > 6 || r.s < windowLoS - 2) {2538 const reason: Exclude<RailDebugReason, "lod-distance"> = fell2539 ? "fall"2540 : r.stallClock > 62541 ? "stall"2542 : "stream-cull";2543 this.respawnPeer(r, reason);2544 }2545 }2546 if (this.peerMesh) this.peerMesh.instanceMatrix.needsUpdate = true;2547 if (this.peerShellMesh) {2548 this.peerShellMesh.instanceMatrix.needsUpdate = true;2549 }2550 if (heroFell) {2551 this.respawn("fall");2552 return;2553 }25542555 // Camera subject: the hero by default; the race leader when the user2556 // elected leader-cam (SPACE). The trail follows the same subject.2557 // Hysteresis on lead changes: neck-and-neck racers alternate the top2558 // spot every few frames — the camera only hands over when someone2559 // clearly leads the current subject (0.75u), or the shot flickers2560 // and the trail resets endlessly.2561 let followed = this.racers[0];2562 if (this.leaderCam) {2563 const current = this.racers.find((r) => r.idx === this.followedIdx) ??2564 followed;2565 let top = current;2566 for (const r of this.racers) if (r.s > top.s) top = r;2567 followed = top.s > current.s + 0.75 ? top : current;2568 }2569 const subjectChanged = this.followedIdx !== followed.idx;2570 this.followedIdx = followed.idx;2571 // Follow-cam local up (M16): the lens rolls with the subject's2572 // gravity frame. Eased HERE as well as in camera.ts, and a missing2573 // rail (mid-cull, mid-respawn) keeps the last known up — a one-frame2574 // flip to world-up read as "snap to a default angle, float back"2575 // (user report).2576 const fRail = this.railFor(followed, followed.nearest.seg);2577 if (fRail) {2578 const fFrame = railFrameAt(fRail, followed.nearest.idx);2579 if (fFrame) frameUp(fFrame, this.camUpTarget);2580 else {2581 this.camUpTarget[0] = 0;2582 this.camUpTarget[1] = 1;2583 this.camUpTarget[2] = 0;2584 }2585 }2586 const ku = 1 - Math.exp(-6 * Math.max(dt, 1e-4));2587 this.camUp[0] += (this.camUpTarget[0] - this.camUp[0]) * ku;2588 this.camUp[1] += (this.camUpTarget[1] - this.camUp[1]) * ku;2589 this.camUp[2] += (this.camUpTarget[2] - this.camUp[2]) * ku;2590 const ul = Math.hypot(this.camUp[0], this.camUp[1], this.camUp[2]) || 1;2591 this.camUp[0] /= ul;2592 this.camUp[1] /= ul;2593 this.camUp[2] /= ul;2594 this.speed = followed.speed;2595 // The WORLD anchors to the HERO: streaming, culling, milestones and2596 // respawn fill all key on marbleS = hero progress (ratcheted in the2597 // hero branch above). The camera subject only moves the lens — with2598 // leader-cam ratcheting marbleS, a peer respawned into the forward2599 // fill became the new leader, slid the window, forced stragglers to2600 // respawn (some ahead again), and generation outchased the hero by2601 // 10k+ units (user report).2602 this.camS = followed.s;2603 // Instanced peers have no scene object — a proxy stands in for the2604 // camera and the trail.2605 let fObj: THREE.Object3D;2606 let fPivot: THREE.Object3D;2607 if (followed.obj && followed.pivot) {2608 fObj = followed.obj;2609 fPivot = followed.pivot;2610 } else {2611 this.instProxy.position.set(2612 followed.pos[0],2613 followed.pos[1],2614 followed.pos[2],2615 );2616 fObj = this.instProxy;2617 fPivot = this.instProxy;2618 }2619 if (this.cameraProxy) {2620 this.cameraProxy.obj = fObj;2621 this.cameraProxy.s = this.camS;2622 this.cameraProxy.speed = this.speed;2623 }2624 if (subjectChanged || this.followedPivot !== fPivot) {2625 this.followedPivot = fPivot;2626 this.trail.reset();2627 this.boostTrail.reset();2628 this.boostTrailFade = 0;2629 this.boostTrailActive = false;2630 }2631 }26322633 get canFollowLeader(): boolean {2634 return this.racers.length > 1;2635 }26362637 /** SPACE toggle: hero-follow (default) ↔ leader-cam. */2638 toggleLeaderCam(): void {2639 if (!this.canFollowLeader) return;2640 this.leaderCam = !this.leaderCam;2641 if (this.racers.length > 0) {2642 this.toast.show(2643 this.leaderCam ? "Following the leader" : "Following your marble",2644 );2645 }2646 }26472648 /** True when no other racer sits within GHOST_CLEAR_D of `r` — checked2649 * against progress-rank neighbors (`this.sorted`, ordered by s): a2650 * cheap stand-in for a spatial query at pack scale. Binary-search the2651 * s-window, then scan it (capped — a mega-clump stops mattering past2652 * the nearest ranks). Allocation-free. */2653 private clearOfPack(r: Racer): boolean {2654 const arr = this.sorted;2655 const d2 = GHOST_CLEAR_D * GHOST_CLEAR_D;2656 // First rank whose s is inside the window (arr is sorted by s).2657 const loS = r.s - 0.5;2658 const hiS = r.s + 0.5;2659 let lo = 0;2660 let hi = arr.length;2661 while (lo < hi) {2662 const mid = (lo + hi) >> 1;2663 if (arr[mid].s < loS) lo = mid + 1;2664 else hi = mid;2665 }2666 for (let j = lo, seen = 0; j < arr.length && seen < 64; j++, seen++) {2667 const o = arr[j];2668 if (o.s > hiS) break;2669 if (o === r) continue;2670 const dx = o.pos[0] - r.pos[0];2671 const dy = o.pos[1] - r.pos[1];2672 const dz = o.pos[2] - r.pos[2];2673 if (dx * dx + dy * dy + dz * dz < d2) return false;2674 }2675 return true;2676 }26772678 /** Respawn a peer racer DISTRIBUTED across the pack's fill range (user2679 * rule: liveliness is distribution, not a clump — a culled segment once2680 * respawned every straggler onto one station). The range grows with2681 * the pack size, filling backward AND forward of the camera subject,2682 * clamped inside the built rails; each racer owns a deterministic slot2683 * (golden-ratio hash of its idx) plus a lane, so simultaneous respawns2684 * land spread out. No checkpoint escalation, no death counting. */2685 private respawnPeer(2686 r: Racer,2687 reason: Exclude<RailDebugReason, "lod-distance">,2688 ): void {2689 const engine = this.engine;2690 if (!engine) return;2691 const debugFrom = this.railDebug ? this.railDebugTile(r) : null;2692 let loS = Infinity;2693 let hiS = -Infinity;2694 for (const b of this.built.values()) {2695 loS = Math.min(loS, b.startS);2696 hiS = Math.max(hiS, b.startS + b.length);2697 }2698 const lo = loS + 2;2699 const hi = hiS - 6;2700 if (!(hi > lo)) return;2701 // Fill range: ~10u for a duo, growing with the pack, capped by the2702 // built window. 70% of it trails the camera subject, 30% runs ahead2703 // (traffic to catch AND to be caught by).2704 const span = Math.min(hi - lo, 10 + this.racers.length * 0.35);2705 const h = (((r.idx * 2654435761) >>> 0) % 1024) / 1024;2706 const heroS = this.racers[0]?.s ?? this.marbleS;2707 const at = Math.max(lo, Math.min(hi, heroS + (h - 0.7) * span));2708 // Fresh lane state: the respawn seats on the MAIN rail; if it lands2709 // inside a braid, the next decide re-draws the (deterministic) lane.2710 r.laneAlt = false;2711 r.laneBraid = -1;2712 r.draft = 1;2713 r.otkBlend = 0;2714 r.blockBlend = 0;2715 r.temperClock = 0;2716 // Pile-up telemetry (M20a): trailing-window respawn counter.2717 this.respawnTimes.push(this.windT);2718 while (2719 this.respawnTimes.length > 0 && this.respawnTimes[0] < this.windT - 42720 ) {2721 this.respawnTimes.shift();2722 }2723 const seg = this.segmentAt(at);2724 const rail = seg ? this.rails.get(seg.index) : undefined;2725 if (!seg || !rail) return;2726 let idx = 0;2727 while (idx < rail.s.length - 1 && rail.s[idx] < at - seg.startS) idx++;2728 if (debugFrom) {2729 this.logRailDebug(2730 reason,2731 r,2732 debugFrom,2733 this.railDebugTile(r, seg.index, idx, rail),2734 );2735 }2736 // Kinematic riders (physics LOD) redistribute by progress alone —2737 // no body to spawn; the next riderUpdate seats them on the rail.2738 if (r.lod === "rider") {2739 r.nearest = { seg: seg.index, idx };2740 r.s = seg.startS + rail.s[idx];2741 r.lastProgressS = r.s;2742 r.stallClock = 0;2743 return;2744 }2745 const t = rail.tans[idx];2746 const nl = Math.hypot(t[2], t[0]) || 1;2747 const lane = ((r.idx % 5) - 2) * 0.13;2748 // Compose the drop in PLAN space (lane beside the line, a hop above2749 // the floor), then present it through the station frame — reoriented2750 // respawns materialize beside their wall-riding pack.2751 const spawnPos: [number, number, number] = [2752 rail.pts[idx][0] + (t[2] / nl) * lane,2753 rail.pts[idx][1] + 0.4 + (r.idx % 7) * 0.12,2754 rail.pts[idx][2] + (-t[0] / nl) * lane,2755 ];2756 const spawnVel: [number, number, number] = [t[0] * 2.2, 0, t[2] * 2.2];2757 const sf = railFrameAt(rail, idx);2758 if (sf) {2759 affineApply(sf, spawnPos, spawnPos);2760 quatRotate(sf.q, spawnVel, spawnVel);2761 }2762 engine.spawnMarble(r.idx, spawnPos, spawnVel);2763 r.nearest = { seg: seg.index, idx };2764 r.s = seg.startS + rail.s[idx];2765 r.lastProgressS = r.s;2766 r.stallClock = 0;2767 r.stallHopped = false;2768 // Materialize gently: scale-in envelope + collision immunity until2769 // clear of the pack. setGhost must come AFTER spawnMarble — spawn2770 // resets the shim shape (and the engine's ghost cache) to solid.2771 r.spawnAnim = SPAWN_IN_T;2772 engine.setGhost(r.idx, true);2773 r.ghostT = GHOST_T;2774 }27752776 /** Tinted marble visuals for a real racer peer (mirrors the ring's2777 * buildVisual; scale stays MARBLE_SCALE so the mesh matches the shared2778 * physics radius). */2779 private buildRacerVisual(index: number): {2780 obj: THREE.Group;2781 pivot: THREE.Group;2782 materials: THREE.Material[];2783 baseEmissive: THREE.Color | null;2784 } {2785 const identity = racerIdentity(this.seed, index);2786 const obj = new THREE.Group();2787 obj.name = `racer:${index}`;2788 const pivot = new THREE.Group();2789 obj.add(pivot);2790 const materials: THREE.Material[] = [];2791 const mesh = this.assets.getPiece(identity.meshId) ??2792 this.assets.getPiece(MARBLE_PIECE);2793 if (mesh) {2794 mesh.scale.setScalar(MARBLE_SCALE);2795 // Empty kit shell → inject the opaque core (see marbleCoreMesh);2796 // the traverse below tints it and applies the phenotype effect2797 // like any other core material.2798 addMarbleCore(mesh);2799 // Always clone materials (boost glow needs its own emissive even2800 // on untinted marbles). The phenotype layers MULTIPLY over the2801 // base look: shell tints the color, the effect reshapes the2802 // material (gloss/pearl/ember/ghost at its intensity).2803 const tint = identity.tint;2804 const { effect } = identity;2805 mesh.traverse((child: THREE.Object3D) => {2806 const m = child as THREE.Mesh;2807 if (!m.isMesh) return;2808 const cloneOne = (mat: THREE.Material): THREE.Material => {2809 // The kit marble is core + shell: the "glass" material IS the2810 // shell — it becomes real transmission glass tinted by the2811 // phenotype (Beer–Lambert), while identity tint + effects2812 // stay on the opaque CORE the glass refracts.2813 if (mat.name === "glass") {2814 // STABLE transparent order per racer: overlapping shells at2815 // near-equal depth otherwise swap draw order frame-to-frame2816 // (reads as z-fighting when marbles press together).2817 m.renderOrder = 10 + index;2818 const g = glassShellMaterial(this.ctx.renderer, identity);2819 materials.push(g);2820 return g;2821 }2822 const clone = (mat as THREE.MeshStandardMaterial).clone();2823 if (tint) {2824 clone.color.setRGB(tint[0] / 255, tint[1] / 255, tint[2] / 255);2825 }2826 const i = effect.intensity;2827 if (effect.kind === "gloss") {2828 clone.roughness *= 1 - 0.7 * i;2829 clone.metalness = Math.min(1, clone.metalness + 0.35 * i);2830 clone.envMap = marbleEnv(this.ctx.renderer);2831 clone.envMapIntensity = 0.6;2832 } else if (effect.kind === "pearl") {2833 clone.roughness = 0.15;2834 clone.metalness = 0.5 * i;2835 clone.color.lerp(new THREE.Color(1, 1, 1), 0.25 * i);2836 clone.envMap = marbleEnv(this.ctx.renderer);2837 clone.envMapIntensity = 0.6;2838 } else if (effect.kind === "ember") {2839 clone.emissive = clone.color.clone().multiplyScalar(0.5 * i);2840 } else if (effect.kind === "ghost") {2841 clone.transparent = true;2842 clone.opacity = 1 - 0.45 * i;2843 }2844 materials.push(clone);2845 return clone;2846 };2847 m.material = Array.isArray(m.material)2848 ? m.material.map(cloneOne)2849 : cloneOne(m.material);2850 });2851 const box = new THREE.Box3().setFromObject(mesh);2852 const center = box.getCenter(new THREE.Vector3());2853 pivot.position.copy(center);2854 mesh.position.sub(center);2855 pivot.add(mesh);2856 }2857 this.ctx.scene.add(obj);2858 const baseEmissive = identity.effect.kind === "ember"2859 ? new THREE.Color().copy(2860 (materials[0] as THREE.MeshStandardMaterial | undefined)?.emissive ??2861 new THREE.Color(0, 0, 0),2862 )2863 : null;2864 return { obj, pivot, materials, baseEmissive };2865 }28662867 /** The rail a racer rides in a segment: the alt lane on its chosen2868 * braid segment, the main (branchOf-resolved) rail everywhere else.2869 * r.nearest.idx always indexes into THIS rail — every dereference of a2870 * racer's station must come through here. */2871 private railFor(r: Racer, segIdx: number): Rail | undefined {2872 // V3: a racer whose lane was drawn at the FORK segment keeps riding2873 // alt through the continuation segment (the boundary is mid-braid).2874 if (2875 r.laneAlt &&2876 (r.laneBraid === segIdx || this.braidContOf.get(segIdx) === r.laneBraid)2877 ) {2878 return this.altRails.get(segIdx) ?? this.rails.get(segIdx);2879 }2880 return this.rails.get(segIdx);2881 }28822883 /** Resolve a racer's dense station back to the authored tile/window. */2884 private railDebugTile(2885 r: Racer,2886 segIdx = r.nearest.seg,2887 stationIdx = r.nearest.idx,2888 railOverride?: Rail,2889 ): RailDebugTile {2890 const rail = railOverride ?? this.railFor(r, segIdx);2891 const station = rail?.pts.length2892 ? Math.max(0, Math.min(rail.pts.length - 1, stationIdx))2893 : Math.max(0, stationIdx);2894 const source: RailStationSource | undefined = rail?.sources?.[station];2895 return {2896 segment: segIdx,2897 station,2898 window: source?.window ?? null,2899 placementIndex: source?.placementIndex ?? null,2900 pieceId: source?.pieceId ?? null,2901 family: source?.family ?? null,2902 zone: source?.zone ?? null,2903 alt: source?.alt ?? false,2904 part: source?.part ?? null,2905 };2906 }29072908 /** Count each generated path window once, before streaming can cull it.2909 * This is the exposure denominator for candidate-event percentages. */2910 private recordRailDebugExposure(2911 data: ReturnType<EndlessWorld["get"]>,2912 ): void {2913 if (!this.railDebug) return;2914 for (const [window, pathSeg] of data.path.segments.entries()) {2915 const family = data.pieceFamilies[window] ?? "unknown";2916 const pieceId = pathSeg.placementIndex >= 02917 ? data.placements[pathSeg.placementIndex]?.pieceId ?? family2918 : family;2919 let stat = this.railDebugPieces.get(pieceId);2920 if (!stat) {2921 stat = {2922 pieceId,2923 family,2924 events: 0,2925 falls: 0,2926 stalls: 0,2927 generatedWindows: 0,2928 };2929 this.railDebugPieces.set(pieceId, stat);2930 }2931 stat.generatedWindows++;2932 this.railDebugGeneratedWindows++;2933 }2934 }29352936 /** Emit compact, rate-limited provenance. Recovery counts log at2937 * 1/2/4/8… so a repeatedly bad tile becomes obvious without a 10k-racer2938 * console flood. Intentional LOD handoffs log once per source tile. */2939 private logRailDebug(2940 reason: RailDebugReason,2941 r: Racer,2942 from: RailDebugTile,2943 to?: RailDebugTile,2944 ): void {2945 if (!this.railDebug) return;2946 const kind = reason === "lod-distance" ? "rail-handoff" : "recovery";2947 const tileCandidate = reason === "fall" || reason === "stall";2948 if (tileCandidate) {2949 this.railDebugCandidateEvents++;2950 const pieceId = from.pieceId ?? from.family ?? "unknown";2951 let stat = this.railDebugPieces.get(pieceId);2952 if (!stat) {2953 stat = {2954 pieceId,2955 family: from.family,2956 events: 0,2957 falls: 0,2958 stalls: 0,2959 generatedWindows: 0,2960 };2961 this.railDebugPieces.set(pieceId, stat);2962 }2963 stat.events++;2964 if (reason === "fall") stat.falls++;2965 else stat.stalls++;2966 }2967 const key = [2968 reason,2969 from.segment,2970 from.window,2971 from.pieceId ?? from.family,2972 from.alt ? "alt" : "main",2973 ].join("|");2974 const count = (this.railDebugCounts.get(key) ?? 0) + 1;2975 this.railDebugCounts.set(key, count);2976 const shouldLog = kind === "rail-handoff"2977 ? count === 12978 : (count & (count - 1)) === 0;2979 if (!shouldLog) return;2980 const event: RailDebugEvent = {2981 kind,2982 reason,2983 tileCandidate,2984 count,2985 seed: this.seed,2986 racer: r.idx,2987 s: Number(r.s.toFixed(2)),2988 from,2989 ...(to ? { to } : {}),2990 };2991 this.railDebugRecent.push(event);2992 if (this.railDebugRecent.length > 12) this.railDebugRecent.shift();2993 if (tileCandidate) console.warn("[rail-debug] tile candidate", event);2994 else console.info("[rail-debug] rail flow", event);2995 }29962997 /** Braid lane law (M17), run each decision: entering a braid segment2998 * draws the racer's own deterministic lane (braidLaneAlt — per-racer2999 * label-derived stream, hero included); and while inside one, a marble3000 * that collisions have physically shoved onto the OTHER lane re-picks3001 * it (the other rail's nearest station clearly closer) — the rail3002 * follows the marble, never fights it. */3003 private braidLaneFor(r: Racer): void {3004 const segIdx = r.nearest.seg;3005 if (!this.braidSegs.has(segIdx)) return;3006 if (r.laneBraid !== segIdx) {3007 // V3 continuation: the boundary is MID-braid — a racer whose lane3008 // was drawn at the fork keeps it (no re-draw, no teleport-flip).3009 const contOf = this.braidContOf.get(segIdx);3010 if (contOf !== undefined && r.laneBraid === contOf) {3011 r.laneBraid = segIdx;3012 this.updateNearestFor(r, r.pos);3013 return;3014 }3015 r.laneBraid = segIdx;3016 // Lane identity is CHARACTER (M20a): base pilot's threshold, never3017 // the rubber-banded scratch. Same label, same single draw. A3018 // continuation segment keys the draw on its FORK segment (one3019 // draw per BRAID, not per segment).3020 const labelSeg = contOf ?? segIdx;3021 let want = braidLaneAlt(this.seed, r.idx, labelSeg, r.pilot.lanePref);3022 // Lane mirroring (M20b): a nearby rival bends the choice — the3023 // grudge FOLLOWS its target's own label-pure draw, the ally takes3024 // the opposite (hunters chase, allies spread). No stream coupling:3025 // the mirrored lane is a pure function of (seed, targetIdx, seg),3026 // and the proximity gate is deterministic sim state.3027 const rv = r.rival;3028 const target = rv ? this.racers[rv.target] : undefined;3029 if (rv && target && Math.abs(target.s - r.s) < RIVAL_MIRROR_S) {3030 const theirs = braidLaneAlt(3031 this.seed,3032 rv.target,3033 labelSeg,3034 target.pilot.lanePref,3035 );3036 want = rv.valence === "grudge" ? theirs : !theirs;3037 }3038 if (want !== r.laneAlt) {3039 r.laneAlt = want;3040 // Re-anchor on the lane rail (pre-fork stations coincide, so the3041 // carried index is a valid scan anchor).3042 this.updateNearestFor(r, r.pos);3043 }3044 }3045 // Recovery: compare against the other lane near the same local s.3046 const cur = this.railFor(r, segIdx);3047 const other = r.laneAlt3048 ? this.rails.get(segIdx)3049 : this.altRails.get(segIdx);3050 if (!cur || !other || other === cur) return;3051 const cp = (cur.worldPts ?? cur.pts)[r.nearest.idx];3052 if (!cp) return;3053 const dCur = Math.hypot(3054 cp[0] - r.pos[0],3055 cp[1] - r.pos[1],3056 cp[2] - r.pos[2],3057 );3058 const sLocal = cur.s[r.nearest.idx];3059 let oi = 0;3060 let hi = other.s.length;3061 while (oi < hi) {3062 const mid = (oi + hi) >> 1;3063 if (other.s[mid] < sLocal) oi = mid + 1;3064 else hi = mid;3065 }3066 const pts = other.worldPts ?? other.pts;3067 let bestIdx = Math.min(oi, pts.length - 1);3068 let bestD = Infinity;3069 for (3070 let i2 = Math.max(0, oi - 24);3071 i2 <= Math.min(pts.length - 1, oi + 24);3072 i2++3073 ) {3074 const p = pts[i2];3075 const d = Math.hypot(3076 p[0] - r.pos[0],3077 p[1] - r.pos[1],3078 p[2] - r.pos[2],3079 );3080 if (d < bestD) {3081 bestD = d;3082 bestIdx = i2;3083 }3084 }3085 if (bestD + 0.6 < dCur) {3086 r.laneAlt = !r.laneAlt;3087 r.nearest = { seg: segIdx, idx: bestIdx };3088 }3089 }30903091 /** Nearest rail station to a racer, advancing across segments. */3092 private updateNearestFor(3093 r: Racer,3094 pos: readonly [number, number, number],3095 ): void {3096 let { seg, idx } = r.nearest;3097 if (!this.rails.has(seg)) {3098 seg = Math.min(...this.rails.keys());3099 idx = 0;3100 }3101 let bestSeg = seg;3102 let bestIdx = idx;3103 let bestD = Infinity;3104 const consider = (sIdx: number, from: number, to: number) => {3105 const rail = this.railFor(r, sIdx);3106 if (!rail) return;3107 // Reoriented rails compare in PRESENTED world space (worldPts is3108 // the frame-applied cache); identity rails' pts ARE world.3109 const pts = rail.worldPts ?? rail.pts;3110 const lo = Math.max(0, from);3111 const hi = Math.min(pts.length - 1, to);3112 for (let i = lo; i <= hi; i++) {3113 const r = pts[i];3114 const d = (r[0] - pos[0]) ** 2 + (r[1] - pos[1]) ** 2 +3115 (r[2] - pos[2]) ** 2;3116 if (d < bestD) {3117 bestD = d;3118 bestSeg = sIdx;3119 bestIdx = i;3120 }3121 }3122 };3123 consider(seg, idx - 25, idx + 50);3124 consider(seg + 1, 0, 40); // the boundary is always in reach3125 r.nearest = { seg: bestSeg, idx: bestIdx };3126 }31273128 /** Respawn at the last checkpoint (the current segment's entry), and3129 * after repeated deaths there, at the NEXT segment — forward progress is3130 * guaranteed even over broken geometry. */3131 private respawn(reason: "fall" | "stall"): void {3132 const hero = this.racers[0];3133 const debugFrom = this.railDebug && hero ? this.railDebugTile(hero) : null;3134 this.deaths++;3135 this.audio.cue("fall");3136 this.stallClock = 0;3137 this.stallHopped = false;3138 this.lastProgressS = 0;3139 let target = this.currentIndex;3140 if (this.lastRespawnSeg === target) {3141 this.respawnsHere++;3142 if (this.respawnsHere >= 2) {3143 target = this.currentIndex + 1;3144 console.warn(3145 `[endless] skipping past segment ${this.currentIndex} after ` +3146 `${this.respawnsHere} failed respawns (seed ${this.seed})`,3147 );3148 }3149 } else {3150 this.respawnsHere = 0;3151 this.lastRespawnSeg = target;3152 }3153 const cp = this.built.get(target) ??3154 this.built.get(this.currentIndex) ??3155 this.built.get(Math.min(...this.built.keys()));3156 if (!cp || !this.engine) return;3157 if (cp.index !== this.currentIndex) {3158 this.currentIndex = cp.index;3159 this.lastRespawnSeg = cp.index;3160 this.respawnsHere = 0;3161 }3162 // Respawn variety (M11): if the checkpoint segment carries a diamond,3163 // the retry rides the OTHER branch — the fall becomes a reveal.3164 // Braid segments are exempt (M17): their lanes are PER-RACER choices3165 // (braidLaneFor), and flipping the shared rail would fight them.3166 const data = this.world.get(cp.index);3167 if (hasAltBranch(data) && !hasBraid(data)) {3168 const next = this.branchOf.get(cp.index) === "alt" ? "main" : "alt";3169 this.branchOf.set(cp.index, next);3170 const swapped = railForSegment(data, next === "alt");3171 // The rebuilt branch rail must re-learn its presentation frames.3172 if (data.frame) attachFrames(swapped, data.frame);3173 this.rails.set(cp.index, swapped);3174 }3175 const rail = this.rails.get(cp.index);3176 if (!rail) return;3177 if (debugFrom && hero) {3178 this.logRailDebug(3179 reason,3180 hero,3181 debugFrom,3182 this.railDebugTile(hero, cp.index, 0, rail),3183 );3184 }3185 const t = rail.tans[0];3186 const spawnPos: [number, number, number] = [3187 rail.pts[0][0],3188 rail.pts[0][1] + 0.4,3189 rail.pts[0][2],3190 ];3191 const spawnVel: [number, number, number] = [t[0] * 2.2, 0, t[2] * 2.2];3192 const sf = railFrameAt(rail, 0);3193 if (sf) {3194 affineApply(sf, spawnPos, spawnPos);3195 quatRotate(sf.q, spawnVel, spawnVel);3196 }3197 this.engine.spawnMarble(0, spawnPos, spawnVel);3198 this.nearest = { seg: cp.index, idx: 0 };3199 this.marbleS = cp.startS;3200 if (hero) {3201 hero.nearest = { seg: cp.index, idx: 0 };3202 hero.laneAlt = false;3203 hero.laneBraid = -1;3204 hero.s = cp.startS;3205 hero.lastProgressS = cp.startS;3206 hero.stallClock = 0;3207 hero.stallHopped = false;3208 // Same materialization as peers: scale-in + ghost-until-clear3209 // (setGhost AFTER spawnMarble — spawn resets the shape to solid).3210 hero.spawnAnim = SPAWN_IN_T;3211 this.engine.setGhost(0, true);3212 hero.ghostT = GHOST_T;3213 }3214 }32153216 /** M25: a hard knock tips nearby trackside furniture — fans, banners3217 * and bumper mushrooms fall over and STAY fallen until their segment3218 * sinks out. The tip axis is horizontal, perpendicular to the shove3219 * (away from the hit), eased in finishFrame. */3220 private tipFurnitureNear(3221 seg: BuiltSegment,3222 x: number,3223 y: number,3224 z: number,3225 ): void {3226 const KNOCKABLE = /^(fan-|banner|mushroom_red$)/;3227 for (const group of [seg.group, seg.postGroup]) {3228 if (!group) continue;3229 for (const obj of group.children) {3230 if (!KNOCKABLE.test(obj.name) || obj.userData.tipped) continue;3231 const p = obj.getWorldPosition(this.trailPoint);3232 const dx = p.x - x;3233 const dz = p.z - z;3234 const d2 = dx * dx + (p.y - y) * (p.y - y) + dz * dz;3235 if (d2 > 0.8 * 0.8) continue;3236 obj.userData.tipped = true;3237 // Axis ⟂ the horizontal shove direction: the piece falls AWAY.3238 const hl = Math.hypot(dx, dz) || 1;3239 this.tipping.push({3240 obj,3241 axis: new THREE.Vector3(dz / hl, 0, -dx / hl),3242 target: -(Math.PI / 2) * 0.82,3243 t: 0,3244 });3245 break;3246 }3247 }3248 }32493250 private finishFrame(dt: number): void {3251 // Trail follows the CAMERA SUBJECT's ball center (the hero by3252 // default; the leader when leader-cam is elected) — a trail on an3253 // off-screen marble is a trail nobody sees.3254 this.followedPivot.getWorldPosition(this.trailPoint);3255 this.trail.update(this.trailPoint, this.ctx.camera);3256 const followed = this.racers[this.followedIdx];3257 if (followed?.boost.boosting) {3258 if (!this.boostTrailActive) this.boostTrail.reset();3259 this.boostTrailActive = true;3260 this.boostTrailFade = 1;3261 this.boostTrail.update(this.trailPoint, this.ctx.camera);3262 } else {3263 this.boostTrailActive = false;3264 this.boostTrailFade = Math.max(0, this.boostTrailFade - dt * 4);3265 this.boostTrail.fade(this.ctx.camera, this.boostTrailFade);3266 }3267 // The marble's rolling voice follows its speed.3268 this.audio.setSpeed(this.speed);32693270 for (const built of this.built.values()) {3271 for (const sp of built.spinners) {3272 sp.obj.rotateOnAxis(sp.axis, sp.radPerSec * dt);3273 }3274 // Void decor float pass (M18b): only after the assemble wave —3275 // SpawnAnim owns instance matrices while a segment is spawning3276 // (writer overlap would fight the wave), and the FloatBuild's own3277 // envelope ramps the bob in from the handoff.3278 if (!this.spawning.has(built.index)) {3279 for (const f of built.floats) f.tick(this.windT);3280 }3281 }32823283 // M25 WORLD MEMORY: drain the step's marks. Every mark parents to3284 // its racer's current segment group — it sinks out with the segment3285 // (ExitAnim) and its GPU state is shared (geometry/material live for3286 // the session). Generation never knows: this is presentation history.3287 if (this.pendingScars.length > 0) {3288 this.scarGeo ??= new THREE.SphereGeometry(1, 8, 6);3289 this.scarMat ??= new THREE.MeshStandardMaterial({3290 color: 0x2b2b31,3291 roughness: 0.95,3292 });3293 this.scuffMat ??= new THREE.MeshStandardMaterial({3294 color: 0xbfd4e6,3295 roughness: 0.9,3296 transparent: true,3297 opacity: 0.55,3298 });3299 for (const scar of this.pendingScars) {3300 const r = this.racers[scar.idx];3301 const segIdx = r ? r.nearest.seg : this.currentIndex;3302 const seg = this.built.get(segIdx);3303 if (!seg) continue;3304 const count = this.scarCount.get(segIdx) ?? 0;3305 if (count >= 30) continue;3306 const knock = scar.speed > 0;3307 const mesh = new THREE.Mesh(3308 this.scarGeo,3309 knock ? this.scarMat : this.scuffMat,3310 );3311 const s = knock ? Math.min(0.16, 0.07 + scar.speed * 0.012) : 0.06;3312 mesh.scale.set(s, s * 0.35, s);3313 // Segment groups may carry a presentation frame — parent in the3314 // group's LOCAL space.3315 seg.group.worldToLocal(3316 mesh.position.set(scar.x, scar.y, scar.z),3317 );3318 mesh.castShadow = false;3319 mesh.userData.voidDecor = true; // skip the fit box3320 seg.group.add(mesh);3321 this.scarCount.set(segIdx, count + 1);3322 // A hard knock near trackside furniture TIPS it (fans, banners,3323 // bumper mushrooms) — the wreck stays until the segment sinks.3324 if (knock) this.tipFurnitureNear(seg, scar.x, scar.y, scar.z);3325 }3326 this.pendingScars.length = 0;3327 }3328 // Furniture mid-tip animations ease to their fallen pose.3329 for (let i = this.tipping.length - 1; i >= 0; i--) {3330 const tip = this.tipping[i];3331 tip.t = Math.min(1, tip.t + dt * 2.2);3332 const k = 1 - (1 - tip.t) * (1 - tip.t); // ease-out3333 tip.obj.setRotationFromAxisAngle(tip.axis, tip.target * k);3334 if (tip.t >= 1) this.tipping.splice(i, 1);3335 }33363337 // Stream animations: assembly waves ahead, sink-out behind.3338 for (const [idx, anim] of [...this.spawning]) {3339 if (anim.update(dt)) this.spawning.delete(idx);3340 }3341 this.exiting = this.exiting.filter(({ group, anim, disposables }) => {3342 if (!anim.update(dt)) return true;3343 this.root.remove(group);3344 group.clear();3345 for (const d of disposables) d.dispose();3346 return false;3347 });33483349 this.toast.update(dt);33503351 // Minimal leaderboard: `#place/N`, hero vs. everyone, throttled —3352 // hidden unless a real race is on (peers exist).3353 this.placeClock += dt;3354 if (this.placeClock >= PLACE_REFRESH) {3355 this.placeClock = 0;3356 const hero = this.racers[0];3357 if (hero && this.racers.length > 1) {3358 // Rival readout (M20b): flag-gated (?rival=1) while the default3359 // is decided at milestone review — one quiet token, grudge +3360 // rival-live only, inheriting the Kenney frame styling.3361 let rivalPlace: number | undefined;3362 if (this.rivalHud && hero.rival?.valence === "grudge") {3363 const target = this.racers[hero.rival.target];3364 if (target) {3365 rivalPlace = racePlace(target.s, this.racers.map((r) => r.s));3366 }3367 }3368 this.placeHud.set(3369 racePlace(hero.s, this.racers.map((r) => r.s)),3370 this.racers.length,3371 rivalPlace,3372 );3373 } else this.placeHud.hide();3374 }33753376 // The follow camera reads marble.s off the proxy we handed it.3377 this.syncCameraProxy();33783379 this.statsClock += dt;3380 if (this.statsClock > 0.5) {3381 this.statsClock = 0;3382 this.refreshStats();3383 }3384 }33853386 private cameraProxy:3387 | {3388 obj: THREE.Object3D;3389 s: number;3390 speed: number;3391 radius: number;3392 update: (dt: number) => void;3393 }3394 | null = null;33953396 /** Path-parameterized camera s — the followed marble's progress (only3397 * differs from marbleS under leader-cam). */3398 private camS = 0;33993400 private syncCameraProxy(): void {3401 if (!this.cameraProxy) return;3402 this.cameraProxy.s = this.engine ? this.camS : this.marbleS;3403 this.cameraProxy.speed = this.speed;3404 }34053406 dispose(): void {3407 // Racer peers (M15): visuals + bodies go with the session.3408 for (const r of this.racers) {3409 if (r.idx !== 0 && r.obj) {3410 disposeMarbleOwnedVisuals(r.obj);3411 this.ctx.scene.remove(r.obj);3412 r.obj.clear();3413 }3414 for (const m of r.materials) m.dispose();3415 this.engine?.destroyMarble(r.idx);3416 }3417 disposeMarbleOwnedVisuals(this.marbleObj);3418 this.engine?.dispose();3419 this.engine = null;3420 if (this.peerMesh) {3421 this.ctx.scene.remove(this.peerMesh);3422 this.peerMesh.geometry.dispose();3423 (this.peerMesh.material as THREE.Material).dispose();3424 this.peerMesh = null;3425 }3426 if (this.peerShellMesh) {3427 this.ctx.scene.remove(this.peerShellMesh);3428 this.peerShellMesh.geometry.dispose();3429 (this.peerShellMesh.material as THREE.Material).dispose();3430 this.peerShellMesh = null;3431 }3432 this.racers = [];3433 // Session teardown is immediate — no exit waves on a seed change.3434 for (const seg of [...this.built.values()]) {3435 this.root.remove(seg.group);3436 seg.group.clear();3437 if (seg.postGroup) {3438 this.root.remove(seg.postGroup);3439 seg.postGroup.clear();3440 }3441 for (const d of seg.disposables) d.dispose();3442 }3443 this.built.clear();3444 this.spawning.clear();3445 this.fanField.clear();3446 for (const { group, disposables } of this.exiting) {3447 this.root.remove(group);3448 group.clear();3449 for (const d of disposables) d.dispose();3450 }3451 this.exiting = [];3452 this.atmosphere.dispose();3453 this.peerRing.dispose();3454 this.toast.dispose();3455 this.placeHud.dispose();3456 this.scarGeo?.dispose();3457 this.scarMat?.dispose();3458 this.scuffMat?.dispose();3459 this.scarGeo = null;3460 this.scarMat = null;3461 this.scuffMat = null;3462 this.ctx.scene.remove(this.root);3463 this.ctx.scene.remove(this.marbleObj);3464 this.ctx.scene.remove(this.trail.obj);3465 this.trail.dispose();3466 this.ctx.scene.remove(this.boostTrail.obj);3467 this.boostTrail.dispose();3468 this.cams.retargetPath(null, null);3469 }34703471 private refreshStats(): void {3472 if (!this.ui.statsEl) return;3473 const y = this.marbleObj.position.y;3474 const weather = this.atmosphere.weatherKind;3475 const phys = this.engine3476 ? ` • physics${this.deaths > 0 ? ` (${this.deaths} falls)` : ""}`3477 : "";3478 this.ui.statsEl.textContent = `endless • ${this.world.style.theme} • ` +3479 `${this.atmosphere.name}${weather === "none" ? "" : `+${weather}`} • ` +3480 `${this.marbleS.toFixed(0)}u travelled • depth ${y.toFixed(1)} • ` +3481 `${this.islandsSeen} islands • seg ${this.currentIndex}${phys} • ` +3482 `${genVersion()}`;3483 }3484}34853486function countNodes(scene: THREE.Scene): number {3487 let n = 0;3488 scene.traverse(() => n++);3489 return n;3490}34913492function makeStarfield(): THREE.Points {3493 const N = 1600;3494 const positions = new Float32Array(N * 3);3495 for (let i = 0; i < N; i++) {3496 // Uniform directions, radii pushed outward so stars sit past the fog.3497 const v = new THREE.Vector3(3498 Math.random() * 2 - 1,3499 Math.random() * 2 - 1,3500 Math.random() * 2 - 1,3501 );3502 if (v.lengthSq() < 1e-4) v.set(0, 1, 0);3503 v.normalize().multiplyScalar(130 + Math.random() * 160);3504 positions[i * 3] = v.x;3505 positions[i * 3 + 1] = v.y;3506 positions[i * 3 + 2] = v.z;3507 }3508 const geom = new THREE.BufferGeometry();3509 geom.setAttribute("position", new THREE.BufferAttribute(positions, 3));3510 const mat = new THREE.PointsMaterial({3511 color: 0xc7d5ff,3512 size: 1.6,3513 sizeAttenuation: false,3514 transparent: true,3515 opacity: 0.85,3516 depthWrite: false,3517 fog: false,3518 });3519 const points = new THREE.Points(geom, mat);3520 points.name = "starfield";3521 points.renderOrder = -1;3522 return points;3523}