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:
Paul Bailey
2026-11-04 10:16:41 +00:00
parent fc7eb3cfed
commit fed96ef118
36 changed files with 2421 additions and 58 deletions

View File

@@ -28,6 +28,11 @@ var _sky_timer := 0.0
const DAY_COLS := [Color(0.24, 0.44, 0.72), Color(0.72, 0.79, 0.85), Color(0.70, 0.77, 0.85)]
const DUSK_COLS := [Color(0.22, 0.27, 0.48), Color(0.98, 0.62, 0.38), Color(0.82, 0.62, 0.50)]
const NIGHT_COLS := [Color(0.02, 0.03, 0.07), Color(0.07, 0.09, 0.16), Color(0.07, 0.09, 0.14)]
const OVERCAST_COLS := [Color(0.40, 0.44, 0.49), Color(0.58, 0.61, 0.64), Color(0.56, 0.59, 0.62)]
const BASE_FOG := 0.0035 ## default_env.tres fog_density
var weather: Node ## weather.gd, set by world.gd
var _flash_shown := false
func setup(world_sun: DirectionalLight3D, environment: Environment) -> void:
sun = world_sun
@@ -78,37 +83,54 @@ func apply(force: bool) -> void:
daylight = smoothstep(-0.2, 0.25, to_sun.y)
var low := 1.0 - smoothstep(0.05, 0.45, absf(to_sun.y)) # 1 near the horizon
# Weather (weather.gd) dims the sun, thickens fog and greys the sky.
var w_light := 1.0
var w_fog := 1.0
var w_clouds := 0.3
var w_flash := 0.0
if weather != null:
w_light = weather.light
w_fog = weather.fog
w_clouds = weather.clouds
w_flash = weather.flash
var overcast := smoothstep(0.5, 1.0, w_clouds)
sun.global_basis = Basis.looking_at(-to_sun if to_sun.y > -0.2 else Vector3.DOWN, Vector3.FORWARD)
sun.light_energy = 1.8 * smoothstep(-0.03, 0.15, to_sun.y)
sun.light_energy = 1.8 * smoothstep(-0.03, 0.15, to_sun.y) * w_light
sun.light_color = Color(1.0, 0.95, 0.83).lerp(Color(1.0, 0.58, 0.32), low)
sun.visible = sun.light_energy > 0.01
sun.shadow_enabled = sun.visible
var moon_dir := Vector3(-to_sun.x, absf(to_sun.y) * 0.8 + 0.25, -to_sun.z * 0.5).normalized()
moon.global_basis = Basis.looking_at(-moon_dir, Vector3.FORWARD)
moon.light_energy = 0.38 * (1.0 - daylight)
moon.light_energy = 0.38 * (1.0 - daylight) * (1.0 - 0.6 * overcast)
moon.visible = moon.light_energy > 0.01
moon.shadow_enabled = moon.visible and not sun.visible
env.ambient_light_energy = lerpf(0.3, 1.0, daylight)
env.fog_light_energy = lerpf(0.25, 1.0, daylight)
env.volumetric_fog_density = lerpf(0.0025, 0.0045, daylight)
env.ambient_light_energy = lerpf(0.3, 1.0, daylight) * lerpf(1.0, 0.85, overcast) + w_flash * 3.0
env.fog_light_energy = lerpf(0.25, 1.0, daylight) * lerpf(1.0, 0.7, overcast) + w_flash * 2.0
env.volumetric_fog_density = lerpf(0.0025, 0.0045, daylight) * lerpf(1.0, 2.0, overcast)
env.fog_density = BASE_FOG * w_fog
_sky_timer -= get_process_delta_time()
if not force and _sky_timer > 0.0:
if not force and _sky_timer > 0.0 and w_flash < 0.05 and not _flash_shown:
return
_flash_shown = w_flash >= 0.05
_sky_timer = 0.25
var cols: Array = []
for i in range(3):
var dayc: Color = DAY_COLS[i].lerp(DUSK_COLS[i], low * daylight)
var dayc: Color = DAY_COLS[i].lerp(DUSK_COLS[i], low * daylight * (1.0 - overcast * 0.7))
dayc = dayc.lerp(OVERCAST_COLS[i], overcast * 0.85)
cols.append(NIGHT_COLS[i].lerp(dayc, daylight))
env.fog_light_color = cols[2]
if sky_mat != null:
sky_mat.set_shader_parameter("zenith_col", cols[0])
sky_mat.set_shader_parameter("horizon_col", cols[1])
sky_mat.set_shader_parameter("energy", lerpf(0.35, 1.0, daylight))
sky_mat.set_shader_parameter("star_strength", 1.0 - smoothstep(0.0, 0.35, daylight))
sky_mat.set_shader_parameter("cloud_lit", Color(0.16, 0.18, 0.24).lerp(Color(1, 1, 1), daylight))
sky_mat.set_shader_parameter("energy", lerpf(0.35, 1.0, daylight) * lerpf(1.0, 0.72, overcast) + w_flash * 2.5)
sky_mat.set_shader_parameter("star_strength", (1.0 - smoothstep(0.0, 0.35, daylight)) * (1.0 - overcast))
sky_mat.set_shader_parameter("cloud_cover", lerpf(0.35, 1.05, w_clouds))
var lit := Color(0.16, 0.18, 0.24).lerp(Color(1, 1, 1), daylight)
sky_mat.set_shader_parameter("cloud_lit", lit.lerp(lit * Color(0.55, 0.57, 0.6), overcast))
sky_mat.set_shader_parameter("sun_tint", Color(1.0, 0.86, 0.62).lerp(Color(1.0, 0.55, 0.3), low))
## 0..100 "how warm the air is" before fires, shelter and clothes.

View File

@@ -30,6 +30,18 @@ func interact_prompt(_player: Node) -> String:
func interact(player: Node) -> void:
Actions.pickup(player, self)
signal taken_now(pickup: Node)
## Picked up: hidden (not freed) so it can reappear later (props.gd regrowth).
func collected() -> void:
if taken:
return
taken = true
queue_free()
visible = false
collision_layer = 0
taken_now.emit(self)
func regrow() -> void:
taken = false
visible = true
collision_layer = 2

View File

@@ -42,6 +42,12 @@ var _resources: Node3D ## harvestable trees/boulders (each its own body)
var resource_nodes: Dictionary = {} ## uid -> resource node
var pickups: Dictionary = {} ## uid -> pickup
var forage: Dictionary = {} ## uid -> berry bush (forage.gd)
## Regrowth: uid -> seconds until a felled tree, mined boulder or picked-up
## item comes back. Saved with the world. Bushes keep their own timers.
var regrow: Dictionary = {}
const REGROW_SECONDS := {"tree": 2400.0, "rock": 3600.0, "pickup": 900.0}
const REGROW_RETRY := 120.0 ## blocked (player or a building nearby): try again later
var _regrow_tick := 0.0
var _bark_mat: StandardMaterial3D
var _leaf_mat: ShaderMaterial
@@ -101,12 +107,83 @@ func place_on(t: Node3D, player: Node3D, spawn: Vector3) -> void:
func _process(delta: float) -> void:
if _player == null:
return
_regrow_tick += delta
if _regrow_tick >= 1.0:
skip_time(_regrow_tick)
_regrow_tick = 0.0
_grass_timer -= delta
if _grass_timer > 0.0:
return
_grass_timer = 0.2
_update_grass(grass_tiles_per_update)
# --- regrowth ---------------------------------------------------------------
func _regrow_kind(uid: String) -> String:
if pickups.has(uid):
return "pickup"
return resource_nodes[uid].kind
func _on_depleted(node: Node) -> void:
regrow[node.uid] = REGROW_SECONDS[node.kind]
func _on_taken(pk: Node) -> void:
regrow[pk.uid] = REGROW_SECONDS["pickup"]
## Advances regrowth timers by `secs` (every second while playing, or in one
## go when you sleep).
func skip_time(secs: float) -> void:
for uid in regrow.keys():
regrow[uid] -= secs
if regrow[uid] <= 0.0:
_try_regrow(uid)
for uid in forage:
var f = forage[uid]
if secs > 1.5 and f.regrow_left > 0.0:
f._process(secs)
func _try_regrow(uid: String) -> void:
var node: Node3D = pickups.get(uid, resource_nodes.get(uid))
if node == null:
regrow.erase(uid)
return
var r := 0.5 if pickups.has(uid) else 1.4
if _player != null and _player.global_position.distance_to(node.global_position) < r + 3.0 \
or _built_over(node.global_position, r):
regrow[uid] = REGROW_RETRY
return
regrow.erase(uid)
node.regrow()
## Something built (or being built) where a tree or rock would come back.
func _built_over(pos: Vector3, r: float) -> bool:
var sph := SphereShape3D.new()
sph.radius = r
var q := PhysicsShapeQueryParameters3D.new()
q.shape = sph
q.transform = Transform3D(Basis(), pos + Vector3(0, r, 0))
q.collision_mask = 4 # building pieces (build_piece.gd BUILD_LAYER)
if not get_world_3d().direct_space_state.intersect_shape(q, 1).is_empty():
return true
# Chests, fires and beds aren't on the building layer.
for p in get_tree().get_nodes_in_group("piece"):
if (p as Node3D).global_position.distance_to(pos) < r + 0.8:
return true
return false
## From a save. Anything gone without a saved timer (older saves) starts a
## fresh one.
func restore_regrow(saved: Dictionary) -> void:
for uid in saved:
if pickups.has(uid) or resource_nodes.has(uid):
regrow[uid] = float(saved[uid])
for uid in resource_nodes:
if resource_nodes[uid].depleted and not regrow.has(uid):
regrow[uid] = REGROW_SECONDS[resource_nodes[uid].kind]
for uid in pickups:
if pickups[uid].taken and not regrow.has(uid):
regrow[uid] = REGROW_SECONDS["pickup"]
# --- materials --------------------------------------------------------------
func _make_materials() -> void:
@@ -205,6 +282,7 @@ func _resource_node(kind: String, xf: Transform3D) -> StaticBody3D:
node.drops_final = {"stone": [3, 5], "flint": [1, 2]}
node.chip_color = Color(0.55, 0.54, 0.52)
node.hp = node.max_hp
node.depleted_now.connect(_on_depleted)
_resources.add_child(node)
node.global_position = xf.origin
return node
@@ -539,6 +617,7 @@ func _scatter_pickups() -> void:
pk.setup(id, mesh, ItemDB.item_material(), radius[id])
pk.uid = "p%d" % pickups.size()
pickups[pk.uid] = pk
pk.taken_now.connect(_on_taken)
root.add_child(pk)
pk.global_transform = Transform3D(_ground_basis(n, 1.0, yaw, 1.0), Vector3(x, h - 0.02, z))
placed += 1
@@ -627,6 +706,12 @@ func _make_grass_tile(k: Vector2i) -> Node3D:
var flower_xf: Array = []
var flower_col: Array = []
var steps := int(GRASS_TILE / grass_spacing)
# Floors that overlap this tile (grass doesn't grow through them).
var cuts: Array = []
var tile := Rect2(ox, oz, GRASS_TILE, GRASS_TILE).grow(2.0)
for c in _grass_cuts:
if tile.has_point(Vector2(c[1].x, c[1].z)):
cuts.append(c)
for iz in range(steps):
for ix in range(steps):
var x := ox + (ix + rng.randf()) * grass_spacing
@@ -641,6 +726,8 @@ func _make_grass_tile(k: Vector2i) -> Node3D:
var n: Vector3 = terrain.sample_normal(x, z)
if n.y < 0.72:
continue
if not cuts.is_empty() and _under_cut(cuts, x, z):
continue
var meadow := _meadow_noise.get_noise_2d(x, z)
var dens := smoothstep(-0.45, 0.1, meadow) * (1.0 - smoothstep(0.85, 0.72, n.y) * 0.7)
if keep > dens:
@@ -666,6 +753,38 @@ func _make_grass_tile(k: Vector2i) -> Node3D:
_grass_root.add_child(node)
return node
## Floor footprints to keep clear of grass: [world->local transform, centre,
## half width]. Set by world.gd whenever floors are built or removed; only the
## grass tiles around old and new floors are rebuilt.
var _grass_cuts: Array = []
func set_grass_cuts(xforms: Array, half: float) -> void:
var old := _grass_cuts
_grass_cuts = []
for xf: Transform3D in xforms:
_grass_cuts.append([xf.affine_inverse(), xf.origin, half])
var touched := {}
for c in old + _grass_cuts:
var p: Vector3 = c[1]
for dz in [-3.0, 0.0, 3.0]:
for dx in [-3.0, 0.0, 3.0]:
touched[Vector2i(floori((p.x + dx) / GRASS_TILE), floori((p.z + dz) / GRASS_TILE))] = true
for k in touched:
if _grass_tiles.has(k):
var node = _grass_tiles[k]
if node != null and is_instance_valid(node):
node.queue_free()
_grass_tiles.erase(k)
if _player != null:
_update_grass(touched.size())
static func _under_cut(cuts: Array, x: float, z: float) -> bool:
for c in cuts:
var l: Vector3 = (c[0] as Transform3D) * Vector3(x, (c[1] as Vector3).y, z)
if absf(l.x) < c[2] and absf(l.z) < c[2]:
return true
return false
func _fade(gi: GeometryInstance3D, dist: float) -> void:
gi.visibility_range_end = dist
gi.visibility_range_end_margin = 10.0

View File

@@ -29,6 +29,11 @@ var rng := RandomNumberGenerator.new()
var _shake := 0.0
var _shake_axis := Vector3.RIGHT
var _stump: MeshInstance3D
var _orig_shapes: Array = [] ## [CollisionShape3D, shape, position] before the stump
## Emitted when a player fells or mines this (not when a save restores it).
signal depleted_now(node: Node)
func display_name() -> String:
return "Tree" if kind == "tree" else "Boulder"
@@ -107,7 +112,36 @@ func _deplete(from: Vector3, animate: bool = true) -> void:
else:
if animate:
_burst(global_position + Vector3(0, 0.8, 0), chip_color, 40)
queue_free()
# Kept (hidden, no collision) so it can regrow later.
set_process(false)
remove_from_group("harvestable")
if animate:
depleted_now.emit(self)
## Brings a felled tree or mined boulder back, growing in over a few seconds.
func regrow() -> void:
if not depleted:
return
depleted = false
hp = max_hp
if _stump != null:
_stump.queue_free()
_stump = null
for e in _orig_shapes:
e[0].shape = e[1]
e[0].position = e[2]
_orig_shapes.clear()
if has_meta("is_stump"):
remove_meta("is_stump")
collision_layer = 1
add_to_group("harvestable")
set_process(true)
if is_inside_tree() and DisplayServer.get_name() != "headless":
var tw := create_tween()
tw.tween_method(func(k: float): _set_instance(Transform3D(base_xf.basis.scaled_local(Vector3.ONE * k), base_xf.origin)),
0.15, 1.0, 4.0).set_trans(Tween.TRANS_SINE)
else:
_set_instance(base_xf)
func _fell(from: Vector3, animate: bool) -> void:
var world := get_parent()
@@ -139,9 +173,11 @@ func _make_stump(world: Node) -> void:
stump.material_override = stump_material
world.add_child(stump)
stump.global_position = base_xf.origin
_stump = stump
# Keep collision as a short stump.
for c in get_children():
if c is CollisionShape3D and c.shape is CapsuleShape3D:
_orig_shapes.append([c, c.shape, c.position])
var cyl := CylinderShape3D.new()
cyl.radius = (c.shape as CapsuleShape3D).radius
cyl.height = 0.6

191
scripts/world/weather.gd Normal file
View 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

217
scripts/world/weather_fx.gd Normal file
View 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)

View File

@@ -21,7 +21,11 @@ const PIECES := {
"workbench": preload("res://scripts/building/workbench.gd"),
"chest": preload("res://scripts/building/chest.gd"),
"grave": preload("res://scripts/building/grave.gd"),
"build": preload("res://scripts/building/build_piece.gd"),
"door": preload("res://scripts/building/door.gd"),
"bed": preload("res://scripts/building/bed.gd"),
}
const BuildPiece = preload("res://scripts/building/build_piece.gd")
const AUTOSAVE_SECONDS := 120.0
@@ -38,6 +42,12 @@ var _autosave_left := AUTOSAVE_SECONDS
var _menu_cam: Camera3D
var _menu_t := 0.0
var day_night: Node ## day_night.gd: time of day, sun, sky
var weather: Node ## weather.gd: the weather model
var weather_fx: Node3D ## weather_fx.gd: rain/snow particles, sound
var build: Node ## build_system.gd: snapping, support, shelter
var has_bed_spawn := false
var bed_spawn := Vector3.ZERO ## position of the bed you respawn at
var _loading := false ## applying a save: defer support recompute
func _ready() -> void:
_pieces = Node3D.new()
@@ -47,6 +57,22 @@ func _ready() -> void:
day_night.name = "DayNight"
add_child(day_night)
day_night.setup(sun, $WorldEnvironment.environment)
weather = preload("res://scripts/world/weather.gd").new()
weather.name = "Weather"
add_child(weather)
weather.snowline = terrain.snow_height - 8.0
weather.frozen = menu_mode
weather.setup(terrain.noise_seed, day_night)
day_night.weather = weather
build = preload("res://scripts/building/build_system.gd").new()
build.name = "Build"
add_child(build)
build.setup(self)
if not menu_mode and DisplayServer.get_name() != "headless":
weather_fx = preload("res://scripts/world/weather_fx.gd").new()
weather_fx.name = "WeatherFX"
add_child(weather_fx)
weather_fx.setup(weather, player)
if menu_mode:
day_night.time = 9.5 / 24.0
day_night.frozen = true
@@ -75,10 +101,17 @@ func _ready() -> void:
# --- survival environment ---------------------------------------------------
## 0..100 air warmth at `pos` before fires: time of day, minus altitude.
func air_warmth(pos: Vector3) -> float:
## 0..100 air warmth at `pos` before fires: time of day, minus altitude,
## plus the weather. A proper shelter keeps out most of the weather and adds a
## little warmth of its own.
func air_warmth(pos: Vector3, sheltered: bool = false) -> float:
var w: float = day_night.air_warmth()
w -= maxf(pos.y - 12.0, 0.0) * 0.9
var ww: float = weather.warmth_at(pos)
if sheltered:
w += ww * 0.25 + 8.0
else:
w += ww
return w
## 0..1 heat from fires and a held torch.
@@ -108,10 +141,71 @@ func _on_player_died() -> void:
player.inventory.load_data(Inventory.new(player.inventory.size()).to_data())
if hud != null:
hud.close_screens()
_respawn()
var bed := spawn_bed()
if bed != null:
_respawn_at_bed(bed)
else:
_respawn()
if has_bed_spawn:
has_bed_spawn = false
Actions.notify.emit("Your bed is gone, so you woke at the island start")
player.survival.reset_after_death()
Actions.notify.emit("You died. Your belongings are in your grave." if has_items else "You died.")
# --- beds and sleeping ------------------------------------------------------
## The bed you respawn at, if it still exists.
func spawn_bed() -> Node3D:
if not has_bed_spawn:
return null
for p in _pieces.get_children():
if p.item_id == "bed" and not p.is_queued_for_deletion() and p.global_position.distance_to(bed_spawn) < 0.3:
return p
return null
func set_bed_spawn(bed: Node3D) -> void:
has_bed_spawn = true
bed_spawn = bed.global_position
func _respawn_at_bed(bed: Node3D) -> void:
player.global_position = bed.global_position + Vector3(0, 0.9, 0)
player.velocity = Vector3.ZERO
player.rotation = Vector3(0.0, bed.global_basis.get_euler().y + PI, 0.0)
player.get_node("Camera3D").rotation.x = 0.0
## Why you can't sleep in `bed` right now ("" = you can).
func sleep_block_reason(bed: Node3D) -> String:
var h: float = day_night.hours()
if h > 5.5 and h < 19.5:
return "You can only sleep at night (after 19:30)"
if not build.sheltered_at(bed.global_position):
return "Your bed needs a roof and walls around it"
return ""
## Sleeps until 06:00: the clock, fires, regrowth and weather all move on.
func sleep(p: Node3D) -> void:
var secs: float = fposmod(6.0 - day_night.hours(), 24.0) / 24.0 * day_night.day_length
if hud != null:
hud.fade(func(): _finish_sleep(p, secs))
else:
_finish_sleep(p, secs)
func _finish_sleep(p: Node3D, secs: float) -> void:
skip_time(secs)
p.survival.after_sleep(secs)
save_now()
Actions.notify.emit("You slept until morning")
## Fast-forwards the world (not the player) by `secs`.
func skip_time(secs: float) -> void:
day_night.advance(secs)
day_night.apply(true)
weather.snap()
props.skip_time(secs)
for f in get_tree().get_nodes_in_group("heat"):
if f.has_method("burn"):
f.burn(secs)
func _on_terrain_ready() -> void:
var t0 := Time.get_ticks_msec()
_spawn = terrain.find_spawn(Vector2.ZERO)
@@ -217,7 +311,9 @@ func collect_save() -> Dictionary:
"stats": player.survival.to_data(),
},
"time": {"day": day_night.day, "time": day_night.time},
"spawn": {"bed": has_bed_spawn, "pos": [bed_spawn.x, bed_spawn.y, bed_spawn.z]},
"forage_regrow": regrow,
"regrow": props.regrow.duplicate(),
"harvested": harvested,
"pickups_taken": taken,
"pieces": pieces,
@@ -250,6 +346,14 @@ func apply_save(s: Dictionary) -> void:
for uid in s.get("pickups_taken", []):
if props.pickups.has(uid) and is_instance_valid(props.pickups[uid]):
props.pickups[uid].collected()
# Regrowth timers (older saves have none: everything gone starts a fresh timer).
props.restore_regrow(s.get("regrow", {}))
var sp: Dictionary = s.get("spawn", {})
has_bed_spawn = bool(sp.get("bed", false))
if sp.has("pos"):
bed_spawn = Vector3(sp.pos[0], sp.pos[1], sp.pos[2])
weather.snap()
_loading = true
for e in s.get("pieces", []):
var def: Dictionary = ItemDB.get_item(e.get("item", ""))
if def.is_empty() or not def.has("place"):
@@ -259,6 +363,9 @@ func apply_save(s: Dictionary) -> void:
piece.inventory.load_data(e.inventory)
if e.has("extra") and piece.has_method("load_extra"):
piece.load_extra(e.extra)
_loading = false
build.recompute() # same terrain, same pieces: nothing collapses on load
_update_grass_cuts()
func _respawn() -> void:
player.global_position = _spawn + Vector3(0.0, 1.5, 0.0)
@@ -277,8 +384,11 @@ func _physics_process(_delta: float) -> void:
# --- building ---------------------------------------------------------------
## Whether a piece can go at `xf`: inside the valley, on dry fairly flat
## ground, and not overlapping trees, rocks or other pieces.
## ground (or a floor), and not overlapping trees, rocks or other pieces.
## Building pieces have their own rules (build_system.gd).
func placement_valid(def: Dictionary, xf: Transform3D) -> bool:
if BuildPiece.is_build(def):
return build.check(def, xf).is_empty()
var p := xf.origin
if not terrain.inside_boundary(p.x, p.z, 1.5):
return false
@@ -287,10 +397,12 @@ func placement_valid(def: Dictionary, xf: Transform3D) -> bool:
var fp: Array = def.place.get("footprint", [1.0, 1.0])
var hx := float(fp[0]) * 0.5
var hz := float(fp[1]) * 0.5
# Ground under the corners must be within reach of the base.
# Something solid (ground or a floor) under each corner, close to the base.
var space := get_world_3d().direct_space_state
for c in [Vector3(-hx, 0, -hz), Vector3(hx, 0, -hz), Vector3(-hx, 0, hz), Vector3(hx, 0, hz)]:
var w: Vector3 = xf * c
if absf(terrain.sample_height(w.x, w.z) - p.y) > 0.45:
var rq := PhysicsRayQueryParameters3D.create(w + Vector3(0, 0.45, 0), w - Vector3(0, 0.45, 0), 1, [player.get_rid()])
if space.intersect_ray(rq).is_empty():
return false
# Overlap test against everything solid except the ground itself.
var box := BoxShape3D.new()
@@ -308,8 +420,75 @@ func spawn_piece(def: Dictionary, xf: Transform3D) -> Node3D:
piece.setup(def)
_pieces.add_child(piece)
piece.global_transform = xf
if piece.is_in_group("build") and not _loading:
build.recompute()
if piece.kind == "floor":
_update_grass_cuts()
return piece
## Grass doesn't grow up through floors.
func _update_grass_cuts() -> void:
var xfs: Array = []
for p in _pieces.get_children():
if p.is_in_group("build") and p.kind == "floor" and not p.is_queued_for_deletion():
xfs.append(p.global_transform)
props.set_grass_cuts(xfs, 1.02)
## Takes a piece out of the world (dismantled). Anything that loses its
## support collapses, and its item goes back to `refund_to` if there's room.
## Returns how many pieces collapsed.
func remove_piece(piece: Node3D, refund_to: Node3D = null) -> int:
if piece.item_id == "bed" and spawn_bed() == piece:
has_bed_spawn = false
Actions.notify.emit("Spawn point cleared")
var was_build := piece.is_in_group("build")
_pieces.remove_child(piece)
piece.queue_free()
if not was_build:
return 0
var weak: Array = build.recompute()
for w in weak:
if refund_to != null:
Actions._give(refund_to, w.item_id, 1)
_collapse_fx(w)
_pieces.remove_child(w)
w.queue_free()
if not weak.is_empty():
build.recompute()
Actions.notify.emit("%d piece%s collapsed" % [weak.size(), "" if weak.size() == 1 else "s"])
_settle_loose_pieces()
_update_grass_cuts()
return weak.size()
## A collapsing piece: a copy of its mesh tumbles down and sinks away.
func _collapse_fx(piece: Node3D) -> void:
if DisplayServer.get_name() == "headless":
return
var mi := MeshInstance3D.new()
mi.mesh = piece.get_node("Mesh").mesh
mi.material_override = ItemDB.item_material()
add_child(mi)
mi.global_transform = piece.global_transform
var tw := mi.create_tween().set_parallel(true)
tw.tween_property(mi, "global_position:y", mi.global_position.y - 4.0, 1.2).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
tw.tween_property(mi, "rotation:x", mi.rotation.x + randf_range(-0.8, 0.8), 1.2)
tw.chain().tween_callback(mi.queue_free)
## Chests, fires and beds left hanging after a collapse drop to whatever is
## below them.
func _settle_loose_pieces() -> void:
var space := get_world_3d().direct_space_state
for p in _pieces.get_children():
if p.is_in_group("build") or p.is_queued_for_deletion():
continue
var at: Vector3 = p.global_position
var rq := PhysicsRayQueryParameters3D.create(at + Vector3(0, 0.3, 0), at - Vector3(0, 0.5, 0), 1, [p.get_rid()])
if not space.intersect_ray(rq).is_empty():
continue
rq = PhysicsRayQueryParameters3D.create(at, at - Vector3(0, 80, 0), 1, [p.get_rid(), player.get_rid()])
var hit := space.intersect_ray(rq)
p.global_position.y = hit.position.y if not hit.is_empty() else terrain.sample_height(at.x, at.z)
# --- dev screenshot hook ----------------------------------------------------
func _maybe_capture() -> void:
@@ -415,3 +594,65 @@ func _capture_survival(prefix: String, cam: Camera3D) -> void:
get_viewport().get_texture().get_image().save_png("%s_menu.png" % prefix)
print("[world] saved %s_menu.png" % prefix)
hud._menu.close()
await _capture_weather(prefix, cam)
## Weather views: a snapped-together hut in clear weather, rain, a storm and
## snow, and the view out of its open door in the rain.
func _capture_weather(prefix: String, cam: Camera3D) -> void:
_respawn()
player.set_physics_process(false)
var yaw: float = player.rotation.y
var hb := Basis(Vector3.UP, yaw)
var site: Vector3 = build.debug_find_site(player.global_position - player.global_basis.z * 11.0, 3.0)
var hut: Dictionary = await build.debug_build_hut(site, yaw)
print("[world] demo hut: %d pieces, failed %s" % [hut.placed, hut.failed])
var top: float = hut.pieces[0].global_position.y
spawn_piece(ItemDB.get_item("bed"), Transform3D(hb, site + hb * Vector3(1.0, top - site.y, -0.6)))
var fire_at: Vector3 = site + hb * Vector3(-0.6, 0, 4.5)
fire_at.y = terrain.sample_height(fire_at.x, fire_at.z)
var fire: Node = spawn_piece(ItemDB.get_item("campfire"), Transform3D(hb, fire_at))
fire.fuel = fire.MAX_FUEL
var door: Node = null
for p in hut.pieces:
if p.item_id == "wood_door":
door = p
player.select(4) # wood: nothing lit in hand
var outside_eye: Vector3 = site + hb * Vector3(4.2, 0, 8.5)
outside_eye.y = terrain.sample_height(outside_eye.x, outside_eye.z) + 1.7
var look: Vector3 = site + Vector3(0, 1.4, 0)
day_night.time = 10.5 / 24.0
for shot in [["hut_clear", "clear", 10.5], ["hut_rain", "rain", 10.5], ["hut_storm", "storm", 16.0], ["hut_snow", "snow", 11.0]]:
weather.force(shot[1])
if shot[1] == "snow":
weather.snow_cover = 1.0
weather.ground_wet = 0.0
day_night.time = float(shot[2]) / 24.0
day_night.apply(true)
_aim_camera(cam, outside_eye, look)
await get_tree().create_timer(2.0).timeout
if shot[1] == "storm":
weather.flash = 0.8
day_night.apply(true)
await RenderingServer.frame_post_draw
get_viewport().get_texture().get_image().save_png("%s_%s.png" % [prefix, shot[0]])
print("[world] saved %s_%s.png" % [prefix, shot[0]])
# From inside, out through the open door, in the rain.
weather.force("rain")
weather.snow_cover = 0.0
day_night.time = 17.5 / 24.0
day_night.apply(true)
door.set_open(true, false)
var in_eye: Vector3 = site + hb * Vector3(0.6, top - site.y + 1.6, -1.2)
_aim_camera(cam, in_eye, site + hb * Vector3(1.2, top - site.y + 1.2, 6.0))
await get_tree().create_timer(2.0).timeout
await RenderingServer.frame_post_draw
get_viewport().get_texture().get_image().save_png("%s_hut_inside.png" % prefix)
print("[world] saved %s_hut_inside.png" % prefix)
weather.force("")
func _aim_camera(cam: Camera3D, eye: Vector3, at: Vector3) -> void:
var dir := at - eye
player.global_position = eye - Vector3(0.0, cam.position.y, 0.0)
player.rotation = Vector3(0.0, atan2(-dir.x, -dir.z), 0.0)
cam.rotation.x = atan2(dir.y, Vector2(dir.x, dir.z).length())
props._update_grass(100000)