Files
Cinderwild/scripts/world/weather_fx.gd
Paul Bailey fed96ef118 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
2026-11-04 10:16:41 +00:00

218 lines
7.8 KiB
GDScript

extends Node3D
## Weather visuals and sound, driven by weather.gd (the model).
##
## - Rain and snow are GPU particles emitted above the camera in world space.
## A heightfield collider that follows the camera stops them on roofs and on
## the ground, so it doesn't rain indoors.
## - Ground wetness, settled snow and wind strength are global shader uniforms
## (weather_wet, weather_snow, weather_wind) read by the terrain, grass,
## foliage and rock shaders.
## - Sound is synthesised: filtered noise for rain and wind, a decaying
## low rumble for thunder. No audio assets, nothing to license.
##
## Client-side only: in M2 the server runs weather.gd and never this.
const Campfire = preload("res://scripts/building/campfire.gd")
var weather: Node
var player: Node3D ## for shelter muffling (survival.roofed)
var _rain: GPUParticles3D
var _snow: GPUParticles3D
var _rain_pm: ParticleProcessMaterial
var _snow_pm: ParticleProcessMaterial
var _field: GPUParticlesCollisionHeightField3D
# Audio
const MIX_RATE := 22050.0
var _audio: AudioStreamPlayer
var _playback: AudioStreamGeneratorPlayback
var _rng := RandomNumberGenerator.new()
var _rain_lp := 0.0
var _rain_hp := 0.0
var _brown := 0.0
var _brown2 := 0.0
var _gust_t := 0.0
var _thunder_env := 0.0
var _thunder_delay := -1.0
var _seen_strikes := 0
var _rain_amp := 0.0
var _wind_amp := 0.0
var _muffle := 0.0
func setup(w: Node, p: Node3D) -> void:
weather = w
player = p
_seen_strikes = weather.lightning_count
_rain_pm = ParticleProcessMaterial.new()
_rain = _make_particles(7000, 1.5, _rain_pm, _streak_mesh())
_rain_pm.emission_box_extents = Vector3(20, 1, 20)
_rain_pm.initial_velocity_min = 13.0
_rain_pm.initial_velocity_max = 16.0
_rain_pm.gravity = Vector3(0, -6, 0)
_rain_pm.particle_flag_align_y = true
_rain_pm.spread = 2.0
_snow_pm = ParticleProcessMaterial.new()
_snow = _make_particles(5000, 9.0, _snow_pm, _flake_mesh())
_snow_pm.emission_box_extents = Vector3(18, 1, 18)
_snow_pm.initial_velocity_min = 0.8
_snow_pm.initial_velocity_max = 1.4
_snow_pm.gravity = Vector3(0, -0.6, 0)
_snow_pm.spread = 15.0
_snow_pm.turbulence_enabled = true
_snow_pm.turbulence_noise_strength = 1.2
_snow_pm.turbulence_noise_scale = 4.0
_snow_pm.turbulence_influence_min = 0.03
_snow_pm.turbulence_influence_max = 0.08
_field = GPUParticlesCollisionHeightField3D.new()
_field.size = Vector3(48, 48, 48)
_field.resolution = GPUParticlesCollisionHeightField3D.RESOLUTION_512
_field.update_mode = GPUParticlesCollisionHeightField3D.UPDATE_MODE_ALWAYS
_field.follow_camera_enabled = true
add_child(_field)
if DisplayServer.get_name() != "headless":
var gen := AudioStreamGenerator.new()
gen.mix_rate = MIX_RATE
gen.buffer_length = 0.2
_audio = AudioStreamPlayer.new()
_audio.stream = gen
_audio.volume_db = -4.0
add_child(_audio)
_audio.play()
_playback = _audio.get_stream_playback()
func _make_particles(amount: int, life: float, pm: ParticleProcessMaterial, mesh: Mesh) -> GPUParticles3D:
var p := GPUParticles3D.new()
p.amount = amount
p.lifetime = life
p.preprocess = life
p.local_coords = false
p.visibility_aabb = AABB(Vector3(-30, -40, -30), Vector3(60, 60, 60))
p.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
p.amount_ratio = 0.0
p.emitting = false
pm.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX
pm.direction = Vector3.DOWN
pm.collision_mode = ParticleProcessMaterial.COLLISION_HIDE_ON_CONTACT
pm.collision_use_scale = false
p.collision_base_size = 0.05
p.process_material = pm
p.draw_pass_1 = mesh
add_child(p)
return p
## Two crossed thin quads along +Y, so a streak reads from any side once the
## particle is aligned to its velocity.
static func _streak_mesh() -> ArrayMesh:
var st := SurfaceTool.new()
st.begin(Mesh.PRIMITIVE_TRIANGLES)
var w := 0.012
var h := 0.32
for axis in [Vector3.RIGHT, Vector3.BACK]:
var a: Vector3 = axis * w
var quad := [-a + Vector3(0, -h, 0), a + Vector3(0, -h, 0), a + Vector3(0, h, 0), -a + Vector3(0, h, 0)]
for i in [0, 1, 2, 0, 2, 3]:
st.set_uv(Vector2(0.5, 1.0 if quad[i].y < 0 else 0.0))
st.add_vertex(quad[i])
var m := StandardMaterial3D.new()
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
m.cull_mode = BaseMaterial3D.CULL_DISABLED
m.albedo_color = Color(0.72, 0.78, 0.88, 0.22)
m.disable_receive_shadows = true
st.set_material(m)
return st.commit()
static func _flake_mesh() -> QuadMesh:
var q := QuadMesh.new()
q.size = Vector2.ONE * 0.07
var m := StandardMaterial3D.new()
m.billboard_mode = BaseMaterial3D.BILLBOARD_PARTICLES
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
m.albedo_texture = Campfire._soft_dot()
m.albedo_color = Color(1, 1, 1, 0.9)
m.emission_enabled = true
m.emission = Color(0.25, 0.27, 0.3) # stays visible against a dark sky
m.disable_receive_shadows = true
q.material = m
return q
func _process(delta: float) -> void:
if weather == null:
return
var cam := get_viewport().get_camera_3d()
if cam == null:
return
var cp := cam.global_position
var fwd := -cam.global_basis.z
fwd.y = 0.0
var snow: bool = weather.snows_at(cp)
var amount: float = weather.precip if weather.precip > 0.05 else 0.0
var wind_v: Vector3 = Vector3(0.8, 0.0, 0.3) * float(weather.wind)
# Emit a little ahead of the camera, so the view is full and the area
# behind doesn't waste particles.
_rain.global_position = cp + fwd.normalized() * 5.0 + Vector3(0, 13, 0)
_snow.global_position = cp + fwd.normalized() * 3.0 + Vector3(0, 9, 0)
_set_amount(_rain, 0.0 if snow else amount)
_set_amount(_snow, amount if snow else 0.0)
_rain_pm.direction = (Vector3.DOWN + wind_v * 0.25).normalized()
_snow_pm.gravity = Vector3(0, -0.6, 0) + wind_v * 1.5
_field.visible = amount > 0.0
RenderingServer.global_shader_parameter_set("weather_wet", weather.ground_wet)
RenderingServer.global_shader_parameter_set("weather_snow", weather.snow_cover)
RenderingServer.global_shader_parameter_set("weather_wind", lerpf(0.7, 3.0, weather.wind))
var roofed: bool = player != null and player.survival.roofed
var sheltered: bool = player != null and player.survival.sheltered
_muffle = move_toward(_muffle, 1.0 if roofed else 0.0, delta * 3.0)
_rain_amp = weather.rain_at(cp) * 0.30 * (1.0 - 0.45 * _muffle)
_wind_amp = weather.wind * weather.wind * 0.22 * (0.35 if sheltered else 1.0)
if weather.lightning_count != _seen_strikes:
_seen_strikes = weather.lightning_count
_thunder_delay = _rng.randf_range(0.5, 2.5)
if _thunder_delay >= 0.0:
_thunder_delay -= delta
if _thunder_delay < 0.0:
_thunder_env = 1.0
_fill_audio()
func _set_amount(p: GPUParticles3D, a: float) -> void:
p.amount_ratio = a
p.emitting = a > 0.0
## Synthesises as many audio frames as the generator can take.
func _fill_audio() -> void:
if _playback == null:
return
var n := _playback.get_frames_available()
if n <= 0:
return
var rain_cut := lerpf(0.55, 0.12, _muffle)
var buf := PackedVector2Array()
buf.resize(n)
var thunder_decay := exp(-1.0 / (MIX_RATE * 0.9))
for i in range(n):
var white := _rng.randf() * 2.0 - 1.0
# Rain: band-limited hiss (low-pass, minus a slower low-pass).
_rain_lp += rain_cut * (white - _rain_lp)
_rain_hp += 0.02 * (_rain_lp - _rain_hp)
var rain := (_rain_lp - _rain_hp) * _rain_amp
# Wind: brown noise, swelling in slow gusts.
_brown = clampf(_brown * 0.998 + white * 0.03, -1.0, 1.0)
_gust_t += 1.0 / MIX_RATE
var gust := 0.6 + 0.4 * sin(_gust_t * 0.7) * sin(_gust_t * 0.23 + 1.0)
var wind_s := _brown * _wind_amp * gust * 2.0
# Thunder: a heavier brown rumble with a long decay.
var thunder := 0.0
if _thunder_env > 0.001:
_brown2 = clampf(_brown2 * 0.9995 + white * 0.02, -1.0, 1.0)
thunder = _brown2 * _thunder_env * 2.2
_thunder_env *= thunder_decay
var s := clampf(rain + wind_s + thunder, -1.0, 1.0)
buf[i] = Vector2(s, s)
_playback.push_buffer(buf)