mirror of
https://github.com/TemujinCalidius/SurvivorCore.git
synced 2026-08-14 09:02:29 +00:00
Closes the gather → craft loop and lays the no-code content layer. - Tool-swing harvesting (#1): equip a hotbar tool (a real Tool via the new ToolEquip bridge), click a node, server-validated hit (range / line-of-sight / equipped tool / cooldown) granting a per-hit random yield, blocking the hit when the inventory is full. Bare-hand hold-E kept. The engine's first client-input → RemoteEvent → server-validation pipeline (combat reuses it). A floating HP bar shows a node's remaining hits. - Hand crafting (#4): server-authoritative consume → produce with a complexity-scaled craft channel + a progress bar above the crafter; a Crafting tab lists hand recipes and gates each on what you carry. - No-code content (#11, Builder first slice): a Resources registry + a content-from-instances loader (Registry.loadFromFolder reads a SurvivorCoreContent folder at start), Gatherable binds to a named Resource, and a per-resource-type reaction API (reed sways, tree fells + leaves a stump). The admin plugin becomes one widget with Stats + Content tabs to create/edit items + gatherables, including "Add to World". New hooks: gather:blocked, craft:start / craft:end / craft:blocked. Docs + CHANGELOG. Engine stays content-free; the demo supplies the sample content. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
2b705487e5
commit
d679850f90
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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`.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user