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 2a8f7a4a..c776aa5d 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 @@ -39,6 +39,7 @@ import type { HintMap } from '../messages' import { useKnowYourWorld } from '../Provider' import type { MapData, MapRegion } from '../types' import { type BoundingBox as DebugBoundingBox, findOptimalZoom } from '../utils/adaptiveZoomSearch' +import { CELEBRATION_TIMING, classifyCelebration } from '../utils/celebration' import type { FeedbackType } from '../utils/hotColdPhrases' import { getAdjustedMagnifiedDimensions, @@ -49,7 +50,6 @@ import { calculateScreenPixelRatio, isAboveThreshold, } from '../utils/screenPixelRatio' -import { classifyCelebration, CELEBRATION_TIMING } from '../utils/celebration' import { CelebrationOverlay } from './CelebrationOverlay' import { DevCropTool } from './DevCropTool' @@ -184,6 +184,180 @@ function getHeatBorderColors( } } +/** + * Convert FeedbackType to a numeric heat level (0-1) + * Used for continuous effects like rotation speed + */ +function getHeatLevel(feedbackType: FeedbackType | null): number { + switch (feedbackType) { + case 'found_it': + return 1.0 + case 'on_fire': + return 0.9 + case 'hot': + return 0.75 + case 'warmer': + return 0.6 + case 'colder': + return 0.4 + case 'cold': + return 0.25 + case 'freezing': + return 0.1 + case 'overshot': + return 0.3 + case 'stuck': + return 0.35 + default: + return 0.5 // Neutral + } +} + +/** + * Calculate rotation speed based on heat level using 1/x backoff curve + * - No rotation below heat 0.5 + * - Maximum rotation (1 rotation/sec = 6°/frame at 60fps) at heat 1.0 + * - Uses squared curve for rapid acceleration near found_it + */ +function getRotationSpeed(heatLevel: number): number { + const THRESHOLD = 0.5 + const MAX_SPEED = 6 // 1 rotation/sec at 60fps (360°/sec / 60 = 6°/frame) + + if (heatLevel <= THRESHOLD) { + return 0 + } + + // 1/x style backoff: speed increases rapidly as heat approaches 1.0 + // Using squared curve: ((heat - 0.5) / 0.5)^2 * maxSpeed + const normalized = (heatLevel - THRESHOLD) / (1 - THRESHOLD) + return MAX_SPEED * normalized * normalized +} + +/** + * Get crosshair styling based on hot/cold feedback + * Returns color, opacity, rotation speed, and fire state for heat-reactive crosshairs + */ +function getHeatCrosshairStyle( + feedbackType: FeedbackType | null, + isDark: boolean, + hotColdEnabled: boolean +): { + color: string + opacity: number + showFire: boolean + rotationSpeed: number // degrees per frame at 60fps (0 = no rotation) + glowColor: string + strokeWidth: number +} { + const heatLevel = getHeatLevel(feedbackType) + const rotationSpeed = hotColdEnabled ? getRotationSpeed(heatLevel) : 0 + + // Default styling when hot/cold not enabled + if (!hotColdEnabled || !feedbackType) { + return { + color: isDark ? '#60a5fa' : '#3b82f6', // Default blue + opacity: 1, + showFire: false, + rotationSpeed: 0, + glowColor: 'transparent', + strokeWidth: 2, + } + } + + switch (feedbackType) { + case 'found_it': + return { + color: '#fbbf24', // Gold + opacity: 1, + showFire: true, + rotationSpeed, + glowColor: 'rgba(251, 191, 36, 0.8)', + strokeWidth: 3, + } + case 'on_fire': + return { + color: '#ef4444', // Bright red + opacity: 1, + showFire: true, // Show fire particles + rotationSpeed, + glowColor: 'rgba(239, 68, 68, 0.7)', + strokeWidth: 3, + } + case 'hot': + return { + color: '#f97316', // Orange + opacity: 1, + showFire: false, + rotationSpeed, + glowColor: 'rgba(249, 115, 22, 0.5)', + strokeWidth: 2.5, + } + case 'warmer': + return { + color: '#fb923c', // Light orange + opacity: 0.9, + showFire: false, + rotationSpeed, + glowColor: 'rgba(251, 146, 60, 0.4)', + strokeWidth: 2, + } + case 'colder': + return { + color: '#93c5fd', // Light blue + opacity: 0.6, + showFire: false, + rotationSpeed, + glowColor: 'transparent', + strokeWidth: 2, + } + case 'cold': + return { + color: '#60a5fa', // Blue + opacity: 0.4, + showFire: false, + rotationSpeed, + glowColor: 'transparent', + strokeWidth: 1.5, + } + case 'freezing': + return { + color: '#38bdf8', // Ice blue/cyan + opacity: 0.25, // Very faded + showFire: false, + rotationSpeed, + glowColor: 'transparent', + strokeWidth: 1, + } + case 'overshot': + return { + color: '#a855f7', // Purple (went past it) + opacity: 0.8, + showFire: false, + rotationSpeed, + glowColor: 'rgba(168, 85, 247, 0.4)', + strokeWidth: 2, + } + case 'stuck': + return { + color: '#9ca3af', // Gray + opacity: 0.5, + showFire: false, + rotationSpeed, + glowColor: 'transparent', + strokeWidth: 1.5, + } + default: + return { + color: isDark ? '#60a5fa' : '#3b82f6', + opacity: 1, + showFire: false, + rotationSpeed: 0, + glowColor: 'transparent', + strokeWidth: 2, + } + } +} + /** * Calculate the actual rendered viewport within an SVG element. * SVG uses preserveAspectRatio="xMidYMid meet" by default, which: @@ -1133,6 +1307,53 @@ export function MapRenderer({ config: { tension: 120, friction: 20 }, }) + // Get crosshair heat styling from the REAL hot/cold feedback system + const crosshairHeatStyle = getHeatCrosshairStyle( + hotColdFeedbackType, + isDark, + effectiveHotColdEnabled + ) + + // Debounced rotation state to prevent flicker from feedback type flickering + // Start rotating immediately when speed > 0, but delay stopping by 150ms + const rawShouldRotate = crosshairHeatStyle.rotationSpeed > 0 + const [debouncedShouldRotate, setDebouncedShouldRotate] = useState(false) + const stopTimeoutRef = useRef | null>(null) + + useEffect(() => { + if (rawShouldRotate) { + // Start immediately + if (stopTimeoutRef.current) { + clearTimeout(stopTimeoutRef.current) + stopTimeoutRef.current = null + } + setDebouncedShouldRotate(true) + } else { + // Delay stopping to prevent flicker + if (!stopTimeoutRef.current) { + stopTimeoutRef.current = setTimeout(() => { + setDebouncedShouldRotate(false) + stopTimeoutRef.current = null + }, 150) + } + } + }, [rawShouldRotate]) + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (stopTimeoutRef.current) { + clearTimeout(stopTimeoutRef.current) + } + } + }, []) + + // Calculate CSS animation duration from rotation speed + // rotationSpeed is degrees per frame at 60fps + // duration = 360 degrees / (speed * 60 frames) seconds + const rotationDuration = + crosshairHeatStyle.rotationSpeed > 0 ? 360 / (crosshairHeatStyle.rotationSpeed * 60) : 1 // fallback, won't be used when paused + // Note: Zoom animation with pause/resume is now handled by useMagnifierZoom hook // This effect only updates the remaining spring properties: opacity, position, movement multiplier useEffect(() => { @@ -3197,7 +3418,8 @@ export function MapRenderer({ // Fill the entire container - viewBox controls what portion of map is visible width: '100%', height: '100%', - cursor: pointerLocked ? 'crosshair' : 'pointer', + // Hide native cursor on desktop since we show custom crosshair + cursor: hasAnyFinePointer ? 'none' : 'pointer', transformOrigin: 'center center', })} style={{ @@ -3330,7 +3552,8 @@ export function MapRenderer({ } }} // Disable clicks on excluded regions and during celebration style={{ - cursor: isExcluded ? 'default' : 'pointer', + // Hide native cursor on desktop (custom crosshair shown instead) + cursor: hasAnyFinePointer ? 'none' : isExcluded ? 'default' : 'pointer', transition: 'all 0.2s ease', // Ensure entire path interior is clickable, not just visible fill pointerEvents: isExcluded ? 'none' : 'all', @@ -3679,7 +3902,8 @@ export function MapRenderer({ top: `${label.labelY}px`, transform: 'translate(-50%, -50%)', pointerEvents: 'all', - cursor: 'pointer', + // Hide native cursor on desktop (custom crosshair shown instead) + cursor: hasAnyFinePointer ? 'none' : 'pointer', zIndex: 20, }} onClick={() => @@ -3782,8 +4006,8 @@ export function MapRenderer({ ) })} - {/* Custom Cursor - Visible when pointer lock is active */} - {pointerLocked && cursorPosition && ( + {/* Custom Cursor - Visible on desktop when cursor is on the map */} + {cursorPosition && hasAnyFinePointer && ( <>
- {/* Crosshair - Vertical line */} -
+ )} + {/* Enhanced SVG crosshair with heat effects - uses CSS animation for smooth rotation */} + - {/* Crosshair - Horizontal line */} -
+ > + {/* Outer ring */} + + {/* Cross lines - top */} + + {/* Cross lines - bottom */} + + {/* Cross lines - left */} + + {/* Cross lines - right */} + + {/* Center dot */} + + + {/* Fire particles around crosshair */} + {crosshairHeatStyle.showFire && ( +
+ {[0, 45, 90, 135, 180, 225, 270, 315].map((angle, i) => { + const rad = (angle * Math.PI) / 180 + const dist = 20 + const px = 16 + Math.cos(rad) * dist - 4 + const py = 16 + Math.sin(rad) * dist - 4 + return ( +
+ ) + })} +
+ )}
{/* Cursor region name label - shows what to find under the cursor */} - {currentRegionName && ( + {currentRegionName && + (() => { + const labelHeatStyle = getHeatCrosshairStyle( + hotColdFeedbackType, + isDark, + effectiveHotColdEnabled + ) + return ( +
+ + Find + + + {currentRegionName} + + {currentFlagEmoji && {currentFlagEmoji}} +
+ ) + })()} + + )} + + {/* Heat crosshair overlay on main map - shows when hot/cold enabled (desktop non-pointer-lock) */} + {effectiveHotColdEnabled && + cursorPosition && + !pointerLocked && + hasAnyFinePointer && + (() => { + const heatStyle = getHeatCrosshairStyle( + hotColdFeedbackType, + isDark, + effectiveHotColdEnabled + ) + return (
- {/* Hot/cold feedback emoji - shows temperature when enabled */} - {effectiveHotColdEnabled && hotColdFeedbackType && ( - - {getHotColdEmoji(hotColdFeedbackType)} - + /> )} - - Find - - - {currentRegionName} - - {currentFlagEmoji && {currentFlagEmoji}} + {/* Outer ring */} + + {/* Cross lines - top */} + + {/* Cross lines - bottom */} + + {/* Cross lines - left */} + + {/* Cross lines - right */} + + {/* Center dot */} + + + {/* Fire particles around crosshair */} + {heatStyle.showFire && ( +
+ {[0, 45, 90, 135, 180, 225, 270, 315].map((angle, i) => { + const rad = (angle * Math.PI) / 180 + const distance = 24 + const px = 20 + Math.cos(rad) * distance - 5 + const py = 20 + Math.sin(rad) * distance - 5 + return ( +
+ ) + })} +
+ )}
- )} - - )} + ) + })()} {/* Magnifier overlay - centers on cursor position */} {(() => { @@ -4136,7 +4591,7 @@ export function MapRenderer({ })} {/* Crosshair at center position (cursor or reveal center during animation) */} - + {(() => { const containerRect = containerRef.current!.getBoundingClientRect() const svgRect = svgRef.current!.getBoundingClientRect() @@ -4158,35 +4613,102 @@ export function MapRenderer({ const cursorSvgX = (cursorPosition.x - svgOffsetX) / viewport.scale + viewBoxX const cursorSvgY = (cursorPosition.y - svgOffsetY) / viewport.scale + viewBoxY + // Get heat-based crosshair styling + const heatStyle = getHeatCrosshairStyle( + hotColdFeedbackType, + isDark, + effectiveHotColdEnabled + ) + const crosshairRadius = viewBoxWidth / 100 + const crosshairLineLength = viewBoxWidth / 50 + return ( <> - - - + {/* Glow effect behind crosshair when hot */} + {heatStyle.glowColor !== 'transparent' && ( + + )} + {/* Crosshair with separate translation and rotation */} + {/* Outer handles translation (follows cursor) */} + {/* Inner handles rotation via CSS animation */} + + + {/* Main crosshair circle - drawn at origin */} + + {/* Horizontal crosshair line - drawn at origin */} + + {/* Vertical crosshair line - drawn at origin */} + + + + {/* Fire particles around crosshair when on_fire or found_it */} + {heatStyle.showFire && ( + <> + {/* Fire particles - 6 small circles radiating outward */} + {[0, 60, 120, 180, 240, 300].map((angle, i) => { + const rad = (angle * Math.PI) / 180 + const particleDistance = crosshairRadius * 1.8 + const px = cursorSvgX + Math.cos(rad) * particleDistance + const py = cursorSvgY + Math.sin(rad) * particleDistance + return ( + + ) + })} + + )} ) })()} @@ -5048,6 +5570,25 @@ export function MapRenderer({ from { stroke-dashoffset: 12; } to { stroke-dashoffset: 0; } } + @keyframes crosshairSpin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } + } + @keyframes fireParticle0 { + 0% { opacity: 0.9; transform: scale(1) translateY(0); } + 50% { opacity: 1; transform: scale(1.3) translateY(-2px); } + 100% { opacity: 0.9; transform: scale(1) translateY(0); } + } + @keyframes fireParticle1 { + 0% { opacity: 0.8; transform: scale(1.1) translateY(-1px); } + 50% { opacity: 1; transform: scale(1) translateY(1px); } + 100% { opacity: 0.8; transform: scale(1.1) translateY(-1px); } + } + @keyframes fireParticle2 { + 0% { opacity: 1; transform: scale(1.2) translateY(-2px); } + 50% { opacity: 0.7; transform: scale(0.9) translateY(0); } + 100% { opacity: 1; transform: scale(1.2) translateY(-2px); } + } `}