diff --git a/frontend/src/Lobbies.jsx b/frontend/src/Lobbies.jsx
index aec83c4..b635c24 100644
--- a/frontend/src/Lobbies.jsx
+++ b/frontend/src/Lobbies.jsx
@@ -116,11 +116,14 @@ function useFeed(id, onEvent, onRoll) {
const live = useRef(false) // false until the history is loaded: old rolls are not animated again
const cb = useRef({})
cb.current = { onEvent, onRoll }
+ const busy = useRef(false) // two overlapping polls (timer + right after a roll) would both deliver the same entries
const poll = useCallback(async () => {
- if (document.hidden) return
+ if (document.hidden || busy.current) return
+ busy.current = true
const r = await fetch(`/api/lobbies/${id}/messages?after=${last.current}`).catch(() => null)
+ busy.current = false
if (!r || !r.ok) return
- const fresh = await r.json()
+ const fresh = (await r.json()).filter((m) => m.id > last.current)
const wasLive = live.current
live.current = true
if (!fresh.length) return
@@ -306,37 +309,50 @@ function Die({ sides, value, delay, mystery }) {
)
}
-// the tray is the middle of the table: dice in the colour of whoever threw them
+// dice are thrown onto the table from a random side and come to rest at a random spot, in the colour of whoever threw them
+const placed = [] // spots of the dice still lying on the table, so new ones do not land on top of them
+function spots(n) {
+ const out = []
+ for (let i = 0; i < n; i++) {
+ let p, tries = 0
+ do { p = { x: 12 + Math.random() * 76, y: 22 + Math.random() * 58 }; tries++ } while (tries < 40 && [...placed, ...out].some((q) => Math.hypot((q.x - p.x) * 1.6, q.y - p.y) < 14))
+ placed.push(p); if (placed.length > 12) placed.shift()
+ const a = Math.random() * Math.PI * 2
+ out.push({ ...p, sx: Math.round(Math.cos(a) * 700), sy: Math.round(Math.sin(a) * 450), sr: Math.round(Math.random() * 720 - 360), er: Math.round(Math.random() * 50 - 25) })
+ }
+ return out
+}
+
+function ThrownDice({ m }) {
+ const sec = m.kind === 'roll' && m.roll.mystery && !m.mine
+ const n = m.kind === 'check' ? 1 : m.roll.n
+ const [pos] = useState(() => spots(n))
+ return pos.map((p, i) => (
+
+ {m.kind === 'check' ?
+ : }
+
+ ))
+}
+
function DiceTray({ rolls }) {
if (!rolls.length) return null
return (
- {rolls.map((m) => {
- const style = { '--dc': m.color || 'var(--noxium)' }
- if (m.kind === 'check') {
- const c = m.check
- return (
-
-
{m.mine ? 'You' : m.from} – {c.what}{c.manual ? ' · own dice' : ''}
-
- {OUTCOME[c.outcome]}
- {c.target !== undefined && {c.roll} vs {c.target}}
-
- )
- }
- const t = rollText(m)
- const sec = m.roll.mystery && !m.mine
- return (
-
-
{m.mine ? 'You' : m.from} rolled {t.dice}
- {m.to ? (m.to === m.from ? ' · only you' : ` · private${m.mine ? ` to ${m.to}` : ''}`) : ''}{m.roll.manual ? ' · own dice' : ''}
-
- {Array.from({ length: t.n }, (_, i) => )}
- {t.n > 1 && t.sum !== null && = {t.sum}}
-
-
- )
- })}
+ {rolls.map((m) =>
)}
+
+ {rolls.map((m) => {
+ const who =
{m.mine ? 'You' : m.from}
+ if (m.kind === 'check') {
+ const c = m.check
+ return
{who} – {c.what}{c.manual ? ' · own dice' : ''}: {OUTCOME[c.outcome]}{c.target !== undefined && ({c.roll} vs {c.target})}
+ }
+ const t = rollText(m)
+ return
{who} rolled {t.dice}{m.to ? (m.to === m.from ? ' · only you' : ` · private${m.mine ? ` to ${m.to}` : ''}`) : ''}{m.roll.manual ? ' · own dice' : ''}
+ {t.sum !== null ? <> = {t.sum}> : ' = ?'}
+ })}
+
)
}
@@ -505,7 +521,7 @@ function LobbyView({ id, rules, user }) {
const onRoll = useCallback((m) => {
const key = `${m.id}`
setTray((t) => [...t.slice(-2), { ...m, key }])
- setTimeout(() => setTray((t) => t.filter((x) => x.key !== key)), 9000)
+ setTimeout(() => setTray((t) => t.filter((x) => x.key !== key)), 20000)
}, [])
const feed = useFeed(id, load, onRoll)
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 9605f26..3694e56 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -375,3 +375,12 @@ body:has(.lobbychat) main { padding: .5rem calc(var(--chatw) + .5rem) 0 .5rem; }
.lobbychat { position: static; width: auto; height: 28rem; border-width: 1px; border-radius: var(--radius); margin-top: 1rem; }
.lobbygrid { grid-template-columns: minmax(0, 1fr); } .playarea { order: -1; height: 32rem; position: static; } .sheetcol { position: static; max-height: none; }
}
+
+/* the table: dice fly in from a random side and rest at a random spot */
+.table { overflow: hidden; }
+.dicetray { inset: 0; top: 0; left: 0; transform: none; display: block; max-width: none; }
+.thrown { position: absolute; width: 2.9rem; height: 2.9rem; margin: -1.45rem 0 0 -1.45rem; transform: rotate(var(--er)); animation: throwin .8s cubic-bezier(.15, .7, .3, 1) both; filter: drop-shadow(0 4px 4px rgba(0, 0, 0, .35)); }
+@keyframes throwin { from { transform: translate(var(--sx), var(--sy)) rotate(var(--sr)) scale(1.3); opacity: .2; } to { transform: rotate(var(--er)); opacity: 1; } }
+.tablelog { position: absolute; left: .6rem; bottom: .5rem; display: grid; gap: .25rem; max-width: 70%; }
+.tablelog .trayroll { padding: .2rem .6rem; border-radius: 8px; font-size: .85rem; background: color-mix(in srgb, var(--panel) 88%, transparent); border: 1px solid var(--line); border-top: 3px solid var(--line); box-shadow: none; }
+@media (prefers-reduced-motion: reduce) { .thrown { animation: none; } }