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.
|
**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
|
## Game Settings Persistence
|
||||||
|
|
||||||
When working on arcade room game settings, refer to:
|
When working on arcade room game settings, refer to:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client'
|
'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 { css } from '@styled/css'
|
||||||
import { forceCollide, forceSimulation, forceX, forceY, type SimulationNodeDatum } from 'd3-force'
|
import { forceCollide, forceSimulation, forceX, forceY, type SimulationNodeDatum } from 'd3-force'
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
|
@ -1314,45 +1314,56 @@ export function MapRenderer({
|
||||||
effectiveHotColdEnabled
|
effectiveHotColdEnabled
|
||||||
)
|
)
|
||||||
|
|
||||||
// Debounced rotation state to prevent flicker from feedback type flickering
|
// === Crosshair rotation using spring-for-speed, manual-integration-for-angle pattern ===
|
||||||
// Start rotating immediately when speed > 0, but delay stopping by 150ms
|
// This gives smooth speed transitions without the issues of CSS animation or
|
||||||
const rawShouldRotate = crosshairHeatStyle.rotationSpeed > 0
|
// calling spring.start() 60 times per second.
|
||||||
const [debouncedShouldRotate, setDebouncedShouldRotate] = useState(false)
|
//
|
||||||
const stopTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (rawShouldRotate) {
|
rotationSpeed.start(targetSpeedDegPerSec)
|
||||||
// Start immediately
|
}, [targetSpeedDegPerSec, rotationSpeed])
|
||||||
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
|
// requestAnimationFrame loop to integrate angle from speed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
let lastTime = performance.now()
|
||||||
if (stopTimeoutRef.current) {
|
let frameId: number
|
||||||
clearTimeout(stopTimeoutRef.current)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Calculate CSS animation duration from rotation speed
|
const loop = (now: number) => {
|
||||||
// rotationSpeed is degrees per frame at 60fps
|
const dt = (now - lastTime) / 1000 // seconds
|
||||||
// duration = 360 degrees / (speed * 60 frames) seconds
|
lastTime = now
|
||||||
const rotationDuration =
|
|
||||||
crosshairHeatStyle.rotationSpeed > 0 ? 360 / (crosshairHeatStyle.rotationSpeed * 60) : 1 // fallback, won't be used when paused
|
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
|
// Note: Zoom animation with pause/resume is now handled by useMagnifierZoom hook
|
||||||
// This effect only updates the remaining spring properties: opacity, position, movement multiplier
|
// 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 */}
|
{/* Enhanced SVG crosshair with heat effects - uses spring-driven rotation */}
|
||||||
<svg
|
<animated.svg
|
||||||
width="32"
|
width="32"
|
||||||
height="32"
|
height="32"
|
||||||
viewBox="0 0 32 32"
|
viewBox="0 0 32 32"
|
||||||
style={{
|
style={{
|
||||||
filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.5)',
|
filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.5)',
|
||||||
animation: `crosshairSpin ${rotationDuration}s linear infinite`,
|
transform: rotationAngle.to((a) => `rotate(${a}deg)`),
|
||||||
animationPlayState: debouncedShouldRotate ? 'running' : 'paused',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Outer ring */}
|
{/* Outer ring */}
|
||||||
|
|
@ -4110,7 +4120,7 @@ export function MapRenderer({
|
||||||
fill={crosshairHeatStyle.color}
|
fill={crosshairHeatStyle.color}
|
||||||
opacity={crosshairHeatStyle.opacity}
|
opacity={crosshairHeatStyle.opacity}
|
||||||
/>
|
/>
|
||||||
</svg>
|
</animated.svg>
|
||||||
{/* Fire particles around crosshair */}
|
{/* Fire particles around crosshair */}
|
||||||
{crosshairHeatStyle.showFire && (
|
{crosshairHeatStyle.showFire && (
|
||||||
<div style={{ position: 'absolute', left: 0, top: 0, width: '32px', height: '32px' }}>
|
<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 */}
|
{/* Enhanced SVG crosshair with heat effects - uses spring-driven rotation */}
|
||||||
<svg
|
<animated.svg
|
||||||
width="40"
|
width="40"
|
||||||
height="40"
|
height="40"
|
||||||
viewBox="0 0 40 40"
|
viewBox="0 0 40 40"
|
||||||
style={{
|
style={{
|
||||||
filter: 'drop-shadow(0 1px 3px rgba(0,0,0,0.6))',
|
filter: 'drop-shadow(0 1px 3px rgba(0,0,0,0.6))',
|
||||||
animation: `crosshairSpin ${rotationDuration}s linear infinite`,
|
transform: rotationAngle.to((a) => `rotate(${a}deg)`),
|
||||||
animationPlayState: debouncedShouldRotate ? 'running' : 'paused',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Outer ring */}
|
{/* Outer ring */}
|
||||||
|
|
@ -4308,7 +4317,7 @@ export function MapRenderer({
|
||||||
/>
|
/>
|
||||||
{/* Center dot */}
|
{/* Center dot */}
|
||||||
<circle cx="20" cy="20" r="2" fill={heatStyle.color} opacity={heatStyle.opacity} />
|
<circle cx="20" cy="20" r="2" fill={heatStyle.color} opacity={heatStyle.opacity} />
|
||||||
</svg>
|
</animated.svg>
|
||||||
{/* Fire particles around crosshair */}
|
{/* Fire particles around crosshair */}
|
||||||
{heatStyle.showFire && (
|
{heatStyle.showFire && (
|
||||||
<div
|
<div
|
||||||
|
|
@ -4639,12 +4648,11 @@ export function MapRenderer({
|
||||||
)}
|
)}
|
||||||
{/* Crosshair with separate translation and rotation */}
|
{/* Crosshair with separate translation and rotation */}
|
||||||
{/* Outer <g> handles translation (follows cursor) */}
|
{/* 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 transform={`translate(${cursorSvgX}, ${cursorSvgY})`}>
|
||||||
<g
|
<animated.g
|
||||||
style={{
|
style={{
|
||||||
animation: `crosshairSpin ${rotationDuration}s linear infinite`,
|
transform: rotationAngle.to((a) => `rotate(${a}deg)`),
|
||||||
animationPlayState: debouncedShouldRotate ? 'running' : 'paused',
|
|
||||||
transformOrigin: '0 0',
|
transformOrigin: '0 0',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -4681,7 +4689,7 @@ export function MapRenderer({
|
||||||
vectorEffect="non-scaling-stroke"
|
vectorEffect="non-scaling-stroke"
|
||||||
opacity={heatStyle.opacity}
|
opacity={heatStyle.opacity}
|
||||||
/>
|
/>
|
||||||
</g>
|
</animated.g>
|
||||||
</g>
|
</g>
|
||||||
{/* Fire particles around crosshair when on_fire or found_it */}
|
{/* Fire particles around crosshair when on_fire or found_it */}
|
||||||
{heatStyle.showFire && (
|
{heatStyle.showFire && (
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue