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 / voidDecor.ts
voidDecor.ts 10.3K
1/**2 * Floating void decor (M18b): sparse set-dressing drifting in the empty3 * air around the line — rock shoals and motes everywhere, biome-flavored4 * silhouettes near themed islands (ships off tropical coasts, a ghost in5 * spooky air, snowflakes in winter), rare space events (meteor showers,6 * a passing craft). Purely atmospheric:7 *8 * - NEW LABELED FAMILY (`void-decor`): by the seed law, adding it9 * perturbs no existing stream.10 * - SEPARATE OUTPUT (`EndlessSegment.voidDecor`, never `placements`):11 * the proxy path structurally cannot see it — non-collision by12 * construction, and it claims nothing in occupancy (decor must never13 * constrain the line).14 * - DENSITY IS THE LIVELINESS LAW: 0-2 clusters a segment (expected15 * ~1) plus a rare event ~0.08 with quiet-after suppression —16 * distribution over a long stretch, never crowd density.17 *18 * Sites hang off the segment's own path samples: a lateral offset into19 * the mid band (8-16u, slightly below the line) or the far band20 * (18-40u, scaled up into fog silhouettes). Sites keep ≥CLEARANCE from21 * every path sample (main + alt), island cells, and any occupancy claim22 * at their column. Future segments can wander anywhere, so a far shoal23 * may eventually share air with track generated later — accepted24 * residual (sparse, non-colliding, reads as parallax).25 */26import type { LoopPath, Placement, Vec3 } from "../../shared/types.ts";27import { rngFamily } from "../rng.ts";28import type { Rng } from "../rng.ts";29import { segLength, segPoint } from "../geom.ts";30import { TRACK_SCALE } from "./scale.ts";31import type { OccView } from "./occupancy.ts";32import type { Island } from "./islands.ts";3334/** Minimum world distance from any path sample / island cell. */35export const VOID_CLEARANCE = 6;36/** Cluster budget per segment (hard cap, the structural-scan law). */37export const VOID_MAX_PIECES = 14;38const EVENT_CHANCE = 0.08;3940interface VoidGroup {41 ids: readonly string[];42 scale: readonly [number, number];43 weight: number;44 /** Composite floating-island SHARD (user correction: a bush or a45 * ground rock floating in air reads WRONG — the void wants things46 * that plausibly float). A shard is a torn-off islet: cliff block(s)47 * with a grass cap and a ragged quarter-course underside, every piece48 * sharing ONE float phase so the islet bobs as a unit. */49 shard?: boolean;50}5152/** Always-on vocabulary: floating island shards + space debris — never53 * bare ground props (those read as set-dressing that forgot gravity). */54const GENERIC: readonly VoidGroup[] = [55 { ids: [], scale: [0.5, 1.1], weight: 3, shard: true },56 {57 ids: [58 "space-kit:meteor_half",59 "space-kit:rock_crystals",60 "space-kit:rock_crystalsLargeA",61 ],62 scale: [0.9, 1.6],63 weight: 1.5,64 },65];6667/** Biome-flavored clusters, keyed by this segment's island biomes —68 * silhouettes that plausibly ride the air (ships, a ghost, snow), never69 * ground props. */70const BIOME_GROUPS: Record<string, readonly VoidGroup[]> = {71 tropical: [72 {73 ids: [74 "pirate-kit:ship-wreck",75 "pirate-kit:ship-ghost",76 "pirate-kit:ship-pirate-small",77 "pirate-kit:ship-small",78 ],79 scale: [0.6, 0.9],80 weight: 3,81 },82 ],83 spooky: [84 { ids: ["graveyard-kit:character-ghost"], scale: [1.2, 1.8], weight: 2.5 },85 { ids: ["graveyard-kit:crypt"], scale: [1.6, 2.4], weight: 1 },86 ],87 winter: [88 {89 ids: [90 "holiday-kit:snowflake-a",91 "holiday-kit:snowflake-b",92 "holiday-kit:snowflake-c",93 ],94 scale: [0.8, 1.4],95 weight: 3,96 },97 ],98};99100/** Rare events: a drifting shoal train or a passing craft, far band. */101const EVENTS: readonly VoidGroup[] = [102 {103 ids: ["space-kit:meteor", "space-kit:meteor_detailed"],104 scale: [1.5, 2.6],105 weight: 3,106 },107 {108 ids: [109 "space-kit:craft_speederA",110 "space-kit:craft_miner",111 "space-kit:satelliteDish_detailed",112 ],113 scale: [1.2, 2.0],114 weight: 2,115 },116];117118interface PathSample {119 p: Vec3;120 /** Unit lateral (up × tangent), horizontal. */121 lat: Vec3;122}123124/** Coarse world-space samples of a path (~2u spacing). */125function samplePath(path: LoopPath, out: PathSample[]): void {126 for (const seg of path.segments) {127 for (const c of seg.curve) {128 const len = segLength(c);129 const n = Math.max(1, Math.round(len / 2));130 for (let k = 0; k <= n; k++) {131 const p = segPoint(c, k / n);132 const q = segPoint(c, Math.min(1, k / n + 0.01));133 const tx = q[0] - p[0];134 const tz = q[2] - p[2];135 const tl = Math.hypot(tx, tz);136 out.push({137 p,138 lat: tl > 1e-6 ? [-tz / tl, 0, tx / tl] : [1, 0, 0],139 });140 }141 }142 }143}144145/** Pure event roll for a segment index — order-independent, so segment146 * i can consult i−1's outcome without generating it. */147function eventRolls(seed: string, index: number): boolean {148 return rngFamily(seed, "void-decor").child(`segment/${index}`).child("event")149 .chance(EVENT_CHANCE);150}151152export function planVoidDecor(153 seed: string,154 index: number,155 path: LoopPath,156 altPath: LoopPath | undefined,157 islands: readonly Island[],158 occ: OccView,159): Placement[] {160 const rng: Rng = rngFamily(seed, "void-decor").child(`segment/${index}`);161 const out: Placement[] = [];162163 const samples: PathSample[] = [];164 samplePath(path, samples);165 if (altPath) samplePath(altPath, samples);166 if (samples.length === 0) return out;167168 const isleCells: [number, number][] = [];169 for (const isle of islands) {170 for (const c of isle.cells) {171 const [x, z] = c.split(",").map(Number);172 isleCells.push([x, z]);173 }174 }175176 const clear = (x: number, y: number, z: number): boolean => {177 for (const s of samples) {178 const dx = x - s.p[0];179 const dy = y - s.p[1];180 const dz = z - s.p[2];181 if (dx * dx + dy * dy + dz * dz < VOID_CLEARANCE * VOID_CLEARANCE) {182 return false;183 }184 }185 for (const [ix, iz] of isleCells) {186 const dx = x - ix;187 const dz = z - iz;188 if (dx * dx + dz * dz < VOID_CLEARANCE * VOID_CLEARANCE) return false;189 }190 // Occupancy column: past/current claims at this cell must not share191 // the site's band (future claims stay residual — decor claims none).192 const ty = y / TRACK_SCALE;193 const bands = occ.at(194 Math.round(x / TRACK_SCALE),195 Math.round(z / TRACK_SCALE),196 );197 for (const b of bands) {198 if (ty - 0.75 < b.y1 && b.y0 < ty + 0.75) return false;199 }200 return true;201 };202203 const biomes = new Set(islands.map((isle) => isle.biome));204 const groups: [VoidGroup, number][] = GENERIC.map((205 g,206 ) => [g, g.weight] as [VoidGroup, number]);207 for (const b of biomes) {208 for (const g of BIOME_GROUPS[b] ?? []) groups.push([g, g.weight * 2]);209 }210211 const emit = (212 r: Rng,213 group: VoidGroup,214 anchor: PathSample,215 side: number,216 far: boolean,217 count: number,218 ): void => {219 const latDist = far ? 18 + r.float() * 22 : 8 + r.float() * 8;220 const yOff = far ? -12 + r.float() * 20 : -6 + r.float() * 10;221 const cx = anchor.p[0] + anchor.lat[0] * latDist * side;222 const cz = anchor.p[2] + anchor.lat[2] * latDist * side;223 const cy = anchor.p[1] + yOff;224 for (let k = 0; k < count && out.length < VOID_MAX_PIECES; k++) {225 const px = cx + (r.float() - 0.5) * 6;226 const py = cy + (r.float() - 0.5) * 4;227 const pz = cz + (r.float() - 0.5) * 6;228 if (!clear(px, py, pz)) continue;229 const scale = group.scale[0] +230 r.float() * (group.scale[1] - group.scale[0]) * (far ? 1.4 : 1);231 const period = 6 + r.float() * 8;232 const drift = far && r.chance(0.6)233 ? [234 (r.float() - 0.5) * 0.24,235 0,236 (r.float() - 0.5) * 0.24,237 ] as Vec3238 : undefined;239 const float: NonNullable<Placement["float"]> = {240 amp: 0.1 + r.float() * 0.3,241 period,242 phase: r.float() * period,243 ...(drift ? { drift } : {}),244 };245 if (group.shard) {246 // Floating island shard: cliff course(s) + grass cap + a ragged247 // quarter underside, one rotation, ONE shared float (the islet248 // bobs and drifts as a body). Offsets scale with the shard (the249 // pieces' origins are bottom-center; a course is 1.0 authored).250 const mat = r.chance(0.6) ? "rock" : "stone";251 const tall = r.chance(0.3);252 const rot = r.int(0, 4) as Placement["rotation"];253 if (out.length + (tall ? 4 : 3) > VOID_MAX_PIECES) continue;254 // The whole islet must clear the line, cap and underside too.255 if (256 !clear(px, py + (tall ? 2 : 1) * scale, pz) ||257 !clear(px, py - 0.25 * scale, pz)258 ) continue;259 const shardPiece = (id: string, dy: number): Placement => ({260 pieceId: id,261 pos: [px, py + dy * scale, pz],262 rotation: rot,263 role: "decor",264 scale,265 float,266 });267 out.push(shardPiece(`nature-kit:cliff_blockQuarter_${mat}`, -0.25));268 out.push(shardPiece(`nature-kit:cliff_block_${mat}`, 0));269 if (tall) out.push(shardPiece(`nature-kit:cliff_block_${mat}`, 1));270 out.push(shardPiece("nature-kit:ground_grass", tall ? 2 : 1));271 continue;272 }273 out.push({274 pieceId: r.pick(group.ids as string[]),275 pos: [px, py, pz],276 rotation: r.int(0, 4) as Placement["rotation"],277 role: "decor",278 scale,279 float,280 });281 }282 };283284 // Clusters: 0-2 a segment, expected ~1.285 const clusters = rng.child("clusters").weighted(286 [[0, 1.2], [1, 2.2], [2, 0.8]] as const,287 );288 for (let c = 0; c < clusters; c++) {289 const r = rng.child(`cluster/${c}`);290 const group = r.weighted(groups);291 const anchor = samples[r.int(0, samples.length)];292 emit(293 r,294 group,295 anchor,296 r.chance(0.5) ? 1 : -1,297 r.chance(0.45),298 1 + r.int(0, 5),299 );300 }301302 // Rare far-band event, suppressed right after another event so two303 // never crowd consecutive segments (both rolls are pure in index).304 if (eventRolls(seed, index) && !(index > 0 && eventRolls(seed, index - 1))) {305 const r = rng.child("event-build");306 const group = r.weighted(EVENTS.map((g) => [g, g.weight] as const));307 const anchor = samples[r.int(0, samples.length)];308 emit(r, group, anchor, r.chance(0.5) ? 1 : -1, true, 2 + r.int(0, 4));309 }310311 return out;312}