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