@el4cteo/rbx-studio-mcp 0.3.1 → 0.3.6
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 +20 -26
- package/dist/index.js +1 -1
- package/dist/lib/format.js +18 -2
- package/dist/lib/format.js.map +1 -1
- package/dist/tools/api.js +20 -1
- package/dist/tools/api.js.map +1 -1
- package/dist/tools/debug.js +18 -8
- package/dist/tools/debug.js.map +1 -1
- package/dist/tools/discover.js +5 -0
- package/dist/tools/discover.js.map +1 -1
- package/dist/tools/input.js +38 -5
- package/dist/tools/input.js.map +1 -1
- package/dist/tools/instances.js +49 -2
- package/dist/tools/instances.js.map +1 -1
- package/dist/tools/perf.js +9 -8
- package/dist/tools/perf.js.map +1 -1
- package/dist/tools/scripts.js +10 -1
- package/dist/tools/scripts.js.map +1 -1
- package/package.json +1 -1
- package/plugin/src/Config.luau +1 -1
- package/plugin/src/Console.luau +301 -117
- package/plugin/src/Mirror.luau +126 -0
- package/plugin/src/Serialize.luau +43 -3
- package/plugin/src/ThemePicker.luau +458 -0
- package/plugin/src/Themes/Aurora.luau +161 -0
- package/plugin/src/Themes/Blueprint.luau +213 -0
- package/plugin/src/Themes/Draw.luau +177 -0
- package/plugin/src/Themes/Lattice.luau +304 -0
- package/plugin/src/Themes/Nebula.luau +182 -0
- package/plugin/src/Themes/Observatory.luau +200 -0
- package/plugin/src/Themes/Orbit.luau +268 -0
- package/plugin/src/Themes/Phosphor.luau +200 -0
- package/plugin/src/Themes/Theme.luau +121 -0
- package/plugin/src/Themes/Void.luau +266 -0
- package/plugin/src/Themes/init.luau +116 -0
- package/plugin/src/Undo.luau +29 -0
- package/plugin/src/Visuals.luau +838 -907
- package/plugin/src/handlers/Device.luau +21 -1
- package/plugin/src/handlers/Discover.luau +75 -1
- package/plugin/src/handlers/Exec.luau +17 -5
- package/plugin/src/handlers/Input.luau +583 -493
- package/plugin/src/handlers/Instances.luau +35 -3
- package/plugin/src/init.server.luau +209 -11
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
Lattice -- the default.
|
|
4
|
+
|
|
5
|
+
A turning wireframe solid with a counter-rotating core, over a bar trace of
|
|
6
|
+
the last forty calls. This is the console's original look, and it stays the
|
|
7
|
+
default because it is the most legible of the eight: hard edges, one accent
|
|
8
|
+
colour, nothing moving that is not reporting something.
|
|
9
|
+
|
|
10
|
+
The solid is a real projection rather than a canned loop. Vertices rotate in
|
|
11
|
+
three dimensions and pass through a perspective divide every frame, so near
|
|
12
|
+
edges are brighter and thicker because they genuinely are nearer, and the
|
|
13
|
+
shape climbs a ladder -- tetrahedron, octahedron, cube, icosahedron -- as the
|
|
14
|
+
session gets busier. An idle plugin shows four vertices; a working one shows
|
|
15
|
+
twelve. The shape itself is the load meter.
|
|
16
|
+
]]
|
|
17
|
+
|
|
18
|
+
local Draw = require(script.Parent.Draw)
|
|
19
|
+
local Theme = require(script.Parent.Theme)
|
|
20
|
+
|
|
21
|
+
local PHI = (1 + math.sqrt(5)) / 2
|
|
22
|
+
|
|
23
|
+
type Shape = {
|
|
24
|
+
name: string,
|
|
25
|
+
vertices: { Vector3 },
|
|
26
|
+
edges: { { number } },
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
--[[
|
|
30
|
+
Ordered by how busy they look. The session climbs this list as it works, so
|
|
31
|
+
the shape itself reports load: an idle plugin shows a tetrahedron, a busy one
|
|
32
|
+
an icosahedron.
|
|
33
|
+
]]
|
|
34
|
+
local SHAPES: { Shape } = {
|
|
35
|
+
{
|
|
36
|
+
name = "tetrahedron",
|
|
37
|
+
vertices = {
|
|
38
|
+
Vector3.new(1, 1, 1),
|
|
39
|
+
Vector3.new(1, -1, -1),
|
|
40
|
+
Vector3.new(-1, 1, -1),
|
|
41
|
+
Vector3.new(-1, -1, 1),
|
|
42
|
+
},
|
|
43
|
+
edges = { { 1, 2 }, { 1, 3 }, { 1, 4 }, { 2, 3 }, { 2, 4 }, { 3, 4 } },
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name = "octahedron",
|
|
47
|
+
vertices = {
|
|
48
|
+
Vector3.new(1, 0, 0),
|
|
49
|
+
Vector3.new(-1, 0, 0),
|
|
50
|
+
Vector3.new(0, 1, 0),
|
|
51
|
+
Vector3.new(0, -1, 0),
|
|
52
|
+
Vector3.new(0, 0, 1),
|
|
53
|
+
Vector3.new(0, 0, -1),
|
|
54
|
+
},
|
|
55
|
+
edges = {
|
|
56
|
+
{ 1, 3 }, { 1, 4 }, { 1, 5 }, { 1, 6 },
|
|
57
|
+
{ 2, 3 }, { 2, 4 }, { 2, 5 }, { 2, 6 },
|
|
58
|
+
{ 3, 5 }, { 5, 4 }, { 4, 6 }, { 6, 3 },
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name = "cube",
|
|
63
|
+
vertices = {
|
|
64
|
+
Vector3.new(-1, -1, -1), Vector3.new(1, -1, -1),
|
|
65
|
+
Vector3.new(1, 1, -1), Vector3.new(-1, 1, -1),
|
|
66
|
+
Vector3.new(-1, -1, 1), Vector3.new(1, -1, 1),
|
|
67
|
+
Vector3.new(1, 1, 1), Vector3.new(-1, 1, 1),
|
|
68
|
+
},
|
|
69
|
+
edges = {
|
|
70
|
+
{ 1, 2 }, { 2, 3 }, { 3, 4 }, { 4, 1 },
|
|
71
|
+
{ 5, 6 }, { 6, 7 }, { 7, 8 }, { 8, 5 },
|
|
72
|
+
{ 1, 5 }, { 2, 6 }, { 3, 7 }, { 4, 8 },
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name = "icosahedron",
|
|
77
|
+
vertices = {
|
|
78
|
+
Vector3.new(0, 1, PHI), Vector3.new(0, -1, PHI),
|
|
79
|
+
Vector3.new(0, 1, -PHI), Vector3.new(0, -1, -PHI),
|
|
80
|
+
Vector3.new(1, PHI, 0), Vector3.new(-1, PHI, 0),
|
|
81
|
+
Vector3.new(1, -PHI, 0), Vector3.new(-1, -PHI, 0),
|
|
82
|
+
Vector3.new(PHI, 0, 1), Vector3.new(PHI, 0, -1),
|
|
83
|
+
Vector3.new(-PHI, 0, 1), Vector3.new(-PHI, 0, -1),
|
|
84
|
+
},
|
|
85
|
+
edges = {
|
|
86
|
+
{ 1, 2 }, { 1, 5 }, { 1, 6 }, { 1, 9 }, { 1, 11 },
|
|
87
|
+
{ 2, 7 }, { 2, 8 }, { 2, 9 }, { 2, 11 },
|
|
88
|
+
{ 3, 4 }, { 3, 5 }, { 3, 6 }, { 3, 10 }, { 3, 12 },
|
|
89
|
+
{ 4, 7 }, { 4, 8 }, { 4, 10 }, { 4, 12 },
|
|
90
|
+
{ 5, 6 }, { 5, 9 }, { 5, 10 },
|
|
91
|
+
{ 6, 11 }, { 6, 12 },
|
|
92
|
+
{ 7, 8 }, { 7, 9 }, { 7, 10 },
|
|
93
|
+
{ 8, 11 }, { 8, 12 },
|
|
94
|
+
{ 9, 10 }, { 11, 12 },
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
local MAX_EDGES = 30
|
|
100
|
+
local MAX_VERTICES = 12
|
|
101
|
+
local FOCAL = 3.4
|
|
102
|
+
|
|
103
|
+
-- The inner solid, counter-rotating inside the outer one. An octahedron
|
|
104
|
+
-- regardless of what is outside it: it reads as a core rather than as a second
|
|
105
|
+
-- object competing for attention, and its 12 edges are cheap.
|
|
106
|
+
local INNER_SHAPE = 2
|
|
107
|
+
local INNER_SCALE = 0.42
|
|
108
|
+
|
|
109
|
+
-- The solid's resting radius as a fraction of the cell. At full overshoot the
|
|
110
|
+
-- perspective divide pushes a near vertex to roughly 15px, which still clears
|
|
111
|
+
-- the cell's half-width.
|
|
112
|
+
local BASE_RADIUS = 0.185
|
|
113
|
+
|
|
114
|
+
local Lattice = {}
|
|
115
|
+
|
|
116
|
+
Lattice.id = "lattice"
|
|
117
|
+
Lattice.label = "Lattice"
|
|
118
|
+
Lattice.blurb = "Wireframe solid"
|
|
119
|
+
|
|
120
|
+
Lattice.palette = {
|
|
121
|
+
-- Violet-tinted near-black. The cast is slight but it stops the panel
|
|
122
|
+
-- reading as the same grey as every other Studio dock.
|
|
123
|
+
background = Color3.fromRGB(11, 12, 20),
|
|
124
|
+
surface = Color3.fromRGB(18, 20, 31),
|
|
125
|
+
rule = Color3.fromRGB(167, 139, 250),
|
|
126
|
+
text = Color3.fromRGB(206, 212, 226),
|
|
127
|
+
dim = Color3.fromRGB(90, 97, 120),
|
|
128
|
+
green = Color3.fromRGB(110, 231, 168),
|
|
129
|
+
red = Color3.fromRGB(251, 113, 133),
|
|
130
|
+
amber = Color3.fromRGB(251, 191, 36),
|
|
131
|
+
cyan = Color3.fromRGB(103, 232, 249),
|
|
132
|
+
violet = Color3.fromRGB(167, 139, 250),
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
local edges: { Frame } = {}
|
|
136
|
+
local innerEdges: { Frame } = {}
|
|
137
|
+
local dots: { Frame } = {}
|
|
138
|
+
|
|
139
|
+
function Lattice.mount(cell: Frame)
|
|
140
|
+
edges = Draw.pool(cell, MAX_EDGES, "Edge", false)
|
|
141
|
+
innerEdges = Draw.pool(cell, #SHAPES[INNER_SHAPE].edges, "Inner", false)
|
|
142
|
+
-- Built after the edges so the lit corners sit over the strokes they join.
|
|
143
|
+
dots = Draw.pool(cell, MAX_VERTICES, "Vertex", true)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
function Lattice.unmount()
|
|
147
|
+
edges = {}
|
|
148
|
+
innerEdges = {}
|
|
149
|
+
dots = {}
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
function Lattice.paint(ctx: Theme.Context, _delta: number)
|
|
153
|
+
local shapeIndex = math.clamp(1 + math.floor(ctx.activity * (#SHAPES - 0.001)), 1, #SHAPES)
|
|
154
|
+
local shape = SHAPES[shapeIndex]
|
|
155
|
+
local radius = ctx.size * BASE_RADIUS * ctx.scale
|
|
156
|
+
|
|
157
|
+
-- Wobble the axis rather than the panel: shaking the strip would read as a
|
|
158
|
+
-- glitch, tilting the solid reads as it being knocked.
|
|
159
|
+
local wobble = if ctx.shake > 0
|
|
160
|
+
then Vector3.new(
|
|
161
|
+
math.sin(ctx.clock * 37) * ctx.shake * 0.12,
|
|
162
|
+
math.cos(ctx.clock * 29) * ctx.shake * 0.12,
|
|
163
|
+
0
|
|
164
|
+
)
|
|
165
|
+
else Vector3.zero
|
|
166
|
+
|
|
167
|
+
local projected: { Vector2 } = {}
|
|
168
|
+
local depths: { number } = {}
|
|
169
|
+
for index, vertex in shape.vertices do
|
|
170
|
+
local point, depth = Draw.project(
|
|
171
|
+
Draw.rotate(vertex, ctx.angle + wobble),
|
|
172
|
+
FOCAL,
|
|
173
|
+
radius,
|
|
174
|
+
ctx.centre
|
|
175
|
+
)
|
|
176
|
+
projected[index] = point
|
|
177
|
+
depths[index] = depth
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
for index = 1, MAX_EDGES do
|
|
181
|
+
local frame = edges[index]
|
|
182
|
+
if frame == nil then
|
|
183
|
+
continue
|
|
184
|
+
end
|
|
185
|
+
local edge = shape.edges[index]
|
|
186
|
+
if edge == nil then
|
|
187
|
+
frame.Visible = false
|
|
188
|
+
continue
|
|
189
|
+
end
|
|
190
|
+
local a, b = edge[1], edge[2]
|
|
191
|
+
local depth = math.clamp(((depths[a] + depths[b]) / 2 + 2) / 4, 0, 1)
|
|
192
|
+
Draw.edge(
|
|
193
|
+
frame,
|
|
194
|
+
projected[a],
|
|
195
|
+
projected[b],
|
|
196
|
+
math.max(1, math.floor(1 + depth * 1.6 + ctx.flare * 0.8)),
|
|
197
|
+
ctx.tint:Lerp(Color3.new(1, 1, 1), depth * 0.35 + ctx.flare * 0.3),
|
|
198
|
+
math.max(0, 0.84 - depth * 0.74 - ctx.flare * 0.3)
|
|
199
|
+
)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
--[[
|
|
203
|
+
Vertices as points, brighter than the edges they join.
|
|
204
|
+
|
|
205
|
+
Wireframes read as flat when every stroke is the same weight. Lighting
|
|
206
|
+
the corners gives the eye something to track as the solid turns, which is
|
|
207
|
+
what makes it read as rotating rather than merely flickering.
|
|
208
|
+
]]
|
|
209
|
+
for index = 1, MAX_VERTICES do
|
|
210
|
+
local dot = dots[index]
|
|
211
|
+
if dot == nil then
|
|
212
|
+
continue
|
|
213
|
+
end
|
|
214
|
+
local point = projected[index]
|
|
215
|
+
if point == nil then
|
|
216
|
+
dot.Visible = false
|
|
217
|
+
continue
|
|
218
|
+
end
|
|
219
|
+
local depth = math.clamp((depths[index] + 2) / 4, 0, 1)
|
|
220
|
+
-- The corners carry the flare. A pulse of light travelling through the
|
|
221
|
+
-- vertices is what makes a completed call land as an event rather than
|
|
222
|
+
-- as a bar quietly appearing somewhere off to the right.
|
|
223
|
+
Draw.dot(
|
|
224
|
+
dot,
|
|
225
|
+
point,
|
|
226
|
+
math.max(2, math.floor(1.5 + depth * 2.5 + ctx.flare * 2.5)),
|
|
227
|
+
ctx.tint:Lerp(Color3.new(1, 1, 1), 0.25 + depth * 0.5),
|
|
228
|
+
math.max(0, 0.55 - depth * 0.5 - ctx.flare * 0.45)
|
|
229
|
+
)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
--[[
|
|
233
|
+
A second solid inside the first, turning the other way.
|
|
234
|
+
|
|
235
|
+
One rotating outline is a loading spinner. Two, at different rates and
|
|
236
|
+
opposite directions, read as a mechanism -- and the counter-rotation
|
|
237
|
+
makes the outer solid's direction obvious, which a lone wireframe hides
|
|
238
|
+
whenever it passes through a symmetrical angle.
|
|
239
|
+
]]
|
|
240
|
+
local inner = SHAPES[INNER_SHAPE]
|
|
241
|
+
local innerAngle = -ctx.angle * 1.6
|
|
242
|
+
local innerProjected: { Vector2 } = {}
|
|
243
|
+
local innerDepths: { number } = {}
|
|
244
|
+
for index, vertex in inner.vertices do
|
|
245
|
+
local point, depth = Draw.project(
|
|
246
|
+
Draw.rotate(vertex, innerAngle),
|
|
247
|
+
FOCAL,
|
|
248
|
+
radius * INNER_SCALE,
|
|
249
|
+
ctx.centre
|
|
250
|
+
)
|
|
251
|
+
innerProjected[index] = point
|
|
252
|
+
innerDepths[index] = depth
|
|
253
|
+
end
|
|
254
|
+
for index, frame in innerEdges do
|
|
255
|
+
local edge = inner.edges[index]
|
|
256
|
+
if edge == nil then
|
|
257
|
+
frame.Visible = false
|
|
258
|
+
continue
|
|
259
|
+
end
|
|
260
|
+
local a, b = edge[1], edge[2]
|
|
261
|
+
local depth = math.clamp(((innerDepths[a] + innerDepths[b]) / 2 + 2) / 4, 0, 1)
|
|
262
|
+
Draw.edge(
|
|
263
|
+
frame,
|
|
264
|
+
innerProjected[a],
|
|
265
|
+
innerProjected[b],
|
|
266
|
+
1,
|
|
267
|
+
ctx.tint:Lerp(Color3.new(1, 1, 1), 0.5),
|
|
268
|
+
0.9 - depth * 0.35
|
|
269
|
+
)
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
--[[
|
|
274
|
+
A still cube, which is the shape people recognise the console by, drawn at a
|
|
275
|
+
three-quarter angle so all three dimensions are visible at once.
|
|
276
|
+
]]
|
|
277
|
+
function Lattice.swatch(frame: Frame, palette: Theme.Palette)
|
|
278
|
+
local centre = Vector2.new(20, 12)
|
|
279
|
+
local pool = Draw.pool(frame, 12, "Edge", false)
|
|
280
|
+
local shape = SHAPES[3]
|
|
281
|
+
local angle = Vector3.new(0.5, 0.7, 0)
|
|
282
|
+
|
|
283
|
+
local projected: { Vector2 } = {}
|
|
284
|
+
local depths: { number } = {}
|
|
285
|
+
for index, vertex in shape.vertices do
|
|
286
|
+
local point, depth = Draw.project(Draw.rotate(vertex, angle), FOCAL, 5.5, centre)
|
|
287
|
+
projected[index] = point
|
|
288
|
+
depths[index] = depth
|
|
289
|
+
end
|
|
290
|
+
for index, edge in shape.edges do
|
|
291
|
+
local a, b = edge[1], edge[2]
|
|
292
|
+
local depth = math.clamp(((depths[a] + depths[b]) / 2 + 2) / 4, 0, 1)
|
|
293
|
+
Draw.edge(
|
|
294
|
+
pool[index],
|
|
295
|
+
projected[a],
|
|
296
|
+
projected[b],
|
|
297
|
+
1,
|
|
298
|
+
palette.violet:Lerp(Color3.new(1, 1, 1), depth * 0.4),
|
|
299
|
+
0.75 - depth * 0.6
|
|
300
|
+
)
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
return Lattice
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
Nebula -- the pink one, and the only preset with no straight lines.
|
|
4
|
+
|
|
5
|
+
Soft blobs advected by a slow flow field, magenta through cyan. Every other
|
|
6
|
+
theme in the set draws edges, points or bars; this one draws nothing with a
|
|
7
|
+
corner in it, which is what makes it read as a different instrument rather
|
|
8
|
+
than as the same instrument recoloured.
|
|
9
|
+
|
|
10
|
+
The flow is a pair of sines sampled at each blob's position -- cheap, and
|
|
11
|
+
continuous, so neighbouring blobs drift in similar directions and the field
|
|
12
|
+
holds together instead of scattering. Blobs that leave the cell wrap back to
|
|
13
|
+
the far side rather than being respawned, so the field never thins out.
|
|
14
|
+
|
|
15
|
+
A reply blooms rather than flashes: the blobs swell and brighten over the
|
|
16
|
+
third of a second `flare` lasts. On a strip this small a hard flash reads as
|
|
17
|
+
a rendering fault; a bloom reads as something arriving.
|
|
18
|
+
]]
|
|
19
|
+
|
|
20
|
+
local Draw = require(script.Parent.Draw)
|
|
21
|
+
local Theme = require(script.Parent.Theme)
|
|
22
|
+
|
|
23
|
+
local BLOBS = 20
|
|
24
|
+
|
|
25
|
+
local Nebula = {}
|
|
26
|
+
|
|
27
|
+
Nebula.id = "nebula"
|
|
28
|
+
Nebula.label = "Nebula"
|
|
29
|
+
Nebula.blurb = "Drifting dust"
|
|
30
|
+
|
|
31
|
+
Nebula.palette = {
|
|
32
|
+
background = Color3.fromRGB(19, 11, 24),
|
|
33
|
+
surface = Color3.fromRGB(30, 18, 38),
|
|
34
|
+
rule = Color3.fromRGB(236, 122, 196),
|
|
35
|
+
text = Color3.fromRGB(238, 214, 236),
|
|
36
|
+
dim = Color3.fromRGB(122, 96, 128),
|
|
37
|
+
green = Color3.fromRGB(142, 230, 196),
|
|
38
|
+
red = Color3.fromRGB(255, 122, 148),
|
|
39
|
+
amber = Color3.fromRGB(250, 190, 140),
|
|
40
|
+
cyan = Color3.fromRGB(134, 226, 240),
|
|
41
|
+
violet = Color3.fromRGB(226, 140, 236),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type Blob = {
|
|
45
|
+
frame: Frame,
|
|
46
|
+
position: Vector2,
|
|
47
|
+
-- Fixed per blob: size, how much of the magenta-to-cyan range it sits at,
|
|
48
|
+
-- and its own drift phase.
|
|
49
|
+
scale: number,
|
|
50
|
+
hue: number,
|
|
51
|
+
phase: number,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
local blobs: { Blob } = {}
|
|
55
|
+
local size = 40
|
|
56
|
+
|
|
57
|
+
--[[
|
|
58
|
+
The flow field. Two sines crossed, which is enough structure to look like a
|
|
59
|
+
current and cheap enough to sample forty times a frame.
|
|
60
|
+
|
|
61
|
+
Time enters slowly. A field that changes as fast as the things moving
|
|
62
|
+
through it reads as noise; at this rate the current itself visibly turns
|
|
63
|
+
over about a minute, under blobs that take a few seconds to cross.
|
|
64
|
+
]]
|
|
65
|
+
local function flow(at: Vector2, clock: number): Vector2
|
|
66
|
+
local x = math.sin(at.Y * 0.16 + clock * 0.21)
|
|
67
|
+
local y = math.cos(at.X * 0.14 - clock * 0.17)
|
|
68
|
+
return Vector2.new(x, y)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
function Nebula.mount(cell: Frame)
|
|
72
|
+
size = cell.AbsoluteSize.X
|
|
73
|
+
if size < 8 then
|
|
74
|
+
size = 40
|
|
75
|
+
end
|
|
76
|
+
local frames = Draw.pool(cell, BLOBS, "Blob", true)
|
|
77
|
+
blobs = {}
|
|
78
|
+
for index = 1, BLOBS do
|
|
79
|
+
blobs[index] = {
|
|
80
|
+
frame = frames[index],
|
|
81
|
+
position = Vector2.new(Draw.hash(index, 21) * size, Draw.hash(index, 22) * size),
|
|
82
|
+
scale = 0.4 + Draw.hash(index, 23) * 1.6,
|
|
83
|
+
hue = Draw.hash(index, 24),
|
|
84
|
+
phase = Draw.hash(index, 25) * math.pi * 2,
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
function Nebula.unmount()
|
|
90
|
+
blobs = {}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
function Nebula.paint(ctx: Theme.Context, delta: number)
|
|
94
|
+
-- Clamped because Studio hands out long frames while it compiles, and an
|
|
95
|
+
-- unclamped step teleports the whole field across the cell in one of them.
|
|
96
|
+
local step = math.min(delta, 1 / 30)
|
|
97
|
+
local drift = 6 + ctx.energy * 22
|
|
98
|
+
|
|
99
|
+
for index = 1, #blobs do
|
|
100
|
+
local blob = blobs[index]
|
|
101
|
+
blob.position += flow(blob.position, ctx.clock + blob.phase) * drift * step
|
|
102
|
+
|
|
103
|
+
-- Wrapped rather than respawned. A blob that reappears at a random point
|
|
104
|
+
-- pops; one that comes back around the far edge is simply still moving.
|
|
105
|
+
local margin = 6
|
|
106
|
+
if blob.position.X < -margin then
|
|
107
|
+
blob.position = Vector2.new(size + margin, blob.position.Y)
|
|
108
|
+
elseif blob.position.X > size + margin then
|
|
109
|
+
blob.position = Vector2.new(-margin, blob.position.Y)
|
|
110
|
+
end
|
|
111
|
+
if blob.position.Y < -margin then
|
|
112
|
+
blob.position = Vector2.new(blob.position.X, size + margin)
|
|
113
|
+
elseif blob.position.Y > size + margin then
|
|
114
|
+
blob.position = Vector2.new(blob.position.X, -margin)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
--[[
|
|
118
|
+
Breathing on the blob's own phase, so the cloud is never uniform.
|
|
119
|
+
The bloom on top of it is shared, which is what makes a reply read as
|
|
120
|
+
one event happening to the whole field rather than as twenty
|
|
121
|
+
independent flickers.
|
|
122
|
+
]]
|
|
123
|
+
local breathe = 0.85 + 0.15 * math.sin(ctx.clock * 0.8 + blob.phase)
|
|
124
|
+
local diameter = math.max(
|
|
125
|
+
2,
|
|
126
|
+
math.floor(blob.scale * 5 * breathe * ctx.scale + ctx.flare * 4)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
local colour = ctx.palette.violet:Lerp(ctx.palette.cyan, blob.hue)
|
|
130
|
+
Draw.dot(
|
|
131
|
+
blob.frame,
|
|
132
|
+
blob.position,
|
|
133
|
+
diameter,
|
|
134
|
+
colour:Lerp(Color3.new(1, 1, 1), ctx.flare * 0.5),
|
|
135
|
+
--[[
|
|
136
|
+
Deliberately very transparent. Dust is only dust when the blobs
|
|
137
|
+
overlap into something denser than any one of them, and at any
|
|
138
|
+
less than this each blob reads as a discrete circle.
|
|
139
|
+
]]
|
|
140
|
+
math.clamp(0.82 - ctx.energy * 0.12 - ctx.flare * 0.25, 0, 1)
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
--[[
|
|
146
|
+
History as columns of dust lit from below rather than as bars.
|
|
147
|
+
|
|
148
|
+
Same data, same heights, but drawn with a rounded cap and no hard baseline,
|
|
149
|
+
so the trace belongs to the same picture as the cell.
|
|
150
|
+
]]
|
|
151
|
+
function Nebula.paintSlot(slot: Theme.Slot, ctx: Theme.Context)
|
|
152
|
+
local frame = slot.frame
|
|
153
|
+
local recency = 1 - slot.age
|
|
154
|
+
local height = math.max(2, math.floor(2 + slot.weight * 20))
|
|
155
|
+
|
|
156
|
+
frame.AnchorPoint = Vector2.new(0.5, 1)
|
|
157
|
+
frame.Size = UDim2.fromOffset(3, height)
|
|
158
|
+
frame.Position = UDim2.new(frame.Position.X.Scale, frame.Position.X.Offset, 1, 0)
|
|
159
|
+
frame.BackgroundColor3 = if slot.ok
|
|
160
|
+
then ctx.palette.violet:Lerp(ctx.palette.cyan, slot.weight)
|
|
161
|
+
else ctx.palette.red
|
|
162
|
+
frame.BackgroundTransparency = math.clamp(0.72 - recency * 0.6, 0, 1)
|
|
163
|
+
frame.Rotation = 0
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
function Nebula.swatch(frame: Frame, palette: Theme.Palette)
|
|
167
|
+
local pool = Draw.pool(frame, 12, "Blob", true)
|
|
168
|
+
for index = 1, 12 do
|
|
169
|
+
local x = 4 + Draw.hash(index, 31) * 32
|
|
170
|
+
local y = 3 + Draw.hash(index, 32) * 18
|
|
171
|
+
local hue = Draw.hash(index, 33)
|
|
172
|
+
Draw.dot(
|
|
173
|
+
pool[index],
|
|
174
|
+
Vector2.new(x, y),
|
|
175
|
+
4 + math.floor(Draw.hash(index, 34) * 6),
|
|
176
|
+
palette.violet:Lerp(palette.cyan, hue),
|
|
177
|
+
0.7
|
|
178
|
+
)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
return Nebula
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
Observatory -- AMOLED, and a session you can read as a sky.
|
|
4
|
+
|
|
5
|
+
The background is true #000000. On an OLED display those pixels are off, so
|
|
6
|
+
the panel has no edges at all and the console appears to float in the Studio
|
|
7
|
+
dock rather than sit in a box. That only works if the black is actually
|
|
8
|
+
black: a near-black at RGB(11, 12, 20) lights every pixel and the illusion
|
|
9
|
+
collapses, which is why this theme does not simply darken the default one.
|
|
10
|
+
|
|
11
|
+
The cell holds a star sphere instead of a prism -- points on a shell, turned
|
|
12
|
+
slowly and projected, twinkling on their own phases. It reads as depth
|
|
13
|
+
without a single straight line, which is the whole point of the contrast
|
|
14
|
+
with Lattice.
|
|
15
|
+
|
|
16
|
+
The trace is the reason the theme exists. Each finished call is a star whose
|
|
17
|
+
height is how slow it was and whose brightness fades as it ages, so forty
|
|
18
|
+
calls of history read as a constellation rather than as a bar chart. A
|
|
19
|
+
failure is a red giant: bigger, redder, and slower to fade, so the one thing
|
|
20
|
+
you actually want to find in a session's history is the one thing the eye
|
|
21
|
+
lands on first.
|
|
22
|
+
]]
|
|
23
|
+
|
|
24
|
+
local Draw = require(script.Parent.Draw)
|
|
25
|
+
local Theme = require(script.Parent.Theme)
|
|
26
|
+
|
|
27
|
+
-- Points on the shell. Enough to read as a field, few enough that every one of
|
|
28
|
+
-- them is a Frame the paint loop touches every frame.
|
|
29
|
+
local STARS = 26
|
|
30
|
+
local FOCAL = 3.2
|
|
31
|
+
local SHELL = 0.36
|
|
32
|
+
|
|
33
|
+
local Observatory = {}
|
|
34
|
+
|
|
35
|
+
Observatory.id = "observatory"
|
|
36
|
+
Observatory.label = "Observatory"
|
|
37
|
+
Observatory.blurb = "True black. Stars"
|
|
38
|
+
|
|
39
|
+
Observatory.palette = {
|
|
40
|
+
-- Genuinely off, not merely dark. Every other colour here is chosen to hold
|
|
41
|
+
-- contrast against zero rather than against a lifted black.
|
|
42
|
+
background = Color3.fromRGB(0, 0, 0),
|
|
43
|
+
surface = Color3.fromRGB(8, 9, 12),
|
|
44
|
+
rule = Color3.fromRGB(148, 178, 214),
|
|
45
|
+
text = Color3.fromRGB(214, 224, 238),
|
|
46
|
+
dim = Color3.fromRGB(78, 88, 106),
|
|
47
|
+
green = Color3.fromRGB(126, 226, 176),
|
|
48
|
+
red = Color3.fromRGB(248, 122, 122),
|
|
49
|
+
amber = Color3.fromRGB(240, 196, 118),
|
|
50
|
+
cyan = Color3.fromRGB(150, 214, 240),
|
|
51
|
+
violet = Color3.fromRGB(176, 172, 236),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
local stars: { Frame } = {}
|
|
55
|
+
local meridian: { Frame } = {}
|
|
56
|
+
|
|
57
|
+
--[[
|
|
58
|
+
A fixed point on the shell for star `index`.
|
|
59
|
+
|
|
60
|
+
Derived from the index rather than stored, and derived the same way every
|
|
61
|
+
frame -- a field that re-randomised itself per frame would boil rather than
|
|
62
|
+
drift.
|
|
63
|
+
|
|
64
|
+
Evenly spread rather than randomly placed. A hashed longitude and latitude
|
|
65
|
+
is the obvious way to do this and it produced a visibly lopsided sky: the
|
|
66
|
+
field bunched into one corner of the cell with an empty half beside it,
|
|
67
|
+
which reads as a rendering fault rather than as stars. Twinkle supplies the
|
|
68
|
+
irregularity instead, and it does it without leaving holes.
|
|
69
|
+
]]
|
|
70
|
+
local function starAt(index: number): Vector3
|
|
71
|
+
return Draw.sphere(index, STARS)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
function Observatory.mount(cell: Frame)
|
|
75
|
+
-- The meridian sits under the stars so a bright point is never cut by the
|
|
76
|
+
-- line it happens to cross.
|
|
77
|
+
meridian = Draw.pool(cell, 24, "Meridian", true)
|
|
78
|
+
stars = Draw.pool(cell, STARS, "Star", true)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
function Observatory.unmount()
|
|
82
|
+
stars = {}
|
|
83
|
+
meridian = {}
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
function Observatory.paint(ctx: Theme.Context, _delta: number)
|
|
87
|
+
local radius = ctx.size * SHELL * ctx.scale
|
|
88
|
+
-- A third of Lattice's rate. Stars that turn at a wireframe's pace read as
|
|
89
|
+
-- a spinning object; at this speed they read as a sky the panel is looking
|
|
90
|
+
-- out at, which is the difference the theme is trading on.
|
|
91
|
+
local angle = ctx.angle * 0.34
|
|
92
|
+
|
|
93
|
+
for index = 1, STARS do
|
|
94
|
+
local frame = stars[index]
|
|
95
|
+
if frame == nil then
|
|
96
|
+
continue
|
|
97
|
+
end
|
|
98
|
+
local point, depth = Draw.project(
|
|
99
|
+
Draw.rotate(starAt(index), angle),
|
|
100
|
+
FOCAL,
|
|
101
|
+
radius,
|
|
102
|
+
ctx.centre
|
|
103
|
+
)
|
|
104
|
+
local near = math.clamp((depth + 1) / 2, 0, 1)
|
|
105
|
+
|
|
106
|
+
--[[
|
|
107
|
+
Each star on its own phase and rate, so the field never pulses as one.
|
|
108
|
+
|
|
109
|
+
A shared clock would make the whole sphere breathe together, which
|
|
110
|
+
reads as the panel flickering rather than as stars twinkling. The
|
|
111
|
+
rates are deliberately irrational relative to each other.
|
|
112
|
+
]]
|
|
113
|
+
local twinkle = 0.5 + 0.5 * math.sin(ctx.clock * (0.7 + Draw.hash(index, 3) * 1.9)
|
|
114
|
+
+ Draw.hash(index, 4) * 9)
|
|
115
|
+
|
|
116
|
+
local brightness = near * 0.7 + twinkle * 0.3
|
|
117
|
+
Draw.dot(
|
|
118
|
+
frame,
|
|
119
|
+
point,
|
|
120
|
+
math.max(1, math.floor(1 + near * 2 + ctx.flare * 2)),
|
|
121
|
+
ctx.tint:Lerp(Color3.new(1, 1, 1), 0.3 + brightness * 0.6),
|
|
122
|
+
math.clamp(0.9 - brightness * 0.8 - ctx.flare * 0.4, 0, 1)
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
--[[
|
|
127
|
+
A great circle through the sphere: the horizon an observatory is aimed
|
|
128
|
+
along. Drawn as points rather than segments so it fades in and out with
|
|
129
|
+
depth like the stars do, instead of reading as a hard hoop drawn on top
|
|
130
|
+
of them.
|
|
131
|
+
]]
|
|
132
|
+
-- 1.05 rather than the 1.18 this started at: at full overshoot the wider
|
|
133
|
+
-- ring reached 21.5px from a centre with 20px of room, so the horizon was
|
|
134
|
+
-- sliced off by the cell's own clipping exactly when the session was busiest.
|
|
135
|
+
for index = 1, #meridian do
|
|
136
|
+
local frame = meridian[index]
|
|
137
|
+
local turn = (index / #meridian) * math.pi * 2
|
|
138
|
+
local point, depth = Draw.project(
|
|
139
|
+
Draw.rotate(Vector3.new(math.cos(turn), 0, math.sin(turn)), angle),
|
|
140
|
+
FOCAL,
|
|
141
|
+
radius * 1.05,
|
|
142
|
+
ctx.centre
|
|
143
|
+
)
|
|
144
|
+
local near = math.clamp((depth + 1) / 2, 0, 1)
|
|
145
|
+
Draw.dot(frame, point, 1, ctx.tint, math.clamp(0.94 - near * 0.35, 0, 1))
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
--[[
|
|
150
|
+
One call as a star rather than as a bar.
|
|
151
|
+
|
|
152
|
+
Height is duration, so a slow call sits high and the eye reads the skyline
|
|
153
|
+
as a latency profile without being told to. Brightness is recency, so the
|
|
154
|
+
newest calls are the ones that stand out and old history recedes on its own
|
|
155
|
+
rather than needing to be scrolled away.
|
|
156
|
+
]]
|
|
157
|
+
function Observatory.paintSlot(slot: Theme.Slot, ctx: Theme.Context)
|
|
158
|
+
local frame = slot.frame
|
|
159
|
+
local recency = 1 - slot.age
|
|
160
|
+
|
|
161
|
+
-- Failures ignore the height rule. A red giant is large because it failed,
|
|
162
|
+
-- not because it was slow, and conflating the two would hide a fast error
|
|
163
|
+
-- among fast successes at the bottom of the trace.
|
|
164
|
+
local size = if slot.ok
|
|
165
|
+
then math.max(2, math.floor(1.5 + slot.weight * 2.5 + recency * 1.5))
|
|
166
|
+
else math.max(4, math.floor(4 + recency * 2))
|
|
167
|
+
|
|
168
|
+
frame.AnchorPoint = Vector2.new(0.5, 0.5)
|
|
169
|
+
frame.Size = UDim2.fromOffset(size, size)
|
|
170
|
+
-- 0.12 keeps the newest star clear of the trace's top edge, and the 0.82
|
|
171
|
+
-- span leaves the baseline visible under the lowest one.
|
|
172
|
+
frame.Position = UDim2.new(frame.Position.X.Scale, frame.Position.X.Offset, 0.9 - slot.weight * 0.78, 0)
|
|
173
|
+
frame.BackgroundColor3 = if slot.ok
|
|
174
|
+
then ctx.palette.cyan:Lerp(Color3.new(1, 1, 1), 0.25 + recency * 0.4)
|
|
175
|
+
else ctx.palette.red
|
|
176
|
+
frame.BackgroundTransparency = if slot.ok
|
|
177
|
+
then math.clamp(0.72 - recency * 0.62, 0, 1)
|
|
178
|
+
-- Slower to fade than a success, so the failure in a session's history
|
|
179
|
+
-- is still legible forty calls later.
|
|
180
|
+
else math.clamp(0.45 - recency * 0.4, 0, 1)
|
|
181
|
+
frame.Rotation = 0
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
function Observatory.swatch(frame: Frame, palette: Theme.Palette)
|
|
185
|
+
local pool = Draw.pool(frame, 14, "Star", true)
|
|
186
|
+
for index = 1, 14 do
|
|
187
|
+
local x = 3 + Draw.hash(index, 11) * 34
|
|
188
|
+
local y = 3 + Draw.hash(index, 12) * 18
|
|
189
|
+
local bright = Draw.hash(index, 13)
|
|
190
|
+
Draw.dot(
|
|
191
|
+
pool[index],
|
|
192
|
+
Vector2.new(x, y),
|
|
193
|
+
if bright > 0.85 then 3 else 1 + math.floor(bright * 2),
|
|
194
|
+
palette.cyan:Lerp(Color3.new(1, 1, 1), bright * 0.7),
|
|
195
|
+
0.85 - bright * 0.7
|
|
196
|
+
)
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
return Observatory
|