diff --git a/CHANGELOG.md b/CHANGELOG.md index 63dd960..a7395d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/demo/server/Boot.server.luau b/demo/server/Boot.server.luau index 6ff196d..0d5d6f8 100644 --- a/demo/server/Boot.server.luau +++ b/demo/server/Boot.server.luau @@ -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 = "" 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) diff --git a/docs/admin-plugin.md b/docs/admin-plugin.md index 274668f..692bd36 100644 --- a/docs/admin-plugin.md +++ b/docs/admin-plugin.md @@ -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) diff --git a/docs/content-authoring.md b/docs/content-authoring.md new file mode 100644 index 0000000..22a524b --- /dev/null +++ b/docs/content-authoring.md @@ -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`. diff --git a/docs/crafting.md b/docs/crafting.md new file mode 100644 index 0000000..f324f6d --- /dev/null +++ b/docs/crafting.md @@ -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). diff --git a/docs/harvesting.md b/docs/harvesting.md new file mode 100644 index 0000000..336c23b --- /dev/null +++ b/docs/harvesting.md @@ -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. diff --git a/plugin/ContentAdmin.luau b/plugin/ContentAdmin.luau new file mode 100644 index 0000000..8ce34bb --- /dev/null +++ b/plugin/ContentAdmin.luau @@ -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/ 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 diff --git a/plugin/ContentAdminUi.luau b/plugin/ContentAdminUi.luau new file mode 100644 index 0000000..7aef214 --- /dev/null +++ b/plugin/ContentAdminUi.luau @@ -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 diff --git a/plugin/init.server.luau b/plugin/init.server.luau index f441d35..f81d5c5 100644 --- a/plugin/init.server.luau +++ b/plugin/init.server.luau @@ -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() diff --git a/src/client/CraftingUi.luau b/src/client/CraftingUi.luau new file mode 100644 index 0000000..c4bf7bb --- /dev/null +++ b/src/client/CraftingUi.luau @@ -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 diff --git a/src/client/PanelManager.luau b/src/client/PanelManager.luau index 4259c56..a7befb0 100644 --- a/src/client/PanelManager.luau +++ b/src/client/PanelManager.luau @@ -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) diff --git a/src/client/ToolHarvest.luau b/src/client/ToolHarvest.luau new file mode 100644 index 0000000..94d91fd --- /dev/null +++ b/src/client/ToolHarvest.luau @@ -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 diff --git a/src/components/Gatherable.luau b/src/components/Gatherable.luau index 9119b06..fc57002 100644 --- a/src/components/Gatherable.luau +++ b/src/components/Gatherable.luau @@ -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, }) diff --git a/src/foundation/Reactions.luau b/src/foundation/Reactions.luau new file mode 100644 index 0000000..1f55ca1 --- /dev/null +++ b/src/foundation/Reactions.luau @@ -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 diff --git a/src/foundation/Registry.luau b/src/foundation/Registry.luau index 80bb739..077fccd 100644 --- a/src/foundation/Registry.luau +++ b/src/foundation/Registry.luau @@ -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 diff --git a/src/init.luau b/src/init.luau index 3bf4576..337bdea 100644 --- a/src/init.luau +++ b/src/init.luau @@ -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). diff --git a/src/registries/init.luau b/src/registries/init.luau index 95f8542..72cdbf5 100644 --- a/src/registries/init.luau +++ b/src/registries/init.luau @@ -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" }) diff --git a/src/shared/CraftingConfig.luau b/src/shared/CraftingConfig.luau new file mode 100644 index 0000000..6d26ec6 --- /dev/null +++ b/src/shared/CraftingConfig.luau @@ -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 diff --git a/src/shared/HarvestingConfig.luau b/src/shared/HarvestingConfig.luau new file mode 100644 index 0000000..5112e18 --- /dev/null +++ b/src/shared/HarvestingConfig.luau @@ -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 diff --git a/src/shared/RecipeData.luau b/src/shared/RecipeData.luau new file mode 100644 index 0000000..1602b0e --- /dev/null +++ b/src/shared/RecipeData.luau @@ -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 diff --git a/src/systems/Crafting.luau b/src/systems/Crafting.luau new file mode 100644 index 0000000..ea70bb0 --- /dev/null +++ b/src/systems/Crafting.luau @@ -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 diff --git a/src/systems/Harvesting.luau b/src/systems/Harvesting.luau new file mode 100644 index 0000000..ba64160 --- /dev/null +++ b/src/systems/Harvesting.luau @@ -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 diff --git a/src/systems/Inventory.luau b/src/systems/Inventory.luau index 05b9b13..5bd32b2 100644 --- a/src/systems/Inventory.luau +++ b/src/systems/Inventory.luau @@ -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 diff --git a/src/systems/ToolEquip.luau b/src/systems/ToolEquip.luau new file mode 100644 index 0000000..29c9ed1 --- /dev/null +++ b/src/systems/ToolEquip.luau @@ -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