diff --git a/apps/web/.claude/ANIMATION_PATTERNS.md b/apps/web/.claude/ANIMATION_PATTERNS.md new file mode 100644 index 00000000..7f869a5d --- /dev/null +++ b/apps/web/.claude/ANIMATION_PATTERNS.md @@ -0,0 +1,111 @@ +# Animation Patterns + +## Spring-for-Speed, Manual-Integration-for-Angle Pattern + +When animating continuous rotation where the **speed changes smoothly** but you need to **avoid position jumps**, use this pattern. + +### The Problem + +**CSS Animation approach fails because:** +- Changing `animation-duration` resets the animation phase, causing jumps +- `animation-delay` tricks don't reliably preserve position across speed changes + +**Calling `spring.start()` 60fps fails because:** +- React-spring's internal batching can't keep up with 60fps updates +- Spring value lags 1000+ degrees behind, causing wild spinning +- React re-renders interfere with spring updates + +### The Solution: Decouple Speed and Angle + +```typescript +import { animated, useSpringValue } from '@react-spring/web' + +// 1. Spring for SPEED (this is what transitions smoothly) +const rotationSpeed = useSpringValue(0, { + config: { tension: 200, friction: 30 }, +}) + +// 2. Spring value for ANGLE (we'll .set() this directly, no springing) +const rotationAngle = useSpringValue(0) + +// 3. Update speed spring when target changes +useEffect(() => { + rotationSpeed.start(targetSpeedDegPerSec) +}, [targetSpeedDegPerSec, rotationSpeed]) + +// 4. requestAnimationFrame loop integrates angle from speed +useEffect(() => { + let lastTime = performance.now() + let frameId: number + + const loop = (now: number) => { + const dt = (now - lastTime) / 1000 // seconds + lastTime = now + + const speed = rotationSpeed.get() // deg/s from the spring + let angle = rotationAngle.get() + speed * dt // integrate + + // Keep angle in reasonable range (prevent overflow) + if (angle >= 360000) angle -= 360000 + if (angle < 0) angle += 360 + + // Direct set - no extra springing on angle itself + rotationAngle.set(angle) + + frameId = requestAnimationFrame(loop) + } + + frameId = requestAnimationFrame(loop) + return () => cancelAnimationFrame(frameId) +}, [rotationSpeed, rotationAngle]) + +// 5. Bind angle to animated element + `rotate(${a}deg)`), + }} +> + {/* SVG content */} + +``` + +### Why This Works + +1. **Speed spring handles smooth transitions**: When target speed changes, the spring smoothly interpolates. No jumps. + +2. **Manual integration preserves continuity**: `angle += speed * dt` always adds to the current angle. The angle never resets or jumps. + +3. **Direct `.set()` avoids lag**: We're not asking the spring to animate the angle - we're directly setting it 60 times per second. No batching issues. + +4. **`useSpringValue` enables binding**: Unlike a plain ref, `useSpringValue` can be bound to animated elements via `.to()`. + +### Key Insights + +- **Spring the derivative, integrate the value**: Speed is the derivative of angle. Spring the speed, integrate to get angle. +- **Never spring something you're updating 60fps**: The spring can't keep up. Use `.set()` instead of `.start()`. +- **Keep integration in rAF, not React effects**: React effects can skip frames or batch. rAF is reliable. + +### When to Use This Pattern + +- Rotating elements where rotation speed changes based on state +- Scrolling effects where scroll speed should transition smoothly +- Any continuous animation where the RATE of change should animate, not the value itself + +### Anti-Patterns to Avoid + +```typescript +// BAD: Calling start() in rAF loop +const loop = () => { + angle.start(currentAngle + speed * dt) // Will lag behind! +} + +// BAD: CSS animation with dynamic duration +style={{ + animation: `spin ${1/speed}s linear infinite` // Jumps on speed change! +}} + +// BAD: Changing animation-delay to preserve position +style={{ + animationDelay: `-${currentAngle / 360 * duration}s` // Unreliable! +}} +``` diff --git a/apps/web/.claude/CLAUDE.md b/apps/web/.claude/CLAUDE.md index 5c725e0b..a1df17f0 100644 --- a/apps/web/.claude/CLAUDE.md +++ b/apps/web/.claude/CLAUDE.md @@ -629,6 +629,40 @@ import { AbacusReact } from '@soroban/abacus-react' **Status:** Known issue, does not block development or deployment. +## Animation Patterns (React-Spring) + +When implementing continuous animations with smoothly-changing speeds (like rotating crosshairs), refer to: + +- **`.claude/ANIMATION_PATTERNS.md`** - Spring-for-speed, manual-integration-for-angle pattern + - Why CSS animation and naive react-spring approaches fail + - How to decouple speed (spring-animated) from angle (manually integrated) + - Complete code example with `useSpringValue` and `requestAnimationFrame` + - Anti-patterns to avoid + +**Quick Reference:** + +```typescript +// Spring the SPEED, integrate the ANGLE +const rotationSpeed = useSpringValue(0, { config: { tension: 200, friction: 30 } }) +const rotationAngle = useSpringValue(0) + +// Update speed spring when target changes +useEffect(() => { rotationSpeed.start(targetSpeed) }, [targetSpeed]) + +// rAF loop integrates angle from speed +useEffect(() => { + const loop = (now) => { + const dt = (now - lastTime) / 1000 + rotationAngle.set(rotationAngle.get() + rotationSpeed.get() * dt) + requestAnimationFrame(loop) + } + requestAnimationFrame(loop) +}, []) + +// Bind to animated element + `rotate(${a}deg)`) }} /> +``` + ## Game Settings Persistence When working on arcade room game settings, refer to: 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 c776aa5d..1a81ce2d 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 @@ -1,6 +1,6 @@ 'use client' -import { animated, to, useSpring } from '@react-spring/web' +import { animated, to, useSpring, useSpringValue } from '@react-spring/web' import { css } from '@styled/css' import { forceCollide, forceSimulation, forceX, forceY, type SimulationNodeDatum } from 'd3-force' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -1314,45 +1314,56 @@ export function MapRenderer({ 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) + // === Crosshair rotation using spring-for-speed, manual-integration-for-angle pattern === + // This gives smooth speed transitions without the issues of CSS animation or + // calling spring.start() 60 times per second. + // + // Pattern: + // 1. Spring animates the SPEED (degrees per second) - smooth transitions + // 2. requestAnimationFrame loop integrates angle from speed + // 3. Angle is bound to animated element via useSpringValue + // Convert rotation speed from degrees/frame@60fps to degrees/second + const targetSpeedDegPerSec = crosshairHeatStyle.rotationSpeed * 60 + + // Spring for rotation speed - this is what makes speed changes smooth + const rotationSpeed = useSpringValue(0, { + config: { tension: 200, friction: 30 }, + }) + + // Spring value for the angle - we'll directly .set() this from the rAF loop + const rotationAngle = useSpringValue(0) + + // Update the speed spring when target changes 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]) + rotationSpeed.start(targetSpeedDegPerSec) + }, [targetSpeedDegPerSec, rotationSpeed]) - // Cleanup timeout on unmount + // requestAnimationFrame loop to integrate angle from speed useEffect(() => { - return () => { - if (stopTimeoutRef.current) { - clearTimeout(stopTimeoutRef.current) - } - } - }, []) + let lastTime = performance.now() + let frameId: number - // 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 + const loop = (now: number) => { + const dt = (now - lastTime) / 1000 // seconds + lastTime = now + + const speed = rotationSpeed.get() // deg/s from the spring + let angle = rotationAngle.get() + speed * dt // integrate + + // Keep angle in reasonable range (prevent overflow after hours of play) + if (angle >= 360000) angle -= 360000 + if (angle < 0) angle += 360 + + // Direct set - no extra springing on angle itself + rotationAngle.set(angle) + + frameId = requestAnimationFrame(loop) + } + + frameId = requestAnimationFrame(loop) + return () => cancelAnimationFrame(frameId) + }, [rotationSpeed, rotationAngle]) // Note: Zoom animation with pause/resume is now handled by useMagnifierZoom hook // This effect only updates the remaining spring properties: opacity, position, movement multiplier @@ -4037,15 +4048,14 @@ export function MapRenderer({ }} /> )} - {/* Enhanced SVG crosshair with heat effects - uses CSS animation for smooth rotation */} - `rotate(${a}deg)`), }} > {/* Outer ring */} @@ -4110,7 +4120,7 @@ export function MapRenderer({ fill={crosshairHeatStyle.color} opacity={crosshairHeatStyle.opacity} /> - + {/* Fire particles around crosshair */} {crosshairHeatStyle.showFire && (
@@ -4241,15 +4251,14 @@ export function MapRenderer({ }} /> )} - {/* Enhanced SVG crosshair with heat effects - uses CSS animation */} - `rotate(${a}deg)`), }} > {/* Outer ring */} @@ -4308,7 +4317,7 @@ export function MapRenderer({ /> {/* Center dot */} - + {/* Fire particles around crosshair */} {heatStyle.showFire && (
handles translation (follows cursor) */} - {/* Inner handles rotation via CSS animation */} + {/* Inner animated.g handles rotation via spring-driven animation */} - `rotate(${a}deg)`), transformOrigin: '0 0', }} > @@ -4681,7 +4689,7 @@ export function MapRenderer({ vectorEffect="non-scaling-stroke" opacity={heatStyle.opacity} /> - + {/* Fire particles around crosshair when on_fire or found_it */} {heatStyle.showFire && (