clone

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

M13: bouncer pins — mushroom bumpers on wide island decks (cylinder hulls via ph_add_bumper, recapture by wide walls)

Dami committed f989ddd
commit
f989dddab091ea6dc27c097d54efe8f9964c4989
parent
33deb211c5416ca06ead26d18410a00948f1d2d1
author
Dami <dami.etoile@gmail.com>
10 files changed, +234 -2
Msrc/client/endless.ts2+
Msrc/client/physics/engine.ts19+++++++++
Msrc/client/physics/shim.c23++++++++++
Msrc/gen/endless/furniture.ts52++++++++++++++++++++++++
Msrc/gen/endless/proxies.ts39+++++++++++++++++-
Msrc/shared/types.ts4++
Mtests/physics_test.ts27++++++++++++
Mtests/scenario_test.ts66++++++++++++++++++++++++++++++
Mtools/build_physics.sh2+-
Mtools/physics-debug/phys-deep.ts2+
M src/client/endless.ts +2 -0
M src/client/physics/engine.ts +19 -0
M src/client/physics/shim.c +23 -0
M src/gen/endless/furniture.ts +52 -0
@@ -36,6 +36,13 @@ export interface FurnishedPiece {
3636 host: number;3737 }3838 39+/** Bouncer pin chance per wide-deck cell (M13). */40+const BUMPER_CHANCE = 0.6;41+/** Lateral pin offset from the wide centerline, track units (the wide42+ * channel spans ±0.85 — the marble can pass on either side, and the wide43+ * walls guarantee recapture; the carom is the spectacle). */44+const BUMPER_OFFSET = 0.25;45+3946 export function planIslandFurniture(4047 rng: Rng,4148 plan: SegmentPlan,
@@ -48,6 +55,51 @@ export function planIslandFurniture(
4855 const windowAt = (idx: number): RealizedWindow | undefined =>4956 windows.find((w) => idx >= w.start && idx < w.start + w.len);5057 58+ // BOUNCER PINS (M13): mushroom bumpers on wide island decks — plinko59+ // pins the marble caroms around (high-restitution cylinder proxy;60+ // outcome stochastic, recapture guaranteed by the wide channel walls).61+ // Wide cells only (the narrow channel has no room to pass a pin) and62+ // never inside a planner zone (a boost bleeding into the wide deck must63+ // launch true, not pinball).64+ for (const crossing of plan.crossings) {65+ for (const w of windows) {66+ if (w.start < crossing.start || w.start >= crossing.start + crossing.len) {67+ continue;68+ }69+ if (w.def.id !== "straight-wide") continue;70+ if (71+ plan.mods.some((m) => w.start >= m.start && w.start < m.start + m.len)72+ ) continue;73+ const host = plan.cells[w.start];74+ const r = rng.child(`isle/${crossing.id}/bumper/${host.x},${host.z}`);75+ if (!r.chance(BUMPER_CHANCE)) continue;76+ const side: Dir = r.chance(0.5)77+ ? turnLeft(host.dirIn)78+ : turnRight(host.dirIn);79+ const [dx, dz] = side === 080+ ? [1, 0]81+ : side === 182+ ? [0, 1]83+ : side === 284+ ? [-1, 0]85+ : [0, -1];86+ out.push({87+ placement: {88+ pieceId: "nature-kit:mushroom_red",89+ pos: [90+ host.x + dx * BUMPER_OFFSET,91+ host.baseY + 0.25, // the channel floor — the pin stands IN it92+ host.z + dz * BUMPER_OFFSET,93+ ],94+ rotation: r.int(0, 4) as Dir,95+ role: "track",96+ bumper: true,97+ },98+ host: w.start,99+ });100+ }101+ }102+51103 for (const crossing of plan.crossings) {52104 for (const sc of crossing.sideCells ?? []) {53105 const host = plan.cells[sc.host];
M src/gen/endless/proxies.ts +38 -1
@@ -45,7 +45,44 @@ export interface MeshProxy {
4545 indices: number[];4646 }4747 48-export type CollisionProxy = SlabProxy | BoxProxy | MeshProxy;48+/** Bouncer pin (M13): a high-restitution cylinder — the mushroom bumper49+ * on wide island decks. Bounce combines via max, so the pin repels the50+ * marble regardless of shock zones. */51+export interface BumperProxy {52+ kind: "bumper";53+ center: readonly [number, number, number];54+ radius: number;55+ height: number;56+ restitution: number;57+}58+59+export type CollisionProxy = SlabProxy | BoxProxy | MeshProxy | BumperProxy;60+61+/** Bumper collision sizing, world units: the mushroom prop at the62+ * furniture scale (cap radius ≈ 0.18, height ≈ 0.41) with the hull a hair63+ * inside the cap so the marble bounces off the visible rim. */64+export const BUMPER_RADIUS = 0.16;65+export const BUMPER_HEIGHT = 0.42;66+export const BUMPER_RESTITUTION = 0.85;67+68+/** Cylinder proxies for every bumper-flagged placement (positions are69+ * already world-scaled by the stream). */70+export function bumperProxies(71+ placements: readonly { pos: readonly number[]; bumper?: boolean }[],72+): BumperProxy[] {73+ const out: BumperProxy[] = [];74+ for (const p of placements) {75+ if (!p.bumper) continue;76+ out.push({77+ kind: "bumper",78+ center: [p.pos[0], p.pos[1], p.pos[2]],79+ radius: BUMPER_RADIUS,80+ height: BUMPER_HEIGHT,81+ restitution: BUMPER_RESTITUTION,82+ });83+ }84+ return out;85+}4986 5087 /** Channel cross-section half-profile (lateral, up) in world units at the5188 * slalom scale: flat floor, gutter fillets, near-vertical walls with a
M src/shared/types.ts +4 -0
M tests/physics_test.ts +27 -0
@@ -13,6 +13,7 @@
1313 import { assert, assertEquals } from "@std/assert";1414 import { EndlessWorld } from "../src/gen/endless/world.ts";1515 import {16+ bumperProxies,1617 channelProxies,1718 islandProxies,1819 wideScalesOf,
@@ -86,6 +87,7 @@ function driveWith(
8687 engine.addSegment(i, [8788 ...channelProxies(seg.path, true, gapSegmentsOf(seg), wideScalesOf(seg)),8889 ...seg.islands.flatMap((isle) => islandProxies(isle.placements)),90+ ...bumperProxies(seg.placements),8991 ]);9092 for (const c of seg.plan.cells) lowest = Math.min(lowest, c.baseY * 2);9193 }
@@ -347,6 +349,31 @@ Deno.test("scenario icy-run: marble slides the low-grip patch and carries on", a
347349 );348350 });349351 352+Deno.test("scenario bouncer-pins: marble caroms the wide-deck pins and carries on", async () => {353+ module_ = await factory();354+ // wide-scan-10 segment 0 hosts a wide island run carrying THREE mushroom355+ // bumpers (the densest early pin field the scan found). The pins are356+ // high-restitution cylinders standing in the wide channel; the marble357+ // must thread or carom past them — outcome stochastic, recapture358+ // guaranteed by the wide walls — and exit the run still rolling.359+ const w = new EndlessWorld("wide-scan-10", catalog);360+ const seg = w.get(0);361+ const pins = seg.placements.filter((p) => p.bumper);362+ assert(pins.length >= 2, "wide-scan-10 seg 0 lost its pin field");363+ const wi = seg.pieceFamilies.indexOf("wide");364+ assert(wi >= 0, "wide-scan-10 seg 0 lost its wide run");365+ let endS = 0;366+ for (let k = 0; k <= wi + 1; k++) endS += seg.path.segments[k].length;367+ const r = drive("wide-scan-10", 3, 60, endS + 8);368+ assert(!r.fell, "marble fell in the pin field");369+ assert(370+ r.reachedS >= endS + 7,371+ `marble stalled at s=${r.reachedS.toFixed(1)} (pin field ends ${372+ endS.toFixed(1)373+ })`,374+ );375+});376+350377 Deno.test("scenario gravity-run: tilted gravity surges the marble on the flat", async () => {351378 module_ = await factory();352379 // grav-scan-7 segment 0 plans a GRAVITY zone: world gravity tilts
M tests/scenario_test.ts +66 -0
@@ -681,6 +681,72 @@ Deno.test("island wedges: slope/diagonal trims respect the surrounding surface",
681681 assert(diagonals > 0, "no diagonal trims emerged across the corpus");682682 });683683 684+Deno.test("bouncer pins: mushroom bumpers only on wide island decks, clear of zones", () => {685+ // M13 bouncers. Rules per bumper placement: it stands on a straight-wide686+ // window cell of an island crossing (the ONLY channel wide enough to687+ // pass a pin — recapture is the wide walls, survivability by688+ // construction), offset laterally ≤ 0.25 track cells from the wide689+ // centerline, standing ON the channel floor (+0.25 track over the deck690+ // base), and never inside a planner zone (a boost bleeding into the691+ // deck must launch true, not pinball).692+ const seeds = [693+ "det-seed",694+ ...Array.from({ length: 15 }, (_, i) => `wide-scan-${i}`),695+ ];696+ let pins = 0;697+ for (const seed of seeds) {698+ const w = new EndlessWorld(seed, catalog);699+ for (let i = 0; i < 5; i++) {700+ const seg = w.get(i);701+ for (const p of seg.placements) {702+ if (!p.bumper) continue;703+ pins++;704+ assert(705+ p.pieceId === "nature-kit:mushroom_red",706+ `${seed} seg ${i}: bumper is ${p.pieceId}`,707+ );708+ const cx = Math.round(p.pos[0] / TRACK_SCALE);709+ const cz = Math.round(p.pos[2] / TRACK_SCALE);710+ const idx = seg.plan.cells.findIndex((c) => c.x === cx && c.z === cz);711+ assert(idx >= 0, `${seed} seg ${i}: bumper off the line at ${p.pos}`);712+ const cell = seg.plan.cells[idx];713+ // Lateral offset within the pin envelope, standing on the floor.714+ const offX = Math.abs(p.pos[0] / TRACK_SCALE - cell.x);715+ const offZ = Math.abs(p.pos[2] / TRACK_SCALE - cell.z);716+ assert(717+ offX <= 0.25 + 1e-9 && offZ <= 0.25 + 1e-9,718+ `${seed} seg ${i}: bumper too far off-center (${offX},${offZ})`,719+ );720+ assert(721+ Math.abs(p.pos[1] / TRACK_SCALE - (cell.baseY + 0.25)) < 1e-9,722+ `${seed} seg ${i}: bumper not on the channel floor`,723+ );724+ // Host window is a wide deck inside an island crossing.725+ const wi = seg.windowRanges.findIndex((r) =>726+ idx >= r.start && idx < r.start + r.len727+ );728+ assert(729+ wi >= 0 && seg.pieceFamilies[wi] === "wide",730+ `${seed} seg ${i}: bumper on a ${731+ seg.pieceFamilies[wi] ?? "?"732+ } window`,733+ );734+ assert(735+ seg.plan.crossings.some((c) =>736+ idx >= c.start && idx < c.start + c.len737+ ),738+ `${seed} seg ${i}: bumper outside any island crossing`,739+ );740+ assert(741+ !seg.plan.mods.some((m) => idx >= m.start && idx < m.start + m.len),742+ `${seed} seg ${i}: bumper inside a planner zone`,743+ );744+ }745+ }746+ }747+ assert(pins > 0, "no bouncer pins emerged across the corpus");748+});749+684750 Deno.test("island diamonds: branches live on island track with claimed flanks", () => {685751 // Branch-respawn contract (rule-based): every diamond reservation sits686752 // 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