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:
@@ -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>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user