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

179
scripts/player/hands.gd Normal file
View File

@@ -0,0 +1,179 @@
extends Node3D
## The player's hands: what you're looking at, what you're holding, and what
## clicking does. Lives under the camera.
##
## - Looking at something interactable shows its prompt (E to interact).
## - LMB with a tool swings it; the hit lands mid-swing via Actions.harvest_hit.
## - Holding a placeable shows a ghost preview: green = valid, red = blocked.
## R rotates, LMB places (Actions.place).
## - Keys 1-8 / mouse wheel pick the hotbar slot.
signal prompt_changed(text: String)
const REACH := 3.2
const PLACE_REACH := 6.0
const SWING_TIME := 0.5
var player: CharacterBody3D
var _ray: RayCast3D
var _view: MeshInstance3D ## held item, first-person
var _view_rest := Transform3D()
var _swing_cd := 0.0
var _ghost: MeshInstance3D
var _ghost_ok_mat: StandardMaterial3D
var _ghost_bad_mat: StandardMaterial3D
var _ghost_yaw := 0.0
var _place_xf := Transform3D()
var _place_ok := false
var _prompt := ""
var _held_shown := "~"
func _ready() -> void:
_ray = RayCast3D.new()
_ray.target_position = Vector3(0, 0, -REACH)
_ray.collision_mask = 1 | 2
_ray.collide_with_areas = true
_ray.add_exception(player)
add_child(_ray)
_view = MeshInstance3D.new()
_view.material_override = ItemDB.item_material()
_view.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
_view_rest = Transform3D(Basis.from_euler(Vector3(deg_to_rad(-25), deg_to_rad(-10), deg_to_rad(-20))), Vector3(0.36, -0.42, -0.62))
_view.transform = _view_rest
add_child(_view)
_ghost_ok_mat = _ghost_material(Color(0.3, 1.0, 0.4, 0.45))
_ghost_bad_mat = _ghost_material(Color(1.0, 0.25, 0.2, 0.45))
_ghost = MeshInstance3D.new()
_ghost.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
_ghost.visible = false
_ghost.top_level = true
add_child(_ghost)
player.inventory.changed.connect(_refresh_held)
player.selected_changed.connect(_refresh_held)
_refresh_held()
static func _ghost_material(c: Color) -> StandardMaterial3D:
var m := StandardMaterial3D.new()
m.albedo_color = c
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
m.no_depth_test = false
return m
func _held_def() -> Dictionary:
return ItemDB.get_item(player.held_item_id())
func _refresh_held() -> void:
var id: String = player.held_item_id()
if id == _held_shown:
return
_held_shown = id
var def := _held_def()
_view.visible = not id.is_empty() and not def.has("place")
if _view.visible:
_view.mesh = ItemDB.mesh_for(id)
var s := 0.85 if def.category in ["tool", "weapon"] else 0.6
_view.scale = Vector3.ONE * s
if def.has("place"):
_ghost.mesh = ItemDB.mesh_for(id)
_ghost_yaw = 0.0
else:
_ghost.visible = false
func _set_prompt(t: String) -> void:
if t != _prompt:
_prompt = t
prompt_changed.emit(t)
func _target() -> Object:
if not _ray.is_colliding():
return null
var c := _ray.get_collider()
if c == null or not c.has_method("interact_prompt"):
return null
if c.get("depleted") == true:
return null
return c
func _physics_process(delta: float) -> void:
_swing_cd = maxf(_swing_cd - delta, 0.0)
if player.ui_open:
_ghost.visible = false
_set_prompt("")
return
var def := _held_def()
if def.has("place"):
_update_ghost(def)
_set_prompt("[LMB] Place %s [R] Rotate" % def.name)
return
_ghost.visible = false
var t := _target()
_set_prompt(t.interact_prompt(player) if t != null else "")
func _update_ghost(def: Dictionary) -> void:
var cam := get_parent() as Camera3D
var from := cam.global_position
var to := from - cam.global_basis.z * PLACE_REACH
var q := PhysicsRayQueryParameters3D.create(from, to, 1, [player.get_rid()])
var hit := cam.get_world_3d().direct_space_state.intersect_ray(q)
if hit.is_empty():
_ghost.visible = false
_place_ok = false
return
var yaw := player.rotation.y + _ghost_yaw # piece's front (+Z) faces the player
_place_xf = Transform3D(Basis(Vector3.UP, yaw), hit.position)
_place_ok = player.get_parent().placement_valid(def, _place_xf)
_ghost.global_transform = _place_xf
_ghost.material_override = _ghost_ok_mat if _place_ok else _ghost_bad_mat
_ghost.visible = true
func _unhandled_input(event: InputEvent) -> void:
if player.ui_open:
return
for i in range(8):
if event.is_action_pressed("hotbar_%d" % (i + 1)):
player.select(i)
return
if event is InputEventMouseButton and event.pressed and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
if event.button_index == MOUSE_BUTTON_WHEEL_UP:
player.select(player.selected - 1)
elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
player.select(player.selected + 1)
if event.is_action_pressed("rotate_piece"):
_ghost_yaw += deg_to_rad(22.5)
elif event.is_action_pressed("interact"):
var t := _target()
if t != null and t.has_method("interact"):
t.interact(player)
elif event.is_action_pressed("attack") and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
if _held_def().has("place"):
if _place_ok:
Actions.place(player, player.selected, _place_xf, player.get_parent())
else:
Actions.notify.emit("Can't place that here")
else:
swing()
## Swings the held item (or fist). The hit is applied at the peak of the swing.
func swing() -> void:
if _swing_cd > 0.0:
return
_swing_cd = SWING_TIME
var tw := create_tween()
var wind := Transform3D(_view_rest.basis.rotated(Vector3.RIGHT, deg_to_rad(35)), _view_rest.origin + Vector3(0, 0.08, 0.05))
var strike := Transform3D(_view_rest.basis.rotated(Vector3.RIGHT, deg_to_rad(-70)), _view_rest.origin + Vector3(-0.05, -0.05, -0.15))
tw.tween_property(_view, "transform", wind, 0.1).set_ease(Tween.EASE_OUT)
tw.tween_property(_view, "transform", strike, 0.1).set_ease(Tween.EASE_IN)
tw.tween_callback(_apply_hit)
tw.tween_property(_view, "transform", _view_rest, 0.25).set_trans(Tween.TRANS_SINE)
func _apply_hit() -> void:
_ray.force_raycast_update()
if not _ray.is_colliding():
return
var c := _ray.get_collider()
if c != null and c.is_in_group("harvestable"):
Actions.harvest_hit(player, c, _ray.get_collision_point())

View File

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

View File

@@ -18,19 +18,46 @@ const FLOAT_DEPTH := 1.3
@onready var camera: Camera3D = $Camera3D
signal selected_changed
signal inventory_requested(container: Node) ## the HUD opens the inventory screen
const HOTBAR := 8
var inventory := Inventory.new(40) ## slots 0-7 are the hotbar
var selected := 0
var ui_open := false ## set by the inventory screen; freezes look/move
var hands: Node3D
var _gravity: float = ProjectSettings.get_setting("physics/3d/default_gravity", 9.8)
func _ready() -> void:
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
if not get_parent().get("menu_mode"): # title-screen backdrop: leave the cursor alone
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
hands = preload("res://scripts/player/hands.gd").new()
hands.name = "Hands"
hands.player = self
camera.add_child(hands)
func held_item_id() -> String:
var s = inventory.get_slot(selected)
return s.id if s != null else ""
func select(i: int) -> void:
selected = wrapi(i, 0, HOTBAR)
selected_changed.emit()
## Called by workbenches and chests (container = null for plain crafting).
func open_inventory(container: Node) -> void:
inventory_requested.emit(container)
func _unhandled_input(event: InputEvent) -> void:
if ui_open:
return
if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
rotate_y(-event.relative.x * mouse_sensitivity)
camera.rotate_x(-event.relative.y * mouse_sensitivity)
camera.rotation.x = clamp(camera.rotation.x, -1.4, 1.4)
elif event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
elif event is InputEventMouseButton and event.pressed:
# Recapture after the window lost focus (ESC now opens the pause menu).
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
func _physics_process(delta: float) -> void:
@@ -43,13 +70,14 @@ func _physics_process(delta: float) -> void:
elif not is_on_floor():
velocity.y -= _gravity * delta
if Input.is_action_just_pressed("jump") and is_on_floor():
var jump := Input.is_action_just_pressed("jump") and not ui_open
if jump and is_on_floor():
velocity.y = jump_velocity
elif Input.is_action_just_pressed("jump") and global_position.y > float_y - 0.4 and global_position.y < float_y + 0.3:
elif jump and global_position.y > float_y - 0.4 and global_position.y < float_y + 0.3:
# Swim-jump: kick up out of the water to climb onto a bank.
velocity.y = jump_velocity
var input_dir := Input.get_vector("move_left", "move_right", "move_forward", "move_back")
var input_dir := Vector2.ZERO if ui_open else Input.get_vector("move_left", "move_right", "move_forward", "move_back")
var direction := (transform.basis * Vector3(input_dir.x, 0, input_dir.y)).normalized()
var current_speed := sprint_speed if Input.is_key_pressed(KEY_SHIFT) else speed