diff --git a/.gitignore b/.gitignore index 2e63958..9bdf788 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,3 @@ docker-compose*.yml !docker-compose.example.yml config*.toml arimelody-web -arimelody-web.tar.gz diff --git a/Makefile b/Makefile deleted file mode 100644 index 11e565a..0000000 --- a/Makefile +++ /dev/null @@ -1,12 +0,0 @@ -EXEC = arimelody-web - -.PHONY: $(EXEC) - -$(EXEC): - GOOS=linux GOARCH=amd64 go build -o $(EXEC) - -bundle: $(EXEC) - tar czf $(EXEC).tar.gz $(EXEC) admin/components/ admin/views/ admin/static/ views/ public/ schema-migration/ - -clean: - rm $(EXEC) $(EXEC).tar.gz diff --git a/bundle.sh b/bundle.sh new file mode 100755 index 0000000..277bb9c --- /dev/null +++ b/bundle.sh @@ -0,0 +1,9 @@ +#!/bin/bash +# simple script to pack up arimelody.me for production distribution + +if [ ! -f arimelody-web ]; then + echo "[FATAL] ./arimelody-web not found! please run \`go build -o arimelody-web\` first." + exit 1 +fi + +tar czf arimelody-web.tar.gz arimelody-web admin/components/ admin/views/ admin/static/ views/ public/ schema-migration/ diff --git a/cursor/cursor.go b/cursor/cursor.go index 4e4504f..a74c998 100644 --- a/cursor/cursor.go +++ b/cursor/cursor.go @@ -17,9 +17,8 @@ type CursorClient struct { ID int32 Conn *websocket.Conn Route string - X float32 - Y float32 - Click bool + X int16 + Y int16 Disconnected bool } @@ -92,7 +91,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:%f:%f", otherClient.ID, otherClient.X, otherClient.Y))) + client.Send([]byte(fmt.Sprintf("pos:%d:%d:%d", otherClient.ID, otherClient.X, otherClient.Y))) } mutex.Unlock() broadcast <- CursorMessage{ @@ -119,26 +118,15 @@ 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.ParseFloat(args[1], 32) - y, err := strconv.ParseFloat(args[2], 32) + x, err := strconv.ParseInt(args[1], 10, 32) + y, err := strconv.ParseInt(args[2], 10, 32) if err != nil { return } - client.X = float32(x) - client.Y = float32(y) + client.X = int16(x) + client.Y = int16(y) broadcast <- CursorMessage{ - []byte(fmt.Sprintf("pos:%d:%f:%f", client.ID, client.X, client.Y)), + []byte(fmt.Sprintf("pos:%d:%d:%d", client.ID, client.X, client.Y)), client.Route, []*CursorClient{ client }, } diff --git a/public/script/cursor.js b/public/script/cursor.js index 188cdbb..059a2bd 100644 --- a/public/script/cursor.js +++ b/public/script/cursor.js @@ -1,14 +1,13 @@ 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 HTMLCanvasElement */ -let canvas; -/** @type CanvasRenderingContext2D */ -let ctx; +/** @type HTMLElement */ +let cursorContainer; /** @type Cursor */ let myCursor; /** @type Map */ @@ -20,12 +19,11 @@ 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; /** @@ -34,20 +32,46 @@ 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(); - this.colour = randomColour(); - this.click = false; + + 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; } /** @@ -56,80 +80,64 @@ 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; - 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.msg.length > 0) { + if (config.cursorFunMode === true) { if (this.#funCharCooldown <= 0) { this.#funCharCooldown = CURSOR_FUNCHAR_RATE; if (this.funChars.length >= CURSOR_MAX_CHARS) { - this.funChars.shift(); + const char = this.funChars.shift(); + char.destroy(); } - const yOffset = -10 / innerHeight; + const yOffset = -20; const accelMultiplier = 0.002; this.funChars.push(new FunChar( - this.x, this.y + yOffset, + 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; } - - 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); + } else { + this.#charElement.innerText = ''; } - const lightTheme = matchMedia && matchMedia('(prefers-color-scheme: light)').matches; + 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); + }); + } - if (lightTheme) - ctx.filter = 'saturate(5) brightness(0.8)'; + setID(id) { + this.id = id; + this.#element.id = 'cursor' + id; + this.#element.innerText = '0x' + id.toString(16).slice(0, 8).padStart(8, '0'); + } - 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 = ''; + /** + * @param {boolean} active + */ + click(active) { + if (active) + this.#element.classList.add('click'); + else + this.#element.classList.remove('click'); } } @@ -144,12 +152,20 @@ class FunChar { 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.xa = xa + Math.random() * .2 - .1; + this.ya = ya + Math.random() * -.25; + this.r = this.xa * 100; 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); } /** @@ -161,27 +177,15 @@ class FunChar { 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); + this.ya = Math.min(this.ya + 0.0005 * deltaTime, 10); - const x = this.x * innerWidth - scrollX; - const y = this.y * innerHeight - scrollY; + 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 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(); + destroy() { + this.element.remove(); } } @@ -201,37 +205,18 @@ function randomColour() { /** * @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; + if (ws && ws.readyState == WebSocket.OPEN) + ws.send(`pos:${event.x}:${event.y}`); + myCursor.move(event.x, event.y); } function handleMouseDown() { - myCursor.click = true; - if (ws && ws.readyState == WebSocket.OPEN) - ws.send('click:1'); + myCursor.click(true); } function handleMouseUp() { - myCursor.click = false; - if (ws && ws.readyState == WebSocket.OPEN) - ws.send('click:0'); + myCursor.click(false); } /** @@ -253,69 +238,56 @@ function handleKeyUp() { } /** - * @param {number} timestamp + * @param {number} time */ -function update(timestamp) { +function updateCursors(time) { if (!running) return; - const deltaTime = timestamp - lastUpdate; - lastUpdate = timestamp; - - ctx.clearRect(0, 0, canvas.width, canvas.height); + const deltaTime = time - lastUpdate; 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; + lastUpdate = time; + requestAnimationFrame(updateCursors); } 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); + cursorContainer = document.createElement('div'); + cursorContainer.id = 'cursors'; + document.body.appendChild(cursorContainer); - ctx = canvas.getContext('2d'); - - myCursor = new Cursor('You!', innerWidth / 2, innerHeight / 2); + myCursor = new Cursor("You!", window.innerWidth / 2, window.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); + requestAnimationFrame(updateCursors); - ws = new WebSocket('/cursor-ws'); - ws.addEventListener('open', () => { - console.log('Cursor connected to server successfully.'); + ws = new WebSocket("/cursor-ws"); - ws.send(`loc:${location.pathname}`); + 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("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; @@ -328,7 +300,7 @@ function cursorSetup() { switch (args[0]) { case 'id': { - myCursor.id = Number(args[1]); + myCursor.setID(Number(args[1])); break; } case 'join': { @@ -338,6 +310,7 @@ function cursorSetup() { } case 'leave': { if (!cursor || cursor === myCursor) break; + cursors.get(id).destroy(); cursors.delete(id); break; } @@ -351,37 +324,33 @@ 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.x = Number(args[2]); - cursor.y = Number(args[3]); + cursor.move(Number(args[2]), 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 @ ${location.pathname}`); + console.log(`Cursor tracking @ ${window.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 73019ee..8561716 100644 --- a/public/style/cursor.css +++ b/public/style/cursor.css @@ -1,9 +1,64 @@ -canvas#cursors { +#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; position: fixed; top: 0; left: 0; - width: 100vw; - height: 100vh; + + z-index: 1000; + overflow: clip; + + user-select: none; pointer-events: none; - z-index: 100; +} + +@media (prefers-color-scheme: light) { + #cursors i.cursor { + filter: saturate(5) brightness(0.8); + background: #fff8; + } }