@el4cteo/rbx-studio-mcp 0.4.2 → 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.
- package/README.md +211 -211
- package/dist/bridge/api.js +3 -0
- package/dist/bridge/api.js.map +1 -1
- package/dist/bridge/failover.js +13 -0
- package/dist/bridge/failover.js.map +1 -1
- package/dist/bridge/remote.js +15 -1
- package/dist/bridge/remote.js.map +1 -1
- package/dist/bridge/rpc.js +35 -6
- package/dist/bridge/rpc.js.map +1 -1
- package/dist/bridge/server.js +55 -7
- package/dist/bridge/server.js.map +1 -1
- package/dist/doctor.js +160 -0
- package/dist/doctor.js.map +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/protocol.js.map +1 -1
- package/dist/tools/exec.js +53 -3
- package/dist/tools/exec.js.map +1 -1
- package/dist/tools/generate.js +120 -0
- package/dist/tools/generate.js.map +1 -0
- package/dist/tools/scripts.js +20 -1
- package/dist/tools/scripts.js.map +1 -1
- package/dist/tools/world.js +203 -20
- package/dist/tools/world.js.map +1 -1
- package/package.json +2 -1
- package/plugin/src/Config.luau +1 -1
- package/plugin/src/Console.luau +233 -9
- package/plugin/src/Transport.luau +6 -1
- package/plugin/src/Visuals.luau +52 -1
- package/plugin/src/handlers/Assets.luau +352 -145
- package/plugin/src/handlers/Generate.luau +386 -0
- package/plugin/src/handlers/Geometry.luau +170 -0
- package/plugin/src/handlers/Scripts.luau +79 -1
- package/plugin/src/handlers/Viewport.luau +416 -302
- package/plugin/src/handlers/World.luau +15 -5
- package/plugin/src/init.server.luau +725 -674
- package/scripts/test-bridge.mjs +56 -0
- 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
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
local
|
|
22
|
-
local
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
local
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
the
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
local
|
|
65
|
-
local
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
config
|
|
74
|
-
config.
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
local
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
local
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
local
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
"
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
answers
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
a
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
"
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
local
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
local
|
|
218
|
-
local
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
--
|
|
222
|
-
--
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
"
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
local
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|