@el4cteo/rbx-studio-mcp 0.3.0 → 0.3.5

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/lib/pluginbuild.js +23 -13
  6. package/dist/lib/pluginbuild.js.map +1 -1
  7. package/dist/tools/api.js +20 -1
  8. package/dist/tools/api.js.map +1 -1
  9. package/dist/tools/debug.js +18 -8
  10. package/dist/tools/debug.js.map +1 -1
  11. package/dist/tools/discover.js +5 -0
  12. package/dist/tools/discover.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 +11 -1
  16. package/dist/tools/perf.js.map +1 -1
  17. package/dist/tools/scripts.js +27 -3
  18. package/dist/tools/scripts.js.map +1 -1
  19. package/dist/tools/session.js +16 -0
  20. package/dist/tools/session.js.map +1 -1
  21. package/package.json +1 -1
  22. package/plugin/src/Config.luau +1 -1
  23. package/plugin/src/Console.luau +257 -116
  24. package/plugin/src/Paths.luau +138 -69
  25. package/plugin/src/Serialize.luau +43 -3
  26. package/plugin/src/ThemePicker.luau +458 -0
  27. package/plugin/src/Themes/Aurora.luau +147 -0
  28. package/plugin/src/Themes/Blueprint.luau +213 -0
  29. package/plugin/src/Themes/Draw.luau +177 -0
  30. package/plugin/src/Themes/Lattice.luau +304 -0
  31. package/plugin/src/Themes/Nebula.luau +182 -0
  32. package/plugin/src/Themes/Observatory.luau +200 -0
  33. package/plugin/src/Themes/Orbit.luau +268 -0
  34. package/plugin/src/Themes/Phosphor.luau +200 -0
  35. package/plugin/src/Themes/Theme.luau +121 -0
  36. package/plugin/src/Themes/Void.luau +230 -0
  37. package/plugin/src/Themes/init.luau +116 -0
  38. package/plugin/src/Visuals.luau +788 -907
  39. package/plugin/src/handlers/Discover.luau +75 -1
  40. package/plugin/src/handlers/Exec.luau +17 -5
  41. package/plugin/src/handlers/Instances.luau +35 -3
  42. package/plugin/src/handlers/Scripts.luau +58 -2
  43. package/plugin/src/init.server.luau +386 -354
@@ -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
@@ -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