Merge pull request #31 from TemujinCalidius/feat/inventory-hotbar-ui

feat(ui): inventory, hotbar & tabbed menu UI (#7, #9, #3)
This commit is contained in:
Samuel Lison
2026-06-23 17:57:20 +10:00
committed by GitHub
27 changed files with 10119 additions and 24 deletions
+26
View File
@@ -5,6 +5,32 @@ All notable changes to SurvivorCore are recorded here. The format follows
[Semantic Versioning](https://semver.org/spec/v2.0.0.html). At release time, `## Unreleased`
is promoted to the new version and `main` is tagged `vX.Y.Z`.
## Unreleased
### Added
- **Inventory, hotbar & tabbed menu UI** (#7, #9, #3) — a server-authoritative inventory with a
**slots + carry-weight** model, a 9-slot quick-use **hotbar** (keys 1-9), **equipment** slots
(head/top/pants/shoes/back/quiver), and a restyleable **tabbed menu** (functional Inventory +
Character Sheet; Codex/Achievements/Quests scaffolded). Built the engine's way — authored
ScreenGui **templates** (`SurvivalMenu`, `SurvivalHotbar`) driven by attribute-discovering
**binders**, so world creators restyle everything in Studio with zero code (and a zero-setup
fallback guarantees the UI always appears). Items **stack** and have **weight**; equipping a
**backpack** raises both slot count and weight limit. **Consumables** apply their `onConsume`
effects through the stat-effects layer (e.g. food lowers Hunger) and fire an `item:use` hook;
every change fires `inventory:changed`. Full **drag-and-drop** (move/merge/swap, pin to hotbar),
pickups **auto-assign** to the smallest free hotbar slot, and gathering a node now grants its
yield straight into the inventory. New public API: `SurvivorCore.Inventory.*`
(add/remove/move/split/equip/unequip/setHotbar/swapHotbar/useSlot/…) and
`SurvivorCore.UI.registerPanel/open/close/toggle` for code-added tabs. Item **display data**
(name/icon/stack/…) replicates to clients automatically, so games register items once
(server-side) and the UI just works; item icons resolve via the `ItemIcons` Assets category or an
inline `icon` field. Tunable via the new `Inventory` and `UI` Config sections. The engine still
ships **zero items** — the demo registers a sample set and seeds a starter inventory. Opening the
menu defaults to **Tab** — the engine frees it by disabling Roblox's player roster (which otherwise
swallows the key), and moves the chat window to the bottom-left so it clears the top-left HUD; both
are opt-out via the `UI` Config section (`ReclaimCoreKeys`, `Chat`). See
[docs/inventory.md](docs/inventory.md).
## 0.2.1 — 2026-06-22
### Fixed
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2
View File
@@ -5,6 +5,8 @@
"Templates": {
"$className": "Folder",
"SurvivalHud": { "$path": "assets/hud/SurvivalHud.model.json" },
"SurvivalMenu": { "$path": "assets/ui/SurvivalMenu.model.json" },
"SurvivalHotbar": { "$path": "assets/ui/SurvivalHotbar.model.json" },
"SurvivalStatsConfig": { "$path": "assets/config/SurvivalStatsConfig.model.json" },
"HudLoader": { "$path": "assets/client/HudLoader.client.luau" }
}
+3 -1
View File
@@ -11,7 +11,9 @@
},
"StarterGui": {
"$className": "StarterGui",
"SurvivalHud": { "$path": "assets/hud/SurvivalHud.model.json" }
"SurvivalHud": { "$path": "assets/hud/SurvivalHud.model.json" },
"SurvivalMenu": { "$path": "assets/ui/SurvivalMenu.model.json" },
"SurvivalHotbar": { "$path": "assets/ui/SurvivalHotbar.model.json" }
},
"StarterPlayer": {
"$className": "StarterPlayer",
+108 -11
View File
@@ -5,15 +5,96 @@
Try it: serve `demo.project.json` into a place, add a Part to Workspace, tag it
"Gatherable" (CollectionService), set attributes ItemId="reed", Yield=2, HP=3,
then play and interact with it.
then play and interact with it the reed lands in your inventory (press Tab).
]]
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local SurvivorCore = require(ReplicatedStorage:WaitForChild("SurvivorCore"))
-- 1. Programmatic content
SurvivorCore.Items.register({ id = "reed", name = "Reed", stack = 20 })
SurvivorCore.Items.register({ id = "reed_basket", name = "Reed Basket", stack = 1 })
-- 1. Programmatic content -----------------------------------------------------
-- NOTE: the `icon` ids below are PLACEHOLDERS (the engine's stat icons) so the demo grid
-- isn't empty. A real game registers proper item icons (per-item `icon` or the "ItemIcons"
-- Assets category). The engine itself ships zero item content.
SurvivorCore.Items.register({
id = "reed",
name = "Reed",
description = "A tough riverside stalk. Weave it into baskets and cordage.",
stack = 20,
weight = 0.05,
category = "material",
icon = "rbxassetid://102789813187589",
})
SurvivorCore.Items.register({
id = "reed_basket",
name = "Reed Basket",
description = "A simple woven basket.",
stack = 1,
weight = 0.5,
category = "container",
icon = "rbxassetid://137051934393677",
})
SurvivorCore.Items.register({
id = "berry",
name = "Wild Berries",
description = "A small handful of tart berries. Eases hunger and thirst a little.",
stack = 20,
weight = 0.05,
category = "consumable",
onConsume = { Hunger = -15, Thirst = -5 },
icon = "rbxassetid://138699077112926",
})
SurvivorCore.Items.register({
id = "mushroom",
name = "Cave Mushroom",
description = "Filling — but eating it raw might make you sick.",
stack = 10,
weight = 0.1,
category = "consumable",
onConsume = { Hunger = -10, Health = 5, Poison = 15 },
icon = "rbxassetid://86406240258610",
})
SurvivorCore.Items.register({
id = "water_skin",
name = "Water Skin",
description = "A full skin of water. Drink to quench your thirst.",
stack = 1,
weight = 0.5,
category = "tool",
onConsume = { Thirst = -40 },
icon = "rbxassetid://124374881225649",
})
SurvivorCore.Items.register({
id = "stone_axe",
name = "Stone Axe",
description = "A crude chopping tool. Pick one up to see it auto-assign to your hotbar.",
stack = 1,
weight = 1.5,
category = "tool",
icon = "rbxassetid://129856164091801",
})
SurvivorCore.Items.register({
id = "straw_hat",
name = "Straw Hat",
description = "Keeps the sun off. Equips to the head slot.",
stack = 1,
weight = 0.2,
category = "apparel",
equipment = { slot = "head" },
icon = "rbxassetid://88514622686548",
})
SurvivorCore.Items.register({
id = "reed_satchel",
name = "Reed Satchel",
description = "A woven back-satchel. Equip it for +6 slots and +10 kg of carry weight.",
stack = 1,
weight = 0.8,
category = "container",
equipment = { slot = "back" },
backpack = { slots = 6, maxWeight = 10 },
icon = "rbxassetid://137051934393677",
})
SurvivorCore.Recipes.register({
id = "reed_basket",
station = "hand",
@@ -21,21 +102,37 @@ SurvivorCore.Recipes.register({
output = { item = "reed_basket", count = 1 },
})
-- 2. React to creator-owned Gatherables
-- 2. React to creator-owned Gatherables (the engine also grants the Yield into the inventory)
SurvivorCore.Hooks.on("gather:depleted", function(ctx)
print(("[demo] %s fully gathered %s"):format(ctx.player.Name, tostring(ctx.values.ItemId)))
end)
-- 3. The HUD shows a "Credits" counter; the game owns that value. Seed a sample so it
-- renders in the demo (a real game sets player:SetAttribute("Credits", n) itself).
local Players = game:GetService("Players")
local function seedCredits(player: Player)
-- A flourish hook: print whenever a consumable is used.
SurvivorCore.Hooks.on("item:use", function(ctx)
print(("[demo] %s used %s"):format(ctx.player.Name, tostring(ctx.itemId)))
end)
-- 3. Seed sample player state so the HUD + inventory render populated in the demo.
-- A real game sets these itself (or restores them from a DataStore).
local function seedPlayer(player: Player)
player:SetAttribute("Credits", 250)
-- A starter inventory (slots 1-4 of the base 5; slot 5 left free to demo equip/unequip).
player:SetAttribute("InvSlot_1", "berry")
player:SetAttribute("InvQty_1", 12)
player:SetAttribute("InvSlot_2", "mushroom")
player:SetAttribute("InvQty_2", 3)
player:SetAttribute("InvSlot_3", "water_skin")
player:SetAttribute("InvQty_3", 1)
player:SetAttribute("InvSlot_4", "reed_satchel")
player:SetAttribute("InvQty_4", 1)
player:SetAttribute("HotbarSlot1", "berry") -- a pre-pinned quick slot
player:SetAttribute("EquipSlot_Head", "straw_hat") -- a pre-filled equipment slot
end
for _, player in Players:GetPlayers() do
seedCredits(player)
seedPlayer(player)
end
Players.PlayerAdded:Connect(seedCredits)
Players.PlayerAdded:Connect(seedPlayer)
SurvivorCore.start()
print("SurvivorCore demo booted — v" .. SurvivorCore.VERSION)
@@ -0,0 +1,74 @@
--!nonstrict
--[[
DEMO ONLY a row of pickup parts to exercise the inventory by hand.
Each part has a ProximityPrompt (walk up, press E) that grants items to the triggering
player via SurvivorCore.Inventory.add a live demonstration of:
weight + stack limits (add returns false when you're over capacity)
• auto-hotbar (tools / consumables auto-pin to the smallest free hotbar slot on pickup)
Open the menu with Tab to watch the slots fill. This is demo scaffolding, not engine code.
]]
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Workspace = game:GetService("Workspace")
local SurvivorCore = require(ReplicatedStorage:WaitForChild("SurvivorCore"))
local PICKUPS = {
{ label = "PICK UP Stone Axe", item = "stone_axe", amount = 1, color = Color3.fromRGB(120, 130, 140) },
{ label = "PICK UP Berries x5", item = "berry", amount = 5, color = Color3.fromRGB(180, 70, 110) },
{ label = "PICK UP Reed x10", item = "reed", amount = 10, color = Color3.fromRGB(110, 160, 90) },
}
local function buildPickup(def: any, position: Vector3)
local part = Instance.new("Part")
part.Name = "InventoryPickup"
part.Size = Vector3.new(3, 3, 3)
part.Anchored = true
part.Color = def.color
part.Material = Enum.Material.SmoothPlastic
part.Position = position
part.Parent = Workspace
local billboard = Instance.new("BillboardGui")
billboard.Size = UDim2.fromOffset(220, 44)
billboard.StudsOffset = Vector3.new(0, 2.5, 0)
billboard.AlwaysOnTop = true
billboard.Parent = part
local label = Instance.new("TextLabel")
label.Size = UDim2.fromScale(1, 1)
label.BackgroundTransparency = 1
label.Text = def.label
label.TextColor3 = Color3.fromRGB(255, 255, 255)
label.TextStrokeTransparency = 0.4
label.TextScaled = true
label.Font = Enum.Font.GothamBold
label.Parent = billboard
local prompt = Instance.new("ProximityPrompt")
prompt.ActionText = "Pick up"
prompt.ObjectText = def.label
prompt.HoldDuration = 0
prompt.RequiresLineOfSight = false
prompt.MaxActivationDistance = 10
prompt.Parent = part
prompt.Triggered:Connect(function(player)
if not (SurvivorCore.Inventory and typeof(SurvivorCore.Inventory.add) == "function") then
warn("[SurvivorCore demo] inventory not ready — has SurvivorCore.start() run?")
return
end
local ok = SurvivorCore.Inventory.add(player, def.item, def.amount)
if not ok then
print(("[demo] %s couldn't carry %s (full or over weight)"):format(player.Name, def.item))
end
end)
end
local BASE = Vector3.new(0, 4, 16) -- opposite side from the stat test stations
for i, def in PICKUPS do
local x = (i - (#PICKUPS + 1) / 2) * 4
buildPickup(def, BASE + Vector3.new(x, 0, 0))
end
+6 -2
View File
@@ -41,7 +41,9 @@ re-skin freely; these are the engine's clean defaults and the rules any shipped
## Icon style
Flat, minimal, **two-tone** glyphs — modern and crisp, legible at ~20 px in a HUD.
Flat, minimal, **two-tone** glyphs — modern and crisp, legible at ~20 px in a HUD. The same style
covers **item icons** (the inventory/hotbar render at ~4864 px) — keep the family consistent so
stat icons and item icons read as one set.
- **Form:** a single clear glyph, centered, **filling ~7080% of the frame** (minimal margin so
it stays large when downscaled). Consistent visual weight across the set.
@@ -65,7 +67,9 @@ Flat, minimal, **two-tone** glyphs — modern and crisp, legible at ~20 px in a
3. **Post-process:** key out the white background → transparent (RGBA), trim to the glyph's
bounding box, then pad to a uniform square so the set is visually consistent.
4. **Upload to Roblox** → asset id, and register: `Assets.register("StatIcons", "<Stat>", id)`
(the HUD's `Icon` slots resolve from category `StatIcons`, key = stat / counter name).
(the HUD's `Icon` slots resolve from category `StatIcons`, key = stat / counter name). Item
icons follow the same flow under `Assets.register("ItemIcons", "<itemId>", id)` (or the item
def's inline `icon` field) — see [inventory.md](inventory.md).
The API key lives **outside the repo** at `~/.config/survivorcore/3daistudio.key` (read at call
time, never printed or committed).
+24
View File
@@ -198,6 +198,30 @@ SurvivorCore.Assets.registerCategory("Animations", {
SurvivorCore.Assets.get("Sounds", "Harvest") -- "rbxassetid://123456789"
```
Two engine-reserved categories resolve automatically in the built-in UI: **`StatIcons`** (per stat,
for the HUD) and **`ItemIcons`** (per item id, for the inventory). An item icon can also live inline on
the def's `icon` field — either works.
```lua
SurvivorCore.Assets.register("ItemIcons", "reed", "rbxassetid://…")
```
---
## Inventory & UI
The engine ships a full inventory (slots + carry-weight), a hotbar, equipment slots, and a tabbed
menu — all driven by the same template + binder model as the HUD, and extensible from code:
```lua
SurvivorCore.Inventory.add(player, "reed", 5) -- server: grant items
SurvivorCore.UI.registerPanel({ id = "map", title = "Map", build = fn }) -- client: add a tab
```
See **[Inventory, Hotbar & Menu UI](inventory.md)** for the item-def schema, the full API, the
RemoteEvent contract, the `item:use` / `inventory:changed` hooks, and the template attribute
conventions.
---
## Putting it together
+210
View File
@@ -0,0 +1,210 @@
# 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.
> 📹 **See it in action:** [Inventory & hotbar system](https://makertube.net/w/wXRkuJo323AHMpZKVwWxt3)
- **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.
+114
View File
@@ -0,0 +1,114 @@
--!nonstrict
--[[
CharacterSheet binds the Character tab. CLIENT-ONLY.
Drives DATA only on two kinds of authored element (discovered by attribute, like the HUD):
`EquipSlot` = <name> (head/top/pants/shoes/back/quiver) → its `Icon` + `Name` from the
EquipSlot_<Name> player attribute. Clicking an occupied slot fires InventoryUnequip.
`AttributeReadout` = <attr> → its `Value` text from that player attribute (the seam for
showing attributes that equipment will later modify).
Booted by SurvivorCore.startClient().
]]
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
assert(RunService:IsClient(), "SurvivorCore.CharacterSheet is client-only")
local InventoryTypes = require(script.Parent.Parent.shared.InventoryTypes)
local ItemData = require(script.Parent.Parent.shared.ItemData)
local Remotes = require(script.Parent.Parent.shared.Remotes)
local SlotGrid = require(script.Parent.SlotGrid)
local CharacterSheet = {}
local started = false
local localPlayer = Players.LocalPlayer
local function bindEquipSlot(element: GuiObject)
local slotName = element:GetAttribute("EquipSlot")
if typeof(slotName) ~= "string" or slotName == "" then
return
end
local attr = InventoryTypes.equipSlotAttr(slotName)
local icon = element:FindFirstChild("Icon")
local nameLabel = element:FindFirstChild("Name")
local function render()
local itemId = tostring(localPlayer:GetAttribute(attr) or "")
local def = if itemId ~= "" then ItemData.get(itemId) else nil
if icon and (icon:IsA("ImageLabel") or icon:IsA("ImageButton")) then
local id = if itemId ~= "" then SlotGrid.resolveItemIcon(itemId) else ""
icon.Image = id
icon.Visible = id ~= ""
end
if nameLabel and nameLabel:IsA("TextLabel") then
nameLabel.Text = if def then def.name else ""
end
end
-- Click an occupied slot to unequip it back to inventory.
element.InputBegan:Connect(function(input)
if
input.UserInputType == Enum.UserInputType.MouseButton1
or input.UserInputType == Enum.UserInputType.Touch
then
if tostring(localPlayer:GetAttribute(attr) or "") ~= "" then
Remotes.event("InventoryUnequip"):FireServer(slotName)
end
end
end)
localPlayer:GetAttributeChangedSignal(attr):Connect(render)
render()
end
local function bindAttributeReadout(element: GuiObject)
local attrName = element:GetAttribute("AttributeReadout")
if typeof(attrName) ~= "string" or attrName == "" then
return
end
local valueLabel = element:FindFirstChild("Value")
if not (valueLabel and valueLabel:IsA("TextLabel")) then
return
end
local function render()
local v = localPlayer:GetAttribute(attrName)
valueLabel.Text = if typeof(v) == "number" then tostring(math.floor(v + 0.5)) else "—"
end
localPlayer:GetAttributeChangedSignal(attrName):Connect(render)
render()
end
function CharacterSheet.start(_options: { [string]: any }?)
if started then
return
end
started = true
local playerGui = localPlayer:WaitForChild("PlayerGui")
local bound: { [Instance]: boolean } = {}
local function tryBind(d: Instance)
if bound[d] or not d:IsA("GuiObject") then
return
end
if d:GetAttribute("EquipSlot") ~= nil then
bound[d] = true
bindEquipSlot(d)
elseif d:GetAttribute("AttributeReadout") ~= nil then
bound[d] = true
bindAttributeReadout(d)
end
end
for _, d in playerGui:GetDescendants() do
tryBind(d)
end
playerGui.DescendantAdded:Connect(tryBind)
end
return CharacterSheet
+210
View File
@@ -0,0 +1,210 @@
--!nonstrict
--[[
DragDrop a generic drag-and-drop primitive for the UI. CLIENT-ONLY.
Ported from The Counter Earth's inventory drag logic, generalised so it isn't tied to the
inventory. A press that moves past a threshold becomes a drag: a ghost follows the cursor in
a top-most IgnoreGuiInset overlay (so it tracks exactly and floats above every panel), and on
release the first registered target whose rectangle contains the cursor receives the drop.
Targets hit-test by AbsolutePosition/AbsoluteSize, which works ACROSS ScreenGuis that's how
an item dragged from the menu drops onto the hotbar (a separate ScreenGui).
DragDrop.beginDrag({ icon = "rbxassetid://…", label = "AXE", data = {...} }) -- on press
DragDrop.addTarget({ hitTest = function(pos) … end, onDrop = function(payload, pos) … end })
if DragDrop.didDrag() then return end -- in a slot's Activated, to ignore the click
Booted by SurvivorCore.startClient() (via the binders that require it). Tuning: "UI" config.
]]
local GuiService = game:GetService("GuiService")
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local UserInputService = game:GetService("UserInputService")
assert(RunService:IsClient(), "SurvivorCore.DragDrop is client-only")
local UiConfig = require(script.Parent.Parent.shared.UiConfig)
local DragDrop = {}
local GHOST_SIZE = 56
local localPlayer = Players.LocalPlayer
local started = false
local overlay: ScreenGui? = nil
local targets: { any } = {}
local armed = false
local dragging = false
local dragStart = Vector2.zero
local payload: any = nil
local ghost: Frame? = nil
local dragEndedThisFrame = false
local function isClickOrTap(input: InputObject): boolean
return input.UserInputType == Enum.UserInputType.MouseButton1 or input.UserInputType == Enum.UserInputType.Touch
end
local function ensureOverlay(): ScreenGui
if overlay then
return overlay
end
local gui = Instance.new("ScreenGui")
gui.Name = "SurvivorCoreDragOverlay"
gui.ResetOnSpawn = false
gui.IgnoreGuiInset = true
gui.ZIndexBehavior = Enum.ZIndexBehavior.Sibling
gui.DisplayOrder = UiConfig.get().DisplayOrder.DragGhost
gui.Parent = localPlayer:WaitForChild("PlayerGui")
overlay = gui
return gui
end
local function makeGhost(pos: Vector2): Frame
local theme = UiConfig.get().Theme
local frame = Instance.new("Frame")
frame.Size = UDim2.fromOffset(GHOST_SIZE, GHOST_SIZE)
frame.Position = UDim2.fromOffset(pos.X - GHOST_SIZE / 2, pos.Y - GHOST_SIZE / 2)
frame.BackgroundColor3 = theme.SlotColor
frame.BackgroundTransparency = 0.25
frame.BorderSizePixel = 0
frame.ZIndex = 100
local corner = Instance.new("UICorner")
corner.CornerRadius = UDim.new(0, 6)
corner.Parent = frame
local stroke = Instance.new("UIStroke")
stroke.Color = theme.Accent
stroke.Thickness = 2
stroke.Parent = frame
if typeof(payload.icon) == "string" and payload.icon ~= "" then
local img = Instance.new("ImageLabel")
img.Size = UDim2.fromScale(0.8, 0.8)
img.Position = UDim2.fromScale(0.1, 0.1)
img.BackgroundTransparency = 1
img.Image = payload.icon
img.ScaleType = Enum.ScaleType.Fit
img.ZIndex = 101
img.Parent = frame
else
local label = Instance.new("TextLabel")
label.Size = UDim2.fromScale(1, 1)
label.BackgroundTransparency = 1
label.Text = tostring(payload.label or "?")
label.TextColor3 = theme.TextSecondary
label.Font = theme.FontBold
label.TextSize = 12
label.ZIndex = 101
label.Parent = frame
end
frame.Parent = ensureOverlay()
return frame
end
local function cleanup()
armed = false
dragging = false
payload = nil
if ghost then
ghost:Destroy()
ghost = nil
end
end
-- True if a GuiObject is on screen and the point is inside its rectangle. Cross-ScreenGui safe.
function DragDrop.hitTestGui(gui: GuiObject?, pos: Vector2): boolean
if not gui or not gui.Visible then
return false
end
local ap, as = gui.AbsolutePosition, gui.AbsoluteSize
if as.X <= 0 or as.Y <= 0 then
return false
end
return pos.X >= ap.X and pos.X <= ap.X + as.X and pos.Y >= ap.Y and pos.Y <= ap.Y + as.Y
end
-- Register a drop target. Targets are tried in registration order; the first whose hitTest
-- returns true gets the drop. (Register more specific targets — slots, hotbar — before any
-- catch-all.)
function DragDrop.addTarget(target: any)
table.insert(targets, target)
end
-- Begin a potential drag. The ghost is not shown until the cursor passes the drag threshold,
-- so a plain click never spawns a ghost. `data` (any field) is handed to the drop target.
function DragDrop.beginDrag(spec: any)
payload = spec
armed = true
dragging = false
dragStart = UserInputService:GetMouseLocation()
end
-- True for the frame in which a drag just ended — call it at the top of a slot's Activated
-- handler so the drag's mouse-up isn't also treated as a click.
function DragDrop.didDrag(): boolean
return dragEndedThisFrame
end
function DragDrop.isDragging(): boolean
return dragging
end
function DragDrop.start(_options: { [string]: any }?)
if started then
return
end
started = true
local threshold = tonumber(UiConfig.get().DragThreshold) or 8
-- Promote an armed press to a real drag and track the ghost from a per-frame poll of the
-- cursor — NOT UserInputService.InputChanged. The inventory grid is a ScrollingFrame, which
-- swallows held-mouse MouseMovement events (so an InputChanged-based threshold never trips);
-- polling GetMouseLocation each frame is immune to that and serves touch identically.
RunService.RenderStepped:Connect(function()
if not armed then
return
end
local pos = UserInputService:GetMouseLocation()
if not dragging and (pos - dragStart).Magnitude > threshold then
dragging = true
ghost = makeGhost(pos)
end
if dragging and ghost then
ghost.Position = UDim2.fromOffset(pos.X - GHOST_SIZE / 2, pos.Y - GHOST_SIZE / 2)
end
end)
UserInputService.InputEnded:Connect(function(input)
if not armed or not isClickOrTap(input) then
return
end
local wasDragging = dragging
local dropped = payload
-- GetMouseLocation() is in true-screen pixels, but our menu/hotbar ScreenGuis respect the
-- GUI inset, so their slots' AbsolutePosition sits ~36px below it. Subtract the inset so the
-- drop hit-test lines up with where the slots actually are. (The ghost stays raw — its
-- overlay is IgnoreGuiInset, so it keeps tracking the cursor exactly.)
local pos = UserInputService:GetMouseLocation() - GuiService:GetGuiInset()
cleanup()
if wasDragging then
dragEndedThisFrame = true
task.defer(function()
dragEndedThisFrame = false
end)
for _, target in targets do
if target.hitTest(pos) then
target.onDrop(dropped, pos)
break
end
end
end
end)
end
return DragDrop
+238
View File
@@ -0,0 +1,238 @@
--!nonstrict
--[[
Hotbar binds the bottom quick-use hotbar. CLIENT-ONLY.
Discovers the authored `HotbarSlot` = 1..9 frames and drives DATA only each slot's `Icon`
(the pinned item), `Count` (its inventory qty), `Key` (the digit), and `Active` highlight
(visible when HotbarEquippedSlot == n). Input:
• number keys 1-9, or clicking a slot → InventoryUseHotbar(n) (use the pinned item)
• drag a slot onto another → InventorySwapHotbar (reorder)
• right-click a slot → InventorySetHotbar(n, nil) (unpin)
• an inventory item dropped on a slot → InventorySetHotbar(n, itemId) (pin)
Also disables Roblox's built-in CoreGui Backpack so the default hotbar doesn't double up
(mirrors how the HUD disables the built-in Health GUI).
Booted by SurvivorCore.startClient(). Tuning: "UI" + "Inventory" config sections.
]]
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local StarterGui = game:GetService("StarterGui")
local UserInputService = game:GetService("UserInputService")
assert(RunService:IsClient(), "SurvivorCore.Hotbar is client-only")
local InventoryConfig = require(script.Parent.Parent.shared.InventoryConfig)
local InventoryTypes = require(script.Parent.Parent.shared.InventoryTypes)
local Remotes = require(script.Parent.Parent.shared.Remotes)
local DragDrop = require(script.Parent.DragDrop)
local SlotGrid = require(script.Parent.SlotGrid)
local Hotbar = {}
local started = false
local localPlayer = Players.LocalPlayer
-- KeyCodes One..Nine map to hotbar slots 1..9.
local DIGIT_KEYS: { [Enum.KeyCode]: number } = {
[Enum.KeyCode.One] = 1,
[Enum.KeyCode.Two] = 2,
[Enum.KeyCode.Three] = 3,
[Enum.KeyCode.Four] = 4,
[Enum.KeyCode.Five] = 5,
[Enum.KeyCode.Six] = 6,
[Enum.KeyCode.Seven] = 7,
[Enum.KeyCode.Eight] = 8,
[Enum.KeyCode.Nine] = 9,
}
local function totalQty(itemId: string): number
if itemId == "" then
return 0
end
local max = math.floor(tonumber(localPlayer:GetAttribute(InventoryTypes.MAX_SLOTS_ATTR)) or 0)
local total = 0
for n = 1, max do
if tostring(localPlayer:GetAttribute(InventoryTypes.invSlotAttr(n)) or "") == itemId then
total += math.floor(tonumber(localPlayer:GetAttribute(InventoryTypes.invQtyAttr(n))) or 0)
end
end
return total
end
local function disableCoreBackpack()
task.spawn(function()
for _ = 1, 10 do
local ok = pcall(function()
StarterGui:SetCoreGuiEnabled(Enum.CoreGuiType.Backpack, false)
end)
if ok then
break
end
task.wait(0.2)
end
end)
end
local function bindHotbar(root: Instance)
local slots: { [number]: GuiObject } = {}
local function driveSlot(n: number)
local slot = slots[n]
if not slot then
return
end
local itemId = tostring(localPlayer:GetAttribute(InventoryTypes.hotbarSlotAttr(n)) or "")
local active = math.floor(tonumber(localPlayer:GetAttribute(InventoryTypes.HOTBAR_EQUIPPED_ATTR)) or 0)
local icon = slot:FindFirstChild("Icon")
if icon and (icon:IsA("ImageLabel") or icon:IsA("ImageButton")) then
local id = if itemId ~= "" then SlotGrid.resolveItemIcon(itemId) else ""
icon.Image = id
icon.Visible = id ~= ""
end
local count = slot:FindFirstChild("Count")
if count and count:IsA("TextLabel") then
local qty = totalQty(itemId)
count.Text = if qty > 1 then tostring(qty) else ""
count.Visible = qty > 1
end
local activeFrame = slot:FindFirstChild("Active")
if activeFrame and activeFrame:IsA("GuiObject") then
activeFrame.Visible = active == n and itemId ~= ""
end
end
local function refresh()
for n in slots do
driveSlot(n)
end
end
-- Discover the 1..9 slot frames.
for _, d in root:GetDescendants() do
if d:IsA("GuiObject") then
local n = d:GetAttribute("HotbarSlot")
if typeof(n) == "number" then
slots[math.floor(n)] = d
end
end
end
for n, slot in slots do
slot.InputBegan:Connect(function(input)
if input.UserInputType == Enum.UserInputType.MouseButton2 then
-- Right-click → unpin.
Remotes.event("InventorySetHotbar"):FireServer(n, nil)
elseif
input.UserInputType == Enum.UserInputType.MouseButton1
or input.UserInputType == Enum.UserInputType.Touch
then
local itemId = tostring(localPlayer:GetAttribute(InventoryTypes.hotbarSlotAttr(n)) or "")
if itemId ~= "" then
DragDrop.beginDrag({
kind = "hotbarSlot",
slot = n,
itemId = itemId,
icon = SlotGrid.resolveItemIcon(itemId),
label = SlotGrid.abbrev(itemId),
})
end
end
end)
if slot:IsA("GuiButton") then
slot.Activated:Connect(function()
if DragDrop.didDrag() then
return -- the press was a drag (reorder), not a click
end
Remotes.event("InventoryUseHotbar"):FireServer(n)
end)
end
end
-- React to replicated changes.
localPlayer.AttributeChanged:Connect(function(attr)
-- Pinned items, the active slot, and inventory qty (the Count badge) all drive the hotbar.
if
string.match(attr, "^HotbarSlot%d+$")
or attr == InventoryTypes.HOTBAR_EQUIPPED_ATTR
or string.match(attr, "^InvSlot_%d+$")
or string.match(attr, "^InvQty_%d+$")
then
refresh()
end
end)
-- Drop target: an inventory item pins here; a hotbar item reorders here.
local function hotbarSlotAt(pos: Vector2): number?
for n, frame in slots do
if DragDrop.hitTestGui(frame, pos) then
return n
end
end
return nil
end
DragDrop.addTarget({
hitTest = function(pos)
return hotbarSlotAt(pos) ~= nil
end,
onDrop = function(payload, pos)
local target = hotbarSlotAt(pos)
if not target then
return
end
if payload.kind == "invSlot" then
Remotes.event("InventorySetHotbar"):FireServer(target, payload.itemId)
elseif payload.kind == "hotbarSlot" and payload.slot ~= target then
Remotes.event("InventorySwapHotbar"):FireServer(payload.slot, target)
end
end,
})
-- Number keys 1-9 → use that hotbar slot.
UserInputService.InputBegan:Connect(function(input, gameProcessed)
if gameProcessed then
return
end
local n = DIGIT_KEYS[input.KeyCode]
if n and n <= (math.floor(tonumber(InventoryConfig.get().HotbarSize) or 9)) then
Remotes.event("InventoryUseHotbar"):FireServer(n)
end
end)
refresh()
end
function Hotbar.start(_options: { [string]: any }?)
if started then
return
end
started = true
disableCoreBackpack()
local playerGui = localPlayer:WaitForChild("PlayerGui")
local bound = false
local function tryBind()
if bound then
return
end
for _, gui in playerGui:GetChildren() do
if gui:IsA("ScreenGui") and gui.Name == "SurvivalHotbar" then
bound = true
bindHotbar(gui)
return
end
end
end
tryBind()
playerGui.ChildAdded:Connect(function(child)
if child.Name == "SurvivalHotbar" and child:IsA("ScreenGui") then
tryBind()
end
end)
end
return Hotbar
+34 -8
View File
@@ -105,15 +105,31 @@ end
local Hud = {}
local started = false
-- installHud() + the StarterGui->PlayerGui copy can briefly produce two HUDs; keep one.
-- Several paths can land more than one HUD in PlayerGui — installHud()'s clone + the
-- StarterGui->PlayerGui copy, or a late fallback racing the authored template. Keep exactly one,
-- always preferring an AUTHORED HUD over the built-in fallback (and dropping extra copies).
local function dedupeHuds(playerGui: Instance)
local kept: Instance? = nil
local authored: Instance? = nil
local fallbacks: { Instance } = {}
for _, gui in playerGui:GetChildren() do
if gui:IsA("ScreenGui") and gui.Name == HUD_NAME then
if kept then
gui:Destroy()
if gui:GetAttribute(HudFallback.FALLBACK_ATTRIBUTE) then
table.insert(fallbacks, gui)
elseif authored then
gui:Destroy() -- a second authored copy
else
kept = gui
authored = gui
end
end
end
if authored then
for _, fb in fallbacks do
fb:Destroy() -- authored wins; the fallback is only a no-template safety net
end
else
for index, fb in fallbacks do
if index > 1 then
fb:Destroy()
end
end
end
@@ -296,6 +312,13 @@ function Hud.start(_options: { [string]: any }?)
end
dedupeHuds(playerGui)
-- Run it again whenever another HUD shows up (a fallback racing the template, a late
-- StarterGui copy, installHud's clone) so we never end up with two on screen.
playerGui.ChildAdded:Connect(function(child)
if child:IsA("ScreenGui") and child.Name == HUD_NAME then
dedupeHuds(playerGui)
end
end)
-- A stat bar is any GuiObject carrying a `Stat` attribute (how the authored
-- template marks them) OR tagged `SurvivorStatBar` (for the Builder UI / fallback).
@@ -442,11 +465,14 @@ function Hud.start(_options: { [string]: any }?)
end
end)
-- Zero-setup safety net: if nothing showed up, build the minimal fallback.
-- Zero-setup safety net: if no HUD showed up at all, build the minimal fallback. Guard on the
-- ScreenGui's presence (not just whether bars bound yet) so a slow authored HUD never gets a
-- fallback built alongside it; if it still arrives late, the ChildAdded dedupe drops the extra.
task.delay(FALLBACK_WAIT, function()
if next(bound) == nil then
HudFallback.build(playerGui, StatConfig.resolve().stats)
if next(bound) ~= nil or playerGui:FindFirstChild(HUD_NAME) then
return
end
HudFallback.build(playerGui, StatConfig.resolve().stats)
end)
end
+5
View File
@@ -28,9 +28,14 @@ local FALLBACK_COLORS = {
local HudFallback = {}
-- Marks a HUD as the built-in fallback (vs an authored template), so the binder's dedupe can
-- always drop the fallback in favour of an authored HUD when both end up present.
HudFallback.FALLBACK_ATTRIBUTE = "SurvivorCoreHudFallback"
function HudFallback.build(playerGui: Instance, stats: { any })
local screen = Instance.new("ScreenGui")
screen.Name = HUD_NAME
screen:SetAttribute(HudFallback.FALLBACK_ATTRIBUTE, true)
screen.ResetOnSpawn = false
-- Respect the ~36px Roblox topbar inset so bars never render under the CoreGui menu.
screen.IgnoreGuiInset = false
+274
View File
@@ -0,0 +1,274 @@
--!nonstrict
--[[
InventoryUi binds the Inventory tab. CLIENT-ONLY.
Discovers the authored elements by attribute (like the HUD binder) and drives DATA only:
`InventoryGrid` → a SlotGrid (clones slots, shows icons/counts, drag + select)
`WeightReadout` → a Fill bar + Value text from CarryWeight / MaxCarryWeight
`SlotReadout` → "used / max slots"
`ItemDetail` → the selected item's icon/name/description + action buttons
(`Action` = use/equip/hotbar/split) that fire the inventory RemoteEvents.
Drag drops are routed through DragDrop targets: inventory→inventory swaps fire
InventorySwapSlots; inventory→hotbar pins fire InventorySetHotbar (registered by Hotbar).
Booted by SurvivorCore.startClient().
]]
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
assert(RunService:IsClient(), "SurvivorCore.InventoryUi is client-only")
local InventoryConfig = require(script.Parent.Parent.shared.InventoryConfig)
local InventoryTypes = require(script.Parent.Parent.shared.InventoryTypes)
local ItemData = require(script.Parent.Parent.shared.ItemData)
local Remotes = require(script.Parent.Parent.shared.Remotes)
local UiConfig = require(script.Parent.Parent.shared.UiConfig)
local DragDrop = require(script.Parent.DragDrop)
local SlotGrid = require(script.Parent.SlotGrid)
local InventoryUi = {}
local started = false
local localPlayer = Players.LocalPlayer
local function findByAttr(root: Instance, attr: string): GuiObject?
for _, d in root:GetDescendants() do
if d:IsA("GuiObject") and d:GetAttribute(attr) ~= nil then
return d
end
end
return nil
end
local function readNum(attr: string): number
local v = localPlayer:GetAttribute(attr)
return if typeof(v) == "number" then v else 0
end
local function clientFindFreeHotbar(): number?
local size = math.floor(tonumber(InventoryConfig.get().HotbarSize) or 9)
for n = 1, size do
local v = localPlayer:GetAttribute(InventoryTypes.hotbarSlotAttr(n))
if typeof(v) ~= "string" or v == "" then
return n
end
end
return nil
end
local function bindGrid(grid: GuiObject)
local menu = grid:FindFirstAncestorWhichIsA("ScreenGui") or grid
local theme = UiConfig.get().Theme
-- Detail strip + selection ------------------------------------------------
local detail = findByAttr(menu, "ItemDetail")
local detailIcon, detailName, detailDesc
local actions: { [string]: GuiButton } = {}
if detail then
detailIcon = detail:FindFirstChild("Icon")
detailName = detail:FindFirstChild("Name")
detailDesc = detail:FindFirstChild("Description")
for _, d in detail:GetDescendants() do
local action = d:GetAttribute("Action")
if typeof(action) == "string" and d:IsA("GuiButton") then
actions[action] = d
end
end
end
local selectedSlot: number? = nil
local function renderDetail()
if not detail then
return
end
local itemId = if selectedSlot
then tostring(localPlayer:GetAttribute(InventoryTypes.invSlotAttr(selectedSlot)) or "")
else ""
local def = if itemId ~= "" then ItemData.get(itemId) else nil
local qty = if selectedSlot then readNum(InventoryTypes.invQtyAttr(selectedSlot)) else 0
if detailIcon and (detailIcon:IsA("ImageLabel") or detailIcon:IsA("ImageButton")) then
local id = if itemId ~= "" then SlotGrid.resolveItemIcon(itemId) else ""
detailIcon.Image = id
detailIcon.Visible = id ~= ""
end
if detailName and detailName:IsA("TextLabel") then
detailName.Text = if def then def.name else ""
end
if detailDesc and detailDesc:IsA("TextLabel") then
detailDesc.Text = if def and def.description then def.description else ""
end
-- Show actions only when they apply to the selected item.
if actions.use then
actions.use.Visible = def ~= nil and def.consumable == true
end
if actions.equip then
actions.equip.Visible = def ~= nil and def.equipSlot ~= nil
end
if actions.hotbar then
actions.hotbar.Visible = def ~= nil
end
if actions.split then
actions.split.Visible = def ~= nil and qty > 1
end
end
-- Mount the slot grid; selecting a slot refreshes the detail strip.
SlotGrid.mount(grid, DragDrop, {
onSelect = function(slot)
selectedSlot = slot
renderDetail()
end,
})
-- Wire the action buttons (once).
if actions.use then
actions.use.Activated:Connect(function()
if selectedSlot then
Remotes.event("InventoryUseItem"):FireServer(selectedSlot)
end
end)
end
if actions.equip then
actions.equip.Activated:Connect(function()
if selectedSlot then
Remotes.event("InventoryEquip"):FireServer(selectedSlot)
end
end)
end
if actions.hotbar then
actions.hotbar.Activated:Connect(function()
if not selectedSlot then
return
end
local itemId = tostring(localPlayer:GetAttribute(InventoryTypes.invSlotAttr(selectedSlot)) or "")
local free = clientFindFreeHotbar()
if itemId ~= "" and free then
Remotes.event("InventorySetHotbar"):FireServer(free, itemId)
end
end)
end
if actions.split then
actions.split.Activated:Connect(function()
if not selectedSlot then
return
end
local qty = readNum(InventoryTypes.invQtyAttr(selectedSlot))
if qty > 1 then
Remotes.event("InventorySplitStack"):FireServer(selectedSlot, math.floor(qty / 2))
end
end)
end
-- Weight readout ----------------------------------------------------------
local weight = findByAttr(menu, "WeightReadout")
local weightFill = weight and weight:FindFirstChild("Fill")
local weightValue = weight and weight:FindFirstChild("Value")
local function renderWeight()
local carry = readNum(InventoryTypes.CARRY_WEIGHT_ATTR)
local maxCarry = readNum(InventoryTypes.MAX_CARRY_WEIGHT_ATTR)
local ratio = if maxCarry > 0 then carry / maxCarry else 0
if weightFill and weightFill:IsA("GuiObject") then
local s = weightFill.Size
weightFill.Size = UDim2.new(math.clamp(ratio, 0, 1), 0, s.Y.Scale, s.Y.Offset)
weightFill.BackgroundColor3 = if ratio > 1 then theme.Bad else theme.Ok
end
if weightValue and weightValue:IsA("TextLabel") then
weightValue.Text = string.format("%.1f / %.0f kg", carry, maxCarry)
end
end
-- Slot-usage readout ------------------------------------------------------
local slotReadout = findByAttr(menu, "SlotReadout")
local slotValue = slotReadout and slotReadout:FindFirstChild("Value")
local function renderSlots()
if not (slotValue and slotValue:IsA("TextLabel")) then
return
end
local max = math.floor(readNum(InventoryTypes.MAX_SLOTS_ATTR))
local used = 0
for n = 1, max do
if tostring(localPlayer:GetAttribute(InventoryTypes.invSlotAttr(n)) or "") ~= "" then
used += 1
end
end
slotValue.Text = string.format("%d / %d slots", used, max)
end
-- React to replicated changes.
localPlayer.AttributeChanged:Connect(function(attr)
if attr == InventoryTypes.CARRY_WEIGHT_ATTR or attr == InventoryTypes.MAX_CARRY_WEIGHT_ATTR then
renderWeight()
end
if string.match(attr, "^InvSlot_(%d+)$") or attr == InventoryTypes.MAX_SLOTS_ATTR then
renderSlots()
end
-- Keep the detail strip honest when the selected slot's contents change.
if selectedSlot then
local n = string.match(attr, "^Inv%a-_(%d+)$")
if n and tonumber(n) == selectedSlot then
renderDetail()
end
end
end)
-- Inventory→inventory drop target: drop one slot onto another → swap/merge.
local function slotAt(pos: Vector2): number?
for _, child in grid:GetChildren() do
if child:IsA("GuiObject") and child:GetAttribute("Index") ~= nil and DragDrop.hitTestGui(child, pos) then
return child:GetAttribute("Index")
end
end
return nil
end
DragDrop.addTarget({
hitTest = function(pos)
return slotAt(pos) ~= nil
end,
onDrop = function(payload, pos)
local target = slotAt(pos)
if not target then
return
end
if payload.kind == "invSlot" and payload.slot ~= target then
Remotes.event("InventorySwapSlots"):FireServer(payload.slot, target)
end
end,
})
renderWeight()
renderSlots()
renderDetail()
end
function InventoryUi.start(_options: { [string]: any }?)
if started then
return
end
started = true
local playerGui = localPlayer:WaitForChild("PlayerGui")
local bound = false
local function tryBind()
if bound then
return
end
local grid = findByAttr(playerGui, "InventoryGrid")
if grid then
bound = true
bindGrid(grid)
end
end
tryBind()
playerGui.DescendantAdded:Connect(function(d)
if d:IsA("GuiObject") and d:GetAttribute("InventoryGrid") ~= nil then
tryBind()
end
end)
end
return InventoryUi
+472
View File
@@ -0,0 +1,472 @@
--!nonstrict
--[[
PanelManager the tabbed-menu framework. CLIENT-ONLY.
SurvivorCore ships ONE menu ScreenGui ("SurvivalMenu") whose tabs (Inventory, Character,
Codex, ) are authored as marked frames inside it the same template+binder model as the
HUD. This manager discovers those tabs by attribute, owns the open/close + tab-switch state,
the menu keybind, the click-outside overlay, and the open/close animation. It drives state
only; the template owns all styling.
Template attribute conventions (set in Studio; the binder never restyles):
The animated panel frame: a child named "Root" (falls back to the first GuiObject child).
A tab button: GuiButton with `InventoryTab` = <id> (e.g. "inventory"). Optional `Selected`
child shown while that tab is active.
A tab's content: GuiObject with `TabContent` = <id> (matched to the button by id).
• A close button (optional): GuiButton with `MenuClose` = true.
• For code-registered tabs: containers marked `TabBar` = true and `TabContentHost` = true.
Public API (re-exported on SurvivorCore.UI):
• registerPanel({ id, title, icon?, order?, build? }) — add a tab from code; `build(frame)`
fills its (empty) content once. Safe to call before start() (buffered).
• open(id?) / close() / toggle(id?) — id defaults to the current/Inventory tab.
Booted by SurvivorCore.startClient(). Tuning: the "UI" Config section.
]]
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local StarterGui = game:GetService("StarterGui")
local TextChatService = game:GetService("TextChatService")
local TweenService = game:GetService("TweenService")
local UserInputService = game:GetService("UserInputService")
assert(RunService:IsClient(), "SurvivorCore.PanelManager is client-only — boot it via SurvivorCore.startClient()")
local UiConfig = require(script.Parent.Parent.shared.UiConfig)
local PanelManager = {}
local MENU_NAME = "SurvivalMenu"
local DEFAULT_TAB = "inventory"
local OPEN_INFO = TweenInfo.new(0.15, Enum.EasingStyle.Quad, Enum.EasingDirection.Out)
local CLOSE_INFO = TweenInfo.new(0.1, Enum.EasingStyle.Quad, Enum.EasingDirection.In)
local started = false
local localPlayer = Players.LocalPlayer
local playerGui: Instance? = nil
local menuGui: ScreenGui? = nil
local menuRoot: GuiObject? = nil
local tabs: { [string]: GuiButton } = {} -- id -> tab button
local contents: { [string]: GuiObject } = {} -- id -> content frame
local activeTab: string? = nil
local menuOpen = false
-- Code-registered panels buffered until the menu is bound.
local pendingPanels: { any } = {}
local registered: { [string]: boolean } = {}
local mouseOverrideConn: RBXScriptConnection? = nil
local clickOutsideOverlay: ScreenGui? = nil
-- ── Mouse unlock (fights first-person re-lock, like TCE) ───────────────────
local function startMouseOverride()
if mouseOverrideConn then
return
end
mouseOverrideConn = RunService.RenderStepped:Connect(function()
UserInputService.MouseBehavior = Enum.MouseBehavior.Default
UserInputService.MouseIconEnabled = true
end)
end
local function stopMouseOverride()
if mouseOverrideConn then
mouseOverrideConn:Disconnect()
mouseOverrideConn = nil
end
if localPlayer.CameraMode == Enum.CameraMode.LockFirstPerson then
UserInputService.MouseIconEnabled = false
end
end
-- ── Click-outside overlay ──────────────────────────────────────────────────
local function showClickOutside()
if clickOutsideOverlay or not playerGui then
return
end
local overlay = Instance.new("ScreenGui")
overlay.Name = "SurvivorCorePanelClickOutside"
overlay.ResetOnSpawn = false
overlay.DisplayOrder = UiConfig.get().DisplayOrder.ClickOutside
overlay.ZIndexBehavior = Enum.ZIndexBehavior.Sibling
local btn = Instance.new("TextButton")
btn.Name = "Overlay"
btn.Size = UDim2.fromScale(1, 1)
btn.BackgroundTransparency = 1
btn.Text = ""
btn.Parent = overlay
btn.Activated:Connect(function()
PanelManager.close()
end)
overlay.Parent = playerGui
clickOutsideOverlay = overlay
end
local function hideClickOutside()
if clickOutsideOverlay then
clickOutsideOverlay:Destroy()
clickOutsideOverlay = nil
end
end
-- ── Animation (scale + optional CanvasGroup fade), ported from TCE ─────────
local function animateOpen(frame: GuiObject)
local target = frame.Size
frame.Size = UDim2.new(target.X.Scale * 0.95, target.X.Offset * 0.95, target.Y.Scale * 0.95, target.Y.Offset * 0.95)
if frame:IsA("CanvasGroup") then
frame.GroupTransparency = 1
TweenService:Create(frame, OPEN_INFO, { Size = target, GroupTransparency = 0 }):Play()
else
TweenService:Create(frame, OPEN_INFO, { Size = target }):Play()
end
end
local function animateClose(frame: GuiObject, callback: () -> ())
local current = frame.Size
local small =
UDim2.new(current.X.Scale * 0.95, current.X.Offset * 0.95, current.Y.Scale * 0.95, current.Y.Offset * 0.95)
local tween
if frame:IsA("CanvasGroup") then
tween = TweenService:Create(frame, CLOSE_INFO, { Size = small, GroupTransparency = 1 })
else
tween = TweenService:Create(frame, CLOSE_INFO, { Size = small })
end
tween.Completed:Once(function()
frame.Size = current
callback()
end)
tween:Play()
end
-- ── Tab switching ──────────────────────────────────────────────────────────
function PanelManager.showTab(id: string)
if not contents[id] then
return
end
activeTab = id
for tabId, content in contents do
content.Visible = tabId == id
end
for tabId, button in tabs do
local selected = button:FindFirstChild("Selected")
if selected and selected:IsA("GuiObject") then
selected.Visible = tabId == id
end
end
end
-- ── Open / close / toggle ───────────────────────────────────────────────────
function PanelManager.open(id: string?)
if not menuGui or not menuRoot then
return
end
local target = id or activeTab or DEFAULT_TAB
if not contents[target] then
-- Fall back to any tab so the menu is never blank.
target = next(contents) :: string
if not target then
return
end
end
menuOpen = true
menuGui.Enabled = true
PanelManager.showTab(target)
animateOpen(menuRoot)
startMouseOverride()
showClickOutside()
end
function PanelManager.close()
if not menuOpen or not menuGui or not menuRoot then
return
end
menuOpen = false
local gui = menuGui
animateClose(menuRoot, function()
gui.Enabled = false
end)
stopMouseOverride()
hideClickOutside()
end
function PanelManager.toggle(id: string?)
if not menuGui then
return
end
if menuOpen then
if id and activeTab ~= id then
PanelManager.showTab(id)
else
PanelManager.close()
end
else
PanelManager.open(id)
end
end
function PanelManager.isOpen(): boolean
return menuOpen
end
-- ── Tab discovery + wiring ───────────────────────────────────────────────────
local function wireTabButton(id: string, button: GuiButton)
tabs[id] = button
registered[id] = true
button.Activated:Connect(function()
PanelManager.showTab(id)
end)
end
local function bindContent(id: string, frame: GuiObject)
contents[id] = frame
frame.Visible = false
end
-- Create a tab from code: clone an authored tab button for styling (or build a plain one),
-- add an empty content frame, and let the caller fill it once.
local function realizePanel(spec: any)
if not menuGui or registered[spec.id] then
return
end
local host = menuGui:FindFirstChild("TabContentHost", true)
or (menuRoot and menuRoot:FindFirstChild("Content"))
or menuRoot
local bar = menuGui:FindFirstChild("TabBar", true)
if not host then
return
end
-- Content frame.
local content = Instance.new("Frame")
content.Name = spec.id
content.Size = UDim2.fromScale(1, 1)
content.BackgroundTransparency = 1
content:SetAttribute("TabContent", spec.id)
content.Parent = host
bindContent(spec.id, content)
if typeof(spec.build) == "function" then
task.spawn(spec.build, content)
end
-- Tab button: clone an existing one to inherit styling, else build a minimal button.
if bar then
local templateButton: GuiButton? = nil
for _, b in tabs do
templateButton = b
break
end
local button: GuiButton
if templateButton then
button = templateButton:Clone()
button:SetAttribute("InventoryTab", spec.id)
local label = button:FindFirstChildWhichIsA("TextLabel") or (button:IsA("TextButton") and button)
if label and spec.title then
label.Text = spec.title
end
else
local tb = Instance.new("TextButton")
tb.Size = UDim2.fromOffset(96, 28)
tb.Text = spec.title or spec.id
tb:SetAttribute("InventoryTab", spec.id)
button = tb
end
if spec.order then
button.LayoutOrder = spec.order
end
button.Parent = bar
wireTabButton(spec.id, button)
end
end
local function bindMenu(gui: ScreenGui)
if menuGui == gui then
return
end
menuGui = gui
menuRoot = (gui:FindFirstChild("Root") or gui:FindFirstChildWhichIsA("GuiObject")) :: any
gui.Enabled = false
for _, d in gui:GetDescendants() do
if d:IsA("GuiObject") then
local tabId = d:GetAttribute("InventoryTab")
local contentId = d:GetAttribute("TabContent")
if typeof(tabId) == "string" and tabId ~= "" and d:IsA("GuiButton") then
wireTabButton(tabId, d)
end
if typeof(contentId) == "string" and contentId ~= "" then
bindContent(contentId, d)
end
if d:GetAttribute("MenuClose") ~= nil and d:IsA("GuiButton") then
d.Activated:Connect(function()
PanelManager.close()
end)
end
end
end
-- Default the active tab so the first open isn't blank.
if contents[DEFAULT_TAB] then
activeTab = DEFAULT_TAB
else
activeTab = next(contents) :: string?
end
-- Flush any panels registered before the menu existed.
local pending = pendingPanels
pendingPanels = {}
for _, spec in pending do
realizePanel(spec)
end
end
-- ── Public registration (buffered until the menu binds) ────────────────────
function PanelManager.registerPanel(spec: any)
assert(typeof(spec) == "table" and typeof(spec.id) == "string", "registerPanel: spec.id (string) is required")
if menuGui then
realizePanel(spec)
else
table.insert(pendingPanels, spec)
end
end
-- ── Keybinds ────────────────────────────────────────────────────────────────
local function resolveKey(name: any): Enum.KeyCode?
if typeof(name) ~= "string" or name == "" then
return nil
end
local ok, key = pcall(function()
return (Enum.KeyCode :: any)[name]
end)
if ok and typeof(key) == "EnumItem" then
return key :: Enum.KeyCode
end
return nil
end
-- Some keys are owned by Roblox CoreGui (Tab opens the player roster) and never reach a game
-- listener — the press arrives with gameProcessed == true. ContextActionService can't out-rank
-- CoreGui, so the only reliable fix is to disable the colliding core element. We do that only
-- when the configured Menu key actually collides (today just Tab), and only if ReclaimCoreKeys
-- is on. Same pcall retry loop the Hotbar/HUD use to survive late CoreGui init + respawns.
local CORE_KEY_COLLISIONS: { [Enum.KeyCode]: Enum.CoreGuiType } = {
[Enum.KeyCode.Tab] = Enum.CoreGuiType.PlayerList,
}
local function reclaimCoreKey(menuKey: Enum.KeyCode?)
if not menuKey or not UiConfig.get().ReclaimCoreKeys then
return
end
local coreType = CORE_KEY_COLLISIONS[menuKey]
if not coreType then
return
end
task.spawn(function()
for _ = 1, 10 do
local ok = pcall(function()
StarterGui:SetCoreGuiEnabled(coreType, false)
end)
if ok then
break
end
task.wait(0.2)
end
end)
end
local function wireKeybinds()
local binds = UiConfig.get().Keybinds or {}
-- Values are a tab id (string) or `false` (the menu-toggle sentinel, no specific tab).
local map: { [Enum.KeyCode]: string | boolean } = {}
local menuKey = resolveKey(binds.Menu)
if menuKey then
map[menuKey] = false
reclaimCoreKey(menuKey) -- free Tab (etc.) from CoreGui so our handler sees it
end
for tabName, keyName in
{ Character = binds.Character, Codex = binds.Codex, Achievements = binds.Achievements, Quests = binds.Quests }
do
local key = resolveKey(keyName)
if key then
map[key] = string.lower(tabName)
end
end
UserInputService.InputBegan:Connect(function(input, gameProcessed)
if gameProcessed or input.UserInputType ~= Enum.UserInputType.Keyboard then
return
end
if UserInputService:GetFocusedTextBox() then
return -- never toggle the menu while the user is typing
end
local entry = map[input.KeyCode]
if entry == false then
PanelManager.toggle(nil) -- the menu-toggle key (no specific tab)
elseif typeof(entry) == "string" then
PanelManager.toggle(entry)
end
end)
end
-- The engine draws its HUD in the top-left, where Roblox's chat also sits. Move the chat
-- (TextChatService) out from under it — to the bottom-left by default — so they don't overlap.
-- Alignment is config-driven; opt out with Config.override("UI", { Chat = { Reposition = false } }).
local function repositionChat()
local chat = UiConfig.get().Chat
if not chat or not chat.Reposition then
return
end
if TextChatService.ChatVersion ~= Enum.ChatVersion.TextChatService then
return -- legacy chat has no ChatWindowConfiguration to move
end
task.spawn(function()
local config = TextChatService:FindFirstChildOfClass("ChatWindowConfiguration")
or TextChatService:WaitForChild("ChatWindowConfiguration", 5)
if not config then
return
end
local h = (Enum.HorizontalAlignment :: any)[chat.Horizontal or "Left"]
local v = (Enum.VerticalAlignment :: any)[chat.Vertical or "Bottom"]
if typeof(h) == "EnumItem" then
(config :: any).HorizontalAlignment = h
end
if typeof(v) == "EnumItem" then
(config :: any).VerticalAlignment = v
end
end)
end
function PanelManager.start(_options: { [string]: any }?)
if started then
return
end
started = true
playerGui = localPlayer:WaitForChild("PlayerGui")
repositionChat()
local existing = (playerGui :: Instance):FindFirstChild(MENU_NAME)
if existing and existing:IsA("ScreenGui") then
bindMenu(existing)
end -- Bind the menu whenever it arrives (StarterGui copy on spawn, or the fallback builder).
(playerGui :: Instance).ChildAdded:Connect(function(child)
if child.Name == MENU_NAME and child:IsA("ScreenGui") then
bindMenu(child)
end
end)
wireKeybinds()
end
return PanelManager
+202
View File
@@ -0,0 +1,202 @@
--!nonstrict
--[[
SlotGrid the reusable inventory slot-grid binder primitive. CLIENT-ONLY.
Given a container marked `InventoryGrid` that holds one prototype child marked `SlotTemplate`,
this clones the template `MaxInvSlots` times into the grid (the grid's authored UIGridLayout
does the layout) and drives each slot's data ONLY never its styling:
child ImageLabel `Icon` item icon (hidden when "")
child TextLabel `Count` stack qty (hidden when 1)
child GuiObject `Selected` shown for the selected slot
Each slot is a drag source (press-drag past threshold DragDrop ghost) and click-to-select.
Rebinds reactively to InvSlot_N / InvQty_N / MaxInvSlots attribute changes (same model as the
HUD binder). Also exposes the shared icon resolver used by the hotbar / character sheet.
]]
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
assert(RunService:IsClient(), "SurvivorCore.SlotGrid is client-only")
local Assets = require(script.Parent.Parent.foundation.Assets)
local ItemData = require(script.Parent.Parent.shared.ItemData)
local InventoryTypes = require(script.Parent.Parent.shared.InventoryTypes)
local SlotGrid = {}
local ICON_CATEGORY = "ItemIcons"
local localPlayer = Players.LocalPlayer
-- Resolve an item's icon: replicated def icon → Assets registry ("ItemIcons") → "" (hidden).
-- Mirrors Hud.resolveIcon, so a fresh engine shows clean empty slots, never a broken image.
function SlotGrid.resolveItemIcon(itemId: string): string
local def = ItemData.get(itemId)
if def and typeof(def.icon) == "string" and def.icon ~= "" then
return def.icon
end
return Assets.tryGet(ICON_CATEGORY, itemId)
end
-- A short uppercase tag for an item (drag-ghost / empty-icon fallback).
function SlotGrid.abbrev(itemId: string): string
local def = ItemData.get(itemId)
local name = (def and def.name) or itemId
return string.upper(string.sub(name, 1, 3))
end
local function readSlotItemId(n: number): string
local v = localPlayer:GetAttribute(InventoryTypes.invSlotAttr(n))
return if typeof(v) == "string" then v else ""
end
local function readSlotQty(n: number): number
local v = localPlayer:GetAttribute(InventoryTypes.invQtyAttr(n))
return if typeof(v) == "number" then math.floor(v) else 0
end
local function readMaxSlots(): number
local v = localPlayer:GetAttribute(InventoryTypes.MAX_SLOTS_ATTR)
return if typeof(v) == "number" and v >= 1 then math.floor(v) else 0
end
-- Mount the grid in `container`. opts.onSelect(slot, itemId) fires on a click (not a drag).
-- Requires the DragDrop module (passed in so SlotGrid doesn't hard-depend on drop wiring).
function SlotGrid.mount(container: GuiObject, dragDrop: any, opts: any)
opts = opts or {}
local template = container:FindFirstChild("SlotTemplate", true)
for _, d in container:GetDescendants() do
if d:IsA("GuiObject") and d:GetAttribute("SlotTemplate") ~= nil then
template = d
break
end
end
if not template or not template:IsA("GuiObject") then
warn(`[SurvivorCore SlotGrid] '{container:GetFullName()}' has no SlotTemplate child`)
return nil
end
template.Visible = false
local slots: { [number]: GuiObject } = {}
local selected: number? = nil
local function driveSlot(n: number)
local slot = slots[n]
if not slot then
return
end
local itemId = readSlotItemId(n)
local qty = readSlotQty(n)
local icon = slot:FindFirstChild("Icon")
if icon and (icon:IsA("ImageLabel") or icon:IsA("ImageButton")) then
local id = if itemId ~= "" then SlotGrid.resolveItemIcon(itemId) else ""
icon.Image = id
icon.Visible = id ~= ""
end
local count = slot:FindFirstChild("Count")
if count and count:IsA("TextLabel") then
count.Text = if qty > 1 then tostring(qty) else ""
count.Visible = qty > 1
end
local sel = slot:FindFirstChild("Selected")
if sel and sel:IsA("GuiObject") then
sel.Visible = selected == n
end
end
local function refresh()
for n in slots do
driveSlot(n)
end
end
local function setSelected(n: number?)
selected = n
refresh()
end
local function wireSlot(n: number, slot: GuiObject)
slot:SetAttribute("Index", n)
slot.InputBegan:Connect(function(input)
if
input.UserInputType == Enum.UserInputType.MouseButton1
or input.UserInputType == Enum.UserInputType.Touch
then
local itemId = readSlotItemId(n)
if itemId ~= "" then
dragDrop.beginDrag({
kind = "invSlot",
slot = n,
itemId = itemId,
icon = SlotGrid.resolveItemIcon(itemId),
label = SlotGrid.abbrev(itemId),
})
end
end
end)
if slot:IsA("GuiButton") then
slot.Activated:Connect(function()
if dragDrop.didDrag() then
return -- the press was a drag, not a click
end
setSelected(n)
if opts.onSelect then
opts.onSelect(n, readSlotItemId(n))
end
end)
end
end
local function build()
local max = readMaxSlots()
-- Add missing slot clones.
for n = 1, max do
if not slots[n] then
local clone = template:Clone()
clone.Name = "Slot" .. n
clone.Visible = true
clone.LayoutOrder = n
clone.Parent = container
slots[n] = clone
wireSlot(n, clone)
end
end
-- Remove surplus slots (e.g. a backpack was unequipped).
for n, slot in slots do
if n > max then
slot:Destroy()
slots[n] = nil
end
end
if selected and not slots[selected] then
selected = nil
end
refresh()
end
build()
localPlayer.AttributeChanged:Connect(function(attr)
local slotIdx = string.match(attr, "^InvSlot_(%d+)$") or string.match(attr, "^InvQty_(%d+)$")
local n = slotIdx and tonumber(slotIdx)
if n then
driveSlot(n)
elseif attr == InventoryTypes.MAX_SLOTS_ATTR then
build()
end
end)
return {
refresh = refresh,
setSelected = setSelected,
getSelected = function()
return selected
end,
getSelectedItemId = function()
return if selected then readSlotItemId(selected) else ""
end,
}
end
return SlotGrid
+651
View File
@@ -0,0 +1,651 @@
--!nonstrict
--[[
UiFallback the zero-setup safety net for the menu + hotbar. CLIENT-ONLY.
If no authored `SurvivalMenu` / `SurvivalHotbar` template reaches the player (none mounted,
bundled, or installed), this builds a deliberately minimal, unstyled version using the SAME
marker attributes the templates use, so the binders (PanelManager / InventoryUi / Hotbar /
CharacterSheet) drive it identically. The authored templates are the real thing this is
just the floor, and (like HudFallback) the only other code that builds UI instances. Zero
asset IDs.
]]
local UiConfig = require(script.Parent.Parent.shared.UiConfig)
local UiFallback = {}
local EQUIP_SLOTS = {
{ "head", "Head" },
{ "top", "Top" },
{ "pants", "Pants" },
{ "shoes", "Shoes" },
{ "back", "Back" },
{ "quiver", "Quiver" },
}
local ATTR_ROWS = { "Health", "Energy", "Hunger", "Thirst", "Fatigue" }
local TABS = {
{ "inventory", "Inventory" },
{ "character", "Character" },
{ "codex", "Codex" },
{ "achievements", "Achievements" },
{ "quests", "Quests" },
}
-- Tiny instance builder: new(class, props, attrs?, children?).
local function new(
class: string,
props: { [string]: any },
attrs: { [string]: any }?,
children: { Instance }?
): Instance
local inst = Instance.new(class)
for k, v in props do
(inst :: any)[k] = v
end
if attrs then
for k, v in attrs do
inst:SetAttribute(k, v)
end
end
if children then
for _, c in children do
c.Parent = inst
end
end
return inst
end
local function corner(r: number): Instance
return new("UICorner", { CornerRadius = UDim.new(0, r) })
end
local function stroke(theme: any): Instance
return new("UIStroke", { Color = theme.StrokeColor, Transparency = theme.StrokeTransparency, Thickness = 1 })
end
local function slotTemplate(theme: any): Instance
return new("ImageButton", {
Name = "SlotTemplate",
Size = UDim2.fromOffset(64, 64),
BackgroundColor3 = theme.SlotColor,
BackgroundTransparency = 0.25,
AutoButtonColor = false,
BorderSizePixel = 0,
Image = "",
Visible = false,
}, { SlotTemplate = true }, {
corner(6),
stroke(theme),
new("ImageLabel", {
Name = "Icon",
Size = UDim2.fromScale(0.78, 0.78),
Position = UDim2.fromScale(0.11, 0.11),
BackgroundTransparency = 1,
ScaleType = Enum.ScaleType.Fit,
Image = "",
Visible = false,
ZIndex = 2,
}),
new("TextLabel", {
Name = "Count",
AnchorPoint = Vector2.new(1, 1),
Position = UDim2.new(1, -3, 1, -2),
Size = UDim2.fromOffset(30, 16),
BackgroundTransparency = 1,
Text = "",
TextColor3 = theme.Text,
Font = theme.FontBold,
TextSize = 12,
TextXAlignment = Enum.TextXAlignment.Right,
ZIndex = 3,
}),
new(
"Frame",
{ Name = "Selected", Size = UDim2.fromScale(1, 1), BackgroundTransparency = 1, Visible = false, ZIndex = 2 },
nil,
{
corner(6),
new("UIStroke", { Color = theme.Accent, Thickness = 2 }),
}
),
})
end
local function buildInventoryContent(theme: any): Instance
local grid = new("ScrollingFrame", {
Name = "Grid",
Position = UDim2.fromScale(0, 0),
Size = UDim2.new(0.6, -6, 1, -50),
BackgroundTransparency = 1,
BorderSizePixel = 0,
ScrollBarThickness = 6,
CanvasSize = UDim2.new(),
}, { InventoryGrid = true }, {
new("UIGridLayout", {
CellSize = UDim2.fromOffset(64, 64),
CellPadding = UDim2.fromOffset(6, 6),
SortOrder = Enum.SortOrder.LayoutOrder,
}),
slotTemplate(theme),
})
local weight = new("Frame", {
Name = "WeightReadout",
Position = UDim2.new(0, 0, 1, -46),
Size = UDim2.new(0.6, -6, 0, 20),
BackgroundColor3 = theme.SlotColor,
BackgroundTransparency = 0.3,
BorderSizePixel = 0,
}, { WeightReadout = true }, {
corner(6),
new(
"Frame",
{ Name = "Fill", Size = UDim2.fromScale(0, 1), BackgroundColor3 = theme.Ok, BorderSizePixel = 0 },
nil,
{ corner(6) }
),
new("TextLabel", {
Name = "Value",
Size = UDim2.fromScale(1, 1),
BackgroundTransparency = 1,
Text = "",
TextColor3 = theme.Text,
Font = theme.Font,
TextSize = 12,
ZIndex = 2,
}),
})
local slotReadout = new("Frame", {
Name = "SlotReadout",
Position = UDim2.new(0, 0, 1, -22),
Size = UDim2.new(0.6, -6, 0, 18),
BackgroundTransparency = 1,
BorderSizePixel = 0,
}, { SlotReadout = true }, {
new("TextLabel", {
Name = "Value",
Size = UDim2.fromScale(1, 1),
BackgroundTransparency = 1,
Text = "",
TextColor3 = theme.TextSecondary,
Font = theme.Font,
TextSize = 12,
TextXAlignment = Enum.TextXAlignment.Left,
}),
})
local actions = {}
for i, spec in { { "use", "Use" }, { "equip", "Equip" }, { "hotbar", "→ Hotbar" }, { "split", "Split" } } do
table.insert(
actions,
new("TextButton", {
Name = "Action_" .. spec[1],
Size = UDim2.new(1, 0, 0, 26),
BackgroundColor3 = theme.SlotColor,
BackgroundTransparency = 0.2,
BorderSizePixel = 0,
Text = spec[2],
TextColor3 = theme.Text,
Font = theme.Font,
TextSize = 13,
LayoutOrder = i,
}, { Action = spec[1] }, { corner(6) })
)
end
local detail = new("Frame", {
Name = "Detail",
AnchorPoint = Vector2.new(1, 0),
Position = UDim2.fromScale(1, 0),
Size = UDim2.fromScale(0.38, 1),
BackgroundColor3 = theme.SlotColor,
BackgroundTransparency = 0.3,
BorderSizePixel = 0,
}, { ItemDetail = true }, {
corner(8),
stroke(theme),
new("UIPadding", {
PaddingTop = UDim.new(0, 10),
PaddingBottom = UDim.new(0, 10),
PaddingLeft = UDim.new(0, 10),
PaddingRight = UDim.new(0, 10),
}),
new("ImageLabel", {
Name = "Icon",
AnchorPoint = Vector2.new(0.5, 0),
Position = UDim2.fromScale(0.5, 0),
Size = UDim2.fromOffset(64, 64),
BackgroundTransparency = 1,
ScaleType = Enum.ScaleType.Fit,
Image = "",
Visible = false,
}),
new("TextLabel", {
Name = "Name",
Position = UDim2.fromOffset(0, 72),
Size = UDim2.new(1, 0, 0, 22),
BackgroundTransparency = 1,
Text = "",
TextColor3 = theme.Text,
Font = theme.FontBold,
TextSize = 15,
}),
new("TextLabel", {
Name = "Description",
Position = UDim2.fromOffset(0, 98),
Size = UDim2.new(1, 0, 0, 90),
BackgroundTransparency = 1,
Text = "",
TextColor3 = theme.TextSecondary,
Font = theme.Font,
TextSize = 12,
TextWrapped = true,
TextYAlignment = Enum.TextYAlignment.Top,
}),
new(
"Frame",
{
Name = "Actions",
AnchorPoint = Vector2.new(0, 1),
Position = UDim2.fromScale(0, 1),
Size = UDim2.new(1, 0, 0, 122),
BackgroundTransparency = 1,
BorderSizePixel = 0,
},
nil,
(function()
local kids = {
new("UIListLayout", {
FillDirection = Enum.FillDirection.Vertical,
Padding = UDim.new(0, 6),
SortOrder = Enum.SortOrder.LayoutOrder,
}),
}
for _, a in actions do
table.insert(kids, a)
end
return kids
end)()
),
})
return new("Frame", {
Name = "Content_inventory",
Size = UDim2.fromScale(1, 1),
BackgroundTransparency = 1,
BorderSizePixel = 0,
Visible = true,
}, { TabContent = "inventory" }, { grid, weight, slotReadout, detail })
end
local function buildCharacterContent(theme: any): Instance
local equipKids = {
new("UIListLayout", {
FillDirection = Enum.FillDirection.Vertical,
Padding = UDim.new(0, 6),
SortOrder = Enum.SortOrder.LayoutOrder,
}),
}
for i, spec in EQUIP_SLOTS do
table.insert(
equipKids,
new("Frame", {
Name = "Equip_" .. spec[1],
Size = UDim2.new(1, 0, 0, 44),
BackgroundColor3 = theme.SlotColor,
BackgroundTransparency = 0.3,
BorderSizePixel = 0,
LayoutOrder = i,
}, { EquipSlot = spec[1] }, {
corner(6),
new("ImageLabel", {
Name = "Icon",
Position = UDim2.fromOffset(6, 6),
Size = UDim2.fromOffset(32, 32),
BackgroundTransparency = 1,
ScaleType = Enum.ScaleType.Fit,
Image = "",
Visible = false,
}),
new("TextLabel", {
Name = "SlotLabel",
Position = UDim2.fromOffset(46, 0),
Size = UDim2.new(0, 70, 1, 0),
BackgroundTransparency = 1,
Text = spec[2],
TextColor3 = theme.TextSecondary,
Font = theme.Font,
TextSize = 13,
TextXAlignment = Enum.TextXAlignment.Left,
}),
new("TextLabel", {
Name = "Name",
Position = UDim2.fromOffset(120, 0),
Size = UDim2.new(1, -126, 1, 0),
BackgroundTransparency = 1,
Text = "",
TextColor3 = theme.Text,
Font = theme.Font,
TextSize = 13,
TextXAlignment = Enum.TextXAlignment.Left,
}),
})
)
end
local equipment = new(
"Frame",
{ Name = "Equipment", Size = UDim2.new(0.46, -6, 1, 0), BackgroundTransparency = 1, BorderSizePixel = 0 },
nil,
equipKids
)
local attrKids = {
new("UIListLayout", {
FillDirection = Enum.FillDirection.Vertical,
Padding = UDim.new(0, 4),
SortOrder = Enum.SortOrder.LayoutOrder,
}),
new("TextLabel", {
Name = "Header",
Size = UDim2.new(1, 0, 0, 22),
BackgroundTransparency = 1,
Text = "Attributes",
TextColor3 = theme.Text,
Font = theme.FontBold,
TextSize = 14,
TextXAlignment = Enum.TextXAlignment.Left,
LayoutOrder = 0,
}),
}
for i, attrName in ATTR_ROWS do
table.insert(
attrKids,
new("Frame", {
Name = "Attr_" .. attrName,
Size = UDim2.new(1, 0, 0, 24),
BackgroundTransparency = 1,
BorderSizePixel = 0,
LayoutOrder = i,
}, { AttributeReadout = attrName }, {
new("TextLabel", {
Name = "Label",
Size = UDim2.fromScale(0.6, 1),
BackgroundTransparency = 1,
Text = attrName,
TextColor3 = theme.TextSecondary,
Font = theme.Font,
TextSize = 13,
TextXAlignment = Enum.TextXAlignment.Left,
}),
new("TextLabel", {
Name = "Value",
AnchorPoint = Vector2.new(1, 0),
Position = UDim2.fromScale(1, 0),
Size = UDim2.fromScale(0.4, 1),
BackgroundTransparency = 1,
Text = "0",
TextColor3 = theme.Text,
Font = theme.FontBold,
TextSize = 13,
TextXAlignment = Enum.TextXAlignment.Right,
}),
})
)
end
local attributes = new(
"Frame",
{
Name = "Attributes",
AnchorPoint = Vector2.new(1, 0),
Position = UDim2.fromScale(1, 0),
Size = UDim2.fromScale(0.5, 1),
BackgroundColor3 = theme.SlotColor,
BackgroundTransparency = 0.3,
BorderSizePixel = 0,
},
nil,
(function()
table.insert(attrKids, 1, corner(8))
table.insert(
attrKids,
2,
new("UIPadding", {
PaddingTop = UDim.new(0, 10),
PaddingBottom = UDim.new(0, 10),
PaddingLeft = UDim.new(0, 10),
PaddingRight = UDim.new(0, 10),
})
)
return attrKids
end)()
)
return new("Frame", {
Name = "Content_character",
Size = UDim2.fromScale(1, 1),
BackgroundTransparency = 1,
BorderSizePixel = 0,
Visible = false,
}, { TabContent = "character" }, { equipment, attributes })
end
local function buildScaffold(theme: any, id: string, text: string): Instance
return new("Frame", {
Name = "Content_" .. id,
Size = UDim2.fromScale(1, 1),
BackgroundTransparency = 1,
BorderSizePixel = 0,
Visible = false,
}, { TabContent = id }, {
new("TextLabel", {
Name = "Placeholder",
Size = UDim2.fromScale(1, 1),
BackgroundTransparency = 1,
Text = text,
TextColor3 = theme.TextSecondary,
Font = theme.Font,
TextSize = 16,
}),
})
end
function UiFallback.buildMenu(playerGui: Instance)
local theme = UiConfig.get().Theme
local tabKids = {
new("UIListLayout", {
FillDirection = Enum.FillDirection.Horizontal,
Padding = UDim.new(0, 6),
SortOrder = Enum.SortOrder.LayoutOrder,
}),
}
for i, spec in TABS do
table.insert(
tabKids,
new("TextButton", {
Name = "Tab_" .. spec[1],
Size = UDim2.new(0, 104, 1, 0),
BackgroundColor3 = theme.SlotColor,
BackgroundTransparency = 0.4,
BorderSizePixel = 0,
Text = spec[2],
TextColor3 = theme.Text,
Font = theme.Font,
TextSize = 14,
LayoutOrder = i,
}, { InventoryTab = spec[1] }, {
corner(6),
new("Frame", {
Name = "Selected",
AnchorPoint = Vector2.new(0.5, 1),
Position = UDim2.new(0.5, 0, 1, -2),
Size = UDim2.new(0.8, 0, 0, 3),
BackgroundColor3 = theme.Accent,
BorderSizePixel = 0,
Visible = false,
}, nil, { corner(2) }),
})
)
end
local tabBar = new("Frame", {
Name = "TabBar",
Position = UDim2.fromScale(0, 0),
Size = UDim2.new(1, -34, 0, 30),
BackgroundTransparency = 1,
BorderSizePixel = 0,
}, { TabBar = true }, tabKids)
local content = new("Frame", {
Name = "Content",
Position = UDim2.fromOffset(0, 40),
Size = UDim2.new(1, 0, 1, -40),
BackgroundTransparency = 1,
BorderSizePixel = 0,
}, { TabContentHost = true }, {
buildInventoryContent(theme),
buildCharacterContent(theme),
buildScaffold(theme, "codex", "Codex — coming soon"),
buildScaffold(theme, "achievements", "Achievements — coming soon"),
buildScaffold(theme, "quests", "Quests — coming soon"),
})
local root = new(
"Frame",
{
Name = "Root",
AnchorPoint = Vector2.new(0.5, 0.5),
Position = UDim2.fromScale(0.5, 0.5),
Size = UDim2.fromOffset(640, 420),
BackgroundColor3 = theme.PanelColor,
BackgroundTransparency = theme.PanelTransparency,
BorderSizePixel = 0,
},
nil,
{
corner(theme.CornerRadius),
stroke(theme),
new("UIPadding", {
PaddingTop = UDim.new(0, 12),
PaddingBottom = UDim.new(0, 12),
PaddingLeft = UDim.new(0, 12),
PaddingRight = UDim.new(0, 12),
}),
new("TextButton", {
Name = "Close",
AnchorPoint = Vector2.new(1, 0),
Position = UDim2.fromScale(1, 0),
Size = UDim2.fromOffset(26, 26),
BackgroundTransparency = 1,
Text = "X",
TextColor3 = theme.TextSecondary,
Font = theme.FontBold,
TextSize = 16,
ZIndex = 5,
}, { MenuClose = true }),
tabBar,
content,
}
)
new("ScreenGui", {
Name = "SurvivalMenu",
ResetOnSpawn = false,
IgnoreGuiInset = false,
ZIndexBehavior = Enum.ZIndexBehavior.Sibling,
DisplayOrder = UiConfig.get().DisplayOrder.Menu,
Enabled = false,
}, nil, { root }).Parent =
playerGui
end
function UiFallback.buildHotbar(playerGui: Instance)
local theme = UiConfig.get().Theme
local slots = {
new("UIListLayout", {
FillDirection = Enum.FillDirection.Horizontal,
Padding = UDim.new(0, 6),
HorizontalAlignment = Enum.HorizontalAlignment.Center,
VerticalAlignment = Enum.VerticalAlignment.Center,
SortOrder = Enum.SortOrder.LayoutOrder,
}),
}
for n = 1, 9 do
table.insert(
slots,
new("ImageButton", {
Name = "Slot" .. n,
Size = UDim2.fromOffset(58, 58),
BackgroundColor3 = theme.SlotColor,
BackgroundTransparency = 0.25,
AutoButtonColor = false,
BorderSizePixel = 0,
Image = "",
LayoutOrder = n,
}, { HotbarSlot = n }, {
corner(6),
stroke(theme),
new("ImageLabel", {
Name = "Icon",
Size = UDim2.fromScale(0.78, 0.78),
Position = UDim2.fromScale(0.11, 0.11),
BackgroundTransparency = 1,
ScaleType = Enum.ScaleType.Fit,
Image = "",
Visible = false,
ZIndex = 2,
}),
new("TextLabel", {
Name = "Key",
Position = UDim2.fromOffset(3, 2),
Size = UDim2.fromOffset(14, 14),
BackgroundTransparency = 1,
Text = tostring(n),
TextColor3 = theme.TextSecondary,
Font = theme.Font,
TextSize = 11,
ZIndex = 3,
}),
new("TextLabel", {
Name = "Count",
AnchorPoint = Vector2.new(1, 1),
Position = UDim2.new(1, -3, 1, -2),
Size = UDim2.fromOffset(28, 14),
BackgroundTransparency = 1,
Text = "",
TextColor3 = theme.Text,
Font = theme.FontBold,
TextSize = 12,
TextXAlignment = Enum.TextXAlignment.Right,
ZIndex = 3,
}),
new("Frame", {
Name = "Active",
Size = UDim2.fromScale(1, 1),
BackgroundTransparency = 1,
Visible = false,
ZIndex = 2,
}, nil, { corner(6), new("UIStroke", { Color = theme.Accent, Thickness = 2 }) }),
})
)
end
local root = new("Frame", {
Name = "Root",
AnchorPoint = Vector2.new(0.5, 1),
Position = UDim2.new(0.5, 0, 1, -12),
Size = UDim2.fromOffset(0, 70),
AutomaticSize = Enum.AutomaticSize.X,
BackgroundTransparency = 1,
BorderSizePixel = 0,
Active = true,
}, nil, slots)
new("ScreenGui", {
Name = "SurvivalHotbar",
ResetOnSpawn = false,
IgnoreGuiInset = false,
ZIndexBehavior = Enum.ZIndexBehavior.Sibling,
DisplayOrder = UiConfig.get().DisplayOrder.Hotbar,
}, nil, { root }).Parent =
playerGui
end
return UiFallback
+4 -2
View File
@@ -40,8 +40,10 @@ return Components.define({
local hp = (instance:GetAttribute("_HP") or 1) - 1
instance:SetAttribute("_HP", hp)
-- TODO (extraction): grant `values.Yield` of `values.ItemId` via the
-- inventory service once persistence/inventory are extracted.
-- The Inventory system grants `values.Yield` of `values.ItemId` by subscribing to
-- the "gather:depleted" hook below (kept here as a hook, not a direct call, to avoid
-- a require cycle and to let any system observe gathers). "gather:hit" fires per
-- interaction for games that want per-hit yield or swing feedback.
Hooks.run("gather:hit", { instance = instance, player = player, values = values, hpLeft = hp })
if hp <= 0 then
+45
View File
@@ -33,6 +33,14 @@ require(script.shared.MovementConfig)
-- Config.override("Consequences", …) works any time before start().
require(script.shared.ConsequenceConfig)
-- Define the "Inventory" Config section (slots + carry-weight + hotbar + equip-slot tuning),
-- so Config.override("Inventory", …) works any time before start().
require(script.shared.InventoryConfig)
-- Define the "UI" Config section (menu/hotbar theme + keybinds), so
-- Config.override("UI", …) works any time before startClient().
require(script.shared.UiConfig)
local SurvivorCore = {}
SurvivorCore.VERSION = "0.2.1"
@@ -92,6 +100,14 @@ function SurvivorCore.start(_options: { [string]: any }?)
-- Health↔Humanoid sync and fresh-body reset on respawn.
require(script.systems.SurvivalConsequences).start(_options)
-- Inventory: server-authoritative slots + carry-weight, hotbar, equipment, consumables.
-- Booted after SurvivalStats so consumables apply their effects via Stats.adjust. The
-- module table IS the public API — SurvivorCore.Inventory.add/remove/move/split/equip/
-- unequip/setHotbar/swapHotbar/useSlot/getQty/has/getSlots (all act on live players).
local inventory = require(script.systems.Inventory)
inventory.start(_options)
SurvivorCore.Inventory = inventory
return SurvivorCore
end
@@ -109,6 +125,35 @@ function SurvivorCore.startClient(_options: { [string]: any }?)
-- Sprint input + low-stat vignette/breathing/heartbeat feedback.
require(script.client.MovementFeedback).start(_options)
-- UI layer: the tabbed menu (Inventory / Character / scaffolded Codex·Achievements·Quests)
-- and the bottom hotbar — authored ScreenGui templates driven by attribute binders, exactly
-- like the HUD. PanelManager owns the menu + keybind; SurvivorCore.UI exposes its registrable
-- panel API (registerPanel / open / close / toggle).
require(script.client.DragDrop).start(_options)
local panelManager = require(script.client.PanelManager)
panelManager.start(_options)
SurvivorCore.UI = panelManager
require(script.client.Hotbar).start(_options)
require(script.client.InventoryUi).start(_options)
require(script.client.CharacterSheet).start(_options)
-- Zero-setup net: if no authored menu/hotbar template reached the player, build the minimal
-- fallback (the binders pick it up via DescendantAdded, like HudFallback).
task.delay(2, function()
local localPlayer = game:GetService("Players").LocalPlayer
local playerGui = localPlayer and localPlayer:FindFirstChild("PlayerGui")
if not playerGui then
return
end
local UiFallback = require(script.client.UiFallback)
if not playerGui:FindFirstChild("SurvivalMenu") then
UiFallback.buildMenu(playerGui)
end
if not playerGui:FindFirstChild("SurvivalHotbar") then
UiFallback.buildHotbar(playerGui)
end
end)
return SurvivorCore
end
+46
View File
@@ -0,0 +1,46 @@
--!nonstrict
--[[
InventoryConfig tuning for the inventory data layer (slots + carry-weight model).
SHARED (the server system reads it; client UI reads HotbarSize / EquipSlots for layout).
Defines the "Inventory" Config section so games retune via
`Config.override("Inventory", { BasePocketSlots = 8 })`.
Numbers ported from The Counter Earth (its GameplayConfig.Inventory + InventoryService
constants) proven values. Read the merged section with InventoryConfig.get().
]]
local Config = require(script.Parent.Parent.foundation.Config)
local InventoryConfig = {}
InventoryConfig.SECTION = "Inventory"
InventoryConfig.DEFAULTS = {
-- Carrying capacity with no backpack equipped. A backpack adds to BOTH.
BasePocketSlots = 5, -- TCE GameplayConfig.Inventory.PocketSlots
BasePocketWeight = 5, -- kg; TCE BasePocketWeight
-- Quick-use hotbar. 9 maps to the number keys 1-9 (and the 9 HotbarSlot{n} attributes).
HotbarSize = 9, -- TCE HOTBAR_SIZE
-- Anti-spam guard on consuming items (seconds between uses, per player).
UseCooldownSeconds = 2.0, -- TCE EAT_COOLDOWN
-- The equipment slots a character sheet exposes. `back` is special — it accepts the
-- backpack (recomputes capacity); the rest are generic single-item slots.
EquipSlots = { "head", "top", "pants", "shoes", "back", "quiver" },
-- Picking up an item of these categories (or anything with an `onConsume`) auto-pins it
-- to the smallest free hotbar slot, so quick-use items are reachable without opening the menu.
AutoHotbarCategories = { "tool", "weapon", "placeable", "consumable" },
}
-- Define the section once per Luau VM (server and client each require this module once).
Config.defineSection(InventoryConfig.SECTION, InventoryConfig.DEFAULTS)
-- The merged (defaults + overrides) Inventory config table.
function InventoryConfig.get(): any
return Config.get(InventoryConfig.SECTION) or InventoryConfig.DEFAULTS
end
return InventoryConfig
+76
View File
@@ -0,0 +1,76 @@
--[[
InventoryTypes the shared inventory schema. SHARED, logic-free (pure functions only).
The single source of truth for how inventory state maps onto Player Attributes, so the
server system (which writes them) and the client binders (which read them) never drift.
The server stores everything as auto-replicating Player Attributes no read RemoteEvents
exactly like the survival stats. Slots are flat scalar pairs (InvSlot_N / InvQty_N) rather
than one serialized table, so the UI gets a granular change signal per slot.
]]
local InventoryTypes = {}
-- A stack occupying one inventory slot.
export type ItemStack = {
itemId: string,
qty: number,
}
-- The inventory fields the engine reads off an item def (all optional but `id`). Defs stay
-- free-form on the Items registry; this just documents what the inventory layer consumes.
export type ItemDef = {
id: string,
name: string?,
stack: number?, -- max stack size (defaults to 1)
weight: number?, -- per-unit carry weight (defaults to 0)
category: string?,
icon: string?,
onConsume: { [string]: number }?, -- statName -> signed delta passed to Stats.adjust
equipment: { slot: string }?, -- head/top/pants/shoes/back/quiver
backpack: { slots: number, maxWeight: number }?, -- only on `back` items
}
-- Canonical equipment slot identifiers (lowercase), matching item-def `equipment.slot` and
-- the Inventory config's EquipSlots. `back` is the backpack slot (recomputes capacity).
InventoryTypes.EQUIP_SLOTS = { "head", "top", "pants", "shoes", "back", "quiver" }
InventoryTypes.BACK_SLOT = "back"
-- Player-attribute names that don't vary per slot.
InventoryTypes.MAX_SLOTS_ATTR = "MaxInvSlots"
InventoryTypes.CARRY_WEIGHT_ATTR = "CarryWeight"
InventoryTypes.MAX_CARRY_WEIGHT_ATTR = "MaxCarryWeight"
InventoryTypes.HOTBAR_EQUIPPED_ATTR = "HotbarEquippedSlot"
-- Per-slot attribute names. Slot indices are 1-based.
function InventoryTypes.invSlotAttr(slot: number): string
return "InvSlot_" .. slot
end
function InventoryTypes.invQtyAttr(slot: number): string
return "InvQty_" .. slot
end
function InventoryTypes.hotbarSlotAttr(slot: number): string
return "HotbarSlot" .. slot
end
-- Normalize an equip-slot identifier ("head", "Head", "HEAD") to the attribute name
-- "EquipSlot_Head", so templates and item defs can use any casing.
function InventoryTypes.equipSlotAttr(name: string): string
local lower = string.lower(name)
local titled = string.upper(string.sub(lower, 1, 1)) .. string.sub(lower, 2)
return "EquipSlot_" .. titled
end
-- True if `name` (any casing) is a recognised equipment slot.
function InventoryTypes.isEquipSlot(name: string): boolean
local lower = string.lower(name)
for _, slot in InventoryTypes.EQUIP_SLOTS do
if slot == lower then
return true
end
end
return false
end
return InventoryTypes
+122
View File
@@ -0,0 +1,122 @@
--!nonstrict
--[[
ItemData replicates item *display data* from the server to clients. SHARED.
The content registries (`SurvivorCore.Items`) are populated per Luau VM: a game registers
items in server code, so the client's copy of the registry is empty. The UI still needs each
item's name / icon / stack / equip slot / consumable flag to render. Rather than make games
register items twice, the engine serialises the display fields of every registered item into
a single replicated StringValue at start(); the client reads it back.
This keeps the engine content-free (it ships no items) and the register-from-code model intact
(register once, server-side) while the UI "just works".
-- server (engine, at start): ItemData.publish(SurvivorCore.Items.getAll())
-- client (engine UI): local def = ItemData.get("berry") -> { id, name, icon, … }
Only DISPLAY fields cross the wire never gameplay logic. The server stays authoritative for
weight/consume/equip; the client copy is for rendering only.
]]
local HttpService = game:GetService("HttpService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ItemData = {}
local HOLDER_NAME = "SurvivorCoreItemData"
export type DisplayDef = {
id: string,
name: string,
description: string?,
icon: string?,
stack: number,
category: string?,
equipSlot: string?, -- the slot this item equips into (incl. "back" for backpacks)
consumable: boolean, -- has an onConsume effect (drives the UI's "Use" affordance)
}
local function toDisplay(def: any): DisplayDef
local stack = tonumber(def.stack)
return {
id = def.id,
name = if typeof(def.name) == "string" then def.name else def.id,
description = if typeof(def.description) == "string" then def.description else nil,
icon = if typeof(def.icon) == "string" and def.icon ~= "" then def.icon else nil,
stack = if stack and stack >= 1 then math.floor(stack) else 1,
category = if typeof(def.category) == "string" then def.category else nil,
equipSlot = if def.equipment and typeof(def.equipment.slot) == "string"
then string.lower(def.equipment.slot)
elseif def.backpack then "back"
else nil,
consumable = def.onConsume ~= nil,
}
end
-- ── Server: serialise + replicate ──────────────────────────────────────────
-- Publish the display data for a list of item defs (SurvivorCore.Items.getAll()). Idempotent;
-- call again to re-publish after registering more items.
function ItemData.publish(defs: { any })
local out = {}
for _, def in defs do
if def and def.id then
table.insert(out, toDisplay(def))
end
end
local holder = ReplicatedStorage:FindFirstChild(HOLDER_NAME)
if not holder then
holder = Instance.new("StringValue")
holder.Name = HOLDER_NAME
holder.Parent = ReplicatedStorage
end
holder.Value = HttpService:JSONEncode(out)
end
-- ── Client: read + cache ────────────────────────────────────────────────────
local cache: { [string]: DisplayDef }? = nil
local watching = false
local function rebuild(): { [string]: DisplayDef }
local result: { [string]: DisplayDef } = {}
local holder = ReplicatedStorage:WaitForChild(HOLDER_NAME, 10)
if holder and holder:IsA("StringValue") and holder.Value ~= "" then
local ok, decoded = pcall(function()
return HttpService:JSONDecode(holder.Value)
end)
if ok and typeof(decoded) == "table" then
for _, d in decoded do
if typeof(d) == "table" and typeof(d.id) == "string" then
result[d.id] = d
end
end
end
if not watching then
watching = true
holder:GetPropertyChangedSignal("Value"):Connect(function()
cache = nil -- invalidate; next get() re-decodes
end)
end
end
return result
end
local function ensureCache(): { [string]: DisplayDef }
local c = cache
if not c then
c = rebuild()
cache = c
end
return c
end
-- The replicated display def for an item, or nil if unknown.
function ItemData.get(itemId: string): DisplayDef?
if typeof(itemId) ~= "string" or itemId == "" then
return nil
end
return ensureCache()[itemId]
end
return ItemData
+98
View File
@@ -0,0 +1,98 @@
--!nonstrict
--[[
UiConfig tuning for the client UI layer (the tabbed menu, the hotbar, drag-drop).
SHARED (defined on both sides so `Config.override("UI", …)` works pre-boot; only the
client reads it). Defines the "UI" Config section.
IMPORTANT: this theme styles only the ENGINE-BUILT primitives the drag ghost, the
zero-setup fallback UI, and the slot-selection highlight. The authored `SurvivalMenu` /
`SurvivalHotbar` ScreenGui templates carry their own colours/fonts (restyle them in
Studio); the binders never overwrite template styling from this config. The neutral
palette here matches the shipped HUD template so the fallback looks consistent.
Read the merged section with UiConfig.get().
]]
local Config = require(script.Parent.Parent.foundation.Config)
local UiConfig = {}
UiConfig.SECTION = "UI"
UiConfig.DEFAULTS = {
-- Keybinds are KeyCode NAMES (resolved via Enum.KeyCode[name]); override with a string.
Keybinds = {
Menu = "Tab", -- toggles the menu (defaults to the Inventory tab)
Character = "C",
Codex = "K",
Achievements = "J",
Quests = "L",
},
-- Roblox's CoreGui owns some keys — notably Tab opens the built-in player roster, which
-- swallows the keypress before any game script sees it. When the Menu keybind collides with
-- such a key, the engine disables that core element so the key reaches the menu (the same way
-- it already hides the default health bar + backpack). Set false to keep Roblox's stock
-- player list — appropriate if you rebind Menu off Tab.
ReclaimCoreKeys = true,
-- The HUD lives in the top-left, where Roblox's chat also sits. By default the client moves
-- the chat window (modern TextChatService) to the bottom-left so the two don't overlap.
-- `Horizontal`/`Vertical` are Enum.HorizontalAlignment / Enum.VerticalAlignment names.
-- Set `Reposition = false` to leave the chat wherever the player/game placed it.
Chat = {
Reposition = true,
Horizontal = "Left",
Vertical = "Bottom",
},
-- Palette mirrors assets/hud/SurvivalHud.model.json so engine-built UI is on-brand.
Theme = {
PanelColor = Color3.fromRGB(20, 23, 30),
PanelTransparency = 0.25,
SlotColor = Color3.fromRGB(28, 32, 42),
CornerRadius = 10,
StrokeColor = Color3.fromRGB(210, 220, 245),
StrokeTransparency = 0.85,
Text = Color3.fromRGB(245, 245, 245),
TextSecondary = Color3.fromRGB(210, 220, 245),
Accent = Color3.fromRGB(204, 166, 102), -- selection / active highlight
Bad = Color3.fromRGB(200, 50, 50), -- over-weight / danger
Ok = Color3.fromRGB(80, 195, 110),
Font = Enum.Font.GothamMedium,
FontBold = Enum.Font.GothamBold,
},
Inventory = {
SlotSize = 64,
SlotPadding = 6,
Columns = 6,
},
Hotbar = {
SlotSize = 58,
SlotPadding = 6,
},
-- Cursor must move this many pixels before a press becomes a drag (vs a click). TCE value.
DragThreshold = 8,
-- ScreenGui layering. The HUD ships at DisplayOrder 2 and the vignette at 1, so the menu
-- sits above both and the drag ghost above everything.
DisplayOrder = {
Menu = 5,
Hotbar = 3,
ClickOutside = 4,
DragGhost = 100,
},
}
-- Define the section once per Luau VM.
Config.defineSection(UiConfig.SECTION, UiConfig.DEFAULTS)
-- The merged (defaults + overrides) UI config table.
function UiConfig.get(): any
return Config.get(UiConfig.SECTION) or UiConfig.DEFAULTS
end
return UiConfig
+753
View File
@@ -0,0 +1,753 @@
--!nonstrict
--[[
Inventory the server-authoritative inventory data layer. SERVER-ONLY.
A slots + carry-weight model (ported from The Counter Earth): every player has a number
of inventory slots and a carry-weight limit; equipping a backpack raises BOTH. Items stack
(up to the item def's `stack`) and carry weight. All state lives in auto-replicating Player
Attributes (see InventoryTypes for the schema) — no read RemoteEvents, exactly like the
survival stats. Clients request mutations through validated write RemoteEvents.
What it owns:
• add / remove / move / swap / split across inventory slots (weight- and stack-checked).
• A 9-slot quick-use hotbar (pin / unpin / reorder / use; auto-pin on pickup).
• Equipment slots (head/top/pants/shoes/back/quiver). `back` is the backpack slot and
recomputes capacity; the rest are display + occupancy only (a future EquipEffects layer
can apply attribute modifiers via the `inventory:changed` hook).
• Consumables: using one applies its `onConsume` deltas via the stat-effects layer
(SurvivalStats.adjust) and fires the `item:use` hook.
Runtime API is surfaced on SurvivorCore.Inventory after start() (it acts on live players).
Tuning: the "Inventory" Config section. Started by SurvivorCore.start().
DEFERRED (documented seams, not built): dropping items to the world / loot bags (#19),
physical Tool instances + equip-to-swing (#1), durability, spoilage, the 2D backpack grid.
]]
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
assert(RunService:IsServer(), "SurvivorCore.Inventory is server-only — require it via SurvivorCore.start()")
local Registries = require(script.Parent.Parent.registries)
local SurvivalStats = require(script.Parent.SurvivalStats)
local InventoryConfig = require(script.Parent.Parent.shared.InventoryConfig)
local InventoryTypes = require(script.Parent.Parent.shared.InventoryTypes)
local ItemData = require(script.Parent.Parent.shared.ItemData)
local Remotes = require(script.Parent.Parent.shared.Remotes)
local Hooks = require(script.Parent.Parent.foundation.Hooks)
local Items = Registries.Items
local Inventory = {}
local started = false
-- Cached config (defaults here are a safety net; start() overwrites from the Config section).
local BASE_POCKET_SLOTS = 5
local BASE_POCKET_WEIGHT = 5
local HOTBAR_SIZE = 9
local USE_COOLDOWN = 2.0
local AUTO_HOTBAR_CATEGORIES: { [string]: boolean } = {}
-- Per-player consume cooldown (os.clock timestamps); cleared on PlayerRemoving.
local lastUse: { [Player]: number } = {}
-- ── Item-def accessors (defs are free-form on the Items registry) ──────────
local function readDef(itemId: string): any
if itemId == "" then
return nil
end
return Items.get(itemId)
end
local function stackMax(def: any): number
local s = def and tonumber(def.stack)
return if s and s >= 1 then math.floor(s) else 1
end
local function weightOf(def: any): number
local w = def and tonumber(def.weight)
return if w and w > 0 then w else 0
end
-- ── Input sanitising ──────────────────────────────────────────────────────
local function sanitizeItemId(value: any): string
if type(value) ~= "string" then
return ""
end
local s = string.sub(value, 1, 40)
if s == "" or not string.match(s, "^[%w_%-%s]+$") then
return ""
end
return s
end
local function toSlot(value: any): number
return math.floor(tonumber(value) or 0)
end
-- ── Slot helpers (mirror InventoryTypes attribute schema) ──────────────────
local function getMaxSlots(player: Player): number
return math.max(1, math.floor(tonumber(player:GetAttribute(InventoryTypes.MAX_SLOTS_ATTR)) or BASE_POCKET_SLOTS))
end
local function getSlotItemId(player: Player, n: number): string
return sanitizeItemId(player:GetAttribute(InventoryTypes.invSlotAttr(n)) or "")
end
local function getSlotQty(player: Player, n: number): number
return math.max(0, math.floor(tonumber(player:GetAttribute(InventoryTypes.invQtyAttr(n))) or 0))
end
local function setSlot(player: Player, n: number, itemId: string, qty: number)
if qty <= 0 then
player:SetAttribute(InventoryTypes.invSlotAttr(n), nil)
player:SetAttribute(InventoryTypes.invQtyAttr(n), nil)
else
player:SetAttribute(InventoryTypes.invSlotAttr(n), itemId)
player:SetAttribute(InventoryTypes.invQtyAttr(n), math.floor(qty + 0.5))
end
end
local function getTotalQty(player: Player, itemId: string): number
local total = 0
for n = 1, getMaxSlots(player) do
if getSlotItemId(player, n) == itemId then
total += getSlotQty(player, n)
end
end
return total
end
-- ── Weight ─────────────────────────────────────────────────────────────────
local function computeWeight(player: Player): number
local total = 0
for n = 1, getMaxSlots(player) do
local itemId = getSlotItemId(player, n)
if itemId ~= "" then
total += weightOf(readDef(itemId)) * getSlotQty(player, n)
end
end
return total
end
local function refreshWeight(player: Player)
player:SetAttribute(InventoryTypes.CARRY_WEIGHT_ATTR, math.floor(computeWeight(player) * 100 + 0.5) / 100)
end
-- ── Backpack capacity recompute ─────────────────────────────────────────────
local function recalcBackpackCapacity(player: Player)
local equipped = player:GetAttribute(InventoryTypes.equipSlotAttr(InventoryTypes.BACK_SLOT))
if type(equipped) == "string" and equipped ~= "" then
local def = readDef(equipped)
if def and def.backpack then
player:SetAttribute(InventoryTypes.MAX_SLOTS_ATTR, BASE_POCKET_SLOTS + (tonumber(def.backpack.slots) or 0))
player:SetAttribute(
InventoryTypes.MAX_CARRY_WEIGHT_ATTR,
BASE_POCKET_WEIGHT + (tonumber(def.backpack.maxWeight) or 0)
)
return
end
end
player:SetAttribute(InventoryTypes.MAX_SLOTS_ATTR, BASE_POCKET_SLOTS)
player:SetAttribute(InventoryTypes.MAX_CARRY_WEIGHT_ATTR, BASE_POCKET_WEIGHT)
end
-- ── Hooks ────────────────────────────────────────────────────────────────────
local function fireChanged(player: Player, kind: string, extra: { [string]: any }?)
local payload: { [string]: any } = { player = player, kind = kind }
if extra then
for k, v in extra do
payload[k] = v
end
end
Hooks.run("inventory:changed", payload)
end
-- ── Add / Remove (weight-checked; partial-then-empty fill; LIFO removal) ────
local function addQty(player: Player, itemId: string, amount: number): boolean
local def = readDef(itemId)
if not def then
return false
end
amount = math.floor(amount)
if amount <= 0 then
return false
end
local maxWeight = tonumber(player:GetAttribute(InventoryTypes.MAX_CARRY_WEIGHT_ATTR)) or BASE_POCKET_WEIGHT
if computeWeight(player) + weightOf(def) * amount > maxWeight + 0.001 then
return false
end
local maxSlots = getMaxSlots(player)
local cap = stackMax(def)
local remaining = amount
-- Fill partial stacks first.
for n = 1, maxSlots do
if remaining <= 0 then
break
end
if getSlotItemId(player, n) == itemId then
local qty = getSlotQty(player, n)
local canAdd = math.min(remaining, cap - qty)
if canAdd > 0 then
setSlot(player, n, itemId, qty + canAdd)
remaining -= canAdd
end
end
end
-- Then empty slots.
for n = 1, maxSlots do
if remaining <= 0 then
break
end
if getSlotItemId(player, n) == "" then
local take = math.min(remaining, cap)
setSlot(player, n, itemId, take)
remaining -= take
end
end
refreshWeight(player)
return remaining == 0
end
local function removeQty(player: Player, itemId: string, amount: number): boolean
amount = math.floor(amount)
if amount <= 0 then
return false
end
if getTotalQty(player, itemId) < amount then
return false
end
local remaining = amount
for n = getMaxSlots(player), 1, -1 do
if remaining <= 0 then
break
end
if getSlotItemId(player, n) == itemId then
local qty = getSlotQty(player, n)
local take = math.min(remaining, qty)
setSlot(player, n, itemId, qty - take)
remaining -= take
end
end
refreshWeight(player)
return true
end
-- ── Hotbar helpers ───────────────────────────────────────────────────────────
local function findFreeHotbarSlot(player: Player): number?
for slot = 1, HOTBAR_SIZE do
local v = player:GetAttribute(InventoryTypes.hotbarSlotAttr(slot))
if type(v) ~= "string" or v == "" then
return slot
end
end
return nil
end
local function isAutoHotbarEligible(def: any): boolean
if not def then
return false
end
if def.onConsume then
return true
end
return type(def.category) == "string" and AUTO_HOTBAR_CATEGORIES[def.category] == true
end
-- Auto-pin a freshly picked-up, hotbar-eligible item to the smallest free hotbar slot, so
-- quick-use items are reachable without opening the menu (the "ground pickup → quick slot" rule).
local function tryAutoHotbar(player: Player, itemId: string)
local def = readDef(itemId)
if not isAutoHotbarEligible(def) then
return
end
for slot = 1, HOTBAR_SIZE do
if player:GetAttribute(InventoryTypes.hotbarSlotAttr(slot)) == itemId then
return -- already pinned somewhere
end
end
local freeSlot = findFreeHotbarSlot(player)
if freeSlot then
player:SetAttribute(InventoryTypes.hotbarSlotAttr(freeSlot), itemId)
end
end
-- Clear hotbar pins whose item no longer exists anywhere in inventory. Call after any removal.
local function cleanOrphanedHotbarPins(player: Player)
for slot = 1, HOTBAR_SIZE do
local pinned = tostring(player:GetAttribute(InventoryTypes.hotbarSlotAttr(slot)) or "")
if pinned ~= "" and getTotalQty(player, pinned) == 0 then
player:SetAttribute(InventoryTypes.hotbarSlotAttr(slot), nil)
if math.floor(tonumber(player:GetAttribute(InventoryTypes.HOTBAR_EQUIPPED_ATTR)) or 0) == slot then
player:SetAttribute(InventoryTypes.HOTBAR_EQUIPPED_ATTR, nil)
end
end
end
end
-- ── Consume ───────────────────────────────────────────────────────────────────
local function consume(player: Player, itemId: string, slot: number?): boolean
local def = readDef(itemId)
if not def or not def.onConsume then
return false
end
local now = os.clock()
if lastUse[player] and now - lastUse[player] < USE_COOLDOWN then
return false
end
lastUse[player] = now
-- Decrement first (authoritative) so a double-fire can't double-apply effects.
if not removeQty(player, itemId, 1) then
return false
end
-- Apply every onConsume entry through the stat-effects layer. Keys are stat names; values
-- are signed deltas (afflictions rise toward 100=bad, so feeding is e.g. Hunger = -15).
for statName, delta in def.onConsume do
if type(statName) == "string" and type(delta) == "number" then
SurvivalStats.adjust(player, statName, delta)
end
end
cleanOrphanedHotbarPins(player)
-- Every successful consume fires item:use — the seam for eat animations, sounds, or
-- modifier logic (e.g. Stats.removeModifier to stop an ongoing poison tick).
Hooks.run("item:use", { player = player, itemId = itemId, def = def, slot = slot })
fireChanged(player, "use", { itemId = itemId })
return true
end
-- ═══════════════════════════════════════════════════════════════════════════
-- Public API (surfaced on SurvivorCore.Inventory after start; acts on live players)
-- ═══════════════════════════════════════════════════════════════════════════
-- Add `amount` of an item; weight-checked. Returns true only if ALL were placed. Auto-pins
-- hotbar-eligible items. This is the pickup path (gather grants, demo pickups, etc.).
function Inventory.add(player: Player, itemId: string, amount: number?): boolean
local id = sanitizeItemId(itemId)
if id == "" then
return false
end
local ok = addQty(player, id, math.max(1, math.floor(tonumber(amount) or 1)))
if getTotalQty(player, id) > 0 then
tryAutoHotbar(player, id)
end
fireChanged(player, "add", { itemId = id })
return ok
end
-- Remove `amount` of an item (LIFO across slots). Returns false if the player has fewer.
function Inventory.remove(player: Player, itemId: string, amount: number?): boolean
local id = sanitizeItemId(itemId)
if id == "" then
return false
end
local ok = removeQty(player, id, math.max(1, math.floor(tonumber(amount) or 1)))
if ok then
cleanOrphanedHotbarPins(player)
fireChanged(player, "remove", { itemId = id })
end
return ok
end
function Inventory.getQty(player: Player, itemId: string): number
return getTotalQty(player, sanitizeItemId(itemId))
end
function Inventory.has(player: Player, itemId: string, amount: number?): boolean
return getTotalQty(player, sanitizeItemId(itemId)) >= math.max(1, math.floor(tonumber(amount) or 1))
end
-- A read-only snapshot of the occupied slots (for tools / tests / custom UIs).
function Inventory.getSlots(player: Player): { { slot: number, itemId: string, qty: number } }
local out = {}
for n = 1, getMaxSlots(player) do
local id = getSlotItemId(player, n)
if id ~= "" then
table.insert(out, { slot = n, itemId = id, qty = getSlotQty(player, n) })
end
end
return out
end
-- Move/merge/swap two inventory slots (the drag-and-drop primitive). Same item → merge up to
-- the stack cap (overflow stays in the source); different (or one empty) → swap.
function Inventory.move(player: Player, fromSlot: number, destSlot: number): boolean
local maxSlots = getMaxSlots(player)
local a, b = toSlot(fromSlot), toSlot(destSlot)
if a < 1 or a > maxSlots or b < 1 or b > maxSlots or a == b then
return false
end
local itemA, qtyA = getSlotItemId(player, a), getSlotQty(player, a)
local itemB, qtyB = getSlotItemId(player, b), getSlotQty(player, b)
if itemA ~= "" and itemA == itemB then
local cap = stackMax(readDef(itemA))
local transfer = math.min(qtyA, cap - qtyB)
if transfer > 0 then
setSlot(player, b, itemB, qtyB + transfer)
setSlot(player, a, itemA, qtyA - transfer)
end
else
setSlot(player, a, itemB, qtyB)
setSlot(player, b, itemA, qtyA)
end
refreshWeight(player)
fireChanged(player, "move")
return true
end
-- Documented alias (the issue lists both names; one implementation).
Inventory.swap = Inventory.move
-- Split `qty` off a slot into the first free slot. `1 <= qty < slotQty`; needs a free slot.
function Inventory.split(player: Player, slot: number, qty: number): boolean
local maxSlots = getMaxSlots(player)
local s = toSlot(slot)
if s < 1 or s > maxSlots then
return false
end
local itemId = getSlotItemId(player, s)
if itemId == "" then
return false
end
local slotQty = getSlotQty(player, s)
local splitQty = toSlot(qty)
if splitQty < 1 or splitQty >= slotQty then
return false
end
local freeSlot: number? = nil
for n = 1, maxSlots do
if n ~= s and getSlotItemId(player, n) == "" then
freeSlot = n
break
end
end
if not freeSlot then
return false
end
setSlot(player, s, itemId, slotQty - splitQty)
setSlot(player, freeSlot, itemId, splitQty)
fireChanged(player, "split")
return true
end
-- Equip the item in inventory slot `invSlot` into its equipment slot. The item dictates its
-- slot (via def.equipment.slot, or `back` for a def with a backpack table); an explicit
-- equipSlotName must match. `back` recomputes capacity.
function Inventory.equip(player: Player, invSlot: number, equipSlotName: string?): boolean
local maxSlots = getMaxSlots(player)
local s = toSlot(invSlot)
if s < 1 or s > maxSlots then
return false
end
local itemId = getSlotItemId(player, s)
if itemId == "" then
return false
end
local def = readDef(itemId)
if not def then
return false
end
-- Resolve the slot the item declares.
local resolved: string? = nil
if def.equipment and type(def.equipment.slot) == "string" then
resolved = string.lower(def.equipment.slot)
elseif def.backpack then
resolved = InventoryTypes.BACK_SLOT
end
if not resolved or not InventoryTypes.isEquipSlot(resolved) then
return false
end
if equipSlotName ~= nil and string.lower(equipSlotName) ~= resolved then
return false -- can't force an item into a slot it doesn't declare
end
if resolved == InventoryTypes.BACK_SLOT and not def.backpack then
return false
end
local attr = InventoryTypes.equipSlotAttr(resolved)
local current = player:GetAttribute(attr)
if type(current) == "string" and current ~= "" then
return false -- slot occupied
end
setSlot(player, s, "", 0)
player:SetAttribute(attr, itemId)
if resolved == InventoryTypes.BACK_SLOT then
recalcBackpackCapacity(player)
end
refreshWeight(player)
-- TODO (EquipEffects): a future layer subscribes to inventory:changed{kind="equip"} and
-- applies an item's attribute modifiers (armor → defense, etc.) via Stats.addModifier.
fireChanged(player, "equip", { equipSlot = resolved, itemId = itemId })
return true
end
-- Return an equipped item to inventory. `back` enforces the unequip rules. Returns
-- (false, reason) on rejection so the caller can surface a message.
function Inventory.unequip(player: Player, equipSlotName: string): (boolean, string?)
if type(equipSlotName) ~= "string" or not InventoryTypes.isEquipSlot(equipSlotName) then
return false, "Unknown equipment slot"
end
local slotName = string.lower(equipSlotName)
local attr = InventoryTypes.equipSlotAttr(slotName)
local equipped = player:GetAttribute(attr)
if type(equipped) ~= "string" or equipped == "" then
return false, "Nothing equipped"
end
if slotName == InventoryTypes.BACK_SLOT then
local def = readDef(equipped)
local bpSlots = (def and def.backpack and tonumber(def.backpack.slots)) or 0
-- The backpack-granted slots must be empty (they vanish when capacity shrinks).
for n = BASE_POCKET_SLOTS + 1, BASE_POCKET_SLOTS + bpSlots do
if getSlotItemId(player, n) ~= "" then
return false, "Empty backpack slots first"
end
end
-- A pocket slot must be free to hold the satchel itself.
local hasFree = false
for n = 1, BASE_POCKET_SLOTS do
if getSlotItemId(player, n) == "" then
hasFree = true
break
end
end
if not hasFree then
return false, "No free inventory slot"
end
player:SetAttribute(attr, nil)
recalcBackpackCapacity(player)
addQty(player, equipped, 1)
else
local hasFree = false
for n = 1, getMaxSlots(player) do
if getSlotItemId(player, n) == "" then
hasFree = true
break
end
end
if not hasFree then
return false, "No free inventory slot"
end
player:SetAttribute(attr, nil)
addQty(player, equipped, 1)
end
refreshWeight(player)
fireChanged(player, "unequip", { equipSlot = slotName, itemId = equipped })
return true
end
-- Pin (itemId present, must exist in inventory) or unpin (nil) a hotbar slot.
function Inventory.setHotbar(player: Player, hotbarSlot: number, itemId: string?): boolean
local slot = toSlot(hotbarSlot)
if slot < 1 or slot > HOTBAR_SIZE then
return false
end
local attr = InventoryTypes.hotbarSlotAttr(slot)
if itemId == nil then
-- Unpin.
if tostring(player:GetAttribute(attr) or "") == "" then
return false
end
player:SetAttribute(attr, nil)
if math.floor(tonumber(player:GetAttribute(InventoryTypes.HOTBAR_EQUIPPED_ATTR)) or 0) == slot then
player:SetAttribute(InventoryTypes.HOTBAR_EQUIPPED_ATTR, nil)
end
fireChanged(player, "hotbar")
return true
end
-- Pin: no category gate — an explicit drag to the hotbar is always allowed.
local id = sanitizeItemId(itemId)
if id == "" or not readDef(id) or getTotalQty(player, id) < 1 then
return false
end
player:SetAttribute(attr, id)
fireChanged(player, "hotbar")
return true
end
-- Reorder two hotbar slots, keeping the active-slot pointer accurate.
function Inventory.swapHotbar(player: Player, slotA: number, slotB: number): boolean
local a, b = toSlot(slotA), toSlot(slotB)
if a < 1 or a > HOTBAR_SIZE or b < 1 or b > HOTBAR_SIZE or a == b then
return false
end
local attrA, attrB = InventoryTypes.hotbarSlotAttr(a), InventoryTypes.hotbarSlotAttr(b)
local itemA = tostring(player:GetAttribute(attrA) or "")
local itemB = tostring(player:GetAttribute(attrB) or "")
player:SetAttribute(attrA, itemB ~= "" and itemB or nil)
player:SetAttribute(attrB, itemA ~= "" and itemA or nil)
local equipped = math.floor(tonumber(player:GetAttribute(InventoryTypes.HOTBAR_EQUIPPED_ATTR)) or 0)
if equipped == a then
player:SetAttribute(InventoryTypes.HOTBAR_EQUIPPED_ATTR, b)
elseif equipped == b then
player:SetAttribute(InventoryTypes.HOTBAR_EQUIPPED_ATTR, a)
end
fireChanged(player, "hotbar")
return true
end
-- Use an item. Pass a hotbar slot index (sets the active slot + uses that pin) or an itemId.
function Inventory.useSlot(player: Player, hotbarSlotOrItemId: number | string): boolean
if type(hotbarSlotOrItemId) == "number" then
local slot = toSlot(hotbarSlotOrItemId)
if slot < 1 or slot > HOTBAR_SIZE then
return false
end
player:SetAttribute(InventoryTypes.HOTBAR_EQUIPPED_ATTR, slot)
local itemId = tostring(player:GetAttribute(InventoryTypes.hotbarSlotAttr(slot)) or "")
if itemId == "" then
return false
end
return consume(player, sanitizeItemId(itemId), nil)
end
return consume(player, sanitizeItemId(hotbarSlotOrItemId), nil)
end
-- ── Per-player lifecycle ───────────────────────────────────────────────────
local function initPlayer(player: Player)
if player:GetAttribute(InventoryTypes.MAX_SLOTS_ATTR) == nil then
player:SetAttribute(InventoryTypes.MAX_SLOTS_ATTR, BASE_POCKET_SLOTS)
end
if player:GetAttribute(InventoryTypes.MAX_CARRY_WEIGHT_ATTR) == nil then
player:SetAttribute(InventoryTypes.MAX_CARRY_WEIGHT_ATTR, BASE_POCKET_WEIGHT)
end
if player:GetAttribute(InventoryTypes.CARRY_WEIGHT_ATTR) == nil then
player:SetAttribute(InventoryTypes.CARRY_WEIGHT_ATTR, 0)
end
-- Recompute weight on out-of-band slot writes; recompute capacity if the backpack changes
-- (future-proofs a DataStore restore that sets EquipSlot_Back after init).
player.AttributeChanged:Connect(function(attrName)
if string.sub(attrName, 1, 8) == "InvSlot_" or string.sub(attrName, 1, 7) == "InvQty_" then
refreshWeight(player)
elseif attrName == InventoryTypes.equipSlotAttr(InventoryTypes.BACK_SLOT) then
recalcBackpackCapacity(player)
refreshWeight(player)
end
end)
task.defer(function()
if player.Parent then
recalcBackpackCapacity(player)
refreshWeight(player)
end
end)
end
-- Gather grant: when a Gatherable node is fully harvested, grant its Yield of ItemId. Using
-- the hook (not editing Gatherable) avoids a require cycle and lets any system observe gathers.
-- Granting on depletion matches the documented attribute semantics ("Yield = per full harvest");
-- a game wanting per-hit yield can subscribe to "gather:hit" itself.
local function onGatherDepleted(ctx: any)
if not ctx or not ctx.player or not ctx.values then
return
end
local itemId = sanitizeItemId(ctx.values.ItemId)
if itemId == "" or not readDef(itemId) then
return -- engine ships no items; an unregistered/placeholder ItemId is silently skipped
end
Inventory.add(ctx.player, itemId, math.max(1, math.floor(tonumber(ctx.values.Yield) or 1)))
end
-- ── Remote handlers (validated client → server requests) ────────────────────
local function wireRemotes()
Remotes.event("InventorySwapSlots").OnServerEvent:Connect(function(player, slotA, slotB)
Inventory.move(player, slotA, slotB)
end)
Remotes.event("InventorySplitStack").OnServerEvent:Connect(function(player, slot, qty)
Inventory.split(player, slot, qty)
end)
Remotes.event("InventoryUseItem").OnServerEvent:Connect(function(player, invSlot)
local maxSlots = getMaxSlots(player)
local s = toSlot(invSlot)
if s < 1 or s > maxSlots then
return
end
consume(player, getSlotItemId(player, s), s)
end)
Remotes.event("InventoryEquip").OnServerEvent:Connect(function(player, invSlot, equipSlotName)
Inventory.equip(player, invSlot, type(equipSlotName) == "string" and equipSlotName or nil)
end)
Remotes.event("InventoryUnequip").OnServerEvent:Connect(function(player, equipSlotName)
if type(equipSlotName) == "string" then
Inventory.unequip(player, equipSlotName)
end
end)
Remotes.event("InventorySetHotbar").OnServerEvent:Connect(function(player, hotbarSlot, itemId)
Inventory.setHotbar(player, hotbarSlot, type(itemId) == "string" and itemId or nil)
end)
Remotes.event("InventorySwapHotbar").OnServerEvent:Connect(function(player, slotA, slotB)
Inventory.swapHotbar(player, slotA, slotB)
end)
Remotes.event("InventoryUseHotbar").OnServerEvent:Connect(function(player, hotbarSlot)
Inventory.useSlot(player, toSlot(hotbarSlot))
end)
end
function Inventory.start(_options: { [string]: any }?)
assert(not started, "Inventory.start() called twice")
started = true
local cfg = InventoryConfig.get()
BASE_POCKET_SLOTS = math.max(1, math.floor(tonumber(cfg.BasePocketSlots) or 5))
BASE_POCKET_WEIGHT = math.max(0, tonumber(cfg.BasePocketWeight) or 5)
HOTBAR_SIZE = math.max(1, math.floor(tonumber(cfg.HotbarSize) or 9))
USE_COOLDOWN = math.max(0, tonumber(cfg.UseCooldownSeconds) or 2.0)
AUTO_HOTBAR_CATEGORIES = {}
for _, category in cfg.AutoHotbarCategories or {} do
if type(category) == "string" then
AUTO_HOTBAR_CATEGORIES[category] = true
end
end
-- Replicate item display data (name/icon/stack/…) so client UI can render items the game
-- registered server-side. Games should register items before SurvivorCore.start().
ItemData.publish(Items.getAll())
for _, player in Players:GetPlayers() do
initPlayer(player)
end
Players.PlayerAdded:Connect(initPlayer)
Players.PlayerRemoving:Connect(function(player)
lastUse[player] = nil
end)
Hooks.on("gather:depleted", onGatherDepleted)
wireRemotes()
end
return Inventory
+9
View File
@@ -119,6 +119,15 @@ function SurvivalStats.installHud()
hudTemplate:Clone().Parent = StarterGui
end
-- The inventory UI rides the same install path: the tabbed menu + the bottom hotbar
-- ScreenGui templates, cloned into StarterGui if the consumer hasn't supplied their own.
for _, uiName in { "SurvivalMenu", "SurvivalHotbar" } do
local uiTemplate = templates:FindFirstChild(uiName)
if uiTemplate and not StarterGui:FindFirstChild(uiName) then
uiTemplate:Clone().Parent = StarterGui
end
end
-- The client boot loader rides StarterPlayerScripts (copied to each joiner).
local loaderTemplate = templates:FindFirstChild(LOADER_NAME)
if loaderTemplate then