fix: transmit hovered region ID with network cursor to avoid hit-testing discrepancies

Previously, each client performed hit-testing on received cursor coordinates
to determine which region was being hovered. Due to pixel scaling and rendering
differences between clients, this could cause inconsistent hover highlighting.

Now the sender's locally hit-tested region ID is transmitted along with cursor
coordinates. Receiving clients use this ID directly instead of re-doing hit-testing,
ensuring consistent hover highlighting across all clients.

Changes:
- Add hoveredRegionId to cursor update types and socket events
- Update networkHoveredRegions to use transmitted region instead of local hit-testing
- Pass regionUnderCursor from MapRenderer's local detection to network broadcast

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-11-26 11:55:32 -06:00
parent e4e09256c2
commit 6c3f860efc
6 changed files with 81 additions and 78 deletions

View File

@ -35,11 +35,15 @@ interface KnowYourWorldContextValue {
setContinent: (continent: import('./continents').ContinentId | 'all') => void
// Cursor position sharing (for multiplayer)
otherPlayerCursors: Record<string, { x: number; y: number; userId: string } | null>
otherPlayerCursors: Record<
string,
{ x: number; y: number; userId: string; hoveredRegionId: string | null } | null
>
sendCursorUpdate: (
playerId: string,
userId: string,
cursorPosition: { x: number; y: number } | null
cursorPosition: { x: number; y: number } | null,
hoveredRegionId: string | null
) => void
// Member players mapping (userId -> players) for cursor emoji display
@ -133,9 +137,14 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
// Pass through cursor updates with the provided player ID and userId
const sendCursorUpdate = useCallback(
(playerId: string, sessionUserId: string, cursorPosition: { x: number; y: number } | null) => {
(
playerId: string,
sessionUserId: string,
cursorPosition: { x: number; y: number } | null,
hoveredRegionId: string | null
) => {
if (playerId && sessionUserId) {
sessionSendCursorUpdate(playerId, sessionUserId, cursorPosition)
sessionSendCursorUpdate(playerId, sessionUserId, cursorPosition, hoveredRegionId)
}
},
[sessionSendCursorUpdate]

View File

@ -207,8 +207,14 @@ interface MapRendererProps {
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<string, { x: number; y: number; userId: string } | null>
onCursorUpdate?: (cursorPosition: { x: number; y: number } | null) => void
otherPlayerCursors?: Record<
string,
{ x: number; y: number; userId: string; hoveredRegionId: string | null } | null
>
onCursorUpdate?: (
cursorPosition: { x: number; y: number } | null,
hoveredRegionId: string | null
) => void
// Unanimous give-up voting (for cooperative multiplayer)
giveUpVotes?: string[] // Session/viewer IDs (userIds) who have voted to give up
activeUserIds?: string[] // All unique session IDs participating (to show "1/2 sessions voted")
@ -629,9 +635,6 @@ export function MapRenderer({
const networkHoveredRegions = useMemo(() => {
const result: Record<string, { playerId: string; color: string }> = {}
// 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
@ -643,43 +646,15 @@ export function MapRenderer({
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 (accounting for preserveAspectRatio letterboxing)
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 viewport = getRenderedViewport(svgRect, viewBoxX, viewBoxY, viewBoxW, viewBoxH)
const screenX = (position.x - viewBoxX) * viewport.scale + svgRect.left + viewport.letterboxX
const screenY = (position.y - viewBoxY) * viewport.scale + svgRect.top + viewport.letterboxY
// 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 }
}
// Use the transmitted hoveredRegionId directly (avoids hit-testing discrepancies
// due to pixel scaling/rendering differences between clients)
if (position.hoveredRegionId) {
result[position.hoveredRegionId] = { playerId, color: player.color }
}
})
return result
}, [
otherPlayerCursors,
localPlayerId,
gameMode,
currentPlayer,
playerMetadata,
displayViewBox,
svgDimensions, // Re-run when SVG size changes
])
}, [otherPlayerCursors, localPlayerId, gameMode, currentPlayer, playerMetadata])
// State for give-up zoom animation target values
const [giveUpZoomTarget, setGiveUpZoomTarget] = useState({
@ -1542,29 +1517,6 @@ 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
// Account for preserveAspectRatio letterboxing when converting to SVG coords
const viewport = getRenderedViewport(svgRect, viewBoxX, viewBoxY, viewBoxW, viewBoxH)
const svgOffsetX = svgRect.left - containerRect.left + viewport.letterboxX
const svgOffsetY = svgRect.top - containerRect.top + viewport.letterboxY
// Use inverse of viewport.scale to convert pixels to viewBox units
const cursorSvgX = (cursorX - svgOffsetX) / viewport.scale + viewBoxX
const cursorSvgY = (cursorY - svgOffsetY) / viewport.scale + 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
@ -1622,6 +1574,32 @@ export function MapRenderer({
setHoveredRegion(regionUnderCursor)
}
// Send cursor position to other players (in SVG coordinates)
// In turn-based mode, only broadcast when it's our turn
// We do this AFTER detectRegions so we can include the exact hovered region
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
// Account for preserveAspectRatio letterboxing when converting to SVG coords
const viewport = getRenderedViewport(svgRect, viewBoxX, viewBoxY, viewBoxW, viewBoxH)
const svgOffsetX = svgRect.left - containerRect.left + viewport.letterboxX
const svgOffsetY = svgRect.top - containerRect.top + viewport.letterboxY
// Use inverse of viewport.scale to convert pixels to viewBox units
const cursorSvgX = (cursorX - svgOffsetX) / viewport.scale + viewBoxX
const cursorSvgY = (cursorY - svgOffsetY) / viewport.scale + viewBoxY
// Pass the exact region under cursor (from local hit-testing) so other clients
// don't need to re-do hit-testing which can yield different results due to scaling
onCursorUpdate({ x: cursorSvgX, y: cursorSvgY }, regionUnderCursor)
}
if (shouldShow) {
// Filter out found regions from zoom calculations
// Found regions shouldn't influence how much we zoom in
@ -1764,7 +1742,7 @@ export function MapRenderer({
// 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)
onCursorUpdate(null, null)
}
}

View File

@ -31,9 +31,9 @@ export function PlayingPhase() {
// Wrap sendCursorUpdate to include localPlayerId and viewerId (session ID)
const handleCursorUpdate = useCallback(
(cursorPosition: { x: number; y: number } | null) => {
(cursorPosition: { x: number; y: number } | null, hoveredRegionId: string | null) => {
if (viewerId) {
sendCursorUpdate(localPlayerId, viewerId, cursorPosition)
sendCursorUpdate(localPlayerId, viewerId, cursorPosition, hoveredRegionId)
}
},
[localPlayerId, viewerId, sendCursorUpdate]

View File

@ -81,20 +81,25 @@ export interface UseArcadeSessionReturn<TState> {
/**
* Other players' cursor positions (ephemeral, real-time)
* Map of playerId -> { x, y, userId } in SVG coordinates, or null if cursor left
* Map of playerId -> { x, y, userId, hoveredRegionId } in SVG coordinates, or null if cursor left
*/
otherPlayerCursors: Record<string, { x: number; y: number; userId: string } | null>
otherPlayerCursors: Record<
string,
{ x: number; y: number; userId: string; hoveredRegionId: string | null } | null
>
/**
* Send cursor position update to other players (ephemeral, real-time)
* @param playerId - The player ID sending the cursor update
* @param userId - The session/viewer ID that owns this cursor
* @param cursorPosition - SVG coordinates, or null when cursor leaves the map
* @param hoveredRegionId - Region being hovered (from local hit-testing), or null
*/
sendCursorUpdate: (
playerId: string,
userId: string,
cursorPosition: { x: number; y: number } | null
cursorPosition: { x: number; y: number } | null,
hoveredRegionId: string | null
) => void
}
@ -171,7 +176,7 @@ export function useArcadeSession<TState>(
// Track other players' cursor positions (ephemeral, real-time)
const [otherPlayerCursors, setOtherPlayerCursors] = useState<
Record<string, { x: number; y: number; userId: string } | null>
Record<string, { x: number; y: number; userId: string; hoveredRegionId: string | null } | null>
>({})
// WebSocket connection
@ -276,7 +281,7 @@ export function useArcadeSession<TState>(
setOtherPlayerCursors((prev) => ({
...prev,
[data.playerId]: data.cursorPosition
? { ...data.cursorPosition, userId: data.userId }
? { ...data.cursorPosition, userId: data.userId, hoveredRegionId: data.hoveredRegionId }
: null,
}))
},
@ -325,9 +330,14 @@ export function useArcadeSession<TState>(
// Send cursor position update to other players (ephemeral, real-time)
const sendCursorUpdate = useCallback(
(playerId: string, sessionUserId: string, cursorPosition: { x: number; y: number } | null) => {
(
playerId: string,
sessionUserId: string,
cursorPosition: { x: number; y: number } | null,
hoveredRegionId: string | null
) => {
if (!roomId) return // Only works in room-based games
socketSendCursorUpdate(roomId, playerId, sessionUserId, cursorPosition)
socketSendCursorUpdate(roomId, playerId, sessionUserId, cursorPosition, hoveredRegionId)
},
[roomId, socketSendCursorUpdate]
)

View File

@ -21,6 +21,7 @@ export interface ArcadeSocketEvents {
playerId: string
userId: string // Session ID that owns this cursor
cursorPosition: { x: number; y: number } | null
hoveredRegionId: string | null // Region being hovered (determined by sender's local hit-testing)
}) => void
/** If true, errors will NOT show toasts (for cases where game handles errors directly) */
suppressErrorToasts?: boolean
@ -38,7 +39,8 @@ export interface UseArcadeSocketReturn {
roomId: string,
playerId: string,
userId: string,
cursorPosition: { x: number; y: number } | null
cursorPosition: { x: number; y: number } | null,
hoveredRegionId: string | null
) => void
}
@ -221,10 +223,11 @@ export function useArcadeSocket(events: ArcadeSocketEvents = {}): UseArcadeSocke
roomId: string,
playerId: string,
userId: string,
cursorPosition: { x: number; y: number } | null
cursorPosition: { x: number; y: number } | null,
hoveredRegionId: string | null
) => {
if (!socket) return
socket.emit('cursor-update', { roomId, playerId, userId, cursorPosition })
socket.emit('cursor-update', { roomId, playerId, userId, cursorPosition, hoveredRegionId })
},
[socket]
)

View File

@ -702,17 +702,20 @@ export function initializeSocketServer(httpServer: HTTPServer) {
playerId,
userId,
cursorPosition,
hoveredRegionId,
}: {
roomId: string
playerId: string
userId: string // Session ID that owns this cursor
cursorPosition: { x: number; y: number } | null // SVG coordinates, null when cursor leaves
hoveredRegionId: string | null // Region being hovered (determined by sender's local hit-testing)
}) => {
// Broadcast to all other sockets in the game room (exclude sender)
socket.to(`game:${roomId}`).emit('cursor-update', {
playerId,
userId,
cursorPosition,
hoveredRegionId,
})
}
)