Merge pull request #36 from TemujinCalidius/feat/gather-craft

feat: tool-swing harvesting, hand crafting & no-code content (#1, #4, #11)
This commit is contained in:
Samuel Lison
2026-06-24 15:44:38 +10:00
committed by GitHub
24 changed files with 2526 additions and 86 deletions
+27
View File
@@ -5,6 +5,33 @@ 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
- **Tool-swing harvesting & the gather → craft loop** (#1, #4) — equip a tool from the hotbar,
**click a node to swing**, and the server validates the hit (range, line-of-sight, equipped tool,
cooldown) before granting a per-hit **random yield** straight into your inventory; a full
inventory **blocks** the hit so nothing is wasted. Bare-hand nodes keep the hold-`E` prompt. This
is the engine's first **client-input → RemoteEvent → server-validation pipeline**, shaped for
combat to reuse. Selecting a tool in the hotbar now equips a **real `Tool`** (the new
hotbar→Tool bridge), with a content-free Tool template path. **Hand crafting** closes the loop:
a server-authoritative runtime consumes a recipe's ingredients and produces its output
(refunding on no room), surfaced as a **Crafting tab** that lists `Recipes.forStation("hand")`
and gates each recipe on what you're carrying. New APIs: `SurvivorCore.Harvesting`,
`SurvivorCore.Crafting`, plus `Harvesting`/`Crafting` Config sections. See
[docs/harvesting.md](docs/harvesting.md) and [docs/crafting.md](docs/crafting.md).
- **No-code content layer** (#11, Builder first slice) — items and gatherable **resources** can now
be authored **without code**. A new `Resources` registry defines what a node *is*
(item it yields, HP/gathers, required tool, yield min/max); a `Gatherable` node binds to one via a
`Resource` attribute (tag a mesh, set `Resource = "oak_tree"` — no per-node attributes). Content
can be defined as instances (`Registry.loadFromFolder` reads a `SurvivorCoreContent` folder at
start), and the **admin plugin gains a "Content" widget** to create/edit/delete items + gatherable
resources from a form — what it writes, the engine registers, no code. Per-resource-type
**reaction hooks** (`SurvivorCore.Gather.onReaction`) drive the juice (a reed sways, a tree fells
and leaves a stump), with `DestroyOnDeplete` so a creator can transform the node on depletion.
New hooks: `gather:blocked`, `craft:start` / `craft:end` / `craft:blocked`. See
[docs/content-authoring.md](docs/content-authoring.md).
## 0.3.0 — 2026-06-23
### Added
+175 -3
View File
@@ -67,12 +67,51 @@ SurvivorCore.Items.register({
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.",
description = "A crude chopping tool. Equip it from the hotbar, then click a tree to chop.",
stack = 1,
weight = 1.5,
category = "tool",
toolType = "axe", -- the ToolEquip bridge gives the held Tool this ToolType; trees require "axe"
icon = "rbxassetid://129856164091801",
})
SurvivorCore.Items.register({
id = "wood",
name = "Wood",
description = "A length of timber chopped from a tree. Craft it into planks.",
stack = 20,
weight = 0.4,
category = "material",
icon = "rbxassetid://102789813187589",
})
SurvivorCore.Items.register({
id = "plank",
name = "Wooden Plank",
description = "Milled from wood at hand. A basic building material.",
stack = 20,
weight = 0.3,
category = "material",
icon = "rbxassetid://137051934393677",
})
-- Gatherable RESOURCE defs: what a tagged node *is*. A creator tags a mesh "Gatherable" and sets
-- Resource = "<id>" to inherit these (no per-node attributes). HP>0 + a tool = click-to-swing;
-- no tool = bare-hand hold-E. (These can also be authored no-code via the admin plugin.)
SurvivorCore.Resources.register({
id = "oak_tree",
item = "wood",
hp = 5,
requireTool = "axe", -- needs an axe equipped
yieldMin = 1,
yieldMax = 2,
})
SurvivorCore.Resources.register({
id = "reed_bush",
item = "reed",
hp = 3,
requireTool = "", -- bare-hand
yieldMin = 1,
yieldMax = 2,
})
SurvivorCore.Items.register({
id = "straw_hat",
name = "Straw Hat",
@@ -101,10 +140,16 @@ SurvivorCore.Recipes.register({
ingredients = { { item = "reed", count = 5 } },
output = { item = "reed_basket", count = 1 },
})
SurvivorCore.Recipes.register({
id = "plank",
station = "hand",
ingredients = { { item = "wood", count = 2 } },
output = { item = "plank", count = 1 },
})
-- 2. React to creator-owned Gatherables (the engine also grants the Yield into the inventory)
-- 2. React to creator-owned Gatherables (the engine grants the per-hit 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)))
print(("[demo] %s fully gathered %s"):format(ctx.player.Name, tostring(ctx.resource or ctx.item)))
end)
-- A flourish hook: print whenever a consumable is used.
@@ -126,7 +171,10 @@ local function seedPlayer(player: Player)
player:SetAttribute("InvQty_3", 1)
player:SetAttribute("InvSlot_4", "reed_satchel")
player:SetAttribute("InvQty_4", 1)
player:SetAttribute("InvSlot_5", "stone_axe")
player:SetAttribute("InvQty_5", 1)
player:SetAttribute("HotbarSlot1", "berry") -- a pre-pinned quick slot
player:SetAttribute("HotbarSlot2", "stone_axe") -- press 2 to equip the axe, then click a tree
player:SetAttribute("EquipSlot_Head", "straw_hat") -- a pre-filled equipment slot
end
for _, player in Players:GetPlayers() do
@@ -135,4 +183,128 @@ end
Players.PlayerAdded:Connect(seedPlayer)
SurvivorCore.start()
-- 4. The demo "gather field": a tool-gated tree + a bare-hand reed, the axe Tool template the
-- hotbar equips, and the reaction "juice" (reed sways on hit; the tree fells + leaves a stump).
-- All demo content — the engine ships none of it. Built after start() so the resource defs are
-- registered before the nodes bind.
local CollectionService = game:GetService("CollectionService")
local TweenService = game:GetService("TweenService")
local Workspace = game:GetService("Workspace")
-- The axe Tool the ToolEquip bridge clones when "stone_axe" is the active hotbar item.
local function buildAxeTemplate()
local content = ReplicatedStorage:FindFirstChild("SurvivorCoreContent")
if not content then
content = Instance.new("Folder")
content.Name = "SurvivorCoreContent"
content.Parent = ReplicatedStorage
end
local tools = content:FindFirstChild("Tools")
if not tools then
tools = Instance.new("Folder")
tools.Name = "Tools"
tools.Parent = content
end
if tools:FindFirstChild("stone_axe") then
return
end
local tool = Instance.new("Tool")
tool.Name = "stone_axe"
tool.RequiresHandle = true
tool.CanBeDropped = false
local handle = Instance.new("Part")
handle.Name = "Handle"
handle.Size = Vector3.new(0.6, 3, 0.6)
handle.Color = Color3.fromRGB(110, 80, 55)
handle.Material = Enum.Material.Wood
handle.CanCollide = false
handle.Massless = true
handle.Parent = tool
tool.Parent = tools
end
buildAxeTemplate()
local function buildTree(position: Vector3)
local trunk = Instance.new("Part")
trunk.Name = "OakTree"
trunk.Anchored = true
trunk.Size = Vector3.new(2, 14, 2)
trunk.Position = position + Vector3.new(0, 7, 0)
trunk.Color = Color3.fromRGB(95, 70, 45)
trunk.Material = Enum.Material.Wood
local leaves = Instance.new("Part")
leaves.Name = "Leaves"
leaves.Shape = Enum.PartType.Ball
leaves.Size = Vector3.new(9, 9, 9)
leaves.Position = position + Vector3.new(0, 15, 0)
leaves.Color = Color3.fromRGB(70, 130, 70)
leaves.Material = Enum.Material.Grass
leaves.Anchored = false
leaves.CanCollide = false
leaves.Massless = true
leaves.Parent = trunk
local weld = Instance.new("WeldConstraint")
weld.Part0 = trunk
weld.Part1 = leaves
weld.Parent = trunk
trunk:SetAttribute("Resource", "oak_tree")
trunk:SetAttribute("DestroyOnDeplete", false) -- the fell reaction transforms it instead
CollectionService:AddTag(trunk, "Gatherable")
trunk.Parent = Workspace
end
local function buildReed(position: Vector3)
local reed = Instance.new("Part")
reed.Name = "ReedBush"
reed.Anchored = true
reed.Size = Vector3.new(2, 4, 2)
reed.Position = position + Vector3.new(0, 2, 0)
reed.Color = Color3.fromRGB(110, 160, 90)
reed.Material = Enum.Material.Grass
reed:SetAttribute("_Rest", reed.CFrame)
reed:SetAttribute("Resource", "reed_bush")
CollectionService:AddTag(reed, "Gatherable")
reed.Parent = Workspace
end
buildTree(Vector3.new(10, 0, 28))
buildTree(Vector3.new(18, 0, 28))
buildReed(Vector3.new(-8, 0, 28))
buildReed(Vector3.new(-12, 0, 28))
-- Reaction juice (per resource type) — pure creator content via the engine's reaction API.
local SWAY = TweenInfo.new(0.1, Enum.EasingStyle.Sine, Enum.EasingDirection.InOut, 0, true)
SurvivorCore.Gather.onReaction("reed_bush", "hit", function(ctx)
local reed = ctx.instance
if reed and reed:IsA("BasePart") then
local rest = reed:GetAttribute("_Rest") or reed.CFrame
TweenService:Create(reed, SWAY, { CFrame = rest * CFrame.Angles(0, 0, math.rad(15)) }):Play()
end
end)
SurvivorCore.Gather.onReaction("oak_tree", "depleted", function(ctx)
local trunk = ctx.instance
if not trunk or not trunk:IsA("BasePart") then
return
end
local stump = Instance.new("Part")
stump.Name = "Stump"
stump.Anchored = true
stump.Size = Vector3.new(2.4, 1.2, 2.4)
stump.Position = Vector3.new(trunk.Position.X, trunk.Position.Y - trunk.Size.Y / 2 + 0.6, trunk.Position.Z)
stump.Color = Color3.fromRGB(95, 70, 45)
stump.Material = Enum.Material.Wood
stump.Parent = Workspace
-- Let physics topple the trunk (leaves welded along for the ride), then clean up.
trunk.Anchored = false
trunk.AssemblyAngularVelocity = Vector3.new(0, 0, 6)
task.delay(4, function()
if trunk and trunk.Parent then
trunk:Destroy()
end
end)
end)
print("SurvivorCore demo booted — v" .. SurvivorCore.VERSION)
+13 -4
View File
@@ -1,8 +1,17 @@
# Survival Stats admin plugin
# SurvivorCore admin plugin
A small **Studio plugin** that gives the experience owner a friendly form to tune the survival
stats — instead of hand-editing Attributes on the `SurvivalStatsConfig` instance in the Explorer.
It's the first slice of the [Builder / Admin plugin](https://github.com/TemujinCalidius/SurvivorCore/issues/11).
A small **Studio plugin** that gives the experience owner friendly forms instead of hand-editing
Attributes in the Explorer. It adds two toolbar buttons under **SurvivorCore**:
- **Survival Stats** — tune the survival-stat rates/thresholds/HUD on the `SurvivalStatsConfig`
instance (the deltas-only, locked model below).
- **Content** — create/edit/delete **items** and **gatherable resources** with no code (the Builder
first slice). It writes `SurvivorCoreContent` instances the engine loads at `start()` — see
[content-authoring.md](content-authoring.md). Unlike the stats editor, content is full
owner-authored defs (not deltas). Every edit is one Studio undo step.
The rest of this page covers the Survival Stats editor; both install the same way. It's the
[Builder / Admin plugin](https://github.com/TemujinCalidius/SurvivorCore/issues/11).
> 📹 **Demo:** [HUD, survival stats & the admin plugin in action](https://makertube.net/w/xqX7wfRpTqd9L9BkozCS1P)
+61
View File
@@ -0,0 +1,61 @@
# No-code content authoring
The engine ships **zero** items, resources, or recipes — your game supplies them. You can do this
two ways, and they coexist:
1. **From code**`SurvivorCore.Items.register{…}`, `SurvivorCore.Resources.register{…}`,
`SurvivorCore.Recipes.register{…}` before `start()` (see the demo `Boot.server.luau`).
2. **No-code, as instances** — author content in the place and the engine loads it at `start()`.
The **admin plugin's Content widget** writes exactly this for you.
## The `SurvivorCoreContent` folder
At `start()`, the engine reads `ReplicatedStorage.SurvivorCoreContent` and registers any defs found:
```
ReplicatedStorage
└─ SurvivorCoreContent
├─ Items (Folder)
│ └─ berry (Configuration) ← child Name = item id
│ • name = "Wild Berries" ← attributes = def fields
│ • stack = 20
│ • weight = 0.05
│ • category = "consumable"
├─ Resources (Folder)
│ └─ berry_bush (Configuration)
│ • item = "berry"
│ • hp = 4
│ • requireTool = "" (blank = bare-hand)
│ • yieldMin = 1
│ • yieldMax = 3
└─ Tools (Folder) ← actual Tool templates the hotbar equips (named by item id)
```
Each child's **Name is the id**; its **attributes are the def fields**
([`Registry.loadFromFolder`](../src/foundation/Registry.luau) copies them verbatim). This is the
same instance-config pattern the survival stats use.
## The admin plugin Content widget
Open Studio → the **SurvivorCore** toolbar → **Content**. Two builders:
- **Items** — create an item by id, then set Name / Max stack / Weight / Category / Tool type /
Icon / Description.
- **Gatherables** — create a resource by id, then set Yields item / HP (gathers) / Tool required /
Yield min / Yield max.
Each edit is one Studio **undo** step. Behind the scenes it creates/edits the
`SurvivorCoreContent` instances above, so pressing Play registers your content with no code.
## Binding a world object
A creator builds any mesh, tags it **`Gatherable`** (CollectionService), and sets a `Resource`
attribute to a resource id (e.g. `"berry_bush"`). The node inherits item/HP/tool/yield from the def.
See [harvesting.md](harvesting.md) for the full attribute list and the per-type **reaction** hooks
(shake / fell / etc.).
## Tools
For a tool item, set `toolType` (e.g. `"axe"`) and `category = "tool"`. To give it a custom look,
place a `Tool` named by the item id under `SurvivorCoreContent.Tools`; otherwise the engine equips a
plain default. Trees etc. require the matching `RequireTool`.
+47
View File
@@ -0,0 +1,47 @@
# Crafting
Hand crafting closes the **gather → craft → use** loop: from your inventory, a recipe's ingredients
are consumed and its output produced — server-authoritative, via
[`src/systems/Crafting.luau`](../src/systems/Crafting.luau).
> 📹 Demo video: _coming soon_
## Recipes
Recipes live in the `Recipes` registry, routed by `station` (`"hand"` is the first consumer;
campfire/cooking later use the same engine). Register in code or
[no-code](content-authoring.md):
```lua
SurvivorCore.Recipes.register({
id = "plank",
station = "hand",
ingredients = { { item = "wood", count = 2 } },
output = { item = "plank", count = 1 },
})
```
## Runtime
`SurvivorCore.Crafting` (available after `start()`):
| Function | Behavior |
|---|---|
| `canCraft(player, recipeId) -> (bool, reason?)` | recipe exists, station `"hand"`, has all ingredients |
| `craft(player, recipeId) -> bool` | consume ingredients → produce output; **refunds** on any mid-way failure or no room |
It fires `craft:start``craft:end` (success) or `craft:blocked` (reason: `unknown` / `station` /
`ingredients` / `full`). The client requests a craft via the `CraftRecipe` RemoteEvent; the server
re-validates everything.
## The Crafting tab
The menu's **Crafting** tab ([`src/client/CraftingUi.luau`](../src/client/CraftingUi.luau)) is
registered through the engine's own `SurvivorCore.UI.registerPanel` API. It lists every hand recipe
(replicated to the client via `RecipeData`), shows the output icon/name + ingredient line, and
**greys out** recipes you can't afford — re-checked live as your inventory changes. Click **Craft**
to fire the request. Styling comes from the `UI` Config `Theme`.
## Tuning
`Config.override("Crafting", { CraftTime = 0 })` (reserved for a future craft-channel progress bar).
+86
View File
@@ -0,0 +1,86 @@
# Harvesting (gathering)
SurvivorCore turns any part/mesh into a harvestable node via the **`Gatherable`** component, with
two interaction styles:
- **Bare-hand** — walk up, hold **E** (a `ProximityPrompt`). For reeds, berries, loose stone.
- **Tool-swing** — equip a tool, **click** to swing. The node requires a tool *type* (e.g. an axe)
and takes several hits. This is the engine's first **client-input → server-validated-hit**
pipeline (combat reuses it later).
Every hit is resolved server-side by [`src/systems/Harvesting.luau`](../src/systems/Harvesting.luau):
it validates, rolls a **per-hit yield** (`yieldMin..yieldMax`), tries to add it to the inventory
(**blocking the hit if you're full** — nothing wasted), then spends one HP and fires the hooks. On
depletion the node is destroyed (unless it opts out — see reactions).
> 📹 Demo video: _coming soon_
## Defining a node
A node's stats come from a **resource def** (preferred) or raw per-node attributes.
### Resource defs (recommended)
Register once, reuse on every node — in code or [no-code via the admin plugin](content-authoring.md):
```lua
SurvivorCore.Resources.register({
id = "oak_tree",
item = "wood", -- the item it yields (an Items id)
hp = 5, -- hits/gathers to deplete
requireTool = "axe", -- tool type required ("" = bare-hand)
yieldMin = 1,
yieldMax = 2, -- random yield per hit
})
```
Then a creator builds a mesh, tags it **`Gatherable`** (CollectionService), and sets one attribute:
| Attribute | Meaning |
|---|---|
| `Resource` | the resource def id to inherit from (e.g. `"oak_tree"`) |
### Raw attributes (overrides / quick nodes)
Any of these override the resource def (or stand alone without one):
| Attribute | Default | Meaning |
|---|---|---|
| `ItemId` | `""` | item yielded |
| `HP` | `3` | hits to deplete |
| `RequireTool` | `""` | tool type needed; `""` = bare-hand |
| `YieldMin` / `YieldMax` | `1` / `1` | random yield per hit (or legacy single `Yield`) |
| `Interaction` | `"auto"` | `"auto"` (tool if `RequireTool` set, else prompt), `"prompt"`, or `"tool"` |
| `DestroyOnDeplete` | `true` | `false` keeps the node so a reaction can transform it (stump/fell) |
## Tools
Tool-swing needs a held Roblox `Tool` whose **`ToolType`** attribute matches the node's
`RequireTool` (falling back to the Tool's `Name`). The hotbar does this for you: give an item a
`toolType` field and a `category` of `"tool"`, and selecting it in the hotbar equips a real `Tool`
(the **hotbar→Tool bridge**, [`src/systems/ToolEquip.luau`](../src/systems/ToolEquip.luau)). The
Tool's look is content-free: it clones a creator template from
`ReplicatedStorage.SurvivorCoreContent.Tools` (a `Tool` named by item id), or builds a minimal
default if none is registered.
## Reactions (the juice)
Global `Hooks.on("gather:hit"/"gather:depleted"/"gather:blocked", …)` fire for every node. For
behavior tied to **one** resource type, use the reaction API — no core edits:
```lua
SurvivorCore.Gather.onReaction("reed_bush", "hit", function(ctx)
-- ctx = { instance, player, resource, item, granted, hpLeft, position }
sway(ctx.instance)
end)
SurvivorCore.Gather.onReaction("oak_tree", "depleted", function(ctx)
rezStumpAndFell(ctx.instance) -- the node had DestroyOnDeplete = false
end)
```
## Tuning
`Config.override("Harvesting", { SwingRange = 12, SwingCooldown = 0.45, RequireLineOfSight = true })`.
## Out of scope (for now)
Durability, multi-phase tree growth/regrow, and particle systems are creator content via the hooks
above — the engine ships none. Combat (#12) reuses this same swing pipeline.
+190
View File
@@ -0,0 +1,190 @@
--!nonstrict
--[[
ContentAdmin the pure LOGIC layer of the no-code content builder (Items + Gatherable
resources). NO Studio widget / `plugin` global / ChangeHistoryService, so it's requirable and
testable headlessly. The UI (ContentAdminUi) + plugin main are thin layers over this.
Unlike the stat editor (which writes DELTAS over engine defaults), content is entirely
owner-created: each item/resource is a `Configuration` child under
`ReplicatedStorage.SurvivorCoreContent.{Items,Resources}`, named by its id, with its fields as
attributes. The engine's `Registry.loadFromFolder` reads exactly this at start() so what the
plugin writes here, the runtime registers, with no code. Field attribute names are the def field
names (name/stack/) the loader copies verbatim.
]]
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ContentAdmin = {}
ContentAdmin.ROOT_NAME = "SurvivorCoreContent"
export type FieldSpec = { attr: string, kind: string, label: string, default: any, placeholder: string? }
export type Category = { folder: string, keyLabel: string, title: string, fields: { FieldSpec } }
-- The two builders. `attr` are the lowercase def field names the engine loader reads.
ContentAdmin.CATEGORIES = {
Items = {
folder = "Items",
title = "Items",
keyLabel = "New item id",
fields = {
{ attr = "name", kind = "string", label = "Name", default = "" },
{ attr = "stack", kind = "number", label = "Max stack", default = 1 },
{ attr = "weight", kind = "number", label = "Weight", default = 0 },
{
attr = "category",
kind = "string",
label = "Category",
default = "",
placeholder = "material / tool / consumable",
},
{ attr = "toolType", kind = "string", label = "Tool type", default = "", placeholder = "axe (tools only)" },
{ attr = "icon", kind = "string", label = "Icon", default = "", placeholder = "rbxassetid://…" },
{ attr = "description", kind = "string", label = "Description", default = "" },
},
},
Resources = {
folder = "Resources",
title = "Gatherables",
keyLabel = "New resource id",
fields = {
{ attr = "item", kind = "string", label = "Yields item", default = "", placeholder = "an item id" },
{ attr = "hp", kind = "number", label = "HP / gathers", default = 3 },
{
attr = "requireTool",
kind = "string",
label = "Tool required",
default = "",
placeholder = "axe (blank = bare hand)",
},
{ attr = "yieldMin", kind = "number", label = "Yield min", default = 1 },
{ attr = "yieldMax", kind = "number", label = "Yield max", default = 1 },
},
},
} :: { [string]: Category }
-- Render order for the UI.
ContentAdmin.ORDER = { "Items", "Resources" }
-- ids are lowercase alphanumeric + underscore (matches how content is referenced everywhere).
function ContentAdmin.sanitizeId(raw: any): string
return (string.gsub(string.lower(tostring(raw or "")), "[^%w_]", ""))
end
function ContentAdmin.getRoot(): Instance?
return ReplicatedStorage:FindFirstChild(ContentAdmin.ROOT_NAME)
end
function ContentAdmin.getCategoryFolder(catKey: string): Instance?
local cat = ContentAdmin.CATEGORIES[catKey]
local root = ContentAdmin.getRoot()
return root and cat and root:FindFirstChild(cat.folder) or nil
end
-- Find-or-create the SurvivorCoreContent/<folder> path. Only called from the write path.
function ContentAdmin.ensureFolder(catKey: string): Instance
local cat = ContentAdmin.CATEGORIES[catKey]
local root = ContentAdmin.getRoot()
if not root then
root = Instance.new("Folder")
root.Name = ContentAdmin.ROOT_NAME
root.Parent = ReplicatedStorage
end
local folder = root:FindFirstChild(cat.folder)
if not folder then
folder = Instance.new("Folder")
folder.Name = cat.folder
folder.Parent = root
end
return folder
end
function ContentAdmin.list(catKey: string): { string }
local out = {}
local folder = ContentAdmin.getCategoryFolder(catKey)
if folder then
for _, child in folder:GetChildren() do
table.insert(out, child.Name)
end
table.sort(out)
end
return out
end
function ContentAdmin.getNode(catKey: string, id: string): Instance?
local folder = ContentAdmin.getCategoryFolder(catKey)
return folder and folder:FindFirstChild(id) or nil
end
local function coerce(kind: string, raw: any): (boolean, any, string?)
if kind == "number" then
local n = tonumber(raw)
if n == nil then
return false, nil, "not a number"
end
return true, n, nil
elseif kind == "boolean" then
if typeof(raw) == "boolean" then
return true, raw, nil
end
return true, raw == "true", nil
end
return true, tostring(raw), nil
end
ContentAdmin.coerce = coerce
-- Create a new entry, seeding every field to its default so the loader sees a complete def.
function ContentAdmin.create(catKey: string, rawId: any): (boolean, string)
local cat = ContentAdmin.CATEGORIES[catKey]
if not cat then
return false, "unknown category"
end
local id = ContentAdmin.sanitizeId(rawId)
if id == "" then
return false, "Enter a valid id (letters, numbers, _)."
end
local folder = ContentAdmin.ensureFolder(catKey)
if folder:FindFirstChild(id) then
return false, `'{id}' already exists.`
end
local node = Instance.new("Configuration")
node.Name = id
for _, f in cat.fields do
node:SetAttribute(f.attr, f.default)
end
node.Parent = folder
return true, id
end
function ContentAdmin.delete(catKey: string, id: string)
local node = ContentAdmin.getNode(catKey, id)
if node then
node:Destroy()
end
end
-- The attribute value for a field (or its default if unset/no node).
function ContentAdmin.read(catKey: string, id: string, field: FieldSpec): any
local node = ContentAdmin.getNode(catKey, id)
local v = node and node:GetAttribute(field.attr)
if v == nil then
return field.default
end
return v
end
-- Write a field (full value — content is owner-owned, not a delta over an engine default).
function ContentAdmin.set(catKey: string, id: string, field: FieldSpec, raw: any): (boolean, string?)
local node = ContentAdmin.getNode(catKey, id)
if not node then
return false, "missing entry"
end
local ok, value, err = coerce(field.kind, raw)
if not ok then
return false, err
end
node:SetAttribute(field.attr, value)
return true, nil
end
return ContentAdmin
+318
View File
@@ -0,0 +1,318 @@
--!nonstrict
--[[
ContentAdminUi the dock-widget UI for the no-code content builder. A thin layer over
ContentAdmin (the logic): it renders an Items section and a Gatherables section, each with a
"create" row and one editable panel per entry (field rows + Delete). Edits route through the
applySet / applyCreate / applyDelete callbacks the plugin main supplies (which wrap the
ContentAdmin call in a ChangeHistoryService recording, then refresh). Styling matches
StatAdminUi / docs/design-language.md.
]]
local ContentAdminUi = {}
local COL_BG = Color3.fromRGB(18, 21, 28)
local COL_PANEL = Color3.fromRGB(28, 32, 42)
local COL_TEXT = Color3.fromRGB(235, 238, 245)
local COL_DIM = Color3.fromRGB(150, 160, 180)
local COL_FIELD = Color3.fromRGB(38, 43, 56)
local COL_ACCENT = Color3.fromRGB(120, 170, 255)
local COL_DANGER = Color3.fromRGB(210, 90, 90)
local FONT = Enum.Font.GothamMedium
local ROW_H = 26
local function make(class: string, props: { [string]: any }, children: { Instance }?): Instance
local inst = Instance.new(class)
for key, value in props do
(inst :: any)[key] = value
end
if children then
for _, child in children do
child.Parent = inst
end
end
return inst
end
local function corner(radius: number): Instance
return make("UICorner", { CornerRadius = UDim.new(0, radius) })
end
-- A single field row: label .... control (TextBox committed on focus loss).
local function buildField(catKey: string, id: string, field: any, value: any, applySet): Instance
local row = make("Frame", { Size = UDim2.new(1, 0, 0, ROW_H), BackgroundTransparency = 1 })
make("TextLabel", {
Size = UDim2.fromScale(0.4, 1),
BackgroundTransparency = 1,
Text = field.label,
TextColor3 = COL_DIM,
TextXAlignment = Enum.TextXAlignment.Left,
TextSize = 13,
Font = FONT,
}).Parent =
row
local box = make("TextBox", {
Size = UDim2.new(0.6, -4, 0, ROW_H - 4),
Position = UDim2.new(0.4, 4, 0, 2),
BackgroundColor3 = COL_FIELD,
Text = tostring(value),
PlaceholderText = field.placeholder or tostring(field.default),
TextColor3 = COL_TEXT,
TextSize = 13,
Font = FONT,
ClearTextOnFocus = false,
BorderSizePixel = 0,
}, { corner(4), make("UIPadding", { PaddingLeft = UDim.new(0, 6), PaddingRight = UDim.new(0, 6) }) })
box.Parent = row
box.FocusLost:Connect(function()
applySet(catKey, id, field, box.Text)
end)
return row
end
-- One entry panel: a title row (id + Delete) + a field row per field.
local function buildEntry(catKey: string, id: string, ContentAdmin: any, applySet, applyDelete, applySpawn): Instance
local cat = ContentAdmin.CATEGORIES[catKey]
local panel = make("Frame", {
BackgroundColor3 = COL_PANEL,
BorderSizePixel = 0,
Size = UDim2.fromScale(1, 0),
AutomaticSize = Enum.AutomaticSize.Y,
}, {
corner(8),
make("UIPadding", {
PaddingLeft = UDim.new(0, 8),
PaddingRight = UDim.new(0, 8),
PaddingTop = UDim.new(0, 6),
PaddingBottom = UDim.new(0, 8),
}),
make("UIListLayout", { Padding = UDim.new(0, 2), SortOrder = Enum.SortOrder.LayoutOrder }),
})
local title = make("Frame", { Size = UDim2.new(1, 0, 0, 24), BackgroundTransparency = 1, LayoutOrder = 0 })
title.Parent = panel
local del = make("TextButton", {
Size = UDim2.fromOffset(56, 20),
Position = UDim2.new(1, -56, 0.5, -10),
BackgroundColor3 = COL_FIELD,
AutoButtonColor = true,
Text = "Delete",
TextColor3 = COL_DANGER,
TextSize = 12,
Font = FONT,
BorderSizePixel = 0,
}, { corner(4) })
del.Parent = title
del.MouseButton1Click:Connect(function()
applyDelete(catKey, id)
end)
-- Gatherables get a one-click "Add to World" that drops a tagged, Resource-set Part in front of
-- the camera (the no-code way to place a node) — the bridge between the editor and the world.
local labelRight = 64
if catKey == "Resources" and applySpawn then
local add = make("TextButton", {
Size = UDim2.fromOffset(104, 20),
Position = UDim2.new(1, -168, 0.5, -10),
BackgroundColor3 = COL_FIELD,
AutoButtonColor = true,
Text = "+ Add to World",
TextColor3 = COL_ACCENT,
TextSize = 11,
Font = FONT,
BorderSizePixel = 0,
}, { corner(4) })
add.Parent = title
add.MouseButton1Click:Connect(function()
applySpawn(id)
end)
labelRight = 176
end
make("TextLabel", {
Size = UDim2.new(1, -labelRight, 1, 0),
BackgroundTransparency = 1,
Text = id,
TextColor3 = COL_TEXT,
TextXAlignment = Enum.TextXAlignment.Left,
TextSize = 14,
Font = Enum.Font.GothamBold,
}).Parent =
title
for i, field in cat.fields do
local row = buildField(catKey, id, field, ContentAdmin.read(catKey, id, field), applySet)
row.LayoutOrder = i
row.Parent = panel
end
return panel
end
-- Build one category block: bold header, a create row, then an entry panel per id.
local function buildCategory(
catKey: string,
ContentAdmin: any,
applySet,
applyCreate,
applyDelete,
applySpawn
): Instance
local cat = ContentAdmin.CATEGORIES[catKey]
local block = make("Frame", {
BackgroundTransparency = 1,
Size = UDim2.fromScale(1, 0),
AutomaticSize = Enum.AutomaticSize.Y,
}, {
make("UIListLayout", { Padding = UDim.new(0, 6), SortOrder = Enum.SortOrder.LayoutOrder }),
})
make("TextLabel", {
Size = UDim2.new(1, 0, 0, 24),
BackgroundTransparency = 1,
Text = cat.title,
TextColor3 = COL_ACCENT,
TextXAlignment = Enum.TextXAlignment.Left,
TextSize = 15,
Font = Enum.Font.GothamBold,
LayoutOrder = 0,
}).Parent =
block
-- Create row: [ new id ............ ] [ + Create ]
local createRow = make("Frame", { Size = UDim2.new(1, 0, 0, 28), BackgroundTransparency = 1, LayoutOrder = 1 })
createRow.Parent = block
local idBox = make("TextBox", {
Size = UDim2.new(1, -92, 1, 0),
BackgroundColor3 = COL_FIELD,
Text = "",
PlaceholderText = cat.keyLabel,
TextColor3 = COL_TEXT,
TextSize = 13,
Font = FONT,
ClearTextOnFocus = false,
BorderSizePixel = 0,
}, { corner(4), make("UIPadding", { PaddingLeft = UDim.new(0, 6), PaddingRight = UDim.new(0, 6) }) })
idBox.Parent = createRow
local createBtn = make("TextButton", {
Size = UDim2.fromOffset(84, 28),
Position = UDim2.new(1, -84, 0, 0),
BackgroundColor3 = COL_FIELD,
AutoButtonColor = true,
Text = "+ Create",
TextColor3 = COL_TEXT,
TextSize = 13,
Font = FONT,
BorderSizePixel = 0,
}, { corner(4) })
createBtn.Parent = createRow
createBtn.MouseButton1Click:Connect(function()
applyCreate(catKey, idBox.Text)
end)
local ids = ContentAdmin.list(catKey)
if #ids == 0 then
make("TextLabel", {
Size = UDim2.new(1, 0, 0, 22),
BackgroundTransparency = 1,
Text = `No {string.lower(cat.title)} yet — create one above.`,
TextColor3 = COL_DIM,
TextXAlignment = Enum.TextXAlignment.Left,
TextSize = 12,
Font = FONT,
LayoutOrder = 2,
}).Parent =
block
else
for i, id in ids do
local entry = buildEntry(catKey, id, ContentAdmin, applySet, applyDelete, applySpawn)
entry.LayoutOrder = 2 + i
entry.Parent = block
end
end
return block
end
function ContentAdminUi.mount(
container: Instance,
ContentAdmin: any,
applySet,
applyCreate,
applyDelete,
applySpawn
): { refresh: () -> () }
for _, child in container:GetChildren() do
if not child:IsA("UIBase") then
child:Destroy()
end
end
(container :: any).BackgroundColor3 = COL_BG
local header = make("Frame", { Size = UDim2.new(1, 0, 0, 32), BackgroundTransparency = 1 })
header.Parent = container
make("TextLabel", {
Size = UDim2.new(1, -84, 1, 0),
Position = UDim2.fromOffset(12, 0),
BackgroundTransparency = 1,
Text = "Content",
TextColor3 = COL_TEXT,
TextXAlignment = Enum.TextXAlignment.Left,
TextSize = 15,
Font = Enum.Font.GothamBold,
}).Parent =
header
local refreshBtn = make("TextButton", {
Size = UDim2.fromOffset(72, 22),
Position = UDim2.new(1, -80, 0.5, -11),
BackgroundColor3 = COL_PANEL,
AutoButtonColor = true,
Text = "Refresh",
TextColor3 = COL_TEXT,
TextSize = 12,
Font = FONT,
BorderSizePixel = 0,
}, { corner(4) })
refreshBtn.Parent = header
local scroll = make("ScrollingFrame", {
Size = UDim2.new(1, 0, 1, -32),
Position = UDim2.fromOffset(0, 32),
BackgroundTransparency = 1,
BorderSizePixel = 0,
ScrollBarThickness = 6,
CanvasSize = UDim2.new(),
AutomaticCanvasSize = Enum.AutomaticSize.Y,
}, {
make("UIListLayout", { Padding = UDim.new(0, 12), SortOrder = Enum.SortOrder.LayoutOrder }),
make("UIPadding", {
PaddingLeft = UDim.new(0, 10),
PaddingRight = UDim.new(0, 10),
PaddingTop = UDim.new(0, 6),
PaddingBottom = UDim.new(0, 12),
}),
})
scroll.Parent = container
local function refresh()
for _, child in scroll:GetChildren() do
if not child:IsA("UIBase") then
child:Destroy()
end
end
for i, catKey in ContentAdmin.ORDER do
local block = buildCategory(catKey, ContentAdmin, applySet, applyCreate, applyDelete, applySpawn)
block.LayoutOrder = i
block.Parent = scroll
end
end
refreshBtn.MouseButton1Click:Connect(refresh)
refresh()
return { refresh = refresh }
end
return ContentAdminUi
+160 -38
View File
@@ -1,91 +1,213 @@
--!nonstrict
--[[
SurvivorCore Survival Stats admin plugin (main).
SurvivorCore admin plugin (main).
A toolbar button toggles a dock widget that edits the survival-stats `SurvivalStatsConfig`
instance through StatAdmin the deltas-only, LOCKED model: it writes only fields the owner
changes from the engine default, removes them on reset, and can NEVER write the engine-owned
semantics (Invert / DangerHigh). Edits are wrapped in ChangeHistoryService recordings so each
is a single undo step. All real logic lives in StatAdmin (testable); the form in StatAdminUi.
First slice of the Builder / Admin plugin (issue #11).
ONE dock widget, a tab bar across the top, one feature per tab:
Stats tune the survival stats (deltas-only, locked) on SurvivalStatsConfig + HUD preview.
Content create/edit/delete Items + Gatherable resources (no-code) as SurvivorCoreContent
instances the engine loads at start().
Every mutation is wrapped in a single ChangeHistoryService recording (one undo step) and then
both editors refresh. All real logic lives in the requirable modules (StatAdmin / ContentAdmin);
the forms in StatAdminUi / ContentAdminUi. This is the Builder / Admin plugin (issue #11), and
new sections (Movement / Consequences, #21) slot in as more tabs.
]]
local ChangeHistoryService = game:GetService("ChangeHistoryService")
local CollectionService = game:GetService("CollectionService")
local Selection = game:GetService("Selection")
local Workspace = game:GetService("Workspace")
local StatAdmin = require(script.StatAdmin)
local StatAdminUi = require(script.StatAdminUi)
local HudPreview = require(script.HudPreview)
local ContentAdmin = require(script.ContentAdmin)
local ContentAdminUi = require(script.ContentAdminUi)
local COL_BG = Color3.fromRGB(18, 21, 28)
local COL_TAB = Color3.fromRGB(28, 32, 42)
local COL_TAB_ON = Color3.fromRGB(52, 58, 74)
local COL_TEXT = Color3.fromRGB(235, 238, 245)
local TAB_H = 30
local toolbar = plugin:CreateToolbar("SurvivorCore")
local button = toolbar:CreateButton("Survival Stats", "Tune survival-stat rates, thresholds and HUD options", "")
local button = toolbar:CreateButton("Admin Panel", "Tune stats + create items/gatherables (no-code)", "")
button.ClickableWhenViewportHidden = true
-- CreateDockWidgetPluginGui is flagged deprecated by the tooling, but it is still the only
-- API for a dockable plugin widget (there is no replacement) — so allow it here.
-- CreateDockWidgetPluginGui is flagged deprecated by the tooling, but it is still the only API for
-- a dockable plugin widget (there is no replacement) — so allow it here.
-- selene: allow(deprecated)
local widget = plugin:CreateDockWidgetPluginGui(
"SurvivorCoreStatAdmin",
DockWidgetPluginGuiInfo.new(Enum.InitialDockState.Right, false, false, 320, 520, 280, 360)
"SurvivorCoreAdmin",
DockWidgetPluginGuiInfo.new(Enum.InitialDockState.Right, false, false, 340, 560, 320, 400)
)
widget.Title = "Survival Stats"
widget.Name = "SurvivorCoreStatAdmin"
widget.Title = "SurvivorCore Admin"
widget.Name = "SurvivorCoreAdmin"
local container = Instance.new("Frame")
container.Size = UDim2.fromScale(1, 1)
container.BackgroundColor3 = Color3.fromRGB(18, 21, 28)
container.BorderSizePixel = 0
container.Parent = widget
local root = Instance.new("Frame")
root.Size = UDim2.fromScale(1, 1)
root.BackgroundColor3 = COL_BG
root.BorderSizePixel = 0
root.Parent = widget
local ui: { refresh: () -> () }? = nil
-- Tab bar + content region.
local tabBar = Instance.new("Frame")
tabBar.Size = UDim2.new(1, 0, 0, TAB_H)
tabBar.BackgroundColor3 = COL_BG
tabBar.BorderSizePixel = 0
tabBar.Parent = root
local tabLayout = Instance.new("UIListLayout")
tabLayout.FillDirection = Enum.FillDirection.Horizontal
tabLayout.Padding = UDim.new(0, 4)
tabLayout.Parent = tabBar
local tabPad = Instance.new("UIPadding")
tabPad.PaddingLeft = UDim.new(0, 8)
tabPad.PaddingTop = UDim.new(0, 4)
tabPad.Parent = tabBar
-- Wrap a single StatAdmin mutation in one ChangeHistory recording (so it's one undo step),
-- then refresh the form so the override indicators reflect the new state.
local region = Instance.new("Frame")
region.Position = UDim2.fromOffset(0, TAB_H)
region.Size = UDim2.new(1, 0, 1, -TAB_H)
region.BackgroundTransparency = 1
region.BorderSizePixel = 0
region.Parent = root
local statFrame = Instance.new("Frame")
statFrame.Size = UDim2.fromScale(1, 1)
statFrame.BackgroundTransparency = 1
statFrame.Parent = region
local contentFrame = Instance.new("Frame")
contentFrame.Size = UDim2.fromScale(1, 1)
contentFrame.BackgroundTransparency = 1
contentFrame.Visible = false
contentFrame.Parent = region
local statUi: { refresh: () -> () }? = nil
local contentUi: { refresh: () -> () }? = nil
-- Wrap a mutation in one ChangeHistory recording (one undo step), then refresh both editors.
local function record(name: string, mutate: () -> any): any
local recording = ChangeHistoryService:TryBeginRecording(name)
local result = mutate()
if recording then
ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Commit)
end
if ui then
ui.refresh()
if statUi then
statUi.refresh()
end
if contentUi then
contentUi.refresh()
end
return result
end
-- Stats callbacks.
local function applyEdit(statName: string, attr: string, raw: any, default: any): any
return record(`Survival Stats: {statName}.{attr}`, function()
return record(`Stats: {statName}.{attr}`, function()
return StatAdmin.setOverride(statName, attr, raw, default)
end)
end
local function applyReset(statName: string, attr: string): any
return record(`Survival Stats: reset {statName}.{attr}`, function()
return record(`Stats: reset {statName}.{attr}`, function()
return StatAdmin.resetOverride(statName, attr)
end)
end
-- Edit-mode HUD preview (paints resolved icons + sample fills onto the StarterGui HUD) and its
-- undo-able restore. Each is one ChangeHistory step; both return the HudPreview result so the
-- footer can show a status.
local function applyPreview(): any
return record("Survival Stats: preview HUD", function()
return record("Stats: preview HUD", function()
return HudPreview.apply(StatAdmin)
end)
end
local function applyClear(): any
return record("Survival Stats: clear HUD preview", function()
return record("Stats: clear HUD preview", function()
return HudPreview.clear()
end)
end
ui = StatAdminUi.mount(container, StatAdmin, applyEdit, applyReset, applyPreview, applyClear)
-- Content callbacks.
local function applySet(catKey: string, id: string, field: any, raw: any): any
return record(`Content: {catKey} {id}.{field.attr}`, function()
return ContentAdmin.set(catKey, id, field, raw)
end)
end
local function applyCreate(catKey: string, rawId: any): any
return record(`Content: create {catKey}`, function()
return ContentAdmin.create(catKey, rawId)
end)
end
local function applyDelete(catKey: string, id: string): any
return record(`Content: delete {catKey} {id}`, function()
ContentAdmin.delete(catKey, id)
end)
end
-- "Add to World": drop a tagged, Resource-set Part in front of the camera, then select it. The
-- no-code way to place a gatherable node — tag + Resource attribute is exactly what the engine reads.
local function applySpawn(id: string): any
return record(`Content: add gatherable '{id}' to world`, function()
local part = Instance.new("Part")
part.Name = id
part.Size = Vector3.new(4, 4, 4)
part.Anchored = true
local cam = Workspace.CurrentCamera
part.Position = if cam then cam.CFrame.Position + cam.CFrame.LookVector * 16 else Vector3.new(0, 5, 0)
part:SetAttribute("Resource", id)
CollectionService:AddTag(part, "Gatherable")
part.Parent = Workspace
Selection:Set({ part })
return part
end)
end
statUi = StatAdminUi.mount(statFrame, StatAdmin, applyEdit, applyReset, applyPreview, applyClear)
contentUi = ContentAdminUi.mount(contentFrame, ContentAdmin, applySet, applyCreate, applyDelete, applySpawn)
-- Tabs.
local TABS = {
{ id = "stats", label = "Stats", frame = statFrame },
{ id = "content", label = "Content", frame = contentFrame },
}
local tabButtons: { [string]: TextButton } = {}
local function showTab(id: string)
for _, t in TABS do
t.frame.Visible = t.id == id
local b = tabButtons[t.id]
if b then
b.BackgroundColor3 = if t.id == id then COL_TAB_ON else COL_TAB
end
end
end
for _, t in TABS do
local b = Instance.new("TextButton")
b.Size = UDim2.fromOffset(92, TAB_H - 6)
b.BackgroundColor3 = COL_TAB
b.AutoButtonColor = true
b.Text = t.label
b.TextColor3 = COL_TEXT
b.TextSize = 13
b.Font = Enum.Font.GothamMedium
b.BorderSizePixel = 0
b.Parent = tabBar
local c = Instance.new("UICorner")
c.CornerRadius = UDim.new(0, 4)
c.Parent = b
tabButtons[t.id] = b
b.MouseButton1Click:Connect(function()
showTab(t.id)
end)
end
showTab("stats")
button.Click:Connect(function()
widget.Enabled = not widget.Enabled
if widget.Enabled and ui then
ui.refresh()
if widget.Enabled then
if statUi then
statUi.refresh()
end
if contentUi then
contentUi.refresh()
end
end
end)
widget:GetPropertyChangedSignal("Enabled"):Connect(function()
+221
View File
@@ -0,0 +1,221 @@
--!nonstrict
--[[
CraftingUi the Crafting tab. CLIENT-ONLY.
Registers a "Crafting" panel via the engine's own SurvivorCore.UI.registerPanel API (which
clones a styled tab button + a content frame), then fills it with one row per hand recipe
(RecipeData.forStation("hand")): output icon + name, an ingredients line, and a Craft button
that fires the CraftRecipe remote. Rows are gated live — greyed when the player lacks the
ingredients — by reading the replicated inventory attributes (re-checked on every InvSlot_/
InvQty_ change, same model as the slot grid). Themed from the "UI" Config section.
Booted by SurvivorCore.startClient() (after PanelManager, so SurvivorCore.UI exists).
]]
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
assert(RunService:IsClient(), "SurvivorCore.CraftingUi is client-only")
local ItemData = require(script.Parent.Parent.shared.ItemData)
local RecipeData = require(script.Parent.Parent.shared.RecipeData)
local Remotes = require(script.Parent.Parent.shared.Remotes)
local InventoryTypes = require(script.Parent.Parent.shared.InventoryTypes)
local UiConfig = require(script.Parent.Parent.shared.UiConfig)
local PanelManager = require(script.Parent.PanelManager)
local SlotGrid = require(script.Parent.SlotGrid)
local CraftingUi = {}
local started = false
local localPlayer = Players.LocalPlayer
local function clientQty(itemId: string): number
local total = 0
local max = math.floor(tonumber(localPlayer:GetAttribute(InventoryTypes.MAX_SLOTS_ATTR)) or 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 canCraftClient(recipe: any): boolean
for _, ing in recipe.ingredients do
if clientQty(ing.item) < ing.count then
return false
end
end
return true
end
local function itemName(itemId: string): string
local def = ItemData.get(itemId)
return (def and def.name) or itemId
end
local function ingredientsText(recipe: any): string
local parts = {}
for _, ing in recipe.ingredients do
table.insert(parts, `{ing.count}× {itemName(ing.item)}`)
end
return table.concat(parts, ", ")
end
local function corner(parent: Instance, radius: number)
local c = Instance.new("UICorner")
c.CornerRadius = UDim.new(0, radius)
c.Parent = parent
end
local function buildCrafting(content: Instance)
local theme = UiConfig.get().Theme
local scroll = Instance.new("ScrollingFrame")
scroll.Name = "CraftList"
scroll:SetAttribute("CraftList", true)
scroll.Size = UDim2.fromScale(1, 1)
scroll.BackgroundTransparency = 1
scroll.BorderSizePixel = 0
scroll.ScrollBarThickness = 6
scroll.CanvasSize = UDim2.new()
scroll.AutomaticCanvasSize = Enum.AutomaticSize.Y
scroll.Active = true -- sink clicks so the menu's click-outside overlay doesn't close the menu
scroll.Parent = content
local layout = Instance.new("UIListLayout")
layout.Padding = UDim.new(0, 6)
layout.SortOrder = Enum.SortOrder.LayoutOrder
layout.Parent = scroll
local rows: { { recipe: any, btn: TextButton, name: TextLabel, ing: TextLabel, icon: ImageLabel } } = {}
local function makeRow(recipe: any, order: number)
local row = Instance.new("Frame")
row.Name = "Recipe_" .. recipe.id
row.Size = UDim2.new(1, -8, 0, 52)
row.BackgroundColor3 = theme.SlotColor or theme.PanelColor or Color3.fromRGB(30, 34, 44)
row.BackgroundTransparency = 0.15
row.BorderSizePixel = 0
row.LayoutOrder = order
row.Parent = scroll
corner(row, theme.CornerRadius or 8)
local icon = Instance.new("ImageLabel")
icon.Name = "Icon"
icon.BackgroundTransparency = 1
icon.Size = UDim2.fromOffset(40, 40)
icon.Position = UDim2.fromOffset(8, 6)
local outItem = recipe.output and recipe.output.item
local iconId = if outItem then SlotGrid.resolveItemIcon(outItem) else ""
icon.Image = iconId
icon.Visible = iconId ~= ""
icon.Parent = row
local nameLabel = Instance.new("TextLabel")
nameLabel.Name = "Name"
nameLabel.BackgroundTransparency = 1
nameLabel.Size = UDim2.new(1, -160, 0, 22)
nameLabel.Position = UDim2.fromOffset(56, 6)
nameLabel.TextXAlignment = Enum.TextXAlignment.Left
nameLabel.Font = theme.FontBold or Enum.Font.GothamBold
nameLabel.TextSize = 15
nameLabel.TextColor3 = theme.Text or Color3.fromRGB(245, 245, 245)
local outCount = (recipe.output and recipe.output.count) or 1
nameLabel.Text = if outItem
then (if outCount > 1 then `{itemName(outItem)} ×{outCount}` else itemName(outItem))
else recipe.id
nameLabel.Parent = row
local ing = Instance.new("TextLabel")
ing.Name = "Ingredients"
ing.BackgroundTransparency = 1
ing.Size = UDim2.new(1, -160, 0, 18)
ing.Position = UDim2.fromOffset(56, 28)
ing.TextXAlignment = Enum.TextXAlignment.Left
ing.Font = theme.Font or Enum.Font.Gotham
ing.TextSize = 12
ing.TextColor3 = theme.TextSecondary or Color3.fromRGB(200, 205, 215)
ing.Text = ingredientsText(recipe)
ing.Parent = row
local btn = Instance.new("TextButton")
btn.Name = "Craft"
btn:SetAttribute("Action", "craft")
btn.Size = UDim2.fromOffset(84, 32)
btn.Position = UDim2.new(1, -92, 0.5, -16)
btn.Font = theme.FontBold or Enum.Font.GothamBold
btn.TextSize = 14
btn.Text = "Craft"
btn.TextColor3 = theme.Text or Color3.fromRGB(245, 245, 245)
btn.BackgroundColor3 = theme.Accent or Color3.fromRGB(160, 130, 80)
btn.AutoButtonColor = true
btn.Parent = row
corner(btn, 6)
btn.Activated:Connect(function()
Remotes.event("CraftRecipe"):FireServer(recipe.id)
end)
table.insert(rows, { recipe = recipe, btn = btn, name = nameLabel, ing = ing, icon = icon })
end
local function refreshGating()
for _, r in rows do
local ok = canCraftClient(r.recipe)
r.btn.Active = ok
r.btn.AutoButtonColor = ok
r.btn.BackgroundColor3 = if ok
then (theme.Accent or Color3.fromRGB(160, 130, 80))
else Color3.fromRGB(60, 64, 74)
r.btn.Text = if ok then "Craft" else "Need more"
-- Dim (but keep readable) the row when you can't afford it no text-hiding overlay.
local t = if ok then 0 else 0.45
r.name.TextTransparency = t
r.ing.TextTransparency = t
r.icon.ImageTransparency = t
end
end
local recipes = RecipeData.forStation("hand")
for i, recipe in recipes do
makeRow(recipe, i)
end
if #recipes == 0 then
local empty = Instance.new("TextLabel")
empty.BackgroundTransparency = 1
empty.Size = UDim2.new(1, 0, 0, 40)
empty.Font = theme.Font or Enum.Font.Gotham
empty.TextSize = 14
empty.TextColor3 = theme.TextSecondary or Color3.fromRGB(200, 205, 215)
empty.Text = "No hand recipes registered."
empty.Parent = scroll
end
refreshGating()
localPlayer.AttributeChanged:Connect(function(attr)
if
string.match(attr, "^InvSlot_%d+$")
or string.match(attr, "^InvQty_%d+$")
or attr == InventoryTypes.MAX_SLOTS_ATTR
then
refreshGating()
end
end)
end
function CraftingUi.start(_options: { [string]: any }?)
if started then
return
end
started = true
PanelManager.registerPanel({
id = "crafting",
title = "Crafting",
order = 3,
build = buildCrafting,
})
end
return CraftingUi
+18
View File
@@ -219,12 +219,30 @@ end
-- ── Tab discovery + wiring ───────────────────────────────────────────────────
-- Resize tab buttons so they always fit the TabBar, however many there are (authored +
-- code-registered like Crafting). Each takes an equal share; the 6px offset leaves room for the
-- UIListLayout's padding so the row never overflows the bar.
local function fitTabs()
local n = 0
for _ in tabs do
n += 1
end
if n == 0 then
return
end
for _, button in tabs do
button.Size = UDim2.new(1 / n, -6, 1, 0)
button.AutomaticSize = Enum.AutomaticSize.None
end
end
local function wireTabButton(id: string, button: GuiButton)
tabs[id] = button
registered[id] = true
button.Activated:Connect(function()
PanelManager.showTab(id)
end)
fitTabs()
end
local function bindContent(id: string, frame: GuiObject)
+125
View File
@@ -0,0 +1,125 @@
--!nonstrict
--[[
ToolHarvest client. Tool-swing input for harvesting. CLIENT-ONLY.
When the player holds a Tool and clicks, find the Gatherable under the cursor within range and
ask the server to harvest it (the server re-validates everything never trust this). Optional
feedback: a registered swing animation (Assets "ToolAnims" by toolType) and a result sound
(Assets "HarvestSounds"); the node's own reactions (shake/fell) run server-side. Booted by
SurvivorCore.startClient().
]]
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local CollectionService = game:GetService("CollectionService")
local SoundService = game:GetService("SoundService")
assert(RunService:IsClient(), "SurvivorCore.ToolHarvest is client-only — boot it via SurvivorCore.startClient()")
local Assets = require(script.Parent.Parent.foundation.Assets)
local Remotes = require(script.Parent.Parent.shared.Remotes)
local HarvestingConfig = require(script.Parent.Parent.shared.HarvestingConfig)
local ToolHarvest = {}
local GATHERABLE_TAG = "Gatherable"
local started = false
local player = Players.LocalPlayer
local function findGatherableUnderMouse(): Instance?
local target = player:GetMouse().Target
local node: Instance? = target
while node do
if CollectionService:HasTag(node, GATHERABLE_TAG) then
return node
end
node = node.Parent
end
return nil
end
local function inRange(node: Instance): boolean
local char = player.Character
local root = char and char:FindFirstChild("HumanoidRootPart")
if not root or not node:IsA("PVInstance") then
return false
end
local range = tonumber(HarvestingConfig.get().SwingRange) or 12
return (root.Position - node:GetPivot().Position).Magnitude <= range
end
local function playSwing(tool: Tool)
local char = player.Character
local humanoid = char and char:FindFirstChildOfClass("Humanoid")
local animator = humanoid and humanoid:FindFirstChildOfClass("Animator")
if not animator then
return
end
local toolType = tostring(tool:GetAttribute("ToolType") or tool.Name)
local animId = Assets.tryGet("ToolAnims", toolType)
if animId == "" then
return
end
local anim = Instance.new("Animation")
anim.AnimationId = animId
local ok, track = pcall(function()
return animator:LoadAnimation(anim)
end)
if ok and track then
track:Play()
end
end
local function onActivated(tool: Tool)
local node = findGatherableUnderMouse()
if not node or not inRange(node) then
return
end
playSwing(tool)
Remotes.event("HarvestSwing"):FireServer(node)
end
local function bindTool(tool: Instance)
if tool:IsA("Tool") then
tool.Activated:Connect(function()
onActivated(tool :: Tool)
end)
end
end
local function watchCharacter(char: Model)
for _, child in char:GetChildren() do
bindTool(child)
end
char.ChildAdded:Connect(bindTool)
end
function ToolHarvest.start(_options: { [string]: any }?)
if started then
return
end
started = true
if player.Character then
watchCharacter(player.Character)
end
player.CharacterAdded:Connect(watchCharacter)
-- Optional: a short "thunk" on a successful/blocked swing (content-free; skipped if unset).
Remotes.event("HarvestResult").OnClientEvent:Connect(function(result)
local key = if result and result.ok then "hit" else "blocked"
local soundId = Assets.tryGet("HarvestSounds", key)
if soundId ~= "" then
local sound = Instance.new("Sound")
sound.SoundId = soundId
sound.Parent = SoundService
sound:Play()
sound.Ended:Once(function()
sound:Destroy()
end)
end
end)
end
return ToolHarvest
+86 -25
View File
@@ -1,29 +1,101 @@
--[[
Gatherable the flagship creator-owned component.
A creator builds ANY part/mesh, tags it "Gatherable", and sets attributes:
ItemId (string) what it yields
Yield (number) amount per full harvest
HP (number) interactions to deplete
A creator builds ANY part/mesh, tags it "Gatherable", and EITHER:
points it at a named resource def `Resource = "oak_tree"` inheriting item/hp/tool/yield
from `SurvivorCore.Resources` (authored in code or no-code via the admin plugin); or
sets raw attributes per node: `ItemId`, `HP`, `RequireTool`, `YieldMin`/`YieldMax`
(or legacy single `Yield`). Raw attributes override the resource def.
No engine-side content needed: the creator owns the object. Advanced behavior
(custom drops, VFX, falling physics) attaches via Hooks rather than editing core.
Interaction is auto-derived: a node that needs a tool (`RequireTool`) is click-to-swing; a
bare-hand node is a hold-E ProximityPrompt. Set `Interaction` ("prompt"/"tool") to force it.
Both paths route every hit through the server `Harvesting` resolver (validation, per-hit yield,
HP, depletion). Per-type juice (shake, fell, stump) attaches via `SurvivorCore.Gather.onReaction`
— no core edits. Set `DestroyOnDeplete = false` to keep the node so a reaction can transform it.
]]
local Components = require(script.Parent)
local Hooks = require(script.Parent.Parent.foundation.Hooks)
local Registries = require(script.Parent.Parent.registries)
local Harvesting = require(script.Parent.Parent.systems.Harvesting)
-- Resolve the node's effective values from its resource def (if any) + per-node overrides.
local function resolve(values: { [string]: any })
local def = nil
if values.Resource ~= "" then
def = Registries.Resources.get(values.Resource)
end
local item = values.ItemId ~= "" and values.ItemId or (def and def.item) or ""
local hp = values.HP > 0 and values.HP or (def and tonumber(def.hp)) or 3
local requireTool = values.RequireTool ~= "" and values.RequireTool or (def and def.requireTool) or ""
-- Yield: explicit Min/Max win; else legacy single Yield; else the resource def; else 1.
local yMin, yMax
if values.YieldMin > 0 or values.YieldMax > 0 then
yMin, yMax = values.YieldMin, values.YieldMax
elseif values.Yield > 0 then
yMin, yMax = values.Yield, values.Yield
elseif def then
yMin, yMax = tonumber(def.yieldMin) or 1, tonumber(def.yieldMax) or 1
else
yMin, yMax = 1, 1
end
if yMin <= 0 then
yMin = 1
end
if yMax < yMin then
yMax = yMin
end
local interaction = values.Interaction
if interaction ~= "prompt" and interaction ~= "tool" then
interaction = if requireTool ~= "" then "tool" else "prompt" -- "auto"
end
return {
resource = values.Resource ~= "" and values.Resource or nil,
item = item,
hp = hp,
requireTool = requireTool,
yieldMin = yMin,
yieldMax = yMax,
interaction = interaction,
destroyOnDeplete = values.DestroyOnDeplete,
}
end
return Components.define({
name = "Gatherable",
tag = "Gatherable",
attributes = {
ItemId = "unknown",
Yield = 1,
HP = 3,
Resource = "", -- named resource def to inherit from (optional)
ItemId = "", -- override / bare-hand item id
Yield = 0, -- legacy single per-hit yield (use YieldMin/Max for a range)
YieldMin = 0,
YieldMax = 0,
HP = 0, -- 0 = inherit from the resource def (or default 3)
RequireTool = "", -- tool type needed (e.g. "axe"); "" = bare-hand
Interaction = "auto", -- "auto" | "prompt" | "tool"
DestroyOnDeplete = true, -- false keeps the node for a reaction to transform (stump/fell)
},
onSetup = function(instance, values)
instance:SetAttribute("_HP", values.HP)
local r = resolve(values)
-- Stash the resolved values so the server Harvesting resolver reads one source of truth.
instance:SetAttribute("_Resource", r.resource)
instance:SetAttribute("_ItemId", r.item)
instance:SetAttribute("_RequireTool", r.requireTool)
instance:SetAttribute("_YieldMin", r.yieldMin)
instance:SetAttribute("_YieldMax", r.yieldMax)
instance:SetAttribute("_DestroyOnDeplete", r.destroyOnDeplete)
instance:SetAttribute("_HP", r.hp)
instance:SetAttribute("_MaxHP", r.hp) -- for the floating "HP left" bar's ratio
if r.interaction == "tool" then
return -- click-to-swing: the client ToolHarvest controller + HarvestSwing remote drive it
end
-- Bare-hand: a hold-E ProximityPrompt routed through the same server resolver.
local host = if instance:IsA("BasePart") then instance else instance:FindFirstChildWhichIsA("BasePart")
if not host then
warn(`[Gatherable] '{instance:GetFullName()}' has no BasePart to host a prompt`)
@@ -32,24 +104,13 @@ return Components.define({
local prompt = Instance.new("ProximityPrompt")
prompt.ActionText = "Gather"
prompt.ObjectText = tostring(values.ItemId)
prompt.ObjectText = r.resource or (r.item ~= "" and r.item) or "Resource"
prompt.HoldDuration = 0.4
prompt.RequiresLineOfSight = false
prompt.Parent = host
prompt.Triggered:Connect(function(player)
local hp = (instance:GetAttribute("_HP") or 1) - 1
instance:SetAttribute("_HP", hp)
-- 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
Hooks.run("gather:depleted", { instance = instance, player = player, values = values })
instance:Destroy()
end
Harvesting.tryHarvest(instance, player, "prompt")
end)
end,
})
+65
View File
@@ -0,0 +1,65 @@
--[[
Reactions per-resource-type gather "juice" hooks.
Global `Hooks.on("gather:hit", …)` fire for EVERY gatherable. Reactions instead bind behavior to
ONE resource type, so a creator can make `"oak_tree"` shake + drop leaves on each hit and rez a
stump + fell the trunk on depletion, while `"reed"` just sways without filtering inside a
global handler. The harvesting path dispatches these alongside the global Hooks.
SurvivorCore.Gather.onReaction("oak_tree", "depleted", function(ctx)
-- ctx = { instance, player, resource, item, position, granted?, hpLeft? }
rezStumpAndFell(ctx.instance, ctx.position) -- creator content; engine ships none
end)
`event` is the gather event without the "gather:" prefix: "hit" | "depleted" | "blocked".
Handlers run via task.spawn (a throwing reaction can't break the harvest). Registration is safe
any time; reactions typically run server-side so part tweens / model swaps replicate.
]]
local Reactions = {}
-- handlers[resourceId][event] = { fn, ... }
local handlers: { [string]: { [string]: { (ctx: any) -> () } } } = {}
-- Register a reaction for a resource type + event. Returns an unsubscribe function.
function Reactions.on(resourceId: string, event: string, fn: (ctx: any) -> ()): () -> ()
assert(type(resourceId) == "string" and resourceId ~= "", "Reactions.on: resourceId must be a non-empty string")
assert(type(event) == "string" and event ~= "", "Reactions.on: event must be a non-empty string")
assert(type(fn) == "function", "Reactions.on: fn must be a function")
local byEvent = handlers[resourceId]
if not byEvent then
byEvent = {}
handlers[resourceId] = byEvent
end
local list = byEvent[event]
if not list then
list = {}
byEvent[event] = list
end
table.insert(list, fn)
return function()
local i = table.find(list, fn)
if i then
table.remove(list, i)
end
end
end
-- Dispatch a reaction event for a resource type. No-op when nothing is registered.
function Reactions.run(resourceId: string?, event: string, ctx: any)
if resourceId == nil then
return
end
local byEvent = handlers[resourceId]
local list = byEvent and byEvent[event]
if not list then
return
end
for _, fn in list do
task.spawn(fn, ctx)
end
end
return Reactions
+24
View File
@@ -45,6 +45,30 @@ function Registry.new(name: string, options: Options?)
end
end
-- No-code authoring path: populate from a Folder of children where each child's Name is the
-- key and its Attributes become the def's fields. This is what the admin plugin writes (under
-- ReplicatedStorage.SurvivorCoreContent); it complements code register(). Already-registered
-- keys are skipped, and a def that fails validation is warned-and-skipped (never throws), so a
-- bad authored entry can't take down boot.
function self.loadFromFolder(folder: Instance?)
if folder == nil then
return
end
for _, child in folder:GetChildren() do
local key = child.Name
if byId[key] == nil then
local def: { [string]: any } = { [keyField] = key }
for attrName, value in child:GetAttributes() do
def[attrName] = value
end
local ok, err = pcall(self.register, def)
if not ok then
warn(`[{name}] loadFromFolder skipped '{tostring(key)}': {tostring(err)}`)
end
end
end
end
function self.get(key: any): any
return byId[key]
end
+42
View File
@@ -13,10 +13,13 @@
Creator components (tag your own object "Gatherable", set attributes) + lifecycle Hooks.
]]
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Config = require(script.foundation.Config)
local Assets = require(script.foundation.Assets)
local EventBridge = require(script.foundation.EventBridge)
local Hooks = require(script.foundation.Hooks)
local Reactions = require(script.foundation.Reactions)
local Registries = require(script.registries)
local Components = require(script.components)
@@ -41,6 +44,11 @@ require(script.shared.InventoryConfig)
-- Config.override("UI", …) works any time before startClient().
require(script.shared.UiConfig)
-- Define the "Harvesting" (tool-swing range/cooldown/LoS) and "Crafting" (craft time) Config
-- sections, so Config.override(...) works any time before start()/startClient().
require(script.shared.HarvestingConfig)
require(script.shared.CraftingConfig)
local SurvivorCore = {}
SurvivorCore.VERSION = "0.3.0"
@@ -51,9 +59,14 @@ SurvivorCore.Assets = Assets
SurvivorCore.Events = EventBridge
SurvivorCore.Hooks = Hooks
-- Gathering juice: per-resource-type reaction hooks (shake / fell / etc.). Registration is safe
-- any time; the harvesting path dispatches them. SurvivorCore.Gather.onReaction(id, event, fn).
SurvivorCore.Gather = { onReaction = Reactions.on }
-- Content registries
SurvivorCore.Items = Registries.Items
SurvivorCore.Recipes = Registries.Recipes
SurvivorCore.Resources = Registries.Resources
SurvivorCore.Stats = Registries.Stats
SurvivorCore.Achievements = Registries.Achievements
SurvivorCore.Codex = Registries.Codex
@@ -71,6 +84,16 @@ function SurvivorCore.start(_options: { [string]: any }?)
assert(not started, "SurvivorCore.start() called twice")
started = true
-- No-code content: register item/resource defs authored as instances (the admin plugin writes
-- these under ReplicatedStorage.SurvivorCoreContent), complementing code register(). Done first,
-- so Gatherable nodes can resolve their Resource def and systems see the full roster. Recipes
-- carry ingredient lists (not flat attributes), so they stay code-registered for now.
local content = ReplicatedStorage:FindFirstChild("SurvivorCoreContent")
if content then
Registries.Items.loadFromFolder(content:FindFirstChild("Items"))
Registries.Resources.loadFromFolder(content:FindFirstChild("Resources"))
end
-- Load built-in components so their tags are recognised.
require(script.components.Gatherable)
@@ -108,6 +131,21 @@ function SurvivorCore.start(_options: { [string]: any }?)
inventory.start(_options)
SurvivorCore.Inventory = inventory
-- ToolEquip: hotbar → physical Tool bridge (an active tool item becomes a held Roblox Tool).
require(script.systems.ToolEquip).start(_options)
-- Harvesting: the authoritative hit resolver for gatherable nodes (prompt + tool-swing). Booted
-- after Inventory so per-hit yield can be granted (and a full inventory blocks the hit). The
-- module table exposes tryHarvest, which the Gatherable component's prompt path calls in.
local harvesting = require(script.systems.Harvesting)
harvesting.start(_options)
SurvivorCore.Harvesting = harvesting
-- Crafting: server-authoritative hand crafting (consume recipe ingredients → produce output).
local crafting = require(script.systems.Crafting)
crafting.start(_options)
SurvivorCore.Crafting = crafting
return SurvivorCore
end
@@ -136,6 +174,10 @@ function SurvivorCore.startClient(_options: { [string]: any }?)
require(script.client.Hotbar).start(_options)
require(script.client.InventoryUi).start(_options)
require(script.client.CharacterSheet).start(_options)
require(script.client.CraftingUi).start(_options)
-- Tool-swing harvesting input (click an equipped tool at a gatherable node).
require(script.client.ToolHarvest).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).
+3
View File
@@ -12,6 +12,9 @@ local Registries = {}
Registries.Items = Registry.new("Items", { keyField = "id" })
Registries.Recipes = Registry.new("Recipes", { keyField = "id" })
-- A gatherable-resource def: what a tagged Gatherable node *is*. Separate from the inventory
-- item it yields. Fields: { id, item, hp, requireTool, yieldMin, yieldMax }.
Registries.Resources = Registry.new("Resources", { keyField = "id" })
Registries.Stats = Registry.new("Stats", { keyField = "name" })
Registries.Achievements = Registry.new("Achievements", { keyField = "key" })
Registries.Codex = Registry.new("Codex", { keyField = "id" })
+27
View File
@@ -0,0 +1,27 @@
--!nonstrict
--[[
CraftingConfig tuning for hand crafting (issue #4). Defines the "Crafting" Config section so
games retune via `Config.override("Crafting", { ... })`. Read with CraftingConfig.get().
]]
local Config = require(script.Parent.Parent.foundation.Config)
local CraftingConfig = {}
CraftingConfig.SECTION = "Crafting"
CraftingConfig.DEFAULTS = {
-- Craft channel time = BaseCraftTime + TimePerIngredient × (total ingredient units), clamped to
-- MaxCraftTime — so more complex recipes take longer. A recipe may override with `craftTime`.
BaseCraftTime = 0.6,
TimePerIngredient = 0.5,
MaxCraftTime = 8,
}
Config.defineSection(CraftingConfig.SECTION, CraftingConfig.DEFAULTS)
function CraftingConfig.get(): any
return Config.get(CraftingConfig.SECTION) or CraftingConfig.DEFAULTS
end
return CraftingConfig
+29
View File
@@ -0,0 +1,29 @@
--!nonstrict
--[[
HarvestingConfig tuning for tool-swing harvesting (issue #1). SHARED (the server validates
swings; the client uses the range for its target pre-check). Defines the "Harvesting" Config
section so games retune via `Config.override("Harvesting", { ... })`.
Read the merged section with HarvestingConfig.get().
]]
local Config = require(script.Parent.Parent.foundation.Config)
local HarvestingConfig = {}
HarvestingConfig.SECTION = "Harvesting"
HarvestingConfig.DEFAULTS = {
SwingRange = 12, -- studs; max player→node distance for a tool swing (server-validated)
SwingCooldown = 0.45, -- seconds between accepted hits per player (anti-spam)
RequireLineOfSight = true, -- raycast player→node; blocks swings through walls
ShowHealthBar = true, -- a floating "HP left" bar above a node while it's being harvested
}
Config.defineSection(HarvestingConfig.SECTION, HarvestingConfig.DEFAULTS)
function HarvestingConfig.get(): any
return Config.get(HarvestingConfig.SECTION) or HarvestingConfig.DEFAULTS
end
return HarvestingConfig
+125
View File
@@ -0,0 +1,125 @@
--!nonstrict
--[[
RecipeData replicates recipe data from the server to clients. SHARED.
Recipes are registered server-side (`SurvivorCore.Recipes`), so the client's registry copy is
empty. The Crafting tab needs each recipe's ingredients + output to render and gate. Mirroring
ItemData, the engine serialises recipes into a replicated StringValue at start(); the client
reads them back. Recipe defs are already plain serialisable tables (id/station/ingredients/
output), so they cross the wire as-is. Server stays authoritative for the actual craft.
-- server (engine, at start): RecipeData.publish(SurvivorCore.Recipes.getAll())
-- client (engine UI): local list = RecipeData.forStation("hand")
]]
local HttpService = game:GetService("HttpService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RecipeData = {}
local HOLDER_NAME = "SurvivorCoreRecipeData"
export type DisplayRecipe = {
id: string,
station: string?,
ingredients: { { item: string, count: number } },
output: { item: string, count: number }?,
}
local function toDisplay(def: any): DisplayRecipe?
if not def or typeof(def.id) ~= "string" then
return nil
end
local ingredients = {}
if typeof(def.ingredients) == "table" then
for _, ing in def.ingredients do
if typeof(ing) == "table" and typeof(ing.item) == "string" then
table.insert(
ingredients,
{ item = ing.item, count = math.max(1, math.floor(tonumber(ing.count) or 1)) }
)
end
end
end
local output = nil
if typeof(def.output) == "table" and typeof(def.output.item) == "string" then
output = { item = def.output.item, count = math.max(1, math.floor(tonumber(def.output.count) or 1)) }
end
return {
id = def.id,
station = if typeof(def.station) == "string" then def.station else nil,
ingredients = ingredients,
output = output,
}
end
-- ── Server: serialise + replicate ──────────────────────────────────────────
function RecipeData.publish(defs: { any })
local out = {}
for _, def in defs do
local d = toDisplay(def)
if d then
table.insert(out, d)
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: { DisplayRecipe }? = nil
local watching = false
local function rebuild(): { DisplayRecipe }
local result: { DisplayRecipe } = {}
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
table.insert(result, d)
end
end
end
if not watching then
watching = true
holder:GetPropertyChangedSignal("Value"):Connect(function()
cache = nil
end)
end
end
return result
end
local function ensureCache(): { DisplayRecipe }
local c = cache
if not c then
c = rebuild()
cache = c
end
return c
end
function RecipeData.getAll(): { DisplayRecipe }
return ensureCache()
end
function RecipeData.forStation(station: string): { DisplayRecipe }
local out = {}
for _, r in ensureCache() do
if (r.station or "hand") == station then
table.insert(out, r)
end
end
return out
end
return RecipeData
+234
View File
@@ -0,0 +1,234 @@
--!nonstrict
--[[
Crafting server. Hand crafting runtime (issue #4): validate a recipe's ingredients against the
player's inventory, consume them, and produce the output closing gather craft use.
Server-authoritative; station-agnostic (routes by `recipe.station`, "hand" first). Player-driven
crafts CHANNEL for a time that scales with the recipe's complexity, showing a progress bar above
the crafter (server-built, so everyone sees it). Emits craft:start / craft:end / craft:blocked.
Public (SurvivorCore.Crafting): canCraft(player, recipeId) -> (bool, reason?), craft(...) (the
INSTANT consume→produce, for code/tests), and craftTime(recipe).
]]
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local TweenService = game:GetService("TweenService")
assert(RunService:IsServer(), "SurvivorCore.Crafting is server-only — require it via SurvivorCore.start()")
local Inventory = require(script.Parent.Inventory)
local Registries = require(script.Parent.Parent.registries)
local Hooks = require(script.Parent.Parent.foundation.Hooks)
local Remotes = require(script.Parent.Parent.shared.Remotes)
local RecipeData = require(script.Parent.Parent.shared.RecipeData)
local CraftingConfig = require(script.Parent.Parent.shared.CraftingConfig)
local Recipes = Registries.Recipes
local Crafting = {}
local started = false
local channeling: { [Player]: boolean } = {} -- players mid-craft (re-entry guard)
local function ingredientsOf(recipe: any): { { item: string, count: number } }
local out = {}
if typeof(recipe.ingredients) == "table" then
for _, ing in recipe.ingredients do
if typeof(ing) == "table" and typeof(ing.item) == "string" then
table.insert(out, { item = ing.item, count = math.max(1, math.floor(tonumber(ing.count) or 1)) })
end
end
end
return out
end
-- Channel time for a recipe: explicit `craftTime`, else BaseCraftTime + TimePerIngredient × units.
function Crafting.craftTime(recipe: any): number
if typeof(recipe.craftTime) == "number" then
return math.max(0, recipe.craftTime)
end
local cfg = CraftingConfig.get()
local units = 0
for _, ing in ingredientsOf(recipe) do
units += ing.count
end
local t = (tonumber(cfg.BaseCraftTime) or 0.6) + (tonumber(cfg.TimePerIngredient) or 0.5) * units
return math.clamp(t, 0, tonumber(cfg.MaxCraftTime) or 8)
end
-- True if the player can craft this recipe right now (hand station + has all ingredients).
function Crafting.canCraft(player: Player, recipeId: string): (boolean, string?)
local recipe = Recipes.get(recipeId)
if not recipe then
return false, "unknown"
end
if recipe.station ~= nil and recipe.station ~= "hand" then
return false, "station"
end
for _, ing in ingredientsOf(recipe) do
if not Inventory.has(player, ing.item, ing.count) then
return false, "ingredients"
end
end
return true
end
-- Consume ingredients → produce output, refunding on any mid-way failure. No hooks (callers fire).
local function consumeProduce(player: Player, recipe: any): (boolean, string?)
local consumed: { { item: string, count: number } } = {}
local function refund()
for _, c in consumed do
Inventory.add(player, c.item, c.count)
end
end
for _, ing in ingredientsOf(recipe) do
if Inventory.remove(player, ing.item, ing.count) then
table.insert(consumed, ing)
else
refund()
return false, "ingredients"
end
end
local output = recipe.output
if output and typeof(output.item) == "string" then
local count = math.max(1, math.floor(tonumber(output.count) or 1))
if not Inventory.add(player, output.item, count) then
refund() -- no room for the output: put the ingredients back, nothing lost
return false, "full"
end
end
return true, nil
end
-- INSTANT craft (public API for code/tests): validate → consume → produce, with hooks.
function Crafting.craft(player: Player, recipeId: string): boolean
local ok, reason = Crafting.canCraft(player, recipeId)
local recipe = Recipes.get(recipeId)
if not ok or not recipe then
Hooks.run("craft:blocked", { player = player, recipeId = recipeId, reason = reason })
return false
end
Hooks.run("craft:start", { player = player, recipeId = recipeId, recipe = recipe })
local done, why = consumeProduce(player, recipe)
if done then
Hooks.run("craft:end", { player = player, recipeId = recipeId, recipe = recipe })
else
Hooks.run("craft:blocked", { player = player, recipeId = recipeId, reason = why })
end
return done
end
-- A progress bar above the crafter that fills over `duration`. Server-built → everyone sees it.
local function showCraftBar(player: Player, duration: number, label: string): Instance?
local char = player.Character
local head = char and (char:FindFirstChild("Head") or char:FindFirstChild("HumanoidRootPart"))
if not head then
return nil
end
local old = head:FindFirstChild("_CraftBar")
if old then
old:Destroy()
end
local gui = Instance.new("BillboardGui")
gui.Name = "_CraftBar"
gui.Adornee = head
gui.Size = UDim2.fromOffset(150, 30)
gui.StudsOffset = Vector3.new(0, 3, 0)
gui.AlwaysOnTop = true
gui.MaxDistance = 60
local bg = Instance.new("Frame")
bg.Size = UDim2.fromScale(1, 1)
bg.BackgroundColor3 = Color3.fromRGB(20, 22, 28)
bg.BackgroundTransparency = 0.25
bg.BorderSizePixel = 0
bg.Parent = gui
local bgc = Instance.new("UICorner")
bgc.CornerRadius = UDim.new(0, 4)
bgc.Parent = bg
local fill = Instance.new("Frame")
fill.Size = UDim2.fromScale(0, 1)
fill.BackgroundColor3 = Color3.fromRGB(160, 130, 80)
fill.BorderSizePixel = 0
fill.Parent = bg
local fc = Instance.new("UICorner")
fc.CornerRadius = UDim.new(0, 4)
fc.Parent = fill
local text = Instance.new("TextLabel")
text.Size = UDim2.fromScale(1, 1)
text.BackgroundTransparency = 1
text.Text = "Crafting " .. label .. "…"
text.TextColor3 = Color3.fromRGB(245, 245, 245)
text.Font = Enum.Font.GothamBold
text.TextSize = 13
text.Parent = gui
gui.Parent = head
TweenService:Create(fill, TweenInfo.new(duration, Enum.EasingStyle.Linear), { Size = UDim2.fromScale(1, 1) }):Play()
return gui
end
-- Player-initiated craft: channel for craftTime (progress bar), re-validate, then produce.
local function channelCraft(player: Player, recipeId: string)
if channeling[player] then
return -- already crafting
end
local ok, reason = Crafting.canCraft(player, recipeId)
local recipe = Recipes.get(recipeId)
if not ok or not recipe then
Hooks.run("craft:blocked", { player = player, recipeId = recipeId, reason = reason })
return
end
channeling[player] = true
Hooks.run("craft:start", { player = player, recipeId = recipeId, recipe = recipe })
local outName = recipe.output and recipe.output.item or recipeId
local bar = showCraftBar(player, Crafting.craftTime(recipe), tostring(outName))
task.wait(Crafting.craftTime(recipe))
if bar then
bar:Destroy()
end
if not channeling[player] then
return -- player left mid-craft
end
channeling[player] = nil
-- Re-validate after the channel (items/inventory may have changed) before consuming.
if not Crafting.canCraft(player, recipeId) then
Hooks.run("craft:blocked", { player = player, recipeId = recipeId, reason = "ingredients" })
return
end
local done, why = consumeProduce(player, recipe)
if done then
Hooks.run("craft:end", { player = player, recipeId = recipeId, recipe = recipe })
else
Hooks.run("craft:blocked", { player = player, recipeId = recipeId, reason = why })
end
end
function Crafting.start(_options: { [string]: any }?)
if started then
return
end
started = true
-- Replicate recipe data so the Crafting tab can render + gate client-side.
RecipeData.publish(Recipes.getAll())
Remotes.event("CraftRecipe").OnServerEvent:Connect(function(player, recipeId)
if type(recipeId) == "string" then
channelCraft(player, recipeId)
end
end)
Players.PlayerRemoving:Connect(function(player)
channeling[player] = nil
end)
end
return Crafting
+308
View File
@@ -0,0 +1,308 @@
--!nonstrict
--[[
Harvesting server. The single, authoritative hit resolver for gatherable nodes, shared by
BOTH interaction paths: the hold-E ProximityPrompt (Gatherable calls in) and tool-swing
(client click HarvestSwing remote here). This is SurvivorCore's first client-input →
RemoteEvent → server-validation pipeline, deliberately shaped so combat can reuse it.
Per hit: resolve the node's resource roll yield (min..max) try to grant it (BLOCK the hit,
no HP loss, if the inventory is full matches the proven TCE behavior) decrement HP fire
`gather:hit` + the per-type reaction; on depletion fire `gather:depleted` + reaction and destroy
(unless the node opts out via DestroyOnDeplete=false, so a creator can rez a stump/fell a trunk).
Rejected swings fire `gather:blocked`. Started by SurvivorCore.start(). Tuning: "Harvesting".
]]
local Players = game:GetService("Players")
local Workspace = game:GetService("Workspace")
local CollectionService = game:GetService("CollectionService")
local RunService = game:GetService("RunService")
assert(RunService:IsServer(), "SurvivorCore.Harvesting is server-only — require it via SurvivorCore.start()")
local Inventory = require(script.Parent.Inventory)
local Reactions = require(script.Parent.Parent.foundation.Reactions)
local Hooks = require(script.Parent.Parent.foundation.Hooks)
local Remotes = require(script.Parent.Parent.shared.Remotes)
local HarvestingConfig = require(script.Parent.Parent.shared.HarvestingConfig)
local Harvesting = {}
local GATHERABLE_TAG = "Gatherable"
local started = false
local lastHit: { [Player]: number } = {}
-- Resolved per-node state (written by Gatherable.onSetup from the resource def + overrides).
local function readNode(node: Instance)
return {
resource = node:GetAttribute("_Resource"),
item = tostring(node:GetAttribute("_ItemId") or ""),
requireTool = tostring(node:GetAttribute("_RequireTool") or ""),
yieldMin = math.floor(tonumber(node:GetAttribute("_YieldMin")) or 1),
yieldMax = math.floor(tonumber(node:GetAttribute("_YieldMax")) or 1),
destroyOnDeplete = node:GetAttribute("_DestroyOnDeplete") ~= false,
hp = tonumber(node:GetAttribute("_HP")) or 0,
}
end
local function nodePosition(node: Instance): Vector3?
if node:IsA("PVInstance") then
return node:GetPivot().Position
end
return nil
end
local function playerRoot(player: Player): BasePart?
local char = player.Character
if not char then
return nil
end
return (char:FindFirstChild("HumanoidRootPart") or char.PrimaryPart) :: BasePart?
end
local function isAlive(player: Player): boolean
local char = player.Character
local hum = char and char:FindFirstChildOfClass("Humanoid")
return hum ~= nil and hum.Health > 0
end
-- The equipped tool's type ("axe"), from the native Tool's ToolType attribute (fallback Name).
local function equippedToolType(player: Player): string?
local char = player.Character
local tool = char and char:FindFirstChildOfClass("Tool")
if not tool then
return nil
end
local t = tool:GetAttribute("ToolType")
return if typeof(t) == "string" and t ~= "" then t else tool.Name
end
local function hasLineOfSight(root: BasePart, node: Instance, target: Vector3): boolean
local params = RaycastParams.new()
params.FilterType = Enum.RaycastFilterType.Exclude
params.FilterDescendantsInstances = { root.Parent :: Instance, node }
local origin = root.Position
local result = Workspace:Raycast(origin, target - origin, params)
return result == nil -- nothing between the player and the node
end
local function fireResult(player: Player, payload: { [string]: any })
Remotes.event("HarvestResult"):FireClient(player, payload)
end
local function fireBlocked(player: Player, node: Instance, resource: any, reason: string)
local ctx = { instance = node, player = player, resource = resource, reason = reason }
Hooks.run("gather:blocked", ctx)
Reactions.run(resource, "blocked", ctx)
fireResult(player, { ok = false, reason = reason, resource = resource })
end
local HP_BAR_NAME = "_HarvestHP"
local function nodeAdornee(node: Instance): BasePart?
if node:IsA("BasePart") then
return node
end
return node:FindFirstChildWhichIsA("BasePart")
end
-- A small floating "HP left" bar above a node while it's being harvested, so the player can see
-- progress. Server-built (everyone nearby sees it), config-gated, auto-fades after a few idle
-- seconds. Removed on depletion (and destroyed with the node if it's destroyed).
local function updateHealthBar(node: Instance, hpLeft: number, maxHp: number)
if not HarvestingConfig.get().ShowHealthBar then
return
end
local adornee = nodeAdornee(node)
if not adornee then
return
end
local gui = node:FindFirstChild(HP_BAR_NAME)
if not gui then
gui = Instance.new("BillboardGui")
gui.Name = HP_BAR_NAME
gui.Adornee = adornee
gui.Size = UDim2.fromOffset(110, 16)
-- Sit a little above the node's top for short nodes, but cap the height for tall ones (a
-- 14-stud tree shouldn't put its bar 16 studs up, above the canopy and out of view).
local aboveBase = math.min(adornee.Size.Y + 1.5, 8)
gui.StudsOffset = Vector3.new(0, aboveBase - adornee.Size.Y / 2, 0)
gui.AlwaysOnTop = true
gui.MaxDistance = 60
local bg = Instance.new("Frame")
bg.Name = "BG"
bg.Size = UDim2.fromScale(1, 1)
bg.BackgroundColor3 = Color3.fromRGB(20, 22, 28)
bg.BackgroundTransparency = 0.3
bg.BorderSizePixel = 0
bg.Parent = gui
local corner = Instance.new("UICorner")
corner.CornerRadius = UDim.new(0, 4)
corner.Parent = bg
local fill = Instance.new("Frame")
fill.Name = "Fill"
fill.Size = UDim2.fromScale(1, 1)
fill.BackgroundColor3 = Color3.fromRGB(90, 200, 110)
fill.BorderSizePixel = 0
fill.Parent = bg
local fc = Instance.new("UICorner")
fc.CornerRadius = UDim.new(0, 4)
fc.Parent = fill
local label = Instance.new("TextLabel")
label.Name = "Label"
label.Size = UDim2.fromScale(1, 1)
label.BackgroundTransparency = 1
label.TextColor3 = Color3.fromRGB(255, 255, 255)
label.Font = Enum.Font.GothamBold
label.TextSize = 12
label.Parent = gui
gui.Parent = node
end
local ratio = if maxHp > 0 then math.clamp(hpLeft / maxHp, 0, 1) else 0
local bg = gui:FindFirstChild("BG")
local fill = bg and bg:FindFirstChild("Fill")
if fill then
(fill :: Frame).Size = UDim2.fromScale(ratio, 1)
end
local label = gui:FindFirstChild("Label")
if label then
(label :: TextLabel).Text = `{hpLeft} / {maxHp}`
end
-- Debounced fade-out: remove the bar after a few idle seconds (a newer hit cancels this).
local token = (tonumber(node:GetAttribute("_HPBarToken")) or 0) + 1
node:SetAttribute("_HPBarToken", token)
task.delay(4, function()
if node.Parent and node:GetAttribute("_HPBarToken") == token then
local g = node:FindFirstChild(HP_BAR_NAME)
if g then
g:Destroy()
end
end
end)
end
local function removeHealthBar(node: Instance)
local g = node:FindFirstChild(HP_BAR_NAME)
if g then
g:Destroy()
end
end
-- Resolve one harvest interaction. `mode` is "prompt" (proximity already enforced) or "tool"
-- (range + line-of-sight enforced here). Returns true if a hit landed.
function Harvesting.tryHarvest(node: Instance, player: Player, mode: string): boolean
if not node or not node.Parent or not CollectionService:HasTag(node, GATHERABLE_TAG) then
return false
end
if not isAlive(player) then
return false
end
local info = readNode(node)
if info.hp <= 0 then
return false -- already depleted (mid-destroy)
end
-- Tool gate (both modes; this is also the prompt-path "requireTool" precursor).
if info.requireTool ~= "" then
local toolType = equippedToolType(player)
if toolType ~= info.requireTool then
fireBlocked(player, node, info.resource, "tool")
return false
end
end
-- Range + line-of-sight (tool mode; the ProximityPrompt already guarantees these for prompts).
local pos = nodePosition(node)
local root = playerRoot(player)
if mode == "tool" then
if not pos or not root then
return false
end
local cfg = HarvestingConfig.get()
if (root.Position - pos).Magnitude > (tonumber(cfg.SwingRange) or 12) then
return false -- out of range; silent (client pre-check should have caught it)
end
if cfg.RequireLineOfSight and not hasLineOfSight(root, node, pos) then
return false
end
end
-- Per-player cooldown (anti-spam for both paths).
local now = os.clock()
local cooldown = tonumber(HarvestingConfig.get().SwingCooldown) or 0.45
if lastHit[player] and now - lastHit[player] < cooldown then
return false
end
-- Roll + grant the yield BEFORE spending HP. If the inventory can't take it, block the hit so
-- nothing is lost and the node isn't wasted (proven TCE behavior).
local amount = info.yieldMax > info.yieldMin and math.random(info.yieldMin, info.yieldMax) or info.yieldMin
amount = math.max(0, amount)
if info.item ~= "" and amount > 0 then
if not Inventory.add(player, info.item, amount) then
fireBlocked(player, node, info.resource, "full")
return false
end
end
lastHit[player] = now
local hp = info.hp - 1
node:SetAttribute("_HP", hp)
updateHealthBar(node, math.max(0, hp), tonumber(node:GetAttribute("_MaxHP")) or info.hp)
local ctx = {
instance = node,
player = player,
resource = info.resource,
item = info.item,
granted = amount,
hpLeft = hp,
position = pos,
}
Hooks.run("gather:hit", ctx)
Reactions.run(info.resource, "hit", ctx)
fireResult(player, { ok = true, resource = info.resource, granted = amount, hpLeft = hp })
if hp <= 0 then
local dctx = {
instance = node,
player = player,
resource = info.resource,
item = info.item,
position = pos,
}
Hooks.run("gather:depleted", dctx)
Reactions.run(info.resource, "depleted", dctx)
removeHealthBar(node)
if info.destroyOnDeplete then
node:Destroy()
end
end
return true
end
function Harvesting.start(_options: { [string]: any }?)
if started then
return
end
started = true
-- Create HarvestResult eagerly (server→client feedback) so clients can connect at startup,
-- not only after the first harvest fires it.
Remotes.event("HarvestResult")
-- Tool-swing: the client sends the node it clicked; the server re-validates everything.
Remotes.event("HarvestSwing").OnServerEvent:Connect(function(player, node)
if typeof(node) == "Instance" then
Harvesting.tryHarvest(node, player, "tool")
end
end)
Players.PlayerRemoving:Connect(function(player)
lastHit[player] = nil
end)
end
return Harvesting
-16
View File
@@ -667,21 +667,6 @@ local function initPlayer(player: Player)
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()
@@ -746,7 +731,6 @@ function Inventory.start(_options: { [string]: any }?)
lastUse[player] = nil
end)
Hooks.on("gather:depleted", onGatherDepleted)
wireRemotes()
end
+142
View File
@@ -0,0 +1,142 @@
--!nonstrict
--[[
ToolEquip server. The hotbar physical Tool bridge.
The hotbar tracks an active slot as data; this turns it into a real, held Roblox `Tool` so
tool-swing harvesting (and later combat) can read `character:FindFirstChildOfClass("Tool")`.
When the active hotbar item's def has a `toolType`, a Tool is equipped into the character with a
`ToolType` attribute (what gatherable nodes match against); switching slots / unequipping / a
non-tool clears it, and it re-equips on respawn.
Content-free: the Tool VISUAL is cloned from a creator-supplied template under
`ReplicatedStorage.SurvivorCoreContent.Tools` (a Tool named by item id); with no template a
minimal default Tool+Handle is built. Started by SurvivorCore.start().
]]
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
assert(RunService:IsServer(), "SurvivorCore.ToolEquip is server-only — require it via SurvivorCore.start()")
local Registries = require(script.Parent.Parent.registries)
local InventoryTypes = require(script.Parent.Parent.shared.InventoryTypes)
local ToolEquip = {}
local TOOL_MARKER = "_SurvivorCoreTool"
local started = false
local function findTemplate(itemId: string): Tool?
local content = ReplicatedStorage:FindFirstChild("SurvivorCoreContent")
local tools = content and content:FindFirstChild("Tools")
local tmpl = tools and tools:FindFirstChild(itemId)
if tmpl and tmpl:IsA("Tool") then
return tmpl
end
return nil
end
local function buildDefaultTool(name: string): Tool
local tool = Instance.new("Tool")
tool.Name = name
tool.RequiresHandle = true
tool.CanBeDropped = false
local handle = Instance.new("Part")
handle.Name = "Handle"
handle.Size = Vector3.new(1, 1, 3)
handle.Parent = tool
return tool
end
local function makeTool(def: any, itemId: string): Tool
local tmpl = findTemplate(itemId)
local tool = if tmpl then tmpl:Clone() else buildDefaultTool(def.name or itemId)
tool.Name = def.name or itemId
tool:SetAttribute(TOOL_MARKER, true)
tool:SetAttribute("_ItemId", itemId)
tool:SetAttribute("ToolType", def.toolType)
return tool
end
-- Remove any engine-spawned Tool the player currently holds (character or backpack).
local function clearEngineTool(player: Player)
local places = { player.Character, player:FindFirstChildOfClass("Backpack") }
for _, place in places do
if place then
for _, child in place:GetChildren() do
if child:IsA("Tool") and child:GetAttribute(TOOL_MARKER) then
child:Destroy()
end
end
end
end
end
local function currentEngineTool(player: Player): Tool?
local char = player.Character
if char then
for _, child in char:GetChildren() do
if child:IsA("Tool") and child:GetAttribute(TOOL_MARKER) then
return child :: Tool
end
end
end
return nil
end
-- Sync the held Tool to the active hotbar slot.
local function refresh(player: Player)
local char = player.Character
if not char or not char:FindFirstChildOfClass("Humanoid") then
return
end
local slot = tonumber(player:GetAttribute(InventoryTypes.HOTBAR_EQUIPPED_ATTR))
local itemId = ""
if slot then
itemId = tostring(player:GetAttribute(InventoryTypes.hotbarSlotAttr(slot)) or "")
end
local def = itemId ~= "" and Registries.Items.get(itemId) or nil
local toolType = def and def.toolType
-- Already holding the right tool? Leave it (avoids re-equip flicker on a re-press).
local held = currentEngineTool(player)
if toolType and held and held:GetAttribute("_ItemId") == itemId then
return
end
clearEngineTool(player)
if not toolType then
return -- active slot isn't a tool; nothing to hold
end
local tool = makeTool(def, itemId)
tool.Parent = char -- parenting a Tool to the character equips (holds) it
end
local function addPlayer(player: Player)
player:GetAttributeChangedSignal(InventoryTypes.HOTBAR_EQUIPPED_ATTR):Connect(function()
refresh(player)
end)
player.CharacterAdded:Connect(function()
task.defer(refresh, player)
end)
if player.Character then
task.defer(refresh, player)
end
end
function ToolEquip.start(_options: { [string]: any }?)
if started then
return
end
started = true
for _, player in Players:GetPlayers() do
addPlayer(player)
end
Players.PlayerAdded:Connect(addPlayer)
end
return ToolEquip