435 lines
15 KiB
GDScript
435 lines
15 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 _worn_label: Label
|
|
var _worn_btn: Button
|
|
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("1-8 pick from the row you're on; K / M change row. Shift-click moves stacks to/from a chest. Q drops what you hold.", 13, UITheme.TEXT_DIM))
|
|
var wr := HBoxContainer.new()
|
|
wr.add_theme_constant_override("separation", 10)
|
|
_worn_label = UITheme.label("", 15)
|
|
wr.add_child(_worn_label)
|
|
_worn_btn = Button.new()
|
|
_worn_btn.text = "Take off"
|
|
_worn_btn.focus_mode = Control.FOCUS_NONE
|
|
_worn_btn.pressed.connect(func():
|
|
Actions.take_off(player)
|
|
_refresh())
|
|
wr.add_child(_worn_btn)
|
|
pv.add_child(wr)
|
|
player.worn_changed.connect(_refresh)
|
|
PlayerNet.cursor_changed.connect(_on_server_cursor)
|
|
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)
|
|
# Categories: Tools, Weapons, Cooking... (ItemDB.CATEGORIES). Two rows
|
|
# of tabs, so they all fit.
|
|
_tabs = HFlowContainer.new()
|
|
_tabs.add_theme_constant_override("h_separation", 4)
|
|
_tabs.add_theme_constant_override("v_separation", 4)
|
|
crv.add_child(_tabs)
|
|
var scroll := ScrollContainer.new()
|
|
scroll.custom_minimum_size = Vector2(320, 330)
|
|
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
|
|
Sfx.ui("ui_open")
|
|
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
|
_pick_tab()
|
|
_rebuild_chest()
|
|
_refresh()
|
|
|
|
func close() -> void:
|
|
if visible:
|
|
Sfx.ui("ui_close")
|
|
if WorldNet.is_client():
|
|
PlayerNet.ask("close") # the server puts the cursor back in our bag
|
|
_cursor = null
|
|
elif _cursor != null:
|
|
var left: int = player.inventory.add_stack(_cursor)
|
|
if left > 0 and container != null and is_instance_valid(container):
|
|
var rest: Dictionary = _cursor.duplicate()
|
|
rest.count = left
|
|
left = container.inventory.add_stack(rest)
|
|
_cursor = null
|
|
if container != null and is_instance_valid(container) 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
|
|
|
|
|
|
func _process(_d: float) -> void:
|
|
if not visible or container == null:
|
|
return
|
|
# Containers can vanish while open (an emptied grave crumbles).
|
|
if not is_instance_valid(container) or container.is_queued_for_deletion():
|
|
container = null
|
|
_rebuild_chest()
|
|
return
|
|
if 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)
|
|
|
|
## Online the server does every click (player_net.gd). Clicks in our own
|
|
## bag also happen here straight away, so dragging doesn't wait on the
|
|
## network; the server's copy replaces ours once it's caught up.
|
|
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)
|
|
if WorldNet.is_client():
|
|
PlayerNet.ask("inv_click", [0, i, right])
|
|
_refresh()
|
|
|
|
func _on_chest_slot(i: int, right: bool, shift: bool) -> void:
|
|
if container == null or not is_instance_valid(container) or not Actions._in_reach(player, container, 1.0):
|
|
return
|
|
if shift:
|
|
Actions.transfer(player, container.inventory, i, player.inventory, container)
|
|
elif WorldNet.is_client():
|
|
# A shared chest (someone else may be in it too): only the server's
|
|
# answer counts.
|
|
PlayerNet.ask("inv_click", [WorldNet.nid(container), i, right])
|
|
else:
|
|
_cursor = container.inventory.click(i, _cursor, right)
|
|
_refresh()
|
|
|
|
## Online: what the server says we're holding.
|
|
func _on_server_cursor(stack) -> void:
|
|
_cursor = stack if visible else null
|
|
_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 ""
|
|
_worn_label.text = "Wearing: %s" % (ItemDB.item_name(player.worn) if not player.worn.is_empty() else "nothing")
|
|
_worn_btn.visible = not player.worn.is_empty()
|
|
_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", "forge", "stonecutter"]:
|
|
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)")
|
|
_rebuild_tabs()
|
|
if category == "repair":
|
|
for slot in _worn_slots():
|
|
_recipe_list.add_child(_repair_row(slot))
|
|
return
|
|
for rid in ItemDB.recipes:
|
|
var r: Dictionary = ItemDB.recipes[rid]
|
|
if r.category == category:
|
|
_recipe_list.add_child(_recipe_row(r))
|
|
|
|
## Slots holding a tool or weapon that's been used.
|
|
func _worn_slots() -> Array:
|
|
var out: Array = []
|
|
for i in range(player.inventory.size()):
|
|
if not Actions.repair_cost(player, i).is_empty():
|
|
out.append(i)
|
|
return out
|
|
|
|
func _repair_row(slot: int) -> Control:
|
|
var s = player.inventory.get_slot(slot)
|
|
var cost: Dictionary = Actions.repair_cost(player, slot)
|
|
var ok := Actions.can_repair(player, slot)
|
|
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(s.id)
|
|
icon.custom_minimum_size = Vector2(44, 44)
|
|
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
|
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
|
h.add_child(icon)
|
|
var v := VBoxContainer.new()
|
|
v.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
v.add_child(UITheme.label("%s (%d/%d)" % [ItemDB.item_name(s.id), ItemDB.durability(s), ItemDB.max_durability(s.id)], 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_WORD_SMART
|
|
req.add_theme_font_size_override("normal_font_size", 13)
|
|
req.custom_minimum_size = Vector2(170, 0)
|
|
var parts: Array[String] = []
|
|
for id in cost.inputs:
|
|
var need := int(cost.inputs[id])
|
|
var have: int = player.inventory.count(id)
|
|
parts.append("[color=#%s]%s %d/%d[/color]" % [(UITheme.OK if have >= need else UITheme.BAD).to_html(false), ItemDB.item_name(id), have, need])
|
|
var st := String(cost.get("station", ""))
|
|
if not st.is_empty():
|
|
parts.append("[color=#%s]@ %s[/color]" % [(UITheme.OK if Actions.station_near(player, st) else UITheme.BAD).to_html(false), st])
|
|
req.text = " ".join(parts)
|
|
v.add_child(req)
|
|
h.add_child(v)
|
|
var b := Button.new()
|
|
b.text = "Repair"
|
|
b.disabled = not ok
|
|
b.focus_mode = Control.FOCUS_NONE
|
|
b.pressed.connect(func():
|
|
Actions.repair(player, slot)
|
|
_refresh())
|
|
h.add_child(b)
|
|
return row
|
|
|
|
var _tabs: HFlowContainer
|
|
var category := "tools" ## the tab showing
|
|
var _picked := false ## chose a tab since opening (don't jump about)
|
|
|
|
## Tabs for the categories that have recipes, each with how many of its
|
|
## recipes you could make right now.
|
|
func _rebuild_tabs() -> void:
|
|
for c in _tabs.get_children():
|
|
c.queue_free()
|
|
var count := {}
|
|
var ready := {}
|
|
for rid in ItemDB.recipes:
|
|
var r: Dictionary = ItemDB.recipes[rid]
|
|
count[r.category] = int(count.get(r.category, 0)) + 1
|
|
if Actions.can_craft(player, rid):
|
|
ready[r.category] = int(ready.get(r.category, 0)) + 1
|
|
# Repair: shown once something you carry is worn.
|
|
var worn := _worn_slots()
|
|
if not worn.is_empty():
|
|
count["repair"] = worn.size()
|
|
ready["repair"] = worn.filter(func(i): return Actions.can_repair(player, i)).size()
|
|
if not count.has(category):
|
|
category = count.keys()[0] if not count.is_empty() else "tools"
|
|
for cat in ItemDB.CATEGORIES.keys() + ["repair"]:
|
|
if not count.has(cat):
|
|
continue
|
|
var b := Button.new()
|
|
var n := int(ready.get(cat, 0))
|
|
b.text = String(ItemDB.CATEGORIES.get(cat, "Repair")) + (" (%d)" % n if n > 0 else "")
|
|
b.toggle_mode = true
|
|
b.button_pressed = cat == category
|
|
b.focus_mode = Control.FOCUS_NONE
|
|
b.add_theme_font_size_override("font_size", 13)
|
|
if n > 0 and cat != category:
|
|
b.add_theme_color_override("font_color", UITheme.OK)
|
|
b.pressed.connect(func():
|
|
category = cat
|
|
_picked = true
|
|
_rebuild_recipes())
|
|
_tabs.add_child(b)
|
|
|
|
## On opening: Cooking if you're at a lit fire with something to cook,
|
|
## otherwise the tab you were on last.
|
|
func _pick_tab() -> void:
|
|
_picked = false
|
|
if not Actions.station_near(player, "campfire"):
|
|
return
|
|
for rid in ItemDB.recipes:
|
|
var r: Dictionary = ItemDB.recipes[rid]
|
|
if r.category == "cooking" and Actions.can_craft(player, rid):
|
|
category = "cooking"
|
|
return
|
|
|
|
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_WORD_SMART # (long lists wrap rather than widen the panel)
|
|
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(170, 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
|
|
|
|
## Shift-click crafts up to five (handy for walls and floors).
|
|
func _craft(recipe_id: String) -> void:
|
|
var n := 5 if Input.is_key_pressed(KEY_SHIFT) else 1
|
|
for i in range(n):
|
|
if not Actions.can_craft(player, recipe_id) or not Actions.craft(player, recipe_id):
|
|
break
|
|
_refresh()
|