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

238
scripts/ui/title_screen.gd Normal file
View File

@@ -0,0 +1,238 @@
extends Node
## Title screen: Continue, New World, Load World (with delete), Quit.
## The island itself, in menu mode, slowly flies past in the background.
const UITheme = preload("res://scripts/ui/theme.gd")
const WORLD_SCENE := "res://scenes/world.tscn"
var _pages: Dictionary = {} ## name -> Control
var _name_edit: LineEdit
var _seed_edit: LineEdit
var _list: VBoxContainer
var _armed_delete := ""
func _ready() -> void:
Saves.current_id = ""
get_tree().auto_accept_quit = true
get_tree().paused = false
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
var backdrop: Node3D = load(WORLD_SCENE).instantiate()
backdrop.menu_mode = true
add_child(backdrop)
# The backdrop's (disabled) player captures the mouse in its _ready; take
# it back, or the menu can't be clicked.
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
var ui := CanvasLayer.new()
ui.layer = 10
add_child(ui)
var root := Control.new()
root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
ui.add_child(root)
# Soft vignette on the left so the text reads over the scenery.
var shade := TextureRect.new()
var g := Gradient.new()
g.offsets = PackedFloat32Array([0.0, 1.0])
g.colors = PackedColorArray([Color(0, 0, 0, 0.6), Color(0, 0, 0, 0)])
var gt := GradientTexture2D.new()
gt.gradient = g
gt.fill_to = Vector2(1, 0)
shade.texture = gt
shade.set_anchors_and_offsets_preset(Control.PRESET_LEFT_WIDE)
shade.offset_right = 720
shade.mouse_filter = Control.MOUSE_FILTER_IGNORE
root.add_child(shade)
var col := VBoxContainer.new()
col.set_anchors_and_offsets_preset(Control.PRESET_LEFT_WIDE)
col.offset_left = 70
col.offset_top = 90
col.offset_right = 520
col.add_theme_constant_override("separation", 14)
root.add_child(col)
col.add_child(UITheme.label("VALHEIMLIKE", 54))
col.add_child(UITheme.label("Survive the island. Build a home.", 18, UITheme.TEXT_DIM))
var spacer := Control.new()
spacer.custom_minimum_size = Vector2(0, 24)
col.add_child(spacer)
_pages["main"] = _main_page()
_pages["new"] = _new_page()
_pages["load"] = _load_page()
for p in _pages.values():
col.add_child(p)
_show("main")
_maybe_capture()
## Dev hook, same as world.gd: `-- --shot=/abs/prefix` saves the title pages.
func _maybe_capture() -> void:
var prefix := ""
for a in OS.get_cmdline_user_args():
if a.begins_with("--shot="):
prefix = a.trim_prefix("--shot=")
if prefix.is_empty():
return
await get_tree().create_timer(1.0).timeout
print("[title] mouse mode: %s" % ["visible", "hidden", "captured", "confined"][Input.mouse_mode])
for page in ["main", "new", "load"]:
_show(page)
await get_tree().create_timer(4.0 if page == "main" else 0.5).timeout
await RenderingServer.frame_post_draw
get_viewport().get_texture().get_image().save_png("%s_title_%s.png" % [prefix, page])
print("[title] saved %s_title_%s.png" % [prefix, page])
get_tree().quit()
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE and not _pages["main"].visible:
_show("main")
func _show(page: String) -> void:
for k in _pages:
_pages[k].visible = k == page
_armed_delete = ""
if page == "load":
_fill_list()
elif page == "new":
_name_edit.text = _unique_name()
_seed_edit.text = ""
_name_edit.grab_focus()
_name_edit.select_all()
func _button(text: String, cb: Callable, min_w: int = 320) -> Button:
var b := Button.new()
b.text = text
b.focus_mode = Control.FOCUS_NONE
b.custom_minimum_size = Vector2(min_w, 46)
b.add_theme_font_size_override("font_size", 19)
b.alignment = HORIZONTAL_ALIGNMENT_LEFT
b.pressed.connect(cb)
return b
func _main_page() -> Control:
var v := VBoxContainer.new()
v.add_theme_constant_override("separation", 10)
var worlds := Saves.list_worlds()
if not worlds.is_empty():
var w: Dictionary = worlds[0]
v.add_child(_button("Continue: %s" % w.name, _play.bind(w.id)))
v.add_child(_button("New World", _show.bind("new")))
var lb := _button("Load World", _show.bind("load"))
lb.disabled = worlds.is_empty()
v.add_child(lb)
v.add_child(_button("Quit", get_tree().quit))
return v
func _new_page() -> Control:
var p := UITheme.panel()
var v := VBoxContainer.new()
v.add_theme_constant_override("separation", 8)
p.add_child(v)
v.add_child(UITheme.label("New World", 24))
v.add_child(UITheme.label("Name", 15, UITheme.TEXT_DIM))
_name_edit = LineEdit.new()
_name_edit.custom_minimum_size = Vector2(360, 40)
_name_edit.max_length = 32
_name_edit.text_submitted.connect(func(_t): _create())
v.add_child(_name_edit)
v.add_child(UITheme.label("Seed (blank = random island, 1337 = the original)", 15, UITheme.TEXT_DIM))
_seed_edit = LineEdit.new()
_seed_edit.placeholder_text = "random"
_seed_edit.custom_minimum_size = Vector2(360, 40)
_seed_edit.text_submitted.connect(func(_t): _create())
v.add_child(_seed_edit)
var h := HBoxContainer.new()
h.add_theme_constant_override("separation", 10)
h.add_child(_button("Create & Play", _create, 170))
h.add_child(_button("Back", _show.bind("main"), 120))
v.add_child(h)
return p
func _load_page() -> Control:
var p := UITheme.panel()
var v := VBoxContainer.new()
v.add_theme_constant_override("separation", 8)
p.add_child(v)
v.add_child(UITheme.label("Load World", 24))
var scroll := ScrollContainer.new()
scroll.custom_minimum_size = Vector2(460, 320)
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
_list = VBoxContainer.new()
_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_list.add_theme_constant_override("separation", 6)
scroll.add_child(_list)
v.add_child(scroll)
v.add_child(_button("Back", _show.bind("main"), 120))
return p
func _fill_list() -> void:
for c in _list.get_children():
c.queue_free()
var worlds := Saves.list_worlds()
if worlds.is_empty():
_list.add_child(UITheme.label("No worlds yet.", 16, UITheme.TEXT_DIM))
return
for w in worlds:
var row := PanelContainer.new()
row.add_theme_stylebox_override("panel", UITheme.box(UITheme.SLOT_BG, UITheme.SLOT_BORDER, 1, 5, 8))
var h := HBoxContainer.new()
h.add_theme_constant_override("separation", 8)
row.add_child(h)
var info := VBoxContainer.new()
info.size_flags_horizontal = Control.SIZE_EXPAND_FILL
info.add_child(UITheme.label(w.name, 18))
var played := int(w.get("play_time", 0.0))
var when := Time.get_datetime_string_from_unix_time(int(w.get("last_played", 0)), true)
info.add_child(UITheme.label("seed %d · played %dh %02dm · %s" % [int(w.seed), played / 3600, (played / 60) % 60, when], 13, UITheme.TEXT_DIM))
h.add_child(info)
h.add_child(_button("Play", _play.bind(w.id), 80))
var del := Button.new()
del.text = "Delete"
del.focus_mode = Control.FOCUS_NONE
del.custom_minimum_size = Vector2(90, 46)
del.add_theme_font_size_override("font_size", 19)
del.pressed.connect(_on_delete.bind(w.id, del))
h.add_child(del)
_list.add_child(row)
func _on_delete(id: String, b: Button) -> void:
if _armed_delete != id:
_armed_delete = id
b.text = "Sure?"
return
Saves.delete_world(id)
_armed_delete = ""
_fill_list()
if Saves.list_worlds().is_empty():
_rebuild_main()
func _rebuild_main() -> void:
var old: Control = _pages["main"]
var parent := old.get_parent()
var idx := old.get_index()
old.queue_free()
_pages["main"] = _main_page()
parent.add_child(_pages["main"])
parent.move_child(_pages["main"], idx)
_pages["main"].visible = false
func _unique_name() -> String:
var names := Saves.list_worlds().map(func(w): return w.name)
var n := names.size() + 1
var base := "Island"
while ("%s %d" % [base, n]) in names:
n += 1
return "%s %d" % [base, n]
func _create() -> void:
var world_name := _name_edit.text.strip_edges()
if world_name.is_empty():
world_name = _unique_name()
var seed_text := _seed_edit.text.strip_edges()
var seed := int(seed_text) if seed_text.is_valid_int() else (seed_text.hash() if not seed_text.is_empty() else randi())
_play(Saves.create_world(world_name, seed))
func _play(id: String) -> void:
Saves.current_id = id
get_tree().change_scene_to_file(WORLD_SCENE)