Files
Emberwild/scripts/ui/inventory_screen.gd
Paul Bailey 4640f5e577 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.
2026-11-04 00:34:46 +00:00

264 lines
8.8 KiB
GDScript

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()