diff --git a/GameModules/SparkGameFPS/Source/Game/MultiplayerSystem.cpp b/GameModules/SparkGameFPS/Source/Game/MultiplayerSystem.cpp index 62c6cc4de..9a24c8c7e 100644 --- a/GameModules/SparkGameFPS/Source/Game/MultiplayerSystem.cpp +++ b/GameModules/SparkGameFPS/Source/Game/MultiplayerSystem.cpp @@ -8,6 +8,7 @@ #include "Utils/SparkConsole.h" #include +#include #include #include @@ -71,8 +72,15 @@ namespace SparkFPS m_playerStates.clear(); m_scores.clear(); m_respawnTimers.clear(); + m_projectiles.clear(); + m_remoteSnapshots.clear(); + m_lastInputByPlayer.clear(); m_stateSequence = 0; + m_nextProjectileId = 1; + m_correctionCount = 0; m_tickAccumulator = 0.0f; + m_clientPrediction.SetMaxPendingInputs(256); + m_clientPrediction.SetSmoothCorrection(true, 10.0f); // Default spawn points if none configured if (m_spawnPoints.empty()) @@ -108,6 +116,8 @@ namespace SparkFPS Disconnect(); m_playerStates.clear(); + m_projectiles.clear(); + m_remoteSnapshots.clear(); m_scores.clear(); m_isActive = false; } @@ -122,6 +132,8 @@ namespace SparkFPS (void)maxPlayers; m_isActive = true; m_isServer = true; + m_localClientId = 1; + OnPlayerJoined(m_localClientId); auto& console = Spark::SimpleConsole::GetInstance(); console.Log("[FPSMultiplayer] Server started on port " + std::to_string(port) + " (max " + @@ -146,6 +158,7 @@ namespace SparkFPS (void)port; m_isActive = true; m_isServer = false; + m_localClientId = 2; auto& console = Spark::SimpleConsole::GetInstance(); console.Log("[FPSMultiplayer] Connecting to " + address + ":" + std::to_string(port)); @@ -163,8 +176,41 @@ namespace SparkFPS if (!m_isActive || m_isServer) return; - // In a real implementation, this would serialize and send via NetworkManager - (void)input; + m_lastInputByPlayer[m_localClientId] = input; + + Spark::PredictedInput predicted{}; + predicted.timestamp = static_cast(input.sequenceNumber) * (1.0f / 60.0f); + predicted.moveDirection = {input.strafe, 0.0f, input.forward}; + predicted.lookYaw = input.yaw; + predicted.lookPitch = input.pitch; + predicted.jump = input.jump; + predicted.crouch = input.crouch; + predicted.fire = input.fire; + predicted.reload = input.reload; + const uint32_t assignedSequence = m_clientPrediction.RecordInput(predicted); + predicted.sequenceNumber = assignedSequence; + + m_clientPrediction.ApplyPrediction(m_localPredictedState, predicted, 1.0f / 60.0f); + if (input.fire) + { + ProjectileData projectile; + projectile.projectileId = m_nextProjectileId++; + projectile.ownerId = m_localClientId; + projectile.originX = m_localPredictedState.position.x; + projectile.originY = m_localPredictedState.position.y + 1.0f; + projectile.originZ = m_localPredictedState.position.z; + projectile.positionX = projectile.originX; + projectile.positionY = projectile.originY; + projectile.positionZ = projectile.originZ; + projectile.dirX = std::cos(input.yaw); + projectile.dirY = 0.0f; + projectile.dirZ = std::sin(input.yaw); + projectile.velocityX = projectile.dirX * projectile.speed; + projectile.velocityY = projectile.dirY * projectile.speed; + projectile.velocityZ = projectile.dirZ * projectile.speed; + projectile.active = true; + m_projectiles[projectile.projectileId] = projectile; // local fire prediction hook + } } // ============================================================================ @@ -222,6 +268,7 @@ namespace SparkFPS // Send state snapshots at tick rate m_tickAccumulator += dt; + UpdateProjectiles(dt); float tickInterval = 1.0f / static_cast(m_tickRate); if (m_tickAccumulator >= tickInterval) { @@ -236,8 +283,19 @@ namespace SparkFPS for (auto& [id, state] : m_playerStates) { state.sequenceNumber = m_stateSequence; + auto inputIt = m_lastInputByPlayer.find(id); + if (inputIt != m_lastInputByPlayer.end()) + { + state.acknowledgedInputSequence = inputIt->second.sequenceNumber; + } + auto& history = m_remoteSnapshots[id]; + history.push_back(state); + constexpr size_t kMaxSnapshots = 4; + while (history.size() > kMaxSnapshots) + { + history.pop_front(); + } } - // In real impl: serialize all player states, send unreliable to all clients } void FPSMultiplayerSystem::ApplyClientInput(uint32_t clientId, const PlayerInput& input, float dt) @@ -254,10 +312,40 @@ namespace SparkFPS state.posX += (input.forward * cosYaw + input.strafe * sinYaw) * m_moveSpeed * dt; state.posZ += (input.forward * sinYaw - input.strafe * cosYaw) * m_moveSpeed * dt; + state.velX = (input.forward * cosYaw + input.strafe * sinYaw) * m_moveSpeed; + state.velY = 0.0f; + state.velZ = (input.forward * sinYaw - input.strafe * cosYaw) * m_moveSpeed; state.yaw = input.yaw; state.pitch = input.pitch; state.isCrouching = input.crouch; + state.actionFlags = ActionNone; + state.actionFlags |= input.jump ? ActionJump : ActionNone; + state.actionFlags |= input.fire ? ActionFire : ActionNone; + state.actionFlags |= input.reload ? ActionReload : ActionNone; + state.actionFlags |= input.crouch ? ActionCrouch : ActionNone; + m_lastInputByPlayer[clientId] = input; + + if (input.fire) + { + ProjectileData projectile; + projectile.projectileId = m_nextProjectileId++; + projectile.ownerId = clientId; + projectile.originX = state.posX; + projectile.originY = state.posY + 1.0f; + projectile.originZ = state.posZ; + projectile.positionX = projectile.originX; + projectile.positionY = projectile.originY; + projectile.positionZ = projectile.originZ; + projectile.dirX = std::cos(state.yaw); + projectile.dirY = 0.0f; + projectile.dirZ = std::sin(state.yaw); + projectile.velocityX = projectile.dirX * projectile.speed; + projectile.velocityY = 0.0f; + projectile.velocityZ = projectile.dirZ * projectile.speed; + projectile.active = true; + m_projectiles[projectile.projectileId] = projectile; + } } void FPSMultiplayerSystem::ValidateHit(uint32_t attackerId, uint32_t victimId, float damage) @@ -266,6 +354,26 @@ namespace SparkFPS if (victimIt == m_playerStates.end() || !victimIt->second.isAlive) return; + bool validated = true; + if (m_isServer) + { + auto attackerIt = m_playerStates.find(attackerId); + if (attackerIt != m_playerStates.end()) + { + const auto rayOrigin = + DirectX::XMFLOAT3(attackerIt->second.posX, attackerIt->second.posY + 1.0f, attackerIt->second.posZ); + const auto rayDir = DirectX::XMFLOAT3(victimIt->second.posX - attackerIt->second.posX, + victimIt->second.posY - attackerIt->second.posY, + victimIt->second.posZ - attackerIt->second.posZ); + const float halfRTT = Spark::Net::NetworkManager::GetInstance().GetEstimatedRTT() * 0.0005f; + const float now = Spark::Net::NetworkManager::GetInstance().GetServerTime(); + auto result = Spark::Net::NetworkManager::GetInstance().ValidateHit(now, halfRTT, rayOrigin, rayDir); + validated = result.hit; + } + } + if (!validated) + return; + victimIt->second.health -= damage; if (victimIt->second.health <= 0.0f) @@ -314,14 +422,42 @@ namespace SparkFPS void FPSMultiplayerSystem::ClientUpdate(float dt) { + auto localIt = m_playerStates.find(m_localClientId); + if (localIt != m_playerStates.end()) + { + ReconcileToAuthoritativeState(localIt->second); + } InterpolateRemotePlayers(dt); + UpdateProjectiles(dt); } void FPSMultiplayerSystem::InterpolateRemotePlayers(float dt) { - // In real impl: interpolate between last two server snapshots - // using a 100ms interpolation buffer for smooth movement - (void)dt; + const float blend = (std::min)(1.0f, dt * 10.0f); + for (auto& [playerId, snapshots] : m_remoteSnapshots) + { + if (playerId == m_localClientId || snapshots.empty()) + continue; + + auto target = snapshots.back(); + auto currentIt = m_playerStates.find(playerId); + if (currentIt == m_playerStates.end()) + { + m_playerStates[playerId] = target; + continue; + } + + auto& current = currentIt->second; + current.posX += (target.posX - current.posX) * blend; + current.posY += (target.posY - current.posY) * blend; + current.posZ += (target.posZ - current.posZ) * blend; + current.velX = target.velX; + current.velY = target.velY; + current.velZ = target.velZ; + current.yaw = target.yaw; + current.pitch = target.pitch; + current.actionFlags = target.actionFlags; + } } // ============================================================================ @@ -341,6 +477,7 @@ namespace SparkFPS state.isAlive = true; m_playerStates[clientId] = state; + m_remoteSnapshots[clientId].push_back(state); PlayerScore score; score.clientId = clientId; @@ -370,9 +507,24 @@ namespace SparkFPS void FPSMultiplayerSystem::OnProjectileFired(uint32_t clientId, const ProjectileData& proj) { - (void)clientId; - (void)proj; - // In real impl: validate, create server-side projectile, broadcast to all clients + if (!m_isServer) + return; + + auto ownerIt = m_playerStates.find(clientId); + if (ownerIt == m_playerStates.end() || !ownerIt->second.isAlive) + return; + + ProjectileData serverProj = proj; + serverProj.projectileId = (serverProj.projectileId == 0) ? m_nextProjectileId++ : serverProj.projectileId; + serverProj.ownerId = clientId; + serverProj.active = true; + serverProj.positionX = serverProj.originX; + serverProj.positionY = serverProj.originY; + serverProj.positionZ = serverProj.originZ; + serverProj.velocityX = serverProj.dirX * serverProj.speed; + serverProj.velocityY = serverProj.dirY * serverProj.speed; + serverProj.velocityZ = serverProj.dirZ * serverProj.speed; + m_projectiles[serverProj.projectileId] = serverProj; } void FPSMultiplayerSystem::OnPlayerDamaged(uint32_t attackerId, uint32_t victimId, float damage) @@ -397,9 +549,94 @@ namespace SparkFPS status += m_isServer ? "Server" : "Client"; status += " | Players: " + std::to_string(m_playerStates.size()); + status += " | Projectiles: " + std::to_string(m_projectiles.size()); status += " | Tick: " + std::to_string(m_tickRate) + "Hz"; status += " | Seq: " + std::to_string(m_stateSequence); + auto metrics = GetDebugMetrics(); + status += " | RTT: " + std::to_string(static_cast(metrics.rttMs)) + "ms"; + status += " | Loss: " + std::to_string(static_cast(metrics.packetLossPercent)) + "%"; + status += " | Corrections: " + std::to_string(metrics.correctionCount); return status; } + FPSMultiplayerSystem::MultiplayerDebugMetrics FPSMultiplayerSystem::GetDebugMetrics() const + { + MultiplayerDebugMetrics out; + const auto& stats = Spark::Net::NetworkManager::GetInstance().GetStats(); + out.packetLossPercent = stats.packetLoss * 100.0f; + out.rttMs = stats.ping; + out.correctionCount = m_correctionCount; + return out; + } + + void FPSMultiplayerSystem::UpdateProjectiles(float dt) + { + for (auto it = m_projectiles.begin(); it != m_projectiles.end();) + { + auto& projectile = it->second; + projectile.positionX += projectile.velocityX * dt; + projectile.positionY += projectile.velocityY * dt; + projectile.positionZ += projectile.velocityZ * dt; + projectile.lifetime -= dt; + + bool despawned = (projectile.lifetime <= 0.0f); + if (!despawned && m_isServer) + { + for (const auto& [playerId, state] : m_playerStates) + { + if (playerId == projectile.ownerId || !state.isAlive) + continue; + + const float dx = projectile.positionX - state.posX; + const float dy = projectile.positionY - state.posY; + const float dz = projectile.positionZ - state.posZ; + const float distSq = dx * dx + dy * dy + dz * dz; + if (distSq <= 1.0f) + { + ValidateHit(projectile.ownerId, playerId, projectile.damage); + despawned = true; + break; + } + } + } + + if (despawned) + it = m_projectiles.erase(it); + else + ++it; + } + } + + void FPSMultiplayerSystem::ReconcileToAuthoritativeState(const NetworkPlayerState& authoritativeState) + { + Spark::PredictedState serverState; + serverState.position = {authoritativeState.posX, authoritativeState.posY, authoritativeState.posZ}; + serverState.velocity = {authoritativeState.velX, authoritativeState.velY, authoritativeState.velZ}; + serverState.yaw = authoritativeState.yaw; + serverState.pitch = authoritativeState.pitch; + serverState.isCrouching = authoritativeState.isCrouching; + serverState.lastProcessedInput = authoritativeState.acknowledgedInputSequence; + + const float before = m_clientPrediction.GetLastCorrectionMagnitude(); + m_clientPrediction.Reconcile(serverState, 1.0f / 60.0f); + const float after = m_clientPrediction.GetLastCorrectionMagnitude(); + if (after > 0.01f && after != before) + { + ++m_correctionCount; + Spark::Net::NetworkManager::GetInstance().SetPredictionCorrectionCount(m_correctionCount); + } + + const auto& predicted = m_clientPrediction.GetState(); + auto& local = m_playerStates[m_localClientId]; + local.posX = predicted.position.x; + local.posY = predicted.position.y; + local.posZ = predicted.position.z; + local.velX = predicted.velocity.x; + local.velY = predicted.velocity.y; + local.velZ = predicted.velocity.z; + local.yaw = predicted.yaw; + local.pitch = predicted.pitch; + local.isCrouching = predicted.isCrouching; + } + } // namespace SparkFPS diff --git a/GameModules/SparkGameFPS/Source/Game/MultiplayerSystem.h b/GameModules/SparkGameFPS/Source/Game/MultiplayerSystem.h index 16eb921dd..233d6ae2d 100644 --- a/GameModules/SparkGameFPS/Source/Game/MultiplayerSystem.h +++ b/GameModules/SparkGameFPS/Source/Game/MultiplayerSystem.h @@ -17,10 +17,12 @@ #pragma once #include "Engine/Networking/NetworkManager.h" +#include "Engine/Networking/ClientPrediction.h" #include "Core/Platform.h" #include #include +#include #include #include #include @@ -54,12 +56,25 @@ namespace SparkFPS // ============================================================================ /** @brief Replicated player state sent in each snapshot. */ + enum PlayerActionFlags : uint32_t + { + ActionNone = 0, + ActionJump = 1u << 0, + ActionFire = 1u << 1, + ActionReload = 1u << 2, + ActionCrouch = 1u << 3, + ActionSprint = 1u << 4 + }; + struct NetworkPlayerState { uint32_t clientId = 0; float posX = 0.0f, posY = 0.0f, posZ = 0.0f; + float velX = 0.0f, velY = 0.0f, velZ = 0.0f; float yaw = 0.0f, pitch = 0.0f; float health = 100.0f; + uint32_t actionFlags = ActionNone; + uint32_t acknowledgedInputSequence = 0; uint8_t currentWeapon = 0; bool isAlive = true; bool isCrouching = false; @@ -104,12 +119,17 @@ namespace SparkFPS /** @brief Projectile replication data. */ struct ProjectileData { + uint32_t projectileId = 0; uint32_t ownerId = 0; uint8_t weaponType = 0; float originX = 0.0f, originY = 0.0f, originZ = 0.0f; float dirX = 0.0f, dirY = 0.0f, dirZ = 0.0f; + float positionX = 0.0f, positionY = 0.0f, positionZ = 0.0f; + float velocityX = 0.0f, velocityY = 0.0f, velocityZ = 0.0f; + float lifetime = 3.0f; float speed = 500.0f; float damage = 25.0f; + bool active = false; }; // ============================================================================ @@ -195,6 +215,14 @@ namespace SparkFPS /** @brief Console status string. */ std::string Console_GetStatus() const; + struct MultiplayerDebugMetrics + { + float packetLossPercent = 0.0f; + float rttMs = 0.0f; + uint32_t correctionCount = 0; + }; + MultiplayerDebugMetrics GetDebugMetrics() const; + private: FPSMultiplayerSystem() = default; @@ -210,12 +238,14 @@ namespace SparkFPS void SendStateSnapshot(); void ApplyClientInput(uint32_t clientId, const PlayerInput& input, float dt); void ValidateHit(uint32_t attackerId, uint32_t victimId, float damage); + void UpdateProjectiles(float dt); SpawnPoint GetRandomSpawnPoint() const; void RespawnPlayer(uint32_t clientId); // -- Client logic -- void ClientUpdate(float dt); void InterpolateRemotePlayers(float dt); + void ReconcileToAuthoritativeState(const NetworkPlayerState& authoritativeState); bool m_isServer = false; bool m_isActive = false; @@ -226,10 +256,17 @@ namespace SparkFPS float m_respawnTime = 5.0f; std::unordered_map m_playerStates; + std::unordered_map m_projectiles; std::unordered_map m_scores; std::unordered_map m_respawnTimers; + std::unordered_map> m_remoteSnapshots; + std::unordered_map m_lastInputByPlayer; std::vector m_spawnPoints; uint32_t m_stateSequence = 0; + uint32_t m_nextProjectileId = 1; + Spark::ClientPrediction m_clientPrediction; + Spark::PredictedState m_localPredictedState{}; + uint32_t m_correctionCount = 0; }; } // namespace SparkFPS diff --git a/SparkEngine/Source/Engine/Networking/NetworkConnection.cpp b/SparkEngine/Source/Engine/Networking/NetworkConnection.cpp index 8222c02f8..4e865c0a3 100644 --- a/SparkEngine/Source/Engine/Networking/NetworkConnection.cpp +++ b/SparkEngine/Source/Engine/Networking/NetworkConnection.cpp @@ -1091,6 +1091,7 @@ namespace Spark::Net ss << "Bandwidth: Up " << m_stats.bandwidthUp << " KB/s, Down " << m_stats.bandwidthDown << " KB/s\n"; ss << "Packets: Sent " << m_stats.packetsSent << ", Received " << m_stats.packetsReceived << ", Dropped " << m_stats.packetsDropped << "\n"; + ss << "Prediction Corrections: " << m_stats.correctionCount << "\n"; ss << "Bytes: Sent " << m_stats.bytesSent << ", Received " << m_stats.bytesReceived << "\n"; ss << "Unacked reliable messages: " << m_unacknowledgedMessages.size() << "\n"; return ss.str(); diff --git a/SparkEngine/Source/Engine/Networking/NetworkManager.h b/SparkEngine/Source/Engine/Networking/NetworkManager.h index fb1378131..8ef8881ac 100644 --- a/SparkEngine/Source/Engine/Networking/NetworkManager.h +++ b/SparkEngine/Source/Engine/Networking/NetworkManager.h @@ -299,6 +299,7 @@ namespace Spark::Net uint32_t packetsSent = 0; ///< Total UDP packets sent. uint32_t packetsReceived = 0; ///< Total UDP packets received. uint32_t packetsDropped = 0; ///< Packets detected as lost (sequence gaps). + uint32_t correctionCount = 0; ///< Number of client prediction corrections observed. float bandwidthUp = 0.0f; ///< KB/s float bandwidthDown = 0.0f; ///< KB/s }; @@ -400,6 +401,7 @@ namespace Spark::Net } float GetServerTime() const { return m_serverTime; } const NetworkStats& GetStats() const { return m_stats; } + void SetPredictionCorrectionCount(uint32_t correctionCount) { m_stats.correctionCount = correctionCount; } bool IsInitialized() const { return m_initialized; } // Client management (server only) diff --git a/Tests/TestNetworkReplicationIntegration.cpp b/Tests/TestNetworkReplicationIntegration.cpp index 055e79131..9f1336e6b 100644 --- a/Tests/TestNetworkReplicationIntegration.cpp +++ b/Tests/TestNetworkReplicationIntegration.cpp @@ -8,8 +8,10 @@ #include "TestFramework.h" #include "Engine/Networking/NetworkManager.h" +#include "Engine/Networking/ClientPrediction.h" #include "Engine/ECS/Components/CoreComponents.h" #include +#include #include using namespace Spark; @@ -318,3 +320,99 @@ TEST(NetReplication_LagCompensationRewind) EXPECT_EQ(closestIdx, static_cast(6)); EXPECT_NEAR(history[closestIdx].posX, 3.0f, 0.001f); } + +TEST(NetReplication_MultiplayerSmoke_ServerClientConvergence) +{ + struct PlayerState + { + float x = 0.0f; + float z = 0.0f; + float vx = 0.0f; + float vz = 0.0f; + uint32_t ack = 0; + }; + struct ProjectileState + { + bool active = false; + float x = 0.0f; + float z = 0.0f; + float vx = 0.0f; + float vz = 0.0f; + float ttl = 0.0f; + }; + + constexpr float dt = 1.0f / 60.0f; + Spark::ClientPrediction prediction; + Spark::PredictedState predicted; + PlayerState server{}; + ProjectileState projectile{}; + std::deque sentInputs; + uint32_t correctionCount = 0; + + for (int frame = 0; frame < 180; ++frame) + { + Spark::PredictedInput input{}; + input.timestamp = static_cast(frame) * dt; + input.moveDirection = {0.0f, 0.0f, 1.0f}; + input.lookYaw = 0.0f; + input.lookPitch = 0.0f; + input.fire = (frame == 30); + + const uint32_t sequence = prediction.RecordInput(input); + input.sequenceNumber = sequence; + sentInputs.push_back(input); + + prediction.ApplyPrediction(predicted, input, dt); + + // Simulate 2-frame transport latency for server authoritative processing. + if (sentInputs.size() >= 2) + { + const auto authoritativeInput = sentInputs.front(); + sentInputs.pop_front(); + + server.vx = authoritativeInput.moveDirection.x * 5.0f; + server.vz = authoritativeInput.moveDirection.z * 5.0f; + server.x += server.vx * dt; + server.z += server.vz * dt; + server.ack = authoritativeInput.sequenceNumber; + + if (authoritativeInput.fire) + { + projectile.active = true; + projectile.x = server.x; + projectile.z = server.z; + projectile.vx = 0.0f; + projectile.vz = 30.0f; + projectile.ttl = 0.5f; + } + } + + if (projectile.active) + { + projectile.x += projectile.vx * dt; + projectile.z += projectile.vz * dt; + projectile.ttl -= dt; + if (projectile.ttl <= 0.0f) + projectile.active = false; + } + + Spark::PredictedState authoritative{}; + authoritative.position = {server.x, 0.0f, server.z}; + authoritative.velocity = {server.vx, 0.0f, server.vz}; + authoritative.lastProcessedInput = server.ack; + + prediction.Reconcile(authoritative, dt); + if (prediction.GetLastCorrectionMagnitude() > 0.001f) + ++correctionCount; + } + + // Client and server should converge closely after reconciliation. + EXPECT_NEAR(prediction.GetState().position.x, server.x, 0.05f); + EXPECT_NEAR(prediction.GetState().position.z, server.z, 0.05f); + + // Projectile should have been spawned and later despawned by TTL. + EXPECT_FALSE(projectile.active); + + // Prediction corrections should have occurred due to latency and reconciliation. + EXPECT_TRUE(correctionCount > 0); +}