diff --git a/data/items.json b/data/items.json index 07c6e88..8a3caa0 100644 --- a/data/items.json +++ b/data/items.json @@ -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 + ] + } } ] } diff --git a/data/recipes.json b/data/recipes.json index ca13359..efd173e 100644 --- a/data/recipes.json +++ b/data/recipes.json @@ -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 + } } ] } diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 05c988b..b2d0290 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 diff --git a/docs/SETUP_ON_NEW_MACHINE.md b/docs/SETUP_ON_NEW_MACHINE.md index a1b8622..adc9e0c 100644 --- a/docs/SETUP_ON_NEW_MACHINE.md +++ b/docs/SETUP_ON_NEW_MACHINE.md @@ -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 diff --git a/docs/SURVIVAL_DESIGN.md b/docs/SURVIVAL_DESIGN.md index 08db7a8..78bf7e2 100644 --- a/docs/SURVIVAL_DESIGN.md +++ b/docs/SURVIVAL_DESIGN.md @@ -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) diff --git a/project.godot b/project.godot index 3384871..697aaf2 100644 --- a/project.godot +++ b/project.godot @@ -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 diff --git a/scenes/world.tscn b/scenes/world.tscn index 5f481c8..9423c6c 100644 --- a/scenes/world.tscn +++ b/scenes/world.tscn @@ -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") diff --git a/scripts/building/bed.gd b/scripts/building/bed.gd new file mode 100644 index 0000000..93790a0 --- /dev/null +++ b/scripts/building/bed.gd @@ -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) diff --git a/scripts/building/build_piece.gd b/scripts/building/build_piece.gd new file mode 100644 index 0000000..fa53a69 --- /dev/null +++ b/scripts/building/build_piece.gd @@ -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() diff --git a/scripts/building/build_system.gd b/scripts/building/build_system.gd new file mode 100644 index 0000000..01d33a8 --- /dev/null +++ b/scripts/building/build_system.gd @@ -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 diff --git a/scripts/building/campfire.gd b/scripts/building/campfire.gd index 751451a..85e32de 100644 --- a/scripts/building/campfire.gd +++ b/scripts/building/campfire.gd @@ -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) diff --git a/scripts/building/chest.gd b/scripts/building/chest.gd index 8901a06..cacdfa9 100644 --- a/scripts/building/chest.gd +++ b/scripts/building/chest.gd @@ -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: diff --git a/scripts/building/door.gd b/scripts/building/door.gd new file mode 100644 index 0000000..1df2407 --- /dev/null +++ b/scripts/building/door.gd @@ -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) diff --git a/scripts/building/piece.gd b/scripts/building/piece.gd index 2960bbd..ae1aff6 100644 --- a/scripts/building/piece.gd +++ b/scripts/building/piece.gd @@ -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 "" diff --git a/scripts/core/actions.gd b/scripts/core/actions.gd index a5eba4c..9fa8b4f 100644 --- a/scripts/core/actions.gd +++ b/scripts/core/actions.gd @@ -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 diff --git a/scripts/items/item_meshes.gd b/scripts/items/item_meshes.gd index 0d3a2b9..dace5ae 100644 --- a/scripts/items/item_meshes.gd +++ b/scripts/items/item_meshes.gd @@ -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) diff --git a/scripts/player/hands.gd b/scripts/player/hands.gd index f7d144e..ec342cc 100644 --- a/scripts/player/hands.gd +++ b/scripts/player/hands.gd @@ -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"): diff --git a/scripts/player/survival.gd b/scripts/player/survival.gd index 1ca1e6a..773d3cb 100644 --- a/scripts/player/survival.gd +++ b/scripts/player/survival.gd @@ -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() diff --git a/scripts/ui/hud.gd b/scripts/ui/hud.gd index c468998..a7291b1 100644 --- a/scripts/ui/hud.gd +++ b/scripts/ui/hud.gd @@ -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: diff --git a/scripts/ui/inventory_screen.gd b/scripts/ui/inventory_screen.gd index 87f4155..0ede22e 100644 --- a/scripts/ui/inventory_screen.gd +++ b/scripts/ui/inventory_screen.gd @@ -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() diff --git a/scripts/ui/pause_menu.gd b/scripts/ui/pause_menu.gd index ac5257a..cc572e6 100644 --- a/scripts/ui/pause_menu.gd +++ b/scripts/ui/pause_menu.gd @@ -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)"], ] diff --git a/scripts/world/day_night.gd b/scripts/world/day_night.gd index 477781a..acf9751 100644 --- a/scripts/world/day_night.gd +++ b/scripts/world/day_night.gd @@ -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. diff --git a/scripts/world/pickup.gd b/scripts/world/pickup.gd index 82732f4..6f6ab09 100644 --- a/scripts/world/pickup.gd +++ b/scripts/world/pickup.gd @@ -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 diff --git a/scripts/world/props.gd b/scripts/world/props.gd index 80d9c90..b33d791 100644 --- a/scripts/world/props.gd +++ b/scripts/world/props.gd @@ -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 diff --git a/scripts/world/resource_node.gd b/scripts/world/resource_node.gd index 85da25a..8538d06 100644 --- a/scripts/world/resource_node.gd +++ b/scripts/world/resource_node.gd @@ -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 diff --git a/scripts/world/weather.gd b/scripts/world/weather.gd new file mode 100644 index 0000000..06efdbb --- /dev/null +++ b/scripts/world/weather.gd @@ -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 diff --git a/scripts/world/weather_fx.gd b/scripts/world/weather_fx.gd new file mode 100644 index 0000000..f0ce735 --- /dev/null +++ b/scripts/world/weather_fx.gd @@ -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) diff --git a/scripts/world/world.gd b/scripts/world/world.gd index 100df0d..66c13ee 100644 --- a/scripts/world/world.gd +++ b/scripts/world/world.gd @@ -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) diff --git a/shaders/common.gdshaderinc b/shaders/common.gdshaderinc index b229e38..ccbcdb2 100644 --- a/shaders/common.gdshaderinc +++ b/shaders/common.gdshaderinc @@ -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; } diff --git a/shaders/foliage.gdshader b/shaders/foliage.gdshader index 55a8fd6..71058d1 100644 --- a/shaders/foliage.gdshader +++ b/shaders/foliage.gdshader @@ -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; diff --git a/shaders/grass.gdshader b/shaders/grass.gdshader index 00cb8ed..d90afff 100644 --- a/shaders/grass.gdshader +++ b/shaders/grass.gdshader @@ -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; diff --git a/shaders/rock.gdshader b/shaders/rock.gdshader index b6015b1..327832b 100644 --- a/shaders/rock.gdshader +++ b/shaders/rock.gdshader @@ -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; } diff --git a/shaders/terrain.gdshader b/shaders/terrain.gdshader index e6e6e03..957d27b 100644 --- a/shaders/terrain.gdshader +++ b/shaders/terrain.gdshader @@ -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); diff --git a/tests/run_all.sh b/tests/run_all.sh new file mode 100755 index 0000000..7223c03 --- /dev/null +++ b/tests/run_all.sh @@ -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 diff --git a/tests/test_survival_stats.gd b/tests/test_survival_stats.gd index 553fa64..79aba8c 100644 --- a/tests/test_survival_stats.gd +++ b/tests/test_survival_stats.gd @@ -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()) diff --git a/tests/test_weather_building.gd b/tests/test_weather_building.gd new file mode 100644 index 0000000..9e2abb1 --- /dev/null +++ b/tests/test_weather_building.gd @@ -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)