feat: add networked hover state infrastructure for multiplayer presence

Implements the server-side and state management foundation for showing
which cards remote players are hovering over in real-time. This creates
"in-person feedback" for network games by transmitting hover cues.

Core changes:
- Add playerHovers state field mapping playerId -> cardId (or null)
- Add HOVER_CARD move type for transmitting hover state
- Add validateHoverCard() validator (always valid, lightweight UI state)
- Add optimistic hover updates in RoomMemoryPairsProvider
- Add hoverCard(cardId) action creator
- Initialize playerHovers in initial state

Remaining work to complete feature:
1. Wire up onMouseEnter/onMouseLeave in GameCard/MemoryGrid
2. Call hoverCard(cardId) on enter, hoverCard(null) on leave
3. Display remote player avatars floating over hovered cards
4. Add smooth transitions when hover changes

This establishes the data flow: hover event → HOVER_CARD move → server
validation → broadcast to room → all clients see hover state.

🤖 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-09 14:34:59 -05:00
parent c32f4dd1f6
commit d14979907c
5 changed files with 75 additions and 3 deletions

View File

@@ -39,6 +39,8 @@ const initialState: MemoryPairsState = {
originalConfig: undefined,
pausedGamePhase: undefined,
pausedGameState: undefined,
// HOVER: Initialize hover state
playerHovers: {},
}
/**
@@ -194,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
}
@@ -514,6 +527,22 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
})
}, [canResumeGame, 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 }
@@ -536,6 +565,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
setGameType,
setDifficulty,
setTurnTimer,
hoverCard,
exitSession,
gameMode,
activePlayers,

View File

@@ -1,13 +1,13 @@
import { ArcadeGuardedPage } from '@/components/ArcadeGuardedPage'
import { MemoryPairsGame } from './components/MemoryPairsGame'
import { ArcadeMemoryPairsProvider } from './context/ArcadeMemoryPairsContext'
import { RoomMemoryPairsProvider } from './context/RoomMemoryPairsProvider'
export default function MatchingPage() {
return (
<ArcadeGuardedPage>
<ArcadeMemoryPairsProvider>
<RoomMemoryPairsProvider>
<MemoryPairsGame />
</ArcadeMemoryPairsProvider>
</RoomMemoryPairsProvider>
</ArcadeGuardedPage>
)
}

View File

@@ -103,6 +103,9 @@ export interface MemoryPairsState {
isProcessingMove: boolean
showMismatchFeedback: boolean
lastMatchedPair: [string, string] | null
// Hover state for networked presence
playerHovers: { [playerId: string]: string | null } // playerId -> cardId (or null if not hovering)
}
export type MemoryPairsAction =
@@ -143,6 +146,7 @@ export interface MemoryPairsContextValue {
setGameType: (type: GameType) => void
setDifficulty: (difficulty: Difficulty) => void
setTurnTimer: (timer: number) => void
hoverCard: (cardId: string | null) => void // Send hover state for networked presence
goToSetup: () => void
exitSession: () => void // Exit arcade session (no-op for non-arcade mode)
}

View File

@@ -44,6 +44,9 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
case 'RESUME_GAME':
return this.validateResumeGame(state)
case 'HOVER_CARD':
return this.validateHoverCard(state, move.data.cardId, move.playerId)
default:
return {
valid: false,
@@ -479,6 +482,31 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
}
}
/**
* Validate hover state update for networked presence
*
* Hover moves are lightweight and always valid - they just update
* which card a player is hovering over for UI feedback to other players.
*/
private validateHoverCard(
state: MemoryPairsState,
cardId: string | null,
playerId: string
): ValidationResult {
// Hover is always valid - it's just UI state for networked presence
// Update the player's hover state
return {
valid: true,
newState: {
...state,
playerHovers: {
...state.playerHovers,
[playerId]: cardId,
},
},
}
}
isGameComplete(state: MemoryPairsState): boolean {
return state.gamePhase === 'results' || state.matchedPairs === state.totalPairs
}
@@ -516,6 +544,8 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
originalConfig: undefined,
pausedGamePhase: undefined,
pausedGameState: undefined,
// HOVER: Initialize hover state
playerHovers: {},
}
}
}

View File

@@ -61,6 +61,13 @@ export interface MatchingResumeGameMove extends GameMove {
data: Record<string, never>
}
export interface MatchingHoverCardMove extends GameMove {
type: 'HOVER_CARD'
data: {
cardId: string | null // null when mouse leaves card
}
}
export type MatchingGameMove =
| MatchingFlipCardMove
| MatchingStartGameMove
@@ -68,6 +75,7 @@ export type MatchingGameMove =
| MatchingGoToSetupMove
| MatchingSetConfigMove
| MatchingResumeGameMove
| MatchingHoverCardMove
// Generic game state union
export type GameState = MemoryPairsState // Add other game states as union later