clone

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

main
marbles / src / gen / endless / furniture.ts
furniture.ts 15.3K
1/**2 * Island furniture (ground vocabulary — an island top IS local ground):3 * stub sidings and boost fans on the ledge cells the planner claimed4 * beside island runs (IslandCrossing.sideCells).5 *6 * The planner claimed the ledge band and the island builder put full mass7 * beneath it; this pass runs AFTER realize/assemble because a ledge is8 * only furnishable when its host cell realized as a plain straight (a9 * tunnel arch or a wave has no side port). Stub hosts mutate in place10 * (straight → split, the loop-mode stubs.ts move: the junction's main11 * channel IS the host's channel; a right-hand branch renders the native12 * left-branching mesh mirrored); fans stand in the ledge cell with the13 * blade side facing the channel.14 */15import type { Dir, Placement } from "../../shared/types.ts";16import type { Rng } from "../rng.ts";17import type { RealizedWindow } from "../wfc.ts";18import { turnLeft, turnRight } from "../geom.ts";19import type { SegmentPlan } from "./path.ts";2021/** No hole caps on islands: a drain spur over solid mass reads wrong. */22const CAPS = ["end-rounded", "end-square"];23const FANS = [24 "fan-standing-two",25 "fan-standing-four",26 "fan-two",27 "fan-four",28 "fan-simple-two",29 "fan-simple-four",30];31const FAN_BOOST = 6;3233// M16 wind variety: fans are force-field volumes now (client fanField.ts),34// and two new placements exploit that. Both replace the legacy cross fan35// on a ledge via draws APPENDED after the legacy ones — every rng stream36// that existed before this milestone draws byte-identically.37const STANDING_FANS = ["fan-standing-two", "fan-standing-four"];38const TABLE_FANS = ["fan-two", "fan-four", "fan-simple-two", "fan-simple-four"];39/** Boost corridor: standing fan aligned WITH the channel on the straight40 * run, blowing along travel. Punctuation, not weather. */41const BOOST_FAN_CHANCE = 0.22;42/** Updraft column: table fan flat at the channel lip — lift assist for43 * marbles hugging that edge. */44const UPDRAFT_FAN_CHANCE = 0.2;45/** Lip offset toward the channel, TRACK units. Must stay < 0.5 so the46 * fan still rounds to its ledge cell (the ground-vocabulary test's own47 * "fans exist only as ledge furniture" rule). */48const LIP_OFFSET = 0.45;4950// M19a wind vocabulary: two more ledge-fan behaviors, drawn from values51// APPENDED after the M16 draws — every stream that existed before this52// milestone still draws byte-identically (per-cell child streams; nothing53// drew after `variant`).54/** Gust wall: a legacy cross fan upgraded to a pulsed blockade — full55 * blocked-phase strength shoves the line against the far wall, the56 * off-phase releases it. The pack must time the window. */57const GUST_CHANCE = 0.4;58/** Vortex: table fan at the lip whose swirl column parks over the59 * channel — marbles S-wiggle around the eddy center. */60const VORTEX_CHANCE = 0.15;61const GUST_STRENGTH = 14;62const GUST_LENGTH = 5;63const GUST_RADIUS = 1.1;64const GUST_DUTY = 0.45;65const GUST_PERIOD_MIN = 2.4;66const GUST_PERIOD_SPAN = 1.2;67const VORTEX_STRENGTH = 7;68const VORTEX_RADIUS = 1.2;69const VORTEX_HEIGHT = 1.6;70/** Dir index → unit step (0=+x, 1=+z, 2=−x, 3=−z). */71const DIR_STEP: readonly (readonly [number, number])[] = [72 [1, 0],73 [0, 1],74 [-1, 0],75 [0, -1],76];7778export interface FurnishedPiece {79 placement: Placement;80 /** Absolute plan-cell index of the host (for chapter stamping). */81 host: number;82}8384/** One gust wall's mutable pulse, for the coordination post-pass. */85export interface GustEntry {86 wind: { pulse: { period: number; duty: number; phase: number } };87 host: number;88 side: string;89 isle: number;90}9192/** Phase coordination over drawn gust walls — rng-FREE (a pure rewrite93 * of pulse phase/period; which ledges gust was already drawn):94 * - BANK: ≥2 same-side gusts on one island whose hosts are ≤2 cells95 * apart phase-lock into a travelling wave (phase_i = i·period/N along96 * travel) sharing the first member's period.97 * - OPPOSED PAIR: L and R gusts on the SAME host share a period and go98 * anti-phase (offset period/2) — an oscillating slalom gate.99 * Pair coupling runs last so a paired bank member still opposes its100 * across-channel partner. */101export function coordinateGusts(gusts: readonly GustEntry[]): void {102 const byIsleSide = new Map<string, GustEntry[]>();103 for (const g of gusts) {104 const key = `${g.isle}/${g.side}`;105 const list = byIsleSide.get(key);106 if (list) list.push(g);107 else byIsleSide.set(key, [g]);108 }109 for (const list of byIsleSide.values()) {110 list.sort((a, b) => a.host - b.host);111 let run: GustEntry[] = [];112 const flush = () => {113 if (run.length >= 2) {114 const period = run[0].wind.pulse.period;115 for (let i = 0; i < run.length; i++) {116 run[i].wind.pulse.period = period;117 run[i].wind.pulse.phase = (i * period) / run.length;118 }119 }120 run = [];121 };122 for (const g of list) {123 // Run gap ≤4 (M24 re-tune): an island's ledge row reads as one124 // authored bank even with a stub between the fans; climate biases125 // spread gusts thinner than the M19a ≤2 law assumed.126 if (run.length > 0 && g.host - run[run.length - 1].host > 4) flush();127 run.push(g);128 }129 flush();130 }131 // Opposed pairs: same island, same host, both sides.132 const byHost = new Map<string, GustEntry[]>();133 for (const g of gusts) {134 const key = `${g.isle}/${g.host}`;135 const list = byHost.get(key);136 if (list) list.push(g);137 else byHost.set(key, [g]);138 }139 for (const list of byHost.values()) {140 if (list.length < 2) continue;141 const [a, b] = list;142 b.wind.pulse.period = a.wind.pulse.period;143 b.wind.pulse.phase = a.wind.pulse.phase + a.wind.pulse.period / 2;144 }145}146147/** Bouncer pin chance per wide-deck cell (M13). */148const BUMPER_CHANCE = 0.6;149/** Lateral pin offset from the wide centerline, track units (the wide150 * channel spans ±0.85 — the marble can pass on either side, and the wide151 * walls guarantee recapture; the carom is the spectacle). */152const BUMPER_OFFSET = 0.25;153154export function planIslandFurniture(155 rng: Rng,156 plan: SegmentPlan,157 windows: readonly RealizedWindow[],158 /** Assembled placements in TRACK coords — stub hosts are MUTATED159 * (straight → split) exactly like loop-mode stubs. */160 assembled: Placement[],161 /** M21: the zone's BASE weather leans the wind vocabulary — ember162 * storms gust harder and rise; snow muffles the gusts. Threshold163 * multipliers only: draw counts never change. */164 weather: string = "none",165): FurnishedPiece[] {166 const gustMul = weather === "embers" ? 1.4 : weather === "snow" ? 0.7 : 1;167 const updraftMul = weather === "embers" ? 1.4 : 1;168 const out: FurnishedPiece[] = [];169 const gusts: GustEntry[] = [];170 const windowAt = (idx: number): RealizedWindow | undefined =>171 windows.find((w) => idx >= w.start && idx < w.start + w.len);172173 // BOUNCER PINS (M13): mushroom bumpers on wide island decks — plinko174 // pins the marble caroms around (high-restitution cylinder proxy;175 // outcome stochastic, recapture guaranteed by the wide channel walls).176 // Wide cells only (the narrow channel has no room to pass a pin) and177 // never inside a planner zone (a boost bleeding into the wide deck must178 // launch true, not pinball).179 for (const crossing of plan.crossings) {180 for (const w of windows) {181 if (182 w.start < crossing.start || w.start >= crossing.start + crossing.len183 ) {184 continue;185 }186 if (w.def.id !== "straight-wide") continue;187 if (188 plan.mods.some((m) => w.start >= m.start && w.start < m.start + m.len)189 ) continue;190 const host = plan.cells[w.start];191 const r = rng.child(`isle/${crossing.id}/bumper/${host.x},${host.z}`);192 if (!r.chance(BUMPER_CHANCE)) continue;193 const side: Dir = r.chance(0.5)194 ? turnLeft(host.dirIn)195 : turnRight(host.dirIn);196 const [dx, dz] = side === 0197 ? [1, 0]198 : side === 1199 ? [0, 1]200 : side === 2201 ? [-1, 0]202 : [0, -1];203 out.push({204 placement: {205 pieceId: "nature-kit:mushroom_red",206 pos: [207 host.x + dx * BUMPER_OFFSET,208 host.baseY + 0.25, // the channel floor — the pin stands IN it209 host.z + dz * BUMPER_OFFSET,210 ],211 rotation: r.int(0, 4) as Dir,212 role: "track",213 bumper: true,214 },215 host: w.start,216 });217 }218 }219220 for (const crossing of plan.crossings) {221 for (const sc of crossing.sideCells ?? []) {222 const host = plan.cells[sc.host];223 const win = windowAt(sc.host);224 // Plain straight hosts only — the family carries a bare centered225 // channel (the banner rule's own test); anything else has no226 // usable side port.227 if (!win || win.def.family !== "straight") continue;228 const hp = assembled.find((p) =>229 p.role === "track" && p.pieceId === "straight" &&230 Math.round(p.pos[0]) === host.x &&231 Math.round(p.pos[2]) === host.z &&232 Math.abs(p.pos[1] - host.baseY) < 1e-6233 );234 if (!hp) continue;235236 const r = rng.child(237 `isle/${crossing.id}/side/${sc.cell[0]},${sc.cell[1]}`,238 );239 const away: Dir = sc.side === "L"240 ? turnLeft(host.dirIn)241 : turnRight(host.dirIn);242 const [cx, cz] = sc.cell;243244 if (r.chance(0.55)) {245 // STUB SIDING: host becomes the 3-way split; the ledge gets a246 // dead-end cap opening back toward the junction.247 hp.pieceId = "split";248 if (sc.side === "R") hp.mirrored = true;249 out.push({250 placement: {251 pieceId: r.pick(CAPS),252 pos: [cx, host.baseY, cz],253 rotation: ((1 - away + 4) % 4) as Dir,254 role: "track",255 },256 host: sc.host,257 });258 } else {259 // FAN: blades face the channel, spinning; boost is kinematic-260 // mode flavor (the endless assist law has its own zones).261 const facing = ((away + 2) % 4) as Dir;262 // Legacy draw order preserved EXACTLY (pick, then float), then263 // the M16 wind-variant draws appended after.264 const crossPiece = r.pick(FANS);265 const radPerSec = 4 + r.float() * 3;266 const variant = r.float();267 const [tx, tz] = DIR_STEP[facing]; // unit step toward the channel268 if (variant < BOOST_FAN_CHANCE) {269 // BOOST CORRIDOR: standing fan aligned WITH the channel,270 // blowing along travel. The scaled disc spans 2 world units —271 // its near edge reaches the rail line — so the wind hint parks272 // the capsule 1 world unit over the channel edge and sinks it273 // toward marble height. ~6.5 u/s² tailwind at the face fading274 // over 5 world units, strongest on the fan's side of the275 // corridor. (Wind hint values are WORLD units.)276 out.push({277 placement: {278 pieceId: r.pick(STANDING_FANS),279 pos: [cx, host.baseY, cz],280 rotation: ((1 - host.dirIn + 4) % 4) as Dir,281 role: "track",282 spin: { axis: [0, 0, 1], radPerSec },283 boost: FAN_BOOST,284 wind: {285 strength: 6.5,286 length: 5,287 radius: 1.3,288 offset: [tx, -0.3, tz],289 },290 },291 host: sc.host,292 });293 } else if (variant < BOOST_FAN_CHANCE + UPDRAFT_FAN_CHANCE * updraftMul) {294 // UPDRAFT COLUMN: table fan flat at the channel lip, blowing295 // straight up — ~9.5 u/s² against gravity 20 (half-weight lift296 // wobble / jump assist, not orbit) for marbles hugging this297 // edge; the hint slides the column over the lip and starts it298 // low enough to catch a rolling marble.299 out.push({300 placement: {301 pieceId: r.pick(TABLE_FANS),302 pos: [303 cx + tx * LIP_OFFSET,304 host.baseY,305 cz + tz * LIP_OFFSET,306 ],307 rotation: ((1 - facing + 4) % 4) as Dir,308 role: "track",309 spin: { axis: [0, 0, 1], radPerSec },310 boost: FAN_BOOST,311 wind: {312 strength: 9.5,313 length: 2.2,314 radius: 0.9,315 offset: [tx * 0.35, -0.5, tz * 0.35],316 },317 },318 host: sc.host,319 });320 } else {321 // M19a draws — appended after the legacy prefix (pick, float,322 // variant), so they exist only in this branch and this cell's323 // child stream; nothing before them moved.324 const extra = r.float();325 if (extra < GUST_CHANCE * gustMul) {326 // GUST WALL: the cross fan keeps its mesh, pose and spin —327 // the upgrade is a pulsed wind field spanning the channel.328 // Coordination (banks / opposed pairs) is a phase-only329 // post-pass over these entries (coordinateGusts).330 const period = GUST_PERIOD_MIN + r.float() * GUST_PERIOD_SPAN;331 const phase = r.float() * period;332 const wind = {333 strength: GUST_STRENGTH,334 length: GUST_LENGTH,335 radius: GUST_RADIUS,336 offset: [tx, -0.3, tz] as [number, number, number],337 pulse: { period, duty: GUST_DUTY, phase },338 };339 out.push({340 placement: {341 pieceId: crossPiece,342 pos: [cx, host.baseY, cz],343 rotation: ((1 - facing + 4) % 4) as Dir,344 role: "track",345 spin: { axis: [0, 0, 1], radPerSec },346 boost: FAN_BOOST,347 wind,348 },349 host: sc.host,350 });351 gusts.push({ wind, host: sc.host, side: sc.side, isle: crossing.id });352 } else if (extra < GUST_CHANCE * gustMul + VORTEX_CHANCE) {353 // VORTEX: table fan at the lip (ledge rule holds — the354 // housing rounds to its cell like the updraft); the swirl355 // column parks over the channel and marbles arc around it.356 out.push({357 placement: {358 pieceId: TABLE_FANS[Math.floor(r.float() * TABLE_FANS.length)],359 pos: [360 cx + tx * LIP_OFFSET,361 host.baseY,362 cz + tz * LIP_OFFSET,363 ],364 rotation: ((1 - facing + 4) % 4) as Dir,365 role: "track",366 spin: { axis: [0, 0, 1], radPerSec },367 boost: FAN_BOOST,368 wind: {369 strength: VORTEX_STRENGTH,370 length: VORTEX_HEIGHT,371 radius: VORTEX_RADIUS,372 offset: [tx * 1.1, -0.5, tz * 1.1],373 swirl: true,374 },375 },376 host: sc.host,377 });378 } else {379 // CROSS-WIND (legacy): blades face the channel; the derived380 // capsule crosses it — the client fan field turns this into a381 // lateral gust with no field needed here.382 out.push({383 placement: {384 pieceId: crossPiece,385 pos: [cx, host.baseY, cz],386 rotation: ((1 - facing + 4) % 4) as Dir,387 role: "track",388 spin: { axis: [0, 0, 1], radPerSec },389 boost: FAN_BOOST,390 },391 host: sc.host,392 });393 }394 }395 }396 }397 }398 coordinateGusts(gusts);399 return out;400}