fix(complement-race): restore smooth train movement with client-side game loop

**Problem**: Train was jumping discretely on each answer instead of moving smoothly

**Root Cause**: Ported incorrectly - position updated on answer submission instead of continuously

**Original Mechanics** (from useSteamJourney.ts):
- 50ms game loop (20fps) runs continuously
- Position calculated from momentum: `position += (momentum * 0.15 * deltaTime) / 1000`
- Pressure calculated from momentum: `pressure = (momentum / 100) * 150` (0-150 PSI)
- Answers only affect momentum (+15 correct, -10 wrong)

**Fixed Implementation**:
- Client-side game loop at 50ms interval
- Position calculated continuously from server momentum
- Pressure calculated continuously from momentum (0-150 PSI)
- Server only tracks momentum (authoritative)
- Removed discrete position jumps from Validator
- Position/pressure are derived values, not stored

**Files Changed**:
- Provider.tsx: Added client game loop, use calculated position/pressure
- Validator.ts: Removed position updates, only track momentum
- types.ts: Removed pressure field (calculated client-side)

This matches the original smooth movement behavior.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-10-17 07:29:27 -05:00
parent 5d89ad7ada
commit 46a80cbcc8
3 changed files with 52 additions and 21 deletions

View File

@@ -251,6 +251,11 @@ 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
const [clientPosition, setClientPosition] = useState(0)
const [clientPressure, setClientPressure] = useState(0)
const lastUpdateRef = useRef(Date.now())
// Transform multiplayer state to look like single-player state
const compatibleState = useMemo((): CompatibleGameState => {
const localPlayer = localPlayerId ? multiplayerState.players[localPlayerId] : null
@@ -316,8 +321,8 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
// Sprint mode specific
momentum: localPlayer?.momentum || 0,
trainPosition: localPlayer?.position || 0,
pressure: localPlayer?.pressure || 0, // Use actual pressure from server (has decay)
trainPosition: clientPosition, // Use client-calculated smooth position
pressure: clientPressure, // Use client-calculated smooth pressure
elapsedTime: multiplayerState.gameStartTime ? Date.now() - multiplayerState.gameStartTime : 0,
lastCorrectAnswerTime: localPlayer?.lastAnswerTime || Date.now(),
currentRoute: multiplayerState.currentRoute,
@@ -339,7 +344,46 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
adaptiveFeedback: localUIState.adaptiveFeedback,
difficultyTracker: localUIState.difficultyTracker,
}
}, [multiplayerState, localPlayerId, localUIState])
}, [multiplayerState, localPlayerId, localUIState, clientPosition, clientPressure])
// Client-side game loop for smooth train movement
useEffect(() => {
if (compatibleState.style !== 'sprint' || !compatibleState.isGameActive) return
const UPDATE_INTERVAL = 50 // 50ms = ~20fps
const SPEED_MULTIPLIER = 0.15 // speed = momentum * 0.15 (% per second)
const interval = setInterval(() => {
const now = Date.now()
const deltaTime = now - lastUpdateRef.current
lastUpdateRef.current = now
// Get server momentum (authoritative)
const serverMomentum = compatibleState.momentum
// Calculate speed from momentum
const speed = serverMomentum * SPEED_MULTIPLIER
// Update position continuously based on momentum
const positionDelta = (speed * deltaTime) / 1000
setClientPosition((prev) => prev + positionDelta)
// Calculate pressure from momentum (0-150 PSI)
const pressure = Math.min(150, (serverMomentum / 100) * 150)
setClientPressure(pressure)
}, UPDATE_INTERVAL)
return () => clearInterval(interval)
}, [compatibleState.style, compatibleState.isGameActive, compatibleState.momentum])
// Sync client position with server position on route changes/resets
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)
}
}, [multiplayerState.players, localPlayerId, clientPosition])
// Debug logging: only log on answer submission or significant events
useEffect(() => {

View File

@@ -169,8 +169,7 @@ export class ComplementRaceValidator
correctAnswers: 0,
totalQuestions: 0,
position: 0,
momentum: 50, // Start with some momentum in sprint mode
pressure: 60, // Start with initial pressure
momentum: 50, // Start with some momentum (position/pressure calculated client-side)
isReady: false,
isActive: true,
currentAnswer: null,
@@ -318,25 +317,15 @@ export class ComplementRaceValidator
updatedPlayer.position = Math.min(100, player.position + 100 / state.config.raceGoal)
}
} else if (state.config.style === 'sprint') {
// Sprint: Update momentum, pressure, AND position
// Sprint: Update momentum only (position calculated client-side for smooth movement)
if (correct) {
updatedPlayer.momentum = Math.min(100, player.momentum + 15)
// Add pressure on correct answer (add steam to boiler)
updatedPlayer.pressure = Math.min(100, player.pressure + 20)
} else {
updatedPlayer.momentum = Math.max(0, player.momentum - 10)
// Less pressure added on wrong answer
updatedPlayer.pressure = Math.min(100, player.pressure + 5)
}
// Pressure decay: Every answer causes some steam to escape
// Decay rate: 8 points per answer (pressure naturally decreases over time)
updatedPlayer.pressure = Math.max(0, updatedPlayer.pressure - 8)
// Move train based on momentum (momentum/20 = position change per answer)
// Higher momentum = faster movement
const moveDistance = updatedPlayer.momentum / 20
updatedPlayer.position = Math.min(100, player.position + moveDistance)
// Position is calculated client-side continuously based on momentum
// This allows for smooth 20fps movement instead of discrete jumps per answer
} else if (state.config.style === 'survival') {
// Survival: Always move forward, speed based on accuracy
const moveDistance = correct ? 5 : 2
@@ -543,7 +532,6 @@ export class ComplementRaceValidator
...player,
position: 0,
momentum: 50, // Reset momentum to starting value
pressure: 60, // Reset pressure to starting value
passengers: [], // Clear any remaining passengers
}
}

View File

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