extends Node ## Weather model: what the sky is doing right now, and what that means for ## warmth, wetness and light. Visuals and sound live in weather_fx.gd. ## ## Deterministic: the weather at any moment is a function of the world seed and ## the absolute world time (day + time of day), so saves don't store it and in ## M2 every client can compute the same weather as the server. ## ## A slow 1D noise "front" value picks the state: high = clear, low = rain, ## lowest = storm. About one day in four is a cold day, when rain falls as snow ## (and a storm becomes a blizzard). Above the snowline it always snows. ## Outputs ease toward the state's targets over about a minute, so fronts ## roll in rather than switching. ## ## P4 (docs/PROGRESSION_DESIGN.md ยง5): every 12 days a hard winter, three ## bitter cold days in a row (hard_winter()), colder still, with more snow and ## blizzards. A blizzard cuts visibility right down and its wind strips the ## warmth from anyone out in it (blizzard_chill(); a shelter keeps it out). signal state_changed(state: String) const STATES := ["clear", "cloudy", "rain", "storm", "snow", "blizzard"] ## Thresholds on the front value (see _state_for). Tuned with ## tests/test_weather_building.gd so that over many days it's roughly ## clear 40%, cloudy 30%, rain 20%, storm 10%. var cut_cloudy := 0.07 var cut_rain := -0.19 var cut_storm := -0.42 ## Per-state targets: clouds 0..1, precip 0..1, wind 0..1, light (sun/ambient ## multiplier), fog (density multiplier), warmth (added to air warmth). const TARGETS := { "clear": {"clouds": 0.30, "precip": 0.0, "wind": 0.25, "light": 1.00, "fog": 1.0, "warmth": 0.0}, "cloudy": {"clouds": 0.72, "precip": 0.0, "wind": 0.45, "light": 0.72, "fog": 1.4, "warmth": -3.0}, "rain": {"clouds": 0.92, "precip": 0.6, "wind": 0.60, "light": 0.50, "fog": 2.2, "warmth": -6.0}, "storm": {"clouds": 1.00, "precip": 1.0, "wind": 1.00, "light": 0.30, "fog": 3.0, "warmth": -12.0}, "snow": {"clouds": 0.90, "precip": 0.6, "wind": 0.35, "light": 0.68, "fog": 2.6, "warmth": -14.0}, "blizzard": {"clouds": 1.00, "precip": 1.0, "wind": 1.00, "light": 0.40, "fog": 7.0, "warmth": -24.0}, } const COLD_DAY_WARMTH := -8.0 const BLEND_SECONDS := 50.0 ## time for outputs to cross from one state to another const CALM_START := 600.0 ## a new world's first 10 minutes are always clear const COLD_DAY_CHANCE := 0.25 const HARD_WINTER_EVERY := 12 ## days 12-14, 24-26, ... const HARD_WINTER_DAYS := 3 const HARD_WINTER_WARMTH := -8.0 ## on top of a cold day's const BLIZZARD_CHILL := 18.0 ## more warmth lost out in a blizzard's wind (a hard winter's freezes you by day) ## In a hard winter the weather leans wet (more snow and blizzards). const HARD_WINTER_FRONT := -0.18 var world_seed := 1337 var day_night: Node var snowline := 32.0 ## above this height precipitation is snow var frozen := false ## title-screen backdrop: hold clear weather var override := "" ## force a state (tests, screenshots); "" = natural var state := "clear" var cold_day := false # Smoothed outputs. var clouds := 0.3 var precip := 0.0 var wind := 0.25 var light := 1.0 var fog := 1.0 var warmth := 0.0 var ground_wet := 0.0 ## 0..1 how soaked the ground looks var snow_cover := 0.0 ## 0..1 how much snow has settled var flash := 0.0 ## lightning brightness, decays fast var lightning_count := 0 var _noise := FastNoiseLite.new() var _strike_left := 8.0 var _rng := RandomNumberGenerator.new() func setup(seed: int, dn: Node) -> void: world_seed = seed day_night = dn _noise.seed = seed + 401 _noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH _noise.frequency = 1.0 / 900.0 # a wet spell lasts about 6 minutes _noise.fractal_type = FastNoiseLite.FRACTAL_FBM _noise.fractal_octaves = 2 _rng.seed = seed + 402 snap() ## Seconds since the world began (day 1, 00:00). func world_seconds() -> float: return (float(day_night.day - 1) + float(day_night.time)) * float(day_night.day_length) ## Day `day` is one of a hard winter's three bitter days. static func hard_winter(day: int) -> bool: return day >= HARD_WINTER_EVERY and (day - HARD_WINTER_EVERY) % HARD_WINTER_EVERY < HARD_WINTER_DAYS ## The first day of the next hard winter on or after `day`. static func next_hard_winter(day: int) -> int: var d := maxi(day, HARD_WINTER_EVERY) while not hard_winter(d): d += 1 return d func is_cold_day(day: int) -> bool: if day <= 1: return false if hard_winter(day): return true return float(absi(hash(Vector2i(world_seed, day))) % 10000) / 10000.0 < COLD_DAY_CHANCE ## The raw front value at an absolute time (higher = fairer). func front_at(t: float) -> float: var v := _noise.get_noise_1d(t) if t < CALM_START: v = maxf(v, 0.3) return v func _state_for(v: float, cold: bool) -> String: if v > cut_cloudy: return "clear" if v > cut_rain: return "cloudy" if v > cut_storm: return "snow" if cold else "rain" return "blizzard" if cold else "storm" ## The state at an absolute time, ignoring smoothing and overrides. func state_at(t: float) -> String: var day := int(floor(t / float(day_night.day_length))) + 1 return _state_for(front_at(t) + (HARD_WINTER_FRONT if hard_winter(day) else 0.0), is_cold_day(day)) func force(s: String) -> void: override = s snap() ## Jump every output straight to the current state's targets (on load, or ## when forcing a state). func snap() -> void: _pick_state() var tg: Dictionary = TARGETS[state] clouds = tg.clouds precip = tg.precip wind = tg.wind light = tg.light fog = tg.fog warmth = tg.warmth + _cold_extra() ground_wet = 1.0 if state in ["rain", "storm"] else 0.0 snow_cover = 0.7 if state in ["snow", "blizzard"] else (0.25 if cold_day else 0.0) func _pick_state() -> void: if day_night == null: return cold_day = is_cold_day(day_night.day) var s := override if frozen and s.is_empty(): s = "clear" if s.is_empty(): s = _state_for(front_at(world_seconds()) + (HARD_WINTER_FRONT if hard_winter(day_night.day) else 0.0), cold_day) if s != state: state = s state_changed.emit(state) func _process(delta: float) -> void: step(delta) func step(dt: float) -> void: _pick_state() var tg: Dictionary = TARGETS[state] var k := dt / BLEND_SECONDS clouds = move_toward(clouds, tg.clouds, k) precip = move_toward(precip, tg.precip, k * 1.2) wind = move_toward(wind, tg.wind, k) light = move_toward(light, tg.light, k * 0.8) fog = move_toward(fog, tg.fog, k * 3.0) warmth = move_toward(warmth, tg.warmth + _cold_extra(), k * 20.0) # The ground soaks in a couple of minutes of rain and dries over ten. var raining := precip > 0.2 and not snowing_globally() ground_wet = move_toward(ground_wet, 1.0 if raining else 0.0, dt / (120.0 if raining else 600.0)) # Snow settles over a few minutes and only melts on warm days. var snowing := precip > 0.2 and snowing_globally() if snowing: snow_cover = move_toward(snow_cover, 1.0, dt * precip / 180.0) elif not cold_day: snow_cover = move_toward(snow_cover, 0.0, dt / 480.0) flash = maxf(flash - dt * 6.0, 0.0) if state == "storm" and precip > 0.7: _strike_left -= dt if _strike_left <= 0.0: _strike_left = _rng.randf_range(7.0, 22.0) flash = 1.0 lightning_count += 1 ## Extra cold from the day: a cold day, and a hard winter on top. func _cold_extra() -> float: if not cold_day: return 0.0 return COLD_DAY_WARMTH + (HARD_WINTER_WARMTH if day_night != null and hard_winter(day_night.day) else 0.0) func in_hard_winter() -> bool: return day_night != null and hard_winter(day_night.day) ## Extra warmth lost by someone out in a blizzard's wind (0 when sheltered or ## when it isn't blowing). func blizzard_chill(sheltered: bool) -> float: if sheltered or state != "blizzard": return 0.0 return BLIZZARD_CHILL * clampf(precip, 0.0, 1.0) func snowing_globally() -> bool: return state in ["snow", "blizzard"] ## Whether precipitation at `pos` falls as snow (cold day or high up). func snows_at(pos: Vector3) -> bool: return snowing_globally() or pos.y > snowline ## 0..1 rain intensity at `pos` (0 when it's snow there). func rain_at(pos: Vector3) -> float: return 0.0 if snows_at(pos) else precip ## Air warmth change from the weather. Altitude snow makes the peaks colder ## still when precipitation is falling up there. func warmth_at(pos: Vector3) -> float: var w := warmth if precip > 0.2 and pos.y > snowline and not snowing_globally(): w -= 10.0 * precip return w func label() -> String: var names := {"clear": "Clear", "cloudy": "Cloudy", "rain": "Rain", "storm": "Storm", "snow": "Snow", "blizzard": "Blizzard"} var s: String = names[state] if cold_day and state in ["clear", "cloudy"]: s += ", cold" if in_hard_winter(): s += " (hard winter)" return s