ghost multiplayer: lobby browser (LIST/ROOMS)
The M menu now has a room browser: a Refresh button asks the relay for its room list, and the list (room code, player count, up to 4 member names) is shown as clickable rows - clicking one sets the room code and joins on the next heartbeat. - Protocol: kTypeList (client -> relay, empty payload) and kTypeRooms (relay -> client, RoomsPayload: up to 16 rooms, code + count + names). LIST works from unjoined addresses so the browser can query before joining; a joined client's LIST is answered too. Version bumped to 4. - Relay: BuildRoomList aggregates the client table by room (refused clients excluded), most-populated first, capped at kGhostMaxRooms. - Client: RequestRooms()/GetRooms() - the network thread sends LIST to the best current peer (public relay, LAN peer, or the local host when hosting) and stores the reply under the shared lock; ROOMS replies from any other address are ignored. The list is cleared on disable. - Dialog: CollapsingHeader 'Browse rooms (lobby)' with refresh + selectable rows. New relay test (ghost_relay_test6) covers the lobby path; all suites pass.
This commit is contained in:
parent
7a48eb9f35
commit
d7c9cbcb70
6 changed files with 199 additions and 2 deletions
|
|
@ -223,6 +223,11 @@ char g_last_error[160] = {};
|
|||
// latest HELLO roster; refreshed by the periodic re-JOIN). g_mu-guarded.
|
||||
std::unordered_map<uint32_t, std::array<char, kGhostMaxNameLen>> g_roster_names;
|
||||
|
||||
// Lobby browser state: a pending request flag (network thread only) and the
|
||||
// latest room list (g_mu-guarded; read by the menu).
|
||||
std::atomic<bool> g_request_rooms{false};
|
||||
RoomList g_rooms;
|
||||
|
||||
// Network thread.
|
||||
std::thread g_thread;
|
||||
std::atomic<bool> g_stop{false};
|
||||
|
|
@ -394,6 +399,31 @@ void SendPose(GhostSock sock, const GhostAddr& to, uint32_t& seq) {
|
|||
|
||||
// ---- packet receive --------------------------------------------------------
|
||||
void HandlePacket(const GhostAddr& from, const PacketBuf& p, double now) {
|
||||
if (p.h.type == kTypeRooms) {
|
||||
if (p.h.payload_size < sizeof(RoomsPayload)) {
|
||||
return;
|
||||
}
|
||||
RoomsPayload rp = {};
|
||||
std::memcpy(&rp, p.payload, sizeof(rp));
|
||||
// Only trust the room list from the relay we are talking to (a spoofed
|
||||
// list from anywhere else is ignored).
|
||||
const bool from_public =
|
||||
g_peer_public.configured && GhostAddrEqual(from, g_peer_public.addr);
|
||||
const bool from_lan =
|
||||
g_peer_lan.configured && GhostAddrEqual(from, g_peer_lan.addr);
|
||||
if (!from_public && !from_lan) {
|
||||
return;
|
||||
}
|
||||
const uint8_t n = std::min<uint8_t>(rp.room_count, kGhostMaxRooms);
|
||||
std::lock_guard<std::mutex> lock(g_mu);
|
||||
g_rooms = RoomList{};
|
||||
g_rooms.version = rp.version;
|
||||
g_rooms.count = n;
|
||||
std::memcpy(g_rooms.rooms, rp.rooms, size_t(n) * sizeof(RoomEntry));
|
||||
g_rooms.received = now;
|
||||
return;
|
||||
}
|
||||
|
||||
if (p.h.type == kTypePoseChunk) {
|
||||
if (p.h.payload_size < sizeof(PoseChunkPayload)) {
|
||||
return;
|
||||
|
|
@ -619,6 +649,7 @@ void ResetSession() {
|
|||
g_ghost_index.clear();
|
||||
g_pending_poses.clear();
|
||||
g_roster_names.clear();
|
||||
g_rooms = RoomList{};
|
||||
g_session_public = 0;
|
||||
g_session_lan = 0;
|
||||
g_peer_public = Peer{};
|
||||
|
|
@ -804,6 +835,24 @@ void GhostNetworkLoop() {
|
|||
}
|
||||
}
|
||||
|
||||
// Lobby browser request: fetch the room list from the best current peer
|
||||
// (public relay, LAN peer, or our own host when we are the relay).
|
||||
if (g_request_rooms.exchange(false)) {
|
||||
GhostAddr target;
|
||||
if (g_peer_public.configured && g_peer_public.joined) {
|
||||
target = g_peer_public.addr;
|
||||
} else if (g_peer_lan.configured && g_peer_lan.joined) {
|
||||
target = g_peer_lan.addr;
|
||||
} else if (g_hosting) {
|
||||
target = GhostAddrLoopback(port);
|
||||
}
|
||||
if (target.valid()) {
|
||||
PacketBuf lp;
|
||||
BuildPacket(lp, kTypeList, nullptr, 0);
|
||||
GhostSend(sock, target, lp);
|
||||
}
|
||||
}
|
||||
|
||||
DrainSocket(sock, now);
|
||||
PruneGhosts(now);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(25));
|
||||
|
|
@ -1041,6 +1090,19 @@ bool GetLocalWorld(float world[16]) {
|
|||
return true;
|
||||
}
|
||||
|
||||
void RequestRooms() {
|
||||
g_request_rooms.store(true, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
bool GetRooms(RoomList* out) {
|
||||
std::lock_guard<std::mutex> lock(g_mu);
|
||||
if (out == nullptr) {
|
||||
return false;
|
||||
}
|
||||
*out = g_rooms;
|
||||
return g_rooms.received > 0.0;
|
||||
}
|
||||
|
||||
bool GetGhostLabel(uint32_t index, float pos[3], float color[3], char* name,
|
||||
size_t name_max) {
|
||||
std::lock_guard<std::mutex> lock(g_mu);
|
||||
|
|
|
|||
|
|
@ -85,6 +85,20 @@ bool GetLocalWorld(float world[16]);
|
|||
bool GetGhostLabel(uint32_t index, float pos[3], float color[3], char* name,
|
||||
size_t name_max);
|
||||
|
||||
// ---- lobby browser ------------------------------------------------------------
|
||||
// Ask the network thread to fetch the relay's room list (non-blocking; the
|
||||
// reply updates GetRooms asynchronously, a second or two later).
|
||||
void RequestRooms();
|
||||
|
||||
// Latest room list snapshot. count == 0 before the first ROOMS reply.
|
||||
struct RoomList {
|
||||
uint8_t version = 0;
|
||||
uint8_t count = 0;
|
||||
RoomEntry rooms[kGhostMaxRooms];
|
||||
double received = 0.0; // steady seconds of the reply (0 = none yet)
|
||||
};
|
||||
bool GetRooms(RoomList* out);
|
||||
|
||||
// ---- cube geometry (56-byte records matching kSceneInputLayout) -----------------
|
||||
struct GhostCubeVertex {
|
||||
float pos[3];
|
||||
|
|
|
|||
|
|
@ -23,11 +23,13 @@
|
|||
namespace skate3::native_ghost {
|
||||
|
||||
constexpr uint32_t kGhostMagic = 0x54484753u; // "GHST" (LE)
|
||||
constexpr uint8_t kGhostVersion = 3;
|
||||
constexpr uint8_t kGhostVersion = 4;
|
||||
constexpr uint16_t kGhostDefaultPort = 40001;
|
||||
constexpr uint32_t kGhostMaxRoomLen = 8;
|
||||
constexpr uint32_t kGhostMaxRoster = 32;
|
||||
constexpr uint32_t kGhostMaxNameLen = 16; // display name, UTF-8-safe ASCII
|
||||
constexpr uint32_t kGhostMaxRooms = 16; // rooms listed per LOBBY reply
|
||||
constexpr uint32_t kGhostMaxRoomMembers = 4; // member names shown per room
|
||||
// Max skeleton rows carried in a POSE (3 float4s per bone). The Skate 3
|
||||
// skater skeleton fits comfortably under this; rows beyond the sender's
|
||||
// captured count are identity-padded so every mesh's referenced bones stay
|
||||
|
|
@ -47,6 +49,8 @@ enum PacketType : uint8_t {
|
|||
kTypePong = 5, // relay -> client: empty
|
||||
kTypeLeave = 6, // client -> relay: empty
|
||||
kTypePoseChunk = 7, // client -> relay -> other members: { PoseChunkPayload }
|
||||
kTypeList = 8, // client -> relay: request the room list (empty payload)
|
||||
kTypeRooms = 9, // relay -> client: { RoomsPayload } (the lobby browser)
|
||||
};
|
||||
|
||||
struct alignas(1) PacketHeader {
|
||||
|
|
@ -83,6 +87,23 @@ struct alignas(1) HelloPayload {
|
|||
RosterEntry roster[kGhostMaxRoster];
|
||||
};
|
||||
|
||||
// One room in the lobby list: the code, the total member count, and up to
|
||||
// kGhostMaxRoomMembers member display names (for the "who's in there" glance).
|
||||
struct alignas(1) RoomEntry {
|
||||
uint8_t room_len;
|
||||
char room[kGhostMaxRoomLen];
|
||||
uint8_t member_count; // total players in this room
|
||||
uint8_t shown_members; // how many names follow (<= kGhostMaxRoomMembers)
|
||||
uint8_t member_name_len[kGhostMaxRoomMembers];
|
||||
char member_names[kGhostMaxRoomMembers][kGhostMaxNameLen];
|
||||
};
|
||||
|
||||
struct alignas(1) RoomsPayload {
|
||||
uint8_t version; // the relay's protocol version (see the handshake)
|
||||
uint8_t room_count;
|
||||
RoomEntry rooms[kGhostMaxRooms];
|
||||
};
|
||||
|
||||
// Full-pose blob (the character-model path): the sender's skater skeleton,
|
||||
// 3 float4 rows per bone (column-vector affine [R | t], model -> world, row
|
||||
// k = bone k exactly as the scene renderer's bone ring expects). Flags bit 0
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
// The relay core is compiled into BOTH the game (rex logging available) and
|
||||
|
|
@ -154,7 +155,8 @@ void GhostRelay::HandlePacket(const GhostAddr& from, const PacketBuf& p,
|
|||
// Resolve the sender's client record. Client records are only created by
|
||||
// JOIN/PING (a client must announce itself before posing); any other
|
||||
// packet from an unknown address is dropped, so spoofed POSE/LEAVE floods
|
||||
// cannot fill the client table.
|
||||
// cannot fill the client table. LIST is the exception: a lobby browser can
|
||||
// ask for the room list before joining anything.
|
||||
Client* me = nullptr;
|
||||
for (Client& c : clients_) {
|
||||
if (GhostAddrEqual(c.addr, from)) {
|
||||
|
|
@ -163,6 +165,10 @@ void GhostRelay::HandlePacket(const GhostAddr& from, const PacketBuf& p,
|
|||
}
|
||||
}
|
||||
if (me == nullptr) {
|
||||
if (p.h.type == kTypeList) {
|
||||
BuildRoomList(from);
|
||||
return;
|
||||
}
|
||||
if (p.h.type != kTypeJoin && p.h.type != kTypePing) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -332,11 +338,67 @@ void GhostRelay::HandlePacket(const GhostAddr& from, const PacketBuf& p,
|
|||
break;
|
||||
}
|
||||
|
||||
case kTypeList: {
|
||||
BuildRoomList(from);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break; // PONG etc. are client-side only; ignore at the relay.
|
||||
}
|
||||
}
|
||||
|
||||
void GhostRelay::BuildRoomList(const GhostAddr& to) {
|
||||
// Aggregate the client table by room (the mu_ lock is already held by the
|
||||
// caller), most-populated first, capped at kGhostMaxRooms with up to
|
||||
// kGhostMaxRoomMembers names per room.
|
||||
struct RoomAgg {
|
||||
uint32_t count = 0;
|
||||
uint32_t shown = 0;
|
||||
uint32_t names[kGhostMaxRoomMembers] = {}; // indices into clients_
|
||||
};
|
||||
std::unordered_map<std::string, RoomAgg> agg;
|
||||
for (size_t i = 0; i < clients_.size(); ++i) {
|
||||
const Client& c = clients_[i];
|
||||
if (c.room_len == 0 || c.refused) {
|
||||
continue;
|
||||
}
|
||||
const std::string key(c.room, c.room_len);
|
||||
RoomAgg& a = agg[key];
|
||||
++a.count;
|
||||
if (a.shown < kGhostMaxRoomMembers && c.name_len != 0) {
|
||||
a.names[a.shown++] = uint32_t(i);
|
||||
}
|
||||
}
|
||||
|
||||
RoomsPayload rp = {};
|
||||
rp.version = kGhostVersion;
|
||||
uint8_t listed = 0;
|
||||
for (const auto& kv : agg) {
|
||||
if (listed >= kGhostMaxRooms) {
|
||||
break;
|
||||
}
|
||||
const std::string& key = kv.first;
|
||||
const RoomAgg& a = kv.second;
|
||||
RoomEntry& e = rp.rooms[listed];
|
||||
e.room_len = uint8_t(std::min<size_t>(key.size(), kGhostMaxRoomLen));
|
||||
std::memcpy(e.room, key.data(), e.room_len);
|
||||
e.member_count = uint8_t(std::min<uint32_t>(a.count, 255));
|
||||
e.shown_members = uint8_t(a.shown);
|
||||
for (uint32_t n = 0; n < a.shown; ++n) {
|
||||
const Client& c = clients_[a.names[n]];
|
||||
e.member_name_len[n] = c.name_len;
|
||||
std::memcpy(e.member_names[n], c.name, c.name_len);
|
||||
}
|
||||
++listed;
|
||||
}
|
||||
rp.room_count = listed;
|
||||
|
||||
PacketBuf out;
|
||||
BuildPacket(out, kTypeRooms, &rp, static_cast<uint16_t>(sizeof(rp)));
|
||||
GhostSockSendTo(sock_, to, &out, PacketSize(out));
|
||||
}
|
||||
|
||||
void GhostRelay::Sweep(double now) {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
if (now <= 0.0) {
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ class GhostRelay {
|
|||
private:
|
||||
void Run();
|
||||
void HandlePacket(const GhostAddr& from, const PacketBuf& p, double now);
|
||||
void BuildRoomList(const GhostAddr& to);
|
||||
void Sweep(double now);
|
||||
|
||||
GhostSock sock_ = GHOST_INVALID_SOCKET;
|
||||
|
|
|
|||
|
|
@ -110,6 +110,43 @@ void GhostDialog::OnDraw(ImGuiIO& io) {
|
|||
|
||||
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_),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue