Players open a no-login link and see live cursors and dragged gear pieces synced in real time, backed by a small Workerman WebSocket relay (src/Command/PuzzleRelayCommand.php) run alongside symfony serve - no Docker/Mercure hub needed. The board renders the wall artwork with a spare-gears tray, and a "Reset puzzle" button (visible only to logged-in users, verified server-side via a signed token) lets the DM wipe the shared state for everyone. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
271 lines
9.4 KiB
JavaScript
271 lines
9.4 KiB
JavaScript
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.
|
|
*
|
|
* <div data-controller="puzzle-room"
|
|
* data-puzzle-room-room-value="chamber-of-motion"
|
|
* data-puzzle-room-relay-url-value="ws://127.0.0.1:8091">
|
|
* <form data-puzzle-room-target="joinForm" data-action="submit->puzzle-room#join">...</form>
|
|
* <div data-puzzle-room-target="board" hidden>
|
|
* <div data-puzzle-room-target="piece" data-piece-id="a"
|
|
* data-action="pointerdown->puzzle-room#startDrag">...</div>
|
|
* </div>
|
|
* </div>
|
|
*
|
|
* 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 = '<span class="puzzle-cursor-dot"></span><span class="puzzle-cursor-label"></span>';
|
|
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%)`;
|
|
}
|