@el4cteo/rbx-studio-mcp 0.1.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/LICENSE +21 -0
- package/README.md +203 -0
- package/dist/bridge/rpc.js +243 -0
- package/dist/bridge/rpc.js.map +1 -0
- package/dist/bridge/server.js +281 -0
- package/dist/bridge/server.js.map +1 -0
- package/dist/index.js +104 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/apidump.js +269 -0
- package/dist/lib/apidump.js.map +1 -0
- package/dist/lib/errors.js +38 -0
- package/dist/lib/errors.js.map +1 -0
- package/dist/lib/format.js +191 -0
- package/dist/lib/format.js.map +1 -0
- package/dist/lib/pluginbuild.js +83 -0
- package/dist/lib/pluginbuild.js.map +1 -0
- package/dist/lib/png.js +84 -0
- package/dist/lib/png.js.map +1 -0
- package/dist/lib/protocol.js +22 -0
- package/dist/lib/protocol.js.map +1 -0
- package/dist/lib/tool.js +27 -0
- package/dist/lib/tool.js.map +1 -0
- package/dist/resources.js +70 -0
- package/dist/resources.js.map +1 -0
- package/dist/tools/api.js +78 -0
- package/dist/tools/api.js.map +1 -0
- package/dist/tools/character.js +94 -0
- package/dist/tools/character.js.map +1 -0
- package/dist/tools/debug.js +211 -0
- package/dist/tools/debug.js.map +1 -0
- package/dist/tools/device.js +74 -0
- package/dist/tools/device.js.map +1 -0
- package/dist/tools/discover.js +217 -0
- package/dist/tools/discover.js.map +1 -0
- package/dist/tools/exec.js +191 -0
- package/dist/tools/exec.js.map +1 -0
- package/dist/tools/input.js +96 -0
- package/dist/tools/input.js.map +1 -0
- package/dist/tools/instances.js +261 -0
- package/dist/tools/instances.js.map +1 -0
- package/dist/tools/perf.js +367 -0
- package/dist/tools/perf.js.map +1 -0
- package/dist/tools/playtest.js +153 -0
- package/dist/tools/playtest.js.map +1 -0
- package/dist/tools/screenshot.js +75 -0
- package/dist/tools/screenshot.js.map +1 -0
- package/dist/tools/scripts.js +316 -0
- package/dist/tools/scripts.js.map +1 -0
- package/dist/tools/session.js +152 -0
- package/dist/tools/session.js.map +1 -0
- package/dist/tools/world.js +281 -0
- package/dist/tools/world.js.map +1 -0
- package/package.json +62 -0
- package/plugin/default.project.json +6 -0
- package/plugin/src/Config.luau +59 -0
- package/plugin/src/Console.luau +657 -0
- package/plugin/src/Context.luau +35 -0
- package/plugin/src/Dispatch.luau +90 -0
- package/plugin/src/Editor.luau +142 -0
- package/plugin/src/Emulation.luau +151 -0
- package/plugin/src/LogBuffer.luau +277 -0
- package/plugin/src/Net.luau +102 -0
- package/plugin/src/Paths.luau +255 -0
- package/plugin/src/Phrase.luau +465 -0
- package/plugin/src/Png.luau +238 -0
- package/plugin/src/Scope.luau +78 -0
- package/plugin/src/ScriptEdit.luau +100 -0
- package/plugin/src/Serialize.luau +287 -0
- package/plugin/src/TextEdit.luau +296 -0
- package/plugin/src/Transport.luau +328 -0
- package/plugin/src/Undo.luau +72 -0
- package/plugin/src/Visuals.luau +710 -0
- package/plugin/src/handlers/Api.luau +242 -0
- package/plugin/src/handlers/Assets.luau +145 -0
- package/plugin/src/handlers/Capture.luau +187 -0
- package/plugin/src/handlers/Character.luau +361 -0
- package/plugin/src/handlers/Debug.luau +391 -0
- package/plugin/src/handlers/Device.luau +119 -0
- package/plugin/src/handlers/Discover.luau +289 -0
- package/plugin/src/handlers/Exec.luau +270 -0
- package/plugin/src/handlers/Geometry.luau +261 -0
- package/plugin/src/handlers/Input.luau +287 -0
- package/plugin/src/handlers/Instances.luau +389 -0
- package/plugin/src/handlers/Perf.luau +645 -0
- package/plugin/src/handlers/Playtest.luau +205 -0
- package/plugin/src/handlers/Scripts.luau +387 -0
- package/plugin/src/handlers/Session.luau +168 -0
- package/plugin/src/handlers/Viewport.luau +302 -0
- package/plugin/src/handlers/World.luau +176 -0
- package/plugin/src/init.server.luau +317 -0
- package/scripts/build-plugin.mjs +157 -0
- package/scripts/check-plugin.mjs +97 -0
- package/scripts/install-plugin.mjs +39 -0
- package/scripts/latency.mjs +201 -0
- package/scripts/locate-luau.mjs +51 -0
- package/scripts/sourcemap.mjs +58 -0
- package/scripts/test-plugin.mjs +82 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
Session and connectivity handlers. `studio.status` is the cheapest call in the
|
|
4
|
+
system and doubles as the liveness probe, so it must never touch anything
|
|
5
|
+
expensive -- notably it counts descendants lazily and caps the selection.
|
|
6
|
+
]]
|
|
7
|
+
|
|
8
|
+
local MarketplaceService = game:GetService("MarketplaceService")
|
|
9
|
+
local RunService = game:GetService("RunService")
|
|
10
|
+
local Selection = game:GetService("Selection")
|
|
11
|
+
|
|
12
|
+
local Dispatch = require(script.Parent.Parent.Dispatch)
|
|
13
|
+
local Context = require(script.Parent.Parent.Context)
|
|
14
|
+
local Editor = require(script.Parent.Parent.Editor)
|
|
15
|
+
local Emulation = require(script.Parent.Parent.Emulation)
|
|
16
|
+
local Paths = require(script.Parent.Parent.Paths)
|
|
17
|
+
|
|
18
|
+
-- `version()` is marked deprecated but remains the only way to read the Studio
|
|
19
|
+
-- build, and the agent needs it to know which engine APIs actually exist in this
|
|
20
|
+
-- session. Bound indirectly so the deprecation lint stays enabled file-wide.
|
|
21
|
+
local studioVersion: () -> string = (version :: any)
|
|
22
|
+
|
|
23
|
+
-- Selection can be tens of thousands of instances after a rubber-band drag.
|
|
24
|
+
-- Anything past this is noise for an agent and would blow the response budget.
|
|
25
|
+
local MAX_SELECTION = 50
|
|
26
|
+
|
|
27
|
+
-- Counting descendants of a large place is O(n) over the whole DataModel, so it
|
|
28
|
+
-- is cached briefly; status is polled often enough for this to matter.
|
|
29
|
+
local COUNT_CACHE_SECONDS = 5
|
|
30
|
+
|
|
31
|
+
local cachedCounts: { descendants: number, scripts: number }? = nil
|
|
32
|
+
local cachedAt = 0
|
|
33
|
+
|
|
34
|
+
--[[
|
|
35
|
+
Which device Studio is pretending to be, or nil for none.
|
|
36
|
+
|
|
37
|
+
Reported here because nothing else says it. Device emulation resizes the
|
|
38
|
+
viewport and stays on until it is switched off, so every screenshot after it
|
|
39
|
+
comes back the wrong shape with no indication why -- and a caller who did not
|
|
40
|
+
set it, or who set it twenty calls ago, has no way to find out. `status` is
|
|
41
|
+
the call agents are told to make first, which makes it the right place for a
|
|
42
|
+
piece of session state that silently changes what everything else returns.
|
|
43
|
+
]]
|
|
44
|
+
local function counts(): { descendants: number, scripts: number }
|
|
45
|
+
local now = os.clock()
|
|
46
|
+
local cache = cachedCounts
|
|
47
|
+
if cache and now - cachedAt < COUNT_CACHE_SECONDS then
|
|
48
|
+
return cache
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
local descendants = 0
|
|
52
|
+
local scripts = 0
|
|
53
|
+
for _, instance in game:GetDescendants() do
|
|
54
|
+
descendants += 1
|
|
55
|
+
if instance:IsA("LuaSourceContainer") then
|
|
56
|
+
scripts += 1
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
local fresh = { descendants = descendants, scripts = scripts }
|
|
61
|
+
cachedCounts = fresh
|
|
62
|
+
cachedAt = now
|
|
63
|
+
return fresh
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
--[[
|
|
67
|
+
The name the user would recognise.
|
|
68
|
+
|
|
69
|
+
`game.Name` is the data model's name -- "Place1", "Place5" -- and has nothing
|
|
70
|
+
to do with what the place is called on the Studio tab or the Creator
|
|
71
|
+
Dashboard. Nothing local holds the real name: it lives on Roblox's servers, so
|
|
72
|
+
it takes a lookup. Without it, a user saying "the other place, the published
|
|
73
|
+
one" cannot be matched to any window, which is the whole point of being able
|
|
74
|
+
to drive several Studios at once.
|
|
75
|
+
|
|
76
|
+
Cached, including the failure, because an unpublished place would otherwise
|
|
77
|
+
make a doomed web request on every status call.
|
|
78
|
+
]]
|
|
79
|
+
local NAME_RETRY_SECONDS = 60
|
|
80
|
+
|
|
81
|
+
local resolvedName: string? = nil
|
|
82
|
+
local resolvedFor = -1
|
|
83
|
+
local resolvedAt = 0
|
|
84
|
+
|
|
85
|
+
local function publishedPlaceName(): string?
|
|
86
|
+
local placeId = game.PlaceId
|
|
87
|
+
-- A place that has never been saved to Roblox has no id and no remote name.
|
|
88
|
+
if placeId == 0 then
|
|
89
|
+
return nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
local fresh = resolvedFor == placeId
|
|
93
|
+
and (resolvedName ~= nil or os.clock() - resolvedAt < NAME_RETRY_SECONDS)
|
|
94
|
+
if fresh then
|
|
95
|
+
return resolvedName
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
resolvedFor = placeId
|
|
99
|
+
resolvedAt = os.clock()
|
|
100
|
+
|
|
101
|
+
local ok, info = pcall(function()
|
|
102
|
+
return MarketplaceService:GetProductInfo(placeId, Enum.InfoType.Asset)
|
|
103
|
+
end)
|
|
104
|
+
local name = if ok and typeof(info) == "table" then (info :: any).Name else nil
|
|
105
|
+
resolvedName = if typeof(name) == "string" and name ~= "" then name else nil
|
|
106
|
+
return resolvedName
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
local Session = {}
|
|
110
|
+
|
|
111
|
+
function Session.status(): { [string]: any }
|
|
112
|
+
local selected: { { path: string, className: string } } = {}
|
|
113
|
+
for index, instance in Selection:Get() do
|
|
114
|
+
if index > MAX_SELECTION then
|
|
115
|
+
break
|
|
116
|
+
end
|
|
117
|
+
table.insert(selected, { path = Paths.of(instance), className = instance.ClassName })
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
local totals = counts()
|
|
121
|
+
-- Open tabs ride along with status rather than getting their own tool: this
|
|
122
|
+
-- is the call agents are told to make first, and "which script is the user
|
|
123
|
+
-- looking at" is orientation, not a separate question.
|
|
124
|
+
local openScripts = Editor.documents()
|
|
125
|
+
|
|
126
|
+
local published = publishedPlaceName()
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
openScripts = if #openScripts > 0 then openScripts else nil,
|
|
130
|
+
placeName = published or (if game.Name ~= "" then game.Name else "Untitled place"),
|
|
131
|
+
-- Kept separate when the two differ, so a path rooted at the data model
|
|
132
|
+
-- name stays explicable next to a place called something else entirely.
|
|
133
|
+
dataModelName = if published and published ~= game.Name then game.Name else nil,
|
|
134
|
+
placeId = game.PlaceId,
|
|
135
|
+
context = Context.of(),
|
|
136
|
+
-- RunService:IsRunning() is true for both Play and Run; IsEdit()
|
|
137
|
+
-- distinguishes the authoring session from a live one.
|
|
138
|
+
isRunning = RunService:IsRunning(),
|
|
139
|
+
isEdit = RunService:IsEdit(),
|
|
140
|
+
isRunMode = RunService:IsRunning() and not RunService:IsClient(),
|
|
141
|
+
isServerView = RunService:IsServer(),
|
|
142
|
+
selection = selected,
|
|
143
|
+
selectionCount = #Selection:Get(),
|
|
144
|
+
descendantCount = totals.descendants,
|
|
145
|
+
scriptCount = totals.scripts,
|
|
146
|
+
studioVersion = studioVersion(),
|
|
147
|
+
emulatedDevice = Emulation.summary(),
|
|
148
|
+
}
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
--[[
|
|
152
|
+
Round-trip probe used by the smoke test and by `studio_status` when the agent
|
|
153
|
+
only needs to know the channel is alive.
|
|
154
|
+
]]
|
|
155
|
+
function Session.ping(params: { [string]: any }): { [string]: any }
|
|
156
|
+
return { pong = true, echo = params.echo }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
function Session.register()
|
|
160
|
+
Dispatch.registerAll("studio", {
|
|
161
|
+
status = function()
|
|
162
|
+
return Session.status()
|
|
163
|
+
end,
|
|
164
|
+
ping = Session.ping,
|
|
165
|
+
})
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
return Session
|
|
@@ -0,0 +1,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 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
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
Undo/redo, and collision groups.
|
|
4
|
+
|
|
5
|
+
Two small things that share a file because neither is big enough to deserve
|
|
6
|
+
its own and both are about the state of the place rather than its contents.
|
|
7
|
+
|
|
8
|
+
Undo matters more than it looks. Every write this server makes is already
|
|
9
|
+
wrapped in a ChangeHistoryService recording, so the user can revert any of it
|
|
10
|
+
by hand -- but the agent that made the mess could not clean it up, which made
|
|
11
|
+
"undo that" a request only a human could carry out. Now it can.
|
|
12
|
+
|
|
13
|
+
Collision groups are the engine's answer to "these parts should pass through
|
|
14
|
+
each other". Without them the alternative is toggling CanCollide, which turns
|
|
15
|
+
collision off against everything rather than against one class of thing.
|
|
16
|
+
]]
|
|
17
|
+
|
|
18
|
+
local ChangeHistoryService = game:GetService("ChangeHistoryService")
|
|
19
|
+
local PhysicsService = game:GetService("PhysicsService")
|
|
20
|
+
|
|
21
|
+
local Dispatch = require(script.Parent.Parent.Dispatch)
|
|
22
|
+
local Paths = require(script.Parent.Parent.Paths)
|
|
23
|
+
local Undo = require(script.Parent.Parent.Undo)
|
|
24
|
+
|
|
25
|
+
local World = {}
|
|
26
|
+
|
|
27
|
+
--[[
|
|
28
|
+
Steps the undo stack.
|
|
29
|
+
|
|
30
|
+
Reports what the stack looks like afterwards, because "undo" that quietly did
|
|
31
|
+
nothing -- an empty stack, or history disabled -- is indistinguishable from
|
|
32
|
+
one that worked unless the caller is told.
|
|
33
|
+
]]
|
|
34
|
+
function World.history(params: { [string]: any }): { [string]: any }
|
|
35
|
+
local action = tostring(params.action or "status")
|
|
36
|
+
local steps = math.clamp(tonumber(params.steps) or 1, 1, 25)
|
|
37
|
+
|
|
38
|
+
if action == "status" then
|
|
39
|
+
return {
|
|
40
|
+
canUndo = ChangeHistoryService:GetCanUndo(),
|
|
41
|
+
canRedo = ChangeHistoryService:GetCanRedo(),
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
if action ~= "undo" and action ~= "redo" then
|
|
46
|
+
Dispatch.fail("BAD_PARAMS", string.format("unknown history action %q", action))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
local applied = 0
|
|
50
|
+
for _ = 1, steps do
|
|
51
|
+
local can = if action == "undo"
|
|
52
|
+
then ChangeHistoryService:GetCanUndo()
|
|
53
|
+
else ChangeHistoryService:GetCanRedo()
|
|
54
|
+
if not can then
|
|
55
|
+
break
|
|
56
|
+
end
|
|
57
|
+
local ok = pcall(function()
|
|
58
|
+
if action == "undo" then
|
|
59
|
+
ChangeHistoryService:Undo()
|
|
60
|
+
else
|
|
61
|
+
ChangeHistoryService:Redo()
|
|
62
|
+
end
|
|
63
|
+
end)
|
|
64
|
+
if not ok then
|
|
65
|
+
break
|
|
66
|
+
end
|
|
67
|
+
applied += 1
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
action = action,
|
|
72
|
+
requested = steps,
|
|
73
|
+
applied = applied,
|
|
74
|
+
canUndo = ChangeHistoryService:GetCanUndo(),
|
|
75
|
+
canRedo = ChangeHistoryService:GetCanRedo(),
|
|
76
|
+
-- Said plainly rather than left to be inferred from applied == 0, which
|
|
77
|
+
-- is the case someone is most likely to misread as success.
|
|
78
|
+
note = if applied == 0
|
|
79
|
+
then string.format("Nothing to %s -- the history stack is empty in that direction.", action)
|
|
80
|
+
elseif applied < steps then string.format("Only %d of %d steps were available.", applied, steps)
|
|
81
|
+
else nil,
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
--[[
|
|
86
|
+
Collision groups: create them, set which collide with which, assign parts.
|
|
87
|
+
|
|
88
|
+
One call does all three, because they are never useful separately -- a group
|
|
89
|
+
nothing is assigned to has no effect, and an assignment to a group that does
|
|
90
|
+
not exist is an error.
|
|
91
|
+
]]
|
|
92
|
+
function World.collision(params: { [string]: any }): { [string]: any }
|
|
93
|
+
local action = tostring(params.action or "list")
|
|
94
|
+
|
|
95
|
+
if action == "list" then
|
|
96
|
+
local groups: { { [string]: any } } = {}
|
|
97
|
+
local ok, registered = pcall(function()
|
|
98
|
+
return PhysicsService:GetRegisteredCollisionGroups()
|
|
99
|
+
end)
|
|
100
|
+
if ok and typeof(registered) == "table" then
|
|
101
|
+
for _, group in registered :: { any } do
|
|
102
|
+
table.insert(groups, { name = group.name, mask = group.mask })
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
return { groups = groups }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
local name = params.group
|
|
109
|
+
if typeof(name) ~= "string" or name == "" then
|
|
110
|
+
Dispatch.fail("BAD_PARAMS", "collision needs a `group` name.")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
if action == "create" then
|
|
114
|
+
local ok, err = pcall(function()
|
|
115
|
+
PhysicsService:RegisterCollisionGroup(name)
|
|
116
|
+
end)
|
|
117
|
+
if not ok then
|
|
118
|
+
-- Already existing is the common case and is not a failure worth
|
|
119
|
+
-- refusing over: the caller wanted the group to exist, and it does.
|
|
120
|
+
if not string.find(tostring(err), "already", 1, true) then
|
|
121
|
+
Dispatch.fail("COLLISION_FAILED", string.format("Could not create %q: %s", name, tostring(err)))
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
return { group = name, created = ok, existed = not ok }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
if action == "assign" then
|
|
128
|
+
local paths = params.paths
|
|
129
|
+
if typeof(paths) ~= "table" or #(paths :: { any }) == 0 then
|
|
130
|
+
Dispatch.fail("BAD_PARAMS", "assign needs a `paths` array.")
|
|
131
|
+
end
|
|
132
|
+
local assigned: { string } = {}
|
|
133
|
+
local _, undoable = Undo.record("MCPCollision", "MCP collision group", function()
|
|
134
|
+
for _, path in paths :: { string } do
|
|
135
|
+
local instance = Paths.resolve(path)
|
|
136
|
+
-- A model is what a caller usually means; assigning its parts is
|
|
137
|
+
-- what they want, and refusing would just make them enumerate.
|
|
138
|
+
local targets = if instance:IsA("BasePart") then { instance } else instance:GetDescendants()
|
|
139
|
+
for _, target in targets do
|
|
140
|
+
if target:IsA("BasePart") then
|
|
141
|
+
(target :: BasePart).CollisionGroup = name
|
|
142
|
+
table.insert(assigned, Paths.of(target))
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end)
|
|
147
|
+
return { group = name, assigned = #assigned, parts = assigned, undoable = undoable }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
if action == "collidable" then
|
|
151
|
+
local other = params.with
|
|
152
|
+
if typeof(other) ~= "string" or other == "" then
|
|
153
|
+
Dispatch.fail("BAD_PARAMS", "collidable needs `with`, the other group's name.")
|
|
154
|
+
end
|
|
155
|
+
local collidable = params.collidable ~= false
|
|
156
|
+
local ok, err = pcall(function()
|
|
157
|
+
PhysicsService:CollisionGroupSetCollidable(name, other, collidable)
|
|
158
|
+
end)
|
|
159
|
+
if not ok then
|
|
160
|
+
Dispatch.fail("COLLISION_FAILED", string.format("Could not set collidability: %s", tostring(err)))
|
|
161
|
+
end
|
|
162
|
+
return { group = name, with = other, collidable = collidable }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
Dispatch.fail("BAD_PARAMS", string.format("unknown collision action %q", action))
|
|
166
|
+
return {}
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
function World.register()
|
|
170
|
+
Dispatch.registerAll("world", {
|
|
171
|
+
history = World.history,
|
|
172
|
+
collision = World.collision,
|
|
173
|
+
})
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
return World
|