clone

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

main
marbles / src / gen / features.ts
features.ts 7.0K
1/**2 * Feature planner: reserves ring intervals for set-piece runs that free3 * collapse can't guarantee — currently wide-channel runs (funnel in, wide4 * straights through, funnel out). Wide pieces bulge 0.35 into the flanking5 * cells, so both lateral neighbors of every cell in a run must be track-free;6 * that check happens here, at plan time, because reservations bypass the7 * WFC's candidate vetting. Width continuity is guaranteed by construction8 * (runs are always funnel-bracketed); realize() asserts it as an invariant.9 */10import type { Rng } from "./rng.ts";11import type { Skeleton } from "./topology.ts";12import type { Reservation } from "./elevation.ts";13import type { StyleField } from "./style.ts";14import { DIR_STEP, turnLeft, turnRight } from "./geom.ts";1516/**17 * Runs BEFORE the elevation planner (the ring is all-flat at plan time);18 * elevation then avoids feature-reserved cells, which keeps every feature19 * window at constant base height by construction.20 */21export function planFeatures(22 rng: Rng,23 skeleton: Skeleton,24 style: StyleField,25): Reservation[] {26 const out: Reservation[] = [];27 const n = skeleton.cells.length;2829 // Grade-crossing cells are spoken for (the cross piece owns them; a wide30 // channel through a crossing has no piece).31 const crossingIdx = new Set<number>();32 for (const { a, b } of skeleton.crossings) {33 crossingIdx.add(a);34 crossingIdx.add(b);35 }3637 const wideRng = rng.child("wide");38 const want = wideRng.chance(style.wideAffinity)39 ? (wideRng.chance(style.wideAffinity * 0.5) ? 2 : 1)40 : 0;4142 const ringCells = new Set(skeleton.cells.map((c) => `${c.x},${c.z}`));43 const claimedFlanks = new Set<string>();4445 // Candidate intervals: maximal straight runs. The skeleton is normalized46 // to start at a turn, so runs never wrap the seam.47 const runs: { start: number; len: number }[] = [];48 let i = 0;49 while (i < n) {50 if (skeleton.cells[i].move !== "F" || crossingIdx.has(i)) {51 i++;52 continue;53 }54 let len = 0;55 while (56 i + len < n && skeleton.cells[i + len].move === "F" &&57 !crossingIdx.has(i + len)58 ) len++;59 if (len >= 3) runs.push({ start: i, len });60 i += len;61 }6263 /** Both flank cells of every interval cell must be track-free and not64 * claimed by another feature. Returns the flank keys, or null. */65 const flanksClear = (start: number, total: number): string[] | null => {66 const flanks: string[] = [];67 for (let j = 0; j < total; j++) {68 const c = skeleton.cells[start + j];69 for (const side of [turnLeft(c.dirIn), turnRight(c.dirIn)]) {70 const [dx, dz] = DIR_STEP[side];71 const k = `${c.x + dx},${c.z + dz}`;72 if (ringCells.has(k) || claimedFlanks.has(k)) return null;73 flanks.push(k);74 }75 }76 return flanks;77 };7879 let placed = 0;80 for (const run of wideRng.shuffle(runs)) {81 if (placed >= want) break;82 // funnel + 1..4 wide straights + funnel; long runs sometimes upgrade a83 // transition to funnel-long (straight + taper + wide fused, 3 cells).84 const entryLen = run.len >= 6 && wideRng.chance(0.35) ? 3 : 1;85 const exitLen = run.len >= entryLen + 5 && wideRng.chance(0.35) ? 3 : 1;86 const maxMid = Math.min(run.len - entryLen - exitLen, 4);87 if (maxMid < 1) continue;88 const mid = wideRng.int(1, maxMid + 1);89 const total = entryLen + mid + exitLen;90 const start = run.start + wideRng.int(0, run.len - total + 1);9192 const flanks = flanksClear(start, total);93 if (!flanks) continue;9495 out.push({96 start,97 len: entryLen,98 pieceIds: [entryLen === 3 ? "funnel-long" : "funnel"],99 reversed: false,100 });101 for (let j = 0; j < mid; j++) {102 out.push({103 start: start + entryLen + j,104 len: 1,105 pieceIds: ["straight-wide"],106 reversed: false,107 });108 }109 out.push({110 start: start + entryLen + mid,111 len: exitLen,112 pieceIds: [exitLen === 3 ? "funnel-long" : "funnel"],113 reversed: true,114 });115 for (const k of flanks) claimedFlanks.add(k);116 placed++;117 }118119 // Split–merge diamond: one per track at most, on a straight run with both120 // flanks free for the whole span (the branches run one lane out each side).121 const diamondRng = rng.child("diamond");122 if (diamondRng.chance(0.4)) {123 const claimedIdx = new Set<number>();124 for (const r of out) {125 for (let j = 0; j < r.len; j++) claimedIdx.add(r.start + j);126 }127 const overlaps = (start: number, total: number): boolean => {128 for (let j = 0; j < total; j++) {129 if (claimedIdx.has(start + j)) return true;130 }131 return false;132 };133 // Exhaustive within each run (eligible geometry is scarce): prefer the134 // longest branch that fits, sliding the window across the run.135 placing:136 for (const run of diamondRng.shuffle(runs)) {137 const maxN = Math.min(3, run.len - 4);138 for (let N = maxN; N >= 1; N--) {139 const total = 4 + N;140 const offsets = diamondRng.shuffle(141 Array.from({ length: run.len - total + 1 }, (_, o) => o),142 );143 for (const o of offsets) {144 const start = run.start + o;145 if (overlaps(start, total)) continue;146 const flanks = flanksClear(start, total);147 if (!flanks) continue;148 out.push({149 start,150 len: total,151 pieceIds: [`diamond-${N}`],152 reversed: false,153 });154 for (const k of flanks) claimedFlanks.add(k);155 break placing;156 }157 }158 }159 }160 // DOUBLE DIAMOND (M19c): a SECOND split–merge event on another run —161 // two lane choices per lap. (A back-to-back chicane pair was ruled out162 // by measurement: mutated rings top out at ~9-cell straight runs and163 // the pair needs 11.) Own child stream — label-derived, so nothing164 // above moves; eligible leftover geometry is scarce, which keeps a165 // twin-diamond lap an event.166 const ddRng = rng.child("diamond2");167 if (ddRng.chance(0.5)) {168 const claimedIdx = new Set<number>();169 for (const r of out) {170 for (let j = 0; j < r.len; j++) claimedIdx.add(r.start + j);171 }172 const overlaps = (start: number, total: number): boolean => {173 for (let j = 0; j < total; j++) {174 if (claimedIdx.has(start + j)) return true;175 }176 return false;177 };178 placing:179 for (const run of ddRng.shuffle(runs)) {180 const maxN = Math.min(3, run.len - 4);181 for (let N = maxN; N >= 1; N--) {182 const total = 4 + N;183 const offsets = ddRng.shuffle(184 Array.from({ length: run.len - total + 1 }, (_, o) => o),185 );186 for (const o of offsets) {187 const start = run.start + o;188 if (overlaps(start, total)) continue;189 const flanks = flanksClear(start, total);190 if (!flanks) continue;191 out.push({192 start,193 len: total,194 pieceIds: [`diamond-${N}`],195 reversed: false,196 });197 for (const k of flanks) claimedFlanks.add(k);198 break placing;199 }200 }201 }202 }203 return out;204}