diff --git a/CMakeLists.txt b/CMakeLists.txt index 9eaa8148..66750846 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/assets/shaders/world_map_fog.frag.glsl b/assets/shaders/world_map_fog.frag.glsl new file mode 100644 index 00000000..312e6d4f --- /dev/null +++ b/assets/shaders/world_map_fog.frag.glsl @@ -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; +} diff --git a/include/core/world_loader.hpp b/include/core/world_loader.hpp index 01ce5483..5078cacf 100644 --- a/include/core/world_loader.hpp +++ b/include/core/world_loader.hpp @@ -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 diff --git a/include/rendering/renderer.hpp b/include/rendering/renderer.hpp index 56746125..d92fb76a 100644 --- a/include/rendering/renderer.hpp +++ b/include/rendering/renderer.hpp @@ -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; diff --git a/include/rendering/world_map.hpp b/include/rendering/world_map.hpp index fee98b6d..776262a5 100644 --- a/include/rendering/world_map.hpp +++ b/include/rendering/world_map.hpp @@ -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 -#include -#include -#include -#include -#include -#include -#include -#include +#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 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& masks, bool hasData); - void setPartyDots(std::vector dots) { partyDots_ = std::move(dots); } - void setTaxiNodes(std::vector 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 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 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 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> zoneTextures; - - // Party member dots (set each frame from the UI layer) - std::vector partyDots_; - - // Taxi node markers (set each frame from the UI layer) - std::vector taxiNodes_; - int currentMapId_ = -1; ///< WoW map ID currently loaded (set in loadZonesFromDBC) - - // Quest POI markers (set each frame from the UI layer) - std::vector questPois_; - - // Corpse marker (ghost state — set each frame from the UI layer) - bool hasCorpse_ = false; - glm::vec3 corpseRenderPos_ = {}; - - // Exploration / fog of war - std::vector serverExplorationMask; - bool hasServerExplorationMask = false; - std::unordered_set exploredZones; - // Locally accumulated exploration (used as fallback when server mask is unavailable) - std::unordered_set 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 diff --git a/include/rendering/world_map/composite_renderer.hpp b/include/rendering/world_map/composite_renderer.hpp new file mode 100644 index 00000000..2419ae0a --- /dev/null +++ b/include/rendering/world_map/composite_renderer.hpp @@ -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 +#include +#include +#include +#include +#include + +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& zones, const std::string& mapName); + + /// Load exploration overlay textures for a zone. + void loadOverlayTextures(int zoneIdx, std::vector& 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& zones, + const std::unordered_set& 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& 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(MAP_W) / static_cast(FBO_W); + static constexpr float MAP_V_MAX = static_cast(MAP_H) / static_cast(FBO_H); + +private: + VkContext* vkCtx = nullptr; + pipeline::AssetManager* assetManager = nullptr; + bool initialized = false; + + std::unique_ptr 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 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> 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 tiles; + bool tilesLoaded = false; + }; + std::vector overlays; + }; + std::vector zoneTextureSlots_; + + void ensureTextureSlots(size_t zoneCount, const std::vector& zones); +}; + +} // namespace world_map +} // namespace rendering +} // namespace wowee diff --git a/include/rendering/world_map/coordinate_projection.hpp b/include/rendering/world_map/coordinate_projection.hpp new file mode 100644 index 00000000..4aa4ab84 --- /dev/null +++ b/include/rendering/world_map/coordinate_projection.hpp @@ -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 +#include + +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& 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& 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& 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& 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& zones, int idx); + +/// Check whether the zone at idx is a leaf continent (parentWorldMapID != 0, areaID == 0). +bool isLeafContinent(const std::vector& zones, int idx); + +} // namespace world_map +} // namespace rendering +} // namespace wowee diff --git a/include/rendering/world_map/data_repository.hpp b/include/rendering/world_map/data_repository.hpp new file mode 100644 index 00000000..b2e04d9e --- /dev/null +++ b/include/rendering/world_map/data_repository.hpp @@ -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 +#include +#include +#include +#include + +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& zones() { return zones_; } + const std::vector& zones() const { return zones_; } + int cosmicIdx() const { return cosmicIdx_; } + int worldIdx() const { return worldIdx_; } + int currentMapId() const { return currentMapId_; } + const std::vector& cosmicMaps() const { return cosmicMaps_; } + const std::vector& azerothRegions() const { return azerothRegions_; } + bool cosmicEnabled() const { return cosmicEnabled_; } + const std::vector& poiMarkers() const { return poiMarkers_; } + + const std::unordered_map& exploreFlagByAreaId() const { return exploreFlagByAreaId_; } + const std::unordered_map& areaNameByAreaId() const { return areaNameByAreaId_; } + + /// ZMP pixel map accessors. + static constexpr int ZMP_SIZE = 128; + const std::array& 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& zmpZoneBounds() const { return zmpZoneBounds_; } + + /// Reset all data (called on map change). + void clear(); + +private: + std::vector zones_; + std::vector poiMarkers_; + std::vector cosmicMaps_; + std::vector azerothRegions_; + std::unordered_map exploreFlagByAreaId_; + std::unordered_map 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 zmpGrid_{}; + bool zmpLoaded_ = false; + // AreaID → zone index (zones_ vector) for quick resolution + std::unordered_map areaIdToZoneIdx_; + // ZMP-derived bounding boxes per zone index (UV coords on display) + std::unordered_map zmpZoneBounds_; + + /// Scan ZMP grid and build bounding boxes for each zone. + void buildZmpZoneBounds(); +}; + +} // namespace world_map +} // namespace rendering +} // namespace wowee diff --git a/include/rendering/world_map/exploration_state.hpp b/include/rendering/world_map/exploration_state.hpp new file mode 100644 index 00000000..fb214608 --- /dev/null +++ b/include/rendering/world_map/exploration_state.hpp @@ -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 +#include +#include +#include + +namespace wowee { +namespace rendering { +namespace world_map { + +class ExplorationState { +public: + void setServerMask(const std::vector& 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& zones, + const glm::vec3& playerRenderPos, + int currentZoneIdx, + const std::unordered_map& exploreFlagByAreaId); + + const std::unordered_set& exploredZones() const { return exploredZones_; } + const std::unordered_set& 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 serverMask_; + bool hasServerMask_ = false; + std::unordered_set exploredZones_; + std::unordered_set exploredOverlays_; + std::unordered_set locallyExploredZones_; + bool overlaysChanged_ = false; +}; + +} // namespace world_map +} // namespace rendering +} // namespace wowee diff --git a/include/rendering/world_map/input_handler.hpp b/include/rendering/world_map/input_handler.hpp new file mode 100644 index 00000000..3625a77a --- /dev/null +++ b/include/rendering/world_map/input_handler.hpp @@ -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 diff --git a/include/rendering/world_map/layers/coordinate_display.hpp b/include/rendering/world_map/layers/coordinate_display.hpp new file mode 100644 index 00000000..c3382681 --- /dev/null +++ b/include/rendering/world_map/layers/coordinate_display.hpp @@ -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 diff --git a/include/rendering/world_map/layers/corpse_marker_layer.hpp b/include/rendering/world_map/layers/corpse_marker_layer.hpp new file mode 100644 index 00000000..e3eebb68 --- /dev/null +++ b/include/rendering/world_map/layers/corpse_marker_layer.hpp @@ -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 + +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 diff --git a/include/rendering/world_map/layers/party_dot_layer.hpp b/include/rendering/world_map/layers/party_dot_layer.hpp new file mode 100644 index 00000000..210aff45 --- /dev/null +++ b/include/rendering/world_map/layers/party_dot_layer.hpp @@ -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 + +namespace wowee { +namespace rendering { +namespace world_map { + +class PartyDotLayer : public IOverlayLayer { +public: + void setDots(const std::vector& dots) { dots_ = &dots; } + void render(const LayerContext& ctx) override; +private: + const std::vector* dots_ = nullptr; +}; + +} // namespace world_map +} // namespace rendering +} // namespace wowee diff --git a/include/rendering/world_map/layers/player_marker_layer.hpp b/include/rendering/world_map/layers/player_marker_layer.hpp new file mode 100644 index 00000000..4b006c68 --- /dev/null +++ b/include/rendering/world_map/layers/player_marker_layer.hpp @@ -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 diff --git a/include/rendering/world_map/layers/poi_marker_layer.hpp b/include/rendering/world_map/layers/poi_marker_layer.hpp new file mode 100644 index 00000000..33657f27 --- /dev/null +++ b/include/rendering/world_map/layers/poi_marker_layer.hpp @@ -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 + +namespace wowee { +namespace rendering { +namespace world_map { + +class POIMarkerLayer : public IOverlayLayer { +public: + void setMarkers(const std::vector& markers) { markers_ = &markers; } + void render(const LayerContext& ctx) override; +private: + const std::vector* markers_ = nullptr; +}; + +} // namespace world_map +} // namespace rendering +} // namespace wowee diff --git a/include/rendering/world_map/layers/quest_poi_layer.hpp b/include/rendering/world_map/layers/quest_poi_layer.hpp new file mode 100644 index 00000000..c43e23f3 --- /dev/null +++ b/include/rendering/world_map/layers/quest_poi_layer.hpp @@ -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 + +namespace wowee { +namespace rendering { +namespace world_map { + +class QuestPOILayer : public IOverlayLayer { +public: + void setPois(const std::vector& pois) { pois_ = &pois; } + void render(const LayerContext& ctx) override; +private: + const std::vector* pois_ = nullptr; +}; + +} // namespace world_map +} // namespace rendering +} // namespace wowee diff --git a/include/rendering/world_map/layers/subzone_tooltip_layer.hpp b/include/rendering/world_map/layers/subzone_tooltip_layer.hpp new file mode 100644 index 00000000..d6f0b1bb --- /dev/null +++ b/include/rendering/world_map/layers/subzone_tooltip_layer.hpp @@ -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 diff --git a/include/rendering/world_map/layers/taxi_node_layer.hpp b/include/rendering/world_map/layers/taxi_node_layer.hpp new file mode 100644 index 00000000..70a2b48c --- /dev/null +++ b/include/rendering/world_map/layers/taxi_node_layer.hpp @@ -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 + +namespace wowee { +namespace rendering { +namespace world_map { + +class TaxiNodeLayer : public IOverlayLayer { +public: + void setNodes(const std::vector& nodes) { nodes_ = &nodes; } + void render(const LayerContext& ctx) override; +private: + const std::vector* nodes_ = nullptr; +}; + +} // namespace world_map +} // namespace rendering +} // namespace wowee diff --git a/include/rendering/world_map/layers/zone_highlight_layer.hpp b/include/rendering/world_map/layers/zone_highlight_layer.hpp new file mode 100644 index 00000000..93f50480 --- /dev/null +++ b/include/rendering/world_map/layers/zone_highlight_layer.hpp @@ -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 +#include +#include + +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 texture; + VkDescriptorSet imguiDS = VK_NULL_HANDLE; // ImGui texture ID + }; + std::unordered_map highlights_; + std::unordered_set missingHighlights_; // areas with no highlight file + + int hoveredZone_ = -1; + int prevHoveredZone_ = -1; + float hoverHighlightAlpha_ = 0.0f; +}; + +} // namespace world_map +} // namespace rendering +} // namespace wowee diff --git a/include/rendering/world_map/map_resolver.hpp b/include/rendering/world_map/map_resolver.hpp new file mode 100644 index 00000000..91a635c2 --- /dev/null +++ b/include/rendering/world_map/map_resolver.hpp @@ -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 +#include +#include + +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& 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& 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& zones, + uint32_t mapId, + int cosmicIdx); + +} // namespace world_map +} // namespace rendering +} // namespace wowee diff --git a/include/rendering/world_map/overlay_renderer.hpp b/include/rendering/world_map/overlay_renderer.hpp new file mode 100644 index 00000000..c3d40fed --- /dev/null +++ b/include/rendering/world_map/overlay_renderer.hpp @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include + +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* zones = nullptr; + const std::unordered_set* exploredZones = nullptr; + const std::unordered_set* exploredOverlays = nullptr; + const std::unordered_map* 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* 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* 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 layer); + void render(const LayerContext& ctx); + +private: + std::vector> layers_; +}; + +} // namespace world_map +} // namespace rendering +} // namespace wowee diff --git a/include/rendering/world_map/view_state_machine.hpp b/include/rendering/world_map/view_state_machine.hpp new file mode 100644 index 00000000..43fbdae9 --- /dev/null +++ b/include/rendering/world_map/view_state_machine.hpp @@ -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 diff --git a/include/rendering/world_map/world_map_facade.hpp b/include/rendering/world_map/world_map_facade.hpp new file mode 100644 index 00000000..50821a02 --- /dev/null +++ b/include/rendering/world_map/world_map_facade.hpp @@ -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 +#include +#include +#include +#include + +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& masks, bool hasData); + void setPartyDots(std::vector dots); + void setTaxiNodes(std::vector nodes); + void setQuestPois(std::vector pois); + void setCorpsePos(bool hasCorpse, glm::vec3 renderPos); + + bool isOpen() const; + void close(); + +private: + struct Impl; + std::unique_ptr 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 diff --git a/include/rendering/world_map/world_map_types.hpp b/include/rendering/world_map/world_map_types.hpp new file mode 100644 index 00000000..7db46fe0 --- /dev/null +++ b/include/rendering/world_map/world_map_types.hpp @@ -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 +#include +#include +#include + +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 exploreBits; // all AreaBit indices (zone + subzones) + std::vector 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 diff --git a/include/rendering/world_map/zone_metadata.hpp b/include/rendering/world_map/zone_metadata.hpp new file mode 100644 index 00000000..45adc64a --- /dev/null +++ b/include/rendering/world_map/zone_metadata.hpp @@ -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 +#include + +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 table_; +}; + +} // namespace world_map +} // namespace rendering +} // namespace wowee diff --git a/src/core/world_loader.cpp b/src/core/world_loader.cpp index 7d47cd7d..808c4fb0 100644 --- a/src/core/world_loader.cpp +++ b/src/core/world_loader.cpp @@ -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(id); + } + return -1; +} + void WorldLoader::processPendingEntry() { if (!pendingWorldEntry_ || loadingWorld_) return; auto entry = *pendingWorldEntry_; diff --git a/src/rendering/world_map.cpp b/src/rendering/world_map.cpp deleted file mode 100644 index 769bea70..00000000 --- a/src/rendering/world_map.cpp +++ /dev/null @@ -1,1360 +0,0 @@ -#include "rendering/world_map.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 "pipeline/dbc_layout.hpp" -#include "core/coordinates.hpp" -#include "core/input.hpp" -#include "core/logger.hpp" -#include "ui/ui_colors.hpp" -#include -#include -#include -#include -#include - -namespace wowee { -namespace rendering { - -namespace { -bool isRootContinent(const std::vector& zones, int idx) { - if (idx < 0 || idx >= static_cast(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& zones, int idx) { - if (idx < 0 || idx >= static_cast(zones.size())) return false; - const auto& c = zones[idx]; - if (c.areaID != 0) return false; - return c.parentWorldMapID != 0; -} -} // namespace - -// 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 - -WorldMap::WorldMap() = default; - -WorldMap::~WorldMap() { - shutdown(); -} - -bool WorldMap::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(); - if (!compositeTarget->create(*vkCtx, FBO_W, FBO_H)) { - LOG_ERROR("WorldMap: 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 (24 tile + 1 display = 25) --- - 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: 12*2 tile + 1 display = 25 --- - constexpr uint32_t totalSets = 25; - std::vector layouts(totalSets, samplerSetLayout); - VkDescriptorSetAllocateInfo allocInfo{}; - allocInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO; - allocInfo.descriptorPool = descPool; - allocInfo.descriptorSetCount = totalSets; - allocInfo.pSetLayouts = layouts.data(); - - VkDescriptorSet allSets[25]; - vkAllocateDescriptorSets(device, &allocInfo, allSets); - - for (int f = 0; f < 2; f++) - for (int t = 0; t < 12; t++) - tileDescSets[f][t] = allSets[f * 12 + t]; - imguiDisplaySet = allSets[24]; - - // --- 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 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("WorldMap: 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("WorldMap: failed to create tile pipeline"); - return false; - } - - initialized = true; - LOG_INFO("WorldMap initialized (", FBO_W, "x", FBO_H, " composite)"); - return true; -} - -void WorldMap::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 (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; } - - destroyZoneTextures(); - - if (compositeTarget) { compositeTarget->destroy(device, alloc); compositeTarget.reset(); } - - zones.clear(); - initialized = false; - vkCtx = nullptr; -} - -void WorldMap::destroyZoneTextures() { - 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& zone : zones) { - for (auto& tex : zone.tileTextures) tex = nullptr; - zone.tilesLoaded = false; - } -} - -void WorldMap::setMapName(const std::string& name) { - if (mapName == name && !zones.empty()) return; - mapName = name; - - destroyZoneTextures(); - zones.clear(); - continentIdx = -1; - currentIdx = -1; - compositedIdx = -1; - pendingCompositeIdx = -1; - viewLevel = ViewLevel::WORLD; -} - -void WorldMap::setServerExplorationMask(const std::vector& masks, bool hasData) { - if (!hasData || masks.empty()) { - // New session or no data yet — reset both server mask and local accumulation - if (hasServerExplorationMask) { - locallyExploredZones_.clear(); - } - hasServerExplorationMask = false; - serverExplorationMask.clear(); - return; - } - hasServerExplorationMask = true; - serverExplorationMask = masks; -} - -// -------------------------------------------------------- -// DBC zone loading (identical to GL version) -// -------------------------------------------------------- - -void WorldMap::loadZonesFromDBC() { - if (!zones.empty() || !assetManager) 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(mapDbc->getUInt32(i, mapL ? (*mapL)["ID"] : 0)); - LOG_INFO("WorldMap: Map.dbc '", mapName, "' -> mapID=", mapID); - break; - } - } - } - - if (mapID < 0) { - if (mapName == "Azeroth") mapID = 0; - else if (mapName == "Kalimdor") mapID = 1; - else if (mapName == "Expansion01") mapID = 530; - else if (mapName == "Northrend") mapID = 571; - else { - LOG_WARNING("WorldMap: 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. - // Incorrect field indices silently return wrong data, so these defaults must match - // the most common AreaTable.dbc layout to minimize breakage. - const auto* atL = activeLayout ? activeLayout->getLayout("AreaTable") : nullptr; - std::unordered_map exploreFlagByAreaId; - std::unordered_map> childBitsByParent; - auto areaDbc = assetManager->loadDBC("AreaTable.dbc"); - if (areaDbc && areaDbc->isLoaded() && areaDbc->getFieldCount() > 3) { - 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); - } - } - - auto wmaDbc = assetManager->loadDBC("WorldMapArea.dbc"); - if (!wmaDbc || !wmaDbc->isLoaded()) { - LOG_WARNING("WorldMap: WorldMapArea.dbc not found"); - return; - } - - const auto* wmaL = activeLayout ? activeLayout->getLayout("WorldMapArea") : nullptr; - - for (uint32_t i = 0; i < wmaDbc->getRecordCount(); i++) { - uint32_t recMapID = wmaDbc->getUInt32(i, wmaL ? (*wmaL)["MapID"] : 1); - if (static_cast(recMapID) != mapID) continue; - - WorldMapZone 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.locLeft = wmaDbc->getFloat(i, wmaL ? (*wmaL)["LocLeft"] : 4); - zone.locRight = wmaDbc->getFloat(i, wmaL ? (*wmaL)["LocRight"] : 5); - zone.locTop = wmaDbc->getFloat(i, wmaL ? (*wmaL)["LocTop"] : 6); - zone.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(zones.size()); - - LOG_INFO("WorldMap: zone[", idx, "] areaID=", zone.areaID, - " '", zone.areaName, "' L=", zone.locLeft, - " R=", zone.locRight, " T=", zone.locTop, - " B=", zone.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(zones.size()); ci++) { - auto& cont = zones[ci]; - if (cont.areaID != 0) continue; - if (std::abs(cont.locLeft) > 0.001f || std::abs(cont.locRight) > 0.001f || - std::abs(cont.locTop) > 0.001f || std::abs(cont.locBottom) > 0.001f) - continue; - - bool first = true; - for (const auto& z : zones) { - if (z.areaID == 0) continue; - if (std::abs(z.locLeft - z.locRight) < 0.001f || - std::abs(z.locTop - z.locBottom) < 0.001f) - continue; - if (z.parentWorldMapID != 0 && cont.wmaID != 0 && z.parentWorldMapID != cont.wmaID) - continue; - - if (first) { - cont.locLeft = z.locLeft; cont.locRight = z.locRight; - cont.locTop = z.locTop; cont.locBottom = z.locBottom; - first = false; - } else { - cont.locLeft = std::min(cont.locLeft, z.locLeft); - cont.locRight = std::max(cont.locRight, z.locRight); - cont.locTop = std::min(cont.locTop, z.locTop); - cont.locBottom = std::max(cont.locBottom, z.locBottom); - } - } - } - - currentMapId_ = mapID; - LOG_INFO("WorldMap: loaded ", zones.size(), " zones for mapID=", mapID, - ", continentIdx=", continentIdx); -} - -int WorldMap::findBestContinentForPlayer(const glm::vec3& playerRenderPos) const { - float wowX = playerRenderPos.y; - float wowY = playerRenderPos.x; - - int bestIdx = -1; - float bestArea = std::numeric_limits::max(); - float bestCenterDist2 = std::numeric_limits::max(); - - bool hasLeafContinent = false; - for (int i = 0; i < static_cast(zones.size()); i++) { - if (zones[i].areaID == 0 && !isRootContinent(zones, i)) { - hasLeafContinent = true; - break; - } - } - - for (int i = 0; i < static_cast(zones.size()); i++) { - const auto& z = zones[i]; - if (z.areaID != 0) continue; - if (hasLeafContinent && isRootContinent(zones, i)) continue; - - float minX = std::min(z.locLeft, z.locRight); - float maxX = std::max(z.locLeft, z.locRight); - float minY = std::min(z.locTop, z.locBottom); - float maxY = std::max(z.locTop, z.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 WorldMap::findZoneForPlayer(const glm::vec3& playerRenderPos) const { - float wowX = playerRenderPos.y; - float wowY = playerRenderPos.x; - - int bestIdx = -1; - float bestArea = std::numeric_limits::max(); - - for (int i = 0; i < static_cast(zones.size()); i++) { - const auto& z = zones[i]; - if (z.areaID == 0) continue; - - float minX = std::min(z.locLeft, z.locRight); - float maxX = std::max(z.locLeft, z.locRight); - float minY = std::min(z.locTop, z.locBottom); - float maxY = std::max(z.locTop, z.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; -} - -bool WorldMap::zoneBelongsToContinent(int zoneIdx, int contIdx) const { - if (zoneIdx < 0 || zoneIdx >= static_cast(zones.size())) return false; - if (contIdx < 0 || contIdx >= static_cast(zones.size())) return false; - - const auto& z = zones[zoneIdx]; - const auto& cont = zones[contIdx]; - if (z.areaID == 0) return false; - - if (z.parentWorldMapID != 0 && cont.wmaID != 0) - return z.parentWorldMapID == cont.wmaID; - - auto rectMinX = [](const WorldMapZone& a) { return std::min(a.locLeft, a.locRight); }; - auto rectMaxX = [](const WorldMapZone& a) { return std::max(a.locLeft, a.locRight); }; - auto rectMinY = [](const WorldMapZone& a) { return std::min(a.locTop, a.locBottom); }; - auto rectMaxY = [](const WorldMapZone& a) { return std::max(a.locTop, a.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(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.locLeft + z.locRight) * 0.5f; - float centerY = (z.locTop + z.locBottom) * 0.5f; - return centerX >= rectMinX(cont) && centerX <= rectMaxX(cont) && - centerY >= rectMinY(cont) && centerY <= rectMaxY(cont); -} - -bool WorldMap::getContinentProjectionBounds(int contIdx, float& left, float& right, - float& top, float& bottom) const { - if (contIdx < 0 || contIdx >= static_cast(zones.size())) return false; - const auto& cont = zones[contIdx]; - if (cont.areaID != 0) return false; - - if (std::abs(cont.locLeft - cont.locRight) > 0.001f && - std::abs(cont.locTop - cont.locBottom) > 0.001f) { - left = cont.locLeft; right = cont.locRight; - top = cont.locTop; bottom = cont.locBottom; - return true; - } - - std::vector northEdges, southEdges, westEdges, eastEdges; - for (int zi = 0; zi < static_cast(zones.size()); zi++) { - if (!zoneBelongsToContinent(zi, contIdx)) continue; - const auto& z = zones[zi]; - if (std::abs(z.locLeft - z.locRight) < 0.001f || - std::abs(z.locTop - z.locBottom) < 0.001f) continue; - northEdges.push_back(std::max(z.locLeft, z.locRight)); - southEdges.push_back(std::min(z.locLeft, z.locRight)); - westEdges.push_back(std::max(z.locTop, z.locBottom)); - eastEdges.push_back(std::min(z.locTop, z.locBottom)); - } - - if (northEdges.size() < 3) { - left = cont.locLeft; right = cont.locRight; - top = cont.locTop; bottom = cont.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.locLeft; right = cont.locRight; - top = cont.locTop; bottom = cont.locBottom; - } - return std::abs(left - right) > 0.001f && std::abs(top - bottom) > 0.001f; -} - -// -------------------------------------------------------- -// Per-zone texture loading (Vulkan) -// -------------------------------------------------------- - -void WorldMap::loadZoneTextures(int zoneIdx) { - if (zoneIdx < 0 || zoneIdx >= static_cast(zones.size())) return; - auto& zone = zones[zoneIdx]; - if (zone.tilesLoaded) return; - zone.tilesLoaded = true; - - const std::string& folder = zone.areaName; - if (folder.empty()) return; - - std::vector candidateFolders; - candidateFolders.push_back(folder); - if (zone.areaID == 0 && mapName == "Azeroth") { - if (folder != "Azeroth") candidateFolders.push_back("Azeroth"); - if (folder != "EasternKingdoms") candidateFolders.push_back("EasternKingdoms"); - } - - VkDevice device = vkCtx->getDevice(); - int loaded = 0; - - for (int i = 0; i < 12; i++) { - pipeline::BLPImage blpImage; - bool found = false; - for (const auto& testFolder : candidateFolders) { - std::string path = "Interface\\WorldMap\\" + testFolder + "\\" + - testFolder + std::to_string(i + 1) + ".blp"; - blpImage = assetManager->loadTexture(path); - if (blpImage.isValid()) { found = true; break; } - } - - if (!found) { - zone.tileTextures[i] = nullptr; - continue; - } - - auto tex = std::make_unique(); - 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); - - zone.tileTextures[i] = tex.get(); - zoneTextures.push_back(std::move(tex)); - loaded++; - } - - LOG_INFO("WorldMap: loaded ", loaded, "/12 tiles for '", folder, "'"); -} - -// -------------------------------------------------------- -// Request composite (deferred to compositePass) -// -------------------------------------------------------- - -void WorldMap::requestComposite(int zoneIdx) { - if (zoneIdx < 0 || zoneIdx >= static_cast(zones.size())) return; - pendingCompositeIdx = zoneIdx; -} - -// -------------------------------------------------------- -// Off-screen composite pass (call BEFORE main render pass) -// -------------------------------------------------------- - -void WorldMap::compositePass(VkCommandBuffer cmd) { - if (!initialized || pendingCompositeIdx < 0 || !compositeTarget) return; - if (pendingCompositeIdx >= static_cast(zones.size())) { - pendingCompositeIdx = -1; - return; - } - - int zoneIdx = pendingCompositeIdx; - pendingCompositeIdx = -1; - - if (compositedIdx == zoneIdx) return; - - const auto& zone = zones[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 = zone.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); - - vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, tilePipeline); - - VkDeviceSize offset = 0; - vkCmdBindVertexBuffers(cmd, 0, 1, &quadVB, &offset); - - // Draw 4x3 tile grid - for (int i = 0; i < 12; i++) { - if (!zone.tileTextures[i] || !zone.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(col), static_cast(row)); - push.gridCols = static_cast(GRID_COLS); - push.gridRows = static_cast(GRID_ROWS); - vkCmdPushConstants(cmd, tilePipelineLayout, VK_SHADER_STAGE_VERTEX_BIT, - 0, sizeof(push), &push); - - vkCmdDraw(cmd, 6, 1, 0, 0); - } - - compositeTarget->endPass(cmd); - compositedIdx = zoneIdx; -} - -void WorldMap::enterWorldView() { - viewLevel = ViewLevel::WORLD; - - int rootIdx = -1; - for (int i = 0; i < static_cast(zones.size()); i++) { - if (isRootContinent(zones, i)) { rootIdx = i; break; } - } - - if (rootIdx >= 0) { - loadZoneTextures(rootIdx); - bool hasAnyTile = false; - for (VkTexture* tex : zones[rootIdx].tileTextures) { - if (tex != nullptr) { hasAnyTile = true; break; } - } - if (hasAnyTile) { - requestComposite(rootIdx); - currentIdx = rootIdx; - return; - } - } - - int fallbackContinent = -1; - for (int i = 0; i < static_cast(zones.size()); i++) { - if (isLeafContinent(zones, i)) { fallbackContinent = i; break; } - } - if (fallbackContinent < 0) { - for (int i = 0; i < static_cast(zones.size()); i++) { - if (zones[i].areaID == 0 && !isRootContinent(zones, i)) { - fallbackContinent = i; break; - } - } - } - if (fallbackContinent >= 0) { - loadZoneTextures(fallbackContinent); - requestComposite(fallbackContinent); - currentIdx = fallbackContinent; - return; - } - - currentIdx = -1; - compositedIdx = -1; - // Render target will be cleared by next compositePass - pendingCompositeIdx = -2; // Signal "clear only" -} - -// -------------------------------------------------------- -// Coordinate projection -// -------------------------------------------------------- - -glm::vec2 WorldMap::renderPosToMapUV(const glm::vec3& renderPos, int zoneIdx) const { - if (zoneIdx < 0 || zoneIdx >= static_cast(zones.size())) - return glm::vec2(0.5f, 0.5f); - - const auto& zone = zones[zoneIdx]; - float wowX = renderPos.y; - float wowY = renderPos.x; - - float left = zone.locLeft, right = zone.locRight; - float top = zone.locTop, bottom = zone.locBottom; - if (zone.areaID == 0) { - float l, r, t, b; - if (getContinentProjectionBounds(zoneIdx, l, r, t, b)) { - left = l; right = r; top = t; bottom = b; - } - } - - float denom_h = left - right; - float denom_v = top - bottom; - if (std::abs(denom_h) < 0.001f || std::abs(denom_v) < 0.001f) - return glm::vec2(0.5f, 0.5f); - - float u = (left - wowX) / denom_h; - float v = (top - wowY) / denom_v; - - if (zone.areaID == 0) { - constexpr float kVScale = 1.0f; - constexpr float kVOffset = -0.15f; - v = (v - 0.5f) * kVScale + 0.5f + kVOffset; - } - return glm::vec2(u, v); -} - -// -------------------------------------------------------- -// Exploration tracking (identical to GL version) -// -------------------------------------------------------- - -void WorldMap::updateExploration(const glm::vec3& playerRenderPos) { - auto isBitSet = [this](uint32_t bitIndex) -> bool { - if (!hasServerExplorationMask || serverExplorationMask.empty()) return false; - const size_t word = bitIndex / 32; - if (word >= serverExplorationMask.size()) return false; - return (serverExplorationMask[word] & (1u << (bitIndex % 32))) != 0; - }; - - if (hasServerExplorationMask) { - exploredZones.clear(); - for (int i = 0; i < static_cast(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; - } - } - } - // Always trust the server mask when available — even if empty (unexplored character). - // 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 (the server bit arrives on the next update). - int curZone = findZoneForPlayer(playerRenderPos); - if (curZone >= 0) exploredZones.insert(curZone); - return; - } - - // Server mask unavailable — fall back to locally-accumulated position tracking. - // Add the zone the player is currently in to the local set and display that. - float wowX = playerRenderPos.y; - float wowY = playerRenderPos.x; - - bool foundPos = false; - for (int i = 0; i < static_cast(zones.size()); i++) { - const auto& z = zones[i]; - if (z.areaID == 0) continue; - float minX = std::min(z.locLeft, z.locRight), maxX = std::max(z.locLeft, z.locRight); - float minY = std::min(z.locTop, z.locBottom), maxY = std::max(z.locTop, z.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(playerRenderPos); - if (zoneIdx >= 0) locallyExploredZones_.insert(zoneIdx); - } - - // Display the accumulated local set - exploredZones = locallyExploredZones_; -} - -void WorldMap::zoomIn(const glm::vec3& playerRenderPos) { - if (viewLevel == ViewLevel::WORLD) { - if (continentIdx >= 0) { - loadZoneTextures(continentIdx); - requestComposite(continentIdx); - currentIdx = continentIdx; - viewLevel = ViewLevel::CONTINENT; - } - } else if (viewLevel == ViewLevel::CONTINENT) { - int zoneIdx = findZoneForPlayer(playerRenderPos); - if (zoneIdx >= 0 && zoneBelongsToContinent(zoneIdx, continentIdx)) { - loadZoneTextures(zoneIdx); - requestComposite(zoneIdx); - currentIdx = zoneIdx; - viewLevel = ViewLevel::ZONE; - } - } -} - -void WorldMap::zoomOut() { - if (viewLevel == ViewLevel::ZONE) { - if (continentIdx >= 0) { - requestComposite(continentIdx); - currentIdx = continentIdx; - viewLevel = ViewLevel::CONTINENT; - } - } else if (viewLevel == ViewLevel::CONTINENT) { - enterWorldView(); - } -} - -// -------------------------------------------------------- -// Main render (input + ImGui overlay) -// -------------------------------------------------------- - -void WorldMap::render(const glm::vec3& playerRenderPos, int screenWidth, int screenHeight, - float playerYawDeg) { - if (!initialized || !assetManager) return; - - auto& input = core::Input::getInstance(); - - if (!zones.empty()) updateExploration(playerRenderPos); - - // game_screen owns the open/close toggle (via showWorldMap_ + TOGGLE_WORLD_MAP keybinding). - // render() is only called when showWorldMap_ is true, so treat each call as "should be open". - if (!open) { - // First time shown: load zones and navigate to player's location. - open = true; - if (zones.empty()) loadZonesFromDBC(); - - int bestContinent = findBestContinentForPlayer(playerRenderPos); - if (bestContinent >= 0 && bestContinent != continentIdx) { - continentIdx = bestContinent; - compositedIdx = -1; - } - - int playerZone = findZoneForPlayer(playerRenderPos); - if (playerZone >= 0 && continentIdx >= 0 && - zoneBelongsToContinent(playerZone, continentIdx)) { - loadZoneTextures(playerZone); - requestComposite(playerZone); - currentIdx = playerZone; - viewLevel = ViewLevel::ZONE; - } else if (continentIdx >= 0) { - loadZoneTextures(continentIdx); - requestComposite(continentIdx); - currentIdx = continentIdx; - viewLevel = ViewLevel::CONTINENT; - } - } - - // ESC closes the map; game_screen will sync showWorldMap_ via wm->isOpen() next frame. - if (input.isKeyJustPressed(SDL_SCANCODE_ESCAPE)) { - open = false; - return; - } - - { - auto& io = ImGui::GetIO(); - float wheelDelta = io.MouseWheel; - if (std::abs(wheelDelta) < 0.001f) - wheelDelta = input.getMouseWheelDelta(); - if (wheelDelta > 0.0f) zoomIn(playerRenderPos); - else if (wheelDelta < 0.0f) zoomOut(); - } - - if (!open) return; - renderImGuiOverlay(playerRenderPos, screenWidth, screenHeight, playerYawDeg); -} - -// -------------------------------------------------------- -// ImGui overlay -// -------------------------------------------------------- - -void WorldMap::renderImGuiOverlay(const glm::vec3& playerRenderPos, int screenWidth, int screenHeight, float playerYawDeg) { - float sw = static_cast(screenWidth); - float sh = static_cast(screenHeight); - - ImGui::SetNextWindowPos(ImVec2(0, 0)); - ImGui::SetNextWindowSize(ImVec2(sw, sh)); - - ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | - ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | - ImGuiWindowFlags_NoScrollWithMouse | ImGuiWindowFlags_NoCollapse | - ImGuiWindowFlags_NoBringToFrontOnFocus | - ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoFocusOnAppearing; - - ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.0f, 0.0f, 0.0f, 0.75f)); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0)); - - if (ImGui::Begin("##WorldMap", nullptr, flags)) { - float mapAspect = static_cast(FBO_W) / static_cast(FBO_H); - float availW = sw * 0.85f; - float availH = sh * 0.85f; - float displayW, displayH; - if (availW / availH > mapAspect) { - displayH = availH; - displayW = availH * mapAspect; - } else { - displayW = availW; - displayH = availW / mapAspect; - } - - float mapX = (sw - displayW) / 2.0f; - float mapY = (sh - displayH) / 2.0f; - - ImGui::SetCursorPos(ImVec2(mapX, mapY)); - // Display composite render target via ImGui (VkDescriptorSet as ImTextureID) - ImGui::Image(reinterpret_cast(imguiDisplaySet), - ImVec2(displayW, displayH), ImVec2(0, 0), ImVec2(1, 1)); - - ImVec2 imgMin = ImGui::GetItemRectMin(); - ImDrawList* drawList = ImGui::GetWindowDrawList(); - - std::vector continentIndices; - bool hasLeafContinents = false; - for (int i = 0; i < static_cast(zones.size()); i++) { - if (isLeafContinent(zones, i)) { hasLeafContinents = true; break; } - } - for (int i = 0; i < static_cast(zones.size()); i++) { - if (zones[i].areaID != 0) continue; - if (hasLeafContinents) { - if (isLeafContinent(zones, i)) continentIndices.push_back(i); - } else if (!isRootContinent(zones, i)) { - continentIndices.push_back(i); - } - } - if (continentIndices.size() > 1) { - std::vector filtered; - filtered.reserve(continentIndices.size()); - for (int idx : continentIndices) { - if (zones[idx].areaName == mapName) continue; - filtered.push_back(idx); - } - if (!filtered.empty()) continentIndices = std::move(filtered); - } - if (continentIndices.empty()) { - for (int i = 0; i < static_cast(zones.size()); i++) { - if (zones[i].areaID == 0) continentIndices.push_back(i); - } - } - - // World-level continent selection UI - if (viewLevel == ViewLevel::WORLD && !continentIndices.empty()) { - ImVec2 titleSz = ImGui::CalcTextSize("World"); - ImGui::SetCursorPos(ImVec2((sw - titleSz.x) * 0.5f, mapY + 8.0f)); - ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 0.95f), "World"); - - ImGui::SetCursorPos(ImVec2(mapX + 8.0f, mapY + 32.0f)); - for (size_t i = 0; i < continentIndices.size(); i++) { - int ci = continentIndices[i]; - if (i > 0) ImGui::SameLine(); - const bool selected = (ci == continentIdx); - if (selected) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.35f, 0.25f, 0.05f, 0.9f)); - - std::string rawName = zones[ci].areaName.empty() ? "Continent" : zones[ci].areaName; - if (rawName == "Azeroth") rawName = "Eastern Kingdoms"; - std::string label = rawName + "##" + std::to_string(ci); - if (ImGui::Button(label.c_str())) { - continentIdx = ci; - loadZoneTextures(continentIdx); - requestComposite(continentIdx); - currentIdx = continentIdx; - viewLevel = ViewLevel::CONTINENT; - } - if (selected) ImGui::PopStyleColor(); - } - } else if (viewLevel == ViewLevel::CONTINENT && continentIndices.size() > 1) { - ImGui::SetCursorPos(ImVec2(mapX + 8.0f, mapY + 8.0f)); - for (size_t i = 0; i < continentIndices.size(); i++) { - int ci = continentIndices[i]; - if (i > 0) ImGui::SameLine(); - const bool selected = (ci == continentIdx); - if (selected) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.35f, 0.25f, 0.05f, 0.9f)); - - std::string rawName = zones[ci].areaName.empty() ? "Continent" : zones[ci].areaName; - if (rawName == "Azeroth") rawName = "Eastern Kingdoms"; - std::string label = rawName + "##" + std::to_string(ci); - if (ImGui::Button(label.c_str())) { - continentIdx = ci; - loadZoneTextures(continentIdx); - requestComposite(continentIdx); - currentIdx = continentIdx; - } - if (selected) ImGui::PopStyleColor(); - } - } - - // Player marker - if (currentIdx >= 0 && viewLevel != ViewLevel::WORLD) { - glm::vec2 playerUV = renderPosToMapUV(playerRenderPos, currentIdx); - if (playerUV.x >= 0.0f && playerUV.x <= 1.0f && - playerUV.y >= 0.0f && playerUV.y <= 1.0f) { - float px = imgMin.x + playerUV.x * displayW; - float py = imgMin.y + playerUV.y * displayH; - // Directional arrow: render-space (cos,sin) maps to screen (-dx,-dy) - // because render+X=west=left and render+Y=north=up (screen Y is down). - float yawRad = glm::radians(playerYawDeg); - float adx = -std::cos(yawRad); // screen-space arrow X - float ady = -std::sin(yawRad); // screen-space arrow Y - float apx = -ady, apy = adx; // perpendicular (left/right of arrow) - constexpr float TIP = 9.0f; // tip distance from center - constexpr float TAIL = 4.0f; // tail distance from center - constexpr float HALF = 5.0f; // half base width - 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); - drawList->AddTriangleFilled(tip, bl, br, IM_COL32(255, 40, 40, 255)); - drawList->AddTriangle(tip, bl, br, IM_COL32(0, 0, 0, 200), 1.5f); - } - } - - // Party member dots - if (currentIdx >= 0 && viewLevel != ViewLevel::WORLD) { - ImFont* font = ImGui::GetFont(); - for (const auto& dot : partyDots_) { - glm::vec2 uv = renderPosToMapUV(dot.renderPos, currentIdx); - if (uv.x < 0.0f || uv.x > 1.0f || uv.y < 0.0f || uv.y > 1.0f) continue; - float px = imgMin.x + uv.x * displayW; - float py = imgMin.y + uv.y * displayH; - drawList->AddCircleFilled(ImVec2(px, py), 5.0f, dot.color); - drawList->AddCircle(ImVec2(px, py), 5.0f, IM_COL32(0, 0, 0, 200), 0, 1.5f); - // Name tooltip on hover - if (!dot.name.empty()) { - ImVec2 mp = ImGui::GetMousePos(); - float dx = mp.x - px, dy = mp.y - py; - if (dx * dx + dy * dy <= 49.0f) { // radius 7 px hit area - ImGui::SetTooltip("%s", dot.name.c_str()); - } - // Draw name label above the dot - 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; - drawList->AddText(ImVec2(tx + 1.0f, ty + 1.0f), IM_COL32(0, 0, 0, 180), dot.name.c_str()); - drawList->AddText(ImVec2(tx, ty), IM_COL32(255, 255, 255, 220), dot.name.c_str()); - } - } - } - - // Taxi node markers — flight master icons on the map - if (currentIdx >= 0 && viewLevel != ViewLevel::WORLD && !taxiNodes_.empty()) { - ImVec2 mp = ImGui::GetMousePos(); - for (const auto& node : taxiNodes_) { - if (!node.known) continue; - if (static_cast(node.mapId) != currentMapId_) continue; - - glm::vec3 rPos = core::coords::canonicalToRender( - glm::vec3(node.wowX, node.wowY, node.wowZ)); - glm::vec2 uv = renderPosToMapUV(rPos, currentIdx); - if (uv.x < 0.0f || uv.x > 1.0f || uv.y < 0.0f || uv.y > 1.0f) continue; - - float px = imgMin.x + uv.x * displayW; - float py = imgMin.y + uv.y * displayH; - - // Flight-master icon: yellow diamond with dark border - constexpr float H = 5.0f; // half-size of diamond - ImVec2 top2(px, py - H); - ImVec2 right2(px + H, py ); - ImVec2 bot2(px, py + H); - ImVec2 left2(px - H, py ); - drawList->AddQuadFilled(top2, right2, bot2, left2, - IM_COL32(255, 215, 0, 230)); - drawList->AddQuad(top2, right2, bot2, left2, - IM_COL32(80, 50, 0, 200), 1.2f); - - // Tooltip on hover - 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()); - } - } - } - } - - // Quest POI markers — golden exclamation marks / question marks - if (currentIdx >= 0 && viewLevel != ViewLevel::WORLD && !questPois_.empty()) { - ImVec2 mp = ImGui::GetMousePos(); - ImFont* qFont = ImGui::GetFont(); - for (const auto& qp : questPois_) { - glm::vec3 rPos = core::coords::canonicalToRender( - glm::vec3(qp.wowX, qp.wowY, 0.0f)); - glm::vec2 uv = renderPosToMapUV(rPos, currentIdx); - if (uv.x < 0.0f || uv.x > 1.0f || uv.y < 0.0f || uv.y > 1.0f) continue; - - float px = imgMin.x + uv.x * displayW; - float py = imgMin.y + uv.y * displayH; - - // Cyan circle with golden ring (matches minimap POI style) - drawList->AddCircleFilled(ImVec2(px, py), 5.0f, IM_COL32(0, 210, 255, 220)); - drawList->AddCircle(ImVec2(px, py), 5.0f, IM_COL32(255, 215, 0, 220), 0, 1.5f); - - // Quest name label - 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; - drawList->AddText(qFont, ImGui::GetFontSize() * 0.85f, - ImVec2(tx + 1.0f, ty + 1.0f), IM_COL32(0, 0, 0, 180), qp.name.c_str()); - drawList->AddText(qFont, ImGui::GetFontSize() * 0.85f, - ImVec2(tx, ty), IM_COL32(255, 230, 100, 230), qp.name.c_str()); - } - // Tooltip on hover - 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()); - } - } - } - - // Corpse marker — skull X shown when player is a ghost with unclaimed corpse - if (hasCorpse_ && currentIdx >= 0 && viewLevel != ViewLevel::WORLD) { - glm::vec2 uv = renderPosToMapUV(corpseRenderPos_, currentIdx); - if (uv.x >= 0.0f && uv.x <= 1.0f && uv.y >= 0.0f && uv.y <= 1.0f) { - float cx = imgMin.x + uv.x * displayW; - float cy = imgMin.y + uv.y * displayH; - constexpr float R = 5.0f; // cross arm half-length - constexpr float T = 1.8f; // line thickness - // Dark outline - drawList->AddLine(ImVec2(cx - R, cy - R), ImVec2(cx + R, cy + R), - IM_COL32(0, 0, 0, 220), T + 1.5f); - drawList->AddLine(ImVec2(cx + R, cy - R), ImVec2(cx - R, cy + R), - IM_COL32(0, 0, 0, 220), T + 1.5f); - // Bone-white X - drawList->AddLine(ImVec2(cx - R, cy - R), ImVec2(cx + R, cy + R), - IM_COL32(230, 220, 200, 240), T); - 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"); - } - } - } - - // Hover coordinate display — show WoW coordinates under cursor - if (currentIdx >= 0 && viewLevel != ViewLevel::WORLD) { - auto& io = ImGui::GetIO(); - ImVec2 mp = io.MousePos; - if (mp.x >= imgMin.x && mp.x <= imgMin.x + displayW && - mp.y >= imgMin.y && mp.y <= imgMin.y + displayH) { - float mu = (mp.x - imgMin.x) / displayW; - float mv = (mp.y - imgMin.y) / displayH; - - const auto& zone = zones[currentIdx]; - float left = zone.locLeft, right = zone.locRight; - float top = zone.locTop, bottom = zone.locBottom; - if (zone.areaID == 0) { - float l, r, t, b; - getContinentProjectionBounds(currentIdx, 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 = imgMin.x + displayW - coordSz.x - 8.0f; - float cy = imgMin.y + displayH - coordSz.y - 8.0f; - drawList->AddText(ImVec2(cx + 1.0f, cy + 1.0f), IM_COL32(0, 0, 0, 180), coordBuf); - drawList->AddText(ImVec2(cx, cy), IM_COL32(220, 210, 150, 230), coordBuf); - } - } - - // Continent view: clickable zone overlays - if (viewLevel == ViewLevel::CONTINENT && continentIdx >= 0) { - const auto& cont = zones[continentIdx]; - float cLeft = cont.locLeft, cRight = cont.locRight; - float cTop = cont.locTop, cBottom = cont.locBottom; - getContinentProjectionBounds(continentIdx, cLeft, cRight, cTop, cBottom); - float cDenomU = cLeft - cRight; - float cDenomV = cTop - cBottom; - - ImVec2 mousePos = ImGui::GetMousePos(); - int hoveredZone = -1; - - if (std::abs(cDenomU) > 0.001f && std::abs(cDenomV) > 0.001f) { - for (int zi = 0; zi < static_cast(zones.size()); zi++) { - if (!zoneBelongsToContinent(zi, continentIdx)) continue; - const auto& z = zones[zi]; - if (std::abs(z.locLeft - z.locRight) < 0.001f || - std::abs(z.locTop - z.locBottom) < 0.001f) continue; - - float zuMin = (cLeft - z.locLeft) / cDenomU; - float zuMax = (cLeft - z.locRight) / cDenomU; - float zvMin = (cTop - z.locTop) / cDenomV; - float zvMax = (cTop - z.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 = imgMin.x + zuMin * displayW; - float sy0 = imgMin.y + zvMin * displayH; - float sx1 = imgMin.x + zuMax * displayW; - float sy1 = imgMin.y + zvMax * displayH; - - bool explored = exploredZones.count(zi) > 0; - bool hovered = (mousePos.x >= sx0 && mousePos.x <= sx1 && - mousePos.y >= sy0 && mousePos.y <= sy1); - - if (!explored) { - drawList->AddRectFilled(ImVec2(sx0, sy0), ImVec2(sx1, sy1), - IM_COL32(0, 0, 0, 160)); - } - if (hovered) { - hoveredZone = zi; - drawList->AddRectFilled(ImVec2(sx0, sy0), ImVec2(sx1, sy1), - IM_COL32(255, 255, 200, 40)); - drawList->AddRect(ImVec2(sx0, sy0), ImVec2(sx1, sy1), - IM_COL32(255, 215, 0, 180), 0.0f, 0, 2.0f); - } else if (explored) { - drawList->AddRect(ImVec2(sx0, sy0), ImVec2(sx1, sy1), - IM_COL32(255, 255, 255, 30), 0.0f, 0, 1.0f); - } - - // Zone name label — only if the rect is large enough to fit it - if (!z.areaName.empty()) { - ImVec2 textSz = ImGui::CalcTextSize(z.areaName.c_str()); - float rectW = sx1 - sx0; - float rectH = sy1 - sy0; - if (rectW > textSz.x + 4.0f && rectH > textSz.y + 2.0f) { - float tx = (sx0 + sx1) * 0.5f - textSz.x * 0.5f; - float ty = (sy0 + sy1) * 0.5f - textSz.y * 0.5f; - ImU32 labelCol = explored - ? IM_COL32(255, 230, 150, 210) - : IM_COL32(160, 160, 160, 80); - drawList->AddText(ImVec2(tx + 1.0f, ty + 1.0f), - IM_COL32(0, 0, 0, 130), z.areaName.c_str()); - drawList->AddText(ImVec2(tx, ty), labelCol, z.areaName.c_str()); - } - } - } - } - - if (hoveredZone >= 0) { - ImGui::SetTooltip("%s", zones[hoveredZone].areaName.c_str()); - if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { - loadZoneTextures(hoveredZone); - requestComposite(hoveredZone); - currentIdx = hoveredZone; - viewLevel = ViewLevel::ZONE; - } - } - } - - // Zone view: back to continent - if (viewLevel == ViewLevel::ZONE && continentIdx >= 0) { - auto& io = ImGui::GetIO(); - bool goBack = io.MouseClicked[1]; - - ImGui::SetCursorPos(ImVec2(mapX + 8.0f, mapY + 8.0f)); - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.15f, 0.15f, 0.8f)); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.3f, 0.3f, 0.1f, 0.9f)); - ImGui::PushStyleColor(ImGuiCol_Text, ui::colors::kBrightGold); - if (ImGui::Button("< Back")) goBack = true; - ImGui::PopStyleColor(3); - - if (goBack) { - requestComposite(continentIdx); - currentIdx = continentIdx; - viewLevel = ViewLevel::CONTINENT; - } - - const char* zoneName = zones[currentIdx].areaName.c_str(); - ImVec2 nameSize = ImGui::CalcTextSize(zoneName); - float nameY = mapY - nameSize.y - 8.0f; - if (nameY > 0.0f) { - ImGui::SetCursorPos(ImVec2((sw - nameSize.x) / 2.0f, nameY)); - ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 0.9f), "%s", zoneName); - } - } - - // Continent view: back to world - if (viewLevel == ViewLevel::CONTINENT) { - auto& io = ImGui::GetIO(); - bool goWorld = io.MouseClicked[1]; - - float worldBtnY = mapY + (continentIndices.size() > 1 ? 40.0f : 8.0f); - ImGui::SetCursorPos(ImVec2(mapX + 8.0f, worldBtnY)); - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.15f, 0.15f, 0.8f)); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.3f, 0.3f, 0.1f, 0.9f)); - ImGui::PushStyleColor(ImGuiCol_Text, ui::colors::kBrightGold); - if (ImGui::Button("< World")) goWorld = true; - ImGui::PopStyleColor(3); - - if (goWorld) enterWorldView(); - } - - // Help text - const char* helpText; - if (viewLevel == ViewLevel::ZONE) - helpText = "Scroll out or right-click to zoom out | M or Escape to close"; - else if (viewLevel == ViewLevel::WORLD) - helpText = "Select a continent | Scroll in to zoom | M or Escape to close"; - else - helpText = "Click zone or scroll in to zoom | Scroll out / right-click for World | M or Escape to close"; - - ImVec2 textSize = ImGui::CalcTextSize(helpText); - float textY = mapY + displayH + 8.0f; - if (textY + textSize.y < sh) { - ImGui::SetCursorPos(ImVec2((sw - textSize.x) / 2.0f, textY)); - ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 0.8f), "%s", helpText); - } - } - ImGui::End(); - - ImGui::PopStyleVar(); - ImGui::PopStyleColor(); -} - -} // namespace rendering -} // namespace wowee diff --git a/src/rendering/world_map/composite_renderer.cpp b/src/rendering/world_map/composite_renderer.cpp new file mode 100644 index 00000000..d8f109fa --- /dev/null +++ b/src/rendering/world_map/composite_renderer.cpp @@ -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& 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(); + 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 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 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 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(); + 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& /*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& zones, + const std::string& mapName) { + if (zoneIdx < 0 || zoneIdx >= static_cast(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(); + 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& zones) { + if (zoneIdx < 0 || zoneIdx >= static_cast(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(); + 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::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(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& zones, + const std::unordered_set& exploredOverlays, + bool hasServerMask) { + if (!initialized || pendingCompositeIdx_ < 0 || !compositeTarget) return; + if (pendingCompositeIdx_ >= static_cast(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(col), static_cast(row)); + push.gridCols = static_cast(GRID_COLS); + push.gridRows = static_cast(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(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(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(ov.offsetX + tileCol * TILE_PX); + float py = static_cast(ov.offsetY + tileRow * TILE_PX); + + OverlayPush ovPush{}; + ovPush.gridOffset = glm::vec2(px / static_cast(TILE_PX), + py / static_cast(TILE_PX)); + ovPush.gridCols = static_cast(GRID_COLS); + ovPush.gridRows = static_cast(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(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 diff --git a/src/rendering/world_map/coordinate_projection.cpp b/src/rendering/world_map/coordinate_projection.cpp new file mode 100644 index 00000000..9ca1b8b8 --- /dev/null +++ b/src/rendering/world_map/coordinate_projection.cpp @@ -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 +#include +#include + +namespace wowee { +namespace rendering { +namespace world_map { + +// ── Continent classification helpers ───────────────────────── + +bool isRootContinent(const std::vector& zones, int idx) { + if (idx < 0 || idx >= static_cast(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& zones, int idx) { + if (idx < 0 || idx >= static_cast(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& zones, + int contIdx, + float& left, float& right, + float& top, float& bottom) { + if (contIdx < 0 || contIdx >= static_cast(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 northEdges, southEdges, westEdges, eastEdges; + for (int zi = 0; zi < static_cast(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& zones, + const glm::vec3& playerRenderPos) { + float wowX = playerRenderPos.y; + float wowY = playerRenderPos.x; + + int bestIdx = -1; + float bestArea = std::numeric_limits::max(); + float bestCenterDist2 = std::numeric_limits::max(); + + bool hasLeaf = false; + for (int i = 0; i < static_cast(zones.size()); i++) { + if (zones[i].areaID == 0 && !isRootContinent(zones, i)) { + hasLeaf = true; + break; + } + } + + for (int i = 0; i < static_cast(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& zones, + const glm::vec3& playerRenderPos) { + float wowX = playerRenderPos.y; + float wowY = playerRenderPos.x; + + int bestIdx = -1; + float bestArea = std::numeric_limits::max(); + + for (int i = 0; i < static_cast(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; +} + +// ── Zone–continent relationship ────────────────────────────── + +bool zoneBelongsToContinent(const std::vector& zones, + int zoneIdx, int contIdx) { + if (zoneIdx < 0 || zoneIdx >= static_cast(zones.size())) return false; + if (contIdx < 0 || contIdx >= static_cast(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(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 diff --git a/src/rendering/world_map/data_repository.cpp b/src/rendering/world_map/data_repository.cpp new file mode 100644 index 00000000..4ccc88f9 --- /dev/null +++ b/src/rendering/world_map/data_repository.cpp @@ -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 +#include +#include + +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(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 exploreFlagByAreaId; + std::unordered_map> 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(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(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(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 wmaIdToZoneIdx; + for (int i = 0; i < static_cast(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(wmoDbc->getUInt32(i, 9)); + ov.texHeight = static_cast(wmoDbc->getUInt32(i, 10)); + ov.offsetX = static_cast(wmoDbc->getUInt32(i, 11)); + ov.offsetY = static_cast(wmoDbc->getUInt32(i, 12)); + // HitRect (fields 13-16): fast AABB pre-filter for subzone hover + ov.hitRectLeft = static_cast(wmoDbc->getUInt32(i, 13)); + ov.hitRectRight = static_cast(wmoDbc->getUInt32(i, 14)); + ov.hitRectTop = static_cast(wmoDbc->getUInt32(i, 15)); + ov.hitRectBottom = static_cast(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(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(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(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(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 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(ZMP_SIZE); + int mapped = 0; + for (const auto& [areaId, rect] : areaRects) { + int zi = zoneIndexForAreaId(areaId); + if (zi < 0) continue; + + float uMin = static_cast(rect.minCol) * kInvSize; + float uMax = static_cast(rect.maxCol + 1) * kInvSize; + float vMin = static_cast(rect.minRow) * kInvSize; + float vMax = static_cast(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(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 diff --git a/src/rendering/world_map/exploration_state.cpp b/src/rendering/world_map/exploration_state.cpp new file mode 100644 index 00000000..1a35d899 --- /dev/null +++ b/src/rendering/world_map/exploration_state.cpp @@ -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 + +namespace wowee { +namespace rendering { +namespace world_map { + +void ExplorationState::setServerMask(const std::vector& 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& zones, + const glm::vec3& playerRenderPos, + int currentZoneIdx, + const std::unordered_map& exploreFlagByAreaId) { + overlaysChanged_ = false; + + if (hasServerMask_) { + exploredZones_.clear(); + for (int i = 0; i < static_cast(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 newExploredOverlays; + if (currentZoneIdx >= 0 && currentZoneIdx < static_cast(zones.size())) { + const auto& curZoneData = zones[currentZoneIdx]; + for (int oi = 0; oi < static_cast(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(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(zones.size())) { + for (int oi = 0; oi < static_cast(zones[currentZoneIdx].overlays.size()); oi++) + exploredOverlays_.insert(oi); + } +} + +} // namespace world_map +} // namespace rendering +} // namespace wowee diff --git a/src/rendering/world_map/input_handler.cpp b/src/rendering/world_map/input_handler.cpp new file mode 100644 index 00000000..483d83fe --- /dev/null +++ b/src/rendering/world_map/input_handler.cpp @@ -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 +#include + +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 diff --git a/src/rendering/world_map/layers/coordinate_display.cpp b/src/rendering/world_map/layers/coordinate_display.cpp new file mode 100644 index 00000000..bd16cb56 --- /dev/null +++ b/src/rendering/world_map/layers/coordinate_display.cpp @@ -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 +#include +#include + +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 diff --git a/src/rendering/world_map/layers/corpse_marker_layer.cpp b/src/rendering/world_map/layers/corpse_marker_layer.cpp new file mode 100644 index 00000000..473532d1 --- /dev/null +++ b/src/rendering/world_map/layers/corpse_marker_layer.cpp @@ -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 + +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 diff --git a/src/rendering/world_map/layers/party_dot_layer.cpp b/src/rendering/world_map/layers/party_dot_layer.cpp new file mode 100644 index 00000000..553281ea --- /dev/null +++ b/src/rendering/world_map/layers/party_dot_layer.cpp @@ -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 + +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 diff --git a/src/rendering/world_map/layers/player_marker_layer.cpp b/src/rendering/world_map/layers/player_marker_layer.cpp new file mode 100644 index 00000000..f808f770 --- /dev/null +++ b/src/rendering/world_map/layers/player_marker_layer.cpp @@ -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 +#include + +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 diff --git a/src/rendering/world_map/layers/poi_marker_layer.cpp b/src/rendering/world_map/layers/poi_marker_layer.cpp new file mode 100644 index 00000000..1e9f719c --- /dev/null +++ b/src/rendering/world_map/layers/poi_marker_layer.cpp @@ -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 + +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(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 diff --git a/src/rendering/world_map/layers/quest_poi_layer.cpp b/src/rendering/world_map/layers/quest_poi_layer.cpp new file mode 100644 index 00000000..9138a8c2 --- /dev/null +++ b/src/rendering/world_map/layers/quest_poi_layer.cpp @@ -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 + +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 diff --git a/src/rendering/world_map/layers/subzone_tooltip_layer.cpp b/src/rendering/world_map/layers/subzone_tooltip_layer.cpp new file mode 100644 index 00000000..ff5fbe84 --- /dev/null +++ b/src/rendering/world_map/layers/subzone_tooltip_layer.cpp @@ -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 +#include + +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::max(); + + float fboW = static_cast(ctx.fboW); + float fboH = static_cast(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(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(ov.hitRectLeft) || + pixelX > static_cast(ov.hitRectRight) || + pixelY < static_cast(ov.hitRectTop) || + pixelY > static_cast(ov.hitRectBottom)) { + continue; // Mouse outside HitRect — skip this overlay + } + } else { + // Fallback: use overlay offset+size AABB (old behaviour) + float ovLeft = static_cast(ov.offsetX) / fboW; + float ovTop = static_cast(ov.offsetY) / fboH; + float ovRight = static_cast(ov.offsetX + ov.texWidth) / fboW; + float ovBottom = static_cast(ov.offsetY + ov.texHeight) / fboH; + + if (mu < ovLeft || mu > ovRight || mv < ovTop || mv > ovBottom) + continue; + } + + float area = static_cast(ov.texWidth) * static_cast(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 diff --git a/src/rendering/world_map/layers/taxi_node_layer.cpp b/src/rendering/world_map/layers/taxi_node_layer.cpp new file mode 100644 index 00000000..764e9bf4 --- /dev/null +++ b/src/rendering/world_map/layers/taxi_node_layer.cpp @@ -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 + +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(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 diff --git a/src/rendering/world_map/layers/zone_highlight_layer.cpp b/src/rendering/world_map/layers/zone_highlight_layer.cpp new file mode 100644 index 00000000..dec4a659 --- /dev/null +++ b/src/rendering/world_map/layers/zone_highlight_layer.cpp @@ -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 +#include +#include +#include + +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 texture; + VkDescriptorSet imguiDS; + }; + auto captured = std::make_shared>(); + 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(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(); + 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(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(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(255.0f * hoverHighlightAlpha_); + // Draw twice for a very bright glow effect + ctx.drawList->AddImage( + reinterpret_cast(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(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(100.0f * hoverHighlightAlpha_); + ctx.drawList->AddRectFilled(ImVec2(sx0, sy0), ImVec2(sx1, sy1), + IM_COL32(255, 235, 50, fillAlpha)); + } + + uint8_t borderAlpha = static_cast(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 diff --git a/src/rendering/world_map/map_resolver.cpp b/src/rendering/world_map/map_resolver.cpp new file mode 100644 index 00000000..6b6f2911 --- /dev/null +++ b/src/rendering/world_map/map_resolver.cpp @@ -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 +#include + +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// 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::length(entry)) continue; + bool match = true; + for (size_t j = 0; j < folder.size(); j++) { + if (std::tolower(static_cast(folder[j])) != + std::tolower(static_cast(entry[j]))) { + match = false; + break; + } + } + if (match) return static_cast(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& zones, + uint32_t mapId, + int cosmicIdx) { + // 1) Prefer a leaf continent whose displayMapID matches the target mapId. + for (int i = 0; i < static_cast(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(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& zones, + int currentMapId, + int cosmicIdx) { + MapResolveResult result; + + if (static_cast(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& zones, + int currentMapId) { + MapResolveResult result; + if (zoneIdx < 0 || zoneIdx >= static_cast(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(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 diff --git a/src/rendering/world_map/overlay_renderer.cpp b/src/rendering/world_map/overlay_renderer.cpp new file mode 100644 index 00000000..ef9cc4d7 --- /dev/null +++ b/src/rendering/world_map/overlay_renderer.cpp @@ -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 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 diff --git a/src/rendering/world_map/view_state_machine.cpp b/src/rendering/world_map/view_state_machine.cpp new file mode 100644 index 00000000..a4173551 --- /dev/null +++ b/src/rendering/world_map/view_state_machine.cpp @@ -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 diff --git a/src/rendering/world_map/world_map_facade.cpp b/src/rendering/world_map/world_map_facade.cpp new file mode 100644 index 00000000..2c593ce0 --- /dev/null +++ b/src/rendering/world_map/world_map_facade.cpp @@ -0,0 +1,1060 @@ +// world_map_facade.cpp — Public API for the world map system. +// Composes all extracted components and orchestrates the world map (Phase 10). +#include "rendering/world_map/world_map_facade.hpp" +#include "rendering/world_map/data_repository.hpp" +#include "rendering/world_map/view_state_machine.hpp" +#include "rendering/world_map/composite_renderer.hpp" +#include "rendering/world_map/exploration_state.hpp" +#include "rendering/world_map/zone_metadata.hpp" +#include "rendering/world_map/coordinate_projection.hpp" +#include "rendering/world_map/map_resolver.hpp" +#include "rendering/world_map/overlay_renderer.hpp" +#include "rendering/world_map/input_handler.hpp" +#include "rendering/world_map/layers/player_marker_layer.hpp" +#include "rendering/world_map/layers/party_dot_layer.hpp" +#include "rendering/world_map/layers/taxi_node_layer.hpp" +#include "rendering/world_map/layers/poi_marker_layer.hpp" +#include "rendering/world_map/layers/quest_poi_layer.hpp" +#include "rendering/world_map/layers/corpse_marker_layer.hpp" +#include "rendering/world_map/layers/zone_highlight_layer.hpp" +#include "rendering/world_map/layers/coordinate_display.hpp" +#include "rendering/world_map/layers/subzone_tooltip_layer.hpp" +#include "rendering/vk_context.hpp" +#include "pipeline/asset_manager.hpp" +#include "ui/ui_colors.hpp" +#include "game/game_utils.hpp" +#include "core/logger.hpp" +#include +#include +#include + +namespace wowee { +namespace rendering { +namespace world_map { + +// Find the zone index for the WORLD view background. +// Find the best continent root zone for displaying a map in CONTINENT view. +// Skips synthetic zones (Cosmic, World) and prefers a zone matching mapName. +static int findContinentRootIdx(const std::vector& zones, + int cosmicIdx, + int worldIdx, + const std::string& mapName) { + LOG_INFO("findContinentRootIdx: searching ", zones.size(), " zones, mapName='", mapName, "'"); + // 1) Exact areaName match for the map name (e.g. "Azeroth", "Kalimdor") + for (int i = 0; i < static_cast(zones.size()); i++) { + if (i == cosmicIdx || i == worldIdx) continue; + if (zones[i].areaID == 0 && zones[i].areaName == mapName) { + LOG_INFO("findContinentRootIdx: matched mapName '", mapName, "' at zone[", i, "]"); + return i; + } + } + // 2) Root continent (parent of leaf continents) + int firstContinent = -1; + for (int i = 0; i < static_cast(zones.size()); i++) { + if (i == cosmicIdx || i == worldIdx) continue; + if (zones[i].areaID == 0) { + if (firstContinent < 0) firstContinent = i; + if (isRootContinent(zones, i)) return i; + } + } + // 3) First continent entry + return firstContinent; +} + +// Find the best zone for the WORLD view (prefers synthetic "World" zone). +// Used only as a fallback when data.worldIdx() is not available. +static int findWorldViewContinentIdx(const std::vector& zones, + int cosmicIdx, + const std::string& mapName) { + LOG_INFO("findWorldViewContinentIdx: searching ", zones.size(), " zones, cosmicIdx=", cosmicIdx, " mapName='", mapName, "'"); + // 1) Exact areaName match for "World" folder + for (int i = 0; i < static_cast(zones.size()); i++) { + if (i == cosmicIdx) continue; + if (zones[i].areaID == 0 && zones[i].areaName == "World") { + LOG_INFO("findWorldViewContinentIdx: matched 'World' at zone[", i, "]"); + return i; + } + } + // 2) Exact areaName match for the map name (e.g. "Azeroth") + for (int i = 0; i < static_cast(zones.size()); i++) { + if (i == cosmicIdx) continue; + if (zones[i].areaID == 0 && zones[i].areaName == mapName) { + LOG_INFO("findWorldViewContinentIdx: matched mapName '", mapName, "' at zone[", i, "]"); + return i; + } + } + // 3) Root continent (parent of leaf continents) + int firstContinent = -1; + for (int i = 0; i < static_cast(zones.size()); i++) { + if (i == cosmicIdx) continue; + if (zones[i].areaID == 0) { + if (firstContinent < 0) firstContinent = i; + if (isRootContinent(zones, i)) return i; + } + } + // 4) First continent entry + return firstContinent; +} + +// ── PIMPL Implementation ───────────────────────────────────── + +struct WorldMapFacade::Impl { + VkContext* vkCtx = nullptr; + pipeline::AssetManager* assetManager = nullptr; + bool initialized = false; + bool open = false; + std::string mapName = "Azeroth"; + std::string pendingMapName; // stored by external setMapName while in world/cosmic view + bool userMapOverride = false; // true when user manually navigated to world/cosmic view + + DataRepository data; + ViewStateMachine viewState; + CompositeRenderer compositor; + ExplorationState exploration; + ZoneMetadata zoneMetadata; + InputHandler input; + OverlayRenderer overlay; + + // Typed layer pointers for setters (non-owning references into overlay) + PartyDotLayer* partyDotLayer = nullptr; + TaxiNodeLayer* taxiNodeLayer = nullptr; + POIMarkerLayer* poiMarkerLayer = nullptr; + QuestPOILayer* questPOILayer = nullptr; + CorpseMarkerLayer* corpseMarkerLayer = nullptr; + ZoneHighlightLayer* zoneHighlightLayer = nullptr; + + // Data set each frame from the UI layer + std::vector partyDots; + std::vector taxiNodes; + std::vector questPois; + + float lastFrameTime = 0.0f; + + void initOverlayLayers(); + void switchToMap(const std::string& newMapName); + void switchToWorldView(); + void renderImGuiOverlay(const glm::vec3& playerRenderPos, + int screenWidth, int screenHeight, + float playerYawDeg, + bool rightClickConsumed); +}; + +void WorldMapFacade::Impl::switchToMap(const std::string& newMapName) { + if (mapName == newMapName && !data.zones().empty()) return; + userMapOverride = true; + pendingMapName.clear(); + if (zoneHighlightLayer) zoneHighlightLayer->clearTextures(); + compositor.detachZoneTextures(); + data.clear(); + compositor.invalidateComposite(); + + mapName = newMapName; + data.loadZones(mapName, *assetManager); + zoneMetadata.initialize(); + viewState.setCosmicEnabled(data.cosmicEnabled()); + + // Find the continent root zone and display it (skip synthetic World/Cosmic) + int rootIdx = findContinentRootIdx(data.zones(), data.cosmicIdx(), data.worldIdx(), mapName); + if (rootIdx < 0) rootIdx = 0; + viewState.setContinentIdx(rootIdx); + compositor.loadZoneTextures(rootIdx, data.zones(), mapName); + compositor.requestComposite(rootIdx); + viewState.setCurrentZoneIdx(rootIdx); + viewState.setLevel(ViewLevel::CONTINENT); +} + +void WorldMapFacade::Impl::switchToWorldView() { + LOG_INFO("switchToWorldView: mapName='", mapName, "'"); + + // Determine whether the current map is an Azeroth continent (EK, Kalimdor, + // Northrend) or a separate world (Outland). Azeroth continents go to the + // world view; other worlds go to the cosmic view. + bool isAzerothContinent = (mapName == "Azeroth"); + if (!isAzerothContinent) { + int curMapId = folderToMapId(mapName); + for (const auto& region : data.azerothRegions()) { + if (static_cast(region.mapId) == curMapId) { + isAzerothContinent = true; + break; + } + } + } + + // If on a different map, switch back to Azeroth first. + if (mapName != "Azeroth") { + if (zoneHighlightLayer) zoneHighlightLayer->clearTextures(); + compositor.detachZoneTextures(); + data.clear(); + compositor.invalidateComposite(); + mapName = "Azeroth"; + data.loadZones(mapName, *assetManager); + zoneMetadata.initialize(); + viewState.setCosmicEnabled(data.cosmicEnabled()); + } + userMapOverride = true; + + // Non-Azeroth worlds (e.g. Outland) go to cosmic view. + if (!isAzerothContinent && viewState.cosmicEnabled() && data.cosmicIdx() >= 0) { + viewState.enterCosmicView(); + compositor.loadZoneTextures(data.cosmicIdx(), data.zones(), mapName); + compositor.requestComposite(data.cosmicIdx()); + viewState.setCurrentZoneIdx(data.cosmicIdx()); + return; + } + + viewState.enterWorldView(); + + // Use the dedicated synthetic "World" zone — its tiles (world1-12.blp) + // are cached independently from zone[0] (Azeroth), avoiding stale-tile + // conflicts when transitioning between WORLD and CONTINENT views. + int worldIdx = data.worldIdx(); + LOG_INFO("switchToWorldView: worldIdx=", worldIdx); + if (worldIdx >= 0) { + compositor.loadZoneTextures(worldIdx, data.zones(), mapName); + if (compositor.hasAnyTile(worldIdx)) { + compositor.invalidateComposite(); + compositor.requestComposite(worldIdx); + viewState.setCurrentZoneIdx(worldIdx); + return; + } + } + + // Fallback: try the root continent zone + int rootIdx = findWorldViewContinentIdx(data.zones(), data.cosmicIdx(), mapName); + LOG_INFO("switchToWorldView: fallback rootIdx=", rootIdx); + if (rootIdx >= 0) { + compositor.loadZoneTextures(rootIdx, data.zones(), mapName); + if (compositor.hasAnyTile(rootIdx)) { + compositor.invalidateComposite(); + compositor.requestComposite(rootIdx); + viewState.setCurrentZoneIdx(rootIdx); + } + } +} + +void WorldMapFacade::Impl::initOverlayLayers() { + // Order matters: later layers draw on top of earlier ones + + // Zone highlights (continent view) + auto zhLayer = std::make_unique(); + zhLayer->setMetadata(&zoneMetadata); + zoneHighlightLayer = zhLayer.get(); + overlay.addLayer(std::move(zhLayer)); + + // Player marker + overlay.addLayer(std::make_unique()); + + // Party dots + auto pdLayer = std::make_unique(); + partyDotLayer = pdLayer.get(); + overlay.addLayer(std::move(pdLayer)); + + // Taxi nodes + auto tnLayer = std::make_unique(); + taxiNodeLayer = tnLayer.get(); + overlay.addLayer(std::move(tnLayer)); + + // // POI markers + // auto poiLayer = std::make_unique(); + // poiMarkerLayer = poiLayer.get(); + // overlay.addLayer(std::move(poiLayer)); + + // Quest POI markers + auto qpLayer = std::make_unique(); + questPOILayer = qpLayer.get(); + overlay.addLayer(std::move(qpLayer)); + + // Corpse marker + auto cmLayer = std::make_unique(); + corpseMarkerLayer = cmLayer.get(); + overlay.addLayer(std::move(cmLayer)); + + // Coordinate display + overlay.addLayer(std::make_unique()); + + // Subzone tooltip + overlay.addLayer(std::make_unique()); +} + +// ── WorldMapFacade Public Methods ──────────────────────────── + +WorldMapFacade::WorldMapFacade() : impl_(std::make_unique()) { + impl_->zoneMetadata.initialize(); + impl_->initOverlayLayers(); +} + +WorldMapFacade::~WorldMapFacade() { + shutdown(); +} + +bool WorldMapFacade::initialize(VkContext* ctx, pipeline::AssetManager* am) { + impl_->vkCtx = ctx; + impl_->assetManager = am; + if (!impl_->compositor.initialize(ctx, am)) return false; + if (impl_->zoneHighlightLayer) + impl_->zoneHighlightLayer->initialize(ctx, am); + impl_->initialized = true; + return true; +} + +void WorldMapFacade::shutdown() { + if (!impl_) return; + if (impl_->zoneHighlightLayer) + impl_->zoneHighlightLayer->clearTextures(); + impl_->compositor.shutdown(); + impl_->data.clear(); + impl_->initialized = false; +} + +void WorldMapFacade::compositePass(VkCommandBuffer cmd) { + impl_->compositor.flushStaleTextures(); + impl_->compositor.compositePass(cmd, + impl_->data.zones(), + impl_->exploration.exploredOverlays(), + impl_->exploration.hasServerMask()); +} + +void WorldMapFacade::render(const glm::vec3& playerRenderPos, + int screenWidth, int screenHeight, + float playerYawDeg) { + auto& d = *impl_; + if (!d.initialized || !d.assetManager) return; + + // Update transition animation + float now = static_cast(ImGui::GetTime()); + float dt = now - d.lastFrameTime; + d.lastFrameTime = now; + d.viewState.updateTransition(dt); + + // Update exploration state + if (!d.data.zones().empty()) { + d.exploration.update(d.data.zones(), playerRenderPos, + d.viewState.currentZoneIdx(), + d.data.exploreFlagByAreaId()); + if (d.exploration.overlaysChanged() && d.viewState.currentZoneIdx() >= 0) { + d.compositor.invalidateComposite(); + d.compositor.requestComposite(d.viewState.currentZoneIdx()); + } + } + + // First-time open or zones lost after map change + if (!d.open || d.data.zones().empty()) { + d.open = true; + if (d.data.zones().empty()) { + d.data.loadZones(d.mapName, *d.assetManager); + d.zoneMetadata.initialize(); + d.viewState.setCosmicEnabled(d.data.cosmicEnabled()); + } + + int bestContinent = findBestContinentForPlayer(d.data.zones(), playerRenderPos); + if (bestContinent >= 0 && bestContinent != d.viewState.continentIdx()) { + d.viewState.setContinentIdx(bestContinent); + d.compositor.invalidateComposite(); + } + + int playerZone = findZoneForPlayer(d.data.zones(), playerRenderPos); + if (playerZone >= 0 && d.viewState.continentIdx() >= 0 && + zoneBelongsToContinent(d.data.zones(), playerZone, d.viewState.continentIdx())) { + d.compositor.loadZoneTextures(playerZone, d.data.zones(), d.mapName); + d.compositor.loadOverlayTextures(playerZone, d.data.zones()); + d.viewState.setCurrentZoneIdx(playerZone); + d.viewState.setLevel(ViewLevel::ZONE); + d.exploration.update(d.data.zones(), playerRenderPos, playerZone, + d.data.exploreFlagByAreaId()); + d.compositor.requestComposite(playerZone); + } else if (d.viewState.continentIdx() >= 0) { + d.compositor.loadZoneTextures(d.viewState.continentIdx(), d.data.zones(), d.mapName); + d.compositor.requestComposite(d.viewState.continentIdx()); + d.viewState.setCurrentZoneIdx(d.viewState.continentIdx()); + d.viewState.setLevel(ViewLevel::CONTINENT); + } + } + + // Process input + int hoveredZone = d.zoneHighlightLayer ? d.zoneHighlightLayer->hoveredZone() : -1; + InputResult inputResult = d.input.process(d.viewState.currentLevel(), + hoveredZone, + d.viewState.cosmicEnabled()); + + switch (inputResult.action) { + case InputAction::CLOSE: + d.open = false; + d.userMapOverride = false; + if (!d.pendingMapName.empty()) { + d.mapName = d.pendingMapName; + d.pendingMapName.clear(); + } + return; + + case InputAction::ZOOM_IN: { + int playerZone = findZoneForPlayer(d.data.zones(), playerRenderPos); + // For continent→zone, verify the zone belongs to the current continent + int candidateZone = hoveredZone >= 0 ? hoveredZone : playerZone; + if (d.viewState.currentLevel() == ViewLevel::CONTINENT && + candidateZone >= 0 && + !zoneBelongsToContinent(d.data.zones(), candidateZone, d.viewState.continentIdx())) { + candidateZone = -1; + } + // Bug fix: also validate playerZone against the continent so the + // fallback inside zoomIn() doesn't navigate to the wrong continent. + int validPlayerZone = playerZone; + if (d.viewState.currentLevel() == ViewLevel::CONTINENT && + validPlayerZone >= 0 && + !zoneBelongsToContinent(d.data.zones(), validPlayerZone, d.viewState.continentIdx())) { + validPlayerZone = -1; + } + auto zr = d.viewState.zoomIn(candidateZone, validPlayerZone); + if (zr.changed && zr.targetIdx >= 0) { + d.compositor.loadZoneTextures(zr.targetIdx, d.data.zones(), d.mapName); + if (zr.newLevel == ViewLevel::ZONE) { + d.compositor.loadOverlayTextures(zr.targetIdx, d.data.zones()); + } + d.compositor.requestComposite(zr.targetIdx); + } else if (zr.changed && zr.newLevel == ViewLevel::WORLD) { + d.switchToWorldView(); + } + break; + } + + case InputAction::ZOOM_OUT: { + auto zr = d.viewState.zoomOut(); + if (zr.changed && zr.targetIdx >= 0) { + d.compositor.loadZoneTextures(zr.targetIdx, d.data.zones(), d.mapName); + d.compositor.requestComposite(zr.targetIdx); + } else if (zr.changed && zr.newLevel == ViewLevel::WORLD) { + d.switchToWorldView(); + } else if (zr.changed && zr.newLevel == ViewLevel::COSMIC) { + if (d.data.cosmicIdx() >= 0) { + d.compositor.loadZoneTextures(d.data.cosmicIdx(), d.data.zones(), d.mapName); + d.compositor.requestComposite(d.data.cosmicIdx()); + d.viewState.setCurrentZoneIdx(d.data.cosmicIdx()); + } + } + break; + } + + case InputAction::CLICK_ZONE: { + int hz = inputResult.targetIdx; + if (hz >= 0) { + // Use centralized resolver to handle cross-map zone navigation + auto zoneResult = resolveZoneClick(hz, d.data.zones(), d.data.currentMapId()); + switch (zoneResult.action) { + case MapResolveAction::LOAD_MAP: + d.switchToMap(zoneResult.targetMapName); + break; + case MapResolveAction::ENTER_ZONE: + d.compositor.loadZoneTextures(hz, d.data.zones(), d.mapName); + d.compositor.loadOverlayTextures(hz, d.data.zones()); + d.compositor.requestComposite(hz); + d.viewState.enterZone(hz); + break; + default: + break; + } + } + break; + } + + case InputAction::RIGHT_CLICK_BACK: { + // Only process right-click if we're at zone or continent level + if (d.viewState.currentLevel() == ViewLevel::ZONE && + d.viewState.continentIdx() >= 0) { + d.compositor.loadZoneTextures(d.viewState.continentIdx(), d.data.zones(), d.mapName); + d.compositor.requestComposite(d.viewState.continentIdx()); + d.viewState.setCurrentZoneIdx(d.viewState.continentIdx()); + d.viewState.setLevel(ViewLevel::CONTINENT); + } else if (d.viewState.currentLevel() == ViewLevel::CONTINENT) { + d.switchToWorldView(); + } else if (d.viewState.currentLevel() == ViewLevel::WORLD && + d.viewState.cosmicEnabled()) { + d.viewState.enterCosmicView(); + if (d.data.cosmicIdx() >= 0) { + d.compositor.loadZoneTextures(d.data.cosmicIdx(), d.data.zones(), d.mapName); + d.compositor.requestComposite(d.data.cosmicIdx()); + d.viewState.setCurrentZoneIdx(d.data.cosmicIdx()); + } + } + break; + } + + default: + break; + } + + if (!d.open) return; + bool rightClickConsumed = (inputResult.action == InputAction::RIGHT_CLICK_BACK); + d.renderImGuiOverlay(playerRenderPos, screenWidth, screenHeight, playerYawDeg, rightClickConsumed); +} + +void WorldMapFacade::setMapName(const std::string& name) { + auto& d = *impl_; + // While the user has manually navigated to the world/cosmic overview, + // remember the game's desired map but don't reset the view. + if (d.userMapOverride) { + d.pendingMapName = name; + return; + } + if (d.mapName == name && !d.data.zones().empty()) return; + d.mapName = name; + + if (d.zoneHighlightLayer) + d.zoneHighlightLayer->clearTextures(); + d.compositor.detachZoneTextures(); + d.data.clear(); + d.viewState.setContinentIdx(-1); + d.viewState.setCurrentZoneIdx(-1); + d.compositor.invalidateComposite(); + d.viewState.setLevel(ViewLevel::WORLD); + d.open = false; +} + +void WorldMapFacade::setServerExplorationMask(const std::vector& masks, bool hasData) { + impl_->exploration.setServerMask(masks, hasData); +} + +void WorldMapFacade::setPartyDots(std::vector dots) { + impl_->partyDots = std::move(dots); +} + +void WorldMapFacade::setTaxiNodes(std::vector nodes) { + impl_->taxiNodes = std::move(nodes); +} + +void WorldMapFacade::setQuestPois(std::vector pois) { + impl_->questPois = std::move(pois); +} + +void WorldMapFacade::setCorpsePos(bool hasCorpse, glm::vec3 renderPos) { + if (impl_->corpseMarkerLayer) + impl_->corpseMarkerLayer->setCorpse(hasCorpse, renderPos); +} + +bool WorldMapFacade::isOpen() const { return impl_->open; } +void WorldMapFacade::close() { + impl_->open = false; + impl_->userMapOverride = false; + // Apply any map name that was deferred while in world/cosmic view + if (!impl_->pendingMapName.empty()) { + impl_->mapName = impl_->pendingMapName; + impl_->pendingMapName.clear(); + } +} + +// ── ImGui Overlay ──────────────────────────────────────────── + +void WorldMapFacade::Impl::renderImGuiOverlay(const glm::vec3& playerRenderPos, + int screenWidth, int screenHeight, + float playerYawDeg, + bool rightClickConsumed) { + float sw = static_cast(screenWidth); + float sh = static_cast(screenHeight); + + // Use the full FBO (1024×768) for aspect ratio — all coordinate math + // (kVOffset, zone DBC projection, ZMP grid) is calibrated for the full + // tile grid, not the cropped 1002×668 content area. + float mapAspect = static_cast(CompositeRenderer::FBO_W) / + static_cast(CompositeRenderer::FBO_H); + float availW = sw * 0.70f; + float availH = sh * 0.70f; + float displayW, displayH; + if (availW / availH > mapAspect) { + displayH = availH; + displayW = availH * mapAspect; + } else { + displayW = availW; + displayH = availW / mapAspect; + } + + // Floor to pixel boundary + displayW = std::floor(displayW); + displayH = std::floor(displayH); + float mapX = std::floor((sw - displayW) / 2.0f); + float mapY = std::floor((sh - displayH) / 2.0f); + + // Map window — styled like the character selection window + ImGui::SetNextWindowPos(ImVec2(mapX, mapY), ImGuiCond_Once); + ImGui::SetNextWindowSize(ImVec2(displayW, displayH), ImGuiCond_Always); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoScrollWithMouse | + ImGuiWindowFlags_NoFocusOnAppearing; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0)); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0, 0)); + + // Bug fix: pass nullptr instead of &open so ImGui's X-button doesn't + // set open=false directly — that bypasses cleanup (userMapOverride, + // pendingMapName) and causes immediate re-open on next render() call. + // Close is handled by ESC / InputAction::CLOSE instead. + if (ImGui::Begin("World Map", nullptr, flags)) { + ImDrawList* drawList = ImGui::GetWindowDrawList(); + + // imgMin/imgMax = the content area (after title bar) + ImVec2 contentPos = ImGui::GetCursorScreenPos(); + ImVec2 contentSize = ImGui::GetContentRegionAvail(); + ImVec2 imgMin = contentPos; + ImVec2 imgMax(contentPos.x + contentSize.x, contentPos.y + contentSize.y); + displayW = contentSize.x; + displayH = contentSize.y; + // Show the full 1024×768 FBO — coordinate math (kVOffset, ZMP grid, + // DBC zone projection) is all calibrated for the full tile grid. + ImGui::Image( + reinterpret_cast(compositor.displayDescriptorSet()), + ImVec2(displayW, displayH), + ImVec2(0, 0), ImVec2(1, 1)); + + // Transition fade overlay + const auto& trans = viewState.transition(); + if (trans.active) { + float alpha = std::max(0.0f, 1.0f - trans.progress); + if (alpha > 0.01f) { + uint8_t fadeAlpha = static_cast(alpha * 180.0f); + drawList->AddRectFilled(imgMin, imgMax, + IM_COL32(0, 0, 0, fadeAlpha)); + } + } + + + + // Build continent index list (expansion-aware filtering, excludes cosmic) + std::vector continentIndices; + int cosmicZoneIdx = data.cosmicIdx(); + bool hasLeafContinents = false; + for (int i = 0; i < static_cast(data.zones().size()); i++) { + if (i == cosmicZoneIdx) continue; + if (isLeafContinent(data.zones(), i)) { hasLeafContinents = true; break; } + } + for (int i = 0; i < static_cast(data.zones().size()); i++) { + if (i == cosmicZoneIdx) continue; + if (data.zones()[i].areaID != 0) continue; + if (hasLeafContinents) { + if (isLeafContinent(data.zones(), i)) continentIndices.push_back(i); + } else if (!isRootContinent(data.zones(), i)) { + continentIndices.push_back(i); + } + } + if (continentIndices.size() > 1) { + std::vector filtered; + filtered.reserve(continentIndices.size()); + for (int idx : continentIndices) { + if (data.zones()[idx].areaName == mapName) continue; + filtered.push_back(idx); + } + if (!filtered.empty()) continentIndices = std::move(filtered); + } + if (continentIndices.empty()) { + for (int i = 0; i < static_cast(data.zones().size()); i++) { + if (i == cosmicZoneIdx) continue; + if (data.zones()[i].areaID == 0) continentIndices.push_back(i); + } + } + + // Expansion filtering + { + std::vector expFiltered; + expFiltered.reserve(continentIndices.size()); + for (int ci : continentIndices) { + uint32_t mapId = data.zones()[ci].displayMapID; + if (mapId == 530 && game::isPreWotlk() && !game::isActiveExpansion("tbc")) continue; + if (mapId == 571 && game::isPreWotlk()) continue; + expFiltered.push_back(ci); + } + if (!expFiltered.empty()) continentIndices = std::move(expFiltered); + } + + // Update layer data pointers + if (partyDotLayer) partyDotLayer->setDots(partyDots); + if (taxiNodeLayer) taxiNodeLayer->setNodes(taxiNodes); + if (poiMarkerLayer) poiMarkerLayer->setMarkers(data.poiMarkers()); + if (questPOILayer) questPOILayer->setPois(questPois); + + // Build layer context + LayerContext layerCtx; + layerCtx.drawList = drawList; + layerCtx.imgMin = imgMin; + layerCtx.displayW = displayW; + layerCtx.displayH = displayH; + layerCtx.playerRenderPos = playerRenderPos; + layerCtx.playerYawDeg = playerYawDeg; + layerCtx.currentZoneIdx = viewState.currentZoneIdx(); + layerCtx.continentIdx = viewState.continentIdx(); + layerCtx.currentMapId = data.currentMapId(); + layerCtx.viewLevel = viewState.currentLevel(); + layerCtx.zones = &data.zones(); + layerCtx.exploredZones = &exploration.exploredZones(); + layerCtx.exploredOverlays = &exploration.exploredOverlays(); + layerCtx.areaNameByAreaId = &data.areaNameByAreaId(); + layerCtx.fboW = CompositeRenderer::FBO_W; + layerCtx.fboH = CompositeRenderer::FBO_H; + + // ZMP pixel map for continent-view hover + if (data.hasZmpData()) { + layerCtx.zmpGrid = &data.zmpGrid(); + layerCtx.hasZmpData = true; + layerCtx.zmpResolveZoneIdx = [](const void* repo, uint32_t areaId) -> int { + return static_cast(repo)->zoneIndexForAreaId(areaId); + }; + layerCtx.zmpRepoPtr = &data; + layerCtx.zmpZoneBounds = &data.zmpZoneBounds(); + } + + // World-level: Azeroth map with clickable continent regions + ViewLevel vl = viewState.currentLevel(); + if (vl == ViewLevel::WORLD) { + bool goCosmic = false; + if (viewState.cosmicEnabled() && !rightClickConsumed) { + goCosmic = ImGui::GetIO().MouseClicked[1]; + } + + // "< Cosmic" back button (only if cosmic view is available for this expansion) + if (viewState.cosmicEnabled()) { + ImGui::SetCursorPos(ImVec2(8.0f, 8.0f)); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.15f, 0.15f, 0.8f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.3f, 0.3f, 0.1f, 0.9f)); + ImGui::PushStyleColor(ImGuiCol_Text, ui::colors::kBrightGold); + if (ImGui::Button("< Cosmic")) goCosmic = true; + ImGui::PopStyleColor(3); + } + + if (goCosmic) { + viewState.enterCosmicView(); + if (data.cosmicIdx() >= 0) { + compositor.loadZoneTextures(data.cosmicIdx(), data.zones(), mapName); + compositor.requestComposite(data.cosmicIdx()); + viewState.setCurrentZoneIdx(data.cosmicIdx()); + } + } + + // Title + ImVec2 titleSz = ImGui::CalcTextSize("World"); + float titleX = imgMin.x + (displayW - titleSz.x) * 0.5f; + float titleY = imgMin.y - titleSz.y - 8.0f; + if (titleY > 0.0f) { + drawList->AddText(ImVec2(titleX + 1.0f, titleY + 1.0f), + IM_COL32(0, 0, 0, 220), "World"); + drawList->AddText(ImVec2(titleX, titleY), + IM_COL32(255, 215, 0, 255), "World"); + } + + // Clickable continent regions on the Azeroth map + ImVec2 mp2 = ImGui::GetMousePos(); + auto& io = ImGui::GetIO(); + for (const auto& region : data.azerothRegions()) { + float rx0 = imgMin.x + region.uvLeft * displayW; + float ry0 = imgMin.y + region.uvTop * displayH; + float rx1 = imgMin.x + region.uvRight * displayW; + float ry1 = imgMin.y + region.uvBottom * displayH; + + bool hovered = (mp2.x >= rx0 && mp2.x <= rx1 && + mp2.y >= ry0 && mp2.y <= ry1); + + if (hovered) { + // Map region mapId to the highlight texture folder name + std::string regionFolder = mapIdToFolder(region.mapId); + + // Draw highlight texture covering the full map area + if (zoneHighlightLayer && !regionFolder.empty()) { + ImTextureID hlTex = zoneHighlightLayer->getHighlightTexture(regionFolder); + if (hlTex) { + drawList->AddImage(hlTex, + ImVec2(imgMin.x, imgMin.y), + ImVec2(imgMin.x + displayW, imgMin.y + displayH), + ImVec2(0, 0), ImVec2(1, 1), + IM_COL32(255, 255, 255, 180)); + } else { + drawList->AddRectFilled(ImVec2(rx0, ry0), ImVec2(rx1, ry1), + IM_COL32(255, 215, 0, 25)); + } + } else { + drawList->AddRectFilled(ImVec2(rx0, ry0), ImVec2(rx1, ry1), + IM_COL32(255, 215, 0, 25)); + } + drawList->AddRect(ImVec2(rx0, ry0), ImVec2(rx1, ry1), + IM_COL32(255, 215, 0, 100), 0, 0, 1.5f); + + ImFont* font = ImGui::GetFont(); + ImVec2 labelSz = font->CalcTextSizeA(ImGui::GetFontSize(), FLT_MAX, 0.0f, + region.label.c_str()); + float lx = (rx0 + rx1 - labelSz.x) * 0.5f; + float ly = ry0 - labelSz.y - 4.0f; + if (ly < imgMin.y) ly = ry0 + 4.0f; + drawList->AddText(ImVec2(lx + 1.0f, ly + 1.0f), + IM_COL32(0, 0, 0, 200), region.label.c_str()); + drawList->AddText(ImVec2(lx, ly), + IM_COL32(255, 230, 100, 255), region.label.c_str()); + + if (io.MouseClicked[0]) { + // Use centralized map resolver to determine navigation action + auto resolveResult = resolveWorldRegionClick( + region.mapId, data.zones(), data.currentMapId(), data.cosmicIdx()); + switch (resolveResult.action) { + case MapResolveAction::NAVIGATE_CONTINENT: + // Same map — just switch to the continent view + viewState.setContinentIdx(resolveResult.targetZoneIdx); + compositor.loadZoneTextures(resolveResult.targetZoneIdx, data.zones(), mapName); + compositor.requestComposite(resolveResult.targetZoneIdx); + viewState.setCurrentZoneIdx(resolveResult.targetZoneIdx); + viewState.setLevel(ViewLevel::CONTINENT); + break; + case MapResolveAction::LOAD_MAP: + switchToMap(resolveResult.targetMapName); + break; + default: + break; + } + break; + } + } + } + } else if (vl == ViewLevel::CONTINENT && continentIndices.size() > 1) { + ImGui::SetCursorPos(ImVec2(8.0f, 8.0f)); + for (size_t i = 0; i < continentIndices.size(); i++) { + int ci = continentIndices[i]; + if (i > 0) ImGui::SameLine(); + const bool selected = (ci == viewState.continentIdx()); + if (selected) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.35f, 0.25f, 0.05f, 0.9f)); + std::string rawName = data.zones()[ci].areaName.empty() ? "Continent" : data.zones()[ci].areaName; + if (rawName == "Azeroth") rawName = mapDisplayName(0); + std::string label = rawName + "##" + std::to_string(ci); + if (ImGui::Button(label.c_str())) { + viewState.setContinentIdx(ci); + compositor.loadZoneTextures(ci, data.zones(), mapName); + compositor.requestComposite(ci); + viewState.setCurrentZoneIdx(ci); + } + if (selected) ImGui::PopStyleColor(); + } + } + + // Render all overlay layers + overlay.render(layerCtx); + + // Zone view: back to continent + zone name + if (vl == ViewLevel::ZONE && viewState.continentIdx() >= 0) { + ImGui::SetCursorPos(ImVec2(8.0f, 8.0f)); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.15f, 0.15f, 0.8f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.3f, 0.3f, 0.1f, 0.9f)); + ImGui::PushStyleColor(ImGuiCol_Text, ui::colors::kBrightGold); + if (ImGui::Button("< Back")) { + compositor.loadZoneTextures(viewState.continentIdx(), data.zones(), mapName); + compositor.requestComposite(viewState.continentIdx()); + viewState.setCurrentZoneIdx(viewState.continentIdx()); + viewState.setLevel(ViewLevel::CONTINENT); + } + ImGui::PopStyleColor(3); + + int curIdx = viewState.currentZoneIdx(); + if (curIdx >= 0 && curIdx < static_cast(data.zones().size())) { + const char* zoneName = data.zones()[curIdx].areaName.c_str(); + ImVec2 nameSize = ImGui::CalcTextSize(zoneName); + float nameY = mapY - nameSize.y - 8.0f; + if (nameY > 0.0f) { + ImGui::SetCursorPos(ImVec2((sw - nameSize.x) / 2.0f, nameY)); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 0.9f), "%s", zoneName); + } + } + } + + // Continent view: back to world + hovered zone name + if (vl == ViewLevel::CONTINENT) { + float localBtnY = (continentIndices.size() > 1 ? 40.0f : 8.0f); + ImGui::SetCursorPos(ImVec2(8.0f, localBtnY)); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.15f, 0.15f, 0.8f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.3f, 0.3f, 0.1f, 0.9f)); + ImGui::PushStyleColor(ImGuiCol_Text, ui::colors::kBrightGold); + if (ImGui::Button("< Azeroth")) { + switchToWorldView(); + } + ImGui::PopStyleColor(3); + + // Show hovered zone name above the map + int hovZone = zoneHighlightLayer ? zoneHighlightLayer->hoveredZone() : -1; + if (hovZone >= 0 && hovZone < static_cast(data.zones().size())) { + const std::string& rawName = data.zones()[hovZone].areaName; + if (!rawName.empty()) { + const ZoneMeta* meta = zoneMetadata.find(rawName); + std::string hoverLabel = ZoneMetadata::formatHoverLabel(rawName, meta); + + ImVec2 hoverSz = ImGui::CalcTextSize(hoverLabel.c_str()); + float hx = imgMin.x + (displayW - hoverSz.x) * 0.5f; + float hy = imgMin.y - hoverSz.y - 8.0f; + if (hy > 0.0f) { + drawList->AddText(ImVec2(hx + 1.0f, hy + 1.0f), + IM_COL32(0, 0, 0, 220), hoverLabel.c_str()); + ImU32 hoverColor = IM_COL32(255, 215, 0, 255); + if (meta) { + switch (meta->faction) { + case ZoneFaction::Alliance: hoverColor = IM_COL32(100, 160, 255, 255); break; + case ZoneFaction::Horde: hoverColor = IM_COL32(255, 80, 80, 255); break; + default: break; + } + } + drawList->AddText(ImVec2(hx, hy), hoverColor, hoverLabel.c_str()); + } + } + } + } + + // Cosmic view: title + clickable landmass regions + if (vl == ViewLevel::COSMIC) { + ImGui::SetCursorPos(ImVec2(8.0f, 8.0f)); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.15f, 0.15f, 0.8f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.3f, 0.3f, 0.1f, 0.9f)); + ImGui::PushStyleColor(ImGuiCol_Text, ui::colors::kBrightGold); + if (ImGui::Button("< Azeroth")) { + switchToWorldView(); + } + ImGui::PopStyleColor(3); + + ImVec2 titleSz = ImGui::CalcTextSize("Cosmic"); + float titleX = imgMin.x + (displayW - titleSz.x) * 0.5f; + float titleY = imgMin.y - titleSz.y - 8.0f; + if (titleY > 0.0f) { + drawList->AddText(ImVec2(titleX + 1.0f, titleY + 1.0f), + IM_COL32(0, 0, 0, 220), "Cosmic"); + drawList->AddText(ImVec2(titleX, titleY), + IM_COL32(255, 215, 0, 255), "Cosmic"); + } + + ImVec2 mp2 = ImGui::GetMousePos(); + auto& io = ImGui::GetIO(); + + for (const auto& entry : data.cosmicMaps()) { + float rx0 = imgMin.x + entry.uvLeft * displayW; + float ry0 = imgMin.y + entry.uvTop * displayH; + float rx1 = imgMin.x + entry.uvRight * displayW; + float ry1 = imgMin.y + entry.uvBottom * displayH; + + bool hovered = (mp2.x >= rx0 && mp2.x <= rx1 && + mp2.y >= ry0 && mp2.y <= ry1); + + if (hovered) { + // Cosmic highlight files: cosmic-{label}-highlight.blp + std::string cosmicLabel = entry.label; + std::transform(cosmicLabel.begin(), cosmicLabel.end(), cosmicLabel.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + std::string cosmicKey = "cosmic-" + cosmicLabel; + std::string cosmicPath = "Interface\\WorldMap\\Cosmic\\cosmic-" + cosmicLabel + "-highlight.blp"; + + // ─── Cosmic Highlight Rendering Logic ─────────────────── + // + // SOURCE TEXTURES: + // cosmic-azeroth-highlight.blp → 512×512 px (DXT3, has alpha) + // cosmic-outland-highlight.blp → 512×512 px (DXT3, has alpha) + // The glow is baked into the alpha channel: + // - Azeroth highlight: glow sits in the RIGHT-CENTER of the texture + // - Outland highlight: glow sits in the LEFT-CENTER of the texture + // + // DISPLAY AREA: + // The map on screen is displayW × displayH pixels. + // displayW/displayH ≈ 1002/668 ≈ 1.5:1 (wider than tall). + // imgMin = top-left corner, imgMax = bottom-right corner. + // + // THE PROBLEM: + // 512×512 is square, but the display area is 1.5× wider than tall. + // If we stretch the texture to fill the full display area + // (imgMin → imgMax), the circular glow becomes an ellipse + // (horizontally stretched ~50%). + // If we render it as a square (side = displayH), it has the + // correct aspect but only covers 2/3 of the map width. + // + // CURRENT APPROACH: + // Render as a square (side = displayH), anchored: + // Azeroth → flush to the RIGHT edge of the map (glow lands bottom-right) + // Outland → flush to the LEFT edge of the map (glow lands top-left) + // This preserves the 1:1 aspect ratio of the glow shape. + // + // TO ADJUST: + // • Make glow wider: increase hlW (e.g. displayH * 1.2f) + // • Make glow taller: increase hlH (e.g. displayH * 1.1f) + // • Full stretch (like WoW original): hlW = displayW, hlH = displayH + // • Shift glow position: adjust hlX offset + // + float hlW = displayW; // width of highlight rect (= square) + float hlH = displayH; // height of highlight rect (= square) + float hlX, hlY; + if (cosmicLabel == "azeroth") { + hlX = imgMax.x - hlW; // flush right + hlY = imgMax.y - hlH; // flush bottom + } else { + hlX = imgMin.x; // flush left + hlY = imgMin.y; // flush top + } + + if (zoneHighlightLayer) { + ImTextureID hlTex = zoneHighlightLayer->getHighlightTexture(cosmicKey, cosmicPath); + if (hlTex) { + drawList->AddImage(hlTex, + ImVec2(hlX, hlY), + ImVec2(hlX + hlW, hlY + hlH), + ImVec2(0, 0), ImVec2(1, 1), + IM_COL32(255, 255, 255, 180)); + } else { + drawList->AddRectFilled(ImVec2(rx0, ry0), ImVec2(rx1, ry1), + IM_COL32(255, 215, 0, 25)); + } + } else { + drawList->AddRectFilled(ImVec2(rx0, ry0), ImVec2(rx1, ry1), + IM_COL32(255, 215, 0, 25)); + } + drawList->AddRect(ImVec2(rx0, ry0), ImVec2(rx1, ry1), + IM_COL32(255, 215, 0, 100), 0, 0, 1.5f); + + ImFont* font = ImGui::GetFont(); + ImVec2 labelSz = font->CalcTextSizeA(ImGui::GetFontSize(), FLT_MAX, 0.0f, + entry.label.c_str()); + float lx = (rx0 + rx1 - labelSz.x) * 0.5f; + float ly = ry0 - labelSz.y - 4.0f; + if (ly < imgMin.y) ly = ry0 + 4.0f; + drawList->AddText(ImVec2(lx + 1.0f, ly + 1.0f), + IM_COL32(0, 0, 0, 200), entry.label.c_str()); + drawList->AddText(ImVec2(lx, ly), + IM_COL32(255, 230, 100, 255), entry.label.c_str()); + + if (io.MouseClicked[0]) { + if (entry.label == "Outland") { + switchToMap("Expansion01"); + } else { + viewState.enterWorldView(); + int wIdx = data.worldIdx(); + if (wIdx >= 0) { + compositor.loadZoneTextures(wIdx, data.zones(), mapName); + compositor.invalidateComposite(); + compositor.requestComposite(wIdx); + viewState.setCurrentZoneIdx(wIdx); + } + } + break; + } + } + } + } + + // Help text + const char* helpText; + if (vl == ViewLevel::ZONE) + helpText = "Right-click to zoom out | M or Escape to close"; + else if (vl == ViewLevel::COSMIC) + helpText = "Scroll in or click to zoom in | M or Escape to close"; + else if (vl == ViewLevel::WORLD && viewState.cosmicEnabled()) + helpText = "Click a continent | Right-click for Cosmic view | M or Escape to close"; + else if (vl == ViewLevel::WORLD) + helpText = "Click a continent | M or Escape to close"; + else + helpText = "Click zone to open | Right-click to zoom out | M or Escape to close"; + + ImVec2 textSize = ImGui::CalcTextSize(helpText); + float textX = mapX + (displayW - textSize.x) / 2.0f; + float textY = mapY + displayH - textSize.y - 4.0f; + ImGui::SetCursorScreenPos(ImVec2(textX, textY)); + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 0.8f), "%s", helpText); + } + ImGui::End(); + + ImGui::PopStyleVar(2); // WindowPadding + ItemSpacing +} + +} // namespace world_map +} // namespace rendering +} // namespace wowee diff --git a/src/rendering/world_map/zone_metadata.cpp b/src/rendering/world_map/zone_metadata.cpp new file mode 100644 index 00000000..4b8cc8dd --- /dev/null +++ b/src/rendering/world_map/zone_metadata.cpp @@ -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 diff --git a/src/ui/action_bar_panel.cpp b/src/ui/action_bar_panel.cpp index 49460c14..9785a105 100644 --- a/src/ui/action_bar_panel.cpp +++ b/src/ui/action_bar_panel.cpp @@ -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()); diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index f1d74efa..435b73b9 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -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 { diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 3e543dc3..8ac5b138 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -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) diff --git a/tests/test_world_map.cpp b/tests/test_world_map.cpp new file mode 100644 index 00000000..ca8d7364 --- /dev/null +++ b/tests/test_world_map.cpp @@ -0,0 +1,498 @@ +// Tests for WorldMap data structures and coordinate math +// Updated to use new modular types from world_map_types.hpp +#include +#include "rendering/world_map/world_map_types.hpp" + +#include +#include +#include +#include +#include +#include + +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 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 filterContinentsByExpansion( + const std::vector& mapIds, int expansionLevel) { + std::vector 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 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 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 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 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(mu * ZMP_SIZE), 0, ZMP_SIZE - 1); + int row = std::clamp(static_cast(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 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 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(mu * ZMP_SIZE), 0, ZMP_SIZE - 1); + int row = std::clamp(static_cast(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(mu * ZMP_SIZE), 0, ZMP_SIZE - 1); + row = std::clamp(static_cast(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 overlays = {large, small}; + + float px = 150.0f, py = 120.0f; // Inside both HitRects + std::string best; + float bestArea = std::numeric_limits::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 buildCosmicMaps(int expLevel) { + std::vector 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& overlays, + float mu, float mv, float fboW, float fboH) { + std::string best; + float bestArea = std::numeric_limits::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 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 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 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)"); +} diff --git a/tests/test_world_map_coordinate_projection.cpp b/tests/test_world_map_coordinate_projection.cpp new file mode 100644 index 00000000..1b243280 --- /dev/null +++ b/tests/test_world_map_coordinate_projection.cpp @@ -0,0 +1,193 @@ +// Tests for the extracted world map coordinate projection module +#include +#include "rendering/world_map/coordinate_projection.hpp" +#include "rendering/world_map/world_map_types.hpp" + +#include +#include +#include + +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 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 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 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 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 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 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 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 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 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 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 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); +} diff --git a/tests/test_world_map_exploration_state.cpp b/tests/test_world_map_exploration_state.cpp new file mode 100644 index 00000000..be940a66 --- /dev/null +++ b/tests/test_world_map_exploration_state.cpp @@ -0,0 +1,89 @@ +// Tests for the extracted world map exploration state module +#include +#include "rendering/world_map/exploration_state.hpp" +#include "rendering/world_map/world_map_types.hpp" + +#include +#include +#include +#include + +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 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 zones; + std::unordered_map 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 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 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); +} diff --git a/tests/test_world_map_map_resolver.cpp b/tests/test_world_map_map_resolver.cpp new file mode 100644 index 00000000..d2472512 --- /dev/null +++ b/tests/test_world_map_map_resolver.cpp @@ -0,0 +1,212 @@ +// Tests for the map_resolver module — centralized map navigation resolution. +#include +#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 buildAzerothZones() { + std::vector 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 buildSimpleZones() { + std::vector 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(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 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 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); +} diff --git a/tests/test_world_map_view_state_machine.cpp b/tests/test_world_map_view_state_machine.cpp new file mode 100644 index 00000000..7b06fe49 --- /dev/null +++ b/tests/test_world_map_view_state_machine.cpp @@ -0,0 +1,198 @@ +// Tests for the extracted world map view state machine module +#include +#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); +} diff --git a/tests/test_world_map_zone_metadata.cpp b/tests/test_world_map_zone_metadata.cpp new file mode 100644 index 00000000..59b824ec --- /dev/null +++ b/tests/test_world_map_zone_metadata.cpp @@ -0,0 +1,86 @@ +// Tests for the extracted world map zone metadata module +#include +#include "rendering/world_map/zone_metadata.hpp" +#include "rendering/world_map/world_map_types.hpp" + +#include + +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); +}