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
|
||||
setStudyDuration: (duration: 0 | 30 | 60 | 120) => 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)
|
||||
|
|
@ -103,13 +107,30 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
|
|||
}
|
||||
}, [roomData])
|
||||
|
||||
const { state, sendMove, exitSession, lastError, clearError } =
|
||||
useArcadeSession<KnowYourWorldState>({
|
||||
userId: viewerId || '',
|
||||
roomId: roomData?.id,
|
||||
initialState,
|
||||
applyMove: (state) => state, // Server handles all state updates
|
||||
})
|
||||
const {
|
||||
state,
|
||||
sendMove,
|
||||
exitSession,
|
||||
lastError,
|
||||
clearError,
|
||||
otherPlayerCursors,
|
||||
sendCursorUpdate: sessionSendCursorUpdate,
|
||||
} = useArcadeSession<KnowYourWorldState>({
|
||||
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}
|
||||
|
|
|
|||
|
|
@ -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<string, { x: number; y: number } | null>
|
||||
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<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
|
||||
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 (
|
||||
<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 */}
|
||||
{isBeingRevealed && (
|
||||
<path
|
||||
|
|
@ -1671,6 +1785,19 @@ export function MapRenderer({
|
|||
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 */}
|
||||
<path
|
||||
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>
|
||||
|
||||
{/* 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 */}
|
||||
<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 */}
|
||||
{(() => {
|
||||
// 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 { 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}
|
||||
/>
|
||||
</div>
|
||||
</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
|
||||
|
|
|
|||
|
|
@ -78,6 +78,22 @@ export interface UseArcadeSessionReturn<TState> {
|
|||
* 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<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: () => {
|
||||
// Mock: do nothing in preview
|
||||
},
|
||||
otherPlayerCursors: {},
|
||||
sendCursorUpdate: () => {
|
||||
// Mock: do nothing in preview
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -147,6 +167,11 @@ export function useArcadeSession<TState>(
|
|||
timestamp: null,
|
||||
})
|
||||
|
||||
// Track other players' cursor positions (ephemeral, real-time)
|
||||
const [otherPlayerCursors, setOtherPlayerCursors] = useState<
|
||||
Record<string, { x: number; y: number } | null>
|
||||
>({})
|
||||
|
||||
// WebSocket connection
|
||||
const {
|
||||
socket,
|
||||
|
|
@ -154,6 +179,7 @@ export function useArcadeSession<TState>(
|
|||
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<TState>(
|
|||
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<TState>(
|
|||
}
|
||||
}, [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<TState>(
|
|||
exitSession,
|
||||
clearError: optimistic.clearError,
|
||||
refresh,
|
||||
otherPlayerCursors,
|
||||
sendCursorUpdate,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue