import config from './config.js'; const CURSOR_TICK_RATE = 1000/30; const CURSOR_LERP_RATE = 1/100; const CURSOR_FUNCHAR_RATE = 20; const CURSOR_CHAR_MAX_LIFE = 5000; const CURSOR_MAX_CHARS = 64; /** @type HTMLElement */ let cursorContainer; /** @type Cursor */ let myCursor; /** @type Map */ let cursors = new Map(); /** @type WebSocket */ let ws; let running = false; let lastUpdate = 0; class Cursor { /** @type HTMLElement */ #element; /** @type HTMLElement */ #charElement; #funCharCooldown = CURSOR_FUNCHAR_RATE; /** * @param {string} id * @param {number} x * @param {number} y */ constructor(id, x, y) { // real coordinates (canonical) this.x = x; this.y = y; // render coordinates (interpolated) this.rx = x; this.ry = y; this.msg = ''; this.funChars = new Array(); const element = document.createElement('i'); element.classList.add('cursor'); const colour = randomColour(); element.style.borderColor = colour; element.style.color = colour; cursorContainer.appendChild(element); this.#element = element; const char = document.createElement('i'); char.className = 'char'; cursorContainer.appendChild(char); this.#charElement = char; this.setID(id); } destroy() { this.#element.remove(); this.#charElement.remove(); this.funChars.forEach(char => { char.destroy(); }); } /** * @param {number} x * @param {number} y */ move(x, y) { this.x = x; this.y = y; } /** * @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; this.#element.style.left = this.rx + 'px'; this.#element.style.top = this.ry + 'px'; this.#charElement.style.left = this.rx + 'px'; this.#charElement.style.top = (this.ry - 24) + 'px'; if (this.#funCharCooldown > 0) this.#funCharCooldown -= deltaTime; if (this.msg.length > 0) { if (config.cursorFunMode === true) { if (this.#funCharCooldown <= 0) { this.#funCharCooldown = CURSOR_FUNCHAR_RATE; if (this.funChars.length >= CURSOR_MAX_CHARS) { const char = this.funChars.shift(); char.destroy(); } const yOffset = -20; const accelMultiplier = 0.002; this.funChars.push(new FunChar( this.x + window.scrollX, this.y + window.scrollY + yOffset, (this.x - this.rx) * accelMultiplier, (this.y - this.ry) * accelMultiplier, this.msg)); } } else { this.#charElement.innerText = this.msg; } } else { this.#charElement.innerText = ''; } this.funChars.forEach(char => { if (char.life > CURSOR_CHAR_MAX_LIFE || char.y > document.body.clientHeight || char.x < 0 || char.x > document.body.clientWidth ) { this.funChars = this.funChars.filter(c => c !== this); char.destroy(); return; } char.update(deltaTime); }); } setID(id) { this.id = id; this.#element.id = 'cursor' + id; this.#element.innerText = '0x' + id.toString(16).slice(0, 8).padStart(8, '0'); } /** * @param {boolean} active */ click(active) { if (active) this.#element.classList.add('click'); else this.#element.classList.remove('click'); } } 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() * .2 - .1; this.ya = ya + Math.random() * -.25; this.r = this.xa * 100; this.ra = this.r * 0.01; this.life = 0; const char = document.createElement('i'); char.className = 'funchar'; char.innerText = text; char.style.left = x + 'px'; char.style.top = y + 'px'; char.style.transform = `rotate(${this.r}deg)`; this.element = char; cursorContainer.appendChild(this.element); } /** * @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.0005 * deltaTime, 10); this.element.style.left = (this.x - window.scrollX) + 'px'; this.element.style.top = (this.y - window.scrollY) + 'px'; this.element.style.transform = `rotate(${this.r}deg)`; } destroy() { this.element.remove(); } } /** * @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 */ function handleMouseMove(event) { if (!myCursor) return; if (ws && ws.readyState == WebSocket.OPEN) ws.send(`pos:${event.x}:${event.y}`); myCursor.move(event.x, event.y); } function handleMouseDown() { myCursor.click(true); } function handleMouseUp() { myCursor.click(false); } /** * @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} time */ function updateCursors(time) { if (!running) return; const deltaTime = time - lastUpdate; cursors.forEach(cursor => { cursor.update(deltaTime); }); lastUpdate = time; requestAnimationFrame(updateCursors); } function cursorSetup() { if (running) throw new Error('Only one instance of Cursor can run at a time.'); running = true; cursorContainer = document.createElement('div'); cursorContainer.id = 'cursors'; document.body.appendChild(cursorContainer); myCursor = new Cursor("You!", window.innerWidth / 2, window.innerHeight / 2); cursors.set(0, myCursor); document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mousedown', handleMouseDown); document.addEventListener('mouseup', handleMouseUp); document.addEventListener('keypress', handleKeyPress); document.addEventListener('keyup', handleKeyUp); requestAnimationFrame(updateCursors); ws = new WebSocket("/cursor-ws"); ws.addEventListener("open", () => { console.log("Cursor connected to server successfully."); ws.send(`loc:${window.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.setID(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.get(id).destroy(); 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 'pos': { if (!cursor || cursor === myCursor) break; cursor.move(Number(args[2]), Number(args[3])); break; } default: { console.warn("Cursor: Unknown command received from server:", args[0]); break; } } }); console.log(`Cursor tracking @ ${window.location.pathname}`); } function cursorDestroy() { if (!running) return; document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mousedown', handleMouseDown); document.removeEventListener('mouseup', handleMouseUp); document.removeEventListener('keypress', handleKeyPress); document.removeEventListener('keyup', handleKeyUp); cursors.forEach(cursor => { cursor.destroy(); }); 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(); });