Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28fc0a14be | ||
|
|
fffaf1df1d | ||
|
|
09df96922e | ||
|
|
0add9e4ef1 | ||
|
|
3eb85d7d72 | ||
|
|
eb3700a57d | ||
|
|
e6c12e87e4 | ||
|
|
ad78a65ed7 | ||
|
|
b95fc1fdff | ||
|
|
79bc0e4c80 | ||
|
|
df60824f37 |
35
CHANGELOG.md
35
CHANGELOG.md
@@ -1,3 +1,38 @@
|
||||
## [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](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)
|
||||
|
||||
|
||||
### 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)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** fix ghost train position update lag and reload position reset ([ad78a65](https://github.com/antialias/soroban-abacus-flashcards/commit/ad78a65ed7f63509602e79246e3761653ea39a15))
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
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,29 +125,32 @@ export function GhostTrain({
|
||||
return cars
|
||||
}, [trainPosition, maxCars, carSpacing, localTrainCarPositions, pathRef])
|
||||
|
||||
// Log only once when this ghost train first renders
|
||||
const hasLoggedRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (!hasLoggedRef.current && locomotiveTransform) {
|
||||
console.log('[GhostTrain] rendering:', player.name, 'at position:', trainPosition.toFixed(1))
|
||||
hasLoggedRef.current = true
|
||||
}
|
||||
}, [locomotiveTransform, player.name, trainPosition])
|
||||
// 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"
|
||||
@@ -188,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}`}
|
||||
@@ -213,7 +226,7 @@ export function GhostTrain({
|
||||
>
|
||||
🚃
|
||||
</text>
|
||||
</g>
|
||||
</animated.g>
|
||||
))}
|
||||
</g>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { animated, useSpring } from '@react-spring/web'
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { memo, useMemo, useRef, useState } from 'react'
|
||||
import { useGameMode } from '@/contexts/GameModeContext'
|
||||
import { useUserProfile } from '@/contexts/UserProfileContext'
|
||||
import { useComplementRace } from '@/arcade-games/complement-race/Provider'
|
||||
@@ -106,16 +106,6 @@ export function SteamTrainJourney({
|
||||
const localPlayer = activePlayers.find((p) => p.isLocal)
|
||||
const playerEmoji = localPlayer?.emoji ?? '👤'
|
||||
|
||||
// Log only when localPlayer changes
|
||||
useEffect(() => {
|
||||
console.log(
|
||||
'[SteamTrainJourney] localPlayer:',
|
||||
localPlayer?.name,
|
||||
'isLocal:',
|
||||
localPlayer?.isLocal
|
||||
)
|
||||
}, [localPlayer?.id, localPlayer?.name, localPlayer?.isLocal])
|
||||
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
const pathRef = useRef<SVGPathElement>(null)
|
||||
const [trackGenerator] = useState(() => new RailroadTrackGenerator(800, 600))
|
||||
@@ -224,17 +214,6 @@ export function SteamTrainJourney({
|
||||
return filtered
|
||||
}, [multiplayerState?.players, localPlayerId])
|
||||
|
||||
// Log only when otherPlayers count changes
|
||||
useEffect(() => {
|
||||
console.log('[SteamTrainJourney] otherPlayers count:', otherPlayers.length)
|
||||
if (otherPlayers.length > 0) {
|
||||
console.log(
|
||||
'[SteamTrainJourney] ghost positions:',
|
||||
otherPlayers.map((p) => `${p.name}: ${p.position.toFixed(1)}`).join(', ')
|
||||
)
|
||||
}
|
||||
}, [otherPlayers.length, otherPlayers])
|
||||
|
||||
if (!trackData) return null
|
||||
|
||||
return (
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -313,18 +313,28 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
return foundId
|
||||
}, [activePlayers, players])
|
||||
|
||||
// Log only when localPlayerId changes
|
||||
useEffect(() => {
|
||||
console.log('[Provider] localPlayerId:', localPlayerId)
|
||||
}, [localPlayerId])
|
||||
|
||||
// Debug logging ref (track last logged values)
|
||||
const lastLogRef = useState({ key: '', count: 0 })[0]
|
||||
|
||||
// Client-side game state (NOT synced to server - purely visual/gameplay)
|
||||
const [clientMomentum, setClientMomentum] = useState(10) // Start at 10 for gentle push
|
||||
const [clientPosition, setClientPosition] = useState(0)
|
||||
const [clientPressure, setClientPressure] = useState(0)
|
||||
|
||||
// Track if we've synced position from server (for reconnect/reload scenarios)
|
||||
const hasInitializedPositionRef = useRef(false)
|
||||
|
||||
// Ref to track latest position for broadcasting (avoids recreating interval on every position change)
|
||||
const clientPositionRef = useRef(clientPosition)
|
||||
|
||||
// Refs for throttled logging
|
||||
const lastBroadcastLogRef = useRef({ position: 0, time: 0 })
|
||||
const broadcastCountRef = useRef(0)
|
||||
const lastReceivedPositionsRef = useRef<Record<string, number>>({})
|
||||
|
||||
// Ref to hold sendMove so interval doesn't restart when sendMove changes
|
||||
const sendMoveRef = useRef(sendMove)
|
||||
useEffect(() => {
|
||||
sendMoveRef.current = sendMove
|
||||
}, [sendMove])
|
||||
|
||||
const [clientAIRacers, setClientAIRacers] = useState<
|
||||
Array<{
|
||||
id: string
|
||||
@@ -355,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 => {
|
||||
@@ -451,16 +461,50 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
clientAIRacers,
|
||||
])
|
||||
|
||||
// Sync client position from server on reconnect/reload (multiplayer only)
|
||||
useEffect(() => {
|
||||
// Only sync if:
|
||||
// 1. We haven't synced yet
|
||||
// 2. Game is active
|
||||
// 3. We're in sprint mode
|
||||
// 4. We have a local player with a position from server
|
||||
if (
|
||||
!hasInitializedPositionRef.current &&
|
||||
multiplayerState.gamePhase === 'playing' &&
|
||||
multiplayerState.config.style === 'sprint' &&
|
||||
localPlayerId
|
||||
) {
|
||||
const serverPosition = multiplayerState.players[localPlayerId]?.position
|
||||
if (serverPosition !== undefined && serverPosition > 0) {
|
||||
console.log(`[POSITION_SYNC] Restoring position from server: ${serverPosition.toFixed(1)}%`)
|
||||
setClientPosition(serverPosition)
|
||||
hasInitializedPositionRef.current = true
|
||||
}
|
||||
}
|
||||
|
||||
// Reset sync flag when game ends
|
||||
if (multiplayerState.gamePhase !== 'playing') {
|
||||
hasInitializedPositionRef.current = false
|
||||
}
|
||||
}, [
|
||||
multiplayerState.gamePhase,
|
||||
multiplayerState.config.style,
|
||||
multiplayerState.players,
|
||||
localPlayerId,
|
||||
])
|
||||
|
||||
// Initialize game start time when game becomes active
|
||||
useEffect(() => {
|
||||
if (compatibleState.isGameActive && compatibleState.style === 'sprint') {
|
||||
if (gameStartTimeRef.current === 0) {
|
||||
gameStartTimeRef.current = Date.now()
|
||||
lastUpdateRef.current = Date.now()
|
||||
// Reset client state for new game
|
||||
setClientMomentum(10) // Start with gentle push
|
||||
setClientPosition(0)
|
||||
setClientPressure((10 / 100) * 150) // Initial pressure from starting momentum
|
||||
// Reset client state for new game (only if not restored from server)
|
||||
if (!hasInitializedPositionRef.current) {
|
||||
setClientMomentum(10) // Start with gentle push
|
||||
setClientPosition(0)
|
||||
setClientPressure((10 / 100) * 150) // Initial pressure from starting momentum
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Reset when game ends
|
||||
@@ -556,39 +600,77 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
const currentRoute = multiplayerState.currentRoute
|
||||
// When route changes, reset position and give starting momentum
|
||||
if (currentRoute > 1 && compatibleState.style === 'sprint') {
|
||||
console.log(
|
||||
`[Provider] Route changed to ${currentRoute}, resetting position. Passengers: ${multiplayerState.passengers.length}`
|
||||
)
|
||||
setClientPosition(0)
|
||||
setClientMomentum(10) // Reset to starting momentum (gentle push)
|
||||
}
|
||||
}, [multiplayerState.currentRoute, compatibleState.style, multiplayerState.passengers.length])
|
||||
|
||||
// Keep position ref in sync with latest position
|
||||
useEffect(() => {
|
||||
clientPositionRef.current = clientPosition
|
||||
}, [clientPosition])
|
||||
|
||||
// Log when we receive position updates from other players
|
||||
useEffect(() => {
|
||||
if (!multiplayerState?.players || !localPlayerId) return
|
||||
|
||||
Object.entries(multiplayerState.players).forEach(([playerId, player]) => {
|
||||
if (playerId === localPlayerId || !player.isActive) return
|
||||
|
||||
const lastPos = lastReceivedPositionsRef.current[playerId] ?? -1
|
||||
const currentPos = player.position
|
||||
|
||||
// Log when position changes significantly (>2%)
|
||||
if (Math.abs(currentPos - lastPos) > 2) {
|
||||
console.log(
|
||||
`[POS_RECEIVED] ${player.name}: ${currentPos.toFixed(1)}% (was ${lastPos.toFixed(1)}%, delta=${(currentPos - lastPos).toFixed(1)}%)`
|
||||
)
|
||||
lastReceivedPositionsRef.current[playerId] = currentPos
|
||||
}
|
||||
})
|
||||
}, [multiplayerState?.players, localPlayerId])
|
||||
|
||||
// Broadcast position to server for multiplayer ghost trains
|
||||
useEffect(() => {
|
||||
if (!compatibleState.isGameActive || compatibleState.style !== 'sprint' || !localPlayerId) {
|
||||
const isGameActive = multiplayerState.gamePhase === 'playing'
|
||||
const isSprint = multiplayerState.config.style === 'sprint'
|
||||
|
||||
if (!isGameActive || !isSprint || !localPlayerId) {
|
||||
return
|
||||
}
|
||||
|
||||
// Send position update every 200ms
|
||||
console.log('[POS_BROADCAST] Starting position broadcast interval')
|
||||
|
||||
// Send position update every 16ms (~60fps) for smoother ghost trains (reads from refs to avoid restarting interval)
|
||||
const interval = setInterval(() => {
|
||||
sendMove({
|
||||
const currentPos = clientPositionRef.current
|
||||
broadcastCountRef.current++
|
||||
|
||||
// Throttled logging: only log when position changes by >2% or every 5 seconds
|
||||
const now = Date.now()
|
||||
const posDiff = Math.abs(currentPos - lastBroadcastLogRef.current.position)
|
||||
const timeDiff = now - lastBroadcastLogRef.current.time
|
||||
|
||||
if (posDiff > 2 || timeDiff > 5000) {
|
||||
console.log(
|
||||
`[POS_BROADCAST] #${broadcastCountRef.current} pos=${currentPos.toFixed(1)}% (delta=${posDiff.toFixed(1)}%)`
|
||||
)
|
||||
lastBroadcastLogRef.current = { position: currentPos, time: now }
|
||||
}
|
||||
|
||||
sendMoveRef.current({
|
||||
type: 'UPDATE_POSITION',
|
||||
playerId: localPlayerId,
|
||||
userId: viewerId || '',
|
||||
data: { position: clientPosition },
|
||||
data: { position: currentPos },
|
||||
} as ComplementRaceMove)
|
||||
}, 200)
|
||||
}, 16)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [
|
||||
compatibleState.isGameActive,
|
||||
compatibleState.style,
|
||||
clientPosition,
|
||||
localPlayerId,
|
||||
viewerId,
|
||||
sendMove,
|
||||
])
|
||||
return () => {
|
||||
console.log(`[POS_BROADCAST] Stopping interval (sent ${broadcastCountRef.current} updates)`)
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, [multiplayerState.gamePhase, multiplayerState.config.style, localPlayerId, viewerId])
|
||||
|
||||
// Keep lastLogRef for future debugging needs
|
||||
// (removed debug logging)
|
||||
@@ -803,7 +885,6 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
case 'START_NEW_ROUTE':
|
||||
// Send route progression to server
|
||||
if (action.routeNumber !== undefined) {
|
||||
console.log(`[Provider] Dispatching START_NEW_ROUTE for route ${action.routeNumber}`)
|
||||
sendMove({
|
||||
type: 'START_NEW_ROUTE',
|
||||
playerId: activePlayers[0] || '',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "soroban-monorepo",
|
||||
"version": "4.66.0",
|
||||
"version": "4.67.2",
|
||||
"private": true,
|
||||
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user