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>
This commit is contained in:
Frank
2026-09-12 13:24:42 +02:00
co-authored by Claude Sonnet 5
parent 40dc30f950
commit 2ccbf4ca67
21 changed files with 3293 additions and 2394 deletions
@@ -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%)`;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB