feat(plugin): add reversible edit-mode HUD preview (icons + sample fills)

Studio doesn't run the HUD client binder in Edit view, so an authored SurvivalHud
shows its static template there — full bars, blank readouts, and only the shipped
default icons (an icon override isn't visible until Play). The admin plugin now has
a footer with **Preview HUD** / **Clear** that paints, in Edit, what the running
game would render, so owners tune-and-see without pressing Play.

- plugin/HudPreview.luau (new): mirrors the engine binder's resolution — per-bar
  `Icon` attribute › the stat's effective icon (config/admin override else shipped
  default) for icons; FillAxis-aware sample fill; per-stat ValueFormat for the
  readout; a sample counter value. Every property is snapshotted once before the
  first write and clear() restores them exactly (so it's fully reversible);
  apply() takes an optional hud root for testability. Skips the Assets registry
  tier (runtime-only, empty in Edit) and never guesses engine-owned invert/dangerHigh.
- StatAdminUi: a footer bar with Preview / Clear buttons + a status readout.
- init.server: wires both through ChangeHistory (each is one undo step).
- Verified in Studio: synthetic-HUD logic test (icon resolution, sample fill/value,
  exact restore) + a live visual pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Samuel Lison
2026-06-19 19:26:54 +10:00
co-authored by Claude Opus 4.8
parent 69bbf12be9
commit c118ced576
5 changed files with 307 additions and 4 deletions
+3 -1
View File
@@ -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 +
+12
View File
@@ -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`
+206
View File
@@ -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.400.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
+69 -2
View File
@@ -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
+17 -1
View File
@@ -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