From 29bd20a93f8305a43b5bf9538dde600de3c5748e Mon Sep 17 00:00:00 2001 From: Samuel Lison Date: Thu, 30 Jul 2026 17:14:52 +1000 Subject: [PATCH 1/5] feat(builder): schema-driven Build page for world objects (#11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Select a Part or Model in Studio, answer "what is this object?", fill a form — it becomes a gatherable node, a mob or a quest giver. Closes the gap between authoring a def and setting up a world object, which until now meant knowing to tag a part and hand-typing PascalCase attributes in the property panel. Engine — components can declare an attribute SCHEMA: - src/components/Schema.luau (new): AttributeSpec/Display/ComponentSchema types, normalize/defaults/get/list, and the schemas for Gatherable, Mob, QuestGiver. Dependency-free ON PURPOSE: the plugin requires it live at edit time, and the component modules themselves can't be required there (Harvesting asserts IsServer; Remotes creates instances in ReplicatedStorage). - Components.define now accepts EITHER the legacy `attr = default` map or a schema array, normalizing both to one ordered spec list; bind() reads the derived default map, so binding is byte-identical. Legacy maps are sorted, as `pairs` order is arbitrary and would make a UI jitter. New getSchema/ listSchemas. The three shipped components pull name/tag/display/attributes from the schema; their onSetup bodies are untouched (defaults verified identical, all 23 attributes). Plugin — the Build page: - Field.luau (new): coerce/format/equalsDefault, lifted from ConfigAdmin (which now delegates), shared by every schema-driven editor. - FieldRow.luau (new): the shared [○/●] label … control + help row, including a ⌄ picker that cycles authored ids for fields declaring `ref`. - BuildAdmin.luau (new): live schema read with three distinct empty states, selection/eligibility/identify, deltas-only attribute writes, applyType (tag + clear any other component) and clear. - BuildAdminUi.luau (new): chooser cards, grouped form, multi-select apply, stale-bind-marker warning, SelectionChanged-driven refresh. - init.server.luau: record()-wrapped buildActions + the page. Docs: docs/admin-plugin.md Build section + a 60-second walkthrough, docs/extending.md schema guide, a CONTRIBUTING rule that new creator components declare one, CHANGELOG. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 18 ++ CONTRIBUTING.md | 5 +- docs/admin-plugin.md | 44 +++ docs/extending.md | 38 +++ plugin/BuildAdmin.luau | 298 +++++++++++++++++++ plugin/BuildAdminUi.luau | 502 +++++++++++++++++++++++++++++++++ plugin/ConfigAdmin.luau | 107 +------ plugin/Field.luau | 129 +++++++++ plugin/FieldRow.luau | 160 +++++++++++ plugin/init.server.luau | 39 +++ sourcemap-plugin.json | 2 +- src/components/Gatherable.luau | 24 +- src/components/Mob.luau | 24 +- src/components/QuestGiver.luau | 14 +- src/components/Schema.luau | 402 ++++++++++++++++++++++++++ src/components/init.luau | 53 +++- 16 files changed, 1716 insertions(+), 143 deletions(-) create mode 100644 plugin/BuildAdmin.luau create mode 100644 plugin/BuildAdminUi.luau create mode 100644 plugin/Field.luau create mode 100644 plugin/FieldRow.luau create mode 100644 src/components/Schema.luau diff --git a/CHANGELOG.md b/CHANGELOG.md index 22099e0..16c3b58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ 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 +- **Build: no-code world objects** (#11) — select a Part or Model in Studio, answer **"what is this + object?"**, and fill a form: it becomes a gatherable node, a mob or a quest giver, tag and + attributes applied for you. The form is generated from the engine's own **component schema**, so + fields, defaults and help text can never drift from what runs — and **any component that declares + a schema gets a form for free, with no per-component UI code**. Fields that reference authored + content (a resource, a mob type, a quest) offer a picker instead of asking you to remember ids; + writes are deltas-only (an attribute exists only where you diverged from the default); *Change + type* and *Clear* are the honest inverses; multi-select applies in bulk as one undo step. +- **`Components.define` accepts an attribute schema** — `attributes` may now be an array of + `{ attr, kind, label, default, help, choices, min/max, group, ref }` specs (plus a `display` block + naming the component for the chooser) instead of the `attr = default` shorthand. **Both forms bind + identically**; existing components and game code are unaffected. New `Components.getSchema` / + `listSchemas`, and `src/components/Schema.luau` — a dependency-free module the Studio plugin reads + live at edit time. See [docs/extending.md](docs/extending.md). + ## 0.9.0 — 2026-07-30 ### Security diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1781892..a3cf9c2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -80,7 +80,10 @@ components, hooks, and the foundation. See [Architecture Overview](#architecture - **Registries** — developers call `register()` from code (`Items`, `Recipes`, `Stats`, `Mobs`, …). - **Components** — creators tag their own objects and set Attributes (`Gatherable`, and the - component family that follows). + component family that follows). **A new creator-facing component must declare an attribute + schema** (a `Schema.COMPONENTS` entry in `src/components/Schema.luau`, plus a `display` block), + so the admin plugin's **Build** page can render its setup form — no creator should have to + memorise attribute names. See [docs/extending.md](docs/extending.md#declaring-a-schema-so-the-builder-can-render-a-form). - **Extend via Hooks, don't fork.** Game-specific flourish (felling physics, station VFX, custom drops) belongs in a `Hooks.on(...)` handler in the *game*, not baked into the engine. If you need a new extension point, add a `Hooks.run("…")` call and document it. diff --git a/docs/admin-plugin.md b/docs/admin-plugin.md index 6cc31fe..455b285 100644 --- a/docs/admin-plugin.md +++ b/docs/admin-plugin.md @@ -5,6 +5,9 @@ Attributes in the Explorer. One **SurvivorCore › Studio** toolbar button opens window** (drag-dock it anywhere) with a sidebar of editors: - **Overview** — is the engine synced, what content exists (click through), how the model works. +- **Build** — select a Part or Model in the viewport, answer **"what is this object?"**, and fill a + form: it becomes a gatherable node, a mob, or a quest giver. No tags to memorise, no attribute + names to type. See [Build: world objects](#build-world-objects) below. - **Survival Stats** — tune the survival-stat rates/thresholds/HUD on the `SurvivalStatsConfig` instance (the deltas-only, locked model below), with an Edit-mode **HUD preview**. - **Engine Config** — every engine Config section (issue #21): Movement (speeds, energy, audio, @@ -66,6 +69,47 @@ the window. > Rojo-synced place, so reconnect Rojo and re-sync (or save the place before restarting) to bring > the engine back. +## Build: world objects + +The **Build** page is how a creator turns their *own* geometry into engine content. Select a Part or +Model and it asks **"What is this object?"** — pick **Gatherable**, **Mob (creature)** or **Quest +giver** and the plugin applies the tag for you, then renders that component's setup form. + +The form is generated from the **engine's own component schema** +([`src/components/Schema.luau`](../src/components/Schema.luau)) — the same source the engine binds +from — so the fields, their defaults and their help text can never drift from what actually runs. +**Any component that declares a schema gets a form here automatically; there is no per-component UI +code.** + +### Your first tree, in 60 seconds + +1. **Author the resource.** *Content › Gatherables › + Create* → id `oak_tree`, yields item `wood`, + HP 4. +2. **Build the mesh.** Insert a Part (or your own tree model) and select it. +3. **Say what it is.** Open **Build** → click the **Gatherable** card. +4. **Point it at the resource.** In the form, click the **⌄** next to *Resource* until it reads + `oak_tree` — that's it; item, HP, tool and yield all follow the entry. +5. **Play.** Walk up and hold **E**. Wood lands in your inventory. + +Change your mind later: **Change type** swaps the component (clearing the old one's settings), and +**Clear** removes the tag and every attribute the engine added. + +### How it behaves + +- **Deltas only.** Applying a component writes **no attributes** — the object follows every default + until you change something, and a changed field that you set back to its default is removed again. + The ○/● dot next to each field tells you which is which. +- **Pickers, not memory.** Fields that reference authored content (a resource, a mob type, a quest) + offer a **⌄** that cycles the ids you've created in *Content*. +- **Eligibility is enforced.** *Mob* needs a Model (Humanoid + PrimaryPart), so it's dimmed with the + reason when a plain Part is selected. +- **Multi-select** applies to every eligible object at once, as one undo step. +- **Undo** — every apply, clear and field edit is a single Studio undo step. +- **Needs SurvivorCore ≥ 0.9.** Against an older engine the page explains that instead of failing. + +> Components a game defines **at runtime** (in its own code) can't appear here — the plugin reads a +> static module in Edit mode, where your server scripts haven't run. Tag those by hand as before. + ## Survival Stats Each stat shows the seven tunable fields, each displaying its **effective** value (your override if diff --git a/docs/extending.md b/docs/extending.md index c8f1fc8..2fc975f 100644 --- a/docs/extending.md +++ b/docs/extending.md @@ -117,6 +117,44 @@ SurvivorCore.Components.define({ tagged and keeps binding new instances as they appear. Each instance is bound once (guarded by an internal `_scBound` attribute). +Attributes prefixed with **`_`** are engine-internal (the `_scBound` bind marker, and any values a +component resolves and stashes for its server system). Never author them by hand. + +### Declaring a schema (so the Builder can render a form) + +`attributes` also accepts a **schema array**, which adds the kind, label, help text and options for +each attribute. That's what lets the Studio plugin's **Build** page render a setup form for your +component — select a part, pick your component, fill the fields — with **no UI code on your side**: + +```lua +SurvivorCore.Components.define({ + name = "Campfire", + tag = "Campfire", + display = { + title = "Campfire", + summary = "A fire players light for warmth and cooking.", + instance = "any", -- or "Model" / "BasePart" — what the Builder will let you tag + }, + attributes = { + { attr = "FuelSeconds", kind = "number", label = "Fuel (seconds)", default = 60, min = 0, + help = "How long one load of fuel burns." }, + { attr = "Lit", kind = "boolean", label = "Starts lit", default = false }, + }, + onSetup = function(instance, values) + -- `values` is identical either way: the instance's attribute, else the default. + end, +}) +``` + +Both forms bind identically — the shorthand map is still supported and nothing changes for +components that use it. Field `kind` is `number` | `boolean` | `string` | `enum` (with `choices`); +a field may also carry `min`/`max`/`integer`, a `placeholder`, a `group` (form section), and `ref` +(a content category whose authored ids the Builder offers as a picker). The engine's own components +declare theirs in [`src/components/Schema.luau`](../src/components/Schema.luau). + +**Convention:** every creator-facing component should declare a schema, so it is Builder-drivable +rather than requiring the creator to know attribute names. + --- ## 3. Hooks — react to engine lifecycle events diff --git a/plugin/BuildAdmin.luau b/plugin/BuildAdmin.luau new file mode 100644 index 0000000..457b5ac --- /dev/null +++ b/plugin/BuildAdmin.luau @@ -0,0 +1,298 @@ +--!nonstrict +--[[ + BuildAdmin — headless logic for the Build page (issue #11): turn a selected Part or Model into + a SurvivorCore world object (a Gatherable node, a Mob, a quest giver) by applying its tag and + attributes, and edit those attributes afterwards. + + The SCHEMA comes from the ENGINE — readSchema() requires + ReplicatedStorage.SurvivorCore.components.Schema live, so the plugin and the engine can never + disagree about a component's fields. (That module is dependency-free precisely so the plugin can + require it: the component modules themselves pull in server systems and must never be required + in Edit mode.) + + Writes are DELTAS-ONLY, the same rule as the Stats and Engine Config editors: + • an attribute exists IFF its value differs from the component's default + • typing the default back (or blanking the box) REMOVES the attribute + • unset attributes keep following engine defaults across engine updates + + NOTE the two namespaces: this module writes PascalCase INSTANCE attributes (`ItemId`, `HP`) on + world objects. The Content editor writes lowercase DEF fields (`item`, `hp`) on + SurvivorCoreContent Configurations. A `ref` on a field links them (pick a def id) — they are + never the same thing. + + No `plugin` global and no ChangeHistory here: the plugin main wraps every call in record(). +]] + +local CollectionService = game:GetService("CollectionService") +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local Selection = game:GetService("Selection") + +local Field = require(script.Parent.Field) + +local BuildAdmin = {} + +local BOUND_ATTR = "_scBound" -- the engine's runtime bind marker + +-- ── Schema (live from the engine) ──────────────────────────────────────────── + +local function findSchemaModule(): ModuleScript? + local engine = ReplicatedStorage:FindFirstChild("SurvivorCore") + local components = engine and engine:FindFirstChild("components") + local module = components and components:FindFirstChild("Schema") + if module and module:IsA("ModuleScript") then + return module + end + return nil +end + +-- { ok = true, source, components = {ComponentSchema}, byTag } | { ok = false, reason, components = {} } +-- Never errors. Re-read on every refresh so a re-synced engine self-heals. +function BuildAdmin.readSchema(): any + local module = findSchemaModule() + if not module then + return { + ok = false, + components = {}, + reason = "SurvivorCore engine not found in this place (expected" + .. " ReplicatedStorage.SurvivorCore.components.Schema — sync or insert the engine, then Refresh).", + } + end + local ok, schema = pcall(require, module) + if not ok or typeof(schema) ~= "table" or typeof(schema.COMPONENTS) ~= "table" then + return { + ok = false, + components = {}, + reason = "Found the engine, but it declares no component schemas — Build needs" + .. " SurvivorCore ≥ 0.9. Update the engine, then Refresh.", + } + end + + -- Build the ordered list defensively: ORDER may name an entry that no longer exists, and a + -- NEWER engine may add entries ORDER doesn't cover. Include both, ORDER first. + local list, seen = {}, {} + for _, name in schema.ORDER or {} do + local entry = schema.COMPONENTS[name] + if typeof(entry) == "table" and typeof(entry.tag) == "string" then + seen[name] = true + table.insert(list, entry) + end + end + local rest = {} + for name, entry in schema.COMPONENTS do + if not seen[name] and typeof(entry) == "table" and typeof(entry.tag) == "string" then + table.insert(rest, name) + end + end + table.sort(rest) + for _, name in rest do + table.insert(list, schema.COMPONENTS[name]) + end + + if #list == 0 then + return { ok = false, components = {}, reason = "This engine declares no components." } + end + + local byTag = {} + for _, entry in list do + byTag[entry.tag] = entry + end + return { ok = true, source = module:GetFullName(), components = list, byTag = byTag } +end + +-- Attributes are only meaningful with a label/kind; tolerate a schema field we don't understand. +local function specsOf(schema: any): { any } + return if typeof(schema) == "table" and typeof(schema.attributes) == "table" then schema.attributes else {} +end + +function BuildAdmin.displayOf(schema: any): any + local d = schema and schema.display + if typeof(d) ~= "table" then + return { title = (schema and schema.name) or "Component", summary = "" } + end + return d +end + +-- ── Selection ──────────────────────────────────────────────────────────────── + +function BuildAdmin.classify(instance: Instance): string + if instance:IsA("Model") then + return "Model" + elseif instance:IsA("BasePart") then + return "BasePart" + end + return "other" +end + +-- Can this component be applied to this instance? Returns (ok, reason). +function BuildAdmin.isEligible(instance: Instance, schema: any): (boolean, string?) + local want: string = tostring(BuildAdmin.displayOf(schema).instance or "any") + local got: string = BuildAdmin.classify(instance) + if got == "other" then + return false, "select a Part or a Model" + end + if want == "Model" then + return got == "Model", "needs a Model (Humanoid + PrimaryPart)" + elseif want == "BasePart" then + return got == "BasePart", "needs a single Part" + end + return true, nil -- "any" +end + +-- { instances, eligible, count } — `eligible` are Parts/Models (anything a component could go on). +function BuildAdmin.getSelection(): any + local instances = Selection:Get() + local eligible = {} + for _, instance in instances do + if BuildAdmin.classify(instance) ~= "other" then + table.insert(eligible, instance) + end + end + return { instances = instances, eligible = eligible, count = #instances } +end + +-- What IS this instance already? { schema?, tag?, unknownTags, multi, stale } +-- multi = it carries more than one component tag (v1 assumes one; say so rather than guess) +-- stale = it carries the runtime bind marker in Edit mode (a copy-paste out of a Play session); +-- such an object silently never binds again, so every write path clears it. +function BuildAdmin.identify(instance: Instance, schemaRead: any): any + local found, unknown = {}, {} + for _, tag in CollectionService:GetTags(instance) do + local entry = schemaRead.ok and schemaRead.byTag[tag] + if entry then + table.insert(found, entry) + else + table.insert(unknown, tag) + end + end + return { + schema = found[1], + tag = found[1] and found[1].tag, + unknownTags = unknown, + multi = #found > 1, + stale = instance:GetAttribute(BOUND_ATTR) ~= nil, + } +end + +-- ── Attribute read / write (deltas-only) ───────────────────────────────────── + +-- { value, default, hasOverride, typeMismatch } — `typeMismatch` flags an attribute hand-set with +-- the wrong type (e.g. HP = "3"), which would error inside the component's onSetup at bind time. +function BuildAdmin.readField(instance: Instance, spec: any): any + local raw = instance:GetAttribute(spec.attr) + if raw == nil then + return { value = spec.default, default = spec.default, hasOverride = false, typeMismatch = false } + end + local mismatch = spec.default ~= nil and typeof(raw) ~= typeof(spec.default) + return { value = raw, default = spec.default, hasOverride = true, typeMismatch = mismatch } +end + +local function alive(instance: Instance?): boolean + return instance ~= nil and instance.Parent ~= nil +end + +-- Set (or clear) one attribute. Blank input = reset. Writes ONLY when the value differs from the +-- component default. Returns { ok, action = "write"|"remove"|"noop", error? }. +function BuildAdmin.setField(instance: Instance, spec: any, raw: any): any + if not alive(instance) then + return { ok = false, action = "noop", error = "that object is gone" } + end + if typeof(raw) == "string" and string.match(raw, "^%s*$") then + return BuildAdmin.resetField(instance, spec) + end + local coerced = Field.coerce(spec, raw) + if not coerced.ok then + return { ok = false, action = "noop", error = coerced.error } + end + if Field.equalsDefault(spec, coerced.value, spec.default) then + return BuildAdmin.resetField(instance, spec) + end + instance:SetAttribute(spec.attr, coerced.value) + instance:SetAttribute(BOUND_ATTR, nil) -- editing implies "bind me fresh next Play" + return { ok = true, action = "write" } +end + +function BuildAdmin.resetField(instance: Instance, spec: any): any + if not alive(instance) then + return { ok = false, action = "noop", error = "that object is gone" } + end + if instance:GetAttribute(spec.attr) ~= nil then + instance:SetAttribute(spec.attr, nil) + instance:SetAttribute(BOUND_ATTR, nil) + return { ok = true, action = "remove" } + end + return { ok = true, action = "noop" } +end + +-- Strip a component's declared attributes (and the engine's `_`-prefixed stamps) from an instance. +local function stripComponent(instance: Instance, schema: any) + for _, spec in specsOf(schema) do + instance:SetAttribute(spec.attr, nil) + end +end + +local function stripEngineStamps(instance: Instance) + for name in instance:GetAttributes() do + if string.sub(name, 1, 1) == "_" then + instance:SetAttribute(name, nil) + end + end +end + +-- Make each instance this component: add its tag, remove any OTHER component's tag and that +-- component's attributes, and clear engine stamps so it binds fresh on the next Play. +-- Deltas-only: no attributes are written, so the object follows every default until you change one. +-- Returns { applied, cleared, skipped }. +function BuildAdmin.applyType(instances: { Instance }, schema: any, all: { any }): any + local applied, cleared, skipped = 0, 0, 0 + for _, instance in instances do + local ok = alive(instance) and BuildAdmin.isEligible(instance, schema) + if not ok then + skipped += 1 + continue + end + for _, other in all do + if other.tag ~= schema.tag and CollectionService:HasTag(instance, other.tag) then + CollectionService:RemoveTag(instance, other.tag) + stripComponent(instance, other) + cleared += 1 + end + end + stripEngineStamps(instance) + if not CollectionService:HasTag(instance, schema.tag) then + CollectionService:AddTag(instance, schema.tag) + end + applied += 1 + end + return { applied = applied, cleared = cleared, skipped = skipped } +end + +-- The honest inverse of applyType: remove every known component tag, its attributes, and the +-- engine's `_`-prefixed stamps. Returns { cleared }. +function BuildAdmin.clear(instances: { Instance }, all: { any }): any + local cleared = 0 + for _, instance in instances do + if not alive(instance) then + continue + end + local touched = false + for _, schema in all do + if CollectionService:HasTag(instance, schema.tag) then + CollectionService:RemoveTag(instance, schema.tag) + stripComponent(instance, schema) + touched = true + end + end + stripEngineStamps(instance) + if touched then + cleared += 1 + end + end + return { cleared = cleared } +end + +-- How many instances in this place currently carry the component (drives the page footer). +function BuildAdmin.countInPlace(schema: any): number + return #CollectionService:GetTagged(schema.tag) +end + +return BuildAdmin diff --git a/plugin/BuildAdminUi.luau b/plugin/BuildAdminUi.luau new file mode 100644 index 0000000..bbdc021 --- /dev/null +++ b/plugin/BuildAdminUi.luau @@ -0,0 +1,502 @@ +--!nonstrict +--[[ + BuildAdminUi — the Build page (issue #11). Select a Part or Model in Studio and this asks + "what is this object?", then renders that component's setup form from the engine's own schema. + + States: engine missing · nothing selected · ineligible class · untagged (the chooser) · tagged + (identity card + form) · unknown tag · multi-select (apply to all). Every write goes through the + plugin main's record() wrapper, so each edit is one undo step. + + Adding a component to the engine gives it a page here for free — there is no per-component UI + code. +]] + +local Selection = game:GetService("Selection") +local UserInputService = game:GetService("UserInputService") + +local Theme = require(script.Parent.Theme) +local FieldRow = require(script.Parent.FieldRow) + +local BuildAdminUi = {} + +local FOOTER_H = 36 + +-- Group the schema's attributes by their `group`, preserving first-appearance order. +local function groupAttributes(specs: { any }, includeAdvanced: boolean): ({ string }, { [string]: { any } }) + local order, byGroup = {}, {} + for _, spec in specs do + if spec.advanced and not includeAdvanced then + continue + end + local name = spec.group or "Settings" + if not byGroup[name] then + byGroup[name] = {} + table.insert(order, name) + end + table.insert(byGroup[name], spec) + end + return order, byGroup +end + +local function countAdvanced(specs: { any }): number + local n = 0 + for _, spec in specs do + if spec.advanced then + n += 1 + end + end + return n +end + +-- One clickable "what is this?" card. Ineligible components render dim and inert, with the reason. +local function chooserCard(schema: any, display: any, eligible: boolean, reason: string?, onPick): Frame + local panel = Theme.panel() + local title = Theme.label({ + Size = UDim2.new(1, 0, 0, 20), + Text = display.title, + Font = Theme.FONT_BOLD, + TextSize = 14, + TextColor3 = if eligible then Theme.COLOR.TEXT else Theme.COLOR.DIM, + LayoutOrder = 0, + }) + title.Parent = panel + + local summary = if eligible then display.summary else `{display.summary} — {reason or "not eligible"}` + Theme.label({ + Size = UDim2.fromScale(1, 0), + AutomaticSize = Enum.AutomaticSize.Y, + Text = summary, + TextColor3 = Theme.COLOR.DIM, + TextWrapped = true, + TextSize = 12, + LayoutOrder = 1, + }).Parent = + panel + + if eligible then + -- A transparent button over the whole card keeps the panel's styling but makes it clickable. + local hit = Theme.make("TextButton", { + Size = UDim2.fromScale(1, 1), + BackgroundTransparency = 1, + Text = "", + ZIndex = 3, + }) :: TextButton + hit.Parent = panel + hit.MouseButton1Click:Connect(function() + onPick(schema) + end) + Theme.hover(panel, Theme.COLOR.PANEL, Theme.COLOR.PANEL_HOVER) + end + return panel +end + +-- actions = { applyType, clear, setField, resetField } — all record()-wrapped by the plugin main. +function BuildAdminUi.mount(container: Frame, BuildAdmin: any, ContentAdmin: any, actions: any): { refresh: () -> () } + local header = Theme.header(container, "Build") + local scroll = Theme.scroll(32, FOOTER_H, 8) + scroll.Parent = container + + local footer = Theme.make("Frame", { + Size = UDim2.new(1, 0, 0, FOOTER_H), + Position = UDim2.new(0, 0, 1, -FOOTER_H), + BackgroundColor3 = Theme.COLOR.PANEL, + BorderSizePixel = 0, + }) :: Frame + footer.Parent = container + local status = Theme.label({ + Size = UDim2.new(1, -20, 1, 0), + Position = UDim2.fromOffset(10, 0), + Text = "", + TextColor3 = Theme.COLOR.DIM, + TextTruncate = Enum.TextTruncate.AtEnd, + TextSize = 11, + }) + status.Parent = footer + + local chooserOpen = false -- "Change type" was clicked on an already-tagged instance + local showAdvanced = false + local refresh -- forward declaration (handlers below call it) + + local function say(text: string) + status.Text = text + end + + local function clearScroll() + for _, child in scroll:GetChildren() do + if not child:IsA("UIBase") then + child:Destroy() + end + end + end + + local function note(text: string, order: number, color: Color3?) + Theme.label({ + Size = UDim2.fromScale(1, 0), + AutomaticSize = Enum.AutomaticSize.Y, + Text = text, + TextColor3 = color or Theme.COLOR.DIM, + TextWrapped = true, + TextSize = 12, + LayoutOrder = order, + }).Parent = + scroll + end + + -- ── writes ──────────────────────────────────────────────────────────────── + local function applyType(instances: { Instance }, schema: any, all: { any }) + local res = actions.applyType(instances, schema, all) + chooserOpen = false + refresh() + if typeof(res) == "table" then + local title = BuildAdmin.displayOf(schema).title + local msg = `✓ {res.applied} object(s) → {title}` + if (res.cleared or 0) > 0 then + msg ..= `, cleared {res.cleared} old component(s)` + end + if (res.skipped or 0) > 0 then + msg ..= `, skipped {res.skipped} ineligible` + end + say(msg) + end + end + + local function clearType(instances: { Instance }, all: { any }) + local res = actions.clear(instances, all) + chooserOpen = false + refresh() + if typeof(res) == "table" then + say(`✓ cleared {res.cleared or 0} object(s)`) + end + end + + local function commitField(instance: Instance, spec: any, raw: any) + local res = actions.setField(instance, spec, raw) + refresh() + if typeof(res) == "table" and res.ok == false and res.error then + say(`✗ {spec.label}: {res.error}`) + end + end + + -- ── the form for one tagged instance ────────────────────────────────────── + local function buildForm(instance: Instance, schema: any, startOrder: number): number + local order = startOrder + local specs = schema.attributes or {} + local groups, byGroup = groupAttributes(specs, showAdvanced) + + for _, groupName in groups do + order += 1 + local panel = Theme.panel() + panel.LayoutOrder = order + panel.Parent = scroll + + Theme.label({ + Size = UDim2.new(1, 0, 0, 20), + Text = groupName, + Font = Theme.FONT_BOLD, + TextSize = 13, + LayoutOrder = 0, + }).Parent = + panel + + for i, spec in byGroup[groupName] do + local state = BuildAdmin.readField(instance, spec) + local choices = nil + if spec.ref and ContentAdmin and ContentAdmin.listRoster then + local ok, roster = pcall(ContentAdmin.listRoster, spec.ref) + if ok and typeof(roster) == "table" then + choices = {} + for _, entry in roster do + table.insert(choices, entry.id) + end + end + end + local row = FieldRow.build({ + spec = spec, + state = state, + choices = choices, + onCommit = function(raw) + commitField(instance, spec, raw) + end, + onReset = function() + actions.resetField(instance, spec) + refresh() + end, + }) + row.LayoutOrder = i + row.Parent = panel + end + end + + local advanced = countAdvanced(specs) + if advanced > 0 then + order += 1 + local toggle = Theme.button({ + Size = UDim2.new(1, 0, 0, 24), + Text = if showAdvanced then "Hide advanced" else `Show advanced ({advanced})`, + TextSize = 12, + LayoutOrder = order, + }) + toggle.Parent = scroll + toggle.MouseButton1Click:Connect(function() + showAdvanced = not showAdvanced + refresh() + end) + end + return order + end + + -- ── render ──────────────────────────────────────────────────────────────── + refresh = function() + clearScroll() + + local schemaRead = BuildAdmin.readSchema() + if not schemaRead.ok then + say("") + note(schemaRead.reason or "Engine not found.", 1) + return + end + local all = schemaRead.components + + local sel = BuildAdmin.getSelection() + + -- Nothing selected: explain, and list what this engine can build. + if sel.count == 0 then + say("") + note("Select a Part or Model in the viewport or Explorer to set it up.", 1) + local order = 1 + for _, schema in all do + order += 1 + local display = BuildAdmin.displayOf(schema) + local panel = Theme.panel() + panel.LayoutOrder = order + panel.Parent = scroll + Theme.label({ + Size = UDim2.new(1, 0, 0, 18), + Text = display.title, + Font = Theme.FONT_BOLD, + TextSize = 13, + LayoutOrder = 0, + }).Parent = + panel + Theme.label({ + Size = UDim2.fromScale(1, 0), + AutomaticSize = Enum.AutomaticSize.Y, + Text = display.summary, + TextColor3 = Theme.COLOR.DIM, + TextWrapped = true, + TextSize = 12, + LayoutOrder = 1, + }).Parent = + panel + end + note("Tip: Content › Gatherables › “+ Add to World” drops a ready-made one.", order + 1) + return + end + + -- Multi-selection: apply/clear in bulk, no per-attribute form. + if sel.count > 1 then + say(`{#sel.eligible} of {sel.count} selected can take a component`) + note(`{sel.count} objects selected ({#sel.eligible} eligible). Applying affects all of them.`, 1) + local order = 1 + for _, schema in all do + order += 1 + local display = BuildAdmin.displayOf(schema) + local btn = Theme.button({ + Size = UDim2.new(1, 0, 0, 26), + Text = `Apply to all — {display.title}`, + TextSize = 13, + LayoutOrder = order, + }) + btn.Parent = scroll + btn.MouseButton1Click:Connect(function() + applyType(sel.eligible, schema, all) + end) + end + order += 1 + local clearBtn = Theme.button({ + Size = UDim2.new(1, 0, 0, 26), + Text = "Clear all", + TextColor3 = Theme.COLOR.DANGER, + TextSize = 13, + LayoutOrder = order, + }) + clearBtn.Parent = scroll + clearBtn.MouseButton1Click:Connect(function() + clearType(sel.eligible, all) + end) + return + end + + -- Exactly one selected. + local instance = sel.instances[1] + if BuildAdmin.classify(instance) == "other" then + say("") + note(`Selected a {instance.ClassName} — Build works on Parts and Models.`, 1) + return + end + + local id = BuildAdmin.identify(instance, schemaRead) + local order = 0 + + if id.stale then + order += 1 + note( + "⚠ This object carries a leftover runtime bind marker (copied out of a Play session)." + .. " Applying or clearing below removes it.", + order, + Theme.COLOR.DANGER + ) + end + + if id.schema and not chooserOpen then + local display = BuildAdmin.displayOf(id.schema) + + -- Identity card + change/clear. + order += 1 + local card = Theme.panel() + card.LayoutOrder = order + card.Parent = scroll + Theme.label({ + Size = UDim2.new(1, 0, 0, 20), + Text = `This is a {display.title}`, + Font = Theme.FONT_BOLD, + TextSize = 14, + LayoutOrder = 0, + }).Parent = + card + Theme.label({ + Size = UDim2.fromScale(1, 0), + AutomaticSize = Enum.AutomaticSize.Y, + Text = if id.multi + then `⚠ It also carries another component tag — v1 expects one. {display.summary}` + else display.summary, + TextColor3 = Theme.COLOR.DIM, + TextWrapped = true, + TextSize = 12, + LayoutOrder = 1, + }).Parent = + card + + local row = Theme.make("Frame", { + Size = UDim2.new(1, 0, 0, 26), + BackgroundTransparency = 1, + LayoutOrder = 2, + }) :: Frame + row.Parent = card + local change = Theme.button({ + Size = UDim2.new(0.5, -4, 1, 0), + Text = "Change type", + TextSize = 13, + }) + change.Parent = row + change.MouseButton1Click:Connect(function() + chooserOpen = true + refresh() + end) + local clearBtn = Theme.button({ + Size = UDim2.new(0.5, -4, 1, 0), + Position = UDim2.new(0.5, 4, 0, 0), + Text = "Clear", + TextColor3 = Theme.COLOR.DANGER, + TextSize = 13, + }) + clearBtn.Parent = row + clearBtn.MouseButton1Click:Connect(function() + clearType({ instance }, all) + end) + + if display.hint and display.hint ~= "" then + order += 1 + note(display.hint, order) + end + + order = buildForm(instance, id.schema, order) + say(`{BuildAdmin.countInPlace(id.schema)} {display.title} in this place`) + return + end + + -- Untagged (or "Change type"): the chooser. + if #id.unknownTags > 0 and not id.schema then + order += 1 + note( + `Tagged “{table.concat(id.unknownTags, ", ")}” — not a component this engine version knows.`, + order + ) + order += 1 + local clearBtn = Theme.button({ + Size = UDim2.new(1, 0, 0, 26), + Text = "Clear", + TextColor3 = Theme.COLOR.DANGER, + TextSize = 13, + LayoutOrder = order, + }) + clearBtn.Parent = scroll + clearBtn.MouseButton1Click:Connect(function() + clearType({ instance }, all) + end) + end + + order += 1 + note(if chooserOpen then "Change this object to…" else "What is this object?", order, Theme.COLOR.TEXT) + + for _, schema in all do + order += 1 + local eligible, reason = BuildAdmin.isEligible(instance, schema) + local card = chooserCard(schema, BuildAdmin.displayOf(schema), eligible, reason, function(picked) + applyType({ instance }, picked, all) + end) + card.LayoutOrder = order + card.Parent = scroll + end + + if chooserOpen then + order += 1 + local cancel = Theme.button({ + Size = UDim2.new(1, 0, 0, 24), + Text = "Cancel", + TextSize = 12, + LayoutOrder = order, + }) + cancel.Parent = scroll + cancel.MouseButton1Click:Connect(function() + chooserOpen = false + refresh() + end) + end + say(`Selected: {instance.Name}`) + end + + -- Re-render on selection change. Two guards: the window is rebuilt when the engine appears + -- (so a stale connection must not touch a destroyed frame), and a refresh while the user is + -- typing in one of our boxes would eat the value. + local selfDirty = false + local connection + connection = Selection.SelectionChanged:Connect(function() + if not container.Parent then + connection:Disconnect() + return + end + local focused = UserInputService:GetFocusedTextBox() + if focused and focused:IsDescendantOf(container) then + return + end + chooserOpen = false + if container.Visible then + refresh() + else + selfDirty = true + end + end) + container:GetPropertyChangedSignal("Visible"):Connect(function() + if container.Visible and selfDirty then + selfDirty = false + refresh() + end + end) + + header.refreshBtn.MouseButton1Click:Connect(refresh) + refresh() + return { refresh = refresh } +end + +return BuildAdminUi diff --git a/plugin/ConfigAdmin.luau b/plugin/ConfigAdmin.luau index 8d1326b..7bf1927 100644 --- a/plugin/ConfigAdmin.luau +++ b/plugin/ConfigAdmin.luau @@ -19,15 +19,12 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") +local Field = require(script.Parent.Field) + local ConfigAdmin = {} ConfigAdmin.INSTANCE_NAME = "SurvivorCoreEngineConfig" --- Relative float tolerance: |v−d| ≤ EPS·max(|v|,|d|,1e-6). StatAdmin's absolute 1e-4 would --- swallow genuine overrides of tiny rates (Consequences drains ≈ 0.0035/s); the relative form --- still collapses display round-trip noise. -local FLOAT_EPSILON = 1e-4 - -- ── Schema (live from the engine) ──────────────────────────────────────────── local function findEngineConfigModule(): ModuleScript? @@ -126,113 +123,19 @@ end -- Parse a raw edit (usually TextBox text) per the field spec. Returns { ok, value } or -- { ok = false, error }. A blank string is handled by setOverride (it means "reset"). function ConfigAdmin.coerce(field: any, raw: any): any - if field.kind == "number" then - local n = if typeof(raw) == "number" then raw else tonumber(tostring(raw)) - if n == nil then - return { ok = false, error = "expected a number" } - end - if n ~= n or n == math.huge or n == -math.huge then - return { ok = false, error = "expected a finite number" } - end - if field.min then - n = math.max(n, field.min) - end - if field.max then - n = math.min(n, field.max) - end - if field.integer then - n = math.floor(n + 0.5) - end - return { ok = true, value = n } - elseif field.kind == "boolean" then - if typeof(raw) == "boolean" then - return { ok = true, value = raw } - end - local s = string.lower(tostring(raw)) - if s == "true" then - return { ok = true, value = true } - elseif s == "false" then - return { ok = true, value = false } - end - return { ok = false, error = "expected true or false" } - elseif field.kind == "enum" then - local s = tostring(raw) - for _, choice in field.choices or {} :: { string } do - if choice == s then - return { ok = true, value = s } - end - end - return { ok = false, error = `must be one of: {table.concat(field.choices or {}, ", ")}` } - elseif field.kind == "color3" then - if typeof(raw) == "Color3" then - return { ok = true, value = raw } - end - local r, g, b = string.match(tostring(raw), "^%s*(%d+)%s*,%s*(%d+)%s*,%s*(%d+)%s*$") - if not r then - return { ok = false, error = "expected R, G, B (0-255 each)" } - end - local rn, gn, bn = tonumber(r), tonumber(g), tonumber(b) - if rn > 255 or gn > 255 or bn > 255 then - return { ok = false, error = "channels are 0-255" } - end - return { ok = true, value = Color3.fromRGB(rn, gn, bn) } - elseif field.kind == "font" then - local s = tostring(raw) - local ok, font = pcall(function() - return (Enum.Font :: any)[s] - end) - if not ok or typeof(font) ~= "EnumItem" then - return { ok = false, error = `'{s}' is not an Enum.Font name` } - end - return { ok = true, value = s } -- stored as the NAME string; the engine resolves it - else -- "string" (incl. check = keycode/assetId) - local s = tostring(raw) - if field.check == "keycode" then - local ok, keyCode = pcall(function() - return (Enum.KeyCode :: any)[s] - end) - if not ok or typeof(keyCode) ~= "EnumItem" then - return { ok = false, error = `'{s}' is not an Enum.KeyCode name` } - end - end - return { ok = true, value = s } - end + return Field.coerce(field, raw) end -- Does a coerced value equal the engine default (→ store nothing)? Kind-aware: fonts compare -- the stored NAME against the default Enum.Font; Color3 compares exactly (both sides come from -- fromRGB construction); numbers use the relative epsilon. function ConfigAdmin.equalsDefault(field: any, value: any, default: any): boolean - if field.kind == "number" then - if typeof(value) ~= "number" or typeof(default) ~= "number" then - return value == default - end - local scale = math.max(math.abs(value), math.abs(default), 1e-6) - return math.abs(value - default) <= FLOAT_EPSILON * scale - elseif field.kind == "font" then - local defaultName = if typeof(default) == "EnumItem" then default.Name else tostring(default) - return value == defaultName - end - return value == default + return Field.equalsDefault(field, value, default) end -- Display formatting for effective values/defaults (what the TextBox shows). function ConfigAdmin.formatValue(field: any, value: any): string - if value == nil then - return "" - end - if field.kind == "color3" and typeof(value) == "Color3" then - return string.format( - "%d, %d, %d", - math.round(value.R * 255), - math.round(value.G * 255), - math.round(value.B * 255) - ) - end - if field.kind == "font" and typeof(value) == "EnumItem" then - return value.Name - end - return tostring(value) + return Field.format(field, value) end -- ── Writes (the deltas-only decision point) ────────────────────────────────── diff --git a/plugin/Field.luau b/plugin/Field.luau new file mode 100644 index 0000000..21310f2 --- /dev/null +++ b/plugin/Field.luau @@ -0,0 +1,129 @@ +--!nonstrict +--[[ + Field — pure value plumbing shared by every schema-driven editor in the plugin (Engine Config + and Build). No UI, no `plugin` global, no Instances: given a field spec ({ kind, min, max, + integer, choices, check }) it coerces a raw input, formats a value for display, and decides + whether a value equals its default. + + `kind` is one of "number" | "boolean" | "string" | "enum" | "color3" | "font". An UNKNOWN kind + falls through to string, so a newer engine's schema can add kinds without breaking an older + plugin. +]] + +local Field = {} + +local FLOAT_EPSILON = 1e-4 + +-- Coerce a raw input (usually a text box's string) to the field's type. +-- Returns { ok = true, value } | { ok = false, error }. +function Field.coerce(field: any, raw: any): any + if field.kind == "number" then + local n = if typeof(raw) == "number" then raw else tonumber(tostring(raw)) + if n == nil then + return { ok = false, error = "expected a number" } + end + if n ~= n or n == math.huge or n == -math.huge then + return { ok = false, error = "expected a finite number" } + end + if field.min then + n = math.max(n, field.min) + end + if field.max then + n = math.min(n, field.max) + end + if field.integer then + n = math.floor(n + 0.5) + end + return { ok = true, value = n } + elseif field.kind == "boolean" then + if typeof(raw) == "boolean" then + return { ok = true, value = raw } + end + local s = string.lower(tostring(raw)) + if s == "true" then + return { ok = true, value = true } + elseif s == "false" then + return { ok = true, value = false } + end + return { ok = false, error = "expected true or false" } + elseif field.kind == "enum" then + local s = tostring(raw) + for _, choice in field.choices or {} :: { string } do + if choice == s then + return { ok = true, value = s } + end + end + return { ok = false, error = `must be one of: {table.concat(field.choices or {}, ", ")}` } + elseif field.kind == "color3" then + if typeof(raw) == "Color3" then + return { ok = true, value = raw } + end + local r, g, b = string.match(tostring(raw), "^%s*(%d+)%s*,%s*(%d+)%s*,%s*(%d+)%s*$") + if not r then + return { ok = false, error = "expected R, G, B (0-255 each)" } + end + local rn, gn, bn = tonumber(r), tonumber(g), tonumber(b) + if rn > 255 or gn > 255 or bn > 255 then + return { ok = false, error = "channels are 0-255" } + end + return { ok = true, value = Color3.fromRGB(rn, gn, bn) } + elseif field.kind == "font" then + local s = tostring(raw) + local ok, font = pcall(function() + return (Enum.Font :: any)[s] + end) + if not ok or typeof(font) ~= "EnumItem" then + return { ok = false, error = `'{s}' is not an Enum.Font name` } + end + return { ok = true, value = s } -- stored as the NAME string; the engine resolves it + else -- "string", and any kind this plugin version doesn't know + local s = tostring(raw) + if field.check == "keycode" then + local ok, keyCode = pcall(function() + return (Enum.KeyCode :: any)[s] + end) + if not ok or typeof(keyCode) ~= "EnumItem" then + return { ok = false, error = `'{s}' is not an Enum.KeyCode name` } + end + end + return { ok = true, value = s } + end +end + +-- A value as the creator should see it in a text box / button. +function Field.format(field: any, value: any): string + if value == nil then + return "" + end + if field.kind == "color3" and typeof(value) == "Color3" then + return string.format( + "%d, %d, %d", + math.round(value.R * 255), + math.round(value.G * 255), + math.round(value.B * 255) + ) + end + if field.kind == "font" and typeof(value) == "EnumItem" then + return value.Name + end + return tostring(value) +end + +-- Deltas-only editors store a value ONLY when it differs from the default, so this decides whether +-- a write becomes a remove. Numbers compare with a RELATIVE epsilon: an absolute one would swallow +-- genuine overrides of very small values. +function Field.equalsDefault(field: any, value: any, default: any): boolean + if field.kind == "number" then + if typeof(value) ~= "number" or typeof(default) ~= "number" then + return value == default + end + local scale = math.max(math.abs(value), math.abs(default), 1e-6) + return math.abs(value - default) <= FLOAT_EPSILON * scale + elseif field.kind == "font" then + local defaultName = if typeof(default) == "EnumItem" then default.Name else tostring(default) + return value == defaultName + end + return value == default +end + +return Field diff --git a/plugin/FieldRow.luau b/plugin/FieldRow.luau new file mode 100644 index 0000000..258656e --- /dev/null +++ b/plugin/FieldRow.luau @@ -0,0 +1,160 @@ +--!nonstrict +--[[ + FieldRow — the shared schema-field row: [○/●] label … [control], plus an optional wrapped help + line underneath. The visual language is the Engine Config editor's (a filled dot means "you set + this"; an empty dot means "following the default"), so every schema-driven editor in the plugin + looks and behaves the same. + + Controls by kind: boolean/enum cycle on click; everything else is a TextBox committed on focus + loss, showing the default as its placeholder. When a field declares `ref` (a content category), + the box gains a ⌄ button that cycles the ids authored in that category — so a creator picks + "oak_tree" instead of remembering it. +]] + +local Theme = require(script.Parent.Theme) +local Field = require(script.Parent.Field) + +local FieldRow = {} + +local ROW_H = Theme.ROW_H + +-- opts = { +-- spec, -- AttributeSpec +-- state, -- { value, default, hasOverride, typeMismatch? } +-- onCommit(raw) -> (), -- commit an edit (blank = reset) +-- onReset() -> (), -- the ○/● dot +-- choices: { string }?, -- resolved `ref` ids, for the ⌄ picker +-- } +function FieldRow.build(opts: any): Frame + local spec, state = opts.spec, opts.state + local hasHelp = typeof(spec.help) == "string" and spec.help ~= "" + + local row = Theme.make("Frame", { + Size = UDim2.fromScale(1, 0), + AutomaticSize = Enum.AutomaticSize.Y, + BackgroundTransparency = 1, + }, { + Theme.make("UIListLayout", { + SortOrder = Enum.SortOrder.LayoutOrder, + Padding = UDim.new(0, 1), + }), + }) :: Frame + + local top = Theme.make("Frame", { + Size = UDim2.new(1, 0, 0, ROW_H), + BackgroundTransparency = 1, + LayoutOrder = 1, + }) :: Frame + top.Parent = row + + -- Override dot: filled + accented when this instance sets the attribute. + local dot = Theme.button({ + Size = UDim2.fromOffset(18, 18), + Position = UDim2.fromOffset(0, 4), + Text = if state.hasOverride then "●" else "○", + TextColor3 = if state.hasOverride then Theme.COLOR.ACCENT else Theme.COLOR.DIM, + BackgroundTransparency = 1, + TextSize = 13, + }) + dot.Parent = top + dot.MouseButton1Click:Connect(function() + opts.onReset() + end) + + local warn = if state.typeMismatch then " ⚠" else "" + Theme.label({ + Size = UDim2.new(0.42, -22, 1, 0), + Position = UDim2.fromOffset(22, 0), + Text = spec.label .. warn, + TextColor3 = if state.hasOverride then Theme.COLOR.TEXT else Theme.COLOR.DIM, + TextTruncate = Enum.TextTruncate.AtEnd, + }).Parent = + top + + local controlX = UDim2.new(0.42, 4, 0, 3) + local controlW = UDim2.new(0.58, -4, 0, ROW_H - 6) + + if spec.kind == "boolean" or spec.kind == "enum" then + local current = Field.format(spec, state.value) + local control = Theme.button({ + Size = controlW, + Position = controlX, + Text = if current == "" then "(blank)" else current, + TextColor3 = if state.hasOverride then Theme.COLOR.TEXT else Theme.COLOR.DIM, + TextSize = 13, + }) + control.Parent = top + control.MouseButton1Click:Connect(function() + if spec.kind == "boolean" then + opts.onCommit(not (state.value == true)) + return + end + local choices = spec.choices or {} + local index = 0 + for i, choice in choices do + if choice == current then + index = i + break + end + end + local nextChoice = choices[(index % math.max(1, #choices)) + 1] + if nextChoice ~= nil then + opts.onCommit(nextChoice) + end + end) + else + local pickable = opts.choices and #opts.choices > 0 + local box = Theme.textBox({ + Size = if pickable then UDim2.new(0.58, -28, 0, ROW_H - 6) else controlW, + Position = controlX, + Text = if state.hasOverride then Field.format(spec, state.value) else "", + PlaceholderText = spec.placeholder or Field.format(spec, spec.default), + }) + box.Parent = top + box.FocusLost:Connect(function() + opts.onCommit(box.Text) + end) + + if pickable then + -- Cycle the ids authored in the referenced content category. + local pick = Theme.button({ + Size = UDim2.fromOffset(22, ROW_H - 6), + Position = UDim2.new(1, -22, 0, 3), + Text = "⌄", + TextSize = 13, + }) + pick.Parent = top + pick.MouseButton1Click:Connect(function() + local choices = opts.choices + local current = if state.hasOverride then Field.format(spec, state.value) else "" + local index = 0 + for i, choice in choices do + if choice == current then + index = i + break + end + end + opts.onCommit(choices[(index % #choices) + 1]) + end) + end + end + + if hasHelp then + Theme.label({ + Size = UDim2.fromScale(1, 0), + AutomaticSize = Enum.AutomaticSize.Y, + Position = UDim2.fromOffset(22, 0), + Text = spec.help, + TextColor3 = Theme.COLOR.DIM, + TextWrapped = true, + TextSize = 11, + TextTruncate = Enum.TextTruncate.None, + LayoutOrder = 2, + }).Parent = + row + end + + return row +end + +return FieldRow diff --git a/plugin/init.server.luau b/plugin/init.server.luau index b224f09..f561824 100644 --- a/plugin/init.server.luau +++ b/plugin/init.server.luau @@ -32,6 +32,8 @@ local ContentAdmin = require(script.ContentAdmin) local ContentAdminUi = require(script.ContentAdminUi) local ConfigAdmin = require(script.ConfigAdmin) local ConfigAdminUi = require(script.ConfigAdminUi) +local BuildAdmin = require(script.BuildAdmin) +local BuildAdminUi = require(script.BuildAdminUi) local SETTING_LAST_PAGE = "SurvivorCoreStudio.lastPage" @@ -112,6 +114,32 @@ local function applyConfigResetSection(section: any): any end) end +-- ── Build callbacks (world objects: tag + attributes) ──────────────────────── +-- One ChangeHistory recording per call, kept strictly at this boundary: BuildAdmin functions must +-- never call each other through here, since a nested TryBeginRecording returns nil. +local buildActions = { + applyType = function(instances: { Instance }, schema: any, all: { any }): any + return record(`Build: {BuildAdmin.displayOf(schema).title}`, function() + return BuildAdmin.applyType(instances, schema, all) + end) + end, + clear = function(instances: { Instance }, all: { any }): any + return record("Build: clear component", function() + return BuildAdmin.clear(instances, all) + end) + end, + setField = function(instance: Instance, spec: any, raw: any): any + return record(`Build: {spec.attr}`, function() + return BuildAdmin.setField(instance, spec, raw) + end) + end, + resetField = function(instance: Instance, spec: any): any + return record(`Build: reset {spec.attr}`, function() + return BuildAdmin.resetField(instance, spec) + end) + end, +} + -- ── Content callbacks (authored + overrides), bundled for the pages ────────── local actions = { set = function(catKey: string, id: string, field: any, raw: any): any @@ -185,6 +213,17 @@ local function mountUi() end, }) + -- Build: select a world object → "what is this?" → a schema-rendered setup form. Registered + -- unconditionally; it renders its own explainer when the engine (or its schemas) is missing. + table.insert(pages, { + id = "build", + label = "Build", + section = "", + mount = function(frame) + return BuildAdminUi.mount(frame, BuildAdmin, ContentAdmin, buildActions) + end, + }) + table.insert(pages, { id = "stats", label = "Survival Stats", diff --git a/sourcemap-plugin.json b/sourcemap-plugin.json index 9fd0795..df3e2a9 100644 --- a/sourcemap-plugin.json +++ b/sourcemap-plugin.json @@ -1 +1 @@ -{"name":"SurvivorCoreStatAdmin","className":"Script","filePaths":["plugin/init.server.luau","plugin.project.json"],"children":[{"name":"ConfigAdmin","className":"ModuleScript","filePaths":["plugin/ConfigAdmin.luau"]},{"name":"ConfigAdminUi","className":"ModuleScript","filePaths":["plugin/ConfigAdminUi.luau"]},{"name":"ContentAdmin","className":"ModuleScript","filePaths":["plugin/ContentAdmin.luau"]},{"name":"ContentAdminUi","className":"ModuleScript","filePaths":["plugin/ContentAdminUi.luau"]},{"name":"HudPreview","className":"ModuleScript","filePaths":["plugin/HudPreview.luau"]},{"name":"Nav","className":"ModuleScript","filePaths":["plugin/Nav.luau"]},{"name":"OverviewPage","className":"ModuleScript","filePaths":["plugin/OverviewPage.luau"]},{"name":"SearchPage","className":"ModuleScript","filePaths":["plugin/SearchPage.luau"]},{"name":"SpawnHelpers","className":"ModuleScript","filePaths":["plugin/SpawnHelpers.luau"]},{"name":"StatAdmin","className":"ModuleScript","filePaths":["plugin/StatAdmin.luau"]},{"name":"StatAdminUi","className":"ModuleScript","filePaths":["plugin/StatAdminUi.luau"]},{"name":"Theme","className":"ModuleScript","filePaths":["plugin/Theme.luau"]}]} \ No newline at end of file +{"name":"SurvivorCoreStatAdmin","className":"Script","filePaths":["plugin/init.server.luau","plugin.project.json"],"children":[{"name":"BuildAdmin","className":"ModuleScript","filePaths":["plugin/BuildAdmin.luau"]},{"name":"BuildAdminUi","className":"ModuleScript","filePaths":["plugin/BuildAdminUi.luau"]},{"name":"ConfigAdmin","className":"ModuleScript","filePaths":["plugin/ConfigAdmin.luau"]},{"name":"ConfigAdminUi","className":"ModuleScript","filePaths":["plugin/ConfigAdminUi.luau"]},{"name":"ContentAdmin","className":"ModuleScript","filePaths":["plugin/ContentAdmin.luau"]},{"name":"ContentAdminUi","className":"ModuleScript","filePaths":["plugin/ContentAdminUi.luau"]},{"name":"Field","className":"ModuleScript","filePaths":["plugin/Field.luau"]},{"name":"FieldRow","className":"ModuleScript","filePaths":["plugin/FieldRow.luau"]},{"name":"HudPreview","className":"ModuleScript","filePaths":["plugin/HudPreview.luau"]},{"name":"Nav","className":"ModuleScript","filePaths":["plugin/Nav.luau"]},{"name":"OverviewPage","className":"ModuleScript","filePaths":["plugin/OverviewPage.luau"]},{"name":"SearchPage","className":"ModuleScript","filePaths":["plugin/SearchPage.luau"]},{"name":"SpawnHelpers","className":"ModuleScript","filePaths":["plugin/SpawnHelpers.luau"]},{"name":"StatAdmin","className":"ModuleScript","filePaths":["plugin/StatAdmin.luau"]},{"name":"StatAdminUi","className":"ModuleScript","filePaths":["plugin/StatAdminUi.luau"]},{"name":"Theme","className":"ModuleScript","filePaths":["plugin/Theme.luau"]}]} \ No newline at end of file diff --git a/src/components/Gatherable.luau b/src/components/Gatherable.luau index 9c7a93a..36e089f 100644 --- a/src/components/Gatherable.luau +++ b/src/components/Gatherable.luau @@ -15,6 +15,7 @@ ]] local Components = require(script.Parent) +local Schema = require(script.Parent.Schema) local Registries = require(script.Parent.Parent.registries) local Harvesting = require(script.Parent.Parent.systems.Harvesting) @@ -64,22 +65,15 @@ local function resolve(values: { [string]: any }) } end +-- Attributes, their defaults and their creator-facing help text live in the schema, so the Studio +-- plugin renders this component's setup form from the same source the engine binds from. +local SCHEMA = Schema.COMPONENTS.Gatherable + return Components.define({ - name = "Gatherable", - tag = "Gatherable", - attributes = { - 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) - PromptText = "", -- prompt-mode ActionText override ("Butcher", "Pick"); "" = "Gather" - PromptObject = "", -- prompt-mode ObjectText override; "" = resource / item id - }, + name = SCHEMA.name, + tag = SCHEMA.tag, + display = SCHEMA.display, + attributes = SCHEMA.attributes, onSetup = function(instance, values) local r = resolve(values) diff --git a/src/components/Mob.luau b/src/components/Mob.luau index a7a8afc..25df184 100644 --- a/src/components/Mob.luau +++ b/src/components/Mob.luau @@ -12,24 +12,18 @@ ]] local Components = require(script.Parent) +local Schema = require(script.Parent.Schema) local Mobs = require(script.Parent.Parent.systems.Mobs) +-- Attributes + their creator-facing help text live in the schema (the Studio plugin renders this +-- component's setup form from it). +local SCHEMA = Schema.COMPONENTS.Mob + return Components.define({ - name = "Mob", - tag = "Mob", - attributes = { - MobType = "", -- the Mobs def id to inherit from; blank = use the model's Name - Faction = "", -- "hostile" | "passive" | "neutral"; blank = inherit the def (default "neutral") - Health = 0, -- 0 = inherit the def (or 50) - WalkSpeed = 0, -- 0 = inherit - RunSpeed = 0, -- 0 = inherit - AggroRange = 0, -- 0 = inherit / Config default - LeashRange = 0, -- 0 = inherit / Config default - AttackRange = 0, -- 0 = inherit / Config default - AttackDamage = 0, -- 0 = inherit / Config default - AttackCooldown = 0, -- 0 = inherit / Config default - WanderRadius = 0, -- 0 = inherit / Config default - }, + name = SCHEMA.name, + tag = SCHEMA.tag, + display = SCHEMA.display, + attributes = SCHEMA.attributes, onSetup = function(instance, _values) -- The server Mobs runtime reads the instance attributes directly (one merge path: -- override attr › registry def › "Mobs" Config), so nothing to stash here — just adopt it. diff --git a/src/components/QuestGiver.luau b/src/components/QuestGiver.luau index 1f5bd36..efa71d3 100644 --- a/src/components/QuestGiver.luau +++ b/src/components/QuestGiver.luau @@ -13,16 +13,20 @@ ]] local Components = require(script.Parent) +local Schema = require(script.Parent.Schema) local Registries = require(script.Parent.Parent.registries) local Quests = require(script.Parent.Parent.systems.Quests) local QuestData = require(script.Parent.Parent.shared.QuestData) +-- Attributes + their creator-facing help text live in the schema (the Studio plugin renders this +-- component's setup form from it). +local SCHEMA = Schema.COMPONENTS.QuestGiver + return Components.define({ - name = "QuestGiver", - tag = "QuestGiver", - attributes = { - Quest = "", -- the quest id this giver offers / accepts turn-ins for - }, + name = SCHEMA.name, + tag = SCHEMA.tag, + display = SCHEMA.display, + attributes = SCHEMA.attributes, onSetup = function(instance, values) local questId = tostring(values.Quest or "") if questId == "" then diff --git a/src/components/Schema.luau b/src/components/Schema.luau new file mode 100644 index 0000000..bb8bc6c --- /dev/null +++ b/src/components/Schema.luau @@ -0,0 +1,402 @@ +--!nonstrict +--[[ + Component schemas — the PURE description of every creator component: what attributes it reads, + their kind/default/help/options, and how a builder UI should present it. + + This module has NO dependencies, on purpose. The Studio plugin requires it LIVE at edit time + (ReplicatedStorage.SurvivorCore.components.Schema), exactly as the plugin's Engine Config editor + requires shared/EngineConfig — so the plugin and the engine can never disagree about a + component's fields. Requiring a component module instead is NOT an option: those pull in server + systems (Harvesting asserts IsServer at module scope; Remotes CREATES instances in + ReplicatedStorage), which would throw or pollute the creator's place file in Edit mode. + + Attribute names here are PascalCase INSTANCE attributes (`ItemId`, `HP`) — a different namespace + from the lowercase registry-def fields the Content editor writes (`item`, `hp`). Never mix them: + a def says what "oak_tree" IS; an instance attribute says what THIS part is (or which def it + inherits from). + + Attributes beginning with "_" are ENGINE-INTERNAL (the `_scBound` bind marker, Gatherable's + resolved `_ItemId`/`_HP`/… stamps). They are never authored, never shown, and are cleared when + a builder untags an instance. +]] + +local Schema = {} + +-- Bumped when the shape changes. Readers must tolerate unknown fields and unknown `kind`s. +Schema.VERSION = 1 + +export type AttributeSpec = { + attr: string, -- PascalCase instance Attribute name + kind: string, -- "number" | "boolean" | "string" | "enum" (unknown kinds render as text) + label: string, + default: any, -- MUST equal the value the component's onSetup assumes + help: string?, -- one sentence shown under the field + choices: { string }?, -- kind == "enum" + min: number?, + max: number?, + integer: boolean?, + placeholder: string?, + group: string?, -- form section header; first-appearance order wins. nil = "Settings" + ref: string?, -- names a content category ("Items"/"Resources"/"Mobs"/"Quests") whose authored + -- ids a builder can offer as a picker. A UI hint only — the engine ignores it. + advanced: boolean?, -- hidden behind "show advanced" (legacy / rarely-set fields) +} + +export type Display = { + title: string, -- chooser card title + summary: string, -- one line: what this component makes the object do + instance: string?, -- "BasePart" | "Model" | "any" (default "any") — chooser eligibility + icon: string?, -- reserved (rbxassetid://…) + order: number?, -- chooser sort; ties break on title + hint: string?, -- shown above the form +} + +export type ComponentSchema = { + name: string, + tag: string, + display: Display, + attributes: { AttributeSpec }, +} + +-- ── Normalisation (accepts the legacy shorthand as well as the schema form) ─── + +-- "ItemId" -> "Item Id". Only used for LEGACY map-form attributes, which declare no label. +local function humanize(attr: string): string + return (string.gsub(attr, "(%l)(%u)", "%1 %2")) +end + +local function inferKind(default: any): string + local t = typeof(default) + return if t == "number" then "number" elseif t == "boolean" then "boolean" else "string" +end + +-- Always returns an ORDERED array of AttributeSpec, from either form: +-- schema: { { attr = "HP", kind = "number", … }, … } (array — attributes[1] ~= nil) +-- legacy: { HP = 0, ItemId = "" } (map — sorted, since `pairs` order is +-- arbitrary and would make a UI jitter) +function Schema.normalize(attributes: any?): { AttributeSpec } + if attributes == nil then + return {} + end + if (attributes :: any)[1] ~= nil then + for _, spec in attributes :: { AttributeSpec } do + assert(typeof(spec.attr) == "string", "component attribute spec needs `attr`") + end + return attributes :: { AttributeSpec } + end + local out: { AttributeSpec } = {} + for attr, default in attributes :: { [string]: any } do + table.insert(out, { attr = attr, kind = inferKind(default), label = humanize(attr), default = default }) + end + table.sort(out, function(a, b) + return a.attr < b.attr + end) + return out +end + +-- attr -> default map: exactly what the legacy `attributes` table was, so binding is unchanged. +function Schema.defaults(attributes: { AttributeSpec }): { [string]: any } + local out = {} + for _, spec in attributes do + out[spec.attr] = spec.default + end + return out +end + +-- ── The shipped component schemas ──────────────────────────────────────────── +-- Defaults are copied VERBATIM from each component's original attribute table: they are behaviour, +-- not decoration (Gatherable's resolve() reads "" / 0 as "inherit from the resource def"). + +local INHERIT_ZERO = "0 = inherit the mob def, then the Mobs config." + +Schema.COMPONENTS = { + Gatherable = { + name = "Gatherable", + tag = "Gatherable", + display = { + title = "Gatherable", + summary = "A tree, rock or bush players harvest for items.", + instance = "any", + order = 1, + hint = "Point it at a Gatherables entry (easiest) — or leave that blank and set the item," + .. " HP and yield by hand. Blank / 0 fields follow the entry.", + }, + attributes = { + { + attr = "Resource", + kind = "string", + label = "Resource", + default = "", + ref = "Resources", + placeholder = "a Gatherables id", + group = "What it is", + help = "Inherit item, HP, tool and yield from a Content › Gatherables entry.", + }, + { + attr = "ItemId", + kind = "string", + label = "Item yielded", + default = "", + ref = "Items", + group = "What it is", + help = "Overrides the resource's item. Blank = use the resource.", + }, + { + attr = "HP", + kind = "number", + label = "HP (hits to deplete)", + default = 0, + min = 0, + integer = true, + group = "Harvesting", + help = "0 = inherit the resource (or 3).", + }, + { + attr = "RequireTool", + kind = "string", + label = "Tool required", + default = "", + placeholder = "axe / pickaxe", + group = "Harvesting", + help = "Blank = bare hands (hold-E). Set a tool type to make it click-to-swing.", + }, + { + attr = "YieldMin", + kind = "number", + label = "Yield min", + default = 0, + min = 0, + integer = true, + group = "Harvesting", + help = "Items per hit, low end. 0 = inherit the resource.", + }, + { + attr = "YieldMax", + kind = "number", + label = "Yield max", + default = 0, + min = 0, + integer = true, + group = "Harvesting", + help = "Items per hit, high end. 0 = inherit the resource.", + }, + { + attr = "Interaction", + kind = "enum", + label = "Interaction", + default = "auto", + choices = { "auto", "prompt", "tool" }, + group = "Harvesting", + help = "auto = tool if a tool is required, else a hold-E prompt.", + }, + { + attr = "DestroyOnDeplete", + kind = "boolean", + label = "Destroy when depleted", + default = true, + group = "Harvesting", + help = "Off keeps the node so a reaction can turn it into a stump.", + }, + { + attr = "PromptText", + kind = "string", + label = "Prompt action text", + default = "", + placeholder = "Gather", + group = "Prompt (hold-E only)", + }, + { + attr = "PromptObject", + kind = "string", + label = "Prompt object text", + default = "", + placeholder = "the resource / item id", + group = "Prompt (hold-E only)", + }, + { + attr = "Yield", + kind = "number", + label = "Yield (legacy)", + default = 0, + min = 0, + integer = true, + advanced = true, + group = "Harvesting", + help = "Legacy single per-hit yield. Prefer Yield min / max.", + }, + }, + }, + + Mob = { + name = "Mob", + tag = "Mob", + display = { + title = "Mob (creature)", + summary = "A creature with AI: wanders, chases, attacks or flees.", + instance = "Model", + order = 2, + hint = "Needs a Model with a Humanoid and a PrimaryPart. Point it at a Mobs entry;" + .. " every override below is optional.", + }, + attributes = { + { + attr = "MobType", + kind = "string", + label = "Mob type", + default = "", + ref = "Mobs", + placeholder = "a Mobs id (blank = the model's name)", + group = "What it is", + help = "The Content › Mobs entry to inherit from.", + }, + { + attr = "Faction", + kind = "enum", + label = "Faction", + default = "", + choices = { "", "hostile", "passive", "neutral" }, + group = "What it is", + help = "Picks the AI profile. Blank = inherit the def (then neutral).", + }, + { + attr = "Health", + kind = "number", + label = "Health", + default = 0, + min = 0, + group = "Overrides (0 = inherit)", + help = "0 = inherit the mob def (or 50).", + }, + { + attr = "WalkSpeed", + kind = "number", + label = "Walk speed", + default = 0, + min = 0, + group = "Overrides (0 = inherit)", + help = INHERIT_ZERO, + }, + { + attr = "RunSpeed", + kind = "number", + label = "Run speed", + default = 0, + min = 0, + group = "Overrides (0 = inherit)", + help = INHERIT_ZERO, + }, + { + attr = "AggroRange", + kind = "number", + label = "Aggro range", + default = 0, + min = 0, + group = "Overrides (0 = inherit)", + help = INHERIT_ZERO, + }, + { + attr = "LeashRange", + kind = "number", + label = "Leash range", + default = 0, + min = 0, + group = "Overrides (0 = inherit)", + help = INHERIT_ZERO, + }, + { + attr = "AttackRange", + kind = "number", + label = "Attack range", + default = 0, + min = 0, + group = "Overrides (0 = inherit)", + help = INHERIT_ZERO, + }, + { + attr = "AttackDamage", + kind = "number", + label = "Attack damage", + default = 0, + min = 0, + group = "Overrides (0 = inherit)", + help = INHERIT_ZERO, + }, + { + attr = "AttackCooldown", + kind = "number", + label = "Attack cooldown (s)", + default = 0, + min = 0, + group = "Overrides (0 = inherit)", + help = INHERIT_ZERO, + }, + { + attr = "WanderRadius", + kind = "number", + label = "Wander radius", + default = 0, + min = 0, + group = "Overrides (0 = inherit)", + help = INHERIT_ZERO, + }, + }, + }, + + QuestGiver = { + name = "QuestGiver", + tag = "QuestGiver", + display = { + title = "Quest giver", + summary = "A sign or NPC that hands out one quest and takes its turn-in.", + instance = "any", + order = 3, + hint = "Point it at a quest; the engine attaches the accept / turn-in prompt.", + }, + attributes = { + { + attr = "Quest", + kind = "string", + label = "Quest", + default = "", + ref = "Quests", + placeholder = "a Quests id", + group = "What it is", + help = "The quest this giver offers and accepts turn-ins for.", + }, + }, + }, +} + +-- Chooser order. A reader must tolerate this naming an entry that no longer exists, AND entries +-- this list doesn't cover (a newer engine adding a component). +Schema.ORDER = { "Gatherable", "Mob", "QuestGiver" } + +function Schema.get(name: string): ComponentSchema? + return Schema.COMPONENTS[name] +end + +-- Every schema, in ORDER first, then anything ORDER missed (alphabetically) — so a newer engine's +-- components still surface in an older reader. +function Schema.list(): { ComponentSchema } + local out, seen = {}, {} + for _, name in Schema.ORDER do + local entry = Schema.COMPONENTS[name] + if entry then + seen[name] = true + table.insert(out, entry) + end + end + local rest = {} + for name in Schema.COMPONENTS do + if not seen[name] then + table.insert(rest, name) + end + end + table.sort(rest) + for _, name in rest do + table.insert(out, Schema.COMPONENTS[name]) + end + return out +end + +return Schema diff --git a/src/components/init.luau b/src/components/init.luau index e3f8e64..01bf9c6 100644 --- a/src/components/init.luau +++ b/src/components/init.luau @@ -11,30 +11,74 @@ }) Call Components.scan() once (SurvivorCore.start does this) to bind existing tagged - instances and watch for new ones. A future builder UI can set these attributes - visually instead of by hand. + instances and watch for new ones. + + `attributes` accepts EITHER form: + • the shorthand map above (attribute -> default), or + • a SCHEMA array of AttributeSpec (kind/label/help/choices/…) — see components/Schema.luau. + Both bind identically; the schema form additionally lets the Studio plugin render a setup form + for the component, so a creator picks "what is this object?" instead of typing attribute names. + Declare a `display` too ({ title, summary, instance }) so it can appear in that chooser. ]] local CollectionService = game:GetService("CollectionService") +local Schema = require(script.Schema) + local Components = {} +export type AttributeSpec = Schema.AttributeSpec + export type Spec = { name: string, tag: string, - attributes: { [string]: any }?, -- attribute name -> default value + -- attribute -> default (shorthand) OR an array of AttributeSpec (schema form) + attributes: ({ [string]: any } | { Schema.AttributeSpec })?, + display: Schema.Display?, -- builder-UI presentation (title / summary / eligible class / hint) onSetup: ((instance: Instance, values: { [string]: any }) -> ())?, } local defined: { [string]: Spec } = {} +local schemas: { [string]: Schema.ComponentSchema } = {} +local defaults: { [string]: { [string]: any } } = {} -- the attr->default map bind() reads function Components.define(spec: Spec): Spec assert(spec.name and spec.tag, "Component requires `name` and `tag`") assert(defined[spec.name] == nil, `Component '{spec.name}' already defined`) + local attributes = Schema.normalize(spec.attributes) defined[spec.name] = spec + schemas[spec.name] = { + name = spec.name, + tag = spec.tag, + display = spec.display or { title = spec.name, summary = "" }, + attributes = attributes, + } + defaults[spec.name] = Schema.defaults(attributes) return spec end +-- Introspection (the runtime mirror of components/Schema.luau, including anything a game defined +-- at runtime — which the edit-time plugin can't see). +function Components.getSchema(name: string): Schema.ComponentSchema? + return schemas[name] +end + +function Components.listSchemas(): { Schema.ComponentSchema } + local out = {} + for _, entry in schemas do + table.insert(out, entry) + end + table.sort(out, function(a, b) + local ao = (a.display and tonumber(a.display.order)) or 100 + local bo = (b.display and tonumber(b.display.order)) or 100 + if ao ~= bo then + return ao < bo + end + return a.name < b.name + end) + return out +end + local function readAttributes(instance: Instance, attributes: { [string]: any }?) local values = {} if attributes then @@ -52,7 +96,8 @@ local function bind(instance: Instance, spec: Spec) end instance:SetAttribute("_scBound", true) if spec.onSetup then - spec.onSetup(instance, readAttributes(instance, spec.attributes)) + -- Always the normalized attr->default map, so both `attributes` forms bind identically. + spec.onSetup(instance, readAttributes(instance, defaults[spec.name])) end end From bbfefbec38109415db6f3d711f70598f299cfc9d Mon Sep 17 00:00:00 2001 From: Samuel Lison Date: Thu, 30 Jul 2026 17:54:21 +1000 Subject: [PATCH 2/5] fix(builder): chooser cards were unclickable and misrouted picks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by an adversarial review of the Build page before it was ever run — all three reviewers independently flagged the first one. 1. The "what is this object?" cards were dead. chooserCard parented a Size=fromScale(1,1) TextButton into Theme.panel() intending an overlay, but Theme.panel() contains a UIListLayout, which lays out EVERY GuiObject child — there is no opt-out, and ZIndex does not affect layout. So the button became another list row: clicking a card's title/summary did nothing, and the oversized button spilled past the card and took the click for the card BELOW, applying the WRONG component (which also strips the previous component's attributes). This was the page's primary interaction. Fixed with Theme.panelButton() — the card itself is the button, matching the pattern OverviewPage already uses. 2. Clear did nothing on an object whose only tag was unknown to this engine, yet reported success. The page renders exactly that branch with a Clear button. BuildAdmin.clear now takes the tags to remove explicitly, and the page passes the unknown tags it just displayed — never removing an unlisted tag speculatively, since it may belong to another plugin. It also reports "nothing to clear" instead of a false success. 3. FieldRow's help text set Position inside a UIListLayout parent, so its indent was silently dropped and every help line rendered flush left. Uses padding now. Co-Authored-By: Claude Opus 4.8 --- plugin/BuildAdmin.luau | 16 ++++++++++++++-- plugin/BuildAdminUi.luau | 27 ++++++++++++--------------- plugin/FieldRow.luau | 10 ++++++---- plugin/Theme.luau | 21 +++++++++++++++++++++ plugin/init.server.luau | 4 ++-- 5 files changed, 55 insertions(+), 23 deletions(-) diff --git a/plugin/BuildAdmin.luau b/plugin/BuildAdmin.luau index 457b5ac..267ac87 100644 --- a/plugin/BuildAdmin.luau +++ b/plugin/BuildAdmin.luau @@ -267,8 +267,11 @@ function BuildAdmin.applyType(instances: { Instance }, schema: any, all: { any } end -- The honest inverse of applyType: remove every known component tag, its attributes, and the --- engine's `_`-prefixed stamps. Returns { cleared }. -function BuildAdmin.clear(instances: { Instance }, all: { any }): any +-- engine's `_`-prefixed stamps. `extraTags` removes specific additional tags by name — the page +-- passes the unknown tags it just displayed, so its "Clear" button actually clears what the user +-- was shown. Tags are never removed speculatively: an unlisted tag may belong to another plugin. +-- Returns { cleared }. +function BuildAdmin.clear(instances: { Instance }, all: { any }, extraTags: { string }?): any local cleared = 0 for _, instance in instances do if not alive(instance) then @@ -282,6 +285,15 @@ function BuildAdmin.clear(instances: { Instance }, all: { any }): any touched = true end end + for _, tag in (extraTags or {}) :: { string } do + if CollectionService:HasTag(instance, tag) then + CollectionService:RemoveTag(instance, tag) + touched = true + end + end + if instance:GetAttribute(BOUND_ATTR) ~= nil then + touched = true + end stripEngineStamps(instance) if touched then cleared += 1 diff --git a/plugin/BuildAdminUi.luau b/plugin/BuildAdminUi.luau index bbdc021..d602e04 100644 --- a/plugin/BuildAdminUi.luau +++ b/plugin/BuildAdminUi.luau @@ -49,8 +49,11 @@ local function countAdvanced(specs: { any }): number end -- One clickable "what is this?" card. Ineligible components render dim and inert, with the reason. -local function chooserCard(schema: any, display: any, eligible: boolean, reason: string?, onPick): Frame - local panel = Theme.panel() +-- The CARD ITSELF is the button when it's pickable: a full-size button parented INTO a panel would +-- be laid out by the panel's UIListLayout as another row (no opt-out; ZIndex doesn't affect +-- layout), leaving the card body dead and the button spilling onto the next card. +local function chooserCard(schema: any, display: any, eligible: boolean, reason: string?, onPick): GuiObject + local panel: any = if eligible then Theme.panelButton() else Theme.panel() local title = Theme.label({ Size = UDim2.new(1, 0, 0, 20), Text = display.title, @@ -74,15 +77,7 @@ local function chooserCard(schema: any, display: any, eligible: boolean, reason: panel if eligible then - -- A transparent button over the whole card keeps the panel's styling but makes it clickable. - local hit = Theme.make("TextButton", { - Size = UDim2.fromScale(1, 1), - BackgroundTransparency = 1, - Text = "", - ZIndex = 3, - }) :: TextButton - hit.Parent = panel - hit.MouseButton1Click:Connect(function() + (panel :: TextButton).MouseButton1Click:Connect(function() onPick(schema) end) Theme.hover(panel, Theme.COLOR.PANEL, Theme.COLOR.PANEL_HOVER) @@ -160,12 +155,13 @@ function BuildAdminUi.mount(container: Frame, BuildAdmin: any, ContentAdmin: any end end - local function clearType(instances: { Instance }, all: { any }) - local res = actions.clear(instances, all) + local function clearType(instances: { Instance }, all: { any }, extraTags: { string }?) + local res = actions.clear(instances, all, extraTags) chooserOpen = false refresh() if typeof(res) == "table" then - say(`✓ cleared {res.cleared or 0} object(s)`) + local n = res.cleared or 0 + say(if n > 0 then `✓ cleared {n} object(s)` else "nothing to clear on that object") end end @@ -432,7 +428,8 @@ function BuildAdminUi.mount(container: Frame, BuildAdmin: any, ContentAdmin: any }) clearBtn.Parent = scroll clearBtn.MouseButton1Click:Connect(function() - clearType({ instance }, all) + -- Pass the unknown tags we just listed, so Clear removes exactly what was shown. + clearType({ instance }, all, id.unknownTags) end) end diff --git a/plugin/FieldRow.luau b/plugin/FieldRow.luau index 258656e..b04a1a2 100644 --- a/plugin/FieldRow.luau +++ b/plugin/FieldRow.luau @@ -140,18 +140,20 @@ function FieldRow.build(opts: any): Frame end if hasHelp then - Theme.label({ + -- `row` owns a UIListLayout, which overwrites child Position — so the indent under the + -- label column has to come from padding, not Position. + local help = Theme.label({ Size = UDim2.fromScale(1, 0), AutomaticSize = Enum.AutomaticSize.Y, - Position = UDim2.fromOffset(22, 0), Text = spec.help, TextColor3 = Theme.COLOR.DIM, TextWrapped = true, TextSize = 11, TextTruncate = Enum.TextTruncate.None, LayoutOrder = 2, - }).Parent = - row + }) + Theme.pad(22, 0, 0, 2).Parent = help + help.Parent = row end return row diff --git a/plugin/Theme.luau b/plugin/Theme.luau index 9dc560f..ec97616 100644 --- a/plugin/Theme.luau +++ b/plugin/Theme.luau @@ -171,6 +171,27 @@ function Theme.panel(): Frame }) :: Frame end +-- A CLICKABLE panel: identical styling to Theme.panel, but the card itself is the button. +-- Use this instead of parenting a full-size button "overlay" into a panel — the panel's own +-- UIListLayout lays out EVERY GuiObject child (there is no opt-out, and ZIndex doesn't change +-- layout), so such an "overlay" becomes another list row: the card body stops being clickable and +-- the oversized button spills onto whatever is rendered next. +function Theme.panelButton(): TextButton + return Theme.make("TextButton", { + BackgroundColor3 = Theme.COLOR.PANEL, + BorderSizePixel = 0, + Size = UDim2.fromScale(1, 0), + AutomaticSize = Enum.AutomaticSize.Y, + Text = "", + AutoButtonColor = false, -- Theme.hover owns the highlight, matching the other cards + }, { + Theme.corner(8), + Theme.stroke(), + Theme.pad(8, 8, 6, 8), + Theme.make("UIListLayout", { Padding = UDim.new(0, 2), SortOrder = Enum.SortOrder.LayoutOrder }), + }) :: TextButton +end + -- A small count/status pill (rail badges, entry badges). function Theme.badge(text: string, textColor: Color3?): TextLabel return Theme.make("TextLabel", { diff --git a/plugin/init.server.luau b/plugin/init.server.luau index f561824..bd2ed18 100644 --- a/plugin/init.server.luau +++ b/plugin/init.server.luau @@ -123,9 +123,9 @@ local buildActions = { return BuildAdmin.applyType(instances, schema, all) end) end, - clear = function(instances: { Instance }, all: { any }): any + clear = function(instances: { Instance }, all: { any }, extraTags: { string }?): any return record("Build: clear component", function() - return BuildAdmin.clear(instances, all) + return BuildAdmin.clear(instances, all, extraTags) end) end, setField = function(instance: Instance, spec: any, raw: any): any From f7a751264a9ecc1b4e5b555f75446ef92ed9f682 Mon Sep 17 00:00:00 2001 From: Samuel Lison Date: Thu, 30 Jul 2026 17:57:25 +1000 Subject: [PATCH 3/5] chore: re-trigger CI From d15b47e7d877840b80f90363b31165d6a4b20e82 Mon Sep 17 00:00:00 2001 From: Samuel Lison Date: Thu, 30 Jul 2026 18:12:28 +1000 Subject: [PATCH 4/5] fix(builder): point first-time creators at Content when a picker is empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found in real use: with no gatherables authored yet, the Build form's Resource field has no ⌄ picker (there's nothing to cycle) and nothing explains where entries come from — so the very first no-code run dead-ends at the moment the flow should feel best. A field that references authored content now shows "No Gatherables yet — create one →" when that category is empty, which jumps straight to the right Content page. Not a mutation, so it isn't wrapped in a ChangeHistory recording. Co-Authored-By: Claude Opus 4.8 --- plugin/BuildAdminUi.luau | 21 +++++++++++++++++++++ plugin/init.server.luau | 7 +++++++ 2 files changed, 28 insertions(+) diff --git a/plugin/BuildAdminUi.luau b/plugin/BuildAdminUi.luau index d602e04..6aebc05 100644 --- a/plugin/BuildAdminUi.luau +++ b/plugin/BuildAdminUi.luau @@ -220,6 +220,27 @@ function BuildAdminUi.mount(container: Frame, BuildAdmin: any, ContentAdmin: any }) row.LayoutOrder = i row.Parent = panel + + -- First-run dead end: a field that points at authored content is useless until some + -- exists, and the picker simply isn't there to hint at it. Say where to go, and + -- offer to take them. + if spec.ref and (not choices or #choices == 0) then + local cat = ContentAdmin.CATEGORIES and ContentAdmin.CATEGORIES[spec.ref] + local catTitle = (cat and cat.title) or spec.ref + local jump = Theme.button({ + Size = UDim2.new(1, 0, 0, 22), + Text = `No {catTitle} yet — create one →`, + TextColor3 = Theme.COLOR.ACCENT, + TextSize = 11, + LayoutOrder = i, + }) + jump.Parent = panel + jump.MouseButton1Click:Connect(function() + if actions.selectPage then + actions.selectPage("content/" .. spec.ref) + end + end) + end end end diff --git a/plugin/init.server.luau b/plugin/init.server.luau index bd2ed18..bc212dc 100644 --- a/plugin/init.server.luau +++ b/plugin/init.server.luau @@ -138,6 +138,13 @@ local buildActions = { return BuildAdmin.resetField(instance, spec) end) end, + -- Not a mutation (no recording): jump to a Content page so a field that references authored + -- content can send a first-time creator somewhere useful instead of dead-ending. + selectPage = function(id: string) + if nav then + nav.selectPage(id) + end + end, } -- ── Content callbacks (authored + overrides), bundled for the pages ────────── From 3a0206e566345a4e86016300cb27b3294419ac2f Mon Sep 17 00:00:00 2001 From: Samuel Lison Date: Sat, 1 Aug 2026 19:29:49 +1000 Subject: [PATCH 5/5] chore(release): v0.10.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote CHANGELOG Unreleased → 0.10.0 (the schema-driven Build page for world objects, #11) and bump wally.toml + SurvivorCore.VERSION. Demo: new demo/server/Stage.luau — one switch for what the demo places in the world (gather field + quest post, mobs, starting inventory, hand-test props). The test-station prop rows now default OFF: they're scaffolding for working ON the engine and crowded the spawn area for everyone else. Collateral: the Build demo video on the docs Demos line and as an 8th site video tile; the landing page's Studio card now tells the Build story; version surfaces bumped across site, README and getting-started. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 11 ++++++- README.md | 4 +-- demo/server/Boot.server.luau | 33 +++++++++++++------- demo/server/InventoryTestStation.server.luau | 11 ++++--- demo/server/Stage.luau | 27 ++++++++++++++++ demo/server/StatTestStation.server.luau | 11 ++++--- docs/admin-plugin.md | 2 +- docs/getting-started.md | 2 +- site/index.html | 8 +++-- src/init.luau | 2 +- wally.toml | 2 +- 11 files changed, 84 insertions(+), 29 deletions(-) create mode 100644 demo/server/Stage.luau diff --git a/CHANGELOG.md b/CHANGELOG.md index 16c3b58..e8bf85d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,16 @@ 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 +## 0.10.0 — 2026-08-01 + +### Changed +- **Demo: one switch for what the world contains** — `demo/server/Stage.luau` toggles the gather + field + quest post, the mobs, the starting inventory and the hand-test prop rows independently. + The **test stations now default OFF**: that labelled row of DAMAGE / FEED / POISON / BLEED + prompts (plus the item pickups) is scaffolding for working *on* the engine, and it crowded the + spawn area for everyone else. Flip any flag on to get that piece back; turn them all off for a + clean stage when filming or when building your own world on the engine. Content *definitions* + register regardless, so the admin plugin's pickers still list the demo's items and mobs. ### Added - **Build: no-code world objects** (#11) — select a Part or Model in Studio, answer **"what is this diff --git a/README.md b/README.md index 0a4a720..a63e6d3 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ -> **Status: v0.9.0 — pre-release.** The core survival loop is in and working; the engine is +> **Status: v0.10.0 — pre-release.** The core survival loop is in and working; the engine is > being grown toward v1.0 and APIs may still shift. Production-tested in > [The Counter Earth](https://thecounterearth.com). @@ -71,7 +71,7 @@ into `ReplicatedStorage`, or add it via [Wally](https://wally.run): ```toml # wally.toml [dependencies] -SurvivorCore = "temujincalidius/survivorcore@0.9.0" +SurvivorCore = "temujincalidius/survivorcore@0.10.0" ``` Working from source? Clone and `rojo serve` the `demo.project.json` place. diff --git a/demo/server/Boot.server.luau b/demo/server/Boot.server.luau index 2d6dadd..bba3a57 100644 --- a/demo/server/Boot.server.luau +++ b/demo/server/Boot.server.luau @@ -12,6 +12,9 @@ local Players = game:GetService("Players") local ReplicatedStorage = game:GetService("ReplicatedStorage") local SurvivorCore = require(ReplicatedStorage:WaitForChild("SurvivorCore")) +-- What this demo places in the world — see demo/server/Stage.luau to turn any of it off. +local STAGE = require(script.Parent.Stage) + -- 1. Programmatic content ----------------------------------------------------- -- NOTE: the `icon` ids below are PLACEHOLDERS (the engine's stat icons) so the demo grid -- isn't empty. A real game registers proper item icons (per-item `icon` or the "ItemIcons" @@ -416,10 +419,12 @@ local function seedPlayer(player: Player) player:SetAttribute("EquipSlot_Head", "straw_hat") -- a pre-filled equipment slot player:SetAttribute("EquipSlot_Back", "reed_satchel") -- equipped for +slots / +carry weight end -for _, player in Players:GetPlayers() do - seedPlayer(player) +if STAGE.SeedInventory then + for _, player in Players:GetPlayers() do + seedPlayer(player) + end + Players.PlayerAdded:Connect(seedPlayer) end -Players.PlayerAdded:Connect(seedPlayer) SurvivorCore.start() @@ -509,10 +514,12 @@ local function buildReed(position: Vector3) 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)) +if STAGE.WorldContent then + buildTree(Vector3.new(10, 0, 28)) + buildTree(Vector3.new(18, 0, 28)) + buildReed(Vector3.new(-8, 0, 28)) + buildReed(Vector3.new(-12, 0, 28)) +end -- 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) @@ -582,9 +589,11 @@ buildWeaponTemplates() -- Husks live out past their aggro+wander reach of spawn — new players shouldn't get farmed at -- the spawn point before they've picked up a weapon (they'll still hunt you in their territory). -SurvivorCore.Mobs.spawn("husk", CFrame.new(70, 5, 45), { respawn = true }) -SurvivorCore.Mobs.spawn("husk", CFrame.new(82, 5, 58), { respawn = true }) -SurvivorCore.Mobs.spawn("boar", CFrame.new(-30, 5, 18)) +if STAGE.Mobs then + SurvivorCore.Mobs.spawn("husk", CFrame.new(70, 5, 45), { respawn = true }) + SurvivorCore.Mobs.spawn("husk", CFrame.new(82, 5, 58), { respawn = true }) + SurvivorCore.Mobs.spawn("boar", CFrame.new(-30, 5, 18)) +end -- The quest-giver post: offers `slay_husk` (hold E to accept; return to turn in). Any mesh works — -- tag it "QuestGiver" + set Quest — this demo just uses a marked wooden post. @@ -608,7 +617,9 @@ local function buildQuestPost(position: Vector3) CollectionService:AddTag(post, "QuestGiver") post.Parent = Workspace end -buildQuestPost(Vector3.new(8, 0, 8)) +if STAGE.WorldContent then + buildQuestPost(Vector3.new(8, 0, 8)) +end -- Flourish hooks: print quest + achievement milestones to the output. SurvivorCore.Hooks.on("quest:completed", function(ctx) diff --git a/demo/server/InventoryTestStation.server.luau b/demo/server/InventoryTestStation.server.luau index 5093520..3801f95 100644 --- a/demo/server/InventoryTestStation.server.luau +++ b/demo/server/InventoryTestStation.server.luau @@ -14,6 +14,7 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") local Workspace = game:GetService("Workspace") local SurvivorCore = require(ReplicatedStorage:WaitForChild("SurvivorCore")) +local Stage = require(script.Parent.Stage) local PICKUPS = { { label = "PICK UP Stone Axe", item = "stone_axe", amount = 1, color = Color3.fromRGB(120, 130, 140) }, @@ -67,8 +68,10 @@ local function buildPickup(def: any, position: Vector3) end) end -local BASE = Vector3.new(0, 4, 16) -- opposite side from the stat test stations -for i, def in PICKUPS do - local x = (i - (#PICKUPS + 1) / 2) * 4 - buildPickup(def, BASE + Vector3.new(x, 0, 0)) +if Stage.TestStations then + local BASE = Vector3.new(0, 4, 16) -- opposite side from the stat test stations + for i, def in PICKUPS do + local x = (i - (#PICKUPS + 1) / 2) * 4 + buildPickup(def, BASE + Vector3.new(x, 0, 0)) + end end diff --git a/demo/server/Stage.luau b/demo/server/Stage.luau new file mode 100644 index 0000000..ef0397a --- /dev/null +++ b/demo/server/Stage.luau @@ -0,0 +1,27 @@ +--!nonstrict +--[[ + Demo stage toggles — ONE place to control what the demo places in the world. + + The demo furnishes a world so the engine has something to DO out of the box. Turn any of it + off for a CLEAN STAGE: filming, screenshots, or building your own world on top of the engine + without the demo's furniture in shot. + + Content DEFINITIONS (items, recipes, quests, achievements, mob types) are always registered + regardless — so the admin plugin's pickers still list `reed_bush`, `husk`, `boar` and friends, + and you can point your own objects at real content. These flags only control what gets PLACED + in the world and what the player starts with. + + Set them all to false and pressing Play gives you an empty baseplate. +]] + +return { + WorldContent = true, -- the gather field (trees + reeds) and the quest post + Mobs = true, -- the husks + the boar + SeedInventory = true, -- the starting items, hotbar pins and worn equipment + + -- OFF by default: these are hand-test scaffolding (a labelled row of DAMAGE / FEED / POISON / + -- BLEED prompts, plus item pickups) for exercising the stat and inventory APIs. Useful when + -- you're working ON the engine, but they crowd the spawn area for everyone else. Flip on when + -- you want to poke the survival systems by hand. + TestStations = false, +} diff --git a/demo/server/StatTestStation.server.luau b/demo/server/StatTestStation.server.luau index 6ded7c2..6b931ef 100644 --- a/demo/server/StatTestStation.server.luau +++ b/demo/server/StatTestStation.server.luau @@ -16,6 +16,7 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") local Workspace = game:GetService("Workspace") local SurvivorCore = require(ReplicatedStorage:WaitForChild("SurvivorCore")) +local Stage = require(script.Parent.Stage) local POISON_SOURCE = "TestStation:poison" local BLEED_SOURCE = "TestStation:bleed" @@ -149,8 +150,10 @@ end -- (The engine syncs the "Health" stat to the Humanoid + resets stats on respawn now, so the -- DAMAGE part's real damage shows on the HUD and death/respawn comes out clean on its own.) -local BASE = Vector3.new(0, 4, -16) -for i, def in STATIONS do - local x = (i - (#STATIONS + 1) / 2) * 4 - buildStation(def, BASE + Vector3.new(x, 0, 0)) +if Stage.TestStations then + local BASE = Vector3.new(0, 4, -16) + for i, def in STATIONS do + local x = (i - (#STATIONS + 1) / 2) * 4 + buildStation(def, BASE + Vector3.new(x, 0, 0)) + end end diff --git a/docs/admin-plugin.md b/docs/admin-plugin.md index 455b285..b50c479 100644 --- a/docs/admin-plugin.md +++ b/docs/admin-plugin.md @@ -31,7 +31,7 @@ This is the [Builder / Admin plugin](https://github.com/TemujinCalidius/Survivor > Studio forgets the old panel's dock position **once** — the new window opens floating; dock it > wherever you like and Studio remembers from then on. -> 📹 **Demos:** [HUD, survival stats & the admin plugin](https://makertube.net/w/xqX7wfRpTqd9L9BkozCS1P) · [no-code item & gatherable creation](https://makertube.net/w/mCneurjoY3Av6yi48VsGQE) · [no-code weapon, ammo & mob creation](https://makertube.net/w/tyn8JEMG3CaMbTXid8osdU) · [no-code quest & achievement creation](https://makertube.net/w/uSGJ2MHEFjSSKxMiJBJ6Y5) · [SurvivorCore Studio — the no-code admin window](https://makertube.net/w/g4oySJeXD4Th7f1zYEu9Bz) +> 📹 **Demos:** [HUD, survival stats & the admin plugin](https://makertube.net/w/xqX7wfRpTqd9L9BkozCS1P) · [no-code item & gatherable creation](https://makertube.net/w/mCneurjoY3Av6yi48VsGQE) · [no-code weapon, ammo & mob creation](https://makertube.net/w/tyn8JEMG3CaMbTXid8osdU) · [no-code quest & achievement creation](https://makertube.net/w/uSGJ2MHEFjSSKxMiJBJ6Y5) · [SurvivorCore Studio — the no-code admin window](https://makertube.net/w/g4oySJeXD4Th7f1zYEu9Bz) · [Build — a plain Part into a working iron node](https://makertube.net/w/2kkyPbDWqoyuKcgbiCGwQG) ## Install diff --git a/docs/getting-started.md b/docs/getting-started.md index 65a4aec..fb5c757 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -34,7 +34,7 @@ Once published, add it to your game's `wally.toml`: ```toml [dependencies] -SurvivorCore = "temujincalidius/survivorcore@0.9.0" +SurvivorCore = "temujincalidius/survivorcore@0.10.0" ``` Then: diff --git a/site/index.html b/site/index.html index d899c71..740c3b5 100644 --- a/site/index.html +++ b/site/index.html @@ -55,7 +55,7 @@
- v0.9.0 · pre-release + v0.10.0 · pre-release

The survival game engine for Roblox

@@ -152,7 +152,8 @@

SurvivorCore Studio — no-code admin

-

One floating window with a sidebar & search: tune every engine config section — movement, combat, mobs, loot, even UI theme colours & fonts — as locked deltas that survive engine updates. Create items, weapons, ammo, mobs, quests and achievements from forms, override code-registered content field-by-field, and drop any of it into the world. No scripting.

+

One floating window with a sidebar & search: tune every engine config section — movement, combat, mobs, loot, even UI theme colours & fonts — as locked deltas that survive engine updates. Create items, weapons, ammo, mobs, quests and achievements from forms, and override code-registered content field-by-field. No scripting.

+

Then Build turns your own geometry into game content: select any Part or Model, answer “what is this object?”, and fill a form — it becomes a gatherable node, a creature or a quest giver. The form is generated from the engine's own component schema, so every component gets a setup form for free, with no UI code behind it.

@@ -180,6 +181,7 @@
Hunting & loot bags
SurvivorCore Studio
Player trading
+
Build: no-code objects
@@ -195,7 +197,7 @@

Grab the drop-in model from the latest release, or add it with Wally:

# wally.toml
 [dependencies]
-SurvivorCore = "temujincalidius/survivorcore@0.9.0"
+SurvivorCore = "temujincalidius/survivorcore@0.10.0"

…or drop SurvivorCore.rbxm into ReplicatedStorage.

Latest release ↗ diff --git a/src/init.luau b/src/init.luau index c02caa9..9fba458 100644 --- a/src/init.luau +++ b/src/init.luau @@ -74,7 +74,7 @@ local EngineConfig = require(script.shared.EngineConfig) local SurvivorCore = {} -SurvivorCore.VERSION = "0.9.0" +SurvivorCore.VERSION = "0.10.0" -- Foundation SurvivorCore.Config = Config diff --git a/wally.toml b/wally.toml index 01d126c..b798c72 100644 --- a/wally.toml +++ b/wally.toml @@ -1,7 +1,7 @@ [package] name = "temujincalidius/survivorcore" description = "Batteries-included, creator-extensible survival game framework for Roblox." -version = "0.9.0" +version = "0.10.0" license = "MIT" authors = ["Samuel Lison"] registry = "https://github.com/UpliftGames/wally-index"