Merge pull request #61 from ldmonster/feat/map-system
Some checks failed
Build / Build (arm64) (push) Has been cancelled
Build / Build (x86-64) (push) Has been cancelled
Build / Build (macOS arm64) (push) Has been cancelled
Build / Build (windows-arm64) (push) Has been cancelled
Build / Build (windows-x86-64) (push) Has been cancelled
Security / CodeQL (C/C++) (push) Has been cancelled
Security / Semgrep (push) Has been cancelled
Security / Sanitizer Build (ASan/UBSan) (push) Has been cancelled

[chore] refactor: Decompose World Map into Modular Component Architecture
This commit is contained in:
Kelsi Rae Davis 2026-04-12 00:10:01 -07:00 committed by GitHub
commit 09c4a9a04a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
55 changed files with 6335 additions and 1542 deletions

View file

@ -634,7 +634,25 @@ set(WOWEE_SOURCES
src/rendering/hiz_system.cpp
src/rendering/quest_marker_renderer.cpp
src/rendering/minimap.cpp
src/rendering/world_map.cpp
src/rendering/world_map/coordinate_projection.cpp
src/rendering/world_map/map_resolver.cpp
src/rendering/world_map/exploration_state.cpp
src/rendering/world_map/zone_metadata.cpp
src/rendering/world_map/data_repository.cpp
src/rendering/world_map/view_state_machine.cpp
src/rendering/world_map/composite_renderer.cpp
src/rendering/world_map/overlay_renderer.cpp
src/rendering/world_map/input_handler.cpp
src/rendering/world_map/world_map_facade.cpp
src/rendering/world_map/layers/player_marker_layer.cpp
src/rendering/world_map/layers/party_dot_layer.cpp
src/rendering/world_map/layers/taxi_node_layer.cpp
src/rendering/world_map/layers/poi_marker_layer.cpp
src/rendering/world_map/layers/quest_poi_layer.cpp
src/rendering/world_map/layers/corpse_marker_layer.cpp
src/rendering/world_map/layers/zone_highlight_layer.cpp
src/rendering/world_map/layers/coordinate_display.cpp
src/rendering/world_map/layers/subzone_tooltip_layer.cpp
src/rendering/swim_effects.cpp
src/rendering/mount_dust.cpp
src/rendering/levelup_effect.cpp
@ -778,6 +796,26 @@ set(WOWEE_HEADERS
include/rendering/lightning.hpp
include/rendering/swim_effects.hpp
include/rendering/world_map.hpp
include/rendering/world_map/world_map_types.hpp
include/rendering/world_map/coordinate_projection.hpp
include/rendering/world_map/map_resolver.hpp
include/rendering/world_map/exploration_state.hpp
include/rendering/world_map/zone_metadata.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/overlay_renderer.hpp
include/rendering/world_map/input_handler.hpp
include/rendering/world_map/world_map_facade.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/character_renderer.hpp
include/rendering/character_preview.hpp
include/rendering/wmo_renderer.hpp

View file

@ -0,0 +1,16 @@
#version 450
layout(set = 0, binding = 0) uniform sampler2D uTileTexture;
layout(push_constant) uniform PushConstants {
layout(offset = 16) vec4 tintColor;
};
layout(location = 0) in vec2 TexCoord;
layout(location = 0) out vec4 outColor;
void main() {
vec4 texel = texture(uTileTexture, TexCoord);
outColor = texel * tintColor;
}

View file

@ -46,6 +46,7 @@ public:
// Map name utilities
static const char* mapIdToName(uint32_t mapId);
static int mapNameToId(const std::string& name);
static const char* mapDisplayName(uint32_t mapId);
// Background preloading — warms AssetManager file cache

View file

@ -46,7 +46,8 @@ class CharacterRenderer;
class WMORenderer;
class M2Renderer;
class Minimap;
class WorldMap;
namespace world_map { class WorldMapFacade; }
using WorldMap = world_map::WorldMapFacade;
class QuestMarkerRenderer;
class CharacterPreview;
class AmdFsr3Runtime;

View file

@ -1,174 +1,23 @@
// world_map.hpp — Shim header for backward compatibility.
// Redirects to the modular world_map/world_map_facade.hpp.
// Consumers should migrate to #include "rendering/world_map/world_map_facade.hpp" directly.
#pragma once
#include <vulkan/vulkan.h>
#include <vk_mem_alloc.h>
#include <glm/glm.hpp>
#include <cstdint>
#include <memory>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector>
#include "rendering/world_map/world_map_facade.hpp"
namespace wowee {
namespace pipeline { class AssetManager; }
namespace rendering {
class VkContext;
class VkTexture;
class VkRenderTarget;
// Backward-compatible type aliases for old consumer code
// (game_screen_hud.cpp, renderer.cpp, etc.)
using WorldMapPartyDot = world_map::PartyDot;
using WorldMapTaxiNode = world_map::TaxiNode;
using MapPOI = world_map::POI;
/// Party member dot passed in from the UI layer for world map overlay.
struct WorldMapPartyDot {
glm::vec3 renderPos; ///< Position in render-space coordinates
uint32_t color; ///< RGBA packed color (IM_COL32 format)
std::string name; ///< Member name (shown as tooltip on hover)
};
/// Taxi (flight master) node passed from the UI layer for world map overlay.
struct WorldMapTaxiNode {
uint32_t id = 0; ///< TaxiNodes.dbc ID
uint32_t mapId = 0; ///< WoW internal map ID (0=EK,1=Kal,530=Outland,571=Northrend)
float wowX = 0, wowY = 0, wowZ = 0; ///< Canonical WoW coordinates
std::string name; ///< Node name (shown as tooltip)
bool known = false; ///< Player has discovered this node
};
struct WorldMapZone {
uint32_t wmaID = 0;
uint32_t areaID = 0; // 0 = continent level
std::string areaName; // texture folder name (from DBC)
float locLeft = 0, locRight = 0, locTop = 0, locBottom = 0;
uint32_t displayMapID = 0;
uint32_t parentWorldMapID = 0;
std::vector<uint32_t> exploreBits; // all AreaBit indices (zone + subzones)
// Per-zone cached textures (owned by WorldMap::zoneTextures)
VkTexture* tileTextures[12] = {};
bool tilesLoaded = false;
};
class WorldMap {
public:
WorldMap();
~WorldMap();
bool initialize(VkContext* ctx, pipeline::AssetManager* assetManager);
void shutdown();
/// Off-screen composite pass — call BEFORE the main render pass begins.
void compositePass(VkCommandBuffer cmd);
/// ImGui overlay — call INSIDE the main render pass (during ImGui frame).
void render(const glm::vec3& playerRenderPos, int screenWidth, int screenHeight,
float playerYawDeg = 0.0f);
void setMapName(const std::string& name);
void setServerExplorationMask(const std::vector<uint32_t>& masks, bool hasData);
void setPartyDots(std::vector<WorldMapPartyDot> dots) { partyDots_ = std::move(dots); }
void setTaxiNodes(std::vector<WorldMapTaxiNode> nodes) { taxiNodes_ = std::move(nodes); }
/// Quest POI marker for world map overlay (from SMSG_QUEST_POI_QUERY_RESPONSE).
struct QuestPoi {
float wowX = 0, wowY = 0; ///< Canonical WoW coordinates (centroid of POI area)
std::string name; ///< Quest title
};
void setQuestPois(std::vector<QuestPoi> pois) { questPois_ = std::move(pois); }
/// Set the player's corpse position for overlay rendering.
/// @param hasCorpse True when the player is a ghost with an unclaimed corpse on this map.
/// @param renderPos Corpse position in render-space coordinates.
void setCorpsePos(bool hasCorpse, glm::vec3 renderPos) {
hasCorpse_ = hasCorpse;
corpseRenderPos_ = renderPos;
}
bool isOpen() const { return open; }
void close() { open = false; }
private:
enum class ViewLevel { WORLD, CONTINENT, ZONE };
void enterWorldView();
void loadZonesFromDBC();
int findBestContinentForPlayer(const glm::vec3& playerRenderPos) const;
int findZoneForPlayer(const glm::vec3& playerRenderPos) const;
bool zoneBelongsToContinent(int zoneIdx, int contIdx) const;
bool getContinentProjectionBounds(int contIdx, float& left, float& right,
float& top, float& bottom) const;
void loadZoneTextures(int zoneIdx);
void requestComposite(int zoneIdx);
void renderImGuiOverlay(const glm::vec3& playerRenderPos, int screenWidth, int screenHeight,
float playerYawDeg);
void updateExploration(const glm::vec3& playerRenderPos);
void zoomIn(const glm::vec3& playerRenderPos);
void zoomOut();
glm::vec2 renderPosToMapUV(const glm::vec3& renderPos, int zoneIdx) const;
void destroyZoneTextures();
VkContext* vkCtx = nullptr;
pipeline::AssetManager* assetManager = nullptr;
bool initialized = false;
bool open = false;
std::string mapName = "Azeroth";
// All zones for current map
std::vector<WorldMapZone> zones;
int continentIdx = -1;
int currentIdx = -1;
ViewLevel viewLevel = ViewLevel::CONTINENT;
int compositedIdx = -1;
int pendingCompositeIdx = -1;
// FBO replacement (4x3 tiles = 1024x768)
static constexpr int GRID_COLS = 4;
static constexpr int GRID_ROWS = 3;
static constexpr int TILE_PX = 256;
static constexpr int FBO_W = GRID_COLS * TILE_PX;
static constexpr int FBO_H = GRID_ROWS * TILE_PX;
std::unique_ptr<VkRenderTarget> compositeTarget;
// Quad vertex buffer (pos2 + uv2)
::VkBuffer quadVB = VK_NULL_HANDLE;
VmaAllocation quadVBAlloc = VK_NULL_HANDLE;
// Descriptor resources
VkDescriptorSetLayout samplerSetLayout = VK_NULL_HANDLE;
VkDescriptorPool descPool = VK_NULL_HANDLE;
static constexpr uint32_t MAX_DESC_SETS = 32;
// Tile composite pipeline
VkPipeline tilePipeline = VK_NULL_HANDLE;
VkPipelineLayout tilePipelineLayout = VK_NULL_HANDLE;
VkDescriptorSet tileDescSets[2][12] = {}; // [frameInFlight][tileSlot]
// ImGui display descriptor set (points to composite render target)
VkDescriptorSet imguiDisplaySet = VK_NULL_HANDLE;
// Texture storage (owns all VkTexture objects for zone tiles)
std::vector<std::unique_ptr<VkTexture>> zoneTextures;
// Party member dots (set each frame from the UI layer)
std::vector<WorldMapPartyDot> partyDots_;
// Taxi node markers (set each frame from the UI layer)
std::vector<WorldMapTaxiNode> taxiNodes_;
int currentMapId_ = -1; ///< WoW map ID currently loaded (set in loadZonesFromDBC)
// Quest POI markers (set each frame from the UI layer)
std::vector<QuestPoi> questPois_;
// Corpse marker (ghost state — set each frame from the UI layer)
bool hasCorpse_ = false;
glm::vec3 corpseRenderPos_ = {};
// Exploration / fog of war
std::vector<uint32_t> serverExplorationMask;
bool hasServerExplorationMask = false;
std::unordered_set<int> exploredZones;
// Locally accumulated exploration (used as fallback when server mask is unavailable)
std::unordered_set<int> locallyExploredZones_;
};
// WorldMap alias is already provided by world_map_facade.hpp:
// using WorldMap = world_map::WorldMapFacade;
// WorldMap::QuestPoi alias is provided inside WorldMapFacade:
// using QuestPoi = QuestPOI;
} // namespace rendering
} // namespace wowee

View file

@ -0,0 +1,157 @@
// composite_renderer.hpp — Vulkan off-screen composite rendering for the world map.
// Extracted from WorldMap (Phase 7 of refactoring plan).
// SRP — all GPU resource management separated from domain logic.
#pragma once
#include "rendering/world_map/world_map_types.hpp"
#include <vulkan/vulkan.h>
#include <vk_mem_alloc.h>
#include <glm/glm.hpp>
#include <memory>
#include <vector>
#include <unordered_set>
namespace wowee {
namespace rendering {
class VkContext;
class VkTexture;
class VkRenderTarget;
}
namespace pipeline { class AssetManager; }
namespace rendering {
namespace world_map {
/// Push constant for world map tile composite vertex shader.
struct WorldMapTilePush {
glm::vec2 gridOffset; // 8 bytes
float gridCols; // 4 bytes
float gridRows; // 4 bytes
}; // 16 bytes
/// Push constant for the overlay/fog pipeline (vertex + fragment stages).
struct OverlayPush {
glm::vec2 gridOffset; // 8 bytes (vertex)
float gridCols; // 4 bytes (vertex)
float gridRows; // 4 bytes (vertex)
glm::vec4 tintColor; // 16 bytes (fragment)
}; // 32 bytes
class CompositeRenderer {
public:
CompositeRenderer();
~CompositeRenderer();
bool initialize(VkContext* ctx, pipeline::AssetManager* am);
void shutdown();
/// Load base tile textures for a zone.
void loadZoneTextures(int zoneIdx, std::vector<Zone>& zones, const std::string& mapName);
/// Load exploration overlay textures for a zone.
void loadOverlayTextures(int zoneIdx, std::vector<Zone>& zones);
/// Request a composite for the given zone (deferred to compositePass).
void requestComposite(int zoneIdx);
/// Execute the off-screen composite pass.
void compositePass(VkCommandBuffer cmd,
const std::vector<Zone>& zones,
const std::unordered_set<int>& exploredOverlays,
bool hasServerMask);
/// Descriptor set for ImGui display of the composite.
VkDescriptorSet displayDescriptorSet() const { return imguiDisplaySet; }
/// Destroy all loaded zone textures (on map change).
void destroyZoneTextures(std::vector<Zone>& zones);
/// Detach zone textures for deferred GPU destruction.
/// Clears CPU tracking immediately but moves GPU texture objects to a stale
/// list so they can be freed later when no in-flight frames reference them.
void detachZoneTextures();
/// Free any GPU textures previously moved to the stale list by detachZoneTextures.
/// Calls vkDeviceWaitIdle internally to ensure no in-flight work references them.
void flushStaleTextures();
/// Index of the zone currently composited (-1 if none).
int compositedIdx() const { return compositedIdx_; }
/// Reset composited index to force re-composite.
void invalidateComposite() { compositedIdx_ = -1; }
/// Check whether a zone has any loaded tile textures.
bool hasAnyTile(int zoneIdx) const;
// FBO dimensions (public for overlay coordinate math)
static constexpr int GRID_COLS = 4;
static constexpr int GRID_ROWS = 3;
static constexpr int TILE_PX = 256;
static constexpr int FBO_W = GRID_COLS * TILE_PX;
static constexpr int FBO_H = GRID_ROWS * TILE_PX;
// WoW's WorldMapDetailFrame is 1002x668 — the visible map content area.
// The FBO is 1024x768 so we crop UVs to show only the actual map region.
static constexpr int MAP_W = 1002;
static constexpr int MAP_H = 668;
static constexpr float MAP_U_MAX = static_cast<float>(MAP_W) / static_cast<float>(FBO_W);
static constexpr float MAP_V_MAX = static_cast<float>(MAP_H) / static_cast<float>(FBO_H);
private:
VkContext* vkCtx = nullptr;
pipeline::AssetManager* assetManager = nullptr;
bool initialized = false;
std::unique_ptr<VkRenderTarget> compositeTarget;
// Quad vertex buffer (pos2 + uv2)
::VkBuffer quadVB = VK_NULL_HANDLE;
VmaAllocation quadVBAlloc = VK_NULL_HANDLE;
// Descriptor resources
VkDescriptorSetLayout samplerSetLayout = VK_NULL_HANDLE;
VkDescriptorPool descPool = VK_NULL_HANDLE;
static constexpr uint32_t MAX_DESC_SETS = 192;
static constexpr uint32_t MAX_OVERLAY_TILES = 48;
// Tile composite pipeline
VkPipeline tilePipeline = VK_NULL_HANDLE;
VkPipelineLayout tilePipelineLayout = VK_NULL_HANDLE;
VkDescriptorSet tileDescSets[2][12] = {}; // [frameInFlight][tileSlot]
// Alpha-blended overlay pipeline (fog + explored area overlays)
VkPipeline overlayPipeline_ = VK_NULL_HANDLE;
VkPipelineLayout overlayPipelineLayout_ = VK_NULL_HANDLE;
std::unique_ptr<VkTexture> fogTexture_; // 1×1 white pixel for fog quad
VkDescriptorSet fogDescSet_ = VK_NULL_HANDLE;
VkDescriptorSet overlayDescSets_[2][MAX_OVERLAY_TILES] = {};
// ImGui display descriptor set (points to composite render target)
VkDescriptorSet imguiDisplaySet = VK_NULL_HANDLE;
// Texture storage (owns all VkTexture objects for zone tiles)
std::vector<std::unique_ptr<VkTexture>> zoneTextures;
int compositedIdx_ = -1;
int pendingCompositeIdx_ = -1;
// Per-zone tile texture pointers (indexed by zone, then by tile slot)
// Stored separately since Zone struct is now Vulkan-free
struct ZoneTextureSlots {
VkTexture* tileTextures[12] = {};
bool tilesLoaded = false;
// Per-overlay tile textures
struct OverlaySlots {
std::vector<VkTexture*> tiles;
bool tilesLoaded = false;
};
std::vector<OverlaySlots> overlays;
};
std::vector<ZoneTextureSlots> zoneTextureSlots_;
void ensureTextureSlots(size_t zoneCount, const std::vector<Zone>& zones);
};
} // namespace world_map
} // namespace rendering
} // namespace wowee

View file

@ -0,0 +1,49 @@
// coordinate_projection.hpp — Pure coordinate math for world map UV projection.
// Extracted from WorldMap methods (Phase 2 of refactoring plan).
// All functions are stateless free functions — trivially testable.
#pragma once
#include "rendering/world_map/world_map_types.hpp"
#include <glm/glm.hpp>
#include <vector>
namespace wowee {
namespace rendering {
namespace world_map {
/// Project render-space position to [0,1] UV on a zone or continent map.
glm::vec2 renderPosToMapUV(const glm::vec3& renderPos,
const ZoneBounds& bounds,
bool isContinent);
/// Derive effective projection bounds for a continent from its child zones.
/// Uses zoneBelongsToContinent() internally. Returns false if insufficient data.
bool getContinentProjectionBounds(const std::vector<Zone>& zones,
int contIdx,
float& left, float& right,
float& top, float& bottom);
/// Find the best-fit continent index for a player position.
/// Prefers the smallest containing continent; falls back to nearest center.
int findBestContinentForPlayer(const std::vector<Zone>& zones,
const glm::vec3& playerRenderPos);
/// Find the smallest zone (areaID != 0) containing the player position.
/// Returns -1 if no zone contains the position.
int findZoneForPlayer(const std::vector<Zone>& zones,
const glm::vec3& playerRenderPos);
/// Test if a zone spatially belongs to a given continent.
/// Uses parentWorldMapID when available, falls back to overlap heuristic.
bool zoneBelongsToContinent(const std::vector<Zone>& zones,
int zoneIdx, int contIdx);
/// Check whether the zone at idx is a root continent (has leaf continents as children).
bool isRootContinent(const std::vector<Zone>& zones, int idx);
/// Check whether the zone at idx is a leaf continent (parentWorldMapID != 0, areaID == 0).
bool isLeafContinent(const std::vector<Zone>& zones, int idx);
} // namespace world_map
} // namespace rendering
} // namespace wowee

View file

@ -0,0 +1,95 @@
// data_repository.hpp — DBC data loading, ZMP pixel map, and zone/POI/overlay storage.
// Extracted from WorldMap::loadZonesFromDBC, loadPOIData, buildCosmicView
// (Phase 5 of refactoring plan). SRP — all DBC parsing lives here.
#pragma once
#include "rendering/world_map/world_map_types.hpp"
#include <unordered_map>
#include <string>
#include <vector>
#include <array>
#include <cstdint>
namespace wowee {
namespace pipeline { class AssetManager; }
namespace rendering {
namespace world_map {
class DataRepository {
public:
/// Load all zone data from DBC files for the given map name.
void loadZones(const std::string& mapName, pipeline::AssetManager& assetManager);
/// Load area POI markers from AreaPOI.dbc.
void loadPOIs(pipeline::AssetManager& assetManager);
/// Build cosmic view entries for the active expansion (uses isActiveExpansion).
void buildCosmicView(int expansionLevel = 0);
/// Build Azeroth world-view continent regions for the active expansion.
void buildAzerothView(int expansionLevel = 0);
/// Load ZMP pixel map for the given continent name (e.g. "Azeroth").
/// The ZMP is a 128x128 grid of uint32 AreaTable IDs.
void loadZmpPixelMap(const std::string& continentName,
pipeline::AssetManager& assetManager);
/// Determine expansion level from the active expansion profile.
static int getExpansionLevel();
// --- Accessors ---
std::vector<Zone>& zones() { return zones_; }
const std::vector<Zone>& zones() const { return zones_; }
int cosmicIdx() const { return cosmicIdx_; }
int worldIdx() const { return worldIdx_; }
int currentMapId() const { return currentMapId_; }
const std::vector<CosmicMapEntry>& cosmicMaps() const { return cosmicMaps_; }
const std::vector<CosmicMapEntry>& azerothRegions() const { return azerothRegions_; }
bool cosmicEnabled() const { return cosmicEnabled_; }
const std::vector<POI>& poiMarkers() const { return poiMarkers_; }
const std::unordered_map<uint32_t, uint32_t>& exploreFlagByAreaId() const { return exploreFlagByAreaId_; }
const std::unordered_map<uint32_t, std::string>& areaNameByAreaId() const { return areaNameByAreaId_; }
/// ZMP pixel map accessors.
static constexpr int ZMP_SIZE = 128;
const std::array<uint32_t, 128 * 128>& zmpGrid() const { return zmpGrid_; }
bool hasZmpData() const { return zmpLoaded_; }
/// Look up zone index from an AreaTable ID (from ZMP). Returns -1 if not found.
int zoneIndexForAreaId(uint32_t areaId) const;
/// ZMP-derived bounding rectangles per zone index (UV [0,1] on display).
const std::unordered_map<int, ZmpRect>& zmpZoneBounds() const { return zmpZoneBounds_; }
/// Reset all data (called on map change).
void clear();
private:
std::vector<Zone> zones_;
std::vector<POI> poiMarkers_;
std::vector<CosmicMapEntry> cosmicMaps_;
std::vector<CosmicMapEntry> azerothRegions_;
std::unordered_map<uint32_t, uint32_t> exploreFlagByAreaId_;
std::unordered_map<uint32_t, std::string> areaNameByAreaId_;
int cosmicIdx_ = -1;
int worldIdx_ = -1;
int currentMapId_ = -1;
bool cosmicEnabled_ = true;
bool poisLoaded_ = false;
// ZMP pixel map: 128x128 grid of AreaTable IDs for continent-level hover
std::array<uint32_t, 128 * 128> zmpGrid_{};
bool zmpLoaded_ = false;
// AreaID → zone index (zones_ vector) for quick resolution
std::unordered_map<uint32_t, int> areaIdToZoneIdx_;
// ZMP-derived bounding boxes per zone index (UV coords on display)
std::unordered_map<int, ZmpRect> zmpZoneBounds_;
/// Scan ZMP grid and build bounding boxes for each zone.
void buildZmpZoneBounds();
};
} // namespace world_map
} // namespace rendering
} // namespace wowee

View file

@ -0,0 +1,53 @@
// exploration_state.hpp — Fog of war / exploration tracking (pure domain logic).
// Extracted from WorldMap::updateExploration (Phase 3 of refactoring plan).
// No rendering or GPU dependencies — fully testable standalone.
#pragma once
#include "rendering/world_map/world_map_types.hpp"
#include <glm/glm.hpp>
#include <unordered_map>
#include <unordered_set>
#include <vector>
namespace wowee {
namespace rendering {
namespace world_map {
class ExplorationState {
public:
void setServerMask(const std::vector<uint32_t>& masks, bool hasData);
bool hasServerMask() const { return hasServerMask_; }
/// Recompute explored zones and overlays for given player position.
/// @param zones All loaded zones
/// @param playerRenderPos Player position in render-space
/// @param currentZoneIdx Currently viewed zone index
/// @param exploreFlagByAreaId AreaID → ExploreFlag mapping from AreaTable.dbc
void update(const std::vector<Zone>& zones,
const glm::vec3& playerRenderPos,
int currentZoneIdx,
const std::unordered_map<uint32_t, uint32_t>& exploreFlagByAreaId);
const std::unordered_set<int>& exploredZones() const { return exploredZones_; }
const std::unordered_set<int>& exploredOverlays() const { return exploredOverlays_; }
/// Returns true if the explored overlay set changed since last update.
bool overlaysChanged() const { return overlaysChanged_; }
/// Clear accumulated local exploration data.
void clearLocal() { locallyExploredZones_.clear(); }
private:
bool isBitSet(uint32_t bitIndex) const;
std::vector<uint32_t> serverMask_;
bool hasServerMask_ = false;
std::unordered_set<int> exploredZones_;
std::unordered_set<int> exploredOverlays_;
std::unordered_set<int> locallyExploredZones_;
bool overlaysChanged_ = false;
};
} // namespace world_map
} // namespace rendering
} // namespace wowee

View file

@ -0,0 +1,37 @@
// input_handler.hpp — Input processing for the world map.
// Extracted from WorldMap::render (Phase 9 of refactoring plan).
// SRP — input interpretation separated from state changes and rendering.
#pragma once
#include "rendering/world_map/world_map_types.hpp"
namespace wowee {
namespace rendering {
namespace world_map {
enum class InputAction {
NONE,
CLOSE,
ZOOM_IN,
ZOOM_OUT,
CLICK_ZONE, // left-click on continent view zone
CLICK_COSMIC_REGION, // left-click on cosmic landmass
RIGHT_CLICK_BACK, // right-click to go back
};
struct InputResult {
InputAction action = InputAction::NONE;
int targetIdx = -1; // zone or cosmic region index
};
class InputHandler {
public:
/// Process input for current frame. Returns the highest-priority action.
InputResult process(ViewLevel currentLevel,
int hoveredZoneIdx,
bool cosmicEnabled);
};
} // namespace world_map
} // namespace rendering
} // namespace wowee

View file

@ -0,0 +1,16 @@
// coordinate_display.hpp — WoW coordinates under cursor on the world map.
#pragma once
#include "rendering/world_map/overlay_renderer.hpp"
namespace wowee {
namespace rendering {
namespace world_map {
class CoordinateDisplay : public IOverlayLayer {
public:
void render(const LayerContext& ctx) override;
};
} // namespace world_map
} // namespace rendering
} // namespace wowee

View file

@ -0,0 +1,24 @@
// corpse_marker_layer.hpp — Death corpse X marker on the world map.
#pragma once
#include "rendering/world_map/overlay_renderer.hpp"
#include <glm/glm.hpp>
namespace wowee {
namespace rendering {
namespace world_map {
class CorpseMarkerLayer : public IOverlayLayer {
public:
void setCorpse(bool hasCorpse, glm::vec3 renderPos) {
hasCorpse_ = hasCorpse;
corpseRenderPos_ = renderPos;
}
void render(const LayerContext& ctx) override;
private:
bool hasCorpse_ = false;
glm::vec3 corpseRenderPos_ = {};
};
} // namespace world_map
} // namespace rendering
} // namespace wowee

View file

@ -0,0 +1,21 @@
// party_dot_layer.hpp — Party member position dots on the world map.
#pragma once
#include "rendering/world_map/overlay_renderer.hpp"
#include "rendering/world_map/world_map_types.hpp"
#include <vector>
namespace wowee {
namespace rendering {
namespace world_map {
class PartyDotLayer : public IOverlayLayer {
public:
void setDots(const std::vector<PartyDot>& dots) { dots_ = &dots; }
void render(const LayerContext& ctx) override;
private:
const std::vector<PartyDot>* dots_ = nullptr;
};
} // namespace world_map
} // namespace rendering
} // namespace wowee

View file

@ -0,0 +1,16 @@
// player_marker_layer.hpp — Directional player arrow on the world map.
#pragma once
#include "rendering/world_map/overlay_renderer.hpp"
namespace wowee {
namespace rendering {
namespace world_map {
class PlayerMarkerLayer : public IOverlayLayer {
public:
void render(const LayerContext& ctx) override;
};
} // namespace world_map
} // namespace rendering
} // namespace wowee

View file

@ -0,0 +1,21 @@
// poi_marker_layer.hpp — Town/dungeon/capital POI icons on the world map.
#pragma once
#include "rendering/world_map/overlay_renderer.hpp"
#include "rendering/world_map/world_map_types.hpp"
#include <vector>
namespace wowee {
namespace rendering {
namespace world_map {
class POIMarkerLayer : public IOverlayLayer {
public:
void setMarkers(const std::vector<POI>& markers) { markers_ = &markers; }
void render(const LayerContext& ctx) override;
private:
const std::vector<POI>* markers_ = nullptr;
};
} // namespace world_map
} // namespace rendering
} // namespace wowee

View file

@ -0,0 +1,21 @@
// quest_poi_layer.hpp — Quest objective markers on the world map.
#pragma once
#include "rendering/world_map/overlay_renderer.hpp"
#include "rendering/world_map/world_map_types.hpp"
#include <vector>
namespace wowee {
namespace rendering {
namespace world_map {
class QuestPOILayer : public IOverlayLayer {
public:
void setPois(const std::vector<QuestPOI>& pois) { pois_ = &pois; }
void render(const LayerContext& ctx) override;
private:
const std::vector<QuestPOI>* pois_ = nullptr;
};
} // namespace world_map
} // namespace rendering
} // namespace wowee

View file

@ -0,0 +1,16 @@
// subzone_tooltip_layer.hpp — Overlay area hover labels in zone view.
#pragma once
#include "rendering/world_map/overlay_renderer.hpp"
namespace wowee {
namespace rendering {
namespace world_map {
class SubzoneTooltipLayer : public IOverlayLayer {
public:
void render(const LayerContext& ctx) override;
};
} // namespace world_map
} // namespace rendering
} // namespace wowee

View file

@ -0,0 +1,21 @@
// taxi_node_layer.hpp — Flight master diamond icons on the world map.
#pragma once
#include "rendering/world_map/overlay_renderer.hpp"
#include "rendering/world_map/world_map_types.hpp"
#include <vector>
namespace wowee {
namespace rendering {
namespace world_map {
class TaxiNodeLayer : public IOverlayLayer {
public:
void setNodes(const std::vector<TaxiNode>& nodes) { nodes_ = &nodes; }
void render(const LayerContext& ctx) override;
private:
const std::vector<TaxiNode>* nodes_ = nullptr;
};
} // namespace world_map
} // namespace rendering
} // namespace wowee

View file

@ -0,0 +1,55 @@
// zone_highlight_layer.hpp — Continent view zone rectangles + hover effects.
#pragma once
#include "rendering/world_map/overlay_renderer.hpp"
#include "rendering/world_map/zone_metadata.hpp"
#include "rendering/vk_texture.hpp"
#include <vulkan/vulkan.h>
#include <unordered_map>
#include <memory>
namespace wowee {
namespace rendering {
class VkContext;
}
namespace pipeline { class AssetManager; }
namespace rendering {
namespace world_map {
class ZoneHighlightLayer : public IOverlayLayer {
public:
~ZoneHighlightLayer() override;
void setMetadata(const ZoneMetadata* metadata) { metadata_ = metadata; }
void initialize(VkContext* ctx, pipeline::AssetManager* am);
void clearTextures();
void render(const LayerContext& ctx) override;
int hoveredZone() const { return hoveredZone_; }
/// Get the ImGui texture ID for a highlight BLP, loading lazily.
/// key is used as cache key; customPath overrides the default path if non-empty.
ImTextureID getHighlightTexture(const std::string& key,
const std::string& customPath = "");
private:
/// Load the highlight BLP and register it with ImGui.
void ensureHighlight(const std::string& key, const std::string& customPath);
const ZoneMetadata* metadata_ = nullptr;
VkContext* vkCtx_ = nullptr;
pipeline::AssetManager* assetManager_ = nullptr;
struct HighlightEntry {
std::unique_ptr<VkTexture> texture;
VkDescriptorSet imguiDS = VK_NULL_HANDLE; // ImGui texture ID
};
std::unordered_map<std::string, HighlightEntry> highlights_;
std::unordered_set<std::string> missingHighlights_; // areas with no highlight file
int hoveredZone_ = -1;
int prevHoveredZone_ = -1;
float hoverHighlightAlpha_ = 0.0f;
};
} // namespace world_map
} // namespace rendering
} // namespace wowee

View file

@ -0,0 +1,75 @@
// map_resolver.hpp — Centralized map navigation resolution for the world map.
// Determines the correct action when clicking a region or zone at any view level.
// All functions are stateless free functions — trivially testable.
// Map folder names are resolved from a built-in table matching
// Data/interface/worldmap/ rather than WorldLoader::mapIdToName.
#pragma once
#include "rendering/world_map/world_map_types.hpp"
#include <string>
#include <vector>
#include <cstdint>
namespace wowee {
namespace rendering {
namespace world_map {
// ── Map folder lookup (replaces WorldLoader::mapIdToName for world map) ──
/// Map ID → worldmap folder name (e.g. 0 → "Azeroth", 571 → "Northrend").
/// Returns empty string if unknown.
const char* mapIdToFolder(uint32_t mapId);
/// Worldmap folder name → map ID (e.g. "Azeroth" → 0, "Northrend" → 571).
/// Case-insensitive comparison. Returns -1 if unknown.
int folderToMapId(const std::string& folder);
/// Map ID → display name for UI (e.g. 0 → "Eastern Kingdoms", 571 → "Northrend").
/// Returns nullptr if unknown.
const char* mapDisplayName(uint32_t mapId);
// ── Result types ─────────────────────────────────────────────
enum class MapResolveAction {
NONE, ///< No valid navigation target
NAVIGATE_CONTINENT, ///< Switch to continent view within current map data
LOAD_MAP, ///< Load a different map entirely (switchToMap)
ENTER_ZONE, ///< Enter zone view within current continent
};
struct MapResolveResult {
MapResolveAction action = MapResolveAction::NONE;
int targetZoneIdx = -1; ///< Zone index for NAVIGATE_CONTINENT or ENTER_ZONE
std::string targetMapName; ///< Map folder name for LOAD_MAP
};
// ── Resolve functions ────────────────────────────────────────
/// Resolve WORLD view region click. Determines whether to navigate within
/// the current map data (e.g. clicking EK when already on Azeroth) or load
/// a new map (e.g. clicking Kalimdor or Northrend from Azeroth world view).
MapResolveResult resolveWorldRegionClick(uint32_t regionMapId,
const std::vector<Zone>& zones,
int currentMapId,
int cosmicIdx);
/// Resolve CONTINENT view zone click. Determines whether the clicked zone
/// can be entered directly (same map) or requires loading a different map
/// (zone's displayMapID differs from current).
MapResolveResult resolveZoneClick(int zoneIdx,
const std::vector<Zone>& zones,
int currentMapId);
/// Resolve COSMIC view map click. Always returns LOAD_MAP for the target.
MapResolveResult resolveCosmicClick(uint32_t targetMapId);
/// Find the best continent zone index to display for a given mapId within
/// the currently loaded zones. Prefers leaf continents over root continents.
/// Returns -1 if no suitable continent is found.
int findContinentForMapId(const std::vector<Zone>& zones,
uint32_t mapId,
int cosmicIdx);
} // namespace world_map
} // namespace rendering
} // namespace wowee

View file

@ -0,0 +1,71 @@
// overlay_renderer.hpp — ImGui overlay layer system for the world map.
// Extracted from WorldMap::renderImGuiOverlay (Phase 8 of refactoring plan).
// OCP — new marker types are added by implementing IOverlayLayer.
#pragma once
#include "rendering/world_map/world_map_types.hpp"
#include <glm/glm.hpp>
#include <vector>
#include <memory>
#include <unordered_set>
#include <unordered_map>
#include <array>
#include <cstdint>
#include <functional>
#include <imgui.h>
struct ImDrawList;
namespace wowee {
namespace rendering {
namespace world_map {
/// Context passed to each overlay layer during rendering.
struct LayerContext {
ImDrawList* drawList = nullptr;
ImVec2 imgMin; // top-left of map image in screen space
float displayW = 0, displayH = 0;
glm::vec3 playerRenderPos;
float playerYawDeg = 0;
int currentZoneIdx = -1;
int continentIdx = -1;
int currentMapId = -1;
ViewLevel viewLevel = ViewLevel::ZONE;
const std::vector<Zone>* zones = nullptr;
const std::unordered_set<int>* exploredZones = nullptr;
const std::unordered_set<int>* exploredOverlays = nullptr;
const std::unordered_map<uint32_t, std::string>* areaNameByAreaId = nullptr;
// FBO dimensions for overlay coordinate math
int fboW = 1024;
int fboH = 768;
// ZMP pixel map for continent-view hover (128x128 grid of AreaTable IDs)
const std::array<uint32_t, 128 * 128>* zmpGrid = nullptr;
bool hasZmpData = false;
// Function to resolve AreaTable ID → zone index (from DataRepository)
int (*zmpResolveZoneIdx)(const void* repo, uint32_t areaId) = nullptr;
const void* zmpRepoPtr = nullptr; // opaque DataRepository pointer
// ZMP-derived zone bounding boxes (zone index → UV rect on display)
const std::unordered_map<int, ZmpRect>* zmpZoneBounds = nullptr;
};
/// Interface for an overlay layer rendered on top of the composite map.
class IOverlayLayer {
public:
virtual ~IOverlayLayer() = default;
virtual void render(const LayerContext& ctx) = 0;
};
/// Orchestrates rendering of all registered overlay layers.
class OverlayRenderer {
public:
void addLayer(std::unique_ptr<IOverlayLayer> layer);
void render(const LayerContext& ctx);
private:
std::vector<std::unique_ptr<IOverlayLayer>> layers_;
};
} // namespace world_map
} // namespace rendering
} // namespace wowee

View file

@ -0,0 +1,65 @@
// view_state_machine.hpp — Navigation state and transitions for the world map.
// Extracted from WorldMap zoom/enter methods (Phase 6 of refactoring plan).
// SRP — pure state machine, no rendering or input code.
#pragma once
#include "rendering/world_map/world_map_types.hpp"
namespace wowee {
namespace rendering {
namespace world_map {
/// Manages the current view level and transitions between views.
class ViewStateMachine {
public:
ViewLevel currentLevel() const { return level_; }
const TransitionState& transition() const { return transition_; }
int continentIdx() const { return continentIdx_; }
int currentZoneIdx() const { return currentIdx_; }
bool cosmicEnabled() const { return cosmicEnabled_; }
void setContinentIdx(int idx) { continentIdx_ = idx; }
void setCurrentZoneIdx(int idx) { currentIdx_ = idx; }
void setCosmicEnabled(bool enabled) { cosmicEnabled_ = enabled; }
void setLevel(ViewLevel level) { level_ = level; }
/// Result of a zoom/navigate operation.
struct ZoomResult {
bool changed = false;
ViewLevel newLevel = ViewLevel::ZONE;
int targetIdx = -1; // zone index to load/composite
};
/// Attempt to zoom in. hoveredZoneIdx is the zone under the cursor (-1 if none).
/// playerZoneIdx is the zone the player is standing in (-1 if none).
ZoomResult zoomIn(int hoveredZoneIdx, int playerZoneIdx);
/// Attempt to zoom out one level.
ZoomResult zoomOut();
/// Navigate to world view. Returns the root/fallback continent index to composite.
ZoomResult enterWorldView();
/// Navigate to cosmic view.
ZoomResult enterCosmicView();
/// Navigate directly into a zone from continent view.
ZoomResult enterZone(int zoneIdx);
/// Advance transition animation. Returns true while animating.
bool updateTransition(float deltaTime);
private:
void startTransition(ViewLevel from, ViewLevel to, float duration = 0.3f);
ViewLevel level_ = ViewLevel::CONTINENT;
TransitionState transition_;
int continentIdx_ = -1;
int currentIdx_ = -1;
bool cosmicEnabled_ = true;
};
} // namespace world_map
} // namespace rendering
} // namespace wowee

View file

@ -0,0 +1,64 @@
// world_map_facade.hpp — Public API for the world map system.
// Drop-in replacement for the monolithic WorldMap class (Phase 10 of refactoring plan).
// Facade pattern — hides internal complexity behind the same public interface.
#pragma once
#include "rendering/world_map/world_map_types.hpp"
#include <glm/glm.hpp>
#include <string>
#include <vector>
#include <memory>
#include <vulkan/vulkan.h>
namespace wowee {
namespace rendering {
class VkContext;
}
namespace pipeline { class AssetManager; }
namespace rendering {
namespace world_map {
class WorldMapFacade {
public:
/// Backward-compatible alias for old WorldMap::QuestPoi usage.
using QuestPoi = QuestPOI;
WorldMapFacade();
~WorldMapFacade();
bool initialize(VkContext* ctx, pipeline::AssetManager* am);
void shutdown();
/// Off-screen composite pass — call BEFORE the main render pass begins.
void compositePass(VkCommandBuffer cmd);
/// ImGui overlay — call INSIDE the main render pass (during ImGui frame).
void render(const glm::vec3& playerRenderPos,
int screenWidth, int screenHeight,
float playerYawDeg = 0.0f);
void setMapName(const std::string& name);
void setServerExplorationMask(const std::vector<uint32_t>& masks, bool hasData);
void setPartyDots(std::vector<PartyDot> dots);
void setTaxiNodes(std::vector<TaxiNode> nodes);
void setQuestPois(std::vector<QuestPOI> pois);
void setCorpsePos(bool hasCorpse, glm::vec3 renderPos);
bool isOpen() const;
void close();
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
} // namespace world_map
} // namespace rendering
} // namespace wowee
// Backward-compatible alias for gradual migration
namespace wowee {
namespace rendering {
using WorldMap = world_map::WorldMapFacade;
} // namespace rendering
} // namespace wowee

View file

@ -0,0 +1,132 @@
// world_map_types.hpp — Vulkan-free domain types for the world map system.
// Extracted from rendering/world_map.hpp (Phase 1 of refactoring plan).
// Consumers of these types do NOT need Vulkan/VMA headers.
#pragma once
#include <glm/glm.hpp>
#include <cstdint>
#include <string>
#include <vector>
namespace wowee {
namespace rendering {
namespace world_map {
// ── View hierarchy ───────────────────────────────────────────
enum class ViewLevel { COSMIC, WORLD, CONTINENT, ZONE };
// ── Transition animation ─────────────────────────────────────
struct TransitionState {
bool active = false;
float progress = 0.0f; // 0.0 → 1.0
float duration = 0.2f; // seconds
ViewLevel fromLevel = ViewLevel::ZONE;
ViewLevel toLevel = ViewLevel::ZONE;
};
// ── Zone faction & metadata ──────────────────────────────────
enum class ZoneFaction : uint8_t { Neutral, Alliance, Horde, Contested };
struct ZoneMeta {
uint8_t minLevel = 0, maxLevel = 0;
ZoneFaction faction = ZoneFaction::Neutral;
};
// ── Cosmic view (cross-realm) ────────────────────────────────
struct CosmicMapEntry {
int mapId = 0;
std::string label;
// Clickable region in UV space (0-1 range on the cosmic composite)
float uvLeft = 0, uvTop = 0, uvRight = 0, uvBottom = 0;
};
// ── Zone bounds (shared between Zone and coordinate projection) ──
struct ZoneBounds {
float locLeft = 0, locRight = 0;
float locTop = 0, locBottom = 0;
};
/// ZMP-derived bounding rectangle in display UV [0,1] coordinates.
/// Computed by scanning the ZMP grid for each zone's area ID.
/// Maps pixel-for-pixel to the continent map tiles shown on screen.
struct ZmpRect {
float uMin = 0, uMax = 0;
float vMin = 0, vMax = 0;
bool valid = false;
};
// ── Overlay entry (exploration overlay from WorldMapOverlay.dbc) ──
struct OverlayEntry {
uint32_t areaIDs[4] = {}; // Up to 4 AreaTable IDs contributing to this overlay
std::string textureName; // Texture prefix (e.g., "Goldshire")
uint16_t texWidth = 0, texHeight = 0; // Overlay size in pixels
uint16_t offsetX = 0, offsetY = 0; // Pixel offset within zone map
int tileCols = 0, tileRows = 0;
// HitRect from WorldMapOverlay.dbc fields 13-16 — fast AABB pre-filter for
// subzone hover detection in zone view (avoids sampling every overlay).
uint16_t hitRectLeft = 0, hitRectRight = 0;
uint16_t hitRectTop = 0, hitRectBottom = 0;
// NOTE: texture pointers are managed by CompositeRenderer, not stored here.
bool tilesLoaded = false;
};
// ── Zone (from WorldMapArea.dbc) ─────────────────────────────
struct Zone {
uint32_t wmaID = 0;
uint32_t areaID = 0; // 0 = continent level
std::string areaName; // texture folder name (from DBC)
ZoneBounds bounds;
uint32_t displayMapID = 0;
uint32_t parentWorldMapID = 0;
std::vector<uint32_t> exploreBits; // all AreaBit indices (zone + subzones)
std::vector<OverlayEntry> overlays;
};
// ── Party member dot (UI layer → world map overlay) ──────────
struct PartyDot {
glm::vec3 renderPos; ///< Position in render-space coordinates
uint32_t color; ///< RGBA packed color (IM_COL32 format)
std::string name; ///< Member name (shown as tooltip on hover)
};
// ── Taxi (flight master) node (UI layer → world map overlay) ─
struct TaxiNode {
uint32_t id = 0; ///< TaxiNodes.dbc ID
uint32_t mapId = 0; ///< WoW internal map ID (0=EK,1=Kal,530=Outland,571=Northrend)
float wowX = 0, wowY = 0, wowZ = 0; ///< Canonical WoW coordinates
std::string name; ///< Node name (shown as tooltip)
bool known = false; ///< Player has discovered this node
};
// ── Area Point of Interest from AreaPOI.dbc ──────────────────
struct POI {
uint32_t id = 0;
uint32_t importance = 0; ///< 0=small, 1=medium, 2=large (capital)
uint32_t iconType = 0; ///< Icon category from AreaPOI.dbc
uint32_t factionId = 0; ///< 0=neutral, 67=Horde, 469=Alliance
float wowX = 0, wowY = 0, wowZ = 0; ///< Canonical WoW coordinates
uint32_t mapId = 0; ///< WoW internal map ID
std::string name;
std::string description;
};
// ── Quest POI marker (from SMSG_QUEST_POI_QUERY_RESPONSE) ────
struct QuestPOI {
float wowX = 0, wowY = 0; ///< Canonical WoW coordinates (centroid)
std::string name; ///< Quest title
};
} // namespace world_map
} // namespace rendering
} // namespace wowee

View file

@ -0,0 +1,37 @@
// zone_metadata.hpp — Zone level ranges, faction data, and label formatting.
// Extracted from WorldMap::initZoneMeta and inline label formatting
// (Phase 4 of refactoring plan). DRY — formatLabel used by multiple layers.
#pragma once
#include "rendering/world_map/world_map_types.hpp"
#include <string>
#include <unordered_map>
namespace wowee {
namespace rendering {
namespace world_map {
class ZoneMetadata {
public:
/// Initialize the zone metadata table (level ranges, factions).
void initialize();
/// Look up metadata for a zone by area name. Returns nullptr if not found.
const ZoneMeta* find(const std::string& areaName) const;
/// Format a zone label with level range and faction tag.
/// e.g. "Elwynn (1-10) [Alliance]"
static std::string formatLabel(const std::string& areaName,
const ZoneMeta* meta);
/// Format hover label with level range and bracket-tag for faction.
static std::string formatHoverLabel(const std::string& areaName,
const ZoneMeta* meta);
private:
std::unordered_map<std::string, ZoneMeta> table_;
};
} // namespace world_map
} // namespace rendering
} // namespace wowee

View file

@ -71,6 +71,8 @@ const char* WorldLoader::mapDisplayName(uint32_t mapId) {
switch (mapId) {
case 0: return "Eastern Kingdoms";
case 1: return "Kalimdor";
case 13: return "Test";
case 169: return "Emerald Dream";
case 530: return "Outland";
case 571: return "Northrend";
default: return nullptr;
@ -170,6 +172,15 @@ const char* WorldLoader::mapIdToName(uint32_t mapId) {
}
}
int WorldLoader::mapNameToId(const std::string& name) {
// Reverse lookup: iterate known continent IDs and match against mapIdToName.
static constexpr uint32_t kContinentIds[] = {0, 1, 530, 571};
for (uint32_t id : kContinentIds) {
if (name == mapIdToName(id)) return static_cast<int>(id);
}
return -1;
}
void WorldLoader::processPendingEntry() {
if (!pendingWorldEntry_ || loadingWorld_) return;
auto entry = *pendingWorldEntry_;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,570 @@
// composite_renderer.cpp — Vulkan off-screen composite rendering for the world map.
// Extracted from WorldMap::initialize, shutdown, compositePass, loadZoneTextures,
// loadOverlayTextures, destroyZoneTextures (Phase 7 of refactoring plan).
#include "rendering/world_map/composite_renderer.hpp"
#include "rendering/vk_context.hpp"
#include "rendering/vk_texture.hpp"
#include "rendering/vk_render_target.hpp"
#include "rendering/vk_pipeline.hpp"
#include "rendering/vk_shader.hpp"
#include "rendering/vk_utils.hpp"
#include "pipeline/asset_manager.hpp"
#include "core/logger.hpp"
namespace wowee {
namespace rendering {
namespace world_map {
CompositeRenderer::CompositeRenderer() = default;
CompositeRenderer::~CompositeRenderer() {
shutdown();
}
void CompositeRenderer::ensureTextureSlots(size_t zoneCount, const std::vector<Zone>& zones) {
if (zoneTextureSlots_.size() >= zoneCount) return;
zoneTextureSlots_.resize(zoneCount);
for (size_t i = 0; i < zoneCount; i++) {
auto& slots = zoneTextureSlots_[i];
if (slots.overlays.size() != zones[i].overlays.size()) {
slots.overlays.resize(zones[i].overlays.size());
for (size_t oi = 0; oi < zones[i].overlays.size(); oi++) {
const auto& ov = zones[i].overlays[oi];
slots.overlays[oi].tiles.resize(ov.tileCols * ov.tileRows, nullptr);
}
}
}
}
bool CompositeRenderer::initialize(VkContext* ctx, pipeline::AssetManager* am) {
if (initialized) return true;
vkCtx = ctx;
assetManager = am;
VkDevice device = vkCtx->getDevice();
// --- Composite render target (1024x768) ---
compositeTarget = std::make_unique<VkRenderTarget>();
if (!compositeTarget->create(*vkCtx, FBO_W, FBO_H)) {
LOG_ERROR("CompositeRenderer: failed to create composite render target");
return false;
}
// --- Quad vertex buffer (unit quad: pos2 + uv2) ---
float quadVerts[] = {
0.0f, 0.0f, 0.0f, 0.0f,
1.0f, 0.0f, 1.0f, 0.0f,
1.0f, 1.0f, 1.0f, 1.0f,
0.0f, 0.0f, 0.0f, 0.0f,
1.0f, 1.0f, 1.0f, 1.0f,
0.0f, 1.0f, 0.0f, 1.0f,
};
auto quadBuf = uploadBuffer(*vkCtx, quadVerts, sizeof(quadVerts),
VK_BUFFER_USAGE_VERTEX_BUFFER_BIT);
quadVB = quadBuf.buffer;
quadVBAlloc = quadBuf.allocation;
// --- Descriptor set layout: 1 combined image sampler at binding 0 ---
VkDescriptorSetLayoutBinding samplerBinding{};
samplerBinding.binding = 0;
samplerBinding.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
samplerBinding.descriptorCount = 1;
samplerBinding.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT;
samplerSetLayout = createDescriptorSetLayout(device, { samplerBinding });
// --- Descriptor pool ---
VkDescriptorPoolSize poolSize{};
poolSize.type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
poolSize.descriptorCount = MAX_DESC_SETS;
VkDescriptorPoolCreateInfo poolInfo{};
poolInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO;
poolInfo.maxSets = MAX_DESC_SETS;
poolInfo.poolSizeCount = 1;
poolInfo.pPoolSizes = &poolSize;
vkCreateDescriptorPool(device, &poolInfo, nullptr, &descPool);
// --- Allocate descriptor sets ---
constexpr uint32_t tileSetCount = 24;
constexpr uint32_t overlaySetCount = MAX_OVERLAY_TILES * 2;
constexpr uint32_t totalSets = tileSetCount + 1 + 1 + overlaySetCount;
std::vector<VkDescriptorSetLayout> layouts(totalSets, samplerSetLayout);
VkDescriptorSetAllocateInfo allocInfo{};
allocInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO;
allocInfo.descriptorPool = descPool;
allocInfo.descriptorSetCount = totalSets;
allocInfo.pSetLayouts = layouts.data();
std::vector<VkDescriptorSet> allSets(totalSets);
vkAllocateDescriptorSets(device, &allocInfo, allSets.data());
uint32_t si = 0;
for (int f = 0; f < 2; f++)
for (int t = 0; t < 12; t++)
tileDescSets[f][t] = allSets[si++];
imguiDisplaySet = allSets[si++];
fogDescSet_ = allSets[si++];
for (int f = 0; f < 2; f++)
for (uint32_t t = 0; t < MAX_OVERLAY_TILES; t++)
overlayDescSets_[f][t] = allSets[si++];
// --- Write display descriptor set → composite render target ---
VkDescriptorImageInfo compositeImgInfo = compositeTarget->descriptorInfo();
VkWriteDescriptorSet displayWrite{};
displayWrite.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
displayWrite.dstSet = imguiDisplaySet;
displayWrite.dstBinding = 0;
displayWrite.descriptorCount = 1;
displayWrite.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
displayWrite.pImageInfo = &compositeImgInfo;
vkUpdateDescriptorSets(device, 1, &displayWrite, 0, nullptr);
// --- Pipeline layout: samplerSetLayout + push constant (16 bytes, vertex) ---
VkPushConstantRange tilePush{};
tilePush.stageFlags = VK_SHADER_STAGE_VERTEX_BIT;
tilePush.offset = 0;
tilePush.size = sizeof(WorldMapTilePush);
tilePipelineLayout = createPipelineLayout(device, { samplerSetLayout }, { tilePush });
// --- Vertex input: pos2 (loc 0) + uv2 (loc 1), stride 16 ---
VkVertexInputBindingDescription binding{};
binding.binding = 0;
binding.stride = 4 * sizeof(float);
binding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX;
std::vector<VkVertexInputAttributeDescription> attrs(2);
attrs[0] = { 0, 0, VK_FORMAT_R32G32_SFLOAT, 0 };
attrs[1] = { 1, 0, VK_FORMAT_R32G32_SFLOAT, 2 * sizeof(float) };
// --- Load tile shaders and build pipeline ---
{
VkShaderModule vs, fs;
if (!vs.loadFromFile(device, "assets/shaders/world_map.vert.spv") ||
!fs.loadFromFile(device, "assets/shaders/world_map.frag.spv")) {
LOG_ERROR("CompositeRenderer: failed to load tile shaders");
return false;
}
tilePipeline = PipelineBuilder()
.setShaders(vs.stageInfo(VK_SHADER_STAGE_VERTEX_BIT),
fs.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT))
.setVertexInput({ binding }, attrs)
.setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST)
.setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE)
.setNoDepthTest()
.setColorBlendAttachment(PipelineBuilder::blendDisabled())
.setLayout(tilePipelineLayout)
.setRenderPass(compositeTarget->getRenderPass())
.setDynamicStates({ VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR })
.build(device, vkCtx->getPipelineCache());
vs.destroy();
fs.destroy();
}
if (!tilePipeline) {
LOG_ERROR("CompositeRenderer: failed to create tile pipeline");
return false;
}
// --- Overlay pipeline (alpha-blended) ---
{
VkPushConstantRange overlayPushVert{};
overlayPushVert.stageFlags = VK_SHADER_STAGE_VERTEX_BIT;
overlayPushVert.offset = 0;
overlayPushVert.size = sizeof(WorldMapTilePush);
VkPushConstantRange overlayPushFrag{};
overlayPushFrag.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT;
overlayPushFrag.offset = 16;
overlayPushFrag.size = sizeof(glm::vec4);
overlayPipelineLayout_ = createPipelineLayout(device, { samplerSetLayout },
{ overlayPushVert, overlayPushFrag });
VkShaderModule vs, fs;
if (!vs.loadFromFile(device, "assets/shaders/world_map.vert.spv") ||
!fs.loadFromFile(device, "assets/shaders/world_map_fog.frag.spv")) {
LOG_ERROR("CompositeRenderer: failed to load overlay shaders");
return false;
}
overlayPipeline_ = PipelineBuilder()
.setShaders(vs.stageInfo(VK_SHADER_STAGE_VERTEX_BIT),
fs.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT))
.setVertexInput({ binding }, attrs)
.setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST)
.setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE)
.setNoDepthTest()
.setColorBlendAttachment(PipelineBuilder::blendAlpha())
.setLayout(overlayPipelineLayout_)
.setRenderPass(compositeTarget->getRenderPass())
.setDynamicStates({ VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR })
.build(device, vkCtx->getPipelineCache());
vs.destroy();
fs.destroy();
}
if (!overlayPipeline_) {
LOG_ERROR("CompositeRenderer: failed to create overlay pipeline");
return false;
}
// --- 1×1 white fog texture ---
{
uint8_t white[] = { 255, 255, 255, 255 };
fogTexture_ = std::make_unique<VkTexture>();
fogTexture_->upload(*vkCtx, white, 1, 1, VK_FORMAT_R8G8B8A8_UNORM, false);
fogTexture_->createSampler(device, VK_FILTER_NEAREST, VK_FILTER_NEAREST,
VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE, 1.0f);
VkDescriptorImageInfo fogImgInfo = fogTexture_->descriptorInfo();
VkWriteDescriptorSet fogWrite{};
fogWrite.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
fogWrite.dstSet = fogDescSet_;
fogWrite.dstBinding = 0;
fogWrite.descriptorCount = 1;
fogWrite.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
fogWrite.pImageInfo = &fogImgInfo;
vkUpdateDescriptorSets(device, 1, &fogWrite, 0, nullptr);
}
initialized = true;
LOG_INFO("CompositeRenderer initialized (", FBO_W, "x", FBO_H, " composite)");
return true;
}
void CompositeRenderer::shutdown() {
if (!vkCtx) return;
VkDevice device = vkCtx->getDevice();
VmaAllocator alloc = vkCtx->getAllocator();
vkDeviceWaitIdle(device);
if (tilePipeline) { vkDestroyPipeline(device, tilePipeline, nullptr); tilePipeline = VK_NULL_HANDLE; }
if (tilePipelineLayout) { vkDestroyPipelineLayout(device, tilePipelineLayout, nullptr); tilePipelineLayout = VK_NULL_HANDLE; }
if (overlayPipeline_) { vkDestroyPipeline(device, overlayPipeline_, nullptr); overlayPipeline_ = VK_NULL_HANDLE; }
if (overlayPipelineLayout_) { vkDestroyPipelineLayout(device, overlayPipelineLayout_, nullptr); overlayPipelineLayout_ = VK_NULL_HANDLE; }
if (descPool) { vkDestroyDescriptorPool(device, descPool, nullptr); descPool = VK_NULL_HANDLE; }
if (samplerSetLayout) { vkDestroyDescriptorSetLayout(device, samplerSetLayout, nullptr); samplerSetLayout = VK_NULL_HANDLE; }
if (quadVB) { vmaDestroyBuffer(alloc, quadVB, quadVBAlloc); quadVB = VK_NULL_HANDLE; }
for (auto& tex : zoneTextures) {
if (tex) tex->destroy(device, alloc);
}
zoneTextures.clear();
zoneTextureSlots_.clear();
if (fogTexture_) { fogTexture_->destroy(device, alloc); fogTexture_.reset(); }
if (compositeTarget) { compositeTarget->destroy(device, alloc); compositeTarget.reset(); }
initialized = false;
vkCtx = nullptr;
}
void CompositeRenderer::destroyZoneTextures(std::vector<Zone>& /*zones*/) {
if (!vkCtx) return;
VkDevice device = vkCtx->getDevice();
VmaAllocator alloc = vkCtx->getAllocator();
for (auto& tex : zoneTextures) {
if (tex) tex->destroy(device, alloc);
}
zoneTextures.clear();
for (auto& slots : zoneTextureSlots_) {
for (auto& tex : slots.tileTextures) tex = nullptr;
slots.tilesLoaded = false;
for (auto& ov : slots.overlays) {
for (auto& t : ov.tiles) t = nullptr;
ov.tilesLoaded = false;
}
}
zoneTextureSlots_.clear();
}
void CompositeRenderer::loadZoneTextures(int zoneIdx, std::vector<Zone>& zones,
const std::string& mapName) {
if (zoneIdx < 0 || zoneIdx >= static_cast<int>(zones.size())) return;
ensureTextureSlots(zones.size(), zones);
auto& slots = zoneTextureSlots_[zoneIdx];
if (slots.tilesLoaded) return;
slots.tilesLoaded = true;
const auto& zone = zones[zoneIdx];
const std::string& folder = zone.areaName;
if (folder.empty()) return;
LOG_INFO("loadZoneTextures: zone[", zoneIdx, "] areaName='", zone.areaName,
"' areaID=", zone.areaID, " mapName='", mapName, "'");
VkDevice device = vkCtx->getDevice();
int loaded = 0;
for (int i = 0; i < 12; i++) {
std::string path = "Interface\\WorldMap\\" + folder + "\\" +
folder + std::to_string(i + 1) + ".blp";
auto blpImage = assetManager->loadTexture(path);
if (!blpImage.isValid()) {
slots.tileTextures[i] = nullptr;
continue;
}
auto tex = std::make_unique<VkTexture>();
tex->upload(*vkCtx, blpImage.data.data(), blpImage.width, blpImage.height,
VK_FORMAT_R8G8B8A8_UNORM, false);
tex->createSampler(device, VK_FILTER_LINEAR, VK_FILTER_LINEAR,
VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE, 1.0f);
slots.tileTextures[i] = tex.get();
zoneTextures.push_back(std::move(tex));
loaded++;
}
LOG_INFO("CompositeRenderer: loaded ", loaded, "/12 tiles for '", folder, "'");
}
void CompositeRenderer::loadOverlayTextures(int zoneIdx, std::vector<Zone>& zones) {
if (zoneIdx < 0 || zoneIdx >= static_cast<int>(zones.size())) return;
ensureTextureSlots(zones.size(), zones);
const auto& zone = zones[zoneIdx];
auto& slots = zoneTextureSlots_[zoneIdx];
if (zone.overlays.empty()) return;
const std::string& folder = zone.areaName;
if (folder.empty()) return;
VkDevice device = vkCtx->getDevice();
int totalLoaded = 0;
for (size_t oi = 0; oi < zone.overlays.size(); oi++) {
const auto& ov = zone.overlays[oi];
auto& ovSlots = slots.overlays[oi];
if (ovSlots.tilesLoaded) continue;
ovSlots.tilesLoaded = true;
int tileCount = ov.tileCols * ov.tileRows;
for (int t = 0; t < tileCount; t++) {
std::string tileName = ov.textureName + std::to_string(t + 1);
std::string path = "Interface\\WorldMap\\" + folder + "\\" + tileName + ".blp";
auto blpImage = assetManager->loadTexture(path);
if (!blpImage.isValid()) {
ovSlots.tiles[t] = nullptr;
continue;
}
auto tex = std::make_unique<VkTexture>();
tex->upload(*vkCtx, blpImage.data.data(), blpImage.width, blpImage.height,
VK_FORMAT_R8G8B8A8_UNORM, false);
tex->createSampler(device, VK_FILTER_LINEAR, VK_FILTER_LINEAR,
VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE, 1.0f);
ovSlots.tiles[t] = tex.get();
zoneTextures.push_back(std::move(tex));
totalLoaded++;
}
}
LOG_INFO("CompositeRenderer: loaded ", totalLoaded, " overlay tiles for '", folder, "'");
}
void CompositeRenderer::detachZoneTextures() {
if (!zoneTextures.empty() && vkCtx) {
// Defer destruction until all in-flight frames have completed.
// This avoids calling vkDeviceWaitIdle mid-frame, which can trigger
// driver TDR (GPU device lost) under heavy rendering load.
VkDevice device = vkCtx->getDevice();
VmaAllocator alloc = vkCtx->getAllocator();
auto captured = std::make_shared<std::vector<std::unique_ptr<VkTexture>>>(
std::move(zoneTextures));
vkCtx->deferAfterAllFrameFences([device, alloc, captured]() {
for (auto& tex : *captured) {
if (tex) tex->destroy(device, alloc);
}
});
}
zoneTextures.clear();
// Clear CPU-side tracking immediately so new zones get fresh loads
for (auto& slots : zoneTextureSlots_) {
for (auto& tex : slots.tileTextures) tex = nullptr;
slots.tilesLoaded = false;
for (auto& ov : slots.overlays) {
for (auto& t : ov.tiles) t = nullptr;
ov.tilesLoaded = false;
}
}
zoneTextureSlots_.clear();
}
void CompositeRenderer::flushStaleTextures() {
// No-op: texture cleanup is now handled by deferAfterAllFrameFences
// in detachZoneTextures. Kept for API compatibility.
}
void CompositeRenderer::requestComposite(int zoneIdx) {
pendingCompositeIdx_ = zoneIdx;
}
bool CompositeRenderer::hasAnyTile(int zoneIdx) const {
if (zoneIdx < 0 || zoneIdx >= static_cast<int>(zoneTextureSlots_.size()))
return false;
const auto& slots = zoneTextureSlots_[zoneIdx];
for (int i = 0; i < 12; i++) {
if (slots.tileTextures[i] != nullptr) return true;
}
return false;
}
void CompositeRenderer::compositePass(VkCommandBuffer cmd,
const std::vector<Zone>& zones,
const std::unordered_set<int>& exploredOverlays,
bool hasServerMask) {
if (!initialized || pendingCompositeIdx_ < 0 || !compositeTarget) return;
if (pendingCompositeIdx_ >= static_cast<int>(zones.size())) {
pendingCompositeIdx_ = -1;
return;
}
int zoneIdx = pendingCompositeIdx_;
pendingCompositeIdx_ = -1;
if (compositedIdx_ == zoneIdx) return;
ensureTextureSlots(zones.size(), zones);
const auto& zone = zones[zoneIdx];
const auto& slots = zoneTextureSlots_[zoneIdx];
uint32_t frameIdx = vkCtx->getCurrentFrame();
VkDevice device = vkCtx->getDevice();
// Update tile descriptor sets for this frame
for (int i = 0; i < 12; i++) {
VkTexture* tileTex = slots.tileTextures[i];
if (!tileTex || !tileTex->isValid()) continue;
VkDescriptorImageInfo imgInfo = tileTex->descriptorInfo();
VkWriteDescriptorSet write{};
write.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
write.dstSet = tileDescSets[frameIdx][i];
write.dstBinding = 0;
write.descriptorCount = 1;
write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
write.pImageInfo = &imgInfo;
vkUpdateDescriptorSets(device, 1, &write, 0, nullptr);
}
// Begin off-screen render pass
VkClearColorValue clearColor = {{ 0.05f, 0.08f, 0.12f, 1.0f }};
compositeTarget->beginPass(cmd, clearColor);
VkDeviceSize offset = 0;
vkCmdBindVertexBuffers(cmd, 0, 1, &quadVB, &offset);
// --- Pass 1: Draw base map tiles (opaque) ---
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, tilePipeline);
for (int i = 0; i < 12; i++) {
if (!slots.tileTextures[i] || !slots.tileTextures[i]->isValid()) continue;
int col = i % GRID_COLS;
int row = i / GRID_COLS;
vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS,
tilePipelineLayout, 0, 1,
&tileDescSets[frameIdx][i], 0, nullptr);
WorldMapTilePush push{};
push.gridOffset = glm::vec2(static_cast<float>(col), static_cast<float>(row));
push.gridCols = static_cast<float>(GRID_COLS);
push.gridRows = static_cast<float>(GRID_ROWS);
vkCmdPushConstants(cmd, tilePipelineLayout, VK_SHADER_STAGE_VERTEX_BIT,
0, sizeof(push), &push);
vkCmdDraw(cmd, 6, 1, 0, 0);
}
// --- Draw explored overlay textures on top of the base map ---
bool hasOverlays = !zone.overlays.empty() && zone.areaID != 0;
if (hasOverlays && overlayPipeline_) {
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, overlayPipeline_);
uint32_t descSlot = 0;
for (int oi = 0; oi < static_cast<int>(zone.overlays.size()); oi++) {
if (exploredOverlays.count(oi) == 0) continue;
const auto& ov = zone.overlays[oi];
const auto& ovSlots = slots.overlays[oi];
for (int t = 0; t < static_cast<int>(ovSlots.tiles.size()); t++) {
if (!ovSlots.tiles[t] || !ovSlots.tiles[t]->isValid()) continue;
if (descSlot >= MAX_OVERLAY_TILES) break;
VkDescriptorImageInfo ovImgInfo = ovSlots.tiles[t]->descriptorInfo();
VkWriteDescriptorSet ovWrite{};
ovWrite.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
ovWrite.dstSet = overlayDescSets_[frameIdx][descSlot];
ovWrite.dstBinding = 0;
ovWrite.descriptorCount = 1;
ovWrite.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
ovWrite.pImageInfo = &ovImgInfo;
vkUpdateDescriptorSets(device, 1, &ovWrite, 0, nullptr);
vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS,
overlayPipelineLayout_, 0, 1,
&overlayDescSets_[frameIdx][descSlot], 0, nullptr);
int tileCol = t % ov.tileCols;
int tileRow = t / ov.tileCols;
float px = static_cast<float>(ov.offsetX + tileCol * TILE_PX);
float py = static_cast<float>(ov.offsetY + tileRow * TILE_PX);
OverlayPush ovPush{};
ovPush.gridOffset = glm::vec2(px / static_cast<float>(TILE_PX),
py / static_cast<float>(TILE_PX));
ovPush.gridCols = static_cast<float>(GRID_COLS);
ovPush.gridRows = static_cast<float>(GRID_ROWS);
ovPush.tintColor = glm::vec4(1.0f, 1.0f, 1.0f, 1.0f);
vkCmdPushConstants(cmd, overlayPipelineLayout_, VK_SHADER_STAGE_VERTEX_BIT,
0, sizeof(WorldMapTilePush), &ovPush);
vkCmdPushConstants(cmd, overlayPipelineLayout_, VK_SHADER_STAGE_FRAGMENT_BIT,
16, sizeof(glm::vec4), &ovPush.tintColor);
vkCmdDraw(cmd, 6, 1, 0, 0);
descSlot++;
}
}
}
// --- Draw fog of war overlay over unexplored areas ---
if (hasServerMask && zone.areaID != 0 && overlayPipeline_ && fogDescSet_) {
bool hasAnyExplored = false;
for (int oi = 0; oi < static_cast<int>(zone.overlays.size()); oi++) {
if (exploredOverlays.count(oi) > 0) { hasAnyExplored = true; break; }
}
if (!hasAnyExplored && !zone.overlays.empty()) {
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, overlayPipeline_);
vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS,
overlayPipelineLayout_, 0, 1,
&fogDescSet_, 0, nullptr);
OverlayPush fogPush{};
fogPush.gridOffset = glm::vec2(0.0f, 0.0f);
fogPush.gridCols = 1.0f;
fogPush.gridRows = 1.0f;
fogPush.tintColor = glm::vec4(0.15f, 0.15f, 0.2f, 0.55f);
vkCmdPushConstants(cmd, overlayPipelineLayout_, VK_SHADER_STAGE_VERTEX_BIT,
0, sizeof(WorldMapTilePush), &fogPush);
vkCmdPushConstants(cmd, overlayPipelineLayout_, VK_SHADER_STAGE_FRAGMENT_BIT,
16, sizeof(glm::vec4), &fogPush.tintColor);
vkCmdDraw(cmd, 6, 1, 0, 0);
}
}
compositeTarget->endPass(cmd);
compositedIdx_ = zoneIdx;
}
} // namespace world_map
} // namespace rendering
} // namespace wowee

View file

@ -0,0 +1,228 @@
// coordinate_projection.cpp — Pure coordinate math for world map UV projection.
// Extracted from WorldMap::renderPosToMapUV, findBestContinentForPlayer,
// findZoneForPlayer, zoneBelongsToContinent, getContinentProjectionBounds,
// isRootContinent, isLeafContinent (Phase 2 of refactoring plan).
#include "rendering/world_map/coordinate_projection.hpp"
#include <algorithm>
#include <cmath>
#include <limits>
namespace wowee {
namespace rendering {
namespace world_map {
// ── Continent classification helpers ─────────────────────────
bool isRootContinent(const std::vector<Zone>& zones, int idx) {
if (idx < 0 || idx >= static_cast<int>(zones.size())) return false;
const auto& c = zones[idx];
if (c.areaID != 0 || c.wmaID == 0) return false;
for (const auto& z : zones) {
if (z.areaID == 0 && z.parentWorldMapID == c.wmaID) {
return true;
}
}
return false;
}
bool isLeafContinent(const std::vector<Zone>& zones, int idx) {
if (idx < 0 || idx >= static_cast<int>(zones.size())) return false;
const auto& c = zones[idx];
if (c.areaID != 0) return false;
return c.parentWorldMapID != 0;
}
// ── UV projection ────────────────────────────────────────────
glm::vec2 renderPosToMapUV(const glm::vec3& renderPos,
const ZoneBounds& bounds,
bool isContinent) {
float wowX = renderPos.y;
float wowY = renderPos.x;
float denom_h = bounds.locLeft - bounds.locRight;
float denom_v = bounds.locTop - bounds.locBottom;
if (std::abs(denom_h) < 0.001f || std::abs(denom_v) < 0.001f)
return glm::vec2(0.5f, 0.5f);
float u = (bounds.locLeft - wowX) / denom_h;
float v = (bounds.locTop - wowY) / denom_v;
if (isContinent) {
constexpr float kVScale = 1.0f;
constexpr float kVOffset = -0.15f;
v = (v - 0.5f) * kVScale + 0.5f + kVOffset;
}
return glm::vec2(u, v);
}
// ── Continent projection bounds ──────────────────────────────
bool getContinentProjectionBounds(const std::vector<Zone>& zones,
int contIdx,
float& left, float& right,
float& top, float& bottom) {
if (contIdx < 0 || contIdx >= static_cast<int>(zones.size())) return false;
const auto& cont = zones[contIdx];
if (cont.areaID != 0) return false;
if (std::abs(cont.bounds.locLeft - cont.bounds.locRight) > 0.001f &&
std::abs(cont.bounds.locTop - cont.bounds.locBottom) > 0.001f) {
left = cont.bounds.locLeft; right = cont.bounds.locRight;
top = cont.bounds.locTop; bottom = cont.bounds.locBottom;
return true;
}
std::vector<float> northEdges, southEdges, westEdges, eastEdges;
for (int zi = 0; zi < static_cast<int>(zones.size()); zi++) {
if (!zoneBelongsToContinent(zones, zi, contIdx)) continue;
const auto& z = zones[zi];
if (std::abs(z.bounds.locLeft - z.bounds.locRight) < 0.001f ||
std::abs(z.bounds.locTop - z.bounds.locBottom) < 0.001f) continue;
northEdges.push_back(std::max(z.bounds.locLeft, z.bounds.locRight));
southEdges.push_back(std::min(z.bounds.locLeft, z.bounds.locRight));
westEdges.push_back(std::max(z.bounds.locTop, z.bounds.locBottom));
eastEdges.push_back(std::min(z.bounds.locTop, z.bounds.locBottom));
}
if (northEdges.size() < 3) {
left = cont.bounds.locLeft; right = cont.bounds.locRight;
top = cont.bounds.locTop; bottom = cont.bounds.locBottom;
return std::abs(left - right) > 0.001f && std::abs(top - bottom) > 0.001f;
}
left = *std::max_element(northEdges.begin(), northEdges.end());
right = *std::min_element(southEdges.begin(), southEdges.end());
top = *std::max_element(westEdges.begin(), westEdges.end());
bottom = *std::min_element(eastEdges.begin(), eastEdges.end());
if (left <= right || top <= bottom) {
left = cont.bounds.locLeft; right = cont.bounds.locRight;
top = cont.bounds.locTop; bottom = cont.bounds.locBottom;
}
return std::abs(left - right) > 0.001f && std::abs(top - bottom) > 0.001f;
}
// ── Player position lookups ──────────────────────────────────
int findBestContinentForPlayer(const std::vector<Zone>& zones,
const glm::vec3& playerRenderPos) {
float wowX = playerRenderPos.y;
float wowY = playerRenderPos.x;
int bestIdx = -1;
float bestArea = std::numeric_limits<float>::max();
float bestCenterDist2 = std::numeric_limits<float>::max();
bool hasLeaf = false;
for (int i = 0; i < static_cast<int>(zones.size()); i++) {
if (zones[i].areaID == 0 && !isRootContinent(zones, i)) {
hasLeaf = true;
break;
}
}
for (int i = 0; i < static_cast<int>(zones.size()); i++) {
const auto& z = zones[i];
if (z.areaID != 0) continue;
if (hasLeaf && isRootContinent(zones, i)) continue;
float minX = std::min(z.bounds.locLeft, z.bounds.locRight);
float maxX = std::max(z.bounds.locLeft, z.bounds.locRight);
float minY = std::min(z.bounds.locTop, z.bounds.locBottom);
float maxY = std::max(z.bounds.locTop, z.bounds.locBottom);
float spanX = maxX - minX;
float spanY = maxY - minY;
if (spanX < 0.001f || spanY < 0.001f) continue;
bool contains = (wowX >= minX && wowX <= maxX && wowY >= minY && wowY <= maxY);
float area = spanX * spanY;
if (contains) {
if (area < bestArea) { bestArea = area; bestIdx = i; }
} else if (bestIdx < 0) {
float cx = (minX + maxX) * 0.5f, cy = (minY + maxY) * 0.5f;
float dist2 = (wowX - cx) * (wowX - cx) + (wowY - cy) * (wowY - cy);
if (dist2 < bestCenterDist2) { bestCenterDist2 = dist2; bestIdx = i; }
}
}
return bestIdx;
}
int findZoneForPlayer(const std::vector<Zone>& zones,
const glm::vec3& playerRenderPos) {
float wowX = playerRenderPos.y;
float wowY = playerRenderPos.x;
int bestIdx = -1;
float bestArea = std::numeric_limits<float>::max();
for (int i = 0; i < static_cast<int>(zones.size()); i++) {
const auto& z = zones[i];
if (z.areaID == 0) continue;
float minX = std::min(z.bounds.locLeft, z.bounds.locRight);
float maxX = std::max(z.bounds.locLeft, z.bounds.locRight);
float minY = std::min(z.bounds.locTop, z.bounds.locBottom);
float maxY = std::max(z.bounds.locTop, z.bounds.locBottom);
float spanX = maxX - minX, spanY = maxY - minY;
if (spanX < 0.001f || spanY < 0.001f) continue;
if (wowX >= minX && wowX <= maxX && wowY >= minY && wowY <= maxY) {
float area = spanX * spanY;
if (area < bestArea) { bestArea = area; bestIdx = i; }
}
}
return bestIdx;
}
// ── Zonecontinent relationship ──────────────────────────────
bool zoneBelongsToContinent(const std::vector<Zone>& zones,
int zoneIdx, int contIdx) {
if (zoneIdx < 0 || zoneIdx >= static_cast<int>(zones.size())) return false;
if (contIdx < 0 || contIdx >= static_cast<int>(zones.size())) return false;
const auto& z = zones[zoneIdx];
const auto& cont = zones[contIdx];
if (z.areaID == 0) return false;
// Prefer explicit parent link if available
if (z.parentWorldMapID != 0 && cont.wmaID != 0)
return z.parentWorldMapID == cont.wmaID;
// Fallback: spatial overlap heuristic
auto rectMinX = [](const Zone& a) { return std::min(a.bounds.locLeft, a.bounds.locRight); };
auto rectMaxX = [](const Zone& a) { return std::max(a.bounds.locLeft, a.bounds.locRight); };
auto rectMinY = [](const Zone& a) { return std::min(a.bounds.locTop, a.bounds.locBottom); };
auto rectMaxY = [](const Zone& a) { return std::max(a.bounds.locTop, a.bounds.locBottom); };
float zMinX = rectMinX(z), zMaxX = rectMaxX(z);
float zMinY = rectMinY(z), zMaxY = rectMaxY(z);
if ((zMaxX - zMinX) < 0.001f || (zMaxY - zMinY) < 0.001f) return false;
int bestContIdx = -1;
float bestOverlap = 0.0f;
for (int i = 0; i < static_cast<int>(zones.size()); i++) {
const auto& c = zones[i];
if (c.areaID != 0) continue;
float cMinX = rectMinX(c), cMaxX = rectMaxX(c);
float cMinY = rectMinY(c), cMaxY = rectMaxY(c);
if ((cMaxX - cMinX) < 0.001f || (cMaxY - cMinY) < 0.001f) continue;
float ox = std::max(0.0f, std::min(zMaxX, cMaxX) - std::max(zMinX, cMinX));
float oy = std::max(0.0f, std::min(zMaxY, cMaxY) - std::max(zMinY, cMinY));
float overlap = ox * oy;
if (overlap > bestOverlap) { bestOverlap = overlap; bestContIdx = i; }
}
if (bestContIdx >= 0) return bestContIdx == contIdx;
float centerX = (z.bounds.locLeft + z.bounds.locRight) * 0.5f;
float centerY = (z.bounds.locTop + z.bounds.locBottom) * 0.5f;
return centerX >= rectMinX(cont) && centerX <= rectMaxX(cont) &&
centerY >= rectMinY(cont) && centerY <= rectMaxY(cont);
}
} // namespace world_map
} // namespace rendering
} // namespace wowee

View file

@ -0,0 +1,485 @@
// data_repository.cpp — DBC data loading, ZMP pixel map, and zone/POI/overlay storage.
// Extracted from WorldMap::loadZonesFromDBC, loadPOIData, buildCosmicView
// (Phase 5 of refactoring plan).
#include "rendering/world_map/data_repository.hpp"
#include "rendering/world_map/map_resolver.hpp"
#include "pipeline/asset_manager.hpp"
#include "pipeline/dbc_layout.hpp"
#include "core/logger.hpp"
#include "core/application.hpp"
#include "game/expansion_profile.hpp"
#include "game/game_utils.hpp"
#include <algorithm>
#include <cmath>
#include <cstring>
namespace wowee {
namespace rendering {
namespace world_map {
void DataRepository::clear() {
zones_.clear();
poiMarkers_.clear();
cosmicMaps_.clear();
azerothRegions_.clear();
exploreFlagByAreaId_.clear();
areaNameByAreaId_.clear();
areaIdToZoneIdx_.clear();
zmpZoneBounds_.clear();
zmpGrid_.fill(0);
zmpLoaded_ = false;
cosmicIdx_ = -1;
worldIdx_ = -1;
currentMapId_ = -1;
cosmicEnabled_ = true;
poisLoaded_ = false;
}
// --------------------------------------------------------
// DBC zone loading (moved from WorldMap::loadZonesFromDBC)
// --------------------------------------------------------
void DataRepository::loadZones(const std::string& mapName,
pipeline::AssetManager& assetManager) {
if (!zones_.empty()) return;
const auto* activeLayout = pipeline::getActiveDBCLayout();
const auto* mapL = activeLayout ? activeLayout->getLayout("Map") : nullptr;
int mapID = -1;
auto mapDbc = assetManager.loadDBC("Map.dbc");
if (mapDbc && mapDbc->isLoaded()) {
for (uint32_t i = 0; i < mapDbc->getRecordCount(); i++) {
std::string dir = mapDbc->getString(i, mapL ? (*mapL)["InternalName"] : 1);
if (dir == mapName) {
mapID = static_cast<int>(mapDbc->getUInt32(i, mapL ? (*mapL)["ID"] : 0));
LOG_INFO("DataRepository: Map.dbc '", mapName, "' -> mapID=", mapID);
break;
}
}
}
if (mapID < 0) {
mapID = folderToMapId(mapName);
if (mapID < 0) {
LOG_WARNING("DataRepository: unknown map '", mapName, "'");
return;
}
}
// Use expansion-aware DBC layout when available; fall back to WotLK stock field
// indices (ID=0, ParentAreaNum=2, ExploreFlag=3) when layout metadata is missing.
const auto* atL = activeLayout ? activeLayout->getLayout("AreaTable") : nullptr;
std::unordered_map<uint32_t, uint32_t> exploreFlagByAreaId;
std::unordered_map<uint32_t, std::vector<uint32_t>> childBitsByParent;
auto areaDbc = assetManager.loadDBC("AreaTable.dbc");
// Bug fix: old code used > 3 which covers core fields (ID=0, ParentAreaNum=2,
// ExploreFlag=3). The > 11 threshold broke exploration for DBC variants with
// 4-11 fields. Load core exploration data with > 3; area name only when > 11.
if (areaDbc && areaDbc->isLoaded() && areaDbc->getFieldCount() > 3) {
const uint32_t fieldCount = areaDbc->getFieldCount();
const uint32_t parentField = atL ? (*atL)["ParentAreaNum"] : 2;
for (uint32_t i = 0; i < areaDbc->getRecordCount(); i++) {
const uint32_t areaId = areaDbc->getUInt32(i, atL ? (*atL)["ID"] : 0);
const uint32_t exploreFlag = areaDbc->getUInt32(i, atL ? (*atL)["ExploreFlag"] : 3);
const uint32_t parentArea = areaDbc->getUInt32(i, parentField);
if (areaId != 0) exploreFlagByAreaId[areaId] = exploreFlag;
if (parentArea != 0) childBitsByParent[parentArea].push_back(exploreFlag);
// Cache area display name (field 11 = AreaName_lang enUS)
if (areaId != 0 && fieldCount > 11) {
std::string areaDispName = areaDbc->getString(i, 11);
if (!areaDispName.empty())
areaNameByAreaId_[areaId] = std::move(areaDispName);
}
}
}
auto wmaDbc = assetManager.loadDBC("WorldMapArea.dbc");
if (!wmaDbc || !wmaDbc->isLoaded()) {
LOG_WARNING("DataRepository: WorldMapArea.dbc not found");
return;
}
const auto* wmaL = activeLayout ? activeLayout->getLayout("WorldMapArea") : nullptr;
int continentIdx = -1;
for (uint32_t i = 0; i < wmaDbc->getRecordCount(); i++) {
uint32_t recMapID = wmaDbc->getUInt32(i, wmaL ? (*wmaL)["MapID"] : 1);
if (static_cast<int>(recMapID) != mapID) continue;
Zone zone;
zone.wmaID = wmaDbc->getUInt32(i, wmaL ? (*wmaL)["ID"] : 0);
zone.areaID = wmaDbc->getUInt32(i, wmaL ? (*wmaL)["AreaID"] : 2);
zone.areaName = wmaDbc->getString(i, wmaL ? (*wmaL)["AreaName"] : 3);
zone.bounds.locLeft = wmaDbc->getFloat(i, wmaL ? (*wmaL)["LocLeft"] : 4);
zone.bounds.locRight = wmaDbc->getFloat(i, wmaL ? (*wmaL)["LocRight"] : 5);
zone.bounds.locTop = wmaDbc->getFloat(i, wmaL ? (*wmaL)["LocTop"] : 6);
zone.bounds.locBottom = wmaDbc->getFloat(i, wmaL ? (*wmaL)["LocBottom"] : 7);
zone.displayMapID = wmaDbc->getUInt32(i, wmaL ? (*wmaL)["DisplayMapID"] : 8);
zone.parentWorldMapID = wmaDbc->getUInt32(i, wmaL ? (*wmaL)["ParentWorldMapID"] : 10);
// Collect the zone's own AreaBit plus all subzone AreaBits
auto exploreIt = exploreFlagByAreaId.find(zone.areaID);
if (exploreIt != exploreFlagByAreaId.end())
zone.exploreBits.push_back(exploreIt->second);
auto childIt = childBitsByParent.find(zone.areaID);
if (childIt != childBitsByParent.end()) {
for (uint32_t bit : childIt->second)
zone.exploreBits.push_back(bit);
}
int idx = static_cast<int>(zones_.size());
LOG_INFO("DataRepository: zone[", idx, "] areaID=", zone.areaID,
" '", zone.areaName, "' L=", zone.bounds.locLeft,
" R=", zone.bounds.locRight, " T=", zone.bounds.locTop,
" B=", zone.bounds.locBottom);
if (zone.areaID == 0 && continentIdx < 0)
continentIdx = idx;
zones_.push_back(std::move(zone));
}
// Derive continent bounds from child zones if missing
for (int ci = 0; ci < static_cast<int>(zones_.size()); ci++) {
auto& cont = zones_[ci];
if (cont.areaID != 0) continue;
if (std::abs(cont.bounds.locLeft) > 0.001f || std::abs(cont.bounds.locRight) > 0.001f ||
std::abs(cont.bounds.locTop) > 0.001f || std::abs(cont.bounds.locBottom) > 0.001f)
continue;
bool first = true;
for (const auto& z : zones_) {
if (z.areaID == 0) continue;
if (std::abs(z.bounds.locLeft - z.bounds.locRight) < 0.001f ||
std::abs(z.bounds.locTop - z.bounds.locBottom) < 0.001f)
continue;
if (z.parentWorldMapID != 0 && cont.wmaID != 0 && z.parentWorldMapID != cont.wmaID)
continue;
if (first) {
cont.bounds.locLeft = z.bounds.locLeft; cont.bounds.locRight = z.bounds.locRight;
cont.bounds.locTop = z.bounds.locTop; cont.bounds.locBottom = z.bounds.locBottom;
first = false;
} else {
cont.bounds.locLeft = std::min(cont.bounds.locLeft, z.bounds.locLeft);
cont.bounds.locRight = std::max(cont.bounds.locRight, z.bounds.locRight);
cont.bounds.locTop = std::min(cont.bounds.locTop, z.bounds.locTop);
cont.bounds.locBottom = std::max(cont.bounds.locBottom, z.bounds.locBottom);
}
}
}
currentMapId_ = mapID;
exploreFlagByAreaId_ = exploreFlagByAreaId; // cache for overlay exploration checks
LOG_INFO("DataRepository: loaded ", zones_.size(), " zones for mapID=", mapID,
", continentIdx=", continentIdx);
// Build wmaID → zone index lookup
std::unordered_map<uint32_t, int> wmaIdToZoneIdx;
for (int i = 0; i < static_cast<int>(zones_.size()); i++)
wmaIdToZoneIdx[zones_[i].wmaID] = i;
// Parse WorldMapOverlay.dbc → attach overlay entries to their zones
auto wmoDbc = assetManager.loadDBC("WorldMapOverlay.dbc");
if (wmoDbc && wmoDbc->isLoaded()) {
// WotLK field layout:
// 0:ID, 1:WorldMapAreaID, 2-5:AreaTableID[4],
// 6:MapPointX, 7:MapPointY, 8:TextureName(str),
// 9:TextureWidth, 10:TextureHeight,
// 11:OffsetX, 12:OffsetY, 13-16:HitRect
int totalOverlays = 0;
for (uint32_t i = 0; i < wmoDbc->getRecordCount(); i++) {
uint32_t wmaID = wmoDbc->getUInt32(i, 1);
auto it = wmaIdToZoneIdx.find(wmaID);
if (it == wmaIdToZoneIdx.end()) continue;
OverlayEntry ov;
ov.areaIDs[0] = wmoDbc->getUInt32(i, 2);
ov.areaIDs[1] = wmoDbc->getUInt32(i, 3);
ov.areaIDs[2] = wmoDbc->getUInt32(i, 4);
ov.areaIDs[3] = wmoDbc->getUInt32(i, 5);
ov.textureName = wmoDbc->getString(i, 8);
ov.texWidth = static_cast<uint16_t>(wmoDbc->getUInt32(i, 9));
ov.texHeight = static_cast<uint16_t>(wmoDbc->getUInt32(i, 10));
ov.offsetX = static_cast<uint16_t>(wmoDbc->getUInt32(i, 11));
ov.offsetY = static_cast<uint16_t>(wmoDbc->getUInt32(i, 12));
// HitRect (fields 13-16): fast AABB pre-filter for subzone hover
ov.hitRectLeft = static_cast<uint16_t>(wmoDbc->getUInt32(i, 13));
ov.hitRectRight = static_cast<uint16_t>(wmoDbc->getUInt32(i, 14));
ov.hitRectTop = static_cast<uint16_t>(wmoDbc->getUInt32(i, 15));
ov.hitRectBottom = static_cast<uint16_t>(wmoDbc->getUInt32(i, 16));
if (ov.textureName.empty() || ov.texWidth == 0 || ov.texHeight == 0) continue;
ov.tileCols = (ov.texWidth + 255) / 256;
ov.tileRows = (ov.texHeight + 255) / 256;
zones_[it->second].overlays.push_back(std::move(ov));
totalOverlays++;
}
LOG_INFO("DataRepository: loaded ", totalOverlays, " overlay entries from WorldMapOverlay.dbc");
}
// Create a synthetic "Cosmic" zone for the cross-world map (Azeroth + Outland)
{
Zone cosmic;
cosmic.areaName = "Cosmic";
cosmicIdx_ = static_cast<int>(zones_.size());
zones_.push_back(std::move(cosmic));
LOG_INFO("DataRepository: added synthetic Cosmic zone at index ", cosmicIdx_);
}
// Create a synthetic "World" zone for the combined world overview map
{
Zone world;
world.areaName = "World";
worldIdx_ = static_cast<int>(zones_.size());
zones_.push_back(std::move(world));
LOG_INFO("DataRepository: added synthetic World zone at index ", worldIdx_);
}
// Load area POI data (towns, dungeons, etc.)
loadPOIs(assetManager);
// Build areaID → zone index lookup for ZMP resolution
for (int i = 0; i < static_cast<int>(zones_.size()); i++) {
if (zones_[i].areaID != 0)
areaIdToZoneIdx_[zones_[i].areaID] = i;
}
// Load ZMP pixel map for continent-level hover detection
loadZmpPixelMap(mapName, assetManager);
// Build views based on active expansion
buildCosmicView();
buildAzerothView();
}
int DataRepository::getExpansionLevel() {
if (game::isClassicLikeExpansion()) return 0;
if (game::isActiveExpansion("tbc")) return 1;
return 2; // WotLK and above
}
// --------------------------------------------------------
// ZMP pixel map loading
// --------------------------------------------------------
void DataRepository::loadZmpPixelMap(const std::string& continentName,
pipeline::AssetManager& assetManager) {
zmpGrid_.fill(0);
zmpLoaded_ = false;
// ZMP path: Interface\WorldMap\{name_lower}.zmp
std::string lower = continentName;
std::transform(lower.begin(), lower.end(), lower.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
std::string zmpPath = "Interface\\WorldMap\\" + lower + ".zmp";
auto data = assetManager.readFileOptional(zmpPath);
if (data.empty()) {
LOG_INFO("DataRepository: ZMP not found at '", zmpPath, "' (ok for non-continent maps)");
return;
}
// ZMP is a 128x128 grid of uint32 = 65536 bytes
constexpr size_t kExpectedSize = ZMP_SIZE * ZMP_SIZE * sizeof(uint32_t);
if (data.size() != kExpectedSize) {
LOG_WARNING("DataRepository: ZMP '", zmpPath, "' unexpected size ",
data.size(), " (expected ", kExpectedSize, ")");
return;
}
std::memcpy(zmpGrid_.data(), data.data(), kExpectedSize);
zmpLoaded_ = true;
// Count non-zero cells and find grid extent for diagnostic
int nonZero = 0;
int maxRow = -1, maxCol = -1;
for (int r = 0; r < ZMP_SIZE; r++) {
for (int c = 0; c < ZMP_SIZE; c++) {
if (zmpGrid_[r * ZMP_SIZE + c] != 0) {
nonZero++;
if (r > maxRow) maxRow = r;
if (c > maxCol) maxCol = c;
}
}
}
LOG_INFO("DataRepository: loaded ZMP '", zmpPath, "' — ",
nonZero, "/", ZMP_SIZE * ZMP_SIZE, " non-zero cells, "
"maxCol=", maxCol, " maxRow=", maxRow,
" (if ~125/~111 → maps to 1024x768 FBO, if ~127/~127 → maps to 1002x668 visible)");
// Derive zone bounding boxes from ZMP grid
buildZmpZoneBounds();
}
void DataRepository::buildZmpZoneBounds() {
zmpZoneBounds_.clear();
if (!zmpLoaded_) return;
// Scan the 128x128 ZMP grid and find the bounding box of each area ID's pixels.
// The ZMP grid maps directly to the visible 1002×668 content area, so
// (col/128, row/128) gives display UV without FBO conversion.
struct RawRect { int minCol = ZMP_SIZE, maxCol = -1, minRow = ZMP_SIZE, maxRow = -1; };
std::unordered_map<uint32_t, RawRect> areaRects;
for (int row = 0; row < ZMP_SIZE; row++) {
for (int col = 0; col < ZMP_SIZE; col++) {
uint32_t areaId = zmpGrid_[row * ZMP_SIZE + col];
if (areaId == 0) continue;
auto& r = areaRects[areaId];
r.minCol = std::min(r.minCol, col);
r.maxCol = std::max(r.maxCol, col);
r.minRow = std::min(r.minRow, row);
r.maxRow = std::max(r.maxRow, row);
}
}
// Map area ID bounding boxes → zone index bounding boxes.
// Multiple area IDs may resolve to the same zone, so union their rects.
constexpr float kInvSize = 1.0f / static_cast<float>(ZMP_SIZE);
int mapped = 0;
for (const auto& [areaId, rect] : areaRects) {
int zi = zoneIndexForAreaId(areaId);
if (zi < 0) continue;
float uMin = static_cast<float>(rect.minCol) * kInvSize;
float uMax = static_cast<float>(rect.maxCol + 1) * kInvSize;
float vMin = static_cast<float>(rect.minRow) * kInvSize;
float vMax = static_cast<float>(rect.maxRow + 1) * kInvSize;
auto it = zmpZoneBounds_.find(zi);
if (it != zmpZoneBounds_.end()) {
// Union with existing rect for this zone
it->second.uMin = std::min(it->second.uMin, uMin);
it->second.uMax = std::max(it->second.uMax, uMax);
it->second.vMin = std::min(it->second.vMin, vMin);
it->second.vMax = std::max(it->second.vMax, vMax);
} else {
ZmpRect zr;
zr.uMin = uMin; zr.uMax = uMax;
zr.vMin = vMin; zr.vMax = vMax;
zr.valid = true;
zmpZoneBounds_[zi] = zr;
mapped++;
}
}
LOG_INFO("DataRepository: built ZMP zone bounds for ", mapped, " zones from ",
areaRects.size(), " area IDs");
}
int DataRepository::zoneIndexForAreaId(uint32_t areaId) const {
if (areaId == 0) return -1;
auto it = areaIdToZoneIdx_.find(areaId);
if (it != areaIdToZoneIdx_.end()) return it->second;
// Fallback: check if areaId is a sub-zone whose parent is in our zone list.
// Some ZMP cells reference sub-area IDs not directly in WorldMapArea.dbc.
// Walk the AreaTable parent chain via exploreFlagByAreaId_ (which was built
// from AreaTable.dbc and includes parentArea relationships).
// For now, iterate zones looking for one whose overlays reference this areaId.
for (int i = 0; i < static_cast<int>(zones_.size()); i++) {
for (const auto& ov : zones_[i].overlays) {
for (int j = 0; j < 4; j++) {
if (ov.areaIDs[j] == areaId) return i;
}
}
}
return -1;
}
void DataRepository::loadPOIs(pipeline::AssetManager& assetManager) {
if (poisLoaded_) return;
poisLoaded_ = true;
auto poiDbc = assetManager.loadDBC("AreaPOI.dbc");
if (!poiDbc || !poiDbc->isLoaded()) {
LOG_INFO("DataRepository: AreaPOI.dbc not found, skipping POI markers");
return;
}
const uint32_t fieldCount = poiDbc->getFieldCount();
if (fieldCount < 17) {
LOG_WARNING("DataRepository: AreaPOI.dbc has too few fields (", fieldCount, ")");
return;
}
// AreaPOI.dbc field layout (WotLK 3.3.5a):
// 0:ID, 1:Importance, 2-10:Icon[9], 11:FactionID,
// 12:X, 13:Y, 14:Z, 15:MapID,
// 16:Name_lang (enUS), ...
int loaded = 0;
for (uint32_t i = 0; i < poiDbc->getRecordCount(); i++) {
POI poi;
poi.id = poiDbc->getUInt32(i, 0);
poi.importance = poiDbc->getUInt32(i, 1);
poi.iconType = poiDbc->getUInt32(i, 2);
poi.factionId = poiDbc->getUInt32(i, 11);
poi.wowX = poiDbc->getFloat(i, 12);
poi.wowY = poiDbc->getFloat(i, 13);
poi.wowZ = poiDbc->getFloat(i, 14);
poi.mapId = poiDbc->getUInt32(i, 15);
poi.name = poiDbc->getString(i, 16);
if (poi.name.empty()) continue;
poiMarkers_.push_back(std::move(poi));
loaded++;
}
// Sort by importance ascending so high-importance POIs are drawn last (on top)
std::sort(poiMarkers_.begin(), poiMarkers_.end(),
[](const POI& a, const POI& b) { return a.importance < b.importance; });
LOG_INFO("DataRepository: loaded ", loaded, " POI markers from AreaPOI.dbc");
}
void DataRepository::buildCosmicView(int /*expLevel*/) {
cosmicMaps_.clear();
if (game::isClassicLikeExpansion()) {
// Vanilla/Classic: No cosmic view — skip from WORLD straight to CONTINENT.
cosmicEnabled_ = false;
LOG_INFO("DataRepository: Classic mode — cosmic view disabled");
return;
}
cosmicEnabled_ = true;
// Azeroth (EK + Kalimdor) — always present; bottom-right region of cosmic map
cosmicMaps_.push_back({0, "Azeroth", 0.58f, 0.05f, 0.95f, 0.95f});
if (game::isActiveExpansion("tbc") || game::isActiveExpansion("wotlk")) {
// TBC+: Add Outland — top-left region of cosmic map
cosmicMaps_.push_back({530, "Outland", 0.05f, 0.10f, 0.55f, 0.90f});
}
LOG_INFO("DataRepository: cosmic view built with ", cosmicMaps_.size(), " landmasses");
}
void DataRepository::buildAzerothView(int /*expLevel*/) {
azerothRegions_.clear();
// Clickable continent regions on the Azeroth world map (azeroth1-12.blp).
// UV coordinates are approximate positions of each landmass on the combined map.
// Eastern Kingdoms — right side of the Azeroth map
azerothRegions_.push_back({0, mapDisplayName(0), 0.55f, 0.05f, 0.95f, 0.95f});
// Kalimdor — left side of the Azeroth map
azerothRegions_.push_back({1, mapDisplayName(1), 0.05f, 0.10f, 0.45f, 0.95f});
if (game::isActiveExpansion("wotlk")) {
// WotLK: Northrend — top-center of the Azeroth map
azerothRegions_.push_back({571, mapDisplayName(571), 0.30f, 0.0f, 0.72f, 0.28f});
}
LOG_INFO("DataRepository: Azeroth view built with ", azerothRegions_.size(), " continent regions");
}
} // namespace world_map
} // namespace rendering
} // namespace wowee

View file

@ -0,0 +1,119 @@
// exploration_state.cpp — Fog of war / exploration tracking implementation.
// Extracted from WorldMap::updateExploration, setServerExplorationMask
// (Phase 3 of refactoring plan).
#include "rendering/world_map/exploration_state.hpp"
#include "rendering/world_map/coordinate_projection.hpp"
#include <cmath>
namespace wowee {
namespace rendering {
namespace world_map {
void ExplorationState::setServerMask(const std::vector<uint32_t>& masks, bool hasData) {
if (!hasData || masks.empty()) {
// New session or no data yet — reset both server mask and local accumulation
if (hasServerMask_) {
locallyExploredZones_.clear();
}
hasServerMask_ = false;
serverMask_.clear();
return;
}
hasServerMask_ = true;
serverMask_ = masks;
}
bool ExplorationState::isBitSet(uint32_t bitIndex) const {
if (!hasServerMask_ || serverMask_.empty()) return false;
const size_t word = bitIndex / 32;
if (word >= serverMask_.size()) return false;
return (serverMask_[word] & (1u << (bitIndex % 32))) != 0;
}
void ExplorationState::update(const std::vector<Zone>& zones,
const glm::vec3& playerRenderPos,
int currentZoneIdx,
const std::unordered_map<uint32_t, uint32_t>& exploreFlagByAreaId) {
overlaysChanged_ = false;
if (hasServerMask_) {
exploredZones_.clear();
for (int i = 0; i < static_cast<int>(zones.size()); i++) {
const auto& z = zones[i];
if (z.areaID == 0 || z.exploreBits.empty()) continue;
for (uint32_t bit : z.exploreBits) {
if (isBitSet(bit)) {
exploredZones_.insert(i);
break;
}
}
}
// Also reveal the zone the player is currently standing in so the map isn't
// pitch-black the moment they first enter a new zone.
int curZone = findZoneForPlayer(zones, playerRenderPos);
if (curZone >= 0) exploredZones_.insert(curZone);
// Per-overlay exploration: check each overlay's areaIDs against the exploration mask
std::unordered_set<int> newExploredOverlays;
if (currentZoneIdx >= 0 && currentZoneIdx < static_cast<int>(zones.size())) {
const auto& curZoneData = zones[currentZoneIdx];
for (int oi = 0; oi < static_cast<int>(curZoneData.overlays.size()); oi++) {
const auto& ov = curZoneData.overlays[oi];
bool revealed = false;
for (int a = 0; a < 4; a++) {
if (ov.areaIDs[a] == 0) continue;
auto flagIt = exploreFlagByAreaId.find(ov.areaIDs[a]);
if (flagIt != exploreFlagByAreaId.end() && isBitSet(flagIt->second)) {
revealed = true;
break;
}
}
if (revealed) newExploredOverlays.insert(oi);
}
}
if (newExploredOverlays != exploredOverlays_) {
exploredOverlays_ = std::move(newExploredOverlays);
overlaysChanged_ = true;
}
return;
}
// Server mask unavailable — fall back to locally-accumulated position tracking.
float wowX = playerRenderPos.y;
float wowY = playerRenderPos.x;
bool foundPos = false;
for (int i = 0; i < static_cast<int>(zones.size()); i++) {
const auto& z = zones[i];
if (z.areaID == 0) continue;
float minX = std::min(z.bounds.locLeft, z.bounds.locRight);
float maxX = std::max(z.bounds.locLeft, z.bounds.locRight);
float minY = std::min(z.bounds.locTop, z.bounds.locBottom);
float maxY = std::max(z.bounds.locTop, z.bounds.locBottom);
if (maxX - minX < 0.001f || maxY - minY < 0.001f) continue;
if (wowX >= minX && wowX <= maxX && wowY >= minY && wowY <= maxY) {
locallyExploredZones_.insert(i);
foundPos = true;
}
}
if (!foundPos) {
int zoneIdx = findZoneForPlayer(zones, playerRenderPos);
if (zoneIdx >= 0) locallyExploredZones_.insert(zoneIdx);
}
// Display the accumulated local set
exploredZones_ = locallyExploredZones_;
// Without server mask, mark all overlays as explored (no fog of war)
exploredOverlays_.clear();
if (currentZoneIdx >= 0 && currentZoneIdx < static_cast<int>(zones.size())) {
for (int oi = 0; oi < static_cast<int>(zones[currentZoneIdx].overlays.size()); oi++)
exploredOverlays_.insert(oi);
}
}
} // namespace world_map
} // namespace rendering
} // namespace wowee

View file

@ -0,0 +1,57 @@
// input_handler.cpp — Input processing for the world map.
// Extracted from WorldMap::render (Phase 9 of refactoring plan).
#include "rendering/world_map/input_handler.hpp"
#include "core/input.hpp"
#include <imgui.h>
#include <cmath>
namespace wowee {
namespace rendering {
namespace world_map {
InputResult InputHandler::process(ViewLevel currentLevel,
int hoveredZoneIdx,
bool cosmicEnabled) {
InputResult result;
auto& input = core::Input::getInstance();
// ESC closes the map
if (input.isKeyJustPressed(SDL_SCANCODE_ESCAPE)) {
result.action = InputAction::CLOSE;
return result;
}
// Scroll wheel zoom
auto& io = ImGui::GetIO();
float wheelDelta = io.MouseWheel;
if (std::abs(wheelDelta) < 0.001f)
wheelDelta = input.getMouseWheelDelta();
if (wheelDelta > 0.0f) {
result.action = InputAction::ZOOM_IN;
return result;
} else if (wheelDelta < 0.0f) {
result.action = InputAction::ZOOM_OUT;
return result;
}
// Continent view: left-click on hovered zone (from previous frame)
if (currentLevel == ViewLevel::CONTINENT && hoveredZoneIdx >= 0 &&
input.isMouseButtonJustPressed(1)) {
result.action = InputAction::CLICK_ZONE;
result.targetIdx = hoveredZoneIdx;
return result;
}
// Right-click to go back (zone → continent; continent → world)
if (io.MouseClicked[1]) {
result.action = InputAction::RIGHT_CLICK_BACK;
return result;
}
return result;
}
} // namespace world_map
} // namespace rendering
} // namespace wowee

View file

@ -0,0 +1,53 @@
// coordinate_display.cpp — WoW coordinates under cursor on the world map.
// Extracted from WorldMap::renderImGuiOverlay (Phase 8 of refactoring plan).
#include "rendering/world_map/layers/coordinate_display.hpp"
#include "rendering/world_map/coordinate_projection.hpp"
#include <imgui.h>
#include <cstdio>
#include <cmath>
namespace wowee {
namespace rendering {
namespace world_map {
void CoordinateDisplay::render(const LayerContext& ctx) {
if (ctx.currentZoneIdx < 0) return;
if (ctx.viewLevel != ViewLevel::ZONE && ctx.viewLevel != ViewLevel::CONTINENT) return;
if (!ctx.zones) return;
auto& io = ImGui::GetIO();
ImVec2 mp = io.MousePos;
if (mp.x < ctx.imgMin.x || mp.x > ctx.imgMin.x + ctx.displayW ||
mp.y < ctx.imgMin.y || mp.y > ctx.imgMin.y + ctx.displayH)
return;
float mu = (mp.x - ctx.imgMin.x) / ctx.displayW;
float mv = (mp.y - ctx.imgMin.y) / ctx.displayH;
const auto& zone = (*ctx.zones)[ctx.currentZoneIdx];
float left = zone.bounds.locLeft, right = zone.bounds.locRight;
float top = zone.bounds.locTop, bottom = zone.bounds.locBottom;
if (zone.areaID == 0) {
float l, r, t, b;
getContinentProjectionBounds(*ctx.zones, ctx.currentZoneIdx, l, r, t, b);
left = l; right = r; top = t; bottom = b;
// Undo the kVOffset applied during renderPosToMapUV for continent
constexpr float kVOffset = -0.15f;
mv -= kVOffset;
}
float hWowX = left - mu * (left - right);
float hWowY = top - mv * (top - bottom);
char coordBuf[32];
snprintf(coordBuf, sizeof(coordBuf), "%.0f, %.0f", hWowX, hWowY);
ImVec2 coordSz = ImGui::CalcTextSize(coordBuf);
float cx = ctx.imgMin.x + ctx.displayW - coordSz.x - 8.0f;
float cy = ctx.imgMin.y + ctx.displayH - coordSz.y - 8.0f;
ctx.drawList->AddText(ImVec2(cx + 1.0f, cy + 1.0f), IM_COL32(0, 0, 0, 180), coordBuf);
ctx.drawList->AddText(ImVec2(cx, cy), IM_COL32(220, 210, 150, 230), coordBuf);
}
} // namespace world_map
} // namespace rendering
} // namespace wowee

View file

@ -0,0 +1,54 @@
// corpse_marker_layer.cpp — Death corpse X marker on the world map.
// Extracted from WorldMap::renderImGuiOverlay (Phase 8 of refactoring plan).
#include "rendering/world_map/layers/corpse_marker_layer.hpp"
#include "rendering/world_map/coordinate_projection.hpp"
#include <imgui.h>
namespace wowee {
namespace rendering {
namespace world_map {
void CorpseMarkerLayer::render(const LayerContext& ctx) {
if (!hasCorpse_) return;
if (ctx.currentZoneIdx < 0) return;
if (ctx.viewLevel != ViewLevel::ZONE && ctx.viewLevel != ViewLevel::CONTINENT) return;
if (!ctx.zones) return;
const auto& zone = (*ctx.zones)[ctx.currentZoneIdx];
ZoneBounds bounds = zone.bounds;
bool isContinent = zone.areaID == 0;
if (isContinent) {
float l, r, t, b;
if (getContinentProjectionBounds(*ctx.zones, ctx.currentZoneIdx, l, r, t, b)) {
bounds = {l, r, t, b};
}
}
glm::vec2 uv = renderPosToMapUV(corpseRenderPos_, bounds, isContinent);
if (uv.x < 0.0f || uv.x > 1.0f || uv.y < 0.0f || uv.y > 1.0f) return;
float cx = ctx.imgMin.x + uv.x * ctx.displayW;
float cy = ctx.imgMin.y + uv.y * ctx.displayH;
constexpr float R = 5.0f;
constexpr float T = 1.8f;
// Dark outline
ctx.drawList->AddLine(ImVec2(cx - R, cy - R), ImVec2(cx + R, cy + R),
IM_COL32(0, 0, 0, 220), T + 1.5f);
ctx.drawList->AddLine(ImVec2(cx + R, cy - R), ImVec2(cx - R, cy + R),
IM_COL32(0, 0, 0, 220), T + 1.5f);
// Bone-white X
ctx.drawList->AddLine(ImVec2(cx - R, cy - R), ImVec2(cx + R, cy + R),
IM_COL32(230, 220, 200, 240), T);
ctx.drawList->AddLine(ImVec2(cx + R, cy - R), ImVec2(cx - R, cy + R),
IM_COL32(230, 220, 200, 240), T);
// Tooltip on hover
ImVec2 mp = ImGui::GetMousePos();
float dx = mp.x - cx, dy = mp.y - cy;
if (dx * dx + dy * dy < 64.0f) {
ImGui::SetTooltip("Your corpse");
}
}
} // namespace world_map
} // namespace rendering
} // namespace wowee

View file

@ -0,0 +1,52 @@
// party_dot_layer.cpp — Party member position dots on the world map.
// Extracted from WorldMap::renderImGuiOverlay (Phase 8 of refactoring plan).
#include "rendering/world_map/layers/party_dot_layer.hpp"
#include "rendering/world_map/coordinate_projection.hpp"
#include <imgui.h>
namespace wowee {
namespace rendering {
namespace world_map {
void PartyDotLayer::render(const LayerContext& ctx) {
if (!dots_ || dots_->empty()) return;
if (ctx.currentZoneIdx < 0) return;
if (ctx.viewLevel != ViewLevel::ZONE && ctx.viewLevel != ViewLevel::CONTINENT) return;
if (!ctx.zones) return;
const auto& zone = (*ctx.zones)[ctx.currentZoneIdx];
ZoneBounds bounds = zone.bounds;
bool isContinent = zone.areaID == 0;
if (isContinent) {
float l, r, t, b;
if (getContinentProjectionBounds(*ctx.zones, ctx.currentZoneIdx, l, r, t, b)) {
bounds = {l, r, t, b};
}
}
ImFont* font = ImGui::GetFont();
for (const auto& dot : *dots_) {
glm::vec2 uv = renderPosToMapUV(dot.renderPos, bounds, isContinent);
if (uv.x < 0.0f || uv.x > 1.0f || uv.y < 0.0f || uv.y > 1.0f) continue;
float px = ctx.imgMin.x + uv.x * ctx.displayW;
float py = ctx.imgMin.y + uv.y * ctx.displayH;
ctx.drawList->AddCircleFilled(ImVec2(px, py), 5.0f, dot.color);
ctx.drawList->AddCircle(ImVec2(px, py), 5.0f, IM_COL32(0, 0, 0, 200), 0, 1.5f);
if (!dot.name.empty()) {
ImVec2 mp = ImGui::GetMousePos();
float dx = mp.x - px, dy = mp.y - py;
if (dx * dx + dy * dy <= 49.0f) {
ImGui::SetTooltip("%s", dot.name.c_str());
}
ImVec2 nameSz = font->CalcTextSizeA(ImGui::GetFontSize(), FLT_MAX, 0.0f, dot.name.c_str());
float tx = px - nameSz.x * 0.5f;
float ty = py - nameSz.y - 7.0f;
ctx.drawList->AddText(ImVec2(tx + 1.0f, ty + 1.0f), IM_COL32(0, 0, 0, 180), dot.name.c_str());
ctx.drawList->AddText(ImVec2(tx, ty), IM_COL32(255, 255, 255, 220), dot.name.c_str());
}
}
}
} // namespace world_map
} // namespace rendering
} // namespace wowee

View file

@ -0,0 +1,57 @@
// player_marker_layer.cpp — Directional player arrow on the world map.
// Extracted from WorldMap::renderImGuiOverlay (Phase 8 of refactoring plan).
#include "rendering/world_map/layers/player_marker_layer.hpp"
#include "rendering/world_map/coordinate_projection.hpp"
#include <imgui.h>
#include <cmath>
namespace wowee {
namespace rendering {
namespace world_map {
void PlayerMarkerLayer::render(const LayerContext& ctx) {
if (ctx.currentZoneIdx < 0) return;
if (ctx.viewLevel != ViewLevel::ZONE && ctx.viewLevel != ViewLevel::CONTINENT) return;
if (!ctx.zones) return;
const auto& zone = (*ctx.zones)[ctx.currentZoneIdx];
ZoneBounds bounds = zone.bounds;
bool isContinent = zone.areaID == 0;
// In continent view, only show the player marker if they are actually
// in a zone belonging to this continent (don't bleed across continents).
if (isContinent) {
int playerZone = findZoneForPlayer(*ctx.zones, ctx.playerRenderPos);
if (playerZone < 0 || !zoneBelongsToContinent(*ctx.zones, playerZone, ctx.currentZoneIdx))
return;
float l, r, t, b;
if (getContinentProjectionBounds(*ctx.zones, ctx.currentZoneIdx, l, r, t, b)) {
bounds = {l, r, t, b};
}
}
glm::vec2 playerUV = renderPosToMapUV(ctx.playerRenderPos, bounds, isContinent);
if (playerUV.x < 0.0f || playerUV.x > 1.0f ||
playerUV.y < 0.0f || playerUV.y > 1.0f) return;
float px = ctx.imgMin.x + playerUV.x * ctx.displayW;
float py = ctx.imgMin.y + playerUV.y * ctx.displayH;
// Directional arrow: render-space (cos,sin) maps to screen (-dx,-dy)
float yawRad = glm::radians(ctx.playerYawDeg);
float adx = -std::cos(yawRad);
float ady = -std::sin(yawRad);
float apx = -ady, apy = adx;
constexpr float TIP = 9.0f;
constexpr float TAIL = 4.0f;
constexpr float HALF = 5.0f;
ImVec2 tip(px + adx * TIP, py + ady * TIP);
ImVec2 bl (px - adx * TAIL + apx * HALF, py - ady * TAIL + apy * HALF);
ImVec2 br (px - adx * TAIL - apx * HALF, py - ady * TAIL - apy * HALF);
ctx.drawList->AddTriangleFilled(tip, bl, br, IM_COL32(255, 40, 40, 255));
ctx.drawList->AddTriangle(tip, bl, br, IM_COL32(0, 0, 0, 200), 1.5f);
}
} // namespace world_map
} // namespace rendering
} // namespace wowee

View file

@ -0,0 +1,102 @@
// poi_marker_layer.cpp — Town/dungeon/capital POI icons on the world map.
// Extracted from WorldMap::renderPOIMarkers (Phase 8 of refactoring plan).
#include "rendering/world_map/layers/poi_marker_layer.hpp"
#include "rendering/world_map/coordinate_projection.hpp"
#include "core/coordinates.hpp"
#include <imgui.h>
namespace wowee {
namespace rendering {
namespace world_map {
void POIMarkerLayer::render(const LayerContext& ctx) {
if (!markers_ || markers_->empty()) return;
if (ctx.currentZoneIdx < 0) return;
if (ctx.viewLevel != ViewLevel::ZONE && ctx.viewLevel != ViewLevel::CONTINENT) return;
if (!ctx.zones) return;
const auto& zone = (*ctx.zones)[ctx.currentZoneIdx];
ZoneBounds bounds = zone.bounds;
bool isContinent = zone.areaID == 0;
if (isContinent) {
float l, r, t, b;
if (getContinentProjectionBounds(*ctx.zones, ctx.currentZoneIdx, l, r, t, b)) {
bounds = {l, r, t, b};
}
}
ImVec2 mp = ImGui::GetMousePos();
ImFont* font = ImGui::GetFont();
for (const auto& poi : *markers_) {
if (static_cast<int>(poi.mapId) != ctx.currentMapId) continue;
glm::vec3 rPos = core::coords::canonicalToRender(
glm::vec3(poi.wowX, poi.wowY, poi.wowZ));
glm::vec2 uv = renderPosToMapUV(rPos, bounds, isContinent);
if (uv.x < 0.0f || uv.x > 1.0f || uv.y < 0.0f || uv.y > 1.0f) continue;
float px = ctx.imgMin.x + uv.x * ctx.displayW;
float py = ctx.imgMin.y + uv.y * ctx.displayH;
float iconSize = (poi.importance >= 2) ? 7.0f :
(poi.importance >= 1) ? 5.0f : 3.0f;
ImU32 fillColor, borderColor;
if (poi.factionId == 469) {
fillColor = IM_COL32(60, 120, 255, 200);
borderColor = IM_COL32(20, 60, 180, 220);
} else if (poi.factionId == 67) {
fillColor = IM_COL32(255, 60, 60, 200);
borderColor = IM_COL32(180, 20, 20, 220);
} else {
fillColor = IM_COL32(255, 215, 0, 200);
borderColor = IM_COL32(180, 150, 0, 220);
}
if (poi.importance >= 2) {
ctx.drawList->AddCircleFilled(ImVec2(px, py), iconSize + 2.0f,
IM_COL32(255, 255, 200, 30));
ctx.drawList->AddCircleFilled(ImVec2(px, py), iconSize, fillColor);
ctx.drawList->AddCircle(ImVec2(px, py), iconSize, borderColor, 0, 2.0f);
} else if (poi.importance >= 1) {
float H = iconSize;
ImVec2 top2(px, py - H);
ImVec2 right2(px + H, py );
ImVec2 bot2(px, py + H);
ImVec2 left2(px - H, py );
ctx.drawList->AddQuadFilled(top2, right2, bot2, left2, fillColor);
ctx.drawList->AddQuad(top2, right2, bot2, left2, borderColor, 1.2f);
} else {
ctx.drawList->AddCircleFilled(ImVec2(px, py), iconSize, fillColor);
ctx.drawList->AddCircle(ImVec2(px, py), iconSize, borderColor, 0, 1.0f);
}
if (poi.importance >= 1 && ctx.viewLevel == ViewLevel::ZONE && !poi.name.empty()) {
float fontSize = (poi.importance >= 2) ? ImGui::GetFontSize() * 0.85f :
ImGui::GetFontSize() * 0.75f;
ImVec2 nameSz = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, poi.name.c_str());
float tx = px - nameSz.x * 0.5f;
float ty = py + iconSize + 2.0f;
ctx.drawList->AddText(font, fontSize,
ImVec2(tx + 1.0f, ty + 1.0f),
IM_COL32(0, 0, 0, 180), poi.name.c_str());
ctx.drawList->AddText(font, fontSize,
ImVec2(tx, ty), IM_COL32(255, 255, 255, 210),
poi.name.c_str());
}
float dx = mp.x - px, dy = mp.y - py;
float hitRadius = iconSize + 4.0f;
if (dx * dx + dy * dy < hitRadius * hitRadius && !poi.name.empty()) {
if (!poi.description.empty())
ImGui::SetTooltip("%s\n%s", poi.name.c_str(), poi.description.c_str());
else
ImGui::SetTooltip("%s", poi.name.c_str());
}
}
}
} // namespace world_map
} // namespace rendering
} // namespace wowee

View file

@ -0,0 +1,60 @@
// quest_poi_layer.cpp — Quest objective markers on the world map.
// Extracted from WorldMap::renderImGuiOverlay (Phase 8 of refactoring plan).
#include "rendering/world_map/layers/quest_poi_layer.hpp"
#include "rendering/world_map/coordinate_projection.hpp"
#include "core/coordinates.hpp"
#include <imgui.h>
namespace wowee {
namespace rendering {
namespace world_map {
void QuestPOILayer::render(const LayerContext& ctx) {
if (!pois_ || pois_->empty()) return;
if (ctx.currentZoneIdx < 0) return;
if (ctx.viewLevel != ViewLevel::ZONE && ctx.viewLevel != ViewLevel::CONTINENT) return;
if (!ctx.zones) return;
const auto& zone = (*ctx.zones)[ctx.currentZoneIdx];
ZoneBounds bounds = zone.bounds;
bool isContinent = zone.areaID == 0;
if (isContinent) {
float l, r, t, b;
if (getContinentProjectionBounds(*ctx.zones, ctx.currentZoneIdx, l, r, t, b)) {
bounds = {l, r, t, b};
}
}
ImVec2 mp = ImGui::GetMousePos();
ImFont* qFont = ImGui::GetFont();
for (const auto& qp : *pois_) {
glm::vec3 rPos = core::coords::canonicalToRender(
glm::vec3(qp.wowX, qp.wowY, 0.0f));
glm::vec2 uv = renderPosToMapUV(rPos, bounds, isContinent);
if (uv.x < 0.0f || uv.x > 1.0f || uv.y < 0.0f || uv.y > 1.0f) continue;
float px = ctx.imgMin.x + uv.x * ctx.displayW;
float py = ctx.imgMin.y + uv.y * ctx.displayH;
ctx.drawList->AddCircleFilled(ImVec2(px, py), 5.0f, IM_COL32(0, 210, 255, 220));
ctx.drawList->AddCircle(ImVec2(px, py), 5.0f, IM_COL32(255, 215, 0, 220), 0, 1.5f);
if (!qp.name.empty()) {
ImVec2 nameSz = qFont->CalcTextSizeA(ImGui::GetFontSize() * 0.85f, FLT_MAX, 0.0f, qp.name.c_str());
float tx = px - nameSz.x * 0.5f;
float ty = py - nameSz.y - 7.0f;
ctx.drawList->AddText(qFont, ImGui::GetFontSize() * 0.85f,
ImVec2(tx + 1.0f, ty + 1.0f), IM_COL32(0, 0, 0, 180), qp.name.c_str());
ctx.drawList->AddText(qFont, ImGui::GetFontSize() * 0.85f,
ImVec2(tx, ty), IM_COL32(255, 230, 100, 230), qp.name.c_str());
}
float mdx = mp.x - px, mdy = mp.y - py;
if (mdx * mdx + mdy * mdy < 49.0f && !qp.name.empty()) {
ImGui::SetTooltip("%s\n(Quest Objective)", qp.name.c_str());
}
}
}
} // namespace world_map
} // namespace rendering
} // namespace wowee

View file

@ -0,0 +1,100 @@
// subzone_tooltip_layer.cpp — Overlay area hover labels in zone view.
// Extracted from WorldMap::renderImGuiOverlay (Phase 8 of refactoring plan).
#include "rendering/world_map/layers/subzone_tooltip_layer.hpp"
#include <imgui.h>
#include <limits>
namespace wowee {
namespace rendering {
namespace world_map {
void SubzoneTooltipLayer::render(const LayerContext& ctx) {
if (ctx.viewLevel != ViewLevel::ZONE) return;
if (ctx.currentZoneIdx < 0 || !ctx.zones) return;
ImVec2 mp = ImGui::GetIO().MousePos;
if (mp.x < ctx.imgMin.x || mp.x > ctx.imgMin.x + ctx.displayW ||
mp.y < ctx.imgMin.y || mp.y > ctx.imgMin.y + ctx.displayH)
return;
float mu = (mp.x - ctx.imgMin.x) / ctx.displayW;
float mv = (mp.y - ctx.imgMin.y) / ctx.displayH;
const auto& zone = (*ctx.zones)[ctx.currentZoneIdx];
std::string hoveredName;
bool hoveredExplored = false;
float bestArea = std::numeric_limits<float>::max();
float fboW = static_cast<float>(ctx.fboW);
float fboH = static_cast<float>(ctx.fboH);
// Mouse position in FBO pixel coordinates (used for HitRect AABB test)
float pixelX = mu * fboW;
float pixelY = mv * fboH;
for (int oi = 0; oi < static_cast<int>(zone.overlays.size()); oi++) {
const auto& ov = zone.overlays[oi];
// ── Hybrid Approach: Zone view uses HitRect AABB pre-filter ──
// WorldMapOverlay.dbc fields 13-16 define a hit-test rectangle.
// Only overlays whose HitRect contains the mouse need further testing.
// This is Blizzard's optimization to avoid sampling every overlay.
bool hasHitRect = (ov.hitRectRight > ov.hitRectLeft &&
ov.hitRectBottom > ov.hitRectTop);
if (hasHitRect) {
if (pixelX < static_cast<float>(ov.hitRectLeft) ||
pixelX > static_cast<float>(ov.hitRectRight) ||
pixelY < static_cast<float>(ov.hitRectTop) ||
pixelY > static_cast<float>(ov.hitRectBottom)) {
continue; // Mouse outside HitRect — skip this overlay
}
} else {
// Fallback: use overlay offset+size AABB (old behaviour)
float ovLeft = static_cast<float>(ov.offsetX) / fboW;
float ovTop = static_cast<float>(ov.offsetY) / fboH;
float ovRight = static_cast<float>(ov.offsetX + ov.texWidth) / fboW;
float ovBottom = static_cast<float>(ov.offsetY + ov.texHeight) / fboH;
if (mu < ovLeft || mu > ovRight || mv < ovTop || mv > ovBottom)
continue;
}
float area = static_cast<float>(ov.texWidth) * static_cast<float>(ov.texHeight);
if (area < bestArea) {
bestArea = area;
// Find display name from the first valid area ID
for (int a = 0; a < 4; a++) {
if (ov.areaIDs[a] == 0) continue;
if (ctx.areaNameByAreaId) {
auto nameIt = ctx.areaNameByAreaId->find(ov.areaIDs[a]);
if (nameIt != ctx.areaNameByAreaId->end()) {
hoveredName = nameIt->second;
break;
}
}
}
hoveredExplored = ctx.exploredOverlays &&
ctx.exploredOverlays->count(oi) > 0;
}
}
if (!hoveredName.empty()) {
std::string label = hoveredName;
if (!hoveredExplored)
label += " (Unexplored)";
ImVec2 labelSz = ImGui::CalcTextSize(label.c_str());
float lx = ctx.imgMin.x + (ctx.displayW - labelSz.x) * 0.5f;
float ly = ctx.imgMin.y + 6.0f;
ImU32 labelCol = hoveredExplored
? IM_COL32(255, 230, 150, 240)
: IM_COL32(160, 160, 160, 200);
ctx.drawList->AddText(ImVec2(lx + 1.0f, ly + 1.0f),
IM_COL32(0, 0, 0, 200), label.c_str());
ctx.drawList->AddText(ImVec2(lx, ly), labelCol, label.c_str());
}
}
} // namespace world_map
} // namespace rendering
} // namespace wowee

View file

@ -0,0 +1,62 @@
// taxi_node_layer.cpp — Flight master diamond icons on the world map.
// Extracted from WorldMap::renderImGuiOverlay (Phase 8 of refactoring plan).
#include "rendering/world_map/layers/taxi_node_layer.hpp"
#include "rendering/world_map/coordinate_projection.hpp"
#include "core/coordinates.hpp"
#include <imgui.h>
namespace wowee {
namespace rendering {
namespace world_map {
void TaxiNodeLayer::render(const LayerContext& ctx) {
if (!nodes_ || nodes_->empty()) return;
if (ctx.currentZoneIdx < 0) return;
if (ctx.viewLevel != ViewLevel::ZONE && ctx.viewLevel != ViewLevel::CONTINENT) return;
if (!ctx.zones) return;
const auto& zone = (*ctx.zones)[ctx.currentZoneIdx];
ZoneBounds bounds = zone.bounds;
bool isContinent = zone.areaID == 0;
if (isContinent) {
float l, r, t, b;
if (getContinentProjectionBounds(*ctx.zones, ctx.currentZoneIdx, l, r, t, b)) {
bounds = {l, r, t, b};
}
}
ImVec2 mp = ImGui::GetMousePos();
for (const auto& node : *nodes_) {
if (!node.known) continue;
if (static_cast<int>(node.mapId) != ctx.currentMapId) continue;
glm::vec3 rPos = core::coords::canonicalToRender(
glm::vec3(node.wowX, node.wowY, node.wowZ));
glm::vec2 uv = renderPosToMapUV(rPos, bounds, isContinent);
if (uv.x < 0.0f || uv.x > 1.0f || uv.y < 0.0f || uv.y > 1.0f) continue;
float px = ctx.imgMin.x + uv.x * ctx.displayW;
float py = ctx.imgMin.y + uv.y * ctx.displayH;
constexpr float H = 5.0f;
ImVec2 top2(px, py - H);
ImVec2 right2(px + H, py );
ImVec2 bot2(px, py + H);
ImVec2 left2(px - H, py );
ctx.drawList->AddQuadFilled(top2, right2, bot2, left2,
IM_COL32(255, 215, 0, 230));
ctx.drawList->AddQuad(top2, right2, bot2, left2,
IM_COL32(80, 50, 0, 200), 1.2f);
if (!node.name.empty()) {
float mdx = mp.x - px, mdy = mp.y - py;
if (mdx * mdx + mdy * mdy < 49.0f) {
ImGui::SetTooltip("%s\n(Flight Master)", node.name.c_str());
}
}
}
}
} // namespace world_map
} // namespace rendering
} // namespace wowee

View file

@ -0,0 +1,294 @@
// zone_highlight_layer.cpp — Continent view zone rectangles + hover effects.
// Extracted from WorldMap::renderZoneHighlights (Phase 8 of refactoring plan).
#include "rendering/world_map/layers/zone_highlight_layer.hpp"
#include "rendering/world_map/coordinate_projection.hpp"
#include "rendering/vk_texture.hpp"
#include "rendering/vk_context.hpp"
#include "pipeline/asset_manager.hpp"
#include "core/logger.hpp"
#include <imgui.h>
#include <backends/imgui_impl_vulkan.h>
#include <algorithm>
#include <cmath>
namespace wowee {
namespace rendering {
namespace world_map {
ZoneHighlightLayer::~ZoneHighlightLayer() {
// At shutdown vkDeviceWaitIdle has been called, so immediate cleanup is safe.
if (vkCtx_) {
VkDevice device = vkCtx_->getDevice();
VmaAllocator alloc = vkCtx_->getAllocator();
for (auto& [name, entry] : highlights_) {
if (entry.imguiDS) ImGui_ImplVulkan_RemoveTexture(entry.imguiDS);
if (entry.texture) entry.texture->destroy(device, alloc);
}
}
highlights_.clear();
missingHighlights_.clear();
}
void ZoneHighlightLayer::initialize(VkContext* ctx, pipeline::AssetManager* am) {
vkCtx_ = ctx;
assetManager_ = am;
}
void ZoneHighlightLayer::clearTextures() {
if (vkCtx_ && !highlights_.empty()) {
// Defer destruction until all in-flight frames complete.
// The previous frame's command buffer may still reference these ImGui
// descriptor sets and texture image views from highlight draw commands.
VkDevice device = vkCtx_->getDevice();
VmaAllocator alloc = vkCtx_->getAllocator();
struct DeferredHighlight {
std::unique_ptr<VkTexture> texture;
VkDescriptorSet imguiDS;
};
auto captured = std::make_shared<std::vector<DeferredHighlight>>();
for (auto& [name, entry] : highlights_) {
DeferredHighlight dh;
dh.texture = std::move(entry.texture);
dh.imguiDS = entry.imguiDS;
captured->push_back(std::move(dh));
}
vkCtx_->deferAfterAllFrameFences([device, alloc, captured]() {
for (auto& dh : *captured) {
if (dh.imguiDS) ImGui_ImplVulkan_RemoveTexture(dh.imguiDS);
if (dh.texture) dh.texture->destroy(device, alloc);
}
});
}
highlights_.clear();
missingHighlights_.clear();
}
void ZoneHighlightLayer::ensureHighlight(const std::string& key,
const std::string& customPath) {
if (!vkCtx_ || !assetManager_) return;
if (key.empty()) return;
if (highlights_.count(key) || missingHighlights_.count(key)) return;
// Determine BLP path
std::string path;
if (!customPath.empty()) {
path = customPath;
} else {
std::string lower = key;
std::transform(lower.begin(), lower.end(), lower.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
path = "Interface\\WorldMap\\" + key + "\\" + lower + "highlight.blp";
}
auto blpImage = assetManager_->loadTexture(path);
if (!blpImage.isValid()) {
LOG_WARNING("ZoneHighlightLayer: highlight not found for key='", key, "' path='", path, "'");
missingHighlights_.insert(key);
return;
}
LOG_INFO("ZoneHighlightLayer: loaded highlight key='", key, "' path='", path,
"' ", blpImage.width, "x", blpImage.height, " dataSize=", blpImage.data.size());
// WoW highlight BLPs with alphaDepth=0 use additive blending (white=glow, black=invisible).
// Convert to alpha-blend compatible: set alpha = max(R,G,B) for fully opaque textures.
{
bool allOpaque = true;
for (size_t i = 3; i < blpImage.data.size(); i += 4) {
if (blpImage.data[i] < 255) { allOpaque = false; break; }
}
if (allOpaque) {
for (size_t i = 0; i < blpImage.data.size(); i += 4) {
uint8_t r = blpImage.data[i], g = blpImage.data[i + 1], b = blpImage.data[i + 2];
blpImage.data[i + 3] = std::max({r, g, b});
}
}
}
VkDevice device = vkCtx_->getDevice();
auto tex = std::make_unique<VkTexture>();
if (!tex->upload(*vkCtx_, blpImage.data.data(), blpImage.width, blpImage.height,
VK_FORMAT_R8G8B8A8_UNORM, false)) {
missingHighlights_.insert(key);
return;
}
if (!tex->createSampler(device, VK_FILTER_LINEAR, VK_FILTER_LINEAR,
VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE, 1.0f)) {
tex->destroy(device, vkCtx_->getAllocator());
missingHighlights_.insert(key);
return;
}
VkDescriptorSet ds = ImGui_ImplVulkan_AddTexture(
tex->getSampler(), tex->getImageView(),
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
if (!ds) {
tex->destroy(device, vkCtx_->getAllocator());
missingHighlights_.insert(key);
return;
}
HighlightEntry entry;
entry.texture = std::move(tex);
entry.imguiDS = ds;
highlights_[key] = std::move(entry);
}
ImTextureID ZoneHighlightLayer::getHighlightTexture(const std::string& key,
const std::string& customPath) {
ensureHighlight(key, customPath);
auto it = highlights_.find(key);
if (it != highlights_.end() && it->second.imguiDS) {
return reinterpret_cast<ImTextureID>(it->second.imguiDS);
}
return 0;
}
void ZoneHighlightLayer::render(const LayerContext& ctx) {
if (ctx.viewLevel != ViewLevel::CONTINENT || ctx.continentIdx < 0) return;
if (!ctx.zones) return;
const auto& cont = (*ctx.zones)[ctx.continentIdx];
float cLeft = cont.bounds.locLeft, cRight = cont.bounds.locRight;
float cTop = cont.bounds.locTop, cBottom = cont.bounds.locBottom;
getContinentProjectionBounds(*ctx.zones, ctx.continentIdx, cLeft, cRight, cTop, cBottom);
float cDenomU = cLeft - cRight;
float cDenomV = cTop - cBottom;
if (std::abs(cDenomU) < 0.001f || std::abs(cDenomV) < 0.001f) return;
hoveredZone_ = -1;
ImVec2 mousePos = ImGui::GetMousePos();
// ── Render zone rectangles using DBC world-coord AABB projection ──
// (Restored from old WorldMap::renderImGuiOverlay — no ZMP dependency)
for (int zi = 0; zi < static_cast<int>(ctx.zones->size()); zi++) {
if (!zoneBelongsToContinent(*ctx.zones, zi, ctx.continentIdx)) continue;
const auto& z = (*ctx.zones)[zi];
if (std::abs(z.bounds.locLeft - z.bounds.locRight) < 0.001f ||
std::abs(z.bounds.locTop - z.bounds.locBottom) < 0.001f) continue;
// Project from WorldMapArea.dbc world coords
float zuMin = (cLeft - z.bounds.locLeft) / cDenomU;
float zuMax = (cLeft - z.bounds.locRight) / cDenomU;
float zvMin = (cTop - z.bounds.locTop) / cDenomV;
float zvMax = (cTop - z.bounds.locBottom) / cDenomV;
constexpr float kOverlayShrink = 0.92f;
float cu = (zuMin + zuMax) * 0.5f, cv = (zvMin + zvMax) * 0.5f;
float hu = (zuMax - zuMin) * 0.5f * kOverlayShrink;
float hv = (zvMax - zvMin) * 0.5f * kOverlayShrink;
zuMin = cu - hu; zuMax = cu + hu;
zvMin = cv - hv; zvMax = cv + hv;
constexpr float kVOffset = -0.15f;
zvMin = (zvMin - 0.5f) + 0.5f + kVOffset;
zvMax = (zvMax - 0.5f) + 0.5f + kVOffset;
zuMin = std::clamp(zuMin, 0.0f, 1.0f);
zuMax = std::clamp(zuMax, 0.0f, 1.0f);
zvMin = std::clamp(zvMin, 0.0f, 1.0f);
zvMax = std::clamp(zvMax, 0.0f, 1.0f);
if (zuMax - zuMin < 0.001f || zvMax - zvMin < 0.001f) continue;
float sx0 = ctx.imgMin.x + zuMin * ctx.displayW;
float sy0 = ctx.imgMin.y + zvMin * ctx.displayH;
float sx1 = ctx.imgMin.x + zuMax * ctx.displayW;
float sy1 = ctx.imgMin.y + zvMax * ctx.displayH;
bool explored = !ctx.exploredZones ||
ctx.exploredZones->empty() ||
ctx.exploredZones->count(zi) > 0;
bool hovered = (mousePos.x >= sx0 && mousePos.x <= sx1 &&
mousePos.y >= sy0 && mousePos.y <= sy1);
if (hovered) {
hoveredZone_ = zi;
if (prevHoveredZone_ == zi) {
hoverHighlightAlpha_ = std::min(hoverHighlightAlpha_ + 0.08f, 1.0f);
} else {
hoverHighlightAlpha_ = 0.3f;
}
// Draw the highlight BLP texture within the zone's bounding rectangle.
auto it = highlights_.find(z.areaName);
if (it == highlights_.end()) ensureHighlight(z.areaName, "");
it = highlights_.find(z.areaName);
if (it != highlights_.end() && it->second.imguiDS) {
uint8_t imgAlpha = static_cast<uint8_t>(255.0f * hoverHighlightAlpha_);
// Draw twice for a very bright glow effect
ctx.drawList->AddImage(
reinterpret_cast<ImTextureID>(it->second.imguiDS),
ImVec2(sx0, sy0), ImVec2(sx1, sy1),
ImVec2(0, 0), ImVec2(1, 1),
IM_COL32(255, 255, 255, imgAlpha));
ctx.drawList->AddImage(
reinterpret_cast<ImTextureID>(it->second.imguiDS),
ImVec2(sx0, sy0), ImVec2(sx1, sy1),
ImVec2(0, 0), ImVec2(1, 1),
IM_COL32(255, 255, 200, imgAlpha));
} else {
// Fallback: bright colored rectangle if no highlight texture
uint8_t fillAlpha = static_cast<uint8_t>(100.0f * hoverHighlightAlpha_);
ctx.drawList->AddRectFilled(ImVec2(sx0, sy0), ImVec2(sx1, sy1),
IM_COL32(255, 235, 50, fillAlpha));
}
uint8_t borderAlpha = static_cast<uint8_t>(200.0f * hoverHighlightAlpha_);
ctx.drawList->AddRect(ImVec2(sx0, sy0), ImVec2(sx1, sy1),
IM_COL32(255, 225, 50, borderAlpha), 0, 0, 2.0f);
} else if (explored) {
ctx.drawList->AddRect(ImVec2(sx0, sy0), ImVec2(sx1, sy1),
IM_COL32(255, 255, 255, 30), 0.0f, 0, 1.0f);
}
// Zone name label
bool zoneExplored = explored;
if (!z.areaName.empty()) {
const ZoneMeta* meta = metadata_ ? metadata_->find(z.areaName) : nullptr;
std::string label = ZoneMetadata::formatLabel(z.areaName, meta);
ImFont* font = ImGui::GetFont();
float fontSize = ImGui::GetFontSize() * 0.75f;
ImVec2 labelSz = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, label.c_str());
float zoneCx = (sx0 + sx1) * 0.5f;
float zoneCy = (sy0 + sy1) * 0.5f;
float lx = zoneCx - labelSz.x * 0.5f;
float ly = zoneCy - labelSz.y * 0.5f;
if (labelSz.x < (sx1 - sx0) * 1.1f && labelSz.y < (sy1 - sy0) * 0.8f) {
ImU32 textColor;
if (!zoneExplored) {
textColor = IM_COL32(140, 140, 140, 130);
} else if (meta) {
switch (meta->faction) {
case ZoneFaction::Alliance: textColor = IM_COL32(100, 160, 255, 200); break;
case ZoneFaction::Horde: textColor = IM_COL32(255, 100, 100, 200); break;
case ZoneFaction::Contested: textColor = IM_COL32(255, 215, 0, 190); break;
default: textColor = IM_COL32(255, 230, 180, 180); break;
}
} else {
textColor = IM_COL32(255, 230, 180, 180);
}
ctx.drawList->AddText(font, fontSize,
ImVec2(lx + 1.0f, ly + 1.0f),
IM_COL32(0, 0, 0, 140), label.c_str());
ctx.drawList->AddText(font, fontSize,
ImVec2(lx, ly), textColor, label.c_str());
}
}
}
prevHoveredZone_ = hoveredZone_;
if (hoveredZone_ < 0) {
hoverHighlightAlpha_ = 0.0f;
}
}
} // namespace world_map
} // namespace rendering
} // namespace wowee

View file

@ -0,0 +1,206 @@
// map_resolver.cpp — Centralized map navigation resolution for the world map.
// Map folder names resolved from a built-in table matching
// Data/interface/worldmap/ — no dependency on WorldLoader.
#include "rendering/world_map/map_resolver.hpp"
#include "rendering/world_map/coordinate_projection.hpp"
#include "core/logger.hpp"
#include <algorithm>
#include <cctype>
namespace wowee {
namespace rendering {
namespace world_map {
// ── Worldmap folder table (from Data/interface/worldmap/) ────
// Each entry maps a DBC MapID to its worldmap folder name and UI display name.
// Folder names match the directories under Data/interface/worldmap/.
struct MapFolderEntry {
uint32_t mapId;
const char* folder; // worldmap folder name (case as on disk)
const char* displayName; // UI display name
};
static constexpr MapFolderEntry kMapFolders[] = {
// Special UI-only views (no DBC MapID — sentinel values)
{ UINT32_MAX, "World", "World" },
{ UINT32_MAX - 1, "Cosmic", "Cosmic" },
// Continents
{ 0, "Azeroth", "Eastern Kingdoms" },
{ 1, "Kalimdor", "Kalimdor" },
{ 530, "Expansion01", "Outland" },
{ 571, "Northrend", "Northrend" },
// Dungeons / instances with worldmap folders
// (Data/interface/worldmap/<folder>/ exists for these)
{ 33, "Shadowfang", "Shadowfang Keep" }, // placeholder no folder yet
{ 209, "Tanaris", "Tanaris" }, // shared with zone
{ 534, "CoTStratholme", "Caverns of Time" },
{ 574, "UtgardeKeep", "Utgarde Keep" },
{ 575, "UtgardePinnacle", "Utgarde Pinnacle" },
{ 578, "Nexus80", "The Nexus" },
{ 595, "ThecullingOfStratholme","Culling of Stratholme" },
{ 599, "HallsOfLightning", "Halls of Lightning" },
{ 600, "HallsOfStone", "Halls of Stone" },
{ 601, "DrakTheron", "Drak'Theron Keep" },
{ 602, "GunDrak", "Gundrak" },
{ 603, "Ulduar77", "Ulduar" },
{ 608, "VioletHold", "Violet Hold" },
{ 619, "AhnKahet", "Ahn'kahet" },
{ 631, "IcecrownCitadel", "Icecrown Citadel" },
{ 632, "TheForgeOfSouls", "Forge of Souls" },
{ 649, "TheArgentColiseum", "Trial of the Crusader" },
{ 658, "PitOfSaron", "Pit of Saron" },
{ 668, "HallsOfReflection", "Halls of Reflection" },
{ 724, "TheRubySanctum", "Ruby Sanctum" },
};
static constexpr int kMapFolderCount = sizeof(kMapFolders) / sizeof(kMapFolders[0]);
// ── Map folder lookup functions ──────────────────────────────
const char* mapIdToFolder(uint32_t mapId) {
for (int i = 0; i < kMapFolderCount; i++) {
if (kMapFolders[i].mapId == mapId)
return kMapFolders[i].folder;
}
return "";
}
int folderToMapId(const std::string& folder) {
for (int i = 0; i < kMapFolderCount; i++) {
// Case-insensitive compare
const char* entry = kMapFolders[i].folder;
if (folder.size() != std::char_traits<char>::length(entry)) continue;
bool match = true;
for (size_t j = 0; j < folder.size(); j++) {
if (std::tolower(static_cast<unsigned char>(folder[j])) !=
std::tolower(static_cast<unsigned char>(entry[j]))) {
match = false;
break;
}
}
if (match) return static_cast<int>(kMapFolders[i].mapId);
}
return -1;
}
const char* mapDisplayName(uint32_t mapId) {
for (int i = 0; i < kMapFolderCount; i++) {
if (kMapFolders[i].mapId == mapId)
return kMapFolders[i].displayName;
}
return nullptr;
}
// ── Helper: find best continent zone for a mapId ─────────────
int findContinentForMapId(const std::vector<Zone>& zones,
uint32_t mapId,
int cosmicIdx) {
// 1) Prefer a leaf continent whose displayMapID matches the target mapId.
for (int i = 0; i < static_cast<int>(zones.size()); i++) {
if (i == cosmicIdx) continue;
if (zones[i].areaID != 0) continue;
if (isLeafContinent(zones, i) && zones[i].displayMapID == mapId)
return i;
}
// 2) Find the first non-root, non-cosmic continent.
int firstContinent = -1;
for (int i = 0; i < static_cast<int>(zones.size()); i++) {
if (i == cosmicIdx) continue;
if (zones[i].areaID != 0) continue;
if (firstContinent < 0) firstContinent = i;
if (!isRootContinent(zones, i)) return i;
}
// 3) Fallback to first continent entry
return firstContinent;
}
// ── Resolve WORLD view region click ──────────────────────────
MapResolveResult resolveWorldRegionClick(uint32_t regionMapId,
const std::vector<Zone>& zones,
int currentMapId,
int cosmicIdx) {
MapResolveResult result;
if (static_cast<int>(regionMapId) == currentMapId) {
// Target map is already loaded — navigate to the matching continent
// within the current zone data (no reload needed).
int contIdx = findContinentForMapId(zones, regionMapId, cosmicIdx);
if (contIdx >= 0) {
result.action = MapResolveAction::NAVIGATE_CONTINENT;
result.targetZoneIdx = contIdx;
LOG_INFO("resolveWorldRegionClick: mapId=", regionMapId,
" matches current map — NAVIGATE_CONTINENT idx=", contIdx);
} else {
LOG_WARNING("resolveWorldRegionClick: mapId=", regionMapId,
" matches current but no continent found");
}
return result;
}
// Different map — need to load it
const char* folder = mapIdToFolder(regionMapId);
if (folder[0]) {
result.action = MapResolveAction::LOAD_MAP;
result.targetMapName = folder;
LOG_INFO("resolveWorldRegionClick: mapId=", regionMapId,
" → LOAD_MAP '", folder, "'");
} else {
LOG_WARNING("resolveWorldRegionClick: unknown mapId=", regionMapId);
}
return result;
}
// ── Resolve CONTINENT view zone click ────────────────────────
MapResolveResult resolveZoneClick(int zoneIdx,
const std::vector<Zone>& zones,
int currentMapId) {
MapResolveResult result;
if (zoneIdx < 0 || zoneIdx >= static_cast<int>(zones.size())) return result;
const auto& zone = zones[zoneIdx];
// If the zone's displayMapID differs from the current map, it belongs to
// a different continent/map. Load that map instead.
// Skip sentinel values (UINT32_MAX / UINT32_MAX-1) used by kMapFolders for
// World/Cosmic; the DBC stores -1 (0xFFFFFFFF) to mean "no display map".
if (zone.displayMapID != 0 &&
zone.displayMapID < UINT32_MAX - 1 &&
static_cast<int>(zone.displayMapID) != currentMapId) {
const char* folder = mapIdToFolder(zone.displayMapID);
if (folder[0]) {
result.action = MapResolveAction::LOAD_MAP;
result.targetMapName = folder;
LOG_INFO("resolveZoneClick: zone[", zoneIdx, "] '", zone.areaName,
"' displayMapID=", zone.displayMapID, " → LOAD_MAP '", folder, "'");
return result;
}
}
// Normal case: enter the zone within the current map
result.action = MapResolveAction::ENTER_ZONE;
result.targetZoneIdx = zoneIdx;
return result;
}
// ── Resolve COSMIC view click ────────────────────────────────
MapResolveResult resolveCosmicClick(uint32_t targetMapId) {
MapResolveResult result;
const char* folder = mapIdToFolder(targetMapId);
if (folder[0]) {
result.action = MapResolveAction::LOAD_MAP;
result.targetMapName = folder;
}
return result;
}
} // namespace world_map
} // namespace rendering
} // namespace wowee

View file

@ -0,0 +1,21 @@
// overlay_renderer.cpp — ImGui overlay orchestrator for the world map.
// Extracted from WorldMap::renderImGuiOverlay (Phase 8 of refactoring plan).
#include "rendering/world_map/overlay_renderer.hpp"
namespace wowee {
namespace rendering {
namespace world_map {
void OverlayRenderer::addLayer(std::unique_ptr<IOverlayLayer> layer) {
layers_.push_back(std::move(layer));
}
void OverlayRenderer::render(const LayerContext& ctx) {
for (auto& layer : layers_) {
layer->render(ctx);
}
}
} // namespace world_map
} // namespace rendering
} // namespace wowee

View file

@ -0,0 +1,141 @@
// view_state_machine.cpp — Navigation state and transitions for the world map.
// Extracted from WorldMap::zoomIn, zoomOut, enterWorldView, enterCosmicView
// (Phase 6 of refactoring plan).
#include "rendering/world_map/view_state_machine.hpp"
namespace wowee {
namespace rendering {
namespace world_map {
void ViewStateMachine::startTransition(ViewLevel from, ViewLevel to, float duration) {
transition_.active = true;
transition_.progress = 0.0f;
transition_.duration = duration;
transition_.fromLevel = from;
transition_.toLevel = to;
}
bool ViewStateMachine::updateTransition(float deltaTime) {
if (!transition_.active) return false;
transition_.progress += deltaTime / transition_.duration;
if (transition_.progress >= 1.0f) {
transition_.progress = 1.0f;
transition_.active = false;
}
return transition_.active;
}
ViewStateMachine::ZoomResult ViewStateMachine::zoomIn(int hoveredZoneIdx, int playerZoneIdx) {
ZoomResult result;
if (level_ == ViewLevel::COSMIC) {
startTransition(ViewLevel::COSMIC, ViewLevel::WORLD);
level_ = ViewLevel::WORLD;
result.changed = true;
result.newLevel = ViewLevel::WORLD;
// Caller should call enterWorldView() to determine target index
return result;
}
if (level_ == ViewLevel::WORLD) {
if (continentIdx_ >= 0) {
startTransition(ViewLevel::WORLD, ViewLevel::CONTINENT);
level_ = ViewLevel::CONTINENT;
currentIdx_ = continentIdx_;
result.changed = true;
result.newLevel = ViewLevel::CONTINENT;
result.targetIdx = continentIdx_;
}
return result;
}
if (level_ == ViewLevel::CONTINENT) {
// Prefer the zone the mouse is hovering over; fall back to the player's zone
int zoneIdx = hoveredZoneIdx >= 0 ? hoveredZoneIdx : playerZoneIdx;
if (zoneIdx >= 0) {
startTransition(ViewLevel::CONTINENT, ViewLevel::ZONE);
level_ = ViewLevel::ZONE;
currentIdx_ = zoneIdx;
result.changed = true;
result.newLevel = ViewLevel::ZONE;
result.targetIdx = zoneIdx;
}
}
return result;
}
ViewStateMachine::ZoomResult ViewStateMachine::zoomOut() {
ZoomResult result;
if (level_ == ViewLevel::ZONE) {
if (continentIdx_ >= 0) {
startTransition(ViewLevel::ZONE, ViewLevel::CONTINENT);
level_ = ViewLevel::CONTINENT;
currentIdx_ = continentIdx_;
result.changed = true;
result.newLevel = ViewLevel::CONTINENT;
result.targetIdx = continentIdx_;
}
return result;
}
if (level_ == ViewLevel::CONTINENT) {
startTransition(ViewLevel::CONTINENT, ViewLevel::WORLD);
level_ = ViewLevel::WORLD;
result.changed = true;
result.newLevel = ViewLevel::WORLD;
// Caller should call enterWorldView() to determine target index
return result;
}
if (level_ == ViewLevel::WORLD) {
// Vanilla: cosmic view disabled, don't zoom out further
if (!cosmicEnabled_) return result;
startTransition(ViewLevel::WORLD, ViewLevel::COSMIC);
level_ = ViewLevel::COSMIC;
result.changed = true;
result.newLevel = ViewLevel::COSMIC;
// Caller should call enterCosmicView() to determine target index
}
return result;
}
ViewStateMachine::ZoomResult ViewStateMachine::enterWorldView() {
ZoomResult result;
level_ = ViewLevel::WORLD;
result.changed = true;
result.newLevel = ViewLevel::WORLD;
// Caller is responsible for finding the root continent and compositing
return result;
}
ViewStateMachine::ZoomResult ViewStateMachine::enterCosmicView() {
// Vanilla: cosmic view is disabled — stay in world view
if (!cosmicEnabled_) {
return enterWorldView();
}
ZoomResult result;
level_ = ViewLevel::COSMIC;
result.changed = true;
result.newLevel = ViewLevel::COSMIC;
// Caller uses cosmicIdx from DataRepository
return result;
}
ViewStateMachine::ZoomResult ViewStateMachine::enterZone(int zoneIdx) {
ZoomResult result;
startTransition(ViewLevel::CONTINENT, ViewLevel::ZONE);
level_ = ViewLevel::ZONE;
currentIdx_ = zoneIdx;
result.changed = true;
result.newLevel = ViewLevel::ZONE;
result.targetIdx = zoneIdx;
return result;
}
} // namespace world_map
} // namespace rendering
} // namespace wowee

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,118 @@
// zone_metadata.cpp — Zone level ranges, faction data, and label formatting.
// Extracted from WorldMap::initZoneMeta (Phase 4 of refactoring plan).
#include "rendering/world_map/zone_metadata.hpp"
namespace wowee {
namespace rendering {
namespace world_map {
void ZoneMetadata::initialize() {
if (!table_.empty()) return;
// Populate known zone level ranges and faction alignment.
// This covers major open-world zones for Vanilla/TBC/WotLK.
auto add = [this](const char* name, uint8_t lo, uint8_t hi, ZoneFaction f) {
table_[name] = {lo, hi, f};
};
// === Eastern Kingdoms ===
add("Elwynn", 1, 10, ZoneFaction::Alliance);
add("DunMorogh", 1, 10, ZoneFaction::Alliance);
add("TirisfalGlades", 1, 10, ZoneFaction::Horde);
add("Westfall", 10, 20, ZoneFaction::Alliance);
add("LochModan", 10, 20, ZoneFaction::Alliance);
add("Silverpine", 10, 20, ZoneFaction::Horde);
add("Redridge", 15, 25, ZoneFaction::Contested);
add("Duskwood", 18, 30, ZoneFaction::Alliance);
add("Wetlands", 20, 30, ZoneFaction::Alliance);
add("Hillsbrad", 20, 30, ZoneFaction::Contested);
add("Alterac", 30, 40, ZoneFaction::Contested);
add("Arathi", 30, 40, ZoneFaction::Contested);
add("StranglethornVale",30, 45, ZoneFaction::Contested);
add("Stranglethorn", 30, 45, ZoneFaction::Contested);
add("Badlands", 35, 45, ZoneFaction::Contested);
add("SwampOfSorrows", 35, 45, ZoneFaction::Contested);
add("TheBlastedLands", 45, 55, ZoneFaction::Contested);
add("SearingGorge", 43, 50, ZoneFaction::Contested);
add("BurningSteppes", 50, 58, ZoneFaction::Contested);
add("WesternPlaguelands",51,58, ZoneFaction::Contested);
add("EasternPlaguelands",53,60, ZoneFaction::Contested);
add("Hinterlands", 40, 50, ZoneFaction::Contested);
add("DeadwindPass", 55, 60, ZoneFaction::Contested);
// === Kalimdor ===
add("Durotar", 1, 10, ZoneFaction::Horde);
add("Mulgore", 1, 10, ZoneFaction::Horde);
add("Teldrassil", 1, 10, ZoneFaction::Alliance);
add("Darkshore", 10, 20, ZoneFaction::Alliance);
add("Barrens", 10, 25, ZoneFaction::Horde);
add("Ashenvale", 18, 30, ZoneFaction::Contested);
add("StonetalonMountains",15,27,ZoneFaction::Contested);
add("ThousandNeedles", 25, 35, ZoneFaction::Contested);
add("Desolace", 30, 40, ZoneFaction::Contested);
add("Dustwallow", 35, 45, ZoneFaction::Contested);
add("Feralas", 40, 50, ZoneFaction::Contested);
add("Tanaris", 40, 50, ZoneFaction::Contested);
add("Azshara", 45, 55, ZoneFaction::Contested);
add("UngoroCrater", 48, 55, ZoneFaction::Contested);
add("Felwood", 48, 55, ZoneFaction::Contested);
add("Winterspring", 55, 60, ZoneFaction::Contested);
add("Silithus", 55, 60, ZoneFaction::Contested);
add("Moonglade", 55, 60, ZoneFaction::Contested);
// === TBC: Outland ===
add("HellFire", 58, 63, ZoneFaction::Contested);
add("Zangarmarsh", 60, 64, ZoneFaction::Contested);
add("TerokkarForest", 62, 65, ZoneFaction::Contested);
add("Nagrand", 64, 67, ZoneFaction::Contested);
add("BladesEdgeMountains",65,68,ZoneFaction::Contested);
add("Netherstorm", 67, 70, ZoneFaction::Contested);
add("ShadowmoonValley",67, 70, ZoneFaction::Contested);
// === WotLK: Northrend ===
add("BoreanTundra", 68, 72, ZoneFaction::Contested);
add("HowlingFjord", 68, 72, ZoneFaction::Contested);
add("Dragonblight", 71, 75, ZoneFaction::Contested);
add("GrizzlyHills", 73, 75, ZoneFaction::Contested);
add("ZulDrak", 74, 77, ZoneFaction::Contested);
add("SholazarBasin", 76, 78, ZoneFaction::Contested);
add("StormPeaks", 77, 80, ZoneFaction::Contested);
add("Icecrown", 77, 80, ZoneFaction::Contested);
add("CrystalsongForest",77,80, ZoneFaction::Contested);
add("LakeWintergrasp", 77, 80, ZoneFaction::Contested);
}
const ZoneMeta* ZoneMetadata::find(const std::string& areaName) const {
auto it = table_.find(areaName);
return it != table_.end() ? &it->second : nullptr;
}
std::string ZoneMetadata::formatLabel(const std::string& areaName,
const ZoneMeta* meta) {
std::string label = areaName;
if (meta) {
if (meta->minLevel > 0 && meta->maxLevel > 0) {
label += " (" + std::to_string(meta->minLevel) + "-" +
std::to_string(meta->maxLevel) + ")";
}
}
return label;
}
std::string ZoneMetadata::formatHoverLabel(const std::string& areaName,
const ZoneMeta* meta) {
std::string label = formatLabel(areaName, meta);
if (meta) {
switch (meta->faction) {
case ZoneFaction::Alliance: label += " [Alliance]"; break;
case ZoneFaction::Horde: label += " [Horde]"; break;
case ZoneFaction::Contested: label += " [Contested]"; break;
default: break;
}
}
return label;
}
} // namespace world_map
} // namespace rendering
} // namespace wowee

View file

@ -11,6 +11,7 @@
#include "ui/quest_log_screen.hpp"
#include "ui/ui_colors.hpp"
#include "core/application.hpp"
#include "core/world_loader.hpp"
#include "core/logger.hpp"
#include "rendering/renderer.hpp"
#include "rendering/vk_context.hpp"
@ -611,13 +612,8 @@ void ActionBarPanel::renderActionBar(game::GameHandler& gameHandler,
}
// Fall back to continent name if zone unavailable
if (homeLocation.empty()) {
switch (mapId) {
case 0: homeLocation = "Eastern Kingdoms"; break;
case 1: homeLocation = "Kalimdor"; break;
case 530: homeLocation = "Outland"; break;
case 571: homeLocation = "Northrend"; break;
default: homeLocation = "Unknown"; break;
}
const char* dn = core::WorldLoader::mapDisplayName(mapId);
homeLocation = dn ? dn : "Unknown";
}
ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f),
"Home: %s", homeLocation.c_str());

View file

@ -3,6 +3,7 @@
#include "ui/keybinding_manager.hpp"
#include "game/game_handler.hpp"
#include "core/application.hpp"
#include "core/world_loader.hpp"
#include "rendering/vk_context.hpp"
#include "core/input.hpp"
#include "rendering/character_preview.hpp"
@ -2632,15 +2633,8 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I
homeLocation = gameHandler_->getWhoAreaName(zoneId);
// Fall back to continent name if zone unavailable
if (homeLocation.empty()) {
switch (mapId) {
case 0: homeLocation = "Eastern Kingdoms"; break;
case 1: homeLocation = "Kalimdor"; break;
case 530: homeLocation = "Outland"; break;
case 571: homeLocation = "Northrend"; break;
case 13: homeLocation = "Test"; break;
case 169: homeLocation = "Emerald Dream"; break;
default: homeLocation = "Unknown"; break;
}
const char* dn = core::WorldLoader::mapDisplayName(mapId);
homeLocation = dn ? dn : "Unknown";
}
ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f), "Home: %s", homeLocation.c_str());
} else {

View file

@ -263,6 +263,92 @@ endif()
add_test(NAME transport_components COMMAND test_transport_components)
register_test_target(test_transport_components)
# ── test_world_map ────────────────────────────────────────────
add_executable(test_world_map
test_world_map.cpp
)
target_include_directories(test_world_map PRIVATE ${TEST_INCLUDE_DIRS})
target_include_directories(test_world_map SYSTEM PRIVATE ${TEST_SYSTEM_INCLUDE_DIRS})
target_link_libraries(test_world_map PRIVATE catch2_main)
if(TARGET glm::glm)
target_link_libraries(test_world_map PRIVATE glm::glm)
endif()
add_test(NAME world_map COMMAND test_world_map)
register_test_target(test_world_map)
# ── test_world_map_coordinate_projection ──────────────────────
add_executable(test_world_map_coordinate_projection
test_world_map_coordinate_projection.cpp
${CMAKE_SOURCE_DIR}/src/rendering/world_map/coordinate_projection.cpp
)
target_include_directories(test_world_map_coordinate_projection PRIVATE ${TEST_INCLUDE_DIRS})
target_include_directories(test_world_map_coordinate_projection SYSTEM PRIVATE ${TEST_SYSTEM_INCLUDE_DIRS})
target_link_libraries(test_world_map_coordinate_projection PRIVATE catch2_main)
if(TARGET glm::glm)
target_link_libraries(test_world_map_coordinate_projection PRIVATE glm::glm)
endif()
add_test(NAME world_map_coordinate_projection COMMAND test_world_map_coordinate_projection)
register_test_target(test_world_map_coordinate_projection)
# ── test_world_map_map_resolver ───────────────────────────────
add_executable(test_world_map_map_resolver
test_world_map_map_resolver.cpp
${CMAKE_SOURCE_DIR}/src/rendering/world_map/map_resolver.cpp
${CMAKE_SOURCE_DIR}/src/rendering/world_map/coordinate_projection.cpp
${TEST_COMMON_SOURCES}
)
target_include_directories(test_world_map_map_resolver PRIVATE ${TEST_INCLUDE_DIRS})
target_include_directories(test_world_map_map_resolver SYSTEM PRIVATE ${TEST_SYSTEM_INCLUDE_DIRS})
target_link_libraries(test_world_map_map_resolver PRIVATE catch2_main)
if(TARGET glm::glm)
target_link_libraries(test_world_map_map_resolver PRIVATE glm::glm)
endif()
add_test(NAME world_map_map_resolver COMMAND test_world_map_map_resolver)
register_test_target(test_world_map_map_resolver)
# ── test_world_map_view_state_machine ─────────────────────────
add_executable(test_world_map_view_state_machine
test_world_map_view_state_machine.cpp
${CMAKE_SOURCE_DIR}/src/rendering/world_map/view_state_machine.cpp
)
target_include_directories(test_world_map_view_state_machine PRIVATE ${TEST_INCLUDE_DIRS})
target_include_directories(test_world_map_view_state_machine SYSTEM PRIVATE ${TEST_SYSTEM_INCLUDE_DIRS})
target_link_libraries(test_world_map_view_state_machine PRIVATE catch2_main)
if(TARGET glm::glm)
target_link_libraries(test_world_map_view_state_machine PRIVATE glm::glm)
endif()
add_test(NAME world_map_view_state_machine COMMAND test_world_map_view_state_machine)
register_test_target(test_world_map_view_state_machine)
# ── test_world_map_exploration_state ──────────────────────────
add_executable(test_world_map_exploration_state
test_world_map_exploration_state.cpp
${CMAKE_SOURCE_DIR}/src/rendering/world_map/exploration_state.cpp
${CMAKE_SOURCE_DIR}/src/rendering/world_map/coordinate_projection.cpp
)
target_include_directories(test_world_map_exploration_state PRIVATE ${TEST_INCLUDE_DIRS})
target_include_directories(test_world_map_exploration_state SYSTEM PRIVATE ${TEST_SYSTEM_INCLUDE_DIRS})
target_link_libraries(test_world_map_exploration_state PRIVATE catch2_main)
if(TARGET glm::glm)
target_link_libraries(test_world_map_exploration_state PRIVATE glm::glm)
endif()
add_test(NAME world_map_exploration_state COMMAND test_world_map_exploration_state)
register_test_target(test_world_map_exploration_state)
# ── test_world_map_zone_metadata ──────────────────────────────
add_executable(test_world_map_zone_metadata
test_world_map_zone_metadata.cpp
${CMAKE_SOURCE_DIR}/src/rendering/world_map/zone_metadata.cpp
)
target_include_directories(test_world_map_zone_metadata PRIVATE ${TEST_INCLUDE_DIRS})
target_include_directories(test_world_map_zone_metadata SYSTEM PRIVATE ${TEST_SYSTEM_INCLUDE_DIRS})
target_link_libraries(test_world_map_zone_metadata PRIVATE catch2_main)
if(TARGET glm::glm)
target_link_libraries(test_world_map_zone_metadata PRIVATE glm::glm)
endif()
add_test(NAME world_map_zone_metadata COMMAND test_world_map_zone_metadata)
register_test_target(test_world_map_zone_metadata)
# ── ASAN / UBSan for test targets ────────────────────────────
if(WOWEE_ENABLE_ASAN AND NOT MSVC)
foreach(_t IN LISTS ALL_TEST_TARGETS)

498
tests/test_world_map.cpp Normal file
View file

@ -0,0 +1,498 @@
// Tests for WorldMap data structures and coordinate math
// Updated to use new modular types from world_map_types.hpp
#include <catch_amalgamated.hpp>
#include "rendering/world_map/world_map_types.hpp"
#include <glm/glm.hpp>
#include <cmath>
#include <algorithm>
#include <array>
#include <vector>
#include <string>
using wowee::rendering::world_map::Zone;
using wowee::rendering::world_map::OverlayEntry;
using wowee::rendering::world_map::POI;
// ── MapPOI struct ────────────────────────────────────────────
TEST_CASE("POI default-constructed is zeroed", "[world_map]") {
POI poi{};
REQUIRE(poi.id == 0);
REQUIRE(poi.importance == 0);
REQUIRE(poi.iconType == 0);
REQUIRE(poi.factionId == 0);
REQUIRE(poi.wowX == 0.0f);
REQUIRE(poi.wowY == 0.0f);
REQUIRE(poi.wowZ == 0.0f);
REQUIRE(poi.mapId == 0);
REQUIRE(poi.name.empty());
REQUIRE(poi.description.empty());
}
TEST_CASE("POI sorts by importance ascending", "[world_map]") {
std::vector<POI> pois;
POI capital;
capital.id = 1;
capital.importance = 3;
capital.name = "Stormwind";
pois.push_back(capital);
POI town;
town.id = 2;
town.importance = 1;
town.name = "Goldshire";
pois.push_back(town);
POI minor;
minor.id = 3;
minor.importance = 0;
minor.name = "Mirror Lake";
pois.push_back(minor);
std::sort(pois.begin(), pois.end(), [](const POI& a, const POI& b) {
return a.importance < b.importance;
});
REQUIRE(pois[0].name == "Mirror Lake");
REQUIRE(pois[1].name == "Goldshire");
REQUIRE(pois[2].name == "Stormwind");
}
// ── WorldMapZone struct ──────────────────────────────────────
TEST_CASE("Zone default-constructed is valid", "[world_map]") {
Zone z{};
REQUIRE(z.wmaID == 0);
REQUIRE(z.areaID == 0);
REQUIRE(z.areaName.empty());
REQUIRE(z.bounds.locLeft == 0.0f);
REQUIRE(z.bounds.locRight == 0.0f);
REQUIRE(z.bounds.locTop == 0.0f);
REQUIRE(z.bounds.locBottom == 0.0f);
REQUIRE(z.displayMapID == 0);
REQUIRE(z.parentWorldMapID == 0);
REQUIRE(z.exploreBits.empty());
}
TEST_CASE("Zone areaID==0 identifies continent", "[world_map]") {
Zone continent{};
continent.areaID = 0;
continent.wmaID = 10;
continent.areaName = "Kalimdor";
Zone zone{};
zone.areaID = 440;
zone.wmaID = 100;
zone.areaName = "Tanaris";
REQUIRE(continent.areaID == 0);
REQUIRE(zone.areaID != 0);
}
// ── Coordinate projection logic ──────────────────────────────
// Replicate the UV projection formula from renderPosToMapUV for standalone testing.
static glm::vec2 computeMapUV(float wowX, float wowY,
float locLeft, float locRight,
float locTop, float locBottom,
bool isContinent) {
float denom_h = locLeft - locRight;
float denom_v = locTop - locBottom;
if (std::abs(denom_h) < 0.001f || std::abs(denom_v) < 0.001f)
return glm::vec2(0.5f, 0.5f);
float u = (locLeft - wowX) / denom_h;
float v = (locTop - wowY) / denom_v;
if (isContinent) {
constexpr float kVOffset = -0.15f;
v = (v - 0.5f) + 0.5f + kVOffset;
}
return glm::vec2(u, v);
}
TEST_CASE("UV projection: center of zone maps to (0.5, 0.5)", "[world_map]") {
// Zone bounds: left=1000, right=0, top=1000, bottom=0
float centerX = 500.0f, centerY = 500.0f;
glm::vec2 uv = computeMapUV(centerX, centerY, 1000.0f, 0.0f, 1000.0f, 0.0f, false);
REQUIRE(uv.x == Catch::Approx(0.5f).margin(0.001f));
REQUIRE(uv.y == Catch::Approx(0.5f).margin(0.001f));
}
TEST_CASE("UV projection: top-left corner maps to (0, 0)", "[world_map]") {
glm::vec2 uv = computeMapUV(1000.0f, 1000.0f, 1000.0f, 0.0f, 1000.0f, 0.0f, false);
REQUIRE(uv.x == Catch::Approx(0.0f).margin(0.001f));
REQUIRE(uv.y == Catch::Approx(0.0f).margin(0.001f));
}
TEST_CASE("UV projection: bottom-right corner maps to (1, 1)", "[world_map]") {
glm::vec2 uv = computeMapUV(0.0f, 0.0f, 1000.0f, 0.0f, 1000.0f, 0.0f, false);
REQUIRE(uv.x == Catch::Approx(1.0f).margin(0.001f));
REQUIRE(uv.y == Catch::Approx(1.0f).margin(0.001f));
}
TEST_CASE("UV projection: degenerate bounds returns center", "[world_map]") {
// left == right → degenerate
glm::vec2 uv = computeMapUV(500.0f, 500.0f, 500.0f, 500.0f, 1000.0f, 0.0f, false);
REQUIRE(uv.x == Catch::Approx(0.5f));
REQUIRE(uv.y == Catch::Approx(0.5f));
}
TEST_CASE("UV projection: continent mode applies vertical offset", "[world_map]") {
// Same center point, but continent mode shifts V by kVOffset=-0.15
glm::vec2 uvZone = computeMapUV(500.0f, 500.0f, 1000.0f, 0.0f, 1000.0f, 0.0f, false);
glm::vec2 uvCont = computeMapUV(500.0f, 500.0f, 1000.0f, 0.0f, 1000.0f, 0.0f, true);
REQUIRE(uvZone.x == Catch::Approx(uvCont.x).margin(0.001f));
// Continent V should be shifted by -0.15
REQUIRE(uvCont.y == Catch::Approx(uvZone.y - 0.15f).margin(0.001f));
}
// ── Expansion level derivation ───────────────────────────────
// Replicate the expansion detection logic from getExpansionLevel.
static int deriveExpansionLevel(int maxLevel) {
if (maxLevel <= 60) return 0; // vanilla
if (maxLevel <= 70) return 1; // TBC
return 2; // WotLK
}
TEST_CASE("Expansion level from maxLevel", "[world_map]") {
REQUIRE(deriveExpansionLevel(60) == 0); // vanilla
REQUIRE(deriveExpansionLevel(58) == 0); // below vanilla cap
REQUIRE(deriveExpansionLevel(70) == 1); // TBC
REQUIRE(deriveExpansionLevel(65) == 1); // mid TBC range
REQUIRE(deriveExpansionLevel(80) == 2); // WotLK
REQUIRE(deriveExpansionLevel(75) == 2); // mid WotLK range
}
// ── Expansion continent filtering ────────────────────────────
static std::vector<uint32_t> filterContinentsByExpansion(
const std::vector<uint32_t>& mapIds, int expansionLevel) {
std::vector<uint32_t> result;
for (uint32_t id : mapIds) {
if (id == 530 && expansionLevel < 1) continue;
if (id == 571 && expansionLevel < 2) continue;
result.push_back(id);
}
return result;
}
TEST_CASE("Vanilla hides TBC and WotLK continents", "[world_map]") {
std::vector<uint32_t> all = {0, 1, 530, 571};
auto filtered = filterContinentsByExpansion(all, 0);
REQUIRE(filtered.size() == 2);
REQUIRE(filtered[0] == 0);
REQUIRE(filtered[1] == 1);
}
TEST_CASE("TBC shows Outland but hides Northrend", "[world_map]") {
std::vector<uint32_t> all = {0, 1, 530, 571};
auto filtered = filterContinentsByExpansion(all, 1);
REQUIRE(filtered.size() == 3);
REQUIRE(filtered[2] == 530);
}
TEST_CASE("WotLK shows all continents", "[world_map]") {
std::vector<uint32_t> all = {0, 1, 530, 571};
auto filtered = filterContinentsByExpansion(all, 2);
REQUIRE(filtered.size() == 4);
}
// ── POI faction coloring logic ───────────────────────────────
enum class Faction { Alliance, Horde, Neutral };
static Faction classifyFaction(uint32_t factionId) {
if (factionId == 469) return Faction::Alliance;
if (factionId == 67) return Faction::Horde;
return Faction::Neutral;
}
TEST_CASE("POI faction classification", "[world_map]") {
REQUIRE(classifyFaction(469) == Faction::Alliance);
REQUIRE(classifyFaction(67) == Faction::Horde);
REQUIRE(classifyFaction(0) == Faction::Neutral);
REQUIRE(classifyFaction(35) == Faction::Neutral);
}
// ── Overlay entry defaults ───────────────────────────────────
TEST_CASE("OverlayEntry defaults", "[world_map]") {
OverlayEntry ov{};
for (int i = 0; i < 4; i++) {
REQUIRE(ov.areaIDs[i] == 0);
}
REQUIRE(ov.textureName.empty());
REQUIRE(ov.texWidth == 0);
REQUIRE(ov.texHeight == 0);
REQUIRE(ov.offsetX == 0);
REQUIRE(ov.offsetY == 0);
REQUIRE(ov.hitRectLeft == 0);
REQUIRE(ov.hitRectRight == 0);
REQUIRE(ov.hitRectTop == 0);
REQUIRE(ov.hitRectBottom == 0);
REQUIRE(ov.tileCols == 0);
REQUIRE(ov.tileRows == 0);
REQUIRE(ov.tilesLoaded == false);
}
// ── ZMP pixel-map zone lookup ────────────────────────────────
TEST_CASE("ZMP grid lookup resolves mouse UV to zone", "[world_map]") {
// Simulate a 128x128 ZMP grid with a zone at a known cell
std::array<uint32_t, 128 * 128> grid{};
uint32_t testAreaId = 42;
// Place area ID at grid cell (64, 64) — center of map
grid[64 * 128 + 64] = testAreaId;
// Mouse at UV (0.5, 0.5) → col=64, row=64
float mu = 0.5f, mv = 0.5f;
constexpr int ZMP_SIZE = 128;
int col = std::clamp(static_cast<int>(mu * ZMP_SIZE), 0, ZMP_SIZE - 1);
int row = std::clamp(static_cast<int>(mv * ZMP_SIZE), 0, ZMP_SIZE - 1);
uint32_t areaId = grid[row * ZMP_SIZE + col];
REQUIRE(areaId == testAreaId);
}
TEST_CASE("ZMP grid returns 0 for empty cells", "[world_map]") {
std::array<uint32_t, 128 * 128> grid{};
// Empty grid — all cells zero (ocean/no zone)
constexpr int ZMP_SIZE = 128;
int col = 10, row = 10;
REQUIRE(grid[row * ZMP_SIZE + col] == 0);
}
TEST_CASE("ZMP grid clamps out-of-range UV", "[world_map]") {
std::array<uint32_t, 128 * 128> grid{};
grid[0] = 100; // (0,0) cell
grid[127 * 128 + 127] = 200; // (127,127) cell
constexpr int ZMP_SIZE = 128;
// UV at (-0.1, -0.1) should clamp to (0, 0)
float mu = -0.1f, mv = -0.1f;
int col = std::clamp(static_cast<int>(mu * ZMP_SIZE), 0, ZMP_SIZE - 1);
int row = std::clamp(static_cast<int>(mv * ZMP_SIZE), 0, ZMP_SIZE - 1);
REQUIRE(grid[row * ZMP_SIZE + col] == 100);
// UV at (1.5, 1.5) should clamp to (127, 127)
mu = 1.5f; mv = 1.5f;
col = std::clamp(static_cast<int>(mu * ZMP_SIZE), 0, ZMP_SIZE - 1);
row = std::clamp(static_cast<int>(mv * ZMP_SIZE), 0, ZMP_SIZE - 1);
REQUIRE(grid[row * ZMP_SIZE + col] == 200);
}
// ── HitRect overlay AABB pre-filter ──────────────────────────
TEST_CASE("HitRect filters overlays correctly", "[world_map]") {
OverlayEntry ov{};
ov.hitRectLeft = 100;
ov.hitRectRight = 300;
ov.hitRectTop = 50;
ov.hitRectBottom = 200;
ov.texWidth = 200;
ov.texHeight = 150;
ov.textureName = "Goldshire";
bool hasHitRect = (ov.hitRectRight > ov.hitRectLeft &&
ov.hitRectBottom > ov.hitRectTop);
REQUIRE(hasHitRect);
// Point inside HitRect
float px = 150.0f, py = 100.0f;
bool inside = (px >= ov.hitRectLeft && px <= ov.hitRectRight &&
py >= ov.hitRectTop && py <= ov.hitRectBottom);
REQUIRE(inside);
// Point outside HitRect
px = 50.0f; py = 25.0f;
inside = (px >= ov.hitRectLeft && px <= ov.hitRectRight &&
py >= ov.hitRectTop && py <= ov.hitRectBottom);
REQUIRE_FALSE(inside);
}
TEST_CASE("HitRect with zero values falls back to offset AABB", "[world_map]") {
OverlayEntry ov{};
// HitRect fields all zero → hasHitRect should be false
bool hasHitRect = (ov.hitRectRight > ov.hitRectLeft &&
ov.hitRectBottom > ov.hitRectTop);
REQUIRE_FALSE(hasHitRect);
}
TEST_CASE("Subzone hover with HitRect picks smallest overlay", "[world_map]") {
// Simulate two overlays — one large with HitRect, one small with HitRect
struct TestHitOverlay {
float hitLeft, hitRight, hitTop, hitBottom;
float texW, texH;
std::string name;
};
TestHitOverlay large{0.0f, 500.0f, 0.0f, 400.0f, 500.0f, 400.0f, "BigArea"};
TestHitOverlay small{100.0f, 250.0f, 80.0f, 180.0f, 150.0f, 100.0f, "SmallArea"};
std::vector<TestHitOverlay> overlays = {large, small};
float px = 150.0f, py = 120.0f; // Inside both HitRects
std::string best;
float bestArea = std::numeric_limits<float>::max();
for (const auto& ov : overlays) {
bool inside = (px >= ov.hitLeft && px <= ov.hitRight &&
py >= ov.hitTop && py <= ov.hitBottom);
if (inside) {
float area = ov.texW * ov.texH;
if (area < bestArea) {
bestArea = area;
best = ov.name;
}
}
}
REQUIRE(best == "SmallArea");
}
// ── Cosmic view expansion logic ──────────────────────────────
struct CosmicMapEntry {
int mapId = 0;
std::string label;
};
static std::vector<CosmicMapEntry> buildCosmicMaps(int expLevel) {
std::vector<CosmicMapEntry> maps;
if (expLevel == 0) return maps; // Vanilla: no cosmic
maps.push_back({0, "Azeroth"});
if (expLevel >= 1) maps.push_back({530, "Outland"});
if (expLevel >= 2) maps.push_back({571, "Northrend"});
return maps;
}
TEST_CASE("Vanilla has no cosmic view entries", "[world_map]") {
auto maps = buildCosmicMaps(0);
REQUIRE(maps.empty());
}
TEST_CASE("TBC cosmic view has Azeroth + Outland", "[world_map]") {
auto maps = buildCosmicMaps(1);
REQUIRE(maps.size() == 2);
REQUIRE(maps[0].mapId == 0);
REQUIRE(maps[0].label == "Azeroth");
REQUIRE(maps[1].mapId == 530);
REQUIRE(maps[1].label == "Outland");
}
TEST_CASE("WotLK cosmic view has all three worlds", "[world_map]") {
auto maps = buildCosmicMaps(2);
REQUIRE(maps.size() == 3);
REQUIRE(maps[2].mapId == 571);
REQUIRE(maps[2].label == "Northrend");
}
// ── Subzone hover priority (smallest overlay wins) ───────────
struct TestOverlay {
float offsetX, offsetY;
float width, height;
std::string name;
};
static std::string findSmallestOverlay(const std::vector<TestOverlay>& overlays,
float mu, float mv, float fboW, float fboH) {
std::string best;
float bestArea = std::numeric_limits<float>::max();
for (const auto& ov : overlays) {
float ovLeft = ov.offsetX / fboW;
float ovTop = ov.offsetY / fboH;
float ovRight = (ov.offsetX + ov.width) / fboW;
float ovBottom = (ov.offsetY + ov.height) / fboH;
if (mu >= ovLeft && mu <= ovRight && mv >= ovTop && mv <= ovBottom) {
float area = ov.width * ov.height;
if (area < bestArea) {
bestArea = area;
best = ov.name;
}
}
}
return best;
}
TEST_CASE("Subzone hover returns smallest overlapping overlay", "[world_map]") {
// Large overlay covers 0-512, 0-512
TestOverlay large{0.0f, 0.0f, 512.0f, 512.0f, "BigZone"};
// Small overlay covers 100-200, 100-200
TestOverlay small{100.0f, 100.0f, 100.0f, 100.0f, "SmallSubzone"};
std::vector<TestOverlay> overlays = {large, small};
// Mouse at UV (0.15, 0.15) → pixel (153.6, 115.2) for 1024x768 FBO
// Both overlays overlap; small should win
std::string result = findSmallestOverlay(overlays, 0.15f, 0.15f, 1024.0f, 768.0f);
REQUIRE(result == "SmallSubzone");
}
TEST_CASE("Subzone hover returns only overlay when one matches", "[world_map]") {
TestOverlay large{0.0f, 0.0f, 512.0f, 512.0f, "BigZone"};
TestOverlay small{100.0f, 100.0f, 100.0f, 100.0f, "SmallSubzone"};
std::vector<TestOverlay> overlays = {large, small};
// Mouse at UV (0.01, 0.01) → only large matches
std::string result = findSmallestOverlay(overlays, 0.01f, 0.01f, 1024.0f, 768.0f);
REQUIRE(result == "BigZone");
}
TEST_CASE("Subzone hover returns empty when nothing matches", "[world_map]") {
TestOverlay ov{100.0f, 100.0f, 50.0f, 50.0f, "Tiny"};
std::vector<TestOverlay> overlays = {ov};
std::string result = findSmallestOverlay(overlays, 0.01f, 0.01f, 1024.0f, 768.0f);
REQUIRE(result.empty());
}
// ── Zone metadata (level range + faction) ────────────────────
enum class TestFaction { Neutral, Alliance, Horde, Contested };
struct TestZoneMeta {
uint8_t minLevel = 0, maxLevel = 0;
TestFaction faction = TestFaction::Neutral;
};
static std::string formatZoneLabel(const std::string& name, const TestZoneMeta* meta) {
std::string label = name;
if (meta) {
if (meta->minLevel > 0 && meta->maxLevel > 0) {
label += " (" + std::to_string(meta->minLevel) + "-" +
std::to_string(meta->maxLevel) + ")";
}
switch (meta->faction) {
case TestFaction::Alliance: label += " [Alliance]"; break;
case TestFaction::Horde: label += " [Horde]"; break;
case TestFaction::Contested: label += " [Contested]"; break;
default: break;
}
}
return label;
}
TEST_CASE("Zone label includes level range and faction", "[world_map]") {
TestZoneMeta meta{1, 10, TestFaction::Alliance};
std::string label = formatZoneLabel("Elwynn", &meta);
REQUIRE(label == "Elwynn (1-10) [Alliance]");
}
TEST_CASE("Zone label shows Contested faction", "[world_map]") {
TestZoneMeta meta{30, 45, TestFaction::Contested};
std::string label = formatZoneLabel("Stranglethorn", &meta);
REQUIRE(label == "Stranglethorn (30-45) [Contested]");
}
TEST_CASE("Zone label without metadata is just the name", "[world_map]") {
std::string label = formatZoneLabel("UnknownZone", nullptr);
REQUIRE(label == "UnknownZone");
}
TEST_CASE("Zone label with Neutral faction omits tag", "[world_map]") {
TestZoneMeta meta{55, 60, TestFaction::Neutral};
std::string label = formatZoneLabel("Moonglade", &meta);
REQUIRE(label == "Moonglade (55-60)");
}

View file

@ -0,0 +1,193 @@
// Tests for the extracted world map coordinate projection module
#include <catch_amalgamated.hpp>
#include "rendering/world_map/coordinate_projection.hpp"
#include "rendering/world_map/world_map_types.hpp"
#include <glm/glm.hpp>
#include <cmath>
#include <vector>
using namespace wowee::rendering::world_map;
// ── Helper: build a minimal zone for testing ─────────────────
static Zone makeZone(uint32_t wmaID, uint32_t areaID,
float locLeft, float locRight,
float locTop, float locBottom,
uint32_t displayMapID = 0,
uint32_t parentWorldMapID = 0,
const std::string& name = "") {
Zone z;
z.wmaID = wmaID;
z.areaID = areaID;
z.areaName = name;
z.bounds.locLeft = locLeft;
z.bounds.locRight = locRight;
z.bounds.locTop = locTop;
z.bounds.locBottom = locBottom;
z.displayMapID = displayMapID;
z.parentWorldMapID = parentWorldMapID;
return z;
}
// ── renderPosToMapUV ─────────────────────────────────────────
TEST_CASE("renderPosToMapUV: center of zone maps to (0.5, ~0.5)", "[world_map][coordinate_projection]") {
ZoneBounds bounds;
bounds.locLeft = 1000.0f;
bounds.locRight = -1000.0f;
bounds.locTop = 1000.0f;
bounds.locBottom = -1000.0f;
// renderPos.y = wowX, renderPos.x = wowY
glm::vec3 center(0.0f, 0.0f, 0.0f);
glm::vec2 uv = renderPosToMapUV(center, bounds, /*isContinent=*/false);
REQUIRE(std::abs(uv.x - 0.5f) < 0.01f);
REQUIRE(std::abs(uv.y - 0.5f) < 0.01f);
}
TEST_CASE("renderPosToMapUV: degenerate bounds returns (0.5, 0.5)", "[world_map][coordinate_projection]") {
ZoneBounds bounds{}; // all zeros
glm::vec3 pos(100.0f, 200.0f, 0.0f);
glm::vec2 uv = renderPosToMapUV(pos, bounds, false);
REQUIRE(uv.x == Catch::Approx(0.5f));
REQUIRE(uv.y == Catch::Approx(0.5f));
}
TEST_CASE("renderPosToMapUV: top-left corner maps to (0, ~0)", "[world_map][coordinate_projection]") {
ZoneBounds bounds;
bounds.locLeft = 1000.0f;
bounds.locRight = -1000.0f;
bounds.locTop = 1000.0f;
bounds.locBottom = -1000.0f;
// wowX = renderPos.y = locLeft = 1000, wowY = renderPos.x = locTop = 1000
glm::vec3 topLeft(1000.0f, 1000.0f, 0.0f);
glm::vec2 uv = renderPosToMapUV(topLeft, bounds, false);
REQUIRE(uv.x == Catch::Approx(0.0f).margin(0.01f));
REQUIRE(uv.y == Catch::Approx(0.0f).margin(0.01f));
}
TEST_CASE("renderPosToMapUV: continent applies vertical offset", "[world_map][coordinate_projection]") {
ZoneBounds bounds;
bounds.locLeft = 1000.0f;
bounds.locRight = -1000.0f;
bounds.locTop = 1000.0f;
bounds.locBottom = -1000.0f;
glm::vec3 center(0.0f, 0.0f, 0.0f);
glm::vec2 zone_uv = renderPosToMapUV(center, bounds, false);
glm::vec2 cont_uv = renderPosToMapUV(center, bounds, true);
// Continent mode applies kVOffset = -0.15
REQUIRE(zone_uv.x == Catch::Approx(cont_uv.x).margin(0.01f));
REQUIRE(cont_uv.y != Catch::Approx(zone_uv.y).margin(0.01f));
}
// ── zoneBelongsToContinent ───────────────────────────────────
TEST_CASE("zoneBelongsToContinent: parent match", "[world_map][coordinate_projection]") {
std::vector<Zone> zones;
// Continent at index 0
zones.push_back(makeZone(1, 0, 5000.0f, -5000.0f, 5000.0f, -5000.0f, 0, 0, "EK"));
// Zone at index 1: parentWorldMapID matches continent's wmaID
zones.push_back(makeZone(2, 100, 1000.0f, -1000.0f, 1000.0f, -1000.0f, 0, 1, "Elwynn"));
REQUIRE(zoneBelongsToContinent(zones, 1, 0) == true);
}
TEST_CASE("zoneBelongsToContinent: no relation", "[world_map][coordinate_projection]") {
std::vector<Zone> zones;
zones.push_back(makeZone(1, 0, 5000.0f, -5000.0f, 5000.0f, -5000.0f, 0, 0, "EK"));
zones.push_back(makeZone(99, 100, 1000.0f, -1000.0f, 1000.0f, -1000.0f, 0, 50, "Far"));
REQUIRE(zoneBelongsToContinent(zones, 1, 0) == false);
}
TEST_CASE("zoneBelongsToContinent: out of bounds returns false", "[world_map][coordinate_projection]") {
std::vector<Zone> zones;
zones.push_back(makeZone(1, 0, 5000.0f, -5000.0f, 5000.0f, -5000.0f));
REQUIRE(zoneBelongsToContinent(zones, -1, 0) == false);
REQUIRE(zoneBelongsToContinent(zones, 5, 0) == false);
}
// ── isRootContinent / isLeafContinent ────────────────────────
TEST_CASE("isRootContinent detects root with leaf children", "[world_map][coordinate_projection]") {
std::vector<Zone> zones;
zones.push_back(makeZone(1, 0, 5000.0f, -5000.0f, 5000.0f, -5000.0f, 0, 0, "Root"));
zones.push_back(makeZone(2, 0, 3000.0f, -3000.0f, 3000.0f, -3000.0f, 0, 1, "Leaf"));
REQUIRE(isRootContinent(zones, 0) == true);
REQUIRE(isLeafContinent(zones, 1) == true);
REQUIRE(isRootContinent(zones, 1) == false);
REQUIRE(isLeafContinent(zones, 0) == false);
}
TEST_CASE("isRootContinent: lone continent is not root (no children)", "[world_map][coordinate_projection]") {
std::vector<Zone> zones;
zones.push_back(makeZone(1, 0, 5000.0f, -5000.0f, 5000.0f, -5000.0f, 0, 0, "Solo"));
REQUIRE(isRootContinent(zones, 0) == false);
}
TEST_CASE("isRootContinent: out of bounds returns false", "[world_map][coordinate_projection]") {
std::vector<Zone> zones;
REQUIRE(isRootContinent(zones, 0) == false);
REQUIRE(isRootContinent(zones, -1) == false);
REQUIRE(isLeafContinent(zones, 0) == false);
}
// ── findZoneForPlayer ────────────────────────────────────────
TEST_CASE("findZoneForPlayer: finds smallest containing zone", "[world_map][coordinate_projection]") {
std::vector<Zone> zones;
// Continent (ignored: areaID == 0)
zones.push_back(makeZone(1, 0, 10000.0f, -10000.0f, 10000.0f, -10000.0f, 0, 0, "Cont"));
// Large zone
zones.push_back(makeZone(2, 100, 5000.0f, -5000.0f, 5000.0f, -5000.0f, 0, 1, "Large"));
// Small zone fully inside large
zones.push_back(makeZone(3, 200, 1000.0f, -1000.0f, 1000.0f, -1000.0f, 0, 1, "Small"));
// Player at center — should find the smaller zone
glm::vec3 playerPos(0.0f, 0.0f, 0.0f);
int found = findZoneForPlayer(zones, playerPos);
REQUIRE(found == 2); // Small zone
}
TEST_CASE("findZoneForPlayer: returns -1 when no zone contains position", "[world_map][coordinate_projection]") {
std::vector<Zone> zones;
zones.push_back(makeZone(1, 100, 100.0f, -100.0f, 100.0f, -100.0f, 0, 0, "Tiny"));
glm::vec3 farAway(9999.0f, 9999.0f, 0.0f);
REQUIRE(findZoneForPlayer(zones, farAway) == -1);
}
// ── getContinentProjectionBounds ─────────────────────────────
TEST_CASE("getContinentProjectionBounds: uses continent's own bounds if available", "[world_map][coordinate_projection]") {
std::vector<Zone> zones;
zones.push_back(makeZone(1, 0, 5000.0f, -5000.0f, 3000.0f, -3000.0f, 0, 0, "EK"));
float l, r, t, b;
bool ok = getContinentProjectionBounds(zones, 0, l, r, t, b);
REQUIRE(ok == true);
REQUIRE(l == Catch::Approx(5000.0f));
REQUIRE(r == Catch::Approx(-5000.0f));
REQUIRE(t == Catch::Approx(3000.0f));
REQUIRE(b == Catch::Approx(-3000.0f));
}
TEST_CASE("getContinentProjectionBounds: returns false for out of bounds", "[world_map][coordinate_projection]") {
std::vector<Zone> zones;
float l, r, t, b;
REQUIRE(getContinentProjectionBounds(zones, 0, l, r, t, b) == false);
REQUIRE(getContinentProjectionBounds(zones, -1, l, r, t, b) == false);
}
TEST_CASE("getContinentProjectionBounds: rejects non-continent zones", "[world_map][coordinate_projection]") {
std::vector<Zone> zones;
zones.push_back(makeZone(1, 100, 5000.0f, -5000.0f, 3000.0f, -3000.0f, 0, 0, "Zone"));
float l, r, t, b;
bool ok = getContinentProjectionBounds(zones, 0, l, r, t, b);
REQUIRE(ok == false);
}

View file

@ -0,0 +1,89 @@
// Tests for the extracted world map exploration state module
#include <catch_amalgamated.hpp>
#include "rendering/world_map/exploration_state.hpp"
#include "rendering/world_map/world_map_types.hpp"
#include <glm/glm.hpp>
#include <vector>
#include <unordered_map>
#include <unordered_set>
using namespace wowee::rendering::world_map;
static Zone makeZone(uint32_t wmaID, uint32_t areaID,
float locLeft, float locRight,
float locTop, float locBottom,
uint32_t parentWmaID = 0) {
Zone z;
z.wmaID = wmaID;
z.areaID = areaID;
z.bounds.locLeft = locLeft;
z.bounds.locRight = locRight;
z.bounds.locTop = locTop;
z.bounds.locBottom = locBottom;
z.parentWorldMapID = parentWmaID;
return z;
}
TEST_CASE("ExplorationState: initially has no server mask", "[world_map][exploration_state]") {
ExplorationState es;
REQUIRE(es.hasServerMask() == false);
REQUIRE(es.exploredZones().empty());
REQUIRE(es.exploredOverlays().empty());
}
TEST_CASE("ExplorationState: setServerMask toggles hasServerMask", "[world_map][exploration_state]") {
ExplorationState es;
std::vector<uint32_t> mask = {0xFF, 0x00, 0x01};
es.setServerMask(mask, true);
REQUIRE(es.hasServerMask() == true);
es.setServerMask({}, false);
REQUIRE(es.hasServerMask() == false);
}
TEST_CASE("ExplorationState: overlaysChanged tracks changes", "[world_map][exploration_state]") {
ExplorationState es;
REQUIRE(es.overlaysChanged() == false);
}
TEST_CASE("ExplorationState: clearLocal resets local data", "[world_map][exploration_state]") {
ExplorationState es;
es.clearLocal();
REQUIRE(es.exploredZones().empty());
}
TEST_CASE("ExplorationState: update with empty zones is safe", "[world_map][exploration_state]") {
ExplorationState es;
std::vector<Zone> zones;
std::unordered_map<uint32_t, uint32_t> exploreFlagByAreaId;
glm::vec3 pos(0.0f);
es.update(zones, pos, -1, exploreFlagByAreaId);
REQUIRE(es.exploredZones().empty());
}
TEST_CASE("ExplorationState: update with valid zone and server mask", "[world_map][exploration_state]") {
ExplorationState es;
std::vector<Zone> zones;
auto z = makeZone(1, 100, 1000.0f, -1000.0f, 1000.0f, -1000.0f, 0);
z.exploreBits.push_back(0); // bit 0
OverlayEntry ov;
ov.areaIDs[0] = 100;
z.overlays.push_back(ov);
zones.push_back(z);
// Set server mask with bit 0 set
es.setServerMask({0x01}, true);
std::unordered_map<uint32_t, uint32_t> exploreFlagByAreaId;
exploreFlagByAreaId[100] = 0; // AreaID 100 → explore bit 0
glm::vec3 playerPos(0.0f, 0.0f, 0.0f);
es.update(zones, playerPos, 0, exploreFlagByAreaId);
// Zone should be explored since bit 0 is set in the mask
REQUIRE(es.exploredZones().count(0) == 1);
}

View file

@ -0,0 +1,212 @@
// Tests for the map_resolver module — centralized map navigation resolution.
#include <catch_amalgamated.hpp>
#include "rendering/world_map/map_resolver.hpp"
#include "rendering/world_map/world_map_types.hpp"
using namespace wowee::rendering::world_map;
// ── Helper: build minimal zones for testing ──────────────────
static Zone makeZone(uint32_t wmaID, uint32_t areaID,
const std::string& name = "",
uint32_t displayMapID = 0,
uint32_t parentWorldMapID = 0) {
Zone z;
z.wmaID = wmaID;
z.areaID = areaID;
z.areaName = name;
z.displayMapID = displayMapID;
z.parentWorldMapID = parentWorldMapID;
return z;
}
// Build zone list mimicking Azeroth (mapID=0) with root + leaf continents
static std::vector<Zone> buildAzerothZones() {
std::vector<Zone> zones;
// [0] Root continent (areaID=0, has children → isRootContinent)
zones.push_back(makeZone(1, 0, "Azeroth", 0, 0));
// [1] Leaf continent for EK (areaID=0, parentWorldMapID=1 → child of root)
zones.push_back(makeZone(2, 0, "EasternKingdoms", 0, 1));
// [2] Leaf continent for Kalimdor (shouldn't exist on mapID=0, but for testing)
zones.push_back(makeZone(3, 0, "Kalimdor", 1, 1));
// [3] Regular zone
zones.push_back(makeZone(10, 40, "Westfall", 0, 2));
// [4] Regular zone
zones.push_back(makeZone(11, 44, "Redridge", 0, 2));
return zones;
}
// Build zone list with only one continent (no leaf/root distinction)
static std::vector<Zone> buildSimpleZones() {
std::vector<Zone> zones;
// [0] Single continent entry
zones.push_back(makeZone(1, 0, "Kalimdor", 1, 0));
// [1] Zone
zones.push_back(makeZone(10, 331, "Ashenvale", 1, 1));
// [2] Zone
zones.push_back(makeZone(11, 400, "ThousandNeedles", 1, 1));
return zones;
}
// ── mapIdToFolder / folderToMapId / mapDisplayName ───────────
TEST_CASE("mapIdToFolder: known continent IDs",
"[world_map][map_resolver]") {
REQUIRE(std::string(mapIdToFolder(0)) == "Azeroth");
REQUIRE(std::string(mapIdToFolder(1)) == "Kalimdor");
REQUIRE(std::string(mapIdToFolder(530)) == "Expansion01");
REQUIRE(std::string(mapIdToFolder(571)) == "Northrend");
}
TEST_CASE("mapIdToFolder: special views",
"[world_map][map_resolver]") {
REQUIRE(std::string(mapIdToFolder(UINT32_MAX)) == "World");
REQUIRE(std::string(mapIdToFolder(UINT32_MAX - 1)) == "Cosmic");
}
TEST_CASE("mapIdToFolder: unknown returns empty",
"[world_map][map_resolver]") {
REQUIRE(std::string(mapIdToFolder(9999)) == "");
}
TEST_CASE("folderToMapId: case-insensitive lookup",
"[world_map][map_resolver]") {
REQUIRE(folderToMapId("Azeroth") == 0);
REQUIRE(folderToMapId("azeroth") == 0);
REQUIRE(folderToMapId("KALIMDOR") == 1);
REQUIRE(folderToMapId("Northrend") == 571);
REQUIRE(folderToMapId("world") == static_cast<int>(UINT32_MAX));
REQUIRE(folderToMapId("unknown") == -1);
}
TEST_CASE("mapDisplayName: returns UI labels",
"[world_map][map_resolver]") {
REQUIRE(std::string(mapDisplayName(0)) == "Eastern Kingdoms");
REQUIRE(std::string(mapDisplayName(1)) == "Kalimdor");
REQUIRE(std::string(mapDisplayName(571)) == "Northrend");
REQUIRE(mapDisplayName(9999) == nullptr);
}
// ── findContinentForMapId ────────────────────────────────────
TEST_CASE("findContinentForMapId: prefers leaf continent with matching displayMapID",
"[world_map][map_resolver]") {
auto zones = buildAzerothZones();
int idx = findContinentForMapId(zones, 0, -1);
REQUIRE(idx == 1);
}
TEST_CASE("findContinentForMapId: finds leaf by displayMapID=1 for Kalimdor",
"[world_map][map_resolver]") {
auto zones = buildAzerothZones();
int idx = findContinentForMapId(zones, 1, -1);
REQUIRE(idx == 2);
}
TEST_CASE("findContinentForMapId: falls back to first non-root continent",
"[world_map][map_resolver]") {
auto zones = buildSimpleZones();
int idx = findContinentForMapId(zones, 999, -1);
REQUIRE(idx == 0);
}
TEST_CASE("findContinentForMapId: skips cosmic zone index",
"[world_map][map_resolver]") {
auto zones = buildAzerothZones();
int idx = findContinentForMapId(zones, 0, 1);
REQUIRE(idx == 2);
}
TEST_CASE("findContinentForMapId: returns -1 for empty zones",
"[world_map][map_resolver]") {
std::vector<Zone> empty;
int idx = findContinentForMapId(empty, 0, -1);
REQUIRE(idx == -1);
}
// ── resolveWorldRegionClick ──────────────────────────────────
TEST_CASE("resolveWorldRegionClick: same map returns NAVIGATE_CONTINENT",
"[world_map][map_resolver]") {
auto zones = buildAzerothZones();
auto result = resolveWorldRegionClick(0, zones, 0, -1);
REQUIRE(result.action == MapResolveAction::NAVIGATE_CONTINENT);
REQUIRE(result.targetZoneIdx == 1);
}
TEST_CASE("resolveWorldRegionClick: different map returns LOAD_MAP",
"[world_map][map_resolver]") {
auto zones = buildAzerothZones();
auto result = resolveWorldRegionClick(1, zones, 0, -1);
REQUIRE(result.action == MapResolveAction::LOAD_MAP);
REQUIRE(result.targetMapName == "Kalimdor");
}
TEST_CASE("resolveWorldRegionClick: Northrend from Azeroth returns LOAD_MAP",
"[world_map][map_resolver]") {
auto zones = buildAzerothZones();
auto result = resolveWorldRegionClick(571, zones, 0, -1);
REQUIRE(result.action == MapResolveAction::LOAD_MAP);
REQUIRE(result.targetMapName == "Northrend");
}
TEST_CASE("resolveWorldRegionClick: unknown mapId returns NONE",
"[world_map][map_resolver]") {
auto zones = buildAzerothZones();
auto result = resolveWorldRegionClick(9999, zones, 0, -1);
REQUIRE(result.action == MapResolveAction::NONE);
}
// ── resolveZoneClick ─────────────────────────────────────────
TEST_CASE("resolveZoneClick: normal zone returns ENTER_ZONE",
"[world_map][map_resolver]") {
auto zones = buildAzerothZones();
auto result = resolveZoneClick(3, zones, 0);
REQUIRE(result.action == MapResolveAction::ENTER_ZONE);
REQUIRE(result.targetZoneIdx == 3);
}
TEST_CASE("resolveZoneClick: zone with different displayMapID returns LOAD_MAP",
"[world_map][map_resolver]") {
std::vector<Zone> zones;
zones.push_back(makeZone(1, 0, "Azeroth", 0, 0));
zones.push_back(makeZone(50, 100, "DarkPortal", 530, 1));
auto result = resolveZoneClick(1, zones, 0);
REQUIRE(result.action == MapResolveAction::LOAD_MAP);
REQUIRE(result.targetMapName == "Expansion01");
}
TEST_CASE("resolveZoneClick: zone with displayMapID matching current returns ENTER_ZONE",
"[world_map][map_resolver]") {
auto zones = buildSimpleZones();
auto result = resolveZoneClick(1, zones, 1);
REQUIRE(result.action == MapResolveAction::ENTER_ZONE);
REQUIRE(result.targetZoneIdx == 1);
}
TEST_CASE("resolveZoneClick: out of range returns NONE",
"[world_map][map_resolver]") {
auto zones = buildAzerothZones();
auto result = resolveZoneClick(-1, zones, 0);
REQUIRE(result.action == MapResolveAction::NONE);
result = resolveZoneClick(99, zones, 0);
REQUIRE(result.action == MapResolveAction::NONE);
}
// ── resolveCosmicClick ───────────────────────────────────────
TEST_CASE("resolveCosmicClick: returns LOAD_MAP for known mapId",
"[world_map][map_resolver]") {
auto result = resolveCosmicClick(530);
REQUIRE(result.action == MapResolveAction::LOAD_MAP);
REQUIRE(result.targetMapName == "Expansion01");
}
TEST_CASE("resolveCosmicClick: returns NONE for unknown mapId",
"[world_map][map_resolver]") {
auto result = resolveCosmicClick(9999);
REQUIRE(result.action == MapResolveAction::NONE);
}

View file

@ -0,0 +1,198 @@
// Tests for the extracted world map view state machine module
#include <catch_amalgamated.hpp>
#include "rendering/world_map/view_state_machine.hpp"
using namespace wowee::rendering::world_map;
TEST_CASE("ViewStateMachine: initial state is CONTINENT", "[world_map][view_state_machine]") {
ViewStateMachine sm;
REQUIRE(sm.currentLevel() == ViewLevel::CONTINENT);
REQUIRE(sm.continentIdx() == -1);
REQUIRE(sm.currentZoneIdx() == -1);
REQUIRE(sm.transition().active == false);
}
TEST_CASE("ViewStateMachine: zoomIn from CONTINENT to ZONE", "[world_map][view_state_machine]") {
ViewStateMachine sm;
sm.setLevel(ViewLevel::CONTINENT);
sm.setContinentIdx(0);
auto result = sm.zoomIn(5, 5);
REQUIRE(result.changed == true);
REQUIRE(result.newLevel == ViewLevel::ZONE);
REQUIRE(result.targetIdx == 5);
REQUIRE(sm.currentLevel() == ViewLevel::ZONE);
REQUIRE(sm.currentZoneIdx() == 5);
}
TEST_CASE("ViewStateMachine: zoomIn from CONTINENT with no zone does nothing", "[world_map][view_state_machine]") {
ViewStateMachine sm;
sm.setLevel(ViewLevel::CONTINENT);
sm.setContinentIdx(0);
auto result = sm.zoomIn(-1, -1);
REQUIRE(result.changed == false);
REQUIRE(sm.currentLevel() == ViewLevel::CONTINENT);
}
TEST_CASE("ViewStateMachine: zoomOut from ZONE to CONTINENT", "[world_map][view_state_machine]") {
ViewStateMachine sm;
sm.setLevel(ViewLevel::ZONE);
sm.setContinentIdx(0);
sm.setCurrentZoneIdx(5);
auto result = sm.zoomOut();
REQUIRE(result.changed == true);
REQUIRE(result.newLevel == ViewLevel::CONTINENT);
REQUIRE(result.targetIdx == 0);
REQUIRE(sm.currentLevel() == ViewLevel::CONTINENT);
}
TEST_CASE("ViewStateMachine: zoomOut from ZONE without continent does nothing", "[world_map][view_state_machine]") {
ViewStateMachine sm;
sm.setLevel(ViewLevel::ZONE);
sm.setContinentIdx(-1);
auto result = sm.zoomOut();
REQUIRE(result.changed == false);
REQUIRE(sm.currentLevel() == ViewLevel::ZONE);
}
TEST_CASE("ViewStateMachine: zoomOut from CONTINENT to WORLD", "[world_map][view_state_machine]") {
ViewStateMachine sm;
sm.setLevel(ViewLevel::CONTINENT);
auto result = sm.zoomOut();
REQUIRE(result.changed == true);
REQUIRE(result.newLevel == ViewLevel::WORLD);
REQUIRE(sm.currentLevel() == ViewLevel::WORLD);
}
TEST_CASE("ViewStateMachine: zoomOut from WORLD to COSMIC when enabled", "[world_map][view_state_machine]") {
ViewStateMachine sm;
sm.setLevel(ViewLevel::WORLD);
sm.setCosmicEnabled(true);
auto result = sm.zoomOut();
REQUIRE(result.changed == true);
REQUIRE(result.newLevel == ViewLevel::COSMIC);
}
TEST_CASE("ViewStateMachine: zoomOut from WORLD stays when cosmic disabled", "[world_map][view_state_machine]") {
ViewStateMachine sm;
sm.setLevel(ViewLevel::WORLD);
sm.setCosmicEnabled(false);
auto result = sm.zoomOut();
REQUIRE(result.changed == false);
REQUIRE(sm.currentLevel() == ViewLevel::WORLD);
}
TEST_CASE("ViewStateMachine: zoomIn from COSMIC goes to WORLD", "[world_map][view_state_machine]") {
ViewStateMachine sm;
sm.setLevel(ViewLevel::COSMIC);
sm.setCosmicEnabled(true);
auto result = sm.zoomIn(-1, -1);
REQUIRE(result.changed == true);
REQUIRE(result.newLevel == ViewLevel::WORLD);
}
TEST_CASE("ViewStateMachine: zoomIn from WORLD to CONTINENT with continent set", "[world_map][view_state_machine]") {
ViewStateMachine sm;
sm.setLevel(ViewLevel::WORLD);
sm.setContinentIdx(3);
auto result = sm.zoomIn(-1, -1);
REQUIRE(result.changed == true);
REQUIRE(result.newLevel == ViewLevel::CONTINENT);
REQUIRE(result.targetIdx == 3);
}
TEST_CASE("ViewStateMachine: enterWorldView sets WORLD level", "[world_map][view_state_machine]") {
ViewStateMachine sm;
sm.setLevel(ViewLevel::ZONE);
auto result = sm.enterWorldView();
REQUIRE(result.changed == true);
REQUIRE(result.newLevel == ViewLevel::WORLD);
REQUIRE(sm.currentLevel() == ViewLevel::WORLD);
}
TEST_CASE("ViewStateMachine: enterCosmicView when disabled falls back to WORLD", "[world_map][view_state_machine]") {
ViewStateMachine sm;
sm.setCosmicEnabled(false);
auto result = sm.enterCosmicView();
REQUIRE(result.newLevel == ViewLevel::WORLD);
}
TEST_CASE("ViewStateMachine: enterZone goes to ZONE level", "[world_map][view_state_machine]") {
ViewStateMachine sm;
sm.setLevel(ViewLevel::CONTINENT);
auto result = sm.enterZone(7);
REQUIRE(result.changed == true);
REQUIRE(result.newLevel == ViewLevel::ZONE);
REQUIRE(result.targetIdx == 7);
REQUIRE(sm.currentZoneIdx() == 7);
}
// ── Transition animation ─────────────────────────────────────
TEST_CASE("ViewStateMachine: transition starts on zoom", "[world_map][view_state_machine]") {
ViewStateMachine sm;
sm.setLevel(ViewLevel::CONTINENT);
sm.setContinentIdx(0);
sm.zoomIn(5, 5);
REQUIRE(sm.transition().active == true);
REQUIRE(sm.transition().progress == Catch::Approx(0.0f));
}
TEST_CASE("ViewStateMachine: updateTransition advances progress", "[world_map][view_state_machine]") {
ViewStateMachine sm;
sm.setLevel(ViewLevel::CONTINENT);
sm.setContinentIdx(0);
sm.zoomIn(5, 5);
float halfDuration = sm.transition().duration / 2.0f;
bool stillActive = sm.updateTransition(halfDuration);
REQUIRE(stillActive == true);
REQUIRE(sm.transition().progress == Catch::Approx(0.5f).margin(0.01f));
}
TEST_CASE("ViewStateMachine: transition completes after full duration", "[world_map][view_state_machine]") {
ViewStateMachine sm;
sm.setLevel(ViewLevel::CONTINENT);
sm.setContinentIdx(0);
sm.zoomIn(5, 5);
float dur = sm.transition().duration;
sm.updateTransition(dur + 0.1f); // overshoot
REQUIRE(sm.transition().active == false);
REQUIRE(sm.transition().progress == Catch::Approx(1.0f));
}
TEST_CASE("ViewStateMachine: updateTransition when no transition returns false", "[world_map][view_state_machine]") {
ViewStateMachine sm;
REQUIRE(sm.updateTransition(0.1f) == false);
}
TEST_CASE("ViewStateMachine: zoomIn prefers hovered zone over player zone", "[world_map][view_state_machine]") {
ViewStateMachine sm;
sm.setLevel(ViewLevel::CONTINENT);
sm.setContinentIdx(0);
auto result = sm.zoomIn(/*hovered=*/3, /*player=*/7);
REQUIRE(result.targetIdx == 3);
}
TEST_CASE("ViewStateMachine: zoomIn falls back to player zone when no hover", "[world_map][view_state_machine]") {
ViewStateMachine sm;
sm.setLevel(ViewLevel::CONTINENT);
sm.setContinentIdx(0);
auto result = sm.zoomIn(/*hovered=*/-1, /*player=*/7);
REQUIRE(result.targetIdx == 7);
}

View file

@ -0,0 +1,86 @@
// Tests for the extracted world map zone metadata module
#include <catch_amalgamated.hpp>
#include "rendering/world_map/zone_metadata.hpp"
#include "rendering/world_map/world_map_types.hpp"
#include <string>
using namespace wowee::rendering::world_map;
TEST_CASE("ZoneMetadata: find returns nullptr for unknown zone", "[world_map][zone_metadata]") {
ZoneMetadata zm;
zm.initialize();
REQUIRE(zm.find("NonexistentZoneXYZ") == nullptr);
}
TEST_CASE("ZoneMetadata: find returns valid data for known zones", "[world_map][zone_metadata]") {
ZoneMetadata zm;
zm.initialize();
const ZoneMeta* elwynn = zm.find("Elwynn");
REQUIRE(elwynn != nullptr);
REQUIRE(elwynn->minLevel > 0);
REQUIRE(elwynn->maxLevel >= elwynn->minLevel);
REQUIRE(elwynn->faction == ZoneFaction::Alliance);
}
TEST_CASE("ZoneMetadata: Contested zones", "[world_map][zone_metadata]") {
ZoneMetadata zm;
zm.initialize();
const ZoneMeta* sTV = zm.find("StranglethornVale");
REQUIRE(sTV != nullptr);
REQUIRE(sTV->faction == ZoneFaction::Contested);
}
TEST_CASE("ZoneMetadata: Horde zones", "[world_map][zone_metadata]") {
ZoneMetadata zm;
zm.initialize();
const ZoneMeta* durotar = zm.find("Durotar");
REQUIRE(durotar != nullptr);
REQUIRE(durotar->faction == ZoneFaction::Horde);
}
TEST_CASE("ZoneMetadata: formatLabel with no metadata", "[world_map][zone_metadata]") {
std::string label = ZoneMetadata::formatLabel("UnknownZone", nullptr);
REQUIRE(label == "UnknownZone");
}
TEST_CASE("ZoneMetadata: formatLabel with metadata", "[world_map][zone_metadata]") {
ZoneMeta meta;
meta.minLevel = 10;
meta.maxLevel = 20;
meta.faction = ZoneFaction::Alliance;
std::string label = ZoneMetadata::formatLabel("Elwynn", &meta);
// Should contain the zone name
REQUIRE(label.find("Elwynn") != std::string::npos);
}
TEST_CASE("ZoneMetadata: formatHoverLabel with metadata", "[world_map][zone_metadata]") {
ZoneMeta meta;
meta.minLevel = 30;
meta.maxLevel = 40;
meta.faction = ZoneFaction::Contested;
std::string label = ZoneMetadata::formatHoverLabel("StranglethornVale", &meta);
// Should contain both zone name and level range
REQUIRE(label.find("StranglethornVale") != std::string::npos);
REQUIRE(label.find("30") != std::string::npos);
REQUIRE(label.find("40") != std::string::npos);
}
TEST_CASE("ZoneMetadata: formatHoverLabel with no metadata just returns name", "[world_map][zone_metadata]") {
std::string label = ZoneMetadata::formatHoverLabel("UnknownZone", nullptr);
REQUIRE(label == "UnknownZone");
}
TEST_CASE("ZoneMetadata: double initialization is safe", "[world_map][zone_metadata]") {
ZoneMetadata zm;
zm.initialize();
zm.initialize(); // should not crash or change data
const ZoneMeta* elwynn = zm.find("Elwynn");
REQUIRE(elwynn != nullptr);
}