Files
SurvivorCore/plugin/ContentAdminUi.luau
T
Samuel LisonandClaude Opus 4.8 9caa888369 feat: quests + achievements — the goals & progression release (#10)
Quests (#10): a Quests registry + server runtime — objectives (gather/craft/
kill/use × count, blank target = any) and rewards, autoStart + requires chains
(completing a prerequisite auto-starts dependents), optional turn-in at a
tagged QuestGiver (component + prompt). Rewards are never lost: a full
inventory parks the quest as ready and the grant retries on inventory change.
Quests menu tab (L) with per-objective progress bars; QuestLog JSON attribute
replicates state; quest:started/progress/completed/blocked via Hooks + bus.

Achievements: an always-on runtime for the existing registry, ported
architecturally from TCE. A shared Progression layer translates bus events
into auto-derived counters (gathers_reed, crafts_total, kills_husk, …) so a
def is just { key, name, counter, threshold } — flat in code and no-code.
Unlock-once + toast + Achievements tab (J) with progress bars.

Toasts: themed top-right notification queue (Notify remote + Toasts.show).

No-code: admin plugin gains Quests (single-objective + "+ Quest giver" drop)
and Achievements (counter/threshold) editors; the engine loads both from
SurvivorCoreContent. Demo: a 3-quest chain + 4 achievements + a giver post.

Fixed: registerPanel now ADOPTS a template-scaffolded tab (hides the
placeholder, builds into the authored frame) instead of silently no-opping —
this replaces the Quests/Achievements "coming soon" placeholders.

EventBridge parity: gather:*, craft:* and item:use now also cross the bus.
Changed: rojo 7.6.1 → 7.7.0 (rokit pin; CI follows; gate verified).

Docs: quests.md + achievements.md (new), content-authoring/admin-plugin/
extending/README updated, CHANGELOG Unreleased, site feature card.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:26:19 +10:00

323 lines
9.1 KiB
Luau

--!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.
]]
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 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", {
Size = UDim2.fromScale(0.4, 1),
BackgroundTransparency = 1,
Text = field.label,
TextColor3 = COL_DIM,
TextXAlignment = Enum.TextXAlignment.Left,
TextSize = 13,
Font = FONT,
}).Parent =
row
local box = make("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)
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 }),
})
local title = make("Frame", { Size = UDim2.new(1, 0, 0, 24), BackgroundTransparency = 1, LayoutOrder = 0 })
title.Parent = panel
local del = make("TextButton", {
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) })
del.Parent = title
del.MouseButton1Click:Connect(function()
applyDelete(catKey, id)
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", {
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,
TextSize = 11,
Font = FONT,
BorderSizePixel = 0,
}, { corner(4) })
add.Parent = title
add.MouseButton1Click:Connect(function()
applySpawn(catKey, id)
end)
labelRight = 176
end
make("TextLabel", {
Size = UDim2.new(1, -labelRight, 1, 0),
BackgroundTransparency = 1,
Text = id,
TextColor3 = COL_TEXT,
TextXAlignment = Enum.TextXAlignment.Left,
TextSize = 14,
Font = Enum.Font.GothamBold,
}).Parent =
title
for i, field in cat.fields do
local row = buildField(catKey, id, field, ContentAdmin.read(catKey, id, field), applySet)
row.LayoutOrder = i
row.Parent = panel
end
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
end
return block
end
function ContentAdminUi.mount(
container: Instance,
ContentAdmin: any,
applySet,
applyCreate,
applyDelete,
applySpawn
): { 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),
}),
})
scroll.Parent = container
local function refresh()
for _, child in scroll:GetChildren() do
if not child:IsA("UIBase") then
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
end
end
refreshBtn.MouseButton1Click:Connect(refresh)
refresh()
return { refresh = refresh }
end
return ContentAdminUi