clone

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

M13: icy zones over the modifier channel (+ re-pinned scenario seeds)

Dami committed 0a76d89
tryIcy planner clause (flat straight 4-7 cells); RailMod.icy drops the
assist to 0.3 authority (grip is the assist) so the marble slides on
carried momentum; zones READ via Placement.zone -> ice-pale pipe
recolor. Physics scenario rides jump-scan-2's patch. Pins follow the
reshuffle: jump->wide-scan-0, helix->repin-16, diamond->dia-2.
90 tests green.
commit
0a76d89749efeb40d045470ece8b71225aebc914
parent
23e0277b92cebe4ef13b790d02a38708b368ac47
author
Dami <dami.etoile@gmail.com>
7 files changed, +123 -16
Mprogress.md22+++++++++++
Msrc/client/endless.ts11+++++-
Msrc/client/physics/rail.ts14+++++++-
Msrc/gen/endless/path.ts19+++++++++-
Msrc/gen/endless/world.ts11+++++-
Msrc/shared/types.ts3++
Mtests/physics_test.ts59++++++++++++++++++++++++------
M progress.md +22 -0
@@ -812,6 +812,28 @@ zones (icy needs a visible surface treatment first — zones must READ).
812812 813813 **DONE this round (post-M12, user-directed):**814814 815+- **M13 POLISH — camera + icy zones**: (1) FOLLOW-CAM ISLAND OCCLUSION —816+ createCameras gained `occluders` (endless passes the built window's817+ island InstancedMeshes); the desired follow position is pulled in818+ front of the first marble→camera hit, so the cam slides over819+ shoulders instead of clipping through mass (the known cosmetic).820+ (2) BANKING LEAN — the follow cam rolls into the upcoming curve821+ (three-sample signed turn around the marble, speed-scaled, smoothed,822+ ±0.32 rad; rotateZ(+φ) banks right — both modes). (3) ICY ZONES —823+ new planner clause tryIcy (flat straight 4–7 cells, PlanMod "icy",824+ weight 0.7); rail law: RailMod.icy drops assist authority to 0.3825+ (grip IS the assist — the marble carries momentum and slides); READS826+ via Placement.zone="icy" → ice-pale pipe recolor [178,224,240]827+ regardless of chapter color. Physics scenario icy-run rides828+ jump-scan-2's patch. Scenario pins re-shuffled again (icy changed829+ every draw): jump→wide-scan-0, helix→repin-16, diamond→dia-2.830+ 90 tests green. REMAINING (deferred, needs design): gravity rotation831+ (wasm gravity is global-Y — needs a shim gravityDir override +832+ rebuild), bouncers (physical bumper prop + survivability pricing),833+ ambient audio, and the track-variety follow-ons (curve-wide scenery834+ macro, tee-spur, takeable spurs, wide bridges, hill-interval,835+ per-branch diamond supports, distance-graded ambient decor).836+815837 - **ISLAND SHAPING + VARIED TEXTURING** (promoted backlog): islands.ts816838 mass builder restructured into two passes (pass 1: per-cell-keyed817839 existence + depth into a Col map; pass 2: emission with neighbor
M src/client/endless.ts +10 -1
M src/client/physics/rail.ts +13 -1
@@ -36,6 +36,9 @@ export interface RailMod {
3636 gravScale?: number;3737 /** Marble restitution override (shock-absorber zones at hard landings). */3838 restitution?: number;39+ /** Low-grip surface (icy zones): the assist's authority drops to a40+ * fraction — the marble carries its momentum and slides. */41+ icy?: boolean;3942 }4043 4144 export interface Rail {
@@ -71,6 +74,8 @@ const ZONE_MODS: Record<string, RailMod> = {
7174 // Shock absorber at hard-landing zones (Marble Blast: 0.01) — the7275 // driver applies it to the marble while the nearest station is inside.7376 shock: { restitution: 0.02 },77+ // Icy patch: grip is what the assist models — on ice it barely has any.78+ icy: { icy: true },7479 };7580 7681 /** The station spacing every driver samples at. */
@@ -201,7 +206,14 @@ export function stationForce(
201206 }202207 const f = assistForce(vel, t, mass, climbTy, curveAhead, mod?.speed ?? 1) ??203208 [0, 0, 0];204- const out: [number, number, number] = [f[0], f[1], f[2]];209+ // Icy: the assist is grip — on ice it keeps only a sliver of authority210+ // (enough that the line still progresses; the slide is the spectacle).211+ const grip = mod?.icy ? 0.3 : 1;212+ const out: [number, number, number] = [213+ f[0] * grip,214+ f[1] * grip,215+ f[2] * grip,216+ ];205217 // Lateral centering in boost zones (straight by construction) and split206218 // windows: a spring-damper toward the rail line kills wall-to-wall207219 // oscillation so the marble launches true / merges clean.
M src/gen/endless/path.ts +18 -1
@@ -140,7 +140,7 @@ export interface IslandCrossing {
140140 export interface PlanMod {141141 start: number;142142 len: number;143- kind: "boost" | "jump" | "propeller" | "shock";143+ kind: "boost" | "jump" | "propeller" | "shock" | "icy";144144 }145145 146146 export interface SegmentPlan {
@@ -394,6 +394,7 @@ class SegmentBuilder {
394394 ["jump", 0.8],395395 ["sprint", 0.9],396396 ["helixDive", 0.5],397+ ["icy", 0.7],397398 ] as const,398399 );399400 const attempts: (() => boolean)[] = [];
@@ -444,6 +445,8 @@ class SegmentBuilder {
444445 return this.trySprint(rng);445446 case "helixDive":446447 return this.tryHelixDive(rng);448+ case "icy":449+ return this.tryIcy(rng);447450 }448451 for (const attempt of attempts) if (attempt()) return true;449452 return false;
@@ -764,6 +767,20 @@ class SegmentBuilder {
764767 return true;765768 }766769 770+ /**771+ * ICY RUN (M13): a flat straight patch of low-grip channel — the assist772+ * backs off (rail.ts scales its authority down) so the marble carries773+ * whatever momentum it arrived with and slides wide through whatever774+ * follows. The zone READS via the client's ice-pale pipe recolor.775+ */776+ private tryIcy(rng: Rng): boolean {777+ const len = rng.int(4, 7);778+ const start = this.cells.length;779+ if (!this.commitSteps(this.straightSteps(len))) return false;780+ this.mods.push({ start, len, kind: "icy" });781+ return true;782+ }783+767784 /** Last resort when boxed in: steep 1.5 chute straight down and out. */768785 private tryEscape(rng: Rng): boolean {769786 for (const delta of ["1.5", "1", "0.5"]) {
M src/gen/endless/world.ts +10 -1
M src/shared/types.ts +3 -0
M tests/physics_test.ts +47 -12
@@ -190,7 +190,7 @@ Deno.test("physics survivability: assisted marble clears early segments", async
190190 Deno.test("scenario jump-gap: cold entry launches, flies, and is caught (both kinds)", async () => {191191 module_ = await factory();192192 // One pinned case per gap kind: prop-scan-1 early segments plan a193- // PROPELLER (2-cell gap at 0.35 gravity), jump-scan-2 a plain JUMP.193+ // PROPELLER (2-cell gap at 0.35 gravity), wide-scan-0 a plain JUMP.194194 // (Re-pinned after the helix-dive clause reshuffled every seed's draws195195 // — det-seed's early propeller moved; no seed freeze, so pins follow.)196196 // Locate the flight span on the shared rail, spawn the marble COLD
@@ -202,7 +202,7 @@ Deno.test("scenario jump-gap: cold entry launches, flies, and is caught (both ki
202202 // the 7-seed survivability gate above.)203203 for (const { seed, kind } of [204204 { seed: "prop-scan-1", kind: "propeller" },205- { seed: "jump-scan-2", kind: "jump" },205+ { seed: "wide-scan-0", kind: "jump" },206206 ]) {207207 const w = new EndlessWorld(seed, catalog);208208 const rails: Rail[] = [];
@@ -232,19 +232,19 @@ Deno.test("scenario jump-gap: cold entry launches, flies, and is caught (both ki
232232 233233 Deno.test("scenario diamond-branch: both branches carry the marble through", async () => {234234 module_ = await factory();235- // det-seed segment 0 hosts an island diamond. Drive through segment 2235+ // dia-2 early segments host an island diamond (re-pinned after the icy clause). Drive through segment 2236236 // twice — once on the main (left) branch rail, once on the alt (right)237237 // — the marble must clear the split, branch, and merge on BOTH. This is238238 // the branch-respawn contract: after a fall, the retry may ride the239239 // other branch, so both must be survivable.240- const w = new EndlessWorld("det-seed", catalog);240+ const w = new EndlessWorld("dia-2", catalog);241241 const hasDiamond = [0, 1, 2].some((i) =>242242 w.get(i).plan.reservations.some((r) => r.pieceIds[0]?.startsWith("diamond"))243243 );244- assert(hasDiamond, "det-seed early segments lost their diamond");244+ assert(hasDiamond, "dia-2 early segments lost their diamond");245245 const goal = w.get(3).startS;246246 for (const useAlt of [false, true]) {247- const r = drive("det-seed", 5, 90, goal, 0, useAlt);247+ const r = drive("dia-2", 5, 90, goal, 0, useAlt);248248 assert(249249 !r.fell && r.reachedS >= goal,250250 `${useAlt ? "alt" : "main"} branch failed: fell=${r.fell} ` +
@@ -255,29 +255,29 @@ Deno.test("scenario diamond-branch: both branches carry the marble through", asy
255255 256256 Deno.test("physics determinism: identical trajectory hash across runs", async () => {257257 module_ = await factory();258- const a = drive("det-seed", 4, 45);258+ const a = drive("dia-2", 4, 45);259259 module_ = await factory(); // fresh module — same-build reproducibility260- const b = drive("det-seed", 4, 45);260+ const b = drive("dia-2", 4, 45);261261 assertEquals(a.hash, b.hash);262262 assertEquals(a.fell, b.fell);263263 });264264 265265 Deno.test("scenario helix-dive: marble rides the full spiral and the underpass", async () => {266266 module_ = await factory();267- // helix-scan-0 segment 0 hosts a full-helix ensemble (pinned by the267+ // repin-16 segment 0 hosts a full-helix ensemble (pinned by the268268 // scenario corpus test). The marble must enter the approach corner,269269 // ride the 360° spiral down 2.0 (helix family mod: banked, ridden270270 // fast), come out through its own underpass, and keep rolling.271- const w = new EndlessWorld("helix-scan-0", catalog);271+ const w = new EndlessWorld("repin-16", catalog);272272 const seg = w.get(0);273273 const r0 = seg.plan.reservations.find((r) =>274274 /^helix-(large-)?(left|right)$/.test(r.pieceIds[0])275275 );276- assert(r0 !== undefined, "helix-scan-0 seg 0 lost its full helix");276+ assert(r0 !== undefined, "repin-16 seg 0 lost its full helix");277277 const wi = seg.windowRanges.findIndex((wr) => wr.start === r0.start);278278 let endS = 0;279279 for (let k = 0; k <= wi; k++) endS += seg.path.segments[k].length;280- const r = drive("helix-scan-0", 3, 60, endS + 8);280+ const r = drive("repin-16", 3, 60, endS + 8);281281 assert(!r.fell, "marble fell during the helix dive");282282 assert(283283 r.reachedS >= endS + 7,
@@ -308,3 +308,38 @@ Deno.test("scenario wide-run: marble rides funnel → wide channel → funnel",
308308 })`,309309 );310310 });311+312+Deno.test("scenario icy-run: marble slides the low-grip patch and carries on", async () => {313+ module_ = await factory();314+ // jump-scan-2 early segments plan an ICY zone (assist authority drops315+ // to 0.3 there — the marble must coast across on carried momentum and316+ // regain the line after).317+ const w = new EndlessWorld("jump-scan-2", catalog);318+ let icyAt: { seg: number; start: number; len: number } | null = null;319+ for (let i = 0; i < 3 && !icyAt; i++) {320+ const m = w.get(i).plan.mods.find((m) => m.kind === "icy");321+ if (m) icyAt = { seg: i, start: m.start, len: m.len };322+ }323+ assert(icyAt !== null, "jump-scan-2 early segments lost their icy zone");324+ const rails: Rail[] = [];325+ for (let i = 0; i < 4; i++) rails.push(railForSegment(w.get(i)));326+ const rail = concatRails(rails);327+ const first = rail.mods.findIndex((m) => m?.icy);328+ assert(first >= 0, "icy zone missing from the rail mods");329+ let last = first;330+ while (rail.mods[last + 1]?.icy) last++;331+ const r = drive(332+ "jump-scan-2",333+ 4,334+ 40,335+ rail.s[last] + 10,336+ Math.max(0, rail.s[first] - 12),337+ );338+ assert(!r.fell, "marble fell in the icy run");339+ assert(340+ r.reachedS >= rail.s[last] + 9,341+ `marble stalled at s=${r.reachedS.toFixed(1)} (ice ends ${342+ rail.s[last].toFixed(1)343+ })`,344+ );345+});