mirror of
https://github.com/TemujinCalidius/SurvivorCore.git
synced 2026-08-14 00:58:01 +00:00
Found by an adversarial review of the Build page before it was ever run — all three reviewers independently flagged the first one. 1. The "what is this object?" cards were dead. chooserCard parented a Size=fromScale(1,1) TextButton into Theme.panel() intending an overlay, but Theme.panel() contains a UIListLayout, which lays out EVERY GuiObject child — there is no opt-out, and ZIndex does not affect layout. So the button became another list row: clicking a card's title/summary did nothing, and the oversized button spilled past the card and took the click for the card BELOW, applying the WRONG component (which also strips the previous component's attributes). This was the page's primary interaction. Fixed with Theme.panelButton() — the card itself is the button, matching the pattern OverviewPage already uses. 2. Clear did nothing on an object whose only tag was unknown to this engine, yet reported success. The page renders exactly that branch with a Clear button. BuildAdmin.clear now takes the tags to remove explicitly, and the page passes the unknown tags it just displayed — never removing an unlisted tag speculatively, since it may belong to another plugin. It also reports "nothing to clear" instead of a false success. 3. FieldRow's help text set Position inside a UIListLayout parent, so its indent was silently dropped and every help line rendered flush left. Uses padding now. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
500 lines
14 KiB
Luau
500 lines
14 KiB
Luau
--!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.
|
||
-- The CARD ITSELF is the button when it's pickable: a full-size button parented INTO a panel would
|
||
-- be laid out by the panel's UIListLayout as another row (no opt-out; ZIndex doesn't affect
|
||
-- layout), leaving the card body dead and the button spilling onto the next card.
|
||
local function chooserCard(schema: any, display: any, eligible: boolean, reason: string?, onPick): GuiObject
|
||
local panel: any = if eligible then Theme.panelButton() else Theme.panel()
|
||
local title = Theme.label({
|
||
Size = UDim2.new(1, 0, 0, 20),
|
||
Text = display.title,
|
||
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
|
||
(panel :: TextButton).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 }, extraTags: { string }?)
|
||
local res = actions.clear(instances, all, extraTags)
|
||
chooserOpen = false
|
||
refresh()
|
||
if typeof(res) == "table" then
|
||
local n = res.cleared or 0
|
||
say(if n > 0 then `✓ cleared {n} object(s)` else "nothing to clear on that object")
|
||
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()
|
||
-- Pass the unknown tags we just listed, so Clear removes exactly what was shown.
|
||
clearType({ instance }, all, id.unknownTags)
|
||
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
|