Kelsidavis-WoWee/src/rendering/world_map/world_map_facade.cpp
Pavel Okhlopkov 97c95941f4 feat(world-map): remove kVOffset hack, ZMP hover, textured player arrow
- Remove the -0.15 vertical offset (kVOffset) from coordinate_projection,
  coordinate_display, and zone_highlight_layer; continent UV math is now
  identical to zone UV math
- Switch world_map_facade aspect ratio to MAP_W/MAP_H (1002×668) and crop
  the FBO image with MAP_U_MAX/MAP_V_MAX instead of stretching the full
  1024×768 FBO
- Account for ImGui title bar height (GetFrameHeight) in window sizing and
  zone highlight screen-space rect coordinates
- Add ZMP 128×128 grid pixel-accurate hover detection in zone_highlight_layer;
  falls back to AABB when ZMP data is unavailable
- Upgrade PlayerMarkerLayer with full Vulkan lifecycle (initialize,
  clearTexture, destructor); loads MinimapArrow.blp and renders a rotated
  32×32 textured quad via AddImageQuad; red triangle retained as fallback
- Expose arrowRotation_ / arrowDS_ accessors on Minimap; clean up arrow DS
  and texture in Minimap::shutdown()
- Wire PlayerMarkerLayer::initialize() into WorldMapFacade::initialize()
- Update coordinate-projection test: continent and zone UV are now equal

Signed-off-by: Pavel Okhlopkov <pavel.okhlopkov@flant.com>
2026-04-12 20:02:50 +03:00

1072 lines
48 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// world_map_facade.cpp — Public API for the world map system.
// Composes all extracted components and orchestrates the world map (Phase 10).
#include "rendering/world_map/world_map_facade.hpp"
#include "rendering/world_map/data_repository.hpp"
#include "rendering/world_map/view_state_machine.hpp"
#include "rendering/world_map/composite_renderer.hpp"
#include "rendering/world_map/exploration_state.hpp"
#include "rendering/world_map/zone_metadata.hpp"
#include "rendering/world_map/coordinate_projection.hpp"
#include "rendering/world_map/map_resolver.hpp"
#include "rendering/world_map/overlay_renderer.hpp"
#include "rendering/world_map/input_handler.hpp"
#include "rendering/world_map/layers/player_marker_layer.hpp"
#include "rendering/world_map/layers/party_dot_layer.hpp"
#include "rendering/world_map/layers/taxi_node_layer.hpp"
#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/zone_highlight_layer.hpp"
#include "rendering/world_map/layers/coordinate_display.hpp"
#include "rendering/world_map/layers/subzone_tooltip_layer.hpp"
#include "rendering/vk_context.hpp"
#include "pipeline/asset_manager.hpp"
#include "ui/ui_colors.hpp"
#include "game/game_utils.hpp"
#include "core/logger.hpp"
#include <imgui.h>
#include <cmath>
#include <algorithm>
namespace wowee {
namespace rendering {
namespace world_map {
// Find the zone index for the WORLD view background.
// Find the best continent root zone for displaying a map in CONTINENT view.
// Skips synthetic zones (Cosmic, World) and prefers a zone matching mapName.
static int findContinentRootIdx(const std::vector<Zone>& zones,
int cosmicIdx,
int worldIdx,
const std::string& mapName) {
LOG_INFO("findContinentRootIdx: searching ", zones.size(), " zones, mapName='", mapName, "'");
// 1) Exact areaName match for the map name (e.g. "Azeroth", "Kalimdor")
for (int i = 0; i < static_cast<int>(zones.size()); i++) {
if (i == cosmicIdx || i == worldIdx) continue;
if (zones[i].areaID == 0 && zones[i].areaName == mapName) {
LOG_INFO("findContinentRootIdx: matched mapName '", mapName, "' at zone[", i, "]");
return i;
}
}
// 2) Root continent (parent of leaf continents)
int firstContinent = -1;
for (int i = 0; i < static_cast<int>(zones.size()); i++) {
if (i == cosmicIdx || i == worldIdx) continue;
if (zones[i].areaID == 0) {
if (firstContinent < 0) firstContinent = i;
if (isRootContinent(zones, i)) return i;
}
}
// 3) First continent entry
return firstContinent;
}
// Find the best zone for the WORLD view (prefers synthetic "World" zone).
// Used only as a fallback when data.worldIdx() is not available.
static int findWorldViewContinentIdx(const std::vector<Zone>& zones,
int cosmicIdx,
const std::string& mapName) {
LOG_INFO("findWorldViewContinentIdx: searching ", zones.size(), " zones, cosmicIdx=", cosmicIdx, " mapName='", mapName, "'");
// 1) Exact areaName match for "World" folder
for (int i = 0; i < static_cast<int>(zones.size()); i++) {
if (i == cosmicIdx) continue;
if (zones[i].areaID == 0 && zones[i].areaName == "World") {
LOG_INFO("findWorldViewContinentIdx: matched 'World' at zone[", i, "]");
return i;
}
}
// 2) Exact areaName match for the map name (e.g. "Azeroth")
for (int i = 0; i < static_cast<int>(zones.size()); i++) {
if (i == cosmicIdx) continue;
if (zones[i].areaID == 0 && zones[i].areaName == mapName) {
LOG_INFO("findWorldViewContinentIdx: matched mapName '", mapName, "' at zone[", i, "]");
return i;
}
}
// 3) Root continent (parent of leaf continents)
int firstContinent = -1;
for (int i = 0; i < static_cast<int>(zones.size()); i++) {
if (i == cosmicIdx) continue;
if (zones[i].areaID == 0) {
if (firstContinent < 0) firstContinent = i;
if (isRootContinent(zones, i)) return i;
}
}
// 4) First continent entry
return firstContinent;
}
// ── PIMPL Implementation ─────────────────────────────────────
struct WorldMapFacade::Impl {
VkContext* vkCtx = nullptr;
pipeline::AssetManager* assetManager = nullptr;
bool initialized = false;
bool open = false;
std::string mapName = "Azeroth";
std::string pendingMapName; // stored by external setMapName while in world/cosmic view
bool userMapOverride = false; // true when user manually navigated to world/cosmic view
DataRepository data;
ViewStateMachine viewState;
CompositeRenderer compositor;
ExplorationState exploration;
ZoneMetadata zoneMetadata;
InputHandler input;
OverlayRenderer overlay;
// Typed layer pointers for setters (non-owning references into overlay)
PartyDotLayer* partyDotLayer = nullptr;
TaxiNodeLayer* taxiNodeLayer = nullptr;
POIMarkerLayer* poiMarkerLayer = nullptr;
QuestPOILayer* questPOILayer = nullptr;
CorpseMarkerLayer* corpseMarkerLayer = nullptr;
ZoneHighlightLayer* zoneHighlightLayer = nullptr;
PlayerMarkerLayer* playerMarkerLayer = nullptr;
// Data set each frame from the UI layer
std::vector<PartyDot> partyDots;
std::vector<TaxiNode> taxiNodes;
std::vector<QuestPOI> questPois;
float lastFrameTime = 0.0f;
void initOverlayLayers();
void switchToMap(const std::string& newMapName);
void switchToWorldView();
void renderImGuiOverlay(const glm::vec3& playerRenderPos,
int screenWidth, int screenHeight,
float playerYawDeg,
bool rightClickConsumed);
};
void WorldMapFacade::Impl::switchToMap(const std::string& newMapName) {
if (mapName == newMapName && !data.zones().empty()) return;
userMapOverride = true;
pendingMapName.clear();
if (zoneHighlightLayer) zoneHighlightLayer->clearTextures();
compositor.detachZoneTextures();
data.clear();
compositor.invalidateComposite();
mapName = newMapName;
data.loadZones(mapName, *assetManager);
zoneMetadata.initialize();
viewState.setCosmicEnabled(data.cosmicEnabled());
// Find the continent root zone and display it (skip synthetic World/Cosmic)
int rootIdx = findContinentRootIdx(data.zones(), data.cosmicIdx(), data.worldIdx(), mapName);
if (rootIdx < 0) rootIdx = 0;
viewState.setContinentIdx(rootIdx);
compositor.loadZoneTextures(rootIdx, data.zones(), mapName);
compositor.requestComposite(rootIdx);
viewState.setCurrentZoneIdx(rootIdx);
viewState.setLevel(ViewLevel::CONTINENT);
}
void WorldMapFacade::Impl::switchToWorldView() {
LOG_INFO("switchToWorldView: mapName='", mapName, "'");
// Determine whether the current map is an Azeroth continent (EK, Kalimdor,
// Northrend) or a separate world (Outland). Azeroth continents go to the
// world view; other worlds go to the cosmic view.
bool isAzerothContinent = (mapName == "Azeroth");
if (!isAzerothContinent) {
int curMapId = folderToMapId(mapName);
for (const auto& region : data.azerothRegions()) {
if (static_cast<int>(region.mapId) == curMapId) {
isAzerothContinent = true;
break;
}
}
}
// If on a different map, switch back to Azeroth first.
if (mapName != "Azeroth") {
if (zoneHighlightLayer) zoneHighlightLayer->clearTextures();
compositor.detachZoneTextures();
data.clear();
compositor.invalidateComposite();
mapName = "Azeroth";
data.loadZones(mapName, *assetManager);
zoneMetadata.initialize();
viewState.setCosmicEnabled(data.cosmicEnabled());
}
userMapOverride = true;
// Non-Azeroth worlds (e.g. Outland) go to cosmic view.
if (!isAzerothContinent && viewState.cosmicEnabled() && data.cosmicIdx() >= 0) {
viewState.enterCosmicView();
compositor.loadZoneTextures(data.cosmicIdx(), data.zones(), mapName);
compositor.requestComposite(data.cosmicIdx());
viewState.setCurrentZoneIdx(data.cosmicIdx());
return;
}
viewState.enterWorldView();
// Use the dedicated synthetic "World" zone — its tiles (world1-12.blp)
// are cached independently from zone[0] (Azeroth), avoiding stale-tile
// conflicts when transitioning between WORLD and CONTINENT views.
int worldIdx = data.worldIdx();
LOG_INFO("switchToWorldView: worldIdx=", worldIdx);
if (worldIdx >= 0) {
compositor.loadZoneTextures(worldIdx, data.zones(), mapName);
if (compositor.hasAnyTile(worldIdx)) {
compositor.invalidateComposite();
compositor.requestComposite(worldIdx);
viewState.setCurrentZoneIdx(worldIdx);
return;
}
}
// Fallback: try the root continent zone
int rootIdx = findWorldViewContinentIdx(data.zones(), data.cosmicIdx(), mapName);
LOG_INFO("switchToWorldView: fallback rootIdx=", rootIdx);
if (rootIdx >= 0) {
compositor.loadZoneTextures(rootIdx, data.zones(), mapName);
if (compositor.hasAnyTile(rootIdx)) {
compositor.invalidateComposite();
compositor.requestComposite(rootIdx);
viewState.setCurrentZoneIdx(rootIdx);
}
}
}
void WorldMapFacade::Impl::initOverlayLayers() {
// Order matters: later layers draw on top of earlier ones
// Zone highlights (continent view)
auto zhLayer = std::make_unique<ZoneHighlightLayer>();
zhLayer->setMetadata(&zoneMetadata);
zoneHighlightLayer = zhLayer.get();
overlay.addLayer(std::move(zhLayer));
// Player marker
auto pmLayer = std::make_unique<PlayerMarkerLayer>();
playerMarkerLayer = pmLayer.get();
overlay.addLayer(std::move(pmLayer));
// Party dots
auto pdLayer = std::make_unique<PartyDotLayer>();
partyDotLayer = pdLayer.get();
overlay.addLayer(std::move(pdLayer));
// Taxi nodes
auto tnLayer = std::make_unique<TaxiNodeLayer>();
taxiNodeLayer = tnLayer.get();
overlay.addLayer(std::move(tnLayer));
// // POI markers
// auto poiLayer = std::make_unique<POIMarkerLayer>();
// poiMarkerLayer = poiLayer.get();
// overlay.addLayer(std::move(poiLayer));
// Quest POI markers
auto qpLayer = std::make_unique<QuestPOILayer>();
questPOILayer = qpLayer.get();
overlay.addLayer(std::move(qpLayer));
// Corpse marker
auto cmLayer = std::make_unique<CorpseMarkerLayer>();
corpseMarkerLayer = cmLayer.get();
overlay.addLayer(std::move(cmLayer));
// Coordinate display
overlay.addLayer(std::make_unique<CoordinateDisplay>());
// Subzone tooltip
overlay.addLayer(std::make_unique<SubzoneTooltipLayer>());
}
// ── WorldMapFacade Public Methods ────────────────────────────
WorldMapFacade::WorldMapFacade() : impl_(std::make_unique<Impl>()) {
impl_->zoneMetadata.initialize();
impl_->initOverlayLayers();
}
WorldMapFacade::~WorldMapFacade() {
shutdown();
}
bool WorldMapFacade::initialize(VkContext* ctx, pipeline::AssetManager* am) {
impl_->vkCtx = ctx;
impl_->assetManager = am;
if (!impl_->compositor.initialize(ctx, am)) return false;
if (impl_->zoneHighlightLayer)
impl_->zoneHighlightLayer->initialize(ctx, am);
if (impl_->playerMarkerLayer)
impl_->playerMarkerLayer->initialize(ctx, am);
impl_->initialized = true;
return true;
}
void WorldMapFacade::shutdown() {
if (!impl_) return;
if (impl_->zoneHighlightLayer)
impl_->zoneHighlightLayer->clearTextures();
impl_->compositor.shutdown();
impl_->data.clear();
impl_->initialized = false;
}
void WorldMapFacade::compositePass(VkCommandBuffer cmd) {
impl_->compositor.flushStaleTextures();
impl_->compositor.compositePass(cmd,
impl_->data.zones(),
impl_->exploration.exploredOverlays(),
impl_->exploration.hasServerMask());
}
void WorldMapFacade::render(const glm::vec3& playerRenderPos,
int screenWidth, int screenHeight,
float playerYawDeg) {
auto& d = *impl_;
if (!d.initialized || !d.assetManager) return;
// Update transition animation
float now = static_cast<float>(ImGui::GetTime());
float dt = now - d.lastFrameTime;
d.lastFrameTime = now;
d.viewState.updateTransition(dt);
// Update exploration state
if (!d.data.zones().empty()) {
d.exploration.update(d.data.zones(), playerRenderPos,
d.viewState.currentZoneIdx(),
d.data.exploreFlagByAreaId());
if (d.exploration.overlaysChanged() && d.viewState.currentZoneIdx() >= 0) {
d.compositor.invalidateComposite();
d.compositor.requestComposite(d.viewState.currentZoneIdx());
}
}
// First-time open or zones lost after map change
if (!d.open || d.data.zones().empty()) {
d.open = true;
if (d.data.zones().empty()) {
d.data.loadZones(d.mapName, *d.assetManager);
d.zoneMetadata.initialize();
d.viewState.setCosmicEnabled(d.data.cosmicEnabled());
}
int bestContinent = findBestContinentForPlayer(d.data.zones(), playerRenderPos);
if (bestContinent >= 0 && bestContinent != d.viewState.continentIdx()) {
d.viewState.setContinentIdx(bestContinent);
d.compositor.invalidateComposite();
}
int playerZone = findZoneForPlayer(d.data.zones(), playerRenderPos);
if (playerZone >= 0 && d.viewState.continentIdx() >= 0 &&
zoneBelongsToContinent(d.data.zones(), playerZone, d.viewState.continentIdx())) {
d.compositor.loadZoneTextures(playerZone, d.data.zones(), d.mapName);
d.compositor.loadOverlayTextures(playerZone, d.data.zones());
d.viewState.setCurrentZoneIdx(playerZone);
d.viewState.setLevel(ViewLevel::ZONE);
d.exploration.update(d.data.zones(), playerRenderPos, playerZone,
d.data.exploreFlagByAreaId());
d.compositor.requestComposite(playerZone);
} else if (d.viewState.continentIdx() >= 0) {
d.compositor.loadZoneTextures(d.viewState.continentIdx(), d.data.zones(), d.mapName);
d.compositor.requestComposite(d.viewState.continentIdx());
d.viewState.setCurrentZoneIdx(d.viewState.continentIdx());
d.viewState.setLevel(ViewLevel::CONTINENT);
}
}
// Process input
int hoveredZone = d.zoneHighlightLayer ? d.zoneHighlightLayer->hoveredZone() : -1;
InputResult inputResult = d.input.process(d.viewState.currentLevel(),
hoveredZone,
d.viewState.cosmicEnabled());
switch (inputResult.action) {
case InputAction::CLOSE:
d.open = false;
d.userMapOverride = false;
if (!d.pendingMapName.empty()) {
d.mapName = d.pendingMapName;
d.pendingMapName.clear();
}
return;
case InputAction::ZOOM_IN: {
int playerZone = findZoneForPlayer(d.data.zones(), playerRenderPos);
// For continent→zone, verify the zone belongs to the current continent
int candidateZone = hoveredZone >= 0 ? hoveredZone : playerZone;
if (d.viewState.currentLevel() == ViewLevel::CONTINENT &&
candidateZone >= 0 &&
!zoneBelongsToContinent(d.data.zones(), candidateZone, d.viewState.continentIdx())) {
candidateZone = -1;
}
// Bug fix: also validate playerZone against the continent so the
// fallback inside zoomIn() doesn't navigate to the wrong continent.
int validPlayerZone = playerZone;
if (d.viewState.currentLevel() == ViewLevel::CONTINENT &&
validPlayerZone >= 0 &&
!zoneBelongsToContinent(d.data.zones(), validPlayerZone, d.viewState.continentIdx())) {
validPlayerZone = -1;
}
auto zr = d.viewState.zoomIn(candidateZone, validPlayerZone);
if (zr.changed && zr.targetIdx >= 0) {
d.compositor.loadZoneTextures(zr.targetIdx, d.data.zones(), d.mapName);
if (zr.newLevel == ViewLevel::ZONE) {
d.compositor.loadOverlayTextures(zr.targetIdx, d.data.zones());
}
d.compositor.requestComposite(zr.targetIdx);
} else if (zr.changed && zr.newLevel == ViewLevel::WORLD) {
d.switchToWorldView();
}
break;
}
case InputAction::ZOOM_OUT: {
auto zr = d.viewState.zoomOut();
if (zr.changed && zr.targetIdx >= 0) {
d.compositor.loadZoneTextures(zr.targetIdx, d.data.zones(), d.mapName);
d.compositor.requestComposite(zr.targetIdx);
} else if (zr.changed && zr.newLevel == ViewLevel::WORLD) {
d.switchToWorldView();
} else if (zr.changed && zr.newLevel == ViewLevel::COSMIC) {
if (d.data.cosmicIdx() >= 0) {
d.compositor.loadZoneTextures(d.data.cosmicIdx(), d.data.zones(), d.mapName);
d.compositor.requestComposite(d.data.cosmicIdx());
d.viewState.setCurrentZoneIdx(d.data.cosmicIdx());
}
}
break;
}
case InputAction::CLICK_ZONE: {
int hz = inputResult.targetIdx;
if (hz >= 0) {
// Use centralized resolver to handle cross-map zone navigation
auto zoneResult = resolveZoneClick(hz, d.data.zones(), d.data.currentMapId());
switch (zoneResult.action) {
case MapResolveAction::LOAD_MAP:
d.switchToMap(zoneResult.targetMapName);
break;
case MapResolveAction::ENTER_ZONE:
d.compositor.loadZoneTextures(hz, d.data.zones(), d.mapName);
d.compositor.loadOverlayTextures(hz, d.data.zones());
d.compositor.requestComposite(hz);
d.viewState.enterZone(hz);
break;
default:
break;
}
}
break;
}
case InputAction::RIGHT_CLICK_BACK: {
// Only process right-click if we're at zone or continent level
if (d.viewState.currentLevel() == ViewLevel::ZONE &&
d.viewState.continentIdx() >= 0) {
d.compositor.loadZoneTextures(d.viewState.continentIdx(), d.data.zones(), d.mapName);
d.compositor.requestComposite(d.viewState.continentIdx());
d.viewState.setCurrentZoneIdx(d.viewState.continentIdx());
d.viewState.setLevel(ViewLevel::CONTINENT);
} else if (d.viewState.currentLevel() == ViewLevel::CONTINENT) {
d.switchToWorldView();
} else if (d.viewState.currentLevel() == ViewLevel::WORLD &&
d.viewState.cosmicEnabled()) {
d.viewState.enterCosmicView();
if (d.data.cosmicIdx() >= 0) {
d.compositor.loadZoneTextures(d.data.cosmicIdx(), d.data.zones(), d.mapName);
d.compositor.requestComposite(d.data.cosmicIdx());
d.viewState.setCurrentZoneIdx(d.data.cosmicIdx());
}
}
break;
}
default:
break;
}
if (!d.open) return;
bool rightClickConsumed = (inputResult.action == InputAction::RIGHT_CLICK_BACK);
d.renderImGuiOverlay(playerRenderPos, screenWidth, screenHeight, playerYawDeg, rightClickConsumed);
}
void WorldMapFacade::setMapName(const std::string& name) {
auto& d = *impl_;
// While the user has manually navigated to the world/cosmic overview,
// remember the game's desired map but don't reset the view.
if (d.userMapOverride) {
d.pendingMapName = name;
return;
}
if (d.mapName == name && !d.data.zones().empty()) return;
d.mapName = name;
if (d.zoneHighlightLayer)
d.zoneHighlightLayer->clearTextures();
d.compositor.detachZoneTextures();
d.data.clear();
d.viewState.setContinentIdx(-1);
d.viewState.setCurrentZoneIdx(-1);
d.compositor.invalidateComposite();
d.viewState.setLevel(ViewLevel::WORLD);
d.open = false;
}
void WorldMapFacade::setServerExplorationMask(const std::vector<uint32_t>& masks, bool hasData) {
impl_->exploration.setServerMask(masks, hasData);
}
void WorldMapFacade::setPartyDots(std::vector<PartyDot> dots) {
impl_->partyDots = std::move(dots);
}
void WorldMapFacade::setTaxiNodes(std::vector<TaxiNode> nodes) {
impl_->taxiNodes = std::move(nodes);
}
void WorldMapFacade::setQuestPois(std::vector<QuestPOI> pois) {
impl_->questPois = std::move(pois);
}
void WorldMapFacade::setCorpsePos(bool hasCorpse, glm::vec3 renderPos) {
if (impl_->corpseMarkerLayer)
impl_->corpseMarkerLayer->setCorpse(hasCorpse, renderPos);
}
bool WorldMapFacade::isOpen() const { return impl_->open; }
void WorldMapFacade::close() {
impl_->open = false;
impl_->userMapOverride = false;
// Apply any map name that was deferred while in world/cosmic view
if (!impl_->pendingMapName.empty()) {
impl_->mapName = impl_->pendingMapName;
impl_->pendingMapName.clear();
}
}
// ── ImGui Overlay ────────────────────────────────────────────
void WorldMapFacade::Impl::renderImGuiOverlay(const glm::vec3& playerRenderPos,
int screenWidth, int screenHeight,
float playerYawDeg,
bool rightClickConsumed) {
float sw = static_cast<float>(screenWidth);
float sh = static_cast<float>(screenHeight);
// Use the visible WoW map area (1002×668) for aspect ratio.
float mapAspect = static_cast<float>(CompositeRenderer::MAP_W) /
static_cast<float>(CompositeRenderer::MAP_H);
float availW = sw * 0.70f;
float availH = sh * 0.70f;
float displayW, displayH;
if (availW / availH > mapAspect) {
displayH = availH;
displayW = availH * mapAspect;
} else {
displayW = availW;
displayH = availW / mapAspect;
}
// Floor to pixel boundary
displayW = std::floor(displayW);
displayH = std::floor(displayH);
// Account for the ImGui title bar so the content area matches the map
float titleBarH = ImGui::GetFrameHeight();
float windowW = displayW;
float windowH = displayH + titleBarH;
float mapX = std::floor((sw - windowW) / 2.0f);
float mapY = std::floor((sh - windowH) / 2.0f);
// Map window — styled like the character selection window
ImGui::SetNextWindowPos(ImVec2(mapX, mapY), ImGuiCond_Once);
ImGui::SetNextWindowSize(ImVec2(windowW, windowH), ImGuiCond_Always);
ImGuiWindowFlags flags = ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize |
ImGuiWindowFlags_NoScrollbar |
ImGuiWindowFlags_NoScrollWithMouse |
ImGuiWindowFlags_NoFocusOnAppearing;
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0, 0));
// Bug fix: pass nullptr instead of &open so ImGui's X-button doesn't
// set open=false directly — that bypasses cleanup (userMapOverride,
// pendingMapName) and causes immediate re-open on next render() call.
// Close is handled by ESC / InputAction::CLOSE instead.
if (ImGui::Begin("World Map", nullptr, flags)) {
ImDrawList* drawList = ImGui::GetWindowDrawList();
// imgMin/imgMax = the content area (after title bar)
ImVec2 contentPos = ImGui::GetCursorScreenPos();
ImVec2 contentSize = ImGui::GetContentRegionAvail();
ImVec2 imgMin = contentPos;
ImVec2 imgMax(contentPos.x + contentSize.x, contentPos.y + contentSize.y);
displayW = contentSize.x;
displayH = contentSize.y;
// Show only the visible 1002×668 content region of the 1024×768 FBO.
ImGui::Image(
reinterpret_cast<ImTextureID>(compositor.displayDescriptorSet()),
ImVec2(displayW, displayH),
ImVec2(0, 0), ImVec2(CompositeRenderer::MAP_U_MAX,
CompositeRenderer::MAP_V_MAX));
// Transition fade overlay
const auto& trans = viewState.transition();
if (trans.active) {
float alpha = std::max(0.0f, 1.0f - trans.progress);
if (alpha > 0.01f) {
uint8_t fadeAlpha = static_cast<uint8_t>(alpha * 180.0f);
drawList->AddRectFilled(imgMin, imgMax,
IM_COL32(0, 0, 0, fadeAlpha));
}
}
// Build continent index list (expansion-aware filtering, excludes cosmic)
std::vector<int> continentIndices;
int cosmicZoneIdx = data.cosmicIdx();
bool hasLeafContinents = false;
for (int i = 0; i < static_cast<int>(data.zones().size()); i++) {
if (i == cosmicZoneIdx) continue;
if (isLeafContinent(data.zones(), i)) { hasLeafContinents = true; break; }
}
for (int i = 0; i < static_cast<int>(data.zones().size()); i++) {
if (i == cosmicZoneIdx) continue;
if (data.zones()[i].areaID != 0) continue;
if (hasLeafContinents) {
if (isLeafContinent(data.zones(), i)) continentIndices.push_back(i);
} else if (!isRootContinent(data.zones(), i)) {
continentIndices.push_back(i);
}
}
if (continentIndices.size() > 1) {
std::vector<int> filtered;
filtered.reserve(continentIndices.size());
for (int idx : continentIndices) {
if (data.zones()[idx].areaName == mapName) continue;
filtered.push_back(idx);
}
if (!filtered.empty()) continentIndices = std::move(filtered);
}
if (continentIndices.empty()) {
for (int i = 0; i < static_cast<int>(data.zones().size()); i++) {
if (i == cosmicZoneIdx) continue;
if (data.zones()[i].areaID == 0) continentIndices.push_back(i);
}
}
// Expansion filtering
{
std::vector<int> expFiltered;
expFiltered.reserve(continentIndices.size());
for (int ci : continentIndices) {
uint32_t mapId = data.zones()[ci].displayMapID;
if (mapId == 530 && game::isPreWotlk() && !game::isActiveExpansion("tbc")) continue;
if (mapId == 571 && game::isPreWotlk()) continue;
expFiltered.push_back(ci);
}
if (!expFiltered.empty()) continentIndices = std::move(expFiltered);
}
// Update layer data pointers
if (partyDotLayer) partyDotLayer->setDots(partyDots);
if (taxiNodeLayer) taxiNodeLayer->setNodes(taxiNodes);
if (poiMarkerLayer) poiMarkerLayer->setMarkers(data.poiMarkers());
if (questPOILayer) questPOILayer->setPois(questPois);
// Build layer context
LayerContext layerCtx;
layerCtx.drawList = drawList;
layerCtx.imgMin = imgMin;
layerCtx.displayW = displayW;
layerCtx.displayH = displayH;
layerCtx.playerRenderPos = playerRenderPos;
layerCtx.playerYawDeg = playerYawDeg;
layerCtx.currentZoneIdx = viewState.currentZoneIdx();
layerCtx.continentIdx = viewState.continentIdx();
layerCtx.currentMapId = data.currentMapId();
layerCtx.viewLevel = viewState.currentLevel();
layerCtx.zones = &data.zones();
layerCtx.exploredZones = &exploration.exploredZones();
layerCtx.exploredOverlays = &exploration.exploredOverlays();
layerCtx.areaNameByAreaId = &data.areaNameByAreaId();
layerCtx.fboW = CompositeRenderer::FBO_W;
layerCtx.fboH = CompositeRenderer::FBO_H;
// ZMP pixel map for continent-view hover
if (data.hasZmpData()) {
layerCtx.zmpGrid = &data.zmpGrid();
layerCtx.hasZmpData = true;
layerCtx.zmpResolveZoneIdx = [](const void* repo, uint32_t areaId) -> int {
return static_cast<const DataRepository*>(repo)->zoneIndexForAreaId(areaId);
};
layerCtx.zmpRepoPtr = &data;
layerCtx.zmpZoneBounds = &data.zmpZoneBounds();
}
// World-level: Azeroth map with clickable continent regions
ViewLevel vl = viewState.currentLevel();
if (vl == ViewLevel::WORLD) {
bool goCosmic = false;
if (viewState.cosmicEnabled() && !rightClickConsumed) {
goCosmic = ImGui::GetIO().MouseClicked[1];
}
// "< Cosmic" back button (only if cosmic view is available for this expansion)
if (viewState.cosmicEnabled()) {
ImGui::SetCursorPos(ImVec2(8.0f, 8.0f));
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.15f, 0.15f, 0.8f));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.3f, 0.3f, 0.1f, 0.9f));
ImGui::PushStyleColor(ImGuiCol_Text, ui::colors::kBrightGold);
if (ImGui::Button("< Cosmic")) goCosmic = true;
ImGui::PopStyleColor(3);
}
if (goCosmic) {
viewState.enterCosmicView();
if (data.cosmicIdx() >= 0) {
compositor.loadZoneTextures(data.cosmicIdx(), data.zones(), mapName);
compositor.requestComposite(data.cosmicIdx());
viewState.setCurrentZoneIdx(data.cosmicIdx());
}
}
// Title
ImVec2 titleSz = ImGui::CalcTextSize("World");
float titleX = imgMin.x + (displayW - titleSz.x) * 0.5f;
float titleY = imgMin.y - titleSz.y - 8.0f;
if (titleY > 0.0f) {
drawList->AddText(ImVec2(titleX + 1.0f, titleY + 1.0f),
IM_COL32(0, 0, 0, 220), "World");
drawList->AddText(ImVec2(titleX, titleY),
IM_COL32(255, 215, 0, 255), "World");
}
// Clickable continent regions on the Azeroth map
ImVec2 mp2 = ImGui::GetMousePos();
auto& io = ImGui::GetIO();
for (const auto& region : data.azerothRegions()) {
float rx0 = imgMin.x + region.uvLeft * displayW;
float ry0 = imgMin.y + region.uvTop * displayH;
float rx1 = imgMin.x + region.uvRight * displayW;
float ry1 = imgMin.y + region.uvBottom * displayH;
bool hovered = (mp2.x >= rx0 && mp2.x <= rx1 &&
mp2.y >= ry0 && mp2.y <= ry1);
if (hovered) {
// Map region mapId to the highlight texture folder name
std::string regionFolder = mapIdToFolder(region.mapId);
// Draw highlight texture covering the full map area
if (zoneHighlightLayer && !regionFolder.empty()) {
ImTextureID hlTex = zoneHighlightLayer->getHighlightTexture(regionFolder);
if (hlTex) {
drawList->AddImage(hlTex,
ImVec2(imgMin.x, imgMin.y),
ImVec2(imgMin.x + displayW, imgMin.y + displayH),
ImVec2(0, 0), ImVec2(1, 1),
IM_COL32(255, 255, 255, 180));
} else {
drawList->AddRectFilled(ImVec2(rx0, ry0), ImVec2(rx1, ry1),
IM_COL32(255, 215, 0, 25));
}
} else {
drawList->AddRectFilled(ImVec2(rx0, ry0), ImVec2(rx1, ry1),
IM_COL32(255, 215, 0, 25));
}
drawList->AddRect(ImVec2(rx0, ry0), ImVec2(rx1, ry1),
IM_COL32(255, 215, 0, 100), 0, 0, 1.5f);
ImFont* font = ImGui::GetFont();
ImVec2 labelSz = font->CalcTextSizeA(ImGui::GetFontSize(), FLT_MAX, 0.0f,
region.label.c_str());
float lx = (rx0 + rx1 - labelSz.x) * 0.5f;
float ly = ry0 - labelSz.y - 4.0f;
if (ly < imgMin.y) ly = ry0 + 4.0f;
drawList->AddText(ImVec2(lx + 1.0f, ly + 1.0f),
IM_COL32(0, 0, 0, 200), region.label.c_str());
drawList->AddText(ImVec2(lx, ly),
IM_COL32(255, 230, 100, 255), region.label.c_str());
if (io.MouseClicked[0]) {
// Use centralized map resolver to determine navigation action
auto resolveResult = resolveWorldRegionClick(
region.mapId, data.zones(), data.currentMapId(), data.cosmicIdx());
switch (resolveResult.action) {
case MapResolveAction::NAVIGATE_CONTINENT:
// Same map — just switch to the continent view
viewState.setContinentIdx(resolveResult.targetZoneIdx);
compositor.loadZoneTextures(resolveResult.targetZoneIdx, data.zones(), mapName);
compositor.requestComposite(resolveResult.targetZoneIdx);
viewState.setCurrentZoneIdx(resolveResult.targetZoneIdx);
viewState.setLevel(ViewLevel::CONTINENT);
break;
case MapResolveAction::LOAD_MAP:
switchToMap(resolveResult.targetMapName);
break;
default:
break;
}
break;
}
}
}
} else if (vl == ViewLevel::CONTINENT && continentIndices.size() > 1) {
ImGui::SetCursorPos(ImVec2(8.0f, 8.0f));
for (size_t i = 0; i < continentIndices.size(); i++) {
int ci = continentIndices[i];
if (i > 0) ImGui::SameLine();
const bool selected = (ci == viewState.continentIdx());
if (selected) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.35f, 0.25f, 0.05f, 0.9f));
std::string rawName = data.zones()[ci].areaName.empty() ? "Continent" : data.zones()[ci].areaName;
if (rawName == "Azeroth") rawName = mapDisplayName(0);
std::string label = rawName + "##" + std::to_string(ci);
if (ImGui::Button(label.c_str())) {
viewState.setContinentIdx(ci);
compositor.loadZoneTextures(ci, data.zones(), mapName);
compositor.requestComposite(ci);
viewState.setCurrentZoneIdx(ci);
}
if (selected) ImGui::PopStyleColor();
}
}
// Render all overlay layers
overlay.render(layerCtx);
// Zone view: back to continent + zone name
if (vl == ViewLevel::ZONE && viewState.continentIdx() >= 0) {
ImGui::SetCursorPos(ImVec2(8.0f, 8.0f));
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.15f, 0.15f, 0.8f));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.3f, 0.3f, 0.1f, 0.9f));
ImGui::PushStyleColor(ImGuiCol_Text, ui::colors::kBrightGold);
if (ImGui::Button("< Back")) {
compositor.loadZoneTextures(viewState.continentIdx(), data.zones(), mapName);
compositor.requestComposite(viewState.continentIdx());
viewState.setCurrentZoneIdx(viewState.continentIdx());
viewState.setLevel(ViewLevel::CONTINENT);
}
ImGui::PopStyleColor(3);
int curIdx = viewState.currentZoneIdx();
if (curIdx >= 0 && curIdx < static_cast<int>(data.zones().size())) {
const char* zoneName = data.zones()[curIdx].areaName.c_str();
ImVec2 nameSize = ImGui::CalcTextSize(zoneName);
float nameY = mapY - nameSize.y - 8.0f;
if (nameY > 0.0f) {
ImGui::SetCursorPos(ImVec2((sw - nameSize.x) / 2.0f, nameY));
ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 0.9f), "%s", zoneName);
}
}
}
// Continent view: back to world + hovered zone name
if (vl == ViewLevel::CONTINENT) {
float localBtnY = (continentIndices.size() > 1 ? 40.0f : 8.0f);
ImGui::SetCursorPos(ImVec2(8.0f, localBtnY));
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.15f, 0.15f, 0.8f));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.3f, 0.3f, 0.1f, 0.9f));
ImGui::PushStyleColor(ImGuiCol_Text, ui::colors::kBrightGold);
if (ImGui::Button("< Azeroth")) {
switchToWorldView();
}
ImGui::PopStyleColor(3);
// Show hovered zone name above the map
int hovZone = zoneHighlightLayer ? zoneHighlightLayer->hoveredZone() : -1;
if (hovZone >= 0 && hovZone < static_cast<int>(data.zones().size())) {
const std::string& rawName = data.zones()[hovZone].areaName;
if (!rawName.empty()) {
const ZoneMeta* meta = zoneMetadata.find(rawName);
std::string hoverLabel = ZoneMetadata::formatHoverLabel(rawName, meta);
ImVec2 hoverSz = ImGui::CalcTextSize(hoverLabel.c_str());
float hx = imgMin.x + (displayW - hoverSz.x) * 0.5f;
float hy = imgMin.y - hoverSz.y - 8.0f;
if (hy > 0.0f) {
drawList->AddText(ImVec2(hx + 1.0f, hy + 1.0f),
IM_COL32(0, 0, 0, 220), hoverLabel.c_str());
ImU32 hoverColor = IM_COL32(255, 215, 0, 255);
if (meta) {
switch (meta->faction) {
case ZoneFaction::Alliance: hoverColor = IM_COL32(100, 160, 255, 255); break;
case ZoneFaction::Horde: hoverColor = IM_COL32(255, 80, 80, 255); break;
default: break;
}
}
drawList->AddText(ImVec2(hx, hy), hoverColor, hoverLabel.c_str());
}
}
}
}
// Cosmic view: title + clickable landmass regions
if (vl == ViewLevel::COSMIC) {
ImGui::SetCursorPos(ImVec2(8.0f, 8.0f));
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.15f, 0.15f, 0.8f));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.3f, 0.3f, 0.1f, 0.9f));
ImGui::PushStyleColor(ImGuiCol_Text, ui::colors::kBrightGold);
if (ImGui::Button("< Azeroth")) {
switchToWorldView();
}
ImGui::PopStyleColor(3);
ImVec2 titleSz = ImGui::CalcTextSize("Cosmic");
float titleX = imgMin.x + (displayW - titleSz.x) * 0.5f;
float titleY = imgMin.y - titleSz.y - 8.0f;
if (titleY > 0.0f) {
drawList->AddText(ImVec2(titleX + 1.0f, titleY + 1.0f),
IM_COL32(0, 0, 0, 220), "Cosmic");
drawList->AddText(ImVec2(titleX, titleY),
IM_COL32(255, 215, 0, 255), "Cosmic");
}
ImVec2 mp2 = ImGui::GetMousePos();
auto& io = ImGui::GetIO();
for (const auto& entry : data.cosmicMaps()) {
float rx0 = imgMin.x + entry.uvLeft * displayW;
float ry0 = imgMin.y + entry.uvTop * displayH;
float rx1 = imgMin.x + entry.uvRight * displayW;
float ry1 = imgMin.y + entry.uvBottom * displayH;
bool hovered = (mp2.x >= rx0 && mp2.x <= rx1 &&
mp2.y >= ry0 && mp2.y <= ry1);
if (hovered) {
// Cosmic highlight files: cosmic-{label}-highlight.blp
std::string cosmicLabel = entry.label;
std::transform(cosmicLabel.begin(), cosmicLabel.end(), cosmicLabel.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
std::string cosmicKey = "cosmic-" + cosmicLabel;
std::string cosmicPath = "Interface\\WorldMap\\Cosmic\\cosmic-" + cosmicLabel + "-highlight.blp";
// ─── Cosmic Highlight Rendering Logic ───────────────────
//
// SOURCE TEXTURES:
// cosmic-azeroth-highlight.blp → 512×512 px (DXT3, has alpha)
// cosmic-outland-highlight.blp → 512×512 px (DXT3, has alpha)
// The glow is baked into the alpha channel:
// - Azeroth highlight: glow sits in the RIGHT-CENTER of the texture
// - Outland highlight: glow sits in the LEFT-CENTER of the texture
//
// DISPLAY AREA:
// The map on screen is displayW × displayH pixels.
// displayW/displayH ≈ 1002/668 ≈ 1.5:1 (wider than tall).
// imgMin = top-left corner, imgMax = bottom-right corner.
//
// THE PROBLEM:
// 512×512 is square, but the display area is 1.5× wider than tall.
// If we stretch the texture to fill the full display area
// (imgMin → imgMax), the circular glow becomes an ellipse
// (horizontally stretched ~50%).
// If we render it as a square (side = displayH), it has the
// correct aspect but only covers 2/3 of the map width.
//
// CURRENT APPROACH:
// Render as a square (side = displayH), anchored:
// Azeroth → flush to the RIGHT edge of the map (glow lands bottom-right)
// Outland → flush to the LEFT edge of the map (glow lands top-left)
// This preserves the 1:1 aspect ratio of the glow shape.
//
// TO ADJUST:
// • Make glow wider: increase hlW (e.g. displayH * 1.2f)
// • Make glow taller: increase hlH (e.g. displayH * 1.1f)
// • Full stretch (like WoW original): hlW = displayW, hlH = displayH
// • Shift glow position: adjust hlX offset
//
float hlW,hlH,hlX, hlY;
if (cosmicLabel == "azeroth") {
hlW = displayW * 0.90f; // width of highlight rect (= square)
hlH = displayH * 0.985f; // height of highlight rect (= square)
hlX = imgMax.x - hlW; // flush right
hlY = imgMax.y - hlH; // flush bottom + title bar
} else {
hlW = displayW * 0.86f; // width of highlight rect (= square)
hlH = displayH * 0.91f; // height of highlight rect (= square)
hlX = imgMin.x + displayW * 0.02f; // flush left
hlY = imgMax.y - displayH * 0.95f; // flush bottom
}
if (zoneHighlightLayer) {
ImTextureID hlTex = zoneHighlightLayer->getHighlightTexture(cosmicKey, cosmicPath);
if (hlTex) {
drawList->AddImage(hlTex,
ImVec2(hlX, hlY),
ImVec2(hlX + hlW, hlY + hlH),
ImVec2(0, 0), ImVec2(1, 1),
IM_COL32(255, 255, 255, 180));
} else {
drawList->AddRectFilled(ImVec2(rx0, ry0), ImVec2(rx1, ry1),
IM_COL32(255, 215, 0, 25));
}
} else {
drawList->AddRectFilled(ImVec2(rx0, ry0), ImVec2(rx1, ry1),
IM_COL32(255, 215, 0, 25));
}
drawList->AddRect(ImVec2(rx0, ry0), ImVec2(rx1, ry1),
IM_COL32(255, 215, 0, 100), 0, 0, 1.5f);
ImFont* font = ImGui::GetFont();
ImVec2 labelSz = font->CalcTextSizeA(ImGui::GetFontSize(), FLT_MAX, 0.0f,
entry.label.c_str());
float lx = (rx0 + rx1 - labelSz.x) * 0.5f;
float ly = ry0 - labelSz.y - 4.0f;
if (ly < imgMin.y) ly = ry0 + 4.0f;
drawList->AddText(ImVec2(lx + 1.0f, ly + 1.0f),
IM_COL32(0, 0, 0, 200), entry.label.c_str());
drawList->AddText(ImVec2(lx, ly),
IM_COL32(255, 230, 100, 255), entry.label.c_str());
if (io.MouseClicked[0]) {
if (entry.label == "Outland") {
switchToMap("Expansion01");
} else {
viewState.enterWorldView();
int wIdx = data.worldIdx();
if (wIdx >= 0) {
compositor.loadZoneTextures(wIdx, data.zones(), mapName);
compositor.invalidateComposite();
compositor.requestComposite(wIdx);
viewState.setCurrentZoneIdx(wIdx);
}
}
break;
}
}
}
}
// Help text
const char* helpText;
if (vl == ViewLevel::ZONE)
helpText = "Right-click to zoom out | M or Escape to close";
else if (vl == ViewLevel::COSMIC)
helpText = "Scroll in or click to zoom in | M or Escape to close";
else if (vl == ViewLevel::WORLD && viewState.cosmicEnabled())
helpText = "Click a continent | Right-click for Cosmic view | M or Escape to close";
else if (vl == ViewLevel::WORLD)
helpText = "Click a continent | M or Escape to close";
else
helpText = "Click zone to open | Right-click to zoom out | M or Escape to close";
ImVec2 textSize = ImGui::CalcTextSize(helpText);
float textX = mapX + (displayW - textSize.x) / 2.0f;
float textY = mapY + displayH - textSize.y - 4.0f;
ImGui::SetCursorScreenPos(ImVec2(textX, textY));
ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 0.8f), "%s", helpText);
}
ImGui::End();
ImGui::PopStyleVar(2); // WindowPadding + ItemSpacing
}
} // namespace world_map
} // namespace rendering
} // namespace wowee