Lobby: dice are thrown onto the table and rest at random spots; fix duplicate dice for the roller

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-22 13:20:55 +00:00
parent 890cbfa993
commit 3e67aba75c
2 changed files with 55 additions and 30 deletions
+40 -24
View File
@@ -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,38 +309,51 @@ 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) => (
<span key={i} className="thrown" style={{ left: `${p.x}%`, top: `${p.y}%`, '--sx': `${p.sx}px`, '--sy': `${p.sy}px`, '--sr': `${p.sr}deg`, '--er': `${p.er}deg`,
'--dc': sec ? '#8b8f85' : m.color || 'var(--noxium)', animationDelay: `${i * 90}ms` }}>
{m.kind === 'check' ? <Die sides={100} value={m.check.roll} delay={0} />
: <Die sides={m.roll.sides} value={m.roll.results ? m.roll.results[i] : 0} delay={i * 120} mystery={!m.roll.results} />}
</span>
))
}
function DiceTray({ rolls }) {
if (!rolls.length) return null
return (
<div className="dicetray" aria-hidden="true">
{rolls.map((m) => <ThrownDice key={m.key} m={m} />)}
<div className="tablelog">
{rolls.map((m) => {
const style = { '--dc': m.color || 'var(--noxium)' }
const who = <b style={{ color: m.color }}>{m.mine ? 'You' : m.from}</b>
if (m.kind === 'check') {
const c = m.check
return (
<div key={m.key} className="trayroll" style={style}>
<div className="small"><b style={{ color: m.color }}>{m.mine ? 'You' : m.from}</b> – {c.what}{c.manual ? ' · own dice' : ''}</div>
<div className="trayrow"><Die sides={100} value={c.roll} delay={0} />
<span className="trayout"><span className={`out ${c.outcome}`}>{OUTCOME[c.outcome]}</span>
{c.target !== undefined && <small>{c.roll} vs {c.target}</small>}</span></div>
</div>
)
return <div key={m.key} className="trayroll">{who} – {c.what}{c.manual ? ' · own dice' : ''}: <span className={`out ${c.outcome}`}>{OUTCOME[c.outcome]}</span>{c.target !== undefined && <small> ({c.roll} vs {c.target})</small>}</div>
}
const t = rollText(m)
const sec = m.roll.mystery && !m.mine
return (
<div key={m.key} className="trayroll" style={sec ? { '--dc': '#8b8f85' } : style}>
<div className="small"><b style={{ color: m.color }}>{m.mine ? 'You' : m.from}</b> rolled <strong>{t.dice}</strong>
{m.to ? (m.to === m.from ? ' · only you' : ` · private${m.mine ? ` to ${m.to}` : ''}`) : ''}{m.roll.manual ? ' · own dice' : ''}</div>
<div className="trayrow">
{Array.from({ length: t.n }, (_, i) => <Die key={i} sides={m.roll.sides} value={m.roll.results ? m.roll.results[i] : 0} delay={i * 120} mystery={!m.roll.results} />)}
{t.n > 1 && t.sum !== null && <span className="traytotal">= {t.sum}</span>}
</div>
</div>
)
return <div key={m.key} className="trayroll">{who} rolled <strong>{t.dice}</strong>{m.to ? (m.to === m.from ? ' · only you' : ` · private${m.mine ? ` to ${m.to}` : ''}`) : ''}{m.roll.manual ? ' · own dice' : ''}
{t.sum !== null ? <> = <b>{t.sum}</b></> : ' = ?'}</div>
})}
</div>
</div>
)
}
@@ -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)
+9
View File
@@ -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; } }