mirror of
https://github.com/TemujinCalidius/SurvivorCore.git
synced 2026-08-14 00:58:01 +00:00
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>
207 lines
7.1 KiB
Luau
207 lines
7.1 KiB
Luau
--!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
|