feat(know-your-world): add session-based give-up voting and fix cursor emojis
Multiplayer improvements: - Add unanimous give-up voting for cooperative mode (all sessions must agree) - Track give-up votes by session (userId) not player, since local players discuss together - Add activeUserIds to track unique sessions participating in the game Cursor emoji fix: - Pass userId through cursor-update socket events to identify sender's session - Use memberPlayers from roomData (canonical source) for cursor emoji lookup - Show all emojis from a session's players on that session's network cursor - Build playerMetadata with correct ownership map from roomData.memberPlayers Files changed: - socket-server.ts: Add userId to cursor-update events - useArcadeSocket.ts: Add userId to cursor update types and functions - useArcadeSession.ts: Store userId with cursor positions - Provider.tsx: Expose memberPlayers, pass userId with cursor updates - PlayingPhase.tsx: Include viewerId in cursor updates - MapRenderer.tsx: Use memberPlayers for emoji lookup, add voting UI props - Validator.ts: Session-based give-up voting logic - types.ts: Add giveUpVotes and activeUserIds to state 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -35,8 +35,15 @@ interface KnowYourWorldContextValue {
|
||||
setContinent: (continent: import('./continents').ContinentId | 'all') => void
|
||||
|
||||
// Cursor position sharing (for multiplayer)
|
||||
otherPlayerCursors: Record<string, { x: number; y: number } | null>
|
||||
sendCursorUpdate: (playerId: string, cursorPosition: { x: number; y: number } | null) => void
|
||||
otherPlayerCursors: Record<string, { x: number; y: number; userId: string } | null>
|
||||
sendCursorUpdate: (
|
||||
playerId: string,
|
||||
userId: string,
|
||||
cursorPosition: { x: number; y: number } | null
|
||||
) => void
|
||||
|
||||
// Member players mapping (userId -> players) for cursor emoji display
|
||||
memberPlayers: Record<string, Array<{ id: string; name: string; emoji: string; color: string }>>
|
||||
}
|
||||
|
||||
const KnowYourWorldContext = createContext<KnowYourWorldContextValue | null>(null)
|
||||
@@ -102,8 +109,10 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
|
||||
guessHistory: [],
|
||||
startTime: 0,
|
||||
activePlayers: [],
|
||||
activeUserIds: [],
|
||||
playerMetadata: {},
|
||||
giveUpReveal: null,
|
||||
giveUpVotes: [],
|
||||
}
|
||||
}, [roomData])
|
||||
|
||||
@@ -122,11 +131,11 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
|
||||
applyMove: (state) => state, // Server handles all state updates
|
||||
})
|
||||
|
||||
// Pass through cursor updates with the provided player ID
|
||||
// Pass through cursor updates with the provided player ID and userId
|
||||
const sendCursorUpdate = useCallback(
|
||||
(playerId: string, cursorPosition: { x: number; y: number } | null) => {
|
||||
if (playerId) {
|
||||
sessionSendCursorUpdate(playerId, cursorPosition)
|
||||
(playerId: string, sessionUserId: string, cursorPosition: { x: number; y: number } | null) => {
|
||||
if (playerId && sessionUserId) {
|
||||
sessionSendCursorUpdate(playerId, sessionUserId, cursorPosition)
|
||||
}
|
||||
},
|
||||
[sessionSendCursorUpdate]
|
||||
@@ -134,7 +143,14 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
|
||||
|
||||
// Action: Start Game
|
||||
const startGame = useCallback(() => {
|
||||
const playerMetadata = buildPlayerMetadata(activePlayers, {}, players, viewerId || undefined)
|
||||
// Build ownership map from roomData to correctly map players to their owners
|
||||
const ownershipMap = buildPlayerOwnershipFromRoomData(roomData)
|
||||
const playerMetadata = buildPlayerMetadata(
|
||||
activePlayers,
|
||||
ownershipMap,
|
||||
players,
|
||||
viewerId || undefined
|
||||
)
|
||||
|
||||
sendMove({
|
||||
type: 'START_GAME',
|
||||
@@ -152,6 +168,7 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
|
||||
activePlayers,
|
||||
players,
|
||||
viewerId,
|
||||
roomData,
|
||||
sendMove,
|
||||
state.selectedMap,
|
||||
state.gameMode,
|
||||
@@ -385,6 +402,14 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
|
||||
})
|
||||
}, [viewerId, sendMove, state.currentPlayer, activePlayers])
|
||||
|
||||
// Memoize memberPlayers from roomData for cursor emoji display
|
||||
const memberPlayers = useMemo(() => {
|
||||
return (roomData?.memberPlayers ?? {}) as Record<
|
||||
string,
|
||||
Array<{ id: string; name: string; emoji: string; color: string }>
|
||||
>
|
||||
}, [roomData?.memberPlayers])
|
||||
|
||||
return (
|
||||
<KnowYourWorldContext.Provider
|
||||
value={{
|
||||
@@ -406,6 +431,7 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
|
||||
setContinent,
|
||||
otherPlayerCursors,
|
||||
sendCursorUpdate,
|
||||
memberPlayers,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -47,8 +47,10 @@ describe('KnowYourWorldValidator', () => {
|
||||
guessHistory: [],
|
||||
startTime: Date.now(),
|
||||
activePlayers: ['player-1'],
|
||||
activeUserIds: ['user-1'],
|
||||
playerMetadata: { 'player-1': { name: 'Player 1' } },
|
||||
giveUpReveal: null,
|
||||
giveUpVotes: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
|
||||
@@ -26,9 +26,9 @@ export class KnowYourWorldValidator
|
||||
): Promise<ValidationResult> {
|
||||
switch (move.type) {
|
||||
case 'START_GAME':
|
||||
return await this.validateStartGame(state, move.data)
|
||||
return await this.validateStartGame(state, move.userId, move.data)
|
||||
case 'CLICK_REGION':
|
||||
return this.validateClickRegion(state, move.playerId, move.data)
|
||||
return this.validateClickRegion(state, move.playerId, move.userId, move.data)
|
||||
case 'NEXT_ROUND':
|
||||
return await this.validateNextRound(state)
|
||||
case 'END_GAME':
|
||||
@@ -48,13 +48,17 @@ export class KnowYourWorldValidator
|
||||
case 'SET_CONTINENT':
|
||||
return this.validateSetContinent(state, move.data.selectedContinent)
|
||||
case 'GIVE_UP':
|
||||
return await this.validateGiveUp(state, move.playerId)
|
||||
return await this.validateGiveUp(state, move.playerId, move.userId)
|
||||
default:
|
||||
return { valid: false, error: 'Unknown move type' }
|
||||
}
|
||||
}
|
||||
|
||||
private async validateStartGame(state: KnowYourWorldState, data: any): Promise<ValidationResult> {
|
||||
private async validateStartGame(
|
||||
state: KnowYourWorldState,
|
||||
userId: string,
|
||||
data: any
|
||||
): Promise<ValidationResult> {
|
||||
if (state.gamePhase !== 'setup') {
|
||||
return { valid: false, error: 'Can only start from setup phase' }
|
||||
}
|
||||
@@ -81,10 +85,14 @@ export class KnowYourWorldValidator
|
||||
// Check if we should go to study phase or directly to playing
|
||||
const shouldStudy = state.studyDuration > 0
|
||||
|
||||
// Track the initial userId (session) - other sessions will be added as they make moves
|
||||
const activeUserIds = userId ? [userId] : []
|
||||
|
||||
const newState: KnowYourWorldState = {
|
||||
...state,
|
||||
gamePhase: shouldStudy ? 'studying' : 'playing',
|
||||
activePlayers,
|
||||
activeUserIds,
|
||||
playerMetadata,
|
||||
selectedMap,
|
||||
gameMode,
|
||||
@@ -101,6 +109,7 @@ export class KnowYourWorldValidator
|
||||
guessHistory: [],
|
||||
startTime: Date.now(),
|
||||
giveUpReveal: null,
|
||||
giveUpVotes: [],
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
@@ -109,6 +118,7 @@ export class KnowYourWorldValidator
|
||||
private validateClickRegion(
|
||||
state: KnowYourWorldState,
|
||||
playerId: string,
|
||||
userId: string,
|
||||
data: any
|
||||
): ValidationResult {
|
||||
if (state.gamePhase !== 'playing') {
|
||||
@@ -126,6 +136,9 @@ export class KnowYourWorldValidator
|
||||
return { valid: false, error: 'Not your turn' }
|
||||
}
|
||||
|
||||
// Track this session if not already known
|
||||
const activeUserIds = this.addUserIdIfNew(state.activeUserIds, userId)
|
||||
|
||||
const isCorrect = regionId === state.currentPrompt
|
||||
const guessRecord: GuessRecord = {
|
||||
playerId,
|
||||
@@ -165,6 +178,8 @@ export class KnowYourWorldValidator
|
||||
guessHistory,
|
||||
endTime: Date.now(),
|
||||
giveUpReveal: null,
|
||||
giveUpVotes: [], // Clear votes when game ends
|
||||
activeUserIds,
|
||||
}
|
||||
return { valid: true, newState }
|
||||
}
|
||||
@@ -190,6 +205,8 @@ export class KnowYourWorldValidator
|
||||
scores: newScores,
|
||||
guessHistory,
|
||||
giveUpReveal: null,
|
||||
giveUpVotes: [], // Clear votes when moving to next region
|
||||
activeUserIds,
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
@@ -213,6 +230,7 @@ export class KnowYourWorldValidator
|
||||
attempts: newAttempts,
|
||||
guessHistory,
|
||||
currentPlayer: nextPlayer,
|
||||
activeUserIds,
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -223,6 +241,15 @@ export class KnowYourWorldValidator
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: Add userId to activeUserIds if not already present
|
||||
private addUserIdIfNew(activeUserIds: string[] | undefined, userId: string): string[] {
|
||||
const existing = activeUserIds ?? []
|
||||
if (!userId || existing.includes(userId)) {
|
||||
return existing
|
||||
}
|
||||
return [...existing, userId]
|
||||
}
|
||||
|
||||
private async validateNextRound(state: KnowYourWorldState): Promise<ValidationResult> {
|
||||
if (state.gamePhase !== 'results') {
|
||||
return { valid: false, error: 'Can only start next round from results' }
|
||||
@@ -264,6 +291,7 @@ export class KnowYourWorldValidator
|
||||
startTime: Date.now(),
|
||||
endTime: undefined,
|
||||
giveUpReveal: null,
|
||||
giveUpVotes: [],
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
@@ -407,7 +435,8 @@ export class KnowYourWorldValidator
|
||||
|
||||
private async validateGiveUp(
|
||||
state: KnowYourWorldState,
|
||||
playerId: string
|
||||
playerId: string,
|
||||
userId: string
|
||||
): Promise<ValidationResult> {
|
||||
if (state.gamePhase !== 'playing') {
|
||||
return { valid: false, error: 'Can only give up during playing phase' }
|
||||
@@ -422,6 +451,47 @@ export class KnowYourWorldValidator
|
||||
return { valid: false, error: 'Not your turn' }
|
||||
}
|
||||
|
||||
// Track this session if not already known
|
||||
const activeUserIds = this.addUserIdIfNew(state.activeUserIds, userId)
|
||||
|
||||
// For cooperative mode with multiple sessions: require unanimous vote by session
|
||||
// (All local players on the same session count as one vote since they can discuss together)
|
||||
const isCooperativeMultiplayer = state.gameMode === 'cooperative' && activeUserIds.length > 1
|
||||
|
||||
if (isCooperativeMultiplayer) {
|
||||
// Check if this session has already voted
|
||||
const existingVotes = state.giveUpVotes ?? []
|
||||
if (existingVotes.includes(userId)) {
|
||||
return { valid: false, error: 'Your session has already voted to give up' }
|
||||
}
|
||||
|
||||
// Add this session's vote
|
||||
const newVotes = [...existingVotes, userId]
|
||||
|
||||
// Check if unanimous (all sessions have voted)
|
||||
const isUnanimous = activeUserIds.every((uid) => newVotes.includes(uid))
|
||||
|
||||
if (!isUnanimous) {
|
||||
// Not unanimous yet - just record the vote
|
||||
const newState: KnowYourWorldState = {
|
||||
...state,
|
||||
giveUpVotes: newVotes,
|
||||
activeUserIds,
|
||||
}
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
// Unanimous! Fall through to execute the give up
|
||||
}
|
||||
|
||||
// Execute the actual give up (single session, turn-based, or unanimous cooperative)
|
||||
return this.executeGiveUp(state, activeUserIds)
|
||||
}
|
||||
|
||||
private async executeGiveUp(
|
||||
state: KnowYourWorldState,
|
||||
activeUserIds: string[]
|
||||
): Promise<ValidationResult> {
|
||||
// Get region info for the reveal
|
||||
const mapData = await getFilteredMapDataLazy(
|
||||
state.selectedMap,
|
||||
@@ -476,6 +546,8 @@ export class KnowYourWorldValidator
|
||||
regionName: region.name,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
giveUpVotes: [], // Clear votes after give up is executed
|
||||
activeUserIds,
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
@@ -507,8 +579,10 @@ export class KnowYourWorldValidator
|
||||
guessHistory: [],
|
||||
startTime: 0,
|
||||
activePlayers: [],
|
||||
activeUserIds: [],
|
||||
playerMetadata: {},
|
||||
giveUpReveal: null,
|
||||
giveUpVotes: [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -113,6 +113,7 @@ interface MapRendererProps {
|
||||
name: string
|
||||
emoji: string
|
||||
color: string
|
||||
userId?: string // Session ID that owns this player
|
||||
}
|
||||
>
|
||||
// Give up reveal animation
|
||||
@@ -138,8 +139,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 } | null>
|
||||
otherPlayerCursors?: Record<string, { x: number; y: number; userId: string } | null>
|
||||
onCursorUpdate?: (cursorPosition: { x: number; y: number } | 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")
|
||||
viewerId?: string // This viewer's userId (to check if local session has voted)
|
||||
// Member players mapping (userId -> players) for cursor emoji display
|
||||
memberPlayers?: Record<string, Array<{ id: string; name: string; emoji: string; color: string }>>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -200,6 +207,10 @@ export function MapRenderer({
|
||||
localPlayerId,
|
||||
otherPlayerCursors = {},
|
||||
onCursorUpdate,
|
||||
giveUpVotes = [],
|
||||
activeUserIds = [],
|
||||
viewerId,
|
||||
memberPlayers = {},
|
||||
}: MapRendererProps) {
|
||||
// Extract force tuning parameters with defaults
|
||||
const {
|
||||
@@ -1210,7 +1221,15 @@ export function MapRenderer({
|
||||
return () => {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}, [mapData, regionsFound, guessHistory, svgDimensions, excludedRegions, excludedRegionIds, displayViewBox])
|
||||
}, [
|
||||
mapData,
|
||||
regionsFound,
|
||||
guessHistory,
|
||||
svgDimensions,
|
||||
excludedRegions,
|
||||
excludedRegionIds,
|
||||
displayViewBox,
|
||||
])
|
||||
|
||||
// Calculate viewBox dimensions for label offset calculations and sea background
|
||||
const viewBoxParts = displayViewBox.split(' ').map(Number)
|
||||
@@ -1974,7 +1993,6 @@ export function MapRenderer({
|
||||
opacity={0.8}
|
||||
/>
|
||||
)}
|
||||
|
||||
</animated.svg>
|
||||
|
||||
{/* HTML labels positioned absolutely over the SVG */}
|
||||
@@ -3208,6 +3226,14 @@ export function MapRenderer({
|
||||
const player = playerMetadata[playerId]
|
||||
if (!player) return null
|
||||
|
||||
// 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
|
||||
const cursorUserId = position.userId
|
||||
const sessionPlayers =
|
||||
gameMode === 'cooperative' && cursorUserId && memberPlayers[cursorUserId]
|
||||
? memberPlayers[cursorUserId]
|
||||
: [player]
|
||||
|
||||
// Convert SVG coordinates to screen coordinates
|
||||
const svgRect = svgRef.current!.getBoundingClientRect()
|
||||
const containerRect = containerRef.current!.getBoundingClientRect()
|
||||
@@ -3308,7 +3334,8 @@ export function MapRenderer({
|
||||
{/* Center dot */}
|
||||
<circle cx="12" cy="12" r="2" fill={player.color} />
|
||||
</svg>
|
||||
{/* Player emoji label - positioned below crosshair */}
|
||||
{/* Player emoji label(s) - positioned below crosshair */}
|
||||
{/* In collaborative mode, show all emojis from the same session */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -3317,9 +3344,10 @@ export function MapRenderer({
|
||||
transform: 'translateX(-50%)',
|
||||
fontSize: '16px',
|
||||
textShadow: '0 1px 2px rgba(0,0,0,0.5)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{player.emoji}
|
||||
{sessionPlayers.map((p) => p.emoji).join('')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -3418,11 +3446,79 @@ export function MapRenderer({
|
||||
},
|
||||
})}
|
||||
>
|
||||
Give Up (G)
|
||||
{(() => {
|
||||
// Determine button text based on game mode and voting state
|
||||
// Voting is per-session (userId), not per-player
|
||||
const isCooperativeMultiplayer =
|
||||
gameMode === 'cooperative' && activeUserIds.length > 1
|
||||
const hasLocalSessionVoted = viewerId && giveUpVotes.includes(viewerId)
|
||||
const voteCount = giveUpVotes.length
|
||||
const totalSessions = activeUserIds.length
|
||||
|
||||
if (isCooperativeMultiplayer) {
|
||||
if (hasLocalSessionVoted) {
|
||||
return (
|
||||
<span>
|
||||
✓ Voted ({voteCount}/{totalSessions})
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (voteCount > 0) {
|
||||
return (
|
||||
<span>
|
||||
Give Up ({voteCount}/{totalSessions}) (G)
|
||||
</span>
|
||||
)
|
||||
}
|
||||
}
|
||||
return 'Give Up (G)'
|
||||
})()}
|
||||
</button>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Show waiting message for give up voting (cooperative multiplayer with multiple sessions) */}
|
||||
{gameMode === 'cooperative' &&
|
||||
activeUserIds.length > 1 &&
|
||||
giveUpVotes.length > 0 &&
|
||||
giveUpVotes.length < activeUserIds.length &&
|
||||
viewerId &&
|
||||
giveUpVotes.includes(viewerId) &&
|
||||
(() => {
|
||||
if (!svgRef.current || !containerRef.current || svgDimensions.width === 0) return null
|
||||
|
||||
const svgRect = svgRef.current.getBoundingClientRect()
|
||||
const containerRect = containerRef.current.getBoundingClientRect()
|
||||
const svgOffsetY = svgRect.top - containerRect.top
|
||||
const buttonRight =
|
||||
containerRect.width - (svgRect.left - containerRect.left + svgRect.width) + 8
|
||||
|
||||
const remaining = activeUserIds.length - giveUpVotes.length
|
||||
|
||||
return (
|
||||
<div
|
||||
data-element="give-up-voters"
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
fontSize: 'xs',
|
||||
color: isDark ? 'yellow.300' : 'yellow.700',
|
||||
bg: isDark ? 'gray.800/90' : 'white/90',
|
||||
padding: '1 2',
|
||||
rounded: 'md',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'yellow.600' : 'yellow.400',
|
||||
zIndex: 49,
|
||||
})}
|
||||
style={{
|
||||
top: `${svgOffsetY + 44}px`, // Below the Give Up button
|
||||
right: `${buttonRight}px`,
|
||||
}}
|
||||
>
|
||||
Waiting for {remaining} other {remaining === 1 ? 'player' : 'players'}...
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Dev-only crop tool for getting custom viewBox coordinates */}
|
||||
<DevCropTool
|
||||
svgRef={svgRef}
|
||||
|
||||
@@ -11,7 +11,8 @@ import { useViewerId } from '@/lib/arcade/game-sdk'
|
||||
import { useGameMode } from '@/lib/arcade/game-sdk'
|
||||
|
||||
export function PlayingPhase() {
|
||||
const { state, clickRegion, giveUp, otherPlayerCursors, sendCursorUpdate } = useKnowYourWorld()
|
||||
const { state, clickRegion, giveUp, otherPlayerCursors, sendCursorUpdate, memberPlayers } =
|
||||
useKnowYourWorld()
|
||||
const { data: viewerId } = useViewerId()
|
||||
const { activePlayers, players } = useGameMode()
|
||||
|
||||
@@ -28,12 +29,14 @@ export function PlayingPhase() {
|
||||
return Array.from(activePlayers)[0] || ''
|
||||
}, [activePlayers, players])
|
||||
|
||||
// Wrap sendCursorUpdate to include localPlayerId
|
||||
// Wrap sendCursorUpdate to include localPlayerId and viewerId (session ID)
|
||||
const handleCursorUpdate = useCallback(
|
||||
(cursorPosition: { x: number; y: number } | null) => {
|
||||
sendCursorUpdate(localPlayerId, cursorPosition)
|
||||
if (viewerId) {
|
||||
sendCursorUpdate(localPlayerId, viewerId, cursorPosition)
|
||||
}
|
||||
},
|
||||
[localPlayerId, sendCursorUpdate]
|
||||
[localPlayerId, viewerId, sendCursorUpdate]
|
||||
)
|
||||
|
||||
const mapData = getFilteredMapDataSync(
|
||||
@@ -145,6 +148,10 @@ export function PlayingPhase() {
|
||||
localPlayerId={localPlayerId}
|
||||
otherPlayerCursors={otherPlayerCursors}
|
||||
onCursorUpdate={handleCursorUpdate}
|
||||
giveUpVotes={state.giveUpVotes}
|
||||
activeUserIds={state.activeUserIds}
|
||||
viewerId={viewerId ?? undefined}
|
||||
memberPlayers={memberPlayers}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
@@ -17,7 +17,7 @@ export const customCrops: CropOverrides = {
|
||||
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',
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -72,6 +72,7 @@ export interface KnowYourWorldState extends GameState {
|
||||
|
||||
// Multiplayer
|
||||
activePlayers: string[]
|
||||
activeUserIds: string[] // Unique session/viewer IDs participating in the game
|
||||
playerMetadata: Record<string, any>
|
||||
|
||||
// Give up reveal state (for animation)
|
||||
@@ -80,6 +81,9 @@ export interface KnowYourWorldState extends GameState {
|
||||
regionName: string
|
||||
timestamp: number // For animation timing key
|
||||
} | null
|
||||
|
||||
// Unanimous give-up voting (for cooperative multiplayer)
|
||||
giveUpVotes: string[] // Session/viewer IDs (userIds) who have voted to give up on current prompt
|
||||
}
|
||||
|
||||
// Move types
|
||||
|
||||
@@ -81,17 +81,19 @@ export interface UseArcadeSessionReturn<TState> {
|
||||
|
||||
/**
|
||||
* Other players' cursor positions (ephemeral, real-time)
|
||||
* Map of playerId -> { x, y } in SVG coordinates, or null if cursor left
|
||||
* Map of playerId -> { x, y, userId } in SVG coordinates, or null if cursor left
|
||||
*/
|
||||
otherPlayerCursors: Record<string, { x: number; y: number } | null>
|
||||
otherPlayerCursors: Record<string, { x: number; y: number; userId: string } | 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
|
||||
*/
|
||||
sendCursorUpdate: (
|
||||
playerId: string,
|
||||
userId: string,
|
||||
cursorPosition: { x: number; y: number } | null
|
||||
) => void
|
||||
}
|
||||
@@ -169,7 +171,7 @@ export function useArcadeSession<TState>(
|
||||
|
||||
// Track other players' cursor positions (ephemeral, real-time)
|
||||
const [otherPlayerCursors, setOtherPlayerCursors] = useState<
|
||||
Record<string, { x: number; y: number } | null>
|
||||
Record<string, { x: number; y: number; userId: string } | null>
|
||||
>({})
|
||||
|
||||
// WebSocket connection
|
||||
@@ -273,7 +275,9 @@ export function useArcadeSession<TState>(
|
||||
onCursorUpdate: (data) => {
|
||||
setOtherPlayerCursors((prev) => ({
|
||||
...prev,
|
||||
[data.playerId]: data.cursorPosition,
|
||||
[data.playerId]: data.cursorPosition
|
||||
? { ...data.cursorPosition, userId: data.userId }
|
||||
: null,
|
||||
}))
|
||||
},
|
||||
})
|
||||
@@ -321,9 +325,9 @@ export function useArcadeSession<TState>(
|
||||
|
||||
// Send cursor position update to other players (ephemeral, real-time)
|
||||
const sendCursorUpdate = useCallback(
|
||||
(playerId: string, cursorPosition: { x: number; y: number } | null) => {
|
||||
(playerId: string, sessionUserId: string, cursorPosition: { x: number; y: number } | null) => {
|
||||
if (!roomId) return // Only works in room-based games
|
||||
socketSendCursorUpdate(roomId, playerId, cursorPosition)
|
||||
socketSendCursorUpdate(roomId, playerId, sessionUserId, cursorPosition)
|
||||
},
|
||||
[roomId, socketSendCursorUpdate]
|
||||
)
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface ArcadeSocketEvents {
|
||||
/** Cursor position update from another player (ephemeral, real-time) */
|
||||
onCursorUpdate?: (data: {
|
||||
playerId: string
|
||||
userId: string // Session ID that owns this cursor
|
||||
cursorPosition: { x: number; y: number } | null
|
||||
}) => void
|
||||
/** If true, errors will NOT show toasts (for cases where game handles errors directly) */
|
||||
@@ -36,6 +37,7 @@ export interface UseArcadeSocketReturn {
|
||||
sendCursorUpdate: (
|
||||
roomId: string,
|
||||
playerId: string,
|
||||
userId: string,
|
||||
cursorPosition: { x: number; y: number } | null
|
||||
) => void
|
||||
}
|
||||
@@ -215,9 +217,14 @@ export function useArcadeSocket(events: ArcadeSocketEvents = {}): UseArcadeSocke
|
||||
)
|
||||
|
||||
const sendCursorUpdate = useCallback(
|
||||
(roomId: string, playerId: string, cursorPosition: { x: number; y: number } | null) => {
|
||||
(
|
||||
roomId: string,
|
||||
playerId: string,
|
||||
userId: string,
|
||||
cursorPosition: { x: number; y: number } | null
|
||||
) => {
|
||||
if (!socket) return
|
||||
socket.emit('cursor-update', { roomId, playerId, cursorPosition })
|
||||
socket.emit('cursor-update', { roomId, playerId, userId, cursorPosition })
|
||||
},
|
||||
[socket]
|
||||
)
|
||||
|
||||
@@ -700,15 +700,18 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
({
|
||||
roomId,
|
||||
playerId,
|
||||
userId,
|
||||
cursorPosition,
|
||||
}: {
|
||||
roomId: string
|
||||
playerId: string
|
||||
userId: string // Session ID that owns this cursor
|
||||
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,
|
||||
userId,
|
||||
cursorPosition,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user