@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.
@@ -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
- Typescript bindings for [EgoMoose's Rbx-Gravity-Controller](https://github.com/EgoMoose/Rbx-Gravity-Controller) with
4
- ground normal finding by [EmilyBendsSpace](https://x.com/EmilyBendsSpace)
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
- ## Flamework setup
6
+ Players can walk on walls, ceilings, and any arbitrary surface with smooth gravity transitions.
7
7
 
8
- 1. Add `installGravityControllerClass` to the `onInit` of some `@Service`
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
- 2. Setup a `GravityController` in some `@Controller`
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
- GravityController,
30
- GravityControllerClass,
290
+ GravityManager,
31
291
  installGravityControllerClass,
292
+ wrapGravityUpSaveAttribute,
32
293
  } from '@rbxts/gravity-controller'
33
- import { Players } from '@rbxts/services'
294
+ import { Logger } from '@rbxts/log'
34
295
 
35
296
  @Controller({})
36
297
  export class PlayerGravityController implements OnStart {
37
- gravityControllerClass: GravityControllerClass | undefined
38
- gravityController: GravityController | undefined
298
+ private gravityManager: GravityManager | undefined
299
+
300
+ constructor(private logger: Logger) {}
39
301
 
40
302
  onStart() {
41
- this.gravityControllerClass = installGravityControllerClass()
42
- Players.LocalPlayer.CharacterAdded.Connect((_character) => {
43
- this.disableGravityController()
44
- this.enableGravityController()
45
- })
46
- if (Players.LocalPlayer.Character) this.enableGravityController()
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
- disableGravityController() {
50
- this.gravityController?.Destroy()
51
- this.gravityController = undefined
312
+ disable() {
313
+ this.gravityManager?.disable()
52
314
  }
315
+ }
316
+ ```
53
317
 
54
- enableGravityController() {
55
- if (this.gravityController || !this.gravityControllerClass) return
56
- const gravityController = new this.gravityControllerClass(Players.LocalPlayer)
318
+ ### Manual lifecycle (without GravityManager)
57
319
 
58
- // Use EmilyBendsSpace's getGroundNormal() to walk up walls
59
- gravityController.GetGravityUp = getGravityControllerUp
320
+ If you need full control over construction timing:
60
321
 
61
- this.gravityController = gravityController
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rbxts/gravity-controller",
3
- "version": "1.0.6",
3
+ "version": "1.0.7",
4
4
  "main": "out/init.lua",
5
5
  "keywords": [
6
6
  "gravity",