#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
|
||||
Reference in New Issue
Block a user