Compare commits

...

8 Commits

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

### Bug Fixes

* **complement-race:** ensure continuous position broadcasting during train movement ([df60824](df60824f37))
2025-10-22 18:14:21 +00:00
Thomas Hallock
df60824f37 fix(complement-race): ensure continuous position broadcasting during train movement
Fixed an issue where ghost trains only updated when players stopped moving.

Root cause: clientPosition in useEffect dependency array caused the
position broadcasting interval to restart on every position change,
creating gaps in broadcasts during continuous movement.

Solution:
- Use useRef to track latest clientPosition without triggering effect
- Keep ref synced with position via separate useEffect
- Read position from ref inside interval callback
- Remove clientPosition from broadcasting useEffect dependencies

Now positions broadcast smoothly every 200ms regardless of movement state.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 13:12:59 -05:00
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
semantic-release-bot
659464d3b4 chore(release): 4.65.1 [skip ci]
## [4.65.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.65.0...v4.65.1) (2025-10-22)

### Bug Fixes

* **complement-race:** use sendMove with correct parameters for position updates ([06cd94b](06cd94b24c))
2025-10-22 17:43:07 +00:00
Thomas Hallock
06cd94b24c fix(complement-race): use sendMove with correct parameters for position updates
Fixed ReferenceError where makeMove was undefined. The correct function
is sendMove from useArcadeSession, which requires playerId and userId
parameters in addition to the move type and data.

Changes:
- Changed makeMove to sendMove
- Added playerId and userId to the move object
- Added localPlayerId guard to prevent updates before player is identified
- Updated dependency array to include localPlayerId, viewerId, and sendMove

This fixes the runtime error preventing ghost trains from working.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 12:41:44 -05:00
semantic-release-bot
ada0becee5 chore(release): 4.65.0 [skip ci]
## [4.65.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.64.2...v4.65.0) (2025-10-22)

### Features

* **complement-race:** implement position broadcasting for ghost trains ([c5fba5b](c5fba5b7dd))
2025-10-22 17:09:44 +00:00
Thomas Hallock
c5fba5b7dd feat(complement-race): implement position broadcasting for ghost trains
Clients now broadcast their train position to server every 200ms,
allowing other clients to render ghost trains at correct locations.

Added UPDATE_POSITION move type and server-side validation to sync
client-calculated positions (from momentum physics) to server state.

This fixes the issue where ghost trains were rendering but invisible
because they all had position=0 from server.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 12:08:19 -05:00
7 changed files with 267 additions and 65 deletions

View File

@@ -1,3 +1,31 @@
## [4.66.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.66.0...v4.66.1) (2025-10-22)
### Bug Fixes
* **complement-race:** ensure continuous position broadcasting during train movement ([df60824](https://github.com/antialias/soroban-abacus-flashcards/commit/df60824f37f52e77e69d32c26926a24e1af88e66))
## [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)
### Bug Fixes
* **complement-race:** use sendMove with correct parameters for position updates ([06cd94b](https://github.com/antialias/soroban-abacus-flashcards/commit/06cd94b24cdd9dbd36fb5800c9ba7be194f7eed0))
## [4.65.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.64.2...v4.65.0) (2025-10-22)
### Features
* **complement-race:** implement position broadcasting for ghost trains ([c5fba5b](https://github.com/antialias/soroban-abacus-flashcards/commit/c5fba5b7dd0f36fd3bbe596409e01b0d3dbd4fbe))
## [4.64.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.64.1...v4.64.2) (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

@@ -325,6 +325,9 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
const [clientMomentum, setClientMomentum] = useState(10) // Start at 10 for gentle push
const [clientPosition, setClientPosition] = useState(0)
const [clientPressure, setClientPressure] = useState(0)
// Ref to track latest position for broadcasting (avoids recreating interval on every position change)
const clientPositionRef = useRef(clientPosition)
const [clientAIRacers, setClientAIRacers] = useState<
Array<{
id: string
@@ -564,6 +567,30 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
}
}, [multiplayerState.currentRoute, compatibleState.style, multiplayerState.passengers.length])
// Keep position ref in sync with latest position
useEffect(() => {
clientPositionRef.current = clientPosition
}, [clientPosition])
// Broadcast position to server for multiplayer ghost trains
useEffect(() => {
if (!compatibleState.isGameActive || compatibleState.style !== 'sprint' || !localPlayerId) {
return
}
// Send position update every 200ms (reads from ref to avoid restarting interval)
const interval = setInterval(() => {
sendMove({
type: 'UPDATE_POSITION',
playerId: localPlayerId,
userId: viewerId || '',
data: { position: clientPositionRef.current },
} as ComplementRaceMove)
}, 200)
return () => clearInterval(interval)
}, [compatibleState.isGameActive, compatibleState.style, localPlayerId, viewerId, sendMove])
// Keep lastLogRef for future debugging needs
// (removed debug logging)

View File

@@ -97,6 +97,9 @@ export class ComplementRaceValidator
case 'UPDATE_INPUT':
return this.validateUpdateInput(state, move.playerId, move.data.input)
case 'UPDATE_POSITION':
return this.validateUpdatePosition(state, move.playerId, move.data.position)
case 'CLAIM_PASSENGER':
return this.validateClaimPassenger(
state,
@@ -397,6 +400,39 @@ export class ComplementRaceValidator
return { valid: true, newState }
}
private validateUpdatePosition(
state: ComplementRaceState,
playerId: string,
position: number
): ValidationResult {
if (state.gamePhase !== 'playing') {
return { valid: false, error: 'Game not in playing phase' }
}
const player = state.players[playerId]
if (!player) {
return { valid: false, error: 'Player not found' }
}
// Validate position is a reasonable number (0-100)
if (typeof position !== 'number' || position < 0 || position > 100) {
return { valid: false, error: 'Invalid position value' }
}
const newState: ComplementRaceState = {
...state,
players: {
...state.players,
[playerId]: {
...player,
position,
},
},
}
return { valid: true, newState }
}
// ==========================================================================
// Sprint Mode: Passenger Management
// ==========================================================================

View File

@@ -143,6 +143,7 @@ export type ComplementRaceMove = BaseGameMove &
// Playing phase
| { type: 'SUBMIT_ANSWER'; data: { answer: number; responseTime: number } }
| { type: 'UPDATE_INPUT'; data: { input: string } } // Show "thinking" indicator
| { type: 'UPDATE_POSITION'; data: { position: number } } // Sprint mode: sync train position
| { type: 'CLAIM_PASSENGER'; data: { passengerId: string; carIndex: number } } // Sprint mode: pickup
| { type: 'DELIVER_PASSENGER'; data: { passengerId: string } } // Sprint mode: delivery

View File

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