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:
35
scripts/world/pickup.gd
Normal file
35
scripts/world/pickup.gd
Normal 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()
|
||||
1
scripts/world/pickup.gd.uid
Normal file
1
scripts/world/pickup.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://d027tu0w5dt0r
|
||||
@@ -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:
|
||||
|
||||
176
scripts/world/resource_node.gd
Normal file
176
scripts/world/resource_node.gd
Normal 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)
|
||||
1
scripts/world/resource_node.gd.uid
Normal file
1
scripts/world/resource_node.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://ctglldcf8jo4c
|
||||
@@ -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 --------------------------------------------------------
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user