clone

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

main
marbles / src / gen / takeable.ts
takeable.ts 9.5K
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 */17import type {18 CurveSeg,19 Dir,20 FallPoint,21 LoopSegment,22 Placement,23 Vec3,24} from "../shared/types.ts";25import type { Skeleton } from "./topology.ts";26import type { ElevationPlan } from "./elevation.ts";27import type { StyleField } from "./style.ts";28import type { Rng } from "./rng.ts";29import { DIR_STEP, segLength } from "./geom.ts";30import { emitSupportStack } from "./supports.ts";3132const SPUR_CHANCE = 0.6;33/** Prefer the wide bridge form when its footprint fits (it is far rarer). */34const WIDE_PREFERENCE = 0.85;3536export interface TakeablePlan {37 /** New branch placements (the host straight is mutated in place). */38 placements: Placement[];39 falls: FallPoint[];40 cells: string[];41}4243export function planTakeableSpurs(44 rng: Rng,45 skeleton: Skeleton,46 elevation: ElevationPlan,47 style: StyleField,48 /** All assembled placements — the chosen host mutates straight → split. */49 placements: Placement[],50 /** Loop segments (assemble output) — the host window gains `alt`. */51 segments: LoopSegment[],52 /** Full track footprint so far (2D cells) — wide decks must not clip. */53 occupied: ReadonlySet<string>,54): TakeablePlan {55 const empty: TakeablePlan = { placements: [], falls: [], cells: [] };56 if (!rng.chance(SPUR_CHANCE)) return empty;5758 const key = (x: number, z: number) => `${x},${z}`;59 // Ring cell → base height; twice-visited cells are ambiguous, skip them.60 const cellHeight = new Map<string, number>();61 const visits = new Map<string, number>();62 skeleton.cells.forEach((c, i) => {63 const k = key(c.x, c.z);64 cellHeight.set(k, elevation.baseY[i]);65 visits.set(k, (visits.get(k) ?? 0) + 1);66 });67 const arched = new Set(68 placements69 .filter((p) => p.pieceId === "tunnel")70 .map((p) => key(Math.round(p.pos[0]), Math.round(p.pos[2]))),71 );72 const goalCells = new Set(73 placements74 .filter((p) => p.goal)75 .map((p) => key(Math.round(p.pos[0]), Math.round(p.pos[2]))),76 );7778 interface Candidate {79 seg: LoopSegment;80 host: Placement;81 w: Dir; // branch direction (toward the hole cell)82 flow: Dir;83 hole: [number, number];84 /** Wide bridge form: funnel → straight-wide-hole → funnel → cap. */85 wide: boolean;86 }87 /** A lower-line cell suitable for the drain hole at deck height y. */88 const drainable = (k: string, y: number): boolean =>89 cellHeight.get(k) === y - 1 && !arched.has(k) && !goalCells.has(k) &&90 (visits.get(k) ?? 0) <= 1;91 /** Free air at deck height (not track, ground open for a pillar). */92 const air = (k: string): boolean =>93 !occupied.has(k) && cellHeight.get(k) === undefined;94 /** Wide-deck flank: open air, or the lower line a full course below. */95 const flankOk = (k: string, y: number): boolean =>96 air(k) || (cellHeight.get(k) !== undefined && cellHeight.get(k)! <= y - 1);97 const candidates: Candidate[] = [];98 for (const seg of segments) {99 if (seg.placementIndex < 0 || seg.alt) continue;100 const host = placements[seg.placementIndex];101 if (!host || host.pieceId !== "straight" || host.role !== "track") {102 continue;103 }104 const y = host.pos[1];105 if (y < 1) continue; // needs a full course of air below the board106 const cx = Math.round(host.pos[0]);107 const cz = Math.round(host.pos[2]);108 const hk = key(cx, cz);109 if (arched.has(hk) || goalCells.has(hk) || (visits.get(hk) ?? 0) > 1) {110 continue;111 }112 const flow = ((1 - host.rotation + 4) % 4) as Dir;113 for (const side of [1, 3] as const) {114 const w = ((flow + side) % 4) as Dir;115 const [dx, dz] = DIR_STEP[w];116 const [fx, fz] = DIR_STEP[flow];117 const at = (n: number): [number, number] => [cx + dx * n, cz + dz * n];118 // NARROW: the hole cell is the adjacent lower ring cell.119 if (drainable(key(...at(1)), y)) {120 candidates.push({ seg, host, w, flow, hole: at(1), wide: false });121 }122 // WIDE BRIDGE (M13): funnel@1 → straight-wide-hole@2 (over the123 // lower line) → reversed funnel@3 → cap@4. The deck cells need124 // open air with pillar ground; every deck cell's lateral flanks125 // (the 0.35 wide bulge) need air or the lower line a full course126 // below.127 if (128 drainable(key(...at(2)), y) &&129 air(key(...at(1))) && air(key(...at(3))) && air(key(...at(4))) &&130 [1, 2, 3].every((n) => {131 const [bx, bz] = at(n);132 return flankOk(key(bx + fx, bz + fz), y) &&133 flankOk(key(bx - fx, bz - fz), y);134 })135 ) {136 candidates.push({ seg, host, w, flow, hole: at(2), wide: true });137 }138 }139 }140 if (candidates.length === 0) return empty;141142 // One spur per track — a set-piece, not a texture. Wide bridges are far143 // rarer geometry; prefer one when it fits.144 const sorted = candidates.sort((a, b) =>145 a.hole[0] - b.hole[0] || a.hole[1] - b.hole[1] || a.w - b.w ||146 Number(a.wide) - Number(b.wide)147 );148 const wides = sorted.filter((c) => c.wide);149 const cand = wides.length > 0 && rng.chance(WIDE_PREFERENCE)150 ? rng.pick(wides)151 : rng.pick(sorted);152 const { host, w, flow, hole } = cand;153 const y = host.pos[1];154 const floor = y + 0.25;155 const cx = Math.round(host.pos[0]);156 const cz = Math.round(host.pos[2]);157 const [fx, fz] = DIR_STEP[flow];158 const [wx, wz] = DIR_STEP[w];159160 // Host mutates into the junction (the split's main channel IS the161 // host's channel; a right-hand branch renders the native mesh mirrored).162 host.pieceId = "split";163 if ((flow + 3) % 4 === w) host.mirrored = true;164165 const rW = ((1 - w + 4) % 4) as Dir;166 const spurPlacements: Placement[] = [];167 const cells: string[] = [];168 /** Turnaround cell for the teardrop (the last deck cell ridden out). */169 let tip: [number, number];170171 if (cand.wide) {172 const at = (n: number): [number, number] => [173 cx + wx * n,174 cz + wz * n,175 ];176 spurPlacements.push(177 {178 pieceId: "funnel",179 pos: [at(1)[0], y, at(1)[1]],180 rotation: rW,181 role: "track",182 },183 {184 pieceId: "straight-wide-hole",185 pos: [at(2)[0], y, at(2)[1]],186 rotation: rW,187 role: "track",188 },189 {190 pieceId: "funnel",191 pos: [at(3)[0], y, at(3)[1]],192 rotation: ((rW + 2) % 4) as Dir,193 role: "track",194 },195 {196 pieceId: rng.chance(0.5) ? "end-rounded" : "end-square",197 pos: [at(4)[0], y, at(4)[1]],198 rotation: rW,199 role: "track",200 },201 );202 // Falsework mirroring the assemble greedy: a plain column under the203 // funnel mouth, a cradle abutment under the cap.204 const supportPrefix = style.supportColor ? "support-color" : "support";205 emitSupportStack(206 spurPlacements,207 supportPrefix,208 style.supportKind,209 at(1)[0],210 at(1)[1],211 y,212 w,213 false,214 0,215 true, // loop-only falsework: M19c pattern pool admitted216 );217 emitSupportStack(218 spurPlacements,219 supportPrefix,220 style.supportKind,221 at(4)[0],222 at(4)[1],223 y,224 w,225 true,226 0,227 true,228 );229 for (let n = 1; n <= 4; n++) cells.push(key(...at(n)));230 tip = at(3);231 } else {232 spurPlacements.push({233 pieceId: rng.chance(0.5) ? "end-hole-rounded" : "end-hole-square",234 pos: [hole[0], y, hole[1]],235 rotation: rW,236 role: "track",237 });238 cells.push(key(...hole));239 tip = hole;240 }241242 // The teardrop alt: entry port → junction → out over the hole to the243 // tip → back → exit port. The marble never rides the return leg — the244 // fall at the hole is guaranteed — but the variant stays a closed,245 // well-formed loop.246 const pts: Vec3[] = [247 [cx - fx / 2, floor, cz - fz / 2],248 [cx, floor, cz],249 [cx + wx * 0.35 - fx * 0.12, floor, cz + wz * 0.35 - fz * 0.12],250 ...(cand.wide251 ? [[hole[0] - fx * 0.15, floor, hole[1] - fz * 0.15] as Vec3]252 : []),253 [254 tip[0] - wx * 0.15 - fx * 0.18,255 floor,256 tip[1] - wz * 0.15 - fz * 0.18,257 ],258 [tip[0] + wx * 0.22, floor, tip[1] + wz * 0.22],259 [260 tip[0] - wx * 0.15 + fx * 0.18,261 floor,262 tip[1] - wz * 0.15 + fz * 0.18,263 ],264 ...(cand.wide265 ? [[hole[0] + fx * 0.18, floor, hole[1] + fz * 0.18] as Vec3]266 : []),267 [cx + wx * 0.35 + fx * 0.12, floor, cz + wz * 0.35 + fz * 0.12],268 [cx, floor, cz],269 [cx + fx / 2, floor, cz + fz / 2],270 ];271 const curve: CurveSeg[] = [];272 for (let i = 0; i + 1 < pts.length; i++) {273 curve.push({ kind: "line", a: pts[i], b: pts[i + 1] });274 }275 cand.seg.alt = {276 curve,277 length: curve.reduce((acc, s) => acc + segLength(s), 0),278 };279280 return {281 placements: spurPlacements,282 falls: [{283 pos: [hole[0], floor, hole[1]],284 landPos: [hole[0], floor - 1, hole[1]],285 guaranteed: true,286 }],287 cells,288 };289}