diff --git a/plugin/ConfigAdmin.luau b/plugin/ConfigAdmin.luau new file mode 100644 index 0000000..c2642eb --- /dev/null +++ b/plugin/ConfigAdmin.luau @@ -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: /
/(attributes | /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 diff --git a/plugin/ConfigAdminUi.luau b/plugin/ConfigAdminUi.luau new file mode 100644 index 0000000..a347ddd --- /dev/null +++ b/plugin/ConfigAdminUi.luau @@ -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 diff --git a/plugin/ContentAdmin.luau b/plugin/ContentAdmin.luau index ba13843..537eba7 100644 --- a/plugin/ContentAdmin.luau +++ b/plugin/ContentAdmin.luau @@ -377,4 +377,131 @@ function ContentAdmin.set(catKey: string, id: string, field: FieldSpec, raw: any return true, nil end +-- ── Overrides (issue #40) ───────────────────────────────────────────────────── +-- +-- SurvivorCoreContent/Overrides// 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/. 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 diff --git a/plugin/ContentAdminUi.luau b/plugin/ContentAdminUi.luau index 20438c5..096302c 100644 --- a/plugin/ContentAdminUi.luau +++ b/plugin/ContentAdminUi.luau @@ -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/: the full editor + (every field seeded, Delete destroys the def), exactly as before. + • override entries — Configurations under SurvivorCoreContent/Overrides/: 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 diff --git a/plugin/Nav.luau b/plugin/Nav.luau new file mode 100644 index 0000000..4ec4587 --- /dev/null +++ b/plugin/Nav.luau @@ -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 diff --git a/plugin/OverviewPage.luau b/plugin/OverviewPage.luau new file mode 100644 index 0000000..77663b0 --- /dev/null +++ b/plugin/OverviewPage.luau @@ -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 diff --git a/plugin/SearchPage.luau b/plugin/SearchPage.luau new file mode 100644 index 0000000..60053e7 --- /dev/null +++ b/plugin/SearchPage.luau @@ -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 diff --git a/plugin/SpawnHelpers.luau b/plugin/SpawnHelpers.luau new file mode 100644 index 0000000..4edc7b7 --- /dev/null +++ b/plugin/SpawnHelpers.luau @@ -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 diff --git a/plugin/StatAdminUi.luau b/plugin/StatAdminUi.luau index c49c6ea..d92a4a8 100644 --- a/plugin/StatAdminUi.luau +++ b/plugin/StatAdminUi.luau @@ -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 diff --git a/plugin/Theme.luau b/plugin/Theme.luau new file mode 100644 index 0000000..9dc560f --- /dev/null +++ b/plugin/Theme.luau @@ -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 diff --git a/plugin/init.server.luau b/plugin/init.server.luau index 387ecae..31fbc87 100644 --- a/plugin/init.server.luau +++ b/plugin/init.server.luau @@ -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() diff --git a/sourcemap-plugin.json b/sourcemap-plugin.json new file mode 100644 index 0000000..9fd0795 --- /dev/null +++ b/sourcemap-plugin.json @@ -0,0 +1 @@ +{"name":"SurvivorCoreStatAdmin","className":"Script","filePaths":["plugin/init.server.luau","plugin.project.json"],"children":[{"name":"ConfigAdmin","className":"ModuleScript","filePaths":["plugin/ConfigAdmin.luau"]},{"name":"ConfigAdminUi","className":"ModuleScript","filePaths":["plugin/ConfigAdminUi.luau"]},{"name":"ContentAdmin","className":"ModuleScript","filePaths":["plugin/ContentAdmin.luau"]},{"name":"ContentAdminUi","className":"ModuleScript","filePaths":["plugin/ContentAdminUi.luau"]},{"name":"HudPreview","className":"ModuleScript","filePaths":["plugin/HudPreview.luau"]},{"name":"Nav","className":"ModuleScript","filePaths":["plugin/Nav.luau"]},{"name":"OverviewPage","className":"ModuleScript","filePaths":["plugin/OverviewPage.luau"]},{"name":"SearchPage","className":"ModuleScript","filePaths":["plugin/SearchPage.luau"]},{"name":"SpawnHelpers","className":"ModuleScript","filePaths":["plugin/SpawnHelpers.luau"]},{"name":"StatAdmin","className":"ModuleScript","filePaths":["plugin/StatAdmin.luau"]},{"name":"StatAdminUi","className":"ModuleScript","filePaths":["plugin/StatAdminUi.luau"]},{"name":"Theme","className":"ModuleScript","filePaths":["plugin/Theme.luau"]}]} \ No newline at end of file