From 7668cc9b113b3eae2acb1b852b0ad48c979e6604 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Wed, 22 Oct 2025 10:59:39 -0500 Subject: [PATCH] feat(complement-race): add ghost trains for multiplayer visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement semi-transparent ghost trains to show other players' positions in steam sprint multiplayer mode. Players can now see opponents racing alongside them in real-time. Changes: - Create GhostTrain component (35% opacity with player name/score labels) - Expose multiplayer state (players, localPlayerId) in Provider context - Render ghost trains for all active non-local players in SteamTrainJourney - Filter by isActive to only show currently playing opponents Addresses multiplayer visibility gap from COMPLEMENT_RACE_MULTIPLAYER_REVIEW.md (Priority: HIGH - "Breaks Multiplayer Experience") 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../components/RaceTrack/GhostTrain.tsx | 114 ++++++++++++++++++ .../RaceTrack/SteamTrainJourney.tsx | 24 +++- .../arcade-games/complement-race/Provider.tsx | 4 + 3 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/app/arcade/complement-race/components/RaceTrack/GhostTrain.tsx 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,