mirror of
https://github.com/TemujinCalidius/SurvivorCore.git
synced 2026-08-14 00:58:01 +00:00
Add survival-stats engine + designer-editable HUD
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
f407056588
commit
649ff0c192
+3
-2
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-5
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
+28
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user