Skip to content

Commit

Permalink
FPS mouse input, UI fixes, and Movement Netcode updates (#188)
Browse files Browse the repository at this point in the history
* comments!, movement smoothing, FPS input, some net sync fixes, UI fixes and additions

Adds headers to CMake generated projects so they show up in IDEs
changes game-state-queue into separate client and server implementation
Adds tracking for netsync info and puts it on the UI
Spelling and Code style adjustments throughout
"Fixes" two race conditions
Adds cursor capture (mouse lock), and turns the mouse look into a toggle that uses it
Changes motion handling to be a bit smoother.
Motion is still missing proper restitution/extrapolation
Adds timestamps to state updates, this might be useful later
Fixes dragging things on the UI, it actually works now
Terminals no longer steal focus when mouse look is toggled
Adds (possibly helpful) comments

* A better fix for input and mouse capture

* Further adjustments and fixes to prediction

* named constants and some clean up
  • Loading branch information
Meisaka authored Feb 16, 2021
1 parent 175168b commit e33a4c0
Show file tree
Hide file tree
Showing 41 changed files with 972 additions and 538 deletions.
8 changes: 7 additions & 1 deletion client/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ endif ()
set(trillek-client_SOURCES # don't include main.cpp to keep it out of tests
${trillek-client_SOURCE_DIR}/game.cpp
${trillek-client_SOURCE_DIR}/imgui-system.cpp
${trillek-client_SOURCE_DIR}/client-game-state-queue.cpp
${trillek-client_SOURCE_DIR}/os.cpp
${trillek-client_SOURCE_DIR}/render-system.cpp
${trillek-client_SOURCE_DIR}/server-connection.cpp
Expand All @@ -48,6 +49,11 @@ set(trillek-client_SOURCES # don't include main.cpp to keep it out of tests

set(trillek-client_SOURCES ${trillek-client_SOURCES} PARENT_SCOPE) # so tests can use them

# Used by IDEs to nicely view header files
file(GLOB_RECURSE trillek-client_HEADERS LIST_DIRECTORIES false ${trillek-client_SOURCE_DIR}/*.hpp)
source_group(Headers FILES ${trillek-client_HEADERS})
set_property(SOURCE ${trillek-client_HEADERS} PROPERTY HEADER_FILE_ONLY true)

# macOS needs to link libobjc
set(OSX_LIBRARIES "")

Expand All @@ -69,7 +75,7 @@ if (UNIX AND NOT APPLE)
find_package(X11 REQUIRED)
endif (UNIX AND NOT APPLE)

add_executable(${trillek-client_PROGRAM} ${trillek-client_SOURCE_DIR}/main.cpp ${trillek-client_SOURCES})
add_executable(${trillek-client_PROGRAM} ${trillek-client_SOURCE_DIR}/main.cpp ${trillek-client_SOURCES} ${trillek-client_HEADERS})

if (MSVC)
set_property(TARGET ${trillek-client_PROGRAM} APPEND_STRING PROPERTY COMPILE_FLAGS /W4)
Expand Down
233 changes: 233 additions & 0 deletions client/client-game-state-queue.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
#include "client-game-state-queue.hpp"
#include "simulation.hpp"

#include <glm/gtx/compatibility.hpp>

#include "components/transforms.hpp"

namespace tec {

ClientGameStateQueue::ClientGameStateQueue(ServerStats& s) : stats(s) {}

void ClientGameStateQueue::Interpolate(const double delta_time) {
std::lock_guard<std::mutex> lg(this->server_state_mutex);
stats.server_state_count = this->server_states.size();
if (this->server_states.size() > 10) { // so many!
std::cout << "getting flooded by state updates" << std::endl;
}
if (this->server_states.size() < 1) { // no states means nothing to do here
return;
}
// this should be the rate at which we receive updates from the server
// used to estimate how far we are before the next update gets received
const double INTERPOLATION_RATE = UPDATE_RATE;
interpolation_accumulator += delta_time;
const GameState& to_state = this->server_states.front();
stats.last_state_time = to_state.timestamp;

// the interpolation_accumulator could be thought of as where we are between two states
// if the incoming states are timestamped, it would probably be better to use that
// instead of guessing like this
if (interpolation_accumulator >= INTERPOLATION_RATE) {
// remove a state from the queue and make it the base state
// also initialize the interpolated state with it
for (auto position : to_state.positions) {
this->base_state.positions[position.first] = position.second;
this->interpolated_state.positions[position.first] = position.second;
}
for (auto velocity : to_state.velocities) {
this->base_state.velocities[velocity.first] = velocity.second;
this->interpolated_state.velocities[velocity.first] = velocity.second;
}
for (auto orientation : to_state.orientations) {
this->base_state.orientations[orientation.first] = orientation.second;
this->interpolated_state.orientations[orientation.first] = orientation.second;
}
// the client controlled entities can use the predicted state
if (this->client_id != 0) {
auto itr = this->predictions.find(this->command_id);
if (itr != this->predictions.end()) {
this->base_state.positions[this->client_id] = itr->second.positions[this->client_id];
this->base_state.velocities[this->client_id] = itr->second.velocities[this->client_id];
this->interpolated_state.positions[this->client_id] = itr->second.positions[this->client_id];
this->interpolated_state.velocities[this->client_id] = itr->second.velocities[this->client_id];
}
}

// when too many states, discard the rest! what could possibly go wrong!?
// we just interpolate between them anyways
while (interpolation_accumulator >= INTERPOLATION_RATE) {
interpolation_accumulator -= INTERPOLATION_RATE;
this->base_state.state_id = to_state.state_id;
this->server_states.pop();
}
}
else {
// figure out how many states we are behind
// this should likely use timestamps instead...
float lerp_percent = static_cast<float>(interpolation_accumulator / (INTERPOLATION_RATE * (to_state.state_id - this->base_state.state_id)));
// if this is zero, then we don't write to the interpolated state?
// that seem weird
if (lerp_percent > 0.0) {
// in each of these sections we iterate through all the items in the new state
// then we Lerp from the base state to the new state
for (auto position : to_state.positions) {
auto base_position_iter = this->base_state.positions.find(position.first);
if (base_position_iter != this->base_state.positions.end()) {
this->interpolated_state.positions[position.first].value =
glm::lerp(base_position_iter->second.value, position.second.value, lerp_percent);
}
else {
this->interpolated_state.positions[position.first] = position.second;
}
}
for (auto velocity : to_state.velocities) {
auto base_velocity_iter = this->base_state.velocities.find(velocity.first);
if (base_velocity_iter != this->base_state.velocities.end()) {
this->interpolated_state.velocities[velocity.first].linear =
glm::lerp(base_velocity_iter->second.linear, velocity.second.linear, lerp_percent);
this->interpolated_state.velocities[velocity.first].angular =
glm::lerp(base_velocity_iter->second.angular, velocity.second.angular, lerp_percent);
}
else {
this->interpolated_state.velocities[velocity.first].linear = velocity.second.linear;
this->interpolated_state.velocities[velocity.first].angular = velocity.second.angular;
}
}
// Quaternions, we use a slerp here instead
for (auto orientation : to_state.orientations) {
auto base_orientation_iter = this->base_state.orientations.find(orientation.first);
if (base_orientation_iter != this->base_state.orientations.end()) {
this->interpolated_state.orientations[orientation.first].value =
glm::slerp(base_orientation_iter->second.value, orientation.second.value, lerp_percent);
}
else {
this->interpolated_state.orientations[orientation.first] = orientation.second;
}
}
}
}
}

void ClientGameStateQueue::ProcessEventQueue() {
EventQueue<EntityCreated>::ProcessEventQueue();
EventQueue<EntityDestroyed>::ProcessEventQueue();
EventQueue<NewGameStateEvent>::ProcessEventQueue();
}

void ClientGameStateQueue::On(std::shared_ptr<NewGameStateEvent> data) {
QueueServerState(std::move(data->new_state));
}

void ClientGameStateQueue::QueueServerState(GameState&& new_state) {
if (new_state.state_id > this->last_server_state_id) {
this->server_states_array[server_state_array_index % SERVER_STATES_ARRAY_SIZE] = new_state;
server_state_array_index++;
this->last_server_state_id = new_state.state_id;
std::lock_guard<std::mutex> lg(this->server_state_mutex);
CheckPredictionResult(new_state);
this->server_states.emplace(std::move(new_state));
}
}

void ClientGameStateQueue::CheckPredictionResult(GameState& new_state) {
if (this->client_id == 0) {
return;
}
this->stats.current_acked_id = new_state.command_id;
// Buffer past one incoming state
// FIXME we don't actually know when the server applies the commands it ACKs!
// so which state we should consider for the error reference is highly ping dependant
// this would require changes to how the server reports movement commands
// for now, waiting one state seems good enough, at least on localhost, over the net is different...
glm::vec3 state_pos = this->stats.server_position_next;
this->stats.server_position = this->stats.server_position_next;
this->stats.server_position_next = new_state.positions[this->client_id].value;

glm::vec3 position_diff = glm::vec3();
glm::vec3 velocity_diff = glm::vec3();

// delete from the prediction queue anything that's not newer than the latest ack
for (auto itr = this->predictions.begin(); itr != this->predictions.end(); ) {
if (itr->first <= new_state.command_id) {
// if this was an acked command, calculate the difference between where we thought we were
// this gives feedback on how good our prediction was
if (itr->first == new_state.command_id) {
glm::vec3 predict_pos = itr->second.positions[this->client_id].value;
position_diff = state_pos - predict_pos;
glm::vec3 state_vel = new_state.velocities[this->client_id].linear;
glm::vec3 predict_vel = itr->second.velocities[this->client_id].linear;
velocity_diff = state_vel - predict_vel;
}
itr = this->predictions.erase(itr);
continue;
}
++itr;
}
// debugging stats, also used by reconciliation
stats.client_position = position_diff;
stats.client_velocity = velocity_diff;
}

/** \brief Explicitly update the current prediction
*/
void ClientGameStateQueue::UpdatePredictions(GameState& new_state) {
if (this->client_id == 0) {
return;
}
this->stats.current_command_id = this->command_id;
GameState predict_state;
// basic reconciliation, attempt to resolve errors by nudging current predictions
predict_state.positions[this->client_id].value = new_state.positions[this->client_id].value + stats.client_position * 0.125f;
predict_state.velocities[this->client_id] = new_state.velocities[this->client_id];
predict_state.orientations[this->client_id] = new_state.orientations[this->client_id];
this->predictions.emplace(std::make_pair(this->command_id, predict_state));
}

void ClientGameStateQueue::On(std::shared_ptr<EntityCreated> data) {
const proto::Entity& entity = data->entity;
eid entity_id = entity.id();
for (int i = 0; i < entity.components_size(); ++i) {
const proto::Component& comp = entity.components(i);
switch (comp.component_case()) {
case proto::Component::kPosition:
{
Position pos;
pos.In(comp);
this->interpolated_state.positions[entity_id] = pos;
this->base_state.positions[entity_id] = pos;
break;
}
case proto::Component::kOrientation:
{
Orientation orientation;
orientation.In(comp);
this->interpolated_state.orientations[entity_id] = orientation;
this->base_state.orientations[entity_id] = orientation;
break;
}
case proto::Component::kVelocity:
{
Velocity vel;
vel.In(comp);
this->interpolated_state.velocities[entity_id] = vel;
this->base_state.velocities[entity_id] = vel;
break;
}
default:
break;
}
}
}

void ClientGameStateQueue::On(std::shared_ptr<EntityDestroyed> data) {
const eid entity_id = data->entity_id;
this->interpolated_state.positions.erase(entity_id);
this->base_state.positions.erase(entity_id);
this->interpolated_state.orientations.erase(entity_id);
this->base_state.orientations.erase(entity_id);
this->interpolated_state.velocities.erase(entity_id);
this->base_state.velocities.erase(entity_id);
}

} // end namespace tec
81 changes: 81 additions & 0 deletions client/client-game-state-queue.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
#pragma once

#include <queue>
#include <iostream>
#include <mutex>
#include <memory>

#include "event-queue.hpp"
#include "event-system.hpp"
#include "game-state.hpp"
#include "server-stats.hpp"
#include "tec-types.hpp"

namespace tec {

class ClientGameStateQueue :
public EventQueue<EntityCreated>,
public EventQueue<EntityDestroyed>,
public EventQueue<NewGameStateEvent> {
public:
ClientGameStateQueue(ServerStats& s);

void Interpolate(const double delta_time);

void QueueServerState(GameState&& new_state);

void UpdatePredictions(GameState& new_state);

void CheckPredictionResult(GameState& new_state);

void ProcessEventQueue();

void SetClientID(eid _client_id) {
this->client_id = _client_id;
}

void SetCommandID(state_id_t _command_id) {
this->command_id = _command_id;
}

using EventQueue<EntityCreated>::On;
using EventQueue<EntityDestroyed>::On;
using EventQueue<NewGameStateEvent>::On;
virtual void On(std::shared_ptr<EntityCreated> data);
virtual void On(std::shared_ptr<EntityDestroyed> data);
virtual void On(std::shared_ptr<NewGameStateEvent> data);

GameState& GetInterpolatedState() {
return this->interpolated_state;
}

GameState& GetBaseState() {
return this->base_state;
}

void SetBaseState(GameState&& new_state) {
this->base_state = std::move(new_state);
}

GameState* GetGameState(int offset) {
return &this->server_states_array[(server_state_array_index - offset) % SERVER_STATES_ARRAY_SIZE];
}
private:
static const unsigned int SERVER_STATES_ARRAY_SIZE{ 5 };

GameState server_states_array[SERVER_STATES_ARRAY_SIZE];
int server_state_array_index{ SERVER_STATES_ARRAY_SIZE - 1 };

ServerStats& stats;
GameState base_state;
GameState interpolated_state;
std::queue<GameState> server_states;
std::mutex server_state_mutex;
state_id_t last_server_state_id{ 0 };
state_id_t command_id{ 0 };
double interpolation_accumulator{ 0.0 };
eid client_id{ 0 };
std::map<state_id_t, GameState> predictions;
};

} // end namespace tec
Loading

0 comments on commit e33a4c0

Please sign in to comment.