diff --git a/apps/web/src/app/arcade/complement-race/components/RaceTrack/GhostTrain.tsx b/apps/web/src/app/arcade/complement-race/components/RaceTrack/GhostTrain.tsx new file mode 100644 index 00000000..c1cdaad2 --- /dev/null +++ b/apps/web/src/app/arcade/complement-race/components/RaceTrack/GhostTrain.tsx @@ -0,0 +1,114 @@ +'use client' + +import { useMemo, useRef } from 'react' +import type { PlayerState } from '@/arcade-games/complement-race/types' +import type { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator' + +interface GhostTrainProps { + player: PlayerState + trainPosition: number + trackGenerator: RailroadTrackGenerator + pathRef: React.RefObject +} + +/** + * GhostTrain - Renders a semi-transparent train for other players in multiplayer + * Shows opponent positions in real-time during steam sprint races + */ +export function GhostTrain({ player, trainPosition, trackGenerator, pathRef }: GhostTrainProps) { + const ghostRef = useRef(null) + + // Calculate train transform using same logic as local player + const trainTransform = useMemo(() => { + if (!pathRef.current) { + return { x: 0, y: 0, rotation: 0, opacity: 0 } + } + + const pathLength = pathRef.current.getTotalLength() + const targetDistance = (trainPosition / 100) * pathLength + const point = pathRef.current.getPointAtLength(targetDistance) + + // Calculate tangent for rotation + const tangentDelta = 1 + const tangentDistance = Math.min(targetDistance + tangentDelta, pathLength) + const tangentPoint = pathRef.current.getPointAtLength(tangentDistance) + const rotation = + (Math.atan2(tangentPoint.y - point.y, tangentPoint.x - point.x) * 180) / Math.PI + + return { + x: point.x, + y: point.y, + rotation, + opacity: 0.35, // Ghost effect - 35% opacity + } + }, [trainPosition, pathRef]) + + // Don't render if position data isn't ready + if (trainTransform.opacity === 0) { + return null + } + + return ( + + {/* Ghost locomotive */} + + 🚂 + + + {/* Player name label - positioned above train */} + + {player.name || `Player ${player.id.slice(0, 4)}`} + + + {/* Score indicator - positioned below train */} + + {player.score} + + + ) +} diff --git a/apps/web/src/app/arcade/complement-race/components/RaceTrack/SteamTrainJourney.tsx b/apps/web/src/app/arcade/complement-race/components/RaceTrack/SteamTrainJourney.tsx index 4fde2a94..e649f0e8 100644 --- a/apps/web/src/app/arcade/complement-race/components/RaceTrack/SteamTrainJourney.tsx +++ b/apps/web/src/app/arcade/complement-race/components/RaceTrack/SteamTrainJourney.tsx @@ -20,6 +20,7 @@ import { GameHUD } from './GameHUD' import { RailroadTrackPath } from './RailroadTrackPath' import { TrainAndCars } from './TrainAndCars' import { TrainTerrainBackground } from './TrainTerrainBackground' +import { GhostTrain } from './GhostTrain' const BoardingPassengerAnimation = memo(({ animation }: { animation: BoardingAnimation }) => { const spring = useSpring({ @@ -92,7 +93,7 @@ export function SteamTrainJourney({ currentQuestion, currentInput, }: SteamTrainJourneyProps) { - const { state } = useComplementRace() + const { state, multiplayerState, localPlayerId } = useComplementRace() const { getSkyGradient, getTimeOfDayPeriod } = useSteamJourney() const _skyGradient = getSkyGradient() @@ -191,6 +192,14 @@ export function SteamTrainJourney({ [] ) + // Get other players for ghost trains (filter out local player) + const otherPlayers = useMemo(() => { + if (!multiplayerState?.players || !localPlayerId) return [] + return Object.entries(multiplayerState.players) + .filter(([playerId, player]) => playerId !== localPlayerId && player.isActive) + .map(([_, player]) => player) + }, [multiplayerState?.players, localPlayerId]) + if (!trackData) return null return ( @@ -252,7 +261,18 @@ export function SteamTrainJourney({ disembarkingAnimations={disembarkingAnimations} /> - {/* Train, cars, and passenger animations */} + {/* Ghost trains - other players in multiplayer */} + {otherPlayers.map((player) => ( + + ))} + + {/* Train, cars, and passenger animations - local player */} void // Compatibility layer lastError: string | null startGame: () => void @@ -914,6 +916,8 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) { const contextValue: ComplementRaceContextValue = { state: compatibleState, // Use transformed state + multiplayerState, // Expose raw multiplayer state for ghost trains + localPlayerId, // Expose local player ID for filtering dispatch, lastError, startGame,