@el4cteo/rbx-studio-mcp 0.4.1 → 0.4.5

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.
Files changed (39) hide show
  1. package/README.md +211 -211
  2. package/dist/bridge/api.js +3 -0
  3. package/dist/bridge/api.js.map +1 -1
  4. package/dist/bridge/failover.js +13 -0
  5. package/dist/bridge/failover.js.map +1 -1
  6. package/dist/bridge/remote.js +15 -1
  7. package/dist/bridge/remote.js.map +1 -1
  8. package/dist/bridge/rpc.js +35 -6
  9. package/dist/bridge/rpc.js.map +1 -1
  10. package/dist/bridge/server.js +55 -7
  11. package/dist/bridge/server.js.map +1 -1
  12. package/dist/doctor.js +160 -0
  13. package/dist/doctor.js.map +1 -0
  14. package/dist/index.js +29 -0
  15. package/dist/index.js.map +1 -1
  16. package/dist/lib/protocol.js.map +1 -1
  17. package/dist/tools/exec.js +53 -3
  18. package/dist/tools/exec.js.map +1 -1
  19. package/dist/tools/generate.js +120 -0
  20. package/dist/tools/generate.js.map +1 -0
  21. package/dist/tools/scripts.js +20 -1
  22. package/dist/tools/scripts.js.map +1 -1
  23. package/dist/tools/world.js +203 -20
  24. package/dist/tools/world.js.map +1 -1
  25. package/package.json +5 -4
  26. package/plugin/src/Config.luau +1 -1
  27. package/plugin/src/Console.luau +233 -9
  28. package/plugin/src/Transport.luau +6 -1
  29. package/plugin/src/Visuals.luau +52 -1
  30. package/plugin/src/handlers/Assets.luau +352 -145
  31. package/plugin/src/handlers/Generate.luau +386 -0
  32. package/plugin/src/handlers/Geometry.luau +170 -0
  33. package/plugin/src/handlers/Scripts.luau +79 -1
  34. package/plugin/src/handlers/Viewport.luau +416 -302
  35. package/plugin/src/handlers/World.luau +15 -5
  36. package/plugin/src/init.server.luau +725 -636
  37. package/scripts/install-plugin.mjs +25 -2
  38. package/scripts/test-bridge.mjs +56 -0
  39. package/scripts/test-failover.mjs +136 -95
@@ -1,302 +1,416 @@
1
- --!strict
2
- --[[
3
- The viewport and the selection -- what the user is pointing at, and what they
4
- have highlighted.
5
-
6
- Selection is the counterpart to `studio_status`, which reports it: this sets
7
- it. Selecting what a tool just built is the cheapest way to show someone the
8
- result of a change, and it puts the instance in reach of Studio's own move
9
- and scale handles.
10
-
11
- Raycasting answers "what is under this point in the 3D view", which is
12
- otherwise unanswerable: an agent can enumerate the data model but has no way
13
- to know what occupies a piece of space.
14
- ]]
15
-
16
- local RunService = game:GetService("RunService")
17
- local Selection = game:GetService("Selection")
18
- local Workspace = game:GetService("Workspace")
19
-
20
- local Dispatch = require(script.Parent.Parent.Dispatch)
21
- local Paths = require(script.Parent.Parent.Paths)
22
- local Serialize = require(script.Parent.Parent.Serialize)
23
-
24
- local MAX_SELECTION = 200
25
- local DEFAULT_RAY_LENGTH = 1000
26
-
27
- local Viewport = {}
28
-
29
- --[[
30
- Whether moving the camera in this session would change what anyone sees.
31
-
32
- A playtest server has a `CurrentCamera` and accepts every write to it, and
33
- renders nothing -- the picture on screen comes from the playtest's client. So
34
- framing a subject here succeeds, reports a plausible camera position, and
35
- moves no camera the user has. Measured: `focus` against a playtest server
36
- returned a full result for a shot nobody could ever see.
37
- ]]
38
- local function rendersToScreen(): boolean
39
- return not (RunService:IsRunning() and RunService:IsServer())
40
- end
41
-
42
-
43
- local function toVector(value: any, label: string): Vector3
44
- local ok, parsed = Serialize.parse(value, "Vector3")
45
- if not ok or typeof(parsed) ~= "Vector3" then
46
- Dispatch.fail(
47
- "BAD_PARAMS",
48
- string.format('%s must be three numbers, e.g. "0, 50, 0".', label)
49
- )
50
- end
51
- return parsed :: Vector3
52
- end
53
-
54
- --[[
55
- Casts a ray through the world and reports the first thing it hits.
56
-
57
- `Workspace:Raycast`, not `StudioService:GizmoRaycast`. The latter reads like
58
- the Studio-aware choice and is not: measured against a place with parts under
59
- the ray, it returns nil every time. It picks Studio's own gizmos and
60
- adornments, not world geometry.
61
- ]]
62
- function Viewport.raycast(params: { [string]: any }): { [string]: any }
63
- local origin = toVector(params.origin, "origin")
64
- local direction = toVector(params.direction, "direction")
65
- local length = tonumber(params.maxDistance) or DEFAULT_RAY_LENGTH
66
-
67
- local filter: { Instance } = {}
68
- for _, path in (params.ignore or {}) :: { string } do
69
- table.insert(filter, Paths.resolve(path))
70
- end
71
-
72
- local config = RaycastParams.new()
73
- config.FilterType = Enum.RaycastFilterType.Exclude
74
- config.FilterDescendantsInstances = filter
75
-
76
- local ok, result = pcall(function()
77
- return Workspace:Raycast(origin, direction.Unit * length, config)
78
- end)
79
-
80
- if not ok or result == nil then
81
- return { hit = false, origin = Serialize.value(origin), direction = Serialize.value(direction.Unit) }
82
- end
83
-
84
- local cast = result :: RaycastResult
85
- return {
86
- hit = true,
87
- path = if cast.Instance then Paths.of(cast.Instance) else nil,
88
- className = if cast.Instance then cast.Instance.ClassName else nil,
89
- position = Serialize.value(cast.Position),
90
- normal = Serialize.value(cast.Normal),
91
- distance = math.floor(cast.Distance * 1000 + 0.5) / 1000,
92
- material = Serialize.value(cast.Material),
93
- }
94
- end
95
-
96
- --[[
97
- Sets, extends or shrinks the Studio selection.
98
- ]]
99
- function Viewport.select(params: { [string]: any }): { [string]: any }
100
- local paths = params.paths
101
- if typeof(paths) ~= "table" then
102
- Dispatch.fail("BAD_PARAMS", "select requires a `paths` array.")
103
- end
104
- if #paths > MAX_SELECTION then
105
- Dispatch.fail(
106
- "TOO_MANY",
107
- string.format("%d paths, over the %d selection limit.", #paths, MAX_SELECTION)
108
- )
109
- end
110
-
111
- local instances: { Instance } = {}
112
- for _, path in paths :: { string } do
113
- table.insert(instances, Paths.resolve(path))
114
- end
115
-
116
- local mode = params.mode or "set"
117
- if mode == "add" then
118
- Selection:Add(instances)
119
- elseif mode == "remove" then
120
- Selection:Remove(instances)
121
- else
122
- Selection:Set(instances)
123
- end
124
-
125
- local current: { { [string]: any } } = {}
126
- local memo: Paths.NameIndex = {}
127
- for index, instance in Selection:Get() do
128
- if index > MAX_SELECTION then
129
- break
130
- end
131
- table.insert(current, { path = Paths.of(instance, memo), className = instance.ClassName })
132
- end
133
-
134
- return { items = current, count = #Selection:Get() }
135
- end
136
-
137
- --[[
138
- The world-space box an instance occupies.
139
-
140
- A Model knows its own extents; a lone part has to be measured from its size
141
- and orientation. Anything else -- a Folder, a Script -- has no position at
142
- all, and saying so is better than framing the origin and returning a picture
143
- of empty ground.
144
- ]]
145
- local function extentsOf(instance: Instance): (Vector3, Vector3)
146
- if instance:IsA("Model") then
147
- local cframe, size = (instance :: Model):GetBoundingBox()
148
- return cframe.Position, size
149
- end
150
- if instance:IsA("BasePart") then
151
- local part = instance :: BasePart
152
- return part.Position, part.Size
153
- end
154
-
155
- -- A container is worth framing if anything inside it has a position.
156
- local minimum: Vector3? = nil
157
- local maximum: Vector3? = nil
158
- for _, descendant in instance:GetDescendants() do
159
- if descendant:IsA("BasePart") then
160
- local part = descendant :: BasePart
161
- local half = part.Size / 2
162
- minimum = if minimum == nil then part.Position - half else (minimum :: Vector3):Min(part.Position - half)
163
- maximum = if maximum == nil then part.Position + half else (maximum :: Vector3):Max(part.Position + half)
164
- end
165
- end
166
- if minimum == nil or maximum == nil then
167
- Dispatch.fail(
168
- "NOT_POSITIONED",
169
- string.format("%s is a %s and occupies no space in the world.", instance:GetFullName(), instance.ClassName),
170
- "Frame something with geometry -- a part, a model, or a folder containing them."
171
- )
172
- end
173
- local low, high = minimum :: Vector3, maximum :: Vector3
174
- return (low + high) / 2, high - low
175
- end
176
-
177
- --[[
178
- Points the Studio camera at something, framed so all of it is on screen.
179
-
180
- This is what makes `screenshot` worth having. A picture of wherever the user
181
- last left their camera answers nothing; a picture of the thing just built
182
- answers "does it look right", which the data model cannot.
183
-
184
- The distance is computed from the subject's size and the camera's own field
185
- of view rather than guessed, so a doorway and a whole map both arrive filling
186
- a similar share of the frame.
187
- ]]
188
- function Viewport.focus(params: { [string]: any }): { [string]: any }
189
- if not rendersToScreen() then
190
- Dispatch.fail(
191
- "NOT_RENDERED",
192
- "This is the playtest's server session, which draws nothing -- moving its "
193
- .. "camera would change no picture anybody sees.",
194
- "Address the editor session (see `list_studios`), or stop the playtest first."
195
- )
196
- end
197
- local camera = Workspace.CurrentCamera
198
- if camera == nil then
199
- Dispatch.fail("NO_CAMERA", "This session has no camera.")
200
- end
201
-
202
- local centre: Vector3
203
- local size: Vector3
204
- if typeof(params.path) == "string" and params.path ~= "" then
205
- centre, size = extentsOf(Paths.resolve(params.path))
206
- else
207
- centre = toVector(params.at, "`at`")
208
- size = Vector3.new(10, 10, 10)
209
- end
210
-
211
- --[[
212
- Half the diagonal, not half the width: a wall seen from an angle presents
213
- its diagonal, and framing to the width alone crops the corners off
214
- exactly the shapes most worth looking at.
215
- ]]
216
- local radius = math.max(size.Magnitude / 2, 1)
217
- local fov = math.rad((camera :: Camera).FieldOfView)
218
- local distance = (radius / math.tan(fov / 2)) * (tonumber(params.padding) or 1.5)
219
-
220
- -- Down the -Z axis and slightly above by default, which is how a person
221
- -- would stand to look at something: face on, a little raised, never level
222
- -- with a flat surface where it would vanish to a line.
223
- local direction: Vector3
224
- if typeof(params.from) == "string" and params.from ~= "" then
225
- direction = toVector(params.from, "`from`").Unit
226
- else
227
- direction = Vector3.new(0.45, 0.35, 1).Unit
228
- end
229
-
230
- local eye = centre + direction * distance
231
- ;(camera :: Camera).CFrame = CFrame.lookAt(eye, centre)
232
- --[[
233
- `Focus` is not optional here, and leaving it out is a silent failure.
234
-
235
- Studio's edit camera orbits around Focus and re-derives its heading from
236
- it every frame. Setting CFrame alone applies -- and is then thrown away
237
- about a fifth of a second later, keeping the new position and restoring
238
- the old rotation. Measured: the look vector read back correct
239
- immediately, and had reverted to the previous heading by the next check,
240
- so the camera moved to the right place and pointed the wrong way, and a
241
- screenshot came back showing empty ground.
242
- ]]
243
- ;(camera :: Camera).Focus = CFrame.new(centre)
244
-
245
- return {
246
- focused = if typeof(params.path) == "string" then params.path else tostring(centre),
247
- cameraPosition = tostring(eye),
248
- lookingAt = tostring(centre),
249
- distance = math.floor(distance * 10 + 0.5) / 10,
250
- subjectSize = tostring(size),
251
- }
252
- end
253
-
254
- --[[
255
- Reads or sets the camera directly, for the cases framing cannot express --
256
- standing inside a room, or matching a specific shot.
257
- ]]
258
- function Viewport.camera(params: { [string]: any }): { [string]: any }
259
- if not rendersToScreen() then
260
- Dispatch.fail(
261
- "NOT_RENDERED",
262
- "This is the playtest's server session, which draws nothing -- moving its "
263
- .. "camera would change no picture anybody sees.",
264
- "Address the editor session (see `list_studios`), or stop the playtest first."
265
- )
266
- end
267
- local camera = Workspace.CurrentCamera
268
- if camera == nil then
269
- Dispatch.fail("NO_CAMERA", "This session has no camera.")
270
- end
271
- local view = camera :: Camera
272
-
273
- if params.position ~= nil then
274
- local eye = toVector(params.position, "`position`")
275
- local target = if params.lookAt ~= nil
276
- then toVector(params.lookAt, "`lookAt`")
277
- else eye + view.CFrame.LookVector
278
- view.CFrame = CFrame.lookAt(eye, target)
279
- -- Same reason as focus: without this the rotation survives a frame.
280
- view.Focus = CFrame.new(target)
281
- end
282
- if tonumber(params.fieldOfView) ~= nil then
283
- view.FieldOfView = math.clamp(tonumber(params.fieldOfView) :: number, 1, 120)
284
- end
285
-
286
- return {
287
- position = tostring(view.CFrame.Position),
288
- lookVector = tostring(view.CFrame.LookVector),
289
- fieldOfView = view.FieldOfView,
290
- }
291
- end
292
-
293
- function Viewport.register()
294
- Dispatch.registerAll("viewport", {
295
- raycast = Viewport.raycast,
296
- select = Viewport.select,
297
- focus = Viewport.focus,
298
- camera = Viewport.camera,
299
- })
300
- end
301
-
302
- return Viewport
1
+ --!strict
2
+ --[[
3
+ The viewport and the selection -- what the user is pointing at, and what they
4
+ have highlighted.
5
+
6
+ Selection is the counterpart to `studio_status`, which reports it: this sets
7
+ it. Selecting what a tool just built is the cheapest way to show someone the
8
+ result of a change, and it puts the instance in reach of Studio's own move
9
+ and scale handles.
10
+
11
+ Raycasting answers "what is under this point in the 3D view", which is
12
+ otherwise unanswerable: an agent can enumerate the data model but has no way
13
+ to know what occupies a piece of space.
14
+ ]]
15
+
16
+ local RunService = game:GetService("RunService")
17
+ local Selection = game:GetService("Selection")
18
+ local TextService = game:GetService("TextService")
19
+ local Workspace = game:GetService("Workspace")
20
+
21
+ local Dispatch = require(script.Parent.Parent.Dispatch)
22
+ local Paths = require(script.Parent.Parent.Paths)
23
+ local Serialize = require(script.Parent.Parent.Serialize)
24
+
25
+ local MAX_SELECTION = 200
26
+ local DEFAULT_RAY_LENGTH = 1000
27
+
28
+ local Viewport = {}
29
+
30
+ --[[
31
+ Whether moving the camera in this session would change what anyone sees.
32
+
33
+ A playtest server has a `CurrentCamera` and accepts every write to it, and
34
+ renders nothing -- the picture on screen comes from the playtest's client. So
35
+ framing a subject here succeeds, reports a plausible camera position, and
36
+ moves no camera the user has. Measured: `focus` against a playtest server
37
+ returned a full result for a shot nobody could ever see.
38
+ ]]
39
+ local function rendersToScreen(): boolean
40
+ return not (RunService:IsRunning() and RunService:IsServer())
41
+ end
42
+
43
+
44
+ local function toVector(value: any, label: string): Vector3
45
+ local ok, parsed = Serialize.parse(value, "Vector3")
46
+ if not ok or typeof(parsed) ~= "Vector3" then
47
+ Dispatch.fail(
48
+ "BAD_PARAMS",
49
+ string.format('%s must be three numbers, e.g. "0, 50, 0".', label)
50
+ )
51
+ end
52
+ return parsed :: Vector3
53
+ end
54
+
55
+ --[[
56
+ Casts a ray through the world and reports the first thing it hits.
57
+
58
+ `Workspace:Raycast`, not `StudioService:GizmoRaycast`. The latter reads like
59
+ the Studio-aware choice and is not: measured against a place with parts under
60
+ the ray, it returns nil every time. It picks Studio's own gizmos and
61
+ adornments, not world geometry.
62
+ ]]
63
+ function Viewport.raycast(params: { [string]: any }): { [string]: any }
64
+ local origin = toVector(params.origin, "origin")
65
+ local direction = toVector(params.direction, "direction")
66
+ local length = tonumber(params.maxDistance) or DEFAULT_RAY_LENGTH
67
+
68
+ local filter: { Instance } = {}
69
+ for _, path in (params.ignore or {}) :: { string } do
70
+ table.insert(filter, Paths.resolve(path))
71
+ end
72
+
73
+ local config = RaycastParams.new()
74
+ config.FilterType = Enum.RaycastFilterType.Exclude
75
+ config.FilterDescendantsInstances = filter
76
+
77
+ local ok, result = pcall(function()
78
+ return Workspace:Raycast(origin, direction.Unit * length, config)
79
+ end)
80
+
81
+ if not ok or result == nil then
82
+ return { hit = false, origin = Serialize.value(origin), direction = Serialize.value(direction.Unit) }
83
+ end
84
+
85
+ local cast = result :: RaycastResult
86
+ return {
87
+ hit = true,
88
+ path = if cast.Instance then Paths.of(cast.Instance) else nil,
89
+ className = if cast.Instance then cast.Instance.ClassName else nil,
90
+ position = Serialize.value(cast.Position),
91
+ normal = Serialize.value(cast.Normal),
92
+ distance = math.floor(cast.Distance * 1000 + 0.5) / 1000,
93
+ material = Serialize.value(cast.Material),
94
+ }
95
+ end
96
+
97
+ --[[
98
+ Sets, extends or shrinks the Studio selection.
99
+ ]]
100
+ function Viewport.select(params: { [string]: any }): { [string]: any }
101
+ local paths = params.paths
102
+ if typeof(paths) ~= "table" then
103
+ Dispatch.fail("BAD_PARAMS", "select requires a `paths` array.")
104
+ end
105
+ if #paths > MAX_SELECTION then
106
+ Dispatch.fail(
107
+ "TOO_MANY",
108
+ string.format("%d paths, over the %d selection limit.", #paths, MAX_SELECTION)
109
+ )
110
+ end
111
+
112
+ local instances: { Instance } = {}
113
+ for _, path in paths :: { string } do
114
+ table.insert(instances, Paths.resolve(path))
115
+ end
116
+
117
+ local mode = params.mode or "set"
118
+ if mode == "add" then
119
+ Selection:Add(instances)
120
+ elseif mode == "remove" then
121
+ Selection:Remove(instances)
122
+ else
123
+ Selection:Set(instances)
124
+ end
125
+
126
+ local current: { { [string]: any } } = {}
127
+ local memo: Paths.NameIndex = {}
128
+ for index, instance in Selection:Get() do
129
+ if index > MAX_SELECTION then
130
+ break
131
+ end
132
+ table.insert(current, { path = Paths.of(instance, memo), className = instance.ClassName })
133
+ end
134
+
135
+ return { items = current, count = #Selection:Get() }
136
+ end
137
+
138
+ --[[
139
+ The world-space box an instance occupies.
140
+
141
+ A Model knows its own extents; a lone part has to be measured from its size
142
+ and orientation. Anything else -- a Folder, a Script -- has no position at
143
+ all, and saying so is better than framing the origin and returning a picture
144
+ of empty ground.
145
+ ]]
146
+ local function extentsOf(instance: Instance): (Vector3, Vector3)
147
+ if instance:IsA("Model") then
148
+ local cframe, size = (instance :: Model):GetBoundingBox()
149
+ return cframe.Position, size
150
+ end
151
+ if instance:IsA("BasePart") then
152
+ local part = instance :: BasePart
153
+ return part.Position, part.Size
154
+ end
155
+
156
+ -- A container is worth framing if anything inside it has a position.
157
+ local minimum: Vector3? = nil
158
+ local maximum: Vector3? = nil
159
+ for _, descendant in instance:GetDescendants() do
160
+ if descendant:IsA("BasePart") then
161
+ local part = descendant :: BasePart
162
+ local half = part.Size / 2
163
+ minimum = if minimum == nil then part.Position - half else (minimum :: Vector3):Min(part.Position - half)
164
+ maximum = if maximum == nil then part.Position + half else (maximum :: Vector3):Max(part.Position + half)
165
+ end
166
+ end
167
+ if minimum == nil or maximum == nil then
168
+ Dispatch.fail(
169
+ "NOT_POSITIONED",
170
+ string.format("%s is a %s and occupies no space in the world.", instance:GetFullName(), instance.ClassName),
171
+ "Frame something with geometry -- a part, a model, or a folder containing them."
172
+ )
173
+ end
174
+ local low, high = minimum :: Vector3, maximum :: Vector3
175
+ return (low + high) / 2, high - low
176
+ end
177
+
178
+ --[[
179
+ Points the Studio camera at something, framed so all of it is on screen.
180
+
181
+ This is what makes `screenshot` worth having. A picture of wherever the user
182
+ last left their camera answers nothing; a picture of the thing just built
183
+ answers "does it look right", which the data model cannot.
184
+
185
+ The distance is computed from the subject's size and the camera's own field
186
+ of view rather than guessed, so a doorway and a whole map both arrive filling
187
+ a similar share of the frame.
188
+ ]]
189
+ function Viewport.focus(params: { [string]: any }): { [string]: any }
190
+ if not rendersToScreen() then
191
+ Dispatch.fail(
192
+ "NOT_RENDERED",
193
+ "This is the playtest's server session, which draws nothing -- moving its "
194
+ .. "camera would change no picture anybody sees.",
195
+ "Address the editor session (see `list_studios`), or stop the playtest first."
196
+ )
197
+ end
198
+ local camera = Workspace.CurrentCamera
199
+ if camera == nil then
200
+ Dispatch.fail("NO_CAMERA", "This session has no camera.")
201
+ end
202
+
203
+ local centre: Vector3
204
+ local size: Vector3
205
+ if typeof(params.path) == "string" and params.path ~= "" then
206
+ centre, size = extentsOf(Paths.resolve(params.path))
207
+ else
208
+ centre = toVector(params.at, "`at`")
209
+ size = Vector3.new(10, 10, 10)
210
+ end
211
+
212
+ --[[
213
+ Half the diagonal, not half the width: a wall seen from an angle presents
214
+ its diagonal, and framing to the width alone crops the corners off
215
+ exactly the shapes most worth looking at.
216
+ ]]
217
+ local radius = math.max(size.Magnitude / 2, 1)
218
+ local fov = math.rad((camera :: Camera).FieldOfView)
219
+ local distance = (radius / math.tan(fov / 2)) * (tonumber(params.padding) or 1.5)
220
+
221
+ -- Down the -Z axis and slightly above by default, which is how a person
222
+ -- would stand to look at something: face on, a little raised, never level
223
+ -- with a flat surface where it would vanish to a line.
224
+ local direction: Vector3
225
+ if typeof(params.from) == "string" and params.from ~= "" then
226
+ direction = toVector(params.from, "`from`").Unit
227
+ else
228
+ direction = Vector3.new(0.45, 0.35, 1).Unit
229
+ end
230
+
231
+ local eye = centre + direction * distance
232
+ ;(camera :: Camera).CFrame = CFrame.lookAt(eye, centre)
233
+ --[[
234
+ `Focus` is not optional here, and leaving it out is a silent failure.
235
+
236
+ Studio's edit camera orbits around Focus and re-derives its heading from
237
+ it every frame. Setting CFrame alone applies -- and is then thrown away
238
+ about a fifth of a second later, keeping the new position and restoring
239
+ the old rotation. Measured: the look vector read back correct
240
+ immediately, and had reverted to the previous heading by the next check,
241
+ so the camera moved to the right place and pointed the wrong way, and a
242
+ screenshot came back showing empty ground.
243
+ ]]
244
+ ;(camera :: Camera).Focus = CFrame.new(centre)
245
+
246
+ return {
247
+ focused = if typeof(params.path) == "string" then params.path else tostring(centre),
248
+ cameraPosition = tostring(eye),
249
+ lookingAt = tostring(centre),
250
+ distance = math.floor(distance * 10 + 0.5) / 10,
251
+ subjectSize = tostring(size),
252
+ }
253
+ end
254
+
255
+ --[[
256
+ Reads or sets the camera directly, for the cases framing cannot express --
257
+ standing inside a room, or matching a specific shot.
258
+ ]]
259
+ function Viewport.camera(params: { [string]: any }): { [string]: any }
260
+ if not rendersToScreen() then
261
+ Dispatch.fail(
262
+ "NOT_RENDERED",
263
+ "This is the playtest's server session, which draws nothing -- moving its "
264
+ .. "camera would change no picture anybody sees.",
265
+ "Address the editor session (see `list_studios`), or stop the playtest first."
266
+ )
267
+ end
268
+ local camera = Workspace.CurrentCamera
269
+ if camera == nil then
270
+ Dispatch.fail("NO_CAMERA", "This session has no camera.")
271
+ end
272
+ local view = camera :: Camera
273
+
274
+ if params.position ~= nil then
275
+ local eye = toVector(params.position, "`position`")
276
+ local target = if params.lookAt ~= nil
277
+ then toVector(params.lookAt, "`lookAt`")
278
+ else eye + view.CFrame.LookVector
279
+ view.CFrame = CFrame.lookAt(eye, target)
280
+ -- Same reason as focus: without this the rotation survives a frame.
281
+ view.Focus = CFrame.new(target)
282
+ end
283
+ if tonumber(params.fieldOfView) ~= nil then
284
+ view.FieldOfView = math.clamp(tonumber(params.fieldOfView) :: number, 1, 120)
285
+ end
286
+
287
+ return {
288
+ position = tostring(view.CFrame.Position),
289
+ lookVector = tostring(view.CFrame.LookVector),
290
+ fieldOfView = view.FieldOfView,
291
+ }
292
+ end
293
+
294
+ --[[
295
+ How big a piece of text actually renders.
296
+
297
+ Every alternative to asking the engine is a guess. Character counts do not
298
+ account for the font, font size does not give you a width, and "it looked
299
+ fine on my screen" is not a measurement -- so a label that overflows is
300
+ normally found by a human looking at it, which is exactly the loop an agent
301
+ cannot close on its own.
302
+
303
+ Pointed at an existing label (`path`) it reads that label's own text, font,
304
+ size and width and answers whether the text fits inside it. Given the values
305
+ directly it just measures. The first form is the useful one: it turns "will
306
+ this fit" into a yes or no instead of two numbers the caller has to compare
307
+ against the right box.
308
+ ]]
309
+ function Viewport.textbounds(params: { [string]: any }): { [string]: any }
310
+ local text = if typeof(params.text) == "string" then params.text else ""
311
+ local size = tonumber(params.size) or 14
312
+ local width = tonumber(params.width) or 0
313
+ local richText = params.richText == true
314
+ local fontFace: Font? = nil
315
+
316
+ local subject: GuiObject? = nil
317
+ if typeof(params.path) == "string" and params.path ~= "" then
318
+ local instance = Paths.resolve(params.path)
319
+ if not instance:IsA("TextLabel") and not instance:IsA("TextButton") and not instance:IsA("TextBox") then
320
+ Dispatch.fail(
321
+ "NOT_TEXT",
322
+ string.format("%s is a %s, which has no text.", params.path, instance.ClassName),
323
+ "Point at a TextLabel, TextButton or TextBox."
324
+ )
325
+ end
326
+ local label = instance :: any
327
+ subject = instance :: GuiObject
328
+ if text == "" then
329
+ text = label.Text
330
+ end
331
+ if params.size == nil then
332
+ --[[
333
+ `TextSize` is meaningless while `TextScaled` is on -- the engine
334
+ picks a size per frame -- so the rendered height is read off
335
+ `AbsoluteSize` instead of a property that is not being used.
336
+ ]]
337
+ size = if label.TextScaled then (instance :: GuiObject).AbsoluteSize.Y else label.TextSize
338
+ end
339
+ if params.richText == nil then
340
+ richText = label.RichText
341
+ end
342
+ if params.width == nil then
343
+ width = (instance :: GuiObject).AbsoluteSize.X
344
+ end
345
+ fontFace = label.FontFace
346
+ end
347
+
348
+ if fontFace == nil then
349
+ local name = tostring(params.font or "SourceSans")
350
+ local chosen = (Enum.Font :: any)[name]
351
+ if chosen == nil then
352
+ Dispatch.fail(
353
+ "BAD_PARAMS",
354
+ string.format("unknown font %q", name),
355
+ "Use an Enum.Font name, e.g. \"Gotham\", \"GothamMedium\", \"SourceSans\"."
356
+ )
357
+ end
358
+ fontFace = Font.fromEnum(chosen)
359
+ end
360
+
361
+ if text == "" then
362
+ Dispatch.fail("BAD_PARAMS", "textbounds needs `text`, or a `path` to something holding it.")
363
+ end
364
+
365
+ local request = Instance.new("GetTextBoundsParams")
366
+ request.Text = text
367
+ request.Size = size
368
+ request.Font = fontFace :: Font
369
+ request.RichText = richText
370
+ -- Zero means "do not wrap", which is what the engine wants for an unbounded
371
+ -- measurement; a negative width would be read as a very narrow column.
372
+ request.Width = math.max(width, 0)
373
+
374
+ local ok, bounds = pcall(function()
375
+ return TextService:GetTextBoundsAsync(request)
376
+ end)
377
+ if not ok then
378
+ Dispatch.fail("MEASURE_FAILED", string.format("GetTextBoundsAsync refused: %s", tostring(bounds)))
379
+ end
380
+
381
+ local measured = bounds :: Vector2
382
+ local report: { [string]: any } = {
383
+ width = math.ceil(measured.X),
384
+ height = math.ceil(measured.Y),
385
+ text = if #text > 120 then string.sub(text, 1, 117) .. "..." else text,
386
+ size = size,
387
+ -- `Family` is a content URI -- "rbxasset://fonts/families/GothamSSm.json"
388
+ -- -- which is the path to the font and not the name of it. The stem is
389
+ -- what a caller recognises and what they would type back.
390
+ font = string.match(tostring((fontFace :: Font).Family), "([^/]+)%.json$")
391
+ or tostring((fontFace :: Font).Family),
392
+ wrappedAt = if width > 0 then math.floor(width) else nil,
393
+ }
394
+
395
+ if subject then
396
+ local box = (subject :: GuiObject).AbsoluteSize
397
+ report.box = string.format("%d x %d", math.floor(box.X), math.floor(box.Y))
398
+ report.fits = measured.X <= box.X and measured.Y <= box.Y
399
+ report.overflowX = math.max(math.ceil(measured.X - box.X), 0)
400
+ report.overflowY = math.max(math.ceil(measured.Y - box.Y), 0)
401
+ end
402
+
403
+ return report
404
+ end
405
+
406
+ function Viewport.register()
407
+ Dispatch.registerAll("viewport", {
408
+ textbounds = Viewport.textbounds,
409
+ raycast = Viewport.raycast,
410
+ select = Viewport.select,
411
+ focus = Viewport.focus,
412
+ camera = Viewport.camera,
413
+ })
414
+ end
415
+
416
+ return Viewport