Add shared multiplayer gear puzzle (Chamber of Motion)
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>
@@ -0,0 +1,270 @@
|
||||
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%)`;
|
||||
}
|
||||
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 215 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 2.9 MiB |