@el4cteo/rbx-studio-mcp 0.7.8 → 0.8.0
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/README.md +175 -175
- package/dist/bridge/harness.js +14 -5
- package/dist/bridge/harness.js.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/lib/apidump.js +38 -23
- package/dist/lib/apidump.js.map +1 -1
- package/dist/lib/format.js +43 -4
- package/dist/lib/format.js.map +1 -1
- package/dist/lib/pluginbuild.js +10 -0
- package/dist/lib/pluginbuild.js.map +1 -1
- package/dist/lib/png.js +104 -0
- package/dist/lib/png.js.map +1 -1
- package/dist/resources.js +3 -2
- package/dist/resources.js.map +1 -1
- package/dist/tools/api.js +4 -3
- package/dist/tools/api.js.map +1 -1
- package/dist/tools/debug.js +2 -2
- package/dist/tools/debug.js.map +1 -1
- package/dist/tools/device.js +2 -2
- package/dist/tools/device.js.map +1 -1
- package/dist/tools/discover.js +7 -3
- package/dist/tools/discover.js.map +1 -1
- package/dist/tools/exec.js +4 -4
- package/dist/tools/exec.js.map +1 -1
- package/dist/tools/instances.js +32 -7
- package/dist/tools/instances.js.map +1 -1
- package/dist/tools/screenshot.js +55 -11
- package/dist/tools/screenshot.js.map +1 -1
- package/dist/tools/scripts.js +4 -4
- package/dist/tools/scripts.js.map +1 -1
- package/dist/tools/terrain.js +3 -3
- package/dist/tools/terrain.js.map +1 -1
- package/dist/tools/world.js +8 -8
- package/dist/tools/world.js.map +1 -1
- package/package.json +75 -75
- package/plugin/src/Config.luau +1 -1
- package/plugin/src/LogBuffer.luau +7 -0
- package/plugin/src/Paths.luau +15 -4
- package/plugin/src/Phrase.luau +1 -20
- package/plugin/src/ScriptEdit.luau +14 -0
- package/plugin/src/Serialize.luau +17 -0
- package/plugin/src/TextEdit.luau +6 -0
- package/plugin/src/handlers/Api.luau +8 -0
- package/plugin/src/handlers/Capture.luau +219 -3
- package/plugin/src/init.server.luau +0 -2
- package/scripts/test-tools.mjs +259 -216
- package/dist/tools/anim.js +0 -159
- package/dist/tools/anim.js.map +0 -1
- package/plugin/src/handlers/Anim.luau +0 -897
|
@@ -1,897 +0,0 @@
|
|
|
1
|
-
--!strict
|
|
2
|
-
--[[
|
|
3
|
-
Animations, as data rather than as a timeline.
|
|
4
|
-
|
|
5
|
-
An animation is the one part of a Roblox place this server could not see at
|
|
6
|
-
all. `inspect` on an Animation instance returns an asset id and nothing else
|
|
7
|
-
-- the poses live on Roblox's servers, and the only way to look at them was
|
|
8
|
-
to open the Animation Editor and scrub, which an agent cannot do.
|
|
9
|
-
|
|
10
|
-
`KeyframeSequenceProvider` hands the whole thing over as instances:
|
|
11
|
-
`GetKeyframeSequenceAsync` downloads any animation as a KeyframeSequence, and
|
|
12
|
-
`RegisterKeyframeSequence` goes the other way, turning one built in memory
|
|
13
|
-
into a content id that plays. Measured against a live session: Roblox's own
|
|
14
|
-
run animation came back as 16 keyframes, and a three-pose sequence built here
|
|
15
|
-
registered and returned an id.
|
|
16
|
-
|
|
17
|
-
What that makes possible is reading: "this animation is 0.8s long, it moves
|
|
18
|
-
the right arm and nothing else, and the last keyframe does not return to the
|
|
19
|
-
first" is a sentence nobody could get out of Studio without watching it
|
|
20
|
-
frame by frame.
|
|
21
|
-
|
|
22
|
-
Nothing here uploads. A registered sequence is local to the running Studio,
|
|
23
|
-
which is the correct scope for a preview and useless as a way to publish --
|
|
24
|
-
see the `publish` tool for that.
|
|
25
|
-
]]
|
|
26
|
-
|
|
27
|
-
local KeyframeSequenceProvider = game:GetService("KeyframeSequenceProvider")
|
|
28
|
-
|
|
29
|
-
local Dispatch = require(script.Parent.Parent.Dispatch)
|
|
30
|
-
local Paths = require(script.Parent.Parent.Paths)
|
|
31
|
-
local Serialize = require(script.Parent.Parent.Serialize)
|
|
32
|
-
|
|
33
|
-
local Anim = {}
|
|
34
|
-
|
|
35
|
-
--[[
|
|
36
|
-
Keyframes returned in full detail before the listing thins out.
|
|
37
|
-
|
|
38
|
-
A long animation is hundreds of keyframes and every one carries a pose per
|
|
39
|
-
moving joint, so the honest full dump of a walk cycle is larger than the
|
|
40
|
-
conversation it would appear in. Past this, times are still all reported --
|
|
41
|
-
they are what the shape of an animation is made of -- and only the poses stop
|
|
42
|
-
being expanded.
|
|
43
|
-
]]
|
|
44
|
-
local DETAIL_LIMIT = 40
|
|
45
|
-
|
|
46
|
-
--[[
|
|
47
|
-
The asset id, however the caller wrote it.
|
|
48
|
-
|
|
49
|
-
Ids reach this server as a number, as "rbxassetid://123", and as an
|
|
50
|
-
Animation instance's path, because all three are what someone has in front
|
|
51
|
-
of them at the time. Normalising here means the three do not each need their
|
|
52
|
-
own branch further down.
|
|
53
|
-
]]
|
|
54
|
-
local function contentId(params: { [string]: any }): string
|
|
55
|
-
local raw = params.assetId
|
|
56
|
-
|
|
57
|
-
--[[
|
|
58
|
-
A locally registered sequence, which looks like nothing else.
|
|
59
|
-
|
|
60
|
-
`RegisterKeyframeSequence` returns a bare 32-character hash -- no scheme,
|
|
61
|
-
no digits-only shape -- so the path branch below claimed it and reported
|
|
62
|
-
"no instance at 37f2a7f5...". That made `build` and `read` unable to talk
|
|
63
|
-
to each other, which is the first thing anyone would try.
|
|
64
|
-
|
|
65
|
-
Checked before the path branch because a 32-character hex string is not a
|
|
66
|
-
plausible instance path, and a path that happened to look like one would
|
|
67
|
-
still be reachable by writing it with its parent.
|
|
68
|
-
]]
|
|
69
|
-
if typeof(raw) == "string" and #raw == 32 and string.match(raw, "^%x+$") ~= nil then
|
|
70
|
-
return raw
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
if typeof(raw) == "string" and string.match(raw, "^%d+$") == nil and string.find(raw, "://") == nil then
|
|
74
|
-
-- Not an id at all: a path to an Animation instance in the place.
|
|
75
|
-
local instance = Paths.resolve(raw)
|
|
76
|
-
if not instance:IsA("Animation") then
|
|
77
|
-
Dispatch.fail(
|
|
78
|
-
"BAD_PARAMS",
|
|
79
|
-
string.format("%s is a %s, not an Animation.", raw, instance.ClassName),
|
|
80
|
-
"Pass an animation asset id, or the path of an Animation instance."
|
|
81
|
-
)
|
|
82
|
-
end
|
|
83
|
-
local id = (instance :: Animation).AnimationId
|
|
84
|
-
if id == "" then
|
|
85
|
-
Dispatch.fail("BAD_PARAMS", string.format("%s has no AnimationId set.", raw))
|
|
86
|
-
end
|
|
87
|
-
return id
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
local number = tonumber(raw)
|
|
91
|
-
if number ~= nil then
|
|
92
|
-
return string.format("rbxassetid://%d", math.floor(number))
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
if typeof(raw) == "string" and raw ~= "" then
|
|
96
|
-
return raw
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
Dispatch.fail("BAD_PARAMS", "this needs an `assetId` -- a number, an rbxassetid:// string, or a path to an Animation.")
|
|
100
|
-
return ""
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
--[[
|
|
104
|
-
Every pose in a keyframe, flattened with its path through the rig.
|
|
105
|
-
|
|
106
|
-
Poses nest the way the rig's joints do -- a Pose named "Left Arm" is a child
|
|
107
|
-
of the Pose for the part it is jointed to -- and that structure is how the
|
|
108
|
-
engine resolves them. It is not how a reader wants them: the question is
|
|
109
|
-
"which joints does this animation move", and the answer is a flat list of
|
|
110
|
-
names, with the nesting kept as a path so the rig is still legible.
|
|
111
|
-
]]
|
|
112
|
-
local function flatten(node: Instance, prefix: string, into: { { [string]: any } })
|
|
113
|
-
for _, child in node:GetChildren() do
|
|
114
|
-
if not child:IsA("Pose") then
|
|
115
|
-
continue
|
|
116
|
-
end
|
|
117
|
-
local pose = child :: Pose
|
|
118
|
-
local name = if prefix == "" then pose.Name else prefix .. "/" .. pose.Name
|
|
119
|
-
table.insert(into, {
|
|
120
|
-
joint = name,
|
|
121
|
-
cframe = Serialize.value(pose.CFrame),
|
|
122
|
-
weight = pose.Weight,
|
|
123
|
-
easing = string.format(
|
|
124
|
-
"%s %s",
|
|
125
|
-
tostring(pose.EasingStyle):gsub("Enum%.PoseEasingStyle%.", ""),
|
|
126
|
-
tostring(pose.EasingDirection):gsub("Enum%.PoseEasingDirection%.", "")
|
|
127
|
-
),
|
|
128
|
-
})
|
|
129
|
-
flatten(pose, name, into)
|
|
130
|
-
end
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
--[[
|
|
134
|
-
Which joint hangs off which, for the two rigs Roblox ships.
|
|
135
|
-
|
|
136
|
-
A KeyframeSequence is not a flat list of poses: the engine matches the POSE
|
|
137
|
-
TREE against the rig's joint tree, and a pose whose parent does not join it
|
|
138
|
-
in the rig is ignored -- silently, with no error and no movement. `build`
|
|
139
|
-
used to parent every pose directly under the root, so an animation naming
|
|
140
|
-
"Right Arm" produced an id that loaded, played, reported weight 1, and moved
|
|
141
|
-
nothing at all. Measured on an R6 rig: Motor6D.Transform stayed identity
|
|
142
|
-
through every frame.
|
|
143
|
-
|
|
144
|
-
So poses have to be nested, and nesting needs to know the rig. Read off a
|
|
145
|
-
real rig when one is named, and otherwise off these -- they are the standard
|
|
146
|
-
characters and they do not change.
|
|
147
|
-
]]
|
|
148
|
-
local R6_PARENT: { [string]: string } = {
|
|
149
|
-
Torso = "HumanoidRootPart",
|
|
150
|
-
Head = "Torso",
|
|
151
|
-
["Left Arm"] = "Torso",
|
|
152
|
-
["Right Arm"] = "Torso",
|
|
153
|
-
["Left Leg"] = "Torso",
|
|
154
|
-
["Right Leg"] = "Torso",
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
local R15_PARENT: { [string]: string } = {
|
|
158
|
-
LowerTorso = "HumanoidRootPart",
|
|
159
|
-
UpperTorso = "LowerTorso",
|
|
160
|
-
Head = "UpperTorso",
|
|
161
|
-
LeftUpperArm = "UpperTorso",
|
|
162
|
-
LeftLowerArm = "LeftUpperArm",
|
|
163
|
-
LeftHand = "LeftLowerArm",
|
|
164
|
-
RightUpperArm = "UpperTorso",
|
|
165
|
-
RightLowerArm = "RightUpperArm",
|
|
166
|
-
RightHand = "RightLowerArm",
|
|
167
|
-
LeftUpperLeg = "LowerTorso",
|
|
168
|
-
LeftLowerLeg = "LeftUpperLeg",
|
|
169
|
-
LeftFoot = "LeftLowerLeg",
|
|
170
|
-
RightUpperLeg = "LowerTorso",
|
|
171
|
-
RightLowerLeg = "RightUpperLeg",
|
|
172
|
-
RightFoot = "RightLowerLeg",
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
local R6_PARTS = {
|
|
176
|
-
["Left Arm"] = true,
|
|
177
|
-
["Right Arm"] = true,
|
|
178
|
-
["Left Leg"] = true,
|
|
179
|
-
["Right Leg"] = true,
|
|
180
|
-
["Torso"] = true,
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
local R15_PARTS = {
|
|
184
|
-
UpperTorso = true,
|
|
185
|
-
LowerTorso = true,
|
|
186
|
-
LeftUpperArm = true,
|
|
187
|
-
RightUpperArm = true,
|
|
188
|
-
LeftUpperLeg = true,
|
|
189
|
-
RightUpperLeg = true,
|
|
190
|
-
LeftLowerArm = true,
|
|
191
|
-
RightLowerArm = true,
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
--[[
|
|
195
|
-
Which rig an animation was made for, read off the joints it moves.
|
|
196
|
-
|
|
197
|
-
This matters more than it sounds. An R15 animation played on an R6 character
|
|
198
|
-
does nothing at all -- no error, no warning, no movement -- because the joint
|
|
199
|
-
names it addresses ("LeftUpperArm") do not exist on that rig ("Left Arm").
|
|
200
|
-
It is one of the quietest failures on the platform, and the asset id carries
|
|
201
|
-
no hint of which kind it is.
|
|
202
|
-
|
|
203
|
-
The two skeletons are told apart by naming rather than by counting. R6 uses
|
|
204
|
-
six limbs with spaces in their names, unchanged since 2007. R15 splits each
|
|
205
|
-
limb in two and writes them without spaces. Anything that matches neither is
|
|
206
|
-
a custom rig, and is reported as such rather than guessed at -- a wrong
|
|
207
|
-
confident answer here is worse than "unknown", because the whole point of
|
|
208
|
-
the field is to be trusted.
|
|
209
|
-
]]
|
|
210
|
-
local function rigOf(joints: { [string]: boolean }): (string, string?)
|
|
211
|
-
local r6, r15 = 0, 0
|
|
212
|
-
for joint in joints do
|
|
213
|
-
if R6_PARTS[joint] then
|
|
214
|
-
r6 += 1
|
|
215
|
-
elseif R15_PARTS[joint] then
|
|
216
|
-
r15 += 1
|
|
217
|
-
end
|
|
218
|
-
end
|
|
219
|
-
|
|
220
|
-
local rig = "custom"
|
|
221
|
-
if r15 > 0 and r15 >= r6 then
|
|
222
|
-
rig = "R15"
|
|
223
|
-
elseif r6 > 0 then
|
|
224
|
-
rig = "R6"
|
|
225
|
-
end
|
|
226
|
-
|
|
227
|
-
--[[
|
|
228
|
-
A tool animation is not a third rig; it is an ordinary one that only
|
|
229
|
-
moves the arm holding the tool. Roblox's own "toolnone" moves the right
|
|
230
|
-
arm and the torso and nothing else, which is what this recognises --
|
|
231
|
-
useful because such an animation looks broken when read as a character
|
|
232
|
-
animation ("it only moves two joints") and is in fact complete.
|
|
233
|
-
]]
|
|
234
|
-
local arms = 0
|
|
235
|
-
local others = 0
|
|
236
|
-
for joint in joints do
|
|
237
|
-
if string.find(joint, "Arm") ~= nil or string.find(joint, "Hand") ~= nil then
|
|
238
|
-
arms += 1
|
|
239
|
-
elseif joint ~= "HumanoidRootPart" and joint ~= "Torso" and joint ~= "UpperTorso" then
|
|
240
|
-
others += 1
|
|
241
|
-
end
|
|
242
|
-
end
|
|
243
|
-
local shape: string? = nil
|
|
244
|
-
if arms > 0 and others == 0 then
|
|
245
|
-
shape = "arm-only -- the shape of a tool or holding animation"
|
|
246
|
-
end
|
|
247
|
-
|
|
248
|
-
return rig, shape
|
|
249
|
-
end
|
|
250
|
-
|
|
251
|
-
--[[
|
|
252
|
-
Downloads an animation and describes it.
|
|
253
|
-
|
|
254
|
-
The summary is the point, not the dump. `duration`, which joints move at all,
|
|
255
|
-
and whether the last keyframe matches the first are the three things that
|
|
256
|
-
decide whether an animation is wrong, and none of them is visible from the
|
|
257
|
-
asset id that `inspect` returns today.
|
|
258
|
-
]]
|
|
259
|
-
function Anim.read(params: { [string]: any }): { [string]: any }
|
|
260
|
-
local id = contentId(params)
|
|
261
|
-
|
|
262
|
-
local ok, sequence = pcall(function()
|
|
263
|
-
return KeyframeSequenceProvider:GetKeyframeSequenceAsync(id)
|
|
264
|
-
end)
|
|
265
|
-
if not ok then
|
|
266
|
-
Dispatch.fail(
|
|
267
|
-
"ANIMATION_UNAVAILABLE",
|
|
268
|
-
string.format("Could not read %s: %s", id, tostring(sequence)),
|
|
269
|
-
"The animation must be public, or owned by the account signed into Studio."
|
|
270
|
-
)
|
|
271
|
-
end
|
|
272
|
-
|
|
273
|
-
local sequenceInstance = sequence :: KeyframeSequence
|
|
274
|
-
local keyframes = sequenceInstance:GetKeyframes()
|
|
275
|
-
table.sort(keyframes, function(a, b)
|
|
276
|
-
return (a :: Keyframe).Time < (b :: Keyframe).Time
|
|
277
|
-
end)
|
|
278
|
-
|
|
279
|
-
local joints: { [string]: boolean } = {}
|
|
280
|
-
local rows: { { [string]: any } } = {}
|
|
281
|
-
local duration = 0
|
|
282
|
-
local firstValue: { [string]: string } = {}
|
|
283
|
-
local lastValue: { [string]: string } = {}
|
|
284
|
-
|
|
285
|
-
for index, frame in keyframes do
|
|
286
|
-
local keyframe = frame :: Keyframe
|
|
287
|
-
duration = math.max(duration, keyframe.Time)
|
|
288
|
-
|
|
289
|
-
local poses: { { [string]: any } } = {}
|
|
290
|
-
flatten(keyframe, "", poses)
|
|
291
|
-
for _, pose in poses do
|
|
292
|
-
--[[
|
|
293
|
-
First and last value per joint, for the loop check below.
|
|
294
|
-
|
|
295
|
-
Recorded as they are walked because keyframes are already in time
|
|
296
|
-
order here, so "last seen" is the final value by the time the walk
|
|
297
|
-
ends -- no second pass and no sorting.
|
|
298
|
-
]]
|
|
299
|
-
--[[
|
|
300
|
-
Keyed by leaf name, never by the path it was found under.
|
|
301
|
-
|
|
302
|
-
The same joint does not keep the same path across an animation.
|
|
303
|
-
Measured on Roblox's own "toolnone": the right arm is nested under
|
|
304
|
-
`HumanoidRootPart/Torso/Right Arm` in the first keyframe and under
|
|
305
|
-
`Torso/Right Arm` in every keyframe after it -- same joint, same
|
|
306
|
-
rig, two different strings. Keying on the path therefore split one
|
|
307
|
-
joint into two, and each half looked like it had no matching end
|
|
308
|
-
value, so a perfectly clean loop was reported as drifting on two
|
|
309
|
-
joints. The values themselves were identical the whole time.
|
|
310
|
-
]]
|
|
311
|
-
local path = tostring(pose.joint)
|
|
312
|
-
local key = string.match(path, "([^/]+)$") or path
|
|
313
|
-
if firstValue[key] == nil then
|
|
314
|
-
firstValue[key] = tostring(pose.cframe)
|
|
315
|
-
end
|
|
316
|
-
lastValue[key] = tostring(pose.cframe)
|
|
317
|
-
end
|
|
318
|
-
for _, pose in poses do
|
|
319
|
-
--[[
|
|
320
|
-
The leaf name, not the path it came down.
|
|
321
|
-
|
|
322
|
-
A face-rigged R15 has 109 joints and their full paths run six
|
|
323
|
-
segments deep, so the honest list of them is several thousand
|
|
324
|
-
characters of "HumanoidRootPart/LowerTorso/UpperTorso/..." repeated
|
|
325
|
-
with a different ending each time. That is the whole reply, for a
|
|
326
|
-
summary line nobody can read. The paths are still on each pose,
|
|
327
|
-
where the nesting is the point; here the question is only which
|
|
328
|
-
joints move.
|
|
329
|
-
]]
|
|
330
|
-
local path = tostring(pose.joint)
|
|
331
|
-
joints[string.match(path, "([^/]+)$") or path] = true
|
|
332
|
-
end
|
|
333
|
-
|
|
334
|
-
table.insert(rows, {
|
|
335
|
-
index = index,
|
|
336
|
-
time = math.round(keyframe.Time * 1000) / 1000,
|
|
337
|
-
name = if keyframe.Name ~= "" then keyframe.Name else nil,
|
|
338
|
-
poseCount = #poses,
|
|
339
|
-
-- Poses only while the listing is small enough to be worth reading.
|
|
340
|
-
poses = if index <= DETAIL_LIMIT then poses else nil,
|
|
341
|
-
})
|
|
342
|
-
end
|
|
343
|
-
|
|
344
|
-
local rig, shape = rigOf(joints)
|
|
345
|
-
|
|
346
|
-
local moved: { string } = {}
|
|
347
|
-
for joint in joints do
|
|
348
|
-
table.insert(moved, joint)
|
|
349
|
-
end
|
|
350
|
-
table.sort(moved)
|
|
351
|
-
local count = #moved
|
|
352
|
-
|
|
353
|
-
--[[
|
|
354
|
-
Capped, with the count kept separate.
|
|
355
|
-
|
|
356
|
-
`jointCount` is the number that answers "does this animate the whole body
|
|
357
|
-
or one arm", and it stays exact. The names are a sample past this point,
|
|
358
|
-
because a rigged face contributes eighty of them and nobody reading a
|
|
359
|
-
summary needs to see `L_eyeD` spelled out.
|
|
360
|
-
]]
|
|
361
|
-
local JOINT_SAMPLE = 24
|
|
362
|
-
local sampled = #moved > JOINT_SAMPLE
|
|
363
|
-
if sampled then
|
|
364
|
-
local short: { string } = {}
|
|
365
|
-
table.move(moved, 1, JOINT_SAMPLE, 1, short)
|
|
366
|
-
moved = short
|
|
367
|
-
end
|
|
368
|
-
|
|
369
|
-
--[[
|
|
370
|
-
Whether the animation ends where it began.
|
|
371
|
-
|
|
372
|
-
This is the most common fault in a looping animation and it is invisible
|
|
373
|
-
in the editor until you watch the loop point twice.
|
|
374
|
-
|
|
375
|
-
Compared on the VALUES each joint ends at against the values it started
|
|
376
|
-
at. An earlier version compared how many poses the first and last
|
|
377
|
-
keyframes carried, and that was wrong in a way worth recording: a
|
|
378
|
-
keyframe that omits a joint is not moving it back to anywhere, it is
|
|
379
|
-
leaving it where the previous keyframe put it. Roblox's own "toolnone"
|
|
380
|
-
animation poses three joints at the start and two afterwards, and the
|
|
381
|
-
count test called it broken -- a false alarm on the platform's own asset,
|
|
382
|
-
which is about the clearest signal that the test was measuring the wrong
|
|
383
|
-
thing.
|
|
384
|
-
|
|
385
|
-
String comparison of the serialised CFrames, which is exact here: both
|
|
386
|
-
values come from the same downloaded sequence, so there is no rounding
|
|
387
|
-
between them to produce a phantom difference.
|
|
388
|
-
]]
|
|
389
|
-
local drifted: { string } = {}
|
|
390
|
-
for joint, startValue in firstValue do
|
|
391
|
-
if lastValue[joint] ~= startValue then
|
|
392
|
-
if #drifted < 8 then
|
|
393
|
-
table.insert(drifted, joint)
|
|
394
|
-
end
|
|
395
|
-
end
|
|
396
|
-
end
|
|
397
|
-
table.sort(drifted)
|
|
398
|
-
local loops: boolean? = if #rows >= 2 then #drifted == 0 else nil
|
|
399
|
-
|
|
400
|
-
return {
|
|
401
|
-
assetId = id,
|
|
402
|
-
name = if sequenceInstance.Name ~= "" then sequenceInstance.Name else nil,
|
|
403
|
-
priority = tostring(sequenceInstance.Priority):gsub("Enum%.AnimationPriority%.", ""),
|
|
404
|
-
looping = sequenceInstance.Loop,
|
|
405
|
-
duration = math.round(duration * 1000) / 1000,
|
|
406
|
-
keyframeCount = #keyframes,
|
|
407
|
-
joints = moved,
|
|
408
|
-
jointCount = count,
|
|
409
|
-
jointsSampled = sampled,
|
|
410
|
-
keyframes = rows,
|
|
411
|
-
endsWhereItStarted = loops,
|
|
412
|
-
-- Named, not just counted: "the right arm does not return" is actionable
|
|
413
|
-
-- where "it does not loop cleanly" sends someone to look at all of it.
|
|
414
|
-
driftingJoints = if #drifted > 0 then drifted else nil,
|
|
415
|
-
rig = rig,
|
|
416
|
-
rigNote = shape,
|
|
417
|
-
truncated = #keyframes > DETAIL_LIMIT,
|
|
418
|
-
}
|
|
419
|
-
end
|
|
420
|
-
|
|
421
|
-
--[[
|
|
422
|
-
The joint tree to nest poses against.
|
|
423
|
-
|
|
424
|
-
A named rig wins, because it is the truth: a custom rig, a tool, a door, a
|
|
425
|
-
Blender import with its own bone names -- none of those are in the tables
|
|
426
|
-
above, and all of them are ordinary things to animate. The rig's own Motor6Ds
|
|
427
|
-
say exactly which part hangs off which.
|
|
428
|
-
|
|
429
|
-
Without one, the pose names pick the map. Every R15 name is unique to R15, so
|
|
430
|
-
one of them appearing settles it; "Head" alone is ambiguous and falls to R6,
|
|
431
|
-
which is the older and likelier default for a hand-written animation.
|
|
432
|
-
]]
|
|
433
|
-
local function parentMap(params: { [string]: any }, joints: { [string]: boolean }): ({ [string]: string }, string)
|
|
434
|
-
if typeof(params.rig) == "string" and params.rig ~= "" then
|
|
435
|
-
local model = Paths.resolve(params.rig)
|
|
436
|
-
local map: { [string]: string } = {}
|
|
437
|
-
local found = 0
|
|
438
|
-
for _, descendant in model:GetDescendants() do
|
|
439
|
-
if descendant:IsA("Motor6D") then
|
|
440
|
-
local zero, one = descendant.Part0, descendant.Part1
|
|
441
|
-
if zero ~= nil and one ~= nil then
|
|
442
|
-
map[one.Name] = zero.Name
|
|
443
|
-
found += 1
|
|
444
|
-
end
|
|
445
|
-
end
|
|
446
|
-
end
|
|
447
|
-
if found == 0 then
|
|
448
|
-
Dispatch.fail(
|
|
449
|
-
"NO_RIG",
|
|
450
|
-
string.format("%s has no Motor6D joints, so there is no hierarchy to build against.", params.rig),
|
|
451
|
-
"Point `rig` at the model whose parts the poses name."
|
|
452
|
-
)
|
|
453
|
-
end
|
|
454
|
-
return map, "rig"
|
|
455
|
-
end
|
|
456
|
-
|
|
457
|
-
for joint in joints do
|
|
458
|
-
if R15_PARENT[joint] ~= nil and R6_PARENT[joint] == nil then
|
|
459
|
-
return R15_PARENT, "R15"
|
|
460
|
-
end
|
|
461
|
-
end
|
|
462
|
-
return R6_PARENT, "R6"
|
|
463
|
-
end
|
|
464
|
-
|
|
465
|
-
--[[
|
|
466
|
-
Builds a KeyframeSequence in memory and registers it for playback.
|
|
467
|
-
|
|
468
|
-
The registration is what makes this more than an instance tree: it returns a
|
|
469
|
-
content id that an Animation can use immediately, in this Studio, with no
|
|
470
|
-
upload and no moderation wait. That is the whole loop an agent needs to try
|
|
471
|
-
an idea -- build, set AnimationId, look -- and it is entirely reversible,
|
|
472
|
-
because nothing left the session.
|
|
473
|
-
|
|
474
|
-
Poses arrive flat, named by joint, and are nested under one root pose here.
|
|
475
|
-
Flat is what a caller can write and nesting is what the engine resolves, and
|
|
476
|
-
the rig's own hierarchy is not knowable from a list of joint names -- so a
|
|
477
|
-
single root is the honest structure: it animates the named joints and claims
|
|
478
|
-
nothing about how they connect.
|
|
479
|
-
]]
|
|
480
|
-
function Anim.build(params: { [string]: any }): { [string]: any }
|
|
481
|
-
local frames = params.keyframes
|
|
482
|
-
if typeof(frames) ~= "table" or #(frames :: { any }) == 0 then
|
|
483
|
-
Dispatch.fail(
|
|
484
|
-
"BAD_PARAMS",
|
|
485
|
-
"build needs a non-empty `keyframes` array.",
|
|
486
|
-
'Each entry is { time = 0.5, poses = { ["Right Arm"] = "0, 1, 0 | 0, 45, 0" } }.'
|
|
487
|
-
)
|
|
488
|
-
end
|
|
489
|
-
|
|
490
|
-
local sequence = Instance.new("KeyframeSequence")
|
|
491
|
-
sequence.Name = if typeof(params.name) == "string" and params.name ~= "" then params.name else "MCPAnimation"
|
|
492
|
-
sequence.Loop = params.loop == true
|
|
493
|
-
if typeof(params.priority) == "string" and params.priority ~= "" then
|
|
494
|
-
local okPriority = pcall(function()
|
|
495
|
-
sequence.Priority = (Enum :: any).AnimationPriority[params.priority]
|
|
496
|
-
end)
|
|
497
|
-
if not okPriority then
|
|
498
|
-
sequence:Destroy()
|
|
499
|
-
Dispatch.fail(
|
|
500
|
-
"BAD_PARAMS",
|
|
501
|
-
string.format("%q is not an AnimationPriority.", tostring(params.priority)),
|
|
502
|
-
'Use "Idle", "Movement", "Action" or "Core".'
|
|
503
|
-
)
|
|
504
|
-
end
|
|
505
|
-
end
|
|
506
|
-
|
|
507
|
-
local rootName = if typeof(params.root) == "string" and params.root ~= "" then params.root else "HumanoidRootPart"
|
|
508
|
-
local count = 0
|
|
509
|
-
|
|
510
|
-
-- Every joint the caller names, gathered before anything is built, because
|
|
511
|
-
-- the rig guess reads the whole set rather than one keyframe's worth.
|
|
512
|
-
local joints: { [string]: boolean } = {}
|
|
513
|
-
for _, entry in frames :: { any } do
|
|
514
|
-
if typeof(entry) == "table" and typeof((entry :: any).poses) == "table" then
|
|
515
|
-
for joint in (entry :: any).poses :: { [string]: any } do
|
|
516
|
-
joints[tostring(joint)] = true
|
|
517
|
-
end
|
|
518
|
-
end
|
|
519
|
-
end
|
|
520
|
-
|
|
521
|
-
local parentOf, hierarchy = parentMap(params, joints)
|
|
522
|
-
|
|
523
|
-
local unmatched: { string } = {}
|
|
524
|
-
for joint in joints do
|
|
525
|
-
if joint ~= rootName and parentOf[joint] == nil then
|
|
526
|
-
table.insert(unmatched, joint)
|
|
527
|
-
end
|
|
528
|
-
end
|
|
529
|
-
table.sort(unmatched)
|
|
530
|
-
|
|
531
|
-
for position, entry in frames :: { any } do
|
|
532
|
-
if typeof(entry) ~= "table" then
|
|
533
|
-
sequence:Destroy()
|
|
534
|
-
Dispatch.fail("BAD_PARAMS", string.format("keyframe %d is not an object.", position))
|
|
535
|
-
end
|
|
536
|
-
local frame = entry :: { [string]: any }
|
|
537
|
-
|
|
538
|
-
local keyframe = Instance.new("Keyframe")
|
|
539
|
-
keyframe.Time = tonumber(frame.time) or 0
|
|
540
|
-
if typeof(frame.name) == "string" and frame.name ~= "" then
|
|
541
|
-
keyframe.Name = frame.name
|
|
542
|
-
end
|
|
543
|
-
|
|
544
|
-
local root = Instance.new("Pose")
|
|
545
|
-
root.Name = rootName
|
|
546
|
-
root.Weight = 1
|
|
547
|
-
root.Parent = keyframe
|
|
548
|
-
|
|
549
|
-
--[[
|
|
550
|
-
Poses placed where the rig says they belong, creating whatever links
|
|
551
|
-
the chain needs along the way.
|
|
552
|
-
|
|
553
|
-
A pose for "Right Arm" is only read if its parent pose is "Torso",
|
|
554
|
-
because that is the joint that moves it -- so animating a hand means
|
|
555
|
-
the whole arm appears in the tree whether or not the caller mentioned
|
|
556
|
-
it. Those filler poses carry Weight 0: they exist to hold the
|
|
557
|
-
hierarchy together, and a weight of 1 would pin the joint they name to
|
|
558
|
-
its rest pose, quietly cancelling any movement the caller did ask for.
|
|
559
|
-
]]
|
|
560
|
-
local placed: { [string]: Pose } = {}
|
|
561
|
-
local function poseFor(joint: string): Pose
|
|
562
|
-
if joint == rootName then
|
|
563
|
-
return root
|
|
564
|
-
end
|
|
565
|
-
local existing = placed[joint]
|
|
566
|
-
if existing ~= nil then
|
|
567
|
-
return existing
|
|
568
|
-
end
|
|
569
|
-
local pose = Instance.new("Pose")
|
|
570
|
-
pose.Name = joint
|
|
571
|
-
pose.CFrame = CFrame.new()
|
|
572
|
-
pose.Weight = 0
|
|
573
|
-
local above = parentOf[joint]
|
|
574
|
-
-- An unknown joint hangs off the root, which is what this did for
|
|
575
|
-
-- every joint before. It is reported rather than assumed correct.
|
|
576
|
-
pose.Parent = if above ~= nil and above ~= joint then poseFor(above) else root
|
|
577
|
-
placed[joint] = pose
|
|
578
|
-
return pose
|
|
579
|
-
end
|
|
580
|
-
|
|
581
|
-
if typeof(frame.poses) == "table" then
|
|
582
|
-
for joint, value in frame.poses :: { [string]: any } do
|
|
583
|
-
local okValue, cframe, why = Serialize.parse(tostring(value), "CFrame")
|
|
584
|
-
if not okValue then
|
|
585
|
-
sequence:Destroy()
|
|
586
|
-
Dispatch.fail(
|
|
587
|
-
"BAD_PARAMS",
|
|
588
|
-
string.format("keyframe %d, joint %q: %s", position, tostring(joint), tostring(why)),
|
|
589
|
-
'A pose is written like a CFrame property: "0, 1, 0" or "0, 1, 0 | 0, 45, 0".'
|
|
590
|
-
)
|
|
591
|
-
end
|
|
592
|
-
local name = tostring(joint)
|
|
593
|
-
local pose = poseFor(name)
|
|
594
|
-
pose.CFrame = cframe :: CFrame
|
|
595
|
-
pose.Weight = 1
|
|
596
|
-
count += 1
|
|
597
|
-
end
|
|
598
|
-
end
|
|
599
|
-
|
|
600
|
-
keyframe.Parent = sequence
|
|
601
|
-
end
|
|
602
|
-
|
|
603
|
-
local ok, id = pcall(function()
|
|
604
|
-
return KeyframeSequenceProvider:RegisterKeyframeSequence(sequence)
|
|
605
|
-
end)
|
|
606
|
-
-- Destroyed either way: registration copies what it needs, and the instance
|
|
607
|
-
-- was never in the data model to begin with.
|
|
608
|
-
sequence:Destroy()
|
|
609
|
-
|
|
610
|
-
if not ok then
|
|
611
|
-
Dispatch.fail("ANIMATION_FAILED", string.format("Could not register the sequence: %s", tostring(id)))
|
|
612
|
-
end
|
|
613
|
-
|
|
614
|
-
return {
|
|
615
|
-
animationId = tostring(id),
|
|
616
|
-
keyframeCount = #(frames :: { any }),
|
|
617
|
-
poseCount = count,
|
|
618
|
-
hierarchy = hierarchy,
|
|
619
|
-
-- Named, because a joint the rig does not have produces an animation that
|
|
620
|
-
-- plays perfectly and moves nothing -- the one failure here with no
|
|
621
|
-
-- symptom at all.
|
|
622
|
-
unmatchedJoints = if #unmatched > 0 then unmatched else nil,
|
|
623
|
-
--[[
|
|
624
|
-
Used exactly as it comes back, with nothing added in front of it.
|
|
625
|
-
|
|
626
|
-
`RegisterKeyframeSequence` returns a bare hash rather than anything
|
|
627
|
-
that looks like a content id, which invites a caller to "fix" it into
|
|
628
|
-
`rbxasset://<hash>`. Measured: the bare string sets on
|
|
629
|
-
`Animation.AnimationId` and reads back through
|
|
630
|
-
`GetKeyframeSequenceAsync`; the prefixed one fails both. So the shape
|
|
631
|
-
is correct and the instinct to correct it is the bug.
|
|
632
|
-
]]
|
|
633
|
-
note = "Use this id verbatim as an Animation's AnimationId -- do NOT put "
|
|
634
|
-
.. "rbxassetid:// or rbxasset:// in front of it, which stops it working. "
|
|
635
|
-
.. "It plays in this Studio session only: not uploaded, and gone on restart.",
|
|
636
|
-
}
|
|
637
|
-
end
|
|
638
|
-
|
|
639
|
-
--[[
|
|
640
|
-
Puts a rig back the way it was before anything was played on it.
|
|
641
|
-
|
|
642
|
-
Four steps, and every one of them is load-bearing -- this took three wrong
|
|
643
|
-
versions to get right, so the reasoning is written down rather than left to
|
|
644
|
-
be rediscovered.
|
|
645
|
-
|
|
646
|
-
1. Speed back to 1 before stopping. `preview` freezes a pose by setting speed
|
|
647
|
-
to 0, and a fade of length 0 at speed 0 never finishes.
|
|
648
|
-
2. Step, so the stop is actually processed. Nothing steps an Animator in edit
|
|
649
|
-
mode, so `Stop` on its own leaves the track in
|
|
650
|
-
`GetPlayingAnimationTracks` and leaves the rig bent, while the call
|
|
651
|
-
reports it stopped.
|
|
652
|
-
3. Clear every `Motor6D.Transform` by hand. Removing the track does NOT clear
|
|
653
|
-
the transforms it wrote -- with no track left there is nothing to write
|
|
654
|
-
anything, so the last animated pose simply stays. Measured: track count 0,
|
|
655
|
-
shoulder transform still (0, 0, 0.25).
|
|
656
|
-
4. Step once more. Writing `Transform` changes nothing by itself in edit
|
|
657
|
-
mode; the step is what makes the engine rebuild the part CFrames from the
|
|
658
|
-
joints, and that is the frame the rig comes back on.
|
|
659
|
-
|
|
660
|
-
The tempting shortcut -- compute each joint's rest CFrame and write it to the
|
|
661
|
-
part -- is worse than doing nothing. Setting `CFrame` on a part jointed to
|
|
662
|
-
others moves the WHOLE assembly, so placing the head shifted the body,
|
|
663
|
-
placing the arm shifted it again, and the rig ended up floating and rotated
|
|
664
|
-
with its anchored root dragged along. Measured: an R6 rig came out at
|
|
665
|
-
(0.08, 3.86, 0.37) rotated 67 degrees after a "restore".
|
|
666
|
-
]]
|
|
667
|
-
local function restPose(model: Instance, animator: Animator)
|
|
668
|
-
for _, track in animator:GetPlayingAnimationTracks() do
|
|
669
|
-
track:AdjustSpeed(1)
|
|
670
|
-
track:Stop(0)
|
|
671
|
-
end
|
|
672
|
-
|
|
673
|
-
--[[
|
|
674
|
-
Stepped until the stop has actually finished, which takes longer than it
|
|
675
|
-
sounds like it should.
|
|
676
|
-
|
|
677
|
-
`Stop(0)` asks for no fade and does not get one: the track's weight comes
|
|
678
|
-
down over frames regardless, and it stays in
|
|
679
|
-
`GetPlayingAnimationTracks` the whole time -- with `IsPlaying` already
|
|
680
|
-
false, so list membership is not a measure of anything. Measured on an R6
|
|
681
|
-
rig: eleven steps, weight falling 1 -> 0.61 -> 0, and the track vanished
|
|
682
|
-
on the eleventh.
|
|
683
|
-
|
|
684
|
-
Guessing the number is how this went wrong twice. One step left the rig
|
|
685
|
-
bent; six steps was worse than one, because the fade was still running
|
|
686
|
-
when the transforms below were cleared and the next step wrote the
|
|
687
|
-
half-faded pose straight back over them. So the loop watches for the
|
|
688
|
-
answer, and the cap is a second of simulated time -- far past any fade,
|
|
689
|
-
and still a bound rather than a spin.
|
|
690
|
-
]]
|
|
691
|
-
for _ = 1, 60 do
|
|
692
|
-
if #animator:GetPlayingAnimationTracks() == 0 then
|
|
693
|
-
break
|
|
694
|
-
end
|
|
695
|
-
animator:StepAnimations(1 / 60)
|
|
696
|
-
end
|
|
697
|
-
|
|
698
|
-
for _, descendant in model:GetDescendants() do
|
|
699
|
-
if descendant:IsA("Motor6D") then
|
|
700
|
-
descendant.Transform = CFrame.new()
|
|
701
|
-
end
|
|
702
|
-
end
|
|
703
|
-
animator:StepAnimations(1 / 60)
|
|
704
|
-
end
|
|
705
|
-
|
|
706
|
-
--[[
|
|
707
|
-
Plays an animation on a real rig, in edit mode, so it can be looked at.
|
|
708
|
-
|
|
709
|
-
`read` says what an animation contains and `build` makes one; neither answers
|
|
710
|
-
"does it look right", which for a piece of motion is most of the question.
|
|
711
|
-
Measured: an `Animator` loads and plays a track in an ordinary edit session
|
|
712
|
-
-- `Length` comes back populated and `IsPlaying` is true -- so the rig in the
|
|
713
|
-
place can be posed without pressing Play.
|
|
714
|
-
|
|
715
|
-
The frame is the point. Playing an animation and returning immediately shows
|
|
716
|
-
whatever pose the rig happened to be in, so this seeks to a chosen moment and
|
|
717
|
-
holds it there: play, jump to `at`, pause. Then `screenshot` sees that exact
|
|
718
|
-
pose, and asking for three different moments gives three comparable frames.
|
|
719
|
-
|
|
720
|
-
Deliberately leaves the rig posed rather than restoring it. The whole purpose
|
|
721
|
-
is to leave something on screen to photograph, and a tool that tidied up
|
|
722
|
-
before returning would always photograph the idle pose. `op="stop"` puts it
|
|
723
|
-
back.
|
|
724
|
-
]]
|
|
725
|
-
function Anim.preview(params: { [string]: any }): { [string]: any }
|
|
726
|
-
local rigPath = params.rig
|
|
727
|
-
if typeof(rigPath) ~= "string" or rigPath == "" then
|
|
728
|
-
Dispatch.fail(
|
|
729
|
-
"BAD_PARAMS",
|
|
730
|
-
"preview needs a `rig` -- the path of a model with a Humanoid or an AnimationController.",
|
|
731
|
-
'Find one with `find selector="Humanoid"`.'
|
|
732
|
-
)
|
|
733
|
-
end
|
|
734
|
-
|
|
735
|
-
local model = Paths.resolve(rigPath)
|
|
736
|
-
local animator: Animator? = nil
|
|
737
|
-
|
|
738
|
-
--[[
|
|
739
|
-
The Animator, wherever it lives.
|
|
740
|
-
|
|
741
|
-
A character rig keeps it under the Humanoid; a non-character rig keeps it
|
|
742
|
-
under an AnimationController. Both are ordinary in a place, and a tool
|
|
743
|
-
that only knew about one of them would refuse half the rigs it was
|
|
744
|
-
pointed at. Created if missing, because a rig imported from Blender often
|
|
745
|
-
has the controller and not the animator, and adding one is what Studio
|
|
746
|
-
does too.
|
|
747
|
-
]]
|
|
748
|
-
local host = model:FindFirstChildWhichIsA("Humanoid") or model:FindFirstChildWhichIsA("AnimationController")
|
|
749
|
-
if host == nil then
|
|
750
|
-
Dispatch.fail(
|
|
751
|
-
"NO_RIG",
|
|
752
|
-
string.format("%s has no Humanoid or AnimationController, so nothing can play an animation on it.", rigPath)
|
|
753
|
-
)
|
|
754
|
-
end
|
|
755
|
-
animator = (host :: Instance):FindFirstChildWhichIsA("Animator")
|
|
756
|
-
if animator == nil then
|
|
757
|
-
local created = Instance.new("Animator")
|
|
758
|
-
created.Parent = host
|
|
759
|
-
animator = created
|
|
760
|
-
end
|
|
761
|
-
|
|
762
|
-
if params.op == "stop" then
|
|
763
|
-
local stopped = 0
|
|
764
|
-
for _, track in (animator :: Animator):GetPlayingAnimationTracks() do
|
|
765
|
-
track:Stop(0)
|
|
766
|
-
stopped += 1
|
|
767
|
-
end
|
|
768
|
-
restPose(model, animator :: Animator)
|
|
769
|
-
local left = #(animator :: Animator):GetPlayingAnimationTracks()
|
|
770
|
-
return {
|
|
771
|
-
rig = Paths.of(model),
|
|
772
|
-
stopped = stopped,
|
|
773
|
-
stillPlaying = if left > 0 then left else nil,
|
|
774
|
-
note = if left > 0
|
|
775
|
-
then "Some tracks are still playing, so the rig may still be posed."
|
|
776
|
-
else "The rig is back in its rest pose.",
|
|
777
|
-
}
|
|
778
|
-
end
|
|
779
|
-
|
|
780
|
-
local id = contentId(params)
|
|
781
|
-
local animation = Instance.new("Animation")
|
|
782
|
-
animation.AnimationId = id
|
|
783
|
-
|
|
784
|
-
local okLoad, track = pcall(function()
|
|
785
|
-
return (animator :: Animator):LoadAnimation(animation)
|
|
786
|
-
end)
|
|
787
|
-
if not okLoad then
|
|
788
|
-
animation:Destroy()
|
|
789
|
-
Dispatch.fail("ANIMATION_FAILED", string.format("Could not load %s: %s", id, tostring(track)))
|
|
790
|
-
end
|
|
791
|
-
|
|
792
|
-
local clip = track :: AnimationTrack
|
|
793
|
-
|
|
794
|
-
--[[
|
|
795
|
-
Waited for, because a freshly loaded track has no length yet.
|
|
796
|
-
|
|
797
|
-
`LoadAnimation` returns before the asset has been fetched, and a track
|
|
798
|
-
with `Length == 0` cannot be seeked -- an unwaited preview silently shows
|
|
799
|
-
frame zero of everything. Measured: the length arrives within a frame or
|
|
800
|
-
two of a warm cache and takes noticeably longer on a cold one.
|
|
801
|
-
]]
|
|
802
|
-
local waited = 0
|
|
803
|
-
while clip.Length == 0 and waited < 5 do
|
|
804
|
-
waited += task.wait()
|
|
805
|
-
end
|
|
806
|
-
if clip.Length == 0 then
|
|
807
|
-
animation:Destroy()
|
|
808
|
-
Dispatch.fail(
|
|
809
|
-
"ANIMATION_TIMEOUT",
|
|
810
|
-
string.format("%s did not load within 5s.", id),
|
|
811
|
-
"The animation must be public, or owned by the account signed into Studio."
|
|
812
|
-
)
|
|
813
|
-
end
|
|
814
|
-
|
|
815
|
-
--[[
|
|
816
|
-
Everything else on this rig stops first, or two animations blend and the
|
|
817
|
-
picture is of neither.
|
|
818
|
-
|
|
819
|
-
`Stop` leaves the track in the list for a moment, so a second preview used
|
|
820
|
-
to show two entries for the same animation. Destroying the track object
|
|
821
|
-
removes it outright, which keeps a repeated preview from accumulating one
|
|
822
|
-
dead track per call.
|
|
823
|
-
]]
|
|
824
|
-
for _, other in (animator :: Animator):GetPlayingAnimationTracks() do
|
|
825
|
-
if other ~= clip then
|
|
826
|
-
other:Stop(0)
|
|
827
|
-
other:Destroy()
|
|
828
|
-
end
|
|
829
|
-
end
|
|
830
|
-
|
|
831
|
-
local at = math.clamp(tonumber(params.at) or 0, 0, clip.Length)
|
|
832
|
-
|
|
833
|
-
--[[
|
|
834
|
-
Stepped by hand, because nothing steps it in edit mode.
|
|
835
|
-
|
|
836
|
-
This is the whole reason a first attempt at this reported perfect success
|
|
837
|
-
and moved nothing. In a running game the engine advances every Animator
|
|
838
|
-
each frame; in an edit session it advances none of them, so a track can be
|
|
839
|
-
playing, at the right TimePosition, and contributing exactly nothing
|
|
840
|
-
forever. Measured: `WeightCurrent` stayed 0 through any amount of
|
|
841
|
-
`task.wait`, and every Motor6D transform stayed identity.
|
|
842
|
-
|
|
843
|
-
`Animator:StepAnimations(delta)` is what Studio's own animation editor
|
|
844
|
-
uses, and it is what actually moves the rig. A few steps at a sixtieth of
|
|
845
|
-
a second let the weight blend reach 1; then the time is set and a
|
|
846
|
-
zero-length step applies that exact pose without advancing it.
|
|
847
|
-
]]
|
|
848
|
-
clip.Priority = Enum.AnimationPriority.Action
|
|
849
|
-
clip:Play(0, 1, 1)
|
|
850
|
-
|
|
851
|
-
local function step(delta: number)
|
|
852
|
-
pcall(function()
|
|
853
|
-
(animator :: any):StepAnimations(delta)
|
|
854
|
-
end)
|
|
855
|
-
end
|
|
856
|
-
|
|
857
|
-
for _ = 1, 5 do
|
|
858
|
-
step(1 / 60)
|
|
859
|
-
end
|
|
860
|
-
|
|
861
|
-
if params.hold ~= false then
|
|
862
|
-
clip:AdjustSpeed(0)
|
|
863
|
-
end
|
|
864
|
-
clip.TimePosition = at
|
|
865
|
-
-- Zero advances nothing and still applies the pose at the time just set.
|
|
866
|
-
step(0)
|
|
867
|
-
|
|
868
|
-
local weight = clip.WeightCurrent
|
|
869
|
-
animation:Destroy()
|
|
870
|
-
|
|
871
|
-
return {
|
|
872
|
-
rig = Paths.of(model),
|
|
873
|
-
animationId = id,
|
|
874
|
-
length = math.round(clip.Length * 1000) / 1000,
|
|
875
|
-
at = math.round(at * 1000) / 1000,
|
|
876
|
-
holding = params.hold ~= false,
|
|
877
|
-
-- Reported because a weight of zero means the rig is NOT posed, however
|
|
878
|
-
-- healthy everything else in this reply looks.
|
|
879
|
-
weight = math.round(weight * 100) / 100,
|
|
880
|
-
note = if weight > 0
|
|
881
|
-
then "The rig is posed at this moment now -- take a `screenshot` to see it. "
|
|
882
|
-
.. 'Call again with a different `at` to compare, or op="stop" to clear it.'
|
|
883
|
-
else "WARNING: the animation loaded but its weight is 0, so the rig is NOT "
|
|
884
|
-
.. "posed. Something else is animating this rig, or it has no Motor6D "
|
|
885
|
-
.. "joints for the animation to drive.",
|
|
886
|
-
}
|
|
887
|
-
end
|
|
888
|
-
|
|
889
|
-
function Anim.register()
|
|
890
|
-
Dispatch.registerAll("anim", {
|
|
891
|
-
read = Anim.read,
|
|
892
|
-
preview = Anim.preview,
|
|
893
|
-
build = Anim.build,
|
|
894
|
-
})
|
|
895
|
-
end
|
|
896
|
-
|
|
897
|
-
return Anim
|