Compare commits

...

10 Commits

Author SHA1 Message Date
semantic-release-bot
dde7ca39cc chore(release): 4.68.2 [skip ci]
## [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](f637ddfdb8))
2025-10-23 11:01:37 +00:00
Thomas Hallock
f637ddfdb8 fix(complement-race): prevent passenger delivery render loop causing thrashing
Fix render loop that was dispatching DELIVER_PASSENGER hundreds of times
per second when train remained at station, causing:
- Train stoppage despite correct answers
- Violent flashing in passenger HUD list
- Server rejecting moves: "Player does not have this passenger"

Solution:
- Add pendingDeliveryRef to track passengers with pending deliveries
- Check if delivery already dispatched before dispatching again
- Mark passenger as pending immediately before dispatch
- Clean up pending set when passengers are delivered
- Clear pending set on route changes

This mirrors the existing pendingBoardingRef pattern used for boarding.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-23 06:00:09 -05:00
semantic-release-bot
128da7f3d2 chore(release): 4.68.1 [skip ci]
## [4.68.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.68.0...v4.68.1) (2025-10-23)

### Performance Improvements

* **complement-race:** increase spring animation responsiveness to reduce lag ([5bd0dad](5bd0dadfdf))
2025-10-23 00:51:31 +00:00
Thomas Hallock
5bd0dadfdf perf(complement-race): increase spring animation responsiveness to reduce lag
Increased spring animation config from tension:280/friction:60 to
tension:600/friction:35 for both local and ghost trains. This makes the
animations much more responsive and reduces visual lag behind the actual
game state position.

The previous slow springs caused the visual train to lag noticeably behind
the actual position, especially when answering questions (which adds momentum
and increases speed). The train would appear "stuck" even though position was
updating correctly in the game loop.

The new faster config still provides smooth interpolation to hide 100ms update
jitter, but responds quickly enough to avoid noticeable lag.

Changes:
- useTrainTransforms: Update locomotive and car spring configs to tension:600, friction:35
- GhostTrain: Update locomotive and car spring configs to match local train

Fixes:
- Train now moves immediately when answering questions
- No more "needing multiple answers" to see train movement
- Reduces perceived flashing/stuttering from render lag

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 19:50:16 -05:00
semantic-release-bot
755487c42d chore(release): 4.68.0 [skip ci]
## [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](e5b58c844c))
2025-10-22 19:11:36 +00:00
Thomas Hallock
e5b58c844c feat(complement-race): add react-spring animations to local train for smooth movement
Apply the same react-spring animation treatment to the local player's train
that was previously added to ghost trains. This eliminates the low-resolution
"choppy" movement by smoothly interpolating between position updates.

Changes:
- Convert useTrainTransforms hook to use react-spring (useSpring, useSprings)
- Update TrainAndCars component to use animated.g and to() interpolation
- Animate locomotive position, rotation, and opacity
- Animate all train cars with individual springs
- Use tension:280, friction:60 config for smooth but responsive movement

Both local and ghost trains now have butter-smooth 60fps interpolated movement.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 14:10:18 -05:00
semantic-release-bot
09df96922e chore(release): 4.67.1 [skip ci]
## [4.67.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.67.0...v4.67.1) (2025-10-22)

### Bug Fixes

* **complement-race:** fix react-spring interpolation TypeScript errors ([0add9e4](0add9e4ef1))
2025-10-22 19:06:35 +00:00
Thomas Hallock
0add9e4ef1 fix(complement-race): fix react-spring interpolation TypeScript errors
Fixed TypeScript errors in transform interpolation by using correct react-spring
syntax: to([spring1, spring2, spring3], (a, b, c) => ...) instead of the
incorrect spring1.to((a, b, c) => ..., spring2, spring3) syntax.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 14:05:21 -05:00
semantic-release-bot
3eb85d7d72 chore(release): 4.67.0 [skip ci]
## [4.67.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.66.2...v4.67.0) (2025-10-22)

### Features

* **complement-race:** add react-spring animations to ghost trains for smooth movement ([eb3700a](eb3700a57d))
2025-10-22 18:52:16 +00:00
Thomas Hallock
eb3700a57d feat(complement-race): add react-spring animations to ghost trains for smooth movement
Ghost trains now use react-spring animations to smoothly interpolate between
position updates (100ms intervals), eliminating the jerky/discrete movement.

Changes:
- Import useSpring, useSprings, and animated from @react-spring/web
- Convert locomotive and car positions to animated springs
- Use animated.g components for smooth transform interpolation
- Configure springs with tension:280, friction:60 for responsive smoothness

This provides buttery-smooth ghost train movement while receiving position
updates at 100ms intervals, fixing the "low resolution" appearance.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 13:50:55 -05:00
7 changed files with 163 additions and 65 deletions

View File

@@ -1,3 +1,38 @@
## [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)
### 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))
## [4.67.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.67.0...v4.67.1) (2025-10-22)
### Bug Fixes
* **complement-race:** fix react-spring interpolation TypeScript errors ([0add9e4](https://github.com/antialias/soroban-abacus-flashcards/commit/0add9e4ef1d69e4e92ffe279cce09c68efa43714))
## [4.67.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.66.2...v4.67.0) (2025-10-22)
### Features
* **complement-race:** add react-spring animations to ghost trains for smooth movement ([eb3700a](https://github.com/antialias/soroban-abacus-flashcards/commit/eb3700a57d035a142c64b60d5d1b21181d21b69f))
## [4.66.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.66.1...v4.66.2) (2025-10-22)

View File

@@ -1,5 +1,6 @@
'use client'
import { useSpring, useSprings, animated, to } from '@react-spring/web'
import { useMemo, useRef } from 'react'
import type { PlayerState } from '@/arcade-games/complement-race/types'
import type { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
@@ -56,8 +57,8 @@ export function GhostTrain({
}: GhostTrainProps) {
const ghostRef = useRef<SVGGElement>(null)
// Calculate ghost train locomotive transform and opacity
const locomotiveTransform = useMemo<CarTransform | null>(() => {
// Calculate target transform for locomotive (used by spring animation)
const locomotiveTarget = useMemo<CarTransform | null>(() => {
if (!pathRef.current) {
return null
}
@@ -82,8 +83,17 @@ export function GhostTrain({
}
}, [trainPosition, localTrainCarPositions, pathRef])
// Calculate ghost train car transforms (each car behind locomotive)
const carTransforms = useMemo<CarTransform[]>(() => {
// Animated spring for smooth locomotive movement
const locomotiveSpring = useSpring({
x: locomotiveTarget?.x ?? 0,
y: locomotiveTarget?.y ?? 0,
rotation: locomotiveTarget?.rotation ?? 0,
opacity: locomotiveTarget?.opacity ?? 1,
config: { tension: 600, friction: 35 }, // Fast/responsive to match local train
})
// Calculate target transforms for cars (used by spring animations)
const carTargets = useMemo<CarTransform[]>(() => {
if (!pathRef.current) {
return []
}
@@ -115,20 +125,32 @@ export function GhostTrain({
return cars
}, [trainPosition, maxCars, carSpacing, localTrainCarPositions, pathRef])
// Animated springs for smooth car movement (useSprings for multiple cars)
const carSprings = useSprings(
carTargets.length,
carTargets.map((target) => ({
x: target.x,
y: target.y,
rotation: target.rotation,
opacity: target.opacity,
config: { tension: 600, friction: 35 }, // Fast/responsive to match local train
}))
)
// Don't render if position data isn't ready
if (!locomotiveTransform) {
if (!locomotiveTarget) {
return null
}
return (
<g ref={ghostRef} data-component="ghost-train" data-player-id={player.id}>
{/* Ghost locomotive */}
<g
transform={`translate(${locomotiveTransform.x}, ${locomotiveTransform.y}) rotate(${locomotiveTransform.rotation}) scale(-1, 1)`}
opacity={locomotiveTransform.opacity}
style={{
transition: 'opacity 0.3s ease-in-out',
}}
{/* Ghost locomotive - animated */}
<animated.g
transform={to(
[locomotiveSpring.x, locomotiveSpring.y, locomotiveSpring.rotation],
(x, y, rot) => `translate(${x}, ${y}) rotate(${rot}) scale(-1, 1)`
)}
opacity={locomotiveSpring.opacity}
>
<text
data-element="ghost-locomotive"
@@ -179,17 +201,17 @@ export function GhostTrain({
>
{player.score}
</text>
</g>
</animated.g>
{/* Ghost cars - each with individual opacity */}
{carTransforms.map((car, index) => (
<g
{/* Ghost cars - each with individual animated opacity and position */}
{carSprings.map((spring, index) => (
<animated.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',
}}
transform={to(
[spring.x, spring.y, spring.rotation],
(x, y, rot) => `translate(${x}, ${y}) rotate(${rot}) scale(-1, 1)`
)}
opacity={spring.opacity}
>
<text
data-element={`ghost-car-${index}`}
@@ -204,7 +226,7 @@ export function GhostTrain({
>
🚃
</text>
</g>
</animated.g>
))}
</g>
)

View File

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

View File

@@ -45,6 +45,7 @@ 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
@@ -65,19 +66,23 @@ export function useSteamJourney() {
}
}, [state.currentRoute, state.passengers, state.stations, state.maxConcurrentPassengers])
// Clean up pendingBoardingRef when passengers are claimed/delivered or route changes
// Clean up pendingBoardingRef and pendingDeliveryRef when passengers are claimed/delivered or route changes
useEffect(() => {
// Remove passengers from pending set if they've been claimed or delivered
// Remove passengers from pending sets 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 requests when route changes
// Clear all pending boarding and delivery 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])
@@ -159,6 +164,9 @@ 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
@@ -172,6 +180,10 @@ 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,4 +1,5 @@
import { useEffect, useMemo, useState } from 'react'
import { useMemo } from 'react'
import { useSpring, useSprings } from '@react-spring/web'
import type { RailroadTrackGenerator } from '../lib/RailroadTrackGenerator'
interface TrainTransform {
@@ -27,22 +28,24 @@ export function useTrainTransforms({
maxCars,
carSpacing,
}: UseTrainTransformsParams) {
const [trainTransform, setTrainTransform] = useState<TrainTransform>({
x: 50,
y: 300,
rotation: 0,
})
// Update train position and rotation
useEffect(() => {
if (pathRef.current) {
const transform = trackGenerator.getTrainTransform(pathRef.current, trainPosition)
setTrainTransform(transform)
// 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])
// Calculate train car transforms (each car follows behind the locomotive)
const trainCars = useMemo((): TrainCarTransform[] => {
// 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
})
// Calculate target transforms for train cars (each car follows behind the locomotive)
const carTargets = useMemo((): TrainCarTransform[] => {
if (!pathRef.current) {
return Array.from({ length: maxCars }, () => ({
x: 0,
@@ -86,8 +89,21 @@ export function useTrainTransforms({
})
}, [trainPosition, trackGenerator, pathRef, maxCars, carSpacing])
// Calculate locomotive opacity (fade in/out through tunnels)
const locomotiveOpacity = useMemo(() => {
// 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(() => {
const fadeInStart = 3
const fadeInEnd = 8
const fadeOutStart = 92
@@ -109,9 +125,15 @@ 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: locomotiveOpacity.opacity,
}
}

View File

@@ -486,7 +486,12 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
if (multiplayerState.gamePhase !== 'playing') {
hasInitializedPositionRef.current = false
}
}, [multiplayerState.gamePhase, multiplayerState.config.style, multiplayerState.players, localPlayerId])
}, [
multiplayerState.gamePhase,
multiplayerState.config.style,
multiplayerState.players,
localPlayerId,
])
// Initialize game start time when game becomes active
useEffect(() => {

View File

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