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:
parent
e4e09256c2
commit
6c3f860efc
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue