feat(worksheets): smooth dice rotation settle to final face

Instead of abruptly snapping to the final face, the dice now:
- Gradually lerps rotation toward target face as it approaches home
- Uses cubic easing for natural deceleration
- Normalizes angles for shortest rotation path
- Waits until rotation is within 5° of target before stopping

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-12-05 17:32:02 -06:00
parent c6db7dcfa2
commit d00c70750e
1 changed files with 64 additions and 10 deletions

View File

@ -251,6 +251,9 @@ export function PreviewCenter({
// Ref to the portal dice element for direct DOM manipulation during drag/flying (avoids re-renders)
const portalDiceRef = useRef<HTMLDivElement>(null)
// Compute target rotation for the current face (needed by physics simulation)
const targetFaceRotation = DICE_FACE_ROTATIONS[currentFace] || { rotateX: 0, rotateY: 0 }
// Physics simulation for thrown dice - uses direct DOM manipulation for performance
useEffect(() => {
if (!isFlying) return
@ -345,9 +348,51 @@ export function PreviewCenter({
// Update rotation based on velocity (dice rolls as it moves)
const speed = Math.sqrt(p.vx * p.vx + p.vy * p.vy)
p.rotationX += p.vy * ROTATION_FACTOR * 12
p.rotationY -= p.vx * ROTATION_FACTOR * 12
p.rotationZ += speed * ROTATION_FACTOR * 3
// As dice gets closer to home, gradually lerp rotation toward final face
// settleProgress: 0 = far away (full physics rotation), 1 = at home (target rotation)
const SETTLE_START_DIST = 150 // Start settling rotation at this distance
const settleProgress = Math.max(0, 1 - dist / SETTLE_START_DIST)
const settleFactor = settleProgress * settleProgress * settleProgress // Cubic easing for smooth settle
// Physics rotation (tumbling)
const physicsRotationDelta = {
x: p.vy * ROTATION_FACTOR * 12,
y: -p.vx * ROTATION_FACTOR * 12,
z: speed * ROTATION_FACTOR * 3,
}
// Apply physics rotation, but reduced as we settle
p.rotationX += physicsRotationDelta.x * (1 - settleFactor)
p.rotationY += physicsRotationDelta.y * (1 - settleFactor)
p.rotationZ += physicsRotationDelta.z * (1 - settleFactor)
// Lerp toward target face rotation as we settle
// Target rotation should show the correct face (from DICE_FACE_ROTATIONS)
const lerpSpeed = 0.08 * settleFactor // Faster lerp as we get closer
if (settleFactor > 0.01) {
// Normalize rotations to find shortest path to target
const targetX = targetFaceRotation.rotateX
const targetY = targetFaceRotation.rotateY
const targetZ = 0 // Final Z rotation should be 0 (flat)
// Normalize current rotation to -180 to 180 range for smooth interpolation
const normalizeAngle = (angle: number) => {
let normalized = angle % 360
if (normalized > 180) normalized -= 360
if (normalized < -180) normalized += 360
return normalized
}
const currentX = normalizeAngle(p.rotationX)
const currentY = normalizeAngle(p.rotationY)
const currentZ = normalizeAngle(p.rotationZ)
// Lerp each axis toward target
p.rotationX = currentX + (targetX - currentX) * lerpSpeed
p.rotationY = currentY + (targetY - currentY) * lerpSpeed
p.rotationZ = currentZ + (targetZ - currentZ) * lerpSpeed
}
// Update scale - grow when far/fast, shrink when close/slow
const targetScale =
@ -377,9 +422,19 @@ export function PreviewCenter({
diceEl.style.transform = `rotateX(${p.rotationX}deg) rotateY(${p.rotationY}deg) rotateZ(${p.rotationZ}deg)`
}
// Check if we should stop - snap to home when close and slow
// Check if we should stop - snap to home when close, slow, small, AND rotation is settled
const totalVelocity = Math.sqrt(p.vx * p.vx + p.vy * p.vy)
if (dist < STOP_THRESHOLD && totalVelocity < VELOCITY_THRESHOLD && p.scale < 1.1) {
const rotationSettled =
Math.abs(p.rotationX - targetFaceRotation.rotateX) < 5 &&
Math.abs(p.rotationY - targetFaceRotation.rotateY) < 5 &&
Math.abs(p.rotationZ) < 5
if (
dist < STOP_THRESHOLD &&
totalVelocity < VELOCITY_THRESHOLD &&
p.scale < 1.1 &&
rotationSettled
) {
// Dice has returned home - clear shadow
el.style.filter = 'none'
setIsFlying(false)
@ -388,8 +443,8 @@ export function PreviewCenter({
y: 0,
vx: 0,
vy: 0,
rotationX: 0,
rotationY: 0,
rotationX: targetFaceRotation.rotateX,
rotationY: targetFaceRotation.rotateY,
rotationZ: 0,
scale: 1,
}
@ -406,10 +461,9 @@ export function PreviewCenter({
cancelAnimationFrame(animationFrameRef.current)
}
}
}, [isFlying, diceOrigin.x, diceOrigin.y])
}, [isFlying, diceOrigin.x, diceOrigin.y, targetFaceRotation.rotateX, targetFaceRotation.rotateY])
// Compute target rotation: add dramatic spins, then land on the face rotation
const targetFaceRotation = DICE_FACE_ROTATIONS[currentFace] || { rotateX: 0, rotateY: 0 }
// Compute dice rotation for react-spring animation (used when not flying)
const diceRotation = {
rotateX: spinCount * 360 + targetFaceRotation.rotateX,
rotateY: spinCount * 360 + targetFaceRotation.rotateY,