Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0d08a1aa2 | ||
|
|
62f3730542 | ||
|
|
98822ecda5 | ||
|
|
db9f9096b4 | ||
|
|
1fe507bc12 | ||
|
|
14ba422919 |
20
CHANGELOG.md
20
CHANGELOG.md
@@ -1,3 +1,23 @@
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo, 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'
|
||||
@@ -81,7 +82,8 @@ function useGridDimensions(gridConfig: any, totalCards: number) {
|
||||
}
|
||||
|
||||
export function MemoryGrid() {
|
||||
const { state, flipCard } = useMemoryPairs()
|
||||
const { state, flipCard, hoverCard } = useMemoryPairs()
|
||||
const { players: playerMap } = useGameMode()
|
||||
|
||||
// Hooks must be called before early return
|
||||
const gridConfig = useMemo(() => getGridConfiguration(state.difficulty), [state.difficulty])
|
||||
@@ -95,6 +97,27 @@ 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
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
@@ -172,7 +195,20 @@ export function MemoryGrid() {
|
||||
opacity: isDimmed ? 0.3 : 1,
|
||||
transition: 'opacity 0.3s ease',
|
||||
filter: isDimmed ? 'grayscale(0.7)' : 'none',
|
||||
position: 'relative', // For avatar positioning
|
||||
})}
|
||||
onMouseEnter={() => {
|
||||
// Send hover state when mouse enters card (if not matched)
|
||||
if (hoverCard && !isMatched) {
|
||||
hoverCard(card.id)
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
// Clear hover state when mouse leaves card
|
||||
if (hoverCard && !isMatched) {
|
||||
hoverCard(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<GameCard
|
||||
card={card}
|
||||
@@ -181,6 +217,43 @@ 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>
|
||||
)
|
||||
})}
|
||||
@@ -237,19 +310,30 @@ export function MemoryGrid() {
|
||||
)
|
||||
}
|
||||
|
||||
// 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 hoverPulse {
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// 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.13.0",
|
||||
"private": true,
|
||||
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user