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

167
scripts/core/inventory.gd Normal file
View File

@@ -0,0 +1,167 @@
class_name Inventory
extends RefCounted
## Slot-based inventory with stacking. Backs the player, chests and (later)
## grave bags, so moving items is the same operation everywhere.
##
## Pure data: no nodes, no UI. Each slot is either null or {"id": String,
## "count": int}. UI widgets render it and listen to `changed`; mutations from
## gameplay go through Actions (see docs/SURVIVAL_DESIGN.md §3).
signal changed
var slots: Array = []
func _init(size: int = 40) -> void:
slots.resize(size)
func size() -> int:
return slots.size()
func get_slot(i: int):
return slots[i]
func set_slot(i: int, stack) -> void:
slots[i] = _norm(stack)
changed.emit()
## Looked up at runtime rather than via the ItemDB global, so this plain data
## class also compiles in contexts that load it before autoloads exist (tests,
## a future headless server tool).
static func _stack_max(id: String) -> int:
var db := (Engine.get_main_loop() as SceneTree).root.get_node_or_null("ItemDB")
return db.stack_size(id) if db != null else 50
static func _norm(stack):
if stack == null or int(stack.count) <= 0:
return null
return {"id": String(stack.id), "count": int(stack.count)}
func count(id: String) -> int:
var n := 0
for s in slots:
if s != null and s.id == id:
n += s.count
return n
## How many of `id` would fit right now.
func space_for(id: String) -> int:
var cap := _stack_max(id)
var room := 0
for s in slots:
if s == null:
room += cap
elif s.id == id:
room += cap - s.count
return room
## Adds up to `n` of `id`, topping up existing stacks first. Returns the
## leftover that didn't fit (0 = everything fitted).
func add(id: String, n: int) -> int:
var cap := _stack_max(id)
for s in slots:
if n <= 0:
break
if s != null and s.id == id and s.count < cap:
var take := mini(cap - s.count, n)
s.count += take
n -= take
for i in range(slots.size()):
if n <= 0:
break
if slots[i] == null:
var take := mini(cap, n)
slots[i] = {"id": id, "count": take}
n -= take
changed.emit()
return n
## Removes `n` of `id` (from the last stacks first, so the hotbar keeps its
## items longest). Returns false and changes nothing if there aren't enough.
func remove(id: String, n: int) -> bool:
if count(id) < n:
return false
for i in range(slots.size() - 1, -1, -1):
if n <= 0:
break
var s = slots[i]
if s != null and s.id == id:
var take := mini(s.count, n)
s.count -= take
n -= take
if s.count <= 0:
slots[i] = null
changed.emit()
return true
## Removes `n` from a specific slot. Returns how many were removed.
func take_from(i: int, n: int) -> int:
var s = slots[i]
if s == null:
return 0
var take := mini(s.count, n)
s.count -= take
if s.count <= 0:
slots[i] = null
changed.emit()
return take
func has_all(inputs: Dictionary) -> bool:
for id in inputs:
if count(id) < int(inputs[id]):
return false
return true
## Moves the stack in slot `i` into `other` (stacking where possible). Whatever
## doesn't fit stays put. Used for shift-click quick-move.
func quick_move(i: int, other: Inventory) -> void:
var s = slots[i]
if s == null:
return
var left := other.add(s.id, s.count)
slots[i] = null if left <= 0 else {"id": s.id, "count": left}
changed.emit()
## Clicking slot `i` while holding `cursor` (a stack or null). Returns the new
## cursor stack. Left click: pick up / put down / merge / swap. Right click:
## pick up half, or put down one.
func click(i: int, cursor, right: bool):
var s = slots[i]
var result = cursor
if cursor == null:
if s == null:
return null
if right and s.count > 1:
var half := int(ceil(s.count / 2.0))
s.count -= half
result = {"id": s.id, "count": half}
else:
result = s
slots[i] = null
elif s == null:
if right:
slots[i] = {"id": cursor.id, "count": 1}
result = _norm({"id": cursor.id, "count": cursor.count - 1})
else:
slots[i] = cursor
result = null
elif s.id == cursor.id:
var cap := _stack_max(s.id)
var move: int = 1 if right else cursor.count
move = mini(move, cap - s.count)
s.count += move
result = _norm({"id": cursor.id, "count": cursor.count - move})
else:
slots[i] = cursor
result = s
changed.emit()
return result
## Serialisable snapshot (for saves and, later, network replication).
func to_data() -> Array:
return slots.duplicate(true)
func load_data(data: Array) -> void:
slots.resize(data.size())
for i in range(data.size()):
slots[i] = _norm(data[i]) if data[i] != null else null
changed.emit()