Redesign survival HUD: panel, collapsible view, numeric values, icon slots

Wraps the bars in a dark translucent Panel and splits them into a Primary group
(Health, Energy — always shown) and a Collapsible group (the other five) toggled
by a chevron button, wired generically in the binder via name/attribute/tag so the
owner can re-group by drag-and-drop. Each bar gains an optional Value label
(configurable fraction/percent/value/none) and a blank, hidden Icon ImageLabel
(resolved from a per-bar attribute, the stat config, or Assets "StatIcons" — the
engine still ships zero asset ids). Sets IgnoreGuiInset=false so the HUD clears the
Roblox topbar. Adds icon/valueFormat fields through StatDefs/StatConfig + the config
template, and a silent Assets.tryGet for the icon hot path. The fallback HUD matches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Samuel Lison
2026-06-19 13:59:32 +10:00
co-authored by Claude Opus 4.8
parent 772b555578
commit c51e4b899b
8 changed files with 1782 additions and 734 deletions
+3 -1
View File
@@ -12,7 +12,9 @@ is promoted to the new version and `main` is tagged `vX.Y.Z`.
drain/regen, stored as auto-replicating Player Attributes, and a reactive **top-left HUD**
renders them (no RemoteEvents). The HUD is a real, designer-editable `SurvivalHud` ScreenGui
in StarterGui — restyle it in Studio with zero code; bars bind by a `Stat` attribute and a
`Fill` child. Ships with seven default stats (health/energy/hunger/thirst/fatigue/blood/
`Fill` child. It's a translucent panel that shows **Health + Energy** by default and **expands**
to the full roster, with per-bar **numeric readouts** (`99/100` / `%`, configurable) and blank
**icon slots** (filled from the `Assets` registry — the engine ships none). Ships with seven default stats (health/energy/hunger/thirst/fatigue/blood/
poison), tunable **without code** via a `SurvivalStatsConfig` Configuration instance (or, for
developers, `Config.override("SurvivalStats", …)` / `Stats.defineStat`). Works out of the box
for every distribution — the demo/Rojo source mount it, the drop-in `.rbxm` auto-installs it
+21 -7
View File
@@ -10,7 +10,9 @@
"Start": 100,
"WarnAt": 25,
"Invert": false,
"Display": true
"Display": true,
"Icon": "",
"ValueFormat": "fraction"
}
},
{
@@ -22,7 +24,9 @@
"Start": 100,
"WarnAt": 25,
"Invert": false,
"Display": true
"Display": true,
"Icon": "",
"ValueFormat": "fraction"
}
},
{
@@ -34,7 +38,9 @@
"Start": 0,
"WarnAt": 25,
"Invert": true,
"Display": true
"Display": true,
"Icon": "",
"ValueFormat": "fraction"
}
},
{
@@ -46,7 +52,9 @@
"Start": 0,
"WarnAt": 25,
"Invert": true,
"Display": true
"Display": true,
"Icon": "",
"ValueFormat": "fraction"
}
},
{
@@ -58,7 +66,9 @@
"Start": 0,
"WarnAt": 25,
"Invert": true,
"Display": true
"Display": true,
"Icon": "",
"ValueFormat": "fraction"
}
},
{
@@ -70,7 +80,9 @@
"Start": 100,
"WarnAt": 25,
"Invert": false,
"Display": true
"Display": true,
"Icon": "",
"ValueFormat": "fraction"
}
},
{
@@ -82,7 +94,9 @@
"Start": 0,
"WarnAt": 25,
"Invert": true,
"Display": true
"Display": true,
"Icon": "",
"ValueFormat": "fraction"
}
}
]
File diff suppressed because it is too large Load Diff
+161 -4
View File
@@ -8,12 +8,27 @@
Per-bar contract (attributes on the tagged Frame; most default from the stat config,
so owners usually set only `Stat`):
Stat (string) Player attribute to bind, e.g. "Hunger" (required)
Max (number) fill denominator
Invert (bool) fill = 1 - value/Max (bar full = healthy)
WarnAt (number) warn when the displayed bar drops below this percent
Stat (string) Player attribute to bind, e.g. "Hunger" (required)
Max (number) fill denominator
Invert (bool) fill = 1 - value/Max (bar full = healthy)
WarnAt (number) warn when the displayed bar drops below this percent
FillColor/WarnColor (Color3), FillAxis ("X"|"Y")
ValueFormat (string) numeric readout: "fraction" "99/100" | "percent" "99%"
| "value" "99" | "none" (hide). Defaults to the stat config.
Icon (string) icon asset id override (defaults to the stat config / Assets).
Required child: a GuiObject named `Fill`. Everything else is free-form styling.
OPTIONAL per-bar children the binder also drives if present (absent => ignored):
TextLabel `Value` filled with the formatted number (per ValueFormat above).
ImageLabel `Icon` Image set from the resolved icon id; hidden while the id is "".
OPTIONAL collapse controls (so the default view shows only the primary bars and the
rest reveal on click) all discovered by name/attribute/tag, never by fixed path:
A GuiButton with a `HudToggle` attribute (or tagged "HudToggle") toggles a
collapsible container. The attribute's string value names the container; empty
falls back to a "Collapsible"-named / "HudCollapsible"-tagged GuiObject.
• The container starts Visible=false; the binder flips it and rotates the chevron.
Everything new is optional, so a minimal HUD (or the fallback) binds exactly as before.
]]
local CollectionService = game:GetService("CollectionService")
@@ -24,12 +39,53 @@ local RunService = game:GetService("RunService")
assert(RunService:IsClient(), "SurvivorCore HUD is client-only — boot it via SurvivorCore.startClient()")
local StatConfig = require(script.Parent.Parent.stats.StatConfig)
local Assets = require(script.Parent.Parent.foundation.Assets)
local HudFallback = require(script.Parent.HudFallback)
local BAR_TAG = "SurvivorStatBar"
local HUD_NAME = "SurvivalHud"
local FALLBACK_WAIT = 1 -- seconds to wait for an authored HUD before building the fallback
local DEFAULT_WARN_COLOR = Color3.fromRGB(200, 40, 40)
local DEFAULT_VALUE_FORMAT = "fraction"
local ICON_CATEGORY = "StatIcons" -- Assets category an owner can populate (key = stat name)
-- Collapse-control discovery (attribute primary, CollectionService tag as a rename-proof
-- fallback the owner can apply in Studio; the shipped template uses attributes only).
local TOGGLE_NAME = "Toggle"
local TOGGLE_TAG = "HudToggle"
local TOGGLE_ATTR = "HudToggle"
local COLLAPSIBLE_NAME = "Collapsible"
local COLLAPSIBLE_TAG = "HudCollapsible"
local COLLAPSIBLE_ATTR = "HudCollapsible"
-- Resolve the bar's numeric readout string for the given raw value/max. `value` is the
-- RAW stat (not the inverted fill ratio), so an inverted Hunger bar still reads "12/100".
local function formatValue(format: string, value: any, max: number): string
local n = if typeof(value) == "number" then value else 0
if format == "none" then
return ""
elseif format == "percent" then
local pct = if max > 0 then math.floor((n / max) * 100 + 0.5) else 0
return string.format("%d%%", math.clamp(pct, 0, 100))
elseif format == "value" then
return tostring(math.floor(n + 0.5))
end
-- "fraction" (default / unknown)
return string.format("%d/%d", math.floor(n + 0.5), math.floor(max + 0.5))
end
-- Resolve a bar's icon id: per-bar `Icon` attribute > stat config `icon` > Assets registry
-- (category "StatIcons", key = stat name). Returns "" when none — never a hardcoded id.
local function resolveIcon(bar: Instance, statName: string, def: any): string
local id = bar:GetAttribute("Icon")
if typeof(id) == "string" and id ~= "" then
return id
end
if def and typeof(def.icon) == "string" and def.icon ~= "" then
return def.icon
end
return Assets.tryGet(ICON_CATEGORY, statName) -- silent: "" when unregistered
end
local Hud = {}
local started = false
@@ -89,6 +145,10 @@ function Hud.start(_options: { [string]: any }?)
end
end
-- Optional per-bar extras (resolved once; absent => the binder no-ops on them).
local valueLabel = bar:FindFirstChild("Value")
local iconLabel = bar:FindFirstChild("Icon")
local function render()
local def = byName[statName]
local max = bar:GetAttribute("Max") or (def and def.max) or 100
@@ -115,6 +175,24 @@ function Hud.start(_options: { [string]: any }?)
fillObject.Size = UDim2.new(fillRatio, 0, size.Y.Scale, size.Y.Offset)
end
setColor(if fillRatio * 100 < warnAt then warnColor else normalColor)
-- Numeric readout (optional `Value` TextLabel). Uses the RAW value, not the
-- inverted fill — owners expect "99/100" to mean the real attribute.
if valueLabel and valueLabel:IsA("TextLabel") then
local format = bar:GetAttribute("ValueFormat")
if typeof(format) ~= "string" then
format = (def and def.valueFormat) or DEFAULT_VALUE_FORMAT
end
valueLabel.Text = formatValue(format, value, max)
end
-- Icon (optional `Icon` ImageLabel). Blank id => hidden, so a fresh engine
-- shows no empty box until the owner supplies art.
if iconLabel and (iconLabel:IsA("ImageLabel") or iconLabel:IsA("ImageButton")) then
local imageId = resolveIcon(bar, statName, def);
(iconLabel :: ImageLabel).Image = imageId;
(iconLabel :: GuiObject).Visible = imageId ~= ""
end
end
local connection = localPlayer:GetAttributeChangedSignal(statName):Connect(render)
@@ -140,14 +218,83 @@ function Hud.start(_options: { [string]: any }?)
return instance:IsA("GuiObject") and instance:GetAttribute("Stat") ~= nil
end
-- A collapse toggle = a GuiButton with a `HudToggle` attribute (or tagged "HudToggle").
local function isToggle(instance: Instance): boolean
if not instance:IsA("GuiButton") then
return false
end
return instance:GetAttribute(TOGGLE_ATTR) ~= nil
or instance.Name == TOGGLE_NAME
or CollectionService:HasTag(instance, TOGGLE_TAG)
end
-- Find the collapsible container a toggle controls. Prefer the container named by the
-- toggle's `HudToggle` attribute (searched from the toggle's HUD root), else fall back
-- to a "HudCollapsible"-tagged / `HudCollapsible`-attributed / "Collapsible"-named one.
local function findCollapsible(button: Instance): Instance?
local hudRoot = button:FindFirstAncestorWhichIsA("ScreenGui") or playerGui
local named = button:GetAttribute(TOGGLE_ATTR)
if typeof(named) == "string" and named ~= "" then
local found = hudRoot:FindFirstChild(named, true)
if found and found:IsA("GuiObject") then
return found
end
end
for _, tagged in CollectionService:GetTagged(COLLAPSIBLE_TAG) do
if tagged:IsDescendantOf(hudRoot) and tagged:IsA("GuiObject") then
return tagged
end
end
for _, descendant in hudRoot:GetDescendants() do
if descendant:IsA("GuiObject") and descendant:GetAttribute(COLLAPSIBLE_ATTR) ~= nil then
return descendant
end
end
local byNameMatch = hudRoot:FindFirstChild(COLLAPSIBLE_NAME, true)
if byNameMatch and byNameMatch:IsA("GuiObject") then
return byNameMatch
end
return nil
end
local toggles: { [Instance]: () -> () } = {}
local function wireToggle(button: Instance)
if toggles[button] or not isToggle(button) or not button:IsDescendantOf(playerGui) then
return
end
local target = findCollapsible(button)
if not target or not target:IsA("GuiObject") then
return -- minimal HUDs with a button but no container: skip silently
end
local container: GuiObject = target
local guiButton: GuiButton = button :: GuiButton
local function apply()
-- Rotate the same glyph 180° to flip the chevron — no second glyph / asset.
guiButton.Rotation = if container.Visible then 180 else 0
end
apply()
local connection = guiButton.Activated:Connect(function()
container.Visible = not container.Visible
apply()
end)
toggles[button] = function()
connection:Disconnect()
end
end
for _, instance in playerGui:GetDescendants() do
if isBar(instance) then
tryBind(instance)
elseif isToggle(instance) then
wireToggle(instance)
end
end
playerGui.DescendantAdded:Connect(function(instance)
if isBar(instance) then
tryBind(instance)
elseif isToggle(instance) then
wireToggle(instance)
end
end)
playerGui.DescendantRemoving:Connect(function(instance)
@@ -157,6 +304,11 @@ function Hud.start(_options: { [string]: any }?)
end
bound[instance] = nil
renderers[instance] = nil
local untoggle = toggles[instance]
if untoggle then
untoggle()
toggles[instance] = nil
end
end)
for _, bar in CollectionService:GetTagged(BAR_TAG) do
@@ -164,6 +316,11 @@ function Hud.start(_options: { [string]: any }?)
end
CollectionService:GetInstanceAddedSignal(BAR_TAG):Connect(tryBind)
for _, button in CollectionService:GetTagged(TOGGLE_TAG) do
wireToggle(button)
end
CollectionService:GetInstanceAddedSignal(TOGGLE_TAG):Connect(wireToggle)
-- Re-render bars live when the owner edits the Studio config (WarnAt / Max / colors).
local function rerenderAll()
byName = StatConfig.resolve().byName
+51 -5
View File
@@ -32,16 +32,45 @@ function HudFallback.build(playerGui: Instance, stats: { any })
local screen = Instance.new("ScreenGui")
screen.Name = HUD_NAME
screen.ResetOnSpawn = false
screen.IgnoreGuiInset = true
-- Respect the ~36px Roblox topbar inset so bars never render under the CoreGui menu.
screen.IgnoreGuiInset = false
screen.ZIndexBehavior = Enum.ZIndexBehavior.Sibling
-- A dark translucent background panel so the fallback reads cleanly, matching the
-- authored template's look. Auto-sizes to its bars; the owner never edits this file.
local panel = Instance.new("Frame")
panel.Name = "Panel"
panel.Position = UDim2.fromOffset(16, 12)
panel.Size = UDim2.fromOffset(248, 0)
panel.AutomaticSize = Enum.AutomaticSize.Y
panel.BackgroundColor3 = Color3.fromRGB(20, 23, 30)
panel.BackgroundTransparency = 0.25
panel.BorderSizePixel = 0
panel.Parent = screen
local corner = Instance.new("UICorner")
corner.CornerRadius = UDim.new(0, 10)
corner.Parent = panel
local stroke = Instance.new("UIStroke")
stroke.Color = Color3.fromRGB(210, 220, 245)
stroke.Thickness = 1
stroke.Transparency = 0.85
stroke.Parent = panel
local padding = Instance.new("UIPadding")
padding.PaddingTop = UDim.new(0, 10)
padding.PaddingBottom = UDim.new(0, 10)
padding.PaddingLeft = UDim.new(0, 12)
padding.PaddingRight = UDim.new(0, 12)
padding.Parent = panel
local container = Instance.new("Frame")
container.Name = "Bars"
container.Position = UDim2.fromOffset(18, 18)
container.Size = UDim2.fromOffset(220, 0)
container.Size = UDim2.fromScale(1, 0)
container.AutomaticSize = Enum.AutomaticSize.Y
container.BackgroundTransparency = 1
container.Parent = screen
container.Parent = panel
local layout = Instance.new("UIListLayout")
layout.Padding = UDim.new(0, 6)
@@ -75,14 +104,31 @@ function HudFallback.build(playerGui: Instance, stats: { any })
local label = Instance.new("TextLabel")
label.Name = "Label"
label.BackgroundTransparency = 1
label.Size = UDim2.fromScale(1, 1)
label.Size = UDim2.new(1, -64, 1, 0)
label.Position = UDim2.fromOffset(8, 0)
label.Text = stat.name
label.TextColor3 = Color3.fromRGB(245, 245, 245)
label.TextSize = 12
label.Font = Enum.Font.GothamMedium
label.TextXAlignment = Enum.TextXAlignment.Left
label.ZIndex = 2
label.Parent = bar
-- Empty `Value` label the binder fills with the formatted number (optional but
-- cheap; keeps the fallback consistent with the authored template).
local valueLabel = Instance.new("TextLabel")
valueLabel.Name = "Value"
valueLabel.BackgroundTransparency = 1
valueLabel.Size = UDim2.new(0, 52, 1, 0)
valueLabel.Position = UDim2.new(1, -58, 0, 0)
valueLabel.Text = ""
valueLabel.TextColor3 = Color3.fromRGB(245, 245, 245)
valueLabel.TextSize = 12
valueLabel.Font = Enum.Font.GothamMedium
valueLabel.TextXAlignment = Enum.TextXAlignment.Right
valueLabel.ZIndex = 2
valueLabel.Parent = bar
CollectionService:AddTag(bar, BAR_TAG)
-- Parent last, fully built, so the binder sees Stat + Fill when it fires.
bar.Parent = container
+12
View File
@@ -31,4 +31,16 @@ function Assets.get(category: string, key: string): string
return id
end
-- Like `get` but SILENT: returns "" for a missing/empty asset without warning.
-- Use it on hot paths (e.g. the HUD resolving an optional per-stat icon every render)
-- where a missing id is an expected, non-error case.
function Assets.tryGet(category: string, key: string): string
local cat = store[category]
local id = cat and cat[key]
if typeof(id) == "string" then
return id
end
return ""
end
return Assets
+6
View File
@@ -29,6 +29,8 @@ local STUDIO_ATTR_MAP = {
WarnAt = "warnAt",
Invert = "invert",
Display = "display",
Icon = "icon",
ValueFormat = "valueFormat",
}
local function applyTable(target: { [string]: any }, override: { [string]: any }?)
@@ -65,6 +67,8 @@ export type ResolvedStat = {
invert: boolean,
warnAt: number,
display: boolean,
icon: string, -- HUD icon asset id, "" when none (engine ships "" — content-free)
valueFormat: string, -- HUD numeric readout: "fraction" | "percent" | "value" | "none"
}
-- Returns the merged stat models, both as an ordered array and a name lookup.
@@ -84,6 +88,8 @@ function StatConfig.resolve(): { stats: { ResolvedStat }, byName: { [string]: Re
invert = def.invert,
warnAt = def.warnAt,
display = def.display,
icon = def.icon or "",
valueFormat = def.valueFormat or "fraction",
}
if section then
applyTable(resolved, section[def.name])
+6
View File
@@ -23,6 +23,8 @@ export type StatDef = {
warnAt: number, -- warn when the displayed bar drops below this percent (0-100)
display: boolean, -- show in the HUD by default
attribute: string, -- Player attribute name (defaults to `name`)
icon: string?, -- HUD icon asset id; ALWAYS "" here (content-free) — owners supply art
valueFormat: string?, -- HUD numeric readout: "fraction" | "percent" | "value" | "none"
}
local StatDefs = {}
@@ -91,6 +93,8 @@ function StatDefs.install()
invert = def.invert,
warnAt = def.warnAt,
display = def.display,
icon = def.icon or "", -- content-free default; owners supply art via config/Assets
valueFormat = def.valueFormat or "fraction",
}
Registries.Stats.register({
name = def.name,
@@ -101,6 +105,8 @@ function StatDefs.install()
warnAt = def.warnAt,
display = def.display,
attribute = def.name,
icon = def.icon or "",
valueFormat = def.valueFormat or "fraction",
})
end
Config.defineSection("SurvivalStats", section)