@quenty/characterutils 12.39.0 → 12.40.1
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/CHANGELOG.md +11 -0
- package/package.json +7 -7
- package/src/Shared/RootPartUtils.lua +94 -0
- package/src/Shared/RootPartUtils.spec.lua +179 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,17 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
## [12.40.1](https://github.com/Quenty/NevermoreEngine/compare/@quenty/characterutils@12.40.0...@quenty/characterutils@12.40.1) (2026-07-25)
|
|
7
|
+
|
|
8
|
+
**Note:** Version bump only for package @quenty/characterutils
|
|
9
|
+
|
|
10
|
+
# [12.40.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/characterutils@12.39.0...@quenty/characterutils@12.40.0) (2026-07-25)
|
|
11
|
+
|
|
12
|
+
### Features
|
|
13
|
+
|
|
14
|
+
- Add RootPartUtils.getPlayerRootPart(player) ([dc1c687](https://github.com/Quenty/NevermoreEngine/commit/dc1c68760c3e4b699b0a4e59eec464b73a61867b))
|
|
15
|
+
- Add RootPartUtils.promisePlayerHumanoidRootPart(player) ([50a789b](https://github.com/Quenty/NevermoreEngine/commit/50a789b4ae0f126d893fd86405b1f5b2ccdfc5b7))
|
|
16
|
+
|
|
6
17
|
# [12.39.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/characterutils@12.38.0...@quenty/characterutils@12.39.0) (2026-07-24)
|
|
7
18
|
|
|
8
19
|
**Note:** Version bump only for package @quenty/characterutils
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quenty/characterutils",
|
|
3
|
-
"version": "12.
|
|
3
|
+
"version": "12.40.1",
|
|
4
4
|
"description": "CharacterUtils",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"Roblox",
|
|
@@ -28,19 +28,19 @@
|
|
|
28
28
|
"Quenty"
|
|
29
29
|
],
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@quenty/brio": "14.
|
|
31
|
+
"@quenty/brio": "14.35.1",
|
|
32
32
|
"@quenty/deferred": "2.3.2",
|
|
33
|
-
"@quenty/instanceutils": "13.
|
|
33
|
+
"@quenty/instanceutils": "13.35.1",
|
|
34
34
|
"@quenty/loader": "10.11.0",
|
|
35
35
|
"@quenty/maid": "3.11.0",
|
|
36
36
|
"@quenty/nevermore-test-runner": "1.5.0",
|
|
37
|
-
"@quenty/playermock": "1.
|
|
38
|
-
"@quenty/promise": "10.
|
|
39
|
-
"@quenty/rx": "13.
|
|
37
|
+
"@quenty/playermock": "1.5.1",
|
|
38
|
+
"@quenty/promise": "10.23.0",
|
|
39
|
+
"@quenty/rx": "13.33.0",
|
|
40
40
|
"@quentystudios/jest-lua": "3.10.0-quenty.2"
|
|
41
41
|
},
|
|
42
42
|
"publishConfig": {
|
|
43
43
|
"access": "public"
|
|
44
44
|
},
|
|
45
|
-
"gitHead": "
|
|
45
|
+
"gitHead": "f276b791b8eb4e88b952b5babaf0f4189c1a11d0"
|
|
46
46
|
}
|
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
|
|
7
7
|
local require = require(script.Parent.loader).load(script)
|
|
8
8
|
|
|
9
|
+
local CharacterUtils = require("CharacterUtils")
|
|
9
10
|
local Maid = require("Maid")
|
|
11
|
+
local PlayerMock = require("PlayerMock")
|
|
10
12
|
local Promise = require("Promise")
|
|
11
13
|
|
|
12
14
|
local RootPartUtils = {}
|
|
@@ -70,6 +72,98 @@ function RootPartUtils.promiseRootPart(humanoid: Humanoid): Promise.Promise<Base
|
|
|
70
72
|
return promise
|
|
71
73
|
end
|
|
72
74
|
|
|
75
|
+
--[=[
|
|
76
|
+
Given a player creates a promise that will resolve once the player has a character whose
|
|
77
|
+
humanoid has a resolved `Humanoid.RootPart`.
|
|
78
|
+
|
|
79
|
+
Resolves with the root part of whatever character the player has at that moment, so a respawn
|
|
80
|
+
while waiting is followed instead of leaving the promise stuck on a destroyed character. Health
|
|
81
|
+
is not considered -- a dead humanoid still has a root part, matching
|
|
82
|
+
[CharacterUtils.getPlayerRootPart].
|
|
83
|
+
|
|
84
|
+
```lua
|
|
85
|
+
RootPartUtils.promisePlayerHumanoidRootPart(player):Then(function(rootPart)
|
|
86
|
+
print(rootPart:GetPivot())
|
|
87
|
+
end)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Accepts a [PlayerMock] as well as a real `Player`.
|
|
91
|
+
|
|
92
|
+
@param player Player
|
|
93
|
+
@return Promise<BasePart>
|
|
94
|
+
]=]
|
|
95
|
+
function RootPartUtils.promisePlayerHumanoidRootPart(player: Player): Promise.Promise<BasePart>
|
|
96
|
+
assert(player:IsA("Player") or PlayerMock.isMock(player), "Bad player")
|
|
97
|
+
|
|
98
|
+
local rootPart = CharacterUtils.getPlayerRootPart(player)
|
|
99
|
+
if rootPart then
|
|
100
|
+
return Promise.resolved(rootPart)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
local maid = Maid.new()
|
|
104
|
+
local promise = Promise.new()
|
|
105
|
+
|
|
106
|
+
-- Same unobservable-property problem promiseRootPart works around, so the same poll: it reads
|
|
107
|
+
-- through CharacterUtils.getPlayerRootPart, which covers the character spawning, its humanoid
|
|
108
|
+
-- appearing and the root part resolving in one check -- and reads a mock's character through
|
|
109
|
+
-- the mock seam.
|
|
110
|
+
task.spawn(function()
|
|
111
|
+
while promise:IsPending() do
|
|
112
|
+
local currentRootPart = CharacterUtils.getPlayerRootPart(player)
|
|
113
|
+
if currentRootPart then
|
|
114
|
+
promise:Resolve(currentRootPart)
|
|
115
|
+
return
|
|
116
|
+
end
|
|
117
|
+
task.wait(0.05)
|
|
118
|
+
end
|
|
119
|
+
end)
|
|
120
|
+
|
|
121
|
+
task.delay(MAX_YIELD_TIME, function()
|
|
122
|
+
if promise:IsPending() then
|
|
123
|
+
warn(debug.traceback("[RootPartUtils.promisePlayerHumanoidRootPart] - Timed out on root part"))
|
|
124
|
+
promise:Reject("Timed out")
|
|
125
|
+
end
|
|
126
|
+
end)
|
|
127
|
+
|
|
128
|
+
-- A player who left is never getting a character; a mock leaves the same way (PlayerMock.kick
|
|
129
|
+
-- unparents rather than destroys), so this native signal serves both.
|
|
130
|
+
maid:GiveTask(player.AncestryChanged:Connect(function()
|
|
131
|
+
if not player:IsDescendantOf(game) then
|
|
132
|
+
promise:Reject("Player removed from game")
|
|
133
|
+
end
|
|
134
|
+
end))
|
|
135
|
+
|
|
136
|
+
promise:Finally(function()
|
|
137
|
+
maid:DoCleaning()
|
|
138
|
+
end)
|
|
139
|
+
|
|
140
|
+
return promise
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
--[=[
|
|
144
|
+
Gets the root part of the player's current character, if it exists.
|
|
145
|
+
|
|
146
|
+
Synchronous counterpart to [RootPartUtils.promisePlayerHumanoidRootPart]. Health is not
|
|
147
|
+
considered -- a dead humanoid still has a root part.
|
|
148
|
+
|
|
149
|
+
```lua
|
|
150
|
+
local rootPart = RootPartUtils.getPlayerRootPart(player)
|
|
151
|
+
if rootPart then
|
|
152
|
+
print(rootPart:GetPivot())
|
|
153
|
+
end
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Accepts a [PlayerMock] as well as a real `Player`.
|
|
157
|
+
|
|
158
|
+
@param player Player
|
|
159
|
+
@return BasePart? -- Nil if not found
|
|
160
|
+
]=]
|
|
161
|
+
function RootPartUtils.getPlayerRootPart(player: Player): BasePart?
|
|
162
|
+
assert(player:IsA("Player") or PlayerMock.isMock(player), "Bad player")
|
|
163
|
+
|
|
164
|
+
return CharacterUtils.getPlayerRootPart(player)
|
|
165
|
+
end
|
|
166
|
+
|
|
73
167
|
--[=[
|
|
74
168
|
Gets the root part of a character, if it exists
|
|
75
169
|
@param character Model
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
local require = require(script.Parent.loader).load(script)
|
|
3
|
+
|
|
4
|
+
local Workspace = game:GetService("Workspace")
|
|
5
|
+
|
|
6
|
+
local Jest = require("Jest")
|
|
7
|
+
local Maid = require("Maid")
|
|
8
|
+
local PlayerMock = require("PlayerMock")
|
|
9
|
+
local RootPartUtils = require("RootPartUtils")
|
|
10
|
+
|
|
11
|
+
local afterEach = Jest.Globals.afterEach
|
|
12
|
+
local beforeEach = Jest.Globals.beforeEach
|
|
13
|
+
local describe = Jest.Globals.describe
|
|
14
|
+
local expect = Jest.Globals.expect
|
|
15
|
+
local it = Jest.Globals.it
|
|
16
|
+
|
|
17
|
+
local maid: Maid.Maid = nil :: any
|
|
18
|
+
local player: Player = nil :: any
|
|
19
|
+
|
|
20
|
+
beforeEach(function()
|
|
21
|
+
maid = Maid.new()
|
|
22
|
+
player = PlayerMock.new()
|
|
23
|
+
player.Parent = Workspace -- the leave path needs an ancestry to change
|
|
24
|
+
end)
|
|
25
|
+
|
|
26
|
+
afterEach(function()
|
|
27
|
+
maid:DoCleaning() -- settles any promise left pending, so no poll outlives the test
|
|
28
|
+
player:Destroy()
|
|
29
|
+
end)
|
|
30
|
+
|
|
31
|
+
local function getRootPart(character: Model): BasePart
|
|
32
|
+
return character:FindFirstChild("HumanoidRootPart") :: BasePart
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
describe("RootPartUtils.promisePlayerHumanoidRootPart", function()
|
|
36
|
+
it("resolves already with the root part of a spawned character", function()
|
|
37
|
+
local character = PlayerMock.loadMinimalCharacterAsync(player)
|
|
38
|
+
|
|
39
|
+
local promise = maid:Add(RootPartUtils.promisePlayerHumanoidRootPart(player))
|
|
40
|
+
expect(promise:IsFulfilled()).toBe(true)
|
|
41
|
+
|
|
42
|
+
local ok, rootPart = promise:Yield()
|
|
43
|
+
expect(ok).toBe(true)
|
|
44
|
+
expect(rootPart).toBe(getRootPart(character))
|
|
45
|
+
end)
|
|
46
|
+
|
|
47
|
+
it("stays pending until a character spawns", function()
|
|
48
|
+
local promise = maid:Add(RootPartUtils.promisePlayerHumanoidRootPart(player))
|
|
49
|
+
expect(promise:IsPending()).toBe(true)
|
|
50
|
+
|
|
51
|
+
local character = PlayerMock.loadMinimalCharacterAsync(player)
|
|
52
|
+
|
|
53
|
+
local ok, rootPart = promise:Yield()
|
|
54
|
+
expect(ok).toBe(true)
|
|
55
|
+
expect(rootPart).toBe(getRootPart(character))
|
|
56
|
+
end)
|
|
57
|
+
|
|
58
|
+
it("resolves with the root part of the character present when it spawns, not a despawned one", function()
|
|
59
|
+
local firstCharacter = PlayerMock.loadMinimalCharacterAsync(player)
|
|
60
|
+
local firstRootPart = getRootPart(firstCharacter)
|
|
61
|
+
PlayerMock.removeCharacter(player)
|
|
62
|
+
|
|
63
|
+
local promise = maid:Add(RootPartUtils.promisePlayerHumanoidRootPart(player))
|
|
64
|
+
expect(promise:IsPending()).toBe(true)
|
|
65
|
+
|
|
66
|
+
local secondCharacter = PlayerMock.loadMinimalCharacterAsync(player)
|
|
67
|
+
|
|
68
|
+
local ok, rootPart = promise:Yield()
|
|
69
|
+
expect(ok).toBe(true)
|
|
70
|
+
expect(rootPart).toBe(getRootPart(secondCharacter))
|
|
71
|
+
expect(rootPart).never.toBe(firstRootPart)
|
|
72
|
+
end)
|
|
73
|
+
|
|
74
|
+
it("resolves for a dead humanoid, which still has a root part", function()
|
|
75
|
+
local character = PlayerMock.loadMinimalCharacterAsync(player)
|
|
76
|
+
local humanoid = character:FindFirstChildOfClass("Humanoid") :: Humanoid
|
|
77
|
+
humanoid.Health = 0
|
|
78
|
+
|
|
79
|
+
local promise = maid:Add(RootPartUtils.promisePlayerHumanoidRootPart(player))
|
|
80
|
+
|
|
81
|
+
local ok, rootPart = promise:Yield()
|
|
82
|
+
expect(ok).toBe(true)
|
|
83
|
+
expect(rootPart).toBe(getRootPart(character))
|
|
84
|
+
end)
|
|
85
|
+
|
|
86
|
+
it("resolves once a humanoid is added to a character that spawned without one", function()
|
|
87
|
+
local character = Instance.new("Model")
|
|
88
|
+
|
|
89
|
+
local rootPart = Instance.new("Part")
|
|
90
|
+
rootPart.Name = "HumanoidRootPart"
|
|
91
|
+
rootPart.Anchored = true
|
|
92
|
+
rootPart.Parent = character
|
|
93
|
+
character.PrimaryPart = rootPart
|
|
94
|
+
|
|
95
|
+
PlayerMock.loadCharacterAsync(player, character)
|
|
96
|
+
|
|
97
|
+
local promise = maid:Add(RootPartUtils.promisePlayerHumanoidRootPart(player))
|
|
98
|
+
expect(promise:IsPending()).toBe(true)
|
|
99
|
+
|
|
100
|
+
local humanoid = Instance.new("Humanoid")
|
|
101
|
+
humanoid.Parent = character
|
|
102
|
+
|
|
103
|
+
local ok, resolved = promise:Yield()
|
|
104
|
+
expect(ok).toBe(true)
|
|
105
|
+
expect(resolved).toBe(rootPart)
|
|
106
|
+
end)
|
|
107
|
+
|
|
108
|
+
it("rejects when the player leaves the game", function()
|
|
109
|
+
local promise = maid:Add(RootPartUtils.promisePlayerHumanoidRootPart(player))
|
|
110
|
+
expect(promise:IsPending()).toBe(true)
|
|
111
|
+
|
|
112
|
+
PlayerMock.kick(player)
|
|
113
|
+
|
|
114
|
+
local ok, err = promise:Yield()
|
|
115
|
+
expect(ok).toBe(false)
|
|
116
|
+
expect(err).toBe("Player removed from game")
|
|
117
|
+
end)
|
|
118
|
+
|
|
119
|
+
it("rejects a value that is neither a Player nor a PlayerMock", function()
|
|
120
|
+
local folder = Instance.new("Folder")
|
|
121
|
+
|
|
122
|
+
expect(function()
|
|
123
|
+
RootPartUtils.promisePlayerHumanoidRootPart(folder :: any)
|
|
124
|
+
end).toThrow()
|
|
125
|
+
|
|
126
|
+
folder:Destroy()
|
|
127
|
+
end)
|
|
128
|
+
end)
|
|
129
|
+
|
|
130
|
+
describe("RootPartUtils.getPlayerRootPart", function()
|
|
131
|
+
it("returns the root part of a spawned character", function()
|
|
132
|
+
local character = PlayerMock.loadMinimalCharacterAsync(player)
|
|
133
|
+
|
|
134
|
+
expect(RootPartUtils.getPlayerRootPart(player)).toBe(getRootPart(character))
|
|
135
|
+
end)
|
|
136
|
+
|
|
137
|
+
it("returns nil when the player has no character", function()
|
|
138
|
+
expect(RootPartUtils.getPlayerRootPart(player)).toBe(nil)
|
|
139
|
+
end)
|
|
140
|
+
|
|
141
|
+
it("returns nil once the character is removed", function()
|
|
142
|
+
PlayerMock.loadMinimalCharacterAsync(player)
|
|
143
|
+
PlayerMock.removeCharacter(player)
|
|
144
|
+
|
|
145
|
+
expect(RootPartUtils.getPlayerRootPart(player)).toBe(nil)
|
|
146
|
+
end)
|
|
147
|
+
|
|
148
|
+
it("returns nil for a character without a humanoid", function()
|
|
149
|
+
local character = Instance.new("Model")
|
|
150
|
+
|
|
151
|
+
local rootPart = Instance.new("Part")
|
|
152
|
+
rootPart.Name = "HumanoidRootPart"
|
|
153
|
+
rootPart.Anchored = true
|
|
154
|
+
rootPart.Parent = character
|
|
155
|
+
character.PrimaryPart = rootPart
|
|
156
|
+
|
|
157
|
+
PlayerMock.loadCharacterAsync(player, character)
|
|
158
|
+
|
|
159
|
+
expect(RootPartUtils.getPlayerRootPart(player)).toBe(nil)
|
|
160
|
+
end)
|
|
161
|
+
|
|
162
|
+
it("returns the root part for a dead humanoid", function()
|
|
163
|
+
local character = PlayerMock.loadMinimalCharacterAsync(player)
|
|
164
|
+
local humanoid = character:FindFirstChildOfClass("Humanoid") :: Humanoid
|
|
165
|
+
humanoid.Health = 0
|
|
166
|
+
|
|
167
|
+
expect(RootPartUtils.getPlayerRootPart(player)).toBe(getRootPart(character))
|
|
168
|
+
end)
|
|
169
|
+
|
|
170
|
+
it("rejects a value that is neither a Player nor a PlayerMock", function()
|
|
171
|
+
local folder = Instance.new("Folder")
|
|
172
|
+
|
|
173
|
+
expect(function()
|
|
174
|
+
RootPartUtils.getPlayerRootPart(folder :: any)
|
|
175
|
+
end).toThrow()
|
|
176
|
+
|
|
177
|
+
folder:Destroy()
|
|
178
|
+
end)
|
|
179
|
+
end)
|