@el4cteo/rbx-studio-mcp 0.6.1 → 0.6.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/README.md +28 -2
- package/dist/bridge/console.js +182 -0
- package/dist/bridge/console.js.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/cloudassets.js +233 -0
- package/dist/lib/cloudassets.js.map +1 -0
- package/dist/lib/credentials.js +180 -0
- package/dist/lib/credentials.js.map +1 -0
- package/dist/lib/livedata.js +325 -0
- package/dist/lib/livedata.js.map +1 -0
- package/dist/lib/liveluau.js +83 -0
- package/dist/lib/liveluau.js.map +1 -0
- package/dist/lib/liveops.js +358 -0
- package/dist/lib/liveops.js.map +1 -0
- package/dist/lib/opencloud.js +235 -0
- package/dist/lib/opencloud.js.map +1 -0
- package/dist/tools/anim.js +159 -0
- package/dist/tools/anim.js.map +1 -0
- package/dist/tools/audio.js +96 -0
- package/dist/tools/audio.js.map +1 -0
- package/dist/tools/character.js +95 -5
- package/dist/tools/character.js.map +1 -1
- package/dist/tools/data.js +292 -0
- package/dist/tools/data.js.map +1 -0
- package/dist/tools/device.js +77 -7
- package/dist/tools/device.js.map +1 -1
- package/dist/tools/discover.js +80 -4
- package/dist/tools/discover.js.map +1 -1
- package/dist/tools/exec.js +96 -2
- package/dist/tools/exec.js.map +1 -1
- package/dist/tools/input.js +35 -9
- package/dist/tools/input.js.map +1 -1
- package/dist/tools/perf.js +74 -7
- package/dist/tools/perf.js.map +1 -1
- package/dist/tools/scripts.js +162 -6
- package/dist/tools/scripts.js.map +1 -1
- package/dist/tools/spatial.js +135 -0
- package/dist/tools/spatial.js.map +1 -0
- package/dist/tools/universe.js +177 -0
- package/dist/tools/universe.js.map +1 -0
- package/dist/tools/upload.js +294 -0
- package/dist/tools/upload.js.map +1 -0
- package/dist/tools/world.js +675 -51
- package/dist/tools/world.js.map +1 -1
- package/package.json +2 -2
- package/plugin/src/Commands.luau +31 -7
- package/plugin/src/Config.luau +65 -65
- package/plugin/src/Console.luau +1909 -1843
- package/plugin/src/Emulation.luau +172 -0
- package/plugin/src/Phrase.luau +816 -618
- package/plugin/src/Png.luau +8 -4
- package/plugin/src/Prompt.luau +965 -961
- package/plugin/src/Secret.luau +86 -0
- package/plugin/src/Serialize.luau +440 -8
- package/plugin/src/Undo.luau +94 -6
- package/plugin/src/handlers/Anim.luau +897 -0
- package/plugin/src/handlers/Assets.luau +286 -2
- package/plugin/src/handlers/Audio.luau +411 -0
- package/plugin/src/handlers/Capture.luau +155 -20
- package/plugin/src/handlers/Character.luau +823 -361
- package/plugin/src/handlers/Data.luau +539 -0
- package/plugin/src/handlers/Device.luau +394 -139
- package/plugin/src/handlers/Discover.luau +685 -363
- package/plugin/src/handlers/Geometry.luau +722 -450
- package/plugin/src/handlers/Instances.luau +84 -4
- package/plugin/src/handlers/Perf.luau +227 -0
- package/plugin/src/handlers/Scripts.luau +673 -539
- package/plugin/src/handlers/Session.luau +3 -0
- package/plugin/src/handlers/Spatial.luau +334 -0
- package/plugin/src/handlers/Viewport.luau +268 -0
- package/plugin/src/handlers/World.luau +89 -15
- package/plugin/src/init.server.luau +9 -1
- package/scripts/build-plugin.mjs +20 -0
- package/scripts/check-plugin.mjs +171 -124
|
@@ -146,6 +146,9 @@ function Session.status(): { [string]: any }
|
|
|
146
146
|
scriptCount = totals.scripts,
|
|
147
147
|
studioVersion = studioVersion(),
|
|
148
148
|
emulatedDevice = Emulation.summary(),
|
|
149
|
+
-- Separate from the device: traffic can be shaped with no device set,
|
|
150
|
+
-- and that is the case most likely to be forgotten about.
|
|
151
|
+
emulatedNetwork = Emulation.networkSummary(),
|
|
149
152
|
}
|
|
150
153
|
end
|
|
151
154
|
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
Spatial queries: casts, and "what is in this box".
|
|
4
|
+
|
|
5
|
+
These answer the one question the Explorer cannot: what is actually THERE.
|
|
6
|
+
A path tells you an instance exists and where its own pivot sits; it does not
|
|
7
|
+
tell you that the door frame overlaps the wall, that the spawn is buried a
|
|
8
|
+
stud inside the floor, or that nothing at all stands between the turret and
|
|
9
|
+
the player. Every one of those is a query against the world, and before this
|
|
10
|
+
the only way to run one was `execute_luau`.
|
|
11
|
+
|
|
12
|
+
All of it goes through a WorldRoot rather than through `workspace` directly,
|
|
13
|
+
for the same reason collision groups do: a `WorldModel` inside a ViewportFrame
|
|
14
|
+
is its own world with its own parts and its own collision groups, and a query
|
|
15
|
+
hard-coded to the Workspace can only ever lie about it.
|
|
16
|
+
|
|
17
|
+
The `collisionGroup` field is the point of doing this now. Roblox moved
|
|
18
|
+
collision group management onto WorldRoot in September 2026, and spatial
|
|
19
|
+
queries inside a WorldModel now honour groups the way Workspace always has.
|
|
20
|
+
A cast run in the wrong group reports a clear path through a wall the player
|
|
21
|
+
cannot walk through -- a wrong answer that looks exactly like a right one.
|
|
22
|
+
]]
|
|
23
|
+
|
|
24
|
+
local Workspace = game:GetService("Workspace")
|
|
25
|
+
|
|
26
|
+
local Dispatch = require(script.Parent.Parent.Dispatch)
|
|
27
|
+
local Paths = require(script.Parent.Parent.Paths)
|
|
28
|
+
local Serialize = require(script.Parent.Parent.Serialize)
|
|
29
|
+
|
|
30
|
+
local Spatial = {}
|
|
31
|
+
|
|
32
|
+
-- A query that returns every part in a large place is not an answer, it is a
|
|
33
|
+
-- transcript. Callers narrow with `filter` or raise this deliberately.
|
|
34
|
+
local DEFAULT_LIMIT = 50
|
|
35
|
+
local MAX_LIMIT = 500
|
|
36
|
+
|
|
37
|
+
--[[
|
|
38
|
+
Which world the query runs in. Same rule as collision groups: the Workspace
|
|
39
|
+
unless a WorldModel is named, and anything that is not a WorldRoot is refused
|
|
40
|
+
by name rather than left to fail inside the engine call.
|
|
41
|
+
]]
|
|
42
|
+
local function root(params: { [string]: any }): Instance
|
|
43
|
+
local path = params.worldModel
|
|
44
|
+
if typeof(path) ~= "string" or path == "" then
|
|
45
|
+
return Workspace
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
local instance = Paths.resolve(path)
|
|
49
|
+
if not instance:IsA("WorldRoot") then
|
|
50
|
+
Dispatch.fail(
|
|
51
|
+
"BAD_PARAMS",
|
|
52
|
+
string.format("%s is a %s, not a WorldModel.", path, instance.ClassName),
|
|
53
|
+
"Spatial queries run on a WorldRoot: the Workspace, or a WorldModel "
|
|
54
|
+
.. "inside a ViewportFrame. Omit `worldModel` for the Workspace."
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
return instance
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
local function vector(value: any, field: string): Vector3
|
|
61
|
+
if typeof(value) == "table" then
|
|
62
|
+
local list = value :: { any }
|
|
63
|
+
local x, y, z = tonumber(list[1]), tonumber(list[2]), tonumber(list[3])
|
|
64
|
+
if x and y and z then
|
|
65
|
+
return Vector3.new(x, y, z)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
if typeof(value) == "string" then
|
|
69
|
+
local ok, parsed = Serialize.parse(value, "Vector3")
|
|
70
|
+
if ok and typeof(parsed) == "Vector3" then
|
|
71
|
+
return parsed
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
Dispatch.fail(
|
|
75
|
+
"BAD_PARAMS",
|
|
76
|
+
string.format("`%s` is not a position.", field),
|
|
77
|
+
'Write it the way the Properties panel does: "12, 0, 5".'
|
|
78
|
+
)
|
|
79
|
+
error("unreachable")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
local function optionalVector(value: any, field: string): Vector3?
|
|
83
|
+
if value == nil or value == "" then
|
|
84
|
+
return nil
|
|
85
|
+
end
|
|
86
|
+
return vector(value, field)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
--[[
|
|
90
|
+
Builds the filter every query shares.
|
|
91
|
+
|
|
92
|
+
`ignore` and `only` are the same engine field with the list flipped, because
|
|
93
|
+
"everything except the character" and "only the doors" are both common and
|
|
94
|
+
only one of them is expressible at a time. Naming a path that no longer
|
|
95
|
+
exists is a caller mistake worth reporting: silently querying against an
|
|
96
|
+
empty filter gives a plausible answer to the wrong question.
|
|
97
|
+
]]
|
|
98
|
+
local function buildParams(params: { [string]: any }): RaycastParams
|
|
99
|
+
local query = RaycastParams.new()
|
|
100
|
+
|
|
101
|
+
local list = params.only or params.ignore
|
|
102
|
+
if list ~= nil then
|
|
103
|
+
if typeof(list) ~= "table" then
|
|
104
|
+
Dispatch.fail("BAD_PARAMS", "`ignore` and `only` take a list of paths.")
|
|
105
|
+
end
|
|
106
|
+
local resolved, missing = Paths.resolveMany(list :: { string })
|
|
107
|
+
if #missing > 0 then
|
|
108
|
+
Dispatch.fail(
|
|
109
|
+
"NOT_FOUND",
|
|
110
|
+
string.format("Could not resolve: %s", table.concat(missing, ", "))
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
query.FilterDescendantsInstances = resolved
|
|
114
|
+
query.FilterType = if params.only ~= nil
|
|
115
|
+
then Enum.RaycastFilterType.Include
|
|
116
|
+
else Enum.RaycastFilterType.Exclude
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
if typeof(params.collisionGroup) == "string" and params.collisionGroup ~= "" then
|
|
120
|
+
query.CollisionGroup = params.collisionGroup
|
|
121
|
+
end
|
|
122
|
+
-- Off by default, matching the engine. A caller asking "can the player see
|
|
123
|
+
-- the exit" usually does want water ignored; one asking "what is here" does
|
|
124
|
+
-- not, and neither can be guessed.
|
|
125
|
+
query.RespectCanCollide = params.respectCanCollide == true
|
|
126
|
+
query.IgnoreWater = params.ignoreWater == true
|
|
127
|
+
query.BruteForceAllSlow = false
|
|
128
|
+
return query
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
local function describeHit(result: RaycastResult, origin: Vector3): { [string]: any }
|
|
132
|
+
return {
|
|
133
|
+
path = Paths.of(result.Instance),
|
|
134
|
+
class = result.Instance.ClassName,
|
|
135
|
+
position = Serialize.value(result.Position),
|
|
136
|
+
normal = Serialize.value(result.Normal),
|
|
137
|
+
material = tostring(result.Material):gsub("Enum%.Material%.", ""),
|
|
138
|
+
distance = math.round((result.Position - origin).Magnitude * 100) / 100,
|
|
139
|
+
}
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
local function describePart(part: BasePart): { [string]: any }
|
|
143
|
+
return {
|
|
144
|
+
path = Paths.of(part),
|
|
145
|
+
class = part.ClassName,
|
|
146
|
+
position = Serialize.value(part.Position),
|
|
147
|
+
size = Serialize.value(part.Size),
|
|
148
|
+
collisionGroup = part.CollisionGroup,
|
|
149
|
+
canCollide = part.CanCollide,
|
|
150
|
+
}
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
--[[
|
|
154
|
+
Casts a ray, a block or a sphere.
|
|
155
|
+
|
|
156
|
+
The three are one operation with one extra argument, and the engine treats
|
|
157
|
+
them that way too -- `Blockcast` and `Spherecast` take the same params and
|
|
158
|
+
return the same RaycastResult. Splitting them into three handlers would only
|
|
159
|
+
mean three copies of the filter code.
|
|
160
|
+
|
|
161
|
+
A miss is reported as `hit = false` with the distance travelled, not as an
|
|
162
|
+
error and not as an empty object. "Nothing is in the way" is a real answer
|
|
163
|
+
and usually the one being checked for.
|
|
164
|
+
]]
|
|
165
|
+
function Spatial.cast(params: { [string]: any }): { [string]: any }
|
|
166
|
+
local world = root(params) :: WorldRoot
|
|
167
|
+
local shape = tostring(params.shape or "ray")
|
|
168
|
+
|
|
169
|
+
local origin = vector(params.from, "from")
|
|
170
|
+
|
|
171
|
+
--[[
|
|
172
|
+
Direction is given either as a target point or as a vector, because both
|
|
173
|
+
are natural and they are not interchangeable. "Can the turret see the
|
|
174
|
+
player" is two positions; "is there ground below" is a direction and a
|
|
175
|
+
distance. Taking only one of them forces the caller to do vector maths
|
|
176
|
+
in the prompt, which is exactly where an off-by-one sign lives.
|
|
177
|
+
]]
|
|
178
|
+
local direction: Vector3
|
|
179
|
+
local to = optionalVector(params.to, "to")
|
|
180
|
+
if to then
|
|
181
|
+
direction = to - origin
|
|
182
|
+
else
|
|
183
|
+
local towards = optionalVector(params.direction, "direction")
|
|
184
|
+
if not towards then
|
|
185
|
+
Dispatch.fail(
|
|
186
|
+
"BAD_PARAMS",
|
|
187
|
+
"A cast needs somewhere to go.",
|
|
188
|
+
"Give `to` for a point to aim at, or `direction` plus `distance`."
|
|
189
|
+
)
|
|
190
|
+
error("unreachable")
|
|
191
|
+
end
|
|
192
|
+
local distance = tonumber(params.distance) or 100
|
|
193
|
+
direction = towards.Unit * distance
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
local query = buildParams(params)
|
|
197
|
+
local result: RaycastResult?
|
|
198
|
+
|
|
199
|
+
if shape == "ray" then
|
|
200
|
+
result = world:Raycast(origin, direction, query)
|
|
201
|
+
elseif shape == "block" then
|
|
202
|
+
local size = optionalVector(params.size, "size") or Vector3.new(1, 1, 1)
|
|
203
|
+
result = world:Blockcast(CFrame.new(origin), size, direction, query)
|
|
204
|
+
elseif shape == "sphere" then
|
|
205
|
+
local radius = tonumber(params.radius) or 1
|
|
206
|
+
result = world:Spherecast(origin, radius, direction, query)
|
|
207
|
+
else
|
|
208
|
+
Dispatch.fail(
|
|
209
|
+
"BAD_PARAMS",
|
|
210
|
+
string.format("unknown cast shape %q", shape),
|
|
211
|
+
'Use "ray", "block" or "sphere".'
|
|
212
|
+
)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
if not result then
|
|
216
|
+
return {
|
|
217
|
+
hit = false,
|
|
218
|
+
shape = shape,
|
|
219
|
+
from = Serialize.value(origin),
|
|
220
|
+
travelled = math.round(direction.Magnitude * 100) / 100,
|
|
221
|
+
world = Paths.of(world),
|
|
222
|
+
note = "Nothing was in the way for the whole length of the cast.",
|
|
223
|
+
}
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
local hit = describeHit(result, origin)
|
|
227
|
+
hit.hit = true
|
|
228
|
+
hit.shape = shape
|
|
229
|
+
hit.from = Serialize.value(origin)
|
|
230
|
+
hit.world = Paths.of(world)
|
|
231
|
+
return hit
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
--[[
|
|
235
|
+
Everything inside a volume.
|
|
236
|
+
|
|
237
|
+
`part` is the one worth reaching for on a build: it takes an existing part
|
|
238
|
+
and reports what overlaps it, which answers "is this door clipping the wall"
|
|
239
|
+
directly rather than by reconstructing the door's bounds by hand.
|
|
240
|
+
]]
|
|
241
|
+
function Spatial.overlap(params: { [string]: any }): { [string]: any }
|
|
242
|
+
local world = root(params) :: WorldRoot
|
|
243
|
+
local region = tostring(params.region or "box")
|
|
244
|
+
local limit = math.clamp(tonumber(params.limit) or DEFAULT_LIMIT, 1, MAX_LIMIT)
|
|
245
|
+
|
|
246
|
+
local overlap = OverlapParams.new()
|
|
247
|
+
local query = buildParams(params)
|
|
248
|
+
overlap.FilterDescendantsInstances = query.FilterDescendantsInstances
|
|
249
|
+
overlap.FilterType = query.FilterType
|
|
250
|
+
overlap.CollisionGroup = query.CollisionGroup
|
|
251
|
+
overlap.RespectCanCollide = query.RespectCanCollide
|
|
252
|
+
-- Asked for one over the limit so the response can say whether the list was
|
|
253
|
+
-- cut, instead of a caller reading exactly 50 as "there are 50".
|
|
254
|
+
overlap.MaxParts = limit + 1
|
|
255
|
+
|
|
256
|
+
local found: { BasePart }
|
|
257
|
+
local about: { [string]: any } = {}
|
|
258
|
+
|
|
259
|
+
if region == "box" then
|
|
260
|
+
local centre = vector(params.at, "at")
|
|
261
|
+
local size = optionalVector(params.size, "size") or Vector3.new(4, 4, 4)
|
|
262
|
+
found = world:GetPartBoundsInBox(CFrame.new(centre), size, overlap)
|
|
263
|
+
about.at, about.size = Serialize.value(centre), Serialize.value(size)
|
|
264
|
+
elseif region == "radius" then
|
|
265
|
+
local centre = vector(params.at, "at")
|
|
266
|
+
local radius = tonumber(params.radius) or 4
|
|
267
|
+
found = world:GetPartBoundsInRadius(centre, radius, overlap)
|
|
268
|
+
about.at, about.radius = Serialize.value(centre), radius
|
|
269
|
+
elseif region == "part" then
|
|
270
|
+
local path = params.path
|
|
271
|
+
if typeof(path) ~= "string" or path == "" then
|
|
272
|
+
Dispatch.fail("BAD_PARAMS", 'region="part" needs `path`, the part to test.')
|
|
273
|
+
end
|
|
274
|
+
local instance = Paths.resolve(path)
|
|
275
|
+
if not instance:IsA("BasePart") then
|
|
276
|
+
Dispatch.fail(
|
|
277
|
+
"BAD_PARAMS",
|
|
278
|
+
string.format("%s is a %s, not a part.", path, instance.ClassName)
|
|
279
|
+
)
|
|
280
|
+
end
|
|
281
|
+
local subject = instance :: BasePart
|
|
282
|
+
--[[
|
|
283
|
+
The subject always overlaps itself, and a caller who asked what
|
|
284
|
+
touches a door does not mean the door. Added to the filter rather
|
|
285
|
+
than stripped from the results, so it does not eat one of the slots
|
|
286
|
+
the limit allows.
|
|
287
|
+
]]
|
|
288
|
+
if overlap.FilterType == Enum.RaycastFilterType.Exclude then
|
|
289
|
+
local excluded = overlap.FilterDescendantsInstances
|
|
290
|
+
table.insert(excluded, subject)
|
|
291
|
+
overlap.FilterDescendantsInstances = excluded
|
|
292
|
+
end
|
|
293
|
+
found = world:GetPartsInPart(subject, overlap)
|
|
294
|
+
about.path = Paths.of(subject)
|
|
295
|
+
else
|
|
296
|
+
Dispatch.fail(
|
|
297
|
+
"BAD_PARAMS",
|
|
298
|
+
string.format("unknown region %q", region),
|
|
299
|
+
'Use "box", "radius" or "part".'
|
|
300
|
+
)
|
|
301
|
+
error("unreachable")
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
local truncated = #found > limit
|
|
305
|
+
local items: { { [string]: any } } = {}
|
|
306
|
+
for index, part in found do
|
|
307
|
+
if index > limit then
|
|
308
|
+
break
|
|
309
|
+
end
|
|
310
|
+
table.insert(items, describePart(part))
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
about.region = region
|
|
314
|
+
about.items = items
|
|
315
|
+
about.count = #items
|
|
316
|
+
about.truncated = truncated
|
|
317
|
+
about.world = Paths.of(world)
|
|
318
|
+
if truncated then
|
|
319
|
+
about.note = string.format(
|
|
320
|
+
"More than %d parts are in this volume; raise `limit` or narrow with `only`.",
|
|
321
|
+
limit
|
|
322
|
+
)
|
|
323
|
+
end
|
|
324
|
+
return about
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
function Spatial.register()
|
|
328
|
+
Dispatch.registerAll("spatial", {
|
|
329
|
+
cast = Spatial.cast,
|
|
330
|
+
overlap = Spatial.overlap,
|
|
331
|
+
})
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
return Spatial
|
|
@@ -15,10 +15,12 @@
|
|
|
15
15
|
|
|
16
16
|
local RunService = game:GetService("RunService")
|
|
17
17
|
local Selection = game:GetService("Selection")
|
|
18
|
+
local StarterGui = game:GetService("StarterGui")
|
|
18
19
|
local TextService = game:GetService("TextService")
|
|
19
20
|
local Workspace = game:GetService("Workspace")
|
|
20
21
|
|
|
21
22
|
local Dispatch = require(script.Parent.Parent.Dispatch)
|
|
23
|
+
local Emulation = require(script.Parent.Parent.Emulation)
|
|
22
24
|
local Paths = require(script.Parent.Parent.Paths)
|
|
23
25
|
local Serialize = require(script.Parent.Parent.Serialize)
|
|
24
26
|
|
|
@@ -403,8 +405,274 @@ function Viewport.textbounds(params: { [string]: any }): { [string]: any }
|
|
|
403
405
|
return report
|
|
404
406
|
end
|
|
405
407
|
|
|
408
|
+
--[[
|
|
409
|
+
Every interface fault that is invisible in the data model.
|
|
410
|
+
|
|
411
|
+
`textbounds` already answers "does this text fit its label". This is the same
|
|
412
|
+
question asked of the whole screen, and it exists because the data model
|
|
413
|
+
cannot answer it at all: a button positioned off the side of a phone has a
|
|
414
|
+
perfectly correct `Position`, a `Size` that is not zero, and a parent that is
|
|
415
|
+
visible. Nothing about the instance is wrong. It is simply not where anyone
|
|
416
|
+
can reach it, and the only ways to find that out today are to play the game
|
|
417
|
+
or to look at a picture.
|
|
418
|
+
|
|
419
|
+
Studio lays StarterGui out for real in edit mode -- measured:
|
|
420
|
+
`AbsolutePosition` and `AbsoluteSize` carry live values with nothing running,
|
|
421
|
+
and they move when a device is emulated. So the audit runs against whatever
|
|
422
|
+
`device` is currently emulating, which is what makes it worth running twice:
|
|
423
|
+
once on desktop, once on a phone.
|
|
424
|
+
|
|
425
|
+
It reports rather than judges. Every finding names the element and the
|
|
426
|
+
number, so a caller can decide whether a label four pixels off the edge is a
|
|
427
|
+
bug or a deliberate bleed.
|
|
428
|
+
]]
|
|
429
|
+
|
|
430
|
+
--[[
|
|
431
|
+
Text below this many pixels tall is not readable on a phone.
|
|
432
|
+
|
|
433
|
+
Roblox's own UI guidance and every accessibility standard land in the same
|
|
434
|
+
place: somewhere around 12 device-independent pixels is the floor for body
|
|
435
|
+
text. 10 is used here because it is the point past which there is no
|
|
436
|
+
argument -- a label under it is unreadable on any display, not merely small.
|
|
437
|
+
]]
|
|
438
|
+
local MIN_TEXT_PIXELS = 10
|
|
439
|
+
|
|
440
|
+
--[[
|
|
441
|
+
Pairs compared for overlap before the check gives up.
|
|
442
|
+
|
|
443
|
+
Overlap is quadratic in the number of siblings, and a scrolling list of two
|
|
444
|
+
hundred rows is both a legitimate design and forty thousand comparisons of
|
|
445
|
+
rectangles that are supposed to sit next to each other. Past this the check
|
|
446
|
+
reports that it stopped rather than continuing to spend the user's frame on
|
|
447
|
+
an answer nobody asked for.
|
|
448
|
+
]]
|
|
449
|
+
local MAX_OVERLAP_PAIRS = 2000
|
|
450
|
+
|
|
451
|
+
local function rectOf(element: GuiObject): (number, number, number, number)
|
|
452
|
+
local position = element.AbsolutePosition
|
|
453
|
+
local size = element.AbsoluteSize
|
|
454
|
+
return position.X, position.Y, position.X + size.X, position.Y + size.Y
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
local function overlaps(a: GuiObject, b: GuiObject): boolean
|
|
458
|
+
local ax1, ay1, ax2, ay2 = rectOf(a)
|
|
459
|
+
local bx1, by1, bx2, by2 = rectOf(b)
|
|
460
|
+
return ax1 < bx2 and bx1 < ax2 and ay1 < by2 and by1 < ay2
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
--[[
|
|
464
|
+
Whether this element is actually on screen at all.
|
|
465
|
+
|
|
466
|
+
Walks up rather than trusting `Visible`: an element with Visible true inside
|
|
467
|
+
a frame with Visible false is not on screen, and reporting it as a fault
|
|
468
|
+
would bury the real findings under every hidden menu in the place.
|
|
469
|
+
]]
|
|
470
|
+
local function shown(element: Instance): boolean
|
|
471
|
+
local node: Instance? = element
|
|
472
|
+
while node ~= nil and not node:IsA("LayerCollector") do
|
|
473
|
+
if node:IsA("GuiObject") and not (node :: GuiObject).Visible then
|
|
474
|
+
return false
|
|
475
|
+
end
|
|
476
|
+
node = node.Parent
|
|
477
|
+
end
|
|
478
|
+
if node ~= nil and node:IsA("ScreenGui") then
|
|
479
|
+
return (node :: ScreenGui).Enabled
|
|
480
|
+
end
|
|
481
|
+
return node ~= nil
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
function Viewport.ui(params: { [string]: any }): { [string]: any }
|
|
485
|
+
local root: Instance = if typeof(params.path) == "string" and params.path ~= ""
|
|
486
|
+
then Paths.resolve(params.path)
|
|
487
|
+
else StarterGui
|
|
488
|
+
|
|
489
|
+
local camera = Workspace.CurrentCamera
|
|
490
|
+
if camera == nil then
|
|
491
|
+
Dispatch.fail("NO_CAMERA", "This session has no camera, so there is no screen to measure against.")
|
|
492
|
+
end
|
|
493
|
+
local screen = (camera :: Camera).ViewportSize
|
|
494
|
+
local screenWidth, screenHeight = math.floor(screen.X), math.floor(screen.Y)
|
|
495
|
+
|
|
496
|
+
local elements: { GuiObject } = {}
|
|
497
|
+
for _, descendant in root:GetDescendants() do
|
|
498
|
+
if descendant:IsA("GuiObject") then
|
|
499
|
+
table.insert(elements, descendant :: GuiObject)
|
|
500
|
+
end
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
local findings: { { [string]: any } } = {}
|
|
504
|
+
local checked = 0
|
|
505
|
+
local hidden = 0
|
|
506
|
+
|
|
507
|
+
local function report(element: GuiObject, issue: string, detail: string)
|
|
508
|
+
if #findings >= 100 then
|
|
509
|
+
return
|
|
510
|
+
end
|
|
511
|
+
table.insert(findings, {
|
|
512
|
+
path = Paths.of(element),
|
|
513
|
+
name = element.Name,
|
|
514
|
+
className = element.ClassName,
|
|
515
|
+
issue = issue,
|
|
516
|
+
detail = detail,
|
|
517
|
+
})
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
local visible: { GuiObject } = {}
|
|
521
|
+
|
|
522
|
+
for _, element in elements do
|
|
523
|
+
if not shown(element) then
|
|
524
|
+
hidden += 1
|
|
525
|
+
continue
|
|
526
|
+
end
|
|
527
|
+
checked += 1
|
|
528
|
+
table.insert(visible, element)
|
|
529
|
+
|
|
530
|
+
local size = element.AbsoluteSize
|
|
531
|
+
local position = element.AbsolutePosition
|
|
532
|
+
|
|
533
|
+
if size.X <= 0 or size.Y <= 0 then
|
|
534
|
+
report(element, "zero size", string.format("%.0f x %.0f -- nothing is drawn", size.X, size.Y))
|
|
535
|
+
continue
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
--[[
|
|
539
|
+
Off screen, and by how much.
|
|
540
|
+
|
|
541
|
+
Reported as a measurement rather than a yes/no, because the two cases
|
|
542
|
+
it covers are different bugs: an element wholly outside the screen was
|
|
543
|
+
positioned for a display that is not this one, and one clipped at an
|
|
544
|
+
edge is usually a layout that does not fit. The numbers tell them
|
|
545
|
+
apart; a boolean would not.
|
|
546
|
+
]]
|
|
547
|
+
local right = position.X + size.X
|
|
548
|
+
local bottom = position.Y + size.Y
|
|
549
|
+
if right <= 0 or bottom <= 0 or position.X >= screenWidth or position.Y >= screenHeight then
|
|
550
|
+
report(
|
|
551
|
+
element,
|
|
552
|
+
"off screen",
|
|
553
|
+
string.format(
|
|
554
|
+
"at %.0f,%.0f on a %dx%d screen -- entirely outside it",
|
|
555
|
+
position.X,
|
|
556
|
+
position.Y,
|
|
557
|
+
screenWidth,
|
|
558
|
+
screenHeight
|
|
559
|
+
)
|
|
560
|
+
)
|
|
561
|
+
elseif position.X < 0 or position.Y < 0 or right > screenWidth or bottom > screenHeight then
|
|
562
|
+
report(
|
|
563
|
+
element,
|
|
564
|
+
"clipped",
|
|
565
|
+
string.format(
|
|
566
|
+
"%.0f,%.0f to %.0f,%.0f runs past the %dx%d screen",
|
|
567
|
+
position.X,
|
|
568
|
+
position.Y,
|
|
569
|
+
right,
|
|
570
|
+
bottom,
|
|
571
|
+
screenWidth,
|
|
572
|
+
screenHeight
|
|
573
|
+
)
|
|
574
|
+
)
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
if element:IsA("TextLabel") or element:IsA("TextButton") or element:IsA("TextBox") then
|
|
578
|
+
local text = element :: any
|
|
579
|
+
--[[
|
|
580
|
+
The size the text is actually drawn at, which is not TextSize when
|
|
581
|
+
TextScaled is on: the engine then fits the string to the box and
|
|
582
|
+
`TextBounds` is the only honest report of the result.
|
|
583
|
+
]]
|
|
584
|
+
local drawn = if text.TextScaled then text.TextBounds.Y else text.TextSize
|
|
585
|
+
if drawn > 0 and drawn < MIN_TEXT_PIXELS and text.Text ~= "" then
|
|
586
|
+
report(
|
|
587
|
+
element,
|
|
588
|
+
"text too small",
|
|
589
|
+
string.format("%.0fpx -- under %dpx is unreadable on a phone", drawn, MIN_TEXT_PIXELS)
|
|
590
|
+
)
|
|
591
|
+
end
|
|
592
|
+
if text.TextTruncate == Enum.TextTruncate.None and not text.TextScaled and not text.TextWrapped then
|
|
593
|
+
if text.TextBounds.X > size.X + 1 then
|
|
594
|
+
report(
|
|
595
|
+
element,
|
|
596
|
+
"text overflows",
|
|
597
|
+
string.format("needs %.0fpx, has %.0fpx", text.TextBounds.X, size.X)
|
|
598
|
+
)
|
|
599
|
+
end
|
|
600
|
+
end
|
|
601
|
+
end
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
--[[
|
|
605
|
+
Overlap between siblings only.
|
|
606
|
+
|
|
607
|
+
A child sitting on top of its parent is the normal way UI is built, and
|
|
608
|
+
reporting it would make the check useless. Two buttons in the same frame
|
|
609
|
+
covering each other is the fault worth naming.
|
|
610
|
+
]]
|
|
611
|
+
--[[
|
|
612
|
+
Whether an element is something a user reads or presses.
|
|
613
|
+
|
|
614
|
+
Overlap between decoration is not a fault, it is how UI is built: a drop
|
|
615
|
+
shadow sits under a panel, a bevel sits under a label, a playhead sits
|
|
616
|
+
over a timeline. Measured on a real HUD, the first version of this check
|
|
617
|
+
reported four overlaps and all four were of that kind -- a Frame named
|
|
618
|
+
Shadow behind the three things it was drawn to sit behind.
|
|
619
|
+
|
|
620
|
+
Two pieces of TEXT on top of each other, or two images, is the fault
|
|
621
|
+
worth naming. So a plain coloured Frame is not counted; something
|
|
622
|
+
carrying text or an image is.
|
|
623
|
+
]]
|
|
624
|
+
local function carriesContent(element: GuiObject): boolean
|
|
625
|
+
if element:IsA("TextLabel") or element:IsA("TextButton") or element:IsA("TextBox") then
|
|
626
|
+
return (element :: any).Text ~= ""
|
|
627
|
+
end
|
|
628
|
+
if element:IsA("ImageLabel") or element:IsA("ImageButton") then
|
|
629
|
+
return (element :: any).Image ~= ""
|
|
630
|
+
end
|
|
631
|
+
return false
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
local pairs = 0
|
|
635
|
+
local stopped = false
|
|
636
|
+
for index = 1, #visible do
|
|
637
|
+
for other = index + 1, #visible do
|
|
638
|
+
if pairs >= MAX_OVERLAP_PAIRS then
|
|
639
|
+
stopped = true
|
|
640
|
+
break
|
|
641
|
+
end
|
|
642
|
+
local a, b = visible[index], visible[other]
|
|
643
|
+
if a.Parent == b.Parent then
|
|
644
|
+
pairs += 1
|
|
645
|
+
if
|
|
646
|
+
overlaps(a, b)
|
|
647
|
+
and a.AbsoluteSize.X > 0
|
|
648
|
+
and b.AbsoluteSize.X > 0
|
|
649
|
+
and carriesContent(a)
|
|
650
|
+
and carriesContent(b)
|
|
651
|
+
then
|
|
652
|
+
report(a, "overlaps", string.format("covers or is covered by %s", b.Name))
|
|
653
|
+
end
|
|
654
|
+
end
|
|
655
|
+
end
|
|
656
|
+
if stopped then
|
|
657
|
+
break
|
|
658
|
+
end
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
return {
|
|
662
|
+
screen = string.format("%dx%d", screenWidth, screenHeight),
|
|
663
|
+
device = Emulation.deviceId(),
|
|
664
|
+
root = Paths.of(root),
|
|
665
|
+
checked = checked,
|
|
666
|
+
hidden = hidden,
|
|
667
|
+
findings = findings,
|
|
668
|
+
findingCount = #findings,
|
|
669
|
+
overlapStopped = stopped,
|
|
670
|
+
}
|
|
671
|
+
end
|
|
672
|
+
|
|
406
673
|
function Viewport.register()
|
|
407
674
|
Dispatch.registerAll("viewport", {
|
|
675
|
+
ui = Viewport.ui,
|
|
408
676
|
textbounds = Viewport.textbounds,
|
|
409
677
|
raycast = Viewport.raycast,
|
|
410
678
|
select = Viewport.select,
|