feat: improve hover avatars with smooth animation and 3D elevation
Major improvements to networked hover presence feature: FIXES: - Only send hover events when it's YOUR TURN (not all sessions) * Check isLocal property of current player before sending hover * Prevents all players from broadcasting hover simultaneously VISUAL ENHANCEMENTS: - 3D elevation effect with layered shadows for floating appearance * Large shadow: 0 8px 20px rgba(0,0,0,0.4) * Medium shadow: 0 4px 8px rgba(0,0,0,0.3) * Glow effect: 0 0 30px with player color * Drop-shadow filter for extra depth * 48px avatar size (up from 40px) SMOOTH ANIMATIONS: - React-spring for butter-smooth position transitions * Tension: 280, Friction: 60, Mass: 1 * Avatars glide smoothly between cards as mouse moves * No jank, feels like remote mouse cursor - Floating animation with vertical bob * 6px up/down motion over 2s * Creates illusion of hovering above surface * Combined with horizontal spring animation ARCHITECTURE: - HoverAvatar component uses fixed positioning - Card refs stored in Map for position tracking - Avatar positions calculated from card getBoundingClientRect() - Rendered outside card wrapper to avoid clipping - Position updates trigger spring animations automatically RESULT: Feels like you're literally watching someone's mouse move over the cards from across the internet. Smooth, elevated, and visually distinct. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useSpring, animated } from '@react-spring/web'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { useGameMode } from '../../../../contexts/GameModeContext'
|
||||
import { useMemoryPairs } from '../context/MemoryPairsContext'
|
||||
@@ -81,10 +82,88 @@ function useGridDimensions(gridConfig: any, totalCards: number) {
|
||||
return gridDimensions
|
||||
}
|
||||
|
||||
// Animated hover avatar component
|
||||
function HoverAvatar({
|
||||
playerId,
|
||||
playerInfo,
|
||||
cardElement,
|
||||
}: {
|
||||
playerId: string
|
||||
playerInfo: { emoji: string; name: string; color?: string }
|
||||
cardElement: HTMLElement | null
|
||||
}) {
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 })
|
||||
|
||||
// Update position when card element changes
|
||||
useEffect(() => {
|
||||
if (cardElement) {
|
||||
const rect = cardElement.getBoundingClientRect()
|
||||
setPosition({
|
||||
x: rect.right - 12, // Position at top-right of card
|
||||
y: rect.top - 12,
|
||||
})
|
||||
}
|
||||
}, [cardElement])
|
||||
|
||||
// Smooth spring animation for position changes
|
||||
const springProps = useSpring({
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
config: {
|
||||
tension: 280,
|
||||
friction: 60,
|
||||
mass: 1,
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<animated.div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: springProps.x,
|
||||
top: springProps.y,
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
borderRadius: '50%',
|
||||
background: playerInfo.color || 'linear-gradient(135deg, #667eea, #764ba2)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '28px',
|
||||
// 3D elevation effect
|
||||
boxShadow:
|
||||
'0 8px 20px rgba(0,0,0,0.4), 0 4px 8px rgba(0,0,0,0.3), 0 0 30px rgba(102, 126, 234, 0.7)',
|
||||
border: '3px solid white',
|
||||
zIndex: 1000,
|
||||
pointerEvents: 'none',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
filter: 'drop-shadow(0 0 8px rgba(102, 126, 234, 0.8))',
|
||||
}}
|
||||
className={css({
|
||||
animation: 'hoverFloat 2s ease-in-out infinite',
|
||||
})}
|
||||
title={`${playerInfo.name} is considering this card`}
|
||||
>
|
||||
{playerInfo.emoji}
|
||||
</animated.div>
|
||||
)
|
||||
}
|
||||
|
||||
export function MemoryGrid() {
|
||||
const { state, flipCard, hoverCard } = useMemoryPairs()
|
||||
const { state, flipCard, hoverCard, gameMode } = useMemoryPairs()
|
||||
const { players: playerMap } = useGameMode()
|
||||
|
||||
// Track card element refs for positioning hover avatars
|
||||
const cardRefs = useRef<Map<string, HTMLElement>>(new Map())
|
||||
|
||||
// Check if it's the local player's turn
|
||||
const isMyTurn = useMemo(() => {
|
||||
if (gameMode === 'single') return true // Always your turn in single player
|
||||
|
||||
const currentPlayerData = playerMap.get(state.currentPlayer)
|
||||
return currentPlayerData?.isLocal === true
|
||||
}, [state.currentPlayer, playerMap, gameMode])
|
||||
|
||||
// Hooks must be called before early return
|
||||
const gridConfig = useMemo(() => getGridConfiguration(state.difficulty), [state.difficulty])
|
||||
const gridDimensions = useGridDimensions(gridConfig, state.gameCards.length)
|
||||
@@ -118,6 +197,15 @@ export function MemoryGrid() {
|
||||
: null
|
||||
}
|
||||
|
||||
// Set card ref callback
|
||||
const setCardRef = (cardId: string) => (element: HTMLDivElement | null) => {
|
||||
if (element) {
|
||||
cardRefs.current.set(cardId, element)
|
||||
} else {
|
||||
cardRefs.current.delete(cardId)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
@@ -185,6 +273,7 @@ export function MemoryGrid() {
|
||||
return (
|
||||
<div
|
||||
key={card.id}
|
||||
ref={setCardRef(card.id)}
|
||||
className={css({
|
||||
aspectRatio: '3/4',
|
||||
// Fully responsive card sizing - no fixed pixel sizes
|
||||
@@ -195,17 +284,17 @@ export function MemoryGrid() {
|
||||
opacity: isDimmed ? 0.3 : 1,
|
||||
transition: 'opacity 0.3s ease',
|
||||
filter: isDimmed ? 'grayscale(0.7)' : 'none',
|
||||
position: 'relative', // For avatar positioning
|
||||
position: 'relative',
|
||||
})}
|
||||
onMouseEnter={() => {
|
||||
// Send hover state when mouse enters card (if not matched)
|
||||
if (hoverCard && !isMatched) {
|
||||
// Only send hover if it's your turn and card is not matched
|
||||
if (hoverCard && !isMatched && isMyTurn) {
|
||||
hoverCard(card.id)
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
// Clear hover state when mouse leaves card
|
||||
if (hoverCard && !isMatched) {
|
||||
if (hoverCard && !isMatched && isMyTurn) {
|
||||
hoverCard(null)
|
||||
}
|
||||
}}
|
||||
@@ -217,43 +306,6 @@ export function MemoryGrid() {
|
||||
onClick={() => (isValidForSelection ? handleCardClick(card.id) : undefined)}
|
||||
disabled={state.isProcessingMove || !isValidForSelection}
|
||||
/>
|
||||
|
||||
{/* Hover Avatars - Show which players are hovering over this card */}
|
||||
{state.playerHovers &&
|
||||
Object.entries(state.playerHovers)
|
||||
.filter(([playerId, hoveredCardId]) => hoveredCardId === card.id)
|
||||
.map(([playerId]) => {
|
||||
const playerInfo = getPlayerHoverInfo(playerId)
|
||||
if (!playerInfo) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
key={playerId}
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '-12px',
|
||||
right: '-12px',
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '50%',
|
||||
background: playerInfo.color || 'linear-gradient(135deg, #667eea, #764ba2)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '24px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.3), 0 0 20px rgba(102, 126, 234, 0.6)',
|
||||
border: '3px solid white',
|
||||
zIndex: 100,
|
||||
animation: 'hoverPulse 1.5s ease-in-out infinite',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0.0, 0.2, 1)',
|
||||
pointerEvents: 'none',
|
||||
})}
|
||||
title={`${playerInfo.name} is considering this card`}
|
||||
>
|
||||
{playerInfo.emoji}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -306,6 +358,26 @@ export function MemoryGrid() {
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Animated Hover Avatars - Rendered as fixed positioned elements that smoothly transition */}
|
||||
{state.playerHovers &&
|
||||
Object.entries(state.playerHovers)
|
||||
.filter(([_, cardId]) => cardId !== null) // Only show if hovering a card
|
||||
.map(([playerId, cardId]) => {
|
||||
const playerInfo = getPlayerHoverInfo(playerId)
|
||||
const cardElement = cardId ? cardRefs.current.get(cardId) : null
|
||||
|
||||
if (!playerInfo || !cardElement) return null
|
||||
|
||||
return (
|
||||
<HoverAvatar
|
||||
key={playerId}
|
||||
playerId={playerId}
|
||||
playerInfo={playerInfo}
|
||||
cardElement={cardElement}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -318,14 +390,12 @@ const gridAnimations = `
|
||||
75% { transform: translate(-50%, -50%) translateX(5px); }
|
||||
}
|
||||
|
||||
@keyframes hoverPulse {
|
||||
@keyframes hoverFloat {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3), 0 0 20px rgba(102, 126, 234, 0.6);
|
||||
transform: translate(-50%, -50%) translateY(0px);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 6px 16px rgba(0,0,0,0.4), 0 0 30px rgba(102, 126, 234, 0.9);
|
||||
transform: translate(-50%, -50%) translateY(-6px);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
Reference in New Issue
Block a user