Compare commits

...

2 Commits

Author SHA1 Message Date
semantic-release-bot
734da610b7 chore(release): 4.4.8 [skip ci]
## [4.4.8](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.7...v4.4.8) (2025-10-17)

### Bug Fixes

* **complement-race:** implement client-side momentum with continuous decay for smooth train movement ([ea19ff9](ea19ff918b))
2025-10-17 12:51:40 +00:00
Thomas Hallock
ea19ff918b fix(complement-race): implement client-side momentum with continuous decay for smooth train movement
Fixes train jumping backward and pressure not decaying to zero in sprint mode by moving momentum/position/pressure tracking entirely to the client.

Changes:
- Remove momentum/pressure from server PlayerState type (sprint mode only)
- Remove all momentum updates from Validator (server tracks only scoring)
- Add client-side momentum state with 50ms game loop for smooth 20fps movement
- Implement continuous momentum decay based on skill level (2.0-13.0/sec)
- Calculate position and pressure client-side from momentum
- Handle answer boosts (+15 correct, -10 wrong) in client

This matches the arcade room's event-driven architecture where visual elements are client-side and the server maintains authoritative game state (score, streak, passengers).

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-17 07:50:45 -05:00
6 changed files with 118 additions and 47 deletions

View File

@@ -1,3 +1,10 @@
## [4.4.8](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.7...v4.4.8) (2025-10-17)
### Bug Fixes
* **complement-race:** implement client-side momentum with continuous decay for smooth train movement ([ea19ff9](https://github.com/antialias/soroban-abacus-flashcards/commit/ea19ff918bc70ad3eb0339e18dbd32195f34816e))
## [4.4.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.6...v4.4.7) (2025-10-17)

View File

@@ -5,7 +5,6 @@ import { useComplementRace } from '@/arcade-games/complement-race/Provider'
import { useAdaptiveDifficulty } from '../hooks/useAdaptiveDifficulty'
import { useAIRacers } from '../hooks/useAIRacers'
import { useSoundEffects } from '../hooks/useSoundEffects'
import { useSteamJourney } from '../hooks/useSteamJourney'
import { generatePassengers } from '../lib/passengerGenerator'
import { AbacusTarget } from './AbacusTarget'
import { CircularTrack } from './RaceTrack/CircularTrack'
@@ -16,10 +15,9 @@ import { RouteCelebration } from './RouteCelebration'
type FeedbackAnimation = 'correct' | 'incorrect' | null
export function GameDisplay() {
const { state, dispatch } = useComplementRace()
const { state, dispatch, boostMomentum } = useComplementRace()
useAIRacers() // Activate AI racer updates (not used in sprint mode)
const { trackPerformance, getAdaptiveFeedbackMessage } = useAdaptiveDifficulty()
const { boostMomentum } = useSteamJourney()
const { playSound } = useSoundEffects()
const [feedbackAnimation, setFeedbackAnimation] = useState<FeedbackAnimation>(null)
@@ -109,7 +107,7 @@ export function GameDisplay() {
// Boost momentum for sprint mode
if (state.style === 'sprint') {
boostMomentum()
boostMomentum(true)
// Play train whistle for milestones in sprint mode (line 13222-13235)
if (newStreak >= 5 && newStreak % 3 === 0) {
@@ -144,6 +142,11 @@ export function GameDisplay() {
// Play incorrect sound (from web_generator.py line 11589)
playSound('incorrect')
// Reduce momentum for sprint mode
if (state.style === 'sprint') {
boostMomentum(false)
}
// Show adaptive feedback
const feedback = getAdaptiveFeedbackMessage(pairKey, false, responseTime)
if (feedback) {

View File

@@ -111,6 +111,7 @@ interface ComplementRaceContextValue {
setConfig: (field: keyof ComplementRaceConfig, value: unknown) => void
clearError: () => void
exitSession: () => void
boostMomentum: (correct: boolean) => void // Client-side momentum boost/reduce
}
const ComplementRaceContext = createContext<ComplementRaceContextValue | null>(null)
@@ -252,10 +253,28 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
// Debug logging ref (track last logged values)
const lastLogRef = useState({ key: '', count: 0 })[0]
// Client-side smooth movement state
// Client-side game state (NOT synced to server - purely visual/gameplay)
const [clientMomentum, setClientMomentum] = useState(50) // Start at 50
const [clientPosition, setClientPosition] = useState(0)
const [clientPressure, setClientPressure] = useState(0)
const lastUpdateRef = useRef(Date.now())
const gameStartTimeRef = useRef(0)
// Decay rates based on skill level (momentum lost per second)
const MOMENTUM_DECAY_RATES = {
preschool: 2.0,
kindergarten: 3.5,
relaxed: 5.0,
slow: 7.0,
normal: 9.0,
fast: 11.0,
expert: 13.0,
}
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
// Transform multiplayer state to look like single-player state
const compatibleState = useMemo((): CompatibleGameState => {
@@ -320,10 +339,10 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
previousPosition: ai.position,
})),
// Sprint mode specific
momentum: localPlayer?.momentum || 0,
trainPosition: clientPosition, // Use client-calculated smooth position
pressure: clientPressure, // Use client-calculated smooth pressure
// Sprint mode specific (all client-side for smooth movement)
momentum: clientMomentum, // Client-only state with continuous decay
trainPosition: clientPosition, // Client-calculated from momentum
pressure: clientPressure, // Client-calculated from momentum (0-150 PSI)
elapsedTime: multiplayerState.gameStartTime ? Date.now() - multiplayerState.gameStartTime : 0,
lastCorrectAnswerTime: localPlayer?.lastAnswerTime || Date.now(),
currentRoute: multiplayerState.currentRoute,
@@ -347,44 +366,78 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
}
}, [multiplayerState, localPlayerId, localUIState, clientPosition, clientPressure])
// Client-side game loop for smooth train movement
// Initialize game start time when game becomes active
useEffect(() => {
if (compatibleState.style !== 'sprint' || !compatibleState.isGameActive) return
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(50)
setClientPosition(0)
setClientPressure((50 / 100) * 150) // Initial pressure from starting momentum
}
} else {
// Reset when game ends
gameStartTimeRef.current = 0
}
}, [compatibleState.isGameActive, compatibleState.style])
const UPDATE_INTERVAL = 50 // 50ms = ~20fps
const SPEED_MULTIPLIER = 0.15 // speed = momentum * 0.15 (% per second)
// Main client-side game loop: momentum decay and position calculation
useEffect(() => {
if (!compatibleState.isGameActive || compatibleState.style !== 'sprint') return
const interval = setInterval(() => {
const now = Date.now()
const deltaTime = now - lastUpdateRef.current
lastUpdateRef.current = now
// Get server momentum (authoritative)
const serverMomentum = compatibleState.momentum
// Get decay rate based on skill level
const decayRate =
MOMENTUM_DECAY_RATES[compatibleState.timeoutSetting as keyof typeof MOMENTUM_DECAY_RATES] ||
MOMENTUM_DECAY_RATES.normal
// Calculate speed from momentum
const speed = serverMomentum * SPEED_MULTIPLIER
setClientMomentum((prevMomentum) => {
// Calculate momentum decay for this frame
const momentumLoss = (decayRate * deltaTime) / 1000
// Update position continuously based on momentum
const positionDelta = (speed * deltaTime) / 1000
setClientPosition((prev) => prev + positionDelta)
// Update momentum (don't go below 0)
const newMomentum = Math.max(0, prevMomentum - momentumLoss)
// Calculate pressure from momentum (0-150 PSI)
const pressure = Math.min(150, (serverMomentum / 100) * 150)
setClientPressure(pressure)
// Calculate speed from momentum (% per second)
const speed = newMomentum * SPEED_MULTIPLIER
// Update position (accumulate, never go backward)
const positionDelta = (speed * deltaTime) / 1000
setClientPosition((prev) => prev + positionDelta)
// Calculate pressure (0-150 PSI)
const pressure = Math.min(150, (newMomentum / 100) * 150)
setClientPressure(pressure)
return newMomentum
})
}, UPDATE_INTERVAL)
return () => clearInterval(interval)
}, [compatibleState.style, compatibleState.isGameActive, compatibleState.momentum])
}, [
compatibleState.isGameActive,
compatibleState.style,
compatibleState.timeoutSetting,
MOMENTUM_DECAY_RATES,
SPEED_MULTIPLIER,
UPDATE_INTERVAL,
])
// Sync client position with server position on route changes/resets
// Reset client position when route changes
useEffect(() => {
const serverPosition = multiplayerState.players[localPlayerId || '']?.position || 0
// Only sync if there's a significant jump (route change)
if (Math.abs(serverPosition - clientPosition) > 10) {
setClientPosition(serverPosition)
const currentRoute = multiplayerState.currentRoute
// When route changes, reset position and give starting momentum
if (currentRoute > 1 && compatibleState.style === 'sprint') {
setClientPosition(0)
setClientMomentum(50) // Reset to starting momentum
}
}, [multiplayerState.players, localPlayerId, clientPosition])
}, [multiplayerState.currentRoute, compatibleState.style])
// Debug logging: only log on answer submission or significant events
useEffect(() => {
@@ -679,6 +732,22 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
]
)
// Client-side momentum boost/reduce (sprint mode only)
const boostMomentum = useCallback(
(correct: boolean) => {
if (compatibleState.style !== 'sprint') return
setClientMomentum((prevMomentum) => {
if (correct) {
return Math.min(100, prevMomentum + MOMENTUM_GAIN_PER_CORRECT)
} else {
return Math.max(0, prevMomentum - MOMENTUM_LOSS_PER_WRONG)
}
})
},
[compatibleState.style, MOMENTUM_GAIN_PER_CORRECT, MOMENTUM_LOSS_PER_WRONG]
)
const contextValue: ComplementRaceContextValue = {
state: compatibleState, // Use transformed state
dispatch,
@@ -694,6 +763,7 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
setConfig,
clearError,
exitSession,
boostMomentum, // Client-side momentum control
}
return (

View File

@@ -168,8 +168,7 @@ export class ComplementRaceValidator
bestStreak: 0,
correctAnswers: 0,
totalQuestions: 0,
position: 0,
momentum: 50, // Start with some momentum (position/pressure calculated client-side)
position: 0, // Only used for practice/survival; sprint mode is client-side
isReady: false,
isActive: true,
currentAnswer: null,
@@ -317,15 +316,9 @@ export class ComplementRaceValidator
updatedPlayer.position = Math.min(100, player.position + 100 / state.config.raceGoal)
}
} else if (state.config.style === 'sprint') {
// Sprint: Update momentum only (position calculated client-side for smooth movement)
if (correct) {
updatedPlayer.momentum = Math.min(100, player.momentum + 15)
} else {
updatedPlayer.momentum = Math.max(0, player.momentum - 10)
}
// Position is calculated client-side continuously based on momentum
// This allows for smooth 20fps movement instead of discrete jumps per answer
// Sprint: All momentum/position handled client-side for smooth 20fps movement
// Server only tracks scoring, passengers, and game progression
// No server-side position updates needed
} else if (state.config.style === 'survival') {
// Survival: Always move forward, speed based on accuracy
const moveDistance = correct ? 5 : 2
@@ -525,13 +518,12 @@ export class ComplementRaceValidator
return { valid: false, error: 'Routes only available in sprint mode' }
}
// Reset all player positions to 0 for new route
// Reset all player positions to 0 for new route (client handles momentum reset)
const resetPlayers: Record<string, PlayerState> = {}
for (const [playerId, player] of Object.entries(state.players)) {
resetPlayers[playerId] = {
...player,
position: 0,
momentum: 50, // Reset momentum to starting value
position: 0, // Server position not used in sprint; client will reset
passengers: [], // Clear any remaining passengers
}
}

View File

@@ -61,8 +61,7 @@ export interface PlayerState {
totalQuestions: number
// Position & Progress
position: number // 0-100% for practice/sprint, lap count for survival
momentum: number // 0-100 (sprint mode only, position/pressure calculated client-side)
position: number // 0-100% for practice/survival only (sprint mode: client-side)
// Current state
isReady: boolean

View File

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