Compare commits

..

2 Commits

Author SHA1 Message Date
semantic-release-bot
28fc0a14be chore(release): 4.67.2 [skip ci]
## [4.67.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.67.1...v4.67.2) (2025-10-23)

### Performance Improvements

* **complement-race:** increase train position update frequency to 60fps ([fffaf1d](fffaf1df1d))
2025-10-23 11:24:24 +00:00
Thomas Hallock
fffaf1df1d perf(complement-race): increase train position update frequency to 60fps
Increased update intervals from 50ms (20fps) to 16ms (60fps) for smoother
train movement without using react-spring animations. Changes applied to:

- Game logic loop (useSteamJourney.ts)
- Momentum/position updates (Provider.tsx)
- Position broadcasts for multiplayer (Provider.tsx)

This resolves the regression where react-spring animations caused guest
players' trains to freeze at their starting position in multiplayer games.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-23 06:23:02 -05:00
7 changed files with 51 additions and 101 deletions

View File

@@ -1,23 +1,9 @@
## [4.68.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.68.1...v4.68.2) (2025-10-23)
### Bug Fixes
* **complement-race:** prevent passenger delivery render loop causing thrashing ([f637ddf](https://github.com/antialias/soroban-abacus-flashcards/commit/f637ddfdb8a62c85ebf8f08c35927af9ebcdf0d7))
## [4.68.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.68.0...v4.68.1) (2025-10-23)
## [4.67.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.67.1...v4.67.2) (2025-10-23)
### Performance Improvements
* **complement-race:** increase spring animation responsiveness to reduce lag ([5bd0dad](https://github.com/antialias/soroban-abacus-flashcards/commit/5bd0dadfdf9d6d81d7db4374983e40de00effecb))
## [4.68.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.67.1...v4.68.0) (2025-10-22)
### Features
* **complement-race:** add react-spring animations to local train for smooth movement ([e5b58c8](https://github.com/antialias/soroban-abacus-flashcards/commit/e5b58c844cacce84b6118b3219b0d1f86e6f74a2))
* **complement-race:** increase train position update frequency to 60fps ([fffaf1d](https://github.com/antialias/soroban-abacus-flashcards/commit/fffaf1df1d4d55c811bf634c957691e3564470d6))
## [4.67.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.67.0...v4.67.1) (2025-10-22)

View File

@@ -89,7 +89,7 @@ export function GhostTrain({
y: locomotiveTarget?.y ?? 0,
rotation: locomotiveTarget?.rotation ?? 0,
opacity: locomotiveTarget?.opacity ?? 1,
config: { tension: 600, friction: 35 }, // Fast/responsive to match local train
config: { tension: 280, friction: 60 }, // Smooth but responsive
})
// Calculate target transforms for cars (used by spring animations)
@@ -133,7 +133,7 @@ export function GhostTrain({
y: target.y,
rotation: target.rotation,
opacity: target.opacity,
config: { tension: 600, friction: 35 }, // Fast/responsive to match local train
config: { tension: 280, friction: 60 },
}))
)

View File

@@ -1,23 +1,21 @@
'use client'
import { memo } from 'react'
import { animated, to } from '@react-spring/web'
import type { SpringValue } from '@react-spring/web'
import type { BoardingAnimation, DisembarkingAnimation } from '../../hooks/usePassengerAnimations'
import type { Passenger } from '@/arcade-games/complement-race/types'
interface TrainCarTransform {
x: SpringValue<number>
y: SpringValue<number>
rotation: SpringValue<number>
position: SpringValue<number>
opacity: SpringValue<number>
x: number
y: number
rotation: number
position: number
opacity: number
}
interface TrainTransform {
x: SpringValue<number>
y: SpringValue<number>
rotation: SpringValue<number>
x: number
y: number
rotation: number
}
interface TrainAndCarsProps {
@@ -32,7 +30,7 @@ interface TrainAndCarsProps {
trainCars: TrainCarTransform[]
boardedPassengers: Passenger[]
trainTransform: TrainTransform
locomotiveOpacity: SpringValue<number>
locomotiveOpacity: number
playerEmoji: string
momentum: number
}
@@ -74,14 +72,14 @@ export const TrainAndCars = memo(
const passenger = boardedPassengers[carIndex]
return (
<animated.g
<g
key={`train-car-${carIndex}`}
data-component="train-car"
transform={to(
[carTransform.x, carTransform.y, carTransform.rotation],
(x, y, rot) => `translate(${x}, ${y}) rotate(${rot}) scale(-1, 1)`
)}
transform={`translate(${carTransform.x}, ${carTransform.y}) rotate(${carTransform.rotation}) scale(-1, 1)`}
opacity={carTransform.opacity}
style={{
transition: 'opacity 0.5s ease-in',
}}
>
{/* Train car */}
<text
@@ -116,18 +114,18 @@ export const TrainAndCars = memo(
{passenger.avatar}
</text>
)}
</animated.g>
</g>
)
})}
{/* Locomotive - rendered last so it appears on top */}
<animated.g
<g
data-component="locomotive-group"
transform={to(
[trainTransform.x, trainTransform.y, trainTransform.rotation],
(x, y, rot) => `translate(${x}, ${y}) rotate(${rot}) scale(-1, 1)`
)}
transform={`translate(${trainTransform.x}, ${trainTransform.y}) rotate(${trainTransform.rotation}) scale(-1, 1)`}
opacity={locomotiveOpacity}
style={{
transition: 'opacity 0.5s ease-in',
}}
>
{/* Train locomotive */}
<text
@@ -193,7 +191,7 @@ export const TrainAndCars = memo(
}}
/>
))}
</animated.g>
</g>
</>
)
}

View File

@@ -34,7 +34,7 @@ const MOMENTUM_DECAY_RATES = {
const MOMENTUM_GAIN_PER_CORRECT = 15 // Momentum added for each correct answer
const SPEED_MULTIPLIER = 0.15 // Convert momentum to speed (% per second at momentum=100)
const UPDATE_INTERVAL = 50 // Update every 50ms (~20 fps)
const UPDATE_INTERVAL = 16 // Update every 16ms (~60 fps for smooth animation)
const GAME_DURATION = 60000 // 60 seconds in milliseconds
export function useSteamJourney() {
@@ -45,7 +45,6 @@ export function useSteamJourney() {
const routeExitThresholdRef = useRef<number>(107) // Default for 1 car: 100 + 7
const missedPassengersRef = useRef<Set<string>>(new Set()) // Track which passengers have been logged as missed
const pendingBoardingRef = useRef<Set<string>>(new Set()) // Track passengers with pending boarding requests across frames
const pendingDeliveryRef = useRef<Set<string>>(new Set()) // Track passengers with pending delivery requests across frames
const previousTrainPositionRef = useRef<number>(0) // Track previous position to detect threshold crossings
// Initialize game start time
@@ -66,23 +65,19 @@ export function useSteamJourney() {
}
}, [state.currentRoute, state.passengers, state.stations, state.maxConcurrentPassengers])
// Clean up pendingBoardingRef and pendingDeliveryRef when passengers are claimed/delivered or route changes
// Clean up pendingBoardingRef when passengers are claimed/delivered or route changes
useEffect(() => {
// Remove passengers from pending sets if they've been claimed or delivered
// Remove passengers from pending set if they've been claimed or delivered
state.passengers.forEach((passenger) => {
if (passenger.claimedBy !== null || passenger.deliveredBy !== null) {
pendingBoardingRef.current.delete(passenger.id)
}
if (passenger.deliveredBy !== null) {
pendingDeliveryRef.current.delete(passenger.id)
}
})
}, [state.passengers])
// Clear all pending boarding and delivery requests when route changes
// Clear all pending boarding requests when route changes
useEffect(() => {
pendingBoardingRef.current.clear()
pendingDeliveryRef.current.clear()
missedPassengersRef.current.clear()
previousTrainPositionRef.current = 0 // Reset previous position for new route
}, [state.currentRoute])
@@ -164,9 +159,6 @@ export function useSteamJourney() {
currentBoardedPassengers.forEach((passenger) => {
if (!passenger || passenger.deliveredBy !== null || passenger.carIndex === null) return
// Skip if delivery already dispatched (prevents render loop spam)
if (pendingDeliveryRef.current.has(passenger.id)) return
const station = state.stations.find((s) => s.id === passenger.destinationStationId)
if (!station) return
@@ -180,10 +172,6 @@ export function useSteamJourney() {
console.log(
`🎯 DELIVERY: ${passenger.name} delivered from Car ${passenger.carIndex} to ${station.emoji} ${station.name} (+${points} pts) (trainPos=${trainPosition.toFixed(1)}, carPos=${carPosition.toFixed(1)}, stationPos=${station.position})`
)
// Mark as pending BEFORE dispatch to prevent duplicate delivery attempts across frames
pendingDeliveryRef.current.add(passenger.id)
dispatch({
type: 'DELIVER_PASSENGER',
passengerId: passenger.id,

View File

@@ -1,5 +1,4 @@
import { useMemo } from 'react'
import { useSpring, useSprings } from '@react-spring/web'
import { useEffect, useMemo, useState } from 'react'
import type { RailroadTrackGenerator } from '../lib/RailroadTrackGenerator'
interface TrainTransform {
@@ -28,24 +27,22 @@ export function useTrainTransforms({
maxCars,
carSpacing,
}: UseTrainTransformsParams) {
// Calculate target locomotive transform
const locomotiveTarget = useMemo<TrainTransform>(() => {
if (!pathRef.current) {
return { x: 50, y: 300, rotation: 0 }
}
return trackGenerator.getTrainTransform(pathRef.current, trainPosition)
}, [trainPosition, trackGenerator, pathRef])
// Animated spring for smooth locomotive movement
const trainTransform = useSpring({
x: locomotiveTarget.x,
y: locomotiveTarget.y,
rotation: locomotiveTarget.rotation,
config: { tension: 600, friction: 35 }, // Fast/responsive to avoid lag
const [trainTransform, setTrainTransform] = useState<TrainTransform>({
x: 50,
y: 300,
rotation: 0,
})
// Calculate target transforms for train cars (each car follows behind the locomotive)
const carTargets = useMemo((): TrainCarTransform[] => {
// Update train position and rotation
useEffect(() => {
if (pathRef.current) {
const transform = trackGenerator.getTrainTransform(pathRef.current, trainPosition)
setTrainTransform(transform)
}
}, [trainPosition, trackGenerator, pathRef])
// Calculate train car transforms (each car follows behind the locomotive)
const trainCars = useMemo((): TrainCarTransform[] => {
if (!pathRef.current) {
return Array.from({ length: maxCars }, () => ({
x: 0,
@@ -89,21 +86,8 @@ export function useTrainTransforms({
})
}, [trainPosition, trackGenerator, pathRef, maxCars, carSpacing])
// Animated springs for smooth car movement
const trainCars = useSprings(
carTargets.length,
carTargets.map((target) => ({
x: target.x,
y: target.y,
rotation: target.rotation,
opacity: target.opacity,
position: target.position,
config: { tension: 600, friction: 35 }, // Fast/responsive to avoid lag
}))
)
// Calculate target locomotive opacity (fade in/out through tunnels)
const locomotiveOpacityTarget = useMemo(() => {
// Calculate locomotive opacity (fade in/out through tunnels)
const locomotiveOpacity = useMemo(() => {
const fadeInStart = 3
const fadeInEnd = 8
const fadeOutStart = 92
@@ -125,15 +109,9 @@ export function useTrainTransforms({
return 1 // Default to fully visible
}, [trainPosition])
// Animated spring for smooth locomotive opacity
const locomotiveOpacity = useSpring({
opacity: locomotiveOpacityTarget,
config: { tension: 600, friction: 35 }, // Fast/responsive to avoid lag
})
return {
trainTransform,
trainCars,
locomotiveOpacity: locomotiveOpacity.opacity,
locomotiveOpacity,
}
}

View File

@@ -365,7 +365,7 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
const MOMENTUM_GAIN_PER_CORRECT = 15
const MOMENTUM_LOSS_PER_WRONG = 10
const SPEED_MULTIPLIER = 0.15 // momentum * 0.15 = % per second
const UPDATE_INTERVAL = 50 // 50ms = ~20fps
const UPDATE_INTERVAL = 16 // 16ms = ~60fps for smooth animation
// Transform multiplayer state to look like single-player state
const compatibleState = useMemo((): CompatibleGameState => {
@@ -641,7 +641,7 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
console.log('[POS_BROADCAST] Starting position broadcast interval')
// Send position update every 100ms for smoother ghost trains (reads from refs to avoid restarting interval)
// Send position update every 16ms (~60fps) for smoother ghost trains (reads from refs to avoid restarting interval)
const interval = setInterval(() => {
const currentPos = clientPositionRef.current
broadcastCountRef.current++
@@ -664,7 +664,7 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
userId: viewerId || '',
data: { position: currentPos },
} as ComplementRaceMove)
}, 100)
}, 16)
return () => {
console.log(`[POS_BROADCAST] Stopping interval (sent ${broadcastCountRef.current} updates)`)

View File

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