Compare commits

...

4 Commits

Author SHA1 Message Date
semantic-release-bot
e0d08a1aa2 chore(release): 2.13.0 [skip ci]
## [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](62f3730542))

### Code Refactoring

* move canModifyPlayers logic into provider layer ([db9f909](db9f9096b4))
* properly separate LocalMemoryPairsProvider and RoomMemoryPairsProvider ([98822ec](98822ecda5))
2025-10-09 20:50:21 +00:00
Thomas Hallock
62f3730542 feat: implement networked hover presence for multiplayer gameplay
Add real-time hover state visualization to show which players are
considering which cards in room-based multiplayer games.

FEATURES:
- Mouse hover detection on cards sends HOVER_CARD moves over network
- Remote player avatars appear as floating badges over hovered cards
- Smooth pulsing animation with glow effect for hover avatars
- Player emoji + color + name shown on hover
- Only active (non-matched) cards trigger hover events

IMPLEMENTATION:
- Added onMouseEnter/onMouseLeave handlers to card wrapper in MemoryGrid
- Calls hoverCard(cardId) on enter, hoverCard(null) on leave
- Filters playerHovers state to find players hovering each card
- Displays floating avatar badges with player info from playerMetadata
- Added hoverPulse animation for smooth visual feedback

UX BENEFITS:
- Network players can see what opponents are thinking about
- Provides rich multiplayer feedback without voice/video
- Makes remote play feel more in-person and engaging
- Smooth transitions as players move between cards

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 15:49:00 -05:00
Thomas Hallock
98822ecda5 refactor: properly separate LocalMemoryPairsProvider and RoomMemoryPairsProvider
Clean separation of concerns between arcade sessions and room-based play:

ARCHITECTURE:
- LocalMemoryPairsProvider: Arcade sessions (roomId: undefined)
  * Uses useArcadeRedirect for canModifyPlayers
  * No room sync, local-only play
  * Used by /arcade/matching

- RoomMemoryPairsProvider: Room-based multiplayer (roomId: roomData.id)
  * Hard-coded canModifyPlayers: false (always show buttons)
  * Network sync across all room members
  * Used by /arcade/room

CHANGES:
- Added canModifyPlayers to LocalMemoryPairsProvider (uses useArcadeRedirect)
- Added hoverCard action to LocalMemoryPairsProvider
- Added HOVER_CARD case to LocalMemoryPairsProvider optimistic updates
- Added playerHovers to LocalMemoryPairsProvider initial state
- Removed isInRoom conditional from RoomMemoryPairsProvider (cleaner)
- Updated /arcade/matching to use LocalMemoryPairsProvider (was incorrectly using Room)
- Added clear doc comments explaining each provider's purpose

FIXES:
- /arcade/matching no longer behaves like a room
- Each provider has single, clear responsibility
- No more cross-cutting concerns or conditional logic based on room state

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 15:44:41 -05:00
Thomas Hallock
db9f9096b4 refactor: move canModifyPlayers logic into provider layer
Architecture improvement to separate concerns between game components
and session management:

BEFORE:
- MemoryPairsGame component had to know about rooms vs arcade sessions
- Component imported useRoomData and useArcadeRedirect
- Logic for button visibility leaked into presentation layer

AFTER:
- canModifyPlayers added to MemoryPairsContextValue interface
- RoomMemoryPairsProvider handles the logic internally:
  * If in room (roomData?.id exists): canModifyPlayers = false (show buttons)
  * If arcade session (no room): uses useArcadeRedirect.canModifyPlayers
- MemoryPairsGame just reads canModifyPlayers from context
- Clean separation: provider knows session type, component doesn't care

FIXES:
- Room-based games now always show Setup/New Game/Quit buttons
- Arcade sessions correctly hide buttons during setup phase
- /arcade/matching was incorrectly behaving like a room (now fixed)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 15:42:20 -05:00
8 changed files with 147 additions and 17 deletions

View File

@@ -1,3 +1,16 @@
## [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)

View File

@@ -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)
}

View File

@@ -3,8 +3,6 @@
import { useRouter } from 'next/navigation'
import { useEffect, useRef } from 'react'
import { PageWithNav } from '@/components/PageWithNav'
import { useArcadeRedirect } from '@/hooks/useArcadeRedirect'
import { useRoomData } from '@/hooks/useRoomData'
import { css } from '../../../../../styled-system/css'
import { StandardGameLayout } from '../../../../components/StandardGameLayout'
import { useFullscreen } from '../../../../contexts/FullscreenContext'
@@ -15,13 +13,8 @@ import { SetupPhase } from './SetupPhase'
export function MemoryPairsGame() {
const router = useRouter()
const { state, exitSession, resetGame, goToSetup } = useMemoryPairs()
const { state, exitSession, resetGame, goToSetup, canModifyPlayers } = useMemoryPairs()
const { setFullscreenElement } = useFullscreen()
const { isInRoom } = useRoomData()
const arcadeRedirect = useArcadeRedirect({ currentGame: 'matching' })
// In rooms, always show buttons (canModifyPlayers = false shows buttons)
// In arcade sessions, use normal arcade redirect logic
const canModifyPlayers = isInRoom ? false : arcadeRedirect.canModifyPlayers
const gameRef = useRef<HTMLDivElement>(null)
useEffect(() => {

View File

@@ -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,

View File

@@ -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,

View File

@@ -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

View File

@@ -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>
)
}

View File

@@ -1,6 +1,6 @@
{
"name": "soroban-monorepo",
"version": "2.12.3",
"version": "2.13.0",
"private": true,
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
"workspaces": [