From 29bd20a93f8305a43b5bf9538dde600de3c5748e Mon Sep 17 00:00:00 2001 From: Samuel Lison Date: Thu, 30 Jul 2026 17:14:52 +1000 Subject: [PATCH] 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