Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5bfcf990a | ||
|
|
00dc4b1d06 | ||
|
|
76063884af | ||
|
|
915d8a5343 | ||
|
|
028b0cb86f | ||
|
|
2bf00af952 | ||
|
|
1d229333bc | ||
|
|
0c67f63ac7 | ||
|
|
106b348585 | ||
|
|
7668cc9b11 | ||
|
|
93527e6e0b | ||
|
|
ef4ca57a6c |
28
CHANGELOG.md
28
CHANGELOG.md
@@ -1,3 +1,31 @@
|
||||
## [4.64.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.64.1...v4.64.2) (2025-10-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** use individual player positions for ghost trains ([00dc4b1](https://github.com/antialias/soroban-abacus-flashcards/commit/00dc4b1d06a4e1763deb16333a298145cafd9187))
|
||||
|
||||
## [4.64.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.64.0...v4.64.1) (2025-10-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** use local player instead of first player for train display ([915d8a5](https://github.com/antialias/soroban-abacus-flashcards/commit/915d8a5343e70a30c7a82bed645e6628fcc08a86))
|
||||
|
||||
## [4.64.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.63.11...v4.64.0) (2025-10-22)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **complement-race:** add ghost trains for multiplayer visibility ([7668cc9](https://github.com/antialias/soroban-abacus-flashcards/commit/7668cc9b113b3eae2acb1b852b0ad48c979e6604))
|
||||
|
||||
## [4.63.11](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.63.10...v4.63.11) (2025-10-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** actually filter by isActive instead of just id ([ef4ca57](https://github.com/antialias/soroban-abacus-flashcards/commit/ef4ca57a6c3f35d1bddc6a70952f478058fbc6b5))
|
||||
|
||||
## [4.63.10](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.63.9...v4.63.10) (2025-10-22)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, 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<SVGPathElement>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<SVGGElement>(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])
|
||||
|
||||
// Log only once when this ghost train first renders
|
||||
const hasLoggedRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (!hasLoggedRef.current && trainTransform.opacity > 0) {
|
||||
console.log('[GhostTrain] rendering:', player.name, 'at position:', trainPosition.toFixed(1))
|
||||
hasLoggedRef.current = true
|
||||
}
|
||||
}, [trainTransform.opacity, player.name, trainPosition])
|
||||
|
||||
// Don't render if position data isn't ready
|
||||
if (trainTransform.opacity === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<g
|
||||
ref={ghostRef}
|
||||
data-component="ghost-train"
|
||||
data-player-id={player.id}
|
||||
transform={`translate(${trainTransform.x}, ${trainTransform.y}) rotate(${trainTransform.rotation}) scale(-1, 1)`}
|
||||
opacity={trainTransform.opacity}
|
||||
style={{
|
||||
transition: 'opacity 0.3s ease-in',
|
||||
}}
|
||||
>
|
||||
{/* Ghost locomotive */}
|
||||
<text
|
||||
data-element="ghost-locomotive"
|
||||
x={0}
|
||||
y={0}
|
||||
textAnchor="middle"
|
||||
style={{
|
||||
fontSize: '100px',
|
||||
filter: `drop-shadow(0 2px 8px ${player.color || 'rgba(100, 100, 255, 0.6)'})`,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
🚂
|
||||
</text>
|
||||
|
||||
{/* Player name label - positioned above train */}
|
||||
<text
|
||||
data-element="ghost-label"
|
||||
x={0}
|
||||
y={-60}
|
||||
textAnchor="middle"
|
||||
style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
fill: player.color || '#6366f1',
|
||||
filter: 'drop-shadow(0 1px 2px rgba(0, 0, 0, 0.3))',
|
||||
pointerEvents: 'none',
|
||||
transform: 'scaleX(-1)', // Counter the parent's scaleX(-1)
|
||||
}}
|
||||
>
|
||||
{player.name || `Player ${player.id.slice(0, 4)}`}
|
||||
</text>
|
||||
|
||||
{/* Score indicator - positioned below train */}
|
||||
<text
|
||||
data-element="ghost-score"
|
||||
x={0}
|
||||
y={50}
|
||||
textAnchor="middle"
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
fill: 'rgba(255, 255, 255, 0.9)',
|
||||
filter: 'drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5))',
|
||||
pointerEvents: 'none',
|
||||
transform: 'scaleX(-1)', // Counter the parent's scaleX(-1)
|
||||
}}
|
||||
>
|
||||
{player.score}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { animated, useSpring } from '@react-spring/web'
|
||||
import { memo, useMemo, useRef, useState } from 'react'
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useGameMode } from '@/contexts/GameModeContext'
|
||||
import { useUserProfile } from '@/contexts/UserProfileContext'
|
||||
import { useComplementRace } from '@/arcade-games/complement-race/Provider'
|
||||
@@ -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()
|
||||
@@ -100,10 +101,20 @@ export function SteamTrainJourney({
|
||||
const { players } = useGameMode()
|
||||
const { profile: _profile } = useUserProfile()
|
||||
|
||||
// Get the first active player's emoji
|
||||
const activePlayers = Array.from(players.values()).filter((p) => p.id)
|
||||
const firstActivePlayer = activePlayers[0]
|
||||
const playerEmoji = firstActivePlayer?.emoji ?? '👤'
|
||||
// Get the LOCAL player's emoji (not just the first player!)
|
||||
const activePlayers = Array.from(players.values()).filter((p) => p.isActive)
|
||||
const localPlayer = activePlayers.find((p) => p.isLocal)
|
||||
const playerEmoji = localPlayer?.emoji ?? '👤'
|
||||
|
||||
// Log only when localPlayer changes
|
||||
useEffect(() => {
|
||||
console.log(
|
||||
'[SteamTrainJourney] localPlayer:',
|
||||
localPlayer?.name,
|
||||
'isLocal:',
|
||||
localPlayer?.isLocal
|
||||
)
|
||||
}, [localPlayer?.id, localPlayer?.name, localPlayer?.isLocal])
|
||||
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
const pathRef = useRef<SVGPathElement>(null)
|
||||
@@ -164,14 +175,13 @@ export function SteamTrainJourney({
|
||||
|
||||
// Memoize filtered passenger lists to avoid recalculating on every render
|
||||
// Arcade room multiplayer uses claimedBy/deliveredBy instead of isBoarded/isDelivered
|
||||
// Only show passengers claimed by the first active player
|
||||
// Only show passengers claimed by the LOCAL player
|
||||
const boardedPassengers = useMemo(
|
||||
() =>
|
||||
displayPassengers.filter(
|
||||
(p) =>
|
||||
p.claimedBy === firstActivePlayer?.id && p.claimedBy !== null && p.deliveredBy === null
|
||||
(p) => p.claimedBy === localPlayer?.id && p.claimedBy !== null && p.deliveredBy === null
|
||||
),
|
||||
[displayPassengers, firstActivePlayer?.id]
|
||||
[displayPassengers, localPlayer?.id]
|
||||
)
|
||||
|
||||
const nonDeliveredPassengers = useMemo(
|
||||
@@ -191,6 +201,30 @@ export function SteamTrainJourney({
|
||||
[]
|
||||
)
|
||||
|
||||
// Get other players for ghost trains (filter out local player)
|
||||
const otherPlayers = useMemo(() => {
|
||||
if (!multiplayerState?.players || !localPlayerId) {
|
||||
return []
|
||||
}
|
||||
|
||||
const filtered = Object.entries(multiplayerState.players)
|
||||
.filter(([playerId, player]) => playerId !== localPlayerId && player.isActive)
|
||||
.map(([_, player]) => player)
|
||||
|
||||
return filtered
|
||||
}, [multiplayerState?.players, localPlayerId])
|
||||
|
||||
// Log only when otherPlayers count changes
|
||||
useEffect(() => {
|
||||
console.log('[SteamTrainJourney] otherPlayers count:', otherPlayers.length)
|
||||
if (otherPlayers.length > 0) {
|
||||
console.log(
|
||||
'[SteamTrainJourney] ghost positions:',
|
||||
otherPlayers.map((p) => `${p.name}: ${p.position.toFixed(1)}`).join(', ')
|
||||
)
|
||||
}
|
||||
}, [otherPlayers.length, otherPlayers])
|
||||
|
||||
if (!trackData) return null
|
||||
|
||||
return (
|
||||
@@ -252,7 +286,18 @@ export function SteamTrainJourney({
|
||||
disembarkingAnimations={disembarkingAnimations}
|
||||
/>
|
||||
|
||||
{/* Train, cars, and passenger animations */}
|
||||
{/* Ghost trains - other players in multiplayer */}
|
||||
{otherPlayers.map((player) => (
|
||||
<GhostTrain
|
||||
key={player.id}
|
||||
player={player}
|
||||
trainPosition={player.position} // Use each player's individual position
|
||||
trackGenerator={trackGenerator}
|
||||
pathRef={pathRef}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Train, cars, and passenger animations - local player */}
|
||||
<TrainAndCars
|
||||
boardingAnimations={boardingAnimations}
|
||||
disembarkingAnimations={disembarkingAnimations}
|
||||
|
||||
@@ -99,6 +99,8 @@ interface CompatibleGameState {
|
||||
*/
|
||||
interface ComplementRaceContextValue {
|
||||
state: CompatibleGameState // Return adapted state
|
||||
multiplayerState: ComplementRaceState // Raw multiplayer state for rendering other players
|
||||
localPlayerId: string | undefined // Local player ID for filtering
|
||||
dispatch: (action: { type: string; [key: string]: any }) => void // Compatibility layer
|
||||
lastError: string | null
|
||||
startGame: () => void
|
||||
@@ -304,12 +306,18 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
// Get local player ID
|
||||
const localPlayerId = useMemo(() => {
|
||||
return activePlayers.find((id) => {
|
||||
const foundId = activePlayers.find((id) => {
|
||||
const player = players.get(id)
|
||||
return player?.isLocal
|
||||
})
|
||||
return foundId
|
||||
}, [activePlayers, players])
|
||||
|
||||
// Log only when localPlayerId changes
|
||||
useEffect(() => {
|
||||
console.log('[Provider] localPlayerId:', localPlayerId)
|
||||
}, [localPlayerId])
|
||||
|
||||
// Debug logging ref (track last logged values)
|
||||
const lastLogRef = useState({ key: '', count: 0 })[0]
|
||||
|
||||
@@ -914,6 +922,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,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "soroban-monorepo",
|
||||
"version": "4.63.10",
|
||||
"version": "4.64.2",
|
||||
"private": true,
|
||||
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user