#1 - ClientWorldController refactoring
This commit is contained in:
@@ -8,19 +8,22 @@
|
||||
|
||||
namespace tw::net {
|
||||
|
||||
/**
|
||||
* Session with the player's client.
|
||||
*/
|
||||
struct PlayerSession {
|
||||
public:
|
||||
SessionId session_id;
|
||||
|
||||
msg::MessageConnection* connection;
|
||||
|
||||
uint32_t last_frame;
|
||||
uint32_t last_received_frame;
|
||||
uint32_t acked_frame;
|
||||
|
||||
PlayerSession(SessionId session_id, msg::MessageConnection* connection) :
|
||||
session_id(session_id),
|
||||
connection(connection),
|
||||
last_frame(0),
|
||||
last_received_frame(0),
|
||||
acked_frame(0)
|
||||
{ }
|
||||
};
|
||||
|
||||
@@ -12,11 +12,6 @@
|
||||
|
||||
namespace tw::net {
|
||||
|
||||
// Listens on a dedicated port for incoming zone-server peer connections and
|
||||
// opens outgoing connections to known peers.
|
||||
//
|
||||
// Incoming and outgoing peers are held together so that broadcast messages
|
||||
// (ZoneHello, ZoneBye) reach every peer uniformly.
|
||||
class ZoneClusterLink {
|
||||
std::unique_ptr<msg::MessageEndpoint> m_endpoint;
|
||||
ProtobufMessages m_messages;
|
||||
@@ -24,13 +19,10 @@ class ZoneClusterLink {
|
||||
public:
|
||||
explicit ZoneClusterLink(int port);
|
||||
|
||||
// Poll for messages and accept any pending peer connections.
|
||||
void update();
|
||||
|
||||
// Connect to a remote zone peer and register it in the peer list.
|
||||
msg::MessageConnection* connect_to_peer(const std::string& host, int port);
|
||||
|
||||
// Send a protobuf message to a single peer.
|
||||
template<typename T>
|
||||
void send_mesg(msg::MessageConnection* peer, const T& msg) {
|
||||
auto send_r = m_messages.send(peer, msg, false);
|
||||
|
||||
@@ -19,8 +19,6 @@ ZoneManager::ZoneManager(im::AreaBounds zone_bounds)
|
||||
))
|
||||
{}
|
||||
|
||||
// ── ZoneProxy interface ───────────────────────────────────────────────────────
|
||||
|
||||
im::AreaBounds ZoneManager::area() const {
|
||||
return {
|
||||
{ (float)m_spatial_backend->world_min_x(), (float)m_spatial_backend->world_min_z() },
|
||||
@@ -33,8 +31,6 @@ void ZoneManager::transfer_entity(EntityInfo&& info) {
|
||||
spawn_entity(std::move(info));
|
||||
}
|
||||
|
||||
// ── Zone management ───────────────────────────────────────────────────────────
|
||||
|
||||
entt::entity ZoneManager::spawn_entity(EntityInfo&& info) {
|
||||
entt::entity entity = m_world->registry().create();
|
||||
|
||||
@@ -104,8 +100,6 @@ uint32_t ZoneManager::acked_input_frame(im::InterestId interest_id) const {
|
||||
return it != m_session_acked_frame.end() ? it->second : 0;
|
||||
}
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
void ZoneManager::check_neighbor_transfers() {
|
||||
ZoneScopedN("ZoneManager::check_neighbor_transfers");
|
||||
|
||||
@@ -161,9 +155,7 @@ void ZoneManager::check_neighbor_transfers() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tick ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
void ZoneManager::tick(uint32_t frame_idx, float delta_time) {
|
||||
void ZoneManager::update(uint32_t frame_idx, float delta_time) {
|
||||
ZoneScopedN("ZoneManager::tick");
|
||||
|
||||
FrameMarkStart("Interest System");
|
||||
@@ -176,21 +168,7 @@ void ZoneManager::tick(uint32_t frame_idx, float delta_time) {
|
||||
m_world->step(delta_time);
|
||||
FrameMarkEnd("World step");
|
||||
|
||||
// Re-stamp each client entity's newest input onto the server's frame index.
|
||||
// The input ring is keyed by client frame numbers (independent counter),
|
||||
// but physics lookup uses the server's frame counter. By re-stamping onto
|
||||
// the server frame, input(server_frame_idx) resolves by exact match rather
|
||||
// than fallback, ensuring deterministic and correct input consumption.
|
||||
for (const auto& [interest_id, entity] : m_client_entities) {
|
||||
auto* controller = m_world->registry().try_get<CharacterController>(entity);
|
||||
if (!controller) continue;
|
||||
controller->set_input(frame_idx, controller->input());
|
||||
}
|
||||
|
||||
FrameMarkStart("Physics step");
|
||||
// Fixed step, not the measured interval: the simulation has to advance by the
|
||||
// same amount every frame for a replay of the same inputs to land in the same
|
||||
// place.
|
||||
m_physics_world.step(frame_idx, JoltPhysicsWorld::FIXED_DELTA_TIME);
|
||||
FrameMarkEnd("Physics step");
|
||||
|
||||
|
||||
@@ -18,30 +18,28 @@
|
||||
|
||||
namespace tw::net {
|
||||
|
||||
// 500-unit cells, 500-unit view radius → 3×3 neighbourhood, no per-entity distance checks.
|
||||
// World bounds are supplied to the constructor at runtime.
|
||||
using SpatialBackendType = im::FixedGrid<500, 500>;
|
||||
|
||||
// How far into this zone a neighbour's interest area is expanded.
|
||||
// Entities within this margin are tracked by the interest system before transfer.
|
||||
static constexpr float kNeighborBorderOverlap = 100.0f;
|
||||
|
||||
/**
|
||||
* Manages one zone: contains state of all entities inside and does logic for them.
|
||||
*/
|
||||
class ZoneManager : public ZoneProxy {
|
||||
struct NeighborEntry {
|
||||
ZoneProxy* proxy;
|
||||
im::InterestId interest_id;
|
||||
};
|
||||
|
||||
// Declaration order == initialisation order.
|
||||
std::unique_ptr<World> m_world;
|
||||
JoltPhysicsWorld m_physics_world;
|
||||
std::unique_ptr<SpatialBackendType> m_spatial_backend;
|
||||
std::unique_ptr<im::InterestSystem<SpatialBackendType>> m_interest_system;
|
||||
|
||||
std::unordered_map<im::InterestId, entt::entity> m_client_entities;
|
||||
std::unordered_map<entt::entity, im::InterestId> m_entity_sessions; // reverse map
|
||||
std::unordered_map<im::InterestId, uint32_t> m_session_input_frame; // most recent input frame received
|
||||
std::unordered_map<im::InterestId, uint32_t> m_session_acked_frame; // input frame consumed by last tick
|
||||
std::unordered_map<entt::entity, im::InterestId> m_entity_sessions;
|
||||
std::unordered_map<im::InterestId, uint32_t> m_session_input_frame;
|
||||
std::unordered_map<im::InterestId, uint32_t> m_session_acked_frame;
|
||||
std::vector<NeighborEntry> m_neighbors;
|
||||
uint32_t m_next_neighbor_id = 0x80000000u;
|
||||
|
||||
@@ -57,39 +55,23 @@ public:
|
||||
|
||||
ZoneManager(im::AreaBounds zone_bounds);
|
||||
|
||||
// ── ZoneProxy interface ───────────────────────────────────────────────────
|
||||
|
||||
// Returns the geographic area this zone owns, derived from the spatial backend bounds.
|
||||
im::AreaBounds area() const override;
|
||||
|
||||
// Receive an entity transferred from a neighbouring zone.
|
||||
// Spawns the entity in this zone's world; wires up input routing if session_id is set.
|
||||
// NOTE: ZoneServer must also update its session→zone routing table after this call.
|
||||
void transfer_entity(EntityInfo&& info) override;
|
||||
|
||||
// ── Zone management ───────────────────────────────────────────────────────
|
||||
|
||||
entt::entity spawn_entity(EntityInfo&& info);
|
||||
|
||||
// Registers an interest for the given entity and begins tracking entities near their position.
|
||||
// Uses session_id as the InterestId; the interest system is unaware of this mapping.
|
||||
// Can fetch the interest using `get_interest(interest_id)`.
|
||||
void add_client(im::InterestId interest_id, entt::entity entity);
|
||||
|
||||
void on_player_move(SessionId session_id, mmo::PlayerMoveMessage&& message);
|
||||
|
||||
// Registers a neighbouring zone. Its geographic area is added to the interest
|
||||
// manager and checked each tick; entities that cross into it are transferred.
|
||||
void register_neighbor_zone(ZoneProxy* neighbor);
|
||||
|
||||
// Returns the current interest state for any registered id, or nullptr.
|
||||
const im::Interest* get_interest(im::InterestId id) const;
|
||||
|
||||
// Returns the input frame that this session's entity was last simulated with, or 0 if unknown.
|
||||
uint32_t acked_input_frame(im::InterestId interest_id) const;
|
||||
|
||||
// Runs one tick: interest queries, neighbour transfer checks, world step, physics step.
|
||||
void tick(uint32_t frame_idx, float delta_time);
|
||||
void update(uint32_t frame_idx, float delta_time);
|
||||
};
|
||||
|
||||
} // namespace tw::net
|
||||
|
||||
@@ -61,10 +61,8 @@ void ZoneServer::player_update_handler(SessionId session_id, mmo::PlayerMoveMess
|
||||
auto it = m_session_zone.find(session_id);
|
||||
if (it == m_session_zone.end()) return;
|
||||
|
||||
// Echoed back in the next snapshot, so the client can tell how long its
|
||||
// input took to come back.
|
||||
if (auto* session = m_player_session_registry->session(session_id)) {
|
||||
session->last_frame = message.frame_idx();
|
||||
session->last_received_frame = message.frame_idx();
|
||||
}
|
||||
|
||||
it->second->on_player_move(session_id, std::move(message));
|
||||
@@ -134,11 +132,8 @@ void ZoneServer::run() {
|
||||
FrameMarkEnd("Update clients");
|
||||
|
||||
for (auto& zone : m_zones) {
|
||||
zone->tick(frame_idx, lock_step.delta_time());
|
||||
zone->update(frame_idx, lock_step.delta_time());
|
||||
|
||||
// Copy acked frames from zone to sessions before replication.
|
||||
// This ensures snapshots carry the input frame actually consumed by this tick,
|
||||
// not frames that arrive in the trailing network update.
|
||||
for (const auto& [session_id, session_zone] : m_session_zone) {
|
||||
if (session_zone != zone.get()) continue;
|
||||
if (auto* session = m_player_session_registry->session(session_id)) {
|
||||
|
||||
@@ -7,28 +7,6 @@
|
||||
|
||||
namespace tw::net::im {
|
||||
|
||||
/**
|
||||
* Bounded-world spatial grid backend.
|
||||
*
|
||||
* World area bounds are supplied at construction time so the same grid type
|
||||
* can be reused for differently-sized zones without recompiling.
|
||||
* CELL_SIZE and VIEW_RADIUS remain template parameters so kNeighborRadius
|
||||
* can be computed at compile time, keeping the hot-path loop bounds constant.
|
||||
*
|
||||
* Template parameters:
|
||||
* CELL_SIZE: size of each cell (meters). Recommended: CELL_SIZE == VIEW_RADIUS
|
||||
* for maximum efficiency (no distance checks needed).
|
||||
* VIEW_RADIUS: the subscription radius around a player (meters).
|
||||
*
|
||||
* Constructor parameters:
|
||||
* min_x, max_x, min_z, max_z — world area bounds (meters).
|
||||
*
|
||||
* Complexity:
|
||||
* begin_frame(): O(cols × rows) clearing
|
||||
* insert(): O(1)
|
||||
* query_neighbors(): O(kNeighborRadius²) cells × avg entities per cell
|
||||
* query_area(): O(cells overlapping AABB) × avg entities per cell
|
||||
*/
|
||||
template<uint32_t CELL_SIZE, uint32_t VIEW_RADIUS>
|
||||
class FixedGrid {
|
||||
public:
|
||||
@@ -45,9 +23,6 @@ private:
|
||||
int32_t m_world_min_z, m_world_max_z;
|
||||
uint32_t m_cols, m_rows;
|
||||
|
||||
// Flat 2D vector of cell vectors indexed as [cz * m_cols + cx].
|
||||
// Outer vector is allocated once at construction; inner vectors retain
|
||||
// capacity across frames so no heap allocations occur in steady state.
|
||||
std::vector<std::vector<entt::entity>> m_cells;
|
||||
|
||||
[[nodiscard]] inline std::pair<uint32_t, uint32_t> world_to_cell(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#pragma once
|
||||
|
||||
#include "Entity.pb.h"
|
||||
#include "MessageRegistry.hpp"
|
||||
#include "WorldState.pb.h"
|
||||
#include "network/NetworkReceiver.hpp"
|
||||
#include "network/PlayerSessionRegistry.hpp"
|
||||
#include "systems/Interest.hpp"
|
||||
@@ -18,14 +20,6 @@ namespace tw::net {
|
||||
|
||||
/**
|
||||
* Decides what to replicate and to whom. Does not mutate world state.
|
||||
*
|
||||
* Per-frame flow:
|
||||
* 1. For each session: reset its BinaryBuffer and write the header,
|
||||
* spawns and despawns from the InterestSystem.
|
||||
* 2. For each session: iterate its interest set and write entity positions
|
||||
* 3. Patch entity_count and send each raw buffer
|
||||
*
|
||||
* Memory: one BinaryBuffer per session, pre-allocated and reused every frame.
|
||||
*/
|
||||
template<im::SpatialBackend Backend>
|
||||
class StateReplicator {
|
||||
@@ -51,7 +45,10 @@ public:
|
||||
/**
|
||||
* Replicates the current world state for one zone to its connected clients.
|
||||
*/
|
||||
void replicate(const entt::registry& registry, const im::InterestSystem<Backend>* interest_manager) {
|
||||
void replicate(
|
||||
const entt::registry& registry,
|
||||
const im::InterestSystem<Backend>* interest_manager
|
||||
) {
|
||||
ZoneScopedN("Replicator");
|
||||
|
||||
const auto& sessions = m_client_registry->sessions();
|
||||
@@ -59,16 +56,12 @@ public:
|
||||
|
||||
if (session_count == 0) return;
|
||||
|
||||
// ── Grow buffer pool if needed (only on new connections) ──────────
|
||||
if (m_frames.size() < session_count) {
|
||||
m_frames.resize(session_count);
|
||||
for (auto& buf : m_frames)
|
||||
buf.reserve(kInitialCapacity);
|
||||
}
|
||||
|
||||
// Build one WorldStateWriter per client referencing the pre-allocated
|
||||
// buffer. reserve() is called before this loop so no reallocation
|
||||
// occurs and the buffer references inside the writers stay valid.
|
||||
std::vector<tw::serial::WorldStateWriter> writers;
|
||||
writers.reserve(session_count);
|
||||
for (std::size_t i = 0; i < session_count; ++i)
|
||||
@@ -76,7 +69,6 @@ public:
|
||||
|
||||
std::vector<const im::Interest*> client_states(session_count);
|
||||
|
||||
// ── 1. Write headers / spawns / despawns ──────────────────────────
|
||||
{
|
||||
ZoneScopedN("Preparing messages");
|
||||
|
||||
@@ -96,26 +88,37 @@ public:
|
||||
|
||||
m_frames[i].reserve(needed);
|
||||
writers[i].reset();
|
||||
writers[i].begin(session->acked_frame, Message<mmo::WorldStateMessage>::value);
|
||||
writers[i].begin(session->last_received_frame, Message<mmo::WorldStateMessage>::value);
|
||||
writers[i].write_spawns(state->spawn());
|
||||
writers[i].write_despawns(state->despawn());
|
||||
|
||||
mmo::EntitySpawnMessage spawn_message = {};
|
||||
|
||||
for(auto& spawn : state->spawn()) {
|
||||
auto spawn_item = spawn_message.add_spawns();
|
||||
spawn_item->set_entity_id((uint32_t)spawn);
|
||||
spawn_item->set_is_player(false);
|
||||
spawn_item->set_name("Test");
|
||||
}
|
||||
|
||||
m_network->send_mesg(sessions[i]->session_id, spawn_message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. Write entity positions (hot path, O(entities × sessions)) ──
|
||||
{
|
||||
ZoneScopedN("Putting transforms into messages");
|
||||
|
||||
auto view = registry.view<Transform>();
|
||||
view.each([&](entt::entity e, const Transform& t) {
|
||||
for (std::size_t i = 0; i < session_count; ++i) {
|
||||
if (client_states[i] && client_states[i]->is_interested_in_entity(e))
|
||||
if (client_states[i] && client_states[i]->is_interested_in_entity(e)) {
|
||||
writers[i].write_entity(e, t.position());
|
||||
// spdlog::info("Entity {} is at {} {} {}", (uint32_t)e, t.position().x, t.position().y, t.position().z);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── 3. Finalise and dispatch ───────────────────────────────────────
|
||||
{
|
||||
ZoneScopedN("Sending messages");
|
||||
|
||||
|
||||
@@ -12,28 +12,10 @@
|
||||
|
||||
namespace tw::net::im {
|
||||
|
||||
/**
|
||||
* Spatial interest management: maintains per-subscription spawn/despawn deltas.
|
||||
*
|
||||
* Each subscription is identified by a caller-chosen InterestId (uint32_t).
|
||||
* Two kinds of subscription:
|
||||
* set_entity_interest(id, entity) — tracks entities in the neighbourhood of
|
||||
* a world entity's position (e.g. a player).
|
||||
* set_area_interest(id, bounds) — tracks entities inside an XZ quad
|
||||
* (e.g. a zone border region).
|
||||
*
|
||||
* The caller decides how ids map to sessions, zones, or anything else.
|
||||
* InterestSystem has no knowledge of that mapping.
|
||||
*
|
||||
* Two-phase update per frame:
|
||||
* Phase 1 — Spatial rebuild (O(E)): insert all Transform entities into the backend.
|
||||
* Phase 2 — Per-subscription deltas (O(N × K)): for each subscription query the
|
||||
* backend, sort results, compute spawn/despawn via set_difference.
|
||||
*/
|
||||
template<SpatialBackend Backend>
|
||||
class InterestSystem {
|
||||
const World* m_world;
|
||||
Backend* m_backend; // non-owning; caller manages lifetime
|
||||
Backend* m_backend;
|
||||
std::unordered_map<InterestId, Interest> m_interests;
|
||||
|
||||
public:
|
||||
@@ -42,33 +24,26 @@ public:
|
||||
m_backend(backend)
|
||||
{}
|
||||
|
||||
// Registers or replaces a subscription tracking the neighbourhood of an entity.
|
||||
void set_entity_interest(InterestId id, entt::entity entity) {
|
||||
m_interests.insert_or_assign(id, Interest(entity));
|
||||
}
|
||||
|
||||
// Registers or replaces a subscription tracking entities inside an XZ quad.
|
||||
void set_area_interest(InterestId id, AreaBounds bounds) {
|
||||
m_interests.insert_or_assign(id, Interest(bounds));
|
||||
}
|
||||
|
||||
// Removes a subscription. No-op if id is not registered.
|
||||
void remove_interest(InterestId id) {
|
||||
m_interests.erase(id);
|
||||
}
|
||||
|
||||
// Returns the current interest state for the given id, or nullptr.
|
||||
const Interest* get_interest(InterestId id) const {
|
||||
auto it = m_interests.find(id);
|
||||
return it != m_interests.end() ? &it->second : nullptr;
|
||||
}
|
||||
|
||||
// Updates all subscriptions for the current frame.
|
||||
// Must be called once per frame after world positions have been updated.
|
||||
void update() {
|
||||
ZoneScopedN("InterestSystem::update");
|
||||
|
||||
// ── Phase 1: Rebuild spatial backend ─────────────────────────────────
|
||||
{
|
||||
ZoneScopedN("InterestSystem::Phase1_GridRebuild");
|
||||
m_backend->begin_frame();
|
||||
@@ -79,7 +54,6 @@ public:
|
||||
});
|
||||
}
|
||||
|
||||
// ── Phase 2: Per-subscription deltas ─────────────────────────────────
|
||||
{
|
||||
ZoneScopedN("InterestSystem::Phase2_Deltas");
|
||||
|
||||
|
||||
@@ -39,16 +39,16 @@ int main() {
|
||||
tw::Transform* transformA = zoneA.registry().try_get<tw::Transform>(entityA);
|
||||
transformA->set_position(glm::vec3(970.0f, 0.0f, 0.0f));
|
||||
|
||||
zoneA.tick(0, 0.1f);
|
||||
zoneB.tick(0, 0.1f);
|
||||
zoneA.update(0, 0.1f);
|
||||
zoneB.update(0, 0.1f);
|
||||
|
||||
interest = zoneA.get_interest(1);
|
||||
|
||||
transformA = zoneA.registry().try_get<tw::Transform>(entityA);
|
||||
transformA->set_position(glm::vec3(1020.0f, 0.0f, 0.0f));
|
||||
|
||||
zoneA.tick(1, 0.1f);
|
||||
zoneB.tick(1, 0.1f);
|
||||
zoneA.update(1, 0.1f);
|
||||
zoneB.update(1, 0.1f);
|
||||
|
||||
interest = zoneA.get_interest(1);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user