M1a: survival core, pause menu, title screen and save/load

Design: docs/SURVIVAL_DESIGN.md (core loop, systems, architecture, phases).

Survival core (M1a):
- Items/recipes as data (data/*.json) in the addon SDK's define_item /
  define_recipe shape; procedural item meshes and offscreen-rendered icons
- Inventory class (40 slots, hotbar = row 1) shared by player and chests;
  click / right-click split / shift-click quick-move
- Actions autoload: the intent layer (craft, pickup, harvest_hit, place,
  transfer) that validates every change; becomes the RPC surface in M2
- Gathering: ground pickups (sticks, stones, flint); trees chopped with an
  axe fall and leave stumps; boulders mined with a pickaxe shrink per hit
- Crafting screen with station checks (workbench); placement mode with
  green/red ghost, rotation and overlap/slope validation
- Pieces: campfire (light, flames, embers), workbench, chest (16 slots)
- HUD: hotbar, held tool view model with swing, prompts, toasts

Menus and saving:
- ESC pause menu: resume, controls, fullscreen, mouse sensitivity, quit
- Title screen over a flyover of the island: Continue / New World (name +
  seed) / Load World (with delete) / Quit
- Saves autoload: one folder per world under user://worlds, atomic writes
  with a .bak; stores only player changes (player, pieces, chest contents,
  harvested resources, taken pickups) and regenerates the rest from the seed
- Autosave every 2 min, Save & Quit to title/desktop, save on window close

Fixes: UI screens now fill the viewport (were pinned top-left); title
backdrop no longer captures the mouse.

Tests: test_survival_core (31 checks), test_save_load (round trip + slots),
test_terrain_edges now checks 8 seeds for deep pits / unbanked water.
This commit is contained in:
Paul Bailey
2026-11-04 00:34:46 +00:00
parent 12f7fd677d
commit 4640f5e577
53 changed files with 3192 additions and 36 deletions

View File

@@ -11,6 +11,8 @@ extends Node3D
## dense to place over the whole map at once.
const ProcMesh = preload("res://scripts/world/proc_mesh.gd")
const ResourceNodeScript = preload("res://scripts/world/resource_node.gd")
const PickupScript = preload("res://scripts/world/pickup.gd")
const CELL := 48.0
const GRASS_TILE := 12.0
@@ -32,6 +34,12 @@ var _spawn := Vector3.ZERO
var _forest_noise := FastNoiseLite.new()
var _meadow_noise := FastNoiseLite.new()
var _colliders: StaticBody3D
var _resources: Node3D ## harvestable trees/boulders (each its own body)
## Stable ids for saving. Placement is deterministic for a given seed, so
## creation order gives the same id to the same tree on every load.
var resource_nodes: Dictionary = {} ## uid -> resource node
var pickups: Dictionary = {} ## uid -> pickup
var _bark_mat: StandardMaterial3D
var _leaf_mat: ShaderMaterial
@@ -48,6 +56,7 @@ func place_on(t: Node3D, player: Node3D, spawn: Vector3) -> void:
terrain = t
_player = player
_spawn = spawn
world_seed = terrain.noise_seed # one seed drives terrain and scenery
_forest_noise.seed = world_seed + 11
_forest_noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
_forest_noise.frequency = 0.011
@@ -60,12 +69,16 @@ func place_on(t: Node3D, player: Node3D, spawn: Vector3) -> void:
_colliders = StaticBody3D.new()
_colliders.name = "PropColliders"
add_child(_colliders)
_resources = Node3D.new()
_resources.name = "Resources"
add_child(_resources)
_make_materials()
_scatter_trees()
_scatter_rocks()
_scatter_bushes()
_place_logs()
_scatter_pickups()
_grass_root = Node3D.new()
_grass_root.name = "Grass"
@@ -141,18 +154,58 @@ func _jittered(spacing: float, rng: RandomNumberGenerator) -> PackedVector2Array
func _forest(x: float, z: float) -> float:
return smoothstep(-0.15, 0.35, _forest_noise.get_noise_2d(x, z))
func _group(groups: Dictionary, x: float, z: float, vi: int, xf: Transform3D) -> void:
## Adds a transform to its cell batch. Returns [key, index] so a resource node
## can find its own instance once the batch exists.
func _group(groups: Dictionary, x: float, z: float, vi: int, xf: Transform3D) -> Array:
var key := Vector3i(floori(x / CELL), floori(z / CELL), vi)
if not groups.has(key):
groups[key] = []
groups[key].append(xf)
return [key, groups[key].size() - 1]
func _flush(groups: Dictionary, meshes: Array, shadows: bool, label: String) -> void:
## Builds the batches. Returns key -> MultiMeshInstance3D.
func _flush(groups: Dictionary, meshes: Array, shadows: bool, label: String) -> Dictionary:
var root := Node3D.new()
root.name = label
add_child(root)
var out := {}
for key: Vector3i in groups:
root.add_child(_mm_instance(meshes[key.z], groups[key], [], shadows))
var mmi := _mm_instance(meshes[key.z], groups[key], [], shadows)
root.add_child(mmi)
out[key] = mmi
return out
## Hands each pending resource node its batch instance.
func _bind_nodes(pending: Array, batches: Dictionary) -> void:
for p in pending:
var node = p[0]
node.mmi = batches[p[1]]
node.index = p[2]
func _resource_node(kind: String, xf: Transform3D) -> StaticBody3D:
var node: StaticBody3D = ResourceNodeScript.new()
node.uid = "%s%d" % [kind[0], resource_nodes.size()]
resource_nodes[node.uid] = node
node.kind = kind
node.base_xf = xf
node.stump_material = _bark_mat
node.rng.seed = hash(xf.origin)
node.add_to_group("harvestable")
if kind == "tree":
node.tool_kind = "axe"
node.max_hp = 5.0
node.drops_final = {"wood": [4, 6], "stick": [1, 3]}
node.chip_color = Color(0.62, 0.46, 0.28)
else:
node.tool_kind = "pickaxe"
node.max_hp = 6.0
node.drops_per_hit = {"stone": 1}
node.drops_final = {"stone": [3, 5], "flint": [1, 2]}
node.chip_color = Color(0.55, 0.54, 0.52)
node.hp = node.max_hp
_resources.add_child(node)
node.global_position = xf.origin
return node
func _mm_instance(mesh: Mesh, xforms: Array, colors: Array, shadows: bool) -> MultiMeshInstance3D:
var mm := MultiMesh.new()
@@ -206,6 +259,7 @@ func _scatter_trees() -> void:
var rng := RandomNumberGenerator.new()
rng.seed = world_seed + 1
var groups := {}
var pending: Array = []
for p in _jittered(tree_spacing, rng):
# Draw every random number up front so the sequence never depends on
# which branch below is taken (stable layout when rules are tweaked).
@@ -227,15 +281,25 @@ func _scatter_trees() -> void:
else:
vi = birches[int(pick * 7919.0) % birches.size()]
var xf := Transform3D(Basis(Vector3.UP, yaw) * Basis.from_scale(Vector3.ONE * s), Vector3(p.x, h - 0.1, p.y))
_group(groups, p.x, p.y, vi, xf)
var slot := _group(groups, p.x, p.y, vi, xf)
if terrain.inside_boundary(p.x, p.y, -4.0):
# Reachable trees are harvestable: their own body + trunk capsule.
var v: Dictionary = variants[vi]
var cap := CapsuleShape3D.new()
cap.radius = v.trunk_r * s
cap.height = maxf(v.trunk_h * s, cap.radius * 2.0 + 0.1)
_add_shape(cap, Transform3D(Basis(), Vector3(p.x, h + cap.height * 0.5, p.y)))
var node := _resource_node("tree", xf)
if vi == dead:
node.drops_final = {"wood": [2, 3], "stick": [2, 4]}
node.max_hp = 3.0
node.hp = 3.0
var cs := CollisionShape3D.new()
cs.shape = cap
cs.position = Vector3(0, cap.height * 0.5 + 0.1, 0)
node.add_child(cs)
pending.append([node, slot[0], slot[1]])
var meshes := variants.map(func(v): return v.mesh)
_flush(groups, meshes, true, "Trees")
_bind_nodes(pending, _flush(groups, meshes, true, "Trees"))
## Open grassy strip along the sea-cliff edge: no trees, bushes or boulders, so
## the view out to sea is clear and you can walk right up to the edge.
@@ -270,6 +334,7 @@ func _scatter_rocks() -> void:
var rng := RandomNumberGenerator.new()
rng.seed = world_seed + 2
var groups := {}
var pending: Array = []
# Small stones: everywhere, more on slopes and in the shallows.
for p in _jittered(6.0, rng):
@@ -292,7 +357,7 @@ func _scatter_rocks() -> void:
continue
if roll > 0.07 + _forest(p.x, p.y) * 0.08:
continue
_place_rock(groups, meshes, hulls, p, h, n, vi, yaw, s, 0.5, 0.3)
_place_rock(groups, meshes, hulls, p, h, n, vi, yaw, s, 0.5, 0.3, pending)
# Cliff outcrops: big slabs breaking up the steep mountain faces.
for p in _jittered(8.0, rng):
@@ -321,20 +386,30 @@ func _scatter_rocks() -> void:
_group(groups, p.x, p.y, vi, Transform3D(b, Vector3(p.x, h - 1.0, p.y)))
placed += 1
_flush(groups, meshes, true, "Rocks")
_bind_nodes(pending, _flush(groups, meshes, true, "Rocks"))
## Places one rock. Passing `pending` makes it a mineable boulder (if it's
## inside the walkable area); otherwise it just gets plain collision.
func _place_rock(groups: Dictionary, meshes: Array, hulls: Array, p: Vector2, h: float, n: Vector3,
vi: int, yaw: float, s: float, follow: float, sink: float) -> void:
vi: int, yaw: float, s: float, follow: float, sink: float, pending = null) -> void:
var b := _ground_basis(n, follow, yaw, s)
var pos := Vector3(p.x, h - sink * s, p.y)
_group(groups, p.x, p.y, vi, Transform3D(b, pos))
var slot := _group(groups, p.x, p.y, vi, Transform3D(b, pos))
if s >= 0.55 and terrain.inside_boundary(p.x, p.y, -6.0):
var pts := PackedVector3Array()
for q: Vector3 in hulls[vi]:
pts.append(q * s)
var shape := ConvexPolygonShape3D.new()
shape.points = pts
_add_shape(shape, Transform3D(b.orthonormalized(), pos))
if pending != null and terrain.inside_boundary(p.x, p.y, 0.0):
var node := _resource_node("rock", Transform3D(b, pos))
var cs := CollisionShape3D.new()
cs.shape = shape
cs.basis = b.orthonormalized()
node.add_child(cs)
pending.append([node, slot[0], slot[1]])
else:
_add_shape(shape, Transform3D(b.orthonormalized(), pos))
# --- bushes, logs, stumps ---------------------------------------------------
@@ -415,6 +490,54 @@ func _place_logs() -> void:
col_xf.origin.y += (shape as CylinderShape3D).height * 0.5 - 0.3
_add_shape(shape, col_xf)
# --- ground pickups ---------------------------------------------------------
## Sticks under trees, stones everywhere, flint near water and on rocky ground.
## Enough for the first tools without hunting around for long.
func _scatter_pickups() -> void:
var root := Node3D.new()
root.name = "Pickups"
add_child(root)
var rng := RandomNumberGenerator.new()
rng.seed = world_seed + 6
var want := {"stick": 260, "stone": 170, "flint": 80}
var radius: Dictionary = {"stick": 0.35, "stone": 0.25, "flint": 0.25}
for id: String in want:
var mesh := ItemDB.mesh_for(id)
var placed := 0
var tries := 0
while placed < want[id] and tries < 20000:
tries += 1
var x := rng.randf_range(-terrain.play_radius, terrain.play_radius)
var z := rng.randf_range(-terrain.play_radius, terrain.play_radius)
var yaw := rng.randf() * TAU
var keep := rng.randf()
if not terrain.inside_boundary(x, z, 3.0):
continue
var h: float = terrain.sample_height(x, z)
var n: Vector3 = terrain.sample_normal(x, z)
if h < terrain.water_level + 0.3 or n.y < 0.8:
continue
var chance := 1.0
match id:
"stick":
chance = 0.08 + _forest(x, z) * 0.9
"flint":
var shore := 1.0 - smoothstep(terrain.water_level + 0.5, terrain.water_level + 4.0, h)
chance = 0.06 + shore * 0.9 + (1.0 - n.y) * 2.0
# A few right by the spawn so the first craft is quick.
if Vector2(x - _spawn.x, z - _spawn.z).length() < 18.0:
chance += 0.5
if keep > chance:
continue
var pk: Area3D = PickupScript.new()
pk.setup(id, mesh, ItemDB.item_material(), radius[id])
pk.uid = "p%d" % pickups.size()
pickups[pk.uid] = pk
root.add_child(pk)
pk.global_transform = Transform3D(_ground_basis(n, 1.0, yaw, 1.0), Vector3(x, h - 0.02, z))
placed += 1
# --- grass streaming --------------------------------------------------------
func _update_grass(budget: int) -> void: