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 ""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)"],
|
||||
]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
191
scripts/world/weather.gd
Normal 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
217
scripts/world/weather_fx.gd
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user