@rbxts/gravity-controller 1.0.6 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/GravityController.rbxmx +3 -1
- package/README.md +324 -28
- package/out/index.d.ts +24 -0
- package/out/init.lua +225 -0
- package/package.json +1 -1
package/GravityController.rbxmx
CHANGED
|
@@ -1744,7 +1744,7 @@ local defaultUpdateMouseBehavior = BaseCamera.UpdateMouseBehavior
|
|
|
1744
1744
|
|
|
1745
1745
|
function BaseCamera:UpdateMouseBehavior()
|
|
1746
1746
|
defaultUpdateMouseBehavior(self)
|
|
1747
|
-
if UserGameSettings.RotationType == Enum.RotationType.CameraRelative then
|
|
1747
|
+
if _G._gravityControllerActive and UserGameSettings.RotationType == Enum.RotationType.CameraRelative then
|
|
1748
1748
|
UserGameSettings.RotationType = Enum.RotationType.MovementRelative
|
|
1749
1749
|
end
|
|
1750
1750
|
end
|
|
@@ -2662,6 +2662,7 @@ function init(self)
|
|
|
2662
2662
|
self._characterMass = getModelMass(self.Character)
|
|
2663
2663
|
end))
|
|
2664
2664
|
|
|
2665
|
+
_G._gravityControllerActive = true
|
|
2665
2666
|
self.Humanoid.PlatformStand = true
|
|
2666
2667
|
self.Maid:Mark(self.Humanoid:GetPropertyChangedSignal("Jump"):Connect(function()
|
|
2667
2668
|
if self.Humanoid.Jump then
|
|
@@ -2719,6 +2720,7 @@ function GravityControllerClass:Destroy()
|
|
|
2719
2720
|
RunService:UnbindFromRenderStep("GravityStep")
|
|
2720
2721
|
self.Maid:Sweep()
|
|
2721
2722
|
self.Humanoid.PlatformStand = false
|
|
2723
|
+
_G._gravityControllerActive = false
|
|
2722
2724
|
end
|
|
2723
2725
|
|
|
2724
2726
|
--
|
package/README.md
CHANGED
|
@@ -1,11 +1,273 @@
|
|
|
1
1
|
# @rbxts/gravity-controller
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
ground
|
|
3
|
+
TypeScript bindings for [EgoMoose's Rbx-Gravity-Controller](https://github.com/EgoMoose/Rbx-Gravity-Controller) with
|
|
4
|
+
ground-normal-based wall walking by [EmilyBendsSpace](https://x.com/EmilyBendsSpace).
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
Players can walk on walls, ceilings, and any arbitrary surface with smooth gravity transitions.
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install @rbxts/gravity-controller
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The package ships a `GravityController.rbxmx` model that contains the Lua runtime (camera, collider, state tracker, animations, and character sounds). The TypeScript wrapper in `src/index.ts` handles deploying those scripts at runtime and provides typed access to the controller.
|
|
15
|
+
|
|
16
|
+
## How it works
|
|
17
|
+
|
|
18
|
+
### Architecture
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
Server (onInit) Client (onStart)
|
|
22
|
+
───────────────── ──────────────────
|
|
23
|
+
installGravityControllerClass() installGravityControllerClass()
|
|
24
|
+
├─ Copies Client/PlayerScriptsLoader └─ require("GravityController")
|
|
25
|
+
│ → StarterPlayerScripts from ReplicatedStorage
|
|
26
|
+
├─ Copies Client/RbxCharacterSounds ↓
|
|
27
|
+
│ → StarterPlayerScripts new GravityControllerClass(player)
|
|
28
|
+
├─ Copies Client/Animate ├─ Camera (custom camera module)
|
|
29
|
+
│ → StarterCharacterScripts ├─ Control (input → move vector)
|
|
30
|
+
└─ Moves GravityController module ├─ Collider (physics body movers)
|
|
31
|
+
→ ReplicatedStorage └─ StateTracker (humanoid states)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`installGravityControllerClass()` must be called on **both** the server and the client. On the server it deploys the bundled scripts into `StarterPlayerScripts`, `StarterCharacterScripts`, and `ReplicatedStorage`. On the client it `require`s the `GravityController` ModuleScript from `ReplicatedStorage` and returns the class.
|
|
35
|
+
|
|
36
|
+
### Permanent runtime modifications
|
|
37
|
+
|
|
38
|
+
The server-side `installGravityControllerClass()` **replaces** scripts in StarterPlayerScripts / StarterCharacterScripts:
|
|
39
|
+
|
|
40
|
+
- **PlayerScriptsLoader** — modified version that monkey-patches `BaseCamera`, `CameraUtils`, `Poppercam`, and the `CameraModule.Update` loop to support arbitrary gravity up-vectors
|
|
41
|
+
- **Animate** — custom version that works with `PlatformStand = true`
|
|
42
|
+
- **RbxCharacterSounds** — custom version driven by the gravity controller's `StateTracker` rather than native `Humanoid` state
|
|
43
|
+
|
|
44
|
+
These replacements are **permanent for the session** — they survive `GravityController:Destroy()`.
|
|
45
|
+
|
|
46
|
+
### Key monkey-patch: `BaseCamera:UpdateMouseBehavior()`
|
|
47
|
+
|
|
48
|
+
The modified PlayerScriptsLoader overrides `BaseCamera:UpdateMouseBehavior()` to force `UserGameSettings.RotationType` from `CameraRelative` to `MovementRelative`. This is necessary while the gravity controller is active because `CameraRelative` rotation conflicts with the custom `BodyGyro`-driven character orientation.
|
|
49
|
+
|
|
50
|
+
This override is guarded by `_G._gravityControllerActive` so it only applies while a gravity controller instance is alive. Without this guard, any first-person camera system that depends on `CameraRelative` (such as Character-Realism's `FpsCamera`) will break permanently after gravity controller destruction.
|
|
51
|
+
|
|
52
|
+
### What a GravityController instance does (per-player, temporary)
|
|
53
|
+
|
|
54
|
+
- Sets `Humanoid.PlatformStand = true` (disables the default humanoid physics)
|
|
55
|
+
- Creates collision proxy parts (Sphere, FloorDetector, JumpDetector) welded to HRP
|
|
56
|
+
- Adds `VectorForce`, `BodyGyro`, `BodyPosition` to HRP
|
|
57
|
+
- Binds `"GravityStep"` RenderStep at priority `Camera - 1` (199)
|
|
58
|
+
- Sets `_G._gravityControllerActive = true`
|
|
59
|
+
|
|
60
|
+
### What `GravityController:Destroy()` does
|
|
61
|
+
|
|
62
|
+
- Unbinds `"GravityStep"` from RenderStep
|
|
63
|
+
- `Maid:Sweep()` — destroys all proxy parts, body movers, disconnects events
|
|
64
|
+
- Sets `Humanoid.PlatformStand = false`
|
|
65
|
+
- Sets `_G._gravityControllerActive = false`
|
|
66
|
+
|
|
67
|
+
It does **not** restore the Animate script or the PlayerScriptsLoader — those persist.
|
|
68
|
+
|
|
69
|
+
### Gravity step (per frame)
|
|
70
|
+
|
|
71
|
+
Each render frame the controller runs `onGravityStep`:
|
|
72
|
+
|
|
73
|
+
1. **Query gravity direction** — calls `GetGravityUp(oldGravity)` which you can override. By default it returns the previous gravity (no change). Assign `getGravityControllerUp` to enable surface-following wall walk.
|
|
74
|
+
2. **Lerp transition** — spherically interpolates from the old gravity direction toward the new one, controlled by `Transition` (default `0.15`).
|
|
75
|
+
3. **Compute world move vector** — projects the camera-relative input onto the plane perpendicular to gravity so the character always moves along the surface.
|
|
76
|
+
4. **Compute forces** — calculates a counter-gravity force (`gForce`) and a walk force (`walkForce`) that accelerates the character toward target velocity.
|
|
77
|
+
5. **Optional horizontal lock** — when `UseBodyPositionLock` is enabled and the character is standing still on an aligned surface, a `BodyPosition` prevents micro-sliding.
|
|
78
|
+
6. **Update collider and state** — applies the combined force to the character's body movers and updates the state tracker (running, jumping, freefall, etc.).
|
|
79
|
+
|
|
80
|
+
### Interaction with first-person camera systems
|
|
81
|
+
|
|
82
|
+
If you use a first-person camera module (e.g. Character-Realism's `FpsCamera`) that depends on `UserGameSettings.RotationType` being `CameraRelative`:
|
|
83
|
+
|
|
84
|
+
- While the gravity controller is **active**, `onGravityStep` handles character rotation via `BodyGyro`. The `_G._gravityControllerActive` flag ensures the monkey-patch forces `MovementRelative` only during this time.
|
|
85
|
+
- After `Destroy()`, the flag clears and the monkey-patch becomes a no-op, allowing your first-person system to set `AutoRotate = false` and take over character rotation normally.
|
|
86
|
+
|
|
87
|
+
### Ground normal detection (`getGroundNormal`)
|
|
88
|
+
|
|
89
|
+
The exported `getGroundNormal` function determines which direction is "up" by casting rays from the character's root part:
|
|
90
|
+
|
|
91
|
+
| Ray group | Count | Purpose |
|
|
92
|
+
|---|---|---|
|
|
93
|
+
| Center ray | 1 | Single downward ray (length 25) to find the surface directly below |
|
|
94
|
+
| Down rays | 24 | Radial ring of rays angled slightly inward/outward, with alternating even/odd radii, to sample the surrounding surface normals |
|
|
95
|
+
| Feeler rays | 9 | Shorter rays (length 2) fanning outward and downward to detect walls and edges the character is approaching |
|
|
96
|
+
|
|
97
|
+
All hit normals are weighted (front-facing rays weighted more heavily, feelers weighted 8x) and summed. The final unit vector becomes the new "up" direction. If no rays hit anything, the previous gravity direction is preserved.
|
|
98
|
+
|
|
99
|
+
### `GravityController.rbxmx` file manifest
|
|
100
|
+
|
|
101
|
+
The `.rbxmx` model bundles all the Lua scripts needed at runtime. During `installGravityControllerClass()` these are deployed to the correct locations in the Roblox data model.
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
GravityController (Script) ← server entry point; deploys children at runtime
|
|
105
|
+
├── Client (Folder) ← scripts that get copied into StarterPlayer
|
|
106
|
+
│ ├── Animate (LocalScript) → StarterCharacterScripts
|
|
107
|
+
│ │ ├── Controller (ModuleScript) — bootstraps R6/R15 animation sets
|
|
108
|
+
│ │ ├── Loaded (BoolValue) — signals when animations are ready
|
|
109
|
+
│ │ ├── PlayEmote (BindableFunction) — emote playback hook
|
|
110
|
+
│ │ ├── R15 (ModuleScript) — full R15 animation state machine
|
|
111
|
+
│ │ ├── R6 (ModuleScript) — full R6 animation state machine
|
|
112
|
+
│ │ ├── ReplicatedHumanoid (ObjectValue) — humanoid reference for replication
|
|
113
|
+
│ │ └── VerifyAnims (ModuleScript) — validates animation assets on the character
|
|
114
|
+
│ ├── PlayerScriptsLoader (LocalScript) → StarterPlayerScripts
|
|
115
|
+
│ │ ├── CameraInjector (ModuleScript) — monkey-patches PlayerModule's CameraModule
|
|
116
|
+
│ │ │ to expose a public GetUpVector API for gravity-aware camera rotation
|
|
117
|
+
│ │ └── FakeUserSettings (ModuleScript) — shims UserSettings() to override feature
|
|
118
|
+
│ │ flags (e.g. disables UserRemoveTheCameraApi) during camera injection
|
|
119
|
+
│ └── RbxCharacterSounds (LocalScript) → StarterPlayerScripts
|
|
120
|
+
│ └── AnimationState (ModuleScript) — maps animation track names to
|
|
121
|
+
│ HumanoidStateTypes so footstep/jump/fall sounds play correctly
|
|
122
|
+
│ under custom gravity
|
|
123
|
+
└── GravityController (ModuleScript) → ReplicatedStorage
|
|
124
|
+
├── CharacterModules (Folder)
|
|
125
|
+
│ ├── Camera (ModuleScript) — hooks into PlayerModule cameras to override
|
|
126
|
+
│ │ GetUpVector, making the camera orbit around the custom gravity axis
|
|
127
|
+
│ └── Control (ModuleScript) — wraps PlayerModule controls to read the
|
|
128
|
+
│ move vector from keyboard/gamepad/touch input
|
|
129
|
+
├── Collider (ModuleScript) — creates an invisible Ball Part welded below
|
|
130
|
+
│ the HRP for ground detection, plus VectorForce (gravity + walk),
|
|
131
|
+
│ BodyGyro (orientation), and BodyPosition (optional anti-slide lock)
|
|
132
|
+
├── StateTracker (ModuleScript) — replaces Humanoid state detection with
|
|
133
|
+
│ velocity-based Running/Jumping/Freefall tracking and fires the
|
|
134
|
+
│ Animate script's callbacks (onRunning, onJumping, onFreeFall, etc.)
|
|
135
|
+
└── Utility (Folder)
|
|
136
|
+
├── Maid (ModuleScript) — connection/instance cleanup utility
|
|
137
|
+
└── Signal (ModuleScript) — lightweight event/signal implementation
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
**Where each piece ends up at runtime:**
|
|
141
|
+
|
|
142
|
+
| Script | Deployed to | Role |
|
|
143
|
+
|---|---|---|
|
|
144
|
+
| `Client/PlayerScriptsLoader` | `StarterPlayerScripts` | Replaces the default PlayerScriptsLoader to inject gravity-aware camera and control overrides into the stock `PlayerModule` |
|
|
145
|
+
| `Client/RbxCharacterSounds` | `StarterPlayerScripts` | Replaces default character sounds so audio triggers are driven by the custom `StateTracker` instead of the native `Humanoid` state |
|
|
146
|
+
| `Client/Animate` | `StarterCharacterScripts` | Replaces default Animate script; plays R6/R15 animations driven by `StateTracker.Changed` events rather than native humanoid states |
|
|
147
|
+
| `GravityController` | `ReplicatedStorage` | The core module — `require`d by both the TypeScript wrapper and the PlayerScriptsLoader at runtime |
|
|
148
|
+
|
|
149
|
+
## API
|
|
150
|
+
|
|
151
|
+
### `installGravityControllerClass(config?)`
|
|
152
|
+
|
|
153
|
+
Initializes the gravity system. Call on both server and client. Returns the `GravityControllerClass` constructor. Idempotent — calling it again returns the same class (and optionally applies new config).
|
|
154
|
+
|
|
155
|
+
### `GravityControllerClass`
|
|
156
|
+
|
|
157
|
+
| Member | Type | Description |
|
|
158
|
+
|---|---|---|
|
|
159
|
+
| `new(player)` | constructor | Creates a controller for the given player's current character |
|
|
160
|
+
| `SetConstants(config)` | static method | Updates physics constants globally (see Configuration below) |
|
|
161
|
+
|
|
162
|
+
### `GravityController` (instance)
|
|
163
|
+
|
|
164
|
+
| Member | Type | Description |
|
|
165
|
+
|---|---|---|
|
|
166
|
+
| `Player` | `Player` | The owning player |
|
|
167
|
+
| `Character` | `Model` | The player's character model |
|
|
168
|
+
| `Humanoid` | `Humanoid` | The character's humanoid |
|
|
169
|
+
| `HRP` | `BasePart` | `HumanoidRootPart` |
|
|
170
|
+
| `Maid` | `{ Mark }` | Cleanup helper — tracks connections for automatic teardown |
|
|
171
|
+
| `GetGravityUp(oldGravity)` | method | Override this to control gravity direction each frame. Default returns `oldGravity` (no change). |
|
|
172
|
+
| `ResetGravity(direction)` | method | Instantly sets the gravity-up vector and resets the fall tracker |
|
|
173
|
+
| `GetFallHeight()` | method | Returns the signed distance fallen along the gravity axis while in freefall; `0` otherwise |
|
|
174
|
+
| `Destroy()` | method | Unbinds the render step, sweeps all connections, and restores `PlatformStand` |
|
|
175
|
+
|
|
176
|
+
### `GravityManager`
|
|
177
|
+
|
|
178
|
+
A plain TypeScript class (no framework dependencies) that manages the full lifecycle of a `GravityController` instance — waiting for the character and Animate script, constructing the controller, handling timeouts and retries, and tearing down cleanly.
|
|
179
|
+
|
|
180
|
+
```typescript
|
|
181
|
+
const cls = installGravityControllerClass(config)
|
|
182
|
+
const manager = new GravityManager(cls, logger)
|
|
183
|
+
|
|
184
|
+
manager.enable(getGravityControllerUp) // async 4-phase construction
|
|
185
|
+
manager.disable() // teardown + reset GravityUp attribute
|
|
186
|
+
manager.getController() // live instance or undefined
|
|
187
|
+
manager.getIsEnabling() // true while construction is in-flight
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
**Constructor:** `new GravityManager(gravityControllerClass, logger?)`
|
|
191
|
+
|
|
192
|
+
- `gravityControllerClass` — the class returned by `installGravityControllerClass()`
|
|
193
|
+
- `logger` — optional `GravityLogger` (see below). If omitted, logging is silently skipped.
|
|
194
|
+
|
|
195
|
+
**`enable(getGravityUp)`** — starts a 4-phase async construction:
|
|
196
|
+
|
|
197
|
+
1. Waits for `Players.LocalPlayer.Character` to exist
|
|
198
|
+
2. Waits for the `Animate` script and its `Controller` child (with timeout)
|
|
199
|
+
3. Waits for the `Animate` module to finish loading (`Loaded.Value = true`)
|
|
200
|
+
4. Constructs the `GravityController` instance and assigns `GetGravityUp`
|
|
201
|
+
|
|
202
|
+
Each call stores the requested `getGravityUp` function. If `enable()` is called again while construction is in-flight, the new function is saved and will be used when the current construction completes or on retry.
|
|
203
|
+
|
|
204
|
+
A generation counter invalidates stale constructions — if `disable()` is called while construction is running, the in-flight thread is cancelled and any completed-but-stale controller is destroyed immediately.
|
|
205
|
+
|
|
206
|
+
A watchdog fires after the timeout period and hard-cancels stuck construction threads, cleans up partial state (`GravityStep` RenderStep binding, `PlatformStand`), and triggers a retry.
|
|
207
|
+
|
|
208
|
+
**`disable()`** — tears down the active controller:
|
|
209
|
+
|
|
210
|
+
- Increments the generation counter (invalidating any in-flight construction)
|
|
211
|
+
- Cancels the construction thread if running
|
|
212
|
+
- Calls `Destroy()` on the live controller
|
|
213
|
+
- Sets the HRP `GravityUp` attribute to `(0, 1, 0)`
|
|
214
|
+
|
|
215
|
+
**`getController()`** — returns the live `GravityController` instance or `undefined`.
|
|
216
|
+
|
|
217
|
+
**`getIsEnabling()`** — returns `true` while construction is in-flight.
|
|
218
|
+
|
|
219
|
+
### `GravityLogger`
|
|
220
|
+
|
|
221
|
+
Minimal logging interface so the package doesn't depend on any specific logging library. Any object with `Info`, `Warn`, and `Error` string methods satisfies it — including `@rbxts/log`'s `Logger`.
|
|
222
|
+
|
|
223
|
+
```typescript
|
|
224
|
+
interface GravityLogger {
|
|
225
|
+
Info(message: string): void
|
|
226
|
+
Warn(message: string): void
|
|
227
|
+
Error(message: string): void
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### `wrapGravityUpSaveAttribute(getGravityUp)`
|
|
232
|
+
|
|
233
|
+
Higher-order function that wraps a `GetGravityUp` function to persist the current gravity direction as an HRP attribute (`GravityUp`). Only writes when the direction actually changes (magnitude delta > 0.001).
|
|
234
|
+
|
|
235
|
+
```typescript
|
|
236
|
+
manager.enable(wrapGravityUpSaveAttribute(getGravityControllerUp))
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
### `GetGravityUp` (type)
|
|
240
|
+
|
|
241
|
+
```typescript
|
|
242
|
+
type GetGravityUp = (self: GravityController, oldGravityUp: Vector3) => Vector3
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
The signature for gravity direction functions. Passed to `GravityManager.enable()` or assigned to `controller.GetGravityUp`.
|
|
246
|
+
|
|
247
|
+
### `getGravityControllerUp(controller, oldGravityUp)`
|
|
248
|
+
|
|
249
|
+
Convenience wrapper that calls `getGroundNormal` using the controller's `HRP.CFrame` with a rig-type-aware origin offset. Assign this to `controller.GetGravityUp` to enable wall walking.
|
|
250
|
+
|
|
251
|
+
### `getGroundNormal(cframe, originOffset, oldGravityUp)`
|
|
252
|
+
|
|
253
|
+
Low-level raycast function that returns a unit `Vector3` representing the surface normal beneath and around `cframe`. Useful if you want to build your own gravity logic.
|
|
254
|
+
|
|
255
|
+
## Configuration
|
|
256
|
+
|
|
257
|
+
Pass a config table to `installGravityControllerClass()` or call `SetConstants()` at any time:
|
|
258
|
+
|
|
259
|
+
| Key | Type | Default | Description |
|
|
260
|
+
|---|---|---|---|
|
|
261
|
+
| `Transition` | `number` | `0.15` | Lerp alpha per frame for gravity direction changes. Lower = slower, smoother transitions. |
|
|
262
|
+
| `WalkForce` | `number` | `66.67` | Horizontal acceleration multiplier. Increase for snappier movement. |
|
|
263
|
+
| `JumpModifier` | `number` | `1.2` | Multiplier on `Humanoid.JumpPower` when jumping along the custom gravity axis. |
|
|
264
|
+
| `UseBodyPositionLock` | `boolean` | `false` | When `true`, locks the character's horizontal position with a `BodyPosition` while idle on an aligned surface to prevent sliding. |
|
|
265
|
+
|
|
266
|
+
## Usage
|
|
267
|
+
|
|
268
|
+
### With GravityManager (recommended)
|
|
269
|
+
|
|
270
|
+
**Server** — install in a `@Service`:
|
|
9
271
|
|
|
10
272
|
```typescript
|
|
11
273
|
import { OnInit, Service } from '@flamework/core'
|
|
@@ -14,52 +276,86 @@ import { installGravityControllerClass } from '@rbxts/gravity-controller'
|
|
|
14
276
|
@Service()
|
|
15
277
|
export class GravityService implements OnInit {
|
|
16
278
|
onInit() {
|
|
17
|
-
// Install EgoMoose's Rbx-Gravity-Controller https://github.com/EgoMoose/Rbx-Gravity-Controller
|
|
18
279
|
installGravityControllerClass()
|
|
19
280
|
}
|
|
20
281
|
}
|
|
21
282
|
```
|
|
22
283
|
|
|
23
|
-
|
|
284
|
+
**Client** — use `GravityManager` to handle the full enable/disable lifecycle:
|
|
24
285
|
|
|
25
286
|
```typescript
|
|
26
287
|
import { Controller, OnStart } from '@flamework/core'
|
|
27
288
|
import {
|
|
28
289
|
getGravityControllerUp,
|
|
29
|
-
|
|
30
|
-
GravityControllerClass,
|
|
290
|
+
GravityManager,
|
|
31
291
|
installGravityControllerClass,
|
|
292
|
+
wrapGravityUpSaveAttribute,
|
|
32
293
|
} from '@rbxts/gravity-controller'
|
|
33
|
-
import {
|
|
294
|
+
import { Logger } from '@rbxts/log'
|
|
34
295
|
|
|
35
296
|
@Controller({})
|
|
36
297
|
export class PlayerGravityController implements OnStart {
|
|
37
|
-
|
|
38
|
-
|
|
298
|
+
private gravityManager: GravityManager | undefined
|
|
299
|
+
|
|
300
|
+
constructor(private logger: Logger) {}
|
|
39
301
|
|
|
40
302
|
onStart() {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
303
|
+
const cls = installGravityControllerClass()
|
|
304
|
+
this.gravityManager = new GravityManager(cls, this.logger)
|
|
305
|
+
|
|
306
|
+
// Enable gravity with surface-following wall walk
|
|
307
|
+
this.gravityManager.enable(
|
|
308
|
+
wrapGravityUpSaveAttribute(getGravityControllerUp),
|
|
309
|
+
)
|
|
47
310
|
}
|
|
48
311
|
|
|
49
|
-
|
|
50
|
-
this.
|
|
51
|
-
this.gravityController = undefined
|
|
312
|
+
disable() {
|
|
313
|
+
this.gravityManager?.disable()
|
|
52
314
|
}
|
|
315
|
+
}
|
|
316
|
+
```
|
|
53
317
|
|
|
54
|
-
|
|
55
|
-
if (this.gravityController || !this.gravityControllerClass) return
|
|
56
|
-
const gravityController = new this.gravityControllerClass(Players.LocalPlayer)
|
|
318
|
+
### Manual lifecycle (without GravityManager)
|
|
57
319
|
|
|
58
|
-
|
|
59
|
-
gravityController.GetGravityUp = getGravityControllerUp
|
|
320
|
+
If you need full control over construction timing:
|
|
60
321
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
322
|
+
```typescript
|
|
323
|
+
import {
|
|
324
|
+
installGravityControllerClass,
|
|
325
|
+
getGravityControllerUp,
|
|
326
|
+
} from '@rbxts/gravity-controller'
|
|
327
|
+
import { Players } from '@rbxts/services'
|
|
328
|
+
|
|
329
|
+
const GravityControllerClass = installGravityControllerClass()
|
|
64
330
|
|
|
331
|
+
Players.LocalPlayer.CharacterAdded.Connect(() => {
|
|
332
|
+
const gc = new GravityControllerClass(Players.LocalPlayer)
|
|
333
|
+
gc.GetGravityUp = getGravityControllerUp
|
|
334
|
+
})
|
|
65
335
|
```
|
|
336
|
+
|
|
337
|
+
### Custom gravity direction
|
|
338
|
+
|
|
339
|
+
If you don't want surface-following wall walk, you can point gravity in any fixed direction:
|
|
340
|
+
|
|
341
|
+
```typescript
|
|
342
|
+
const gc = new GravityControllerClass(Players.LocalPlayer)
|
|
343
|
+
|
|
344
|
+
// Gravity pulls toward -X (sideways)
|
|
345
|
+
gc.GetGravityUp = () => new Vector3(1, 0, 0)
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
Or reset gravity imperatively:
|
|
349
|
+
|
|
350
|
+
```typescript
|
|
351
|
+
gc.ResetGravity(new Vector3(0, -1, 0)) // flip upside down
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
## Credits
|
|
355
|
+
|
|
356
|
+
- [EgoMoose](https://github.com/EgoMoose) — original [Rbx-Gravity-Controller](https://github.com/EgoMoose/Rbx-Gravity-Controller) Lua implementation
|
|
357
|
+
- [EmilyBendsSpace](https://x.com/EmilyBendsSpace) — improved ground normal raycasting for smooth wall walking ([DevForum post](https://devforum.roblox.com/t/example-source-smooth-wall-walking-gravity-controller-from-club-raven/440229))
|
|
358
|
+
|
|
359
|
+
## License
|
|
360
|
+
|
|
361
|
+
MIT
|
package/out/index.d.ts
CHANGED
|
@@ -28,7 +28,31 @@ export interface GravityControllerConfig {
|
|
|
28
28
|
JumpModifier?: number;
|
|
29
29
|
UseBodyPositionLock?: boolean;
|
|
30
30
|
}
|
|
31
|
+
export interface GravityLogger {
|
|
32
|
+
Info(message: string): void;
|
|
33
|
+
Warn(message: string): void;
|
|
34
|
+
Error(message: string): void;
|
|
35
|
+
}
|
|
36
|
+
export type GetGravityUp = (self: GravityController, oldGravityUp: Vector3) => Vector3;
|
|
31
37
|
export declare let gravityControllerClass: GravityControllerClass;
|
|
32
38
|
export declare function installGravityControllerClass(config?: GravityControllerConfig): GravityControllerClass;
|
|
39
|
+
export declare function wrapGravityUpSaveAttribute(getGravityUp: GetGravityUp): (gravityController: GravityController, oldGravityUp: Vector3) => Vector3;
|
|
40
|
+
export declare class GravityManager {
|
|
41
|
+
private _controller;
|
|
42
|
+
private _enabling;
|
|
43
|
+
private enablingStartedAt;
|
|
44
|
+
private generation;
|
|
45
|
+
private retryCount;
|
|
46
|
+
private constructionThread;
|
|
47
|
+
private pendingGetGravityUp;
|
|
48
|
+
private readonly gravityControllerClass;
|
|
49
|
+
private readonly logger;
|
|
50
|
+
constructor(gravityControllerClass: GravityControllerClass, logger?: GravityLogger);
|
|
51
|
+
getController(): GravityController | undefined;
|
|
52
|
+
getIsEnabling(): boolean;
|
|
53
|
+
disable(): void;
|
|
54
|
+
enable(getGravityUp: GetGravityUp): void;
|
|
55
|
+
private retryEnable;
|
|
56
|
+
}
|
|
33
57
|
export declare function getGroundNormal(cframe: CFrame, originOffset: Vector3, oldGravityUp: Vector3): Vector3;
|
|
34
58
|
export declare function getGravityControllerUp(gravityController: GravityController, oldGravityUp: Vector3): Vector3;
|
package/out/init.lua
CHANGED
|
@@ -6,6 +6,7 @@ local Players = _services.Players
|
|
|
6
6
|
local ReplicatedStorage = _services.ReplicatedStorage
|
|
7
7
|
local RunService = _services.RunService
|
|
8
8
|
local StarterPlayer = _services.StarterPlayer
|
|
9
|
+
local Workspace = _services.Workspace
|
|
9
10
|
local function installGravityControllerClass(config)
|
|
10
11
|
if exports.gravityControllerClass then
|
|
11
12
|
if config then
|
|
@@ -44,6 +45,228 @@ local function installGravityControllerClass(config)
|
|
|
44
45
|
end
|
|
45
46
|
return exports.gravityControllerClass
|
|
46
47
|
end
|
|
48
|
+
-- ── Utility ──────────────────────────────────────────────────────────
|
|
49
|
+
local function wrapGravityUpSaveAttribute(getGravityUp)
|
|
50
|
+
return function(gravityController, oldGravityUp)
|
|
51
|
+
local up = getGravityUp(gravityController, oldGravityUp)
|
|
52
|
+
local _oldGravityUp = oldGravityUp
|
|
53
|
+
if (up - _oldGravityUp).Magnitude > 0.001 then
|
|
54
|
+
gravityController.HRP:SetAttribute("GravityUp", up)
|
|
55
|
+
end
|
|
56
|
+
return up
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
-- ── GravityManager ───────────────────────────────────────────────────
|
|
60
|
+
local ENABLING_STALE_SEC = 5
|
|
61
|
+
local MAX_GRAVITY_RETRIES = 3
|
|
62
|
+
local RETRY_BACKOFF_SEC = 2
|
|
63
|
+
local noopLogger = {
|
|
64
|
+
Info = function(self) end,
|
|
65
|
+
Warn = function(self) end,
|
|
66
|
+
Error = function(self) end,
|
|
67
|
+
}
|
|
68
|
+
local GravityManager
|
|
69
|
+
do
|
|
70
|
+
GravityManager = setmetatable({}, {
|
|
71
|
+
__tostring = function()
|
|
72
|
+
return "GravityManager"
|
|
73
|
+
end,
|
|
74
|
+
})
|
|
75
|
+
GravityManager.__index = GravityManager
|
|
76
|
+
function GravityManager.new(...)
|
|
77
|
+
local self = setmetatable({}, GravityManager)
|
|
78
|
+
return self:constructor(...) or self
|
|
79
|
+
end
|
|
80
|
+
function GravityManager:constructor(gravityControllerClass, logger)
|
|
81
|
+
self._enabling = false
|
|
82
|
+
self.enablingStartedAt = 0
|
|
83
|
+
self.generation = 0
|
|
84
|
+
self.retryCount = 0
|
|
85
|
+
self.gravityControllerClass = gravityControllerClass
|
|
86
|
+
self.logger = logger or noopLogger
|
|
87
|
+
end
|
|
88
|
+
function GravityManager:getController()
|
|
89
|
+
return self._controller
|
|
90
|
+
end
|
|
91
|
+
function GravityManager:getIsEnabling()
|
|
92
|
+
return self._enabling
|
|
93
|
+
end
|
|
94
|
+
function GravityManager:disable()
|
|
95
|
+
local wasActive = self._controller ~= nil
|
|
96
|
+
local wasEnabling = self._enabling
|
|
97
|
+
if not wasActive and not wasEnabling then
|
|
98
|
+
return nil
|
|
99
|
+
end
|
|
100
|
+
local prevGen = self.generation
|
|
101
|
+
self.generation += 1
|
|
102
|
+
self._enabling = false
|
|
103
|
+
self.pendingGetGravityUp = nil
|
|
104
|
+
self.retryCount = 0
|
|
105
|
+
if self.constructionThread then
|
|
106
|
+
pcall(function()
|
|
107
|
+
return task.cancel(self.constructionThread)
|
|
108
|
+
end)
|
|
109
|
+
self.constructionThread = nil
|
|
110
|
+
end
|
|
111
|
+
self.logger:Info(`Disabling gravity controller: wasActive={wasActive}, wasEnabling={wasEnabling}` .. `, gen={prevGen}->{self.generation}`)
|
|
112
|
+
local _result = self._controller
|
|
113
|
+
if _result ~= nil then
|
|
114
|
+
_result:Destroy()
|
|
115
|
+
end
|
|
116
|
+
self._controller = nil
|
|
117
|
+
local _result_1 = Players.LocalPlayer.Character
|
|
118
|
+
if _result_1 ~= nil then
|
|
119
|
+
_result_1 = _result_1:FindFirstChild("HumanoidRootPart")
|
|
120
|
+
end
|
|
121
|
+
local hrp = _result_1
|
|
122
|
+
local _result_2 = hrp
|
|
123
|
+
if _result_2 ~= nil then
|
|
124
|
+
_result_2:SetAttribute("GravityUp", Vector3.new(0, 1, 0))
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
function GravityManager:enable(getGravityUp)
|
|
128
|
+
self.pendingGetGravityUp = getGravityUp
|
|
129
|
+
if self._controller then
|
|
130
|
+
return nil
|
|
131
|
+
end
|
|
132
|
+
local now = Workspace:GetServerTimeNow()
|
|
133
|
+
if self._enabling and now - self.enablingStartedAt < ENABLING_STALE_SEC then
|
|
134
|
+
return nil
|
|
135
|
+
end
|
|
136
|
+
self._enabling = true
|
|
137
|
+
self.enablingStartedAt = now
|
|
138
|
+
local generation = self.generation
|
|
139
|
+
local timeout = ENABLING_STALE_SEC + self.retryCount * RETRY_BACKOFF_SEC
|
|
140
|
+
self.logger:Info(`Enabling gravity controller (gen {generation}, timeout {timeout}s)`)
|
|
141
|
+
self.constructionThread = task.spawn(function()
|
|
142
|
+
-- Phase 1: wait for character
|
|
143
|
+
if not Players.LocalPlayer.Character then
|
|
144
|
+
Players.LocalPlayer.CharacterAdded:Wait()
|
|
145
|
+
end
|
|
146
|
+
local character = Players.LocalPlayer.Character
|
|
147
|
+
if generation ~= self.generation then
|
|
148
|
+
self._enabling = false
|
|
149
|
+
local pending = self.pendingGetGravityUp
|
|
150
|
+
if pending then
|
|
151
|
+
self:enable(pending)
|
|
152
|
+
end
|
|
153
|
+
return nil
|
|
154
|
+
end
|
|
155
|
+
-- Phase 2: wait for Animate script + Controller instance
|
|
156
|
+
local animate = character:WaitForChild("Animate", timeout)
|
|
157
|
+
if not animate then
|
|
158
|
+
self:retryEnable(generation, `Animate not found after {timeout}s`)
|
|
159
|
+
return nil
|
|
160
|
+
end
|
|
161
|
+
local animController = animate:WaitForChild("Controller", timeout)
|
|
162
|
+
if not animController then
|
|
163
|
+
self:retryEnable(generation, `Animate.Controller not found after {timeout}s`)
|
|
164
|
+
return nil
|
|
165
|
+
end
|
|
166
|
+
-- Phase 3: wait for Animate module to finish executing
|
|
167
|
+
local loaded = animate:FindFirstChild("Loaded")
|
|
168
|
+
if loaded and not loaded.Value then
|
|
169
|
+
self.logger:Info(`Waiting for Animate to finish loading (gen {generation})`)
|
|
170
|
+
local loadStart = os.clock()
|
|
171
|
+
while not loaded.Value and os.clock() - loadStart < timeout and generation == self.generation do
|
|
172
|
+
task.wait(0.1)
|
|
173
|
+
end
|
|
174
|
+
if not loaded.Value then
|
|
175
|
+
self:retryEnable(generation, `Animate not fully loaded after {timeout}s`)
|
|
176
|
+
return nil
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
if generation ~= self.generation then
|
|
180
|
+
self._enabling = false
|
|
181
|
+
local pending = self.pendingGetGravityUp
|
|
182
|
+
if pending then
|
|
183
|
+
self:enable(pending)
|
|
184
|
+
end
|
|
185
|
+
return nil
|
|
186
|
+
end
|
|
187
|
+
-- Phase 4: construct
|
|
188
|
+
self.logger:Info(`Constructing gravity controller (gen {generation})`)
|
|
189
|
+
local ok, result = pcall(function()
|
|
190
|
+
local gc = self.gravityControllerClass.new(Players.LocalPlayer)
|
|
191
|
+
gc.GetGravityUp = getGravityUp
|
|
192
|
+
return gc
|
|
193
|
+
end)
|
|
194
|
+
self._enabling = false
|
|
195
|
+
if generation ~= self.generation then
|
|
196
|
+
self.logger:Info(`Gravity controller construction completed but generation is stale ({generation} vs {self.generation}), destroying`)
|
|
197
|
+
if ok and result then
|
|
198
|
+
result:Destroy()
|
|
199
|
+
end
|
|
200
|
+
local pending = self.pendingGetGravityUp
|
|
201
|
+
if pending then
|
|
202
|
+
self:enable(pending)
|
|
203
|
+
end
|
|
204
|
+
return nil
|
|
205
|
+
end
|
|
206
|
+
if ok and result then
|
|
207
|
+
self._controller = result
|
|
208
|
+
self.pendingGetGravityUp = nil
|
|
209
|
+
self.retryCount = 0
|
|
210
|
+
self.logger:Info(`Gravity controller enabled (gen {generation})`)
|
|
211
|
+
else
|
|
212
|
+
local _result
|
|
213
|
+
if type(result) == "string" then
|
|
214
|
+
_result = result
|
|
215
|
+
else
|
|
216
|
+
local _condition = result
|
|
217
|
+
if _condition == nil then
|
|
218
|
+
_condition = "Unknown error"
|
|
219
|
+
end
|
|
220
|
+
_result = tostring(_condition)
|
|
221
|
+
end
|
|
222
|
+
local err = _result
|
|
223
|
+
self.logger:Error(`Error enabling gravity controller: {err}`)
|
|
224
|
+
end
|
|
225
|
+
end)
|
|
226
|
+
-- Watchdog: hard-cancel + cleanup + retry
|
|
227
|
+
task.delay(timeout, function()
|
|
228
|
+
if not self._enabling or self.generation ~= generation then
|
|
229
|
+
return nil
|
|
230
|
+
end
|
|
231
|
+
if self.constructionThread then
|
|
232
|
+
pcall(function()
|
|
233
|
+
return task.cancel(self.constructionThread)
|
|
234
|
+
end)
|
|
235
|
+
self.constructionThread = nil
|
|
236
|
+
end
|
|
237
|
+
pcall(function()
|
|
238
|
+
return RunService:UnbindFromRenderStep("GravityStep")
|
|
239
|
+
end)
|
|
240
|
+
local _humanoid = Players.LocalPlayer.Character
|
|
241
|
+
if _humanoid ~= nil then
|
|
242
|
+
_humanoid = _humanoid:FindFirstChildOfClass("Humanoid")
|
|
243
|
+
end
|
|
244
|
+
local humanoid = _humanoid
|
|
245
|
+
if humanoid then
|
|
246
|
+
humanoid.PlatformStand = false
|
|
247
|
+
end
|
|
248
|
+
self:retryEnable(generation, `construction timed out after {timeout}s`)
|
|
249
|
+
end)
|
|
250
|
+
end
|
|
251
|
+
function GravityManager:retryEnable(generation, reason)
|
|
252
|
+
if generation ~= self.generation then
|
|
253
|
+
return nil
|
|
254
|
+
end
|
|
255
|
+
self._enabling = false
|
|
256
|
+
self.retryCount += 1
|
|
257
|
+
if self.retryCount > MAX_GRAVITY_RETRIES then
|
|
258
|
+
self.logger:Error(`Gravity controller failed after {self.retryCount} attempts ({reason}), giving up (gen {generation})`)
|
|
259
|
+
return nil
|
|
260
|
+
end
|
|
261
|
+
self.logger:Warn(`Gravity controller: {reason} (gen {generation}), retrying (attempt {self.retryCount}/{MAX_GRAVITY_RETRIES})`)
|
|
262
|
+
self.generation += 1
|
|
263
|
+
local pending = self.pendingGetGravityUp
|
|
264
|
+
if pending then
|
|
265
|
+
self:enable(pending)
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
-- ── GetGravityUp implementations ─────────────────────────────────────
|
|
47
270
|
local PI2 = math.pi * 2
|
|
48
271
|
local ZERO = Vector3.new(0, 0, 0)
|
|
49
272
|
local LOWER_RADIUS_OFFSET = 3
|
|
@@ -175,6 +398,8 @@ local function getGravityControllerUp(gravityController, oldGravityUp)
|
|
|
175
398
|
return getGroundNormal(gravityController.HRP.CFrame, if gravityController.Humanoid.RigType == Enum.HumanoidRigType.R15 then ZERO else oldGravityUp * 0.35, oldGravityUp)
|
|
176
399
|
end
|
|
177
400
|
exports.installGravityControllerClass = installGravityControllerClass
|
|
401
|
+
exports.wrapGravityUpSaveAttribute = wrapGravityUpSaveAttribute
|
|
178
402
|
exports.getGroundNormal = getGroundNormal
|
|
179
403
|
exports.getGravityControllerUp = getGravityControllerUp
|
|
404
|
+
exports.GravityManager = GravityManager
|
|
180
405
|
return exports
|