@el4cteo/rbx-studio-mcp 0.1.0 → 0.1.1
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 +46 -4
- package/dist/bridge/api.js +25 -0
- package/dist/bridge/api.js.map +1 -0
- package/dist/bridge/remote.js +106 -0
- package/dist/bridge/remote.js.map +1 -0
- package/dist/bridge/rpc.js +24 -4
- package/dist/bridge/rpc.js.map +1 -1
- package/dist/bridge/server.js +84 -4
- package/dist/bridge/server.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/errors.js +48 -3
- package/dist/lib/errors.js.map +1 -1
- package/dist/tools/input.js +13 -1
- package/dist/tools/input.js.map +1 -1
- package/dist/tools/playtest.js +3 -3
- package/dist/tools/playtest.js.map +1 -1
- package/dist/tools/screenshot.js +46 -11
- package/dist/tools/screenshot.js.map +1 -1
- package/dist/tools/session.js +10 -8
- package/dist/tools/session.js.map +1 -1
- package/package.json +62 -62
- package/plugin/src/Config.luau +59 -59
- package/plugin/src/handlers/Capture.luau +366 -187
- package/plugin/src/handlers/Debug.luau +2 -1
- package/plugin/src/handlers/Input.luau +166 -0
- package/scripts/prism.mjs +310 -0
|
@@ -45,6 +45,12 @@ local MAX_HOLD = 10
|
|
|
45
45
|
source, so a key called `A) print("owned") --` would run. The plan travels as
|
|
46
46
|
JSON on an attribute instead, and this script only ever indexes enums by name
|
|
47
47
|
and checks the result.
|
|
48
|
+
|
|
49
|
+
It also draws the pointer. Synthetic mouse input moves nothing the eye can
|
|
50
|
+
follow -- the click lands and the only evidence is whatever it changed -- so
|
|
51
|
+
anyone watching a playtest being driven sees results with no cause. The
|
|
52
|
+
cursor is a real ImageLabel tweened to each target before the click is sent,
|
|
53
|
+
which makes the agent's aim visible and, when it misses, visibly wrong.
|
|
48
54
|
]]
|
|
49
55
|
local RELAY_SOURCE = [==[
|
|
50
56
|
local UserInputService = game:GetService("UserInputService")
|
|
@@ -67,6 +73,154 @@ end
|
|
|
67
73
|
local virtual = UserInputService:CreateVirtualInput()
|
|
68
74
|
local performed = {}
|
|
69
75
|
|
|
76
|
+
--[[
|
|
77
|
+
The on-screen pointer.
|
|
78
|
+
|
|
79
|
+
Roblox's own arrow texture, not one of ours. Shipping the pixels was tried
|
|
80
|
+
first -- base64 in a StringValue, decoded into an EditableImage -- and it
|
|
81
|
+
cannot work: writing to an EditableImage is gated on an experience-level
|
|
82
|
+
security setting that is off by default, so every place would have to opt in
|
|
83
|
+
before the cursor appeared. `WritePixelsBuffer` says so outright ("go to the
|
|
84
|
+
Security Tab in Experience Settings to enable this API") and the failure was
|
|
85
|
+
invisible, because a cursor that fails to build just does not show up.
|
|
86
|
+
|
|
87
|
+
An image id needs neither. The primary is a public Creator Store pointer;
|
|
88
|
+
`rbxasset://` content, which ships inside the client itself and can never
|
|
89
|
+
fail to resolve, is the fallback for the day that asset is moderated or
|
|
90
|
+
pulled. Both are tinted and given a halo -- the point is to show what the
|
|
91
|
+
agent is aiming at, and a pointer indistinguishable from the player's own
|
|
92
|
+
would show nothing.
|
|
93
|
+
]]
|
|
94
|
+
local ARROW = "rbxassetid://15718988485"
|
|
95
|
+
local ARROW_FALLBACK = "rbxasset://textures/Cursors/KeyboardMouse/ArrowCursor.png"
|
|
96
|
+
local ACCENT = Color3.fromRGB(255, 162, 0)
|
|
97
|
+
|
|
98
|
+
--[[
|
|
99
|
+
Where each arrow's tip sits inside its own image, as a fraction of it.
|
|
100
|
+
|
|
101
|
+
Both measured rather than guessed, and they are nothing alike -- the store
|
|
102
|
+
image is padded and the built-in one puts its tip at the exact centre of a
|
|
103
|
+
64x64 square -- so the anchor has to change with the image. Getting this
|
|
104
|
+
wrong is the quiet kind of wrong: the pointer still glides to the right
|
|
105
|
+
area and still ripples, it just lies about which pixel it hit.
|
|
106
|
+
|
|
107
|
+
The tint only works on a filled arrow. An outline-only cursor multiplies to
|
|
108
|
+
black and looks like the image failed to load.
|
|
109
|
+
]]
|
|
110
|
+
local HOTSPOT = Vector2.new(0.36, 0.32)
|
|
111
|
+
local HOTSPOT_FALLBACK = Vector2.new(0.484, 0.5)
|
|
112
|
+
|
|
113
|
+
local cursor = nil
|
|
114
|
+
local cursorGui = nil
|
|
115
|
+
|
|
116
|
+
local function makeCursor()
|
|
117
|
+
if relay:GetAttribute("Cursor") ~= true then
|
|
118
|
+
return
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
local gui = Instance.new("ScreenGui")
|
|
122
|
+
gui.Name = "MCPCursor"
|
|
123
|
+
gui.ResetOnSpawn = false
|
|
124
|
+
gui.IgnoreGuiInset = true
|
|
125
|
+
gui.DisplayOrder = 2147483647
|
|
126
|
+
gui.ZIndexBehavior = Enum.ZIndexBehavior.Sibling
|
|
127
|
+
|
|
128
|
+
local label = Instance.new("ImageLabel")
|
|
129
|
+
label.Name = "Pointer"
|
|
130
|
+
label.BackgroundTransparency = 1
|
|
131
|
+
label.Size = UDim2.fromOffset(52, 52)
|
|
132
|
+
label.AnchorPoint = HOTSPOT
|
|
133
|
+
label.Position = UDim2.fromScale(0.5, 0.5)
|
|
134
|
+
label.Image = ARROW
|
|
135
|
+
label.ImageColor3 = ACCENT
|
|
136
|
+
label.ZIndex = 100
|
|
137
|
+
label.Parent = gui
|
|
138
|
+
|
|
139
|
+
-- Centred on the tip rather than on the image, and behind the arrow, so the
|
|
140
|
+
-- pointer stays findable against a busy scene without hiding what it targets.
|
|
141
|
+
local halo = Instance.new("Frame")
|
|
142
|
+
halo.Name = "Halo"
|
|
143
|
+
halo.AnchorPoint = Vector2.new(0.5, 0.5)
|
|
144
|
+
halo.Position = UDim2.fromScale(HOTSPOT.X, HOTSPOT.Y)
|
|
145
|
+
halo.Size = UDim2.fromOffset(32, 32)
|
|
146
|
+
halo.BackgroundColor3 = ACCENT
|
|
147
|
+
halo.BackgroundTransparency = 0.45
|
|
148
|
+
halo.BorderSizePixel = 0
|
|
149
|
+
halo.ZIndex = 99
|
|
150
|
+
local round = Instance.new("UICorner")
|
|
151
|
+
round.CornerRadius = UDim.new(1, 0)
|
|
152
|
+
round.Parent = halo
|
|
153
|
+
halo.Parent = label
|
|
154
|
+
|
|
155
|
+
gui.Parent = relay.Parent
|
|
156
|
+
cursorGui = gui
|
|
157
|
+
cursor = label
|
|
158
|
+
|
|
159
|
+
-- Swapped only if the store image really did not arrive. Checked after the
|
|
160
|
+
-- GUI is live so the pointer is on screen either way, and never blocks.
|
|
161
|
+
task.spawn(function()
|
|
162
|
+
local waited = 0
|
|
163
|
+
while not label.IsLoaded and waited < 3 do
|
|
164
|
+
task.wait(0.1)
|
|
165
|
+
waited += 0.1
|
|
166
|
+
end
|
|
167
|
+
if not label.IsLoaded then
|
|
168
|
+
label.Image = ARROW_FALLBACK
|
|
169
|
+
-- The tip moves with the image, so the anchor and the halo move too.
|
|
170
|
+
label.AnchorPoint = HOTSPOT_FALLBACK
|
|
171
|
+
halo.Position = UDim2.fromScale(HOTSPOT_FALLBACK.X, HOTSPOT_FALLBACK.Y)
|
|
172
|
+
end
|
|
173
|
+
end)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
-- Moves the pointer there and waits, so the click is seen to be aimed.
|
|
177
|
+
local function moveCursor(x, y, duration)
|
|
178
|
+
if cursor == nil then
|
|
179
|
+
return
|
|
180
|
+
end
|
|
181
|
+
local TweenService = game:GetService("TweenService")
|
|
182
|
+
local goal = UDim2.fromOffset(x, y)
|
|
183
|
+
local tween = TweenService:Create(
|
|
184
|
+
cursor,
|
|
185
|
+
TweenInfo.new(duration, Enum.EasingStyle.Quad, Enum.EasingDirection.Out),
|
|
186
|
+
{ Position = goal }
|
|
187
|
+
)
|
|
188
|
+
tween:Play()
|
|
189
|
+
tween.Completed:Wait()
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
-- A ring that expands and fades where the click landed.
|
|
193
|
+
local function pulse()
|
|
194
|
+
if cursor == nil then
|
|
195
|
+
return
|
|
196
|
+
end
|
|
197
|
+
local TweenService = game:GetService("TweenService")
|
|
198
|
+
local ring = Instance.new("Frame")
|
|
199
|
+
ring.AnchorPoint = Vector2.new(0.5, 0.5)
|
|
200
|
+
ring.Position = UDim2.new(cursor.Position.X.Scale, cursor.Position.X.Offset, cursor.Position.Y.Scale, cursor.Position.Y.Offset)
|
|
201
|
+
ring.Size = UDim2.fromOffset(8, 8)
|
|
202
|
+
ring.BackgroundColor3 = ACCENT
|
|
203
|
+
ring.BackgroundTransparency = 0.25
|
|
204
|
+
ring.BorderSizePixel = 0
|
|
205
|
+
ring.ZIndex = 99
|
|
206
|
+
local corner = Instance.new("UICorner")
|
|
207
|
+
corner.CornerRadius = UDim.new(1, 0)
|
|
208
|
+
corner.Parent = ring
|
|
209
|
+
ring.Parent = cursorGui
|
|
210
|
+
|
|
211
|
+
local grow = TweenService:Create(
|
|
212
|
+
ring,
|
|
213
|
+
TweenInfo.new(0.35, Enum.EasingStyle.Quad, Enum.EasingDirection.Out),
|
|
214
|
+
{ Size = UDim2.fromOffset(52, 52), BackgroundTransparency = 1 }
|
|
215
|
+
)
|
|
216
|
+
grow:Play()
|
|
217
|
+
grow.Completed:Connect(function()
|
|
218
|
+
ring:Destroy()
|
|
219
|
+
end)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
makeCursor()
|
|
223
|
+
|
|
70
224
|
for _, step in plan do
|
|
71
225
|
local kind = step.kind
|
|
72
226
|
if kind == "key" then
|
|
@@ -81,10 +235,15 @@ for _, step in plan do
|
|
|
81
235
|
end
|
|
82
236
|
end
|
|
83
237
|
elseif kind == "move" then
|
|
238
|
+
moveCursor(step.x, step.y, 0.3)
|
|
84
239
|
virtual:SendMousePosition(Vector2.new(step.x, step.y))
|
|
85
240
|
elseif kind == "click" then
|
|
86
241
|
local button = Enum.UserInputType[step.button or "MouseButton1"]
|
|
87
242
|
local at = Vector2.new(step.x, step.y)
|
|
243
|
+
-- Travel first, then click. Teleporting the pointer onto the target at
|
|
244
|
+
-- the same instant it fires looks like nothing happened at all.
|
|
245
|
+
moveCursor(step.x, step.y, 0.3)
|
|
246
|
+
pulse()
|
|
88
247
|
virtual:SendMousePosition(at)
|
|
89
248
|
virtual:SendMouseButton(at, button, true, 0)
|
|
90
249
|
if step.action ~= "press" then
|
|
@@ -100,6 +259,12 @@ for _, step in plan do
|
|
|
100
259
|
end
|
|
101
260
|
end
|
|
102
261
|
|
|
262
|
+
-- Left up for a moment so the last action is still visible when the tool returns.
|
|
263
|
+
task.wait(0.25)
|
|
264
|
+
if cursorGui ~= nil then
|
|
265
|
+
cursorGui:Destroy()
|
|
266
|
+
end
|
|
267
|
+
|
|
103
268
|
report:FireServer({ ok = true, performed = performed })
|
|
104
269
|
]==]
|
|
105
270
|
|
|
@@ -235,6 +400,7 @@ function Input.send(params: { [string]: any }): { [string]: any }
|
|
|
235
400
|
relay.Name = "MCPInputRelay"
|
|
236
401
|
relay.Source = RELAY_SOURCE
|
|
237
402
|
relay:SetAttribute("Plan", HttpService:JSONEncode(plan))
|
|
403
|
+
relay:SetAttribute("Cursor", params.cursor ~= false)
|
|
238
404
|
remote.Parent = relay
|
|
239
405
|
|
|
240
406
|
local answer: { [string]: any }? = nil
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Draws the project mark: a triangular prism splitting white light.
|
|
3
|
+
*
|
|
4
|
+
* Both outputs come from this one file because they have to agree. The SVG is
|
|
5
|
+
* for anywhere that takes vector; the PNG exists because GitHub, npm and most
|
|
6
|
+
* other places reject SVG for an avatar, and hand-keeping two drawings in sync
|
|
7
|
+
* is how they end up different.
|
|
8
|
+
*
|
|
9
|
+
* There is no rasteriser installed and the shapes do not need one: the body is
|
|
10
|
+
* three flat polygons and the light is a set of bands with a Gaussian falloff
|
|
11
|
+
* across their width. The PNG is written by the same encoder behind the
|
|
12
|
+
* `screenshot` tool.
|
|
13
|
+
*
|
|
14
|
+
* Usage: node scripts/prism.mjs docs/prism.svg docs/prism.png
|
|
15
|
+
*/
|
|
16
|
+
import { writeFileSync } from "node:fs";
|
|
17
|
+
import { encodePng } from "../dist/lib/png.js";
|
|
18
|
+
|
|
19
|
+
const SIZE = 512;
|
|
20
|
+
const SS = 3; // subsamples per axis
|
|
21
|
+
|
|
22
|
+
// --- geometry --------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
const A = [238, 142]; // apex
|
|
25
|
+
const B = [146, 322]; // bottom left
|
|
26
|
+
const C = [330, 322]; // bottom right
|
|
27
|
+
const DEPTH = [40, -24]; // extrusion, up and to the right
|
|
28
|
+
|
|
29
|
+
const add = (p, q) => [p[0] + q[0], p[1] + q[1]];
|
|
30
|
+
const mix = (p, q, t) => [p[0] + (q[0] - p[0]) * t, p[1] + (q[1] - p[1]) * t];
|
|
31
|
+
|
|
32
|
+
const A2 = add(A, DEPTH);
|
|
33
|
+
const B2 = add(B, DEPTH);
|
|
34
|
+
const C2 = add(C, DEPTH);
|
|
35
|
+
|
|
36
|
+
const FRONT = [A, B, C];
|
|
37
|
+
const SIDE = [A, C, C2, A2]; // the only extruded face facing the viewer
|
|
38
|
+
|
|
39
|
+
// Where the beam enters the left face and leaves the right one. Both sit on an
|
|
40
|
+
// edge rather than near a corner, so the light visibly passes through the body.
|
|
41
|
+
const ENTRY = mix(A, B, 0.55);
|
|
42
|
+
const EXIT = mix(A, C, 0.62);
|
|
43
|
+
const SOURCE = [24, 196];
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The spectrum, ordered by how far each colour bends.
|
|
47
|
+
*
|
|
48
|
+
* Red deviates least and violet most, which is the whole reason a prism
|
|
49
|
+
* separates them, so red leaves closest to the original path and violet swings
|
|
50
|
+
* furthest toward the base. Reversing this is the usual tell that a prism
|
|
51
|
+
* drawing was copied rather than understood.
|
|
52
|
+
*/
|
|
53
|
+
const SPECTRUM = [
|
|
54
|
+
{ angle: 6, color: [255, 64, 72] },
|
|
55
|
+
{ angle: 11, color: [255, 138, 48] },
|
|
56
|
+
{ angle: 16, color: [255, 214, 66] },
|
|
57
|
+
{ angle: 21, color: [88, 230, 122] },
|
|
58
|
+
{ angle: 26, color: [70, 210, 255] },
|
|
59
|
+
{ angle: 31, color: [96, 132, 255] },
|
|
60
|
+
{ angle: 36, color: [176, 106, 255] },
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
const FAN_LENGTH = 190;
|
|
64
|
+
const BG = [10, 14, 26];
|
|
65
|
+
|
|
66
|
+
const rad = (deg) => (deg * Math.PI) / 180;
|
|
67
|
+
|
|
68
|
+
/** A light band: origin, direction, and how wide it is at each end. */
|
|
69
|
+
function band(origin, angleDeg, length, w0, w1) {
|
|
70
|
+
const t = rad(angleDeg);
|
|
71
|
+
return {
|
|
72
|
+
origin,
|
|
73
|
+
dir: [Math.cos(t), Math.sin(t)],
|
|
74
|
+
perp: [-Math.sin(t), Math.cos(t)],
|
|
75
|
+
length,
|
|
76
|
+
w0,
|
|
77
|
+
w1,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const FAN = SPECTRUM.map((s) => ({
|
|
82
|
+
...s,
|
|
83
|
+
band: band(EXIT, s.angle, FAN_LENGTH, 3, 15),
|
|
84
|
+
}));
|
|
85
|
+
|
|
86
|
+
const INCOMING = (() => {
|
|
87
|
+
const d = [ENTRY[0] - SOURCE[0], ENTRY[1] - SOURCE[1]];
|
|
88
|
+
const len = Math.hypot(d[0], d[1]);
|
|
89
|
+
return { band: band(SOURCE, (Math.atan2(d[1], d[0]) * 180) / Math.PI, len, 7, 4), color: [255, 255, 255] };
|
|
90
|
+
})();
|
|
91
|
+
|
|
92
|
+
const INTERNAL = (() => {
|
|
93
|
+
const d = [EXIT[0] - ENTRY[0], EXIT[1] - ENTRY[1]];
|
|
94
|
+
const len = Math.hypot(d[0], d[1]);
|
|
95
|
+
return { band: band(ENTRY, (Math.atan2(d[1], d[0]) * 180) / Math.PI, len, 4, 6), color: [220, 235, 255] };
|
|
96
|
+
})();
|
|
97
|
+
|
|
98
|
+
/** Corners of a band, for the SVG. */
|
|
99
|
+
function bandPoints(b) {
|
|
100
|
+
const s = b.origin;
|
|
101
|
+
const e = [s[0] + b.dir[0] * b.length, s[1] + b.dir[1] * b.length];
|
|
102
|
+
const off = (p, w, sign) => [p[0] + b.perp[0] * w * sign, p[1] + b.perp[1] * w * sign];
|
|
103
|
+
return [off(s, b.w0, -1), off(e, b.w1, -1), off(e, b.w1, 1), off(s, b.w0, 1)];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// --- SVG -------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
const n = (v) => Math.round(v * 10) / 10;
|
|
109
|
+
const poly = (pts) => pts.map((p) => `${n(p[0])},${n(p[1])}`).join(" ");
|
|
110
|
+
const rgb = (c) => `rgb(${c[0]},${c[1]},${c[2]})`;
|
|
111
|
+
|
|
112
|
+
function svg() {
|
|
113
|
+
const beams = [];
|
|
114
|
+
for (const [i, f] of FAN.entries()) {
|
|
115
|
+
const pts = bandPoints(f.band);
|
|
116
|
+
beams.push(
|
|
117
|
+
`<linearGradient id="g${i}" gradientUnits="userSpaceOnUse" ` +
|
|
118
|
+
`x1="${n(EXIT[0])}" y1="${n(EXIT[1])}" ` +
|
|
119
|
+
`x2="${n(pts[1][0])}" y2="${n(pts[1][1])}">` +
|
|
120
|
+
`<stop offset="0" stop-color="${rgb(f.color)}" stop-opacity="0.95"/>` +
|
|
121
|
+
`<stop offset="0.55" stop-color="${rgb(f.color)}" stop-opacity="0.5"/>` +
|
|
122
|
+
`<stop offset="1" stop-color="${rgb(f.color)}" stop-opacity="0"/>` +
|
|
123
|
+
`</linearGradient>`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const fan = FAN.map((f, i) => `<polygon points="${poly(bandPoints(f.band))}" fill="url(#g${i})"/>`).join("\n ");
|
|
128
|
+
|
|
129
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${SIZE} ${SIZE}" width="${SIZE}" height="${SIZE}" role="img" aria-label="A prism splitting a beam of white light into a spectrum">
|
|
130
|
+
<title>rbx-studio-mcp</title>
|
|
131
|
+
<defs>
|
|
132
|
+
${beams.join("\n ")}
|
|
133
|
+
<linearGradient id="face" gradientUnits="userSpaceOnUse" x1="${n(A[0])}" y1="${n(A[1])}" x2="${n(C[0])}" y2="${n(C[1])}">
|
|
134
|
+
<stop offset="0" stop-color="#AFC0E8" stop-opacity="0.30"/>
|
|
135
|
+
<stop offset="1" stop-color="#5C6E9E" stop-opacity="0.16"/>
|
|
136
|
+
</linearGradient>
|
|
137
|
+
<linearGradient id="side" gradientUnits="userSpaceOnUse" x1="${n(A[0])}" y1="${n(A[1])}" x2="${n(C2[0])}" y2="${n(C2[1])}">
|
|
138
|
+
<stop offset="0" stop-color="#8FA2D0" stop-opacity="0.26"/>
|
|
139
|
+
<stop offset="1" stop-color="#39456A" stop-opacity="0.34"/>
|
|
140
|
+
</linearGradient>
|
|
141
|
+
<radialGradient id="flare">
|
|
142
|
+
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.85"/>
|
|
143
|
+
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0"/>
|
|
144
|
+
</radialGradient>
|
|
145
|
+
<filter id="soft" x="-40%" y="-40%" width="180%" height="180%">
|
|
146
|
+
<feGaussianBlur stdDeviation="5"/>
|
|
147
|
+
</filter>
|
|
148
|
+
</defs>
|
|
149
|
+
|
|
150
|
+
<rect width="${SIZE}" height="${SIZE}" fill="#0A0E1A"/>
|
|
151
|
+
|
|
152
|
+
<g filter="url(#soft)" opacity="0.85">
|
|
153
|
+
${fan}
|
|
154
|
+
</g>
|
|
155
|
+
<g>
|
|
156
|
+
${fan}
|
|
157
|
+
</g>
|
|
158
|
+
|
|
159
|
+
<polygon points="${poly(bandPoints(INCOMING.band))}" fill="#FFFFFF" opacity="0.85" filter="url(#soft)"/>
|
|
160
|
+
<polygon points="${poly(bandPoints(INCOMING.band))}" fill="#FFFFFF" opacity="0.9"/>
|
|
161
|
+
|
|
162
|
+
<polygon points="${poly(SIDE)}" fill="url(#side)"/>
|
|
163
|
+
<polygon points="${poly(FRONT)}" fill="url(#face)"/>
|
|
164
|
+
|
|
165
|
+
<polygon points="${poly(bandPoints(INTERNAL.band))}" fill="#DCEBFF" opacity="0.55"/>
|
|
166
|
+
|
|
167
|
+
<polygon points="${poly(FRONT)}" fill="none" stroke="#DCE6FF" stroke-opacity="0.85" stroke-width="2.5" stroke-linejoin="round"/>
|
|
168
|
+
<polygon points="${poly(SIDE)}" fill="none" stroke="#AEC0EA" stroke-opacity="0.5" stroke-width="2" stroke-linejoin="round"/>
|
|
169
|
+
|
|
170
|
+
<circle cx="${n(ENTRY[0])}" cy="${n(ENTRY[1])}" r="16" fill="url(#flare)"/>
|
|
171
|
+
<circle cx="${n(EXIT[0])}" cy="${n(EXIT[1])}" r="20" fill="url(#flare)"/>
|
|
172
|
+
</svg>
|
|
173
|
+
`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// --- PNG -------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
function insidePoly(pts, x, y) {
|
|
179
|
+
let hit = false;
|
|
180
|
+
for (let i = 0, j = pts.length - 1; i < pts.length; j = i, i += 1) {
|
|
181
|
+
const [xi, yi] = pts[i];
|
|
182
|
+
const [xj, yj] = pts[j];
|
|
183
|
+
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) hit = !hit;
|
|
184
|
+
}
|
|
185
|
+
return hit;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* How brightly a band lights the point, 0..1.
|
|
190
|
+
*
|
|
191
|
+
* Gaussian across the width and falling off along the length, which is what
|
|
192
|
+
* makes a beam look like light rather than a painted stripe.
|
|
193
|
+
*/
|
|
194
|
+
function bandLight(b, x, y) {
|
|
195
|
+
const dx = x - b.origin[0];
|
|
196
|
+
const dy = y - b.origin[1];
|
|
197
|
+
const along = dx * b.dir[0] + dy * b.dir[1];
|
|
198
|
+
if (along < 0 || along > b.length) return 0;
|
|
199
|
+
const t = along / b.length;
|
|
200
|
+
const across = Math.abs(dx * b.perp[0] + dy * b.perp[1]);
|
|
201
|
+
const width = b.w0 + (b.w1 - b.w0) * t;
|
|
202
|
+
const profile = Math.exp(-((across / width) ** 2) * 2.2);
|
|
203
|
+
const fade = (1 - t) ** 0.7;
|
|
204
|
+
const ramp = Math.min(1, t / 0.04);
|
|
205
|
+
return profile * fade * ramp;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function flare(p, x, y, radius) {
|
|
209
|
+
const d = Math.hypot(x - p[0], y - p[1]) / radius;
|
|
210
|
+
return Math.exp(-d * d * 2.4);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function png() {
|
|
214
|
+
const out = Buffer.alloc(SIZE * SIZE * 3);
|
|
215
|
+
|
|
216
|
+
for (let py = 0; py < SIZE; py += 1) {
|
|
217
|
+
for (let px = 0; px < SIZE; px += 1) {
|
|
218
|
+
let r = 0;
|
|
219
|
+
let g = 0;
|
|
220
|
+
let b = 0;
|
|
221
|
+
|
|
222
|
+
for (let sy = 0; sy < SS; sy += 1) {
|
|
223
|
+
for (let sx = 0; sx < SS; sx += 1) {
|
|
224
|
+
const x = px + (sx + 0.5) / SS;
|
|
225
|
+
const y = py + (sy + 0.5) / SS;
|
|
226
|
+
|
|
227
|
+
let c = [...BG];
|
|
228
|
+
|
|
229
|
+
// Glass first: a faint body that the light is then added over, so the
|
|
230
|
+
// beams read as passing through rather than being drawn on.
|
|
231
|
+
if (insidePoly(SIDE, x, y)) {
|
|
232
|
+
const t = (x - A[0]) / (C2[0] - A[0]);
|
|
233
|
+
c = [c[0] + 44 * (1 - t * 0.5), c[1] + 54 * (1 - t * 0.5), c[2] + 84 * (1 - t * 0.4)];
|
|
234
|
+
}
|
|
235
|
+
if (insidePoly(FRONT, x, y)) {
|
|
236
|
+
const t = (y - A[1]) / (C[1] - A[1]);
|
|
237
|
+
c = [c[0] + 62 - 22 * t, c[1] + 74 - 24 * t, c[2] + 108 - 28 * t];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Light is added, never blended: two beams crossing should be
|
|
241
|
+
// brighter, which is the one thing alpha compositing gets wrong.
|
|
242
|
+
const lights = [INCOMING, ...FAN];
|
|
243
|
+
for (const l of lights) {
|
|
244
|
+
const v = bandLight(l.band, x, y);
|
|
245
|
+
if (v > 0) {
|
|
246
|
+
c[0] += l.color[0] * v;
|
|
247
|
+
c[1] += l.color[1] * v;
|
|
248
|
+
c[2] += l.color[2] * v;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (insidePoly(FRONT, x, y)) {
|
|
252
|
+
const v = bandLight(INTERNAL.band, x, y) * 0.85;
|
|
253
|
+
c[0] += 220 * v;
|
|
254
|
+
c[1] += 235 * v;
|
|
255
|
+
c[2] += 255 * v;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
for (const [p, radius, strength] of [
|
|
259
|
+
[ENTRY, 20, 0.55],
|
|
260
|
+
[EXIT, 26, 0.75],
|
|
261
|
+
]) {
|
|
262
|
+
const v = flare(p, x, y, radius) * strength;
|
|
263
|
+
c[0] += 255 * v;
|
|
264
|
+
c[1] += 255 * v;
|
|
265
|
+
c[2] += 255 * v;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Edges, drawn as distance to each segment so they stay crisp.
|
|
269
|
+
const edges = [
|
|
270
|
+
[FRONT, 1.6, 210, 226, 255],
|
|
271
|
+
[SIDE, 1.2, 150, 170, 220],
|
|
272
|
+
];
|
|
273
|
+
for (const [shape, halfWidth, er, eg, eb] of edges) {
|
|
274
|
+
for (let i = 0; i < shape.length; i += 1) {
|
|
275
|
+
const p0 = shape[i];
|
|
276
|
+
const p1 = shape[(i + 1) % shape.length];
|
|
277
|
+
const vx = p1[0] - p0[0];
|
|
278
|
+
const vy = p1[1] - p0[1];
|
|
279
|
+
const len2 = vx * vx + vy * vy;
|
|
280
|
+
let t = ((x - p0[0]) * vx + (y - p0[1]) * vy) / len2;
|
|
281
|
+
t = Math.max(0, Math.min(1, t));
|
|
282
|
+
const d = Math.hypot(x - (p0[0] + vx * t), y - (p0[1] + vy * t));
|
|
283
|
+
if (d < halfWidth + 1) {
|
|
284
|
+
const a = Math.max(0, Math.min(1, halfWidth + 1 - d));
|
|
285
|
+
c[0] += (er - c[0]) * a * 0.9;
|
|
286
|
+
c[1] += (eg - c[1]) * a * 0.9;
|
|
287
|
+
c[2] += (eb - c[2]) * a * 0.9;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
r += Math.min(255, c[0]);
|
|
293
|
+
g += Math.min(255, c[1]);
|
|
294
|
+
b += Math.min(255, c[2]);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const n2 = SS * SS;
|
|
299
|
+
const at = (py * SIZE + px) * 3;
|
|
300
|
+
out[at] = Math.round(r / n2);
|
|
301
|
+
out[at + 1] = Math.round(g / n2);
|
|
302
|
+
out[at + 2] = Math.round(b / n2);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return encodePng(out, SIZE, SIZE);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
writeFileSync(process.argv[2], svg());
|
|
310
|
+
writeFileSync(process.argv[3], png());
|