clone

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

Island shaping + varied texturing (promoted backlog)

Dami committed 521b013
Two-pass mass builder: shoulder trim (slope/diagonal top blocks toward
open sides — orientations MEASURED in gallery: slope descends +x,
diagonal cuts +x/+z, cave/waterfall face -z), cave mouths where columns
bottom out on pierced channels, one waterfall face per some islands,
statue/rock landmarks on far flat tops, deep-course material swap.
Shaped tops excluded from caps/decor. 89 tests green.
commit
521b01343d3614f26dacaf98667b2fe43c294157
parent
772438fe9338b58a3f1444ccb4f4f6f5d2fcf057
author
Dami <dami.etoile@gmail.com>
2 files changed, +293 -40
Mprogress.md22++
Msrc/gen/endless/islands.ts311++++++++++++++++++++++++++----
M progress.md +22 -0
@@ -812,6 +812,28 @@ zones (icy needs a visible surface treatment first — zones must READ).
812812 813813 **DONE this round (post-M12, user-directed):**814814 815+- **ISLAND SHAPING + VARIED TEXTURING** (promoted backlog): islands.ts816+ mass builder restructured into two passes (pass 1: per-cell-keyed817+ existence + depth into a Col map; pass 2: emission with neighbor818+ knowledge). SHOULDER TRIM: non-run edge tops with 1–2 open orthogonal819+ sides (open = no column, or top ≥ a course lower) swap their top block820+ for cliff_blockSlope (single side) or cliff_blockDiagonal (corner),821+ chance 0.7 — MEASURED in gallery: slope descends local +x, diagonal822+ cuts the +x/+z corner, cave arch and waterfall face −z (slopeRot/823+ diagRot/faceRot map accordingly; first guess was wrong, screenshots824+ settled it). CAVE MOUTHS: a column that bottoms out on an older825+ channel band (crossing.clipped) emits cliff_blockCave facing along826+ the pierced line (neighbor track cell carrying the same ref band) —827+ rare, as piercings are. WATERFALLS: 0.4/island, one edge column828+ (depth ≥2) gets cliff_waterfallTop + cliff_waterfall faces running829+ down toward the void. LANDMARKS: 0.35 (margin ≥2) / 0.15 chance, one830+ statue/tall-rock on a far flat top with owned airspace. TEXTURE:831+ 0.4/island the deep courses (≥2 below top) swap to the OTHER cliff832+ material (stone base under rock and vice versa). Shaped tops carry no833+ caps/decor. Emergence: 1912 slopes / 923 diagonals / 59 waterfall834+ pieces / 65 landmarks / 1 cave over 10 seeds × 8 segs. 89 tests835+ green; browser: shoulders slope outward, rim reads as terrain.836+815837 - **GROUND VOCABULARY ON ISLANDS** (promoted backlog: "an island top IS816838 local ground"). Tunnels needed nothing (already free-collapse in817839 endless — the banner rule's arched cells). WIDE RUNS: tryIsland plans
M src/gen/endless/islands.ts +271 -40
@@ -185,6 +185,25 @@ export function buildIsland(
185185 const keepFor = (dist: number): number =>186186 dist <= 1 ? 1 : Math.max(0.2, 1.05 - dist * 0.16);187187 const topCells: string[] = [];188+189+ // PASS 1 — column existence + depth per world cell (all per-cell-keyed190+ // draws, order-independent). Shaping needs neighbor knowledge, so191+ // emission is a second pass.192+ interface Col {193+ wx: number;194+ wz: number;195+ tx: number;196+ tz: number;197+ topWorld: number;198+ depth: number;199+ /** Claim bottom (world) — the column was clipped here if an older200+ * band sits directly beneath. */201+ clipY: number;202+ clipped: boolean;203+ onRun: boolean;204+ dist: number;205+ }206+ const cols = new Map<string, Col>();188207 for (const claim of occ.claimsOf(ref)) {189208 const { x, z, y0, y1 } = claim;190209 const cellTop = terraceTop(x, z);
@@ -204,54 +223,264 @@ export function buildIsland(
204223 (margin * TRACK_SCALE + 2.5 - dist) * (0.5 + cellRng.float() * 0.45),205224 );206225 const depth = quantize(Math.min(availWorld, target));226+ cols.set(key(wx, wz), {227+ wx,228+ wz,229+ tx: x,230+ tz: z,231+ topWorld: cellTopWorld,232+ depth,233+ clipY: y0 * TRACK_SCALE,234+ clipped: depth >= availWorld - 1e-9 && availWorld < target,235+ onRun,236+ dist,237+ });238+ }239+ }207240 208- // Stack blocks top-down: full blocks, then a half/quarter shim so209- // the underside lands exactly on the claimed grid.210- let remaining = depth;211- let yTop = cellTopWorld;212- while (remaining >= 1 - 1e-9) {213- placements.push(214- block(`cliff_block_${biome.material}`, wx, yTop - 1, wz, cellRng),241+ // ISLAND SHAPING (backlog): trims, cave mouths, waterfalls, landmarks.242+ // A shoulder cell is a non-run top with at least one open orthogonal243+ // side at its own level; single-open sides slope down and out, double-244+ // open corners take the diagonal cut. Old-track piercings get a cave245+ // mouth facing along the pierced channel; one edge column per some246+ // islands runs a waterfall down its face; big islands may carry one247+ // landmark. Shaped tops are excluded from caps/decor (their surface248+ // is no longer flat).249+ const DIRS: readonly (readonly [number, number])[] = [250+ [1, 0],251+ [0, 1],252+ [-1, 0],253+ [0, -1],254+ ];255+ /** Open at the column's own level: no column there, or one whose top256+ * sits a course or more lower. */257+ const openAt = (c: Col, dx: number, dz: number): boolean => {258+ const n = cols.get(key(c.wx + dx, c.wz + dz));259+ return !n || n.topWorld < c.topWorld - 0.26;260+ };261+ const shaped = new Set<string>(); // no caps/decor on these tops262+263+ // Waterfall pick (per island): one qualifying edge column.264+ let waterfallCell: string | null = null;265+ {266+ const wRng = rng.child("waterfall");267+ if (wRng.chance(0.4)) {268+ const cands = [...cols.values()]269+ .filter((c) =>270+ !c.onRun && c.dist >= 1 && c.depth >= 2 &&271+ DIRS.some(([dx, dz]) => openAt(c, dx, dz))272+ )273+ .map((c) => key(c.wx, c.wz))274+ .sort();275+ if (cands.length > 0) waterfallCell = wRng.pick(cands);276+ }277+ }278+ // Landmark pick (per island, larger islands lean in).279+ let landmarkCell: string | null = null;280+ const LANDMARKS = [281+ "statue_head",282+ "statue_obelisk",283+ "statue_column",284+ "statue_columnDamaged",285+ "statue_ring",286+ "rock_tallA",287+ "rock_tallD",288+ "stone_tallB",289+ ];290+ {291+ const lRng = rng.child("landmark");292+ if (lRng.chance(margin >= 2 ? 0.35 : 0.15)) {293+ const cands = [...cols.values()]294+ .filter((c) => {295+ if (c.onRun || c.dist < 2) return false;296+ // Needs its own airspace (canopy claim at the cell's terrace).297+ const top = c.topWorld / TRACK_SCALE;298+ return occ.at(c.tx, c.tz).some((b) =>299+ b.ref === ref && b.y1 > top + 0.5300+ );301+ })302+ .map((c) => key(c.wx, c.wz))303+ .sort();304+ if (cands.length > 0) landmarkCell = lRng.pick(cands);305+ }306+ }307+308+ // Per-island deep-course material swap (varied texturing): some309+ // islands sit on a base of the OTHER cliff material.310+ const otherMat = biome.material === "rock" ? "stone" : "rock";311+ const deepMat = rng.child("strata").chance(0.4) ? otherMat : biome.material;312+ const matAt = (yTop: number, topWorld: number): string =>313+ yTop <= topWorld - 2 + 1e-9 ? deepMat : biome.material;314+315+ /** Rotation turning local −z toward world direction (dx, dz) — the316+ * cave arch and the waterfall face live on −z (measured in gallery). */317+ const faceRot = (dx: number, dz: number): Placement["rotation"] =>318+ (dx === 1 ? 3 : dx === -1 ? 1 : dz === -1 ? 0 : 2) as Placement[319+ "rotation"320+ ];321+ const dirOf = (dx: number, dz: number): number =>322+ dx === 1 ? 0 : dz === 1 ? 1 : dx === -1 ? 2 : 3;323+ /** cliff_blockSlope descends toward local +x (measured): rotation324+ * mapping local +x onto world dir w. */325+ const slopeRot = (w: number): Placement["rotation"] =>326+ ((4 - w) % 4) as Placement["rotation"];327+ /** cliff_blockDiagonal's cut corner spans local +x/+z (measured):328+ * rotation mapping that corner onto adjacent open dirs (k, k+1). */329+ const diagRot = (k: number): Placement["rotation"] =>330+ ((4 - k) % 4) as Placement["rotation"];331+332+ // PASS 2 — emit blocks with shaping.333+ for (const c of cols.values()) {334+ const cellRng = rng.child(`blocks/${c.wx}/${c.wz}`);335+ const k = key(c.wx, c.wz);336+ const open = DIRS.filter(([dx, dz]) => openAt(c, dx, dz));337+338+ // Waterfall column: waterfallTop at the crest, waterfall faces below.339+ const isFall = k === waterfallCell && open.length > 0;340+ // Shoulder trim on plain edge tops (never on the waterfall).341+ const trim = !isFall && !c.onRun && c.dist >= 1 && c.depth >= 1 &&342+ open.length >= 1 && open.length <= 2 &&343+ cellRng.chance(0.7);344+345+ let remaining = c.depth;346+ let yTop = c.topWorld;347+ let course = 0;348+ while (remaining >= 1 - 1e-9) {349+ const mat = matAt(yTop, c.topWorld);350+ if (course === 0 && isFall) {351+ const [dx, dz] = cellRng.pick(open);352+ placements.push({353+ pieceId: `${NATURE}:cliff_waterfallTop_${mat}`,354+ pos: [c.wx, yTop - 1, c.wz],355+ rotation: faceRot(dx, dz),356+ role: "support",357+ });358+ shaped.add(k);359+ } else if (course > 0 && isFall && remaining >= 2 - 1e-9) {360+ // Water continues down the face toward the void.361+ const [dx, dz] = cellRng.pick(open);362+ placements.push({363+ pieceId: `${NATURE}:cliff_waterfall_${mat}`,364+ pos: [c.wx, yTop - 1, c.wz],365+ rotation: faceRot(dx, dz),366+ role: "support",367+ });368+ } else if (course === 0 && trim) {369+ // Slope toward the single open side; diagonal across a corner.370+ const dirs = open.map(([dx, dz]) => dirOf(dx, dz));371+ const corner = open.length === 2 &&372+ (open[0][0] !== open[1][0] && open[0][1] !== open[1][1]);373+ placements.push({374+ pieceId: `${NATURE}:${375+ corner ? "cliff_blockDiagonal" : "cliff_blockSlope"376+ }_${mat}`,377+ pos: [c.wx, yTop - 1, c.wz],378+ rotation: corner379+ // Anchor the cut on the dir whose successor is also open.380+ ? diagRot(381+ dirs.includes((dirs[0] + 1) % 4) ? dirs[0] : dirs[1],382+ )383+ : slopeRot(dirs[0]),384+ role: "support",385+ });386+ shaped.add(k);387+ } else if (388+ remaining < 2 - 1e-9 && c.clipped &&389+ Math.abs(yTop - 1 - c.clipY) < 0.26390+ ) {391+ // CAVE MOUTH: the column bottoms out on an older channel band —392+ // the mass was pierced. Face the arch along the pierced line393+ // (the neighbor track cell carrying a band at the same height).394+ const below = occ.at(c.tx, c.tz).find((b) =>395+ b.ref !== ref && Math.abs(b.y1 * TRACK_SCALE - c.clipY) < 0.05215396 );216- yTop -= 1;217- remaining -= 1;218- }219- if (remaining >= 0.5 - 1e-9) {397+ let arch: readonly [number, number] | null = null;398+ if (below) {399+ for (const [dx, dz] of DIRS) {400+ if (401+ occ.at(c.tx + dx, c.tz + dz).some((b) =>402+ b.ref === below.ref &&403+ Math.abs(b.y1 - below.y1) < 0.15404+ )405+ ) {406+ arch = [dx, dz];407+ break;408+ }409+ }410+ }220411 placements.push(221- block(222- `cliff_blockHalf_${biome.material}`,223- wx,224- yTop - 0.5,225- wz,226- cellRng,227- ),412+ arch413+ ? {414+ pieceId: `${NATURE}:cliff_blockCave_${matAt(yTop, c.topWorld)}`,415+ pos: [c.wx, yTop - 1, c.wz],416+ rotation: faceRot(arch[0], arch[1]),417+ role: "support",418+ }419+ : block(420+ `cliff_block_${matAt(yTop, c.topWorld)}`,421+ c.wx,422+ yTop - 1,423+ c.wz,424+ cellRng,425+ ),228426 );229- yTop -= 0.5;230- remaining -= 0.5;231- }232- if (remaining >= 0.25 - 1e-9) {427+ } else {233428 placements.push(234- block(235- `cliff_blockQuarter_${biome.material}`,236- wx,237- yTop - 0.25,238- wz,239- cellRng,240- ),429+ block(`cliff_block_${mat}`, c.wx, yTop - 1, c.wz, cellRng),241430 );242431 }243- topCells.push(key(wx, wz));432+ yTop -= 1;433+ remaining -= 1;434+ course++;435+ }436+ if (remaining >= 0.5 - 1e-9) {437+ placements.push(438+ block(439+ `cliff_blockHalf_${matAt(yTop, c.topWorld)}`,440+ c.wx,441+ yTop - 0.5,442+ c.wz,443+ cellRng,444+ ),445+ );446+ yTop -= 0.5;447+ remaining -= 0.5;448+ }449+ if (remaining >= 0.25 - 1e-9) {450+ placements.push(451+ block(452+ `cliff_blockQuarter_${matAt(yTop, c.topWorld)}`,453+ c.wx,454+ yTop - 0.25,455+ c.wz,456+ cellRng,457+ ),458+ );459+ }460+ topCells.push(k);244461 245- // Caps: biome surface tile on non-track tops (at the cell's own246- // terrace level).247- if (!onRun && biome.capId && cellRng.chance(biome.capChance)) {248- placements.push({249- pieceId: `${NATURE}:${biome.capId}`,250- pos: [wx, cellTopWorld, wz],251- rotation: cellRng.int(0, 4) as Placement["rotation"],252- role: "decor",253- });254- }462+ // Landmark decor stands alone on its cell.463+ if (k === landmarkCell && !shaped.has(k)) {464+ placements.push({465+ pieceId: `${NATURE}:${rng.child(`landmark/${k}`).pick(LANDMARKS)}`,466+ pos: [c.wx, c.topWorld, c.wz],467+ rotation: cellRng.int(0, 4) as Placement["rotation"],468+ role: "decor",469+ });470+ shaped.add(k);471+ }472+473+ // Caps: biome surface tile on non-track FLAT tops only.474+ if (475+ !c.onRun && !shaped.has(k) && biome.capId &&476+ cellRng.chance(biome.capChance)477+ ) {478+ placements.push({479+ pieceId: `${NATURE}:${biome.capId}`,480+ pos: [c.wx, c.topWorld, c.wz],481+ rotation: cellRng.int(0, 4) as Placement["rotation"],482+ role: "decor",483+ });255484 }256485 }257486
@@ -274,6 +503,8 @@ export function buildIsland(
274503 const cx = Math.round(p.pos[0] - 0.5) + 0.5;275504 const cz = Math.round(p.pos[2] - 0.5) + 0.5;276505 if (!topSet.has(key(cx, cz)) || runWorld.has(key(cx, cz))) continue;506+ // Shaped tops (trims, waterfalls, landmarks) keep their silhouette.507+ if (shaped.has(key(cx, cz))) continue;277508 // Vegetation needs the canopy airspace claim (made at plan time in278509 // TRACK cells) — cells whose air an older line owns stay bare rather279510 // than growing a tree through someone's channel. Decor sits on the