clone

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

M13: gravity rotation — shim gravityDir + tilted-gravity zones over the modifier channel (+ re-pinned scenario seeds)

Dami committed 33deb21
commit
33deb211c5416ca06ead26d18410a00948f1d2d1
parent
5e45ea46c9b698cc87207e8dc90281608a597ed5
author
Dami <dami.etoile@gmail.com>
10 files changed, +292 -17
Msrc/client/endless.ts9++-
Msrc/client/physics/engine.ts14+++
Msrc/client/physics/rail.ts28++++++
Msrc/client/physics/shim.c9++
Msrc/gen/endless/path.ts22++++-
Msrc/gen/endless/world.ts7+-
Mtests/physics_test.ts74+++++++++++++---
Mtests/scenario_test.ts142++++++++++++++++++++++++++++++
Mtools/build_physics.sh2+-
Mtools/physics-debug/phys-deep.ts2+
M src/client/endless.ts +8 -1
M src/client/physics/engine.ts +14 -0
M src/client/physics/rail.ts +28 -0
@@ -39,6 +39,11 @@ export interface RailMod {
3939 /** Low-grip surface (icy zones): the assist's authority drops to a4040 * fraction — the marble carries its momentum and slides. */4141 icy?: boolean;42+ /** Gravity-rotation zones (M13): world gravity TILTS forward along the43+ * station's travel direction by this many radians — the void's gravity44+ * leans and the marble surges down a run that is geometrically flat.45+ * The driver applies it via PhysicsEngine.setGravity per step. */46+ tilt?: number;4247 }4348 4449 export interface Rail {
@@ -76,6 +81,11 @@ const ZONE_MODS: Record<string, RailMod> = {
7681 shock: { restitution: 0.02 },7782 // Icy patch: grip is what the assist models — on ice it barely has any.7883 icy: { icy: true },84+ // Gravity rotation: ~17° forward lean (20·sin ≈ 5.9 u/s² of pull on a85+ // flat run). The assist stays silent between cruise and the 9 u/s86+ // envelope cap, so gravity itself is what hurls the marble — and the87+ // centering spring keeps the surge straight.88+ gravity: { tilt: 0.3, center: true },7989 };8090 8191 /** The station spacing every driver samples at. */
@@ -152,6 +162,24 @@ export function stationRestitution(rail: Rail, idx: number): number {
152162 return rail.mods[idx]?.restitution ?? MARBLE_RESTITUTION;153163 }154164 165+/** World gravity vector the station calls for (gravity-rotation zones166+ * tilt it forward along the station's horizontal travel direction; every167+ * other station keeps plain -Y). Drivers pass this to168+ * PhysicsEngine.setGravity per step — the engine caches. */169+export function stationGravity(170+ rail: Rail,171+ idx: number,172+): [number, number, number] {173+ const tilt = rail.mods[idx]?.tilt;174+ const t = rail.tans[idx];175+ if (!tilt || !t) return [0, GRAVITY_Y, 0];176+ const hl = Math.hypot(t[0], t[2]);177+ if (hl < 1e-6) return [0, GRAVITY_Y, 0];178+ const g = -GRAVITY_Y; // magnitude179+ const fwd = g * Math.sin(tilt) / hl;180+ return [t[0] * fwd, GRAVITY_Y * Math.cos(tilt), t[2] * fwd];181+}182+155183 /** Stations of rail lookahead (≈4 world units at RAIL_STEP). */156184 const LOOKAHEAD = 8;157185
M src/client/physics/shim.c +9 -0
M src/gen/endless/path.ts +21 -1
@@ -140,7 +140,7 @@ export interface IslandCrossing {
140140 export interface PlanMod {141141 start: number;142142 len: number;143- kind: "boost" | "jump" | "propeller" | "shock" | "icy";143+ kind: "boost" | "jump" | "propeller" | "shock" | "icy" | "gravity";144144 }145145 146146 export interface SegmentPlan {
@@ -395,6 +395,7 @@ class SegmentBuilder {
395395 ["sprint", 0.9],396396 ["helixDive", 0.5],397397 ["icy", 0.7],398+ ["gravity", 0.5],398399 ] as const,399400 );400401 const attempts: (() => boolean)[] = [];
@@ -447,6 +448,8 @@ class SegmentBuilder {
447448 return this.tryHelixDive(rng);448449 case "icy":449450 return this.tryIcy(rng);451+ case "gravity":452+ return this.tryGravity(rng);450453 }451454 for (const attempt of attempts) if (attempt()) return true;452455 return false;
@@ -781,6 +784,23 @@ class SegmentBuilder {
781784 return true;782785 }783786 787+ /**788+ * GRAVITY-ROTATION RUN (M13): a flat straight patch where the WORLD's789+ * gravity vector tilts forward along the run (shim gravityDir) — the790+ * marble surges down a run that is geometrically flat, and the follow791+ * cam leans with it. Sprint-style layout: the zone stops 2 cells short792+ * of the clause end, so gravity has snapped back and the assist has793+ * brake runway before whatever follows. READS via the client's794+ * violet-charged pipe recolor.795+ */796+ private tryGravity(rng: Rng): boolean {797+ const len = rng.int(6, 8);798+ const start = this.cells.length;799+ if (!this.commitSteps(this.straightSteps(len))) return false;800+ this.mods.push({ start, len: len - 2, kind: "gravity" });801+ return true;802+ }803+784804 /** Last resort when boxed in: steep 1.5 chute straight down and out. */785805 private tryEscape(rng: Rng): boolean {786806 for (const delta of ["1.5", "1", "0.5"]) {
M src/gen/endless/world.ts +5 -2
M tests/physics_test.ts +62 -12
@@ -28,6 +28,7 @@ import {
2828 type Rail,2929 railForSegment,3030 stationForce,31+ stationGravity,3132 stationRestitution,3233 } from "../src/client/physics/rail.ts";3334 import type { DecorCatalog } from "../src/gen/decor.ts";
@@ -137,6 +138,7 @@ function driveWith(
137138 }138139 }139140 engine.setRestitution(stationRestitution(rail, nearest));141+ engine.setGravity(stationGravity(rail, nearest));140142 const f = stationForce(rail, nearest, st.pos, st.vel, engine.mass);141143 if (f) engine.force(f[0], f[1], f[2]);142144 });
@@ -189,10 +191,10 @@ Deno.test("physics survivability: assisted marble clears early segments", async
189191 190192 Deno.test("scenario jump-gap: cold entry launches, flies, and is caught (both kinds)", async () => {191193 module_ = await factory();192- // One pinned case per gap kind: prop-scan-1 early segments plan a194+ // One pinned case per gap kind: grav-scan-0 early segments plan a193195 // PROPELLER (2-cell gap at 0.35 gravity), wide-scan-0 a plain JUMP.194- // (Re-pinned after the helix-dive clause reshuffled every seed's draws195- // — det-seed's early propeller moved; no seed freeze, so pins follow.)196+ // (Re-pinned after the gravity clause reshuffled every seed's draws —197+ // no seed freeze, so pins follow the stream.)196198 // Locate the flight span on the shared rail, spawn the marble COLD197199 // (base speed 2.2) just before the boost approach, and require it to198200 // cross the gap and keep rolling. This is the envelope MINIMUM — the
@@ -201,7 +203,7 @@ Deno.test("scenario jump-gap: cold entry launches, flies, and is caught (both ki
201203 // landing cells absorb by construction; hot arrivals are exercised by202204 // the 7-seed survivability gate above.)203205 for (const { seed, kind } of [204- { seed: "prop-scan-1", kind: "propeller" },206+ { seed: "grav-scan-0", kind: "propeller" },205207 { seed: "wide-scan-0", kind: "jump" },206208 ]) {207209 const w = new EndlessWorld(seed, catalog);
@@ -232,19 +234,20 @@ Deno.test("scenario jump-gap: cold entry launches, flies, and is caught (both ki
232234 233235 Deno.test("scenario diamond-branch: both branches carry the marble through", async () => {234236 module_ = await factory();235- // dia-2 early segments host an island diamond (re-pinned after the icy clause). Drive through segment 2237+ // grav-scan-5 early segments host an island diamond (re-pinned after238+ // the gravity clause). Drive through segment 2236239 // twice — once on the main (left) branch rail, once on the alt (right)237240 // — the marble must clear the split, branch, and merge on BOTH. This is238241 // the branch-respawn contract: after a fall, the retry may ride the239242 // other branch, so both must be survivable.240- const w = new EndlessWorld("dia-2", catalog);243+ const w = new EndlessWorld("grav-scan-5", catalog);241244 const hasDiamond = [0, 1, 2].some((i) =>242245 w.get(i).plan.reservations.some((r) => r.pieceIds[0]?.startsWith("diamond"))243246 );244- assert(hasDiamond, "dia-2 early segments lost their diamond");247+ assert(hasDiamond, "grav-scan-5 early segments lost their diamond");245248 const goal = w.get(3).startS;246249 for (const useAlt of [false, true]) {247- const r = drive("dia-2", 5, 90, goal, 0, useAlt);250+ const r = drive("grav-scan-5", 5, 90, goal, 0, useAlt);248251 assert(249252 !r.fell && r.reachedS >= goal,250253 `${useAlt ? "alt" : "main"} branch failed: fell=${r.fell} ` +
@@ -264,20 +267,20 @@ Deno.test("physics determinism: identical trajectory hash across runs", async ()
264267 265268 Deno.test("scenario helix-dive: marble rides the full spiral and the underpass", async () => {266269 module_ = await factory();267- // repin-16 segment 0 hosts a full-helix ensemble (pinned by the270+ // grav-scan-23 segment 0 hosts a full-helix ensemble (pinned by the268271 // scenario corpus test). The marble must enter the approach corner,269272 // ride the 360° spiral down 2.0 (helix family mod: banked, ridden270273 // fast), come out through its own underpass, and keep rolling.271- const w = new EndlessWorld("repin-16", catalog);274+ const w = new EndlessWorld("grav-scan-23", catalog);272275 const seg = w.get(0);273276 const r0 = seg.plan.reservations.find((r) =>274277 /^helix-(large-)?(left|right)$/.test(r.pieceIds[0])275278 );276- assert(r0 !== undefined, "repin-16 seg 0 lost its full helix");279+ assert(r0 !== undefined, "grav-scan-23 seg 0 lost its full helix");277280 const wi = seg.windowRanges.findIndex((wr) => wr.start === r0.start);278281 let endS = 0;279282 for (let k = 0; k <= wi; k++) endS += seg.path.segments[k].length;280- const r = drive("repin-16", 3, 60, endS + 8);283+ const r = drive("grav-scan-23", 3, 60, endS + 8);281284 assert(!r.fell, "marble fell during the helix dive");282285 assert(283286 r.reachedS >= endS + 7,
@@ -343,3 +346,50 @@ Deno.test("scenario icy-run: marble slides the low-grip patch and carries on", a
343346 })`,344347 );345348 });349+350+Deno.test("scenario gravity-run: tilted gravity surges the marble on the flat", async () => {351+ module_ = await factory();352+ // grav-scan-7 segment 0 plans a GRAVITY zone: world gravity tilts353+ // forward along the run (shim gravityDir), so a geometrically flat354+ // straight accelerates the marble — the assist stays silent between355+ // cruise and the envelope cap, gravity itself does the work. The zone356+ // stops 2 cells short of its clause (brake runway) and the marble must357+ // roll on past it.358+ const w = new EndlessWorld("grav-scan-7", catalog);359+ let gravAt: number | null = null;360+ for (let i = 0; i < 3 && gravAt === null; i++) {361+ if (w.get(i).plan.mods.some((m) => m.kind === "gravity")) gravAt = i;362+ }363+ assert(gravAt !== null, "grav-scan-7 early segments lost their gravity zone");364+ const rails: Rail[] = [];365+ for (let i = 0; i < 4; i++) rails.push(railForSegment(w.get(i)));366+ const rail = concatRails(rails);367+ const first = rail.mods.findIndex((m) => m?.tilt);368+ assert(first >= 0, "gravity zone missing from the rail mods");369+ let last = first;370+ while (rail.mods[last + 1]?.tilt) last++;371+ // The station law tilts gravity forward along the tangent, magnitude372+ // preserved (|g| = 20) — and returns plain -Y everywhere else.373+ const g = stationGravity(rail, first);374+ const mag = Math.hypot(g[0], g[1], g[2]);375+ assert(Math.abs(mag - 20) < 1e-6, `tilted |g| = ${mag}`);376+ const t = rail.tans[first];377+ const fwd = g[0] * t[0] + g[2] * t[2];378+ assert(fwd > 4, `forward gravity pull ${fwd.toFixed(2)} too weak`);379+ const plain = stationGravity(rail, 0);380+ assertEquals(plain, [0, -20, 0]);381+ const r = drive(382+ "grav-scan-7",383+ 4,384+ 40,385+ rail.s[last] + 10,386+ Math.max(0, rail.s[first] - 12),387+ );388+ assert(!r.fell, "marble fell in the gravity run");389+ assert(390+ r.reachedS >= rail.s[last] + 9,391+ `marble stalled at s=${r.reachedS.toFixed(1)} (gravity zone ends ${392+ rail.s[last].toFixed(1)393+ })`,394+ );395+});
M tests/scenario_test.ts +142 -0
@@ -440,6 +440,70 @@ Deno.test("zone banners: only on plain straight windows (never curves/tunnels)",
440440 assert(banners > 0, "no banners emerged across the corpus");441441 });442442 443+Deno.test("gravity zones: flat straight runs with brake runway, and they READ", () => {444+ // M13 gravity rotation: a gravity zone tilts WORLD gravity forward445+ // along the run. Rules per committed zone: every zone cell is a flat446+ // straight "F" cell at one base (the surge is gravity's, not a slope's),447+ // the zone leaves ≥2 cells of same-clause runway before the next448+ // non-straight thing could arrive (planner stops the mod 2 short of its449+ // clause — verified as 2 flat straight cells beyond the zone), and the450+ // zone's placements carry zone="gravity" so the client recolors them.451+ const seeds = [452+ "det-seed",453+ "atmo-seed",454+ ...Array.from({ length: 12 }, (_, i) => `grav-scan-${i}`),455+ ];456+ let zones = 0;457+ for (const seed of seeds) {458+ const w = new EndlessWorld(seed, catalog);459+ for (let i = 0; i < 5; i++) {460+ const seg = w.get(i);461+ const { cells, mods } = seg.plan;462+ for (const m of mods) {463+ if (m.kind !== "gravity") continue;464+ zones++;465+ const baseY = cells[m.start].baseY;466+ for (let k = m.start; k < m.start + m.len; k++) {467+ assert(468+ cells[k]?.move === "F" && cells[k].baseY === baseY,469+ `${seed} seg ${i}: gravity zone cell ${k} not flat straight`,470+ );471+ }472+ for (let k = m.start + m.len; k < m.start + m.len + 2; k++) {473+ assert(474+ cells[k]?.move === "F" && cells[k].baseY === baseY,475+ `${seed} seg ${i}: gravity runway cell ${k} not flat straight`,476+ );477+ }478+ // The surface READS: at least one placement in the zone's span is479+ // tagged zone="gravity" (unless a higher-priority mod overlaps480+ // the same window — rare; the zone must still be flat above).481+ const zoneCells = new Set<string>();482+ for (let k = m.start; k < m.start + m.len; k++) {483+ zoneCells.add(`${cells[k].x},${cells[k].z}`);484+ }485+ const tagged = seg.placements.some((p) =>486+ p.zone === "gravity" &&487+ zoneCells.has(488+ `${Math.round(p.pos[0] / TRACK_SCALE)},${489+ Math.round(p.pos[2] / TRACK_SCALE)490+ }`,491+ )492+ );493+ const overlapped = mods.some((o) =>494+ o !== m && o.kind !== "gravity" &&495+ o.start < m.start + m.len && m.start < o.start + o.len496+ );497+ assert(498+ tagged || overlapped,499+ `${seed} seg ${i}: gravity zone @${m.start} has no tagged placement`,500+ );501+ }502+ }503+ }504+ assert(zones > 0, "no gravity zones emerged across the corpus");505+});506+443507 Deno.test("pipe chapters: every track placement carries its island's chapter", () => {444508 // USER RULE: the pipe color is PER ISLAND. Every track placement in an445509 // endless segment carries a chapter (the most recent island's id at its
@@ -539,6 +603,84 @@ Deno.test("island verticality: sloped runs terrace, every cell anchored at its h
539603 assert(sloped > 0, "no sloped island runs emerged across the corpus");540604 });541605 606+Deno.test("island wedges: slope/diagonal trims respect the surrounding surface", () => {607+ // USER RULE (2026-08-09): pyramid/wedge blocks must respect the surface608+ // they are on — no illogical rotations. Per emitted trim block: a609+ // cliff_blockSlope's descending face (local +x under its rotation) must610+ // look onto air a FULL course deep (neighbor column absent or its top611+ // at/below the wedge's base); a cliff_blockDiagonal's cut corner spans two612+ // adjacent open dirs AND the diagonal cell between them must be open613+ // too, or the cut face jams into that block's corner.614+ const seeds = [615+ "det-seed",616+ "atmo-seed",617+ "hctqaix2hl",618+ ...Array.from({ length: 10 }, (_, i) => `jump-scan-${i}`),619+ ];620+ const heightOf = (id: string): number =>621+ id.includes("blockQuarter")622+ ? 0.25623+ : id.includes("blockHalf")624+ ? 0.5625+ : 1;626+ const DIRV: readonly (readonly [number, number])[] = [627+ [1, 0],628+ [0, 1],629+ [-1, 0],630+ [0, -1],631+ ];632+ let slopes = 0;633+ let diagonals = 0;634+ for (const seed of seeds) {635+ const w = new EndlessWorld(seed, catalog);636+ for (let i = 0; i < 6; i++) {637+ for (const isle of w.get(i).islands) {638+ // Rebuild the mass surface: per cell, the highest block top.639+ const tops = new Map<string, number>();640+ for (const p of isle.placements) {641+ if (p.role !== "support" || !p.pieceId.includes("cliff_")) continue;642+ const k = `${p.pos[0]},${p.pos[2]}`;643+ const top = p.pos[1] + heightOf(p.pieceId);644+ if ((tops.get(k) ?? -Infinity) < top) tops.set(k, top);645+ }646+ const openFrom = (p: { pos: readonly number[] }, w2: number) => {647+ const [dx, dz] = DIRV[w2];648+ const t = tops.get(`${p.pos[0] + dx},${p.pos[2] + dz}`);649+ return t === undefined || t <= p.pos[1] + 0.01;650+ };651+ for (const p of isle.placements) {652+ if (p.pieceId.includes("cliff_blockSlope")) {653+ slopes++;654+ const w2 = (4 - p.rotation) % 4; // local +x → world dir655+ assert(656+ openFrom(p, w2),657+ `${seed} seg ${i}: slope at (${p.pos}) descends into a ` +658+ `higher neighbor (dir ${w2})`,659+ );660+ } else if (p.pieceId.includes("cliff_blockDiagonal")) {661+ diagonals++;662+ const k0 = (4 - p.rotation) % 4; // cut anchor (local +x)663+ const k1 = (k0 + 1) % 4; // cut partner (local +z)664+ const [dx0, dz0] = DIRV[k0];665+ const [dx1, dz1] = DIRV[k1];666+ const diagTop = tops.get(667+ `${p.pos[0] + dx0 + dx1},${p.pos[2] + dz0 + dz1}`,668+ );669+ assert(670+ openFrom(p, k0) && openFrom(p, k1) &&671+ (diagTop === undefined || diagTop <= p.pos[1] + 0.01),672+ `${seed} seg ${i}: diagonal cut at (${p.pos}) jams into a ` +673+ `higher neighbor (dirs ${k0},${k1})`,674+ );675+ }676+ }677+ }678+ }679+ }680+ assert(slopes > 0, "no slope trims emerged across the corpus");681+ assert(diagonals > 0, "no diagonal trims emerged across the corpus");682+});683+542684 Deno.test("island diamonds: branches live on island track with claimed flanks", () => {543685 // Branch-respawn contract (rule-based): every diamond reservation sits544686 // fully inside an island run's straight cells, its crossing records the
M tools/build_physics.sh +1 -1
M tools/physics-debug/phys-deep.ts +2 -0