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

35
scripts/world/pickup.gd Normal file
View File

@@ -0,0 +1,35 @@
extends Area3D
## An item lying on the ground (stick, stone, flint). Look at it and press E.
## Lives on collision layer 2, so the player walks through it but the
## interaction ray still finds it.
var item_id := "stick"
var amount := 1
var uid := "" ## stable id for saves (see props.gd)
var taken := false
func setup(id: String, mesh: Mesh, material: Material, radius: float) -> void:
item_id = id
collision_layer = 2
collision_mask = 0
monitoring = false
var mi := MeshInstance3D.new()
mi.mesh = mesh
mi.material_override = material
add_child(mi)
var cs := CollisionShape3D.new()
var sph := SphereShape3D.new()
sph.radius = radius
cs.shape = sph
cs.position = Vector3(0, radius * 0.5, 0)
add_child(cs)
func interact_prompt(_player: Node) -> String:
return "[E] Pick up %s" % ItemDB.item_name(item_id)
func interact(player: Node) -> void:
Actions.pickup(player, self)
func collected() -> void:
taken = true
queue_free()

View File

@@ -0,0 +1 @@
uid://d027tu0w5dt0r

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:

View File

@@ -0,0 +1,176 @@
extends StaticBody3D
## A harvestable tree or boulder.
##
## The visible mesh is one instance inside a MultiMesh batch (props.gd), so this
## body only holds collision plus a reference to its instance. Hits shake or
## shrink that instance. When depleted the instance is hidden; a tree is
## replaced by a falling copy and a stump.
##
## Game rules (damage, drops) run through Actions.harvest_hit, which calls hit().
const ProcMesh = preload("res://scripts/world/proc_mesh.gd")
var uid := "" ## stable id for saves (see props.gd)
var kind := "tree" ## "tree" | "rock"
var tool_kind := "axe"
var min_tier := 1
var max_hp := 5.0
var hp := 5.0
var drops_per_hit: Dictionary = {} ## id -> count, given on every hit
var drops_final: Dictionary = {} ## id -> [min, max], given when depleted
var depleted := false
var chip_color := Color(0.6, 0.45, 0.28)
var mmi: MultiMeshInstance3D ## batch holding the visual
var index := -1 ## instance index in that batch
var base_xf := Transform3D()
var stump_material: Material
var rng := RandomNumberGenerator.new()
var _shake := 0.0
var _shake_axis := Vector3.RIGHT
func display_name() -> String:
return "Tree" if kind == "tree" else "Boulder"
func interact_prompt(player: Node) -> String:
var tool: Dictionary = ItemDB.get_item(player.held_item_id()).get("tool", {})
var verb := "Chop" if kind == "tree" else "Mine"
if tool.get("kind", "") == tool_kind:
return "%s [LMB] %s" % [display_name(), verb]
return "%s (needs a %s)" % [display_name(), tool_kind]
## Applies one hit. Returns the items earned by this hit.
func hit(damage: float, point: Vector3, from: Vector3) -> Dictionary:
var out := {}
hp -= damage
for id in drops_per_hit:
out[id] = drops_per_hit[id]
_burst(point, chip_color, 14)
if hp <= 0.0:
depleted = true
for id in drops_final:
var r: Array = drops_final[id]
out[id] = out.get(id, 0) + rng.randi_range(int(r[0]), int(r[1]))
_deplete(from)
else:
_shake = 1.0
var away := (global_position - from)
away.y = 0.0
_shake_axis = away.normalized().cross(Vector3.UP) if away.length() > 0.01 else Vector3.RIGHT
if kind == "rock":
_set_instance(_rock_xf())
return out
func _process(delta: float) -> void:
if _shake <= 0.0 or depleted:
return
_shake = maxf(_shake - delta * 3.5, 0.0)
var base := _rock_xf() if kind == "rock" else base_xf
var wobble := sin(_shake * 40.0) * 0.035 * _shake
_set_instance(Transform3D(Basis(_shake_axis, wobble) * base.basis, base.origin))
## Rocks shrink toward 55% of their size as they're mined.
func _rock_xf() -> Transform3D:
var k := 0.55 + 0.45 * clampf(hp / max_hp, 0.0, 1.0)
return Transform3D(base_xf.basis.scaled_local(Vector3.ONE * k), base_xf.origin)
func _set_instance(xf: Transform3D) -> void:
if mmi != null and index >= 0:
mmi.multimesh.set_instance_transform(index, xf)
## Restores saved damage when a world loads. hp <= 0 = already felled/mined
## (no animation, no drops).
func restore(saved_hp: float) -> void:
hp = saved_hp
if hp <= 0.0:
depleted = true
_deplete(global_position, false)
elif kind == "rock":
_set_instance(_rock_xf())
func _deplete(from: Vector3, animate: bool = true) -> void:
# Hide the batched instance (moved far below ground: a zero scale would
# give the wind shaders a non-invertible matrix).
_set_instance(Transform3D(base_xf.basis, base_xf.origin + Vector3(0, -1000, 0)))
collision_layer = 0
if kind == "tree" and mmi != null:
_fell(from, animate)
else:
if animate:
_burst(global_position + Vector3(0, 0.8, 0), chip_color, 40)
queue_free()
func _fell(from: Vector3, animate: bool) -> void:
var world := get_parent()
if animate:
_fall_animation(world, from)
_make_stump(world)
func _fall_animation(world: Node, from: Vector3) -> void:
var falling := MeshInstance3D.new()
falling.mesh = mmi.multimesh.mesh
world.add_child(falling)
falling.global_transform = base_xf
var away := base_xf.origin - from
away.y = 0.0
var axis := away.normalized().cross(Vector3.DOWN) if away.length() > 0.01 else Vector3.RIGHT
var start := base_xf.basis
var tw := falling.create_tween()
tw.tween_method(func(a: float): falling.global_basis = Basis(axis, a) * start, 0.0, deg_to_rad(86.0), 1.6) \
.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
tw.tween_callback(func(): _burst(falling.global_position + away.normalized() * 3.0, Color(0.3, 0.45, 0.2), 30))
tw.tween_interval(1.2)
tw.tween_property(falling, "position:y", falling.position.y - 4.0, 1.5)
tw.tween_callback(falling.queue_free)
func _make_stump(world: Node) -> void:
var stump := MeshInstance3D.new()
var s := base_xf.basis.get_scale().x
stump.mesh = ProcMesh.stump_mesh(rng.randi(), 0.45, 0.3 * s)
stump.material_override = stump_material
world.add_child(stump)
stump.global_position = base_xf.origin
# Keep collision as a short stump.
for c in get_children():
if c is CollisionShape3D and c.shape is CapsuleShape3D:
var cyl := CylinderShape3D.new()
cyl.radius = (c.shape as CapsuleShape3D).radius
cyl.height = 0.6
c.shape = cyl
c.position = Vector3(0, 0.3, 0)
collision_layer = 1
set_process(false)
remove_from_group("harvestable")
set_meta("is_stump", true)
static func _burst_material(color: Color) -> StandardMaterial3D:
var m := StandardMaterial3D.new()
m.albedo_color = color
m.roughness = 1.0
m.vertex_color_use_as_albedo = false
return m
func _burst(pos: Vector3, color: Color, amount: int) -> void:
var p := CPUParticles3D.new()
var box := BoxMesh.new()
box.size = Vector3.ONE * 0.06
box.material = _burst_material(color)
p.mesh = box
p.amount = amount
p.one_shot = true
p.explosiveness = 0.95
p.lifetime = 0.9
p.direction = Vector3.UP
p.spread = 70.0
p.initial_velocity_min = 2.0
p.initial_velocity_max = 4.5
p.gravity = Vector3(0, -12, 0)
p.scale_amount_min = 0.6
p.scale_amount_max = 1.4
p.angular_velocity_min = -400.0
p.angular_velocity_max = 400.0
get_parent().add_child(p)
p.global_position = pos
p.emitting = true
p.finished.connect(p.queue_free)

View File

@@ -0,0 +1 @@
uid://ctglldcf8jo4c

View File

@@ -69,6 +69,10 @@ var _heights := PackedFloat32Array()
var _normals := PackedVector3Array()
func _ready() -> void:
# A saved world brings its own seed; otherwise keep the exported default.
var saves := get_node_or_null("/root/Saves")
if saves != null and saves.has_world():
noise_seed = saves.world_seed()
_build()
# --- height function --------------------------------------------------------

View File

@@ -18,10 +18,45 @@ extends Node3D
@onready var player: CharacterBody3D = $Player
@onready var sun: DirectionalLight3D = $Sun
const PIECES := {
"campfire": preload("res://scripts/building/campfire.gd"),
"workbench": preload("res://scripts/building/workbench.gd"),
"chest": preload("res://scripts/building/chest.gd"),
}
const AUTOSAVE_SECONDS := 120.0
## Set by the title screen before adding the world: no player, no HUD, no
## saving, just a slow camera flight over the island as a menu backdrop.
var menu_mode := false
var _spawn := Vector3.ZERO
var _spawn_yaw := 0.0
var _pieces: Node3D
var hud: CanvasLayer
var _session_time := 0.0 ## seconds played since the last save
var _autosave_left := AUTOSAVE_SECONDS
var _menu_cam: Camera3D
var _menu_t := 0.0
func _ready() -> void:
_pieces = Node3D.new()
_pieces.name = "Pieces"
add_child(_pieces)
if menu_mode:
player.process_mode = Node.PROCESS_MODE_DISABLED
player.get_node("Camera3D").current = false
_menu_cam = Camera3D.new()
add_child(_menu_cam)
_menu_cam.current = true
$HUD.visible = false
elif DisplayServer.get_name() != "headless":
hud = preload("res://scripts/ui/hud.gd").new()
add_child(hud)
hud.bind(player)
if Saves.has_world() and not menu_mode:
# Save on window close instead of quitting straight away.
get_tree().auto_accept_quit = false
_aim_sun()
player.set_physics_process(false)
set_physics_process(false)
@@ -43,13 +78,124 @@ func _on_terrain_ready() -> void:
var to_lake := lake - Vector2(_spawn.x, _spawn.z)
_spawn_yaw = atan2(-to_lake.x, -to_lake.y)
player.water_level = terrain.water_level
props.place_on(terrain, player, _spawn)
props.place_on(terrain, player if not menu_mode else _menu_cam, _spawn)
print("[world] scenery placed in %d ms" % (Time.get_ticks_msec() - t0))
if menu_mode:
return
_respawn()
if Saves.has_world():
var state: Dictionary = Saves.load_state(Saves.current_id)
if not state.is_empty():
apply_save(state)
print("[world] loaded '%s'" % Saves.current_id)
player.set_physics_process(true)
set_physics_process(true)
_maybe_capture()
func _process(delta: float) -> void:
if menu_mode:
# Slow orbit over the valley, looking toward the lake.
_menu_t += delta * 0.025
var r := 70.0
var p := Vector3(cos(_menu_t) * r, 0.0, sin(_menu_t) * r)
p.y = maxf(terrain.sample_height(p.x, p.z), terrain.water_level) + 24.0
_menu_cam.global_position = p
var lake: Vector2 = terrain.lake_center
_menu_cam.look_at(Vector3(lake.x * 0.5, 2.0, lake.y * 0.5), Vector3.UP)
return
if not Saves.has_world() or get_tree().paused:
return
_session_time += delta
_autosave_left -= delta
if _autosave_left <= 0.0:
save_now()
# --- saving -----------------------------------------------------------------
## Writes the current game to the active world slot. Returns false if this
## session isn't tied to a slot (e.g. running world.tscn directly).
func save_now() -> bool:
_autosave_left = AUTOSAVE_SECONDS
if not Saves.has_world():
return false
var ok: bool = Saves.save_state(Saves.current_id, collect_save(), _session_time)
if ok:
_session_time = 0.0
Actions.notify.emit("Game saved" if ok else "Save FAILED")
return ok
func _notification(what: int) -> void:
if what == NOTIFICATION_WM_CLOSE_REQUEST and Saves.has_world() and not menu_mode:
save_now()
get_tree().quit()
static func _xf_to_array(xf: Transform3D) -> Array:
var b := xf.basis
return [b.x.x, b.x.y, b.x.z, b.y.x, b.y.y, b.y.z, b.z.x, b.z.y, b.z.z, xf.origin.x, xf.origin.y, xf.origin.z]
static func _array_to_xf(a: Array) -> Transform3D:
return Transform3D(Vector3(a[0], a[1], a[2]), Vector3(a[3], a[4], a[5]), Vector3(a[6], a[7], a[8]), Vector3(a[9], a[10], a[11]))
## Everything the player changed. The rest regenerates from the seed.
func collect_save() -> Dictionary:
var cam: Camera3D = player.get_node("Camera3D")
var harvested := {}
for uid in props.resource_nodes:
var n = props.resource_nodes[uid]
if not is_instance_valid(n) or n.depleted:
harvested[uid] = 0.0
elif n.hp < n.max_hp:
harvested[uid] = n.hp
var taken: Array = []
for uid in props.pickups:
var pk = props.pickups[uid]
if not is_instance_valid(pk) or pk.taken:
taken.append(uid)
var pieces: Array = []
for piece in _pieces.get_children():
if piece.is_queued_for_deletion():
continue
var entry := {"item": piece.item_id, "xf": _xf_to_array(piece.global_transform)}
if piece.get("inventory") != null:
entry["inventory"] = piece.inventory.to_data()
pieces.append(entry)
return {
"player": {
"pos": [player.global_position.x, player.global_position.y, player.global_position.z],
"yaw": player.rotation.y,
"pitch": cam.rotation.x,
"inventory": player.inventory.to_data(),
"selected": player.selected,
},
"harvested": harvested,
"pickups_taken": taken,
"pieces": pieces,
}
func apply_save(s: Dictionary) -> void:
var p: Dictionary = s.get("player", {})
if p.has("pos"):
player.global_position = Vector3(p.pos[0], p.pos[1] + 0.05, p.pos[2])
player.rotation = Vector3(0.0, float(p.get("yaw", 0.0)), 0.0)
player.get_node("Camera3D").rotation.x = float(p.get("pitch", 0.0))
if p.has("inventory"):
player.inventory.load_data(p.inventory)
player.select(int(p.get("selected", 0)))
var harvested: Dictionary = s.get("harvested", {})
for uid in harvested:
if props.resource_nodes.has(uid):
props.resource_nodes[uid].restore(float(harvested[uid]))
for uid in s.get("pickups_taken", []):
if props.pickups.has(uid) and is_instance_valid(props.pickups[uid]):
props.pickups[uid].collected()
for e in s.get("pieces", []):
var def: Dictionary = ItemDB.get_item(e.get("item", ""))
if def.is_empty() or not def.has("place"):
continue
var piece := spawn_piece(def, _array_to_xf(e.xf))
if e.has("inventory") and piece.get("inventory") != null:
piece.inventory.load_data(e.inventory)
func _respawn() -> void:
player.global_position = _spawn + Vector3(0.0, 1.5, 0.0)
player.velocity = Vector3.ZERO
@@ -64,6 +210,42 @@ func _physics_process(_delta: float) -> void:
push_warning("[world] player left the map at %s; respawning" % p)
_respawn()
# --- building ---------------------------------------------------------------
## Whether a piece can go at `xf`: inside the valley, on dry fairly flat
## ground, and not overlapping trees, rocks or other pieces.
func placement_valid(def: Dictionary, xf: Transform3D) -> bool:
var p := xf.origin
if not terrain.inside_boundary(p.x, p.z, 1.5):
return false
if p.y < terrain.water_level + 0.15:
return false
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.
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:
return false
# Overlap test against everything solid except the ground itself.
var box := BoxShape3D.new()
box.size = Vector3(hx * 2.0 - 0.1, 1.0, hz * 2.0 - 0.1)
var q := PhysicsShapeQueryParameters3D.new()
q.shape = box
q.transform = Transform3D(xf.basis, p + Vector3(0, 0.75, 0))
q.collision_mask = 1
q.exclude = [terrain.get_node("TerrainBody").get_rid(), player.get_rid()]
return get_world_3d().direct_space_state.intersect_shape(q, 1).is_empty()
func spawn_piece(def: Dictionary, xf: Transform3D) -> Node3D:
var script: Script = PIECES.get(def.place.piece, preload("res://scripts/building/piece.gd"))
var piece: StaticBody3D = script.new()
piece.setup(def)
_pieces.add_child(piece)
piece.global_transform = xf
return piece
# --- dev screenshot hook ----------------------------------------------------
func _maybe_capture() -> void:
@@ -113,4 +295,46 @@ func _maybe_capture() -> void:
var path := "%s_%s.png" % [prefix, v[0]]
get_viewport().get_texture().get_image().save_png(path)
print("[world] saved ", path)
await _capture_survival(prefix, cam)
get_tree().quit()
## Survival views: a small camp in front of the spawn (axe in hand), then the
## inventory screen with a chest open.
func _capture_survival(prefix: String, cam: Camera3D) -> void:
_respawn()
var inv: Inventory = player.inventory
for pair in [["stone_axe", 1], ["stone_pickaxe", 1], ["campfire", 1], ["club", 1],
["wood", 23], ["stick", 12], ["stone", 17], ["flint", 5]]:
inv.add(pair[0], pair[1])
var fwd := -player.global_basis.z
var right := player.global_basis.x
var chest: Node = null
for k in [["campfire", 4.5, 0.0], ["workbench", 7.0, -2.8], ["chest", 6.5, 2.4]]:
var p: Vector3 = player.global_position + fwd * float(k[1]) + right * float(k[2])
p.y = terrain.sample_height(p.x, p.z)
var piece := spawn_piece(ItemDB.get_item(k[0]), Transform3D(Basis(Vector3.UP, player.rotation.y), p))
if k[0] == "chest":
chest = piece
player.select(0)
cam.rotation.x = -0.22
await get_tree().create_timer(2.5).timeout
await RenderingServer.frame_post_draw
get_viewport().get_texture().get_image().save_png("%s_camp.png" % prefix)
print("[world] saved %s_camp.png" % prefix)
chest.inventory.add("wood", 40)
chest.inventory.add("stone", 12)
player.global_position = chest.global_position + fwd * -2.0
hud._screen.open(chest)
await get_tree().create_timer(0.6).timeout
await RenderingServer.frame_post_draw
get_viewport().get_texture().get_image().save_png("%s_inventory.png" % prefix)
print("[world] saved %s_inventory.png" % prefix)
hud._screen.close()
hud._menu.open()
await get_tree().create_timer(0.4).timeout
await RenderingServer.frame_post_draw
get_viewport().get_texture().get_image().save_png("%s_menu.png" % prefix)
print("[world] saved %s_menu.png" % prefix)
hud._menu.close()