M1c: weather, snap building, shelter, beds and regrowth

- Seeded weather (clear/cloudy/rain/storm, snow and blizzards on cold days
  and above the snowline), lightning, synthesised rain/wind/thunder sound
- Rain/snow particles stopped by roofs; wet ground, settled snow and wind
  via global shader uniforms
- Snap-together wood floor, wall, door, roof and gable with structural
  support, collapse and X to dismantle
- Shelter detection: dry under a roof, warmer and Rested inside with a fire;
  rain burns an uncovered campfire faster
- Bed sets the spawn point and lets you sleep through the night
- Trees, boulders and ground pickups regrow; timers saved
- Grass cleared under floors; shorter HUD hint
- New test_weather_building suite (62 checks) and tests/run_all.sh, which
  also fails on script errors
This commit is contained in:
Paul Bailey
2026-11-04 10:16:41 +00:00
parent fc7eb3cfed
commit fed96ef118
36 changed files with 2421 additions and 58 deletions

View File

@@ -191,6 +191,81 @@
1.6
]
}
},
{
"id": "wood_floor",
"name": "Wood Floor",
"category": "building",
"stack": 20,
"description": "2 x 2 m. Snaps to other pieces. Sits level on the ground.",
"mesh": "wood_floor",
"place": {
"piece": "build",
"kind": "floor"
}
},
{
"id": "wood_wall",
"name": "Wood Wall",
"category": "building",
"stack": 20,
"description": "2 x 2 m. Snaps to floor edges, other walls and roofs.",
"mesh": "wood_wall",
"place": {
"piece": "build",
"kind": "wall"
}
},
{
"id": "wood_door",
"name": "Wood Door",
"category": "building",
"stack": 10,
"description": "A wall with a door in it. E opens and closes it.",
"mesh": "wood_door",
"place": {
"piece": "door",
"kind": "door"
}
},
{
"id": "wood_roof",
"name": "Wood Roof",
"category": "building",
"stack": 20,
"description": "Rises 1 m over 2 m. Two from opposite walls of a 4 m hut meet at the ridge.",
"mesh": "wood_roof",
"place": {
"piece": "build",
"kind": "roof"
}
},
{
"id": "wood_gable",
"name": "Wood Gable",
"category": "building",
"stack": 20,
"description": "Triangle that closes the end of a roof. R to flip it.",
"mesh": "wood_gable",
"place": {
"piece": "build",
"kind": "gable"
}
},
{
"id": "bed",
"name": "Bed",
"category": "placeable",
"stack": 1,
"description": "Sets your spawn point. Sleep through the night under a roof.",
"mesh": "bed",
"place": {
"piece": "bed",
"footprint": [
1.0,
2.1
]
}
}
]
}

View File

@@ -78,6 +78,62 @@
"inputs": {
"mushroom": 1
}
},
{
"id": "wood_floor",
"output": "wood_floor",
"count": 1,
"station": "workbench",
"inputs": {
"wood": 2
}
},
{
"id": "wood_wall",
"output": "wood_wall",
"count": 1,
"station": "workbench",
"inputs": {
"wood": 2
}
},
{
"id": "wood_door",
"output": "wood_door",
"count": 1,
"station": "workbench",
"inputs": {
"wood": 4
}
},
{
"id": "wood_roof",
"output": "wood_roof",
"count": 1,
"station": "workbench",
"inputs": {
"wood": 2,
"fiber": 1
}
},
{
"id": "wood_gable",
"output": "wood_gable",
"count": 1,
"station": "workbench",
"inputs": {
"wood": 1
}
},
{
"id": "bed",
"output": "bed",
"count": 1,
"station": "workbench",
"inputs": {
"wood": 8,
"fiber": 6
}
}
]
}

View File

@@ -29,8 +29,10 @@ Detailed plan in `docs/SURVIVAL_DESIGN.md` (phases M1a–M1d).
- [x] Crafting (recipe: wood → tools / building pieces) *(M1a)*
- [x] Pause menu, title screen, world slots, save/load, autosave *(M1a)*
- [x] Health / stamina / hunger / warmth, day-night cycle, fire fuel, cooking, foraging, torches, death + grave *(M1b)*
- [ ] Weather (rain, storm, snow), wet/shelter *(M1c)*
- [ ] Snap-together building: place a wall/floor, resource cost, basic support rule
- [x] Weather (rain, storm, snow), wet/shelter *(M1c)*
- [x] Snap-together building: floor/wall/door/roof/gable, resource cost, support + collapse, dismantling *(M1c)*
- [x] Bed spawn point and sleeping, resource regrowth *(M1c)*
- [ ] Creatures, hunting, weapons *(M1d)*
- **Done when:** gather → craft → build a tiny shelter, single-player.
## M2 — Multiplayer · ~2–4 months

View File

@@ -14,7 +14,14 @@ download — the M0 world is generated in code.
`/Applications`.
## Tests
Run everything (fails on any failed check or script error):
```bash
tests/run_all.sh
```
Or one at a time:
```bash
# Weather, shelter, snap building, support, doors, beds, regrowth
godot --headless --path . --script res://tests/test_weather_building.gd
# Boundary: tries to walk/jump out of the valley in 24 directions
godot --headless --path . --fixed-fps 30 --script res://tests/test_boundary.gd
# Terrain: no deep pits / floating water at the boundary, across 8 seeds

View File

@@ -55,7 +55,13 @@ Item fields: `id`, `name`, `description`, `stack`, `category`
| Animals *(M1d)* | weapon | raw meat, hide, bone |
Tool **tiers** gate harder resources later (e.g. iron ore needs a tier-2
pickaxe). Felled trees leave stumps. Resources regrow over time *(M1c)*.
pickaxe). Felled trees leave stumps.
**Regrowth** *(built in M1c, `props.gd`)*: felled trees grow back after
2400 s (2 days), mined boulders after 3600 s, picked-up sticks/stones/flint/
mushrooms after 900 s. A timer waits (retries every 2 min) while you're
standing on the spot or something is built there. Timers are saved, and
sleeping advances them.
### 2.4 Crafting & stations
Recipes list inputs, an output and an optional **station** that must be within
@@ -65,12 +71,34 @@ panel shows every recipe you know, greyed out with the missing inputs in red.
### 2.5 Building
- **Placement mode**: holding a placeable shows a ghost preview (green = valid,
red = blocked). R rotates it, left-click places it.
- M1a pieces: campfire, workbench, chest.
- *(M1c)* Snap-together pieces: wood floor, wall, door, roof, stairs, with
snap points and Valheim-style **structural support** (stability falls off
with distance from the ground; unsupported pieces collapse).
- **Shelter detection**: you count as sheltered when rays upward hit a roof and
most sideways rays hit walls. That drives "dry" and "rested" status.
- M1a pieces: campfire, workbench, chest. M1c adds a bed.
- *(built in M1c)* Snap-together wood pieces, crafted at a workbench
(shift-click Craft makes 5): floor (2 wood), wall (2), door (4), roof
(2 wood + 1 fiber), gable (1). Everything is 2 m on a side. A roof rises
1 m over 2 m, so two roofs from opposite walls of a 4 m hut meet at a ridge,
and gables close the ends. Geometry and snap points live in
`build_piece.gd`, rules in `build_system.gd`.
- **Snapping**: the ghost sits on your crosshair, then moves to the nearest
pair of snap points (its own and a nearby piece's), with rotation locked to
that piece's 90-degree grid. R turns building pieces a quarter turn. Away
from other pieces, floors sit level on the highest ground under them.
- **Support**: touching the ground = 100%. Otherwise a piece takes the best
of its neighbours minus 10% going up/down or 25% sideways. Under 30% it
can't be placed, so an overhang reaches about 4 floors.
- **Dismantling**: X on any placed piece puts it back in your inventory
(chests must be empty, graves can't be). Pieces left below 30% support
collapse and come back to you too. Loose chests/fires/beds drop to the
ground.
- **Shelter detection**: roofed = 3 of 5 rays straight up hit a building piece.
Sheltered = roofed and at least 6 of 8 sideways rays hit walls within 8 m.
Roofed keeps rain off you (and off a campfire). Sheltered cuts the weather's
chill to a quarter, adds +8 warmth, and with a fire nearby makes you Rested
after 20 s (stamina regen +50% for 5 min).
- **Bed**: E sets your spawn point (death respawns you there while it
stands). E again at night (19:30 to 05:30) sleeps until 06:00 if the bed is
sheltered: the clock, fires, regrowth and weather move on, you lose a
little food, and you wake dry, warm and Rested.
- Grass doesn't grow through floors.
### 2.6 Survival stats *(M1b)*
| Stat | Drains from | Restored by | At zero |
@@ -103,10 +131,26 @@ Built in M1b (`scripts/player/survival.gd`), current numbers:
- Day/night cycle: 20 real minutes per day by default. The sun, sky, fog and
ambient light follow time of day. Nights are dark enough that a campfire or
torch matters.
- Weather is a seeded state machine: clear → cloudy → rain → storm, plus
**snow** on cold days and always near the snowline. Each state sets the
cloud cover, fog, wind (grass/trees already read wind), particles and
sound, plus a warmth modifier. Transitions blend over about a minute.
- *(built in M1c)* Weather is a function of the world seed and the absolute
world time (`weather.gd`), so it isn't saved and every M2 client can
compute the same weather. A slow noise "front" picks the state. Measured
over 60 days on seed 1337: clear 38%, cloudy about 30%, rain/snow 33%,
storm 12%. A wet spell lasts about 6 minutes. A new world's first
10 minutes are always clear.
- About 1 day in 4 (never day 1) is a **cold day** (-8 warmth): rain falls as
snow and storms become blizzards. Above the snowline (28 m) it always
snows.
- Warmth from weather: cloudy -3, rain -6, storm -12, snow -14, blizzard -24.
Rain on you (no roof) keeps you Wet. Snow chills but doesn't soak. Rain on
an uncovered campfire burns its fuel up to 4x faster.
- Each state sets cloud cover, sun and ambient light, fog, wind and
precipitation, blending over about 50 s. Storms flash lightning, with
thunder 0.5 to 2.5 s later.
- Visuals and sound (`weather_fx.gd`, client side): GPU rain and snow
particles stopped by a heightfield collider, so it doesn't rain indoors.
Wet ground, settled snow (on terrain, rocks, grass and tree crowns) and wind
strength are global shader uniforms. Rain, wind and thunder sounds are
synthesised from filtered noise, and rain is muffled under a roof.
### 2.8 Creatures & hunting *(M1d)*
- **Rabbit**: skittish, fast, gives a little meat.
@@ -137,7 +181,9 @@ Built in M1b (`scripts/player/survival.gd`), current numbers:
inventory, hotbar slot; placed pieces and chest contents; tree and boulder
damage, stumps, mined rocks; collected pickups. Everything else regenerates
from the seed. Since M1b it also stores stats, time of day, bush regrowth
timers, campfire fuel and graves. *(Later: weather.)*
timers, campfire fuel and graves. Since M1c: building pieces (door open or
shut), the bed spawn point and regrowth timers. Weather isn't saved: it
follows from the seed and the time.
### 2.11 Death *(built in M1b)*
- On death you respawn at your bed (or the island spawn) and leave a **grave
@@ -163,7 +209,7 @@ Built in M1b (`scripts/player/survival.gd`), current numbers:
|---|---|---|
| **M1a** *(done)* | items/recipes data, inventory + hotbar, pickups, chop/mine with tools, crafting UI, campfire / workbench / chest placement, chest storage, pause menu, **title screen + world slots + save/load + autosave** (pulled forward from M1c) | you craft an axe, fell a tree, build a fire, stash wood in a chest, quit and continue |
| **M1b** *(done)* | hunger/warmth/stamina/health HUD, day/night cycle with moon and stars, campfire warmth + fuel, cooking, bushes (fiber, berries), mushrooms, torches, **death + grave bag** (pulled forward from M1c) | you survive your first night by the fire |
| **M1c** | weather (rain, storm, snow) + wet/shelter, snap building (floor/wall/roof/door) with support, bed spawn point, resource regrowth | you build a hut and wait out a storm |
| **M1c** *(done)* | weather (rain, storm, snow, blizzard, lightning, sound) + wet/shelter/rested, snap building (floor/wall/door/roof/gable) with support, dismantling and collapse, bed spawn point + sleeping, resource regrowth | you build a hut and wait out a storm |
| **M1d** | rabbit/deer/boar AI, club/spear/bow, hides, cooked food buffs | you hunt a deer and eat its meat |
## 5. Open decisions (current defaults in bold)

View File

@@ -64,6 +64,21 @@ jump={
common/physics_ticks_per_second=30
[shader_globals]
weather_wet={
"type": "float",
"value": 0.0
}
weather_snow={
"type": "float",
"value": 0.0
}
weather_wind={
"type": "float",
"value": 1.0
}
[rendering]
lights_and_shadows/directional_shadow/size=8192

View File

@@ -43,5 +43,5 @@ offset_left = 16.0
offset_top = 12.0
offset_right = 600.0
offset_bottom = 60.0
text = "WASD move · Shift sprint · Space jump · E interact · LMB use/place · RMB eat · R rotate · 1-8 hotbar · Tab inventory & crafting · ESC menu"
text = "E interact · LMB use/place · RMB eat · R rotate · X dismantle · Tab inventory & crafting · Esc menu & controls"
label_settings = SubResource("Label_1")

18
scripts/building/bed.gd Normal file
View File

@@ -0,0 +1,18 @@
extends "res://scripts/building/piece.gd"
## Bed: E sets your spawn point here. Once it's yours, E at night sleeps until
## morning, if the bed is under a roof with walls around it.
func display_name() -> String:
return "Bed"
func is_spawn() -> bool:
var w := get_parent().get_parent()
return w.get("bed_spawn") != null and w.has_bed_spawn and (w.bed_spawn as Vector3).distance_to(global_position) < 0.3
func interact_prompt(_player: Node) -> String:
if not is_spawn():
return "Bed [E] Set spawn point"
return "Bed (your spawn point) [E] Sleep"
func interact(player: Node) -> void:
Actions.use_bed(player, self)

View File

@@ -0,0 +1,135 @@
extends "res://scripts/building/piece.gd"
## A snap-together building piece: wood floor, wall, roof, gable or door frame.
##
## Geometry lives here (not in items.json) because it has to match the meshes
## in item_meshes.gd exactly:
## - `shapes`: the colliders, also used (shrunk slightly) for overlap checks.
## - `snaps`: snap points in local space. Two pieces are connected when a snap
## point of one coincides with a snap point of the other; build_system.gd
## uses that for snapping and for structural support.
## - `center`: rough centre of mass, used to tell vertical from sideways
## support.
##
## All pieces are 2 m on a side so they line up on a 2 m grid.
## Origins: floor = centre of the top surface; wall, door and gable = bottom
## centre; roof = centre of the low edge, rising 1 m over 2 m toward -Z
## (about 27 degrees), so two roofs from opposite walls of a 4 m hut meet at a
## ridge.
const BUILD_LAYER := 4 ## physics layer 3: shelter + regrowth queries
const ROOF_ANGLE := 0.4636476 ## atan(1 / 2)
const ROOF_T := 0.12 ## roof panel thickness
static var _kinds := {}
var kind := "wall"
var support := 1.0 ## 0..1, set by build_system.gd
var grounded := false
static func kinds() -> Dictionary:
if _kinds.is_empty():
_kinds = _define()
return _kinds
static func _define() -> Dictionary:
var k := {}
var wall_snaps := [
Vector3(-1, 0, 0), Vector3(0, 0, 0), Vector3(1, 0, 0),
Vector3(-1, 2, 0), Vector3(0, 2, 0), Vector3(1, 2, 0)]
k["floor"] = {
"shapes": [_box(Vector3(0, -0.1, 0), Vector3(2, 0.2, 2))],
"snaps": [Vector3(-1, 0, -1), Vector3(0, 0, -1), Vector3(1, 0, -1), Vector3(-1, 0, 0), Vector3(1, 0, 0),
Vector3(-1, 0, 1), Vector3(0, 0, 1), Vector3(1, 0, 1)],
"center": Vector3(0, -0.1, 0),
}
k["wall"] = {
"shapes": [_box(Vector3(0, 1, 0), Vector3(2, 2, 0.2))],
"snaps": wall_snaps,
"center": Vector3(0, 1, 0),
}
# Door frame: 1.1 m x 1.9 m opening (the player is 0.8 m x 1.8 m).
k["door"] = {
"shapes": [_box(Vector3(-0.775, 1, 0), Vector3(0.45, 2, 0.2)), _box(Vector3(0.775, 1, 0), Vector3(0.45, 2, 0.2)),
_box(Vector3(0, 1.95, 0), Vector3(1.1, 0.1, 0.2))],
"snaps": wall_snaps,
"center": Vector3(0, 1, 0),
}
# Gable: right triangle, 0 m tall at -X, 1 m at +X (fills the end of a
# roof; rotate 180 degrees for the other half).
k["gable"] = {
"shapes": [_prism([Vector2(-1, 0), Vector2(1, 0), Vector2(1, 1)], 0.2)],
"snaps": [Vector3(-1, 0, 0), Vector3(0, 0, 0), Vector3(1, 0, 0), Vector3(1, 1, 0)],
"center": Vector3(0.33, 0.33, 0),
}
var n := Vector3(0, cos(ROOF_ANGLE), sin(ROOF_ANGLE)) # panel normal (up and out)
# (Ends exactly at the ridge. The seam where two roofs meet is handled by
# build_system.roofed_at sampling several rays.)
k["roof"] = {
"shapes": [{"type": "box", "center": Vector3(0, 0.5, -1) + n * ROOF_T * 0.5,
"size": Vector3(2, ROOF_T, sqrt(5.0)), "basis": Basis(Vector3.RIGHT, ROOF_ANGLE)}],
"snaps": [Vector3(-1, 0, 0), Vector3(0, 0, 0), Vector3(1, 0, 0),
Vector3(-1, 1, -2), Vector3(0, 1, -2), Vector3(1, 1, -2)],
"center": Vector3(0, 0.5, -1),
}
return k
static func _box(c: Vector3, s: Vector3) -> Dictionary:
return {"type": "box", "center": c, "size": s, "basis": Basis()}
## A prism: a 2D outline in the XY plane, extruded `t` along Z (centred).
static func _prism(outline: Array, t: float) -> Dictionary:
return {"type": "prism", "outline": outline, "thickness": t}
## Builds a Shape3D for one shape entry, shrunk by `margin` on every side
## (margin > 0 is for overlap tests, so touching neighbours don't count).
## Returns [shape, local transform].
static func make_shape(s: Dictionary, margin: float = 0.0) -> Array:
if s.type == "box":
var b := BoxShape3D.new()
var size: Vector3 = s.size
b.size = Vector3(maxf(size.x - margin * 2.0, 0.02), maxf(size.y - margin * 2.0, 0.02), maxf(size.z - margin * 2.0, 0.02))
return [b, Transform3D(s.basis, s.center)]
var outline: Array = s.outline
var c := Vector2.ZERO
for p in outline:
c += p
c /= outline.size()
var pts := PackedVector3Array()
var hz := maxf(float(s.thickness) * 0.5 - margin, 0.01)
for p: Vector2 in outline:
var q := p
if margin > 0.0:
q = c + (p - c) * (1.0 - margin * 2.0 / maxf((p - c).length(), 0.01))
pts.append(Vector3(q.x, q.y, -hz))
pts.append(Vector3(q.x, q.y, hz))
var cv := ConvexPolygonShape3D.new()
cv.points = pts
return [cv, Transform3D()]
static func is_build(def: Dictionary) -> bool:
return def.has("place") and def.place.has("kind")
func _make_collision(_mi: MeshInstance3D) -> void:
kind = def.place.kind
collision_layer = 1 | BUILD_LAYER
add_to_group("build")
for s in kinds()[kind].shapes:
var made := make_shape(s)
var cs := CollisionShape3D.new()
cs.shape = made[0]
cs.transform = made[1]
add_child(cs)
func snap_points() -> Array:
var out := []
for p in kinds()[kind].snaps:
out.append(global_transform * p)
return out
func display_name() -> String:
return ItemDB.item_name(item_id)
func interact_prompt(_player: Node) -> String:
if support < 0.999:
return "%s (support %d%%)" % [display_name(), roundi(support * 100.0)]
return display_name()

View File

@@ -0,0 +1,368 @@
extends Node
## Building rules: snap placement, validity, structural support and shelter.
##
## Snapping (Valheim-style): for a ghost near existing pieces, every pairing of
## one of the ghost's snap points with a nearby piece's snap point gives a
## candidate transform (rotation locked to that piece's 90-degree grid). The
## best-scoring candidate that passes the validity check wins. Away from other
## pieces you can place freely on the ground.
##
## Support: a piece touching the ground has support 1. Every other piece takes
## the best support among the pieces it shares a snap point with, minus a loss
## (small going up or down, larger sideways). Below MIN_SUPPORT a piece can't
## be placed, and if dismantling a piece leaves others below it they collapse.
## So you can build a hut, a second floor or a short overhang, but not a
## bridge hanging in mid-air.
##
## Shelter: a ray up hits a roof (build layer), and most of 8 sideways rays hit
## walls. Drives the Wet, Sheltered and Rested rules and campfire rain.
const BuildPiece = preload("res://scripts/building/build_piece.gd")
const SNAP_EPS := 0.08 ## snap points closer than this are connected
const SNAP_RADIUS := 1.3 ## how far from the aim point a snap point may be
const MIN_SUPPORT := 0.3
const VERTICAL_LOSS := 0.1
const HORIZONTAL_LOSS := 0.25
const GROUND_EPS := 0.3 ## a snap point this close to the ground touches it
## Overlap checks shrink shapes by this much. Must exceed half a wall's
## thickness: a floor's edge sits on a wall's centre line by design.
const OVERLAP_MARGIN := 0.11
const CELL := 0.5
var world: Node3D
var terrain: Node3D
var pieces_root: Node3D
var _hash: Dictionary = {} ## Vector3i -> Array of [piece, point]
var last_blocker: Object ## what the last failed overlap check hit (debugging)
var _dirty := true
func setup(w: Node3D) -> void:
world = w
terrain = w.terrain
pieces_root = w._pieces
func mark_dirty() -> void:
_dirty = true
func build_pieces() -> Array:
var out := []
for p in pieces_root.get_children():
if p.is_in_group("build") and not p.is_queued_for_deletion():
out.append(p)
return out
static func _cell(p: Vector3) -> Vector3i:
return Vector3i(floori(p.x / CELL), floori(p.y / CELL), floori(p.z / CELL))
func _ensure_hash() -> void:
if not _dirty:
return
_dirty = false
_hash.clear()
for piece in build_pieces():
for pt in piece.snap_points():
var k := _cell(pt)
if not _hash.has(k):
_hash[k] = []
_hash[k].append([piece, pt])
## Pieces with a snap point within SNAP_EPS of any of `points`.
func _touching(points: Array, exclude: Node = null) -> Array:
_ensure_hash()
var out := []
for pt: Vector3 in points:
var c := _cell(pt)
for dx in range(-1, 2):
for dy in range(-1, 2):
for dz in range(-1, 2):
for e in _hash.get(c + Vector3i(dx, dy, dz), []):
if e[0] != exclude and not out.has(e[0]) and (e[1] as Vector3).distance_to(pt) < SNAP_EPS:
out.append(e[0])
return out
static func _world_snaps(kind: String, xf: Transform3D) -> Array:
var out := []
for p in BuildPiece.kinds()[kind].snaps:
out.append(xf * p)
return out
func _grounded(points: Array) -> bool:
for pt: Vector3 in points:
if pt.y <= terrain.sample_height(pt.x, pt.z) + GROUND_EPS:
return true
return false
static func _loss(from_center: Vector3, to_center: Vector3) -> float:
var d := to_center - from_center
var horiz := Vector2(d.x, d.z).length()
return VERTICAL_LOSS if absf(d.y) >= horiz * 0.99 else HORIZONTAL_LOSS
## Support a piece of `kind` would have at `xf`, given the current structure.
func support_for(kind: String, xf: Transform3D, exclude: Node = null) -> float:
var pts := _world_snaps(kind, xf)
if _grounded(pts):
return 1.0
var center: Vector3 = xf * BuildPiece.kinds()[kind].center
var best := 0.0
for n in _touching(pts, exclude):
var nc: Vector3 = n.global_transform * BuildPiece.kinds()[n.kind].center
best = maxf(best, n.support * (1.0 - _loss(nc, center)))
return best
## Recomputes every piece's support from the ground up. Returns the pieces now
## below MIN_SUPPORT (the caller collapses them).
func recompute() -> Array:
mark_dirty()
var pieces := build_pieces()
var nbrs := {}
var centers := {}
for p in pieces:
var pts: Array = p.snap_points()
p.grounded = _grounded(pts)
p.support = 1.0 if p.grounded else 0.0
nbrs[p] = _touching(pts, p)
centers[p] = p.global_transform * BuildPiece.kinds()[p.kind].center
# Relax until stable (support only ever grows from 0, bounded by 1).
for _iter in range(200):
var changed := false
for p in pieces:
if p.grounded:
continue
var best := 0.0
for n in nbrs[p]:
best = maxf(best, n.support * (1.0 - _loss(centers[n], centers[p])))
if best > p.support + 0.0001:
p.support = best
changed = true
if not changed:
break
var weak := []
for p in pieces:
if p.support < MIN_SUPPORT:
weak.append(p)
return weak
# --- placement --------------------------------------------------------------
## Why a building piece can't go at `xf` ("" = it can).
func check(def: Dictionary, xf: Transform3D, exclude: Node = null) -> String:
var kind: String = def.place.kind
var info: Dictionary = BuildPiece.kinds()[kind]
var c: Vector3 = xf * info.center
if not terrain.inside_boundary(c.x, c.z, 1.5):
return "Too close to the edge of the world"
if c.y < terrain.water_level + 0.1:
return "Can't build in water"
var space := world.get_world_3d().direct_space_state
for s in info.shapes:
var made := BuildPiece.make_shape(s, OVERLAP_MARGIN)
var q := PhysicsShapeQueryParameters3D.new()
q.shape = made[0]
q.transform = xf * (made[1] as Transform3D)
q.collision_mask = 1
var ex: Array[RID] = [terrain.get_node("TerrainBody").get_rid()]
if exclude != null:
ex.append(exclude.get_rid())
q.exclude = ex
var hits := space.intersect_shape(q, 1)
if not hits.is_empty():
last_blocker = hits[0].collider
return "Something's in the way"
if support_for(kind, xf, exclude) < MIN_SUPPORT:
return "Not enough support"
return ""
## Where a building piece goes when the player aims at `hit` (a ray result
## dictionary: position, normal, collider). `yaw` is the player's preferred
## rotation. Returns {xf, ok, reason, snapped}.
func placement(def: Dictionary, hit: Dictionary, yaw: float) -> Dictionary:
var kind: String = def.place.kind
var info: Dictionary = BuildPiece.kinds()[kind]
var hp: Vector3 = hit.position
var cands := []
for piece in build_pieces():
if piece.global_position.distance_to(hp) > 4.5:
continue
var pyaw: float = piece.global_basis.get_euler().y
var k := roundf(wrapf(yaw - pyaw, -PI, PI) / (PI * 0.5))
var basis := Basis(Vector3.UP, pyaw + k * PI * 0.5)
# The ghost sits at the aim point first (origin on the crosshair), then
# moves the short distance that makes one of its snap points meet one
# of this piece's. Closest pair wins, so the piece follows your aim.
var ghost := Transform3D(basis, hp)
for se: Vector3 in piece.snap_points():
if se.distance_to(hp) > SNAP_RADIUS + 2.5:
continue
for sp: Vector3 in info.snaps:
var d := (ghost * sp).distance_to(se)
if d > SNAP_RADIUS:
continue
var xf := Transform3D(basis, se - basis * sp)
var score := d + 0.05 * (xf * (info.center as Vector3)).distance_to(hp)
cands.append([score, xf])
cands.sort_custom(func(a, b): return a[0] < b[0])
var tried: Array[Vector3] = []
var first_reason := ""
var first_xf := Transform3D()
for cand in cands:
var xf: Transform3D = cand[1]
var dup := false
for o in tried:
if o.distance_to(xf.origin) < 0.05:
dup = true
break
if dup:
continue
tried.append(xf.origin)
if tried.size() > 14:
break
var why := check(def, xf)
if why.is_empty():
return {"xf": xf, "ok": true, "reason": "", "snapped": true}
if first_reason.is_empty():
first_reason = why
first_xf = xf
# Nothing to snap to: free placement on the ground or on top of a piece.
var n: Vector3 = hit.get("normal", Vector3.UP)
if n.y > 0.6:
var xf := _free_xf(kind, hp, yaw, hit.get("collider") == terrain.get_node("TerrainBody"))
var why := check(def, xf)
if why.is_empty() or first_reason.is_empty():
return {"xf": xf, "ok": why.is_empty(), "reason": why, "snapped": false}
if not first_reason.is_empty():
return {"xf": first_xf, "ok": false, "reason": first_reason, "snapped": true}
return {"xf": Transform3D(Basis(Vector3.UP, yaw), hp), "ok": false, "reason": "Needs flat ground or a piece to attach to", "snapped": false}
## Free placement. On the ground a floor sits level on the highest point under
## it (so it floats a little on a slope instead of sinking in); walls sit on
## the lowest point so there's no gap underneath.
func _free_xf(kind: String, hp: Vector3, yaw: float, on_terrain: bool) -> Transform3D:
var basis := Basis(Vector3.UP, yaw)
var origin := hp
if on_terrain:
var probe := []
match kind:
"floor":
probe = [Vector3(-1, 0, -1), Vector3(1, 0, -1), Vector3(-1, 0, 1), Vector3(1, 0, 1), Vector3.ZERO]
_:
probe = [Vector3(-1, 0, 0), Vector3.ZERO, Vector3(1, 0, 0)]
var hs: Array[float] = []
for p: Vector3 in probe:
var w: Vector3 = hp + basis * p
hs.append(terrain.sample_height(w.x, w.z))
origin.y = (hs.max() + 0.03) if kind == "floor" else hs.min()
return Transform3D(basis, origin)
# --- shelter ----------------------------------------------------------------
func _ray(from: Vector3, to: Vector3) -> bool:
var q := PhysicsRayQueryParameters3D.create(from, to, BuildPiece.BUILD_LAYER)
return not world.get_world_3d().direct_space_state.intersect_ray(q).is_empty()
## A roof (any building piece) above `pos` (feet height): at least 3 of 5
## rays up (centre and 30 cm around it) hit one, so a seam doesn't count as
## open sky but standing under the edge of an eave doesn't count as indoors.
func roofed_at(pos: Vector3) -> bool:
var hits := 0
for o in [Vector3.ZERO, Vector3(0.3, 0, 0), Vector3(-0.3, 0, 0), Vector3(0, 0, 0.3), Vector3(0, 0, -0.3)]:
if _ray(pos + o + Vector3(0, 0.3, 0), pos + o + Vector3(0, 14, 0)):
hits += 1
return hits >= 3
## 0..1 fraction of 8 horizontal directions blocked by walls within 8 m.
func enclosure_at(pos: Vector3) -> float:
var from := pos + Vector3(0, 1.2, 0)
var hits := 0
for i in range(8):
var a := TAU * i / 8.0
if _ray(from, from + Vector3(cos(a), 0, sin(a)) * 8.0):
hits += 1
return hits / 8.0
func sheltered_at(pos: Vector3) -> bool:
return roofed_at(pos) and enclosure_at(pos) >= 0.74
# --- dev helper ---------------------------------------------------------------
## Builds a 4 x 4 m hut through the same snapping a player uses, aiming at the
## spots a player would aim at: 4 floors, 7 walls and a door (south side, +Z
## in hut space), 4 roofs meeting at a ridge, 4 gables. Used by the tests and
## the screenshot views. A coroutine (await it): like a player, it lets a
## physics step pass after each piece so the next overlap check sees it.
## Returns {"placed": n, "failed": [...], "pieces": [...]}.
func debug_build_hut(center: Vector3, yaw: float) -> Dictionary:
var out := {"placed": 0, "failed": [], "pieces": []}
# Floors: the first sits on the ground, the rest snap to it.
var f0: Node3D = await _hut_place(out, center, yaw, "wood_floor", Vector3(-1, 0, -1), 0.0, null)
if f0 == null:
return out
var top: float = f0.global_position.y - center.y
for l in [Vector3(1, 0, -1), Vector3(-1, 0, 1), Vector3(1, 0, 1)]:
await _hut_place(out, center, yaw, "wood_floor", l, 0.0, null)
# Walls: aim at the floor just inside each edge.
var q := PI * 0.5
for x in [-1.0, 1.0]:
await _hut_place(out, center, yaw, "wood_wall", Vector3(x, top, -1.9), 0.0, f0) # north
await _hut_place(out, center, yaw, "wood_door" if x > 0 else "wood_wall", Vector3(x, top, 1.9), 0.0, f0) # south
for z in [-1.0, 1.0]:
await _hut_place(out, center, yaw, "wood_wall", Vector3(1.9, top, z), q, f0) # east
await _hut_place(out, center, yaw, "wood_wall", Vector3(-1.9, top, z), q, f0) # west
# Roofs on the north and south wall tops, rising to a ridge along X.
for x in [-1.0, 1.0]:
await _hut_place(out, center, yaw, "wood_roof", Vector3(x, top + 2.0, 2.0), 0.0, f0)
await _hut_place(out, center, yaw, "wood_roof", Vector3(x, top + 2.0, -2.0), PI, f0)
# Gables on the east and west wall tops, high end toward the ridge.
for x in [-2.0, 2.0]:
await _hut_place(out, center, yaw, "wood_gable", Vector3(x, top + 2.0, 1.0), q, f0)
await _hut_place(out, center, yaw, "wood_gable", Vector3(x, top + 2.0, -1.0), -q, f0)
return out
## One aimed placement for debug_build_hut. `on` = the piece the aim lands on
## (null = the ground).
func _hut_place(out: Dictionary, center: Vector3, yaw: float, id: String, local: Vector3, ryaw: float, on: Node3D) -> Node3D:
var def: Dictionary = ItemDB.get_item(id)
var w: Vector3 = center + Basis(Vector3.UP, yaw) * local
var tb: Node = terrain.get_node("TerrainBody")
var hit_y: float = terrain.sample_height(w.x, w.z) if on == null else w.y
var hit := {"position": Vector3(w.x, hit_y, w.z), "normal": Vector3.UP, "collider": tb if on == null else on}
var res := placement(def, hit, yaw + ryaw)
if not res.ok:
out.failed.append("%s@%s: %s" % [id, local, res.reason])
return null
var p: Node3D = world.spawn_piece(def, res.xf)
out.placed += 1
out.pieces.append(p)
await world.get_tree().physics_frame
return p
## A spot near `near` with a clear, fairly flat `half`-metre square: nothing
## solid in the way and the ground within 0.6 m across it. Searches outward
## in rings. Returns `near` itself if nothing is found.
func debug_find_site(near: Vector3, half: float) -> Vector3:
var space := world.get_world_3d().direct_space_state
var box := BoxShape3D.new()
box.size = Vector3(half * 2.0 + 2.0, 6.0, half * 2.0 + 2.0)
var ex: Array[RID] = [terrain.get_node("TerrainBody").get_rid(), world.player.get_rid()]
for ring in range(0, 12):
var n := maxi(1, ring * 6)
for i in range(n):
var a := TAU * i / n
var c := near + Vector3(cos(a), 0, sin(a)) * ring * 4.0
if not terrain.inside_boundary(c.x, c.z, half + 4.0):
continue
var hs: Array[float] = []
for d in [Vector2(-1, -1), Vector2(1, -1), Vector2(-1, 1), Vector2(1, 1), Vector2.ZERO]:
hs.append(terrain.sample_height(c.x + d.x * half, c.z + d.y * half))
if hs.max() - hs.min() > 0.6 or hs.min() < terrain.water_level + 0.5:
continue
c.y = hs[4]
var q := PhysicsShapeQueryParameters3D.new()
q.shape = box
q.transform = Transform3D(Basis(), c + Vector3(0, 3.0, 0))
q.collision_mask = 1
q.exclude = ex
if space.intersect_shape(q, 1).is_empty():
return c
return near

View File

@@ -15,6 +15,8 @@ var _fx: Array[CPUParticles3D] = []
var _t := 0.0
var _noise := FastNoiseLite.new()
var _lit := true
var _roofed_cache := -1.0 ## -1 unknown, 0 open sky, 1 under a roof
var _roof_timer := 0.0
func _build() -> void:
add_to_group("station:campfire")
@@ -64,19 +66,44 @@ func interact_prompt(player: Node) -> String:
var has_fuel: bool = player.inventory.count("wood") + player.inventory.count("stick") > 0
if _lit:
var left := "%d:%02d" % [int(fuel) / 60, int(fuel) % 60]
return "Campfire (%s left)%s" % [left, " [E] Add fuel" if has_fuel else " (add wood or sticks)"]
var hiss := ", hissing in the rain" if burn_rate() > 1.2 else ""
return "Campfire (%s left%s)%s" % [left, hiss, " [E] Add fuel" if has_fuel else " (add wood or sticks)"]
return "Campfire (out)%s" % (" [E] Add fuel to light" if has_fuel else " (needs wood or sticks)")
func interact(player: Node) -> void:
Actions.add_fuel(player, self)
func _process(delta: float) -> void:
## How much faster fuel burns right now: rain on an uncovered fire makes it
## hiss and eat through its wood (up to 4x in a storm). A roof stops that.
func burn_rate() -> float:
var w: Node = get_parent().get_parent() if get_parent() != null else null
if w == null or w.get("weather") == null:
return 1.0
var rain: float = w.weather.rain_at(global_position)
if rain <= 0.05:
_roofed_cache = -1.0
return 1.0
if _roofed_cache < 0.0:
_roofed_cache = 1.0 if w.build.roofed_at(global_position) else 0.0
return 1.0 if _roofed_cache > 0.5 else 1.0 + 3.0 * rain
func burn(seconds: float) -> void:
if not _lit:
return
fuel -= delta
fuel -= seconds * burn_rate()
if fuel <= 0.0:
fuel = 0.0
_set_lit(false)
func _process(delta: float) -> void:
if not _lit:
return
_roof_timer -= delta
if _roof_timer <= 0.0:
_roof_timer = 2.0
_roofed_cache = -1.0 # re-check the roof now and then (you may build one)
burn(delta)
if not _lit:
return
_t += delta
var f := _noise.get_noise_1d(_t * 10.0)

View File

@@ -4,6 +4,12 @@ extends "res://scripts/building/piece.gd"
const SLOTS := 16
var inventory := Inventory.new(SLOTS)
func dismantle_block_reason() -> String:
for s in inventory.slots:
if s != null:
return "Empty the chest first"
return ""
func interact_prompt(_player: Node) -> String:
var used := 0
for s in inventory.slots:

61
scripts/building/door.gd Normal file
View File

@@ -0,0 +1,61 @@
extends "res://scripts/building/build_piece.gd"
## Wooden door: a wall-sized frame (snaps like a wall) with a hinged panel.
## E opens or closes it. The panel is an AnimatableBody3D so it pushes the
## player out of the way rather than trapping them.
const OPEN_ANGLE := 1.75 ## radians: swings toward -Z, away from where you stood to place it
var open := false
var _hinge: Node3D
var _panel: AnimatableBody3D
var _tween: Tween
func _visual_mesh() -> Mesh:
return ItemDB.mesh_for("wood_door_frame")
func _build() -> void:
_hinge = Node3D.new()
_hinge.name = "Hinge"
_hinge.position = Vector3(-0.54, 0.0, 0.0)
add_child(_hinge)
_panel = AnimatableBody3D.new()
_panel.name = "Panel"
# Follow the hinge directly (sync_to_physics would only track the body's
# own motion, not its parent rotating).
_panel.sync_to_physics = false
_panel.collision_layer = 1 | BUILD_LAYER
_panel.set_meta("owner_piece", self)
_hinge.add_child(_panel)
var mi := MeshInstance3D.new()
mi.mesh = ItemDB.mesh_for("wood_door_panel")
mi.material_override = ItemDB.item_material()
_panel.add_child(mi)
var cs := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = Vector3(1.06, 1.86, 0.08)
cs.shape = box
cs.position = Vector3(0.54, 0.95, 0.0)
_panel.add_child(cs)
func interact_prompt(_player: Node) -> String:
return "Door [E] %s" % ("Close" if open else "Open")
func interact(player: Node) -> void:
Actions.toggle_door(player, self)
func set_open(on: bool, animate: bool = true) -> void:
open = on
var target := OPEN_ANGLE if on else 0.0
if _tween != null:
_tween.kill()
if animate and is_inside_tree():
_tween = create_tween()
_tween.tween_property(_hinge, "rotation:y", target, 0.45).set_trans(Tween.TRANS_SINE)
else:
_hinge.rotation.y = target
func save_extra() -> Dictionary:
return {"open": open}
func load_extra(d: Dictionary) -> void:
set_open(bool(d.get("open", false)), false)

View File

@@ -1,7 +1,8 @@
extends StaticBody3D
## Base for placed pieces (campfire, workbench, chest, later walls/floors).
## Builds the visual from the item's procedural mesh and a box collider from
## the item's footprint. Subclasses add behaviour.
## Base for placed pieces (campfire, workbench, chest, bed, building pieces).
## Builds the visual from the item's procedural mesh and, by default, a box
## collider from the mesh bounds. Subclasses add behaviour or their own
## colliders (_make_collision).
var item_id := ""
var def: Dictionary = {}
@@ -9,11 +10,19 @@ var def: Dictionary = {}
func setup(d: Dictionary) -> void:
def = d
item_id = d.id
add_to_group("piece")
var mi := MeshInstance3D.new()
mi.name = "Mesh"
mi.mesh = ItemDB.mesh_for(item_id)
mi.mesh = _visual_mesh()
mi.material_override = ItemDB.item_material()
add_child(mi)
_make_collision(mi)
_build()
func _visual_mesh() -> Mesh:
return ItemDB.mesh_for(item_id)
func _make_collision(mi: MeshInstance3D) -> void:
var aabb := mi.mesh.get_aabb()
var cs := CollisionShape3D.new()
var box := BoxShape3D.new()
@@ -21,7 +30,6 @@ func setup(d: Dictionary) -> void:
cs.shape = box
cs.position = aabb.get_center()
add_child(cs)
_build()
func _build() -> void:
pass
@@ -34,3 +42,7 @@ func interact_prompt(_player: Node) -> String:
func interact(_player: Node) -> void:
pass
## Whether X can take this piece back into your inventory (and why not).
func dismantle_block_reason() -> String:
return ""

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,6 +28,11 @@ var _sky_timer := 0.0
const DAY_COLS := [Color(0.24, 0.44, 0.72), Color(0.72, 0.79, 0.85), Color(0.70, 0.77, 0.85)]
const DUSK_COLS := [Color(0.22, 0.27, 0.48), Color(0.98, 0.62, 0.38), Color(0.82, 0.62, 0.50)]
const NIGHT_COLS := [Color(0.02, 0.03, 0.07), Color(0.07, 0.09, 0.16), Color(0.07, 0.09, 0.14)]
const OVERCAST_COLS := [Color(0.40, 0.44, 0.49), Color(0.58, 0.61, 0.64), Color(0.56, 0.59, 0.62)]
const BASE_FOG := 0.0035 ## default_env.tres fog_density
var weather: Node ## weather.gd, set by world.gd
var _flash_shown := false
func setup(world_sun: DirectionalLight3D, environment: Environment) -> void:
sun = world_sun
@@ -78,37 +83,54 @@ func apply(force: bool) -> void:
daylight = smoothstep(-0.2, 0.25, to_sun.y)
var low := 1.0 - smoothstep(0.05, 0.45, absf(to_sun.y)) # 1 near the horizon
# Weather (weather.gd) dims the sun, thickens fog and greys the sky.
var w_light := 1.0
var w_fog := 1.0
var w_clouds := 0.3
var w_flash := 0.0
if weather != null:
w_light = weather.light
w_fog = weather.fog
w_clouds = weather.clouds
w_flash = weather.flash
var overcast := smoothstep(0.5, 1.0, w_clouds)
sun.global_basis = Basis.looking_at(-to_sun if to_sun.y > -0.2 else Vector3.DOWN, Vector3.FORWARD)
sun.light_energy = 1.8 * smoothstep(-0.03, 0.15, to_sun.y)
sun.light_energy = 1.8 * smoothstep(-0.03, 0.15, to_sun.y) * w_light
sun.light_color = Color(1.0, 0.95, 0.83).lerp(Color(1.0, 0.58, 0.32), low)
sun.visible = sun.light_energy > 0.01
sun.shadow_enabled = sun.visible
var moon_dir := Vector3(-to_sun.x, absf(to_sun.y) * 0.8 + 0.25, -to_sun.z * 0.5).normalized()
moon.global_basis = Basis.looking_at(-moon_dir, Vector3.FORWARD)
moon.light_energy = 0.38 * (1.0 - daylight)
moon.light_energy = 0.38 * (1.0 - daylight) * (1.0 - 0.6 * overcast)
moon.visible = moon.light_energy > 0.01
moon.shadow_enabled = moon.visible and not sun.visible
env.ambient_light_energy = lerpf(0.3, 1.0, daylight)
env.fog_light_energy = lerpf(0.25, 1.0, daylight)
env.volumetric_fog_density = lerpf(0.0025, 0.0045, daylight)
env.ambient_light_energy = lerpf(0.3, 1.0, daylight) * lerpf(1.0, 0.85, overcast) + w_flash * 3.0
env.fog_light_energy = lerpf(0.25, 1.0, daylight) * lerpf(1.0, 0.7, overcast) + w_flash * 2.0
env.volumetric_fog_density = lerpf(0.0025, 0.0045, daylight) * lerpf(1.0, 2.0, overcast)
env.fog_density = BASE_FOG * w_fog
_sky_timer -= get_process_delta_time()
if not force and _sky_timer > 0.0:
if not force and _sky_timer > 0.0 and w_flash < 0.05 and not _flash_shown:
return
_flash_shown = w_flash >= 0.05
_sky_timer = 0.25
var cols: Array = []
for i in range(3):
var dayc: Color = DAY_COLS[i].lerp(DUSK_COLS[i], low * daylight)
var dayc: Color = DAY_COLS[i].lerp(DUSK_COLS[i], low * daylight * (1.0 - overcast * 0.7))
dayc = dayc.lerp(OVERCAST_COLS[i], overcast * 0.85)
cols.append(NIGHT_COLS[i].lerp(dayc, daylight))
env.fog_light_color = cols[2]
if sky_mat != null:
sky_mat.set_shader_parameter("zenith_col", cols[0])
sky_mat.set_shader_parameter("horizon_col", cols[1])
sky_mat.set_shader_parameter("energy", lerpf(0.35, 1.0, daylight))
sky_mat.set_shader_parameter("star_strength", 1.0 - smoothstep(0.0, 0.35, daylight))
sky_mat.set_shader_parameter("cloud_lit", Color(0.16, 0.18, 0.24).lerp(Color(1, 1, 1), daylight))
sky_mat.set_shader_parameter("energy", lerpf(0.35, 1.0, daylight) * lerpf(1.0, 0.72, overcast) + w_flash * 2.5)
sky_mat.set_shader_parameter("star_strength", (1.0 - smoothstep(0.0, 0.35, daylight)) * (1.0 - overcast))
sky_mat.set_shader_parameter("cloud_cover", lerpf(0.35, 1.05, w_clouds))
var lit := Color(0.16, 0.18, 0.24).lerp(Color(1, 1, 1), daylight)
sky_mat.set_shader_parameter("cloud_lit", lit.lerp(lit * Color(0.55, 0.57, 0.6), overcast))
sky_mat.set_shader_parameter("sun_tint", Color(1.0, 0.86, 0.62).lerp(Color(1.0, 0.55, 0.3), low))
## 0..100 "how warm the air is" before fires, shelter and clothes.

View File

@@ -30,6 +30,18 @@ func interact_prompt(_player: Node) -> String:
func interact(player: Node) -> void:
Actions.pickup(player, self)
signal taken_now(pickup: Node)
## Picked up: hidden (not freed) so it can reappear later (props.gd regrowth).
func collected() -> void:
if taken:
return
taken = true
queue_free()
visible = false
collision_layer = 0
taken_now.emit(self)
func regrow() -> void:
taken = false
visible = true
collision_layer = 2

View File

@@ -42,6 +42,12 @@ var _resources: Node3D ## harvestable trees/boulders (each its own body)
var resource_nodes: Dictionary = {} ## uid -> resource node
var pickups: Dictionary = {} ## uid -> pickup
var forage: Dictionary = {} ## uid -> berry bush (forage.gd)
## Regrowth: uid -> seconds until a felled tree, mined boulder or picked-up
## item comes back. Saved with the world. Bushes keep their own timers.
var regrow: Dictionary = {}
const REGROW_SECONDS := {"tree": 2400.0, "rock": 3600.0, "pickup": 900.0}
const REGROW_RETRY := 120.0 ## blocked (player or a building nearby): try again later
var _regrow_tick := 0.0
var _bark_mat: StandardMaterial3D
var _leaf_mat: ShaderMaterial
@@ -101,12 +107,83 @@ func place_on(t: Node3D, player: Node3D, spawn: Vector3) -> void:
func _process(delta: float) -> void:
if _player == null:
return
_regrow_tick += delta
if _regrow_tick >= 1.0:
skip_time(_regrow_tick)
_regrow_tick = 0.0
_grass_timer -= delta
if _grass_timer > 0.0:
return
_grass_timer = 0.2
_update_grass(grass_tiles_per_update)
# --- regrowth ---------------------------------------------------------------
func _regrow_kind(uid: String) -> String:
if pickups.has(uid):
return "pickup"
return resource_nodes[uid].kind
func _on_depleted(node: Node) -> void:
regrow[node.uid] = REGROW_SECONDS[node.kind]
func _on_taken(pk: Node) -> void:
regrow[pk.uid] = REGROW_SECONDS["pickup"]
## Advances regrowth timers by `secs` (every second while playing, or in one
## go when you sleep).
func skip_time(secs: float) -> void:
for uid in regrow.keys():
regrow[uid] -= secs
if regrow[uid] <= 0.0:
_try_regrow(uid)
for uid in forage:
var f = forage[uid]
if secs > 1.5 and f.regrow_left > 0.0:
f._process(secs)
func _try_regrow(uid: String) -> void:
var node: Node3D = pickups.get(uid, resource_nodes.get(uid))
if node == null:
regrow.erase(uid)
return
var r := 0.5 if pickups.has(uid) else 1.4
if _player != null and _player.global_position.distance_to(node.global_position) < r + 3.0 \
or _built_over(node.global_position, r):
regrow[uid] = REGROW_RETRY
return
regrow.erase(uid)
node.regrow()
## Something built (or being built) where a tree or rock would come back.
func _built_over(pos: Vector3, r: float) -> bool:
var sph := SphereShape3D.new()
sph.radius = r
var q := PhysicsShapeQueryParameters3D.new()
q.shape = sph
q.transform = Transform3D(Basis(), pos + Vector3(0, r, 0))
q.collision_mask = 4 # building pieces (build_piece.gd BUILD_LAYER)
if not get_world_3d().direct_space_state.intersect_shape(q, 1).is_empty():
return true
# Chests, fires and beds aren't on the building layer.
for p in get_tree().get_nodes_in_group("piece"):
if (p as Node3D).global_position.distance_to(pos) < r + 0.8:
return true
return false
## From a save. Anything gone without a saved timer (older saves) starts a
## fresh one.
func restore_regrow(saved: Dictionary) -> void:
for uid in saved:
if pickups.has(uid) or resource_nodes.has(uid):
regrow[uid] = float(saved[uid])
for uid in resource_nodes:
if resource_nodes[uid].depleted and not regrow.has(uid):
regrow[uid] = REGROW_SECONDS[resource_nodes[uid].kind]
for uid in pickups:
if pickups[uid].taken and not regrow.has(uid):
regrow[uid] = REGROW_SECONDS["pickup"]
# --- materials --------------------------------------------------------------
func _make_materials() -> void:
@@ -205,6 +282,7 @@ func _resource_node(kind: String, xf: Transform3D) -> StaticBody3D:
node.drops_final = {"stone": [3, 5], "flint": [1, 2]}
node.chip_color = Color(0.55, 0.54, 0.52)
node.hp = node.max_hp
node.depleted_now.connect(_on_depleted)
_resources.add_child(node)
node.global_position = xf.origin
return node
@@ -539,6 +617,7 @@ func _scatter_pickups() -> void:
pk.setup(id, mesh, ItemDB.item_material(), radius[id])
pk.uid = "p%d" % pickups.size()
pickups[pk.uid] = pk
pk.taken_now.connect(_on_taken)
root.add_child(pk)
pk.global_transform = Transform3D(_ground_basis(n, 1.0, yaw, 1.0), Vector3(x, h - 0.02, z))
placed += 1
@@ -627,6 +706,12 @@ func _make_grass_tile(k: Vector2i) -> Node3D:
var flower_xf: Array = []
var flower_col: Array = []
var steps := int(GRASS_TILE / grass_spacing)
# Floors that overlap this tile (grass doesn't grow through them).
var cuts: Array = []
var tile := Rect2(ox, oz, GRASS_TILE, GRASS_TILE).grow(2.0)
for c in _grass_cuts:
if tile.has_point(Vector2(c[1].x, c[1].z)):
cuts.append(c)
for iz in range(steps):
for ix in range(steps):
var x := ox + (ix + rng.randf()) * grass_spacing
@@ -641,6 +726,8 @@ func _make_grass_tile(k: Vector2i) -> Node3D:
var n: Vector3 = terrain.sample_normal(x, z)
if n.y < 0.72:
continue
if not cuts.is_empty() and _under_cut(cuts, x, z):
continue
var meadow := _meadow_noise.get_noise_2d(x, z)
var dens := smoothstep(-0.45, 0.1, meadow) * (1.0 - smoothstep(0.85, 0.72, n.y) * 0.7)
if keep > dens:
@@ -666,6 +753,38 @@ func _make_grass_tile(k: Vector2i) -> Node3D:
_grass_root.add_child(node)
return node
## Floor footprints to keep clear of grass: [world->local transform, centre,
## half width]. Set by world.gd whenever floors are built or removed; only the
## grass tiles around old and new floors are rebuilt.
var _grass_cuts: Array = []
func set_grass_cuts(xforms: Array, half: float) -> void:
var old := _grass_cuts
_grass_cuts = []
for xf: Transform3D in xforms:
_grass_cuts.append([xf.affine_inverse(), xf.origin, half])
var touched := {}
for c in old + _grass_cuts:
var p: Vector3 = c[1]
for dz in [-3.0, 0.0, 3.0]:
for dx in [-3.0, 0.0, 3.0]:
touched[Vector2i(floori((p.x + dx) / GRASS_TILE), floori((p.z + dz) / GRASS_TILE))] = true
for k in touched:
if _grass_tiles.has(k):
var node = _grass_tiles[k]
if node != null and is_instance_valid(node):
node.queue_free()
_grass_tiles.erase(k)
if _player != null:
_update_grass(touched.size())
static func _under_cut(cuts: Array, x: float, z: float) -> bool:
for c in cuts:
var l: Vector3 = (c[0] as Transform3D) * Vector3(x, (c[1] as Vector3).y, z)
if absf(l.x) < c[2] and absf(l.z) < c[2]:
return true
return false
func _fade(gi: GeometryInstance3D, dist: float) -> void:
gi.visibility_range_end = dist
gi.visibility_range_end_margin = 10.0

View File

@@ -29,6 +29,11 @@ var rng := RandomNumberGenerator.new()
var _shake := 0.0
var _shake_axis := Vector3.RIGHT
var _stump: MeshInstance3D
var _orig_shapes: Array = [] ## [CollisionShape3D, shape, position] before the stump
## Emitted when a player fells or mines this (not when a save restores it).
signal depleted_now(node: Node)
func display_name() -> String:
return "Tree" if kind == "tree" else "Boulder"
@@ -107,7 +112,36 @@ func _deplete(from: Vector3, animate: bool = true) -> void:
else:
if animate:
_burst(global_position + Vector3(0, 0.8, 0), chip_color, 40)
queue_free()
# Kept (hidden, no collision) so it can regrow later.
set_process(false)
remove_from_group("harvestable")
if animate:
depleted_now.emit(self)
## Brings a felled tree or mined boulder back, growing in over a few seconds.
func regrow() -> void:
if not depleted:
return
depleted = false
hp = max_hp
if _stump != null:
_stump.queue_free()
_stump = null
for e in _orig_shapes:
e[0].shape = e[1]
e[0].position = e[2]
_orig_shapes.clear()
if has_meta("is_stump"):
remove_meta("is_stump")
collision_layer = 1
add_to_group("harvestable")
set_process(true)
if is_inside_tree() and DisplayServer.get_name() != "headless":
var tw := create_tween()
tw.tween_method(func(k: float): _set_instance(Transform3D(base_xf.basis.scaled_local(Vector3.ONE * k), base_xf.origin)),
0.15, 1.0, 4.0).set_trans(Tween.TRANS_SINE)
else:
_set_instance(base_xf)
func _fell(from: Vector3, animate: bool) -> void:
var world := get_parent()
@@ -139,9 +173,11 @@ func _make_stump(world: Node) -> void:
stump.material_override = stump_material
world.add_child(stump)
stump.global_position = base_xf.origin
_stump = stump
# Keep collision as a short stump.
for c in get_children():
if c is CollisionShape3D and c.shape is CapsuleShape3D:
_orig_shapes.append([c, c.shape, c.position])
var cyl := CylinderShape3D.new()
cyl.radius = (c.shape as CapsuleShape3D).radius
cyl.height = 0.6

191
scripts/world/weather.gd Normal file
View File

@@ -0,0 +1,191 @@
extends Node
## Weather model: what the sky is doing right now, and what that means for
## warmth, wetness and light. Visuals and sound live in weather_fx.gd.
##
## Deterministic: the weather at any moment is a function of the world seed and
## the absolute world time (day + time of day), so saves don't store it and in
## M2 every client can compute the same weather as the server.
##
## A slow 1D noise "front" value picks the state: high = clear, low = rain,
## lowest = storm. About one day in four is a cold day, when rain falls as snow
## (and a storm becomes a blizzard). Above the snowline it always snows.
## Outputs ease toward the state's targets over about a minute, so fronts
## roll in rather than switching.
signal state_changed(state: String)
const STATES := ["clear", "cloudy", "rain", "storm", "snow", "blizzard"]
## Thresholds on the front value (see _state_for). Tuned with
## tests/test_weather_building.gd so that over many days it's roughly
## clear 40%, cloudy 30%, rain 20%, storm 10%.
var cut_cloudy := 0.07
var cut_rain := -0.19
var cut_storm := -0.42
## Per-state targets: clouds 0..1, precip 0..1, wind 0..1, light (sun/ambient
## multiplier), fog (density multiplier), warmth (added to air warmth).
const TARGETS := {
"clear": {"clouds": 0.30, "precip": 0.0, "wind": 0.25, "light": 1.00, "fog": 1.0, "warmth": 0.0},
"cloudy": {"clouds": 0.72, "precip": 0.0, "wind": 0.45, "light": 0.72, "fog": 1.4, "warmth": -3.0},
"rain": {"clouds": 0.92, "precip": 0.6, "wind": 0.60, "light": 0.50, "fog": 2.2, "warmth": -6.0},
"storm": {"clouds": 1.00, "precip": 1.0, "wind": 1.00, "light": 0.30, "fog": 3.0, "warmth": -12.0},
"snow": {"clouds": 0.90, "precip": 0.6, "wind": 0.35, "light": 0.68, "fog": 2.6, "warmth": -14.0},
"blizzard": {"clouds": 1.00, "precip": 1.0, "wind": 0.90, "light": 0.45, "fog": 4.5, "warmth": -24.0},
}
const COLD_DAY_WARMTH := -8.0
const BLEND_SECONDS := 50.0 ## time for outputs to cross from one state to another
const CALM_START := 600.0 ## a new world's first 10 minutes are always clear
const COLD_DAY_CHANCE := 0.25
var world_seed := 1337
var day_night: Node
var snowline := 32.0 ## above this height precipitation is snow
var frozen := false ## title-screen backdrop: hold clear weather
var override := "" ## force a state (tests, screenshots); "" = natural
var state := "clear"
var cold_day := false
# Smoothed outputs.
var clouds := 0.3
var precip := 0.0
var wind := 0.25
var light := 1.0
var fog := 1.0
var warmth := 0.0
var ground_wet := 0.0 ## 0..1 how soaked the ground looks
var snow_cover := 0.0 ## 0..1 how much snow has settled
var flash := 0.0 ## lightning brightness, decays fast
var lightning_count := 0
var _noise := FastNoiseLite.new()
var _strike_left := 8.0
var _rng := RandomNumberGenerator.new()
func setup(seed: int, dn: Node) -> void:
world_seed = seed
day_night = dn
_noise.seed = seed + 401
_noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
_noise.frequency = 1.0 / 900.0 # a wet spell lasts about 6 minutes
_noise.fractal_type = FastNoiseLite.FRACTAL_FBM
_noise.fractal_octaves = 2
_rng.seed = seed + 402
snap()
## Seconds since the world began (day 1, 00:00).
func world_seconds() -> float:
return (float(day_night.day - 1) + float(day_night.time)) * float(day_night.day_length)
func is_cold_day(day: int) -> bool:
if day <= 1:
return false
return float(absi(hash(Vector2i(world_seed, day))) % 10000) / 10000.0 < COLD_DAY_CHANCE
## The raw front value at an absolute time (higher = fairer).
func front_at(t: float) -> float:
var v := _noise.get_noise_1d(t)
if t < CALM_START:
v = maxf(v, 0.3)
return v
func _state_for(v: float, cold: bool) -> String:
if v > cut_cloudy:
return "clear"
if v > cut_rain:
return "cloudy"
if v > cut_storm:
return "snow" if cold else "rain"
return "blizzard" if cold else "storm"
## The state at an absolute time, ignoring smoothing and overrides.
func state_at(t: float) -> String:
var day := int(floor(t / float(day_night.day_length))) + 1
return _state_for(front_at(t), is_cold_day(day))
func force(s: String) -> void:
override = s
snap()
## Jump every output straight to the current state's targets (on load, or
## when forcing a state).
func snap() -> void:
_pick_state()
var tg: Dictionary = TARGETS[state]
clouds = tg.clouds
precip = tg.precip
wind = tg.wind
light = tg.light
fog = tg.fog
warmth = tg.warmth + (COLD_DAY_WARMTH if cold_day else 0.0)
ground_wet = 1.0 if state in ["rain", "storm"] else 0.0
snow_cover = 0.7 if state in ["snow", "blizzard"] else (0.25 if cold_day else 0.0)
func _pick_state() -> void:
if day_night == null:
return
cold_day = is_cold_day(day_night.day)
var s := override
if frozen and s.is_empty():
s = "clear"
if s.is_empty():
s = _state_for(front_at(world_seconds()), cold_day)
if s != state:
state = s
state_changed.emit(state)
func _process(delta: float) -> void:
step(delta)
func step(dt: float) -> void:
_pick_state()
var tg: Dictionary = TARGETS[state]
var k := dt / BLEND_SECONDS
clouds = move_toward(clouds, tg.clouds, k)
precip = move_toward(precip, tg.precip, k * 1.2)
wind = move_toward(wind, tg.wind, k)
light = move_toward(light, tg.light, k * 0.8)
fog = move_toward(fog, tg.fog, k * 3.0)
warmth = move_toward(warmth, tg.warmth + (COLD_DAY_WARMTH if cold_day else 0.0), k * 20.0)
# The ground soaks in a couple of minutes of rain and dries over ten.
var raining := precip > 0.2 and not snowing_globally()
ground_wet = move_toward(ground_wet, 1.0 if raining else 0.0, dt / (120.0 if raining else 600.0))
# Snow settles over a few minutes and only melts on warm days.
var snowing := precip > 0.2 and snowing_globally()
if snowing:
snow_cover = move_toward(snow_cover, 1.0, dt * precip / 180.0)
elif not cold_day:
snow_cover = move_toward(snow_cover, 0.0, dt / 480.0)
flash = maxf(flash - dt * 6.0, 0.0)
if state == "storm" and precip > 0.7:
_strike_left -= dt
if _strike_left <= 0.0:
_strike_left = _rng.randf_range(7.0, 22.0)
flash = 1.0
lightning_count += 1
func snowing_globally() -> bool:
return state in ["snow", "blizzard"]
## Whether precipitation at `pos` falls as snow (cold day or high up).
func snows_at(pos: Vector3) -> bool:
return snowing_globally() or pos.y > snowline
## 0..1 rain intensity at `pos` (0 when it's snow there).
func rain_at(pos: Vector3) -> float:
return 0.0 if snows_at(pos) else precip
## Air warmth change from the weather. Altitude snow makes the peaks colder
## still when precipitation is falling up there.
func warmth_at(pos: Vector3) -> float:
var w := warmth
if precip > 0.2 and pos.y > snowline and not snowing_globally():
w -= 10.0 * precip
return w
func label() -> String:
var names := {"clear": "Clear", "cloudy": "Cloudy", "rain": "Rain", "storm": "Storm",
"snow": "Snow", "blizzard": "Blizzard"}
var s: String = names[state]
if cold_day and state in ["clear", "cloudy"]:
s += ", cold"
return s

217
scripts/world/weather_fx.gd Normal file
View File

@@ -0,0 +1,217 @@
extends Node3D
## Weather visuals and sound, driven by weather.gd (the model).
##
## - Rain and snow are GPU particles emitted above the camera in world space.
## A heightfield collider that follows the camera stops them on roofs and on
## the ground, so it doesn't rain indoors.
## - Ground wetness, settled snow and wind strength are global shader uniforms
## (weather_wet, weather_snow, weather_wind) read by the terrain, grass,
## foliage and rock shaders.
## - Sound is synthesised: filtered noise for rain and wind, a decaying
## low rumble for thunder. No audio assets, nothing to license.
##
## Client-side only: in M2 the server runs weather.gd and never this.
const Campfire = preload("res://scripts/building/campfire.gd")
var weather: Node
var player: Node3D ## for shelter muffling (survival.roofed)
var _rain: GPUParticles3D
var _snow: GPUParticles3D
var _rain_pm: ParticleProcessMaterial
var _snow_pm: ParticleProcessMaterial
var _field: GPUParticlesCollisionHeightField3D
# Audio
const MIX_RATE := 22050.0
var _audio: AudioStreamPlayer
var _playback: AudioStreamGeneratorPlayback
var _rng := RandomNumberGenerator.new()
var _rain_lp := 0.0
var _rain_hp := 0.0
var _brown := 0.0
var _brown2 := 0.0
var _gust_t := 0.0
var _thunder_env := 0.0
var _thunder_delay := -1.0
var _seen_strikes := 0
var _rain_amp := 0.0
var _wind_amp := 0.0
var _muffle := 0.0
func setup(w: Node, p: Node3D) -> void:
weather = w
player = p
_seen_strikes = weather.lightning_count
_rain_pm = ParticleProcessMaterial.new()
_rain = _make_particles(7000, 1.5, _rain_pm, _streak_mesh())
_rain_pm.emission_box_extents = Vector3(20, 1, 20)
_rain_pm.initial_velocity_min = 13.0
_rain_pm.initial_velocity_max = 16.0
_rain_pm.gravity = Vector3(0, -6, 0)
_rain_pm.particle_flag_align_y = true
_rain_pm.spread = 2.0
_snow_pm = ParticleProcessMaterial.new()
_snow = _make_particles(5000, 9.0, _snow_pm, _flake_mesh())
_snow_pm.emission_box_extents = Vector3(18, 1, 18)
_snow_pm.initial_velocity_min = 0.8
_snow_pm.initial_velocity_max = 1.4
_snow_pm.gravity = Vector3(0, -0.6, 0)
_snow_pm.spread = 15.0
_snow_pm.turbulence_enabled = true
_snow_pm.turbulence_noise_strength = 1.2
_snow_pm.turbulence_noise_scale = 4.0
_snow_pm.turbulence_influence_min = 0.03
_snow_pm.turbulence_influence_max = 0.08
_field = GPUParticlesCollisionHeightField3D.new()
_field.size = Vector3(48, 48, 48)
_field.resolution = GPUParticlesCollisionHeightField3D.RESOLUTION_512
_field.update_mode = GPUParticlesCollisionHeightField3D.UPDATE_MODE_ALWAYS
_field.follow_camera_enabled = true
add_child(_field)
if DisplayServer.get_name() != "headless":
var gen := AudioStreamGenerator.new()
gen.mix_rate = MIX_RATE
gen.buffer_length = 0.2
_audio = AudioStreamPlayer.new()
_audio.stream = gen
_audio.volume_db = -4.0
add_child(_audio)
_audio.play()
_playback = _audio.get_stream_playback()
func _make_particles(amount: int, life: float, pm: ParticleProcessMaterial, mesh: Mesh) -> GPUParticles3D:
var p := GPUParticles3D.new()
p.amount = amount
p.lifetime = life
p.preprocess = life
p.local_coords = false
p.visibility_aabb = AABB(Vector3(-30, -40, -30), Vector3(60, 60, 60))
p.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
p.amount_ratio = 0.0
p.emitting = false
pm.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX
pm.direction = Vector3.DOWN
pm.collision_mode = ParticleProcessMaterial.COLLISION_HIDE_ON_CONTACT
pm.collision_use_scale = false
p.collision_base_size = 0.05
p.process_material = pm
p.draw_pass_1 = mesh
add_child(p)
return p
## Two crossed thin quads along +Y, so a streak reads from any side once the
## particle is aligned to its velocity.
static func _streak_mesh() -> ArrayMesh:
var st := SurfaceTool.new()
st.begin(Mesh.PRIMITIVE_TRIANGLES)
var w := 0.012
var h := 0.32
for axis in [Vector3.RIGHT, Vector3.BACK]:
var a: Vector3 = axis * w
var quad := [-a + Vector3(0, -h, 0), a + Vector3(0, -h, 0), a + Vector3(0, h, 0), -a + Vector3(0, h, 0)]
for i in [0, 1, 2, 0, 2, 3]:
st.set_uv(Vector2(0.5, 1.0 if quad[i].y < 0 else 0.0))
st.add_vertex(quad[i])
var m := StandardMaterial3D.new()
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
m.cull_mode = BaseMaterial3D.CULL_DISABLED
m.albedo_color = Color(0.72, 0.78, 0.88, 0.22)
m.disable_receive_shadows = true
st.set_material(m)
return st.commit()
static func _flake_mesh() -> QuadMesh:
var q := QuadMesh.new()
q.size = Vector2.ONE * 0.07
var m := StandardMaterial3D.new()
m.billboard_mode = BaseMaterial3D.BILLBOARD_PARTICLES
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
m.albedo_texture = Campfire._soft_dot()
m.albedo_color = Color(1, 1, 1, 0.9)
m.emission_enabled = true
m.emission = Color(0.25, 0.27, 0.3) # stays visible against a dark sky
m.disable_receive_shadows = true
q.material = m
return q
func _process(delta: float) -> void:
if weather == null:
return
var cam := get_viewport().get_camera_3d()
if cam == null:
return
var cp := cam.global_position
var fwd := -cam.global_basis.z
fwd.y = 0.0
var snow: bool = weather.snows_at(cp)
var amount: float = weather.precip if weather.precip > 0.05 else 0.0
var wind_v: Vector3 = Vector3(0.8, 0.0, 0.3) * float(weather.wind)
# Emit a little ahead of the camera, so the view is full and the area
# behind doesn't waste particles.
_rain.global_position = cp + fwd.normalized() * 5.0 + Vector3(0, 13, 0)
_snow.global_position = cp + fwd.normalized() * 3.0 + Vector3(0, 9, 0)
_set_amount(_rain, 0.0 if snow else amount)
_set_amount(_snow, amount if snow else 0.0)
_rain_pm.direction = (Vector3.DOWN + wind_v * 0.25).normalized()
_snow_pm.gravity = Vector3(0, -0.6, 0) + wind_v * 1.5
_field.visible = amount > 0.0
RenderingServer.global_shader_parameter_set("weather_wet", weather.ground_wet)
RenderingServer.global_shader_parameter_set("weather_snow", weather.snow_cover)
RenderingServer.global_shader_parameter_set("weather_wind", lerpf(0.7, 3.0, weather.wind))
var roofed: bool = player != null and player.survival.roofed
var sheltered: bool = player != null and player.survival.sheltered
_muffle = move_toward(_muffle, 1.0 if roofed else 0.0, delta * 3.0)
_rain_amp = weather.rain_at(cp) * 0.30 * (1.0 - 0.45 * _muffle)
_wind_amp = weather.wind * weather.wind * 0.22 * (0.35 if sheltered else 1.0)
if weather.lightning_count != _seen_strikes:
_seen_strikes = weather.lightning_count
_thunder_delay = _rng.randf_range(0.5, 2.5)
if _thunder_delay >= 0.0:
_thunder_delay -= delta
if _thunder_delay < 0.0:
_thunder_env = 1.0
_fill_audio()
func _set_amount(p: GPUParticles3D, a: float) -> void:
p.amount_ratio = a
p.emitting = a > 0.0
## Synthesises as many audio frames as the generator can take.
func _fill_audio() -> void:
if _playback == null:
return
var n := _playback.get_frames_available()
if n <= 0:
return
var rain_cut := lerpf(0.55, 0.12, _muffle)
var buf := PackedVector2Array()
buf.resize(n)
var thunder_decay := exp(-1.0 / (MIX_RATE * 0.9))
for i in range(n):
var white := _rng.randf() * 2.0 - 1.0
# Rain: band-limited hiss (low-pass, minus a slower low-pass).
_rain_lp += rain_cut * (white - _rain_lp)
_rain_hp += 0.02 * (_rain_lp - _rain_hp)
var rain := (_rain_lp - _rain_hp) * _rain_amp
# Wind: brown noise, swelling in slow gusts.
_brown = clampf(_brown * 0.998 + white * 0.03, -1.0, 1.0)
_gust_t += 1.0 / MIX_RATE
var gust := 0.6 + 0.4 * sin(_gust_t * 0.7) * sin(_gust_t * 0.23 + 1.0)
var wind_s := _brown * _wind_amp * gust * 2.0
# Thunder: a heavier brown rumble with a long decay.
var thunder := 0.0
if _thunder_env > 0.001:
_brown2 = clampf(_brown2 * 0.9995 + white * 0.02, -1.0, 1.0)
thunder = _brown2 * _thunder_env * 2.2
_thunder_env *= thunder_decay
var s := clampf(rain + wind_s + thunder, -1.0, 1.0)
buf[i] = Vector2(s, s)
_playback.push_buffer(buf)

View File

@@ -21,7 +21,11 @@ const PIECES := {
"workbench": preload("res://scripts/building/workbench.gd"),
"chest": preload("res://scripts/building/chest.gd"),
"grave": preload("res://scripts/building/grave.gd"),
"build": preload("res://scripts/building/build_piece.gd"),
"door": preload("res://scripts/building/door.gd"),
"bed": preload("res://scripts/building/bed.gd"),
}
const BuildPiece = preload("res://scripts/building/build_piece.gd")
const AUTOSAVE_SECONDS := 120.0
@@ -38,6 +42,12 @@ var _autosave_left := AUTOSAVE_SECONDS
var _menu_cam: Camera3D
var _menu_t := 0.0
var day_night: Node ## day_night.gd: time of day, sun, sky
var weather: Node ## weather.gd: the weather model
var weather_fx: Node3D ## weather_fx.gd: rain/snow particles, sound
var build: Node ## build_system.gd: snapping, support, shelter
var has_bed_spawn := false
var bed_spawn := Vector3.ZERO ## position of the bed you respawn at
var _loading := false ## applying a save: defer support recompute
func _ready() -> void:
_pieces = Node3D.new()
@@ -47,6 +57,22 @@ func _ready() -> void:
day_night.name = "DayNight"
add_child(day_night)
day_night.setup(sun, $WorldEnvironment.environment)
weather = preload("res://scripts/world/weather.gd").new()
weather.name = "Weather"
add_child(weather)
weather.snowline = terrain.snow_height - 8.0
weather.frozen = menu_mode
weather.setup(terrain.noise_seed, day_night)
day_night.weather = weather
build = preload("res://scripts/building/build_system.gd").new()
build.name = "Build"
add_child(build)
build.setup(self)
if not menu_mode and DisplayServer.get_name() != "headless":
weather_fx = preload("res://scripts/world/weather_fx.gd").new()
weather_fx.name = "WeatherFX"
add_child(weather_fx)
weather_fx.setup(weather, player)
if menu_mode:
day_night.time = 9.5 / 24.0
day_night.frozen = true
@@ -75,10 +101,17 @@ func _ready() -> void:
# --- survival environment ---------------------------------------------------
## 0..100 air warmth at `pos` before fires: time of day, minus altitude.
func air_warmth(pos: Vector3) -> float:
## 0..100 air warmth at `pos` before fires: time of day, minus altitude,
## plus the weather. A proper shelter keeps out most of the weather and adds a
## little warmth of its own.
func air_warmth(pos: Vector3, sheltered: bool = false) -> float:
var w: float = day_night.air_warmth()
w -= maxf(pos.y - 12.0, 0.0) * 0.9
var ww: float = weather.warmth_at(pos)
if sheltered:
w += ww * 0.25 + 8.0
else:
w += ww
return w
## 0..1 heat from fires and a held torch.
@@ -108,10 +141,71 @@ func _on_player_died() -> void:
player.inventory.load_data(Inventory.new(player.inventory.size()).to_data())
if hud != null:
hud.close_screens()
_respawn()
var bed := spawn_bed()
if bed != null:
_respawn_at_bed(bed)
else:
_respawn()
if has_bed_spawn:
has_bed_spawn = false
Actions.notify.emit("Your bed is gone, so you woke at the island start")
player.survival.reset_after_death()
Actions.notify.emit("You died. Your belongings are in your grave." if has_items else "You died.")
# --- beds and sleeping ------------------------------------------------------
## The bed you respawn at, if it still exists.
func spawn_bed() -> Node3D:
if not has_bed_spawn:
return null
for p in _pieces.get_children():
if p.item_id == "bed" and not p.is_queued_for_deletion() and p.global_position.distance_to(bed_spawn) < 0.3:
return p
return null
func set_bed_spawn(bed: Node3D) -> void:
has_bed_spawn = true
bed_spawn = bed.global_position
func _respawn_at_bed(bed: Node3D) -> void:
player.global_position = bed.global_position + Vector3(0, 0.9, 0)
player.velocity = Vector3.ZERO
player.rotation = Vector3(0.0, bed.global_basis.get_euler().y + PI, 0.0)
player.get_node("Camera3D").rotation.x = 0.0
## Why you can't sleep in `bed` right now ("" = you can).
func sleep_block_reason(bed: Node3D) -> String:
var h: float = day_night.hours()
if h > 5.5 and h < 19.5:
return "You can only sleep at night (after 19:30)"
if not build.sheltered_at(bed.global_position):
return "Your bed needs a roof and walls around it"
return ""
## Sleeps until 06:00: the clock, fires, regrowth and weather all move on.
func sleep(p: Node3D) -> void:
var secs: float = fposmod(6.0 - day_night.hours(), 24.0) / 24.0 * day_night.day_length
if hud != null:
hud.fade(func(): _finish_sleep(p, secs))
else:
_finish_sleep(p, secs)
func _finish_sleep(p: Node3D, secs: float) -> void:
skip_time(secs)
p.survival.after_sleep(secs)
save_now()
Actions.notify.emit("You slept until morning")
## Fast-forwards the world (not the player) by `secs`.
func skip_time(secs: float) -> void:
day_night.advance(secs)
day_night.apply(true)
weather.snap()
props.skip_time(secs)
for f in get_tree().get_nodes_in_group("heat"):
if f.has_method("burn"):
f.burn(secs)
func _on_terrain_ready() -> void:
var t0 := Time.get_ticks_msec()
_spawn = terrain.find_spawn(Vector2.ZERO)
@@ -217,7 +311,9 @@ func collect_save() -> Dictionary:
"stats": player.survival.to_data(),
},
"time": {"day": day_night.day, "time": day_night.time},
"spawn": {"bed": has_bed_spawn, "pos": [bed_spawn.x, bed_spawn.y, bed_spawn.z]},
"forage_regrow": regrow,
"regrow": props.regrow.duplicate(),
"harvested": harvested,
"pickups_taken": taken,
"pieces": pieces,
@@ -250,6 +346,14 @@ func apply_save(s: Dictionary) -> void:
for uid in s.get("pickups_taken", []):
if props.pickups.has(uid) and is_instance_valid(props.pickups[uid]):
props.pickups[uid].collected()
# Regrowth timers (older saves have none: everything gone starts a fresh timer).
props.restore_regrow(s.get("regrow", {}))
var sp: Dictionary = s.get("spawn", {})
has_bed_spawn = bool(sp.get("bed", false))
if sp.has("pos"):
bed_spawn = Vector3(sp.pos[0], sp.pos[1], sp.pos[2])
weather.snap()
_loading = true
for e in s.get("pieces", []):
var def: Dictionary = ItemDB.get_item(e.get("item", ""))
if def.is_empty() or not def.has("place"):
@@ -259,6 +363,9 @@ func apply_save(s: Dictionary) -> void:
piece.inventory.load_data(e.inventory)
if e.has("extra") and piece.has_method("load_extra"):
piece.load_extra(e.extra)
_loading = false
build.recompute() # same terrain, same pieces: nothing collapses on load
_update_grass_cuts()
func _respawn() -> void:
player.global_position = _spawn + Vector3(0.0, 1.5, 0.0)
@@ -277,8 +384,11 @@ func _physics_process(_delta: float) -> void:
# --- building ---------------------------------------------------------------
## Whether a piece can go at `xf`: inside the valley, on dry fairly flat
## ground, and not overlapping trees, rocks or other pieces.
## ground (or a floor), and not overlapping trees, rocks or other pieces.
## Building pieces have their own rules (build_system.gd).
func placement_valid(def: Dictionary, xf: Transform3D) -> bool:
if BuildPiece.is_build(def):
return build.check(def, xf).is_empty()
var p := xf.origin
if not terrain.inside_boundary(p.x, p.z, 1.5):
return false
@@ -287,10 +397,12 @@ func placement_valid(def: Dictionary, xf: Transform3D) -> bool:
var fp: Array = def.place.get("footprint", [1.0, 1.0])
var hx := float(fp[0]) * 0.5
var hz := float(fp[1]) * 0.5
# Ground under the corners must be within reach of the base.
# Something solid (ground or a floor) under each corner, close to the base.
var space := get_world_3d().direct_space_state
for c in [Vector3(-hx, 0, -hz), Vector3(hx, 0, -hz), Vector3(-hx, 0, hz), Vector3(hx, 0, hz)]:
var w: Vector3 = xf * c
if absf(terrain.sample_height(w.x, w.z) - p.y) > 0.45:
var rq := PhysicsRayQueryParameters3D.create(w + Vector3(0, 0.45, 0), w - Vector3(0, 0.45, 0), 1, [player.get_rid()])
if space.intersect_ray(rq).is_empty():
return false
# Overlap test against everything solid except the ground itself.
var box := BoxShape3D.new()
@@ -308,8 +420,75 @@ func spawn_piece(def: Dictionary, xf: Transform3D) -> Node3D:
piece.setup(def)
_pieces.add_child(piece)
piece.global_transform = xf
if piece.is_in_group("build") and not _loading:
build.recompute()
if piece.kind == "floor":
_update_grass_cuts()
return piece
## Grass doesn't grow up through floors.
func _update_grass_cuts() -> void:
var xfs: Array = []
for p in _pieces.get_children():
if p.is_in_group("build") and p.kind == "floor" and not p.is_queued_for_deletion():
xfs.append(p.global_transform)
props.set_grass_cuts(xfs, 1.02)
## Takes a piece out of the world (dismantled). Anything that loses its
## support collapses, and its item goes back to `refund_to` if there's room.
## Returns how many pieces collapsed.
func remove_piece(piece: Node3D, refund_to: Node3D = null) -> int:
if piece.item_id == "bed" and spawn_bed() == piece:
has_bed_spawn = false
Actions.notify.emit("Spawn point cleared")
var was_build := piece.is_in_group("build")
_pieces.remove_child(piece)
piece.queue_free()
if not was_build:
return 0
var weak: Array = build.recompute()
for w in weak:
if refund_to != null:
Actions._give(refund_to, w.item_id, 1)
_collapse_fx(w)
_pieces.remove_child(w)
w.queue_free()
if not weak.is_empty():
build.recompute()
Actions.notify.emit("%d piece%s collapsed" % [weak.size(), "" if weak.size() == 1 else "s"])
_settle_loose_pieces()
_update_grass_cuts()
return weak.size()
## A collapsing piece: a copy of its mesh tumbles down and sinks away.
func _collapse_fx(piece: Node3D) -> void:
if DisplayServer.get_name() == "headless":
return
var mi := MeshInstance3D.new()
mi.mesh = piece.get_node("Mesh").mesh
mi.material_override = ItemDB.item_material()
add_child(mi)
mi.global_transform = piece.global_transform
var tw := mi.create_tween().set_parallel(true)
tw.tween_property(mi, "global_position:y", mi.global_position.y - 4.0, 1.2).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
tw.tween_property(mi, "rotation:x", mi.rotation.x + randf_range(-0.8, 0.8), 1.2)
tw.chain().tween_callback(mi.queue_free)
## Chests, fires and beds left hanging after a collapse drop to whatever is
## below them.
func _settle_loose_pieces() -> void:
var space := get_world_3d().direct_space_state
for p in _pieces.get_children():
if p.is_in_group("build") or p.is_queued_for_deletion():
continue
var at: Vector3 = p.global_position
var rq := PhysicsRayQueryParameters3D.create(at + Vector3(0, 0.3, 0), at - Vector3(0, 0.5, 0), 1, [p.get_rid()])
if not space.intersect_ray(rq).is_empty():
continue
rq = PhysicsRayQueryParameters3D.create(at, at - Vector3(0, 80, 0), 1, [p.get_rid(), player.get_rid()])
var hit := space.intersect_ray(rq)
p.global_position.y = hit.position.y if not hit.is_empty() else terrain.sample_height(at.x, at.z)
# --- dev screenshot hook ----------------------------------------------------
func _maybe_capture() -> void:
@@ -415,3 +594,65 @@ func _capture_survival(prefix: String, cam: Camera3D) -> void:
get_viewport().get_texture().get_image().save_png("%s_menu.png" % prefix)
print("[world] saved %s_menu.png" % prefix)
hud._menu.close()
await _capture_weather(prefix, cam)
## Weather views: a snapped-together hut in clear weather, rain, a storm and
## snow, and the view out of its open door in the rain.
func _capture_weather(prefix: String, cam: Camera3D) -> void:
_respawn()
player.set_physics_process(false)
var yaw: float = player.rotation.y
var hb := Basis(Vector3.UP, yaw)
var site: Vector3 = build.debug_find_site(player.global_position - player.global_basis.z * 11.0, 3.0)
var hut: Dictionary = await build.debug_build_hut(site, yaw)
print("[world] demo hut: %d pieces, failed %s" % [hut.placed, hut.failed])
var top: float = hut.pieces[0].global_position.y
spawn_piece(ItemDB.get_item("bed"), Transform3D(hb, site + hb * Vector3(1.0, top - site.y, -0.6)))
var fire_at: Vector3 = site + hb * Vector3(-0.6, 0, 4.5)
fire_at.y = terrain.sample_height(fire_at.x, fire_at.z)
var fire: Node = spawn_piece(ItemDB.get_item("campfire"), Transform3D(hb, fire_at))
fire.fuel = fire.MAX_FUEL
var door: Node = null
for p in hut.pieces:
if p.item_id == "wood_door":
door = p
player.select(4) # wood: nothing lit in hand
var outside_eye: Vector3 = site + hb * Vector3(4.2, 0, 8.5)
outside_eye.y = terrain.sample_height(outside_eye.x, outside_eye.z) + 1.7
var look: Vector3 = site + Vector3(0, 1.4, 0)
day_night.time = 10.5 / 24.0
for shot in [["hut_clear", "clear", 10.5], ["hut_rain", "rain", 10.5], ["hut_storm", "storm", 16.0], ["hut_snow", "snow", 11.0]]:
weather.force(shot[1])
if shot[1] == "snow":
weather.snow_cover = 1.0
weather.ground_wet = 0.0
day_night.time = float(shot[2]) / 24.0
day_night.apply(true)
_aim_camera(cam, outside_eye, look)
await get_tree().create_timer(2.0).timeout
if shot[1] == "storm":
weather.flash = 0.8
day_night.apply(true)
await RenderingServer.frame_post_draw
get_viewport().get_texture().get_image().save_png("%s_%s.png" % [prefix, shot[0]])
print("[world] saved %s_%s.png" % [prefix, shot[0]])
# From inside, out through the open door, in the rain.
weather.force("rain")
weather.snow_cover = 0.0
day_night.time = 17.5 / 24.0
day_night.apply(true)
door.set_open(true, false)
var in_eye: Vector3 = site + hb * Vector3(0.6, top - site.y + 1.6, -1.2)
_aim_camera(cam, in_eye, site + hb * Vector3(1.2, top - site.y + 1.2, 6.0))
await get_tree().create_timer(2.0).timeout
await RenderingServer.frame_post_draw
get_viewport().get_texture().get_image().save_png("%s_hut_inside.png" % prefix)
print("[world] saved %s_hut_inside.png" % prefix)
weather.force("")
func _aim_camera(cam: Camera3D, eye: Vector3, at: Vector3) -> void:
var dir := at - eye
player.global_position = eye - Vector3(0.0, cam.position.y, 0.0)
player.rotation = Vector3(0.0, atan2(-dir.x, -dir.z), 0.0)
cam.rotation.x = atan2(dir.y, Vector2(dir.x, dir.z).length())
props._update_grass(100000)

View File

@@ -5,6 +5,22 @@
uniform sampler2D noise_tex : filter_linear_mipmap, repeat_enable;
// Set every frame by weather_fx.gd (declared in project.godot [shader_globals]).
global uniform float weather_wet; // 0..1 rain-soaked ground
global uniform float weather_snow; // 0..1 settled snow
global uniform float weather_wind; // wind multiplier, ~1 on a calm day
// Wet surfaces: darker and glossier.
vec3 wet_albedo(vec3 c, float wet) {
return c * (1.0 - 0.35 * wet);
}
// Settled snow on upward-facing surfaces, patchy where it's thin.
float snow_mask(float up, float patch) {
float s = weather_snow * 1.25 - (1.0 - patch) * 0.45;
return clamp(s, 0.0, 1.0) * smoothstep(0.55, 0.85, up);
}
float nz(vec2 p) {
return texture(noise_tex, p).r;
}

View File

@@ -18,8 +18,9 @@ void vertex() {
float phase = dot(inst_pos.xz, vec2(0.13, 0.17));
float gust = nz(inst_pos.xz * 0.012 + vec2(TIME * 0.02, TIME * 0.013));
vec3 sway = vec3(sin(TIME * 0.9 + phase), 0.0, cos(TIME * 0.7 + phase * 1.3) * 0.6);
sway *= wind_strength * h * (0.4 + gust);
sway += vec3(sin(TIME * 3.7 + dot(VERTEX, vec3(3.1, 1.7, 2.3)) + phase)) * flutter * clamp(VERTEX.y, 0.0, 1.0);
float wk = min(weather_wind, 2.2); // storms bend crowns, but not into the ground
sway *= wind_strength * wk * h * (0.4 + gust);
sway += vec3(sin(TIME * 3.7 * (0.7 + 0.3 * wk) + dot(VERTEX, vec3(3.1, 1.7, 2.3)) + phase)) * flutter * wk * clamp(VERTEX.y, 0.0, 1.0);
VERTEX += (inverse(MODEL_MATRIX) * vec4(sway, 0.0)).xyz;
}
@@ -27,6 +28,10 @@ void fragment() {
float v = nz(inst_pos.xz * 0.045);
vec3 tint = mix(vec3(0.88, 1.0, 0.85), vec3(1.12, 1.04, 0.78), v);
vec3 col = to_linear(COLOR.rgb) * tint;
// Snow on the upper faces of the crowns.
float up = (INV_VIEW_MATRIX * vec4(NORMAL, 0.0)).y;
col = mix(col, vec3(0.86, 0.89, 0.93), snow_mask(up, v) * 0.9);
col = wet_albedo(col, weather_wet * 0.5);
ALBEDO = col;
ROUGHNESS = 0.85;
SPECULAR = 0.3;

View File

@@ -23,7 +23,7 @@ void vertex() {
vec2 wd = normalize(wind_dir);
float gust = nz(wp.xz * 0.015 - TIME * 0.05 * wd);
float sway = sin(TIME * 1.8 + wp.x * 0.35 + wp.z * 0.25) * 0.35 + gust * 1.3 - 0.4;
float k = wind_strength * hfac * hfac;
float k = wind_strength * weather_wind * hfac * hfac;
vec3 off = vec3(wd.x * sway * k, -abs(sway) * k * 0.3, wd.y * sway * k);
VERTEX += (inverse(MODEL_MATRIX) * vec4(off, 0.0)).xyz;
}
@@ -35,6 +35,10 @@ void fragment() {
base *= 0.82 + 0.36 * nz(inst_pos.xz * 0.17);
vec3 col = base * mix(0.55, 1.0, hfac); // darker at the root
col = mix(col, col * tip_tint * 1.3, smoothstep(0.6, 1.0, hfac) * 0.3);
// Snow weighs down on the blades from the root up; rain darkens them.
float snowy = clamp(weather_snow * 1.3 - hfac * 0.5 + (nz(inst_pos.xz * 0.17) - 0.5) * 0.4, 0.0, 0.85);
col = mix(col, vec3(0.62, 0.65, 0.7), snowy); // greyer than ground snow: blades are lit as if flat
col = wet_albedo(col, weather_wet * 0.6 * (1.0 - snowy));
ALBEDO = col;
ROUGHNESS = 0.9;
SPECULAR = 0.2;

View File

@@ -33,8 +33,13 @@ void fragment() {
float snow = smoothstep(snow_height - 3.0, snow_height + 3.0, w_pos.y + (a - 0.5) * 10.0);
snow *= smoothstep(0.35, 0.7, w_nrm.y);
col = mix(col, snow_col, snow);
float wsnow = snow_mask(w_nrm.y, a);
col = mix(col, snow_col, wsnow);
snow = max(snow, wsnow);
float wet = weather_wet * (1.0 - snow);
col = wet_albedo(col, wet);
ALBEDO = col;
ROUGHNESS = mix(mix(0.8, 0.95, moss), 0.6, snow);
ROUGHNESS = mix(mix(mix(0.8, 0.95, moss), 0.6, snow), 0.3, wet * 0.8);
SPECULAR = 0.35;
}

View File

@@ -66,8 +66,16 @@ void fragment() {
snow_m *= 1.0 - smoothstep(0.5, 0.7, slope);
col = mix(col, snow_col * (0.92 + 0.08 * fine), snow_m);
// Weather: fresh snow settles on flat ground above the waterline; rain
// darkens and glosses everything else.
float wsnow = snow_mask(w_nrm.y, fine * 0.6 + mid * 0.4) * step(water_level + 0.1, w_pos.y);
col = mix(col, snow_col * (0.9 + 0.1 * fine), wsnow);
snow_m = max(snow_m, wsnow);
float wet = weather_wet * (1.0 - snow_m);
col = wet_albedo(col, wet);
ALBEDO = col;
ROUGHNESS = mix(mix(0.95, 0.82, rock_m), 0.6, snow_m);
ROUGHNESS = mix(mix(mix(0.95, 0.82, rock_m), 0.6, snow_m), 0.4, wet * 0.8);
SPECULAR = 0.3;
NORMAL_MAP = texture(normal_tex, w_pos.xz * 0.35).rgb;
NORMAL_MAP_DEPTH = mix(0.5, 1.4, rock_m) * (1.0 - snow_m * 0.7);

21
tests/run_all.sh Executable file
View File

@@ -0,0 +1,21 @@
#!/bin/sh
# Runs every headless test suite. Fails if any suite fails OR prints a script
# error (a script that fails to compile can otherwise let a suite "pass").
# Usage: tests/run_all.sh (from the repo root; needs `godot` on PATH)
cd "$(dirname "$0")/.." || exit 1
status=0
for t in test_survival_core test_survival_stats test_save_load test_weather_building test_terrain_edges test_boundary; do
extra=""
[ "$t" = "test_boundary" ] && extra="--fixed-fps 30"
out=$(godot --headless --path . $extra --script "res://tests/$t.gd" 2>&1)
code=$?
result=$(printf '%s\n' "$out" | grep "RESULT:" | tail -n 1)
if [ $code -ne 0 ] || printf '%s\n' "$out" | grep -q "SCRIPT ERROR"; then
echo "FAIL $t $result"
printf '%s\n' "$out" | grep -E "FAIL|SCRIPT ERROR|at: " | head -n 20
status=1
else
echo "ok $t $result"
fi
done
exit $status

View File

@@ -61,6 +61,7 @@ func _body(world: Node3D, id: String) -> void:
var terrain: Node3D = world.get_node("Terrain")
player.set_physics_process(false) # keep the player put while we fast-forward
sv.set_process(false)
world.weather.force("clear") # weather has its own test (test_weather_building)
print("day / night")
check(dn.day == 1 and absf(dn.hours() - 8.0) < 0.1, "new world starts on day 1 at 08:00 (%s)" % dn.clock_text())

View File

@@ -0,0 +1,382 @@
extends SceneTree
## M1c: weather, shelter, snap building + support + dismantling, doors, beds
## and sleeping, resource regrowth, and saving all of it.
## Run: godot --headless --path . --script res://tests/test_weather_building.gd
var _fails := 0
var _finished := false
var A: Node
var S: Node
var DB: Node
func check(cond: bool, what: String) -> void:
print((" ok " if cond else " FAIL ") + what)
if not cond:
_fails += 1
func _initialize() -> void:
_run.call_deferred()
func _spawn_world() -> Node3D:
var w: Node3D = load("res://scenes/world.tscn").instantiate()
root.add_child(w)
for _i in range(5):
await physics_frame
return w
func _settle() -> void:
for _i in range(3):
await physics_frame
func _run() -> void:
A = root.get_node("Actions")
S = root.get_node("Saves")
DB = root.get_node("ItemDB")
S.root_dir = "user://test_worlds"
for w in S.list_worlds():
S.delete_world(w.id)
var id: String = S.create_world("Huts", 1337)
S.current_id = id
_weather_model()
var world: Node3D = await _spawn_world()
await _body(world)
check(_finished, "test ran to the end (no script errors)")
S.delete_world(id)
DirAccess.remove_absolute(S.root_dir)
print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails])
quit(1 if _fails > 0 else 0)
## The weather model on its own: deterministic and with sensible odds.
func _weather_model() -> void:
print("weather model")
var dn: Node = load("res://scripts/world/day_night.gd").new()
var wa: Node = load("res://scripts/world/weather.gd").new()
var wb: Node = load("res://scripts/world/weather.gd").new()
wa.setup(1337, dn)
wb.setup(1337, dn)
var same := true
var counts := {}
var cold_days := 0
var snow_on_warm_day := false
var days := 60
var t := 0.0
while t < days * 1200.0:
var s: String = wa.state_at(t)
if s != wb.state_at(t):
same = false
counts[s] = counts.get(s, 0) + 1
var day := int(t / 1200.0) + 1
if s in ["snow", "blizzard"] and not wa.is_cold_day(day):
snow_on_warm_day = true
t += 20.0
for d in range(1, days + 1):
if wa.is_cold_day(d):
cold_days += 1
var total := float(days * 60)
var frac := func(names: Array) -> float:
var n := 0
for k in names:
n += counts.get(k, 0)
return n / total
check(same, "same seed and time give the same weather")
check(frac.call(["clear"]) > 0.3 and frac.call(["clear"]) < 0.5, "clear about 40%% of the time (%.0f%%)" % (frac.call(["clear"]) * 100))
var wet: float = frac.call(["rain", "storm", "snow", "blizzard"])
check(wet > 0.2 and wet < 0.4, "rain or snow about 30%% of the time (%.0f%%)" % (wet * 100))
var hard: float = frac.call(["storm", "blizzard"])
check(hard > 0.04 and hard < 0.16, "storms about 10%% of the time (%.0f%%)" % (hard * 100))
check(cold_days >= 8 and cold_days <= 24, "about a quarter of days are cold (%d of %d)" % [cold_days, days])
check(not snow_on_warm_day, "it only snows in the valley on cold days")
check(wa.state_at(0.0) == "clear" and wa.state_at(500.0) == "clear", "a new world starts with clear weather")
var other: Node = load("res://scripts/world/weather.gd").new()
other.setup(99, dn)
var differs := false
t = 0.0
while t < 20 * 1200.0 and not differs:
differs = other.state_at(t) != wa.state_at(t)
t += 60.0
check(differs, "a different seed gives different weather")
check(wa.snows_at(Vector3(0, wa.snowline + 1.0, 0)), "above the snowline it snows")
for n in [dn, wa, wb, other]:
n.free()
func _body(world: Node3D) -> void:
var player: CharacterBody3D = world.get_node("Player")
var sv: Node = player.survival
var dn: Node = world.day_night
var wx: Node = world.weather
var bs: Node = world.build
var inv: Inventory = player.inventory
var terrain: Node3D = world.get_node("Terrain")
var props: Node3D = world.get_node("Props")
player.set_physics_process(false)
sv.set_process(false)
wx.set_process(false)
wx.force("clear")
# A flat-ish spot a little way from the spawn, facing the same way.
var fwd := -player.global_basis.z
var site: Vector3 = bs.debug_find_site(player.global_position + fwd * 12.0, 3.0)
var yaw: float = player.rotation.y
print("snapping")
var floor_def: Dictionary = DB.get_item("wood_floor")
var tb: Node = terrain.get_node("TerrainBody")
var r0: Dictionary = bs.placement(floor_def, {"position": site, "normal": Vector3.UP, "collider": tb}, yaw)
check(r0.ok, "a floor can go on open ground (%s)" % r0.reason)
var f0: Node3D = world.spawn_piece(floor_def, r0.xf)
await _settle()
check(f0.grounded and f0.support == 1.0, "a floor on the ground is fully supported")
var right: Vector3 = f0.global_basis.x
var aim := f0.global_position + right * 1.6
var r1: Dictionary = bs.placement(floor_def, {"position": aim, "normal": Vector3.UP, "collider": f0}, yaw + 0.2)
var off: Vector3 = r1.xf.origin - f0.global_position
check(r1.ok and r1.snapped and off.distance_to(right * 2.0) < 0.01, "the next floor snaps exactly 2 m along (%s)" % off)
check(absf(r1.xf.basis.get_euler().y - f0.global_basis.get_euler().y) < 0.01, "and lines up with the first despite a skewed aim")
var wall_def: Dictionary = DB.get_item("wood_wall")
var aimw := f0.global_position - f0.global_basis.z * 0.9
var rw: Dictionary = bs.placement(wall_def, {"position": aimw, "normal": Vector3.UP, "collider": f0}, yaw)
var edge := f0.global_position - f0.global_basis.z * 1.0
check(rw.ok and rw.xf.origin.distance_to(edge) < 0.01, "a wall snaps onto the floor's edge")
var air := site + Vector3(0, 6, 0)
var ra: Dictionary = bs.placement(floor_def, {"position": air, "normal": Vector3.UP, "collider": null}, yaw)
check(not ra.ok and ra.reason == "Not enough support", "a floor in mid-air has no support (%s)" % ra.reason)
var rblock: Dictionary = bs.placement(floor_def, {"position": f0.global_position, "normal": Vector3.UP, "collider": f0}, yaw)
check(not rblock.ok or rblock.xf.origin.distance_to(f0.global_position) > 1.0, "can't place a floor on top of another one")
world.remove_piece(f0)
await _settle()
print("hut")
var hut: Dictionary = await bs.debug_build_hut(site, yaw)
await _settle()
check(hut.placed == 20 and hut.failed.is_empty(), "built a 20-piece hut by snapping (%d placed, failed: %s, blocker %s)" % [hut.placed, hut.failed, bs.last_blocker])
var weakest := 1.0
for p in hut.pieces:
weakest = minf(weakest, p.support)
check(weakest >= bs.MIN_SUPPORT, "every hut piece is supported (weakest %.2f)" % weakest)
var inside: Vector3 = hut.pieces[0].global_position + hut.pieces[0].global_basis * Vector3(1, 0.05, 1)
var outside: Vector3 = site + fwd * -6.0
outside.y = terrain.sample_height(outside.x, outside.z)
check(bs.roofed_at(inside) and bs.enclosure_at(inside) > 0.99 and bs.sheltered_at(inside), "inside the hut is sheltered")
check(not bs.roofed_at(outside) and not bs.sheltered_at(outside), "outside is not")
print("rain and shelter")
dn.time = 0.5
dn.apply(true)
wx.force("rain")
player.global_position = outside
sv.wet = 0.0
sv._shelter_left = 0.0
sv.step(0.5)
check(sv.wet > 0.0 and "Wet" in sv.statuses, "rain soaks you outside")
player.global_position = inside
sv.wet = 0.0
sv._shelter_left = 0.0
sv.step(0.5)
check(sv.wet == 0.0 and sv.sheltered and "Sheltered" in sv.statuses, "inside the hut you stay dry")
check(world.air_warmth(inside, true) > world.air_warmth(outside, false) + 8.0, "the hut keeps out the weather's chill")
var fire_out: Node = world.spawn_piece(DB.get_item("campfire"), Transform3D(Basis(), outside + right * 3.0))
var fire_in: Node = world.spawn_piece(DB.get_item("campfire"), Transform3D(Basis(), inside + right * 0.2))
check(fire_out.burn_rate() > 2.0 and fire_in.burn_rate() == 1.0, "rain eats an open fire's fuel, not one under a roof (%.1fx)" % fire_out.burn_rate())
sv._rest_build = 0.0
for _i in range(25):
sv.step(1.0)
check(sv.rested > 0.0 and "Rested" in sv.statuses, "resting by a fire in the hut makes you Rested")
world.remove_piece(fire_out)
wx.force("storm")
wx.step(30.0)
check(wx.lightning_count > 0, "storms bring lightning")
wx.force("snow")
player.global_position = outside
sv.wet = 0.0
sv._shelter_left = 0.0
sv.step(0.5)
check(sv.wet == 0.0, "snow makes you cold, not wet")
check(world.air_warmth(outside) < world.air_warmth(inside, true) - 10.0, "snow is colder outside than in the hut")
wx.force("clear")
print("door")
var door: Node = null
for p in hut.pieces:
if p.item_id == "wood_door":
door = p
player.global_position = door.global_position + door.global_basis.z * 1.5
var panel_ray := func() -> bool:
var from: Vector3 = door.global_position + door.global_basis.z * 0.6 + Vector3(0, 1.0, 0)
var q := PhysicsRayQueryParameters3D.create(from, from - door.global_basis.z * 1.2, 1)
return not world.get_world_3d().direct_space_state.intersect_ray(q).is_empty()
check(panel_ray.call(), "a closed door blocks the doorway")
check(A.toggle_door(player, door) and door.open, "E opens the door")
door.set_open(true, false)
await _settle()
check(not panel_ray.call(), "an open door lets you through")
door.set_open(false, false)
await _settle()
print("support and collapse")
# A lone wall on a floor, with floors stacked off its top as an overhang:
# they reach out a few pieces, then run out of support.
var s2: Vector3 = bs.debug_find_site(site + right * 16.0, 6.0) - fwd * 4.0
s2.y = terrain.sample_height(s2.x, s2.z)
var rg: Dictionary = bs.placement(floor_def, {"position": s2, "normal": Vector3.UP, "collider": tb}, yaw)
var g: Node3D = world.spawn_piece(floor_def, rg.xf)
await physics_frame
var rtw: Dictionary = bs.placement(wall_def, {"position": g.global_position - g.global_basis.z * 0.9, "normal": Vector3.UP, "collider": g}, yaw)
check(rg.ok and rtw.ok, "a test wall on its own floor (%s %s)" % [rg.reason, rtw.reason])
var tw: Node3D = world.spawn_piece(wall_def, rtw.xf)
await physics_frame
var out_dir: Vector3 = -tw.global_basis.z
var chain: Array = []
var top: Vector3 = tw.global_position + Vector3(0, 2, 0)
var stopped := -1
var stop_reason := ""
for i in range(8):
var rc: Dictionary = bs.placement(floor_def, {"position": top + out_dir * (1.0 + 2.0 * i), "normal": Vector3.UP, "collider": tw}, yaw)
if not rc.ok:
stopped = i
stop_reason = rc.reason
break
chain.append(world.spawn_piece(floor_def, rc.xf))
await physics_frame
check(stopped >= 2 and stopped <= 5 and stop_reason == "Not enough support", "an overhang reaches %d floors before it runs out of support (%s %s)" % [stopped, stop_reason, bs.last_blocker])
if chain.size() >= 2:
check(chain[-1].support < chain[0].support, "support falls off along the overhang (%.2f -> %.2f)" % [chain[0].support, chain[-1].support])
inv.load_data(Inventory.new(inv.size()).to_data())
player.global_position = tw.global_position + out_dir * -1.5
var n_chain := chain.size()
check(A.dismantle(player, tw), "X dismantles a wall")
await _settle()
var gone := 0
for p in chain:
if not is_instance_valid(p) or p.get_parent() == null:
gone += 1
check(gone == n_chain and n_chain > 0, "the overhang it held up collapses (%d of %d)" % [gone, n_chain])
check(inv.count("wood_wall") == 1 and inv.count("wood_floor") == n_chain, "dismantled and collapsed pieces come back to you")
world.remove_piece(g)
# Taking one wall out of the hut: the roof is tied in by the other walls,
# the ridge and the gables, so nothing falls.
var hut_wall: Node3D = hut.pieces[4] # north wall, west half (see debug_build_hut)
var hut_floor: Node3D = hut.pieces[0]
player.global_position = inside
check(hut_wall.item_id == "wood_wall" and A.dismantle(player, hut_wall), "dismantled one hut wall")
await _settle()
var roofs_left := 0
for p in world.get_node("Pieces").get_children():
if p.item_id == "wood_roof":
roofs_left += 1
check(roofs_left == 4, "the roof still stands (%d of 4 roof pieces)" % roofs_left)
_hold(player, "wood_wall")
var aimb: Vector3 = hut_floor.global_position - hut_floor.global_basis.z * 0.9
var rb: Dictionary = bs.placement(wall_def, {"position": aimb, "normal": Vector3.UP, "collider": hut_floor}, yaw)
check(rb.ok and A.place(player, 0, rb.xf, world), "and it goes back where it was (%s)" % rb.reason)
await _settle()
check(bs.sheltered_at(inside), "the hut is whole again")
world.remove_piece(fire_in)
var chest: Node = world.spawn_piece(DB.get_item("chest"), Transform3D(Basis(Vector3.UP, yaw), inside - right * 1.0))
chest.inventory.add("stone", 3)
check(not A.dismantle(player, chest), "can't dismantle a chest with things in it")
print("bed and sleep")
inv.add("bed", 1)
_hold(player, "bed")
var bed_xf := Transform3D(Basis(Vector3.UP, yaw), inside + right * 0.9 - Vector3(0, 0.05, 0))
check(world.placement_valid(DB.get_item("bed"), bed_xf), "a bed fits on the hut floor")
A.place(player, 0, bed_xf, world)
var bed: Node = null
for p in world.get_node("Pieces").get_children():
if p.item_id == "bed":
bed = p
player.global_position = bed.global_position + right * 1.0
check(A.use_bed(player, bed) and world.has_bed_spawn and bed.is_spawn(), "E on a bed sets your spawn point")
dn.time = 14.0 / 24.0
check(not A.use_bed(player, bed), "can't sleep in the afternoon")
dn.time = 22.0 / 24.0
var day0: int = dn.day
var hunger0: float = sv.hunger
check(A.use_bed(player, bed), "can sleep at 22:00 in a sheltered bed")
check(dn.day == day0 + 1 and absf(dn.hours() - 6.0) < 0.05, "woke at 06:00 the next day (%s)" % dn.clock_text())
check(sv.rested > 0.0 and sv.hunger < hunger0 and sv.hunger > hunger0 - 15.0, "rested, and a little hungrier (%.0f -> %.0f)" % [hunger0, sv.hunger])
sv.hunger = 0.0
sv.health = 0.3
player.global_position = outside
sv.step(2.0)
check(player.global_position.distance_to(bed.global_position) < 2.0, "after dying you wake at your bed")
print("regrowth")
var tree: Node = null
for uid in props.resource_nodes:
var n = props.resource_nodes[uid]
if n.kind == "tree" and not n.depleted and n.global_position.distance_to(player.global_position) > 15.0:
tree = n
break
inv.add("stone_axe", 1)
_hold(player, "stone_axe")
player.global_position = tree.global_position + Vector3(1.5, 0, 0)
var hits := 0
while not tree.depleted and hits < 20:
A.harvest_hit(player, tree, tree.global_position + Vector3(0, 1, 0))
sv.stamina = 100.0
hits += 1
check(tree.depleted and props.regrow.has(tree.uid), "a felled tree starts a regrowth timer (%.0f s)" % props.regrow.get(tree.uid, 0.0))
var pk: Node = null
for uid in props.pickups:
if not props.pickups[uid].taken:
pk = props.pickups[uid]
break
player.global_position = pk.global_position + Vector3(0.5, 0, 0)
A.pickup(player, pk)
check(pk.taken and props.regrow.has(pk.uid), "a picked-up item starts one too")
props.skip_time(1000.0)
check(pk.taken and props.regrow.has(pk.uid), "with you standing on the spot, the item waits")
player.global_position = outside + Vector3(0, 0, 40)
props.skip_time(2500.0)
check(not tree.depleted and tree.is_in_group("harvestable") and tree.collision_layer == 1, "the tree grows back after two days")
check(not pk.taken and pk.visible, "the item comes back")
print("save + load")
tree.restore(0.0)
props.regrow[tree.uid] = 777.0
var tree_uid: String = tree.uid
door.set_open(true, false)
var n_pieces := world.get_node("Pieces").get_child_count()
var bed_pos: Vector3 = bed.global_position
world.save_now()
world.queue_free()
await process_frame
await process_frame
world = await _spawn_world()
player = world.get_node("Player")
props = world.get_node("Props")
await _settle()
check(world.get_node("Pieces").get_child_count() == n_pieces, "all %d pieces restored" % n_pieces)
var weakest2 := 1.0
var door2: Node = null
for p in world.get_node("Pieces").get_children():
if p.is_in_group("build"):
weakest2 = minf(weakest2, p.support)
if p.item_id == "wood_door":
door2 = p
check(weakest2 >= world.build.MIN_SUPPORT, "nothing collapses on load (weakest %.2f)" % weakest2)
check(door2 != null and door2.open, "the open door is still open")
check(world.has_bed_spawn and world.bed_spawn.distance_to(bed_pos) < 0.05, "spawn point restored")
check(absf(float(props.regrow.get(tree_uid, 0.0)) - 777.0) < 2.0, "regrowth timers restored")
check(world.build.sheltered_at(inside), "the restored hut still shelters you")
# Older saves had no regrowth timers: anything gone starts a fresh one.
var state: Dictionary = S.load_state(S.current_id)
state.erase("regrow")
props.regrow.clear()
props.restore_regrow(state.get("regrow", {}))
check(props.regrow.has(tree_uid) and props.regrow[tree_uid] == props.REGROW_SECONDS["tree"], "old saves: gone things get a full timer")
world.queue_free()
_finished = true
func _hold(player: Node, id: String) -> void:
var inv: Inventory = player.inventory
for i in range(inv.size()):
var s = inv.get_slot(i)
if s != null and s.id == id:
var tmp = inv.get_slot(0)
inv.set_slot(0, s)
inv.set_slot(i, tmp)
break
player.select(0)