mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-18 00:27:06 +02:00
Objects other players place are spawned locally, so the game took every one for the local player's own: all were solid, and their card offered the owner's buttons and named the wrong player. - Other players' objects are ghosts the skater passes through, as in the game's own sessions. Each player's row in the session menu has an "Objects: solid / ghost" switch, remembered by Steam ID; a dedicated server's own objects (its parks) are solid unless switched off. `mp objects-solid <SteamID64|server> on|off` does the same. - The card's own Disable and Enable on one object stand over that player's setting until the setting is changed. - The card on an object someone else placed no longer offers Edit or Delete: the game's "is this mine" and "may this be edited" checks answer for who really placed it. It offers Disable instead. - The card names the owner of the object it is for, read from the card's own script, instead of the owner of the object nearest the skater. - A dedicated server has a player card of its own, "Server", shown on what it placed. Server: the Mods folder is kept when the configuration is reloaded. A park mod was reported as not being in the Mods folder after a reload.
3305 lines
150 KiB
C++
3305 lines
150 KiB
C++
// ReSkate Dedicated Server GUI
|
|
// Built for DingoSDK ReSkate Dedicated Server — Made with love by wxndr
|
|
|
|
#ifndef WIN32_LEAN_AND_MEAN
|
|
#define WIN32_LEAN_AND_MEAN
|
|
#endif
|
|
#ifndef NOMINMAX
|
|
#define NOMINMAX
|
|
#endif
|
|
#include "server_gui.h"
|
|
#include "server_gui_renderer.h"
|
|
#include "../server_engine.h"
|
|
#include "../server_words.h"
|
|
#include "Extension/UI/skate_theme.h"
|
|
#include "Extension/UI/Overlay/skate_style.h"
|
|
#include "Extension/Multiplayer/Net/protocol.h"
|
|
#include "Extension/Multiplayer/developer_identity.h"
|
|
#include "Extension/UI/Overlay/role_badge.h"
|
|
|
|
#include <dwmapi.h>
|
|
#include <uxtheme.h>
|
|
#include <shellapi.h>
|
|
#ifdef small
|
|
#undef small
|
|
#endif
|
|
#include <backends/imgui_impl_win32.h>
|
|
#include <backends/imgui_impl_dx12.h>
|
|
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <chrono>
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
#include <filesystem>
|
|
#include <format>
|
|
#include <string>
|
|
|
|
extern IMGUI_IMPL_API LRESULT ImGui_ImplWin32_WndProcHandler(HWND, UINT, WPARAM, LPARAM);
|
|
|
|
namespace dingosdk::server_gui {
|
|
|
|
namespace fs = std::filesystem;
|
|
using namespace skate_theme;
|
|
using namespace dingosdk::server;
|
|
|
|
namespace {
|
|
|
|
HWND g_window = nullptr;
|
|
Renderer g_renderer;
|
|
GuiState g_state;
|
|
GuiFonts g_fonts;
|
|
float g_scale = 1.0f;
|
|
bool g_drag_allowed = true;
|
|
|
|
inline float S(float v) { return v * g_scale; }
|
|
constexpr ImU32 muted = skate_theme::grey_text;
|
|
|
|
// ============================================================================
|
|
// 1-1 In-Game Nametag, Role Badge, and Animated Gradient Architecture
|
|
// (Directly using game headers: custom_nametags.h, role_badge.h, nametag_gradient.h)
|
|
// ============================================================================
|
|
using namespace dingosdk::overlay::detail;
|
|
using namespace dingosdk::multiplayer;
|
|
|
|
// 1:1 In-Game Player Role & Tag Resolver (session_view.cpp)
|
|
inline std::pair<std::uint32_t, std::string> player_role(std::uint64_t id, bool admin) {
|
|
if (!id) return {nametag_white, ""};
|
|
if (const auto mark = multiplayer::identity_mark(id)) {
|
|
switch (*mark) {
|
|
case multiplayer::IdentityList::developer: return {nametag_developer, "Dev"};
|
|
case multiplayer::IdentityList::content_creator: return {nametag_creator, "Content Creator"};
|
|
case multiplayer::IdentityList::centrix: return {nametag_centrix, "Centrix"};
|
|
case multiplayer::IdentityList::staff: return {nametag_staff, "ReSkate Admin"};
|
|
default: return {nametag_homie, "Homie"};
|
|
}
|
|
}
|
|
if (admin) return {nametag_admin, "Admin"};
|
|
return {nametag_white, ""};
|
|
}
|
|
|
|
void show_notification(const std::string& message, double duration = 3.5, ImU32 accent = 0) {
|
|
if (message.empty()) return;
|
|
const double until = ImGui::GetTime() + duration;
|
|
g_state.feedback_text = message;
|
|
g_state.feedback_until = until;
|
|
|
|
g_state.notifications.insert(g_state.notifications.begin(), {message, until, accent});
|
|
if (g_state.notifications.size() > 5) {
|
|
g_state.notifications.resize(5);
|
|
}
|
|
}
|
|
|
|
// --- Section Header with Blue Accent Bar (1-1 with ReSkate in-game menu) ---
|
|
void section(const char* text) {
|
|
ImGui::Spacing();
|
|
ImGui::PushFont(g_fonts.heading);
|
|
ImGui::TextUnformatted(text);
|
|
ImGui::PopFont();
|
|
const auto at = ImGui::GetCursorScreenPos();
|
|
auto* draw = ImGui::GetWindowDrawList();
|
|
const float avail_x = ImGui::GetContentRegionAvail().x;
|
|
draw->AddRectFilled(at, ImVec2(at.x + avail_x, at.y + S(2)), tile_light);
|
|
draw->AddRectFilled(at, ImVec2(at.x + S(34), at.y + S(3)), blue);
|
|
ImGui::Dummy(ImVec2(0, S(8)));
|
|
}
|
|
|
|
// --- Card Container with 4px Blue Left Accent (1-1 with ReSkate in-game menu) ---
|
|
void begin_card(const char* id, const char* title, const char* subtitle = nullptr) {
|
|
ImGui::PushStyleColor(ImGuiCol_ChildBg, tile);
|
|
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(S(16), S(14)));
|
|
ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, 0);
|
|
ImGui::BeginChild(id, ImVec2(0, 0), ImGuiChildFlags_AutoResizeY | ImGuiChildFlags_AlwaysUseWindowPadding,
|
|
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
|
|
ImGui::PopStyleVar(2);
|
|
ImGui::PopStyleColor();
|
|
|
|
const auto at = ImGui::GetWindowPos();
|
|
ImGui::GetWindowDrawList()->AddRectFilled(at, ImVec2(at.x + S(4), at.y + ImGui::GetWindowHeight()), blue);
|
|
|
|
if (title) {
|
|
ImGui::PushFont(g_fonts.bold);
|
|
ImGui::TextUnformatted(title);
|
|
ImGui::PopFont();
|
|
if (subtitle) {
|
|
ImGui::SameLine(0, S(10));
|
|
ImGui::PushStyleColor(ImGuiCol_Text, muted);
|
|
ImGui::TextUnformatted(subtitle);
|
|
ImGui::PopStyleColor();
|
|
}
|
|
ImGui::Dummy(ImVec2(0, S(4)));
|
|
}
|
|
}
|
|
|
|
void end_card() {
|
|
ImGui::EndChild();
|
|
ImGui::Dummy(ImVec2(0, S(8)));
|
|
}
|
|
|
|
// --- Stat / Metric Display Row ---
|
|
void stat_row(const char* label, const std::string& value, ImU32 value_color = white) {
|
|
const float col = ImGui::GetCursorPosX() + S(220);
|
|
ImGui::PushStyleColor(ImGuiCol_Text, muted);
|
|
ImGui::TextUnformatted(label);
|
|
ImGui::PopStyleColor();
|
|
ImGui::SameLine(col);
|
|
ImGui::PushStyleColor(ImGuiCol_Text, value_color);
|
|
ImGui::TextUnformatted(value.c_str());
|
|
ImGui::PopStyleColor();
|
|
}
|
|
|
|
// --- Pill Status Badge ---
|
|
void badge(const char* text, ImU32 bg_color, ImU32 text_color = black) {
|
|
const auto size = g_fonts.bold->CalcTextSizeA(S(12), FLT_MAX, 0, text);
|
|
const ImVec2 pad(S(8), S(4));
|
|
const auto at = ImGui::GetCursorScreenPos();
|
|
const float line = ImGui::GetFrameHeight();
|
|
const float top = at.y + std::max(0.0f, (line - size.y - pad.y * 2) * 0.5f);
|
|
ImGui::Dummy(ImVec2(size.x + pad.x * 2, std::max(line, size.y + pad.y * 2)));
|
|
auto* draw = ImGui::GetWindowDrawList();
|
|
draw->AddRectFilled(ImVec2(at.x, top), ImVec2(at.x + size.x + pad.x * 2, top + size.y + pad.y * 2), bg_color, S(3));
|
|
draw->AddText(g_fonts.bold, S(12), ImVec2(at.x + pad.x, top + pad.y), text_color, text);
|
|
}
|
|
|
|
// --- Feature Chip for Capability Tags ---
|
|
void feature_chip(const char* text) {
|
|
const auto size = g_fonts.caption->CalcTextSizeA(S(11), FLT_MAX, 0, text);
|
|
const ImVec2 pad(S(8), S(3));
|
|
const auto at = ImGui::GetCursorScreenPos();
|
|
const float line = ImGui::GetFrameHeight();
|
|
const float item_h = size.y + pad.y * 2;
|
|
const float top = at.y + std::max(0.0f, (line - item_h) * 0.5f);
|
|
ImGui::Dummy(ImVec2(size.x + pad.x * 2, std::max(line, item_h)));
|
|
auto* draw = ImGui::GetWindowDrawList();
|
|
draw->AddRectFilled(ImVec2(at.x, top), ImVec2(at.x + size.x + pad.x * 2, top + item_h),
|
|
IM_COL32(36, 40, 48, 255), S(3));
|
|
draw->AddRect(ImVec2(at.x, top), ImVec2(at.x + size.x + pad.x * 2, top + item_h),
|
|
IM_COL32(56, 62, 74, 255), S(3));
|
|
draw->AddText(g_fonts.caption, S(11), ImVec2(at.x + pad.x, top + pad.y), white, text);
|
|
}
|
|
|
|
// --- Toggle Row (1-1 with skate_menu.cpp toggle_row) ---
|
|
bool toggle_row(const char* label, const char* hint, bool& value) {
|
|
ImGui::PushID(label);
|
|
const auto at = ImGui::GetCursorScreenPos();
|
|
const float width = ImGui::GetContentRegionAvail().x;
|
|
const float height = S(36);
|
|
const bool clicked = ImGui::Selectable("##toggle", false, ImGuiSelectableFlags_None, ImVec2(width, height));
|
|
const bool hovered = ImGui::IsItemHovered();
|
|
if (clicked) value = !value;
|
|
|
|
auto* draw = ImGui::GetWindowDrawList();
|
|
const unsigned seed = static_cast<unsigned>(ImGui::GetItemID());
|
|
rough_rect(draw, at, ImVec2(at.x + width, at.y + height - S(3)),
|
|
hovered ? tile_light : tile_grey, seed, g_scale);
|
|
|
|
draw->AddText(g_fonts.bold, S(15), ImVec2(at.x + S(12), at.y + S(8)), white, label);
|
|
|
|
const char* state_str = value ? "ON" : "OFF";
|
|
const float block = S(58);
|
|
const ImVec2 box(at.x + width - block - S(6), at.y + S(6));
|
|
draw->AddRectFilled(box, ImVec2(box.x + block, at.y + height - S(9)), value ? blue : IM_COL32(0, 0, 0, 90));
|
|
const auto text_size = g_fonts.bold->CalcTextSizeA(S(13), FLT_MAX, 0, state_str);
|
|
draw->AddText(g_fonts.bold, S(13),
|
|
ImVec2(box.x + (block - text_size.x) * 0.5f, box.y + (height - S(15) - text_size.y) * 0.5f),
|
|
value ? black : muted, state_str);
|
|
|
|
if (hovered && hint && *hint) ImGui::SetTooltip("%s", hint);
|
|
ImGui::PopID();
|
|
return clicked;
|
|
}
|
|
|
|
// --- Category Tabs Row (1-1 with category_tabs from skate_menu.cpp) ---
|
|
void category_tabs(int& selected, const std::vector<const char*>& tabs, const char* id) {
|
|
selected = std::clamp(selected, 0, static_cast<int>(tabs.size()) - 1);
|
|
ImGui::PushID(id);
|
|
const float gap = S(6);
|
|
const float tab_width = (ImGui::GetContentRegionAvail().x - gap * (tabs.size() - 1)) / tabs.size();
|
|
const float height = S(34);
|
|
auto* draw = ImGui::GetWindowDrawList();
|
|
|
|
for (int i = 0; i < static_cast<int>(tabs.size()); ++i) {
|
|
if (i) ImGui::SameLine(0, gap);
|
|
const auto at = ImGui::GetCursorScreenPos();
|
|
ImGui::PushID(i);
|
|
if (ImGui::InvisibleButton("##tab", ImVec2(tab_width, height)) && selected != i) {
|
|
selected = i;
|
|
}
|
|
const bool hovered = ImGui::IsItemHovered();
|
|
ImGui::PopID();
|
|
|
|
const bool on = selected == i;
|
|
rough_rect(draw, at, ImVec2(at.x + tab_width, at.y + height),
|
|
on ? blue : hovered ? tile_light : tile_grey, static_cast<unsigned>(i + 17), g_scale);
|
|
const char* label = tabs[i];
|
|
const auto size = g_fonts.bold->CalcTextSizeA(S(14), FLT_MAX, 0, label);
|
|
draw->AddText(g_fonts.bold, S(14),
|
|
ImVec2(at.x + std::max(S(6), (tab_width - size.x) * 0.5f), at.y + (height - size.y) * 0.5f),
|
|
on ? black : white, label);
|
|
}
|
|
ImGui::PopID();
|
|
ImGui::Dummy(ImVec2(0, S(8)));
|
|
}
|
|
|
|
// --- Rough Rect Primary Button ---
|
|
bool rough_button(const char* label, ImVec2 size = ImVec2(0, 0), bool active = false) {
|
|
const float width = size.x > 0 ? size.x : ImGui::GetContentRegionAvail().x;
|
|
const float height = size.y > 0 ? size.y : S(34);
|
|
const auto at = ImGui::GetCursorScreenPos();
|
|
const bool clicked = ImGui::InvisibleButton(label, ImVec2(width, height));
|
|
const bool hovered = ImGui::IsItemHovered();
|
|
if (hovered) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
|
|
|
auto* draw = ImGui::GetWindowDrawList();
|
|
const unsigned seed = static_cast<unsigned>(ImGui::GetItemID());
|
|
rough_rect(draw, at, ImVec2(at.x + width, at.y + height),
|
|
active ? blue : hovered ? tile_light : tile_grey, seed, g_scale);
|
|
|
|
const auto text_size = g_fonts.bold->CalcTextSizeA(S(13), FLT_MAX, 0, label);
|
|
draw->AddText(g_fonts.bold, S(13),
|
|
ImVec2(at.x + std::max(S(6), (width - text_size.x) * 0.5f), at.y + (height - text_size.y) * 0.5f),
|
|
active ? black : white, label);
|
|
return clicked;
|
|
}
|
|
|
|
// --- High-Visibility Colored Action Button ---
|
|
bool colored_action_button(const char* label, ImVec2 size = ImVec2(0, 0), ImU32 bg_col = tile_grey, ImU32 text_col = white, const char* tooltip = nullptr) {
|
|
const float width = size.x > 0 ? size.x : ImGui::GetContentRegionAvail().x;
|
|
const float height = size.y > 0 ? size.y : S(30);
|
|
const auto at = ImGui::GetCursorScreenPos();
|
|
const bool clicked = ImGui::InvisibleButton(label, ImVec2(width, height));
|
|
const bool hovered = ImGui::IsItemHovered();
|
|
if (hovered) {
|
|
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
|
if (tooltip && tooltip[0]) ImGui::SetTooltip("%s", tooltip);
|
|
}
|
|
|
|
auto* draw = ImGui::GetWindowDrawList();
|
|
ImU32 fill = bg_col;
|
|
if (hovered) {
|
|
const int r = std::min(255, (int)((bg_col & 0xFF) * 1.18f));
|
|
const int g = std::min(255, (int)(((bg_col >> 8) & 0xFF) * 1.18f));
|
|
const int b = std::min(255, (int)(((bg_col >> 16) & 0xFF) * 1.18f));
|
|
const int a = (bg_col >> 24) & 0xFF;
|
|
fill = IM_COL32(r, g, b, a);
|
|
}
|
|
draw->AddRectFilled(at, ImVec2(at.x + width, at.y + height), fill, S(4.0f));
|
|
|
|
const auto text_size = g_fonts.bold->CalcTextSizeA(S(13), FLT_MAX, 0, label);
|
|
draw->AddText(g_fonts.bold, S(13),
|
|
ImVec2(at.x + std::max(S(6), (width - text_size.x) * 0.5f), at.y + (height - text_size.y) * 0.5f),
|
|
text_col, label);
|
|
return clicked;
|
|
}
|
|
|
|
// --- Server Lifecycle Status Pill ---
|
|
void draw_server_status_pill(ServerState state) {
|
|
const float seg_h = S(30.0f);
|
|
const float rounding = S(4.0f);
|
|
ImU32 bg_col = danger;
|
|
ImU32 text_col = white;
|
|
const char* text = "OFFLINE";
|
|
|
|
switch (state) {
|
|
case ServerState::Running:
|
|
bg_col = good;
|
|
text_col = black;
|
|
text = "ONLINE";
|
|
break;
|
|
case ServerState::Starting:
|
|
bg_col = warning;
|
|
text_col = black;
|
|
text = "STARTING...";
|
|
break;
|
|
case ServerState::Stopping:
|
|
bg_col = warning;
|
|
text_col = black;
|
|
text = "STOPPING...";
|
|
break;
|
|
case ServerState::Error:
|
|
bg_col = danger;
|
|
text_col = white;
|
|
text = "SERVER ERROR";
|
|
break;
|
|
case ServerState::Stopped:
|
|
default:
|
|
bg_col = danger;
|
|
text_col = white;
|
|
text = "OFFLINE";
|
|
break;
|
|
}
|
|
|
|
const auto sz_text = g_fonts.bold->CalcTextSizeA(S(12.0f), FLT_MAX, 0.0f, text);
|
|
const float pad_x = S(14.0f);
|
|
const float total_w = sz_text.x + pad_x * 2.0f;
|
|
|
|
const auto origin = ImGui::GetCursorScreenPos();
|
|
auto* draw = ImGui::GetWindowDrawList();
|
|
|
|
ImGui::PushID("pill_server_status");
|
|
ImGui::InvisibleButton("##status_pill_btn", ImVec2(total_w, seg_h));
|
|
const bool hovered = ImGui::IsItemHovered();
|
|
if (hovered) {
|
|
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
|
ImGui::SetTooltip("Server Lifecycle State: %s", text);
|
|
}
|
|
ImGui::PopID();
|
|
|
|
draw->AddRectFilled(origin, ImVec2(origin.x + total_w, origin.y + seg_h), bg_col, rounding);
|
|
if (hovered) {
|
|
draw->AddRectFilled(origin, ImVec2(origin.x + total_w, origin.y + seg_h), IM_COL32(255, 255, 255, 30), rounding);
|
|
}
|
|
const float text_x = origin.x + (total_w - sz_text.x) * 0.5f;
|
|
const float text_y = origin.y + (seg_h - sz_text.y) * 0.5f;
|
|
draw->AddText(g_fonts.bold, S(12.0f), ImVec2(text_x, text_y), text_col, text);
|
|
}
|
|
|
|
// --- Live Player Count Pill ---
|
|
void draw_player_count_pill(unsigned count, unsigned max_p, bool is_online) {
|
|
const float seg_h = S(30.0f);
|
|
const float rounding = S(4.0f);
|
|
|
|
char count_str[32];
|
|
std::snprintf(count_str, sizeof(count_str), "%u / %u", count, max_p);
|
|
|
|
const auto sz_label = g_fonts.bold->CalcTextSizeA(S(11.0f), FLT_MAX, 0.0f, "PLAYERS");
|
|
const auto sz_count = g_fonts.bold->CalcTextSizeA(S(13.0f), FLT_MAX, 0.0f, count_str);
|
|
|
|
const float dot_r = S(3.5f);
|
|
const float pad_x = S(10.0f);
|
|
const float gap_dot_label = S(8.0f);
|
|
const float gap_label_sep = S(10.0f);
|
|
const float gap_sep_count = S(10.0f);
|
|
|
|
const float total_w = pad_x + (dot_r * 2.0f) + gap_dot_label + sz_label.x + gap_label_sep + 1.0f + gap_sep_count + sz_count.x + pad_x;
|
|
|
|
const auto origin = ImGui::GetCursorScreenPos();
|
|
auto* draw = ImGui::GetWindowDrawList();
|
|
|
|
ImGui::PushID("pill_player_count");
|
|
const bool clicked = ImGui::InvisibleButton("##player_pill_btn", ImVec2(total_w, seg_h));
|
|
const bool hovered = ImGui::IsItemHovered();
|
|
if (hovered) {
|
|
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
|
if (is_online) {
|
|
ImGui::SetTooltip("Connected Players: %u / %u\nClick to query active players in console", count, max_p);
|
|
} else {
|
|
ImGui::SetTooltip("Server Offline (Capacity: %u players)\nClick to check status", max_p);
|
|
}
|
|
}
|
|
if (clicked) {
|
|
if (is_online) ServerEngine::instance().queue_command("players");
|
|
else ServerEngine::instance().log("Server is offline. No players connected.", LogLevel::warning);
|
|
}
|
|
ImGui::PopID();
|
|
|
|
// 1. Background rectangle (clean flat, no border outline)
|
|
draw->AddRectFilled(origin, ImVec2(origin.x + total_w, origin.y + seg_h), hovered ? tile_light : tile_grey, rounding);
|
|
|
|
// 2. Status Dot
|
|
const ImU32 dot_col = is_online ? good : muted;
|
|
const ImVec2 dot_center(origin.x + pad_x + dot_r, origin.y + seg_h * 0.5f);
|
|
draw->AddCircleFilled(dot_center, dot_r, dot_col);
|
|
|
|
// 3. Label "PLAYERS"
|
|
const float label_x = origin.x + pad_x + (dot_r * 2.0f) + gap_dot_label;
|
|
const float label_y = origin.y + (seg_h - sz_label.y) * 0.5f;
|
|
draw->AddText(g_fonts.bold, S(11.0f), ImVec2(label_x, label_y), muted, "PLAYERS");
|
|
|
|
// 4. Subtle vertical divider line
|
|
const float sep_x = label_x + sz_label.x + gap_label_sep;
|
|
draw->AddLine(ImVec2(sep_x, origin.y + S(4.0f)), ImVec2(sep_x, origin.y + seg_h - S(4.0f)), IM_COL32(26, 26, 28, 255), 1.0f);
|
|
|
|
// 5. Count string "0 / 249"
|
|
const float count_x = sep_x + 1.0f + gap_sep_count;
|
|
const float count_y = origin.y + (seg_h - sz_count.y) * 0.5f;
|
|
const ImU32 count_col = (is_online && count > 0) ? good : white;
|
|
draw->AddText(g_fonts.bold, S(13.0f), ImVec2(count_x, count_y), count_col, count_str);
|
|
}
|
|
|
|
// --- Connected Server Control Block [ ▶ | ↻ | ■ ] ---
|
|
void draw_server_control_block(const ServerStatusSnapshot& stats) {
|
|
const float seg_w = S(38.0f);
|
|
const float seg_h = S(30.0f);
|
|
const float total_w = seg_w * 3.0f;
|
|
const float rounding = S(4.0f);
|
|
|
|
const bool is_offline = (stats.state == ServerState::Stopped || stats.state == ServerState::Error);
|
|
const bool is_active = !is_offline;
|
|
|
|
const bool start_enabled = is_offline;
|
|
const bool restart_enabled = is_active;
|
|
const bool stop_enabled = is_active;
|
|
|
|
const auto origin = ImGui::GetCursorScreenPos();
|
|
auto* draw = ImGui::GetWindowDrawList();
|
|
|
|
// 1. Base container background using the UI's pre-existing tile_grey
|
|
draw->AddRectFilled(origin, ImVec2(origin.x + total_w, origin.y + seg_h), tile_grey, rounding);
|
|
|
|
// Segment 0: Start (▶ Green)
|
|
ImGui::PushID("ctrl_seg_start");
|
|
const bool p0_clicked = ImGui::InvisibleButton("##start", ImVec2(seg_w, seg_h));
|
|
const bool start_hovered = ImGui::IsItemHovered();
|
|
if (start_hovered && start_enabled) {
|
|
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
|
}
|
|
if (start_hovered) {
|
|
ImGui::SetTooltip("%s", start_enabled ? "Start Server" : "Server is already active");
|
|
}
|
|
if (p0_clicked && start_enabled) {
|
|
ServerEngine::instance().start_server_async();
|
|
show_notification("Starting dedicated server session...", 3.5, good);
|
|
}
|
|
ImGui::PopID();
|
|
|
|
// Segment 1: Restart (↻ Yellow)
|
|
ImGui::SameLine(0, 0);
|
|
ImGui::PushID("ctrl_seg_restart");
|
|
const bool p1_clicked = ImGui::InvisibleButton("##restart", ImVec2(seg_w, seg_h));
|
|
const bool restart_hovered = ImGui::IsItemHovered();
|
|
if (restart_hovered && restart_enabled) {
|
|
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
|
}
|
|
if (restart_hovered) {
|
|
ImGui::SetTooltip("%s", restart_enabled ? "Restart Server" : "Server is offline");
|
|
}
|
|
if (p1_clicked && restart_enabled) {
|
|
ServerEngine::instance().restart_server_async();
|
|
show_notification("Restarting dedicated server...", 3.5, warning);
|
|
}
|
|
ImGui::PopID();
|
|
|
|
// Segment 2: Stop (■ Red)
|
|
ImGui::SameLine(0, 0);
|
|
ImGui::PushID("ctrl_seg_stop");
|
|
const bool p2_clicked = ImGui::InvisibleButton("##stop", ImVec2(seg_w, seg_h));
|
|
const bool stop_hovered = ImGui::IsItemHovered();
|
|
if (stop_hovered && stop_enabled) {
|
|
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
|
}
|
|
if (stop_hovered) {
|
|
ImGui::SetTooltip("%s", stop_enabled ? "Stop Server" : "Server is offline");
|
|
}
|
|
if (p2_clicked && stop_enabled) {
|
|
ServerEngine::instance().stop_server_async();
|
|
show_notification("Stopping server session...", 3.5, danger);
|
|
}
|
|
ImGui::PopID();
|
|
|
|
// 2. Render segment filled backgrounds
|
|
// --- Segment 0 (Start) ---
|
|
const ImVec2 p0_min = origin;
|
|
const ImVec2 p0_max(origin.x + seg_w, origin.y + seg_h);
|
|
ImU32 icon0_color = grey_text;
|
|
if (start_enabled) {
|
|
draw->AddRectFilled(p0_min, p0_max, IM_COL32(38, 195, 95, 255), rounding, ImDrawFlags_RoundCornersLeft);
|
|
if (start_hovered) {
|
|
draw->AddRectFilled(p0_min, p0_max, IM_COL32(255, 255, 255, 40), rounding, ImDrawFlags_RoundCornersLeft);
|
|
}
|
|
icon0_color = white;
|
|
} else if (start_hovered) {
|
|
draw->AddRectFilled(p0_min, p0_max, tile_light, rounding, ImDrawFlags_RoundCornersLeft);
|
|
}
|
|
|
|
// --- Segment 1 (Restart) ---
|
|
const ImVec2 p1_min(origin.x + seg_w, origin.y);
|
|
const ImVec2 p1_max(origin.x + seg_w * 2.0f, origin.y + seg_h);
|
|
ImU32 icon1_color = grey_text;
|
|
if (restart_enabled) {
|
|
draw->AddRectFilled(p1_min, p1_max, IM_COL32(245, 190, 25, 255), 0.0f, ImDrawFlags_RoundCornersNone);
|
|
if (restart_hovered) {
|
|
draw->AddRectFilled(p1_min, p1_max, IM_COL32(255, 255, 255, 45), 0.0f, ImDrawFlags_RoundCornersNone);
|
|
}
|
|
icon1_color = IM_COL32(20, 20, 20, 255);
|
|
} else if (restart_hovered) {
|
|
draw->AddRectFilled(p1_min, p1_max, tile_light, 0.0f, ImDrawFlags_RoundCornersNone);
|
|
}
|
|
|
|
// --- Segment 2 (Stop) ---
|
|
const ImVec2 p2_min(origin.x + seg_w * 2.0f, origin.y);
|
|
const ImVec2 p2_max(origin.x + total_w, origin.y + seg_h);
|
|
ImU32 icon2_color = grey_text;
|
|
if (stop_enabled) {
|
|
draw->AddRectFilled(p2_min, p2_max, IM_COL32(230, 55, 55, 255), rounding, ImDrawFlags_RoundCornersRight);
|
|
if (stop_hovered) {
|
|
draw->AddRectFilled(p2_min, p2_max, IM_COL32(255, 255, 255, 40), rounding, ImDrawFlags_RoundCornersRight);
|
|
}
|
|
icon2_color = white;
|
|
} else if (stop_hovered) {
|
|
draw->AddRectFilled(p2_min, p2_max, tile_light, rounding, ImDrawFlags_RoundCornersRight);
|
|
}
|
|
|
|
// 3. Render vector icons
|
|
// Start Icon (Triangle ▶)
|
|
const ImVec2 c0(origin.x + seg_w * 0.5f, origin.y + seg_h * 0.5f);
|
|
const float r0 = S(6.0f);
|
|
draw->AddTriangleFilled(
|
|
ImVec2(c0.x - r0 * 0.65f, c0.y - r0 * 0.95f),
|
|
ImVec2(c0.x + r0 * 0.95f, c0.y),
|
|
ImVec2(c0.x - r0 * 0.65f, c0.y + r0 * 0.95f),
|
|
icon0_color
|
|
);
|
|
|
|
// Restart Icon (Circular Arrow ↻)
|
|
const ImVec2 c1(origin.x + seg_w * 1.5f, origin.y + seg_h * 0.5f);
|
|
const float r1 = S(6.0f);
|
|
draw->PathArcTo(c1, r1, -2.6f, 1.8f, 16);
|
|
draw->PathStroke(icon1_color, 0, S(2.2f));
|
|
const float end_angle = 1.8f;
|
|
const ImVec2 tip(c1.x + r1 * std::cos(end_angle), c1.y + r1 * std::sin(end_angle));
|
|
draw->AddTriangleFilled(
|
|
ImVec2(tip.x - S(2.5f), tip.y - S(4.5f)),
|
|
tip,
|
|
ImVec2(tip.x + S(4.5f), tip.y - S(1.5f)),
|
|
icon1_color
|
|
);
|
|
|
|
// Stop Icon (Square ■)
|
|
const ImVec2 c2(origin.x + seg_w * 2.5f, origin.y + seg_h * 0.5f);
|
|
const float s2 = S(5.5f);
|
|
draw->AddRectFilled(
|
|
ImVec2(c2.x - s2, c2.y - s2),
|
|
ImVec2(c2.x + s2, c2.y + s2),
|
|
icon2_color,
|
|
S(1.5f)
|
|
);
|
|
|
|
// 4. Clean vertical line separators splitting the 3 segments
|
|
const ImU32 sep_color = IM_COL32(26, 26, 28, 255);
|
|
draw->AddLine(ImVec2(origin.x + seg_w, origin.y), ImVec2(origin.x + seg_w, origin.y + seg_h), sep_color, 1.0f);
|
|
draw->AddLine(ImVec2(origin.x + seg_w * 2.0f, origin.y), ImVec2(origin.x + seg_w * 2.0f, origin.y + seg_h), sep_color, 1.0f);
|
|
}
|
|
|
|
// --- Fonts Loader ---
|
|
ImFont* embedded_font(const wchar_t* name, float size) {
|
|
static const ImWchar ranges[]{0x0020, 0x024F, 0x0400, 0x052F, 0x2000, 0x206F, 0x20A0, 0x20CF, 0x2190, 0x21FF, 0};
|
|
const auto instance = GetModuleHandleW(nullptr);
|
|
const auto resource = FindResourceW(instance, name, MAKEINTRESOURCEW(10));
|
|
if (!resource) return nullptr;
|
|
const auto loaded = LoadResource(instance, resource);
|
|
void* data = loaded ? LockResource(loaded) : nullptr;
|
|
if (!data) return nullptr;
|
|
ImFontConfig config;
|
|
config.FontDataOwnedByAtlas = false;
|
|
config.OversampleH = size >= 32 ? 1 : 2;
|
|
return ImGui::GetIO().Fonts->AddFontFromMemoryTTF(data, static_cast<int>(SizeofResource(instance, resource)),
|
|
S(size), &config, ranges);
|
|
}
|
|
|
|
ImFont* file_font(const fs::path& path, float size) {
|
|
std::error_code ec;
|
|
if (!fs::is_regular_file(path, ec)) return nullptr;
|
|
return ImGui::GetIO().Fonts->AddFontFromFileTTF(path.string().c_str(), S(size));
|
|
}
|
|
|
|
void load_fonts(const fs::path& root_folder) {
|
|
auto& io = ImGui::GetIO();
|
|
io.Fonts->TexGlyphPadding = 2;
|
|
|
|
const auto font_dir = root_folder / "source" / "External" / "fonts";
|
|
const auto ext_dir = root_folder / "External" / "fonts";
|
|
|
|
const auto load = [&](const wchar_t* res_name, const char* file_name, const char* fallback_sys, float size) -> ImFont* {
|
|
if (auto* f = embedded_font(res_name, size)) return f;
|
|
if (auto* f = file_font(font_dir / file_name, size)) return f;
|
|
if (auto* f = file_font(ext_dir / file_name, size)) return f;
|
|
std::array<char, MAX_PATH> win_dir{};
|
|
if (GetWindowsDirectoryA(win_dir.data(), static_cast<UINT>(win_dir.size()))) {
|
|
const auto win_font = fs::path(win_dir.data()) / "Fonts" / fallback_sys;
|
|
if (auto* f = file_font(win_font, size)) return f;
|
|
}
|
|
ImFontConfig cfg;
|
|
cfg.SizePixels = S(size);
|
|
return io.Fonts->AddFontDefault(&cfg);
|
|
};
|
|
|
|
io.Fonts->AddFontDefault();
|
|
g_fonts.body = load(L"FONT_BODY", "Montserrat-SemiBold.ttf", "segoeui.ttf", 15.0f);
|
|
g_fonts.caption = load(L"FONT_BODY", "Montserrat-SemiBold.ttf", "segoeui.ttf", 12.0f);
|
|
g_fonts.bold = load(L"FONT_HEADING", "Montserrat-ExtraBold.ttf", "segoeuib.ttf", 16.0f);
|
|
g_fonts.heading = load(L"FONT_HEADING", "Montserrat-ExtraBold.ttf", "segoeuib.ttf", 22.0f);
|
|
g_fonts.title = load(L"FONT_BRUSH", "PermanentMarker-Regular.ttf", "arialbi.ttf", 42.0f);
|
|
|
|
std::array<char, MAX_PATH> win_dir{};
|
|
if (GetWindowsDirectoryA(win_dir.data(), static_cast<UINT>(win_dir.size()))) {
|
|
g_fonts.mono = file_font(fs::path(win_dir.data()) / "Fonts" / "consola.ttf", 14.0f);
|
|
}
|
|
if (!g_fonts.mono) g_fonts.mono = g_fonts.body;
|
|
|
|
io.FontDefault = g_fonts.body;
|
|
io.Fonts->Build();
|
|
}
|
|
|
|
void apply_style() {
|
|
auto& style = ImGui::GetStyle();
|
|
style.WindowRounding = 0;
|
|
style.FrameRounding = 0;
|
|
style.GrabRounding = 0;
|
|
style.PopupRounding = 0;
|
|
style.ScrollbarRounding = 0;
|
|
style.WindowBorderSize = 0;
|
|
style.FrameBorderSize = 0;
|
|
|
|
style.WindowPadding = ImVec2(S(24), S(20));
|
|
style.FramePadding = ImVec2(S(10), S(7));
|
|
style.ItemSpacing = ImVec2(S(10), S(8));
|
|
style.ScrollbarSize = S(10);
|
|
|
|
auto* c = style.Colors;
|
|
const auto rgb = [](ImU32 col) { return ImGui::ColorConvertU32ToFloat4(col); };
|
|
|
|
c[ImGuiCol_Text] = rgb(white);
|
|
c[ImGuiCol_TextDisabled] = rgb(muted);
|
|
c[ImGuiCol_WindowBg] = rgb(IM_COL32(14, 15, 18, 255));
|
|
c[ImGuiCol_ChildBg] = rgb(tile);
|
|
c[ImGuiCol_PopupBg] = rgb(tile);
|
|
c[ImGuiCol_Border] = rgb(tile_light);
|
|
c[ImGuiCol_FrameBg] = rgb(tile_grey);
|
|
c[ImGuiCol_FrameBgHovered] = rgb(tile_light);
|
|
c[ImGuiCol_FrameBgActive] = rgb(blue_active);
|
|
c[ImGuiCol_TitleBg] = rgb(IM_COL32(10, 10, 11, 255));
|
|
c[ImGuiCol_TitleBgActive] = rgb(IM_COL32(10, 10, 11, 255));
|
|
c[ImGuiCol_Button] = rgb(tile_grey);
|
|
c[ImGuiCol_ButtonHovered] = rgb(tile_light);
|
|
c[ImGuiCol_ButtonActive] = rgb(blue_active);
|
|
c[ImGuiCol_Header] = rgb(tile_grey);
|
|
c[ImGuiCol_HeaderHovered] = rgb(tile_light);
|
|
c[ImGuiCol_HeaderActive] = rgb(blue);
|
|
c[ImGuiCol_CheckMark] = rgb(blue);
|
|
c[ImGuiCol_SliderGrab] = rgb(blue);
|
|
c[ImGuiCol_SliderGrabActive] = rgb(blue_hover);
|
|
c[ImGuiCol_ScrollbarBg] = rgb(IM_COL32(16, 17, 20, 255));
|
|
c[ImGuiCol_ScrollbarGrab] = rgb(tile_light);
|
|
c[ImGuiCol_ScrollbarGrabHovered] = rgb(blue);
|
|
c[ImGuiCol_ScrollbarGrabActive] = rgb(blue_hover);
|
|
c[ImGuiCol_Separator] = rgb(tile_light);
|
|
c[ImGuiCol_TextSelectedBg] = rgb(IM_COL32(1, 131, 255, 115));
|
|
}
|
|
|
|
LRESULT CALLBACK window_procedure(HWND window, UINT message, WPARAM wparam, LPARAM lparam) {
|
|
if (ImGui_ImplWin32_WndProcHandler(window, message, wparam, lparam)) return 1;
|
|
|
|
switch (message) {
|
|
case WM_NCCALCSIZE: {
|
|
if (wparam == TRUE) {
|
|
// Fill entire window rectangle with client area, eliminating default titlebar/borders
|
|
return 0;
|
|
}
|
|
break;
|
|
}
|
|
case WM_NCHITTEST: {
|
|
POINT pt{static_cast<short>(LOWORD(lparam)), static_cast<short>(HIWORD(lparam))};
|
|
ScreenToClient(window, &pt);
|
|
RECT rc;
|
|
GetClientRect(window, &rc);
|
|
|
|
const bool maximized = IsZoomed(window);
|
|
|
|
// Edge and corner resize hit testing when not maximized
|
|
if (!maximized) {
|
|
const int border = static_cast<int>(S(8.0f));
|
|
|
|
if (pt.y < border && pt.x < border) return HTTOPLEFT;
|
|
if (pt.y < border && pt.x >= rc.right - border) return HTTOPRIGHT;
|
|
if (pt.y >= rc.bottom - border && pt.x < border) return HTBOTTOMLEFT;
|
|
if (pt.y >= rc.bottom - border && pt.x >= rc.right - border) return HTBOTTOMRIGHT;
|
|
|
|
if (pt.y < border) return HTTOP;
|
|
if (pt.y >= rc.bottom - border) return HTBOTTOM;
|
|
if (pt.x < border) return HTLEFT;
|
|
if (pt.x >= rc.right - border) return HTRIGHT;
|
|
}
|
|
|
|
// Drag window from top bar (leaving room on the right for window controls)
|
|
if (g_drag_allowed && pt.y >= 0 && pt.y < S(48) && pt.x < (rc.right - S(112.0f))) {
|
|
return HTCAPTION;
|
|
}
|
|
return HTCLIENT;
|
|
}
|
|
case WM_GETMINMAXINFO: {
|
|
auto* mmi = reinterpret_cast<MINMAXINFO*>(lparam);
|
|
mmi->ptMinTrackSize.x = static_cast<LONG>(S(850.0f));
|
|
mmi->ptMinTrackSize.y = static_cast<LONG>(S(550.0f));
|
|
|
|
HMONITOR monitor = MonitorFromWindow(window, MONITOR_DEFAULTTONEAREST);
|
|
if (monitor) {
|
|
MONITORINFO mi{sizeof(mi)};
|
|
if (GetMonitorInfoW(monitor, &mi)) {
|
|
mmi->ptMaxPosition.x = mi.rcWork.left - mi.rcMonitor.left;
|
|
mmi->ptMaxPosition.y = mi.rcWork.top - mi.rcMonitor.top;
|
|
mmi->ptMaxSize.x = mi.rcWork.right - mi.rcWork.left;
|
|
mmi->ptMaxSize.y = mi.rcWork.bottom - mi.rcWork.top;
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
case WM_SIZE:
|
|
if (wparam != SIZE_MINIMIZED) {
|
|
g_renderer.resize(LOWORD(lparam), HIWORD(lparam));
|
|
}
|
|
return 0;
|
|
case WM_SYSCOMMAND:
|
|
if ((wparam & 0xfff0) == SC_KEYMENU) return 0;
|
|
break;
|
|
case WM_DESTROY:
|
|
PostQuitMessage(0);
|
|
return 0;
|
|
}
|
|
return DefWindowProcW(window, message, wparam, lparam);
|
|
}
|
|
|
|
// ============================================================================
|
|
// TAB RENDERERS
|
|
// ============================================================================
|
|
|
|
void draw_overview_tab(const ServerStatusSnapshot& stats) {
|
|
section("NETWORK & SESSION OVERVIEW");
|
|
|
|
ImGui::Columns(2, "overview_cols", false);
|
|
ImGui::SetColumnWidth(0, ImGui::GetWindowWidth() * 0.50f);
|
|
|
|
begin_card("card_session", "SESSION IDENTITY", "Host configuration and live status");
|
|
stat_row("Server Status", stats.state_text,
|
|
stats.state == ServerState::Running ? good : (stats.state == ServerState::Error ? danger : warning));
|
|
stat_row("Server Name", stats.server_name);
|
|
stat_row("Current Map", stats.map_name);
|
|
|
|
char buf[128];
|
|
if (stats.state == ServerState::Running) {
|
|
stat_row("Connected Players", std::to_string(stats.player_count) + " / " + std::to_string(stats.max_players));
|
|
std::snprintf(buf, sizeof(buf), "%u TPS / %.1f TPS", stats.target_tps, stats.actual_tps);
|
|
stat_row("Target / Measured TPS", buf);
|
|
std::snprintf(buf, sizeof(buf), "%.2f ms", stats.frame_time_ms);
|
|
stat_row("Frame Latency", buf);
|
|
|
|
const auto hours = stats.uptime_seconds / 3600;
|
|
const auto mins = (stats.uptime_seconds % 3600) / 60;
|
|
const auto secs = stats.uptime_seconds % 60;
|
|
std::snprintf(buf, sizeof(buf), "%02llu:%02llu:%02llu", hours, mins, secs);
|
|
stat_row("Server Uptime", buf);
|
|
|
|
ImGui::Spacing();
|
|
ImGui::PushStyleColor(ImGuiCol_Text, muted);
|
|
ImGui::TextUnformatted("Session Join Code");
|
|
ImGui::PopStyleColor();
|
|
ImGui::SameLine(ImGui::GetCursorPosX() + S(220));
|
|
ImGui::PushFont(g_fonts.mono);
|
|
ImGui::PushStyleColor(ImGuiCol_Text, blue);
|
|
ImGui::TextUnformatted(stats.invite_code.c_str());
|
|
ImGui::PopStyleColor();
|
|
ImGui::PopFont();
|
|
ImGui::SameLine(0, S(8));
|
|
const bool just_copied = ImGui::GetTime() < g_state.copied_until;
|
|
if (rough_button(just_copied ? "COPIED" : "COPY", ImVec2(S(60), S(20)))) {
|
|
ImGui::SetClipboardText(stats.invite_code.c_str());
|
|
g_state.copied_until = ImGui::GetTime() + 3.0;
|
|
}
|
|
} else {
|
|
stat_row("Player Capacity", "0 / " + std::to_string(stats.max_players) + " (Offline)");
|
|
stat_row("Target Engine TPS", std::to_string(stats.target_tps) + " TPS (Dedicated Standard)");
|
|
stat_row("Session Join Code", "None (Server Stopped)", muted);
|
|
}
|
|
end_card();
|
|
|
|
begin_card("card_network", "STEAM TRANSPORT & PORTS", "Valve Steam SDR Networking");
|
|
stat_row("Public IP Address", stats.state == ServerState::Running
|
|
? (stats.public_ip.empty() ? "Detecting..." : stats.public_ip)
|
|
: "Offline (Unbound)");
|
|
stat_row("Game Port (UDP)", std::to_string(stats.port));
|
|
stat_row("Query Port (UDP)", std::to_string(stats.query_port));
|
|
stat_row("Host SteamID64", stats.state == ServerState::Running
|
|
? std::to_string(stats.steam_id)
|
|
: (stats.has_steam_token ? "Configured in GSLT Token" : "Anonymous (Generated on Boot)"));
|
|
stat_row("Steam GSLT Token", stats.has_steam_token ? "CONFIGURED (Persistent ID)" : "NONE (Anonymous Session)",
|
|
stats.has_steam_token ? good : (stats.is_listed ? warning : muted));
|
|
stat_row("Password Protected", stats.has_password ? "YES" : "NO", stats.has_password ? warning : good);
|
|
stat_row("Listed in Browser", stats.is_listed ? "YES" : "NO", stats.is_listed ? good : muted);
|
|
end_card();
|
|
|
|
ImGui::NextColumn();
|
|
|
|
begin_card("card_audio", "AUDIO & VOICE CHAT", "Voice and communication rules");
|
|
stat_row("Proximity Voice Chat", stats.voice_allowed ? "ENABLED" : "DISABLED", stats.voice_allowed ? good : danger);
|
|
std::snprintf(buf, sizeof(buf), "%.0f meters", stats.voice_range);
|
|
stat_row("Voice Broadcast Range", buf);
|
|
stat_row("Word Blacklist Automod", "ACTIVE (~425 terms)", good);
|
|
stat_row("Chat Rate Limiter", "ACTIVE (Burst protection)", good);
|
|
end_card();
|
|
|
|
begin_card("card_gameplay", "GAMEPLAY & SECURITY RULES", "Authoritative verification rules");
|
|
const auto& engine_cfg = ServerEngine::instance().config();
|
|
stat_row("Speed Anti-Cheat", engine_cfg.speed_check == "kick" ? "ENFORCED (KICK)" : (engine_cfg.speed_check == "warn" ? "ACTIVE (WARN)" : "DISABLED"), engine_cfg.speed_check != "off" ? good : muted);
|
|
stat_row("Scoring Anti-Cheat", engine_cfg.score_check == "kick" ? "ENFORCED (KICK)" : (engine_cfg.score_check == "warn" ? "ACTIVE (WARN)" : "DISABLED"), engine_cfg.score_check != "off" ? good : muted);
|
|
char bone_buf[32];
|
|
std::snprintf(bone_buf, sizeof(bone_buf), "%.1fx factor", stats.bone_scale_limit);
|
|
stat_row("Bone Scale Limit", bone_buf, good);
|
|
stat_row("Physics Tuning", engine_cfg.enforce_tuning ? "ENFORCED (Standard)" : "UNRESTRICTED", engine_cfg.enforce_tuning ? good : warning);
|
|
stat_row("Noclip & Boosts", engine_cfg.noclip ? "ALLOWED" : "RESTRICTED", engine_cfg.noclip ? good : warning);
|
|
stat_row("Player Parties", engine_cfg.parties ? ("ENABLED (Up to " + std::to_string(engine_cfg.party_size) + " players)") : "DISABLED", engine_cfg.parties ? good : muted);
|
|
end_card();
|
|
|
|
ImGui::Columns(1);
|
|
}
|
|
|
|
void draw_players_tab(const std::vector<Host::PlayerInfo>& players) {
|
|
const std::string players_title = "CONNECTED PLAYERS (" + std::to_string(players.size()) + " ONLINE)";
|
|
section(players_title.c_str());
|
|
|
|
auto* draw = ImGui::GetWindowDrawList();
|
|
|
|
// Search bar & summary ribbon
|
|
const float search_w = S(280.0f);
|
|
const float search_h = S(32.0f);
|
|
const float pad_y = std::max(0.0f, (search_h - ImGui::GetFontSize()) * 0.5f);
|
|
|
|
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(S(8), pad_y));
|
|
ImGui::SetNextItemWidth(search_w);
|
|
ImGui::InputTextWithHint("##player_filter", "Search by name or SteamID64...", g_state.player_search, sizeof(g_state.player_search));
|
|
ImGui::PopStyleVar();
|
|
|
|
const ImVec2 search_min = ImGui::GetItemRectMin();
|
|
const ImVec2 search_max = ImGui::GetItemRectMax();
|
|
const float actual_h = search_max.y - search_min.y;
|
|
|
|
if (g_state.player_search[0]) {
|
|
ImGui::SameLine(0, S(8));
|
|
if (rough_button("CLEAR", ImVec2(S(70), actual_h))) {
|
|
g_state.player_search[0] = '\0';
|
|
}
|
|
}
|
|
|
|
ImGui::SameLine(0, S(16));
|
|
const auto stats = ServerEngine::instance().status_snapshot();
|
|
const bool is_online = (stats.state == ServerState::Running);
|
|
const unsigned cur_players = static_cast<unsigned>(players.size());
|
|
const unsigned server_cap = stats.max_players;
|
|
|
|
char count_str[32];
|
|
std::snprintf(count_str, sizeof(count_str), "%u / %u", cur_players, server_cap);
|
|
|
|
const auto sz_label = g_fonts.bold->CalcTextSizeA(S(11.0f), FLT_MAX, 0.0f, "PLAYERS");
|
|
const auto sz_count = g_fonts.bold->CalcTextSizeA(S(13.0f), FLT_MAX, 0.0f, count_str);
|
|
|
|
const float dot_r = S(3.5f);
|
|
const float pad_x = S(10.0f);
|
|
const float gap_dot_label = S(8.0f);
|
|
const float gap_label_sep = S(10.0f);
|
|
const float gap_sep_count = S(10.0f);
|
|
|
|
const float count_badge_w = pad_x + (dot_r * 2.0f) + gap_dot_label + sz_label.x + gap_label_sep + 1.0f + gap_sep_count + sz_count.x + pad_x;
|
|
const ImVec2 badge_origin(ImGui::GetCursorScreenPos().x, search_min.y);
|
|
|
|
ImGui::PushID("players_tab_count_badge");
|
|
ImGui::InvisibleButton("##players_tab_badge_btn", ImVec2(count_badge_w, actual_h));
|
|
const bool badge_hovered = ImGui::IsItemHovered();
|
|
if (badge_hovered) {
|
|
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
|
ImGui::SetTooltip("Connected Players: %u / %u\nMax Capacity: %u", cur_players, server_cap, server_cap);
|
|
}
|
|
ImGui::PopID();
|
|
|
|
// 1. Background rectangle
|
|
draw->AddRectFilled(badge_origin, ImVec2(badge_origin.x + count_badge_w, badge_origin.y + actual_h), badge_hovered ? tile_light : tile_grey, S(4.0f));
|
|
|
|
// 2. Status Dot
|
|
const ImU32 dot_col = is_online ? good : muted;
|
|
const ImVec2 dot_center(badge_origin.x + pad_x + dot_r, badge_origin.y + actual_h * 0.5f);
|
|
draw->AddCircleFilled(dot_center, dot_r, dot_col);
|
|
|
|
// 3. Label "PLAYERS"
|
|
const float label_x = badge_origin.x + pad_x + (dot_r * 2.0f) + gap_dot_label;
|
|
const float label_y = badge_origin.y + (actual_h - sz_label.y) * 0.5f;
|
|
draw->AddText(g_fonts.bold, S(11.0f), ImVec2(label_x, label_y), muted, "PLAYERS");
|
|
|
|
// 4. Subtle vertical divider line
|
|
const float sep_x = label_x + sz_label.x + gap_label_sep;
|
|
draw->AddLine(ImVec2(sep_x, badge_origin.y + S(5.0f)), ImVec2(sep_x, badge_origin.y + actual_h - S(5.0f)), IM_COL32(26, 26, 28, 255), 1.0f);
|
|
|
|
// 5. Count string "1 / 249"
|
|
const float count_x = sep_x + 1.0f + gap_sep_count;
|
|
const float count_y = badge_origin.y + (actual_h - sz_count.y) * 0.5f;
|
|
const ImU32 count_col = (is_online && cur_players > 0) ? good : white;
|
|
draw->AddText(g_fonts.bold, S(13.0f), ImVec2(count_x, count_y), count_col, count_str);
|
|
|
|
ImGui::SetCursorScreenPos(ImVec2(badge_origin.x + count_badge_w, badge_origin.y + actual_h));
|
|
|
|
ImGui::Dummy(ImVec2(0, S(10)));
|
|
|
|
if (players.empty()) {
|
|
begin_card("card_no_players", "NO PLAYERS CONNECTED", "Session is ready for incoming client connections");
|
|
ImGui::PushStyleColor(ImGuiCol_Text, muted);
|
|
ImGui::TextUnformatted("No players are currently connected to the dedicated server.");
|
|
ImGui::TextUnformatted("Share your Join Code or host IP in the community server browser to invite players!");
|
|
ImGui::PopStyleColor();
|
|
end_card();
|
|
return;
|
|
}
|
|
|
|
std::string search_query = g_state.player_search;
|
|
std::transform(search_query.begin(), search_query.end(), search_query.begin(), ::tolower);
|
|
|
|
int visible_count = 0;
|
|
|
|
for (size_t i = 0; i < players.size(); ++i) {
|
|
const auto& p = players[i];
|
|
|
|
// Filter matching
|
|
if (!search_query.empty()) {
|
|
std::string name_lower = p.name;
|
|
std::transform(name_lower.begin(), name_lower.end(), name_lower.begin(), ::tolower);
|
|
std::string id_str = std::to_string(p.id);
|
|
if (name_lower.find(search_query) == std::string::npos && id_str.find(search_query) == std::string::npos) {
|
|
continue;
|
|
}
|
|
}
|
|
visible_count++;
|
|
|
|
ImGui::PushID(static_cast<int>(p.id));
|
|
|
|
const auto card_pos = ImGui::GetCursorScreenPos();
|
|
const float card_w = ImGui::GetContentRegionAvail().x;
|
|
const float card_h = S(62.0f);
|
|
|
|
const bool is_hovered = ImGui::IsMouseHoveringRect(card_pos, ImVec2(card_pos.x + card_w, card_pos.y + card_h));
|
|
|
|
// 1. Draw card background tile
|
|
draw->AddRectFilled(card_pos, ImVec2(card_pos.x + card_w, card_pos.y + card_h),
|
|
is_hovered ? tile_light : tile_grey, S(4.0f));
|
|
|
|
// 2. Role Accent & Left 4px Stripe (1-1 in-game role color & animated gradient)
|
|
const auto [role_col, role_tag] = player_role(p.id, p.admin);
|
|
const bool has_badge = !role_tag.empty();
|
|
const double cur_time = ImGui::GetTime();
|
|
const ImU32 stripe_col = has_badge ? animated_nametag_colour(role_col, cur_time) : IM_COL32(58, 195, 95, 255);
|
|
|
|
draw->AddRectFilled(card_pos, ImVec2(card_pos.x + S(4.0f), card_pos.y + card_h), stripe_col, S(4.0f), ImDrawFlags_RoundCornersLeft);
|
|
|
|
// 3. Right Action Buttons [ KICK | BAN ] with bold colors
|
|
const float btn_w = S(84.0f);
|
|
const float btn_h = S(32.0f);
|
|
const float btn_gap = S(8.0f);
|
|
const float total_btns_w = (btn_w * 2.0f) + btn_gap;
|
|
|
|
const float btns_start_x = card_pos.x + card_w - total_btns_w - S(16.0f);
|
|
const float btns_y = card_pos.y + (card_h - btn_h) * 0.5f;
|
|
|
|
// Button 1: Kick (Vivid Amber/Warning)
|
|
const ImVec2 kick_pos(btns_start_x, btns_y);
|
|
ImGui::SetCursorScreenPos(kick_pos);
|
|
const bool kick_clicked = ImGui::InvisibleButton("##btn_kick", ImVec2(btn_w, btn_h));
|
|
const bool kick_hovered = ImGui::IsItemHovered();
|
|
if (kick_hovered) {
|
|
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
|
ImGui::SetTooltip("Kick %s from the server session", p.name.c_str());
|
|
}
|
|
const ImU32 kick_bg = kick_hovered ? IM_COL32(255, 185, 45, 255) : IM_COL32(235, 155, 30, 255);
|
|
draw->AddRectFilled(kick_pos, ImVec2(kick_pos.x + btn_w, kick_pos.y + btn_h), kick_bg, S(4.0f));
|
|
const auto sz_kick = g_fonts.bold->CalcTextSizeA(S(13.0f), FLT_MAX, 0.0f, "KICK");
|
|
draw->AddText(g_fonts.bold, S(13.0f),
|
|
ImVec2(kick_pos.x + (btn_w - sz_kick.x) * 0.5f, kick_pos.y + (btn_h - sz_kick.y) * 0.5f),
|
|
black, "KICK");
|
|
|
|
if (kick_clicked) {
|
|
g_state.target_player_id = p.id;
|
|
g_state.target_player_name = p.name;
|
|
g_state.is_kick_modal = true;
|
|
g_state.show_player_modal = true;
|
|
g_state.target_action_reason[0] = '\0';
|
|
}
|
|
|
|
// Button 2: Ban (Vivid Crimson/Danger)
|
|
const ImVec2 ban_pos(btns_start_x + btn_w + btn_gap, btns_y);
|
|
ImGui::SetCursorScreenPos(ban_pos);
|
|
const bool ban_clicked = ImGui::InvisibleButton("##btn_ban", ImVec2(btn_w, btn_h));
|
|
const bool ban_hovered = ImGui::IsItemHovered();
|
|
if (ban_hovered) {
|
|
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
|
ImGui::SetTooltip("Ban %s from the server and add to bans list", p.name.c_str());
|
|
}
|
|
const ImU32 ban_bg = ban_hovered ? IM_COL32(250, 70, 70, 255) : IM_COL32(225, 45, 45, 255);
|
|
draw->AddRectFilled(ban_pos, ImVec2(ban_pos.x + btn_w, ban_pos.y + btn_h), ban_bg, S(4.0f));
|
|
const auto sz_ban = g_fonts.bold->CalcTextSizeA(S(13.0f), FLT_MAX, 0.0f, "BAN");
|
|
draw->AddText(g_fonts.bold, S(13.0f),
|
|
ImVec2(ban_pos.x + (btn_w - sz_ban.x) * 0.5f, ban_pos.y + (btn_h - sz_ban.y) * 0.5f),
|
|
white, "BAN");
|
|
|
|
if (ban_clicked) {
|
|
g_state.target_player_id = p.id;
|
|
g_state.target_player_name = p.name;
|
|
g_state.is_kick_modal = false;
|
|
g_state.show_player_modal = true;
|
|
g_state.target_action_reason[0] = '\0';
|
|
}
|
|
|
|
// 4. Left Content: Clean, Centered Two-Row Layout
|
|
const float row1_center_y = card_pos.y + S(19.0f);
|
|
const float row2_y = card_pos.y + S(44.0f);
|
|
float cur_x = card_pos.x + S(18.0f);
|
|
|
|
// Row 1: Authentic 1-1 In-Game Nametag Layout [ Role Badge ] [ Player Name (Gradient Shimmer) ] [ Ping Pill ] [ Mute Status ]
|
|
const float tag_font_size = S(15.0f);
|
|
const auto name_sz = g_fonts.bold->CalcTextSizeA(tag_font_size, FLT_MAX, 0.0f, p.name.c_str());
|
|
|
|
// Role badge sizing & vertical bounds
|
|
const float badge_font_size = tag_font_size * 0.78f;
|
|
const auto badge_extent = g_fonts.bold->CalcTextSizeA(badge_font_size, FLT_MAX, 0.0f, role_tag.c_str());
|
|
const float badge_h = std::round(badge_extent.y + tag_font_size * 0.22f);
|
|
const float badge_y = std::round(row1_center_y - badge_h * 0.5f);
|
|
const float badge_radius = tag_font_size * 0.22f;
|
|
|
|
// 1:1 In-Game Role Badge (Dev, Staff, Centrix, Content Creator, Homie, Admin) - Drawn BEFORE the name!
|
|
if (has_badge) {
|
|
const float badge_w = role_badge_width(g_fonts.bold, tag_font_size, role_tag);
|
|
draw_role_badge(draw, g_fonts.bold, tag_font_size, ImVec2(cur_x, badge_y), badge_h, role_tag, role_col, 1.0f);
|
|
cur_x += badge_w + S(8.0f);
|
|
}
|
|
|
|
// 1:1 In-Game Name Text with drop shadow and animated gradient shimmer (perfectly baseline-aligned)
|
|
const float name_y = std::round(badge_y + (badge_h - badge_extent.y) * 0.5f + (badge_font_size - tag_font_size) * 0.76f);
|
|
const ImVec2 name_at(cur_x, name_y);
|
|
draw->AddText(g_fonts.bold, tag_font_size, ImVec2(name_at.x + 1.0f, name_at.y + 1.0f), IM_COL32(0, 0, 0, 190), p.name.c_str());
|
|
const int name_vert = draw->VtxBuffer.Size;
|
|
draw->AddText(g_fonts.bold, tag_font_size, name_at, has_badge ? role_col : white, p.name.c_str());
|
|
shade_nametag_gradient(draw, name_vert, name_at.x, name_sz.x, role_col, cur_time);
|
|
cur_x += name_sz.x + S(12.0f);
|
|
|
|
// Ping Badge Pill (matching role badge height, radius, and vertical alignment)
|
|
char ping_str[32];
|
|
std::snprintf(ping_str, sizeof(ping_str), "%u ms", p.ping);
|
|
const ImU32 ping_col = p.ping < 60 ? good : (p.ping < 130 ? warning : danger);
|
|
const float ping_font_size = S(11.0f);
|
|
const auto ping_sz = g_fonts.bold->CalcTextSizeA(ping_font_size, FLT_MAX, 0.0f, ping_str);
|
|
const float ping_w = ping_sz.x + S(14.0f);
|
|
draw->AddRectFilled(ImVec2(cur_x, badge_y), ImVec2(cur_x + ping_w, badge_y + badge_h), ping_col, badge_radius);
|
|
draw->AddText(g_fonts.bold, ping_font_size, ImVec2(cur_x + (ping_w - ping_sz.x) * 0.5f, badge_y + (badge_h - ping_sz.y) * 0.5f), black, ping_str);
|
|
cur_x += ping_w + S(8.0f);
|
|
|
|
// Moderation badges if muted
|
|
if (p.voice_muted) {
|
|
const auto m_sz = g_fonts.bold->CalcTextSizeA(S(10.0f), FLT_MAX, 0.0f, "VOICE MUTED");
|
|
const float m_w = m_sz.x + S(12.0f);
|
|
draw->AddRectFilled(ImVec2(cur_x, badge_y), ImVec2(cur_x + m_w, badge_y + badge_h), warning, badge_radius);
|
|
draw->AddText(g_fonts.bold, S(10.0f), ImVec2(cur_x + (m_w - m_sz.x) * 0.5f, badge_y + (badge_h - m_sz.y) * 0.5f), black, "VOICE MUTED");
|
|
cur_x += m_w + S(6.0f);
|
|
}
|
|
if (p.text_muted) {
|
|
const auto m_sz = g_fonts.bold->CalcTextSizeA(S(10.0f), FLT_MAX, 0.0f, "CHAT MUTED");
|
|
const float m_w = m_sz.x + S(12.0f);
|
|
draw->AddRectFilled(ImVec2(cur_x, badge_y), ImVec2(cur_x + m_w, badge_y + badge_h), warning, badge_radius);
|
|
draw->AddText(g_fonts.bold, S(10.0f), ImVec2(cur_x + (m_w - m_sz.x) * 0.5f, badge_y + (badge_h - m_sz.y) * 0.5f), black, "CHAT MUTED");
|
|
cur_x += m_w + S(6.0f);
|
|
}
|
|
|
|
// Row 2: SteamID64 (Clickable to Copy) + Position Coordinates
|
|
cur_x = card_pos.x + S(18.0f);
|
|
|
|
std::string steam_str = std::to_string(p.id);
|
|
std::string steam_lbl = "SteamID: " + steam_str;
|
|
const auto steam_sz = g_fonts.mono->CalcTextSizeA(S(12.0f), FLT_MAX, 0.0f, steam_lbl.c_str());
|
|
|
|
ImGui::SetCursorScreenPos(ImVec2(cur_x, row2_y - steam_sz.y * 0.5f));
|
|
ImGui::PushID("steam_copy_hitbox");
|
|
const bool steam_clicked = ImGui::InvisibleButton("##steam_copy", steam_sz);
|
|
const bool steam_hovered = ImGui::IsItemHovered();
|
|
if (steam_hovered) {
|
|
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
|
ImGui::SetTooltip("SteamID64: %s (Click to copy to clipboard)", steam_str.c_str());
|
|
}
|
|
if (steam_clicked) {
|
|
ImGui::SetClipboardText(steam_str.c_str());
|
|
show_notification("Copied SteamID: " + steam_str, 2.5, blue);
|
|
}
|
|
ImGui::PopID();
|
|
|
|
draw->AddText(g_fonts.mono, S(12.0f), ImVec2(cur_x, row2_y - steam_sz.y * 0.5f),
|
|
steam_hovered ? white : muted, steam_lbl.c_str());
|
|
cur_x += steam_sz.x + S(24.0f);
|
|
|
|
char pos_str[64];
|
|
std::snprintf(pos_str, sizeof(pos_str), "Pos: (%.0f, %.0f, %.0f)", p.x, p.y, p.z);
|
|
char pos_copy_str[64];
|
|
std::snprintf(pos_copy_str, sizeof(pos_copy_str), "%.2f, %.2f, %.2f", p.x, p.y, p.z);
|
|
const auto pos_sz = g_fonts.mono->CalcTextSizeA(S(12.0f), FLT_MAX, 0.0f, pos_str);
|
|
|
|
ImGui::SetCursorScreenPos(ImVec2(cur_x, row2_y - pos_sz.y * 0.5f));
|
|
ImGui::PushID("pos_copy_hitbox");
|
|
const bool pos_clicked = ImGui::InvisibleButton("##pos_copy", pos_sz);
|
|
const bool pos_hovered = ImGui::IsItemHovered();
|
|
if (pos_hovered) {
|
|
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
|
ImGui::SetTooltip("Coordinates: %s (Click to copy to clipboard)", pos_copy_str);
|
|
}
|
|
if (pos_clicked) {
|
|
ImGui::SetClipboardText(pos_copy_str);
|
|
show_notification("Copied Position: " + std::string(pos_copy_str), 2.5, blue);
|
|
}
|
|
ImGui::PopID();
|
|
|
|
draw->AddText(g_fonts.mono, S(12.0f), ImVec2(cur_x, row2_y - pos_sz.y * 0.5f),
|
|
pos_hovered ? white : muted, pos_str);
|
|
|
|
// Advance layout cursor past this card
|
|
ImGui::SetCursorScreenPos(ImVec2(card_pos.x, card_pos.y + card_h + S(8.0f)));
|
|
ImGui::PopID();
|
|
}
|
|
|
|
if (visible_count == 0 && !search_query.empty()) {
|
|
ImGui::PushStyleColor(ImGuiCol_Text, muted);
|
|
ImGui::Text("No connected players match \"%s\".", g_state.player_search);
|
|
ImGui::PopStyleColor();
|
|
}
|
|
}
|
|
|
|
void draw_world_tab(const ServerStatusSnapshot& stats) {
|
|
section("MAP & DESTINATION SWITCHER");
|
|
|
|
ImGui::PushStyleColor(ImGuiCol_Text, muted);
|
|
ImGui::Text("Current Active Map: %s", stats.map_name.c_str());
|
|
ImGui::PopStyleColor();
|
|
ImGui::Spacing();
|
|
|
|
const auto map_list = level_snapshot(); // a copy: "maps reload" changes the list on the server's thread
|
|
if (!map_list.empty()) {
|
|
for (const auto& level : map_list) {
|
|
const bool active = (stats.map_name == level.name || map_setting(stats.map_name) == map_setting(level.name));
|
|
if (rough_button(level.name.c_str(), ImVec2(S(200), S(34)), active)) {
|
|
ServerEngine::instance().queue_command("map " + level.name);
|
|
show_notification("Loading map: " + level.name, 3.0, good);
|
|
}
|
|
ImGui::SameLine();
|
|
}
|
|
ImGui::NewLine();
|
|
} else {
|
|
const std::vector<std::pair<const char*, const char*>> fallback_maps = {
|
|
{"San Vansterdam", "San Vansterdam"},
|
|
{"Isle of Grom", "Isle of Grom"},
|
|
{"Super Ultra Mega Resort", "Super Ultra Mega Resort"}
|
|
};
|
|
for (const auto& [label, dest] : fallback_maps) {
|
|
const bool active = (stats.map_name == dest);
|
|
if (rough_button(label, ImVec2(S(200), S(34)), active)) {
|
|
ServerEngine::instance().queue_command(std::string("map ") + dest);
|
|
show_notification(std::string("Loading map: ") + label, 3.0, good);
|
|
}
|
|
ImGui::SameLine();
|
|
}
|
|
ImGui::NewLine();
|
|
}
|
|
|
|
ImGui::Spacing();
|
|
ImGui::PushItemWidth(S(320));
|
|
ImGui::InputTextWithHint("##custom_map", "Enter custom map path / mod name...", g_state.custom_map, sizeof(g_state.custom_map));
|
|
ImGui::PopItemWidth();
|
|
ImGui::SameLine();
|
|
if (rough_button("LOAD MAP", ImVec2(S(120), S(32)))) {
|
|
if (g_state.custom_map[0]) {
|
|
ServerEngine::instance().queue_command(std::string("map ") + g_state.custom_map);
|
|
show_notification(std::string("Switching to map: ") + g_state.custom_map, 3.0, blue);
|
|
g_state.custom_map[0] = '\0';
|
|
}
|
|
}
|
|
|
|
section("TIME OF DAY PRESETS");
|
|
const std::vector<std::pair<const char*, const char*>> times = {
|
|
{"Default", "default"},
|
|
{"Morning (08:00)", "morning"},
|
|
{"Noon (12:00)", "noon"},
|
|
{"Afternoon (15:00)", "afternoon"},
|
|
{"Evening (17:30)", "evening"},
|
|
{"Night (00:00)", "night"},
|
|
{"Weather Day", "weatherday"},
|
|
{"Weather Night", "weathernight"}
|
|
};
|
|
|
|
for (const auto& [label, preset] : times) {
|
|
if (rough_button(label, ImVec2(S(135), S(32)))) {
|
|
ServerEngine::instance().queue_command(std::string("tod ") + preset);
|
|
show_notification(std::string("Time of Day: ") + label, 2.5, blue);
|
|
}
|
|
ImGui::SameLine();
|
|
}
|
|
ImGui::NewLine();
|
|
|
|
section("GAMEPLAY & PHYSICS TOGGLES");
|
|
auto& cfg = ServerEngine::instance().config();
|
|
bool nobail_val = cfg.no_bail;
|
|
bool boosts_val = cfg.boosts;
|
|
bool noclip_val = cfg.noclip;
|
|
bool placement_val = (cfg.object_placement != ObjectPlacement::nobody);
|
|
bool layer_sync_val = cfg.world_layer_sync;
|
|
bool tuning_val = cfg.enforce_tuning;
|
|
|
|
if (toggle_row("NoBail Mode", "Allows players to continue skating without ragdolling on impacts", nobail_val)) {
|
|
cfg.no_bail = nobail_val;
|
|
ServerEngine::instance().queue_command(std::string("nobail ") + (nobail_val ? "on" : "off"));
|
|
show_notification(nobail_val ? "NoBail enabled" : "NoBail disabled", 2.5, blue);
|
|
}
|
|
if (toggle_row("Ramp Boosts Allowed", "Allows physics velocity speed boosts on ramps and quarter pipes", boosts_val)) {
|
|
cfg.boosts = boosts_val;
|
|
ServerEngine::instance().queue_command(std::string("boosts ") + (boosts_val ? "on" : "off"));
|
|
show_notification(boosts_val ? "Ramp boosts enabled" : "Ramp boosts disabled", 2.5, blue);
|
|
}
|
|
if (toggle_row("NoClip Free-Cam Allowed", "Allows free flying camera mode for players", noclip_val)) {
|
|
cfg.noclip = noclip_val;
|
|
ServerEngine::instance().queue_command(std::string("noclip ") + (noclip_val ? "on" : "off"));
|
|
show_notification(noclip_val ? "NoClip enabled" : "NoClip disabled", 2.5, blue);
|
|
}
|
|
if (toggle_row("Park Editor Object Placement", "Allows dropping ramps, rails, and props in the world", placement_val)) {
|
|
cfg.object_placement = placement_val ? ObjectPlacement::everyone : ObjectPlacement::nobody;
|
|
ServerEngine::instance().queue_command(std::string("placement ") + (placement_val ? "everyone" : "nobody"));
|
|
show_notification(placement_val ? "Object placement enabled" : "Object placement disabled", 2.5, blue);
|
|
}
|
|
if (toggle_row("World Layer Sync", "Synchronizes level destruction and object layers across clients", layer_sync_val)) {
|
|
cfg.world_layer_sync = layer_sync_val;
|
|
ServerEngine::instance().queue_command(std::string("layer-sync ") + (layer_sync_val ? "on" : "off"));
|
|
show_notification(layer_sync_val ? "World layer sync enabled" : "World layer sync disabled", 2.5, blue);
|
|
}
|
|
if (toggle_row("Tuning Enforcement", "Enforces server-authoritative physics tuning on connected clients", tuning_val)) {
|
|
cfg.enforce_tuning = tuning_val;
|
|
ServerEngine::instance().queue_command(std::string("tuning ") + (tuning_val ? "on" : "off"));
|
|
show_notification(tuning_val ? "Tuning enforced" : "Tuning free", 2.5, blue);
|
|
}
|
|
}
|
|
|
|
// --- Roles editor (data/roles.json; Server/roles.h) ---
|
|
// A copy of the roles is edited here and written back whole: the file is saved and, with the
|
|
// server running, read again by it ("roles reload"). Who has which role is not edited here (the
|
|
// PLAYER ROLES tab and the role command do that), so a save keeps the players the server has now.
|
|
struct RolesEditor {
|
|
Roles roles;
|
|
bool loaded{};
|
|
int selected{};
|
|
char new_name[32]{};
|
|
};
|
|
static RolesEditor s_roles;
|
|
|
|
static bool text_field(const char* label, std::string& value, std::size_t most, const char* hint = "") {
|
|
std::array<char, 512> buffer{};
|
|
strncpy_s(buffer.data(), buffer.size(), value.c_str(), _TRUNCATE);
|
|
const bool changed = ImGui::InputTextWithHint(label, hint, buffer.data(), std::min(buffer.size(), most + 1));
|
|
if (changed) value = buffer.data();
|
|
return changed;
|
|
}
|
|
|
|
static void save_roles() {
|
|
auto& engine = ServerEngine::instance();
|
|
auto& cfg = engine.config();
|
|
auto roles = s_roles.roles;
|
|
// Players keep the roles the server has given them, except a role that was deleted here.
|
|
roles.players = cfg.roles.players;
|
|
std::erase_if(roles.players, [&](const auto& entry) { return !roles.find(entry.second); });
|
|
if (!roles.default_role.empty() && !roles.find(roles.default_role)) roles.default_role.clear();
|
|
roles.settle();
|
|
if (const auto problem = roles_error(roles); !problem.empty()) {
|
|
show_notification("Roles not saved: " + problem, 6.0, danger);
|
|
return;
|
|
}
|
|
try {
|
|
const auto file = roles_file(cfg);
|
|
std::filesystem::create_directories(file.parent_path());
|
|
{
|
|
std::ofstream out(file, std::ios::binary | std::ios::trunc);
|
|
if (!(out << roles_text(roles))) throw std::runtime_error("could not write data/roles.json");
|
|
}
|
|
if (engine.state() == ServerState::Running) engine.queue_command("roles reload");
|
|
else cfg.roles = roles;
|
|
s_roles.roles = roles;
|
|
show_notification("Roles saved to data/roles.json.", 3.5, good);
|
|
} catch (const std::exception& e) {
|
|
show_notification(std::string("Roles not saved: ") + e.what(), 5.0, danger);
|
|
}
|
|
}
|
|
|
|
static void draw_roles_editor() {
|
|
auto& cfg = ServerEngine::instance().config();
|
|
if (!s_roles.loaded) {
|
|
s_roles.roles = cfg.roles;
|
|
s_roles.loaded = true;
|
|
}
|
|
auto& roles = s_roles.roles;
|
|
|
|
if (rough_button("SAVE ROLES", ImVec2(S(150), S(32)))) save_roles();
|
|
ImGui::SameLine(0, S(10));
|
|
if (rough_button("DISCARD CHANGES", ImVec2(S(170), S(32)))) {
|
|
roles = cfg.roles;
|
|
s_roles.selected = 0;
|
|
show_notification("Roles put back as the server has them.", 3.0, blue);
|
|
}
|
|
ImGui::SameLine(0, S(10));
|
|
if (rough_button("OPEN ROLES FILE", ImVec2(S(170), S(32)))) {
|
|
const auto path = roles_file(cfg).string();
|
|
ShellExecuteA(nullptr, "open", path.c_str(), nullptr, nullptr, SW_SHOW);
|
|
}
|
|
ImGui::Dummy(ImVec2(0, S(6)));
|
|
|
|
ImGui::Columns(2, "roles_cols", false);
|
|
ImGui::SetColumnWidth(0, ImGui::GetWindowWidth() * 0.34f);
|
|
|
|
// The list of roles, highest rank first, and making a new one.
|
|
const std::string count = std::to_string(roles.list.size()) + " of " + std::to_string(max_roles) + " roles";
|
|
begin_card("card_roles_list", "ROLES", count.c_str());
|
|
std::vector<int> order(roles.list.size());
|
|
for (int i = 0; i < static_cast<int>(order.size()); ++i) order[i] = i;
|
|
std::stable_sort(order.begin(), order.end(), [&](int a, int b) { return roles.list[a].rank > roles.list[b].rank; });
|
|
s_roles.selected = std::clamp(s_roles.selected, 0, std::max(0, static_cast<int>(roles.list.size()) - 1));
|
|
for (const int i : order) {
|
|
const auto& role = roles.list[i];
|
|
std::string label = role.name + " (rank " + std::to_string(role.rank) + ")";
|
|
if (role.name == roles.default_role) label += " - default";
|
|
ImGui::PushID(i);
|
|
if (rough_button(label.c_str(), ImVec2(ImGui::GetContentRegionAvail().x, S(28)), s_roles.selected == i)) s_roles.selected = i;
|
|
ImGui::PopID();
|
|
}
|
|
ImGui::Spacing();
|
|
ImGui::PushItemWidth(ImGui::GetContentRegionAvail().x);
|
|
ImGui::InputTextWithHint("##new_role", "new role name (a-z, 0-9, - and _)", s_roles.new_name, 25);
|
|
ImGui::PopItemWidth();
|
|
if (rough_button("ADD ROLE", ImVec2(S(130), S(28)))) {
|
|
Role role;
|
|
role.name = s_roles.new_name;
|
|
Roles tried = roles;
|
|
tried.list.push_back(role);
|
|
tried.players.clear();
|
|
if (const auto problem = roles_error(tried); !problem.empty()) {
|
|
show_notification("Role not added: " + problem, 5.0, danger);
|
|
} else {
|
|
roles.list.push_back(std::move(role));
|
|
s_roles.selected = static_cast<int>(roles.list.size()) - 1;
|
|
s_roles.new_name[0] = '\0';
|
|
}
|
|
}
|
|
end_card();
|
|
|
|
ImGui::NextColumn();
|
|
|
|
if (roles.list.empty()) {
|
|
begin_card("card_role_none", "NO ROLES", "Add a role on the left");
|
|
ImGui::PushStyleColor(ImGuiCol_Text, muted);
|
|
ImGui::TextWrapped("Without roles every player chats, votes and joins parties as usual, and nobody but the console runs commands.");
|
|
ImGui::PopStyleColor();
|
|
end_card();
|
|
ImGui::Columns(1);
|
|
return;
|
|
}
|
|
|
|
auto& role = roles.list[s_roles.selected];
|
|
const std::string title = "ROLE: " + role.name;
|
|
begin_card("card_role_edit", title.c_str(), "Changes apply when you press SAVE ROLES");
|
|
ImGui::PushID(s_roles.selected);
|
|
ImGui::PushItemWidth(S(300));
|
|
text_field("Tag", role.tag, multiplayer::max_role_tag, "shown before the player's name; empty for none");
|
|
{
|
|
// The tag's colour, kept as the #RRGGBB the file holds.
|
|
const auto rgb = role.color.size() == 7 && role.color[0] == '#' ? std::strtoul(role.color.c_str() + 1, nullptr, 16) : 0xFFFFFFUL;
|
|
float colour[3]{((rgb >> 16) & 255) / 255.f, ((rgb >> 8) & 255) / 255.f, (rgb & 255) / 255.f};
|
|
if (ImGui::ColorEdit3("Tag Colour", colour, ImGuiColorEditFlags_NoInputs | ImGuiColorEditFlags_NoAlpha)) {
|
|
const auto part = [](float v) { return static_cast<unsigned>(std::lround(std::clamp(v, 0.f, 1.f) * 255.f)); };
|
|
role.color = std::format("#{:02X}{:02X}{:02X}", part(colour[0]), part(colour[1]), part(colour[2]));
|
|
}
|
|
ImGui::SameLine(0, S(10));
|
|
ImGui::PushStyleColor(ImGuiCol_Text, muted);
|
|
ImGui::TextUnformatted(role.color.c_str());
|
|
ImGui::PopStyleColor();
|
|
}
|
|
ImGui::SliderInt("Rank", &role.rank, 0, 1000);
|
|
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Nobody uses a command on a player whose rank is theirs or higher, or gives out such a role.");
|
|
ImGui::PopItemWidth();
|
|
|
|
bool is_default = role.name == roles.default_role;
|
|
if (ImGui::Checkbox("Default role (everyone who was given none)", &is_default)) roles.default_role = is_default ? role.name : std::string{};
|
|
ImGui::Checkbox("Also has what the default role has", &role.inherit);
|
|
|
|
ImGui::Dummy(ImVec2(0, S(4)));
|
|
ImGui::PushFont(g_fonts.bold);
|
|
ImGui::TextUnformatted("PERMISSIONS");
|
|
ImGui::PopFont();
|
|
static constexpr std::array<const char*, 11> permission_labels{
|
|
"Chat", "Start votes and vote", "Parties", "Private messages", "Reserved slot (joins a full server)",
|
|
"No object limit, builds when players may not", "Noclip, No Bail and boosts when players may not",
|
|
"Never removed for being away", "Cannot be vote-kicked", "Sees anti-cheat alerts", "Starts and ends polls"};
|
|
for (std::size_t i = 0; i < role_permissions.size(); ++i) {
|
|
bool on = (role.permissions & role_permissions[i].second) != 0;
|
|
if (ImGui::Checkbox(permission_labels[i], &on)) role.permissions = on ? role.permissions | role_permissions[i].second : role.permissions & ~role_permissions[i].second;
|
|
if (i % 2 == 0 && i + 1 < role_permissions.size()) ImGui::SameLine(S(330));
|
|
}
|
|
|
|
ImGui::Dummy(ImVec2(0, S(4)));
|
|
ImGui::PushFont(g_fonts.bold);
|
|
ImGui::TextUnformatted("SERVER COMMANDS");
|
|
ImGui::PopFont();
|
|
ImGui::Checkbox("Every command (except role and reserved, unless listed below)", &role.all_commands);
|
|
{
|
|
// The commands it lists, as words with blanks between.
|
|
std::string listed;
|
|
for (const auto& command : role.commands) listed += (listed.empty() ? "" : " ") + command;
|
|
ImGui::PushItemWidth(ImGui::GetContentRegionAvail().x);
|
|
if (text_field("##role_commands", listed, 500, "commands, e.g. kick ban unban bans players msg tphere role")) {
|
|
role.commands.clear();
|
|
std::string word;
|
|
for (const char c : listed + " ") {
|
|
if (c != ' ' && c != ',') { word += static_cast<char>(std::tolower(static_cast<unsigned char>(c))); continue; }
|
|
if (!word.empty() && std::find(role.commands.begin(), role.commands.end(), word) == role.commands.end()) role.commands.push_back(word);
|
|
word.clear();
|
|
}
|
|
}
|
|
ImGui::PopItemWidth();
|
|
}
|
|
|
|
ImGui::Dummy(ImVec2(0, S(6)));
|
|
std::size_t holders = 0;
|
|
for (const auto& [id, name] : cfg.roles.players) holders += name == role.name;
|
|
const std::string remove = "DELETE ROLE" + (holders ? " (" + std::to_string(holders) + " player" + (holders == 1 ? "" : "s") + " lose it)" : std::string{});
|
|
if (colored_action_button(remove.c_str(), ImVec2(S(300), S(28)), danger, white, "Removed when you press SAVE ROLES")) {
|
|
if (roles.default_role == role.name) roles.default_role.clear();
|
|
roles.list.erase(roles.list.begin() + s_roles.selected);
|
|
s_roles.selected = 0;
|
|
}
|
|
ImGui::PopID();
|
|
end_card();
|
|
|
|
ImGui::Columns(1);
|
|
}
|
|
|
|
void draw_moderation_tab() {
|
|
section("MODERATION & ACCESS CONTROLS");
|
|
|
|
category_tabs(g_state.moderation_subtab,
|
|
{"SERVER BANS", "PLAYER ROLES", "ROLES"},
|
|
"mod_tabs");
|
|
|
|
if (g_state.moderation_subtab == 0) {
|
|
// SERVER BANS
|
|
const auto& bans = ServerEngine::instance().config().bans;
|
|
const std::string ban_count = std::to_string(bans.size()) + (bans.size() == 1 ? " banned player" : " banned players");
|
|
begin_card("card_bans_list", "BANNED PLAYERS", ban_count.c_str());
|
|
|
|
if (bans.empty()) {
|
|
ImGui::PushStyleColor(ImGuiCol_Text, muted);
|
|
ImGui::TextUnformatted("Nobody is currently banned from this server. Ban a player from the Players tab, or add one below.");
|
|
ImGui::PopStyleColor();
|
|
} else {
|
|
if (ImGui::BeginTable("bans_table", 4, ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersInnerH | ImGuiTableFlags_SizingStretchProp)) {
|
|
ImGui::TableSetupColumn("Player Name", ImGuiTableColumnFlags_WidthStretch, 2.0f);
|
|
ImGui::TableSetupColumn("SteamID64", ImGuiTableColumnFlags_WidthFixed, S(160));
|
|
ImGui::TableSetupColumn("Banned Date", ImGuiTableColumnFlags_WidthFixed, S(130));
|
|
ImGui::TableSetupColumn("Action", ImGuiTableColumnFlags_WidthFixed, S(100));
|
|
ImGui::TableHeadersRow();
|
|
|
|
for (const auto& ban : bans) {
|
|
ImGui::TableNextRow();
|
|
|
|
// Name
|
|
ImGui::TableSetColumnIndex(0);
|
|
ImGui::PushFont(g_fonts.bold);
|
|
ImGui::TextUnformatted(ban.name.empty() ? "Unknown Player" : ban.name.c_str());
|
|
ImGui::PopFont();
|
|
|
|
// SteamID
|
|
ImGui::TableSetColumnIndex(1);
|
|
ImGui::PushFont(g_fonts.mono);
|
|
ImGui::Text("%llu", ban.id);
|
|
ImGui::PopFont();
|
|
|
|
// Date
|
|
ImGui::TableSetColumnIndex(2);
|
|
std::array<char, 32> date{};
|
|
const auto added_t = static_cast<std::time_t>(ban.added);
|
|
std::tm local_tm{};
|
|
if (ban.added > 0 && localtime_s(&local_tm, &added_t) == 0) {
|
|
std::strftime(date.data(), date.size(), "%d %b %Y", &local_tm);
|
|
}
|
|
ImGui::TextUnformatted(date[0] ? date.data() : "-");
|
|
|
|
// Action
|
|
ImGui::TableSetColumnIndex(3);
|
|
ImGui::PushID(static_cast<int>(ban.id));
|
|
if (rough_button("Unban", ImVec2(S(80), S(22)))) {
|
|
ServerEngine::instance().queue_command("unban " + std::to_string(ban.id));
|
|
show_notification("Unbanned player: " + std::to_string(ban.id), 3.5, good);
|
|
}
|
|
ImGui::PopID();
|
|
}
|
|
ImGui::EndTable();
|
|
}
|
|
}
|
|
end_card();
|
|
|
|
// Ban by Steam ID Form (1-1 with in-game menu)
|
|
begin_card("card_ban_add", "BAN BY STEAM ID", "Add a SteamID64 to the server ban list");
|
|
ImGui::PushItemWidth(S(320));
|
|
ImGui::InputTextWithHint("SteamID64", "7656119... (17 digits)", g_state.mod_steam_id, sizeof(g_state.mod_steam_id));
|
|
ImGui::InputTextWithHint("Player Name / Reason", "Optional player identifier", g_state.mod_reason, sizeof(g_state.mod_reason));
|
|
ImGui::PopItemWidth();
|
|
|
|
ImGui::Spacing();
|
|
if (rough_button("BAN PLAYER", ImVec2(S(160), S(32)))) {
|
|
const std::string typed_id = g_state.mod_steam_id;
|
|
const std::string typed_reason = g_state.mod_reason;
|
|
if (!typed_id.empty()) {
|
|
ServerEngine::instance().queue_command("ban " + typed_id + " " + typed_reason);
|
|
show_notification("Issued ban for SteamID: " + typed_id, 3.5, danger);
|
|
g_state.mod_steam_id[0] = '\0';
|
|
g_state.mod_reason[0] = '\0';
|
|
}
|
|
}
|
|
end_card();
|
|
|
|
} else if (g_state.moderation_subtab == 1) {
|
|
// PLAYER ROLES
|
|
// Everyone who was given a role is listed with it, and taking it away leaves them the
|
|
// default role. (A copy: the commands below change the list.)
|
|
const auto admins = ServerEngine::instance().config().roles.players;
|
|
begin_card("card_admins", "PLAYER ROLES", "Players who were given a role (data/roles.json)");
|
|
if (admins.empty()) {
|
|
ImGui::PushStyleColor(ImGuiCol_Text, muted);
|
|
ImGui::TextUnformatted("Nobody was given a role yet.");
|
|
ImGui::PopStyleColor();
|
|
} else {
|
|
for (const auto& [admin_id, role_name] : admins) {
|
|
ImGui::AlignTextToFramePadding();
|
|
ImGui::PushFont(g_fonts.mono);
|
|
ImGui::Text("%llu", admin_id);
|
|
ImGui::PopFont();
|
|
ImGui::SameLine(0, S(16));
|
|
ImGui::TextUnformatted(role_name.c_str());
|
|
ImGui::SameLine(0, S(16));
|
|
ImGui::PushID(static_cast<int>(admin_id));
|
|
if (rough_button("Remove Role", ImVec2(S(120), S(24)))) {
|
|
ServerEngine::instance().queue_command("role " + std::to_string(admin_id) + " none");
|
|
show_notification("Removed the role of " + std::to_string(admin_id), 3.0, warning);
|
|
}
|
|
ImGui::PopID();
|
|
}
|
|
}
|
|
|
|
ImGui::Spacing();
|
|
static char s_new_admin[64]{};
|
|
ImGui::PushItemWidth(S(260));
|
|
ImGui::InputTextWithHint("##new_admin_id", "SteamID64 (e.g. 7656119...)", s_new_admin, sizeof(s_new_admin));
|
|
ImGui::PopItemWidth();
|
|
// Which role to give: any of the server's, highest rank first.
|
|
static std::string s_grant_role = "admin";
|
|
const auto& known = ServerEngine::instance().config().roles;
|
|
if (!known.find(s_grant_role) && !known.list.empty()) s_grant_role = known.list.front().name;
|
|
ImGui::SameLine(0, S(10));
|
|
ImGui::PushItemWidth(S(170));
|
|
if (ImGui::BeginCombo("##grant_role", s_grant_role.c_str())) {
|
|
for (const auto& role : known.list)
|
|
if (ImGui::Selectable(role.name.c_str(), role.name == s_grant_role)) s_grant_role = role.name;
|
|
ImGui::EndCombo();
|
|
}
|
|
ImGui::PopItemWidth();
|
|
ImGui::SameLine(0, S(10));
|
|
if (rough_button("GIVE ROLE", ImVec2(S(140), S(30)))) {
|
|
if (s_new_admin[0] && known.find(s_grant_role)) {
|
|
ServerEngine::instance().queue_command(std::string("role ") + s_new_admin + " " + s_grant_role);
|
|
show_notification("Gave " + std::string(s_new_admin) + " the role " + s_grant_role, 3.5, good);
|
|
s_new_admin[0] = '\0';
|
|
}
|
|
}
|
|
ImGui::PushStyleColor(ImGuiCol_Text, muted);
|
|
ImGui::TextWrapped("Roles are given while the server is running. Create and edit the roles themselves on the ROLES tab.");
|
|
ImGui::PopStyleColor();
|
|
end_card();
|
|
} else {
|
|
draw_roles_editor();
|
|
}
|
|
}
|
|
|
|
void draw_interactive_telemetry_graph(const ServerStatusSnapshot& stats) {
|
|
const auto& history = ServerEngine::instance().telemetry_history();
|
|
|
|
// 1. Time scale selector
|
|
category_tabs(g_state.telemetry_time_scale,
|
|
{"LAST 60 SECONDS", "LAST 5 MINUTES", "LAST 15 MINUTES", "LAST 1 HOUR"},
|
|
"telemetry_timescale_tabs");
|
|
|
|
// 2. Metric selector
|
|
category_tabs(g_state.telemetry_metric,
|
|
{"TICK RATE (TPS)", "FRAME LATENCY (MS)", "PHYSICAL MEMORY (MB)"},
|
|
"telemetry_metric_tabs");
|
|
|
|
// Determine sample window
|
|
size_t sample_window = 60;
|
|
if (g_state.telemetry_time_scale == 1) sample_window = 300;
|
|
else if (g_state.telemetry_time_scale == 2) sample_window = 900;
|
|
else if (g_state.telemetry_time_scale == 3) sample_window = 3600;
|
|
|
|
std::vector<float> metric_values;
|
|
std::vector<uint32_t> player_counts;
|
|
std::vector<uint64_t> timestamps;
|
|
|
|
const size_t available = history.size();
|
|
const size_t take_count = std::min(available, sample_window);
|
|
const size_t start_idx = available > take_count ? available - take_count : 0;
|
|
|
|
metric_values.reserve(take_count);
|
|
player_counts.reserve(take_count);
|
|
timestamps.reserve(take_count);
|
|
|
|
for (size_t i = start_idx; i < available; ++i) {
|
|
const auto& s = history[i];
|
|
timestamps.push_back(s.uptime_seconds);
|
|
player_counts.push_back(s.player_count);
|
|
|
|
if (g_state.telemetry_metric == 0) {
|
|
metric_values.push_back(s.tps);
|
|
} else if (g_state.telemetry_metric == 1) {
|
|
metric_values.push_back(s.frame_time_ms);
|
|
} else {
|
|
metric_values.push_back(s.memory_mb);
|
|
}
|
|
}
|
|
|
|
// Fallback if no rolling samples yet
|
|
if (metric_values.empty()) {
|
|
timestamps.push_back(0);
|
|
player_counts.push_back(stats.player_count);
|
|
if (g_state.telemetry_metric == 0) metric_values.push_back(static_cast<float>(stats.actual_tps));
|
|
else if (g_state.telemetry_metric == 1) metric_values.push_back(static_cast<float>(stats.frame_time_ms));
|
|
else metric_values.push_back(static_cast<float>(ServerEngine::instance().process_memory_mb()));
|
|
}
|
|
|
|
// Key Statistics
|
|
const float current_val = metric_values.back();
|
|
float sum = 0.0f;
|
|
float min_val = metric_values.front();
|
|
float max_val = metric_values.front();
|
|
|
|
for (float v : metric_values) {
|
|
sum += v;
|
|
if (v < min_val) min_val = v;
|
|
if (v > max_val) max_val = v;
|
|
}
|
|
const float avg_val = sum / static_cast<float>(metric_values.size());
|
|
|
|
// 3. KPI Statistical Summary Ribbon
|
|
begin_card("card_telemetry_kpi", nullptr, nullptr);
|
|
{
|
|
const float card_w = ImGui::GetContentRegionAvail().x;
|
|
const float col_w = card_w / 5.0f;
|
|
auto* draw = ImGui::GetWindowDrawList();
|
|
|
|
auto draw_kpi_cell = [&](int index, const char* label, const std::string& val_str, ImU32 col) {
|
|
const auto origin = ImGui::GetCursorScreenPos();
|
|
const ImVec2 cell_pos(origin.x + col_w * index, origin.y);
|
|
draw->AddText(g_fonts.caption, S(11), cell_pos, muted, label);
|
|
draw->AddText(g_fonts.bold, S(16), ImVec2(cell_pos.x, cell_pos.y + S(16)), col, val_str.c_str());
|
|
if (index < 4) {
|
|
draw->AddLine(ImVec2(cell_pos.x + col_w - S(10), cell_pos.y + S(4)),
|
|
ImVec2(cell_pos.x + col_w - S(10), cell_pos.y + S(36)),
|
|
IM_COL32(45, 48, 56, 255), 1.0f);
|
|
}
|
|
};
|
|
|
|
char s_cur[32], s_avg[32], s_min[32], s_max[32], s_stab[32];
|
|
ImU32 col_cur = good, col_avg = good, col_max = good;
|
|
|
|
if (g_state.telemetry_metric == 0) {
|
|
// TPS
|
|
std::snprintf(s_cur, sizeof(s_cur), "%.1f TPS", current_val);
|
|
std::snprintf(s_avg, sizeof(s_avg), "%.1f TPS", avg_val);
|
|
std::snprintf(s_min, sizeof(s_min), "%.1f TPS", min_val);
|
|
std::snprintf(s_max, sizeof(s_max), "%.1f TPS", max_val);
|
|
const float stab = avg_val > 0.0f ? std::clamp((avg_val / 20.0f) * 100.0f, 0.0f, 100.0f) : 0.0f;
|
|
std::snprintf(s_stab, sizeof(s_stab), "%.1f%% Optimal", stab);
|
|
col_cur = current_val >= 19.0f ? good : (current_val >= 15.0f ? warning : danger);
|
|
col_avg = avg_val >= 19.0f ? good : (avg_val >= 15.0f ? warning : danger);
|
|
col_max = good;
|
|
} else if (g_state.telemetry_metric == 1) {
|
|
// Frame Latency (ms)
|
|
std::snprintf(s_cur, sizeof(s_cur), "%.2f ms", current_val);
|
|
std::snprintf(s_avg, sizeof(s_avg), "%.2f ms", avg_val);
|
|
std::snprintf(s_min, sizeof(s_min), "%.2f ms", min_val);
|
|
std::snprintf(s_max, sizeof(s_max), "%.2f ms", max_val);
|
|
std::snprintf(s_stab, sizeof(s_stab), "P99: %.2f ms", max_val);
|
|
col_cur = current_val < 5.0f ? good : (current_val < 20.0f ? warning : danger);
|
|
col_avg = avg_val < 5.0f ? good : (avg_val < 20.0f ? warning : danger);
|
|
col_max = max_val < 10.0f ? good : (max_val < 30.0f ? warning : danger);
|
|
} else {
|
|
// Memory (MB)
|
|
std::snprintf(s_cur, sizeof(s_cur), "%.1f MB", current_val);
|
|
std::snprintf(s_avg, sizeof(s_avg), "%.1f MB", avg_val);
|
|
std::snprintf(s_min, sizeof(s_min), "%.1f MB", min_val);
|
|
std::snprintf(s_max, sizeof(s_max), "%.1f MB", max_val);
|
|
std::snprintf(s_stab, sizeof(s_stab), "Delta: %+.1f MB", current_val - metric_values.front());
|
|
col_cur = current_val < 500.0f ? good : (current_val < 1000.0f ? warning : danger);
|
|
col_avg = good;
|
|
col_max = good;
|
|
}
|
|
|
|
draw_kpi_cell(0, "CURRENT VALUE", s_cur, col_cur);
|
|
draw_kpi_cell(1, "ROLLING AVERAGE", s_avg, col_avg);
|
|
draw_kpi_cell(2, "MINIMUM DIP", s_min, (g_state.telemetry_metric == 0 && min_val < 18.0f) ? warning : muted);
|
|
draw_kpi_cell(3, "PEAK SPIKE", s_max, col_max);
|
|
draw_kpi_cell(4, "LOOP STABILITY", s_stab, good);
|
|
|
|
ImGui::Dummy(ImVec2(card_w, S(42)));
|
|
}
|
|
end_card();
|
|
|
|
// 4. Vector Performance Graph Canvas
|
|
const float graph_w = ImGui::GetContentRegionAvail().x;
|
|
const float graph_h = S(190.0f);
|
|
const auto canvas_pos = ImGui::GetCursorScreenPos();
|
|
auto* draw = ImGui::GetWindowDrawList();
|
|
|
|
ImGui::PushID("telemetry_graph_canvas");
|
|
ImGui::InvisibleButton("##graph_hitbox", ImVec2(graph_w, graph_h));
|
|
const bool is_canvas_hovered = ImGui::IsItemHovered();
|
|
ImGui::PopID();
|
|
|
|
// Background tile
|
|
draw->AddRectFilled(canvas_pos, ImVec2(canvas_pos.x + graph_w, canvas_pos.y + graph_h), IM_COL32(14, 15, 18, 255));
|
|
draw->AddRect(canvas_pos, ImVec2(canvas_pos.x + graph_w, canvas_pos.y + graph_h), IM_COL32(35, 38, 46, 255));
|
|
|
|
float axis_min = 0.0f;
|
|
float axis_max = 25.0f;
|
|
std::vector<std::pair<float, std::string>> grid_levels;
|
|
|
|
if (g_state.telemetry_metric == 0) {
|
|
axis_min = 0.0f;
|
|
axis_max = 25.0f;
|
|
grid_levels = {
|
|
{20.0f, "20 TPS (Target)"},
|
|
{15.0f, "15 TPS"},
|
|
{10.0f, "10 TPS"},
|
|
{5.0f, "5 TPS"},
|
|
{0.0f, "0 TPS"}
|
|
};
|
|
} else if (g_state.telemetry_metric == 1) {
|
|
axis_min = 0.0f;
|
|
const float peak_headroom = std::max(0.5f, max_val * 1.35f);
|
|
if (peak_headroom <= 2.0f) {
|
|
axis_max = 2.0f;
|
|
grid_levels = {
|
|
{2.0f, "2.0 ms"},
|
|
{1.5f, "1.5 ms"},
|
|
{1.0f, "1.0 ms"},
|
|
{0.5f, "0.5 ms"},
|
|
{0.0f, "0 ms"}
|
|
};
|
|
} else if (peak_headroom <= 5.0f) {
|
|
axis_max = 5.0f;
|
|
grid_levels = {
|
|
{5.0f, "5.0 ms"},
|
|
{3.75f, "3.75 ms"},
|
|
{2.5f, "2.5 ms"},
|
|
{1.25f, "1.25 ms"},
|
|
{0.0f, "0 ms"}
|
|
};
|
|
} else if (peak_headroom <= 10.0f) {
|
|
axis_max = 10.0f;
|
|
grid_levels = {
|
|
{10.0f, "10 ms"},
|
|
{7.5f, "7.5 ms"},
|
|
{5.0f, "5.0 ms"},
|
|
{2.5f, "2.5 ms"},
|
|
{0.0f, "0 ms"}
|
|
};
|
|
} else if (peak_headroom <= 25.0f) {
|
|
axis_max = 25.0f;
|
|
grid_levels = {
|
|
{25.0f, "25 ms"},
|
|
{20.0f, "20 ms"},
|
|
{15.0f, "15 ms"},
|
|
{10.0f, "10 ms"},
|
|
{5.0f, "5.0 ms"},
|
|
{0.0f, "0 ms"}
|
|
};
|
|
} else {
|
|
axis_max = std::max(50.0f, std::ceil(peak_headroom / 10.0f) * 10.0f);
|
|
grid_levels = {
|
|
{50.0f, "50 ms (Budget)"},
|
|
{axis_max, std::format("{:.0f} ms", axis_max)},
|
|
{axis_max * 0.75f, std::format("{:.0f} ms", axis_max * 0.75f)},
|
|
{axis_max * 0.50f, std::format("{:.0f} ms", axis_max * 0.50f)},
|
|
{axis_max * 0.25f, std::format("{:.0f} ms", axis_max * 0.25f)},
|
|
{0.0f, "0 ms"}
|
|
};
|
|
}
|
|
} else {
|
|
axis_min = 0.0f;
|
|
axis_max = std::max(200.0f, std::ceil(max_val * 1.25f / 50.0f) * 50.0f);
|
|
grid_levels = {
|
|
{axis_max, std::format("{:.0f} MB", axis_max)},
|
|
{axis_max * 0.75f, std::format("{:.0f} MB", axis_max * 0.75f)},
|
|
{axis_max * 0.50f, std::format("{:.0f} MB", axis_max * 0.50f)},
|
|
{axis_max * 0.25f, std::format("{:.0f} MB", axis_max * 0.25f)},
|
|
{0.0f, "0 MB"}
|
|
};
|
|
}
|
|
|
|
float max_lbl_w = 0.0f;
|
|
for (const auto& [level, label] : grid_levels) {
|
|
const auto sz = g_fonts.caption->CalcTextSizeA(S(10), FLT_MAX, 0, label.c_str());
|
|
if (sz.x > max_lbl_w) max_lbl_w = sz.x;
|
|
}
|
|
|
|
const float pad_left = max_lbl_w + S(18.0f);
|
|
const float pad_right = S(18.0f);
|
|
const float pad_top = S(18.0f);
|
|
const float pad_bottom = S(26.0f);
|
|
|
|
const float plot_w = graph_w - pad_left - pad_right;
|
|
const float plot_h = graph_h - pad_top - pad_bottom;
|
|
|
|
for (const auto& [level, label] : grid_levels) {
|
|
if (level < axis_min || level > axis_max) continue;
|
|
const float norm_y = (level - axis_min) / (axis_max - axis_min);
|
|
const float line_y = canvas_pos.y + pad_top + plot_h * (1.0f - norm_y);
|
|
|
|
const bool is_target_level = (g_state.telemetry_metric == 0 && level == 20.0f) || (g_state.telemetry_metric == 1 && level == 50.0f);
|
|
const ImU32 line_col = is_target_level ? IM_COL32(1, 131, 255, 120) : IM_COL32(32, 35, 42, 255);
|
|
const ImU32 txt_col = is_target_level ? blue : IM_COL32(90, 95, 105, 255);
|
|
|
|
draw->AddLine(ImVec2(canvas_pos.x + pad_left, line_y),
|
|
ImVec2(canvas_pos.x + pad_left + plot_w, line_y),
|
|
line_col, 1.0f);
|
|
|
|
const auto lbl_sz = g_fonts.caption->CalcTextSizeA(S(10), FLT_MAX, 0, label.c_str());
|
|
draw->AddText(g_fonts.caption, S(10),
|
|
ImVec2(canvas_pos.x + pad_left - lbl_sz.x - S(6), line_y - lbl_sz.y * 0.5f),
|
|
txt_col, label.c_str());
|
|
}
|
|
|
|
const size_t count = metric_values.size();
|
|
std::vector<ImVec2> points;
|
|
points.reserve(count);
|
|
|
|
for (size_t i = 0; i < count; ++i) {
|
|
const float t = count > 1 ? static_cast<float>(i) / static_cast<float>(count - 1) : 1.0f;
|
|
const float px = canvas_pos.x + pad_left + plot_w * t;
|
|
const float val = metric_values[i];
|
|
const float norm_y = std::clamp((val - axis_min) / (axis_max - axis_min), 0.0f, 1.0f);
|
|
const float py = canvas_pos.y + pad_top + plot_h * (1.0f - norm_y);
|
|
points.push_back(ImVec2(px, py));
|
|
}
|
|
|
|
if (points.size() >= 2) {
|
|
std::vector<ImVec2> fill_poly;
|
|
fill_poly.reserve(points.size() + 2);
|
|
fill_poly.push_back(ImVec2(points.front().x, canvas_pos.y + pad_top + plot_h));
|
|
for (const auto& pt : points) fill_poly.push_back(pt);
|
|
fill_poly.push_back(ImVec2(points.back().x, canvas_pos.y + pad_top + plot_h));
|
|
|
|
draw->AddConvexPolyFilled(fill_poly.data(), static_cast<int>(fill_poly.size()), IM_COL32(1, 131, 255, 30));
|
|
draw->AddPolyline(points.data(), static_cast<int>(points.size()), blue, 0, S(2.0f));
|
|
} else if (points.size() == 1) {
|
|
draw->AddCircleFilled(points[0], S(4.0f), blue);
|
|
}
|
|
|
|
const char* t_start = g_state.telemetry_time_scale == 0 ? "-60s" : (g_state.telemetry_time_scale == 1 ? "-5m" : (g_state.telemetry_time_scale == 2 ? "-15m" : "-1h"));
|
|
const char* t_mid = g_state.telemetry_time_scale == 0 ? "-30s" : (g_state.telemetry_time_scale == 1 ? "-2.5m" : (g_state.telemetry_time_scale == 2 ? "-7.5m" : "-30m"));
|
|
const char* t_now = "Now";
|
|
|
|
draw->AddText(g_fonts.caption, S(10), ImVec2(canvas_pos.x + pad_left, canvas_pos.y + pad_top + plot_h + S(4)), muted, t_start);
|
|
const auto sz_mid = g_fonts.caption->CalcTextSizeA(S(10), FLT_MAX, 0, t_mid);
|
|
draw->AddText(g_fonts.caption, S(10), ImVec2(canvas_pos.x + pad_left + (plot_w - sz_mid.x) * 0.5f, canvas_pos.y + pad_top + plot_h + S(4)), muted, t_mid);
|
|
const auto sz_now = g_fonts.caption->CalcTextSizeA(S(10), FLT_MAX, 0, t_now);
|
|
draw->AddText(g_fonts.caption, S(10), ImVec2(canvas_pos.x + pad_left + plot_w - sz_now.x, canvas_pos.y + pad_top + plot_h + S(4)), good, t_now);
|
|
|
|
const auto io = ImGui::GetIO();
|
|
if (is_canvas_hovered && count > 0 && io.MousePos.x >= canvas_pos.x + pad_left && io.MousePos.x <= canvas_pos.x + pad_left + plot_w) {
|
|
const float rel_x = (io.MousePos.x - (canvas_pos.x + pad_left)) / plot_w;
|
|
const size_t hover_idx = std::clamp(static_cast<size_t>(std::round(rel_x * (count - 1))), static_cast<size_t>(0), count - 1);
|
|
|
|
const ImVec2 pt = points[hover_idx];
|
|
const float val = metric_values[hover_idx];
|
|
const uint32_t p_count = player_counts[hover_idx];
|
|
|
|
draw->AddLine(ImVec2(pt.x, canvas_pos.y + pad_top), ImVec2(pt.x, canvas_pos.y + pad_top + plot_h), IM_COL32(255, 255, 255, 140), 1.0f);
|
|
draw->AddCircleFilled(pt, S(5.0f), IM_COL32(255, 255, 255, 255));
|
|
draw->AddCircleFilled(pt, S(3.5f), blue);
|
|
|
|
char hud_val[64], hud_time[64], hud_players[32];
|
|
const int secs_ago = static_cast<int>((count - 1 - hover_idx));
|
|
if (secs_ago == 0) std::snprintf(hud_time, sizeof(hud_time), "Live (Current)");
|
|
else if (secs_ago < 60) std::snprintf(hud_time, sizeof(hud_time), "-%ds ago", secs_ago);
|
|
else std::snprintf(hud_time, sizeof(hud_time), "-%dm %ds ago", secs_ago / 60, secs_ago % 60);
|
|
|
|
if (g_state.telemetry_metric == 0) std::snprintf(hud_val, sizeof(hud_val), "TPS: %.1f", val);
|
|
else if (g_state.telemetry_metric == 1) std::snprintf(hud_val, sizeof(hud_val), "Latency: %.2f ms", val);
|
|
else std::snprintf(hud_val, sizeof(hud_val), "Memory: %.1f MB", val);
|
|
|
|
std::snprintf(hud_players, sizeof(hud_players), "Players: %u", p_count);
|
|
|
|
const auto sz_v = g_fonts.bold->CalcTextSizeA(S(13), FLT_MAX, 0, hud_val);
|
|
const auto sz_t = g_fonts.caption->CalcTextSizeA(S(11), FLT_MAX, 0, hud_time);
|
|
const auto sz_p = g_fonts.caption->CalcTextSizeA(S(11), FLT_MAX, 0, hud_players);
|
|
|
|
const float hud_w = std::max({sz_v.x, sz_t.x, sz_p.x}) + S(20.0f);
|
|
const float hud_h = S(58.0f);
|
|
|
|
float hud_x = pt.x + S(12.0f);
|
|
if (hud_x + hud_w > canvas_pos.x + graph_w - S(10.0f)) {
|
|
hud_x = pt.x - hud_w - S(12.0f);
|
|
}
|
|
float hud_y = pt.y - hud_h * 0.5f;
|
|
if (hud_y < canvas_pos.y + pad_top) hud_y = canvas_pos.y + pad_top;
|
|
if (hud_y + hud_h > canvas_pos.y + graph_h - S(8.0f)) hud_y = canvas_pos.y + graph_h - hud_h - S(8.0f);
|
|
|
|
draw->AddRectFilled(ImVec2(hud_x, hud_y), ImVec2(hud_x + hud_w, hud_y + hud_h), IM_COL32(22, 25, 32, 245), S(4));
|
|
draw->AddRect(ImVec2(hud_x, hud_y), ImVec2(hud_x + hud_w, hud_y + hud_h), IM_COL32(60, 65, 78, 255), S(4));
|
|
draw->AddRectFilled(ImVec2(hud_x, hud_y), ImVec2(hud_x + S(3), hud_y + hud_h), blue, S(4), ImDrawFlags_RoundCornersLeft);
|
|
|
|
draw->AddText(g_fonts.bold, S(13), ImVec2(hud_x + S(10), hud_y + S(6)), white, hud_val);
|
|
draw->AddText(g_fonts.caption, S(11), ImVec2(hud_x + S(10), hud_y + S(24)), muted, hud_time);
|
|
draw->AddText(g_fonts.caption, S(11), ImVec2(hud_x + S(10), hud_y + S(38)), good, hud_players);
|
|
}
|
|
|
|
ImGui::Dummy(ImVec2(0, S(14)));
|
|
}
|
|
|
|
void draw_telemetry_tab() {
|
|
section("SERVER TELEMETRY & PERFORMANCE METRICS");
|
|
|
|
const auto stats = ServerEngine::instance().status_snapshot();
|
|
const double mem_mb = ServerEngine::instance().process_memory_mb();
|
|
const auto players = ServerEngine::instance().player_list();
|
|
|
|
// Render the interactive Vector Performance Graph
|
|
draw_interactive_telemetry_graph(stats);
|
|
|
|
ImGui::Columns(2, "stock_telemetry_cards", false);
|
|
ImGui::SetColumnWidth(0, ImGui::GetWindowWidth() * 0.50f);
|
|
|
|
// Card 1: Server Tick & Engine Health
|
|
begin_card("card_tick_health", "SERVER TICK & ENGINE HEALTH", "Tick pacing and loop performance");
|
|
stat_row("Target Engine Rate", std::to_string(stats.target_tps) + " TPS (Dedicated Standard)");
|
|
char buf[64];
|
|
std::snprintf(buf, sizeof(buf), "%.1f TPS", stats.actual_tps);
|
|
stat_row("Measured Tick Rate", stats.state == ServerState::Running ? buf : "0.0 TPS (Offline)", stats.state == ServerState::Running ? good : muted);
|
|
std::snprintf(buf, sizeof(buf), "%.2f ms", stats.frame_time_ms);
|
|
stat_row("Frame Execution Latency", stats.state == ServerState::Running ? buf : "0.00 ms");
|
|
const double budget_pct = (stats.frame_time_ms / (1000.0 / 20.0)) * 100.0;
|
|
std::snprintf(buf, sizeof(buf), "%.1f%% of 50ms tick budget", budget_pct);
|
|
stat_row("Tick Budget Share", stats.state == ServerState::Running ? buf : "0.0%");
|
|
stat_row("Engine Health Status", stats.state == ServerState::Running ? (stats.frame_time_ms < 5.0 ? "OPTIMAL (Sub-millisecond)" : "STABLE") : "OFFLINE", stats.state == ServerState::Running ? good : muted);
|
|
end_card();
|
|
|
|
// Card 2: Steam SDR Networking & Protocols
|
|
begin_card("card_steam_proto", "STEAM SDR NETWORKING & TRAFFIC", "Valve SDR transport and stream batching");
|
|
stat_row("Transport Architecture", "Valve Steam Datagram Relay (SDR)", good);
|
|
stat_row("Pose Compression", "PoseBatch Tier 0-3 + DeltaCodec", good);
|
|
stat_row("Audio Stream Codec", "SoundCodec (Opus 20ms Frame Rate)", good);
|
|
stat_row("Wire Encoding", "Byte-Packed BitStream (Optimized)", good);
|
|
stat_row("Direct UDP Fallback", "ENABLED (Direct Port 27015)", good);
|
|
end_card();
|
|
|
|
ImGui::NextColumn();
|
|
|
|
// Card 3: Process Threads & Memory
|
|
begin_card("card_process_mem", "SERVER PROCESS & MEMORY", "Host execution environment metrics");
|
|
std::snprintf(buf, sizeof(buf), "%.1f MB Working Set", mem_mb);
|
|
stat_row("Physical Memory (RAM)", buf);
|
|
stat_row("Worker Thread Dispatch", "Multi-Threaded (Up to 8 cores)", good);
|
|
stat_row("Monotonic Clock", "High-Resolution Monotonic Epoch", good);
|
|
stat_row("Release Build Platform", "Windows x64 (MSVC Release)", good);
|
|
end_card();
|
|
|
|
// Card 4: Traffic & Relay Protocol
|
|
begin_card("card_traffic_stats", "TRAFFIC & RELAY PROTOCOL", "Network routing and connection state");
|
|
stat_row("Direct UDP Port", "Port " + std::to_string(stats.port));
|
|
stat_row("Query Port", "Port " + std::to_string(stats.query_port));
|
|
stat_row("Active Player Streams", std::to_string(stats.player_count) + " stream(s)");
|
|
stat_row("Steam Relay Status", stats.state == ServerState::Running ? "CONNECTED (Active Relay Cluster)" : "OFFLINE", stats.state == ServerState::Running ? good : muted);
|
|
end_card();
|
|
|
|
ImGui::Columns(1);
|
|
|
|
ImGui::Dummy(ImVec2(0, S(8)));
|
|
|
|
// Live Client Network Performance Table
|
|
section("LIVE CLIENT NETWORK MONITOR");
|
|
if (players.empty()) {
|
|
ImGui::PushStyleColor(ImGuiCol_Text, muted);
|
|
ImGui::TextUnformatted("No players currently connected. Live client ping and network telemetry will appear once players join.");
|
|
ImGui::PopStyleColor();
|
|
} else {
|
|
if (ImGui::BeginTable("client_perf_table", 5, ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersInnerH | ImGuiTableFlags_SizingStretchProp)) {
|
|
ImGui::TableSetupColumn("SteamID64", ImGuiTableColumnFlags_WidthFixed, S(140));
|
|
ImGui::TableSetupColumn("Player Name", ImGuiTableColumnFlags_WidthStretch, 2.0f);
|
|
ImGui::TableSetupColumn("Round-Trip Ping", ImGuiTableColumnFlags_WidthFixed, S(120));
|
|
ImGui::TableSetupColumn("World Position", ImGuiTableColumnFlags_WidthFixed, S(130));
|
|
ImGui::TableSetupColumn("Status & Role", ImGuiTableColumnFlags_WidthFixed, S(110));
|
|
ImGui::TableHeadersRow();
|
|
|
|
for (const auto& p : players) {
|
|
ImGui::TableNextRow();
|
|
|
|
ImGui::TableSetColumnIndex(0);
|
|
ImGui::PushFont(g_fonts.mono);
|
|
ImGui::Text("%llu", p.id);
|
|
ImGui::PopFont();
|
|
|
|
ImGui::TableSetColumnIndex(1);
|
|
const auto [t_role_col, t_role_tag] = player_role(p.id, p.admin);
|
|
const float f_sz = S(13.0f);
|
|
const auto line_ht = ImGui::GetTextLineHeight();
|
|
const auto c_pos = ImGui::GetCursorScreenPos();
|
|
auto* t_draw = ImGui::GetWindowDrawList();
|
|
float t_cur_x = c_pos.x;
|
|
if (!t_role_tag.empty()) {
|
|
draw_role_badge(t_draw, g_fonts.bold, f_sz, ImVec2(t_cur_x, c_pos.y), line_ht, t_role_tag, t_role_col, 1.0f);
|
|
t_cur_x += role_badge_width(g_fonts.bold, f_sz, t_role_tag) + S(6.0f);
|
|
}
|
|
const auto t_namesz = g_fonts.bold->CalcTextSizeA(f_sz, FLT_MAX, 0.0f, p.name.c_str());
|
|
t_draw->AddText(g_fonts.bold, f_sz, ImVec2(t_cur_x + 1.0f, c_pos.y + 1.0f), IM_COL32(0, 0, 0, 180), p.name.c_str());
|
|
const int t_name_v = t_draw->VtxBuffer.Size;
|
|
t_draw->AddText(g_fonts.bold, f_sz, ImVec2(t_cur_x, c_pos.y), (!t_role_tag.empty()) ? t_role_col : white, p.name.c_str());
|
|
shade_nametag_gradient(t_draw, t_name_v, t_cur_x, t_namesz.x, t_role_col, ImGui::GetTime());
|
|
ImGui::Dummy(ImVec2(t_cur_x - c_pos.x + t_namesz.x, line_ht));
|
|
|
|
ImGui::TableSetColumnIndex(2);
|
|
const auto ping_col = p.ping < 60 ? good : p.ping < 130 ? warning : danger;
|
|
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(ping_col), "%u ms", p.ping);
|
|
|
|
ImGui::TableSetColumnIndex(3);
|
|
ImGui::PushFont(g_fonts.mono);
|
|
ImGui::Text("%.0f, %.0f, %.0f", p.x, p.y, p.z);
|
|
ImGui::PopFont();
|
|
|
|
ImGui::TableSetColumnIndex(4);
|
|
if (!t_role_tag.empty()) {
|
|
badge(t_role_tag.c_str(), animated_nametag_colour(t_role_col, ImGui::GetTime()), black);
|
|
} else {
|
|
badge("PLAYER", good, black);
|
|
}
|
|
}
|
|
ImGui::EndTable();
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
static void render_colored_log_text(const std::string& text, ImU32 default_col, float indent_x = -1.0f) {
|
|
if (text.empty()) {
|
|
ImGui::NewLine();
|
|
return;
|
|
}
|
|
|
|
struct LogToken {
|
|
enum class Type { Word, Space, Newline };
|
|
Type type;
|
|
std::string text;
|
|
ImU32 color;
|
|
};
|
|
|
|
std::vector<LogToken> tokens;
|
|
tokens.reserve(text.size() / 4 + 4);
|
|
|
|
const bool has_carets = (text.find('^') != std::string::npos);
|
|
ImU32 current_col = has_carets ? skate_theme::white : default_col;
|
|
|
|
auto get_token_color = [&](const std::string& word, ImU32 base_col) -> ImU32 {
|
|
if (has_carets) return base_col;
|
|
if (word.empty()) return base_col;
|
|
|
|
// Bracketed Tag Coloring
|
|
if (word.front() == '[' && word.back() == ']') {
|
|
std::string tag = word;
|
|
std::transform(tag.begin(), tag.end(), tag.begin(), ::tolower);
|
|
if (tag == "[+]" || tag == "[join]" || tag == "[connect]") return IM_COL32(80, 230, 100, 255);
|
|
if (tag == "[-]" || tag == "[leave]" || tag == "[left]" || tag == "[disconnect]" || tag == "[kicked]" || tag == "[ban]" || tag == "[banned]") return IM_COL32(255, 95, 95, 255);
|
|
if (tag == "[chat]") return IM_COL32(255, 215, 100, 255);
|
|
if (tag == "[cmd]" || tag == "[command]") return IM_COL32(85, 175, 255, 255);
|
|
if (tag == "[admin]") return IM_COL32(215, 130, 255, 255);
|
|
if (tag == "[map]") return IM_COL32(90, 235, 180, 255);
|
|
if (tag == "[steam]" || tag == "[direct]") return IM_COL32(115, 185, 255, 255);
|
|
if (tag == "[network]" || tag == "[traffic]") return IM_COL32(75, 220, 240, 255);
|
|
if (tag == "[words]" || tag == "[afk]" || tag == "[objects]") return IM_COL32(255, 175, 75, 255);
|
|
if (tag == "[updates]" || tag == "[update]") return IM_COL32(255, 215, 60, 255);
|
|
if (tag == "[error]" || tag == "[fatal]" || tag == "[fail]") return IM_COL32(255, 80, 80, 255);
|
|
if (tag == "[warning]" || tag == "[warn]") return IM_COL32(255, 205, 50, 255);
|
|
return IM_COL32(180, 185, 195, 255);
|
|
}
|
|
|
|
// Keywords and semantic tokens
|
|
if (word == "joined" || word == "connected" || word == "joined,") return IM_COL32(80, 230, 100, 255);
|
|
if (word == "left" || word == "kicked:" || word == "kicked" || word == "banned" || word == "removed") return IM_COL32(255, 95, 95, 255);
|
|
if (word == "admin" || word == "admin,") return IM_COL32(215, 130, 255, 255);
|
|
if (word.starts_with("http://") || word.starts_with("https://")) return IM_COL32(95, 180, 255, 255);
|
|
|
|
// SteamID (17 digits starting with 7656119)
|
|
if (word.size() >= 17 && word.starts_with("7656119")) {
|
|
bool all_digits = true;
|
|
for (size_t c = 0; c < 17; ++c) {
|
|
if (!std::isdigit(static_cast<unsigned char>(word[c]))) { all_digits = false; break; }
|
|
}
|
|
if (all_digits) return IM_COL32(130, 220, 255, 255);
|
|
}
|
|
|
|
return base_col;
|
|
};
|
|
|
|
std::string cur_buf;
|
|
LogToken::Type cur_type = LogToken::Type::Word;
|
|
|
|
auto flush_token = [&](LogToken::Type t) {
|
|
if (!cur_buf.empty()) {
|
|
ImU32 col = (t == LogToken::Type::Word) ? get_token_color(cur_buf, current_col) : current_col;
|
|
tokens.push_back({ t, cur_buf, col });
|
|
cur_buf.clear();
|
|
}
|
|
};
|
|
|
|
for (size_t i = 0; i < text.size(); ++i) {
|
|
if (text[i] == '^' && i + 1 < text.size()) {
|
|
char code = text[i + 1];
|
|
if ((code >= '0' && code <= '9') || code == 'r' || code == 'R') {
|
|
flush_token(cur_type);
|
|
switch (code) {
|
|
case '1': current_col = IM_COL32(255, 80, 80, 255); break; // Red
|
|
case '2': current_col = IM_COL32(80, 230, 100, 255); break; // Green
|
|
case '3': current_col = IM_COL32(255, 220, 60, 255); break; // Yellow
|
|
case '4': current_col = IM_COL32(80, 160, 255, 255); break; // Blue
|
|
case '5': current_col = IM_COL32(60, 215, 255, 255); break; // Cyan
|
|
case '6': current_col = IM_COL32(220, 120, 255, 255); break; // Magenta
|
|
case '7': current_col = skate_theme::white; break; // White / Default
|
|
case '8': current_col = IM_COL32(255, 160, 50, 255); break; // Orange
|
|
case '9': current_col = IM_COL32(140, 145, 155, 255); break; // Grey
|
|
case '0':
|
|
case 'r':
|
|
case 'R': current_col = skate_theme::white; break;
|
|
}
|
|
++i;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (text[i] == '\n') {
|
|
flush_token(cur_type);
|
|
tokens.push_back({ LogToken::Type::Newline, "\n", current_col });
|
|
continue;
|
|
}
|
|
|
|
if (text[i] == ' ' || text[i] == '\t') {
|
|
if (cur_type != LogToken::Type::Space) {
|
|
flush_token(cur_type);
|
|
cur_type = LogToken::Type::Space;
|
|
}
|
|
cur_buf += text[i];
|
|
} else {
|
|
if (cur_type != LogToken::Type::Word) {
|
|
flush_token(cur_type);
|
|
cur_type = LogToken::Type::Word;
|
|
}
|
|
cur_buf += text[i];
|
|
// Split breakable punctuation so URLs and comma-separated lists wrap at punctuation marks
|
|
if (text[i] == ',' || text[i] == '/' || text[i] == '&' || text[i] == ';' || text[i] == '?') {
|
|
flush_token(cur_type);
|
|
}
|
|
}
|
|
}
|
|
flush_token(cur_type);
|
|
|
|
if (tokens.empty()) {
|
|
ImGui::NewLine();
|
|
return;
|
|
}
|
|
|
|
const float window_left_x = ImGui::GetWindowPos().x;
|
|
const float window_w = ImGui::GetWindowWidth();
|
|
const float scrollbar_w = ImGui::GetStyle().ScrollbarSize;
|
|
const float padding_x = ImGui::GetStyle().WindowPadding.x;
|
|
// Right margin limit for text rendering
|
|
const float max_screen_x = window_left_x + window_w - padding_x - scrollbar_w - S(6.0f);
|
|
|
|
float start_screen_x = ImGui::GetCursorScreenPos().x;
|
|
float indent_screen_x = (indent_x >= 0.0f) ? (window_left_x + indent_x) : start_screen_x;
|
|
if (max_screen_x - indent_screen_x < S(160.0f)) {
|
|
indent_screen_x = window_left_x + padding_x;
|
|
}
|
|
|
|
float current_screen_x = start_screen_x;
|
|
bool first_on_line = true;
|
|
size_t idx = 0;
|
|
|
|
while (idx < tokens.size()) {
|
|
if (tokens[idx].type == LogToken::Type::Newline) {
|
|
if (first_on_line) {
|
|
ImGui::NewLine();
|
|
}
|
|
ImGui::SetCursorScreenPos(ImVec2(indent_screen_x, ImGui::GetCursorScreenPos().y));
|
|
current_screen_x = indent_screen_x;
|
|
first_on_line = true;
|
|
++idx;
|
|
continue;
|
|
}
|
|
|
|
if (tokens[idx].type == LogToken::Type::Space) {
|
|
if (first_on_line) {
|
|
++idx;
|
|
continue;
|
|
}
|
|
float sp_w = ImGui::CalcTextSize(tokens[idx].text.c_str()).x;
|
|
if (current_screen_x + sp_w <= max_screen_x) {
|
|
ImGui::PushStyleColor(ImGuiCol_Text, tokens[idx].color);
|
|
ImGui::SameLine(0.0f, 0.0f);
|
|
ImGui::TextUnformatted(tokens[idx].text.c_str());
|
|
ImGui::PopStyleColor();
|
|
current_screen_x += sp_w;
|
|
} else {
|
|
// Trailing space overflows; cursor is already on next line after previous TextUnformatted
|
|
ImGui::SetCursorScreenPos(ImVec2(indent_screen_x, ImGui::GetCursorScreenPos().y));
|
|
current_screen_x = indent_screen_x;
|
|
first_on_line = true;
|
|
}
|
|
++idx;
|
|
continue;
|
|
}
|
|
|
|
// Word token
|
|
float tok_w = ImGui::CalcTextSize(tokens[idx].text.c_str()).x;
|
|
if (!first_on_line && current_screen_x + tok_w > max_screen_x) {
|
|
// TextUnformatted on previous item already advanced CursorPos.y to the next row!
|
|
// Directly align cursor on that next row without inserting an empty line:
|
|
ImGui::SetCursorScreenPos(ImVec2(indent_screen_x, ImGui::GetCursorScreenPos().y));
|
|
current_screen_x = indent_screen_x;
|
|
first_on_line = true;
|
|
}
|
|
|
|
if (tok_w > (max_screen_x - indent_screen_x)) {
|
|
const auto& str = tokens[idx].text;
|
|
ImGui::PushStyleColor(ImGuiCol_Text, tokens[idx].color);
|
|
size_t pos = 0;
|
|
while (pos < str.size()) {
|
|
float avail = max_screen_x - current_screen_x;
|
|
size_t take = 0;
|
|
float sub_w = 0.0f;
|
|
while (pos + take < str.size()) {
|
|
char ch = str[pos + take];
|
|
char ch_buf[2] = { ch, '\0' };
|
|
float ch_w = ImGui::CalcTextSize(ch_buf).x;
|
|
if (take > 0 && sub_w + ch_w > avail) break;
|
|
sub_w += ch_w;
|
|
++take;
|
|
}
|
|
if (take == 0) take = 1;
|
|
|
|
std::string sub = str.substr(pos, take);
|
|
if (!first_on_line) {
|
|
ImGui::SameLine(0.0f, 0.0f);
|
|
}
|
|
ImGui::TextUnformatted(sub.c_str());
|
|
current_screen_x += ImGui::CalcTextSize(sub.c_str()).x;
|
|
first_on_line = false;
|
|
pos += take;
|
|
|
|
if (pos < str.size()) {
|
|
// Next row for remaining characters of long token
|
|
ImGui::SetCursorScreenPos(ImVec2(indent_screen_x, ImGui::GetCursorScreenPos().y));
|
|
current_screen_x = indent_screen_x;
|
|
first_on_line = true;
|
|
}
|
|
}
|
|
ImGui::PopStyleColor();
|
|
} else {
|
|
ImGui::PushStyleColor(ImGuiCol_Text, tokens[idx].color);
|
|
if (!first_on_line) {
|
|
ImGui::SameLine(0.0f, 0.0f);
|
|
}
|
|
ImGui::TextUnformatted(tokens[idx].text.c_str());
|
|
ImGui::PopStyleColor();
|
|
current_screen_x += tok_w;
|
|
first_on_line = false;
|
|
}
|
|
|
|
++idx;
|
|
}
|
|
}
|
|
|
|
void draw_console_tab(const ServerStatusSnapshot& stats) {
|
|
section("LIVE SERVER TERMINAL & ACTIVITY STREAM");
|
|
|
|
// Lifecycle Action Bar
|
|
ImGui::PushStyleColor(ImGuiCol_ChildBg, tile);
|
|
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(S(14), S(10)));
|
|
ImGui::BeginChild("console_control_bar", ImVec2(0, 0), ImGuiChildFlags_AutoResizeY | ImGuiChildFlags_AlwaysUseWindowPadding);
|
|
ImGui::PopStyleVar();
|
|
ImGui::PopStyleColor();
|
|
|
|
const auto at = ImGui::GetWindowPos();
|
|
ImU32 bar_accent = blue;
|
|
if (stats.state == ServerState::Running) bar_accent = good;
|
|
else if (stats.state == ServerState::Error) bar_accent = danger;
|
|
else if (stats.state == ServerState::Starting || stats.state == ServerState::Stopping) bar_accent = warning;
|
|
ImGui::GetWindowDrawList()->AddRectFilled(at, ImVec2(at.x + S(4), at.y + ImGui::GetWindowHeight()), bar_accent);
|
|
|
|
// Quick Command Buttons (Left Side)
|
|
const bool is_online = stats.state == ServerState::Running;
|
|
if (rough_button("status", ImVec2(S(75), S(30)))) {
|
|
if (is_online) ServerEngine::instance().queue_command("status");
|
|
else ServerEngine::instance().log("Server is offline. Click START to boot the server lobby.", LogLevel::warning);
|
|
}
|
|
ImGui::SameLine(0, S(6));
|
|
if (rough_button("players", ImVec2(S(75), S(30)))) {
|
|
if (is_online) ServerEngine::instance().queue_command("players");
|
|
else ServerEngine::instance().log("Server is offline. No players connected.", LogLevel::warning);
|
|
}
|
|
ImGui::SameLine(0, S(6));
|
|
if (rough_button("help", ImVec2(S(75), S(30)))) {
|
|
if (is_online) ServerEngine::instance().queue_command("help");
|
|
else ServerEngine::instance().log("Server is offline. Click START or type 'start' to boot.", LogLevel::info);
|
|
}
|
|
ImGui::SameLine(0, S(14));
|
|
ImGui::Checkbox("Auto-Scroll", &g_state.auto_scroll);
|
|
|
|
ImGui::EndChild();
|
|
ImGui::Dummy(ImVec2(0, S(4)));
|
|
|
|
// Error Alert Box (if in error state)
|
|
if (stats.state == ServerState::Error) {
|
|
ImGui::PushStyleColor(ImGuiCol_ChildBg, IM_COL32(40, 16, 18, 255));
|
|
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(S(14), S(10)));
|
|
ImGui::BeginChild("console_err_box", ImVec2(0, 0), ImGuiChildFlags_AutoResizeY | ImGuiChildFlags_AlwaysUseWindowPadding);
|
|
ImGui::PopStyleVar();
|
|
ImGui::PopStyleColor();
|
|
|
|
const auto eat = ImGui::GetWindowPos();
|
|
ImGui::GetWindowDrawList()->AddRectFilled(eat, ImVec2(eat.x + S(4), eat.y + ImGui::GetWindowHeight()), danger);
|
|
|
|
ImGui::PushFont(g_fonts.bold);
|
|
ImGui::PushStyleColor(ImGuiCol_Text, danger);
|
|
ImGui::TextUnformatted("SERVER ERROR DETECTED:");
|
|
ImGui::PopStyleColor();
|
|
ImGui::PopFont();
|
|
|
|
ImGui::PushFont(g_fonts.mono);
|
|
ImGui::PushStyleColor(ImGuiCol_Text, IM_COL32(255, 180, 180, 255));
|
|
ImGui::TextWrapped("%s", stats.last_error.empty() ? "Unknown startup failure." : stats.last_error.c_str());
|
|
ImGui::PopStyleColor();
|
|
ImGui::PopFont();
|
|
|
|
ImGui::PushStyleColor(ImGuiCol_Text, muted);
|
|
ImGui::BulletText("Port Conflict: Make sure another server is not running on UDP 27015/27016.");
|
|
ImGui::BulletText("Check steam_token or network connection if Steam sign-in timed out.");
|
|
ImGui::PopStyleColor();
|
|
|
|
ImGui::EndChild();
|
|
ImGui::Dummy(ImVec2(0, S(4)));
|
|
}
|
|
|
|
// Console output window
|
|
const float footer_height = S(110.0f);
|
|
ImGui::PushStyleColor(ImGuiCol_ChildBg, IM_COL32(10, 11, 14, 255));
|
|
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(S(12), S(10)));
|
|
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(S(4), S(2)));
|
|
ImGui::BeginChild("console_output", ImVec2(0, -footer_height), ImGuiChildFlags_Borders, ImGuiWindowFlags_AlwaysVerticalScrollbar);
|
|
|
|
const auto logs = ServerEngine::instance().log_entries();
|
|
ImGui::PushFont(g_fonts.mono);
|
|
|
|
for (const auto& entry : logs) {
|
|
ImGui::PushStyleColor(ImGuiCol_Text, IM_COL32(110, 115, 125, 255));
|
|
ImGui::Text("[%s] ", entry.timestamp.c_str());
|
|
ImGui::PopStyleColor();
|
|
ImGui::SameLine();
|
|
const float indent_x = ImGui::GetCursorPosX();
|
|
|
|
ImU32 text_col = white;
|
|
switch (entry.level) {
|
|
case LogLevel::error: text_col = danger; break;
|
|
case LogLevel::warning: text_col = warning; break;
|
|
case LogLevel::success: text_col = good; break;
|
|
case LogLevel::chat: text_col = IM_COL32(255, 215, 100, 255); break;
|
|
case LogLevel::command: text_col = blue; break;
|
|
default: text_col = white; break;
|
|
}
|
|
|
|
render_colored_log_text(entry.text, text_col, indent_x);
|
|
}
|
|
|
|
if (g_state.auto_scroll && ImGui::GetScrollY() >= ImGui::GetScrollMaxY()) {
|
|
ImGui::SetScrollHereY(1.0f);
|
|
}
|
|
|
|
ImGui::PopFont();
|
|
ImGui::EndChild();
|
|
ImGui::PopStyleVar(2);
|
|
ImGui::PopStyleColor();
|
|
|
|
ImGui::Dummy(ImVec2(0, S(4)));
|
|
|
|
// Command input bar
|
|
ImGui::PushItemWidth(ImGui::GetContentRegionAvail().x - S(100));
|
|
const bool enter_pressed = ImGui::InputTextWithHint("##cmd_in", "Type command (e.g. status, kick <player>, ban <id>, map <dest>, help)...",
|
|
g_state.console_input, sizeof(g_state.console_input),
|
|
ImGuiInputTextFlags_EnterReturnsTrue);
|
|
ImGui::PopItemWidth();
|
|
ImGui::SameLine();
|
|
|
|
if (rough_button("SEND", ImVec2(S(90), S(34))) || enter_pressed) {
|
|
if (g_state.console_input[0]) {
|
|
std::string cmd = g_state.console_input;
|
|
if (ServerEngine::instance().state() != ServerState::Running) {
|
|
ServerEngine::instance().log("[cmd] " + cmd, LogLevel::command);
|
|
if (cmd == "start" || cmd == "boot") {
|
|
ServerEngine::instance().start_server_async();
|
|
} else {
|
|
ServerEngine::instance().log("Server is offline. Start the server to execute live console commands.", LogLevel::warning);
|
|
}
|
|
} else {
|
|
ServerEngine::instance().queue_command(cmd);
|
|
}
|
|
g_state.command_history.push_back(cmd);
|
|
g_state.history_index = -1;
|
|
g_state.console_input[0] = '\0';
|
|
ImGui::SetKeyboardFocusHere(-1);
|
|
}
|
|
}
|
|
|
|
ImGui::Dummy(ImVec2(0, S(6)));
|
|
|
|
// Bottom Action & Status Toolbar
|
|
const float cur_bar_y = ImGui::GetCursorPosY();
|
|
const float bar_h = S(30.0f);
|
|
const auto origin_bar = ImGui::GetCursorScreenPos();
|
|
|
|
// Left Side: Live Status Pill + Player Count Pill + Session Metadata
|
|
draw_server_status_pill(stats.state);
|
|
ImGui::SameLine(0, S(8));
|
|
draw_player_count_pill(stats.player_count, stats.max_players, stats.state == ServerState::Running);
|
|
|
|
if (stats.state == ServerState::Running) {
|
|
ImGui::SameLine(0, S(16));
|
|
const auto meta_start_pos = ImGui::GetCursorScreenPos();
|
|
auto* draw = ImGui::GetWindowDrawList();
|
|
const float font_size = S(12.0f);
|
|
|
|
float cur_x = meta_start_pos.x;
|
|
|
|
// 1. Draw "MAP:"
|
|
const auto sz_map_lbl = g_fonts.bold->CalcTextSizeA(font_size, FLT_MAX, 0.0f, "MAP:");
|
|
draw->AddText(g_fonts.bold, font_size, ImVec2(cur_x, origin_bar.y + (bar_h - sz_map_lbl.y) * 0.5f), muted, "MAP:");
|
|
cur_x += sz_map_lbl.x + S(6.0f);
|
|
|
|
// 2. Draw Map Name (in Blue)
|
|
const std::string map_str = stats.map_name.empty() ? "San Vansterdam" : stats.map_name;
|
|
const auto sz_map_val = g_fonts.bold->CalcTextSizeA(font_size, FLT_MAX, 0.0f, map_str.c_str());
|
|
draw->AddText(g_fonts.bold, font_size, ImVec2(cur_x, origin_bar.y + (bar_h - sz_map_val.y) * 0.5f), blue, map_str.c_str());
|
|
cur_x += sz_map_val.x + S(20.0f);
|
|
|
|
// 3. Draw "JOIN:" + Clickable Join Code
|
|
if (!stats.invite_code.empty()) {
|
|
const auto sz_join_lbl = g_fonts.bold->CalcTextSizeA(font_size, FLT_MAX, 0.0f, "JOIN:");
|
|
draw->AddText(g_fonts.bold, font_size, ImVec2(cur_x, origin_bar.y + (bar_h - sz_join_lbl.y) * 0.5f), muted, "JOIN:");
|
|
cur_x += sz_join_lbl.x + S(6.0f);
|
|
|
|
const bool just_copied = (ImGui::GetTime() < g_state.copied_until);
|
|
const char* code_display = just_copied ? "[COPIED]" : stats.invite_code.c_str();
|
|
const ImU32 code_col = just_copied ? good : white;
|
|
|
|
const auto sz_code = g_fonts.bold->CalcTextSizeA(font_size, FLT_MAX, 0.0f, code_display);
|
|
|
|
// Interactive hit-test region over join code
|
|
ImGui::SetCursorScreenPos(ImVec2(cur_x, origin_bar.y));
|
|
ImGui::PushID("btn_copy_join_code");
|
|
const bool code_clicked = ImGui::InvisibleButton("##copy_code", ImVec2(sz_code.x, bar_h));
|
|
const bool code_hovered = ImGui::IsItemHovered();
|
|
if (code_hovered) {
|
|
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
|
ImGui::SetTooltip("Click to copy Join Code: %s", stats.invite_code.c_str());
|
|
}
|
|
if (code_clicked) {
|
|
ImGui::SetClipboardText(stats.invite_code.c_str());
|
|
g_state.copied_until = ImGui::GetTime() + 2.5;
|
|
show_notification("Copied Join Code: " + stats.invite_code, 2.5, good);
|
|
}
|
|
ImGui::PopID();
|
|
|
|
draw->AddText(g_fonts.bold, font_size, ImVec2(cur_x, origin_bar.y + (bar_h - sz_code.y) * 0.5f), code_col, code_display);
|
|
cur_x += sz_code.x;
|
|
}
|
|
|
|
ImGui::SetCursorScreenPos(ImVec2(cur_x, origin_bar.y));
|
|
}
|
|
|
|
// Right Side: Server Control Block [ ▶ | ↻ | ■ ] (+ Clear Error if applicable)
|
|
const float seg_w = S(38.0f);
|
|
const float total_block_w = seg_w * 3.0f;
|
|
const float clear_w = S(110.0f);
|
|
const float clear_gap = S(10.0f);
|
|
|
|
float right_elements_w = total_block_w;
|
|
if (stats.state == ServerState::Error) {
|
|
right_elements_w += clear_w + clear_gap;
|
|
}
|
|
|
|
const float avail_w = ImGui::GetContentRegionAvail().x;
|
|
if (avail_w > right_elements_w) {
|
|
ImGui::SameLine(ImGui::GetCursorPosX() + (avail_w - right_elements_w));
|
|
} else {
|
|
ImGui::SameLine(0, S(10));
|
|
}
|
|
|
|
ImGui::SetCursorPosY(cur_bar_y);
|
|
|
|
if (stats.state == ServerState::Error) {
|
|
if (rough_button("CLEAR ERROR", ImVec2(clear_w, S(30)))) {
|
|
ServerEngine::instance().clear_error();
|
|
}
|
|
ImGui::SameLine(0, clear_gap);
|
|
}
|
|
|
|
draw_server_control_block(stats);
|
|
}
|
|
|
|
struct SettingsForm {
|
|
char name[128]{};
|
|
char password[64]{};
|
|
char welcome[256]{};
|
|
int max_players{16};
|
|
bool listed{true};
|
|
bool autostart{false};
|
|
bool auto_update{true};
|
|
bool activity_log{true};
|
|
|
|
int port{27015};
|
|
int query_port{27016};
|
|
char steam_token[128]{};
|
|
bool use_steam_relay{true};
|
|
int send_rate{900};
|
|
int crowd_budget{600};
|
|
|
|
bool boosts{true};
|
|
bool no_bail{true};
|
|
bool noclip{true};
|
|
bool parties{true};
|
|
int party_size{8};
|
|
bool voice_chat{true};
|
|
float voice_range{300.0f};
|
|
int object_placement_idx{0};
|
|
int object_limit{100};
|
|
|
|
int speed_check_idx{1};
|
|
int score_check_idx{1};
|
|
bool enforce_tuning{true};
|
|
float bone_scale_limit{2.0f};
|
|
bool global_bans{true};
|
|
bool global_words{true};
|
|
|
|
bool initialized{false};
|
|
};
|
|
|
|
static SettingsForm s_form;
|
|
|
|
static void sync_form_from_config(SettingsForm& form, const ServerConfig& cfg) {
|
|
strncpy_s(form.name, cfg.name.c_str(), sizeof(form.name) - 1);
|
|
strncpy_s(form.password, cfg.password.c_str(), sizeof(form.password) - 1);
|
|
strncpy_s(form.welcome, cfg.welcome.c_str(), sizeof(form.welcome) - 1);
|
|
form.max_players = static_cast<int>(cfg.max_players);
|
|
form.listed = cfg.listed;
|
|
form.autostart = cfg.autostart;
|
|
form.auto_update = cfg.auto_update;
|
|
form.activity_log = cfg.activity_log;
|
|
|
|
form.port = static_cast<int>(cfg.port);
|
|
form.query_port = static_cast<int>(cfg.query_port);
|
|
strncpy_s(form.steam_token, cfg.steam_token.c_str(), sizeof(form.steam_token) - 1);
|
|
form.use_steam_relay = cfg.use_steam_relay;
|
|
form.send_rate = static_cast<int>(cfg.send_rate);
|
|
form.crowd_budget = static_cast<int>(cfg.crowd_budget);
|
|
|
|
form.boosts = cfg.boosts;
|
|
form.no_bail = cfg.no_bail;
|
|
form.noclip = cfg.noclip;
|
|
form.parties = cfg.parties;
|
|
form.party_size = static_cast<int>(cfg.party_size);
|
|
form.voice_chat = cfg.voice_chat;
|
|
form.voice_range = cfg.voice_range;
|
|
form.object_placement_idx = cfg.object_placement == ObjectPlacement::everyone ? 0 : (cfg.object_placement == ObjectPlacement::host_only ? 1 : 2);
|
|
form.object_limit = static_cast<int>(cfg.object_limit);
|
|
|
|
form.speed_check_idx = cfg.speed_check == "off" ? 0 : (cfg.speed_check == "kick" ? 2 : 1);
|
|
form.score_check_idx = cfg.score_check == "off" ? 0 : (cfg.score_check == "kick" ? 2 : 1);
|
|
form.enforce_tuning = cfg.enforce_tuning;
|
|
form.bone_scale_limit = cfg.bone_scale_limit;
|
|
form.global_bans = cfg.global_bans;
|
|
form.global_words = cfg.global_words;
|
|
|
|
form.initialized = true;
|
|
}
|
|
|
|
void draw_settings_tab(ServerStatusSnapshot& stats) {
|
|
(void)stats;
|
|
section("SERVER CONFIGURATION & PREFERENCES");
|
|
|
|
if (!s_form.initialized) {
|
|
sync_form_from_config(s_form, ServerEngine::instance().config());
|
|
}
|
|
|
|
// Top Action Bar
|
|
if (rough_button("APPLY & SAVE SETTINGS", ImVec2(S(220), S(34)))) {
|
|
auto& cfg = ServerEngine::instance().config();
|
|
cfg.name = s_form.name;
|
|
cfg.password = s_form.password;
|
|
cfg.welcome = s_form.welcome;
|
|
cfg.max_players = static_cast<unsigned>(std::clamp(s_form.max_players, 1, 249));
|
|
cfg.tps = dedicated_tps;
|
|
cfg.listed = s_form.listed;
|
|
cfg.autostart = s_form.autostart;
|
|
cfg.auto_update = s_form.auto_update;
|
|
cfg.activity_log = s_form.activity_log;
|
|
|
|
cfg.port = static_cast<uint16_t>(std::clamp(s_form.port, 1024, 65535));
|
|
cfg.query_port = static_cast<uint16_t>(std::clamp(s_form.query_port, 1024, 65535));
|
|
cfg.steam_token = s_form.steam_token;
|
|
cfg.use_steam_relay = s_form.use_steam_relay;
|
|
cfg.send_rate = static_cast<unsigned>(std::clamp(s_form.send_rate, 128, 16384));
|
|
cfg.crowd_budget = static_cast<unsigned>(std::clamp(s_form.crowd_budget, 0, 20000));
|
|
|
|
cfg.boosts = s_form.boosts;
|
|
cfg.no_bail = s_form.no_bail;
|
|
cfg.noclip = s_form.noclip;
|
|
cfg.parties = s_form.parties;
|
|
cfg.party_size = static_cast<unsigned>(std::clamp(s_form.party_size, 2, 8));
|
|
cfg.voice_chat = s_form.voice_chat;
|
|
cfg.voice_range = std::clamp(s_form.voice_range, 50.0f, 1000.0f);
|
|
cfg.object_placement = s_form.object_placement_idx == 0 ? ObjectPlacement::everyone : (s_form.object_placement_idx == 1 ? ObjectPlacement::host_only : ObjectPlacement::nobody);
|
|
cfg.object_limit = static_cast<unsigned>(std::clamp(s_form.object_limit, 0, 500));
|
|
|
|
cfg.speed_check = s_form.speed_check_idx == 0 ? "off" : (s_form.speed_check_idx == 2 ? "kick" : "warn");
|
|
cfg.score_check = s_form.score_check_idx == 0 ? "off" : (s_form.score_check_idx == 2 ? "kick" : "warn");
|
|
cfg.enforce_tuning = s_form.enforce_tuning;
|
|
cfg.bone_scale_limit = std::clamp(s_form.bone_scale_limit, 1.0f, 8.0f);
|
|
cfg.global_bans = s_form.global_bans;
|
|
cfg.global_words = s_form.global_words;
|
|
|
|
try {
|
|
save_config(cfg);
|
|
if (ServerEngine::instance().state() == ServerState::Running) {
|
|
ServerEngine::instance().queue_command("name " + cfg.name);
|
|
ServerEngine::instance().queue_command(cfg.password.empty() ? "password off" : ("password " + cfg.password));
|
|
ServerEngine::instance().queue_command(cfg.welcome.empty() ? "welcome off" : ("welcome " + cfg.welcome));
|
|
ServerEngine::instance().queue_command(std::string("listed ") + (cfg.listed ? "on" : "off"));
|
|
ServerEngine::instance().queue_command(std::string("boosts ") + (cfg.boosts ? "on" : "off"));
|
|
ServerEngine::instance().queue_command(std::string("nobail ") + (cfg.no_bail ? "on" : "off"));
|
|
ServerEngine::instance().queue_command(std::string("noclip ") + (cfg.noclip ? "on" : "off"));
|
|
ServerEngine::instance().queue_command(std::string("tuning ") + (cfg.enforce_tuning ? "on" : "off"));
|
|
ServerEngine::instance().queue_command(std::string("voice ") + (cfg.voice_chat ? "on" : "off"));
|
|
ServerEngine::instance().queue_command("voice-range " + std::to_string(static_cast<int>(cfg.voice_range)));
|
|
ServerEngine::instance().queue_command(std::string("placement ") + (cfg.object_placement == ObjectPlacement::everyone ? "everyone" : (cfg.object_placement == ObjectPlacement::host_only ? "admins" : "nobody")));
|
|
ServerEngine::instance().queue_command("speed-check " + cfg.speed_check);
|
|
ServerEngine::instance().queue_command("score-check " + cfg.score_check);
|
|
ServerEngine::instance().queue_command("bone-scale " + std::to_string(static_cast<int>(cfg.bone_scale_limit)));
|
|
ServerEngine::instance().queue_command(std::string("words global ") + (cfg.global_words ? "on" : "off"));
|
|
}
|
|
show_notification("Configuration saved to ReSkateServer.json & synchronized.", 4.0, good);
|
|
} catch (const std::exception& e) {
|
|
show_notification(std::string("Save failed: ") + e.what(), 4.0, danger);
|
|
}
|
|
}
|
|
ImGui::SameLine(0, S(10));
|
|
if (rough_button("RELOAD FROM DISK", ImVec2(S(180), S(34)))) {
|
|
try {
|
|
auto cfg_path = ServerEngine::instance().folder() / "ReSkateServer.json";
|
|
auto loaded = load_config(cfg_path);
|
|
loaded.mods = ServerEngine::instance().config().mods; // where the Mods folder is: not in the file
|
|
ServerEngine::instance().config() = loaded;
|
|
sync_form_from_config(s_form, loaded);
|
|
show_notification("Reloaded configuration from ReSkateServer.json.", 3.5, blue);
|
|
} catch (const std::exception& e) {
|
|
show_notification(std::string("Reload failed: ") + e.what(), 4.0, danger);
|
|
}
|
|
}
|
|
ImGui::SameLine(0, S(10));
|
|
if (rough_button("OPEN CONFIG FILE", ImVec2(S(170), S(34)))) {
|
|
auto cfg_path = (ServerEngine::instance().folder() / "ReSkateServer.json").string();
|
|
ShellExecuteA(nullptr, "open", cfg_path.c_str(), nullptr, nullptr, SW_SHOW);
|
|
}
|
|
|
|
ImGui::Dummy(ImVec2(0, S(8)));
|
|
|
|
ImGui::Columns(2, "settings_cols", false);
|
|
ImGui::SetColumnWidth(0, ImGui::GetWindowWidth() * 0.50f);
|
|
|
|
// Column 1: Server Identity
|
|
begin_card("card_cfg_server", "SERVER IDENTITY SETTINGS", "Configure your public server profile");
|
|
ImGui::PushItemWidth(S(360));
|
|
ImGui::InputText("Server Name", s_form.name, sizeof(s_form.name));
|
|
ImGui::InputTextWithHint("Password", "Leave empty for public server", s_form.password, sizeof(s_form.password));
|
|
ImGui::InputTextWithHint("Welcome Message", "Sent to players when joining...", s_form.welcome, sizeof(s_form.welcome));
|
|
ImGui::SliderInt("Max Players", &s_form.max_players, 1, 249);
|
|
ImGui::Checkbox("List in Public Server Browser", &s_form.listed);
|
|
ImGui::Checkbox("Autostart Server with GUI", &s_form.autostart);
|
|
ImGui::Checkbox("Auto-Update New Releases", &s_form.auto_update);
|
|
ImGui::Checkbox("Activity Log in Console", &s_form.activity_log);
|
|
ImGui::PopItemWidth();
|
|
end_card();
|
|
|
|
// Column 1: Anti-Cheat & Rules
|
|
begin_card("card_cfg_anticheat", "ANTI-CHEAT & PHYSICS ENFORCEMENT", "Authoritative verification rules");
|
|
ImGui::PushItemWidth(S(360));
|
|
const char* check_modes[] = { "Disabled (Off)", "Warn Admins", "Kick Player" };
|
|
ImGui::Combo("Speed-Hack Action", &s_form.speed_check_idx, check_modes, IM_ARRAYSIZE(check_modes));
|
|
ImGui::Combo("Scoring Mod Action", &s_form.score_check_idx, check_modes, IM_ARRAYSIZE(check_modes));
|
|
ImGui::Checkbox("Enforce Server Physics Tuning", &s_form.enforce_tuning);
|
|
ImGui::SliderFloat("Bone Scale Limit", &s_form.bone_scale_limit, 1.0f, 8.0f, "%.1fx");
|
|
ImGui::Checkbox("Enforce Global Ban List", &s_form.global_bans);
|
|
ImGui::PopItemWidth();
|
|
end_card();
|
|
|
|
// Column 1: Chat words
|
|
begin_card("card_cfg_words", "CHAT WORD FILTER", "The ReSkate team's lists and this server's own");
|
|
ImGui::Checkbox("Use Global Word Filter", &s_form.global_words);
|
|
if (ImGui::IsItemHovered())
|
|
ImGui::SetTooltip("Off: only this server's own words are filtered and blocked.\nPlayers' games still star the team's filtered words for players with their chat filter on.");
|
|
ImGui::PushStyleColor(ImGuiCol_Text, muted);
|
|
ImGui::TextWrapped("This server's own words are in data/words_filtered.txt (starred for players with the chat filter on) and data/words_blocked.txt (never passed on). One word to a line.");
|
|
ImGui::PopStyleColor();
|
|
if (rough_button("FILTERED WORDS", ImVec2(S(160), S(28)))) {
|
|
const auto path = filtered_words_file(ServerEngine::instance().config()).string();
|
|
ShellExecuteA(nullptr, "open", path.c_str(), nullptr, nullptr, SW_SHOW);
|
|
}
|
|
ImGui::SameLine(0, S(8));
|
|
if (rough_button("BLOCKED WORDS", ImVec2(S(160), S(28)))) {
|
|
const auto path = blocked_words_file(ServerEngine::instance().config()).string();
|
|
ShellExecuteA(nullptr, "open", path.c_str(), nullptr, nullptr, SW_SHOW);
|
|
}
|
|
ImGui::SameLine(0, S(8));
|
|
if (rough_button("RELOAD WORDS", ImVec2(S(150), S(28)))) {
|
|
if (ServerEngine::instance().state() == ServerState::Running) {
|
|
ServerEngine::instance().queue_command("words reload");
|
|
show_notification("Reading this server's word files again; see the console.", 3.5, blue);
|
|
} else {
|
|
show_notification("The word files are read when the server starts.", 3.5, blue);
|
|
}
|
|
}
|
|
end_card();
|
|
|
|
ImGui::NextColumn();
|
|
|
|
// Column 2: Network & Ports
|
|
begin_card("card_cfg_network", "NETWORK & PORT BINDINGS", "Steam SDR UDP ports and token");
|
|
ImGui::PushItemWidth(S(360));
|
|
ImGui::InputInt("Game Port (UDP)", &s_form.port);
|
|
ImGui::InputInt("Query Port (UDP)", &s_form.query_port);
|
|
ImGui::InputText("Steam GSLT Token", s_form.steam_token, sizeof(s_form.steam_token));
|
|
ImGui::Checkbox("Route via Steam SDR Relays", &s_form.use_steam_relay);
|
|
ImGui::SliderInt("Send Rate (KB/s)", &s_form.send_rate, 128, 4096);
|
|
ImGui::SliderInt("Crowd Pose Budget", &s_form.crowd_budget, 300, 2000);
|
|
|
|
ImGui::Spacing();
|
|
ImGui::PushStyleColor(ImGuiCol_Text, muted);
|
|
ImGui::Text("Target Tick Rate: ");
|
|
ImGui::SameLine();
|
|
ImGui::PopStyleColor();
|
|
badge("20 TPS (Dedicated Standard)", good, black);
|
|
ImGui::PushStyleColor(ImGuiCol_Text, muted);
|
|
ImGui::TextWrapped("Fixed at 20 TPS by ReSkate dedicated server protocol specification for network bandwidth and crowd pose stability.");
|
|
ImGui::PopStyleColor();
|
|
|
|
ImGui::PopItemWidth();
|
|
end_card();
|
|
|
|
// Column 2: Gameplay Permissions
|
|
begin_card("card_cfg_gameplay", "PLAYER PERMISSIONS & PROXIMITY VOICE", "Allowed player features");
|
|
ImGui::PushItemWidth(S(360));
|
|
ImGui::Checkbox("Allow Proximity Voice Chat", &s_form.voice_chat);
|
|
if (s_form.voice_chat) {
|
|
ImGui::SliderFloat("Voice Range (Meters)", &s_form.voice_range, 50.0f, 1000.0f, "%.0f m");
|
|
}
|
|
ImGui::Checkbox("Allow Ramp Boosts", &s_form.boosts);
|
|
ImGui::Checkbox("Allow NoBail Mode", &s_form.no_bail);
|
|
ImGui::Checkbox("Allow NoClip Free-Cam", &s_form.noclip);
|
|
ImGui::Checkbox("Allow Player Parties", &s_form.parties);
|
|
if (s_form.parties) {
|
|
ImGui::SliderInt("Max Party Size", &s_form.party_size, 2, 8);
|
|
}
|
|
const char* placement_modes[] = { "Everyone", "Admins Only", "Nobody" };
|
|
ImGui::Combo("Object Placement", &s_form.object_placement_idx, placement_modes, IM_ARRAYSIZE(placement_modes));
|
|
ImGui::SliderInt("Object Limit Per Player", &s_form.object_limit, 0, 500);
|
|
ImGui::PopItemWidth();
|
|
end_card();
|
|
|
|
// Updates & Releases Card
|
|
begin_card("card_cfg_updates", "UPDATES & RELEASES", "Check and install official releases");
|
|
stat_row("Installed Server Build", "ReSkate v2.0.3 (Protocol " + std::to_string(multiplayer::protocol_version) + ")", good);
|
|
const auto update = ServerEngine::instance().update_info();
|
|
stat_row("Release Status", update.message,
|
|
update.status == ServerEngine::UpdateInfo::Status::Available ? warning : (update.status == ServerEngine::UpdateInfo::Status::Error ? danger : good));
|
|
|
|
ImGui::Spacing();
|
|
if (rough_button("CHECK FOR UPDATES", ImVec2(S(180), S(30)))) {
|
|
ServerEngine::instance().check_for_updates_async();
|
|
show_notification("Checking GitHub for latest release...", 3.0, blue);
|
|
}
|
|
if (update.available) {
|
|
ImGui::SameLine(0, S(10));
|
|
if (rough_button("INSTALL UPDATE", ImVec2(S(160), S(30)))) {
|
|
ServerEngine::instance().install_update_async();
|
|
show_notification("Installing update from GitHub...", 4.0, warning);
|
|
}
|
|
}
|
|
end_card();
|
|
|
|
// About & Credits Card
|
|
begin_card("card_cfg_about", "ABOUT DEDICATED SERVER GUI", "System credits and build info");
|
|
stat_row("Dedicated Server GUI", "Made with love by wxndr", good);
|
|
stat_row("UI Framework", "Dear ImGui (DirectX 12 / Win32)", good);
|
|
end_card();
|
|
|
|
ImGui::Columns(1);
|
|
}
|
|
|
|
static void draw_blue_heart(ImDrawList* draw, ImVec2 center, float r, ImU32 color) {
|
|
const float w = r;
|
|
const float h = r * 0.9f;
|
|
const ImVec2 bottom_tip(center.x, center.y + h * 0.75f);
|
|
draw->AddCircleFilled(ImVec2(center.x - w * 0.38f, center.y - h * 0.2f), w * 0.44f, color, 12);
|
|
draw->AddCircleFilled(ImVec2(center.x + w * 0.38f, center.y - h * 0.2f), w * 0.44f, color, 12);
|
|
draw->AddTriangleFilled(
|
|
ImVec2(center.x - w * 0.76f, center.y - h * 0.1f),
|
|
ImVec2(center.x + w * 0.76f, center.y - h * 0.1f),
|
|
bottom_tip,
|
|
color
|
|
);
|
|
}
|
|
|
|
// --- Main Frame Render ---
|
|
void render_frame() {
|
|
auto stats = ServerEngine::instance().status_snapshot();
|
|
const auto players = ServerEngine::instance().player_list();
|
|
|
|
const auto io = ImGui::GetIO();
|
|
const ImVec2 size = io.DisplaySize;
|
|
|
|
ImGui::SetNextWindowPos(ImVec2(0, 0));
|
|
ImGui::SetNextWindowSize(size);
|
|
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
|
|
ImGui::Begin("MainServerWindow", nullptr,
|
|
ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoBringToFrontOnFocus);
|
|
ImGui::PopStyleVar();
|
|
|
|
auto* draw = ImGui::GetWindowDrawList();
|
|
const float sidebar_width = S(210.0f);
|
|
|
|
// Sidebar Background (Ultra-dark slate)
|
|
draw->AddRectFilled(ImVec2(0, 0), ImVec2(sidebar_width, size.y), IM_COL32(10, 10, 12, 255));
|
|
|
|
// Brushed Wordmark "RESKATE" rotated -4.0 deg with shadow
|
|
{
|
|
const int start = draw->VtxBuffer.Size;
|
|
const ImVec2 at(S(20), S(16));
|
|
draw->AddText(g_fonts.title, S(38), ImVec2(at.x + S(2), at.y + S(2)), IM_COL32(0, 0, 0, 160), "RESKATE");
|
|
draw->AddText(g_fonts.title, S(38), at, white, "RESKATE");
|
|
const auto extent = g_fonts.title->CalcTextSizeA(S(38), FLT_MAX, 0, "RESKATE");
|
|
rotate_since(draw, start, -4.0f, ImVec2(at.x + extent.x * 0.5f, at.y + extent.y * 0.5f));
|
|
}
|
|
|
|
draw->AddText(g_fonts.caption, S(11), ImVec2(S(22), S(66)), muted, "SERVER MANAGEMENT");
|
|
|
|
// Sidebar Navigation Tabs
|
|
std::vector<std::pair<const char*, Tab>> tabs = {
|
|
{"CONSOLE", Tab::Console},
|
|
{"OVERVIEW", Tab::Overview},
|
|
{"PLAYERS", Tab::Players},
|
|
{"WORLD", Tab::World},
|
|
{"MODERATION", Tab::Moderation},
|
|
{"TELEMETRY", Tab::Telemetry},
|
|
{"SETTINGS", Tab::Settings}
|
|
};
|
|
|
|
ImGui::SetCursorPos(ImVec2(S(12), S(96)));
|
|
ImGui::BeginChild("navigation", ImVec2(sidebar_width - S(24), size.y - S(170)), ImGuiChildFlags_None);
|
|
|
|
const float tab_h = S(40);
|
|
for (size_t i = 0; i < tabs.size(); ++i) {
|
|
const auto& [name, tab] = tabs[i];
|
|
const bool selected = g_state.current_tab == tab;
|
|
const auto at = ImGui::GetCursorScreenPos();
|
|
ImGui::PushID(static_cast<int>(i));
|
|
|
|
if (ImGui::InvisibleButton("##nav", ImVec2(sidebar_width - S(24), tab_h)) && !selected) {
|
|
g_state.current_tab = tab;
|
|
}
|
|
const bool hovered = ImGui::IsItemHovered();
|
|
ImGui::PopID();
|
|
|
|
const ImVec2 end(at.x + sidebar_width - S(24), at.y + tab_h - S(4));
|
|
if (selected || hovered) {
|
|
rough_rect(draw, at, end, selected ? blue : tile_grey, static_cast<unsigned>(i + 5), g_scale);
|
|
}
|
|
|
|
draw->AddText(g_fonts.bold, S(15), ImVec2(at.x + S(14), at.y + (tab_h - S(4) - S(16)) * 0.5f),
|
|
selected ? black : white, name);
|
|
|
|
// Badge pill in tab if players connected
|
|
if (tab == Tab::Players && stats.player_count > 0) {
|
|
const std::string cnt = std::to_string(stats.player_count);
|
|
const auto pill_sz = g_fonts.caption->CalcTextSizeA(S(11), FLT_MAX, 0, cnt.c_str());
|
|
const ImVec2 ppos(end.x - S(12) - pill_sz.x - S(8), at.y + (tab_h - S(4) - S(18)) * 0.5f);
|
|
draw->AddRectFilled(ppos, ImVec2(ppos.x + pill_sz.x + S(8), ppos.y + S(18)),
|
|
selected ? black : good, S(3));
|
|
draw->AddText(g_fonts.caption, S(11), ImVec2(ppos.x + S(4), ppos.y + S(2)),
|
|
selected ? white : black, cnt.c_str());
|
|
}
|
|
}
|
|
ImGui::EndChild();
|
|
|
|
// Bottom Connected Status Element (Solid Red for Offline, Solid Green for Online)
|
|
{
|
|
const float status_w = sidebar_width - S(24.0f);
|
|
const float status_h = S(26.0f);
|
|
const ImVec2 status_pos(S(12), size.y - S(52));
|
|
|
|
ImU32 bg_col = danger; // Full Red for Offline
|
|
ImU32 text_col = white;
|
|
const char* status_label = "OFFLINE";
|
|
|
|
if (stats.state == ServerState::Running) {
|
|
bg_col = good; // Full Green for Online
|
|
text_col = black;
|
|
status_label = "ONLINE";
|
|
} else if (stats.state == ServerState::Starting) {
|
|
bg_col = warning; // Full Amber for Starting
|
|
text_col = black;
|
|
status_label = "STARTING...";
|
|
} else if (stats.state == ServerState::Stopping) {
|
|
bg_col = warning; // Full Amber for Stopping
|
|
text_col = black;
|
|
status_label = "STOPPING...";
|
|
} else if (stats.state == ServerState::Error) {
|
|
bg_col = danger; // Darker Red for Error
|
|
text_col = white;
|
|
status_label = "SERVER ERROR";
|
|
}
|
|
|
|
// 1. Draw solid rounded status badge
|
|
draw->AddRectFilled(status_pos, ImVec2(status_pos.x + status_w, status_pos.y + status_h), bg_col, S(4.0f));
|
|
|
|
// 2. Centered Status Text (Clean and perfectly centered)
|
|
const auto sz_txt = g_fonts.bold->CalcTextSizeA(S(12.0f), FLT_MAX, 0, status_label);
|
|
const float text_x = status_pos.x + (status_w - sz_txt.x) * 0.5f;
|
|
const float text_y = status_pos.y + (status_h - sz_txt.y) * 0.5f;
|
|
draw->AddText(g_fonts.bold, S(12.0f), ImVec2(text_x, text_y), text_col, status_label);
|
|
}
|
|
|
|
// Sidebar Footer (Blue heart + Interactive author profile link)
|
|
{
|
|
const float heart_x = S(14.0f);
|
|
const float heart_y = size.y - S(14.0f);
|
|
draw_blue_heart(draw, ImVec2(heart_x + S(4.0f), heart_y), S(4.5f), skate_theme::blue);
|
|
|
|
const ImVec2 prefix_pos(heart_x + S(14.0f), size.y - S(20.0f));
|
|
draw->AddText(g_fonts.caption, S(10.5f), prefix_pos, IM_COL32(130, 130, 130, 180), "GUI made with love by ");
|
|
const auto prefix_sz = g_fonts.caption->CalcTextSizeA(S(10.5f), FLT_MAX, 0, "GUI made with love by ");
|
|
const ImVec2 name_pos(prefix_pos.x + prefix_sz.x, prefix_pos.y);
|
|
const auto name_sz = g_fonts.caption->CalcTextSizeA(S(10.5f), FLT_MAX, 0, "wxndr");
|
|
|
|
ImGui::SetCursorScreenPos(name_pos);
|
|
ImGui::PushID("sidebar_author_link");
|
|
if (ImGui::InvisibleButton("##wxndr_link", name_sz)) {
|
|
ShellExecuteW(nullptr, L"open", L"https://steamcommunity.com/id/1wxndr/", nullptr, nullptr, SW_SHOWNORMAL);
|
|
}
|
|
const bool link_hovered = ImGui::IsItemHovered();
|
|
if (link_hovered) {
|
|
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
|
ImGui::SetTooltip("Open wxndr's Steam Profile (https://steamcommunity.com/id/1wxndr/)");
|
|
}
|
|
ImGui::PopID();
|
|
|
|
const ImU32 name_col = link_hovered ? skate_theme::blue : IM_COL32(190, 190, 190, 220);
|
|
draw->AddText(g_fonts.caption, S(10.5f), name_pos, name_col, "wxndr");
|
|
if (link_hovered) {
|
|
draw->AddLine(ImVec2(name_pos.x, name_pos.y + name_sz.y), ImVec2(name_pos.x + name_sz.x, name_pos.y + name_sz.y), name_col, 1.0f);
|
|
}
|
|
}
|
|
|
|
// Main Header: Brushed Title rotated -3.0 deg without subtitle text
|
|
const char* active_tab_name = "CONSOLE";
|
|
switch (g_state.current_tab) {
|
|
case Tab::Console: active_tab_name = "CONSOLE"; break;
|
|
case Tab::Overview: active_tab_name = "OVERVIEW"; break;
|
|
case Tab::Players: active_tab_name = "PLAYERS"; break;
|
|
case Tab::World: active_tab_name = "WORLD"; break;
|
|
case Tab::Moderation: active_tab_name = "MODERATION"; break;
|
|
case Tab::Telemetry: active_tab_name = "TELEMETRY"; break;
|
|
case Tab::Settings: active_tab_name = "SETTINGS"; break;
|
|
default: break;
|
|
}
|
|
|
|
{
|
|
const int start = draw->VtxBuffer.Size;
|
|
const ImVec2 at(sidebar_width + S(24), S(14));
|
|
draw->AddText(g_fonts.title, S(40), ImVec2(at.x + S(2), at.y + S(2)), IM_COL32(0, 0, 0, 160), active_tab_name);
|
|
draw->AddText(g_fonts.title, S(40), at, white, active_tab_name);
|
|
const auto extent = g_fonts.title->CalcTextSizeA(S(40), FLT_MAX, 0, active_tab_name);
|
|
rotate_since(draw, start, -3.0f, ImVec2(at.x + extent.x * 0.5f, at.y + extent.y * 0.5f));
|
|
}
|
|
|
|
// Top Right Window Controls (Minimize, Maximize / Restore, Close)
|
|
const bool is_maximized = IsZoomed(g_window);
|
|
ImGui::SetCursorPos(ImVec2(size.x - S(112), S(12)));
|
|
if (ImGui::Button("_", ImVec2(S(28), S(28)))) {
|
|
ShowWindow(g_window, SW_MINIMIZE);
|
|
}
|
|
ImGui::SameLine();
|
|
if (ImGui::Button(is_maximized ? "=" : "[ ]", ImVec2(S(28), S(28)))) {
|
|
ShowWindow(g_window, is_maximized ? SW_RESTORE : SW_MAXIMIZE);
|
|
}
|
|
ImGui::SameLine();
|
|
if (ImGui::Button("X", ImVec2(S(28), S(28)))) {
|
|
PostMessageW(g_window, WM_CLOSE, 0, 0);
|
|
}
|
|
|
|
// Main Content Area (Lighter grey background with internal safe padding so "Network" is never on the border)
|
|
ImGui::SetCursorPos(ImVec2(sidebar_width + S(24), S(76)));
|
|
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(S(20), S(16)));
|
|
ImGuiWindowFlags content_flags = ImGuiWindowFlags_None;
|
|
if (g_state.current_tab == Tab::Console) {
|
|
content_flags |= ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse;
|
|
}
|
|
ImGui::BeginChild("tab_content", ImVec2(size.x - sidebar_width - S(48), size.y - S(96)), ImGuiChildFlags_AlwaysUseWindowPadding, content_flags);
|
|
ImGui::PopStyleVar();
|
|
|
|
switch (g_state.current_tab) {
|
|
case Tab::Console: draw_console_tab(stats); break;
|
|
case Tab::Overview: draw_overview_tab(stats); break;
|
|
case Tab::Players: draw_players_tab(players); break;
|
|
case Tab::World: draw_world_tab(stats); break;
|
|
case Tab::Moderation: draw_moderation_tab(); break;
|
|
case Tab::Telemetry: draw_telemetry_tab(); break;
|
|
case Tab::Settings: draw_settings_tab(stats); break;
|
|
default: break;
|
|
}
|
|
|
|
ImGui::EndChild();
|
|
|
|
// Top-Right Stacking Floating Toast Notifications (latest at top, stacking down)
|
|
const double now = ImGui::GetTime();
|
|
|
|
// Clean up expired notifications
|
|
for (auto it = g_state.notifications.begin(); it != g_state.notifications.end();) {
|
|
if (now >= it->feedback_until) {
|
|
it = g_state.notifications.erase(it);
|
|
} else {
|
|
++it;
|
|
}
|
|
}
|
|
|
|
if (!g_state.notifications.empty()) {
|
|
auto* fg = ImGui::GetForegroundDrawList();
|
|
float current_y = S(48.0f);
|
|
const float toast_h = S(28.0f);
|
|
const float gap_y = S(6.0f);
|
|
|
|
for (auto& toast : g_state.notifications) {
|
|
const double time_left = toast.feedback_until - now;
|
|
const float alpha = static_cast<float>(std::clamp(time_left / 0.4, 0.0, 1.0));
|
|
|
|
const auto txt_sz = g_fonts.bold->CalcTextSizeA(S(12.0f), FLT_MAX, 0, toast.message.c_str());
|
|
const float toast_w = txt_sz.x + S(28.0f);
|
|
const ImVec2 pos(size.x - toast_w - S(20.0f), current_y);
|
|
|
|
// Click to dismiss early
|
|
if (io.MouseClicked[0]) {
|
|
const auto m = io.MousePos;
|
|
if (m.x >= pos.x && m.x <= pos.x + toast_w && m.y >= pos.y && m.y <= pos.y + toast_h) {
|
|
toast.feedback_until = 0.0;
|
|
}
|
|
}
|
|
|
|
// Toast body (clean sharp rectangle, no border outline, no drop shadow)
|
|
fg->AddRectFilled(pos, ImVec2(pos.x + toast_w, pos.y + toast_h),
|
|
IM_COL32(26, 28, 34, static_cast<int>(245 * alpha)), 0.0f);
|
|
|
|
// Left status accent bar (flush vertical rectangle stripe)
|
|
ImU32 accent = toast.accent != 0 ? toast.accent : blue;
|
|
if (toast.accent == 0 && (toast.message.find("fail") != std::string::npos ||
|
|
toast.message.find("Error") != std::string::npos ||
|
|
toast.message.find("failed") != std::string::npos)) {
|
|
accent = danger;
|
|
}
|
|
const ImVec4 col = ImGui::ColorConvertU32ToFloat4(accent);
|
|
fg->AddRectFilled(pos, ImVec2(pos.x + S(4.0f), pos.y + toast_h),
|
|
ImColor(col.x, col.y, col.z, col.w * alpha), 0.0f);
|
|
|
|
// Notification message text
|
|
fg->AddText(g_fonts.bold, S(12.0f),
|
|
ImVec2(pos.x + S(14.0f), pos.y + (toast_h - txt_sz.y) * 0.5f),
|
|
ImColor(245, 245, 245, static_cast<int>(255 * alpha)),
|
|
toast.message.c_str());
|
|
|
|
current_y += toast_h + gap_y;
|
|
}
|
|
}
|
|
|
|
// Player Moderation Confirmation Modal (Kick / Ban)
|
|
if (g_state.show_player_modal) {
|
|
ImGui::OpenPopup("Confirm Player Action###player_action_modal");
|
|
g_state.show_player_modal = false;
|
|
}
|
|
|
|
if (ImGui::BeginPopupModal("Confirm Player Action###player_action_modal", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
|
|
const char* action_name = g_state.is_kick_modal ? "Kick Player" : "Ban Player";
|
|
ImGui::PushFont(g_fonts.heading);
|
|
ImGui::TextColored(ImVec4(1.0f, 0.35f, 0.35f, 1.0f), "%s", action_name);
|
|
ImGui::PopFont();
|
|
ImGui::Spacing();
|
|
|
|
ImGui::Text("Target: %s (SteamID64: %llu)", g_state.target_player_name.c_str(), g_state.target_player_id);
|
|
ImGui::Spacing();
|
|
|
|
ImGui::PushItemWidth(S(340.0f));
|
|
ImGui::InputTextWithHint("Reason", "Enter reason for moderation...", g_state.target_action_reason, sizeof(g_state.target_action_reason));
|
|
ImGui::PopItemWidth();
|
|
ImGui::Spacing();
|
|
|
|
const float m_btn_w = S(150.0f);
|
|
const float m_btn_h = S(32.0f);
|
|
const auto m_btn_pos = ImGui::GetCursorScreenPos();
|
|
const bool confirm_clicked = ImGui::InvisibleButton("##confirm_mod_btn", ImVec2(m_btn_w, m_btn_h));
|
|
const bool confirm_hovered = ImGui::IsItemHovered();
|
|
if (confirm_hovered) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
|
auto* m_draw = ImGui::GetWindowDrawList();
|
|
|
|
const ImU32 m_btn_bg = g_state.is_kick_modal
|
|
? (confirm_hovered ? IM_COL32(255, 185, 45, 255) : IM_COL32(235, 155, 30, 255))
|
|
: (confirm_hovered ? IM_COL32(250, 70, 70, 255) : IM_COL32(225, 45, 45, 255));
|
|
const ImU32 m_btn_fg = g_state.is_kick_modal ? black : white;
|
|
const char* m_btn_txt = g_state.is_kick_modal ? "CONFIRM KICK" : "CONFIRM BAN";
|
|
|
|
m_draw->AddRectFilled(m_btn_pos, ImVec2(m_btn_pos.x + m_btn_w, m_btn_pos.y + m_btn_h), m_btn_bg, S(4.0f));
|
|
const auto sz_mtxt = g_fonts.bold->CalcTextSizeA(S(13.0f), FLT_MAX, 0.0f, m_btn_txt);
|
|
m_draw->AddText(g_fonts.bold, S(13.0f),
|
|
ImVec2(m_btn_pos.x + (m_btn_w - sz_mtxt.x) * 0.5f, m_btn_pos.y + (m_btn_h - sz_mtxt.y) * 0.5f),
|
|
m_btn_fg, m_btn_txt);
|
|
|
|
if (confirm_clicked) {
|
|
std::string r = g_state.target_action_reason[0] ? g_state.target_action_reason : (g_state.is_kick_modal ? "Kicked from GUI" : "Banned from GUI");
|
|
if (g_state.is_kick_modal) {
|
|
ServerEngine::instance().queue_command("kick " + std::to_string(g_state.target_player_id) + " \"" + r + "\"");
|
|
show_notification("Kicked player: " + g_state.target_player_name, 3.5, warning);
|
|
} else {
|
|
ServerEngine::instance().queue_command("ban " + std::to_string(g_state.target_player_id) + " \"" + r + "\"");
|
|
show_notification("Banned player: " + g_state.target_player_name, 4.0, danger);
|
|
}
|
|
g_state.target_action_reason[0] = '\0';
|
|
ImGui::CloseCurrentPopup();
|
|
}
|
|
ImGui::SameLine(0, S(10));
|
|
if (rough_button("CANCEL", ImVec2(S(100), m_btn_h))) {
|
|
g_state.target_action_reason[0] = '\0';
|
|
ImGui::CloseCurrentPopup();
|
|
}
|
|
ImGui::EndPopup();
|
|
}
|
|
|
|
ImGui::End();
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int run_gui(HINSTANCE instance, int cmd_show) {
|
|
SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
|
|
|
|
RECT work{};
|
|
SystemParametersInfoW(SPI_GETWORKAREA, 0, &work, 0);
|
|
g_scale = std::max(1.0f, static_cast<float>(GetDpiForSystem()) / 96.0f);
|
|
|
|
constexpr float design_w = 1260.0f;
|
|
constexpr float design_h = 860.0f;
|
|
const int max_w = static_cast<int>((work.right - work.left) * 0.95f);
|
|
const int max_h = static_cast<int>((work.bottom - work.top) * 0.95f);
|
|
const int width = std::min(static_cast<int>(S(design_w)), max_w);
|
|
const int height = std::min(static_cast<int>(S(design_h)), max_h);
|
|
|
|
WNDCLASSEXW wc{sizeof(wc)};
|
|
wc.style = CS_HREDRAW | CS_VREDRAW;
|
|
wc.lpfnWndProc = window_procedure;
|
|
wc.hInstance = instance;
|
|
wc.hCursor = LoadCursorW(nullptr, IDC_ARROW);
|
|
wc.hIcon = LoadIconW(instance, MAKEINTRESOURCEW(1));
|
|
wc.lpszClassName = L"ReSkateServerGUI";
|
|
RegisterClassExW(&wc);
|
|
|
|
const HWND window = CreateWindowExW(
|
|
WS_EX_APPWINDOW, wc.lpszClassName, L"ReSkate Dedicated Server",
|
|
WS_POPUP | WS_THICKFRAME | WS_MINIMIZEBOX | WS_MAXIMIZEBOX | WS_SYSMENU,
|
|
work.left + (work.right - work.left - width) / 2,
|
|
work.top + (work.bottom - work.top - height) / 2,
|
|
width, height, nullptr, nullptr, instance, nullptr
|
|
);
|
|
|
|
if (!window) return 1;
|
|
g_window = window;
|
|
|
|
const DWORD corners = 2; // DWMWCP_ROUND
|
|
DwmSetWindowAttribute(window, 33, &corners, sizeof(corners));
|
|
const BOOL dark = TRUE;
|
|
DwmSetWindowAttribute(window, 20, &dark, sizeof(dark));
|
|
const MARGINS margins = {1, 1, 1, 1};
|
|
DwmExtendFrameIntoClientArea(window, &margins);
|
|
SetWindowPos(window, nullptr, 0, 0, 0, 0, SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER);
|
|
|
|
IMGUI_CHECKVERSION();
|
|
ImGui::CreateContext();
|
|
ImGui::GetIO().IniFilename = nullptr;
|
|
ImGui::GetIO().LogFilename = nullptr;
|
|
|
|
// Detect server root folder
|
|
std::wstring mod_path(32768, L'\0');
|
|
const auto len = GetModuleFileNameW(nullptr, mod_path.data(), static_cast<DWORD>(mod_path.size()));
|
|
mod_path.resize(len);
|
|
const fs::path root_folder = fs::path(mod_path).parent_path();
|
|
|
|
load_fonts(root_folder);
|
|
apply_style();
|
|
|
|
ImGui_ImplWin32_Init(window);
|
|
if (!g_renderer.init(window)) {
|
|
ImGui_ImplWin32_Shutdown();
|
|
ImGui::DestroyContext();
|
|
DestroyWindow(window);
|
|
MessageBoxW(nullptr, L"Direct3D 12 could not be initialized for the server GUI.", L"DirectX Error", MB_OK | MB_ICONERROR);
|
|
return 1;
|
|
}
|
|
|
|
ShowWindow(window, (cmd_show == SW_HIDE || cmd_show == 0) ? SW_NORMAL : cmd_show);
|
|
UpdateWindow(window);
|
|
|
|
// Initialize server environment and inspect configuration
|
|
const bool env_ok = ServerEngine::instance().load_environment(root_folder, root_folder / "ReSkateServer.json", true);
|
|
if (env_ok && ServerEngine::instance().config().autostart) {
|
|
ServerEngine::instance().start_server_async();
|
|
}
|
|
|
|
bool running = true;
|
|
while (running) {
|
|
MSG msg;
|
|
while (PeekMessageW(&msg, nullptr, 0, 0, PM_REMOVE)) {
|
|
TranslateMessage(&msg);
|
|
DispatchMessageW(&msg);
|
|
if (msg.message == WM_QUIT) running = false;
|
|
}
|
|
if (!running) break;
|
|
|
|
if (IsIconic(window)) {
|
|
Sleep(50);
|
|
continue;
|
|
}
|
|
|
|
ImGui_ImplDX12_NewFrame();
|
|
ImGui_ImplWin32_NewFrame();
|
|
ImGui::NewFrame();
|
|
|
|
render_frame();
|
|
|
|
ImGui::Render();
|
|
g_renderer.render();
|
|
}
|
|
|
|
ServerEngine::instance().stop();
|
|
g_renderer.shutdown();
|
|
ImGui_ImplWin32_Shutdown();
|
|
ImGui::DestroyContext();
|
|
DestroyWindow(window);
|
|
return 0;
|
|
}
|
|
|
|
} // namespace dingosdk::server_gui
|