- commit
- 16d0130224295d01b4961f5ef830b5bacfffaedd
- parent
- 2bacc996ba23267b0b1c606a526b17f3090da42b
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 1 | + |
| M | src/ | 25 | ++++++++- |
| M | src/ | 20 | ++++++++ |
| M | src/ | 79 | +++++++++++++++++------------- |
| M | src/ | 24 | +++++++-- |
| M | tests/ | 2 | +- |
| M | tests/ | 31 | ++++++++++++ |
| M | tools/ | 2 | +- |
@@ -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).@@ -367,6 +367,7 @@ class SegmentBuilder {
367367 ["slope", 3],368368 ["cornerDrop", 1.2],369369 ["jump", 0.8],370+ ["sprint", 0.9],370371 ] as const,371372 );372373 const attempts: (() => boolean)[] = [];@@ -413,6 +414,8 @@ class SegmentBuilder {
413414 return this.tryCornerDrop(rng);414415 case "jump":415416 return this.tryJump(rng);417+ case "sprint":418+ return this.trySprint(rng);416419 }417420 for (const attempt of attempts) if (attempt()) return true;418421 return false;@@ -565,6 +568,23 @@ class SegmentBuilder {
565568 return true;566569 }567570 571+ /**572+ * SPRINT set-piece (M11): a long straight boost zone on the flat — the573+ * marble accelerates to the boost target and tears down the run. The574+ * zone stops 2 cells short of the clause end so the brake has runway575+ * before whatever follows. These are ALSO the only cells where WFC may576+ * free-collapse BUMP windows (4 flat cells fully inside a boost zone):577+ * at sprint speed a bump crest is a small launch, and the ribbon that578+ * follows the bump profile is its own catch.579+ */580+ private trySprint(rng: Rng): boolean {581+ const len = rng.int(6, 8);582+ const start = this.cells.length;583+ if (!this.commitSteps(this.straightSteps(len))) return false;584+ this.mods.push({ start, len: len - 2, kind: "boost" });585+ return true;586+ }587+568588 /**569589 * JUMP set-piece (M11): a real 1-cell channel gap. Layout is570590 * boost-approach (3 flat cells — the assist accelerates to launch@@ -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 }@@ -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 )),@@ -131,7 +131,7 @@ function driveWith(
131131 }132132 }133133 engine.setRestitution(stationRestitution(rail, nearest));134- const f = stationForce(rail, nearest, st.vel, engine.mass);134+ const f = stationForce(rail, nearest, st.pos, st.vel, engine.mass);135135 if (f) engine.force(f[0], f[1], f[2]);136136 });137137 st = engine.state();@@ -299,3 +299,34 @@ Deno.test("jump set-piece: every planned jump obeys the trajectory contract", ()
299299 assert(propellers > 0, "no propeller jumps emerged across the corpus");300300 assert(shocks > 0, "no shock zones emerged across the corpus");301301 });302+303+Deno.test("bump lips: bumps realize ONLY fully inside a boost zone", () => {304+ // The momentum rule made physical: a bump window's 4 cells must sit305+ // inside one boost zone (sprint runs) — the crest launch is priced by306+ // the zone's speed. A bump outside a zone is the pre-M11 hazard the307+ // exclude exists for.308+ const seeds = [309+ "cua-seed-1",310+ "det-seed",311+ "atmo-seed",312+ ...Array.from({ length: 15 }, (_, i) => `jump-scan-${i}`),313+ ];314+ let bumps = 0;315+ for (const seed of seeds) {316+ const w = new EndlessWorld(seed, catalog);317+ for (let i = 0; i < 5; i++) {318+ const seg = w.get(i);319+ seg.pieceFamilies.forEach((fam, wi) => {320+ if (fam !== "bump") return;321+ bumps++;322+ // Windows align 1:1 with windowMods; a bump fully inside a boost323+ // zone resolves to "boost" (nothing outranks it on flat cells).324+ assert(325+ seg.windowMods[wi] === "boost",326+ `${seed} seg ${i}: bump window ${wi} outside a boost zone`,327+ );328+ });329+ }330+ }331+ assert(bumps > 0, "no bump lips emerged across the corpus");332+});@@ -68,7 +68,7 @@ for (let step = 0; step < 240 * 60; step++) {
6868 }6969 const st = engine.state();7070 engine.setRestitution(stationRestitution(rail, nearest));71- const f = stationForce(rail, nearest, st.vel, engine.mass);71+ const f = stationForce(rail, nearest, st.pos, st.vel, engine.mass);7272 if (f) engine.force(f[0], f[1], f[2]);7373 });7474 {