mirror of
https://github.com/TemujinCalidius/SurvivorCore.git
synced 2026-08-14 00:58:01 +00:00
feat(plugin): SurvivorCore Studio — floating window, sidebar nav, search, Engine Config editor, roster overrides (#11, #21, #40)
New shell: Theme (shared visual system), Nav (rail + lazy router + badges), OverviewPage, SearchPage (global editable results), SpawnHelpers; init.server restructured around data-driven pages with last-page persistence and undo/redo-aware refresh. Widget: floating 660×580 (min 480×380), new ID SurvivorCoreStudio. ConfigAdmin/-Ui: deltas-only editor pages for all 11 engine Config sections incl. Theme colors (R,G,B + swatch) and fonts. ContentAdmin/-Ui: per-category pages over the merged roster; + Override entries (blank = inherit) tuning code-registered defs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c661832485
commit
91c7a87f89
@@ -0,0 +1,298 @@
|
||||
--!nonstrict
|
||||
--[[
|
||||
ConfigAdmin — headless logic for the Engine Config editor (issue #21). The generic sibling of
|
||||
StatAdmin: it edits the persisted `ReplicatedStorage.SurvivorCoreEngineConfig` instance the
|
||||
engine layers over every Config section at start()/startClient().
|
||||
|
||||
The SCHEMA (sections → groups → typed fields) comes from the ENGINE — readSchema() requires
|
||||
ReplicatedStorage.SurvivorCore.shared.EngineConfig live, so plugin and engine can never
|
||||
disagree about fields, kinds or defaults. Writes are DELTAS-ONLY, same rule as StatAdmin:
|
||||
|
||||
• an attribute exists IFF the owner's value differs from the engine default
|
||||
• typing the default back (or blanking the box) REMOVES the attribute
|
||||
• unset fields keep following engine defaults across engine updates
|
||||
|
||||
Instance shape: <instance>/<Section>/(attributes | <Group>/attributes). Field kinds:
|
||||
number | boolean | string | enum | color3 (native Color3 attribute) | font (attribute is an
|
||||
Enum.Font NAME string; the engine resolves it at apply time).
|
||||
]]
|
||||
|
||||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
|
||||
local ConfigAdmin = {}
|
||||
|
||||
ConfigAdmin.INSTANCE_NAME = "SurvivorCoreEngineConfig"
|
||||
|
||||
-- Relative float tolerance: |v−d| ≤ EPS·max(|v|,|d|,1e-6). StatAdmin's absolute 1e-4 would
|
||||
-- swallow genuine overrides of tiny rates (Consequences drains ≈ 0.0035/s); the relative form
|
||||
-- still collapses display round-trip noise.
|
||||
local FLOAT_EPSILON = 1e-4
|
||||
|
||||
-- ── Schema (live from the engine) ────────────────────────────────────────────
|
||||
|
||||
local function findEngineConfigModule(): ModuleScript?
|
||||
local engine = ReplicatedStorage:FindFirstChild("SurvivorCore")
|
||||
local shared = engine and engine:FindFirstChild("shared")
|
||||
local module = shared and shared:FindFirstChild("EngineConfig")
|
||||
if module and module:IsA("ModuleScript") then
|
||||
return module
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Read the schema + per-section defaults from the live engine. Never errors:
|
||||
-- { ok = true, source, sections, defaults } | { ok = false, reason, sections = {} }
|
||||
function ConfigAdmin.readSchema(): any
|
||||
local module = findEngineConfigModule()
|
||||
if not module then
|
||||
return {
|
||||
ok = false,
|
||||
reason = "SurvivorCore engine not found in this place (expected"
|
||||
.. " ReplicatedStorage.SurvivorCore.shared.EngineConfig — sync/insert the engine, then Refresh).",
|
||||
sections = {},
|
||||
}
|
||||
end
|
||||
local ok, engineConfig = pcall(require, module)
|
||||
if not ok or typeof(engineConfig) ~= "table" or typeof(engineConfig.SECTIONS) ~= "table" then
|
||||
return {
|
||||
ok = false,
|
||||
reason = "Found the engine, but its EngineConfig module failed to load — is the engine"
|
||||
.. " up to date? (Engine Config needs SurvivorCore ≥ 0.8.)",
|
||||
sections = {},
|
||||
}
|
||||
end
|
||||
local defaults: { [string]: any } = {}
|
||||
for _, section in engineConfig.SECTIONS do
|
||||
defaults[section.id] = engineConfig.getDefaults(section.id)
|
||||
end
|
||||
return {
|
||||
ok = true,
|
||||
source = module:GetFullName(),
|
||||
sections = engineConfig.SECTIONS,
|
||||
defaults = defaults,
|
||||
}
|
||||
end
|
||||
|
||||
-- ── Instance access (read-only vs ensure) ────────────────────────────────────
|
||||
|
||||
function ConfigAdmin.getNode(sectionId: string, groupName: string?): Instance?
|
||||
local instance = ReplicatedStorage:FindFirstChild(ConfigAdmin.INSTANCE_NAME)
|
||||
local section = instance and instance:FindFirstChild(sectionId)
|
||||
if groupName then
|
||||
return section and section:FindFirstChild(groupName)
|
||||
end
|
||||
return section
|
||||
end
|
||||
|
||||
local function ensureConfiguration(parent: Instance, name: string): Instance
|
||||
local existing = parent:FindFirstChild(name)
|
||||
if existing then
|
||||
return existing
|
||||
end
|
||||
local node = Instance.new("Configuration")
|
||||
node.Name = name
|
||||
node.Parent = parent
|
||||
return node
|
||||
end
|
||||
|
||||
function ConfigAdmin.ensureNode(sectionId: string, groupName: string?): Instance
|
||||
local instance = ReplicatedStorage:FindFirstChild(ConfigAdmin.INSTANCE_NAME)
|
||||
if not instance then
|
||||
instance = Instance.new("Configuration")
|
||||
instance.Name = ConfigAdmin.INSTANCE_NAME
|
||||
instance.Parent = ReplicatedStorage
|
||||
end
|
||||
local section = ensureConfiguration(instance, sectionId)
|
||||
if groupName then
|
||||
return ensureConfiguration(section, groupName)
|
||||
end
|
||||
return section
|
||||
end
|
||||
|
||||
-- ── Effective values ─────────────────────────────────────────────────────────
|
||||
|
||||
-- The value the engine will use for a field: the instance override when present, else `default`.
|
||||
function ConfigAdmin.readEffective(sectionId: string, groupName: string?, attr: string, default: any): any
|
||||
local node = ConfigAdmin.getNode(sectionId, groupName)
|
||||
local override = node and node:GetAttribute(attr)
|
||||
if override ~= nil then
|
||||
return { value = override, default = default, hasOverride = true }
|
||||
end
|
||||
return { value = default, default = default, hasOverride = false }
|
||||
end
|
||||
|
||||
-- ── Coercion & default equality ──────────────────────────────────────────────
|
||||
|
||||
-- Parse a raw edit (usually TextBox text) per the field spec. Returns { ok, value } or
|
||||
-- { ok = false, error }. A blank string is handled by setOverride (it means "reset").
|
||||
function ConfigAdmin.coerce(field: any, raw: any): any
|
||||
if field.kind == "number" then
|
||||
local n = if typeof(raw) == "number" then raw else tonumber(tostring(raw))
|
||||
if n == nil then
|
||||
return { ok = false, error = "expected a number" }
|
||||
end
|
||||
if 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
|
||||
|
||||
-- Does a coerced value equal the engine default (→ store nothing)? Kind-aware: fonts compare
|
||||
-- the stored NAME against the default Enum.Font; Color3 compares exactly (both sides come from
|
||||
-- fromRGB construction); numbers use the relative epsilon.
|
||||
function ConfigAdmin.equalsDefault(field: any, value: any, default: any): boolean
|
||||
if field.kind == "number" then
|
||||
if typeof(value) ~= "number" or typeof(default) ~= "number" then
|
||||
return value == default
|
||||
end
|
||||
local scale = math.max(math.abs(value), math.abs(default), 1e-6)
|
||||
return math.abs(value - default) <= FLOAT_EPSILON * scale
|
||||
elseif field.kind == "font" then
|
||||
local defaultName = if typeof(default) == "EnumItem" then default.Name else tostring(default)
|
||||
return value == defaultName
|
||||
end
|
||||
return value == default
|
||||
end
|
||||
|
||||
-- Display formatting for effective values/defaults (what the TextBox shows).
|
||||
function ConfigAdmin.formatValue(field: any, value: any): string
|
||||
if value == nil then
|
||||
return ""
|
||||
end
|
||||
if field.kind == "color3" and typeof(value) == "Color3" then
|
||||
return string.format(
|
||||
"%d, %d, %d",
|
||||
math.round(value.R * 255),
|
||||
math.round(value.G * 255),
|
||||
math.round(value.B * 255)
|
||||
)
|
||||
end
|
||||
if field.kind == "font" and typeof(value) == "EnumItem" then
|
||||
return value.Name
|
||||
end
|
||||
return tostring(value)
|
||||
end
|
||||
|
||||
-- ── Writes (the deltas-only decision point) ──────────────────────────────────
|
||||
|
||||
-- Set (or clear) one field. Blank input = reset to default. Writes ONLY when the value differs
|
||||
-- from the engine default; removes the attribute when it matches.
|
||||
-- Returns { ok, action = "write"|"remove"|"noop", error? }.
|
||||
function ConfigAdmin.setOverride(sectionId: string, groupName: string?, field: any, raw: any, default: any): any
|
||||
if typeof(raw) == "string" and string.match(raw, "^%s*$") then
|
||||
return ConfigAdmin.resetOverride(sectionId, groupName, field.attr)
|
||||
end
|
||||
local coerced = ConfigAdmin.coerce(field, raw)
|
||||
if not coerced.ok then
|
||||
return { ok = false, action = "noop", error = coerced.error }
|
||||
end
|
||||
if ConfigAdmin.equalsDefault(field, coerced.value, default) then
|
||||
return ConfigAdmin.resetOverride(sectionId, groupName, field.attr)
|
||||
end
|
||||
local node = ConfigAdmin.ensureNode(sectionId, groupName)
|
||||
node:SetAttribute(field.attr, coerced.value)
|
||||
return { ok = true, action = "write" }
|
||||
end
|
||||
|
||||
function ConfigAdmin.resetOverride(sectionId: string, groupName: string?, attr: string): any
|
||||
local node = ConfigAdmin.getNode(sectionId, groupName)
|
||||
if node and node:GetAttribute(attr) ~= nil then
|
||||
node:SetAttribute(attr, nil)
|
||||
return { ok = true, action = "remove" }
|
||||
end
|
||||
return { ok = true, action = "noop" }
|
||||
end
|
||||
|
||||
-- Remove every override in one section (all groups). Returns the number removed.
|
||||
function ConfigAdmin.resetSection(section: any): number
|
||||
local removed = 0
|
||||
for _, group in section.groups do
|
||||
local node = ConfigAdmin.getNode(section.id, group.name)
|
||||
if node then
|
||||
for _, field in group.fields do
|
||||
if node:GetAttribute(field.attr) ~= nil then
|
||||
node:SetAttribute(field.attr, nil)
|
||||
removed += 1
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return removed
|
||||
end
|
||||
|
||||
-- Count the overrides currently set in one section (drives the "n overridden" footer).
|
||||
function ConfigAdmin.countOverrides(section: any): number
|
||||
local count = 0
|
||||
for _, group in section.groups do
|
||||
local node = ConfigAdmin.getNode(section.id, group.name)
|
||||
if node then
|
||||
for _, field in group.fields do
|
||||
if node:GetAttribute(field.attr) ~= nil then
|
||||
count += 1
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return count
|
||||
end
|
||||
|
||||
return ConfigAdmin
|
||||
@@ -0,0 +1,338 @@
|
||||
--!nonstrict
|
||||
--[[
|
||||
ConfigAdminUi — the Engine Config editor pages (issue #21). One sidebar page per Config
|
||||
section; each page renders its groups as headed panels of typed field rows (the StatAdminUi
|
||||
row pattern, generalized): reset dot ●/○, label, and a control per kind — number/string
|
||||
TextBox (placeholder = engine default), boolean/enum click-cycle, color3 TextBox "R, G, B"
|
||||
with a live swatch, font TextBox with a cycle button over common fonts. All writes route
|
||||
through the plugin main's record() wrappers (one undo step each), deltas-only via ConfigAdmin.
|
||||
]]
|
||||
|
||||
local Theme = require(script.Parent.Theme)
|
||||
|
||||
local ConfigAdminUi = {}
|
||||
|
||||
local ROW_H = Theme.ROW_H
|
||||
local FOOTER_H = 36
|
||||
|
||||
-- The click-cycle list for font fields (any valid Enum.Font name can still be typed).
|
||||
local COMMON_FONTS = {
|
||||
"Gotham",
|
||||
"GothamMedium",
|
||||
"GothamBold",
|
||||
"GothamBlack",
|
||||
"SourceSans",
|
||||
"SourceSansBold",
|
||||
"Arial",
|
||||
"ArialBold",
|
||||
"Merriweather",
|
||||
"Bangers",
|
||||
"PermanentMarker",
|
||||
}
|
||||
|
||||
-- One field row. `groupName` may be nil (root attributes).
|
||||
local function buildRow(ConfigAdmin, sectionId, groupName, field, eff, applyEdit, applyReset): Instance
|
||||
local row = Theme.make("Frame", {
|
||||
Size = UDim2.new(1, 0, 0, ROW_H),
|
||||
BackgroundTransparency = 1,
|
||||
})
|
||||
|
||||
local reset = Theme.button({
|
||||
Size = UDim2.fromOffset(18, 18),
|
||||
Position = UDim2.new(0, 0, 0.5, -9),
|
||||
Text = eff.hasOverride and "●" or "○",
|
||||
TextColor3 = eff.hasOverride and Theme.COLOR.ACCENT or Theme.COLOR.DIM,
|
||||
})
|
||||
reset.Parent = row
|
||||
reset.MouseButton1Click:Connect(function()
|
||||
applyReset(sectionId, groupName, field.attr)
|
||||
end)
|
||||
|
||||
Theme.label({
|
||||
Size = UDim2.new(0.42, -28, 1, 0),
|
||||
Position = UDim2.fromOffset(26, 0),
|
||||
Text = field.label,
|
||||
TextColor3 = eff.hasOverride and Theme.COLOR.TEXT or Theme.COLOR.DIM,
|
||||
TextTruncate = Enum.TextTruncate.AtEnd,
|
||||
}).Parent =
|
||||
row
|
||||
|
||||
local controlPos = UDim2.new(0.42, 4, 0, 2)
|
||||
local controlSize = UDim2.new(0.58, -4, 0, ROW_H - 4)
|
||||
|
||||
if field.kind == "boolean" or field.kind == "enum" then
|
||||
local control = Theme.button({
|
||||
Size = controlSize,
|
||||
Position = controlPos,
|
||||
Text = ConfigAdmin.formatValue(field, eff.value),
|
||||
TextSize = 13,
|
||||
})
|
||||
control.Parent = row
|
||||
control.MouseButton1Click:Connect(function()
|
||||
local nextValue: any
|
||||
if field.kind == "boolean" then
|
||||
nextValue = not (eff.value == true)
|
||||
else
|
||||
local choices = field.choices or {}
|
||||
local i = table.find(choices, tostring(eff.value)) or 0
|
||||
nextValue = choices[(i % #choices) + 1]
|
||||
end
|
||||
applyEdit(sectionId, groupName, field, nextValue, eff.default)
|
||||
end)
|
||||
elseif field.kind == "color3" then
|
||||
-- [ R, G, B text box ][ swatch ] — the swatch previews the effective color live.
|
||||
local box = Theme.textBox({
|
||||
Size = UDim2.new(0.58, -28, 0, ROW_H - 4),
|
||||
Position = controlPos,
|
||||
Text = ConfigAdmin.formatValue(field, eff.value),
|
||||
PlaceholderText = ConfigAdmin.formatValue(field, eff.default),
|
||||
})
|
||||
box.Parent = row
|
||||
local swatch = Theme.make("Frame", {
|
||||
Size = UDim2.fromOffset(18, 18),
|
||||
Position = UDim2.new(1, -18, 0.5, -9),
|
||||
BackgroundColor3 = if typeof(eff.value) == "Color3" then eff.value else Color3.new(0, 0, 0),
|
||||
BorderSizePixel = 0,
|
||||
}, { Theme.corner(4), Theme.stroke(0.7) })
|
||||
swatch.Parent = row
|
||||
box.FocusLost:Connect(function()
|
||||
applyEdit(sectionId, groupName, field, box.Text, eff.default)
|
||||
end)
|
||||
elseif field.kind == "font" then
|
||||
-- [ font name box ][ ↻ cycle ] — cycles common fonts; any Enum.Font name can be typed.
|
||||
local box = Theme.textBox({
|
||||
Size = UDim2.new(0.58, -28, 0, ROW_H - 4),
|
||||
Position = controlPos,
|
||||
Text = ConfigAdmin.formatValue(field, eff.value),
|
||||
PlaceholderText = ConfigAdmin.formatValue(field, eff.default),
|
||||
})
|
||||
box.Parent = row
|
||||
box.FocusLost:Connect(function()
|
||||
applyEdit(sectionId, groupName, field, box.Text, eff.default)
|
||||
end)
|
||||
local cycle = Theme.button({
|
||||
Size = UDim2.fromOffset(22, ROW_H - 4),
|
||||
Position = UDim2.new(1, -22, 0, 2),
|
||||
Text = "↻",
|
||||
TextColor3 = Theme.COLOR.DIM,
|
||||
})
|
||||
cycle.Parent = row
|
||||
cycle.MouseButton1Click:Connect(function()
|
||||
local current = ConfigAdmin.formatValue(field, eff.value)
|
||||
local i = table.find(COMMON_FONTS, current) or 0
|
||||
applyEdit(sectionId, groupName, field, COMMON_FONTS[(i % #COMMON_FONTS) + 1], eff.default)
|
||||
end)
|
||||
else
|
||||
local box = Theme.textBox({
|
||||
Size = controlSize,
|
||||
Position = controlPos,
|
||||
Text = ConfigAdmin.formatValue(field, eff.value),
|
||||
PlaceholderText = ConfigAdmin.formatValue(field, eff.default),
|
||||
})
|
||||
box.Parent = row
|
||||
box.FocusLost:Connect(function()
|
||||
applyEdit(sectionId, groupName, field, box.Text, eff.default)
|
||||
end)
|
||||
end
|
||||
|
||||
return row
|
||||
end
|
||||
|
||||
-- Mount one section's page. Re-reads the schema on every refresh (self-heals after re-syncs).
|
||||
local function mountSection(
|
||||
container: Frame,
|
||||
ConfigAdmin: any,
|
||||
sectionId: string,
|
||||
applyEdit,
|
||||
applyReset,
|
||||
applyResetSection
|
||||
): { refresh: () -> () }
|
||||
local header = Theme.header(container, "Engine Config")
|
||||
local scroll = Theme.scroll(32, FOOTER_H, 6)
|
||||
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,
|
||||
})
|
||||
footer.Parent = container
|
||||
local resetBtn = Theme.button({
|
||||
Size = UDim2.fromOffset(96, 22),
|
||||
Position = UDim2.new(0, 10, 0.5, -11),
|
||||
Text = "Reset section",
|
||||
TextColor3 = Theme.COLOR.DANGER,
|
||||
})
|
||||
resetBtn.Parent = footer
|
||||
local status = Theme.label({
|
||||
Size = UDim2.new(1, -120, 1, 0),
|
||||
Position = UDim2.fromOffset(112, 0),
|
||||
Text = "",
|
||||
TextColor3 = Theme.COLOR.DIM,
|
||||
TextXAlignment = Enum.TextXAlignment.Right,
|
||||
TextTruncate = Enum.TextTruncate.AtEnd,
|
||||
TextSize = 11,
|
||||
})
|
||||
status.Parent = footer
|
||||
|
||||
local currentSection: any = nil
|
||||
|
||||
local function refresh()
|
||||
for _, child in scroll:GetChildren() do
|
||||
if not child:IsA("UIBase") then
|
||||
child:Destroy()
|
||||
end
|
||||
end
|
||||
|
||||
local schema = ConfigAdmin.readSchema()
|
||||
if not schema.ok then
|
||||
currentSection = nil
|
||||
status.Text = ""
|
||||
Theme.label({
|
||||
Size = UDim2.new(1, 0, 0, 80),
|
||||
Text = schema.reason or "Engine not found.",
|
||||
TextColor3 = Theme.COLOR.DIM,
|
||||
TextWrapped = true,
|
||||
}).Parent =
|
||||
scroll
|
||||
return
|
||||
end
|
||||
|
||||
local section: any = nil
|
||||
for _, s in schema.sections do
|
||||
if s.id == sectionId then
|
||||
section = s
|
||||
break
|
||||
end
|
||||
end
|
||||
if not section then
|
||||
status.Text = ""
|
||||
Theme.label({
|
||||
Size = UDim2.new(1, 0, 0, 60),
|
||||
Text = `Section '{sectionId}' is not in this engine's schema (older engine?).`,
|
||||
TextColor3 = Theme.COLOR.DIM,
|
||||
TextWrapped = true,
|
||||
}).Parent =
|
||||
scroll
|
||||
return
|
||||
end
|
||||
currentSection = section
|
||||
local defaults = schema.defaults[sectionId] or {}
|
||||
|
||||
local order = 0
|
||||
Theme.label({
|
||||
Size = UDim2.new(1, 0, 0, 20),
|
||||
Text = section.title,
|
||||
Font = Theme.FONT_BOLD,
|
||||
TextSize = 15,
|
||||
LayoutOrder = order,
|
||||
}).Parent =
|
||||
scroll
|
||||
|
||||
if section.note then
|
||||
order += 1
|
||||
Theme.label({
|
||||
Size = UDim2.fromScale(1, 0),
|
||||
AutomaticSize = Enum.AutomaticSize.Y,
|
||||
Text = section.note,
|
||||
TextColor3 = Theme.COLOR.DIM,
|
||||
TextWrapped = true,
|
||||
TextSize = 12,
|
||||
LayoutOrder = order,
|
||||
}).Parent =
|
||||
scroll
|
||||
end
|
||||
|
||||
for _, group in section.groups do
|
||||
order += 1
|
||||
local panel = Theme.panel()
|
||||
panel.Size = UDim2.new(1, 0, 0, 22 + ROW_H * #group.fields + 8)
|
||||
panel.AutomaticSize = Enum.AutomaticSize.None
|
||||
panel.LayoutOrder = order
|
||||
panel.Parent = scroll
|
||||
|
||||
Theme.label({
|
||||
Size = UDim2.new(1, 0, 0, 22),
|
||||
Text = group.label,
|
||||
Font = Theme.FONT_BOLD,
|
||||
TextSize = 14,
|
||||
LayoutOrder = 0,
|
||||
}).Parent =
|
||||
panel
|
||||
|
||||
local groupDefaults = if group.name then defaults[group.name] else defaults
|
||||
for i, field in group.fields do
|
||||
local default = if typeof(groupDefaults) == "table" then groupDefaults[field.attr] else nil
|
||||
local eff = ConfigAdmin.readEffective(section.id, group.name, field.attr, default)
|
||||
local row = buildRow(ConfigAdmin, section.id, group.name, field, eff, applyEdit, applyReset)
|
||||
row.LayoutOrder = i
|
||||
row.Parent = panel
|
||||
end
|
||||
end
|
||||
|
||||
local overridden = ConfigAdmin.countOverrides(section)
|
||||
status.Text = if overridden == 0
|
||||
then "no overrides — engine defaults"
|
||||
else `{overridden} field(s) overridden · applies on next Play`
|
||||
end
|
||||
|
||||
resetBtn.MouseButton1Click:Connect(function()
|
||||
if currentSection then
|
||||
applyResetSection(currentSection)
|
||||
end
|
||||
end)
|
||||
header.refreshBtn.MouseButton1Click:Connect(refresh)
|
||||
refresh()
|
||||
return { refresh = refresh }
|
||||
end
|
||||
|
||||
-- Build the Nav pages: one per section when the engine is present, else a single explainer page.
|
||||
-- Callbacks are the plugin main's record() wrappers.
|
||||
function ConfigAdminUi.pages(ConfigAdmin: any, applyEdit, applyReset, applyResetSection): { any }
|
||||
local pages = {}
|
||||
local schema = ConfigAdmin.readSchema()
|
||||
if schema.ok then
|
||||
for _, section in schema.sections do
|
||||
table.insert(pages, {
|
||||
id = "config/" .. section.id,
|
||||
label = section.title,
|
||||
section = "Engine Config",
|
||||
mount = function(frame)
|
||||
return mountSection(frame, ConfigAdmin, section.id, applyEdit, applyReset, applyResetSection)
|
||||
end,
|
||||
})
|
||||
end
|
||||
else
|
||||
table.insert(pages, {
|
||||
id = "config/unavailable",
|
||||
label = "Engine Config",
|
||||
section = "Engine Config",
|
||||
mount = function(frame)
|
||||
local header = Theme.header(frame, "Engine Config")
|
||||
local message = Theme.label({
|
||||
Size = UDim2.new(1, -24, 0, 100),
|
||||
Position = UDim2.fromOffset(12, 40),
|
||||
Text = (schema.reason or "Engine not found.")
|
||||
.. " Once the engine is in the place, close and reopen this window to load the sections.",
|
||||
TextColor3 = Theme.COLOR.DIM,
|
||||
TextWrapped = true,
|
||||
TextYAlignment = Enum.TextYAlignment.Top,
|
||||
})
|
||||
message.Parent = frame
|
||||
local function refresh()
|
||||
local retry = ConfigAdmin.readSchema()
|
||||
if retry.ok then
|
||||
message.Text = "Engine found — close and reopen the SurvivorCore Studio window"
|
||||
.. " to load the Engine Config sections."
|
||||
end
|
||||
end
|
||||
header.refreshBtn.MouseButton1Click:Connect(refresh)
|
||||
return { refresh = refresh }
|
||||
end,
|
||||
})
|
||||
end
|
||||
return pages
|
||||
end
|
||||
|
||||
return ConfigAdminUi
|
||||
@@ -377,4 +377,131 @@ function ContentAdmin.set(catKey: string, id: string, field: FieldSpec, raw: any
|
||||
return true, nil
|
||||
end
|
||||
|
||||
-- ── Overrides (issue #40) ─────────────────────────────────────────────────────
|
||||
--
|
||||
-- SurvivorCoreContent/Overrides/<folder>/<id> tunes a def registered FROM CODE (which the
|
||||
-- authoring folders can't touch — the engine's loadFromFolder skips already-registered keys).
|
||||
-- The engine field-merges these at start(), so an override child carries ONLY the attributes it
|
||||
-- changes: BLANK = INHERIT the code value. The plugin can't display code defaults in Edit mode
|
||||
-- (registries fill at runtime), which is exactly why these are deltas with empty placeholders —
|
||||
-- and why an unknown id can only be caught at Play (the engine warns in Output).
|
||||
|
||||
ContentAdmin.OVERRIDES_FOLDER = "Overrides"
|
||||
|
||||
function ContentAdmin.getOverrideFolder(catKey: string): Instance?
|
||||
local cat = ContentAdmin.CATEGORIES[catKey]
|
||||
local root = ContentAdmin.getRoot()
|
||||
local overrides = root and root:FindFirstChild(ContentAdmin.OVERRIDES_FOLDER)
|
||||
return overrides and cat and overrides:FindFirstChild(cat.folder) or nil
|
||||
end
|
||||
|
||||
-- Find-or-create SurvivorCoreContent/Overrides/<folder>. Only called from the write path.
|
||||
function ContentAdmin.ensureOverrideFolder(catKey: string): Instance
|
||||
local cat = ContentAdmin.CATEGORIES[catKey]
|
||||
ContentAdmin.ensureFolder(catKey) -- guarantees the root exists
|
||||
local root = ContentAdmin.getRoot() :: Instance
|
||||
local overrides = root:FindFirstChild(ContentAdmin.OVERRIDES_FOLDER)
|
||||
if not overrides then
|
||||
overrides = Instance.new("Folder")
|
||||
overrides.Name = ContentAdmin.OVERRIDES_FOLDER
|
||||
overrides.Parent = root
|
||||
end
|
||||
local folder = overrides:FindFirstChild(cat.folder)
|
||||
if not folder then
|
||||
folder = Instance.new("Folder")
|
||||
folder.Name = cat.folder
|
||||
folder.Parent = overrides
|
||||
end
|
||||
return folder
|
||||
end
|
||||
|
||||
function ContentAdmin.getOverrideNode(catKey: string, id: string): Instance?
|
||||
local folder = ContentAdmin.getOverrideFolder(catKey)
|
||||
return folder and folder:FindFirstChild(id) or nil
|
||||
end
|
||||
|
||||
export type RosterEntry = { id: string, authored: boolean, override: boolean }
|
||||
|
||||
-- The merged category roster: authored entries + override entries, sorted by id.
|
||||
function ContentAdmin.listRoster(catKey: string): { RosterEntry }
|
||||
local byId: { [string]: RosterEntry } = {}
|
||||
for _, id in ContentAdmin.list(catKey) do
|
||||
byId[id] = { id = id, authored = true, override = false }
|
||||
end
|
||||
local overrideFolder = ContentAdmin.getOverrideFolder(catKey)
|
||||
if overrideFolder then
|
||||
for _, child in overrideFolder:GetChildren() do
|
||||
local entry = byId[child.Name]
|
||||
if entry then
|
||||
entry.override = true -- authored + override shouldn't happen (createOverride refuses)
|
||||
else
|
||||
byId[child.Name] = { id = child.Name, authored = false, override = true }
|
||||
end
|
||||
end
|
||||
end
|
||||
local out = {}
|
||||
for _, entry in byId do
|
||||
table.insert(out, entry)
|
||||
end
|
||||
table.sort(out, function(a, b)
|
||||
return a.id < b.id
|
||||
end)
|
||||
return out
|
||||
end
|
||||
|
||||
-- Create an EMPTY override (no attributes — deltas-only; NOT create()'s seed-all-defaults).
|
||||
function ContentAdmin.createOverride(catKey: string, rawId: any): (boolean, string)
|
||||
local cat = ContentAdmin.CATEGORIES[catKey]
|
||||
if not cat then
|
||||
return false, "unknown category"
|
||||
end
|
||||
local id = ContentAdmin.sanitizeId(rawId)
|
||||
if id == "" then
|
||||
return false, "Enter a valid id (letters, numbers, _)."
|
||||
end
|
||||
if ContentAdmin.getNode(catKey, id) then
|
||||
return false, `'{id}' is authored here — edit the entry itself.`
|
||||
end
|
||||
local folder = ContentAdmin.ensureOverrideFolder(catKey)
|
||||
if folder:FindFirstChild(id) then
|
||||
return false, `'{id}' already has an override.`
|
||||
end
|
||||
local node = Instance.new("Configuration")
|
||||
node.Name = id
|
||||
node.Parent = folder
|
||||
return true, id
|
||||
end
|
||||
|
||||
function ContentAdmin.deleteOverride(catKey: string, id: string)
|
||||
local node = ContentAdmin.getOverrideNode(catKey, id)
|
||||
if node then
|
||||
node:Destroy()
|
||||
end
|
||||
end
|
||||
|
||||
-- Read one override field: { value?, hasOverride } — value is nil when inheriting.
|
||||
function ContentAdmin.readOverrideField(catKey: string, id: string, field: FieldSpec): any
|
||||
local node = ContentAdmin.getOverrideNode(catKey, id)
|
||||
local v = node and node:GetAttribute(field.attr)
|
||||
return { value = v, hasOverride = v ~= nil }
|
||||
end
|
||||
|
||||
-- Write one override field. BLANK (or nil) = remove the attribute → inherit the code value.
|
||||
function ContentAdmin.setOverrideField(catKey: string, id: string, field: FieldSpec, raw: any): (boolean, string?)
|
||||
local node = ContentAdmin.getOverrideNode(catKey, id)
|
||||
if not node then
|
||||
return false, "missing override"
|
||||
end
|
||||
if raw == nil or (typeof(raw) == "string" and string.match(raw, "^%s*$")) then
|
||||
node:SetAttribute(field.attr, nil)
|
||||
return true, nil
|
||||
end
|
||||
local ok, value, err = coerce(field.kind, raw)
|
||||
if not ok then
|
||||
return false, err
|
||||
end
|
||||
node:SetAttribute(field.attr, value)
|
||||
return true, nil
|
||||
end
|
||||
|
||||
return ContentAdmin
|
||||
|
||||
+198
-244
@@ -1,154 +1,166 @@
|
||||
--!nonstrict
|
||||
--[[
|
||||
ContentAdminUi — the dock-widget UI for the no-code content builder. A thin layer over
|
||||
ContentAdmin (the logic): it renders an Items section and a Gatherables section, each with a
|
||||
"create" row and one editable panel per entry (field rows + Delete). Edits route through the
|
||||
applySet / applyCreate / applyDelete callbacks the plugin main supplies (which wrap the
|
||||
ContentAdmin call in a ChangeHistoryService recording, then refresh). Styling matches
|
||||
StatAdminUi / docs/design-language.md.
|
||||
ContentAdminUi — the no-code content editor pages. One sidebar page per category
|
||||
(mountCategory), each rendering the MERGED roster (issue #40):
|
||||
|
||||
• authored entries — Configurations under SurvivorCoreContent/<folder>: the full editor
|
||||
(every field seeded, Delete destroys the def), exactly as before.
|
||||
• override entries — Configurations under SurvivorCoreContent/Overrides/<folder>: DELTAS
|
||||
over a def registered from code. Blank = inherit the code value (the plugin can't show
|
||||
code defaults in Edit mode — registries fill at runtime); Remove restores the pristine
|
||||
code def on the next Play.
|
||||
|
||||
buildRosterEntry/matchEntry are exported so the global search page renders the same editable
|
||||
panels. All writes route through the `actions` table of record()-wrapped callbacks the plugin
|
||||
main supplies. Styling comes from Theme.
|
||||
]]
|
||||
|
||||
local Theme = require(script.Parent.Theme)
|
||||
|
||||
local ContentAdminUi = {}
|
||||
|
||||
local COL_BG = Color3.fromRGB(18, 21, 28)
|
||||
local COL_PANEL = Color3.fromRGB(28, 32, 42)
|
||||
local COL_TEXT = Color3.fromRGB(235, 238, 245)
|
||||
local COL_DIM = Color3.fromRGB(150, 160, 180)
|
||||
local COL_FIELD = Color3.fromRGB(38, 43, 56)
|
||||
local COL_ACCENT = Color3.fromRGB(120, 170, 255)
|
||||
local COL_DANGER = Color3.fromRGB(210, 90, 90)
|
||||
local FONT = Enum.Font.GothamMedium
|
||||
local ROW_H = 26
|
||||
local ROW_H = Theme.ROW_H
|
||||
|
||||
local function make(class: string, props: { [string]: any }, children: { Instance }?): Instance
|
||||
local inst = Instance.new(class)
|
||||
for key, value in props do
|
||||
(inst :: any)[key] = value
|
||||
end
|
||||
if children then
|
||||
for _, child in children do
|
||||
child.Parent = inst
|
||||
end
|
||||
end
|
||||
return inst
|
||||
end
|
||||
|
||||
local function corner(radius: number): Instance
|
||||
return make("UICorner", { CornerRadius = UDim.new(0, radius) })
|
||||
end
|
||||
|
||||
-- A single field row: label .... control (TextBox committed on focus loss).
|
||||
local function buildField(catKey: string, id: string, field: any, value: any, applySet): Instance
|
||||
local row = make("Frame", { Size = UDim2.new(1, 0, 0, ROW_H), BackgroundTransparency = 1 })
|
||||
|
||||
make("TextLabel", {
|
||||
-- A single authored-field row: label .... TextBox (committed on focus loss).
|
||||
local function buildField(catKey: string, id: string, field: any, value: any, actions): Instance
|
||||
local row = Theme.make("Frame", { Size = UDim2.new(1, 0, 0, ROW_H), BackgroundTransparency = 1 })
|
||||
Theme.label({
|
||||
Size = UDim2.fromScale(0.4, 1),
|
||||
BackgroundTransparency = 1,
|
||||
Text = field.label,
|
||||
TextColor3 = COL_DIM,
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
TextSize = 13,
|
||||
Font = FONT,
|
||||
TextColor3 = Theme.COLOR.DIM,
|
||||
TextTruncate = Enum.TextTruncate.AtEnd,
|
||||
}).Parent =
|
||||
row
|
||||
|
||||
local box = make("TextBox", {
|
||||
local box = Theme.textBox({
|
||||
Size = UDim2.new(0.6, -4, 0, ROW_H - 4),
|
||||
Position = UDim2.new(0.4, 4, 0, 2),
|
||||
BackgroundColor3 = COL_FIELD,
|
||||
Text = tostring(value),
|
||||
PlaceholderText = field.placeholder or tostring(field.default),
|
||||
TextColor3 = COL_TEXT,
|
||||
TextSize = 13,
|
||||
Font = FONT,
|
||||
ClearTextOnFocus = false,
|
||||
BorderSizePixel = 0,
|
||||
}, { corner(4), make("UIPadding", { PaddingLeft = UDim.new(0, 6), PaddingRight = UDim.new(0, 6) }) })
|
||||
})
|
||||
box.Parent = row
|
||||
box.FocusLost:Connect(function()
|
||||
applySet(catKey, id, field, box.Text)
|
||||
actions.set(catKey, id, field, box.Text)
|
||||
end)
|
||||
|
||||
return row
|
||||
end
|
||||
|
||||
-- One entry panel: a title row (id + Delete) + a field row per field.
|
||||
local function buildEntry(catKey: string, id: string, ContentAdmin: any, applySet, applyDelete, applySpawn): Instance
|
||||
local cat = ContentAdmin.CATEGORIES[catKey]
|
||||
local panel = make("Frame", {
|
||||
BackgroundColor3 = COL_PANEL,
|
||||
BorderSizePixel = 0,
|
||||
Size = UDim2.fromScale(1, 0),
|
||||
AutomaticSize = Enum.AutomaticSize.Y,
|
||||
}, {
|
||||
corner(8),
|
||||
make("UIPadding", {
|
||||
PaddingLeft = UDim.new(0, 8),
|
||||
PaddingRight = UDim.new(0, 8),
|
||||
PaddingTop = UDim.new(0, 6),
|
||||
PaddingBottom = UDim.new(0, 8),
|
||||
}),
|
||||
make("UIListLayout", { Padding = UDim.new(0, 2), SortOrder = Enum.SortOrder.LayoutOrder }),
|
||||
})
|
||||
-- A single override-field row: blank = inherit. Booleans cycle (inherit) → true → false.
|
||||
local function buildOverrideField(catKey: string, id: string, field: any, state: any, actions): Instance
|
||||
local row = Theme.make("Frame", { Size = UDim2.new(1, 0, 0, ROW_H), BackgroundTransparency = 1 })
|
||||
Theme.label({
|
||||
Size = UDim2.fromScale(0.4, 1),
|
||||
Text = field.label,
|
||||
TextColor3 = if state.hasOverride then Theme.COLOR.TEXT else Theme.COLOR.DIM,
|
||||
TextTruncate = Enum.TextTruncate.AtEnd,
|
||||
}).Parent =
|
||||
row
|
||||
|
||||
local title = make("Frame", { Size = UDim2.new(1, 0, 0, 24), BackgroundTransparency = 1, LayoutOrder = 0 })
|
||||
if field.kind == "boolean" then
|
||||
local control = Theme.button({
|
||||
Size = UDim2.new(0.6, -4, 0, ROW_H - 4),
|
||||
Position = UDim2.new(0.4, 4, 0, 2),
|
||||
Text = if state.hasOverride then tostring(state.value) else "(inherit)",
|
||||
TextColor3 = if state.hasOverride then Theme.COLOR.TEXT else Theme.COLOR.DIM,
|
||||
TextSize = 13,
|
||||
})
|
||||
control.Parent = row
|
||||
control.MouseButton1Click:Connect(function()
|
||||
-- (inherit) → true → false → (inherit)
|
||||
local nextRaw: any
|
||||
if not state.hasOverride then
|
||||
nextRaw = true
|
||||
elseif state.value == true then
|
||||
nextRaw = false
|
||||
else
|
||||
nextRaw = "" -- blank = remove the attribute = inherit
|
||||
end
|
||||
actions.setOverride(catKey, id, field, nextRaw)
|
||||
end)
|
||||
else
|
||||
local box = Theme.textBox({
|
||||
Size = UDim2.new(0.6, -4, 0, ROW_H - 4),
|
||||
Position = UDim2.new(0.4, 4, 0, 2),
|
||||
Text = if state.hasOverride then tostring(state.value) else "",
|
||||
PlaceholderText = "(code default)",
|
||||
})
|
||||
box.Parent = row
|
||||
box.FocusLost:Connect(function()
|
||||
actions.setOverride(catKey, id, field, box.Text)
|
||||
end)
|
||||
end
|
||||
return row
|
||||
end
|
||||
|
||||
-- One roster entry panel — branches authored vs override.
|
||||
function ContentAdminUi.buildRosterEntry(catKey: string, entry: any, ContentAdmin: any, actions): Instance
|
||||
local cat = ContentAdmin.CATEGORIES[catKey]
|
||||
local panel = Theme.panel()
|
||||
local title = Theme.make("Frame", { Size = UDim2.new(1, 0, 0, 24), BackgroundTransparency = 1, LayoutOrder = 0 })
|
||||
title.Parent = panel
|
||||
|
||||
local del = make("TextButton", {
|
||||
local del = Theme.button({
|
||||
Size = UDim2.fromOffset(56, 20),
|
||||
Position = UDim2.new(1, -56, 0.5, -10),
|
||||
BackgroundColor3 = COL_FIELD,
|
||||
AutoButtonColor = true,
|
||||
Text = "Delete",
|
||||
TextColor3 = COL_DANGER,
|
||||
TextSize = 12,
|
||||
Font = FONT,
|
||||
BorderSizePixel = 0,
|
||||
}, { corner(4) })
|
||||
Text = if entry.override then "Remove" else "Delete",
|
||||
TextColor3 = Theme.COLOR.DANGER,
|
||||
})
|
||||
del.Parent = title
|
||||
del.MouseButton1Click:Connect(function()
|
||||
applyDelete(catKey, id)
|
||||
if entry.override then
|
||||
actions.deleteOverride(catKey, entry.id)
|
||||
else
|
||||
actions.delete(catKey, entry.id)
|
||||
end
|
||||
end)
|
||||
|
||||
-- Gatherables + Mobs + Quests drop a tagged, def-linked instance; Weapons drop a starter Tool
|
||||
-- (Handle + the weapon's ToolType/WeaponKind) you can model the look on — all in front of the
|
||||
-- camera, the bridge between the editor and the world.
|
||||
local labelRight = 64
|
||||
if (catKey == "Resources" or catKey == "Mobs" or catKey == "Weapons" or catKey == "Quests") and applySpawn then
|
||||
local add = make("TextButton", {
|
||||
|
||||
if entry.override then
|
||||
-- Accent pill marking this as a delta over a code-registered def.
|
||||
local pill = Theme.badge("override", Theme.COLOR.ACCENT)
|
||||
pill.AnchorPoint = Vector2.new(1, 0.5)
|
||||
pill.Position = UDim2.new(1, -64, 0.5, 0)
|
||||
pill.Parent = title
|
||||
labelRight = 128
|
||||
elseif
|
||||
(catKey == "Resources" or catKey == "Mobs" or catKey == "Weapons" or catKey == "Quests") and actions.spawn
|
||||
then
|
||||
-- Gatherables + Mobs + Quests drop a tagged, def-linked instance; Weapons drop a starter
|
||||
-- Tool you can model the look on — all in front of the camera.
|
||||
local add = Theme.button({
|
||||
Size = UDim2.fromOffset(104, 20),
|
||||
Position = UDim2.new(1, -168, 0.5, -10),
|
||||
BackgroundColor3 = COL_FIELD,
|
||||
AutoButtonColor = true,
|
||||
Text = if catKey == "Weapons"
|
||||
then "+ Tool model"
|
||||
elseif catKey == "Quests" then "+ Quest giver"
|
||||
else "+ Add to World",
|
||||
TextColor3 = COL_ACCENT,
|
||||
TextColor3 = Theme.COLOR.ACCENT,
|
||||
TextSize = 11,
|
||||
Font = FONT,
|
||||
BorderSizePixel = 0,
|
||||
}, { corner(4) })
|
||||
})
|
||||
add.Parent = title
|
||||
add.MouseButton1Click:Connect(function()
|
||||
applySpawn(catKey, id)
|
||||
actions.spawn(catKey, entry.id)
|
||||
end)
|
||||
labelRight = 176
|
||||
end
|
||||
|
||||
make("TextLabel", {
|
||||
Theme.label({
|
||||
Size = UDim2.new(1, -labelRight, 1, 0),
|
||||
BackgroundTransparency = 1,
|
||||
Text = id,
|
||||
TextColor3 = COL_TEXT,
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
Text = entry.id,
|
||||
TextSize = 14,
|
||||
Font = Enum.Font.GothamBold,
|
||||
Font = Theme.FONT_BOLD,
|
||||
TextTruncate = Enum.TextTruncate.AtEnd,
|
||||
}).Parent =
|
||||
title
|
||||
|
||||
for i, field in cat.fields do
|
||||
local row = buildField(catKey, id, field, ContentAdmin.read(catKey, id, field), applySet)
|
||||
local row: any
|
||||
if entry.override then
|
||||
local state = ContentAdmin.readOverrideField(catKey, entry.id, field)
|
||||
row = buildOverrideField(catKey, entry.id, field, state, actions)
|
||||
else
|
||||
row = buildField(catKey, entry.id, field, ContentAdmin.read(catKey, entry.id, field), actions)
|
||||
end
|
||||
row.LayoutOrder = i
|
||||
row.Parent = panel
|
||||
end
|
||||
@@ -156,149 +168,33 @@ local function buildEntry(catKey: string, id: string, ContentAdmin: any, applySe
|
||||
return panel
|
||||
end
|
||||
|
||||
-- Build one category block: bold header, a create row, then an entry panel per id.
|
||||
local function buildCategory(
|
||||
catKey: string,
|
||||
ContentAdmin: any,
|
||||
applySet,
|
||||
applyCreate,
|
||||
applyDelete,
|
||||
applySpawn
|
||||
): Instance
|
||||
local cat = ContentAdmin.CATEGORIES[catKey]
|
||||
local block = make("Frame", {
|
||||
BackgroundTransparency = 1,
|
||||
Size = UDim2.fromScale(1, 0),
|
||||
AutomaticSize = Enum.AutomaticSize.Y,
|
||||
}, {
|
||||
make("UIListLayout", { Padding = UDim.new(0, 6), SortOrder = Enum.SortOrder.LayoutOrder }),
|
||||
})
|
||||
|
||||
make("TextLabel", {
|
||||
Size = UDim2.new(1, 0, 0, 24),
|
||||
BackgroundTransparency = 1,
|
||||
Text = cat.title,
|
||||
TextColor3 = COL_ACCENT,
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
TextSize = 15,
|
||||
Font = Enum.Font.GothamBold,
|
||||
LayoutOrder = 0,
|
||||
}).Parent =
|
||||
block
|
||||
|
||||
-- Create row: [ new id ............ ] [ + Create ]
|
||||
local createRow = make("Frame", { Size = UDim2.new(1, 0, 0, 28), BackgroundTransparency = 1, LayoutOrder = 1 })
|
||||
createRow.Parent = block
|
||||
local idBox = make("TextBox", {
|
||||
Size = UDim2.new(1, -92, 1, 0),
|
||||
BackgroundColor3 = COL_FIELD,
|
||||
Text = "",
|
||||
PlaceholderText = cat.keyLabel,
|
||||
TextColor3 = COL_TEXT,
|
||||
TextSize = 13,
|
||||
Font = FONT,
|
||||
ClearTextOnFocus = false,
|
||||
BorderSizePixel = 0,
|
||||
}, { corner(4), make("UIPadding", { PaddingLeft = UDim.new(0, 6), PaddingRight = UDim.new(0, 6) }) })
|
||||
idBox.Parent = createRow
|
||||
local createBtn = make("TextButton", {
|
||||
Size = UDim2.fromOffset(84, 28),
|
||||
Position = UDim2.new(1, -84, 0, 0),
|
||||
BackgroundColor3 = COL_FIELD,
|
||||
AutoButtonColor = true,
|
||||
Text = "+ Create",
|
||||
TextColor3 = COL_TEXT,
|
||||
TextSize = 13,
|
||||
Font = FONT,
|
||||
BorderSizePixel = 0,
|
||||
}, { corner(4) })
|
||||
createBtn.Parent = createRow
|
||||
createBtn.MouseButton1Click:Connect(function()
|
||||
applyCreate(catKey, idBox.Text)
|
||||
end)
|
||||
|
||||
local ids = ContentAdmin.list(catKey)
|
||||
if #ids == 0 then
|
||||
make("TextLabel", {
|
||||
Size = UDim2.new(1, 0, 0, 22),
|
||||
BackgroundTransparency = 1,
|
||||
Text = `No {string.lower(cat.title)} yet — create one above.`,
|
||||
TextColor3 = COL_DIM,
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
TextSize = 12,
|
||||
Font = FONT,
|
||||
LayoutOrder = 2,
|
||||
}).Parent =
|
||||
block
|
||||
else
|
||||
for i, id in ids do
|
||||
local entry = buildEntry(catKey, id, ContentAdmin, applySet, applyDelete, applySpawn)
|
||||
entry.LayoutOrder = 2 + i
|
||||
entry.Parent = block
|
||||
end
|
||||
-- Case-insensitive substring match on the entry id and its `name` attribute (when the category
|
||||
-- has one). Shared by the category pages and the global search page.
|
||||
function ContentAdminUi.matchEntry(ContentAdmin: any, catKey: string, entry: any, loweredQuery: string): boolean
|
||||
if string.find(string.lower(entry.id), loweredQuery, 1, true) then
|
||||
return true
|
||||
end
|
||||
|
||||
return block
|
||||
local node = if entry.override
|
||||
then ContentAdmin.getOverrideNode(catKey, entry.id)
|
||||
else ContentAdmin.getNode(catKey, entry.id)
|
||||
local name = node and node:GetAttribute("name")
|
||||
if typeof(name) == "string" and string.find(string.lower(name), loweredQuery, 1, true) then
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function ContentAdminUi.mount(
|
||||
container: Instance,
|
||||
-- Mount one category's page. `actions` = { set, create, delete, spawn, createOverride,
|
||||
-- setOverride, deleteOverride } — all record()-wrapped by the plugin main.
|
||||
function ContentAdminUi.mountCategory(
|
||||
container: Frame,
|
||||
catKey: string,
|
||||
ContentAdmin: any,
|
||||
applySet,
|
||||
applyCreate,
|
||||
applyDelete,
|
||||
applySpawn
|
||||
actions
|
||||
): { refresh: () -> () }
|
||||
for _, child in container:GetChildren() do
|
||||
if not child:IsA("UIBase") then
|
||||
child:Destroy()
|
||||
end
|
||||
end
|
||||
(container :: any).BackgroundColor3 = COL_BG
|
||||
|
||||
local header = make("Frame", { Size = UDim2.new(1, 0, 0, 32), BackgroundTransparency = 1 })
|
||||
header.Parent = container
|
||||
make("TextLabel", {
|
||||
Size = UDim2.new(1, -84, 1, 0),
|
||||
Position = UDim2.fromOffset(12, 0),
|
||||
BackgroundTransparency = 1,
|
||||
Text = "Content",
|
||||
TextColor3 = COL_TEXT,
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
TextSize = 15,
|
||||
Font = Enum.Font.GothamBold,
|
||||
}).Parent =
|
||||
header
|
||||
local refreshBtn = make("TextButton", {
|
||||
Size = UDim2.fromOffset(72, 22),
|
||||
Position = UDim2.new(1, -80, 0.5, -11),
|
||||
BackgroundColor3 = COL_PANEL,
|
||||
AutoButtonColor = true,
|
||||
Text = "Refresh",
|
||||
TextColor3 = COL_TEXT,
|
||||
TextSize = 12,
|
||||
Font = FONT,
|
||||
BorderSizePixel = 0,
|
||||
}, { corner(4) })
|
||||
refreshBtn.Parent = header
|
||||
|
||||
local scroll = make("ScrollingFrame", {
|
||||
Size = UDim2.new(1, 0, 1, -32),
|
||||
Position = UDim2.fromOffset(0, 32),
|
||||
BackgroundTransparency = 1,
|
||||
BorderSizePixel = 0,
|
||||
ScrollBarThickness = 6,
|
||||
CanvasSize = UDim2.new(),
|
||||
AutomaticCanvasSize = Enum.AutomaticSize.Y,
|
||||
}, {
|
||||
make("UIListLayout", { Padding = UDim.new(0, 12), SortOrder = Enum.SortOrder.LayoutOrder }),
|
||||
make("UIPadding", {
|
||||
PaddingLeft = UDim.new(0, 10),
|
||||
PaddingRight = UDim.new(0, 10),
|
||||
PaddingTop = UDim.new(0, 6),
|
||||
PaddingBottom = UDim.new(0, 12),
|
||||
}),
|
||||
})
|
||||
local cat = ContentAdmin.CATEGORIES[catKey]
|
||||
local header = Theme.header(container, cat.title)
|
||||
local scroll = Theme.scroll(32, 0, 8)
|
||||
scroll.Parent = container
|
||||
|
||||
local function refresh()
|
||||
@@ -307,14 +203,72 @@ function ContentAdminUi.mount(
|
||||
child:Destroy()
|
||||
end
|
||||
end
|
||||
for i, catKey in ContentAdmin.ORDER do
|
||||
local block = buildCategory(catKey, ContentAdmin, applySet, applyCreate, applyDelete, applySpawn)
|
||||
block.LayoutOrder = i
|
||||
block.Parent = scroll
|
||||
|
||||
-- Create row: [ new id ............ ] [ + Create ] [ + Override ]
|
||||
local createRow =
|
||||
Theme.make("Frame", { Size = UDim2.new(1, 0, 0, 28), BackgroundTransparency = 1, LayoutOrder = 1 })
|
||||
createRow.Parent = scroll
|
||||
local idBox = Theme.textBox({
|
||||
Size = UDim2.new(1, -172, 1, 0),
|
||||
Text = "",
|
||||
PlaceholderText = cat.keyLabel,
|
||||
})
|
||||
idBox.Parent = createRow
|
||||
local createBtn = Theme.button({
|
||||
Size = UDim2.fromOffset(76, 28),
|
||||
Position = UDim2.new(1, -164, 0, 0),
|
||||
Text = "+ Create",
|
||||
TextSize = 13,
|
||||
})
|
||||
createBtn.Parent = createRow
|
||||
createBtn.MouseButton1Click:Connect(function()
|
||||
actions.create(catKey, idBox.Text)
|
||||
end)
|
||||
local overrideBtn = Theme.button({
|
||||
Size = UDim2.fromOffset(84, 28),
|
||||
Position = UDim2.new(1, -84, 0, 0),
|
||||
Text = "+ Override",
|
||||
TextColor3 = Theme.COLOR.ACCENT,
|
||||
TextSize = 13,
|
||||
})
|
||||
overrideBtn.Parent = createRow
|
||||
overrideBtn.MouseButton1Click:Connect(function()
|
||||
actions.createOverride(catKey, idBox.Text)
|
||||
end)
|
||||
|
||||
Theme.label({
|
||||
Size = UDim2.fromScale(1, 0),
|
||||
AutomaticSize = Enum.AutomaticSize.Y,
|
||||
Text = "Create authors a full def here. Override tunes a def registered from code"
|
||||
.. " (invisible in Edit — registries fill at runtime): type its id, set ONLY the"
|
||||
.. " fields to change, blank = inherit. Typos warn in Output on Play.",
|
||||
TextColor3 = Theme.COLOR.DIM,
|
||||
TextWrapped = true,
|
||||
TextSize = 11,
|
||||
LayoutOrder = 2,
|
||||
}).Parent =
|
||||
scroll
|
||||
|
||||
local roster = ContentAdmin.listRoster(catKey)
|
||||
if #roster == 0 then
|
||||
Theme.label({
|
||||
Size = UDim2.new(1, 0, 0, 22),
|
||||
Text = `No {string.lower(cat.title)} yet — create one above.`,
|
||||
TextColor3 = Theme.COLOR.DIM,
|
||||
TextSize = 12,
|
||||
LayoutOrder = 3,
|
||||
}).Parent =
|
||||
scroll
|
||||
else
|
||||
for i, entry in roster do
|
||||
local panel = ContentAdminUi.buildRosterEntry(catKey, entry, ContentAdmin, actions)
|
||||
panel.LayoutOrder = 3 + i
|
||||
panel.Parent = scroll
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
refreshBtn.MouseButton1Click:Connect(refresh)
|
||||
header.refreshBtn.MouseButton1Click:Connect(refresh)
|
||||
refresh()
|
||||
return { refresh = refresh }
|
||||
end
|
||||
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
--!nonstrict
|
||||
--[[
|
||||
Nav — the plugin's sidebar rail + page router. Pages are data (id/label/section/mount);
|
||||
Nav renders the grouped rail (uppercase section headers, count badges), lazy-mounts each
|
||||
page into its own detail frame on first selection, and toggles visibility after that —
|
||||
so per-page state (scroll position, footer status) survives switching.
|
||||
|
||||
refreshAll() refreshes the ACTIVE page immediately and dirty-flags the other mounted pages,
|
||||
which re-render the next time they're shown — an edit on one page never rebuilds a form the
|
||||
user might be mid-thought on elsewhere, but every page is fresh by the time it's seen.
|
||||
]]
|
||||
|
||||
local Theme = require(script.Parent.Theme)
|
||||
|
||||
local Nav = {}
|
||||
|
||||
export type PageHandle = { refresh: () -> () }
|
||||
|
||||
export type Page = {
|
||||
id: string,
|
||||
label: string,
|
||||
section: string, -- rail group header ("" = top-level, no header)
|
||||
badge: (() -> number?)?,
|
||||
hidden: boolean?, -- routable but not listed (e.g. search results)
|
||||
mount: (frame: Frame) -> PageHandle,
|
||||
}
|
||||
|
||||
export type Options = {
|
||||
railWidth: number?,
|
||||
initialId: string?,
|
||||
onNavigate: ((id: string) -> ())?,
|
||||
onSearch: ((query: string) -> ())?,
|
||||
}
|
||||
|
||||
local ITEM_H = 24
|
||||
local SEARCH_H = 26
|
||||
|
||||
function Nav.mount(root: Frame, pages: { Page }, opts: Options?)
|
||||
local options: Options = opts or {}
|
||||
local railWidth = options.railWidth or 156
|
||||
|
||||
local byId: { [string]: Page } = {}
|
||||
for _, page in pages do
|
||||
byId[page.id] = page
|
||||
end
|
||||
|
||||
-- ── Layout: rail (left) + detail (right) ────────────────────────────────
|
||||
local rail = Theme.make("Frame", {
|
||||
Size = UDim2.new(0, railWidth, 1, 0),
|
||||
BackgroundColor3 = Theme.COLOR.RAIL,
|
||||
BorderSizePixel = 0,
|
||||
}) :: Frame
|
||||
rail.Parent = root
|
||||
|
||||
local detail = Theme.make("Frame", {
|
||||
Position = UDim2.fromOffset(railWidth, 0),
|
||||
Size = UDim2.new(1, -railWidth, 1, 0),
|
||||
BackgroundTransparency = 1,
|
||||
}) :: Frame
|
||||
detail.Parent = root
|
||||
|
||||
-- Search box pinned to the rail top.
|
||||
local searchBox = Theme.textBox({
|
||||
Size = UDim2.new(1, -12, 0, SEARCH_H),
|
||||
Position = UDim2.fromOffset(6, 6),
|
||||
Text = "",
|
||||
PlaceholderText = "Search content…",
|
||||
TextSize = 12,
|
||||
})
|
||||
searchBox.Parent = rail
|
||||
if options.onSearch then
|
||||
searchBox:GetPropertyChangedSignal("Text"):Connect(function()
|
||||
options.onSearch(searchBox.Text)
|
||||
end)
|
||||
end
|
||||
|
||||
local railScroll = Theme.make("ScrollingFrame", {
|
||||
Position = UDim2.fromOffset(0, SEARCH_H + 12),
|
||||
Size = UDim2.new(1, 0, 1, -(SEARCH_H + 12)),
|
||||
BackgroundTransparency = 1,
|
||||
BorderSizePixel = 0,
|
||||
ScrollBarThickness = 4,
|
||||
CanvasSize = UDim2.new(),
|
||||
AutomaticCanvasSize = Enum.AutomaticSize.Y,
|
||||
}, {
|
||||
Theme.make("UIListLayout", { Padding = UDim.new(0, 2), SortOrder = Enum.SortOrder.LayoutOrder }),
|
||||
Theme.pad(6, 6, 0, 10),
|
||||
}) :: ScrollingFrame
|
||||
railScroll.Parent = rail
|
||||
|
||||
-- ── Router state ─────────────────────────────────────────────────────────
|
||||
local frames: { [string]: Frame } = {}
|
||||
local handles: { [string]: PageHandle } = {}
|
||||
local dirty: { [string]: boolean } = {}
|
||||
local activeId: string? = nil
|
||||
local items: { [string]: { button: TextButton, label: TextLabel, bar: Frame, badge: TextLabel? } } = {}
|
||||
|
||||
local function paintItem(id: string)
|
||||
local item = items[id]
|
||||
if not item then
|
||||
return
|
||||
end
|
||||
local selected = id == activeId
|
||||
item.button.BackgroundColor3 = if selected then Theme.COLOR.PANEL else Theme.COLOR.RAIL
|
||||
item.button.BackgroundTransparency = if selected then 0 else 1
|
||||
item.label.TextColor3 = if selected then Theme.COLOR.TEXT else Theme.COLOR.DIM
|
||||
item.bar.Visible = selected
|
||||
end
|
||||
|
||||
local function refreshBadges()
|
||||
for id, item in items do
|
||||
local page = byId[id]
|
||||
if item.badge and page and page.badge then
|
||||
local count = page.badge()
|
||||
item.badge.Text = if count ~= nil then tostring(count) else ""
|
||||
item.badge.Visible = count ~= nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local api = {}
|
||||
|
||||
function api.selectPage(id: string)
|
||||
local page = byId[id]
|
||||
if not page then
|
||||
return
|
||||
end
|
||||
if not frames[id] then
|
||||
local frame = Theme.make("Frame", {
|
||||
Size = UDim2.fromScale(1, 1),
|
||||
BackgroundColor3 = Theme.COLOR.BG,
|
||||
BorderSizePixel = 0,
|
||||
Visible = false,
|
||||
}) :: Frame
|
||||
frame.Parent = detail
|
||||
frames[id] = frame
|
||||
handles[id] = page.mount(frame)
|
||||
dirty[id] = nil -- mount() renders fresh
|
||||
end
|
||||
if activeId ~= id then
|
||||
local previous = activeId and frames[activeId :: string]
|
||||
if previous then
|
||||
previous.Visible = false
|
||||
end
|
||||
local old = activeId
|
||||
activeId = id
|
||||
frames[id].Visible = true
|
||||
if old then
|
||||
paintItem(old)
|
||||
end
|
||||
paintItem(id)
|
||||
end
|
||||
if dirty[id] then
|
||||
dirty[id] = nil
|
||||
handles[id].refresh()
|
||||
end
|
||||
if options.onNavigate and not page.hidden then
|
||||
options.onNavigate(id)
|
||||
end
|
||||
end
|
||||
|
||||
-- Refresh the active page now; mark every other mounted page to refresh on next show.
|
||||
function api.refreshAll()
|
||||
for id in handles do
|
||||
if id == activeId then
|
||||
handles[id].refresh()
|
||||
else
|
||||
dirty[id] = true
|
||||
end
|
||||
end
|
||||
refreshBadges()
|
||||
end
|
||||
|
||||
function api.getActiveId(): string?
|
||||
return activeId
|
||||
end
|
||||
|
||||
-- ── Rail rendering (grouped, ordered as given) ───────────────────────────
|
||||
local order = 0
|
||||
local seenSections: { [string]: boolean } = {}
|
||||
for _, page in pages do
|
||||
if page.hidden then
|
||||
continue
|
||||
end
|
||||
if page.section ~= "" and not seenSections[page.section] then
|
||||
seenSections[page.section] = true
|
||||
order += 1
|
||||
Theme.label({
|
||||
Size = UDim2.new(1, 0, 0, 20),
|
||||
Text = string.upper(page.section),
|
||||
TextColor3 = Theme.COLOR.DIM,
|
||||
TextSize = 10,
|
||||
LayoutOrder = order,
|
||||
}).Parent =
|
||||
railScroll
|
||||
end
|
||||
order += 1
|
||||
|
||||
local button = Theme.make("TextButton", {
|
||||
Size = UDim2.new(1, 0, 0, ITEM_H),
|
||||
BackgroundColor3 = Theme.COLOR.RAIL,
|
||||
BackgroundTransparency = 1,
|
||||
AutoButtonColor = false,
|
||||
Text = "",
|
||||
BorderSizePixel = 0,
|
||||
LayoutOrder = order,
|
||||
}, { Theme.corner(4) }) :: TextButton
|
||||
button.Parent = railScroll
|
||||
local label = Theme.make("TextLabel", {
|
||||
Size = UDim2.new(1, -34, 1, 0),
|
||||
Position = UDim2.fromOffset(10, 0),
|
||||
BackgroundTransparency = 1,
|
||||
Text = page.label,
|
||||
TextColor3 = Theme.COLOR.DIM,
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
TextTruncate = Enum.TextTruncate.AtEnd,
|
||||
TextSize = 12,
|
||||
Font = Theme.FONT,
|
||||
}) :: TextLabel
|
||||
label.Parent = button
|
||||
|
||||
local bar = Theme.make("Frame", {
|
||||
Size = UDim2.new(0, 2, 1, -6),
|
||||
Position = UDim2.fromOffset(0, 3),
|
||||
BackgroundColor3 = Theme.COLOR.ACCENT,
|
||||
BorderSizePixel = 0,
|
||||
Visible = false,
|
||||
}) :: Frame
|
||||
bar.Parent = button
|
||||
|
||||
local badgeLabel: TextLabel? = nil
|
||||
if page.badge then
|
||||
badgeLabel = Theme.badge("")
|
||||
local badge = badgeLabel :: TextLabel
|
||||
badge.AnchorPoint = Vector2.new(1, 0.5)
|
||||
badge.Position = UDim2.new(1, -4, 0.5, 0)
|
||||
badge.Parent = button
|
||||
end
|
||||
|
||||
items[page.id] = { button = button, label = label, bar = bar, badge = badgeLabel }
|
||||
button.MouseEnter:Connect(function()
|
||||
if page.id ~= activeId then
|
||||
button.BackgroundTransparency = 0
|
||||
button.BackgroundColor3 = Theme.COLOR.PANEL_HOVER
|
||||
end
|
||||
end)
|
||||
button.MouseLeave:Connect(function()
|
||||
paintItem(page.id)
|
||||
end)
|
||||
button.MouseButton1Click:Connect(function()
|
||||
api.selectPage(page.id)
|
||||
end)
|
||||
end
|
||||
|
||||
refreshBadges()
|
||||
api.selectPage(options.initialId or pages[1].id)
|
||||
|
||||
return api
|
||||
end
|
||||
|
||||
return Nav
|
||||
@@ -0,0 +1,124 @@
|
||||
--!nonstrict
|
||||
--[[
|
||||
OverviewPage — the Studio window's home page: is the engine synced, what content exists
|
||||
(click a row to jump to its editor), and where the docs live.
|
||||
]]
|
||||
|
||||
local Theme = require(script.Parent.Theme)
|
||||
|
||||
local OverviewPage = {}
|
||||
|
||||
-- deps = { StatAdmin, ContentAdmin, selectPage(id) }
|
||||
function OverviewPage.mount(container: Frame, deps: any): { refresh: () -> () }
|
||||
local header = Theme.header(container, "SurvivorCore Studio")
|
||||
local scroll = Theme.scroll(32, 0, 6)
|
||||
scroll.Parent = container
|
||||
|
||||
local function refresh()
|
||||
for _, child in scroll:GetChildren() do
|
||||
if not child:IsA("UIBase") then
|
||||
child:Destroy()
|
||||
end
|
||||
end
|
||||
|
||||
-- Engine status.
|
||||
local roster = deps.StatAdmin.readRoster()
|
||||
local status = Theme.panel()
|
||||
status.LayoutOrder = 1
|
||||
status.Parent = scroll
|
||||
Theme.label({
|
||||
Size = UDim2.new(1, 0, 0, 20),
|
||||
Text = "Engine",
|
||||
Font = Theme.FONT_BOLD,
|
||||
TextSize = 14,
|
||||
LayoutOrder = 0,
|
||||
}).Parent =
|
||||
status
|
||||
Theme.label({
|
||||
Size = UDim2.fromScale(1, 0),
|
||||
AutomaticSize = Enum.AutomaticSize.Y,
|
||||
Text = if roster.ok
|
||||
then `✓ Synced — {roster.source or "ReplicatedStorage.SurvivorCore"}. Stats, engine config and content editors are live.`
|
||||
else `✗ {roster.reason or "Engine not found in this place."}`,
|
||||
TextColor3 = if roster.ok then Theme.COLOR.DIM else Theme.COLOR.DANGER,
|
||||
TextWrapped = true,
|
||||
TextSize = 12,
|
||||
LayoutOrder = 1,
|
||||
}).Parent =
|
||||
status
|
||||
|
||||
-- Content counts (click-through).
|
||||
local content = Theme.panel()
|
||||
content.LayoutOrder = 2
|
||||
content.Parent = scroll
|
||||
Theme.label({
|
||||
Size = UDim2.new(1, 0, 0, 20),
|
||||
Text = "Content",
|
||||
Font = Theme.FONT_BOLD,
|
||||
TextSize = 14,
|
||||
LayoutOrder = 0,
|
||||
}).Parent =
|
||||
content
|
||||
for i, catKey in deps.ContentAdmin.ORDER do
|
||||
local cat = deps.ContentAdmin.CATEGORIES[catKey]
|
||||
local count = #deps.ContentAdmin.listRoster(catKey)
|
||||
local row = Theme.make("TextButton", {
|
||||
Size = UDim2.new(1, 0, 0, 22),
|
||||
BackgroundColor3 = Theme.COLOR.PANEL,
|
||||
AutoButtonColor = false,
|
||||
Text = "",
|
||||
BorderSizePixel = 0,
|
||||
LayoutOrder = i,
|
||||
}, { Theme.corner(4) }) :: TextButton
|
||||
row.Parent = content
|
||||
Theme.label({
|
||||
Size = UDim2.new(1, -40, 1, 0),
|
||||
Position = UDim2.fromOffset(6, 0),
|
||||
Text = cat.title,
|
||||
TextColor3 = Theme.COLOR.DIM,
|
||||
TextSize = 12,
|
||||
}).Parent =
|
||||
row
|
||||
local badge = Theme.badge(tostring(count), Theme.COLOR.TEXT)
|
||||
badge.AnchorPoint = Vector2.new(1, 0.5)
|
||||
badge.Position = UDim2.new(1, -4, 0.5, 0)
|
||||
badge.Parent = row
|
||||
Theme.hover(row, Theme.COLOR.PANEL, Theme.COLOR.PANEL_HOVER)
|
||||
row.MouseButton1Click:Connect(function()
|
||||
deps.selectPage("content/" .. catKey)
|
||||
end)
|
||||
end
|
||||
|
||||
-- Pointers.
|
||||
local help = Theme.panel()
|
||||
help.LayoutOrder = 3
|
||||
help.Parent = scroll
|
||||
Theme.label({
|
||||
Size = UDim2.new(1, 0, 0, 20),
|
||||
Text = "How it works",
|
||||
Font = Theme.FONT_BOLD,
|
||||
TextSize = 14,
|
||||
LayoutOrder = 0,
|
||||
}).Parent =
|
||||
help
|
||||
Theme.label({
|
||||
Size = UDim2.fromScale(1, 0),
|
||||
AutomaticSize = Enum.AutomaticSize.Y,
|
||||
Text = "Everything here is no-code and deltas-only: a field is written ONLY when it"
|
||||
.. " differs from the engine default, so unset fields keep following engine updates."
|
||||
.. " Stats apply live; Engine Config and content edits apply on the next Play."
|
||||
.. " Docs: docs/admin-plugin.md in the SurvivorCore repo.",
|
||||
TextColor3 = Theme.COLOR.DIM,
|
||||
TextWrapped = true,
|
||||
TextSize = 12,
|
||||
LayoutOrder = 1,
|
||||
}).Parent =
|
||||
help
|
||||
end
|
||||
|
||||
header.refreshBtn.MouseButton1Click:Connect(refresh)
|
||||
refresh()
|
||||
return { refresh = refresh }
|
||||
end
|
||||
|
||||
return OverviewPage
|
||||
@@ -0,0 +1,104 @@
|
||||
--!nonstrict
|
||||
--[[
|
||||
SearchPage — global content search results. A hidden (rail-less) page the plugin main routes
|
||||
to while the rail search box has text. Matches ids + `name` attributes across every category
|
||||
(ContentAdminUi.matchEntry) and renders full EDITABLE entry panels grouped by category — you
|
||||
fix the thing right where you found it.
|
||||
]]
|
||||
|
||||
local Theme = require(script.Parent.Theme)
|
||||
|
||||
local MAX_RESULTS = 50
|
||||
|
||||
local SearchPage = {}
|
||||
|
||||
-- deps = { ContentAdmin, ContentAdminUi, getQuery(), actions }
|
||||
function SearchPage.mount(container: Frame, deps: any): { refresh: () -> () }
|
||||
local header = Theme.header(container, "Search")
|
||||
local scroll = Theme.scroll(32, 0, 8)
|
||||
scroll.Parent = container
|
||||
|
||||
local function refresh()
|
||||
for _, child in scroll:GetChildren() do
|
||||
if not child:IsA("UIBase") then
|
||||
child:Destroy()
|
||||
end
|
||||
end
|
||||
|
||||
local query = tostring(deps.getQuery() or "")
|
||||
if string.match(query, "^%s*$") then
|
||||
Theme.label({
|
||||
Size = UDim2.new(1, 0, 0, 40),
|
||||
Text = "Type in the sidebar box to search content by id or name.",
|
||||
TextColor3 = Theme.COLOR.DIM,
|
||||
TextWrapped = true,
|
||||
}).Parent =
|
||||
scroll
|
||||
return
|
||||
end
|
||||
local lowered = string.lower(query)
|
||||
|
||||
local order = 0
|
||||
local shown = 0
|
||||
local total = 0
|
||||
for _, catKey in deps.ContentAdmin.ORDER do
|
||||
local cat = deps.ContentAdmin.CATEGORIES[catKey]
|
||||
local matches = {}
|
||||
for _, entry in deps.ContentAdmin.listRoster(catKey) do
|
||||
if deps.ContentAdminUi.matchEntry(deps.ContentAdmin, catKey, entry, lowered) then
|
||||
table.insert(matches, entry)
|
||||
end
|
||||
end
|
||||
total += #matches
|
||||
if #matches == 0 then
|
||||
continue
|
||||
end
|
||||
order += 1
|
||||
Theme.label({
|
||||
Size = UDim2.new(1, 0, 0, 20),
|
||||
Text = `{cat.title} — {#matches} match{if #matches == 1 then "" else "es"}`,
|
||||
TextColor3 = Theme.COLOR.ACCENT,
|
||||
TextSize = 13,
|
||||
Font = Theme.FONT_BOLD,
|
||||
LayoutOrder = order,
|
||||
}).Parent =
|
||||
scroll
|
||||
for _, entry in matches do
|
||||
if shown >= MAX_RESULTS then
|
||||
break
|
||||
end
|
||||
shown += 1
|
||||
order += 1
|
||||
local panel = deps.ContentAdminUi.buildRosterEntry(catKey, entry, deps.ContentAdmin, deps.actions)
|
||||
panel.LayoutOrder = order
|
||||
panel.Parent = scroll
|
||||
end
|
||||
end
|
||||
|
||||
if total == 0 then
|
||||
Theme.label({
|
||||
Size = UDim2.new(1, 0, 0, 40),
|
||||
Text = `No content matches '{query}'.`,
|
||||
TextColor3 = Theme.COLOR.DIM,
|
||||
TextWrapped = true,
|
||||
}).Parent =
|
||||
scroll
|
||||
elseif total > shown then
|
||||
order += 1
|
||||
Theme.label({
|
||||
Size = UDim2.new(1, 0, 0, 22),
|
||||
Text = `Showing {shown} of {total} — refine your search.`,
|
||||
TextColor3 = Theme.COLOR.DIM,
|
||||
TextSize = 12,
|
||||
LayoutOrder = order,
|
||||
}).Parent =
|
||||
scroll
|
||||
end
|
||||
end
|
||||
|
||||
header.refreshBtn.MouseButton1Click:Connect(refresh)
|
||||
refresh()
|
||||
return { refresh = refresh }
|
||||
end
|
||||
|
||||
return SearchPage
|
||||
@@ -0,0 +1,117 @@
|
||||
--!nonstrict
|
||||
--[[
|
||||
SpawnHelpers — the "Add to World" branch logic, moved verbatim out of the plugin main. Drops a
|
||||
tagged, def-linked instance in front of the camera and selects it — the no-code bridge between
|
||||
an editor entry and the world:
|
||||
Resources → a Part tagged "Gatherable" with Resource = id.
|
||||
Mobs → a placeholder rig Model tagged "Mob" with MobType = id.
|
||||
Quests → a quest-giver post tagged "QuestGiver" with Quest = id.
|
||||
Weapons → a starter Tool (Handle + ToolType/WeaponKind) to model the equipped look on.
|
||||
]]
|
||||
|
||||
local CollectionService = game:GetService("CollectionService")
|
||||
local Selection = game:GetService("Selection")
|
||||
local Workspace = game:GetService("Workspace")
|
||||
|
||||
local SpawnHelpers = {}
|
||||
|
||||
local function spawnPosition(): Vector3
|
||||
local cam = Workspace.CurrentCamera
|
||||
return if cam then cam.CFrame.Position + cam.CFrame.LookVector * 16 else Vector3.new(0, 5, 0)
|
||||
end
|
||||
|
||||
-- A minimal placeholder mob rig (Model + HumanoidRootPart + Humanoid + head), tagged "Mob" with the
|
||||
-- MobType set — exactly what the engine adopts at play. The creator swaps in their own rig later.
|
||||
local function buildMobRig(id: string): Model
|
||||
local model = Instance.new("Model")
|
||||
model.Name = id
|
||||
local rootPart = Instance.new("Part")
|
||||
rootPart.Name = "HumanoidRootPart"
|
||||
rootPart.Size = Vector3.new(2, 3, 1)
|
||||
rootPart.Color = Color3.fromRGB(150, 60, 60)
|
||||
rootPart.Anchored = false
|
||||
rootPart.Parent = model
|
||||
local head = Instance.new("Part")
|
||||
head.Name = "Head"
|
||||
head.Shape = Enum.PartType.Ball
|
||||
head.Size = Vector3.new(1.4, 1.4, 1.4)
|
||||
head.Color = rootPart.Color
|
||||
head.CanCollide = false
|
||||
head.Massless = true
|
||||
head.CFrame = rootPart.CFrame * CFrame.new(0, 2, 0)
|
||||
head.Parent = model
|
||||
local weld = Instance.new("WeldConstraint")
|
||||
weld.Part0 = rootPart
|
||||
weld.Part1 = head
|
||||
weld.Parent = rootPart
|
||||
local hum = Instance.new("Humanoid")
|
||||
hum.Parent = model
|
||||
model.PrimaryPart = rootPart
|
||||
return model
|
||||
end
|
||||
|
||||
function SpawnHelpers.spawn(ContentAdmin: any, catKey: string, id: string): Instance
|
||||
if catKey == "Mobs" then
|
||||
local model = buildMobRig(id)
|
||||
model:SetAttribute("MobType", id)
|
||||
model:PivotTo(CFrame.new(spawnPosition()))
|
||||
CollectionService:AddTag(model, "Mob")
|
||||
model.Parent = Workspace
|
||||
Selection:Set({ model })
|
||||
return model
|
||||
end
|
||||
if catKey == "Quests" then
|
||||
-- Drop a quest-giver post: tag + Quest attribute is exactly what the engine reads. The
|
||||
-- creator swaps in their own NPC/notice-board model later (tag it and set Quest).
|
||||
local post = Instance.new("Part")
|
||||
post.Name = id .. "_giver"
|
||||
post.Size = Vector3.new(1, 5, 1)
|
||||
post.Anchored = true
|
||||
post.Color = Color3.fromRGB(120, 170, 255)
|
||||
post.Material = Enum.Material.Wood
|
||||
post.Position = spawnPosition()
|
||||
post:SetAttribute("Quest", id)
|
||||
CollectionService:AddTag(post, "QuestGiver")
|
||||
post.Parent = Workspace
|
||||
Selection:Set({ post })
|
||||
return post
|
||||
end
|
||||
if catKey == "Weapons" then
|
||||
-- Drop a starter Tool you can model the look on. It carries the weapon's ToolType /
|
||||
-- WeaponKind so it's recognisable; move it under ReplicatedStorage.SurvivorCoreContent.Tools
|
||||
-- (named by this id) to make it the equipped look the hotbar clones.
|
||||
local node = ContentAdmin.getNode("Weapons", id)
|
||||
local tool = Instance.new("Tool")
|
||||
tool.Name = id
|
||||
tool.RequiresHandle = true
|
||||
tool.CanBeDropped = false
|
||||
local handle = Instance.new("Part")
|
||||
handle.Name = "Handle"
|
||||
handle.Size = Vector3.new(0.5, 3, 0.5)
|
||||
handle.Color = Color3.fromRGB(120, 90, 60)
|
||||
handle.Anchored = true
|
||||
handle.Position = spawnPosition()
|
||||
handle.Parent = tool
|
||||
for _, attr in { "toolType", "weaponKind" } do
|
||||
local v = node and node:GetAttribute(attr)
|
||||
if typeof(v) == "string" and v ~= "" then
|
||||
tool:SetAttribute(attr == "toolType" and "ToolType" or "WeaponKind", v)
|
||||
end
|
||||
end
|
||||
tool.Parent = Workspace
|
||||
Selection:Set({ tool })
|
||||
return tool
|
||||
end
|
||||
local part = Instance.new("Part")
|
||||
part.Name = id
|
||||
part.Size = Vector3.new(4, 4, 4)
|
||||
part.Anchored = true
|
||||
part.Position = spawnPosition()
|
||||
part:SetAttribute("Resource", id)
|
||||
CollectionService:AddTag(part, "Gatherable")
|
||||
part.Parent = Workspace
|
||||
Selection:Set({ part })
|
||||
return part
|
||||
end
|
||||
|
||||
return SpawnHelpers
|
||||
+12
-24
@@ -6,34 +6,22 @@
|
||||
ChangeHistoryService recording, then refresh). Styling follows docs/design-language.md.
|
||||
]]
|
||||
|
||||
local Theme = require(script.Parent.Theme)
|
||||
|
||||
local StatAdminUi = {}
|
||||
|
||||
local COL_BG = Color3.fromRGB(18, 21, 28)
|
||||
local COL_PANEL = Color3.fromRGB(28, 32, 42)
|
||||
local COL_TEXT = Color3.fromRGB(235, 238, 245)
|
||||
local COL_DIM = Color3.fromRGB(150, 160, 180)
|
||||
local COL_ACCENT = Color3.fromRGB(120, 170, 255) -- marks an overridden field
|
||||
local COL_FIELD = Color3.fromRGB(38, 43, 56)
|
||||
local FONT = Enum.Font.GothamMedium
|
||||
local ROW_H = 26
|
||||
local COL_BG = Theme.COLOR.BG
|
||||
local COL_PANEL = Theme.COLOR.PANEL
|
||||
local COL_TEXT = Theme.COLOR.TEXT
|
||||
local COL_DIM = Theme.COLOR.DIM
|
||||
local COL_ACCENT = Theme.COLOR.ACCENT -- marks an overridden field
|
||||
local COL_FIELD = Theme.COLOR.FIELD
|
||||
local FONT = Theme.FONT
|
||||
local ROW_H = Theme.ROW_H
|
||||
local FOOTER_H = 36 -- the Preview / Clear control bar at the bottom
|
||||
|
||||
local function make(class: string, props: { [string]: any }, children: { Instance }?): Instance
|
||||
local inst = Instance.new(class)
|
||||
for key, value in props do
|
||||
(inst :: any)[key] = value
|
||||
end
|
||||
if children then
|
||||
for _, child in children do
|
||||
child.Parent = inst
|
||||
end
|
||||
end
|
||||
return inst
|
||||
end
|
||||
|
||||
local function corner(radius: number): Instance
|
||||
return make("UICorner", { CornerRadius = UDim.new(0, radius) })
|
||||
end
|
||||
local make = Theme.make
|
||||
local corner = Theme.corner
|
||||
|
||||
-- A single field row: [reset] label .... control. `applyEdit/applyReset` take (stat, attr, …).
|
||||
local function buildRow(stat: string, spec: any, eff: any, applyEdit, applyReset): Instance
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
--!nonstrict
|
||||
--[[
|
||||
Theme — the plugin's shared visual system. One home for the style constants that used to be
|
||||
copy-pasted across every UI file, plus the small builders (instance factory, header, scroll,
|
||||
panel, styled controls) every page composes from. The palette is the dense/dark editor variant
|
||||
of docs/design-language.md.
|
||||
]]
|
||||
|
||||
local Theme = {}
|
||||
|
||||
Theme.COLOR = {
|
||||
BG = Color3.fromRGB(18, 21, 28),
|
||||
RAIL = Color3.fromRGB(14, 17, 23), -- sidebar, a step darker than the page
|
||||
PANEL = Color3.fromRGB(28, 32, 42),
|
||||
PANEL_HOVER = Color3.fromRGB(34, 39, 52),
|
||||
FIELD = Color3.fromRGB(38, 43, 56),
|
||||
TEXT = Color3.fromRGB(235, 238, 245),
|
||||
DIM = Color3.fromRGB(150, 160, 180),
|
||||
ACCENT = Color3.fromRGB(120, 170, 255),
|
||||
DANGER = Color3.fromRGB(210, 90, 90),
|
||||
STROKE = Color3.fromRGB(210, 220, 245),
|
||||
}
|
||||
|
||||
Theme.FONT = Enum.Font.GothamMedium
|
||||
Theme.FONT_BOLD = Enum.Font.GothamBold
|
||||
Theme.ROW_H = 26
|
||||
|
||||
-- Generic instance factory: props dict + optional children (reparented in order).
|
||||
function Theme.make(class: string, props: { [string]: any }, children: { Instance }?): Instance
|
||||
local inst = Instance.new(class)
|
||||
for key, value in props do
|
||||
(inst :: any)[key] = value
|
||||
end
|
||||
if children then
|
||||
for _, child in children do
|
||||
child.Parent = inst
|
||||
end
|
||||
end
|
||||
return inst
|
||||
end
|
||||
|
||||
function Theme.corner(radius: number): Instance
|
||||
return Theme.make("UICorner", { CornerRadius = UDim.new(0, radius) })
|
||||
end
|
||||
|
||||
function Theme.stroke(transparency: number?): Instance
|
||||
return Theme.make("UIStroke", {
|
||||
Color = Theme.COLOR.STROKE,
|
||||
Transparency = transparency or 0.9,
|
||||
ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
|
||||
})
|
||||
end
|
||||
|
||||
function Theme.pad(left: number, right: number, top: number, bottom: number): Instance
|
||||
return Theme.make("UIPadding", {
|
||||
PaddingLeft = UDim.new(0, left),
|
||||
PaddingRight = UDim.new(0, right),
|
||||
PaddingTop = UDim.new(0, top),
|
||||
PaddingBottom = UDim.new(0, bottom),
|
||||
})
|
||||
end
|
||||
|
||||
-- Styled primitives: sensible defaults, overridable via props.
|
||||
function Theme.label(props: { [string]: any }): TextLabel
|
||||
local merged: { [string]: any } = {
|
||||
BackgroundTransparency = 1,
|
||||
TextColor3 = Theme.COLOR.TEXT,
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
TextSize = 13,
|
||||
Font = Theme.FONT,
|
||||
}
|
||||
for k, v in props do
|
||||
merged[k] = v
|
||||
end
|
||||
return Theme.make("TextLabel", merged) :: TextLabel
|
||||
end
|
||||
|
||||
function Theme.button(props: { [string]: any }): TextButton
|
||||
local merged: { [string]: any } = {
|
||||
BackgroundColor3 = Theme.COLOR.FIELD,
|
||||
AutoButtonColor = true,
|
||||
TextColor3 = Theme.COLOR.TEXT,
|
||||
TextSize = 12,
|
||||
Font = Theme.FONT,
|
||||
BorderSizePixel = 0,
|
||||
}
|
||||
for k, v in props do
|
||||
merged[k] = v
|
||||
end
|
||||
return Theme.make("TextButton", merged, { Theme.corner(4) }) :: TextButton
|
||||
end
|
||||
|
||||
function Theme.textBox(props: { [string]: any }): TextBox
|
||||
local merged: { [string]: any } = {
|
||||
BackgroundColor3 = Theme.COLOR.FIELD,
|
||||
TextColor3 = Theme.COLOR.TEXT,
|
||||
TextSize = 13,
|
||||
Font = Theme.FONT,
|
||||
ClearTextOnFocus = false,
|
||||
BorderSizePixel = 0,
|
||||
}
|
||||
for k, v in props do
|
||||
merged[k] = v
|
||||
end
|
||||
return Theme.make("TextBox", merged, {
|
||||
Theme.corner(4),
|
||||
Theme.make("UIPadding", { PaddingLeft = UDim.new(0, 6), PaddingRight = UDim.new(0, 6) }),
|
||||
}) :: TextBox
|
||||
end
|
||||
|
||||
-- The standard 32px page header: bold title left, Refresh button right.
|
||||
function Theme.header(container: Instance, title: string): { frame: Frame, refreshBtn: TextButton }
|
||||
local frame = Theme.make("Frame", {
|
||||
Size = UDim2.new(1, 0, 0, 32),
|
||||
BackgroundTransparency = 1,
|
||||
}) :: Frame
|
||||
frame.Parent = container
|
||||
Theme.label({
|
||||
Size = UDim2.new(1, -84, 1, 0),
|
||||
Position = UDim2.fromOffset(12, 0),
|
||||
Text = title,
|
||||
TextSize = 15,
|
||||
Font = Theme.FONT_BOLD,
|
||||
TextTruncate = Enum.TextTruncate.AtEnd,
|
||||
}).Parent =
|
||||
frame
|
||||
local refreshBtn = Theme.button({
|
||||
Size = UDim2.fromOffset(72, 22),
|
||||
Position = UDim2.new(1, -80, 0.5, -11),
|
||||
BackgroundColor3 = Theme.COLOR.PANEL,
|
||||
Text = "Refresh",
|
||||
})
|
||||
refreshBtn.Parent = frame
|
||||
return { frame = frame, refreshBtn = refreshBtn }
|
||||
end
|
||||
|
||||
-- The standard page scroll: CanvasSize reset + AutomaticCanvasSize.Y + list layout. Keep this
|
||||
-- exact recipe — the explicit `CanvasSize = UDim2.new()` matters (the default canvas adds
|
||||
-- phantom scroll).
|
||||
function Theme.scroll(offsetTop: number, offsetBottom: number, listPadding: number?): ScrollingFrame
|
||||
return Theme.make("ScrollingFrame", {
|
||||
Size = UDim2.new(1, 0, 1, -(offsetTop + offsetBottom)),
|
||||
Position = UDim2.fromOffset(0, offsetTop),
|
||||
BackgroundTransparency = 1,
|
||||
BorderSizePixel = 0,
|
||||
ScrollBarThickness = 6,
|
||||
CanvasSize = UDim2.new(),
|
||||
AutomaticCanvasSize = Enum.AutomaticSize.Y,
|
||||
}, {
|
||||
Theme.make("UIListLayout", {
|
||||
Padding = UDim.new(0, listPadding or 4),
|
||||
SortOrder = Enum.SortOrder.LayoutOrder,
|
||||
}),
|
||||
Theme.pad(10, 10, 6, 12),
|
||||
}) :: ScrollingFrame
|
||||
end
|
||||
|
||||
-- The standard content panel: PANEL background, rounded, subtle stroke, padded list layout.
|
||||
-- Grows with content by default (AutomaticSize.Y); override Size/AutomaticSize for fixed panels.
|
||||
function Theme.panel(): Frame
|
||||
return Theme.make("Frame", {
|
||||
BackgroundColor3 = Theme.COLOR.PANEL,
|
||||
BorderSizePixel = 0,
|
||||
Size = UDim2.fromScale(1, 0),
|
||||
AutomaticSize = Enum.AutomaticSize.Y,
|
||||
}, {
|
||||
Theme.corner(8),
|
||||
Theme.stroke(),
|
||||
Theme.pad(8, 8, 6, 8),
|
||||
Theme.make("UIListLayout", { Padding = UDim.new(0, 2), SortOrder = Enum.SortOrder.LayoutOrder }),
|
||||
}) :: Frame
|
||||
end
|
||||
|
||||
-- A small count/status pill (rail badges, entry badges).
|
||||
function Theme.badge(text: string, textColor: Color3?): TextLabel
|
||||
return Theme.make("TextLabel", {
|
||||
AutomaticSize = Enum.AutomaticSize.X,
|
||||
Size = UDim2.fromOffset(0, 14),
|
||||
BackgroundColor3 = Theme.COLOR.FIELD,
|
||||
BorderSizePixel = 0,
|
||||
Text = text,
|
||||
TextColor3 = textColor or Theme.COLOR.DIM,
|
||||
TextSize = 10,
|
||||
Font = Theme.FONT,
|
||||
}, {
|
||||
Theme.corner(7),
|
||||
Theme.make("UIPadding", { PaddingLeft = UDim.new(0, 6), PaddingRight = UDim.new(0, 6) }),
|
||||
}) :: TextLabel
|
||||
end
|
||||
|
||||
-- MouseEnter/Leave background tint for custom (non-AutoButtonColor) elements.
|
||||
function Theme.hover(gui: GuiObject, base: Color3, hoverColor: Color3)
|
||||
gui.MouseEnter:Connect(function()
|
||||
gui.BackgroundColor3 = hoverColor
|
||||
end)
|
||||
gui.MouseLeave:Connect(function()
|
||||
gui.BackgroundColor3 = base
|
||||
end)
|
||||
end
|
||||
|
||||
return Theme
|
||||
+197
-221
@@ -1,107 +1,78 @@
|
||||
--!nonstrict
|
||||
--[[
|
||||
SurvivorCore — admin plugin (main).
|
||||
SurvivorCore Studio — admin plugin (main).
|
||||
|
||||
ONE dock widget, a tab bar across the top, one feature per tab:
|
||||
• Stats — tune the survival stats (deltas-only, locked) on SurvivalStatsConfig + HUD preview.
|
||||
• Content — create/edit/delete Items + Gatherable resources (no-code) as SurvivorCoreContent
|
||||
instances the engine loads at start().
|
||||
Every mutation is wrapped in a single ChangeHistoryService recording (one undo step) and then
|
||||
both editors refresh. All real logic lives in the requirable modules (StatAdmin / ContentAdmin);
|
||||
the forms in StatAdminUi / ContentAdminUi. This is the Builder / Admin plugin (issue #11), and
|
||||
new sections (Movement / Consequences, #21) slot in as more tabs.
|
||||
ONE floating (dockable) window with a sidebar rail and a detail pane, one page per editor:
|
||||
• Overview — engine status + content counts (click-through) + how-it-works.
|
||||
• Survival Stats — tune stats (deltas-only, locked) on SurvivalStatsConfig + HUD preview.
|
||||
• Engine Config — every engine Config section (Movement/Combat/Mobs/…/UI theme) as
|
||||
deltas-only overrides on SurvivorCoreEngineConfig (issue #21).
|
||||
• Content — create/edit/delete Items/Weapons/Arrows/Resources/Mobs/Quests/Achievements
|
||||
as SurvivorCoreContent instances, plus OVERRIDES that tune code-registered
|
||||
defs (issue #40). A rail search box finds any entry by id/name.
|
||||
|
||||
Every mutation is wrapped in a single ChangeHistoryService recording (one undo step), then the
|
||||
active page refreshes (others refresh on next show). All real logic lives in the requirable
|
||||
modules (StatAdmin / ConfigAdmin / ContentAdmin); the forms in the *Ui modules; the shared look
|
||||
in Theme. This is the Builder / Admin plugin (issue #11).
|
||||
]]
|
||||
|
||||
local ChangeHistoryService = game:GetService("ChangeHistoryService")
|
||||
local CollectionService = game:GetService("CollectionService")
|
||||
local Selection = game:GetService("Selection")
|
||||
local Workspace = game:GetService("Workspace")
|
||||
|
||||
local Theme = require(script.Theme)
|
||||
local Nav = require(script.Nav)
|
||||
local SpawnHelpers = require(script.SpawnHelpers)
|
||||
local OverviewPage = require(script.OverviewPage)
|
||||
local SearchPage = require(script.SearchPage)
|
||||
local StatAdmin = require(script.StatAdmin)
|
||||
local StatAdminUi = require(script.StatAdminUi)
|
||||
local HudPreview = require(script.HudPreview)
|
||||
local ContentAdmin = require(script.ContentAdmin)
|
||||
local ContentAdminUi = require(script.ContentAdminUi)
|
||||
local ConfigAdmin = require(script.ConfigAdmin)
|
||||
local ConfigAdminUi = require(script.ConfigAdminUi)
|
||||
|
||||
local COL_BG = Color3.fromRGB(18, 21, 28)
|
||||
local COL_TAB = Color3.fromRGB(28, 32, 42)
|
||||
local COL_TAB_ON = Color3.fromRGB(52, 58, 74)
|
||||
local COL_TEXT = Color3.fromRGB(235, 238, 245)
|
||||
local TAB_H = 30
|
||||
local SETTING_LAST_PAGE = "SurvivorCoreStudio.lastPage"
|
||||
|
||||
local toolbar = plugin:CreateToolbar("SurvivorCore")
|
||||
local button = toolbar:CreateButton("Admin Panel", "Tune stats + create items/weapons/gatherables/mobs (no-code)", "")
|
||||
local button = toolbar:CreateButton("Studio", "Tune the engine + create items/weapons/gatherables/mobs (no-code)", "")
|
||||
button.ClickableWhenViewportHidden = true
|
||||
|
||||
-- CreateDockWidgetPluginGui is flagged deprecated by the tooling, but it is still the only API for
|
||||
-- a dockable plugin widget (there is no replacement) — so allow it here.
|
||||
-- a dockable plugin widget (there is no replacement) — so allow it here. NOTE: the widget ID is
|
||||
-- "SurvivorCoreStudio" (was "SurvivorCoreAdmin") — Studio persists dock state PER ID and ignores
|
||||
-- new initial values for known IDs, so the rename is what makes the floating default reach
|
||||
-- existing users (their old dock position resets once).
|
||||
-- selene: allow(deprecated)
|
||||
local widget = plugin:CreateDockWidgetPluginGui(
|
||||
"SurvivorCoreAdmin",
|
||||
DockWidgetPluginGuiInfo.new(Enum.InitialDockState.Right, false, false, 340, 560, 320, 400)
|
||||
"SurvivorCoreStudio",
|
||||
DockWidgetPluginGuiInfo.new(Enum.InitialDockState.Float, false, false, 660, 580, 480, 380)
|
||||
)
|
||||
widget.Title = "SurvivorCore Admin"
|
||||
widget.Name = "SurvivorCoreAdmin"
|
||||
widget.Title = "SurvivorCore Studio"
|
||||
widget.Name = "SurvivorCoreStudio"
|
||||
|
||||
local root = Instance.new("Frame")
|
||||
root.Size = UDim2.fromScale(1, 1)
|
||||
root.BackgroundColor3 = COL_BG
|
||||
root.BackgroundColor3 = Theme.COLOR.BG
|
||||
root.BorderSizePixel = 0
|
||||
root.Parent = widget
|
||||
|
||||
-- Tab bar + content region.
|
||||
local tabBar = Instance.new("Frame")
|
||||
tabBar.Size = UDim2.new(1, 0, 0, TAB_H)
|
||||
tabBar.BackgroundColor3 = COL_BG
|
||||
tabBar.BorderSizePixel = 0
|
||||
tabBar.Parent = root
|
||||
local tabLayout = Instance.new("UIListLayout")
|
||||
tabLayout.FillDirection = Enum.FillDirection.Horizontal
|
||||
tabLayout.Padding = UDim.new(0, 4)
|
||||
tabLayout.Parent = tabBar
|
||||
local tabPad = Instance.new("UIPadding")
|
||||
tabPad.PaddingLeft = UDim.new(0, 8)
|
||||
tabPad.PaddingTop = UDim.new(0, 4)
|
||||
tabPad.Parent = tabBar
|
||||
local nav -- assigned after the pages are assembled; closures below capture it
|
||||
|
||||
local region = Instance.new("Frame")
|
||||
region.Position = UDim2.fromOffset(0, TAB_H)
|
||||
region.Size = UDim2.new(1, 0, 1, -TAB_H)
|
||||
region.BackgroundTransparency = 1
|
||||
region.BorderSizePixel = 0
|
||||
region.Parent = root
|
||||
|
||||
local statFrame = Instance.new("Frame")
|
||||
statFrame.Size = UDim2.fromScale(1, 1)
|
||||
statFrame.BackgroundTransparency = 1
|
||||
statFrame.Parent = region
|
||||
|
||||
local contentFrame = Instance.new("Frame")
|
||||
contentFrame.Size = UDim2.fromScale(1, 1)
|
||||
contentFrame.BackgroundTransparency = 1
|
||||
contentFrame.Visible = false
|
||||
contentFrame.Parent = region
|
||||
|
||||
local statUi: { refresh: () -> () }? = nil
|
||||
local contentUi: { refresh: () -> () }? = nil
|
||||
|
||||
-- Wrap a mutation in one ChangeHistory recording (one undo step), then refresh both editors.
|
||||
-- Wrap a mutation in one ChangeHistory recording (one undo step), then refresh the UI.
|
||||
local function record(name: string, mutate: () -> any): any
|
||||
local recording = ChangeHistoryService:TryBeginRecording(name)
|
||||
local result = mutate()
|
||||
if recording then
|
||||
ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Commit)
|
||||
end
|
||||
if statUi then
|
||||
statUi.refresh()
|
||||
end
|
||||
if contentUi then
|
||||
contentUi.refresh()
|
||||
if nav then
|
||||
nav.refreshAll()
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
-- Stats callbacks.
|
||||
-- ── Stats callbacks ───────────────────────────────────────────────────────────
|
||||
local function applyEdit(statName: string, attr: string, raw: any, default: any): any
|
||||
return record(`Stats: {statName}.{attr}`, function()
|
||||
return StatAdmin.setOverride(statName, attr, raw, default)
|
||||
@@ -123,178 +94,183 @@ local function applyClear(): any
|
||||
end)
|
||||
end
|
||||
|
||||
-- Content callbacks.
|
||||
local function applySet(catKey: string, id: string, field: any, raw: any): any
|
||||
return record(`Content: {catKey} {id}.{field.attr}`, function()
|
||||
return ContentAdmin.set(catKey, id, field, raw)
|
||||
-- ── Engine Config callbacks ───────────────────────────────────────────────────
|
||||
local function applyConfigEdit(sectionId: string, groupName: string?, field: any, raw: any, default: any): any
|
||||
return record(`Config: {sectionId}.{field.attr}`, function()
|
||||
return ConfigAdmin.setOverride(sectionId, groupName, field, raw, default)
|
||||
end)
|
||||
end
|
||||
local function applyCreate(catKey: string, rawId: any): any
|
||||
return record(`Content: create {catKey}`, function()
|
||||
return ContentAdmin.create(catKey, rawId)
|
||||
local function applyConfigReset(sectionId: string, groupName: string?, attr: string): any
|
||||
return record(`Config: reset {sectionId}.{attr}`, function()
|
||||
return ConfigAdmin.resetOverride(sectionId, groupName, attr)
|
||||
end)
|
||||
end
|
||||
local function applyDelete(catKey: string, id: string): any
|
||||
return record(`Content: delete {catKey} {id}`, function()
|
||||
ContentAdmin.delete(catKey, id)
|
||||
local function applyConfigResetSection(section: any): any
|
||||
return record(`Config: reset section {section.id}`, function()
|
||||
return ConfigAdmin.resetSection(section)
|
||||
end)
|
||||
end
|
||||
|
||||
local function spawnPosition(): Vector3
|
||||
local cam = Workspace.CurrentCamera
|
||||
return if cam then cam.CFrame.Position + cam.CFrame.LookVector * 16 else Vector3.new(0, 5, 0)
|
||||
end
|
||||
|
||||
-- A minimal placeholder mob rig (Model + HumanoidRootPart + Humanoid + head), tagged "Mob" with the
|
||||
-- MobType set — exactly what the engine adopts at play. The creator swaps in their own rig later.
|
||||
local function buildMobRig(id: string): Model
|
||||
local model = Instance.new("Model")
|
||||
model.Name = id
|
||||
local rootPart = Instance.new("Part")
|
||||
rootPart.Name = "HumanoidRootPart"
|
||||
rootPart.Size = Vector3.new(2, 3, 1)
|
||||
rootPart.Color = Color3.fromRGB(150, 60, 60)
|
||||
rootPart.Anchored = false
|
||||
rootPart.Parent = model
|
||||
local head = Instance.new("Part")
|
||||
head.Name = "Head"
|
||||
head.Shape = Enum.PartType.Ball
|
||||
head.Size = Vector3.new(1.4, 1.4, 1.4)
|
||||
head.Color = rootPart.Color
|
||||
head.CanCollide = false
|
||||
head.Massless = true
|
||||
head.CFrame = rootPart.CFrame * CFrame.new(0, 2, 0)
|
||||
head.Parent = model
|
||||
local weld = Instance.new("WeldConstraint")
|
||||
weld.Part0 = rootPart
|
||||
weld.Part1 = head
|
||||
weld.Parent = rootPart
|
||||
local hum = Instance.new("Humanoid")
|
||||
hum.Parent = model
|
||||
model.PrimaryPart = rootPart
|
||||
return model
|
||||
end
|
||||
|
||||
-- "Add to World": drop a tagged, def-linked instance in front of the camera, then select it. The
|
||||
-- no-code way to place one — tag + def-id attribute is exactly what the engine reads at play.
|
||||
-- Resources → a Part tagged "Gatherable" with Resource = id.
|
||||
-- Mobs → a placeholder rig Model tagged "Mob" with MobType = id.
|
||||
local function applySpawn(catKey: string, id: string): any
|
||||
return record(`Content: add {catKey} '{id}' to world`, function()
|
||||
if catKey == "Mobs" then
|
||||
local model = buildMobRig(id)
|
||||
model:SetAttribute("MobType", id)
|
||||
model:PivotTo(CFrame.new(spawnPosition()))
|
||||
CollectionService:AddTag(model, "Mob")
|
||||
model.Parent = Workspace
|
||||
Selection:Set({ model })
|
||||
return model
|
||||
end
|
||||
if catKey == "Quests" then
|
||||
-- Drop a quest-giver post: tag + Quest attribute is exactly what the engine reads. The
|
||||
-- creator swaps in their own NPC/notice-board model later (tag it and set Quest).
|
||||
local post = Instance.new("Part")
|
||||
post.Name = id .. "_giver"
|
||||
post.Size = Vector3.new(1, 5, 1)
|
||||
post.Anchored = true
|
||||
post.Color = Color3.fromRGB(120, 170, 255)
|
||||
post.Material = Enum.Material.Wood
|
||||
post.Position = spawnPosition()
|
||||
post:SetAttribute("Quest", id)
|
||||
CollectionService:AddTag(post, "QuestGiver")
|
||||
post.Parent = Workspace
|
||||
Selection:Set({ post })
|
||||
return post
|
||||
end
|
||||
if catKey == "Weapons" then
|
||||
-- Drop a starter Tool you can model the look on. It carries the weapon's ToolType /
|
||||
-- WeaponKind so it's recognisable; move it under ReplicatedStorage.SurvivorCoreContent.Tools
|
||||
-- (named by this id) to make it the equipped look the hotbar clones.
|
||||
local node = ContentAdmin.getNode("Weapons", id)
|
||||
local tool = Instance.new("Tool")
|
||||
tool.Name = id
|
||||
tool.RequiresHandle = true
|
||||
tool.CanBeDropped = false
|
||||
local handle = Instance.new("Part")
|
||||
handle.Name = "Handle"
|
||||
handle.Size = Vector3.new(0.5, 3, 0.5)
|
||||
handle.Color = Color3.fromRGB(120, 90, 60)
|
||||
handle.Anchored = true
|
||||
handle.Position = spawnPosition()
|
||||
handle.Parent = tool
|
||||
for _, attr in { "toolType", "weaponKind" } do
|
||||
local v = node and node:GetAttribute(attr)
|
||||
if typeof(v) == "string" and v ~= "" then
|
||||
tool:SetAttribute(attr == "toolType" and "ToolType" or "WeaponKind", v)
|
||||
end
|
||||
end
|
||||
tool.Parent = Workspace
|
||||
Selection:Set({ tool })
|
||||
return tool
|
||||
end
|
||||
local part = Instance.new("Part")
|
||||
part.Name = id
|
||||
part.Size = Vector3.new(4, 4, 4)
|
||||
part.Anchored = true
|
||||
part.Position = spawnPosition()
|
||||
part:SetAttribute("Resource", id)
|
||||
CollectionService:AddTag(part, "Gatherable")
|
||||
part.Parent = Workspace
|
||||
Selection:Set({ part })
|
||||
return part
|
||||
end)
|
||||
end
|
||||
|
||||
statUi = StatAdminUi.mount(statFrame, StatAdmin, applyEdit, applyReset, applyPreview, applyClear)
|
||||
contentUi = ContentAdminUi.mount(contentFrame, ContentAdmin, applySet, applyCreate, applyDelete, applySpawn)
|
||||
|
||||
-- Tabs.
|
||||
local TABS = {
|
||||
{ id = "stats", label = "Stats", frame = statFrame },
|
||||
{ id = "content", label = "Content", frame = contentFrame },
|
||||
-- ── Content callbacks (authored + overrides), bundled for the pages ──────────
|
||||
local actions = {
|
||||
set = function(catKey: string, id: string, field: any, raw: any): any
|
||||
return record(`Content: {catKey} {id}.{field.attr}`, function()
|
||||
return ContentAdmin.set(catKey, id, field, raw)
|
||||
end)
|
||||
end,
|
||||
create = function(catKey: string, rawId: any): any
|
||||
return record(`Content: create {catKey}`, function()
|
||||
return ContentAdmin.create(catKey, rawId)
|
||||
end)
|
||||
end,
|
||||
delete = function(catKey: string, id: string): any
|
||||
return record(`Content: delete {catKey} {id}`, function()
|
||||
ContentAdmin.delete(catKey, id)
|
||||
end)
|
||||
end,
|
||||
spawn = function(catKey: string, id: string): any
|
||||
return record(`Content: add {catKey} '{id}' to world`, function()
|
||||
return SpawnHelpers.spawn(ContentAdmin, catKey, id)
|
||||
end)
|
||||
end,
|
||||
createOverride = function(catKey: string, rawId: any): any
|
||||
return record(`Content: override {catKey}`, function()
|
||||
return ContentAdmin.createOverride(catKey, rawId)
|
||||
end)
|
||||
end,
|
||||
setOverride = function(catKey: string, id: string, field: any, raw: any): any
|
||||
return record(`Content: override {catKey} {id}.{field.attr}`, function()
|
||||
return ContentAdmin.setOverrideField(catKey, id, field, raw)
|
||||
end)
|
||||
end,
|
||||
deleteOverride = function(catKey: string, id: string): any
|
||||
return record(`Content: remove override {catKey} {id}`, function()
|
||||
ContentAdmin.deleteOverride(catKey, id)
|
||||
end)
|
||||
end,
|
||||
}
|
||||
local tabButtons: { [string]: TextButton } = {}
|
||||
|
||||
local function showTab(id: string)
|
||||
for _, t in TABS do
|
||||
t.frame.Visible = t.id == id
|
||||
local b = tabButtons[t.id]
|
||||
if b then
|
||||
b.BackgroundColor3 = if t.id == id then COL_TAB_ON else COL_TAB
|
||||
-- ── Search state (rail box → hidden results page) ─────────────────────────────
|
||||
local currentQuery = ""
|
||||
local searchGeneration = 0
|
||||
local lastNonSearchId = "overview"
|
||||
|
||||
-- ── NAV assembly (data-driven; new editors slot in as more pages) ─────────────
|
||||
local pages = {}
|
||||
|
||||
table.insert(pages, {
|
||||
id = "overview",
|
||||
label = "Overview",
|
||||
section = "",
|
||||
mount = function(frame)
|
||||
return OverviewPage.mount(frame, {
|
||||
StatAdmin = StatAdmin,
|
||||
ContentAdmin = ContentAdmin,
|
||||
selectPage = function(id: string)
|
||||
nav.selectPage(id)
|
||||
end,
|
||||
})
|
||||
end,
|
||||
})
|
||||
|
||||
table.insert(pages, {
|
||||
id = "stats",
|
||||
label = "Survival Stats",
|
||||
section = "Stats",
|
||||
mount = function(frame)
|
||||
return StatAdminUi.mount(frame, StatAdmin, applyEdit, applyReset, applyPreview, applyClear)
|
||||
end,
|
||||
})
|
||||
|
||||
for _, page in ConfigAdminUi.pages(ConfigAdmin, applyConfigEdit, applyConfigReset, applyConfigResetSection) do
|
||||
table.insert(pages, page)
|
||||
end
|
||||
|
||||
for _, catKey in ContentAdmin.ORDER do
|
||||
local cat = ContentAdmin.CATEGORIES[catKey]
|
||||
table.insert(pages, {
|
||||
id = "content/" .. catKey,
|
||||
label = cat.title,
|
||||
section = "Content",
|
||||
badge = function()
|
||||
return #ContentAdmin.listRoster(catKey)
|
||||
end,
|
||||
mount = function(frame)
|
||||
return ContentAdminUi.mountCategory(frame, catKey, ContentAdmin, actions)
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
table.insert(pages, {
|
||||
id = "search",
|
||||
label = "Search",
|
||||
section = "",
|
||||
hidden = true,
|
||||
mount = function(frame)
|
||||
return SearchPage.mount(frame, {
|
||||
ContentAdmin = ContentAdmin,
|
||||
ContentAdminUi = ContentAdminUi,
|
||||
actions = actions,
|
||||
getQuery = function()
|
||||
return currentQuery
|
||||
end,
|
||||
})
|
||||
end,
|
||||
})
|
||||
|
||||
-- Restore the last-open page (validated against the live page list — ids may change across
|
||||
-- plugin versions or when the engine's schema grows/shrinks).
|
||||
local restoredId = "overview"
|
||||
local saved = plugin:GetSetting(SETTING_LAST_PAGE)
|
||||
if typeof(saved) == "string" then
|
||||
for _, page in pages do
|
||||
if page.id == saved and not page.hidden then
|
||||
restoredId = saved
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
for _, t in TABS do
|
||||
local b = Instance.new("TextButton")
|
||||
b.Size = UDim2.fromOffset(92, TAB_H - 6)
|
||||
b.BackgroundColor3 = COL_TAB
|
||||
b.AutoButtonColor = true
|
||||
b.Text = t.label
|
||||
b.TextColor3 = COL_TEXT
|
||||
b.TextSize = 13
|
||||
b.Font = Enum.Font.GothamMedium
|
||||
b.BorderSizePixel = 0
|
||||
b.Parent = tabBar
|
||||
local c = Instance.new("UICorner")
|
||||
c.CornerRadius = UDim.new(0, 4)
|
||||
c.Parent = b
|
||||
tabButtons[t.id] = b
|
||||
b.MouseButton1Click:Connect(function()
|
||||
showTab(t.id)
|
||||
end)
|
||||
end
|
||||
showTab("stats")
|
||||
nav = Nav.mount(root, pages, {
|
||||
initialId = restoredId,
|
||||
onNavigate = function(id: string)
|
||||
lastNonSearchId = id
|
||||
if plugin:GetSetting(SETTING_LAST_PAGE) ~= id then
|
||||
plugin:SetSetting(SETTING_LAST_PAGE, id)
|
||||
end
|
||||
end,
|
||||
onSearch = function(text: string)
|
||||
currentQuery = text
|
||||
searchGeneration += 1
|
||||
local generation = searchGeneration
|
||||
task.delay(0.25, function()
|
||||
if generation ~= searchGeneration or not widget.Enabled then
|
||||
return
|
||||
end
|
||||
if string.match(currentQuery, "^%s*$") then
|
||||
if nav.getActiveId() == "search" then
|
||||
nav.selectPage(lastNonSearchId)
|
||||
end
|
||||
else
|
||||
nav.selectPage("search")
|
||||
nav.refreshAll() -- re-render the results with the new query
|
||||
end
|
||||
end)
|
||||
end,
|
||||
})
|
||||
|
||||
-- An undo/redo can revert any edit the pages show — refresh so they never go stale.
|
||||
ChangeHistoryService.OnUndo:Connect(function()
|
||||
nav.refreshAll()
|
||||
end)
|
||||
ChangeHistoryService.OnRedo:Connect(function()
|
||||
nav.refreshAll()
|
||||
end)
|
||||
|
||||
button.Click:Connect(function()
|
||||
widget.Enabled = not widget.Enabled
|
||||
if widget.Enabled then
|
||||
if statUi then
|
||||
statUi.refresh()
|
||||
end
|
||||
if contentUi then
|
||||
contentUi.refresh()
|
||||
end
|
||||
nav.refreshAll()
|
||||
end
|
||||
end)
|
||||
widget:GetPropertyChangedSignal("Enabled"):Connect(function()
|
||||
|
||||
Reference in New Issue
Block a user