mirror of
https://github.com/TemujinCalidius/SurvivorCore.git
synced 2026-08-14 09:02:29 +00:00
@@ -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`.
|
||||
|
||||
## 0.3.0 — 2026-06-23
|
||||
|
||||
### 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
@@ -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
@@ -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
@@ -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
|
||||
@@ -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 ~48–64 px) — keep the family consistent so
|
||||
stat icons and item icons read as one set.
|
||||
|
||||
- **Form:** a single clear glyph, centered, **filling ~70–80% 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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
+46
-1
@@ -33,9 +33,17 @@ 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"
|
||||
SurvivorCore.VERSION = "0.3.0"
|
||||
|
||||
-- Foundation
|
||||
SurvivorCore.Config = Config
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "temujincalidius/survivorcore"
|
||||
description = "Batteries-included, creator-extensible survival game framework for Roblox."
|
||||
version = "0.2.1"
|
||||
version = "0.3.0"
|
||||
license = "MIT"
|
||||
authors = ["Samuel Lison"]
|
||||
registry = "https://github.com/UpliftGames/wally-index"
|
||||
|
||||
Reference in New Issue
Block a user