Something went wrong. Try again.
home to your local SPACEGIRL 💫 arimelody.space
Something went wrong. Try again.
12 kB · 403 lines
JavaScript
at dev
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404import 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<number, Cursor> */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<FunChar> */ 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 = id; 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);
ctx.clearRect(0, 0, canvas.width, canvas.height); cursors.clear(); myCursor = null;
console.log(`Cursor no longer tracking.`); running = false;}
if (config.cursor === true) { cursorSetup();}
config.addListener('cursor', enabled => { if (enabled === true) cursorSetup(); else cursorDestroy();});