From f40d09cb8f712e87691af4912f3630d92d692779 Mon Sep 17 00:00:00 2001 From: untodesu Date: Thu, 11 Dec 2025 15:14:26 +0500 Subject: Shuffle stuff around - Use the new and improved hierarchy I figured out when making Prospero chat - Re-add NSIS scripts, again from Prospero - Update most build and utility scripts with their most recent versions --- src/game/client/gui/play_menu.cc | 594 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 594 insertions(+) create mode 100644 src/game/client/gui/play_menu.cc (limited to 'src/game/client/gui/play_menu.cc') diff --git a/src/game/client/gui/play_menu.cc b/src/game/client/gui/play_menu.cc new file mode 100644 index 0000000..5b1ecde --- /dev/null +++ b/src/game/client/gui/play_menu.cc @@ -0,0 +1,594 @@ +#include "client/pch.hh" + +#include "client/gui/play_menu.hh" + +#include "core/config/boolean.hh" + +#include "core/io/config_map.hh" + +#include "core/math/constexpr.hh" + +#include "core/utils/string.hh" + +#include "core/version.hh" + +#include "shared/protocol.hh" + +#include "client/gui/bother.hh" +#include "client/gui/gui_screen.hh" +#include "client/gui/language.hh" + +#include "client/io/glfw.hh" + +#include "client/game.hh" +#include "client/globals.hh" +#include "client/session.hh" + +constexpr static ImGuiWindowFlags WINDOW_FLAGS = ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoDecoration; +constexpr static std::string_view DEFAULT_SERVER_NAME = "Voxelius Server"; +constexpr static std::string_view SERVERS_TXT = "servers.txt"; + +constexpr static std::size_t MAX_SERVER_ITEM_NAME = 18; + +enum class item_status : unsigned int { + UNKNOWN = 0x0000U, + PINGING = 0x0001U, + REACHED = 0x0002U, + FAILURE = 0x0003U, +}; + +struct ServerStatusItem final { + std::string name; + std::string password; + std::string hostname; + std::uint16_t port; + + // Things pulled from bother events + std::uint16_t num_players; + std::uint16_t max_players; + std::string motd; + std::uint16_t game_version_major; + std::uint16_t game_version_minor; + std::uint16_t game_version_patch; + + // Unique identifier that monotonically + // grows with each new server added and + // doesn't reset with each server removed + unsigned int identity; + + item_status status; +}; + +static std::string str_tab_servers; + +static std::string str_join; +static std::string str_connect; +static std::string str_add; +static std::string str_edit; +static std::string str_remove; +static std::string str_refresh; + +static std::string str_status_init; +static std::string str_status_ping; +static std::string str_status_fail; + +static std::string input_itemname; +static std::string input_hostname; +static std::string input_password; + +static unsigned int next_identity; +static std::deque servers_deque; +static ServerStatusItem* selected_server; +static bool editing_server; +static bool adding_server; +static bool needs_focus; + +static void parse_hostname(ServerStatusItem* item, const std::string& hostname) +{ + auto parts = utils::split(hostname, ":"); + + if(!parts[0].empty()) { + item->hostname = parts[0]; + } + else { + item->hostname = std::string("localhost"); + } + + if(parts.size() >= 2) { + item->port = glm::clamp(static_cast(strtoul(parts[1].c_str(), nullptr, 10)), 1024, UINT16_MAX); + } + else { + item->port = protocol::PORT; + } +} + +static void add_new_server(void) +{ + auto item = new ServerStatusItem(); + item->port = protocol::PORT; + item->max_players = UINT16_MAX; + item->num_players = UINT16_MAX; + item->identity = next_identity; + item->status = item_status::UNKNOWN; + item->game_version_major = 0U; + item->game_version_minor = 0U; + item->game_version_patch = 0U; + + next_identity += 1U; + + input_itemname = DEFAULT_SERVER_NAME; + input_hostname = std::string(); + input_password = std::string(); + + servers_deque.push_back(item); + selected_server = item; + editing_server = true; + adding_server = true; + needs_focus = true; +} + +static void edit_selected_server(void) +{ + input_itemname = selected_server->name; + + if(selected_server->port != protocol::PORT) { + input_hostname = std::format("{}:{}", selected_server->hostname, selected_server->port); + } + else { + input_hostname = selected_server->hostname; + } + + input_password = selected_server->password; + + editing_server = true; + needs_focus = true; +} + +static void remove_selected_server(void) +{ + gui::bother::cancel(selected_server->identity); + + for(auto it = servers_deque.cbegin(); it != servers_deque.cend(); ++it) { + if(selected_server == (*it)) { + delete selected_server; + selected_server = nullptr; + servers_deque.erase(it); + return; + } + } +} + +static void join_selected_server(void) +{ + if(!session::peer) { + session::connect(selected_server->hostname.c_str(), selected_server->port, selected_server->password.c_str()); + } +} + +static void on_glfw_key(const io::GlfwKeyEvent& event) +{ + if((event.key == GLFW_KEY_ESCAPE) && (event.action == GLFW_PRESS)) { + if(globals::gui_screen == GUI_PLAY_MENU) { + if(editing_server) { + if(adding_server) { + remove_selected_server(); + } + else { + input_itemname.clear(); + input_hostname.clear(); + input_password.clear(); + editing_server = false; + adding_server = false; + return; + } + } + + globals::gui_screen = GUI_MAIN_MENU; + selected_server = nullptr; + return; + } + } +} + +static void on_language_set(const gui::LanguageSetEvent& event) +{ + str_tab_servers = gui::language::resolve_gui("play_menu.tab.servers"); + + str_join = gui::language::resolve_gui("play_menu.join"); + str_connect = gui::language::resolve_gui("play_menu.connect"); + str_add = gui::language::resolve_gui("play_menu.add"); + str_edit = gui::language::resolve_gui("play_menu.edit"); + str_remove = gui::language::resolve_gui("play_menu.remove"); + str_refresh = gui::language::resolve_gui("play_menu.refresh"); + + str_status_init = gui::language::resolve("play_menu.status.init"); + str_status_ping = gui::language::resolve("play_menu.status.ping"); + str_status_fail = gui::language::resolve("play_menu.status.fail"); +} + +static void on_bother_response(const gui::BotherResponseEvent& event) +{ + for(auto item : servers_deque) { + if(item->identity == event.identity) { + if(event.is_server_unreachable) { + item->num_players = UINT16_MAX; + item->max_players = UINT16_MAX; + item->motd = str_status_fail; + item->status = item_status::FAILURE; + item->game_version_major = 0U; + item->game_version_minor = 0U; + item->game_version_patch = 0U; + } + else { + item->num_players = event.num_players; + item->max_players = event.max_players; + item->motd = event.motd; + item->status = item_status::REACHED; + item->game_version_major = event.game_version_major; + item->game_version_minor = event.game_version_minor; + item->game_version_patch = event.game_version_patch; + } + + break; + } + } +} + +static void layout_server_item(ServerStatusItem* item) +{ + // Preserve the cursor at which we draw stuff + const ImVec2& cursor = ImGui::GetCursorScreenPos(); + const ImVec2& padding = ImGui::GetStyle().FramePadding; + const ImVec2& spacing = ImGui::GetStyle().ItemSpacing; + + const float item_width = ImGui::GetContentRegionAvail().x; + const float line_height = ImGui::GetTextLineHeightWithSpacing(); + const std::string sid = std::format("###play_menu.servers.{}", static_cast(item)); + if(ImGui::Selectable(sid.c_str(), (item == selected_server), 0, ImVec2(0.0, 2.0f * (line_height + padding.y + spacing.y)))) { + selected_server = item; + editing_server = false; + } + + if(ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { + // Double clicked - join the selected server + join_selected_server(); + } + + ImDrawList* draw_list = ImGui::GetWindowDrawList(); + + if(item == selected_server) { + const ImVec2 start = ImVec2(cursor.x, cursor.y); + const ImVec2 end = ImVec2(start.x + item_width, start.y + 2.0f * (line_height + padding.y + spacing.y)); + draw_list->AddRect(start, end, ImGui::GetColorU32(ImGuiCol_Text), 0.0f, 0, static_cast(globals::gui_scale)); + } + + const ImVec2 name_pos = ImVec2(cursor.x + padding.x + 0.5f * spacing.x, cursor.y + padding.y); + draw_list->AddText(name_pos, ImGui::GetColorU32(ImGuiCol_Text), item->name.c_str(), item->name.c_str() + item->name.size()); + + if(item->status == item_status::REACHED) { + auto stats = std::format("{}/{}", item->num_players, item->max_players); + auto stats_size = ImGui::CalcTextSize(stats.c_str(), stats.c_str() + stats.size()); + auto stats_pos = ImVec2(cursor.x + item_width - stats_size.x - padding.x, cursor.y + padding.y); + draw_list->AddText(stats_pos, ImGui::GetColorU32(ImGuiCol_TextDisabled), stats.c_str(), stats.c_str() + stats.size()); + + auto major_version_mismatch = item->game_version_major != version::major; + auto minor_version_mismatch = item->game_version_minor != version::minor; + auto patch_version_mismatch = item->game_version_patch != version::patch; + + ImU32 version_color; + + if(major_version_mismatch || minor_version_mismatch || patch_version_mismatch) { + version_color = ImGui::GetColorU32(major_version_mismatch ? ImGuiCol_PlotLinesHovered : ImGuiCol_DragDropTarget); + } + else { + version_color = ImGui::GetColorU32(ImGuiCol_PlotHistogram); + } + + ImGui::PushFont(globals::font_unscii8, 4.0f); + + std::string version_toast; + + if(item->game_version_major < 16U) { + // Pre v16.x.x servers didn't send minor and patch versions + // and also used a different versioning scheme; post v16 the + // major version became the protocol version and the semver lost the tweak part + version_toast = std::string("15.x.x"); + } + else { + version_toast = std::format("{}.{}.{}", item->game_version_major, item->game_version_minor, item->game_version_patch); + } + + auto version_size = ImGui::CalcTextSize(version_toast.c_str(), version_toast.c_str() + version_toast.size()); + auto version_pos = ImVec2(stats_pos.x - version_size.x - padding.x - 4.0f * globals::gui_scale, + cursor.y + padding.y + 0.5f * (stats_size.y - version_size.y)); + auto version_end = ImVec2(version_pos.x + version_size.x, version_pos.y + version_size.y); + + auto outline_pos = ImVec2(version_pos.x - 2U * globals::gui_scale, version_pos.y - 2U * globals::gui_scale); + auto outline_end = ImVec2(version_end.x + 2U * globals::gui_scale, version_end.y + 2U * globals::gui_scale); + auto outline_thickness = glm::max(1.0f, 0.5f * static_cast(globals::gui_scale)); + + draw_list->AddRect(outline_pos, outline_end, version_color, 0.0f, 0, outline_thickness); + draw_list->AddText(version_pos, version_color, version_toast.c_str(), version_toast.c_str() + version_toast.size()); + + ImGui::PopFont(); + } + + ImU32 motd_color = {}; + const std::string* motd_text; + + switch(item->status) { + case item_status::UNKNOWN: + motd_color = ImGui::GetColorU32(ImGuiCol_TextDisabled); + motd_text = &str_status_init; + break; + case item_status::PINGING: + motd_color = ImGui::GetColorU32(ImGuiCol_TextDisabled); + motd_text = &str_status_ping; + break; + case item_status::REACHED: + motd_color = ImGui::GetColorU32(ImGuiCol_TextDisabled); + motd_text = &item->motd; + break; + default: + motd_color = ImGui::GetColorU32(ImGuiCol_PlotLinesHovered); + motd_text = &str_status_fail; + break; + } + + const ImVec2 motd_pos = ImVec2(cursor.x + padding.x + 0.5f * spacing.x, cursor.y + padding.y + line_height); + draw_list->AddText(motd_pos, motd_color, motd_text->c_str(), motd_text->c_str() + motd_text->size()); +} + +static void layout_server_edit(ServerStatusItem* item) +{ + if(needs_focus) { + ImGui::SetKeyboardFocusHere(); + needs_focus = false; + } + + ImGui::SetNextItemWidth(-0.25f * ImGui::GetContentRegionAvail().x); + ImGui::InputText("###play_menu.servers.edit_itemname", &input_itemname); + ImGui::SameLine(); + + const bool ignore_input = utils::is_whitespace(input_itemname) || input_hostname.empty(); + + ImGui::BeginDisabled(ignore_input); + + if(ImGui::Button("OK###play_menu.servers.submit_input", ImVec2(-1.0f, 0.0f)) + || (!ignore_input && ImGui::IsKeyPressed(ImGuiKey_Enter))) { + parse_hostname(item, input_hostname); + item->password = input_password; + item->name = input_itemname.substr(0, MAX_SERVER_ITEM_NAME); + item->status = item_status::UNKNOWN; + editing_server = false; + adding_server = false; + + input_itemname.clear(); + input_hostname.clear(); + + gui::bother::cancel(item->identity); + } + + ImGui::EndDisabled(); + + ImGuiInputTextFlags hostname_flags = ImGuiInputTextFlags_CharsNoBlank; + + if(client_game::streamer_mode.get_value()) { + // Hide server hostname to avoid things like + // followers flooding the server that is streamed online + hostname_flags |= ImGuiInputTextFlags_Password; + } + + ImGui::SetNextItemWidth(-0.50f * ImGui::GetContentRegionAvail().x); + ImGui::InputText("###play_menu.servers.edit_hostname", &input_hostname, hostname_flags); + ImGui::SameLine(); + + ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x); + ImGui::InputText("###play_menu.servers.edit_password", &input_password, ImGuiInputTextFlags_Password); +} + +static void layout_servers(void) +{ + if(ImGui::BeginListBox("###play_menu.servers.listbox", ImVec2(-1.0f, -1.0f))) { + for(ServerStatusItem* item : servers_deque) { + if(editing_server && item == selected_server) { + layout_server_edit(item); + } + else { + layout_server_item(item); + } + } + + ImGui::EndListBox(); + } +} + +static void layout_servers_buttons(void) +{ + auto avail_width = ImGui::GetContentRegionAvail().x; + + // Can only join when selected and not editing + ImGui::BeginDisabled(!selected_server || editing_server); + + if(ImGui::Button(str_join.c_str(), ImVec2(-0.50f * avail_width, 0.0f))) { + join_selected_server(); + } + + ImGui::EndDisabled(); + ImGui::SameLine(); + + // Can only connect directly when not editing anything + ImGui::BeginDisabled(editing_server); + + if(ImGui::Button(str_connect.c_str(), ImVec2(-1.00f, 0.0f))) { + globals::gui_screen = GUI_DIRECT_CONNECTION; + } + + ImGui::EndDisabled(); + + // Can only add when not editing anything + ImGui::BeginDisabled(editing_server); + + if(ImGui::Button(str_add.c_str(), ImVec2(-0.75f * avail_width, 0.0f))) { + add_new_server(); + } + + ImGui::EndDisabled(); + ImGui::SameLine(); + + // Can only edit when selected and not editing + ImGui::BeginDisabled(!selected_server || editing_server); + + if(ImGui::Button(str_edit.c_str(), ImVec2(-0.50f * avail_width, 0.0f))) { + edit_selected_server(); + } + + ImGui::EndDisabled(); + ImGui::SameLine(); + + // Can only remove when selected and not editing + ImGui::BeginDisabled(!selected_server || editing_server); + + if(ImGui::Button(str_remove.c_str(), ImVec2(-0.25f * avail_width, 0.0f))) { + remove_selected_server(); + } + + ImGui::EndDisabled(); + ImGui::SameLine(); + + if(ImGui::Button(str_refresh.c_str(), ImVec2(-1.0f, 0.0f))) { + for(ServerStatusItem* item : servers_deque) { + if(item->status != item_status::PINGING) { + if(!editing_server || item != selected_server) { + item->status = item_status::UNKNOWN; + gui::bother::cancel(item->identity); + } + } + } + } +} + +void gui::play_menu::init(void) +{ + if(auto file = PHYSFS_openRead(std::string(SERVERS_TXT).c_str())) { + auto source = std::string(PHYSFS_fileLength(file), char(0x00)); + PHYSFS_readBytes(file, source.data(), source.size()); + PHYSFS_close(file); + + auto stream = std::istringstream(source); + auto line = std::string(); + + while(std::getline(stream, line)) { + auto parts = utils::split(line, "%"); + + auto item = new ServerStatusItem(); + item->port = protocol::PORT; + item->max_players = UINT16_MAX; + item->num_players = UINT16_MAX; + item->identity = next_identity; + item->status = item_status::UNKNOWN; + item->game_version_major = version::major; + item->game_version_minor = version::minor; + item->game_version_patch = version::patch; + + next_identity += 1U; + + parse_hostname(item, parts[0]); + + if(parts.size() >= 2) { + item->password = parts[1]; + } + else { + item->password = std::string(); + } + + if(parts.size() >= 3) { + item->name = parts[2].substr(0, MAX_SERVER_ITEM_NAME); + } + else { + item->name = DEFAULT_SERVER_NAME; + } + + servers_deque.push_back(item); + } + } + + globals::dispatcher.sink().connect<&on_glfw_key>(); + globals::dispatcher.sink().connect<&on_language_set>(); + globals::dispatcher.sink().connect<&on_bother_response>(); +} + +void gui::play_menu::shutdown(void) +{ + std::ostringstream stream; + + for(const auto item : servers_deque) { + stream << std::format("{}:{}%{}%{}", item->hostname, item->port, item->password, item->name) << std::endl; + } + + if(auto file = PHYSFS_openWrite(std::string(SERVERS_TXT).c_str())) { + auto source = stream.str(); + PHYSFS_writeBytes(file, source.data(), source.size()); + PHYSFS_close(file); + } + + for(auto item : servers_deque) + delete item; + servers_deque.clear(); +} + +void gui::play_menu::layout(void) +{ + const auto viewport = ImGui::GetMainViewport(); + const auto window_start = ImVec2(viewport->Size.x * 0.05f, viewport->Size.y * 0.05f); + const auto window_size = ImVec2(viewport->Size.x * 0.90f, viewport->Size.y * 0.90f); + + ImGui::SetNextWindowPos(window_start); + ImGui::SetNextWindowSize(window_size); + + if(ImGui::Begin("###play_menu", nullptr, WINDOW_FLAGS)) { + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(3.0f * globals::gui_scale, 3.0f * globals::gui_scale)); + + if(ImGui::BeginTabBar("###play_menu.tabs", ImGuiTabBarFlags_FittingPolicyResizeDown)) { + if(ImGui::TabItemButton("<<")) { + globals::gui_screen = GUI_MAIN_MENU; + selected_server = nullptr; + editing_server = false; + } + + if(ImGui::BeginTabItem(str_tab_servers.c_str())) { + if(ImGui::BeginChild("###play_menu.servers.child", ImVec2(0.0f, -2.0f * ImGui::GetFrameHeightWithSpacing()))) { + layout_servers(); + } + + ImGui::EndChild(); + + layout_servers_buttons(); + + ImGui::EndTabItem(); + } + + if(ImGui::BeginTabItem("debug###play_menu.debug.child")) { + ImGui::ShowStyleEditor(); + ImGui::EndTabItem(); + } + + ImGui::EndTabBar(); + } + + ImGui::PopStyleVar(); + } + + ImGui::End(); +} + +void gui::play_menu::update_late(void) +{ + for(auto item : servers_deque) { + if(item->status == item_status::UNKNOWN) { + gui::bother::ping(item->identity, item->hostname.c_str(), item->port); + item->status = item_status::PINGING; + continue; + } + } +} -- cgit