fix(know-your-world): fix pointer lock escape for all edges and add smooth release animation

**Problem:**
- Left/right edge squish-through escape wasn't working when SVG didn't fill container width
- Cursor would jump when releasing pointer lock (managed cursor position ≠ real cursor position)

**Root Cause:**
- Boundary detection used container rect instead of SVG rect
- SVG may be smaller than container due to aspect ratio constraints
- No animation back to initial capture position before releasing pointer lock

**Solution:**

1. **Use SVG boundaries for edge detection:**
   - Calculate SVG offset within container (lines 796-797)
   - Measure distances from SVG edges, not container edges (lines 806-809)
   - Use SVG bounds for dampened distance checks (lines 831-834)
   - Clamp cursor to SVG bounds (lines 914-915)
   - Added debug logging showing SVG size/offset (lines 842-843)

2. **Smooth release animation:**
   - Store initial cursor position when pointer lock acquired (line 185, 244-246)
   - Track release animation state (line 192)
   - Animate cursor back to capture position before releasing (lines 890-921)
   - 200ms cubic ease-out interpolation
   - Block mouse input during animation (lines 773-774)
   - Real cursor appears at same position where user clicked - no jump!

**Result:**
- Squish-through escape works on all four edges regardless of map aspect ratio
- Seamless transition when releasing pointer lock
- Cursor smoothly returns to original position before handoff to real cursor

🤖 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-22 21:43:26 -06:00
parent 1729418dc5
commit a7fa858a29
1 changed files with 145 additions and 49 deletions

View File

@ -182,11 +182,15 @@ export function MapRenderer({
// Cursor position tracking (container-relative coordinates)
const cursorPositionRef = useRef<{ x: number; y: number } | null>(null)
const initialCapturePositionRef = useRef<{ x: number; y: number } | null>(null)
const [smallestRegionSize, setSmallestRegionSize] = useState<number>(Infinity)
// Cursor distortion at boundaries (for squish effect)
const [cursorSquish, setCursorSquish] = useState({ x: 1, y: 1 }) // Scale factors
// Track if we're animating back to release position
const [isReleasingPointerLock, setIsReleasingPointerLock] = useState(false)
// Debug: Track bounding boxes for visualization
const [debugBoundingBoxes, setDebugBoundingBoxes] = useState<
Array<{ regionId: string; x: number; y: number; width: number; height: number }>
@ -224,9 +228,17 @@ export function MapRenderer({
})
setPointerLocked(isLocked)
// When acquiring pointer lock, save the initial cursor position
if (isLocked && cursorPositionRef.current) {
initialCapturePositionRef.current = { ...cursorPositionRef.current }
console.log('[Pointer Lock] 📍 Saved initial capture position:', initialCapturePositionRef.current)
}
// Reset cursor squish when lock state changes
if (!isLocked) {
setCursorSquish({ x: 1, y: 1 })
setIsReleasingPointerLock(false)
initialCapturePositionRef.current = null
}
}
@ -758,6 +770,9 @@ export function MapRenderer({
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
if (!svgRef.current || !containerRef.current) return
// Don't process mouse movement during pointer lock release animation
if (isReleasingPointerLock) return
const containerRect = containerRef.current.getBoundingClientRect()
const svgRect = svgRef.current.getBoundingClientRect()
@ -773,8 +788,6 @@ export function MapRenderer({
// Apply smoothly animated movement multiplier for gradual cursor dampening transitions
// This prevents jarring changes when moving between regions of different sizes
const currentMultiplier = magnifierSpring.movementMultiplier.get()
const newX = lastX + e.movementX * currentMultiplier
const newY = lastY + e.movementY * currentMultiplier
// Boundary dampening and squish effect
// As cursor approaches edge, dampen movement and visually squish the cursor
@ -783,32 +796,25 @@ export function MapRenderer({
const squishZone = 20 // Distance from edge where squish becomes visible (px)
const escapeThreshold = 2 // When within this distance, escape! (px)
// Calculate distance from each edge
const distLeft = newX
const distRight = containerRect.width - newX
const distTop = newY
const distBottom = containerRect.height - newY
// Calculate SVG offset within container (SVG may be smaller due to aspect ratio)
const svgOffsetX = svgRect.left - containerRect.left
const svgOffsetY = svgRect.top - containerRect.top
// First, calculate undampened position to check how close we are to edges
const undampenedX = lastX + e.movementX * currentMultiplier
const undampenedY = lastY + e.movementY * currentMultiplier
// Calculate distance from SVG edges (not container edges!)
// This is critical - the interactive area is the SVG, not the container
const distLeft = undampenedX - svgOffsetX
const distRight = svgOffsetX + svgRect.width - undampenedX
const distTop = undampenedY - svgOffsetY
const distBottom = svgOffsetY + svgRect.height - undampenedY
// Find closest edge distance
const minDist = Math.min(distLeft, distRight, distTop, distBottom)
// Check if cursor has squished through and should escape
if (minDist < escapeThreshold) {
console.log('[Pointer Lock] 🔓 ESCAPING (squished through boundary):', {
minDist,
escapeThreshold,
cursorX: newX,
cursorY: newY,
})
// Release pointer lock - cursor has escaped!
document.exitPointerLock()
// Don't update cursor position - let it naturally transition
return
}
// Calculate dampening factor (1.0 = normal, < 1.0 = dampened)
// Calculate dampening factor based on proximity to edge
let dampenFactor = 1.0
if (minDist < dampenZone) {
// Quadratic easing for smooth dampening
@ -816,46 +822,136 @@ export function MapRenderer({
dampenFactor = t * t // Squared for stronger dampening near edge
}
// Apply dampening to movement
// Apply dampening to movement - this is the actual cursor position we'll use
const dampenedDeltaX = e.movementX * currentMultiplier * dampenFactor
const dampenedDeltaY = e.movementY * currentMultiplier * dampenFactor
cursorX = lastX + dampenedDeltaX
cursorY = lastY + dampenedDeltaY
// Calculate squish effect based on proximity to edges
// Now check escape threshold using the DAMPENED position (not undampened!)
// This is critical - we need to check where the cursor actually is, not where it would be without dampening
// And we must use SVG bounds, not container bounds!
const dampenedDistLeft = cursorX - svgOffsetX
const dampenedDistRight = svgOffsetX + svgRect.width - cursorX
const dampenedDistTop = cursorY - svgOffsetY
const dampenedDistBottom = svgOffsetY + svgRect.height - cursorY
const dampenedMinDist = Math.min(dampenedDistLeft, dampenedDistRight, dampenedDistTop, dampenedDistBottom)
// Debug logging for boundary proximity
if (dampenedMinDist < squishZone) {
console.log('[Squish Debug]', {
cursorPos: { x: cursorX.toFixed(1), y: cursorY.toFixed(1) },
containerSize: { width: containerRect.width.toFixed(1), height: containerRect.height.toFixed(1) },
svgSize: { width: svgRect.width.toFixed(1), height: svgRect.height.toFixed(1) },
svgOffset: { x: svgOffsetX.toFixed(1), y: svgOffsetY.toFixed(1) },
distances: {
left: dampenedDistLeft.toFixed(1),
right: dampenedDistRight.toFixed(1),
top: dampenedDistTop.toFixed(1),
bottom: dampenedDistBottom.toFixed(1),
min: dampenedMinDist.toFixed(1),
},
dampenFactor: dampenFactor.toFixed(3),
thresholds: {
squishZone,
escapeThreshold,
},
willEscape: dampenedMinDist < escapeThreshold,
})
}
// Check if cursor has squished through and should escape (using dampened position!)
if (dampenedMinDist < escapeThreshold && !isReleasingPointerLock) {
console.log('[Pointer Lock] 🔓 ESCAPING (squished through boundary):', {
dampenedMinDist,
escapeThreshold,
cursorX,
cursorY,
whichEdge: {
left: dampenedDistLeft === dampenedMinDist,
right: dampenedDistRight === dampenedMinDist,
top: dampenedDistTop === dampenedMinDist,
bottom: dampenedDistBottom === dampenedMinDist,
},
})
// Start animation back to initial capture position
setIsReleasingPointerLock(true)
// Animate cursor back to initial position before releasing
if (initialCapturePositionRef.current) {
const startPos = { x: cursorX, y: cursorY }
const endPos = initialCapturePositionRef.current
const duration = 200 // ms
const startTime = performance.now()
const animate = (currentTime: number) => {
const elapsed = currentTime - startTime
const progress = Math.min(elapsed / duration, 1)
// Ease out cubic for smooth deceleration
const eased = 1 - Math.pow(1 - progress, 3)
const interpolatedX = startPos.x + (endPos.x - startPos.x) * eased
const interpolatedY = startPos.y + (endPos.y - startPos.y) * eased
// Update cursor position
cursorPositionRef.current = { x: interpolatedX, y: interpolatedY }
setCursorPosition({ x: interpolatedX, y: interpolatedY })
if (progress < 1) {
requestAnimationFrame(animate)
} else {
// Animation complete - now release pointer lock
console.log('[Pointer Lock] 🔓 Animation complete, releasing pointer lock')
document.exitPointerLock()
}
}
requestAnimationFrame(animate)
} else {
// No initial position saved, release immediately
document.exitPointerLock()
}
// Don't update cursor position in this frame - animation will handle it
return
}
// Calculate squish effect based on proximity to edges (using dampened position!)
// Handle horizontal and vertical squishing independently to support corners
let squishX = 1.0
let squishY = 1.0
if (distLeft < squishZone) {
// Squishing against left edge - compress horizontally, stretch vertically
const t = 1 - distLeft / squishZone
squishX = 1.0 - t * 0.5 // Compress to 50% width when fully squished
squishY = 1.0 + t * 0.4 // Stretch to 140% height when fully squished
} else if (distRight < squishZone) {
// Squishing against right edge
const t = 1 - distRight / squishZone
squishX = 1.0 - t * 0.5
squishY = 1.0 + t * 0.4
// Horizontal squishing (left/right edges)
if (dampenedDistLeft < squishZone) {
// Squishing against left edge - compress horizontally
const t = 1 - dampenedDistLeft / squishZone
squishX = Math.min(squishX, 1.0 - t * 0.5) // Compress to 50% width
} else if (dampenedDistRight < squishZone) {
// Squishing against right edge - compress horizontally
const t = 1 - dampenedDistRight / squishZone
squishX = Math.min(squishX, 1.0 - t * 0.5)
}
if (distTop < squishZone) {
// Squishing against top edge - compress vertically, stretch horizontally
const t = 1 - distTop / squishZone
squishY = 1.0 - t * 0.5
squishX = 1.0 + t * 0.4
} else if (distBottom < squishZone) {
// Squishing against bottom edge
const t = 1 - distBottom / squishZone
squishY = 1.0 - t * 0.5
squishX = 1.0 + t * 0.4
// Vertical squishing (top/bottom edges)
if (dampenedDistTop < squishZone) {
// Squishing against top edge - compress vertically
const t = 1 - dampenedDistTop / squishZone
squishY = Math.min(squishY, 1.0 - t * 0.5)
} else if (dampenedDistBottom < squishZone) {
// Squishing against bottom edge - compress vertically
const t = 1 - dampenedDistBottom / squishZone
squishY = Math.min(squishY, 1.0 - t * 0.5)
}
// Update squish state
setCursorSquish({ x: squishX, y: squishY })
// Clamp to container bounds (but allow reaching the escape threshold)
cursorX = Math.max(0, Math.min(containerRect.width, cursorX))
cursorY = Math.max(0, Math.min(containerRect.height, cursorY))
// Clamp to SVG bounds (not container bounds!)
// Allow cursor to reach escape threshold at SVG edges
cursorX = Math.max(svgOffsetX, Math.min(svgOffsetX + svgRect.width, cursorX))
cursorY = Math.max(svgOffsetY, Math.min(svgOffsetY + svgRect.height, cursorY))
} else {
// Normal mode: use absolute position
cursorX = e.clientX - containerRect.left