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 / islands.ts
islands.ts 30.3K
1/**2 * Island builder — the anchors of the endless line.3 *4 * The path planner has already committed an anchored run and claimed a mass5 * envelope (`isle/${id}` bands: per-cell free depth below the top surface,6 * pre-shrunk around older track that crosses under). This builder realizes7 * that claim as a cliff-block voxel mass with a biome on top:8 *9 * - mass: nature-kit cliff_block family (1×1×1 blocks + 0.5/0.25 slabs) on10 * our exact grid; columns under the run always exist, blob rings around it11 * thin out and shallow out with distance (stepped underside taper — the12 * floating-island silhouette).13 * - biome: data-driven family table (material, cap tiles, decor rules,14 * density). v1 rows draw on the nature kit; lava/snow/city are future rows15 * pointing at future packs.16 * - decor: the M2a field API over the island's top — region = island bbox,17 * oracle = the anchored run's cells, placements filtered to real mass tops18 * and lifted to topY. Per-cell RNG children keep it order-independent.19 *20 * Every placement belongs to the island's ref; culling an island releases21 * its claim and its render group together.22 */23import type { Placement } from "../../shared/types.ts";24import type { Rng } from "../rng.ts";25import {26 cellSetOracle,27 type DecorCatalog,28 placeDecor,29 regionAround,30 settingByName,31} from "../decor.ts";32import type { StyleField } from "../style.ts";33import { Occupancy } from "./occupancy.ts";34import type { IslandCrossing } from "./path.ts";35import { TRACK_SCALE, worldCellsOf } from "./scale.ts";3637export interface BiomeSpec {38 name: string;39 weight: number;40 /** Cliff family material suffix. */41 material: "rock" | "stone";42 /** Cap tile on non-track top cells (FULLY-QUALIFIED pieceId, e.g.43 * "nature-kit:ground_grass"), or bare block tops. Only the nature44 * ground tile is a clean 1×1 — themed biomes reuse it and the client45 * tints it per biome (snow, sand). */46 capId: string | null;47 capChance: number;48 /** SETTINGS row the top decor borrows, or null for barren tops. */49 setting: string | null;50 decorDensity: number;51 /** Landmark vocabulary (fully-qualified ids), one per island at most.52 * Omitted = the default nature statue/rock list. */53 landmarks?: readonly string[];54 /** Uniform scale on landmark placements (pack size normalization);55 * omitted = unscaled, byte-identical to the pre-M18a canon. */56 landmarkScale?: number;57 /** Island MASS FAMILY (user directive: other packs as other island58 * types, not just color phenotypes). Omitted = "cliff" (the nature59 * voxel family with the full shaping vocabulary). */60 massFamily?: string;61}6263/** A mass family: what an island's body is BUILT from. The cliff family64 * keeps the whole nature shaping vocabulary (wedges, trims, caves,65 * waterfalls, strata); "block" families are simpler voxel stacks with66 * INTEGRATED tops (no cap tile) — measured: Platformer blocks are clean67 * 1.0 full / 0.5 low courses, bottom-center, with a cosmetic 0.082 lip68 * overhang that reads as authored trim. Families without quarter courses69 * bottom out up to 0.45 shallow — the underside faces the void.70 * RESIDUAL: block-*-corner rim rounding awaits a mesh-orientation71 * measure (bbox alone cannot orient the rounded edge). */72interface MassFamily {73 kind: "cliff" | "block";74 block?: string;75 half?: string;76}7778const MASS_FAMILIES: Record<string, MassFamily> = {79 cliff: { kind: "cliff" },80 blockGrass: {81 kind: "block",82 block: "platformer-kit:block-grass",83 half: "platformer-kit:block-grass-low",84 },85 blockSnow: {86 kind: "block",87 block: "platformer-kit:block-snow",88 half: "platformer-kit:block-snow-low",89 },90};9192/** Island families. Emergent variety comes from (biome × size × run shape ×93 * depth noise); new packs slot in as new rows. */94export const BIOMES: readonly BiomeSpec[] = [95 {96 name: "forest",97 weight: 3,98 material: "stone",99 capId: "nature-kit:ground_grass",100 capChance: 0.9,101 setting: "forest",102 decorDensity: 0.65,103 },104 {105 name: "meadow",106 weight: 2.5,107 material: "stone",108 capId: "nature-kit:ground_grass",109 capChance: 0.95,110 setting: "meadow",111 decorDensity: 0.55,112 },113 {114 name: "rocky",115 weight: 2.5,116 material: "rock",117 capId: null,118 capChance: 0,119 setting: "rocky",120 decorDensity: 0.4,121 },122 {123 name: "barren",124 weight: 1.5,125 material: "rock",126 capId: null,127 capChance: 0,128 setting: "rocky",129 decorDensity: 0.15,130 },131 // Rare novelty: dev-map island — bare geometry in TF2-orange/grey blocking132 // colors (the renderer swaps its color scheme; no caps, no vegetation).133 {134 name: "abstract",135 weight: 0.6,136 material: "rock",137 capId: null,138 capChance: 0,139 setting: null,140 decorDensity: 0,141 },142 // M18a themed rows (Holiday/Graveyard/Pirate/Survival kits). Weights143 // sized so the nature families keep ~70% of draws — a themed island is144 // an EVENT (liveliness = distribution over a long stretch, not145 // density). Caps reuse the 1×1 nature ground tile; the client tints it146 // per biome (snow / sand). Landmark scales normalize pack sizing147 // (pirate ~2.5-3x nature, survival props tiny) — measured 2026-08-09.148 {149 name: "winter",150 weight: 1.2,151 material: "stone",152 // Mass-family round: winter islands are BUILT of snow now (Platformer153 // block-snow voxels, integrated snow top) — the tinted-cliff + white154 // cap phenotype retired.155 capId: null,156 capChance: 0,157 setting: "winter",158 decorDensity: 0.5,159 landmarks: [160 "holiday-kit:snowman",161 "holiday-kit:snowman-hat",162 "holiday-kit:tree-decorated",163 "holiday-kit:nutcracker",164 ],165 landmarkScale: 1.1,166 massFamily: "blockSnow",167 },168 {169 name: "spooky",170 weight: 0.8,171 material: "rock",172 capId: null,173 capChance: 0,174 setting: "spooky",175 decorDensity: 0.55,176 landmarks: [177 "graveyard-kit:crypt-small",178 "graveyard-kit:crypt",179 "graveyard-kit:pillar-obelisk",180 "graveyard-kit:cross-column",181 "graveyard-kit:lightpost-all",182 ],183 landmarkScale: 1.2,184 },185 {186 name: "tropical",187 weight: 1.2,188 material: "stone",189 capId: "nature-kit:ground_grass",190 capChance: 0.85,191 setting: "tropical",192 decorDensity: 0.5,193 landmarks: [194 "pirate-kit:flag-pirate-high",195 "pirate-kit:cannon-mobile",196 "pirate-kit:tower-complete-small",197 ],198 landmarkScale: 0.35,199 },200 {201 name: "autumn",202 weight: 1.0,203 material: "rock",204 capId: null,205 capChance: 0,206 setting: "autumn",207 decorDensity: 0.55,208 landmarks: [209 "survival-kit:tent",210 "survival-kit:structure-canvas",211 "survival-kit:signpost",212 "survival-kit:campfire-pit",213 "survival-kit:workbench-anvil",214 ],215 landmarkScale: 1.5,216 },217 // Mass-family round: a whole-different island BODY — chunky grass218 // voxels with the platformer lip, integrated green top.219 {220 name: "grove",221 weight: 1.0,222 material: "stone",223 capId: null,224 capChance: 0,225 setting: "forest",226 decorDensity: 0.6,227 massFamily: "blockGrass",228 },229];230231export interface Island {232 id: number;233 ref: string;234 biome: string;235 /** Top surface height in WORLD units (track topY × TRACK_SCALE). */236 topY: number;237 /** Placements in WORLD coordinates (island blocks/decor are 1× scale —238 * the slalom rule: track ×2, world 1×). */239 placements: Placement[];240 /** Top-surface mass cells (world-grid "x,z"), the run patch included. */241 cells: string[];242}243244const NATURE = "nature-kit";245const key = (x: number, z: number) => `${x},${z}`;246247/** Default landmark vocabulary (fully-qualified) — the historical nature248 * statue/rock list, byte-identical draws for the pre-M18a biome rows. */249const DEFAULT_LANDMARKS: readonly string[] = [250 "nature-kit:statue_head",251 "nature-kit:statue_obelisk",252 "nature-kit:statue_column",253 "nature-kit:statue_columnDamaged",254 "nature-kit:statue_ring",255 "nature-kit:rock_tallA",256 "nature-kit:rock_tallD",257 "nature-kit:stone_tallB",258];259260export function buildIsland(261 rng: Rng,262 crossing: IslandCrossing,263 runCells: readonly (readonly [number, number])[],264 occ: Occupancy,265 style: StyleField,266 catalog: DecorCatalog,267): Island {268 const { id, topY, margin } = crossing;269 const worldTop = topY * TRACK_SCALE;270 const ref = `isle/${id}`;271 // Terrace top per TRACK cell — the EXACT rule the planner claimed with272 // (nearest run cell's floor base, Chebyshev, ties pick the lower top).273 // Mass claims are recognized by their y1 matching this; recomputing274 // with any other rule would orphan claims at tie cells.275 const profile = crossing.profile;276 const terraceTop = (cx: number, cz: number): number => {277 let best = Infinity;278 let top = topY;279 for (let j = 0; j < runCells.length; j++) {280 const [rx, rz] = runCells[j];281 const d = Math.max(Math.abs(cx - rx), Math.abs(cz - rz));282 if (d < best || (d === best && profile[j] < top)) {283 best = d;284 top = profile[j];285 }286 }287 return top;288 };289 // M24: the seed's temperament leans the island draw — a serene seed290 // grows meadows, a treacherous one spooky crags.291 const biome = rng.weighted(292 BIOMES.map((b) =>293 [b, b.weight * (style.temperament.biomeBias[b.name] ?? 1)] as const294 ),295 );296 const family = MASS_FAMILIES[biome.massFamily ?? "cliff"] ??297 MASS_FAMILIES.cliff;298 const placements: Placement[] = [];299300 // The run's WORLD patch: each track cell covers a 2×2 set of world cells301 // (slalom rule — the channel swath the mass must carry). A diamond's302 // branch columns are track too: full mass beneath, no caps, no decor.303 const runWorld = new Set<string>();304 for (const [cx, cz] of runCells) {305 for (const [wx, wz] of worldCellsOf(cx, cz)) runWorld.add(key(wx, wz));306 }307 for (const [cx, cz] of crossing.branchCells ?? []) {308 for (const [wx, wz] of worldCellsOf(cx, cz)) runWorld.add(key(wx, wz));309 }310 // Wide-run flanks and furniture ledges are track patch too: full mass311 // beneath, no caps (a cap tile would z-fight the channel/cap pieces312 // standing there), no decor.313 for (const [cx, cz] of crossing.wideFlanks ?? []) {314 for (const [wx, wz] of worldCellsOf(cx, cz)) runWorld.add(key(wx, wz));315 }316 for (const { cell } of crossing.sideCells ?? []) {317 for (const [wx, wz] of worldCellsOf(cell[0], cell[1])) {318 runWorld.add(key(wx, wz));319 }320 }321 const runWorldList = [...runWorld].map((k) => k.split(",").map(Number));322 const chebToRun = (x: number, z: number): number => {323 let best = Infinity;324 for (const [rx, rz] of runWorldList) {325 best = Math.min(best, Math.max(Math.abs(x - rx), Math.abs(z - rz)));326 }327 return best;328 };329330 // ---------------------------------------------------------------------331 // Mass columns on the WORLD grid: run columns always; ring columns thin332 // and shallow with distance → stepped inverted-ziggurat underside. All333 // decisions per-world-cell-keyed (order-independent).334 // ---------------------------------------------------------------------335 const keepFor = (dist: number): number =>336 dist <= 1 ? 1 : Math.max(0.2, 1.05 - dist * 0.16);337 const topCells: string[] = [];338339 // PASS 1 — column existence + depth per world cell (all per-cell-keyed340 // draws, order-independent). Shaping needs neighbor knowledge, so341 // emission is a second pass.342 interface Col {343 wx: number;344 wz: number;345 tx: number;346 tz: number;347 topWorld: number;348 depth: number;349 /** Claim bottom (world) — the column was clipped here if an older350 * band sits directly beneath. */351 clipY: number;352 clipped: boolean;353 onRun: boolean;354 dist: number;355 }356 const cols = new Map<string, Col>();357 for (const claim of occ.claimsOf(ref)) {358 const { x, z, y0, y1 } = claim;359 const cellTop = terraceTop(x, z);360 if (Math.abs(y1 - cellTop) > 1e-9) continue; // canopy claim, not mass361 const cellTopWorld = cellTop * TRACK_SCALE;362 const availWorld = (cellTop - y0) * TRACK_SCALE;363 if (availWorld < 0.999) continue;364 for (const [wx, wz] of worldCellsOf(x, z)) {365 const cellRng = rng.child(`cell/${wx}/${wz}`);366 const dist = chebToRun(wx, wz);367 const onRun = runWorld.has(key(wx, wz));368 if (!onRun && !cellRng.chance(keepFor(dist))) continue;369370 // Depth target: deepest under the run, shallowing outward, jittered.371 const target = Math.max(372 1,373 (margin * TRACK_SCALE + 2.5 - dist) * (0.5 + cellRng.float() * 0.45),374 );375 const depth = quantize(Math.min(availWorld, target));376 cols.set(key(wx, wz), {377 wx,378 wz,379 tx: x,380 tz: z,381 topWorld: cellTopWorld,382 depth,383 clipY: y0 * TRACK_SCALE,384 clipped: depth >= availWorld - 1e-9 && availWorld < target,385 onRun,386 dist,387 });388 }389 }390391 // ISLAND SHAPING (backlog): trims, cave mouths, waterfalls, landmarks.392 // A shoulder cell is a non-run top with at least one open orthogonal393 // side at its own level; single-open sides slope down and out, double-394 // open corners take the diagonal cut. Old-track piercings get a cave395 // mouth facing along the pierced channel; one edge column per some396 // islands runs a waterfall down its face; big islands may carry one397 // landmark. Shaped tops are excluded from caps/decor (their surface398 // is no longer flat).399 const DIRS: readonly (readonly [number, number])[] = [400 [1, 0],401 [0, 1],402 [-1, 0],403 [0, -1],404 ];405 /** How far the surface DROPS from this column's top to the neighbor406 * (Infinity into the void). Shaping decisions read the surface through407 * this: a wedge face needs a full course of air or it sinks into the408 * neighbor's block. */409 const dropAt = (c: Col, dx: number, dz: number): number => {410 const n = cols.get(key(c.wx + dx, c.wz + dz));411 return n ? c.topWorld - n.topWorld : Infinity;412 };413 /** Open at the column's own level: no column there, or one whose top414 * sits a FULL course or more lower (USER RULE: pyramid/wedge blocks415 * must respect the surface they are on — a side counted "open" at a416 * half-course drop buried the wedge's low edge in its neighbor). */417 const openAt = (c: Col, dx: number, dz: number): boolean =>418 dropAt(c, dx, dz) >= 0.99;419 const shaped = new Set<string>(); // no caps/decor on these tops420421 // Shoulder trim is an ISLAND-level trait (WFC coherence rule, USER RULE:422 // triangles must never read jagged): either the whole rim bevels or the423 // island stays blocky. The old per-cell 0.7 coin left random full cubes424 // standing inside bevel runs — a sawtooth silhouette.425 const trimIsland = rng.child("trim").chance(0.75);426427 // Waterfall pick (per island): one qualifying edge column.428 let waterfallCell: string | null = null;429 {430 const wRng = rng.child("waterfall");431 if (wRng.chance(0.4)) {432 const cands = [...cols.values()]433 .filter((c) =>434 !c.onRun && c.dist >= 1 && c.depth >= 2 &&435 DIRS.some(([dx, dz]) => openAt(c, dx, dz))436 )437 .map((c) => key(c.wx, c.wz))438 .sort();439 if (cands.length > 0) waterfallCell = wRng.pick(cands);440 }441 }442 // Landmark pick (per island, larger islands lean in). Biome rows may443 // carry their own vocabulary; the default is the nature statue list444 // (same length + order as ever — the pick index is stream-identical).445 let landmarkCell: string | null = null;446 const LANDMARKS = biome.landmarks ?? DEFAULT_LANDMARKS;447 {448 const lRng = rng.child("landmark");449 if (lRng.chance(margin >= 2 ? 0.35 : 0.15)) {450 const cands = [...cols.values()]451 .filter((c) => {452 if (c.onRun || c.dist < 2) return false;453 // Needs its own airspace (canopy claim at the cell's terrace).454 const top = c.topWorld / TRACK_SCALE;455 return occ.at(c.tx, c.tz).some((b) =>456 b.ref === ref && b.y1 > top + 0.5457 );458 })459 .map((c) => key(c.wx, c.wz))460 .sort();461 if (cands.length > 0) landmarkCell = lRng.pick(cands);462 }463 }464465 // Per-island deep-course material swap (varied texturing): some466 // islands sit on a base of the OTHER cliff material.467 const otherMat = biome.material === "rock" ? "stone" : "rock";468 const deepMat = rng.child("strata").chance(0.4) ? otherMat : biome.material;469 const matAt = (yTop: number, topWorld: number): string =>470 yTop <= topWorld - 2 + 1e-9 ? deepMat : biome.material;471472 /** Rotation turning local −z toward world direction (dx, dz) — the473 * cave arch and the waterfall face live on −z (measured in gallery). */474 const faceRot = (dx: number, dz: number): Placement["rotation"] =>475 (dx === 1 ? 3 : dx === -1 ? 1 : dz === -1 ? 0 : 2) as Placement[476 "rotation"477 ];478 const dirOf = (dx: number, dz: number): number =>479 dx === 1 ? 0 : dz === 1 ? 1 : dx === -1 ? 2 : 3;480 /** cliff_blockSlope descends toward local +x (measured): rotation481 * mapping local +x onto world dir w. */482 const slopeRot = (w: number): Placement["rotation"] =>483 ((4 - w) % 4) as Placement["rotation"];484 /** cliff_blockDiagonal's cut corner spans local +x/+z (measured):485 * rotation mapping that corner onto adjacent open dirs (k, k+1). */486 const diagRot = (k: number): Placement["rotation"] =>487 ((4 - k) % 4) as Placement["rotation"];488489 /** The slope direction a column would wedge toward under the490 * flush-or-open law, from tops alone (null = keeps the cube; corner491 * diagonals also report null). Pure in `cols`, so a cell can ask it of492 * a NEIGHBOR: the tops-only law is blind to the neighbor's top-block493 * SHAPE, and two same-height side-by-side wedges sloping OPPOSITE ways494 * would each treat the other's sloping side as an anchor — leaving a495 * bowtie of exposed triangle slivers where the faces don't overlap. */496 const wedgeDirOf = (col: Col | undefined): number | null => {497 if (!col || col.onRun || col.dist < 1 || col.depth < 1) return null;498 let openDir = -1;499 for (let w = 0; w < 4; w++) {500 const d = dropAt(col, DIRS[w][0], DIRS[w][1]);501 if (d > 0.01 && d < 0.99) return null;502 if (d >= 0.99) {503 if (openDir >= 0) return null;504 openDir = w;505 }506 }507 return openDir >= 0 ? openDir : null;508 };509510 // PASS 1.5 — RIM-RUN COHERENCE (user round 2 of the triangle blocks):511 // per-cell wedge eligibility flickers along a straight rim (half-step512 // neighbors, protrusion corners, depth dips), and a rim that mixes513 // chamfered and square cells reads as a sawtooth comb — the wedge law514 // kept every piece well-mated yet ENFORCED the jagged silhouette.515 // A slope is now only allowed inside a contiguous run of ≥3 cells all516 // eligible toward the SAME open side: long rims read as one authored517 // bevel, noisy rims keep calm cubes. Diagonals only cap a committed518 // run.519 const slopeEligible = new Map<string, number>();520 for (const c of cols.values()) {521 const k = key(c.wx, c.wz);522 if (k === waterfallCell || c.onRun || c.dist < 1 || c.depth < 1) continue;523 const open = DIRS.filter(([dx, dz]) => openAt(c, dx, dz));524 if (open.length !== 1) continue;525 const flush = DIRS.every(([dx, dz]) => {526 const d = dropAt(c, dx, dz);527 return d <= 0.01 || d >= 0.99;528 });529 if (!flush) continue;530 const w = dirOf(open[0][0], open[0][1]);531 const sideMated = [1, 3].every((t) => {532 const s = (w + t) % 4;533 const n = cols.get(key(c.wx + DIRS[s][0], c.wz + DIRS[s][1]));534 if (!n || Math.abs(n.topWorld - c.topWorld) > 0.01) return true;535 return wedgeDirOf(n) !== (w + 2) % 4;536 });537 if (sideMated) slopeEligible.set(k, w);538 }539 const trimKeep = new Set<string>();540 for (const [k, w] of slopeEligible) {541 const [x, z] = k.split(",").map(Number);542 const [px, pz] = w % 2 === 0 ? [0, 1] : [1, 0]; // along the rim543 let run = 1;544 for (const s of [1, -1]) {545 let n = 1;546 while (slopeEligible.get(key(x + px * n * s, z + pz * n * s)) === w) {547 run++;548 n++;549 }550 }551 if (run >= 3) trimKeep.add(k);552 }553 const nearTrimRun = (wx: number, wz: number): boolean =>554 DIRS.some(([dx, dz]) => trimKeep.has(key(wx + dx, wz + dz)));555556 // PASS 2 — emit blocks with shaping.557 for (const c of cols.values()) {558 const cellRng = rng.child(`blocks/${c.wx}/${c.wz}`);559 const k = key(c.wx, c.wz);560 const open = DIRS.filter(([dx, dz]) => openAt(c, dx, dz));561562 // Waterfall column: waterfallTop at the crest, waterfall faces below.563 const isFall = k === waterfallCell && open.length > 0;564 // Shoulder trim on plain edge tops (never on the waterfall). WFC565 // wedge law (USER RULE: triangles must never read jagged): every side566 // of a wedge must be either FLUSH (neighbor top at the wedge's own567 // level or higher) or fully OPEN (a clean full-course drop). Any568 // in-between step — the kit's 0.5/0.25 half courses — disqualifies569 // the cell: half-step terraces otherwise grow staircases of exposed570 // triangle profiles. A slope then needs exactly ONE open side; a571 // diagonal cut exactly two ADJACENT open sides with the corner572 // between them open too (else the cut face jams into that corner).573 const flush = DIRS.every(([dx, dz]) => {574 const d = dropAt(c, dx, dz);575 return d <= 0.01 || d >= 0.99;576 });577 const adjacentOpen = open.length === 2 &&578 open[0][0] !== open[1][0] && open[0][1] !== open[1][1];579 const dirs = open.map(([dx, dz]) => dirOf(dx, dz));580 const corner = adjacentOpen &&581 dropAt(c, open[0][0] + open[1][0], open[0][1] + open[1][1]) >= 0.99;582 // Side-mating: a slope's side triangles only mate with a same-height583 // neighbor that is a cube, a wedge sloping the SAME way, or a wedge584 // sloping perpendicular-away (whose facing side is its full back).585 // An opposite-sloping same-height pair is forbidden (both cells keep586 // cubes — the check is symmetric).587 const sideMated = open.length !== 1 ||588 [1, 3].every((t) => {589 const w = dirs[0];590 const s = (w + t) % 4;591 const n = cols.get(key(c.wx + DIRS[s][0], c.wz + DIRS[s][1]));592 if (!n || Math.abs(n.topWorld - c.topWorld) > 0.01) return true;593 return wedgeDirOf(n) !== (w + 2) % 4;594 });595 const trim = trimIsland && !isFall && !c.onRun && c.dist >= 1 &&596 c.depth >= 1 && flush && sideMated &&597 (corner598 // A diagonal cut only CAPS a committed bevel run — a lone corner599 // notch on an untrimmed rim reads as a random bite.600 ? nearTrimRun(c.wx, c.wz)601 : open.length === 1 && trimKeep.has(k));602603 // BLOCK mass families (user directive): a simpler voxel body — full604 // + half courses only, integrated top, no cliff shaping. The cliff605 // path below is untouched (byte-identical for cliff biomes).606 if (family.kind === "block") {607 let remB = c.depth;608 let yB = c.topWorld;609 while (remB >= 1 - 1e-9) {610 placements.push({611 pieceId: family.block!,612 pos: [c.wx, yB - 1, c.wz],613 rotation: cellRng.int(0, 4) as Placement["rotation"],614 role: "support",615 });616 yB -= 1;617 remB -= 1;618 }619 if (remB >= 0.5 - 1e-9 && family.half) {620 placements.push({621 pieceId: family.half,622 pos: [c.wx, yB - 0.5, c.wz],623 rotation: cellRng.int(0, 4) as Placement["rotation"],624 role: "support",625 });626 }627 topCells.push(k);628 if (k === landmarkCell && !shaped.has(k)) {629 placements.push({630 pieceId: rng.child(`landmark/${k}`).pick(LANDMARKS as string[]),631 pos: [c.wx, c.topWorld, c.wz],632 rotation: cellRng.int(0, 4) as Placement["rotation"],633 role: "decor",634 ...(biome.landmarkScale !== undefined635 ? { scale: biome.landmarkScale }636 : {}),637 });638 shaped.add(k);639 }640 continue;641 }642643 // One face per waterfall column — water can't zigzag around the644 // corner course to course (the old per-course re-pick did).645 const fallFace = isFall ? cellRng.pick(open) : null;646647 let remaining = c.depth;648 let yTop = c.topWorld;649 let course = 0;650 while (remaining >= 1 - 1e-9) {651 const mat = matAt(yTop, c.topWorld);652 if (course === 0 && isFall) {653 const [dx, dz] = fallFace!;654 placements.push({655 pieceId: `${NATURE}:cliff_waterfallTop_${mat}`,656 pos: [c.wx, yTop - 1, c.wz],657 rotation: faceRot(dx, dz),658 role: "support",659 });660 shaped.add(k);661 } else if (course > 0 && isFall && remaining >= 2 - 1e-9) {662 // Water continues down the face toward the void.663 const [dx, dz] = fallFace!;664 placements.push({665 pieceId: `${NATURE}:cliff_waterfall_${mat}`,666 pos: [c.wx, yTop - 1, c.wz],667 rotation: faceRot(dx, dz),668 role: "support",669 });670 } else if (course === 0 && trim) {671 // Slope toward the single open side; diagonal across a corner672 // (the cut needs the cell BETWEEN its two open sides open too,673 // else the cut face jams into that block's corner).674 placements.push({675 pieceId: `${NATURE}:${676 corner ? "cliff_blockDiagonal" : "cliff_blockSlope"677 }_${mat}`,678 pos: [c.wx, yTop - 1, c.wz],679 rotation: corner680 // Anchor the cut on the dir whose successor is also open.681 ? diagRot(682 dirs.includes((dirs[0] + 1) % 4) ? dirs[0] : dirs[1],683 )684 : slopeRot(dirs[0]),685 role: "support",686 });687 shaped.add(k);688 } else if (689 remaining < 2 - 1e-9 && c.clipped &&690 Math.abs(yTop - 1 - c.clipY) < 0.26691 ) {692 // CAVE MOUTH: the column bottoms out on an older channel band —693 // the mass was pierced. Face the arch along the pierced line694 // (the neighbor track cell carrying a band at the same height).695 const below = occ.at(c.tx, c.tz).find((b) =>696 b.ref !== ref && Math.abs(b.y1 * TRACK_SCALE - c.clipY) < 0.05697 );698 let arch: readonly [number, number] | null = null;699 if (below) {700 for (const [dx, dz] of DIRS) {701 if (702 occ.at(c.tx + dx, c.tz + dz).some((b) =>703 b.ref === below.ref &&704 Math.abs(b.y1 - below.y1) < 0.15705 )706 ) {707 arch = [dx, dz];708 break;709 }710 }711 }712 placements.push(713 arch714 ? {715 pieceId: `${NATURE}:cliff_blockCave_${matAt(yTop, c.topWorld)}`,716 pos: [c.wx, yTop - 1, c.wz],717 rotation: faceRot(arch[0], arch[1]),718 role: "support",719 }720 : block(721 `cliff_block_${matAt(yTop, c.topWorld)}`,722 c.wx,723 yTop - 1,724 c.wz,725 cellRng,726 ),727 );728 } else {729 placements.push(730 block(`cliff_block_${mat}`, c.wx, yTop - 1, c.wz, cellRng),731 );732 }733 yTop -= 1;734 remaining -= 1;735 course++;736 }737 if (remaining >= 0.5 - 1e-9) {738 placements.push(739 block(740 `cliff_blockHalf_${matAt(yTop, c.topWorld)}`,741 c.wx,742 yTop - 0.5,743 c.wz,744 cellRng,745 ),746 );747 yTop -= 0.5;748 remaining -= 0.5;749 }750 if (remaining >= 0.25 - 1e-9) {751 placements.push(752 block(753 `cliff_blockQuarter_${matAt(yTop, c.topWorld)}`,754 c.wx,755 yTop - 0.25,756 c.wz,757 cellRng,758 ),759 );760 }761 topCells.push(k);762763 // Landmark decor stands alone on its cell.764 if (k === landmarkCell && !shaped.has(k)) {765 placements.push({766 pieceId: rng.child(`landmark/${k}`).pick(LANDMARKS as string[]),767 pos: [c.wx, c.topWorld, c.wz],768 rotation: cellRng.int(0, 4) as Placement["rotation"],769 role: "decor",770 ...(biome.landmarkScale !== undefined771 ? { scale: biome.landmarkScale }772 : {}),773 });774 shaped.add(k);775 }776777 // Caps: biome surface tile on non-track FLAT tops. Raised viaduct778 // runs (crossing.raised) cap their deck too — the track stands on779 // piers 0.5/1.0 ABOVE it, so the tile is visible and can't z-fight780 // the channel.781 if (782 (!c.onRun || (crossing.raised ?? 0) > 0) && !shaped.has(k) &&783 biome.capId &&784 cellRng.chance(biome.capChance)785 ) {786 placements.push({787 pieceId: biome.capId,788 pos: [c.wx, c.topWorld, c.wz],789 rotation: cellRng.int(0, 4) as Placement["rotation"],790 role: "decor",791 });792 }793 }794795 // ---------------------------------------------------------------------796 // Decor field on the top surface (world grid): the M2a API with the run797 // patch as "the track", filtered to actual mass tops, lifted to the top.798 // ---------------------------------------------------------------------799 if (biome.setting && catalog.packs.length > 0) {800 const setting = settingByName(biome.setting, catalog);801 const topSet = new Set(topCells);802 const decor = placeDecor(803 rng.child("decor"),804 setting,805 { ...style, decorDensity: biome.decorDensity },806 regionAround(runWorld, margin * TRACK_SCALE),807 cellSetOracle(runWorld),808 catalog,809 );810 for (const p of decor) {811 const cx = Math.round(p.pos[0] - 0.5) + 0.5;812 const cz = Math.round(p.pos[2] - 0.5) + 0.5;813 if (!topSet.has(key(cx, cz)) || runWorld.has(key(cx, cz))) continue;814 // Shaped tops (trims, waterfalls, landmarks) keep their silhouette.815 if (shaped.has(key(cx, cz))) continue;816 // Vegetation needs the canopy airspace claim (made at plan time in817 // TRACK cells) — cells whose air an older line owns stay bare rather818 // than growing a tree through someone's channel. Decor sits on the819 // cell's own terrace.820 const tx = Math.round(cx / TRACK_SCALE);821 const tz = Math.round(cz / TRACK_SCALE);822 const cellTop = terraceTop(tx, tz);823 const ownsAir = occ824 .at(tx, tz)825 .some((b) => b.ref === ref && b.y1 > cellTop + 0.5);826 if (!ownsAir) continue;827 placements.push({828 ...p,829 pos: [p.pos[0], cellTop * TRACK_SCALE, p.pos[2]],830 });831 }832 }833834 return {835 id,836 ref,837 biome: biome.name,838 topY: worldTop,839 placements,840 cells: topCells,841 };842}843844function quantize(depth: number): number {845 return Math.max(1, Math.round(depth * 4) / 4);846}847848function block(849 pieceId: string,850 x: number,851 y: number,852 z: number,853 rng: Rng,854): Placement {855 return {856 pieceId: `${NATURE}:${pieceId}`,857 pos: [x, y, z],858 // Random facing — the cliff faces are textured on all sides.859 rotation: rng.int(0, 4) as Placement["rotation"],860 role: "support",861 };862}