mirror of
https://github.com/Kelsidavis/WoWee.git
synced 2026-08-02 10:54:38 -04:00
Draw undiscovered flight masters as green diamonds so players can see where flight paths exist before visiting them, keeping discovered nodes gold. Filter to the player's own faction (own-faction or neutral both- faction nodes only) so the opposite faction's flight masters and non-flight boat/zeppelin nodes are not shown. Adds GameHandler:: isPlayerAlliance() helper.
2054 lines
95 KiB
C++
2054 lines
95 KiB
C++
#include "ui/game_screen.hpp"
|
|
#include "ui/ui_colors.hpp"
|
|
#include "ui/ui_helpers.hpp"
|
|
#include "rendering/vk_context.hpp"
|
|
#include "core/application.hpp"
|
|
#include "core/appearance_composer.hpp"
|
|
#include "addons/addon_manager.hpp"
|
|
#include "core/coordinates.hpp"
|
|
#include "core/input.hpp"
|
|
#include "rendering/renderer.hpp"
|
|
#include "rendering/post_process_pipeline.hpp"
|
|
#include "rendering/animation_controller.hpp"
|
|
#include "rendering/wmo_renderer.hpp"
|
|
#include "rendering/terrain_manager.hpp"
|
|
#include "rendering/minimap.hpp"
|
|
#include "rendering/world_map.hpp"
|
|
#include "rendering/character_renderer.hpp"
|
|
#include "rendering/camera.hpp"
|
|
#include "rendering/camera_controller.hpp"
|
|
#include "audio/audio_coordinator.hpp"
|
|
#include "audio/audio_engine.hpp"
|
|
#include "audio/music_manager.hpp"
|
|
#include "game/zone_manager.hpp"
|
|
#include "audio/footstep_manager.hpp"
|
|
#include "audio/activity_sound_manager.hpp"
|
|
#include "audio/mount_sound_manager.hpp"
|
|
#include "audio/npc_voice_manager.hpp"
|
|
#include "audio/ambient_sound_manager.hpp"
|
|
#include "audio/ui_sound_manager.hpp"
|
|
#include "audio/combat_sound_manager.hpp"
|
|
#include "audio/spell_sound_manager.hpp"
|
|
#include "audio/movement_sound_manager.hpp"
|
|
#include "pipeline/asset_manager.hpp"
|
|
#include "pipeline/dbc_loader.hpp"
|
|
#include "pipeline/dbc_layout.hpp"
|
|
|
|
#include "game/expansion_profile.hpp"
|
|
#include "game/character.hpp"
|
|
#include "core/logger.hpp"
|
|
#include <imgui.h>
|
|
#include <imgui_internal.h>
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <cstring>
|
|
#include <sstream>
|
|
#include <cstdlib>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <cctype>
|
|
#include <chrono>
|
|
#include <ctime>
|
|
#include <limits>
|
|
|
|
#include <unordered_set>
|
|
|
|
namespace {
|
|
using namespace wowee::ui::colors;
|
|
using namespace wowee::ui::helpers;
|
|
constexpr auto& kColorGreen = kGreen;
|
|
constexpr auto& kColorGray = kGray;
|
|
|
|
bool raySphereIntersect(const wowee::rendering::Ray& ray, const glm::vec3& center, float radius, float& tOut) {
|
|
glm::vec3 oc = ray.origin - center;
|
|
float b = glm::dot(oc, ray.direction);
|
|
float c = glm::dot(oc, oc) - radius * radius;
|
|
float discriminant = b * b - c;
|
|
if (discriminant < 0.0f) return false;
|
|
float t = -b - std::sqrt(discriminant);
|
|
if (t < 0.0f) t = -b + std::sqrt(discriminant);
|
|
if (t < 0.0f) return false;
|
|
tOut = t;
|
|
return true;
|
|
}
|
|
|
|
std::string getEntityName(const std::shared_ptr<wowee::game::Entity>& entity) {
|
|
if (entity->getType() == wowee::game::ObjectType::PLAYER) {
|
|
auto player = std::static_pointer_cast<wowee::game::Player>(entity);
|
|
if (!player->getName().empty()) return player->getName();
|
|
} else if (entity->getType() == wowee::game::ObjectType::UNIT) {
|
|
auto unit = std::static_pointer_cast<wowee::game::Unit>(entity);
|
|
if (!unit->getName().empty()) return unit->getName();
|
|
} else if (entity->getType() == wowee::game::ObjectType::GAMEOBJECT) {
|
|
auto go = std::static_pointer_cast<wowee::game::GameObject>(entity);
|
|
if (!go->getName().empty()) return go->getName();
|
|
}
|
|
return "Unknown";
|
|
}
|
|
|
|
}
|
|
|
|
namespace wowee { namespace ui {
|
|
|
|
void GameScreen::updateCharacterGeosets(game::Inventory& inventory) {
|
|
auto& app = core::Application::getInstance();
|
|
auto* renderer = app.getRenderer();
|
|
if (!renderer) return;
|
|
|
|
uint32_t instanceId = renderer->getCharacterInstanceId();
|
|
if (instanceId == 0) return;
|
|
|
|
auto* charRenderer = renderer->getCharacterRenderer();
|
|
if (!charRenderer) return;
|
|
|
|
auto* assetManager = app.getAssetManager();
|
|
|
|
// Load ItemDisplayInfo.dbc for geosetGroup lookup
|
|
std::shared_ptr<pipeline::DBCFile> displayInfoDbc;
|
|
if (assetManager) {
|
|
displayInfoDbc = assetManager->loadDBC("ItemDisplayInfo.dbc");
|
|
}
|
|
const auto* idiL = pipeline::getActiveDBCLayout()
|
|
? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr;
|
|
const uint32_t geosetGroup1Field = idiL ? (*idiL)["GeosetGroup1"] : 7;
|
|
const uint32_t geosetGroup3Field = idiL ? (*idiL)["GeosetGroup3"] : 9;
|
|
|
|
auto getGeosetGroup = [&](uint32_t displayInfoId, uint32_t fieldIdx) -> uint32_t {
|
|
if (!displayInfoDbc || displayInfoId == 0) return 0;
|
|
int32_t recIdx = displayInfoDbc->findRecordById(displayInfoId);
|
|
if (recIdx < 0) return 0;
|
|
return displayInfoDbc->getUInt32(static_cast<uint32_t>(recIdx), fieldIdx);
|
|
};
|
|
|
|
// Helper: find first equipped item matching inventoryType, return its displayInfoId
|
|
auto findEquippedDisplayId = [&](std::initializer_list<uint8_t> types) -> uint32_t {
|
|
for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) {
|
|
const auto& slot = inventory.getEquipSlot(static_cast<game::EquipSlot>(s));
|
|
if (!slot.empty()) {
|
|
for (uint8_t t : types) {
|
|
if (slot.item.inventoryType == t)
|
|
return slot.item.displayInfoId;
|
|
}
|
|
}
|
|
}
|
|
return 0;
|
|
};
|
|
|
|
// Helper: check if any equipment slot has the given inventoryType
|
|
auto hasEquippedType = [&](std::initializer_list<uint8_t> types) -> bool {
|
|
for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) {
|
|
const auto& slot = inventory.getEquipSlot(static_cast<game::EquipSlot>(s));
|
|
if (!slot.empty()) {
|
|
for (uint8_t t : types) {
|
|
if (slot.item.inventoryType == t) return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
std::unordered_set<uint16_t> geosets;
|
|
if (appearanceComposer_) {
|
|
if (auto* gh = app.getGameHandler()) {
|
|
if (const auto* ch = gh->getActiveCharacter()) {
|
|
const uint8_t raceId = static_cast<uint8_t>(ch->race);
|
|
const uint8_t sexId = static_cast<uint8_t>(ch->gender);
|
|
const uint8_t hairStyleId = static_cast<uint8_t>((ch->appearanceBytes >> 16) & 0xFF);
|
|
const uint8_t facialId = ch->facialFeatures;
|
|
geosets = appearanceComposer_->buildDefaultPlayerGeosets(raceId, sexId, hairStyleId, facialId);
|
|
}
|
|
}
|
|
}
|
|
if (geosets.empty()) {
|
|
geosets.insert(0);
|
|
geosets.insert(101);
|
|
geosets.insert(201);
|
|
geosets.insert(301);
|
|
geosets.insert(702);
|
|
geosets.insert(2002);
|
|
}
|
|
|
|
auto eraseGroup = [&](uint16_t group) {
|
|
for (auto it = geosets.begin(); it != geosets.end();) {
|
|
if ((*it / 100) == group) it = geosets.erase(it);
|
|
else ++it;
|
|
}
|
|
};
|
|
|
|
// Build set of geoset IDs present in the model for validation.
|
|
// Races like Gnome (no 501) and Tauren (only 505) need fallback.
|
|
std::unordered_set<uint16_t> modelGeosets;
|
|
if (const auto* modelData = charRenderer->getInstanceModelData(instanceId)) {
|
|
for (const auto& batch : modelData->batches) {
|
|
modelGeosets.insert(batch.submeshId);
|
|
}
|
|
}
|
|
|
|
auto pickGeoset = [&](uint16_t preferred, uint16_t fallback) -> uint16_t {
|
|
if (modelGeosets.empty()) return preferred;
|
|
if (preferred != 0 && modelGeosets.count(preferred) > 0) return preferred;
|
|
if (fallback != 0 && modelGeosets.count(fallback) > 0) return fallback;
|
|
return preferred;
|
|
};
|
|
|
|
auto lowestInGroup = [&](uint16_t group) -> uint16_t {
|
|
uint16_t best = 0;
|
|
for (uint16_t g : modelGeosets) {
|
|
if (g / 100 == group && (best == 0 || g < best)) best = g;
|
|
}
|
|
return best;
|
|
};
|
|
|
|
eraseGroup(4);
|
|
eraseGroup(5);
|
|
eraseGroup(8);
|
|
eraseGroup(13);
|
|
eraseGroup(15);
|
|
eraseGroup(12);
|
|
|
|
// CharGeosets mapping (verified via vertex bounding boxes):
|
|
// Group 4 (401+) = GLOVES (forearm area, Z~1.1-1.4)
|
|
// Group 5 (501+) = BOOTS (shin area, Z~0.1-0.6)
|
|
// Group 8 (801+) = WRISTBANDS/SLEEVES (controlled by chest armor)
|
|
// Group 9 (901+) = KNEEPADS
|
|
// Group 13 (1301+) = TROUSERS/PANTS
|
|
// Group 15 (1501+) = CAPE/CLOAK
|
|
// Group 20 (2002) = FEET
|
|
|
|
// Gloves: inventoryType 10 → group 4 (forearms)
|
|
{
|
|
uint32_t did = findEquippedDisplayId({10});
|
|
uint32_t gg = getGeosetGroup(did, geosetGroup1Field);
|
|
geosets.insert(pickGeoset(static_cast<uint16_t>(gg > 0 ? 401 + gg : 401), lowestInGroup(4)));
|
|
}
|
|
|
|
// Boots: inventoryType 8 → group 5 (shins/lower legs)
|
|
{
|
|
uint32_t did = findEquippedDisplayId({8});
|
|
uint32_t gg = getGeosetGroup(did, geosetGroup1Field);
|
|
uint16_t selectedShin = pickGeoset(static_cast<uint16_t>(gg > 0 ? 501 + gg : core::kGeosetBareShins), lowestInGroup(5));
|
|
geosets.insert(selectedShin);
|
|
}
|
|
|
|
// Chest/Shirt: inventoryType 4 (shirt), 5 (chest), 20 (robe)
|
|
// Controls group 8 (wristbands/sleeve length): 801=bare wrists, 802+=sleeve styles
|
|
// Also controls group 13 (trousers) via GeosetGroup[2] for robes
|
|
{
|
|
uint32_t did = findEquippedDisplayId({4, 5, 20});
|
|
uint32_t gg = getGeosetGroup(did, geosetGroup1Field);
|
|
geosets.insert(static_cast<uint16_t>(gg > 0 ? 801 + gg : 801));
|
|
uint32_t gg3 = getGeosetGroup(did, geosetGroup3Field);
|
|
if (gg3 > 0) {
|
|
geosets.insert(static_cast<uint16_t>(1301 + gg3));
|
|
}
|
|
}
|
|
|
|
// Kneepads: group 9 (always default 902)
|
|
geosets.insert(902);
|
|
|
|
// Legs/Pants: inventoryType 7 → group 13 (trousers/thighs)
|
|
// 1301=bare legs, 1302+=pant/kilt styles
|
|
{
|
|
uint32_t did = findEquippedDisplayId({7});
|
|
uint32_t gg = getGeosetGroup(did, geosetGroup1Field);
|
|
// Only add if robe hasn't already set a kilt geoset
|
|
if (geosets.count(1302) == 0 && geosets.count(1303) == 0) {
|
|
geosets.insert(static_cast<uint16_t>(gg > 0 ? 1301 + gg : 1301));
|
|
}
|
|
}
|
|
|
|
// Back/Cloak: inventoryType 16 → group 15
|
|
geosets.insert(hasEquippedType({16}) ? 1502 : 1501);
|
|
|
|
// Tabard: inventoryType 19 → group 12
|
|
if (hasEquippedType({19})) {
|
|
geosets.insert(1201);
|
|
}
|
|
|
|
charRenderer->setActiveGeosets(instanceId, geosets);
|
|
}
|
|
|
|
void GameScreen::updateCharacterTextures(game::Inventory& inventory) {
|
|
auto& app = core::Application::getInstance();
|
|
auto* renderer = app.getRenderer();
|
|
if (!renderer) return;
|
|
|
|
auto* charRenderer = renderer->getCharacterRenderer();
|
|
if (!charRenderer) return;
|
|
|
|
auto* assetManager = app.getAssetManager();
|
|
if (!assetManager) return;
|
|
|
|
const auto& bodySkinPath = app.getBodySkinPath();
|
|
const auto& underwearPaths = app.getUnderwearPaths();
|
|
uint32_t skinSlot = app.getSkinTextureSlotIndex();
|
|
|
|
if (bodySkinPath.empty()) return;
|
|
|
|
// Component directory names indexed by region
|
|
static constexpr const char* componentDirs[] = {
|
|
"ArmUpperTexture", // 0
|
|
"ArmLowerTexture", // 1
|
|
"HandTexture", // 2
|
|
"TorsoUpperTexture", // 3
|
|
"TorsoLowerTexture", // 4
|
|
"LegUpperTexture", // 5
|
|
"LegLowerTexture", // 6
|
|
"FootTexture", // 7
|
|
};
|
|
|
|
// Load ItemDisplayInfo.dbc
|
|
auto displayInfoDbc = assetManager->loadDBC("ItemDisplayInfo.dbc");
|
|
if (!displayInfoDbc) return;
|
|
const auto* idiL = pipeline::getActiveDBCLayout()
|
|
? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr;
|
|
uint32_t texRegionFields[8];
|
|
pipeline::getItemDisplayInfoTextureFields(*displayInfoDbc, idiL, texRegionFields);
|
|
|
|
// Collect equipment texture regions from all equipped items
|
|
std::vector<std::pair<int, std::string>> regionLayers;
|
|
|
|
for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) {
|
|
const auto& slot = inventory.getEquipSlot(static_cast<game::EquipSlot>(s));
|
|
if (slot.empty() || slot.item.displayInfoId == 0) continue;
|
|
|
|
int32_t recIdx = displayInfoDbc->findRecordById(slot.item.displayInfoId);
|
|
if (recIdx < 0) continue;
|
|
|
|
for (int region = 0; region < 8; region++) {
|
|
std::string texName = displayInfoDbc->getString(
|
|
static_cast<uint32_t>(recIdx), texRegionFields[region]);
|
|
if (texName.empty()) continue;
|
|
|
|
// Actual MPQ files have a gender suffix: _M (male), _F (female), _U (unisex)
|
|
// Try gender-specific first, then unisex fallback
|
|
std::string base = "Item\\TextureComponents\\" +
|
|
std::string(componentDirs[region]) + "\\" + texName;
|
|
// Determine gender suffix from active character
|
|
bool isFemale = false;
|
|
if (auto* gh = app.getGameHandler()) {
|
|
if (auto* ch = gh->getActiveCharacter()) {
|
|
isFemale = (ch->gender == game::Gender::FEMALE) ||
|
|
(ch->gender == game::Gender::NONBINARY && ch->useFemaleModel);
|
|
}
|
|
}
|
|
std::string genderPath = base + (isFemale ? "_F.blp" : "_M.blp");
|
|
std::string unisexPath = base + "_U.blp";
|
|
std::string fullPath;
|
|
if (assetManager->fileExists(genderPath)) {
|
|
fullPath = genderPath;
|
|
} else if (assetManager->fileExists(unisexPath)) {
|
|
fullPath = unisexPath;
|
|
} else if (assetManager->fileExists(base + ".blp")) {
|
|
fullPath = base + ".blp";
|
|
} else {
|
|
continue;
|
|
}
|
|
regionLayers.emplace_back(region, fullPath);
|
|
}
|
|
}
|
|
|
|
// Re-composite: base skin + underwear + equipment regions
|
|
// Clear composite cache first to prevent stale textures from being reused
|
|
charRenderer->clearCompositeCache();
|
|
// Use per-instance texture override (not model-level) to avoid deleting cached composites.
|
|
uint32_t instanceId = renderer->getCharacterInstanceId();
|
|
auto* newTex = charRenderer->compositeWithRegions(bodySkinPath, underwearPaths, regionLayers);
|
|
if (newTex != nullptr && instanceId != 0) {
|
|
charRenderer->setTextureSlotOverride(instanceId, static_cast<uint16_t>(skinSlot), newTex);
|
|
}
|
|
|
|
// Cloak cape texture — separate from skin atlas, uses texture slot type-2 (Object Skin)
|
|
uint32_t cloakSlot = app.getCloakTextureSlotIndex();
|
|
if (cloakSlot > 0 && instanceId != 0) {
|
|
// Find equipped cloak (inventoryType 16)
|
|
uint32_t cloakDisplayId = 0;
|
|
for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) {
|
|
const auto& slot = inventory.getEquipSlot(static_cast<game::EquipSlot>(s));
|
|
if (!slot.empty() && slot.item.inventoryType == 16 && slot.item.displayInfoId != 0) {
|
|
cloakDisplayId = slot.item.displayInfoId;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (cloakDisplayId > 0) {
|
|
int32_t recIdx = displayInfoDbc->findRecordById(cloakDisplayId);
|
|
if (recIdx >= 0) {
|
|
// DBC field 3 = modelTexture_1 (cape texture name)
|
|
const auto* dispL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr;
|
|
std::string capeName = displayInfoDbc->getString(static_cast<uint32_t>(recIdx), dispL ? (*dispL)["LeftModelTexture"] : 3);
|
|
if (!capeName.empty()) {
|
|
std::string capePath = "Item\\ObjectComponents\\Cape\\" + capeName + ".blp";
|
|
auto* capeTex = charRenderer->loadTexture(capePath);
|
|
if (capeTex != nullptr) {
|
|
charRenderer->setTextureSlotOverride(instanceId, static_cast<uint16_t>(cloakSlot), capeTex);
|
|
LOG_INFO("Cloak texture applied: ", capePath);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// No cloak equipped — clear override so model's default (white) shows
|
|
charRenderer->clearTextureSlotOverride(instanceId, static_cast<uint16_t>(cloakSlot));
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// World Map
|
|
// ============================================================
|
|
|
|
void GameScreen::renderWorldMap(game::GameHandler& gameHandler) {
|
|
auto& app = core::Application::getInstance();
|
|
auto* renderer = app.getRenderer();
|
|
if (!renderer) return;
|
|
|
|
auto* wm = renderer->getWorldMap();
|
|
if (!wm) return;
|
|
|
|
// Flight master window drives the world map's flight-map (taxi selection)
|
|
// mode: opening SMSG_SHOWTAXINODES opens the map, activating a flight or
|
|
// closing the gossip closes it. A user-dismissed map (Escape / X) closes
|
|
// the flight master window through the onClose handler.
|
|
const bool taxiWanted = gameHandler.isTaxiWindowOpen();
|
|
if (taxiWanted && !wm->isTaxiMapOpen()) {
|
|
auto* gh = &gameHandler;
|
|
wm->openTaxiMap(
|
|
[gh](uint32_t dest) { return gh->getTaxiRouteTo(dest); },
|
|
[gh](uint32_t dest) { gh->activateTaxi(dest); },
|
|
[gh]() { gh->closeTaxi(); });
|
|
} else if (!taxiWanted && wm->isTaxiMapOpen()) {
|
|
wm->closeTaxiMap();
|
|
}
|
|
|
|
if (!showWorldMap_ && !wm->isTaxiMapOpen()) return;
|
|
|
|
// Keep map name in sync with minimap's map name
|
|
auto* minimap = renderer->getMinimap();
|
|
if (minimap) {
|
|
wm->setMapName(minimap->getMapName());
|
|
}
|
|
wm->setServerExplorationMask(
|
|
gameHandler.getPlayerExploredZoneMasks(),
|
|
gameHandler.hasPlayerExploredZoneMasks());
|
|
|
|
// Party member dots on world map
|
|
{
|
|
std::vector<rendering::WorldMapPartyDot> dots;
|
|
if (gameHandler.isInGroup()) {
|
|
const auto& partyData = gameHandler.getPartyData();
|
|
for (const auto& member : partyData.members) {
|
|
if (!member.isOnline || !member.hasPartyStats) continue;
|
|
if (member.posX == 0 && member.posY == 0) continue;
|
|
// posY → canonical X (north), posX → canonical Y (west)
|
|
float wowX = static_cast<float>(member.posY);
|
|
float wowY = static_cast<float>(member.posX);
|
|
glm::vec3 rpos = core::coords::canonicalToRender(glm::vec3(wowX, wowY, 0.0f));
|
|
auto ent = gameHandler.getEntityManager().getEntity(member.guid);
|
|
uint8_t cid = entityClassId(ent.get());
|
|
ImU32 col = (cid != 0)
|
|
? classColorU32(cid, 230)
|
|
: (member.guid == partyData.leaderGuid
|
|
? IM_COL32(255, 210, 0, 230)
|
|
: IM_COL32(100, 180, 255, 230));
|
|
dots.push_back({ rpos, col, member.name });
|
|
}
|
|
}
|
|
wm->setPartyDots(std::move(dots));
|
|
}
|
|
|
|
// Taxi node markers on world map
|
|
{
|
|
std::vector<rendering::WorldMapTaxiNode> taxiNodes;
|
|
const auto& nodes = gameHandler.getTaxiNodes();
|
|
uint32_t currentTaxiNode = gameHandler.getTaxiCurrentNode();
|
|
const bool playerAlliance = gameHandler.isPlayerAlliance();
|
|
taxiNodes.reserve(nodes.size());
|
|
for (const auto& [id, node] : nodes) {
|
|
const bool known = gameHandler.isKnownTaxiNode(id);
|
|
// Undiscovered nodes are shown so the player can see where flight
|
|
// paths exist, but only ones their faction can actually use. A node's
|
|
// faction is inferred from which taxi mount TaxiNodes.dbc lists:
|
|
// own-faction mount → show; both mounts → neutral flight point, show;
|
|
// opposite-faction-only OR no mount at all (boat/zeppelin/script
|
|
// nodes) → hide. Known nodes are always shown.
|
|
if (!known) {
|
|
const bool hasAlliance = node.mountDisplayIdAlliance != 0;
|
|
const bool hasHorde = node.mountDisplayIdHorde != 0;
|
|
const bool bothFactions = hasAlliance && hasHorde; // neutral hub
|
|
const bool ownFaction = playerAlliance ? hasAlliance : hasHorde;
|
|
if (!bothFactions && !ownFaction) continue;
|
|
}
|
|
rendering::WorldMapTaxiNode wtn;
|
|
wtn.id = node.id;
|
|
wtn.mapId = node.mapId;
|
|
// TaxiNodes.dbc stores server/wire-order coordinates — convert to
|
|
// canonical (X=north, Y=west) like the taxi flight path code does,
|
|
// or the markers land transposed on the map.
|
|
glm::vec3 canonical = core::coords::serverToCanonical(
|
|
glm::vec3(node.x, node.y, node.z));
|
|
wtn.wowX = canonical.x;
|
|
wtn.wowY = canonical.y;
|
|
wtn.wowZ = canonical.z;
|
|
wtn.name = node.name;
|
|
wtn.known = known;
|
|
wtn.costCopper = gameHandler.getTaxiCostTo(id);
|
|
wtn.current = (id == currentTaxiNode);
|
|
wtn.reachable = gameHandler.hasTaxiRouteTo(id);
|
|
taxiNodes.push_back(std::move(wtn));
|
|
}
|
|
wm->setTaxiNodes(std::move(taxiNodes));
|
|
}
|
|
|
|
// Quest objective and quest-giver markers on the world map.
|
|
{
|
|
std::vector<rendering::WorldMap::QuestPoi> qpois;
|
|
const auto& questStatuses = gameHandler.getNpcQuestStatuses();
|
|
// Add authoritative NPC statuses first. Some servers also emit a
|
|
// generic POI at the NPC's position; that duplicate is filtered below
|
|
// so it cannot leave a teal objective circle on a quest giver.
|
|
for (const auto& [guid, status] : questStatuses) {
|
|
auto entity = gameHandler.getEntityManager().getEntity(guid);
|
|
if (!entity || entity->getType() != game::ObjectType::UNIT) continue;
|
|
|
|
rendering::WorldMap::QuestPoi qp;
|
|
qp.wowX = entity->getX();
|
|
qp.wowY = entity->getY();
|
|
qp.name = std::static_pointer_cast<game::Unit>(entity)->getName();
|
|
switch (status) {
|
|
case game::QuestGiverStatus::AVAILABLE:
|
|
qp.kind = rendering::WorldMap::QuestPoi::Kind::AVAILABLE;
|
|
break;
|
|
case game::QuestGiverStatus::AVAILABLE_LOW:
|
|
qp.kind = rendering::WorldMap::QuestPoi::Kind::AVAILABLE_LOW;
|
|
break;
|
|
case game::QuestGiverStatus::REWARD:
|
|
case game::QuestGiverStatus::REWARD_REP:
|
|
qp.kind = rendering::WorldMap::QuestPoi::Kind::REWARD;
|
|
break;
|
|
case game::QuestGiverStatus::INCOMPLETE:
|
|
qp.kind = rendering::WorldMap::QuestPoi::Kind::INCOMPLETE;
|
|
break;
|
|
default:
|
|
continue;
|
|
}
|
|
qpois.push_back(std::move(qp));
|
|
}
|
|
|
|
constexpr float kQuestGiverPoiMergeDistance = 15.0f;
|
|
constexpr float kQuestGiverPoiMergeDistanceSq =
|
|
kQuestGiverPoiMergeDistance * kQuestGiverPoiMergeDistance;
|
|
for (const auto& poi : gameHandler.getGossipPois()) {
|
|
// Keep ordinary gossip navigation POIs, but only include quest
|
|
// objectives/endpoints explicitly enabled from the tracker.
|
|
if (poi.questObjectiveIndex != -2 &&
|
|
!gameHandler.isQuestShownOnMap(poi.data)) {
|
|
continue;
|
|
}
|
|
bool duplicatesQuestGiver = false;
|
|
for (const auto& existing : qpois) {
|
|
if (existing.kind == rendering::WorldMap::QuestPoi::Kind::OBJECTIVE) continue;
|
|
const float dx = existing.wowX - poi.x;
|
|
const float dy = existing.wowY - poi.y;
|
|
if (dx * dx + dy * dy <= kQuestGiverPoiMergeDistanceSq) {
|
|
duplicatesQuestGiver = true;
|
|
break;
|
|
}
|
|
}
|
|
if (duplicatesQuestGiver) continue;
|
|
|
|
rendering::WorldMap::QuestPoi qp;
|
|
qp.wowX = poi.x;
|
|
qp.wowY = poi.y;
|
|
qp.name = poi.name;
|
|
if (poi.questObjectiveIndex == -1) {
|
|
// A quest POI with no objective index is the quest endpoint,
|
|
// not an objective area. Completed quests use a yellow ?,
|
|
// while in-progress endpoints use a gray ?.
|
|
qp.kind = rendering::WorldMap::QuestPoi::Kind::INCOMPLETE;
|
|
for (const auto& quest : gameHandler.getQuestLog()) {
|
|
if (quest.questId != poi.data) continue;
|
|
if (quest.complete) {
|
|
qp.kind = rendering::WorldMap::QuestPoi::Kind::REWARD;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
qpois.push_back(std::move(qp));
|
|
}
|
|
wm->setQuestPois(std::move(qpois));
|
|
}
|
|
|
|
// Corpse marker: show skull X on world map when ghost with unclaimed corpse
|
|
{
|
|
float corpseCanX = 0.0f, corpseCanY = 0.0f;
|
|
bool ghostWithCorpse = gameHandler.isPlayerGhost() &&
|
|
gameHandler.getCorpseCanonicalPos(corpseCanX, corpseCanY);
|
|
glm::vec3 corpseRender = ghostWithCorpse
|
|
? core::coords::canonicalToRender(glm::vec3(corpseCanX, corpseCanY, 0.0f))
|
|
: glm::vec3{};
|
|
wm->setCorpsePos(ghostWithCorpse, corpseRender);
|
|
}
|
|
|
|
// Rare tracker: mark every spawned rare / rare-elite the client currently has loaded.
|
|
// Entities only exist while near the player, so a marker means that rare is out now.
|
|
// Opt-in via the Interface setting; when off, feed an empty list so markers clear.
|
|
{
|
|
std::vector<rendering::WorldMapRareMark> rares;
|
|
if (settingsPanel_.showRareTracker_)
|
|
for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) {
|
|
if (!entity || entity->getType() != game::ObjectType::UNIT) continue;
|
|
auto unit = std::static_pointer_cast<game::Unit>(entity);
|
|
const int rank = gameHandler.getCreatureRank(unit->getEntry());
|
|
if (rank != 2 && rank != 4) continue; // 2 = Rare Elite, 4 = Rare
|
|
if (unit->getHealth() == 0) continue; // skip dead/looted rares
|
|
rendering::WorldMapRareMark m;
|
|
m.renderPos = core::coords::canonicalToRender(
|
|
glm::vec3(unit->getX(), unit->getY(), unit->getZ()));
|
|
m.name = unit->getName();
|
|
m.rank = rank;
|
|
rares.push_back(std::move(m));
|
|
}
|
|
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();
|
|
int screenW = window ? window->getWidth() : 1280;
|
|
int screenH = window ? window->getHeight() : 720;
|
|
wm->render(playerPos, screenW, screenH, playerYaw);
|
|
|
|
// Sync showWorldMap_ if the map closed itself (e.g. ESC key inside the overlay).
|
|
if (!wm->isOpen()) showWorldMap_ = false;
|
|
}
|
|
|
|
// ============================================================
|
|
// Action Bar
|
|
// ============================================================
|
|
|
|
VkDescriptorSet GameScreen::getSpellIcon(uint32_t spellId, pipeline::AssetManager* am) {
|
|
if (spellId == 0 || !am) return VK_NULL_HANDLE;
|
|
|
|
// Check cache first
|
|
auto cit = spellIconCache_.find(spellId);
|
|
if (cit != spellIconCache_.end()) return cit->second;
|
|
|
|
// Lazy-load SpellIcon.dbc and Spell.dbc icon IDs
|
|
if (!spellIconDbLoaded_) {
|
|
spellIconDbLoaded_ = true;
|
|
|
|
// Load SpellIcon.dbc: field 0 = ID, field 1 = icon path
|
|
auto iconDbc = am->loadDBC("SpellIcon.dbc");
|
|
const auto* iconL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("SpellIcon") : nullptr;
|
|
if (iconDbc && iconDbc->isLoaded()) {
|
|
for (uint32_t i = 0; i < iconDbc->getRecordCount(); i++) {
|
|
uint32_t id = iconDbc->getUInt32(i, iconL ? (*iconL)["ID"] : 0);
|
|
std::string path = iconDbc->getString(i, iconL ? (*iconL)["Path"] : 1);
|
|
if (!path.empty() && id > 0) {
|
|
spellIconPaths_[id] = path;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Load Spell.dbc: SpellIconID field
|
|
auto spellDbc = am->loadDBC("Spell.dbc");
|
|
const auto* spellL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Spell") : nullptr;
|
|
if (spellDbc && spellDbc->isLoaded()) {
|
|
uint32_t fieldCount = spellDbc->getFieldCount();
|
|
// Helper to load icons for a given field layout
|
|
auto tryLoadIcons = [&](uint32_t idField, uint32_t iconField) {
|
|
spellIconIds_.clear();
|
|
if (iconField >= fieldCount) return;
|
|
for (uint32_t i = 0; i < spellDbc->getRecordCount(); i++) {
|
|
uint32_t id = spellDbc->getUInt32(i, idField);
|
|
uint32_t iconId = spellDbc->getUInt32(i, iconField);
|
|
if (id > 0 && iconId > 0) {
|
|
spellIconIds_[id] = iconId;
|
|
}
|
|
}
|
|
};
|
|
|
|
// Use the active expansion layout when its fields are present in
|
|
// the loaded DBC. TBC/WotLK/Classic place IconID in different
|
|
// columns, so reading the WotLK default for every client leaves
|
|
// action bars and spell UI without icons.
|
|
uint32_t iconField = 133; // WotLK default
|
|
uint32_t idField = 0;
|
|
if (spellL) {
|
|
try {
|
|
uint32_t layoutId = (*spellL)["ID"];
|
|
uint32_t layoutIcon = (*spellL)["IconID"];
|
|
if (layoutId < fieldCount && layoutIcon < fieldCount) {
|
|
iconField = layoutIcon;
|
|
idField = layoutId;
|
|
}
|
|
} catch (...) {}
|
|
}
|
|
tryLoadIcons(idField, iconField);
|
|
}
|
|
}
|
|
|
|
// Rate-limit GPU uploads per frame to prevent stalls when many icons are uncached
|
|
// (e.g., first login, after loading screen, or many new auras appearing at once).
|
|
static int gsLoadsThisFrame = 0;
|
|
static int gsLastImGuiFrame = -1;
|
|
int gsCurFrame = ImGui::GetFrameCount();
|
|
if (gsCurFrame != gsLastImGuiFrame) { gsLoadsThisFrame = 0; gsLastImGuiFrame = gsCurFrame; }
|
|
if (gsLoadsThisFrame >= 4) return VK_NULL_HANDLE; // defer — do NOT cache null here
|
|
|
|
// Look up spellId -> SpellIconID -> icon path
|
|
auto iit = spellIconIds_.find(spellId);
|
|
if (iit == spellIconIds_.end()) {
|
|
spellIconCache_[spellId] = VK_NULL_HANDLE;
|
|
return VK_NULL_HANDLE;
|
|
}
|
|
|
|
auto pit = spellIconPaths_.find(iit->second);
|
|
if (pit == spellIconPaths_.end()) {
|
|
spellIconCache_[spellId] = VK_NULL_HANDLE;
|
|
return VK_NULL_HANDLE;
|
|
}
|
|
|
|
// Path from DBC has no extension — append .blp
|
|
std::string iconPath = pit->second + ".blp";
|
|
auto blpData = am->readFile(iconPath);
|
|
if (blpData.empty()) {
|
|
spellIconCache_[spellId] = VK_NULL_HANDLE;
|
|
return VK_NULL_HANDLE;
|
|
}
|
|
|
|
auto image = pipeline::BLPLoader::load(blpData);
|
|
if (!image.isValid()) {
|
|
spellIconCache_[spellId] = VK_NULL_HANDLE;
|
|
return VK_NULL_HANDLE;
|
|
}
|
|
|
|
// Upload to Vulkan via VkContext
|
|
auto* window = services_.window;
|
|
auto* vkCtx = window ? window->getVkContext() : nullptr;
|
|
if (!vkCtx) {
|
|
spellIconCache_[spellId] = VK_NULL_HANDLE;
|
|
return VK_NULL_HANDLE;
|
|
}
|
|
|
|
++gsLoadsThisFrame;
|
|
VkDescriptorSet ds = vkCtx->uploadImGuiTexture(image.data.data(), image.width, image.height);
|
|
spellIconCache_[spellId] = ds;
|
|
return ds;
|
|
}
|
|
|
|
// ============================================================
|
|
// Mirror Timers (breath / fatigue / feign death)
|
|
// ============================================================
|
|
|
|
void GameScreen::renderMirrorTimers(game::GameHandler& gameHandler) {
|
|
ImVec2 displaySize = ImGui::GetIO().DisplaySize;
|
|
float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f;
|
|
float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f;
|
|
|
|
static constexpr struct { const char* label; ImVec4 color; } kTimerInfo[3] = {
|
|
{ "Fatigue", ImVec4(0.8f, 0.4f, 0.1f, 1.0f) },
|
|
{ "Breath", ImVec4(0.2f, 0.5f, 1.0f, 1.0f) },
|
|
{ "Feign", kColorGray },
|
|
};
|
|
|
|
float barW = 280.0f;
|
|
float barH = 36.0f;
|
|
float barX = (screenW - barW) / 2.0f;
|
|
float baseY = screenH - 160.0f; // Just above the cast bar slot
|
|
|
|
ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse |
|
|
ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoScrollbar |
|
|
ImGuiWindowFlags_NoInputs;
|
|
|
|
for (int i = 0; i < 3; ++i) {
|
|
const auto& t = gameHandler.getMirrorTimer(i);
|
|
if (!t.active || t.maxValue <= 0) continue;
|
|
|
|
float frac = static_cast<float>(t.value) / static_cast<float>(t.maxValue);
|
|
frac = std::max(0.0f, std::min(1.0f, frac));
|
|
|
|
char winId[32];
|
|
std::snprintf(winId, sizeof(winId), "##MirrorTimer%d", i);
|
|
ImGui::SetNextWindowPos(ImVec2(barX, baseY - i * (barH + 4.0f)), ImGuiCond_Always);
|
|
ImGui::SetNextWindowSize(ImVec2(barW, barH), ImGuiCond_Always);
|
|
|
|
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f);
|
|
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.05f, 0.05f, 0.05f, 0.88f));
|
|
if (ImGui::Begin(winId, nullptr, flags)) {
|
|
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, kTimerInfo[i].color);
|
|
char overlay[48];
|
|
float sec = static_cast<float>(t.value) / 1000.0f;
|
|
std::snprintf(overlay, sizeof(overlay), "%s %.0fs", kTimerInfo[i].label, sec);
|
|
ImGui::ProgressBar(frac, ImVec2(-1, 20), overlay);
|
|
ImGui::PopStyleColor();
|
|
}
|
|
ImGui::End();
|
|
ImGui::PopStyleColor();
|
|
ImGui::PopStyleVar();
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// Cooldown Tracker — floating panel showing all active spell CDs
|
|
// ============================================================
|
|
|
|
// ============================================================
|
|
// Quest Objective Tracker (right-side HUD)
|
|
// ============================================================
|
|
|
|
void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) {
|
|
const auto& questLog = gameHandler.getQuestLog();
|
|
if (questLog.empty()) return;
|
|
|
|
auto* window = services_.window;
|
|
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
|
|
|
|
constexpr float TRACKER_W = 220.0f;
|
|
constexpr float RIGHT_MARGIN = 10.0f;
|
|
// Build display list: tracked quests only, or all quests if none tracked
|
|
const auto& trackedIds = gameHandler.getTrackedQuestIds();
|
|
const bool anyTracked = !trackedIds.empty();
|
|
std::vector<const game::GameHandler::QuestLogEntry*> toShow;
|
|
toShow.reserve(questLog.size());
|
|
if (anyTracked) {
|
|
for (const auto& q : questLog) {
|
|
if (q.questId == 0) continue;
|
|
if (trackedIds.count(q.questId)) toShow.push_back(&q);
|
|
}
|
|
}
|
|
// Fallback: show all quests if nothing is tracked
|
|
if (toShow.empty()) {
|
|
for (const auto& q : questLog) {
|
|
if (q.questId == 0) continue;
|
|
toShow.push_back(&q);
|
|
}
|
|
}
|
|
if (toShow.empty()) return;
|
|
|
|
// Apply completion filter (0=All, 1=Active, 2=Done). Keep the window open
|
|
// even when the filter hides everything so the user can cycle it back.
|
|
if (questTrackerFilter_ != 0) {
|
|
std::erase_if(toShow, [this](const game::GameHandler::QuestLogEntry* q) {
|
|
return questTrackerFilter_ == 1 ? q->complete : !q->complete;
|
|
});
|
|
}
|
|
|
|
// Sort lowest level first so stale quests surface at the top;
|
|
// unknown level (0) and player-scaling (-1) sort last
|
|
std::stable_sort(toShow.begin(), toShow.end(),
|
|
[](const game::GameHandler::QuestLogEntry* a,
|
|
const game::GameHandler::QuestLogEntry* b) {
|
|
const int la = a->level > 0 ? a->level : std::numeric_limits<int>::max();
|
|
const int lb = b->level > 0 ? b->level : std::numeric_limits<int>::max();
|
|
if (la != lb) return la < lb;
|
|
return a->title < b->title;
|
|
});
|
|
|
|
float screenH = ImGui::GetIO().DisplaySize.y > 0.0f ? ImGui::GetIO().DisplaySize.y : 720.0f;
|
|
|
|
// Default position: top-right, below minimap + buff bar space.
|
|
// questTrackerRightOffset_ stores pixels from the right edge so the tracker
|
|
// stays anchored to the right side when the window is resized.
|
|
if (!questTrackerPosInit_ || questTrackerRightOffset_ < 0.0f) {
|
|
questTrackerRightOffset_ = TRACKER_W + RIGHT_MARGIN; // default: right-aligned
|
|
questTrackerPos_.y = 320.0f;
|
|
questTrackerPosInit_ = true;
|
|
}
|
|
// Recompute X from right offset every frame (handles window resize)
|
|
questTrackerPos_.x = screenW - questTrackerRightOffset_;
|
|
|
|
// Collapsed: draw a small draggable bubble at the tracker anchor instead;
|
|
// click (without dragging) to expand back to the full tracker
|
|
if (questTrackerCollapsed_) {
|
|
ImGui::SetNextWindowPos(questTrackerPos_, ImGuiCond_Always);
|
|
ImGuiWindowFlags bubbleFlags = ImGuiWindowFlags_NoTitleBar |
|
|
ImGuiWindowFlags_NoCollapse |
|
|
ImGuiWindowFlags_NoNav |
|
|
ImGuiWindowFlags_NoBringToFrontOnFocus |
|
|
ImGuiWindowFlags_AlwaysAutoResize |
|
|
ImGuiWindowFlags_NoScrollbar;
|
|
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.0f, 0.0f, 0.0f, 0.55f));
|
|
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 12.0f);
|
|
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(9.0f, 5.0f));
|
|
if (ImGui::Begin("##QuestTrackerBubble", nullptr, bubbleFlags)) {
|
|
ImGui::TextColored(colors::kWarmGold, "! %d", static_cast<int>(toShow.size()));
|
|
if (ImGui::IsWindowHovered()) {
|
|
ImGui::SetTooltip("Quest tracker — click to expand, drag to move");
|
|
// Expand on click, but not when the press was a drag
|
|
if (ImGui::IsMouseReleased(ImGuiMouseButton_Left) &&
|
|
ImGui::GetIO().MouseDragMaxDistanceSqr[ImGuiMouseButton_Left] < 9.0f) {
|
|
questTrackerCollapsed_ = false;
|
|
saveSettings();
|
|
}
|
|
}
|
|
// Capture drag so the bubble and tracker share one anchor
|
|
ImVec2 newPos = ImGui::GetWindowPos();
|
|
newPos.x = std::clamp(newPos.x, 0.0f, screenW - ImGui::GetWindowSize().x);
|
|
newPos.y = std::clamp(newPos.y, 0.0f, screenH - 40.0f);
|
|
if (std::abs(newPos.x - questTrackerPos_.x) > 0.5f ||
|
|
std::abs(newPos.y - questTrackerPos_.y) > 0.5f) {
|
|
questTrackerPos_ = newPos;
|
|
questTrackerRightOffset_ = screenW - newPos.x;
|
|
saveSettings();
|
|
}
|
|
}
|
|
ImGui::End();
|
|
ImGui::PopStyleVar(2);
|
|
ImGui::PopStyleColor();
|
|
return;
|
|
}
|
|
|
|
ImGui::SetNextWindowPos(questTrackerPos_, ImGuiCond_Always);
|
|
ImGui::SetNextWindowSize(questTrackerSize_, ImGuiCond_FirstUseEver);
|
|
|
|
ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar |
|
|
ImGuiWindowFlags_NoCollapse |
|
|
ImGuiWindowFlags_NoNav |
|
|
ImGuiWindowFlags_NoBringToFrontOnFocus;
|
|
|
|
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.0f, 0.0f, 0.0f, 0.55f));
|
|
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.0f, 6.0f));
|
|
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4.0f, 2.0f));
|
|
|
|
if (ImGui::Begin("##QuestTracker", nullptr, flags)) {
|
|
// Header row: quest count + completion filter (click to cycle) + hide
|
|
static const char* kFilterNames[] = {"All", "Active", "Done"};
|
|
ImGui::TextDisabled("Quests (%d)", static_cast<int>(toShow.size()));
|
|
{
|
|
const ImGuiStyle& style = ImGui::GetStyle();
|
|
float filterW = ImGui::CalcTextSize("Active").x + style.FramePadding.x * 2.0f;
|
|
float hideW = ImGui::CalcTextSize("-").x + style.FramePadding.x * 2.0f;
|
|
ImGui::SameLine();
|
|
float off = ImGui::GetContentRegionAvail().x -
|
|
(filterW + style.ItemSpacing.x + hideW);
|
|
if (off > 0.0f) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + off);
|
|
if (ImGui::SmallButton(kFilterNames[questTrackerFilter_])) {
|
|
questTrackerFilter_ = (questTrackerFilter_ + 1) % 3;
|
|
saveSettings();
|
|
}
|
|
if (ImGui::IsItemHovered()) {
|
|
ImGui::SetTooltip("Filter: All / Active / Done (click to cycle)");
|
|
}
|
|
ImGui::SameLine();
|
|
if (ImGui::SmallButton("-")) {
|
|
questTrackerCollapsed_ = true;
|
|
saveSettings();
|
|
}
|
|
if (ImGui::IsItemHovered()) {
|
|
ImGui::SetTooltip("Hide (collapse to bubble)");
|
|
}
|
|
}
|
|
ImGui::Separator();
|
|
if (toShow.empty()) {
|
|
ImGui::TextDisabled("%s", questTrackerFilter_ == 1 ? "No active quests"
|
|
: "No completed quests");
|
|
}
|
|
|
|
for (int i = 0; i < static_cast<int>(toShow.size()); ++i) {
|
|
const auto& q = *toShow[i];
|
|
|
|
// Per-quest map checkbox + clickable title + small [x] untrack button.
|
|
ImGui::PushID(q.questId);
|
|
bool shownOnMap = gameHandler.isQuestShownOnMap(q.questId);
|
|
if (ImGui::Checkbox("##ShowOnMap", &shownOnMap)) {
|
|
gameHandler.setQuestShownOnMap(q.questId, shownOnMap);
|
|
}
|
|
if (ImGui::IsItemHovered()) {
|
|
ImGui::SetTooltip("Show this quest's objectives on the minimap and world map");
|
|
}
|
|
ImGui::SameLine();
|
|
float untrackW = ImGui::CalcTextSize("x").x + ImGui::GetStyle().FramePadding.x * 2.0f;
|
|
float titleW = ImGui::GetContentRegionAvail().x - untrackW -
|
|
ImGui::GetStyle().ItemSpacing.x;
|
|
ImVec4 titleCol = q.complete ? colors::kWarmGold
|
|
: ImVec4(1.0f, 1.0f, 0.85f, 1.0f);
|
|
std::string titleLabel = q.level > 0
|
|
? "[" + std::to_string(q.level) + "] " + q.title
|
|
: q.title;
|
|
ImGui::PushStyleColor(ImGuiCol_Text, titleCol);
|
|
if (ImGui::Selectable(titleLabel.c_str(), false,
|
|
ImGuiSelectableFlags_DontClosePopups, ImVec2(titleW, 0))) {
|
|
questLogScreen.openAndSelectQuest(q.questId);
|
|
}
|
|
if (ImGui::IsItemHovered() && !ImGui::IsPopupOpen("##QTCtx")) {
|
|
ImGui::SetTooltip("Click: open Quest Log | Right-click: tracking options");
|
|
}
|
|
ImGui::PopStyleColor();
|
|
|
|
// Right-click context menu for quest tracker entry (attaches to the
|
|
// title Selectable — must come before the untrack button below)
|
|
if (ImGui::BeginPopupContextItem("##QTCtx")) {
|
|
ImGui::TextDisabled("%s", q.title.c_str());
|
|
ImGui::Separator();
|
|
if (ImGui::MenuItem("Open in Quest Log")) {
|
|
questLogScreen.openAndSelectQuest(q.questId);
|
|
}
|
|
bool mapVisible = gameHandler.isQuestShownOnMap(q.questId);
|
|
if (ImGui::MenuItem("Show on Map", nullptr, mapVisible)) {
|
|
gameHandler.setQuestShownOnMap(q.questId, !mapVisible);
|
|
}
|
|
bool tracked = gameHandler.isQuestTracked(q.questId);
|
|
if (tracked) {
|
|
if (ImGui::MenuItem("Stop Tracking")) {
|
|
gameHandler.setQuestTracked(q.questId, false);
|
|
}
|
|
} else {
|
|
if (ImGui::MenuItem("Track")) {
|
|
gameHandler.setQuestTracked(q.questId, true);
|
|
}
|
|
}
|
|
if (gameHandler.isInGroup() && !q.complete) {
|
|
if (ImGui::MenuItem("Share Quest")) {
|
|
gameHandler.shareQuestWithParty(q.questId);
|
|
}
|
|
}
|
|
if (!q.complete) {
|
|
ImGui::Separator();
|
|
if (ImGui::MenuItem("Abandon Quest")) {
|
|
gameHandler.abandonQuest(q.questId);
|
|
}
|
|
}
|
|
ImGui::EndPopup();
|
|
}
|
|
|
|
// Untrack button at the end of the title row
|
|
ImGui::SameLine();
|
|
if (ImGui::SmallButton("x")) {
|
|
if (anyTracked) {
|
|
gameHandler.setQuestTracked(q.questId, false);
|
|
} else {
|
|
// Nothing explicitly tracked (tracker shows all quests):
|
|
// hide this one by tracking every other quest instead
|
|
for (const auto& other : questLog) {
|
|
if (other.questId != 0 && other.questId != q.questId) {
|
|
gameHandler.setQuestTracked(other.questId, true);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (ImGui::IsItemHovered()) {
|
|
ImGui::SetTooltip("Untrack (remove from tracker)");
|
|
}
|
|
ImGui::PopID();
|
|
|
|
// Objectives — every required objective with progress, done ones marked
|
|
if (q.complete) {
|
|
ImGui::TextColored(colors::kActiveGreen, " Ready to turn in");
|
|
} else {
|
|
constexpr ImVec4 kPendingGray(0.75f, 0.75f, 0.75f, 1.0f);
|
|
|
|
// Kill/interact objective line — green + "(done)" when finished
|
|
auto renderKillLine = [&](uint32_t entry, uint32_t count, uint32_t required) {
|
|
bool objDone = (required > 0 && count >= required);
|
|
ImVec4 objColor = objDone ? kColorGreen : kPendingGray;
|
|
std::string name = gameHandler.getCachedCreatureName(entry);
|
|
if (name.empty()) {
|
|
const auto* goInfo = gameHandler.getCachedGameObjectInfo(entry);
|
|
if (goInfo && !goInfo->name.empty()) name = goInfo->name;
|
|
}
|
|
if (!name.empty()) {
|
|
ImGui::TextColored(objColor, " - %s: %u/%u%s", name.c_str(),
|
|
count, required, objDone ? " (done)" : "");
|
|
} else {
|
|
ImGui::TextColored(objColor, " - %u/%u%s",
|
|
count, required, objDone ? " (done)" : "");
|
|
}
|
|
};
|
|
// Quest query data first (covers objectives with no progress yet),
|
|
// then any progress entries the query didn't know about
|
|
std::unordered_set<uint32_t> shownKills;
|
|
for (const auto& obj : q.killObjectives) {
|
|
if (obj.npcOrGoId == 0 || obj.required == 0) continue;
|
|
uint32_t entry = static_cast<uint32_t>(
|
|
obj.npcOrGoId > 0 ? obj.npcOrGoId : -obj.npcOrGoId);
|
|
uint32_t count = 0;
|
|
auto it = q.killCounts.find(entry);
|
|
if (it != q.killCounts.end()) count = it->second.first;
|
|
renderKillLine(entry, count, obj.required);
|
|
shownKills.insert(entry);
|
|
}
|
|
for (const auto& [entry, progress] : q.killCounts) {
|
|
if (shownKills.count(entry)) continue;
|
|
renderKillLine(entry, progress.first, progress.second);
|
|
}
|
|
|
|
// Item objective line — small icon + name, green + "(done)" when finished
|
|
auto renderItemLine = [&](uint32_t itemId, uint32_t count, uint32_t required) {
|
|
bool objDone = (required > 0 && count >= required);
|
|
ImVec4 objColor = objDone ? kColorGreen : kPendingGray;
|
|
const auto* info = gameHandler.getItemInfo(itemId);
|
|
const char* itemName = (info && !info->name.empty()) ? info->name.c_str() : nullptr;
|
|
|
|
uint32_t dispId = (info && info->displayInfoId) ? info->displayInfoId : 0;
|
|
VkDescriptorSet iconTex = dispId ? inventoryScreen.getItemIcon(dispId) : VK_NULL_HANDLE;
|
|
if (iconTex) {
|
|
ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(12, 12));
|
|
if (info && info->valid && ImGui::IsItemHovered()) {
|
|
ImGui::BeginTooltip();
|
|
inventoryScreen.renderItemTooltip(*info);
|
|
ImGui::EndTooltip();
|
|
}
|
|
ImGui::SameLine(0, 3);
|
|
ImGui::TextColored(objColor, "%s: %u/%u%s",
|
|
itemName ? itemName : "Item",
|
|
count, required, objDone ? " (done)" : "");
|
|
if (info && info->valid && ImGui::IsItemHovered()) {
|
|
ImGui::BeginTooltip();
|
|
inventoryScreen.renderItemTooltip(*info);
|
|
ImGui::EndTooltip();
|
|
}
|
|
} else {
|
|
ImGui::TextColored(objColor, " - %s: %u/%u%s",
|
|
itemName ? itemName : "Item",
|
|
count, required, objDone ? " (done)" : "");
|
|
if (info && info->valid && ImGui::IsItemHovered()) {
|
|
ImGui::BeginTooltip();
|
|
inventoryScreen.renderItemTooltip(*info);
|
|
ImGui::EndTooltip();
|
|
}
|
|
}
|
|
};
|
|
// requiredItemCounts is the authoritative required list (includes
|
|
// 0-progress items); then show stray progress entries not in it
|
|
std::unordered_set<uint32_t> shownItems;
|
|
for (const auto& obj : q.itemObjectives) {
|
|
if (obj.itemId == 0 || obj.required == 0) continue;
|
|
uint32_t count = 0;
|
|
auto it = q.itemCounts.find(obj.itemId);
|
|
if (it != q.itemCounts.end()) count = it->second;
|
|
renderItemLine(obj.itemId, count, obj.required);
|
|
shownItems.insert(obj.itemId);
|
|
}
|
|
for (const auto& [itemId, required] : q.requiredItemCounts) {
|
|
if (shownItems.count(itemId)) continue;
|
|
uint32_t count = 0;
|
|
auto it = q.itemCounts.find(itemId);
|
|
if (it != q.itemCounts.end()) count = it->second;
|
|
renderItemLine(itemId, count, required);
|
|
shownItems.insert(itemId);
|
|
}
|
|
for (const auto& [itemId, count] : q.itemCounts) {
|
|
if (shownItems.count(itemId)) continue;
|
|
renderItemLine(itemId, count, 1);
|
|
}
|
|
|
|
bool hasCounted = !shownKills.empty() || !shownItems.empty() ||
|
|
!q.killCounts.empty() || !q.itemCounts.empty();
|
|
if (!hasCounted && !q.objectives.empty()) {
|
|
const std::string& obj = q.objectives;
|
|
if (obj.size() > 40) {
|
|
ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f),
|
|
" %.37s...", obj.c_str());
|
|
} else {
|
|
ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f),
|
|
" %s", obj.c_str());
|
|
}
|
|
}
|
|
}
|
|
|
|
if (i < static_cast<int>(toShow.size()) - 1) {
|
|
ImGui::Spacing();
|
|
}
|
|
}
|
|
|
|
// Capture position and size after drag/resize
|
|
ImVec2 newPos = ImGui::GetWindowPos();
|
|
ImVec2 newSize = ImGui::GetWindowSize();
|
|
bool changed = false;
|
|
|
|
// Clamp within screen
|
|
newPos.x = std::clamp(newPos.x, 0.0f, screenW - newSize.x);
|
|
newPos.y = std::clamp(newPos.y, 0.0f, screenH - 40.0f);
|
|
|
|
if (std::abs(newPos.x - questTrackerPos_.x) > 0.5f ||
|
|
std::abs(newPos.y - questTrackerPos_.y) > 0.5f) {
|
|
questTrackerPos_ = newPos;
|
|
// Update right offset so resizes keep the new position anchored
|
|
questTrackerRightOffset_ = screenW - newPos.x;
|
|
changed = true;
|
|
}
|
|
if (std::abs(newSize.x - questTrackerSize_.x) > 0.5f ||
|
|
std::abs(newSize.y - questTrackerSize_.y) > 0.5f) {
|
|
questTrackerSize_ = newSize;
|
|
changed = true;
|
|
}
|
|
if (changed) saveSettings();
|
|
}
|
|
ImGui::End();
|
|
|
|
ImGui::PopStyleVar(2);
|
|
ImGui::PopStyleColor();
|
|
}
|
|
|
|
// ============================================================
|
|
// Nameplates — world-space health bars projected to screen
|
|
// ============================================================
|
|
|
|
void GameScreen::renderNameplates(game::GameHandler& gameHandler) {
|
|
if (gameHandler.getState() != game::WorldState::IN_WORLD) return;
|
|
|
|
// Reset mouseover each frame; we'll set it below when the cursor is over a nameplate
|
|
gameHandler.setMouseoverGuid(0);
|
|
|
|
auto* appRenderer = services_.renderer;
|
|
if (!appRenderer) return;
|
|
rendering::Camera* camera = appRenderer->getCamera();
|
|
if (!camera) return;
|
|
|
|
auto* window = services_.window;
|
|
if (!window) return;
|
|
const float screenW = static_cast<float>(window->getWidth());
|
|
const float screenH = static_cast<float>(window->getHeight());
|
|
|
|
const glm::mat4 viewProj = camera->getProjectionMatrix() * camera->getViewMatrix();
|
|
const glm::vec3 camPos = camera->getPosition();
|
|
const uint64_t playerGuid = gameHandler.getPlayerGuid();
|
|
const uint64_t targetGuid = gameHandler.getTargetGuid();
|
|
|
|
refreshQuestObjectiveCache(gameHandler);
|
|
const auto& questKillEntries = minimapQuestCreatureEntries_;
|
|
|
|
ImDrawList* drawList = ImGui::GetBackgroundDrawList();
|
|
static thread_local std::vector<std::shared_ptr<game::Entity>> nameplateEntities;
|
|
glm::vec3 playerCanonical(0.0f);
|
|
if (auto player = gameHandler.getEntityManager().getEntity(playerGuid))
|
|
playerCanonical = glm::vec3(player->getX(), player->getY(), player->getZ());
|
|
gameHandler.getEntityManager().getEntitiesNear(
|
|
playerCanonical.x, playerCanonical.y, 150.0f, nameplateEntities);
|
|
|
|
for (const auto& entityPtr : nameplateEntities) {
|
|
if (!entityPtr) continue;
|
|
const uint64_t guid = entityPtr->getGuid();
|
|
if (!entityPtr || guid == playerGuid) continue;
|
|
|
|
if (!entityPtr->isUnit()) continue;
|
|
auto* unit = static_cast<game::Unit*>(entityPtr.get());
|
|
if (unit->getMaxHealth() == 0) continue;
|
|
|
|
bool isPlayer = (entityPtr->getType() == game::ObjectType::PLAYER);
|
|
bool isTarget = (guid == targetGuid);
|
|
const bool isHostile = unit->isHostile();
|
|
|
|
// Friendly player nameplates use Shift+V; enemy players and hostile/NPC
|
|
// nameplates use V. Reaction, not object type, owns the visual category.
|
|
// The current target ALWAYS gets a nameplate so it's clear what is
|
|
// selected even with nameplates toggled off.
|
|
if (isPlayer && !isHostile && !settingsPanel_.showFriendlyNameplates_ && !isTarget) continue;
|
|
if ((!isPlayer || isHostile) && !showNameplates_ && !isTarget) continue;
|
|
|
|
// For corpses (dead units), only show a minimal grey nameplate if selected
|
|
bool isCorpse = (unit->getHealth() == 0);
|
|
if (isCorpse && !isTarget) continue;
|
|
|
|
// Prefer the renderer's actual instance position so the nameplate tracks the
|
|
// rendered model exactly (avoids drift from the parallel entity interpolator).
|
|
glm::vec3 renderPos;
|
|
if (!core::Application::getInstance().getRenderPositionForGuid(guid, renderPos)) {
|
|
renderPos = core::coords::canonicalToRender(
|
|
glm::vec3(unit->getX(), unit->getY(), unit->getZ()));
|
|
}
|
|
renderPos.z += 2.3f;
|
|
|
|
// Cull distance: the current target stays visible to 60 units so its
|
|
// bar doesn't vanish at combat range; players 40; other NPCs 20.
|
|
glm::vec3 nameDelta = renderPos - camPos;
|
|
float distSq = glm::dot(nameDelta, nameDelta);
|
|
float cullDist = isTarget ? 60.0f : (isPlayer ? 40.0f : 20.0f);
|
|
if (distSq > cullDist * cullDist) continue;
|
|
|
|
// Project to clip space
|
|
glm::vec4 clipPos = viewProj * glm::vec4(renderPos, 1.0f);
|
|
if (clipPos.w <= 0.01f) continue; // Behind camera
|
|
|
|
glm::vec3 ndc = glm::vec3(clipPos) / clipPos.w;
|
|
if (ndc.x < -1.2f || ndc.x > 1.2f || ndc.y < -1.2f || ndc.y > 1.2f) continue;
|
|
|
|
// NDC → screen pixels.
|
|
// The camera bakes the Vulkan Y-flip into the projection matrix, so
|
|
// NDC y = -1 is the top of the screen and y = 1 is the bottom.
|
|
// Map directly: sy = (ndc.y + 1) / 2 * screenH (no extra inversion).
|
|
float sx = (ndc.x * 0.5f + 0.5f) * screenW;
|
|
float sy = (ndc.y * 0.5f + 0.5f) * screenH;
|
|
|
|
// Fade out in the last 5 units of cull range
|
|
float fadeSq = (cullDist - 5.0f) * (cullDist - 5.0f);
|
|
float dist = std::sqrt(distSq);
|
|
float alpha = distSq < fadeSq ? 1.0f : 1.0f - (dist - (cullDist - 5.0f)) / 5.0f;
|
|
auto A = [&](int v) { return static_cast<int>(v * alpha); };
|
|
|
|
// Bar colour by hostility (grey for corpses)
|
|
ImU32 barColor, bgColor;
|
|
if (isCorpse) {
|
|
// Minimal grey bar for selected corpses (loot/skin targets)
|
|
barColor = IM_COL32(140, 140, 140, A(200));
|
|
bgColor = IM_COL32(70, 70, 70, A(160));
|
|
} else if (isHostile) {
|
|
// Check if mob is tapped by another player (grey nameplate)
|
|
uint32_t dynFlags = unit->getDynamicFlags();
|
|
bool tappedByOther = (dynFlags & 0x0004) != 0 && (dynFlags & 0x0008) == 0; // TAPPED but not TAPPED_BY_ALL_THREAT_LIST
|
|
if (tappedByOther) {
|
|
barColor = IM_COL32(160, 160, 160, A(200));
|
|
bgColor = IM_COL32(80, 80, 80, A(160));
|
|
} else {
|
|
barColor = IM_COL32(220, 60, 60, A(200));
|
|
bgColor = IM_COL32(100, 25, 25, A(160));
|
|
}
|
|
} else if (isPlayer) {
|
|
// World-space bars communicate reaction. Class colors belong in
|
|
// party/social UI, where red DK and pink Paladin cannot imply hostility.
|
|
barColor = IM_COL32(60, 200, 80, A(200));
|
|
bgColor = IM_COL32(25, 100, 35, A(160));
|
|
} else {
|
|
barColor = IM_COL32(60, 200, 80, A(200));
|
|
bgColor = IM_COL32(25, 100, 35, A(160));
|
|
}
|
|
// Check if this unit is targeting the local player (threat indicator)
|
|
bool isTargetingPlayer = false;
|
|
if (unit->isHostile() && !isCorpse) {
|
|
const auto& fields = entityPtr->getFields();
|
|
auto loIt = fields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_LO));
|
|
if (loIt != fields.end() && loIt->second != 0) {
|
|
uint64_t unitTarget = loIt->second;
|
|
auto hiIt = fields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_HI));
|
|
if (hiIt != fields.end())
|
|
unitTarget |= (static_cast<uint64_t>(hiIt->second) << 32);
|
|
isTargetingPlayer = (unitTarget == playerGuid);
|
|
}
|
|
}
|
|
// Creature rank for border styling (Elite=gold double border, Boss=red, Rare=silver)
|
|
int creatureRank = -1;
|
|
if (!isPlayer) creatureRank = gameHandler.getCreatureRank(unit->getEntry());
|
|
|
|
// Border: gold = currently selected, orange = targeting player, dark = default
|
|
ImU32 borderColor = isTarget
|
|
? IM_COL32(255, 215, 0, A(255))
|
|
: isTargetingPlayer
|
|
? IM_COL32(255, 140, 0, A(220)) // orange = this mob is targeting you
|
|
: IM_COL32(20, 20, 20, A(180));
|
|
|
|
// Bar geometry
|
|
const float barW = 80.0f * settingsPanel_.nameplateScale_;
|
|
const float barH = 8.0f * settingsPanel_.nameplateScale_;
|
|
const float barX = sx - barW * 0.5f;
|
|
|
|
// Guard against division by zero when maxHealth hasn't been populated yet
|
|
// (freshly spawned entity with default fields). 0/0 produces NaN which
|
|
// poisons all downstream geometry; +inf is clamped but still wasteful.
|
|
float healthPct = (unit->getMaxHealth() > 0)
|
|
? std::clamp(static_cast<float>(unit->getHealth()) / static_cast<float>(unit->getMaxHealth()), 0.0f, 1.0f)
|
|
: 0.0f;
|
|
|
|
drawList->AddRectFilled(ImVec2(barX, sy), ImVec2(barX + barW, sy + barH), bgColor, 2.0f);
|
|
// For corpses, don't fill health bar (just show grey background)
|
|
if (!isCorpse) {
|
|
drawList->AddRectFilled(ImVec2(barX, sy), ImVec2(barX + barW * healthPct, sy + barH), barColor, 2.0f);
|
|
}
|
|
drawList->AddRect (ImVec2(barX - 1.0f, sy - 1.0f), ImVec2(barX + barW + 1.0f, sy + barH + 1.0f), borderColor, 2.0f);
|
|
|
|
// Elite/Boss/Rare decoration: extra outer border with rank-specific color
|
|
if (creatureRank == 1 || creatureRank == 2) {
|
|
// Elite / Rare Elite: gold double border
|
|
drawList->AddRect(ImVec2(barX - 3.0f, sy - 3.0f),
|
|
ImVec2(barX + barW + 3.0f, sy + barH + 3.0f),
|
|
IM_COL32(255, 200, 50, A(200)), 3.0f);
|
|
} else if (creatureRank == 3) {
|
|
// Boss: red double border
|
|
drawList->AddRect(ImVec2(barX - 3.0f, sy - 3.0f),
|
|
ImVec2(barX + barW + 3.0f, sy + barH + 3.0f),
|
|
IM_COL32(255, 40, 40, A(200)), 3.0f);
|
|
} else if (creatureRank == 4) {
|
|
// Rare: silver double border
|
|
drawList->AddRect(ImVec2(barX - 3.0f, sy - 3.0f),
|
|
ImVec2(barX + barW + 3.0f, sy + barH + 3.0f),
|
|
IM_COL32(170, 200, 230, A(200)), 3.0f);
|
|
}
|
|
|
|
// HP % text centered on health bar (non-corpse, non-full-health for readability)
|
|
if (!isCorpse && unit->getMaxHealth() > 0) {
|
|
int hpPct = static_cast<int>(healthPct * 100.0f + 0.5f);
|
|
char hpBuf[8];
|
|
snprintf(hpBuf, sizeof(hpBuf), "%d%%", hpPct);
|
|
ImVec2 hpTextSz = ImGui::CalcTextSize(hpBuf);
|
|
float hpTx = sx - hpTextSz.x * 0.5f;
|
|
float hpTy = sy + (barH - hpTextSz.y) * 0.5f;
|
|
drawList->AddText(ImVec2(hpTx + 1.0f, hpTy + 1.0f), IM_COL32(0, 0, 0, A(140)), hpBuf);
|
|
drawList->AddText(ImVec2(hpTx, hpTy), IM_COL32(255, 255, 255, A(200)), hpBuf);
|
|
}
|
|
|
|
// Cast bar below health bar when unit is casting
|
|
float castBarBaseY = sy + barH + 2.0f;
|
|
float nameplateBottom = castBarBaseY; // tracks lowest drawn element for debuff dots
|
|
{
|
|
const auto* cs = gameHandler.getUnitCastState(guid);
|
|
if (cs && cs->casting && cs->timeTotal > 0.0f) {
|
|
float castPct = std::clamp((cs->timeTotal - cs->timeRemaining) / cs->timeTotal, 0.0f, 1.0f);
|
|
const float cbH = 6.0f * settingsPanel_.nameplateScale_;
|
|
|
|
// Spell icon + name above the cast bar
|
|
const std::string& spellName = gameHandler.getSpellName(cs->spellId);
|
|
{
|
|
auto* castAm = services_.assetManager;
|
|
VkDescriptorSet castIcon = (cs->spellId && castAm)
|
|
? getSpellIcon(cs->spellId, castAm) : VK_NULL_HANDLE;
|
|
float iconSz = cbH + 8.0f;
|
|
if (castIcon) {
|
|
// Draw icon to the left of the cast bar
|
|
float iconX = barX - iconSz - 2.0f;
|
|
float iconY = castBarBaseY;
|
|
drawList->AddImage((ImTextureID)(uintptr_t)castIcon,
|
|
ImVec2(iconX, iconY),
|
|
ImVec2(iconX + iconSz, iconY + iconSz));
|
|
drawList->AddRect(ImVec2(iconX - 1.0f, iconY - 1.0f),
|
|
ImVec2(iconX + iconSz + 1.0f, iconY + iconSz + 1.0f),
|
|
IM_COL32(0, 0, 0, A(180)), 1.0f);
|
|
}
|
|
if (!spellName.empty()) {
|
|
ImVec2 snSz = ImGui::CalcTextSize(spellName.c_str());
|
|
float snX = sx - snSz.x * 0.5f;
|
|
float snY = castBarBaseY;
|
|
drawList->AddText(ImVec2(snX + 1.0f, snY + 1.0f), IM_COL32(0, 0, 0, A(140)), spellName.c_str());
|
|
drawList->AddText(ImVec2(snX, snY), IM_COL32(255, 210, 100, A(220)), spellName.c_str());
|
|
castBarBaseY += snSz.y + 2.0f;
|
|
}
|
|
}
|
|
|
|
// Cast bar: green = interruptible, red = uninterruptible; both pulse when >80% complete
|
|
ImU32 cbBg = IM_COL32(30, 25, 40, A(180));
|
|
ImU32 cbFill;
|
|
if (castPct > 0.8f && unit->isHostile()) {
|
|
float pulse = 0.7f + 0.3f * std::sin(static_cast<float>(ImGui::GetTime()) * 8.0f);
|
|
cbFill = cs->interruptible
|
|
? IM_COL32(static_cast<int>(40 * pulse), static_cast<int>(220 * pulse), static_cast<int>(40 * pulse), A(220)) // green pulse
|
|
: IM_COL32(static_cast<int>(255 * pulse), static_cast<int>(30 * pulse), static_cast<int>(30 * pulse), A(220)); // red pulse
|
|
} else {
|
|
cbFill = cs->interruptible
|
|
? IM_COL32(50, 190, 50, A(200)) // green = interruptible
|
|
: IM_COL32(190, 40, 40, A(200)); // red = uninterruptible
|
|
}
|
|
drawList->AddRectFilled(ImVec2(barX, castBarBaseY),
|
|
ImVec2(barX + barW, castBarBaseY + cbH), cbBg, 2.0f);
|
|
drawList->AddRectFilled(ImVec2(barX, castBarBaseY),
|
|
ImVec2(barX + barW * castPct, castBarBaseY + cbH), cbFill, 2.0f);
|
|
drawList->AddRect (ImVec2(barX - 1.0f, castBarBaseY - 1.0f),
|
|
ImVec2(barX + barW + 1.0f, castBarBaseY + cbH + 1.0f),
|
|
IM_COL32(20, 10, 40, A(200)), 2.0f);
|
|
|
|
// Time remaining text
|
|
char timeBuf[12];
|
|
snprintf(timeBuf, sizeof(timeBuf), "%.1fs", cs->timeRemaining);
|
|
ImVec2 timeSz = ImGui::CalcTextSize(timeBuf);
|
|
float timeX = sx - timeSz.x * 0.5f;
|
|
float timeY = castBarBaseY + (cbH - timeSz.y) * 0.5f;
|
|
drawList->AddText(ImVec2(timeX + 1.0f, timeY + 1.0f), IM_COL32(0, 0, 0, A(140)), timeBuf);
|
|
drawList->AddText(ImVec2(timeX, timeY), IM_COL32(220, 200, 255, A(220)), timeBuf);
|
|
nameplateBottom = castBarBaseY + cbH + 2.0f;
|
|
}
|
|
}
|
|
|
|
// Debuff dot indicators: small colored squares below the nameplate showing
|
|
// player-applied auras on the current hostile target.
|
|
// Colors: Magic=blue, Curse=purple, Disease=yellow, Poison=green, Other=grey
|
|
if (isTarget && unit->isHostile() && !isCorpse) {
|
|
const auto& auras = gameHandler.getTargetAuras();
|
|
const uint64_t pguid = gameHandler.getPlayerGuid();
|
|
const float dotSize = 6.0f * settingsPanel_.nameplateScale_;
|
|
const float dotGap = 2.0f;
|
|
float dotX = barX;
|
|
for (const auto& aura : auras) {
|
|
if (aura.isEmpty() || aura.casterGuid != pguid) continue;
|
|
uint8_t dispelType = gameHandler.getSpellDispelType(aura.spellId);
|
|
ImU32 dotCol;
|
|
switch (dispelType) {
|
|
case 1: dotCol = IM_COL32( 64, 128, 255, A(210)); break; // Magic - blue
|
|
case 2: dotCol = IM_COL32(160, 32, 240, A(210)); break; // Curse - purple
|
|
case 3: dotCol = IM_COL32(180, 140, 40, A(210)); break; // Disease - yellow-brown
|
|
case 4: dotCol = IM_COL32( 50, 200, 50, A(210)); break; // Poison - green
|
|
default: dotCol = IM_COL32(170, 170, 170, A(170)); break; // Other - grey
|
|
}
|
|
drawList->AddRectFilled(ImVec2(dotX, nameplateBottom),
|
|
ImVec2(dotX + dotSize, nameplateBottom + dotSize), dotCol, 1.0f);
|
|
drawList->AddRect (ImVec2(dotX - 1.0f, nameplateBottom - 1.0f),
|
|
ImVec2(dotX + dotSize + 1.0f, nameplateBottom + dotSize + 1.0f),
|
|
IM_COL32(0, 0, 0, A(150)), 1.0f);
|
|
|
|
// Duration clock-sweep overlay (like target frame auras)
|
|
uint64_t nowMs = static_cast<uint64_t>(
|
|
std::chrono::duration_cast<std::chrono::milliseconds>(
|
|
std::chrono::steady_clock::now().time_since_epoch()).count());
|
|
int32_t remainMs = aura.getRemainingMs(nowMs);
|
|
if (aura.maxDurationMs > 0 && remainMs > 0) {
|
|
float pct = 1.0f - static_cast<float>(remainMs) / static_cast<float>(aura.maxDurationMs);
|
|
pct = std::clamp(pct, 0.0f, 1.0f);
|
|
float cx = dotX + dotSize * 0.5f;
|
|
float cy = nameplateBottom + dotSize * 0.5f;
|
|
float r = dotSize * 0.5f;
|
|
float startAngle = -IM_PI * 0.5f;
|
|
float endAngle = startAngle + pct * IM_PI * 2.0f;
|
|
ImVec2 center(cx, cy);
|
|
const int segments = 12;
|
|
for (int seg = 0; seg < segments; seg++) {
|
|
float a0 = startAngle + (endAngle - startAngle) * seg / segments;
|
|
float a1 = startAngle + (endAngle - startAngle) * (seg + 1) / segments;
|
|
drawList->AddTriangleFilled(
|
|
center,
|
|
ImVec2(cx + r * std::cos(a0), cy + r * std::sin(a0)),
|
|
ImVec2(cx + r * std::cos(a1), cy + r * std::sin(a1)),
|
|
IM_COL32(0, 0, 0, A(100)));
|
|
}
|
|
}
|
|
|
|
// Stack count on dot (upper-left corner)
|
|
if (aura.charges > 1) {
|
|
char stackBuf[8];
|
|
snprintf(stackBuf, sizeof(stackBuf), "%d", aura.charges);
|
|
drawList->AddText(ImVec2(dotX + 1.0f, nameplateBottom), IM_COL32(0, 0, 0, A(200)), stackBuf);
|
|
drawList->AddText(ImVec2(dotX, nameplateBottom - 1.0f), IM_COL32(255, 255, 255, A(240)), stackBuf);
|
|
}
|
|
|
|
// Duration text below dot
|
|
if (remainMs > 0) {
|
|
char durBuf[8];
|
|
if (remainMs >= 60000)
|
|
snprintf(durBuf, sizeof(durBuf), "%dm", remainMs / 60000);
|
|
else
|
|
snprintf(durBuf, sizeof(durBuf), "%d", remainMs / 1000);
|
|
ImVec2 durSz = ImGui::CalcTextSize(durBuf);
|
|
float durX = dotX + (dotSize - durSz.x) * 0.5f;
|
|
float durY = nameplateBottom + dotSize + 1.0f;
|
|
drawList->AddText(ImVec2(durX + 1.0f, durY + 1.0f), IM_COL32(0, 0, 0, A(180)), durBuf);
|
|
// Color: red if < 5s, yellow if < 15s, white otherwise
|
|
ImU32 durCol = remainMs < 5000 ? IM_COL32(255, 60, 60, A(240))
|
|
: remainMs < 15000 ? IM_COL32(255, 200, 60, A(240))
|
|
: IM_COL32(230, 230, 230, A(220));
|
|
drawList->AddText(ImVec2(durX, durY), durCol, durBuf);
|
|
}
|
|
|
|
// Spell name + duration tooltip on hover
|
|
{
|
|
ImVec2 mouse = ImGui::GetMousePos();
|
|
if (mouse.x >= dotX && mouse.x < dotX + dotSize &&
|
|
mouse.y >= nameplateBottom && mouse.y < nameplateBottom + dotSize) {
|
|
const std::string& dotSpellName = gameHandler.getSpellName(aura.spellId);
|
|
if (!dotSpellName.empty()) {
|
|
if (remainMs > 0) {
|
|
int secs = remainMs / 1000;
|
|
int mins = secs / 60;
|
|
secs %= 60;
|
|
char tipBuf[128];
|
|
if (mins > 0)
|
|
snprintf(tipBuf, sizeof(tipBuf), "%s (%dm %ds)", dotSpellName.c_str(), mins, secs);
|
|
else
|
|
snprintf(tipBuf, sizeof(tipBuf), "%s (%ds)", dotSpellName.c_str(), secs);
|
|
ImGui::SetTooltip("%s", tipBuf);
|
|
} else {
|
|
ImGui::SetTooltip("%s", dotSpellName.c_str());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
dotX += dotSize + dotGap;
|
|
if (dotX + dotSize > barX + barW) break;
|
|
}
|
|
}
|
|
|
|
// Name + level label above health bar
|
|
uint32_t level = unit->getLevel();
|
|
const std::string& unitName = unit->getName();
|
|
char labelBuf[96];
|
|
if (isPlayer) {
|
|
// Player nameplates: show name only (no level clutter).
|
|
// Fall back to level as placeholder while the name query is pending.
|
|
if (!unitName.empty())
|
|
snprintf(labelBuf, sizeof(labelBuf), "%s", unitName.c_str());
|
|
else {
|
|
// Name query may be pending; request it now to ensure it gets resolved
|
|
gameHandler.queryPlayerName(unit->getGuid());
|
|
if (level > 0)
|
|
snprintf(labelBuf, sizeof(labelBuf), "Player (%u)", level);
|
|
else
|
|
snprintf(labelBuf, sizeof(labelBuf), "Player");
|
|
}
|
|
} else if (level > 0) {
|
|
uint32_t playerLevel = gameHandler.getPlayerLevel();
|
|
// Show skull for units more than 10 levels above the player
|
|
if (playerLevel > 0 && level > playerLevel + 10)
|
|
snprintf(labelBuf, sizeof(labelBuf), "?? %s", unitName.c_str());
|
|
else
|
|
snprintf(labelBuf, sizeof(labelBuf), "%u %s", level, unitName.c_str());
|
|
} else {
|
|
snprintf(labelBuf, sizeof(labelBuf), "%s", unitName.c_str());
|
|
}
|
|
ImVec2 textSize = ImGui::CalcTextSize(labelBuf);
|
|
float nameX = sx - textSize.x * 0.5f;
|
|
float nameY = sy - barH - 12.0f;
|
|
// Name color communicates reaction too: friendly players blue, enemies red.
|
|
ImU32 nameColor;
|
|
if (isPlayer) {
|
|
nameColor = isHostile
|
|
? IM_COL32(220, 80, 80, A(230))
|
|
: IM_COL32(102, 153, 255, A(230));
|
|
} else {
|
|
nameColor = isHostile
|
|
? IM_COL32(220, 80, 80, A(230)) // red — hostile NPC
|
|
: IM_COL32(240, 200, 100, A(230)); // yellow — friendly NPC
|
|
}
|
|
// Sub-label below the name: guild tag for players, subtitle for NPCs
|
|
std::string subLabel;
|
|
if (isPlayer) {
|
|
uint32_t guildId = gameHandler.getEntityGuildId(guid);
|
|
if (guildId != 0) {
|
|
const std::string& gn = gameHandler.lookupGuildName(guildId);
|
|
if (!gn.empty()) subLabel = "<" + gn + ">";
|
|
}
|
|
} else {
|
|
// NPC subtitle (e.g. "<Reagent Vendor>", "<Innkeeper>")
|
|
std::string sub = gameHandler.getCachedCreatureSubName(unit->getEntry());
|
|
if (!sub.empty()) subLabel = "<" + sub + ">";
|
|
}
|
|
if (!subLabel.empty()) nameY -= 10.0f; // shift name up for sub-label line
|
|
|
|
drawList->AddText(ImVec2(nameX + 1.0f, nameY + 1.0f), IM_COL32(0, 0, 0, A(160)), labelBuf);
|
|
drawList->AddText(ImVec2(nameX, nameY), nameColor, labelBuf);
|
|
|
|
// Gold chevron above the current target's plate — a gently bobbing
|
|
// down-arrow so the selected enemy is unmistakable at a glance.
|
|
if (isTarget) {
|
|
float bob = 2.0f * std::sin(static_cast<float>(ImGui::GetTime()) * 5.0f);
|
|
float tipY = nameY - 5.0f + bob;
|
|
float baseY = tipY - 8.0f;
|
|
float halfW = 6.0f;
|
|
drawList->AddTriangleFilled(
|
|
ImVec2(sx - halfW + 1.0f, baseY + 1.0f), ImVec2(sx + halfW + 1.0f, baseY + 1.0f),
|
|
ImVec2(sx + 1.0f, tipY + 1.0f), IM_COL32(0, 0, 0, A(140)));
|
|
drawList->AddTriangleFilled(
|
|
ImVec2(sx - halfW, baseY), ImVec2(sx + halfW, baseY),
|
|
ImVec2(sx, tipY), IM_COL32(255, 215, 0, A(240)));
|
|
}
|
|
|
|
// Sub-label below the name (WoW-style <Guild Name> or <NPC Title> in lighter color)
|
|
if (!subLabel.empty()) {
|
|
ImVec2 subSz = ImGui::CalcTextSize(subLabel.c_str());
|
|
float subX = sx - subSz.x * 0.5f;
|
|
float subY = nameY + textSize.y + 1.0f;
|
|
drawList->AddText(ImVec2(subX + 1.0f, subY + 1.0f), IM_COL32(0, 0, 0, A(120)), subLabel.c_str());
|
|
drawList->AddText(ImVec2(subX, subY), IM_COL32(180, 180, 180, A(200)), subLabel.c_str());
|
|
}
|
|
|
|
// Group leader crown to the right of the name on player nameplates
|
|
if (isPlayer && gameHandler.isInGroup() &&
|
|
gameHandler.getPartyData().leaderGuid == guid) {
|
|
float crownX = nameX + textSize.x + 3.0f;
|
|
const char* crownSym = "\xe2\x99\x9b"; // ♛
|
|
drawList->AddText(ImVec2(crownX + 1.0f, nameY + 1.0f), IM_COL32(0, 0, 0, A(160)), crownSym);
|
|
drawList->AddText(ImVec2(crownX, nameY), IM_COL32(255, 215, 0, A(240)), crownSym);
|
|
}
|
|
|
|
// Raid mark (if any) to the left of the name
|
|
{
|
|
static constexpr struct { const char* sym; ImU32 col; } kNPMarks[] = {
|
|
{ "\xe2\x98\x85", IM_COL32(255,220, 50,230) }, // Star
|
|
{ "\xe2\x97\x8f", IM_COL32(255,140, 0,230) }, // Circle
|
|
{ "\xe2\x97\x86", IM_COL32(160, 32,240,230) }, // Diamond
|
|
{ "\xe2\x96\xb2", IM_COL32( 50,200, 50,230) }, // Triangle
|
|
{ "\xe2\x97\x8c", IM_COL32( 80,160,255,230) }, // Moon
|
|
{ "\xe2\x96\xa0", IM_COL32( 50,200,220,230) }, // Square
|
|
{ "\xe2\x9c\x9d", IM_COL32(255, 80, 80,230) }, // Cross
|
|
{ "\xe2\x98\xa0", IM_COL32(255,255,255,230) }, // Skull
|
|
};
|
|
uint8_t raidMark = gameHandler.getEntityRaidMark(guid);
|
|
if (raidMark < game::GameHandler::kRaidMarkCount) {
|
|
float markX = nameX - 14.0f;
|
|
drawList->AddText(ImVec2(markX + 1.0f, nameY + 1.0f), IM_COL32(0,0,0,120), kNPMarks[raidMark].sym);
|
|
drawList->AddText(ImVec2(markX, nameY), kNPMarks[raidMark].col, kNPMarks[raidMark].sym);
|
|
}
|
|
|
|
// Quest kill objective indicator: small yellow sword icon to the right of the name
|
|
float questIconX = nameX + textSize.x + 4.0f;
|
|
if (!isPlayer && questKillEntries.count(unit->getEntry())) {
|
|
const char* objSym = "\xe2\x9a\x94"; // ⚔ crossed swords (UTF-8)
|
|
drawList->AddText(ImVec2(questIconX + 1.0f, nameY + 1.0f), IM_COL32(0, 0, 0, A(160)), objSym);
|
|
drawList->AddText(ImVec2(questIconX, nameY), IM_COL32(255, 220, 0, A(230)), objSym);
|
|
questIconX += ImGui::CalcTextSize("\xe2\x9a\x94").x + 2.0f;
|
|
}
|
|
|
|
// Quest giver indicator: "!" for available quests, "?" for completable/incomplete
|
|
if (!isPlayer) {
|
|
using QGS = game::QuestGiverStatus;
|
|
QGS qgs = gameHandler.getQuestGiverStatus(guid);
|
|
const char* qSym = nullptr;
|
|
ImU32 qCol = IM_COL32(255, 210, 0, A(255));
|
|
if (qgs == QGS::AVAILABLE) {
|
|
qSym = "!";
|
|
} else if (qgs == QGS::AVAILABLE_LOW) {
|
|
qSym = "!";
|
|
qCol = IM_COL32(160, 160, 160, A(220));
|
|
} else if (qgs == QGS::REWARD || qgs == QGS::REWARD_REP) {
|
|
qSym = "?";
|
|
} else if (qgs == QGS::INCOMPLETE) {
|
|
qSym = "?";
|
|
qCol = IM_COL32(160, 160, 160, A(220));
|
|
}
|
|
if (qSym) {
|
|
drawList->AddText(ImVec2(questIconX + 1.0f, nameY + 1.0f), IM_COL32(0, 0, 0, A(160)), qSym);
|
|
drawList->AddText(ImVec2(questIconX, nameY), qCol, qSym);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Click to target / right-click context: detect clicks inside the nameplate region.
|
|
// Use the wider of name text or health bar for the horizontal hit area so short
|
|
// names like "Wolf" don't produce a tiny clickable strip narrower than the bar.
|
|
if (!ImGui::GetIO().WantCaptureMouse) {
|
|
ImVec2 mouse = ImGui::GetIO().MousePos;
|
|
float hitLeft = std::min(nameX, barX) - 2.0f;
|
|
float hitRight = std::max(nameX + textSize.x, barX + barW) + 2.0f;
|
|
float ny0 = nameY - 1.0f;
|
|
float ny1 = sy + barH + 2.0f;
|
|
float nx0 = hitLeft;
|
|
float nx1 = hitRight;
|
|
if (mouse.x >= nx0 && mouse.x <= nx1 && mouse.y >= ny0 && mouse.y <= ny1) {
|
|
// Track mouseover for [target=mouseover] macro conditionals
|
|
gameHandler.setMouseoverGuid(guid);
|
|
// Hover tooltip: name, level/class, guild
|
|
ImGui::BeginTooltip();
|
|
ImGui::TextUnformatted(unitName.c_str());
|
|
if (isPlayer) {
|
|
uint8_t cid = entityClassId(unit);
|
|
ImGui::Text("Level %u %s", level, classNameStr(cid));
|
|
} else if (level > 0) {
|
|
ImGui::Text("Level %u", level);
|
|
}
|
|
if (!subLabel.empty()) ImGui::TextColored(ImVec4(0.7f,0.7f,0.7f,1.0f), "%s", subLabel.c_str());
|
|
ImGui::EndTooltip();
|
|
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
|
|
gameHandler.setTarget(guid);
|
|
} else if (ImGui::IsMouseClicked(ImGuiMouseButton_Right)) {
|
|
nameplateCtxGuid_ = guid;
|
|
nameplateCtxPos_ = mouse;
|
|
ImGui::OpenPopup("##NameplateCtx");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Render nameplate context popup (uses a tiny overlay window as host)
|
|
if (nameplateCtxGuid_ != 0) {
|
|
ImGui::SetNextWindowPos(nameplateCtxPos_, ImGuiCond_Appearing);
|
|
ImGui::SetNextWindowSize(ImVec2(0, 0), ImGuiCond_Always);
|
|
ImGuiWindowFlags ctxHostFlags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
|
|
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar |
|
|
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoFocusOnAppearing |
|
|
ImGuiWindowFlags_AlwaysAutoResize;
|
|
if (ImGui::Begin("##NameplateCtxHost", nullptr, ctxHostFlags)) {
|
|
if (ImGui::BeginPopup("##NameplateCtx")) {
|
|
auto entityPtr = gameHandler.getEntityManager().getEntity(nameplateCtxGuid_);
|
|
std::string ctxName = entityPtr ? getEntityName(entityPtr) : "";
|
|
if (!ctxName.empty()) {
|
|
ImGui::TextDisabled("%s", ctxName.c_str());
|
|
ImGui::Separator();
|
|
}
|
|
if (ImGui::MenuItem("Target"))
|
|
gameHandler.setTarget(nameplateCtxGuid_);
|
|
if (ImGui::MenuItem("Set Focus"))
|
|
gameHandler.setFocus(nameplateCtxGuid_);
|
|
bool isPlayer = entityPtr && entityPtr->getType() == game::ObjectType::PLAYER;
|
|
if (isPlayer && !ctxName.empty()) {
|
|
ImGui::Separator();
|
|
if (ImGui::MenuItem("Whisper")) {
|
|
chatPanel_.setWhisperTarget(ctxName);
|
|
}
|
|
if (ImGui::MenuItem("Invite to Group"))
|
|
gameHandler.inviteToGroup(ctxName);
|
|
if (ImGui::MenuItem("Trade"))
|
|
gameHandler.initiateTrade(nameplateCtxGuid_);
|
|
if (ImGui::MenuItem("Duel"))
|
|
gameHandler.proposeDuel(nameplateCtxGuid_);
|
|
if (ImGui::MenuItem("Inspect")) {
|
|
gameHandler.setTarget(nameplateCtxGuid_);
|
|
gameHandler.inspectTarget();
|
|
socialPanel_.showInspectWindow_ = true;
|
|
}
|
|
ImGui::Separator();
|
|
if (ImGui::MenuItem("Add Friend"))
|
|
gameHandler.addFriend(ctxName);
|
|
if (ImGui::MenuItem("Ignore"))
|
|
gameHandler.addIgnore(ctxName);
|
|
}
|
|
ImGui::EndPopup();
|
|
} else {
|
|
nameplateCtxGuid_ = 0;
|
|
}
|
|
}
|
|
ImGui::End();
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// Durability Warning (equipment damage indicator)
|
|
// ============================================================
|
|
|
|
void GameScreen::takeScreenshot(game::GameHandler& /*gameHandler*/) {
|
|
auto* renderer = services_.renderer;
|
|
if (!renderer) return;
|
|
|
|
// Build path: ~/.wowee/screenshots/WoWee_YYYYMMDD_HHMMSS.png
|
|
const char* home = std::getenv("HOME");
|
|
if (!home) home = std::getenv("USERPROFILE");
|
|
if (!home) home = "/tmp";
|
|
std::string dir = std::string(home) + "/.wowee/screenshots";
|
|
|
|
auto now = std::chrono::system_clock::now();
|
|
auto tt = std::chrono::system_clock::to_time_t(now);
|
|
std::tm tm{};
|
|
#ifdef _WIN32
|
|
localtime_s(&tm, &tt);
|
|
#else
|
|
localtime_r(&tt, &tm);
|
|
#endif
|
|
|
|
char filename[128];
|
|
std::snprintf(filename, sizeof(filename),
|
|
"WoWee_%04d%02d%02d_%02d%02d%02d.png",
|
|
tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday,
|
|
tm.tm_hour, tm.tm_min, tm.tm_sec);
|
|
|
|
std::string path = dir + "/" + filename;
|
|
|
|
if (renderer->captureScreenshot(path)) {
|
|
game::MessageChatData sysMsg;
|
|
sysMsg.type = game::ChatType::SYSTEM;
|
|
sysMsg.language = game::ChatLanguage::UNIVERSAL;
|
|
sysMsg.message = "Screenshot saved: " + path;
|
|
services_.gameHandler->addLocalChatMessage(sysMsg);
|
|
}
|
|
}
|
|
|
|
void GameScreen::renderDurabilityWarning(game::GameHandler& gameHandler) {
|
|
if (gameHandler.getPlayerGuid() == 0) return;
|
|
|
|
const auto& inv = gameHandler.getInventory();
|
|
|
|
// Scan all equipment slots (skip bag slots which have no durability)
|
|
float minDurPct = 1.0f;
|
|
bool hasBroken = false;
|
|
|
|
for (int i = static_cast<int>(game::EquipSlot::HEAD);
|
|
i < static_cast<int>(game::EquipSlot::BAG1); ++i) {
|
|
const auto& slot = inv.getEquipSlot(static_cast<game::EquipSlot>(i));
|
|
if (slot.empty() || slot.item.maxDurability == 0) continue;
|
|
if (slot.item.curDurability == 0) {
|
|
hasBroken = true;
|
|
}
|
|
float pct = static_cast<float>(slot.item.curDurability) /
|
|
static_cast<float>(slot.item.maxDurability);
|
|
if (pct < minDurPct) minDurPct = pct;
|
|
}
|
|
|
|
// Only show warning below 20%
|
|
if (minDurPct >= 0.2f && !hasBroken) return;
|
|
|
|
ImGuiIO& io = ImGui::GetIO();
|
|
const float screenW = io.DisplaySize.x;
|
|
const float screenH = io.DisplaySize.y;
|
|
|
|
// Position: just above the XP bar / action bar area (bottom-center)
|
|
const float warningW = 220.0f;
|
|
const float warningH = 26.0f;
|
|
const float posX = (screenW - warningW) * 0.5f;
|
|
const float posY = screenH - 140.0f; // above action bar
|
|
|
|
ImGui::SetNextWindowPos(ImVec2(posX, posY), ImGuiCond_Always);
|
|
ImGui::SetNextWindowSize(ImVec2(warningW, warningH), ImGuiCond_Always);
|
|
ImGui::SetNextWindowBgAlpha(0.75f);
|
|
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6, 4));
|
|
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f);
|
|
ImGui::PushStyleVar(ImGuiStyleVar_WindowMinSize, ImVec2(0, 0));
|
|
|
|
ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
|
|
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoInputs |
|
|
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings |
|
|
ImGuiWindowFlags_NoBringToFrontOnFocus;
|
|
|
|
if (ImGui::Begin("##durability_warn", nullptr, flags)) {
|
|
if (hasBroken) {
|
|
ImGui::TextColored(ImVec4(1.0f, 0.15f, 0.15f, 1.0f),
|
|
"\xef\x94\x9b Gear broken! Visit a repair NPC");
|
|
} else {
|
|
int pctInt = static_cast<int>(minDurPct * 100.0f);
|
|
ImGui::TextColored(colors::kSymbolGold,
|
|
"\xef\x94\x9b Low durability: %d%%", pctInt);
|
|
}
|
|
if (ImGui::IsWindowHovered())
|
|
ImGui::SetTooltip("Your equipment is damaged. Visit any blacksmith or repair NPC.");
|
|
}
|
|
ImGui::End();
|
|
ImGui::PopStyleVar(3);
|
|
}
|
|
|
|
// ============================================================
|
|
// UI Error Frame (WoW-style center-bottom error overlay)
|
|
// ============================================================
|
|
|
|
void GameScreen::renderUIErrors(game::GameHandler& /*gameHandler*/, float deltaTime) {
|
|
// Age out old entries
|
|
for (auto& e : uiErrors_) e.age += deltaTime;
|
|
uiErrors_.erase(
|
|
std::remove_if(uiErrors_.begin(), uiErrors_.end(),
|
|
[](const UIErrorEntry& e) { return e.age >= kUIErrorLifetime; }),
|
|
uiErrors_.end());
|
|
|
|
if (uiErrors_.empty()) return;
|
|
|
|
auto* window = services_.window;
|
|
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
|
|
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
|
|
|
|
// Fixed invisible overlay
|
|
ImGui::SetNextWindowPos(ImVec2(0, 0));
|
|
ImGui::SetNextWindowSize(ImVec2(screenW, screenH));
|
|
ImGuiWindowFlags flags = ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoDecoration |
|
|
ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoNav |
|
|
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar;
|
|
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
|
|
if (ImGui::Begin("##UIErrors", nullptr, flags)) {
|
|
// Render messages stacked above the action bar (~200px from bottom)
|
|
// The newest message is on top; older ones fade below it.
|
|
const float baseY = screenH - 200.0f;
|
|
const float lineH = 20.0f;
|
|
const int count = static_cast<int>(uiErrors_.size());
|
|
|
|
ImDrawList* draw = ImGui::GetWindowDrawList();
|
|
for (int i = count - 1; i >= 0; --i) {
|
|
const auto& e = uiErrors_[i];
|
|
float alpha = 1.0f - (e.age / kUIErrorLifetime);
|
|
alpha = std::max(0.0f, std::min(1.0f, alpha));
|
|
|
|
// Fade fast in the last 0.5 s
|
|
if (e.age > kUIErrorLifetime - 0.5f)
|
|
alpha *= (kUIErrorLifetime - e.age) / 0.5f;
|
|
|
|
uint8_t a8 = static_cast<uint8_t>(alpha * 255.0f);
|
|
ImU32 textCol = IM_COL32(255, 50, 50, a8);
|
|
ImU32 shadowCol= IM_COL32( 0, 0, 0, static_cast<uint8_t>(alpha * 180));
|
|
|
|
const char* txt = e.text.c_str();
|
|
ImVec2 sz = ImGui::CalcTextSize(txt);
|
|
float x = std::round((screenW - sz.x) * 0.5f);
|
|
float y = std::round(baseY - (count - 1 - i) * lineH);
|
|
|
|
// Drop shadow
|
|
draw->AddText(ImVec2(x + 1, y + 1), shadowCol, txt);
|
|
draw->AddText(ImVec2(x, y), textCol, txt);
|
|
}
|
|
}
|
|
ImGui::End();
|
|
ImGui::PopStyleVar();
|
|
}
|
|
|
|
void GameScreen::renderQuestMarkers(game::GameHandler& gameHandler) {
|
|
const auto& statuses = gameHandler.getNpcQuestStatuses();
|
|
if (statuses.empty()) return;
|
|
|
|
auto* renderer = services_.renderer;
|
|
auto* camera = renderer ? renderer->getCamera() : nullptr;
|
|
auto* window = services_.window;
|
|
if (!camera || !window) return;
|
|
|
|
float screenW = static_cast<float>(window->getWidth());
|
|
float screenH = static_cast<float>(window->getHeight());
|
|
glm::mat4 viewProj = camera->getViewProjectionMatrix();
|
|
auto* drawList = ImGui::GetForegroundDrawList();
|
|
|
|
for (const auto& [guid, status] : statuses) {
|
|
// Only show markers for available (!) and reward/completable (?)
|
|
const char* marker = nullptr;
|
|
ImU32 color = IM_COL32(255, 210, 0, 255); // yellow
|
|
if (status == game::QuestGiverStatus::AVAILABLE) {
|
|
marker = "!";
|
|
} else if (status == game::QuestGiverStatus::AVAILABLE_LOW) {
|
|
marker = "!";
|
|
color = IM_COL32(160, 160, 160, 255); // gray
|
|
} else if (status == game::QuestGiverStatus::REWARD ||
|
|
status == game::QuestGiverStatus::REWARD_REP) {
|
|
marker = "?";
|
|
} else if (status == game::QuestGiverStatus::INCOMPLETE) {
|
|
marker = "?";
|
|
color = IM_COL32(160, 160, 160, 255); // gray
|
|
} else {
|
|
continue;
|
|
}
|
|
|
|
// Get entity position (canonical coords)
|
|
auto entity = gameHandler.getEntityManager().getEntity(guid);
|
|
if (!entity) continue;
|
|
|
|
glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ());
|
|
glm::vec3 renderPos = core::coords::canonicalToRender(canonical);
|
|
|
|
// Get model height for offset
|
|
float heightOffset = 3.0f;
|
|
glm::vec3 boundsCenter;
|
|
float boundsRadius = 0.0f;
|
|
if (core::Application::getInstance().getRenderBoundsForGuid(guid, boundsCenter, boundsRadius)) {
|
|
heightOffset = boundsRadius * 2.0f + 1.0f;
|
|
}
|
|
renderPos.z += heightOffset;
|
|
|
|
// Project to screen
|
|
glm::vec4 clipPos = viewProj * glm::vec4(renderPos, 1.0f);
|
|
if (clipPos.w <= 0.0f) continue;
|
|
|
|
glm::vec2 ndc(clipPos.x / clipPos.w, clipPos.y / clipPos.w);
|
|
float sx = (ndc.x + 1.0f) * 0.5f * screenW;
|
|
float sy = (1.0f - ndc.y) * 0.5f * screenH;
|
|
|
|
// Skip if off-screen
|
|
if (sx < -50 || sx > screenW + 50 || sy < -50 || sy > screenH + 50) continue;
|
|
|
|
// Scale text size based on distance
|
|
float dist = clipPos.w;
|
|
float fontSize = std::clamp(800.0f / dist, 14.0f, 48.0f);
|
|
|
|
// Draw outlined text: 4 shadow copies then main text
|
|
ImFont* font = ImGui::GetFont();
|
|
ImU32 outlineColor = IM_COL32(0, 0, 0, 220);
|
|
float off = std::max(1.0f, fontSize * 0.06f);
|
|
ImVec2 textSize = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, marker);
|
|
float tx = sx - textSize.x * 0.5f;
|
|
float ty = sy - textSize.y * 0.5f;
|
|
|
|
drawList->AddText(font, fontSize, ImVec2(tx - off, ty), outlineColor, marker);
|
|
drawList->AddText(font, fontSize, ImVec2(tx + off, ty), outlineColor, marker);
|
|
drawList->AddText(font, fontSize, ImVec2(tx, ty - off), outlineColor, marker);
|
|
drawList->AddText(font, fontSize, ImVec2(tx, ty + off), outlineColor, marker);
|
|
drawList->AddText(font, fontSize, ImVec2(tx, ty), color, marker);
|
|
}
|
|
}
|
|
|
|
|
|
}} // namespace wowee::ui
|