import config from './config.js'; const CURSOR_LERP_RATE = 1/100; const CURSOR_FUNCHAR_RATE = 20; const CURSOR_CHAR_MAX_LIFE = 5000; const CURSOR_MAX_CHARS = 64; /** @type HTMLCanvasElement */ let canvas; /** @type CanvasRenderingContext2D */ let ctx; /** @type Cursor */ let myCursor; /** @type Map */ let cursors = new Map(); /** @type WebSocket */ let ws; let running = false; let lastUpdate = 0; let cursorBoxHeight = 0; let cursorBoxRadius = 0; let cursorIDFontSize = 0; let cursorCharFontSize = 0; class Cursor { #funCharCooldown = CURSOR_FUNCHAR_RATE; /** * @param {string} id * @param {number} x * @param {number} y */ constructor(id, x, y) { this.id = id; // real coordinates (canonical) this.x = x; this.y = y; // render coordinates (interpolated) this.rx = x; this.ry = y; this.msg = ''; /** @type Array */ this.funChars = new Array(); this.colour = randomColour(); this.click = false; } /** * @param {number} deltaTime */ update(deltaTime) { this.rx += (this.x - this.rx) * CURSOR_LERP_RATE * deltaTime; this.ry += (this.y - this.ry) * CURSOR_LERP_RATE * deltaTime; if (this.#funCharCooldown > 0) this.#funCharCooldown -= deltaTime; const x = this.rx * innerWidth - scrollX; const y = this.ry * innerHeight - scrollY; const onBackground = ctx.fillStyle = getComputedStyle(document.body).getPropertyValue('--on-background'); if (config.cursorFunMode === true) { if (this.msg.length > 0) { if (this.#funCharCooldown <= 0) { this.#funCharCooldown = CURSOR_FUNCHAR_RATE; if (this.funChars.length >= CURSOR_MAX_CHARS) { this.funChars.shift(); } const yOffset = -10 / innerHeight; const accelMultiplier = 0.002; this.funChars.push(new FunChar( this.x, this.y + yOffset, (this.x - this.rx) * accelMultiplier, (this.y - this.ry) * accelMultiplier, this.msg)); } } this.funChars.forEach(char => { if (char.life > CURSOR_CHAR_MAX_LIFE || char.y - scrollY > innerHeight || char.x < 0 || char.x * innerWidth - scrollX > innerWidth ) { this.funChars = this.funChars.filter(c => c !== this); return; } char.update(deltaTime); }); } else if (this.msg.length > 0) { ctx.font = 'normal bold ' + cursorCharFontSize + 'px monospace'; ctx.fillStyle = onBackground; ctx.fillText( this.msg, (x + 6) * devicePixelRatio, (y + -8) * devicePixelRatio); } const lightTheme = matchMedia && matchMedia('(prefers-color-scheme: light)').matches; if (lightTheme) ctx.filter = 'saturate(5) brightness(0.8)'; const idText = '0x' + this.id.toString(16).padStart(8, '0'); const colour = this.click ? onBackground : this.colour; ctx.beginPath(); ctx.roundRect( (x) * devicePixelRatio, (y) * devicePixelRatio, (12 + 7.2 * idText.length) * devicePixelRatio, cursorBoxHeight, cursorBoxRadius); ctx.closePath(); ctx.fillStyle = lightTheme ? '#fff8' : '#0008'; ctx.fill(); ctx.strokeStyle = colour; ctx.lineWidth = devicePixelRatio; ctx.stroke(); ctx.font = cursorIDFontSize + 'px monospace'; ctx.fillStyle = colour; ctx.fillText( idText, (x + 6) * devicePixelRatio, (y + 14) * devicePixelRatio); ctx.filter = ''; } } class FunChar { /** * @param {number} x * @param {number} y * @param {number} xa * @param {number} ya * @param {string} text */ constructor(x, y, xa, ya, text) { this.x = x; this.y = y; this.xa = xa + Math.random() * .0005 - .00025; this.ya = ya + Math.random() * -.00025; this.r = this.xa * 1000; this.ra = this.r * 0.01; this.text = text; this.life = 0; } /** * @param {number} deltaTime */ update(deltaTime) { this.life += deltaTime; this.x += this.xa * deltaTime; this.y += this.ya * deltaTime; this.r += this.ra * deltaTime; this.ya = Math.min(this.ya + 0.000001 * deltaTime, 10); const x = this.x * innerWidth - scrollX; const y = this.y * innerHeight - scrollY; const translateOffset = { x: (x + 7.2) * devicePixelRatio, y: (y - 7.2) * devicePixelRatio, }; ctx.translate(translateOffset.x, translateOffset.y); ctx.rotate(this.r); ctx.translate(-translateOffset.x, -translateOffset.y); ctx.font = 'normal bold ' + cursorCharFontSize + 'px monospace'; ctx.fillStyle = getComputedStyle(document.body).getPropertyValue('--on-background'); ctx.fillText( this.text, x * devicePixelRatio, y * devicePixelRatio); ctx.resetTransform(); } } /** * @returns string */ function randomColour() { const min = 128; const range = 100; const red = Math.round((min + Math.random() * range)).toString(16); const green = Math.round((min + Math.random() * range)).toString(16); const blue = Math.round((min + Math.random() * range)).toString(16); return '#' + red + green + blue; } /** * @param {MouseEvent} event */ let mouseMoveLock = false; const mouseMoveCooldown = 1000/30; function handleMouseMove(event) { if (!myCursor) return; const x = event.pageX / innerWidth; const y = event.pageY / innerHeight; const f = 10000; // four digit floating-point precision if (!mouseMoveLock) { mouseMoveLock = true; if (ws && ws.readyState == WebSocket.OPEN) ws.send(`pos:${Math.round(x * f) / f}:${Math.round(y * f) / f}`); setTimeout(() => { mouseMoveLock = false; }, mouseMoveCooldown); } myCursor.x = x; myCursor.y = y; } function handleMouseDown() { myCursor.click = true; if (ws && ws.readyState == WebSocket.OPEN) ws.send('click:1'); } function handleMouseUp() { myCursor.click = false; if (ws && ws.readyState == WebSocket.OPEN) ws.send('click:0'); } /** * @param {KeyboardEvent} event */ function handleKeyPress(event) { if (event.key.length > 1) return; if (event.metaKey || event.ctrlKey) return; if (myCursor.msg === event.key) return; if (ws && ws.readyState == WebSocket.OPEN) ws.send(`char:${event.key}`); myCursor.msg = event.key; } function handleKeyUp() { if (ws && ws.readyState == WebSocket.OPEN) ws.send(`nochar`); myCursor.msg = ''; } /** * @param {number} timestamp */ function update(timestamp) { if (!running) return; const deltaTime = timestamp - lastUpdate; lastUpdate = timestamp; ctx.clearRect(0, 0, canvas.width, canvas.height); cursors.forEach(cursor => { cursor.update(deltaTime); }); requestAnimationFrame(update); } function handleWindowResize() { canvas.width = innerWidth * devicePixelRatio; canvas.height = innerHeight * devicePixelRatio; cursorBoxHeight = 20 * devicePixelRatio; cursorBoxRadius = 4 * devicePixelRatio; cursorIDFontSize = 12 * devicePixelRatio; cursorCharFontSize = 20 * devicePixelRatio; } function cursorSetup() { if (running) throw new Error('Only one instance of Cursor can run at a time.'); running = true; canvas = document.createElement('canvas'); canvas.id = 'cursors'; handleWindowResize(); document.body.appendChild(canvas); ctx = canvas.getContext('2d'); myCursor = new Cursor('You!', innerWidth / 2, innerHeight / 2); cursors.set(0, myCursor); addEventListener('resize', handleWindowResize); document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mousedown', handleMouseDown); document.addEventListener('mouseup', handleMouseUp); document.addEventListener('keypress', handleKeyPress); document.addEventListener('keyup', handleKeyUp); requestAnimationFrame(update); ws = new WebSocket('/cursor-ws'); ws.addEventListener('open', () => { console.log('Cursor connected to server successfully.'); ws.send(`loc:${location.pathname}`); }); ws.addEventListener('error', error => { console.error('Cursor WebSocket error:', error); }); ws.addEventListener('close', () => { console.log('Cursor connection closed.'); }); ws.addEventListener('message', event => { const args = String(event.data).split(':'); if (args.length == 0) return; let id = 0; /** @type Cursor */ let cursor; if (args.length > 1) { id = Number(args[1]); cursor = cursors.get(id); } switch (args[0]) { case 'id': { myCursor.id = Number(args[1]); break; } case 'join': { if (id === myCursor.id) break; cursors.set(id, new Cursor(id, 0, 0)); break; } case 'leave': { if (!cursor || cursor === myCursor) break; cursors.delete(id); break; } case 'char': { if (!cursor || cursor === myCursor) break; cursor.msg = args[2]; break; } case 'nochar': { if (!cursor || cursor === myCursor) break; cursor.msg = ''; break; } case 'click': { if (!cursor || cursor === myCursor) break; cursor.click = args[2] == '1'; break; } case 'pos': { if (!cursor || cursor === myCursor) break; cursor.x = Number(args[2]); cursor.y = Number(args[3]); break; } default: { console.warn('Cursor: Unknown command received from server:', args[0]); break; } } }); console.log(`Cursor tracking @ ${location.pathname}`); } function cursorDestroy() { if (!running) return; removeEventListener('resize', handleWindowResize); document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mousedown', handleMouseDown); document.removeEventListener('mouseup', handleMouseUp); document.removeEventListener('keypress', handleKeyPress); document.removeEventListener('keyup', handleKeyUp); cursors.clear(); myCursor = null; cursorContainer.remove(); console.log(`Cursor no longer tracking.`); running = false; } if (config.cursor === true) { cursorSetup(); } config.addListener('cursor', enabled => { if (enabled === true) cursorSetup(); else cursorDestroy(); });