@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,710 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
The console's activity band: a turning wireframe beside a live latency trace.
|
|
4
|
+
|
|
5
|
+
Watching an agent work is mostly waiting, and the log answers "what is it
|
|
6
|
+
doing" precisely while answering "is it still alive" badly -- a stalled
|
|
7
|
+
session and a thinking one produce the same still screen.
|
|
8
|
+
|
|
9
|
+
This began as a full-panel overlay and that was wrong. It sat on top of the
|
|
10
|
+
log, so the thing you actually read was covered and, being slightly
|
|
11
|
+
transparent, softened underneath. Decoration that costs legibility is a bad
|
|
12
|
+
trade however good it looks. So it is a band now: a fixed strip above the
|
|
13
|
+
log, which keeps every line at full contrast and never moves under the
|
|
14
|
+
reader. The log shrinks by the band's height and nothing overlaps.
|
|
15
|
+
|
|
16
|
+
The strip earns its space twice over. The wireframe on the left says the
|
|
17
|
+
session is live; the trace beside it is the last forty calls, each a bar as
|
|
18
|
+
tall as it was slow and coloured by whether it worked. That turns "is it
|
|
19
|
+
alive" and "is it healthy" into one glance, which is more than the log gives
|
|
20
|
+
and more than an animation alone would.
|
|
21
|
+
|
|
22
|
+
The solid is a real projection, not a canned loop: vertices rotate in three
|
|
23
|
+
dimensions and pass through a perspective divide every frame, so near edges
|
|
24
|
+
are brighter and thicker because they genuinely are nearer. Roblox has no
|
|
25
|
+
line primitive, so each edge is a thin Frame turned about its midpoint, and
|
|
26
|
+
the frames are pooled -- allocating Instances inside a per-frame loop is how
|
|
27
|
+
a decorative strip becomes a performance complaint.
|
|
28
|
+
]]
|
|
29
|
+
|
|
30
|
+
local RunService = game:GetService("RunService")
|
|
31
|
+
|
|
32
|
+
local Visuals = {}
|
|
33
|
+
|
|
34
|
+
local PHI = (1 + math.sqrt(5)) / 2
|
|
35
|
+
|
|
36
|
+
type Shape = {
|
|
37
|
+
name: string,
|
|
38
|
+
vertices: { Vector3 },
|
|
39
|
+
edges: { { number } },
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
--[[
|
|
43
|
+
Ordered by how busy they look. The session climbs this list as it works, so
|
|
44
|
+
the shape itself reports load: an idle plugin shows a tetrahedron, a busy one
|
|
45
|
+
an icosahedron.
|
|
46
|
+
]]
|
|
47
|
+
local SHAPES: { Shape } = {
|
|
48
|
+
{
|
|
49
|
+
name = "tetrahedron",
|
|
50
|
+
vertices = {
|
|
51
|
+
Vector3.new(1, 1, 1),
|
|
52
|
+
Vector3.new(1, -1, -1),
|
|
53
|
+
Vector3.new(-1, 1, -1),
|
|
54
|
+
Vector3.new(-1, -1, 1),
|
|
55
|
+
},
|
|
56
|
+
edges = { { 1, 2 }, { 1, 3 }, { 1, 4 }, { 2, 3 }, { 2, 4 }, { 3, 4 } },
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name = "octahedron",
|
|
60
|
+
vertices = {
|
|
61
|
+
Vector3.new(1, 0, 0),
|
|
62
|
+
Vector3.new(-1, 0, 0),
|
|
63
|
+
Vector3.new(0, 1, 0),
|
|
64
|
+
Vector3.new(0, -1, 0),
|
|
65
|
+
Vector3.new(0, 0, 1),
|
|
66
|
+
Vector3.new(0, 0, -1),
|
|
67
|
+
},
|
|
68
|
+
edges = {
|
|
69
|
+
{ 1, 3 }, { 1, 4 }, { 1, 5 }, { 1, 6 },
|
|
70
|
+
{ 2, 3 }, { 2, 4 }, { 2, 5 }, { 2, 6 },
|
|
71
|
+
{ 3, 5 }, { 5, 4 }, { 4, 6 }, { 6, 3 },
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
name = "cube",
|
|
76
|
+
vertices = {
|
|
77
|
+
Vector3.new(-1, -1, -1), Vector3.new(1, -1, -1),
|
|
78
|
+
Vector3.new(1, 1, -1), Vector3.new(-1, 1, -1),
|
|
79
|
+
Vector3.new(-1, -1, 1), Vector3.new(1, -1, 1),
|
|
80
|
+
Vector3.new(1, 1, 1), Vector3.new(-1, 1, 1),
|
|
81
|
+
},
|
|
82
|
+
edges = {
|
|
83
|
+
{ 1, 2 }, { 2, 3 }, { 3, 4 }, { 4, 1 },
|
|
84
|
+
{ 5, 6 }, { 6, 7 }, { 7, 8 }, { 8, 5 },
|
|
85
|
+
{ 1, 5 }, { 2, 6 }, { 3, 7 }, { 4, 8 },
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name = "icosahedron",
|
|
90
|
+
vertices = {
|
|
91
|
+
Vector3.new(0, 1, PHI), Vector3.new(0, -1, PHI),
|
|
92
|
+
Vector3.new(0, 1, -PHI), Vector3.new(0, -1, -PHI),
|
|
93
|
+
Vector3.new(1, PHI, 0), Vector3.new(-1, PHI, 0),
|
|
94
|
+
Vector3.new(1, -PHI, 0), Vector3.new(-1, -PHI, 0),
|
|
95
|
+
Vector3.new(PHI, 0, 1), Vector3.new(PHI, 0, -1),
|
|
96
|
+
Vector3.new(-PHI, 0, 1), Vector3.new(-PHI, 0, -1),
|
|
97
|
+
},
|
|
98
|
+
edges = {
|
|
99
|
+
{ 1, 2 }, { 1, 5 }, { 1, 6 }, { 1, 9 }, { 1, 11 },
|
|
100
|
+
{ 2, 7 }, { 2, 8 }, { 2, 9 }, { 2, 11 },
|
|
101
|
+
{ 3, 4 }, { 3, 5 }, { 3, 6 }, { 3, 10 }, { 3, 12 },
|
|
102
|
+
{ 4, 7 }, { 4, 8 }, { 4, 10 }, { 4, 12 },
|
|
103
|
+
{ 5, 6 }, { 5, 9 }, { 5, 10 },
|
|
104
|
+
{ 6, 11 }, { 6, 12 },
|
|
105
|
+
{ 7, 8 }, { 7, 9 }, { 7, 10 },
|
|
106
|
+
{ 8, 11 }, { 8, 12 },
|
|
107
|
+
{ 9, 10 }, { 11, 12 },
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
local MAX_EDGES = 30
|
|
113
|
+
local MAX_VERTICES = 12
|
|
114
|
+
local FOCAL = 3.4
|
|
115
|
+
|
|
116
|
+
-- The inner solid, counter-rotating inside the outer one. An octahedron
|
|
117
|
+
-- regardless of what is outside it: it reads as a core rather than as a second
|
|
118
|
+
-- object competing for attention, and its 12 edges are cheap.
|
|
119
|
+
local INNER_SHAPE = 2
|
|
120
|
+
local INNER_SCALE = 0.42
|
|
121
|
+
|
|
122
|
+
-- The strip's height, and the width reserved for the solid at its left end.
|
|
123
|
+
-- The solid was originally as tall as the band and read as looming: it crowded
|
|
124
|
+
-- the caption and made a 46px strip feel like a panel. It is an indicator, not
|
|
125
|
+
-- the subject, so it sits small with air around it.
|
|
126
|
+
Visuals.BAND_HEIGHT = 44
|
|
127
|
+
local CELL = 34
|
|
128
|
+
local GUTTER = 16
|
|
129
|
+
|
|
130
|
+
-- How many calls the trace remembers. Forty is about a screen's width of bars
|
|
131
|
+
-- at two pixels each, and about as far back as anyone reads a trend.
|
|
132
|
+
local TRACE = 40
|
|
133
|
+
|
|
134
|
+
-- Bars are scaled against this, so a normal call is a low bar and a slow one is
|
|
135
|
+
-- obvious. Anything past it clamps to full height rather than flattening the
|
|
136
|
+
-- rest of the trace into invisibility.
|
|
137
|
+
local SLOW_MS = 400
|
|
138
|
+
|
|
139
|
+
-- Pixels from one bar to the next. Also the width of a bar's hit target and of
|
|
140
|
+
-- the hover highlight, so pointing between two bars still selects one of them
|
|
141
|
+
-- rather than falling into a dead gap.
|
|
142
|
+
local BAR_STRIDE = 5
|
|
143
|
+
|
|
144
|
+
type Bar = {
|
|
145
|
+
frame: Frame,
|
|
146
|
+
--[[
|
|
147
|
+
An invisible column over the bar, full height of the trace, wide enough to
|
|
148
|
+
hit. The bar itself is three pixels wide and usually one pixel tall -- a
|
|
149
|
+
fast call is a stub on the baseline -- so hovering the bar is not something
|
|
150
|
+
a person can reliably do. The hit target is the whole column above it, so
|
|
151
|
+
pointing anywhere near a bar selects it.
|
|
152
|
+
]]
|
|
153
|
+
hit: TextButton,
|
|
154
|
+
milliseconds: number,
|
|
155
|
+
ok: boolean,
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
type Runtime = {
|
|
159
|
+
band: Frame?,
|
|
160
|
+
cell: Frame?,
|
|
161
|
+
trace: Frame?,
|
|
162
|
+
highlight: Frame?,
|
|
163
|
+
readout: TextLabel?,
|
|
164
|
+
caption: TextLabel?,
|
|
165
|
+
edges: { Frame },
|
|
166
|
+
innerEdges: { Frame },
|
|
167
|
+
dots: { Frame },
|
|
168
|
+
bars: { Bar },
|
|
169
|
+
connection: RBXScriptConnection?,
|
|
170
|
+
|
|
171
|
+
spin: Vector3,
|
|
172
|
+
angle: Vector3,
|
|
173
|
+
shapeIndex: number,
|
|
174
|
+
|
|
175
|
+
energy: number,
|
|
176
|
+
shake: number,
|
|
177
|
+
tint: Color3,
|
|
178
|
+
targetTint: Color3,
|
|
179
|
+
baseTint: Color3,
|
|
180
|
+
activeTint: Color3,
|
|
181
|
+
activity: number,
|
|
182
|
+
lastTitle: string?,
|
|
183
|
+
palette: { [string]: Color3 },
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
local runtime: Runtime = {
|
|
187
|
+
band = nil,
|
|
188
|
+
cell = nil,
|
|
189
|
+
trace = nil,
|
|
190
|
+
highlight = nil,
|
|
191
|
+
readout = nil,
|
|
192
|
+
caption = nil,
|
|
193
|
+
edges = {},
|
|
194
|
+
innerEdges = {},
|
|
195
|
+
dots = {},
|
|
196
|
+
bars = {},
|
|
197
|
+
connection = nil,
|
|
198
|
+
spin = Vector3.new(0.18, 0.27, 0.11),
|
|
199
|
+
angle = Vector3.zero,
|
|
200
|
+
shapeIndex = 1,
|
|
201
|
+
energy = 0,
|
|
202
|
+
shake = 0,
|
|
203
|
+
tint = Color3.fromRGB(167, 139, 250),
|
|
204
|
+
targetTint = Color3.fromRGB(167, 139, 250),
|
|
205
|
+
baseTint = Color3.fromRGB(167, 139, 250),
|
|
206
|
+
activeTint = Color3.fromRGB(167, 139, 250),
|
|
207
|
+
activity = 0,
|
|
208
|
+
lastTitle = nil,
|
|
209
|
+
palette = {},
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
local function rotate(point: Vector3, angle: Vector3): Vector3
|
|
213
|
+
local sinX, cosX = math.sin(angle.X), math.cos(angle.X)
|
|
214
|
+
local sinY, cosY = math.sin(angle.Y), math.cos(angle.Y)
|
|
215
|
+
local sinZ, cosZ = math.sin(angle.Z), math.cos(angle.Z)
|
|
216
|
+
|
|
217
|
+
local y1 = point.Y * cosX - point.Z * sinX
|
|
218
|
+
local z1 = point.Y * sinX + point.Z * cosX
|
|
219
|
+
local x2 = point.X * cosY + z1 * sinY
|
|
220
|
+
local z2 = -point.X * sinY + z1 * cosY
|
|
221
|
+
local x3 = x2 * cosZ - y1 * sinZ
|
|
222
|
+
local y3 = x2 * sinZ + y1 * cosZ
|
|
223
|
+
|
|
224
|
+
return Vector3.new(x3, y3, z2)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
--[[
|
|
228
|
+
One edge as a rectangle turned about its midpoint. `Rotation` pivots a
|
|
229
|
+
GuiObject around its centre, which is exactly what a segment needs.
|
|
230
|
+
]]
|
|
231
|
+
local function drawEdge(frame: Frame, a: Vector2, b: Vector2, thickness: number, color: Color3, transparency: number)
|
|
232
|
+
local delta = b - a
|
|
233
|
+
local length = delta.Magnitude
|
|
234
|
+
if length < 0.5 then
|
|
235
|
+
frame.Visible = false
|
|
236
|
+
return
|
|
237
|
+
end
|
|
238
|
+
frame.Visible = true
|
|
239
|
+
frame.Position = UDim2.fromOffset((a.X + b.X) / 2, (a.Y + b.Y) / 2)
|
|
240
|
+
frame.Size = UDim2.fromOffset(math.ceil(length), thickness)
|
|
241
|
+
frame.Rotation = math.deg(math.atan2(delta.Y, delta.X))
|
|
242
|
+
frame.BackgroundColor3 = color
|
|
243
|
+
frame.BackgroundTransparency = transparency
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
local function step(delta: number)
|
|
247
|
+
local band = runtime.band
|
|
248
|
+
if band == nil or not band.Visible then
|
|
249
|
+
return
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
runtime.energy = math.max(0, runtime.energy - delta * 0.9)
|
|
253
|
+
runtime.shake = math.max(0, runtime.shake - delta * 2.4)
|
|
254
|
+
runtime.activity = math.max(0, runtime.activity - delta * 0.35)
|
|
255
|
+
--[[
|
|
256
|
+
Two colours, blended by how recently something happened.
|
|
257
|
+
|
|
258
|
+
`baseTint` is the connection state, which is what the panel should read
|
|
259
|
+
as when nothing is going on. `activeTint` is the kind of work last done
|
|
260
|
+
-- reading, writing, running, debugging -- and it takes over while that
|
|
261
|
+
work is fresh, then fades back. So the strip is not one fixed colour: it
|
|
262
|
+
leans toward whatever the session is actually doing and returns to
|
|
263
|
+
resting on its own.
|
|
264
|
+
]]
|
|
265
|
+
local blend = math.clamp(runtime.energy, 0, 1)
|
|
266
|
+
runtime.targetTint = runtime.baseTint:Lerp(runtime.activeTint, blend)
|
|
267
|
+
runtime.tint = runtime.tint:Lerp(runtime.targetTint, math.min(1, delta * 5))
|
|
268
|
+
|
|
269
|
+
-- Turning faster while busy makes the rate itself readable: a glance says
|
|
270
|
+
-- whether anything is happening without reading a word.
|
|
271
|
+
runtime.angle += runtime.spin * delta * (1 + runtime.energy * 2.2)
|
|
272
|
+
|
|
273
|
+
local shape = SHAPES[runtime.shapeIndex]
|
|
274
|
+
local centre = Vector2.new(CELL / 2, CELL / 2)
|
|
275
|
+
-- Kept clear of the cell's edge. The perspective divide pushes a near vertex
|
|
276
|
+
-- outward, so sizing to the full half-width clipped the solid against its own
|
|
277
|
+
-- container whenever a corner swung toward the viewer.
|
|
278
|
+
local radius = CELL * (0.2 + runtime.energy * 0.03)
|
|
279
|
+
|
|
280
|
+
-- Wobble the axis rather than the panel: shaking the strip would read as a
|
|
281
|
+
-- glitch, tilting the solid reads as it being knocked.
|
|
282
|
+
local wobble = if runtime.shake > 0
|
|
283
|
+
then Vector3.new(
|
|
284
|
+
math.sin(os.clock() * 37) * runtime.shake * 0.12,
|
|
285
|
+
math.cos(os.clock() * 29) * runtime.shake * 0.12,
|
|
286
|
+
0
|
|
287
|
+
)
|
|
288
|
+
else Vector3.zero
|
|
289
|
+
|
|
290
|
+
local projected: { Vector2 } = {}
|
|
291
|
+
local depths: { number } = {}
|
|
292
|
+
for index, vertex in shape.vertices do
|
|
293
|
+
local turned = rotate(vertex, runtime.angle + wobble)
|
|
294
|
+
local scale = FOCAL / (FOCAL - turned.Z)
|
|
295
|
+
projected[index] = centre + Vector2.new(turned.X * scale * radius, turned.Y * scale * radius)
|
|
296
|
+
depths[index] = turned.Z
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
for index = 1, MAX_EDGES do
|
|
300
|
+
local frame = runtime.edges[index]
|
|
301
|
+
local edge = shape.edges[index]
|
|
302
|
+
if edge == nil then
|
|
303
|
+
frame.Visible = false
|
|
304
|
+
continue
|
|
305
|
+
end
|
|
306
|
+
local a, b = edge[1], edge[2]
|
|
307
|
+
local depth = math.clamp(((depths[a] + depths[b]) / 2 + 2) / 4, 0, 1)
|
|
308
|
+
drawEdge(
|
|
309
|
+
frame,
|
|
310
|
+
projected[a],
|
|
311
|
+
projected[b],
|
|
312
|
+
math.max(1, math.floor(1 + depth * 1.6)),
|
|
313
|
+
runtime.tint:Lerp(Color3.new(1, 1, 1), depth * 0.35),
|
|
314
|
+
0.84 - depth * 0.74
|
|
315
|
+
)
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
--[[
|
|
319
|
+
Vertices as points, brighter than the edges they join.
|
|
320
|
+
|
|
321
|
+
Wireframes read as flat when every stroke is the same weight. Lighting
|
|
322
|
+
the corners gives the eye something to track as the solid turns, which is
|
|
323
|
+
what makes it read as rotating rather than merely flickering.
|
|
324
|
+
]]
|
|
325
|
+
for index = 1, MAX_VERTICES do
|
|
326
|
+
local dot = runtime.dots[index]
|
|
327
|
+
local point = projected[index]
|
|
328
|
+
if point == nil then
|
|
329
|
+
dot.Visible = false
|
|
330
|
+
continue
|
|
331
|
+
end
|
|
332
|
+
local depth = math.clamp((depths[index] + 2) / 4, 0, 1)
|
|
333
|
+
local size = math.max(2, math.floor(1.5 + depth * 2.5))
|
|
334
|
+
dot.Visible = true
|
|
335
|
+
dot.Position = UDim2.fromOffset(point.X, point.Y)
|
|
336
|
+
dot.Size = UDim2.fromOffset(size, size)
|
|
337
|
+
dot.BackgroundColor3 = runtime.tint:Lerp(Color3.new(1, 1, 1), 0.25 + depth * 0.5)
|
|
338
|
+
dot.BackgroundTransparency = 0.55 - depth * 0.5
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
--[[
|
|
342
|
+
A second solid inside the first, turning the other way.
|
|
343
|
+
|
|
344
|
+
One rotating outline is a loading spinner. Two, at different rates and
|
|
345
|
+
opposite directions, read as a mechanism -- and the counter-rotation
|
|
346
|
+
makes the outer solid's direction obvious, which a lone wireframe hides
|
|
347
|
+
whenever it passes through a symmetrical angle.
|
|
348
|
+
]]
|
|
349
|
+
local inner = SHAPES[INNER_SHAPE]
|
|
350
|
+
local innerAngle = -runtime.angle * 1.6
|
|
351
|
+
local innerProjected: { Vector2 } = {}
|
|
352
|
+
local innerDepths: { number } = {}
|
|
353
|
+
for index, vertex in inner.vertices do
|
|
354
|
+
local turned = rotate(vertex, innerAngle)
|
|
355
|
+
local scale = FOCAL / (FOCAL - turned.Z)
|
|
356
|
+
innerProjected[index] = centre
|
|
357
|
+
+ Vector2.new(turned.X * scale * radius * INNER_SCALE, turned.Y * scale * radius * INNER_SCALE)
|
|
358
|
+
innerDepths[index] = turned.Z
|
|
359
|
+
end
|
|
360
|
+
for index, frame in runtime.innerEdges do
|
|
361
|
+
local edge = inner.edges[index]
|
|
362
|
+
if edge == nil then
|
|
363
|
+
frame.Visible = false
|
|
364
|
+
continue
|
|
365
|
+
end
|
|
366
|
+
local a, b = edge[1], edge[2]
|
|
367
|
+
local depth = math.clamp(((innerDepths[a] + innerDepths[b]) / 2 + 2) / 4, 0, 1)
|
|
368
|
+
drawEdge(
|
|
369
|
+
frame,
|
|
370
|
+
innerProjected[a],
|
|
371
|
+
innerProjected[b],
|
|
372
|
+
1,
|
|
373
|
+
runtime.tint:Lerp(Color3.new(1, 1, 1), 0.5),
|
|
374
|
+
0.9 - depth * 0.35
|
|
375
|
+
)
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
--[[
|
|
380
|
+
Puts the highlight behind one bar and prints its timing.
|
|
381
|
+
|
|
382
|
+
The trace is only legible if you already know what it plots, and nothing on
|
|
383
|
+
screen said so. Hovering answers it directly: the column lights up, and the
|
|
384
|
+
number that made that bar its height appears in words. One reading at a time,
|
|
385
|
+
so a single highlight and a single label are moved around rather than one of
|
|
386
|
+
each being built per bar.
|
|
387
|
+
]]
|
|
388
|
+
local function showReading(entry: Bar)
|
|
389
|
+
local highlight = runtime.highlight
|
|
390
|
+
local readout = runtime.readout
|
|
391
|
+
if highlight == nil or readout == nil then
|
|
392
|
+
return
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
highlight.Position = UDim2.new(entry.hit.Position.X.Scale, entry.hit.Position.X.Offset, 0, 0)
|
|
396
|
+
highlight.Visible = true
|
|
397
|
+
|
|
398
|
+
readout.Text = if entry.ok
|
|
399
|
+
then string.format("%d ms", math.round(entry.milliseconds))
|
|
400
|
+
else string.format("failed after %d ms", math.round(entry.milliseconds))
|
|
401
|
+
readout.TextColor3 = if entry.ok
|
|
402
|
+
then runtime.palette.text or Color3.fromRGB(226, 232, 240)
|
|
403
|
+
else runtime.palette.red or Color3.fromRGB(251, 113, 133)
|
|
404
|
+
readout.Visible = true
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
local function clearReading()
|
|
408
|
+
if runtime.highlight then
|
|
409
|
+
runtime.highlight.Visible = false
|
|
410
|
+
end
|
|
411
|
+
if runtime.readout then
|
|
412
|
+
runtime.readout.Visible = false
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
--[[
|
|
417
|
+
Records one finished call as a bar on the trace.
|
|
418
|
+
|
|
419
|
+
Height is duration and colour is outcome, so a slow call and a failed one are
|
|
420
|
+
distinguishable at a glance -- which is the pair of questions that actually
|
|
421
|
+
get asked of a log this size.
|
|
422
|
+
]]
|
|
423
|
+
function Visuals.recordCall(milliseconds: number, ok: boolean)
|
|
424
|
+
runtime.energy = math.min(1.6, runtime.energy + (if ok then 0.3 else 0.8))
|
|
425
|
+
runtime.activity = math.min(1, runtime.activity + 0.25)
|
|
426
|
+
runtime.shapeIndex = math.clamp(1 + math.floor(runtime.activity * (#SHAPES - 0.001)), 1, #SHAPES)
|
|
427
|
+
if not ok then
|
|
428
|
+
runtime.shake = 1
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
local trace = runtime.trace
|
|
432
|
+
if trace == nil then
|
|
433
|
+
return
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
local bar = Instance.new("Frame")
|
|
437
|
+
bar.AnchorPoint = Vector2.new(0, 1)
|
|
438
|
+
bar.BorderSizePixel = 0
|
|
439
|
+
bar.Parent = trace
|
|
440
|
+
|
|
441
|
+
-- A TextButton rather than a Frame: buttons take mouse events reliably in a
|
|
442
|
+
-- plugin widget, and with no text and no background it is purely a target.
|
|
443
|
+
local hit = Instance.new("TextButton")
|
|
444
|
+
hit.AnchorPoint = Vector2.new(0.5, 1)
|
|
445
|
+
hit.BackgroundTransparency = 1
|
|
446
|
+
hit.Text = ""
|
|
447
|
+
hit.AutoButtonColor = false
|
|
448
|
+
hit.BorderSizePixel = 0
|
|
449
|
+
hit.Size = UDim2.new(0, BAR_STRIDE, 1, 0)
|
|
450
|
+
hit.Parent = trace
|
|
451
|
+
|
|
452
|
+
local entry: Bar = { frame = bar, hit = hit, milliseconds = milliseconds, ok = ok }
|
|
453
|
+
table.insert(runtime.bars, entry)
|
|
454
|
+
|
|
455
|
+
hit.MouseEnter:Connect(function()
|
|
456
|
+
showReading(entry)
|
|
457
|
+
end)
|
|
458
|
+
hit.MouseLeave:Connect(function()
|
|
459
|
+
clearReading()
|
|
460
|
+
end)
|
|
461
|
+
|
|
462
|
+
while #runtime.bars > TRACE do
|
|
463
|
+
local oldest = runtime.bars[1]
|
|
464
|
+
oldest.frame:Destroy()
|
|
465
|
+
oldest.hit:Destroy()
|
|
466
|
+
table.remove(runtime.bars, 1)
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
-- Laid out right to left so the newest bar is always at the same edge and
|
|
470
|
+
-- the trace reads as scrolling rather than reshuffling.
|
|
471
|
+
local total = #runtime.bars
|
|
472
|
+
for index, item in runtime.bars do
|
|
473
|
+
local fromRight = total - index
|
|
474
|
+
local height = math.clamp(item.milliseconds / SLOW_MS, 0.06, 1)
|
|
475
|
+
local offset = -(fromRight + 1) * BAR_STRIDE
|
|
476
|
+
item.frame.Position = UDim2.new(1, offset, 1, 0)
|
|
477
|
+
item.frame.Size = UDim2.new(0, 3, height, 0)
|
|
478
|
+
item.frame.BackgroundColor3 = if item.ok
|
|
479
|
+
then runtime.palette.violet or runtime.tint
|
|
480
|
+
else runtime.palette.red or Color3.fromRGB(251, 113, 133)
|
|
481
|
+
item.frame.BackgroundTransparency = 0.15 + (fromRight / TRACE) * 0.6
|
|
482
|
+
-- Centred on the bar, so the column a person points at is the one they get.
|
|
483
|
+
item.hit.Position = UDim2.new(1, offset + 1, 1, 0)
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
-- The bars have all moved, so a reading still on screen now names the wrong
|
|
487
|
+
-- one. Cheaper and more honest to drop it than to work out which bar the
|
|
488
|
+
-- pointer has ended up over.
|
|
489
|
+
clearReading()
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
--[[
|
|
493
|
+
The resting colour, which is the connection state.
|
|
494
|
+
]]
|
|
495
|
+
function Visuals.setTint(color: Color3)
|
|
496
|
+
runtime.baseTint = color
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
--[[
|
|
500
|
+
The colour and pace of the work now running.
|
|
501
|
+
|
|
502
|
+
Both are set from the same call because they describe the same thing: what
|
|
503
|
+
kind of command this is. Reads are quick and cool, writes are slower and
|
|
504
|
+
warm, so the strip's colour and its rate agree with each other instead of
|
|
505
|
+
moving independently.
|
|
506
|
+
]]
|
|
507
|
+
function Visuals.setKind(color: Color3, urgency: number)
|
|
508
|
+
runtime.activeTint = color
|
|
509
|
+
runtime.energy = math.min(1.6, math.max(runtime.energy, urgency))
|
|
510
|
+
-- Re-aimed per kind so successive commands of different types visibly
|
|
511
|
+
-- change the axis rather than continuing the same turn.
|
|
512
|
+
runtime.spin = Vector3.new(
|
|
513
|
+
0.12 + math.random() * 0.16,
|
|
514
|
+
0.18 + math.random() * 0.22,
|
|
515
|
+
0.06 + math.random() * 0.12
|
|
516
|
+
)
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
--[[
|
|
520
|
+
What is running, in the same words the log uses. The motion says something is
|
|
521
|
+
happening; this says what, and neither answers the other's question.
|
|
522
|
+
]]
|
|
523
|
+
function Visuals.setCaption(title: string)
|
|
524
|
+
runtime.lastTitle = title
|
|
525
|
+
if runtime.caption then
|
|
526
|
+
runtime.caption.Text = title
|
|
527
|
+
end
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
--[[
|
|
531
|
+
Falls back to the last thing that ran once a command finishes.
|
|
532
|
+
|
|
533
|
+
The footer already reports totals, so repeating "idle" here would say the
|
|
534
|
+
same word twice on one screen. What has just happened is more useful and is
|
|
535
|
+
not shown anywhere else.
|
|
536
|
+
]]
|
|
537
|
+
function Visuals.setIdle()
|
|
538
|
+
if runtime.caption == nil then
|
|
539
|
+
return
|
|
540
|
+
end
|
|
541
|
+
runtime.caption.Text = if runtime.lastTitle ~= nil
|
|
542
|
+
then "last: " .. runtime.lastTitle
|
|
543
|
+
else "waiting for a command"
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
function Visuals.setVisible(visible: boolean)
|
|
547
|
+
local band = runtime.band
|
|
548
|
+
if band == nil then
|
|
549
|
+
return
|
|
550
|
+
end
|
|
551
|
+
band.Visible = visible
|
|
552
|
+
|
|
553
|
+
-- Connected only while on screen: a hidden strip that keeps projecting
|
|
554
|
+
-- geometry every frame is a battery complaint waiting to happen.
|
|
555
|
+
if visible and runtime.connection == nil then
|
|
556
|
+
runtime.connection = RunService.Heartbeat:Connect(step)
|
|
557
|
+
elseif not visible and runtime.connection ~= nil then
|
|
558
|
+
runtime.connection:Disconnect()
|
|
559
|
+
runtime.connection = nil
|
|
560
|
+
end
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
function Visuals.isVisible(): boolean
|
|
564
|
+
local band = runtime.band
|
|
565
|
+
return band ~= nil and band.Visible
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
--[[
|
|
569
|
+
Builds the strip. Every edge frame is created here and never again, which is
|
|
570
|
+
what keeps the per-frame path allocation-free.
|
|
571
|
+
]]
|
|
572
|
+
function Visuals.mount(parent: Instance, palette: { [string]: Color3 })
|
|
573
|
+
runtime.palette = palette
|
|
574
|
+
|
|
575
|
+
local band = Instance.new("Frame")
|
|
576
|
+
band.Name = "ActivityBand"
|
|
577
|
+
band.BackgroundColor3 = palette.surface
|
|
578
|
+
band.BorderSizePixel = 0
|
|
579
|
+
band.Visible = false
|
|
580
|
+
band.ClipsDescendants = true
|
|
581
|
+
band.Parent = parent
|
|
582
|
+
runtime.band = band
|
|
583
|
+
|
|
584
|
+
local cell = Instance.new("Frame")
|
|
585
|
+
cell.Name = "Solid"
|
|
586
|
+
cell.Position = UDim2.fromOffset(6, (Visuals.BAND_HEIGHT - CELL) / 2)
|
|
587
|
+
cell.Size = UDim2.fromOffset(CELL, CELL)
|
|
588
|
+
cell.BackgroundTransparency = 1
|
|
589
|
+
cell.ClipsDescendants = true
|
|
590
|
+
cell.Parent = band
|
|
591
|
+
runtime.cell = cell
|
|
592
|
+
|
|
593
|
+
for index = 1, MAX_EDGES do
|
|
594
|
+
local edge = Instance.new("Frame")
|
|
595
|
+
edge.Name = string.format("Edge%02d", index)
|
|
596
|
+
edge.AnchorPoint = Vector2.new(0.5, 0.5)
|
|
597
|
+
edge.BorderSizePixel = 0
|
|
598
|
+
edge.Visible = false
|
|
599
|
+
edge.Parent = cell
|
|
600
|
+
runtime.edges[index] = edge
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
-- A hairline between the solid and the trace, so the strip reads as two
|
|
604
|
+
-- instruments rather than one busy rectangle.
|
|
605
|
+
local divider = Instance.new("Frame")
|
|
606
|
+
divider.Position = UDim2.fromOffset(CELL + GUTTER / 2, 8)
|
|
607
|
+
divider.Size = UDim2.new(0, 1, 1, -16)
|
|
608
|
+
divider.BackgroundColor3 = palette.dim
|
|
609
|
+
divider.BackgroundTransparency = 0.7
|
|
610
|
+
divider.BorderSizePixel = 0
|
|
611
|
+
divider.Parent = band
|
|
612
|
+
|
|
613
|
+
for index = 1, MAX_VERTICES do
|
|
614
|
+
local dot = Instance.new("Frame")
|
|
615
|
+
dot.Name = string.format("Vertex%02d", index)
|
|
616
|
+
dot.AnchorPoint = Vector2.new(0.5, 0.5)
|
|
617
|
+
dot.BorderSizePixel = 0
|
|
618
|
+
dot.Visible = false
|
|
619
|
+
dot.Parent = cell
|
|
620
|
+
local round = Instance.new("UICorner")
|
|
621
|
+
round.CornerRadius = UDim.new(1, 0)
|
|
622
|
+
round.Parent = dot
|
|
623
|
+
runtime.dots[index] = dot
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
for index = 1, #SHAPES[INNER_SHAPE].edges do
|
|
627
|
+
local edge = Instance.new("Frame")
|
|
628
|
+
edge.Name = string.format("Inner%02d", index)
|
|
629
|
+
edge.AnchorPoint = Vector2.new(0.5, 0.5)
|
|
630
|
+
edge.BorderSizePixel = 0
|
|
631
|
+
edge.Visible = false
|
|
632
|
+
edge.Parent = cell
|
|
633
|
+
runtime.innerEdges[index] = edge
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
local caption = Instance.new("TextLabel")
|
|
637
|
+
caption.Position = UDim2.new(0, CELL + GUTTER, 0, 4)
|
|
638
|
+
caption.Size = UDim2.new(1, -CELL - GUTTER - 12, 0, 14)
|
|
639
|
+
caption.BackgroundTransparency = 1
|
|
640
|
+
caption.Font = Enum.Font.Code
|
|
641
|
+
caption.TextSize = 11
|
|
642
|
+
caption.TextColor3 = palette.text
|
|
643
|
+
caption.TextXAlignment = Enum.TextXAlignment.Left
|
|
644
|
+
caption.TextTruncate = Enum.TextTruncate.AtEnd
|
|
645
|
+
caption.Text = "idle"
|
|
646
|
+
caption.Parent = band
|
|
647
|
+
runtime.caption = caption
|
|
648
|
+
|
|
649
|
+
local trace = Instance.new("Frame")
|
|
650
|
+
trace.Name = "Trace"
|
|
651
|
+
trace.Position = UDim2.new(0, CELL + GUTTER, 0, 21)
|
|
652
|
+
trace.Size = UDim2.new(1, -CELL - GUTTER - 12, 0, 19)
|
|
653
|
+
trace.BackgroundTransparency = 1
|
|
654
|
+
trace.ClipsDescendants = true
|
|
655
|
+
trace.Parent = band
|
|
656
|
+
runtime.trace = trace
|
|
657
|
+
|
|
658
|
+
-- A baseline under the bars, so an empty trace still reads as an instrument
|
|
659
|
+
-- waiting for data rather than as a blank gap.
|
|
660
|
+
local baseline = Instance.new("Frame")
|
|
661
|
+
baseline.AnchorPoint = Vector2.new(0, 1)
|
|
662
|
+
baseline.Position = UDim2.fromScale(0, 1)
|
|
663
|
+
baseline.Size = UDim2.new(1, 0, 0, 1)
|
|
664
|
+
baseline.BackgroundColor3 = palette.dim
|
|
665
|
+
baseline.BackgroundTransparency = 0.75
|
|
666
|
+
baseline.BorderSizePixel = 0
|
|
667
|
+
baseline.Parent = trace
|
|
668
|
+
|
|
669
|
+
--[[
|
|
670
|
+
Built once and moved, and created BEFORE the bars exist so it sits under
|
|
671
|
+
them in draw order -- a highlight drawn over a one-pixel bar would hide the
|
|
672
|
+
very thing it is pointing at.
|
|
673
|
+
]]
|
|
674
|
+
local highlight = Instance.new("Frame")
|
|
675
|
+
highlight.Name = "Highlight"
|
|
676
|
+
highlight.AnchorPoint = Vector2.new(0.5, 1)
|
|
677
|
+
highlight.Position = UDim2.new(1, 0, 1, 0)
|
|
678
|
+
highlight.Size = UDim2.new(0, BAR_STRIDE, 1, 0)
|
|
679
|
+
highlight.BackgroundColor3 = palette.text or Color3.fromRGB(226, 232, 240)
|
|
680
|
+
highlight.BackgroundTransparency = 0.85
|
|
681
|
+
highlight.BorderSizePixel = 0
|
|
682
|
+
highlight.Visible = false
|
|
683
|
+
highlight.Parent = trace
|
|
684
|
+
runtime.highlight = highlight
|
|
685
|
+
|
|
686
|
+
--[[
|
|
687
|
+
The reading sits at the left end of the trace rather than beside the bar it
|
|
688
|
+
describes. Following the pointer would put it off the right edge for the
|
|
689
|
+
newest bars, which are the ones most often asked about, and the left end is
|
|
690
|
+
empty until forty calls have accumulated.
|
|
691
|
+
]]
|
|
692
|
+
local readout = Instance.new("TextLabel")
|
|
693
|
+
readout.Name = "Readout"
|
|
694
|
+
readout.AnchorPoint = Vector2.new(0, 0.5)
|
|
695
|
+
readout.Position = UDim2.new(0, 0, 0.5, 0)
|
|
696
|
+
readout.Size = UDim2.new(0, 110, 0, 14)
|
|
697
|
+
readout.BackgroundColor3 = palette.background or Color3.fromRGB(11, 12, 20)
|
|
698
|
+
readout.BackgroundTransparency = 0.15
|
|
699
|
+
readout.BorderSizePixel = 0
|
|
700
|
+
readout.Font = Enum.Font.Code
|
|
701
|
+
readout.TextSize = 11
|
|
702
|
+
readout.TextXAlignment = Enum.TextXAlignment.Left
|
|
703
|
+
readout.Text = ""
|
|
704
|
+
readout.Visible = false
|
|
705
|
+
readout.ZIndex = 3
|
|
706
|
+
readout.Parent = trace
|
|
707
|
+
runtime.readout = readout
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
return Visuals
|