diff --git a/package-lock.json b/package-lock.json index ab18918..7bf2134 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@opentelemetry/sdk-logs": "^0.222.0", "@opentelemetry/sdk-node": "^0.222.0", "@opentelemetry/semantic-conventions": "^1.43.0", + "konva": "^10.6.0", "matrix-js-sdk": "^42.4.0", "ws": "^8.18.0" }, @@ -3940,6 +3941,38 @@ "bignumber.js": "^9.0.0" } }, + "node_modules/konva": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/konva/-/konva-10.6.0.tgz", + "integrity": "sha512-6dyI91lZTx2mDu9DRo5zrT2Pg+Q3TFeyLfGQSAPJMhfvKjelbjPnTJimYQISam/EW+g0+pjLk/yztJKpSuxFPQ==", + "funding": [ + { + "type": "patreon", + "url": "https://www.patreon.com/lavrton" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/konva" + }, + { + "type": "github", + "url": "https://github.com/sponsors/lavrton" + } + ], + "license": "MIT", + "peerDependencies": { + "canvas": "^3.0.0", + "skia-canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + }, + "skia-canvas": { + "optional": true + } + } + }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", diff --git a/package.json b/package.json index 9b57738..f1664b2 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@opentelemetry/sdk-logs": "^0.222.0", "@opentelemetry/sdk-node": "^0.222.0", "@opentelemetry/semantic-conventions": "^1.43.0", + "konva": "^10.6.0", "matrix-js-sdk": "^42.4.0", "ws": "^8.18.0" }, diff --git a/public/app.js b/public/app.js index e336670..61b259d 100644 --- a/public/app.js +++ b/public/app.js @@ -1,4 +1,5 @@ import * as matrixcs from 'matrix-js-sdk'; +import Konva from 'konva'; // --------------------------------------------------------------------------- // state @@ -904,124 +905,132 @@ function closePopout(key) { } // --------------------------------------------------------------------------- -// spatial canvas +// spatial canvas (Konva) — the "me" node is draggable and stays visually +// pinned to the stage center: every world-unit it moves, the layer is +// re-centered by the same amount in the opposite direction, so the world +// pans underneath it instead of the dot sliding away from center. // --------------------------------------------------------------------------- -const ctx2d = els.spatial.getContext('2d'); -let dragging = null; +const pad = 30; +let sx = 1, sy = 1; // px per world unit, recomputed on resize -// Camera is always centered on the listener (me), matching how the audio -// panning already works (peer positions are relative to S.me). While -// dragging, the camera freezes at its pre-drag position so the world -// doesn't slide under the pointer as S.me moves. -function spatialMetrics(camX = S.me.x, camY = S.me.y) { - const r = els.spatial.getBoundingClientRect(); - const dpr = window.devicePixelRatio || 1; - if (els.spatial.width !== r.width * dpr) { - els.spatial.width = r.width * dpr; - els.spatial.height = r.height * dpr; +const spatialStage = new Konva.Stage({ container: 'spatial', width: 1, height: 1 }); +const worldLayer = new Konva.Layer(); +spatialStage.add(worldLayer); + +// grid lives inside worldLayer so it pans with the camera along with everyone else +const gridGroup = new Konva.Group({ listening: false }); +worldLayer.add(gridGroup); + +const rangeCircle = new Konva.Circle({ + radius: 0, stroke: 'rgba(88,101,242,.35)', strokeWidth: 1, dash: [5, 5], listening: false, +}); +worldLayer.add(rangeCircle); + +const avatarNodes = new Map(); // id -> {group, circle, initial, nameText} +function avatarNode(id, isMe) { + let node = avatarNodes.get(id); + if (node) return node; + const r = isMe ? 16 : 13; + const group = new Konva.Group({ draggable: isMe }); + const circle = new Konva.Circle({ radius: r, fill: '#5865f2' }); + const initial = new Konva.Text({ + text: '', fontStyle: 'bold', fontSize: Math.round(r), fill: '#fff', + width: r * 2, height: r * 2, offsetX: r, offsetY: r, align: 'center', verticalAlign: 'middle', + }); + const nameText = new Konva.Text({ + text: '', fontSize: 11, fill: isMe ? '#b5bac1' : '#80848e', y: r + 4, align: 'center', + }); + group.add(circle, initial, nameText); + worldLayer.add(group); + if (isMe) { + group.dragBoundFunc(function (pos) { + // pos is in worldLayer's parent (stage) coordinates; clamp the + // resulting world position to the room bounds, not the screen. + const worldX = Math.min(ROOM_UNITS, Math.max(0, (pos.x - worldLayer.x()) / sx)); + const worldY = Math.min(ROOM_UNITS, Math.max(0, (pos.y - worldLayer.y()) / sy)); + return { x: worldLayer.x() + worldX * sx, y: worldLayer.y() + worldY * sy }; + }); + group.on('dragmove', () => { + S.me.x = (group.x() - worldLayer.x()) / sx; + S.me.y = (group.y() - worldLayer.y()) / sy; + recenterCamera(); + sendMyPos(); + }); + group.on('dragstart', () => els.spatial.classList.add('dragging')); + group.on('dragend', () => { + els.spatial.classList.remove('dragging'); + sendMyPos(true); + }); } - ctx2d.setTransform(dpr, 0, 0, dpr, 0, 0); - const pad = 30; - const sx = (r.width - 2 * pad) / ROOM_UNITS; - const sy = (r.height - 2 * pad) / ROOM_UNITS; - const cx = r.width / 2, cy = r.height / 2; - return { w: r.width, h: r.height, pad, sx, sy, - toPx: (x, y) => [cx + (x - camX) * sx, cy + (y - camY) * sy], - fromPx: (px, py) => [camX + (px - cx) / sx, camY + (py - cy) / sy] }; + node = { group, circle, initial, nameText }; + avatarNodes.set(id, node); + return node; +} + +function recenterCamera() { + const cx = spatialStage.width() / 2, cy = spatialStage.height() / 2; + worldLayer.position({ x: cx - S.me.x * sx, y: cy - S.me.y * sy }); +} + +function layoutSpatialGrid() { + gridGroup.destroyChildren(); + for (let i = 0; i <= ROOM_UNITS; i += 10) { + gridGroup.add(new Konva.Line({ + points: [i * sx, 0, i * sx, ROOM_UNITS * sy], stroke: 'rgba(255,255,255,.05)', strokeWidth: 1, + })); + gridGroup.add(new Konva.Line({ + points: [0, i * sy, ROOM_UNITS * sx, i * sy], stroke: 'rgba(255,255,255,.05)', strokeWidth: 1, + })); + } +} + +function resizeSpatial() { + const r = els.spatial.getBoundingClientRect(); + spatialStage.width(r.width); + spatialStage.height(r.height); + sx = (r.width - 2 * pad) / ROOM_UNITS; + sy = (r.height - 2 * pad) / ROOM_UNITS; + layoutSpatialGrid(); + recenterCamera(); +} +new ResizeObserver(resizeSpatial).observe(els.spatial); +resizeSpatial(); + +function updateAvatarNode(node, id, name, muted, speaking, isMe, worldX, worldY) { + // Safe to set even for "me" while dragging: dragmove already derived + // S.me from the node's own current position, so this is a no-op then. + node.group.position({ x: worldX * sx, y: worldY * sy }); + node.circle.stroke(muted ? '#f23f43' : speaking ? '#23a55a' : ''); + node.circle.strokeWidth(3); + node.circle.fill(colorFor(id)); + node.initial.text((name || '?').slice(0, 1).toUpperCase()); + node.nameText.text(name + (isMe ? ' (you)' : '')); + node.nameText.offsetX(node.nameText.width() / 2); } function drawSpatial() { - const camX = dragging ? dragging.camX : S.me.x; - const camY = dragging ? dragging.camY : S.me.y; - const m = spatialMetrics(camX, camY); - ctx2d.clearRect(0, 0, m.w, m.h); + // range circle stays centered on "me", which the camera keeps at center + rangeCircle.position({ x: S.me.x * sx, y: S.me.y * sy }); + rangeCircle.radius(Math.max(0, Math.min(56 * sx, spatialStage.height() / 2 - pad))); - // grid (world bounds only — clamped so it doesn't imply an infinite plane) - ctx2d.strokeStyle = 'rgba(255,255,255,.05)'; - ctx2d.lineWidth = 1; - for (let i = 0; i <= ROOM_UNITS; i += 10) { - const [gx] = m.toPx(i, 0); - const [, gy] = m.toPx(0, i); - ctx2d.beginPath(); ctx2d.moveTo(gx, m.toPx(0, 0)[1]); ctx2d.lineTo(gx, m.toPx(0, ROOM_UNITS)[1]); ctx2d.stroke(); - ctx2d.beginPath(); ctx2d.moveTo(m.toPx(0, 0)[0], gy); ctx2d.lineTo(m.toPx(ROOM_UNITS, 0)[0], gy); ctx2d.stroke(); + const seen = new Set(['me']); + const meId = S.myId || 'me'; + const meNode = avatarNode('me', true); + updateAvatarNode(meNode, meId, S.name || 'me', S.muted, els.meAvatar.classList.contains('speaking'), true, S.me.x, S.me.y); + meNode.group.moveToTop(); + + for (const peer of S.peers.values()) { + seen.add(peer.id); + const node = avatarNode(peer.id, false); + updateAvatarNode(node, peer.id, peer.name, peer.state.muted, peer.speaking, false, peer.x, peer.y); + } + for (const [id, node] of avatarNodes) { + if (!seen.has(id)) { node.group.destroy(); avatarNodes.delete(id); } } - // audible range around me (~gain 0.05 at 56 units with ref=4, rolloff=1.4) - const [mx, my] = m.toPx(S.me.x, S.me.y); - const rangeR = 56 * m.sx; - ctx2d.strokeStyle = 'rgba(88,101,242,.35)'; - ctx2d.setLineDash([5, 5]); - ctx2d.beginPath(); ctx2d.arc(mx, my, Math.max(0, Math.min(rangeR, m.h / 2 - m.pad)), 0, Math.PI * 2); ctx2d.stroke(); - ctx2d.setLineDash([]); - - // peers - for (const peer of S.peers.values()) drawAvatarDot(peer.name, peer.id, peer.x, peer.y, peer.speaking, peer.state.muted, false, m); - // me (draggable listener, always drawn at the camera's center) - drawAvatarDot(S.name || 'me', S.myId || 'me', S.me.x, S.me.y, els.meAvatar.classList.contains('speaking'), S.muted, true, m); - - if (!dragging) requestAnimationFrame(drawSpatial); + worldLayer.batchDraw(); } -requestAnimationFrame(drawSpatial); - -function drawAvatarDot(name, id, x, y, speaking, muted, isMe, m) { - const [px, py] = m.toPx(x, y); - const r = isMe ? 16 : 13; - ctx2d.beginPath(); - ctx2d.arc(px, py, r, 0, Math.PI * 2); - ctx2d.fillStyle = colorFor(id); - ctx2d.fill(); - if (speaking) { - ctx2d.strokeStyle = '#23a55a'; - ctx2d.lineWidth = 3; - ctx2d.stroke(); - } - if (muted) { - ctx2d.strokeStyle = '#f23f43'; - ctx2d.lineWidth = 3; - ctx2d.beginPath(); - ctx2d.moveTo(px - r, py + r); - ctx2d.lineTo(px + r, py - r); - ctx2d.stroke(); - } - ctx2d.fillStyle = '#fff'; - ctx2d.font = `bold ${Math.round(r)}px system-ui`; - ctx2d.textAlign = 'center'; - ctx2d.textBaseline = 'middle'; - ctx2d.fillText(name.slice(0, 1).toUpperCase(), px, py + 1); - ctx2d.font = '11px system-ui'; - ctx2d.fillStyle = isMe ? '#b5bac1' : '#80848e'; - ctx2d.fillText(name + (isMe ? ' (you)' : ''), px, py + r + 12); -} - -els.spatial.addEventListener('pointerdown', (ev) => { - const m = spatialMetrics(); - const rect = els.spatial.getBoundingClientRect(); - const px = ev.clientX - rect.left, py = ev.clientY - rect.top; - const [ux, uy] = m.fromPx(px, py); - const d = Math.hypot(ux - S.me.x, uy - S.me.y); - if (d < 10) { - dragging = { dx: S.me.x - ux, dy: S.me.y - uy, camX: S.me.x, camY: S.me.y }; - els.spatial.classList.add('dragging'); - els.spatial.setPointerCapture(ev.pointerId); - } -}); -els.spatial.addEventListener('pointermove', (ev) => { - if (!dragging) return; - const m = spatialMetrics(dragging.camX, dragging.camY); - const rect = els.spatial.getBoundingClientRect(); - const [ux, uy] = m.fromPx(ev.clientX - rect.left, ev.clientY - rect.top); - S.me.x = Math.min(ROOM_UNITS, Math.max(0, ux + dragging.dx)); - S.me.y = Math.min(ROOM_UNITS, Math.max(0, uy + dragging.dy)); - drawSpatial(); - sendMyPos(); -}); -els.spatial.addEventListener('pointerup', () => { - if (!dragging) return; - dragging = null; - els.spatial.classList.remove('dragging'); - sendMyPos(true); - requestAnimationFrame(drawSpatial); // rAF loop stopped while dragging -}); +requestAnimationFrame(function loop() { drawSpatial(); requestAnimationFrame(loop); }); // --------------------------------------------------------------------------- // Matrix text chat (experimental) — native OAuth2/OIDC auth against the diff --git a/public/index.html b/public/index.html index 6e2b3ea..d4e9d30 100644 --- a/public/index.html +++ b/public/index.html @@ -72,7 +72,7 @@ SPATIAL AUDIO drag your avatar — others hear you shift; you hear them relative to position - +