From 5ab855fa1bad0cb8401a347465c7654c9e6d3e98 Mon Sep 17 00:00:00 2001 From: Samuel Lison Date: Tue, 23 Jun 2026 17:06:54 +1000 Subject: [PATCH 1/2] feat(ui): inventory, hotbar & tabbed menu UI (#7, #9, #3) Server-authoritative slots+weight inventory, a 9-slot hotbar (keys 1-9), equipment slots, and a restyleable tabbed menu (functional Inventory + Character Sheet; Codex/Achievements/Quests scaffolded), built the engine's way: authored ScreenGui templates + attribute-discovering binders, with a zero-setup fallback. - Drag-and-drop is driven by a per-frame cursor poll (immune to the grid ScrollingFrame that swallows InputChanged) with GUI-inset-corrected drop hit-testing so releases land on the actual slot. - Menu key defaults to Tab; the engine frees it by disabling the CoreGui player roster (ReclaimCoreKeys) and moves the chat to bottom-left so it clears the top-left HUD (Chat config). Toggle ignores focused TextBoxes. - Consumables apply onConsume via the stat-effects layer + fire item:use; gather yields flow into the inventory; pickups auto-pin to the hotbar. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 26 + assets/ui/SurvivalHotbar.model.json | 2228 ++++++++++ assets/ui/SurvivalMenu.model.json | 4085 ++++++++++++++++++ default.project.json | 2 + demo.project.json | 4 +- demo/server/Boot.server.luau | 119 +- demo/server/InventoryTestStation.server.luau | 74 + docs/design-language.md | 8 +- docs/extending.md | 24 + docs/inventory.md | 208 + src/client/CharacterSheet.luau | 114 + src/client/DragDrop.luau | 210 + src/client/Hotbar.luau | 238 + src/client/InventoryUi.luau | 274 ++ src/client/PanelManager.luau | 472 ++ src/client/SlotGrid.luau | 202 + src/client/UiFallback.luau | 651 +++ src/components/Gatherable.luau | 6 +- src/init.luau | 45 + src/shared/InventoryConfig.luau | 46 + src/shared/InventoryTypes.luau | 76 + src/shared/ItemData.luau | 122 + src/shared/UiConfig.luau | 98 + src/systems/Inventory.luau | 753 ++++ src/systems/SurvivalStats.luau | 9 + 25 files changed, 10078 insertions(+), 16 deletions(-) create mode 100644 assets/ui/SurvivalHotbar.model.json create mode 100644 assets/ui/SurvivalMenu.model.json create mode 100644 demo/server/InventoryTestStation.server.luau create mode 100644 docs/inventory.md create mode 100644 src/client/CharacterSheet.luau create mode 100644 src/client/DragDrop.luau create mode 100644 src/client/Hotbar.luau create mode 100644 src/client/InventoryUi.luau create mode 100644 src/client/PanelManager.luau create mode 100644 src/client/SlotGrid.luau create mode 100644 src/client/UiFallback.luau create mode 100644 src/shared/InventoryConfig.luau create mode 100644 src/shared/InventoryTypes.luau create mode 100644 src/shared/ItemData.luau create mode 100644 src/shared/UiConfig.luau create mode 100644 src/systems/Inventory.luau diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a0fe54..6a8c9af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,32 @@ All notable changes to SurvivorCore are recorded here. The format follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). At release time, `## Unreleased` is promoted to the new version and `main` is tagged `vX.Y.Z`. +## Unreleased + +### Added +- **Inventory, hotbar & tabbed menu UI** (#7, #9, #3) — a server-authoritative inventory with a + **slots + carry-weight** model, a 9-slot quick-use **hotbar** (keys 1-9), **equipment** slots + (head/top/pants/shoes/back/quiver), and a restyleable **tabbed menu** (functional Inventory + + Character Sheet; Codex/Achievements/Quests scaffolded). Built the engine's way — authored + ScreenGui **templates** (`SurvivalMenu`, `SurvivalHotbar`) driven by attribute-discovering + **binders**, so world creators restyle everything in Studio with zero code (and a zero-setup + fallback guarantees the UI always appears). Items **stack** and have **weight**; equipping a + **backpack** raises both slot count and weight limit. **Consumables** apply their `onConsume` + effects through the stat-effects layer (e.g. food lowers Hunger) and fire an `item:use` hook; + every change fires `inventory:changed`. Full **drag-and-drop** (move/merge/swap, pin to hotbar), + pickups **auto-assign** to the smallest free hotbar slot, and gathering a node now grants its + yield straight into the inventory. New public API: `SurvivorCore.Inventory.*` + (add/remove/move/split/equip/unequip/setHotbar/swapHotbar/useSlot/…) and + `SurvivorCore.UI.registerPanel/open/close/toggle` for code-added tabs. Item **display data** + (name/icon/stack/…) replicates to clients automatically, so games register items once + (server-side) and the UI just works; item icons resolve via the `ItemIcons` Assets category or an + inline `icon` field. Tunable via the new `Inventory` and `UI` Config sections. The engine still + ships **zero items** — the demo registers a sample set and seeds a starter inventory. Opening the + menu defaults to **Tab** — the engine frees it by disabling Roblox's player roster (which otherwise + swallows the key), and moves the chat window to the bottom-left so it clears the top-left HUD; both + are opt-out via the `UI` Config section (`ReclaimCoreKeys`, `Chat`). See + [docs/inventory.md](docs/inventory.md). + ## 0.2.1 — 2026-06-22 ### Fixed diff --git a/assets/ui/SurvivalHotbar.model.json b/assets/ui/SurvivalHotbar.model.json new file mode 100644 index 0000000..d36d7aa --- /dev/null +++ b/assets/ui/SurvivalHotbar.model.json @@ -0,0 +1,2228 @@ +{ + "className": "ScreenGui", + "properties": { + "ResetOnSpawn": false, + "IgnoreGuiInset": false, + "ZIndexBehavior": "Sibling", + "DisplayOrder": 3 + }, + "children": [ + { + "className": "Frame", + "name": "Root", + "properties": { + "AnchorPoint": { + "Vector2": [ + 0.5, + 1 + ] + }, + "Position": { + "UDim2": [ + [ + 0.5, + 0 + ], + [ + 1, + -12 + ] + ] + }, + "Size": { + "UDim2": [ + [ + 0, + 0 + ], + [ + 0, + 70 + ] + ] + }, + "AutomaticSize": "X", + "BackgroundTransparency": 1, + "BorderSizePixel": 0, + "Active": true + }, + "children": [ + { + "className": "UIListLayout", + "properties": { + "FillDirection": "Horizontal", + "Padding": { + "UDim": [ + 0, + 6 + ] + }, + "SortOrder": "LayoutOrder", + "HorizontalAlignment": "Center", + "VerticalAlignment": "Center" + } + }, + { + "className": "ImageButton", + "name": "Slot1", + "properties": { + "Size": { + "UDim2": [ + [ + 0, + 58 + ], + [ + 0, + 58 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.1098, + 0.1255, + 0.1647 + ] + }, + "BackgroundTransparency": 0.25, + "BorderSizePixel": 0, + "LayoutOrder": 1, + "AutoButtonColor": false, + "Image": "" + }, + "attributes": { + "HotbarSlot": 1 + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "UIStroke", + "properties": { + "Color": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Transparency": 0.8, + "Thickness": 1 + } + }, + { + "className": "ImageLabel", + "name": "Icon", + "properties": { + "Size": { + "UDim2": [ + [ + 0.78, + 0 + ], + [ + 0.78, + 0 + ] + ] + }, + "Position": { + "UDim2": [ + [ + 0.11, + 0 + ], + [ + 0.11, + 0 + ] + ] + }, + "BackgroundTransparency": 1, + "ScaleType": "Fit", + "Image": "", + "Visible": false, + "ZIndex": 2 + } + }, + { + "className": "TextLabel", + "name": "Key", + "properties": { + "BackgroundTransparency": 1, + "Text": "1", + "TextColor3": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Font": "GothamMedium", + "TextSize": 11, + "BorderSizePixel": 0, + "Position": { + "UDim2": [ + [ + 0, + 3 + ], + [ + 0, + 2 + ] + ] + }, + "Size": { + "UDim2": [ + [ + 0, + 14 + ], + [ + 0, + 14 + ] + ] + }, + "TextXAlignment": "Left", + "TextYAlignment": "Top", + "ZIndex": 3 + } + }, + { + "className": "TextLabel", + "name": "Count", + "properties": { + "BackgroundTransparency": 1, + "Text": "", + "TextColor3": { + "Color3": [ + 0.9608, + 0.9608, + 0.9608 + ] + }, + "Font": "GothamBold", + "TextSize": 12, + "BorderSizePixel": 0, + "AnchorPoint": { + "Vector2": [ + 1, + 1 + ] + }, + "Position": { + "UDim2": [ + [ + 1, + -3 + ], + [ + 1, + -2 + ] + ] + }, + "Size": { + "UDim2": [ + [ + 0, + 28 + ], + [ + 0, + 14 + ] + ] + }, + "TextXAlignment": "Right", + "TextYAlignment": "Bottom", + "ZIndex": 3 + } + }, + { + "className": "Frame", + "name": "Active", + "properties": { + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 1, + 0 + ] + ] + }, + "BackgroundTransparency": 1, + "BorderSizePixel": 0, + "Visible": false, + "ZIndex": 2 + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "UIStroke", + "properties": { + "Color": { + "Color3": [ + 0.8, + 0.651, + 0.4 + ] + }, + "Transparency": 0.0, + "Thickness": 2 + } + } + ] + } + ] + }, + { + "className": "ImageButton", + "name": "Slot2", + "properties": { + "Size": { + "UDim2": [ + [ + 0, + 58 + ], + [ + 0, + 58 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.1098, + 0.1255, + 0.1647 + ] + }, + "BackgroundTransparency": 0.25, + "BorderSizePixel": 0, + "LayoutOrder": 2, + "AutoButtonColor": false, + "Image": "" + }, + "attributes": { + "HotbarSlot": 2 + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "UIStroke", + "properties": { + "Color": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Transparency": 0.8, + "Thickness": 1 + } + }, + { + "className": "ImageLabel", + "name": "Icon", + "properties": { + "Size": { + "UDim2": [ + [ + 0.78, + 0 + ], + [ + 0.78, + 0 + ] + ] + }, + "Position": { + "UDim2": [ + [ + 0.11, + 0 + ], + [ + 0.11, + 0 + ] + ] + }, + "BackgroundTransparency": 1, + "ScaleType": "Fit", + "Image": "", + "Visible": false, + "ZIndex": 2 + } + }, + { + "className": "TextLabel", + "name": "Key", + "properties": { + "BackgroundTransparency": 1, + "Text": "2", + "TextColor3": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Font": "GothamMedium", + "TextSize": 11, + "BorderSizePixel": 0, + "Position": { + "UDim2": [ + [ + 0, + 3 + ], + [ + 0, + 2 + ] + ] + }, + "Size": { + "UDim2": [ + [ + 0, + 14 + ], + [ + 0, + 14 + ] + ] + }, + "TextXAlignment": "Left", + "TextYAlignment": "Top", + "ZIndex": 3 + } + }, + { + "className": "TextLabel", + "name": "Count", + "properties": { + "BackgroundTransparency": 1, + "Text": "", + "TextColor3": { + "Color3": [ + 0.9608, + 0.9608, + 0.9608 + ] + }, + "Font": "GothamBold", + "TextSize": 12, + "BorderSizePixel": 0, + "AnchorPoint": { + "Vector2": [ + 1, + 1 + ] + }, + "Position": { + "UDim2": [ + [ + 1, + -3 + ], + [ + 1, + -2 + ] + ] + }, + "Size": { + "UDim2": [ + [ + 0, + 28 + ], + [ + 0, + 14 + ] + ] + }, + "TextXAlignment": "Right", + "TextYAlignment": "Bottom", + "ZIndex": 3 + } + }, + { + "className": "Frame", + "name": "Active", + "properties": { + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 1, + 0 + ] + ] + }, + "BackgroundTransparency": 1, + "BorderSizePixel": 0, + "Visible": false, + "ZIndex": 2 + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "UIStroke", + "properties": { + "Color": { + "Color3": [ + 0.8, + 0.651, + 0.4 + ] + }, + "Transparency": 0.0, + "Thickness": 2 + } + } + ] + } + ] + }, + { + "className": "ImageButton", + "name": "Slot3", + "properties": { + "Size": { + "UDim2": [ + [ + 0, + 58 + ], + [ + 0, + 58 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.1098, + 0.1255, + 0.1647 + ] + }, + "BackgroundTransparency": 0.25, + "BorderSizePixel": 0, + "LayoutOrder": 3, + "AutoButtonColor": false, + "Image": "" + }, + "attributes": { + "HotbarSlot": 3 + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "UIStroke", + "properties": { + "Color": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Transparency": 0.8, + "Thickness": 1 + } + }, + { + "className": "ImageLabel", + "name": "Icon", + "properties": { + "Size": { + "UDim2": [ + [ + 0.78, + 0 + ], + [ + 0.78, + 0 + ] + ] + }, + "Position": { + "UDim2": [ + [ + 0.11, + 0 + ], + [ + 0.11, + 0 + ] + ] + }, + "BackgroundTransparency": 1, + "ScaleType": "Fit", + "Image": "", + "Visible": false, + "ZIndex": 2 + } + }, + { + "className": "TextLabel", + "name": "Key", + "properties": { + "BackgroundTransparency": 1, + "Text": "3", + "TextColor3": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Font": "GothamMedium", + "TextSize": 11, + "BorderSizePixel": 0, + "Position": { + "UDim2": [ + [ + 0, + 3 + ], + [ + 0, + 2 + ] + ] + }, + "Size": { + "UDim2": [ + [ + 0, + 14 + ], + [ + 0, + 14 + ] + ] + }, + "TextXAlignment": "Left", + "TextYAlignment": "Top", + "ZIndex": 3 + } + }, + { + "className": "TextLabel", + "name": "Count", + "properties": { + "BackgroundTransparency": 1, + "Text": "", + "TextColor3": { + "Color3": [ + 0.9608, + 0.9608, + 0.9608 + ] + }, + "Font": "GothamBold", + "TextSize": 12, + "BorderSizePixel": 0, + "AnchorPoint": { + "Vector2": [ + 1, + 1 + ] + }, + "Position": { + "UDim2": [ + [ + 1, + -3 + ], + [ + 1, + -2 + ] + ] + }, + "Size": { + "UDim2": [ + [ + 0, + 28 + ], + [ + 0, + 14 + ] + ] + }, + "TextXAlignment": "Right", + "TextYAlignment": "Bottom", + "ZIndex": 3 + } + }, + { + "className": "Frame", + "name": "Active", + "properties": { + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 1, + 0 + ] + ] + }, + "BackgroundTransparency": 1, + "BorderSizePixel": 0, + "Visible": false, + "ZIndex": 2 + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "UIStroke", + "properties": { + "Color": { + "Color3": [ + 0.8, + 0.651, + 0.4 + ] + }, + "Transparency": 0.0, + "Thickness": 2 + } + } + ] + } + ] + }, + { + "className": "ImageButton", + "name": "Slot4", + "properties": { + "Size": { + "UDim2": [ + [ + 0, + 58 + ], + [ + 0, + 58 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.1098, + 0.1255, + 0.1647 + ] + }, + "BackgroundTransparency": 0.25, + "BorderSizePixel": 0, + "LayoutOrder": 4, + "AutoButtonColor": false, + "Image": "" + }, + "attributes": { + "HotbarSlot": 4 + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "UIStroke", + "properties": { + "Color": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Transparency": 0.8, + "Thickness": 1 + } + }, + { + "className": "ImageLabel", + "name": "Icon", + "properties": { + "Size": { + "UDim2": [ + [ + 0.78, + 0 + ], + [ + 0.78, + 0 + ] + ] + }, + "Position": { + "UDim2": [ + [ + 0.11, + 0 + ], + [ + 0.11, + 0 + ] + ] + }, + "BackgroundTransparency": 1, + "ScaleType": "Fit", + "Image": "", + "Visible": false, + "ZIndex": 2 + } + }, + { + "className": "TextLabel", + "name": "Key", + "properties": { + "BackgroundTransparency": 1, + "Text": "4", + "TextColor3": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Font": "GothamMedium", + "TextSize": 11, + "BorderSizePixel": 0, + "Position": { + "UDim2": [ + [ + 0, + 3 + ], + [ + 0, + 2 + ] + ] + }, + "Size": { + "UDim2": [ + [ + 0, + 14 + ], + [ + 0, + 14 + ] + ] + }, + "TextXAlignment": "Left", + "TextYAlignment": "Top", + "ZIndex": 3 + } + }, + { + "className": "TextLabel", + "name": "Count", + "properties": { + "BackgroundTransparency": 1, + "Text": "", + "TextColor3": { + "Color3": [ + 0.9608, + 0.9608, + 0.9608 + ] + }, + "Font": "GothamBold", + "TextSize": 12, + "BorderSizePixel": 0, + "AnchorPoint": { + "Vector2": [ + 1, + 1 + ] + }, + "Position": { + "UDim2": [ + [ + 1, + -3 + ], + [ + 1, + -2 + ] + ] + }, + "Size": { + "UDim2": [ + [ + 0, + 28 + ], + [ + 0, + 14 + ] + ] + }, + "TextXAlignment": "Right", + "TextYAlignment": "Bottom", + "ZIndex": 3 + } + }, + { + "className": "Frame", + "name": "Active", + "properties": { + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 1, + 0 + ] + ] + }, + "BackgroundTransparency": 1, + "BorderSizePixel": 0, + "Visible": false, + "ZIndex": 2 + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "UIStroke", + "properties": { + "Color": { + "Color3": [ + 0.8, + 0.651, + 0.4 + ] + }, + "Transparency": 0.0, + "Thickness": 2 + } + } + ] + } + ] + }, + { + "className": "ImageButton", + "name": "Slot5", + "properties": { + "Size": { + "UDim2": [ + [ + 0, + 58 + ], + [ + 0, + 58 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.1098, + 0.1255, + 0.1647 + ] + }, + "BackgroundTransparency": 0.25, + "BorderSizePixel": 0, + "LayoutOrder": 5, + "AutoButtonColor": false, + "Image": "" + }, + "attributes": { + "HotbarSlot": 5 + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "UIStroke", + "properties": { + "Color": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Transparency": 0.8, + "Thickness": 1 + } + }, + { + "className": "ImageLabel", + "name": "Icon", + "properties": { + "Size": { + "UDim2": [ + [ + 0.78, + 0 + ], + [ + 0.78, + 0 + ] + ] + }, + "Position": { + "UDim2": [ + [ + 0.11, + 0 + ], + [ + 0.11, + 0 + ] + ] + }, + "BackgroundTransparency": 1, + "ScaleType": "Fit", + "Image": "", + "Visible": false, + "ZIndex": 2 + } + }, + { + "className": "TextLabel", + "name": "Key", + "properties": { + "BackgroundTransparency": 1, + "Text": "5", + "TextColor3": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Font": "GothamMedium", + "TextSize": 11, + "BorderSizePixel": 0, + "Position": { + "UDim2": [ + [ + 0, + 3 + ], + [ + 0, + 2 + ] + ] + }, + "Size": { + "UDim2": [ + [ + 0, + 14 + ], + [ + 0, + 14 + ] + ] + }, + "TextXAlignment": "Left", + "TextYAlignment": "Top", + "ZIndex": 3 + } + }, + { + "className": "TextLabel", + "name": "Count", + "properties": { + "BackgroundTransparency": 1, + "Text": "", + "TextColor3": { + "Color3": [ + 0.9608, + 0.9608, + 0.9608 + ] + }, + "Font": "GothamBold", + "TextSize": 12, + "BorderSizePixel": 0, + "AnchorPoint": { + "Vector2": [ + 1, + 1 + ] + }, + "Position": { + "UDim2": [ + [ + 1, + -3 + ], + [ + 1, + -2 + ] + ] + }, + "Size": { + "UDim2": [ + [ + 0, + 28 + ], + [ + 0, + 14 + ] + ] + }, + "TextXAlignment": "Right", + "TextYAlignment": "Bottom", + "ZIndex": 3 + } + }, + { + "className": "Frame", + "name": "Active", + "properties": { + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 1, + 0 + ] + ] + }, + "BackgroundTransparency": 1, + "BorderSizePixel": 0, + "Visible": false, + "ZIndex": 2 + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "UIStroke", + "properties": { + "Color": { + "Color3": [ + 0.8, + 0.651, + 0.4 + ] + }, + "Transparency": 0.0, + "Thickness": 2 + } + } + ] + } + ] + }, + { + "className": "ImageButton", + "name": "Slot6", + "properties": { + "Size": { + "UDim2": [ + [ + 0, + 58 + ], + [ + 0, + 58 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.1098, + 0.1255, + 0.1647 + ] + }, + "BackgroundTransparency": 0.25, + "BorderSizePixel": 0, + "LayoutOrder": 6, + "AutoButtonColor": false, + "Image": "" + }, + "attributes": { + "HotbarSlot": 6 + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "UIStroke", + "properties": { + "Color": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Transparency": 0.8, + "Thickness": 1 + } + }, + { + "className": "ImageLabel", + "name": "Icon", + "properties": { + "Size": { + "UDim2": [ + [ + 0.78, + 0 + ], + [ + 0.78, + 0 + ] + ] + }, + "Position": { + "UDim2": [ + [ + 0.11, + 0 + ], + [ + 0.11, + 0 + ] + ] + }, + "BackgroundTransparency": 1, + "ScaleType": "Fit", + "Image": "", + "Visible": false, + "ZIndex": 2 + } + }, + { + "className": "TextLabel", + "name": "Key", + "properties": { + "BackgroundTransparency": 1, + "Text": "6", + "TextColor3": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Font": "GothamMedium", + "TextSize": 11, + "BorderSizePixel": 0, + "Position": { + "UDim2": [ + [ + 0, + 3 + ], + [ + 0, + 2 + ] + ] + }, + "Size": { + "UDim2": [ + [ + 0, + 14 + ], + [ + 0, + 14 + ] + ] + }, + "TextXAlignment": "Left", + "TextYAlignment": "Top", + "ZIndex": 3 + } + }, + { + "className": "TextLabel", + "name": "Count", + "properties": { + "BackgroundTransparency": 1, + "Text": "", + "TextColor3": { + "Color3": [ + 0.9608, + 0.9608, + 0.9608 + ] + }, + "Font": "GothamBold", + "TextSize": 12, + "BorderSizePixel": 0, + "AnchorPoint": { + "Vector2": [ + 1, + 1 + ] + }, + "Position": { + "UDim2": [ + [ + 1, + -3 + ], + [ + 1, + -2 + ] + ] + }, + "Size": { + "UDim2": [ + [ + 0, + 28 + ], + [ + 0, + 14 + ] + ] + }, + "TextXAlignment": "Right", + "TextYAlignment": "Bottom", + "ZIndex": 3 + } + }, + { + "className": "Frame", + "name": "Active", + "properties": { + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 1, + 0 + ] + ] + }, + "BackgroundTransparency": 1, + "BorderSizePixel": 0, + "Visible": false, + "ZIndex": 2 + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "UIStroke", + "properties": { + "Color": { + "Color3": [ + 0.8, + 0.651, + 0.4 + ] + }, + "Transparency": 0.0, + "Thickness": 2 + } + } + ] + } + ] + }, + { + "className": "ImageButton", + "name": "Slot7", + "properties": { + "Size": { + "UDim2": [ + [ + 0, + 58 + ], + [ + 0, + 58 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.1098, + 0.1255, + 0.1647 + ] + }, + "BackgroundTransparency": 0.25, + "BorderSizePixel": 0, + "LayoutOrder": 7, + "AutoButtonColor": false, + "Image": "" + }, + "attributes": { + "HotbarSlot": 7 + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "UIStroke", + "properties": { + "Color": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Transparency": 0.8, + "Thickness": 1 + } + }, + { + "className": "ImageLabel", + "name": "Icon", + "properties": { + "Size": { + "UDim2": [ + [ + 0.78, + 0 + ], + [ + 0.78, + 0 + ] + ] + }, + "Position": { + "UDim2": [ + [ + 0.11, + 0 + ], + [ + 0.11, + 0 + ] + ] + }, + "BackgroundTransparency": 1, + "ScaleType": "Fit", + "Image": "", + "Visible": false, + "ZIndex": 2 + } + }, + { + "className": "TextLabel", + "name": "Key", + "properties": { + "BackgroundTransparency": 1, + "Text": "7", + "TextColor3": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Font": "GothamMedium", + "TextSize": 11, + "BorderSizePixel": 0, + "Position": { + "UDim2": [ + [ + 0, + 3 + ], + [ + 0, + 2 + ] + ] + }, + "Size": { + "UDim2": [ + [ + 0, + 14 + ], + [ + 0, + 14 + ] + ] + }, + "TextXAlignment": "Left", + "TextYAlignment": "Top", + "ZIndex": 3 + } + }, + { + "className": "TextLabel", + "name": "Count", + "properties": { + "BackgroundTransparency": 1, + "Text": "", + "TextColor3": { + "Color3": [ + 0.9608, + 0.9608, + 0.9608 + ] + }, + "Font": "GothamBold", + "TextSize": 12, + "BorderSizePixel": 0, + "AnchorPoint": { + "Vector2": [ + 1, + 1 + ] + }, + "Position": { + "UDim2": [ + [ + 1, + -3 + ], + [ + 1, + -2 + ] + ] + }, + "Size": { + "UDim2": [ + [ + 0, + 28 + ], + [ + 0, + 14 + ] + ] + }, + "TextXAlignment": "Right", + "TextYAlignment": "Bottom", + "ZIndex": 3 + } + }, + { + "className": "Frame", + "name": "Active", + "properties": { + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 1, + 0 + ] + ] + }, + "BackgroundTransparency": 1, + "BorderSizePixel": 0, + "Visible": false, + "ZIndex": 2 + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "UIStroke", + "properties": { + "Color": { + "Color3": [ + 0.8, + 0.651, + 0.4 + ] + }, + "Transparency": 0.0, + "Thickness": 2 + } + } + ] + } + ] + }, + { + "className": "ImageButton", + "name": "Slot8", + "properties": { + "Size": { + "UDim2": [ + [ + 0, + 58 + ], + [ + 0, + 58 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.1098, + 0.1255, + 0.1647 + ] + }, + "BackgroundTransparency": 0.25, + "BorderSizePixel": 0, + "LayoutOrder": 8, + "AutoButtonColor": false, + "Image": "" + }, + "attributes": { + "HotbarSlot": 8 + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "UIStroke", + "properties": { + "Color": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Transparency": 0.8, + "Thickness": 1 + } + }, + { + "className": "ImageLabel", + "name": "Icon", + "properties": { + "Size": { + "UDim2": [ + [ + 0.78, + 0 + ], + [ + 0.78, + 0 + ] + ] + }, + "Position": { + "UDim2": [ + [ + 0.11, + 0 + ], + [ + 0.11, + 0 + ] + ] + }, + "BackgroundTransparency": 1, + "ScaleType": "Fit", + "Image": "", + "Visible": false, + "ZIndex": 2 + } + }, + { + "className": "TextLabel", + "name": "Key", + "properties": { + "BackgroundTransparency": 1, + "Text": "8", + "TextColor3": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Font": "GothamMedium", + "TextSize": 11, + "BorderSizePixel": 0, + "Position": { + "UDim2": [ + [ + 0, + 3 + ], + [ + 0, + 2 + ] + ] + }, + "Size": { + "UDim2": [ + [ + 0, + 14 + ], + [ + 0, + 14 + ] + ] + }, + "TextXAlignment": "Left", + "TextYAlignment": "Top", + "ZIndex": 3 + } + }, + { + "className": "TextLabel", + "name": "Count", + "properties": { + "BackgroundTransparency": 1, + "Text": "", + "TextColor3": { + "Color3": [ + 0.9608, + 0.9608, + 0.9608 + ] + }, + "Font": "GothamBold", + "TextSize": 12, + "BorderSizePixel": 0, + "AnchorPoint": { + "Vector2": [ + 1, + 1 + ] + }, + "Position": { + "UDim2": [ + [ + 1, + -3 + ], + [ + 1, + -2 + ] + ] + }, + "Size": { + "UDim2": [ + [ + 0, + 28 + ], + [ + 0, + 14 + ] + ] + }, + "TextXAlignment": "Right", + "TextYAlignment": "Bottom", + "ZIndex": 3 + } + }, + { + "className": "Frame", + "name": "Active", + "properties": { + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 1, + 0 + ] + ] + }, + "BackgroundTransparency": 1, + "BorderSizePixel": 0, + "Visible": false, + "ZIndex": 2 + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "UIStroke", + "properties": { + "Color": { + "Color3": [ + 0.8, + 0.651, + 0.4 + ] + }, + "Transparency": 0.0, + "Thickness": 2 + } + } + ] + } + ] + }, + { + "className": "ImageButton", + "name": "Slot9", + "properties": { + "Size": { + "UDim2": [ + [ + 0, + 58 + ], + [ + 0, + 58 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.1098, + 0.1255, + 0.1647 + ] + }, + "BackgroundTransparency": 0.25, + "BorderSizePixel": 0, + "LayoutOrder": 9, + "AutoButtonColor": false, + "Image": "" + }, + "attributes": { + "HotbarSlot": 9 + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "UIStroke", + "properties": { + "Color": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Transparency": 0.8, + "Thickness": 1 + } + }, + { + "className": "ImageLabel", + "name": "Icon", + "properties": { + "Size": { + "UDim2": [ + [ + 0.78, + 0 + ], + [ + 0.78, + 0 + ] + ] + }, + "Position": { + "UDim2": [ + [ + 0.11, + 0 + ], + [ + 0.11, + 0 + ] + ] + }, + "BackgroundTransparency": 1, + "ScaleType": "Fit", + "Image": "", + "Visible": false, + "ZIndex": 2 + } + }, + { + "className": "TextLabel", + "name": "Key", + "properties": { + "BackgroundTransparency": 1, + "Text": "9", + "TextColor3": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Font": "GothamMedium", + "TextSize": 11, + "BorderSizePixel": 0, + "Position": { + "UDim2": [ + [ + 0, + 3 + ], + [ + 0, + 2 + ] + ] + }, + "Size": { + "UDim2": [ + [ + 0, + 14 + ], + [ + 0, + 14 + ] + ] + }, + "TextXAlignment": "Left", + "TextYAlignment": "Top", + "ZIndex": 3 + } + }, + { + "className": "TextLabel", + "name": "Count", + "properties": { + "BackgroundTransparency": 1, + "Text": "", + "TextColor3": { + "Color3": [ + 0.9608, + 0.9608, + 0.9608 + ] + }, + "Font": "GothamBold", + "TextSize": 12, + "BorderSizePixel": 0, + "AnchorPoint": { + "Vector2": [ + 1, + 1 + ] + }, + "Position": { + "UDim2": [ + [ + 1, + -3 + ], + [ + 1, + -2 + ] + ] + }, + "Size": { + "UDim2": [ + [ + 0, + 28 + ], + [ + 0, + 14 + ] + ] + }, + "TextXAlignment": "Right", + "TextYAlignment": "Bottom", + "ZIndex": 3 + } + }, + { + "className": "Frame", + "name": "Active", + "properties": { + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 1, + 0 + ] + ] + }, + "BackgroundTransparency": 1, + "BorderSizePixel": 0, + "Visible": false, + "ZIndex": 2 + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "UIStroke", + "properties": { + "Color": { + "Color3": [ + 0.8, + 0.651, + 0.4 + ] + }, + "Transparency": 0.0, + "Thickness": 2 + } + } + ] + } + ] + } + ] + } + ] +} diff --git a/assets/ui/SurvivalMenu.model.json b/assets/ui/SurvivalMenu.model.json new file mode 100644 index 0000000..00274d4 --- /dev/null +++ b/assets/ui/SurvivalMenu.model.json @@ -0,0 +1,4085 @@ +{ + "className": "ScreenGui", + "properties": { + "ResetOnSpawn": false, + "IgnoreGuiInset": false, + "ZIndexBehavior": "Sibling", + "DisplayOrder": 5, + "Enabled": false + }, + "children": [ + { + "className": "Frame", + "name": "Root", + "properties": { + "AnchorPoint": { + "Vector2": [ + 0.5, + 0.5 + ] + }, + "Position": { + "UDim2": [ + [ + 0.5, + 0 + ], + [ + 0.5, + 0 + ] + ] + }, + "Size": { + "UDim2": [ + [ + 0, + 640 + ], + [ + 0, + 420 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.0784, + 0.0902, + 0.1176 + ] + }, + "BackgroundTransparency": 0.25, + "BorderSizePixel": 0 + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 10 + ] + } + } + }, + { + "className": "UIStroke", + "properties": { + "Color": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Transparency": 0.85, + "Thickness": 1 + } + }, + { + "className": "UIPadding", + "properties": { + "PaddingTop": { + "UDim": [ + 0, + 12 + ] + }, + "PaddingBottom": { + "UDim": [ + 0, + 12 + ] + }, + "PaddingLeft": { + "UDim": [ + 0, + 12 + ] + }, + "PaddingRight": { + "UDim": [ + 0, + 12 + ] + } + } + }, + { + "className": "TextButton", + "name": "Close", + "properties": { + "AnchorPoint": { + "Vector2": [ + 1, + 0 + ] + }, + "Position": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 0, + 0 + ] + ] + }, + "Size": { + "UDim2": [ + [ + 0, + 26 + ], + [ + 0, + 26 + ] + ] + }, + "BackgroundTransparency": 1, + "Text": "\u2715", + "TextColor3": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Font": "GothamBold", + "TextSize": 16, + "ZIndex": 5 + }, + "attributes": { + "MenuClose": true + } + }, + { + "className": "Frame", + "name": "TabBar", + "properties": { + "Position": { + "UDim2": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ] + }, + "Size": { + "UDim2": [ + [ + 1, + -34 + ], + [ + 0, + 30 + ] + ] + }, + "BackgroundTransparency": 1, + "BorderSizePixel": 0 + }, + "attributes": { + "TabBar": true + }, + "children": [ + { + "className": "UIListLayout", + "properties": { + "FillDirection": "Horizontal", + "Padding": { + "UDim": [ + 0, + 6 + ] + }, + "SortOrder": "LayoutOrder", + "HorizontalAlignment": "Left", + "VerticalAlignment": "Center" + } + }, + { + "className": "TextButton", + "name": "Tab_inventory", + "properties": { + "Size": { + "UDim2": [ + [ + 0, + 104 + ], + [ + 1, + 0 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.1098, + 0.1255, + 0.1647 + ] + }, + "BackgroundTransparency": 0.4, + "BorderSizePixel": 0, + "AutoButtonColor": true, + "Text": "Inventory", + "TextColor3": { + "Color3": [ + 0.9608, + 0.9608, + 0.9608 + ] + }, + "Font": "GothamMedium", + "TextSize": 14, + "LayoutOrder": 1 + }, + "attributes": { + "InventoryTab": "inventory" + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "Frame", + "name": "Selected", + "properties": { + "AnchorPoint": { + "Vector2": [ + 0.5, + 1 + ] + }, + "Position": { + "UDim2": [ + [ + 0.5, + 0 + ], + [ + 1, + -2 + ] + ] + }, + "Size": { + "UDim2": [ + [ + 0.8, + 0 + ], + [ + 0, + 3 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.8, + 0.651, + 0.4 + ] + }, + "BorderSizePixel": 0, + "Visible": false + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 2 + ] + } + } + } + ] + } + ] + }, + { + "className": "TextButton", + "name": "Tab_character", + "properties": { + "Size": { + "UDim2": [ + [ + 0, + 104 + ], + [ + 1, + 0 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.1098, + 0.1255, + 0.1647 + ] + }, + "BackgroundTransparency": 0.4, + "BorderSizePixel": 0, + "AutoButtonColor": true, + "Text": "Character", + "TextColor3": { + "Color3": [ + 0.9608, + 0.9608, + 0.9608 + ] + }, + "Font": "GothamMedium", + "TextSize": 14, + "LayoutOrder": 2 + }, + "attributes": { + "InventoryTab": "character" + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "Frame", + "name": "Selected", + "properties": { + "AnchorPoint": { + "Vector2": [ + 0.5, + 1 + ] + }, + "Position": { + "UDim2": [ + [ + 0.5, + 0 + ], + [ + 1, + -2 + ] + ] + }, + "Size": { + "UDim2": [ + [ + 0.8, + 0 + ], + [ + 0, + 3 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.8, + 0.651, + 0.4 + ] + }, + "BorderSizePixel": 0, + "Visible": false + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 2 + ] + } + } + } + ] + } + ] + }, + { + "className": "TextButton", + "name": "Tab_codex", + "properties": { + "Size": { + "UDim2": [ + [ + 0, + 104 + ], + [ + 1, + 0 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.1098, + 0.1255, + 0.1647 + ] + }, + "BackgroundTransparency": 0.4, + "BorderSizePixel": 0, + "AutoButtonColor": true, + "Text": "Codex", + "TextColor3": { + "Color3": [ + 0.9608, + 0.9608, + 0.9608 + ] + }, + "Font": "GothamMedium", + "TextSize": 14, + "LayoutOrder": 3 + }, + "attributes": { + "InventoryTab": "codex" + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "Frame", + "name": "Selected", + "properties": { + "AnchorPoint": { + "Vector2": [ + 0.5, + 1 + ] + }, + "Position": { + "UDim2": [ + [ + 0.5, + 0 + ], + [ + 1, + -2 + ] + ] + }, + "Size": { + "UDim2": [ + [ + 0.8, + 0 + ], + [ + 0, + 3 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.8, + 0.651, + 0.4 + ] + }, + "BorderSizePixel": 0, + "Visible": false + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 2 + ] + } + } + } + ] + } + ] + }, + { + "className": "TextButton", + "name": "Tab_achievements", + "properties": { + "Size": { + "UDim2": [ + [ + 0, + 104 + ], + [ + 1, + 0 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.1098, + 0.1255, + 0.1647 + ] + }, + "BackgroundTransparency": 0.4, + "BorderSizePixel": 0, + "AutoButtonColor": true, + "Text": "Achievements", + "TextColor3": { + "Color3": [ + 0.9608, + 0.9608, + 0.9608 + ] + }, + "Font": "GothamMedium", + "TextSize": 14, + "LayoutOrder": 4 + }, + "attributes": { + "InventoryTab": "achievements" + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "Frame", + "name": "Selected", + "properties": { + "AnchorPoint": { + "Vector2": [ + 0.5, + 1 + ] + }, + "Position": { + "UDim2": [ + [ + 0.5, + 0 + ], + [ + 1, + -2 + ] + ] + }, + "Size": { + "UDim2": [ + [ + 0.8, + 0 + ], + [ + 0, + 3 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.8, + 0.651, + 0.4 + ] + }, + "BorderSizePixel": 0, + "Visible": false + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 2 + ] + } + } + } + ] + } + ] + }, + { + "className": "TextButton", + "name": "Tab_quests", + "properties": { + "Size": { + "UDim2": [ + [ + 0, + 104 + ], + [ + 1, + 0 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.1098, + 0.1255, + 0.1647 + ] + }, + "BackgroundTransparency": 0.4, + "BorderSizePixel": 0, + "AutoButtonColor": true, + "Text": "Quests", + "TextColor3": { + "Color3": [ + 0.9608, + 0.9608, + 0.9608 + ] + }, + "Font": "GothamMedium", + "TextSize": 14, + "LayoutOrder": 5 + }, + "attributes": { + "InventoryTab": "quests" + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "Frame", + "name": "Selected", + "properties": { + "AnchorPoint": { + "Vector2": [ + 0.5, + 1 + ] + }, + "Position": { + "UDim2": [ + [ + 0.5, + 0 + ], + [ + 1, + -2 + ] + ] + }, + "Size": { + "UDim2": [ + [ + 0.8, + 0 + ], + [ + 0, + 3 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.8, + 0.651, + 0.4 + ] + }, + "BorderSizePixel": 0, + "Visible": false + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 2 + ] + } + } + } + ] + } + ] + } + ] + }, + { + "className": "Frame", + "name": "Content", + "properties": { + "Position": { + "UDim2": [ + [ + 0, + 0 + ], + [ + 0, + 40 + ] + ] + }, + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 1, + -40 + ] + ] + }, + "BackgroundTransparency": 1, + "BorderSizePixel": 0 + }, + "attributes": { + "TabContentHost": true + }, + "children": [ + { + "className": "Frame", + "name": "Content_inventory", + "properties": { + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 1, + 0 + ] + ] + }, + "BackgroundTransparency": 1, + "BorderSizePixel": 0, + "Visible": true + }, + "attributes": { + "TabContent": "inventory" + }, + "children": [ + { + "className": "Frame", + "name": "GridArea", + "properties": { + "Position": { + "UDim2": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ] + }, + "Size": { + "UDim2": [ + [ + 0.62, + -6 + ], + [ + 1, + 0 + ] + ] + }, + "BackgroundTransparency": 1, + "BorderSizePixel": 0 + }, + "children": [ + { + "className": "ScrollingFrame", + "name": "Grid", + "properties": { + "Position": { + "UDim2": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ] + }, + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 1, + -52 + ] + ] + }, + "BackgroundTransparency": 1, + "BorderSizePixel": 0, + "ScrollBarThickness": 6, + "CanvasSize": { + "UDim2": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ] + }, + "AutomaticCanvasSize": "Y", + "ScrollingDirection": "Y" + }, + "attributes": { + "InventoryGrid": true + }, + "children": [ + { + "className": "UIGridLayout", + "properties": { + "CellSize": { + "UDim2": [ + [ + 0, + 64 + ], + [ + 0, + 64 + ] + ] + }, + "CellPadding": { + "UDim2": [ + [ + 0, + 6 + ], + [ + 0, + 6 + ] + ] + }, + "SortOrder": "LayoutOrder", + "HorizontalAlignment": "Left" + } + }, + { + "className": "ImageButton", + "name": "SlotTemplate", + "properties": { + "Size": { + "UDim2": [ + [ + 0, + 64 + ], + [ + 0, + 64 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.1098, + 0.1255, + 0.1647 + ] + }, + "BackgroundTransparency": 0.25, + "AutoButtonColor": false, + "BorderSizePixel": 0, + "Image": "", + "Visible": false + }, + "attributes": { + "SlotTemplate": true + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "UIStroke", + "properties": { + "Color": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Transparency": 0.82, + "Thickness": 1 + } + }, + { + "className": "ImageLabel", + "name": "Icon", + "properties": { + "Size": { + "UDim2": [ + [ + 0.78, + 0 + ], + [ + 0.78, + 0 + ] + ] + }, + "Position": { + "UDim2": [ + [ + 0.11, + 0 + ], + [ + 0.11, + 0 + ] + ] + }, + "BackgroundTransparency": 1, + "ScaleType": "Fit", + "Image": "", + "Visible": false, + "ZIndex": 2 + } + }, + { + "className": "TextLabel", + "name": "Count", + "properties": { + "BackgroundTransparency": 1, + "Text": "", + "TextColor3": { + "Color3": [ + 0.9608, + 0.9608, + 0.9608 + ] + }, + "Font": "GothamBold", + "TextSize": 12, + "BorderSizePixel": 0, + "Size": { + "UDim2": [ + [ + 0, + 30 + ], + [ + 0, + 16 + ] + ] + }, + "Position": { + "UDim2": [ + [ + 1, + -32 + ], + [ + 1, + -18 + ] + ] + }, + "TextXAlignment": "Right", + "TextYAlignment": "Bottom", + "ZIndex": 3 + } + }, + { + "className": "Frame", + "name": "Selected", + "properties": { + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 1, + 0 + ] + ] + }, + "BackgroundTransparency": 1, + "BorderSizePixel": 0, + "Visible": false, + "ZIndex": 2 + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "UIStroke", + "properties": { + "Color": { + "Color3": [ + 0.8, + 0.651, + 0.4 + ] + }, + "Transparency": 0.0, + "Thickness": 2 + } + } + ] + } + ] + } + ] + }, + { + "className": "Frame", + "name": "WeightReadout", + "properties": { + "Position": { + "UDim2": [ + [ + 0, + 0 + ], + [ + 1, + -48 + ] + ] + }, + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 0, + 22 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.1098, + 0.1255, + 0.1647 + ] + }, + "BackgroundTransparency": 0.3, + "BorderSizePixel": 0 + }, + "attributes": { + "WeightReadout": true + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "Frame", + "name": "Fill", + "properties": { + "Size": { + "UDim2": [ + [ + 0, + 0 + ], + [ + 1, + 0 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.3137, + 0.7647, + 0.4314 + ] + }, + "BorderSizePixel": 0 + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + } + ] + }, + { + "className": "TextLabel", + "name": "Value", + "properties": { + "BackgroundTransparency": 1, + "Text": "0 / 5 kg", + "TextColor3": { + "Color3": [ + 0.9608, + 0.9608, + 0.9608 + ] + }, + "Font": "GothamMedium", + "TextSize": 12, + "BorderSizePixel": 0, + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 1, + 0 + ] + ] + }, + "TextXAlignment": "Center", + "ZIndex": 2 + } + } + ] + }, + { + "className": "Frame", + "name": "SlotReadout", + "properties": { + "Position": { + "UDim2": [ + [ + 0, + 0 + ], + [ + 1, + -22 + ] + ] + }, + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 0, + 18 + ] + ] + }, + "BackgroundTransparency": 1, + "BorderSizePixel": 0 + }, + "attributes": { + "SlotReadout": true + }, + "children": [ + { + "className": "TextLabel", + "name": "Value", + "properties": { + "BackgroundTransparency": 1, + "Text": "0 / 5 slots", + "TextColor3": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Font": "GothamMedium", + "TextSize": 12, + "BorderSizePixel": 0, + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 1, + 0 + ] + ] + }, + "TextXAlignment": "Left" + } + } + ] + } + ] + }, + { + "className": "Frame", + "name": "Detail", + "properties": { + "AnchorPoint": { + "Vector2": [ + 1, + 0 + ] + }, + "Position": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 0, + 0 + ] + ] + }, + "Size": { + "UDim2": [ + [ + 0.38, + 0 + ], + [ + 1, + 0 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.1098, + 0.1255, + 0.1647 + ] + }, + "BackgroundTransparency": 0.3, + "BorderSizePixel": 0 + }, + "attributes": { + "ItemDetail": true + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 8 + ] + } + } + }, + { + "className": "UIStroke", + "properties": { + "Color": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Transparency": 0.85, + "Thickness": 1 + } + }, + { + "className": "UIPadding", + "properties": { + "PaddingTop": { + "UDim": [ + 0, + 10 + ] + }, + "PaddingBottom": { + "UDim": [ + 0, + 10 + ] + }, + "PaddingLeft": { + "UDim": [ + 0, + 10 + ] + }, + "PaddingRight": { + "UDim": [ + 0, + 10 + ] + } + } + }, + { + "className": "ImageLabel", + "name": "Icon", + "properties": { + "AnchorPoint": { + "Vector2": [ + 0.5, + 0 + ] + }, + "Position": { + "UDim2": [ + [ + 0.5, + 0 + ], + [ + 0, + 0 + ] + ] + }, + "Size": { + "UDim2": [ + [ + 0, + 64 + ], + [ + 0, + 64 + ] + ] + }, + "BackgroundTransparency": 1, + "ScaleType": "Fit", + "Image": "", + "Visible": false + } + }, + { + "className": "TextLabel", + "name": "Name", + "properties": { + "BackgroundTransparency": 1, + "Text": "", + "TextColor3": { + "Color3": [ + 0.9608, + 0.9608, + 0.9608 + ] + }, + "Font": "GothamBold", + "TextSize": 15, + "BorderSizePixel": 0, + "Position": { + "UDim2": [ + [ + 0, + 0 + ], + [ + 0, + 72 + ] + ] + }, + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 0, + 22 + ] + ] + }, + "TextXAlignment": "Center" + } + }, + { + "className": "TextLabel", + "name": "Description", + "properties": { + "BackgroundTransparency": 1, + "Text": "", + "TextColor3": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Font": "GothamMedium", + "TextSize": 12, + "BorderSizePixel": 0, + "Position": { + "UDim2": [ + [ + 0, + 0 + ], + [ + 0, + 98 + ] + ] + }, + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 0, + 90 + ] + ] + }, + "TextWrapped": true, + "TextXAlignment": "Left", + "TextYAlignment": "Top" + } + }, + { + "className": "Frame", + "name": "Actions", + "properties": { + "AnchorPoint": { + "Vector2": [ + 0, + 1 + ] + }, + "Position": { + "UDim2": [ + [ + 0, + 0 + ], + [ + 1, + 0 + ] + ] + }, + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 0, + 122 + ] + ] + }, + "BackgroundTransparency": 1, + "BorderSizePixel": 0 + }, + "children": [ + { + "className": "UIListLayout", + "properties": { + "FillDirection": "Vertical", + "Padding": { + "UDim": [ + 0, + 6 + ] + }, + "SortOrder": "LayoutOrder", + "HorizontalAlignment": "Center" + } + }, + { + "className": "TextButton", + "name": "Action_use", + "properties": { + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 0, + 26 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.1098, + 0.1255, + 0.1647 + ] + }, + "BackgroundTransparency": 0.2, + "BorderSizePixel": 0, + "AutoButtonColor": true, + "Text": "Use", + "TextColor3": { + "Color3": [ + 0.9608, + 0.9608, + 0.9608 + ] + }, + "Font": "GothamMedium", + "TextSize": 13, + "LayoutOrder": 1 + }, + "attributes": { + "Action": "use" + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "UIStroke", + "properties": { + "Color": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Transparency": 0.7, + "Thickness": 1 + } + } + ] + }, + { + "className": "TextButton", + "name": "Action_equip", + "properties": { + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 0, + 26 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.1098, + 0.1255, + 0.1647 + ] + }, + "BackgroundTransparency": 0.2, + "BorderSizePixel": 0, + "AutoButtonColor": true, + "Text": "Equip", + "TextColor3": { + "Color3": [ + 0.9608, + 0.9608, + 0.9608 + ] + }, + "Font": "GothamMedium", + "TextSize": 13, + "LayoutOrder": 2 + }, + "attributes": { + "Action": "equip" + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "UIStroke", + "properties": { + "Color": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Transparency": 0.7, + "Thickness": 1 + } + } + ] + }, + { + "className": "TextButton", + "name": "Action_hotbar", + "properties": { + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 0, + 26 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.1098, + 0.1255, + 0.1647 + ] + }, + "BackgroundTransparency": 0.2, + "BorderSizePixel": 0, + "AutoButtonColor": true, + "Text": "\u2192 Hotbar", + "TextColor3": { + "Color3": [ + 0.9608, + 0.9608, + 0.9608 + ] + }, + "Font": "GothamMedium", + "TextSize": 13, + "LayoutOrder": 3 + }, + "attributes": { + "Action": "hotbar" + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "UIStroke", + "properties": { + "Color": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Transparency": 0.7, + "Thickness": 1 + } + } + ] + }, + { + "className": "TextButton", + "name": "Action_split", + "properties": { + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 0, + 26 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.1098, + 0.1255, + 0.1647 + ] + }, + "BackgroundTransparency": 0.2, + "BorderSizePixel": 0, + "AutoButtonColor": true, + "Text": "Split", + "TextColor3": { + "Color3": [ + 0.9608, + 0.9608, + 0.9608 + ] + }, + "Font": "GothamMedium", + "TextSize": 13, + "LayoutOrder": 4 + }, + "attributes": { + "Action": "split" + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "UIStroke", + "properties": { + "Color": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Transparency": 0.7, + "Thickness": 1 + } + } + ] + } + ] + } + ] + } + ] + }, + { + "className": "Frame", + "name": "Content_character", + "properties": { + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 1, + 0 + ] + ] + }, + "BackgroundTransparency": 1, + "BorderSizePixel": 0, + "Visible": false + }, + "attributes": { + "TabContent": "character" + }, + "children": [ + { + "className": "Frame", + "name": "Equipment", + "properties": { + "Position": { + "UDim2": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ] + }, + "Size": { + "UDim2": [ + [ + 0.46, + -6 + ], + [ + 1, + 0 + ] + ] + }, + "BackgroundTransparency": 1, + "BorderSizePixel": 0 + }, + "children": [ + { + "className": "UIListLayout", + "properties": { + "FillDirection": "Vertical", + "Padding": { + "UDim": [ + 0, + 6 + ] + }, + "SortOrder": "LayoutOrder", + "HorizontalAlignment": "Left" + } + }, + { + "className": "Frame", + "name": "Equip_head", + "properties": { + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 0, + 44 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.1098, + 0.1255, + 0.1647 + ] + }, + "BackgroundTransparency": 0.3, + "BorderSizePixel": 0, + "LayoutOrder": 1 + }, + "attributes": { + "EquipSlot": "head" + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "UIStroke", + "properties": { + "Color": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Transparency": 0.8, + "Thickness": 1 + } + }, + { + "className": "UIPadding", + "properties": { + "PaddingTop": { + "UDim": [ + 0, + 6 + ] + }, + "PaddingBottom": { + "UDim": [ + 0, + 6 + ] + }, + "PaddingLeft": { + "UDim": [ + 0, + 6 + ] + }, + "PaddingRight": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "UIListLayout", + "properties": { + "FillDirection": "Horizontal", + "Padding": { + "UDim": [ + 0, + 8 + ] + }, + "SortOrder": "LayoutOrder", + "HorizontalAlignment": "Left", + "VerticalAlignment": "Center" + } + }, + { + "className": "ImageLabel", + "name": "Icon", + "properties": { + "Size": { + "UDim2": [ + [ + 0, + 32 + ], + [ + 0, + 32 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.0784, + 0.0902, + 0.1176 + ] + }, + "BackgroundTransparency": 0.3, + "ScaleType": "Fit", + "Image": "", + "BorderSizePixel": 0, + "LayoutOrder": 1 + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 4 + ] + } + } + } + ] + }, + { + "className": "TextLabel", + "name": "SlotLabel", + "properties": { + "BackgroundTransparency": 1, + "Text": "Head", + "TextColor3": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Font": "GothamMedium", + "TextSize": 13, + "BorderSizePixel": 0, + "Size": { + "UDim2": [ + [ + 0, + 70 + ], + [ + 1, + 0 + ] + ] + }, + "TextXAlignment": "Left", + "LayoutOrder": 2 + } + }, + { + "className": "TextLabel", + "name": "Name", + "properties": { + "BackgroundTransparency": 1, + "Text": "", + "TextColor3": { + "Color3": [ + 0.9608, + 0.9608, + 0.9608 + ] + }, + "Font": "GothamMedium", + "TextSize": 13, + "BorderSizePixel": 0, + "Size": { + "UDim2": [ + [ + 1, + -120 + ], + [ + 1, + 0 + ] + ] + }, + "TextXAlignment": "Left", + "LayoutOrder": 3 + } + } + ] + }, + { + "className": "Frame", + "name": "Equip_top", + "properties": { + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 0, + 44 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.1098, + 0.1255, + 0.1647 + ] + }, + "BackgroundTransparency": 0.3, + "BorderSizePixel": 0, + "LayoutOrder": 2 + }, + "attributes": { + "EquipSlot": "top" + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "UIStroke", + "properties": { + "Color": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Transparency": 0.8, + "Thickness": 1 + } + }, + { + "className": "UIPadding", + "properties": { + "PaddingTop": { + "UDim": [ + 0, + 6 + ] + }, + "PaddingBottom": { + "UDim": [ + 0, + 6 + ] + }, + "PaddingLeft": { + "UDim": [ + 0, + 6 + ] + }, + "PaddingRight": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "UIListLayout", + "properties": { + "FillDirection": "Horizontal", + "Padding": { + "UDim": [ + 0, + 8 + ] + }, + "SortOrder": "LayoutOrder", + "HorizontalAlignment": "Left", + "VerticalAlignment": "Center" + } + }, + { + "className": "ImageLabel", + "name": "Icon", + "properties": { + "Size": { + "UDim2": [ + [ + 0, + 32 + ], + [ + 0, + 32 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.0784, + 0.0902, + 0.1176 + ] + }, + "BackgroundTransparency": 0.3, + "ScaleType": "Fit", + "Image": "", + "BorderSizePixel": 0, + "LayoutOrder": 1 + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 4 + ] + } + } + } + ] + }, + { + "className": "TextLabel", + "name": "SlotLabel", + "properties": { + "BackgroundTransparency": 1, + "Text": "Top", + "TextColor3": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Font": "GothamMedium", + "TextSize": 13, + "BorderSizePixel": 0, + "Size": { + "UDim2": [ + [ + 0, + 70 + ], + [ + 1, + 0 + ] + ] + }, + "TextXAlignment": "Left", + "LayoutOrder": 2 + } + }, + { + "className": "TextLabel", + "name": "Name", + "properties": { + "BackgroundTransparency": 1, + "Text": "", + "TextColor3": { + "Color3": [ + 0.9608, + 0.9608, + 0.9608 + ] + }, + "Font": "GothamMedium", + "TextSize": 13, + "BorderSizePixel": 0, + "Size": { + "UDim2": [ + [ + 1, + -120 + ], + [ + 1, + 0 + ] + ] + }, + "TextXAlignment": "Left", + "LayoutOrder": 3 + } + } + ] + }, + { + "className": "Frame", + "name": "Equip_pants", + "properties": { + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 0, + 44 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.1098, + 0.1255, + 0.1647 + ] + }, + "BackgroundTransparency": 0.3, + "BorderSizePixel": 0, + "LayoutOrder": 3 + }, + "attributes": { + "EquipSlot": "pants" + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "UIStroke", + "properties": { + "Color": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Transparency": 0.8, + "Thickness": 1 + } + }, + { + "className": "UIPadding", + "properties": { + "PaddingTop": { + "UDim": [ + 0, + 6 + ] + }, + "PaddingBottom": { + "UDim": [ + 0, + 6 + ] + }, + "PaddingLeft": { + "UDim": [ + 0, + 6 + ] + }, + "PaddingRight": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "UIListLayout", + "properties": { + "FillDirection": "Horizontal", + "Padding": { + "UDim": [ + 0, + 8 + ] + }, + "SortOrder": "LayoutOrder", + "HorizontalAlignment": "Left", + "VerticalAlignment": "Center" + } + }, + { + "className": "ImageLabel", + "name": "Icon", + "properties": { + "Size": { + "UDim2": [ + [ + 0, + 32 + ], + [ + 0, + 32 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.0784, + 0.0902, + 0.1176 + ] + }, + "BackgroundTransparency": 0.3, + "ScaleType": "Fit", + "Image": "", + "BorderSizePixel": 0, + "LayoutOrder": 1 + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 4 + ] + } + } + } + ] + }, + { + "className": "TextLabel", + "name": "SlotLabel", + "properties": { + "BackgroundTransparency": 1, + "Text": "Pants", + "TextColor3": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Font": "GothamMedium", + "TextSize": 13, + "BorderSizePixel": 0, + "Size": { + "UDim2": [ + [ + 0, + 70 + ], + [ + 1, + 0 + ] + ] + }, + "TextXAlignment": "Left", + "LayoutOrder": 2 + } + }, + { + "className": "TextLabel", + "name": "Name", + "properties": { + "BackgroundTransparency": 1, + "Text": "", + "TextColor3": { + "Color3": [ + 0.9608, + 0.9608, + 0.9608 + ] + }, + "Font": "GothamMedium", + "TextSize": 13, + "BorderSizePixel": 0, + "Size": { + "UDim2": [ + [ + 1, + -120 + ], + [ + 1, + 0 + ] + ] + }, + "TextXAlignment": "Left", + "LayoutOrder": 3 + } + } + ] + }, + { + "className": "Frame", + "name": "Equip_shoes", + "properties": { + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 0, + 44 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.1098, + 0.1255, + 0.1647 + ] + }, + "BackgroundTransparency": 0.3, + "BorderSizePixel": 0, + "LayoutOrder": 4 + }, + "attributes": { + "EquipSlot": "shoes" + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "UIStroke", + "properties": { + "Color": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Transparency": 0.8, + "Thickness": 1 + } + }, + { + "className": "UIPadding", + "properties": { + "PaddingTop": { + "UDim": [ + 0, + 6 + ] + }, + "PaddingBottom": { + "UDim": [ + 0, + 6 + ] + }, + "PaddingLeft": { + "UDim": [ + 0, + 6 + ] + }, + "PaddingRight": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "UIListLayout", + "properties": { + "FillDirection": "Horizontal", + "Padding": { + "UDim": [ + 0, + 8 + ] + }, + "SortOrder": "LayoutOrder", + "HorizontalAlignment": "Left", + "VerticalAlignment": "Center" + } + }, + { + "className": "ImageLabel", + "name": "Icon", + "properties": { + "Size": { + "UDim2": [ + [ + 0, + 32 + ], + [ + 0, + 32 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.0784, + 0.0902, + 0.1176 + ] + }, + "BackgroundTransparency": 0.3, + "ScaleType": "Fit", + "Image": "", + "BorderSizePixel": 0, + "LayoutOrder": 1 + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 4 + ] + } + } + } + ] + }, + { + "className": "TextLabel", + "name": "SlotLabel", + "properties": { + "BackgroundTransparency": 1, + "Text": "Shoes", + "TextColor3": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Font": "GothamMedium", + "TextSize": 13, + "BorderSizePixel": 0, + "Size": { + "UDim2": [ + [ + 0, + 70 + ], + [ + 1, + 0 + ] + ] + }, + "TextXAlignment": "Left", + "LayoutOrder": 2 + } + }, + { + "className": "TextLabel", + "name": "Name", + "properties": { + "BackgroundTransparency": 1, + "Text": "", + "TextColor3": { + "Color3": [ + 0.9608, + 0.9608, + 0.9608 + ] + }, + "Font": "GothamMedium", + "TextSize": 13, + "BorderSizePixel": 0, + "Size": { + "UDim2": [ + [ + 1, + -120 + ], + [ + 1, + 0 + ] + ] + }, + "TextXAlignment": "Left", + "LayoutOrder": 3 + } + } + ] + }, + { + "className": "Frame", + "name": "Equip_back", + "properties": { + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 0, + 44 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.1098, + 0.1255, + 0.1647 + ] + }, + "BackgroundTransparency": 0.3, + "BorderSizePixel": 0, + "LayoutOrder": 5 + }, + "attributes": { + "EquipSlot": "back" + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "UIStroke", + "properties": { + "Color": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Transparency": 0.8, + "Thickness": 1 + } + }, + { + "className": "UIPadding", + "properties": { + "PaddingTop": { + "UDim": [ + 0, + 6 + ] + }, + "PaddingBottom": { + "UDim": [ + 0, + 6 + ] + }, + "PaddingLeft": { + "UDim": [ + 0, + 6 + ] + }, + "PaddingRight": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "UIListLayout", + "properties": { + "FillDirection": "Horizontal", + "Padding": { + "UDim": [ + 0, + 8 + ] + }, + "SortOrder": "LayoutOrder", + "HorizontalAlignment": "Left", + "VerticalAlignment": "Center" + } + }, + { + "className": "ImageLabel", + "name": "Icon", + "properties": { + "Size": { + "UDim2": [ + [ + 0, + 32 + ], + [ + 0, + 32 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.0784, + 0.0902, + 0.1176 + ] + }, + "BackgroundTransparency": 0.3, + "ScaleType": "Fit", + "Image": "", + "BorderSizePixel": 0, + "LayoutOrder": 1 + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 4 + ] + } + } + } + ] + }, + { + "className": "TextLabel", + "name": "SlotLabel", + "properties": { + "BackgroundTransparency": 1, + "Text": "Back", + "TextColor3": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Font": "GothamMedium", + "TextSize": 13, + "BorderSizePixel": 0, + "Size": { + "UDim2": [ + [ + 0, + 70 + ], + [ + 1, + 0 + ] + ] + }, + "TextXAlignment": "Left", + "LayoutOrder": 2 + } + }, + { + "className": "TextLabel", + "name": "Name", + "properties": { + "BackgroundTransparency": 1, + "Text": "", + "TextColor3": { + "Color3": [ + 0.9608, + 0.9608, + 0.9608 + ] + }, + "Font": "GothamMedium", + "TextSize": 13, + "BorderSizePixel": 0, + "Size": { + "UDim2": [ + [ + 1, + -120 + ], + [ + 1, + 0 + ] + ] + }, + "TextXAlignment": "Left", + "LayoutOrder": 3 + } + } + ] + }, + { + "className": "Frame", + "name": "Equip_quiver", + "properties": { + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 0, + 44 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.1098, + 0.1255, + 0.1647 + ] + }, + "BackgroundTransparency": 0.3, + "BorderSizePixel": 0, + "LayoutOrder": 6 + }, + "attributes": { + "EquipSlot": "quiver" + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "UIStroke", + "properties": { + "Color": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Transparency": 0.8, + "Thickness": 1 + } + }, + { + "className": "UIPadding", + "properties": { + "PaddingTop": { + "UDim": [ + 0, + 6 + ] + }, + "PaddingBottom": { + "UDim": [ + 0, + 6 + ] + }, + "PaddingLeft": { + "UDim": [ + 0, + 6 + ] + }, + "PaddingRight": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "UIListLayout", + "properties": { + "FillDirection": "Horizontal", + "Padding": { + "UDim": [ + 0, + 8 + ] + }, + "SortOrder": "LayoutOrder", + "HorizontalAlignment": "Left", + "VerticalAlignment": "Center" + } + }, + { + "className": "ImageLabel", + "name": "Icon", + "properties": { + "Size": { + "UDim2": [ + [ + 0, + 32 + ], + [ + 0, + 32 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.0784, + 0.0902, + 0.1176 + ] + }, + "BackgroundTransparency": 0.3, + "ScaleType": "Fit", + "Image": "", + "BorderSizePixel": 0, + "LayoutOrder": 1 + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 4 + ] + } + } + } + ] + }, + { + "className": "TextLabel", + "name": "SlotLabel", + "properties": { + "BackgroundTransparency": 1, + "Text": "Quiver", + "TextColor3": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Font": "GothamMedium", + "TextSize": 13, + "BorderSizePixel": 0, + "Size": { + "UDim2": [ + [ + 0, + 70 + ], + [ + 1, + 0 + ] + ] + }, + "TextXAlignment": "Left", + "LayoutOrder": 2 + } + }, + { + "className": "TextLabel", + "name": "Name", + "properties": { + "BackgroundTransparency": 1, + "Text": "", + "TextColor3": { + "Color3": [ + 0.9608, + 0.9608, + 0.9608 + ] + }, + "Font": "GothamMedium", + "TextSize": 13, + "BorderSizePixel": 0, + "Size": { + "UDim2": [ + [ + 1, + -120 + ], + [ + 1, + 0 + ] + ] + }, + "TextXAlignment": "Left", + "LayoutOrder": 3 + } + } + ] + } + ] + }, + { + "className": "Frame", + "name": "Attributes", + "properties": { + "AnchorPoint": { + "Vector2": [ + 1, + 0 + ] + }, + "Position": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 0, + 0 + ] + ] + }, + "Size": { + "UDim2": [ + [ + 0.5, + 0 + ], + [ + 1, + 0 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.1098, + 0.1255, + 0.1647 + ] + }, + "BackgroundTransparency": 0.3, + "BorderSizePixel": 0 + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 8 + ] + } + } + }, + { + "className": "UIStroke", + "properties": { + "Color": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Transparency": 0.85, + "Thickness": 1 + } + }, + { + "className": "UIPadding", + "properties": { + "PaddingTop": { + "UDim": [ + 0, + 10 + ] + }, + "PaddingBottom": { + "UDim": [ + 0, + 10 + ] + }, + "PaddingLeft": { + "UDim": [ + 0, + 10 + ] + }, + "PaddingRight": { + "UDim": [ + 0, + 10 + ] + } + } + }, + { + "className": "UIListLayout", + "properties": { + "FillDirection": "Vertical", + "Padding": { + "UDim": [ + 0, + 4 + ] + }, + "SortOrder": "LayoutOrder", + "HorizontalAlignment": "Left" + } + }, + { + "className": "TextLabel", + "name": "Header", + "properties": { + "BackgroundTransparency": 1, + "Text": "Attributes", + "TextColor3": { + "Color3": [ + 0.9608, + 0.9608, + 0.9608 + ] + }, + "Font": "GothamBold", + "TextSize": 14, + "BorderSizePixel": 0, + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 0, + 22 + ] + ] + }, + "TextXAlignment": "Left", + "LayoutOrder": 0 + } + }, + { + "className": "Frame", + "name": "Attr_Health", + "properties": { + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 0, + 24 + ] + ] + }, + "BackgroundTransparency": 1, + "BorderSizePixel": 0, + "LayoutOrder": 1 + }, + "attributes": { + "AttributeReadout": "Health" + }, + "children": [ + { + "className": "TextLabel", + "name": "Label", + "properties": { + "BackgroundTransparency": 1, + "Text": "Health", + "TextColor3": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Font": "GothamMedium", + "TextSize": 13, + "BorderSizePixel": 0, + "Size": { + "UDim2": [ + [ + 0.6, + 0 + ], + [ + 1, + 0 + ] + ] + }, + "TextXAlignment": "Left" + } + }, + { + "className": "TextLabel", + "name": "Value", + "properties": { + "BackgroundTransparency": 1, + "Text": "0", + "TextColor3": { + "Color3": [ + 0.9608, + 0.9608, + 0.9608 + ] + }, + "Font": "GothamBold", + "TextSize": 13, + "BorderSizePixel": 0, + "AnchorPoint": { + "Vector2": [ + 1, + 0 + ] + }, + "Position": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 0, + 0 + ] + ] + }, + "Size": { + "UDim2": [ + [ + 0.4, + 0 + ], + [ + 1, + 0 + ] + ] + }, + "TextXAlignment": "Right" + } + } + ] + }, + { + "className": "Frame", + "name": "Attr_Energy", + "properties": { + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 0, + 24 + ] + ] + }, + "BackgroundTransparency": 1, + "BorderSizePixel": 0, + "LayoutOrder": 2 + }, + "attributes": { + "AttributeReadout": "Energy" + }, + "children": [ + { + "className": "TextLabel", + "name": "Label", + "properties": { + "BackgroundTransparency": 1, + "Text": "Energy", + "TextColor3": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Font": "GothamMedium", + "TextSize": 13, + "BorderSizePixel": 0, + "Size": { + "UDim2": [ + [ + 0.6, + 0 + ], + [ + 1, + 0 + ] + ] + }, + "TextXAlignment": "Left" + } + }, + { + "className": "TextLabel", + "name": "Value", + "properties": { + "BackgroundTransparency": 1, + "Text": "0", + "TextColor3": { + "Color3": [ + 0.9608, + 0.9608, + 0.9608 + ] + }, + "Font": "GothamBold", + "TextSize": 13, + "BorderSizePixel": 0, + "AnchorPoint": { + "Vector2": [ + 1, + 0 + ] + }, + "Position": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 0, + 0 + ] + ] + }, + "Size": { + "UDim2": [ + [ + 0.4, + 0 + ], + [ + 1, + 0 + ] + ] + }, + "TextXAlignment": "Right" + } + } + ] + }, + { + "className": "Frame", + "name": "Attr_Hunger", + "properties": { + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 0, + 24 + ] + ] + }, + "BackgroundTransparency": 1, + "BorderSizePixel": 0, + "LayoutOrder": 3 + }, + "attributes": { + "AttributeReadout": "Hunger" + }, + "children": [ + { + "className": "TextLabel", + "name": "Label", + "properties": { + "BackgroundTransparency": 1, + "Text": "Hunger", + "TextColor3": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Font": "GothamMedium", + "TextSize": 13, + "BorderSizePixel": 0, + "Size": { + "UDim2": [ + [ + 0.6, + 0 + ], + [ + 1, + 0 + ] + ] + }, + "TextXAlignment": "Left" + } + }, + { + "className": "TextLabel", + "name": "Value", + "properties": { + "BackgroundTransparency": 1, + "Text": "0", + "TextColor3": { + "Color3": [ + 0.9608, + 0.9608, + 0.9608 + ] + }, + "Font": "GothamBold", + "TextSize": 13, + "BorderSizePixel": 0, + "AnchorPoint": { + "Vector2": [ + 1, + 0 + ] + }, + "Position": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 0, + 0 + ] + ] + }, + "Size": { + "UDim2": [ + [ + 0.4, + 0 + ], + [ + 1, + 0 + ] + ] + }, + "TextXAlignment": "Right" + } + } + ] + }, + { + "className": "Frame", + "name": "Attr_Thirst", + "properties": { + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 0, + 24 + ] + ] + }, + "BackgroundTransparency": 1, + "BorderSizePixel": 0, + "LayoutOrder": 4 + }, + "attributes": { + "AttributeReadout": "Thirst" + }, + "children": [ + { + "className": "TextLabel", + "name": "Label", + "properties": { + "BackgroundTransparency": 1, + "Text": "Thirst", + "TextColor3": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Font": "GothamMedium", + "TextSize": 13, + "BorderSizePixel": 0, + "Size": { + "UDim2": [ + [ + 0.6, + 0 + ], + [ + 1, + 0 + ] + ] + }, + "TextXAlignment": "Left" + } + }, + { + "className": "TextLabel", + "name": "Value", + "properties": { + "BackgroundTransparency": 1, + "Text": "0", + "TextColor3": { + "Color3": [ + 0.9608, + 0.9608, + 0.9608 + ] + }, + "Font": "GothamBold", + "TextSize": 13, + "BorderSizePixel": 0, + "AnchorPoint": { + "Vector2": [ + 1, + 0 + ] + }, + "Position": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 0, + 0 + ] + ] + }, + "Size": { + "UDim2": [ + [ + 0.4, + 0 + ], + [ + 1, + 0 + ] + ] + }, + "TextXAlignment": "Right" + } + } + ] + }, + { + "className": "Frame", + "name": "Attr_Fatigue", + "properties": { + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 0, + 24 + ] + ] + }, + "BackgroundTransparency": 1, + "BorderSizePixel": 0, + "LayoutOrder": 5 + }, + "attributes": { + "AttributeReadout": "Fatigue" + }, + "children": [ + { + "className": "TextLabel", + "name": "Label", + "properties": { + "BackgroundTransparency": 1, + "Text": "Fatigue", + "TextColor3": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Font": "GothamMedium", + "TextSize": 13, + "BorderSizePixel": 0, + "Size": { + "UDim2": [ + [ + 0.6, + 0 + ], + [ + 1, + 0 + ] + ] + }, + "TextXAlignment": "Left" + } + }, + { + "className": "TextLabel", + "name": "Value", + "properties": { + "BackgroundTransparency": 1, + "Text": "0", + "TextColor3": { + "Color3": [ + 0.9608, + 0.9608, + 0.9608 + ] + }, + "Font": "GothamBold", + "TextSize": 13, + "BorderSizePixel": 0, + "AnchorPoint": { + "Vector2": [ + 1, + 0 + ] + }, + "Position": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 0, + 0 + ] + ] + }, + "Size": { + "UDim2": [ + [ + 0.4, + 0 + ], + [ + 1, + 0 + ] + ] + }, + "TextXAlignment": "Right" + } + } + ] + } + ] + } + ] + }, + { + "className": "Frame", + "name": "Content_codex", + "properties": { + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 1, + 0 + ] + ] + }, + "BackgroundTransparency": 1, + "BorderSizePixel": 0, + "Visible": false + }, + "attributes": { + "TabContent": "codex" + }, + "children": [ + { + "className": "TextLabel", + "name": "Placeholder", + "properties": { + "BackgroundTransparency": 1, + "Text": "Codex \u2014 coming soon", + "TextColor3": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Font": "GothamMedium", + "TextSize": 16, + "BorderSizePixel": 0, + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 1, + 0 + ] + ] + }, + "TextXAlignment": "Center", + "TextYAlignment": "Center" + } + } + ] + }, + { + "className": "Frame", + "name": "Content_achievements", + "properties": { + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 1, + 0 + ] + ] + }, + "BackgroundTransparency": 1, + "BorderSizePixel": 0, + "Visible": false + }, + "attributes": { + "TabContent": "achievements" + }, + "children": [ + { + "className": "TextLabel", + "name": "Placeholder", + "properties": { + "BackgroundTransparency": 1, + "Text": "Achievements \u2014 coming soon", + "TextColor3": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Font": "GothamMedium", + "TextSize": 16, + "BorderSizePixel": 0, + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 1, + 0 + ] + ] + }, + "TextXAlignment": "Center", + "TextYAlignment": "Center" + } + } + ] + }, + { + "className": "Frame", + "name": "Content_quests", + "properties": { + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 1, + 0 + ] + ] + }, + "BackgroundTransparency": 1, + "BorderSizePixel": 0, + "Visible": false + }, + "attributes": { + "TabContent": "quests" + }, + "children": [ + { + "className": "TextLabel", + "name": "Placeholder", + "properties": { + "BackgroundTransparency": 1, + "Text": "Quests \u2014 coming soon", + "TextColor3": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Font": "GothamMedium", + "TextSize": 16, + "BorderSizePixel": 0, + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 1, + 0 + ] + ] + }, + "TextXAlignment": "Center", + "TextYAlignment": "Center" + } + } + ] + } + ] + } + ] + } + ] +} diff --git a/default.project.json b/default.project.json index 230e215..e5c821a 100644 --- a/default.project.json +++ b/default.project.json @@ -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" } } diff --git a/demo.project.json b/demo.project.json index 7fa96ea..8b91f94 100644 --- a/demo.project.json +++ b/demo.project.json @@ -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", diff --git a/demo/server/Boot.server.luau b/demo/server/Boot.server.luau index 70f3a1a..6ff196d 100644 --- a/demo/server/Boot.server.luau +++ b/demo/server/Boot.server.luau @@ -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) diff --git a/demo/server/InventoryTestStation.server.luau b/demo/server/InventoryTestStation.server.luau new file mode 100644 index 0000000..5093520 --- /dev/null +++ b/demo/server/InventoryTestStation.server.luau @@ -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 diff --git a/docs/design-language.md b/docs/design-language.md index e8760a5..4f60c8a 100644 --- a/docs/design-language.md +++ b/docs/design-language.md @@ -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", "", 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", "", 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). diff --git a/docs/extending.md b/docs/extending.md index 59d2d88..27f04a3 100644 --- a/docs/extending.md +++ b/docs/extending.md @@ -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 diff --git a/docs/inventory.md b/docs/inventory.md new file mode 100644 index 0000000..7bc12b4 --- /dev/null +++ b/docs/inventory.md @@ -0,0 +1,208 @@ +# Inventory, Hotbar & Menu UI + +SurvivorCore ships a complete, server-authoritative **inventory** with a **slots + carry-weight** +model, a quick-use **hotbar**, **equipment** slots, and a restyleable **tabbed menu** (Inventory + +Character Sheet, with Codex / Achievements / Quests scaffolded). Like the survival HUD, the UI is a +**template + binder**: you author/restyle the ScreenGui in Studio, and the engine drives only data +(icons, counts, fills) — never layout or colors. The engine ships **zero items**; your game registers +them, and their display data replicates to clients automatically. + +- **Data layer (server):** [src/systems/Inventory.luau](../src/systems/Inventory.luau) +- **UI (client):** `PanelManager`, `InventoryUi`, `Hotbar`, `CharacterSheet`, `DragDrop`, `SlotGrid`, + `UiFallback` under [src/client/](../src/client) +- **Templates:** [assets/ui/SurvivalMenu.model.json](../assets/ui/SurvivalMenu.model.json), + [assets/ui/SurvivalHotbar.model.json](../assets/ui/SurvivalHotbar.model.json) +- **Tuning:** the `Inventory` and `UI` Config sections. + +--- + +## The model: slots + weight + +Every player has a number of inventory **slots** and a **carry-weight** limit. Items **stack** (up to +the item's `stack`) and each unit has a **weight**. `add` fails (returns `false`) when a pickup would +exceed either limit. Equipping a **backpack** raises *both* the slot count and the weight limit. + +All state is stored as **Player Attributes**, which Roblox auto-replicates to the owning client — so +the UI needs no read RemoteEvents (the same model as the survival stats). Defaults live in the +`Inventory` Config section: + +```lua +SurvivorCore.Config.override("Inventory", { + BasePocketSlots = 5, -- slots with no backpack + BasePocketWeight = 5, -- kg with no backpack + HotbarSize = 9, -- quick-use slots (keys 1-9) + UseCooldownSeconds = 2.0, -- anti-spam on consuming + EquipSlots = { "head", "top", "pants", "shoes", "back", "quiver" }, + AutoHotbarCategories = { "tool", "weapon", "placeable", "consumable" }, +}) +``` + +--- + +## Item definitions + +The inventory reads these fields off your `Items` registry defs. All are optional except `id` +(the engine tolerates anything missing). Register items **before** `SurvivorCore.start()`: + +```lua +SurvivorCore.Items.register({ + id = "berry", + name = "Wild Berries", + description = "Tart and filling.", + stack = 20, -- max per slot (default 1) + weight = 0.05, -- per-unit carry weight (default 0) + category = "consumable", -- used for auto-hotbar; freeform otherwise + icon = "rbxassetid://…", -- display icon (see "Icons" below) + onConsume = { Hunger = -15, Thirst = -5 }, -- makes it a consumable +}) + +SurvivorCore.Items.register({ + id = "reed_satchel", + name = "Reed Satchel", + weight = 0.8, + equipment = { slot = "back" }, -- equips into the Back slot + backpack = { slots = 6, maxWeight = 10 }, -- +6 slots, +10 kg when equipped +}) + +SurvivorCore.Items.register({ + id = "straw_hat", + name = "Straw Hat", + equipment = { slot = "head" }, -- head / top / pants / shoes / back / quiver +}) +``` + +**`onConsume` keys are stat names**, passed straight to the stat-effects layer +(`Stats.adjust`). SurvivorCore afflictions *rise* toward `100 = bad`, so **feeding lowers them** +(`Hunger = -15`) and a **cure drives one to zero** (`Poison = -100`). No special tags — it's all data. + +--- + +## Server API + +Available on `SurvivorCore.Inventory` after `start()` (all act on live players, server-only): + +| Call | Effect | +|---|---| +| `add(player, itemId, n?)` → `bool` | Weight/stack-checked; fills partial stacks then empties. Auto-pins hotbar-eligible items. `true` only if **all** placed. | +| `remove(player, itemId, n?)` → `bool` | Removes across slots (newest first). `false` if the player has fewer. | +| `getQty(player, itemId)` → `number` / `has(player, itemId, n?)` → `bool` | Totals across slots. | +| `getSlots(player)` → `{ {slot,itemId,qty} }` | Read-only snapshot. | +| `move(player, from, to)` / `swap(...)` | Merge same item up to stack, else swap. | +| `split(player, slot, qty)` | Split into the first free slot. | +| `equip(player, invSlot, slot?)` / `unequip(player, slot)` → `(bool, reason?)` | Equip/unequip; `back` recomputes capacity and enforces unequip rules. | +| `setHotbar(player, slot, itemId?)` | Pin (itemId) / unpin (nil) a hotbar slot. | +| `swapHotbar(player, a, b)` | Reorder the hotbar. | +| `useSlot(player, hotbarSlotOrItemId)` | Use/consume an item. | + +```lua +SurvivorCore.Inventory.add(player, "stone_axe", 1) -- e.g. on a pickup; auto-hotbars tools +``` + +Gathering is wired for free: when a `Gatherable` is fully harvested, the engine grants its +`Yield × ItemId` into the player's inventory (via the `gather:depleted` hook). + +--- + +## Hooks + +| Hook | Payload | Fires when | +|---|---|---| +| `inventory:changed` | `{ player, kind, itemId?, equipSlot? }` | Any inventory mutation (`add`/`remove`/`move`/`use`/`equip`/…). | +| `item:use` | `{ player, itemId, def, slot? }` | **Every** successful consume — the seam for eat animations, sounds, or stopping a poison tick via `Stats.removeModifier`. | + +```lua +SurvivorCore.Hooks.on("item:use", function(ctx) + -- e.g. play a chewing sound, or clear an ongoing affliction the item cures +end) +``` + +--- + +## The UI + +The menu and hotbar are authored ScreenGui templates driven by attribute-discovering binders — +**restyle them in Studio with zero code**, exactly like the HUD. The binder finds elements by +attribute and drives only their data. + +### Open it + +Press **Tab** (configurable) to toggle the menu; **C / K / J / L** jump to the Character / Codex / +Achievements / Quests tabs. Hotbar keys **1-9** use the pinned item. Drag items between slots, onto +the hotbar to pin, and right-click a hotbar slot to unpin. + +> **Drag-to-drop is intentionally a no-op.** Dragging an item out to empty space does nothing yet — +> dropping items to the world (loot bags, drop-on-death) is the deferred world-drop system (#19). +> When that lands it registers a catch-all drop target. (Dragging is driven by a per-frame cursor +> poll, not `InputChanged`, so it works even though the inventory grid is a `ScrollingFrame` that +> would otherwise swallow the gesture.) + +> **Tab & the player roster.** Roblox's CoreGui owns Tab (it toggles the built-in player list) and +> consumes the keypress before any game script sees it — and ContextActionService can't out-rank +> CoreGui. So when the Menu keybind collides with a core key, the engine disables that core element +> to free the key (the same way it hides the default health bar and backpack). This is on by default; +> set `Config.override("UI", { ReclaimCoreKeys = false })` to keep Roblox's stock player list (e.g. if +> you rebind Menu off Tab). The toggle also never fires while a TextBox is focused. + +> **Chat placement.** The HUD lives in the top-left, where Roblox's chat sits too, so the client moves +> the chat window (modern TextChatService) to the **bottom-left** by default. Change the alignment, or +> opt out, via `Config.override("UI", { Chat = { Reposition = false } })` (run on the client before +> `startClient()`). + +### Template attribute conventions + +| Attribute (on a GuiObject) | Children the binder drives | Meaning | +|---|---|---| +| `InventoryTab` = id | optional `Selected` | A tab button; click shows the matching `TabContent`. | +| `TabContent` = id | — | The content shown when its tab is active. | +| `InventoryGrid` = true | one `SlotTemplate` child | Slots are cloned from the template to fill `MaxInvSlots`. | +| `SlotTemplate` = true | `Icon`, `Count`, `Selected` | The prototype slot (drag source + click-to-select). | +| `WeightReadout` = true | `Fill`, `Value` | Carry-weight bar + text. | +| `SlotReadout` = true | `Value` | "used / max slots". | +| `ItemDetail` = true | `Icon`, `Name`, `Description`, buttons `Action`=use/equip/hotbar/split | Selected-item detail strip. | +| `HotbarSlot` = 1..9 | `Icon`, `Count`, `Key`, `Active` | A hotbar slot. | +| `EquipSlot` = name | `Icon`, `Name` | An equipment slot (head/top/pants/shoes/back/quiver). | +| `AttributeReadout` = attr | `Value` | Shows a Player attribute (the seam for equipment-driven attributes). | + +If no template ever reaches the player, a deliberately minimal `UiFallback` builds one with the same +attributes, so the UI always works. + +### Adding your own tab + +```lua +-- client, after startClient(): +SurvivorCore.UI.registerPanel({ + id = "map", title = "Map", order = 6, + build = function(contentFrame) -- fill the (empty) tab content once + -- … build your panel UI here … + end, +}) +SurvivorCore.UI.open("map") -- open / close / toggle by id +``` + +--- + +## Icons + +Item icons resolve **per item `icon` field → the `ItemIcons` Assets category → "" (hidden)**. A fresh +engine shows clean, empty slots — never a broken-image box. Supply icons either way: + +```lua +-- inline on the def: +SurvivorCore.Items.register({ id = "berry", icon = "rbxassetid://…", … }) +-- or via the registry (handy for bulk / theming): +SurvivorCore.Assets.register("ItemIcons", "berry", "rbxassetid://…") +``` + +Because item registration happens server-side but the UI runs on the client, the engine replicates +each item's **display data** (name, icon, stack, equip slot, consumable flag) automatically at +`start()` — your game registers items once, server-side, and the UI just works. See the icon-style and +generation guidance in [design-language.md](design-language.md). + +--- + +## What's deferred + +Dropping items to the world / loot bags (#19), physical `Tool` instances + equip-to-swing (#1), +equipment **attribute modifiers** (armor → defense — a future layer on the `inventory:changed` hook), +durability, spoilage, and the 2D backpack grid are intentionally out of this slice. The seams are +marked in the code. diff --git a/src/client/CharacterSheet.luau b/src/client/CharacterSheet.luau new file mode 100644 index 0000000..945a5bf --- /dev/null +++ b/src/client/CharacterSheet.luau @@ -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` = (head/top/pants/shoes/back/quiver) → its `Icon` + `Name` from the + EquipSlot_ player attribute. Clicking an occupied slot fires InventoryUnequip. + • `AttributeReadout` = → 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 diff --git a/src/client/DragDrop.luau b/src/client/DragDrop.luau new file mode 100644 index 0000000..8ad4fc0 --- /dev/null +++ b/src/client/DragDrop.luau @@ -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 diff --git a/src/client/Hotbar.luau b/src/client/Hotbar.luau new file mode 100644 index 0000000..648539c --- /dev/null +++ b/src/client/Hotbar.luau @@ -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 diff --git a/src/client/InventoryUi.luau b/src/client/InventoryUi.luau new file mode 100644 index 0000000..f2637c6 --- /dev/null +++ b/src/client/InventoryUi.luau @@ -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 diff --git a/src/client/PanelManager.luau b/src/client/PanelManager.luau new file mode 100644 index 0000000..4259c56 --- /dev/null +++ b/src/client/PanelManager.luau @@ -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` = (e.g. "inventory"). Optional `Selected` + child shown while that tab is active. + • A tab's content: GuiObject with `TabContent` = (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 diff --git a/src/client/SlotGrid.luau b/src/client/SlotGrid.luau new file mode 100644 index 0000000..25faba1 --- /dev/null +++ b/src/client/SlotGrid.luau @@ -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 diff --git a/src/client/UiFallback.luau b/src/client/UiFallback.luau new file mode 100644 index 0000000..e298390 --- /dev/null +++ b/src/client/UiFallback.luau @@ -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 diff --git a/src/components/Gatherable.luau b/src/components/Gatherable.luau index f9935b1..9119b06 100644 --- a/src/components/Gatherable.luau +++ b/src/components/Gatherable.luau @@ -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 diff --git a/src/init.luau b/src/init.luau index 971171e..46ac05f 100644 --- a/src/init.luau +++ b/src/init.luau @@ -33,6 +33,14 @@ require(script.shared.MovementConfig) -- Config.override("Consequences", …) works any time before start(). require(script.shared.ConsequenceConfig) +-- Define the "Inventory" Config section (slots + carry-weight + hotbar + equip-slot tuning), +-- so Config.override("Inventory", …) works any time before start(). +require(script.shared.InventoryConfig) + +-- Define the "UI" Config section (menu/hotbar theme + keybinds), so +-- Config.override("UI", …) works any time before startClient(). +require(script.shared.UiConfig) + local SurvivorCore = {} SurvivorCore.VERSION = "0.2.1" @@ -92,6 +100,14 @@ function SurvivorCore.start(_options: { [string]: any }?) -- Health↔Humanoid sync and fresh-body reset on respawn. require(script.systems.SurvivalConsequences).start(_options) + -- Inventory: server-authoritative slots + carry-weight, hotbar, equipment, consumables. + -- Booted after SurvivalStats so consumables apply their effects via Stats.adjust. The + -- module table IS the public API — SurvivorCore.Inventory.add/remove/move/split/equip/ + -- unequip/setHotbar/swapHotbar/useSlot/getQty/has/getSlots (all act on live players). + local inventory = require(script.systems.Inventory) + inventory.start(_options) + SurvivorCore.Inventory = inventory + return SurvivorCore end @@ -109,6 +125,35 @@ function SurvivorCore.startClient(_options: { [string]: any }?) -- Sprint input + low-stat vignette/breathing/heartbeat feedback. require(script.client.MovementFeedback).start(_options) + -- UI layer: the tabbed menu (Inventory / Character / scaffolded Codex·Achievements·Quests) + -- and the bottom hotbar — authored ScreenGui templates driven by attribute binders, exactly + -- like the HUD. PanelManager owns the menu + keybind; SurvivorCore.UI exposes its registrable + -- panel API (registerPanel / open / close / toggle). + require(script.client.DragDrop).start(_options) + local panelManager = require(script.client.PanelManager) + panelManager.start(_options) + SurvivorCore.UI = panelManager + require(script.client.Hotbar).start(_options) + require(script.client.InventoryUi).start(_options) + require(script.client.CharacterSheet).start(_options) + + -- Zero-setup net: if no authored menu/hotbar template reached the player, build the minimal + -- fallback (the binders pick it up via DescendantAdded, like HudFallback). + task.delay(2, function() + local localPlayer = game:GetService("Players").LocalPlayer + local playerGui = localPlayer and localPlayer:FindFirstChild("PlayerGui") + if not playerGui then + return + end + local UiFallback = require(script.client.UiFallback) + if not playerGui:FindFirstChild("SurvivalMenu") then + UiFallback.buildMenu(playerGui) + end + if not playerGui:FindFirstChild("SurvivalHotbar") then + UiFallback.buildHotbar(playerGui) + end + end) + return SurvivorCore end diff --git a/src/shared/InventoryConfig.luau b/src/shared/InventoryConfig.luau new file mode 100644 index 0000000..9696534 --- /dev/null +++ b/src/shared/InventoryConfig.luau @@ -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 diff --git a/src/shared/InventoryTypes.luau b/src/shared/InventoryTypes.luau new file mode 100644 index 0000000..490182c --- /dev/null +++ b/src/shared/InventoryTypes.luau @@ -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 diff --git a/src/shared/ItemData.luau b/src/shared/ItemData.luau new file mode 100644 index 0000000..4a3a445 --- /dev/null +++ b/src/shared/ItemData.luau @@ -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 diff --git a/src/shared/UiConfig.luau b/src/shared/UiConfig.luau new file mode 100644 index 0000000..e1b3b70 --- /dev/null +++ b/src/shared/UiConfig.luau @@ -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 diff --git a/src/systems/Inventory.luau b/src/systems/Inventory.luau new file mode 100644 index 0000000..05b9b13 --- /dev/null +++ b/src/systems/Inventory.luau @@ -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 diff --git a/src/systems/SurvivalStats.luau b/src/systems/SurvivalStats.luau index d14361c..027e2df 100644 --- a/src/systems/SurvivalStats.luau +++ b/src/systems/SurvivalStats.luau @@ -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 From 1b5ef088b34a8ed19d398a68128d6c23435a57c0 Mon Sep 17 00:00:00 2001 From: Samuel Lison Date: Tue, 23 Jun 2026 17:23:17 +1000 Subject: [PATCH 2/2] fix(hud): keep a single HUD, preferring the authored one over the fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both the authored template and the built-in fallback name their ScreenGui "SurvivalHud", and the fallback fires on a 1s timer if no bars have bound yet. The dedupe ran only once at startup, so a fallback that raced the template (more likely now startClient does more before the template copies in) left two HUDs on screen — a restart usually shuffled the timing enough to hide it, until it didn't. Tag the fallback, make the dedupe continuous (re-runs whenever another HUD appears) and always prefer the authored HUD, and don't build the fallback when a HUD ScreenGui already exists. Also link the inventory/hotbar demo video in docs/inventory.md. Co-Authored-By: Claude Opus 4.8 --- docs/inventory.md | 2 ++ src/client/Hud.luau | 42 ++++++++++++++++++++++++++++++------- src/client/HudFallback.luau | 5 +++++ 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/docs/inventory.md b/docs/inventory.md index 7bc12b4..5f13c8b 100644 --- a/docs/inventory.md +++ b/docs/inventory.md @@ -7,6 +7,8 @@ Character Sheet, with Codex / Achievements / Quests scaffolded). Like the surviv (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) diff --git a/src/client/Hud.luau b/src/client/Hud.luau index f8023c3..c3c0462 100644 --- a/src/client/Hud.luau +++ b/src/client/Hud.luau @@ -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 diff --git a/src/client/HudFallback.luau b/src/client/HudFallback.luau index d6a7ceb..5546a3b 100644 --- a/src/client/HudFallback.luau +++ b/src/client/HudFallback.luau @@ -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