fix(know-your-world): use spring-for-speed pattern for smooth crosshair rotation
Replace CSS animation with spring-for-speed, manual-integration-for-angle pattern: - Spring animates rotation SPEED for smooth transitions between heat levels - requestAnimationFrame loop integrates angle from speed (no jumps) - useSpringValue binds angle directly to animated SVG elements This solves position jumps that occurred when CSS animation duration changed. Add documentation: - .claude/ANIMATION_PATTERNS.md - complete pattern explanation - Reference in CLAUDE.md for future similar tasks 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
af5e7b59dc
commit
b7fe2af369
|
|
@ -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
|
||||
<animated.svg
|
||||
style={{
|
||||
transform: rotationAngle.to((a) => `rotate(${a}deg)`),
|
||||
}}
|
||||
>
|
||||
{/* SVG content */}
|
||||
</animated.svg>
|
||||
```
|
||||
|
||||
### 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!
|
||||
}}
|
||||
```
|
||||
|
|
@ -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
|
||||
<animated.svg style={{ transform: rotationAngle.to(a => `rotate(${a}deg)`) }} />
|
||||
```
|
||||
|
||||
## Game Settings Persistence
|
||||
|
||||
When working on arcade room game settings, refer to:
|
||||
|
|
|
|||
|
|
@ -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<ReturnType<typeof setTimeout> | 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 */}
|
||||
<svg
|
||||
{/* Enhanced SVG crosshair with heat effects - uses spring-driven rotation */}
|
||||
<animated.svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 32 32"
|
||||
style={{
|
||||
filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.5)',
|
||||
animation: `crosshairSpin ${rotationDuration}s linear infinite`,
|
||||
animationPlayState: debouncedShouldRotate ? 'running' : 'paused',
|
||||
transform: rotationAngle.to((a) => `rotate(${a}deg)`),
|
||||
}}
|
||||
>
|
||||
{/* Outer ring */}
|
||||
|
|
@ -4110,7 +4120,7 @@ export function MapRenderer({
|
|||
fill={crosshairHeatStyle.color}
|
||||
opacity={crosshairHeatStyle.opacity}
|
||||
/>
|
||||
</svg>
|
||||
</animated.svg>
|
||||
{/* Fire particles around crosshair */}
|
||||
{crosshairHeatStyle.showFire && (
|
||||
<div style={{ position: 'absolute', left: 0, top: 0, width: '32px', height: '32px' }}>
|
||||
|
|
@ -4241,15 +4251,14 @@ export function MapRenderer({
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Enhanced SVG crosshair with heat effects - uses CSS animation */}
|
||||
<svg
|
||||
{/* Enhanced SVG crosshair with heat effects - uses spring-driven rotation */}
|
||||
<animated.svg
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 40 40"
|
||||
style={{
|
||||
filter: 'drop-shadow(0 1px 3px rgba(0,0,0,0.6))',
|
||||
animation: `crosshairSpin ${rotationDuration}s linear infinite`,
|
||||
animationPlayState: debouncedShouldRotate ? 'running' : 'paused',
|
||||
transform: rotationAngle.to((a) => `rotate(${a}deg)`),
|
||||
}}
|
||||
>
|
||||
{/* Outer ring */}
|
||||
|
|
@ -4308,7 +4317,7 @@ export function MapRenderer({
|
|||
/>
|
||||
{/* Center dot */}
|
||||
<circle cx="20" cy="20" r="2" fill={heatStyle.color} opacity={heatStyle.opacity} />
|
||||
</svg>
|
||||
</animated.svg>
|
||||
{/* Fire particles around crosshair */}
|
||||
{heatStyle.showFire && (
|
||||
<div
|
||||
|
|
@ -4639,12 +4648,11 @@ export function MapRenderer({
|
|||
)}
|
||||
{/* Crosshair with separate translation and rotation */}
|
||||
{/* Outer <g> handles translation (follows cursor) */}
|
||||
{/* Inner <g> handles rotation via CSS animation */}
|
||||
{/* Inner animated.g handles rotation via spring-driven animation */}
|
||||
<g transform={`translate(${cursorSvgX}, ${cursorSvgY})`}>
|
||||
<g
|
||||
<animated.g
|
||||
style={{
|
||||
animation: `crosshairSpin ${rotationDuration}s linear infinite`,
|
||||
animationPlayState: debouncedShouldRotate ? 'running' : 'paused',
|
||||
transform: rotationAngle.to((a) => `rotate(${a}deg)`),
|
||||
transformOrigin: '0 0',
|
||||
}}
|
||||
>
|
||||
|
|
@ -4681,7 +4689,7 @@ export function MapRenderer({
|
|||
vectorEffect="non-scaling-stroke"
|
||||
opacity={heatStyle.opacity}
|
||||
/>
|
||||
</g>
|
||||
</animated.g>
|
||||
</g>
|
||||
{/* Fire particles around crosshair when on_fire or found_it */}
|
||||
{heatStyle.showFire && (
|
||||
|
|
|
|||
Loading…
Reference in New Issue