@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.
Files changed (43) hide show
  1. package/README.md +20 -26
  2. package/dist/index.js +1 -1
  3. package/dist/lib/format.js +18 -2
  4. package/dist/lib/format.js.map +1 -1
  5. package/dist/tools/api.js +20 -1
  6. package/dist/tools/api.js.map +1 -1
  7. package/dist/tools/debug.js +18 -8
  8. package/dist/tools/debug.js.map +1 -1
  9. package/dist/tools/discover.js +5 -0
  10. package/dist/tools/discover.js.map +1 -1
  11. package/dist/tools/input.js +38 -5
  12. package/dist/tools/input.js.map +1 -1
  13. package/dist/tools/instances.js +49 -2
  14. package/dist/tools/instances.js.map +1 -1
  15. package/dist/tools/perf.js +9 -8
  16. package/dist/tools/perf.js.map +1 -1
  17. package/dist/tools/scripts.js +10 -1
  18. package/dist/tools/scripts.js.map +1 -1
  19. package/package.json +1 -1
  20. package/plugin/src/Config.luau +1 -1
  21. package/plugin/src/Console.luau +301 -117
  22. package/plugin/src/Mirror.luau +126 -0
  23. package/plugin/src/Serialize.luau +43 -3
  24. package/plugin/src/ThemePicker.luau +458 -0
  25. package/plugin/src/Themes/Aurora.luau +161 -0
  26. package/plugin/src/Themes/Blueprint.luau +213 -0
  27. package/plugin/src/Themes/Draw.luau +177 -0
  28. package/plugin/src/Themes/Lattice.luau +304 -0
  29. package/plugin/src/Themes/Nebula.luau +182 -0
  30. package/plugin/src/Themes/Observatory.luau +200 -0
  31. package/plugin/src/Themes/Orbit.luau +268 -0
  32. package/plugin/src/Themes/Phosphor.luau +200 -0
  33. package/plugin/src/Themes/Theme.luau +121 -0
  34. package/plugin/src/Themes/Void.luau +266 -0
  35. package/plugin/src/Themes/init.luau +116 -0
  36. package/plugin/src/Undo.luau +29 -0
  37. package/plugin/src/Visuals.luau +838 -907
  38. package/plugin/src/handlers/Device.luau +21 -1
  39. package/plugin/src/handlers/Discover.luau +75 -1
  40. package/plugin/src/handlers/Exec.luau +17 -5
  41. package/plugin/src/handlers/Input.luau +583 -493
  42. package/plugin/src/handlers/Instances.luau +35 -3
  43. package/plugin/src/init.server.luau +209 -11
@@ -0,0 +1,268 @@
1
+ --!strict
2
+ --[[
3
+ Orbit -- the only preset that reports something the others cannot.
4
+
5
+ A body at the centre with satellites running elliptical paths around it. The
6
+ number of satellites is the session's activity, so where Lattice climbs a
7
+ ladder of shapes to say "busy", this says it in a way you can count: one
8
+ moon is a quiet session, five is a burst.
9
+
10
+ The ellipses are a projected circle rather than a drawn oval, so the whole
11
+ system tilts as the angle turns and a satellite genuinely passes behind the
12
+ body -- it dims and shrinks on the far side because it is further away. That
13
+ is what stops it reading as a loading spinner with extra dots.
14
+
15
+ Colour is warm here on purpose. Every other dark preset in the set leans
16
+ blue or violet; a gold system against navy is the one that looks like an
17
+ instrument rather than a screensaver.
18
+ ]]
19
+
20
+ local Draw = require(script.Parent.Draw)
21
+ local Theme = require(script.Parent.Theme)
22
+
23
+ -- Satellites are pooled at the maximum and hidden when the session is calm, so
24
+ -- activity changes what is visible rather than what exists.
25
+ local MOONS = 6
26
+ local PATH_DOTS = 28
27
+ local FOCAL = 3.2
28
+
29
+ --[[
30
+ How far the orbits reach, as a multiple of the body's radius.
31
+
32
+ Chosen so the outermost satellite sits about 18px from a centre with 20px of
33
+ room: filling the cell, with just enough margin that it never touches the
34
+ clip. Two earlier values were both wrong for the same underlying reason --
35
+ 2.4 threw the sixth moon 26px out when busy, and 1.5 fixed the clipping by
36
+ making the whole system too small to read.
37
+ ]]
38
+ local ORBIT_SCALE = 1.95
39
+
40
+ --[[
41
+ How much of the scale spring the ORBITS take. Almost none.
42
+
43
+ The spring swings 0.82 to 1.20, and letting the satellites carry that meant
44
+ the system had to rest small enough that its overshoot still fit -- so it
45
+ was sized by its worst moment rather than by the one you spend all your time
46
+ looking at. The body and the halo carry the spring instead: they sit at the
47
+ centre with room to grow, and a struck object whose middle flexes reads the
48
+ same as one that flexes all over.
49
+ ]]
50
+ local ORBIT_BREATH = 0.08
51
+
52
+ local Orbit = {}
53
+
54
+ Orbit.id = "orbit"
55
+ Orbit.label = "Orbit"
56
+ Orbit.blurb = "Body and satellites"
57
+
58
+ Orbit.palette = {
59
+ background = Color3.fromRGB(10, 13, 22),
60
+ surface = Color3.fromRGB(17, 22, 36),
61
+ rule = Color3.fromRGB(240, 186, 108),
62
+ text = Color3.fromRGB(226, 222, 210),
63
+ dim = Color3.fromRGB(96, 100, 116),
64
+ green = Color3.fromRGB(134, 214, 158),
65
+ red = Color3.fromRGB(240, 130, 116),
66
+ amber = Color3.fromRGB(246, 190, 106),
67
+ cyan = Color3.fromRGB(132, 196, 226),
68
+ violet = Color3.fromRGB(196, 164, 226),
69
+ }
70
+
71
+ local body: Frame? = nil
72
+ local halo: Frame? = nil
73
+ local moons: { Frame } = {}
74
+ local path: { Frame } = {}
75
+
76
+ --[[
77
+ Orbital parameters for satellite `index`.
78
+
79
+ Fixed per index so a moon keeps its own path for the life of the session --
80
+ re-rolling them would make the system reshuffle every frame. Radius rises
81
+ with the index so the moons never share an orbit, and the inclination is
82
+ what makes the paths cross rather than sit in one flat ring.
83
+ ]]
84
+ local function orbitOf(index: number): (number, number, number)
85
+ -- Tightened from `0.42 + index * 0.1`, which put the sixth moon at 1.02 and
86
+ -- 26px from a centre with 20px of room -- so the satellite that only appears
87
+ -- when the session is busiest was the one that flew off the edge.
88
+ local radius = 0.34 + index * 0.055
89
+ local speed = 1.5 / (radius * 2.4)
90
+ local inclination = (Draw.hash(index, 5) - 0.5) * 1.6
91
+ return radius, speed, inclination
92
+ end
93
+
94
+ function Orbit.mount(cell: Frame)
95
+ path = Draw.pool(cell, PATH_DOTS, "Path", true)
96
+
97
+ local disc = Instance.new("Frame")
98
+ disc.Name = "Halo"
99
+ disc.AnchorPoint = Vector2.new(0.5, 0.5)
100
+ disc.BorderSizePixel = 0
101
+ disc.Parent = cell
102
+ local haloCorner = Instance.new("UICorner")
103
+ haloCorner.CornerRadius = UDim.new(1, 0)
104
+ haloCorner.Parent = disc
105
+ halo = disc
106
+
107
+ local core = Instance.new("Frame")
108
+ core.Name = "Body"
109
+ core.AnchorPoint = Vector2.new(0.5, 0.5)
110
+ core.BorderSizePixel = 0
111
+ core.Parent = cell
112
+ local coreCorner = Instance.new("UICorner")
113
+ coreCorner.CornerRadius = UDim.new(1, 0)
114
+ coreCorner.Parent = core
115
+ body = core
116
+
117
+ -- Last, so a near satellite passes in front of the body it orbits.
118
+ moons = Draw.pool(cell, MOONS, "Moon", true)
119
+ end
120
+
121
+ function Orbit.unmount()
122
+ body = nil
123
+ halo = nil
124
+ moons = {}
125
+ path = {}
126
+ end
127
+
128
+ function Orbit.paint(ctx: Theme.Context, _delta: number)
129
+ local breath = (1 - ORBIT_BREATH) + ctx.scale * ORBIT_BREATH
130
+ local radius = ctx.size * 0.34 * breath
131
+ local core = body
132
+ local glow = halo
133
+
134
+ if core ~= nil then
135
+ --[[
136
+ Amber, not the status tint.
137
+
138
+ Every other preset paints its cell in `ctx.tint` so the panel reads as
139
+ disconnected at a glance. Orbit cannot: the tint is green while
140
+ connected, so the body came out green while the drawer previewed it in
141
+ gold, and the preview is a promise. The HALO carries the status colour
142
+ instead -- same information, on the part of the scene that is supposed
143
+ to change.
144
+ ]]
145
+ local size = math.max(5, math.floor(7.5 * ctx.scale + ctx.flare * 3))
146
+ Draw.dot(
147
+ core,
148
+ ctx.centre,
149
+ size,
150
+ ctx.palette.amber:Lerp(Color3.new(1, 1, 1), 0.3 + ctx.flare * 0.5),
151
+ 0
152
+ )
153
+ end
154
+ if glow ~= nil then
155
+ -- A halo that grows with energy rather than a body that does: the body
156
+ -- staying put is what gives the satellites something fixed to orbit.
157
+ local size = math.max(10, math.floor(13 * ctx.scale + ctx.energy * 8 + ctx.flare * 6))
158
+ Draw.dot(glow, ctx.centre, size, ctx.tint, math.clamp(0.9 - ctx.flare * 0.35, 0, 1))
159
+ end
160
+
161
+ --[[
162
+ The innermost path, drawn as points so it fades with depth.
163
+
164
+ Only one path is drawn even though there are six orbits. Six rings turns
165
+ the cell into a scribble at forty pixels across; one ring establishes
166
+ that these things are orbiting and the rest is left to the motion.
167
+ ]]
168
+ local pathRadius, _, pathTilt = orbitOf(1)
169
+ for index = 1, PATH_DOTS do
170
+ local frame = path[index]
171
+ local turn = (index / PATH_DOTS) * math.pi * 2
172
+ local local3 = Vector3.new(
173
+ math.cos(turn) * pathRadius,
174
+ math.sin(turn) * pathRadius * math.sin(pathTilt),
175
+ math.sin(turn) * pathRadius * math.cos(pathTilt)
176
+ )
177
+ local point, depth = Draw.project(
178
+ Draw.rotate(local3, ctx.angle * 0.5),
179
+ FOCAL,
180
+ radius * ORBIT_SCALE,
181
+ ctx.centre
182
+ )
183
+ local near = math.clamp((depth + 1) / 2, 0, 1)
184
+ Draw.dot(frame, point, 1, ctx.palette.dim, math.clamp(0.78 - near * 0.3, 0, 1))
185
+ end
186
+
187
+ --[[
188
+ How many satellites are up. Activity runs 0..1 over about ten seconds,
189
+ so this climbs through the pool as a burst lands and drops back as it
190
+ unwinds -- which is the readout the theme exists for.
191
+ ]]
192
+ local visible = 1 + math.floor(math.clamp(ctx.activity, 0, 1) * (MOONS - 1) + 0.5)
193
+
194
+ for index = 1, MOONS do
195
+ local frame = moons[index]
196
+ if frame == nil then
197
+ continue
198
+ end
199
+ if index > visible then
200
+ frame.Visible = false
201
+ continue
202
+ end
203
+
204
+ local orbitRadius, speed, tilt = orbitOf(index)
205
+ -- Phase offset per moon so they do not launch from the same point and
206
+ -- travel as a clump.
207
+ local turn = ctx.clock * speed + Draw.hash(index, 6) * math.pi * 2
208
+ local local3 = Vector3.new(
209
+ math.cos(turn) * orbitRadius,
210
+ math.sin(turn) * orbitRadius * math.sin(tilt),
211
+ math.sin(turn) * orbitRadius * math.cos(tilt)
212
+ )
213
+ local point, depth = Draw.project(
214
+ Draw.rotate(local3, ctx.angle * 0.5),
215
+ FOCAL,
216
+ radius * ORBIT_SCALE,
217
+ ctx.centre
218
+ )
219
+ local near = math.clamp((depth + 1) / 2, 0, 1)
220
+ Draw.dot(
221
+ frame,
222
+ point,
223
+ math.max(2, math.floor(2.5 + near * 2.5 + ctx.flare * 1.5)),
224
+ ctx.palette.amber:Lerp(Color3.new(1, 1, 1), near * 0.4 + ctx.flare * 0.3),
225
+ math.clamp(0.35 - near * 0.32 - ctx.flare * 0.3, 0, 1)
226
+ )
227
+ end
228
+ end
229
+
230
+ --[[
231
+ History as a run of moons at their apoapsis: a dot per call, high when slow.
232
+
233
+ Bars would be the obvious choice and are wrong here -- a rectangle sitting
234
+ on a baseline has nothing to do with the rest of the scene, and the trace
235
+ and the cell would read as two unrelated instruments sharing a strip.
236
+ ]]
237
+ function Orbit.paintSlot(slot: Theme.Slot, ctx: Theme.Context)
238
+ local frame = slot.frame
239
+ local recency = 1 - slot.age
240
+
241
+ frame.AnchorPoint = Vector2.new(0.5, 0.5)
242
+ local size = math.max(2, math.floor(2 + slot.weight * 3 + recency))
243
+ frame.Size = UDim2.fromOffset(size, size)
244
+ frame.Position = UDim2.new(frame.Position.X.Scale, frame.Position.X.Offset, 0.88 - slot.weight * 0.74, 0)
245
+ frame.BackgroundColor3 = if slot.ok then ctx.palette.amber else ctx.palette.red
246
+ frame.BackgroundTransparency = math.clamp((if slot.ok then 0.7 else 0.35) - recency * 0.55, 0, 1)
247
+ frame.Rotation = 0
248
+ end
249
+
250
+ function Orbit.swatch(frame: Frame, palette: Theme.Palette)
251
+ local centre = Vector2.new(20, 12)
252
+ local ring = Draw.pool(frame, 20, "Path", true)
253
+ for index = 1, 20 do
254
+ local turn = (index / 20) * math.pi * 2
255
+ Draw.dot(
256
+ ring[index],
257
+ centre + Vector2.new(math.cos(turn) * 13, math.sin(turn) * 5.5),
258
+ 1,
259
+ palette.dim,
260
+ 0.6
261
+ )
262
+ end
263
+ local pool = Draw.pool(frame, 2, "Body", true)
264
+ Draw.dot(pool[1], centre, 7, palette.amber:Lerp(Color3.new(1, 1, 1), 0.3), 0)
265
+ Draw.dot(pool[2], centre + Vector2.new(13, -1), 3, palette.amber, 0.1)
266
+ end
267
+
268
+ return Orbit
@@ -0,0 +1,200 @@
1
+ --!strict
2
+ --[[
3
+ Phosphor -- a CRT oscilloscope.
4
+
5
+ A Lissajous figure traced by a moving beam, with the phosphor decaying
6
+ behind it. The head is white-hot, the tail fades to green over about forty
7
+ samples, and the whole cell sits under scanlines.
8
+
9
+ It deliberately does NOT reuse the wireframe. Drawing Lattice's solid in
10
+ green with a trail would be the obvious CRT gag and would leave two of the
11
+ eight presets doing the same thing in different colours. A beam tracing a
12
+ curve is what a scope actually does, and it gives the theme its own answer
13
+ to "is this alive": the figure's shape changes with the session, because the
14
+ frequency ratio is driven by activity. Idle it draws a lazy ellipse; busy it
15
+ knots into a rose.
16
+
17
+ The sample buffer is a ring rather than a growing list. The head advances,
18
+ the sample under it is overwritten, and age is computed from the distance
19
+ back to the head -- so nothing is allocated or shifted per frame.
20
+ ]]
21
+
22
+ local Draw = require(script.Parent.Draw)
23
+ local Theme = require(script.Parent.Theme)
24
+
25
+ local SAMPLES = 46
26
+ -- Samples per second the beam advances. Fast enough that the figure is a curve
27
+ -- rather than a dotted line, slow enough that the decay is visible.
28
+ local RATE = 60
29
+
30
+ local Phosphor = {}
31
+
32
+ Phosphor.id = "phosphor"
33
+ Phosphor.label = "Phosphor"
34
+ Phosphor.blurb = "CRT scope beam"
35
+
36
+ Phosphor.palette = {
37
+ background = Color3.fromRGB(4, 8, 5),
38
+ surface = Color3.fromRGB(9, 16, 11),
39
+ rule = Color3.fromRGB(86, 232, 122),
40
+ text = Color3.fromRGB(178, 236, 190),
41
+ dim = Color3.fromRGB(70, 112, 82),
42
+ green = Color3.fromRGB(86, 240, 128),
43
+ red = Color3.fromRGB(248, 118, 96),
44
+ amber = Color3.fromRGB(240, 208, 106),
45
+ cyan = Color3.fromRGB(122, 234, 208),
46
+ violet = Color3.fromRGB(150, 226, 150),
47
+ }
48
+
49
+ local dots: { Frame } = {}
50
+ local scanlines: { Frame } = {}
51
+ local samples: { Vector2 } = {}
52
+ local head = 0
53
+ local accumulator = 0
54
+
55
+ function Phosphor.mount(cell: Frame)
56
+ dots = Draw.pool(cell, SAMPLES, "Sample", true)
57
+
58
+ scanlines = {}
59
+ samples = {}
60
+ head = 0
61
+ accumulator = 0
62
+
63
+ --[[
64
+ Scanlines last, so they lie OVER the beam.
65
+
66
+ Underneath they were invisible -- the beam is the brightest thing in the
67
+ cell and simply covered them. A CRT's lines cross what it is drawing;
68
+ that is what makes it read as a screen rather than as a dark background
69
+ with stripes on it.
70
+ ]]
71
+ for index = 1, 10 do
72
+ local line = Instance.new("Frame")
73
+ line.Name = string.format("Scan%02d", index)
74
+ line.BorderSizePixel = 0
75
+ line.BackgroundColor3 = Color3.new(0, 0, 0)
76
+ line.BackgroundTransparency = 0.78
77
+ line.Size = UDim2.new(1, 0, 0, 1)
78
+ line.Position = UDim2.fromOffset(0, index * 4)
79
+ line.ZIndex = 4
80
+ line.Parent = cell
81
+ scanlines[index] = line
82
+ end
83
+ end
84
+
85
+ function Phosphor.unmount()
86
+ dots = {}
87
+ scanlines = {}
88
+ samples = {}
89
+ end
90
+
91
+ function Phosphor.paint(ctx: Theme.Context, delta: number)
92
+ local radius = ctx.size * 0.33 * ctx.scale
93
+
94
+ --[[
95
+ The figure's ratio, from activity.
96
+
97
+ A Lissajous curve closes when its two frequencies are a simple ratio, so
98
+ holding X at 2 and sliding Y between 3 and roughly 5 walks it from an
99
+ open ellipse through a figure-eight into a knot. The session's load
100
+ therefore changes the SHAPE, not just the speed -- which is the same
101
+ trick Lattice plays with its solids, done continuously.
102
+ ]]
103
+ local ratio = 3 + math.clamp(ctx.activity, 0, 1) * 2.1
104
+
105
+ -- Fixed-step sampling. Advancing one sample per frame would make the trail's
106
+ -- length depend on Studio's frame rate, so the beam would visibly speed up
107
+ -- whenever the editor was idle.
108
+ accumulator += math.min(delta, 1 / 15)
109
+ local advance = math.floor(accumulator * RATE)
110
+ if advance > 0 then
111
+ accumulator -= advance / RATE
112
+ for _ = 1, math.min(advance, SAMPLES) do
113
+ head += 1
114
+ local t = head / RATE
115
+ samples[(head % SAMPLES) + 1] = Vector2.new(
116
+ math.sin(t * 2) * radius,
117
+ math.sin(t * ratio + ctx.clock * 0.15) * radius
118
+ )
119
+ end
120
+ end
121
+
122
+ for index = 1, SAMPLES do
123
+ local frame = dots[index]
124
+ local point = samples[index]
125
+ if frame == nil then
126
+ continue
127
+ end
128
+ if point == nil then
129
+ frame.Visible = false
130
+ continue
131
+ end
132
+
133
+ --[[
134
+ How far back from the head this sample is, 0 (newest) to 1 (about to
135
+ be overwritten). The modulo is what lets the ring buffer stand in for
136
+ an ordered trail without moving anything.
137
+ ]]
138
+ local back = (head - (index - 1)) % SAMPLES
139
+ local age = back / SAMPLES
140
+
141
+ -- Squared, so the head is a bright point with a long dim tail rather
142
+ -- than an evenly grey worm. Phosphor decays fast at first.
143
+ local glow = (1 - age) * (1 - age)
144
+
145
+ Draw.dot(
146
+ frame,
147
+ ctx.centre + point,
148
+ math.max(1, math.floor(1 + glow * 2 + ctx.flare * 1.5)),
149
+ ctx.palette.green:Lerp(Color3.new(1, 1, 1), glow * 0.85),
150
+ math.clamp(0.94 - glow * 0.9 - ctx.flare * 0.2, 0, 1)
151
+ )
152
+ end
153
+ end
154
+
155
+ --[[
156
+ History as scope columns: hard-edged, one pixel wide, no rounding. The trace
157
+ is the one part of a scope's screen that is supposed to look like a chart.
158
+ ]]
159
+ function Phosphor.paintSlot(slot: Theme.Slot, ctx: Theme.Context)
160
+ local frame = slot.frame
161
+ local recency = 1 - slot.age
162
+ local height = math.max(1, math.floor(1 + slot.weight * 22))
163
+
164
+ frame.AnchorPoint = Vector2.new(0.5, 1)
165
+ frame.Size = UDim2.fromOffset(2, height)
166
+ frame.Position = UDim2.new(frame.Position.X.Scale, frame.Position.X.Offset, 1, 0)
167
+ frame.BackgroundColor3 = if slot.ok
168
+ then ctx.palette.green:Lerp(Color3.new(1, 1, 1), recency * 0.4)
169
+ else ctx.palette.red
170
+ frame.BackgroundTransparency = math.clamp(0.7 - recency * 0.65, 0, 1)
171
+ frame.Rotation = 0
172
+ end
173
+
174
+ function Phosphor.swatch(frame: Frame, palette: Theme.Palette)
175
+ local centre = Vector2.new(20, 12)
176
+ local pool = Draw.pool(frame, 30, "Sample", true)
177
+ for index = 1, 30 do
178
+ local t = index / 6
179
+ local glow = index / 30
180
+ Draw.dot(
181
+ pool[index],
182
+ centre + Vector2.new(math.sin(t * 2) * 15, math.sin(t * 3) * 9),
183
+ 1 + math.floor(glow * 2),
184
+ palette.green:Lerp(Color3.new(1, 1, 1), glow * 0.8),
185
+ 0.85 - glow * 0.8
186
+ )
187
+ end
188
+ for index = 1, 6 do
189
+ local line = Instance.new("Frame")
190
+ line.BorderSizePixel = 0
191
+ line.BackgroundColor3 = Color3.new(0, 0, 0)
192
+ line.BackgroundTransparency = 0.75
193
+ line.Size = UDim2.new(1, 0, 0, 1)
194
+ line.Position = UDim2.fromOffset(0, index * 4)
195
+ line.ZIndex = 4
196
+ line.Parent = frame
197
+ end
198
+ end
199
+
200
+ return Phosphor
@@ -0,0 +1,121 @@
1
+ --!strict
2
+ --[[
3
+ The contract every preset satisfies.
4
+
5
+ A theme owns two surfaces and nothing else. `cell` is the square at the left
6
+ of the activity band -- the slot the wireframe prism occupies under the
7
+ default theme, and which any other theme is free to fill with something that
8
+ is not a prism at all. `slots` are the forty most recent calls, drawn as bars
9
+ under the default theme and as anything else under the rest.
10
+
11
+ What a theme deliberately does NOT own is the simulation. Energy, activity,
12
+ flare, shake, the scale spring and the quiet timer are computed once in
13
+ Visuals and handed to whichever preset is active. That is the whole reason
14
+ eight presets do not mean eight animation systems: they are eight painters
15
+ over one set of physics, so a preset is a few hundred lines of drawing and
16
+ holds no state that could drift out of step with the session.
17
+ ]]
18
+
19
+ export type Palette = {
20
+ background: Color3,
21
+ surface: Color3,
22
+ rule: Color3,
23
+ text: Color3,
24
+ dim: Color3,
25
+ green: Color3,
26
+ red: Color3,
27
+ amber: Color3,
28
+ cyan: Color3,
29
+ violet: Color3,
30
+ }
31
+
32
+ --[[
33
+ The session, as numbers a painter can use.
34
+
35
+ Everything here decays on its own, so a painter never has to schedule
36
+ anything: read the value, draw that frame, and the motion follows.
37
+
38
+ `energy` rises with each command and falls over about a second -- use it for
39
+ anything that should react. `activity` is the slower average, over roughly
40
+ ten seconds, and is what should drive how BUSY a scene looks rather than how
41
+ startled. `flare` spikes to 1 the instant a reply lands and is gone in a
42
+ third of a second, which is what makes a completed call read as an event.
43
+ `shake` is the same but only for failures. `quiet` is seconds since anything
44
+ ran; past `QUIET_AFTER` the session has stopped rather than paused, and a
45
+ scene that keeps working at full pace after that is claiming an activity
46
+ which is not happening.
47
+ ]]
48
+ export type Context = {
49
+ palette: Palette,
50
+ -- Connection state blended toward the kind of work last done.
51
+ tint: Color3,
52
+ energy: number,
53
+ activity: number,
54
+ flare: number,
55
+ shake: number,
56
+ -- The scale spring: 1 at rest, overshoots past 1 on a reply, 0.82 when idle.
57
+ scale: number,
58
+ quiet: number,
59
+ -- Accumulated rotation on three axes. Meaningful to anything that turns;
60
+ -- ignorable by anything that does not.
61
+ angle: Vector3,
62
+ -- `os.clock()`, sampled once per frame so every painter agrees on the time.
63
+ clock: number,
64
+ -- The cell is square; this is its side in pixels.
65
+ size: number,
66
+ centre: Vector2,
67
+ }
68
+
69
+ --[[
70
+ One finished call, as history rather than as an event.
71
+
72
+ `age` is 0 for the newest and rises toward 1 for the oldest still on screen,
73
+ which is what lets a preset fade its history out without tracking when
74
+ anything happened. `frame` is pooled and reused as bars shift left, so a
75
+ painter must set every property it cares about rather than assuming the
76
+ frame arrives clean.
77
+ ]]
78
+ export type Slot = {
79
+ frame: Frame,
80
+ milliseconds: number,
81
+ ok: boolean,
82
+ title: string,
83
+ age: number,
84
+ -- Duration against the "slow" reference, clamped to 1. Height, for a bar.
85
+ weight: number,
86
+ }
87
+
88
+ export type Theme = {
89
+ id: string,
90
+ label: string,
91
+ -- One line, shown under the label in the picker.
92
+ blurb: string,
93
+ palette: Palette,
94
+ --[[
95
+ Builds whatever the cell needs, once. Pooled Instances belong here:
96
+ allocating inside `paint` is how a decorative strip becomes a performance
97
+ complaint.
98
+ ]]
99
+ mount: (cell: Frame) -> (),
100
+ paint: (ctx: Context, delta: number) -> (),
101
+ --[[
102
+ Destroys what `mount` built. Called on every theme switch, so it must
103
+ leave the cell empty enough for the next preset to start clean.
104
+ ]]
105
+ unmount: () -> (),
106
+ --[[
107
+ Draws one call of history. Optional: a preset that wants ordinary bars
108
+ omits it and Visuals draws them.
109
+ ]]
110
+ paintSlot: ((slot: Slot, ctx: Context) -> ())?,
111
+ --[[
112
+ A still miniature for the picker, drawn into a 40x24 frame.
113
+
114
+ Static on purpose. Eight live renderers running behind a hover menu is
115
+ the one place the "only one preset runs at a time" argument breaks, and
116
+ it breaks in a drawer the user looks at for two seconds.
117
+ ]]
118
+ swatch: (frame: Frame, palette: Palette) -> (),
119
+ }
120
+
121
+ return {}