Compare commits

...

6 Commits

Author SHA1 Message Date
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
6 changed files with 134 additions and 62 deletions

View File

@@ -1,3 +1,24 @@
## [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: 280, friction: 60 }, // Smooth but responsive
})
// 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: 280, friction: 60 },
}))
)
// 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

@@ -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: 280, friction: 60 },
})
// 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: 280, friction: 60 },
}))
)
// 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: 280, friction: 60 },
})
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.0",
"private": true,
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
"workspaces": [