Server-authoritative slots+weight inventory, a 9-slot hotbar (keys 1-9), equipment slots, and a restyleable tabbed menu (functional Inventory + Character Sheet; Codex/Achievements/Quests scaffolded), built the engine's way: authored ScreenGui templates + attribute-discovering binders, with a zero-setup fallback. - Drag-and-drop is driven by a per-frame cursor poll (immune to the grid ScrollingFrame that swallows InputChanged) with GUI-inset-corrected drop hit-testing so releases land on the actual slot. - Menu key defaults to Tab; the engine frees it by disabling the CoreGui player roster (ReclaimCoreKeys) and moves the chat to bottom-left so it clears the top-left HUD (Chat config). Toggle ignores focused TextBoxes. - Consumables apply onConsume via the stat-effects layer + fire item:use; gather yields flow into the inventory; pickups auto-pin to the hotbar. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
9.7 KiB
Inventory, Hotbar & Menu UI
SurvivorCore ships a complete, server-authoritative inventory with a slots + carry-weight model, a quick-use hotbar, equipment slots, and a restyleable tabbed menu (Inventory + Character Sheet, with Codex / Achievements / Quests scaffolded). Like the survival HUD, the UI is a template + binder: you author/restyle the ScreenGui in Studio, and the engine drives only data (icons, counts, fills) — never layout or colors. The engine ships zero items; your game registers them, and their display data replicates to clients automatically.
- Data layer (server): src/systems/Inventory.luau
- UI (client):
PanelManager,InventoryUi,Hotbar,CharacterSheet,DragDrop,SlotGrid,UiFallbackunder src/client/ - Templates: assets/ui/SurvivalMenu.model.json, assets/ui/SurvivalHotbar.model.json
- Tuning: the
InventoryandUIConfig sections.
The model: slots + weight
Every player has a number of inventory slots and a carry-weight limit. Items stack (up to
the item's stack) and each unit has a weight. add fails (returns false) when a pickup would
exceed either limit. Equipping a backpack raises both the slot count and the weight limit.
All state is stored as Player Attributes, which Roblox auto-replicates to the owning client — so
the UI needs no read RemoteEvents (the same model as the survival stats). Defaults live in the
Inventory Config section:
SurvivorCore.Config.override("Inventory", {
BasePocketSlots = 5, -- slots with no backpack
BasePocketWeight = 5, -- kg with no backpack
HotbarSize = 9, -- quick-use slots (keys 1-9)
UseCooldownSeconds = 2.0, -- anti-spam on consuming
EquipSlots = { "head", "top", "pants", "shoes", "back", "quiver" },
AutoHotbarCategories = { "tool", "weapon", "placeable", "consumable" },
})
Item definitions
The inventory reads these fields off your Items registry defs. All are optional except id
(the engine tolerates anything missing). Register items before SurvivorCore.start():
SurvivorCore.Items.register({
id = "berry",
name = "Wild Berries",
description = "Tart and filling.",
stack = 20, -- max per slot (default 1)
weight = 0.05, -- per-unit carry weight (default 0)
category = "consumable", -- used for auto-hotbar; freeform otherwise
icon = "rbxassetid://…", -- display icon (see "Icons" below)
onConsume = { Hunger = -15, Thirst = -5 }, -- makes it a consumable
})
SurvivorCore.Items.register({
id = "reed_satchel",
name = "Reed Satchel",
weight = 0.8,
equipment = { slot = "back" }, -- equips into the Back slot
backpack = { slots = 6, maxWeight = 10 }, -- +6 slots, +10 kg when equipped
})
SurvivorCore.Items.register({
id = "straw_hat",
name = "Straw Hat",
equipment = { slot = "head" }, -- head / top / pants / shoes / back / quiver
})
onConsume keys are stat names, passed straight to the stat-effects layer
(Stats.adjust). SurvivorCore afflictions rise toward 100 = bad, so feeding lowers them
(Hunger = -15) and a cure drives one to zero (Poison = -100). No special tags — it's all data.
Server API
Available on SurvivorCore.Inventory after start() (all act on live players, server-only):
| Call | Effect |
|---|---|
add(player, itemId, n?) → bool |
Weight/stack-checked; fills partial stacks then empties. Auto-pins hotbar-eligible items. true only if all placed. |
remove(player, itemId, n?) → bool |
Removes across slots (newest first). false if the player has fewer. |
getQty(player, itemId) → number / has(player, itemId, n?) → bool |
Totals across slots. |
getSlots(player) → { {slot,itemId,qty} } |
Read-only snapshot. |
move(player, from, to) / swap(...) |
Merge same item up to stack, else swap. |
split(player, slot, qty) |
Split into the first free slot. |
equip(player, invSlot, slot?) / unequip(player, slot) → (bool, reason?) |
Equip/unequip; back recomputes capacity and enforces unequip rules. |
setHotbar(player, slot, itemId?) |
Pin (itemId) / unpin (nil) a hotbar slot. |
swapHotbar(player, a, b) |
Reorder the hotbar. |
useSlot(player, hotbarSlotOrItemId) |
Use/consume an item. |
SurvivorCore.Inventory.add(player, "stone_axe", 1) -- e.g. on a pickup; auto-hotbars tools
Gathering is wired for free: when a Gatherable is fully harvested, the engine grants its
Yield × ItemId into the player's inventory (via the gather:depleted hook).
Hooks
| Hook | Payload | Fires when |
|---|---|---|
inventory:changed |
{ player, kind, itemId?, equipSlot? } |
Any inventory mutation (add/remove/move/use/equip/…). |
item:use |
{ player, itemId, def, slot? } |
Every successful consume — the seam for eat animations, sounds, or stopping a poison tick via Stats.removeModifier. |
SurvivorCore.Hooks.on("item:use", function(ctx)
-- e.g. play a chewing sound, or clear an ongoing affliction the item cures
end)
The UI
The menu and hotbar are authored ScreenGui templates driven by attribute-discovering binders — restyle them in Studio with zero code, exactly like the HUD. The binder finds elements by attribute and drives only their data.
Open it
Press Tab (configurable) to toggle the menu; C / K / J / L jump to the Character / Codex / Achievements / Quests tabs. Hotbar keys 1-9 use the pinned item. Drag items between slots, onto the hotbar to pin, and right-click a hotbar slot to unpin.
Drag-to-drop is intentionally a no-op. Dragging an item out to empty space does nothing yet — dropping items to the world (loot bags, drop-on-death) is the deferred world-drop system (#19). When that lands it registers a catch-all drop target. (Dragging is driven by a per-frame cursor poll, not
InputChanged, so it works even though the inventory grid is aScrollingFramethat would otherwise swallow the gesture.)
Tab & the player roster. Roblox's CoreGui owns Tab (it toggles the built-in player list) and consumes the keypress before any game script sees it — and ContextActionService can't out-rank CoreGui. So when the Menu keybind collides with a core key, the engine disables that core element to free the key (the same way it hides the default health bar and backpack). This is on by default; set
Config.override("UI", { ReclaimCoreKeys = false })to keep Roblox's stock player list (e.g. if you rebind Menu off Tab). The toggle also never fires while a TextBox is focused.
Chat placement. The HUD lives in the top-left, where Roblox's chat sits too, so the client moves the chat window (modern TextChatService) to the bottom-left by default. Change the alignment, or opt out, via
Config.override("UI", { Chat = { Reposition = false } })(run on the client beforestartClient()).
Template attribute conventions
| Attribute (on a GuiObject) | Children the binder drives | Meaning |
|---|---|---|
InventoryTab = id |
optional Selected |
A tab button; click shows the matching TabContent. |
TabContent = id |
— | The content shown when its tab is active. |
InventoryGrid = true |
one SlotTemplate child |
Slots are cloned from the template to fill MaxInvSlots. |
SlotTemplate = true |
Icon, Count, Selected |
The prototype slot (drag source + click-to-select). |
WeightReadout = true |
Fill, Value |
Carry-weight bar + text. |
SlotReadout = true |
Value |
"used / max slots". |
ItemDetail = true |
Icon, Name, Description, buttons Action=use/equip/hotbar/split |
Selected-item detail strip. |
HotbarSlot = 1..9 |
Icon, Count, Key, Active |
A hotbar slot. |
EquipSlot = name |
Icon, Name |
An equipment slot (head/top/pants/shoes/back/quiver). |
AttributeReadout = attr |
Value |
Shows a Player attribute (the seam for equipment-driven attributes). |
If no template ever reaches the player, a deliberately minimal UiFallback builds one with the same
attributes, so the UI always works.
Adding your own tab
-- client, after startClient():
SurvivorCore.UI.registerPanel({
id = "map", title = "Map", order = 6,
build = function(contentFrame) -- fill the (empty) tab content once
-- … build your panel UI here …
end,
})
SurvivorCore.UI.open("map") -- open / close / toggle by id
Icons
Item icons resolve per item icon field → the ItemIcons Assets category → "" (hidden). A fresh
engine shows clean, empty slots — never a broken-image box. Supply icons either way:
-- inline on the def:
SurvivorCore.Items.register({ id = "berry", icon = "rbxassetid://…", … })
-- or via the registry (handy for bulk / theming):
SurvivorCore.Assets.register("ItemIcons", "berry", "rbxassetid://…")
Because item registration happens server-side but the UI runs on the client, the engine replicates
each item's display data (name, icon, stack, equip slot, consumable flag) automatically at
start() — your game registers items once, server-side, and the UI just works. See the icon-style and
generation guidance in design-language.md.
What's deferred
Dropping items to the world / loot bags (#19), physical Tool instances + equip-to-swing (#1),
equipment attribute modifiers (armor → defense — a future layer on the inventory:changed hook),
durability, spoilage, and the 2D backpack grid are intentionally out of this slice. The seams are
marked in the code.