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.
239 lines
7.9 KiB
GDScript
239 lines
7.9 KiB
GDScript
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)
|