Compare commits

...

2 Commits

Author SHA1 Message Date
semantic-release-bot
543675340d chore(release): 4.66.0 [skip ci]
## [4.66.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.65.1...v4.66.0) (2025-10-22)

### Features

* **complement-race:** implement per-car adaptive opacity for ghost trains ([9b1d47d](9b1d47d4c7))
2025-10-22 18:07:48 +00:00
Thomas Hallock
9b1d47d4c7 feat(complement-race): implement per-car adaptive opacity for ghost trains
Ghost train cars now individually adjust opacity based on proximity to local
train, reducing visual clutter when overlapping while maintaining clarity
when separated.

Changes:
- Calculate local train car positions array in SteamTrainJourney
- Pass positions to GhostTrain for overlap detection
- Rewrite GhostTrain to render locomotive and each car separately
- Each car calculates opacity independently (0.35 when <20% from any local car, 1.0 otherwise)
- Smooth 0.3s CSS transitions between opacity states
- Overlap threshold: 20% of track length

Benefits:
- Reduced clutter when trains overlap
- Clear visibility when trains separated
- Per-car granularity for mixed scenarios

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 13:06:32 -05:00
4 changed files with 182 additions and 65 deletions

View File

@@ -1,3 +1,10 @@
## [4.66.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.65.1...v4.66.0) (2025-10-22)
### Features
* **complement-race:** implement per-car adaptive opacity for ghost trains ([9b1d47d](https://github.com/antialias/soroban-abacus-flashcards/commit/9b1d47d4c7bdaf44f3921ff99971dfb3b65442bd))
## [4.65.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.65.0...v4.65.1) (2025-10-22)

View File

@@ -4,24 +4,62 @@ import { useEffect, useMemo, useRef } from 'react'
import type { PlayerState } from '@/arcade-games/complement-race/types'
import type { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
// Overlap threshold: if ghost car is within this distance of any local car, make it ghostly
const OVERLAP_THRESHOLD = 20 // % of track length
const GHOST_OPACITY = 0.35 // Opacity when overlapping
const SOLID_OPACITY = 1.0 // Opacity when separated
interface GhostTrainProps {
player: PlayerState
trainPosition: number
localTrainCarPositions: number[] // [locomotive, car1, car2, car3]
maxCars: number
carSpacing: number
trackGenerator: RailroadTrackGenerator
pathRef: React.RefObject<SVGPathElement>
}
interface CarTransform {
x: number
y: number
rotation: number
opacity: number
position: number
}
/**
* Calculate opacity for a ghost car based on distance to nearest local car
*/
function calculateCarOpacity(ghostCarPosition: number, localCarPositions: number[]): number {
// Find minimum distance to any local car
const minDistance = Math.min(
...localCarPositions.map((localPos) => Math.abs(ghostCarPosition - localPos))
)
// If within threshold, use ghost opacity; otherwise solid
return minDistance < OVERLAP_THRESHOLD ? GHOST_OPACITY : SOLID_OPACITY
}
/**
* GhostTrain - Renders a semi-transparent train for other players in multiplayer
* Shows opponent positions in real-time during steam sprint races
* Uses per-car adaptive opacity: cars are ghostly when overlapping local train,
* solid when separated
*/
export function GhostTrain({ player, trainPosition, trackGenerator, pathRef }: GhostTrainProps) {
export function GhostTrain({
player,
trainPosition,
localTrainCarPositions,
maxCars,
carSpacing,
trackGenerator,
pathRef,
}: GhostTrainProps) {
const ghostRef = useRef<SVGGElement>(null)
// Calculate train transform using same logic as local player
const trainTransform = useMemo(() => {
// Calculate ghost train locomotive transform and opacity
const locomotiveTransform = useMemo<CarTransform | null>(() => {
if (!pathRef.current) {
return { x: 0, y: 0, rotation: 0, opacity: 0 }
return null
}
const pathLength = pathRef.current.getTotalLength()
@@ -39,85 +77,144 @@ export function GhostTrain({ player, trainPosition, trackGenerator, pathRef }: G
x: point.x,
y: point.y,
rotation,
opacity: 0.35, // Ghost effect - 35% opacity
position: trainPosition,
opacity: calculateCarOpacity(trainPosition, localTrainCarPositions),
}
}, [trainPosition, pathRef])
}, [trainPosition, localTrainCarPositions, pathRef])
// Calculate ghost train car transforms (each car behind locomotive)
const carTransforms = useMemo<CarTransform[]>(() => {
if (!pathRef.current) {
return []
}
const pathLength = pathRef.current.getTotalLength()
const cars: CarTransform[] = []
for (let i = 0; i < maxCars; i++) {
const carPosition = Math.max(0, trainPosition - (i + 1) * carSpacing)
const targetDistance = (carPosition / 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
cars.push({
x: point.x,
y: point.y,
rotation,
position: carPosition,
opacity: calculateCarOpacity(carPosition, localTrainCarPositions),
})
}
return cars
}, [trainPosition, maxCars, carSpacing, localTrainCarPositions, pathRef])
// Log only once when this ghost train first renders
const hasLoggedRef = useRef(false)
useEffect(() => {
if (!hasLoggedRef.current && trainTransform.opacity > 0) {
if (!hasLoggedRef.current && locomotiveTransform) {
console.log('[GhostTrain] rendering:', player.name, 'at position:', trainPosition.toFixed(1))
hasLoggedRef.current = true
}
}, [trainTransform.opacity, player.name, trainPosition])
}, [locomotiveTransform, player.name, trainPosition])
// Don't render if position data isn't ready
if (trainTransform.opacity === 0) {
if (!locomotiveTransform) {
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',
}}
>
<g ref={ghostRef} data-component="ghost-train" data-player-id={player.id}>
{/* Ghost locomotive */}
<text
data-element="ghost-locomotive"
x={0}
y={0}
textAnchor="middle"
<g
transform={`translate(${locomotiveTransform.x}, ${locomotiveTransform.y}) rotate(${locomotiveTransform.rotation}) scale(-1, 1)`}
opacity={locomotiveTransform.opacity}
style={{
fontSize: '100px',
filter: `drop-shadow(0 2px 8px ${player.color || 'rgba(100, 100, 255, 0.6)'})`,
pointerEvents: 'none',
transition: 'opacity 0.3s ease-in-out',
}}
>
🚂
</text>
<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>
{/* Player name label - positioned above locomotive */}
<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>
{/* Score indicator - positioned below locomotive */}
<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>
{/* Ghost cars - each with individual opacity */}
{carTransforms.map((car, index) => (
<g
key={`car-${index}`}
transform={`translate(${car.x}, ${car.y}) rotate(${car.rotation}) scale(-1, 1)`}
opacity={car.opacity}
style={{
transition: 'opacity 0.3s ease-in-out',
}}
>
<text
data-element={`ghost-car-${index}`}
x={0}
y={0}
textAnchor="middle"
style={{
fontSize: '85px',
filter: `drop-shadow(0 2px 6px ${player.color || 'rgba(100, 100, 255, 0.4)'})`,
pointerEvents: 'none',
}}
>
🚃
</text>
</g>
))}
</g>
)
}

View File

@@ -201,6 +201,16 @@ export function SteamTrainJourney({
[]
)
// Calculate local train car positions for ghost train overlap detection
// Array includes locomotive + all cars: [locomotive, car1, car2, car3]
const localTrainCarPositions = useMemo(() => {
const positions = [trainPosition] // Locomotive at front
for (let i = 0; i < maxCars; i++) {
positions.push(Math.max(0, trainPosition - (i + 1) * carSpacing))
}
return positions
}, [trainPosition, maxCars, carSpacing])
// Get other players for ghost trains (filter out local player)
const otherPlayers = useMemo(() => {
if (!multiplayerState?.players || !localPlayerId) {
@@ -292,6 +302,9 @@ export function SteamTrainJourney({
key={player.id}
player={player}
trainPosition={player.position} // Use each player's individual position
localTrainCarPositions={localTrainCarPositions} // For per-car overlap detection
maxCars={maxCars}
carSpacing={carSpacing}
trackGenerator={trackGenerator}
pathRef={pathRef}
/>

View File

@@ -1,6 +1,6 @@
{
"name": "soroban-monorepo",
"version": "4.65.1",
"version": "4.66.0",
"private": true,
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
"workspaces": [