clone

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

main
marbles / src / gen / wfc.ts
wfc.ts 10.0K
1/**2 * Realization layer: wave-function collapse over the path domain.3 *4 * The slot universe is the skeleton ring. Candidate states are (pieceVariant,5 * startIndex) windows whose move signature matches the ring at that position;6 * constraints are coverage (each ring cell exactly once), elevation (flat7 * windows only span constant base height; reserved climb windows are8 * pre-restricted), and 2D occupancy (multi-cell pieces' extra footprint must9 * not collide with the rest of the track). Min-entropy collapse with10 * style-weighted draws; the single-cell fallback (straight / corner) always11 * exists, so generation cannot contradict — occupancy vetoes just remove12 * candidates.13 */14import type { Dir } from "../shared/types.ts";15import type { Rng } from "./rng.ts";16import type { Skeleton } from "./topology.ts";17import type { ElevationPlan, Reservation } from "./elevation.ts";18import type { StyleField } from "./style.ts";19import type { PieceDef, PieceRegistry } from "./registry.ts";20import { applyXform, windowXform } from "./geom.ts";2122export interface RealizedWindow {23 start: number;24 len: number;25 def: PieceDef;26}2728export interface RealizeOptions {29 /**30 * Open path (endless mode): candidate windows must not wrap past the last31 * cell, and the width-continuity check skips the nonexistent seam. Closed32 * rings (the default) are unaffected.33 */34 open?: boolean;35 /**36 * Extra veto for footprint-extra cells — endless mode consults the world37 * occupancy here (other segments, island masses) since the skeleton only38 * covers this segment. Returning false rejects the candidate.39 */40 extraFree?: (x: number, z: number, baseY: number) => boolean;41 /**42 * Exclude free-collapse candidates (endless physics: pieces whose43 * traversal demands momentum, or lateral pieces over set-piece cells44 * whose trajectory math assumes a straight channel). `start` is the45 * candidate window's start index. Reservations are unaffected.46 */47 exclude?: (def: PieceDef, start: number) => boolean;48}4950interface Candidate extends RealizedWindow {51 weight: number;52}5354const key = (x: number, z: number) => `${x},${z}`;5556export function realize(57 rng: Rng,58 skeleton: Skeleton,59 elevation: ElevationPlan,60 style: StyleField,61 registry: PieceRegistry,62 features: readonly Reservation[] = [],63 opts: RealizeOptions = {},64): RealizedWindow[] {65 const n = skeleton.cells.length;66 const moves = skeleton.cells.map((c) => c.move).join("");67 const covered = new Array<boolean>(n).fill(false);68 const committed: RealizedWindow[] = [];6970 // Occupancy: every skeleton cell is track-reserved from the start; extra71 // footprint cells of committed multi-cell pieces get added as we collapse.72 // Values carry the deck base height at that cell — plate-wing support73 // must be SAME-HEIGHT track (a channel a level below a platform wing74 // doesn't visually carry it).75 const occupied = new Map<string, number>();76 skeleton.cells.forEach((c, i) =>77 occupied.set(key(c.x, c.z), elevation.baseY[i])78 );7980 const reservedCells = new Set<number>();8182 /** Footprint cells of a def at `start` that fall outside its window. */83 const extraCellsOf = (def: PieceDef, start: number): [number, number][] => {84 const cell = skeleton.cells[start % n];85 const xf = windowXform(cell.x, cell.z, cell.dirIn, 0);86 const windowSet = new Set<string>();87 for (let j = 0; j < def.moves.length; j++) {88 const c = skeleton.cells[(start + j) % n];89 windowSet.add(key(c.x, c.z));90 }91 const extras: [number, number][] = [];92 for (const [fx, fz] of def.footprint) {93 const w = applyXform(xf, [fx, 0, fz]);94 const k = key(Math.round(w[0]), Math.round(w[2]));95 if (!windowSet.has(k)) extras.push([Math.round(w[0]), Math.round(w[2])]);96 }97 return extras;98 };99100 // 1. Commit reservations (pre-collapsed slots) from the elevation planner101 // (climbs/descents) and the feature planner (wide runs). Their extra102 // footprint cells (wide pieces bulge into flanking cells) go straight into103 // occupancy so free collapse routes multi-cell pieces around them.104 for (const res of [...elevation.reservations, ...features]) {105 const candidates = registry.variants.filter((v) =>106 res.pieceIds.includes(v.id) && Boolean(v.reversed) === res.reversed &&107 v.moves.length === res.len108 );109 if (candidates.length === 0) {110 throw new Error(111 `no variant for reservation ${res.pieceIds} rev=${res.reversed}`,112 );113 }114 const def = candidates.length === 1115 ? candidates[0]116 : rng.child(`reserve/${res.start}`).weighted(117 candidates.map((c) =>118 [c, c.baseWeight * style.weightFor(c, res.start)] as const119 ),120 );121 committed.push({ start: res.start, len: res.len, def });122 for (let j = 0; j < res.len; j++) {123 covered[(res.start + j) % n] = true;124 reservedCells.add((res.start + j) % n);125 }126 for (const [x, z] of extraCellsOf(def, res.start)) {127 occupied.set(key(x, z), elevation.baseY[res.start % n]);128 }129 }130131 // 2. Enumerate flat candidates: windows matching the ring's move string at132 // uncovered positions with constant base height. (Windows never wrap the133 // seam in candidate enumeration when they'd cross a covered cell — wrap134 // across index 0 is fine as long as cells are uncovered; handled modulo.)135 const candidateOk = (def: PieceDef, start: number): boolean => {136 const len = def.moves.length;137 if (opts.open && start + len > n) return false;138 for (let j = 0; j < len; j++) {139 const idx = (start + j) % n;140 if (covered[idx] || reservedCells.has(idx)) return false;141 if (moves[idx] !== def.moves[j]) return false;142 // Flat windows only: base height constant across the window.143 if (elevation.baseY[idx] !== elevation.baseY[start]) return false;144 }145 if (def.deckDelta !== 0) return false;146 // Occupancy: extra footprint cells must be free — locally, and (open147 // mode) against the world beyond this segment.148 for (const [x, z] of extraCellsOf(def, start)) {149 if (occupied.has(key(x, z))) return false;150 if (opts.extraFree && !opts.extraFree(x, z, elevation.baseY[start])) {151 return false;152 }153 }154 // Plate tiles: the connected deck must lie along AT LEAST ONE platform155 // wing (user rule), at the SAME base height, or the slab reads as a156 // bare platform jutting into grass — corners stay the default.157 // (Port-edge neighbors don't count; they touch every piece by158 // construction.)159 if (def.plateSides) {160 const cell = skeleton.cells[start % n];161 const xf = windowXform(cell.x, cell.z, cell.dirIn, 0);162 const anyWing = def.plateSides.some(([px, pz]) => {163 const w = applyXform(xf, [px, 0, pz]);164 return occupied.get(key(Math.round(w[0]), Math.round(w[2]))) ===165 elevation.baseY[start % n];166 });167 if (!anyWing) return false;168 }169 return true;170 };171172 const allCandidates: Candidate[] = [];173 for (let start = 0; start < n; start++) {174 for (const def of registry.variants) {175 if (def.reservedOnly) continue;176 if (opts.exclude?.(def, start)) continue;177 if (def.moves.length > n) continue;178 if (candidateOk(def, start)) {179 const weight = def.baseWeight * style.weightFor(def, start);180 if (weight > 0) {181 allCandidates.push({ start, len: def.moves.length, def, weight });182 }183 }184 }185 }186187 // 3. Min-entropy collapse: repeatedly pick the uncovered cell with the188 // fewest valid candidates covering it, then draw one by weight.189 const collapseRng = rng.child("collapse");190 let guard = 0;191 while (covered.some((c) => !c)) {192 if (guard++ > n * 4) throw new Error("wfc did not converge");193194 // Valid candidates per uncovered index.195 const covering = new Map<number, Candidate[]>();196 for (const cand of allCandidates) {197 if (!candidateOk(cand.def, cand.start)) continue;198 for (let j = 0; j < cand.len; j++) {199 const idx = (cand.start + j) % n;200 if (covered[idx]) continue;201 const list = covering.get(idx) ?? [];202 list.push(cand);203 covering.set(idx, list);204 }205 }206207 let target = -1;208 let best = Infinity;209 for (let idx = 0; idx < n; idx++) {210 if (covered[idx]) continue;211 const count = covering.get(idx)?.length ?? 0;212 if (count === 0) {213 throw new Error(`cell ${idx} has no candidates (fallback missing?)`);214 }215 if (count < best) {216 best = count;217 target = idx;218 }219 }220221 const options = covering.get(target)!;222 const chosen = collapseRng.weighted(223 options.map((c) => [c, c.weight] as const),224 );225 committed.push({ start: chosen.start, len: chosen.len, def: chosen.def });226 for (let j = 0; j < chosen.len; j++) covered[(chosen.start + j) % n] = true;227 for (const [x, z] of extraCellsOf(chosen.def, chosen.start)) {228 occupied.set(key(x, z), elevation.baseY[chosen.start % n]);229 }230 }231232 committed.sort((a, b) => a.start % n - b.start % n);233234 // Width continuity: at every boundary between adjacent windows, the exit235 // channel width must equal the next window's entry width. The feature236 // planner guarantees this by construction (wide runs are funnel-bracketed237 // reservations); free candidates are all narrow–narrow. Hard invariant.238 const inW = new Map<number, string>();239 const outW = new Map<number, string>();240 for (const w of committed) {241 // Open paths have no seam: the exit boundary of the last window (index242 // n) matches no entry and is rightly skipped.243 inW.set(w.start % n, w.def.widthIn ?? "narrow");244 outW.set(245 opts.open ? w.start + w.len : (w.start + w.len) % n,246 w.def.widthOut ?? "narrow",247 );248 }249 for (const [b, wIn] of inW) {250 const wOut = outW.get(b);251 if (wOut !== undefined && wOut !== wIn) {252 throw new Error(`channel width mismatch at ring boundary ${b}`);253 }254 }255256 return committed;257}258259/** World heading into a window's entry cell — convenience for assembly. */260export function windowHeading(skeleton: Skeleton, start: number): Dir {261 return skeleton.cells[start].dirIn;262}