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>
| M | progress.md | 22 | +++++++++++ |
| M | src/ | 11 | +++++- |
| M | src/ | 14 | +++++++- |
| M | src/ | 19 | +++++++++- |
| M | src/ | 11 | +++++- |
| M | src/ | 3 | ++ |
| M | tests/ | 59 | ++++++++++++++++++++++++------ |
@@ -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@@ -59,6 +59,9 @@ const BEHIND = 2;
5959 /** Segments per atmosphere zone (a zone ≈ 2 minutes of travel). */6060 const ZONE_SEGMENTS = 5;6161 62+/** Ice-pale pipe recolor for icy zones (zones must READ). */63+const ICE_PIPE: readonly [number, number, number] = [178, 224, 240];64+6265 // Marble feel — same constants as the finite loop (marble.ts).6366 const MARBLE_PIECE = "marble-center-high";6467 const BASE_SPEED = 2.2;@@ -444,7 +447,13 @@ class Session {
444447 group,445448 spinners,446449 missing,447- (p) => p.chapter === undefined ? null : this.chapterColor(p.chapter),450+ (p) =>451+ // Icy zones READ as ice-pale pipe regardless of chapter color.452+ p.zone === "icy"453+ ? ICE_PIPE454+ : p.chapter === undefined455+ ? null456+ : this.chapterColor(p.chapter),448457 );449458 // Islands render INSTANCED with per-instance color variation (strata,450459 // moss/rust accents, per-island foliage lean) — islandRender.ts.@@ -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.@@ -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"]) {@@ -373,6 +373,7 @@ export class EndlessWorld {
373373 "jump",374374 "propeller",375375 "boost",376+ "icy",376377 "shock",377378 ];378379 const windowMods = windows.map((w) => {@@ -400,6 +401,9 @@ export class EndlessWorld {
400401 const chapterOf = new Array<number>(assembled.placements.length).fill(401402 entryChapter,402403 );404+ const zoneOf = new Array<string | null>(assembled.placements.length).fill(405+ null,406+ );403407 const loopSegs = assembled.loop.segments;404408 for (let wi = 0; wi < loopSegs.length; wi++) {405409 const startIdx = loopSegs[wi].placementIndex;@@ -417,6 +421,7 @@ export class EndlessWorld {
417421 if (windowMods[wi] === "jump" || windowMods[wi] === "propeller") {418422 dropped.add(k);419423 }424+ if (windowMods[wi] === "icy") zoneOf[k] = "icy";420425 }421426 }422427 @@ -486,7 +491,11 @@ export class EndlessWorld {
486491 // and decor stay 1×. The centerline scales with the track.487492 const placements = [488493 ...assembled.placements489- .map((p, idx): Placement => ({ ...p, chapter: chapterOf[idx] }))494+ .map((p, idx): Placement => ({495+ ...p,496+ chapter: chapterOf[idx],497+ ...(zoneOf[idx] ? { zone: zoneOf[idx]! } : {}),498+ }))490499 .filter((_, idx) => !dropped.has(idx)),491500 ...furniture.map(({ placement, host }): Placement => ({492501 ...placement,@@ -46,6 +46,9 @@ export interface Placement {
4646 /** Endless pipe-color chapter (the island id whose reach this track4747 * piece is in) — the client recolors the pipe swatch per chapter. */4848 chapter?: number;49+ /** Planner zone this placement's window carries ("icy": the client50+ * paints the pipe ice-pale so the surface READS — zones must read). */51+ zone?: string;4952 }5053 5154 /** A pierced bridge deck: the marble MAY drop through onto the channel@@ -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+});