ispyx 1.0.5 → 1.0.7

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ispyx",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "Local desktop-control MCP server: lets an AI use your keyboard, mouse and screen (windows, screenshots, clicks, typing, clipboard). stdio only, Windows and macOS.",
5
5
  "type": "module",
6
6
  "main": "src/server.js",
@@ -48,6 +48,7 @@
48
48
  "hook-test": "node test/hook-life.js",
49
49
  "shutdown-test": "node test/shutdown-all.js",
50
50
  "scan-test": "node test/scan.js",
51
+ "laser-test": "node test/laser.js",
51
52
  "radar-multi-test": "node test/radar-multi.js",
52
53
  "scan-multi-test": "node test/scan-multi.js",
53
54
  "auth-test": "node test/auth.js",
@@ -37,6 +37,7 @@ h2{font-size:11px;letter-spacing:.05em;text-transform:uppercase;color:var(--mute
37
37
  button{font:inherit;color:var(--fg);background:var(--card);border:1px solid var(--line);border-radius:7px;padding:4px 12px;cursor:pointer;box-shadow:0 1px 0 rgba(0,0,0,.04)}
38
38
  button:hover{background:var(--fill)}
39
39
  button:active{filter:brightness(.95)}
40
+ button:disabled{opacity:.55;cursor:default;filter:none}
40
41
  button:focus-visible,.sw input:focus-visible+i{outline:2px solid var(--acc);outline-offset:2px}
41
42
  button.pri{background:var(--acc);border-color:var(--acc);color:var(--accfg);font-weight:600}
42
43
  button.pri:hover{filter:brightness(.96);background:var(--acc)}
@@ -101,6 +101,15 @@ function playTrail(a, z, button, opts) {
101
101
  effects.add(run);
102
102
  return stop;
103
103
  }
104
+ /** drag: an eye beside the line fires a beam at the pointer's heels (scorch fades after a couple of seconds); the returned function ends it. */
105
+ function playLaser(a, z) {
106
+ const epoch = guard.epoch;
107
+ let stop;
108
+ const stopped = new Promise((r) => { stop = r; });
109
+ const run = scan.laser({ x: a.sx, y: a.sy }, { x: z.sx, y: z.sy }, stopped, () => guard.epoch !== epoch).finally(() => effects.delete(run));
110
+ effects.add(run);
111
+ return stop;
112
+ }
104
113
  /** scroll: chevrons beside the pointer while the wheel turns; the returned function ends it. */
105
114
  function playWheelCue(p, vert, horiz) {
106
115
  const epoch = guard.epoch;
@@ -594,7 +603,8 @@ server.registerTool('drag', {
594
603
  await w.sleep(40);
595
604
  w.mouseDown(b);
596
605
  guard.heldButtons.add(b);
597
- const stopTrail = playTrail(a, z2, b);
606
+ const stopTrail = playTrail(a, z2, b, { line: false }); // start ring and end ring; the beam draws the path
607
+ const stopLaser = playLaser(a, z2);
598
608
  try {
599
609
  await w.sleep(40);
600
610
  await glide(z2, args, { trail: false });
@@ -603,6 +613,7 @@ server.registerTool('drag', {
603
613
  w.mouseUp(b);
604
614
  guard.heldButtons.delete(b);
605
615
  stopTrail();
616
+ stopLaser();
606
617
  }
607
618
  });
608
619
  return json({ ok: true, from: { x: a.sx, y: a.sy }, to: { x: z2.sx, y: z2.sy } });
@@ -0,0 +1,182 @@
1
+ // The drag laser: an eye beside the drag line fires a thick beam at the spot just behind the pointer, as if the pointer
2
+ // were running from it. Pure drawing into premultiplied BGRA buffers (Layer.px) and the placement rules; scan.js
3
+ // runs it (ScanAnimation.laser). Colours stay in the ispyx green family, brighter and glassier than the flat trail.
4
+
5
+ const clamp = (v, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, v));
6
+ const mix = (a, b, t) => a + (b - a) * t;
7
+ const smooth = (t) => t * t * (3 - 2 * t);
8
+
9
+ export const LASER_EYE_SIZE = 112;
10
+ export const BURN_LIFE_MS = 2000;
11
+ export const BEAM_WIDTH = 26; // half-width in px of the full beam
12
+
13
+ /**
14
+ * Where the eye goes: beside the line a -> b (never on it), at a random place along it and on a random side, as far
15
+ * from the line as the monitor `mon` {x, y, width, height} allows (up to size/2 + 190 px). Returns the eye centre.
16
+ */
17
+ export function placeEye(a, b, mon, size = LASER_EYE_SIZE, rand = Math.random) {
18
+ const len = Math.hypot(b.x - a.x, b.y - a.y) || 1;
19
+ const nx = -(b.y - a.y) / len, ny = (b.x - a.x) / len;
20
+ const f = 0.3 + 0.4 * rand();
21
+ const mx = a.x + (b.x - a.x) * f, my = a.y + (b.y - a.y) * f;
22
+ const half = size / 2, margin = 10;
23
+ const fits = (c) => c.x - half >= mon.x + margin && c.x + half <= mon.x + mon.width - margin && c.y - half >= mon.y + margin && c.y + half <= mon.y + mon.height - margin;
24
+ const first = rand() < 0.5 ? 1 : -1;
25
+ for (const d of [half + 190, half + 130, half + 80]) {
26
+ for (const s of [first, -first]) {
27
+ const c = { x: mx + nx * s * d, y: my + ny * s * d };
28
+ if (fits(c)) return c;
29
+ }
30
+ }
31
+ // no room on either side (a drag across a whole small screen): the side with more room, kept on the monitor
32
+ const c = { x: mx + nx * first * (half + 80), y: my + ny * first * (half + 80) };
33
+ return { x: clamp(c.x, mon.x + margin + half, mon.x + mon.width - margin - half), y: clamp(c.y, mon.y + margin + half, mon.y + mon.height - margin - half) };
34
+ }
35
+
36
+ /** Premultiplied "over": add colour (r, g, b) with coverage a at pixel index i. */
37
+ function over(px, i, r, g, b, a) {
38
+ if (a <= 0) return;
39
+ const k = 1 - a;
40
+ px[i] = b * a + px[i] * k; px[i + 1] = g * a + px[i + 1] * k; px[i + 2] = r * a + px[i + 2] * k; px[i + 3] = a * 255 + px[i + 3] * k;
41
+ }
42
+
43
+ /** A glowing flare at (cx, cy): white core, green/cyan halo, a ring and slowly turning spikes. `w` is the beam half-width. */
44
+ function flare(px, W, H, cx, cy, w, tMs, strength) {
45
+ const R = Math.ceil(w * 3.6);
46
+ const rot = tMs * 0.0011;
47
+ // cos(6 * (angle + rot)) without atan2/cos per pixel: (cos a + i sin a)^6 rotated by 6 * rot
48
+ const cr = Math.cos(6 * rot), sr = Math.sin(6 * rot);
49
+ for (let y = Math.max(0, Math.floor(cy - R)); y <= Math.min(H - 1, Math.ceil(cy + R)); y++) {
50
+ for (let x = Math.max(0, Math.floor(cx - R)); x <= Math.min(W - 1, Math.ceil(cx + R)); x++) {
51
+ const dx = x + 0.5 - cx, dy = y + 0.5 - cy;
52
+ const r = Math.sqrt(dx * dx + dy * dy);
53
+ if (r > R) continue;
54
+ const i = (y * W + x) * 4;
55
+ const halo = Math.exp(-((r / (w * 2.1)) ** 2)) * 0.55 * strength;
56
+ over(px, i, 40, 235, 170, halo);
57
+ const ring = clamp(1 - Math.abs(r - w * 1.9) / 1.6) * 0.55 * strength;
58
+ over(px, i, 170, 255, 220, ring);
59
+ const c1 = r > 0 ? dx / r : 1, s1 = r > 0 ? dy / r : 0;
60
+ const c2 = c1 * c1 - s1 * s1, s2 = 2 * c1 * s1; // angle x2
61
+ const c6 = c2 * (c2 * c2 - s2 * s2) - s2 * (2 * c2 * s2), s6 = s2 * (c2 * c2 - s2 * s2) + c2 * (2 * c2 * s2); // x2 * x3 = x6
62
+ const k = Math.max(0, c6 * cr - s6 * sr);
63
+ const k2 = k * k, k4 = k2 * k2, k16 = k4 * k4 * k4 * k4; // k^22 = k^16 * k^4 * k^2
64
+ const spike = k16 * k4 * k2 * Math.exp(-r / (w * 1.5)) * 0.9 * strength;
65
+ over(px, i, 200, 255, 225, spike);
66
+ const core = Math.exp(-((r / (w * 1.05)) ** 2)) * strength;
67
+ over(px, i, 240, 255, 246, core);
68
+ }
69
+ }
70
+ }
71
+
72
+ /**
73
+ * The beam from (ax, ay) to (bx, by) with half-width `w` (0 = off): rounded ends and the same width all along, a soft green glow, an emerald body that
74
+ * darkens towards its rim, a white-green core, energy bands running along it, and a flare at each end.
75
+ * `tMs` animates the bands and the spikes.
76
+ */
77
+ export function renderBeam(px, W, H, ax, ay, bx, by, w, tMs) {
78
+ if (w < 0.5) return;
79
+ const len = Math.hypot(bx - ax, by - ay) || 1;
80
+ const ux = (bx - ax) / len, uy = (by - ay) / len;
81
+ const pad = Math.ceil(w * 3.2);
82
+ const x0 = Math.max(0, Math.floor(Math.min(ax, bx) - pad)), x1 = Math.min(W - 1, Math.ceil(Math.max(ax, bx) + pad));
83
+ const y0 = Math.max(0, Math.floor(Math.min(ay, by) - pad)), y1 = Math.min(H - 1, Math.ceil(Math.max(ay, by) + pad));
84
+ const reach2 = (w * 3.1) ** 2;
85
+ for (let y = y0; y <= y1; y++) {
86
+ for (let x = x0; x <= x1; x++) {
87
+ const dx = x + 0.5 - ax, dy = y + 0.5 - ay;
88
+ const s = clamp(dx * ux + dy * uy, 0, len); // rounded ends: distance to the segment
89
+ const ex = dx - s * ux, ey = dy - s * uy;
90
+ const d2 = ex * ex + ey * ey;
91
+ if (d2 > reach2) continue; // most of a diagonal beam's box: no square root, nothing drawn
92
+ const d = Math.sqrt(d2);
93
+ const i = (y * W + x) * 4;
94
+ const hw = w; // the same width along its whole length
95
+ over(px, i, 20, 200, 120, Math.exp(-((d / (hw * 2.3)) ** 2)) * 0.5); // outer glow
96
+ const body = clamp(hw - d + 0.5);
97
+ if (body > 0) {
98
+ const band = 0.86 + 0.14 * Math.sin(s * 0.11 - tMs * 0.045);
99
+ const e = clamp(d / hw);
100
+ const k = e * e; // 0 in the middle, 1 on the rim
101
+ over(px, i, mix(70, 8, k) * band, mix(255, 130, k) * band, mix(150, 80, k) * band, body * 0.97);
102
+ const core = clamp(hw * 0.52 - d + 0.5) * (0.9 + 0.1 * band);
103
+ over(px, i, 238, 255, 244, core);
104
+ }
105
+ }
106
+ }
107
+ flare(px, W, H, ax, ay, w, tMs, 0.85);
108
+ flare(px, W, H, bx, by, w, tMs + 400, 1);
109
+ }
110
+
111
+ /**
112
+ * A thick line piece (a capsule); the stronger alpha wins where pieces overlap (so a burn trail has no beads at its
113
+ * joints). Each pixel of the bounding box is visited once and shaded by its distance to the segment: the burn
114
+ * redraws every piece every frame, and stamping a disc at each pixel along the line cost ~30x more.
115
+ */
116
+ export function stroke(px, W, H, x0, y0, x1, y1, radius, rgb, alpha) {
117
+ const R = radius + 1;
118
+ const xa = Math.max(0, Math.floor(Math.min(x0, x1) - R)), xb = Math.min(W - 1, Math.ceil(Math.max(x0, x1) + R));
119
+ const ya = Math.max(0, Math.floor(Math.min(y0, y1) - R)), yb = Math.min(H - 1, Math.ceil(Math.max(y0, y1) + R));
120
+ const dx = x1 - x0, dy = y1 - y0, len2 = dx * dx + dy * dy;
121
+ const b = rgb[2], g = rgb[1], r = rgb[0];
122
+ for (let y = ya; y <= yb; y++) {
123
+ const py = y + 0.5 - y0;
124
+ for (let x = xa; x <= xb; x++) {
125
+ const qx = x + 0.5 - x0;
126
+ const u = len2 > 0 ? Math.min(1, Math.max(0, (qx * dx + py * dy) / len2)) : 0;
127
+ const ex = qx - u * dx, ey = py - u * dy;
128
+ const a = clamp(radius + 0.5 - Math.sqrt(ex * ex + ey * ey)) * alpha;
129
+ if (a <= 0) continue;
130
+ const i = (y * W + x) * 4;
131
+ if (a * 255 <= px[i + 3]) continue;
132
+ px[i] = b * a; px[i + 1] = g * a; px[i + 2] = r * a; px[i + 3] = a * 255;
133
+ }
134
+ }
135
+ }
136
+
137
+ /**
138
+ * The scorch the beam leaves: pts = [{ x, y, t }] (buffer pixels, ms stamps), now = ms. Fresh pieces glow lime-white,
139
+ * cool to a charred dark line with a faint green ember rim, and disappear after BURN_LIFE_MS.
140
+ */
141
+ export function renderBurn(px, W, H, pts, now) {
142
+ for (let pass = 0; pass < 3; pass++) {
143
+ for (let k = 1; k < pts.length; k++) {
144
+ const age = now - pts[k].t;
145
+ const fade = Math.pow(clamp(1 - age / BURN_LIFE_MS), 0.8);
146
+ if (fade <= 0.02) continue;
147
+ const A = pts[k - 1], B = pts[k];
148
+ if (pass === 0) stroke(px, W, H, A.x, A.y, B.x, B.y, 13, [22, 120, 66], 0.3 * fade);
149
+ else if (pass === 1) stroke(px, W, H, A.x, A.y, B.x, B.y, 9, [10, 16, 13], 0.82 * fade);
150
+ else {
151
+ const heat = clamp(1 - age / 320);
152
+ if (heat <= 0.03) continue;
153
+ stroke(px, W, H, A.x, A.y, B.x, B.y, 3.5 + 5 * heat, [mix(90, 235, heat), 255, mix(130, 215, heat)], heat * 0.95);
154
+ }
155
+ }
156
+ }
157
+ }
158
+
159
+ /** Sparks flying off the impact point: [{ x, y, vx, vy, t0, life }] (px, px/ms, ms). Returns the ones still alive. */
160
+ export function renderSparks(px, W, H, sparks, now) {
161
+ const alive = [];
162
+ for (const s of sparks) {
163
+ const age = now - s.t0;
164
+ if (age >= s.life) continue;
165
+ alive.push(s);
166
+ const f = 1 - age / s.life;
167
+ const x = s.x + s.vx * age, y = s.y + s.vy * age + 0.0005 * age * age;
168
+ const px0 = x - s.vx * 26 * f, py0 = y - s.vy * 26 * f;
169
+ stroke(px, W, H, px0, py0, x, y, 0.9 + 1.2 * f, [210, 255, 225], f);
170
+ }
171
+ return alive;
172
+ }
173
+
174
+ /** New sparks at (x, y), flying away from the beam source (ax, ay) in a fan. */
175
+ export function spawnSparks(list, x, y, ax, ay, now, count = 2, rand = Math.random) {
176
+ const base = Math.atan2(y - ay, x - ax);
177
+ for (let n = 0; n < count; n++) {
178
+ const ang = base + (rand() - 0.5) * 2.4;
179
+ const v = 0.12 + rand() * 0.34;
180
+ list.push({ x, y, vx: Math.cos(ang) * v, vy: Math.sin(ang) * v, t0: now, life: 260 + rand() * 260 });
181
+ }
182
+ }
@@ -2,6 +2,7 @@
2
2
  // a glowing green scan line that sweeps the region from top to bottom. Two click-through, per-pixel-alpha windows;
3
3
  // pixels are drawn in software into DIB sections, so nothing here depends on GDI+.
4
4
  import platform from '../platform/index.js';
5
+ import { LASER_EYE_SIZE, BEAM_WIDTH, BURN_LIFE_MS, placeEye, renderBeam, renderBurn, renderSparks, spawnSparks } from './laser.js';
5
6
 
6
7
  const clamp = (v, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, v));
7
8
  const mix = (a, b, t) => a + (b - a) * t;
@@ -310,7 +311,7 @@ export class ScanAnimation {
310
311
  * fading with age. When `stopped` resolves (button released) the trail fades out and rings mark the end.
311
312
  * `bounds` {left, top, right, bottom}: where the drag can go. Never throws.
312
313
  */
313
- async trail(start, bounds, stopped, cancelled = () => false, button = 'left', { marker: showMarker = true, life = TRAIL_LIFE_MS, ring = true } = {}) {
314
+ async trail(start, bounds, stopped, cancelled = () => false, button = 'left', { marker: showMarker = true, life = TRAIL_LIFE_MS, ring = true, line = true } = {}) {
314
315
  if (!this.enabled) return;
315
316
  let layer = null;
316
317
  let ended = false;
@@ -338,7 +339,7 @@ export class ScanAnimation {
338
339
  }
339
340
  layer.px.fill(0);
340
341
  const fade = endAt ? 1 - clamp((t - endAt) / TRAIL_FADE_MS) : 1;
341
- for (let pass = 0; pass < 2; pass++) {
342
+ for (let pass = 0; pass < (line ? 2 : 0); pass++) {
342
343
  for (let k = 1; k < pts.length; k++) {
343
344
  const age = (endAt ? Math.min(t, endAt) : t) - pts[k].t;
344
345
  const a = clamp(1 - age / life) * fade;
@@ -370,6 +371,97 @@ export class ScanAnimation {
370
371
  }
371
372
  }
372
373
 
374
+ /**
375
+ * drag: an eye opens beside the drag line (random side, random place along it) and fires a thick beam at the spot just
376
+ * behind the pointer, as if the pointer were running from it; the beam leaves a scorch that fades after a couple of
377
+ * seconds. Runs until the beam and the scorch are gone. `start`/`end` are desktop pixels. Never throws.
378
+ */
379
+ async laser(start, end, stopped, cancelled = () => false) {
380
+ if (!this.enabled || Math.hypot(end.x - start.x, end.y - start.y) < 80) return;
381
+ const FIRE = 260, LAG = 130, HOLD = 260, MIN_BEAM = 520, CUT = 180, EYE_CLOSE = 300;
382
+ let beam = null, eye = null;
383
+ let ended = false;
384
+ stopped.then(() => { ended = true; });
385
+ try {
386
+ platform.ensurePump();
387
+ const v = platform.virtualScreen();
388
+ const mid = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 };
389
+ const mon = platform.listMonitors().find((q) => mid.x >= q.left && mid.x < q.left + q.width && mid.y >= q.top && mid.y < q.top + q.height);
390
+ const area = mon ? { x: mon.left, y: mon.top, width: mon.width, height: mon.height } : v;
391
+ const size = LASER_EYE_SIZE;
392
+ const ec = placeEye(start, end, area, size);
393
+ const ex = Math.round(ec.x - size / 2), ey = Math.round(ec.y - size / 2);
394
+ const pad = 150;
395
+ const left = Math.max(v.x, Math.floor(Math.min(start.x, end.x, ex) - pad)), top = Math.max(v.y, Math.floor(Math.min(start.y, end.y, ey) - pad));
396
+ const right = Math.min(v.x + v.width, Math.ceil(Math.max(start.x, end.x, ex + size) + pad)), bottom = Math.min(v.y + v.height, Math.ceil(Math.max(start.y, end.y, ey + size) + pad));
397
+ beam = new platform.Layer(Math.max(40, right - left), Math.max(40, bottom - top));
398
+ eye = new platform.Layer(size, size);
399
+ const samples = [{ x: start.x, y: start.y, t: 0 }]; // where the pointer was, and when
400
+ const burn = [];
401
+ let sparks = [];
402
+ let endAt = 0, cutAt = 0, tau = 0, lastT = 0, lastBurn = 0, last = { x: start.x, y: start.y };
403
+ const pointAt = (u) => {
404
+ for (let k = 1; k < samples.length; k++) {
405
+ if (samples[k].t >= u) {
406
+ const a = samples[k - 1], b = samples[k];
407
+ const f = b.t > a.t ? (u - a.t) / (b.t - a.t) : 1;
408
+ return { x: a.x + (b.x - a.x) * f, y: a.y + (b.y - a.y) * f };
409
+ }
410
+ }
411
+ return { x: samples[samples.length - 1].x, y: samples[samples.length - 1].y };
412
+ };
413
+ const t0 = performance.now();
414
+ for (;;) {
415
+ const tf = performance.now(), t = tf - t0;
416
+ if (cancelled()) break;
417
+ if (!endAt) {
418
+ const c = platform.cursorPos();
419
+ if (Math.hypot(c.x - samples[samples.length - 1].x, c.y - samples[samples.length - 1].y) >= 1) samples.push({ x: c.x, y: c.y, t });
420
+ if (ended) { endAt = t; cutAt = Math.max(endAt + HOLD, FIRE + MIN_BEAM); samples.push({ x: samples[samples.length - 1].x, y: samples[samples.length - 1].y, t }); }
421
+ }
422
+ const eyeEnd = cutAt ? cutAt + CUT + EYE_CLOSE : Infinity;
423
+ const firing = t >= FIRE && !(cutAt && t >= cutAt + CUT);
424
+ const grow = ease(clamp((t - FIRE) / 120));
425
+ const shrink = cutAt ? 1 - ease(clamp((t - cutAt) / CUT)) : 1;
426
+ const w = BEAM_WIDTH * grow * shrink;
427
+ // the beam's end follows the pointer's path about LAG ms behind it, catching up fast from the start point
428
+ if (t >= FIRE) tau = Math.min(tau + (t - lastT) * 2.2, Math.max(0, t - LAG), samples[samples.length - 1].t);
429
+ lastT = t;
430
+ const T = t >= FIRE ? pointAt(tau) : samples[samples.length - 1];
431
+ const open = t < FIRE ? ease(t / FIRE) : cutAt && t >= cutAt + CUT ? 1 - ease(clamp((t - cutAt - CUT) / EYE_CLOSE)) : 1;
432
+ // the pupil looks at the beam's end
433
+ const dx = T.x - ec.x, dy = T.y - ec.y, dl = Math.hypot(dx, dy) || 1;
434
+ const gx = (dx / dl) * 0.95, gy = (dy / dl) * 0.95;
435
+ renderEye(eye.px, size, open, gx, gy, t < FIRE ? clamp((t - 70) / (FIRE - 70)) : 1);
436
+ const pp = pupilPos(size, gx, gy);
437
+ // the eye trembles while it fires (the beam starts at the shaken pupil)
438
+ const sh = firing ? 4.5 * grow * shrink : 0;
439
+ const sx = (Math.random() - 0.5) * 2 * sh, sy = (Math.random() - 0.5) * 2 * sh;
440
+ beam.px.fill(0);
441
+ if (firing && w >= 1 && Math.hypot(T.x - last.x, T.y - last.y) >= 1) {
442
+ burn.push({ x: T.x - left, y: T.y - top, t });
443
+ lastBurn = t;
444
+ last = { x: T.x, y: T.y };
445
+ spawnSparks(sparks, T.x - left, T.y - top, ex + pp.x - left, ey + pp.y - top, t);
446
+ }
447
+ renderBurn(beam.px, beam.width, beam.height, burn, t);
448
+ if (firing && w >= 1) renderBeam(beam.px, beam.width, beam.height, ex + sx + pp.x - left, ey + sy + pp.y - top, T.x - left, T.y - top, w, t);
449
+ sparks = renderSparks(beam.px, beam.width, beam.height, sparks, t);
450
+ beam.present(left, top, 255);
451
+ if (t < eyeEnd) eye.present(Math.round(ex + sx), Math.round(ey + sy), 255 * open);
452
+ else if (eye.shown) eye.present(ex, ey, 0, 1);
453
+ if (t >= eyeEnd && t - lastBurn > BURN_LIFE_MS && !sparks.length) break;
454
+ await new Promise((r) => setTimeout(r, Math.max(1, 16 - (performance.now() - tf))));
455
+ }
456
+ } catch (e) {
457
+ process.stderr.write(`ispyx: laser effect failed (${e.message})
458
+ `);
459
+ } finally {
460
+ beam?.destroy();
461
+ eye?.destroy();
462
+ }
463
+ }
464
+
373
465
  /**
374
466
  * scroll: chevrons beside the pointer travel in the scroll direction while the wheel turns (until `stopped`
375
467
  * resolves, at least 380 ms, then a short fade). dirV: +1 up / -1 down / 0, dirH: +1 right / -1 left / 0. Never throws.