mirror of
https://github.com/TemujinCalidius/SurvivorCore.git
synced 2026-08-14 00:58:01 +00:00
feat(movement): sprint/jump/energy + low-stat feedback (ported from TheCounterEarth)
Sprinting did nothing because Energy shipped display-only. This ports TCE's
proven movement system into the engine, server-authoritative:
- Hold Shift → sprint: drains Energy while moving, raises WalkSpeed to SprintSpeed,
forces ExhaustedSpeed at 0 energy; Energy regenerates after an idle delay.
- Jumps cost energy and are gated below MinToJump (JumpPower → 0), with the same
0.15s multi-signal throttle TCE uses.
- Energy is written through the stat-effects layer (Stats.adjust), so it stays a
replicated Player Attribute the HUD already shows — no extra remotes for state.
Adds the engine's FIRST RemoteEvent plumbing (shared/Remotes.luau → SprintIntent,
created server-side, awaited client-side) and a tunable "Movement" Config section
(shared/MovementConfig.luau) carrying TCE's exact numbers + the free vignette/
breathing/heartbeat asset IDs.
Client (MovementFeedback, booted by startClient): Shift input → SprintIntent, plus
the low-stat feedback — a screen vignette + breathing loop that intensify as Energy
drops and a heartbeat loop below 40% health, all smoothed per frame.
New files: src/shared/{Remotes,MovementConfig}.luau, src/systems/Movement.luau,
src/client/MovementFeedback.luau. Wired into start()/startClient().
Gate green (stylua/selene/luau-lsp/build x3). Needs an in-Play test after a Studio
restart (Play-Solo stale-bytecode cache). Thirst/fatigue/campfire regen gating is a
follow-on with the full consequence-system port (StatsConfig is the blueprint).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
bbcbdff5fd
commit
b72350757f
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user