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:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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: {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user