@el4cteo/rbx-studio-mcp 0.3.8 → 0.3.9
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/package.json +1 -1
- package/plugin/src/Config.luau +1 -1
- package/plugin/src/Console.luau +273 -0
- package/plugin/src/Visuals.luau +304 -0
package/package.json
CHANGED
package/plugin/src/Config.luau
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
local Config = {}
|
|
11
11
|
|
|
12
|
-
Config.PLUGIN_VERSION = "0.3.
|
|
12
|
+
Config.PLUGIN_VERSION = "0.3.9"
|
|
13
13
|
|
|
14
14
|
-- Fingerprint of plugin/src, stamped in by scripts/build-plugin.mjs. The server
|
|
15
15
|
-- computes the same hash from its own copy of the sources and compares, so a
|
package/plugin/src/Console.luau
CHANGED
|
@@ -18,12 +18,49 @@
|
|
|
18
18
|
Instances, and colour spans do the same job in a fraction of the code.
|
|
19
19
|
]]
|
|
20
20
|
|
|
21
|
+
local TweenService = game:GetService("TweenService")
|
|
22
|
+
|
|
21
23
|
local ThemePicker = require(script.Parent.ThemePicker)
|
|
22
24
|
local Themes = require(script.Parent.Themes)
|
|
23
25
|
local Visuals = require(script.Parent.Visuals)
|
|
24
26
|
|
|
25
27
|
local Console = {}
|
|
26
28
|
|
|
29
|
+
--[[
|
|
30
|
+
The clear wipe: an old CRT being switched off.
|
|
31
|
+
|
|
32
|
+
A button that empties the screen and leaves no trace is indistinguishable
|
|
33
|
+
from one that crashed the panel. The receipt row answers that in words; this
|
|
34
|
+
answers it in the two hundred milliseconds before anyone has read the words.
|
|
35
|
+
|
|
36
|
+
It is an OVERLAY and nothing else. The log is cleared for real at the same
|
|
37
|
+
instant, underneath, so nothing about the console's state waits on an
|
|
38
|
+
animation -- a line that arrives mid-wipe lands in the fresh log and is
|
|
39
|
+
simply revealed when the overlay goes. What collapses is a still copy of the
|
|
40
|
+
text that was on screen, which is the honest thing to animate: it is a
|
|
41
|
+
picture of what was erased.
|
|
42
|
+
|
|
43
|
+
Three phases, and the timings are the whole effect. The picture holds and
|
|
44
|
+
then whips shut to a line; the line sits a moment; the line whips to a point
|
|
45
|
+
and is gone. Easing into each collapse rather than out of it is what makes it
|
|
46
|
+
read as a tube discharging rather than as a panel being resized.
|
|
47
|
+
]]
|
|
48
|
+
local CRT_COLLAPSE = 0.16
|
|
49
|
+
local CRT_HOLD = 0.07
|
|
50
|
+
local CRT_BLINK = 0.17
|
|
51
|
+
local CRT_SHUT = TweenInfo.new(CRT_COLLAPSE, Enum.EasingStyle.Quart, Enum.EasingDirection.In)
|
|
52
|
+
local CRT_SETTLE = TweenInfo.new(0.05, Enum.EasingStyle.Quad, Enum.EasingDirection.Out)
|
|
53
|
+
local CRT_OUT = TweenInfo.new(CRT_BLINK, Enum.EasingStyle.Quart, Enum.EasingDirection.In)
|
|
54
|
+
|
|
55
|
+
--[[
|
|
56
|
+
Explicit depths, because a plugin widget draws with ZIndexBehavior.Global
|
|
57
|
+
and a child does not inherit its parent's. Above the log, the band and the
|
|
58
|
+
hover readout; below the preset drawer, which must stay clickable.
|
|
59
|
+
]]
|
|
60
|
+
local Z_CRT = 20
|
|
61
|
+
local Z_CRT_TEXT = 21
|
|
62
|
+
local Z_CRT_LINE = 22
|
|
63
|
+
|
|
27
64
|
--[[
|
|
28
65
|
Ring buffer bound, counted in logged rows rather than in rendered lines.
|
|
29
66
|
|
|
@@ -209,6 +246,24 @@ type State = {
|
|
|
209
246
|
clients: number,
|
|
210
247
|
-- Set while a repaint is already scheduled for the end of this frame.
|
|
211
248
|
dirty: boolean,
|
|
249
|
+
|
|
250
|
+
--[[
|
|
251
|
+
The clear wipe's overlay: an unclipped shell over the log region, the
|
|
252
|
+
collapsing screen inside it, a fixed window holding the still copy of
|
|
253
|
+
the text, and the bright line the picture discharges into.
|
|
254
|
+
]]
|
|
255
|
+
crt: Frame?,
|
|
256
|
+
crtScreen: Frame?,
|
|
257
|
+
crtWindow: Frame?,
|
|
258
|
+
crtGhost: TextLabel?,
|
|
259
|
+
crtLine: Frame?,
|
|
260
|
+
--[[
|
|
261
|
+
Bumped by every wipe, so the delayed halves of an earlier one know they
|
|
262
|
+
have been superseded and do nothing. `task.delay` hands back no handle to
|
|
263
|
+
cancel, and clearing twice inside four hundred milliseconds is a click
|
|
264
|
+
away.
|
|
265
|
+
]]
|
|
266
|
+
crtGeneration: number,
|
|
212
267
|
}
|
|
213
268
|
|
|
214
269
|
local state: State = {
|
|
@@ -232,6 +287,12 @@ local state: State = {
|
|
|
232
287
|
generation = 0,
|
|
233
288
|
clients = 1,
|
|
234
289
|
dirty = false,
|
|
290
|
+
crt = nil,
|
|
291
|
+
crtScreen = nil,
|
|
292
|
+
crtWindow = nil,
|
|
293
|
+
crtGhost = nil,
|
|
294
|
+
crtLine = nil,
|
|
295
|
+
crtGeneration = 0,
|
|
235
296
|
}
|
|
236
297
|
|
|
237
298
|
-- How long the session must be silent before the console says so. Long enough
|
|
@@ -656,7 +717,121 @@ function Console.setClients(count: number)
|
|
|
656
717
|
end
|
|
657
718
|
end
|
|
658
719
|
|
|
720
|
+
--[[
|
|
721
|
+
Plays the wipe over whatever is on screen right now.
|
|
722
|
+
|
|
723
|
+
Must be called BEFORE the log is emptied: the still copy it collapses is
|
|
724
|
+
read straight off the live label, scroll offset and all, so what discharges
|
|
725
|
+
is exactly the text the user was looking at rather than a re-render of it.
|
|
726
|
+
|
|
727
|
+
Silent when there is nothing to play it on -- a widget dragged down to a
|
|
728
|
+
sliver has no room for a picture to collapse, and a flourish that plays in
|
|
729
|
+
four pixels is a flicker, not an effect.
|
|
730
|
+
]]
|
|
731
|
+
local function crtWipe()
|
|
732
|
+
local shell = state.crt
|
|
733
|
+
local screen = state.crtScreen
|
|
734
|
+
local window = state.crtWindow
|
|
735
|
+
local ghost = state.crtGhost
|
|
736
|
+
local line = state.crtLine
|
|
737
|
+
local scroller = state.scroller
|
|
738
|
+
local label = state.label
|
|
739
|
+
if
|
|
740
|
+
shell == nil
|
|
741
|
+
or screen == nil
|
|
742
|
+
or window == nil
|
|
743
|
+
or ghost == nil
|
|
744
|
+
or line == nil
|
|
745
|
+
or scroller == nil
|
|
746
|
+
or label == nil
|
|
747
|
+
then
|
|
748
|
+
return
|
|
749
|
+
end
|
|
750
|
+
|
|
751
|
+
local height = scroller.AbsoluteSize.Y
|
|
752
|
+
if height < 24 then
|
|
753
|
+
return
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
state.crtGeneration += 1
|
|
757
|
+
local generation = state.crtGeneration
|
|
758
|
+
|
|
759
|
+
-- Read at play time rather than tracked, so the overlay lands on the log
|
|
760
|
+
-- wherever the band toggle and the widget's size have left it.
|
|
761
|
+
shell.Position = scroller.Position
|
|
762
|
+
shell.Size = scroller.Size
|
|
763
|
+
|
|
764
|
+
--[[
|
|
765
|
+
The still copy, offset by exactly as far as the log is scrolled.
|
|
766
|
+
|
|
767
|
+
A TextLabel cannot scroll its own text, so the window is a fixed-height
|
|
768
|
+
clip and the label inside it is pushed up by the canvas offset. That is
|
|
769
|
+
what makes the copy line up with the real log to the pixel instead of
|
|
770
|
+
snapping to the top of the buffer the moment the wipe starts.
|
|
771
|
+
]]
|
|
772
|
+
window.Size = UDim2.new(1, 0, 0, height)
|
|
773
|
+
ghost.Position = UDim2.new(0, 12, 0, 8 - scroller.CanvasPosition.Y)
|
|
774
|
+
ghost.Size = UDim2.new(1, -24, 0, 0)
|
|
775
|
+
ghost.TextColor3 = PALETTE.text
|
|
776
|
+
ghost.Text = label.Text
|
|
777
|
+
|
|
778
|
+
screen.BackgroundColor3 = PALETTE.background
|
|
779
|
+
screen.Size = UDim2.new(1, 0, 1, 0)
|
|
780
|
+
screen.Visible = true
|
|
781
|
+
|
|
782
|
+
line.BackgroundColor3 = PALETTE.text
|
|
783
|
+
line.Size = UDim2.new(1, 0, 0, 2)
|
|
784
|
+
line.BackgroundTransparency = 1
|
|
785
|
+
line.Visible = false
|
|
786
|
+
|
|
787
|
+
shell.Visible = true
|
|
788
|
+
|
|
789
|
+
-- The picture, whipping shut around its own middle.
|
|
790
|
+
TweenService:Create(screen, CRT_SHUT, { Size = UDim2.new(1, 0, 0, 2) }):Play()
|
|
791
|
+
|
|
792
|
+
task.delay(CRT_COLLAPSE, function()
|
|
793
|
+
if state.crtGeneration ~= generation then
|
|
794
|
+
return
|
|
795
|
+
end
|
|
796
|
+
|
|
797
|
+
--[[
|
|
798
|
+
The handover. The screen goes and the line arrives in the same frame,
|
|
799
|
+
a little over-thick, and settles -- which is the flash. Fading one
|
|
800
|
+
into the other instead just looks like a crossfade of two rectangles.
|
|
801
|
+
]]
|
|
802
|
+
screen.Visible = false
|
|
803
|
+
line.BackgroundTransparency = 0
|
|
804
|
+
line.Size = UDim2.new(1, 0, 0, 3)
|
|
805
|
+
line.Visible = true
|
|
806
|
+
TweenService:Create(line, CRT_SETTLE, { Size = UDim2.new(1, 0, 0, 2) }):Play()
|
|
807
|
+
|
|
808
|
+
task.delay(CRT_HOLD, function()
|
|
809
|
+
if state.crtGeneration ~= generation then
|
|
810
|
+
return
|
|
811
|
+
end
|
|
812
|
+
|
|
813
|
+
TweenService:Create(line, CRT_OUT, {
|
|
814
|
+
Size = UDim2.new(0, 0, 0, 2),
|
|
815
|
+
BackgroundTransparency = 0.35,
|
|
816
|
+
}):Play()
|
|
817
|
+
|
|
818
|
+
task.delay(CRT_BLINK, function()
|
|
819
|
+
if state.crtGeneration ~= generation then
|
|
820
|
+
return
|
|
821
|
+
end
|
|
822
|
+
shell.Visible = false
|
|
823
|
+
line.Visible = false
|
|
824
|
+
screen.Visible = true
|
|
825
|
+
end)
|
|
826
|
+
end)
|
|
827
|
+
end)
|
|
828
|
+
end
|
|
829
|
+
|
|
659
830
|
function Console.clear()
|
|
831
|
+
-- Before anything is emptied: the wipe collapses a copy of what is on
|
|
832
|
+
-- screen, and a moment later there is nothing on screen to copy.
|
|
833
|
+
crtWipe()
|
|
834
|
+
|
|
660
835
|
--[[
|
|
661
836
|
Says what it did, with a timestamp, like everything else in here.
|
|
662
837
|
|
|
@@ -691,6 +866,9 @@ end
|
|
|
691
866
|
visible without scrolling, however long the session has run.
|
|
692
867
|
]]
|
|
693
868
|
function Console.setStatus(status: string, meta: string)
|
|
869
|
+
-- Read before it is overwritten: the arrival flourish below is the one thing
|
|
870
|
+
-- here that cares whether this is a change or a repeat.
|
|
871
|
+
local previous = state.status
|
|
694
872
|
state.status = status
|
|
695
873
|
state.statusMeta = meta
|
|
696
874
|
|
|
@@ -718,6 +896,23 @@ function Console.setStatus(status: string, meta: string)
|
|
|
718
896
|
-- The wireframe takes the same colour as the status light, so the panel
|
|
719
897
|
-- reads as disconnected at a glance even with the header off screen.
|
|
720
898
|
Visuals.setTint(color)
|
|
899
|
+
|
|
900
|
+
-- The trace has no calls to plot until a session exists, so it waves while
|
|
901
|
+
-- the transport is reaching for one rather than sitting as a dead baseline
|
|
902
|
+
-- at exactly the moment somebody is watching to see whether it is alive.
|
|
903
|
+
Visuals.setConnecting(status == "connecting")
|
|
904
|
+
|
|
905
|
+
--[[
|
|
906
|
+
And it celebrates when the session finally lands.
|
|
907
|
+
|
|
908
|
+
Only on the transition. `setStatus` is also how a theme switch and a
|
|
909
|
+
port change repaint the header, and both replay the current status --
|
|
910
|
+
which would fire the flourish again for an event that already happened,
|
|
911
|
+
several times a session, until it read as noise rather than as news.
|
|
912
|
+
]]
|
|
913
|
+
if status == "connected" and previous ~= "connected" then
|
|
914
|
+
Visuals.celebrate()
|
|
915
|
+
end
|
|
721
916
|
end
|
|
722
917
|
|
|
723
918
|
--[[
|
|
@@ -958,6 +1153,84 @@ function Console.mount(parent: Instance, handlers: Handlers)
|
|
|
958
1153
|
label.Parent = scroller
|
|
959
1154
|
state.label = label
|
|
960
1155
|
|
|
1156
|
+
--[[
|
|
1157
|
+
The clear wipe's overlay, built once and hidden.
|
|
1158
|
+
|
|
1159
|
+
Four nested pieces, and each one earns its place. `crt` is unclipped and
|
|
1160
|
+
covers the log region, so the line can stay full width while the screen
|
|
1161
|
+
inside it closes. `screen` is the clip that actually collapses, anchored
|
|
1162
|
+
to its own middle so it shuts toward the centre rather than rolling up
|
|
1163
|
+
from the top. `window` is a fixed-height clip that does not move with it,
|
|
1164
|
+
which is what holds the copied text still while the screen closes over
|
|
1165
|
+
it. `ghost` is the copy.
|
|
1166
|
+
]]
|
|
1167
|
+
local crt = Instance.new("Frame")
|
|
1168
|
+
crt.Name = "ClearWipe"
|
|
1169
|
+
crt.BackgroundTransparency = 1
|
|
1170
|
+
crt.BorderSizePixel = 0
|
|
1171
|
+
crt.Visible = false
|
|
1172
|
+
crt.ZIndex = Z_CRT
|
|
1173
|
+
crt.Parent = root
|
|
1174
|
+
state.crt = crt
|
|
1175
|
+
|
|
1176
|
+
local crtScreen = Instance.new("Frame")
|
|
1177
|
+
crtScreen.Name = "Screen"
|
|
1178
|
+
crtScreen.AnchorPoint = Vector2.new(0.5, 0.5)
|
|
1179
|
+
crtScreen.Position = UDim2.fromScale(0.5, 0.5)
|
|
1180
|
+
crtScreen.Size = UDim2.new(1, 0, 1, 0)
|
|
1181
|
+
themed(crtScreen, "BackgroundColor3", "background")
|
|
1182
|
+
crtScreen.BorderSizePixel = 0
|
|
1183
|
+
crtScreen.ClipsDescendants = true
|
|
1184
|
+
crtScreen.ZIndex = Z_CRT
|
|
1185
|
+
crtScreen.Parent = crt
|
|
1186
|
+
state.crtScreen = crtScreen
|
|
1187
|
+
|
|
1188
|
+
local crtWindow = Instance.new("Frame")
|
|
1189
|
+
crtWindow.Name = "Window"
|
|
1190
|
+
crtWindow.AnchorPoint = Vector2.new(0.5, 0.5)
|
|
1191
|
+
crtWindow.Position = UDim2.fromScale(0.5, 0.5)
|
|
1192
|
+
crtWindow.Size = UDim2.new(1, 0, 1, 0)
|
|
1193
|
+
crtWindow.BackgroundTransparency = 1
|
|
1194
|
+
crtWindow.BorderSizePixel = 0
|
|
1195
|
+
crtWindow.ClipsDescendants = true
|
|
1196
|
+
crtWindow.ZIndex = Z_CRT_TEXT
|
|
1197
|
+
crtWindow.Parent = crtScreen
|
|
1198
|
+
state.crtWindow = crtWindow
|
|
1199
|
+
|
|
1200
|
+
-- Every text property the log's own label has, because the copy has to be
|
|
1201
|
+
-- indistinguishable from it for the frame before it starts moving.
|
|
1202
|
+
local crtGhost = Instance.new("TextLabel")
|
|
1203
|
+
crtGhost.Name = "Ghost"
|
|
1204
|
+
crtGhost.BackgroundTransparency = 1
|
|
1205
|
+
crtGhost.Font = Enum.Font.Code
|
|
1206
|
+
crtGhost.TextSize = 12
|
|
1207
|
+
crtGhost.LineHeight = 1.25
|
|
1208
|
+
themed(crtGhost, "TextColor3", "text")
|
|
1209
|
+
crtGhost.RichText = true
|
|
1210
|
+
crtGhost.TextWrapped = true
|
|
1211
|
+
crtGhost.AutomaticSize = Enum.AutomaticSize.Y
|
|
1212
|
+
crtGhost.Size = UDim2.new(1, -24, 0, 0)
|
|
1213
|
+
crtGhost.TextXAlignment = Enum.TextXAlignment.Left
|
|
1214
|
+
crtGhost.TextYAlignment = Enum.TextYAlignment.Top
|
|
1215
|
+
crtGhost.Text = ""
|
|
1216
|
+
crtGhost.ZIndex = Z_CRT_TEXT
|
|
1217
|
+
crtGhost.Parent = crtWindow
|
|
1218
|
+
state.crtGhost = crtGhost
|
|
1219
|
+
|
|
1220
|
+
-- The line the picture discharges into. Outside `screen`, so it keeps its
|
|
1221
|
+
-- width while the screen closes to nothing behind it.
|
|
1222
|
+
local crtLine = Instance.new("Frame")
|
|
1223
|
+
crtLine.Name = "Line"
|
|
1224
|
+
crtLine.AnchorPoint = Vector2.new(0.5, 0.5)
|
|
1225
|
+
crtLine.Position = UDim2.fromScale(0.5, 0.5)
|
|
1226
|
+
crtLine.Size = UDim2.new(1, 0, 0, 2)
|
|
1227
|
+
themed(crtLine, "BackgroundColor3", "text")
|
|
1228
|
+
crtLine.BorderSizePixel = 0
|
|
1229
|
+
crtLine.Visible = false
|
|
1230
|
+
crtLine.ZIndex = Z_CRT_LINE
|
|
1231
|
+
crtLine.Parent = crt
|
|
1232
|
+
state.crtLine = crtLine
|
|
1233
|
+
|
|
961
1234
|
-- Status bar ------------------------------------------------------------
|
|
962
1235
|
local footer = Instance.new("Frame")
|
|
963
1236
|
footer.AnchorPoint = Vector2.new(0, 1)
|
package/plugin/src/Visuals.luau
CHANGED
|
@@ -65,6 +65,87 @@ local BAR_STRIDE = 5
|
|
|
65
65
|
-- its extra pixels here: a 19px column was a small thing to aim a pointer at.
|
|
66
66
|
local TRACE_HEIGHT = 26
|
|
67
67
|
|
|
68
|
+
--[[
|
|
69
|
+
The connecting wave: what the trace does while there is no session to plot.
|
|
70
|
+
|
|
71
|
+
A connect or a reconnect is the one moment the trace has nothing true to
|
|
72
|
+
say. No calls have happened yet, so it is a bare baseline -- which looks
|
|
73
|
+
exactly like a panel that has died, at the one time the user is most likely
|
|
74
|
+
to be watching it to find out whether it has.
|
|
75
|
+
|
|
76
|
+
So the columns wave instead: a swell travelling along the trace for as long
|
|
77
|
+
as the transport is reaching for the bridge, and gone the moment it either
|
|
78
|
+
arrives or gives up.
|
|
79
|
+
|
|
80
|
+
It is drawn THROUGH THE ACTIVE PRESET's slot painter rather than painted
|
|
81
|
+
here, for the same reason history is. Eight presets already know how to draw
|
|
82
|
+
a column of a given height and freshness, so routing the wave through them
|
|
83
|
+
means it arrives in all eight without eight implementations of it -- a theme
|
|
84
|
+
that draws history as rising points draws the wave as an arc of points, and
|
|
85
|
+
nobody had to tell it to.
|
|
86
|
+
]]
|
|
87
|
+
-- Columns in the wave. Fixed, and positioned by scale rather than by stride, so
|
|
88
|
+
-- the wave spans the trace at any panel width off a pool built once at mount.
|
|
89
|
+
local WAVE_COLUMNS = 48
|
|
90
|
+
-- Seconds for one swell to cross the trace.
|
|
91
|
+
local WAVE_PERIOD = 1.8
|
|
92
|
+
-- The swell's width as a fraction of the trace. Narrow enough to read as a
|
|
93
|
+
-- pulse travelling along it rather than as the whole trace breathing.
|
|
94
|
+
local WAVE_SPREAD = 0.2
|
|
95
|
+
-- A finer ripple riding on the swell, in cycles across the trace, so the crest
|
|
96
|
+
-- reads as strips moving rather than as one solid hump sliding past.
|
|
97
|
+
local WAVE_RIPPLE = 2.5
|
|
98
|
+
-- Fade rates, in and out. Out is the slower of the two: an attempt that fails
|
|
99
|
+
-- immediately should ebb rather than blink off.
|
|
100
|
+
local WAVE_IN = 7
|
|
101
|
+
local WAVE_OUT = 2.5
|
|
102
|
+
--[[
|
|
103
|
+
The shortest time the wave stays up once it has started.
|
|
104
|
+
|
|
105
|
+
A handshake against a port with nothing behind it fails in milliseconds, and
|
|
106
|
+
the transport reports "connecting" before every one of those attempts. Without
|
|
107
|
+
a floor the wave would be a single-frame flash per retry, which reads as a
|
|
108
|
+
glitch rather than as an attempt being made.
|
|
109
|
+
]]
|
|
110
|
+
local WAVE_HOLD = 0.7
|
|
111
|
+
-- Where the columns rest when the wave is not lifting them. Low enough to read
|
|
112
|
+
-- as an instrument waiting rather than as invented data, high enough to be
|
|
113
|
+
-- there at all.
|
|
114
|
+
local WAVE_FLOOR = 0.05
|
|
115
|
+
|
|
116
|
+
--[[
|
|
117
|
+
The arrival: what the trace does the moment a session actually exists.
|
|
118
|
+
|
|
119
|
+
The connecting wave is a searching motion and says so -- the same swell over
|
|
120
|
+
and over, going nowhere. Landing deserves a different shape, once, or the
|
|
121
|
+
only thing that marks the event is a word in the header going green.
|
|
122
|
+
|
|
123
|
+
So the columns are struck rather than swept. A bright front races along the
|
|
124
|
+
trace and every column it reaches leaps and rings down behind it, which
|
|
125
|
+
reads as a thing arriving rather than a thing looking. It plays once per
|
|
126
|
+
connection and cannot repeat on its own -- the status line only fires it on
|
|
127
|
+
a real transition into `connected`, so a theme switch or a port change
|
|
128
|
+
replaying the same status does not replay the celebration.
|
|
129
|
+
]]
|
|
130
|
+
-- How long the whole flourish lasts.
|
|
131
|
+
local CHEER_TIME = 1.4
|
|
132
|
+
-- The fraction of that time the front takes to cross the trace. Well under
|
|
133
|
+
-- half: the sweep should outrun the ringing rather than drag it along.
|
|
134
|
+
local CHEER_SWEEP = 0.38
|
|
135
|
+
-- How fast a struck column settles. Against the sweep above this leaves about
|
|
136
|
+
-- two visible bounces before it is back on the baseline.
|
|
137
|
+
local CHEER_DECAY = 3.2
|
|
138
|
+
-- Bounces per column, over the flourish's own length.
|
|
139
|
+
local CHEER_RING = 2.5
|
|
140
|
+
--[[
|
|
141
|
+
How abruptly the flourish fades.
|
|
142
|
+
|
|
143
|
+
Opacity holds at full for the first stretch and only gives way at the end,
|
|
144
|
+
so the celebration reads as ringing out rather than as being dimmed while it
|
|
145
|
+
is still moving. `1 / CHEER_FADE` is the fraction of the tail spent fading.
|
|
146
|
+
]]
|
|
147
|
+
local CHEER_FADE = 2.5
|
|
148
|
+
|
|
68
149
|
--[[
|
|
69
150
|
The strip's height, and the geometry around the cell.
|
|
70
151
|
|
|
@@ -146,6 +227,22 @@ type Runtime = {
|
|
|
146
227
|
lastTitle: string?,
|
|
147
228
|
-- Whether the active preset has been given its Instances yet.
|
|
148
229
|
mounted: boolean,
|
|
230
|
+
|
|
231
|
+
-- The wave's columns, pooled at mount and hidden until a connect needs them.
|
|
232
|
+
wave: { Frame },
|
|
233
|
+
-- What the transport last said: true while it is trying to reach the bridge.
|
|
234
|
+
waveWanted: boolean,
|
|
235
|
+
-- The ramp, 0 to 1. Separate from `waveWanted` so the wave fades rather than
|
|
236
|
+
-- appearing and vanishing with the status line.
|
|
237
|
+
waveLevel: number,
|
|
238
|
+
-- Seconds the wave is held up regardless of `waveWanted`. See WAVE_HOLD.
|
|
239
|
+
waveHold: number,
|
|
240
|
+
-- Whether the columns are currently on screen. Kept rather than derived, so
|
|
241
|
+
-- a settled wave hides them once instead of every frame afterwards.
|
|
242
|
+
waveShown: boolean,
|
|
243
|
+
-- The arrival flourish: 1 the instant a session connects, down to 0 over
|
|
244
|
+
-- CHEER_TIME. Overrides the connecting wave's shape while it runs.
|
|
245
|
+
cheer: number,
|
|
149
246
|
}
|
|
150
247
|
|
|
151
248
|
local runtime: Runtime = {
|
|
@@ -174,6 +271,12 @@ local runtime: Runtime = {
|
|
|
174
271
|
activity = 0,
|
|
175
272
|
lastTitle = nil,
|
|
176
273
|
mounted = false,
|
|
274
|
+
wave = {},
|
|
275
|
+
waveWanted = false,
|
|
276
|
+
waveLevel = 0,
|
|
277
|
+
waveHold = 0,
|
|
278
|
+
waveShown = false,
|
|
279
|
+
cheer = 0,
|
|
177
280
|
}
|
|
178
281
|
|
|
179
282
|
--[[
|
|
@@ -201,6 +304,10 @@ local function context(): Themes.Context
|
|
|
201
304
|
}
|
|
202
305
|
end
|
|
203
306
|
|
|
307
|
+
-- Declared here and defined below, because it draws through the same slot
|
|
308
|
+
-- painter history does and that helper is defined after this function.
|
|
309
|
+
local stepWave: (delta: number) -> ()
|
|
310
|
+
|
|
204
311
|
local function step(delta: number)
|
|
205
312
|
local band = runtime.band
|
|
206
313
|
local cell = runtime.cell
|
|
@@ -314,6 +421,10 @@ local function step(delta: number)
|
|
|
314
421
|
end
|
|
315
422
|
end
|
|
316
423
|
|
|
424
|
+
-- Stepped before the cell is handed off, so a preset that errors and
|
|
425
|
+
-- disables the strip does not also take the connect animation with it.
|
|
426
|
+
stepWave(delta)
|
|
427
|
+
|
|
317
428
|
--[[
|
|
318
429
|
Handed off. A preset that throws must not take the console's status
|
|
319
430
|
display down with it, so a failure here disables the strip and says so
|
|
@@ -396,6 +507,171 @@ local function defaultSlot(slot: Themes.Slot, ctx: Themes.Context)
|
|
|
396
507
|
frame.Rotation = 0
|
|
397
508
|
end
|
|
398
509
|
|
|
510
|
+
--[[
|
|
511
|
+
Runs the connecting wave for one frame.
|
|
512
|
+
|
|
513
|
+
Cheap when nothing is connecting: the ramp settles at zero, the columns are
|
|
514
|
+
hidden once, and every later frame leaves after two comparisons.
|
|
515
|
+
]]
|
|
516
|
+
function stepWave(delta: number)
|
|
517
|
+
if #runtime.wave == 0 then
|
|
518
|
+
return
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
runtime.cheer = math.max(0, runtime.cheer - delta / CHEER_TIME)
|
|
522
|
+
|
|
523
|
+
runtime.waveHold = math.max(0, runtime.waveHold - delta)
|
|
524
|
+
local target = if runtime.waveWanted or runtime.waveHold > 0 then 1 else 0
|
|
525
|
+
local rate = if target > runtime.waveLevel then WAVE_IN else WAVE_OUT
|
|
526
|
+
runtime.waveLevel += (target - runtime.waveLevel) * math.min(1, delta * rate)
|
|
527
|
+
|
|
528
|
+
--[[
|
|
529
|
+
Two sources, one opacity.
|
|
530
|
+
|
|
531
|
+
They overlap for a moment at every connection -- the status goes green,
|
|
532
|
+
which drops `waveWanted`, while the flourish is only just starting -- and
|
|
533
|
+
taking the louder of the two is what carries the handover without a dip
|
|
534
|
+
between the search ending and the arrival beginning.
|
|
535
|
+
]]
|
|
536
|
+
local celebration = math.min(1, runtime.cheer * CHEER_FADE)
|
|
537
|
+
local strength = math.max(runtime.waveLevel, celebration)
|
|
538
|
+
|
|
539
|
+
if strength <= 0.01 then
|
|
540
|
+
-- Hidden once on the way down rather than every frame afterwards, so a
|
|
541
|
+
-- connected session pays nothing for an animation it is not running.
|
|
542
|
+
if runtime.waveShown then
|
|
543
|
+
runtime.waveShown = false
|
|
544
|
+
runtime.waveLevel = 0
|
|
545
|
+
for _, column in runtime.wave do
|
|
546
|
+
column.Visible = false
|
|
547
|
+
end
|
|
548
|
+
end
|
|
549
|
+
return
|
|
550
|
+
end
|
|
551
|
+
runtime.waveShown = true
|
|
552
|
+
|
|
553
|
+
local ctx = context()
|
|
554
|
+
local painter = Themes.active().paintSlot or defaultSlot
|
|
555
|
+
local phase = ctx.clock / WAVE_PERIOD
|
|
556
|
+
local crest = phase % 1
|
|
557
|
+
-- The flourish runs on its own progress rather than on the clock, so it is
|
|
558
|
+
-- the same length however long the connection took to land.
|
|
559
|
+
local arriving = runtime.cheer > 0
|
|
560
|
+
local progress = 1 - runtime.cheer
|
|
561
|
+
|
|
562
|
+
for index, column in runtime.wave do
|
|
563
|
+
-- 0 at the left edge of the trace, 1 at the right.
|
|
564
|
+
local x = (index - 0.5) / WAVE_COLUMNS
|
|
565
|
+
|
|
566
|
+
local weight: number
|
|
567
|
+
local age: number
|
|
568
|
+
|
|
569
|
+
if arriving then
|
|
570
|
+
--[[
|
|
571
|
+
Struck, not swept. `since` is how long ago the front passed this
|
|
572
|
+
column: negative means it has not arrived, and the column waits
|
|
573
|
+
flat and dark rather than anticipating it.
|
|
574
|
+
]]
|
|
575
|
+
local since = progress - x * CHEER_SWEEP
|
|
576
|
+
if since < 0 then
|
|
577
|
+
weight = WAVE_FLOOR
|
|
578
|
+
age = 1
|
|
579
|
+
else
|
|
580
|
+
-- One envelope for both height and brightness, so a column is
|
|
581
|
+
-- at its tallest exactly when it is at its brightest.
|
|
582
|
+
local settle = math.exp(-since * CHEER_DECAY)
|
|
583
|
+
local bounce = 0.5 + 0.5 * math.cos(since * CHEER_RING * math.pi * 2)
|
|
584
|
+
weight = math.clamp(WAVE_FLOOR + settle * bounce * 0.95, WAVE_FLOOR, 1)
|
|
585
|
+
age = math.clamp(1 - settle, 0, 1)
|
|
586
|
+
end
|
|
587
|
+
else
|
|
588
|
+
--[[
|
|
589
|
+
Distance to the crest, wrapped around the ends, so the swell
|
|
590
|
+
leaves the right edge and re-enters at the left instead of
|
|
591
|
+
jumping back across a trace it has just crossed.
|
|
592
|
+
]]
|
|
593
|
+
local distance = math.abs(x - crest)
|
|
594
|
+
distance = math.min(distance, 1 - distance)
|
|
595
|
+
local swell = math.exp(-(distance * distance) / (2 * WAVE_SPREAD * WAVE_SPREAD))
|
|
596
|
+
local ripple = 0.5 + 0.5 * math.sin((x * WAVE_RIPPLE - phase * 2) * math.pi * 2)
|
|
597
|
+
|
|
598
|
+
--[[
|
|
599
|
+
A floor everywhere and a hump at the crest. Away from the swell
|
|
600
|
+
the columns sit just off the baseline, which keeps the trace
|
|
601
|
+
reading as an instrument at rest rather than as forty bars of
|
|
602
|
+
invented data.
|
|
603
|
+
]]
|
|
604
|
+
weight = math.clamp(WAVE_FLOOR + swell * (0.55 + 0.45 * ripple) * 0.9, WAVE_FLOOR, 1)
|
|
605
|
+
--[[
|
|
606
|
+
Freshness follows the swell rather than position. Every preset
|
|
607
|
+
fades a slot by how recent it is, so handing the crest an age of
|
|
608
|
+
zero lights it and leaves the rest of the trace resting -- one
|
|
609
|
+
number, and eight presets brighten the same part of the wave.
|
|
610
|
+
]]
|
|
611
|
+
age = math.clamp(1 - swell, 0, 1)
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
local slot: Themes.Slot = {
|
|
615
|
+
frame = column,
|
|
616
|
+
-- Kept agreeing with the height, since that is the pair every
|
|
617
|
+
-- painter is written against, even though none of them reads it.
|
|
618
|
+
milliseconds = weight * SLOW_MS,
|
|
619
|
+
ok = true,
|
|
620
|
+
title = if arriving then "connected" else "connecting",
|
|
621
|
+
age = age,
|
|
622
|
+
weight = weight,
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
-- Set before the painter runs: every painter preserves the X it is
|
|
626
|
+
-- given and decides the rest.
|
|
627
|
+
column.Position = UDim2.new(x, 0, 1, 0)
|
|
628
|
+
if not pcall(painter, slot, ctx) then
|
|
629
|
+
-- Silent, unlike the history path. A preset that throws here would
|
|
630
|
+
-- throw sixty times a second, and `relayout` will have said so once
|
|
631
|
+
-- already the next time a call lands.
|
|
632
|
+
defaultSlot(slot, ctx)
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
--[[
|
|
636
|
+
Faded as one thing, after whoever drew it. The ramp belongs to the
|
|
637
|
+
wave and not to any preset, and applying it to the transparency the
|
|
638
|
+
painter chose keeps each theme's own weighting intact.
|
|
639
|
+
]]
|
|
640
|
+
column.BackgroundTransparency = 1 - (1 - column.BackgroundTransparency) * strength
|
|
641
|
+
column.Visible = true
|
|
642
|
+
end
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
--[[
|
|
646
|
+
Whether the transport is currently reaching for the bridge.
|
|
647
|
+
|
|
648
|
+
Told rather than inferred, because "connecting" is the transport's word and
|
|
649
|
+
the trace must never disagree with the status dot about what the session is
|
|
650
|
+
doing.
|
|
651
|
+
]]
|
|
652
|
+
function Visuals.setConnecting(active: boolean)
|
|
653
|
+
if active and not runtime.waveWanted then
|
|
654
|
+
runtime.waveHold = WAVE_HOLD
|
|
655
|
+
end
|
|
656
|
+
runtime.waveWanted = active
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
--[[
|
|
660
|
+
Plays the arrival flourish, once.
|
|
661
|
+
|
|
662
|
+
Edge-triggered by the caller rather than latched here: only the status line
|
|
663
|
+
knows whether this is a session actually landing or the same `connected`
|
|
664
|
+
state being repeated after a theme switch, and a celebration that replays
|
|
665
|
+
itself every time the port is re-read stops meaning anything.
|
|
666
|
+
]]
|
|
667
|
+
function Visuals.celebrate()
|
|
668
|
+
runtime.cheer = 1
|
|
669
|
+
-- The search is over. Nothing should be left holding the connecting wave up
|
|
670
|
+
-- underneath a flourish that has replaced it.
|
|
671
|
+
runtime.waveWanted = false
|
|
672
|
+
runtime.waveHold = 0
|
|
673
|
+
end
|
|
674
|
+
|
|
399
675
|
--[[
|
|
400
676
|
Re-places every bar and hands each to the active preset to draw.
|
|
401
677
|
|
|
@@ -775,6 +1051,34 @@ function Visuals.mount(parent: Instance)
|
|
|
775
1051
|
baseline.Parent = trace
|
|
776
1052
|
runtime.baseline = baseline
|
|
777
1053
|
|
|
1054
|
+
--[[
|
|
1055
|
+
The connecting wave's columns, built once and hidden until an attempt
|
|
1056
|
+
needs them.
|
|
1057
|
+
|
|
1058
|
+
Created here -- before the highlight and long before any bar -- because
|
|
1059
|
+
draw order among siblings is creation order, and the wave must sit under
|
|
1060
|
+
both. It is what the trace does while it has no history; it must never
|
|
1061
|
+
be drawn over the history it was standing in for.
|
|
1062
|
+
]]
|
|
1063
|
+
for index = 1, WAVE_COLUMNS do
|
|
1064
|
+
local column = Instance.new("Frame")
|
|
1065
|
+
column.Name = "Wave"
|
|
1066
|
+
column.AnchorPoint = Vector2.new(0.5, 1)
|
|
1067
|
+
column.Position = UDim2.new((index - 0.5) / WAVE_COLUMNS, 0, 1, 0)
|
|
1068
|
+
column.Size = UDim2.fromOffset(2, 1)
|
|
1069
|
+
column.BackgroundColor3 = palette.violet
|
|
1070
|
+
column.BorderSizePixel = 0
|
|
1071
|
+
column.Visible = false
|
|
1072
|
+
column.Parent = trace
|
|
1073
|
+
|
|
1074
|
+
-- Presets draw points as well as bars, same as the history slots.
|
|
1075
|
+
local round = Instance.new("UICorner")
|
|
1076
|
+
round.CornerRadius = UDim.new(0, 1)
|
|
1077
|
+
round.Parent = column
|
|
1078
|
+
|
|
1079
|
+
runtime.wave[index] = column
|
|
1080
|
+
end
|
|
1081
|
+
|
|
778
1082
|
--[[
|
|
779
1083
|
Built once and moved, and created BEFORE the bars exist so it sits under
|
|
780
1084
|
them in draw order -- a highlight drawn over a one-pixel bar would hide the
|