#5 - fixed interpolation & rollback
This commit is contained in:
@@ -27,8 +27,8 @@ $ conan install . --output-folder=build --build=missing -s build_type=Debug
|
||||
Configure and build using the generated preset:
|
||||
|
||||
```bash
|
||||
$ cmake --preset conan-debug
|
||||
$ cmake --build --preset conan-debug
|
||||
$ cmake -S . -B ./build -DCMAKE_TOOLCHAIN_FILE="build/conan_toolchain.cmake" -DCMAKE_BUILD_TYPE=Debug
|
||||
$ cmake --build ./build
|
||||
```
|
||||
|
||||
For a release build, repeat both steps with `-s build_type=Release` and the
|
||||
|
||||
+2
-11
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
timescaledb:
|
||||
image: timescale/timescaledb:latest-pg16
|
||||
image: timescale/timescaledb:latest-pg15
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: mmo
|
||||
@@ -9,7 +9,7 @@ services:
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- timescaledb_data:/var/lib/postgresql/data
|
||||
- timescaledb_data:/var/lib/towards_db/data
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana:latest
|
||||
@@ -24,15 +24,6 @@ services:
|
||||
depends_on:
|
||||
- timescaledb
|
||||
|
||||
# The zone server is not containerised yet: it needs libpqxx >= 7.7 for
|
||||
# pqxx::params and jammy carries 6.4.
|
||||
|
||||
# ── e2e harness ────────────────────────────────────────────────────────────
|
||||
# Opt in with `--profile e2e`, so a plain `docker compose up` still brings up
|
||||
# the database and dashboards on their own.
|
||||
#
|
||||
# docker compose --profile e2e up --build \
|
||||
# --abort-on-container-exit --exit-code-from chat-mock-client
|
||||
chat-server:
|
||||
profiles: [e2e]
|
||||
build:
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
layout(location = 0) out vec4 outColor;
|
||||
|
||||
layout(location = 0) in vec3 inNormal;
|
||||
|
||||
void main() {
|
||||
float diffuse = max(0.1, dot(vec3(0.8, 1.5, -1.0), inNormal));
|
||||
outColor = vec4(1.0, 0.0, 0.0, 1.0);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
layout(location = 0) in vec3 pos;
|
||||
layout(location = 1) in vec3 norm;
|
||||
layout(location = 0) out vec3 outNormal;
|
||||
|
||||
layout(set = 0, binding = 0) uniform Camera {
|
||||
mat4 proj;
|
||||
@@ -21,5 +22,6 @@ void main() {
|
||||
// outPos = pos.xyz * 0.05;
|
||||
// outNormal = norm.xyz;
|
||||
// outUV = uv;
|
||||
outNormal = norm;
|
||||
gl_Position = cam.proj * cam.view * PushConstants.transform * vec4(pos, 1.0);
|
||||
}
|
||||
|
||||
@@ -101,10 +101,54 @@ class tw::dbg::ComponentGui<tw::net::EntityPositionInterpolation> {
|
||||
private:
|
||||
using Clock = std::chrono::high_resolution_clock;
|
||||
|
||||
/** How far back the plot reaches. */
|
||||
static constexpr float WINDOW_IN_SECONDS = 10.0f;
|
||||
|
||||
/** Ten seconds of frames at sixty a second. */
|
||||
static constexpr size_t CAPACITY = 600;
|
||||
|
||||
inline static const net::EntityPositionInterpolation* m_subject = nullptr;
|
||||
|
||||
inline static int m_last_frame = -1;
|
||||
inline static size_t m_head = 0;
|
||||
inline static size_t m_count = 0;
|
||||
|
||||
inline static std::vector<Clock::time_point> m_read_times;
|
||||
inline static std::vector<glm::vec3> m_read_values;
|
||||
|
||||
static int64_t millis_since(Clock::time_point point) {
|
||||
return std::chrono::duration_cast<std::chrono::milliseconds>(Clock::now() - point).count();
|
||||
}
|
||||
|
||||
void restart(const net::EntityPositionInterpolation* instance) {
|
||||
m_subject = instance;
|
||||
m_last_frame = -1;
|
||||
m_head = 0;
|
||||
m_count = 0;
|
||||
|
||||
m_read_times.resize(CAPACITY);
|
||||
m_read_values.resize(CAPACITY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps one reading per frame, so drawing twice does not double up. The
|
||||
* whole value is kept rather than the axis on show, so switching axis
|
||||
* still shows the trace that was already gathered.
|
||||
*/
|
||||
void sample(Clock::time_point time, glm::vec3 value) {
|
||||
const int frame = ImGui::GetFrameCount();
|
||||
if(m_last_frame == frame) {
|
||||
return;
|
||||
}
|
||||
m_last_frame = frame;
|
||||
|
||||
m_read_times[m_head] = time;
|
||||
m_read_values[m_head] = value;
|
||||
|
||||
m_head = (m_head + 1) % CAPACITY;
|
||||
m_count = std::min(m_count + 1, CAPACITY);
|
||||
}
|
||||
|
||||
public:
|
||||
void draw(net::EntityPositionInterpolation* instance) {
|
||||
ImGui::SeparatorText("Received Positions");
|
||||
@@ -122,5 +166,73 @@ public:
|
||||
instance->values()[0].y,
|
||||
instance->values()[0].z,
|
||||
millis_since(instance->times()[0]));
|
||||
|
||||
static int axis = 0;
|
||||
ImGui::Combo("Axis##received", &axis, "X\0Y\0Z\0");
|
||||
|
||||
/** How far behind the present the reader looks; matches the caller. */
|
||||
static int delay_in_millis = 1000;
|
||||
ImGui::SliderInt("Delay (ms)", &delay_in_millis, 0, 2000);
|
||||
|
||||
if(m_subject != instance) {
|
||||
restart(instance);
|
||||
}
|
||||
|
||||
// The buffer wraps, so the index says nothing about when a sample
|
||||
// arrived. Placing each one at its own age puts it where it belongs
|
||||
// without having to know where the ring currently starts. Slots
|
||||
// nothing was pushed into are left out.
|
||||
const auto now = Clock::now();
|
||||
const size_t count = instance->size();
|
||||
|
||||
std::vector<float> ages(count);
|
||||
std::vector<float> positions(count);
|
||||
|
||||
for(size_t i = 0; i < count; i++) {
|
||||
ages[i] = -std::chrono::duration<float>(now - instance->times()[i]).count();
|
||||
positions[i] = instance->values()[i][axis];
|
||||
}
|
||||
|
||||
if(ImPlot::BeginPlot("Buffer", ImVec2(-1.0f, 180.0f))) {
|
||||
ImPlot::SetupAxes("seconds ago", "position", ImPlotAxisFlags_None, ImPlotAxisFlags_AutoFit);
|
||||
ImPlot::SetupAxisLimits(ImAxis_X1,
|
||||
-WINDOW_IN_SECONDS, 0.0f, ImGuiCond_Always);
|
||||
|
||||
ImPlot::PlotScatter("Received", ages.data(), positions.data(), (int)count);
|
||||
|
||||
// Reading the same way the position is read for drawing shows
|
||||
// which two samples the delay lands between, and how far the
|
||||
// result sits from either of them.
|
||||
const auto point = now - std::chrono::milliseconds(delay_in_millis);
|
||||
const auto [from, to, alpha] = instance->get_values_around(point);
|
||||
|
||||
const float read_at = -std::chrono::duration<float>(now - point).count();
|
||||
const float read_out = glm::mix(from, to, alpha)[axis];
|
||||
|
||||
ImPlot::SetNextMarkerStyle(ImPlotMarker_Circle, 5.0f);
|
||||
ImPlot::PlotScatter("Interpolated", &read_at, &read_out, 1);
|
||||
|
||||
// Each reading is kept at the moment it was read for, so the trace
|
||||
// ends on the marker above and runs back along the same timeline
|
||||
// the received samples sit on.
|
||||
sample(point, glm::mix(from, to, alpha));
|
||||
|
||||
std::vector<float> trace_ages(m_count);
|
||||
std::vector<float> trace_positions(m_count);
|
||||
|
||||
const size_t oldest = m_count == CAPACITY ? m_head : 0;
|
||||
|
||||
for(size_t i = 0; i < m_count; i++) {
|
||||
const size_t index = (oldest + i) % CAPACITY;
|
||||
|
||||
trace_ages[i] = -std::chrono::duration<float>(now - m_read_times[index]).count();
|
||||
trace_positions[i] = m_read_values[index][axis];
|
||||
}
|
||||
|
||||
ImPlot::PlotLine("Interpolated Trace",
|
||||
trace_ages.data(), trace_positions.data(), (int)m_count);
|
||||
|
||||
ImPlot::EndPlot();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -25,7 +25,7 @@ void EntityPositionInterpolator::set_position(Clock::time_point time_point, entt
|
||||
return;
|
||||
}
|
||||
|
||||
interpolation->push(Clock::now(), position);
|
||||
interpolation->push(time_point, position);
|
||||
}
|
||||
|
||||
glm::vec3 EntityPositionInterpolator::get_position(Clock::time_point time_point, entt::entity entity) {
|
||||
@@ -35,9 +35,7 @@ glm::vec3 EntityPositionInterpolator::get_position(Clock::time_point time_point,
|
||||
return glm::vec3();
|
||||
}
|
||||
|
||||
auto now = Clock::now() - std::chrono::milliseconds(m_bufferingIntervalInMillis);
|
||||
|
||||
auto [from, to, value] = interpolation->get_values_around(now);
|
||||
auto [from, to, value] = interpolation->get_values_around(time_point);
|
||||
return glm::mix(from, to, value);
|
||||
}
|
||||
|
||||
@@ -45,6 +43,10 @@ void EntityPositionInterpolator::interpolate_smoothed_entities(Clock::time_point
|
||||
m_registry->view<EntityPositionInterpolation, Transform>()
|
||||
.each([&](const auto entity, const EntityPositionInterpolation& interpolation, Transform& ts) {
|
||||
auto [from, to, value] = interpolation.get_values_around(time_point);
|
||||
spdlog::info("From: {} {} {} - To: {} {} {} - By: {}",
|
||||
from.x, from.y, from.z,
|
||||
to.x, to.y, to.z,
|
||||
value);
|
||||
ts.set_position(glm::mix(from, to, value));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,15 +5,21 @@
|
||||
|
||||
#include "common.hpp"
|
||||
|
||||
template<typename T, int length = 3>
|
||||
template<typename T, int length = 10>
|
||||
class InterpolatedProperty {
|
||||
using Clock = std::chrono::high_resolution_clock;
|
||||
|
||||
std::array<T, length> m_buffer;
|
||||
std::array<Clock::time_point, length> m_time_buffer;
|
||||
std::vector<T> m_buffer;
|
||||
std::vector<Clock::time_point> m_time_buffer;
|
||||
|
||||
uint32_t m_head;
|
||||
uint32_t m_size;
|
||||
|
||||
public:
|
||||
InterpolatedProperty(T initial_value) {
|
||||
InterpolatedProperty(T initial_value) :
|
||||
m_buffer(length),
|
||||
m_time_buffer(length),
|
||||
m_size(0), m_head(0) {
|
||||
for(int i = 0; i < length; i++) {
|
||||
m_buffer[i] = initial_value;
|
||||
m_time_buffer[i] = Clock::now();
|
||||
@@ -23,7 +29,21 @@ public:
|
||||
GET_REF(m_buffer, values);
|
||||
GET_REF(m_time_buffer, times);
|
||||
|
||||
/** How many slots hold something that was pushed. */
|
||||
inline uint32_t size() const {
|
||||
return m_size;
|
||||
}
|
||||
|
||||
void push(Clock::time_point point, T value) {
|
||||
// m_buffer[m_head] = std::move(value);
|
||||
// m_time_buffer[m_head] = point;
|
||||
//
|
||||
// m_head = (m_head + 1) % m_buffer.size();
|
||||
//
|
||||
// if (m_size < m_buffer.size())
|
||||
// {
|
||||
// ++m_size;
|
||||
// }
|
||||
if(point < m_time_buffer.at(0)) {
|
||||
return;
|
||||
}
|
||||
@@ -64,6 +84,46 @@ public:
|
||||
}
|
||||
|
||||
inline std::tuple<T, T, float> get_values_around(Clock::time_point point) const {
|
||||
/* const std::size_t capacity = m_buffer.size();
|
||||
const std::size_t oldest = (m_head + capacity - m_size) % capacity;
|
||||
|
||||
if (m_size == 0)
|
||||
{
|
||||
return { T(), T(), 0.0f };
|
||||
}
|
||||
|
||||
// Asking for a time the buffer no longer reaches back to. Standing on
|
||||
// the oldest is wrong by however much was missed, but it is wrong in
|
||||
// the direction the values were heading, unlike the newest.
|
||||
if (point < m_time_buffer[oldest])
|
||||
{
|
||||
return { m_buffer[oldest], m_buffer[oldest], 0.0f };
|
||||
}
|
||||
|
||||
for (std::size_t i = 0; i + 1 < m_size; ++i)
|
||||
{
|
||||
const std::size_t idx0 = (oldest + i) % capacity;
|
||||
const std::size_t idx1 = (oldest + i + 1) % capacity;
|
||||
|
||||
const auto t0 = m_time_buffer[idx0];
|
||||
const auto t1 = m_time_buffer[idx1];
|
||||
|
||||
if (point >= t0 && point <= t1)
|
||||
{
|
||||
const auto total =
|
||||
std::chrono::duration<float>(t1 - t0).count();
|
||||
const auto elapsed =
|
||||
std::chrono::duration<float>(point - t0).count();
|
||||
|
||||
const float alpha = total > 0.0f ? elapsed / total : 0.0f;
|
||||
|
||||
return { m_buffer[idx0], m_buffer[idx1], alpha };
|
||||
}
|
||||
}
|
||||
|
||||
// Handle exact last sample (or clamp)
|
||||
const std::size_t last = (oldest + m_size - 1) % capacity;
|
||||
return { m_buffer[last], m_buffer[last], 0.0f }; */
|
||||
T prev = m_buffer.at(0);
|
||||
Clock::time_point prev_point = m_time_buffer.at(0);
|
||||
|
||||
|
||||
@@ -151,6 +151,10 @@ void ReplicatorClient::handle_snapshot_entity(
|
||||
// return;
|
||||
}
|
||||
|
||||
// Applies correction from record_frame_idx to current_frame_idx (if any) on entity. Compares previous position on
|
||||
// frame record_frame_idx with p
|
||||
m_rollback.apply_correction(record_frame_idx, p, entity.value(), &m_world->registry(), current_frame_idx);
|
||||
|
||||
//snap_player_to(current_frame_idx, entity.value(), p);
|
||||
|
||||
// bool reconcile_happened = m_reconciler.reconcile(record_frame_idx, p, entity.value(), &m_world->registry(), current_frame_idx);
|
||||
@@ -168,15 +172,29 @@ void ReplicatorClient::handle_snapshot_entity(
|
||||
// }
|
||||
// m_network_metrics->record_ack_lag(ack_lag);
|
||||
// }
|
||||
}
|
||||
|
||||
} else {
|
||||
m_entity_interpolator->set_position(std::chrono::high_resolution_clock::now(), entity.value(), p);
|
||||
}
|
||||
}
|
||||
|
||||
void ReplicatorClient::handle_snapshot(uint32_t current_frame_idx, serial::WorldStateReader& reader) {
|
||||
auto header = reader.read_header();
|
||||
measure_response_time(current_frame_idx, header.frame_idx);
|
||||
|
||||
// Snapshots are sent unreliably, so one can overtake another. Taking a
|
||||
// late one would put entities back where they have already been seen.
|
||||
if(header.tick_idx <= m_latest_tick_idx) {
|
||||
return;
|
||||
}
|
||||
|
||||
m_latest_tick_idx = header.tick_idx;
|
||||
|
||||
// Arrival is the only time the client can be sure of, and it grows with
|
||||
// every snapshot, which is what reading the buffer back relies on. What it
|
||||
// costs is spacing: the gaps between samples carry the jitter of the way
|
||||
// here rather than the even gaps the server sent them on.
|
||||
const auto taken_at = std::chrono::high_resolution_clock::now();
|
||||
|
||||
while(reader.has_entity()) {
|
||||
auto entity_r = reader.read_entity();
|
||||
|
||||
|
||||
@@ -86,6 +86,7 @@ private:
|
||||
net::PlayerRollback m_rollback;
|
||||
World *m_world;
|
||||
net::ServerConnection* m_server_connection;
|
||||
uint32_t m_latest_tick_idx = 0;
|
||||
|
||||
std::optional<entt::entity> m_player_entity;
|
||||
|
||||
|
||||
@@ -50,7 +50,12 @@ void ThirdPersonPlayerController::update(const tw::io::InputManager* input, doub
|
||||
m_camera_rotation = yaw * m_camera_rotation * pitch;
|
||||
|
||||
glm::vec3 offset = m_camera_rotation * glm::vec3(0, 0, m_camera_zoom);
|
||||
m_camera->view().look_at(get_target_position() + offset, get_target_position());
|
||||
|
||||
glm::vec3 from = m_camera->view().position();
|
||||
glm::vec3 to = get_target_position() + offset;
|
||||
glm::vec3 interpolated = glm::mix(from, to, 0.8f);
|
||||
|
||||
m_camera->view().look_at(interpolated, get_target_position());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -25,8 +25,6 @@
|
||||
|
||||
namespace tw {
|
||||
|
||||
typedef HistoryBuffer<long, glm::vec3> EntityPositionHistory;
|
||||
|
||||
entt::entity
|
||||
ClientWorldController::create_entity(const std::string& name, glm::vec3 position) {
|
||||
const auto entity = m_world->registry().create();
|
||||
@@ -82,8 +80,8 @@ void ClientWorldController::spawn_entity(const std::string& name, uint32_t serve
|
||||
if(m_controlled_server_id.has_value() && m_controlled_server_id.value() == server_id) {
|
||||
try_bind_player_entity();
|
||||
} else {
|
||||
}
|
||||
m_interpolator.register_entity(entity);
|
||||
}
|
||||
}
|
||||
|
||||
ClientWorldController::ClientWorldController(
|
||||
@@ -165,9 +163,9 @@ void ClientWorldController::try_bind_player_entity() {
|
||||
position
|
||||
));
|
||||
|
||||
// if(m_world->registry().all_of<net::EntityPositionInterpolation>(entity)) {
|
||||
// m_world->registry().remove<net::EntityPositionInterpolation>(entity);
|
||||
// }
|
||||
if(m_world->registry().all_of<net::EntityPositionInterpolation>(entity)) {
|
||||
m_world->registry().remove<net::EntityPositionInterpolation>(entity);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -187,7 +185,6 @@ void ClientWorldController::update_network() {
|
||||
});
|
||||
|
||||
|
||||
m_replicator_client.record_prediction(m_frame_idx);
|
||||
|
||||
|
||||
// m_world->registry().view<Transform>()
|
||||
@@ -216,10 +213,11 @@ void ClientWorldController::update(double delta_time) {
|
||||
if(controller) {
|
||||
// controller->set_input(m_network_frame_idx, input);
|
||||
m_replicator_client.set_input(m_network_frame_idx, input);
|
||||
m_replicator_client.record_prediction(m_network_frame_idx);
|
||||
}
|
||||
}
|
||||
|
||||
// m_physics_world->step(m_network_frame_idx, JoltPhysicsWorld::FIXED_DELTA_TIME, true);
|
||||
m_physics_world->step(m_network_frame_idx, JoltPhysicsWorld::FIXED_DELTA_TIME, true);
|
||||
m_network_frame_idx++;
|
||||
}
|
||||
|
||||
@@ -230,7 +228,7 @@ void ClientWorldController::update(double delta_time) {
|
||||
// TODO: The third person controller could pull
|
||||
Transform* player_transform = m_world->registry().try_get<Transform>(m_player_entity.value());
|
||||
if(player_transform) {
|
||||
m_player_controller.set_target(player_transform->position());
|
||||
// m_player_controller.set_target(player_transform->position());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ class WorldStateWriter {
|
||||
public:
|
||||
explicit WorldStateWriter(BinaryBuffer& buf) noexcept : m_w(buf) {}
|
||||
|
||||
void begin(uint32_t frame_idx, uint32_t message_type) noexcept {
|
||||
void begin(uint32_t frame_idx, uint32_t tick_idx, uint32_t message_type) noexcept {
|
||||
m_entity_count = 0;
|
||||
|
||||
// message_type — lets the receiver dispatch without peeking further
|
||||
@@ -35,6 +35,10 @@ public:
|
||||
// frame_idx
|
||||
m_w.encode<uint32_t>(frame_idx);
|
||||
|
||||
// tick_idx — counts the states that went out, so the receiver can tell
|
||||
// a late one from a new one. Says nothing about the frame answered.
|
||||
m_w.encode<uint32_t>(tick_idx);
|
||||
|
||||
// entity_count placeholder — patched when end() is called
|
||||
m_entity_count_offset = m_w.reserve_u32();
|
||||
}
|
||||
@@ -85,6 +89,7 @@ public:
|
||||
struct WorldStateHeader {
|
||||
uint32_t packet_type;
|
||||
uint32_t frame_idx;
|
||||
uint32_t tick_idx;
|
||||
uint32_t entity_count;
|
||||
};
|
||||
|
||||
@@ -109,10 +114,11 @@ public:
|
||||
explicit WorldStateReader(std::span<const std::byte> data) noexcept
|
||||
: m_r(data) {}
|
||||
|
||||
/** Read the 12-byte header. Must be called first. */
|
||||
/** Read the header. Must be called first. */
|
||||
WorldStateHeader read_header() noexcept {
|
||||
// m_header.packet_type = m_r.decode<uint32_t>();
|
||||
m_header.frame_idx = m_r.decode<uint32_t>();
|
||||
m_header.tick_idx = m_r.decode<uint32_t>();
|
||||
m_header.entity_count = m_r.decode<uint32_t>();
|
||||
|
||||
// spawn count follows immediately
|
||||
|
||||
@@ -62,6 +62,9 @@ void ZoneServer::player_update_handler(SessionId session_id, mmo::PlayerMoveMess
|
||||
if (it == m_session_zone.end()) return;
|
||||
|
||||
if (auto* session = m_player_session_registry->session(session_id)) {
|
||||
if(session->last_received_frame >= message.frame_idx()) {
|
||||
return;
|
||||
}
|
||||
session->last_received_frame = message.frame_idx();
|
||||
}
|
||||
|
||||
@@ -141,7 +144,7 @@ void ZoneServer::run() {
|
||||
}
|
||||
}
|
||||
|
||||
m_replicator->replicate(zone->registry(), zone->interest());
|
||||
m_replicator->replicate(frame_idx, zone->registry(), zone->interest());
|
||||
}
|
||||
|
||||
m_network_receiver->update();
|
||||
|
||||
@@ -29,8 +29,8 @@ class StateReplicator {
|
||||
// Per-client backing buffers reused every frame.
|
||||
std::vector<tw::serial::BinaryBuffer> m_frames;
|
||||
|
||||
// Header(16) + spawn_hdr(4) + despawn_hdr(4) + 512 entities × 16 bytes
|
||||
static constexpr std::size_t kHeaderCapacity = 24;
|
||||
// Header(20) + spawn_hdr(4) + despawn_hdr(4) + 512 entities × 16 bytes
|
||||
static constexpr std::size_t kHeaderCapacity = 28;
|
||||
static constexpr std::size_t kInitialCapacity = kHeaderCapacity + 512 * 16;
|
||||
|
||||
public:
|
||||
@@ -46,6 +46,7 @@ public:
|
||||
* Replicates the current world state for one zone to its connected clients.
|
||||
*/
|
||||
void replicate(
|
||||
uint32_t frame_idx,
|
||||
const entt::registry& registry,
|
||||
const im::InterestSystem<Backend>* interest_manager
|
||||
) {
|
||||
@@ -88,7 +89,8 @@ public:
|
||||
|
||||
m_frames[i].reserve(needed);
|
||||
writers[i].reset();
|
||||
writers[i].begin(session->last_received_frame, Message<mmo::WorldStateMessage>::value);
|
||||
writers[i].begin(session->last_received_frame, frame_idx,
|
||||
Message<mmo::WorldStateMessage>::value);
|
||||
writers[i].write_spawns(state->spawn());
|
||||
writers[i].write_despawns(state->despawn());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user