From 649ff0c1929326938316523f8611f9de16d1fb55 Mon Sep 17 00:00:00 2001 From: Samuel Lison Date: Fri, 19 Jun 2026 13:30:12 +1000 Subject: [PATCH] Add survival-stats engine + designer-editable HUD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server tick (src/systems/SurvivalStats) simulates per-stat drain/regen stored as Player Attributes, which auto-replicate so the client HUD (src/client/Hud) binds reactively via GetAttributeChangedSignal — no RemoteEvents. Stats are defined in src/stats/StatDefs and resolved through three layers (defaults < Config.override < a Studio SurvivalStatsConfig instance) by src/stats/StatConfig, so owners retune rates with no code. The HUD is a real, designer-editable ScreenGui in StarterGui, authored as diff-friendly JSON (assets/hud/SurvivalHud.model.json); bars bind by a `Stat` attribute + a `Fill` child, so re-texturing needs zero code. It ships for every distribution: the demo/Rojo source mount it, the drop-in .rbxm bundles it under a Templates folder and start()/installHud() deploys it, and HudFallback guarantees a HUD always appears. Adds the engine's first client layer (startClient()). Allow committing *.rbxmx/model templates (.gitignore) and skip the CI-fetched globalTypes.d.luau in selene. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 5 +- assets/client/HudLoader.client.luau | 13 + assets/config/SurvivalStatsConfig.model.json | 89 ++ assets/hud/SurvivalHud.model.json | 1039 ++++++++++++++++++ default.project.json | 8 +- demo.project.json | 18 +- selene.toml | 4 +- src/client/Hud.luau | 203 ++++ src/client/HudFallback.luau | 95 ++ src/init.luau | 29 +- src/stats/StatConfig.luau | 103 ++ src/stats/StatDefs.luau | 111 ++ src/systems/SurvivalStats.luau | 158 +++ 13 files changed, 1864 insertions(+), 11 deletions(-) create mode 100644 assets/client/HudLoader.client.luau create mode 100644 assets/config/SurvivalStatsConfig.model.json create mode 100644 assets/hud/SurvivalHud.model.json create mode 100644 src/client/Hud.luau create mode 100644 src/client/HudFallback.luau create mode 100644 src/stats/StatConfig.luau create mode 100644 src/stats/StatDefs.luau create mode 100644 src/systems/SurvivalStats.luau diff --git a/.gitignore b/.gitignore index c534f1f..d5828c2 100644 --- a/.gitignore +++ b/.gitignore @@ -6,9 +6,10 @@ globalTypes.d.luau *.rbxl.lock *.rbxlx.lock -# Build output (the .rbxm is built in CI and attached to releases) +# Build output (the .rbxm is built in CI and attached to releases). +# NOTE: *.rbxmx (XML models — e.g. the HUD/config templates under assets/) are +# version-controlled source, NOT build output, so they are intentionally tracked. *.rbxm -*.rbxmx /build/ # Toolchain managers diff --git a/assets/client/HudLoader.client.luau b/assets/client/HudLoader.client.luau new file mode 100644 index 0000000..0286243 --- /dev/null +++ b/assets/client/HudLoader.client.luau @@ -0,0 +1,13 @@ +--!nonstrict +--[[ + SurvivorCore HUD loader. Ships into StarterPlayerScripts (mounted by the demo, + cloned by SurvivorCore.start()/installHud() for drop-in consumers). It just boots + the client HUD; all logic lives in the engine module so it stays versioned. + Editing or restyling the HUD is done on the `SurvivalHud` ScreenGui in StarterGui — + never here. +]] + +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local SurvivorCore = require(ReplicatedStorage:WaitForChild("SurvivorCore")) +SurvivorCore.startClient() diff --git a/assets/config/SurvivalStatsConfig.model.json b/assets/config/SurvivalStatsConfig.model.json new file mode 100644 index 0000000..2588f52 --- /dev/null +++ b/assets/config/SurvivalStatsConfig.model.json @@ -0,0 +1,89 @@ +{ + "className": "Configuration", + "children": [ + { + "className": "Configuration", + "name": "Health", + "attributes": { + "RatePerSecond": 0.0, + "Max": 100, + "Start": 100, + "WarnAt": 25, + "Invert": false, + "Display": true + } + }, + { + "className": "Configuration", + "name": "Energy", + "attributes": { + "RatePerSecond": 0.0, + "Max": 100, + "Start": 100, + "WarnAt": 25, + "Invert": false, + "Display": true + } + }, + { + "className": "Configuration", + "name": "Hunger", + "attributes": { + "RatePerSecond": 0.055556, + "Max": 100, + "Start": 0, + "WarnAt": 25, + "Invert": true, + "Display": true + } + }, + { + "className": "Configuration", + "name": "Thirst", + "attributes": { + "RatePerSecond": 0.083333, + "Max": 100, + "Start": 0, + "WarnAt": 25, + "Invert": true, + "Display": true + } + }, + { + "className": "Configuration", + "name": "Fatigue", + "attributes": { + "RatePerSecond": 0.027778, + "Max": 100, + "Start": 0, + "WarnAt": 25, + "Invert": true, + "Display": true + } + }, + { + "className": "Configuration", + "name": "Blood", + "attributes": { + "RatePerSecond": 0.0, + "Max": 100, + "Start": 100, + "WarnAt": 25, + "Invert": false, + "Display": true + } + }, + { + "className": "Configuration", + "name": "Poison", + "attributes": { + "RatePerSecond": 0.0, + "Max": 100, + "Start": 0, + "WarnAt": 25, + "Invert": true, + "Display": true + } + } + ] +} diff --git a/assets/hud/SurvivalHud.model.json b/assets/hud/SurvivalHud.model.json new file mode 100644 index 0000000..8a93c50 --- /dev/null +++ b/assets/hud/SurvivalHud.model.json @@ -0,0 +1,1039 @@ +{ + "className": "ScreenGui", + "properties": { + "ResetOnSpawn": false, + "IgnoreGuiInset": true, + "ZIndexBehavior": "Sibling", + "DisplayOrder": 2 + }, + "children": [ + { + "className": "Frame", + "name": "Bars", + "properties": { + "Position": { + "UDim2": [ + [ + 0, + 16 + ], + [ + 0, + 16 + ] + ] + }, + "Size": { + "UDim2": [ + [ + 0, + 232 + ], + [ + 0, + 0 + ] + ] + }, + "AutomaticSize": "Y", + "BackgroundTransparency": 1, + "BorderSizePixel": 0 + }, + "children": [ + { + "className": "UIListLayout", + "properties": { + "Padding": { + "UDim": [ + 0, + 6 + ] + }, + "SortOrder": "LayoutOrder", + "FillDirection": "Vertical" + } + }, + { + "className": "Frame", + "name": "Health", + "properties": { + "Size": { + "UDim2": [ + [ + 0, + 232 + ], + [ + 0, + 22 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.1098, + 0.1255, + 0.1647 + ] + }, + "BorderSizePixel": 0, + "LayoutOrder": 1 + }, + "attributes": { + "Stat": "Health" + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "UIStroke", + "properties": { + "Color": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Thickness": 1, + "Transparency": 0.7 + } + }, + { + "className": "Frame", + "name": "Fill", + "properties": { + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 1, + 0 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.9098, + 0.2745, + 0.2745 + ] + }, + "BorderSizePixel": 0 + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + } + ] + }, + { + "className": "TextLabel", + "name": "Label", + "properties": { + "Size": { + "UDim2": [ + [ + 1, + -16 + ], + [ + 1, + 0 + ] + ] + }, + "Position": { + "UDim2": [ + [ + 0, + 10 + ], + [ + 0, + 0 + ] + ] + }, + "BackgroundTransparency": 1, + "Text": "Health", + "TextColor3": { + "Color3": [ + 0.9608, + 0.9608, + 0.9608 + ] + }, + "TextSize": 13, + "Font": "GothamMedium", + "TextXAlignment": "Left", + "TextYAlignment": "Center", + "ZIndex": 2 + } + } + ] + }, + { + "className": "Frame", + "name": "Energy", + "properties": { + "Size": { + "UDim2": [ + [ + 0, + 232 + ], + [ + 0, + 22 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.1098, + 0.1255, + 0.1647 + ] + }, + "BorderSizePixel": 0, + "LayoutOrder": 2 + }, + "attributes": { + "Stat": "Energy" + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "UIStroke", + "properties": { + "Color": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Thickness": 1, + "Transparency": 0.7 + } + }, + { + "className": "Frame", + "name": "Fill", + "properties": { + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 1, + 0 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.3529, + 0.8039, + 0.4706 + ] + }, + "BorderSizePixel": 0 + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + } + ] + }, + { + "className": "TextLabel", + "name": "Label", + "properties": { + "Size": { + "UDim2": [ + [ + 1, + -16 + ], + [ + 1, + 0 + ] + ] + }, + "Position": { + "UDim2": [ + [ + 0, + 10 + ], + [ + 0, + 0 + ] + ] + }, + "BackgroundTransparency": 1, + "Text": "Energy", + "TextColor3": { + "Color3": [ + 0.9608, + 0.9608, + 0.9608 + ] + }, + "TextSize": 13, + "Font": "GothamMedium", + "TextXAlignment": "Left", + "TextYAlignment": "Center", + "ZIndex": 2 + } + } + ] + }, + { + "className": "Frame", + "name": "Hunger", + "properties": { + "Size": { + "UDim2": [ + [ + 0, + 232 + ], + [ + 0, + 22 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.1098, + 0.1255, + 0.1647 + ] + }, + "BorderSizePixel": 0, + "LayoutOrder": 3 + }, + "attributes": { + "Stat": "Hunger" + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "UIStroke", + "properties": { + "Color": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Thickness": 1, + "Transparency": 0.7 + } + }, + { + "className": "Frame", + "name": "Fill", + "properties": { + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 1, + 0 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.8627, + 0.549, + 0.1569 + ] + }, + "BorderSizePixel": 0 + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + } + ] + }, + { + "className": "TextLabel", + "name": "Label", + "properties": { + "Size": { + "UDim2": [ + [ + 1, + -16 + ], + [ + 1, + 0 + ] + ] + }, + "Position": { + "UDim2": [ + [ + 0, + 10 + ], + [ + 0, + 0 + ] + ] + }, + "BackgroundTransparency": 1, + "Text": "Hunger", + "TextColor3": { + "Color3": [ + 0.9608, + 0.9608, + 0.9608 + ] + }, + "TextSize": 13, + "Font": "GothamMedium", + "TextXAlignment": "Left", + "TextYAlignment": "Center", + "ZIndex": 2 + } + } + ] + }, + { + "className": "Frame", + "name": "Thirst", + "properties": { + "Size": { + "UDim2": [ + [ + 0, + 232 + ], + [ + 0, + 22 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.1098, + 0.1255, + 0.1647 + ] + }, + "BorderSizePixel": 0, + "LayoutOrder": 4 + }, + "attributes": { + "Stat": "Thirst" + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "UIStroke", + "properties": { + "Color": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Thickness": 1, + "Transparency": 0.7 + } + }, + { + "className": "Frame", + "name": "Fill", + "properties": { + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 1, + 0 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.2353, + 0.6275, + 0.902 + ] + }, + "BorderSizePixel": 0 + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + } + ] + }, + { + "className": "TextLabel", + "name": "Label", + "properties": { + "Size": { + "UDim2": [ + [ + 1, + -16 + ], + [ + 1, + 0 + ] + ] + }, + "Position": { + "UDim2": [ + [ + 0, + 10 + ], + [ + 0, + 0 + ] + ] + }, + "BackgroundTransparency": 1, + "Text": "Thirst", + "TextColor3": { + "Color3": [ + 0.9608, + 0.9608, + 0.9608 + ] + }, + "TextSize": 13, + "Font": "GothamMedium", + "TextXAlignment": "Left", + "TextYAlignment": "Center", + "ZIndex": 2 + } + } + ] + }, + { + "className": "Frame", + "name": "Fatigue", + "properties": { + "Size": { + "UDim2": [ + [ + 0, + 232 + ], + [ + 0, + 22 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.1098, + 0.1255, + 0.1647 + ] + }, + "BorderSizePixel": 0, + "LayoutOrder": 5 + }, + "attributes": { + "Stat": "Fatigue" + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "UIStroke", + "properties": { + "Color": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Thickness": 1, + "Transparency": 0.7 + } + }, + { + "className": "Frame", + "name": "Fill", + "properties": { + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 1, + 0 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.6275, + 0.3922, + 0.8627 + ] + }, + "BorderSizePixel": 0 + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + } + ] + }, + { + "className": "TextLabel", + "name": "Label", + "properties": { + "Size": { + "UDim2": [ + [ + 1, + -16 + ], + [ + 1, + 0 + ] + ] + }, + "Position": { + "UDim2": [ + [ + 0, + 10 + ], + [ + 0, + 0 + ] + ] + }, + "BackgroundTransparency": 1, + "Text": "Fatigue", + "TextColor3": { + "Color3": [ + 0.9608, + 0.9608, + 0.9608 + ] + }, + "TextSize": 13, + "Font": "GothamMedium", + "TextXAlignment": "Left", + "TextYAlignment": "Center", + "ZIndex": 2 + } + } + ] + }, + { + "className": "Frame", + "name": "Blood", + "properties": { + "Size": { + "UDim2": [ + [ + 0, + 232 + ], + [ + 0, + 22 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.1098, + 0.1255, + 0.1647 + ] + }, + "BorderSizePixel": 0, + "LayoutOrder": 6 + }, + "attributes": { + "Stat": "Blood" + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "UIStroke", + "properties": { + "Color": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Thickness": 1, + "Transparency": 0.7 + } + }, + { + "className": "Frame", + "name": "Fill", + "properties": { + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 1, + 0 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.7059, + 0.1569, + 0.1569 + ] + }, + "BorderSizePixel": 0 + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + } + ] + }, + { + "className": "TextLabel", + "name": "Label", + "properties": { + "Size": { + "UDim2": [ + [ + 1, + -16 + ], + [ + 1, + 0 + ] + ] + }, + "Position": { + "UDim2": [ + [ + 0, + 10 + ], + [ + 0, + 0 + ] + ] + }, + "BackgroundTransparency": 1, + "Text": "Blood", + "TextColor3": { + "Color3": [ + 0.9608, + 0.9608, + 0.9608 + ] + }, + "TextSize": 13, + "Font": "GothamMedium", + "TextXAlignment": "Left", + "TextYAlignment": "Center", + "ZIndex": 2 + } + } + ] + }, + { + "className": "Frame", + "name": "Poison", + "properties": { + "Size": { + "UDim2": [ + [ + 0, + 232 + ], + [ + 0, + 22 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.1098, + 0.1255, + 0.1647 + ] + }, + "BorderSizePixel": 0, + "LayoutOrder": 7 + }, + "attributes": { + "Stat": "Poison" + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + }, + { + "className": "UIStroke", + "properties": { + "Color": { + "Color3": [ + 0.8235, + 0.8627, + 0.9608 + ] + }, + "Thickness": 1, + "Transparency": 0.7 + } + }, + { + "className": "Frame", + "name": "Fill", + "properties": { + "Size": { + "UDim2": [ + [ + 1, + 0 + ], + [ + 1, + 0 + ] + ] + }, + "BackgroundColor3": { + "Color3": [ + 0.4706, + 0.7059, + 0.2353 + ] + }, + "BorderSizePixel": 0 + }, + "children": [ + { + "className": "UICorner", + "properties": { + "CornerRadius": { + "UDim": [ + 0, + 6 + ] + } + } + } + ] + }, + { + "className": "TextLabel", + "name": "Label", + "properties": { + "Size": { + "UDim2": [ + [ + 1, + -16 + ], + [ + 1, + 0 + ] + ] + }, + "Position": { + "UDim2": [ + [ + 0, + 10 + ], + [ + 0, + 0 + ] + ] + }, + "BackgroundTransparency": 1, + "Text": "Poison", + "TextColor3": { + "Color3": [ + 0.9608, + 0.9608, + 0.9608 + ] + }, + "TextSize": 13, + "Font": "GothamMedium", + "TextXAlignment": "Left", + "TextYAlignment": "Center", + "ZIndex": 2 + } + } + ] + } + ] + } + ] +} diff --git a/default.project.json b/default.project.json index 5ab023c..230e215 100644 --- a/default.project.json +++ b/default.project.json @@ -1,6 +1,12 @@ { "name": "SurvivorCore", "tree": { - "$path": "src" + "$path": "src", + "Templates": { + "$className": "Folder", + "SurvivalHud": { "$path": "assets/hud/SurvivalHud.model.json" }, + "SurvivalStatsConfig": { "$path": "assets/config/SurvivalStatsConfig.model.json" }, + "HudLoader": { "$path": "assets/client/HudLoader.client.luau" } + } } } diff --git a/demo.project.json b/demo.project.json index 413e1f4..7fa96ea 100644 --- a/demo.project.json +++ b/demo.project.json @@ -3,13 +3,21 @@ "tree": { "$className": "DataModel", "ReplicatedStorage": { - "SurvivorCore": { - "$path": "src" - } + "SurvivorCore": { "$path": "src" }, + "SurvivalStatsConfig": { "$path": "assets/config/SurvivalStatsConfig.model.json" } }, "ServerScriptService": { - "Demo": { - "$path": "demo/server" + "Demo": { "$path": "demo/server" } + }, + "StarterGui": { + "$className": "StarterGui", + "SurvivalHud": { "$path": "assets/hud/SurvivalHud.model.json" } + }, + "StarterPlayer": { + "$className": "StarterPlayer", + "StarterPlayerScripts": { + "$className": "StarterPlayerScripts", + "HudLoader": { "$path": "assets/client/HudLoader.client.luau" } } }, "Workspace": { diff --git a/selene.toml b/selene.toml index ea74587..a0fc6e8 100644 --- a/selene.toml +++ b/selene.toml @@ -2,8 +2,8 @@ # Uses the Roblox standard library (globals like `game`, `task`, `typeof`, `warn`). std = "roblox" -# Generated Wally/Rojo artifacts are not ours to lint. -exclude = ["Packages", "ServerPackages", "DevPackages"] +# Generated Wally/Rojo artifacts (and the CI-fetched Roblox type defs) are not ours to lint. +exclude = ["Packages", "ServerPackages", "DevPackages", "globalTypes.d.luau"] [lints] # Roblox code legitimately leaves some values unused (e.g. connection handles); diff --git a/src/client/Hud.luau b/src/client/Hud.luau new file mode 100644 index 0000000..d95f766 --- /dev/null +++ b/src/client/Hud.luau @@ -0,0 +1,203 @@ +--[[ + Hud — the client HUD binder. CLIENT-ONLY. + + Drives any `SurvivorStatBar`-tagged GUI from the player's stat Attributes (which + the server writes and Roblox auto-replicates — no RemoteEvents). The owner authors + and re-textures the actual ScreenGui in StarterGui; this only ever sets each bar's + `Fill` size + color, so restyling needs zero code. + + 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 + • FillColor/WarnColor (Color3), FillAxis ("X"|"Y") + Required child: a GuiObject named `Fill`. Everything else is free-form styling. +]] + +local CollectionService = game:GetService("CollectionService") +local Players = game:GetService("Players") +local ReplicatedStorage = game:GetService("ReplicatedStorage") +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 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 Hud = {} +local started = false + +-- installHud() + the StarterGui->PlayerGui copy can briefly produce two HUDs; keep one. +local function dedupeHuds(playerGui: Instance) + local kept: Instance? = nil + for _, gui in playerGui:GetChildren() do + if gui:IsA("ScreenGui") and gui.Name == HUD_NAME then + if kept then + gui:Destroy() + else + kept = gui + end + end + end +end + +function Hud.start(_options: { [string]: any }?) + if started then + -- warn-and-return (not assert): the loader may re-run across respawns. + warn("[SurvivorCore] startClient() called more than once — ignoring") + return + end + started = true + + local localPlayer = Players.LocalPlayer + local playerGui = localPlayer:WaitForChild("PlayerGui") + + local byName = StatConfig.resolve().byName + + local bound: { [Instance]: () -> () } = {} + local renderers: { [Instance]: () -> () } = {} + + local function bindBar(bar: Instance) + local statName = bar:GetAttribute("Stat") + if typeof(statName) ~= "string" or statName == "" then + warn(`[SurvivorCore HUD] bar '{bar:GetFullName()}' is missing a 'Stat' attribute`) + return + end + local fill = bar:FindFirstChild("Fill") + if not fill or not fill:IsA("GuiObject") then + warn(`[SurvivorCore HUD] bar '{bar:GetFullName()}' is missing a 'Fill' GuiObject child`) + return + end + local fillObject: GuiObject = fill + local isImage = fillObject:IsA("ImageLabel") or fillObject:IsA("ImageButton") + local authoredColor = if isImage + then (fillObject :: ImageLabel).ImageColor3 + else (fillObject :: Frame).BackgroundColor3 + + local function setColor(color: Color3) + if isImage then + (fillObject :: ImageLabel).ImageColor3 = color + else + (fillObject :: Frame).BackgroundColor3 = color + end + end + + local function render() + local def = byName[statName] + local max = bar:GetAttribute("Max") or (def and def.max) or 100 + local invert = bar:GetAttribute("Invert") + if invert == nil then + invert = (def and def.invert) or false + end + local warnAt = bar:GetAttribute("WarnAt") or (def and def.warnAt) or 25 + local axis = bar:GetAttribute("FillAxis") or "X" + local normalColor = bar:GetAttribute("FillColor") or authoredColor + local warnColor = bar:GetAttribute("WarnColor") or DEFAULT_WARN_COLOR + + local value = localPlayer:GetAttribute(statName) + local ratio = 0 + if typeof(value) == "number" and max > 0 then + ratio = math.clamp(value / max, 0, 1) + end + local fillRatio = if invert then 1 - ratio else ratio + + local size = fillObject.Size + if axis == "Y" then + fillObject.Size = UDim2.new(size.X.Scale, size.X.Offset, fillRatio, 0) + else + fillObject.Size = UDim2.new(fillRatio, 0, size.Y.Scale, size.Y.Offset) + end + setColor(if fillRatio * 100 < warnAt then warnColor else normalColor) + end + + local connection = localPlayer:GetAttributeChangedSignal(statName):Connect(render) + renderers[bar] = render + bound[bar] = function() + connection:Disconnect() + end + render() + end + + local function tryBind(bar: Instance) + if bound[bar] or not bar:IsA("GuiObject") or not bar:IsDescendantOf(playerGui) then + return + end + bindBar(bar) + end + + dedupeHuds(playerGui) + + -- A stat bar is any GuiObject carrying a `Stat` attribute (how the authored + -- template marks them) OR tagged `SurvivorStatBar` (for the Builder UI / fallback). + local function isBar(instance: Instance): boolean + return instance:IsA("GuiObject") and instance:GetAttribute("Stat") ~= nil + end + + for _, instance in playerGui:GetDescendants() do + if isBar(instance) then + tryBind(instance) + end + end + playerGui.DescendantAdded:Connect(function(instance) + if isBar(instance) then + tryBind(instance) + end + end) + playerGui.DescendantRemoving:Connect(function(instance) + local disconnect = bound[instance] + if disconnect then + disconnect() + end + bound[instance] = nil + renderers[instance] = nil + end) + + for _, bar in CollectionService:GetTagged(BAR_TAG) do + tryBind(bar) + end + CollectionService:GetInstanceAddedSignal(BAR_TAG):Connect(tryBind) + + -- Re-render bars live when the owner edits the Studio config (WarnAt / Max / colors). + local function rerenderAll() + byName = StatConfig.resolve().byName + for _, render in renderers do + render() + end + end + local function watch(instance: Instance) + instance.AttributeChanged:Connect(rerenderAll) + for _, child in instance:GetChildren() do + child.AttributeChanged:Connect(rerenderAll) + end + instance.ChildAdded:Connect(function(child) + child.AttributeChanged:Connect(rerenderAll) + rerenderAll() + end) + end + local configInstance = StatConfig.getConfigInstance() + if configInstance then + watch(configInstance) + end + ReplicatedStorage.ChildAdded:Connect(function(child) + if child.Name == StatConfig.CONFIG_INSTANCE_NAME then + rerenderAll() + watch(child) + end + end) + + -- Zero-setup safety net: if nothing showed up, build the minimal fallback. + task.delay(FALLBACK_WAIT, function() + if next(bound) == nil then + HudFallback.build(playerGui, StatConfig.resolve().stats) + end + end) +end + +return Hud diff --git a/src/client/HudFallback.luau b/src/client/HudFallback.luau new file mode 100644 index 0000000..da05d9f --- /dev/null +++ b/src/client/HudFallback.luau @@ -0,0 +1,95 @@ +--[[ + HudFallback — the zero-setup safety net. CLIENT-ONLY. + + If no designer-authored HUD reaches the player (no template mounted, none + bundled, none installed), the client binder calls this to build a deliberately + minimal, unstyled bar stack so the HUD ALWAYS appears. It is intentionally plain + — the authored `SurvivalHud` template is the real thing. Uses zero asset IDs. + + The bars it creates follow the same `SurvivorStatBar` tag + `Fill` child contract + as the template, so the binder drives them identically. +]] + +local CollectionService = game:GetService("CollectionService") + +local BAR_TAG = "SurvivorStatBar" +local HUD_NAME = "SurvivalHud" + +-- Plain, readable colors for the fallback only (the template carries its own art). +local FALLBACK_COLORS = { + Health = Color3.fromRGB(232, 70, 70), + Energy = Color3.fromRGB(90, 205, 120), + Hunger = Color3.fromRGB(220, 140, 40), + Thirst = Color3.fromRGB(60, 160, 230), + Fatigue = Color3.fromRGB(160, 100, 220), + Blood = Color3.fromRGB(180, 40, 40), + Poison = Color3.fromRGB(120, 180, 60), +} + +local HudFallback = {} + +function HudFallback.build(playerGui: Instance, stats: { any }) + local screen = Instance.new("ScreenGui") + screen.Name = HUD_NAME + screen.ResetOnSpawn = false + screen.IgnoreGuiInset = true + screen.ZIndexBehavior = Enum.ZIndexBehavior.Sibling + + local container = Instance.new("Frame") + container.Name = "Bars" + container.Position = UDim2.fromOffset(18, 18) + container.Size = UDim2.fromOffset(220, 0) + container.AutomaticSize = Enum.AutomaticSize.Y + container.BackgroundTransparency = 1 + container.Parent = screen + + local layout = Instance.new("UIListLayout") + layout.Padding = UDim.new(0, 6) + layout.SortOrder = Enum.SortOrder.LayoutOrder + layout.Parent = container + + -- Parent the ScreenGui BEFORE tagging bars, so the tag-added signal fires while + -- each bar is already a descendant of PlayerGui (the binder requires that). + screen.Parent = playerGui + + local order = 0 + for _, stat in stats do + if stat.display then + order += 1 + + local bar = Instance.new("Frame") + bar.Name = stat.name + bar.LayoutOrder = order + bar.Size = UDim2.new(1, 0, 0, 18) + bar.BackgroundColor3 = Color3.fromRGB(28, 32, 42) + bar.BorderSizePixel = 0 + bar:SetAttribute("Stat", stat.name) + + local fill = Instance.new("Frame") + fill.Name = "Fill" + fill.Size = UDim2.fromScale(1, 1) -- driven by the binder + fill.BackgroundColor3 = FALLBACK_COLORS[stat.name] or Color3.fromRGB(150, 150, 150) + fill.BorderSizePixel = 0 + fill.Parent = bar + + local label = Instance.new("TextLabel") + label.Name = "Label" + label.BackgroundTransparency = 1 + label.Size = UDim2.fromScale(1, 1) + label.Text = stat.name + label.TextColor3 = Color3.fromRGB(245, 245, 245) + label.TextSize = 12 + label.Font = Enum.Font.GothamMedium + label.ZIndex = 2 + label.Parent = bar + + CollectionService:AddTag(bar, BAR_TAG) + -- Parent last, fully built, so the binder sees Stat + Fill when it fires. + bar.Parent = container + end + end + + return screen +end + +return HudFallback diff --git a/src/init.luau b/src/init.luau index 0cfdfad..31ec234 100644 --- a/src/init.luau +++ b/src/init.luau @@ -3,7 +3,10 @@ local SurvivorCore = require(ReplicatedStorage.SurvivorCore) SurvivorCore.Items.register({ id = "reed", name = "Reed" }) - SurvivorCore.start() + SurvivorCore.start() -- server: boots content + the survival-stats sim + + -- on the client (the built-in HUD template's loader LocalScript does this): + SurvivorCore.startClient() -- builds + binds the reactive survival HUD Two extension layers: • Programmatic registries (Items, Recipes, Stats, Mobs, ...) — register content from code. @@ -17,6 +20,11 @@ local Hooks = require(script.foundation.Hooks) local Registries = require(script.registries) local Components = require(script.components) +-- Eagerly install the built-in survival stats: defines the "SurvivalStats" Config +-- section and registers the default stats, so Config.override("SurvivalStats", …) +-- works any time before start(). Runs on both server and client (idempotent per side). +require(script.stats.StatDefs) + local SurvivorCore = {} SurvivorCore.VERSION = "0.1.0" @@ -40,6 +48,7 @@ SurvivorCore.Mobs = Registries.Mobs SurvivorCore.Components = Components local started = false +local clientStarted = false -- Boot the engine. Call once, from the server, after registering content. function SurvivorCore.start(_options: { [string]: any }?) @@ -52,6 +61,24 @@ function SurvivorCore.start(_options: { [string]: any }?) -- TODO (extraction): boot order — Config merge → Assets → persistence → systems. Components.scan() + -- Survival-stats simulation (server-only): ticks stats as Player Attributes and + -- installs the built-in HUD for drop-in consumers that haven't supplied their own. + require(script.systems.SurvivalStats).start(_options) + + return SurvivorCore +end + +-- Boot the client layer. Call once per client — the built-in HUD template's loader +-- LocalScript does this for you. Builds + binds the reactive survival HUD. +function SurvivorCore.startClient(_options: { [string]: any }?) + if clientStarted then + warn("[SurvivorCore] startClient() called more than once — ignoring") + return SurvivorCore + end + clientStarted = true + + require(script.client.Hud).start(_options) + return SurvivorCore end diff --git a/src/stats/StatConfig.luau b/src/stats/StatConfig.luau new file mode 100644 index 0000000..de9e9da --- /dev/null +++ b/src/stats/StatConfig.luau @@ -0,0 +1,103 @@ +--[[ + StatConfig — resolves the effective survival-stat models by merging three layers + (last wins): + 1. engine defaults — the Stats registry (populated by StatDefs). + 2. developer path — Config.override("SurvivalStats", { Hunger = { ratePerSecond = … } }). + 3. owner / no-code — a `SurvivalStatsConfig` Configuration instance in + ReplicatedStorage, whose per-stat children carry Attributes + the owner edits in Studio (highest priority). + + SHARED: the server reads this for the tick loop; the client reads it for per-bar + defaults (Max / Invert / WarnAt) and the fallback HUD. +]] + +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Config = require(script.Parent.Parent.foundation.Config) +local Registries = require(script.Parent.Parent.registries) + +local StatConfig = {} + +StatConfig.CONFIG_SECTION = "SurvivalStats" +StatConfig.CONFIG_INSTANCE_NAME = "SurvivalStatsConfig" + +-- Attribute name on the Studio Configuration child -> resolved field name. +local STUDIO_ATTR_MAP = { + RatePerSecond = "ratePerSecond", + Max = "max", + Start = "start", + WarnAt = "warnAt", + Invert = "invert", + Display = "display", +} + +local function applyTable(target: { [string]: any }, override: { [string]: any }?) + if typeof(override) ~= "table" then + return + end + for key, value in override do + target[key] = value + end +end + +local function applyStudio(target: { [string]: any }, configInstance: Instance?, statName: string) + if not configInstance then + return + end + local node = configInstance:FindFirstChild(statName) + if not node then + return + end + for attr, field in STUDIO_ATTR_MAP do + local value = node:GetAttribute(attr) + if value ~= nil then + target[field] = value + end + end +end + +export type ResolvedStat = { + name: string, + attribute: string, + start: number, + max: number, + ratePerSecond: number, + invert: boolean, + warnAt: number, + display: boolean, +} + +-- Returns the merged stat models, both as an ordered array and a name lookup. +function StatConfig.resolve(): { stats: { ResolvedStat }, byName: { [string]: ResolvedStat } } + local section = Config.get(StatConfig.CONFIG_SECTION) -- nil if StatDefs never installed + local configInstance = ReplicatedStorage:FindFirstChild(StatConfig.CONFIG_INSTANCE_NAME) + + local stats = {} + local byName = {} + for _, def in Registries.Stats.getAll() do + local resolved = { + name = def.name, + attribute = def.attribute or def.name, + start = def.start, + max = def.max, + ratePerSecond = def.ratePerSecond, + invert = def.invert, + warnAt = def.warnAt, + display = def.display, + } + if section then + applyTable(resolved, section[def.name]) + end + applyStudio(resolved, configInstance, def.name) + table.insert(stats, resolved) + byName[def.name] = resolved + end + return { stats = stats, byName = byName } +end + +-- The live Studio config instance, if present (used to watch for owner edits). +function StatConfig.getConfigInstance(): Instance? + return ReplicatedStorage:FindFirstChild(StatConfig.CONFIG_INSTANCE_NAME) +end + +return StatConfig diff --git a/src/stats/StatDefs.luau b/src/stats/StatDefs.luau new file mode 100644 index 0000000..4caff0f --- /dev/null +++ b/src/stats/StatDefs.luau @@ -0,0 +1,111 @@ +--[[ + StatDefs — the survival-stat roster the engine ships with, and the one-time + install that registers it. SHARED: safe to require on server and client. + + Each stat is a single signed-rate model: `ratePerSecond` moves the raw value + toward `max` (positive) or `0` (negative). `invert` affects ONLY HUD display / + warnings — an inverted bar shows the *healthy* amount (full = good), so a stat + that rises to be bad (Hunger 0→100) renders as a bar that depletes. + + Owners retune without code via the Studio `SurvivalStatsConfig` instance; + developers may instead `Config.override("SurvivalStats", { ... })`. See StatConfig. +]] + +local Config = require(script.Parent.Parent.foundation.Config) +local Registries = require(script.Parent.Parent.registries) + +export type StatDef = { + name: string, + start: number, + max: number, + ratePerSecond: number, -- signed: + rises toward max, - falls toward 0 + invert: boolean, -- display only: true => bar full = healthy (value 0 = full) + 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`) +} + +local StatDefs = {} + +-- Reach `max` (from 0) in N minutes — readable way to express drift rates. +local function perMinutes(minutes: number): number + return 100 / (minutes * 60) +end + +-- The shipped defaults. Numbers ported from The Counter Earth; content-free. +-- Hunger/Thirst/Fatigue/Poison start at 0 and rise toward 100 = bad (invert = true, +-- so their bars deplete). Blood starts full and 0 = death. Health/Energy are +-- display-only in this build (drain/regen + consequences arrive with #5/#6). +StatDefs.DEFAULTS = { + { name = "Health", start = 100, max = 100, ratePerSecond = 0, invert = false, warnAt = 25, display = true }, + { name = "Energy", start = 100, max = 100, ratePerSecond = 0, invert = false, warnAt = 25, display = true }, + { + name = "Hunger", + start = 0, + max = 100, + ratePerSecond = perMinutes(30), + invert = true, + warnAt = 25, + display = true, + }, + { + name = "Thirst", + start = 0, + max = 100, + ratePerSecond = perMinutes(20), + invert = true, + warnAt = 25, + display = true, + }, + { + name = "Fatigue", + start = 0, + max = 100, + ratePerSecond = perMinutes(60), + invert = true, + warnAt = 25, + display = true, + }, + { name = "Poison", start = 0, max = 100, ratePerSecond = 0, invert = true, warnAt = 25, display = true }, + { name = "Blood", start = 100, max = 100, ratePerSecond = 0, invert = false, warnAt = 25, display = true }, +} + +local installed = false + +-- Register each default stat into the Stats registry and define the tunable +-- `SurvivalStats` Config section. Idempotent per Luau VM (server and client each +-- install once). Called eagerly at require time so `Config.override("SurvivalStats", …)` +-- works any time before `start()`. +function StatDefs.install() + if installed then + return + end + installed = true + + local section = {} + for _, def in StatDefs.DEFAULTS do + section[def.name] = { + start = def.start, + max = def.max, + ratePerSecond = def.ratePerSecond, + invert = def.invert, + warnAt = def.warnAt, + display = def.display, + } + Registries.Stats.register({ + name = def.name, + start = def.start, + max = def.max, + ratePerSecond = def.ratePerSecond, + invert = def.invert, + warnAt = def.warnAt, + display = def.display, + attribute = def.name, + }) + end + Config.defineSection("SurvivalStats", section) +end + +StatDefs.install() + +return StatDefs diff --git a/src/systems/SurvivalStats.luau b/src/systems/SurvivalStats.luau new file mode 100644 index 0000000..45b0c17 --- /dev/null +++ b/src/systems/SurvivalStats.luau @@ -0,0 +1,158 @@ +--[[ + SurvivalStats — the server-side survival simulation. SERVER-ONLY. + + Stores each stat as a Player Attribute (which auto-replicates to that player's + client, so the HUD needs no RemoteEvents), and ticks every stat by its signed + `ratePerSecond` toward 0 / max. Owners retune live via the Studio + `SurvivalStatsConfig` instance; this service re-resolves when it changes. + + `installHud()` deploys the bundled HUD/config templates for drop-in consumers + that haven't supplied their own. +]] + +local CollectionService = game:GetService("CollectionService") +local Players = game:GetService("Players") +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local RunService = game:GetService("RunService") +local StarterGui = game:GetService("StarterGui") + +assert(RunService:IsServer(), "SurvivorCore.SurvivalStats is server-only — require it via SurvivorCore.start()") + +local StatConfig = require(script.Parent.Parent.stats.StatConfig) + +local HUD_NAME = "SurvivalHud" +local LOADER_NAME = "HudLoader" +local BAR_TAG = "SurvivorStatBar" +local TICK_INTERVAL = 0.1 -- 10 Hz: smooth bars without flooding attribute replication + +local SurvivalStats = {} + +local started = false +local resolved: any = nil -- cached StatConfig.resolve() result + +local function refresh() + resolved = StatConfig.resolve() +end + +local function initPlayer(player: Player) + if not resolved then + return + end + for _, stat in resolved.stats do + if player:GetAttribute(stat.attribute) == nil then + player:SetAttribute(stat.attribute, stat.start) + end + end +end + +-- Re-resolve the moment the owner edits the Studio config instance, so no-code +-- rate tweaks take effect without a restart. +local function watchConfig() + local function bindInstance(instance: Instance) + instance.AttributeChanged:Connect(refresh) + for _, child in instance:GetChildren() do + child.AttributeChanged:Connect(refresh) + end + instance.ChildAdded:Connect(function(child) + child.AttributeChanged:Connect(refresh) + refresh() + end) + end + + local existing = StatConfig.getConfigInstance() + if existing then + bindInstance(existing) + end + ReplicatedStorage.ChildAdded:Connect(function(child) + if child.Name == StatConfig.CONFIG_INSTANCE_NAME then + refresh() + bindInstance(child) + end + end) +end + +local function hudPresentInStarterGui(): boolean + if StarterGui:FindFirstChild(HUD_NAME) then + return true + end + for _, instance in CollectionService:GetTagged(BAR_TAG) do + if instance:IsDescendantOf(StarterGui) then + return true + end + end + return false +end + +-- Deploy the bundled templates if the consumer hasn't supplied their own. No-op for +-- source/demo consumers (who mount the templates directly) and when an owner HUD +-- already exists. Cloned into StarterGui so it reaches players via the native +-- StarterGui -> PlayerGui copy on spawn. +function SurvivalStats.installHud() + local root = script.Parent.Parent -- the SurvivorCore root model + local templates = root:FindFirstChild("Templates") + if not templates then + return + end + + local configTemplate = templates:FindFirstChild(StatConfig.CONFIG_INSTANCE_NAME) + if configTemplate and not ReplicatedStorage:FindFirstChild(StatConfig.CONFIG_INSTANCE_NAME) then + configTemplate:Clone().Parent = ReplicatedStorage + end + + local hudTemplate = templates:FindFirstChild(HUD_NAME) + if hudTemplate and not hudPresentInStarterGui() then + hudTemplate:Clone().Parent = StarterGui + end + + -- The client boot loader rides StarterPlayerScripts (copied to each joiner). + local loaderTemplate = templates:FindFirstChild(LOADER_NAME) + if loaderTemplate then + local starterScripts = game:GetService("StarterPlayer"):FindFirstChild("StarterPlayerScripts") + if starterScripts and not starterScripts:FindFirstChild(LOADER_NAME) then + loaderTemplate:Clone().Parent = starterScripts + end + end +end + +function SurvivalStats.start(_options: { [string]: any }?) + assert(not started, "SurvivalStats.start() called twice") + started = true + + SurvivalStats.installHud() + refresh() + watchConfig() + + for _, player in Players:GetPlayers() do + initPlayer(player) + end + Players.PlayerAdded:Connect(initPlayer) + + local accumulator = 0 + RunService.Heartbeat:Connect(function(dt) + if not resolved then + return + end + accumulator += dt + if accumulator < TICK_INTERVAL then + return + end + local step = accumulator + accumulator = 0 + + for _, player in Players:GetPlayers() do + for _, stat in resolved.stats do + if stat.ratePerSecond ~= 0 then + local current = player:GetAttribute(stat.attribute) + if typeof(current) == "number" then + local nextValue = math.clamp(current + stat.ratePerSecond * step, 0, stat.max) + if nextValue ~= current then + player:SetAttribute(stat.attribute, nextValue) + end + end + end + end + end + end) +end + +return SurvivalStats