Revert "Lobby: CSS 3D dice tray at the top right next to the chat"

Back to the card-style dice tray above the hotbar.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
This commit is contained in:
Claude
2026-10-21 18:13:56 +00:00
parent d730022447
commit 7e69b68b37
4 changed files with 62 additions and 147 deletions
-96
View File
@@ -1,96 +0,0 @@
import { useMemo } from 'react'
// Dice tray with CSS 3D "throws". The result always comes from the server; the animation only has to land on it:
// d6 is a real cube, the other dice are faceted slabs (stacked layers) that tumble in perspective.
const rand = (a, b) => a + Math.random() * (b - a)
const reduced = () => window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
const CELL = 72, COLS = 5, SIZE = 58
// Outline and inner facet lines of each die seen face-on (viewBox 0 0 100 100)
const FACE = {
4: { poly: '50,6 96,88 4,88', lines: ['50,62 50,6', '50,62 96,88', '50,62 4,88'], ty: 70 },
8: { poly: '50,4 96,50 50,96 4,50', lines: ['50,4 50,96', '4,50 96,50'], ty: 55 },
10: { poly: '50,3 97,40 50,97 3,40', lines: ['50,3 50,97', '3,40 50,62', '97,40 50,62'], ty: 56 },
12: { poly: '50,4 96,38 78,94 22,94 4,38', lines: ['50,4 50,30', '96,38 72,46', '78,94 64,72', '22,94 36,72', '4,38 28,46', '50,30 72,46 64,72 36,72 28,46 50,30'], ty: 62 },
20: { poly: '50,3 93,27 93,73 50,97 7,73 7,27', lines: ['50,28 74,70 26,70 50,28', '50,28 50,3', '50,28 7,27', '50,28 93,27', '74,70 93,73', '74,70 50,97', '26,70 50,97', '26,70 7,73'], ty: 56 },
100: { circle: true, lines: [], ty: 56 },
}
function Slab({ sides, value }) {
const f = FACE[sides]
const depth = 6
const svg = (i) => (
<svg key={i} className="layer" viewBox="0 0 100 100" style={{ transform: `translateZ(${-i * 1.6}px)`, filter: i ? `brightness(${1 - i * 0.075})` : undefined }} aria-hidden="true">
{f.circle ? <circle cx="50" cy="50" r="46" /> : <polygon points={f.poly} />}
{i === 0 && (
<>
{f.circle && <circle cx="50" cy="50" r="35" className="facet" />}
{f.lines.map((l, k) => <polyline key={k} points={l} className="facet" />)}
<text x="50" y={f.ty} textAnchor="middle" dominantBaseline="central" className="pip">{value}</text>
</>
)}
</svg>
)
return <>{Array.from({ length: depth }, (_, i) => svg(depth - 1 - i))}</>
}
// rotation that brings face v of the cube to the front
const CUBE = { 1: [0, 0], 2: [0, -90], 3: [-90, 0], 4: [90, 0], 5: [0, 90], 6: [0, 180] }
const CUBE_FACES = [['front', 1], ['right', 2], ['top', 3], ['bottom', 4], ['left', 5], ['back', 6]]
function Die({ sides, value, i, col, row, rolled }) {
const v = useMemo(() => {
const cube = sides === 6
const [fx, fy] = cube ? CUBE[value] : [0, 0]
const fz = cube ? 0 : rand(-14, 14)
const turn = () => (Math.random() < .5 ? -1 : 1) * Math.round(rand(2, 4)) * 360
return {
'--dx': `${rand(120, 230)}px`, '--dy': `${-rand(130, 200)}px`, '--delay': `${i * 90}ms`, '--dur': `${rand(1.05, 1.3).toFixed(2)}s`,
'--rx0': `${fx + turn()}deg`, '--ry0': `${fy + turn()}deg`, '--rz0': `${fz + turn()}deg`,
'--rx1': `${fx}deg`, '--ry1': `${fy}deg`, '--rz1': `${fz}deg`,
left: col * CELL + rand(-5, 5) + 8, top: row * CELL + rand(-3, 5) + 6,
}
}, [sides, value, i, col, row])
const { left, top, ...vars } = v
return (
<div className={`dieslot ${rolled ? 'rolled' : ''}`} style={{ left, top, ...vars }}>
<span className="dieshadow" />
<div className="flight">
<div className="tilt">
<div className={`spin ${sides === 6 ? 'cubic' : ''}`}>
{sides === 6
? CUBE_FACES.map(([name, n]) => <span key={name} className={`cf cf-${name}`}>{n}</span>)
: <Slab sides={sides} value={value} />}
</div>
</div>
</div>
</div>
)
}
function Group({ m }) {
const n = m.roll.results.length, sum = m.roll.results.reduce((a, b) => a + b, 0)
const rows = Math.ceil(n / COLS)
const secret = !!m.to
return (
<div className={`traygroup ${secret ? 'secret' : ''} ${reduced() ? 'still' : ''}`}>
<div className="traycap">
<span><b>{m.mine ? 'You' : m.from}</b> · {n > 1 ? n : ''}d{m.roll.sides}{secret ? (m.to === m.from ? ' · only you' : ' · private') : ''}</span>
<span className="trayres">{n > 1 ? `= ${sum}` : ''}</span>
</div>
<div className="traydice" style={{ height: rows * CELL + 6, width: Math.min(n, COLS) * CELL + 12 }}>
{m.roll.results.map((v, i) => <Die key={i} sides={m.roll.sides} value={v} i={i} col={i % COLS} row={Math.floor(i / COLS)} />)}
</div>
</div>
)
}
export default function DiceTray({ rolls }) {
if (!rolls.length) return null
return (
<div className="dicetray3d" aria-hidden="true">
{rolls.map((m) => <Group key={m.key} m={m} />)}
</div>
)
}
export { SIZE }
+42 -4
View File
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import PaperSheet from './PaperSheet.jsx'
import DiceTray from './DiceTray.jsx'
const J = { 'Content-Type': 'application/json' }
const send = (url, method, body) => fetch(url, { method, headers: J, body: body === undefined ? undefined : JSON.stringify(body) })
@@ -129,6 +128,7 @@ function Card({ m, isDm, onOpen, onKick }) {
const clock = (t) => new Date(t * 1000).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
const DICE = [4, 6, 8, 10, 12, 20, 100]
const reduced = () => window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
// Chat, whispers, event log and dice rolls arrive through one feed that is polled while the page is open.
function useFeed(id, onEvent, onRoll) {
@@ -271,6 +271,44 @@ function DiceBar({ id, to, poll, isDm }) {
)
}
function Die({ sides, value, delay }) {
const [shown, setShown] = useState(reduced() ? value : null)
useEffect(() => {
if (reduced()) return
const t0 = Date.now(), run = 650 + delay
const iv = setInterval(() => {
if (Date.now() - t0 >= run) { setShown(value); clearInterval(iv) } else setShown(1 + Math.floor(Math.random() * sides))
}, 70)
return () => clearInterval(iv)
}, [sides, value, delay])
const rolling = shown !== value
return (
<span className={`die d${sides} ${rolling ? 'rolling' : 'landed'}`} style={{ animationDelay: `${delay % 200}ms` }}>
<span>{shown ?? '?'}</span>
</span>
)
}
function DiceTray({ rolls, me }) {
if (!rolls.length) return null
return (
<div className="dicetray" aria-hidden="true">
{rolls.map((m) => {
const t = rollText(m)
return (
<div key={m.key} className="trayroll">
<div className="small">{m.mine ? 'You' : m.from} rolled <strong>{t.dice}</strong>{m.to ? (m.to === m.from ? ' · only you' : ` · private${m.mine ? ` to ${m.to}` : ''}`) : ''}</div>
<div className="trayrow">
{m.roll.results.map((v, i) => <Die key={i} sides={m.roll.sides} value={v} delay={i * 120} />)}
{t.n > 1 && <span className="traytotal">= {t.sum}</span>}
</div>
</div>
)
})}
</div>
)
}
// ---- sheet of one character, refreshed regularly so the DM sees changes the player saves
function CharTab({ cid, owner, rules, onOpen, name }) {
const [data, setData] = useState(null)
@@ -321,8 +359,8 @@ function LobbyView({ id, rules, user, onOpen }) {
const onRoll = useCallback((m) => {
const key = `${m.id}`
setTray((t) => [...t.slice(-1), { ...m, key }])
setTimeout(() => setTray((t) => t.filter((x) => x.key !== key)), 10000)
setTray((t) => [...t.slice(-2), { ...m, key }])
setTimeout(() => setTray((t) => t.filter((x) => x.key !== key)), 9000)
}, [])
const feed = useFeed(id, load, onRoll)
@@ -424,7 +462,7 @@ function LobbyView({ id, rules, user, onOpen }) {
</aside>
</div>
<DiceTray rolls={tray} />
<DiceTray rolls={tray} me={user} />
<DiceBar id={id} to={dest} poll={feed.poll} isDm={lobby.is_dm} />
</section>
)
+18 -45
View File
@@ -228,6 +228,24 @@ body:has(.lobbychat) main { max-width: none; margin: 0; padding-right: calc(var(
.diebtn:hover:not(:disabled) { border-color: var(--noxium); color: var(--noxium); }
.dicedest { margin-left: .4rem; padding-left: .6rem; border-left: 1px solid var(--line); }
.dicedest.priv { color: #8a63c7; font-weight: 600; }
.dicetray { position: fixed; bottom: 5.4rem; left: calc((100vw - var(--chatw)) / 2); transform: translateX(-50%); z-index: 29; display: grid; gap: .5rem; justify-items: center; pointer-events: none; }
.trayroll { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: .5rem .9rem; box-shadow: 0 6px 24px rgba(0, 0, 0, .25); animation: trayin .25s ease-out; }
.trayrow { display: flex; align-items: center; gap: .5rem; margin-top: .3rem; flex-wrap: wrap; justify-content: center; max-width: 30rem; }
.traytotal { font-weight: 700; font-size: 1.2rem; margin-left: .3rem; }
.die { display: inline-flex; align-items: center; justify-content: center; width: 2.9rem; height: 2.9rem; background: var(--noxium); color: #12150f; font-weight: 800; font-variant-numeric: tabular-nums; }
.die.d4 { clip-path: polygon(50% 4%, 96% 92%, 4% 92%); padding-top: .55rem; }
.die.d6 { border-radius: 12px; }
.die.d8 { clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%); }
.die.d10 { clip-path: polygon(50% 0, 98% 40%, 50% 100%, 2% 40%); }
.die.d12 { clip-path: polygon(50% 0, 98% 38%, 80% 96%, 20% 96%, 2% 38%); }
.die.d20 { clip-path: polygon(25% 4%, 75% 4%, 100% 50%, 75% 96%, 25% 96%, 0 50%); }
.die.d100 { border-radius: 50%; }
.die.rolling { animation: tumble .32s linear infinite; filter: brightness(.9); }
.die.landed { animation: land .25s ease-out; }
@keyframes tumble { 0% { transform: rotate(0) translateY(0); } 25% { transform: rotate(90deg) translateY(-8px); } 50% { transform: rotate(180deg) translateY(0); } 75% { transform: rotate(270deg) translateY(-5px); } 100% { transform: rotate(360deg) translateY(0); } }
@keyframes land { 0% { transform: scale(1.35); } 100% { transform: scale(1); } }
@keyframes trayin { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) { .die.rolling, .die.landed, .trayroll { animation: none; } }
@media (max-width: 1050px) { body:has(.lobbychat) main { max-width: 1280px; margin: 0 auto; padding-right: 1rem; } .lobbychat { position: static; width: auto; height: 28rem; border-width: 1px; border-radius: var(--radius); margin-top: 1rem; } .dicebar, .dicetray { left: 50%; } .dicebar { max-width: calc(100vw - 1.5rem); } }
.lobbycard { align-content: start; }
@@ -310,48 +328,3 @@ body:has(.lobbychat) main { max-width: none; margin: 0; padding-right: calc(var(
.lobbyhead h1 { font-size: 1.3rem; margin: 0; }
.lobbypage .tabs { margin: .4rem 0 .7rem; }
.lobbypage .tabs button { padding: .3rem .8rem; }
/* ---- 3D dice tray (top right, left of the chat) ---- */
.dicetray3d { position: fixed; top: calc(var(--hdr) + .8rem); right: calc(var(--chatw) + 1rem); z-index: 25; display: grid; gap: .6rem; pointer-events: none; perspective: 800px; }
.traygroup { background: radial-gradient(ellipse at 50% 35%, #2b3a20, #151c10 85%); border: 1px solid #3c4c2e; border-radius: 16px; padding: .5rem .6rem .35rem; box-shadow: 0 10px 30px rgba(0, 0, 0, .35), inset 0 0 24px rgba(0, 0, 0, .45); animation: trayin .3s ease-out; overflow: hidden; }
.traygroup.secret { background: radial-gradient(ellipse at 50% 35%, #33284a, #17101f 85%); border-color: #5b4a86; }
.traycap { display: flex; justify-content: space-between; gap: 1rem; font-size: .78rem; color: #cfd8c0; margin-bottom: .2rem; }
.traycap b { color: #fff; }
.trayres { font-weight: 800; font-size: 1.05rem; color: #fff; opacity: 0; animation: resin .3s ease-out 1.45s forwards; }
.traygroup.still .trayres { opacity: 1; animation: none; }
.traydice { position: relative; min-width: 150px; }
.dieslot { --dc: #ece7d2; --ink: #1c2116; position: absolute; width: 58px; height: 58px; transform-style: preserve-3d; }
.secret .dieslot { --dc: #cbb9f2; }
.dieshadow { position: absolute; left: 8%; right: 8%; bottom: -4px; height: 10px; border-radius: 50%; background: rgba(0, 0, 0, .55); filter: blur(3px); opacity: 0; animation: shadowin .25s ease-out calc(var(--delay) + var(--dur) * .55) forwards; }
.flight, .tilt, .spin { position: absolute; inset: 0; transform-style: preserve-3d; }
.flight { animation: fly var(--dur) cubic-bezier(.3, .1, .4, 1) var(--delay) both; }
.tilt { transform: rotateX(-20deg) rotateY(-24deg); }
.spin { transform: rotateX(var(--rx1)) rotateY(var(--ry1)) rotateZ(var(--rz1)); animation: tumble var(--dur) cubic-bezier(.2, .75, .35, 1) var(--delay) both; }
.layer { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; fill: var(--dc); stroke: #3a3f2c; stroke-width: 2.4; stroke-linejoin: round; }
.layer .facet { fill: none; stroke: rgba(40, 46, 30, .55); stroke-width: 1.6; }
.layer .pip { fill: var(--ink); stroke: none; font: 800 30px "Bahnschrift", "Segoe UI", system-ui, sans-serif; }
.cf { position: absolute; inset: 0; display: grid; place-items: center; font: 800 26px "Bahnschrift", "Segoe UI", system-ui, sans-serif; color: var(--ink); border: 1.5px solid #3a3f2c; border-radius: 9px; backface-visibility: hidden;
background: linear-gradient(145deg, var(--dc), color-mix(in srgb, var(--dc) 78%, #6d664a)); box-shadow: inset 0 0 8px rgba(0, 0, 0, .12); }
.cf-front { transform: translateZ(29px); }
.cf-back { transform: rotateY(180deg) translateZ(29px); }
.cf-right { transform: rotateY(90deg) translateZ(29px); }
.cf-left { transform: rotateY(-90deg) translateZ(29px); }
.cf-top { transform: rotateX(90deg) translateZ(29px); }
.cf-bottom { transform: rotateX(-90deg) translateZ(29px); }
@keyframes fly {
0% { transform: translate3d(var(--dx), var(--dy), 260px); }
56% { transform: translate3d(0, 0, 0); }
68% { transform: translate3d(calc(var(--dx) * -.05), -28px, 46px); }
80% { transform: translate3d(0, 0, 0); }
89% { transform: translate3d(0, -9px, 14px); }
100% { transform: translate3d(0, 0, 0); }
}
@keyframes tumble {
0% { transform: rotateX(var(--rx0)) rotateY(var(--ry0)) rotateZ(var(--rz0)); }
100% { transform: rotateX(var(--rx1)) rotateY(var(--ry1)) rotateZ(var(--rz1)); }
}
@keyframes shadowin { from { opacity: 0; transform: scale(.4); } to { opacity: 1; transform: scale(1); } }
@keyframes resin { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
@keyframes trayin { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) { .flight, .spin, .dieshadow, .traygroup { animation: none; } .dieshadow { opacity: 1; } }
@media (max-width: 1050px) { .dicetray3d { right: 1rem; top: auto; bottom: 5.6rem; } }