From c502a4fa926b92d2a16d8e31e7e55243752d80d8 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Sun, 30 Nov 2025 16:55:23 -0600 Subject: [PATCH] feat(know-your-world): add device capability hooks and improve mobile support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create useDeviceCapabilities.ts with three hooks: - useIsTouchDevice(): detect touch-only devices - useCanUsePrecisionMode(): check pointer lock + fine pointer support - useHasAnyFinePointer(): detect any fine pointer (for hybrid devices) - Update usePointerLock to accept canUsePrecisionMode option: - Prevents pointer lock on unsupported devices - Auto-exits pointer lock when switching to mobile mode (DevTools) - Update MapRenderer to use new hooks: - Replace manual isTouchDevice detection with hooks - Use canUsePrecisionMode for precision mode UI visibility - Use hasAnyFinePointer for hot/cold feedback - Add pinch-to-zoom magnifier expansion: - Magnifier expands to fill leftover area during pinch gesture - Tap outside dismisses and resets size - Update SimpleLetterKeyboard to import from shared hooks file 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../components/MapRenderer.tsx | 257 ++++++++++++++---- .../components/SimpleLetterKeyboard.tsx | 44 +-- .../hooks/useDeviceCapabilities.ts | 118 ++++++++ .../know-your-world/hooks/useMagnifierZoom.ts | 28 +- .../know-your-world/hooks/usePointerLock.ts | 18 +- 5 files changed, 366 insertions(+), 99 deletions(-) create mode 100644 apps/web/src/arcade-games/know-your-world/hooks/useDeviceCapabilities.ts diff --git a/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx b/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx index 2aebafee..9f0ad028 100644 --- a/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx +++ b/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx @@ -7,6 +7,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTheme } from '@/contexts/ThemeContext' import { useVisualDebugSafe } from '@/contexts/VisualDebugContext' import type { ContinentId } from '../continents' +import { + useCanUsePrecisionMode, + useHasAnyFinePointer, + useIsTouchDevice, +} from '../hooks/useDeviceCapabilities' import { useHotColdFeedback } from '../hooks/useHotColdFeedback' import { useMagnifierZoom } from '../hooks/useMagnifierZoom' import { usePointerLock } from '../hooks/usePointerLock' @@ -442,6 +447,11 @@ export function MapRenderer({ const [cursorSquish, setCursorSquish] = useState({ x: 1, y: 1 }) const [isReleasingPointerLock, setIsReleasingPointerLock] = useState(false) + // Device capability hooks for adaptive UI + const isTouchDevice = useIsTouchDevice() // For touch-specific UI (magnifier expansion) + const canUsePrecisionMode = useCanUsePrecisionMode() // For precision mode UI/behavior + const hasAnyFinePointer = useHasAnyFinePointer() // For hot/cold feedback visibility + // Memoize pointer lock callbacks to prevent render loop const handleLockAcquired = useCallback(() => { // Save initial cursor position @@ -459,13 +469,16 @@ export function MapRenderer({ }, []) // Pointer lock hook (needed by zoom hook) + // Pass canUsePrecisionMode to prevent pointer lock on unsupported devices const { pointerLocked, requestPointerLock, exitPointerLock } = usePointerLock({ containerRef, + canUsePrecisionMode, onLockAcquired: handleLockAcquired, onLockReleased: handleLockReleased, }) // Magnifier zoom hook + // Disable threshold capping when precision mode isn't available (touch-only devices) const { targetZoom, setTargetZoom, zoomSpring, getCurrentZoom, uncappedAdaptiveZoomRef } = useMagnifierZoom({ containerRef, @@ -474,6 +487,7 @@ export function MapRenderer({ threshold: PRECISION_MODE_THRESHOLD, pointerLocked, initialZoom: 10, + disableThresholdCapping: !canUsePrecisionMode, }) const [svgDimensions, setSvgDimensions] = useState({ @@ -502,6 +516,12 @@ export function MapRenderer({ const magnifierRef = useRef(null) // Ref to magnifier element for tap position calculation const magnifierTapPositionRef = useRef<{ x: number; y: number } | null>(null) // Where user tapped on magnifier + // Pinch-to-zoom state for magnifier + const [isPinching, setIsPinching] = useState(false) + const pinchStartDistanceRef = useRef(null) // Initial distance between two fingers + const pinchStartZoomRef = useRef(null) // Zoom level when pinch started + const [isMagnifierExpanded, setIsMagnifierExpanded] = useState(false) // Magnifier fills leftover area during pinch + // Mobile map drag state - detect touch drags on the map to show magnifier const [isMobileMapDragging, setIsMobileMapDragging] = useState(false) const mapTouchStartRef = useRef<{ x: number; y: number } | null>(null) @@ -594,13 +614,9 @@ export function MapRenderer({ return localStorage.getItem('knowYourWorld.hotColdAudio') === 'true' }) - // Detect if device has a fine pointer (mouse) - iPads with mice will return true - // This is better than isTouchDevice because iPads with attached mice should show hot/cold - const hasFinePointer = - typeof window !== 'undefined' && window.matchMedia('(any-pointer: fine)').matches - // Whether hot/cold button should be shown at all - const showHotCold = isSpeechSupported && hasFinePointer && assistanceAllowsHotCold + // Uses hasAnyFinePointer because iPads with attached mice should show hot/cold + const showHotCold = isSpeechSupported && hasAnyFinePointer && assistanceAllowsHotCold // Persist auto-speak setting const handleAutoSpeakChange = useCallback((enabled: boolean) => { @@ -728,7 +744,7 @@ export function MapRenderer({ lastFeedbackType: hotColdFeedbackType, getSearchMetrics, } = useHotColdFeedback({ - enabled: assistanceAllowsHotCold && hotColdEnabled && hasFinePointer, + enabled: assistanceAllowsHotCold && hotColdEnabled && hasAnyFinePointer, targetRegionId: currentPrompt, isSpeaking, mapName: hotColdMapName, @@ -1999,7 +2015,7 @@ export function MapRenderer({ if ( hotColdEnabledRef.current && currentPrompt && - hasFinePointer && + hasAnyFinePointer && !isGiveUpAnimating && !isInTakeover ) { @@ -2296,27 +2312,47 @@ export function MapRenderer({ leftoverHeight ) - // Calculate leftover rectangle bounds (where magnifier can safely be positioned) - const leftoverTop = SAFE_ZONE_MARGINS.top - const leftoverBottom = - containerRect.height - SAFE_ZONE_MARGINS.bottom - magnifierHeight - 20 - const leftoverLeft = SAFE_ZONE_MARGINS.left + 20 - const leftoverRight = containerRect.width - SAFE_ZONE_MARGINS.right - magnifierWidth - 20 + // Lazy positioning like desktop - only move magnifier when cursor would be obscured + // Exception: always position on first show (when drag just started) + const isFirstShow = !isMobileMapDragging // State hasn't updated yet on first frame - // Calculate the center of the leftover rectangle for positioning decisions - const leftoverCenterX = (leftoverLeft + leftoverRight + magnifierWidth) / 2 - const leftoverCenterY = (leftoverTop + leftoverBottom + magnifierHeight) / 2 + // Check if cursor would be obscured by magnifier + const padding = 30 // Extra padding around magnifier to trigger movement early + const currentMagLeft = targetLeft + const currentMagTop = targetTop + const currentMagRight = currentMagLeft + magnifierWidth + const currentMagBottom = currentMagTop + magnifierHeight - // Position magnifier away from touch point (relative to leftover rectangle center) - const isLeftHalf = cursorX < leftoverCenterX - const isTopHalf = cursorY < leftoverCenterY + const cursorInMagnifier = + cursorX >= currentMagLeft - padding && + cursorX <= currentMagRight + padding && + cursorY >= currentMagTop - padding && + cursorY <= currentMagBottom + padding - // Place magnifier in opposite corner from where user is touching, within leftover bounds - const newTop = isTopHalf ? leftoverBottom : leftoverTop - const newLeft = isLeftHalf ? leftoverRight : leftoverLeft + // Only calculate new position if first show OR cursor would be obscured + if (isFirstShow || cursorInMagnifier) { + // Calculate leftover rectangle bounds (where magnifier can safely be positioned) + const leftoverTop = SAFE_ZONE_MARGINS.top + const leftoverBottom = + containerRect.height - SAFE_ZONE_MARGINS.bottom - magnifierHeight - 20 + const leftoverLeft = SAFE_ZONE_MARGINS.left + 20 + const leftoverRight = containerRect.width - SAFE_ZONE_MARGINS.right - magnifierWidth - 20 - setTargetTop(newTop) - setTargetLeft(newLeft) + // Calculate the center of the leftover rectangle for positioning decisions + const leftoverCenterX = (leftoverLeft + leftoverRight + magnifierWidth) / 2 + const leftoverCenterY = (leftoverTop + leftoverBottom + magnifierHeight) / 2 + + // Position magnifier away from touch point (relative to leftover rectangle center) + const isLeftHalf = cursorX < leftoverCenterX + const isTopHalf = cursorY < leftoverCenterY + + // Place magnifier in opposite corner from where user is touching, within leftover bounds + const newTop = isTopHalf ? leftoverBottom : leftoverTop + const newLeft = isLeftHalf ? leftoverRight : leftoverLeft + + setTargetTop(newTop) + setTargetLeft(newLeft) + } } }, [ @@ -2327,6 +2363,8 @@ export function MapRenderer({ getMagnifierDimensions, regionsFound, mapData, + targetLeft, + targetTop, ] ) @@ -2336,6 +2374,7 @@ export function MapRenderer({ setTargetOpacity(0) setCursorPosition(null) cursorPositionRef.current = null + setIsMagnifierExpanded(false) // Reset expanded state on dismiss }, []) const handleMapTouchEnd = useCallback(() => { @@ -2352,29 +2391,78 @@ export function MapRenderer({ } }, [isMobileMapDragging, showMagnifier, dismissMagnifier]) - // Mobile magnifier touch handlers - allow panning by dragging on the magnifier - const handleMagnifierTouchStart = useCallback((e: React.TouchEvent) => { - if (e.touches.length !== 1) return // Only handle single-finger touch - - const touch = e.touches[0] - magnifierTouchStartRef.current = { x: touch.clientX, y: touch.clientY } - magnifierDidMoveRef.current = false // Reset movement tracking - - // Record tap position relative to magnifier for tap-to-select - if (magnifierRef.current) { - const magnifierRect = magnifierRef.current.getBoundingClientRect() - magnifierTapPositionRef.current = { - x: touch.clientX - magnifierRect.left, - y: touch.clientY - magnifierRect.top, - } - } - - setIsMagnifierDragging(true) - e.preventDefault() // Prevent scrolling + // Helper to calculate distance between two touch points + const getTouchDistance = useCallback((touches: React.TouchList): number => { + if (touches.length < 2) return 0 + const dx = touches[0].clientX - touches[1].clientX + const dy = touches[0].clientY - touches[1].clientY + return Math.sqrt(dx * dx + dy * dy) }, []) + // Mobile magnifier touch handlers - allow panning by dragging on the magnifier + const handleMagnifierTouchStart = useCallback( + (e: React.TouchEvent) => { + // Stop propagation to prevent map container from receiving this touch + e.stopPropagation() + + // Handle two-finger touch (pinch start) + if (e.touches.length === 2) { + const distance = getTouchDistance(e.touches) + pinchStartDistanceRef.current = distance + pinchStartZoomRef.current = getCurrentZoom() + setIsPinching(true) + setIsMagnifierExpanded(true) // Expand magnifier to fill leftover area during pinch + setIsMagnifierDragging(false) // Cancel any single-finger drag + magnifierTouchStartRef.current = null + e.preventDefault() + return + } + + // Handle single-finger touch (pan/tap) + if (e.touches.length === 1) { + const touch = e.touches[0] + magnifierTouchStartRef.current = { x: touch.clientX, y: touch.clientY } + magnifierDidMoveRef.current = false // Reset movement tracking + + // Record tap position relative to magnifier for tap-to-select + if (magnifierRef.current) { + const magnifierRect = magnifierRef.current.getBoundingClientRect() + magnifierTapPositionRef.current = { + x: touch.clientX - magnifierRect.left, + y: touch.clientY - magnifierRect.top, + } + } + + setIsMagnifierDragging(true) + e.preventDefault() // Prevent scrolling + } + }, + [getTouchDistance, getCurrentZoom] + ) + const handleMagnifierTouchMove = useCallback( (e: React.TouchEvent) => { + // Stop propagation to prevent map container from receiving this touch + e.stopPropagation() + + // Handle two-finger pinch gesture + if (e.touches.length === 2 && isPinching) { + const currentDistance = getTouchDistance(e.touches) + const startDistance = pinchStartDistanceRef.current + const startZoom = pinchStartZoomRef.current + + if (startDistance && startZoom && currentDistance > 0) { + // Calculate new zoom based on pinch scale + const scale = currentDistance / startDistance + const newZoom = Math.max(1, Math.min(MAX_ZOOM, startZoom * scale)) + setTargetZoom(newZoom) + } + + e.preventDefault() + return + } + + // Handle single-finger panning if (!isMagnifierDragging || e.touches.length !== 1) return if (!magnifierTouchStartRef.current || !cursorPositionRef.current) return if (!svgRef.current || !containerRef.current) return @@ -2451,6 +2539,10 @@ export function MapRenderer({ }, [ isMagnifierDragging, + isPinching, + getTouchDistance, + MAX_ZOOM, + setTargetZoom, detectRegions, onCursorUpdate, gameMode, @@ -2463,6 +2555,25 @@ export function MapRenderer({ const handleMagnifierTouchEnd = useCallback( (e: React.TouchEvent) => { + // Always stop propagation to prevent map container from receiving touch end + // (which would trigger dismissMagnifier via handleMapTouchEnd) + e.stopPropagation() + + // Reset pinch state + if (isPinching) { + setIsPinching(false) + pinchStartDistanceRef.current = null + pinchStartZoomRef.current = null + // If still have one finger down, don't reset drag state - they might continue panning + if (e.touches.length === 1) { + // User lifted one finger but still has one down - start panning + const touch = e.touches[0] + magnifierTouchStartRef.current = { x: touch.clientX, y: touch.clientY } + setIsMagnifierDragging(true) + } + return + } + // Check if this was a tap (no significant movement) vs a drag // If the user just tapped on the magnifier, select the region at the tap position const didMove = magnifierDidMoveRef.current @@ -2535,6 +2646,7 @@ export function MapRenderer({ } }, [ + isPinching, detectRegions, mapData.regions, handleRegionClickWithCelebration, @@ -2563,7 +2675,13 @@ export function MapRenderer({ // Dismiss magnifier after selection attempt dismissMagnifier() - }, [detectRegions, mapData.regions, handleRegionClickWithCelebration, celebration, dismissMagnifier]) + }, [ + detectRegions, + mapData.regions, + handleRegionClickWithCelebration, + celebration, + dismissMagnifier, + ]) return (
60x) gets gold border, normal zoom gets blue border @@ -3468,7 +3591,11 @@ export function MapRenderer({ }) // When at or above threshold (but not in precision mode), add disabled effect - if (isAboveThreshold(screenPixelRatio, PRECISION_MODE_THRESHOLD)) { + // Only show disabled effect when precision mode is available but not active + if ( + canUsePrecisionMode && + isAboveThreshold(screenPixelRatio, PRECISION_MODE_THRESHOLD) + ) { return 'brightness(0.6) saturate(0.5)' } @@ -3973,7 +4100,11 @@ export function MapRenderer({ }) // If at or above threshold, show notice about activating precision controls - if (isAboveThreshold(screenPixelRatio, PRECISION_MODE_THRESHOLD)) { + // Only show precision mode message when precision mode is available + if ( + canUsePrecisionMode && + isAboveThreshold(screenPixelRatio, PRECISION_MODE_THRESHOLD) + ) { return 'Click to activate precision mode' } @@ -3987,7 +4118,9 @@ export function MapRenderer({ {/* Scrim overlay - shows when at threshold to indicate barrier */} + {/* Only show scrim when precision mode is available */} {!pointerLocked && + canUsePrecisionMode && (() => { const containerRect = containerRef.current?.getBoundingClientRect() const svgRect = svgRef.current?.getBoundingClientRect() @@ -4051,17 +4184,27 @@ export function MapRenderer({ top: magnifierSpring.top.to((t) => { const containerRect = containerRef.current?.getBoundingClientRect() if (!containerRect) return t + 200 - const leftoverWidth = containerRect.width - SAFE_ZONE_MARGINS.left - SAFE_ZONE_MARGINS.right - const leftoverHeight = containerRect.height - SAFE_ZONE_MARGINS.top - SAFE_ZONE_MARGINS.bottom - const { height: magnifierHeight } = getMagnifierDimensions(leftoverWidth, leftoverHeight) + const leftoverWidth = + containerRect.width - SAFE_ZONE_MARGINS.left - SAFE_ZONE_MARGINS.right + const leftoverHeight = + containerRect.height - SAFE_ZONE_MARGINS.top - SAFE_ZONE_MARGINS.bottom + const { height: magnifierHeight } = getMagnifierDimensions( + leftoverWidth, + leftoverHeight + ) return t + magnifierHeight + 12 // 12px gap below magnifier }), left: magnifierSpring.left.to((l) => { const containerRect = containerRef.current?.getBoundingClientRect() if (!containerRect) return l - const leftoverWidth = containerRect.width - SAFE_ZONE_MARGINS.left - SAFE_ZONE_MARGINS.right - const leftoverHeight = containerRect.height - SAFE_ZONE_MARGINS.top - SAFE_ZONE_MARGINS.bottom - const { width: magnifierWidth } = getMagnifierDimensions(leftoverWidth, leftoverHeight) + const leftoverWidth = + containerRect.width - SAFE_ZONE_MARGINS.left - SAFE_ZONE_MARGINS.right + const leftoverHeight = + containerRect.height - SAFE_ZONE_MARGINS.top - SAFE_ZONE_MARGINS.bottom + const { width: magnifierWidth } = getMagnifierDimensions( + leftoverWidth, + leftoverHeight + ) return l + magnifierWidth / 2 - 60 // Center the 120px button under magnifier }), width: 120, diff --git a/apps/web/src/arcade-games/know-your-world/components/SimpleLetterKeyboard.tsx b/apps/web/src/arcade-games/know-your-world/components/SimpleLetterKeyboard.tsx index a3692606..58f8006b 100644 --- a/apps/web/src/arcade-games/know-your-world/components/SimpleLetterKeyboard.tsx +++ b/apps/web/src/arcade-games/know-your-world/components/SimpleLetterKeyboard.tsx @@ -1,9 +1,13 @@ 'use client' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef } from 'react' import Keyboard from 'react-simple-keyboard' import 'react-simple-keyboard/build/css/index.css' import { css } from '@styled/css' +import { useIsTouchDevice } from '../hooks/useDeviceCapabilities' + +// Re-export for backwards compatibility +export { useIsTouchDevice } from '../hooks/useDeviceCapabilities' interface SimpleLetterKeyboardProps { /** Whether to show uppercase or lowercase letters */ @@ -16,44 +20,6 @@ interface SimpleLetterKeyboardProps { forceShow?: boolean } -/** - * Hook to detect if the device is primarily touch-based (mobile/tablet) - * Returns true only for devices where touch is the primary input method - */ -export function useIsTouchDevice() { - const [isTouchDevice, setIsTouchDevice] = useState(false) - - useEffect(() => { - // Check if device is primarily touch-based - // 1. Has touch capability - // 2. Is a mobile/tablet device (no fine pointer like mouse) - const checkTouchDevice = () => { - const hasTouchCapability = - 'ontouchstart' in window || - navigator.maxTouchPoints > 0 || - // @ts-expect-error - msMaxTouchPoints is IE/Edge specific - navigator.msMaxTouchPoints > 0 - - // Check if the device has no fine pointer (mouse) - // This helps distinguish touch-only devices from laptops with touchscreens - const hasNoFinePointer = !window.matchMedia('(pointer: fine)').matches - - // Also check for coarse pointer (finger/touch) - const hasCoarsePointer = window.matchMedia('(pointer: coarse)').matches - - setIsTouchDevice(hasTouchCapability && (hasNoFinePointer || hasCoarsePointer)) - } - - checkTouchDevice() - - // Re-check on resize (in case device mode changes, e.g., responsive testing) - window.addEventListener('resize', checkTouchDevice) - return () => window.removeEventListener('resize', checkTouchDevice) - }, []) - - return isTouchDevice -} - /** * A simple on-screen keyboard for mobile devices. * Shows only letters (no numbers, no shift, no special keys). diff --git a/apps/web/src/arcade-games/know-your-world/hooks/useDeviceCapabilities.ts b/apps/web/src/arcade-games/know-your-world/hooks/useDeviceCapabilities.ts new file mode 100644 index 00000000..d72f51b6 --- /dev/null +++ b/apps/web/src/arcade-games/know-your-world/hooks/useDeviceCapabilities.ts @@ -0,0 +1,118 @@ +/** + * Device Capabilities Hooks + * + * Detect device input capabilities for adaptive UI behavior. + * These hooks help distinguish between: + * - Touch-only devices (phones, tablets without mouse) + * - Pointer devices (desktops, laptops with mouse/trackpad) + * - Hybrid devices (laptops with touchscreen, tablets with attached mouse) + */ + +import { useEffect, useState } from 'react' + +/** + * Hook to detect if the device is primarily touch-based (mobile/tablet). + * Returns true only for devices where touch is the primary input method. + * + * Use cases: + * - Showing virtual keyboards + * - Adjusting touch target sizes + * - Showing mobile-specific UI + */ +export function useIsTouchDevice(): boolean { + const [isTouchDevice, setIsTouchDevice] = useState(false) + + useEffect(() => { + const checkTouchDevice = () => { + // Check if device has touch capability + const hasTouchCapability = + 'ontouchstart' in window || + navigator.maxTouchPoints > 0 || + // @ts-expect-error - msMaxTouchPoints is IE/Edge specific + navigator.msMaxTouchPoints > 0 + + // Check if the device has no fine pointer (mouse) + // This helps distinguish touch-only devices from laptops with touchscreens + const hasNoFinePointer = !window.matchMedia('(pointer: fine)').matches + + // Also check for coarse pointer (finger/touch) + const hasCoarsePointer = window.matchMedia('(pointer: coarse)').matches + + setIsTouchDevice(hasTouchCapability && (hasNoFinePointer || hasCoarsePointer)) + } + + checkTouchDevice() + + // Re-check on resize (in case device mode changes, e.g., responsive testing) + window.addEventListener('resize', checkTouchDevice) + return () => window.removeEventListener('resize', checkTouchDevice) + }, []) + + return isTouchDevice +} + +/** + * Hook to detect if the device supports precision mode (pointer lock). + * Returns true only if: + * 1. The browser supports the Pointer Lock API + * 2. The device has a fine pointer (mouse/trackpad) + * + * Use cases: + * - Showing/hiding precision mode UI + * - Enabling/disabling zoom threshold capping + * - Showing "click to activate precision mode" messages + */ +export function useCanUsePrecisionMode(): boolean { + const [canUsePrecisionMode, setCanUsePrecisionMode] = useState(false) + + useEffect(() => { + const checkPrecisionMode = () => { + // Check if Pointer Lock API is supported + const supportsPointerLock = 'pointerLockElement' in document + + // Check if device has a fine pointer (mouse/trackpad) + const hasFinePointer = window.matchMedia('(pointer: fine)').matches + + setCanUsePrecisionMode(supportsPointerLock && hasFinePointer) + } + + checkPrecisionMode() + + // Re-check on resize (in case device mode changes) + window.addEventListener('resize', checkPrecisionMode) + return () => window.removeEventListener('resize', checkPrecisionMode) + }, []) + + return canUsePrecisionMode +} + +/** + * Hook to detect if any pointing device on this device is "fine" (mouse-like). + * Returns true for: + * - Desktops/laptops with mouse + * - Tablets with attached mouse/trackpad + * - Laptops with touchscreen (primary may be touch, but mouse is available) + * + * Use cases: + * - Showing hover-based UI hints (hot/cold feedback) + * - Enabling mouse-specific interactions + */ +export function useHasAnyFinePointer(): boolean { + const [hasAnyFinePointer, setHasAnyFinePointer] = useState(false) + + useEffect(() => { + const checkFinePointer = () => { + // any-pointer: fine matches if ANY available pointing device is fine + // This is broader than pointer: fine which only checks the primary device + setHasAnyFinePointer(window.matchMedia('(any-pointer: fine)').matches) + } + + checkFinePointer() + + // Re-check on resize (in case device mode changes) + window.addEventListener('resize', checkFinePointer) + return () => window.removeEventListener('resize', checkFinePointer) + }, []) + + return hasAnyFinePointer +} diff --git a/apps/web/src/arcade-games/know-your-world/hooks/useMagnifierZoom.ts b/apps/web/src/arcade-games/know-your-world/hooks/useMagnifierZoom.ts index 1173cd85..cad6a033 100644 --- a/apps/web/src/arcade-games/know-your-world/hooks/useMagnifierZoom.ts +++ b/apps/web/src/arcade-games/know-your-world/hooks/useMagnifierZoom.ts @@ -28,6 +28,8 @@ export interface UseMagnifierZoomOptions { pointerLocked: boolean /** Initial zoom level */ initialZoom?: number + /** Disable threshold-based zoom capping (useful for mobile where there's no pointer lock alternative) */ + disableThresholdCapping?: boolean } export interface UseMagnifierZoomReturn { @@ -57,7 +59,15 @@ export interface UseMagnifierZoomReturn { * @returns Zoom state and control methods */ export function useMagnifierZoom(options: UseMagnifierZoomOptions): UseMagnifierZoomReturn { - const { containerRef, svgRef, viewBox, threshold, pointerLocked, initialZoom = 10 } = options + const { + containerRef, + svgRef, + viewBox, + threshold, + pointerLocked, + initialZoom = 10, + disableThresholdCapping = false, + } = options const [targetZoom, setTargetZoom] = useState(initialZoom) const uncappedAdaptiveZoomRef = useRef(null) @@ -81,6 +91,11 @@ export function useMagnifierZoom(options: UseMagnifierZoomOptions): UseMagnifier // Handle pointer lock state changes - recalculate zoom with capping useEffect(() => { + // Skip capping logic entirely when disabled (e.g., on mobile) + if (disableThresholdCapping) { + return + } + // When pointer lock is released, cap zoom if it exceeds threshold if (!pointerLocked && uncappedAdaptiveZoomRef.current !== null) { const containerElement = containerRef.current @@ -123,13 +138,21 @@ export function useMagnifierZoom(options: UseMagnifierZoomOptions): UseMagnifier if (pointerLocked && uncappedAdaptiveZoomRef.current !== null) { setTargetZoom(uncappedAdaptiveZoomRef.current) } - }, [pointerLocked, containerRef, svgRef, viewBox, threshold]) + }, [pointerLocked, containerRef, svgRef, viewBox, threshold, disableThresholdCapping]) // Handle pause/resume at threshold useEffect(() => { const currentZoom = magnifierSpring.zoom.get() const zoomIsAnimating = Math.abs(currentZoom - targetZoom) > 0.01 + // Skip threshold checking when capping is disabled (e.g., on mobile) + // In this case, just ensure the animation runs without pausing + if (disableThresholdCapping) { + magnifierApi.resume() + magnifierApi.start({ zoom: targetZoom }) + return + } + // Check if CURRENT zoom is at/above threshold (zoom is capped) let currentScreenPixelRatio = 0 const currentIsAtThreshold = @@ -208,6 +231,7 @@ export function useMagnifierZoom(options: UseMagnifierZoomOptions): UseMagnifier containerRef, svgRef, magnifierApi, + disableThresholdCapping, // NOTE: Do NOT include magnifierSpring.zoom here! // Spring values don't trigger React effects correctly. // We read spring.zoom.get() inside the effect, but don't depend on it. diff --git a/apps/web/src/arcade-games/know-your-world/hooks/usePointerLock.ts b/apps/web/src/arcade-games/know-your-world/hooks/usePointerLock.ts index 73a044e1..ae46ad5e 100644 --- a/apps/web/src/arcade-games/know-your-world/hooks/usePointerLock.ts +++ b/apps/web/src/arcade-games/know-your-world/hooks/usePointerLock.ts @@ -11,6 +11,8 @@ import { useState, useEffect, useRef, type RefObject } from 'react' export interface UsePointerLockOptions { /** The container element to lock the pointer to */ containerRef: RefObject + /** Whether precision mode is available on this device */ + canUsePrecisionMode: boolean /** Callback when pointer lock is acquired */ onLockAcquired?: () => void /** Callback when pointer lock is released */ @@ -37,12 +39,21 @@ export interface UsePointerLockReturn { * @returns Pointer lock state and control methods */ export function usePointerLock(options: UsePointerLockOptions): UsePointerLockReturn { - const { containerRef, onLockAcquired, onLockReleased } = options + const { containerRef, canUsePrecisionMode, onLockAcquired, onLockReleased } = options const [pointerLocked, setPointerLocked] = useState(false) // Track previous lock state for detecting transitions const prevLockedRef = useRef(false) + // Auto-exit pointer lock when precision mode becomes unavailable + // (e.g., when switching to mobile device toolbar in Chrome DevTools) + useEffect(() => { + if (!canUsePrecisionMode && document.pointerLockElement) { + console.log('[usePointerLock] Precision mode unavailable - exiting pointer lock') + document.exitPointerLock() + } + }, [canUsePrecisionMode]) + // Set up pointer lock event listeners useEffect(() => { const handlePointerLockChange = () => { @@ -98,6 +109,11 @@ export function usePointerLock(options: UsePointerLockOptions): UsePointerLockRe }, []) const requestPointerLock = () => { + // Don't request pointer lock if precision mode is not available + if (!canUsePrecisionMode) { + console.log('[usePointerLock] Precision mode not available - skipping pointer lock request') + return + } if (containerRef.current && !pointerLocked) { console.log('[usePointerLock] Requesting pointer lock') containerRef.current.requestPointerLock()