mirror of
https://github.com/TemujinCalidius/SurvivorCore.git
synced 2026-08-14 00:58:01 +00:00
feat(builder): schema-driven Build page for world objects (#11)
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
7346c5ccf4
commit
29bd20a93f
@@ -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`
|
[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`.
|
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
|
## 0.9.0 — 2026-07-30
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|||||||
+4
-1
@@ -80,7 +80,10 @@ components, hooks, and the foundation. See [Architecture Overview](#architecture
|
|||||||
- **Registries** — developers call `register()` from code (`Items`, `Recipes`, `Stats`,
|
- **Registries** — developers call `register()` from code (`Items`, `Recipes`, `Stats`,
|
||||||
`Mobs`, …).
|
`Mobs`, …).
|
||||||
- **Components** — creators tag their own objects and set Attributes (`Gatherable`, and the
|
- **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,
|
- **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.
|
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.
|
If you need a new extension point, add a `Hooks.run("…")` call and document it.
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ Attributes in the Explorer. One **SurvivorCore › Studio** toolbar button opens
|
|||||||
window** (drag-dock it anywhere) with a sidebar of editors:
|
window** (drag-dock it anywhere) with a sidebar of editors:
|
||||||
|
|
||||||
- **Overview** — is the engine synced, what content exists (click through), how the model works.
|
- **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`
|
- **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**.
|
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,
|
- **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
|
> Rojo-synced place, so reconnect Rojo and re-sync (or save the place before restarting) to bring
|
||||||
> the engine back.
|
> 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
|
## Survival Stats
|
||||||
|
|
||||||
Each stat shows the seven tunable fields, each displaying its **effective** value (your override if
|
Each stat shows the seven tunable fields, each displaying its **effective** value (your override if
|
||||||
|
|||||||
@@ -117,6 +117,44 @@ SurvivorCore.Components.define({
|
|||||||
tagged and keeps binding new instances as they appear. Each instance is bound once (guarded by
|
tagged and keeps binding new instances as they appear. Each instance is bound once (guarded by
|
||||||
an internal `_scBound` attribute).
|
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
|
## 3. Hooks — react to engine lifecycle events
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
+5
-102
@@ -19,15 +19,12 @@
|
|||||||
|
|
||||||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
|
|
||||||
|
local Field = require(script.Parent.Field)
|
||||||
|
|
||||||
local ConfigAdmin = {}
|
local ConfigAdmin = {}
|
||||||
|
|
||||||
ConfigAdmin.INSTANCE_NAME = "SurvivorCoreEngineConfig"
|
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) ────────────────────────────────────────────
|
-- ── Schema (live from the engine) ────────────────────────────────────────────
|
||||||
|
|
||||||
local function findEngineConfigModule(): ModuleScript?
|
local function findEngineConfigModule(): ModuleScript?
|
||||||
@@ -126,113 +123,19 @@ end
|
|||||||
-- Parse a raw edit (usually TextBox text) per the field spec. Returns { ok, value } or
|
-- 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").
|
-- { ok = false, error }. A blank string is handled by setOverride (it means "reset").
|
||||||
function ConfigAdmin.coerce(field: any, raw: any): any
|
function ConfigAdmin.coerce(field: any, raw: any): any
|
||||||
if field.kind == "number" then
|
return Field.coerce(field, raw)
|
||||||
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
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Does a coerced value equal the engine default (→ store nothing)? Kind-aware: fonts compare
|
-- 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
|
-- the stored NAME against the default Enum.Font; Color3 compares exactly (both sides come from
|
||||||
-- fromRGB construction); numbers use the relative epsilon.
|
-- fromRGB construction); numbers use the relative epsilon.
|
||||||
function ConfigAdmin.equalsDefault(field: any, value: any, default: any): boolean
|
function ConfigAdmin.equalsDefault(field: any, value: any, default: any): boolean
|
||||||
if field.kind == "number" then
|
return Field.equalsDefault(field, value, default)
|
||||||
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
|
end
|
||||||
|
|
||||||
-- Display formatting for effective values/defaults (what the TextBox shows).
|
-- Display formatting for effective values/defaults (what the TextBox shows).
|
||||||
function ConfigAdmin.formatValue(field: any, value: any): string
|
function ConfigAdmin.formatValue(field: any, value: any): string
|
||||||
if value == nil then
|
return Field.format(field, value)
|
||||||
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
|
end
|
||||||
|
|
||||||
-- ── Writes (the deltas-only decision point) ──────────────────────────────────
|
-- ── Writes (the deltas-only decision point) ──────────────────────────────────
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -32,6 +32,8 @@ local ContentAdmin = require(script.ContentAdmin)
|
|||||||
local ContentAdminUi = require(script.ContentAdminUi)
|
local ContentAdminUi = require(script.ContentAdminUi)
|
||||||
local ConfigAdmin = require(script.ConfigAdmin)
|
local ConfigAdmin = require(script.ConfigAdmin)
|
||||||
local ConfigAdminUi = require(script.ConfigAdminUi)
|
local ConfigAdminUi = require(script.ConfigAdminUi)
|
||||||
|
local BuildAdmin = require(script.BuildAdmin)
|
||||||
|
local BuildAdminUi = require(script.BuildAdminUi)
|
||||||
|
|
||||||
local SETTING_LAST_PAGE = "SurvivorCoreStudio.lastPage"
|
local SETTING_LAST_PAGE = "SurvivorCoreStudio.lastPage"
|
||||||
|
|
||||||
@@ -112,6 +114,32 @@ local function applyConfigResetSection(section: any): any
|
|||||||
end)
|
end)
|
||||||
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 ──────────
|
-- ── Content callbacks (authored + overrides), bundled for the pages ──────────
|
||||||
local actions = {
|
local actions = {
|
||||||
set = function(catKey: string, id: string, field: any, raw: any): any
|
set = function(catKey: string, id: string, field: any, raw: any): any
|
||||||
@@ -185,6 +213,17 @@ local function mountUi()
|
|||||||
end,
|
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, {
|
table.insert(pages, {
|
||||||
id = "stats",
|
id = "stats",
|
||||||
label = "Survival Stats",
|
label = "Survival Stats",
|
||||||
|
|||||||
@@ -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"]}]}
|
{"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"]}]}
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
]]
|
]]
|
||||||
|
|
||||||
local Components = require(script.Parent)
|
local Components = require(script.Parent)
|
||||||
|
local Schema = require(script.Parent.Schema)
|
||||||
local Registries = require(script.Parent.Parent.registries)
|
local Registries = require(script.Parent.Parent.registries)
|
||||||
local Harvesting = require(script.Parent.Parent.systems.Harvesting)
|
local Harvesting = require(script.Parent.Parent.systems.Harvesting)
|
||||||
|
|
||||||
@@ -64,22 +65,15 @@ local function resolve(values: { [string]: any })
|
|||||||
}
|
}
|
||||||
end
|
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({
|
return Components.define({
|
||||||
name = "Gatherable",
|
name = SCHEMA.name,
|
||||||
tag = "Gatherable",
|
tag = SCHEMA.tag,
|
||||||
attributes = {
|
display = SCHEMA.display,
|
||||||
Resource = "", -- named resource def to inherit from (optional)
|
attributes = SCHEMA.attributes,
|
||||||
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
|
|
||||||
},
|
|
||||||
onSetup = function(instance, values)
|
onSetup = function(instance, values)
|
||||||
local r = resolve(values)
|
local r = resolve(values)
|
||||||
|
|
||||||
|
|||||||
+9
-15
@@ -12,24 +12,18 @@
|
|||||||
]]
|
]]
|
||||||
|
|
||||||
local Components = require(script.Parent)
|
local Components = require(script.Parent)
|
||||||
|
local Schema = require(script.Parent.Schema)
|
||||||
local Mobs = require(script.Parent.Parent.systems.Mobs)
|
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({
|
return Components.define({
|
||||||
name = "Mob",
|
name = SCHEMA.name,
|
||||||
tag = "Mob",
|
tag = SCHEMA.tag,
|
||||||
attributes = {
|
display = SCHEMA.display,
|
||||||
MobType = "", -- the Mobs def id to inherit from; blank = use the model's Name
|
attributes = SCHEMA.attributes,
|
||||||
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
|
|
||||||
},
|
|
||||||
onSetup = function(instance, _values)
|
onSetup = function(instance, _values)
|
||||||
-- The server Mobs runtime reads the instance attributes directly (one merge path:
|
-- 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.
|
-- override attr › registry def › "Mobs" Config), so nothing to stash here — just adopt it.
|
||||||
|
|||||||
@@ -13,16 +13,20 @@
|
|||||||
]]
|
]]
|
||||||
|
|
||||||
local Components = require(script.Parent)
|
local Components = require(script.Parent)
|
||||||
|
local Schema = require(script.Parent.Schema)
|
||||||
local Registries = require(script.Parent.Parent.registries)
|
local Registries = require(script.Parent.Parent.registries)
|
||||||
local Quests = require(script.Parent.Parent.systems.Quests)
|
local Quests = require(script.Parent.Parent.systems.Quests)
|
||||||
local QuestData = require(script.Parent.Parent.shared.QuestData)
|
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({
|
return Components.define({
|
||||||
name = "QuestGiver",
|
name = SCHEMA.name,
|
||||||
tag = "QuestGiver",
|
tag = SCHEMA.tag,
|
||||||
attributes = {
|
display = SCHEMA.display,
|
||||||
Quest = "", -- the quest id this giver offers / accepts turn-ins for
|
attributes = SCHEMA.attributes,
|
||||||
},
|
|
||||||
onSetup = function(instance, values)
|
onSetup = function(instance, values)
|
||||||
local questId = tostring(values.Quest or "")
|
local questId = tostring(values.Quest or "")
|
||||||
if questId == "" then
|
if questId == "" then
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -11,30 +11,74 @@
|
|||||||
})
|
})
|
||||||
|
|
||||||
Call Components.scan() once (SurvivorCore.start does this) to bind existing tagged
|
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
|
instances and watch for new ones.
|
||||||
visually instead of by hand.
|
|
||||||
|
`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 CollectionService = game:GetService("CollectionService")
|
||||||
|
|
||||||
|
local Schema = require(script.Schema)
|
||||||
|
|
||||||
local Components = {}
|
local Components = {}
|
||||||
|
|
||||||
|
export type AttributeSpec = Schema.AttributeSpec
|
||||||
|
|
||||||
export type Spec = {
|
export type Spec = {
|
||||||
name: string,
|
name: string,
|
||||||
tag: 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 }) -> ())?,
|
onSetup: ((instance: Instance, values: { [string]: any }) -> ())?,
|
||||||
}
|
}
|
||||||
|
|
||||||
local defined: { [string]: Spec } = {}
|
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
|
function Components.define(spec: Spec): Spec
|
||||||
assert(spec.name and spec.tag, "Component requires `name` and `tag`")
|
assert(spec.name and spec.tag, "Component requires `name` and `tag`")
|
||||||
assert(defined[spec.name] == nil, `Component '{spec.name}' already defined`)
|
assert(defined[spec.name] == nil, `Component '{spec.name}' already defined`)
|
||||||
|
local attributes = Schema.normalize(spec.attributes)
|
||||||
defined[spec.name] = spec
|
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
|
return spec
|
||||||
end
|
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 function readAttributes(instance: Instance, attributes: { [string]: any }?)
|
||||||
local values = {}
|
local values = {}
|
||||||
if attributes then
|
if attributes then
|
||||||
@@ -52,7 +96,8 @@ local function bind(instance: Instance, spec: Spec)
|
|||||||
end
|
end
|
||||||
instance:SetAttribute("_scBound", true)
|
instance:SetAttribute("_scBound", true)
|
||||||
if spec.onSetup then
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user