home to your local SPACEGIRL 馃挮 arimelody.space
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

at dev 403 lines 12 kB view raw
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});