clone

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

main
marbles / src / gen / generateTrack.ts
generateTrack.ts 6.6K
1/**2 * generateTrack — the generation core's public entry point.3 *4 * Pure and deterministic: same (seed, catalog) → same Track, always.5 * Each subsystem draws only from its own labeled RNG family, so adding6 * systems later cannot perturb existing seeds' outputs.7 */8import type { Track, Vec3 } from "../shared/types.ts";9import { rngFamily } from "./rng.ts";10import { buildRegistry } from "./registry.ts";11import { generateSkeleton } from "./topology.ts";12import { planElevation } from "./elevation.ts";13import { pickStyle } from "./style.ts";14import { planFeatures } from "./features.ts";15import { realize } from "./wfc.ts";16import { assemble } from "./assemble.ts";17import { planStubs } from "./stubs.ts";18import { planTakeableSpurs } from "./takeable.ts";19import { planFans } from "./fans.ts";20import { planSceneryRing } from "./scenery.ts";21import {22 cellSetOracle,23 type DecorCatalog,24 pickSetting,25 placeDecor,26 regionAround,27} from "./decor.ts";28import { validateTrack } from "./validate.ts";2930export interface GenerateOptions {31 /** Throw if validateTrack reports problems (on in tests/dev). */32 strict?: boolean;33 /** Skip decor (faster tests). */34 skipDecor?: boolean;35}3637const registry = buildRegistry();3839/** Single-track ambient margin — wide enough that the field fades into fog40 * instead of ending at a visible decor ring. */41const DECOR_MARGIN = 13;4243export function generateTrack(44 seed: string,45 catalog: DecorCatalog = { packs: [] },46 opts: GenerateOptions = {},47): Track {48 const skeleton = generateSkeleton(rngFamily(seed, "topology"));49 const style = pickStyle(rngFamily(seed, "style"));50 // Features plan first (the ring is flat at plan time); elevation then51 // steers its hills around the feature-reserved cells. Helix-loop tracks52 // skip wide runs: the mandatory 2.0 climb must not lose its straight run53 // to the feature planner.54 const features = skeleton.helixLoops.length > 055 ? []56 : planFeatures(rngFamily(seed, "features"), skeleton, style);57 const blocked = new Set<number>();58 for (const r of features) {59 for (let j = 0; j < r.len; j++) {60 blocked.add((r.start + j) % skeleton.cells.length);61 }62 }63 const elevation = planElevation(64 rngFamily(seed, "elevation"),65 skeleton,66 registry,67 blocked,68 );69 // Crossings are pre-reserved. Flat pierce: the 4-way cross mesh at the70 // first visit + the noMesh phantom at the second. Bridge pierce: the71 // upper visit (always the column, index-wise first) is the deck —72 // sometimes with a pierced floor, the marble's chance-fall onto the73 // channel below — and the lower visit is a plain straight.74 const bridge = skeleton.pierce?.bridge ?? false;75 const fallsRng = rngFamily(seed, "falls");76 const falls: { pos: Vec3; landPos: Vec3 }[] = [];77 const crossReservations = skeleton.crossings.flatMap(({ a, b }, i) => {78 if (!bridge) {79 return [80 { start: a, len: 1, pieceIds: ["cross"], reversed: false },81 { start: b, len: 1, pieceIds: ["cross-over"], reversed: false },82 ];83 }84 const hole = fallsRng.child(`hole/${i}`).chance(0.6);85 if (hole) {86 const c = skeleton.cells[a];87 falls.push({88 pos: [c.x, 1.25, c.z],89 landPos: [c.x, 0.25, c.z],90 });91 }92 return [93 {94 start: a,95 len: 1,96 pieceIds: [hole ? "straight-hole" : "straight"],97 reversed: false,98 },99 { start: b, len: 1, pieceIds: ["straight"], reversed: false },100 ];101 });102 const windows = realize(103 rngFamily(seed, "wfc"),104 skeleton,105 elevation,106 style,107 registry,108 [...features, ...crossReservations],109 );110 const assembled = assemble(skeleton, elevation, style, windows);111112 const placements = [...assembled.placements];113114 // Dead-end sidings: real split junctions in the ring with short branches.115 const stubs = planStubs(116 rngFamily(seed, "stubs"),117 placements,118 assembled.trackCells,119 );120 placements.push(...stubs.placements);121 for (const cell of stubs.cells) assembled.trackCells.add(cell);122123 // Takeable spur branches (M13): an elevated split siding draining124 // through an end-hole onto the lower line — the marble takes it on125 // alternate laps (LoopSegment.alt) and the fall is guaranteed.126 const takeable = planTakeableSpurs(127 rngFamily(seed, "takeable"),128 skeleton,129 elevation,130 style,131 placements,132 assembled.loop.segments as import("../shared/types.ts").LoopSegment[],133 assembled.trackCells,134 );135 placements.push(...takeable.placements);136 falls.push(...takeable.falls);137 for (const cell of takeable.cells) assembled.trackCells.add(cell);138139 // Trackside fans: deliberate machinery with a marble boost (M4).140 const fans = planFans(141 rngFamily(seed, "fans"),142 placements,143 assembled.trackCells,144 );145 placements.push(...fans.placements);146 for (const cell of fans.cells) assembled.trackCells.add(cell);147 // Curve-wide scenery ring (M13): the off-grid family as a freestanding148 // self-closed roundabout beside the line. Its cells join trackCells so149 // the decor field bands around it (a clearing with a landmark).150 const scenery = planSceneryRing(151 rngFamily(seed, "scenery"),152 assembled.trackCells,153 );154 placements.push(...scenery.placements);155 for (const cell of scenery.cells) assembled.trackCells.add(cell);156157 const setting = pickSetting(rngFamily(seed, "setting"), catalog);158 if (!opts.skipDecor) {159 // Decor is a world field: single-track mode realizes it for the track's160 // bounds plus an ambient margin (the fog eats the region edge), with an161 // oracle over the one ring's cells. M5 swaps in chunk regions + a162 // chunk-backed oracle without touching placeDecor.163 placements.push(164 ...placeDecor(165 rngFamily(seed, "decor"),166 setting,167 style,168 regionAround(assembled.trackCells, DECOR_MARGIN),169 cellSetOracle(assembled.trackCells),170 catalog,171 ),172 );173 }174175 const track: Track = {176 seed,177 placements,178 loop: assembled.loop,179 ...(falls.length > 0 ? { falls } : {}),180 stats: {181 theme: style.theme,182 paletteIndex: style.paletteIndex,183 setting: setting.name,184 pieceCount: placements.length,185 loopCells: skeleton.cells.length,186 maxDeckY: assembled.maxDeckY,187 families: [...new Set(placements.map((p) => p.pieceId))],188 },189 };190191 if (opts.strict) {192 const errors = validateTrack(track);193 if (errors.length > 0) {194 throw new Error(195 `invalid track for seed "${seed}":\n${errors.join("\n")}`,196 );197 }198 }199 return track;200}201202export { sampleLoop } from "./assemble.ts";203export { validateTrack } from "./validate.ts";204export type { DecorCatalog } from "./decor.ts";