From e5b58c844cacce84b6118b3219b0d1f86e6f74a2 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Wed, 22 Oct 2025 14:10:03 -0500 Subject: [PATCH] feat(complement-race): add react-spring animations to local train for smooth movement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../components/RaceTrack/TrainAndCars.tsx | 44 ++++++++------- .../hooks/useTrainTransforms.ts | 56 +++++++++++++------ .../arcade-games/complement-race/Provider.tsx | 7 ++- 3 files changed, 68 insertions(+), 39 deletions(-) diff --git a/apps/web/src/app/arcade/complement-race/components/RaceTrack/TrainAndCars.tsx b/apps/web/src/app/arcade/complement-race/components/RaceTrack/TrainAndCars.tsx index 58c22a95..bdf34237 100644 --- a/apps/web/src/app/arcade/complement-race/components/RaceTrack/TrainAndCars.tsx +++ b/apps/web/src/app/arcade/complement-race/components/RaceTrack/TrainAndCars.tsx @@ -1,21 +1,23 @@ 'use client' 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 { Passenger } from '@/arcade-games/complement-race/types' interface TrainCarTransform { - x: number - y: number - rotation: number - position: number - opacity: number + x: SpringValue + y: SpringValue + rotation: SpringValue + position: SpringValue + opacity: SpringValue } interface TrainTransform { - x: number - y: number - rotation: number + x: SpringValue + y: SpringValue + rotation: SpringValue } interface TrainAndCarsProps { @@ -30,7 +32,7 @@ interface TrainAndCarsProps { trainCars: TrainCarTransform[] boardedPassengers: Passenger[] trainTransform: TrainTransform - locomotiveOpacity: number + locomotiveOpacity: SpringValue playerEmoji: string momentum: number } @@ -72,14 +74,14 @@ export const TrainAndCars = memo( const passenger = boardedPassengers[carIndex] return ( - `translate(${x}, ${y}) rotate(${rot}) scale(-1, 1)` + )} opacity={carTransform.opacity} - style={{ - transition: 'opacity 0.5s ease-in', - }} > {/* Train car */} )} - + ) })} {/* Locomotive - rendered last so it appears on top */} - `translate(${x}, ${y}) rotate(${rot}) scale(-1, 1)` + )} opacity={locomotiveOpacity} - style={{ - transition: 'opacity 0.5s ease-in', - }} > {/* Train locomotive */} ))} - + ) } diff --git a/apps/web/src/app/arcade/complement-race/hooks/useTrainTransforms.ts b/apps/web/src/app/arcade/complement-race/hooks/useTrainTransforms.ts index a13cbd4b..96083a8e 100644 --- a/apps/web/src/app/arcade/complement-race/hooks/useTrainTransforms.ts +++ b/apps/web/src/app/arcade/complement-race/hooks/useTrainTransforms.ts @@ -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' interface TrainTransform { @@ -27,22 +28,24 @@ export function useTrainTransforms({ maxCars, carSpacing, }: UseTrainTransformsParams) { - const [trainTransform, setTrainTransform] = useState({ - x: 50, - y: 300, - rotation: 0, - }) - - // Update train position and rotation - useEffect(() => { - if (pathRef.current) { - const transform = trackGenerator.getTrainTransform(pathRef.current, trainPosition) - setTrainTransform(transform) + // Calculate target locomotive transform + const locomotiveTarget = useMemo(() => { + if (!pathRef.current) { + return { x: 50, y: 300, rotation: 0 } } + return trackGenerator.getTrainTransform(pathRef.current, trainPosition) }, [trainPosition, trackGenerator, pathRef]) - // Calculate train car transforms (each car follows behind the locomotive) - const trainCars = useMemo((): TrainCarTransform[] => { + // Animated spring for smooth locomotive movement + 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) { return Array.from({ length: maxCars }, () => ({ x: 0, @@ -86,8 +89,21 @@ export function useTrainTransforms({ }) }, [trainPosition, trackGenerator, pathRef, maxCars, carSpacing]) - // Calculate locomotive opacity (fade in/out through tunnels) - const locomotiveOpacity = useMemo(() => { + // Animated springs for smooth car movement + 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 fadeInEnd = 8 const fadeOutStart = 92 @@ -109,9 +125,15 @@ export function useTrainTransforms({ return 1 // Default to fully visible }, [trainPosition]) + // Animated spring for smooth locomotive opacity + const locomotiveOpacity = useSpring({ + opacity: locomotiveOpacityTarget, + config: { tension: 280, friction: 60 }, + }) + return { trainTransform, trainCars, - locomotiveOpacity, + locomotiveOpacity: locomotiveOpacity.opacity, } } diff --git a/apps/web/src/arcade-games/complement-race/Provider.tsx b/apps/web/src/arcade-games/complement-race/Provider.tsx index 7ee546c3..2049278d 100644 --- a/apps/web/src/arcade-games/complement-race/Provider.tsx +++ b/apps/web/src/arcade-games/complement-race/Provider.tsx @@ -486,7 +486,12 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) { if (multiplayerState.gamePhase !== 'playing') { 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 useEffect(() => {