Files
Cinderwild/scripts/ui/inventory_screen.gd

432 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("Row 1 is your hotbar. Shift-click moves stacks to/from a chest.", 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
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
_pick_tab()
_rebuild_chest()
_refresh()
func close() -> void:
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()