@el4cteo/rbx-studio-mcp 0.7.7 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +175 -175
- package/dist/bridge/harness.js +14 -5
- package/dist/bridge/harness.js.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/lib/apidump.js +38 -23
- package/dist/lib/apidump.js.map +1 -1
- package/dist/lib/format.js +43 -4
- package/dist/lib/format.js.map +1 -1
- package/dist/lib/pluginbuild.js +10 -0
- package/dist/lib/pluginbuild.js.map +1 -1
- package/dist/lib/png.js +104 -0
- package/dist/lib/png.js.map +1 -1
- package/dist/resources.js +3 -2
- package/dist/resources.js.map +1 -1
- package/dist/tools/api.js +4 -3
- package/dist/tools/api.js.map +1 -1
- package/dist/tools/debug.js +2 -2
- package/dist/tools/debug.js.map +1 -1
- package/dist/tools/device.js +2 -2
- package/dist/tools/device.js.map +1 -1
- package/dist/tools/discover.js +7 -3
- package/dist/tools/discover.js.map +1 -1
- package/dist/tools/exec.js +4 -4
- package/dist/tools/exec.js.map +1 -1
- package/dist/tools/instances.js +32 -7
- package/dist/tools/instances.js.map +1 -1
- package/dist/tools/perf.js +35 -13
- package/dist/tools/perf.js.map +1 -1
- package/dist/tools/playtest.js +34 -13
- package/dist/tools/playtest.js.map +1 -1
- package/dist/tools/screenshot.js +55 -11
- package/dist/tools/screenshot.js.map +1 -1
- package/dist/tools/scripts.js +4 -4
- package/dist/tools/scripts.js.map +1 -1
- package/dist/tools/terrain.js +3 -3
- package/dist/tools/terrain.js.map +1 -1
- package/dist/tools/world.js +8 -8
- package/dist/tools/world.js.map +1 -1
- package/package.json +75 -75
- package/plugin/src/ClientRelay.luau +128 -1
- package/plugin/src/Config.luau +1 -1
- package/plugin/src/LogBuffer.luau +363 -277
- package/plugin/src/Paths.luau +15 -4
- package/plugin/src/Phrase.luau +1 -20
- package/plugin/src/ScriptEdit.luau +14 -0
- package/plugin/src/Serialize.luau +17 -0
- package/plugin/src/TextEdit.luau +6 -0
- package/plugin/src/handlers/Api.luau +8 -0
- package/plugin/src/handlers/Capture.luau +219 -3
- package/plugin/src/handlers/Perf.luau +913 -889
- package/plugin/src/handlers/Playtest.luau +6 -1
- package/plugin/src/init.server.luau +3 -2
- package/scripts/test-live.mjs +106 -1
- package/scripts/test-plugin.mjs +2 -0
- package/scripts/test-tools.mjs +259 -162
- package/dist/tools/anim.js +0 -159
- package/dist/tools/anim.js.map +0 -1
- package/plugin/src/handlers/Anim.luau +0 -897
package/plugin/src/Paths.luau
CHANGED
|
@@ -147,13 +147,19 @@ local function childBySegment(
|
|
|
147
147
|
|
|
148
148
|
if ordinal then
|
|
149
149
|
local list = childrenByName(parent, nil)[name]
|
|
150
|
-
if list
|
|
151
|
-
return nil, nil
|
|
152
|
-
end
|
|
153
|
-
local child = list[ordinal]
|
|
150
|
+
local child = if list then list[ordinal] else nil
|
|
154
151
|
if child then
|
|
155
152
|
return child, nil
|
|
156
153
|
end
|
|
154
|
+
-- Not an index after all: a child literally named "x[1]". Typed by hand,
|
|
155
|
+
-- or from a path emitted before `Paths.of` began marking such names.
|
|
156
|
+
local literal = parent:FindFirstChild(segment)
|
|
157
|
+
if literal then
|
|
158
|
+
return literal, nil
|
|
159
|
+
end
|
|
160
|
+
if list == nil then
|
|
161
|
+
return nil, nil
|
|
162
|
+
end
|
|
157
163
|
return nil,
|
|
158
164
|
string.format(
|
|
159
165
|
'"%s" has %d child%s named "%s", so [%d] is past the end. Indexes are '
|
|
@@ -343,6 +349,11 @@ function Paths.of(instance: Instance, memo: NameIndex?): string
|
|
|
343
349
|
while current ~= nil and current ~= game do
|
|
344
350
|
local node = current :: Instance
|
|
345
351
|
local ordinal = siblingIndex(node, memo)
|
|
352
|
+
-- A name that itself ends in "[n]" would read back as an index into a
|
|
353
|
+
-- shorter name. An explicit index keeps it literal: "x[1][1]".
|
|
354
|
+
if ordinal == nil and string.match(node.Name, "%[%d+%]$") then
|
|
355
|
+
ordinal = 1
|
|
356
|
+
end
|
|
346
357
|
table.insert(
|
|
347
358
|
segments,
|
|
348
359
|
1,
|
package/plugin/src/Phrase.luau
CHANGED
|
@@ -566,29 +566,11 @@ local DESCRIBERS: { [string]: Describer } = {
|
|
|
566
566
|
|
|
567
567
|
--[[
|
|
568
568
|
Everything below was added after the first pass over this table, and each
|
|
569
|
-
one spent time showing as the fallback -- "
|
|
569
|
+
one spent time showing as the fallback -- "Data set",
|
|
570
570
|
"Terrain fill". That form is honest but says nothing about the subject,
|
|
571
571
|
which is the whole point of this module: the panel is watched to see WHAT
|
|
572
572
|
an agent is touching, and 26 of 72 operations were declining to say.
|
|
573
573
|
]]
|
|
574
|
-
["anim.read"] = function(params)
|
|
575
|
-
local name = leaf(params.assetId)
|
|
576
|
-
return if name ~= nil then "Read the animation " .. name else "Read an animation"
|
|
577
|
-
end,
|
|
578
|
-
["anim.build"] = function(params)
|
|
579
|
-
local frames = count(params.keyframes)
|
|
580
|
-
return string.format("Build an animation from %s", plural(frames, "keyframe"))
|
|
581
|
-
end,
|
|
582
|
-
["anim.preview"] = function(params)
|
|
583
|
-
local rig = leaf(params.rig) or "a rig"
|
|
584
|
-
if params.op == "stop" then
|
|
585
|
-
return "Clear the pose on " .. rig
|
|
586
|
-
end
|
|
587
|
-
local at = tonumber(params.at)
|
|
588
|
-
return if at ~= nil
|
|
589
|
-
then string.format("Pose %s at %.2fs", rig, at)
|
|
590
|
-
else "Pose " .. rig
|
|
591
|
-
end,
|
|
592
574
|
|
|
593
575
|
["data.list"] = function(params)
|
|
594
576
|
local store = params.store
|
|
@@ -771,7 +753,6 @@ local KINDS: { [string]: string } = {
|
|
|
771
753
|
]]
|
|
772
754
|
terrain = "write",
|
|
773
755
|
generate = "write",
|
|
774
|
-
anim = "write",
|
|
775
756
|
data = "write",
|
|
776
757
|
}
|
|
777
758
|
|
|
@@ -67,6 +67,20 @@ local function confirm(target: LuaSourceContainer, produced: string?)
|
|
|
67
67
|
return
|
|
68
68
|
end
|
|
69
69
|
|
|
70
|
+
--[[
|
|
71
|
+
No document, no race. The overwrite comes from an editor tab that was
|
|
72
|
+
loading while we wrote; a tab opened afterwards loads the new source. So
|
|
73
|
+
a script nobody has open is confirmed by one read, instead of paying the
|
|
74
|
+
settle -- which cost every written script 200ms, one after another, on
|
|
75
|
+
every script_edit and script_create.
|
|
76
|
+
]]
|
|
77
|
+
local found, document = pcall(function()
|
|
78
|
+
return ScriptEditorService:FindScriptDocument(target)
|
|
79
|
+
end)
|
|
80
|
+
if found and document == nil and ScriptEdit.read(target) == produced then
|
|
81
|
+
return
|
|
82
|
+
end
|
|
83
|
+
|
|
70
84
|
-- Three rounds: the race is with one load, so a single rewrite settles it,
|
|
71
85
|
-- and a script that discards three identical writes is failing for a reason
|
|
72
86
|
-- repeating will not fix.
|
|
@@ -403,6 +403,11 @@ function Serialize.parse(text: any, valueType: string): (boolean, any, string?)
|
|
|
403
403
|
return true, Vector2.new(numbers[1], numbers[2]), nil
|
|
404
404
|
end
|
|
405
405
|
if valueType == "Color3" then
|
|
406
|
+
-- "#FF8800", the way colours are usually written everywhere else.
|
|
407
|
+
local hex = if kind == "string" then string.match(text, "^%s*#(%x%x%x%x%x%x)%s*$") else nil
|
|
408
|
+
if hex then
|
|
409
|
+
return true, Color3.fromHex(hex), nil
|
|
410
|
+
end
|
|
406
411
|
if #numbers < 3 then
|
|
407
412
|
return false, nil, 'expected three 0-1 components, e.g. "1, 0.5, 0"'
|
|
408
413
|
end
|
|
@@ -435,6 +440,18 @@ function Serialize.parse(text: any, valueType: string): (boolean, any, string?)
|
|
|
435
440
|
return true, UDim.new(numbers[1], numbers[2]), nil
|
|
436
441
|
end
|
|
437
442
|
if valueType == "CFrame" then
|
|
443
|
+
-- Twelve numbers are a full matrix -- what `tostring(cframe)` prints. Read
|
|
444
|
+
-- as "position | rotation" they gave a silently wrong orientation.
|
|
445
|
+
if #numbers == 12 then
|
|
446
|
+
return true,
|
|
447
|
+
CFrame.new(
|
|
448
|
+
numbers[1], numbers[2], numbers[3],
|
|
449
|
+
numbers[4], numbers[5], numbers[6],
|
|
450
|
+
numbers[7], numbers[8], numbers[9],
|
|
451
|
+
numbers[10], numbers[11], numbers[12]
|
|
452
|
+
),
|
|
453
|
+
nil
|
|
454
|
+
end
|
|
438
455
|
if #numbers >= 6 then
|
|
439
456
|
return true,
|
|
440
457
|
CFrame.new(numbers[1], numbers[2], numbers[3])
|
package/plugin/src/TextEdit.luau
CHANGED
|
@@ -263,6 +263,12 @@ function TextEdit.apply(path: string, source: string, edits: { Edit }): string
|
|
|
263
263
|
end
|
|
264
264
|
|
|
265
265
|
local text = table.concat(lines, "\n")
|
|
266
|
+
-- `toLines` drops the final newline, so it is put back here. Without this
|
|
267
|
+
-- every edit -- even a find/replace in the middle -- stripped it silently,
|
|
268
|
+
-- and a `find` that included it could never match.
|
|
269
|
+
if string.sub(source, -1) == "\n" and #lines > 0 then
|
|
270
|
+
text ..= "\n"
|
|
271
|
+
end
|
|
266
272
|
|
|
267
273
|
for _, edit in findEdits do
|
|
268
274
|
local needle = edit.find :: string
|
|
@@ -122,6 +122,14 @@ local function collect(entries: { any }, className: string, wantInherited: boole
|
|
|
122
122
|
deprecated += 1
|
|
123
123
|
continue
|
|
124
124
|
end
|
|
125
|
+
-- No permits at all means no script can touch it: Part's old `shap` and
|
|
126
|
+
-- `shape` aliases, or engine-only calls like GetPhysicsCost. The engine
|
|
127
|
+
-- does not flag those as deprecated, so they are held back here too.
|
|
128
|
+
local permits = if typeof(entry) == "table" then (entry :: any).Permits else nil
|
|
129
|
+
if typeof(permits) == "table" and next(permits) == nil then
|
|
130
|
+
deprecated += 1
|
|
131
|
+
continue
|
|
132
|
+
end
|
|
125
133
|
local own = typeof(entry) ~= "table" or tostring((entry :: any).Owner) == className
|
|
126
134
|
if not own then
|
|
127
135
|
inherited += 1
|
|
@@ -21,6 +21,7 @@ local RunService = game:GetService("RunService")
|
|
|
21
21
|
|
|
22
22
|
local Dispatch = require(script.Parent.Parent.Dispatch)
|
|
23
23
|
local Emulation = require(script.Parent.Parent.Emulation)
|
|
24
|
+
local Paths = require(script.Parent.Parent.Paths)
|
|
24
25
|
local Png = require(script.Parent.Parent.Png)
|
|
25
26
|
|
|
26
27
|
-- Wide enough to read a GUI label, small enough that the encode stays quick and
|
|
@@ -445,7 +446,98 @@ end
|
|
|
445
446
|
and read in another: the id is all that travels, and this is the reading
|
|
446
447
|
half, which needs plugin identity and therefore an editor session.
|
|
447
448
|
]]
|
|
448
|
-
|
|
449
|
+
-- In capture pixels, or in viewport pixels when `viewportWidth` says so.
|
|
450
|
+
export type Region = { x: number, y: number, width: number, height: number, viewportWidth: number? }
|
|
451
|
+
|
|
452
|
+
--[[
|
|
453
|
+
The full-resolution path: the captured pixels themselves, cropped to `region`
|
|
454
|
+
when one is given, compressed for the wire and scaled down in Node.
|
|
455
|
+
|
|
456
|
+
The engine resample below is bilinear, which only averages the pixels it
|
|
457
|
+
merges at exactly 2x -- beyond that it skips source pixels, and a 1661-wide
|
|
458
|
+
viewport scaled to 800 lost thin edges and broke small GUI text apart. Node
|
|
459
|
+
scales with a box filter instead, where every source pixel counts. Measured
|
|
460
|
+
cost of sending the full frame: ~60ms of a ~550ms call, most of which is the
|
|
461
|
+
engine's own capture.
|
|
462
|
+
|
|
463
|
+
Returns nil when this Studio cannot do it (no EncodingService), so the caller
|
|
464
|
+
falls back to the engine resample.
|
|
465
|
+
]]
|
|
466
|
+
local function encodeFull(
|
|
467
|
+
image: any,
|
|
468
|
+
sourceWidth: number,
|
|
469
|
+
sourceHeight: number,
|
|
470
|
+
width: number,
|
|
471
|
+
context: string,
|
|
472
|
+
region: Region?
|
|
473
|
+
): { [string]: any }?
|
|
474
|
+
local x, y, w, h = 0, 0, sourceWidth, sourceHeight
|
|
475
|
+
if region then
|
|
476
|
+
-- Viewport and capture pixels differ under display scaling.
|
|
477
|
+
local ratio = if region.viewportWidth then sourceWidth / region.viewportWidth else 1
|
|
478
|
+
region = {
|
|
479
|
+
x = region.x * ratio,
|
|
480
|
+
y = region.y * ratio,
|
|
481
|
+
width = region.width * ratio,
|
|
482
|
+
height = region.height * ratio,
|
|
483
|
+
}
|
|
484
|
+
x = math.clamp(math.floor(region.x), 0, sourceWidth)
|
|
485
|
+
y = math.clamp(math.floor(region.y), 0, sourceHeight)
|
|
486
|
+
w = math.min(math.ceil(region.width), sourceWidth - x)
|
|
487
|
+
h = math.min(math.ceil(region.height), sourceHeight - y)
|
|
488
|
+
if w < 1 or h < 1 then
|
|
489
|
+
Dispatch.fail(
|
|
490
|
+
"REGION_OFFSCREEN",
|
|
491
|
+
string.format(
|
|
492
|
+
"The region (%d, %d, %d x %d) is outside the %d x %d viewport.",
|
|
493
|
+
region.x,
|
|
494
|
+
region.y,
|
|
495
|
+
region.width,
|
|
496
|
+
region.height,
|
|
497
|
+
sourceWidth,
|
|
498
|
+
sourceHeight
|
|
499
|
+
),
|
|
500
|
+
"Aim the camera at it first with `viewport op=\"focus\"`, then take the screenshot."
|
|
501
|
+
)
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
local ok, result = pcall(function()
|
|
506
|
+
local rgba = image:ReadPixelsBuffer(Vector2.new(x, y), Vector2.new(w, h))
|
|
507
|
+
local rgb = stripAlpha(rgba, w, h)
|
|
508
|
+
local compressed = (EncodingService :: any):CompressBuffer(
|
|
509
|
+
rgb,
|
|
510
|
+
(Enum :: any).CompressionAlgorithm.Zstd,
|
|
511
|
+
COMPRESSION_LEVEL
|
|
512
|
+
)
|
|
513
|
+
return {
|
|
514
|
+
rgb = rgb,
|
|
515
|
+
packed = buffer.tostring((EncodingService :: any):Base64Encode(compressed)),
|
|
516
|
+
}
|
|
517
|
+
end)
|
|
518
|
+
if not ok then
|
|
519
|
+
return nil
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
return {
|
|
523
|
+
encoding = "zstd-rgb",
|
|
524
|
+
data = result.packed,
|
|
525
|
+
width = w,
|
|
526
|
+
height = h,
|
|
527
|
+
-- Node scales to this with a box filter; see above.
|
|
528
|
+
scaleTo = width,
|
|
529
|
+
sourceWidth = sourceWidth,
|
|
530
|
+
sourceHeight = sourceHeight,
|
|
531
|
+
region = if region then { x = x, y = y, width = w, height = h } else nil,
|
|
532
|
+
rawBytes = buffer.len(result.rgb),
|
|
533
|
+
bytes = #result.packed,
|
|
534
|
+
context = context,
|
|
535
|
+
black = isFlat(result.rgb),
|
|
536
|
+
device = Emulation.deviceId(),
|
|
537
|
+
}
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
local function encode(contentId: string, width: number, context: string, region: Region?): { [string]: any }
|
|
449
541
|
local okImage, image = pcall(function()
|
|
450
542
|
return AssetService:CreateEditableImageAsync(Content.fromUri(contentId))
|
|
451
543
|
end)
|
|
@@ -460,6 +552,21 @@ local function encode(contentId: string, width: number, context: string): { [str
|
|
|
460
552
|
local sourceWidth = math.floor(size.X)
|
|
461
553
|
local sourceHeight = math.floor(size.Y)
|
|
462
554
|
|
|
555
|
+
local full = encodeFull(image, sourceWidth, sourceHeight, width, context, region)
|
|
556
|
+
if full then
|
|
557
|
+
pcall(function()
|
|
558
|
+
(image :: any):Destroy()
|
|
559
|
+
end)
|
|
560
|
+
return full
|
|
561
|
+
end
|
|
562
|
+
if region then
|
|
563
|
+
Dispatch.fail(
|
|
564
|
+
"REGION_UNSUPPORTED",
|
|
565
|
+
"This Studio build cannot crop a screenshot (EncodingService is missing).",
|
|
566
|
+
"Take the full screenshot instead, or update Studio."
|
|
567
|
+
)
|
|
568
|
+
end
|
|
569
|
+
|
|
463
570
|
local rgb, outWidth, outHeight = resample(image, sourceWidth, sourceHeight, width)
|
|
464
571
|
|
|
465
572
|
if rgb == nil then
|
|
@@ -544,6 +651,111 @@ local function encode(contentId: string, width: number, context: string): { [str
|
|
|
544
651
|
}
|
|
545
652
|
end
|
|
546
653
|
|
|
654
|
+
-- Room left around a zoomed subject, as a share of its size, so its edges are
|
|
655
|
+
-- visible rather than cut exactly at the frame.
|
|
656
|
+
local REGION_PADDING = 0.08
|
|
657
|
+
|
|
658
|
+
--[[
|
|
659
|
+
Where on screen a GUI element or a piece of the world is, in viewport pixels.
|
|
660
|
+
|
|
661
|
+
GUI positions leave out the top inset, so it is added back; a 3D subject is
|
|
662
|
+
the screen box around its bounding box's eight corners. A subject partly
|
|
663
|
+
behind the camera has no honest box, so that is refused with the fix.
|
|
664
|
+
]]
|
|
665
|
+
local function boundsOf(target: Instance): (Vector2, Vector2)
|
|
666
|
+
if target:IsA("GuiObject") then
|
|
667
|
+
local inset = game:GetService("GuiService"):GetGuiInset()
|
|
668
|
+
return target.AbsolutePosition + inset, target.AbsolutePosition + target.AbsoluteSize + inset
|
|
669
|
+
end
|
|
670
|
+
|
|
671
|
+
local cframe: CFrame, size: Vector3
|
|
672
|
+
if target:IsA("BasePart") then
|
|
673
|
+
cframe, size = target.CFrame, target.Size
|
|
674
|
+
elseif target:IsA("Model") then
|
|
675
|
+
cframe, size = target:GetBoundingBox()
|
|
676
|
+
else
|
|
677
|
+
local low, high = Vector3.one * math.huge, -Vector3.one * math.huge
|
|
678
|
+
local counted = 0
|
|
679
|
+
for _, descendant in target:GetDescendants() do
|
|
680
|
+
if descendant:IsA("BasePart") then
|
|
681
|
+
local half = descendant.Size / 2
|
|
682
|
+
for _, corner in { Vector3.new(-1, -1, -1), Vector3.new(1, 1, 1), Vector3.new(-1, 1, -1), Vector3.new(1, -1, 1) } do
|
|
683
|
+
local point = descendant.CFrame:PointToWorldSpace(half * corner)
|
|
684
|
+
low, high = low:Min(point), high:Max(point)
|
|
685
|
+
end
|
|
686
|
+
counted += 1
|
|
687
|
+
if counted >= 2000 then
|
|
688
|
+
break
|
|
689
|
+
end
|
|
690
|
+
end
|
|
691
|
+
end
|
|
692
|
+
if counted == 0 then
|
|
693
|
+
Dispatch.fail(
|
|
694
|
+
"NOT_VISUAL",
|
|
695
|
+
string.format("%s has no GUI or parts to zoom to.", target:GetFullName()),
|
|
696
|
+
"Zoom to a GuiObject, a BasePart, a Model, or a folder containing parts."
|
|
697
|
+
)
|
|
698
|
+
end
|
|
699
|
+
cframe, size = CFrame.new((low + high) / 2), high - low
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
local camera = workspace.CurrentCamera
|
|
703
|
+
local low, high = Vector2.one * math.huge, -Vector2.one * math.huge
|
|
704
|
+
for _, sx in { -1, 1 } do
|
|
705
|
+
for _, sy in { -1, 1 } do
|
|
706
|
+
for _, sz in { -1, 1 } do
|
|
707
|
+
local point, _ = camera:WorldToViewportPoint(cframe:PointToWorldSpace(size / 2 * Vector3.new(sx, sy, sz)))
|
|
708
|
+
if point.Z <= 0 then
|
|
709
|
+
Dispatch.fail(
|
|
710
|
+
"REGION_OFFSCREEN",
|
|
711
|
+
string.format("%s is partly behind the camera.", target:GetFullName()),
|
|
712
|
+
"Aim the camera at it first with `viewport op=\"focus\"`, then take the screenshot."
|
|
713
|
+
)
|
|
714
|
+
end
|
|
715
|
+
local flat = Vector2.new(point.X, point.Y)
|
|
716
|
+
low, high = low:Min(flat), high:Max(flat)
|
|
717
|
+
end
|
|
718
|
+
end
|
|
719
|
+
end
|
|
720
|
+
return low, high
|
|
721
|
+
end
|
|
722
|
+
|
|
723
|
+
--[[
|
|
724
|
+
The part of the capture to keep, in capture pixels.
|
|
725
|
+
|
|
726
|
+
`path` zooms to an instance; `rect` is "x, y, width, height" in the pixels of
|
|
727
|
+
the full-resolution capture, which every screenshot caption states. A path
|
|
728
|
+
is resolved before the capture, so a bad one fails without taking it.
|
|
729
|
+
]]
|
|
730
|
+
local function regionFor(params: { [string]: any }): Region?
|
|
731
|
+
if typeof(params.rect) == "string" and params.rect ~= "" then
|
|
732
|
+
local numbers = {}
|
|
733
|
+
for token in string.gmatch(params.rect, "-?[%d%.]+") do
|
|
734
|
+
table.insert(numbers, tonumber(token))
|
|
735
|
+
end
|
|
736
|
+
if #numbers ~= 4 or numbers[3] <= 0 or numbers[4] <= 0 then
|
|
737
|
+
Dispatch.fail("BAD_PARAMS", 'rect must be "x, y, width, height", e.g. "400, 200, 320, 180".')
|
|
738
|
+
end
|
|
739
|
+
return { x = numbers[1], y = numbers[2], width = numbers[3], height = numbers[4] }
|
|
740
|
+
end
|
|
741
|
+
|
|
742
|
+
if typeof(params.path) ~= "string" or params.path == "" then
|
|
743
|
+
return nil
|
|
744
|
+
end
|
|
745
|
+
local target = Paths.resolve(params.path)
|
|
746
|
+
local low, high = boundsOf(target)
|
|
747
|
+
local extent = high - low
|
|
748
|
+
local pad = Vector2.new(math.max(8, extent.X * REGION_PADDING), math.max(8, extent.Y * REGION_PADDING))
|
|
749
|
+
low, high = low - pad, high + pad
|
|
750
|
+
return {
|
|
751
|
+
x = low.X,
|
|
752
|
+
y = low.Y,
|
|
753
|
+
width = high.X - low.X,
|
|
754
|
+
height = high.Y - low.Y,
|
|
755
|
+
viewportWidth = workspace.CurrentCamera.ViewportSize.X,
|
|
756
|
+
}
|
|
757
|
+
end
|
|
758
|
+
|
|
547
759
|
function Capture.screenshot(params: { [string]: any }): { [string]: any }
|
|
548
760
|
--[[
|
|
549
761
|
Refused rather than half-done. A playtest screenshot needs two sessions
|
|
@@ -562,7 +774,9 @@ function Capture.screenshot(params: { [string]: any }): { [string]: any }
|
|
|
562
774
|
end
|
|
563
775
|
|
|
564
776
|
local width = math.clamp(tonumber(params.width) or DEFAULT_WIDTH, MIN_WIDTH, MAX_WIDTH)
|
|
565
|
-
|
|
777
|
+
-- Before the capture, so a path that does not resolve costs no screenshot.
|
|
778
|
+
local region = regionFor(params)
|
|
779
|
+
return encode(takeScreenshot(), width, if RunService:IsEdit() then "edit" else "playtest", region)
|
|
566
780
|
end
|
|
567
781
|
|
|
568
782
|
--[[
|
|
@@ -579,7 +793,9 @@ function Capture.decode(params: { [string]: any }): { [string]: any }
|
|
|
579
793
|
|
|
580
794
|
local width = math.clamp(tonumber(params.width) or DEFAULT_WIDTH, MIN_WIDTH, MAX_WIDTH)
|
|
581
795
|
local context = if typeof(params.context) == "string" then params.context else "playtest client"
|
|
582
|
-
|
|
796
|
+
-- Only `rect` here: a path would have to be resolved in the client's view.
|
|
797
|
+
local region = if typeof(params.rect) == "string" then regionFor({ rect = params.rect }) else nil
|
|
798
|
+
return encode(contentId :: string, width, context, region)
|
|
583
799
|
end
|
|
584
800
|
|
|
585
801
|
function Capture.register()
|