Files
SurvivorCore/docs/inventory.md
T
Samuel LisonandClaude Opus 4.8 5ab855fa1b feat(ui): inventory, hotbar & tabbed menu UI (#7, #9, #3)
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>
2026-06-23 17:06:54 +10:00

209 lines
9.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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](../src/systems/Inventory.luau)
- **UI (client):** `PanelManager`, `InventoryUi`, `Hotbar`, `CharacterSheet`, `DragDrop`, `SlotGrid`,
`UiFallback` under [src/client/](../src/client)
- **Templates:** [assets/ui/SurvivalMenu.model.json](../assets/ui/SurvivalMenu.model.json),
[assets/ui/SurvivalHotbar.model.json](../assets/ui/SurvivalHotbar.model.json)
- **Tuning:** the `Inventory` and `UI` Config 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:
```lua
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()`:
```lua
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. |
```lua
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`. |
```lua
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 a `ScrollingFrame` that
> 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 before
> `startClient()`).
### 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
```lua
-- 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:
```lua
-- 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](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.