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:
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()
|
||||
Reference in New Issue
Block a user