@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.
@@ -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());