diff --git a/lib/Die.test.tsx b/lib/Die.test.tsx index 95d7079..b76bd39 100644 --- a/lib/Die.test.tsx +++ b/lib/Die.test.tsx @@ -259,5 +259,62 @@ describe('Die', () => { const face = container.querySelector('.face') as HTMLElement expect(face.style.boxShadow).toBe('') }) + + it('does not render gap fillers while the die is at rest', () => { + const { container } = renderDie({ dieCornerRadius: 10 }) + expect(container.querySelectorAll('.die-core')).toHaveLength(0) + }) + + it('renders a solid core to back the corner gaps while rolling', () => { + vi.useFakeTimers() + try { + const { container, ref } = renderDie({ dieCornerRadius: 10, dieSize: 60, faceColor: 'rgb(255, 0, 0)' }) + act(() => ref.current!.rollDie(4)) + const core = container.querySelectorAll('.die-core') + expect(core).toHaveLength(6) + const coreFace = core[0] as HTMLElement + expect(coreFace.style.background).toBe('rgb(255, 0, 0)') + // dieSize - 2 * cornerRadius = 60 - 20 = 40 + expect(coreFace.style.width).toBe('40px') + expect(coreFace.style.height).toBe('40px') + } finally { + vi.useRealTimers() + } + }) + + it('removes the gap fillers once the roll settles', () => { + vi.useFakeTimers() + try { + const { container, ref } = renderDie({ dieCornerRadius: 10, rollTime: 2 }) + act(() => ref.current!.rollDie(4)) + expect(container.querySelectorAll('.die-core')).toHaveLength(6) + act(() => vi.advanceTimersByTime(2000)) + expect(container.querySelectorAll('.die-core')).toHaveLength(0) + } finally { + vi.useRealTimers() + } + }) + + it('omits the fillers when cornerRadius would consume the whole die', () => { + vi.useFakeTimers() + try { + const { container, ref } = renderDie({ dieCornerRadius: 30, dieSize: 60 }) + act(() => ref.current!.rollDie(4)) + expect(container.querySelectorAll('.die-core')).toHaveLength(0) + } finally { + vi.useRealTimers() + } + }) + + it('omits the fillers when cornerRadius is 0', () => { + vi.useFakeTimers() + try { + const { container, ref } = renderDie({ dieCornerRadius: 0 }) + act(() => ref.current!.rollDie(4)) + expect(container.querySelectorAll('.die-core')).toHaveLength(0) + } finally { + vi.useRealTimers() + } + }) }) }) diff --git a/lib/Die.tsx b/lib/Die.tsx index 8b30593..d21b2e9 100644 --- a/lib/Die.tsx +++ b/lib/Die.tsx @@ -64,6 +64,10 @@ const Die = forwardRef( // className-reset + forced-reflow hack that could fail to restart on // rapid/overlapping rolls). const [rollKey, setRollKey] = useState(0) + // Only fill the corner/interior gaps while the die is mid-roll. At rest the + // die sits face-on, where a rounded cube correctly shows empty corners — the + // fillers there would just be visible scallops poking past the silhouette. + const [isRolling, setIsRolling] = useState(false) // Only d6 faces are rendered; clamp to 1-6 regardless of sides prop const getRandomInt = () => { @@ -79,8 +83,10 @@ const Die = forwardRef( setDieValue(roll) setHasRolled(true) setRollKey((k) => k + 1) + setIsRolling(true) if (timeoutRef.current !== null) clearTimeout(timeoutRef.current) timeoutRef.current = setTimeout(() => { + setIsRolling(false) onRollDone(roll) timeoutRef.current = null }, rollTime * 1000) @@ -150,6 +156,35 @@ const Die = forwardRef( bottom: `${dieSize / 6}px`, right: `${dieSize / 6}px`, } + // Solid inner core to back the gaps left by rounded faces. The cube is + // hollow, so when dieCornerRadius is large the rounded-away corners of + // adjacent faces let the page background show through during a roll. A + // smaller solid cube in faceColor sits just inside the shell so those gaps + // reveal the die's own color instead of empty space. It stays strictly + // inside the shell silhouette, so it never pokes past the faces. + const coreSize = Math.max(dieSize - dieCornerRadius * 2, 0) + const showFillers = isRolling && dieCornerRadius > 0 && coreSize > 0 + const coreInset = (dieSize - coreSize) / 2 + const coreFaceStyle: React.CSSProperties = { + background: faceColor, + height: `${coreSize}px`, + width: `${coreSize}px`, + position: 'absolute', + top: `${coreInset}px`, + left: `${coreInset}px`, + // visible from both sides so a gap always shows faceColor at any angle + backfaceVisibility: 'visible', + WebkitBackfaceVisibility: 'visible', + } + const coreTransforms = [ + `rotateY(0deg) translateZ(${coreSize / 2}px)`, + `rotateX(180deg) translateZ(${coreSize / 2}px)`, + `rotateY(90deg) translateZ(${coreSize / 2}px)`, + `rotateY(-90deg) translateZ(${coreSize / 2}px)`, + `rotateX(90deg) translateZ(${coreSize / 2}px)`, + `rotateX(-90deg) translateZ(${coreSize / 2}px)`, + ] + // roll styles const rollStyle = { animationDuration: `${rollTime}s`, @@ -180,6 +215,15 @@ const Die = forwardRef( className={`die ${hasRolled ? 'roll' : 'init-roll'}${dieValue}`} style={rollStyle} > + {showFillers && + coreTransforms.map((transform, i) => ( +