feat(know-your-world): add multiplayer cursor sharing and fix map viewport
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 <noreply@anthropic.com>
This commit is contained in:
parent
171143711c
commit
c3b94bea3d
|
|
@ -33,6 +33,10 @@ interface KnowYourWorldContextValue {
|
||||||
setDifficulty: (difficulty: string) => void
|
setDifficulty: (difficulty: string) => void
|
||||||
setStudyDuration: (duration: 0 | 30 | 60 | 120) => void
|
setStudyDuration: (duration: 0 | 30 | 60 | 120) => void
|
||||||
setContinent: (continent: import('./continents').ContinentId | 'all') => void
|
setContinent: (continent: import('./continents').ContinentId | 'all') => void
|
||||||
|
|
||||||
|
// Cursor position sharing (for multiplayer)
|
||||||
|
otherPlayerCursors: Record<string, { x: number; y: number } | null>
|
||||||
|
sendCursorUpdate: (cursorPosition: { x: number; y: number } | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const KnowYourWorldContext = createContext<KnowYourWorldContextValue | null>(null)
|
const KnowYourWorldContext = createContext<KnowYourWorldContextValue | null>(null)
|
||||||
|
|
@ -103,14 +107,31 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
|
||||||
}
|
}
|
||||||
}, [roomData])
|
}, [roomData])
|
||||||
|
|
||||||
const { state, sendMove, exitSession, lastError, clearError } =
|
const {
|
||||||
useArcadeSession<KnowYourWorldState>({
|
state,
|
||||||
|
sendMove,
|
||||||
|
exitSession,
|
||||||
|
lastError,
|
||||||
|
clearError,
|
||||||
|
otherPlayerCursors,
|
||||||
|
sendCursorUpdate: sessionSendCursorUpdate,
|
||||||
|
} = useArcadeSession<KnowYourWorldState>({
|
||||||
userId: viewerId || '',
|
userId: viewerId || '',
|
||||||
roomId: roomData?.id,
|
roomId: roomData?.id,
|
||||||
initialState,
|
initialState,
|
||||||
applyMove: (state) => state, // Server handles all state updates
|
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
|
// Action: Start Game
|
||||||
const startGame = useCallback(() => {
|
const startGame = useCallback(() => {
|
||||||
const playerMetadata = buildPlayerMetadata(activePlayers, {}, players, viewerId || undefined)
|
const playerMetadata = buildPlayerMetadata(activePlayers, {}, players, viewerId || undefined)
|
||||||
|
|
@ -383,6 +404,8 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
|
||||||
setDifficulty,
|
setDifficulty,
|
||||||
setStudyDuration,
|
setStudyDuration,
|
||||||
setContinent,
|
setContinent,
|
||||||
|
otherPlayerCursors,
|
||||||
|
sendCursorUpdate,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,6 @@ const SHOW_MAGNIFIER_DEBUG_INFO = process.env.NODE_ENV === 'development'
|
||||||
// Debug flag: show bounding boxes with importance scores (dev only)
|
// Debug flag: show bounding boxes with importance scores (dev only)
|
||||||
const SHOW_DEBUG_BOUNDING_BOXES = process.env.NODE_ENV === 'development'
|
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
|
// Precision mode threshold: screen pixel ratio that triggers pointer lock recommendation
|
||||||
const PRECISION_MODE_THRESHOLD = 20
|
const PRECISION_MODE_THRESHOLD = 20
|
||||||
|
|
||||||
|
|
@ -137,6 +134,12 @@ interface MapRendererProps {
|
||||||
}
|
}
|
||||||
// Debug flags
|
// Debug flags
|
||||||
showDebugBoundingBoxes?: boolean
|
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<string, { x: number; y: number } | null>
|
||||||
|
onCursorUpdate?: (cursorPosition: { x: number; y: number } | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -192,6 +195,11 @@ export function MapRenderer({
|
||||||
onGiveUp,
|
onGiveUp,
|
||||||
forceTuning = {},
|
forceTuning = {},
|
||||||
showDebugBoundingBoxes = SHOW_DEBUG_BOUNDING_BOXES,
|
showDebugBoundingBoxes = SHOW_DEBUG_BOUNDING_BOXES,
|
||||||
|
gameMode,
|
||||||
|
currentPlayer,
|
||||||
|
localPlayerId,
|
||||||
|
otherPlayerCursors = {},
|
||||||
|
onCursorUpdate,
|
||||||
}: MapRendererProps) {
|
}: MapRendererProps) {
|
||||||
// Extract force tuning parameters with defaults
|
// Extract force tuning parameters with defaults
|
||||||
const {
|
const {
|
||||||
|
|
@ -537,6 +545,64 @@ export function MapRenderer({
|
||||||
}
|
}
|
||||||
}, [displayViewBox])
|
}, [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<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
|
||||||
|
|
||||||
|
// 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
|
// State for give-up zoom animation target values
|
||||||
const [giveUpZoomTarget, setGiveUpZoomTarget] = useState({
|
const [giveUpZoomTarget, setGiveUpZoomTarget] = useState({
|
||||||
scale: 1,
|
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(() => {
|
useEffect(() => {
|
||||||
if (!svgRef.current) return
|
if (!containerRef.current) return
|
||||||
|
|
||||||
const updateDimensions = () => {
|
const updateDimensions = () => {
|
||||||
const rect = svgRef.current?.getBoundingClientRect()
|
const rect = containerRef.current?.getBoundingClientRect()
|
||||||
if (rect) {
|
if (rect) {
|
||||||
setSvgDimensions({ width: rect.width, height: rect.height })
|
setSvgDimensions({ width: rect.width, height: rect.height })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use ResizeObserver to detect panel resizing (not just window resize)
|
// Use ResizeObserver to detect panel resizing (not just window resize)
|
||||||
const observer = new ResizeObserver((entries) => {
|
const observer = new ResizeObserver(() => {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
updateDimensions()
|
updateDimensions()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
observer.observe(svgRef.current)
|
observer.observe(containerRef.current)
|
||||||
|
|
||||||
// Initial measurement
|
// Initial measurement
|
||||||
updateDimensions()
|
updateDimensions()
|
||||||
|
|
||||||
return () => observer.disconnect()
|
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
|
// Calculate label positions using ghost elements
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -1386,6 +1454,28 @@ export function MapRenderer({
|
||||||
cursorPositionRef.current = { x: cursorX, y: cursorY }
|
cursorPositionRef.current = { x: cursorX, y: cursorY }
|
||||||
setCursorPosition({ 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)
|
// Check if fake cursor is hovering over Give Up button (for pointer lock mode)
|
||||||
if (pointerLocked) {
|
if (pointerLocked) {
|
||||||
const buttonBounds = giveUpButtonBoundsRef.current
|
const buttonBounds = giveUpButtonBoundsRef.current
|
||||||
|
|
@ -1556,6 +1646,12 @@ export function MapRenderer({
|
||||||
setCursorPosition(null)
|
setCursorPosition(null)
|
||||||
setDebugBoundingBoxes([]) // Clear bounding boxes when leaving
|
setDebugBoundingBoxes([]) // Clear bounding boxes when leaving
|
||||||
cursorPositionRef.current = null
|
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 (
|
return (
|
||||||
|
|
@ -1615,13 +1711,15 @@ export function MapRenderer({
|
||||||
ref={svgRef}
|
ref={svgRef}
|
||||||
viewBox={displayViewBox}
|
viewBox={displayViewBox}
|
||||||
className={css({
|
className={css({
|
||||||
maxWidth: '100%',
|
// Fill the entire container - viewBox controls what portion of map is visible
|
||||||
maxHeight: '100%',
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
cursor: pointerLocked ? 'crosshair' : 'pointer',
|
cursor: pointerLocked ? 'crosshair' : 'pointer',
|
||||||
transformOrigin: 'center center',
|
transformOrigin: 'center center',
|
||||||
})}
|
})}
|
||||||
style={{
|
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
|
// CSS transform for zoom animation during give-up reveal
|
||||||
transform: to(
|
transform: to(
|
||||||
[mainMapSpring.scale, mainMapSpring.translateX, mainMapSpring.translateY],
|
[mainMapSpring.scale, mainMapSpring.translateX, mainMapSpring.translateY],
|
||||||
|
|
@ -1658,8 +1756,24 @@ export function MapRenderer({
|
||||||
: getRegionStroke(isFound, isDark)
|
: getRegionStroke(isFound, isDark)
|
||||||
const strokeWidth = isBeingRevealed ? 3 : isFound ? 1 : 1.5
|
const strokeWidth = isBeingRevealed ? 3 : isFound ? 1 : 1.5
|
||||||
|
|
||||||
|
// Check if a network cursor is hovering over this region
|
||||||
|
const networkHover = networkHoveredRegions[region.id]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<g key={region.id} style={{ opacity: dimmedOpacity }}>
|
<g key={region.id} style={{ opacity: dimmedOpacity }}>
|
||||||
|
{/* Glow effect for network-hovered region (other player's cursor) */}
|
||||||
|
{networkHover && !isBeingRevealed && (
|
||||||
|
<path
|
||||||
|
d={region.path}
|
||||||
|
fill="none"
|
||||||
|
stroke={networkHover.color}
|
||||||
|
strokeWidth={6}
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
opacity={0.5}
|
||||||
|
style={{ filter: 'blur(3px)' }}
|
||||||
|
pointerEvents="none"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{/* Glow effect for revealed region */}
|
{/* Glow effect for revealed region */}
|
||||||
{isBeingRevealed && (
|
{isBeingRevealed && (
|
||||||
<path
|
<path
|
||||||
|
|
@ -1671,6 +1785,19 @@ export function MapRenderer({
|
||||||
style={{ filter: 'blur(4px)' }}
|
style={{ filter: 'blur(4px)' }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{/* Network hover border (crisp outline in player color) */}
|
||||||
|
{networkHover && !isBeingRevealed && (
|
||||||
|
<path
|
||||||
|
d={region.path}
|
||||||
|
fill="none"
|
||||||
|
stroke={networkHover.color}
|
||||||
|
strokeWidth={2}
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
opacity={0.8}
|
||||||
|
strokeDasharray="4,2"
|
||||||
|
pointerEvents="none"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{/* Region path */}
|
{/* Region path */}
|
||||||
<path
|
<path
|
||||||
data-region-id={region.id}
|
data-region-id={region.id}
|
||||||
|
|
@ -1848,30 +1975,6 @@ export function MapRenderer({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 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 (
|
|
||||||
<rect
|
|
||||||
data-element="crop-region-debug"
|
|
||||||
x={cropX}
|
|
||||||
y={cropY}
|
|
||||||
width={cropWidth}
|
|
||||||
height={cropHeight}
|
|
||||||
fill="none"
|
|
||||||
stroke="#ff00ff"
|
|
||||||
strokeWidth={2}
|
|
||||||
vectorEffect="non-scaling-stroke"
|
|
||||||
strokeDasharray="8,4"
|
|
||||||
pointerEvents="none"
|
|
||||||
opacity={0.8}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
</animated.svg>
|
</animated.svg>
|
||||||
|
|
||||||
{/* HTML labels positioned absolutely over the SVG */}
|
{/* HTML labels positioned absolutely over the SVG */}
|
||||||
|
|
@ -2968,8 +3071,8 @@ export function MapRenderer({
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* Debug: Auto zoom detection visualization */}
|
{/* Debug: Auto zoom detection visualization (dev only) */}
|
||||||
{cursorPosition && containerRef.current && (
|
{SHOW_MAGNIFIER_DEBUG_INFO && cursorPosition && containerRef.current && (
|
||||||
<>
|
<>
|
||||||
{/* Detection box - 50px box around cursor */}
|
{/* Detection box - 50px box around cursor */}
|
||||||
<div
|
<div
|
||||||
|
|
@ -3091,6 +3194,137 @@ export function MapRenderer({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 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 (
|
||||||
|
<div
|
||||||
|
key={`cursor-${playerId}`}
|
||||||
|
data-element="other-player-cursor"
|
||||||
|
data-player-id={playerId}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: screenX,
|
||||||
|
top: screenY,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 100,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Crosshair - centered on the cursor position */}
|
||||||
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: -12, // Half of width to center
|
||||||
|
top: -12, // Half of height to center
|
||||||
|
filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.5))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Outer ring */}
|
||||||
|
<circle
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="8"
|
||||||
|
fill="none"
|
||||||
|
stroke={player.color}
|
||||||
|
strokeWidth="2"
|
||||||
|
opacity="0.8"
|
||||||
|
/>
|
||||||
|
{/* Cross lines */}
|
||||||
|
<line
|
||||||
|
x1="12"
|
||||||
|
y1="2"
|
||||||
|
x2="12"
|
||||||
|
y2="8"
|
||||||
|
stroke={player.color}
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="12"
|
||||||
|
y1="16"
|
||||||
|
x2="12"
|
||||||
|
y2="22"
|
||||||
|
stroke={player.color}
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="2"
|
||||||
|
y1="12"
|
||||||
|
x2="8"
|
||||||
|
y2="12"
|
||||||
|
stroke={player.color}
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="16"
|
||||||
|
y1="12"
|
||||||
|
x2="22"
|
||||||
|
y2="12"
|
||||||
|
stroke={player.color}
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
{/* Center dot */}
|
||||||
|
<circle cx="12" cy="12" r="2" fill={player.color} />
|
||||||
|
</svg>
|
||||||
|
{/* Player emoji label - positioned below crosshair */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '50%',
|
||||||
|
top: 14, // Below the crosshair (12px half-height + 2px gap)
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
fontSize: '16px',
|
||||||
|
textShadow: '0 1px 2px rgba(0,0,0,0.5)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{player.emoji}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
{/* Give Up button overlay - positioned within SVG bounds for pointer lock accessibility */}
|
{/* 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
|
// Use svgDimensions to trigger re-render on resize, but get actual rect for positioning
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,17 @@ import { useKnowYourWorld } from '../Provider'
|
||||||
import { getFilteredMapDataSync } from '../maps'
|
import { getFilteredMapDataSync } from '../maps'
|
||||||
import { MapRenderer } from './MapRenderer'
|
import { MapRenderer } from './MapRenderer'
|
||||||
import { GameInfoPanel } from './GameInfoPanel'
|
import { GameInfoPanel } from './GameInfoPanel'
|
||||||
|
import { useViewerId } from '@/lib/arcade/game-sdk'
|
||||||
|
import { useGameMode } from '@/lib/arcade/game-sdk'
|
||||||
|
|
||||||
export function PlayingPhase() {
|
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(
|
const mapData = getFilteredMapDataSync(
|
||||||
state.selectedMap,
|
state.selectedMap,
|
||||||
|
|
@ -114,6 +122,11 @@ export function PlayingPhase() {
|
||||||
playerMetadata={state.playerMetadata}
|
playerMetadata={state.playerMetadata}
|
||||||
giveUpReveal={state.giveUpReveal}
|
giveUpReveal={state.giveUpReveal}
|
||||||
onGiveUp={giveUp}
|
onGiveUp={giveUp}
|
||||||
|
gameMode={state.gameMode}
|
||||||
|
currentPlayer={state.currentPlayer}
|
||||||
|
localPlayerId={localPlayerId}
|
||||||
|
otherPlayerCursors={otherPlayerCursors}
|
||||||
|
onCursorUpdate={sendCursorUpdate}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
|
||||||
|
|
@ -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
|
* Get custom crop viewBox for a map/continent combination
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,22 @@ export interface UseArcadeSessionReturn<TState> {
|
||||||
* Manually sync with server (useful after reconnect)
|
* Manually sync with server (useful after reconnect)
|
||||||
*/
|
*/
|
||||||
refresh: () => void
|
refresh: () => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Other players' cursor positions (ephemeral, real-time)
|
||||||
|
* Map of playerId -> { x, y } in SVG coordinates, or null if cursor left
|
||||||
|
*/
|
||||||
|
otherPlayerCursors: Record<string, { x: number; y: number } | null>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<TState>(
|
||||||
refresh: () => {
|
refresh: () => {
|
||||||
// Mock: do nothing in preview
|
// Mock: do nothing in preview
|
||||||
},
|
},
|
||||||
|
otherPlayerCursors: {},
|
||||||
|
sendCursorUpdate: () => {
|
||||||
|
// Mock: do nothing in preview
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -147,6 +167,11 @@ export function useArcadeSession<TState>(
|
||||||
timestamp: null,
|
timestamp: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Track other players' cursor positions (ephemeral, real-time)
|
||||||
|
const [otherPlayerCursors, setOtherPlayerCursors] = useState<
|
||||||
|
Record<string, { x: number; y: number } | null>
|
||||||
|
>({})
|
||||||
|
|
||||||
// WebSocket connection
|
// WebSocket connection
|
||||||
const {
|
const {
|
||||||
socket,
|
socket,
|
||||||
|
|
@ -154,6 +179,7 @@ export function useArcadeSession<TState>(
|
||||||
joinSession,
|
joinSession,
|
||||||
sendMove: socketSendMove,
|
sendMove: socketSendMove,
|
||||||
exitSession: socketExitSession,
|
exitSession: socketExitSession,
|
||||||
|
sendCursorUpdate: socketSendCursorUpdate,
|
||||||
} = useArcadeSocket({
|
} = useArcadeSocket({
|
||||||
onSessionState: (data) => {
|
onSessionState: (data) => {
|
||||||
optimistic.syncWithServer(data.gameState as TState, data.version)
|
optimistic.syncWithServer(data.gameState as TState, data.version)
|
||||||
|
|
@ -243,6 +269,13 @@ export function useArcadeSession<TState>(
|
||||||
onError: (data) => {
|
onError: (data) => {
|
||||||
console.error(`[ArcadeSession] Error: ${data.error}`)
|
console.error(`[ArcadeSession] Error: ${data.error}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
onCursorUpdate: (data) => {
|
||||||
|
setOtherPlayerCursors((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[data.playerId]: data.cursorPosition,
|
||||||
|
}))
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Auto-join session when connected
|
// Auto-join session when connected
|
||||||
|
|
@ -286,6 +319,15 @@ export function useArcadeSession<TState>(
|
||||||
}
|
}
|
||||||
}, [connected, userId, roomId, joinSession])
|
}, [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 {
|
return {
|
||||||
state: optimistic.state,
|
state: optimistic.state,
|
||||||
version: optimistic.version,
|
version: optimistic.version,
|
||||||
|
|
@ -297,5 +339,7 @@ export function useArcadeSession<TState>(
|
||||||
exitSession,
|
exitSession,
|
||||||
clearError: optimistic.clearError,
|
clearError: optimistic.clearError,
|
||||||
refresh,
|
refresh,
|
||||||
|
otherPlayerCursors,
|
||||||
|
sendCursorUpdate,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,11 @@ export interface ArcadeSocketEvents {
|
||||||
onSessionEnded?: () => void
|
onSessionEnded?: () => void
|
||||||
onNoActiveSession?: () => void
|
onNoActiveSession?: () => void
|
||||||
onError?: (error: { error: string }) => 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) */
|
/** If true, errors will NOT show toasts (for cases where game handles errors directly) */
|
||||||
suppressErrorToasts?: boolean
|
suppressErrorToasts?: boolean
|
||||||
}
|
}
|
||||||
|
|
@ -27,6 +32,12 @@ export interface UseArcadeSocketReturn {
|
||||||
sendMove: (userId: string, move: GameMove, roomId?: string) => void
|
sendMove: (userId: string, move: GameMove, roomId?: string) => void
|
||||||
exitSession: (userId: string) => void
|
exitSession: (userId: string) => void
|
||||||
pingSession: (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')
|
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)
|
setSocket(socketInstance)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|
@ -198,6 +214,14 @@ export function useArcadeSocket(events: ArcadeSocketEvents = {}): UseArcadeSocke
|
||||||
[socket]
|
[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 {
|
return {
|
||||||
socket,
|
socket,
|
||||||
connected,
|
connected,
|
||||||
|
|
@ -205,5 +229,6 @@ export function useArcadeSocket(events: ArcadeSocketEvents = {}): UseArcadeSocke
|
||||||
sendMove,
|
sendMove,
|
||||||
exitSession,
|
exitSession,
|
||||||
pingSession,
|
pingSession,
|
||||||
|
sendCursorUpdate,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
socket.on('disconnect', () => {
|
||||||
// Don't delete session on disconnect - it persists across devices
|
// Don't delete session on disconnect - it persists across devices
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue