import { Controller } from '@hotwired/stimulus'; /* * The shared, no-login puzzle board. Connects to the WebSocket relay * (src/Command/PuzzleRelayCommand.php) so every open tab on this room sees * the same piece positions and each other's cursors, live. * *
*
...
* *
* * Room state (piece positions, who's online) lives only in the relay * process's memory - there is no database or cache behind this. */ const CLIENT_KEY = 'dmtools.puzzleClient'; const CURSOR_THROTTLE_MS = 40; const PIECE_THROTTLE_MS = 40; const RECONNECT_DELAY_MS = 2000; const HEARTBEAT_INTERVAL_MS = 15000; export default class extends Controller { static targets = ['board', 'piece', 'joinForm', 'nameInput', 'status']; static values = { room: String, relayUrl: String, resetToken: String }; connect() { this.cursorEls = new Map(); this.pieceEls = new Map(); this.defaultPositions = new Map(); this.pieceTargets.forEach((el) => { this.pieceEls.set(el.dataset.pieceId, el); this.defaultPositions.set(el.dataset.pieceId, { left: el.style.left, top: el.style.top }); }); this.dragging = null; this.onPointerMove = this.onPointerMove.bind(this); this.onPointerUp = this.onPointerUp.bind(this); this.onBoardPointerMove = this.onBoardPointerMove.bind(this); this.sendCursor = throttle((x, y) => this.send({ type: 'cursor', room: this.roomValue, x, y }), CURSOR_THROTTLE_MS); this.sendPieceMove = throttle((id, x, y) => this.send({ type: 'piece', room: this.roomValue, id, x, y, phase: 'move' }), PIECE_THROTTLE_MS); this.client = this.loadClient(); if (this.client) { this.startSession(); } } disconnect() { clearInterval(this.heartbeatTimer); this.socket?.close(); clearTimeout(this.reconnectTimer); window.removeEventListener('pointermove', this.onPointerMove); window.removeEventListener('pointerup', this.onPointerUp); } join(event) { event.preventDefault(); const name = this.nameInputTarget.value.trim(); if (!name) return; this.client = { clientId: crypto.randomUUID(), name: name.slice(0, 40), color: randomColor() }; try { sessionStorage.setItem(CLIENT_KEY, JSON.stringify(this.client)); } catch { /* private mode: identity just won't survive a reload */ } this.startSession(); } loadClient() { try { const raw = sessionStorage.getItem(CLIENT_KEY); return raw ? JSON.parse(raw) : null; } catch { return null; } } startSession() { this.joinFormTarget.hidden = true; this.boardTarget.hidden = false; this.boardTarget.addEventListener('pointermove', this.onBoardPointerMove); this.connectSocket(); } connectSocket() { this.setStatus('Connecting…'); const socket = new WebSocket(this.relayUrlValue); this.socket = socket; socket.addEventListener('open', () => { this.send({ type: 'join', room: this.roomValue, clientId: this.client.clientId, name: this.client.name, color: this.client.color, }); this.heartbeatTimer = setInterval(() => this.send({ type: 'ping' }), HEARTBEAT_INTERVAL_MS); }); socket.addEventListener('message', (event) => this.onMessage(event)); socket.addEventListener('close', () => { clearInterval(this.heartbeatTimer); this.setStatus('Disconnected — retrying…'); this.reconnectTimer = setTimeout(() => this.connectSocket(), RECONNECT_DELAY_MS); }); socket.addEventListener('error', () => socket.close()); } setStatus(text) { if (this.hasStatusTarget) this.statusTarget.textContent = text; } send(payload) { if (this.socket?.readyState === WebSocket.OPEN) { this.socket.send(JSON.stringify(payload)); } } onMessage(event) { let message; try { message = JSON.parse(event.data); } catch { return; } switch (message.type) { case 'state': Object.entries(message.pieces || {}).forEach(([id, pos]) => this.placePiece(id, pos.x, pos.y)); this.renderPresence(message.clients || []); break; case 'cursor': if (message.clientId !== this.client.clientId) this.renderCursor(message); break; case 'piece': if (message.clientId !== this.client.clientId) this.placePiece(message.id, message.x, message.y); break; case 'presence': this.renderPresence(message.clients || []); break; case 'reset': this.resetPieces(); break; } } reset() { if (!this.resetTokenValue) return; if (!window.confirm('Reset the puzzle? This puts every gear back in the tray for everyone in this room.')) return; this.send({ type: 'reset', room: this.roomValue, resetToken: this.resetTokenValue }); } resetPieces() { this.dragging = null; for (const [id, el] of this.pieceEls) { const pos = this.defaultPositions.get(id); if (!pos) continue; el.style.left = pos.left; el.style.top = pos.top; } } renderPresence(clients) { const seen = new Set(clients.map((c) => c.clientId)); for (const [clientId, el] of this.cursorEls) { if (!seen.has(clientId)) { el.remove(); this.cursorEls.delete(clientId); } } const others = clients.filter((c) => c.clientId !== this.client.clientId).length; this.setStatus(others > 0 ? `Connected — ${others} other${others === 1 ? '' : 's'} here` : "Connected — you're the only one here"); } renderCursor(message) { let el = this.cursorEls.get(message.clientId); if (!el) { el = document.createElement('div'); el.className = 'puzzle-cursor'; el.innerHTML = ''; el.querySelector('.puzzle-cursor-dot').style.background = message.color; el.querySelector('.puzzle-cursor-label').style.background = message.color; el.querySelector('.puzzle-cursor-label').textContent = message.name; this.boardTarget.appendChild(el); this.cursorEls.set(message.clientId, el); } el.style.left = `${message.x * 100}%`; el.style.top = `${message.y * 100}%`; } placePiece(id, x, y) { const el = this.pieceEls.get(id); if (!el || this.dragging?.id === id) return; el.style.left = `${x * 100}%`; el.style.top = `${y * 100}%`; } onBoardPointerMove(event) { const { x, y } = this.relativePosition(event); this.sendCursor(x, y); } relativePosition(event) { const rect = this.boardTarget.getBoundingClientRect(); return { x: clamp((event.clientX - rect.left) / rect.width, 0, 1), y: clamp((event.clientY - rect.top) / rect.height, 0, 1), }; } startDrag(event) { const el = event.currentTarget; el.setPointerCapture(event.pointerId); this.dragging = { id: el.dataset.pieceId, el }; el.classList.add('is-dragging'); window.addEventListener('pointermove', this.onPointerMove); window.addEventListener('pointerup', this.onPointerUp); } onPointerMove(event) { if (!this.dragging) return; const { x, y } = this.relativePosition(event); this.dragging.el.style.left = `${x * 100}%`; this.dragging.el.style.top = `${y * 100}%`; this.sendPieceMove(this.dragging.id, x, y); } onPointerUp(event) { if (!this.dragging) return; const { x, y } = this.relativePosition(event); this.send({ type: 'piece', room: this.roomValue, id: this.dragging.id, x, y, phase: 'drop' }); this.dragging.el.classList.remove('is-dragging'); this.dragging = null; window.removeEventListener('pointermove', this.onPointerMove); window.removeEventListener('pointerup', this.onPointerUp); } } function throttle(fn, ms) { let last = 0; let timer = null; return (...args) => { const now = Date.now(); const remaining = ms - (now - last); clearTimeout(timer); if (remaining <= 0) { last = now; fn(...args); } else { timer = setTimeout(() => { last = Date.now(); fn(...args); }, remaining); } }; } function clamp(value, min, max) { return Math.min(max, Math.max(min, value)); } function randomColor() { return `hsl(${Math.floor(Math.random() * 360)}, 70%, 55%)`; }