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:
@@ -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 %}
|
||||
Reference in New Issue
Block a user