feat(card-sorting): smooth spring transition from game table to results grid

Implemented smooth react-spring animations for cards transitioning from
their final game table positions to the results grid layout.

Changes:
- Use useSprings to animate each card individually
- Start cards at their actual game table positions (from state.cardPositions)
- Convert percentage-based positions to pixels for the results view
- Smoothly animate to grid layout after 2 seconds
- Preserve rotation during initial transition
- Use react-spring config.gentle for smooth, natural motion

This creates a visually pleasing transition that helps players understand
how their arrangement maps to the final results grid.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-10-23 22:54:19 -05:00
parent 077b457fe7
commit c5f39d51eb
1 changed files with 75 additions and 31 deletions

View File

@ -2,8 +2,8 @@
import { css } from '../../../../styled-system/css' import { css } from '../../../../styled-system/css'
import { useCardSorting } from '../Provider' import { useCardSorting } from '../Provider'
import { useSpring, animated, config } from '@react-spring/web' import { useSpring, animated, config, useSprings } from '@react-spring/web'
import { useState, useEffect } from 'react' import { useState, useEffect, useRef } from 'react'
import type { SortingCard } from '../types' import type { SortingCard } from '../types'
// Add result animations // Add result animations
@ -49,12 +49,26 @@ export function ResultsPhase() {
// Get user's sequence from placedCards // Get user's sequence from placedCards
const userSequence = state.placedCards.filter((c): c is SortingCard => c !== null) const userSequence = state.placedCards.filter((c): c is SortingCard => c !== null)
// Calculate positions for cards in a compact grid layout // Get viewport dimensions for converting percentage positions to pixels
const calculateCardPositions = ( const containerRef = useRef<HTMLDivElement>(null)
cards: SortingCard[], const [viewportDimensions, setViewportDimensions] = useState({ width: 1000, height: 800 })
shouldCorrect: boolean
): Map<string, CardPosition> => { useEffect(() => {
const positions = new Map<string, CardPosition>() const updateDimensions = () => {
if (containerRef.current) {
setViewportDimensions({
width: containerRef.current.offsetWidth,
height: containerRef.current.offsetHeight,
})
}
}
updateDimensions()
window.addEventListener('resize', updateDimensions)
return () => window.removeEventListener('resize', updateDimensions)
}, [])
// Calculate grid positions for cards (final positions)
const calculateGridPosition = (cardIndex: number, shouldCorrect: boolean) => {
const gridCols = 3 const gridCols = 3
const cardWidth = 100 const cardWidth = 100
const cardHeight = 130 const cardHeight = 130
@ -62,33 +76,62 @@ export function ResultsPhase() {
const startX = 50 const startX = 50
const startY = 100 const startY = 100
cards.forEach((card, index) => { const effectiveIndex = shouldCorrect ? cardIndex : cardIndex
const correctIndex = shouldCorrect
? state.correctOrder.findIndex((c) => c.id === card.id)
: index
const col = correctIndex % gridCols const col = effectiveIndex % gridCols
const row = Math.floor(correctIndex / gridCols) const row = Math.floor(effectiveIndex / gridCols)
positions.set(card.id, { return {
x: startX + col * (cardWidth + gap), x: startX + col * (cardWidth + gap),
y: startY + row * (cardHeight + gap), y: startY + row * (cardHeight + gap),
rotation: 0, rotation: 0,
}) scale: 1,
}) }
return positions
} }
const [cardPositions, setCardPositions] = useState(() => // Get initial positions from game table (percentage-based from state.cardPositions)
calculateCardPositions(userSequence, false) const getInitialPosition = (cardId: string) => {
const cardPos = state.cardPositions.find((p) => p.cardId === cardId)
if (!cardPos) {
return { x: 0, y: 0, rotation: 0, scale: 1 }
}
// Convert percentage to pixels relative to container
return {
x: (cardPos.x / 100) * viewportDimensions.width,
y: (cardPos.y / 100) * viewportDimensions.height,
rotation: cardPos.rotation,
scale: 1,
}
}
// Create springs for each card
const [springs, api] = useSprings(
userSequence.length,
(index) => {
const card = userSequence[index]
const initial = getInitialPosition(card.id)
return {
from: initial,
to: initial,
config: { ...config.gentle, duration: 800 },
}
},
[userSequence]
) )
// Auto-show corrections after 2 seconds // Auto-show corrections and animate to grid after 2 seconds
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
setShowCorrections(true) setShowCorrections(true)
setCardPositions(calculateCardPositions(userSequence, true)) // Animate all cards to their grid positions
api.start((index) => {
const card = userSequence[index]
const correctIndex = state.correctOrder.findIndex((c) => c.id === card.id)
return {
to: calculateGridPosition(correctIndex, true),
config: { ...config.gentle, duration: 800 },
}
})
}, 2000) }, 2000)
return () => clearTimeout(timer) return () => clearTimeout(timer)
}, []) }, [])
@ -155,6 +198,7 @@ export function ResultsPhase() {
> >
{/* Left side: Card visualization */} {/* Left side: Card visualization */}
<div <div
ref={containerRef}
className={css({ className={css({
flex: '0 0 50%', flex: '0 0 50%',
position: 'relative', position: 'relative',
@ -178,8 +222,8 @@ export function ResultsPhase() {
{/* Cards with animated positions */} {/* Cards with animated positions */}
{userSequence.map((card, userIndex) => { {userSequence.map((card, userIndex) => {
const position = cardPositions.get(card.id) const spring = springs[userIndex]
if (!position) return null if (!spring) return null
// Check if this card is correct for its position in the user's sequence // Check if this card is correct for its position in the user's sequence
// Same logic as during gameplay: does the card at this position match the correct card for this position? // Same logic as during gameplay: does the card at this position match the correct card for this position?
@ -191,11 +235,11 @@ export function ResultsPhase() {
key={card.id} key={card.id}
style={{ style={{
position: 'absolute', position: 'absolute',
left: `${position.x}px`, left: spring.x.to((x) => `${x}px`),
top: `${position.y}px`, top: spring.y.to((y) => `${y}px`),
transform: spring.rotation.to((r) => `rotate(${r}deg)`),
width: '100px', width: '100px',
height: '130px', height: '130px',
transition: 'all 0.8s cubic-bezier(0.34, 1.56, 0.64, 1)',
zIndex: 5, zIndex: 5,
}} }}
> >