Lobby: CSS 3D dice tray at the top right next to the chat
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
This commit is contained in:
@@ -89,10 +89,10 @@ The Summary page and the sheet tabs in the lobbies show the character as **A4 pa
|
|||||||
|
|
||||||
The **Lobbies** tab groups a campaign. Whoever creates a lobby is its DM and gets an 8-character join code. Players join with the code and pick one of their saved characters (they can switch it later or leave).
|
The **Lobbies** tab groups a campaign. Whoever creates a lobby is its DM and gets an 8-character join code. Players join with the code and pick one of their saved characters (they can switch it later or leave).
|
||||||
|
|
||||||
The lobby page is built for desktop: the main area on the left, the chat as a column at the right edge of the window and a dice hotbar at the bottom centre of the main area.
|
The lobby page is built for desktop: the main area on the left, the dice tray at the top right, the chat as a column at the right edge of the window and a dice hotbar at the bottom centre of the main area.
|
||||||
|
|
||||||
- **Chat** (right column): live chat for everyone, plus **whispers** to a single person (pick them in the dropdown next to the message field). Whispers are only visible to sender and recipient, the DM does not see other people's whispers. The same feed shows the **event log** (joins, leaves, character switches, renames, new codes); it can be hidden with a checkbox. The chat polls the server every few seconds, keeps the last 1000 entries per lobby and allows 500 characters per message and 30 messages per minute.
|
- **Chat** (right column): live chat for everyone, plus **whispers** to a single person (pick them in the dropdown next to the message field). Whispers are only visible to sender and recipient, the DM does not see other people's whispers. The same feed shows the **event log** (joins, leaves, character switches, renames, new codes); it can be hidden with a checkbox. The chat polls the server every few seconds, keeps the last 1000 entries per lobby and allows 500 characters per message and 30 messages per minute.
|
||||||
- **Dice** (bottom centre): d4, d6, d8, d10, d12, d20 and d100, with a count (1 to 10 dice). A click rolls on the server (so nobody can cheat), the dice tumble into a tray above the hotbar for everybody in the lobby and the result is posted in the chat. Rolls follow the chat's recipient dropdown: with a person selected it is a private roll only that person (and you) can see. **The DM's rolls are hidden from the players** (the hotbar says "Hidden (only you)"); the DM can still pick a player as recipient to show them a roll.
|
- **Dice** (bottom centre): d4, d6, d8, d10, d12, d20 and d100, with a count (1 to 10 dice). A click rolls on the server (so nobody can cheat), the dice are thrown into a tray at the top right, next to the chat, for everybody in the lobby (CSS 3D: the d6 is a real cube, the other dice are faceted slabs; the animation always lands on the server's result) and the result is posted in the chat. Rolls follow the chat's recipient dropdown: with a person selected it is a private roll only that person (and you) can see. **The DM's rolls are hidden from the players** (the hotbar says "Hidden (only you)"); the DM can still pick a player as recipient to show them a roll.
|
||||||
- **Party** (tab): member cards (name, concept, class, profession). The DM also finds the join code, rename, remove player and delete lobby here.
|
- **Party** (tab): member cards (name, concept, class, profession). The DM also finds the join code, rename, remove player and delete lobby here.
|
||||||
- **One sheet tab per character** (next to Party): the DM gets a tab for every character in the lobby (read-only, always the current saved version, refreshed every 15 s); a player gets "My character". Hover or tap a value to see how it is made up. "Open in the Character tab" jumps to the full editor (for the DM it is a read-only look, "Save" would create the DM's own copy).
|
- **One sheet tab per character** (next to Party): the DM gets a tab for every character in the lobby (read-only, always the current saved version, refreshed every 15 s); a player gets "My character". Hover or tap a value to see how it is made up. "Open in the Character tab" jumps to the full editor (for the DM it is a read-only look, "Save" would create the DM's own copy).
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
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 }
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import PaperSheet from './PaperSheet.jsx'
|
import PaperSheet from './PaperSheet.jsx'
|
||||||
|
import DiceTray from './DiceTray.jsx'
|
||||||
|
|
||||||
const J = { 'Content-Type': 'application/json' }
|
const J = { 'Content-Type': 'application/json' }
|
||||||
const send = (url, method, body) => fetch(url, { method, headers: J, body: body === undefined ? undefined : JSON.stringify(body) })
|
const send = (url, method, body) => fetch(url, { method, headers: J, body: body === undefined ? undefined : JSON.stringify(body) })
|
||||||
@@ -128,7 +129,6 @@ function Card({ m, isDm, onOpen, onKick }) {
|
|||||||
|
|
||||||
const clock = (t) => new Date(t * 1000).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
|
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 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.
|
// Chat, whispers, event log and dice rolls arrive through one feed that is polled while the page is open.
|
||||||
function useFeed(id, onEvent, onRoll) {
|
function useFeed(id, onEvent, onRoll) {
|
||||||
@@ -271,44 +271,6 @@ 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
|
// ---- sheet of one character, refreshed regularly so the DM sees changes the player saves
|
||||||
function CharTab({ cid, owner, rules, onOpen, name }) {
|
function CharTab({ cid, owner, rules, onOpen, name }) {
|
||||||
const [data, setData] = useState(null)
|
const [data, setData] = useState(null)
|
||||||
@@ -359,8 +321,8 @@ function LobbyView({ id, rules, user, onOpen }) {
|
|||||||
|
|
||||||
const onRoll = useCallback((m) => {
|
const onRoll = useCallback((m) => {
|
||||||
const key = `${m.id}`
|
const key = `${m.id}`
|
||||||
setTray((t) => [...t.slice(-2), { ...m, key }])
|
setTray((t) => [...t.slice(-1), { ...m, key }])
|
||||||
setTimeout(() => setTray((t) => t.filter((x) => x.key !== key)), 9000)
|
setTimeout(() => setTray((t) => t.filter((x) => x.key !== key)), 10000)
|
||||||
}, [])
|
}, [])
|
||||||
const feed = useFeed(id, load, onRoll)
|
const feed = useFeed(id, load, onRoll)
|
||||||
|
|
||||||
@@ -462,7 +424,7 @@ function LobbyView({ id, rules, user, onOpen }) {
|
|||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DiceTray rolls={tray} me={user} />
|
<DiceTray rolls={tray} />
|
||||||
<DiceBar id={id} to={dest} poll={feed.poll} isDm={lobby.is_dm} />
|
<DiceBar id={id} to={dest} poll={feed.poll} isDm={lobby.is_dm} />
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|||||||
+45
-18
@@ -228,24 +228,6 @@ body:has(.lobbychat) main { max-width: none; margin: 0; padding-right: calc(var(
|
|||||||
.diebtn:hover:not(:disabled) { border-color: var(--noxium); color: var(--noxium); }
|
.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 { margin-left: .4rem; padding-left: .6rem; border-left: 1px solid var(--line); }
|
||||||
.dicedest.priv { color: #8a63c7; font-weight: 600; }
|
.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); } }
|
@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; }
|
.lobbycard { align-content: start; }
|
||||||
@@ -328,3 +310,48 @@ body:has(.lobbychat) main { max-width: none; margin: 0; padding-right: calc(var(
|
|||||||
.lobbyhead h1 { font-size: 1.3rem; margin: 0; }
|
.lobbyhead h1 { font-size: 1.3rem; margin: 0; }
|
||||||
.lobbypage .tabs { margin: .4rem 0 .7rem; }
|
.lobbypage .tabs { margin: .4rem 0 .7rem; }
|
||||||
.lobbypage .tabs button { padding: .3rem .8rem; }
|
.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; } }
|
||||||
|
|||||||
Reference in New Issue
Block a user