clone

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

main
marbles / src / gen / scenery.ts
scenery.ts 4.9K
1/**2 * Curve-wide scenery macro (M13) — the off-grid curve-wide family enters3 * as a SET-PIECE: four quarter-turn wide arcs close into a freestanding4 * ring (a roundabout display) on open ground beside the track. The family5 * cannot mate with the 1-unit port lattice (ports on ±0.85-style bbox6 * faces, lane non-concentric) — but it mates perfectly with ITSELF, so7 * the ring is self-closed: every port flush, nothing juts (composition8 * honesty). Solid variants make a filled plaza; the small size has no9 * solid form and always builds open.10 *11 * Measured (tools/measure_ports.ts): each variant's ports sit centered on12 * its x− and z− faces; rotations [0, 3, 2, 1] at ring-center offsets13 * (+h,+h), (−h,+h), (−h,−h), (+h,−h) mate all four in a closed circuit.14 */15import type { Placement } from "../shared/types.ts";16import type { Rng } from "./rng.ts";1718const RING_CHANCE = 0.3;1920interface Variant {21 id: string;22 solidId?: string;23 /** Piece bbox half-extent (ring pieces sit ±h from the ring center). */24 h: number;25}2627const VARIANTS: readonly Variant[] = [28 { id: "curve-wide", h: 0.85 },29 { id: "curve-wide-medium", solidId: "curve-wide-solid-medium", h: 1.35 },30 { id: "curve-wide-large", solidId: "curve-wide-solid-large", h: 1.85 },31];3233/** Piece center offsets (units of h) and rotations that close the ring. */34const CORNERS: readonly (readonly [number, number, number])[] = [35 [1, 1, 0],36 [-1, 1, 3],37 [-1, -1, 2],38 [1, -1, 1],39];4041export interface SceneryPlan {42 placements: Placement[];43 cells: string[];44}4546export function planSceneryRing(47 rng: Rng,48 trackCells: ReadonlySet<string>,49): SceneryPlan {50 const empty: SceneryPlan = { placements: [], cells: [] };51 if (!rng.chance(RING_CHANCE)) return empty;5253 let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;54 for (const cell of trackCells) {55 const [x, z] = cell.split(",").map(Number);56 minX = Math.min(minX, x);57 maxX = Math.max(maxX, x);58 minZ = Math.min(minZ, z);59 maxZ = Math.max(maxZ, z);60 }61 if (!Number.isFinite(minX)) return empty;6263 // Pick the variant first (bigger rings preferred when they fit), then64 // search ring centers on cell corners around the track: the whole65 // footprint block must be clear of track, and the ring must stand near66 // the line (visible, not fog-lost) without touching it.67 const order = rng.shuffle(68 VARIANTS.flatMap((v) => (v.h > 1 ? [v, v] : [v])),69 );70 for (const variant of order) {71 const reach = variant.h * 2; // footprint half-extent around the center72 const cellsFor = (rx: number, rz: number): string[] | null => {73 const out: string[] = [];74 let nearTrack = false;75 for (76 let cx = Math.floor(rx - reach);77 cx <= Math.ceil(rx + reach);78 cx++79 ) {80 for (81 let cz = Math.floor(rz - reach);82 cz <= Math.ceil(rz + reach);83 cz++84 ) {85 if (trackCells.has(`${cx},${cz}`)) return null;86 out.push(`${cx},${cz}`);87 }88 }89 // Proximity: some track cell within reach+4 of the center.90 outer:91 for (let dx = -Math.ceil(reach + 4); dx <= reach + 4; dx++) {92 for (let dz = -Math.ceil(reach + 4); dz <= reach + 4; dz++) {93 if (trackCells.has(`${Math.round(rx + dx)},${Math.round(rz + dz)}`)) {94 nearTrack = true;95 break outer;96 }97 }98 }99 return nearTrack ? out : null;100 };101102 const candidates: [number, number][] = [];103 for (let x = minX - 8; x <= maxX + 8; x++) {104 for (let z = minZ - 8; z <= maxZ + 8; z++) {105 if (cellsFor(x + 0.5, z + 0.5) !== null) candidates.push([x, z]);106 }107 }108 if (candidates.length === 0) continue;109110 const [gx, gz] = rng.pick(111 candidates.sort((a, b) => a[0] - b[0] || a[1] - b[1]),112 );113 const rx = gx + 0.5;114 const rz = gz + 0.5;115 const solid = variant.solidId !== undefined && rng.chance(0.5);116 // TWO-TIER ROTUNDA (M19c): measured — open and solid curve-wide117 // forms are BOTH exactly 0.5 tall with identical footprints, so a118 // solid ring is a plinth an open ring stacks on flush (self-mating119 // in plan, flush in elevation; no falsework needed). Appended child120 // draw: the solid coin above stays byte-identical.121 const rotunda = solid && rng.child("rotunda").chance(0.4);122 const id = solid ? variant.solidId! : variant.id;123 const placements: Placement[] = CORNERS.map(([sx, sz, r]) => ({124 pieceId: id,125 pos: [rx + sx * variant.h, 0, rz + sz * variant.h],126 rotation: r as Placement["rotation"],127 role: "decor" as const,128 }));129 if (rotunda) {130 placements.push(...CORNERS.map(([sx, sz, r]): Placement => ({131 pieceId: variant.id,132 pos: [rx + sx * variant.h, 0.5, rz + sz * variant.h],133 rotation: r as Placement["rotation"],134 role: "decor",135 })));136 }137 return { placements, cells: cellsFor(rx, rz)! };138 }139 return empty;140}