M1c: weather, snap building, shelter, beds and regrowth
- Seeded weather (clear/cloudy/rain/storm, snow and blizzards on cold days and above the snowline), lightning, synthesised rain/wind/thunder sound - Rain/snow particles stopped by roofs; wet ground, settled snow and wind via global shader uniforms - Snap-together wood floor, wall, door, roof and gable with structural support, collapse and X to dismantle - Shelter detection: dry under a roof, warmer and Rested inside with a fire; rain burns an uncovered campfire faster - Bed sets the spawn point and lets you sleep through the night - Trees, boulders and ground pickups regrow; timers saved - Grass cleared under floors; shorter HUD hint - New test_weather_building suite (62 checks) and tests/run_all.sh, which also fails on script errors
This commit is contained in:
191
scripts/world/weather.gd
Normal file
191
scripts/world/weather.gd
Normal file
@@ -0,0 +1,191 @@
|
||||
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.
|
||||
|
||||
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": 0.90, "light": 0.45, "fog": 4.5, "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
|
||||
|
||||
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)
|
||||
|
||||
func is_cold_day(day: int) -> bool:
|
||||
if day <= 1:
|
||||
return false
|
||||
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), 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_DAY_WARMTH if cold_day else 0.0)
|
||||
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()), 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_DAY_WARMTH if cold_day else 0.0), 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
|
||||
|
||||
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"
|
||||
return s
|
||||
Reference in New Issue
Block a user