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:
217
scripts/world/weather_fx.gd
Normal file
217
scripts/world/weather_fx.gd
Normal file
@@ -0,0 +1,217 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user