#1 - quicr module
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
#include "ClientArgs.hpp"
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <charconv>
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
|
||||
namespace tw::app {
|
||||
|
||||
namespace {
|
||||
|
||||
std::string_view trim(std::string_view str) {
|
||||
// Skip leading whitespace
|
||||
size_t start = 0;
|
||||
while(start < str.length() && std::isspace(static_cast<unsigned char>(str[start]))) {
|
||||
++start;
|
||||
}
|
||||
|
||||
// Skip trailing whitespace
|
||||
size_t end = str.length();
|
||||
while(end > start && std::isspace(static_cast<unsigned char>(str[end - 1]))) {
|
||||
--end;
|
||||
}
|
||||
|
||||
return str.substr(start, end - start);
|
||||
}
|
||||
|
||||
bool is_valid_ipv4(std::string_view ip_str) {
|
||||
// Use inet_pton to validate IPv4 format
|
||||
struct in_addr addr;
|
||||
return inet_pton(AF_INET, std::string(ip_str).c_str(), &addr) == 1;
|
||||
}
|
||||
|
||||
tl::expected<int, std::string> parse_port(std::string_view port_str) {
|
||||
if(port_str.empty()) {
|
||||
return 8080; // Default port
|
||||
}
|
||||
|
||||
int port = 0;
|
||||
const char* end = port_str.data() + port_str.length();
|
||||
auto result = std::from_chars(port_str.data(), end, port);
|
||||
|
||||
// from_chars stops at the first character it cannot use, so a partially
|
||||
// numeric port like "80x" would otherwise be accepted as 80.
|
||||
if(result.ec != std::errc() || result.ptr != end) {
|
||||
return tl::make_unexpected("port must be numeric");
|
||||
}
|
||||
|
||||
if(port < 1 || port > 65535) {
|
||||
return tl::make_unexpected("port must be between 1 and 65535");
|
||||
}
|
||||
|
||||
return port;
|
||||
}
|
||||
|
||||
} // anonymous namespace
|
||||
|
||||
tl::expected<net::Address, std::string> parse_address(std::string_view text) {
|
||||
// Trim whitespace
|
||||
text = trim(text);
|
||||
|
||||
if(text.empty()) {
|
||||
return tl::make_unexpected("address cannot be empty");
|
||||
}
|
||||
|
||||
// Find the colon to split host and port
|
||||
size_t colon_pos = text.rfind(':');
|
||||
|
||||
std::string_view host;
|
||||
std::string_view port_str;
|
||||
|
||||
if(colon_pos == std::string_view::npos) {
|
||||
// No colon found: treat entire string as port or host
|
||||
// If it's all digits, treat as port; otherwise as host (will fail validation)
|
||||
bool all_digits = !text.empty() && std::all_of(text.begin(), text.end(),
|
||||
[](unsigned char c) { return std::isdigit(c); });
|
||||
|
||||
if(all_digits) {
|
||||
host = "127.0.0.1";
|
||||
port_str = text;
|
||||
} else {
|
||||
// Treat as host with no port
|
||||
host = text;
|
||||
port_str = "";
|
||||
}
|
||||
} else {
|
||||
host = text.substr(0, colon_pos);
|
||||
port_str = text.substr(colon_pos + 1);
|
||||
}
|
||||
|
||||
// Validate host
|
||||
if(host.empty()) {
|
||||
return tl::make_unexpected("host cannot be empty");
|
||||
}
|
||||
|
||||
if(!is_valid_ipv4(host)) {
|
||||
return tl::make_unexpected("not a valid IPv4 address");
|
||||
}
|
||||
|
||||
// Parse port
|
||||
auto port_result = parse_port(port_str);
|
||||
if(!port_result) {
|
||||
return tl::make_unexpected(port_result.error());
|
||||
}
|
||||
|
||||
int port = port_result.value();
|
||||
return net::Address(std::optional<std::string>(std::string(host)), port);
|
||||
}
|
||||
|
||||
std::optional<std::string> server_arg(int argc, char** argv) {
|
||||
for(int i = 1; i < argc - 1; ++i) {
|
||||
if(std::string_view(argv[i]) == "--server") {
|
||||
return std::string(argv[i + 1]);
|
||||
}
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
} // namespace tw::app
|
||||
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
|
||||
#include <string_view>
|
||||
#include <string>
|
||||
#include <optional>
|
||||
#include <tl/expected.hpp>
|
||||
|
||||
#include "Address.hpp"
|
||||
|
||||
namespace tw::app {
|
||||
|
||||
/**
|
||||
* Parse an address string into a network address.
|
||||
*
|
||||
* Accepts "host:port" or a bare port number. Bare port uses 127.0.0.1.
|
||||
* Missing port defaults to 8080. Trims surrounding whitespace.
|
||||
* Validates the host with inet_pton and returns an error string
|
||||
* for non-IPv4 addresses or invalid ports.
|
||||
*/
|
||||
tl::expected<net::Address, std::string> parse_address(std::string_view text);
|
||||
|
||||
/**
|
||||
* Extract the --server argument value from the command line.
|
||||
*
|
||||
* Scans argv for --server and returns the following argument,
|
||||
* or nothing if the flag is absent or has no value.
|
||||
*/
|
||||
std::optional<std::string> server_arg(int argc, char** argv);
|
||||
|
||||
} // namespace tw::app
|
||||
@@ -0,0 +1,31 @@
|
||||
#include "FavouriteServers.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace tw::app {
|
||||
|
||||
FavouriteServers::FavouriteServers()
|
||||
: FileAddressList("favourite_servers.txt") {
|
||||
}
|
||||
|
||||
const char* FavouriteServers::name() const {
|
||||
return "Favourites";
|
||||
}
|
||||
|
||||
void FavouriteServers::add(const std::string& entry) {
|
||||
// Check if already present
|
||||
auto it = std::find(m_entries.begin(), m_entries.end(), entry);
|
||||
if(it != m_entries.end()) {
|
||||
return; // Already present, do nothing
|
||||
}
|
||||
|
||||
// Append at the end
|
||||
m_entries.push_back(entry);
|
||||
|
||||
// Cap at MAX_ENTRIES
|
||||
if(m_entries.size() > MAX_ENTRIES) {
|
||||
m_entries.resize(MAX_ENTRIES);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace tw::app
|
||||
@@ -0,0 +1,33 @@
|
||||
#pragma once
|
||||
|
||||
#include "FileAddressList.hpp"
|
||||
|
||||
namespace tw::app {
|
||||
|
||||
/**
|
||||
* Manages a list of favourite server addresses.
|
||||
*
|
||||
* Persists addresses to a text file, one "ip:port" per line.
|
||||
* Appended in the order they are added, de-duplicated,
|
||||
* capped at 32 entries. File path is
|
||||
* $XDG_CONFIG_HOME/towards/favourite_servers.txt, falling back to
|
||||
* $HOME/.config/towards/favourite_servers.txt. If both variables are
|
||||
* unset, keeps the list in memory only.
|
||||
*/
|
||||
class FavouriteServers : public FileAddressList {
|
||||
static constexpr size_t MAX_ENTRIES = 32;
|
||||
|
||||
public:
|
||||
FavouriteServers();
|
||||
|
||||
const char* name() const override;
|
||||
|
||||
/**
|
||||
* Add an address to the favourites list.
|
||||
* Appended at the end if not already present, capped at 32.
|
||||
* Does not persist to disk; call save() after modifying.
|
||||
*/
|
||||
void add(const std::string& entry) override;
|
||||
};
|
||||
|
||||
} // namespace tw::app
|
||||
@@ -0,0 +1,110 @@
|
||||
#include "FileAddressList.hpp"
|
||||
|
||||
#include <spdlog/spdlog.h>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <cstdlib>
|
||||
#include <algorithm>
|
||||
|
||||
namespace tw::app {
|
||||
|
||||
namespace {
|
||||
|
||||
std::string get_config_dir() {
|
||||
// Try XDG_CONFIG_HOME first
|
||||
const char* xdg_config_home = std::getenv("XDG_CONFIG_HOME");
|
||||
if(xdg_config_home && xdg_config_home[0] != '\0') {
|
||||
return std::string(xdg_config_home) + "/towards";
|
||||
}
|
||||
|
||||
// Fall back to $HOME/.config/towards
|
||||
const char* home = std::getenv("HOME");
|
||||
if(home && home[0] != '\0') {
|
||||
return std::string(home) + "/.config/towards";
|
||||
}
|
||||
|
||||
// Both unset
|
||||
return "";
|
||||
}
|
||||
|
||||
} // anonymous namespace
|
||||
|
||||
FileAddressList::FileAddressList(const std::string& file_name) {
|
||||
std::string config_dir = get_config_dir();
|
||||
|
||||
if(config_dir.empty()) {
|
||||
spdlog::debug("XDG_CONFIG_HOME and HOME not set; address lists will not be persisted");
|
||||
m_can_save = false;
|
||||
return;
|
||||
}
|
||||
|
||||
m_path = config_dir + "/" + file_name;
|
||||
m_can_save = true;
|
||||
}
|
||||
|
||||
void FileAddressList::load() {
|
||||
if(m_path.empty()) {
|
||||
return; // No config path available
|
||||
}
|
||||
|
||||
std::ifstream file(m_path);
|
||||
if(!file.is_open()) {
|
||||
// File doesn't exist or can't be read; this is not an error
|
||||
return;
|
||||
}
|
||||
|
||||
m_entries.clear();
|
||||
std::string line;
|
||||
while(std::getline(file, line)) {
|
||||
// Trim whitespace from the line
|
||||
size_t start = line.find_first_not_of(" \t\r\n");
|
||||
size_t end = line.find_last_not_of(" \t\r\n");
|
||||
|
||||
if(start != std::string::npos) {
|
||||
line = line.substr(start, end - start + 1);
|
||||
if(!line.empty()) {
|
||||
m_entries.push_back(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void FileAddressList::save() const {
|
||||
if(!m_can_save || m_path.empty()) {
|
||||
return; // Cannot save without config path
|
||||
}
|
||||
|
||||
// Create the directory if needed
|
||||
std::filesystem::path config_path(m_path);
|
||||
std::filesystem::path config_dir = config_path.parent_path();
|
||||
|
||||
try {
|
||||
std::filesystem::create_directories(config_dir);
|
||||
} catch(const std::filesystem::filesystem_error&) {
|
||||
// If we can't create the directory, silently fail to save
|
||||
return;
|
||||
}
|
||||
|
||||
// Write entries to file
|
||||
std::ofstream file(m_path);
|
||||
if(!file.is_open()) {
|
||||
return; // Can't open file for writing; silently fail
|
||||
}
|
||||
|
||||
for(const auto& entry : m_entries) {
|
||||
file << entry << "\n";
|
||||
}
|
||||
}
|
||||
|
||||
void FileAddressList::remove(const std::string& entry) {
|
||||
auto it = std::find(m_entries.begin(), m_entries.end(), entry);
|
||||
if(it != m_entries.end()) {
|
||||
m_entries.erase(it);
|
||||
}
|
||||
}
|
||||
|
||||
const std::vector<std::string>& FileAddressList::entries() const {
|
||||
return m_entries;
|
||||
}
|
||||
|
||||
} // namespace tw::app
|
||||
@@ -0,0 +1,32 @@
|
||||
#pragma once
|
||||
|
||||
#include "ServerAddressProvider.hpp"
|
||||
#include <vector>
|
||||
#include <string>
|
||||
|
||||
namespace tw::app {
|
||||
|
||||
/**
|
||||
* Shared behaviour of address lists backed by a file.
|
||||
*
|
||||
* One "ip:port" per line. Resolves file path to
|
||||
* $XDG_CONFIG_HOME/towards/<file_name>, falling back to
|
||||
* $HOME/.config/towards/<file_name>.
|
||||
* add() stays pure virtual: subclasses define their own order.
|
||||
*/
|
||||
class FileAddressList : public ServerAddressProvider {
|
||||
protected:
|
||||
std::vector<std::string> m_entries;
|
||||
std::string m_path;
|
||||
bool m_can_save = false;
|
||||
|
||||
explicit FileAddressList(const std::string& file_name);
|
||||
|
||||
public:
|
||||
void load() override;
|
||||
void save() const override;
|
||||
void remove(const std::string& entry) override;
|
||||
const std::vector<std::string>& entries() const override;
|
||||
};
|
||||
|
||||
} // namespace tw::app
|
||||
@@ -0,0 +1,24 @@
|
||||
#pragma once
|
||||
|
||||
#include "debug/DebugWindowRegistry.hpp"
|
||||
#include "debug/metrics/NetworkMetrics.hpp"
|
||||
#include "draw/WorldRenderer.hpp"
|
||||
#include "io/InputState.hpp"
|
||||
#include "world/JoltPhysicsWorld.hpp"
|
||||
#include "world/World.hpp"
|
||||
|
||||
namespace tw::app {
|
||||
|
||||
/**
|
||||
* Bundle of runtime-owned subsystems that the game state needs.
|
||||
*/
|
||||
struct GameContext {
|
||||
tw::World* world;
|
||||
tw::JoltPhysicsWorld* physics_world;
|
||||
tw::drw::WorldRenderer* renderer;
|
||||
tw::io::InputManager* input_manager;
|
||||
tw::dbg::NetworkMetrics* metrics;
|
||||
tw::dbg::DebugWindowRegistry* debug_windows;
|
||||
};
|
||||
|
||||
} // namespace tw::app
|
||||
@@ -0,0 +1,34 @@
|
||||
#include "GameState.hpp"
|
||||
|
||||
namespace tw::app {
|
||||
|
||||
GameState::GameState(GameContext context, std::unique_ptr<tw::net::ServerConnection> connection)
|
||||
: m_context(context),
|
||||
m_connection(std::move(connection)),
|
||||
m_entity_gui(context.world),
|
||||
m_network_gui(*context.metrics)
|
||||
{
|
||||
m_controller = std::make_unique<tw::ClientWorldController>(
|
||||
m_context.input_manager,
|
||||
m_context.world,
|
||||
m_context.physics_world,
|
||||
m_context.renderer,
|
||||
m_connection.get(),
|
||||
m_context.metrics
|
||||
);
|
||||
|
||||
m_context.debug_windows->add(&m_entity_gui);
|
||||
m_context.debug_windows->add(&m_network_gui);
|
||||
}
|
||||
|
||||
GameState::~GameState() {
|
||||
m_context.debug_windows->remove(&m_entity_gui);
|
||||
m_context.debug_windows->remove(&m_network_gui);
|
||||
}
|
||||
|
||||
void GameState::update(double delta_time) {
|
||||
m_controller->update(delta_time);
|
||||
m_context.world->step(delta_time);
|
||||
}
|
||||
|
||||
} // namespace tw::app
|
||||
@@ -0,0 +1,43 @@
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
|
||||
#include "GameContext.hpp"
|
||||
#include "debug/tools/EntityManagerGui.hpp"
|
||||
#include "debug/tools/NetworkStatsGui.hpp"
|
||||
#include "network/ServerConnection.hpp"
|
||||
#include "world/ClientWorldController.hpp"
|
||||
|
||||
namespace tw::app {
|
||||
|
||||
/**
|
||||
* The game world state. Owns the connection, world controller, and debug GUIs.
|
||||
* Responsible for updating the game simulation and rendering debug information.
|
||||
*/
|
||||
class GameState {
|
||||
GameContext m_context;
|
||||
std::unique_ptr<tw::net::ServerConnection> m_connection;
|
||||
std::unique_ptr<tw::ClientWorldController> m_controller;
|
||||
tw::dbg::tools::EntityManagerGui m_entity_gui;
|
||||
tw::dbg::tools::NetworkStatsGui m_network_gui;
|
||||
|
||||
public:
|
||||
/**
|
||||
* Constructs the game state with the given context and connection.
|
||||
* The connection must be established before creating the game state.
|
||||
*/
|
||||
GameState(GameContext context, std::unique_ptr<tw::net::ServerConnection> connection);
|
||||
|
||||
/**
|
||||
* Takes the debug panels back out of the menu.
|
||||
*/
|
||||
~GameState();
|
||||
|
||||
/**
|
||||
* Updates the game state: draws debug GUIs, updates the controller,
|
||||
* and steps the world physics.
|
||||
*/
|
||||
void update(double delta_time);
|
||||
};
|
||||
|
||||
} // namespace tw::app
|
||||
@@ -0,0 +1,182 @@
|
||||
#include "LobbyState.hpp"
|
||||
|
||||
#include "ClientArgs.hpp"
|
||||
#include "imgui.h"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
namespace tw::app {
|
||||
|
||||
namespace {
|
||||
|
||||
const ImVec4 FAVOURITES_COLOUR{1.0f, 0.8f, 0.2f, 1.0f};
|
||||
const ImVec4 RECENT_COLOUR{0.7f, 0.7f, 0.7f, 1.0f};
|
||||
|
||||
}
|
||||
|
||||
LobbyState::LobbyState(std::optional<tw::net::Address> auto_connect) {
|
||||
m_recent.load();
|
||||
m_favourites.load();
|
||||
|
||||
set_address_input(m_recent.entries().empty()
|
||||
? "127.0.0.1:8080"
|
||||
: m_recent.entries().front());
|
||||
|
||||
if(auto_connect) {
|
||||
begin_connect(*auto_connect);
|
||||
}
|
||||
}
|
||||
|
||||
void LobbyState::set_address_input(const std::string& address) {
|
||||
std::snprintf(m_address_input, sizeof(m_address_input), "%s", address.c_str());
|
||||
}
|
||||
|
||||
void LobbyState::begin_connect(tw::net::Address address) {
|
||||
m_error.clear();
|
||||
m_connection = std::make_unique<tw::net::ServerConnection>(address);
|
||||
|
||||
auto started = m_connection->start();
|
||||
if(!started) {
|
||||
m_error = started.error().message();
|
||||
m_connection.reset();
|
||||
}
|
||||
}
|
||||
|
||||
void LobbyState::draw_form() {
|
||||
const float input_width = 200.0f;
|
||||
|
||||
ImGui::SetNextItemWidth(input_width);
|
||||
bool submitted = ImGui::InputText("##address", m_address_input, sizeof(m_address_input),
|
||||
ImGuiInputTextFlags_EnterReturnsTrue);
|
||||
|
||||
ImGui::SameLine();
|
||||
submitted |= ImGui::Button("Connect");
|
||||
|
||||
if(!submitted) {
|
||||
return;
|
||||
}
|
||||
|
||||
auto parsed = parse_address(m_address_input);
|
||||
if(parsed) {
|
||||
begin_connect(*parsed);
|
||||
} else {
|
||||
m_error = parsed.error();
|
||||
}
|
||||
}
|
||||
|
||||
void LobbyState::draw_favourite_toggle(const std::string& entry) {
|
||||
const char* label = m_favourites.contains(entry) ? "[*]" : "[ ]";
|
||||
|
||||
// The label alone would collide between the rows drawn in one frame, so the
|
||||
// entry it acts on is what identifies the button.
|
||||
std::string button_id = std::string(label) + "##fav_" + entry;
|
||||
|
||||
if(ImGui::SmallButton(button_id.c_str())) {
|
||||
toggle_favourite(entry);
|
||||
}
|
||||
}
|
||||
|
||||
void LobbyState::toggle_favourite(const std::string& entry) {
|
||||
if(m_favourites.contains(entry)) {
|
||||
m_favourites.remove(entry);
|
||||
} else {
|
||||
m_favourites.add(entry);
|
||||
}
|
||||
m_favourites.save();
|
||||
}
|
||||
|
||||
void LobbyState::draw_provider(ServerAddressProvider& provider, const ImVec4& header_colour) {
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, header_colour);
|
||||
ImGui::SeparatorText(provider.name());
|
||||
ImGui::PopStyleColor();
|
||||
|
||||
// BeginChild is one of the two calls whose End must run even when it
|
||||
// returns false, so the result only decides whether rows are submitted.
|
||||
if(ImGui::BeginChild(provider.name(), ImVec2(0, 120), ImGuiChildFlags_Borders)) {
|
||||
for(const auto& entry : provider.entries()) {
|
||||
draw_favourite_toggle(entry);
|
||||
ImGui::SameLine();
|
||||
|
||||
if(ImGui::Selectable(entry.c_str(), false)) {
|
||||
set_address_input(entry);
|
||||
}
|
||||
|
||||
if(ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(0)) {
|
||||
auto parsed = parse_address(entry);
|
||||
if(parsed) {
|
||||
begin_connect(*parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::EndChild();
|
||||
}
|
||||
|
||||
void LobbyState::draw_status() {
|
||||
if(m_connection) {
|
||||
switch(m_connection->status()) {
|
||||
case tw::net::ConnectionStatus::Idle:
|
||||
break;
|
||||
case tw::net::ConnectionStatus::Connecting: {
|
||||
ImGui::TextUnformatted("Connecting...");
|
||||
ImGui::SameLine();
|
||||
draw_favourite_toggle(m_connection->address().to_string());
|
||||
ImGui::SameLine();
|
||||
if(ImGui::Button("Cancel")) {
|
||||
m_connection.reset();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case tw::net::ConnectionStatus::Connected:
|
||||
ImGui::TextColored(ImVec4(0, 1, 0, 1), "Connected!");
|
||||
m_recent.add(m_connection->address().to_string());
|
||||
m_recent.save();
|
||||
m_result = LobbyResult{std::move(m_connection)};
|
||||
break;
|
||||
case tw::net::ConnectionStatus::Failed: {
|
||||
std::string failed_msg = "Connection failed: " + m_connection->error();
|
||||
ImGui::TextColored(ImVec4(1, 0, 0, 1), "%s", failed_msg.c_str());
|
||||
if(ImGui::Button("Dismiss")) {
|
||||
m_connection.reset();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if(!m_error.empty()) {
|
||||
std::string error_msg = "Error: " + m_error;
|
||||
ImGui::TextColored(ImVec4(1, 0, 0, 1), "%s", error_msg.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
void LobbyState::update(double delta_time) {
|
||||
if(m_connection) {
|
||||
m_connection->update();
|
||||
}
|
||||
|
||||
// Center the window
|
||||
ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_FirstUseEver,
|
||||
ImVec2(0.5f, 0.5f));
|
||||
ImGui::SetNextWindowSize(ImVec2(400, 0), ImGuiCond_FirstUseEver);
|
||||
|
||||
ImGui::Begin("Lobby", nullptr, ImGuiWindowFlags_NoMove);
|
||||
|
||||
draw_form();
|
||||
draw_provider(m_favourites, FAVOURITES_COLOUR);
|
||||
draw_provider(m_recent, RECENT_COLOUR);
|
||||
ImGui::Separator();
|
||||
draw_status();
|
||||
|
||||
ImGui::End();
|
||||
}
|
||||
|
||||
std::optional<LobbyResult> LobbyState::take_result() {
|
||||
// Moving out of an optional leaves it engaged, which would hand the caller
|
||||
// a second, empty result on the next frame.
|
||||
auto result = std::move(m_result);
|
||||
m_result.reset();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace tw::app
|
||||
@@ -0,0 +1,78 @@
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <optional>
|
||||
|
||||
#include "RecentServers.hpp"
|
||||
#include "FavouriteServers.hpp"
|
||||
#include "ServerAddressProvider.hpp"
|
||||
#include "network/ServerConnection.hpp"
|
||||
|
||||
struct ImVec4;
|
||||
|
||||
namespace tw::app {
|
||||
|
||||
/**
|
||||
* The edge out of the lobby: a connection that finished its handshake.
|
||||
*/
|
||||
struct LobbyResult {
|
||||
std::unique_ptr<tw::net::ServerConnection> connection;
|
||||
};
|
||||
|
||||
/**
|
||||
* The lobby screen state. Renders an address input field, recent server list,
|
||||
* and manages a connection attempt in flight.
|
||||
*/
|
||||
class LobbyState {
|
||||
static constexpr size_t ADDRESS_INPUT_SIZE = 64;
|
||||
|
||||
tw::app::RecentServers m_recent;
|
||||
tw::app::FavouriteServers m_favourites;
|
||||
|
||||
/**
|
||||
* Edited in place by the input field, so it has to outlive the frame that
|
||||
* draws it rather than being rebuilt from a string every time.
|
||||
*/
|
||||
char m_address_input[ADDRESS_INPUT_SIZE];
|
||||
|
||||
std::string m_error;
|
||||
std::unique_ptr<tw::net::ServerConnection> m_connection;
|
||||
std::optional<LobbyResult> m_result;
|
||||
|
||||
void begin_connect(tw::net::Address address);
|
||||
void set_address_input(const std::string& address);
|
||||
|
||||
void draw_form();
|
||||
|
||||
/**
|
||||
* Renders one address list. The colour is what tells the lists apart on
|
||||
* screen, so it belongs to the lobby rather than to the list itself.
|
||||
*/
|
||||
void draw_provider(ServerAddressProvider& provider, const ImVec4& header_colour);
|
||||
|
||||
void draw_favourite_toggle(const std::string& entry);
|
||||
void toggle_favourite(const std::string& entry);
|
||||
void draw_status();
|
||||
|
||||
public:
|
||||
/**
|
||||
* Constructs the lobby state, optionally starting an auto-connect if a
|
||||
* server address is provided.
|
||||
*/
|
||||
explicit LobbyState(std::optional<tw::net::Address> auto_connect);
|
||||
|
||||
/**
|
||||
* Updates the lobby: draws the UI, pumps the connection attempt if one
|
||||
* is in flight.
|
||||
*/
|
||||
void update(double delta_time);
|
||||
|
||||
/**
|
||||
* Returns the transition result if the lobby is done. Moves the result
|
||||
* out, leaving none behind.
|
||||
*/
|
||||
std::optional<LobbyResult> take_result();
|
||||
};
|
||||
|
||||
} // namespace tw::app
|
||||
@@ -0,0 +1,31 @@
|
||||
#include "RecentServers.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace tw::app {
|
||||
|
||||
RecentServers::RecentServers()
|
||||
: FileAddressList("recent_servers.txt") {
|
||||
}
|
||||
|
||||
const char* RecentServers::name() const {
|
||||
return "Recent";
|
||||
}
|
||||
|
||||
void RecentServers::add(const std::string& entry) {
|
||||
// Remove if already in list (de-duplicate)
|
||||
auto it = std::find(m_entries.begin(), m_entries.end(), entry);
|
||||
if(it != m_entries.end()) {
|
||||
m_entries.erase(it);
|
||||
}
|
||||
|
||||
// Add to front (most recent first)
|
||||
m_entries.insert(m_entries.begin(), entry);
|
||||
|
||||
// Cap at MAX_ENTRIES
|
||||
if(m_entries.size() > MAX_ENTRIES) {
|
||||
m_entries.resize(MAX_ENTRIES);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace tw::app
|
||||
@@ -0,0 +1,32 @@
|
||||
#pragma once
|
||||
|
||||
#include "FileAddressList.hpp"
|
||||
|
||||
namespace tw::app {
|
||||
|
||||
/**
|
||||
* Manages a list of recently used server addresses.
|
||||
*
|
||||
* Persists addresses to a text file, one "ip:port" per line.
|
||||
* Most recent first, capped at 8 entries. File path is
|
||||
* $XDG_CONFIG_HOME/towards/recent_servers.txt, falling back to
|
||||
* $HOME/.config/towards/recent_servers.txt. If both variables are
|
||||
* unset, keeps the list in memory only.
|
||||
*/
|
||||
class RecentServers : public FileAddressList {
|
||||
static constexpr size_t MAX_ENTRIES = 8;
|
||||
|
||||
public:
|
||||
RecentServers();
|
||||
|
||||
const char* name() const override;
|
||||
|
||||
/**
|
||||
* Add an address to the recents list.
|
||||
* Most recent first, de-duplicated, capped at 8.
|
||||
* Does not persist to disk; call save() after connecting.
|
||||
*/
|
||||
void add(const std::string& entry) override;
|
||||
};
|
||||
|
||||
} // namespace tw::app
|
||||
@@ -0,0 +1,42 @@
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
#include <string>
|
||||
|
||||
namespace tw::app {
|
||||
|
||||
/**
|
||||
* Abstract interface for server address lists.
|
||||
*
|
||||
* Implementations manage a list of "ip:port" entries, load/save them,
|
||||
* and provide a human-readable name for the UI.
|
||||
*/
|
||||
class ServerAddressProvider {
|
||||
public:
|
||||
virtual ~ServerAddressProvider() = default;
|
||||
|
||||
/** Human-readable list name, shown as the section header in the lobby. */
|
||||
virtual const char* name() const = 0;
|
||||
|
||||
virtual const std::vector<std::string>& entries() const = 0;
|
||||
virtual void load() = 0;
|
||||
virtual void save() const = 0;
|
||||
virtual void add(const std::string& entry) = 0;
|
||||
virtual void remove(const std::string& entry) = 0;
|
||||
|
||||
/**
|
||||
* Check whether an entry exists in the list.
|
||||
* Implemented over entries() — subclasses need not override.
|
||||
*/
|
||||
bool contains(const std::string& entry) const {
|
||||
const auto& vec = entries();
|
||||
for(const auto& e : vec) {
|
||||
if(e == entry) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace tw::app
|
||||
@@ -0,0 +1,44 @@
|
||||
#include "DebugUI.hpp"
|
||||
#include <imgui.h>
|
||||
|
||||
namespace tw::dbg {
|
||||
|
||||
void DebugUI::draw_dockspace() {
|
||||
const ImGuiViewport* viewport = ImGui::GetMainViewport();
|
||||
ImGui::SetNextWindowPos(viewport->WorkPos);
|
||||
ImGui::SetNextWindowSize(viewport->WorkSize);
|
||||
ImGui::SetNextWindowViewport(viewport->ID);
|
||||
|
||||
const ImGuiWindowFlags flags =
|
||||
ImGuiWindowFlags_MenuBar | ImGuiWindowFlags_NoDocking |
|
||||
ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoCollapse |
|
||||
ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
|
||||
ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoNavFocus |
|
||||
ImGuiWindowFlags_NoBackground;
|
||||
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.0f);
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f);
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f));
|
||||
|
||||
ImGui::Begin("##debug_dockspace_host", nullptr, flags);
|
||||
|
||||
ImGui::PopStyleVar(3);
|
||||
|
||||
// A pass through centre leaves the middle empty until something is docked
|
||||
// there, which is where the world is drawn.
|
||||
ImGui::DockSpace(ImGui::GetID("debug_dockspace"), ImVec2(0.0f, 0.0f),
|
||||
ImGuiDockNodeFlags_PassthruCentralNode);
|
||||
|
||||
if(ImGui::BeginMenuBar()) {
|
||||
m_windows.draw_menu();
|
||||
ImGui::EndMenuBar();
|
||||
}
|
||||
|
||||
ImGui::End();
|
||||
}
|
||||
|
||||
void DebugUI::draw_windows() {
|
||||
m_windows.draw_windows();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
|
||||
#include "debug/DebugWindowRegistry.hpp"
|
||||
|
||||
namespace tw::dbg {
|
||||
|
||||
/**
|
||||
* The full screen host the debug panels live in: a menu bar to toggle them and
|
||||
* a dock space to arrange them in. Draws no background of its own, so the world
|
||||
* stays visible underneath.
|
||||
*/
|
||||
class DebugUI {
|
||||
DebugWindowRegistry m_windows;
|
||||
|
||||
public:
|
||||
DebugWindowRegistry& windows() {
|
||||
return m_windows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the host for this frame. Has to run before anything that should be
|
||||
* dockable is drawn, since the dock space has to exist by then.
|
||||
*/
|
||||
void draw_dockspace();
|
||||
|
||||
void draw_windows();
|
||||
};
|
||||
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
#include "DebugWindow.hpp"
|
||||
#include <imgui.h>
|
||||
#include <utility>
|
||||
|
||||
namespace tw::dbg {
|
||||
|
||||
DebugWindow::DebugWindow(std::string id, std::string title, std::string category)
|
||||
: m_id(std::move(id)),
|
||||
m_title(std::move(title)),
|
||||
m_category(std::move(category)),
|
||||
m_label(m_title + "##" + m_id)
|
||||
{
|
||||
}
|
||||
|
||||
void DebugWindow::draw() {
|
||||
if(!m_open) {
|
||||
return;
|
||||
}
|
||||
|
||||
if(ImGui::Begin(m_label.c_str(), &m_open)) {
|
||||
draw_contents();
|
||||
}
|
||||
ImGui::End();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace tw::dbg {
|
||||
|
||||
/**
|
||||
* A debug panel that can be toggled from the menu bar.
|
||||
*
|
||||
* The frame around a panel is drawn here so every one of them gets the same
|
||||
* close button and docking behaviour; subclasses only fill in the contents.
|
||||
*/
|
||||
class DebugWindow {
|
||||
std::string m_id;
|
||||
std::string m_title;
|
||||
std::string m_category;
|
||||
|
||||
/**
|
||||
* The label handed to the ui, "title##id". Saved positions are keyed by the
|
||||
* whole label, so the visible half can change without losing the layout.
|
||||
*/
|
||||
std::string m_label;
|
||||
|
||||
bool m_open = false;
|
||||
|
||||
protected:
|
||||
/**
|
||||
* Fills the panel. Called only while it is open, between begin and end.
|
||||
*/
|
||||
virtual void draw_contents() = 0;
|
||||
|
||||
public:
|
||||
DebugWindow(std::string id, std::string title, std::string category);
|
||||
virtual ~DebugWindow() = default;
|
||||
|
||||
DebugWindow(const DebugWindow&) = delete;
|
||||
DebugWindow& operator=(const DebugWindow&) = delete;
|
||||
|
||||
const std::string& id() const { return m_id; }
|
||||
const std::string& title() const { return m_title; }
|
||||
const std::string& category() const { return m_category; }
|
||||
|
||||
bool is_open() const { return m_open; }
|
||||
void set_open(bool open) { m_open = open; }
|
||||
|
||||
/**
|
||||
* The flag the menu item toggles, and the one the close button clears.
|
||||
*/
|
||||
bool* open_flag() { return &m_open; }
|
||||
|
||||
void draw();
|
||||
};
|
||||
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
#include "DebugWindowRegistry.hpp"
|
||||
#include "DebugWindow.hpp"
|
||||
#include <imgui.h>
|
||||
#include <algorithm>
|
||||
|
||||
namespace tw::dbg {
|
||||
|
||||
void DebugWindowRegistry::add(DebugWindow* window) {
|
||||
auto remembered = m_open_state.find(window->id());
|
||||
if(remembered != m_open_state.end()) {
|
||||
window->set_open(remembered->second);
|
||||
}
|
||||
|
||||
m_windows.push_back(window);
|
||||
}
|
||||
|
||||
void DebugWindowRegistry::remove(DebugWindow* window) {
|
||||
m_open_state[window->id()] = window->is_open();
|
||||
std::erase(m_windows, window);
|
||||
}
|
||||
|
||||
void DebugWindowRegistry::draw_menu() {
|
||||
if(!ImGui::BeginMenu("Windows")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ordered by the first panel that asked for the category, so the menu does
|
||||
// not reshuffle as panels come and go.
|
||||
std::vector<std::string> categories;
|
||||
for(auto* window : m_windows) {
|
||||
if(std::find(categories.begin(), categories.end(), window->category()) == categories.end()) {
|
||||
categories.push_back(window->category());
|
||||
}
|
||||
}
|
||||
|
||||
for(const auto& category : categories) {
|
||||
if(!ImGui::BeginMenu(category.c_str())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for(auto* window : m_windows) {
|
||||
if(window->category() == category) {
|
||||
ImGui::MenuItem(window->title().c_str(), nullptr, window->open_flag());
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::EndMenu();
|
||||
}
|
||||
|
||||
ImGui::EndMenu();
|
||||
}
|
||||
|
||||
void DebugWindowRegistry::draw_windows() {
|
||||
for(auto* window : m_windows) {
|
||||
window->draw();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
namespace tw::dbg {
|
||||
|
||||
class DebugWindow;
|
||||
|
||||
/**
|
||||
* The debug panels that exist right now.
|
||||
*
|
||||
* Panels are listed by whatever owns them, for as long as it lives, so the menu
|
||||
* follows what the client is currently doing. Whether a panel was open is kept
|
||||
* here rather than on the panel, since the owner is built again on every
|
||||
* reconnect and the panel would come back closed.
|
||||
*/
|
||||
class DebugWindowRegistry {
|
||||
std::vector<DebugWindow*> m_windows;
|
||||
|
||||
/**
|
||||
* Keyed by panel id, remembered across the panels themselves.
|
||||
*/
|
||||
std::unordered_map<std::string, bool> m_open_state;
|
||||
|
||||
public:
|
||||
/**
|
||||
* Lists a panel, restoring whether it was open last time one with the same
|
||||
* id was listed. Ownership stays with the caller, which has to remove it
|
||||
* again before the panel dies.
|
||||
*/
|
||||
void add(DebugWindow* window);
|
||||
|
||||
void remove(DebugWindow* window);
|
||||
|
||||
/**
|
||||
* One submenu per category, listing every panel in it. Expects to be called
|
||||
* inside a menu bar.
|
||||
*/
|
||||
void draw_menu();
|
||||
|
||||
void draw_windows();
|
||||
};
|
||||
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
#pragma once
|
||||
|
||||
#include "metrics/MetricSeries.hpp"
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
|
||||
namespace tw::dbg {
|
||||
|
||||
/**
|
||||
* Per-second history of what the client sends, receives and waits for.
|
||||
*
|
||||
* Traffic arrives as running totals, one reading per tick: sample() keeps the
|
||||
* change since the previous reading, so a bucket sums to the traffic of that
|
||||
* second and its extremes are the quietest and busiest tick within it.
|
||||
* Durations are recorded as they are measured.
|
||||
*/
|
||||
class NetworkMetrics {
|
||||
public:
|
||||
using Interval = std::chrono::seconds;
|
||||
using Series = metrics::MetricSeries<Interval>;
|
||||
|
||||
/** How many seconds of history are kept. */
|
||||
static constexpr size_t DEFAULT_HISTORY = 300;
|
||||
|
||||
/** Running totals as of one tick. */
|
||||
struct Totals {
|
||||
uint64_t bytes_sent = 0;
|
||||
uint64_t bytes_received = 0;
|
||||
uint64_t messages_sent = 0;
|
||||
uint64_t messages_received = 0;
|
||||
};
|
||||
|
||||
private:
|
||||
Series m_bytes_out;
|
||||
Series m_bytes_in;
|
||||
Series m_messages_out;
|
||||
Series m_messages_in;
|
||||
Series m_response_ms;
|
||||
Series m_update_ms;
|
||||
|
||||
Series m_rollbacks;
|
||||
Series m_correction_distance;
|
||||
Series m_ack_lag_frames;
|
||||
Series m_replayed_frames;
|
||||
|
||||
Totals m_previous;
|
||||
bool m_has_previous = false;
|
||||
|
||||
static uint64_t delta(uint64_t current, uint64_t previous) {
|
||||
return current > previous ? current - previous : 0;
|
||||
}
|
||||
|
||||
static double to_millis(std::chrono::nanoseconds elapsed) {
|
||||
return std::chrono::duration<double, std::milli>(elapsed).count();
|
||||
}
|
||||
|
||||
public:
|
||||
explicit NetworkMetrics(size_t history_in_seconds = DEFAULT_HISTORY) :
|
||||
m_bytes_out(history_in_seconds),
|
||||
m_bytes_in(history_in_seconds),
|
||||
m_messages_out(history_in_seconds),
|
||||
m_messages_in(history_in_seconds),
|
||||
m_response_ms(history_in_seconds),
|
||||
m_update_ms(history_in_seconds),
|
||||
m_rollbacks(history_in_seconds),
|
||||
m_correction_distance(history_in_seconds),
|
||||
m_ack_lag_frames(history_in_seconds),
|
||||
m_replayed_frames(history_in_seconds)
|
||||
{ }
|
||||
|
||||
/**
|
||||
* Records how much `totals` grew since the previous call. The first call
|
||||
* only remembers where the counters started.
|
||||
*/
|
||||
void sample(const Totals& totals) {
|
||||
if(m_has_previous) {
|
||||
m_bytes_out.push((double)delta(totals.bytes_sent, m_previous.bytes_sent));
|
||||
m_bytes_in.push((double)delta(totals.bytes_received, m_previous.bytes_received));
|
||||
m_messages_out.push((double)delta(totals.messages_sent, m_previous.messages_sent));
|
||||
m_messages_in.push((double)delta(totals.messages_received, m_previous.messages_received));
|
||||
}
|
||||
|
||||
m_previous = totals;
|
||||
m_has_previous = true;
|
||||
}
|
||||
|
||||
/** Time between sending an input and seeing the answer to it. */
|
||||
void record_response_time(std::chrono::nanoseconds elapsed) {
|
||||
m_response_ms.push(to_millis(elapsed));
|
||||
}
|
||||
|
||||
/** Time one tick spent moving messages in and out, handlers included. */
|
||||
void record_update_time(std::chrono::nanoseconds elapsed) {
|
||||
m_update_ms.push(to_millis(elapsed));
|
||||
}
|
||||
|
||||
const Series& bytes_out() const {
|
||||
return m_bytes_out;
|
||||
}
|
||||
|
||||
const Series& bytes_in() const {
|
||||
return m_bytes_in;
|
||||
}
|
||||
|
||||
const Series& messages_out() const {
|
||||
return m_messages_out;
|
||||
}
|
||||
|
||||
const Series& messages_in() const {
|
||||
return m_messages_in;
|
||||
}
|
||||
|
||||
const Series& response_ms() const {
|
||||
return m_response_ms;
|
||||
}
|
||||
|
||||
const Series& update_ms() const {
|
||||
return m_update_ms;
|
||||
}
|
||||
|
||||
/** Records a rollback event (one sample per rollback). */
|
||||
void record_rollback() {
|
||||
m_rollbacks.push(1.0);
|
||||
}
|
||||
|
||||
/** Records the distance in meters of a position correction. */
|
||||
void record_correction_distance(double meters) {
|
||||
m_correction_distance.push(meters);
|
||||
}
|
||||
|
||||
/** Records how many frames behind the ack is trailing the current frame. */
|
||||
void record_ack_lag(uint32_t frames) {
|
||||
m_ack_lag_frames.push((double)frames);
|
||||
}
|
||||
|
||||
/** Records how many frames were replayed during a rollback. */
|
||||
void record_replayed_frames(uint32_t frames) {
|
||||
m_replayed_frames.push((double)frames);
|
||||
}
|
||||
|
||||
const Series& rollbacks() const {
|
||||
return m_rollbacks;
|
||||
}
|
||||
|
||||
const Series& correction_distance() const {
|
||||
return m_correction_distance;
|
||||
}
|
||||
|
||||
const Series& ack_lag_frames() const {
|
||||
return m_ack_lag_frames;
|
||||
}
|
||||
|
||||
const Series& replayed_frames() const {
|
||||
return m_replayed_frames;
|
||||
}
|
||||
};
|
||||
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
namespace tw::dbg::tools {
|
||||
|
||||
EntityManagerGui::EntityManagerGui(World* world) :
|
||||
DebugWindow("entity_manager", "Entities", "World"),
|
||||
m_world(world) {
|
||||
|
||||
}
|
||||
@@ -64,9 +65,7 @@ void EntityManagerGui::draw_entity_components() {
|
||||
ImGui::EndChild();
|
||||
}
|
||||
|
||||
void EntityManagerGui::draw() {
|
||||
ImGui::Begin("Transforms");
|
||||
|
||||
void EntityManagerGui::draw_contents() {
|
||||
ImGui::BeginChild("Entities", ImVec2(0, 260), ImGuiChildFlags_Border);
|
||||
|
||||
ImGui::SeparatorText("Entities");
|
||||
@@ -87,8 +86,6 @@ void EntityManagerGui::draw() {
|
||||
if(m_selected_entity.has_value()) {
|
||||
draw_entity_components();
|
||||
}
|
||||
|
||||
ImGui::End();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "debug/ComponentGui.hpp"
|
||||
#include "debug/DebugWindow.hpp"
|
||||
#include "entt/entity/fwd.hpp"
|
||||
#include "world/World.hpp"
|
||||
|
||||
@@ -8,7 +9,7 @@
|
||||
|
||||
namespace tw::dbg::tools {
|
||||
|
||||
class EntityManagerGui {
|
||||
class EntityManagerGui : public tw::dbg::DebugWindow {
|
||||
private:
|
||||
World* m_world;
|
||||
|
||||
@@ -29,14 +30,15 @@ private:
|
||||
...);
|
||||
}
|
||||
|
||||
protected:
|
||||
void draw_contents() override;
|
||||
|
||||
public:
|
||||
entt::entity& selected() {
|
||||
return m_selected;
|
||||
}
|
||||
|
||||
EntityManagerGui(World* world);
|
||||
|
||||
void draw();
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
@@ -2,95 +2,109 @@
|
||||
|
||||
#include <imgui.h>
|
||||
#include <implot.h>
|
||||
#include <implot_internal.h>
|
||||
|
||||
#include "metrics/BucketMetric.hpp"
|
||||
#include "metrics/MetricSeries.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace tw::dbg::tools {
|
||||
|
||||
template<typename T, typename Interval, typename Op = net::SumOp<T>>
|
||||
/**
|
||||
* Plots one series against the seconds behind now, ending at the last second
|
||||
* that has fully elapsed.
|
||||
*
|
||||
* The line follows whichever statistic the series is read with. Reading an
|
||||
* average also shades the quietest and busiest value of each second behind it;
|
||||
* a total has no such range to show, since its buckets already hold every value
|
||||
* of that second added together.
|
||||
*/
|
||||
class MetricWidget {
|
||||
std::string m_name;
|
||||
|
||||
net::BucketMetric<T, Interval, Op>& m_metric;
|
||||
|
||||
using Self = MetricWidget<T, Interval, Op>;
|
||||
|
||||
struct {
|
||||
T constraint_from;
|
||||
T constraint_to;
|
||||
|
||||
T from;
|
||||
T to;
|
||||
} y_axis;
|
||||
|
||||
bool m_is_scrolling = true;
|
||||
|
||||
public:
|
||||
MetricWidget(
|
||||
const std::string& name,
|
||||
net::BucketMetric<T, Interval, Op>& metric
|
||||
) :
|
||||
m_name(name),
|
||||
m_metric(metric)
|
||||
{
|
||||
y_axis = {
|
||||
.constraint_from = 0,
|
||||
.constraint_to = 500,
|
||||
.from = 0,
|
||||
.to = 250
|
||||
};
|
||||
using Series = metrics::MetricSeries<std::chrono::seconds>;
|
||||
|
||||
private:
|
||||
std::string m_name;
|
||||
std::string m_unit;
|
||||
const Series* m_series;
|
||||
metrics::MetricField m_field;
|
||||
|
||||
/**
|
||||
* The second in progress is left out: it only holds the part of itself
|
||||
* that has elapsed, so drawing it makes the newest point drop and climb
|
||||
* back once a second.
|
||||
*/
|
||||
static constexpr size_t SKIP_IN_PROGRESS = 1;
|
||||
|
||||
std::vector<double> m_ages;
|
||||
std::vector<double> m_values;
|
||||
std::vector<double> m_lows;
|
||||
std::vector<double> m_highs;
|
||||
|
||||
bool has_range() const {
|
||||
return m_field == metrics::MetricField::Avg;
|
||||
}
|
||||
|
||||
Self& set_x_axis_limits_contraints(T from, T to) {
|
||||
ImPlot::SetupAxisLimits(ImAxis_X1, from, to);
|
||||
return *this;
|
||||
}
|
||||
|
||||
Self& set_y_axis_limits(double from, double to) {
|
||||
return *this;
|
||||
}
|
||||
|
||||
Self& enable_scrolling() {
|
||||
m_is_scrolling = true;
|
||||
}
|
||||
|
||||
Self& disable_scrolling() {
|
||||
m_is_scrolling = true;
|
||||
}
|
||||
|
||||
|
||||
void draw() {
|
||||
ImGui::PushID(m_name.c_str());
|
||||
|
||||
auto head = m_metric.get_head();
|
||||
auto head_timeline = m_metric.get_head_timeline();
|
||||
|
||||
auto tail = m_metric.get_tail();
|
||||
auto tail_timeline = m_metric.get_tail_timeline();
|
||||
|
||||
static float m_metric_history = 10.0f;
|
||||
ImGui::Checkbox("Is Scrolling", &m_is_scrolling);
|
||||
if(m_is_scrolling) {
|
||||
ImGui::SliderFloat("History", &m_metric_history,1,30,"%.1f s");
|
||||
/** Describes what is drawn, so the numbers always match the line. */
|
||||
void draw_summary() const {
|
||||
if(m_values.empty()) {
|
||||
ImGui::TextUnformatted("no samples yet");
|
||||
return;
|
||||
}
|
||||
|
||||
ImGui::Text("Min: %i", m_metric.min());
|
||||
ImGui::Text("Max: %i", m_metric.max());
|
||||
auto [low, high] = std::minmax_element(m_values.begin(), m_values.end());
|
||||
|
||||
if(ImPlot::BeginPlot(m_name.c_str())) {
|
||||
auto from = tail_timeline.empty() ? *(head_timeline.end() - 1) : *(tail_timeline.end() - 1);
|
||||
double total = 0.0;
|
||||
for(double value : m_values) {
|
||||
total += value;
|
||||
}
|
||||
|
||||
ImPlot::SetupAxes("Time", m_name.c_str(), ImPlotAxisFlags_None, ImPlotAxisFlags_None);
|
||||
if(m_is_scrolling) {
|
||||
ImPlot::SetupAxisLimits(ImAxis_X1, from - m_metric_history, from, ImGuiCond_Always);
|
||||
ImGui::Text("min %.1f %s avg %.1f %s max %.1f %s",
|
||||
*low, m_unit.c_str(),
|
||||
total / (double)m_values.size(), m_unit.c_str(),
|
||||
*high, m_unit.c_str());
|
||||
}
|
||||
|
||||
public:
|
||||
MetricWidget(std::string name, std::string unit, const Series& series, metrics::MetricField field) :
|
||||
m_name(std::move(name)),
|
||||
m_unit(std::move(unit)),
|
||||
m_series(&series),
|
||||
m_field(field)
|
||||
{ }
|
||||
|
||||
/** Draws the last `history_in_seconds` seconds of the series. */
|
||||
void draw(size_t history_in_seconds) {
|
||||
ImGui::PushID(m_name.c_str());
|
||||
|
||||
m_series->linearize(m_ages, m_values, m_field, history_in_seconds, SKIP_IN_PROGRESS);
|
||||
|
||||
if(has_range()) {
|
||||
m_series->linearize(m_ages, m_lows, metrics::MetricField::Min,
|
||||
history_in_seconds, SKIP_IN_PROGRESS);
|
||||
m_series->linearize(m_ages, m_highs, metrics::MetricField::Max,
|
||||
history_in_seconds, SKIP_IN_PROGRESS);
|
||||
}
|
||||
|
||||
draw_summary();
|
||||
|
||||
if(ImPlot::BeginPlot(m_name.c_str(), ImVec2(-1.0f, 150.0f))) {
|
||||
ImPlot::SetupAxes("seconds ago", m_unit.c_str(),
|
||||
ImPlotAxisFlags_None, ImPlotAxisFlags_AutoFit);
|
||||
ImPlot::SetupAxisLimits(ImAxis_X1, -(double)history_in_seconds, 0.0, ImGuiCond_Always);
|
||||
|
||||
const int count = (int)m_values.size();
|
||||
|
||||
if(has_range() && count > 0) {
|
||||
ImPlot::PlotShaded("range", m_ages.data(), m_lows.data(), m_highs.data(), count);
|
||||
}
|
||||
|
||||
ImPlot::SetupAxisLimits(ImAxis_Y1, 0, m_metric.max() * 2, ImGuiCond_Always);
|
||||
// ImPlot::SetupAxisLimitsConstraints(ImAxis_Y1, 0, 10000);
|
||||
if(count > 0) {
|
||||
ImPlot::PlotLine(m_name.c_str(), m_ages.data(), m_values.data(), count);
|
||||
}
|
||||
|
||||
ImPlot::PlotLine(m_name.c_str(), head_timeline.data(), head.data(), head.size());
|
||||
ImPlot::PlotLine(m_name.c_str(), tail_timeline.data(), tail.data(), tail.size());
|
||||
ImPlot::EndPlot();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,62 +1,70 @@
|
||||
#pragma once
|
||||
|
||||
#include "metrics/BucketMetric.hpp"
|
||||
#include "metrics/NetworkStatsLogger.hpp"
|
||||
#include "debug/DebugWindow.hpp"
|
||||
#include "debug/metrics/NetworkMetrics.hpp"
|
||||
#include "debug/tools/MetricWidget.hpp"
|
||||
|
||||
#include <implot.h>
|
||||
#include <implot_internal.h>
|
||||
#include <imgui.h>
|
||||
|
||||
namespace tw::dbg::tools {
|
||||
|
||||
class NetworkStatsGui {
|
||||
private:
|
||||
// MetricWidget<uint32_t, std::chrono::seconds, net::AverageOp<uint32_t>> m_ping_widget;
|
||||
// MetricWidget<uint32_t, std::chrono::seconds> m_outgoing_widget;
|
||||
// MetricWidget<uint32_t, std::chrono::seconds> m_incoming_widget;
|
||||
/**
|
||||
* Panel over everything the client measured about its traffic.
|
||||
*
|
||||
* Traffic is shown as the total of each second, since that is the rate the
|
||||
* connection actually carried. Durations are shown as the average of each
|
||||
* second, with the range behind them.
|
||||
*/
|
||||
class NetworkStatsGui : public tw::dbg::DebugWindow {
|
||||
MetricWidget m_response;
|
||||
MetricWidget m_update;
|
||||
MetricWidget m_bytes_in;
|
||||
MetricWidget m_bytes_out;
|
||||
MetricWidget m_messages_in;
|
||||
MetricWidget m_messages_out;
|
||||
|
||||
MetricWidget m_rollbacks;
|
||||
MetricWidget m_correction_distance;
|
||||
MetricWidget m_ack_lag_frames;
|
||||
MetricWidget m_replayed_frames;
|
||||
|
||||
int m_history_in_seconds = 30;
|
||||
|
||||
protected:
|
||||
void draw_contents() override {
|
||||
ImGui::SliderInt("History", &m_history_in_seconds, 5, 300, "%d s");
|
||||
|
||||
const size_t history = (size_t)m_history_in_seconds;
|
||||
|
||||
m_response.draw(history);
|
||||
m_update.draw(history);
|
||||
m_bytes_in.draw(history);
|
||||
m_bytes_out.draw(history);
|
||||
m_messages_in.draw(history);
|
||||
m_messages_out.draw(history);
|
||||
|
||||
ImGui::Separator();
|
||||
ImGui::TextUnformatted("Prediction");
|
||||
m_rollbacks.draw(history);
|
||||
m_correction_distance.draw(history);
|
||||
m_ack_lag_frames.draw(history);
|
||||
m_replayed_frames.draw(history);
|
||||
}
|
||||
|
||||
public:
|
||||
// NetworkStatsGui() :
|
||||
// m_ping_widget("Ping", net::NetworkStatsLogger::instance()->ping()),
|
||||
// m_outgoing_widget("Outgoing", net::NetworkStatsLogger::instance()->outgoing()),
|
||||
// m_incoming_widget("Incoming", net::NetworkStatsLogger::instance()->incoming())
|
||||
// {
|
||||
// }
|
||||
|
||||
void draw() {
|
||||
// auto* instance = net::NetworkStatsLogger::instance();
|
||||
// auto& ping = instance->ping();
|
||||
|
||||
ImGui::Begin("Network Stats");
|
||||
|
||||
/* auto head = ping.get_head();
|
||||
auto head_timeline = ping.get_head_timeline();
|
||||
|
||||
auto tail = ping.get_tail();
|
||||
auto tail_timeline = ping.get_tail_timeline();
|
||||
|
||||
static float ping_history = 10.0f;
|
||||
ImGui::SliderFloat("Ping History", &ping_history,1,30,"%.1f s");
|
||||
|
||||
if(ImPlot::BeginPlot("Ping")) {
|
||||
auto from = tail_timeline.empty() ? *(head_timeline.end() - 1) : *(tail_timeline.end() - 1);
|
||||
|
||||
ImPlot::SetupAxes("FrameIdx","FPS", ImPlotAxisFlags_None, ImPlotAxisFlags_None);
|
||||
ImPlot::SetupAxisLimits(ImAxis_X1, from - ping_history, from, ImGuiCond_Always);
|
||||
ImPlot::SetupAxisLimits(ImAxis_Y1, 0, 120);
|
||||
ImPlot::SetupAxisLimitsConstraints(ImAxis_Y1, 0, 10000);
|
||||
|
||||
ImPlot::PlotLine("Ping", head_timeline.data(), head.data(), head.size());
|
||||
ImPlot::PlotLine("Ping", tail_timeline.data(), tail.data(), tail.size());
|
||||
ImPlot::EndPlot();
|
||||
} */
|
||||
|
||||
// m_ping_widget.draw();
|
||||
// m_outgoing_widget.draw();
|
||||
// m_incoming_widget.draw();
|
||||
|
||||
ImGui::End();
|
||||
}
|
||||
explicit NetworkStatsGui(const NetworkMetrics& metrics) :
|
||||
DebugWindow("network_stats", "Network Stats", "Network"),
|
||||
m_response("Response", "ms", metrics.response_ms(), metrics::MetricField::Avg),
|
||||
m_update("Network update", "ms", metrics.update_ms(), metrics::MetricField::Avg),
|
||||
m_bytes_in("Bytes in", "B/s", metrics.bytes_in(), metrics::MetricField::Sum),
|
||||
m_bytes_out("Bytes out", "B/s", metrics.bytes_out(), metrics::MetricField::Sum),
|
||||
m_messages_in("Messages in", "1/s", metrics.messages_in(), metrics::MetricField::Sum),
|
||||
m_messages_out("Messages out", "1/s", metrics.messages_out(), metrics::MetricField::Sum),
|
||||
m_rollbacks("Rollbacks", "1/s", metrics.rollbacks(), metrics::MetricField::Sum),
|
||||
m_correction_distance("Correction distance", "m", metrics.correction_distance(), metrics::MetricField::Avg),
|
||||
m_ack_lag_frames("Ack lag", "frames", metrics.ack_lag_frames(), metrics::MetricField::Avg),
|
||||
m_replayed_frames("Replayed frames", "frames", metrics.replayed_frames(), metrics::MetricField::Avg)
|
||||
{ }
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <format>
|
||||
#include <vector>
|
||||
|
||||
#include "imgui.h"
|
||||
|
||||
#include "metrics/NetworkStatsLogger.hpp"
|
||||
|
||||
namespace tw::dbg::tools {
|
||||
|
||||
class PacketBacklogGui {
|
||||
private:
|
||||
std::vector<uint32_t> m_buckets;
|
||||
uint32_t m_last_backlog_idx;
|
||||
|
||||
public:
|
||||
void draw() {
|
||||
// auto* instance = net::NetworkStatsLogger::instance();
|
||||
// size_t size = instance->get_size();
|
||||
|
||||
// if(ImGui::BeginTable("Network Packets", 5)) {
|
||||
// ImGui::TableSetupColumn("Message Type");
|
||||
// ImGui::TableSetupColumn("Time");
|
||||
// ImGui::TableSetupColumn("Is From Us");
|
||||
// ImGui::TableSetupColumn("Target");
|
||||
// ImGui::TableSetupColumn("Size");
|
||||
|
||||
// for(int32_t i = size-1; i >= 0; i--) {
|
||||
// auto& item = instance->get_item(i);
|
||||
// ImGui::PushID(item.timepoint.time_since_epoch().count());
|
||||
|
||||
// ImGui::TableNextRow();
|
||||
|
||||
// ImGui::TableNextColumn();
|
||||
// ImGui::Text("%i", item.message_type);
|
||||
|
||||
// ImGui::TableNextColumn();
|
||||
// ImGui::Text(std::format("{}", item.timepoint.time_since_epoch()).c_str());
|
||||
|
||||
// ImGui::TableNextColumn();
|
||||
// ImGui::Checkbox("is_sent_from_us", &item.is_sent_by_us);
|
||||
|
||||
// ImGui::TableNextColumn();
|
||||
// ImGui::Text(item.target.to_string().c_str());
|
||||
|
||||
// ImGui::TableNextColumn();
|
||||
// ImGui::Text("%ld", item.buffer.size());
|
||||
|
||||
// ImGui::PopID();
|
||||
// }
|
||||
// ImGui::EndTable();
|
||||
// }
|
||||
}
|
||||
};
|
||||
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
#include "Address.hpp"
|
||||
#include "ByteBuffer.hpp"
|
||||
#include "packets/Packet.hpp"
|
||||
#include <cstring>
|
||||
|
||||
namespace tw::dbg::tools {
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
namespace tw::dbg::tools {
|
||||
|
||||
PerformanceStatsGui::PerformanceStatsGui(LockStep& lock_step) :
|
||||
DebugWindow("performance_stats", "Performance", "General"),
|
||||
m_lockstep(lock_step),
|
||||
fps_history(1000),
|
||||
frame_idxs(1000)
|
||||
{
|
||||
}
|
||||
|
||||
void PerformanceStatsGui::draw() {
|
||||
ImGui::Begin("Stats");
|
||||
void PerformanceStatsGui::draw_contents() {
|
||||
|
||||
ImGui::Text("FPS: %ld", m_lockstep.fps());
|
||||
fps_history[fps_history_idx] = m_lockstep.fps();
|
||||
@@ -34,8 +34,6 @@ void PerformanceStatsGui::draw() {
|
||||
ImPlot::PlotLine("FPS", frame_idxs.data(), fps_history.data(), is_plot_filled ? (int)fps_history.size() : (int)fps_history_idx - 1);
|
||||
ImPlot::EndPlot();
|
||||
}
|
||||
|
||||
ImGui::End();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
#include "debug/DebugWindow.hpp"
|
||||
#include "runtime/LockStep.hpp"
|
||||
|
||||
namespace tw::dbg::tools {
|
||||
|
||||
class PerformanceStatsGui {
|
||||
class PerformanceStatsGui : public tw::dbg::DebugWindow {
|
||||
private:
|
||||
LockStep& m_lockstep;
|
||||
|
||||
@@ -15,10 +16,11 @@ private:
|
||||
uint32_t frame_idx = 0;
|
||||
bool is_plot_filled = false;
|
||||
|
||||
protected:
|
||||
void draw_contents() override;
|
||||
|
||||
public:
|
||||
PerformanceStatsGui(LockStep& lock_step);
|
||||
|
||||
void draw();
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
#include "PlayerReconciler.hpp"
|
||||
|
||||
#include "world/JoltPhysicsWorld.hpp"
|
||||
#include "world/CharacterBody.hpp"
|
||||
#include "world/Transform.hpp"
|
||||
#include <spdlog/spdlog.h>
|
||||
#include <glm/glm.hpp>
|
||||
#include <glm/gtx/norm.hpp>
|
||||
|
||||
namespace tw::net {
|
||||
|
||||
PlayerReconciler::PlayerReconciler(JoltPhysicsWorld* physics)
|
||||
: m_physics(physics), m_last_reconciled_ack(0), m_rollback_count(0),
|
||||
m_last_correction_distance(0.0f), m_last_replayed_frames(0), m_last_ack_frame(0)
|
||||
{
|
||||
for (auto& record : m_records) {
|
||||
record.frame = 0;
|
||||
record.valid = false;
|
||||
record.input = glm::vec3(0.0f);
|
||||
record.predicted_position = glm::vec3(0.0f);
|
||||
}
|
||||
}
|
||||
|
||||
void PlayerReconciler::record_input(uint32_t frame, glm::vec3 input) {
|
||||
size_t idx = frame % RING_SIZE;
|
||||
m_records[idx].frame = frame;
|
||||
m_records[idx].valid = true;
|
||||
m_records[idx].input = input;
|
||||
}
|
||||
|
||||
void PlayerReconciler::record_prediction(uint32_t frame, glm::vec3 position) {
|
||||
size_t idx = frame % RING_SIZE;
|
||||
if (m_records[idx].frame == frame && m_records[idx].valid) {
|
||||
m_records[idx].predicted_position = position;
|
||||
}
|
||||
}
|
||||
|
||||
bool PlayerReconciler::reconcile(uint32_t ack_frame, glm::vec3 authoritative_position,
|
||||
entt::entity player, entt::registry* registry,
|
||||
uint32_t current_frame)
|
||||
{
|
||||
if (ack_frame == 0 || ack_frame <= m_last_reconciled_ack || ack_frame >= current_frame) {
|
||||
return false;
|
||||
}
|
||||
|
||||
m_last_reconciled_ack = ack_frame;
|
||||
m_last_ack_frame = ack_frame;
|
||||
|
||||
Record& record = m_records[ack_frame % RING_SIZE];
|
||||
const bool has_prediction = record.valid && record.frame == ack_frame;
|
||||
|
||||
// With a prediction to compare against, an answer that already matches costs
|
||||
// nothing further. This is the case almost every frame.
|
||||
if (has_prediction) {
|
||||
float distance = glm::distance(record.predicted_position, authoritative_position);
|
||||
m_last_correction_distance = distance;
|
||||
|
||||
if (distance < kPositionEpsilon) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Restoring the frame the answer describes keeps everything the simulation
|
||||
// derived from it, so only the character has to be moved. Without a stored
|
||||
// frame there is nothing to restore and the answer is taken as it stands.
|
||||
const bool restored = has_prediction && m_physics->rollback(ack_frame);
|
||||
|
||||
place_character(player, registry, authoritative_position, !restored);
|
||||
replay_from(ack_frame, player, registry, current_frame);
|
||||
|
||||
m_last_replayed_frames = current_frame - 1 - ack_frame;
|
||||
m_rollback_count++;
|
||||
|
||||
spdlog::debug("Corrected at frame {}: distance {}, replayed {}, restored {}",
|
||||
ack_frame, m_last_correction_distance, m_last_replayed_frames, restored);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void PlayerReconciler::place_character(entt::entity player, entt::registry* registry,
|
||||
glm::vec3 position, bool clear_velocity) {
|
||||
CharacterBody* body = registry->try_get<CharacterBody>(player);
|
||||
if (!body) {
|
||||
return;
|
||||
}
|
||||
|
||||
body->m_character->SetPosition(JPH::RVec3(position.x, position.y, position.z));
|
||||
|
||||
if (clear_velocity) {
|
||||
body->m_character->SetLinearVelocity(JPH::Vec3::sZero());
|
||||
body->m_desired_velocity = JPH::Vec3::sZero();
|
||||
}
|
||||
}
|
||||
|
||||
void PlayerReconciler::replay_from(uint32_t from_frame, entt::entity player,
|
||||
entt::registry* registry, uint32_t current_frame) {
|
||||
for (uint32_t f = from_frame + 1; f < current_frame; ++f) {
|
||||
m_physics->step(f, tw::JoltPhysicsWorld::FIXED_DELTA_TIME, true);
|
||||
|
||||
Transform* transform = registry->try_get<Transform>(player);
|
||||
if (!transform) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Frames the ring never saw still need an entry, or the answer to them
|
||||
// arrives with nothing to compare against and forces another correction.
|
||||
Record& replayed = m_records[f % RING_SIZE];
|
||||
replayed.frame = f;
|
||||
replayed.valid = true;
|
||||
replayed.predicted_position = transform->position();
|
||||
}
|
||||
}
|
||||
|
||||
void PlayerReconciler::reset_at(uint32_t frame) {
|
||||
m_last_reconciled_ack = frame;
|
||||
|
||||
for (auto& record : m_records) {
|
||||
record.valid = false;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <array>
|
||||
#include <glm/glm.hpp>
|
||||
#include <entt/entt.hpp>
|
||||
|
||||
namespace tw {
|
||||
class JoltPhysicsWorld;
|
||||
}
|
||||
|
||||
namespace tw::net {
|
||||
|
||||
class PlayerReconciler {
|
||||
private:
|
||||
struct Record {
|
||||
uint32_t frame;
|
||||
bool valid;
|
||||
glm::vec3 input;
|
||||
glm::vec3 predicted_position;
|
||||
};
|
||||
|
||||
static constexpr size_t RING_SIZE = 64;
|
||||
static constexpr float kPositionEpsilon = 0.05f;
|
||||
|
||||
tw::JoltPhysicsWorld* m_physics;
|
||||
std::array<Record, RING_SIZE> m_records;
|
||||
|
||||
uint32_t m_last_reconciled_ack = 0;
|
||||
uint64_t m_rollback_count = 0;
|
||||
float m_last_correction_distance = 0.0f;
|
||||
uint32_t m_last_replayed_frames = 0;
|
||||
uint32_t m_last_ack_frame = 0;
|
||||
|
||||
public:
|
||||
PlayerReconciler(tw::JoltPhysicsWorld* physics);
|
||||
|
||||
void record_input(uint32_t frame, glm::vec3 input);
|
||||
void record_prediction(uint32_t frame, glm::vec3 position);
|
||||
|
||||
bool reconcile(uint32_t ack_frame, glm::vec3 authoritative_position,
|
||||
entt::entity player, entt::registry* registry, uint32_t current_frame);
|
||||
|
||||
private:
|
||||
/**
|
||||
* Re-simulates `from_frame + 1` up to the newest frame, refreshing the stored
|
||||
* prediction for each. The inputs come from the character itself, so this
|
||||
* works even for frames this ring never recorded.
|
||||
*/
|
||||
void replay_from(uint32_t from_frame, entt::entity player,
|
||||
entt::registry* registry, uint32_t current_frame);
|
||||
|
||||
/** Places the character at `position` without disturbing the stored frames. */
|
||||
void place_character(entt::entity player, entt::registry* registry,
|
||||
glm::vec3 position, bool clear_velocity);
|
||||
|
||||
public:
|
||||
|
||||
/**
|
||||
* Drops every stored frame and treats `frame` as already answered. Used when
|
||||
* the player is placed outright, where nothing recorded before the placement
|
||||
* describes where it now is.
|
||||
*/
|
||||
void reset_at(uint32_t frame);
|
||||
|
||||
uint64_t rollback_count() const { return m_rollback_count; }
|
||||
float last_correction_distance() const { return m_last_correction_distance; }
|
||||
uint32_t last_replayed_frames() const { return m_last_replayed_frames; }
|
||||
uint32_t last_ack_frame() const { return m_last_ack_frame; }
|
||||
};
|
||||
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
#include "ServerConnection.hpp"
|
||||
|
||||
#include <spdlog/spdlog.h>
|
||||
|
||||
namespace tw::net {
|
||||
|
||||
ServerConnection::ServerConnection(Address address) :
|
||||
m_address(address),
|
||||
m_status(ConnectionStatus::Idle),
|
||||
m_started_at(Clock::now()) {
|
||||
}
|
||||
|
||||
tl::expected<void, msg::MessageError> ServerConnection::start() {
|
||||
// Create the endpoint
|
||||
auto endpoint_r = msg::MessageEndpoint::create();
|
||||
if(!endpoint_r) {
|
||||
m_status = ConnectionStatus::Failed;
|
||||
m_error = endpoint_r.error().message();
|
||||
return tl::make_unexpected(endpoint_r.error());
|
||||
}
|
||||
|
||||
m_endpoint = std::move(endpoint_r.value());
|
||||
|
||||
// Connect to the server
|
||||
auto server_r = m_endpoint->connect(m_address.ip_string(), m_address.port());
|
||||
if(!server_r) {
|
||||
m_status = ConnectionStatus::Failed;
|
||||
m_error = server_r.error().message();
|
||||
return tl::make_unexpected(server_r.error());
|
||||
}
|
||||
|
||||
m_server = server_r.value();
|
||||
m_status = ConnectionStatus::Connecting;
|
||||
m_started_at = Clock::now();
|
||||
spdlog::info("Attempting to connect to server at {}", m_address.to_string());
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
void ServerConnection::update() {
|
||||
if(!m_endpoint) {
|
||||
return;
|
||||
}
|
||||
|
||||
m_endpoint->update();
|
||||
|
||||
// Check if the connection has become established
|
||||
if(m_status == ConnectionStatus::Connecting && m_server) {
|
||||
if(m_server->is_established()) {
|
||||
m_status = ConnectionStatus::Connected;
|
||||
spdlog::info("Connected to server at {}", m_address.to_string());
|
||||
} else {
|
||||
// Check for timeout
|
||||
auto elapsed = Clock::now() - m_started_at;
|
||||
if(elapsed >= CONNECT_TIMEOUT) {
|
||||
m_status = ConnectionStatus::Failed;
|
||||
m_error = "No response from " + m_address.to_string();
|
||||
spdlog::error("Connection timeout to {}", m_address.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ConnectionStatus ServerConnection::status() const {
|
||||
return m_status;
|
||||
}
|
||||
|
||||
const std::string& ServerConnection::error() const {
|
||||
return m_error;
|
||||
}
|
||||
|
||||
const Address& ServerConnection::address() const {
|
||||
return m_address;
|
||||
}
|
||||
|
||||
msg::MessageEndpoint* ServerConnection::endpoint() const {
|
||||
return m_endpoint.get();
|
||||
}
|
||||
|
||||
msg::MessageConnection* ServerConnection::server() const {
|
||||
return m_server;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <chrono>
|
||||
|
||||
#include "Address.hpp"
|
||||
#include "message_protocol/MessageEndpoint.hpp"
|
||||
#include "message_protocol/MessageConnection.hpp"
|
||||
#include "message_protocol/MessageError.hpp"
|
||||
|
||||
#include <tl/expected.hpp>
|
||||
|
||||
namespace tw::net {
|
||||
|
||||
/**
|
||||
* Status of a server connection attempt or established connection.
|
||||
*/
|
||||
enum class ConnectionStatus { Idle, Connecting, Connected, Failed };
|
||||
|
||||
/**
|
||||
* Encapsulates a connection to a game server.
|
||||
*
|
||||
* Owns the endpoint and connection, managing the state of the connection
|
||||
* attempt and providing non-blocking access to send/receive.
|
||||
*/
|
||||
class ServerConnection {
|
||||
using Clock = std::chrono::steady_clock;
|
||||
|
||||
std::unique_ptr<msg::MessageEndpoint> m_endpoint;
|
||||
msg::MessageConnection* m_server = nullptr;
|
||||
Address m_address;
|
||||
ConnectionStatus m_status = ConnectionStatus::Idle;
|
||||
std::string m_error;
|
||||
Clock::time_point m_started_at;
|
||||
|
||||
static constexpr std::chrono::seconds CONNECT_TIMEOUT{5};
|
||||
|
||||
public:
|
||||
/**
|
||||
* Constructs a connection object for the given address, without starting I/O.
|
||||
*/
|
||||
explicit ServerConnection(Address address);
|
||||
|
||||
/**
|
||||
* Starts the connection process by creating an endpoint and connecting to
|
||||
* the server. Returns an error if the endpoint cannot be created.
|
||||
*/
|
||||
tl::expected<void, msg::MessageError> start();
|
||||
|
||||
/**
|
||||
* Updates the connection state: pumps the endpoint, and checks for timeout
|
||||
* or successful connection. Must be called regularly.
|
||||
*/
|
||||
void update();
|
||||
|
||||
ConnectionStatus status() const;
|
||||
const std::string& error() const;
|
||||
const Address& address() const;
|
||||
msg::MessageEndpoint* endpoint() const;
|
||||
msg::MessageConnection* server() const;
|
||||
};
|
||||
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
#include "runtime.hpp"
|
||||
|
||||
#include "app/ClientArgs.hpp"
|
||||
#include "Address.hpp"
|
||||
#include "SDLWindow.h"
|
||||
#include "debug/tools/NetworkStatsGui.hpp"
|
||||
#include "debug/tools/PacketBacklogGui.hpp"
|
||||
#include "debug/tools/PerformanceStatsGui.hpp"
|
||||
#include "entt/entity/fwd.hpp"
|
||||
#include "io/InputState.hpp"
|
||||
#include "debug/tools/EntityManagerGui.hpp"
|
||||
#include "debug/tools/PerformanceStatsGui.hpp"
|
||||
#include "draw/MeshData.hpp"
|
||||
|
||||
#include "imgui.h"
|
||||
@@ -18,6 +16,8 @@
|
||||
#include "implot_internal.h"
|
||||
#include "world/Transform.hpp"
|
||||
|
||||
#include "spdlog/spdlog.h"
|
||||
|
||||
#include <SDL_events.h>
|
||||
#include <glm/glm.hpp>
|
||||
#include <tracy/Tracy.hpp>
|
||||
@@ -32,17 +32,23 @@ std::unique_ptr<lft::win::Window> create_window(const std::string& name, VkExten
|
||||
});
|
||||
}
|
||||
|
||||
int get_port_from_args(int argc, char** argv) {
|
||||
try {
|
||||
if(argc > 1) {
|
||||
return atoi(argv[1]);
|
||||
} else {
|
||||
return 8080;
|
||||
}
|
||||
} catch(std::exception& e) {
|
||||
std::println("Could not parse port from arguments, using default.");
|
||||
return 8080;
|
||||
/**
|
||||
* The address to connect to without asking, if one was given on the command
|
||||
* line. A malformed one is reported and dropped, leaving the user in the lobby.
|
||||
*/
|
||||
static std::optional<net::Address> auto_connect_address(int argc, char** argv) {
|
||||
auto argument = app::server_arg(argc, argv);
|
||||
if(!argument) {
|
||||
return {};
|
||||
}
|
||||
|
||||
auto parsed = app::parse_address(*argument);
|
||||
if(!parsed) {
|
||||
spdlog::error("Failed to parse server address: {}", parsed.error());
|
||||
return {};
|
||||
}
|
||||
|
||||
return *parsed;
|
||||
}
|
||||
|
||||
Runtime::Runtime(int argc, char** argv) :
|
||||
@@ -52,52 +58,44 @@ Runtime::Runtime(int argc, char** argv) :
|
||||
m_physics_world(&m_world),
|
||||
m_world_renderer("towards", m_window.get(), &m_world, &m_files),
|
||||
m_input_manager(m_window.get()),
|
||||
m_world_controller(&m_input_manager, &m_world, &m_physics_world, &m_world_renderer, { "127.0.0.1", get_port_from_args(argc, argv) }),
|
||||
m_lockstep(60)
|
||||
m_network_metrics(),
|
||||
m_lockstep(60),
|
||||
m_debug_ui(),
|
||||
m_perf_stats(m_lockstep),
|
||||
m_state(std::in_place_type<app::LobbyState>, auto_connect_address(argc, argv))
|
||||
{
|
||||
m_debug_ui.windows().add(&m_perf_stats);
|
||||
}
|
||||
|
||||
// bool Runtime::world_state_packet_handler(uint32_t* p_frame_idx, WorldSnapshotMessage* mesg) {
|
||||
// uint32_t frame_idx = *p_frame_idx;
|
||||
Runtime::~Runtime() {
|
||||
m_debug_ui.windows().remove(&m_perf_stats);
|
||||
}
|
||||
|
||||
// if(mesg->frame_idx < frame_idx) {
|
||||
// return false;
|
||||
// }
|
||||
app::GameContext Runtime::context() {
|
||||
return app::GameContext{
|
||||
&m_world,
|
||||
&m_physics_world,
|
||||
&m_world_renderer,
|
||||
&m_input_manager,
|
||||
&m_network_metrics,
|
||||
&m_debug_ui.windows(),
|
||||
};
|
||||
}
|
||||
|
||||
void Runtime::update_state(double delta_time) {
|
||||
if(auto* lobby = std::get_if<app::LobbyState>(&m_state)) {
|
||||
lobby->update(delta_time);
|
||||
|
||||
// for(int i = 0; i < mesg->player_states.size(); i++) {
|
||||
// if(!m_players.contains(mesg->player_states[i].id)) {
|
||||
// auto mesh = m_world_renderer.add_mesh(drw::MeshData::cube(glm::vec3(1.0f)));
|
||||
// const auto entity = m_world.registry().create();
|
||||
// m_players.insert({mesg->player_states[i].id, entity});
|
||||
if(auto result = lobby->take_result()) {
|
||||
m_state.emplace<app::GameState>(context(), std::move(result->connection));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// m_world.registry().emplace<Transform>(entity, Transform(mesg->player_states[i].position));
|
||||
// m_world.registry().emplace<PlayerInfoComponent>(entity,
|
||||
// PlayerInfoComponent(
|
||||
// mesg->player_states[i].id,
|
||||
// mesg->player_states[i].name));
|
||||
// m_world.registry().emplace<drw::Mesh>(entity, mesh);
|
||||
|
||||
// } else {
|
||||
// auto entity = (entt::entity)mesg->player_states[i].id;
|
||||
// auto entity_ts = m_world.registry()
|
||||
// .try_get<Transform>(entity);
|
||||
|
||||
// if(entity_ts) {
|
||||
// entity_ts->transform = glm::translate(glm::mat4(1.0f), mesg->player_states[i].position);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// return true;
|
||||
// }
|
||||
std::get<app::GameState>(m_state).update(delta_time);
|
||||
}
|
||||
|
||||
void Runtime::run() {
|
||||
dbg::tools::EntityManagerGui entity_manager(&m_world);
|
||||
dbg::tools::PerformanceStatsGui perf_stats(m_lockstep);
|
||||
dbg::tools::PacketBacklogGui packet_backlog;
|
||||
dbg::tools::NetworkStatsGui network_stats;
|
||||
|
||||
ImPlot::CreateContext();
|
||||
m_is_running = true;
|
||||
|
||||
@@ -110,48 +108,18 @@ void Runtime::run() {
|
||||
ImGui_ImplSDL2_NewFrame();
|
||||
ImGui::NewFrame();
|
||||
|
||||
// ImGui::DockSpaceOverViewport();
|
||||
|
||||
entity_manager.draw();
|
||||
|
||||
ImGui::Begin("History");
|
||||
|
||||
if(ImGui::BeginTable("historyTable", 2)) {
|
||||
// for(auto key : m_world_controller.position_history().keys()) {
|
||||
// ImGui::TableNextRow();
|
||||
//
|
||||
// // auto v = *m_world_controller.player_history().get(key).value();
|
||||
// // auto input = std::format("{} {} {}", v.x, v.y, v.z);
|
||||
// auto p = *m_world_controller.position_history().get(key).value();
|
||||
// auto position = std::format("{} {} {}", p.x, p.y, p.z);
|
||||
//
|
||||
// ImGui::TableNextColumn();
|
||||
// ImGui::Text("%u", key);
|
||||
// // ImGui::TableNextColumn();
|
||||
// // ImGui::Text(input.c_str());
|
||||
// ImGui::TableNextColumn();
|
||||
// ImGui::Text(position.c_str());
|
||||
// }
|
||||
|
||||
ImGui::EndTable();
|
||||
}
|
||||
|
||||
ImGui::End();
|
||||
|
||||
bool change_imgui = false;
|
||||
|
||||
m_input_manager.update();
|
||||
if(m_input_manager.is_quit()) {
|
||||
m_is_running = false;
|
||||
}
|
||||
m_world_controller.update(m_lockstep.delta_time());
|
||||
|
||||
perf_stats.draw();
|
||||
packet_backlog.draw();
|
||||
network_stats.draw();
|
||||
tw::dbg::ComponentGui<tw::io::InputManager>().draw(&m_input_manager);
|
||||
// Anything that docks needs the dock space to already be there, so the
|
||||
// host goes up before the state draws.
|
||||
m_debug_ui.draw_dockspace();
|
||||
|
||||
m_world.step(m_lockstep.delta_time());
|
||||
update_state(m_lockstep.delta_time());
|
||||
|
||||
m_debug_ui.draw_windows();
|
||||
|
||||
m_world_renderer.render();
|
||||
FrameMark;
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
#pragma once
|
||||
|
||||
#include <variant>
|
||||
|
||||
#include "app/GameContext.hpp"
|
||||
#include "app/GameState.hpp"
|
||||
#include "app/LobbyState.hpp"
|
||||
#include "debug/DebugUI.hpp"
|
||||
#include "debug/metrics/NetworkMetrics.hpp"
|
||||
#include "debug/tools/PerformanceStatsGui.hpp"
|
||||
#include "draw/WorldRenderer.hpp"
|
||||
#include "io/InputState.hpp"
|
||||
#include "runtime/LockStep.hpp"
|
||||
#include "world/ClientWorldController.hpp"
|
||||
#include "world/JoltPhysicsWorld.hpp"
|
||||
#include "world/World.hpp"
|
||||
|
||||
@@ -17,33 +24,38 @@ namespace tw {
|
||||
class Runtime {
|
||||
private:
|
||||
io::Files m_files;
|
||||
|
||||
std::unique_ptr<lft::win::Window> m_window;
|
||||
|
||||
tw::World m_world;
|
||||
|
||||
JoltPhysicsWorld m_physics_world;
|
||||
|
||||
tw::drw::WorldRenderer m_world_renderer;
|
||||
|
||||
tw::io::InputManager m_input_manager;
|
||||
|
||||
tw::ClientWorldController m_world_controller;
|
||||
|
||||
tw::dbg::NetworkMetrics m_network_metrics;
|
||||
tw::LockStep m_lockstep;
|
||||
|
||||
bool m_is_running;
|
||||
/**
|
||||
* Declared before the state so panels the state owns can still be taken out
|
||||
* of the menu while the state is being torn down.
|
||||
*/
|
||||
tw::dbg::DebugUI m_debug_ui;
|
||||
|
||||
tw::dbg::tools::PerformanceStatsGui m_perf_stats;
|
||||
std::variant<tw::app::LobbyState, tw::app::GameState> m_state;
|
||||
bool m_is_running;
|
||||
std::unordered_map<uint32_t, entt::entity> m_players;
|
||||
|
||||
void send_player_positions();
|
||||
|
||||
app::GameContext context();
|
||||
|
||||
void update_state(double delta_time);
|
||||
|
||||
public:
|
||||
const bool is_running() const {
|
||||
return m_is_running;
|
||||
}
|
||||
|
||||
Runtime(int argc, char** argv);
|
||||
~Runtime();
|
||||
|
||||
void run();
|
||||
};
|
||||
|
||||
@@ -14,8 +14,7 @@ struct CameraData {
|
||||
CameraData(glm::mat4 projection, Transform view) :
|
||||
projection(projection),
|
||||
view(view)
|
||||
{
|
||||
}
|
||||
{ }
|
||||
};
|
||||
|
||||
class Camera {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
#include <chrono>
|
||||
#include <spdlog/spdlog.h>
|
||||
|
||||
#include "Address.hpp"
|
||||
#include "network/ServerConnection.hpp"
|
||||
|
||||
#include "Entity.pb.h"
|
||||
#include "Login.pb.h"
|
||||
@@ -12,9 +12,7 @@
|
||||
#include "PlayerMove.pb.h"
|
||||
|
||||
#include "entt/entity/entity.hpp"
|
||||
#include "messenger/MessageHandler.hpp"
|
||||
#include "messenger/Messenger.hpp"
|
||||
#include "TcpStream.hpp"
|
||||
#include "entt/entity/fwd.hpp"
|
||||
#include "messages/PlayerMoveMessage.hpp"
|
||||
#include "metrics/HistoryBuffer.hpp"
|
||||
#include "world/CharacterBody.hpp"
|
||||
@@ -22,6 +20,7 @@
|
||||
#include "world/JoltPhysicsWorld.hpp"
|
||||
#include "world/WorldEntity.hpp"
|
||||
#include "tw/serial/WorldStateWriter.hpp"
|
||||
#include "network/EntityInterpolation.hpp"
|
||||
|
||||
namespace tw {
|
||||
|
||||
@@ -83,17 +82,6 @@ ClientWorldController::create_entity(const std::string& name, glm::vec3 position
|
||||
return entity;
|
||||
}
|
||||
|
||||
net::TcpStream create_stream(tw::net::Address address) {
|
||||
auto stream = net::TcpStream::connect(address);
|
||||
if(!stream.has_value()) {
|
||||
spdlog::error("Failed to connect to server");
|
||||
throw std::runtime_error("Failed to connect to server");
|
||||
}
|
||||
|
||||
stream.value().set_non_blocking();
|
||||
|
||||
return std::move(stream.value());
|
||||
}
|
||||
|
||||
std::optional<entt::entity> ClientWorldController::map_from_server_entity(int id) {
|
||||
if(m_entity_mapping.contains(id)) {
|
||||
@@ -134,33 +122,45 @@ ClientWorldController::ClientWorldController(
|
||||
World* world,
|
||||
JoltPhysicsWorld* physics_world,
|
||||
drw::WorldRenderer* world_renderer,
|
||||
tw::net::Address address
|
||||
net::ServerConnection* connection,
|
||||
dbg::NetworkMetrics* network_metrics
|
||||
) :
|
||||
m_input_manager(inputs),
|
||||
m_world(world),
|
||||
m_physics_world(physics_world),
|
||||
m_world_renderer(world_renderer),
|
||||
m_player_entity(create_player_entity(world, physics_world, world_renderer)),
|
||||
// m_player_entity(/* create_player_entity(world, physics_world, world_renderer) */),
|
||||
m_player_controller(&world_renderer->camera(), glm::vec3()),
|
||||
m_messenger{address},
|
||||
m_connection(connection),
|
||||
m_messages(connection->endpoint()),
|
||||
m_network_metrics(network_metrics),
|
||||
m_tick_step(20),
|
||||
m_is_connected(false),
|
||||
m_input_send_times(INPUT_SEND_TIME_COUNT),
|
||||
m_position_history_exporter("/home/martin/output.csv"),
|
||||
m_entity_interpolator(&m_world->registry(), m_player_entity, 300)
|
||||
m_entity_interpolator(&m_world->registry(), (entt::entity)0, 300),
|
||||
m_reconciler(physics_world)
|
||||
{
|
||||
m_messenger->set_handler<mmo::LoginResponse>(
|
||||
[&](mmo::LoginResponse* mesg) {
|
||||
if(!m_is_connected) {
|
||||
spdlog::info("Joined the game!");
|
||||
}
|
||||
m_messages.set_handler<mmo::LoginResponse>(
|
||||
[this](msg::PeerId, const mmo::LoginResponse& mesg) {
|
||||
spdlog::info("Logged in!");
|
||||
});
|
||||
|
||||
m_messenger->set_raw_handler(Message<mmo::WorldStateMessage>::value,
|
||||
[&](std::span<std::byte> data) -> tl::expected<void, net::NetworkError> {
|
||||
m_messages.set_handler<mmo::SetControlledEntity>(
|
||||
[this](msg::PeerId, const mmo::SetControlledEntity& mesg) {
|
||||
spdlog::info("Setting controlled entity from server id {}", mesg.entity_id());
|
||||
m_controlled_server_id = mesg.entity_id();
|
||||
try_bind_player_entity();
|
||||
});
|
||||
|
||||
m_connection->endpoint()->set_handler(Message<mmo::WorldStateMessage>::value,
|
||||
[this](msg::PeerId, std::span<const std::byte> data) {
|
||||
|
||||
serial::WorldStateReader reader(data);
|
||||
|
||||
auto header = reader.read_header();
|
||||
|
||||
measure_response_time(header.frame_idx);
|
||||
|
||||
while(reader.has_spawn()) {
|
||||
auto spawn = reader.read_spawn();
|
||||
auto entity = create_entity("test", glm::vec3());
|
||||
@@ -168,7 +168,11 @@ ClientWorldController::ClientWorldController(
|
||||
|
||||
map_server_entity(spawn, entity);
|
||||
|
||||
m_entity_interpolator.register_entity(entity);
|
||||
if(m_controlled_server_id.has_value() && m_controlled_server_id.value() == spawn) {
|
||||
try_bind_player_entity();
|
||||
} else {
|
||||
m_entity_interpolator.register_entity(entity);
|
||||
}
|
||||
}
|
||||
|
||||
while(reader.has_entity()) {
|
||||
@@ -182,7 +186,57 @@ ClientWorldController::ClientWorldController(
|
||||
}
|
||||
|
||||
glm::vec3 p = {entity_r.position.x, entity_r.position.y, entity_r.position.z};
|
||||
m_entity_interpolator.add_position_for_entity(entity.value(), p);
|
||||
|
||||
if(m_player_entity.has_value() && entity.value() == m_player_entity.value()) {
|
||||
// The entity was created before its position was known, so the
|
||||
// body sits at the origin until the server places it. There is
|
||||
// no predicted history to reconcile against yet.
|
||||
if(!m_player_position_initialized) {
|
||||
m_player_position_initialized = true;
|
||||
snap_player_to(entity.value(), p);
|
||||
continue;
|
||||
}
|
||||
|
||||
glm::vec3 position_before = glm::vec3(0.0f);
|
||||
Transform* player_transform = m_world->registry().try_get<Transform>(entity.value());
|
||||
if(player_transform) {
|
||||
position_before = player_transform->position();
|
||||
}
|
||||
|
||||
bool reconcile_happened = m_reconciler.reconcile(header.frame_idx, p, entity.value(), &m_world->registry(), m_frame_idx);
|
||||
|
||||
if(reconcile_happened) {
|
||||
// Where the replay actually ended up, which is ahead of the
|
||||
// acked position by the frames that were re-simulated.
|
||||
glm::vec3 position_after = player_transform
|
||||
? player_transform->position()
|
||||
: p;
|
||||
glm::vec3 correction_delta = position_before - position_after;
|
||||
float correction_magnitude = glm::length(correction_delta);
|
||||
if(correction_magnitude > 5.0f) {
|
||||
correction_delta = glm::normalize(correction_delta) * 5.0f;
|
||||
}
|
||||
m_visual_error += correction_delta;
|
||||
|
||||
m_render_curr_position = position_after;
|
||||
m_render_prev_position = position_after;
|
||||
m_tick_accumulator = 0.0;
|
||||
|
||||
m_network_metrics->record_rollback();
|
||||
m_network_metrics->record_correction_distance(m_reconciler.last_correction_distance());
|
||||
m_network_metrics->record_replayed_frames(m_reconciler.last_replayed_frames());
|
||||
}
|
||||
|
||||
if(header.frame_idx != 0) {
|
||||
uint32_t ack_lag = 0;
|
||||
if(m_frame_idx >= header.frame_idx) {
|
||||
ack_lag = m_frame_idx - header.frame_idx;
|
||||
}
|
||||
m_network_metrics->record_ack_lag(ack_lag);
|
||||
}
|
||||
} else {
|
||||
m_entity_interpolator.add_position_for_entity(entity.value(), p);
|
||||
}
|
||||
|
||||
EntityPositionHistory* history = m_world->registry().try_get<EntityPositionHistory>(entity.value());
|
||||
|
||||
@@ -193,33 +247,111 @@ ClientWorldController::ClientWorldController(
|
||||
}
|
||||
|
||||
// apply_entity_positions();
|
||||
|
||||
|
||||
return {};
|
||||
});
|
||||
|
||||
m_messenger->set_handler<mmo::EntitySpawnMessage>(
|
||||
[&](mmo::EntitySpawnMessage* mesg) {
|
||||
auto entity = create_entity(mesg->name(), glm::vec3());
|
||||
m_messages.set_handler<mmo::EntitySpawnMessage>(
|
||||
[this](msg::PeerId, const mmo::EntitySpawnMessage& mesg) {
|
||||
auto entity = create_entity(mesg.name(), glm::vec3());
|
||||
|
||||
map_server_entity(mesg->entity_id(), entity);
|
||||
map_server_entity(mesg.entity_id(), entity);
|
||||
|
||||
m_entity_interpolator.register_entity(entity);
|
||||
if(m_controlled_server_id.has_value() && m_controlled_server_id.value() == mesg.entity_id()) {
|
||||
try_bind_player_entity();
|
||||
} else {
|
||||
m_entity_interpolator.register_entity(entity);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ClientWorldController::~ClientWorldController() {
|
||||
}
|
||||
|
||||
void ClientWorldController::try_bind_player_entity() {
|
||||
if(!m_controlled_server_id.has_value()) {
|
||||
return;
|
||||
}
|
||||
|
||||
auto local_entity = map_from_server_entity(m_controlled_server_id.value());
|
||||
if(!local_entity.has_value()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if(m_player_entity.has_value() && m_player_entity.value() == local_entity.value()) {
|
||||
return;
|
||||
}
|
||||
|
||||
entt::entity entity = local_entity.value();
|
||||
spdlog::info("Binding player entity");
|
||||
|
||||
m_player_entity = entity;
|
||||
|
||||
Transform* transform = m_world->registry().try_get<Transform>(entity);
|
||||
glm::vec3 position = transform ? transform->position() : glm::vec3(0.0f);
|
||||
|
||||
m_world->registry().emplace<CharacterController>(entity, 20.0f);
|
||||
m_world->registry().emplace<CharacterBody>(entity, m_physics_world->create_character(
|
||||
new JPH::BoxShape(JPH::Vec3Arg(0.5f, 0.5f, 0.5f)),
|
||||
position
|
||||
));
|
||||
|
||||
if(m_world->registry().all_of<net::EntityPositionInterpolation>(entity)) {
|
||||
m_world->registry().remove<net::EntityPositionInterpolation>(entity);
|
||||
}
|
||||
}
|
||||
|
||||
void ClientWorldController::snap_player_to(entt::entity entity, glm::vec3 position) {
|
||||
CharacterBody* body = m_world->registry().try_get<CharacterBody>(entity);
|
||||
if(body) {
|
||||
body->m_character->SetPosition(JPH::RVec3(position.x, position.y, position.z));
|
||||
body->m_character->SetLinearVelocity(JPH::Vec3::sZero());
|
||||
body->m_desired_velocity = JPH::Vec3::sZero();
|
||||
}
|
||||
|
||||
Transform* transform = m_world->registry().try_get<Transform>(entity);
|
||||
if(transform) {
|
||||
transform->set_position(position);
|
||||
}
|
||||
|
||||
m_render_prev_position = position;
|
||||
m_render_curr_position = position;
|
||||
m_tick_accumulator = 0.0;
|
||||
m_visual_error = glm::vec3(0.0f);
|
||||
|
||||
// Frames simulated before the player was placed describe a position it never
|
||||
// actually had, so answers to them must not be reconciled against.
|
||||
m_reconciler.reset_at(m_frame_idx);
|
||||
}
|
||||
|
||||
void ClientWorldController::export_entity_history() {
|
||||
}
|
||||
|
||||
void ClientWorldController::measure_response_time(uint32_t frame_idx) {
|
||||
// Snapshots carry frame zero until the server has an input to answer, and
|
||||
// repeat the same frame whenever no newer one arrived in between.
|
||||
if(frame_idx == 0 || frame_idx <= m_last_measured_frame) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Anything the send times no longer cover, including a frame we never sent,
|
||||
// which underflows into a large distance.
|
||||
if(m_frame_idx - frame_idx >= INPUT_SEND_TIME_COUNT) {
|
||||
return;
|
||||
}
|
||||
|
||||
m_last_measured_frame = frame_idx;
|
||||
|
||||
auto sent_at = m_input_send_times[frame_idx % INPUT_SEND_TIME_COUNT];
|
||||
m_network_metrics->record_response_time(Clock::now() - sent_at);
|
||||
}
|
||||
|
||||
void ClientWorldController::update(double delta_time) {
|
||||
m_player_controller.update(m_input_manager, delta_time);
|
||||
|
||||
ImGui::Begin("Player Controller");
|
||||
if(m_player_entity.has_value()) {
|
||||
ImGui::Text("Player entity ID: %d", (uint32_t)m_player_entity.value());
|
||||
}
|
||||
|
||||
ImGui::Text("Player entity ID: %d", (uint32_t)m_player_entity);
|
||||
ImGui::Text("Player count: %ld", m_entity_mapping.size());
|
||||
for(auto mapping : m_entity_mapping) {
|
||||
ImGui::Text("%d -> %d", (uint32_t)mapping.first, mapping.second);
|
||||
@@ -228,34 +360,55 @@ void ClientWorldController::update(double delta_time) {
|
||||
ImGui::End();
|
||||
|
||||
if(m_tick_step.update()) {
|
||||
m_messenger->update();
|
||||
auto network_start = Clock::now();
|
||||
m_connection->update();
|
||||
m_network_metrics->record_update_time(Clock::now() - network_start);
|
||||
|
||||
if(!m_is_connected && false) {
|
||||
return;
|
||||
} else {
|
||||
m_network_metrics->sample({
|
||||
.bytes_sent = m_connection->endpoint()->bytes_sent(),
|
||||
.bytes_received = m_connection->endpoint()->bytes_received(),
|
||||
.messages_sent = m_connection->endpoint()->messages_sent(),
|
||||
.messages_received = m_connection->endpoint()->messages_received()
|
||||
});
|
||||
|
||||
// CharacterController& character = m_world->registry().get<CharacterController>(m_player_entity);
|
||||
// character.set_input(m_frame_idx, m_player_controller.input());
|
||||
{
|
||||
glm::vec3 input = m_player_controller.input();
|
||||
|
||||
if(m_player_entity.has_value()) {
|
||||
CharacterController* controller = m_world->registry().try_get<CharacterController>(m_player_entity.value());
|
||||
if(controller) {
|
||||
controller->set_input(m_frame_idx, input);
|
||||
m_reconciler.record_input(m_frame_idx, input);
|
||||
}
|
||||
}
|
||||
|
||||
m_physics_world->step(m_frame_idx, JoltPhysicsWorld::FIXED_DELTA_TIME, true);
|
||||
|
||||
if(m_player_entity.has_value()) {
|
||||
Transform* player_transform = m_world->registry().try_get<Transform>(m_player_entity.value());
|
||||
if(player_transform) {
|
||||
glm::vec3 true_position = player_transform->position();
|
||||
m_reconciler.record_prediction(m_frame_idx, true_position);
|
||||
|
||||
m_render_prev_position = m_render_curr_position;
|
||||
m_render_curr_position = true_position;
|
||||
m_tick_accumulator = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
mmo::PlayerMoveMessage player_move_message = {};
|
||||
|
||||
player_move_message.set_frame_idx(m_frame_idx);
|
||||
mmo::PlayerInput* player_input = new mmo::PlayerInput();
|
||||
player_input->set_x(m_player_controller.input().x);
|
||||
player_input->set_y(m_player_controller.input().y);
|
||||
player_input->set_z(m_player_controller.input().z);
|
||||
player_input->set_x(input.x);
|
||||
player_input->set_y(input.y);
|
||||
player_input->set_z(input.z);
|
||||
|
||||
player_move_message.set_allocated_input(player_input);
|
||||
auto r = m_messenger->send(player_move_message);
|
||||
auto r = m_messages.send(m_connection->server(), player_move_message, false);
|
||||
|
||||
// CharacterBody& ts = m_world->registry().get<CharacterBody>(m_player_entity);
|
||||
// auto position = ts.m_character->GetPosition();
|
||||
// character.position_history().set(m_frame_idx, glm::vec3(position[0], position[1], position[2]));
|
||||
// EntityInterpolation& interpolation = m_world->registry().get<EntityInterpolation>(m_player_entity);
|
||||
// interpolation.push(std::chrono::steady_clock::now(), glm::vec3(position[0], position[1], position[2]));
|
||||
m_input_send_times[m_frame_idx % INPUT_SEND_TIME_COUNT] = Clock::now();
|
||||
|
||||
// m_player_controller.set_target(glm::vec3(position[0], position[1], position[2]));
|
||||
//
|
||||
export_entity_history();
|
||||
|
||||
m_frame_idx++;
|
||||
@@ -268,9 +421,27 @@ void ClientWorldController::update(double delta_time) {
|
||||
}
|
||||
}
|
||||
|
||||
m_physics_world->step(m_frame_idx, delta_time);
|
||||
|
||||
m_entity_interpolator.update();
|
||||
|
||||
if(m_player_entity.has_value()) {
|
||||
Transform* player_transform = m_world->registry().try_get<Transform>(m_player_entity.value());
|
||||
if(player_transform) {
|
||||
m_visual_error *= std::exp(-delta_time * kVisualErrorDecayRate);
|
||||
if(glm::length(m_visual_error) < 0.001f) {
|
||||
m_visual_error = glm::vec3(0.0f);
|
||||
}
|
||||
|
||||
m_tick_accumulator += delta_time;
|
||||
float alpha = glm::clamp(
|
||||
static_cast<float>(m_tick_accumulator / JoltPhysicsWorld::FIXED_DELTA_TIME),
|
||||
0.0f, 1.0f
|
||||
);
|
||||
glm::vec3 smoothed_position = glm::mix(m_render_prev_position, m_render_curr_position, alpha) + m_visual_error;
|
||||
player_transform->set_position(smoothed_position);
|
||||
|
||||
m_player_controller.set_target(smoothed_position);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
#include <entt/entt.hpp>
|
||||
#include <glm/gtx/io.hpp>
|
||||
|
||||
#include "Address.hpp"
|
||||
#include "TcpStream.hpp"
|
||||
#include "messenger/MessageHandler.hpp"
|
||||
#include "ProtobufMessages.hpp"
|
||||
#include "debug/metrics/NetworkMetrics.hpp"
|
||||
#include "entt/entity/fwd.hpp"
|
||||
#include "io/InputState.hpp"
|
||||
#include "metrics/HistoryBufferExporter.hpp"
|
||||
@@ -15,7 +14,11 @@
|
||||
#include "draw/WorldRenderer.hpp"
|
||||
#include "world/ThirdPersonPlayerController.hpp"
|
||||
#include "network/EntityPositionInterpolator.hpp"
|
||||
#include "network/PlayerReconciler.hpp"
|
||||
|
||||
namespace tw::net {
|
||||
class ServerConnection;
|
||||
}
|
||||
|
||||
namespace tw {
|
||||
|
||||
@@ -35,10 +38,14 @@ class ClientWorldController {
|
||||
drw::WorldRenderer* m_world_renderer;
|
||||
JoltPhysicsWorld* m_physics_world;
|
||||
|
||||
entt::entity m_player_entity;
|
||||
std::optional<entt::entity> m_player_entity;
|
||||
std::optional<uint32_t> m_controlled_server_id;
|
||||
ThirdPersonPlayerController m_player_controller;
|
||||
|
||||
std::optional<tw::net::MessageHandler> m_messenger;
|
||||
net::ServerConnection* m_connection;
|
||||
ProtobufMessages m_messages;
|
||||
|
||||
dbg::NetworkMetrics* m_network_metrics;
|
||||
|
||||
LockStep m_tick_step;
|
||||
|
||||
@@ -47,15 +54,62 @@ class ClientWorldController {
|
||||
|
||||
glm::vec3 m_input;
|
||||
|
||||
bool m_is_connected;
|
||||
|
||||
std::optional<drw::Mesh> m_mesh;
|
||||
|
||||
using Clock = std::chrono::steady_clock;
|
||||
|
||||
void try_bind_player_entity();
|
||||
|
||||
/**
|
||||
* Places the player at an authoritative position outright, clearing the
|
||||
* predicted state that led there. Used for the first position the server
|
||||
* sends, which the local simulation has no history to reconcile against.
|
||||
*/
|
||||
void snap_player_to(entt::entity entity, glm::vec3 position);
|
||||
|
||||
/**
|
||||
* Whether the server has placed the player at least once. Entities are
|
||||
* created before their position arrives, so the body starts at the origin
|
||||
* and has to be moved once the first position shows up.
|
||||
*/
|
||||
bool m_player_position_initialized = false;
|
||||
|
||||
/**
|
||||
* When each input was sent, indexed by its frame. Holds the most recent
|
||||
* INPUT_SEND_TIME_COUNT frames; an answer that takes longer than that goes
|
||||
* unmeasured.
|
||||
*/
|
||||
static constexpr size_t INPUT_SEND_TIME_COUNT = 256;
|
||||
|
||||
std::vector<Clock::time_point> m_input_send_times;
|
||||
uint32_t m_last_measured_frame = 0;
|
||||
|
||||
/**
|
||||
* Records how long the answer to `frame_idx` took to arrive, ignoring
|
||||
* frames that were already measured or are too old to still have a send
|
||||
* time.
|
||||
*/
|
||||
void measure_response_time(uint32_t frame_idx);
|
||||
|
||||
HistoryBufferExporter<long, glm::vec3> m_position_history_exporter;
|
||||
|
||||
net::EntityPositionInterpolator m_entity_interpolator;
|
||||
|
||||
net::PlayerReconciler m_reconciler;
|
||||
|
||||
/**
|
||||
* Visual smoothing for render-rate interpolation between 20 Hz ticks.
|
||||
*/
|
||||
glm::vec3 m_render_prev_position{0.0f};
|
||||
glm::vec3 m_render_curr_position{0.0f};
|
||||
double m_tick_accumulator = 0.0;
|
||||
|
||||
/**
|
||||
* Visual error from reconciliation corrections, decays over time.
|
||||
*/
|
||||
glm::vec3 m_visual_error{0.0f};
|
||||
static constexpr double kVisualErrorDecayRate = 12.0;
|
||||
|
||||
/**
|
||||
* Mapping from the server entity_id to local entity_id
|
||||
* Server might have the same entity under different name
|
||||
@@ -65,8 +119,6 @@ class ClientWorldController {
|
||||
|
||||
entt::entity create_entity(const std::string& name, glm::vec3 position);
|
||||
|
||||
net::MessageHandler create_messenger();
|
||||
|
||||
std::optional<entt::entity> map_from_server_entity(int id);
|
||||
|
||||
void map_server_entity(int server_id, entt::entity local_id);
|
||||
@@ -86,7 +138,8 @@ public:
|
||||
World* world,
|
||||
JoltPhysicsWorld* physics_world,
|
||||
drw::WorldRenderer* world_renderer,
|
||||
tw::net::Address address
|
||||
net::ServerConnection* connection,
|
||||
dbg::NetworkMetrics* network_metrics
|
||||
);
|
||||
|
||||
~ClientWorldController();
|
||||
|
||||
Reference in New Issue
Block a user