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