Files
dmtools/templates/puzzle/room.html.twig
T
FrankandClaude Sonnet 5 2ccbf4ca67 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>
2026-09-12 13:24:42 +02:00

182 lines
6.4 KiB
Twig

{% 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 %}