#include "skate3_native_ghost_dialog.h" #include #include #include #include #include #include #include "native/skate3_native_ghost.h" #include "skate3_native_scene.h" REXCVAR_DECLARE(bool, skate3_ghost_enable); REXCVAR_DECLARE(std::string, skate3_ghost_server); REXCVAR_DECLARE(int32_t, skate3_ghost_port); REXCVAR_DECLARE(std::string, skate3_ghost_room); REXCVAR_DECLARE(std::string, skate3_ghost_name); REXCVAR_DECLARE(double, skate3_ghost_rate); REXCVAR_DECLARE(bool, skate3_ghost_draw_local); namespace skate3 { void GhostDialog::Show() { visible_ = true; SetDrawActive(true); } void GhostDialog::Hide() { if (!visible_) { return; } visible_ = false; SetDrawActive(false); } void GhostDialog::Toggle() { if (visible_) { Hide(); } else { Show(); } } void GhostDialog::SyncBuffersFromCvars() { const std::string room = REXCVAR_GET(skate3_ghost_room); if (!room.empty()) { std::snprintf(room_input_, sizeof(room_input_), "%s", room.c_str()); } else { // Auto-assigned room: the active code comes from the connection, not the // (empty) cvar - surface it in the input so the user can see/copy it. skate3::native_ghost::Status st; if (skate3::native_ghost::GetStatus(&st) && st.room_len > 0) { std::snprintf(room_input_, sizeof(room_input_), "%.*s", int(st.room_len), st.room); } else { room_input_[0] = '\0'; } } const std::string server = REXCVAR_GET(skate3_ghost_server); std::snprintf(server_input_, sizeof(server_input_), "%s", server.c_str()); const std::string name = REXCVAR_GET(skate3_ghost_name); std::snprintf(name_input_, sizeof(name_input_), "%s", name.c_str()); synced_ = true; } void GhostDialog::OnDraw(ImGuiIO& io) { (void)io; if (!visible_) { return; } if (!synced_) { SyncBuffersFromCvars(); } bool open = visible_; ImGui::SetNextWindowSize(ImVec2(380.0f, 0.0f), ImGuiCond_FirstUseEver); if (!ImGui::Begin("Ghost Multiplayer (M)", &open, ImGuiWindowFlags_NoCollapse)) { ImGui::End(); if (!open) { Hide(); } return; } bool enable = REXCVAR_GET(skate3_ghost_enable); if (ImGui::Checkbox("Enable ghost multiplayer", &enable)) { REXCVAR_SET(skate3_ghost_enable, enable); if (enable) { REXLOG_INFO("ghost: multiplayer enabled"); } } ImGui::SameLine(); ImGui::TextDisabled("(relays your position, draws remote players)"); ImGui::Separator(); ImGui::Text("Room code"); if (ImGui::InputText("##ghost_room", room_input_, sizeof(room_input_), ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_CharsUppercase)) { REXCVAR_SET(skate3_ghost_room, std::string(room_input_)); ImGui::SetKeyboardFocusHere(-1); } ImGui::SameLine(); // Copy the ACTIVE room (the one this player is actually in), which for an // auto-assigned room lives in the connection state, not the cvar. if (ImGui::Button("Copy")) { skate3::native_ghost::Status st; if (skate3::native_ghost::GetStatus(&st) && st.room_len > 0) { char buf[skate3::native_ghost::kGhostMaxRoomLen + 1] = {}; std::memcpy(buf, st.room, st.room_len); ImGui::SetClipboardText(buf); copy_feedback_ = true; } } if (copy_feedback_) { ImGui::SameLine(); ImGui::TextDisabled("copied"); copy_feedback_ = false; } ImGui::TextDisabled("Empty = the server assigns one; share it with friends."); ImGui::Separator(); ImGui::Text("Display name"); if (ImGui::InputText("##ghost_name", name_input_, sizeof(name_input_), ImGuiInputTextFlags_EnterReturnsTrue)) { REXCVAR_SET(skate3_ghost_name, std::string(name_input_)); ImGui::SetKeyboardFocusHere(-1); } ImGui::TextDisabled("Shown on your nameplate above the ghost. Empty = " "your OS user name."); ImGui::Separator(); if (ImGui::CollapsingHeader("Browse rooms (lobby)", ImGuiTreeNodeFlags_DefaultOpen)) { if (ImGui::Button("Refresh rooms")) { skate3::native_ghost::RequestRooms(); } ImGui::SameLine(); skate3::native_ghost::RoomList rl; const bool have = skate3::native_ghost::GetRooms(&rl); ImGui::TextDisabled(have ? "%u room(s) on the relay" : "no room list yet"); if (have) { for (uint8_t i = 0; i < rl.count; ++i) { const skate3::native_ghost::RoomEntry& e = rl.rooms[i]; std::string label(e.room, e.room_len); label += " (" + std::to_string(int(e.member_count)) + " player" + (e.member_count == 1 ? "" : "s") + ")"; std::string names; for (uint8_t n = 0; n < e.shown_members; ++n) { if (n != 0) { names += ", "; } names += std::string(e.member_names[n], e.member_name_len[n]); } if (!names.empty()) { label += " [" + names + "]"; } const std::string code(e.room, e.room_len); if (ImGui::Selectable(label.c_str(), false)) { REXCVAR_SET(skate3_ghost_room, code); std::snprintf(room_input_, sizeof(room_input_), "%s", code.c_str()); } } ImGui::TextDisabled("Click a room to join it (applies on the next " "heartbeat)."); } } ImGui::Separator(); ImGui::Text("Relay server (optional)"); ImGui::PushItemWidth(-1.0f); if (ImGui::InputText("##ghost_server", server_input_, sizeof(server_input_), ImGuiInputTextFlags_EnterReturnsTrue)) { REXCVAR_SET(skate3_ghost_server, std::string(server_input_)); ImGui::SetKeyboardFocusHere(-1); } ImGui::PopItemWidth(); ImGui::TextDisabled("Empty = built-in default relay. No port forwarding " "needed on any client."); ImGui::Separator(); int port = int(REXCVAR_GET(skate3_ghost_port)); if (ImGui::SliderInt("Port", &port, 1024, 65535)) { REXCVAR_SET(skate3_ghost_port, int32_t(port)); } const double rate = REXCVAR_GET(skate3_ghost_rate); rate_int_ = int(rate + 0.5); if (ImGui::SliderInt("Broadcast rate (Hz)", &rate_int_, 5, 60)) { REXCVAR_SET(skate3_ghost_rate, double(rate_int_)); } bool draw_local = REXCVAR_GET(skate3_ghost_draw_local); if (ImGui::Checkbox("Show my own cube (self-check)", &draw_local)) { REXCVAR_SET(skate3_ghost_draw_local, draw_local); } ImGui::Separator(); skate3::native_ghost::Status st; skate3::native_ghost::GetStatus(&st); ImGui::Text("Status:"); ImGui::Text(" Enabled: %s", st.enabled ? "yes" : "no"); ImGui::Text(" Connected: %s%s", st.connected ? "yes" : "no", st.hosting ? " (LAN host)" : ""); ImGui::Text(" Session: %u", st.session_id); ImGui::Text(" Room: %.*s", int(st.room_len), st.room); ImGui::Text(" In room: %zu player(s)", st.roster + 1); ImGui::Text(" Ghosts drawn: %zu", st.ghosts); if (st.peer[0] != '\0') { ImGui::Text(" Relay: %s", st.peer); } if (st.refused) { ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.3f, 1.0f), " Refused by relay (protocol version mismatch)"); } if (st.last_error[0] != '\0') { ImGui::TextWrapped(" %s", st.last_error); } ImGui::End(); if (!open) { Hide(); } } void GhostNameplateOverlay::OnDraw(ImGuiIO& io) { if (!REXCVAR_GET(skate3_ghost_enable)) { return; } if (io.DisplaySize.x < 16.0f || io.DisplaySize.y < 16.0f) { return; } float vp[16]; float cam[3]; if (!skate3::native_scene::GetPublishedCamera(vp, cam)) { return; } const uint32_t count = skate3::native_ghost::GhostCount(); if (count == 0) { return; } ImDrawList* dl = ImGui::GetBackgroundDrawList(); const float w = io.DisplaySize.x; const float h = io.DisplaySize.y; for (uint32_t i = 0; i < count; ++i) { float pos[3]; float color[3]; char name[32]; if (!skate3::native_ghost::GetGhostLabel(i, pos, color, name, sizeof(name))) { continue; } // Project the point ~1.75 m above the entity origin (roughly the head). const float p[3] = {pos[0], pos[1] + 1.75f, pos[2]}; float c[4]; for (int r = 0; r < 4; ++r) { c[r] = p[0] * vp[r] + p[1] * vp[4 + r] + p[2] * vp[8 + r] + vp[12 + r]; } if (c[3] < 0.01f) { continue; // behind the camera } const float ndc_x = c[0] / c[3]; const float ndc_y = c[1] / c[3]; if (ndc_x < -1.6f || ndc_x > 1.6f || ndc_y < -1.6f || ndc_y > 1.6f) { continue; } const float sx = (ndc_x * 0.5f + 0.5f) * w; const float sy = (1.0f - (ndc_y * 0.5f + 0.5f)) * h; const ImU32 text_col = IM_COL32(int(color[0] * 255.0f), int(color[1] * 255.0f), int(color[2] * 255.0f), 235); const ImVec2 text_size = ImGui::CalcTextSize(name); // Small dark backdrop so the label stays readable over the world. dl->AddRectFilled(ImVec2(sx - 4.0f, sy - 14.0f), ImVec2(sx + text_size.x + 4.0f, sy + 2.0f), IM_COL32(0, 0, 0, 140)); dl->AddText(ImVec2(sx, sy - 14.0f), text_col, name); } } } // namespace skate3