commit a8d725e94b082a0b0ea5da5e04deca05902c5d4d Author: Oleg Ermakov Date: Sun Jan 11 01:37:39 2026 +0400 Initial commit: full project code diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5499ce7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +/.idea \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..1a9c078 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "extern/enet"] + path = extern/enet + url = https://github.com/zpl-c/enet diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..b85706e --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,153 @@ +cmake_minimum_required(VERSION 4.2) +project(netcode_demo LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 23) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin) +set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib) + +# ENet enable shared +set(ENET_SHARED ON CACHE BOOL "Build enet as a shared library" FORCE) +set(ENET_STATIC OFF CACHE BOOL "Build enet as a static library" FORCE) + +# Build flags +option(BUILD_ALL "Build everything" ON) +option(BUILD_COMMON "Build common" ON) +option(BUILD_CLIENT "Build client" ON) +option(BUILD_SERVER "Build server" ON) +option(BUILD_APP "Build app" ON) +option(BUILD_SAPP "Build sapp" ON) + +find_package(SFML 3.0 COMPONENTS System Window Graphics Network REQUIRED) +#add_compile_definitions(ENET_IPV6=1) +add_subdirectory(extern/enet) +#target_compile_definitions(enet_shared PUBLIC ENET_IPV6=1) + +# --------------------------- +# Common library +# --------------------------- +if(BUILD_COMMON OR BUILD_ALL) + add_library(common SHARED + common/src/common.cpp + ) + set_target_properties(common PROPERTIES WINDOWS_EXPORT_ALL_SYMBOLS ON) + target_include_directories(common PUBLIC common/include) + target_link_libraries(common PUBLIC enet::enet_shared) + if(WIN32) + target_link_libraries(common PUBLIC ws2_32 winmm) + endif() +endif() + +# --------------------------- +# App library +# --------------------------- +if(BUILD_APP OR BUILD_ALL) + add_library(app SHARED + app/src/app_entry.cpp + app/include/app/network_simulation.hpp + ) + set_target_properties(app PROPERTIES WINDOWS_EXPORT_ALL_SYMBOLS ON) + target_include_directories(app PUBLIC app/include) + if(BUILD_COMMON OR BUILD_ALL) + target_link_libraries(app PUBLIC common) + endif() +endif() + +# --------------------------- +# Sapp library +# --------------------------- +if(BUILD_SAPP OR BUILD_ALL) + add_library(sapp SHARED + sapp/src/sapp_entry.cpp + ) + set_target_properties(sapp PROPERTIES WINDOWS_EXPORT_ALL_SYMBOLS ON) + target_include_directories(sapp PUBLIC sapp/include) + if(BUILD_COMMON OR BUILD_ALL) + target_link_libraries(sapp PUBLIC common) + endif() +endif() + +# --------------------------- +# Client executable +# --------------------------- +if(BUILD_CLIENT OR BUILD_ALL) + add_executable(client client/cl_main.cpp + ) + target_include_directories(client PRIVATE client) + target_link_libraries(client PUBLIC + $<$:common> + $<$:app> + SFML::System SFML::Window SFML::Graphics SFML::Network enet::enet_shared + ) + if(WIN32) + target_link_libraries(client PRIVATE ws2_32 winmm) + endif() + set_target_properties(client PROPERTIES + RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin/client/$ + ) + + if(WIN32) + add_custom_command(TARGET client POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different $ $ + COMMAND ${CMAKE_COMMAND} -E copy_if_different $ $ + COMMAND ${CMAKE_COMMAND} -E copy_if_different $ $ + COMMAND_EXPAND_LISTS + ) + endif() +endif() + +# --------------------------- +# Server executable +# --------------------------- +if(BUILD_SERVER OR BUILD_ALL) + add_executable(server server/sv_main.cpp) + target_include_directories(server PRIVATE server) + target_link_libraries(server PUBLIC + $<$:common> + $<$:sapp> + enet::enet_shared + ) + if(WIN32) + target_link_libraries(server PRIVATE ws2_32 winmm) + endif() + set_target_properties(server PROPERTIES + RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin/server/$ + ) + + if(WIN32) + add_custom_command(TARGET server POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different $ $ + COMMAND ${CMAKE_COMMAND} -E copy_if_different $ $ + COMMAND ${CMAKE_COMMAND} -E copy_if_different $ $ + COMMAND_EXPAND_LISTS + ) + endif() +endif() + + +# --------------------------- +# Main target +# --------------------------- +add_custom_target(netcode_demo ALL + COMMENT "Build all enabled subprojects according to BUILD_* flags" +) + +if(BUILD_COMMON OR BUILD_ALL) + add_dependencies(netcode_demo common) +endif() +if(BUILD_APP OR BUILD_ALL) + add_dependencies(netcode_demo app) +endif() +if(BUILD_SAPP OR BUILD_ALL) + add_dependencies(netcode_demo sapp) +endif() +if(BUILD_CLIENT OR BUILD_ALL) + add_dependencies(netcode_demo client) +endif() +if(BUILD_SERVER OR BUILD_ALL) + add_dependencies(netcode_demo server) +endif() + + + diff --git a/app/include/app/app_entry.hpp b/app/include/app/app_entry.hpp new file mode 100644 index 0000000..17113c4 --- /dev/null +++ b/app/include/app/app_entry.hpp @@ -0,0 +1,5 @@ +#ifndef NETCODE_DEMO_APP_ENTRY_HPP +#define NETCODE_DEMO_APP_ENTRY_HPP + + +#endif //NETCODE_DEMO_APP_ENTRY_HPP \ No newline at end of file diff --git a/app/include/app/client_entity.hpp b/app/include/app/client_entity.hpp new file mode 100644 index 0000000..92da0d0 --- /dev/null +++ b/app/include/app/client_entity.hpp @@ -0,0 +1,54 @@ +#pragma once + +#include "common/types.hpp" +#include "common/math_types.hpp" +#include "common/snapshot.hpp" + +namespace netcode { + + // представление сущности на клиенте + struct ClientEntity { + EntityId id = INVALID_ENTITY_ID; + EntityType type = EntityType::None; + + // позиции для разных режимов + Vec2 server_position; // подтвержденная + Vec2 predicted_position; // предиктнутая + Vec2 interpolated_position; // Интерполированная + Vec2 render_position; // для рендера + + Vec2 server_velocity; + Vec2 predicted_velocity; + + // интерполяция + Vec2 prev_position; + Vec2 next_position; + TickNumber prev_tick = 0; + TickNumber next_tick = 0; + + Vec2 correction_offset; + + // Данные сущности + float radius = 0.0f; + TeamId team = TeamId::None; + ClientId owner_id = INVALID_CLIENT_ID; + + // локальный клиент + bool is_local_player = false; + + void update_from_state(const EntityState& state) { + type = state.type; + server_position = state.position; + server_velocity = state.velocity; + + if (type == EntityType::Player) { + radius = state.data.player.radius; + team = state.data.player.team; + owner_id = state.data.player.owner_id; + } else if (type == EntityType::Ball) { + radius = state.data.ball.radius; + } + } + }; + +} \ No newline at end of file diff --git a/app/include/app/client_world.hpp b/app/include/app/client_world.hpp new file mode 100644 index 0000000..5c002eb --- /dev/null +++ b/app/include/app/client_world.hpp @@ -0,0 +1,260 @@ +#pragma once + +#include "common/types.hpp" +#include "common/math_types.hpp" +#include "common/snapshot.hpp" +#include "common/simulation_params.hpp" +#include "common/compensation_algorithm.hpp" +#include "common/metrics.hpp" +#include "common/timestamp.hpp" +#include "client_entity.hpp" +#include "prediction.hpp" +#include "reconciliation.hpp" +#include "interpolation.hpp" +#include +#include + +namespace netcode { + class LocalSimulator; +} + +namespace netcode { + +class ClientWorld { +public: + explicit ClientWorld(const SimulationParams& params = DEFAULT_SIMULATION_PARAMS) + : params_(params) + , prediction_(params) + , interpolator_(100.0f) + , extrapolator_(250.0f) + , local_simulator_(params) + {} + + void set_local_player(ClientId client_id, EntityId entity_id) { + local_client_id_ = client_id; + local_entity_id_ = entity_id; + } + + void process_local_input(const InputCommand& cmd) { + prediction_.add_input(cmd); + metrics_.add_prediction(); + } + + void receive_snapshot(const WorldSnapshot& snapshot) { + for (const auto& ack : snapshot.client_acks) { + if (ack.client_id == local_client_id_) { + last_ack_sequence_ = ack.last_processed_sequence; + break; + } + } + + last_server_tick_ = snapshot.server_tick; + last_snapshot_time_ = Timestamp::now_us(); + + interpolator_.add_snapshot(snapshot); + + SequenceNumber ack_seq = 0; + for (const auto& ack : snapshot.client_acks) { + if (ack.client_id == local_client_id_) { + ack_seq = ack.last_processed_sequence; + break; + } + } + + for (const auto& state : snapshot.entities) { + auto it = entities_.find(state.id); + if (it == entities_.end()) { + ClientEntity entity; + entity.id = state.id; + entity.update_from_state(state); + entity.is_local_player = (state.id == local_entity_id_); + entity.predicted_position = state.position; + entity.predicted_velocity = state.velocity; + entity.interpolated_position = state.position; + entity.render_position = state.position; + entities_[state.id] = entity; + } else { + ClientEntity& entity = it->second; + + Vec2 old_render_position = entity.render_position; + + entity.update_from_state(state); + + if (entity.is_local_player && + (algorithm_ == CompensationAlgorithm::PredictionReconciliation || + algorithm_ == CompensationAlgorithm::Hybrid)) { + + auto result = reconciliator_.reconcile( + entity.predicted_position, entity.predicted_velocity, + state.position, state.velocity, + ack_seq, prediction_, &metrics_ + ); + + entity.predicted_position = result.new_predicted_pos; + entity.predicted_velocity = result.new_predicted_vel; + entity.correction_offset = result.correction_delta; + } + else if (entity.is_local_player && + algorithm_ == CompensationAlgorithm::ClientPrediction) { + + Vec2 pos = state.position; + Vec2 vel = state.velocity; + + prediction_.acknowledge(ack_seq); + prediction_.predict(pos, vel, ack_seq); + + entity.predicted_position = pos; + entity.predicted_velocity = vel; + } + + if (entity.is_local_player && algorithm_ != CompensationAlgorithm::None) { + metrics_.add_position_error( + old_render_position.x, old_render_position.y, + state.position.x, state.position.y + ); + } + } + } + + std::vector to_remove; + for (const auto& [id, entity] : entities_) { + if (!snapshot.find_entity(id)) { + to_remove.push_back(id); + } + } + for (EntityId id : to_remove) { + entities_.erase(id); + } + + red_team_time_ = snapshot.red_team_time; + blue_team_time_ = snapshot.blue_team_time; + } + + void update(double current_time_ms) { + auto it = entities_.find(local_entity_id_); + if (it != entities_.end() && it->second.is_local_player) { + ClientEntity& player = it->second; + + if (algorithm_ == CompensationAlgorithm::ClientPrediction || + algorithm_ == CompensationAlgorithm::PredictionReconciliation || + algorithm_ == CompensationAlgorithm::Hybrid) { + + Vec2 pos = player.server_position; + Vec2 vel = player.server_velocity; + + prediction_.predict(pos, vel, last_ack_sequence_); + + player.predicted_position = pos; + player.predicted_velocity = vel; + } + } + + for (auto& [id, entity] : entities_) { + switch (algorithm_) { + case CompensationAlgorithm::None: + entity.render_position = entity.server_position; + break; + + case CompensationAlgorithm::ClientPrediction: + case CompensationAlgorithm::PredictionReconciliation: + if (entity.is_local_player) { + entity.render_position = entity.predicted_position; + } else { + entity.render_position = entity.server_position; + } + break; + + case CompensationAlgorithm::EntityInterpolation: + entity.render_position = interpolator_.interpolate(id, current_time_ms); + break; + + case CompensationAlgorithm::DeadReckoning: { + double elapsed = current_time_ms - + static_cast(last_snapshot_time_) / 1000.0; + entity.render_position = extrapolator_.extrapolate( + entity.server_position, entity.server_velocity, elapsed + ); + break; + } + + case CompensationAlgorithm::Hybrid: + if (entity.is_local_player) { + entity.render_position = entity.predicted_position; + } else { + entity.render_position = interpolator_.interpolate(id, current_time_ms); + } + break; + + default: + entity.render_position = entity.server_position; + break; + } + + if (entity.correction_offset.length_squared() > 0.01f) { + entity.correction_offset *= 0.9f; + entity.render_position += entity.correction_offset; + } + } + } + + void set_algorithm(CompensationAlgorithm algo) { + algorithm_ = algo; + } + + CompensationAlgorithm algorithm() const { return algorithm_; } + + const std::unordered_map& entities() const { + return entities_; + } + + const ClientEntity* get_local_player() const { + auto it = entities_.find(local_entity_id_); + return it != entities_.end() ? &it->second : nullptr; + } + + void set_simulation_params(const SimulationParams& new_params) { + params_ = new_params; + local_simulator_.params_ = new_params; + } + + MetricsCollector& metrics() { return metrics_; } + const MetricsCollector& metrics() const { return metrics_; } + + float red_team_time() const { return red_team_time_; } + float blue_team_time() const { return blue_team_time_; } + + const SimulationParams& params() const { return params_; } + + void set_interpolation_delay(float ms) { interpolator_.set_delay(ms); } + void set_reconciliation_config(const Reconciliator::Config& cfg) { + reconciliator_.set_config(cfg); + } + +private: + SequenceNumber last_ack_sequence_ = 0; + + SimulationParams params_; + + std::unordered_map entities_; + + ClientId local_client_id_ = INVALID_CLIENT_ID; + EntityId local_entity_id_ = INVALID_ENTITY_ID; + + CompensationAlgorithm algorithm_ = CompensationAlgorithm::Hybrid; + + PredictionManager prediction_; + Reconciliator reconciliator_; + Interpolator interpolator_; + Extrapolator extrapolator_; + LocalSimulator local_simulator_; + + MetricsCollector metrics_; + + TickNumber last_server_tick_ = 0; + uint64_t last_snapshot_time_ = 0; + + float red_team_time_ = 0.0f; + float blue_team_time_ = 0.0f; +}; + +} // namespace netcode \ No newline at end of file diff --git a/app/include/app/interpolation.hpp b/app/include/app/interpolation.hpp new file mode 100644 index 0000000..e54e47d --- /dev/null +++ b/app/include/app/interpolation.hpp @@ -0,0 +1,150 @@ +#pragma once + +#include "common/types.hpp" +#include "common/math_types.hpp" +#include "common/snapshot.hpp" +#include "common/simulation_params.hpp" +#include "common/ring_buffer.hpp" +#include "client_entity.hpp" +#include + +namespace netcode { + +class Interpolator { +public: + explicit Interpolator(float delay_ms = 100.0f) + : interpolation_delay_ms_(delay_ms) {} + + void set_delay(float delay_ms) { + interpolation_delay_ms_ = delay_ms; + } + + // новый снапшот в кольцевой буфер + void add_snapshot(const WorldSnapshot& snapshot) { + snapshots_.push(snapshot); + } + + // основная интерполяция с fallback на экстраполяцию + Vec2 interpolate(EntityId entity_id, double current_time_ms) const { + double render_time = current_time_ms - interpolation_delay_ms_; + + const WorldSnapshot* before = nullptr; + const WorldSnapshot* after = nullptr; + + // ищем ближайшие снапшоты слева и справа от render_time + for (size_t i = 0; i < snapshots_.size(); ++i) { + const WorldSnapshot* snap = snapshots_.at(i); + if (!snap) continue; + + double snap_time = static_cast(snap->timestamp_us) / 1000.0; + + if (snap_time <= render_time) { + if (!before || snap_time > static_cast(before->timestamp_us) / 1000.0) { + before = snap; + } + } + if (snap_time >= render_time) { + if (!after || snap_time < static_cast(after->timestamp_us) / 1000.0) { + after = snap; + } + } + } + + if (!before && !after) return Vec2(); // пусто + + // только будущий снапшот - экстраполируем назад (редко) + if (!before) { + const auto* e = after->find_entity(entity_id); + if (!e) return Vec2(); + double dt = (render_time - static_cast(after->timestamp_us)/1000.0) / 1000.0; + return e->position + e->velocity * static_cast(dt); + } + + // только старый - экстраполируем вперед + if (!after) { + const auto* e = before->find_entity(entity_id); + if (!e) return Vec2(); + + double dt = (render_time - static_cast(before->timestamp_us)/1000.0) / 1000.0; + dt = (std::min)(dt, 0.1); // лимит 100мс + + return e->position + e->velocity * static_cast(dt); + } + + // интерполяция + const auto* eb = before->find_entity(entity_id); + const auto* ea = after->find_entity(entity_id); + + if (!eb || !ea) { + return eb ? eb->position : (ea ? ea->position : Vec2()); + } + + double t1 = static_cast(before->timestamp_us) / 1000.0; + double t2 = static_cast(after->timestamp_us) / 1000.0; + + double t = 0.0; + if (t2 > t1) { + t = (render_time - t1) / (t2 - t1); + t = std::clamp(t, 0.0, 1.0); + } + + // hermite вместо линейной для более плавного движения + return hermite_interpolate( + eb->position, eb->velocity, + ea->position, ea->velocity, + static_cast(t) + ); + } + + void clear() { + snapshots_.clear(); + } + + float get_delay() const { return interpolation_delay_ms_; } + +private: + // hermite - пытаемся учесть скорость на концах + Vec2 hermite_interpolate(const Vec2& p0, const Vec2& v0, + const Vec2& p1, const Vec2& v1, + float t) const { + float t2 = t*t; + float t3 = t2*t; + + float h00 = 2*t3 - 3*t2 + 1; + float h10 = t3 - 2*t2 + t; + float h01 = -2*t3 + 3*t2; + float h11 = t3 - t2; + + // грубая оценка, dt между снапшотами ~50мс + float dt = 0.05f; + Vec2 m0 = v0 * dt; + Vec2 m1 = v1 * dt; + + return p0*h00 + m0*h10 + p1*h01 + m1*h11; + } + + RingBuffer snapshots_; + float interpolation_delay_ms_; +}; + +// простая линейная экстраполяция с отсечкой +class Extrapolator { +public: + explicit Extrapolator(float max_time_ms = 250.0f) + : max_extrapolation_ms_(max_time_ms) {} + + Vec2 extrapolate(const Vec2& position, const Vec2& velocity, + double elapsed_ms) const { + double t = (std::min)(elapsed_ms, static_cast(max_extrapolation_ms_)); + return position + velocity * static_cast(t / 1000.0); + } + + void set_max_time(float max_ms) { + max_extrapolation_ms_ = max_ms; + } + +private: + float max_extrapolation_ms_; +}; + +} \ No newline at end of file diff --git a/app/include/app/network_simulation.hpp b/app/include/app/network_simulation.hpp new file mode 100644 index 0000000..684d26e --- /dev/null +++ b/app/include/app/network_simulation.hpp @@ -0,0 +1,302 @@ +#pragma once + +#include "common/types.hpp" +#include "common/timestamp.hpp" +#include +#include +#include +#include + +namespace netcode { + +// Пакет с искусственной задержкой +struct DelayedPacket { + std::vector data; + uint64_t delivery_time_us; // когда доставить + + DelayedPacket(const uint8_t* packet_data, size_t size, uint64_t delivery) + : data(packet_data, packet_data + size) + , delivery_time_us(delivery) + {} +}; + +// Симулятор сетевых условий +class NetworkSimulator { +public: + struct Config { + bool enabled = false; + + // Задержка (ping/2) + float base_latency_ms = 0.0f; + + // Джиттер + float jitter_ms = 0.0f; // максимальное отклонение + + // Потеря пакетов + float packet_loss = 0.0f; // 0.0 - 1.0 (вероятность) + + // Дубликация пакетов + float packet_duplication = 0.0f; // 0.0 - 1.0 (вероятность) + + // Искусственный спайк + bool spike_enabled = false; + float spike_duration_ms = 0.0f; + float spike_delay_ms = 0.0f; + }; + + explicit NetworkSimulator(const Config& config = Config{}) + : config_(config) + , rng_(std::random_device{}()) + , jitter_dist_(0.0f, 1.0f) + , loss_dist_(0.0f, 1.0f) + {} + + // Добавить пакет в очередь с задержкой + void send_packet(const uint8_t* data, size_t size) { + if (!config_.enabled) { + // Режим без симуляции + immediate_packets_.emplace(data, size, 0); + return; + } + + // Потеря пакета + if (config_.packet_loss > 0.0f && loss_dist_(rng_) < config_.packet_loss) { + packets_lost_++; + return; + } + + uint64_t now = Timestamp::now_us(); + float delay_ms = calculate_delay(); + uint64_t delivery_time = now + static_cast(delay_ms * 1000.0f); + + delayed_packets_.emplace(data, size, delivery_time); + packets_sent_++; + + // Дубликация пакета + if (config_.packet_duplication > 0.0f && + loss_dist_(rng_) < config_.packet_duplication) { + // Дубликат с дополнительной задержкой + float dup_delay = delay_ms + (jitter_dist_(rng_) * 10.0f); + uint64_t dup_time = now + static_cast(dup_delay * 1000.0f); + delayed_packets_.emplace(data, size, dup_time); + packets_duplicated_++; + } + } + + // Получить готовые пакеты + std::vector> receive_packets() { + std::vector> ready; + uint64_t now = Timestamp::now_us(); + + // Без симуляции + if (!config_.enabled) { + while (!immediate_packets_.empty()) { + ready.push_back(std::move(immediate_packets_.front().data)); + immediate_packets_.pop(); + } + return ready; + } + + // С симуляцией + while (!delayed_packets_.empty()) { + const auto& packet = delayed_packets_.top(); + + if (packet.delivery_time_us <= now) { + ready.push_back(packet.data); + delayed_packets_.pop(); + packets_delivered_++; + } else { + break; + } + } + + return ready; + } + + void trigger_lag_spike(float duration_ms, float delay_ms) { + spike_start_time_ = Timestamp::now_us(); + spike_duration_us_ = static_cast(duration_ms * 1000.0f); + spike_delay_us_ = static_cast(delay_ms * 1000.0f); + spike_active_ = true; + } + + void update_config(const Config& config) { + config_ = config; + } + + const Config& config() const { return config_; } + + // Статистика + struct Stats { + uint64_t packets_sent = 0; + uint64_t packets_delivered = 0; + uint64_t packets_lost = 0; + uint64_t packets_duplicated = 0; + size_t packets_in_queue = 0; + float current_latency_ms = 0.0f; + }; + + Stats get_stats() const { + Stats stats; + stats.packets_sent = packets_sent_; + stats.packets_delivered = packets_delivered_; + stats.packets_lost = packets_lost_; + stats.packets_duplicated = packets_duplicated_; + stats.packets_in_queue = delayed_packets_.size(); + stats.current_latency_ms = config_.base_latency_ms; + return stats; + } + + void reset_stats() { + packets_sent_ = 0; + packets_delivered_ = 0; + packets_lost_ = 0; + packets_duplicated_ = 0; + } + bool is_enabled() const { return config_.enabled; } + + float get_simulated_rtt_ms() const { + return config_.base_latency_ms * 2.0f; + } + +private: + float calculate_delay() { + float delay = config_.base_latency_ms; + + // случайное отклонение + if (config_.jitter_ms > 0.0f) { + // Джиттер в диапазоне -jitter +jitter + float jitter = (jitter_dist_(rng_) * 2.0f - 1.0f) * config_.jitter_ms; + delay += jitter; + } + + // Lag spike + if (spike_active_) { + uint64_t now = Timestamp::now_us(); + uint64_t elapsed = now - spike_start_time_; + + if (elapsed < spike_duration_us_) { + delay += static_cast(spike_delay_us_) / 1000.0f; + } else { + spike_active_ = false; + } + } + + // Минимум 0 + return (std::max)(0.0f, delay); + } + + struct PacketCompare { + bool operator()(const DelayedPacket& a, const DelayedPacket& b) const { + return a.delivery_time_us > b.delivery_time_us; + } + }; + + Config config_; + + // очереди пакетов + std::priority_queue, PacketCompare> delayed_packets_; + std::queue immediate_packets_; + + std::mt19937 rng_; + std::uniform_real_distribution jitter_dist_; + std::uniform_real_distribution loss_dist_; + + bool spike_active_ = false; + uint64_t spike_start_time_ = 0; + uint64_t spike_duration_us_ = 0; + uint64_t spike_delay_us_ = 0; + + // Статистика + uint64_t packets_sent_ = 0; + uint64_t packets_delivered_ = 0; + uint64_t packets_lost_ = 0; + uint64_t packets_duplicated_ = 0; +}; + +// пресеты +namespace NetworkPresets { + inline NetworkSimulator::Config Perfect() { + return NetworkSimulator::Config{ + .enabled = false + }; + } + + inline NetworkSimulator::Config LAN() { + return NetworkSimulator::Config{ + .enabled = true, + .base_latency_ms = 2.0f, + .jitter_ms = 1.0f, + .packet_loss = 0.001f + }; + } + + inline NetworkSimulator::Config GoodBroadband() { + return NetworkSimulator::Config{ + .enabled = true, + .base_latency_ms = 25.0f, + .jitter_ms = 5.0f, + .packet_loss = 0.01f + }; + } + + inline NetworkSimulator::Config AverageBroadband() { + return NetworkSimulator::Config{ + .enabled = true, + .base_latency_ms = 50.0f, + .jitter_ms = 10.0f, + .packet_loss = 0.02f + }; + } + + inline NetworkSimulator::Config PoorBroadband() { + return NetworkSimulator::Config{ + .enabled = true, + .base_latency_ms = 100.0f, + .jitter_ms = 25.0f, + .packet_loss = 0.05f + }; + } + + inline NetworkSimulator::Config Mobile4G() { + return NetworkSimulator::Config{ + .enabled = true, + .base_latency_ms = 75.0f, + .jitter_ms = 30.0f, + .packet_loss = 0.03f, + .packet_duplication = 0.005f + }; + } + + inline NetworkSimulator::Config Mobile3G() { + return NetworkSimulator::Config{ + .enabled = true, + .base_latency_ms = 150.0f, + .jitter_ms = 50.0f, + .packet_loss = 0.08f, + .packet_duplication = 0.01f + }; + } + + inline NetworkSimulator::Config Satellite() { + return NetworkSimulator::Config{ + .enabled = true, + .base_latency_ms = 300.0f, + .jitter_ms = 100.0f, + .packet_loss = 0.05f + }; + } + + inline NetworkSimulator::Config Custom(float latency_ms, float jitter_ms, + float loss = 0.0f, float duplication = 0.0f) { + return NetworkSimulator::Config{ + .enabled = true, + .base_latency_ms = latency_ms, + .jitter_ms = jitter_ms, + .packet_loss = loss, + .packet_duplication = duplication + }; + } +} + +} // namespace netcode \ No newline at end of file diff --git a/app/include/app/prediction.hpp b/app/include/app/prediction.hpp new file mode 100644 index 0000000..078d7eb --- /dev/null +++ b/app/include/app/prediction.hpp @@ -0,0 +1,123 @@ +#pragma once + +#include "common/types.hpp" +#include "common/math_types.hpp" +#include "common/input_command.hpp" +#include "common/simulation_params.hpp" +#include "common/ring_buffer.hpp" +#include + +namespace netcode { + +// локальная физика игрока (только для предсказания) +class LocalSimulator { +public: + explicit LocalSimulator(const SimulationParams& params = DEFAULT_SIMULATION_PARAMS) + : params_(params) {} + + // один тик симуляции по вводу + void simulate_player(Vec2& position, Vec2& velocity, + const InputState& input, float dt) { + Vec2 dir = input.get_direction(); + Vec2 accel = dir * params_.player_acceleration; + + // трение + velocity -= velocity * params_.player_friction * dt; + + // разгон + velocity += accel * dt; + + // ограничение скорости + float len = velocity.length(); + if (len > params_.player_max_speed) { + velocity = velocity.normalized() * params_.player_max_speed; + } + + // движение + position += velocity * dt; + + // отскок от стен + constrain_to_arena(position, velocity, params_.player_radius); + } + + // обработка стен (отражение с потерей энергии) + void constrain_to_arena(Vec2& position, Vec2& velocity, float radius) { + float minx = params_.wall_thickness + radius; + float maxx = params_.arena_width - params_.wall_thickness - radius; + float miny = params_.wall_thickness + radius; + float maxy = params_.arena_height - params_.wall_thickness - radius; + + if (position.x < minx) { position.x = minx; velocity.x = -velocity.x * params_.collision_restitution; } + if (position.x > maxx) { position.x = maxx; velocity.x = -velocity.x * params_.collision_restitution; } + if (position.y < miny) { position.y = miny; velocity.y = -velocity.y * params_.collision_restitution; } + if (position.y > maxy) { position.y = maxy; velocity.y = -velocity.y * params_.collision_restitution; } + } + + const SimulationParams& params() const { return params_; } + + SimulationParams params_; +}; + +// хранит и воспроизводит очередь неподтверждённых вводов +class PredictionManager { +public: + explicit PredictionManager(const SimulationParams& params = DEFAULT_SIMULATION_PARAMS) + : simulator_(params) {} + + // новая команда игрока в очередь + void add_input(const InputCommand& cmd) { + pending_inputs_.push(cmd); + } + + // прогнать все неподтверждённые вводы поверх текущего состояния + void predict(Vec2& position, Vec2& velocity, + SequenceNumber last_ack_sequence) { + float dt = static_cast(simulator_.params().fixed_delta_time); + + for (size_t i = 0; i < pending_inputs_.size(); ++i) { + const InputCommand* cmd = pending_inputs_.at(i); + if (!cmd) continue; + + // уже подтверждённые пропускаем + if (cmd->sequence <= last_ack_sequence) continue; + + simulator_.simulate_player(position, velocity, cmd->input, dt); + } + } + + // убираем всё что сервер уже обработал + void acknowledge(SequenceNumber sequence) { + while (!pending_inputs_.empty()) { + const InputCommand* cmd = pending_inputs_.peek(); + if (cmd && cmd->sequence <= sequence) { + pending_inputs_.pop(); + } else { + break; + } + } + } + + // для отладки/логов/отправки на сервер при необходимости + std::vector get_unacknowledged(SequenceNumber last_ack) const { + std::vector result; + for (size_t i = 0; i < pending_inputs_.size(); ++i) { + const InputCommand* cmd = pending_inputs_.at(i); + if (cmd && cmd->sequence > last_ack) { + result.push_back(*cmd); + } + } + return result; + } + + void clear() { + pending_inputs_.clear(); + } + + size_t pending_count() const { return pending_inputs_.size(); } + +private: + LocalSimulator simulator_; + RingBuffer pending_inputs_; // очередь последних вводов +}; + +} // namespace netcode \ No newline at end of file diff --git a/app/include/app/reconciliation.hpp b/app/include/app/reconciliation.hpp new file mode 100644 index 0000000..cd1c18a --- /dev/null +++ b/app/include/app/reconciliation.hpp @@ -0,0 +1,79 @@ +#pragma once + +#include "common/types.hpp" +#include "common/math_types.hpp" +#include "common/input_command.hpp" +#include "common/snapshot.hpp" +#include "common/metrics.hpp" +#include "prediction.hpp" + +namespace netcode { + +class Reconciliator { +public: + struct Config { + float instant_threshold = 5.0f; // TODO: реализовать логику + }; + + struct ReconcileResult { + Vec2 new_predicted_pos; + Vec2 new_predicted_vel; + Vec2 correction_delta; + }; + + explicit Reconciliator(const Config& config = Config{}) + : config_(config) {} + + // основная точка сверки предсказания с сервером + ReconcileResult reconcile( + const Vec2& old_predicted_pos, + const Vec2& old_predicted_vel, // не используется + const Vec2& server_position, + const Vec2& server_velocity, + SequenceNumber server_ack, + PredictionManager& prediction, + MetricsCollector* metrics = nullptr + ) { + // берём серверное состояние как базу + Vec2 pos = server_position; + Vec2 vel = server_velocity; + + // говорим предсказателю что до этого момента всё подтверждено + prediction.acknowledge(server_ack); + + // и сразу прогоняем все оставшиеся неподтверждённые вводы + prediction.predict(pos, vel, server_ack); + + // считаем насколько сильно разошлось старое предсказание + Vec2 delta = pos - old_predicted_pos; + float error = delta.length(); + + // метрики + if (metrics) { + metrics->add_reconciliation(); + metrics->add_position_error( + old_predicted_pos.x, old_predicted_pos.y, + pos.x, pos.y + ); + if (error > 0.01f) { + metrics->add_correction(error); + } + } + + // возвращаем новое предсказанное состояние + вектор коррекции + return { pos, vel, delta }; + } + + void set_config(const Config& cfg) { + config_ = cfg; + } + + const Config& config() const { + return config_; + } + +private: + Config config_; // (не используется) +}; + +} \ No newline at end of file diff --git a/app/src/app_entry.cpp b/app/src/app_entry.cpp new file mode 100644 index 0000000..a0134ee --- /dev/null +++ b/app/src/app_entry.cpp @@ -0,0 +1,15 @@ +#include "app/app_entry.hpp" +#include "app/client_world.hpp" +#include "app/client_entity.hpp" +#include "app/prediction.hpp" +#include "app/reconciliation.hpp" +#include "app/interpolation.hpp" + +namespace netcode { + + // Placeholder для линковки DLL + void app_init() { + // Инициализация библиотеки app при необходимости + } + +} // namespace netcode \ No newline at end of file diff --git a/client/cl_main.cpp b/client/cl_main.cpp new file mode 100644 index 0000000..b880a62 --- /dev/null +++ b/client/cl_main.cpp @@ -0,0 +1,1302 @@ +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN +#include +#include +#endif + + +#define NOMINMAX +#include +#include +#include +#include +#include +#include +#include + +#include "common/types.hpp" +#include "common/enet_wrapper.hpp" +#include "common/serialization.hpp" +#include "common/network_messages.hpp" +#include "common/timestamp.hpp" +#include "common/simulation_params.hpp" +#include "app/client_world.hpp" +#include "app/network_simulation.hpp" + +#include +#include + + + +#ifdef _WIN32 +const std::string defaultFontPath = "C:/Windows/Fonts/arial.ttf"; +#else +const std::string defaultFontPath = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"; +#endif + +using namespace netcode; + + +// Состояния клиента +enum class ClientState { + TeamSelection, + Connecting, + Connected, + Disconnected +}; + +class GameClient { +public: + sf::Clock bot_clock_; + + GameClient() : window_(sf::VideoMode({1760, 990}), "Netcode Demo - Client") { + window_.setFramerateLimit(120); + auto_test_mode_ = false; + if (!font_.openFromFile(defaultFontPath)) { + std::cerr << "Warning: Could not load font\n"; + } + + state_ = ClientState::TeamSelection; + + network_presets_ = { + {"Perfect (No Sim)", NetworkPresets::Perfect()}, + {"LAN (2ms)", NetworkPresets::LAN()}, + {"Good Broadband (25ms)", NetworkPresets::GoodBroadband()}, + {"Average Broadband (50ms)", NetworkPresets::AverageBroadband()}, + {"Poor Broadband (100ms)", NetworkPresets::PoorBroadband()}, + {"Mobile 4G (75ms)", NetworkPresets::Mobile4G()}, + {"Mobile 3G (150ms)", NetworkPresets::Mobile3G()}, + {"Satellite (300ms)", NetworkPresets::Satellite()} + }; + + network_sim_.update_config(network_presets_[0].second); + } + + void enable_auto_test() { + auto_test_mode_ = true; + bot_enabled_ = false; + + bot_config_.test_duration_per_preset_sec = 240.0f; + bot_config_.stabilization_time_sec = 5.0f; + bot_config_.movement_change_interval_sec = 0.7f; + bot_config_.random_movements = true; + bot_config_.circle_radius = 15.0f; + bot_config_.circle_speed = 2.0f; + + std::cout << "AUTO TEST MODE ENABLED\n"; + } + + void direct_select_team(TeamId team) { + selected_team_ = team; + state_ = ClientState::Connecting; + if (!connect_to_server()) { + state_ = ClientState::Disconnected; + } + std::cout << "Auto-selected team: " << (team == TeamId::Red ? "Red" : team == TeamId::Blue ? "Blue" : "Auto") << "\n"; + } + + void direct_set_algorithm(CompensationAlgorithm algo) { + set_algorithm(algo); + std::cout << "Direct switched to algorithm: " << algorithm_name(algo) << "\n"; + } + + void direct_set_preset(size_t index) { + if (index >= network_presets_.size()) return; + current_preset_index_ = index; + network_sim_.update_config(network_presets_[index].second); + std::cout << "Direct switched to preset: " << network_presets_[index].first << "\n"; + } + + InputState get_bot_input() { + InputState input; + if (!bot_enabled_) return input; + + float dt = bot_clock_.restart().asSeconds(); + + bot_timer_ += dt; + direction_timer_ += dt; + + if (direction_timer_ >= bot_config_.movement_change_interval_sec) { + direction_timer_ = 0.0f; + + if (bot_config_.random_movements) { + std::random_device rd; + std::mt19937 gen(rd()); + std::uniform_real_distribution dist(-1.0f, 1.0f); + + current_bot_direction_.x = dist(gen); + current_bot_direction_.y = dist(gen); + + if (current_bot_direction_.length_squared() > 0.01f) { + current_bot_direction_ = current_bot_direction_.normalized(); + } + } else { + circle_angle_ += bot_config_.circle_speed * dt; + current_bot_direction_.x = std::cos(circle_angle_); + current_bot_direction_.y = std::sin(circle_angle_); + } + } + + input.move_right = current_bot_direction_.x > 0.3f; + input.move_left = current_bot_direction_.x < -0.3f; + input.move_down = current_bot_direction_.y > 0.3f; + input.move_up = current_bot_direction_.y < -0.3f; + + return input; + } + + void run_auto_test() { + if (!auto_test_mode_) return; + + std::cout << "Starting auto test...\n"; + direct_select_team(TeamId::None); + } + + bool connect(const std::string& host, uint16_t port) { + host_ = host; + port_ = port; + return true; + } + + // Жестко багуется ибо microsoft + bool connect_to_server() { + std::cout << "Creating client...\n"; + if (!network_.create_client()) { + std::cerr << "Failed to create client\n"; +#ifdef _WIN32 + std::cerr << "WSA Error: " << WSAGetLastError() << "\n"; +#endif + return false; + } + std::cout << "Client created successfully\n"; + + std::cout << "Connecting to " << host_ << ":" << port_ << "...\n"; + server_peer_ = network_.connect(host_, port_); + if (!server_peer_) { + std::cerr << "Failed to initiate connection\n"; +#ifdef _WIN32 + std::cerr << "WSA Error: " << WSAGetLastError() << "\n"; +#endif + return false; + } + std::cout << "Connection initiated, waiting for response...\n"; + + ENetHost* raw_host = network_.raw(); + if (raw_host) { + std::cout << "Host socket: " << raw_host->socket << "\n"; + } + + ENetEvent event; + int result = network_.service(event, 5000); + + std::cout << "Service returned: " << result << "\n"; +#ifdef _WIN32 + if (result < 0) { + std::cerr << "WSA Error after service: " << WSAGetLastError() << "\n"; + } +#endif + + if (result > 0 && event.type == ENET_EVENT_TYPE_CONNECT) { + std::cout << "Connected to server\n"; + send_connect_request(); + return true; + } + + return false; + } + + void send_delayed_packets() { + if (!network_sim_.is_enabled()) return; + + auto packets = network_sim_.receive_packets(); + + for (const auto& packet_data : packets) { + if (packet_data.empty()) continue; + + NetworkChannel channel = NetworkChannel::Unreliable; + if (!packet_data.empty()) { + MessageType type = static_cast(packet_data[0]); + if (type == MessageType::ClientConnect || + type == MessageType::ClientSetAlgorithm) { + channel = NetworkChannel::Reliable; + } + } + + network_.send(server_peer_, channel, + packet_data.data(), packet_data.size(), + channel == NetworkChannel::Reliable); + } + } + + void set_custom_network_config(float latency_ms, float jitter_ms, + float packet_loss = 0.0f, float duplication = 0.0f) { + auto cfg = NetworkPresets::Custom(latency_ms, jitter_ms, packet_loss, duplication); + network_sim_.update_config(cfg); + std::cout << std::format("Custom network: {}ms ±{}ms, loss {:.1f}%\n", + latency_ms, jitter_ms, packet_loss * 100.0f); + } + + void run() { + sf::Clock clock; + sf::Clock metrics_clock; + sf::Clock compensation_metrics_clock; + + size_t current_algo_index = 0; + size_t current_preset_index = 0; + enum class AutoTestPhase { WaitingConnect, StabilizeAlgo, TestPreset, Finished } phase = AutoTestPhase::WaitingConnect; + float phase_timer = 0.0f; + + run_auto_test(); + + while (window_.isOpen()) { + float dt = clock.restart().asSeconds(); + + while (const auto event = window_.pollEvent()) { + if (event->is()) { + if (state_ == ClientState::Connected && server_peer_) { + enet_peer_disconnect(server_peer_, 0); + + ENetEvent event; + while (network_.service(event, 100) > 0) { + if (event.type == ENET_EVENT_TYPE_DISCONNECT) { + break; + } + } + } + window_.close(); + } + + if (const auto* key = event->getIf()) { + handle_key_press(key->code); + } + + if (const auto* mouse = event->getIf()) { + handle_mouse_click(mouse->position); + } + } + + switch (state_) { + case ClientState::TeamSelection: + render_team_selection(); + break; + + case ClientState::Connecting: + process_network(); + render_connecting(); + break; + + case ClientState::Connected: + process_network(); + process_input(); + + { + double current_time_ms = Timestamp::now_ms(); + world_.update(current_time_ms); + } + + ping_timer_ += dt; + if (ping_timer_ >= 0.5f) { + send_ping(); + ping_timer_ = 0.0f; + } + + if (metrics_clock.getElapsedTime().asSeconds() >= 1.0f) { + update_metrics(); + metrics_clock.restart(); + } + + if (compensation_metrics_clock.getElapsedTime().asSeconds() >= 1.0f) { + update_compensation_metrics(); + compensation_metrics_clock.restart(); + } + + render(); + + if (auto_test_mode_) { + if (!bot_enabled_) { + bot_enabled_ = true; + bot_clock_.restart(); + std::cout << "Bot enabled after connection\n"; + phase = AutoTestPhase::StabilizeAlgo; + phase_timer = 0.0f; + current_algo_index = 0; + current_preset_index = 0; + + direct_set_algorithm(algorithms_to_test_[0]); + std::cout << "[AutoTest] Starting with algorithm: " << algorithm_name(algorithms_to_test_[0]) << "\n"; + } + + phase_timer += dt; + + if (phase == AutoTestPhase::StabilizeAlgo) { + if (phase_timer >= bot_config_.stabilization_time_sec) { + std::cout << "[AutoTest] Stabilization complete, starting test phase\n"; + phase = AutoTestPhase::TestPreset; + phase_timer = 0.0f; + } + } else if (phase == AutoTestPhase::TestPreset) { + if (phase_timer >= bot_config_.test_duration_per_preset_sec) { + current_preset_index++; + + if (current_preset_index >= network_presets_.size()) { + current_preset_index = 0; + current_algo_index++; + + if (current_algo_index >= algorithms_to_test_.size()) { + phase = AutoTestPhase::Finished; + std::cout << "AUTO TEST COMPLETED!\n"; + std::this_thread::sleep_for(std::chrono::seconds(5)); + window_.close(); + return; + } + + CompensationAlgorithm next_algo = algorithms_to_test_[current_algo_index]; + direct_set_algorithm(next_algo); + direct_set_preset(0); + std::cout << "[AutoTest] Switched to algorithm: " << algorithm_name(next_algo) << "\n"; + std::cout << "[AutoTest] Reset to preset: " << network_presets_[0].first << "\n"; + + phase = AutoTestPhase::StabilizeAlgo; + phase_timer = 0.0f; + } else { + direct_set_preset(current_preset_index); + std::cout << "[AutoTest] Switched to preset: " << network_presets_[current_preset_index].first << "\n"; + + phase = AutoTestPhase::StabilizeAlgo; + phase_timer = 0.0f; + } + } + } + } + + break; + + case ClientState::Disconnected: + render_disconnected(); + if (auto_test_mode_) { + window_.close(); + } + break; + } + + window_.display(); + } + } + + void handle_key_press(sf::Keyboard::Key key) { + if (state_ != ClientState::Connected) { + if (key == sf::Keyboard::Key::Escape) { + window_.close(); + } + return; + } + + switch (key) { + case sf::Keyboard::Key::Num1: + set_algorithm(CompensationAlgorithm::None); + break; + case sf::Keyboard::Key::Num2: + set_algorithm(CompensationAlgorithm::ClientPrediction); + break; + case sf::Keyboard::Key::Num3: + set_algorithm(CompensationAlgorithm::PredictionReconciliation); + break; + case sf::Keyboard::Key::Num4: + set_algorithm(CompensationAlgorithm::EntityInterpolation); + break; + case sf::Keyboard::Key::Num5: + set_algorithm(CompensationAlgorithm::DeadReckoning); + break; + case sf::Keyboard::Key::Num6: + set_algorithm(CompensationAlgorithm::Hybrid); + break; + case sf::Keyboard::Key::Num7: + set_algorithm(CompensationAlgorithm::ServerLagCompensation); + break; + + case sf::Keyboard::Key::F1: + show_server_positions_ = !show_server_positions_; + break; + case sf::Keyboard::Key::F2: + show_predicted_positions_ = !show_predicted_positions_; + break; + case sf::Keyboard::Key::F3: + show_interpolated_positions_ = !show_interpolated_positions_; + break; + case sf::Keyboard::Key::F4: + show_velocity_vectors_ = !show_velocity_vectors_; + break; + case sf::Keyboard::Key::F5: + show_debug_info_ = !show_debug_info_; + break; + + case sf::Keyboard::Key::Add: + interpolation_delay_ms_ = (std::min)(interpolation_delay_ms_ + 10.0f, 500.0f); + world_.set_interpolation_delay(interpolation_delay_ms_); + break; + case sf::Keyboard::Key::Subtract: + interpolation_delay_ms_ = (std::max)(interpolation_delay_ms_ - 10.0f, 0.0f); + world_.set_interpolation_delay(interpolation_delay_ms_); + break; + + case sf::Keyboard::Key::Escape: + window_.close(); + break; + + case sf::Keyboard::Key::N: + current_preset_index_ = (current_preset_index_ + 1) % network_presets_.size(); + network_sim_.update_config(network_presets_[current_preset_index_].second); + std::cout << "Network preset: " << network_presets_[current_preset_index_].first << "\n"; + break; + + case sf::Keyboard::Key::M: + if (current_preset_index_ == 0) { + current_preset_index_ = network_presets_.size() - 1; + } else { + current_preset_index_--; + } + network_sim_.update_config(network_presets_[current_preset_index_].second); + std::cout << "Network preset: " << network_presets_[current_preset_index_].first << "\n"; + break; + + case sf::Keyboard::Key::F6: + show_network_stats_ = !show_network_stats_; + break; + + case sf::Keyboard::Key::L: + network_sim_.trigger_lag_spike(1000.0f, 500.0f); + std::cout << "Lag spike triggered (1s duration, +500ms delay)\n"; + break; + + case sf::Keyboard::Key::PageUp: { + auto cfg = network_sim_.config(); + cfg.enabled = true; + cfg.base_latency_ms += 10.0f; + network_sim_.update_config(cfg); + std::cout << "Latency: " << cfg.base_latency_ms << "ms\n"; + break; + } + case sf::Keyboard::Key::PageDown: { + auto cfg = network_sim_.config(); + cfg.base_latency_ms = (std::max)(0.0f, cfg.base_latency_ms - 10.0f); + if (cfg.base_latency_ms == 0.0f) { + cfg.enabled = false; + } + network_sim_.update_config(cfg); + std::cout << "Latency: " << cfg.base_latency_ms << "ms\n"; + break; + } + + case sf::Keyboard::Key::Home: { + auto cfg = network_sim_.config(); + cfg.enabled = true; + cfg.jitter_ms += 5.0f; + network_sim_.update_config(cfg); + std::cout << "Jitter: " << cfg.jitter_ms << "ms\n"; + break; + } + case sf::Keyboard::Key::End: { + auto cfg = network_sim_.config(); + cfg.jitter_ms = (std::max)(0.0f, cfg.jitter_ms - 5.0f); + network_sim_.update_config(cfg); + std::cout << "Jitter: " << cfg.jitter_ms << "ms\n"; + break; + } + + case sf::Keyboard::Key::P: { + auto cfg = network_sim_.config(); + cfg.enabled = true; + cfg.packet_loss += 0.01f; + if (cfg.packet_loss > 0.2f) cfg.packet_loss = 0.0f; + network_sim_.update_config(cfg); + std::cout << "Packet loss: " << (cfg.packet_loss * 100.0f) << "%\n"; + break; + } + + default: + break; + } + } + + void handle_mouse_click(sf::Vector2i mouse_pos) { + if (state_ != ClientState::TeamSelection) return; + + if (red_team_button_.contains(sf::Vector2f(mouse_pos))) { + selected_team_ = TeamId::Red; + state_ = ClientState::Connecting; + if (!connect_to_server()) { + state_ = ClientState::Disconnected; + } + } else if (blue_team_button_.contains(sf::Vector2f(mouse_pos))) { + selected_team_ = TeamId::Blue; + state_ = ClientState::Connecting; + if (!connect_to_server()) { + state_ = ClientState::Disconnected; + } + } else if (auto_team_button_.contains(sf::Vector2f(mouse_pos))) { + selected_team_ = TeamId::None; // Авто-выбор + state_ = ClientState::Connecting; + if (!connect_to_server()) { + state_ = ClientState::Disconnected; + } + } + } + + void set_algorithm(CompensationAlgorithm algo) { + world_.set_algorithm(algo); + current_algorithm_ = algo; + + ClientSetAlgorithmMessage msg; + msg.algorithm = algo; + + WriteBuffer buf; + buf.write_u8(static_cast(MessageType::ClientSetAlgorithm)); + buf.write_u8(static_cast(algo)); + + network_.send(server_peer_, NetworkChannel::Reliable, buf.data(), buf.size(), true); + + std::cout << "Algorithm changed to: " << algorithm_name(algo) << "\n"; + } + + void send_connect_request() { + ClientConnectMessage msg; + msg.protocol_version = 1; + msg.team_preference = selected_team_; + std::strncpy(msg.player_name, player_name_.c_str(), sizeof(msg.player_name) - 1); + + WriteBuffer buf; + buf.write_u8(static_cast(MessageType::ClientConnect)); + buf.write_u32(msg.protocol_version); + buf.write_string(msg.player_name, sizeof(msg.player_name)); + buf.write_u8(static_cast(msg.team_preference)); + + if (network_sim_.is_enabled()) { + network_sim_.send_packet(buf.data(), buf.size()); + } else { + network_.send(server_peer_, NetworkChannel::Reliable, buf.data(), buf.size(), true); + } + } + + void process_network() { + send_delayed_packets(); + + ENetEvent event; + while (network_.service(event, 0) > 0) { + switch (event.type) { + case ENET_EVENT_TYPE_RECEIVE: + handle_packet(event.packet); + enet_packet_destroy(event.packet); + break; + + case ENET_EVENT_TYPE_DISCONNECT: + std::cout << "Disconnected from server\n"; + state_ = ClientState::Disconnected; + break; + + default: + break; + } + } + } + + + void handle_packet(ENetPacket* packet) { + if (packet->dataLength < 1) return; + + ReadBuffer buf(packet->data, packet->dataLength); + uint8_t type_byte; + if (!buf.read_u8(type_byte)) return; + + MessageType type = static_cast(type_byte); + + switch (type) { + case MessageType::ServerAccept: + handle_server_accept(buf); + break; + case MessageType::ServerReject: + handle_server_reject(buf); + break; + case MessageType::ServerSnapshot: + handle_snapshot(packet->data, packet->dataLength); + break; + case MessageType::ServerConfig: + handle_server_config(buf); + break; + case MessageType::PingResponse: + handle_ping_response(buf); + break; + default: + break; + } + } + + void handle_server_accept(ReadBuffer& buf) { + uint16_t client_id; + uint32_t entity_id; + uint8_t team; + uint32_t tick; + uint64_t server_time; + + if (!buf.read_u16(client_id)) return; + if (!buf.read_u32(entity_id)) return; + if (!buf.read_u8(team)) return; + if (!buf.read_u32(tick)) return; + if (!buf.read_u64(server_time)) return; + + local_client_id_ = client_id; + local_entity_id_ = entity_id; + local_team_ = static_cast(team); + + world_.set_local_player(local_client_id_, local_entity_id_); + state_ = ClientState::Connected; + + std::cout << "Connected! Client ID: " << client_id + << ", Entity ID: " << entity_id + << ", Team: " << (local_team_ == TeamId::Red ? "Red" : "Blue") << "\n"; + } + + void handle_server_reject(ReadBuffer& buf) { + uint8_t reason; + buf.read_u8(reason); + std::cerr << "Connection rejected. Reason: " << static_cast(reason) << "\n"; + state_ = ClientState::Disconnected; + } + + void handle_snapshot(const uint8_t* data, size_t size) { + ReadBuffer buf(data, size); + WorldSnapshot snapshot; + + if (deserialize_snapshot(buf, snapshot)) { + world_.receive_snapshot(snapshot); + last_server_tick_ = snapshot.server_tick; + snapshots_received_++; + } + } + + void handle_server_config(ReadBuffer& buf) { + SimulationParams new_params = DEFAULT_SIMULATION_PARAMS; + + if (!buf.read_u32(new_params.tick_rate)) return; + if (!buf.read_u32(new_params.snapshot_rate)) return; + if (!buf.read_float(new_params.arena_width)) return; + if (!buf.read_float(new_params.arena_height)) return; + if (!buf.read_float(new_params.player_radius)) return; + if (!buf.read_float(new_params.player_max_speed)) return; + if (!buf.read_float(new_params.ball_radius)) return; + + new_params.fixed_delta_time = 1.0 / static_cast(new_params.tick_rate); + world_.set_simulation_params(new_params); + + + std::cout << "Server config: tick_rate=" << new_params.tick_rate + << ", snapshot_rate=" << new_params.snapshot_rate << "\n"; + } + + void handle_ping_response(ReadBuffer& buf) { + uint32_t ping_id; + uint64_t client_time, server_time; + uint32_t server_tick; + + if (!buf.read_u32(ping_id)) return; + if (!buf.read_u64(client_time)) return; + if (!buf.read_u64(server_time)) return; + if (!buf.read_u32(server_tick)) return; + + uint64_t now = Timestamp::now_us(); + time_sync_.add_sample(client_time, server_time, now); + + current_rtt_ms_ = time_sync_.get_rtt_ms(); + current_jitter_ms_ = time_sync_.get_jitter_ms(); + } + + void process_input() { + InputState input; + + if (auto_test_mode_ && bot_enabled_) { + input = get_bot_input(); + } else { + input.move_up = sf::Keyboard::isKeyPressed(sf::Keyboard::Key::W); + input.move_down = sf::Keyboard::isKeyPressed(sf::Keyboard::Key::S); + input.move_left = sf::Keyboard::isKeyPressed(sf::Keyboard::Key::A); + input.move_right = sf::Keyboard::isKeyPressed(sf::Keyboard::Key::D); + } + + InputCommand cmd; + cmd.sequence = ++input_sequence_; + cmd.client_tick = client_tick_++; + cmd.timestamp_us = Timestamp::now_us(); + cmd.input = input; + + world_.process_local_input(cmd); + send_input(cmd); + recent_inputs_.push(cmd); + } + + void send_input(const InputCommand& cmd) { + WriteBuffer buf; + buf.write_u8(static_cast(MessageType::ClientInput)); + buf.write_u8(1); + + buf.write_u32(cmd.sequence); + buf.write_u32(cmd.client_tick); + buf.write_u64(cmd.timestamp_us); + buf.write_u8(cmd.input.to_byte()); + + if (network_sim_.is_enabled()) { + network_sim_.send_packet(buf.data(), buf.size()); + } else { + network_.send(server_peer_, NetworkChannel::Unreliable, buf.data(), buf.size(), false); + } + + inputs_sent_++; + } + + + void send_ping() { + WriteBuffer buf; + buf.write_u8(static_cast(MessageType::PingRequest)); + buf.write_u32(++ping_id_); + buf.write_u64(Timestamp::now_us()); + + if (network_sim_.is_enabled()) { + network_sim_.send_packet(buf.data(), buf.size()); + } else { + network_.send(server_peer_, NetworkChannel::Unreliable, buf.data(), buf.size(), false); + } + } + + void update_metrics() { + fps_ = static_cast(frame_count_); + frame_count_ = 0; + inputs_per_second_ = inputs_sent_; + inputs_sent_ = 0; + snapshots_per_second_ = snapshots_received_; + snapshots_received_ = 0; + + uint64_t now_ms = Timestamp::now_ms(); + + if (state_ == ClientState::Connected && now_ms - last_log_time_ms_ >= 1000) { + auto pos_metrics = world_.metrics().get_position_metrics(); + auto comp_metrics = world_.metrics().get_compensation_metrics(1.0); // за последнюю секунду + + auto cfg = network_sim_.config(); + auto sim_stats = network_sim_.get_stats(); + + uint64_t packets_sent_ps = sim_stats.packets_sent - prev_packets_sent_; + uint64_t packets_delivered_ps = sim_stats.packets_delivered - prev_packets_delivered_; + uint64_t packets_lost_ps = sim_stats.packets_lost - prev_packets_lost_; + uint64_t packets_duplicated_ps = sim_stats.packets_duplicated - prev_packets_duplicated_; + + prev_packets_sent_ = sim_stats.packets_sent; + prev_packets_delivered_ = sim_stats.packets_delivered; + prev_packets_lost_ = sim_stats.packets_lost; + prev_packets_duplicated_ = sim_stats.packets_duplicated; + + logger_.log( + now_ms, + network_presets_[current_preset_index_].first, + algorithm_name(current_algorithm_), + current_rtt_ms_, + current_jitter_ms_, + cfg.packet_loss * 100.0f, + packets_sent_ps, + packets_delivered_ps, + packets_lost_ps, + packets_duplicated_ps, + fps_, + inputs_per_second_, + snapshots_per_second_, + pos_metrics, + comp_metrics + ); + + world_.metrics().reset_compensation_metrics(); + world_.metrics().reset(); + + last_log_time_ms_ = now_ms; + } + } + + void render_team_selection() { + frame_count_++; + window_.clear(sf::Color(20, 20, 30)); + + sf::Text title(font_, "SELECT YOUR TEAM", 48); + title.setPosition(sf::Vector2f(window_.getSize().x / 2 - 250.0f, 100.0f)); + title.setFillColor(sf::Color::White); + window_.draw(title); + + float button_width = 300.0f; + float button_height = 150.0f; + float center_x = window_.getSize().x / 2; + float center_y = window_.getSize().y / 2; + + float red_x = center_x - button_width - 50.0f; + float red_y = center_y - button_height / 2.0f; + red_team_button_.position = { red_x, red_y }; + red_team_button_.size = { button_width, button_height }; + + sf::RectangleShape red_button(sf::Vector2f(button_width, button_height)); + red_button.setPosition(sf::Vector2f(red_x, red_y)); + red_button.setFillColor(sf::Color(150, 30, 30)); + red_button.setOutlineColor(sf::Color(255, 100, 100)); + red_button.setOutlineThickness(3.0f); + + sf::Vector2i mouse_pos_i = sf::Mouse::getPosition(window_); + sf::Vector2f mouse_pos_f(static_cast(mouse_pos_i.x), static_cast(mouse_pos_i.y)); + if (red_team_button_.contains(mouse_pos_f)) { + red_button.setFillColor(sf::Color(200, 50, 50)); + } + window_.draw(red_button); + + sf::Text red_text(font_, "RED TEAM", 32); + red_text.setPosition(sf::Vector2f(red_x + 50.0f, red_y + 60.0f)); + red_text.setFillColor(sf::Color::White); + window_.draw(red_text); + + float blue_x = center_x + 50.0f; + float blue_y = center_y - button_height / 2.0f; + blue_team_button_.position = { blue_x, blue_y }; + blue_team_button_.size = { button_width, button_height }; + sf::RectangleShape blue_button(sf::Vector2f(button_width, button_height)); + blue_button.setPosition(sf::Vector2f(blue_x, blue_y)); + blue_button.setFillColor(sf::Color(30, 30, 150)); + blue_button.setOutlineColor(sf::Color(100, 100, 255)); + blue_button.setOutlineThickness(3.0f); + + if (blue_team_button_.contains(mouse_pos_f)) { + blue_button.setFillColor(sf::Color(50, 50, 200)); + } + window_.draw(blue_button); + + sf::Text blue_text(font_, "BLUE TEAM", 32); + blue_text.setPosition(sf::Vector2f(blue_x + 40.0f, blue_y + 60.0f)); + blue_text.setFillColor(sf::Color::White); + window_.draw(blue_text); + + float auto_x = center_x - button_width / 2.0f; + float auto_y = center_y + button_height; + auto_team_button_.position = { auto_x, auto_y }; + auto_team_button_.size = { button_width, button_height * 0.6f }; + sf::RectangleShape auto_button(sf::Vector2f(button_width, button_height * 0.6f)); + auto_button.setPosition(sf::Vector2f(auto_x, auto_y)); + auto_button.setFillColor(sf::Color(60, 60, 60)); + auto_button.setOutlineColor(sf::Color(150, 150, 150)); + auto_button.setOutlineThickness(3.0f); + + if (auto_team_button_.contains(mouse_pos_f)) { + auto_button.setFillColor(sf::Color(80, 80, 80)); + } + window_.draw(auto_button); + + sf::Text auto_text(font_, "AUTO BALANCE", 24); + auto_text.setPosition(sf::Vector2f(auto_x + 60.0f, auto_y + 30.0f)); + auto_text.setFillColor(sf::Color::White); + window_.draw(auto_text); + + sf::Text instruction(font_, "Click to select your team", 20); + instruction.setPosition(sf::Vector2f(center_x - 120.0f, window_.getSize().y - 100.0f)); + instruction.setFillColor(sf::Color(150, 150, 150)); + window_.draw(instruction); + } + + + void render_connecting() { + frame_count_++; + window_.clear(sf::Color(20, 20, 30)); + + sf::Text text(font_, "Connecting to server...", 36); + text.setPosition(sf::Vector2f(window_.getSize().x / 2 - 200.0f, + window_.getSize().y / 2)); + text.setFillColor(sf::Color::White); + window_.draw(text); + } + + void render_disconnected() { + frame_count_++; + window_.clear(sf::Color(20, 20, 30)); + + sf::Text text(font_, "Disconnected from server", 36); + text.setPosition(sf::Vector2f(window_.getSize().x / 2 - 230.0f, + window_.getSize().y / 2 - 50.0f)); + text.setFillColor(sf::Color::Red); + window_.draw(text); + + sf::Text instruction(font_, "Press ESC to exit", 24); + instruction.setPosition(sf::Vector2f(window_.getSize().x / 2 - 100.0f, + window_.getSize().y / 2 + 50.0f)); + instruction.setFillColor(sf::Color(150, 150, 150)); + window_.draw(instruction); + } + + void render() { + frame_count_++; + window_.clear(sf::Color(30, 30, 40)); + + const auto& params = world_.params(); + + float arena_display_width = netcode::DEFAULT_SIMULATION_PARAMS.arena_width; + float arena_display_height = netcode::DEFAULT_SIMULATION_PARAMS.arena_height; + float scale_x = arena_display_width / params.arena_width; + float scale_y = arena_display_height / params.arena_height; + float scale = (std::min)(scale_x, scale_y); + float offset_x = 60.0f; + float offset_y = 20.0f; + + auto to_screen = [&](const Vec2& pos) -> sf::Vector2f { + return sf::Vector2f(offset_x + pos.x * scale, offset_y + pos.y * scale); + }; + + // Арена + sf::RectangleShape arena(sf::Vector2f(params.arena_width * scale, params.arena_height * scale)); + arena.setPosition(sf::Vector2f(offset_x, offset_y)); + arena.setFillColor(sf::Color(50, 50, 60)); + arena.setOutlineColor(sf::Color::White); + arena.setOutlineThickness(2.0f); + window_.draw(arena); + + // Зоны команд + sf::RectangleShape red_zone(sf::Vector2f(params.red_zone.width * scale, params.red_zone.height * scale)); + red_zone.setPosition(to_screen(Vec2(params.red_zone.x, params.red_zone.y))); + red_zone.setFillColor(sf::Color(255, 0, 0, 50)); + red_zone.setOutlineColor(sf::Color(255, 0, 0, 150)); + red_zone.setOutlineThickness(2.0f); + window_.draw(red_zone); + + sf::RectangleShape blue_zone(sf::Vector2f(params.blue_zone.width * scale, params.blue_zone.height * scale)); + blue_zone.setPosition(to_screen(Vec2(params.blue_zone.x, params.blue_zone.y))); + blue_zone.setFillColor(sf::Color(0, 0, 255, 50)); + blue_zone.setOutlineColor(sf::Color(0, 0, 255, 150)); + blue_zone.setOutlineThickness(2.0f); + window_.draw(blue_zone); + + // Сущности + for (const auto& [id, entity] : world_.entities()) { + render_entity(entity, scale, offset_x, offset_y); + } + + // UI + render_ui(); + } + + + void render_entity(const ClientEntity& entity, float scale, float offset_x, float offset_y) { + auto to_screen = [&](const Vec2& pos) -> sf::Vector2f { + return sf::Vector2f(offset_x + pos.x * scale, offset_y + pos.y * scale); + }; + + float radius = entity.radius * scale; + + // Серверная позиция (полупрозрачная) + if (show_server_positions_) { + sf::CircleShape server_circle(radius); + server_circle.setOrigin(sf::Vector2f(radius, radius)); + server_circle.setPosition(to_screen(entity.server_position)); + server_circle.setFillColor(sf::Color(100, 100, 100, 80)); + server_circle.setOutlineColor(sf::Color(150, 150, 150, 150)); + server_circle.setOutlineThickness(1.0f); + window_.draw(server_circle); + } + + // Предсказанная позиция + if (show_predicted_positions_ && entity.is_local_player) { + sf::CircleShape pred_circle(radius); + pred_circle.setOrigin(sf::Vector2f(radius, radius)); + pred_circle.setPosition(to_screen(entity.predicted_position)); + pred_circle.setFillColor(sf::Color(0, 255, 0, 60)); + pred_circle.setOutlineColor(sf::Color(0, 255, 0, 200)); + pred_circle.setOutlineThickness(1.0f); + window_.draw(pred_circle); + } + + // Интерполированная позиция + if (show_interpolated_positions_ && !entity.is_local_player) { + sf::CircleShape interp_circle(radius); + interp_circle.setOrigin(sf::Vector2f(radius, radius)); + interp_circle.setPosition(to_screen(entity.interpolated_position)); + interp_circle.setFillColor(sf::Color(255, 255, 0, 60)); + interp_circle.setOutlineColor(sf::Color(255, 255, 0, 200)); + interp_circle.setOutlineThickness(1.0f); + window_.draw(interp_circle); + } + + // рендер позиция + sf::CircleShape main_circle(radius); + main_circle.setOrigin(sf::Vector2f(radius, radius)); + main_circle.setPosition(to_screen(entity.render_position)); + + if (entity.type == EntityType::Player) { + if (entity.is_local_player) { + main_circle.setFillColor(sf::Color(0, 200, 0)); + main_circle.setOutlineColor(sf::Color::White); + } else if (entity.team == TeamId::Red) { + main_circle.setFillColor(sf::Color(200, 50, 50)); + main_circle.setOutlineColor(sf::Color(255, 100, 100)); + } else { + main_circle.setFillColor(sf::Color(50, 50, 200)); + main_circle.setOutlineColor(sf::Color(100, 100, 255)); + } + } else if (entity.type == EntityType::Ball) { + main_circle.setFillColor(sf::Color(255, 200, 50)); + main_circle.setOutlineColor(sf::Color::White); + } + + main_circle.setOutlineThickness(2.0f); + window_.draw(main_circle); + + // Вектор скорости + if (show_velocity_vectors_) { + Vec2 vel_end = entity.render_position + entity.server_velocity * 0.1f; + sf::Vertex line[] = { + sf::Vertex(to_screen(entity.render_position), sf::Color::Yellow), + sf::Vertex(to_screen(vel_end), sf::Color::Red) + }; + window_.draw(line, 2, sf::PrimitiveType::Lines); + } + + // Линия коррекции + if (entity.is_local_player && entity.correction_offset.length_squared() > 0.1f) { + Vec2 correction_end = entity.render_position - entity.correction_offset; + sf::Vertex line[] = { + sf::Vertex(to_screen(entity.render_position), sf::Color::Magenta), + sf::Vertex(to_screen(correction_end), sf::Color::Cyan) + }; + window_.draw(line, 2, sf::PrimitiveType::Lines); + } + } + void update_compensation_metrics() { + world_.metrics().reset_compensation_metrics(); + } + void render_ui() { + float y = 10.0f; + float line_height = 20.0f; + + auto draw_text = [&](const std::string& str, sf::Color color = sf::Color::White) { + sf::Text text(font_, str, 14); + text.setPosition(sf::Vector2f(10.0f, y)); + text.setFillColor(color); + window_.draw(text); + y += line_height; + }; + + // Команда + std::string team_str = (local_team_ == TeamId::Red) ? "RED" : + (local_team_ == TeamId::Blue) ? "BLUE" : "NONE"; + sf::Color team_color = (local_team_ == TeamId::Red) ? sf::Color(255, 100, 100) : + sf::Color(100, 100, 255); + draw_text(std::format("Team: {}", team_str), team_color); + + // Алгоритм + draw_text(std::format("Algorithm: {} [1-7 to change]", algorithm_name(current_algorithm_)), + sf::Color::Yellow); + + // Сеть + draw_text(std::format("RTT: {:.1f} ms | Jitter: {:.1f} ms", current_rtt_ms_, current_jitter_ms_)); + draw_text(std::format("Server Tick: {} | FPS: {:.0f}", last_server_tick_, fps_)); + draw_text(std::format("Inputs/s: {} | Snapshots/s: {}", inputs_per_second_, snapshots_per_second_)); + + draw_text(std::format("Interp Delay: {:.0f} ms [+/- to adjust] (Auto: {:.0f} ms)", + interpolation_delay_ms_, + current_rtt_ms_ / 2.0 + current_jitter_ms_ * 2.0)); + + // Счёт + y = 30.0f; + sf::Text score_text(font_, std::format("Red: {:.1f}s | Blue: {:.1f}s", + world_.red_team_time(), world_.blue_team_time()), 18); + score_text.setPosition(sf::Vector2f(window_.getSize().x - 260.0f, y)); + score_text.setFillColor(sf::Color::White); + window_.draw(score_text); + + if (show_debug_info_) { + y = 150.0f; + auto pos_metrics = world_.metrics().get_position_metrics(); + draw_text(std::format("Position MAE: {:.2f} | MSE: {:.2f}", pos_metrics.mae, pos_metrics.mse)); + draw_text(std::format("Max Error: {:.2f} | Samples: {}", pos_metrics.max_error, pos_metrics.sample_count)); + + auto comp_metrics = world_.metrics().get_compensation_metrics(1.0); + draw_text(std::format("Predictions/s: {} | Reconciliations/s: {}", + comp_metrics.predictions_per_second, comp_metrics.reconciliations_per_second)); + draw_text(std::format("Avg Correction: {:.2f} | Max: {:.2f}", + comp_metrics.avg_correction_distance, comp_metrics.max_correction_distance)); + } + if (show_network_stats_ && network_sim_.is_enabled()) { + y = 310.0f; + + auto sim_stats = network_sim_.get_stats(); + auto cfg = network_sim_.config(); + + draw_text("Network Simulation", sf::Color::Cyan); + draw_text(std::format("Preset: {}", network_presets_[current_preset_index_].first), + sf::Color::Yellow); + draw_text(std::format("Base Latency: {:.1f}ms (RTT: {:.1f}ms)", + cfg.base_latency_ms, cfg.base_latency_ms * 2.0f)); + draw_text(std::format("Jitter: {:.1f}ms", cfg.jitter_ms)); + draw_text(std::format("Packet Loss: {:.1f}%", cfg.packet_loss * 100.0f)); + + if (cfg.packet_duplication > 0.0f) { + draw_text(std::format("Duplication: {:.1f}%", cfg.packet_duplication * 100.0f)); + } + + draw_text(std::format("Sent: {} | Delivered: {} | Lost: {}", + sim_stats.packets_sent, sim_stats.packets_delivered, sim_stats.packets_lost)); + + if (sim_stats.packets_duplicated > 0) { + draw_text(std::format("Duplicated: {}", sim_stats.packets_duplicated)); + } + + draw_text(std::format("Queue: {} packets", sim_stats.packets_in_queue)); + } + else { + y = 300; + draw_text("No simulation", sf::Color::Yellow); + } + + y = window_.getSize().y - 120.0f; + draw_text("F1-F5: Visualization | F6: Network Stats", sf::Color(150, 150, 150)); + draw_text("N/M: Network Preset | L: Lag Spike | P: Packet Loss", sf::Color(150, 150, 150)); + draw_text("PgUp/PgDn: Latency | Home/End: Jitter", sf::Color(150, 150, 150)); + draw_text("WASD: Move | 1-7: Algorithm | ESC: Quit", sf::Color(150, 150, 150)); + } + +private: + // Окно и рендер + sf::RenderWindow window_; + sf::Font font_; + + // Состояние + ClientState state_; + std::string host_; + uint16_t port_; + + // Выбор команды + TeamId selected_team_ = TeamId::None; + sf::FloatRect red_team_button_; + sf::FloatRect blue_team_button_; + sf::FloatRect auto_team_button_; + + // Сеть + ENetInitializer enet_init_; + NetworkHost network_; + ENetPeer* server_peer_ = nullptr; + TimeSynchronizer time_sync_; + + NetworkSimulator network_sim_; + bool show_network_stats_ = true; + + std::vector> network_presets_; + size_t current_preset_index_ = 0; + + // Мир + ClientWorld world_; + + // Состояние подключения + ClientId local_client_id_ = INVALID_CLIENT_ID; + EntityId local_entity_id_ = INVALID_ENTITY_ID; + TeamId local_team_ = TeamId::None; + std::string player_name_ = "Player"; + + // Ввод + SequenceNumber input_sequence_ = 0; + TickNumber client_tick_ = 0; + RingBuffer recent_inputs_; + + // Алгоритм + CompensationAlgorithm current_algorithm_ = CompensationAlgorithm::Hybrid; + float interpolation_delay_ms_ = 100.0f; + + // Визуализация + bool show_server_positions_ = true; + bool show_predicted_positions_ = true; + bool show_interpolated_positions_ = true; + bool show_velocity_vectors_ = false; + bool show_debug_info_ = true; + + // Метрики + float ping_timer_ = 0.0f; + uint32_t ping_id_ = 0; + double current_rtt_ms_ = 0.0; + double current_jitter_ms_ = 0.0; + TickNumber last_server_tick_ = 0; + MetricsLogger logger_{"client_metrics.csv"}; + uint64_t last_log_time_ms_ = 0; + + uint64_t prev_packets_sent_ = 0; + uint64_t prev_packets_delivered_ = 0; + uint64_t prev_packets_lost_ = 0; + uint64_t prev_packets_duplicated_ = 0; + + float fps_ = 0.0f; + uint32_t frame_count_ = 0; + uint32_t inputs_sent_ = 0; + uint32_t inputs_per_second_ = 0; + uint32_t snapshots_received_ = 0; + uint32_t snapshots_per_second_ = 0; + + // Тесты + bool auto_test_mode_ = false; + bool bot_enabled_ = false; + + struct BotConfig { + float test_duration_per_preset_sec = 10.0f; + float stabilization_time_sec = 10.0f; + float movement_change_interval_sec = 4.0f; + float speed_factor = 1.0f; + bool random_movements = true; + float circle_radius = 15.0f; + float circle_speed = 2.0f; + }; + + BotConfig bot_config_; + + float bot_timer_ = 0.0f; + float direction_timer_ = 0.0f; + Vec2 current_bot_direction_ = {0.0f, 0.0f}; + float circle_angle_ = 0.0f; + + std::vector algorithms_to_test_ = { + + CompensationAlgorithm::ClientPrediction, + CompensationAlgorithm::PredictionReconciliation, + CompensationAlgorithm::EntityInterpolation, + CompensationAlgorithm::DeadReckoning, + CompensationAlgorithm::Hybrid, + }; +}; + +int main(int argc, char* argv[]) { + std::string host = "::1"; + uint16_t port = 7777; + + bool auto_test = false; + for (int i = 1; i < argc; ++i) { + std::string arg = argv[i]; + if (arg == "--auto-test") { + auto_test = true; + } else if (arg == "-h" || arg == "--host") { + if (i + 1 < argc) host = argv[++i]; + } else if (arg == "-p" || arg == "--port") { + if (i + 1 < argc) port = static_cast(std::stoi(argv[++i])); + } else if (arg == "--help") { + std::cout << "Usage: client [-h host] [-p port]\n"; + return 0; + } + } + + std::cout << "Will connect to " << host << ":" << port << "...\n"; + + try { + GameClient client; + if (auto_test) { + client.enable_auto_test(); + } + + if (client.connect(host, port)) { + client.run(); + } + } catch (const std::exception& e) { + std::cerr << "Error: " << e.what() << "\n"; + return 1; + } + + return 0; +} \ No newline at end of file diff --git a/common/include/common/common.hpp b/common/include/common/common.hpp new file mode 100644 index 0000000..c9ccf04 --- /dev/null +++ b/common/include/common/common.hpp @@ -0,0 +1,5 @@ +#ifndef NETCODE_DEMO_COMMON_ENTRY_HPP +#define NETCODE_DEMO_COMMON_ENTRY_HPP + + +#endif //NETCODE_DEMO_COMMON_ENTRY_HPP \ No newline at end of file diff --git a/common/include/common/compensation_algorithm.hpp b/common/include/common/compensation_algorithm.hpp new file mode 100644 index 0000000..7199e2c --- /dev/null +++ b/common/include/common/compensation_algorithm.hpp @@ -0,0 +1,59 @@ +#pragma once + +#include +#include +#include + +namespace netcode { + + enum class CompensationAlgorithm : uint8_t { + None = 0, // Без компенсации + ClientPrediction, // Client-Side Prediction + PredictionReconciliation, // Prediction + Reconciliation + EntityInterpolation, // Entity Interpolation + DeadReckoning, // Dead Reckoning / Экстраполяция + Hybrid, // Prediction для локального, интерполяция для остальных + ServerLagCompensation, // Server-Side Lag Compensation + + COUNT + }; + + inline constexpr std::array(CompensationAlgorithm::COUNT)> + ALGORITHM_NAMES = { + "None", + "Client Prediction", + "Prediction + Reconciliation", + "Entity Interpolation", + "Dead Reckoning", + "Hybrid", + "Server Lag Compensation" + }; + + inline const char* algorithm_name(CompensationAlgorithm algo) { + auto idx = static_cast(algo); + if (idx < ALGORITHM_NAMES.size()) { + return ALGORITHM_NAMES[idx]; + } + return "Unknown"; + } + + // Настройки алгоритмов + struct AlgorithmSettings { + // Интерполяция + float interpolation_delay_ms = 100.0f; // Задержка интерполяции + + // Prediction + uint32_t max_prediction_ticks = 30; // Максимум тиков предсказания + + // Reconciliation + float correction_smoothing = 0.1f; // Сглаживание коррекции (0-1) + float correction_threshold = 0.5f; // Порог для мгновенной коррекции + + // Dead Reckoning + float extrapolation_limit_ms = 250.0f; // Лимит экстраполяции + + // Server Lag Compensation + float max_rewind_ms = 200.0f; // Максимум отката на сервере + }; + +} // namespace netcode \ No newline at end of file diff --git a/common/include/common/enet_wrapper.hpp b/common/include/common/enet_wrapper.hpp new file mode 100644 index 0000000..668df9e --- /dev/null +++ b/common/include/common/enet_wrapper.hpp @@ -0,0 +1,164 @@ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include "types.hpp" +#include "network_channel.hpp" +#include + +namespace netcode { + +// инициализация +class ENetInitializer { +public: + ENetInitializer() { + if (enet_initialize() != 0) { + throw std::runtime_error("Failed to initialize ENet"); + } + } + + ~ENetInitializer() { + enet_deinitialize(); + } + + ENetInitializer(const ENetInitializer&) = delete; + ENetInitializer& operator=(const ENetInitializer&) = delete; +}; + +// Настройки симуляции сети +struct NetworkConditions { + uint32_t min_latency_ms = 0; + uint32_t max_latency_ms = 0; + float packet_loss_percent = 0.0f; + float duplicate_percent = 0.0f; + + bool is_enabled() const { + return min_latency_ms > 0 || max_latency_ms > 0 || + packet_loss_percent > 0 || duplicate_percent > 0; + } +}; + +// хост враппер +class NetworkHost { +public: + NetworkHost() = default; + ~NetworkHost() { + if (host_) { + enet_host_destroy(host_); + } + } + + bool create_server(uint16_t port, size_t max_clients) { + ENetAddress address; + std::memset(&address, 0, sizeof(address)); + + // address.host = ENET_HOST_ANY; + address.port = port; + + host_ = enet_host_create(&address, max_clients, CHANNEL_COUNT, 0, 0); + + if (!host_) { +#ifdef _WIN32 + std::cerr << "enet_host_create failed, WSA error: " << WSAGetLastError() << "\n"; +#else + std::cerr << "enet_host_create failed, errno: " << errno << "\n"; +#endif + return false; + } + + return true; + } + + bool create_client() { + host_ = enet_host_create(nullptr, 1, CHANNEL_COUNT, 0, 0); + if (!host_) { +#ifdef _WIN32 + std::cerr << "create_client failed, WSA error: " << WSAGetLastError() << "\n"; +#endif + return false; + } + return true; + } + + ENetPeer* connect(const std::string& hostname, uint16_t port) { + ENetAddress address; + std::memset(&address, 0, sizeof(address)); + + int result = enet_address_set_host_ip(&address, hostname.c_str()); + + if (result != 0) { + std::cout << "Trying DNS lookup for: " << hostname << "\n"; + result = enet_address_set_host(&address, hostname.c_str()); + } + + if (result != 0) { + std::cerr << "Failed to resolve address: " << hostname << "\n"; +#ifdef _WIN32 + std::cerr << "WSA error: " << WSAGetLastError() << "\n"; +#endif + return nullptr; + } + + address.port = port; + + std::cout << "Resolved address - host: "; + + char ipv6_str[INET6_ADDRSTRLEN]; + inet_ntop(AF_INET6, &address.host, ipv6_str, INET6_ADDRSTRLEN); + std::cout << ipv6_str; + std::cout << ", port: " << address.port << "\n"; + + ENetPeer* peer = enet_host_connect(host_, &address, CHANNEL_COUNT, 0); + if (!peer) { + std::cerr << "enet_host_connect failed\n"; +#ifdef _WIN32 + std::cerr << "WSA error: " << WSAGetLastError() << "\n"; +#endif + } + + return peer; + } + + // Обработка событий + int service(ENetEvent& event, uint32_t timeout_ms = 0) { + return enet_host_service(host_, &event, timeout_ms); + } + + // Отправка + void send(ENetPeer* peer, NetworkChannel channel, const void* data, size_t size, bool reliable) { + uint32_t flags = reliable ? ENET_PACKET_FLAG_RELIABLE : 0; + ENetPacket* packet = enet_packet_create(data, size, flags); + enet_peer_send(peer, static_cast(channel), packet); + } + + void broadcast(NetworkChannel channel, const void* data, size_t size, bool reliable) { + uint32_t flags = reliable ? ENET_PACKET_FLAG_RELIABLE : 0; + ENetPacket* packet = enet_packet_create(data, size, flags); + enet_host_broadcast(host_, static_cast(channel), packet); + } + + void flush() { + enet_host_flush(host_); + } + + // Симуляция сетевых условий (применяется к peer) + void apply_conditions(ENetPeer* peer, const NetworkConditions& conditions) { + if (conditions.is_enabled()) { + // реализовано через троттлинг + } + } + + ENetHost* raw() { return host_; } + const ENetHost* raw() const { return host_; } + +private: + ENetHost* host_ = nullptr; +}; + +} // namespace netcode \ No newline at end of file diff --git a/common/include/common/input_command.hpp b/common/include/common/input_command.hpp new file mode 100644 index 0000000..548ccde --- /dev/null +++ b/common/include/common/input_command.hpp @@ -0,0 +1,64 @@ +#pragma once + +#include "types.hpp" +#include "math_types.hpp" +#include + +namespace netcode { + + // Состояние ввода + struct InputState { + bool move_up = false; + bool move_down = false; + bool move_left = false; + bool move_right = false; + + Vec2 get_direction() const { + Vec2 dir; + if (move_up) dir.y -= 1.0f; + if (move_down) dir.y += 1.0f; + if (move_left) dir.x -= 1.0f; + if (move_right) dir.x += 1.0f; + + if (dir.length_squared() > 0.0f) { + dir = dir.normalized(); + } + return dir; + } + + bool any_input() const { + return move_up || move_down || move_left || move_right; + } + + uint8_t to_byte() const { + uint8_t result = 0; + if (move_up) result |= 0x01; + if (move_down) result |= 0x02; + if (move_left) result |= 0x04; + if (move_right) result |= 0x08; + return result; + } + + static InputState from_byte(uint8_t byte) { + InputState state; + state.move_up = (byte & 0x01) != 0; + state.move_down = (byte & 0x02) != 0; + state.move_left = (byte & 0x04) != 0; + state.move_right = (byte & 0x08) != 0; + return state; + } + }; + + // Команда клиента + struct InputCommand { + SequenceNumber sequence = 0; // Порядковый номер команды + TickNumber client_tick = 0; // Тик клиента + uint64_t timestamp_us = 0; // Временная метка (микросекунды) + InputState input; // Состояние ввода + + // Для reconciliation + Vec2 predicted_position; // Предсказанная позиция после команды + Vec2 predicted_velocity; // Предсказанная скорость + }; + +} // namespace netcode \ No newline at end of file diff --git a/common/include/common/math_types.hpp b/common/include/common/math_types.hpp new file mode 100644 index 0000000..9edf524 --- /dev/null +++ b/common/include/common/math_types.hpp @@ -0,0 +1,76 @@ +#pragma once + +#include +#include + +namespace netcode { + +struct Vec2 { + float x = 0.0f; + float y = 0.0f; + + Vec2() = default; + Vec2(float x_, float y_) : x(x_), y(y_) {} + + Vec2 operator+(const Vec2& other) const { return {x + other.x, y + other.y}; } + Vec2 operator-(const Vec2& other) const { return {x - other.x, y - other.y}; } + Vec2 operator*(float scalar) const { return {x * scalar, y * scalar}; } + Vec2 operator/(float scalar) const { return {x / scalar, y / scalar}; } + + Vec2& operator+=(const Vec2& other) { x += other.x; y += other.y; return *this; } + Vec2& operator-=(const Vec2& other) { x -= other.x; y -= other.y; return *this; } + Vec2& operator*=(float scalar) { x *= scalar; y *= scalar; return *this; } + + float length() const { return std::sqrt(x * x + y * y); } + float length_squared() const { return x * x + y * y; } + + Vec2 normalized() const { + float len = length(); + if (len > 0.0001f) return *this / len; + return {0, 0}; + } + + static float dot(const Vec2& a, const Vec2& b) { + return a.x * b.x + a.y * b.y; + } + + static float distance(const Vec2& a, const Vec2& b) { + return (a - b).length(); + } + + static Vec2 lerp(const Vec2& a, const Vec2& b, float t) { + return a + (b - a) * t; + } +}; + +struct Rect { + float x = 0.0f; + float y = 0.0f; + float width = 0.0f; + float height = 0.0f; + + bool contains(const Vec2& point) const { + return point.x >= x && point.x <= x + width && + point.y >= y && point.y <= y + height; + } + + Vec2 center() const { + return {x + width / 2.0f, y + height / 2.0f}; + } +}; + +struct Circle { + Vec2 center; + float radius = 0.0f; + + bool contains(const Vec2& point) const { + return Vec2::distance(center, point) <= radius; + } + + bool intersects(const Circle& other) const { + float dist = Vec2::distance(center, other.center); + return dist <= (radius + other.radius); + } +}; + +} // namespace netcode \ No newline at end of file diff --git a/common/include/common/metrics.hpp b/common/include/common/metrics.hpp new file mode 100644 index 0000000..54d899c --- /dev/null +++ b/common/include/common/metrics.hpp @@ -0,0 +1,204 @@ +#pragma once +#include "types.hpp" +#include "ring_buffer.hpp" +#include +#include +#include +#include +#include + +namespace netcode { + +// Метрики позиции +struct PositionMetrics { + double mae = 0.0; // Mean Absolute Error + double mse = 0.0; // Mean Squared Error + double max_error = 0.0; // Максимальная ошибка + uint32_t sample_count = 0; +}; + +// Метрики сети +struct NetworkMetrics { + double rtt_ms = 0.0; + double jitter_ms = 0.0; + double packet_loss_percent = 0.0; + uint64_t bytes_sent = 0; + uint64_t bytes_received = 0; + uint64_t packets_sent = 0; + uint64_t packets_received = 0; +}; + +// Метрики алгоритмов компенсации +struct CompensationMetrics { + uint32_t predictions_per_second = 0; + uint32_t reconciliations_per_second = 0; + double avg_correction_distance = 0.0; + double max_correction_distance = 0.0; + uint32_t rollbacks_count = 0; + double input_delay_ms = 0.0; +}; + +// Накопитель метрик +class MetricsCollector { +public: + void add_position_error(float predicted_x, float predicted_y, + float actual_x, float actual_y) { + float dx = predicted_x - actual_x; + float dy = predicted_y - actual_y; + float error = std::sqrt(dx * dx + dy * dy); + + position_errors_.push(error); + total_error_ += error; + total_squared_error_ += error * error; + max_error_ = (std::max)(max_error_, static_cast(error)); + error_count_++; + } + + void add_correction(float distance) { + corrections_.push(distance); + correction_count_++; + total_correction_ += distance; + max_correction_ = (std::max)(max_correction_, static_cast(distance)); + } + + void add_reconciliation() { + reconciliation_count_++; + } + + void add_prediction() { + prediction_count_++; + } + + void add_rollback() { + rollback_count_++; + } + + PositionMetrics get_position_metrics() const { + PositionMetrics m; + m.sample_count = error_count_; + if (error_count_ > 0) { + m.mae = total_error_ / error_count_; + m.mse = total_squared_error_ / error_count_; + m.max_error = max_error_; + } + return m; + } + + CompensationMetrics get_compensation_metrics(double elapsed_seconds) const { + CompensationMetrics m; + if (elapsed_seconds > 0) { + m.predictions_per_second = static_cast(prediction_count_ / elapsed_seconds); + m.reconciliations_per_second = static_cast(reconciliation_count_ / elapsed_seconds); + } + if (correction_count_ > 0) { + m.avg_correction_distance = total_correction_ / correction_count_; + } + m.max_correction_distance = max_correction_; + m.rollbacks_count = rollback_count_; + return m; + } + void reset_compensation_metrics() { + prediction_count_ = 0; + reconciliation_count_ = 0; + total_correction_ = 0.0; + max_correction_ = 0.0; + correction_count_ = 0; + } + + void reset() { + position_errors_.clear(); + corrections_.clear(); + total_error_ = 0; + total_squared_error_ = 0; + max_error_ = 0; + error_count_ = 0; + total_correction_ = 0; + max_correction_ = 0; + correction_count_ = 0; + prediction_count_ = 0; + reconciliation_count_ = 0; + rollback_count_ = 0; + } + + +private: + RingBuffer position_errors_; + RingBuffer corrections_; + + double total_error_ = 0; + double total_squared_error_ = 0; + double max_error_ = 0; + uint32_t error_count_ = 0; + + double total_correction_ = 0; + double max_correction_ = 0; + uint32_t correction_count_ = 0; + + uint32_t prediction_count_ = 0; + uint32_t reconciliation_count_ = 0; + uint32_t rollback_count_ = 0; +}; + + +class MetricsLogger { +public: + explicit MetricsLogger(const std::string& filename = "client_metrics.csv") { + file_.open(filename); + if (file_.is_open()) { + file_ << "timestamp_ms,preset,algorithm,rtt_ms,jitter_ms,packet_loss_percent," + << "packets_sent_per_sec,packets_delivered_per_sec,packets_lost_per_sec,packets_duplicated_per_sec," + << "fps,inputs_per_sec,snapshots_per_sec," + << "position_mae,position_mse,position_max_error,samples," + << "predictions_per_sec,reconciliations_per_sec," + << "avg_correction_distance,max_correction_distance\n"; + file_.flush(); + } + } + + void log(uint64_t timestamp_ms, + const std::string& preset_name, + const std::string& algorithm_name, + double rtt_ms, + double jitter_ms, + double packet_loss_percent, + uint64_t packets_sent_ps, + uint64_t packets_delivered_ps, + uint64_t packets_lost_ps, + uint64_t packets_duplicated_ps, + float fps, + uint32_t inputs_per_sec, + uint32_t snapshots_per_sec, + const PositionMetrics& pos, + const CompensationMetrics& comp) { + if (!file_.is_open()) return; + + file_ << timestamp_ms << "," + << preset_name << "," + << algorithm_name << "," + << std::fixed << std::setprecision(3) + << rtt_ms << "," + << jitter_ms << "," + << packet_loss_percent << "," + << packets_sent_ps << "," + << packets_delivered_ps << "," + << packets_lost_ps << "," + << packets_duplicated_ps << "," + << fps << "," + << inputs_per_sec << "," + << snapshots_per_sec << "," + << pos.mae << "," + << pos.mse << "," + << pos.max_error << "," + << pos.sample_count << "," + << comp.predictions_per_second << "," + << comp.reconciliations_per_second << "," + << comp.avg_correction_distance << "," + << comp.max_correction_distance << "\n"; + file_.flush(); + } + +private: + std::ofstream file_; +}; + +} // namespace netcode \ No newline at end of file diff --git a/common/include/common/network_channel.hpp b/common/include/common/network_channel.hpp new file mode 100644 index 0000000..d2e15b0 --- /dev/null +++ b/common/include/common/network_channel.hpp @@ -0,0 +1,17 @@ +#pragma once + +#include + +namespace netcode { + + // Каналы ENet + enum class NetworkChannel : uint8_t { + Reliable = 0, + Unreliable = 1, + + COUNT = 2 + }; + + constexpr uint8_t CHANNEL_COUNT = static_cast(NetworkChannel::COUNT); + +} // namespace netcode \ No newline at end of file diff --git a/common/include/common/network_messages.hpp b/common/include/common/network_messages.hpp new file mode 100644 index 0000000..2c69f15 --- /dev/null +++ b/common/include/common/network_messages.hpp @@ -0,0 +1,150 @@ +#pragma once + +#include "types.hpp" +#include "math_types.hpp" +#include "input_command.hpp" +#include "snapshot.hpp" +#include "compensation_algorithm.hpp" +#include +#include + +namespace netcode { + +// Типы сообщений +enum class MessageType : uint8_t { + // Подключение + ClientConnect = 1, + ServerAccept, + ServerReject, + ClientDisconnect, + + // Игровой процесс + ClientInput, + ServerSnapshot, + ServerDeltaSnapshot, + + // Конфигурация + ClientRequestConfig, + ServerConfig, + ClientSetAlgorithm, + + // Синхронизация времени + PingRequest, + PingResponse, + + // Отладка + DebugInfo +}; + +// Базовый заголовок сообщения +struct MessageHeader { + MessageType type; + uint16_t payload_size; +}; + +// Сообщения подключения + +struct ClientConnectMessage { + static constexpr MessageType TYPE = MessageType::ClientConnect; + + uint32_t protocol_version = 1; + char player_name[32] = {}; + TeamId team_preference; +}; + +struct ServerAcceptMessage { + static constexpr MessageType TYPE = MessageType::ServerAccept; + + ClientId assigned_client_id; + EntityId assigned_entity_id; + TeamId assigned_team; + TickNumber current_tick; + uint64_t server_time_us; +}; + +struct ServerRejectMessage { + static constexpr MessageType TYPE = MessageType::ServerReject; + + enum class Reason : uint8_t { + ServerFull, + VersionMismatch, + Banned, + Unknown + } reason; + char message[64] = {}; +}; + +// Игровые сообщения + +struct ClientInputMessage { + static constexpr MessageType TYPE = MessageType::ClientInput; + + struct InputEntry { + SequenceNumber sequence; + TickNumber client_tick; + uint64_t timestamp_us; + uint8_t input_state; + }; + + uint8_t input_count = 0; + InputEntry inputs[16]; // Максимум 16 команд в пакете +}; + +struct ServerSnapshotMessage { + static constexpr MessageType TYPE = MessageType::ServerSnapshot; + + TickNumber server_tick; + uint64_t timestamp_us; + + struct Ack { + ClientId client_id; + SequenceNumber last_sequence; + }; + uint8_t ack_count; + Ack acks[16]; + + float red_team_time; + float blue_team_time; + + uint8_t entity_count; +}; + +// Конфигурация + +struct ServerConfigMessage { + static constexpr MessageType TYPE = MessageType::ServerConfig; + + uint32_t tick_rate; + uint32_t snapshot_rate; + float arena_width; + float arena_height; + float player_radius; + float player_max_speed; + float ball_radius; +}; + +struct ClientSetAlgorithmMessage { + static constexpr MessageType TYPE = MessageType::ClientSetAlgorithm; + + CompensationAlgorithm algorithm; +}; + +// Синхронизация времени + +struct PingRequestMessage { + static constexpr MessageType TYPE = MessageType::PingRequest; + + uint32_t ping_id; + uint64_t client_time_us; +}; + +struct PingResponseMessage { + static constexpr MessageType TYPE = MessageType::PingResponse; + + uint32_t ping_id; + uint64_t client_time_us; + uint64_t server_time_us; + TickNumber server_tick; +}; + +} // namespace netcode \ No newline at end of file diff --git a/common/include/common/ring_buffer.hpp b/common/include/common/ring_buffer.hpp new file mode 100644 index 0000000..f80b514 --- /dev/null +++ b/common/include/common/ring_buffer.hpp @@ -0,0 +1,78 @@ +#pragma once + +#include +#include +#include + +namespace netcode { + + template + class RingBuffer { + public: + void push(const T& item) { + data_[write_index_] = item; + write_index_ = (write_index_ + 1) % Capacity; + if (count_ < Capacity) { + count_++; + } else { + read_index_ = (read_index_ + 1) % Capacity; + } + } + + std::optional pop() { + if (count_ == 0) return std::nullopt; + + T item = data_[read_index_]; + read_index_ = (read_index_ + 1) % Capacity; + count_--; + return item; + } + + const T* peek() const { + if (count_ == 0) return nullptr; + return &data_[read_index_]; + } + + const T* at(size_t index) const { + if (index >= count_) return nullptr; + size_t actual_index = (read_index_ + index) % Capacity; + return &data_[actual_index]; + } + + T* at(size_t index) { + if (index >= count_) return nullptr; + size_t actual_index = (read_index_ + index) % Capacity; + return &data_[actual_index]; + } + + // Доступ по sequence number (для команд) + template + const T* find_by_sequence(uint32_t seq, SeqGetter getter) const { + for (size_t i = 0; i < count_; ++i) { + const T* item = at(i); + if (item && getter(*item) == seq) { + return item; + } + } + return nullptr; + } + + void clear() { + read_index_ = 0; + write_index_ = 0; + count_ = 0; + } + + size_t size() const { return count_; } + bool empty() const { return count_ == 0; } + bool full() const { return count_ == Capacity; } + static constexpr size_t capacity() { return Capacity; } + + private: + std::array data_; + size_t read_index_ = 0; + size_t write_index_ = 0; + size_t count_ = 0; + }; + +} // namespace netcode \ No newline at end of file diff --git a/common/include/common/serialization.hpp b/common/include/common/serialization.hpp new file mode 100644 index 0000000..7a01d18 --- /dev/null +++ b/common/include/common/serialization.hpp @@ -0,0 +1,237 @@ +#pragma once + +#include "types.hpp" +#include "math_types.hpp" +#include "snapshot.hpp" +#include "network_messages.hpp" +#include +#include +#include + +namespace netcode { + +class WriteBuffer { +public: + WriteBuffer(size_t initial_capacity = 1024) { + data_.reserve(initial_capacity); + } + + void write_u8(uint8_t value) { + data_.push_back(value); + } + + void write_u16(uint16_t value) { + data_.push_back(static_cast(value & 0xFF)); + data_.push_back(static_cast((value >> 8) & 0xFF)); + } + + void write_u32(uint32_t value) { + for (int i = 0; i < 4; ++i) { + data_.push_back(static_cast((value >> (i * 8)) & 0xFF)); + } + } + + void write_u64(uint64_t value) { + for (int i = 0; i < 8; ++i) { + data_.push_back(static_cast((value >> (i * 8)) & 0xFF)); + } + } + + void write_float(float value) { + uint32_t bits; + std::memcpy(&bits, &value, sizeof(float)); + write_u32(bits); + } + + void write_vec2(const Vec2& v) { + write_float(v.x); + write_float(v.y); + } + + void write_bytes(const void* data, size_t size) { + const uint8_t* bytes = static_cast(data); + data_.insert(data_.end(), bytes, bytes + size); + } + + void write_string(const char* str, size_t max_len) { + size_t len = std::strlen(str); + len = (std::min)(len, max_len - 1); + write_u8(static_cast(len)); + write_bytes(str, len); + } + + const uint8_t* data() const { return data_.data(); } + size_t size() const { return data_.size(); } + + void clear() { data_.clear(); } + +private: + std::vector data_; +}; + +class ReadBuffer { +public: + ReadBuffer(const uint8_t* data, size_t size) + : data_(data), size_(size), pos_(0) {} + + bool read_u8(uint8_t& value) { + if (pos_ + 1 > size_) return false; + value = data_[pos_++]; + return true; + } + + bool read_u16(uint16_t& value) { + if (pos_ + 2 > size_) return false; + value = static_cast(data_[pos_]) | + (static_cast(data_[pos_ + 1]) << 8); + pos_ += 2; + return true; + } + + bool read_u32(uint32_t& value) { + if (pos_ + 4 > size_) return false; + value = 0; + for (int i = 0; i < 4; ++i) { + value |= static_cast(data_[pos_ + i]) << (i * 8); + } + pos_ += 4; + return true; + } + + bool read_u64(uint64_t& value) { + if (pos_ + 8 > size_) return false; + value = 0; + for (int i = 0; i < 8; ++i) { + value |= static_cast(data_[pos_ + i]) << (i * 8); + } + pos_ += 8; + return true; + } + + bool read_float(float& value) { + uint32_t bits; + if (!read_u32(bits)) return false; + std::memcpy(&value, &bits, sizeof(float)); + return true; + } + + bool read_vec2(Vec2& v) { + return read_float(v.x) && read_float(v.y); + } + + bool read_bytes(void* dest, size_t size) { + if (pos_ + size > size_) return false; + std::memcpy(dest, data_ + pos_, size); + pos_ += size; + return true; + } + + size_t remaining() const { return size_ - pos_; } + bool empty() const { return pos_ >= size_; } + +private: + const uint8_t* data_; + size_t size_; + size_t pos_; +}; + +// сериализация сущности +inline void serialize_entity(WriteBuffer& buf, const EntityState& entity) { + buf.write_u32(entity.id); + buf.write_u8(static_cast(entity.type)); + buf.write_vec2(entity.position); + buf.write_vec2(entity.velocity); + + switch (entity.type) { + case EntityType::Player: + buf.write_u16(entity.data.player.owner_id); + buf.write_u8(static_cast(entity.data.player.team)); + buf.write_float(entity.data.player.radius); + break; + case EntityType::Ball: + buf.write_float(entity.data.ball.radius); + break; + default: + break; + } +} + +inline bool deserialize_entity(ReadBuffer& buf, EntityState& entity) { + uint8_t type_byte; + if (!buf.read_u32(entity.id)) return false; + if (!buf.read_u8(type_byte)) return false; + entity.type = static_cast(type_byte); + if (!buf.read_vec2(entity.position)) return false; + if (!buf.read_vec2(entity.velocity)) return false; + + switch (entity.type) { + case EntityType::Player: { + uint8_t team_byte; + if (!buf.read_u16(entity.data.player.owner_id)) return false; + if (!buf.read_u8(team_byte)) return false; + entity.data.player.team = static_cast(team_byte); + if (!buf.read_float(entity.data.player.radius)) return false; + break; + } + case EntityType::Ball: + if (!buf.read_float(entity.data.ball.radius)) return false; + break; + default: + break; + } + return true; +} + +// сериализация снапшота +inline void serialize_snapshot(WriteBuffer& buf, const WorldSnapshot& snapshot) { + buf.write_u8(static_cast(MessageType::ServerSnapshot)); + buf.write_u32(snapshot.server_tick); + buf.write_u64(snapshot.timestamp_us); + buf.write_float(snapshot.red_team_time); + buf.write_float(snapshot.blue_team_time); + + // подтверждения + buf.write_u8(static_cast(snapshot.client_acks.size())); + for (const auto& ack : snapshot.client_acks) { + buf.write_u16(ack.client_id); + buf.write_u32(ack.last_processed_sequence); + } + + // сущности + buf.write_u8(static_cast(snapshot.entities.size())); + for (const auto& entity : snapshot.entities) { + serialize_entity(buf, entity); + } +} + +inline bool deserialize_snapshot(ReadBuffer& buf, WorldSnapshot& snapshot) { + uint8_t msg_type; + if (!buf.read_u8(msg_type)) return false; + if (static_cast(msg_type) != MessageType::ServerSnapshot) return false; + + if (!buf.read_u32(snapshot.server_tick)) return false; + if (!buf.read_u64(snapshot.timestamp_us)) return false; + if (!buf.read_float(snapshot.red_team_time)) return false; + if (!buf.read_float(snapshot.blue_team_time)) return false; + + // подтверждения + uint8_t ack_count; + if (!buf.read_u8(ack_count)) return false; + snapshot.client_acks.resize(ack_count); + for (uint8_t i = 0; i < ack_count; ++i) { + if (!buf.read_u16(snapshot.client_acks[i].client_id)) return false; + if (!buf.read_u32(snapshot.client_acks[i].last_processed_sequence)) return false; + } + + // сущности + uint8_t entity_count; + if (!buf.read_u8(entity_count)) return false; + snapshot.entities.resize(entity_count); + for (uint8_t i = 0; i < entity_count; ++i) { + if (!deserialize_entity(buf, snapshot.entities[i])) return false; + } + + return true; +} + +} // namespace netcode \ No newline at end of file diff --git a/common/include/common/simulation_params.hpp b/common/include/common/simulation_params.hpp new file mode 100644 index 0000000..997e17b --- /dev/null +++ b/common/include/common/simulation_params.hpp @@ -0,0 +1,44 @@ +#pragma once + +#include "types.hpp" +#include "math_types.hpp" + +namespace netcode { + + struct SimulationParams { + uint32_t tick_rate = 60; + double fixed_delta_time = 1.0 / 60.0; + + uint32_t snapshot_rate = 20; + uint32_t snapshot_history_size = 128; + + float arena_width = 1620.0f; + float arena_height = 900.0f; + float wall_thickness = 10.0f; + + Rect red_zone = {50.0f, 250.0f, 150.0f, 300.0f}; + Rect blue_zone = {1400.0f, 250.0f, 150.0f, 300.0f}; + + float player_radius = 20.0f; + float player_mass = 1.0f; + float player_max_speed = 400.0f; + float player_acceleration = 1650.0f; + float player_friction = 4.0f; + + float ball_radius = 30.0f; + float ball_mass = 1.5f; + float ball_friction = 2.0f; + float ball_restitution = 0.8f; + + float collision_restitution = 0.5f; + + uint32_t input_buffer_size = 64; + uint32_t jitter_buffer_size = 3; + + double tick_interval() const { return 1.0 / tick_rate; } + double snapshot_interval() const { return 1.0 / snapshot_rate; } + }; + + inline SimulationParams DEFAULT_SIMULATION_PARAMS; + +} // namespace netcode \ No newline at end of file diff --git a/common/include/common/snapshot.hpp b/common/include/common/snapshot.hpp new file mode 100644 index 0000000..352abeb --- /dev/null +++ b/common/include/common/snapshot.hpp @@ -0,0 +1,62 @@ +#pragma once + +#include "types.hpp" +#include "math_types.hpp" +#include +#include + +namespace netcode { + + struct EntityState { + EntityId id = INVALID_ENTITY_ID; + EntityType type = EntityType::None; + + Vec2 position; + Vec2 velocity; + + union { + struct { + ClientId owner_id; + TeamId team; + float radius; + } player; + + struct { + float radius; + } ball; + } data = {}; + + EntityState() { std::memset(&data, 0, sizeof(data)); } + }; + + struct WorldSnapshot { + TickNumber server_tick = 0; + uint64_t timestamp_us = 0; + + std::vector entities; + + struct ClientAck { + ClientId client_id; + SequenceNumber last_processed_sequence; + }; + std::vector client_acks; + + float red_team_time = 0.0f; + float blue_team_time = 0.0f; + + const EntityState* find_entity(EntityId id) const { + for (const auto& e : entities) { + if (e.id == id) return &e; + } + return nullptr; + } + + EntityState* find_entity(EntityId id) { + for (auto& e : entities) { + if (e.id == id) return &e; + } + return nullptr; + } + }; + +} // namespace netcode \ No newline at end of file diff --git a/common/include/common/timestamp.hpp b/common/include/common/timestamp.hpp new file mode 100644 index 0000000..eec2b38 --- /dev/null +++ b/common/include/common/timestamp.hpp @@ -0,0 +1,96 @@ +#pragma once + +#include "types.hpp" +#include + +namespace netcode { + +class Timestamp { +public: + static uint64_t now_us() { + auto now = Clock::now(); + return std::chrono::duration_cast( + now.time_since_epoch() + ).count(); + } + + static uint64_t now_ms() { + return now_us() / 1000; + } + + static double now_seconds() { + return static_cast(now_us()) / 1000000.0; + } +}; + +// Синхронизация времени между клиентом и сервером +class TimeSynchronizer { +public: + void add_sample(uint64_t client_send_time, uint64_t server_time, uint64_t client_receive_time) { + // RTT + uint64_t rtt = client_receive_time - client_send_time; + rtt_samples_[sample_index_] = rtt; + + // предпологается симметричная задержка + uint64_t one_way_delay = rtt / 2; + int64_t offset = static_cast(server_time) - + static_cast(client_send_time + one_way_delay); + offset_samples_[sample_index_] = offset; + + sample_index_ = (sample_index_ + 1) % MAX_SAMPLES; + if (sample_count_ < MAX_SAMPLES) sample_count_++; + + update_estimates(); + } + + uint64_t client_to_server_time(uint64_t client_time) const { + return static_cast(static_cast(client_time) + clock_offset_); + } + + uint64_t server_to_client_time(uint64_t server_time) const { + return static_cast(static_cast(server_time) - clock_offset_); + } + + double get_rtt_ms() const { return rtt_estimate_ / 1000.0; } + double get_jitter_ms() const { return jitter_estimate_ / 1000.0; } + + bool is_synchronized() const { return sample_count_ >= 3; } + +private: + static constexpr size_t MAX_SAMPLES = 16; + + uint64_t rtt_samples_[MAX_SAMPLES] = {}; + int64_t offset_samples_[MAX_SAMPLES] = {}; + size_t sample_index_ = 0; + size_t sample_count_ = 0; + + int64_t clock_offset_ = 0; + double rtt_estimate_ = 0.0; + double jitter_estimate_ = 0.0; + + void update_estimates() { + if (sample_count_ == 0) return; + + // Среднее RTT + uint64_t rtt_sum = 0; + for (size_t i = 0; i < sample_count_; ++i) { + rtt_sum += rtt_samples_[i]; + } + rtt_estimate_ = static_cast(rtt_sum) / sample_count_; + + // Джиттер + double jitter_sum = 0; + for (size_t i = 0; i < sample_count_; ++i) { + double diff = static_cast(rtt_samples_[i]) - rtt_estimate_; + jitter_sum += std::abs(diff); + } + jitter_estimate_ = jitter_sum / sample_count_; + + int64_t sorted_offsets[MAX_SAMPLES]; + std::copy_n(offset_samples_, sample_count_, sorted_offsets); + std::sort(sorted_offsets, sorted_offsets + sample_count_); + clock_offset_ = sorted_offsets[sample_count_ / 2]; + } +}; + +} // namespace netcode \ No newline at end of file diff --git a/common/include/common/types.hpp b/common/include/common/types.hpp new file mode 100644 index 0000000..b18f235 --- /dev/null +++ b/common/include/common/types.hpp @@ -0,0 +1,49 @@ +#pragma once + +#include +#include +#include + +namespace netcode { + + // Базовые типы идентификаторов + using ClientId = uint16_t; + using EntityId = uint32_t; + using TickNumber = uint32_t; + using SequenceNumber = uint32_t; + + // Константы + constexpr ClientId INVALID_CLIENT_ID = 0xFFFF; + constexpr EntityId INVALID_ENTITY_ID = 0xFFFFFFFF; + constexpr TickNumber INVALID_TICK = 0xFFFFFFFF; + + // Типы сущностей + enum class EntityType : uint8_t { + None = 0, + Player, + Ball + }; + + // Типы команд + enum class TeamId : uint8_t { + None = 0, + Red, + Blue + }; + + // Высокоточное время + using Clock = std::chrono::steady_clock; + using TimePoint = Clock::time_point; + using Duration = std::chrono::duration; + using Milliseconds = std::chrono::milliseconds; + using Microseconds = std::chrono::microseconds; + + inline double to_seconds(Duration d) { + return d.count(); + } + + inline double to_milliseconds(Duration d) { + return std::chrono::duration(d).count(); + } + +} // namespace netcode \ No newline at end of file diff --git a/common/src/common.cpp b/common/src/common.cpp new file mode 100644 index 0000000..ebdae99 --- /dev/null +++ b/common/src/common.cpp @@ -0,0 +1,21 @@ +#define ENET_IMPLEMENTATION +#include "common/common.hpp" +#include "common/types.hpp" +#include "common/math_types.hpp" +#include "common/serialization.hpp" +#include "common/enet_wrapper.hpp" +#include "common/timestamp.hpp" +#include "common/metrics.hpp" + + +// Основная логика в заголовочных файлах + +namespace netcode { + + // Версия протокола + const uint32_t PROTOCOL_VERSION = 1; + + // Порт по умолчанию + const uint16_t DEFAULT_PORT = 7777; + +} // namespace netcode \ No newline at end of file diff --git a/extern/enet b/extern/enet new file mode 160000 index 0000000..8647b6e --- /dev/null +++ b/extern/enet @@ -0,0 +1 @@ +Subproject commit 8647b6eaea881c86471ae29f732620d299fc20d7 diff --git a/sapp/include/sapp/lag_compensation.hpp b/sapp/include/sapp/lag_compensation.hpp new file mode 100644 index 0000000..1ba0559 --- /dev/null +++ b/sapp/include/sapp/lag_compensation.hpp @@ -0,0 +1,66 @@ +// на будущее расширение + + +// #pragma once +// +// #include "common/types.hpp" +// #include "common/math_types.hpp" +// #include "server_world.hpp" +// +// namespace netcode { +// +// class LagCompensator { +// public: +// explicit LagCompensator(float max_rewind_ms = 200.0f) +// : max_rewind_ms_(max_rewind_ms) {} +// +// struct HitCheckResult { +// bool hit = false; +// EntityId hit_entity = INVALID_ENTITY_ID; +// Vec2 hit_position; +// }; +// +// HitCheckResult check_hit(const ServerWorld& world, +// ClientId shooter_client, +// const Vec2& origin, +// const Vec2& direction, +// float max_distance, +// uint64_t client_timestamp_us) { +// HitCheckResult result; +// +// auto history = world.get_history_at(client_timestamp_us); +// if (!history) return result; +// +// for (const auto& entity : history->entities) { +// if (entity.type != EntityType::Player) continue; +// +// if (entity.data.player.owner_id == shooter_client) continue; +// +// float radius = entity.data.player.radius; +// Vec2 to_center = entity.position - origin; +// float proj = Vec2::dot(to_center, direction); +// +// if (proj < 0 || proj > max_distance) continue; +// +// Vec2 closest = origin + direction * proj; +// float dist = Vec2::distance(closest, entity.position); +// +// if (dist <= radius) { +// result.hit = true; +// result.hit_entity = entity.id; +// result.hit_position = closest; +// return result; +// } +// } +// +// return result; +// } +// +// void set_max_rewind(float ms) { max_rewind_ms_ = ms; } +// float max_rewind() const { return max_rewind_ms_; } +// +// private: +// float max_rewind_ms_; +// }; +// +// } // namespace netcode \ No newline at end of file diff --git a/sapp/include/sapp/physics.hpp b/sapp/include/sapp/physics.hpp new file mode 100644 index 0000000..7ddca02 --- /dev/null +++ b/sapp/include/sapp/physics.hpp @@ -0,0 +1,193 @@ +#pragma once + +#include "common/types.hpp" +#include "common/math_types.hpp" +#include "common/simulation_params.hpp" +#include + +namespace netcode { + +struct PhysicsBody { + EntityId entity_id = INVALID_ENTITY_ID; + EntityType type = EntityType::None; + + Vec2 position; + Vec2 velocity; + Vec2 acceleration; + + float radius = 0.0f; + float mass = 1.0f; + float friction = 0.0f; + float restitution = 0.5f; + + bool is_static = false; +}; + +class PhysicsWorld { +public: + explicit PhysicsWorld(const SimulationParams& params = DEFAULT_SIMULATION_PARAMS) + : params_(params) {} + + // Добавление тела + void add_body(const PhysicsBody& body) { + bodies_.push_back(body); + } + + // Удаление тела + void remove_body(EntityId id) { + bodies_.erase( + std::remove_if(bodies_.begin(), bodies_.end(), + [id](const PhysicsBody& b) { return b.entity_id == id; }), + bodies_.end() + ); + } + + // Получение тела + PhysicsBody* get_body(EntityId id) { + for (auto& body : bodies_) { + if (body.entity_id == id) return &body; + } + return nullptr; + } + + const PhysicsBody* get_body(EntityId id) const { + for (const auto& body : bodies_) { + if (body.entity_id == id) return &body; + } + return nullptr; + } + + // Симуляция одного шага + void step(float dt) { + for (auto& body : bodies_) { + if (body.is_static) continue; + + // трение + body.velocity -= body.velocity * body.friction * dt; + + // ускорение + body.velocity += body.acceleration * dt; + + // скорость + if (body.type == EntityType::Player) { + float speed = body.velocity.length(); + if (speed > params_.player_max_speed) { + body.velocity = body.velocity.normalized() * params_.player_max_speed; + } + } + + // Обновление позиции + body.position += body.velocity * dt; + + // Сбрасываем ускорение + body.acceleration = Vec2(); + } + + // обработка коллизиц + resolve_collisions(); + + // Коллизии со стенами + for (auto& body : bodies_) { + constrain_to_arena(body); + } + } + + // Применить силу к телу + void apply_force(EntityId id, const Vec2& force) { + PhysicsBody* body = get_body(id); + if (body) { + body->acceleration += force / body->mass; + } + } + + // Установить ускорение + void set_acceleration(EntityId id, const Vec2& acc) { + PhysicsBody* body = get_body(id); + if (body) { + body->acceleration = acc; + } + } + + const std::vector& bodies() const { return bodies_; } + +private: + void constrain_to_arena(PhysicsBody& body) { + float min_x = params_.wall_thickness + body.radius; + float max_x = params_.arena_width - params_.wall_thickness - body.radius; + float min_y = params_.wall_thickness + body.radius; + float max_y = params_.arena_height - params_.wall_thickness - body.radius; + + if (body.position.x < min_x) { + body.position.x = min_x; + body.velocity.x = -body.velocity.x * body.restitution; + } + if (body.position.x > max_x) { + body.position.x = max_x; + body.velocity.x = -body.velocity.x * body.restitution; + } + if (body.position.y < min_y) { + body.position.y = min_y; + body.velocity.y = -body.velocity.y * body.restitution; + } + if (body.position.y > max_y) { + body.position.y = max_y; + body.velocity.y = -body.velocity.y * body.restitution; + } + } + + void resolve_collisions() { + for (size_t i = 0; i < bodies_.size(); ++i) { + for (size_t j = i + 1; j < bodies_.size(); ++j) { + resolve_collision(bodies_[i], bodies_[j]); + } + } + } + + void resolve_collision(PhysicsBody& a, PhysicsBody& b) { + Vec2 diff = b.position - a.position; + float dist = diff.length(); + float min_dist = a.radius + b.radius; + + if (dist < min_dist && dist > 0.001f) { + // Нормаль коллизии + Vec2 normal = diff / dist; + + // Глубина проникновения + float penetration = min_dist - dist; + + // Разделение тел + float total_mass = a.mass + b.mass; + Vec2 separation = normal * penetration; + + if (!a.is_static && !b.is_static) { + a.position -= separation * (b.mass / total_mass); + b.position += separation * (a.mass / total_mass); + } else if (!a.is_static) { + a.position -= separation; + } else if (!b.is_static) { + b.position += separation; + } + + // Относительная скорость + Vec2 rel_vel = b.velocity - a.velocity; + float rel_vel_normal = Vec2::dot(rel_vel, normal); + + // Только если сближаются + if (rel_vel_normal < 0) { + float restitution = (std::min)(a.restitution, b.restitution); + float impulse_mag = -(1.0f + restitution) * rel_vel_normal; + impulse_mag /= (1.0f / a.mass) + (1.0f / b.mass); + + Vec2 impulse = normal * impulse_mag; + + if (!a.is_static) a.velocity -= impulse / a.mass; + if (!b.is_static) b.velocity += impulse / b.mass; + } + } + } + + SimulationParams params_; + std::vector bodies_; +}; + +} // namespace netcode \ No newline at end of file diff --git a/sapp/include/sapp/sapp_entry.hpp b/sapp/include/sapp/sapp_entry.hpp new file mode 100644 index 0000000..34cff13 --- /dev/null +++ b/sapp/include/sapp/sapp_entry.hpp @@ -0,0 +1,5 @@ +#ifndef NETCODE_DEMO_SAPP_ENTRY_HPP +#define NETCODE_DEMO_SAPP_ENTRY_HPP + + +#endif //NETCODE_DEMO_SAPP_ENTRY_HPP \ No newline at end of file diff --git a/sapp/include/sapp/server_world.hpp b/sapp/include/sapp/server_world.hpp new file mode 100644 index 0000000..a7c4cf8 --- /dev/null +++ b/sapp/include/sapp/server_world.hpp @@ -0,0 +1,351 @@ +#pragma once + +#include + +#include "common/types.hpp" +#include "common/math_types.hpp" +#include "common/snapshot.hpp" +#include "common/input_command.hpp" +#include "common/simulation_params.hpp" +#include "common/ring_buffer.hpp" +#include "physics.hpp" +#include +#include +#include +#include + +namespace netcode { + +// Информация об игроке +struct ServerPlayer { + ClientId client_id = INVALID_CLIENT_ID; + EntityId entity_id = INVALID_ENTITY_ID; + TeamId team = TeamId::None; + std::string name; + + SequenceNumber last_processed_input = 0; + RingBuffer input_buffer; + + uint64_t estimated_rtt_us = 0; +}; + +// история состояний +struct WorldHistoryEntry { + TickNumber tick; + uint64_t timestamp_us; + std::vector entities; +}; + +class ServerWorld { +public: + explicit ServerWorld(const SimulationParams& params = DEFAULT_SIMULATION_PARAMS) + : params_(params), physics_(params) { + create_ball(); + } + + EntityId create_player(ClientId client_id, const std::string& name) { + EntityId entity_id = next_entity_id_++; + + // Определяем команду + TeamId team = (red_team_count_ <= blue_team_count_) ? TeamId::Red : TeamId::Blue; + if (team == TeamId::Red) red_team_count_++; + else blue_team_count_++; + + // Позиция спавна + Vec2 spawn_pos; + if (team == TeamId::Red) { + spawn_pos = params_.red_zone.center(); + } else { + spawn_pos = params_.blue_zone.center(); + } + + // Физическое тело + PhysicsBody body; + body.entity_id = entity_id; + body.type = EntityType::Player; + body.position = spawn_pos; + body.radius = params_.player_radius; + body.mass = params_.player_mass; + body.friction = params_.player_friction; + body.restitution = params_.collision_restitution; + physics_.add_body(body); + + // Данные игрока + ServerPlayer player; + player.client_id = client_id; + player.entity_id = entity_id; + player.team = team; + player.name = name; + players_[client_id] = player; + + return entity_id; + } + + void remove_player(ClientId client_id) { + auto it = players_.find(client_id); + if (it != players_.end()) { + physics_.remove_body(it->second.entity_id); + + if (it->second.team == TeamId::Red) red_team_count_--; + else if (it->second.team == TeamId::Blue) blue_team_count_--; + + players_.erase(it); + } + } + + TeamId get_player_team(ClientId client_id) const { + auto it = players_.find(client_id); + if (it != players_.end()) { + return it->second.team; + } + return TeamId::None; + } + + EntityId get_player_entity(ClientId client_id) const { + auto it = players_.find(client_id); + if (it != players_.end()) { + return it->second.entity_id; + } + return INVALID_ENTITY_ID; + } + + void add_player_input(ClientId client_id, const InputCommand& cmd) { + auto it = players_.find(client_id); + if (it != players_.end()) { + it->second.input_buffer.push(cmd); + } + } + + void update_player_rtt(ClientId client_id, uint64_t rtt_us) { + auto it = players_.find(client_id); + if (it != players_.end()) { + it->second.estimated_rtt_us = rtt_us; + } + } + + void tick() { + current_tick_++; + + // Обрабатываем ввод всех игроков + for (auto& [client_id, player] : players_) { + process_player_input(player); + } + + // Физика + physics_.step(static_cast(params_.fixed_delta_time)); + + // Обновляем счёт + update_score(); + + // Сохраняем историю + save_history(); + } + + // Получить снапшот + WorldSnapshot create_snapshot() const { + WorldSnapshot snapshot; + snapshot.server_tick = current_tick_; + snapshot.timestamp_us = Timestamp::now_us(); + snapshot.red_team_time = red_team_time_; + snapshot.blue_team_time = blue_team_time_; + + // Сущности + for (const auto& body : physics_.bodies()) { + EntityState state; + state.id = body.entity_id; + state.type = body.type; + state.position = body.position; + state.velocity = body.velocity; + + if (body.type == EntityType::Player) { + // Находим данные игрока + for (const auto& [cid, player] : players_) { + if (player.entity_id == body.entity_id) { + state.data.player.owner_id = player.client_id; + state.data.player.team = player.team; + state.data.player.radius = body.radius; + break; + } + } + } else if (body.type == EntityType::Ball) { + state.data.ball.radius = body.radius; + } + + snapshot.entities.push_back(state); + } + + // Подтверждения + for (const auto& [client_id, player] : players_) { + WorldSnapshot::ClientAck ack; + ack.client_id = client_id; + ack.last_processed_sequence = player.last_processed_input; + snapshot.client_acks.push_back(ack); + } + + return snapshot; + } + + std::optional get_history_at(uint64_t timestamp_us) const { + for (size_t i = 0; i < history_.size(); ++i) { + const auto* entry = history_.at(i); + if (entry && entry->timestamp_us <= timestamp_us) { + const auto* next = history_.at(i + 1); + if (!next || next->timestamp_us > timestamp_us) { + return *entry; + } + } + } + return std::nullopt; + } + +EntityId create_player(ClientId client_id, const std::string& name, + TeamId team_preference = TeamId::None) { + EntityId entity_id = next_entity_id_++; + + TeamId team; + if (team_preference == TeamId::Red || team_preference == TeamId::Blue) { + if (team_preference == TeamId::Red) { + if (red_team_count_ <= blue_team_count_ + 2) { + team = TeamId::Red; + } else { + team = TeamId::Blue; + std::cout << "Balancing: assigning to Blue instead of Red\n"; + } + } else { + if (blue_team_count_ <= red_team_count_ + 2) { + team = TeamId::Blue; + } else { + team = TeamId::Red; + std::cout << "Balancing: assigning to Red instead of Blue\n"; + } + } + } else { + team = (red_team_count_ <= blue_team_count_) ? TeamId::Red : TeamId::Blue; + } + + if (team == TeamId::Red) red_team_count_++; + else blue_team_count_++; + + // Позиция спавна + Vec2 spawn_pos; + if (team == TeamId::Red) { + spawn_pos = params_.red_zone.center(); + } else { + spawn_pos = params_.blue_zone.center(); + } + + // Физическое тело + PhysicsBody body; + body.entity_id = entity_id; + body.type = EntityType::Player; + body.position = spawn_pos; + body.radius = params_.player_radius; + body.mass = params_.player_mass; + body.friction = params_.player_friction; + body.restitution = params_.collision_restitution; + physics_.add_body(body); + + // Данные игрока + ServerPlayer player; + player.client_id = client_id; + player.entity_id = entity_id; + player.team = team; + player.name = name; + players_[client_id] = player; + + return entity_id; +} + + TickNumber current_tick() const { return current_tick_; } + const SimulationParams& params() const { return params_; } + + float red_team_time() const { return red_team_time_; } + float blue_team_time() const { return blue_team_time_; } + +private: + void create_ball() { + EntityId ball_id = next_entity_id_++; + ball_entity_id_ = ball_id; + + PhysicsBody body; + body.entity_id = ball_id; + body.type = EntityType::Ball; + body.position = Vec2(params_.arena_width / 2, params_.arena_height / 2); + body.radius = params_.ball_radius; + body.mass = params_.ball_mass; + body.friction = params_.ball_friction; + body.restitution = params_.ball_restitution; + physics_.add_body(body); + } + + void process_player_input(ServerPlayer& player) { + // Обрабатываем накопленный ввод + while (!player.input_buffer.empty()) { + auto cmd_opt = player.input_buffer.pop(); + if (!cmd_opt) break; + + const InputCommand& cmd = *cmd_opt; + + // Пропускаем старые команды + if (cmd.sequence <= player.last_processed_input) continue; + + // Применяем ввод + Vec2 input_dir = cmd.input.get_direction(); + physics_.set_acceleration(player.entity_id, + input_dir * params_.player_acceleration); + + player.last_processed_input = cmd.sequence; + } + } + + void update_score() { + const PhysicsBody* ball = physics_.get_body(ball_entity_id_); + if (!ball) return; + + float dt = static_cast(params_.fixed_delta_time); + + if (params_.red_zone.contains(ball->position)) { + red_team_time_ += dt; + } + if (params_.blue_zone.contains(ball->position)) { + blue_team_time_ += dt; + } + } + + + + void save_history() { + WorldHistoryEntry entry; + entry.tick = current_tick_; + entry.timestamp_us = Timestamp::now_us(); + + for (const auto& body : physics_.bodies()) { + EntityState state; + state.id = body.entity_id; + state.type = body.type; + state.position = body.position; + state.velocity = body.velocity; + entry.entities.push_back(state); + } + + history_.push(entry); + } + + SimulationParams params_; + PhysicsWorld physics_; + + std::unordered_map players_; + EntityId ball_entity_id_ = INVALID_ENTITY_ID; + + TickNumber current_tick_ = 0; + EntityId next_entity_id_ = 1; + + uint32_t red_team_count_ = 0; + uint32_t blue_team_count_ = 0; + float red_team_time_ = 0.0f; + float blue_team_time_ = 0.0f; + + RingBuffer history_; +}; + +} // namespace netcode \ No newline at end of file diff --git a/sapp/src/sapp_entry.cpp b/sapp/src/sapp_entry.cpp new file mode 100644 index 0000000..b9852bc --- /dev/null +++ b/sapp/src/sapp_entry.cpp @@ -0,0 +1,14 @@ +#include "sapp/sapp_entry.hpp" +#include "sapp/server_world.hpp" +#include "sapp/physics.hpp" + + +// Точка входа для DLL + +namespace netcode { + + void sapp_init() { + // Инициализация серверной библиотеки + } + +} // namespace netcode \ No newline at end of file diff --git a/server/sv_main.cpp b/server/sv_main.cpp new file mode 100644 index 0000000..3554f46 --- /dev/null +++ b/server/sv_main.cpp @@ -0,0 +1,480 @@ +// server/sv_main.cpp +#include "common/types.hpp" +#include "common/enet_wrapper.hpp" +#include "common/serialization.hpp" +#include "common/network_messages.hpp" +#include "common/timestamp.hpp" +#include "common/simulation_params.hpp" +#include "sapp/server_world.hpp" + +#include +#include +#include +#include +#include +#include +#include + +using namespace netcode; + +struct ClientSession { + ENetPeer* peer = nullptr; + ClientId client_id = INVALID_CLIENT_ID; + EntityId entity_id = INVALID_ENTITY_ID; + std::string name; + bool authenticated = false; + + uint64_t last_ping_time = 0; + uint64_t estimated_rtt_us = 0; + uint64_t last_activity_time = 0; + + CompensationAlgorithm client_algorithm = CompensationAlgorithm::Hybrid; +}; + +class GameServer { +public: + explicit GameServer(uint16_t port = 7777, const SimulationParams& params = DEFAULT_SIMULATION_PARAMS) + : port_(port) + , params_(params) + , world_(params) + {} + + bool start() { + if (!network_.create_server(port_, 16)) { + std::cerr << "Failed to create server on port " << port_ << "\n"; + return false; + } + + std::cout << "Server started on port " << port_ << "\n"; + std::cout << "Tick rate: " << params_.tick_rate << " Hz\n"; + std::cout << "Snapshot rate: " << params_.snapshot_rate << " Hz\n"; + + running_ = true; + return true; + } + + void run() { + using namespace std::chrono; + + auto last_tick_time = steady_clock::now(); + auto last_timeout_check = steady_clock::now(); + + double tick_accumulator = 0.0; + double snapshot_accumulator = 0.0; + double log_timer = 0.0; + + while (running_) { + auto now = steady_clock::now(); + + // Прошедшее время с последнего тика + double dt = duration(now - last_tick_time).count(); + + // Обработка сети + process_network(); + + // тики + uint32_t ticks_performed = 0; + tick_accumulator += dt; + while (tick_accumulator >= params_.tick_interval() && ticks_performed < 8) { + world_.tick(); + tick_accumulator -= params_.tick_interval(); + tick_count_++; + ticks_performed++; + } + + // снапшоты + snapshot_accumulator += dt; + if (snapshot_accumulator >= params_.snapshot_interval()) { + send_snapshots(); + snapshot_count_++; + snapshot_accumulator -= params_.snapshot_interval(); + } + + // проверка таймаутов + if (duration(now - last_timeout_check).count() >= 5.0) { + check_timeouts(); + last_timeout_check = now; + } + + // логирование + log_timer += dt; + if (log_timer >= 1.0) { + print_stats(); + log_timer -= 1.0; + } + + // вычисление следующего тика + double time_to_next_tick = params_.tick_interval() - tick_accumulator; + double time_to_next_snapshot = params_.snapshot_interval() - snapshot_accumulator; + + double time_to_next_event = (std::min)(time_to_next_tick, time_to_next_snapshot); + time_to_next_event = (std::max)(time_to_next_event, 0.0); + + // Обновляем время последнего тика + last_tick_time = now; + + // Спим до следующего события + if (time_to_next_event > 0.0005) { // > 500 мкс + auto sleep_duration = duration_cast(duration(time_to_next_event)); + std::this_thread::sleep_for(sleep_duration); + } else { + std::this_thread::yield(); // если система решит забрать это время то ждем, если нет -- сразу начинаем выполнение + } + } + } + + void stop() { + running_ = false; + } + +private: + void process_network() { + ENetEvent event; + while (network_.service(event, 0) > 0) { + switch (event.type) { + case ENET_EVENT_TYPE_CONNECT: + handle_connect(event.peer); + break; + + case ENET_EVENT_TYPE_RECEIVE: + handle_packet(event.peer, event.packet); + enet_packet_destroy(event.packet); + break; + + case ENET_EVENT_TYPE_DISCONNECT: + handle_disconnect(event.peer); + break; + + default: + break; + } + } + } + + void handle_connect(ENetPeer* peer) { + char hostStr[46]; + if (enet_address_get_host_ip(&peer->address, hostStr, sizeof(hostStr)) == 0) { + std::cout << "New connection from " + << hostStr << ":" + << peer->address.port << "\n"; + } else { + std::cout << "New connection from [unknown address]:" + << peer->address.port << "\n"; + } + } + + void handle_disconnect(ENetPeer* peer) { + auto it = sessions_by_peer_.find(peer); + if (it != sessions_by_peer_.end()) { + ClientId client_id = it->second; + auto session_it = sessions_.find(client_id); + if (session_it != sessions_.end()) { + std::cout << "Player disconnected: " << session_it->second.name + << " (ID: " << client_id << ")\n"; + + world_.remove_player(client_id); + sessions_.erase(session_it); + } + sessions_by_peer_.erase(it); + } + } + + void handle_packet(ENetPeer* peer, ENetPacket* packet) { + if (packet->dataLength < 1) return; + + ReadBuffer buf(packet->data, packet->dataLength); + uint8_t type_byte; + if (!buf.read_u8(type_byte)) return; + + MessageType type = static_cast(type_byte); + + switch (type) { + case MessageType::ClientConnect: + handle_client_connect(peer, buf); + break; + case MessageType::ClientInput: + handle_client_input(peer, buf); + break; + case MessageType::ClientSetAlgorithm: + handle_client_set_algorithm(peer, buf); + break; + case MessageType::PingRequest: + handle_ping_request(peer, buf); + break; + default: + break; + } + } + + void handle_client_connect(ENetPeer* peer, ReadBuffer& buf) { + uint32_t protocol_version; + if (!buf.read_u32(protocol_version)) return; + + uint8_t name_len; + if (!buf.read_u8(name_len)) return; + char name[33] = {}; + if (name_len > 0 && name_len <= 32) { + buf.read_bytes(name, name_len); + } + + uint8_t team_pref_byte; + TeamId team_preference = TeamId::None; + if (buf.read_u8(team_pref_byte)) { + team_preference = static_cast(team_pref_byte); + } + + if (protocol_version != 1) { + send_reject(peer, ServerRejectMessage::Reason::VersionMismatch); + return; + } + + if (sessions_.size() >= 16) { + send_reject(peer, ServerRejectMessage::Reason::ServerFull); + return; + } + + ClientId client_id = next_client_id_++; + EntityId entity_id = world_.create_player(client_id, name, team_preference); + TeamId team = world_.get_player_team(client_id); + + ClientSession session; + session.peer = peer; + session.client_id = client_id; + session.entity_id = entity_id; + session.name = name; + session.authenticated = true; + session.last_activity_time = Timestamp::now_us(); // НОВОЕ + + sessions_[client_id] = session; + sessions_by_peer_[peer] = client_id; + + send_accept(peer, client_id, entity_id, team); + send_config(peer); + + std::cout << "Player connected: " << name + << " (ID: " << client_id + << ", Entity: " << entity_id + << ", Team: " << (team == TeamId::Red ? "Red" : "Blue") + << ", Preferred: " << (team_preference == TeamId::Red ? "Red" : + team_preference == TeamId::Blue ? "Blue" : "Auto") << ")\n"; + } + + void handle_client_input(ENetPeer* peer, ReadBuffer& buf) { + auto it = sessions_by_peer_.find(peer); + if (it == sessions_by_peer_.end()) return; + + ClientId client_id = it->second; + + auto session_it = sessions_.find(client_id); + if (session_it != sessions_.end()) { + session_it->second.last_activity_time = Timestamp::now_us(); + } + + uint8_t input_count; + if (!buf.read_u8(input_count)) return; + + for (uint8_t i = 0; i < input_count && i < 16; ++i) { + InputCommand cmd; + if (!buf.read_u32(cmd.sequence)) break; + if (!buf.read_u32(cmd.client_tick)) break; + if (!buf.read_u64(cmd.timestamp_us)) break; + + uint8_t input_byte; + if (!buf.read_u8(input_byte)) break; + cmd.input = InputState::from_byte(input_byte); + + world_.add_player_input(client_id, cmd); + inputs_received_++; + } + } + + void handle_client_set_algorithm(ENetPeer* peer, ReadBuffer& buf) { + auto it = sessions_by_peer_.find(peer); + if (it == sessions_by_peer_.end()) return; + + uint8_t algo_byte; + if (!buf.read_u8(algo_byte)) return; + + ClientId client_id = it->second; + auto session_it = sessions_.find(client_id); + if (session_it != sessions_.end()) { + session_it->second.client_algorithm = static_cast(algo_byte); + std::cout << "Client " << client_id << " switched to algorithm: " + << algorithm_name(session_it->second.client_algorithm) << "\n"; + } + } + + void handle_ping_request(ENetPeer* peer, ReadBuffer& buf) { + uint32_t ping_id; + uint64_t client_time; + if (!buf.read_u32(ping_id)) return; + if (!buf.read_u64(client_time)) return; + + WriteBuffer response; + response.write_u8(static_cast(MessageType::PingResponse)); + response.write_u32(ping_id); + response.write_u64(client_time); + response.write_u64(Timestamp::now_us()); + response.write_u32(world_.current_tick()); + + network_.send(peer, NetworkChannel::Unreliable, response.data(), response.size(), false); + + auto it = sessions_by_peer_.find(peer); + if (it != sessions_by_peer_.end()) { + auto session_it = sessions_.find(it->second); + if (session_it != sessions_.end()) { + uint64_t now = Timestamp::now_us(); + if (session_it->second.last_ping_time > 0) { + session_it->second.estimated_rtt_us = now - session_it->second.last_ping_time; + world_.update_player_rtt(it->second, session_it->second.estimated_rtt_us); + } + session_it->second.last_ping_time = now; + } + } + } + + void check_timeouts() { + uint64_t now = Timestamp::now_us(); + const uint64_t timeout_us = 30'000'000; // 30 секунд + + std::vector to_disconnect; + + for (const auto& [client_id, session] : sessions_) { + if (session.authenticated && + session.last_activity_time > 0 && + now - session.last_activity_time > timeout_us) { + to_disconnect.push_back(client_id); + } + } + + for (ClientId client_id : to_disconnect) { + auto it = sessions_.find(client_id); + if (it != sessions_.end()) { + std::cout << "Client timeout: " << it->second.name << "\n"; + if (it->second.peer) { + enet_peer_disconnect(it->second.peer, 0); + } + world_.remove_player(client_id); + sessions_by_peer_.erase(it->second.peer); + sessions_.erase(it); + } + } + } + + void send_reject(ENetPeer* peer, ServerRejectMessage::Reason reason) { + WriteBuffer buf; + buf.write_u8(static_cast(MessageType::ServerReject)); + buf.write_u8(static_cast(reason)); + + network_.send(peer, NetworkChannel::Reliable, buf.data(), buf.size(), true); + } + + void send_accept(ENetPeer* peer, ClientId client_id, EntityId entity_id, TeamId team) { + WriteBuffer buf; + buf.write_u8(static_cast(MessageType::ServerAccept)); + buf.write_u16(client_id); + buf.write_u32(entity_id); + buf.write_u8(static_cast(team)); + buf.write_u32(world_.current_tick()); + buf.write_u64(Timestamp::now_us()); + + network_.send(peer, NetworkChannel::Reliable, buf.data(), buf.size(), true); + } + + void send_config(ENetPeer* peer) { + WriteBuffer buf; + buf.write_u8(static_cast(MessageType::ServerConfig)); + buf.write_u32(params_.tick_rate); + buf.write_u32(params_.snapshot_rate); + buf.write_float(params_.arena_width); + buf.write_float(params_.arena_height); + buf.write_float(params_.player_radius); + buf.write_float(params_.player_max_speed); + buf.write_float(params_.ball_radius); + + network_.send(peer, NetworkChannel::Reliable, buf.data(), buf.size(), true); + } + + void send_snapshots() { + if (sessions_.empty()) return; + + WorldSnapshot snapshot = world_.create_snapshot(); + + WriteBuffer buf; + serialize_snapshot(buf, snapshot); + + network_.broadcast(NetworkChannel::Unreliable, buf.data(), buf.size(), false); + snapshots_sent_++; + } + + void print_stats() { + std::cout << std::format("[Tick {}] Players: {} | Inputs/s: {} | Snapshots/s: {} | Red: {:.1f}s | Blue: {:.1f}s\n", + world_.current_tick(), + sessions_.size(), + inputs_received_, + snapshots_sent_, + world_.red_team_time(), + world_.blue_team_time()); + + inputs_received_ = 0; + snapshots_sent_ = 0; + } + +private: + uint16_t port_; + SimulationParams params_; + + ENetInitializer enet_init_; + NetworkHost network_; + + ServerWorld world_; + + std::unordered_map sessions_; + std::unordered_map sessions_by_peer_; + + ClientId next_client_id_ = 1; + + bool running_ = false; + + uint32_t tick_count_ = 0; + uint32_t snapshot_count_ = 0; + uint32_t inputs_received_ = 0; + uint32_t snapshots_sent_ = 0; + double log_timer_ = 0.0; +}; + +int main(int argc, char* argv[]) { + uint16_t port = 7777; + SimulationParams params = DEFAULT_SIMULATION_PARAMS; + + for (int i = 1; i < argc; ++i) { + std::string arg = argv[i]; + if (arg == "-p" || arg == "--port") { + if (i + 1 < argc) port = static_cast(std::stoi(argv[++i])); + } else if (arg == "--tick-rate") { + if (i + 1 < argc) { + params.tick_rate = static_cast(std::stoi(argv[++i])); + params.fixed_delta_time = 1.0 / params.tick_rate; + } + } else if (arg == "--snapshot-rate") { + if (i + 1 < argc) { + params.snapshot_rate = static_cast(std::stoi(argv[++i])); + } + } else if (arg == "--help") { + std::cout << "Usage: server [-p port] [--tick-rate N] [--snapshot-rate N]\n"; + return 0; + } + } + + try { + GameServer server(port, params); + if (server.start()) { + server.run(); + } + } catch (const std::exception& e) { + std::cerr << "Error: " << e.what() << "\n"; + return 1; + } + + return 0; +} \ No newline at end of file