home to your local SPACEGIRL 馃挮
arimelody.space
1import config from './config.js';
2
3const CURSOR_LERP_RATE = 1/100;
4const CURSOR_FUNCHAR_RATE = 20;
5const CURSOR_CHAR_MAX_LIFE = 5000;
6const CURSOR_MAX_CHARS = 64;
7
8/** @type HTMLCanvasElement */
9let canvas;
10/** @type CanvasRenderingContext2D */
11let ctx;
12/** @type Cursor */
13let myCursor;
14/** @type Map<number, Cursor> */
15let cursors = new Map();
16
17/** @type WebSocket */
18let ws;
19
20let running = false;
21let lastUpdate = 0;
22
23let cursorBoxHeight = 0;
24let cursorBoxRadius = 0;
25let cursorIDFontSize = 0;
26let cursorCharFontSize = 0;
27
28class Cursor {
29 #funCharCooldown = CURSOR_FUNCHAR_RATE;
30
31 /**
32 * @param {string} id
33 * @param {number} x
34 * @param {number} y
35 */
36 constructor(id, x, y) {
37 this.id = id;
38
39 // real coordinates (canonical)
40 this.x = x;
41 this.y = y;
42 // render coordinates (interpolated)
43 this.rx = x;
44 this.ry = y;
45
46 this.msg = '';
47 /** @type Array<FunChar> */
48 this.funChars = new Array();
49 this.colour = randomColour();
50 this.click = false;
51 }
52
53 /**
54 * @param {number} deltaTime
55 */
56 update(deltaTime) {
57 this.rx += (this.x - this.rx) * CURSOR_LERP_RATE * deltaTime;
58 this.ry += (this.y - this.ry) * CURSOR_LERP_RATE * deltaTime;
59
60 if (this.#funCharCooldown > 0)
61 this.#funCharCooldown -= deltaTime;
62
63 const x = this.rx * innerWidth - scrollX;
64 const y = this.ry * innerHeight - scrollY;
65 const onBackground = ctx.fillStyle = getComputedStyle(document.body).getPropertyValue('--on-background');
66
67 if (config.cursorFunMode === true) {
68 if (this.msg.length > 0) {
69 if (this.#funCharCooldown <= 0) {
70 this.#funCharCooldown = CURSOR_FUNCHAR_RATE;
71 if (this.funChars.length >= CURSOR_MAX_CHARS) {
72 this.funChars.shift();
73 }
74 const yOffset = -10 / innerHeight;
75 const accelMultiplier = 0.002;
76 this.funChars.push(new FunChar(
77 this.x, this.y + yOffset,
78 (this.x - this.rx) * accelMultiplier, (this.y - this.ry) * accelMultiplier,
79 this.msg));
80 }
81 }
82
83 this.funChars.forEach(char => {
84 if (char.life > CURSOR_CHAR_MAX_LIFE ||
85 char.y - scrollY > innerHeight ||
86 char.x < 0 ||
87 char.x * innerWidth - scrollX > innerWidth
88 ) {
89 this.funChars = this.funChars.filter(c => c !== this);
90 return;
91 }
92 char.update(deltaTime);
93 });
94 } else if (this.msg.length > 0) {
95 ctx.font = 'normal bold ' + cursorCharFontSize + 'px monospace';
96 ctx.fillStyle = onBackground;
97 ctx.fillText(
98 this.msg,
99 (x + 6) * devicePixelRatio,
100 (y + -8) * devicePixelRatio);
101 }
102
103 const lightTheme = matchMedia && matchMedia('(prefers-color-scheme: light)').matches;
104
105 if (lightTheme)
106 ctx.filter = 'saturate(5) brightness(0.8)';
107
108 const idText = '0x' + this.id.toString(16).padStart(8, '0');
109 const colour = this.click ? onBackground : this.colour;
110
111 ctx.beginPath();
112 ctx.roundRect(
113 (x) * devicePixelRatio,
114 (y) * devicePixelRatio,
115 (12 + 7.2 * idText.length) * devicePixelRatio,
116 cursorBoxHeight,
117 cursorBoxRadius);
118 ctx.closePath();
119 ctx.fillStyle = lightTheme ? '#fff8' : '#0008';
120 ctx.fill();
121 ctx.strokeStyle = colour;
122 ctx.lineWidth = devicePixelRatio;
123 ctx.stroke();
124
125 ctx.font = cursorIDFontSize + 'px monospace';
126 ctx.fillStyle = colour;
127 ctx.fillText(
128 idText,
129 (x + 6) * devicePixelRatio,
130 (y + 14) * devicePixelRatio);
131
132 ctx.filter = '';
133 }
134}
135
136class FunChar {
137 /**
138 * @param {number} x
139 * @param {number} y
140 * @param {number} xa
141 * @param {number} ya
142 * @param {string} text
143 */
144 constructor(x, y, xa, ya, text) {
145 this.x = x;
146 this.y = y;
147 this.xa = xa + Math.random() * .0005 - .00025;
148 this.ya = ya + Math.random() * -.00025;
149 this.r = this.xa * 1000;
150 this.ra = this.r * 0.01;
151 this.text = text;
152 this.life = 0;
153 }
154
155 /**
156 * @param {number} deltaTime
157 */
158 update(deltaTime) {
159 this.life += deltaTime;
160
161 this.x += this.xa * deltaTime;
162 this.y += this.ya * deltaTime;
163 this.r += this.ra * deltaTime;
164 this.ya = Math.min(this.ya + 0.000001 * deltaTime, 10);
165
166 const x = this.x * innerWidth - scrollX;
167 const y = this.y * innerHeight - scrollY;
168
169 const translateOffset = {
170 x: (x + 7.2) * devicePixelRatio,
171 y: (y - 7.2) * devicePixelRatio,
172 };
173 ctx.translate(translateOffset.x, translateOffset.y);
174 ctx.rotate(this.r);
175 ctx.translate(-translateOffset.x, -translateOffset.y);
176
177 ctx.font = 'normal bold ' + cursorCharFontSize + 'px monospace';
178 ctx.fillStyle = getComputedStyle(document.body).getPropertyValue('--on-background');
179 ctx.fillText(
180 this.text,
181 x * devicePixelRatio,
182 y * devicePixelRatio);
183
184 ctx.resetTransform();
185 }
186}
187
188/**
189 * @returns string
190 */
191function randomColour() {
192 const min = 128;
193 const range = 100;
194 const red = Math.round((min + Math.random() * range)).toString(16);
195 const green = Math.round((min + Math.random() * range)).toString(16);
196 const blue = Math.round((min + Math.random() * range)).toString(16);
197
198 return '#' + red + green + blue;
199}
200
201/**
202 * @param {MouseEvent} event
203 */
204let mouseMoveLock = false;
205const mouseMoveCooldown = 1000/30;
206function handleMouseMove(event) {
207 if (!myCursor) return;
208
209 const x = event.pageX / innerWidth;
210 const y = event.pageY / innerHeight;
211 const f = 10000; // four digit floating-point precision
212
213 if (!mouseMoveLock) {
214 mouseMoveLock = true;
215 if (ws && ws.readyState == WebSocket.OPEN)
216 ws.send(`pos:${Math.round(x * f) / f}:${Math.round(y * f) / f}`);
217 setTimeout(() => {
218 mouseMoveLock = false;
219 }, mouseMoveCooldown);
220 }
221
222 myCursor.x = x;
223 myCursor.y = y;
224}
225
226function handleMouseDown() {
227 myCursor.click = true;
228 if (ws && ws.readyState == WebSocket.OPEN)
229 ws.send('click:1');
230}
231function handleMouseUp() {
232 myCursor.click = false;
233 if (ws && ws.readyState == WebSocket.OPEN)
234 ws.send('click:0');
235}
236
237/**
238 * @param {KeyboardEvent} event
239 */
240function handleKeyPress(event) {
241 if (event.key.length > 1) return;
242 if (event.metaKey || event.ctrlKey) return;
243 if (myCursor.msg === event.key) return;
244 if (ws && ws.readyState == WebSocket.OPEN)
245 ws.send(`char:${event.key}`);
246 myCursor.msg = event.key;
247}
248
249function handleKeyUp() {
250 if (ws && ws.readyState == WebSocket.OPEN)
251 ws.send(`nochar`);
252 myCursor.msg = '';
253}
254
255/**
256 * @param {number} timestamp
257 */
258function update(timestamp) {
259 if (!running) return;
260
261 const deltaTime = timestamp - lastUpdate;
262 lastUpdate = timestamp;
263
264 ctx.clearRect(0, 0, canvas.width, canvas.height);
265
266 cursors.forEach(cursor => {
267 cursor.update(deltaTime);
268 });
269
270 requestAnimationFrame(update);
271}
272
273function handleWindowResize() {
274 canvas.width = innerWidth * devicePixelRatio;
275 canvas.height = innerHeight * devicePixelRatio;
276 cursorBoxHeight = 20 * devicePixelRatio;
277 cursorBoxRadius = 4 * devicePixelRatio;
278 cursorIDFontSize = 12 * devicePixelRatio;
279 cursorCharFontSize = 20 * devicePixelRatio;
280}
281
282function cursorSetup() {
283 if (running) throw new Error('Only one instance of Cursor can run at a time.');
284 running = true;
285
286 canvas = document.createElement('canvas');
287 canvas.id = 'cursors';
288 handleWindowResize();
289 document.body.appendChild(canvas);
290
291 ctx = canvas.getContext('2d');
292
293 myCursor = new Cursor('You!', innerWidth / 2, innerHeight / 2);
294 cursors.set(0, myCursor);
295
296 addEventListener('resize', handleWindowResize);
297 document.addEventListener('mousemove', handleMouseMove);
298 document.addEventListener('mousedown', handleMouseDown);
299 document.addEventListener('mouseup', handleMouseUp);
300 document.addEventListener('keypress', handleKeyPress);
301 document.addEventListener('keyup', handleKeyUp);
302
303 requestAnimationFrame(update);
304
305 ws = new WebSocket('/cursor-ws');
306 ws.addEventListener('open', () => {
307 console.log('Cursor connected to server successfully.');
308
309 ws.send(`loc:${location.pathname}`);
310 });
311 ws.addEventListener('error', error => {
312 console.error('Cursor WebSocket error:', error);
313 });
314 ws.addEventListener('close', () => {
315 console.log('Cursor connection closed.');
316 });
317 ws.addEventListener('message', event => {
318 const args = String(event.data).split(':');
319 if (args.length == 0) return;
320
321 let id = 0;
322 /** @type Cursor */
323 let cursor;
324 if (args.length > 1) {
325 id = Number(args[1]);
326 cursor = cursors.get(id);
327 }
328
329 switch (args[0]) {
330 case 'id': {
331 myCursor.id = id;
332 break;
333 }
334 case 'join': {
335 if (id === myCursor.id) break;
336 cursors.set(id, new Cursor(id, 0, 0));
337 break;
338 }
339 case 'leave': {
340 if (!cursor || cursor === myCursor) break;
341 cursors.delete(id);
342 break;
343 }
344 case 'char': {
345 if (!cursor || cursor === myCursor) break;
346 cursor.msg = args[2];
347 break;
348 }
349 case 'nochar': {
350 if (!cursor || cursor === myCursor) break;
351 cursor.msg = '';
352 break;
353 }
354 case 'click': {
355 if (!cursor || cursor === myCursor) break;
356 cursor.click = args[2] == '1';
357 break;
358 }
359 case 'pos': {
360 if (!cursor || cursor === myCursor) break;
361 cursor.x = Number(args[2]);
362 cursor.y = Number(args[3]);
363 break;
364 }
365 default: {
366 console.warn('Cursor: Unknown command received from server:', args[0]);
367 break;
368 }
369 }
370 });
371
372 console.log(`Cursor tracking @ ${location.pathname}`);
373}
374
375function cursorDestroy() {
376 if (!running) return;
377
378 removeEventListener('resize', handleWindowResize);
379 document.removeEventListener('mousemove', handleMouseMove);
380 document.removeEventListener('mousedown', handleMouseDown);
381 document.removeEventListener('mouseup', handleMouseUp);
382 document.removeEventListener('keypress', handleKeyPress);
383 document.removeEventListener('keyup', handleKeyUp);
384
385 ctx.clearRect(0, 0, canvas.width, canvas.height);
386
387 cursors.clear();
388 myCursor = null;
389
390 console.log(`Cursor no longer tracking.`);
391 running = false;
392}
393
394if (config.cursor === true) {
395 cursorSetup();
396}
397
398config.addListener('cursor', enabled => {
399 if (enabled === true)
400 cursorSetup();
401 else
402 cursorDestroy();
403});