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

View File

@@ -19,6 +19,7 @@ func _ready() -> void:
_bind("interact", [KEY_E])
_bind("inventory", [KEY_TAB, KEY_I])
_bind("rotate_piece", [KEY_R])
_bind("dismantle", [KEY_X])
for i in range(8):
_bind("hotbar_%d" % (i + 1), [KEY_1 + i])
_bind_mouse("attack", MOUSE_BUTTON_LEFT)
@@ -147,6 +148,49 @@ func place(player: Node3D, slot: int, xf: Transform3D, world: Node3D) -> bool:
world.spawn_piece(def, xf)
return true
## Takes a placed piece back into your inventory. Building pieces that lose
## their support collapse, and come back to you too.
func dismantle(player: Node3D, piece: Node3D) -> bool:
if not is_instance_valid(piece) or not piece.is_in_group("piece") or piece.is_queued_for_deletion():
return false
if player.global_position.distance_to(piece.global_position) > 8.0:
return false
if piece.item_id == "grave":
notify.emit("Take your things out and it will crumble")
return false
var why: String = piece.dismantle_block_reason()
if not why.is_empty():
notify.emit(why)
return false
if player.inventory.space_for(piece.item_id) < 1:
notify.emit("Inventory full")
return false
_give(player, piece.item_id, 1)
piece.get_parent().get_parent().remove_piece(piece, player)
return true
func toggle_door(player: Node3D, door: Node3D) -> bool:
if not is_instance_valid(door) or not _in_reach(player, door, 1.0):
return false
door.set_open(not door.open)
return true
## E on a bed: claim it as your spawn point, or (if it's already yours) sleep.
func use_bed(player: Node3D, bed: Node3D) -> bool:
if not is_instance_valid(bed) or not _in_reach(player, bed, 1.0):
return false
var world: Node = bed.get_parent().get_parent()
if not bed.is_spawn():
world.set_bed_spawn(bed)
notify.emit("Spawn point set")
return true
var why: String = world.sleep_block_reason(bed)
if not why.is_empty():
notify.emit(why)
return false
world.sleep(player)
return true
func forage(player: Node3D, bush: Node3D) -> bool:
if not is_instance_valid(bush) or not _in_reach(player, bush) or not bush.ripe():
return false

View File

@@ -49,6 +49,14 @@ static func build(key: String) -> ArrayMesh:
"roasted_mushroom": m = _mushroom(MUSH_ROAST)
"torch": m = _torch()
"grave": m = _grave()
"wood_floor": m = _floor()
"wood_wall": m = _wall()
"wood_door": m = _door(true)
"wood_door_frame": m = _door(false)
"wood_door_panel": m = _door_panel()
"wood_roof": m = _roof()
"wood_gable": m = _gable()
"bed": m = _bed()
"berry_bush": m = _berry_bush(true)
"berry_bush_bare": m = _berry_bush(false)
_:
@@ -245,3 +253,123 @@ static func _berry_bush(ripe: bool) -> ArrayMesh:
var d := Vector3(r.randf_range(-1, 1), r.randf_range(0.2, 1), r.randf_range(-1, 1)).normalized() * 0.4
P._blob(st, c + d, Vector3.ONE * 0.045, n, 0.1, BERRY, r, 0)
return P._commit(st)
# --- building pieces ----------------------------------------------------------
# Dimensions must match the colliders in scripts/building/build_piece.gd.
const BEAM := Color(0.40, 0.27, 0.15)
const SHINGLE := Color(0.46, 0.33, 0.20)
const THATCH := Color(0.62, 0.52, 0.30)
const FUR := Color(0.45, 0.30, 0.20)
const LINEN := Color(0.80, 0.76, 0.64)
## Per-board tint so planks don't read as one flat sheet.
static func _board(r: RandomNumberGenerator, base: Color = PLANK) -> Color:
return P._mul(base, r.randf_range(0.82, 1.08))
## 2 x 2 m, top surface at y = 0, 0.2 m deep.
static func _floor() -> ArrayMesh:
var st := P._begin()
var r := _rng(71)
for i in range(5):
_box(st, Vector3(-0.8 + 0.4 * i, -0.05, 0), Vector3(0.385, 0.1, 2.0), _board(r))
for z in [-0.85, 0.0, 0.85]:
_box(st, Vector3(0, -0.15, z), Vector3(2.0, 0.1, 0.22), BEAM)
return P._commit(st)
## Horizontal boards between two posts, 2 x 2 m, 0.2 m thick, bottom centre at origin.
static func _wall_body(st: SurfaceTool, r: RandomNumberGenerator, x0: float, x1: float, y0: float, y1: float) -> void:
var rows := maxi(roundi((y1 - y0) / 0.31), 1)
var h := (y1 - y0) / rows
for i in range(rows):
var y := y0 + h * (i + 0.5)
_box(st, Vector3((x0 + x1) * 0.5, y, 0), Vector3(x1 - x0, h - 0.015, 0.12), _board(r))
static func _wall() -> ArrayMesh:
var st := P._begin()
var r := _rng(73)
_wall_body(st, r, -0.84, 0.84, 0.1, 1.9)
for x in [-0.92, 0.92]:
_box(st, Vector3(x, 1.0, 0), Vector3(0.16, 2.0, 0.2), BEAM)
for y in [0.06, 1.94]:
_box(st, Vector3(0, y, 0), Vector3(1.68, 0.12, 0.2), BEAM)
return P._commit(st)
## Door frame (1.1 x 1.9 m opening); `with_panel` adds the closed door, for the
## inventory icon and the placement ghost.
static func _door(with_panel: bool) -> ArrayMesh:
var st := P._begin()
var r := _rng(79)
for side in [-1.0, 1.0]:
_wall_body(st, r, side * 0.55 if side > 0 else -0.84, 0.84 if side > 0 else -0.55, 0.1, 1.9)
_box(st, Vector3(side * 0.92, 1.0, 0), Vector3(0.16, 2.0, 0.2), BEAM)
_box(st, Vector3(side * 0.6, 0.95, 0), Vector3(0.1, 1.9, 0.2), BEAM) # jamb
_box(st, Vector3(0, 1.95, 0), Vector3(1.68, 0.1, 0.2), BEAM)
_box(st, Vector3(-0.7, 0.06, 0), Vector3(0.28, 0.12, 0.2), BEAM)
_box(st, Vector3(0.7, 0.06, 0), Vector3(0.28, 0.12, 0.2), BEAM)
if with_panel:
_door_panel_into(st, Vector3(-0.54, 0, 0))
return P._commit(st)
## The door leaf, hinge at the origin, spanning +X.
static func _door_panel() -> ArrayMesh:
var st := P._begin()
_door_panel_into(st, Vector3.ZERO)
return P._commit(st)
static func _door_panel_into(st: SurfaceTool, o: Vector3) -> void:
var r := _rng(83)
for i in range(4):
_box(st, o + Vector3(0.135 + 0.27 * i, 0.95, 0), Vector3(0.26, 1.86, 0.07), _board(r))
for y in [0.35, 1.55]:
_box(st, o + Vector3(0.54, y, 0.05), Vector3(0.96, 0.12, 0.04), BEAM)
_box(st, o + Vector3(0.54, 0.95, 0.05), Vector3(0.1, 1.2, 0.04), BEAM, Basis(Vector3.BACK, 0.62))
_box(st, o + Vector3(0.95, 1.0, 0.07), Vector3(0.05, 0.14, 0.05), IRON)
_box(st, o + Vector3(0.95, 1.0, -0.07), Vector3(0.05, 0.14, 0.05), IRON)
## Sloped roof: low edge centre at the origin, rising 1 m over 2 m toward -Z,
## with a short eave hanging past the low edge.
static func _roof() -> ArrayMesh:
var st := P._begin()
var r := _rng(89)
var a := 0.4636476
var b := Basis(Vector3.RIGHT, a)
var n := Vector3(0, cos(a), sin(a))
var along := Vector3(0, sin(a), -cos(a)) # up the slope
var length := sqrt(5.0)
# Boarded deck, then overlapping rows of shingles on top.
_box(st, along * (length * 0.5 - 0.15) + n * 0.03, Vector3(2.0, 0.06, length + 0.3), _board(r, BEAM), b)
var rows := 6
for i in range(rows):
var s := -0.3 + (length + 0.3) * (i + 0.5) / rows
var tilt := Basis(Vector3.RIGHT, a + 0.06)
_box(st, along * s + n * 0.085, Vector3(2.04, 0.05, (length + 0.3) / rows + 0.1), _board(r, SHINGLE), tilt)
return P._commit(st)
## Triangular gable end: 0 m tall at -X, 1 m tall at +X.
static func _gable() -> ArrayMesh:
var st := P._begin()
var r := _rng(97)
for i in range(3):
var y0 := i / 3.0
var x0 := 2.0 * y0 - 1.0 + 0.1
_box(st, Vector3((x0 + 0.92) * 0.5, y0 + 1.0 / 6.0, 0), Vector3(0.92 - x0, 1.0 / 3.0 - 0.015, 0.12), _board(r))
_box(st, Vector3(0.92, 0.5, 0), Vector3(0.16, 1.0, 0.2), BEAM)
_box(st, Vector3(0, 0.06, 0), Vector3(2.0, 0.12, 0.2), BEAM)
return P._commit(st)
## Bed: 1 x 2.1 m frame with a straw mattress, pillow and fur blanket.
static func _bed() -> ArrayMesh:
var st := P._begin()
var r := _rng(101)
for sx in [-0.45, 0.45]:
for sz in [-0.98, 0.98]:
_box(st, Vector3(sx, 0.22, sz), Vector3(0.1, 0.44, 0.1), BEAM)
_box(st, Vector3(sx, 0.28, 0), Vector3(0.08, 0.14, 1.9), _board(r))
for sz in [-0.98, 0.98]:
_box(st, Vector3(0, 0.28, sz), Vector3(0.84, 0.14, 0.08), _board(r))
_box(st, Vector3(0, 0.62, -1.0), Vector3(0.96, 0.4, 0.08), _board(r)) # headboard
_box(st, Vector3(0, 0.38, 0), Vector3(0.86, 0.14, 1.9), THATCH)
_box(st, Vector3(0, 0.5, -0.72), Vector3(0.6, 0.1, 0.32), LINEN)
_box(st, Vector3(0, 0.47, 0.25), Vector3(0.9, 0.08, 1.3), FUR)
return P._commit(st)

View File

@@ -11,6 +11,7 @@ extends Node3D
signal prompt_changed(text: String)
const Campfire = preload("res://scripts/building/campfire.gd")
const BuildPiece = preload("res://scripts/building/build_piece.gd")
const REACH := 3.2
const PLACE_REACH := 6.0
const SWING_TIME := 0.5
@@ -26,6 +27,7 @@ var _ghost_bad_mat: StandardMaterial3D
var _ghost_yaw := 0.0
var _place_xf := Transform3D()
var _place_ok := false
var _place_reason := ""
var _prompt := ""
var _held_shown := "~"
var _torch_light: OmniLight3D
@@ -123,6 +125,8 @@ func _target() -> Object:
if not _ray.is_colliding():
return null
var c := _ray.get_collider()
if c != null and c.has_meta("owner_piece"):
c = c.get_meta("owner_piece") # a door's moving panel
if c == null or not c.has_method("interact_prompt"):
return null
if c.get("depleted") == true:
@@ -138,7 +142,8 @@ func _physics_process(delta: float) -> void:
var def := _held_def()
if def.has("place"):
_update_ghost(def)
_set_prompt("[LMB] Place %s [R] Rotate" % def.name)
var why := "" if _place_ok or _place_reason.is_empty() else " (%s)" % _place_reason
_set_prompt("[LMB] Place %s [R] Rotate%s" % [def.name, why])
return
_ghost.visible = false
if _torch_light.visible:
@@ -146,7 +151,10 @@ func _physics_process(delta: float) -> void:
_torch_light.light_energy = float(def.get("light", {}).get("energy", 1.5)) * (0.85 + 0.15 * sin(_t * 17.0) * sin(_t * 5.3))
var t := _target()
if t != null:
_set_prompt(t.interact_prompt(player))
var p: String = t.interact_prompt(player)
if t.is_in_group("piece") and t.item_id != "grave":
p += " [X] Dismantle"
_set_prompt(p)
elif def.has("food"):
_set_prompt("[RMB] Eat %s" % def.name)
else:
@@ -163,8 +171,16 @@ func _update_ghost(def: Dictionary) -> void:
_place_ok = false
return
var yaw := player.rotation.y + _ghost_yaw # piece's front (+Z) faces the player
_place_xf = Transform3D(Basis(Vector3.UP, yaw), hit.position)
_place_ok = player.get_parent().placement_valid(def, _place_xf)
if BuildPiece.is_build(def):
# Building pieces snap to what's already built (build_system.gd).
var res: Dictionary = player.get_parent().build.placement(def, hit, yaw)
_place_xf = res.xf
_place_ok = res.ok
_place_reason = res.reason
else:
_place_xf = Transform3D(Basis(Vector3.UP, yaw), hit.position)
_place_ok = player.get_parent().placement_valid(def, _place_xf)
_place_reason = "" if _place_ok else "blocked or too steep"
_ghost.global_transform = _place_xf
_ghost.material_override = _ghost_ok_mat if _place_ok else _ghost_bad_mat
_ghost.visible = true
@@ -185,7 +201,12 @@ func _unhandled_input(event: InputEvent) -> void:
if _held_def().has("food"):
Actions.eat(player, player.selected)
elif event.is_action_pressed("rotate_piece"):
_ghost_yaw += deg_to_rad(22.5)
# Building pieces turn in quarter turns (they snap to a grid).
_ghost_yaw += deg_to_rad(90.0 if BuildPiece.is_build(_held_def()) else 22.5)
elif event.is_action_pressed("dismantle"):
var t := _target()
if t != null and t.is_in_group("piece"):
Actions.dismantle(player, t)
elif event.is_action_pressed("interact"):
var t := _target()
if t != null and t.has_method("interact"):

View File

@@ -19,6 +19,8 @@ const HUNGER_DRAIN := 100.0 / 1500.0 ## full to empty in ~1.25 in-game days
const STAMINA_REGEN := 24.0
const STAMINA_DELAY := 1.0
const WET_SECONDS := 90.0
const REST_AFTER := 20.0 ## seconds sheltered by a fire before you're Rested
const REST_SECONDS := 300.0 ## Rested: stamina regenerates 50% faster
var health := MAX
var stamina := MAX
@@ -28,6 +30,11 @@ var wet := 0.0 ## seconds of "Wet" left
var heat := 0.0 ## 0..1 from nearby fires etc (last step)
var statuses: Array[String] = []
var dead := false
var roofed := false ## a roof overhead (rain can't reach you)
var sheltered := false ## roof and walls (the weather barely reaches you)
var rested := 0.0 ## seconds of Rested left
var _shelter_left := 0.0
var _rest_build := 0.0
var player: CharacterBody3D
var world: Node3D
@@ -42,7 +49,23 @@ func step(dt: float) -> void:
return
var p := player.global_position
heat = world.heat_at(p)
var target: float = world.air_warmth(p) + heat * 70.0
_shelter_left -= dt
if _shelter_left <= 0.0:
_shelter_left = 0.25
roofed = world.build.roofed_at(p)
sheltered = roofed and world.build.enclosure_at(p) >= 0.74
# Rain soaks you unless there's a roof over your head.
if not roofed and world.weather.rain_at(p) > 0.25:
wet = WET_SECONDS
# Resting: a while by a fire with a roof and walls around you.
if sheltered and heat > 0.25:
_rest_build += dt
if _rest_build >= REST_AFTER:
rested = maxf(rested, REST_SECONDS)
else:
_rest_build = 0.0
rested = maxf(rested - dt, 0.0)
var target: float = world.air_warmth(p, sheltered) + heat * 70.0
if wet > 0.0:
target -= 22.0
wet = maxf(wet - dt * (3.0 if heat > 0.3 else 1.0), 0.0) # fires dry you off
@@ -55,7 +78,7 @@ func step(dt: float) -> void:
if _stamina_wait > 0.0:
_stamina_wait -= dt
else:
stamina = minf(stamina + STAMINA_REGEN * dt, MAX)
stamina = minf(stamina + STAMINA_REGEN * (1.5 if rested > 0.0 else 1.0) * dt, MAX)
var dmg := 0.0
if hunger <= 0.0:
@@ -87,6 +110,10 @@ func _update_statuses() -> void:
statuses.append("Hungry")
if wet > 0.0:
statuses.append("Wet")
if sheltered:
statuses.append("Sheltered")
if rested > 0.0:
statuses.append("Rested")
## Spends stamina for an action. Returns false (and spends nothing) if you're
## too tired. Continuous costs (sprinting) pass `partial = true`.
@@ -105,6 +132,16 @@ func eat(food: Dictionary) -> void:
func soak() -> void:
wet = WET_SECONDS
## After a night's sleep: a little hungrier, dry, warm and rested.
func after_sleep(seconds: float) -> void:
hunger = maxf(hunger - HUNGER_DRAIN * seconds * 0.3, 0.0)
wet = 0.0
warmth = maxf(warmth, 60.0)
stamina = MAX
rested = REST_SECONDS * 2.0
_update_statuses()
changed.emit()
func reset_after_death() -> void:
dead = false
health = 50.0
@@ -115,7 +152,7 @@ func reset_after_death() -> void:
changed.emit()
func to_data() -> Dictionary:
return {"health": health, "stamina": stamina, "hunger": hunger, "warmth": warmth, "wet": wet}
return {"health": health, "stamina": stamina, "hunger": hunger, "warmth": warmth, "wet": wet, "rested": rested}
func load_data(d: Dictionary) -> void:
health = float(d.get("health", health))
@@ -123,5 +160,6 @@ func load_data(d: Dictionary) -> void:
hunger = float(d.get("hunger", hunger))
warmth = float(d.get("warmth", warmth))
wet = float(d.get("wet", 0.0))
rested = float(d.get("rested", 0.0))
dead = false
changed.emit()

View File

@@ -147,7 +147,25 @@ func _process(_d: float) -> void:
_status.text = " ".join(s.statuses)
var dn = player.get_parent().day_night
if dn != null:
_clock.text = dn.clock_text()
var wx = player.get_parent().weather
_clock.text = dn.clock_text() + (" " + wx.label() if wx != null else "")
## Fades to black, runs `mid` (e.g. skipping the night), and fades back in.
func fade(mid: Callable) -> void:
var black := ColorRect.new()
black.color = Color(0, 0, 0, 0)
black.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
black.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(black)
player.ui_open = true
var tw := black.create_tween()
tw.tween_property(black, "color:a", 1.0, 0.8)
tw.tween_callback(mid)
tw.tween_interval(0.7)
tw.tween_property(black, "color:a", 0.0, 1.0)
tw.tween_callback(func():
player.ui_open = false
black.queue_free())
## Closes the inventory / menu (e.g. on death).
func close_screens() -> void:

View File

@@ -263,6 +263,10 @@ func _recipe_row(r: Dictionary) -> Control:
h.add_child(b)
return row
## Shift-click crafts up to five (handy for walls and floors).
func _craft(recipe_id: String) -> void:
Actions.craft(player, recipe_id)
var n := 5 if Input.is_key_pressed(KEY_SHIFT) else 1
for i in range(n):
if not Actions.can_craft(player, recipe_id) or not Actions.craft(player, recipe_id):
break
_refresh()

View File

@@ -9,7 +9,9 @@ const CONTROLS := [
["W A S D", "Move"], ["Shift", "Sprint"], ["Space", "Jump / swim up"],
["Mouse", "Look"], ["E", "Interact / pick up / add fuel"], ["Left click", "Use tool / place"],
["Right click (holding food)", "Eat"],
["R", "Rotate piece"], ["1 - 8, wheel", "Hotbar"], ["Tab", "Inventory & crafting"],
["R", "Rotate piece (building pieces: quarter turns)"], ["X", "Dismantle the piece you're looking at"],
["E on a bed", "Set spawn point / sleep (night, under a roof)"],
["1 - 8, wheel", "Hotbar"], ["Tab", "Inventory & crafting (shift-click Craft for 5)"],
["Shift + click", "Move stack to/from a chest"], ["Right click", "Split stack"],
["Esc", "This menu (game autosaves every 2 min)"],
]

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)