clone

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

main
marbles / src / gen / climate.ts
climate.ts 5.4K
1/**2 * Climate (M21): the zone atmosphere draw, SHARED between renderer and3 * generator. drawZone is a pure label-derived function of (seed, zone),4 * which makes weather a legal generation input — the same climate the5 * sky presents can bias the planner's hazard appetites (snowdrift zones6 * grow icy patches, goldhour zones lean into jumps) without any client7 * state ever touching generation. The biome bias stays a PRESENTATION8 * lean only (the client passes it; the generator always reads the BASE9 * draw — islands are planned inside the zone, so the base draw is the10 * only self-consistent generation input).11 *12 * Moved verbatim from src/client/atmosphere.ts — the draw SHAPE (mood13 * weighted → fogNear → fogFar → sunIntensity → weather pick) must never14 * change silently: skies are part of every shared seed.15 */16import { rngFamily } from "./rng.ts";1718export type WeatherKind = "none" | "snow" | "dust" | "embers";1920/** A zone is this many segments (~2 minutes of travel). */21export const ZONE_SEGMENTS = 5;2223export interface MoodSpec {24 name: string;25 weight: number;26 sky: number;27 fogNear: number;28 fogFar: number;29 sunColor: number;30 sunIntensity: number;31 /** Sun offset direction (normalized against focus radius). */32 sunDir: readonly [number, number, number];33 hemiSky: number;34 hemiGround: number;35 hemiIntensity: number;36 starColor: number;37 starOpacity: number;38 weathers: readonly WeatherKind[];39 weatherColor?: number;40}4142export const MOODS: readonly MoodSpec[] = [43 {44 name: "midnight",45 weight: 3,46 sky: 0x070b16,47 fogNear: 32,48 fogFar: 115,49 sunColor: 0xfff4e0,50 sunIntensity: 2.2,51 sunDir: [0.6, 1.2, 0.4],52 hemiSky: 0xbfd8ff,53 hemiGround: 0x8a6f5a,54 hemiIntensity: 1.1,55 starColor: 0xc7d5ff,56 starOpacity: 0.85,57 weathers: ["none", "dust"],58 },59 {60 name: "ember",61 weight: 2,62 sky: 0x170a08,63 fogNear: 26,64 fogFar: 95,65 sunColor: 0xffab6e,66 sunIntensity: 2.6,67 sunDir: [1.1, 0.9, 0.5],68 hemiSky: 0xffc9a3,69 hemiGround: 0x5a3a30,70 hemiIntensity: 0.9,71 starColor: 0xffd9b0,72 starOpacity: 0.7,73 weathers: ["embers"],74 weatherColor: 0xffa050,75 },76 {77 name: "violet",78 weight: 2,79 sky: 0x0d0a1e,80 fogNear: 30,81 fogFar: 110,82 sunColor: 0xd9c8ff,83 sunIntensity: 2.0,84 sunDir: [0.5, 1.4, 0.7],85 hemiSky: 0x9f8fdf,86 hemiGround: 0x3a3050,87 hemiIntensity: 1.2,88 starColor: 0xe0d5ff,89 starOpacity: 0.95,90 weathers: ["dust", "none"],91 weatherColor: 0xb8a8ff,92 },93 {94 name: "abyss",95 weight: 1.5,96 sky: 0x05100f,97 fogNear: 20,98 fogFar: 72,99 sunColor: 0x9fe8d8,100 sunIntensity: 1.7,101 sunDir: [0.4, 1.0, 0.8],102 hemiSky: 0x7fd8c8,103 hemiGround: 0x1f4038,104 hemiIntensity: 1.0,105 starColor: 0xafeade,106 starOpacity: 0.3,107 weathers: ["none"],108 },109 {110 name: "snowdrift",111 weight: 2,112 sky: 0x0e141f,113 fogNear: 26,114 fogFar: 98,115 sunColor: 0xdfeaff,116 sunIntensity: 2.3,117 sunDir: [0.7, 1.5, 0.3],118 hemiSky: 0xcfe0ff,119 hemiGround: 0x60707f,120 hemiIntensity: 1.3,121 starColor: 0xffffff,122 starOpacity: 0.9,123 weathers: ["snow"],124 weatherColor: 0xf0f6ff,125 },126 {127 name: "goldhour",128 weight: 1.5,129 sky: 0x140f08,130 fogNear: 34,131 fogFar: 125,132 sunColor: 0xffc46a,133 sunIntensity: 2.9,134 sunDir: [2.0, 0.55, 1.2],135 hemiSky: 0xffe0b0,136 hemiGround: 0x504030,137 hemiIntensity: 0.8,138 starColor: 0xffe9c0,139 starOpacity: 0.45,140 weathers: ["none", "dust"],141 weatherColor: 0xd8b890,142 },143];144145/** Mood-weight multipliers per island biome (M18a): a themed stretch146 * leans the sky toward its climate WITHOUT ever forcing it — no bias, no147 * change (today's draw exactly). Presentation-only; never touches148 * generation. */149export const BIOME_MOOD_BIAS: Record<string, Record<string, number>> = {150 winter: { snowdrift: 4, abyss: 1.5 },151 spooky: { midnight: 3, abyss: 3 },152 tropical: { goldhour: 3, ember: 1.5 },153 autumn: { ember: 2.5, goldhour: 2.5 },154};155156/** One drawn zone: mood + jitter + weather pick. */157export interface ZoneAtmosphere {158 mood: MoodSpec;159 fogNear: number;160 fogFar: number;161 sunIntensity: number;162 weather: WeatherKind;163}164165/** Deterministic zone draw — `atmosphere` family, keyed by zone index.166 * `biomeBias` (the zone's dominant island biome, if any) multiplies mood167 * weights before the draw; the rng stream shape is IDENTICAL either way168 * (same draws in the same order — only the weighted outcome moves). */169export function drawZone(170 seed: string,171 zone: number,172 biomeBias?: string,173): ZoneAtmosphere {174 const rng = rngFamily(seed, "atmosphere").child(`zone/${zone}`);175 const bias = biomeBias ? BIOME_MOOD_BIAS[biomeBias] : undefined;176 const mood = rng.weighted(177 MOODS.map((m) => [m, m.weight * (bias?.[m.name] ?? 1)] as const),178 );179 return {180 mood,181 fogNear: mood.fogNear * (0.9 + rng.float() * 0.25),182 fogFar: mood.fogFar * (0.85 + rng.float() * 0.3),183 sunIntensity: mood.sunIntensity * (0.9 + rng.float() * 0.2),184 weather: rng.pick(mood.weathers),185 };186}187188/** The BASE climate the generator reads for a segment (M21) — always189 * unbiased: the biome lean is presentation, and islands are planned190 * inside the zone, so the base draw is the only self-consistent191 * generation input. */192export function segmentClimate(193 seed: string,194 segIndex: number,195): { mood: string; weather: WeatherKind } {196 const za = drawZone(seed, Math.floor(segIndex / ZONE_SEGMENTS));197 return { mood: za.mood.name, weather: za.weather };198}