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:
139
scripts/ui/hud.gd
Normal file
139
scripts/ui/hud.gd
Normal 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
1
scripts/ui/hud.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://c4dm11w8jqsiv
|
||||
263
scripts/ui/inventory_screen.gd
Normal file
263
scripts/ui/inventory_screen.gd
Normal 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()
|
||||
1
scripts/ui/inventory_screen.gd.uid
Normal file
1
scripts/ui/inventory_screen.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cuoylqtlj5dmy
|
||||
167
scripts/ui/pause_menu.gd
Normal file
167
scripts/ui/pause_menu.gd
Normal 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"
|
||||
1
scripts/ui/pause_menu.gd.uid
Normal file
1
scripts/ui/pause_menu.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cufp0428mqob7
|
||||
66
scripts/ui/slot.gd
Normal file
66
scripts/ui/slot.gd
Normal 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
1
scripts/ui/slot.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bfaf1tn6bi3if
|
||||
35
scripts/ui/theme.gd
Normal file
35
scripts/ui/theme.gd
Normal 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
1
scripts/ui/theme.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://jhtk2nvq1tft
|
||||
238
scripts/ui/title_screen.gd
Normal file
238
scripts/ui/title_screen.gd
Normal 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)
|
||||
1
scripts/ui/title_screen.gd.uid
Normal file
1
scripts/ui/title_screen.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dxm34hvuh7wk1
|
||||
Reference in New Issue
Block a user