add map trackers and fix spell and mount state

This commit is contained in:
Kelsi
2026-07-21 02:23:51 -07:00
parent 31e962d9fc
commit 2de06c267a
15 changed files with 261 additions and 23 deletions

View File

@@ -808,6 +808,7 @@ set(WOWEE_SOURCES
src/rendering/world_map/layers/quest_poi_layer.cpp
src/rendering/world_map/layers/corpse_marker_layer.cpp
src/rendering/world_map/layers/rare_tracker_layer.cpp
src/rendering/world_map/layers/chest_tracker_layer.cpp
src/rendering/world_map/layers/zone_highlight_layer.cpp
src/rendering/world_map/layers/coordinate_display.cpp
src/rendering/world_map/layers/subzone_tooltip_layer.cpp
@@ -996,6 +997,8 @@ set(WOWEE_HEADERS
include/rendering/world_map/layers/poi_marker_layer.hpp
include/rendering/world_map/layers/quest_poi_layer.hpp
include/rendering/world_map/layers/corpse_marker_layer.hpp
include/rendering/world_map/layers/rare_tracker_layer.hpp
include/rendering/world_map/layers/chest_tracker_layer.hpp
include/rendering/world_map/layers/zone_highlight_layer.hpp
include/rendering/world_map/layers/coordinate_display.hpp
include/rendering/world_map/layers/subzone_tooltip_layer.hpp

View File

@@ -134,11 +134,16 @@ inline uint32_t resolveHighestKnownRank(
const SpellRankInfo* info = lookup(spellId);
if (!info || info->name.empty()) return spellId;
// Keep the requested name by value. Some DBC adapters reuse one scratch object
// for every lookup; retaining its pointer here lets the first candidate overwrite
// the requested spell and makes unrelated known spells look like matching ranks.
const std::string requestedName = info->name;
uint32_t best = 0;
int bestRank = -1;
for (uint32_t known : knownSpells) {
const SpellRankInfo* candidate = lookup(known);
if (!candidate || candidate->name != info->name) continue;
if (!candidate || candidate->name != requestedName) continue;
const int rank = rankValue(candidate->rank);
if (rank > bestRank) {
bestRank = rank;

View File

@@ -12,6 +12,7 @@ namespace rendering {
// (game_screen_hud.cpp, renderer.cpp, etc.)
using WorldMapPartyDot = world_map::PartyDot;
using WorldMapRareMark = world_map::RareMark;
using WorldMapChestMark = world_map::ChestMark;
using WorldMapTaxiNode = world_map::TaxiNode;
using MapPOI = world_map::POI;

View File

@@ -0,0 +1,22 @@
// chest_tracker_layer.hpp — Nearby spawned chest markers on the world map.
#pragma once
#include "rendering/world_map/overlay_renderer.hpp"
#include "rendering/world_map/world_map_types.hpp"
#include <vector>
namespace wowee {
namespace rendering {
namespace world_map {
class ChestTrackerLayer : public IOverlayLayer {
public:
void setChests(const std::vector<ChestMark>& chests) { chests_ = &chests; }
void render(const LayerContext& ctx) override;
private:
const std::vector<ChestMark>* chests_ = nullptr;
};
} // namespace world_map
} // namespace rendering
} // namespace wowee

View File

@@ -47,6 +47,8 @@ public:
void setCorpsePos(bool hasCorpse, glm::vec3 renderPos);
/// Nearby rare/rare-elite creatures currently spawned near the player.
void setRares(std::vector<RareMark> rares);
/// Nearby chest-type game objects currently spawned near the player.
void setChests(std::vector<ChestMark> chests);
bool isOpen() const;
void close();

View File

@@ -108,6 +108,13 @@ struct RareMark {
int rank = 4; ///< Creature rank: 2 = Rare Elite, 4 = Rare
};
// ── Nearby spawned chest marker (UI layer → world map overlay) ─
struct ChestMark {
glm::vec3 renderPos; ///< Position in render-space coordinates
std::string name; ///< Chest/container name (shown as tooltip on hover)
};
// ── Taxi (flight master) node (UI layer → world map overlay) ─
struct TaxiNode {

View File

@@ -157,7 +157,8 @@ public:
bool showFriendlyNameplates_ = true; // Shift+V toggles friendly player nameplates
bool showDPSMeter_ = false;
bool showCooldownTracker_ = false;
bool showRareTracker_ = false; // Mark nearby spawned rares/rare-elites on the world map
bool showRareTracker_ = false; // Mark nearby spawned rares/rare-elites on both maps
bool showChestTracker_ = false; // Mark nearby spawned non-gather chests on both maps
bool damageFlashEnabled_ = true;
bool lowHealthVignetteEnabled_ = true; // Persistent pulsing red vignette below 20% HP

View File

@@ -1844,23 +1844,15 @@ void EntityController::onValuesUpdatePlayer(const UpdateBlock& block, std::share
oldFieldsSnapshot = owner_.lastPlayerFieldsRef();
}
if (block.hasMovement && block.runSpeed > 0.1f && block.runSpeed < 100.0f) {
// Speed is independent of mount ownership: slows and ordinary movement
// snapshots may report base-speed values while UNIT_FIELD_MOUNTDISPLAYID
// still authoritatively says the player is mounted.
if (auto* movement = owner_.getMovementHandler()) {
movement->applyServerMovementSpeeds(
block.walkSpeed, block.runSpeed, block.runBackSpeed,
block.swimSpeed, block.swimBackSpeed, block.flightSpeed,
block.flightBackSpeed, block.turnRate, block.pitchRate);
}
// Some server dismount paths update run speed without updating mount display field.
const bool onRealTaxiFlight = owner_.getMovementHandler() && owner_.getMovementHandler()->isOnTaxiFlight();
if (!onRealTaxiFlight && !owner_.taxiMountActiveRef() &&
owner_.currentMountDisplayIdRef() != 0 && block.runSpeed <= 8.5f) {
LOG_INFO("Auto-clearing mount from movement speed update: speed=", block.runSpeed,
" displayId=", owner_.currentMountDisplayIdRef());
owner_.currentMountDisplayIdRef() = 0;
if (owner_.mountCallbackRef()) {
owner_.mountCallbackRef()(0);
}
}
}
// Merge block fields into the persistent snapshot. Both are sorted, so
// a simple insert_or_assign per key keeps the invariant intact.

View File

@@ -982,16 +982,9 @@ void MovementHandler::handleForceSpeedChange(network::Packet& packet, const char
}
void MovementHandler::handleForceRunSpeedChange(network::Packet& packet) {
// A speed change is not a dismount signal. Mount ownership is updated by
// SMSG_DISMOUNT / UNIT_FIELD_MOUNTDISPLAYID instead.
handleForceSpeedChange(packet, "RUN_SPEED", Opcode::CMSG_FORCE_RUN_SPEED_CHANGE_ACK, &serverRunSpeed_);
if (!onTaxiFlight_ && !taxiMountActive_ && owner_.currentMountDisplayIdRef() != 0 && serverRunSpeed_ <= 8.5f) {
LOG_INFO("Auto-clearing mount from speed change: speed=", serverRunSpeed_,
" displayId=", owner_.currentMountDisplayIdRef());
owner_.currentMountDisplayIdRef() = 0;
if (owner_.mountCallbackRef()) {
owner_.mountCallbackRef()(0);
}
}
}
void MovementHandler::handleForceMoveRootState(network::Packet& packet, bool rooted) {

View File

@@ -0,0 +1,69 @@
// chest_tracker_layer.cpp — Nearby spawned chest markers on the world map.
#include "rendering/world_map/layers/chest_tracker_layer.hpp"
#include "rendering/world_map/coordinate_projection.hpp"
#include <imgui.h>
namespace wowee {
namespace rendering {
namespace world_map {
void ChestTrackerLayer::render(const LayerContext& ctx) {
if (!chests_ || chests_->empty()) return;
if (ctx.currentZoneIdx < 0) return;
if (ctx.viewLevel != ViewLevel::ZONE && ctx.viewLevel != ViewLevel::CONTINENT) return;
if (!ctx.zones) return;
const auto& zone = (*ctx.zones)[ctx.currentZoneIdx];
ZoneBounds bounds = zone.bounds;
const bool isContinent = zone.areaID == 0;
if (isContinent) {
float left, right, top, bottom;
if (getContinentProjectionBounds(*ctx.zones, ctx.currentZoneIdx,
left, right, top, bottom)) {
bounds = {left, right, top, bottom};
}
}
constexpr ImU32 fill = IM_COL32(205, 125, 35, 255);
constexpr ImU32 outline = IM_COL32(45, 25, 5, 230);
ImFont* font = ImGui::GetFont();
for (const auto& chest : *chests_) {
glm::vec2 uv = renderPosToMapUV(chest.renderPos, bounds, isContinent);
if (uv.x < 0.0f || uv.x > 1.0f || uv.y < 0.0f || uv.y > 1.0f) continue;
const float px = ctx.imgMin.x + uv.x * ctx.displayW;
const float py = ctx.imgMin.y + uv.y * ctx.displayH;
// Compact chest silhouette: box, domed lid line, and bright latch.
constexpr float halfW = 6.0f;
constexpr float halfH = 4.5f;
ctx.drawList->AddRectFilled(ImVec2(px - halfW, py - halfH),
ImVec2(px + halfW, py + halfH), fill, 1.5f);
ctx.drawList->AddRect(ImVec2(px - halfW, py - halfH),
ImVec2(px + halfW, py + halfH), outline, 1.5f, 0, 1.5f);
ctx.drawList->AddLine(ImVec2(px - halfW, py - 1.0f),
ImVec2(px + halfW, py - 1.0f), outline, 1.2f);
ctx.drawList->AddRectFilled(ImVec2(px - 1.2f, py - 1.5f),
ImVec2(px + 1.2f, py + 1.5f),
IM_COL32(255, 220, 80, 255), 0.5f);
ImVec2 mouse = ImGui::GetMousePos();
if (mouse.x >= px - halfW - 2.0f && mouse.x <= px + halfW + 2.0f &&
mouse.y >= py - halfH - 2.0f && mouse.y <= py + halfH + 2.0f &&
!chest.name.empty()) {
ImGui::SetTooltip("%s\nChest", chest.name.c_str());
}
if (!chest.name.empty()) {
ImVec2 nameSize = font->CalcTextSizeA(ImGui::GetFontSize(), FLT_MAX, 0.0f,
chest.name.c_str());
const float tx = px - nameSize.x * 0.5f;
const float ty = py - nameSize.y - halfH - 3.0f;
ctx.drawList->AddText(ImVec2(tx + 1.0f, ty + 1.0f),
IM_COL32(0, 0, 0, 190), chest.name.c_str());
ctx.drawList->AddText(ImVec2(tx, ty), fill, chest.name.c_str());
}
}
}
} // namespace world_map
} // namespace rendering
} // namespace wowee

View File

@@ -17,6 +17,7 @@
#include "rendering/world_map/layers/quest_poi_layer.hpp"
#include "rendering/world_map/layers/corpse_marker_layer.hpp"
#include "rendering/world_map/layers/rare_tracker_layer.hpp"
#include "rendering/world_map/layers/chest_tracker_layer.hpp"
#include "rendering/world_map/layers/zone_highlight_layer.hpp"
#include "rendering/world_map/layers/coordinate_display.hpp"
#include "rendering/world_map/layers/subzone_tooltip_layer.hpp"
@@ -121,11 +122,13 @@ struct WorldMapFacade::Impl {
// Typed layer pointers for setters (non-owning references into overlay)
PartyDotLayer* partyDotLayer = nullptr;
std::vector<RareMark> rares;
std::vector<ChestMark> chests;
TaxiNodeLayer* taxiNodeLayer = nullptr;
POIMarkerLayer* poiMarkerLayer = nullptr;
QuestPOILayer* questPOILayer = nullptr;
CorpseMarkerLayer* corpseMarkerLayer = nullptr;
RareTrackerLayer* rareTrackerLayer = nullptr;
ChestTrackerLayer* chestTrackerLayer = nullptr;
ZoneHighlightLayer* zoneHighlightLayer = nullptr;
PlayerMarkerLayer* playerMarkerLayer = nullptr;
@@ -305,6 +308,11 @@ void WorldMapFacade::Impl::initOverlayLayers() {
rareTrackerLayer = rtLayer.get();
overlay.addLayer(std::move(rtLayer));
// Nearby spawned chest tracker
auto ctLayer = std::make_unique<ChestTrackerLayer>();
chestTrackerLayer = ctLayer.get();
overlay.addLayer(std::move(ctLayer));
// Coordinate display
overlay.addLayer(std::make_unique<CoordinateDisplay>());
@@ -635,6 +643,10 @@ void WorldMapFacade::setRares(std::vector<RareMark> rares) {
impl_->rares = std::move(rares);
}
void WorldMapFacade::setChests(std::vector<ChestMark> chests) {
impl_->chests = std::move(chests);
}
void WorldMapFacade::setTaxiNodes(std::vector<TaxiNode> nodes) {
impl_->taxiNodes = std::move(nodes);
}
@@ -815,6 +827,7 @@ void WorldMapFacade::Impl::renderImGuiOverlay(const glm::vec3& playerRenderPos,
// Update layer data pointers
if (partyDotLayer) partyDotLayer->setDots(partyDots);
if (rareTrackerLayer) rareTrackerLayer->setRares(rares);
if (chestTrackerLayer) chestTrackerLayer->setChests(chests);
if (taxiNodeLayer) taxiNodeLayer->setNodes(taxiNodes);
if (poiMarkerLayer) poiMarkerLayer->setMarkers(data.poiMarkers());
if (questPOILayer) questPOILayer->setPois(questPois);

View File

@@ -596,6 +596,28 @@ void GameScreen::renderWorldMap(game::GameHandler& gameHandler) {
wm->setRares(std::move(rares));
}
// Chest tracker: mark loaded type-3 chest objects while excluding mining and
// herbalism nodes, which share GAMEOBJECT_TYPE_CHEST on the wire.
{
std::vector<rendering::WorldMapChestMark> chests;
if (settingsPanel_.showChestTracker_) {
for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) {
if (!entity || entity->getType() != game::ObjectType::GAMEOBJECT) continue;
auto chest = std::static_pointer_cast<game::GameObject>(entity);
const auto* info = gameHandler.getCachedGameObjectInfo(chest->getEntry());
if (!info || !info->isValid() || info->type != 3) continue;
if (gameHandler.isGatherGameObject(guid)) continue;
rendering::WorldMapChestMark mark;
mark.renderPos = core::coords::canonicalToRender(
glm::vec3(chest->getX(), chest->getY(), chest->getZ()));
mark.name = chest->getName().empty() ? info->name : chest->getName();
chests.push_back(std::move(mark));
}
}
wm->setChests(std::move(chests));
}
glm::vec3 playerPos = renderer->getCharacterPosition();
float playerYaw = renderer->getCharacterYaw();
auto* window = app.getWindow();

View File

@@ -257,6 +257,46 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) {
}
}
// Rare tracker: use the same live creature-rank classification as the world map.
// This is independent of generic NPC dots so enabling rare tracking consistently
// shows spawned rares on both maps without adding every nearby creature.
if (settingsPanel_.showRareTracker_) {
ImVec2 mouse = ImGui::GetMousePos();
for (const auto& entity : minimapUnits) {
auto unit = std::static_pointer_cast<game::Unit>(entity);
if (!unit || unit->getHealth() == 0) continue;
const int rank = gameHandler.getCreatureRank(unit->getEntry());
if (rank != 2 && rank != 4) continue; // 2 = Rare Elite, 4 = Rare
glm::vec3 rareRender = core::coords::canonicalToRender(
glm::vec3(entity->getX(), entity->getY(), entity->getZ()));
float sx = 0.0f, sy = 0.0f;
if (!projectToMinimap(rareRender, sx, sy)) continue;
// Match the world-map tracker: gold for Rare, silver for Rare Elite.
const bool isElite = rank == 2;
const ImU32 fill = isElite
? IM_COL32(210, 210, 225, 255)
: IM_COL32(255, 190, 60, 255);
constexpr float halfSize = 5.0f;
const ImVec2 top(sx, sy - halfSize);
const ImVec2 right(sx + halfSize, sy);
const ImVec2 bottom(sx, sy + halfSize);
const ImVec2 left(sx - halfSize, sy);
drawList->AddQuadFilled(top, right, bottom, left, fill);
drawList->AddQuad(top, right, bottom, left, IM_COL32(0, 0, 0, 220), 1.5f);
float mdx = mouse.x - sx, mdy = mouse.y - sy;
if (mdx * mdx + mdy * mdy <= 49.0f) {
const std::string& name = unit->getName();
ImGui::SetTooltip("%s\n%s",
name.empty() ? "Unknown creature" : name.c_str(),
isElite ? "Rare Elite" : "Rare");
}
}
}
// Nearby other-player dots — shown when NPC dots are enabled.
// Party members are already drawn as squares above; other players get a small circle.
if (settingsPanel_.minimapNpcDots_) {
@@ -366,6 +406,44 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) {
}
}
// Chest tracker: GAMEOBJECT_TYPE_CHEST also covers gathering nodes on WoW
// servers, so explicitly exclude the existing mining/herbalism classification.
if (settingsPanel_.showChestTracker_) {
ImVec2 mouse = ImGui::GetMousePos();
for (const auto& entity : minimapGameObjects) {
auto chest = std::static_pointer_cast<game::GameObject>(entity);
if (!chest) continue;
const auto* info = gameHandler.getCachedGameObjectInfo(chest->getEntry());
if (!info || !info->isValid() || info->type != 3) continue;
if (gameHandler.isGatherGameObject(chest->getGuid())) continue;
glm::vec3 chestRender = core::coords::canonicalToRender(
glm::vec3(entity->getX(), entity->getY(), entity->getZ()));
float sx = 0.0f, sy = 0.0f;
if (!projectToMinimap(chestRender, sx, sy)) continue;
constexpr float halfW = 5.5f;
constexpr float halfH = 4.0f;
constexpr ImU32 fill = IM_COL32(205, 125, 35, 255);
constexpr ImU32 outline = IM_COL32(45, 25, 5, 230);
drawList->AddRectFilled(ImVec2(sx - halfW, sy - halfH),
ImVec2(sx + halfW, sy + halfH), fill, 1.5f);
drawList->AddRect(ImVec2(sx - halfW, sy - halfH),
ImVec2(sx + halfW, sy + halfH), outline, 1.5f, 0, 1.5f);
drawList->AddLine(ImVec2(sx - halfW, sy - 0.8f),
ImVec2(sx + halfW, sy - 0.8f), outline, 1.0f);
drawList->AddRectFilled(ImVec2(sx - 1.0f, sy - 1.2f),
ImVec2(sx + 1.0f, sy + 1.3f),
IM_COL32(255, 220, 80, 255), 0.5f);
if (mouse.x >= sx - halfW - 2.0f && mouse.x <= sx + halfW + 2.0f &&
mouse.y >= sy - halfH - 2.0f && mouse.y <= sy + halfH + 2.0f) {
const std::string& name = chest->getName().empty() ? info->name : chest->getName();
ImGui::SetTooltip("%s\nChest", name.empty() ? "Chest" : name.c_str());
}
}
}
// Party member dots on minimap — small colored squares with name tooltip on hover
if (gameHandler.isInGroup()) {
const auto& partyData = gameHandler.getPartyData();
@@ -1455,6 +1533,7 @@ void GameScreen::saveSettings() {
out << "show_dps_meter=" << (settingsPanel_.showDPSMeter_ ? 1 : 0) << "\n";
out << "show_cooldown_tracker=" << (settingsPanel_.showCooldownTracker_ ? 1 : 0) << "\n";
out << "show_rare_tracker=" << (settingsPanel_.showRareTracker_ ? 1 : 0) << "\n";
out << "show_chest_tracker=" << (settingsPanel_.showChestTracker_ ? 1 : 0) << "\n";
out << "separate_bags=" << (settingsPanel_.pendingSeparateBags ? 1 : 0) << "\n";
out << "show_keyring=" << (settingsPanel_.pendingShowKeyring ? 1 : 0) << "\n";
out << "bag_scale=" << settingsPanel_.pendingBagScale << "\n";
@@ -1616,6 +1695,8 @@ void GameScreen::loadSettings() {
settingsPanel_.showCooldownTracker_ = (std::stoi(val) != 0);
} else if (key == "show_rare_tracker") {
settingsPanel_.showRareTracker_ = (std::stoi(val) != 0);
} else if (key == "show_chest_tracker") {
settingsPanel_.showChestTracker_ = (std::stoi(val) != 0);
} else if (key == "separate_bags") {
settingsPanel_.pendingSeparateBags = (std::stoi(val) != 0);
inventoryScreen.setSeparateBags(settingsPanel_.pendingSeparateBags);

View File

@@ -175,7 +175,13 @@ if (ImGui::Checkbox("Show Rare Tracker", &showRareTracker_)) {
saveCallback();
}
ImGui::SameLine();
ImGui::TextDisabled("(mark nearby spawned rares on the world map)");
ImGui::TextDisabled("(mark nearby spawned rares on the world map and minimap)");
if (ImGui::Checkbox("Show Chest Tracker", &showChestTracker_)) {
saveCallback();
}
ImGui::SameLine();
ImGui::TextDisabled("(mark nearby spawned loot chests on the world map and minimap)");
ImGui::Spacing();
ImGui::SeparatorText("Screen Effects");

View File

@@ -164,6 +164,27 @@ TEST_CASE("A superseded rank resolves to the highest known rank", "[spell][rank]
REQUIRE(resolveHighestKnownRank(11549, known, dbc.lookup()) == 6192);
}
TEST_CASE("Rank resolution tolerates a lookup adapter with reused storage", "[spell][rank]") {
const std::unordered_map<uint32_t, SpellRankInfo> entries{
{6673, {"Battle Shout", "Rank 1"}},
{5242, {"Battle Shout", "Rank 2"}},
{6192, {"Battle Shout", "Rank 3"}},
{133, {"Fireball", "Rank 14"}},
};
SpellRankInfo scratch;
auto reusedLookup = [&entries, &scratch](uint32_t id) -> const SpellRankInfo* {
auto it = entries.find(id);
if (it == entries.end()) return nullptr;
scratch = it->second;
return &scratch;
};
// The production Spell.dbc adapter reuses scratch storage. Looking up Fireball
// must not overwrite the requested Battle Shout name and win merely by rank.
const std::unordered_set<uint32_t> known{5242, 6192, 133};
REQUIRE(resolveHighestKnownRank(6673, known, reusedLookup) == 6192);
}
TEST_CASE("A known spell is left alone", "[spell][rank]") {
FakeSpellDbc dbc;
dbc.add(772, "Rend", "Rank 1");