@wavegrid/simulator 0.1.1 → 0.2.0

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.
@@ -0,0 +1,4 @@
1
+ import { CannonTarget } from './grid';
2
+ export type AnimationFn = (grid: CannonTarget[], tick: number, attack: number) => void;
3
+ export declare const animations: Record<string, AnimationFn>;
4
+ export declare function getAnimationNames(): string[];
package/animations.js ADDED
@@ -0,0 +1,105 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.animations = void 0;
4
+ exports.getAnimationNames = getAnimationNames;
5
+ const grid_1 = require("./grid");
6
+ exports.animations = {
7
+ wave: (grid, tick, attack) => {
8
+ for (let i = 0; i < grid_1.NUM_CANNONS; i++) {
9
+ const col = i % grid_1.GRID_SIZE;
10
+ const hue = (tick * 2 + col * 40) % 360;
11
+ const bright = 60 + Math.sin(tick * 0.05 + col * 0.8) * 20;
12
+ (0, grid_1.setCannonTarget)(grid, i, hue, 85, bright, attack);
13
+ }
14
+ },
15
+ breathe: (grid, tick, attack) => {
16
+ const brightness = 40 + Math.sin(tick * 0.03) * 35;
17
+ for (let i = 0; i < grid_1.NUM_CANNONS; i++) {
18
+ (0, grid_1.setCannonTarget)(grid, i, 220, 80, brightness, attack);
19
+ }
20
+ },
21
+ rainbow: (grid, tick, attack) => {
22
+ for (let i = 0; i < grid_1.NUM_CANNONS; i++) {
23
+ const row = Math.floor(i / grid_1.GRID_SIZE);
24
+ const col = i % grid_1.GRID_SIZE;
25
+ const hue = (tick * 1.5 + (row + col) * 25) % 360;
26
+ (0, grid_1.setCannonTarget)(grid, i, hue, 90, 80, attack);
27
+ }
28
+ },
29
+ pacman: (grid, tick, attack) => {
30
+ // Bright dot chasing around the perimeter
31
+ const perimeter = getPerimeterIndices();
32
+ const pos = Math.floor(tick * 0.3) % perimeter.length;
33
+ for (let i = 0; i < grid_1.NUM_CANNONS; i++) {
34
+ (0, grid_1.setCannonTarget)(grid, i, 220, 60, 15, attack);
35
+ }
36
+ // Pac-man (bright yellow)
37
+ const pacIdx = perimeter[pos];
38
+ (0, grid_1.setCannonTarget)(grid, pacIdx, 55, 95, 95, 1.0);
39
+ // Trail (fading)
40
+ for (let t = 1; t <= 3; t++) {
41
+ const trailPos = (pos - t + perimeter.length) % perimeter.length;
42
+ const trailIdx = perimeter[trailPos];
43
+ (0, grid_1.setCannonTarget)(grid, trailIdx, 55, 80, 70 - t * 18, 1.0);
44
+ }
45
+ },
46
+ spiral: (grid, tick, attack) => {
47
+ for (let i = 0; i < grid_1.NUM_CANNONS; i++) {
48
+ const row = Math.floor(i / grid_1.GRID_SIZE);
49
+ const col = i % grid_1.GRID_SIZE;
50
+ const cx = col - 3, cy = row - 3;
51
+ const angle = Math.atan2(cy, cx);
52
+ const dist = Math.sqrt(cx * cx + cy * cy);
53
+ const hue = (angle * 57.3 + dist * 40 + tick * 3) % 360;
54
+ (0, grid_1.setCannonTarget)(grid, i, (hue + 360) % 360, 85, 75, attack);
55
+ }
56
+ },
57
+ rain: (grid, tick, attack) => {
58
+ for (let i = 0; i < grid_1.NUM_CANNONS; i++) {
59
+ const row = Math.floor(i / grid_1.GRID_SIZE);
60
+ const col = i % grid_1.GRID_SIZE;
61
+ // Each column has a different phase offset (pseudo-random via prime)
62
+ const phase = (tick * 0.15 + col * 2.3 + col * col * 0.7) % grid_1.GRID_SIZE;
63
+ const dist = Math.abs(row - phase);
64
+ const bright = dist < 1.5 ? 90 - dist * 30 : 10;
65
+ (0, grid_1.setCannonTarget)(grid, i, 200 + col * 8, 70, bright, attack);
66
+ }
67
+ },
68
+ heartbeat: (grid, tick, attack) => {
69
+ // Double-pulse then rest (period ~120 ticks at 60fps = 2s)
70
+ const phase = tick % 120;
71
+ let brightness;
72
+ if (phase < 10)
73
+ brightness = 40 + phase * 5; // first pulse up
74
+ else if (phase < 20)
75
+ brightness = 90 - (phase - 10) * 5; // first pulse down
76
+ else if (phase < 30)
77
+ brightness = 40 + (phase - 20) * 4; // second pulse up
78
+ else if (phase < 40)
79
+ brightness = 80 - (phase - 30) * 4; // second pulse down
80
+ else
81
+ brightness = 40; // rest
82
+ for (let i = 0; i < grid_1.NUM_CANNONS; i++) {
83
+ (0, grid_1.setCannonTarget)(grid, i, 0, 90, brightness, attack);
84
+ }
85
+ }
86
+ };
87
+ function getPerimeterIndices() {
88
+ const indices = [];
89
+ // Top row
90
+ for (let c = 0; c < grid_1.GRID_SIZE; c++)
91
+ indices.push(c);
92
+ // Right column (skip top-right corner)
93
+ for (let r = 1; r < grid_1.GRID_SIZE; r++)
94
+ indices.push(r * grid_1.GRID_SIZE + (grid_1.GRID_SIZE - 1));
95
+ // Bottom row reversed (skip bottom-right corner)
96
+ for (let c = grid_1.GRID_SIZE - 2; c >= 0; c--)
97
+ indices.push((grid_1.GRID_SIZE - 1) * grid_1.GRID_SIZE + c);
98
+ // Left column reversed (skip corners)
99
+ for (let r = grid_1.GRID_SIZE - 2; r >= 1; r--)
100
+ indices.push(r * grid_1.GRID_SIZE);
101
+ return indices;
102
+ }
103
+ function getAnimationNames() {
104
+ return Object.keys(exports.animations);
105
+ }
@@ -0,0 +1,101 @@
1
+ import { GRID_SIZE, NUM_CANNONS, setCannonTarget } from './grid';
2
+ export const animations = {
3
+ wave: (grid, tick, attack) => {
4
+ for (let i = 0; i < NUM_CANNONS; i++) {
5
+ const col = i % GRID_SIZE;
6
+ const hue = (tick * 2 + col * 40) % 360;
7
+ const bright = 60 + Math.sin(tick * 0.05 + col * 0.8) * 20;
8
+ setCannonTarget(grid, i, hue, 85, bright, attack);
9
+ }
10
+ },
11
+ breathe: (grid, tick, attack) => {
12
+ const brightness = 40 + Math.sin(tick * 0.03) * 35;
13
+ for (let i = 0; i < NUM_CANNONS; i++) {
14
+ setCannonTarget(grid, i, 220, 80, brightness, attack);
15
+ }
16
+ },
17
+ rainbow: (grid, tick, attack) => {
18
+ for (let i = 0; i < NUM_CANNONS; i++) {
19
+ const row = Math.floor(i / GRID_SIZE);
20
+ const col = i % GRID_SIZE;
21
+ const hue = (tick * 1.5 + (row + col) * 25) % 360;
22
+ setCannonTarget(grid, i, hue, 90, 80, attack);
23
+ }
24
+ },
25
+ pacman: (grid, tick, attack) => {
26
+ // Bright dot chasing around the perimeter
27
+ const perimeter = getPerimeterIndices();
28
+ const pos = Math.floor(tick * 0.3) % perimeter.length;
29
+ for (let i = 0; i < NUM_CANNONS; i++) {
30
+ setCannonTarget(grid, i, 220, 60, 15, attack);
31
+ }
32
+ // Pac-man (bright yellow)
33
+ const pacIdx = perimeter[pos];
34
+ setCannonTarget(grid, pacIdx, 55, 95, 95, 1.0);
35
+ // Trail (fading)
36
+ for (let t = 1; t <= 3; t++) {
37
+ const trailPos = (pos - t + perimeter.length) % perimeter.length;
38
+ const trailIdx = perimeter[trailPos];
39
+ setCannonTarget(grid, trailIdx, 55, 80, 70 - t * 18, 1.0);
40
+ }
41
+ },
42
+ spiral: (grid, tick, attack) => {
43
+ for (let i = 0; i < NUM_CANNONS; i++) {
44
+ const row = Math.floor(i / GRID_SIZE);
45
+ const col = i % GRID_SIZE;
46
+ const cx = col - 3, cy = row - 3;
47
+ const angle = Math.atan2(cy, cx);
48
+ const dist = Math.sqrt(cx * cx + cy * cy);
49
+ const hue = (angle * 57.3 + dist * 40 + tick * 3) % 360;
50
+ setCannonTarget(grid, i, (hue + 360) % 360, 85, 75, attack);
51
+ }
52
+ },
53
+ rain: (grid, tick, attack) => {
54
+ for (let i = 0; i < NUM_CANNONS; i++) {
55
+ const row = Math.floor(i / GRID_SIZE);
56
+ const col = i % GRID_SIZE;
57
+ // Each column has a different phase offset (pseudo-random via prime)
58
+ const phase = (tick * 0.15 + col * 2.3 + col * col * 0.7) % GRID_SIZE;
59
+ const dist = Math.abs(row - phase);
60
+ const bright = dist < 1.5 ? 90 - dist * 30 : 10;
61
+ setCannonTarget(grid, i, 200 + col * 8, 70, bright, attack);
62
+ }
63
+ },
64
+ heartbeat: (grid, tick, attack) => {
65
+ // Double-pulse then rest (period ~120 ticks at 60fps = 2s)
66
+ const phase = tick % 120;
67
+ let brightness;
68
+ if (phase < 10)
69
+ brightness = 40 + phase * 5; // first pulse up
70
+ else if (phase < 20)
71
+ brightness = 90 - (phase - 10) * 5; // first pulse down
72
+ else if (phase < 30)
73
+ brightness = 40 + (phase - 20) * 4; // second pulse up
74
+ else if (phase < 40)
75
+ brightness = 80 - (phase - 30) * 4; // second pulse down
76
+ else
77
+ brightness = 40; // rest
78
+ for (let i = 0; i < NUM_CANNONS; i++) {
79
+ setCannonTarget(grid, i, 0, 90, brightness, attack);
80
+ }
81
+ }
82
+ };
83
+ function getPerimeterIndices() {
84
+ const indices = [];
85
+ // Top row
86
+ for (let c = 0; c < GRID_SIZE; c++)
87
+ indices.push(c);
88
+ // Right column (skip top-right corner)
89
+ for (let r = 1; r < GRID_SIZE; r++)
90
+ indices.push(r * GRID_SIZE + (GRID_SIZE - 1));
91
+ // Bottom row reversed (skip bottom-right corner)
92
+ for (let c = GRID_SIZE - 2; c >= 0; c--)
93
+ indices.push((GRID_SIZE - 1) * GRID_SIZE + c);
94
+ // Left column reversed (skip corners)
95
+ for (let r = GRID_SIZE - 2; r >= 1; r--)
96
+ indices.push(r * GRID_SIZE);
97
+ return indices;
98
+ }
99
+ export function getAnimationNames() {
100
+ return Object.keys(animations);
101
+ }
package/esm/grid.js CHANGED
@@ -48,17 +48,35 @@ function angleDelta(from, to) {
48
48
  let d = ((to - from + 540) % 360) - 180;
49
49
  return d;
50
50
  }
51
- export function setCannonTarget(grid, index, h, s, b) {
51
+ /**
52
+ * Set target for a single cannon.
53
+ * attack (0–1): how much of the new value to apply.
54
+ * 1.0 = full (instant snap to new target)
55
+ * 0.1 = soft (target blends 10% toward new value)
56
+ */
57
+ export function setCannonTarget(grid, index, h, s, b, attack = 1.0) {
52
58
  const c = grid[index];
53
- if (h !== undefined)
54
- c.targetH = h;
55
- if (s !== undefined)
56
- c.targetS = s;
57
- if (b !== undefined)
58
- c.targetB = b;
59
+ if (attack >= 1.0) {
60
+ if (h !== undefined)
61
+ c.targetH = h;
62
+ if (s !== undefined)
63
+ c.targetS = s;
64
+ if (b !== undefined)
65
+ c.targetB = b;
66
+ }
67
+ else {
68
+ if (h !== undefined) {
69
+ const dh = angleDelta(c.targetH, h);
70
+ c.targetH = (c.targetH + dh * attack + 360) % 360;
71
+ }
72
+ if (s !== undefined)
73
+ c.targetS = c.targetS + (s - c.targetS) * attack;
74
+ if (b !== undefined)
75
+ c.targetB = c.targetB + (b - c.targetB) * attack;
76
+ }
59
77
  }
60
- export function setAllTargets(grid, h, s, b) {
78
+ export function setAllTargets(grid, h, s, b, attack = 1.0) {
61
79
  for (let i = 0; i < grid.length; i++) {
62
- setCannonTarget(grid, i, h, s, b);
80
+ setCannonTarget(grid, i, h, s, b, attack);
63
81
  }
64
82
  }
package/esm/index.js CHANGED
@@ -1,2 +1,3 @@
1
- export { createGrid, tickGrid, setCannonTarget, setAllTargets, NUM_CANNONS, GRID_SIZE, DEFAULT_ALPHA } from './grid';
1
+ export { animations, getAnimationNames } from './animations';
2
+ export { createGrid, DEFAULT_ALPHA, GRID_SIZE, NUM_CANNONS, setAllTargets, setCannonTarget, tickGrid } from './grid';
2
3
  export { applyScene, scenes } from './scenes';
package/esm/server.js CHANGED
@@ -1,12 +1,26 @@
1
1
  import http from 'http';
2
- import { WebSocketServer, WebSocket } from 'ws';
3
- import { createGrid, tickGrid, setCannonTarget, setAllTargets } from './grid';
2
+ import { WebSocket, WebSocketServer } from 'ws';
3
+ import { animations } from './animations';
4
+ import { createGrid, DEFAULT_ALPHA, setAllTargets, setCannonTarget, tickGrid } from './grid';
4
5
  import { applyScene, scenes } from './scenes';
5
6
  import { getHTML } from './ui';
6
7
  const PORT = parseInt(process.env.PORT || '3000', 10);
7
8
  const TICK_MS = 1000 / 60; // 60fps interpolation
8
9
  const grid = createGrid();
9
- const server = http.createServer((_req, res) => {
10
+ let currentAlpha = DEFAULT_ALPHA;
11
+ let currentAttack = 1.0;
12
+ let currentAnimation = null;
13
+ let animationTick = 0;
14
+ // constructive.io brand mark — served as the favicon
15
+ const FAVICON_SVG = `<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
16
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M23.3315 21.7046V28.9348L26.2354 27.2232L29.8206 25.1157L36.9909 29.3307V37.761L30.1657 41.7731V41.785L22.9955 46L19.4102 43.8925L15.8343 41.7848V41.7749L12.5759 39.8595L9 37.7518V21.2924L12.5759 19.1847L15.8343 17.2694V9.25722L19.4102 7.14956L22.6685 5.23418V5.21521L26.2445 3.10755L29.8297 1L37 5.21499V13.6453L30.1657 17.6628V17.6873L23.3315 21.7046ZM16.16 17.8789L12.9168 19.7854L10.0443 21.4784L16.0542 25.0113L22.2948 21.4903L19.4101 19.7945L16.16 17.8789ZM23.6598 5.43249L29.7813 9.0309L35.955 5.40169L33.0743 3.70829L29.8297 1.80095L26.5853 3.70818L23.6598 5.43249ZM22.5139 38.2327L16.8333 41.5721L19.7511 43.2918L22.5185 44.9187L22.5196 38.2427L22.5139 38.2327ZM29.0399 33.6349L29.0153 33.5916L29.1105 33.5357L26.24 31.8482L23.3405 30.1438V33.546V36.9854L29.0399 33.6349ZM29.0998 9.43154L26.24 7.75041L22.9953 5.84307L19.7509 7.7503L16.8486 9.461L22.97 13.0595L29.0348 9.49437L29.0244 9.47595L29.0998 9.43154ZM16.5153 10.0661V13.4722V17.2854L22.5224 20.8167L22.5236 13.598L16.5153 10.0661ZM35.9458 29.5176L33.0651 27.8242L29.8205 25.9168L26.5761 27.8241L23.6705 29.5367L29.7919 33.1352L35.9458 29.5176ZM15.794 33.7218L12.5758 31.8299L9.68105 30.1237V33.5369V37.3517L12.9167 39.2589L15.7928 40.9496L15.794 33.7218ZM15.7954 25.7332L9.68116 22.1389V25.5074V29.3222L12.9168 31.2293L15.7943 32.9208L15.7954 25.7332Z" fill="#01A1FF"/>
17
+ </svg>`;
18
+ const server = http.createServer((req, res) => {
19
+ if (req.url === '/favicon.svg' || req.url === '/favicon.ico') {
20
+ res.writeHead(200, { 'Content-Type': 'image/svg+xml; charset=utf-8' });
21
+ res.end(FAVICON_SVG);
22
+ return;
23
+ }
10
24
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
11
25
  res.end(getHTML());
12
26
  });
@@ -41,30 +55,54 @@ wss.on('connection', (ws) => {
41
55
  function handleMessage(msg) {
42
56
  switch (msg.type) {
43
57
  case 'cannon':
44
- setCannonTarget(grid, msg.index, msg.h ?? undefined, msg.s ?? undefined, msg.b ?? undefined);
58
+ setCannonTarget(grid, msg.index, msg.h ?? undefined, msg.s ?? undefined, msg.b ?? undefined, currentAttack);
45
59
  break;
46
60
  case 'master_brightness':
47
- setAllTargets(grid, undefined, undefined, msg.value * 100);
61
+ setAllTargets(grid, undefined, undefined, msg.value * 100, currentAttack);
48
62
  break;
49
63
  case 'scene':
50
64
  if (msg.name && scenes[msg.name]) {
65
+ currentAnimation = null;
51
66
  applyScene(grid, msg.name);
52
67
  }
53
68
  break;
69
+ case 'animation':
70
+ if (msg.name && animations[msg.name]) {
71
+ currentAnimation = msg.name;
72
+ animationTick = 0;
73
+ }
74
+ else if (msg.name === 'stop') {
75
+ currentAnimation = null;
76
+ }
77
+ break;
54
78
  case 'selection':
55
79
  if (Array.isArray(msg.indices)) {
56
80
  for (const idx of msg.indices) {
57
81
  if (idx >= 0 && idx < grid.length) {
58
- setCannonTarget(grid, idx, msg.h ?? undefined, msg.s ?? undefined, msg.b ?? undefined);
82
+ setCannonTarget(grid, idx, msg.h ?? undefined, msg.s ?? undefined, msg.b ?? undefined, currentAttack);
59
83
  }
60
84
  }
61
85
  }
62
86
  break;
87
+ case 'smoothness':
88
+ if (typeof msg.value === 'number') {
89
+ currentAlpha = msg.value;
90
+ }
91
+ break;
92
+ case 'attack':
93
+ if (typeof msg.value === 'number') {
94
+ currentAttack = msg.value;
95
+ }
96
+ break;
63
97
  }
64
98
  }
65
99
  // Animation loop: tick interpolation and broadcast
66
100
  setInterval(() => {
67
- const changed = tickGrid(grid);
101
+ if (currentAnimation && animations[currentAnimation]) {
102
+ animations[currentAnimation](grid, animationTick, currentAttack);
103
+ animationTick++;
104
+ }
105
+ const changed = tickGrid(grid, currentAlpha);
68
106
  if (changed) {
69
107
  broadcastState();
70
108
  }
@@ -79,4 +117,4 @@ server.listen(PORT, '0.0.0.0', () => {
79
117
  console.log(` → http://localhost:${PORT}`);
80
118
  console.log('');
81
119
  });
82
- export { server, grid };
120
+ export { grid, server };