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 c488b212..90f6404f 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 @@ -3,6 +3,7 @@ import { useEffect, useRef, useState, useMemo, memo } from 'react' import { useSpring, animated } from '@react-spring/web' import { useSteamJourney } from '../../hooks/useSteamJourney' +import { usePassengerAnimations, type BoardingAnimation, type DisembarkingAnimation } from '../../hooks/usePassengerAnimations' import { useComplementRace } from '../../context/ComplementRaceContext' import { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator' import { PassengerCard } from '../PassengerCard' @@ -13,25 +14,6 @@ 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 -} - const BoardingPassengerAnimation = memo(({ animation }: { animation: BoardingAnimation }) => { const spring = useSpring({ from: { x: animation.fromX, y: animation.fromY, opacity: 1 }, @@ -123,9 +105,16 @@ 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(state.passengers) + + // Passenger animations (extracted to hook) + const { boardingAnimations, disembarkingAnimations } = usePassengerAnimations({ + passengers: state.passengers, + stations: state.stations, + stationPositions, + trainPosition, + trackGenerator, + pathRef + }) // Generate landmarks when route changes useEffect(() => { @@ -246,119 +235,6 @@ export function SteamTrainJourney({ momentum, trainPosition, pressure, elapsedTi } }, [trainPosition, trackGenerator]) - // Detect passengers boarding/disembarking and start animations (consolidated for performance) - 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 - }) - - // 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 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) - }) - - // 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) - }) - - // Update ref - previousPassengersRef.current = currentPassengers - }, [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 diff --git a/apps/web/src/app/games/complement-race/hooks/usePassengerAnimations.ts b/apps/web/src/app/games/complement-race/hooks/usePassengerAnimations.ts new file mode 100644 index 00000000..62d05fab --- /dev/null +++ b/apps/web/src/app/games/complement-race/hooks/usePassengerAnimations.ts @@ -0,0 +1,162 @@ +import { useEffect, useRef, useState } from 'react' +import type { Passenger, Station } from '../lib/gameTypes' +import type { RailroadTrackGenerator } from '../lib/RailroadTrackGenerator' + +export interface BoardingAnimation { + passenger: Passenger + fromX: number + fromY: number + toX: number + toY: number + carIndex: number + startTime: number +} + +export interface DisembarkingAnimation { + passenger: Passenger + fromX: number + fromY: number + toX: number + toY: number + startTime: number +} + +interface UsePassengerAnimationsParams { + passengers: Passenger[] + stations: Station[] + stationPositions: Array<{ x: number; y: number }> + trainPosition: number + trackGenerator: RailroadTrackGenerator + pathRef: React.RefObject +} + +export function usePassengerAnimations({ + passengers, + stations, + stationPositions, + trainPosition, + trackGenerator, + pathRef +}: UsePassengerAnimationsParams) { + const [boardingAnimations, setBoardingAnimations] = useState>(new Map()) + const [disembarkingAnimations, setDisembarkingAnimations] = useState>(new Map()) + const previousPassengersRef = useRef(passengers) + + // Detect passengers boarding/disembarking and start animations + useEffect(() => { + if (!pathRef.current || stationPositions.length === 0) return + + const previousPassengers = previousPassengersRef.current + const currentPassengers = 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 + }) + + // 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 boarded passenger + newlyBoarded.forEach(passenger => { + // Find origin station + const originStation = stations.find(s => s.id === passenger.originStationId) + if (!originStation) return + + const stationIndex = 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) + }) + + // Start animation for each newly delivered passenger + newlyDelivered.forEach(passenger => { + // Find destination station + const destinationStation = stations.find(s => s.id === passenger.destinationStationId) + if (!destinationStation) return + + const stationIndex = 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) + }) + + // Update ref + previousPassengersRef.current = currentPassengers + }, [passengers, stations, stationPositions, trainPosition, trackGenerator, pathRef]) + + return { + boardingAnimations, + disembarkingAnimations + } +}