diff --git a/data/items.json b/data/items.json index 50332cb..f047416 100644 --- a/data/items.json +++ b/data/items.json @@ -576,7 +576,7 @@ "name": "Iron Nails", "category": "resource", "stack": 100, - "description": "For sturdier building (stairs, windows, palisades: coming soon).", + "description": "For sturdier wooden building: stairs, windows, a palisade and its gate.", "mesh": "nails" }, { @@ -719,6 +719,182 @@ 1.2 ] } + }, + { + "id": "cut_stone", + "name": "Cut Stone", + "category": "resource", + "stack": 50, + "description": "A squared block of stone, cut at a stonecutter. Builds stone pieces.", + "mesh": "cut_stone" + }, + { + "id": "stonecutter", + "name": "Stonecutter", + "category": "placeable", + "stack": 1, + "description": "A saw bench for dressing stone: cuts stone into blocks and makes the stone building pieces.", + "mesh": "stonecutter", + "place": { + "piece": "stonecutter", + "footprint": [ + 1.8, + 1.0 + ] + } + }, + { + "id": "stone_floor", + "name": "Stone Floor", + "category": "building", + "stack": 20, + "description": "2 x 2 m of flagstones. Stone stacks tall but can't stand on wood.", + "mesh": "stone_floor", + "place": { + "piece": "build", + "kind": "floor", + "material": "stone" + } + }, + { + "id": "stone_wall", + "name": "Stone Wall", + "category": "building", + "stack": 20, + "description": "2 x 2 m. Loses little support going up: build towers. Wood can sit on it, not under it.", + "mesh": "stone_wall", + "place": { + "piece": "build", + "kind": "wall", + "material": "stone" + } + }, + { + "id": "stone_pillar", + "name": "Stone Pillar", + "category": "building", + "stack": 20, + "description": "A post for a floor's corner or edge. Carries what's on top straight to the ground.", + "mesh": "stone_pillar", + "place": { + "piece": "build", + "kind": "pillar", + "material": "stone" + } + }, + { + "id": "stone_stairs", + "name": "Stone Stairs", + "category": "building", + "stack": 20, + "description": "Climbs 2 m (one storey) over 4 m. From a floor's edge or the ground.", + "mesh": "stone_stairs", + "place": { + "piece": "build", + "kind": "stairs", + "material": "stone" + } + }, + { + "id": "stone_window", + "name": "Stone Window", + "category": "building", + "stack": 20, + "description": "A stone wall with a window in it. Still keeps the weather out.", + "mesh": "stone_window", + "place": { + "piece": "build", + "kind": "window", + "material": "stone" + } + }, + { + "id": "stone_doorway", + "name": "Stone Doorway", + "category": "building", + "stack": 10, + "description": "An arch in a stone wall, with a wooden door. E opens and closes it.", + "mesh": "stone_doorway", + "place": { + "piece": "door", + "kind": "door", + "material": "stone" + } + }, + { + "id": "stone_hearth", + "name": "Stone Hearth", + "category": "placeable", + "stack": 1, + "description": "An indoor fire with a chimney: warms a bigger room than a campfire, never rained out, cooks.", + "mesh": "stone_hearth", + "place": { + "piece": "hearth", + "footprint": [ + 1.6, + 1.2 + ] + } + }, + { + "id": "wood_stairs", + "name": "Wood Stairs", + "category": "building", + "stack": 20, + "description": "Climbs 2 m (one storey) over 4 m. From a floor's edge or the ground.", + "mesh": "wood_stairs", + "place": { + "piece": "build", + "kind": "stairs" + } + }, + { + "id": "wood_window", + "name": "Wood Window", + "category": "building", + "stack": 20, + "description": "A wall with a shuttered window. Lets light in, still sheltered.", + "mesh": "wood_window", + "place": { + "piece": "build", + "kind": "window" + } + }, + { + "id": "wood_pillar", + "name": "Wood Post", + "category": "building", + "stack": 20, + "description": "A post for a floor's corner or edge. Carries what's on top down to the ground.", + "mesh": "wood_pillar", + "place": { + "piece": "build", + "kind": "pillar" + } + }, + { + "id": "palisade", + "name": "Palisade", + "category": "building", + "stack": 20, + "description": "2 m of sharpened logs, 3 m tall. A ring of them keeps wolves out.", + "mesh": "palisade", + "place": { + "piece": "build", + "kind": "palisade" + } + }, + { + "id": "palisade_gate", + "name": "Palisade Gate", + "category": "building", + "stack": 10, + "description": "A gate for your palisade. E opens and closes it.", + "mesh": "palisade_gate", + "place": { + "piece": "door", + "kind": "door" + } } ] } diff --git a/data/recipes.json b/data/recipes.json index 0100334..677cc93 100644 --- a/data/recipes.json +++ b/data/recipes.json @@ -349,6 +349,153 @@ "inputs": { "iron_bar": 1 } + }, + { + "id": "stonecutter", + "output": "stonecutter", + "count": 1, + "station": "workbench", + "category": "stations", + "inputs": { + "wood": 8, + "iron_bar": 4, + "stone": 10 + } + }, + { + "id": "cut_stone", + "output": "cut_stone", + "count": 1, + "station": "stonecutter", + "category": "building", + "inputs": { + "stone": 2 + } + }, + { + "id": "stone_floor", + "output": "stone_floor", + "count": 1, + "station": "stonecutter", + "category": "building", + "inputs": { + "cut_stone": 4 + } + }, + { + "id": "stone_wall", + "output": "stone_wall", + "count": 1, + "station": "stonecutter", + "category": "building", + "inputs": { + "cut_stone": 6 + } + }, + { + "id": "stone_pillar", + "output": "stone_pillar", + "count": 1, + "station": "stonecutter", + "category": "building", + "inputs": { + "cut_stone": 3 + } + }, + { + "id": "stone_stairs", + "output": "stone_stairs", + "count": 1, + "station": "stonecutter", + "category": "building", + "inputs": { + "cut_stone": 4 + } + }, + { + "id": "stone_window", + "output": "stone_window", + "count": 1, + "station": "stonecutter", + "category": "building", + "inputs": { + "cut_stone": 5 + } + }, + { + "id": "stone_doorway", + "output": "stone_doorway", + "count": 1, + "station": "stonecutter", + "category": "building", + "inputs": { + "cut_stone": 6, + "wood": 4 + } + }, + { + "id": "stone_hearth", + "output": "stone_hearth", + "count": 1, + "station": "stonecutter", + "category": "stations", + "inputs": { + "cut_stone": 10 + } + }, + { + "id": "wood_stairs", + "output": "wood_stairs", + "count": 1, + "station": "workbench", + "category": "building", + "inputs": { + "wood": 4, + "nails": 4 + } + }, + { + "id": "wood_window", + "output": "wood_window", + "count": 1, + "station": "workbench", + "category": "building", + "inputs": { + "wood": 2, + "nails": 2 + } + }, + { + "id": "wood_pillar", + "output": "wood_pillar", + "count": 1, + "station": "workbench", + "category": "building", + "inputs": { + "wood": 2 + } + }, + { + "id": "palisade", + "output": "palisade", + "count": 1, + "station": "workbench", + "category": "building", + "inputs": { + "wood": 4, + "nails": 2 + } + }, + { + "id": "palisade_gate", + "output": "palisade_gate", + "count": 1, + "station": "workbench", + "category": "building", + "inputs": { + "wood": 6, + "nails": 4 + } } ] } diff --git a/docs/PROGRESSION_DESIGN.md b/docs/PROGRESSION_DESIGN.md index e58f61f..08cd13c 100644 --- a/docs/PROGRESSION_DESIGN.md +++ b/docs/PROGRESSION_DESIGN.md @@ -1,6 +1,6 @@ # Progression and challenges design -Status: **P1 (iron) built**; P2-P5 are proposals. +Status: **P1 (iron) and P2 (stone) built**; P3-P5 are proposals. *As built in P1:* - Iron nuggets (70, in the scree under the rim), clay (60, on the lake @@ -20,6 +20,23 @@ Status: **P1 (iron) built**; P2-P5 are proposals. - Not yet: the cooking pot and stews, the iron-banded door (with P3, when something can break doors), iron armour. +*As built in P2:* +- Stonecutter (workbench: 8 wood, 4 iron, 10 stone) cuts 2 stone into a + block of cut stone. No granite outcrops yet: the stonecutter is the only + source. +- At the stonecutter: stone floor (4), wall (6), pillar (3), stairs (4), + window (5), doorway with a wooden door (6 + 4 wood), hearth (10). +- At the workbench: wooden stairs, window and palisade (with nails), a + wooden post, a palisade gate. +- Support: stone loses 5% going up (wood 10%); wood can't hold stone up + ("Stone needs stone or the ground under it"); through a pillar any step + up or down counts as vertical, so a post under a span carries it. +- Hearth: a campfire (fuel, cooking, wolves) with 1.5x the heat radius that + rain never speeds up. A sheltered room with mostly stone walls is 6 + warmer. Shelter rays now run 0.6 m up, under window sills, so a room with + windows still counts. +- Palisade: 3 m tall, blocks like a wall online too. No fences. + Today the island runs out of goals after a few days: once you have stone tools, a hut, a bow and a fire, the only threats are wolves at night, the cold and your diet. This plan adds a **second tier of materials** (iron and diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 2cd903f..0d851a9 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -38,6 +38,7 @@ Detailed plan in `docs/SURVIVAL_DESIGN.md` (phases M1a–M1d). - [ ] Hide armour, more recipes, sea fishing *(later M1 polish)* - [x] Wild flax (fibre plants) *(polish)* - [x] Iron: nuggets, ore, clay, kiln, smelter, forge, iron tools and arrows, lantern, tool wear and repair *(P1)* +- [x] Stone building: stonecutter, cut stone, stone floors, walls, pillars, stairs, windows, doorways, hearth; wooden stairs, windows, posts, palisade and gate *(P2)* - [ ] **Iron, stone building and new adversaries**: iron nuggets and ore, smelting and a forge, iron tools and durability, cut-stone building, an alpha wolf, a bear, a barrow with guards, spoilage and blizzards diff --git a/scripts/building/build_piece.gd b/scripts/building/build_piece.gd index fa53a69..f51d477 100644 --- a/scripts/building/build_piece.gd +++ b/scripts/building/build_piece.gd @@ -61,6 +61,40 @@ static func _define() -> Dictionary: "snaps": [Vector3(-1, 0, 0), Vector3(0, 0, 0), Vector3(1, 0, 0), Vector3(1, 1, 0)], "center": Vector3(0.33, 0.33, 0), } + # --- P2 (docs/PROGRESSION_DESIGN.md §3) --- + # Pillar: a 0.4 m post standing on a floor corner or edge, carrying what's + # on top (a floor's corner meets its top). + # (The collider stops 0.2 m short of the top: that's where a floor resting + # on it has its slab, and the overlap check would count it as in the way.) + k["pillar"] = { + "shapes": [_box(Vector3(0, 0.9, 0), Vector3(0.4, 1.8, 0.4))], + "snaps": [Vector3(0, 0, 0), Vector3(0, 2, 0)], + "center": Vector3(0, 1, 0), + } + # Stairs: 2 m wide, climbing 2 m over 4 m toward -Z (the roof's slope): + # low edge on a floor's edge (or the ground), top edge at the next floor + # up. One smooth ramp to walk on; the steps are in the mesh. + var sn := Vector3(0, cos(ROOF_ANGLE), sin(ROOF_ANGLE)) + k["stairs"] = { + "shapes": [{"type": "box", "center": Vector3(0, 1, -2) - sn * 0.1, + "size": Vector3(2, 0.2, sqrt(20.0)), "basis": Basis(Vector3.RIGHT, ROOF_ANGLE)}], + "snaps": [Vector3(-1, 0, 0), Vector3(0, 0, 0), Vector3(1, 0, 0), + Vector3(-1, 2, -4), Vector3(0, 2, -4), Vector3(1, 2, -4)], + "center": Vector3(0, 1, -2), + } + # Window: a wall with a 1 x 0.7 m opening, 1 m up. + k["window"] = { + "shapes": [_box(Vector3(0, 0.5, 0), Vector3(2, 1, 0.2)), _box(Vector3(0, 1.85, 0), Vector3(2, 0.3, 0.2)), + _box(Vector3(-0.75, 1.35, 0), Vector3(0.5, 0.7, 0.2)), _box(Vector3(0.75, 1.35, 0), Vector3(0.5, 0.7, 0.2))], + "snaps": wall_snaps, + "center": Vector3(0, 1, 0), + } + # Palisade: 2 m of sharpened logs, 3 m tall, for a ring round your camp. + k["palisade"] = { + "shapes": [_box(Vector3(0, 1.5, 0), Vector3(2, 3, 0.36))], + "snaps": [Vector3(-1, 0, 0), Vector3(0, 0, 0), Vector3(1, 0, 0)], + "center": Vector3(0, 1.5, 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.) @@ -109,8 +143,16 @@ static func make_shape(s: Dictionary, margin: float = 0.0) -> Array: static func is_build(def: Dictionary) -> bool: return def.has("place") and def.place.has("kind") +## "wood" or "stone" (items.json place.material). Stone loses less support +## going up and can't stand on wood (build_system.gd). +var material := "wood" + +static func material_of(d: Dictionary) -> String: + return String(d.get("place", {}).get("material", "wood")) + func _make_collision(_mi: MeshInstance3D) -> void: kind = def.place.kind + material = material_of(def) collision_layer = 1 | BUILD_LAYER add_to_group("build") for s in kinds()[kind].shapes: diff --git a/scripts/building/build_system.gd b/scripts/building/build_system.gd index 01d33a8..4ed6363 100644 --- a/scripts/building/build_system.gd +++ b/scripts/building/build_system.gd @@ -94,21 +94,35 @@ func _grounded(points: Array) -> bool: return true return false -static func _loss(from_center: Vector3, to_center: Vector3) -> float: +const STONE_VERTICAL_LOSS := 0.05 ## stone stacks tall (P2) + +## Support lost passing from a piece centred at `from_center` to one of +## `material` centred at `to_center`. Through a pillar (`via_pillar`: either +## piece is one) any real step up or down counts as vertical: a pillar under a +## floor's corner carries it straight down to the ground. +static func _loss(from_center: Vector3, to_center: Vector3, material: String = "wood", via_pillar: bool = false) -> float: var d := to_center - from_center var horiz := Vector2(d.x, d.z).length() - return VERTICAL_LOSS if absf(d.y) >= horiz * 0.99 else HORIZONTAL_LOSS + if absf(d.y) >= horiz * 0.99 or (via_pillar and absf(d.y) > 0.5): + return STONE_VERTICAL_LOSS if material == "stone" else VERTICAL_LOSS + return HORIZONTAL_LOSS -## Support a piece of `kind` would have at `xf`, given the current structure. -func support_for(kind: String, xf: Transform3D, exclude: Node = null) -> float: +## Wood can't hold stone up (stone on wood has no support from it). +static func _carries(below: Node, material: String) -> bool: + return not (material == "stone" and below.material == "wood") + +## Support a piece of `kind` (and `material`) would have at `xf`. +func support_for(kind: String, xf: Transform3D, exclude: Node = null, material: String = "wood") -> float: var pts := _world_snaps(kind, xf) if _grounded(pts): return 1.0 var center: Vector3 = xf * BuildPiece.kinds()[kind].center var best := 0.0 for n in _touching(pts, exclude): + if not _carries(n, material): + continue var nc: Vector3 = n.global_transform * BuildPiece.kinds()[n.kind].center - best = maxf(best, n.support * (1.0 - _loss(nc, center))) + best = maxf(best, n.support * (1.0 - _loss(nc, center, material, kind == "pillar" or n.kind == "pillar"))) return best ## Recomputes every piece's support from the ground up. Returns the pieces now @@ -132,7 +146,8 @@ func recompute() -> Array: continue var best := 0.0 for n in nbrs[p]: - best = maxf(best, n.support * (1.0 - _loss(centers[n], centers[p]))) + if _carries(n, p.material): + best = maxf(best, n.support * (1.0 - _loss(centers[n], centers[p], p.material, p.kind == "pillar" or n.kind == "pillar"))) if best > p.support + 0.0001: p.support = best changed = true @@ -170,10 +185,19 @@ func check(def: Dictionary, xf: Transform3D, exclude: Node = null) -> String: if not hits.is_empty(): last_blocker = hits[0].collider return "Something's in the way" - if support_for(kind, xf, exclude) < MIN_SUPPORT: + var mat := BuildPiece.material_of(def) + if support_for(kind, xf, exclude, mat) < MIN_SUPPORT: + if mat == "stone" and _rests_on_wood(kind, xf, exclude): + return "Stone needs stone or the ground under it (wood can't hold it)" return "Not enough support" return "" +func _rests_on_wood(kind: String, xf: Transform3D, exclude: Node) -> bool: + for n in _touching(_world_snaps(kind, xf), exclude): + if n.material == "wood": + return true + return false + ## Where a building piece goes when the player aims at `hit` (a ray result ## dictionary: position, normal, collider). `yaw` is the player's preferred ## rotation. Returns {xf, ok, reason, snapped}. @@ -246,6 +270,8 @@ func _free_xf(kind: String, hp: Vector3, yaw: float, on_terrain: bool) -> Transf match kind: "floor": probe = [Vector3(-1, 0, -1), Vector3(1, 0, -1), Vector3(-1, 0, 1), Vector3(1, 0, 1), Vector3.ZERO] + "pillar": + probe = [Vector3.ZERO] _: probe = [Vector3(-1, 0, 0), Vector3.ZERO, Vector3(1, 0, 0)] var hs: Array[float] = [] @@ -271,9 +297,13 @@ func roofed_at(pos: Vector3) -> bool: hits += 1 return hits >= 3 +## Height of the sideways shelter rays: under a window's sill (1 m), so a +## room with windows still counts as walled in. +const WALL_RAY_Y := 0.6 + ## 0..1 fraction of 8 horizontal directions blocked by walls within 8 m. func enclosure_at(pos: Vector3) -> float: - var from := pos + Vector3(0, 1.2, 0) + var from := pos + Vector3(0, WALL_RAY_Y, 0) var hits := 0 for i in range(8): var a := TAU * i / 8.0 @@ -284,6 +314,25 @@ func enclosure_at(pos: Vector3) -> float: func sheltered_at(pos: Vector3) -> bool: return roofed_at(pos) and enclosure_at(pos) >= 0.74 +## 0..1 fraction of 8 horizontal directions whose nearest wall (within 8 m) +## is stone. A stone room keeps you a little warmer (survival.gd). +func stone_walls_at(pos: Vector3) -> float: + var from := pos + Vector3(0, WALL_RAY_Y, 0) + var space := world.get_world_3d().direct_space_state + var hits := 0 + for i in range(8): + var a := TAU * i / 8.0 + var q := PhysicsRayQueryParameters3D.create(from, from + Vector3(cos(a), 0, sin(a)) * 8.0, BuildPiece.BUILD_LAYER) + var hit := space.intersect_ray(q) + if hit.is_empty(): + continue + var c: Object = hit.collider + if c != null and c.has_meta("owner_piece"): + c = c.get_meta("owner_piece") + if c != null and c.get("material") == "stone": + hits += 1 + return hits / 8.0 + # --- dev helper --------------------------------------------------------------- ## Builds a 4 x 4 m hut through the same snapping a player uses, aiming at the @@ -291,24 +340,29 @@ func sheltered_at(pos: Vector3) -> bool: ## in hut space), 4 roofs meeting at a ridge, 4 gables. Used by the tests and ## the screenshot views. A coroutine (await it): like a player, it lets a ## physics step pass after each piece so the next overlap check sees it. +## `mat` "stone" builds the floors and walls in stone (a stone doorway, a +## wooden roof on top). ## Returns {"placed": n, "failed": [...], "pieces": [...]}. -func debug_build_hut(center: Vector3, yaw: float) -> Dictionary: +func debug_build_hut(center: Vector3, yaw: float, mat: String = "wood") -> Dictionary: var out := {"placed": 0, "failed": [], "pieces": []} + var floor_id := mat + "_floor" + var wall_id := mat + "_wall" + var door_id := "stone_doorway" if mat == "stone" else "wood_door" # Floors: the first sits on the ground, the rest snap to it. - var f0: Node3D = await _hut_place(out, center, yaw, "wood_floor", Vector3(-1, 0, -1), 0.0, null) + var f0: Node3D = await _hut_place(out, center, yaw, floor_id, Vector3(-1, 0, -1), 0.0, null) if f0 == null: return out var top: float = f0.global_position.y - center.y for l in [Vector3(1, 0, -1), Vector3(-1, 0, 1), Vector3(1, 0, 1)]: - await _hut_place(out, center, yaw, "wood_floor", l, 0.0, null) + await _hut_place(out, center, yaw, floor_id, l, 0.0, null) # Walls: aim at the floor just inside each edge. var q := PI * 0.5 for x in [-1.0, 1.0]: - await _hut_place(out, center, yaw, "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 + await _hut_place(out, center, yaw, wall_id, Vector3(x, top, -1.9), 0.0, f0) # north + await _hut_place(out, center, yaw, door_id if x > 0 else wall_id, Vector3(x, top, 1.9), 0.0, f0) # south for z in [-1.0, 1.0]: - await _hut_place(out, center, yaw, "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 + await _hut_place(out, center, yaw, wall_id, Vector3(1.9, top, z), q, f0) # east + await _hut_place(out, center, yaw, wall_id, Vector3(-1.9, top, z), q, f0) # west # Roofs on the north and south wall tops, rising to a ridge along X. for x in [-1.0, 1.0]: await _hut_place(out, center, yaw, "wood_roof", Vector3(x, top + 2.0, 2.0), 0.0, f0) diff --git a/scripts/building/door.gd b/scripts/building/door.gd index 1df2407..baecf23 100644 --- a/scripts/building/door.gd +++ b/scripts/building/door.gd @@ -11,7 +11,7 @@ var _panel: AnimatableBody3D var _tween: Tween func _visual_mesh() -> Mesh: - return ItemDB.mesh_for("wood_door_frame") + return ItemDB.mesh_for(String(def.get("mesh", item_id)) + "_frame") # (wood_door_frame, stone_door_frame) func _build() -> void: _hinge = Node3D.new() diff --git a/scripts/building/hearth.gd b/scripts/building/hearth.gd new file mode 100644 index 0000000..f8f5ff4 --- /dev/null +++ b/scripts/building/hearth.gd @@ -0,0 +1,21 @@ +extends "res://scripts/building/campfire.gd" +## Stone hearth: an indoor fire (docs/PROGRESSION_DESIGN.md §3). Everything a +## campfire does (fuel, cooking, keeping wolves off), but it warms a bigger +## room (1.5x the radius), its chimney keeps the rain off so it never burns +## faster in a storm, and its stone doesn't need a floor of its own. + +const HEARTH_RADIUS := HEAT_RADIUS * 1.5 + +func display_name() -> String: + return "Stone Hearth" + +func heat_at(pos: Vector3) -> float: + if not _lit: + return 0.0 + return 1.0 - smoothstep(2.0, HEARTH_RADIUS, pos.distance_to(global_position)) + +func burn_rate() -> float: + return 1.0 + +func interact_prompt(player: Node) -> String: + return super.interact_prompt(player).replace("Campfire", "Stone hearth") diff --git a/scripts/building/stonecutter.gd b/scripts/building/stonecutter.gd new file mode 100644 index 0000000..4c41d98 --- /dev/null +++ b/scripts/building/stonecutter.gd @@ -0,0 +1,12 @@ +extends "res://scripts/building/piece.gd" +## Stonecutter: a saw bench for dressing stone. Unlocks "stonecutter" recipes +## (cut stone, and the stone building pieces) within 6 m. E opens crafting. + +func _build() -> void: + add_to_group("station:stonecutter") + +func interact_prompt(_player: Node) -> String: + return "Stonecutter [E] Craft" + +func interact(player: Node) -> void: + player.open_inventory(null) diff --git a/scripts/items/item_meshes.gd b/scripts/items/item_meshes.gd index 07b30b1..779ce16 100644 --- a/scripts/items/item_meshes.gd +++ b/scripts/items/item_meshes.gd @@ -90,6 +90,22 @@ static func build(key: String) -> ArrayMesh: "charcoal_kiln": m = _kiln() "smelter": m = _smelter() "forge": m = _forge() + "cut_stone": m = _cut_stone() + "stonecutter": m = _stonecutter() + "stone_floor": m = _stone_floor() + "stone_wall": m = _stone_wall() + "stone_pillar": m = _stone_pillar() + "stone_stairs": m = _stairs(true) + "stone_window": m = _stone_window() + "stone_doorway": m = _stone_doorway(true) + "stone_doorway_frame": m = _stone_doorway(false) + "stone_hearth": m = _hearth() + "wood_stairs": m = _stairs(false) + "wood_window": m = _wood_window() + "wood_pillar": m = _wood_pillar() + "palisade": m = _palisade() + "palisade_gate": m = _palisade_gate(true) + "palisade_gate_frame": m = _palisade_gate(false) "berry_bush": m = _berry_bush(true) "berry_bush_bare": m = _berry_bush(false) _: @@ -697,3 +713,227 @@ static func _fish(col: Color, len: float, grilled: bool) -> ArrayMesh: for s in [-1.0, 1.0]: P._blob(st, Vector3(len * 0.75, len * 0.38, s * len * 0.11), Vector3.ONE * len * 0.06, n, 0.0, Color(0.05, 0.05, 0.05), r, 0) return P._commit(st) + +# --- P2 stone building and wooden upgrades --------------------------------------- +# Dimensions match the colliders in build_piece.gd (pillar, stairs, window, +# palisade) or reuse the wall/floor/door ones. + +const MORTAR := Color(0.36, 0.35, 0.33) +const STONE_BLOCK := Color(0.62, 0.61, 0.58) + +static func _block_col(r: RandomNumberGenerator) -> Color: + var c := P._mul(STONE_BLOCK, r.randf_range(0.8, 1.08)) + return c.lerp(Color(0.6, 0.55, 0.47), r.randf() * 0.25) # some warmer stones + +## Courses of staggered blocks filling x0..x1, y0..y1, `depth` thick, with a +## mortar core behind them so the joints read dark. +static func _blocks(st: SurfaceTool, r: RandomNumberGenerator, x0: float, x1: float, y0: float, y1: float, + depth: float = 0.22, course: float = 0.33) -> void: + if x1 - x0 < 0.02 or y1 - y0 < 0.02: + return + _box(st, Vector3((x0 + x1) * 0.5, (y0 + y1) * 0.5, 0), Vector3(x1 - x0, y1 - y0, depth - 0.04), MORTAR) + var rows := maxi(roundi((y1 - y0) / course), 1) + var h := (y1 - y0) / rows + for i in range(rows): + var y := y0 + h * (i + 0.5) + var x := x0 + # Stagger: every other course starts with a half block (world-aligned + # so neighbouring walls line up). + var step := 0.5 + var next := x0 + (step * 0.5 if i % 2 == 1 else step) + while x < x1 - 0.01: + var e := minf(next, x1) + _box(st, Vector3((x + e) * 0.5, y, 0), Vector3(e - x - 0.025, h - 0.025, depth), _block_col(r)) + x = e + next = e + step * r.randf_range(0.8, 1.2) + +static func _cut_stone() -> ArrayMesh: + var st := P._begin() + var r := _rng(211) + _box(st, Vector3(0, 0.08, 0), Vector3(0.3, 0.16, 0.2), _block_col(r)) + _box(st, Vector3(0.01, 0.161, 0), Vector3(0.26, 0.004, 0.16), P._mul(STONE_BLOCK, 1.1)) + return P._commit(st) + +## A heavy bench with a block on it and a frame saw: the stonecutter. +static func _stonecutter() -> ArrayMesh: + var st := P._begin() + var r := _rng(213) + _box(st, Vector3(0, 0.72, 0), Vector3(1.6, 0.12, 0.8), PLANK) + for sx in [-0.7, 0.7]: + for sz in [-0.32, 0.32]: + _box(st, Vector3(sx, 0.33, sz), Vector3(0.14, 0.66, 0.14), PLANK_DARK) + _box(st, Vector3(0, 0.2, 0), Vector3(1.4, 0.08, 0.64), PLANK_DARK) + # Rough stone waiting on the shelf, a block being cut on top. + for i in range(3): + _box(st, Vector3(-0.45 + 0.42 * i, 0.32, 0.02), Vector3(0.3, 0.16, 0.24), _block_col(r), Basis(Vector3.UP, r.randf_range(-0.3, 0.3))) + _box(st, Vector3(0.2, 0.92, 0), Vector3(0.6, 0.28, 0.4), _block_col(r)) + # Frame saw: two uprights, a cross bar and the iron blade in the cut. + for sx in [-0.2, 0.62]: + _box(st, Vector3(sx, 1.12, 0), Vector3(0.05, 0.5, 0.05), HANDLE) + _box(st, Vector3(0.21, 1.36, 0), Vector3(0.9, 0.04, 0.04), HANDLE) + _box(st, Vector3(0.21, 1.04, 0), Vector3(0.86, 0.06, 0.012), IRON_BRIGHT) + # Chips and dust. + for i in range(5): + _box(st, Vector3(r.randf_range(-0.7, 0.7), 0.79, r.randf_range(-0.3, 0.3)), Vector3(0.05, 0.03, 0.04), STONE) + return P._commit(st) + +## 2 x 2 m flagstones, top at y = 0, on a 0.2 m bed. +static func _stone_floor() -> ArrayMesh: + var st := P._begin() + var r := _rng(217) + _box(st, Vector3(0, -0.11, 0), Vector3(2.0, 0.18, 2.0), MORTAR) + for i in range(4): + for j in range(4): + var c := Vector3(-0.75 + 0.5 * i + r.randf_range(-0.02, 0.02), -0.03, -0.75 + 0.5 * j + r.randf_range(-0.02, 0.02)) + _box(st, c, Vector3(0.47, 0.06, 0.47) * Vector3(r.randf_range(0.95, 1.0), 1, r.randf_range(0.95, 1.0)), _block_col(r)) + return P._commit(st) + +static func _stone_wall() -> ArrayMesh: + var st := P._begin() + _blocks(st, _rng(219), -1.0, 1.0, 0.0, 2.0) + return P._commit(st) + +## Square column with a base and a capital, 0.4 m. +static func _stone_pillar() -> ArrayMesh: + var st := P._begin() + var r := _rng(223) + _box(st, Vector3(0, 1.0, 0), Vector3(0.32, 2.0, 0.32), MORTAR) + for i in range(6): + var y := 0.2 + (1.6 / 6.0) * (i + 0.5) + _box(st, Vector3(0, y, 0), Vector3(0.36, 1.6 / 6.0 - 0.025, 0.36), _block_col(r), Basis(Vector3.UP, r.randf_range(-0.04, 0.04))) + _box(st, Vector3(0, 0.1, 0), Vector3(0.44, 0.2, 0.44), _block_col(r)) + _box(st, Vector3(0, 1.9, 0), Vector3(0.44, 0.2, 0.44), _block_col(r)) + return P._commit(st) + +static func _wood_pillar() -> ArrayMesh: + var st := P._begin() + var r := _rng(227) + _box(st, Vector3(0, 1.0, 0), Vector3(0.3, 2.0, 0.3), _board(r, BEAM)) + _box(st, Vector3(0, 1.94, 0), Vector3(0.4, 0.12, 0.4), BEAM) + _box(st, Vector3(0, 0.06, 0), Vector3(0.38, 0.12, 0.38), BEAM) + return P._commit(st) + +## 8 steps climbing 2 m over 4 m toward -Z (the collider is the smooth ramp +## through their noses). Stone is solid underneath; wood is treads on two +## stringers. +static func _stairs(stone: bool) -> ArrayMesh: + var st := P._begin() + var r := _rng(229 if stone else 231) + var a := 0.4636476 + for i in range(8): + var top := 0.25 * (i + 1) - 0.06 + var zc := -0.25 - 0.5 * i + if stone: + _box(st, Vector3(0, top * 0.5, zc), Vector3(2.0, top, 0.5), MORTAR) + _box(st, Vector3(0, top - 0.05, zc + 0.01), Vector3(1.96, 0.1, 0.5), _block_col(r)) + else: + _box(st, Vector3(0, top - 0.04, zc + 0.03), Vector3(1.7, 0.08, 0.46), _board(r)) + if not stone: + var length := sqrt(20.0) + for x in [-0.9, 0.9]: + _box(st, Vector3(x, 0.92, -2.0), Vector3(0.12, 0.26, length), BEAM, Basis(Vector3.RIGHT, a)) + return P._commit(st) + +## Wall with a 1 x 0.7 m opening 1 m up (x -0.5..0.5, y 1.0..1.7). +static func _stone_window() -> ArrayMesh: + var st := P._begin() + var r := _rng(233) + _blocks(st, r, -1.0, 1.0, 0.0, 1.0) + _blocks(st, r, -1.0, -0.5, 1.0, 1.7, 0.22, 0.35) + _blocks(st, r, 0.5, 1.0, 1.0, 1.7, 0.22, 0.35) + _blocks(st, r, -1.0, 1.0, 1.7, 2.0, 0.22, 0.3) + _box(st, Vector3(0, 0.97, 0.03), Vector3(1.2, 0.07, 0.3), _block_col(r)) # sill + _box(st, Vector3(0, 1.75, 0), Vector3(1.24, 0.12, 0.24), _block_col(r)) # lintel + return P._commit(st) + +static func _wood_window() -> ArrayMesh: + var st := P._begin() + var r := _rng(237) + _wall_body(st, r, -0.84, 0.84, 0.1, 1.0) + _wall_body(st, r, -0.84, -0.5, 1.0, 1.7) + _wall_body(st, r, 0.5, 0.84, 1.0, 1.7) + _wall_body(st, r, -0.84, 0.84, 1.7, 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) + # Frame, sill and a shutter swung open on each side (outside, +Z). + _box(st, Vector3(0, 0.98, 0.02), Vector3(1.16, 0.06, 0.26), BEAM) + _box(st, Vector3(0, 1.72, 0), Vector3(1.1, 0.06, 0.2), BEAM) + for side in [-1.0, 1.0]: + _box(st, Vector3(side * 0.52, 1.35, 0), Vector3(0.05, 0.72, 0.2), BEAM) + _box(st, Vector3(side * 0.78, 1.35, 0.14), Vector3(0.48, 0.66, 0.04), _board(r, PLANK_DARK)) + _box(st, Vector3(0.0, 1.35, 0), Vector3(0.03, 0.7, 0.03), BEAM) # mullion + return P._commit(st) + +## Stone arch for a 1.1 x 1.9 m door (the wooden leaf is wood_door_panel). +static func _stone_doorway(with_panel: bool) -> ArrayMesh: + var st := P._begin() + var r := _rng(239) + _blocks(st, r, -1.0, -0.55, 0.0, 2.0) + _blocks(st, r, 0.55, 1.0, 0.0, 2.0) + _box(st, Vector3(0, 1.97, 0), Vector3(1.14, 0.06, 0.2), MORTAR) + # Arch stones over the opening: a keystone and its neighbours. + for i in range(5): + var x := -0.44 + 0.22 * i + var h := 0.16 + (0.08 if i == 2 else 0.0) + _box(st, Vector3(x, 1.9 + h * 0.5 - 0.02, 0), Vector3(0.2, h, 0.24), _block_col(r)) + if with_panel: + _door_panel_into(st, Vector3(-0.54, 0, 0)) + return P._commit(st) + +## Hearth: the fire sits at the origin in a stone firebox (back toward -Z), +## under a hood and chimney. +static func _hearth() -> ArrayMesh: + var st := P._begin() + var r := _rng(241) + _box(st, Vector3(0, 0.06, 0), Vector3(1.6, 0.12, 1.2), _block_col(r)) + _box(st, Vector3(0, 0.125, 0.05), Vector3(0.9, 0.01, 0.7), ASH) + # Back and cheeks. + _box(st, Vector3(0, 0.75, -0.48), Vector3(1.6, 1.3, 0.24), MORTAR) + for i in range(4): + _box(st, Vector3(-0.4 + 0.8 * (i % 2) + r.randf_range(-0.05, 0.05), 0.3 + 0.33 * i, -0.36), Vector3(0.7, 0.3, 0.02), _block_col(r)) + for sx in [-0.68, 0.68]: + for i in range(4): + _box(st, Vector3(sx, 0.28 + 0.3 * i, -0.05), Vector3(0.26, 0.28, 0.7), _block_col(r)) + # Mantel, hood and chimney. + _box(st, Vector3(0, 1.3, 0.05), Vector3(1.7, 0.12, 0.8), _block_col(r)) + _box(st, Vector3(0, 1.55, -0.12), Vector3(1.3, 0.38, 0.6), MORTAR) + _blocks(st, r, -0.3, 0.3, 1.74, 2.0, 0.42, 0.26) + # A few logs in the fire. + for i in range(3): + var a := 0.6 * i - 0.6 + var d := Vector3(cos(a), 0, sin(a)) * 0.3 + P._cylinder(st, Vector3(0, 0.18, 0.05) - d, Vector3(0, 0.18, 0.05) + d, 0.06, 0.06, 5, P.BARK, r, -1.0, PLANK) + return P._commit(st) + +## 2 m of sharpened logs, 3 m tall. +static func _palisade() -> ArrayMesh: + var st := P._begin() + var r := _rng(243) + for i in range(5): + _stake(st, r, -0.8 + 0.4 * i, 0.0, 3.0) + for y in [0.7, 2.1]: + _box(st, Vector3(0, y, -0.2), Vector3(2.0, 0.14, 0.08), BEAM) + return P._commit(st) + +static func _stake(st: SurfaceTool, r: RandomNumberGenerator, x: float, y0: float, top: float) -> void: + var h := top - r.randf_range(0.0, 0.18) + var rad := r.randf_range(0.16, 0.19) + P._cylinder(st, Vector3(x, y0, 0), Vector3(x, h - 0.35, 0), rad, rad * 0.95, 7, P.BARK, r) + P._cylinder(st, Vector3(x, h - 0.35, 0), Vector3(x, h, 0), rad * 0.95, 0.02, 7, P.WOOD, r) + +## Palisade gate: stakes either side of a 1.1 x 1.9 m opening, more above it. +static func _palisade_gate(with_panel: bool) -> ArrayMesh: + var st := P._begin() + var r := _rng(247) + for x in [-0.8, 0.8]: + _stake(st, r, x, 0.0, 3.0) + for x in [-0.36, 0.0, 0.36]: + _stake(st, r, x, 1.95, 3.0) + for x in [-0.62, 0.62]: + _box(st, Vector3(x, 0.98, 0), Vector3(0.12, 1.96, 0.2), BEAM) + _box(st, Vector3(0, 1.98, 0), Vector3(1.4, 0.12, 0.24), BEAM) + if with_panel: + _door_panel_into(st, Vector3(-0.54, 0, 0)) + return P._commit(st) diff --git a/scripts/net/net.gd b/scripts/net/net.gd index cff1833..ba2f972 100644 --- a/scripts/net/net.gd +++ b/scripts/net/net.gd @@ -557,7 +557,7 @@ func _wall_hit(space: PhysicsDirectSpaceState3D, a: Vector3, b: Vector3) -> bool var piece: Node = c as Node while piece != null and piece.get("kind") == null: piece = piece.get_parent() - if piece != null and String(piece.kind) in ["wall", "door", "gable"]: + if piece != null and String(piece.kind) in ["wall", "door", "gable", "window", "palisade", "pillar"]: return true ex.append(hit.rid) # a floor or roof edge: look past it return false diff --git a/scripts/player/survival.gd b/scripts/player/survival.gd index 82bb540..66d9021 100644 --- a/scripts/player/survival.gd +++ b/scripts/player/survival.gd @@ -73,6 +73,9 @@ func balanced_diet() -> bool: return false return true +var stone_room := false ## sheltered by mostly stone walls (P2): a little warmer +const STONE_ROOM_WARMTH := 6.0 + var player: Node3D ## the local player, or a server_player.gd on a server var world: Node3D var _stamina_wait := 0.0 @@ -92,6 +95,7 @@ func step(dt: float) -> void: _shelter_left = 0.25 roofed = world.build.roofed_at(p) sheltered = roofed and world.build.enclosure_at(p) >= 0.74 + stone_room = sheltered and world.build.stone_walls_at(p) >= 0.5 # 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 @@ -105,6 +109,8 @@ func step(dt: float) -> void: rested = maxf(rested - dt, 0.0) var wear: Dictionary = ItemDB.get_item(player.worn).get("wear", {}) if not player.worn.is_empty() else {} var target: float = world.air_warmth(p, sheltered) + heat * 70.0 + float(wear.get("warmth", 0.0)) + if stone_room: + target += STONE_ROOM_WARMTH if wet > 0.0: target -= 22.0 * float(wear.get("wet_chill", 1.0)) wet = maxf(wet - dt * (3.0 if heat > 0.3 else 1.0), 0.0) # fires dry you off diff --git a/scripts/ui/inventory_screen.gd b/scripts/ui/inventory_screen.gd index 860865c..5524968 100644 --- a/scripts/ui/inventory_screen.gd +++ b/scripts/ui/inventory_screen.gd @@ -255,7 +255,7 @@ func _rebuild_recipes() -> void: for c in _recipe_list.get_children(): c.queue_free() var stations: Array[String] = [] - for st in ["workbench", "campfire", "forge"]: + for st in ["workbench", "campfire", "forge", "stonecutter"]: if Actions.station_near(player, st): stations.append(st) _station_label.text = "Nearby: " + (", ".join(stations) if not stations.is_empty() else "nothing (hand crafting only)") diff --git a/scripts/world/world.gd b/scripts/world/world.gd index a873779..8ddff65 100644 --- a/scripts/world/world.gd +++ b/scripts/world/world.gd @@ -27,6 +27,8 @@ const PIECES := { "kiln": preload("res://scripts/building/processor.gd"), "smelter": preload("res://scripts/building/processor.gd"), "forge": preload("res://scripts/building/forge.gd"), + "hearth": preload("res://scripts/building/hearth.gd"), + "stonecutter": preload("res://scripts/building/stonecutter.gd"), } const BuildPiece = preload("res://scripts/building/build_piece.gd") const UITheme = preload("res://scripts/ui/theme.gd") diff --git a/tests/run_all.sh b/tests/run_all.sh index f93c855..330f815 100755 --- a/tests/run_all.sh +++ b/tests/run_all.sh @@ -9,7 +9,7 @@ if [ ! -f .godot/global_script_class_cache.cfg ]; then echo "Importing the project first (fresh copy)..." godot --headless --path . --import >/dev/null 2>&1 fi -for t in test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_wolves test_den_sites test_fishing_diet test_iron test_net_lobby test_net_world test_net_shared test_net_life test_net_rules test_host test_terrain_edges test_boundary; do +for t in test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_wolves test_den_sites test_fishing_diet test_iron test_stone test_net_lobby test_net_world test_net_shared test_net_life test_net_rules test_host test_terrain_edges test_boundary; do extra="" [ "$t" = "test_boundary" ] && extra="--fixed-fps 30" # A suite that waits on a world that failed to load would hang forever, diff --git a/tests/test_stone.gd b/tests/test_stone.gd new file mode 100644 index 0000000..fe7a328 --- /dev/null +++ b/tests/test_stone.gd @@ -0,0 +1,384 @@ +extends SceneTree +## P2 stone building (docs/PROGRESSION_DESIGN.md §3): the stonecutter and cut +## stone; stone floors, walls, pillars, stairs, windows, doorways and the +## hearth; stone's support rules; the wooden upgrades (stairs, windows, posts, +## palisade and gate). +## Run: godot --headless --path . --script res://tests/test_stone.gd + +var _fails := 0 +var _finished := false +var A: Node +var S: Node +var DB: Node +var world: Node3D +var player: CharacterBody3D +var bs: Node + +const NEW_PIECES := ["cut_stone", "stonecutter", "stone_floor", "stone_wall", "stone_pillar", "stone_stairs", + "stone_window", "stone_doorway", "stone_hearth", "wood_stairs", "wood_window", "wood_pillar", "palisade", "palisade_gate"] + +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 _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) + S.current_id = S.create_world("Stone", 1337) + await _load() + await _body() + check(_finished, "test ran to the end (no script errors)") + for w in S.list_worlds(): + S.delete_world(w.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) + +func _load() -> void: + world = load("res://scenes/world.tscn").instantiate() + root.add_child(world) + for _i in range(5): + await physics_frame + world.creatures.enabled = false + for c in world.creatures.alive(): + c.queue_free() + if world.wolves != null: + world.wolves.enabled = false + player = world.player + player.set_physics_process(false) + player.survival.set_process(false) + world.weather.set_process(false) + bs = world.build + +func _settle() -> void: + for _i in range(3): + await physics_frame + +func _body() -> void: + _data() + await _crafting() + await _tower() + await _span() + await _stairs() + await _hearth_room() + await _palisade() + await _saving() + _finished = true + +# --- data ------------------------------------------------------------------------- + +func _data() -> void: + print("items, recipes and meshes") + var missing := [] + for id in NEW_PIECES: + if DB.get_item(id).is_empty() or not DB.recipes.has(id): + missing.append(id) + check(missing.is_empty(), "every new piece has an item and a recipe (missing %s)" % [missing]) + var bad := [] + for m in NEW_PIECES + ["stone_doorway_frame", "palisade_gate_frame"]: + var mesh: Mesh = DB.mesh_for(m) + # (A missing mesh falls back to a 0.3 m magenta pebble.) + if mesh == null or mesh.get_aabb().size.length() < 0.2 or (m != "cut_stone" and mesh.get_aabb().size.length() < 0.5): + bad.append(m) + check(bad.is_empty(), "every new piece has its own mesh (bad: %s)" % [bad]) + var sizes := {"stone_wall": Vector3(2, 2, 0.22), "stone_floor": Vector3(2, 0.2, 2), "palisade": Vector3(2, 3, 0.38)} + for k in sizes: + var s: Vector3 = DB.mesh_for(k).get_aabb().size + check(s.distance_to(sizes[k]) < 0.15, "%s mesh is %s (matches its collider)" % [k, s]) + check(DB.get_item("stone_wall").place.get("material", "") == "stone" and DB.get_item("wood_stairs").place.get("material", "wood") == "wood", + "stone pieces are stone, the wooden upgrades wood") + +# --- the stonecutter ------------------------------------------------------------------ + +func _place(id: String, side: float) -> Node3D: + var at: Vector3 = player.global_position + player.global_basis.x * side - player.global_basis.z * 3.0 + at.y = world.terrain.sample_height(at.x, at.z) + return world.spawn_piece(DB.get_item(id), Transform3D(Basis(), at)) + +func _crafting() -> void: + print("the stonecutter") + _clear() + _give("wood", 30) + _give("iron_bar", 4) + _give("stone", 40) + _give("nails", 10) + check(not A.can_craft(player, "stonecutter"), "no stonecutter without a workbench") + var wb := _place("workbench", 3.0) + check(A.craft(player, "stonecutter"), "a stonecutter at the workbench (8 wood, 4 iron, 10 stone)") + check(not A.can_craft(player, "cut_stone"), "no cut stone until it's built") + var sc := _place("stonecutter", -3.0) + check(sc != null and A.station_near(player, "stonecutter"), "placed, it's a station") + var n := 0 + while A.craft(player, "cut_stone"): + n += 1 + check(n == 15 and player.inventory.count("cut_stone") == 15, "30 stone cut into %d blocks (2 each)" % n) + check(A.craft(player, "stone_wall") and A.craft(player, "stone_floor") and A.craft(player, "stone_pillar"), + "a stone wall, floor and pillar from cut stone") + check(player.inventory.count("cut_stone") == 2, "for 6 + 4 + 3 blocks") + check(A.craft(player, "wood_stairs") and A.craft(player, "palisade") and A.craft(player, "wood_window"), + "wooden stairs, a palisade and a window at the workbench, with nails") + check(player.inventory.count("nails") == 2, "(4 + 2 + 2 nails)") + wb.queue_free() + sc.queue_free() + await _settle() + +# --- support ------------------------------------------------------------------------ + +## A clear, fairly flat spot `ahead` metres in front of the spawn. +func _site(ahead: float, half: float) -> Vector3: + return bs.debug_find_site(player.global_position - player.global_basis.z * ahead, half) + +func _spawn(id: String, xf: Transform3D) -> Node3D: + return world.spawn_piece(DB.get_item(id), xf) + +var tower_top: Node3D + +func _tower() -> void: + print("a stone tower") + var site := _site(14.0, 3.0) + var f: Transform3D = bs._free_xf("floor", site, 0.0, true) + check(bs.check(DB.get_item("stone_floor"), f) == "", "a stone floor on the ground") + var floor: Node3D = _spawn("stone_floor", f) + var walls: Array = [] + var why := "" + for i in range(5): + var xf := Transform3D(f.basis, f.origin + Vector3(0, 2.0 * i, -1)) + why = bs.check(DB.get_item("stone_wall"), xf) + if not why.is_empty(): + break + walls.append(_spawn("stone_wall", xf)) + await physics_frame + tower_top = walls.back() + check(walls.size() == 5, "five stone walls stacked: 10 m (%s)" % why) + check(tower_top.support > 0.78, "the top one still has %d%% support (5%% lost per storey)" % roundi(tower_top.support * 100.0)) + # The same in wood, beside it. + var wf := Transform3D(f.basis, f.origin + Vector3(0, 0, 1)) + var wood: Array = [] + for i in range(5): + wood.append(_spawn("wood_wall", Transform3D(f.basis, wf.origin + Vector3(0, 2.0 * i, 0)))) + check(wood.back().support < tower_top.support - 0.1, "a wooden one loses more (%d%% at the top)" % roundi(wood.back().support * 100.0)) + print("stone and wood together") + var on_wood := Transform3D(f.basis, wf.origin + Vector3(0, 10.0, 0)) + var reason: String = bs.check(DB.get_item("stone_wall"), on_wood) + check(reason.begins_with("Stone needs stone"), "stone on wood is refused: \"%s\"" % reason) + var wood_on_stone := Transform3D(f.basis, f.origin + Vector3(0, 10.0, -1)) + check(bs.check(DB.get_item("wood_wall"), wood_on_stone) == "", "wood on stone is fine (stone at the bottom, wood on top)") + wood.reverse() # (top down: taking the bottom first would drop the rest) + for p in wood: + world.remove_piece(p) + await _settle() + check(tower_top.support > 0.78 and is_instance_valid(floor), "taking the wooden wall down leaves the stone one standing") + +func _span() -> void: + print("pillars carry a span") + var site := _site(-16.0, 6.0) + var f0: Transform3D = bs._free_xf("floor", site, 0.0, true) + var x := f0.basis.x + # A row of floors on the ground, a wall on the west end, and floors out + # from the wall's top, one storey up. + for i in range(6): + _spawn("wood_floor", Transform3D(f0.basis, f0.origin + x * 2.0 * i)) + _spawn("wood_wall", Transform3D(f0.basis.rotated(Vector3.UP, PI * 0.5), f0.origin - x * 1.0)) + await physics_frame + var up: Array = [] + var fdef: Dictionary = DB.get_item("wood_floor") + var refused_at := -1 + for i in range(6): + var xf := Transform3D(f0.basis, f0.origin + x * 2.0 * i + Vector3(0, 2, 0)) + if not bs.check(fdef, xf).is_empty(): + refused_at = i + break + up.append(_spawn("wood_floor", xf)) + await physics_frame + check(refused_at >= 2 and refused_at <= 4, "a floor hanging out from a wall reaches %d m" % (refused_at * 2)) + # A pillar under the far edge, standing on the ground floor's corners. + var pdef: Dictionary = DB.get_item("stone_pillar") + var g: Node3D = null + for p in bs.build_pieces(): + if p.kind == "floor" and p.global_position.distance_to(f0.origin + x * 2.0 * (refused_at - 1)) < 0.1: + g = p + var corner: Vector3 = g.global_transform * Vector3(1, 0, 1) + var aim := {"position": corner + Vector3(-0.2, 0, -0.2), "normal": Vector3.UP, "collider": g} + var r: Dictionary = bs.placement(pdef, aim, 0.0) + check(r.ok and r.xf.origin.distance_to(corner) < 0.01, "a pillar snaps onto a floor's corner (%s)" % r.reason) + _spawn("stone_pillar", r.xf) + _spawn("stone_pillar", Transform3D(r.xf.basis, g.global_transform * Vector3(1, 0, -1))) + await physics_frame + var xf := Transform3D(f0.basis, f0.origin + x * 2.0 * refused_at + Vector3(0, 2, 0)) + check(bs.check(fdef, xf) == "", "with it, the next floor out is fine") + var held: Node3D = _spawn("wood_floor", xf) + check(held.support > 0.8, "carried straight down to the ground (%d%% support)" % roundi(held.support * 100.0)) + check(bs.check(fdef, Transform3D(f0.basis, xf.origin + x * 2.0)) == "", "and the span goes on from there") + var near: Array = bs.build_pieces().filter(func(p): return p.global_position.distance_to(f0.origin) < 14.0) + near.sort_custom(func(a, b): return a.global_position.y > b.global_position.y) + for p in near: + if is_instance_valid(p) and p.is_inside_tree() and not p.is_queued_for_deletion(): + world.remove_piece(p) + await _settle() + +# --- stairs --------------------------------------------------------------------------- + +func _stairs() -> void: + print("stairs") + var site := _site(-6.0, 4.0) + var yaw: float = player.rotation.y + var sdef: Dictionary = DB.get_item("wood_stairs") + var r: Dictionary = bs.placement(sdef, {"position": site, "normal": Vector3.UP, "collider": world.terrain.get_node("TerrainBody")}, yaw) + check(r.ok, "wooden stairs on the ground (%s)" % r.reason) + var stairs: Node3D = world.spawn_piece(sdef, r.xf) + var top: Vector3 = r.xf * Vector3(0, 2, -4) + var landing: Node3D = _spawn("wood_floor", Transform3D(r.xf.basis, r.xf * Vector3(0, 2, -5))) + check(landing.support >= bs.MIN_SUPPORT, "a floor at the top, held by the stairs (%d%%)" % roundi(landing.support * 100.0)) + await _settle() + var q := PhysicsRayQueryParameters3D.create(r.xf * Vector3(0, 3, -2), r.xf * Vector3(0, -1, -2), 1) + var hit := world.get_world_3d().direct_space_state.intersect_ray(q) + check(not hit.is_empty() and absf(hit.position.y - (r.xf * Vector3(0, 1, -2)).y) < 0.05 and hit.normal.y > cos(deg_to_rad(40.0)), + "a smooth ramp halfway up, gentle enough to walk (%.0f degrees)" % rad_to_deg(acos(hit.normal.y if not hit.is_empty() else 0.0))) + # Walk up them. + player.global_position = r.xf * Vector3(0, 0.1, 1.0) + player.rotation.y = r.xf.basis.get_euler().y + player.velocity = Vector3.ZERO + player.set_physics_process(true) + Input.action_press("move_forward") + var best := -INF + for _i in range(150): + await physics_frame + best = maxf(best, player.global_position.y) + if player.global_position.distance_to(r.xf * Vector3(0, 2, -5.5)) < 0.6: + break + Input.action_release("move_forward") + for _i in range(10): + await physics_frame + player.set_physics_process(false) + var on_top: bool = absf(player.global_position.y - top.y) < 0.2 and (r.xf.affine_inverse() * player.global_position).z < -4.0 + check(on_top, "the player walks up them onto the landing (%.2f m up, %.1f m along)" % [ + player.global_position.y - r.xf.origin.y, -(r.xf.affine_inverse() * player.global_position).z]) + world.remove_piece(landing) + world.remove_piece(stairs) + await _settle() + +# --- a stone house with a hearth ---------------------------------------------------------- + +func _hearth_room() -> void: + print("a stone house") + var sv: Node = player.survival + var site := _site(26.0, 3.0) + var yaw: float = player.rotation.y + var hut: Dictionary = await bs.debug_build_hut(site, yaw, "stone") + await _settle() + check(hut.placed == 20 and hut.failed.is_empty(), "a stone hut with a wooden roof, by snapping (%d placed, failed %s)" % [hut.placed, hut.failed]) + var f0: Node3D = hut.pieces[0] + var center: Vector3 = f0.global_transform * Vector3(1, 0, 1) + var b := Basis(Vector3.UP, yaw) + var inside: Vector3 = center + b * Vector3(0.6, 0.05, 0.8) + check(bs.sheltered_at(inside) and bs.stone_walls_at(inside) > 0.7, "inside is sheltered, by stone (%d%%)" % roundi(bs.stone_walls_at(inside) * 100.0)) + print("the hearth") + var hdef: Dictionary = DB.get_item("stone_hearth") + var hxf := Transform3D(b, center + b * Vector3(0, 0, -1.25)) + check(world.placement_valid(hdef, hxf), "a hearth fits against the back wall") + var hearth: Node3D = world.spawn_piece(hdef, hxf) + check(hearth.is_in_group("station:campfire") and hearth.is_in_group("heat"), "it cooks and warms like a campfire") + var camp: Node3D = world.spawn_piece(DB.get_item("campfire"), Transform3D(b, site + b * Vector3(12, 0, 0))) + var far := 8.5 + check(hearth.heat_at(hearth.global_position + Vector3(far, 0, 0)) > 0.1 and camp.heat_at(camp.global_position + Vector3(far, 0, 0)) == 0.0, + "its warmth reaches %.1f m, where a campfire's doesn't" % far) + world.weather.force("storm") + var out_hearth: Node3D = world.spawn_piece(hdef, Transform3D(b, site + b * Vector3(-12, 0, 0))) + camp._roofed_cache = -1.0 + check(out_hearth.burn_rate() == 1.0 and camp.burn_rate() > 2.0, "a storm doesn't eat its fuel, even outside (a campfire's burns %.1fx)" % camp.burn_rate()) + world.remove_piece(out_hearth) + world.remove_piece(camp) + player.global_position = inside + sv.wet = 0.0 + sv._shelter_left = 0.0 + sv.step(0.5) + check(sv.sheltered and sv.stone_room and "Warm" in sv.statuses and "Sheltered" in sv.statuses, + "by the hearth: Sheltered and Warm, in a stone room (%s)" % [sv.statuses]) + # With the fire out, the stone alone: a few degrees over what the air + # gives a sheltered spot. + hearth._set_lit(false) + sv.warmth = 0.0 + for _i in range(60): + sv.step(1.0) + var air: float = world.air_warmth(inside, true) + check(absf(sv.warmth - (air + sv.STONE_ROOM_WARMTH)) < 1.5, "stone keeps you a little warmer (%.0f, against %.0f from the air)" % [sv.warmth, air]) + hearth._set_lit(true) + print("a window") + var wall: Node3D = hut.pieces[4] + var wxf: Transform3D = wall.global_transform + world.remove_piece(wall) + await _settle() + check(bs.check(DB.get_item("stone_window"), wxf) == "", "a stone window goes where the wall was") + world.spawn_piece(DB.get_item("stone_window"), wxf) + await _settle() + check(bs.sheltered_at(inside), "and the room is still sheltered") + var through := PhysicsRayQueryParameters3D.create(wxf * Vector3(0, 1.35, 1), wxf * Vector3(0, 1.35, -1), 1) + check(world.get_world_3d().direct_space_state.intersect_ray(through).is_empty(), "but you can see out of it") + world.weather.force("clear") + +# --- the palisade ----------------------------------------------------------------------- + +func _palisade() -> void: + print("a palisade") + var site := _site(-28.0, 4.0) + var yaw: float = player.rotation.y + var tb: Node = world.terrain.get_node("TerrainBody") + var pdef: Dictionary = DB.get_item("palisade") + var r: Dictionary = bs.placement(pdef, {"position": site, "normal": Vector3.UP, "collider": tb}, yaw) + check(r.ok, "a palisade on the ground (%s)" % r.reason) + var p0: Node3D = world.spawn_piece(pdef, r.xf) + await _settle() + var aim: Vector3 = p0.global_transform * Vector3(1.6, 0, 0) + aim.y = world.terrain.sample_height(aim.x, aim.z) + var r2: Dictionary = bs.placement(pdef, {"position": aim, "normal": Vector3.UP, "collider": tb}, yaw) + check(r2.ok and r2.snapped and r2.xf.origin.distance_to(p0.global_transform * Vector3(2, 0, 0)) < 0.01, "the next one snaps on, 2 m along") + var p1: Node3D = world.spawn_piece(pdef, r2.xf) + var gdef: Dictionary = DB.get_item("palisade_gate") + var gxf := Transform3D(p0.global_basis, p0.global_transform * Vector3(-2, 0, 0)) + var gate: Node3D = world.spawn_piece(gdef, gxf) + await _settle() + check(gate.has_method("set_open"), "a gate in it that opens") + var space := world.get_world_3d().direct_space_state + var high := PhysicsRayQueryParameters3D.create(p0.global_transform * Vector3(0, 2.6, 2), p0.global_transform * Vector3(0, 2.6, -2), 1) + check(not space.intersect_ray(high).is_empty(), "it's 3 m tall: higher than a wall") + var net: Node = root.get_node("Net") + check(net._wall_hit(space, p1.global_transform * Vector3(0, 1, 2), p1.global_transform * Vector3(0, 1, -2)), "online it blocks wolves and players like a wall") + for p in [p0, p1, gate]: + world.remove_piece(p) + await _settle() + +# --- saving ------------------------------------------------------------------------------- + +func _saving() -> void: + print("saving") + var at: Vector3 = tower_top.global_position + var sup: float = tower_top.support + check(world.save_now(), "saved") + world.queue_free() + await process_frame + await _load() + var top: Node3D = null + var hearths := 0 + for p in world.all_pieces(): + if p.item_id == "stone_wall" and p.global_position.distance_to(at) < 0.05: + top = p + if p.item_id == "stone_hearth": + hearths += 1 + check(top != null and top.material == "stone" and absf(top.support - sup) < 0.001, "the stone tower comes back, just as strong") + check(hearths == 1, "and the hearth") + +# --- helpers -------------------------------------------------------------------------- + +func _give(id: String, n: int) -> void: + player.inventory.add(id, n) + +func _clear() -> void: + player.inventory.load_data(Inventory.new(40).to_data())