Add threat list window showing live aggro data for current target

Store SMSG_THREAT_UPDATE/SMSG_HIGHEST_THREAT_UPDATE in a per-unit map
(sorted descending by threat) and clear on SMSG_THREAT_REMOVE/CLEAR.
Show a threat window (/threat or via target frame button) with a progress
bar per player and gold highlight for the tank, red if local player has aggro.
This commit is contained in:
Kelsi 2026-03-12 02:59:09 -07:00
parent 43de2be1f2
commit 920950dfbd
4 changed files with 149 additions and 27 deletions

View file

@ -514,6 +514,21 @@ public:
const std::vector<CombatTextEntry>& getCombatText() const { return combatText; }
void updateCombatText(float deltaTime);
// Threat
struct ThreatEntry {
uint64_t victimGuid = 0;
uint32_t threat = 0;
};
// Returns the current threat list for a given unit GUID (from last SMSG_THREAT_UPDATE)
const std::vector<ThreatEntry>* getThreatList(uint64_t unitGuid) const {
auto it = threatLists_.find(unitGuid);
return (it != threatLists_.end()) ? &it->second : nullptr;
}
// Returns the threat list for the player's current target, or nullptr
const std::vector<ThreatEntry>* getTargetThreatList() const {
return targetGuid ? getThreatList(targetGuid) : nullptr;
}
// ---- Phase 3: Spells ----
void castSpell(uint32_t spellId, uint64_t targetGuid = 0);
void cancelCast();
@ -2047,6 +2062,8 @@ private:
float autoAttackFacingSyncTimer_ = 0.0f; // Periodic facing sync while meleeing
std::unordered_set<uint64_t> hostileAttackers_;
std::vector<CombatTextEntry> combatText;
// unitGuid → sorted threat list (descending by threat value)
std::unordered_map<uint64_t, std::vector<ThreatEntry>> threatLists_;
// ---- Phase 3: Spells ----
WorldEntryCallback worldEntryCallback_;

View file

@ -368,6 +368,10 @@ private:
// Inspect window
bool showInspectWindow_ = false;
void renderInspectWindow(game::GameHandler& gameHandler);
// Threat window
bool showThreatWindow_ = false;
void renderThreatWindow(game::GameHandler& gameHandler);
uint8_t lfgRoles_ = 0x08; // default: DPS (0x02=tank, 0x04=healer, 0x08=dps)
uint32_t lfgSelectedDungeon_ = 861; // default: random dungeon (entry 861 = Random Dungeon WotLK)

View file

@ -2331,24 +2331,51 @@ void GameHandler::handlePacket(network::Packet& packet) {
break;
case Opcode::SMSG_THREAT_CLEAR:
// All threat dropped on the local player (e.g. Vanish, Feign Death)
// No local state to clear — informational
threatLists_.clear();
LOG_DEBUG("SMSG_THREAT_CLEAR: threat wiped");
break;
case Opcode::SMSG_THREAT_REMOVE: {
// packed_guid (unit) + packed_guid (victim whose threat was removed)
if (packet.getSize() - packet.getReadPos() >= 1) {
(void)UpdateObjectParser::readPackedGuid(packet);
if (packet.getSize() - packet.getReadPos() >= 1) {
(void)UpdateObjectParser::readPackedGuid(packet);
}
if (packet.getSize() - packet.getReadPos() < 1) break;
uint64_t unitGuid = UpdateObjectParser::readPackedGuid(packet);
if (packet.getSize() - packet.getReadPos() < 1) break;
uint64_t victimGuid = UpdateObjectParser::readPackedGuid(packet);
auto it = threatLists_.find(unitGuid);
if (it != threatLists_.end()) {
auto& list = it->second;
list.erase(std::remove_if(list.begin(), list.end(),
[victimGuid](const ThreatEntry& e){ return e.victimGuid == victimGuid; }),
list.end());
if (list.empty()) threatLists_.erase(it);
}
break;
}
case Opcode::SMSG_HIGHEST_THREAT_UPDATE: {
// packed_guid (tank) + packed_guid (new highest threat unit) + uint32 count
// + count × (packed_guid victim + uint32 threat)
// Informational — no threat UI yet; consume to suppress warnings
packet.setReadPos(packet.getSize());
case Opcode::SMSG_HIGHEST_THREAT_UPDATE:
case Opcode::SMSG_THREAT_UPDATE: {
// Both packets share the same format:
// packed_guid (unit) + packed_guid (highest-threat target or target, unused here)
// + uint32 count + count × (packed_guid victim + uint32 threat)
if (packet.getSize() - packet.getReadPos() < 1) break;
uint64_t unitGuid = UpdateObjectParser::readPackedGuid(packet);
if (packet.getSize() - packet.getReadPos() < 1) break;
(void)UpdateObjectParser::readPackedGuid(packet); // highest-threat / current target
if (packet.getSize() - packet.getReadPos() < 4) break;
uint32_t cnt = packet.readUInt32();
if (cnt > 100) { packet.setReadPos(packet.getSize()); break; } // sanity
std::vector<ThreatEntry> list;
list.reserve(cnt);
for (uint32_t i = 0; i < cnt; ++i) {
if (packet.getSize() - packet.getReadPos() < 1) break;
ThreatEntry entry;
entry.victimGuid = UpdateObjectParser::readPackedGuid(packet);
if (packet.getSize() - packet.getReadPos() < 4) break;
entry.threat = packet.readUInt32();
list.push_back(entry);
}
// Sort descending by threat so highest is first
std::sort(list.begin(), list.end(),
[](const ThreatEntry& a, const ThreatEntry& b){ return a.threat > b.threat; });
threatLists_[unitGuid] = std::move(list);
break;
}
@ -5656,22 +5683,6 @@ void GameHandler::handlePacket(network::Packet& packet) {
break;
}
case Opcode::SMSG_THREAT_UPDATE: {
// packed_guid (unit) + packed_guid (target) + uint32 count
// + count × (packed_guid victim + uint32 threat) — consume to suppress warnings
if (packet.getSize() - packet.getReadPos() < 1) break;
(void)UpdateObjectParser::readPackedGuid(packet);
if (packet.getSize() - packet.getReadPos() < 1) break;
(void)UpdateObjectParser::readPackedGuid(packet);
if (packet.getSize() - packet.getReadPos() < 4) break;
uint32_t cnt = packet.readUInt32();
for (uint32_t i = 0; i < cnt && packet.getSize() - packet.getReadPos() >= 1; ++i) {
(void)UpdateObjectParser::readPackedGuid(packet);
if (packet.getSize() - packet.getReadPos() >= 4)
packet.readUInt32();
}
break;
}
case Opcode::SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT: {
// uint32 slot + packed_guid unit (0 packed = clear slot)
if (packet.getSize() - packet.getReadPos() < 5) {

View file

@ -500,6 +500,7 @@ void GameScreen::render(game::GameHandler& gameHandler) {
renderAchievementWindow(gameHandler);
renderGmTicketWindow(gameHandler);
renderInspectWindow(gameHandler);
renderThreatWindow(gameHandler);
// renderQuestMarkers(gameHandler); // Disabled - using 3D billboard markers now
if (showMinimap_) {
renderMinimapMarkers(gameHandler);
@ -2728,6 +2729,15 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) {
float distance = std::sqrt(dx*dx + dy*dy + dz*dz);
ImGui::TextDisabled("%.1f yd", distance);
// Threat button (shown when in combat and threat data is available)
if (gameHandler.getTargetThreatList()) {
ImGui::SameLine();
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.5f, 0.1f, 0.1f, 0.8f));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.7f, 0.2f, 0.2f, 0.9f));
if (ImGui::SmallButton("Threat")) showThreatWindow_ = !showThreatWindow_;
ImGui::PopStyleColor(2);
}
// Target auras (buffs/debuffs)
const auto& targetAuras = gameHandler.getTargetAuras();
int activeAuras = 0;
@ -3152,6 +3162,13 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) {
return;
}
// /threat command
if (cmdLower == "threat") {
showThreatWindow_ = !showThreatWindow_;
chatInputBuffer[0] = '\0';
return;
}
// /time command
if (cmdLower == "time") {
gameHandler.queryServerTime();
@ -14490,6 +14507,79 @@ void GameScreen::renderGmTicketWindow(game::GameHandler& gameHandler) {
ImGui::End();
}
// ─── Threat Window ────────────────────────────────────────────────────────────
void GameScreen::renderThreatWindow(game::GameHandler& gameHandler) {
if (!showThreatWindow_) return;
const auto* list = gameHandler.getTargetThreatList();
ImGui::SetNextWindowSize(ImVec2(280, 220), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowPos(ImVec2(10, 300), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowBgAlpha(0.85f);
if (!ImGui::Begin("Threat###ThreatWin", &showThreatWindow_,
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize)) {
ImGui::End();
return;
}
if (!list || list->empty()) {
ImGui::TextDisabled("No threat data for current target.");
ImGui::End();
return;
}
uint32_t maxThreat = list->front().threat;
ImGui::TextDisabled("%-19s Threat", "Player");
ImGui::Separator();
uint64_t playerGuid = gameHandler.getPlayerGuid();
int rank = 0;
for (const auto& entry : *list) {
++rank;
bool isPlayer = (entry.victimGuid == playerGuid);
// Resolve name
std::string victimName;
auto entity = gameHandler.getEntityManager().getEntity(entry.victimGuid);
if (entity) {
if (entity->getType() == game::ObjectType::PLAYER) {
auto p = std::static_pointer_cast<game::Player>(entity);
victimName = p->getName().empty() ? "Player" : p->getName();
} else if (entity->getType() == game::ObjectType::UNIT) {
auto u = std::static_pointer_cast<game::Unit>(entity);
victimName = u->getName().empty() ? "NPC" : u->getName();
}
}
if (victimName.empty())
victimName = "0x" + [&](){
char buf[20]; snprintf(buf, sizeof(buf), "%llX",
static_cast<unsigned long long>(entry.victimGuid)); return std::string(buf); }();
// Colour: gold for #1 (tank), red if player is highest, white otherwise
ImVec4 col = ImVec4(1.0f, 1.0f, 1.0f, 1.0f);
if (rank == 1) col = ImVec4(1.0f, 0.82f, 0.0f, 1.0f); // gold
if (isPlayer && rank == 1) col = ImVec4(1.0f, 0.3f, 0.3f, 1.0f); // red — you have aggro
// Threat bar
float pct = (maxThreat > 0) ? (float)entry.threat / (float)maxThreat : 0.0f;
ImGui::PushStyleColor(ImGuiCol_PlotHistogram,
isPlayer ? ImVec4(0.8f, 0.2f, 0.2f, 0.7f) : ImVec4(0.2f, 0.5f, 0.8f, 0.5f));
char barLabel[48];
snprintf(barLabel, sizeof(barLabel), "%.0f%%", pct * 100.0f);
ImGui::ProgressBar(pct, ImVec2(60, 14), barLabel);
ImGui::PopStyleColor();
ImGui::SameLine();
ImGui::TextColored(col, "%-18s %u", victimName.c_str(), entry.threat);
if (rank >= 10) break; // cap display at 10 entries
}
ImGui::End();
}
// ─── Inspect Window ───────────────────────────────────────────────────────────
void GameScreen::renderInspectWindow(game::GameHandler& gameHandler) {
if (!showInspectWindow_) return;