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

@@ -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