diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c5edfb..2282f4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ is promoted to the new version and `main` is tagged `vX.Y.Z`. ## Unreleased ### Added +- **Sprinting, jumping & energy** — hold **Shift** to sprint (server-authoritative): it drains + the Energy stat, speeds you up, and forces an exhausted crawl at 0 energy; energy regenerates + after a short idle delay. Jumps cost energy and are blocked below a threshold. Ships a low-stat + **feedback** layer too — a screen vignette + breathing loop that intensify as energy drops, and + a heartbeat loop below 40% health. Adds the engine's first RemoteEvent (`SprintIntent`) and a + `Movement` Config section (`Config.override("Movement", …)`). Logic + tuning + free default art + ported from The Counter Earth. See [docs/survival-stats.md](docs/survival-stats.md). - **Dynamic stat effects** — a per-player, server-side modifier layer over the base rates, so stats can be driven by events instead of only a constant drift. `SurvivorCore.Stats.adjust` (one-time clamped delta), `addModifier` / `removeModifier` (named, optionally-timed rate diff --git a/docs/survival-stats.md b/docs/survival-stats.md index 37c7578..52031e8 100644 --- a/docs/survival-stats.md +++ b/docs/survival-stats.md @@ -113,6 +113,31 @@ A modifier with the same `source` replaces the old one (no implicit stacking); a makes it expire on its own; a player's modifiers are dropped when they leave. These are **server-only** (authoritative) — drive them from your gameplay code, never trust the client. +## Sprint, jump & energy + +The engine ships a server-authoritative movement system that spends the **Energy** stat (ported +from The Counter Earth's proven tuning): + +- **Hold Shift** to sprint — the client sends intent over the `SprintIntent` RemoteEvent; the + server speeds the character up (`SprintSpeed`) and drains energy while you're actually moving. +- At **0 energy** you're forced to a slow `ExhaustedSpeed` until it recovers. +- **Energy regenerates** after `RegenDelaySeconds` of not exerting (and not moving). +- **Jumps cost energy** (`JumpCost`) and are **blocked below `MinToJump`** (JumpPower drops to 0). + +It's all tunable via the **`Movement`** Config section — no engine edits: + +```lua +SurvivorCore.Config.override("Movement", { + Energy = { SprintDrainPerSecond = 20, RegenPerSecond = 10, JumpCost = 8 }, + Movement = { SprintSpeed = 28, WalkSpeed = 16, ExhaustedSpeed = 6, JumpPower = 80 }, +}) +``` + +**Low-stat feedback (client).** A full-screen **vignette** + a **breathing** loop fade in as energy +drops, and a **heartbeat** loop fades in below `Health.HeartbeatStartRatio` (40%) of health. The +engine ships default art for these (overridable under `Movement.Assets` / the audio + ratio knobs +in the same section). It boots automatically from `SurvivorCore.startClient()`. + ## The HUD — restyle it freely The HUD is the `SurvivalHud` ScreenGui in **StarterGui**. Restyle anything — colors, gradients, diff --git a/src/client/MovementFeedback.luau b/src/client/MovementFeedback.luau new file mode 100644 index 0000000..4c07bf8 --- /dev/null +++ b/src/client/MovementFeedback.luau @@ -0,0 +1,165 @@ +--!nonstrict +--[[ + MovementFeedback — client sprint input + low-stat feedback. CLIENT-ONLY. + + Ported from The Counter Earth's HudController. Two jobs: + • SPRINT INPUT — hold Left/Right Shift to sprint; fires the `SprintIntent` RemoteEvent + (the server is authoritative for speed + energy). Releases on key-up / focus loss. + • LOW-STAT FEEDBACK — a full-screen vignette + breathing loop that intensify as Energy + drops, and a heartbeat loop that fades in below HeartbeatStartRatio of health. All + driven by reading the replicated Energy attribute + the character's Humanoid health, + smoothed each frame. + + Booted by SurvivorCore.startClient(). Tuning + asset ids come from MovementConfig. +]] + +local Players = game:GetService("Players") +local RunService = game:GetService("RunService") +local SoundService = game:GetService("SoundService") +local UserInputService = game:GetService("UserInputService") + +assert(RunService:IsClient(), "SurvivorCore.MovementFeedback is client-only — boot it via SurvivorCore.startClient()") + +local MovementConfig = require(script.Parent.Parent.shared.MovementConfig) +local Remotes = require(script.Parent.Parent.shared.Remotes) + +local MovementFeedback = {} + +local started = false + +-- Frame-rate-independent approach toward a target (the TCE `smooth` helper). +local function smooth(current: number, target: number, speed: number, dt: number): number + return current + (target - current) * math.clamp(dt * speed, 0, 1) +end + +local function makeLoopedSound(name: string, soundId: string): Sound + local sound = Instance.new("Sound") + sound.Name = name + sound.SoundId = soundId + sound.Looped = true + sound.Volume = 0 + sound.PlaybackSpeed = 1 + sound.Parent = SoundService + if soundId ~= "" then + sound:Play() + end + return sound +end + +function MovementFeedback.start(_options: { [string]: any }?) + if started then + return + end + started = true + + local player = Players.LocalPlayer + local cfg = MovementConfig.get() + + -- --- Sprint input → server ------------------------------------------------- + local sprintRemote = Remotes.event("SprintIntent") + local sprintHeld = false + local function setSprint(value: boolean) + if sprintHeld == value then + return + end + sprintHeld = value + sprintRemote:FireServer(value) + end + + UserInputService.InputBegan:Connect(function(input, gameProcessed) + if gameProcessed then + return + end + if input.KeyCode == Enum.KeyCode.LeftShift or input.KeyCode == Enum.KeyCode.RightShift then + setSprint(true) + end + end) + UserInputService.InputEnded:Connect(function(input) + if input.KeyCode == Enum.KeyCode.LeftShift or input.KeyCode == Enum.KeyCode.RightShift then + setSprint(false) + end + end) + UserInputService.WindowFocusReleased:Connect(function() + setSprint(false) + end) + + -- --- Vignette overlay ------------------------------------------------------ + local playerGui = player:WaitForChild("PlayerGui") + local vignetteGui = Instance.new("ScreenGui") + vignetteGui.Name = "SurvivorCoreVignette" + vignetteGui.ResetOnSpawn = false + vignetteGui.IgnoreGuiInset = true + vignetteGui.ZIndexBehavior = Enum.ZIndexBehavior.Sibling + vignetteGui.DisplayOrder = 1 + vignetteGui.Parent = playerGui + + local vignette = Instance.new("ImageLabel") + vignette.Name = "EnergyVignette" + vignette.BackgroundTransparency = 1 + vignette.Size = UDim2.fromScale(1, 1) + vignette.Image = cfg.Assets.Vignette + vignette.ImageColor3 = Color3.fromRGB(255, 255, 255) + vignette.ImageTransparency = 1 + vignette.ScaleType = Enum.ScaleType.Stretch + vignette.Visible = cfg.Assets.Vignette ~= "" + vignette.Parent = vignetteGui + + -- --- Breathing + heartbeat loops ------------------------------------------- + local breathing = makeLoopedSound("SurvivorCoreBreathing", cfg.Assets.Breathing) + local heartbeat = makeLoopedSound("SurvivorCoreHeartbeat", cfg.Assets.Heartbeat) + + RunService.RenderStepped:Connect(function(dt) + local energy = player:GetAttribute("Energy") + if typeof(energy) ~= "number" then + energy = 0 + end + local maxEnergy = cfg.Energy.Max + if typeof(maxEnergy) ~= "number" or maxEnergy <= 0 then + maxEnergy = 100 + end + local energyRatio = math.clamp(energy / maxEnergy, 0, 1) + + -- Health from the live character Humanoid (the real health bar). + local healthRatio, alive = 1, false + local character = player.Character + local humanoid = character and character:FindFirstChildOfClass("Humanoid") + if humanoid and humanoid.MaxHealth > 0 then + healthRatio = math.clamp(humanoid.Health / humanoid.MaxHealth, 0, 1) + alive = humanoid.Health > 0 + end + + -- Vignette: intensifies as energy drops. + if vignette.Visible then + local intensity = math.clamp(1 - energyRatio, 0, 1) ^ 1.2 + vignette.ImageTransparency = smooth(vignette.ImageTransparency, 1 - (intensity * 0.9), 5, dt) + end + + -- Breathing: tracks low energy. + local breathingIntensity = math.clamp(1 - energyRatio, 0, 1) + breathing.Volume = smooth(breathing.Volume, breathingIntensity * cfg.Audio.BreathingMaxVolume, 5, dt) + breathing.PlaybackSpeed = smooth( + breathing.PlaybackSpeed, + cfg.Audio.BreathingMinSpeed + + (cfg.Audio.BreathingMaxSpeed - cfg.Audio.BreathingMinSpeed) * breathingIntensity, + 5, + dt + ) + + -- Heartbeat: fades in below HeartbeatStartRatio of health. + local heartbeatStart = math.clamp(cfg.Health.HeartbeatStartRatio, 0.05, 1) + local heartbeatIntensity = 0 + if alive and healthRatio < heartbeatStart then + heartbeatIntensity = math.clamp((heartbeatStart - healthRatio) / heartbeatStart, 0, 1) + end + heartbeat.Volume = smooth(heartbeat.Volume, heartbeatIntensity * cfg.Audio.HeartbeatMaxVolume, 7, dt) + heartbeat.PlaybackSpeed = smooth( + heartbeat.PlaybackSpeed, + cfg.Audio.HeartbeatMinSpeed + + (cfg.Audio.HeartbeatMaxSpeed - cfg.Audio.HeartbeatMinSpeed) * heartbeatIntensity, + 7, + dt + ) + end) +end + +return MovementFeedback diff --git a/src/init.luau b/src/init.luau index dce14d9..428b490 100644 --- a/src/init.luau +++ b/src/init.luau @@ -25,6 +25,10 @@ local Components = require(script.components) -- works any time before start(). Runs on both server and client (idempotent per side). require(script.stats.StatDefs) +-- Define the "Movement" Config section (sprint/jump/energy + feedback tuning) on both +-- sides, so Config.override("Movement", …) works any time before start()/startClient(). +require(script.shared.MovementConfig) + local SurvivorCore = {} SurvivorCore.VERSION = "0.1.0" @@ -77,6 +81,9 @@ function SurvivorCore.start(_options: { [string]: any }?) SurvivorCore.Stats.removeModifier = survival.removeModifier SurvivorCore.Stats.getValue = survival.getValue + -- Movement: server-authoritative sprint/jump/energy (creates the SprintIntent RemoteEvent). + require(script.systems.Movement).start(_options) + return SurvivorCore end @@ -91,6 +98,9 @@ function SurvivorCore.startClient(_options: { [string]: any }?) require(script.client.Hud).start(_options) + -- Sprint input + low-stat vignette/breathing/heartbeat feedback. + require(script.client.MovementFeedback).start(_options) + return SurvivorCore end diff --git a/src/shared/MovementConfig.luau b/src/shared/MovementConfig.luau new file mode 100644 index 0000000..a8c6cd8 --- /dev/null +++ b/src/shared/MovementConfig.luau @@ -0,0 +1,59 @@ +--!nonstrict +--[[ + MovementConfig — tuning for the sprint / jump / energy system and its low-stat feedback. + SHARED (server reads movement/energy; client reads audio/feedback). Defines the "Movement" + Config section so games retune via `Config.override("Movement", { Energy = { ... } })`. + + Numbers + asset ids ported from The Counter Earth (its GameplayConfig + AssetIds) — proven, + tuned values. Read the merged section with MovementConfig.get(). +]] + +local Config = require(script.Parent.Parent.foundation.Config) + +local MovementConfig = {} + +MovementConfig.SECTION = "Movement" + +MovementConfig.DEFAULTS = { + Energy = { + Max = 100, -- matches the Energy stat's max in StatDefs + SprintDrainPerSecond = 16, + JumpCost = 10, + RegenPerSecond = 14, + RegenDelaySeconds = 0.75, -- regen waits this long after the last sprint/jump + MinToJump = 10, -- can't jump below this much energy + }, + Movement = { + WalkSpeed = 16, + SprintSpeed = 24, + ExhaustedSpeed = 6, -- forced speed at 0 energy + JumpPower = 80, + }, + Health = { + HeartbeatStartRatio = 0.4, -- heartbeat audio fades in below this fraction of health + }, + Audio = { + BreathingMaxVolume = 0.9, + BreathingMinSpeed = 0.9, + BreathingMaxSpeed = 1.5, + HeartbeatMaxVolume = 1, + HeartbeatMinSpeed = 0.9, + HeartbeatMaxSpeed = 1.8, + }, + -- Free default art (the engine ships these; override per game). "" disables a piece. + Assets = { + Vignette = "rbxassetid://117281807378470", + Breathing = "rbxassetid://109256307915148", + Heartbeat = "rbxassetid://90073835550134", + }, +} + +-- Define the section once per Luau VM (server and client each require this module once). +Config.defineSection(MovementConfig.SECTION, MovementConfig.DEFAULTS) + +-- The merged (defaults + overrides) Movement config table. +function MovementConfig.get(): any + return Config.get(MovementConfig.SECTION) or MovementConfig.DEFAULTS +end + +return MovementConfig diff --git a/src/shared/Remotes.luau b/src/shared/Remotes.luau new file mode 100644 index 0000000..5798826 --- /dev/null +++ b/src/shared/Remotes.luau @@ -0,0 +1,60 @@ +--!nonstrict +--[[ + Remotes — the engine's RemoteEvent plumbing. SHARED (server + client). + + SurvivorCore is attribute-driven and has needed no RemoteEvents until now; the first + is sprint intent (client → server). This keeps that contract in one place: the SERVER + creates each named RemoteEvent under a `SurvivorCoreRemotes` folder in ReplicatedStorage; + the CLIENT waits for it. Works for every distribution (Rojo source, demo, drop-in `.rbxm`) + because the folder + events are created at runtime, not baked into the model. + + -- server: local remote = Remotes.event("SprintIntent"); remote.OnServerEvent:Connect(...) + -- client: local remote = Remotes.event("SprintIntent"); remote:FireServer(true) +]] + +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local RunService = game:GetService("RunService") + +local Remotes = {} + +local FOLDER_NAME = "SurvivorCoreRemotes" +local WAIT_TIMEOUT = 10 + +local function getFolder(): Instance + local existing = ReplicatedStorage:FindFirstChild(FOLDER_NAME) + if existing then + return existing + end + if RunService:IsServer() then + local folder = Instance.new("Folder") + folder.Name = FOLDER_NAME + folder.Parent = ReplicatedStorage + return folder + end + local folder = ReplicatedStorage:WaitForChild(FOLDER_NAME, WAIT_TIMEOUT) + assert( + folder, + `[SurvivorCore] Remotes folder '{FOLDER_NAME}' not found — is the server running SurvivorCore.start()?` + ) + return folder +end + +-- Get (server: create-if-absent) a named RemoteEvent. Idempotent. +function Remotes.event(name: string): RemoteEvent + local folder = getFolder() + local existing = folder:FindFirstChild(name) + if existing and existing:IsA("RemoteEvent") then + return existing + end + if RunService:IsServer() then + local event = Instance.new("RemoteEvent") + event.Name = name + event.Parent = folder + return event + end + local event = folder:WaitForChild(name, WAIT_TIMEOUT) + assert(event and event:IsA("RemoteEvent"), `[SurvivorCore] RemoteEvent '{name}' not found`) + return event +end + +return Remotes diff --git a/src/systems/Movement.luau b/src/systems/Movement.luau new file mode 100644 index 0000000..1a1ec7d --- /dev/null +++ b/src/systems/Movement.luau @@ -0,0 +1,251 @@ +--!nonstrict +--[[ + Movement — server-side sprinting, jumping and energy. SERVER-ONLY. + + Ported from The Counter Earth's PlayerStateService. The client sends sprint intent over the + `SprintIntent` RemoteEvent; the server is authoritative: + • While sprinting AND moving AND energy > 0 → drain energy, set WalkSpeed = SprintSpeed. + • At 0 energy → forced to ExhaustedSpeed (can't sprint until it recovers). + • Idle for RegenDelaySeconds after the last exertion → energy regenerates. + • Jumps cost energy and are gated below MinToJump (JumpPower drops to 0). + Energy is the engine's "Energy" stat (a Player Attribute) written through the stat-effects + layer (Stats.adjust), so it replicates to the HUD with no extra RemoteEvents. + + Tuning lives in MovementConfig ("Movement" Config section). Started by SurvivorCore.start(). +]] + +local Players = game:GetService("Players") +local RunService = game:GetService("RunService") + +assert(RunService:IsServer(), "SurvivorCore.Movement is server-only — require it via SurvivorCore.start()") + +local MovementConfig = require(script.Parent.Parent.shared.MovementConfig) +local Remotes = require(script.Parent.Parent.shared.Remotes) +local SurvivalStats = require(script.Parent.SurvivalStats) + +local Movement = {} + +local TICK_INTERVAL = 0.1 -- 10 Hz: energy + walk-speed updates (jumps are event-driven) +local JUMP_DRAIN_THROTTLE = 0.15 -- seconds; jumps fire several Humanoid signals at once +local MOVING_MAGNITUDE = 0.05 + +local started = false + +type State = { + humanoid: Humanoid?, + sprintRequested: boolean, + lastEnergyUseTime: number, + lastJumpDrainTime: number, + currentWalkSpeed: number, + jumpEnabled: boolean, + connections: { RBXScriptConnection }, +} + +local states: { [Player]: State } = {} + +local function cfg(): any + return MovementConfig.get() +end + +local function energyOf(player: Player): number + return SurvivalStats.getValue(player, "Energy") or 0 +end + +local function setWalkSpeed(state: State, speed: number) + local humanoid = state.humanoid + if not humanoid then + return + end + if state.currentWalkSpeed ~= speed or humanoid.WalkSpeed ~= speed then + state.currentWalkSpeed = speed + humanoid.WalkSpeed = speed + end +end + +local function setJumpEnabled(state: State, enabled: boolean, jumpPower: number) + local humanoid = state.humanoid + if not humanoid then + return + end + local desired = if enabled then jumpPower else 0 + if state.jumpEnabled ~= enabled or humanoid.JumpPower ~= desired then + state.jumpEnabled = enabled + humanoid:SetStateEnabled(Enum.HumanoidStateType.Jumping, enabled) + humanoid.UseJumpPower = true + humanoid.JumpPower = desired + if not enabled and humanoid.Jump then + humanoid.Jump = false + end + end +end + +-- A jump fires Humanoid.Jump + Jumping + StateChanged at once, so throttle the energy cost. +local function drainJumpEnergy(player: Player, state: State) + local c = cfg() + local now = os.clock() + if now - state.lastJumpDrainTime < JUMP_DRAIN_THROTTLE then + return + end + state.lastJumpDrainTime = now + if energyOf(player) < c.Energy.MinToJump then + setJumpEnabled(state, false, c.Movement.JumpPower) + local humanoid = state.humanoid + if humanoid and humanoid.Jump then + humanoid.Jump = false + end + return + end + SurvivalStats.adjust(player, "Energy", -c.Energy.JumpCost) + state.lastEnergyUseTime = now +end + +local function setupCharacter(player: Player, character: Model) + local state = states[player] + if not state then + return + end + local humanoid = character:FindFirstChildOfClass("Humanoid") or character:WaitForChild("Humanoid", 10) + if not humanoid or not humanoid:IsA("Humanoid") then + return + end + + for _, conn in state.connections do + conn:Disconnect() + end + table.clear(state.connections) + + local c = cfg() + state.humanoid = humanoid + state.currentWalkSpeed = c.Movement.WalkSpeed + state.jumpEnabled = true + state.lastEnergyUseTime = os.clock() + state.lastJumpDrainTime = 0 + + humanoid.UseJumpPower = true + humanoid.WalkSpeed = c.Movement.WalkSpeed + humanoid.JumpPower = c.Movement.JumpPower + humanoid:SetStateEnabled(Enum.HumanoidStateType.Jumping, true) + + table.insert( + state.connections, + humanoid:GetPropertyChangedSignal("Jump"):Connect(function() + if humanoid.Jump then + drainJumpEnergy(player, state) + end + end) + ) + table.insert( + state.connections, + humanoid.Jumping:Connect(function(isJumping) + if isJumping then + drainJumpEnergy(player, state) + end + end) + ) + table.insert( + state.connections, + humanoid.StateChanged:Connect(function(_, newState) + if newState == Enum.HumanoidStateType.Jumping then + drainJumpEnergy(player, state) + end + end) + ) +end + +local function addPlayer(player: Player) + states[player] = { + humanoid = nil, + sprintRequested = false, + lastEnergyUseTime = os.clock(), + lastJumpDrainTime = 0, + currentWalkSpeed = cfg().Movement.WalkSpeed, + jumpEnabled = true, + connections = {}, + } + player.CharacterAdded:Connect(function(character) + setupCharacter(player, character) + end) + if player.Character then + setupCharacter(player, player.Character) + end +end + +local function removePlayer(player: Player) + local state = states[player] + if state then + for _, conn in state.connections do + conn:Disconnect() + end + end + states[player] = nil +end + +function Movement.start(_options: { [string]: any }?) + assert(not started, "Movement.start() called twice") + started = true + + local sprintRemote = Remotes.event("SprintIntent") + sprintRemote.OnServerEvent:Connect(function(player, wantsToSprint) + if typeof(wantsToSprint) ~= "boolean" then + return -- ignore malformed client input + end + local state = states[player] + if state then + state.sprintRequested = wantsToSprint + end + end) + + for _, player in Players:GetPlayers() do + addPlayer(player) + end + Players.PlayerAdded:Connect(addPlayer) + Players.PlayerRemoving:Connect(removePlayer) + + local accumulator = 0 + RunService.Heartbeat:Connect(function(dt) + accumulator += dt + if accumulator < TICK_INTERVAL then + return + end + local step = accumulator + accumulator = 0 + local c = cfg() + + for player, state in states do + local humanoid = state.humanoid + if not humanoid or humanoid.Parent == nil or humanoid.Health <= 0 then + continue + end + + local moving = humanoid.MoveDirection.Magnitude > MOVING_MAGNITUDE + local swimming = humanoid:GetState() == Enum.HumanoidStateType.Swimming + local energy = energyOf(player) + local shouldSprint = state.sprintRequested and moving and energy > 0 + + if shouldSprint then + SurvivalStats.adjust(player, "Energy", -c.Energy.SprintDrainPerSecond * step) + state.lastEnergyUseTime = os.clock() + elseif + not moving + and not swimming + and os.clock() - state.lastEnergyUseTime >= c.Energy.RegenDelaySeconds + then + SurvivalStats.adjust(player, "Energy", c.Energy.RegenPerSecond * step) + end + + -- Jump gate: no jump below MinToJump (drop JumpPower to 0). + setJumpEnabled(state, energy >= c.Energy.MinToJump, c.Movement.JumpPower) + + -- Movement speed reflects energy state. + if energy <= 0 then + setWalkSpeed(state, c.Movement.ExhaustedSpeed) + elseif shouldSprint then + setWalkSpeed(state, c.Movement.SprintSpeed) + else + setWalkSpeed(state, c.Movement.WalkSpeed) + end + end + end) +end + +return Movement