feat(complement-race): add react-spring animations to local train for smooth movement

Apply the same react-spring animation treatment to the local player's train
that was previously added to ghost trains. This eliminates the low-resolution
"choppy" movement by smoothly interpolating between position updates.

Changes:
- Convert useTrainTransforms hook to use react-spring (useSpring, useSprings)
- Update TrainAndCars component to use animated.g and to() interpolation
- Animate locomotive position, rotation, and opacity
- Animate all train cars with individual springs
- Use tension:280, friction:60 config for smooth but responsive movement

Both local and ghost trains now have butter-smooth 60fps interpolated movement.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-10-22 14:10:03 -05:00
parent 09df96922e
commit e5b58c844c
3 changed files with 68 additions and 39 deletions

View File

@ -1,21 +1,23 @@
'use client' 'use client'
import { memo } from 'react' import { memo } from 'react'
import { animated, to } from '@react-spring/web'
import type { SpringValue } from '@react-spring/web'
import type { BoardingAnimation, DisembarkingAnimation } from '../../hooks/usePassengerAnimations' import type { BoardingAnimation, DisembarkingAnimation } from '../../hooks/usePassengerAnimations'
import type { Passenger } from '@/arcade-games/complement-race/types' import type { Passenger } from '@/arcade-games/complement-race/types'
interface TrainCarTransform { interface TrainCarTransform {
x: number x: SpringValue<number>
y: number y: SpringValue<number>
rotation: number rotation: SpringValue<number>
position: number position: SpringValue<number>
opacity: number opacity: SpringValue<number>
} }
interface TrainTransform { interface TrainTransform {
x: number x: SpringValue<number>
y: number y: SpringValue<number>
rotation: number rotation: SpringValue<number>
} }
interface TrainAndCarsProps { interface TrainAndCarsProps {
@ -30,7 +32,7 @@ interface TrainAndCarsProps {
trainCars: TrainCarTransform[] trainCars: TrainCarTransform[]
boardedPassengers: Passenger[] boardedPassengers: Passenger[]
trainTransform: TrainTransform trainTransform: TrainTransform
locomotiveOpacity: number locomotiveOpacity: SpringValue<number>
playerEmoji: string playerEmoji: string
momentum: number momentum: number
} }
@ -72,14 +74,14 @@ export const TrainAndCars = memo(
const passenger = boardedPassengers[carIndex] const passenger = boardedPassengers[carIndex]
return ( return (
<g <animated.g
key={`train-car-${carIndex}`} key={`train-car-${carIndex}`}
data-component="train-car" data-component="train-car"
transform={`translate(${carTransform.x}, ${carTransform.y}) rotate(${carTransform.rotation}) scale(-1, 1)`} transform={to(
[carTransform.x, carTransform.y, carTransform.rotation],
(x, y, rot) => `translate(${x}, ${y}) rotate(${rot}) scale(-1, 1)`
)}
opacity={carTransform.opacity} opacity={carTransform.opacity}
style={{
transition: 'opacity 0.5s ease-in',
}}
> >
{/* Train car */} {/* Train car */}
<text <text
@ -114,18 +116,18 @@ export const TrainAndCars = memo(
{passenger.avatar} {passenger.avatar}
</text> </text>
)} )}
</g> </animated.g>
) )
})} })}
{/* Locomotive - rendered last so it appears on top */} {/* Locomotive - rendered last so it appears on top */}
<g <animated.g
data-component="locomotive-group" data-component="locomotive-group"
transform={`translate(${trainTransform.x}, ${trainTransform.y}) rotate(${trainTransform.rotation}) scale(-1, 1)`} transform={to(
[trainTransform.x, trainTransform.y, trainTransform.rotation],
(x, y, rot) => `translate(${x}, ${y}) rotate(${rot}) scale(-1, 1)`
)}
opacity={locomotiveOpacity} opacity={locomotiveOpacity}
style={{
transition: 'opacity 0.5s ease-in',
}}
> >
{/* Train locomotive */} {/* Train locomotive */}
<text <text
@ -191,7 +193,7 @@ export const TrainAndCars = memo(
}} }}
/> />
))} ))}
</g> </animated.g>
</> </>
) )
} }

View File

@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from 'react' import { useMemo } from 'react'
import { useSpring, useSprings } from '@react-spring/web'
import type { RailroadTrackGenerator } from '../lib/RailroadTrackGenerator' import type { RailroadTrackGenerator } from '../lib/RailroadTrackGenerator'
interface TrainTransform { interface TrainTransform {
@ -27,22 +28,24 @@ export function useTrainTransforms({
maxCars, maxCars,
carSpacing, carSpacing,
}: UseTrainTransformsParams) { }: UseTrainTransformsParams) {
const [trainTransform, setTrainTransform] = useState<TrainTransform>({ // Calculate target locomotive transform
x: 50, const locomotiveTarget = useMemo<TrainTransform>(() => {
y: 300, if (!pathRef.current) {
rotation: 0, return { x: 50, y: 300, rotation: 0 }
})
// Update train position and rotation
useEffect(() => {
if (pathRef.current) {
const transform = trackGenerator.getTrainTransform(pathRef.current, trainPosition)
setTrainTransform(transform)
} }
return trackGenerator.getTrainTransform(pathRef.current, trainPosition)
}, [trainPosition, trackGenerator, pathRef]) }, [trainPosition, trackGenerator, pathRef])
// Calculate train car transforms (each car follows behind the locomotive) // Animated spring for smooth locomotive movement
const trainCars = useMemo((): TrainCarTransform[] => { const trainTransform = useSpring({
x: locomotiveTarget.x,
y: locomotiveTarget.y,
rotation: locomotiveTarget.rotation,
config: { tension: 280, friction: 60 },
})
// Calculate target transforms for train cars (each car follows behind the locomotive)
const carTargets = useMemo((): TrainCarTransform[] => {
if (!pathRef.current) { if (!pathRef.current) {
return Array.from({ length: maxCars }, () => ({ return Array.from({ length: maxCars }, () => ({
x: 0, x: 0,
@ -86,8 +89,21 @@ export function useTrainTransforms({
}) })
}, [trainPosition, trackGenerator, pathRef, maxCars, carSpacing]) }, [trainPosition, trackGenerator, pathRef, maxCars, carSpacing])
// Calculate locomotive opacity (fade in/out through tunnels) // Animated springs for smooth car movement
const locomotiveOpacity = useMemo(() => { const trainCars = useSprings(
carTargets.length,
carTargets.map((target) => ({
x: target.x,
y: target.y,
rotation: target.rotation,
opacity: target.opacity,
position: target.position,
config: { tension: 280, friction: 60 },
}))
)
// Calculate target locomotive opacity (fade in/out through tunnels)
const locomotiveOpacityTarget = useMemo(() => {
const fadeInStart = 3 const fadeInStart = 3
const fadeInEnd = 8 const fadeInEnd = 8
const fadeOutStart = 92 const fadeOutStart = 92
@ -109,9 +125,15 @@ export function useTrainTransforms({
return 1 // Default to fully visible return 1 // Default to fully visible
}, [trainPosition]) }, [trainPosition])
// Animated spring for smooth locomotive opacity
const locomotiveOpacity = useSpring({
opacity: locomotiveOpacityTarget,
config: { tension: 280, friction: 60 },
})
return { return {
trainTransform, trainTransform,
trainCars, trainCars,
locomotiveOpacity, locomotiveOpacity: locomotiveOpacity.opacity,
} }
} }

View File

@ -486,7 +486,12 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
if (multiplayerState.gamePhase !== 'playing') { if (multiplayerState.gamePhase !== 'playing') {
hasInitializedPositionRef.current = false hasInitializedPositionRef.current = false
} }
}, [multiplayerState.gamePhase, multiplayerState.config.style, multiplayerState.players, localPlayerId]) }, [
multiplayerState.gamePhase,
multiplayerState.config.style,
multiplayerState.players,
localPlayerId,
])
// Initialize game start time when game becomes active // Initialize game start time when game becomes active
useEffect(() => { useEffect(() => {