423 lines
17 KiB
GDScript
423 lines
17 KiB
GDScript
extends Node
|
|
## Building rules: snap placement, validity, structural support and shelter.
|
|
##
|
|
## Snapping (Valheim-style): for a ghost near existing pieces, every pairing of
|
|
## one of the ghost's snap points with a nearby piece's snap point gives a
|
|
## candidate transform (rotation locked to that piece's 90-degree grid). The
|
|
## best-scoring candidate that passes the validity check wins. Away from other
|
|
## pieces you can place freely on the ground.
|
|
##
|
|
## Support: a piece touching the ground has support 1. Every other piece takes
|
|
## the best support among the pieces it shares a snap point with, minus a loss
|
|
## (small going up or down, larger sideways). Below MIN_SUPPORT a piece can't
|
|
## be placed, and if dismantling a piece leaves others below it they collapse.
|
|
## So you can build a hut, a second floor or a short overhang, but not a
|
|
## bridge hanging in mid-air.
|
|
##
|
|
## Shelter: a ray up hits a roof (build layer), and most of 8 sideways rays hit
|
|
## walls. Drives the Wet, Sheltered and Rested rules and campfire rain.
|
|
|
|
const BuildPiece = preload("res://scripts/building/build_piece.gd")
|
|
|
|
const SNAP_EPS := 0.08 ## snap points closer than this are connected
|
|
const SNAP_RADIUS := 1.3 ## how far from the aim point a snap point may be
|
|
const MIN_SUPPORT := 0.3
|
|
const VERTICAL_LOSS := 0.1
|
|
const HORIZONTAL_LOSS := 0.25
|
|
const GROUND_EPS := 0.3 ## a snap point this close to the ground touches it
|
|
## Overlap checks shrink shapes by this much. Must exceed half a wall's
|
|
## thickness: a floor's edge sits on a wall's centre line by design.
|
|
const OVERLAP_MARGIN := 0.11
|
|
const CELL := 0.5
|
|
|
|
var world: Node3D
|
|
var terrain: Node3D
|
|
var pieces_root: Node3D
|
|
|
|
var _hash: Dictionary = {} ## Vector3i -> Array of [piece, point]
|
|
var last_blocker: Object ## what the last failed overlap check hit (debugging)
|
|
var _dirty := true
|
|
|
|
func setup(w: Node3D) -> void:
|
|
world = w
|
|
terrain = w.terrain
|
|
pieces_root = w._pieces
|
|
|
|
func mark_dirty() -> void:
|
|
_dirty = true
|
|
|
|
func build_pieces() -> Array:
|
|
var out := []
|
|
for p in pieces_root.get_children():
|
|
if p.is_in_group("build") and not p.is_queued_for_deletion():
|
|
out.append(p)
|
|
return out
|
|
|
|
static func _cell(p: Vector3) -> Vector3i:
|
|
return Vector3i(floori(p.x / CELL), floori(p.y / CELL), floori(p.z / CELL))
|
|
|
|
func _ensure_hash() -> void:
|
|
if not _dirty:
|
|
return
|
|
_dirty = false
|
|
_hash.clear()
|
|
for piece in build_pieces():
|
|
for pt in piece.snap_points():
|
|
var k := _cell(pt)
|
|
if not _hash.has(k):
|
|
_hash[k] = []
|
|
_hash[k].append([piece, pt])
|
|
|
|
## Pieces with a snap point within SNAP_EPS of any of `points`.
|
|
func _touching(points: Array, exclude: Node = null) -> Array:
|
|
_ensure_hash()
|
|
var out := []
|
|
for pt: Vector3 in points:
|
|
var c := _cell(pt)
|
|
for dx in range(-1, 2):
|
|
for dy in range(-1, 2):
|
|
for dz in range(-1, 2):
|
|
for e in _hash.get(c + Vector3i(dx, dy, dz), []):
|
|
if e[0] != exclude and not out.has(e[0]) and (e[1] as Vector3).distance_to(pt) < SNAP_EPS:
|
|
out.append(e[0])
|
|
return out
|
|
|
|
static func _world_snaps(kind: String, xf: Transform3D) -> Array:
|
|
var out := []
|
|
for p in BuildPiece.kinds()[kind].snaps:
|
|
out.append(xf * p)
|
|
return out
|
|
|
|
func _grounded(points: Array) -> bool:
|
|
for pt: Vector3 in points:
|
|
if pt.y <= terrain.sample_height(pt.x, pt.z) + GROUND_EPS:
|
|
return true
|
|
return false
|
|
|
|
const STONE_VERTICAL_LOSS := 0.05 ## stone stacks tall (P2)
|
|
|
|
## Support lost passing from a piece centred at `from_center` to one of
|
|
## `material` centred at `to_center`. Through a pillar (`via_pillar`: either
|
|
## piece is one) any real step up or down counts as vertical: a pillar under a
|
|
## floor's corner carries it straight down to the ground.
|
|
static func _loss(from_center: Vector3, to_center: Vector3, material: String = "wood", via_pillar: bool = false) -> float:
|
|
var d := to_center - from_center
|
|
var horiz := Vector2(d.x, d.z).length()
|
|
if absf(d.y) >= horiz * 0.99 or (via_pillar and absf(d.y) > 0.5):
|
|
return STONE_VERTICAL_LOSS if material == "stone" else VERTICAL_LOSS
|
|
return HORIZONTAL_LOSS
|
|
|
|
## Wood can't hold stone up (stone on wood has no support from it).
|
|
static func _carries(below: Node, material: String) -> bool:
|
|
return not (material == "stone" and below.material == "wood")
|
|
|
|
## Support a piece of `kind` (and `material`) would have at `xf`.
|
|
func support_for(kind: String, xf: Transform3D, exclude: Node = null, material: String = "wood") -> 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):
|
|
if not _carries(n, material):
|
|
continue
|
|
var nc: Vector3 = n.global_transform * BuildPiece.kinds()[n.kind].center
|
|
best = maxf(best, n.support * (1.0 - _loss(nc, center, material, kind == "pillar" or n.kind == "pillar")))
|
|
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]:
|
|
if _carries(n, p.material):
|
|
best = maxf(best, n.support * (1.0 - _loss(centers[n], centers[p], p.material, p.kind == "pillar" or n.kind == "pillar")))
|
|
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"
|
|
var mat := BuildPiece.material_of(def)
|
|
if support_for(kind, xf, exclude, mat) < MIN_SUPPORT:
|
|
if mat == "stone" and _rests_on_wood(kind, xf, exclude):
|
|
return "Stone needs stone or the ground under it (wood can't hold it)"
|
|
return "Not enough support"
|
|
return ""
|
|
|
|
func _rests_on_wood(kind: String, xf: Transform3D, exclude: Node) -> bool:
|
|
for n in _touching(_world_snaps(kind, xf), exclude):
|
|
if n.material == "wood":
|
|
return true
|
|
return false
|
|
|
|
## 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]
|
|
"pillar":
|
|
probe = [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
|
|
|
|
## Height of the sideways shelter rays: under a window's sill (1 m), so a
|
|
## room with windows still counts as walled in.
|
|
const WALL_RAY_Y := 0.6
|
|
|
|
## 0..1 fraction of 8 horizontal directions blocked by walls within 8 m.
|
|
func enclosure_at(pos: Vector3) -> float:
|
|
var from := pos + Vector3(0, WALL_RAY_Y, 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
|
|
|
|
## 0..1 fraction of 8 horizontal directions whose nearest wall (within 8 m)
|
|
## is stone. A stone room keeps you a little warmer (survival.gd).
|
|
func stone_walls_at(pos: Vector3) -> float:
|
|
var from := pos + Vector3(0, WALL_RAY_Y, 0)
|
|
var space := world.get_world_3d().direct_space_state
|
|
var hits := 0
|
|
for i in range(8):
|
|
var a := TAU * i / 8.0
|
|
var q := PhysicsRayQueryParameters3D.create(from, from + Vector3(cos(a), 0, sin(a)) * 8.0, BuildPiece.BUILD_LAYER)
|
|
var hit := space.intersect_ray(q)
|
|
if hit.is_empty():
|
|
continue
|
|
var c: Object = hit.collider
|
|
if c != null and c.has_meta("owner_piece"):
|
|
c = c.get_meta("owner_piece")
|
|
if c != null and c.get("material") == "stone":
|
|
hits += 1
|
|
return hits / 8.0
|
|
|
|
# --- 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.
|
|
## `mat` "stone" builds the floors and walls in stone (a stone doorway, a
|
|
## wooden roof on top).
|
|
## Returns {"placed": n, "failed": [...], "pieces": [...]}.
|
|
func debug_build_hut(center: Vector3, yaw: float, mat: String = "wood") -> Dictionary:
|
|
var out := {"placed": 0, "failed": [], "pieces": []}
|
|
var floor_id := mat + "_floor"
|
|
var wall_id := mat + "_wall"
|
|
var door_id := "stone_doorway" if mat == "stone" else "wood_door"
|
|
# Floors: the first sits on the ground, the rest snap to it.
|
|
var f0: Node3D = await _hut_place(out, center, yaw, floor_id, 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, floor_id, 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, wall_id, Vector3(x, top, -1.9), 0.0, f0) # north
|
|
await _hut_place(out, center, yaw, door_id if x > 0 else wall_id, Vector3(x, top, 1.9), 0.0, f0) # south
|
|
for z in [-1.0, 1.0]:
|
|
await _hut_place(out, center, yaw, wall_id, Vector3(1.9, top, z), q, f0) # east
|
|
await _hut_place(out, center, yaw, wall_id, 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
|