diff --git a/CHANGELOG.md b/CHANGELOG.md index ad0d4b4..3e0263f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,7 +33,9 @@ is promoted to the new version and `main` is tagged `vX.Y.Z`. owner tuning survives engine updates while untouched fields keep following improvable defaults, and a hard guardrail makes the engine-owned `Invert`/`DangerHigh` semantics impossible to write. Built as a separate `plugin.project.json` Rojo target (install via - `rojo build plugin.project.json --plugin …`); CI lints and builds it too. + `rojo build plugin.project.json --plugin …`); CI lints and builds it too. Includes a reversible + **edit-mode HUD preview** (Preview / Clear) that paints resolved icons + sample fills onto the + StarterGui HUD, so owners see play-time styling without pressing Play. See [docs/admin-plugin.md](docs/admin-plugin.md). - **Continuous integration** (`.github/workflows/ci.yml`) — every push to `main`/`dev` and every PR runs `stylua --check`, `selene`, `luau-lsp analyze` (against a Rojo sourcemap + diff --git a/docs/admin-plugin.md b/docs/admin-plugin.md index 4bd298e..0e17626 100644 --- a/docs/admin-plugin.md +++ b/docs/admin-plugin.md @@ -58,6 +58,18 @@ It reads the stat roster + defaults live from the engine in the place, so it alw version you're running. With no engine synced it shows an instructional empty state and writes nothing. +### Preview the HUD in Edit mode + +Roblox doesn't run the HUD's client binder in Studio's **Edit** view, so an authored `SurvivalHud` +normally shows its static template there — full bars, blank readouts, and only the shipped *default* +icons (an icon override you set above isn't visible until you press Play). The footer's **Preview +HUD** button paints, in Edit, what the running game *would* render: each bar's resolved icon +(including your overrides), a sample partial fill, and a sample value readout — so you can tune and +**see the result without pressing Play**. **Clear** restores the HUD; both are a single **undo** +step. (Preview resolves icons exactly like the engine — per-bar `Icon` attribute › the stat's +effective `icon` — and edits the `SurvivalHud` in StarterGui, so on the Rojo-mounted demo a re-sync +also resets it.) + ## Why your tuning is "locked" — it survives re-syncs and engine updates This is the important part. The engine resolves each stat as **engine default → `Config.override` → diff --git a/plugin/HudPreview.luau b/plugin/HudPreview.luau new file mode 100644 index 0000000..aedb9da --- /dev/null +++ b/plugin/HudPreview.luau @@ -0,0 +1,206 @@ +--!nonstrict +--[[ + HudPreview — a REVERSIBLE, edit-mode "what it looks like in play" preview for the HUD. + + Roblox doesn't run the HUD's client binder in Studio's Edit view, so an authored + `SurvivalHud` shows its static template: full bars, blank value text, and only the + shipped DEFAULT icons (an admin/config icon override isn't visible until you Play). + This paints, in Edit, what the runtime binder WOULD render — resolved icons (incl. + owner overrides) + a sample partial fill + a sample value readout — so the owner can + tune the HUD and see the real thing without pressing Play. + + It mirrors the engine binder's resolution (`src/client/Hud.luau`) so the preview matches + runtime: per-bar `Icon` attribute > the stat's effective `icon` (config/admin override, else + the shipped default) for icons; `FillAxis`-aware fill sizing; the per-stat `ValueFormat` for + the readout. (It does NOT consult the `Assets` registry tier — that's populated at runtime, so + it's empty in Edit anyway.) Stat *fill direction* is engine-owned, so the sample is a plain + ratio; we don't guess invert/dangerHigh. + + REVERSIBLE: every property it changes is snapshotted (once) before the first write, and + `clear()` restores the snapshots exactly. Each apply/clear is wrapped by the plugin main in a + single ChangeHistory recording, so Studio undo reverts it too. +]] + +local StarterGui = game:GetService("StarterGui") + +local HudPreview = {} + +local HUD_NAME = "SurvivalHud" +local SAMPLE_COUNTER = 1250 -- a believable credits value for the readout + +-- inst -> { [prop] = originalValue }. The live preview's snapshot of everything we touched. +local snapshots: { [Instance]: { [string]: any } } = {} + +-- Snapshot inst[prop] ONCE (so re-applying preview never captures a previewed value as original). +local function remember(inst: Instance, prop: string) + local bag = snapshots[inst] + if not bag then + bag = {} + snapshots[inst] = bag + end + if bag[prop] == nil then + bag[prop] = (inst :: any)[prop] + end +end + +-- A deterministic, varied sample fill ratio per stat (so bars don't all look identical, and the +-- preview is stable across clicks). Range ~0.40–0.85. +local function sampleRatio(name: string): number + local sum = 0 + for i = 1, #name do + sum += string.byte(name, i) + end + return 0.4 + (sum % 46) / 100 +end + +-- Mirror of Hud.luau formatValue for the sample readout. +local function formatValue(format: string, value: number, max: number): string + if format == "none" then + return "" + elseif format == "percent" then + local pct = if max > 0 then math.floor((value / max) * 100 + 0.5) else 0 + return string.format("%d%%", math.clamp(pct, 0, 100)) + elseif format == "value" then + return tostring(math.floor(value + 0.5)) + end + return string.format("%d/%d", math.floor(value + 0.5), math.floor(max + 0.5)) -- fraction +end + +-- Mirror of Hud.luau formatCounter ("1,250"). +local function formatCounter(n: number): string + local digits = tostring(math.abs(math.floor(n + 0.5))) + local grouped = "" + while #digits > 3 do + grouped = "," .. string.sub(digits, -3) .. grouped + digits = string.sub(digits, 1, -4) + end + return digits .. grouped +end + +-- The authored HUD in StarterGui, or nil. Read-only. +function HudPreview.findHud(): Instance? + return StarterGui:FindFirstChild(HUD_NAME) +end + +local function isImage(inst: Instance?): boolean + return inst ~= nil and (inst:IsA("ImageLabel") or inst:IsA("ImageButton")) +end + +export type Result = { ok: boolean, count: number, reason: string? } + +-- Paint the preview onto the HUD (StarterGui's by default; `hudRoot` lets callers/tests target a +-- specific tree). `StatAdmin` supplies the effective (config-aware) icon / max / value-format per +-- stat. Returns how many bars/counters were painted. +function HudPreview.apply(StatAdmin: any, hudRoot: Instance?): Result + local hud = hudRoot or HudPreview.findHud() + if not hud then + return { + ok = false, + count = 0, + reason = "No SurvivalHud in StarterGui — sync the engine/HUD, then preview.", + } + end + + -- Effective per-stat values (config/admin override else the shipped default), keyed by stat. + local roster = StatAdmin.readRoster() + local defaultsByName: { [string]: any } = {} + if roster.ok then + for _, entry in roster.stats do + defaultsByName[entry.name] = entry.defaults + end + end + local function effective(stat: string, attr: string): any + local defs = defaultsByName[stat] + return StatAdmin.readEffective(stat, attr, defs and defs[attr]).value + end + + local count = 0 + for _, node in hud:GetDescendants() do + local stat = node:GetAttribute("Stat") + local counter = node:GetAttribute("Counter") + local key = stat or counter + if typeof(key) ~= "string" then + continue + end + + -- Icon: per-bar attribute wins, else the stat's effective icon (counters have no roster + -- entry, so they rely on the per-bar attribute the template bakes). + local perBar = node:GetAttribute("Icon") + local icon: string = "" + if typeof(perBar) == "string" and perBar ~= "" then + icon = perBar + elseif typeof(stat) == "string" and defaultsByName[stat] then + local resolved = effective(stat, "Icon") + icon = if typeof(resolved) == "string" then resolved else "" + end + + local iconLabel = node:FindFirstChild("Icon") + if isImage(iconLabel) then + remember(iconLabel :: Instance, "Image") + remember(iconLabel :: Instance, "Visible"); + (iconLabel :: any).Image = icon; + (iconLabel :: any).Visible = icon ~= "" + end + + if typeof(stat) == "string" then + local max = tonumber(effective(stat, "Max")) or 100 + local ratio = sampleRatio(stat) + local sampleValue = ratio * max + + local fill = node:FindFirstChild("Fill") + if fill and fill:IsA("GuiObject") then + remember(fill, "Size") + local size = (fill :: GuiObject).Size + local axis = node:GetAttribute("FillAxis") + if axis == "Y" then + (fill :: GuiObject).Size = UDim2.new(size.X.Scale, size.X.Offset, ratio, 0) + else + (fill :: GuiObject).Size = UDim2.new(ratio, 0, size.Y.Scale, size.Y.Offset) + end + end + + local valueLabel = node:FindFirstChild("Value") + if valueLabel and valueLabel:IsA("TextLabel") then + local format = effective(stat, "ValueFormat") + format = if typeof(format) == "string" then format else "fraction" + remember(valueLabel, "Text"); + (valueLabel :: TextLabel).Text = formatValue(format, sampleValue, max) + end + elseif typeof(counter) == "string" then + local valueLabel = node:FindFirstChild("Value") + if valueLabel and valueLabel:IsA("TextLabel") then + remember(valueLabel, "Text"); + (valueLabel :: TextLabel).Text = formatCounter(SAMPLE_COUNTER) + end + end + + count += 1 + end + + return { ok = true, count = count } +end + +-- Restore every property the preview changed, then forget them. Safe to call with nothing active +-- (returns count = 0). Stale/destroyed instances are skipped. +function HudPreview.clear(): Result + local count = 0 + for inst, bag in snapshots do + if inst and inst.Parent then + for prop, value in bag do + pcall(function() + (inst :: any)[prop] = value + end) + end + count += 1 + end + end + snapshots = {} + return { ok = true, count = count } +end + +-- True when a preview is currently applied (used by the UI to label the toggle). +function HudPreview.isActive(): boolean + return next(snapshots) ~= nil +end + +return HudPreview diff --git a/plugin/StatAdminUi.luau b/plugin/StatAdminUi.luau index 2a9be94..c49c6ea 100644 --- a/plugin/StatAdminUi.luau +++ b/plugin/StatAdminUi.luau @@ -16,6 +16,7 @@ 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 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) @@ -124,7 +125,14 @@ end -- Mount the widget UI into `container`. Returns { refresh } — call refresh() after any edit -- or when the engine sync changes. `StatAdmin` is the logic module; the two callbacks route -- writes through the plugin main (ChangeHistory + refresh). -function StatAdminUi.mount(container: Instance, StatAdmin: any, applyEdit, applyReset): { refresh: () -> () } +function StatAdminUi.mount( + container: Instance, + StatAdmin: any, + applyEdit, + applyReset, + applyPreview, + applyClear +): { refresh: () -> () } for _, child in container:GetChildren() do if not child:IsA("UIBase") then child:Destroy() @@ -162,7 +170,7 @@ function StatAdminUi.mount(container: Instance, StatAdmin: any, applyEdit, apply refreshBtn.Parent = header local scroll = make("ScrollingFrame", { - Size = UDim2.new(1, 0, 1, -32), + Size = UDim2.new(1, 0, 1, -(32 + FOOTER_H)), Position = UDim2.fromOffset(0, 32), BackgroundTransparency = 1, BorderSizePixel = 0, @@ -180,6 +188,65 @@ function StatAdminUi.mount(container: Instance, StatAdmin: any, applyEdit, apply }) scroll.Parent = container + -- Footer: the edit-mode HUD preview controls. Preview paints resolved icons + sample fills + -- onto the StarterGui HUD (so you see play-time styling without pressing Play); Clear restores. + local footer = make("Frame", { + Size = UDim2.new(1, 0, 0, FOOTER_H), + Position = UDim2.new(0, 0, 1, -FOOTER_H), + BackgroundColor3 = COL_PANEL, + BorderSizePixel = 0, + }) + footer.Parent = container + local previewBtn = make("TextButton", { + Size = UDim2.fromOffset(92, 22), + Position = UDim2.new(0, 10, 0.5, -11), + BackgroundColor3 = COL_FIELD, + AutoButtonColor = true, + Text = "Preview HUD", + TextColor3 = COL_TEXT, + TextSize = 12, + Font = FONT, + BorderSizePixel = 0, + }, { corner(4) }) + previewBtn.Parent = footer + local clearBtn = make("TextButton", { + Size = UDim2.fromOffset(56, 22), + Position = UDim2.new(0, 108, 0.5, -11), + BackgroundColor3 = COL_FIELD, + AutoButtonColor = true, + Text = "Clear", + TextColor3 = COL_DIM, + TextSize = 12, + Font = FONT, + BorderSizePixel = 0, + }, { corner(4) }) + clearBtn.Parent = footer + local status = make("TextLabel", { + Size = UDim2.new(1, -184, 1, 0), + Position = UDim2.fromOffset(174, 0), + BackgroundTransparency = 1, + Text = "", + TextColor3 = COL_DIM, + TextXAlignment = Enum.TextXAlignment.Right, + TextTruncate = Enum.TextTruncate.AtEnd, + TextSize = 11, + Font = FONT, + }) + status.Parent = footer + + previewBtn.MouseButton1Click:Connect(function() + local res = applyPreview and applyPreview() + if typeof(res) == "table" then + status.Text = if res.ok then `previewing {res.count}` else (res.reason or "no HUD found") + end + end) + clearBtn.MouseButton1Click:Connect(function() + local res = applyClear and applyClear() + if typeof(res) == "table" then + status.Text = if res.ok then "cleared" else (res.reason or "") + end + end) + local function refresh() for _, child in scroll:GetChildren() do if not child:IsA("UIBase") then diff --git a/plugin/init.server.luau b/plugin/init.server.luau index f9591c6..f441d35 100644 --- a/plugin/init.server.luau +++ b/plugin/init.server.luau @@ -15,6 +15,7 @@ local ChangeHistoryService = game:GetService("ChangeHistoryService") local StatAdmin = require(script.StatAdmin) local StatAdminUi = require(script.StatAdminUi) +local HudPreview = require(script.HudPreview) local toolbar = plugin:CreateToolbar("SurvivorCore") local button = toolbar:CreateButton("Survival Stats", "Tune survival-stat rates, thresholds and HUD options", "") @@ -64,7 +65,22 @@ local function applyReset(statName: string, attr: string): any end) end -ui = StatAdminUi.mount(container, StatAdmin, applyEdit, applyReset) +-- Edit-mode HUD preview (paints resolved icons + sample fills onto the StarterGui HUD) and its +-- undo-able restore. Each is one ChangeHistory step; both return the HudPreview result so the +-- footer can show a status. +local function applyPreview(): any + return record("Survival Stats: preview HUD", function() + return HudPreview.apply(StatAdmin) + end) +end + +local function applyClear(): any + return record("Survival Stats: clear HUD preview", function() + return HudPreview.clear() + end) +end + +ui = StatAdminUi.mount(container, StatAdmin, applyEdit, applyReset, applyPreview, applyClear) button.Click:Connect(function() widget.Enabled = not widget.Enabled