clone

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

M13: takeable spur branches — elevated split diving-board end-hole drains onto the lower line (alt teardrop + guaranteed fall; per-variant fall gating)

Dami committed dccb545
commit
dccb5451ad1d584cde21327feea74ef772efa81d
parent
c50ef0fe6d5588870304226d2d521084319d3f4b
author
Dami <dami.etoile@gmail.com>
6 files changed, +292 -8
Msrc/client/main.ts1+
Msrc/client/marble.ts31++++-
Msrc/gen/generateTrack.ts15+++
Asrc/gen/takeable.ts180++++++++++++++++++++++++++++++
Msrc/shared/types.ts3+
Mtests/track_test.ts70++++++++++++
M src/client/main.ts +1 -0
M src/client/marble.ts +23 -8
@@ -37,6 +37,8 @@ export function createMarble(
3737 fallPoints: {3838 pos: [number, number, number];3939 landPos: [number, number, number];40+ /** Takeable spur drains always drop (end-hole law). */41+ guaranteed?: boolean;4042 }[] = [],4143 ): Marble {4244 const obj = new THREE.Group();
@@ -88,7 +90,7 @@ export function createMarble(
8890 const closestS = (8991 c: THREE.Curve<THREE.Vector3>,9092 p: [number, number, number],91- ): number => {93+ ): { s: number; dist: number } => {9294 const total = c.getLength();9395 const probe = new THREE.Vector3();9496 let best = 0;
@@ -102,14 +104,24 @@ export function createMarble(
102104 best = (i / steps) * total;103105 }104106 }105- return best;107+ return { s: best, dist: Math.sqrt(bestD) };106108 };107109 const falls = variants.map((c) =>108- fallPoints.map((f) => ({109- holeS: closestS(c, f.pos),110- landS: closestS(c, f.landPos),111- dropY: f.pos[1] - f.landPos[1],112- }))110+ fallPoints.map((f) => {111+ const hole = closestS(c, f.pos);112+ const land = closestS(c, f.landPos);113+ return {114+ holeS: hole.s,115+ landS: land.s,116+ dropY: f.pos[1] - f.landPos[1],117+ // A hole is live only on variants that actually PASS it — a118+ // takeable spur's hole lies on the branch variant alone; mapping119+ // it onto the main line would fire a phantom drop at the nearest120+ // unrelated point.121+ active: hole.dist < 0.5,122+ guaranteed: f.guaranteed === true,123+ };124+ })113125 );114126 // Falling state: eased vertical drop from the hole to the landing.115127 let falling:
@@ -172,9 +184,12 @@ export function createMarble(
172184 marble.s = next % total;173185 174186 // Bridge holes: on a taking lap, crossing the hole s starts the drop.187+ // Spur drains (guaranteed) always take; inactive holes (not on this188+ // variant) never do.175189 for (const [i, f] of falls[lap % variants.length].entries()) {190+ if (!f.active) continue;176191 const crossed = prevS < f.holeS && next >= f.holeS;177- if (crossed && takesFall(lap, i)) {192+ if (crossed && (f.guaranteed || takesFall(lap, i))) {178193 active().getPointAt(f.holeS / total, point);179194 falling = {180195 t: 0,
M src/gen/generateTrack.ts +15 -0
A src/gen/takeable.ts +180 -0
@@ -0,0 +1,180 @@
1+/**2+ * Takeable spur branches (M13) — the branch-taking beyond diamonds the3+ * hole-semantics round promised: a split siding on an ELEVATED straight4+ * whose one-cell branch is an `end-hole` diving board cantilevered5+ * DIRECTLY above a lower ring channel (Δ exactly 1.0 — the bridge law).6+ * The spur is a GUARANTEED drain (end-hole = guaranteed fall, user rule):7+ * on alternate laps the marble takes the branch, rolls out over the void,8+ * drops through the hole, and continues on the lower line.9+ *10+ * Mechanically this composes two existing systems instead of inventing a11+ * third: the split window gains a LoopSegment.alt (the diamond machinery12+ * — a teardrop out over the hole and back, never ridden past the hole),13+ * and the drop itself is a Track.falls entry (the bridge chance-fall14+ * machinery) flagged `guaranteed`, active only on the variant that15+ * actually passes the hole.16+ */17+import type {18+ CurveSeg,19+ Dir,20+ FallPoint,21+ LoopSegment,22+ Placement,23+ Vec3,24+} from "../shared/types.ts";25+import type { Skeleton } from "./topology.ts";26+import type { ElevationPlan } from "./elevation.ts";27+import type { Rng } from "./rng.ts";28+import { DIR_STEP, segLength } from "./geom.ts";29+30+const SPUR_CHANCE = 0.6;31+32+export interface TakeablePlan {33+ /** New branch placements (the host straight is mutated in place). */34+ placements: Placement[];35+ falls: FallPoint[];36+ cells: string[];37+}38+39+export function planTakeableSpurs(40+ rng: Rng,41+ skeleton: Skeleton,42+ elevation: ElevationPlan,43+ /** All assembled placements — the chosen host mutates straight → split. */44+ placements: Placement[],45+ /** Loop segments (assemble output) — the host window gains `alt`. */46+ segments: LoopSegment[],47+): TakeablePlan {48+ const empty: TakeablePlan = { placements: [], falls: [], cells: [] };49+ if (!rng.chance(SPUR_CHANCE)) return empty;50+51+ const key = (x: number, z: number) => `${x},${z}`;52+ // Ring cell → base height; twice-visited cells are ambiguous, skip them.53+ const cellHeight = new Map<string, number>();54+ const visits = new Map<string, number>();55+ skeleton.cells.forEach((c, i) => {56+ const k = key(c.x, c.z);57+ cellHeight.set(k, elevation.baseY[i]);58+ visits.set(k, (visits.get(k) ?? 0) + 1);59+ });60+ const arched = new Set(61+ placements62+ .filter((p) => p.pieceId === "tunnel")63+ .map((p) => key(Math.round(p.pos[0]), Math.round(p.pos[2]))),64+ );65+ const goalCells = new Set(66+ placements67+ .filter((p) => p.goal)68+ .map((p) => key(Math.round(p.pos[0]), Math.round(p.pos[2]))),69+ );70+71+ interface Candidate {72+ seg: LoopSegment;73+ host: Placement;74+ w: Dir; // branch direction (toward the hole cell)75+ flow: Dir;76+ hole: [number, number];77+ }78+ const candidates: Candidate[] = [];79+ for (const seg of segments) {80+ if (seg.placementIndex < 0 || seg.alt) continue;81+ const host = placements[seg.placementIndex];82+ if (!host || host.pieceId !== "straight" || host.role !== "track") {83+ continue;84+ }85+ const y = host.pos[1];86+ if (y < 1) continue; // needs a full course of air below the board87+ const cx = Math.round(host.pos[0]);88+ const cz = Math.round(host.pos[2]);89+ const hk = key(cx, cz);90+ if (arched.has(hk) || goalCells.has(hk) || (visits.get(hk) ?? 0) > 1) {91+ continue;92+ }93+ const flow = ((1 - host.rotation + 4) % 4) as Dir;94+ for (const side of [1, 3] as const) {95+ const w = ((flow + side) % 4) as Dir;96+ const [dx, dz] = DIR_STEP[w];97+ const hole: [number, number] = [cx + dx, cz + dz];98+ const hkey = key(...hole);99+ // Alignment: the hole cell must BE a lower ring cell at exactly100+ // Δ1.0 below the deck — the drain lands on the lower line. No101+ // arches (the marble would fall through a tunnel roof), no goal102+ // gate (the banner straddles that cell), no twice-visited cells.103+ if (cellHeight.get(hkey) !== y - 1) continue;104+ if (105+ arched.has(hkey) || goalCells.has(hkey) ||106+ (visits.get(hkey) ?? 0) > 1107+ ) continue;108+ candidates.push({ seg, host, w, flow, hole });109+ }110+ }111+ if (candidates.length === 0) return empty;112+113+ // One spur per track — a set-piece, not a texture.114+ const cand = rng.pick(115+ candidates.sort((a, b) =>116+ a.hole[0] - b.hole[0] || a.hole[1] - b.hole[1] || a.w - b.w117+ ),118+ );119+ const { host, w, flow, hole } = cand;120+ const y = host.pos[1];121+ const floor = y + 0.25;122+ const cx = Math.round(host.pos[0]);123+ const cz = Math.round(host.pos[2]);124+ const [fx, fz] = DIR_STEP[flow];125+ const [wx, wz] = DIR_STEP[w];126+127+ // Host mutates into the junction (the split's main channel IS the128+ // host's channel; a right-hand branch renders the native mesh mirrored).129+ host.pieceId = "split";130+ if ((flow + 3) % 4 === w) host.mirrored = true;131+132+ const capRotation = ((1 - w + 4) % 4) as Dir;133+ const spurPlacements: Placement[] = [{134+ pieceId: rng.chance(0.5) ? "end-hole-rounded" : "end-hole-square",135+ pos: [hole[0], y, hole[1]],136+ rotation: capRotation,137+ role: "track",138+ }];139+140+ // The teardrop alt: entry port → junction → out over the hole → back →141+ // exit port. The marble never rides the return leg — the fall at the142+ // hole is guaranteed — but the variant stays a closed, well-formed loop.143+ const pts: Vec3[] = [144+ [cx - fx / 2, floor, cz - fz / 2],145+ [cx, floor, cz],146+ [cx + wx * 0.35 - fx * 0.12, floor, cz + wz * 0.35 - fz * 0.12],147+ [148+ hole[0] - wx * 0.15 - fx * 0.18,149+ floor,150+ hole[1] - wz * 0.15 - fz * 0.18,151+ ],152+ [hole[0] + wx * 0.22, floor, hole[1] + wz * 0.22],153+ [154+ hole[0] - wx * 0.15 + fx * 0.18,155+ floor,156+ hole[1] - wz * 0.15 + fz * 0.18,157+ ],158+ [cx + wx * 0.35 + fx * 0.12, floor, cz + wz * 0.35 + fz * 0.12],159+ [cx, floor, cz],160+ [cx + fx / 2, floor, cz + fz / 2],161+ ];162+ const curve: CurveSeg[] = [];163+ for (let i = 0; i + 1 < pts.length; i++) {164+ curve.push({ kind: "line", a: pts[i], b: pts[i + 1] });165+ }166+ cand.seg.alt = {167+ curve,168+ length: curve.reduce((acc, s) => acc + segLength(s), 0),169+ };170+171+ return {172+ placements: spurPlacements,173+ falls: [{174+ pos: [hole[0], floor, hole[1]],175+ landPos: [hole[0], floor - 1, hole[1]],176+ guaranteed: true,177+ }],178+ cells: [key(...hole)],179+ };180+}
M src/shared/types.ts +3 -0
M tests/track_test.ts +70 -0
@@ -256,6 +256,76 @@ Deno.test("tee-spur: split-double-sides stems from a junction, arms capped", ()
256256 assert(tees > 0, "no tee-spurs across the pinned seeds");257257 });258258 259+Deno.test("takeable spur: elevated split drains through an end-hole onto the lower line", () => {260+ // M13 branch-taking beyond diamonds. Rules per guaranteed fall: the261+ // hole placement is an end-hole exactly Δ1.0 above the fall landing;262+ // the landing lies ON the main loop centerline (the drain reaches the263+ // lower line); a split junction stands orthogonally adjacent at deck264+ // height; and the spur's teardrop alt curve passes over the hole.265+ const seeds = ["take-65", "take-105", "take-168", "take-282", "take-308"];266+ let spurs = 0;267+ for (const seed of seeds) {268+ const track = generateTrack(seed, { packs: [] }, { skipDecor: true });269+ const guaranteed = (track.falls ?? []).filter((f) => f.guaranteed);270+ assert(guaranteed.length > 0, `${seed} lost its takeable spur`);271+ for (const f of guaranteed) {272+ spurs++;273+ assert(274+ Math.abs(f.pos[1] - f.landPos[1] - 1) < 1e-9 &&275+ f.pos[0] === f.landPos[0] && f.pos[2] === f.landPos[2],276+ `${seed}: drain is not a vertical Δ1.0 drop`,277+ );278+ const holeY = f.pos[1] - 0.25;279+ assert(280+ track.placements.some((p) =>281+ p.pieceId.startsWith("end-hole") &&282+ Math.round(p.pos[0]) === f.pos[0] &&283+ Math.round(p.pos[2]) === f.pos[2] &&284+ Math.abs(p.pos[1] - holeY) < 1e-9285+ ),286+ `${seed}: no end-hole board at the drain`,287+ );288+ assert(289+ track.placements.some((p) =>290+ p.pieceId === "split" &&291+ Math.abs(p.pos[1] - holeY) < 1e-9 &&292+ Math.abs(Math.round(p.pos[0]) - f.pos[0]) +293+ Math.abs(Math.round(p.pos[2]) - f.pos[2]) === 1294+ ),295+ `${seed}: no split junction beside the board`,296+ );297+ // Landing on the main line; hole on the alt (teardrop) variant.298+ const passes = (299+ curves: readonly (readonly import("../src/shared/types.ts").CurveSeg[])[],300+ p: readonly number[],301+ ): boolean =>302+ curves.some((curve) =>303+ curve.some((part) => {304+ for (let t = 0; t <= 8; t++) {305+ const q = samplePart(part, t / 8);306+ if (307+ Math.hypot(q[0] - p[0], q[1] - p[1], q[2] - p[2]) < 0.35308+ ) return true;309+ }310+ return false;311+ })312+ );313+ assert(314+ passes(track.loop.segments.map((s) => s.curve), f.landPos),315+ `${seed}: landing point is off the main centerline`,316+ );317+ assert(318+ passes(319+ track.loop.segments.filter((s) => s.alt).map((s) => s.alt!.curve),320+ f.pos,321+ ),322+ `${seed}: no alt branch passes over the drain hole`,323+ );324+ }325+ }326+ assert(spurs > 0, "no takeable spurs across the pinned seeds");327+});328+259329 Deno.test("determinism: same seed → deep-equal track", () => {260330 const a = generateTrack("determinism-check");261331 const b = generateTrack("determinism-check");