Files
Emberwild/scripts/world/weather.gd

234 lines
8.6 KiB
GDScript

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