- commit
- f989dddab091ea6dc27c097d54efe8f9964c4989
- parent
- 33deb211c5416ca06ead26d18410a00948f1d2d1
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 2 | + |
| M | src/ | 19 | +++++++++ |
| M | src/ | 23 | ++++++++++ |
| M | src/ | 52 | ++++++++++++++++++++++++ |
| M | src/ | 39 | +++++++++++++++++- |
| M | src/ | 4 | ++ |
| M | tests/ | 27 | ++++++++++++ |
| M | tests/ | 66 | ++++++++++++++++++++++++++++++ |
| M | tools/ | 2 | +- |
| M | tools/ | 2 | + |
@@ -37,6 +37,7 @@ import {
3737 stationRestitution,3838 } from "./physics/rail.ts";3939 import {40+ bumperProxies,4041 channelProxies,4142 islandProxies,4243 wideScalesOf,@@ -482,6 +483,7 @@ class Session {
482483 wideScalesOf(data),483484 ),484485 ...data.islands.flatMap((isle) => islandProxies(isle.placements)),486+ ...bumperProxies(data.placements),485487 ]);486488 // Dense rail (points/tangents/s/mods) for nearest-point progress and487489 // the assist force — the shared law (rail.ts), one truth with the@@ -45,6 +45,14 @@ export interface PhysicsModule {
4545 trisPtr: number,4646 triCount: number,4747 ): number;48+ _ph_add_bumper(49+ cx: number,50+ cy: number,51+ cz: number,52+ radius: number,53+ height: number,54+ restitution: number,55+ ): void;4856 _ph_marble_force(fx: number, fy: number, fz: number): void;4957 _ph_marble_mass(): number;5058 _ph_marble_restitution(restitution: number): void;@@ -172,6 +180,17 @@ export class PhysicsEngine {
172180 }173181 continue;174182 }183+ if (p.kind === "bumper") {184+ this.m._ph_add_bumper(185+ p.center[0],186+ p.center[1],187+ p.center[2],188+ p.radius,189+ p.height,190+ p.restitution,191+ );192+ continue;193+ }175194 if (p.kind === "box") {176195 this.m._ph_add_box(177196 p.center[0],@@ -113,6 +113,29 @@ void ph_add_slab(
113113 b3CreateHullShape(refBodies[pendingSlot], &shapeDef, &box.base);114114 }115115 116+// Bouncer pin (M13): a high-restitution cylinder prop (the mushroom117+// bumper). Contact restitution combines via max, so the pin bounces the118+// marble regardless of its own restitution. The cylinder hull is interned119+// into the world's shape database by b3CreateTransformedHullShape (the120+// geometry is fully cloned), so the local hull is destroyed immediately.121+EMSCRIPTEN_KEEPALIVE122+void ph_add_bumper(123+ double cx, double cy, double cz,124+ float radius, float height, float restitution) {125+ if (pendingSlot < 0) return;126+ b3HullData* cyl = b3CreateCylinder(height, radius, 0.0f, 12);127+ if (!cyl) return;128+ b3ShapeDef sd = b3DefaultShapeDef();129+ sd.baseMaterial.restitution = restitution;130+ sd.baseMaterial.friction = 0.4f;131+ b3Transform xf;132+ xf.p = (b3Vec3){(float)cx, (float)cy, (float)cz};133+ xf.q = b3MakeQuatFromAxisAngle((b3Vec3){0, 1, 0}, 0.0f);134+ b3CreateTransformedHullShape(refBodies[pendingSlot], &sd, cyl, xf,135+ (b3Vec3){1, 1, 1});136+ b3DestroyHull(cyl);137+}138+116139 EMSCRIPTEN_KEEPALIVE117140 void ph_end_static(void) {118141 pendingSlot = -1;@@ -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];@@ -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@@ -49,6 +49,10 @@ export interface Placement {
4949 /** Planner zone this placement's window carries ("icy": the client5050 * paints the pipe ice-pale so the surface READS — zones must read). */5151 zone?: string;52+ /** Bouncer pin (M13): the physics stream adds a high-restitution53+ * cylinder proxy at this placement (the mushroom bumper on wide island54+ * decks — plinko pins; recapture is the wide channel's walls). */55+ bumper?: boolean;5256 }5357 5458 /** A pierced bridge deck: the marble MAY drop through onto the channel@@ -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@@ -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@@ -30,7 +30,7 @@ emcc "$ROOT/src/client/physics/shim.c" \
3030 -O2 \3131 -sMODULARIZE=1 -sEXPORT_ES6=1 -sENVIRONMENT=web,node \3232 -sALLOW_MEMORY_GROWTH=1 \33- -sEXPORTED_FUNCTIONS=_ph_init,_ph_begin_static,_ph_add_box,_ph_add_slab,_ph_add_mesh,_ph_end_static,_ph_remove_static,_ph_spawn_marble,_ph_marble_force,_ph_marble_mass,_ph_marble_restitution,_ph_gravity,_ph_step,_ph_marble_state,_malloc,_free \33+ -sEXPORTED_FUNCTIONS=_ph_init,_ph_begin_static,_ph_add_box,_ph_add_slab,_ph_add_mesh,_ph_add_bumper,_ph_end_static,_ph_remove_static,_ph_spawn_marble,_ph_marble_force,_ph_marble_mass,_ph_marble_restitution,_ph_gravity,_ph_step,_ph_marble_state,_malloc,_free \3434 -sEXPORTED_RUNTIME_METHODS=HEAPF64,HEAPF32,HEAP32 \3535 -o "$ROOT/static/dist/box3d.mjs"3636 @@ -4,6 +4,7 @@
44 // Usage: deno run --allow-read tools/physics-debug/phys-deep.ts <seed>55 import { EndlessWorld } from "../../src/gen/endless/world.ts";66 import {7+ bumperProxies,78 channelProxies,89 wideScalesOf,910 islandProxies,@@ -42,6 +43,7 @@ for (let i = 0; i < 8; i++) {
4243 engine.addSegment(i, [4344 ...channelProxies(seg.path, true, gapSegmentsOf(seg), wideScalesOf(seg)),4445 ...seg.islands.flatMap((x) => islandProxies(x.placements)),46+ ...bumperProxies(seg.placements),4547 ]);4648 rails.push(railForSegment(seg));4749 }