From 1613912740756d984205e3625791c1d8a2a6fa51 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Wed, 1 Oct 2025 08:52:44 -0500 Subject: [PATCH] feat: add train car system with smooth boarding/disembarking animations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement a complete passenger train car system with animated boarding and disembarking using react-spring. Train Car System: - Each passenger gets their own train car (🚃) following the track - Cars follow curved track with proper rotation and spacing (7%) - Cars fade in as they emerge from tunnel to avoid visual pile-up - Compact car sizing (65px) and passenger sizing (42px) - Maximum 5 cars per train for performance Boarding Animations: - Smooth spring-animated transitions from station to train car - Passengers fly from station platform to assigned car (800ms) - Passengers hidden from station and car during animation - Animated passenger tracked separately to avoid flickering Disembarking Animations: - Reverse animation from train car to destination station - Green glow effect for delivered passengers - Celebration animation plays after landing at station - Smooth transition prevents "ghost" effect Station Passenger Display: - Positioned directly above station circle (30px offset) - Compact 55px size for better visual balance - Tight horizontal spacing (28px) when multiple waiting - Passengers properly excluded during boarding/disembarking Visual Improvements: - Removed decorative rock/bush elements from mountains - Cleaner mountain tunnel appearance - Better layering of animations between stations and train 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../RaceTrack/SteamTrainJourney.tsx | 385 ++++++++++++++---- 1 file changed, 304 insertions(+), 81 deletions(-) diff --git a/apps/web/src/app/games/complement-race/components/RaceTrack/SteamTrainJourney.tsx b/apps/web/src/app/games/complement-race/components/RaceTrack/SteamTrainJourney.tsx index 8719f61d..698a4b02 100644 --- a/apps/web/src/app/games/complement-race/components/RaceTrack/SteamTrainJourney.tsx +++ b/apps/web/src/app/games/complement-race/components/RaceTrack/SteamTrainJourney.tsx @@ -1,6 +1,7 @@ 'use client' import { useEffect, useRef, useState } from 'react' +import { useSpring, animated } from '@react-spring/web' import { useSteamJourney } from '../../hooks/useSteamJourney' import { useComplementRace } from '../../context/ComplementRaceContext' import { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator' @@ -10,6 +11,76 @@ import { generateLandmarks, type Landmark } from '../../lib/landmarks' import { PressureGauge } from '../PressureGauge' import { useGameMode } from '@/contexts/GameModeContext' import { useUserProfile } from '@/contexts/UserProfileContext' +import type { Passenger } from '../../lib/gameTypes' + +interface BoardingAnimation { + passenger: Passenger + fromX: number + fromY: number + toX: number + toY: number + carIndex: number + startTime: number +} + +interface DisembarkingAnimation { + passenger: Passenger + fromX: number + fromY: number + toX: number + toY: number + startTime: number +} + +function BoardingPassengerAnimation({ animation }: { animation: BoardingAnimation }) { + const spring = useSpring({ + from: { x: animation.fromX, y: animation.fromY, opacity: 1 }, + to: { x: animation.toX, y: animation.toY, opacity: 1 }, + config: { tension: 120, friction: 14 } + }) + + return ( + + {animation.passenger.avatar} + + ) +} + +function DisembarkingPassengerAnimation({ animation }: { animation: DisembarkingAnimation }) { + const spring = useSpring({ + from: { x: animation.fromX, y: animation.fromY, opacity: 1 }, + to: { x: animation.toX, y: animation.toY, opacity: 1 }, + config: { tension: 120, friction: 14 } + }) + + return ( + + {animation.passenger.avatar} + + ) +} interface SteamTrainJourneyProps { momentum: number @@ -50,6 +121,9 @@ export function SteamTrainJourney({ momentum, trainPosition, pressure, elapsedTi const [stationPositions, setStationPositions] = useState>([]) const [landmarks, setLandmarks] = useState([]) const [landmarkPositions, setLandmarkPositions] = useState>([]) + const [boardingAnimations, setBoardingAnimations] = useState>(new Map()) + const [disembarkingAnimations, setDisembarkingAnimations] = useState>(new Map()) + const previousPassengersRef = useRef([]) // Generate landmarks when route changes useEffect(() => { @@ -117,6 +191,153 @@ export function SteamTrainJourney({ momentum, trainPosition, pressure, elapsedTi } }, [trainPosition, trackGenerator]) + // Detect passengers boarding and start animations + useEffect(() => { + if (!pathRef.current || stationPositions.length === 0) return + + const previousPassengers = previousPassengersRef.current + const currentPassengers = state.passengers + + // Find newly boarded passengers + const newlyBoarded = currentPassengers.filter(curr => { + const prev = previousPassengers.find(p => p.id === curr.id) + return curr.isBoarded && prev && !prev.isBoarded + }) + + // Start animation for each newly boarded passenger + newlyBoarded.forEach(passenger => { + // Find origin station + const originStation = state.stations.find(s => s.id === passenger.originStationId) + if (!originStation) return + + const stationIndex = state.stations.indexOf(originStation) + const stationPos = stationPositions[stationIndex] + if (!stationPos) return + + // Find which car this passenger will be in + const boardedPassengers = currentPassengers.filter(p => p.isBoarded && !p.isDelivered) + const carIndex = boardedPassengers.indexOf(passenger) + + // Calculate train car position + const carPosition = Math.max(0, trainPosition - (carIndex + 1) * 7) // 7% spacing + const carTransform = trackGenerator.getTrainTransform(pathRef.current!, carPosition) + + // Create boarding animation + const animation: BoardingAnimation = { + passenger, + fromX: stationPos.x, + fromY: stationPos.y - 30, + toX: carTransform.x, + toY: carTransform.y, + carIndex, + startTime: Date.now() + } + + setBoardingAnimations(prev => { + const next = new Map(prev) + next.set(passenger.id, animation) + return next + }) + + // Remove animation after 800ms + setTimeout(() => { + setBoardingAnimations(prev => { + const next = new Map(prev) + next.delete(passenger.id) + return next + }) + }, 800) + }) + + // Update ref + previousPassengersRef.current = currentPassengers + }, [state.passengers, state.stations, stationPositions, trainPosition, trackGenerator, pathRef]) + + // Detect passengers disembarking and start animations + useEffect(() => { + if (!pathRef.current || stationPositions.length === 0) return + + const previousPassengers = previousPassengersRef.current + const currentPassengers = state.passengers + + // Find newly delivered passengers + const newlyDelivered = currentPassengers.filter(curr => { + const prev = previousPassengers.find(p => p.id === curr.id) + return curr.isDelivered && prev && !prev.isDelivered + }) + + // Start animation for each newly delivered passenger + newlyDelivered.forEach(passenger => { + // Find destination station + const destinationStation = state.stations.find(s => s.id === passenger.destinationStationId) + if (!destinationStation) return + + const stationIndex = state.stations.indexOf(destinationStation) + const stationPos = stationPositions[stationIndex] + if (!stationPos) return + + // Find which car this passenger was in (before delivery) + const prevBoardedPassengers = previousPassengers.filter(p => p.isBoarded && !p.isDelivered) + const carIndex = prevBoardedPassengers.findIndex(p => p.id === passenger.id) + if (carIndex === -1) return + + // Calculate train car position at time of disembarking + const carPosition = Math.max(0, trainPosition - (carIndex + 1) * 7) // 7% spacing + const carTransform = trackGenerator.getTrainTransform(pathRef.current!, carPosition) + + // Create disembarking animation (from car to station) + const animation: DisembarkingAnimation = { + passenger, + fromX: carTransform.x, + fromY: carTransform.y, + toX: stationPos.x, + toY: stationPos.y - 30, + startTime: Date.now() + } + + setDisembarkingAnimations(prev => { + const next = new Map(prev) + next.set(passenger.id, animation) + return next + }) + + // Remove animation after 800ms + setTimeout(() => { + setDisembarkingAnimations(prev => { + const next = new Map(prev) + next.delete(passenger.id) + return next + }) + }, 800) + }) + }, [state.passengers, state.stations, stationPositions, trainPosition, trackGenerator, pathRef]) + + // Calculate train car transforms (each car follows behind the locomotive) + const maxCars = 5 // Maximum passengers per route + const carSpacing = 7 // Percentage of track between cars + const trainCars = Array.from({ length: maxCars }).map((_, carIndex) => { + if (!pathRef.current) return { x: 0, y: 0, rotation: 0, position: 0, opacity: 0 } + + // Calculate position for this car (behind the locomotive) + const carPosition = Math.max(0, trainPosition - (carIndex + 1) * carSpacing) + + // Calculate opacity: fade in as car emerges from tunnel (after 3% of track) + const fadeStartPosition = 3 + const fadeEndPosition = 8 + let opacity = 0 + if (carPosition > fadeEndPosition) { + opacity = 1 + } else if (carPosition > fadeStartPosition) { + opacity = (carPosition - fadeStartPosition) / (fadeEndPosition - fadeStartPosition) + } + + return { + ...trackGenerator.getTrainTransform(pathRef.current, carPosition), + position: carPosition, + opacity + } + }) + if (!trackData) return null return ( @@ -283,31 +504,6 @@ export function SteamTrainJourney({ momentum, trainPosition, pressure, elapsedTi fill="url(#mountainGradientLeft)" /> - {/* Rocky texture - vertical cracks */} - {[0, 1, 2, 3].map((i) => ( - - ))} - - {/* Vegetation/bushes at base */} - {[0, 1, 2].map((i) => ( - - ))} {/* Tunnel depth/interior (dark entrance) */} - {/* Rocky texture - vertical cracks */} - {[0, 1, 2, 3].map((i) => ( - - ))} - - {/* Vegetation/bushes at base */} - {[0, 1, 2].map((i) => ( - - ))} {/* Tunnel depth/interior (dark entrance) */} { const station = state.stations[index] - // Find passengers waiting at this station + // Find passengers waiting at this station (exclude currently boarding) const waitingPassengers = state.passengers.filter(p => - p.originStationId === station?.id && !p.isBoarded && !p.isDelivered + p.originStationId === station?.id && !p.isBoarded && !p.isDelivered && !boardingAnimations.has(p.id) ) - // Find passengers delivered at this station + // Find passengers delivered at this station (exclude currently disembarking) const deliveredPassengers = state.passengers.filter(p => - p.destinationStationId === station?.id && p.isDelivered + p.destinationStationId === station?.id && p.isDelivered && !disembarkingAnimations.has(p.id) ) return ( @@ -552,11 +723,11 @@ export function SteamTrainJourney({ momentum, trainPosition, pressure, elapsedTi {waitingPassengers.map((passenger, pIndex) => ( ( + {/* Boarding animations - passengers moving from station to train car */} + {Array.from(boardingAnimations.values()).map(animation => ( + + ))} + + {/* Disembarking animations - passengers moving from train car to station */} + {Array.from(disembarkingAnimations.values()).map(animation => ( + + ))} + + {/* Train cars - render in reverse order so locomotive appears on top */} + {trainCars.map((carTransform, carIndex) => { + // Get boarded passengers + const boardedPassengers = state.passengers.filter(p => p.isBoarded && !p.isDelivered) + // Assign passenger to this car (if one exists for this car index) + const passenger = boardedPassengers[carIndex] + + return ( + + {/* Train car */} + + 🚃 + + + {/* Passenger inside this car (hide if currently boarding) */} + {passenger && !boardingAnimations.has(passenger.id) && ( + + {passenger.avatar} + + )} + + ) + })} + + {/* Locomotive - rendered last so it appears on top */} + {/* Train locomotive */} - {/* Boarded passengers riding on the train */} - {state.passengers.filter(p => p.isBoarded && !p.isDelivered).map((passenger, index) => ( - - {passenger.avatar} - - ))} - {/* Steam puffs - positioned at smokestack, layered over train */} {momentum > 10 && ( <> @@ -708,6 +930,7 @@ export function SteamTrainJourney({ momentum, trainPosition, pressure, elapsedTi s.id === passenger.originStationId)} destinationStation={state.stations.find(s => s.id === passenger.destinationStationId)} /> ))}