feat(know-your-world): add mobile cursor sharing and fix multi-device coop mode
- Broadcast cursor position during mobile drag gesture for magnifier - Key cursors by userId (session ID) instead of playerId to support multiple devices per player in cooperative mode - Enable hot/cold feedback during initial mobile drag (not just magnifier pan) - Fall back to memberPlayers lookup for remote player metadata when rendering cursors (fixes cursor visibility for remote players) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
54402501e5
commit
2ce5e180b7
|
|
@ -137,12 +137,13 @@ interface KnowYourWorldContextValue {
|
||||||
setContinent: (continent: import('./continents').ContinentId | 'all') => void
|
setContinent: (continent: import('./continents').ContinentId | 'all') => void
|
||||||
|
|
||||||
// Cursor position sharing (for multiplayer)
|
// Cursor position sharing (for multiplayer)
|
||||||
|
// Keyed by userId (session ID) to support multiple devices in coop mode
|
||||||
otherPlayerCursors: Record<
|
otherPlayerCursors: Record<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
x: number
|
x: number
|
||||||
y: number
|
y: number
|
||||||
userId: string
|
playerId: string
|
||||||
hoveredRegionId: string | null
|
hoveredRegionId: string | null
|
||||||
} | null
|
} | null
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -468,12 +468,13 @@ interface MapRendererProps {
|
||||||
gameMode?: 'cooperative' | 'race' | 'turn-based'
|
gameMode?: 'cooperative' | 'race' | 'turn-based'
|
||||||
currentPlayer?: string // The player whose turn it is (for turn-based mode)
|
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)
|
localPlayerId?: string // The local player's ID (to filter out our own cursor from others)
|
||||||
|
// Keyed by userId (session ID) to support multiple devices in coop mode
|
||||||
otherPlayerCursors?: Record<
|
otherPlayerCursors?: Record<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
x: number
|
x: number
|
||||||
y: number
|
y: number
|
||||||
userId: string
|
playerId: string
|
||||||
hoveredRegionId: string | null
|
hoveredRegionId: string | null
|
||||||
} | null
|
} | null
|
||||||
>
|
>
|
||||||
|
|
@ -1101,7 +1102,7 @@ export function MapRenderer({
|
||||||
|
|
||||||
// Hot/cold audio feedback hook
|
// Hot/cold audio feedback hook
|
||||||
// Enabled if: 1) assistance level allows it, 2) user toggle is on
|
// Enabled if: 1) assistance level allows it, 2) user toggle is on
|
||||||
// 3) either has fine pointer (desktop) OR magnifier is active (mobile)
|
// 3) either has fine pointer (desktop) OR magnifier/drag is active (mobile)
|
||||||
// Use continent name for language lookup if available, otherwise use selectedMap
|
// Use continent name for language lookup if available, otherwise use selectedMap
|
||||||
const hotColdMapName = selectedContinent || selectedMap
|
const hotColdMapName = selectedContinent || selectedMap
|
||||||
const {
|
const {
|
||||||
|
|
@ -1112,11 +1113,11 @@ export function MapRenderer({
|
||||||
} = useHotColdFeedback({
|
} = useHotColdFeedback({
|
||||||
// In turn-based mode, only enable hot/cold for the player whose turn it is
|
// In turn-based mode, only enable hot/cold for the player whose turn it is
|
||||||
// Desktop: hasAnyFinePointer enables mouse-based hot/cold
|
// Desktop: hasAnyFinePointer enables mouse-based hot/cold
|
||||||
// Mobile: showMagnifier enables magnifier-based hot/cold
|
// Mobile: showMagnifier OR isMobileMapDragging enables touch-based hot/cold
|
||||||
enabled:
|
enabled:
|
||||||
assistanceAllowsHotCold &&
|
assistanceAllowsHotCold &&
|
||||||
hotColdEnabled &&
|
hotColdEnabled &&
|
||||||
(hasAnyFinePointer || showMagnifier) &&
|
(hasAnyFinePointer || showMagnifier || isMobileMapDragging) &&
|
||||||
(gameMode !== 'turn-based' || currentPlayer === localPlayerId),
|
(gameMode !== 'turn-based' || currentPlayer === localPlayerId),
|
||||||
targetRegionId: currentPrompt,
|
targetRegionId: currentPrompt,
|
||||||
isSpeaking,
|
isSpeaking,
|
||||||
|
|
@ -1412,26 +1413,38 @@ export function MapRenderer({
|
||||||
const networkHoveredRegions = useMemo(() => {
|
const networkHoveredRegions = useMemo(() => {
|
||||||
const result: Record<string, { playerId: string; color: string }> = {}
|
const result: Record<string, { playerId: string; color: string }> = {}
|
||||||
|
|
||||||
Object.entries(otherPlayerCursors).forEach(([playerId, position]) => {
|
// Cursors are keyed by userId (session ID), playerId is in the position data
|
||||||
// Skip our own cursor and null positions
|
Object.entries(otherPlayerCursors).forEach(([cursorUserId, position]) => {
|
||||||
if (playerId === localPlayerId || !position) return
|
// Skip our own cursor (by viewerId) and null positions
|
||||||
|
if (cursorUserId === viewerId || !position) return
|
||||||
|
|
||||||
// In turn-based mode, only show hover when it's not our turn
|
// In turn-based mode, only show hover when it's not our turn
|
||||||
if (gameMode === 'turn-based' && currentPlayer === localPlayerId) return
|
if (gameMode === 'turn-based' && currentPlayer === localPlayerId) return
|
||||||
|
|
||||||
// Get player color
|
// Get player color from the playerId in the cursor data
|
||||||
const player = playerMetadata[playerId]
|
// First check playerMetadata, then fall back to memberPlayers (for remote players)
|
||||||
|
let player = playerMetadata[position.playerId]
|
||||||
|
if (!player) {
|
||||||
|
// Player not in local playerMetadata - look through memberPlayers
|
||||||
|
for (const players of Object.values(memberPlayers)) {
|
||||||
|
const found = players.find((p) => p.id === position.playerId)
|
||||||
|
if (found) {
|
||||||
|
player = found
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!player) return
|
if (!player) return
|
||||||
|
|
||||||
// Use the transmitted hoveredRegionId directly (avoids hit-testing discrepancies
|
// Use the transmitted hoveredRegionId directly (avoids hit-testing discrepancies
|
||||||
// due to pixel scaling/rendering differences between clients)
|
// due to pixel scaling/rendering differences between clients)
|
||||||
if (position.hoveredRegionId) {
|
if (position.hoveredRegionId) {
|
||||||
result[position.hoveredRegionId] = { playerId, color: player.color }
|
result[position.hoveredRegionId] = { playerId: position.playerId, color: player.color }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}, [otherPlayerCursors, localPlayerId, gameMode, currentPlayer, playerMetadata])
|
}, [otherPlayerCursors, viewerId, gameMode, currentPlayer, localPlayerId, playerMetadata, memberPlayers])
|
||||||
|
|
||||||
// State for give-up zoom animation target values
|
// State for give-up zoom animation target values
|
||||||
const [giveUpZoomTarget, setGiveUpZoomTarget] = useState({
|
const [giveUpZoomTarget, setGiveUpZoomTarget] = useState({
|
||||||
|
|
@ -2751,6 +2764,16 @@ export function MapRenderer({
|
||||||
svgRef.current &&
|
svgRef.current &&
|
||||||
(gameMode !== 'turn-based' || currentPlayer === localPlayerId)
|
(gameMode !== 'turn-based' || currentPlayer === localPlayerId)
|
||||||
|
|
||||||
|
// Only log occasionally to avoid spam (every 60 frames ~= 1 second)
|
||||||
|
if (Math.random() < 0.016) {
|
||||||
|
console.log('[CursorShare] 🖱️ Desktop pointer broadcast check:', {
|
||||||
|
hasOnCursorUpdate: !!onCursorUpdate,
|
||||||
|
hasSvgRef: !!svgRef.current,
|
||||||
|
gameMode,
|
||||||
|
shouldBroadcast: shouldBroadcastCursor,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (shouldBroadcastCursor) {
|
if (shouldBroadcastCursor) {
|
||||||
const viewBoxParts = displayViewBox.split(' ').map(Number)
|
const viewBoxParts = displayViewBox.split(' ').map(Number)
|
||||||
const viewBoxX = viewBoxParts[0] || 0
|
const viewBoxX = viewBoxParts[0] || 0
|
||||||
|
|
@ -3021,6 +3044,36 @@ export function MapRenderer({
|
||||||
|
|
||||||
// Use adaptive zoom search utility to find optimal zoom (same algorithm as desktop)
|
// Use adaptive zoom search utility to find optimal zoom (same algorithm as desktop)
|
||||||
const svgRect = svgRef.current.getBoundingClientRect()
|
const svgRect = svgRef.current.getBoundingClientRect()
|
||||||
|
|
||||||
|
// Broadcast cursor position to other players (in SVG coordinates)
|
||||||
|
// In turn-based mode, only broadcast when it's our turn
|
||||||
|
const shouldBroadcastCursor =
|
||||||
|
onCursorUpdate && (gameMode !== 'turn-based' || currentPlayer === localPlayerId)
|
||||||
|
|
||||||
|
// Only log occasionally to avoid spam
|
||||||
|
if (Math.random() < 0.1) {
|
||||||
|
console.log('[CursorShare] 📱 handleMapTouchMove broadcast check:', {
|
||||||
|
hasOnCursorUpdate: !!onCursorUpdate,
|
||||||
|
gameMode,
|
||||||
|
currentPlayer,
|
||||||
|
localPlayerId,
|
||||||
|
shouldBroadcast: shouldBroadcastCursor,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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 viewport = getRenderedViewport(svgRect, viewBoxX, viewBoxY, viewBoxW, viewBoxH)
|
||||||
|
const svgOffsetX = svgRect.left - containerRect.left + viewport.letterboxX
|
||||||
|
const svgOffsetY = svgRect.top - containerRect.top + viewport.letterboxY
|
||||||
|
const cursorSvgX = (cursorX - svgOffsetX) / viewport.scale + viewBoxX
|
||||||
|
const cursorSvgY = (cursorY - svgOffsetY) / viewport.scale + viewBoxY
|
||||||
|
onCursorUpdate({ x: cursorSvgX, y: cursorSvgY }, regionUnderCursor)
|
||||||
|
}
|
||||||
const zoomSearchResult = findOptimalZoom({
|
const zoomSearchResult = findOptimalZoom({
|
||||||
detectedRegions: unfoundRegionObjects,
|
detectedRegions: unfoundRegionObjects,
|
||||||
detectedSmallestSize,
|
detectedSmallestSize,
|
||||||
|
|
@ -3107,6 +3160,10 @@ export function MapRenderer({
|
||||||
isInTakeover,
|
isInTakeover,
|
||||||
displayViewBox,
|
displayViewBox,
|
||||||
checkHotCold,
|
checkHotCold,
|
||||||
|
onCursorUpdate,
|
||||||
|
gameMode,
|
||||||
|
currentPlayer,
|
||||||
|
localPlayerId,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -3118,7 +3175,13 @@ export function MapRenderer({
|
||||||
cursorPositionRef.current = null
|
cursorPositionRef.current = null
|
||||||
setIsMagnifierExpanded(false) // Reset expanded state on dismiss
|
setIsMagnifierExpanded(false) // Reset expanded state on dismiss
|
||||||
setMobileMapDragTriggeredMagnifier(false) // Reset mobile drag trigger state
|
setMobileMapDragTriggeredMagnifier(false) // Reset mobile drag trigger state
|
||||||
}, [])
|
|
||||||
|
// Notify other players that cursor is no longer active
|
||||||
|
// In turn-based mode, only broadcast when it's our turn
|
||||||
|
if (onCursorUpdate && (gameMode !== 'turn-based' || currentPlayer === localPlayerId)) {
|
||||||
|
onCursorUpdate(null, null)
|
||||||
|
}
|
||||||
|
}, [onCursorUpdate, gameMode, currentPlayer, localPlayerId])
|
||||||
|
|
||||||
const handleMapTouchEnd = useCallback(() => {
|
const handleMapTouchEnd = useCallback(() => {
|
||||||
const wasDragging = isMobileMapDragging
|
const wasDragging = isMobileMapDragging
|
||||||
|
|
@ -5784,22 +5847,37 @@ export function MapRenderer({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Other players' cursors - show in multiplayer when not exclusively our turn */}
|
{/* Other players' cursors - show in multiplayer when not exclusively our turn */}
|
||||||
|
{/* Cursor rendering debug - only log when cursor count changes */}
|
||||||
{svgRef.current &&
|
{svgRef.current &&
|
||||||
containerRef.current &&
|
containerRef.current &&
|
||||||
Object.entries(otherPlayerCursors).map(([playerId, position]) => {
|
Object.entries(otherPlayerCursors).map(([cursorUserId, position]) => {
|
||||||
// Skip our own cursor and null positions
|
// Skip our own cursor (by viewerId) and null positions
|
||||||
if (playerId === localPlayerId || !position) return null
|
if (cursorUserId === viewerId || !position) return null
|
||||||
|
|
||||||
// In turn-based mode, only show other cursors when it's not our turn
|
// In turn-based mode, only show other cursors when it's not our turn
|
||||||
if (gameMode === 'turn-based' && currentPlayer === localPlayerId) return null
|
if (gameMode === 'turn-based' && currentPlayer === localPlayerId) return null
|
||||||
|
|
||||||
// Get player metadata for emoji and color
|
// Get player metadata for emoji and color (playerId is in position data)
|
||||||
const player = playerMetadata[playerId]
|
// First check playerMetadata, then fall back to memberPlayers (for remote players)
|
||||||
if (!player) return null
|
let player = playerMetadata[position.playerId]
|
||||||
|
if (!player) {
|
||||||
|
// Player not in local playerMetadata - look through memberPlayers
|
||||||
|
// memberPlayers is keyed by userId and contains arrays of players
|
||||||
|
for (const players of Object.values(memberPlayers)) {
|
||||||
|
const found = players.find((p) => p.id === position.playerId)
|
||||||
|
if (found) {
|
||||||
|
player = found
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!player) {
|
||||||
|
console.log('[CursorShare] ⚠️ No player found in playerMetadata or memberPlayers for playerId:', position.playerId)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
// In collaborative mode, find all players from the same session and show all their emojis
|
// In collaborative mode, find all players from the same session and show all their emojis
|
||||||
// Use memberPlayers (from roomData) which is the canonical source of player ownership
|
// Use memberPlayers (from roomData) which is the canonical source of player ownership
|
||||||
const cursorUserId = position.userId
|
|
||||||
const sessionPlayers =
|
const sessionPlayers =
|
||||||
gameMode === 'cooperative' && cursorUserId && memberPlayers[cursorUserId]
|
gameMode === 'cooperative' && cursorUserId && memberPlayers[cursorUserId]
|
||||||
? memberPlayers[cursorUserId]
|
? memberPlayers[cursorUserId]
|
||||||
|
|
@ -5831,9 +5909,10 @@ export function MapRenderer({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`cursor-${playerId}`}
|
key={`cursor-${cursorUserId}`}
|
||||||
data-element="other-player-cursor"
|
data-element="other-player-cursor"
|
||||||
data-player-id={playerId}
|
data-player-id={position.playerId}
|
||||||
|
data-user-id={cursorUserId}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: screenX,
|
left: screenX,
|
||||||
|
|
|
||||||
|
|
@ -81,14 +81,15 @@ export interface UseArcadeSessionReturn<TState> {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Other players' cursor positions (ephemeral, real-time)
|
* Other players' cursor positions (ephemeral, real-time)
|
||||||
* Map of playerId -> { x, y, userId, hoveredRegionId } in SVG coordinates, or null if cursor left
|
* Map of userId -> { x, y, playerId, hoveredRegionId } in SVG coordinates, or null if cursor left
|
||||||
|
* Keyed by userId (session ID) to support multiple devices in coop mode
|
||||||
*/
|
*/
|
||||||
otherPlayerCursors: Record<
|
otherPlayerCursors: Record<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
x: number
|
x: number
|
||||||
y: number
|
y: number
|
||||||
userId: string
|
playerId: string
|
||||||
hoveredRegionId: string | null
|
hoveredRegionId: string | null
|
||||||
} | null
|
} | null
|
||||||
>
|
>
|
||||||
|
|
@ -180,13 +181,14 @@ export function useArcadeSession<TState>(
|
||||||
})
|
})
|
||||||
|
|
||||||
// Track other players' cursor positions (ephemeral, real-time)
|
// Track other players' cursor positions (ephemeral, real-time)
|
||||||
|
// Keyed by userId (session ID) to support multiple devices in coop mode
|
||||||
const [otherPlayerCursors, setOtherPlayerCursors] = useState<
|
const [otherPlayerCursors, setOtherPlayerCursors] = useState<
|
||||||
Record<
|
Record<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
x: number
|
x: number
|
||||||
y: number
|
y: number
|
||||||
userId: string
|
playerId: string
|
||||||
hoveredRegionId: string | null
|
hoveredRegionId: string | null
|
||||||
} | null
|
} | null
|
||||||
>
|
>
|
||||||
|
|
@ -291,16 +293,21 @@ export function useArcadeSession<TState>(
|
||||||
},
|
},
|
||||||
|
|
||||||
onCursorUpdate: (data) => {
|
onCursorUpdate: (data) => {
|
||||||
setOtherPlayerCursors((prev) => ({
|
// Key by userId (session ID) to support multiple devices in coop mode
|
||||||
|
// Each device has a unique userId, even if they're playing as the same player
|
||||||
|
setOtherPlayerCursors((prev) => {
|
||||||
|
const newState = {
|
||||||
...prev,
|
...prev,
|
||||||
[data.playerId]: data.cursorPosition
|
[data.userId]: data.cursorPosition
|
||||||
? {
|
? {
|
||||||
...data.cursorPosition,
|
...data.cursorPosition,
|
||||||
userId: data.userId,
|
playerId: data.playerId,
|
||||||
hoveredRegionId: data.hoveredRegionId,
|
hoveredRegionId: data.hoveredRegionId,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
}))
|
}
|
||||||
|
return newState
|
||||||
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -161,6 +161,12 @@ export function useArcadeSocket(events: ArcadeSocketEvents = {}): UseArcadeSocke
|
||||||
|
|
||||||
// Cursor position update from other players (ephemeral, real-time)
|
// Cursor position update from other players (ephemeral, real-time)
|
||||||
socketInstance.on('cursor-update', (data) => {
|
socketInstance.on('cursor-update', (data) => {
|
||||||
|
console.log('[CursorShare] 📥 Received cursor-update:', {
|
||||||
|
fromUserId: data.userId,
|
||||||
|
playerId: data.playerId,
|
||||||
|
hasPosition: !!data.cursorPosition,
|
||||||
|
hoveredRegionId: data.hoveredRegionId,
|
||||||
|
})
|
||||||
eventsRef.current.onCursorUpdate?.(data)
|
eventsRef.current.onCursorUpdate?.(data)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -227,6 +233,13 @@ export function useArcadeSocket(events: ArcadeSocketEvents = {}): UseArcadeSocke
|
||||||
hoveredRegionId: string | null
|
hoveredRegionId: string | null
|
||||||
) => {
|
) => {
|
||||||
if (!socket) return
|
if (!socket) return
|
||||||
|
console.log('[CursorShare] 📤 Sending cursor-update:', {
|
||||||
|
roomId,
|
||||||
|
playerId,
|
||||||
|
userId,
|
||||||
|
hasPosition: !!cursorPosition,
|
||||||
|
hoveredRegionId,
|
||||||
|
})
|
||||||
socket.emit('cursor-update', {
|
socket.emit('cursor-update', {
|
||||||
roomId,
|
roomId,
|
||||||
playerId,
|
playerId,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue