From a097f4d4b0ecfd2ac10c725bbed961e1c5d087bc Mon Sep 17 00:00:00 2001 From: Martin Slachta Date: Mon, 3 Aug 2026 23:30:18 +0200 Subject: [PATCH] #1 - ClientWorldController refactoring --- modules/client/CMakeLists.txt | 5 +- modules/client/src/app/GameState.hpp | 4 +- .../src/debug/EntityInterpolationDebug.hpp | 174 ++++--- .../src/debug/tools/EntityManagerGui.cpp | 3 +- .../src/network/EntityDerivationWriter.hpp | 10 - modules/client/src/network/EntityIdMap.hpp | 32 ++ .../network/EntityPositionInterpolator.cpp | 17 +- .../network/EntityPositionInterpolator.hpp | 12 +- ...layerReconciler.cpp => PlayerRollback.cpp} | 18 +- ...layerReconciler.hpp => PlayerRollback.hpp} | 16 +- .../client/src/network/ReplicatorClient.cpp | 187 ++++++++ .../client/src/network/ReplicatorClient.hpp | 114 +++++ .../client/src/network/ServerConnection.cpp | 23 - .../client/src/network/ServerConnection.hpp | 28 +- .../src/world/ClientWorldController.cpp | 447 ------------------ .../src/world/ClientWorldController.hpp | 150 ------ .../controllers/ClientWorldController.cpp | 242 ++++++++++ .../controllers/ClientWorldController.hpp | 107 +++++ .../LocalWorldController.cpp | 0 .../LocalWorldController.hpp | 0 modules/mock_client/src/main.cpp | 7 +- modules/protocol/proto/Entity.proto | 6 +- .../include/tw/serial/WorldStateWriter.hpp | 92 ---- modules/server/src/PlayerSession.hpp | 7 +- modules/server/src/ZoneClusterLink.hpp | 8 - modules/server/src/ZoneManager.cpp | 24 +- modules/server/src/ZoneManager.hpp | 32 +- modules/server/src/ZoneServer.cpp | 9 +- .../src/interest_management/FixedGrid.hpp | 25 - .../src/replication/StateReplicator.hpp | 39 +- modules/server/src/systems/InterestSystem.hpp | 28 +- modules/server/tests/ZoneManagerTests.cpp | 8 +- src/runtime/LockStep.hpp | 2 +- src/world/CharacterController.hpp | 31 +- src/world/JoltPhysicsWorld.cpp | 2 +- 35 files changed, 899 insertions(+), 1010 deletions(-) delete mode 100644 modules/client/src/network/EntityDerivationWriter.hpp create mode 100644 modules/client/src/network/EntityIdMap.hpp rename modules/client/src/network/{PlayerReconciler.cpp => PlayerRollback.cpp} (85%) rename modules/client/src/network/{PlayerReconciler.hpp => PlayerRollback.hpp} (76%) create mode 100644 modules/client/src/network/ReplicatorClient.cpp create mode 100644 modules/client/src/network/ReplicatorClient.hpp delete mode 100644 modules/client/src/world/ClientWorldController.cpp delete mode 100644 modules/client/src/world/ClientWorldController.hpp create mode 100644 modules/client/src/world/controllers/ClientWorldController.cpp create mode 100644 modules/client/src/world/controllers/ClientWorldController.hpp rename modules/client/src/world/{ => controllers}/LocalWorldController.cpp (100%) rename modules/client/src/world/{ => controllers}/LocalWorldController.hpp (100%) diff --git a/modules/client/CMakeLists.txt b/modules/client/CMakeLists.txt index 0ee2c71..1e6e08d 100644 --- a/modules/client/CMakeLists.txt +++ b/modules/client/CMakeLists.txt @@ -7,6 +7,7 @@ file(GLOB FILES src/app/*.cpp src/network/*.cpp src/world/*.cpp + src/world/controllers/*.cpp src/io/*.cpp src/draw/*.cpp src/draw/RenderPasses/*.cpp @@ -39,8 +40,8 @@ target_link_libraries(${PROJECT_NAME} Jolt Tracy::TracyClient TracyClient - pqxx - pq + # pqxx + # pq ) target_include_directories(${PROJECT_NAME} diff --git a/modules/client/src/app/GameState.hpp b/modules/client/src/app/GameState.hpp index 24097d5..9a21195 100644 --- a/modules/client/src/app/GameState.hpp +++ b/modules/client/src/app/GameState.hpp @@ -6,12 +6,12 @@ #include "debug/tools/EntityManagerGui.hpp" #include "debug/tools/NetworkStatsGui.hpp" #include "network/ServerConnection.hpp" -#include "world/ClientWorldController.hpp" +#include "world/controllers/ClientWorldController.hpp" namespace tw::app { /** - * The game world state. Owns the connection, world controller, and debug GUIs. + * The game world state. * Responsible for updating the game simulation and rendering debug information. */ class GameState { diff --git a/modules/client/src/debug/EntityInterpolationDebug.hpp b/modules/client/src/debug/EntityInterpolationDebug.hpp index b23f27f..4daca0c 100644 --- a/modules/client/src/debug/EntityInterpolationDebug.hpp +++ b/modules/client/src/debug/EntityInterpolationDebug.hpp @@ -1,92 +1,126 @@ #pragma once #include +#include + +#include #include -#include -#include +#include +#include #include "debug/ComponentGui.hpp" -#include -#include #include "network/EntityInterpolation.hpp" +#include "world/Transform.hpp" + template<> -class tw::dbg::ComponentGui { +class tw::dbg::ComponentGui { +private: + using Clock = std::chrono::high_resolution_clock; + + /** Ten seconds of frames at sixty a second. */ + static constexpr size_t CAPACITY = 600; + + /** How much of the trace the plot shows. */ + static constexpr float WINDOW_IN_SECONDS = 10.0f; + + inline static const Transform* m_subject = nullptr; + inline static int m_last_frame = -1; + inline static Clock::time_point m_started_at {}; + inline static size_t m_head = 0; + inline static size_t m_count = 0; + inline static std::vector m_times; + inline static std::vector m_positions; + + void restart(const Transform* instance) { + m_subject = instance; + m_last_frame = -1; + m_started_at = Clock::now(); + m_head = 0; + m_count = 0; + + m_times.resize(CAPACITY); + m_positions.resize(CAPACITY); + } + + /** Keeps one sample per frame, so drawing twice does not double up. */ + void sample(float time, float position) { + const int frame = ImGui::GetFrameCount(); + if(m_last_frame == frame) { + return; + } + m_last_frame = frame; + + m_times[m_head] = time; + m_positions[m_head] = position; + + m_head = (m_head + 1) % CAPACITY; + m_count = std::min(m_count + 1, CAPACITY); + } + public: - void draw(net::EntityPositionInterpolation* instance) { - ImGui::SeparatorText("Entity Interpolation"); + void draw(Transform* instance) { + ImGui::SeparatorText("Drawn Position"); - bool m_is_scrolling = true; - static float m_metric_history = 10.0f; - ImGui::Checkbox("Is Scrolling", &m_is_scrolling); - if(m_is_scrolling) { - ImGui::SliderFloat("History", &m_metric_history,1,30,"%.1f s"); + static int axis = 0; + ImGui::Combo("Axis", &axis, "X\0Y\0Z\0"); + + if(m_subject != instance) { + restart(instance); } - static int32_t time_buffer_len = 1000; - ImGui::SliderInt("Buffer length (ms)", &time_buffer_len, 0, 1000); + const float time = std::chrono::duration(Clock::now() - m_started_at).count(); + const float position = instance->position()[axis]; - auto now = std::chrono::high_resolution_clock::now() - std::chrono::milliseconds(time_buffer_len); - auto [from, to, mix] = instance->get_values_around(now); - auto mixed_value = glm::mix(from, to, mix); + sample(time, position); - ImGui::Text("Mixed value: (%f, %f)", mixed_value.x, mixed_value.y); + ImGui::Text("%.3f", position); - if(ImPlot::BeginPlot("Interpolation")) { - auto plot_x_from = instance->times().begin()->time_since_epoch().count(); - std::vector times(instance->times().max_size()); - for(auto it = instance->times().begin(); it != instance->times().end(); ++it) { - times.push_back(it->time_since_epoch().count() - plot_x_from); - } + if(ImPlot::BeginPlot("Position", ImVec2(-1.0f, 180.0f))) { + ImPlot::SetupAxes("seconds", "position", ImPlotAxisFlags_None, ImPlotAxisFlags_AutoFit); + ImPlot::SetupAxisLimits(ImAxis_X1, + time - WINDOW_IN_SECONDS, time, ImGuiCond_Always); - float interpolated_time = now.time_since_epoch().count() - plot_x_from; - std::vector values(instance->values().max_size()); - for(auto it = instance->values().begin(); it != instance->values().end(); ++it) { - values.push_back(it->x); - } - values.push_back(mixed_value.x); + // The trace wraps around, so it is read from wherever the oldest + // sample ended up. + const int offset = m_count == CAPACITY ? (int)m_head : 0; + ImPlot::PlotLine("Drawn", m_times.data(), m_positions.data(), + (int)m_count, ImPlotLineFlags_None, offset); - ImPlot::PlotScatter("Server", times.data(), values.data(), times.size() - 1); - ImPlot::PushStyleVar(ImPlotStyleVar_FillAlpha, 0.25f); - ImPlot::SetNextMarkerStyle(ImPlotMarker_Square, 6, ImPlot::GetColormapColor(1), IMPLOT_AUTO, ImPlot::GetColormapColor(1)); - ImPlot::PlotScatter("Client", &interpolated_time, &mixed_value.x, 1); - ImPlot::PopStyleVar(); ImPlot::EndPlot(); } - - if(ImGui::BeginTable("entityInterpolation", 2)) { - for(int i = 0; i < instance->values().size(); i++) { - ImGui::TableNextRow(); - - ImGui::TableNextColumn(); - auto value = instance->values()[i]; - auto now = std::chrono::high_resolution_clock::now() - std::chrono::milliseconds(time_buffer_len); - if(instance->times()[i] > now && (i == instance->values().size() - 1 || instance->times()[i + 1] <= now)) { - ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg1, ImGui::GetColorU32(ImVec4(0.7f, 0.3f, 0.3f, 0.65f))); - ImGui::Text("%f %f %f", mixed_value.x, mixed_value.y, mixed_value.z); - ImGui::TableNextColumn(); - ImGui::TableNextRow(); - ImGui::TableNextColumn(); - ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg1, ImGui::GetColorU32(ImVec4(0.0f, 0.0f, 0.0f, 0.0f))); - } - - ImGui::Text("%f %f %f", value.x, value.y, value.z); - - auto point = instance->times()[i].time_since_epoch(); - auto hours = std::chrono::duration_cast(point); - point -= hours; - hours %= 24; - auto minutes = std::chrono::duration_cast(point); - point -= minutes; - auto seconds = std::chrono::duration_cast(point); - point -= seconds; - auto milliseconds = std::chrono::duration_cast(point); - - ImGui::TableNextColumn(); - auto fmt = std::format("{}:{}:{}.{}", hours, minutes, seconds, milliseconds); - ImGui::Text(fmt.c_str()); - } - ImGui::EndTable(); - } + } +}; + +/** + * Says whether the position above stands still because nothing new arrives for + * the entity, or because what arrives is the same position over and over. + */ +template<> +class tw::dbg::ComponentGui { +private: + using Clock = std::chrono::high_resolution_clock; + + static int64_t millis_since(Clock::time_point point) { + return std::chrono::duration_cast(Clock::now() - point).count(); + } + +public: + void draw(net::EntityPositionInterpolation* instance) { + ImGui::SeparatorText("Received Positions"); + + const size_t newest = instance->values().size() - 1; + + ImGui::Text("Newest %.3f %.3f %.3f, %ld ms ago", + instance->values()[newest].x, + instance->values()[newest].y, + instance->values()[newest].z, + millis_since(instance->times()[newest])); + + ImGui::Text("Oldest %.3f %.3f %.3f, %ld ms ago", + instance->values()[0].x, + instance->values()[0].y, + instance->values()[0].z, + millis_since(instance->times()[0])); } }; diff --git a/modules/client/src/debug/tools/EntityManagerGui.cpp b/modules/client/src/debug/tools/EntityManagerGui.cpp index 15975f5..1b1591a 100644 --- a/modules/client/src/debug/tools/EntityManagerGui.cpp +++ b/modules/client/src/debug/tools/EntityManagerGui.cpp @@ -50,7 +50,8 @@ void EntityManagerGui::draw_entity_components() { ImGui::Text("Vertex offset: %i", mesh->vertex_offset()); } - draw_debug_components(); + draw_debug_components(); // auto character = m_world->registry().try_get(m_selected); // if(character != nullptr) { diff --git a/modules/client/src/network/EntityDerivationWriter.hpp b/modules/client/src/network/EntityDerivationWriter.hpp deleted file mode 100644 index bed4d2a..0000000 --- a/modules/client/src/network/EntityDerivationWriter.hpp +++ /dev/null @@ -1,10 +0,0 @@ -#pragma once - - -/** - * Writes entity derivation message - */ -template -class EntityDerivationWriter { - -}; diff --git a/modules/client/src/network/EntityIdMap.hpp b/modules/client/src/network/EntityIdMap.hpp new file mode 100644 index 0000000..b83f5bd --- /dev/null +++ b/modules/client/src/network/EntityIdMap.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include "entt/entt.hpp" +#include +#include +#include + +namespace tw::net { + +class EntityIdMap { +public: + const bool is_server_entity_registered(uint32_t server_id) const { + return m_mappings.find(server_id) != m_mappings.end(); + } + + const std::optional get_local(uint32_t server_id) const { + if(is_server_entity_registered(server_id)) { + return m_mappings.find(server_id)->second; + } + + return {}; + } + + void register_server_to_local(uint32_t server_id, entt::entity local) { + m_mappings[server_id] = local; + } + +private: + std::map m_mappings; +}; + +} diff --git a/modules/client/src/network/EntityPositionInterpolator.cpp b/modules/client/src/network/EntityPositionInterpolator.cpp index 2475bcd..63d432b 100644 --- a/modules/client/src/network/EntityPositionInterpolator.cpp +++ b/modules/client/src/network/EntityPositionInterpolator.cpp @@ -9,19 +9,16 @@ namespace tw::net { EntityPositionInterpolator::EntityPositionInterpolator( - entt::registry* registry, entt::entity player_entity, size_t bufferingIntervalInMillis + entt::registry* registry, size_t bufferingIntervalInMillis ) : m_registry(registry), - m_player_entity(player_entity), m_bufferingIntervalInMillis(bufferingIntervalInMillis) -{ - -} +{ } void EntityPositionInterpolator::register_entity(entt::entity entity) { m_registry->emplace(entity, glm::vec3()); } -void EntityPositionInterpolator::add_position_for_entity(entt::entity entity, glm::vec3 position) { +void EntityPositionInterpolator::set_position(Clock::time_point time_point, entt::entity entity, glm::vec3 position) { auto* interpolation = m_registry->try_get(entity); if(interpolation == nullptr) { spdlog::warn("Attempt to add position for non-registered entity {}", (uint32_t)entity); @@ -31,7 +28,7 @@ void EntityPositionInterpolator::add_position_for_entity(entt::entity entity, gl interpolation->push(Clock::now(), position); } -glm::vec3 EntityPositionInterpolator::get_position_for_entity(entt::entity entity) { +glm::vec3 EntityPositionInterpolator::get_position(Clock::time_point time_point, entt::entity entity) { auto* interpolation = m_registry->try_get(entity); if(interpolation == nullptr) { spdlog::warn("Attempt to get position for non-registered entity {}", (uint32_t)entity); @@ -44,12 +41,10 @@ glm::vec3 EntityPositionInterpolator::get_position_for_entity(entt::entity entit return glm::mix(from, to, value); } -void EntityPositionInterpolator::update() { +void EntityPositionInterpolator::interpolate_smoothed_entities(Clock::time_point time_point) { m_registry->view() .each([&](const auto entity, const EntityPositionInterpolation& interpolation, Transform& ts) { - auto now = Clock::now() - std::chrono::milliseconds(m_bufferingIntervalInMillis); - - auto [from, to, value] = interpolation.get_values_around(now); + auto [from, to, value] = interpolation.get_values_around(time_point); ts.set_position(glm::mix(from, to, value)); }); } diff --git a/modules/client/src/network/EntityPositionInterpolator.hpp b/modules/client/src/network/EntityPositionInterpolator.hpp index ad9d0dd..b19188b 100644 --- a/modules/client/src/network/EntityPositionInterpolator.hpp +++ b/modules/client/src/network/EntityPositionInterpolator.hpp @@ -1,3 +1,4 @@ +#pragma once #include #include @@ -12,23 +13,20 @@ class EntityPositionInterpolator { entt::registry* m_registry; - entt::entity m_player_entity; - size_t m_bufferingIntervalInMillis; + glm::vec3 get_position(Clock::time_point time_point, entt::entity entity); + public: EntityPositionInterpolator( entt::registry* registry, - entt::entity player_entity, size_t bufferingIntervalInMillis); void register_entity(entt::entity entity); - void add_position_for_entity(entt::entity entity, glm::vec3 position); + void set_position(Clock::time_point time_point, entt::entity entity, glm::vec3 position); - glm::vec3 get_position_for_entity(entt::entity entity); - - void update(); + void interpolate_smoothed_entities(Clock::time_point time_point); }; } diff --git a/modules/client/src/network/PlayerReconciler.cpp b/modules/client/src/network/PlayerRollback.cpp similarity index 85% rename from modules/client/src/network/PlayerReconciler.cpp rename to modules/client/src/network/PlayerRollback.cpp index d47bd1c..01563b8 100644 --- a/modules/client/src/network/PlayerReconciler.cpp +++ b/modules/client/src/network/PlayerRollback.cpp @@ -1,4 +1,4 @@ -#include "PlayerReconciler.hpp" +#include "PlayerRollback.hpp" #include "world/JoltPhysicsWorld.hpp" #include "world/CharacterBody.hpp" @@ -9,7 +9,7 @@ namespace tw::net { -PlayerReconciler::PlayerReconciler(JoltPhysicsWorld* physics) +PlayerRollback::PlayerRollback(JoltPhysicsWorld* physics) : m_physics(physics), m_last_reconciled_ack(0), m_rollback_count(0), m_last_correction_distance(0.0f), m_last_replayed_frames(0), m_last_ack_frame(0) { @@ -21,21 +21,21 @@ PlayerReconciler::PlayerReconciler(JoltPhysicsWorld* physics) } } -void PlayerReconciler::record_input(uint32_t frame, glm::vec3 input) { +void PlayerRollback::set_input(uint32_t frame, glm::vec3 input) { size_t idx = frame % RING_SIZE; m_records[idx].frame = frame; m_records[idx].valid = true; m_records[idx].input = input; } -void PlayerReconciler::record_prediction(uint32_t frame, glm::vec3 position) { +void PlayerRollback::record_prediction(uint32_t frame, glm::vec3 position) { size_t idx = frame % RING_SIZE; if (m_records[idx].frame == frame && m_records[idx].valid) { m_records[idx].predicted_position = position; } } -bool PlayerReconciler::reconcile(uint32_t ack_frame, glm::vec3 authoritative_position, +bool PlayerRollback::apply_correction(uint32_t ack_frame, glm::vec3 authoritative_position, entt::entity player, entt::registry* registry, uint32_t current_frame) { @@ -77,14 +77,16 @@ bool PlayerReconciler::reconcile(uint32_t ack_frame, glm::vec3 authoritative_pos return true; } -void PlayerReconciler::place_character(entt::entity player, entt::registry* registry, +void PlayerRollback::place_character(entt::entity player, entt::registry* registry, glm::vec3 position, bool clear_velocity) { CharacterBody* body = registry->try_get(player); + Transform* transform = registry->try_get(player); if (!body) { return; } body->m_character->SetPosition(JPH::RVec3(position.x, position.y, position.z)); + transform->set_position(position); if (clear_velocity) { body->m_character->SetLinearVelocity(JPH::Vec3::sZero()); @@ -92,7 +94,7 @@ void PlayerReconciler::place_character(entt::entity player, entt::registry* regi } } -void PlayerReconciler::replay_from(uint32_t from_frame, entt::entity player, +void PlayerRollback::replay_from(uint32_t from_frame, entt::entity player, entt::registry* registry, uint32_t current_frame) { for (uint32_t f = from_frame + 1; f < current_frame; ++f) { m_physics->step(f, tw::JoltPhysicsWorld::FIXED_DELTA_TIME, true); @@ -111,7 +113,7 @@ void PlayerReconciler::replay_from(uint32_t from_frame, entt::entity player, } } -void PlayerReconciler::reset_at(uint32_t frame) { +void PlayerRollback::reset_at(uint32_t frame) { m_last_reconciled_ack = frame; for (auto& record : m_records) { diff --git a/modules/client/src/network/PlayerReconciler.hpp b/modules/client/src/network/PlayerRollback.hpp similarity index 76% rename from modules/client/src/network/PlayerReconciler.hpp rename to modules/client/src/network/PlayerRollback.hpp index 214db50..a40c824 100644 --- a/modules/client/src/network/PlayerReconciler.hpp +++ b/modules/client/src/network/PlayerRollback.hpp @@ -11,7 +11,7 @@ class JoltPhysicsWorld; namespace tw::net { -class PlayerReconciler { +class PlayerRollback { private: struct Record { uint32_t frame; @@ -33,13 +33,14 @@ private: uint32_t m_last_ack_frame = 0; public: - PlayerReconciler(tw::JoltPhysicsWorld* physics); + PlayerRollback(tw::JoltPhysicsWorld* physics); - void record_input(uint32_t frame, glm::vec3 input); + void set_input(uint32_t frame, glm::vec3 input); void record_prediction(uint32_t frame, glm::vec3 position); - bool reconcile(uint32_t ack_frame, glm::vec3 authoritative_position, - entt::entity player, entt::registry* registry, uint32_t current_frame); + bool apply_correction( + uint32_t ack_frame, glm::vec3 authoritative_position, + entt::entity player, entt::registry* registry, uint32_t current_frame); private: /** @@ -56,11 +57,6 @@ private: public: - /** - * Drops every stored frame and treats `frame` as already answered. Used when - * the player is placed outright, where nothing recorded before the placement - * describes where it now is. - */ void reset_at(uint32_t frame); uint64_t rollback_count() const { return m_rollback_count; } diff --git a/modules/client/src/network/ReplicatorClient.cpp b/modules/client/src/network/ReplicatorClient.cpp new file mode 100644 index 0000000..4e9614d --- /dev/null +++ b/modules/client/src/network/ReplicatorClient.cpp @@ -0,0 +1,187 @@ +#include "ReplicatorClient.hpp" + +#include "PlayerMove.pb.h" +#include "debug/metrics/NetworkMetrics.hpp" +#include "network/EntityIdMap.hpp" +#include "world/CharacterBody.hpp" +#include "world/JoltPhysicsWorld.hpp" +#include "world/Transform.hpp" +#include + +namespace tw::net { + +ReplicatorClient::ReplicatorClient( + ProtobufMessages* messages, + EntityIdMap* entity_id_map, + dbg::NetworkMetrics *network_metrics, + JoltPhysicsWorld *physics_world, + World *world, + net::ServerConnection* server_connection, + EntityPositionInterpolator* entity_writer +) : + m_messages(messages), + m_entity_id_map(entity_id_map), + m_network_metrics(network_metrics), + m_rollback(physics_world), + m_world(world), + m_server_connection(server_connection), + m_input_send_times(INPUT_SEND_TIME_COUNT), + m_entity_interpolator(entity_writer) +{ + +} + +int64_t ReplicatorClient::now_ms() { + return std::chrono::duration_cast( + Clock::now().time_since_epoch()).count(); +} + +void ReplicatorClient::set_player_entity(entt::entity entity) { + m_player_entity = entity; +} + +void ReplicatorClient::measure_response_time(uint32_t current_frame_idx, uint32_t snapshot_frame_idx) { + // Snapshots carry frame zero until the server has an input to answer, and + // repeat the same frame whenever no newer one arrived in between. + if(snapshot_frame_idx == 0 || snapshot_frame_idx <= m_last_measured_frame) { + return; + } + + // Anything the send times no longer cover, including a frame we never sent, + // which underflows into a large distance. + if(current_frame_idx - snapshot_frame_idx >= INPUT_SEND_TIME_COUNT) { + return; + } + + m_last_measured_frame = snapshot_frame_idx; + + auto sent_at = m_input_send_times[snapshot_frame_idx % INPUT_SEND_TIME_COUNT]; + m_network_metrics->record_response_time(Clock::now() - sent_at); +} + +void ReplicatorClient::set_input(uint32_t frame_idx, glm::vec3 input) { + m_rollback.set_input(frame_idx, input); + + mmo::PlayerMoveMessage player_move_message = {}; + + player_move_message.set_frame_idx(frame_idx); + mmo::PlayerInput* player_input = new mmo::PlayerInput(); + player_input->set_x(input.x); + player_input->set_y(input.y); + player_input->set_z(input.z); + + player_move_message.set_allocated_input(player_input); + + auto send_result = m_messages->send(m_server_connection->server(), player_move_message, false); + if(!send_result) { + spdlog::error("Failed to send message: {}", send_result.error().message()); + } + + m_input_send_times[frame_idx % INPUT_SEND_TIME_COUNT] = Clock::now(); +} + +void ReplicatorClient::record_prediction(uint32_t frame_idx) { + if(!m_player_entity.has_value()) { + return; + } + + Transform* transform = m_world->registry().try_get(m_player_entity.value()); + if(!transform) { + return; + } + + glm::vec3 position = transform->position(); + + m_rollback.record_prediction(frame_idx, position); +} + +void ReplicatorClient::update() { + if(!m_player_entity.has_value()) { + return; + } + + // m_entity_interpolator.interpolate_smoothed_entities(std::chrono::high_resolution_clock::now() - std::chrono::milliseconds(100)); + + // Transform* transform = m_world->registry().try_get(m_player_entity.value()); + // if(!transform) { + // return; + // } + // + // transform->set_position(m_render_position); +} + +void ReplicatorClient::snap_player_to(uint32_t frame_idx, entt::entity entity, glm::vec3 position) { + CharacterBody* body = m_world->registry().try_get(entity); + if(body) { + body->m_character->SetPosition(JPH::RVec3(position.x, position.y, position.z)); + body->m_character->SetLinearVelocity(JPH::Vec3::sZero()); + body->m_desired_velocity = JPH::Vec3::sZero(); + } + + Transform* transform = m_world->registry().try_get(entity); + if(transform) { + transform->set_position(position); + } + + m_render_position = position; + + // m_rollback.reset_at(frame_idx); +} + +void ReplicatorClient::handle_snapshot_entity( + uint32_t current_frame_idx, + uint32_t record_frame_idx, + serial::EntityRecord& record +) { + std::optional entity = m_entity_id_map->get_local(record.id); + + if(!entity.has_value()) { + spdlog::warn("Received position update for unknown entity {}", record.id); + return; + } + + glm::vec3 p = {record.position.x, record.position.y, record.position.z}; + + if(m_player_entity.has_value() && + entity.value() == m_player_entity.value() + ) { + if(!m_player_position_initialized) { + m_player_position_initialized = true; + // snap_player_to(current_frame_idx, entity.value(), p); + // return; + } + + //snap_player_to(current_frame_idx, entity.value(), p); + + // bool reconcile_happened = m_reconciler.reconcile(record_frame_idx, p, entity.value(), &m_world->registry(), current_frame_idx); + // + // if(reconcile_happened) { + // m_network_metrics->record_rollback(); + // m_network_metrics->record_correction_distance(m_reconciler.last_correction_distance()); + // m_network_metrics->record_replayed_frames(m_reconciler.last_replayed_frames()); + // } + + // if(record_frame_idx != 0) { + // uint32_t ack_lag = 0; + // if(current_frame_idx >= record_frame_idx) { + // ack_lag = current_frame_idx - record_frame_idx; + // } + // m_network_metrics->record_ack_lag(ack_lag); + // } + } + + m_entity_interpolator->set_position(std::chrono::high_resolution_clock::now(), entity.value(), p); +} + +void ReplicatorClient::handle_snapshot(uint32_t current_frame_idx, serial::WorldStateReader& reader) { + auto header = reader.read_header(); + measure_response_time(current_frame_idx, header.frame_idx); + + while(reader.has_entity()) { + auto entity_r = reader.read_entity(); + + handle_snapshot_entity(current_frame_idx, header.frame_idx, entity_r); + } +} + +} diff --git a/modules/client/src/network/ReplicatorClient.hpp b/modules/client/src/network/ReplicatorClient.hpp new file mode 100644 index 0000000..00bfd9f --- /dev/null +++ b/modules/client/src/network/ReplicatorClient.hpp @@ -0,0 +1,114 @@ +#pragma once + +#include "ProtobufMessages.hpp" +#include "WorldStateWriter.hpp" +#include "debug/metrics/NetworkMetrics.hpp" +#include "network/EntityIdMap.hpp" +#include "network/PlayerRollback.hpp" +#include "network/ServerConnection.hpp" +#include "world/JoltPhysicsWorld.hpp" +#include "network/EntityPositionInterpolator.hpp" +#include "world/World.hpp" + +namespace tw::net { + + +/** + * Handles replication messages from server. + */ +class ReplicatorClient { +public: + ReplicatorClient( + ProtobufMessages* messages, + EntityIdMap* entity_id_map, + dbg::NetworkMetrics *network_metrics, + JoltPhysicsWorld *physics_world, + World *world, + net::ServerConnection* server_connection, + EntityPositionInterpolator* entity_writer + ); + + /** + * Sets player controlled entity. The behaviour for this entity will be + * little different. + */ + void set_player_entity(entt::entity entity); + + /** + * Takes the input driving `frame_idx`, keeping it for the answer to that + * frame and putting it on the wire. + */ + void set_input(uint32_t frame_idx, glm::vec3 input); + + /** + * Takes where the player ended up on `frame_idx`, which should represent + * the prediction taken. + */ + void record_prediction(uint32_t frame_idx); + + void handle_snapshot(uint32_t current_frame_idx, serial::WorldStateReader& reader); + +private: + /** + * Records how long the answer to `frame_idx` took to arrive, ignoring + * frames that were already measured or are too old to still have a send + * time. + */ + void measure_response_time(uint32_t current_frame_idx, uint32_t snapshot_frame_idx); + + /** + * Places the player at an authoritative position outright, clearing the + * predicted state that led there. Used for the first position the server + * sends, which the local simulation has no history to reconcile against. + */ + void snap_player_to(uint32_t frame_idx, entt::entity entity, glm::vec3 position); + + void handle_snapshot_entity( + uint32_t current_frame_idx, + uint32_t record_frame_idx, + serial::EntityRecord& record + ); + + void update(); + + glm::vec3 render_position() const { + return m_render_position; + } + +private: + using Clock = std::chrono::steady_clock; + + static int64_t now_ms(); + + ProtobufMessages *m_messages; + EntityIdMap* m_entity_id_map; + dbg::NetworkMetrics *m_network_metrics; + net::PlayerRollback m_rollback; + World *m_world; + net::ServerConnection* m_server_connection; + + std::optional m_player_entity; + + net::EntityPositionInterpolator* m_entity_interpolator; + + /** + * Whether the server has placed the player at least once. Entities are + * created before their position arrives, so the body starts at the origin + * and has to be moved once the first position shows up. + */ + bool m_player_position_initialized = false; + + glm::vec3 m_render_position{0.0f}; + + /** + * When each input was sent, indexed by its frame. Holds the most recent + * INPUT_SEND_TIME_COUNT frames; an answer that takes longer than that goes + * unmeasured. + */ + static constexpr size_t INPUT_SEND_TIME_COUNT = 256; + + std::vector m_input_send_times; + uint32_t m_last_measured_frame = 0; +}; + +} diff --git a/modules/client/src/network/ServerConnection.cpp b/modules/client/src/network/ServerConnection.cpp index 8cb1984..915d080 100644 --- a/modules/client/src/network/ServerConnection.cpp +++ b/modules/client/src/network/ServerConnection.cpp @@ -11,7 +11,6 @@ ServerConnection::ServerConnection(Address address) : } tl::expected ServerConnection::start() { - // Create the endpoint auto endpoint_r = msg::MessageEndpoint::create(); if(!endpoint_r) { m_status = ConnectionStatus::Failed; @@ -21,7 +20,6 @@ tl::expected ServerConnection::start() { m_endpoint = std::move(endpoint_r.value()); - // Connect to the server auto server_r = m_endpoint->connect(m_address.ip_string(), m_address.port()); if(!server_r) { m_status = ConnectionStatus::Failed; @@ -44,13 +42,11 @@ void ServerConnection::update() { m_endpoint->update(); - // Check if the connection has become established if(m_status == ConnectionStatus::Connecting && m_server) { if(m_server->is_established()) { m_status = ConnectionStatus::Connected; spdlog::info("Connected to server at {}", m_address.to_string()); } else { - // Check for timeout auto elapsed = Clock::now() - m_started_at; if(elapsed >= CONNECT_TIMEOUT) { m_status = ConnectionStatus::Failed; @@ -61,24 +57,5 @@ void ServerConnection::update() { } } -ConnectionStatus ServerConnection::status() const { - return m_status; -} - -const std::string& ServerConnection::error() const { - return m_error; -} - -const Address& ServerConnection::address() const { - return m_address; -} - -msg::MessageEndpoint* ServerConnection::endpoint() const { - return m_endpoint.get(); -} - -msg::MessageConnection* ServerConnection::server() const { - return m_server; -} } diff --git a/modules/client/src/network/ServerConnection.hpp b/modules/client/src/network/ServerConnection.hpp index 38af43d..7afdd07 100644 --- a/modules/client/src/network/ServerConnection.hpp +++ b/modules/client/src/network/ServerConnection.hpp @@ -20,9 +20,6 @@ enum class ConnectionStatus { Idle, Connecting, Connected, Failed }; /** * Encapsulates a connection to a game server. - * - * Owns the endpoint and connection, managing the state of the connection - * attempt and providing non-blocking access to send/receive. */ class ServerConnection { using Clock = std::chrono::steady_clock; @@ -37,28 +34,21 @@ class ServerConnection { static constexpr std::chrono::seconds CONNECT_TIMEOUT{5}; public: - /** - * Constructs a connection object for the given address, without starting I/O. - */ explicit ServerConnection(Address address); - /** - * Starts the connection process by creating an endpoint and connecting to - * the server. Returns an error if the endpoint cannot be created. - */ tl::expected start(); - /** - * Updates the connection state: pumps the endpoint, and checks for timeout - * or successful connection. Must be called regularly. - */ void update(); - ConnectionStatus status() const; - const std::string& error() const; - const Address& address() const; - msg::MessageEndpoint* endpoint() const; - msg::MessageConnection* server() const; + ConnectionStatus status() const { return m_status; } + + const std::string& error() const { return m_error; } + + const Address& address() const { return m_address; } + + msg::MessageEndpoint* endpoint() const { return m_endpoint.get(); } + + msg::MessageConnection* server() const { return m_server; } }; } diff --git a/modules/client/src/world/ClientWorldController.cpp b/modules/client/src/world/ClientWorldController.cpp deleted file mode 100644 index 3d23819..0000000 --- a/modules/client/src/world/ClientWorldController.cpp +++ /dev/null @@ -1,447 +0,0 @@ -#include "ClientWorldController.hpp" - -#include -#include -#include - -#include "network/ServerConnection.hpp" - -#include "Entity.pb.h" -#include "Login.pb.h" -#include "WorldState.pb.h" -#include "PlayerMove.pb.h" - -#include "entt/entity/entity.hpp" -#include "entt/entity/fwd.hpp" -#include "messages/PlayerMoveMessage.hpp" -#include "metrics/HistoryBuffer.hpp" -#include "world/CharacterBody.hpp" -#include "world/CharacterController.hpp" -#include "world/JoltPhysicsWorld.hpp" -#include "world/WorldEntity.hpp" -#include "tw/serial/WorldStateWriter.hpp" -#include "network/EntityInterpolation.hpp" - -namespace tw { - -typedef HistoryBuffer EntityPositionHistory; - -entt::entity create_player_entity(World* world, JoltPhysicsWorld* physics_world, drw::WorldRenderer* renderer) { - // entt::entity entity = world->registry().create(); - - // drw::Mesh mesh = renderer->add_mesh(drw::MeshData::cube(glm::vec3(1.0f))); - - // world->registry() - // .emplace(entity, - // Transform(glm::vec3(0.0f, 10.0f, 0.0f))); - - // world->registry() - // .emplace(entity, 0, glm::vec3(0.0f, 10.0f, 0.0f), 1000); - - // world->registry() - // .emplace(entity, - // tw::WorldEntity(std::string("player"), (uint32_t)entity)); - - // world->registry() - // .emplace(entity, mesh); - - // world->registry() - // .emplace(entity, 20.0f); - // auto* body = &world->registry() - // .emplace(entity, physics_world->create_character( - // new JPH::BoxShape(JPH::Vec3Arg(0.5f, 0.5f, 0.5f)), - // glm::vec3(0.0f, 10.0f, 0.0f))); - - - // return entity; - return entt::entity(0); -} - - -entt::entity -ClientWorldController::create_entity(const std::string& name, glm::vec3 position) { - const auto entity = m_world->registry().create(); - - if(!m_mesh.has_value()) { - m_mesh = m_world_renderer->add_mesh(drw::MeshData::cube(glm::vec3(1.0f))); - } - spdlog::info("Creating entity {}", name); - - m_world->registry() - .emplace(entity, - Transform(position)); - - m_world->registry() - .emplace(entity, - tw::WorldEntity(name, (uint32_t)entity)); - - m_world->registry() - .emplace(entity, - m_mesh.value()); - - return entity; -} - - -std::optional ClientWorldController::map_from_server_entity(int id) { - if(m_entity_mapping.contains(id)) { - return m_entity_mapping[id]; - } - - return {}; -} - -void ClientWorldController::map_server_entity(int server_id, entt::entity local_id) { - m_entity_mapping[server_id] = local_id; -} - -void ClientWorldController::apply_entity_positions(const mmo::EntityPosition* const* positions, size_t count) { - for(int i = 0; i < count; i++) { - const mmo::EntityPosition* const position = positions[i]; - std::optional entity = map_from_server_entity(position->id()); - - if(!entity.has_value()) { - spdlog::warn("Received position update for unknown entity {}", position->id()); - continue; - } - - glm::vec3 p = {position->x(), position->y(), position->z()}; - m_entity_interpolator.add_position_for_entity(entity.value(), p); - - EntityPositionHistory* history = m_world->registry().try_get(entity.value()); - - if(history != nullptr) { - auto millis = std::chrono::duration_cast(Clock::now().time_since_epoch()).count(); - history->set(millis, p); - } - } -} - -ClientWorldController::ClientWorldController( - const io::InputManager* inputs, - World* world, - JoltPhysicsWorld* physics_world, - drw::WorldRenderer* world_renderer, - net::ServerConnection* connection, - dbg::NetworkMetrics* network_metrics -) : - m_input_manager(inputs), - m_world(world), - m_physics_world(physics_world), - m_world_renderer(world_renderer), - // m_player_entity(/* create_player_entity(world, physics_world, world_renderer) */), - m_player_controller(&world_renderer->camera(), glm::vec3()), - m_connection(connection), - m_messages(connection->endpoint()), - m_network_metrics(network_metrics), - m_tick_step(20), - m_input_send_times(INPUT_SEND_TIME_COUNT), - m_position_history_exporter("/home/martin/output.csv"), - m_entity_interpolator(&m_world->registry(), (entt::entity)0, 300), - m_reconciler(physics_world) -{ - m_messages.set_handler( - [this](msg::PeerId, const mmo::LoginResponse& mesg) { - spdlog::info("Logged in!"); - }); - - m_messages.set_handler( - [this](msg::PeerId, const mmo::SetControlledEntity& mesg) { - spdlog::info("Setting controlled entity from server id {}", mesg.entity_id()); - m_controlled_server_id = mesg.entity_id(); - try_bind_player_entity(); - }); - - m_connection->endpoint()->set_handler(Message::value, - [this](msg::PeerId, std::span data) { - - serial::WorldStateReader reader(data); - - auto header = reader.read_header(); - - measure_response_time(header.frame_idx); - - while(reader.has_spawn()) { - auto spawn = reader.read_spawn(); - auto entity = create_entity("test", glm::vec3()); - spdlog::info("Spawning entity {}", spawn); - - map_server_entity(spawn, entity); - - if(m_controlled_server_id.has_value() && m_controlled_server_id.value() == spawn) { - try_bind_player_entity(); - } else { - m_entity_interpolator.register_entity(entity); - } - } - - while(reader.has_entity()) { - auto entity_r = reader.read_entity(); - - std::optional entity = map_from_server_entity(entity_r.id); - - if(!entity.has_value()) { - spdlog::warn("Received position update for unknown entity {}", entity_r.id); - continue; - } - - glm::vec3 p = {entity_r.position.x, entity_r.position.y, entity_r.position.z}; - - if(m_player_entity.has_value() && entity.value() == m_player_entity.value()) { - // The entity was created before its position was known, so the - // body sits at the origin until the server places it. There is - // no predicted history to reconcile against yet. - if(!m_player_position_initialized) { - m_player_position_initialized = true; - snap_player_to(entity.value(), p); - continue; - } - - glm::vec3 position_before = glm::vec3(0.0f); - Transform* player_transform = m_world->registry().try_get(entity.value()); - if(player_transform) { - position_before = player_transform->position(); - } - - bool reconcile_happened = m_reconciler.reconcile(header.frame_idx, p, entity.value(), &m_world->registry(), m_frame_idx); - - if(reconcile_happened) { - // Where the replay actually ended up, which is ahead of the - // acked position by the frames that were re-simulated. - glm::vec3 position_after = player_transform - ? player_transform->position() - : p; - glm::vec3 correction_delta = position_before - position_after; - float correction_magnitude = glm::length(correction_delta); - if(correction_magnitude > 5.0f) { - correction_delta = glm::normalize(correction_delta) * 5.0f; - } - m_visual_error += correction_delta; - - m_render_curr_position = position_after; - m_render_prev_position = position_after; - m_tick_accumulator = 0.0; - - m_network_metrics->record_rollback(); - m_network_metrics->record_correction_distance(m_reconciler.last_correction_distance()); - m_network_metrics->record_replayed_frames(m_reconciler.last_replayed_frames()); - } - - if(header.frame_idx != 0) { - uint32_t ack_lag = 0; - if(m_frame_idx >= header.frame_idx) { - ack_lag = m_frame_idx - header.frame_idx; - } - m_network_metrics->record_ack_lag(ack_lag); - } - } else { - m_entity_interpolator.add_position_for_entity(entity.value(), p); - } - - EntityPositionHistory* history = m_world->registry().try_get(entity.value()); - - if(history != nullptr) { - auto millis = std::chrono::duration_cast(Clock::now().time_since_epoch()).count(); - history->set(millis, p); - } - } - - // apply_entity_positions(); - }); - - m_messages.set_handler( - [this](msg::PeerId, const mmo::EntitySpawnMessage& mesg) { - auto entity = create_entity(mesg.name(), glm::vec3()); - - map_server_entity(mesg.entity_id(), entity); - - if(m_controlled_server_id.has_value() && m_controlled_server_id.value() == mesg.entity_id()) { - try_bind_player_entity(); - } else { - m_entity_interpolator.register_entity(entity); - } - }); -} - -ClientWorldController::~ClientWorldController() { -} - -void ClientWorldController::try_bind_player_entity() { - if(!m_controlled_server_id.has_value()) { - return; - } - - auto local_entity = map_from_server_entity(m_controlled_server_id.value()); - if(!local_entity.has_value()) { - return; - } - - if(m_player_entity.has_value() && m_player_entity.value() == local_entity.value()) { - return; - } - - entt::entity entity = local_entity.value(); - spdlog::info("Binding player entity"); - - m_player_entity = entity; - - Transform* transform = m_world->registry().try_get(entity); - glm::vec3 position = transform ? transform->position() : glm::vec3(0.0f); - - m_world->registry().emplace(entity, 20.0f); - m_world->registry().emplace(entity, m_physics_world->create_character( - new JPH::BoxShape(JPH::Vec3Arg(0.5f, 0.5f, 0.5f)), - position - )); - - if(m_world->registry().all_of(entity)) { - m_world->registry().remove(entity); - } -} - -void ClientWorldController::snap_player_to(entt::entity entity, glm::vec3 position) { - CharacterBody* body = m_world->registry().try_get(entity); - if(body) { - body->m_character->SetPosition(JPH::RVec3(position.x, position.y, position.z)); - body->m_character->SetLinearVelocity(JPH::Vec3::sZero()); - body->m_desired_velocity = JPH::Vec3::sZero(); - } - - Transform* transform = m_world->registry().try_get(entity); - if(transform) { - transform->set_position(position); - } - - m_render_prev_position = position; - m_render_curr_position = position; - m_tick_accumulator = 0.0; - m_visual_error = glm::vec3(0.0f); - - // Frames simulated before the player was placed describe a position it never - // actually had, so answers to them must not be reconciled against. - m_reconciler.reset_at(m_frame_idx); -} - -void ClientWorldController::export_entity_history() { -} - -void ClientWorldController::measure_response_time(uint32_t frame_idx) { - // Snapshots carry frame zero until the server has an input to answer, and - // repeat the same frame whenever no newer one arrived in between. - if(frame_idx == 0 || frame_idx <= m_last_measured_frame) { - return; - } - - // Anything the send times no longer cover, including a frame we never sent, - // which underflows into a large distance. - if(m_frame_idx - frame_idx >= INPUT_SEND_TIME_COUNT) { - return; - } - - m_last_measured_frame = frame_idx; - - auto sent_at = m_input_send_times[frame_idx % INPUT_SEND_TIME_COUNT]; - m_network_metrics->record_response_time(Clock::now() - sent_at); -} - -void ClientWorldController::update(double delta_time) { - m_player_controller.update(m_input_manager, delta_time); - - ImGui::Begin("Player Controller"); - if(m_player_entity.has_value()) { - ImGui::Text("Player entity ID: %d", (uint32_t)m_player_entity.value()); - } - - ImGui::Text("Player count: %ld", m_entity_mapping.size()); - for(auto mapping : m_entity_mapping) { - ImGui::Text("%d -> %d", (uint32_t)mapping.first, mapping.second); - } - - ImGui::End(); - - if(m_tick_step.update()) { - auto network_start = Clock::now(); - m_connection->update(); - m_network_metrics->record_update_time(Clock::now() - network_start); - - m_network_metrics->sample({ - .bytes_sent = m_connection->endpoint()->bytes_sent(), - .bytes_received = m_connection->endpoint()->bytes_received(), - .messages_sent = m_connection->endpoint()->messages_sent(), - .messages_received = m_connection->endpoint()->messages_received() - }); - - { - glm::vec3 input = m_player_controller.input(); - - if(m_player_entity.has_value()) { - CharacterController* controller = m_world->registry().try_get(m_player_entity.value()); - if(controller) { - controller->set_input(m_frame_idx, input); - m_reconciler.record_input(m_frame_idx, input); - } - } - - m_physics_world->step(m_frame_idx, JoltPhysicsWorld::FIXED_DELTA_TIME, true); - - if(m_player_entity.has_value()) { - Transform* player_transform = m_world->registry().try_get(m_player_entity.value()); - if(player_transform) { - glm::vec3 true_position = player_transform->position(); - m_reconciler.record_prediction(m_frame_idx, true_position); - - m_render_prev_position = m_render_curr_position; - m_render_curr_position = true_position; - m_tick_accumulator = 0.0; - } - } - - mmo::PlayerMoveMessage player_move_message = {}; - - player_move_message.set_frame_idx(m_frame_idx); - mmo::PlayerInput* player_input = new mmo::PlayerInput(); - player_input->set_x(input.x); - player_input->set_y(input.y); - player_input->set_z(input.z); - - player_move_message.set_allocated_input(player_input); - auto r = m_messages.send(m_connection->server(), player_move_message, false); - - m_input_send_times[m_frame_idx % INPUT_SEND_TIME_COUNT] = Clock::now(); - - export_entity_history(); - - m_frame_idx++; - - m_world->registry().view() - .each([&](const auto e, Transform& t) { - m_position_history_exporter.write((uint32_t)e, - std::chrono::duration_cast(Clock::now().time_since_epoch()).count(), t.position()); - }); - } - } - - m_entity_interpolator.update(); - - if(m_player_entity.has_value()) { - Transform* player_transform = m_world->registry().try_get(m_player_entity.value()); - if(player_transform) { - m_visual_error *= std::exp(-delta_time * kVisualErrorDecayRate); - if(glm::length(m_visual_error) < 0.001f) { - m_visual_error = glm::vec3(0.0f); - } - - m_tick_accumulator += delta_time; - float alpha = glm::clamp( - static_cast(m_tick_accumulator / JoltPhysicsWorld::FIXED_DELTA_TIME), - 0.0f, 1.0f - ); - glm::vec3 smoothed_position = glm::mix(m_render_prev_position, m_render_curr_position, alpha) + m_visual_error; - player_transform->set_position(smoothed_position); - - m_player_controller.set_target(smoothed_position); - } - } -} - -} diff --git a/modules/client/src/world/ClientWorldController.hpp b/modules/client/src/world/ClientWorldController.hpp deleted file mode 100644 index 81da5fe..0000000 --- a/modules/client/src/world/ClientWorldController.hpp +++ /dev/null @@ -1,150 +0,0 @@ -#pragma once - -#include -#include - -#include "ProtobufMessages.hpp" -#include "debug/metrics/NetworkMetrics.hpp" -#include "entt/entity/fwd.hpp" -#include "io/InputState.hpp" -#include "metrics/HistoryBufferExporter.hpp" -#include "runtime/LockStep.hpp" -#include "world/JoltPhysicsWorld.hpp" -#include "world/World.hpp" -#include "draw/WorldRenderer.hpp" -#include "world/ThirdPersonPlayerController.hpp" -#include "network/EntityPositionInterpolator.hpp" -#include "network/PlayerReconciler.hpp" - -namespace tw::net { -class ServerConnection; -} - -namespace tw { - -/** - * Multiplayer player controller. Requires address of a server to work. - */ -class ClientWorldController { - /** - * Inputs - */ - const io::InputManager* m_input_manager; - - /** - * Pointers to the world - */ - World* m_world; - drw::WorldRenderer* m_world_renderer; - JoltPhysicsWorld* m_physics_world; - - std::optional m_player_entity; - std::optional m_controlled_server_id; - ThirdPersonPlayerController m_player_controller; - - net::ServerConnection* m_connection; - ProtobufMessages m_messages; - - dbg::NetworkMetrics* m_network_metrics; - - LockStep m_tick_step; - - uint32_t m_frame_idx = 1; - entt::entity m_entity_id; - - glm::vec3 m_input; - - std::optional m_mesh; - - using Clock = std::chrono::steady_clock; - - void try_bind_player_entity(); - - /** - * Places the player at an authoritative position outright, clearing the - * predicted state that led there. Used for the first position the server - * sends, which the local simulation has no history to reconcile against. - */ - void snap_player_to(entt::entity entity, glm::vec3 position); - - /** - * Whether the server has placed the player at least once. Entities are - * created before their position arrives, so the body starts at the origin - * and has to be moved once the first position shows up. - */ - bool m_player_position_initialized = false; - - /** - * When each input was sent, indexed by its frame. Holds the most recent - * INPUT_SEND_TIME_COUNT frames; an answer that takes longer than that goes - * unmeasured. - */ - static constexpr size_t INPUT_SEND_TIME_COUNT = 256; - - std::vector m_input_send_times; - uint32_t m_last_measured_frame = 0; - - /** - * Records how long the answer to `frame_idx` took to arrive, ignoring - * frames that were already measured or are too old to still have a send - * time. - */ - void measure_response_time(uint32_t frame_idx); - - HistoryBufferExporter m_position_history_exporter; - - net::EntityPositionInterpolator m_entity_interpolator; - - net::PlayerReconciler m_reconciler; - - /** - * Visual smoothing for render-rate interpolation between 20 Hz ticks. - */ - glm::vec3 m_render_prev_position{0.0f}; - glm::vec3 m_render_curr_position{0.0f}; - double m_tick_accumulator = 0.0; - - /** - * Visual error from reconciliation corrections, decays over time. - */ - glm::vec3 m_visual_error{0.0f}; - static constexpr double kVisualErrorDecayRate = 12.0; - - /** - * Mapping from the server entity_id to local entity_id - * Server might have the same entity under different name - * TODO: Figure out if entt supports custUntitledom IDs to sync them - */ - std::unordered_map m_entity_mapping; - - entt::entity create_entity(const std::string& name, glm::vec3 position); - - std::optional map_from_server_entity(int id); - - void map_server_entity(int server_id, entt::entity local_id); - - void apply_entity_positions(const mmo::EntityPosition* const* positions, size_t count); - - - - /** - * Exports entity history to CSV file for analysis - */ - void export_entity_history(); - -public: - ClientWorldController( - const io::InputManager* inputs, - World* world, - JoltPhysicsWorld* physics_world, - drw::WorldRenderer* world_renderer, - net::ServerConnection* connection, - dbg::NetworkMetrics* network_metrics - ); - - ~ClientWorldController(); - - void update(double delta_time); -}; - -} diff --git a/modules/client/src/world/controllers/ClientWorldController.cpp b/modules/client/src/world/controllers/ClientWorldController.cpp new file mode 100644 index 0000000..3045291 --- /dev/null +++ b/modules/client/src/world/controllers/ClientWorldController.cpp @@ -0,0 +1,242 @@ +#include "ClientWorldController.hpp" + +#include +#include +#include +#include + +#include "network/ServerConnection.hpp" + +#include "Entity.pb.h" +#include "Login.pb.h" +#include "WorldState.pb.h" +#include "PlayerMove.pb.h" + +#include "entt/entity/entity.hpp" +#include "entt/entity/fwd.hpp" +#include "messages/PlayerMoveMessage.hpp" +#include "metrics/HistoryBuffer.hpp" +#include "world/CharacterBody.hpp" +#include "world/CharacterController.hpp" +#include "world/JoltPhysicsWorld.hpp" +#include "world/WorldEntity.hpp" +#include "tw/serial/WorldStateWriter.hpp" +#include "network/EntityInterpolation.hpp" + +namespace tw { + +typedef HistoryBuffer EntityPositionHistory; + +entt::entity +ClientWorldController::create_entity(const std::string& name, glm::vec3 position) { + const auto entity = m_world->registry().create(); + + if(!m_mesh.has_value()) { + m_mesh = m_world_renderer->add_mesh(drw::MeshData::cube(glm::vec3(1.0f))); + } + spdlog::info("Creating entity {}", name); + + m_world->registry() + .emplace(entity, + Transform(position)); + + m_world->registry() + .emplace(entity, + tw::WorldEntity(name, (uint32_t)entity)); + + m_world->registry() + .emplace(entity, + m_mesh.value()); + + return entity; +} + +// void ClientWorldController::apply_entity_positions(const mmo::EntityPosition* const* positions, size_t count) { +// for(int i = 0; i < count; i++) { +// const mmo::EntityPosition* const position = positions[i]; +// std::optional entity = m_entity_id_map.get_local(position->id()); +// +// if(!entity.has_value()) { +// spdlog::warn("Received position update for unknown entity {}", position->id()); +// continue; +// } +// +// glm::vec3 p = {position->x(), position->y(), position->z()}; +// m_entity_interpolator.add_position_for_entity(entity.value(), p); +// +// EntityPositionHistory* history = m_world->registry().try_get(entity.value()); +// +// if(history != nullptr) { +// auto millis = std::chrono::duration_cast(Clock::now().time_since_epoch()).count(); +// history->set(millis, p); +// } +// } +// } + +void ClientWorldController::spawn_entity(const std::string& name, uint32_t server_id) { + auto entity = create_entity(name, glm::vec3()); + spdlog::info("Spawning entity {}", server_id); + + m_entity_id_map.register_server_to_local(server_id, entity); + + if(m_controlled_server_id.has_value() && m_controlled_server_id.value() == server_id) { + try_bind_player_entity(); + } else { + } + m_interpolator.register_entity(entity); +} + +ClientWorldController::ClientWorldController( + const io::InputManager* inputs, + World* world, + JoltPhysicsWorld* physics_world, + drw::WorldRenderer* world_renderer, + net::ServerConnection* connection, + dbg::NetworkMetrics* network_metrics +) : + m_input_manager(inputs), + m_world(world), + m_physics_world(physics_world), + m_world_renderer(world_renderer), + m_player_controller(&world_renderer->camera(), glm::vec3()), + m_connection(connection), + m_messages(connection->endpoint()), + m_network_metrics(network_metrics), + m_input_update_tick_step(20), + m_interpolator(&m_world->registry(), 300), + m_replicator_client(&m_messages, &m_entity_id_map, m_network_metrics, m_physics_world, m_world, m_connection, &m_interpolator) +{ + m_messages.set_handler( + [this](msg::PeerId, const mmo::LoginResponse& mesg) { + spdlog::info("Logged in!"); + }); + + m_messages.set_handler( + [this](msg::PeerId, const mmo::SetControlledEntity& mesg) { + spdlog::info("Setting controlled entity from server id {}", mesg.entity_id()); + m_controlled_server_id = mesg.entity_id(); + try_bind_player_entity(); + }); + + m_connection->endpoint()->set_handler(Message::value, + [this](msg::PeerId, std::span data) { + + serial::WorldStateReader reader(data); + + m_replicator_client.handle_snapshot(m_network_frame_idx, reader); + }); + + m_messages.set_handler( + [this](msg::PeerId, const mmo::EntitySpawnMessage& mesg) { + for(auto& spawn : mesg.spawns()) { + spawn_entity(spawn.name(), spawn.entity_id()); + } + }); +} + +ClientWorldController::~ClientWorldController() { } + +void ClientWorldController::try_bind_player_entity() { + if(!m_controlled_server_id.has_value()) { + return; + } + + auto local_entity = m_entity_id_map.get_local(m_controlled_server_id.value()); + if(!local_entity.has_value()) { + return; + } + + if(m_player_entity.has_value() && m_player_entity.value() == local_entity.value()) { + return; + } + + entt::entity entity = local_entity.value(); + spdlog::info("Binding player entity"); + + m_player_entity = entity; + m_replicator_client.set_player_entity(entity); + + Transform* transform = m_world->registry().try_get(entity); + glm::vec3 position = transform ? transform->position() : glm::vec3(0.0f); + + m_world->registry().emplace(entity, 20.0f); + m_world->registry().emplace(entity, m_physics_world->create_character( + new JPH::BoxShape(JPH::Vec3Arg(0.5f, 0.5f, 0.5f)), + position + )); + + // if(m_world->registry().all_of(entity)) { + // m_world->registry().remove(entity); + // } +} + + + + +void ClientWorldController::update_network() { + auto network_start = Clock::now(); + // collects incoming messages + m_connection->update(); + m_network_metrics->record_update_time(Clock::now() - network_start); + + m_network_metrics->sample({ + .bytes_sent = m_connection->endpoint()->bytes_sent(), + .bytes_received = m_connection->endpoint()->bytes_received(), + .messages_sent = m_connection->endpoint()->messages_sent(), + .messages_received = m_connection->endpoint()->messages_received() + }); + + + m_replicator_client.record_prediction(m_frame_idx); + + + // m_world->registry().view() + // .each([&](const auto e, Transform& t) { + // m_position_history_exporter.write((uint32_t)e, + // std::chrono::duration_cast(Clock::now().time_since_epoch()).count(), t.position()); + // }); + +} + +void ClientWorldController::update(double delta_time) { + // collects messages from network for processing + update_network(); + + m_player_controller.update(m_input_manager, delta_time); + + // collect input every tick of the input_update_tick_step + if(m_input_update_tick_step.has_ticked()) { + // TODO: might be more like `calc_velocity_vector`. + // 1. it is not exactly *input* but input mapped to normalized velocity vector + // 2. I hate calling methods `update` + glm::vec3 input = m_player_controller.input(); + + if(m_player_entity.has_value()) { + CharacterController* controller = m_world->registry().try_get(m_player_entity.value()); + if(controller) { + // controller->set_input(m_network_frame_idx, input); + m_replicator_client.set_input(m_network_frame_idx, input); + } + } + + // m_physics_world->step(m_network_frame_idx, JoltPhysicsWorld::FIXED_DELTA_TIME, true); + m_network_frame_idx++; + } + + m_interpolator.interpolate_smoothed_entities(std::chrono::high_resolution_clock::now() - std::chrono::milliseconds(500)); + + if(m_player_entity.has_value()) { + // after interpolation happen, follow the target + // TODO: The third person controller could pull + Transform* player_transform = m_world->registry().try_get(m_player_entity.value()); + if(player_transform) { + m_player_controller.set_target(player_transform->position()); + } + } + + m_frame_idx++; + + // m_replicator_client.update(); +} + +} diff --git a/modules/client/src/world/controllers/ClientWorldController.hpp b/modules/client/src/world/controllers/ClientWorldController.hpp new file mode 100644 index 0000000..b4603bb --- /dev/null +++ b/modules/client/src/world/controllers/ClientWorldController.hpp @@ -0,0 +1,107 @@ +#pragma once + +#include +#include + +#include "ProtobufMessages.hpp" +#include "debug/metrics/NetworkMetrics.hpp" +#include "entt/entity/fwd.hpp" +#include "io/InputState.hpp" +#include "metrics/HistoryBufferExporter.hpp" +#include "network/EntityIdMap.hpp" +#include "network/ReplicatorClient.hpp" +#include "runtime/LockStep.hpp" +#include "world/JoltPhysicsWorld.hpp" +#include "world/World.hpp" +#include "draw/WorldRenderer.hpp" +#include "world/ThirdPersonPlayerController.hpp" +#include "network/EntityPositionInterpolator.hpp" + +namespace tw::net { +class ServerConnection; +} + +namespace tw { + +/** + * Multiplayer player controller. Requires address of a server to work. + */ +class ClientWorldController { + /** + * Inputs + */ + const io::InputManager* m_input_manager; + + /** + * Pointers to the world + */ + World* m_world; + drw::WorldRenderer* m_world_renderer; + JoltPhysicsWorld* m_physics_world; + + std::optional m_player_entity; + std::optional m_controlled_server_id; + ThirdPersonPlayerController m_player_controller; + + net::ServerConnection* m_connection; + ProtobufMessages m_messages; + + dbg::NetworkMetrics* m_network_metrics; + + LockStep m_input_update_tick_step; + + uint32_t m_frame_idx = 1; + uint32_t m_network_frame_idx = 1; + entt::entity m_entity_id; + + glm::vec3 m_input; + + std::optional m_mesh; + + net::EntityPositionInterpolator m_interpolator; + + net::ReplicatorClient m_replicator_client; + + net::EntityIdMap m_entity_id_map; + + using Clock = std::chrono::steady_clock; + + void try_bind_player_entity(); + + entt::entity create_entity(const std::string& name, glm::vec3 position); + + void apply_entity_positions(const mmo::EntityPosition* const* positions, size_t count); + + void update_network(); + +public: + ClientWorldController( + const io::InputManager* inputs, + World* world, + JoltPhysicsWorld* physics_world, + drw::WorldRenderer* world_renderer, + net::ServerConnection* connection, + dbg::NetworkMetrics* network_metrics + ); + + ~ClientWorldController(); + + + constexpr uint32_t frame_idx() const { + return m_frame_idx; + } + + constexpr std::optional player_entity() const { + if(!m_controlled_server_id.has_value()) { + return {}; + } + + return m_entity_id_map.get_local(m_controlled_server_id.value()); + } + + void spawn_entity(const std::string& name, uint32_t server_id); + + void update(double delta_time); +}; + +} diff --git a/modules/client/src/world/LocalWorldController.cpp b/modules/client/src/world/controllers/LocalWorldController.cpp similarity index 100% rename from modules/client/src/world/LocalWorldController.cpp rename to modules/client/src/world/controllers/LocalWorldController.cpp diff --git a/modules/client/src/world/LocalWorldController.hpp b/modules/client/src/world/controllers/LocalWorldController.hpp similarity index 100% rename from modules/client/src/world/LocalWorldController.hpp rename to modules/client/src/world/controllers/LocalWorldController.hpp diff --git a/modules/mock_client/src/main.cpp b/modules/mock_client/src/main.cpp index ebcfd6f..427bbec 100644 --- a/modules/mock_client/src/main.cpp +++ b/modules/mock_client/src/main.cpp @@ -1,4 +1,5 @@ #include "Address.hpp" +#include "Entity.pb.h" #include "Login.pb.h" #include "PlayerMove.pb.h" #include "ProtobufMessages.hpp" @@ -63,6 +64,9 @@ public: m_messages.set_handler( [this](tw::msg::PeerId, const mmo::WorldStateMessage& mesg) { }); + m_messages.set_handler( + [this](tw::msg::PeerId, const mmo::EntitySpawnMessage& mesg) { }); + m_messages.set_handler( [this](tw::msg::PeerId, const mmo::LoginResponse& mesg) { if(!m_is_connected) { @@ -103,12 +107,13 @@ public: player_input->set_z(m_velocity.z); player_move_mesg.set_allocated_input(player_input); auto r = m_messages.send(m_server, player_move_mesg, false); + m_frame_idx++; } } }; int main() { - const int NUM_CLIENTS = 600; + const int NUM_CLIENTS = 10; tw::net::Address address = {"127.0.0.1", 8101}; std::vector threads; diff --git a/modules/protocol/proto/Entity.proto b/modules/protocol/proto/Entity.proto index 22fed36..814b782 100644 --- a/modules/protocol/proto/Entity.proto +++ b/modules/protocol/proto/Entity.proto @@ -2,12 +2,16 @@ syntax = "proto3"; package mmo; -message EntitySpawnMessage { +message EntitySpawn { uint32 entity_id = 1; bool is_player = 2; string name = 3; } +message EntitySpawnMessage { + repeated EntitySpawn spawns = 1; +} + message EntityDespawnMessage { uint32 entity_id = 1; } diff --git a/modules/serialization/include/tw/serial/WorldStateWriter.hpp b/modules/serialization/include/tw/serial/WorldStateWriter.hpp index 3029e40..0b9df87 100644 --- a/modules/serialization/include/tw/serial/WorldStateWriter.hpp +++ b/modules/serialization/include/tw/serial/WorldStateWriter.hpp @@ -1,59 +1,5 @@ #pragma once -/** - * WorldStateWriter / WorldStateReader - * ===================================== - * Project-specific serialisation for the per-frame world-state snapshot - * sent from the server to each connected client. - * - * Wire format (all values little-endian): - * - * ┌──────────────────────────────────────────────────────────┐ - * │ Header (16 bytes) │ - * │ message_type : uint32 (supplied by the caller) │ - * │ sequence : uint32 (always 0, no reply expected) │ - * │ frame_idx : uint32 │ - * │ entity_count : uint32 (number of position records) │ - * ├──────────────────────────────────────────────────────────┤ - * │ Spawns section │ - * │ spawn_count : uint32 │ - * │ spawn[i].id : uint32 × spawn_count │ - * ├──────────────────────────────────────────────────────────┤ - * │ Despawns section │ - * │ despawn_count : uint32 │ - * │ despawn[i].id : uint32 × despawn_count │ - * ├──────────────────────────────────────────────────────────┤ - * │ Entity positions (hot path — tightly packed) │ - * │ [ id:uint32, x:float, y:float, z:float ] × entity_count│ - * └──────────────────────────────────────────────────────────┘ - * - * Total minimum size : 20 bytes (header + empty spawns + empty despawns) - * Per entity : 16 bytes - * 300 entities : 20 + 300×16 = 4820 bytes (well under MTU for segmented) - * - * Usage (server side, called once per client per frame): - * - * tw::serial::WorldStateWriter w(buffer); // buffer is a BinaryBuffer - * w.begin(frame_idx); - * w.write_spawns(interest.spawns()); - * w.write_despawns(interest.despawns()); - * w.begin_entities(num_entities); // writes entity_count slot - * for each entity in interest.entities(): - * w.write_entity(entity_id, position); - * w.end(); // patches entity_count - * // buffer.view() is ready to send - * - * Usage (client side): - * - * tw::serial::WorldStateReader r(payload_span); - * auto header = r.read_header(); // frame_idx + counts - * for (auto id : r.read_spawns()) { ... } - * for (auto id : r.read_despawns()) { ... } - * while (r.has_entity()) { - * auto [id, pos] = r.read_entity(); - * ... - * } - */ #include "Codec.hpp" #include "GlmCodec.hpp" @@ -67,10 +13,6 @@ namespace tw::serial { -// ────────────────────────────────────────────────────────────────────────── -// WorldStateWriter -// ────────────────────────────────────────────────────────────────────────── - class WorldStateWriter { BinaryWriter m_w; @@ -81,13 +23,6 @@ class WorldStateWriter { public: explicit WorldStateWriter(BinaryBuffer& buf) noexcept : m_w(buf) {} - /** - * Write the header. Call once per frame, before everything else. - * entity_count is patched in end(). - * - * message_type is supplied by the caller so that this module stays free of - * the address the message is delivered to. - */ void begin(uint32_t frame_idx, uint32_t message_type) noexcept { m_entity_count = 0; @@ -104,11 +39,6 @@ public: m_entity_count_offset = m_w.reserve_u32(); } - // ── Spawns ──────────────────────────────────────────────────────────── - - /** - * Write the spawn list. Pass any range of entt::entity. - */ template void write_spawns(const Range& spawns) noexcept { auto count = static_cast(std::size(spawns)); @@ -118,8 +48,6 @@ public: } } - // ── Despawns ────────────────────────────────────────────────────────── - template void write_despawns(const Range& despawns) noexcept { auto count = static_cast(std::size(despawns)); @@ -129,16 +57,6 @@ public: } } - // ── Entity positions (the hot path) ─────────────────────────────────── - - /** - * Write a single entity position record. - * id : raw uint32 of the entity handle - * pos : world-space position (x, y, z) - * - * This is the innermost loop of the replicator — every byte matters. - * The compiler will inline both calls down to two contiguous memcpys. - */ void write_entity(uint32_t id, const glm::vec3& pos) noexcept { m_w.write(id); // Write x, y, z as 3 contiguous floats @@ -146,20 +64,14 @@ public: ++m_entity_count; } - // Convenience overload accepting an entt::entity handle directly void write_entity(entt::entity entity, const glm::vec3& pos) noexcept { write_entity(static_cast(entity), pos); } - /** - * Patch the entity_count field written in begin() and finalise the - * buffer. Must be called exactly once after all write_entity() calls. - */ void end() noexcept { m_w.patch_u32(m_entity_count_offset, m_entity_count); } - /** Expose the underlying buffer view (e.g. to pass to send_message). */ std::span view() noexcept { return m_w.buffer().view(); } @@ -170,10 +82,6 @@ public: } }; -// ────────────────────────────────────────────────────────────────────────── -// WorldStateReader (client-side / test use) -// ────────────────────────────────────────────────────────────────────────── - struct WorldStateHeader { uint32_t packet_type; uint32_t frame_idx; diff --git a/modules/server/src/PlayerSession.hpp b/modules/server/src/PlayerSession.hpp index 600a801..7c79a0e 100644 --- a/modules/server/src/PlayerSession.hpp +++ b/modules/server/src/PlayerSession.hpp @@ -8,19 +8,22 @@ namespace tw::net { +/** + * Session with the player's client. + */ struct PlayerSession { public: SessionId session_id; msg::MessageConnection* connection; - uint32_t last_frame; + uint32_t last_received_frame; uint32_t acked_frame; PlayerSession(SessionId session_id, msg::MessageConnection* connection) : session_id(session_id), connection(connection), - last_frame(0), + last_received_frame(0), acked_frame(0) { } }; diff --git a/modules/server/src/ZoneClusterLink.hpp b/modules/server/src/ZoneClusterLink.hpp index c488ed8..3c14c41 100644 --- a/modules/server/src/ZoneClusterLink.hpp +++ b/modules/server/src/ZoneClusterLink.hpp @@ -12,11 +12,6 @@ namespace tw::net { -// Listens on a dedicated port for incoming zone-server peer connections and -// opens outgoing connections to known peers. -// -// Incoming and outgoing peers are held together so that broadcast messages -// (ZoneHello, ZoneBye) reach every peer uniformly. class ZoneClusterLink { std::unique_ptr m_endpoint; ProtobufMessages m_messages; @@ -24,13 +19,10 @@ class ZoneClusterLink { public: explicit ZoneClusterLink(int port); - // Poll for messages and accept any pending peer connections. void update(); - // Connect to a remote zone peer and register it in the peer list. msg::MessageConnection* connect_to_peer(const std::string& host, int port); - // Send a protobuf message to a single peer. template void send_mesg(msg::MessageConnection* peer, const T& msg) { auto send_r = m_messages.send(peer, msg, false); diff --git a/modules/server/src/ZoneManager.cpp b/modules/server/src/ZoneManager.cpp index f44dcbb..1c4e91c 100644 --- a/modules/server/src/ZoneManager.cpp +++ b/modules/server/src/ZoneManager.cpp @@ -19,8 +19,6 @@ ZoneManager::ZoneManager(im::AreaBounds zone_bounds) )) {} -// ── ZoneProxy interface ─────────────────────────────────────────────────────── - im::AreaBounds ZoneManager::area() const { return { { (float)m_spatial_backend->world_min_x(), (float)m_spatial_backend->world_min_z() }, @@ -33,8 +31,6 @@ void ZoneManager::transfer_entity(EntityInfo&& info) { spawn_entity(std::move(info)); } -// ── Zone management ─────────────────────────────────────────────────────────── - entt::entity ZoneManager::spawn_entity(EntityInfo&& info) { entt::entity entity = m_world->registry().create(); @@ -104,8 +100,6 @@ uint32_t ZoneManager::acked_input_frame(im::InterestId interest_id) const { return it != m_session_acked_frame.end() ? it->second : 0; } -// ── Private helpers ─────────────────────────────────────────────────────────── - void ZoneManager::check_neighbor_transfers() { ZoneScopedN("ZoneManager::check_neighbor_transfers"); @@ -161,9 +155,7 @@ void ZoneManager::check_neighbor_transfers() { } } -// ── Tick ────────────────────────────────────────────────────────────────────── - -void ZoneManager::tick(uint32_t frame_idx, float delta_time) { +void ZoneManager::update(uint32_t frame_idx, float delta_time) { ZoneScopedN("ZoneManager::tick"); FrameMarkStart("Interest System"); @@ -176,21 +168,7 @@ void ZoneManager::tick(uint32_t frame_idx, float delta_time) { m_world->step(delta_time); FrameMarkEnd("World step"); - // Re-stamp each client entity's newest input onto the server's frame index. - // The input ring is keyed by client frame numbers (independent counter), - // but physics lookup uses the server's frame counter. By re-stamping onto - // the server frame, input(server_frame_idx) resolves by exact match rather - // than fallback, ensuring deterministic and correct input consumption. - for (const auto& [interest_id, entity] : m_client_entities) { - auto* controller = m_world->registry().try_get(entity); - if (!controller) continue; - controller->set_input(frame_idx, controller->input()); - } - FrameMarkStart("Physics step"); - // Fixed step, not the measured interval: the simulation has to advance by the - // same amount every frame for a replay of the same inputs to land in the same - // place. m_physics_world.step(frame_idx, JoltPhysicsWorld::FIXED_DELTA_TIME); FrameMarkEnd("Physics step"); diff --git a/modules/server/src/ZoneManager.hpp b/modules/server/src/ZoneManager.hpp index af230bd..daa5ebf 100644 --- a/modules/server/src/ZoneManager.hpp +++ b/modules/server/src/ZoneManager.hpp @@ -18,30 +18,28 @@ namespace tw::net { -// 500-unit cells, 500-unit view radius → 3×3 neighbourhood, no per-entity distance checks. -// World bounds are supplied to the constructor at runtime. using SpatialBackendType = im::FixedGrid<500, 500>; -// How far into this zone a neighbour's interest area is expanded. -// Entities within this margin are tracked by the interest system before transfer. static constexpr float kNeighborBorderOverlap = 100.0f; +/** + * Manages one zone: contains state of all entities inside and does logic for them. + */ class ZoneManager : public ZoneProxy { struct NeighborEntry { ZoneProxy* proxy; im::InterestId interest_id; }; - // Declaration order == initialisation order. std::unique_ptr m_world; JoltPhysicsWorld m_physics_world; std::unique_ptr m_spatial_backend; std::unique_ptr> m_interest_system; std::unordered_map m_client_entities; - std::unordered_map m_entity_sessions; // reverse map - std::unordered_map m_session_input_frame; // most recent input frame received - std::unordered_map m_session_acked_frame; // input frame consumed by last tick + std::unordered_map m_entity_sessions; + std::unordered_map m_session_input_frame; + std::unordered_map m_session_acked_frame; std::vector m_neighbors; uint32_t m_next_neighbor_id = 0x80000000u; @@ -57,39 +55,23 @@ public: ZoneManager(im::AreaBounds zone_bounds); - // ── ZoneProxy interface ─────────────────────────────────────────────────── - - // Returns the geographic area this zone owns, derived from the spatial backend bounds. im::AreaBounds area() const override; - // Receive an entity transferred from a neighbouring zone. - // Spawns the entity in this zone's world; wires up input routing if session_id is set. - // NOTE: ZoneServer must also update its session→zone routing table after this call. void transfer_entity(EntityInfo&& info) override; - // ── Zone management ─────────────────────────────────────────────────────── - entt::entity spawn_entity(EntityInfo&& info); - // Registers an interest for the given entity and begins tracking entities near their position. - // Uses session_id as the InterestId; the interest system is unaware of this mapping. - // Can fetch the interest using `get_interest(interest_id)`. void add_client(im::InterestId interest_id, entt::entity entity); void on_player_move(SessionId session_id, mmo::PlayerMoveMessage&& message); - // Registers a neighbouring zone. Its geographic area is added to the interest - // manager and checked each tick; entities that cross into it are transferred. void register_neighbor_zone(ZoneProxy* neighbor); - // Returns the current interest state for any registered id, or nullptr. const im::Interest* get_interest(im::InterestId id) const; - // Returns the input frame that this session's entity was last simulated with, or 0 if unknown. uint32_t acked_input_frame(im::InterestId interest_id) const; - // Runs one tick: interest queries, neighbour transfer checks, world step, physics step. - void tick(uint32_t frame_idx, float delta_time); + void update(uint32_t frame_idx, float delta_time); }; } // namespace tw::net diff --git a/modules/server/src/ZoneServer.cpp b/modules/server/src/ZoneServer.cpp index 6c8050e..da1f58b 100644 --- a/modules/server/src/ZoneServer.cpp +++ b/modules/server/src/ZoneServer.cpp @@ -61,10 +61,8 @@ void ZoneServer::player_update_handler(SessionId session_id, mmo::PlayerMoveMess auto it = m_session_zone.find(session_id); if (it == m_session_zone.end()) return; - // Echoed back in the next snapshot, so the client can tell how long its - // input took to come back. if (auto* session = m_player_session_registry->session(session_id)) { - session->last_frame = message.frame_idx(); + session->last_received_frame = message.frame_idx(); } it->second->on_player_move(session_id, std::move(message)); @@ -134,11 +132,8 @@ void ZoneServer::run() { FrameMarkEnd("Update clients"); for (auto& zone : m_zones) { - zone->tick(frame_idx, lock_step.delta_time()); + zone->update(frame_idx, lock_step.delta_time()); - // Copy acked frames from zone to sessions before replication. - // This ensures snapshots carry the input frame actually consumed by this tick, - // not frames that arrive in the trailing network update. for (const auto& [session_id, session_zone] : m_session_zone) { if (session_zone != zone.get()) continue; if (auto* session = m_player_session_registry->session(session_id)) { diff --git a/modules/server/src/interest_management/FixedGrid.hpp b/modules/server/src/interest_management/FixedGrid.hpp index 0f521c0..3a4b1b0 100644 --- a/modules/server/src/interest_management/FixedGrid.hpp +++ b/modules/server/src/interest_management/FixedGrid.hpp @@ -7,28 +7,6 @@ namespace tw::net::im { -/** - * Bounded-world spatial grid backend. - * - * World area bounds are supplied at construction time so the same grid type - * can be reused for differently-sized zones without recompiling. - * CELL_SIZE and VIEW_RADIUS remain template parameters so kNeighborRadius - * can be computed at compile time, keeping the hot-path loop bounds constant. - * - * Template parameters: - * CELL_SIZE: size of each cell (meters). Recommended: CELL_SIZE == VIEW_RADIUS - * for maximum efficiency (no distance checks needed). - * VIEW_RADIUS: the subscription radius around a player (meters). - * - * Constructor parameters: - * min_x, max_x, min_z, max_z — world area bounds (meters). - * - * Complexity: - * begin_frame(): O(cols × rows) clearing - * insert(): O(1) - * query_neighbors(): O(kNeighborRadius²) cells × avg entities per cell - * query_area(): O(cells overlapping AABB) × avg entities per cell - */ template class FixedGrid { public: @@ -45,9 +23,6 @@ private: int32_t m_world_min_z, m_world_max_z; uint32_t m_cols, m_rows; - // Flat 2D vector of cell vectors indexed as [cz * m_cols + cx]. - // Outer vector is allocated once at construction; inner vectors retain - // capacity across frames so no heap allocations occur in steady state. std::vector> m_cells; [[nodiscard]] inline std::pair world_to_cell( diff --git a/modules/server/src/replication/StateReplicator.hpp b/modules/server/src/replication/StateReplicator.hpp index e2b3f77..2266395 100644 --- a/modules/server/src/replication/StateReplicator.hpp +++ b/modules/server/src/replication/StateReplicator.hpp @@ -1,6 +1,8 @@ #pragma once +#include "Entity.pb.h" #include "MessageRegistry.hpp" +#include "WorldState.pb.h" #include "network/NetworkReceiver.hpp" #include "network/PlayerSessionRegistry.hpp" #include "systems/Interest.hpp" @@ -18,14 +20,6 @@ namespace tw::net { /** * Decides what to replicate and to whom. Does not mutate world state. - * - * Per-frame flow: - * 1. For each session: reset its BinaryBuffer and write the header, - * spawns and despawns from the InterestSystem. - * 2. For each session: iterate its interest set and write entity positions - * 3. Patch entity_count and send each raw buffer - * - * Memory: one BinaryBuffer per session, pre-allocated and reused every frame. */ template class StateReplicator { @@ -51,7 +45,10 @@ public: /** * Replicates the current world state for one zone to its connected clients. */ - void replicate(const entt::registry& registry, const im::InterestSystem* interest_manager) { + void replicate( + const entt::registry& registry, + const im::InterestSystem* interest_manager + ) { ZoneScopedN("Replicator"); const auto& sessions = m_client_registry->sessions(); @@ -59,16 +56,12 @@ public: if (session_count == 0) return; - // ── Grow buffer pool if needed (only on new connections) ────────── if (m_frames.size() < session_count) { m_frames.resize(session_count); for (auto& buf : m_frames) buf.reserve(kInitialCapacity); } - // Build one WorldStateWriter per client referencing the pre-allocated - // buffer. reserve() is called before this loop so no reallocation - // occurs and the buffer references inside the writers stay valid. std::vector writers; writers.reserve(session_count); for (std::size_t i = 0; i < session_count; ++i) @@ -76,7 +69,6 @@ public: std::vector client_states(session_count); - // ── 1. Write headers / spawns / despawns ────────────────────────── { ZoneScopedN("Preparing messages"); @@ -96,26 +88,37 @@ public: m_frames[i].reserve(needed); writers[i].reset(); - writers[i].begin(session->acked_frame, Message::value); + writers[i].begin(session->last_received_frame, Message::value); writers[i].write_spawns(state->spawn()); writers[i].write_despawns(state->despawn()); + + mmo::EntitySpawnMessage spawn_message = {}; + + for(auto& spawn : state->spawn()) { + auto spawn_item = spawn_message.add_spawns(); + spawn_item->set_entity_id((uint32_t)spawn); + spawn_item->set_is_player(false); + spawn_item->set_name("Test"); + } + + m_network->send_mesg(sessions[i]->session_id, spawn_message); } } - // ── 2. Write entity positions (hot path, O(entities × sessions)) ── { ZoneScopedN("Putting transforms into messages"); auto view = registry.view(); view.each([&](entt::entity e, const Transform& t) { for (std::size_t i = 0; i < session_count; ++i) { - if (client_states[i] && client_states[i]->is_interested_in_entity(e)) + if (client_states[i] && client_states[i]->is_interested_in_entity(e)) { writers[i].write_entity(e, t.position()); + // spdlog::info("Entity {} is at {} {} {}", (uint32_t)e, t.position().x, t.position().y, t.position().z); + } } }); } - // ── 3. Finalise and dispatch ─────────────────────────────────────── { ZoneScopedN("Sending messages"); diff --git a/modules/server/src/systems/InterestSystem.hpp b/modules/server/src/systems/InterestSystem.hpp index 155c594..139ba72 100644 --- a/modules/server/src/systems/InterestSystem.hpp +++ b/modules/server/src/systems/InterestSystem.hpp @@ -12,28 +12,10 @@ namespace tw::net::im { -/** - * Spatial interest management: maintains per-subscription spawn/despawn deltas. - * - * Each subscription is identified by a caller-chosen InterestId (uint32_t). - * Two kinds of subscription: - * set_entity_interest(id, entity) — tracks entities in the neighbourhood of - * a world entity's position (e.g. a player). - * set_area_interest(id, bounds) — tracks entities inside an XZ quad - * (e.g. a zone border region). - * - * The caller decides how ids map to sessions, zones, or anything else. - * InterestSystem has no knowledge of that mapping. - * - * Two-phase update per frame: - * Phase 1 — Spatial rebuild (O(E)): insert all Transform entities into the backend. - * Phase 2 — Per-subscription deltas (O(N × K)): for each subscription query the - * backend, sort results, compute spawn/despawn via set_difference. - */ template class InterestSystem { const World* m_world; - Backend* m_backend; // non-owning; caller manages lifetime + Backend* m_backend; std::unordered_map m_interests; public: @@ -42,33 +24,26 @@ public: m_backend(backend) {} - // Registers or replaces a subscription tracking the neighbourhood of an entity. void set_entity_interest(InterestId id, entt::entity entity) { m_interests.insert_or_assign(id, Interest(entity)); } - // Registers or replaces a subscription tracking entities inside an XZ quad. void set_area_interest(InterestId id, AreaBounds bounds) { m_interests.insert_or_assign(id, Interest(bounds)); } - // Removes a subscription. No-op if id is not registered. void remove_interest(InterestId id) { m_interests.erase(id); } - // Returns the current interest state for the given id, or nullptr. const Interest* get_interest(InterestId id) const { auto it = m_interests.find(id); return it != m_interests.end() ? &it->second : nullptr; } - // Updates all subscriptions for the current frame. - // Must be called once per frame after world positions have been updated. void update() { ZoneScopedN("InterestSystem::update"); - // ── Phase 1: Rebuild spatial backend ───────────────────────────────── { ZoneScopedN("InterestSystem::Phase1_GridRebuild"); m_backend->begin_frame(); @@ -79,7 +54,6 @@ public: }); } - // ── Phase 2: Per-subscription deltas ───────────────────────────────── { ZoneScopedN("InterestSystem::Phase2_Deltas"); diff --git a/modules/server/tests/ZoneManagerTests.cpp b/modules/server/tests/ZoneManagerTests.cpp index 1c1c947..c023394 100644 --- a/modules/server/tests/ZoneManagerTests.cpp +++ b/modules/server/tests/ZoneManagerTests.cpp @@ -39,16 +39,16 @@ int main() { tw::Transform* transformA = zoneA.registry().try_get(entityA); transformA->set_position(glm::vec3(970.0f, 0.0f, 0.0f)); - zoneA.tick(0, 0.1f); - zoneB.tick(0, 0.1f); + zoneA.update(0, 0.1f); + zoneB.update(0, 0.1f); interest = zoneA.get_interest(1); transformA = zoneA.registry().try_get(entityA); transformA->set_position(glm::vec3(1020.0f, 0.0f, 0.0f)); - zoneA.tick(1, 0.1f); - zoneB.tick(1, 0.1f); + zoneA.update(1, 0.1f); + zoneB.update(1, 0.1f); interest = zoneA.get_interest(1); diff --git a/src/runtime/LockStep.hpp b/src/runtime/LockStep.hpp index 758e0b5..9c29dcc 100644 --- a/src/runtime/LockStep.hpp +++ b/src/runtime/LockStep.hpp @@ -51,7 +51,7 @@ public: return m_last_interval_ms / (double)IN_SECOND; } - bool update() { + bool has_ticked() { auto now = Clock::now(); m_last_interval_ms = duration_cast(now - m_last_point).count(); diff --git a/src/world/CharacterController.hpp b/src/world/CharacterController.hpp index 2440a6e..8723e25 100644 --- a/src/world/CharacterController.hpp +++ b/src/world/CharacterController.hpp @@ -1,7 +1,7 @@ #pragma once -#include #include +#include #include #include "metrics/HistoryBuffer.hpp" @@ -20,16 +20,15 @@ private: HistoryBuffer m_history; HistoryBuffer m_position_history; - // Frame-indexed input ring, capacity 64 struct InputSlot { uint32_t frame; glm::vec3 input; bool valid; }; - std::array m_input_ring; + // buffers inputs with history frame_idx stamp. Can be then read with get_input(frame_idx) + std::vector m_input_ring; uint32_t m_last_input_frame; - glm::vec3 m_last_input; uint32_t m_frame_idx; @@ -42,11 +41,10 @@ public: m_speed(speed), m_history(Clock::now(), glm::vec3(), 10 * 20), m_position_history(Clock::now(), glm::vec3(), 10 * 20), + m_input_ring(64), m_last_input_frame(0), - m_last_input(0.0f), m_frame_idx(0) { - // Initialize input ring for(auto& slot : m_input_ring) { slot.frame = 0; slot.input = glm::vec3(0.0f); @@ -61,31 +59,34 @@ public: m_input_ring[idx].valid = true; m_last_input_frame = frame_idx; - m_last_input = input; } void set_frame_idx(uint32_t idx) { m_frame_idx = idx; } - glm::vec3 input() const { - return m_last_input; + /** + * Returns the most recently received input, or zero vector if none was ever set. + * This is what the simulation consumes: frame stamps come from the client's own + * counter, so they cannot be looked up by a server-side frame number. + */ + glm::vec3 latest_input() const { + const auto& slot = m_input_ring[m_last_input_frame % m_input_ring.size()]; + return slot.valid ? slot.input : glm::vec3(0.0f); } - // Returns the input for the specified frame, or falls back to the most recently set input - // if the frame slot has been overwritten or never written + /** + * Returns input for specific frame or zero vector if not yet set + */ glm::vec3 input(uint32_t frame_idx) const { size_t idx = frame_idx % m_input_ring.size(); const auto& slot = m_input_ring[idx]; - // If slot contains the exact frame we're looking for, return it if(slot.valid && slot.frame == frame_idx) { return slot.input; } - // Otherwise, fall back to the most recent input - // This handles dropped packets (slot never written) or wraparound (slot overwritten) - return m_last_input; + return glm::vec3(0.0f); } }; diff --git a/src/world/JoltPhysicsWorld.cpp b/src/world/JoltPhysicsWorld.cpp index 5415eec..4977d3f 100644 --- a/src/world/JoltPhysicsWorld.cpp +++ b/src/world/JoltPhysicsWorld.cpp @@ -133,7 +133,7 @@ void JoltPhysicsWorld::update(uint32_t frame_idx, double delta_time) { bool mAllowSliding = true; if (player_controls_horizontal_velocity) { - glm::vec3 input = controller.input(frame_idx); + glm::vec3 input = controller.latest_input(); // Smooth the player input JPH::Vec3 vel = JPH::Vec3(input.x, input.y, input.z) * controller.speed(); float inertia_factor = 1.0f - std::exp(-delta_time * 10.0f); // 10.0 = responsiveness tuning knob