clone

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

main
marbles / src / gen / stubs.ts
stubs.ts 11.8K
1/**2 * Dead-end sidings: a real 3-way `split` piece IN the ring (its main channel3 * is the straight the window realized; the third port opens sideways), with4 * a short branch — straight-hole, then an end cap — dead-ending beside the5 * track. The marble keeps to the main line (the loop centerline is6 * unchanged); the branch is scenery with honest junction geometry. Branch7 * marbles would roll in and drop through the hole — a drain spur.8 *9 * The native split branches LEFT of the flow; a right-hand branch renders10 * the same mesh mirrored.11 */12import type { Dir, Placement } from "../shared/types.ts";13import type { Rng } from "./rng.ts";14import { DIR_STEP } from "./geom.ts";1516const STUB_CHANCE = 0.45;1718const CAPS = [19 "end-hole-rounded",20 "end-hole-square",21 "end-rounded",22 "end-square",23];2425export interface StubPlan {26 /** New branch placements (the chosen straights are mutated in place). */27 placements: Placement[];28 /** Cells the branches occupy (world "x,z" keys) — merge into trackCells. */29 cells: string[];30}3132export function planStubs(33 rng: Rng,34 /** All assembled placements — chosen `straight`s become `split`s. */35 placements: Placement[],36 /** Full track footprint incl. supports (assembled). */37 occupied: ReadonlySet<string>,38): StubPlan {39 const empty: StubPlan = { placements: [], cells: [] };40 if (!rng.chance(STUB_CHANCE)) return empty;4142 const key = (x: number, z: number) => `${x},${z}`;43 // Cells that carry a tunnel arch — a junction under a tunnel reads wrong.44 const arched = new Set(45 placements46 .filter((p) => p.pieceId === "tunnel")47 .map((p) => key(Math.round(p.pos[0]), Math.round(p.pos[2]))),48 );49 // The goal gate straddles its straight — keep the lap line clean.50 const goalCells = new Set(51 placements52 .filter((p) => p.goal)53 .map((p) => key(Math.round(p.pos[0]), Math.round(p.pos[2]))),54 );55 // Cells carrying stacked track (bridge decks over channels): a junction56 // in the lower channel would sit under the bridge — skip.57 const cellCounts = new Map<string, number>();58 for (const p of placements) {59 if (p.role !== "track") continue;60 const k = key(Math.round(p.pos[0]), Math.round(p.pos[2]));61 cellCounts.set(k, (cellCounts.get(k) ?? 0) + 1);62 }63 const stacked = (k: string) => (cellCounts.get(k) ?? 0) > 1;6465 /** Rotate a mesh-local offset by the placement rotation (r quarters). */66 const rot = (r: Dir, x: number, z: number): [number, number] =>67 r === 0 ? [x, z] : r === 1 ? [z, -x] : r === 2 ? [-x, -z] : [-z, x];6869 interface Candidate {70 placement: Placement;71 /** What the host placement becomes (junction id; mirrored optional). */72 junction: string;73 mirrored?: boolean;74 branch: [number, number][]; // cells outward from the ring75 dir: Dir; // branch direction (away from the ring)76 }77 const taken = new Set<string>(occupied);78 const candidates: Candidate[] = [];79 for (const p of placements) {80 if (p.role !== "track" || p.pos[1] !== 0) continue;8182 if (p.pieceId === "straight") {83 const cx = Math.round(p.pos[0]);84 const cz = Math.round(p.pos[2]);85 if (86 arched.has(key(cx, cz)) || goalCells.has(key(cx, cz)) ||87 stacked(key(cx, cz))88 ) continue;89 // Placement rotation r maps entry-frame +z to the flow direction.90 const flow = ((1 - p.rotation + 4) % 4) as Dir;91 for (const side of [1, 3] as const) {92 const w = ((flow + side) % 4) as Dir;93 const [dx, dz] = DIR_STEP[w];94 const s1: [number, number] = [cx + dx, cz + dz];95 const s2: [number, number] = [cx + dx * 2, cz + dz * 2];96 if (taken.has(key(...s1)) || taken.has(key(...s2))) continue;97 candidates.push({98 placement: p,99 // side 1 = turnLeft(flow) — the native split branches left; a100 // right branch renders the same mesh mirrored.101 junction: "split",102 mirrored: side === 3,103 branch: [s1, s2],104 dir: w,105 });106 }107 }108109 // Turn junctions: split-left is the curve mesh plus a branch port on110 // the outer flank (measured — drop-in replacement); split-right is its111 // native mirror, so rendering it mirrored gives the same shape while112 // covering the right-handed id. Same for the large family.113 const turn = p.pieceId === "curve" || p.pieceId === "curve-solid"114 ? { large: false, solid: p.pieceId === "curve-solid" }115 : p.pieceId === "curve-large" || p.pieceId === "curve-solid-large"116 ? { large: true, solid: p.pieceId === "curve-solid-large" }117 : null;118 if (turn && !p.mirrored) {119 // Branch continues along mesh +x from the outer flank cell.120 const cell0 = turn.large ? [2, 1] : [1.5, 0.5];121 const w = ((0 - p.rotation + 4) % 4) as Dir;122 const b: [number, number][] = [];123 let ok = true;124 for (let j = 0; j < 2; j++) {125 const [ox, oz] = rot(p.rotation, cell0[0] + j, cell0[1]);126 const bx = Math.round(p.pos[0] + ox);127 const bz = Math.round(p.pos[2] + oz);128 if (taken.has(key(bx, bz))) ok = false;129 b.push([bx, bz]);130 }131 if (ok) {132 candidates.push({133 placement: p,134 junction: "", // resolved at placement time (left/right alternation)135 branch: b,136 dir: w,137 });138 }139 }140 }141 if (candidates.length === 0) return empty;142143 const count = rng.int(1, 3);144 let placed = 0;145 const usedHosts = new Set<Placement>();146 const plan: StubPlan = { placements: [], cells: [] };147 for (const cand of rng.shuffle(candidates)) {148 if (placed >= count) break;149 if (usedHosts.has(cand.placement)) continue;150 if (cand.branch.some(([x, z]) => taken.has(key(x, z)))) continue;151152 // The host becomes the junction (same cell, same rotation — the153 // junction's main channel is the host's channel).154 if (cand.junction === "split") {155 cand.placement.pieceId = "split";156 if (cand.mirrored) cand.placement.mirrored = true;157 } else {158 const large = cand.placement.pieceId.includes("large");159 const solid = cand.placement.pieceId.includes("solid");160 // Alternate ids: the right-handed GLB mirrored renders the same161 // left-handed shape (s-curve convention), keeping both ids in play.162 const right = rng.chance(0.5);163 cand.placement.pieceId = [164 "split",165 ...(large ? ["large"] : []),166 ...(solid ? ["solid"] : []),167 right ? "right" : "left",168 ].join("-");169 if (right) cand.placement.mirrored = true;170 }171 usedHosts.add(cand.placement);172173 // TEE-SPUR (M13): the branch stems into a split-double-sides — a174 // T-junction with NO through port (unusable on the ring, which kept175 // it coverage-exempt until now) whose two arms dead-end in caps. A176 // 3×2 footprint + 2 cap cells must be free; rarity is honest.177 const teePlaced = cand.junction === "split" && rng.chance(0.3) &&178 (() => {179 const w = cand.dir;180 const rT = ((1 - w + 4) % 4) as Dir;181 const [s1x, s1z] = cand.branch[0];182 const cellAt = (ex: number, ez: number): [number, number] => {183 const [ox, oz] = rot(rT, ex, ez);184 return [s1x + ox, s1z + oz];185 };186 const footprint: [number, number][] = [];187 for (const ez of [0, 1]) {188 for (const ex of [-1, 0, 1]) footprint.push(cellAt(ex, ez));189 }190 const capA = cellAt(2, 1); // arm exit toward mesh +x191 const capB = cellAt(-2, 1);192 const all = [...footprint, capA, capB];193 if (all.some(([x, z]) => taken.has(key(x, z)))) return false;194 const [mx, mz] = rot(rT, 0, 0.5);195 plan.placements.push({196 pieceId: "split-double-sides",197 pos: [s1x + mx, 0, s1z + mz],198 rotation: rT,199 role: "track",200 });201 const dirOf = (dx: number, dz: number): Dir =>202 dx === 1 ? 0 : dz === 1 ? 1 : dx === -1 ? 2 : 3;203 for (const [capCell, ex] of [[capA, 1], [capB, -1]] as const) {204 const [adx, adz] = rot(rT, ex, 0);205 const armDir = dirOf(adx, adz);206 plan.placements.push({207 pieceId: rng.pick(["end-rounded", "end-square"]),208 pos: [capCell[0], 0, capCell[1]],209 rotation: ((1 - armDir + 4) % 4) as Dir,210 role: "track",211 });212 }213 for (const [x, z] of all) {214 taken.add(key(x, z));215 plan.cells.push(key(x, z));216 }217 return true;218 })();219 if (teePlaced) {220 placed++;221 continue;222 }223224 const len = rng.chance(0.6) ? 2 : 1;225 const rotation = ((1 - cand.dir + 4) % 4) as Dir;226 const cap = rng.pick(CAPS);227 const pieces = len === 2 ? ["straight-hole", cap] : [cap];228 for (let j = 0; j < len; j++) {229 const [x, z] = cand.branch[j];230 plan.placements.push({231 pieceId: pieces[j],232 pos: [x, 0, z],233 rotation,234 role: "track",235 });236 taken.add(key(x, z));237 plan.cells.push(key(x, z));238 }239 // An unused branch cell stays free for decor when the spur is short.240 placed++;241 }242243 // STUB GARDEN (M19c): a clustered ensemble — 2-3 sidings on nearby244 // hosts sharing ONE cap style, with a trackside fan beyond the middle245 // stub's cap facing back at the ring (M19a loop wind: its capsule246 // brushes the main channel, so the garden literally breathes on247 // passing marbles). Own child stream, appended after the singles pass.248 const gRng = rng.child("garden");249 if (gRng.chance(0.35)) {250 const fresh = candidates.filter((c) =>251 !usedHosts.has(c.placement) &&252 c.junction === "split" &&253 !c.branch.some(([x, z]) => taken.has(key(x, z)))254 );255 // Cluster: seed candidate + others whose hosts are within 3 cells.256 for (const seedCand of gRng.shuffle(fresh)) {257 const [sx, sz] = [258 Math.round(seedCand.placement.pos[0]),259 Math.round(seedCand.placement.pos[2]),260 ];261 const cluster = [seedCand];262 for (const c of fresh) {263 if (c === seedCand || cluster.length >= 3) continue;264 if (usedHosts.has(c.placement) || cluster.some((m) => m.placement === c.placement)) continue;265 const dx = Math.abs(Math.round(c.placement.pos[0]) - sx);266 const dz = Math.abs(Math.round(c.placement.pos[2]) - sz);267 if (Math.max(dx, dz) <= 3) cluster.push(c);268 }269 if (cluster.length < 2) continue;270 const cap = gRng.pick(["end-rounded", "end-square"]);271 let ok = 0;272 for (const cand of cluster) {273 const [bx, bz] = cand.branch[0];274 if (taken.has(key(bx, bz))) continue;275 cand.placement.pieceId = "split";276 if (cand.mirrored) cand.placement.mirrored = true;277 usedHosts.add(cand.placement);278 plan.placements.push({279 pieceId: cap,280 pos: [bx, 0, bz],281 rotation: ((1 - cand.dir + 4) % 4) as Dir,282 role: "track",283 });284 taken.add(key(bx, bz));285 plan.cells.push(key(bx, bz));286 ok++;287 }288 if (ok >= 2) {289 // The garden fan: one cell past the middle stub's cap, blades290 // facing the ring (rotation maps local +z toward −dir).291 const mid = cluster[Math.floor(cluster.length / 2)];292 const [fx, fz] = DIR_STEP[mid.dir];293 const [bx, bz] = mid.branch[0];294 const fanCell: [number, number] = [bx + fx, bz + fz];295 if (!taken.has(key(...fanCell))) {296 const facing = ((mid.dir + 2) % 4) as Dir;297 plan.placements.push({298 pieceId: gRng.pick([299 "fan-standing-two",300 "fan-standing-four",301 "fan-two",302 "fan-four",303 ]),304 pos: [fanCell[0], 0, fanCell[1]],305 rotation: ((1 - facing + 4) % 4) as Dir,306 role: "track",307 spin: { axis: [0, 0, 1], radPerSec: 4 + gRng.float() * 3 },308 });309 taken.add(key(...fanCell));310 plan.cells.push(key(...fanCell));311 }312 }313 break;314 }315 }316 return plan;317}