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

18
scripts/building/bed.gd Normal file
View File

@@ -0,0 +1,18 @@
extends "res://scripts/building/piece.gd"
## Bed: E sets your spawn point here. Once it's yours, E at night sleeps until
## morning, if the bed is under a roof with walls around it.
func display_name() -> String:
return "Bed"
func is_spawn() -> bool:
var w := get_parent().get_parent()
return w.get("bed_spawn") != null and w.has_bed_spawn and (w.bed_spawn as Vector3).distance_to(global_position) < 0.3
func interact_prompt(_player: Node) -> String:
if not is_spawn():
return "Bed [E] Set spawn point"
return "Bed (your spawn point) [E] Sleep"
func interact(player: Node) -> void:
Actions.use_bed(player, self)

View File

@@ -0,0 +1,135 @@
extends "res://scripts/building/piece.gd"
## A snap-together building piece: wood floor, wall, roof, gable or door frame.
##
## Geometry lives here (not in items.json) because it has to match the meshes
## in item_meshes.gd exactly:
## - `shapes`: the colliders, also used (shrunk slightly) for overlap checks.
## - `snaps`: snap points in local space. Two pieces are connected when a snap
## point of one coincides with a snap point of the other; build_system.gd
## uses that for snapping and for structural support.
## - `center`: rough centre of mass, used to tell vertical from sideways
## support.
##
## All pieces are 2 m on a side so they line up on a 2 m grid.
## Origins: floor = centre of the top surface; wall, door and gable = bottom
## centre; roof = centre of the low edge, rising 1 m over 2 m toward -Z
## (about 27 degrees), so two roofs from opposite walls of a 4 m hut meet at a
## ridge.
const BUILD_LAYER := 4 ## physics layer 3: shelter + regrowth queries
const ROOF_ANGLE := 0.4636476 ## atan(1 / 2)
const ROOF_T := 0.12 ## roof panel thickness
static var _kinds := {}
var kind := "wall"
var support := 1.0 ## 0..1, set by build_system.gd
var grounded := false
static func kinds() -> Dictionary:
if _kinds.is_empty():
_kinds = _define()
return _kinds
static func _define() -> Dictionary:
var k := {}
var wall_snaps := [
Vector3(-1, 0, 0), Vector3(0, 0, 0), Vector3(1, 0, 0),
Vector3(-1, 2, 0), Vector3(0, 2, 0), Vector3(1, 2, 0)]
k["floor"] = {
"shapes": [_box(Vector3(0, -0.1, 0), Vector3(2, 0.2, 2))],
"snaps": [Vector3(-1, 0, -1), Vector3(0, 0, -1), Vector3(1, 0, -1), Vector3(-1, 0, 0), Vector3(1, 0, 0),
Vector3(-1, 0, 1), Vector3(0, 0, 1), Vector3(1, 0, 1)],
"center": Vector3(0, -0.1, 0),
}
k["wall"] = {
"shapes": [_box(Vector3(0, 1, 0), Vector3(2, 2, 0.2))],
"snaps": wall_snaps,
"center": Vector3(0, 1, 0),
}
# Door frame: 1.1 m x 1.9 m opening (the player is 0.8 m x 1.8 m).
k["door"] = {
"shapes": [_box(Vector3(-0.775, 1, 0), Vector3(0.45, 2, 0.2)), _box(Vector3(0.775, 1, 0), Vector3(0.45, 2, 0.2)),
_box(Vector3(0, 1.95, 0), Vector3(1.1, 0.1, 0.2))],
"snaps": wall_snaps,
"center": Vector3(0, 1, 0),
}
# Gable: right triangle, 0 m tall at -X, 1 m at +X (fills the end of a
# roof; rotate 180 degrees for the other half).
k["gable"] = {
"shapes": [_prism([Vector2(-1, 0), Vector2(1, 0), Vector2(1, 1)], 0.2)],
"snaps": [Vector3(-1, 0, 0), Vector3(0, 0, 0), Vector3(1, 0, 0), Vector3(1, 1, 0)],
"center": Vector3(0.33, 0.33, 0),
}
var n := Vector3(0, cos(ROOF_ANGLE), sin(ROOF_ANGLE)) # panel normal (up and out)
# (Ends exactly at the ridge. The seam where two roofs meet is handled by
# build_system.roofed_at sampling several rays.)
k["roof"] = {
"shapes": [{"type": "box", "center": Vector3(0, 0.5, -1) + n * ROOF_T * 0.5,
"size": Vector3(2, ROOF_T, sqrt(5.0)), "basis": Basis(Vector3.RIGHT, ROOF_ANGLE)}],
"snaps": [Vector3(-1, 0, 0), Vector3(0, 0, 0), Vector3(1, 0, 0),
Vector3(-1, 1, -2), Vector3(0, 1, -2), Vector3(1, 1, -2)],
"center": Vector3(0, 0.5, -1),
}
return k
static func _box(c: Vector3, s: Vector3) -> Dictionary:
return {"type": "box", "center": c, "size": s, "basis": Basis()}
## A prism: a 2D outline in the XY plane, extruded `t` along Z (centred).
static func _prism(outline: Array, t: float) -> Dictionary:
return {"type": "prism", "outline": outline, "thickness": t}
## Builds a Shape3D for one shape entry, shrunk by `margin` on every side
## (margin > 0 is for overlap tests, so touching neighbours don't count).
## Returns [shape, local transform].
static func make_shape(s: Dictionary, margin: float = 0.0) -> Array:
if s.type == "box":
var b := BoxShape3D.new()
var size: Vector3 = s.size
b.size = Vector3(maxf(size.x - margin * 2.0, 0.02), maxf(size.y - margin * 2.0, 0.02), maxf(size.z - margin * 2.0, 0.02))
return [b, Transform3D(s.basis, s.center)]
var outline: Array = s.outline
var c := Vector2.ZERO
for p in outline:
c += p
c /= outline.size()
var pts := PackedVector3Array()
var hz := maxf(float(s.thickness) * 0.5 - margin, 0.01)
for p: Vector2 in outline:
var q := p
if margin > 0.0:
q = c + (p - c) * (1.0 - margin * 2.0 / maxf((p - c).length(), 0.01))
pts.append(Vector3(q.x, q.y, -hz))
pts.append(Vector3(q.x, q.y, hz))
var cv := ConvexPolygonShape3D.new()
cv.points = pts
return [cv, Transform3D()]
static func is_build(def: Dictionary) -> bool:
return def.has("place") and def.place.has("kind")
func _make_collision(_mi: MeshInstance3D) -> void:
kind = def.place.kind
collision_layer = 1 | BUILD_LAYER
add_to_group("build")
for s in kinds()[kind].shapes:
var made := make_shape(s)
var cs := CollisionShape3D.new()
cs.shape = made[0]
cs.transform = made[1]
add_child(cs)
func snap_points() -> Array:
var out := []
for p in kinds()[kind].snaps:
out.append(global_transform * p)
return out
func display_name() -> String:
return ItemDB.item_name(item_id)
func interact_prompt(_player: Node) -> String:
if support < 0.999:
return "%s (support %d%%)" % [display_name(), roundi(support * 100.0)]
return display_name()

View File

@@ -0,0 +1,368 @@
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

View File

@@ -15,6 +15,8 @@ var _fx: Array[CPUParticles3D] = []
var _t := 0.0
var _noise := FastNoiseLite.new()
var _lit := true
var _roofed_cache := -1.0 ## -1 unknown, 0 open sky, 1 under a roof
var _roof_timer := 0.0
func _build() -> void:
add_to_group("station:campfire")
@@ -64,19 +66,44 @@ func interact_prompt(player: Node) -> String:
var has_fuel: bool = player.inventory.count("wood") + player.inventory.count("stick") > 0
if _lit:
var left := "%d:%02d" % [int(fuel) / 60, int(fuel) % 60]
return "Campfire (%s left)%s" % [left, " [E] Add fuel" if has_fuel else " (add wood or sticks)"]
var hiss := ", hissing in the rain" if burn_rate() > 1.2 else ""
return "Campfire (%s left%s)%s" % [left, hiss, " [E] Add fuel" if has_fuel else " (add wood or sticks)"]
return "Campfire (out)%s" % (" [E] Add fuel to light" if has_fuel else " (needs wood or sticks)")
func interact(player: Node) -> void:
Actions.add_fuel(player, self)
func _process(delta: float) -> void:
## How much faster fuel burns right now: rain on an uncovered fire makes it
## hiss and eat through its wood (up to 4x in a storm). A roof stops that.
func burn_rate() -> float:
var w: Node = get_parent().get_parent() if get_parent() != null else null
if w == null or w.get("weather") == null:
return 1.0
var rain: float = w.weather.rain_at(global_position)
if rain <= 0.05:
_roofed_cache = -1.0
return 1.0
if _roofed_cache < 0.0:
_roofed_cache = 1.0 if w.build.roofed_at(global_position) else 0.0
return 1.0 if _roofed_cache > 0.5 else 1.0 + 3.0 * rain
func burn(seconds: float) -> void:
if not _lit:
return
fuel -= delta
fuel -= seconds * burn_rate()
if fuel <= 0.0:
fuel = 0.0
_set_lit(false)
func _process(delta: float) -> void:
if not _lit:
return
_roof_timer -= delta
if _roof_timer <= 0.0:
_roof_timer = 2.0
_roofed_cache = -1.0 # re-check the roof now and then (you may build one)
burn(delta)
if not _lit:
return
_t += delta
var f := _noise.get_noise_1d(_t * 10.0)

View File

@@ -4,6 +4,12 @@ extends "res://scripts/building/piece.gd"
const SLOTS := 16
var inventory := Inventory.new(SLOTS)
func dismantle_block_reason() -> String:
for s in inventory.slots:
if s != null:
return "Empty the chest first"
return ""
func interact_prompt(_player: Node) -> String:
var used := 0
for s in inventory.slots:

61
scripts/building/door.gd Normal file
View File

@@ -0,0 +1,61 @@
extends "res://scripts/building/build_piece.gd"
## Wooden door: a wall-sized frame (snaps like a wall) with a hinged panel.
## E opens or closes it. The panel is an AnimatableBody3D so it pushes the
## player out of the way rather than trapping them.
const OPEN_ANGLE := 1.75 ## radians: swings toward -Z, away from where you stood to place it
var open := false
var _hinge: Node3D
var _panel: AnimatableBody3D
var _tween: Tween
func _visual_mesh() -> Mesh:
return ItemDB.mesh_for("wood_door_frame")
func _build() -> void:
_hinge = Node3D.new()
_hinge.name = "Hinge"
_hinge.position = Vector3(-0.54, 0.0, 0.0)
add_child(_hinge)
_panel = AnimatableBody3D.new()
_panel.name = "Panel"
# Follow the hinge directly (sync_to_physics would only track the body's
# own motion, not its parent rotating).
_panel.sync_to_physics = false
_panel.collision_layer = 1 | BUILD_LAYER
_panel.set_meta("owner_piece", self)
_hinge.add_child(_panel)
var mi := MeshInstance3D.new()
mi.mesh = ItemDB.mesh_for("wood_door_panel")
mi.material_override = ItemDB.item_material()
_panel.add_child(mi)
var cs := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = Vector3(1.06, 1.86, 0.08)
cs.shape = box
cs.position = Vector3(0.54, 0.95, 0.0)
_panel.add_child(cs)
func interact_prompt(_player: Node) -> String:
return "Door [E] %s" % ("Close" if open else "Open")
func interact(player: Node) -> void:
Actions.toggle_door(player, self)
func set_open(on: bool, animate: bool = true) -> void:
open = on
var target := OPEN_ANGLE if on else 0.0
if _tween != null:
_tween.kill()
if animate and is_inside_tree():
_tween = create_tween()
_tween.tween_property(_hinge, "rotation:y", target, 0.45).set_trans(Tween.TRANS_SINE)
else:
_hinge.rotation.y = target
func save_extra() -> Dictionary:
return {"open": open}
func load_extra(d: Dictionary) -> void:
set_open(bool(d.get("open", false)), false)

View File

@@ -1,7 +1,8 @@
extends StaticBody3D
## Base for placed pieces (campfire, workbench, chest, later walls/floors).
## Builds the visual from the item's procedural mesh and a box collider from
## the item's footprint. Subclasses add behaviour.
## Base for placed pieces (campfire, workbench, chest, bed, building pieces).
## Builds the visual from the item's procedural mesh and, by default, a box
## collider from the mesh bounds. Subclasses add behaviour or their own
## colliders (_make_collision).
var item_id := ""
var def: Dictionary = {}
@@ -9,11 +10,19 @@ var def: Dictionary = {}
func setup(d: Dictionary) -> void:
def = d
item_id = d.id
add_to_group("piece")
var mi := MeshInstance3D.new()
mi.name = "Mesh"
mi.mesh = ItemDB.mesh_for(item_id)
mi.mesh = _visual_mesh()
mi.material_override = ItemDB.item_material()
add_child(mi)
_make_collision(mi)
_build()
func _visual_mesh() -> Mesh:
return ItemDB.mesh_for(item_id)
func _make_collision(mi: MeshInstance3D) -> void:
var aabb := mi.mesh.get_aabb()
var cs := CollisionShape3D.new()
var box := BoxShape3D.new()
@@ -21,7 +30,6 @@ func setup(d: Dictionary) -> void:
cs.shape = box
cs.position = aabb.get_center()
add_child(cs)
_build()
func _build() -> void:
pass
@@ -34,3 +42,7 @@ func interact_prompt(_player: Node) -> String:
func interact(_player: Node) -> void:
pass
## Whether X can take this piece back into your inventory (and why not).
func dismantle_block_reason() -> String:
return ""