diff --git a/cursor/cursor.go b/cursor/cursor.go index a74c998..4e4504f 100644 --- a/cursor/cursor.go +++ b/cursor/cursor.go @@ -17,8 +17,9 @@ type CursorClient struct { ID int32 Conn *websocket.Conn Route string - X int16 - Y int16 + X float32 + Y float32 + Click bool Disconnected bool } @@ -91,7 +92,7 @@ func handleClient(client *CursorClient) { if otherClient.ID == client.ID { continue } if otherClient.Route != client.Route { continue } client.Send([]byte(fmt.Sprintf("join:%d", otherClient.ID))) - client.Send([]byte(fmt.Sprintf("pos:%d:%d:%d", otherClient.ID, otherClient.X, otherClient.Y))) + client.Send([]byte(fmt.Sprintf("pos:%d:%f:%f", otherClient.ID, otherClient.X, otherClient.Y))) } mutex.Unlock() broadcast <- CursorMessage{ @@ -118,15 +119,26 @@ func handleClient(client *CursorClient) { client.Route, []*CursorClient{ client }, } + case "click": + if len(args) < 2 { return } + click := 0 + if args[1][0] == '1' { + click = 1 + } + broadcast <- CursorMessage{ + []byte(fmt.Sprintf("click:%d:%d", client.ID, click)), + client.Route, + []*CursorClient{ client }, + } case "pos": if len(args) < 3 { return } - x, err := strconv.ParseInt(args[1], 10, 32) - y, err := strconv.ParseInt(args[2], 10, 32) + x, err := strconv.ParseFloat(args[1], 32) + y, err := strconv.ParseFloat(args[2], 32) if err != nil { return } - client.X = int16(x) - client.Y = int16(y) + client.X = float32(x) + client.Y = float32(y) broadcast <- CursorMessage{ - []byte(fmt.Sprintf("pos:%d:%d:%d", client.ID, client.X, client.Y)), + []byte(fmt.Sprintf("pos:%d:%f:%f", client.ID, client.X, client.Y)), client.Route, []*CursorClient{ client }, } diff --git a/public/script/cursor.js b/public/script/cursor.js index 059a2bd..188cdbb 100644 --- a/public/script/cursor.js +++ b/public/script/cursor.js @@ -1,13 +1,14 @@ 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 HTMLCanvasElement */ +let canvas; +/** @type CanvasRenderingContext2D */ +let ctx; /** @type Cursor */ let myCursor; /** @type Map */ @@ -19,11 +20,12 @@ let ws; let running = false; let lastUpdate = 0; +let cursorBoxHeight = 0; +let cursorBoxRadius = 0; +let cursorIDFontSize = 0; +let cursorCharFontSize = 0; + class Cursor { - /** @type HTMLElement */ - #element; - /** @type HTMLElement */ - #charElement; #funCharCooldown = CURSOR_FUNCHAR_RATE; /** @@ -32,46 +34,20 @@ class Cursor { * @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(); - - 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; + this.colour = randomColour(); + this.click = false; } /** @@ -80,64 +56,80 @@ class Cursor { 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) { + 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) { - const char = this.funChars.shift(); - char.destroy(); + this.funChars.shift(); } - const yOffset = -20; + const yOffset = -10 / innerHeight; const accelMultiplier = 0.002; this.funChars.push(new FunChar( - this.x + window.scrollX, this.y + window.scrollY + yOffset, + this.x, this.y + 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 - 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); } - 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); - }); - } + const lightTheme = matchMedia && matchMedia('(prefers-color-scheme: light)').matches; - setID(id) { - this.id = id; - this.#element.id = 'cursor' + id; - this.#element.innerText = '0x' + id.toString(16).slice(0, 8).padStart(8, '0'); - } + if (lightTheme) + ctx.filter = 'saturate(5) brightness(0.8)'; - /** - * @param {boolean} active - */ - click(active) { - if (active) - this.#element.classList.add('click'); - else - this.#element.classList.remove('click'); + 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 = ''; } } @@ -152,20 +144,12 @@ class FunChar { 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.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; - - 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); } /** @@ -177,15 +161,27 @@ class FunChar { 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.ya = Math.min(this.ya + 0.000001 * 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)`; - } + const x = this.x * innerWidth - scrollX; + const y = this.y * innerHeight - scrollY; - destroy() { - this.element.remove(); + 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(); } } @@ -205,18 +201,37 @@ function randomColour() { /** * @param {MouseEvent} event */ +let mouseMoveLock = false; +const mouseMoveCooldown = 1000/30; 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); + + 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); + myCursor.click = true; + if (ws && ws.readyState == WebSocket.OPEN) + ws.send('click:1'); } function handleMouseUp() { - myCursor.click(false); + myCursor.click = false; + if (ws && ws.readyState == WebSocket.OPEN) + ws.send('click:0'); } /** @@ -238,56 +253,69 @@ function handleKeyUp() { } /** - * @param {number} time + * @param {number} timestamp */ -function updateCursors(time) { +function update(timestamp) { if (!running) return; - const deltaTime = time - lastUpdate; + const deltaTime = timestamp - lastUpdate; + lastUpdate = timestamp; + + ctx.clearRect(0, 0, canvas.width, canvas.height); cursors.forEach(cursor => { cursor.update(deltaTime); }); - lastUpdate = time; - requestAnimationFrame(updateCursors); + 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; - cursorContainer = document.createElement('div'); - cursorContainer.id = 'cursors'; - document.body.appendChild(cursorContainer); + canvas = document.createElement('canvas'); + canvas.id = 'cursors'; + handleWindowResize(); + document.body.appendChild(canvas); - myCursor = new Cursor("You!", window.innerWidth / 2, window.innerHeight / 2); + 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(updateCursors); + requestAnimationFrame(update); - ws = new WebSocket("/cursor-ws"); + ws = new WebSocket('/cursor-ws'); + ws.addEventListener('open', () => { + console.log('Cursor connected to server successfully.'); - ws.addEventListener("open", () => { - console.log("Cursor connected to server successfully."); - - ws.send(`loc:${window.location.pathname}`); + ws.send(`loc:${location.pathname}`); }); - ws.addEventListener("error", error => { - console.error("Cursor WebSocket error:", error); + ws.addEventListener('error', error => { + console.error('Cursor WebSocket error:', error); }); - ws.addEventListener("close", () => { - console.log("Cursor connection closed."); + ws.addEventListener('close', () => { + console.log('Cursor connection closed.'); }); - - ws.addEventListener("message", event => { - const args = String(event.data).split(":"); + ws.addEventListener('message', event => { + const args = String(event.data).split(':'); if (args.length == 0) return; let id = 0; @@ -300,7 +328,7 @@ function cursorSetup() { switch (args[0]) { case 'id': { - myCursor.setID(Number(args[1])); + myCursor.id = Number(args[1]); break; } case 'join': { @@ -310,7 +338,6 @@ function cursorSetup() { } case 'leave': { if (!cursor || cursor === myCursor) break; - cursors.get(id).destroy(); cursors.delete(id); break; } @@ -324,33 +351,37 @@ function cursorSetup() { cursor.msg = ''; break; } + case 'click': { + if (!cursor || cursor === myCursor) break; + cursor.click = args[2] == '1'; + break; + } case 'pos': { if (!cursor || cursor === myCursor) break; - cursor.move(Number(args[2]), Number(args[3])); + cursor.x = Number(args[2]); + cursor.y = Number(args[3]); break; } default: { - console.warn("Cursor: Unknown command received from server:", args[0]); + console.warn('Cursor: Unknown command received from server:', args[0]); break; } } }); - console.log(`Cursor tracking @ ${window.location.pathname}`); + 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.forEach(cursor => { - cursor.destroy(); - }); cursors.clear(); myCursor = null; diff --git a/public/style/cursor.css b/public/style/cursor.css index 8561716..73019ee 100644 --- a/public/style/cursor.css +++ b/public/style/cursor.css @@ -1,64 +1,9 @@ -#cursors i.cursor { - position: fixed; - padding: 4px; - - display: block; - z-index: 1000; - - background: #0008; - border: 2px solid #808080; - border-radius: 2px; - - font-style: normal; - font-size: 10px; - font-weight: bold; - white-space: nowrap; -} - -#cursors i.cursor.click { - color: var(--on-background) !important; - border-color: var(--on-background) !important; -} - -#cursors i.char { - position: absolute; - margin: 0; - font-style: normal; - font-size: 20px; - font-weight: bold; - color: var(--on-background); -} - -#cursors i.funchar { - position: absolute; - margin: 0; - - display: block; - z-index: 1000; - - font-style: normal; - font-size: 20px; - font-weight: bold; - color: var(--on-background); -} - -#cursors { - width: 100vw; - height: 100vh; +canvas#cursors { position: fixed; top: 0; left: 0; - - z-index: 1000; - overflow: clip; - - user-select: none; + width: 100vw; + height: 100vh; pointer-events: none; -} - -@media (prefers-color-scheme: light) { - #cursors i.cursor { - filter: saturate(5) brightness(0.8); - background: #fff8; - } + z-index: 100; }