clone

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

main
marbles / src / client / physics / rail.ts
rail.ts 19.7K
1/**2 * The shared rail law (M11) — ONE truth for the client driver, the headless3 * survivability tests, and the phys-debug tools.4 *5 * A rail is a dense polyline over a segment's world-space path (points,6 * tangents, cumulative s) with a per-station physics modifier resolved from7 * the per-tile channel: piece families carry movement character (helix =8 * banked spiral, ride it faster; chutes pull harder) and planner set-piece9 * zones outrank families (a boost-chute hurls; a jump gap is hands-off10 * ballistic flight).11 *12 * `stationForce` is the entire per-step control law: nearest-station13 * lookahead (steepest climb ahead + curvature ahead), the assist force, and14 * the modifier extras (gravity scaling), combined into one world-space15 * force vector. Drivers differ only in how they find the nearest station.16 */17import type { LoopPath } from "../../shared/types.ts";18import { segLength, segPoint } from "../../gen/geom.ts";19import { assistForce, GRAVITY_Y, MARBLE_RESTITUTION } from "./engine.ts";20import {21 type Affine,22 affineApply,23 isIdentityAffine,24 quatRotate,25 type SegmentFrame,26 segmentFrameAt,27 twistParam,28} from "../../shared/frame.ts";2930/** Resolved per-station physics modifier. */31export interface RailMod {32 /** Scales the assist's target/safe speeds (and its authority). */33 speed?: number;34 /** Lateral centering spring toward the rail line (split windows: a35 * marble still oscillating from the branch bend wedges on the merge36 * wedge-slab edge — fuzz-0 parked at the mouth seam). Boost zones get37 * this implicitly via speed > 1. */38 center?: boolean;39 /** Gravity multiplier while on the tile (chutes pull harder). */40 grav?: number;41 /** Ballistic flight (jump gaps): the assist is hands-off entirely. */42 flight?: boolean;43 /** Flight gravity scale (propeller zones: Marble Blast helicopter —44 * an upward force cancels this fraction of gravity mid-flight). */45 gravScale?: number;46 /** Marble restitution override (shock-absorber zones at hard landings). */47 restitution?: number;48 /** Low-grip surface (icy zones): the assist's authority drops to a49 * fraction — the marble carries its momentum and slides. */50 icy?: boolean;51 /** Gravity-rotation zones (M13): world gravity TILTS forward along the52 * station's travel direction by this many radians — the void's gravity53 * leans and the marble surges down a run that is geometrically flat.54 * The driver applies it via PhysicsEngine.setGravity per step. */55 tilt?: number;56}5758export interface Rail {59 pts: [number, number, number][];60 tans: [number, number, number][];61 /** Cumulative arc length, starting at 0 for this rail. */62 s: number[];63 mods: (RailMod | null)[];64 /** Authoring provenance aligned 1:1 with the dense stations. Debug65 * recovery logs use this to name the exact realized tile/window that66 * handed a marble to the kinematic rail or respawn net. Older synthetic67 * test rails may omit it. */68 sources?: RailStationSource[];69 /** Gravity-reorientation frames (M16): pts/tans/mods stay in PLAN70 * space (+y up — the control laws' one truth); each station's affine71 * maps plan → presented world. Undefined = identity (the common case);72 * a single Affine = the whole rail shares one frame; an array = a73 * twist zone varies along the rail. Drivers conjugate marble state74 * through the frame — the autopilot never sees a wall or ceiling. */75 frames?: Affine | (Affine | null)[];76 /** Cached presented-world station points (filled by attachFrames) —77 * nearest-station search and respawns compare in world space. */78 worldPts?: [number, number, number][];79}8081/** Authored source of one dense rail station. */82export interface RailStationSource {83 /** Path/window index within the endless segment. */84 window: number;85 /** Generator family selected for that window (straight, slant, ...). */86 family: string;87 /** Planner set-piece tag, including a per-part alt-lane override. */88 zone: string | null;89 /** Index into the segment's authored placements, or -1 for shared mesh. */90 placementIndex: number;91 /** Concrete GLB piece id when the placement is addressable. */92 pieceId: string | null;93 /** Whether this station follows the alternate branch/lane. */94 alt: boolean;95 /** Curve-part index within the selected window/lane. */96 part: number;97}9899/** Attach a segment's presentation frames to its rail: per-station100 * affines (a partial roll inside the twist run) + cached world points.101 * Rails without reorient history skip this entirely. */102export function attachFrames(rail: Rail, f: SegmentFrame): void {103 if (!f.twist) {104 if (isIdentityAffine(f.entry)) return;105 rail.frames = f.entry;106 rail.worldPts = rail.pts.map((p) =>107 affineApply(f.entry, p) as [number, number, number]108 );109 return;110 }111 const frames: Affine[] = [];112 const worldPts: [number, number, number][] = [];113 for (const p of rail.pts) {114 const fr = segmentFrameAt(f, twistParam(f, p));115 frames.push(fr);116 worldPts.push(affineApply(fr, p) as [number, number, number]);117 }118 rail.frames = frames;119 rail.worldPts = worldPts;120}121122/** Station frame (plan → world), or null for identity. */123export function railFrameAt(rail: Rail, idx: number): Affine | null {124 const f = rail.frames;125 if (!f) return null;126 if (Array.isArray(f)) return f[idx] ?? null;127 return isIdentityAffine(f) ? null : f;128}129130/** Station point in presented WORLD space (respawns, camera, streaming). */131export function stationWorldPoint(132 rail: Rail,133 idx: number,134 out: [number, number, number] = [0, 0, 0],135): [number, number, number] {136 const w = rail.worldPts?.[idx];137 if (w) {138 out[0] = w[0];139 out[1] = w[1];140 out[2] = w[2];141 return out;142 }143 const p = rail.pts[idx];144 const f = railFrameAt(rail, idx);145 if (!f) {146 out[0] = p[0];147 out[1] = p[1];148 out[2] = p[2];149 return out;150 }151 return affineApply(f, p, out);152}153154/** Station tangent in presented WORLD space. */155export function stationWorldTan(156 rail: Rail,157 idx: number,158 out: [number, number, number] = [0, 0, 0],159): [number, number, number] {160 const t = rail.tans[idx];161 const f = railFrameAt(rail, idx);162 if (!f) {163 out[0] = t[0];164 out[1] = t[1];165 out[2] = t[2];166 return out;167 }168 return quatRotate(f.q, t, out);169}170171/**172 * Per-tile movement character by piece family (Marble Blast-inspired).173 * The same channel carries icy/boost/bouncer surfaces and island gravity174 * rotation later.175 */176const TILE_MODS: Record<string, RailMod> = {177 helix: { speed: 1.35 },178 slant: { grav: 1.2 },179 ramp: { grav: 1.15 },180 // The rail follows the branch lane through a diamond, so centering onto181 // it is correct along the whole window — and it keeps merge arrivals off182 // the wedge-slab edge seam.183 split: { center: true },184};185186/** Planner zone kinds → rail modifiers. Zones outrank family character. */187const ZONE_MODS: Record<string, RailMod> = {188 boost: { speed: 1.9, grav: 1.2 },189 jump: { flight: true },190 // Propeller-jump: the helicopter trick — gravity drops to 0.35 during191 // the (longer) flight, so the same launch speed clears a 2-cell gap.192 propeller: { flight: true, gravScale: 0.35 },193 // Shock absorber at hard-landing zones (Marble Blast: 0.01) — the194 // driver applies it to the marble while the nearest station is inside.195 shock: { restitution: 0.02 },196 // Icy patch: grip is what the assist models — on ice it barely has any.197 icy: { icy: true },198 // Gravity rotation: ~17° forward lean (20·sin ≈ 5.9 u/s² of pull on a199 // flat run). The assist stays silent between cruise and the 9 u/s200 // envelope cap, so gravity itself is what hurls the marble — and the201 // centering spring keeps the surge straight.202 gravity: { tilt: 0.3, center: true },203 // Takeable spur window: centering keeps main-line marbles out of the204 // branch mouth (the composite opens one wall there) and branch riders205 // square on the diving board.206 spur: { center: true },207 // Braid fork/merge junctions (M17): same law — main-lane marbles stay208 // out of the open branch mouths, alt riders hold their pull-off line.209 // (The fork window's mod rides the WHOLE alt chain for alt riders —210 // centering keeps them on the wandering lane.)211 braid: { center: true },212 // Reorient run (M16): the channel corkscrews around the centerline —213 // plan-space centering (rotated to world by the frame) holds the214 // marble on the line while the floor rolls under it.215 reorient: { center: true },216};217218/** The station spacing every driver samples at. */219export const RAIL_STEP = 0.5;220221/** The segment fields the rail law needs (EndlessSegment satisfies it). */222export interface RailSource {223 path: LoopPath;224 pieceFamilies: string[];225 windowMods: (string | null)[];226 /** Optional for small synthetic law tests; EndlessSegment supplies it. */227 placements?: readonly { pieceId: string }[];228}229230/** Path-segment indices that are jump gaps — the collision proxies emit no231 * channel there (absence of geometry IS the gap). The law lives with the232 * proxy builder (T7 parity); re-exported here for the drivers. */233export { gapSegmentsOf } from "../../gen/endless/proxies.ts";234235/** True if any window in the segment carries a diamond's other branch. */236export function hasAltBranch(seg: RailSource): boolean {237 return seg.path.segments.some((ls) => ls.alt !== undefined);238}239240/** True if the segment hosts a braid (M17). Braid segments use PER-RACER241 * lane choice — the client's global respawn branch-flip (branchOf) must242 * ignore them (that machinery stays for diamonds/spurs). */243export function hasBraid(seg: RailSource): boolean {244 return seg.path.segments.some((ls) => ls.alt?.braid === true);245}246247/** Dense rail for one segment (local s from 0). `useAlt` rides every248 * diamond's OTHER branch — the respawn-variety rail. A takeable SPUR's249 * alt ends diving through the hole; the stations then SKIP the main250 * hook and resume at the receiver window passing directly under the251 * hole (Δ 1.0 by construction) — station order matches what the falling252 * marble physically does, so the wishdir look target never points back253 * up at geometry the rider skipped. */254export function railForSegment(seg: RailSource, useAlt = false): Rail {255 const rail: Rail = { pts: [], tans: [], s: [], mods: [], sources: [] };256 let acc = 0;257 /** Set after a spur dive: skip windows until one passes this point. */258 let resumeNear: readonly [number, number, number] | null = null;259 /** Set after a braid alt chain (which runs through to the MERGE exit260 * port): skip the main windows underneath the braid until the window261 * that STARTS at that port — ports are exact, so the match is tight. */262 let resumeAtStart: readonly [number, number, number] | null = null;263 const approaches = (264 curve: readonly LoopPath["segments"][number]["curve"][number][],265 p: readonly [number, number, number],266 ): boolean =>267 curve.some((part) =>268 [0, 0.5, 1].some((t) => {269 const q = segPoint(part, t);270 return Math.hypot(q[0] - p[0], q[1] - p[1], q[2] - p[2]) < 0.6;271 })272 );273 for (const [lsIdx, ls] of seg.path.segments.entries()) {274 if (resumeAtStart) {275 const p0 = segPoint(ls.curve[0], 0);276 if (277 Math.hypot(278 p0[0] - resumeAtStart[0],279 p0[1] - resumeAtStart[1],280 p0[2] - resumeAtStart[2],281 ) > 0.3282 ) continue;283 resumeAtStart = null;284 }285 if (resumeNear) {286 if (!approaches(ls.curve, resumeNear)) continue;287 resumeNear = null;288 }289 const zone = seg.windowMods[lsIdx];290 const mod = (zone ? ZONE_MODS[zone] : undefined) ??291 TILE_MODS[seg.pieceFamilies[lsIdx]] ?? null;292 const alt = useAlt && ls.alt;293 const parts = alt ? ls.alt!.curve : ls.curve;294 for (const [partIdx, part] of parts.entries()) {295 // Braid lane mods (M19b): per-part zone tags OVERLAY the host296 // window's mod on the alt chain — the fork's centering still rides297 // the whole lane (keeps wishdir riders on the wander), the tag298 // adds its surface on top. Untagged chains (diamonds, spurs, V1299 // braids) are byte-identical to before.300 const altZone = alt ? ls.alt!.zones?.[partIdx] : undefined;301 const partMod = altZone ? { ...(mod ?? {}), ...ZONE_MODS[altZone] } : mod;302 const plen = segLength(part);303 const steps = Math.max(1, Math.ceil(plen / RAIL_STEP));304 for (let j = 0; j < steps; j++) {305 const a = segPoint(part, j / steps);306 const b = segPoint(part, Math.min(1, (j + 1) / steps));307 const d = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];308 const l = Math.hypot(d[0], d[1], d[2]) || 1;309 rail.pts.push([a[0], a[1], a[2]]);310 rail.tans.push([d[0] / l, d[1] / l, d[2] / l]);311 rail.s.push(acc);312 rail.mods.push(partMod);313 rail.sources!.push({314 window: lsIdx,315 family: seg.pieceFamilies[lsIdx] ?? "unknown",316 zone: altZone ?? zone ?? null,317 placementIndex: ls.placementIndex,318 pieceId: ls.placementIndex >= 0319 ? seg.placements?.[ls.placementIndex]?.pieceId ?? null320 : null,321 alt: Boolean(alt),322 part: partIdx,323 });324 acc += l;325 }326 }327 if (alt && ls.alt!.spur) {328 const lastPart = ls.alt!.curve[ls.alt!.curve.length - 1];329 const tip = segPoint(lastPart, 1);330 resumeNear = [tip[0], tip[1], tip[2]];331 } else if (alt && ls.alt!.braid) {332 const lastPart = ls.alt!.curve[ls.alt!.curve.length - 1];333 const tip = segPoint(lastPart, 1);334 resumeAtStart = [tip[0], tip[1], tip[2]];335 }336 }337 return rail;338}339340/** Chain per-segment rails into one (headless drivers). Presentation341 * frames (M16) survive the concat: any framed input promotes the output342 * to per-station frames (null = identity) + world points. */343export function concatRails(rails: readonly Rail[]): Rail {344 const sourced = rails.every((r) => r.sources !== undefined);345 const out: Rail = {346 pts: [],347 tans: [],348 s: [],349 mods: [],350 sources: sourced ? [] : undefined,351 };352 let base = 0;353 const framed = rails.some((r) => r.frames !== undefined);354 if (framed) {355 out.frames = [];356 out.worldPts = [];357 }358 for (const r of rails) {359 out.pts.push(...r.pts);360 out.tans.push(...r.tans);361 for (const s of r.s) out.s.push(base + s);362 out.mods.push(...r.mods);363 if (sourced) out.sources!.push(...r.sources!);364 if (framed) {365 const frames = out.frames as (Affine | null)[];366 for (let i = 0; i < r.pts.length; i++) {367 frames.push(railFrameAt(r, i));368 out.worldPts!.push(369 r.worldPts?.[i] ?? (r.pts[i].slice() as [number, number, number]),370 );371 }372 }373 if (r.s.length > 0) base += r.s[r.s.length - 1] + RAIL_STEP;374 }375 return out;376}377378/** Marble restitution the station calls for (shock zones override the379 * default; drivers pass this to PhysicsEngine.setRestitution per step —380 * the engine caches, so repeats are free). */381export function stationRestitution(rail: Rail, idx: number): number {382 return rail.mods[idx]?.restitution ?? MARBLE_RESTITUTION;383}384385/** World gravity vector the station calls for (gravity-rotation zones386 * tilt it forward along the station's horizontal travel direction; every387 * other station keeps plain -Y). Drivers pass this to388 * PhysicsEngine.setGravity per step — the engine caches. */389export function stationGravity(390 rail: Rail,391 idx: number,392): [number, number, number] {393 const tilt = rail.mods[idx]?.tilt;394 const t = rail.tans[idx];395 if (!tilt || !t) return [0, GRAVITY_Y, 0];396 const hl = Math.hypot(t[0], t[2]);397 if (hl < 1e-6) return [0, GRAVITY_Y, 0];398 const g = -GRAVITY_Y; // magnitude399 const fwd = g * Math.sin(tilt) / hl;400 return [t[0] * fwd, GRAVITY_Y * Math.cos(tilt), t[2] * fwd];401}402403/** Per-marble gravity-zone force (M15): with N marbles in one world the404 * global gravity vector stays fixed at [0, GRAVITY_Y, 0]; a marble whose405 * station calls for tilted gravity gets m·(g_zone − g_world) as an extra406 * force — identical acceleration, correct per marble. Reoriented frames407 * (M16) compose here: the zone gravity (plan space, including any tilt)408 * rotates through the station frame, so a wall-riding marble is pulled409 * INTO its local floor while world gravity stays put. */410export function stationGravityDelta(411 rail: Rail,412 idx: number,413 mass: number,414): [number, number, number] | null {415 const g = stationGravity(rail, idx);416 const f = railFrameAt(rail, idx);417 if (f) quatRotate(f.q, g, g);418 if (g[0] === 0 && g[1] === GRAVITY_Y && g[2] === 0) return null;419 return [g[0] * mass, (g[1] - GRAVITY_Y) * mass, g[2] * mass];420}421422/** Stations of rail lookahead (≈4 world units at RAIL_STEP). */423const LOOKAHEAD = 8;424425/**426 * The complete per-step control law at a station: assist toward the local427 * tangent (sized by the steepest climb ahead, braked by the curvature428 * ahead, scaled by the station's modifier) plus modifier gravity extras,429 * plus a lateral centering spring in boost zones — a launch is only as430 * straight as its run-up, and a marble still oscillating from an earlier431 * bend leaves the lip diagonally and misses any catch (abs-1: −1.9 u/s432 * lateral at launch drifted 1.4 world off the pan). Returns one433 * world-space force or null. `nextRail` chains lookahead across a434 * segment boundary (client drivers keep per-segment rails).435 */436export function stationForce(437 rail: Rail,438 idx: number,439 pos: readonly [number, number, number],440 vel: readonly [number, number, number],441 mass: number,442 nextRail: Rail | null = null,443): [number, number, number] | null {444 const tanAt = (i: number): [number, number, number] | null =>445 rail.tans[i] ??446 (nextRail ? nextRail.tans[i - rail.tans.length] ?? null : null);447 const t = rail.tans[idx];448 if (!t) return null;449 const mod = rail.mods[idx];450 // Jump gaps: ballistic flight — no assist (the launch speed was priced451 // at plan time; steering mid-flight would change where it lands). A452 // propeller zone's gravity scaling is part of that pricing: a constant453 // upward force cancels (1 − gravScale) of gravity for a longer, floatier454 // arc over a wider gap.455 if (mod?.flight) {456 if (mod.gravScale !== undefined && mod.gravScale !== 1) {457 return [0, (1 - mod.gravScale) * -GRAVITY_Y * mass, 0];458 }459 return null;460 }461 let climbTy = t[1];462 let curveAhead = 0;463 for (let k = 1; k <= LOOKAHEAD; k++) {464 const look = tanAt(idx + k);465 const prev = tanAt(idx + k - 1);466 if (!look || !prev) break;467 climbTy = Math.max(climbTy, look[1]);468 const dot = Math.min(469 1,470 look[0] * prev[0] + look[1] * prev[1] + look[2] * prev[2],471 );472 curveAhead += Math.acos(Math.max(-1, dot));473 }474 const f = assistForce(vel, t, mass, climbTy, curveAhead, mod?.speed ?? 1) ??475 [0, 0, 0];476 // Icy: the assist is grip — on ice it keeps only a sliver of authority477 // (enough that the line still progresses; the slide is the spectacle).478 const grip = mod?.icy ? 0.3 : 1;479 const out: [number, number, number] = [480 f[0] * grip,481 f[1] * grip,482 f[2] * grip,483 ];484 // Lateral centering in boost zones (straight by construction) and split485 // windows: a spring-damper toward the rail line kills wall-to-wall486 // oscillation so the marble launches true / merges clean.487 if ((mod?.speed && mod.speed > 1) || mod?.center) {488 const r = rail.pts[idx];489 const nl = Math.hypot(t[2], t[0]) || 1;490 const ux = t[2] / nl;491 const uz = -t[0] / nl;492 const off = (pos[0] - r[0]) * ux + (pos[2] - r[2]) * uz;493 const lvel = vel[0] * ux + vel[2] * uz;494 const a = Math.max(-14, Math.min(14, -40 * off - 12 * lvel));495 out[0] += ux * a * mass;496 out[2] += uz * a * mass;497 }498 // Extra pull ONLY on descending stations — grav mods mean "chutes pull499 // harder", never "fight the climb carry" (a ramp tile is the same family500 // going up; extra gravity there wedged marbles at climb bases).501 if (mod?.grav && mod.grav !== 1 && t[1] < -0.05 && climbTy < 0.12) {502 out[1] += (mod.grav - 1) * GRAVITY_Y * mass;503 }504 return out[0] === 0 && out[1] === 0 && out[2] === 0 ? null : out;505}