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

139
scripts/ui/hud.gd Normal file
View File

@@ -0,0 +1,139 @@
extends CanvasLayer
## In-game HUD: crosshair, interaction prompt, hotbar, toasts, and the
## inventory screen. Rendering only: it reads player state and forwards
## clicks to Actions.
const UITheme = preload("res://scripts/ui/theme.gd")
const SlotScript = preload("res://scripts/ui/slot.gd")
const InventoryScreen = preload("res://scripts/ui/inventory_screen.gd")
const PauseMenu = preload("res://scripts/ui/pause_menu.gd")
var player: CharacterBody3D
var _prompt: Label
var _held_name: Label
var _hotbar: Array = []
var _toasts: VBoxContainer
var _screen: Control
var _menu: Control
func bind(p: CharacterBody3D) -> void:
player = p
layer = 5
var root := Control.new()
root.set_anchors_preset(Control.PRESET_FULL_RECT)
root.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(root)
# Crosshair.
var dot := ColorRect.new()
dot.color = Color(1, 1, 1, 0.8)
dot.size = Vector2(4, 4)
dot.set_anchors_preset(Control.PRESET_CENTER)
dot.position -= Vector2(2, 2)
dot.mouse_filter = Control.MOUSE_FILTER_IGNORE
root.add_child(dot)
_prompt = UITheme.label("", 18)
_prompt.set_anchors_and_offsets_preset(Control.PRESET_CENTER)
_prompt.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_prompt.grow_horizontal = Control.GROW_DIRECTION_BOTH
_prompt.offset_top = 28
_prompt.offset_bottom = 56
root.add_child(_prompt)
# Hotbar.
var bar := HBoxContainer.new()
bar.add_theme_constant_override("separation", 6)
bar.set_anchors_and_offsets_preset(Control.PRESET_CENTER_BOTTOM)
bar.grow_horizontal = Control.GROW_DIRECTION_BOTH
bar.grow_vertical = Control.GROW_DIRECTION_BEGIN
bar.offset_bottom = -18
bar.mouse_filter = Control.MOUSE_FILTER_IGNORE
for i in range(8):
var s: Panel = SlotScript.new(i, 64, str(i + 1))
s.mouse_filter = Control.MOUSE_FILTER_IGNORE
bar.add_child(s)
_hotbar.append(s)
root.add_child(bar)
_held_name = UITheme.label("", 15, UITheme.TEXT_DIM)
_held_name.set_anchors_and_offsets_preset(Control.PRESET_CENTER_BOTTOM)
_held_name.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_held_name.grow_horizontal = Control.GROW_DIRECTION_BOTH
_held_name.offset_top = -112
_held_name.offset_bottom = -90
root.add_child(_held_name)
_toasts = VBoxContainer.new()
_toasts.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_RIGHT)
_toasts.grow_horizontal = Control.GROW_DIRECTION_BEGIN
_toasts.grow_vertical = Control.GROW_DIRECTION_BEGIN
_toasts.offset_right = -24
_toasts.offset_bottom = -110
_toasts.alignment = BoxContainer.ALIGNMENT_END
_toasts.mouse_filter = Control.MOUSE_FILTER_IGNORE
root.add_child(_toasts)
_screen = InventoryScreen.new()
root.add_child(_screen)
_screen.bind(player)
_menu = PauseMenu.new()
root.add_child(_menu)
_menu.bind(player)
process_mode = Node.PROCESS_MODE_ALWAYS # so ESC works while paused
_screen.process_mode = Node.PROCESS_MODE_PAUSABLE
player.inventory.changed.connect(_refresh)
player.selected_changed.connect(_refresh)
player.inventory_requested.connect(_on_inventory_requested)
player.hands.prompt_changed.connect(func(t): _prompt.text = t)
Actions.notify.connect(toast)
ItemDB.icons_ready.connect(_refresh)
_refresh()
func _on_inventory_requested(container: Node) -> void:
if not _screen.is_open():
_screen.open(container)
## ESC: close the inventory if it's open, otherwise toggle the pause menu.
func _input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_ESCAPE:
if _screen.is_open():
_screen.close()
elif _menu.is_open():
_menu.back()
else:
_menu.open()
get_viewport().set_input_as_handled()
func _unhandled_input(event: InputEvent) -> void:
if _menu.is_open():
return
if event.is_action_pressed("inventory") and not _screen.is_open():
_screen.open(null)
get_viewport().set_input_as_handled()
func _refresh() -> void:
for i in range(_hotbar.size()):
_hotbar[i].set_stack(player.inventory.get_slot(i))
_hotbar[i].set_selected(i == player.selected)
var id: String = player.held_item_id()
_held_name.text = ItemDB.item_name(id) if not id.is_empty() else ""
func toast(text: String) -> void:
# Merge repeated "+N Item" toasts so chopping doesn't spam the corner.
for c in _toasts.get_children():
if c.get_meta("base", "") == text:
c.modulate.a = 1.0
return
var l := UITheme.label(text, 17)
l.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
l.set_meta("base", text)
_toasts.add_child(l)
while _toasts.get_child_count() > 6:
_toasts.get_child(0).free()
var tw := l.create_tween()
tw.tween_interval(2.2)
tw.tween_property(l, "modulate:a", 0.0, 0.8)
tw.tween_callback(l.queue_free)

1
scripts/ui/hud.gd.uid Normal file
View File

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

View File

@@ -0,0 +1,263 @@
extends Control
## Inventory screen (Tab): your 40 slots, the crafting list, and an open
## chest beside them.
##
## Click picks up or puts down a stack, right-click splits it or drops one,
## shift-click quick-moves between you and the chest. Crafting shows every
## recipe; the ones you can't make yet are dimmed, with the missing inputs and
## station in red. All changes go through Actions.
const UITheme = preload("res://scripts/ui/theme.gd")
const SlotScript = preload("res://scripts/ui/slot.gd")
const COLS := 8
var player: CharacterBody3D
var container: Node = null ## open chest, or null
var _player_slots: Array = []
var _chest_slots: Array = []
var _chest_panel: PanelContainer
var _chest_grid: GridContainer
var _chest_title: Label
var _recipe_list: VBoxContainer
var _station_label: Label
var _cursor = null ## stack held on the mouse
var _cursor_view: Control
var _cursor_icon: TextureRect
var _cursor_count: Label
func bind(p: CharacterBody3D) -> void:
player = p
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
mouse_filter = Control.MOUSE_FILTER_STOP
visible = false
var dim := ColorRect.new()
dim.color = Color(0, 0, 0, 0.35)
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
dim.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(dim)
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
center.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(center)
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 16)
center.add_child(row)
# Chest panel (only while a chest is open).
_chest_panel = UITheme.panel()
var cv := VBoxContainer.new()
_chest_title = UITheme.label("Chest", 20)
cv.add_child(_chest_title)
_chest_grid = GridContainer.new()
_chest_grid.columns = 4
cv.add_child(_chest_grid)
_chest_panel.add_child(cv)
row.add_child(_chest_panel)
# Player inventory.
var pp := UITheme.panel()
var pv := VBoxContainer.new()
pv.add_theme_constant_override("separation", 8)
pv.add_child(UITheme.label("Inventory", 20))
var grid := GridContainer.new()
grid.columns = COLS
for i in range(player.inventory.size()):
var s: Panel = SlotScript.new(i, 60, str(i + 1) if i < 8 else "")
s.clicked.connect(_on_player_slot)
grid.add_child(s)
_player_slots.append(s)
if i == 7:
pass
pv.add_child(grid)
pv.add_child(UITheme.label("Row 1 is your hotbar. Shift-click moves stacks to/from a chest.", 13, UITheme.TEXT_DIM))
pp.add_child(pv)
row.add_child(pp)
# Crafting.
var cp := UITheme.panel()
cp.custom_minimum_size = Vector2(340, 0)
var crv := VBoxContainer.new()
crv.add_theme_constant_override("separation", 6)
crv.add_child(UITheme.label("Crafting", 20))
_station_label = UITheme.label("", 13, UITheme.TEXT_DIM)
crv.add_child(_station_label)
var scroll := ScrollContainer.new()
scroll.custom_minimum_size = Vector2(320, 380)
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
_recipe_list = VBoxContainer.new()
_recipe_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_recipe_list.add_theme_constant_override("separation", 6)
scroll.add_child(_recipe_list)
crv.add_child(scroll)
cp.add_child(crv)
row.add_child(cp)
# Stack on the cursor.
_cursor_view = Control.new()
_cursor_view.mouse_filter = Control.MOUSE_FILTER_IGNORE
_cursor_view.top_level = true
_cursor_icon = TextureRect.new()
_cursor_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
_cursor_icon.size = Vector2(52, 52)
_cursor_icon.position = Vector2(-26, -26)
_cursor_icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
_cursor_view.add_child(_cursor_icon)
_cursor_count = UITheme.label("", 15)
_cursor_count.position = Vector2(8, 6)
_cursor_view.add_child(_cursor_count)
add_child(_cursor_view)
player.inventory.changed.connect(_refresh)
ItemDB.icons_ready.connect(_refresh)
func is_open() -> bool:
return visible
func open(chest: Node) -> void:
container = chest
if container != null:
container.inventory.changed.connect(_refresh)
visible = true
player.ui_open = true
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
_rebuild_chest()
_refresh()
func close() -> void:
if _cursor != null:
var left: int = player.inventory.add(_cursor.id, _cursor.count)
if left > 0 and container != null:
left = container.inventory.add(_cursor.id, left)
_cursor = null
if container != null and container.inventory.changed.is_connected(_refresh):
container.inventory.changed.disconnect(_refresh)
container = null
visible = false
player.ui_open = false
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
func _input(event: InputEvent) -> void:
if not visible:
return
if event.is_action_pressed("inventory"): # ESC is handled by the HUD
close()
get_viewport().set_input_as_handled()
elif event is InputEventMouseMotion:
_cursor_view.global_position = event.position
# Walking away from a chest closes it.
if container != null and player.global_position.distance_to(container.global_position) > 5.0:
close()
func _process(_d: float) -> void:
if visible and container != null and player.global_position.distance_to(container.global_position) > 5.0:
close()
func _rebuild_chest() -> void:
for c in _chest_grid.get_children():
c.queue_free()
_chest_slots.clear()
_chest_panel.visible = container != null
if container == null:
return
_chest_title.text = container.display_name()
for i in range(container.inventory.size()):
var s: Panel = SlotScript.new(i, 60)
s.clicked.connect(_on_chest_slot)
_chest_grid.add_child(s)
_chest_slots.append(s)
func _on_player_slot(i: int, right: bool, shift: bool) -> void:
if shift and container != null:
Actions.transfer(player, player.inventory, i, container.inventory, container)
else:
_cursor = player.inventory.click(i, _cursor, right)
_refresh()
func _on_chest_slot(i: int, right: bool, shift: bool) -> void:
if not Actions._in_reach(player, container, 1.0):
return
if shift:
Actions.transfer(player, container.inventory, i, player.inventory, container)
else:
_cursor = container.inventory.click(i, _cursor, right)
_refresh()
func _refresh() -> void:
if player == null:
return
for i in range(_player_slots.size()):
_player_slots[i].set_stack(player.inventory.get_slot(i))
_player_slots[i].set_selected(i == player.selected)
if container != null:
for i in range(_chest_slots.size()):
_chest_slots[i].set_stack(container.inventory.get_slot(i))
_cursor_view.visible = _cursor != null
if _cursor != null:
_cursor_icon.texture = ItemDB.icon(_cursor.id)
_cursor_count.text = str(_cursor.count) if _cursor.count > 1 else ""
_rebuild_recipes()
func _rebuild_recipes() -> void:
if not visible:
return
for c in _recipe_list.get_children():
c.queue_free()
var stations: Array[String] = []
for st in ["workbench", "campfire"]:
if Actions.station_near(player, st):
stations.append(st)
_station_label.text = "Nearby: " + (", ".join(stations) if not stations.is_empty() else "nothing (hand crafting only)")
for rid in ItemDB.recipes:
_recipe_list.add_child(_recipe_row(ItemDB.recipes[rid]))
func _recipe_row(r: Dictionary) -> Control:
var ok := Actions.can_craft(player, r.id)
var row := PanelContainer.new()
row.add_theme_stylebox_override("panel", UITheme.box(UITheme.SLOT_BG, UITheme.SELECTED if ok else UITheme.SLOT_BORDER, 1, 5, 6))
var h := HBoxContainer.new()
h.add_theme_constant_override("separation", 8)
row.add_child(h)
var icon := TextureRect.new()
icon.texture = ItemDB.icon(r.output)
icon.custom_minimum_size = Vector2(44, 44)
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
icon.modulate = Color.WHITE if ok else Color(1, 1, 1, 0.45)
h.add_child(icon)
var v := VBoxContainer.new()
v.size_flags_horizontal = Control.SIZE_EXPAND_FILL
var title := ItemDB.item_name(r.output) + (" x%d" % r.count if r.count > 1 else "")
v.add_child(UITheme.label(title, 16, UITheme.TEXT if ok else UITheme.TEXT_DIM))
var req := RichTextLabel.new()
req.bbcode_enabled = true
req.fit_content = true
req.scroll_active = false
req.autowrap_mode = TextServer.AUTOWRAP_OFF
req.add_theme_font_size_override("normal_font_size", 13)
var parts: Array[String] = []
for id in r.inputs:
var need := int(r.inputs[id])
var have: int = player.inventory.count(id)
var col := UITheme.OK if have >= need else UITheme.BAD
parts.append("[color=#%s]%s %d/%d[/color]" % [col.to_html(false), ItemDB.item_name(id), have, need])
if not r.station.is_empty():
var col := UITheme.OK if Actions.station_near(player, r.station) else UITheme.BAD
parts.append("[color=#%s]@ %s[/color]" % [col.to_html(false), r.station])
req.text = " ".join(parts)
req.custom_minimum_size = Vector2(200, 0)
v.add_child(req)
h.add_child(v)
var b := Button.new()
b.text = "Craft"
b.disabled = not ok
b.focus_mode = Control.FOCUS_NONE
b.pressed.connect(_craft.bind(r.id))
h.add_child(b)
return row
func _craft(recipe_id: String) -> void:
Actions.craft(player, recipe_id)
_refresh()

View File

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

167
scripts/ui/pause_menu.gd Normal file
View File

@@ -0,0 +1,167 @@
extends Control
## Pause menu (ESC): resume, controls, fullscreen, mouse sensitivity, quit.
## Pauses the scene tree while open; runs with PROCESS_MODE_ALWAYS so it still
## gets input.
const UITheme = preload("res://scripts/ui/theme.gd")
const CONTROLS := [
["W A S D", "Move"], ["Shift", "Sprint"], ["Space", "Jump / swim up"],
["Mouse", "Look"], ["E", "Interact / pick up"], ["Left click", "Use tool / place"],
["R", "Rotate piece"], ["1 - 8, wheel", "Hotbar"], ["Tab", "Inventory & crafting"],
["Shift + click", "Move stack to/from a chest"], ["Right click", "Split stack"],
["Esc", "This menu (game autosaves every 2 min)"],
]
var player: CharacterBody3D
var _main: VBoxContainer
var _controls: VBoxContainer
var _quit_btn: Button
var _quit_armed := false
func bind(p: CharacterBody3D) -> void:
player = p
process_mode = Node.PROCESS_MODE_ALWAYS
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
mouse_filter = Control.MOUSE_FILTER_STOP
visible = false
var dim := ColorRect.new()
dim.color = Color(0, 0, 0, 0.55)
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
dim.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(dim)
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
center.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(center)
var panel := UITheme.panel()
panel.custom_minimum_size = Vector2(380, 0)
center.add_child(panel)
var stack := VBoxContainer.new()
panel.add_child(stack)
# Main page.
_main = VBoxContainer.new()
_main.add_theme_constant_override("separation", 10)
stack.add_child(_main)
var title := UITheme.label("Paused", 30)
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_main.add_child(title)
_main.add_child(_button("Resume", close))
_main.add_child(_button("Controls", _show_controls.bind(true)))
var fs := CheckButton.new()
fs.text = "Fullscreen"
fs.focus_mode = Control.FOCUS_NONE
fs.button_pressed = DisplayServer.window_get_mode() == DisplayServer.WINDOW_MODE_FULLSCREEN
fs.add_theme_font_size_override("font_size", 18)
fs.toggled.connect(_on_fullscreen)
_main.add_child(fs)
var sens_row := HBoxContainer.new()
sens_row.add_child(UITheme.label("Mouse sensitivity", 16))
var sens := HSlider.new()
sens.min_value = 0.0005
sens.max_value = 0.006
sens.step = 0.0001
sens.value = player.mouse_sensitivity
sens.size_flags_horizontal = Control.SIZE_EXPAND_FILL
sens.focus_mode = Control.FOCUS_NONE
sens.value_changed.connect(func(v: float): player.mouse_sensitivity = v)
sens_row.add_child(sens)
_main.add_child(sens_row)
var saved: bool = Saves.has_world()
if saved:
_main.add_child(_button("Save & Quit to Title", _on_quit_to_title))
_quit_btn = _button("Save & Quit to Desktop", _on_quit)
else:
_quit_btn = _button("Quit to Desktop", _on_quit)
_main.add_child(_quit_btn)
var note_text := "Autosaves every 2 minutes. World: %s" % Saves.read_meta(Saves.current_id).get("name", "?") if saved \
else "Not playing a saved world: progress won't be kept."
var note := UITheme.label(note_text, 13, UITheme.TEXT_DIM)
note.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_main.add_child(note)
# Controls page.
_controls = VBoxContainer.new()
_controls.add_theme_constant_override("separation", 6)
_controls.visible = false
stack.add_child(_controls)
var ct := UITheme.label("Controls", 26)
ct.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_controls.add_child(ct)
var grid := GridContainer.new()
grid.columns = 2
grid.add_theme_constant_override("h_separation", 24)
for row in CONTROLS:
grid.add_child(UITheme.label(row[0], 16, UITheme.SELECTED))
grid.add_child(UITheme.label(row[1], 16))
_controls.add_child(grid)
_controls.add_child(_button("Back", _show_controls.bind(false)))
func _button(text: String, cb: Callable) -> Button:
var b := Button.new()
b.text = text
b.focus_mode = Control.FOCUS_NONE
b.custom_minimum_size = Vector2(0, 42)
b.add_theme_font_size_override("font_size", 18)
b.pressed.connect(cb)
return b
func is_open() -> bool:
return visible
func open() -> void:
visible = true
_show_controls(false)
_disarm_quit()
player.ui_open = true
get_tree().paused = true
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
func close() -> void:
visible = false
player.ui_open = false
get_tree().paused = false
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
## ESC on the controls page goes back; on the main page it resumes.
func back() -> void:
if _controls.visible:
_show_controls(false)
else:
close()
func _show_controls(on: bool) -> void:
_controls.visible = on
_main.visible = not on
func _on_fullscreen(on: bool) -> void:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN if on else DisplayServer.WINDOW_MODE_WINDOWED)
func _on_quit() -> void:
if Saves.has_world():
player.get_parent().save_now()
get_tree().quit()
return
# Unsaved session: ask twice so nobody loses a run by accident.
if not _quit_armed:
_quit_armed = true
_quit_btn.text = "Click again to quit"
return
get_tree().quit()
func _on_quit_to_title() -> void:
player.get_parent().save_now()
get_tree().paused = false
Saves.current_id = ""
get_tree().auto_accept_quit = true
get_tree().change_scene_to_file("res://scenes/title.tscn")
func _disarm_quit() -> void:
_quit_armed = false
_quit_btn.text = "Save & Quit to Desktop" if Saves.has_world() else "Quit to Desktop"

View File

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

66
scripts/ui/slot.gd Normal file
View File

@@ -0,0 +1,66 @@
extends Panel
## One inventory slot: icon, stack count, optional hotbar number. Renders a
## stack and reports clicks; it never changes the inventory itself.
signal clicked(index: int, right: bool, shift: bool)
const UITheme = preload("res://scripts/ui/theme.gd")
var index := 0
var _icon: TextureRect
var _count: Label
var _key: Label
var _style: StyleBoxFlat
var _selected_style: StyleBoxFlat
var _stack = null
func _init(i: int = 0, size_px: int = 64, key_label: String = "") -> void:
index = i
custom_minimum_size = Vector2(size_px, size_px)
mouse_filter = Control.MOUSE_FILTER_STOP
_style = UITheme.box(UITheme.SLOT_BG, UITheme.SLOT_BORDER, 2, 5, 0)
_selected_style = UITheme.box(UITheme.SLOT_BG.lightened(0.15), UITheme.SELECTED, 3, 5, 0)
add_theme_stylebox_override("panel", _style)
_icon = TextureRect.new()
_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
_icon.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_icon.offset_left = 5; _icon.offset_top = 5; _icon.offset_right = -5; _icon.offset_bottom = -5
_icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_icon)
_count = UITheme.label("", 15)
_count.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_RIGHT)
_count.grow_horizontal = Control.GROW_DIRECTION_BEGIN
_count.grow_vertical = Control.GROW_DIRECTION_BEGIN
_count.offset_right = -5
_count.offset_bottom = -2
_count.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_count)
_key = UITheme.label(key_label, 12, UITheme.TEXT_DIM)
_key.position = Vector2(5, 2)
_key.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_key)
func set_stack(stack) -> void:
_stack = stack
if stack == null:
_icon.texture = null
_count.text = ""
tooltip_text = ""
return
_icon.texture = ItemDB.icon(stack.id)
_count.text = str(stack.count) if stack.count > 1 else ""
var def := ItemDB.get_item(stack.id)
tooltip_text = "%s\n%s" % [def.get("name", stack.id), def.get("description", "")]
func set_selected(on: bool) -> void:
add_theme_stylebox_override("panel", _selected_style if on else _style)
func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed:
if event.button_index == MOUSE_BUTTON_LEFT or event.button_index == MOUSE_BUTTON_RIGHT:
clicked.emit(index, event.button_index == MOUSE_BUTTON_RIGHT, event.shift_pressed)
accept_event()

1
scripts/ui/slot.gd.uid Normal file
View File

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

35
scripts/ui/theme.gd Normal file
View File

@@ -0,0 +1,35 @@
extends RefCounted
## Shared UI styling: dark, warm, slightly translucent panels.
const PANEL_BG := Color(0.09, 0.075, 0.06, 0.88)
const PANEL_BORDER := Color(0.42, 0.33, 0.22, 0.9)
const SLOT_BG := Color(0.16, 0.13, 0.10, 0.85)
const SLOT_BORDER := Color(0.32, 0.26, 0.18, 0.9)
const SELECTED := Color(0.95, 0.78, 0.35, 1.0)
const TEXT := Color(0.93, 0.89, 0.80)
const TEXT_DIM := Color(0.68, 0.63, 0.55)
const OK := Color(0.55, 0.85, 0.45)
const BAD := Color(0.95, 0.45, 0.35)
static func box(bg: Color, border: Color, width: int = 2, radius: int = 6, pad: int = 10) -> StyleBoxFlat:
var s := StyleBoxFlat.new()
s.bg_color = bg
s.border_color = border
s.set_border_width_all(width)
s.set_corner_radius_all(radius)
s.set_content_margin_all(pad)
return s
static func panel() -> PanelContainer:
var p := PanelContainer.new()
p.add_theme_stylebox_override("panel", box(PANEL_BG, PANEL_BORDER, 2, 8, 14))
return p
static func label(text: String, size: int = 16, color: Color = TEXT) -> Label:
var l := Label.new()
l.text = text
l.add_theme_font_size_override("font_size", size)
l.add_theme_color_override("font_color", color)
l.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.8))
l.add_theme_constant_override("outline_size", 4)
return l

1
scripts/ui/theme.gd.uid Normal file
View File

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

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)

View File

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