Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75b193e1d2 | ||
|
|
8d53b589aa | ||
|
|
af85b3e481 | ||
|
|
573d0df20d | ||
|
|
d312969747 | ||
|
|
7f65a67cef | ||
|
|
4d7f6f469f | ||
|
|
71b11f4ef0 | ||
|
|
e0d08a1aa2 | ||
|
|
62f3730542 | ||
|
|
98822ecda5 | ||
|
|
db9f9096b4 | ||
|
|
1fe507bc12 | ||
|
|
14ba422919 |
48
CHANGELOG.md
48
CHANGELOG.md
@@ -1,3 +1,51 @@
|
||||
## [2.14.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.14.2...v2.14.3) (2025-10-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* enable smooth spring animations between card hovers ([8d53b58](https://github.com/antialias/soroban-abacus-flashcards/commit/8d53b589aa17ebc6d0a9251b3006fd8a90f90a61))
|
||||
|
||||
## [2.14.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.14.1...v2.14.2) (2025-10-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* correct avatar positioning to prevent fly-in animation ([573d0df](https://github.com/antialias/soroban-abacus-flashcards/commit/573d0df20dcdac41021c46feb423dbf3782728f6))
|
||||
|
||||
## [2.14.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.14.0...v2.14.1) (2025-10-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* prevent avatar fly-in and hide local player's own hover ([7f65a67](https://github.com/antialias/soroban-abacus-flashcards/commit/7f65a67cef3d7f0ebce1bd7417972a6138acfc46))
|
||||
|
||||
## [2.14.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.13.0...v2.14.0) (2025-10-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* improve hover avatars with smooth animation and 3D elevation ([71b11f4](https://github.com/antialias/soroban-abacus-flashcards/commit/71b11f4ef08a5f9c3f1c1aaabca21ef023d5c0ce))
|
||||
|
||||
## [2.13.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.12.3...v2.13.0) (2025-10-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* implement networked hover presence for multiplayer gameplay ([62f3730](https://github.com/antialias/soroban-abacus-flashcards/commit/62f3730542334a0580f5dad1c73adc333614ee58))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* move canModifyPlayers logic into provider layer ([db9f909](https://github.com/antialias/soroban-abacus-flashcards/commit/db9f9096b446b078e1b4dfe970723bef54a6f4ae))
|
||||
* properly separate LocalMemoryPairsProvider and RoomMemoryPairsProvider ([98822ec](https://github.com/antialias/soroban-abacus-flashcards/commit/98822ecda52bf004d9950e3f4c92c834fd820e49))
|
||||
|
||||
## [2.12.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.12.2...v2.12.3) (2025-10-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* always show game control buttons in room-based sessions ([14ba422](https://github.com/antialias/soroban-abacus-flashcards/commit/14ba422919abd648e2a134ce167a5e6fd9f84e73))
|
||||
|
||||
## [2.12.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.12.1...v2.12.2) (2025-10-09)
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
'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'
|
||||
import { getGridConfiguration } from '../utils/cardGeneration'
|
||||
import { GameCard } from './GameCard'
|
||||
@@ -80,8 +82,107 @@ 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: number; y: number } | null>(null)
|
||||
const isFirstRender = useRef(true)
|
||||
|
||||
// Update position when card element changes
|
||||
useEffect(() => {
|
||||
if (cardElement) {
|
||||
const rect = cardElement.getBoundingClientRect()
|
||||
// Calculate the actual position we want the avatar centered at (top-right of card)
|
||||
const avatarCenterX = rect.right - 12 // 12px from right edge
|
||||
const avatarCenterY = rect.top - 12 // 12px from top edge
|
||||
|
||||
setPosition({
|
||||
x: avatarCenterX,
|
||||
y: avatarCenterY,
|
||||
})
|
||||
}
|
||||
}, [cardElement])
|
||||
|
||||
// Smooth spring animation for position changes
|
||||
const springProps = useSpring({
|
||||
x: position?.x ?? 0,
|
||||
y: position?.y ?? 0,
|
||||
opacity: position ? 1 : 0,
|
||||
config: {
|
||||
tension: 280,
|
||||
friction: 60,
|
||||
mass: 1,
|
||||
},
|
||||
immediate: isFirstRender.current, // Skip animation on first render only
|
||||
})
|
||||
|
||||
// Clear first render flag after initial render
|
||||
useEffect(() => {
|
||||
if (position && isFirstRender.current) {
|
||||
isFirstRender.current = false
|
||||
}
|
||||
}, [position])
|
||||
|
||||
// Don't render until we have a position
|
||||
if (!position) return null
|
||||
|
||||
return (
|
||||
<animated.div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
// Don't use translate, just position directly at the calculated point
|
||||
left: springProps.x.to((x) => `${x}px`),
|
||||
top: springProps.y.to((y) => `${y}px`),
|
||||
opacity: springProps.opacity,
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
marginLeft: '-24px', // Center horizontally (half of width)
|
||||
marginTop: '-24px', // Center vertically (half of height)
|
||||
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',
|
||||
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 } = 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])
|
||||
@@ -95,6 +196,36 @@ export function MemoryGrid() {
|
||||
flipCard(cardId)
|
||||
}
|
||||
|
||||
// Get player metadata for hover avatars
|
||||
const getPlayerHoverInfo = (playerId: string) => {
|
||||
// Check playerMetadata first (from room members)
|
||||
if (state.playerMetadata && state.playerMetadata[playerId]) {
|
||||
return {
|
||||
emoji: state.playerMetadata[playerId].emoji,
|
||||
name: state.playerMetadata[playerId].name,
|
||||
color: state.playerMetadata[playerId].color,
|
||||
}
|
||||
}
|
||||
// Fall back to local player map
|
||||
const player = playerMap.get(playerId)
|
||||
return player
|
||||
? {
|
||||
emoji: player.emoji,
|
||||
name: player.name,
|
||||
color: player.color,
|
||||
}
|
||||
: 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({
|
||||
@@ -162,6 +293,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
|
||||
@@ -172,7 +304,20 @@ export function MemoryGrid() {
|
||||
opacity: isDimmed ? 0.3 : 1,
|
||||
transition: 'opacity 0.3s ease',
|
||||
filter: isDimmed ? 'grayscale(0.7)' : 'none',
|
||||
position: 'relative',
|
||||
})}
|
||||
onMouseEnter={() => {
|
||||
// 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 && isMyTurn) {
|
||||
hoverCard(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<GameCard
|
||||
card={card}
|
||||
@@ -233,23 +378,57 @@ 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
|
||||
.filter(([playerId]) => {
|
||||
// Don't show your own hover avatar (only show remote players)
|
||||
const player = playerMap.get(playerId)
|
||||
return player?.isLocal !== true
|
||||
})
|
||||
.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>
|
||||
)
|
||||
}
|
||||
|
||||
// Add shake animation for mismatch feedback
|
||||
const shakeAnimation = `
|
||||
// Add animations for mismatch feedback and hover avatars
|
||||
const gridAnimations = `
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translate(-50%, -50%) translateX(0); }
|
||||
25% { transform: translate(-50%, -50%) translateX(-5px); }
|
||||
75% { transform: translate(-50%, -50%) translateX(5px); }
|
||||
}
|
||||
|
||||
@keyframes hoverFloat {
|
||||
0%, 100% {
|
||||
transform: translate(-50%, -50%) translateY(0px);
|
||||
}
|
||||
50% {
|
||||
transform: translate(-50%, -50%) translateY(-6px);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// Inject animation styles
|
||||
if (typeof document !== 'undefined' && !document.getElementById('memory-grid-animations')) {
|
||||
const style = document.createElement('style')
|
||||
style.id = 'memory-grid-animations'
|
||||
style.textContent = shakeAnimation
|
||||
style.textContent = gridAnimations
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { useArcadeRedirect } from '@/hooks/useArcadeRedirect'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { StandardGameLayout } from '../../../../components/StandardGameLayout'
|
||||
import { useFullscreen } from '../../../../contexts/FullscreenContext'
|
||||
@@ -14,9 +13,8 @@ import { SetupPhase } from './SetupPhase'
|
||||
|
||||
export function MemoryPairsGame() {
|
||||
const router = useRouter()
|
||||
const { state, exitSession, resetGame } = useMemoryPairs()
|
||||
const { state, exitSession, resetGame, goToSetup, canModifyPlayers } = useMemoryPairs()
|
||||
const { setFullscreenElement } = useFullscreen()
|
||||
const { canModifyPlayers } = useArcadeRedirect({ currentGame: 'matching' })
|
||||
const gameRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -37,11 +35,14 @@ export function MemoryPairsGame() {
|
||||
exitSession()
|
||||
router.push('/arcade')
|
||||
}}
|
||||
onSetup={() => {
|
||||
// Exit current session and return to arcade (which will redirect to setup)
|
||||
exitSession()
|
||||
router.push('/arcade/matching')
|
||||
}}
|
||||
onSetup={
|
||||
goToSetup
|
||||
? () => {
|
||||
// Transition to setup phase (will pause game if active)
|
||||
goToSetup()
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onNewGame={() => {
|
||||
resetGame()
|
||||
}}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { type ReactNode, useCallback, useEffect, useMemo } from 'react'
|
||||
import { useArcadeRedirect } from '@/hooks/useArcadeRedirect'
|
||||
import { useArcadeSession } from '@/hooks/useArcadeSession'
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
import type { GameMove } from '@/lib/arcade/validation'
|
||||
@@ -38,6 +39,8 @@ const initialState: MemoryPairsState = {
|
||||
originalConfig: undefined,
|
||||
pausedGamePhase: undefined,
|
||||
pausedGameState: undefined,
|
||||
// HOVER: Initialize hover state
|
||||
playerHovers: {},
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -193,6 +196,17 @@ function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): Memor
|
||||
}
|
||||
}
|
||||
|
||||
case 'HOVER_CARD': {
|
||||
// Update player hover state for networked presence
|
||||
return {
|
||||
...state,
|
||||
playerHovers: {
|
||||
...state.playerHovers,
|
||||
[move.playerId]: move.data.cardId,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return state
|
||||
}
|
||||
@@ -204,6 +218,9 @@ export function LocalMemoryPairsProvider({ children }: { children: ReactNode })
|
||||
// NOTE: We deliberately do NOT call useRoomData() for local play
|
||||
const { activePlayerCount, activePlayers: activePlayerIds, players } = useGameMode()
|
||||
|
||||
// Use arcade redirect to determine button visibility for arcade sessions
|
||||
const { canModifyPlayers } = useArcadeRedirect({ currentGame: 'matching' })
|
||||
|
||||
// Get active player IDs directly as strings (UUIDs)
|
||||
const activePlayers = Array.from(activePlayerIds)
|
||||
|
||||
@@ -494,7 +511,7 @@ export function LocalMemoryPairsProvider({ children }: { children: ReactNode })
|
||||
}, [canResumeGame, activePlayers, state.currentPlayer, sendMove])
|
||||
|
||||
const goToSetup = useCallback(() => {
|
||||
// Send GO_TO_SETUP move - synchronized across all room members
|
||||
// Send GO_TO_SETUP move
|
||||
const playerId = activePlayers[0] || state.currentPlayer || ''
|
||||
sendMove({
|
||||
type: 'GO_TO_SETUP',
|
||||
@@ -503,6 +520,22 @@ export function LocalMemoryPairsProvider({ children }: { children: ReactNode })
|
||||
})
|
||||
}, [activePlayers, state.currentPlayer, sendMove])
|
||||
|
||||
const hoverCard = useCallback(
|
||||
(cardId: string | null) => {
|
||||
// HOVER: Send hover state for networked presence
|
||||
// Use current player as the one hovering
|
||||
const playerId = state.currentPlayer || activePlayers[0] || ''
|
||||
if (!playerId) return // No active player to send hover for
|
||||
|
||||
sendMove({
|
||||
type: 'HOVER_CARD',
|
||||
playerId,
|
||||
data: { cardId },
|
||||
})
|
||||
},
|
||||
[state.currentPlayer, activePlayers, sendMove]
|
||||
)
|
||||
|
||||
// NO MORE effectiveState merging! Just use session state directly with gameMode added
|
||||
const effectiveState = { ...state, gameMode } as MemoryPairsState & { gameMode: GameMode }
|
||||
|
||||
@@ -517,6 +550,7 @@ export function LocalMemoryPairsProvider({ children }: { children: ReactNode })
|
||||
currentGameStatistics,
|
||||
hasConfigChanged,
|
||||
canResumeGame,
|
||||
canModifyPlayers, // Arcade sessions: use arcade redirect logic
|
||||
startGame,
|
||||
resumeGame,
|
||||
flipCard,
|
||||
@@ -525,6 +559,7 @@ export function LocalMemoryPairsProvider({ children }: { children: ReactNode })
|
||||
setGameType,
|
||||
setDifficulty,
|
||||
setTurnTimer,
|
||||
hoverCard,
|
||||
exitSession,
|
||||
gameMode,
|
||||
activePlayers,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { type ReactNode, useCallback, useEffect, useMemo } from 'react'
|
||||
import { useArcadeRedirect } from '@/hooks/useArcadeRedirect'
|
||||
import { useArcadeSession } from '@/hooks/useArcadeSession'
|
||||
import { useRoomData } from '@/hooks/useRoomData'
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
@@ -213,6 +214,8 @@ function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): Memor
|
||||
}
|
||||
|
||||
// Provider component for ROOM-BASED play (with network sync)
|
||||
// NOTE: This provider should ONLY be used for room-based multiplayer games.
|
||||
// For arcade sessions without rooms, use LocalMemoryPairsProvider instead.
|
||||
export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
const { data: viewerId } = useViewerId()
|
||||
const { roomData } = useRoomData() // Fetch room data for room-based play
|
||||
@@ -557,6 +560,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
currentGameStatistics,
|
||||
hasConfigChanged,
|
||||
canResumeGame,
|
||||
canModifyPlayers: false, // Room-based games: always show buttons (false = show buttons)
|
||||
startGame,
|
||||
resumeGame,
|
||||
flipCard,
|
||||
|
||||
@@ -120,6 +120,7 @@ export interface MemoryPairsContextValue {
|
||||
currentGameStatistics: GameStatistics
|
||||
gameMode: GameMode // Derived from global context
|
||||
activePlayers: Player[] // Active player IDs from arena
|
||||
canModifyPlayers: boolean // Whether players can be added/removed (controls button visibility)
|
||||
|
||||
// PAUSE/RESUME: Computed pause/resume values
|
||||
hasConfigChanged?: boolean
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { ArcadeGuardedPage } from '@/components/ArcadeGuardedPage'
|
||||
import { MemoryPairsGame } from './components/MemoryPairsGame'
|
||||
import { RoomMemoryPairsProvider } from './context/RoomMemoryPairsProvider'
|
||||
import { LocalMemoryPairsProvider } from './context/LocalMemoryPairsProvider'
|
||||
|
||||
export default function MatchingPage() {
|
||||
return (
|
||||
<ArcadeGuardedPage>
|
||||
<RoomMemoryPairsProvider>
|
||||
<LocalMemoryPairsProvider>
|
||||
<MemoryPairsGame />
|
||||
</RoomMemoryPairsProvider>
|
||||
</LocalMemoryPairsProvider>
|
||||
</ArcadeGuardedPage>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "soroban-monorepo",
|
||||
"version": "2.12.2",
|
||||
"version": "2.14.3",
|
||||
"private": true,
|
||||
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user