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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user