From c3b94bea3d43dfdd022d27974c679f055ec39043 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Wed, 26 Nov 2025 08:25:11 -0600 Subject: [PATCH] feat(know-your-world): add multiplayer cursor sharing and fix map viewport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multiplayer cursor sharing: - Add cursor-update socket event for real-time cursor position broadcasting - Show other players' cursors with crosshair in their color + emoji - Add network hover effects (glow + dashed border) for regions other players hover - In turn-based mode, only broadcast/show cursor for current player's turn Map viewport fix: - Fix SVG not filling full viewport width by measuring container instead of SVG - Remove aspectRatio constraint that was causing circular dependency - SVG now properly extends to show additional map context (e.g., eastern Russia) Cleanup: - Remove debug crop region outline (no longer needed) - Guard debug detection box visualization behind dev-only flag 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../arcade-games/know-your-world/Provider.tsx | 37 ++- .../components/MapRenderer.tsx | 310 +++++++++++++++--- .../components/PlayingPhase.tsx | 15 +- .../know-your-world/customCrops.ts | 8 +- apps/web/src/hooks/useArcadeSession.ts | 44 +++ apps/web/src/hooks/useArcadeSocket.ts | 25 ++ apps/web/src/socket-server.ts | 21 ++ 7 files changed, 413 insertions(+), 47 deletions(-) diff --git a/apps/web/src/arcade-games/know-your-world/Provider.tsx b/apps/web/src/arcade-games/know-your-world/Provider.tsx index b0a59f04..f4aeb90e 100644 --- a/apps/web/src/arcade-games/know-your-world/Provider.tsx +++ b/apps/web/src/arcade-games/know-your-world/Provider.tsx @@ -33,6 +33,10 @@ interface KnowYourWorldContextValue { setDifficulty: (difficulty: string) => void setStudyDuration: (duration: 0 | 30 | 60 | 120) => void setContinent: (continent: import('./continents').ContinentId | 'all') => void + + // Cursor position sharing (for multiplayer) + otherPlayerCursors: Record + sendCursorUpdate: (cursorPosition: { x: number; y: number } | null) => void } const KnowYourWorldContext = createContext(null) @@ -103,13 +107,30 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode } }, [roomData]) - const { state, sendMove, exitSession, lastError, clearError } = - useArcadeSession({ - userId: viewerId || '', - roomId: roomData?.id, - initialState, - applyMove: (state) => state, // Server handles all state updates - }) + const { + state, + sendMove, + exitSession, + lastError, + clearError, + otherPlayerCursors, + sendCursorUpdate: sessionSendCursorUpdate, + } = useArcadeSession({ + userId: viewerId || '', + roomId: roomData?.id, + initialState, + applyMove: (state) => state, // Server handles all state updates + }) + + // Wrap sendCursorUpdate to automatically include the current player ID + const sendCursorUpdate = useCallback( + (cursorPosition: { x: number; y: number } | null) => { + if (state.currentPlayer) { + sessionSendCursorUpdate(state.currentPlayer, cursorPosition) + } + }, + [state.currentPlayer, sessionSendCursorUpdate] + ) // Action: Start Game const startGame = useCallback(() => { @@ -383,6 +404,8 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode setDifficulty, setStudyDuration, setContinent, + otherPlayerCursors, + sendCursorUpdate, }} > {children} diff --git a/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx b/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx index 0ff531d7..f82b1018 100644 --- a/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx +++ b/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx @@ -37,9 +37,6 @@ const SHOW_MAGNIFIER_DEBUG_INFO = process.env.NODE_ENV === 'development' // Debug flag: show bounding boxes with importance scores (dev only) const SHOW_DEBUG_BOUNDING_BOXES = process.env.NODE_ENV === 'development' -// Debug flag: show custom crop region outline (dev only) -const SHOW_CROP_REGION_DEBUG = process.env.NODE_ENV === 'development' - // Precision mode threshold: screen pixel ratio that triggers pointer lock recommendation const PRECISION_MODE_THRESHOLD = 20 @@ -137,6 +134,12 @@ interface MapRendererProps { } // Debug flags showDebugBoundingBoxes?: boolean + // Multiplayer cursor sharing + gameMode?: 'cooperative' | 'race' | 'turn-based' + currentPlayer?: string // The player whose turn it is (for turn-based mode) + localPlayerId?: string // The local player's ID (to filter out our own cursor from others) + otherPlayerCursors?: Record + onCursorUpdate?: (cursorPosition: { x: number; y: number } | null) => void } /** @@ -192,6 +195,11 @@ export function MapRenderer({ onGiveUp, forceTuning = {}, showDebugBoundingBoxes = SHOW_DEBUG_BOUNDING_BOXES, + gameMode, + currentPlayer, + localPlayerId, + otherPlayerCursors = {}, + onCursorUpdate, }: MapRendererProps) { // Extract force tuning parameters with defaults const { @@ -537,6 +545,64 @@ export function MapRenderer({ } }, [displayViewBox]) + // Compute which regions network cursors are hovering over + // Returns a map of regionId -> { playerId, color } for regions with network hovers + const networkHoveredRegions = useMemo(() => { + const result: Record = {} + + // Skip if no SVG ref or no cursors + if (!svgRef.current) return result + + Object.entries(otherPlayerCursors).forEach(([playerId, position]) => { + // Skip our own cursor and null positions + if (playerId === localPlayerId || !position) return + + // In turn-based mode, only show hover when it's not our turn + if (gameMode === 'turn-based' && currentPlayer === localPlayerId) return + + // Get player color + const player = playerMetadata[playerId] + if (!player) return + + // Use SVG's native hit testing + // Create an SVGPoint and use getIntersectionList or check each path + const svg = svgRef.current + if (!svg) return + + // Find the region element under this point using elementFromPoint + // First convert SVG coords to screen coords + const viewBoxParts = displayViewBox.split(' ').map(Number) + const viewBoxX = viewBoxParts[0] || 0 + const viewBoxY = viewBoxParts[1] || 0 + const viewBoxW = viewBoxParts[2] || 1000 + const viewBoxH = viewBoxParts[3] || 500 + const svgRect = svg.getBoundingClientRect() + const scaleX = svgRect.width / viewBoxW + const scaleY = svgRect.height / viewBoxH + const screenX = (position.x - viewBoxX) * scaleX + svgRect.left + const screenY = (position.y - viewBoxY) * scaleY + svgRect.top + + // Get element at this screen position + const element = document.elementFromPoint(screenX, screenY) + if (element && element.hasAttribute('data-region-id')) { + const regionId = element.getAttribute('data-region-id') + if (regionId) { + result[regionId] = { playerId, color: player.color } + } + } + }) + + return result + }, [ + otherPlayerCursors, + localPlayerId, + gameMode, + currentPlayer, + playerMetadata, + displayViewBox, + svgDimensions, // Re-run when SVG size changes + ]) + // State for give-up zoom animation target values const [giveUpZoomTarget, setGiveUpZoomTarget] = useState({ scale: 1, @@ -772,31 +838,33 @@ export function MapRenderer({ }> >([]) - // Measure SVG element to get actual pixel dimensions using ResizeObserver + // Measure container element to get available space for viewBox calculation + // IMPORTANT: We measure the container, not the SVG, to avoid circular dependency: + // The SVG fills the container, and the viewBox is calculated based on container aspect ratio useEffect(() => { - if (!svgRef.current) return + if (!containerRef.current) return const updateDimensions = () => { - const rect = svgRef.current?.getBoundingClientRect() + const rect = containerRef.current?.getBoundingClientRect() if (rect) { setSvgDimensions({ width: rect.width, height: rect.height }) } } // Use ResizeObserver to detect panel resizing (not just window resize) - const observer = new ResizeObserver((entries) => { + const observer = new ResizeObserver(() => { requestAnimationFrame(() => { updateDimensions() }) }) - observer.observe(svgRef.current) + observer.observe(containerRef.current) // Initial measurement updateDimensions() return () => observer.disconnect() - }, [displayViewBox]) // Re-measure when viewBox changes (continent/map selection) + }, []) // No dependencies - container size doesn't depend on viewBox // Calculate label positions using ghost elements useEffect(() => { @@ -1386,6 +1454,28 @@ export function MapRenderer({ cursorPositionRef.current = { x: cursorX, y: cursorY } setCursorPosition({ x: cursorX, y: cursorY }) + // Send cursor position to other players (in SVG coordinates) + // In turn-based mode, only broadcast when it's our turn + const shouldBroadcastCursor = + onCursorUpdate && + svgRef.current && + (gameMode !== 'turn-based' || currentPlayer === localPlayerId) + + if (shouldBroadcastCursor) { + const viewBoxParts = displayViewBox.split(' ').map(Number) + const viewBoxX = viewBoxParts[0] || 0 + const viewBoxY = viewBoxParts[1] || 0 + const viewBoxW = viewBoxParts[2] || 1000 + const viewBoxH = viewBoxParts[3] || 500 + const svgOffsetX = svgRect.left - containerRect.left + const svgOffsetY = svgRect.top - containerRect.top + const scaleX = viewBoxW / svgRect.width + const scaleY = viewBoxH / svgRect.height + const cursorSvgX = (cursorX - svgOffsetX) * scaleX + viewBoxX + const cursorSvgY = (cursorY - svgOffsetY) * scaleY + viewBoxY + onCursorUpdate({ x: cursorSvgX, y: cursorSvgY }) + } + // Check if fake cursor is hovering over Give Up button (for pointer lock mode) if (pointerLocked) { const buttonBounds = giveUpButtonBoundsRef.current @@ -1556,6 +1646,12 @@ export function MapRenderer({ setCursorPosition(null) setDebugBoundingBoxes([]) // Clear bounding boxes when leaving cursorPositionRef.current = null + + // Notify other players that cursor left + // In turn-based mode, only broadcast when it's our turn + if (onCursorUpdate && (gameMode !== 'turn-based' || currentPlayer === localPlayerId)) { + onCursorUpdate(null) + } } return ( @@ -1615,13 +1711,15 @@ export function MapRenderer({ ref={svgRef} viewBox={displayViewBox} className={css({ - maxWidth: '100%', - maxHeight: '100%', + // Fill the entire container - viewBox controls what portion of map is visible + width: '100%', + height: '100%', cursor: pointerLocked ? 'crosshair' : 'pointer', transformOrigin: 'center center', })} style={{ - aspectRatio: `${viewBoxWidth} / ${viewBoxHeight}`, + // No aspectRatio - the SVG fills the container and viewBox is calculated + // to match the container's aspect ratio via calculateFitCropViewBox // CSS transform for zoom animation during give-up reveal transform: to( [mainMapSpring.scale, mainMapSpring.translateX, mainMapSpring.translateY], @@ -1658,8 +1756,24 @@ export function MapRenderer({ : getRegionStroke(isFound, isDark) const strokeWidth = isBeingRevealed ? 3 : isFound ? 1 : 1.5 + // Check if a network cursor is hovering over this region + const networkHover = networkHoveredRegions[region.id] + return ( + {/* Glow effect for network-hovered region (other player's cursor) */} + {networkHover && !isBeingRevealed && ( + + )} {/* Glow effect for revealed region */} {isBeingRevealed && ( )} + {/* Network hover border (crisp outline in player color) */} + {networkHover && !isBeingRevealed && ( + + )} {/* Region path */} )} - {/* Debug: Show custom crop region outline */} - {SHOW_CROP_REGION_DEBUG && mapData.customCrop && (() => { - const cropParts = mapData.customCrop.split(' ').map(Number) - const cropX = cropParts[0] || 0 - const cropY = cropParts[1] || 0 - const cropWidth = cropParts[2] || 100 - const cropHeight = cropParts[3] || 100 - return ( - - ) - })()} {/* HTML labels positioned absolutely over the SVG */} @@ -2968,8 +3071,8 @@ export function MapRenderer({ ) })()} - {/* Debug: Auto zoom detection visualization */} - {cursorPosition && containerRef.current && ( + {/* Debug: Auto zoom detection visualization (dev only) */} + {SHOW_MAGNIFIER_DEBUG_INFO && cursorPosition && containerRef.current && ( <> {/* Detection box - 50px box around cursor */}
)} + {/* Other players' cursors - show in multiplayer when not exclusively our turn */} + {svgRef.current && + containerRef.current && + Object.entries(otherPlayerCursors).map(([playerId, position]) => { + // Skip our own cursor and null positions + if (playerId === localPlayerId || !position) return null + + // In turn-based mode, only show other cursors when it's not our turn + if (gameMode === 'turn-based' && currentPlayer === localPlayerId) return null + + // Get player metadata for emoji and color + const player = playerMetadata[playerId] + if (!player) return null + + // Convert SVG coordinates to screen coordinates + const svgRect = svgRef.current!.getBoundingClientRect() + const containerRect = containerRef.current!.getBoundingClientRect() + const viewBoxParts = displayViewBox.split(' ').map(Number) + const viewBoxX = viewBoxParts[0] || 0 + const viewBoxY = viewBoxParts[1] || 0 + const viewBoxW = viewBoxParts[2] || 1000 + const viewBoxH = viewBoxParts[3] || 500 + const svgOffsetX = svgRect.left - containerRect.left + const svgOffsetY = svgRect.top - containerRect.top + const scaleX = svgRect.width / viewBoxW + const scaleY = svgRect.height / viewBoxH + const screenX = (position.x - viewBoxX) * scaleX + svgOffsetX + const screenY = (position.y - viewBoxY) * scaleY + svgOffsetY + + // Check if cursor is within SVG bounds + if ( + screenX < svgOffsetX || + screenX > svgOffsetX + svgRect.width || + screenY < svgOffsetY || + screenY > svgOffsetY + svgRect.height + ) { + return null + } + + return ( +
+ {/* Crosshair - centered on the cursor position */} + + {/* Outer ring */} + + {/* Cross lines */} + + + + + {/* Center dot */} + + + {/* Player emoji label - positioned below crosshair */} +
+ {player.emoji} +
+
+ ) + })} + {/* Give Up button overlay - positioned within SVG bounds for pointer lock accessibility */} {(() => { // Use svgDimensions to trigger re-render on resize, but get actual rect for positioning diff --git a/apps/web/src/arcade-games/know-your-world/components/PlayingPhase.tsx b/apps/web/src/arcade-games/know-your-world/components/PlayingPhase.tsx index 3a093ca2..7e65b329 100644 --- a/apps/web/src/arcade-games/know-your-world/components/PlayingPhase.tsx +++ b/apps/web/src/arcade-games/know-your-world/components/PlayingPhase.tsx @@ -6,9 +6,17 @@ import { useKnowYourWorld } from '../Provider' import { getFilteredMapDataSync } from '../maps' import { MapRenderer } from './MapRenderer' import { GameInfoPanel } from './GameInfoPanel' +import { useViewerId } from '@/lib/arcade/game-sdk' +import { useGameMode } from '@/lib/arcade/game-sdk' export function PlayingPhase() { - const { state, clickRegion, giveUp } = useKnowYourWorld() + const { state, clickRegion, giveUp, otherPlayerCursors, sendCursorUpdate } = useKnowYourWorld() + const { data: viewerId } = useViewerId() + const { activePlayers } = useGameMode() + + // Find the local player ID (first player that belongs to this viewer) + // In most cases, each user controls one player + const localPlayerId = Array.from(activePlayers)[0] || '' const mapData = getFilteredMapDataSync( state.selectedMap, @@ -114,6 +122,11 @@ export function PlayingPhase() { playerMetadata={state.playerMetadata} giveUpReveal={state.giveUpReveal} onGiveUp={giveUp} + gameMode={state.gameMode} + currentPlayer={state.currentPlayer} + localPlayerId={localPlayerId} + otherPlayerCursors={otherPlayerCursors} + onCursorUpdate={sendCursorUpdate} />
diff --git a/apps/web/src/arcade-games/know-your-world/customCrops.ts b/apps/web/src/arcade-games/know-your-world/customCrops.ts index bb689ff1..92052017 100644 --- a/apps/web/src/arcade-games/know-your-world/customCrops.ts +++ b/apps/web/src/arcade-games/know-your-world/customCrops.ts @@ -12,7 +12,13 @@ export interface CropOverrides { } } -export const customCrops: CropOverrides = {} +export const customCrops: CropOverrides = { + world: { + europe: '399.10 106.44 200.47 263.75', + africa: '472.47 346.18 95.84 227.49', + oceania: '775.56 437.22 233.73 161.35', + } +} /** * Get custom crop viewBox for a map/continent combination diff --git a/apps/web/src/hooks/useArcadeSession.ts b/apps/web/src/hooks/useArcadeSession.ts index 9251b056..f94747a7 100644 --- a/apps/web/src/hooks/useArcadeSession.ts +++ b/apps/web/src/hooks/useArcadeSession.ts @@ -78,6 +78,22 @@ export interface UseArcadeSessionReturn { * Manually sync with server (useful after reconnect) */ refresh: () => void + + /** + * Other players' cursor positions (ephemeral, real-time) + * Map of playerId -> { x, y } in SVG coordinates, or null if cursor left + */ + otherPlayerCursors: Record + + /** + * Send cursor position update to other players (ephemeral, real-time) + * @param playerId - The player ID sending the cursor update + * @param cursorPosition - SVG coordinates, or null when cursor leaves the map + */ + sendCursorUpdate: ( + playerId: string, + cursorPosition: { x: number; y: number } | null + ) => void } /** @@ -133,6 +149,10 @@ export function useArcadeSession( refresh: () => { // Mock: do nothing in preview }, + otherPlayerCursors: {}, + sendCursorUpdate: () => { + // Mock: do nothing in preview + }, } } @@ -147,6 +167,11 @@ export function useArcadeSession( timestamp: null, }) + // Track other players' cursor positions (ephemeral, real-time) + const [otherPlayerCursors, setOtherPlayerCursors] = useState< + Record + >({}) + // WebSocket connection const { socket, @@ -154,6 +179,7 @@ export function useArcadeSession( joinSession, sendMove: socketSendMove, exitSession: socketExitSession, + sendCursorUpdate: socketSendCursorUpdate, } = useArcadeSocket({ onSessionState: (data) => { optimistic.syncWithServer(data.gameState as TState, data.version) @@ -243,6 +269,13 @@ export function useArcadeSession( onError: (data) => { console.error(`[ArcadeSession] Error: ${data.error}`) }, + + onCursorUpdate: (data) => { + setOtherPlayerCursors((prev) => ({ + ...prev, + [data.playerId]: data.cursorPosition, + })) + }, }) // Auto-join session when connected @@ -286,6 +319,15 @@ export function useArcadeSession( } }, [connected, userId, roomId, joinSession]) + // Send cursor position update to other players (ephemeral, real-time) + const sendCursorUpdate = useCallback( + (playerId: string, cursorPosition: { x: number; y: number } | null) => { + if (!roomId) return // Only works in room-based games + socketSendCursorUpdate(roomId, playerId, cursorPosition) + }, + [roomId, socketSendCursorUpdate] + ) + return { state: optimistic.state, version: optimistic.version, @@ -297,5 +339,7 @@ export function useArcadeSession( exitSession, clearError: optimistic.clearError, refresh, + otherPlayerCursors, + sendCursorUpdate, } } diff --git a/apps/web/src/hooks/useArcadeSocket.ts b/apps/web/src/hooks/useArcadeSocket.ts index 39440baf..94a098af 100644 --- a/apps/web/src/hooks/useArcadeSocket.ts +++ b/apps/web/src/hooks/useArcadeSocket.ts @@ -16,6 +16,11 @@ export interface ArcadeSocketEvents { onSessionEnded?: () => void onNoActiveSession?: () => void onError?: (error: { error: string }) => void + /** Cursor position update from another player (ephemeral, real-time) */ + onCursorUpdate?: (data: { + playerId: string + cursorPosition: { x: number; y: number } | null + }) => void /** If true, errors will NOT show toasts (for cases where game handles errors directly) */ suppressErrorToasts?: boolean } @@ -27,6 +32,12 @@ export interface UseArcadeSocketReturn { sendMove: (userId: string, move: GameMove, roomId?: string) => void exitSession: (userId: string) => void pingSession: (userId: string) => void + /** Send cursor position update to other players in the room (ephemeral, real-time) */ + sendCursorUpdate: ( + roomId: string, + playerId: string, + cursorPosition: { x: number; y: number } | null + ) => void } /** @@ -144,6 +155,11 @@ export function useArcadeSocket(events: ArcadeSocketEvents = {}): UseArcadeSocke console.log('[ArcadeSocket] Pong received') }) + // Cursor position update from other players (ephemeral, real-time) + socketInstance.on('cursor-update', (data) => { + eventsRef.current.onCursorUpdate?.(data) + }) + setSocket(socketInstance) return () => { @@ -198,6 +214,14 @@ export function useArcadeSocket(events: ArcadeSocketEvents = {}): UseArcadeSocke [socket] ) + const sendCursorUpdate = useCallback( + (roomId: string, playerId: string, cursorPosition: { x: number; y: number } | null) => { + if (!socket) return + socket.emit('cursor-update', { roomId, playerId, cursorPosition }) + }, + [socket] + ) + return { socket, connected, @@ -205,5 +229,6 @@ export function useArcadeSocket(events: ArcadeSocketEvents = {}): UseArcadeSocke sendMove, exitSession, pingSession, + sendCursorUpdate, } } diff --git a/apps/web/src/socket-server.ts b/apps/web/src/socket-server.ts index 0886629b..7122e8e9 100644 --- a/apps/web/src/socket-server.ts +++ b/apps/web/src/socket-server.ts @@ -693,6 +693,27 @@ export function initializeSocketServer(httpServer: HTTPServer) { } }) + // Cursor position update (ephemeral, not persisted) + // Used for showing other players' cursors in real-time games + socket.on( + 'cursor-update', + ({ + roomId, + playerId, + cursorPosition, + }: { + roomId: string + playerId: string + cursorPosition: { x: number; y: number } | null // SVG coordinates, null when cursor leaves + }) => { + // Broadcast to all other sockets in the game room (exclude sender) + socket.to(`game:${roomId}`).emit('cursor-update', { + playerId, + cursorPosition, + }) + } + ) + socket.on('disconnect', () => { // Don't delete session on disconnect - it persists across devices })