234 lines
8.6 KiB
GDScript
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
|