feat: battleground invitation popup with countdown timer

Replace the text-only "/join to enter" message with an interactive
popup that shows the BG name, a live countdown progress bar, and
Enter/Leave Queue buttons.

- Parse STATUS_WAIT_JOIN timeout from SMSG_BATTLEFIELD_STATUS
- Store inviteReceivedTime (steady_clock) on the queue slot
- BgQueueSlot moved to public section so UI can read invite details
- Add declineBattlefield() that sends CMSG_BATTLEFIELD_PORT(action=0)
- acceptBattlefield() optimistically sets statusId=3 to dismiss popup
- renderBgInvitePopup: colored countdown bar (green→yellow→red),
  named BG (Alterac Valley, Warsong Gulch, etc.), auto-dismisses on expiry
This commit is contained in:
Kelsi 2026-03-10 21:12:28 -07:00
parent 4986308581
commit a7a559cdcc
4 changed files with 184 additions and 8 deletions

View file

@ -340,9 +340,21 @@ public:
// Random roll // Random roll
void randomRoll(uint32_t minRoll = 1, uint32_t maxRoll = 100); void randomRoll(uint32_t minRoll = 1, uint32_t maxRoll = 100);
// Battleground queue slot (public so UI can read invite details)
struct BgQueueSlot {
uint32_t queueSlot = 0;
uint32_t bgTypeId = 0;
uint8_t arenaType = 0;
uint32_t statusId = 0; // 0=none, 1=wait_queue, 2=wait_join, 3=in_progress
uint32_t inviteTimeout = 80;
std::chrono::steady_clock::time_point inviteReceivedTime{};
};
// Battleground // Battleground
bool hasPendingBgInvite() const; bool hasPendingBgInvite() const;
void acceptBattlefield(uint32_t queueSlot = 0xFFFFFFFF); void acceptBattlefield(uint32_t queueSlot = 0xFFFFFFFF);
void declineBattlefield(uint32_t queueSlot = 0xFFFFFFFF);
const std::array<BgQueueSlot, 3>& getBgQueues() const { return bgQueues_; }
// Logout commands // Logout commands
void requestLogout(); void requestLogout();
@ -1970,12 +1982,6 @@ private:
std::unordered_set<uint32_t> petAutocastSpells_; // spells with autocast on std::unordered_set<uint32_t> petAutocastSpells_; // spells with autocast on
// ---- Battleground queue state ---- // ---- Battleground queue state ----
struct BgQueueSlot {
uint32_t queueSlot = 0;
uint32_t bgTypeId = 0;
uint8_t arenaType = 0;
uint32_t statusId = 0; // 0=none, 1=wait_queue, 2=wait_join, 3=in_progress
};
std::array<BgQueueSlot, 3> bgQueues_{}; std::array<BgQueueSlot, 3> bgQueues_{};
// Instance difficulty // Instance difficulty

View file

@ -248,6 +248,7 @@ private:
void renderGuildRoster(game::GameHandler& gameHandler); void renderGuildRoster(game::GameHandler& gameHandler);
void renderGuildInvitePopup(game::GameHandler& gameHandler); void renderGuildInvitePopup(game::GameHandler& gameHandler);
void renderReadyCheckPopup(game::GameHandler& gameHandler); void renderReadyCheckPopup(game::GameHandler& gameHandler);
void renderBgInvitePopup(game::GameHandler& gameHandler);
void renderChatBubbles(game::GameHandler& gameHandler); void renderChatBubbles(game::GameHandler& gameHandler);
void renderMailWindow(game::GameHandler& gameHandler); void renderMailWindow(game::GameHandler& gameHandler);
void renderMailComposeWindow(game::GameHandler& gameHandler); void renderMailComposeWindow(game::GameHandler& gameHandler);

View file

@ -11832,12 +11832,41 @@ void GameHandler::handleBattlefieldStatus(network::Packet& packet) {
bgName = std::to_string(arenaType) + "v" + std::to_string(arenaType) + " Arena"; bgName = std::to_string(arenaType) + "v" + std::to_string(arenaType) + " Arena";
} }
// Parse status-specific fields
uint32_t inviteTimeout = 80; // default WoW BG invite window (seconds)
if (statusId == 1) {
// STATUS_WAIT_QUEUE: avgWaitTime(4) + timeInQueue(4)
if (packet.getSize() - packet.getReadPos() >= 8) {
/*uint32_t avgWait =*/ packet.readUInt32();
/*uint32_t inQueue =*/ packet.readUInt32();
}
} else if (statusId == 2) {
// STATUS_WAIT_JOIN: timeout(4) + mapId(4)
if (packet.getSize() - packet.getReadPos() >= 4) {
inviteTimeout = packet.readUInt32();
}
if (packet.getSize() - packet.getReadPos() >= 4) {
/*uint32_t mapId =*/ packet.readUInt32();
}
} else if (statusId == 3) {
// STATUS_IN_PROGRESS: mapId(4) + timeSinceStart(4)
if (packet.getSize() - packet.getReadPos() >= 8) {
/*uint32_t mapId =*/ packet.readUInt32();
/*uint32_t elapsed =*/ packet.readUInt32();
}
}
// Store queue state // Store queue state
if (queueSlot < bgQueues_.size()) { if (queueSlot < bgQueues_.size()) {
bool wasInvite = (bgQueues_[queueSlot].statusId == 2);
bgQueues_[queueSlot].queueSlot = queueSlot; bgQueues_[queueSlot].queueSlot = queueSlot;
bgQueues_[queueSlot].bgTypeId = bgTypeId; bgQueues_[queueSlot].bgTypeId = bgTypeId;
bgQueues_[queueSlot].arenaType = arenaType; bgQueues_[queueSlot].arenaType = arenaType;
bgQueues_[queueSlot].statusId = statusId; bgQueues_[queueSlot].statusId = statusId;
if (statusId == 2 && !wasInvite) {
bgQueues_[queueSlot].inviteTimeout = inviteTimeout;
bgQueues_[queueSlot].inviteReceivedTime = std::chrono::steady_clock::now();
}
} }
switch (statusId) { switch (statusId) {
@ -11849,8 +11878,10 @@ void GameHandler::handleBattlefieldStatus(network::Packet& packet) {
LOG_INFO("Battlefield status: WAIT_QUEUE for ", bgName); LOG_INFO("Battlefield status: WAIT_QUEUE for ", bgName);
break; break;
case 2: // STATUS_WAIT_JOIN case 2: // STATUS_WAIT_JOIN
addSystemChatMessage(bgName + " is ready! Type /join to enter."); // Popup shown by the UI; add chat notification too.
LOG_INFO("Battlefield status: WAIT_JOIN for ", bgName); addSystemChatMessage(bgName + " is ready!");
LOG_INFO("Battlefield status: WAIT_JOIN for ", bgName,
" timeout=", inviteTimeout, "s");
break; break;
case 3: // STATUS_IN_PROGRESS case 3: // STATUS_IN_PROGRESS
addSystemChatMessage("Entered " + bgName + "."); addSystemChatMessage("Entered " + bgName + ".");
@ -11865,6 +11896,44 @@ void GameHandler::handleBattlefieldStatus(network::Packet& packet) {
} }
} }
void GameHandler::declineBattlefield(uint32_t queueSlot) {
if (state != WorldState::IN_WORLD) return;
if (!socket) return;
const BgQueueSlot* slot = nullptr;
if (queueSlot == 0xFFFFFFFF) {
for (const auto& s : bgQueues_) {
if (s.statusId == 2) { slot = &s; break; }
}
} else if (queueSlot < bgQueues_.size() && bgQueues_[queueSlot].statusId == 2) {
slot = &bgQueues_[queueSlot];
}
if (!slot) {
addSystemChatMessage("No battleground invitation pending.");
return;
}
// CMSG_BATTLEFIELD_PORT with action=0 (decline)
network::Packet pkt(wireOpcode(Opcode::CMSG_BATTLEFIELD_PORT));
pkt.writeUInt8(slot->arenaType);
pkt.writeUInt8(0x00);
pkt.writeUInt32(slot->bgTypeId);
pkt.writeUInt16(0x0000);
pkt.writeUInt8(0); // 0 = decline
socket->send(pkt);
// Clear queue slot
uint32_t clearSlot = slot->queueSlot;
if (clearSlot < bgQueues_.size()) {
bgQueues_[clearSlot] = BgQueueSlot{};
}
addSystemChatMessage("Battleground invitation declined.");
LOG_INFO("Sent CMSG_BATTLEFIELD_PORT: decline");
}
bool GameHandler::hasPendingBgInvite() const { bool GameHandler::hasPendingBgInvite() const {
for (const auto& slot : bgQueues_) { for (const auto& slot : bgQueues_) {
if (slot.statusId == 2) return true; // STATUS_WAIT_JOIN if (slot.statusId == 2) return true; // STATUS_WAIT_JOIN
@ -11901,6 +11970,12 @@ void GameHandler::acceptBattlefield(uint32_t queueSlot) {
socket->send(pkt); socket->send(pkt);
// Optimistically clear the invite so the popup disappears immediately.
uint32_t clearSlot = slot->queueSlot;
if (clearSlot < bgQueues_.size()) {
bgQueues_[clearSlot].statusId = 3; // STATUS_IN_PROGRESS (server will confirm)
}
addSystemChatMessage("Accepting battleground invitation..."); addSystemChatMessage("Accepting battleground invitation...");
LOG_INFO("Sent CMSG_BATTLEFIELD_PORT: accept bgTypeId=", slot->bgTypeId); LOG_INFO("Sent CMSG_BATTLEFIELD_PORT: accept bgTypeId=", slot->bgTypeId);
} }

View file

@ -414,6 +414,7 @@ void GameScreen::render(game::GameHandler& gameHandler) {
renderItemTextWindow(gameHandler); renderItemTextWindow(gameHandler);
renderGuildInvitePopup(gameHandler); renderGuildInvitePopup(gameHandler);
renderReadyCheckPopup(gameHandler); renderReadyCheckPopup(gameHandler);
renderBgInvitePopup(gameHandler);
renderGuildRoster(gameHandler); renderGuildRoster(gameHandler);
renderBuffBar(gameHandler); renderBuffBar(gameHandler);
renderLootWindow(gameHandler); renderLootWindow(gameHandler);
@ -5867,6 +5868,99 @@ void GameScreen::renderReadyCheckPopup(game::GameHandler& gameHandler) {
ImGui::End(); ImGui::End();
} }
void GameScreen::renderBgInvitePopup(game::GameHandler& gameHandler) {
if (!gameHandler.hasPendingBgInvite()) return;
const auto& queues = gameHandler.getBgQueues();
// Find the first WAIT_JOIN slot
const game::GameHandler::BgQueueSlot* slot = nullptr;
for (const auto& s : queues) {
if (s.statusId == 2) { slot = &s; break; }
}
if (!slot) return;
// Compute time remaining
auto now = std::chrono::steady_clock::now();
double elapsed = std::chrono::duration<double>(now - slot->inviteReceivedTime).count();
double remaining = static_cast<double>(slot->inviteTimeout) - elapsed;
// If invite has expired, clear it silently (server will handle the queue)
if (remaining <= 0.0) {
gameHandler.declineBattlefield(slot->queueSlot);
return;
}
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 190, screenH / 2 - 70), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(380, 0), ImGuiCond_Always);
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.08f, 0.18f, 0.95f));
ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.4f, 0.4f, 1.0f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_TitleBgActive, ImVec4(0.15f, 0.15f, 0.4f, 1.0f));
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f);
const ImGuiWindowFlags popupFlags =
ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse;
if (ImGui::Begin("Battleground Ready!", nullptr, popupFlags)) {
// BG name
std::string bgName;
if (slot->arenaType > 0) {
bgName = std::to_string(slot->arenaType) + "v" + std::to_string(slot->arenaType) + " Arena";
} else {
switch (slot->bgTypeId) {
case 1: bgName = "Alterac Valley"; break;
case 2: bgName = "Warsong Gulch"; break;
case 3: bgName = "Arathi Basin"; break;
case 7: bgName = "Eye of the Storm"; break;
case 9: bgName = "Strand of the Ancients"; break;
case 11: bgName = "Isle of Conquest"; break;
default: bgName = "Battleground"; break;
}
}
ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.2f, 1.0f), "%s", bgName.c_str());
ImGui::TextWrapped("A spot has opened! You have %d seconds to enter.", static_cast<int>(remaining));
ImGui::Spacing();
// Countdown progress bar
float frac = static_cast<float>(remaining / static_cast<double>(slot->inviteTimeout));
frac = std::clamp(frac, 0.0f, 1.0f);
ImVec4 barColor = frac > 0.5f ? ImVec4(0.2f, 0.8f, 0.2f, 1.0f)
: frac > 0.25f ? ImVec4(0.9f, 0.7f, 0.1f, 1.0f)
: ImVec4(0.9f, 0.2f, 0.2f, 1.0f);
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, barColor);
char countdownLabel[32];
snprintf(countdownLabel, sizeof(countdownLabel), "%ds", static_cast<int>(remaining));
ImGui::ProgressBar(frac, ImVec2(-1, 16), countdownLabel);
ImGui::PopStyleColor();
ImGui::Spacing();
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.5f, 0.15f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.2f, 0.7f, 0.2f, 1.0f));
if (ImGui::Button("Enter Battleground", ImVec2(180, 30))) {
gameHandler.acceptBattlefield(slot->queueSlot);
}
ImGui::PopStyleColor(2);
ImGui::SameLine();
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.5f, 0.15f, 0.15f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.7f, 0.2f, 0.2f, 1.0f));
if (ImGui::Button("Leave Queue", ImVec2(175, 30))) {
gameHandler.declineBattlefield(slot->queueSlot);
}
ImGui::PopStyleColor(2);
}
ImGui::End();
ImGui::PopStyleVar();
ImGui::PopStyleColor(3);
}
void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) {
// O key toggle (WoW default Social/Guild keybind) // O key toggle (WoW default Social/Guild keybind)
if (!ImGui::GetIO().WantCaptureKeyboard && ImGui::IsKeyPressed(ImGuiKey_O)) { if (!ImGui::GetIO().WantCaptureKeyboard && ImGui::IsKeyPressed(ImGuiKey_O)) {