refactor: simplify pointer lock with movement delta multipliers

Major refactoring of precision controls for Know Your World game:

**New Flow:**
- Pointer lock requested once when PlayingPhase mounts (user click)
- Active for entire game session until unmount
- Simpler: use movementX/movementY with adaptive multiplier

**PlayingPhase.tsx:**
- Add pointer lock management with event listeners
- Show "Enable Precision Controls" overlay on mount
- Request pointer lock on first click (user gesture)
- Release on unmount (game ends)
- Pass pointerLocked prop to MapRenderer

**MapRenderer.tsx:**
- Accept pointerLocked as prop (don't manage internally)
- Remove complex cursor dampening (dual refs: raw vs dampened)
- Simplified: apply multiplier to movementX/movementY based on region size
  - Sub-pixel regions (<1px): 3% speed
  - Tiny regions (1-5px): 10% speed
  - Small regions (5-15px): 25% speed
  - Larger regions: 100% speed
- Remove precision mode state (not needed)
- Remove cooldown logic (not needed)
- Remove click capture handler (not needed)
- Update cursor style and mouse handlers to use pointerLocked

**Benefits:**
- Much simpler code (removed ~100 lines of complexity)
- No lag when changing direction (no interpolation)
- Pointer lock active entire session (can't drift off map)
- Movement multiplier clearer than dampening

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-11-19 08:58:20 -06:00
parent 5e42aabfa9
commit 749b16ac27
3 changed files with 145 additions and 167 deletions

View File

@@ -100,9 +100,12 @@ const Template = (args: any) => {
regionsFound={regionsFound}
currentPrompt={mapData.regions[5]?.id || null}
difficulty={args.difficulty}
selectedMap="world"
selectedContinent={args.continent}
onRegionClick={(id, name) => console.log('Clicked:', id, name)}
guessHistory={guessHistory}
playerMetadata={mockPlayerMetadata}
pointerLocked={false}
forceTuning={{
showArrows: args.showArrows,
centeringStrength: args.centeringStrength,

View File

@@ -48,6 +48,7 @@ interface MapRendererProps {
color: string
}
>
pointerLocked: boolean // Whether pointer lock is currently active
// Force simulation tuning parameters
forceTuning?: {
showArrows?: boolean
@@ -108,6 +109,7 @@ export function MapRenderer({
onRegionClick,
guessHistory,
playerMetadata,
pointerLocked,
forceTuning = {},
}: MapRendererProps) {
// Extract force tuning parameters with defaults
@@ -163,86 +165,30 @@ export function MapRenderer({
const [targetTop, setTargetTop] = useState(20)
const [targetLeft, setTargetLeft] = useState(20)
// Precision mode: automatic cursor dampening when over small regions
const lastRawCursorRef = useRef<{ x: number; y: number } | null>(null) // Raw mouse position
const dampenedCursorRef = useRef<{ x: number; y: number } | null>(null) // Dampened position
// Cursor position tracking (container-relative coordinates)
const cursorPositionRef = useRef<{ x: number; y: number } | null>(null)
const lastMoveTimeRef = useRef<number>(Date.now())
const hoverTimerRef = useRef<NodeJS.Timeout | null>(null)
const precisionModeCooldownRef = useRef<number>(0) // Timestamp when cooldown expires
const [precisionMode, setPrecisionMode] = useState(false)
const [superZoomActive, setSuperZoomActive] = useState(false)
const [smallestRegionSize, setSmallestRegionSize] = useState<number>(Infinity)
const [pointerLocked, setPointerLocked] = useState(false)
// Configuration
const HOVER_DELAY_MS = 500 // Time to hover before super zoom activates
const QUICK_MOVE_THRESHOLD = 15 // Pixels per frame - exceeding this cancels dampening/zoom
const PRECISION_MODE_COOLDOWN_MS = 1200 // Cooldown after quick-escape before precision can re-activate
const QUICK_MOVE_THRESHOLD = 50 // Pixels per frame - exceeding this cancels super zoom
const SUPER_ZOOM_MULTIPLIER = 2.5 // Super zoom is 2.5x the normal adaptive zoom
// Adaptive dampening based on smallest region size
// Movement speed multiplier based on smallest region size
// When pointer lock is active, apply this multiplier to movementX/movementY
// For sub-pixel regions (< 1px): 3% speed (ultra precision)
// For tiny regions (1-5px): 10% speed (high precision)
// For small regions (5-15px): 25% speed (moderate precision)
const getDampeningFactor = (size: number): number => {
const getMovementMultiplier = (size: number): number => {
if (size < 1) return 0.03 // Ultra precision for sub-pixel regions like Gibraltar (0.08px)
if (size < 5) return 0.1 // High precision for regions like Jersey (0.82px)
return 0.25 // Moderate precision for regions like Rhode Island (11px)
if (size < 15) return 0.25 // Moderate precision for regions like Rhode Island (11px)
return 1.0 // Normal speed for larger regions
}
// Pointer lock management for precision mode
useEffect(() => {
if (!containerRef.current) return
const handlePointerLockChange = () => {
const isLocked = document.pointerLockElement === containerRef.current
setPointerLocked(isLocked)
console.log('[Pointer Lock]', isLocked ? '🔒 LOCKED' : '🔓 UNLOCKED')
}
const handlePointerLockError = () => {
console.error('[Pointer Lock] ❌ Failed to acquire pointer lock')
setPointerLocked(false)
}
document.addEventListener('pointerlockchange', handlePointerLockChange)
document.addEventListener('pointerlockerror', handlePointerLockError)
return () => {
document.removeEventListener('pointerlockchange', handlePointerLockChange)
document.removeEventListener('pointerlockerror', handlePointerLockError)
}
}, [])
// Request/release pointer lock based on precision mode
useEffect(() => {
console.log('[Pointer Lock] Effect triggered:', {
precisionMode,
pointerLocked,
hasContainer: !!containerRef.current,
willRequest: precisionMode && !pointerLocked,
willRelease: !precisionMode && pointerLocked,
})
if (!containerRef.current) {
console.warn('[Pointer Lock] No container ref!')
return
}
if (precisionMode && !pointerLocked) {
console.log('[Pointer Lock] 🔒 REQUESTING pointer lock for precision mode')
try {
containerRef.current.requestPointerLock()
console.log('[Pointer Lock] Request sent successfully')
} catch (error) {
console.error('[Pointer Lock] Request failed:', error)
}
} else if (!precisionMode && pointerLocked) {
console.log('[Pointer Lock] 🔓 RELEASING pointer lock')
document.exitPointerLock()
}
}, [precisionMode, pointerLocked])
// Animated spring values for smooth transitions
// Different fade speeds: fast fade-in (100ms), slow fade-out (1000ms)
// Position animates with medium speed (300ms)
@@ -681,23 +627,18 @@ export function MapRenderer({
let cursorY: number
if (pointerLocked) {
// When pointer is locked, use movement deltas to update position
// This prevents cursor from leaving the container
const lastX = lastRawCursorRef.current?.x ?? containerRect.width / 2
const lastY = lastRawCursorRef.current?.y ?? containerRect.height / 2
// When pointer is locked, use movement deltas with precision multiplier
const lastX = cursorPositionRef.current?.x ?? containerRect.width / 2
const lastY = cursorPositionRef.current?.y ?? containerRect.height / 2
cursorX = lastX + e.movementX
cursorY = lastY + e.movementY
// Apply movement multiplier based on smallest region size for precision control
const movementMultiplier = getMovementMultiplier(smallestRegionSize)
cursorX = lastX + e.movementX * movementMultiplier
cursorY = lastY + e.movementY * movementMultiplier
// Clamp to container bounds
cursorX = Math.max(0, Math.min(containerRect.width, cursorX))
cursorY = Math.max(0, Math.min(containerRect.height, cursorY))
console.log('[Pointer Lock] Movement:', {
movementX: e.movementX,
movementY: e.movementY,
newPos: { x: cursorX.toFixed(1), y: cursorY.toFixed(1) },
})
} else {
// Normal mode: use absolute position
cursorX = e.clientX - containerRect.left
@@ -705,17 +646,11 @@ export function MapRenderer({
}
// Check if cursor is over the SVG
const isOverSvg = pointerLocked
? // When pointer is locked, check if our calculated position is within SVG bounds
cursorX >= svgRect.left - containerRect.left &&
cursorX <= svgRect.right - containerRect.left &&
cursorY >= svgRect.top - containerRect.top &&
cursorY <= svgRect.bottom - containerRect.top
: // Normal mode: use real mouse position
e.clientX >= svgRect.left &&
e.clientX <= svgRect.right &&
e.clientY >= svgRect.top &&
e.clientY <= svgRect.bottom
const isOverSvg =
cursorX >= svgRect.left - containerRect.left &&
cursorX <= svgRect.right - containerRect.left &&
cursorY >= svgRect.top - containerRect.top &&
cursorY <= svgRect.bottom - containerRect.top
// Don't hide magnifier if mouse is still in container (just moved to padding/magnifier area)
// Only update cursor position and check for regions if over SVG
@@ -725,26 +660,23 @@ export function MapRenderer({
return
}
// Calculate mouse velocity for quick-escape detection
// Calculate mouse velocity for quick-escape detection (cancel super zoom on fast movement)
const now = Date.now()
const timeDelta = now - lastMoveTimeRef.current
let velocity = 0
if (lastRawCursorRef.current && timeDelta > 0) {
const deltaX = cursorX - lastRawCursorRef.current.x
const deltaY = cursorY - lastRawCursorRef.current.y
if (cursorPositionRef.current && timeDelta > 0) {
const deltaX = cursorX - cursorPositionRef.current.x
const deltaY = cursorY - cursorPositionRef.current.y
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY)
velocity = distance // Distance in pixels (effectively pixels per frame)
}
// Quick escape: If moving fast, cancel dampening and super zoom
// Quick escape: If moving fast, cancel super zoom
if (velocity > QUICK_MOVE_THRESHOLD) {
const cooldownUntil = now + PRECISION_MODE_COOLDOWN_MS
precisionModeCooldownRef.current = cooldownUntil
console.log(
`[Quick Escape] 💨 TRIGGERED! Fast movement detected (${velocity.toFixed(0)}px > ${QUICK_MOVE_THRESHOLD}px threshold) - canceling precision mode and super zoom (cooldown until ${new Date(cooldownUntil).toLocaleTimeString()})`
`[Quick Escape] 💨 Fast movement detected (${velocity.toFixed(0)}px > ${QUICK_MOVE_THRESHOLD}px) - canceling super zoom`
)
setPrecisionMode(false)
setSuperZoomActive(false)
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current)
@@ -754,42 +686,17 @@ export function MapRenderer({
lastMoveTimeRef.current = now
// Apply cursor dampening in precision mode for better control over small regions
let finalCursorX = cursorX
let finalCursorY = cursorY
// Calculate adaptive dampening factor based on smallest region
const dampeningFactor = getDampeningFactor(smallestRegionSize)
if (precisionMode && lastRawCursorRef.current && dampenedCursorRef.current) {
// Calculate delta from LAST RAW to CURRENT RAW (true velocity/direction)
const deltaX = cursorX - lastRawCursorRef.current.x
const deltaY = cursorY - lastRawCursorRef.current.y
// Apply dampening to the delta and add to last DAMPENED position
// This ensures instant direction changes without lag
finalCursorX = dampenedCursorRef.current.x + deltaX * dampeningFactor
finalCursorY = dampenedCursorRef.current.y + deltaY * dampeningFactor
} else if (precisionMode) {
// First frame of precision mode - initialize dampened cursor at raw position
finalCursorX = cursorX
finalCursorY = cursorY
}
// Update both cursor refs for next frame
lastRawCursorRef.current = { x: cursorX, y: cursorY }
dampenedCursorRef.current = { x: finalCursorX, y: finalCursorY }
setCursorPosition({ x: finalCursorX, y: finalCursorY })
// Update cursor position ref for next frame
cursorPositionRef.current = { x: cursorX, y: cursorY }
setCursorPosition({ x: cursorX, y: cursorY })
// Define 50px × 50px detection box around cursor
const detectionBoxSize = 50
const halfBox = detectionBoxSize / 2
// Convert dampened cursor position back to client coordinates for region detection
// This ensures the detection box matches the crosshairs in the magnifier
const finalClientX = containerRect.left + finalCursorX
const finalClientY = containerRect.top + finalCursorY
// Convert cursor position to client coordinates for region detection
const cursorClientX = containerRect.left + cursorX
const cursorClientY = containerRect.top + cursorY
// Count regions in the detection box and track their sizes
let regionsInBox = 0
@@ -806,11 +713,11 @@ export function MapRenderer({
const pathRect = regionPath.getBoundingClientRect()
// Check if region overlaps with detection box (using dampened cursor position)
const boxLeft = finalClientX - halfBox
const boxRight = finalClientX + halfBox
const boxTop = finalClientY - halfBox
const boxBottom = finalClientY + halfBox
// Check if region overlaps with detection box
const boxLeft = cursorClientX - halfBox
const boxRight = cursorClientX + halfBox
const boxTop = cursorClientY - halfBox
const boxBottom = cursorClientY + halfBox
const regionLeft = pathRect.left
const regionRight = pathRect.right
@@ -823,19 +730,19 @@ export function MapRenderer({
regionTop < boxBottom &&
regionBottom > boxTop
// Also check if dampened cursor is directly over this region
// Also check if cursor is directly over this region
const cursorInRegion =
finalClientX >= regionLeft &&
finalClientX <= regionRight &&
finalClientY >= regionTop &&
finalClientY <= regionBottom
cursorClientX >= regionLeft &&
cursorClientX <= regionRight &&
cursorClientY >= regionTop &&
cursorClientY <= regionBottom
if (cursorInRegion) {
// Calculate distance from cursor to region center to find the "best" match
const regionCenterX = (regionLeft + regionRight) / 2
const regionCenterY = (regionTop + regionBottom) / 2
const distanceToCenter = Math.sqrt(
(finalClientX - regionCenterX) ** 2 + (finalClientY - regionCenterY) ** 2
(cursorClientX - regionCenterX) ** 2 + (cursorClientY - regionCenterY) ** 2
)
if (distanceToCenter < smallestDistanceToCenter) {
@@ -884,27 +791,12 @@ export function MapRenderer({
setSmallestRegionSize(Infinity)
}
// Set hover highlighting based on dampened cursor position
// Set hover highlighting based on cursor position
// This ensures the crosshairs match what's highlighted
if (regionUnderCursor !== hoveredRegion) {
setHoveredRegion(regionUnderCursor)
}
// Enable precision mode (cursor dampening) when magnifier is needed
// This gives users more control over tiny regions like Jersey
// Respect cooldown period after quick-escape to let user escape the area
const cooldownActive = now < precisionModeCooldownRef.current
const cooldownTimeRemaining = Math.max(0, precisionModeCooldownRef.current - now)
const shouldEnablePrecisionMode = shouldShow && !cooldownActive
// Log precision mode state changes
if (shouldEnablePrecisionMode !== precisionMode) {
console.log(
`[Precision Mode] ⚡ CHANGING STATE: ${shouldEnablePrecisionMode ? '🎯 ENABLING' : '❌ DISABLING'} precision mode | Smallest region: ${detectedSmallestSize.toFixed(2)}px${cooldownActive ? ' (COOLDOWN ACTIVE - ' + cooldownTimeRemaining.toFixed(0) + 'ms remaining)' : ''} | Pointer locked: ${pointerLocked}`
)
}
setPrecisionMode(shouldEnablePrecisionMode)
// Auto super-zoom on hover: If hovering over sub-pixel regions (< 1px), start timer
const shouldEnableSuperZoom = detectedSmallestSize < 1 && shouldShow
if (shouldEnableSuperZoom && !hoverTimerRef.current && !superZoomActive) {
@@ -934,8 +826,9 @@ export function MapRenderer({
hasSmallRegion,
smallestRegionSize: detectedSmallestSize.toFixed(2) + 'px',
shouldShow,
precisionMode: shouldShow,
cursorPos: { x: e.clientX, y: e.clientY },
pointerLocked,
movementMultiplier: getMovementMultiplier(detectedSmallestSize).toFixed(2),
cursorPos: { x: cursorX.toFixed(1), y: cursorY.toFixed(1) },
})
if (shouldShow) {
@@ -992,8 +885,8 @@ export function MapRenderer({
}
const handleMouseLeave = () => {
// Don't hide magnifier when pointer is locked (precision mode active)
// The real cursor may leave the container, but we're tracking movement deltas
// Don't hide magnifier when pointer is locked
// The cursor is locked to the container, so mouse leave events are not meaningful
if (pointerLocked) {
console.log('[Mouse Leave] Ignoring - pointer is locked')
return
@@ -1002,11 +895,8 @@ export function MapRenderer({
setShowMagnifier(false)
setTargetOpacity(0)
setCursorPosition(null)
setPrecisionMode(false)
setSuperZoomActive(false)
lastRawCursorRef.current = null
dampenedCursorRef.current = null
precisionModeCooldownRef.current = 0
cursorPositionRef.current = null
// Clear hover timer if active
if (hoverTimerRef.current) {
@@ -1038,7 +928,7 @@ export function MapRenderer({
className={css({
width: '100%',
height: 'auto',
cursor: precisionMode ? 'crosshair' : 'pointer',
cursor: pointerLocked ? 'crosshair' : 'pointer',
})}
>
{/* Background */}
@@ -1070,10 +960,10 @@ export function MapRenderer({
strokeWidth={1}
vectorEffect="non-scaling-stroke"
opacity={showOutline(region) ? 1 : 0.7} // Increased from 0.3 to 0.7 for better visibility
// When precision mode is active, hover is controlled by dampened cursor position
// When pointer lock is active, hover is controlled by cursor position tracking
// Otherwise, use native mouse events
onMouseEnter={() => !isExcluded && !precisionMode && setHoveredRegion(region.id)}
onMouseLeave={() => !precisionMode && setHoveredRegion(null)}
onMouseEnter={() => !isExcluded && !pointerLocked && setHoveredRegion(region.id)}
onMouseLeave={() => !pointerLocked && setHoveredRegion(null)}
onClick={() => !isExcluded && onRegionClick(region.id, region.name)} // Disable clicks on excluded regions
style={{
cursor: isExcluded ? 'default' : 'pointer',

View File

@@ -1,6 +1,6 @@
'use client'
import { useEffect } from 'react'
import { useEffect, useState, useRef } from 'react'
import { css } from '@styled/css'
import { useTheme } from '@/contexts/ThemeContext'
import { useKnowYourWorld } from '../Provider'
@@ -12,6 +12,10 @@ export function PlayingPhase() {
const isDark = resolvedTheme === 'dark'
const { state, clickRegion, lastError, clearError } = useKnowYourWorld()
const [pointerLocked, setPointerLocked] = useState(false)
const [showLockPrompt, setShowLockPrompt] = useState(true)
const containerRef = useRef<HTMLDivElement>(null)
const mapData = getFilteredMapData(state.selectedMap, state.selectedContinent, state.difficulty)
const totalRegions = mapData.regions.length
const foundCount = state.regionsFound.length
@@ -25,6 +29,48 @@ export function PlayingPhase() {
}
}, [lastError, clearError])
// Set up pointer lock event listeners
useEffect(() => {
const handlePointerLockChange = () => {
const isLocked = document.pointerLockElement === containerRef.current
setPointerLocked(isLocked)
console.log('[Pointer Lock]', isLocked ? '🔒 LOCKED' : '🔓 UNLOCKED')
}
const handlePointerLockError = () => {
console.error('[Pointer Lock] ❌ Failed to acquire pointer lock')
setPointerLocked(false)
setShowLockPrompt(true) // Show prompt again if lock fails
}
document.addEventListener('pointerlockchange', handlePointerLockChange)
document.addEventListener('pointerlockerror', handlePointerLockError)
return () => {
document.removeEventListener('pointerlockchange', handlePointerLockChange)
document.removeEventListener('pointerlockerror', handlePointerLockError)
}
}, [])
// Release pointer lock when component unmounts (game ends)
useEffect(() => {
return () => {
if (document.pointerLockElement) {
console.log('[Pointer Lock] 🔓 RELEASING (PlayingPhase unmount)')
document.exitPointerLock()
}
}
}, [])
// Request pointer lock on first click
const handleContainerClick = () => {
if (!pointerLocked && containerRef.current && showLockPrompt) {
console.log('[Pointer Lock] 🔒 REQUESTING pointer lock (user clicked)')
containerRef.current.requestPointerLock()
setShowLockPrompt(false) // Hide prompt after first click
}
}
// Get the display name for the current prompt
const currentRegionName = state.currentPrompt
? mapData.regions.find((r) => r.id === state.currentPrompt)?.name
@@ -43,7 +89,9 @@ export function PlayingPhase() {
return (
<div
ref={containerRef}
data-component="playing-phase"
onClick={handleContainerClick}
className={css({
display: 'flex',
flexDirection: 'column',
@@ -53,8 +101,44 @@ export function PlayingPhase() {
paddingBottom: '4',
maxWidth: '1200px',
margin: '0 auto',
position: 'relative',
})}
>
{/* Pointer Lock Prompt Overlay */}
{showLockPrompt && !pointerLocked && (
<div
data-element="pointer-lock-prompt"
className={css({
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
bg: isDark ? 'rgba(0, 0, 0, 0.9)' : 'rgba(255, 255, 255, 0.95)',
color: isDark ? 'white' : 'gray.900',
padding: '8',
rounded: 'xl',
border: '3px solid',
borderColor: 'blue.500',
boxShadow: '0 10px 40px rgba(0, 0, 0, 0.5)',
zIndex: 1000,
textAlign: 'center',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
transform: 'translate(-50%, -50%) scale(1.05)',
borderColor: 'blue.400',
},
})}
>
<div className={css({ fontSize: '4xl', marginBottom: '4' })}>🎯</div>
<div className={css({ fontSize: 'xl', fontWeight: 'bold', marginBottom: '2' })}>
Enable Precision Controls
</div>
<div className={css({ fontSize: 'sm', color: isDark ? 'gray.400' : 'gray.600' })}>
Click anywhere to lock cursor and enable precise clicking on tiny regions
</div>
</div>
)}
{/* Current Prompt */}
<div
data-section="current-prompt"
@@ -173,6 +257,7 @@ export function PlayingPhase() {
onRegionClick={clickRegion}
guessHistory={state.guessHistory}
playerMetadata={state.playerMetadata}
pointerLocked={pointerLocked}
/>
{/* Game Mode Info */}