mirror of
https://github.com/TemujinCalidius/SurvivorCore.git
synced 2026-08-14 00:58:01 +00:00
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>
130 lines
4.3 KiB
Luau
130 lines
4.3 KiB
Luau
--!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
|