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,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "dmtools",
|
||||||
|
"runtimeExecutable": "symfony",
|
||||||
|
"runtimeArgs": ["serve", "--no-tls"],
|
||||||
|
"port": 8000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -59,3 +59,14 @@ MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
|
|||||||
###> symfony/mailer ###
|
###> symfony/mailer ###
|
||||||
MAILER_DSN=null://null
|
MAILER_DSN=null://null
|
||||||
###< symfony/mailer ###
|
###< symfony/mailer ###
|
||||||
|
|
||||||
|
# Puzzle relay (src/Command/PuzzleRelayCommand.php): a small WebSocket server,
|
||||||
|
# run separately with `symfony console app:puzzle-relay` (or `php bin/console
|
||||||
|
# app:puzzle-relay`) alongside symfony serve, that syncs cursors and dragged
|
||||||
|
# pieces across the shared puzzle screens. No Symfony/Mercure component covers
|
||||||
|
# this (Mercure needs its own hub process/container); Workerman gives us a
|
||||||
|
# plain-PHP WebSocket server instead.
|
||||||
|
# PUZZLE_RELAY_LISTEN is the address the relay process binds to.
|
||||||
|
PUZZLE_RELAY_LISTEN=0.0.0.0:8091
|
||||||
|
# PUZZLE_RELAY_PUBLIC_URL is the address browsers connect to.
|
||||||
|
PUZZLE_RELAY_PUBLIC_URL=ws://127.0.0.1:8091
|
||||||
|
|||||||
@@ -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 |
@@ -44,7 +44,8 @@
|
|||||||
"symfony/web-link": "8.1.*",
|
"symfony/web-link": "8.1.*",
|
||||||
"symfony/yaml": "8.1.*",
|
"symfony/yaml": "8.1.*",
|
||||||
"twig/extra-bundle": "^2.12|^3.0",
|
"twig/extra-bundle": "^2.12|^3.0",
|
||||||
"twig/twig": "^2.12|^3.0"
|
"twig/twig": "^2.12|^3.0",
|
||||||
|
"workerman/workerman": "^5.2"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"allow-plugins": {
|
"allow-plugins": {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ security:
|
|||||||
# Note: Only the *first* matching rule is applied
|
# Note: Only the *first* matching rule is applied
|
||||||
access_control:
|
access_control:
|
||||||
- { path: ^/login$, roles: PUBLIC_ACCESS }
|
- { path: ^/login$, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/puzzle/, roles: PUBLIC_ACCESS }
|
||||||
- { path: ^/, roles: IS_AUTHENTICATED_FULLY }
|
- { path: ^/, roles: IS_AUTHENTICATED_FULLY }
|
||||||
|
|
||||||
role_hierarchy:
|
role_hierarchy:
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Puzzle\PuzzleResetTokenSigner;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
use Workerman\Connection\TcpConnection;
|
||||||
|
use Workerman\Timer;
|
||||||
|
use Workerman\Worker;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A small WebSocket relay that lets several browsers looking at the same
|
||||||
|
* puzzle room see each other's cursor and drag the same pieces around.
|
||||||
|
*
|
||||||
|
* Rooms live only in this process's memory (no database, no cache): if the
|
||||||
|
* relay restarts, every room resets. Run it separately from the web server,
|
||||||
|
* e.g. `symfony console app:puzzle-relay` next to `symfony serve`.
|
||||||
|
*/
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:puzzle-relay',
|
||||||
|
description: 'Runs the WebSocket relay that syncs cursors and dragged pieces across the shared puzzle screens.',
|
||||||
|
)]
|
||||||
|
final class PuzzleRelayCommand extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Clients ping every 15s (see puzzle_room_controller.js); a connection
|
||||||
|
* that's gone quiet for longer than this is presumed dead (closed tab,
|
||||||
|
* lost network, phone asleep) and gets dropped so it stops showing up
|
||||||
|
* as a ghost cursor/piece-holder for everyone else.
|
||||||
|
*/
|
||||||
|
private const HEARTBEAT_TIMEOUT_SECONDS = 45;
|
||||||
|
|
||||||
|
/** @var array<string, array<int, TcpConnection>> room => connection id => connection */
|
||||||
|
private array $connections = [];
|
||||||
|
|
||||||
|
/** @var array<string, array<int, array{clientId: string, name: string, color: string}>> room => connection id => presence */
|
||||||
|
private array $presence = [];
|
||||||
|
|
||||||
|
/** @var array<string, array<string, array{x: float, y: float}>> room => piece id => position */
|
||||||
|
private array $pieces = [];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
#[Autowire(env: 'PUZZLE_RELAY_LISTEN')]
|
||||||
|
private readonly string $listenAddress,
|
||||||
|
#[Autowire('%kernel.project_dir%')]
|
||||||
|
private readonly string $projectDir,
|
||||||
|
private readonly PuzzleResetTokenSigner $resetTokenSigner,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$output->writeln("Puzzle relay listening on ws://{$this->listenAddress} (Ctrl+C to stop)");
|
||||||
|
|
||||||
|
$logDir = $this->projectDir.'/var/log';
|
||||||
|
if (!is_dir($logDir)) {
|
||||||
|
mkdir($logDir, 0777, true);
|
||||||
|
}
|
||||||
|
Worker::$logFile = $logDir.'/puzzle-relay.log';
|
||||||
|
Worker::$pidFile = $logDir.'/puzzle-relay.pid';
|
||||||
|
Worker::$stdoutFile = $logDir.'/puzzle-relay-stdout.log';
|
||||||
|
|
||||||
|
$worker = new Worker("websocket://{$this->listenAddress}");
|
||||||
|
$worker->onConnect = static function (TcpConnection $connection): void {
|
||||||
|
$connection->lastSeenAt = time();
|
||||||
|
};
|
||||||
|
$worker->onMessage = $this->onMessage(...);
|
||||||
|
$worker->onClose = $this->onClose(...);
|
||||||
|
$worker->onWorkerStart = function () use ($worker): void {
|
||||||
|
Timer::add(10, fn () => $this->reapDeadConnections($worker));
|
||||||
|
};
|
||||||
|
|
||||||
|
Worker::runAll();
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function onMessage(TcpConnection $connection, string $data): void
|
||||||
|
{
|
||||||
|
$message = json_decode($data, true);
|
||||||
|
if (!\is_array($message) || !\is_string($message['type'] ?? null)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$connection->lastSeenAt = time();
|
||||||
|
|
||||||
|
match ($message['type']) {
|
||||||
|
'join' => $this->handleJoin($connection, $message),
|
||||||
|
'cursor' => $this->handleCursor($connection, $message),
|
||||||
|
'piece' => $this->handlePiece($connection, $message),
|
||||||
|
'reset' => $this->handleReset($connection, $message),
|
||||||
|
'ping' => null,
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function reapDeadConnections(Worker $worker): void
|
||||||
|
{
|
||||||
|
$cutoff = time() - self::HEARTBEAT_TIMEOUT_SECONDS;
|
||||||
|
foreach ($worker->connections as $connection) {
|
||||||
|
if (($connection->lastSeenAt ?? 0) < $cutoff) {
|
||||||
|
$connection->close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $message */
|
||||||
|
private function handleJoin(TcpConnection $connection, array $message): void
|
||||||
|
{
|
||||||
|
$room = $this->roomFrom($message);
|
||||||
|
if (null === $room) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$connection->room = $room;
|
||||||
|
$connection->clientId = (string) ($message['clientId'] ?? '');
|
||||||
|
$connection->name = mb_substr((string) ($message['name'] ?? 'Player'), 0, 40);
|
||||||
|
$connection->color = (string) ($message['color'] ?? '#8a8a8a');
|
||||||
|
|
||||||
|
$this->connections[$room][$connection->id] = $connection;
|
||||||
|
$this->presence[$room][$connection->id] = [
|
||||||
|
'clientId' => $connection->clientId,
|
||||||
|
'name' => $connection->name,
|
||||||
|
'color' => $connection->color,
|
||||||
|
];
|
||||||
|
|
||||||
|
$connection->send(json_encode([
|
||||||
|
'type' => 'state',
|
||||||
|
'pieces' => $this->pieces[$room] ?? new \stdClass(),
|
||||||
|
'clients' => array_values($this->presence[$room]),
|
||||||
|
], \JSON_THROW_ON_ERROR));
|
||||||
|
|
||||||
|
$this->broadcastPresence($room);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $message */
|
||||||
|
private function handleCursor(TcpConnection $connection, array $message): void
|
||||||
|
{
|
||||||
|
$room = $connection->room ?? null;
|
||||||
|
if (!\is_string($room)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->broadcast($room, [
|
||||||
|
'type' => 'cursor',
|
||||||
|
'clientId' => $connection->clientId,
|
||||||
|
'name' => $connection->name,
|
||||||
|
'color' => $connection->color,
|
||||||
|
'x' => (float) ($message['x'] ?? 0),
|
||||||
|
'y' => (float) ($message['y'] ?? 0),
|
||||||
|
], except: $connection->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $message */
|
||||||
|
private function handlePiece(TcpConnection $connection, array $message): void
|
||||||
|
{
|
||||||
|
$room = $connection->room ?? null;
|
||||||
|
$id = $message['id'] ?? null;
|
||||||
|
if (!\is_string($room) || !\is_string($id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$x = (float) ($message['x'] ?? 0);
|
||||||
|
$y = (float) ($message['y'] ?? 0);
|
||||||
|
$this->pieces[$room][$id] = ['x' => $x, 'y' => $y];
|
||||||
|
|
||||||
|
$this->broadcast($room, [
|
||||||
|
'type' => 'piece',
|
||||||
|
'id' => $id,
|
||||||
|
'x' => $x,
|
||||||
|
'y' => $y,
|
||||||
|
'phase' => \in_array($message['phase'] ?? null, ['move', 'drop'], true) ? $message['phase'] : 'move',
|
||||||
|
'clientId' => $connection->clientId,
|
||||||
|
], except: $connection->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $message */
|
||||||
|
private function handleReset(TcpConnection $connection, array $message): void
|
||||||
|
{
|
||||||
|
$room = $connection->room ?? null;
|
||||||
|
$token = $message['resetToken'] ?? null;
|
||||||
|
if (!\is_string($room) || !\is_string($token) || !$this->resetTokenSigner->verify($room, $token)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
unset($this->pieces[$room]);
|
||||||
|
|
||||||
|
$this->broadcast($room, ['type' => 'reset']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function onClose(TcpConnection $connection): void
|
||||||
|
{
|
||||||
|
$room = $connection->room ?? null;
|
||||||
|
if (!\is_string($room)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
unset($this->connections[$room][$connection->id], $this->presence[$room][$connection->id]);
|
||||||
|
$this->broadcastPresence($room);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function broadcastPresence(string $room): void
|
||||||
|
{
|
||||||
|
$this->broadcast($room, [
|
||||||
|
'type' => 'presence',
|
||||||
|
'clients' => array_values($this->presence[$room] ?? []),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $payload */
|
||||||
|
private function broadcast(string $room, array $payload, ?int $except = null): void
|
||||||
|
{
|
||||||
|
$encoded = json_encode($payload, \JSON_THROW_ON_ERROR);
|
||||||
|
foreach ($this->connections[$room] ?? [] as $id => $connection) {
|
||||||
|
if ($id === $except) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$connection->send($encoded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $message */
|
||||||
|
private function roomFrom(array $message): ?string
|
||||||
|
{
|
||||||
|
$room = $message['room'] ?? null;
|
||||||
|
|
||||||
|
return \is_string($room) && '' !== $room ? $room : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Puzzle\PuzzleResetTokenSigner;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared, no-login puzzle screens: everyone with the link sees the same
|
||||||
|
* board, the same pieces, and each other's cursors, synced over the
|
||||||
|
* WebSocket relay in {@see \App\Command\PuzzleRelayCommand}.
|
||||||
|
*/
|
||||||
|
final class PuzzleController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Autowire(env: 'PUZZLE_RELAY_PUBLIC_URL')]
|
||||||
|
private readonly string $relayPublicUrl,
|
||||||
|
private readonly PuzzleResetTokenSigner $resetTokenSigner,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/puzzle/{room}', name: 'app_puzzle_room', requirements: ['room' => '[a-z0-9-]+'], methods: ['GET'])]
|
||||||
|
public function room(string $room): Response
|
||||||
|
{
|
||||||
|
return $this->render('puzzle/room.html.twig', [
|
||||||
|
'room' => $room,
|
||||||
|
'relayPublicUrl' => $this->relayPublicUrl,
|
||||||
|
// Only a logged-in user gets a token that lets them reset the shared
|
||||||
|
// board; anonymous players can play but not wipe it for everyone.
|
||||||
|
'resetToken' => $this->getUser() ? $this->resetTokenSigner->sign($room) : null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Puzzle;
|
||||||
|
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signs/verifies the token that lets a client reset a shared puzzle room.
|
||||||
|
* Used by both {@see \App\Controller\PuzzleController} (issues it to logged-in
|
||||||
|
* users) and {@see \App\Command\PuzzleRelayCommand} (a public WebSocket server
|
||||||
|
* outside Symfony's session/security system, so it must check the signature
|
||||||
|
* itself rather than trusting the client).
|
||||||
|
*/
|
||||||
|
final class PuzzleResetTokenSigner
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Autowire(env: 'APP_SECRET')]
|
||||||
|
private readonly string $secret,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sign(string $room): string
|
||||||
|
{
|
||||||
|
return hash_hmac('sha256', $room, $this->secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verify(string $room, string $token): bool
|
||||||
|
{
|
||||||
|
return hash_equals($this->sign($room), $token);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
{% extends 'base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Puzzle: {{ room }} · dmtools{% endblock %}
|
||||||
|
|
||||||
|
{% block navbar %}{% endblock %}
|
||||||
|
|
||||||
|
{% block stylesheets %}
|
||||||
|
<style>
|
||||||
|
.puzzle-join {
|
||||||
|
max-width: 320px;
|
||||||
|
margin: 4rem auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-fullbleed {
|
||||||
|
position: relative;
|
||||||
|
left: 50%;
|
||||||
|
width: 100vw;
|
||||||
|
margin-left: -50vw;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-board {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1200 / 485;
|
||||||
|
background: #1b1f24;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-tray {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 39.4%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(180deg, #2a2f37, #1b1f24);
|
||||||
|
border-right: 3px solid rgba(255, 255, 255, .08);
|
||||||
|
box-shadow: inset 0 0 30px rgba(0, 0, 0, .5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-tray-label {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.3rem;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
font-size: .75rem;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(255, 255, 255, .45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-wall {
|
||||||
|
position: absolute;
|
||||||
|
left: 39.4%;
|
||||||
|
top: 0;
|
||||||
|
width: 60.6%;
|
||||||
|
aspect-ratio: 1536 / 1024;
|
||||||
|
background: url('{{ asset('image/puzzle/wall.png') }}') center / cover no-repeat;
|
||||||
|
box-shadow: 0 0 0 3px rgba(0, 0, 0, .4), 0 10px 30px rgba(0, 0, 0, .5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-piece {
|
||||||
|
position: absolute;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-piece img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
pointer-events: none;
|
||||||
|
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, .5));
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-piece.is-dragging {
|
||||||
|
cursor: grabbing;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-piece.is-dragging img {
|
||||||
|
filter: drop-shadow(0 8px 16px rgba(0, 0, 0, .65));
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-cursor {
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translate(-2px, -2px);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-cursor-dot {
|
||||||
|
display: block;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-cursor-label {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 2px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: .7rem;
|
||||||
|
color: #fff;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-status {
|
||||||
|
opacity: .7;
|
||||||
|
font-size: .85rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div data-controller="puzzle-room"
|
||||||
|
data-puzzle-room-room-value="{{ room }}"
|
||||||
|
data-puzzle-room-relay-url-value="{{ relayPublicUrl }}"
|
||||||
|
data-puzzle-room-reset-token-value="{{ resetToken }}">
|
||||||
|
|
||||||
|
<form class="puzzle-join card p-4" data-puzzle-room-target="joinForm" data-action="submit->puzzle-room#join">
|
||||||
|
<h1 class="h4 mb-3">Join the puzzle</h1>
|
||||||
|
<label class="form-label" for="puzzle-name">Your name</label>
|
||||||
|
<input class="form-control mb-3" id="puzzle-name" type="text" maxlength="40" required
|
||||||
|
data-puzzle-room-target="nameInput" placeholder="e.g. Frank" autofocus>
|
||||||
|
<button class="btn btn-primary w-100" type="submit">Join</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="puzzle-fullbleed">
|
||||||
|
<div class="mb-2 d-flex justify-content-between align-items-center">
|
||||||
|
<h1 class="h5 mb-0">Puzzle room: {{ room }}</h1>
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<span class="puzzle-status" data-puzzle-room-target="status">Connecting…</span>
|
||||||
|
{% if resetToken %}
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" data-action="puzzle-room#reset">Reset puzzle</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="puzzle-board" hidden data-puzzle-room-target="board">
|
||||||
|
<div class="puzzle-tray">
|
||||||
|
<span class="puzzle-tray-label">Spare gears</span>
|
||||||
|
</div>
|
||||||
|
<div class="puzzle-wall"></div>
|
||||||
|
|
||||||
|
{# Gear sizes are % of board width. The bronze pegboard panel in wall.png
|
||||||
|
is ~49.5% of the wall image's own width, and the wall image itself is
|
||||||
|
60.6% of the board width (the rest is the 3-column tray on the left) -
|
||||||
|
so the biggest gear (F) is a third of the panel's actual on-screen
|
||||||
|
size, times 1.25 (bumped up 25% on request). Packed tightly (minimal
|
||||||
|
gaps/padding) so the tray stays as narrow and short as possible,
|
||||||
|
leaving the wall panel as big as it can be while still fitting one
|
||||||
|
screen height. #}
|
||||||
|
{% for piece in [
|
||||||
|
{ id: 'f', size: 12.49, x: 0.0657, y: 0.2582 },
|
||||||
|
{ id: 'e', size: 9.78, x: 0.197, y: 0.2582 },
|
||||||
|
{ id: 'd', size: 8.53, x: 0.3283, y: 0.2582 },
|
||||||
|
{ id: 'i', size: 7.38, x: 0.0657, y: 0.5393 },
|
||||||
|
{ id: 'c', size: 6.84, x: 0.197, y: 0.5393 },
|
||||||
|
{ id: 'h', size: 6.66, x: 0.3283, y: 0.5393 },
|
||||||
|
{ id: 'b', size: 5.31, x: 0.0657, y: 0.7339 },
|
||||||
|
{ id: 'g', size: 5.25, x: 0.197, y: 0.7339 },
|
||||||
|
{ id: 'a', size: 4.31, x: 0.3283, y: 0.7339 },
|
||||||
|
] %}
|
||||||
|
<div class="puzzle-piece" data-puzzle-room-target="piece" data-piece-id="{{ piece.id }}"
|
||||||
|
data-action="pointerdown->puzzle-room#startDrag"
|
||||||
|
style="left: {{ (piece.x * 100) }}%; top: {{ (piece.y * 100) }}%; width: {{ piece.size }}%;">
|
||||||
|
<img src="{{ asset('image/puzzle/gear-' ~ piece.id ~ '.png') }}" alt="Gear {{ piece.id|upper }}" draggable="false">
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||