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:
18
scripts/building/bed.gd
Normal file
18
scripts/building/bed.gd
Normal 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)
|
||||
135
scripts/building/build_piece.gd
Normal file
135
scripts/building/build_piece.gd
Normal 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()
|
||||
368
scripts/building/build_system.gd
Normal file
368
scripts/building/build_system.gd
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
61
scripts/building/door.gd
Normal 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)
|
||||
@@ -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 ""
|
||||
|
||||
Reference in New Issue
Block a user