clone

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

M11: sprint runs + bump lips (a/b tiers in boost zones), catch-mouth ribbon flare, lateral centering in boost zones

Dami committed 16d0130
commit
16d0130224295d01b4961f5ef830b5bacfffaedd
parent
2bacc996ba23267b0b1c606a526b17f3090da42b
author
Dami <dami.etoile@gmail.com>
8 files changed, +140 -44
Msrc/client/endless.ts1+
Msrc/client/physics/rail.ts25++++++++-
Msrc/gen/endless/path.ts20++++++++
Msrc/gen/endless/proxies.ts79+++++++++++++++++-------------
Msrc/gen/endless/world.ts24+++++++--
Mtests/physics_test.ts2+-
Mtests/scenario_test.ts31++++++++++++
Mtools/physics-debug/phys-deep.ts2+-
M src/client/endless.ts +1 -0
M src/client/physics/rail.ts +22 -3
@@ -138,13 +138,18 @@ const LOOKAHEAD = 8;
138138 /**139139 * The complete per-step control law at a station: assist toward the local140140 * tangent (sized by the steepest climb ahead, braked by the curvature141- * ahead, scaled by the station's modifier) plus modifier gravity extras.142- * Returns one world-space force or null. `nextRail` chains lookahead143- * across a segment boundary (client drivers keep per-segment rails).141+ * ahead, scaled by the station's modifier) plus modifier gravity extras,142+ * plus a lateral centering spring in boost zones — a launch is only as143+ * straight as its run-up, and a marble still oscillating from an earlier144+ * bend leaves the lip diagonally and misses any catch (abs-1: −1.9 u/s145+ * lateral at launch drifted 1.4 world off the pan). Returns one146+ * world-space force or null. `nextRail` chains lookahead across a147+ * segment boundary (client drivers keep per-segment rails).144148 */145149 export function stationForce(146150 rail: Rail,147151 idx: number,152+ pos: readonly [number, number, number],148153 vel: readonly [number, number, number],149154 mass: number,150155 nextRail: Rail | null = null,
@@ -182,6 +187,20 @@ export function stationForce(
182187 const f = assistForce(vel, t, mass, climbTy, curveAhead, mod?.speed ?? 1) ??183188 [0, 0, 0];184189 const out: [number, number, number] = [f[0], f[1], f[2]];190+ // Lateral centering in boost zones (straight by construction): a191+ // spring-damper toward the rail line kills wall-to-wall oscillation192+ // within the approach so the marble launches (or sprints) true.193+ if (mod?.speed && mod.speed > 1) {194+ const r = rail.pts[idx];195+ const nl = Math.hypot(t[2], t[0]) || 1;196+ const ux = t[2] / nl;197+ const uz = -t[0] / nl;198+ const off = (pos[0] - r[0]) * ux + (pos[2] - r[2]) * uz;199+ const lvel = vel[0] * ux + vel[2] * uz;200+ const a = Math.max(-14, Math.min(14, -40 * off - 12 * lvel));201+ out[0] += ux * a * mass;202+ out[2] += uz * a * mass;203+ }185204 // Extra pull ONLY on descending stations — grav mods mean "chutes pull186205 // harder", never "fight the climb carry" (a ramp tile is the same family187206 // going up; extra gravity there wedged marbles at climb bases).
M src/gen/endless/path.ts +20 -0
M src/gen/endless/proxies.ts +45 -34
@@ -173,14 +173,21 @@ export function channelProxies(
173173 * port: yes at the path start, at segment boundaries, and after a jump174174 * gap (the landing catches at its very edge); never out of a shaft. */175175 let extendStart = true;176- const flushStrip = (extendEnd: boolean, followingExtends: boolean) => {176+ /** Whether the CURRENT strip starts at a jump landing — its mouth rings177+ * flare wide (the ribbon IS the catch funnel). */178+ let flareStart = false;179+ const flushStrip = (180+ extendEnd: boolean,181+ followingExtends: boolean,182+ followingFlares = false,183+ ) => {177184 if (strip.length >= 2) {178185 if (USE_RIBBON) {179186 // Extend to the true strip ends (segment boundaries share ring180187 // positions with the neighbor segment → seamless; jump lips run181188 // to the gap edge), but never toward a drop shaft (that would182189 // overhang the mouth).183- out.push(ribbonOf(strip, extendStart, extendEnd));190+ out.push(ribbonOf(strip, extendStart, extendEnd, flareStart));184191 } else {185192 strip.forEach((st, idx) =>186193 emitSlabs(st, out, !extendEnd && idx === strip.length - 1)
@@ -189,6 +196,7 @@ export function channelProxies(
189196 }190197 strip = [];191198 extendStart = followingExtends;199+ flareStart = followingFlares;192200 };193201 for (let i = 0; i < stations.length; i++) {194202 const st = stations[i];
@@ -198,7 +206,7 @@ export function channelProxies(
198206 // marble flies. The landing side gets a catch pan reaching back199207 // under the flight path plus funnel walls, so undershoots land and200208 // roll on instead of clipping the landing's leading face.201- flushStrip(true, true);209+ flushStrip(true, true, true);202210 let k = i;203211 while (k < stations.length && stations[k].gap) k++;204212 if (k < stations.length && !stations[k].vertical) {
@@ -272,44 +280,27 @@ export function channelProxies(
272280 return out;273281 }274282 275-/** Catch hardware at a jump landing: a pan reaching back under the flight276- * path (top a hair ABOVE the landing ribbon sheet — arriving below a277- * one-sided mesh means catching its edge forever) plus CONVERGING funnel278- * walls. The funnel matters: a marble that drifted into the gutter lane279- * before launch lands off-center, and the landing ribbon's gutter profile280- * rises above the pan — hitting that first ring edge-on bounces the281- * marble BACKWARD into the void (found the hard way). The walls narrow282- * from ±1.05 to ±0.55, shepherding any landing onto the flat floor band283- * before the ribbon starts. */283+/** Catch pan at a jump landing, reaching back under the flight path. Its284+ * top sits a hair ABOVE the landing ribbon sheet (arriving below a285+ * one-sided mesh means catching its edge forever). Lateral catching is286+ * the RIBBON MOUTH's job: the landing strip's first rings flare wide287+ * (ribbonOf flareStart), so off-center landings meet flat floor and the288+ * converging gutters shepherd the marble to center — funnel wall slabs289+ * were tried twice and either clipped shallow flights from above or made290+ * wedge pockets at their tips. */284291 function emitCatch(landing: Station, out: CollisionProxy[]): void {285292 const fwdX = Math.sin(landing.yaw);286293 const fwdZ = Math.cos(landing.yaw);287- const lx = Math.cos(landing.yaw);288- const lz = -Math.sin(landing.yaw);289294 const floorY = landing.p[1];290295 const cx = landing.p[0] - fwdX * 1.0;291296 const cz = landing.p[2] - fwdZ * 1.0;292297 out.push({293298 kind: "slab",294299 center: [cx, floorY - FLOOR_HALF_T + 0.03, cz],295- half: [1.0, FLOOR_HALF_T, 1.05],300+ half: [1.1, FLOOR_HALF_T, 1.05],296301 yaw: landing.yaw,297302 pitch: 0,298303 });299- const FUNNEL = 0.24; // rad — front opening ±0.55, back ±1.05300- for (const side of [-1, 1]) {301- out.push({302- kind: "slab",303- center: [304- cx + lx * 0.80 * side,305- floorY + 0.4,306- cz + lz * 0.80 * side,307- ],308- half: [0.09, 0.5, 1.05],309- yaw: landing.yaw - side * FUNNEL,310- pitch: 0,311- });312- }313304 }314305 315306 /** Slab law (proven, M10): floor follows slope; walls are vertical. The
@@ -350,11 +341,16 @@ function emitSlabs(
350341 }351342 352343 /** Extrude the channel PROFILE along a strip of stations into one mesh353- * with shared ring vertices (C0-continuous — no seams to catch on). */344+ * with shared ring vertices (C0-continuous — no seams to catch on).345+ * `flareStart` widens the first rings laterally (×1.35 at the mouth,346+ * blending to ×1 over ~2 world units): the jump-landing catch mouth —347+ * off-center ballistic landings meet flat floor instead of a gutter348+ * edge, and the converging gutters center the marble as it rolls. */354349 function ribbonOf(355350 strip: readonly Station[],356351 extendStart: boolean,357352 extendEnd: boolean,353+ flareStart = false,358354 ): MeshProxy {359355 const vertices: number[] = [];360356 const indices: number[] = [];
@@ -435,7 +431,22 @@ function ribbonOf(
435431 rings.push(st);436432 last = st;437433 }438- for (const st of rings) {434+ let arc = 0;435+ for (const [ri, st] of rings.entries()) {436+ if (ri > 0) {437+ const prev = rings[ri - 1];438+ arc += Math.hypot(439+ st.p[0] - prev.p[0],440+ st.p[1] - prev.p[1],441+ st.p[2] - prev.p[2],442+ );443+ }444+ // Catch-mouth flare: lateral scale ramps 1.35 → 1 over the first445+ // 2 world units (heights untouched — the floor band widens and the446+ // gutter walls converge downstream).447+ const latScale = flareStart448+ ? 1 + 0.35 * Math.max(0, 1 - arc / 2.0)449+ : 1;439450 // Frame from the true tangent: right = up × t, upV = t × right.440451 const ty = Math.sin(-st.pitch);441452 const th = Math.cos(st.pitch);
@@ -452,9 +463,9 @@ function ribbonOf(
452463 ];453464 for (const [lat, up] of PROFILE) {454465 vertices.push(455- st.p[0] + right[0] * lat + upV[0] * up,456- st.p[1] + right[1] * lat + upV[1] * up,457- st.p[2] + right[2] * lat + upV[2] * up,466+ st.p[0] + right[0] * lat * latScale + upV[0] * up,467+ st.p[1] + right[1] * lat * latScale + upV[1] * up,468+ st.p[2] + right[2] * lat * latScale + upV[2] * up,458469 );459470 }460471 }
M src/gen/endless/world.ts +19 -5
@@ -222,6 +222,13 @@ export class EndlessWorld {
222222 const noLateral: [number, number][] = plan.mods223223 .filter((m) => m.kind === "jump" || m.kind === "propeller")224224 .map((m) => [m.start - 3, m.start + m.len + 2]);225+ // Bump lips: bumps re-admit ONLY fully inside a single boost zone226+ // (sprint runs are the only zones with ≥4 free flat cells) — the227+ // crest launch is priced by the zone's speed, and elsewhere the228+ // assisted marble lacks crest momentum.229+ const boostRanges: [number, number][] = plan.mods230+ .filter((m) => m.kind === "boost")231+ .map((m) => [m.start, m.start + m.len]);225232 const windows = realize(226233 rng.child("wfc"),227234 skeleton,
@@ -233,12 +240,19 @@ export class EndlessWorld {
233240 open: true,234241 extraFree: (x, z, baseY) =>235242 tx.isFree(x, z, baseY, baseY + CHANNEL_CLEARANCE),236- // Physics era: bumps demand crest momentum the assisted marble237- // doesn't carry — excluded until M11's speed envelope prices238- // momentum vocabulary at plan time. Waves stay out of jump239- // trajectory cells (straight-channel contract).243+ // Physics era: bumps demand crest momentum — allowed only when244+ // the window sits fully inside one boost zone (sprint runs),245+ // and only the LIP tiers: a/b rise 0.5/1.0 world and the crest246+ // curvature stays below the launch threshold at the boost247+ // target (κ ≈ 0.375·R_world vs v²κ = g at 0.54). Tier c/d are248+ // 2–3 world tall — genuine launch ramps that threw the marble249+ // over the walls.240250 exclude: (def, start) =>241- def.family === "bump" ||251+ (def.family === "bump" &&252+ (!(def.id === "bump-a" || def.id === "bump-b") ||253+ !boostRanges.some(([a, b]) =>254+ start >= a && start + def.moves.length <= b255+ ))) ||242256 (def.family === "wave" && noLateral.some(([a, b]) =>243257 start < b && a < start + def.moves.length244258 )),
M tests/physics_test.ts +1 -1
M tests/scenario_test.ts +31 -0
M tools/physics-debug/phys-deep.ts +1 -1