Files
Cinderwild/scripts/building/build_system.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

369 lines
14 KiB
GDScript

extends Node
## Building rules: snap placement, validity, structural support and shelter.
##
## Snapping (Valheim-style): for a ghost near existing pieces, every pairing of
## one of the ghost's snap points with a nearby piece's snap point gives a
## candidate transform (rotation locked to that piece's 90-degree grid). The
## best-scoring candidate that passes the validity check wins. Away from other
## pieces you can place freely on the ground.
##
## Support: a piece touching the ground has support 1. Every other piece takes
## the best support among the pieces it shares a snap point with, minus a loss
## (small going up or down, larger sideways). Below MIN_SUPPORT a piece can't
## be placed, and if dismantling a piece leaves others below it they collapse.
## So you can build a hut, a second floor or a short overhang, but not a
## bridge hanging in mid-air.
##
## Shelter: a ray up hits a roof (build layer), and most of 8 sideways rays hit
## walls. Drives the Wet, Sheltered and Rested rules and campfire rain.
const BuildPiece = preload("res://scripts/building/build_piece.gd")
const SNAP_EPS := 0.08 ## snap points closer than this are connected
const SNAP_RADIUS := 1.3 ## how far from the aim point a snap point may be
const MIN_SUPPORT := 0.3
const VERTICAL_LOSS := 0.1
const HORIZONTAL_LOSS := 0.25
const GROUND_EPS := 0.3 ## a snap point this close to the ground touches it
## Overlap checks shrink shapes by this much. Must exceed half a wall's
## thickness: a floor's edge sits on a wall's centre line by design.
const OVERLAP_MARGIN := 0.11
const CELL := 0.5
var world: Node3D
var terrain: Node3D
var pieces_root: Node3D
var _hash: Dictionary = {} ## Vector3i -> Array of [piece, point]
var last_blocker: Object ## what the last failed overlap check hit (debugging)
var _dirty := true
func setup(w: Node3D) -> void:
world = w
terrain = w.terrain
pieces_root = w._pieces
func mark_dirty() -> void:
_dirty = true
func build_pieces() -> Array:
var out := []
for p in pieces_root.get_children():
if p.is_in_group("build") and not p.is_queued_for_deletion():
out.append(p)
return out
static func _cell(p: Vector3) -> Vector3i:
return Vector3i(floori(p.x / CELL), floori(p.y / CELL), floori(p.z / CELL))
func _ensure_hash() -> void:
if not _dirty:
return
_dirty = false
_hash.clear()
for piece in build_pieces():
for pt in piece.snap_points():
var k := _cell(pt)
if not _hash.has(k):
_hash[k] = []
_hash[k].append([piece, pt])
## Pieces with a snap point within SNAP_EPS of any of `points`.
func _touching(points: Array, exclude: Node = null) -> Array:
_ensure_hash()
var out := []
for pt: Vector3 in points:
var c := _cell(pt)
for dx in range(-1, 2):
for dy in range(-1, 2):
for dz in range(-1, 2):
for e in _hash.get(c + Vector3i(dx, dy, dz), []):
if e[0] != exclude and not out.has(e[0]) and (e[1] as Vector3).distance_to(pt) < SNAP_EPS:
out.append(e[0])
return out
static func _world_snaps(kind: String, xf: Transform3D) -> Array:
var out := []
for p in BuildPiece.kinds()[kind].snaps:
out.append(xf * p)
return out
func _grounded(points: Array) -> bool:
for pt: Vector3 in points:
if pt.y <= terrain.sample_height(pt.x, pt.z) + GROUND_EPS:
return true
return false
static func _loss(from_center: Vector3, to_center: Vector3) -> float:
var d := to_center - from_center
var horiz := Vector2(d.x, d.z).length()
return VERTICAL_LOSS if absf(d.y) >= horiz * 0.99 else HORIZONTAL_LOSS
## Support a piece of `kind` would have at `xf`, given the current structure.
func support_for(kind: String, xf: Transform3D, exclude: Node = null) -> float:
var pts := _world_snaps(kind, xf)
if _grounded(pts):
return 1.0
var center: Vector3 = xf * BuildPiece.kinds()[kind].center
var best := 0.0
for n in _touching(pts, exclude):
var nc: Vector3 = n.global_transform * BuildPiece.kinds()[n.kind].center
best = maxf(best, n.support * (1.0 - _loss(nc, center)))
return best
## Recomputes every piece's support from the ground up. Returns the pieces now
## below MIN_SUPPORT (the caller collapses them).
func recompute() -> Array:
mark_dirty()
var pieces := build_pieces()
var nbrs := {}
var centers := {}
for p in pieces:
var pts: Array = p.snap_points()
p.grounded = _grounded(pts)
p.support = 1.0 if p.grounded else 0.0
nbrs[p] = _touching(pts, p)
centers[p] = p.global_transform * BuildPiece.kinds()[p.kind].center
# Relax until stable (support only ever grows from 0, bounded by 1).
for _iter in range(200):
var changed := false
for p in pieces:
if p.grounded:
continue
var best := 0.0
for n in nbrs[p]:
best = maxf(best, n.support * (1.0 - _loss(centers[n], centers[p])))
if best > p.support + 0.0001:
p.support = best
changed = true
if not changed:
break
var weak := []
for p in pieces:
if p.support < MIN_SUPPORT:
weak.append(p)
return weak
# --- placement --------------------------------------------------------------
## Why a building piece can't go at `xf` ("" = it can).
func check(def: Dictionary, xf: Transform3D, exclude: Node = null) -> String:
var kind: String = def.place.kind
var info: Dictionary = BuildPiece.kinds()[kind]
var c: Vector3 = xf * info.center
if not terrain.inside_boundary(c.x, c.z, 1.5):
return "Too close to the edge of the world"
if c.y < terrain.water_level + 0.1:
return "Can't build in water"
var space := world.get_world_3d().direct_space_state
for s in info.shapes:
var made := BuildPiece.make_shape(s, OVERLAP_MARGIN)
var q := PhysicsShapeQueryParameters3D.new()
q.shape = made[0]
q.transform = xf * (made[1] as Transform3D)
q.collision_mask = 1
var ex: Array[RID] = [terrain.get_node("TerrainBody").get_rid()]
if exclude != null:
ex.append(exclude.get_rid())
q.exclude = ex
var hits := space.intersect_shape(q, 1)
if not hits.is_empty():
last_blocker = hits[0].collider
return "Something's in the way"
if support_for(kind, xf, exclude) < MIN_SUPPORT:
return "Not enough support"
return ""
## Where a building piece goes when the player aims at `hit` (a ray result
## dictionary: position, normal, collider). `yaw` is the player's preferred
## rotation. Returns {xf, ok, reason, snapped}.
func placement(def: Dictionary, hit: Dictionary, yaw: float) -> Dictionary:
var kind: String = def.place.kind
var info: Dictionary = BuildPiece.kinds()[kind]
var hp: Vector3 = hit.position
var cands := []
for piece in build_pieces():
if piece.global_position.distance_to(hp) > 4.5:
continue
var pyaw: float = piece.global_basis.get_euler().y
var k := roundf(wrapf(yaw - pyaw, -PI, PI) / (PI * 0.5))
var basis := Basis(Vector3.UP, pyaw + k * PI * 0.5)
# The ghost sits at the aim point first (origin on the crosshair), then
# moves the short distance that makes one of its snap points meet one
# of this piece's. Closest pair wins, so the piece follows your aim.
var ghost := Transform3D(basis, hp)
for se: Vector3 in piece.snap_points():
if se.distance_to(hp) > SNAP_RADIUS + 2.5:
continue
for sp: Vector3 in info.snaps:
var d := (ghost * sp).distance_to(se)
if d > SNAP_RADIUS:
continue
var xf := Transform3D(basis, se - basis * sp)
var score := d + 0.05 * (xf * (info.center as Vector3)).distance_to(hp)
cands.append([score, xf])
cands.sort_custom(func(a, b): return a[0] < b[0])
var tried: Array[Vector3] = []
var first_reason := ""
var first_xf := Transform3D()
for cand in cands:
var xf: Transform3D = cand[1]
var dup := false
for o in tried:
if o.distance_to(xf.origin) < 0.05:
dup = true
break
if dup:
continue
tried.append(xf.origin)
if tried.size() > 14:
break
var why := check(def, xf)
if why.is_empty():
return {"xf": xf, "ok": true, "reason": "", "snapped": true}
if first_reason.is_empty():
first_reason = why
first_xf = xf
# Nothing to snap to: free placement on the ground or on top of a piece.
var n: Vector3 = hit.get("normal", Vector3.UP)
if n.y > 0.6:
var xf := _free_xf(kind, hp, yaw, hit.get("collider") == terrain.get_node("TerrainBody"))
var why := check(def, xf)
if why.is_empty() or first_reason.is_empty():
return {"xf": xf, "ok": why.is_empty(), "reason": why, "snapped": false}
if not first_reason.is_empty():
return {"xf": first_xf, "ok": false, "reason": first_reason, "snapped": true}
return {"xf": Transform3D(Basis(Vector3.UP, yaw), hp), "ok": false, "reason": "Needs flat ground or a piece to attach to", "snapped": false}
## Free placement. On the ground a floor sits level on the highest point under
## it (so it floats a little on a slope instead of sinking in); walls sit on
## the lowest point so there's no gap underneath.
func _free_xf(kind: String, hp: Vector3, yaw: float, on_terrain: bool) -> Transform3D:
var basis := Basis(Vector3.UP, yaw)
var origin := hp
if on_terrain:
var probe := []
match kind:
"floor":
probe = [Vector3(-1, 0, -1), Vector3(1, 0, -1), Vector3(-1, 0, 1), Vector3(1, 0, 1), Vector3.ZERO]
_:
probe = [Vector3(-1, 0, 0), Vector3.ZERO, Vector3(1, 0, 0)]
var hs: Array[float] = []
for p: Vector3 in probe:
var w: Vector3 = hp + basis * p
hs.append(terrain.sample_height(w.x, w.z))
origin.y = (hs.max() + 0.03) if kind == "floor" else hs.min()
return Transform3D(basis, origin)
# --- shelter ----------------------------------------------------------------
func _ray(from: Vector3, to: Vector3) -> bool:
var q := PhysicsRayQueryParameters3D.create(from, to, BuildPiece.BUILD_LAYER)
return not world.get_world_3d().direct_space_state.intersect_ray(q).is_empty()
## A roof (any building piece) above `pos` (feet height): at least 3 of 5
## rays up (centre and 30 cm around it) hit one, so a seam doesn't count as
## open sky but standing under the edge of an eave doesn't count as indoors.
func roofed_at(pos: Vector3) -> bool:
var hits := 0
for o in [Vector3.ZERO, Vector3(0.3, 0, 0), Vector3(-0.3, 0, 0), Vector3(0, 0, 0.3), Vector3(0, 0, -0.3)]:
if _ray(pos + o + Vector3(0, 0.3, 0), pos + o + Vector3(0, 14, 0)):
hits += 1
return hits >= 3
## 0..1 fraction of 8 horizontal directions blocked by walls within 8 m.
func enclosure_at(pos: Vector3) -> float:
var from := pos + Vector3(0, 1.2, 0)
var hits := 0
for i in range(8):
var a := TAU * i / 8.0
if _ray(from, from + Vector3(cos(a), 0, sin(a)) * 8.0):
hits += 1
return hits / 8.0
func sheltered_at(pos: Vector3) -> bool:
return roofed_at(pos) and enclosure_at(pos) >= 0.74
# --- dev helper ---------------------------------------------------------------
## Builds a 4 x 4 m hut through the same snapping a player uses, aiming at the
## spots a player would aim at: 4 floors, 7 walls and a door (south side, +Z
## in hut space), 4 roofs meeting at a ridge, 4 gables. Used by the tests and
## the screenshot views. A coroutine (await it): like a player, it lets a
## physics step pass after each piece so the next overlap check sees it.
## Returns {"placed": n, "failed": [...], "pieces": [...]}.
func debug_build_hut(center: Vector3, yaw: float) -> Dictionary:
var out := {"placed": 0, "failed": [], "pieces": []}
# Floors: the first sits on the ground, the rest snap to it.
var f0: Node3D = await _hut_place(out, center, yaw, "wood_floor", Vector3(-1, 0, -1), 0.0, null)
if f0 == null:
return out
var top: float = f0.global_position.y - center.y
for l in [Vector3(1, 0, -1), Vector3(-1, 0, 1), Vector3(1, 0, 1)]:
await _hut_place(out, center, yaw, "wood_floor", l, 0.0, null)
# Walls: aim at the floor just inside each edge.
var q := PI * 0.5
for x in [-1.0, 1.0]:
await _hut_place(out, center, yaw, "wood_wall", Vector3(x, top, -1.9), 0.0, f0) # north
await _hut_place(out, center, yaw, "wood_door" if x > 0 else "wood_wall", Vector3(x, top, 1.9), 0.0, f0) # south
for z in [-1.0, 1.0]:
await _hut_place(out, center, yaw, "wood_wall", Vector3(1.9, top, z), q, f0) # east
await _hut_place(out, center, yaw, "wood_wall", Vector3(-1.9, top, z), q, f0) # west
# Roofs on the north and south wall tops, rising to a ridge along X.
for x in [-1.0, 1.0]:
await _hut_place(out, center, yaw, "wood_roof", Vector3(x, top + 2.0, 2.0), 0.0, f0)
await _hut_place(out, center, yaw, "wood_roof", Vector3(x, top + 2.0, -2.0), PI, f0)
# Gables on the east and west wall tops, high end toward the ridge.
for x in [-2.0, 2.0]:
await _hut_place(out, center, yaw, "wood_gable", Vector3(x, top + 2.0, 1.0), q, f0)
await _hut_place(out, center, yaw, "wood_gable", Vector3(x, top + 2.0, -1.0), -q, f0)
return out
## One aimed placement for debug_build_hut. `on` = the piece the aim lands on
## (null = the ground).
func _hut_place(out: Dictionary, center: Vector3, yaw: float, id: String, local: Vector3, ryaw: float, on: Node3D) -> Node3D:
var def: Dictionary = ItemDB.get_item(id)
var w: Vector3 = center + Basis(Vector3.UP, yaw) * local
var tb: Node = terrain.get_node("TerrainBody")
var hit_y: float = terrain.sample_height(w.x, w.z) if on == null else w.y
var hit := {"position": Vector3(w.x, hit_y, w.z), "normal": Vector3.UP, "collider": tb if on == null else on}
var res := placement(def, hit, yaw + ryaw)
if not res.ok:
out.failed.append("%s@%s: %s" % [id, local, res.reason])
return null
var p: Node3D = world.spawn_piece(def, res.xf)
out.placed += 1
out.pieces.append(p)
await world.get_tree().physics_frame
return p
## A spot near `near` with a clear, fairly flat `half`-metre square: nothing
## solid in the way and the ground within 0.6 m across it. Searches outward
## in rings. Returns `near` itself if nothing is found.
func debug_find_site(near: Vector3, half: float) -> Vector3:
var space := world.get_world_3d().direct_space_state
var box := BoxShape3D.new()
box.size = Vector3(half * 2.0 + 2.0, 6.0, half * 2.0 + 2.0)
var ex: Array[RID] = [terrain.get_node("TerrainBody").get_rid(), world.player.get_rid()]
for ring in range(0, 12):
var n := maxi(1, ring * 6)
for i in range(n):
var a := TAU * i / n
var c := near + Vector3(cos(a), 0, sin(a)) * ring * 4.0
if not terrain.inside_boundary(c.x, c.z, half + 4.0):
continue
var hs: Array[float] = []
for d in [Vector2(-1, -1), Vector2(1, -1), Vector2(-1, 1), Vector2(1, 1), Vector2.ZERO]:
hs.append(terrain.sample_height(c.x + d.x * half, c.z + d.y * half))
if hs.max() - hs.min() > 0.6 or hs.min() < terrain.water_level + 0.5:
continue
c.y = hs[4]
var q := PhysicsShapeQueryParameters3D.new()
q.shape = box
q.transform = Transform3D(Basis(), c + Vector3(0, 3.0, 0))
q.collision_mask = 1
q.exclude = ex
if space.intersect_shape(q, 1).is_empty():
return c
return near