diff --git a/CMakeLists.txt b/CMakeLists.txt index 304d1e98..8dffbcdc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -134,6 +134,7 @@ set(WOWEE_SOURCES src/rendering/wmo_renderer.cpp src/rendering/m2_renderer.cpp src/rendering/minimap.cpp + src/rendering/world_map.cpp src/rendering/swim_effects.cpp src/rendering/loading_screen.cpp @@ -217,6 +218,7 @@ set(WOWEE_HEADERS include/rendering/weather.hpp include/rendering/lightning.hpp include/rendering/swim_effects.hpp + include/rendering/world_map.hpp include/rendering/character_renderer.hpp include/rendering/wmo_renderer.hpp include/rendering/loading_screen.hpp diff --git a/include/rendering/minimap.hpp b/include/rendering/minimap.hpp index 9c3e8f07..c84822b3 100644 --- a/include/rendering/minimap.hpp +++ b/include/rendering/minimap.hpp @@ -34,9 +34,14 @@ public: void setViewRadius(float radius) { viewRadius = radius; } + // Public accessors for WorldMap + GLuint getOrLoadTileTexture(int tileX, int tileY); + void ensureTRSParsed() { if (!trsParsed) parseTRS(); } + GLuint getTileQuadVAO() const { return tileQuadVAO; } + const std::string& getMapName() const { return mapName; } + private: void parseTRS(); - GLuint getOrLoadTileTexture(int tileX, int tileY); void compositeTilesToFBO(const glm::vec3& centerWorldPos); void renderQuad(const Camera& playerCamera, const glm::vec3& centerWorldPos, int screenWidth, int screenHeight); diff --git a/include/rendering/world_map.hpp b/include/rendering/world_map.hpp new file mode 100644 index 00000000..e501495c --- /dev/null +++ b/include/rendering/world_map.hpp @@ -0,0 +1,86 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace wowee { +namespace pipeline { class AssetManager; } +namespace rendering { + +class Shader; + +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; + + // Per-zone cached textures + GLuint tileTextures[12] = {}; + bool tilesLoaded = false; +}; + +class WorldMap { +public: + WorldMap(); + ~WorldMap(); + + void initialize(pipeline::AssetManager* assetManager); + void render(const glm::vec3& playerRenderPos, int screenWidth, int screenHeight); + void setMapName(const std::string& name); + bool isOpen() const { return open; } + void close() { open = false; } + +private: + enum class ViewLevel { WORLD, CONTINENT, ZONE }; + + void createFBO(); + void createTileShader(); + void createQuad(); + void enterWorldView(); + void loadZonesFromDBC(); + int findBestContinentForPlayer(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 compositeZone(int zoneIdx); + void renderImGuiOverlay(const glm::vec3& playerRenderPos, int screenWidth, int screenHeight); + + // World pos → map UV using a specific zone's bounds + glm::vec2 renderPosToMapUV(const glm::vec3& renderPos, int zoneIdx) const; + + 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; // index of AreaID=0 entry in zones + int currentIdx = -1; // currently displayed zone index + ViewLevel viewLevel = ViewLevel::CONTINENT; + int compositedIdx = -1; // which zone is currently composited in FBO + + // FBO for composited map (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; // 1024 + static constexpr int FBO_H = GRID_ROWS * TILE_PX; // 768 + + GLuint fbo = 0; + GLuint fboTexture = 0; + std::unique_ptr tileShader; + GLuint tileQuadVAO = 0; + GLuint tileQuadVBO = 0; +}; + +} // namespace rendering +} // namespace wowee diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 3e39ac4f..f15da1d3 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -2,6 +2,7 @@ #include "game/game_handler.hpp" #include "game/inventory.hpp" +#include "rendering/world_map.hpp" #include "ui/inventory_screen.hpp" #include "ui/spellbook_screen.hpp" #include @@ -122,8 +123,11 @@ private: /** * Inventory screen */ + void renderWorldMap(game::GameHandler& gameHandler); + InventoryScreen inventoryScreen; SpellbookScreen spellbookScreen; + rendering::WorldMap worldMap; }; }} // namespace wowee::ui diff --git a/src/rendering/world_map.cpp b/src/rendering/world_map.cpp new file mode 100644 index 00000000..0d1cfeff --- /dev/null +++ b/src/rendering/world_map.cpp @@ -0,0 +1,1045 @@ +#include "rendering/world_map.hpp" +#include "rendering/shader.hpp" +#include "pipeline/asset_manager.hpp" +#include "core/coordinates.hpp" +#include "core/input.hpp" +#include "core/logger.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 + +WorldMap::WorldMap() = default; + +WorldMap::~WorldMap() { + if (fbo) glDeleteFramebuffers(1, &fbo); + if (fboTexture) glDeleteTextures(1, &fboTexture); + if (tileQuadVAO) glDeleteVertexArrays(1, &tileQuadVAO); + if (tileQuadVBO) glDeleteBuffers(1, &tileQuadVBO); + for (auto& zone : zones) { + for (auto& tex : zone.tileTextures) { + if (tex) glDeleteTextures(1, &tex); + } + } + tileShader.reset(); +} + +void WorldMap::initialize(pipeline::AssetManager* am) { + if (initialized) return; + assetManager = am; + createFBO(); + createTileShader(); + createQuad(); + initialized = true; + LOG_INFO("WorldMap initialized (", FBO_W, "x", FBO_H, " FBO)"); +} + +void WorldMap::setMapName(const std::string& name) { + if (mapName == name && !zones.empty()) return; + mapName = name; + // Clear old zone data + for (auto& zone : zones) { + for (auto& tex : zone.tileTextures) { + if (tex) { glDeleteTextures(1, &tex); tex = 0; } + } + } + zones.clear(); + continentIdx = -1; + currentIdx = -1; + compositedIdx = -1; + viewLevel = ViewLevel::WORLD; +} + +// -------------------------------------------------------- +// GL resource creation +// -------------------------------------------------------- + +void WorldMap::createFBO() { + glGenFramebuffers(1, &fbo); + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + + glGenTextures(1, &fboTexture); + glBindTexture(GL_TEXTURE_2D, fboTexture); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, FBO_W, FBO_H, 0, + GL_RGBA, GL_UNSIGNED_BYTE, nullptr); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, fboTexture, 0); + + if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) { + LOG_ERROR("WorldMap FBO incomplete"); + } + glBindFramebuffer(GL_FRAMEBUFFER, 0); +} + +void WorldMap::createTileShader() { + const char* vertSrc = R"( + #version 330 core + layout (location = 0) in vec2 aPos; + layout (location = 1) in vec2 aUV; + + uniform vec2 uGridOffset; // (col, row) in grid + uniform float uGridCols; + uniform float uGridRows; + + out vec2 TexCoord; + + void main() { + vec2 gridPos = vec2( + (uGridOffset.x + aPos.x) / uGridCols, + (uGridOffset.y + aPos.y) / uGridRows + ); + gl_Position = vec4(gridPos * 2.0 - 1.0, 0.0, 1.0); + TexCoord = aUV; + } + )"; + + const char* fragSrc = R"( + #version 330 core + in vec2 TexCoord; + + uniform sampler2D uTileTexture; + + out vec4 FragColor; + + void main() { + FragColor = texture(uTileTexture, TexCoord); + } + )"; + + tileShader = std::make_unique(); + if (!tileShader->loadFromSource(vertSrc, fragSrc)) { + LOG_ERROR("Failed to create WorldMap tile shader"); + } +} + +void WorldMap::createQuad() { + float quadVerts[] = { + // pos (x,y), uv (u,v) + 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, + }; + + glGenVertexArrays(1, &tileQuadVAO); + glGenBuffers(1, &tileQuadVBO); + glBindVertexArray(tileQuadVAO); + glBindBuffer(GL_ARRAY_BUFFER, tileQuadVBO); + glBufferData(GL_ARRAY_BUFFER, sizeof(quadVerts), quadVerts, GL_STATIC_DRAW); + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), (void*)0); + glEnableVertexAttribArray(1); + glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), (void*)(2 * sizeof(float))); + glBindVertexArray(0); +} + +// -------------------------------------------------------- +// DBC zone loading +// -------------------------------------------------------- + +void WorldMap::loadZonesFromDBC() { + if (!zones.empty() || !assetManager) return; + + // Step 1: Resolve mapID from Map.dbc + 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, 1); + if (dir == mapName) { + mapID = static_cast(mapDbc->getUInt32(i, 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; + } + } + + // Step 2: Load ALL WorldMapArea records for this mapID + auto wmaDbc = assetManager->loadDBC("WorldMapArea.dbc"); + if (!wmaDbc || !wmaDbc->isLoaded()) { + LOG_WARNING("WorldMap: WorldMapArea.dbc not found"); + return; + } + + LOG_INFO("WorldMap: WorldMapArea.dbc has ", wmaDbc->getFieldCount(), + " fields, ", wmaDbc->getRecordCount(), " records"); + + // WorldMapArea.dbc layout (11 fields, no localized strings): + // 0: ID, 1: MapID, 2: AreaID, 3: AreaName (stringref) + // 4: locLeft, 5: locRight, 6: locTop, 7: locBottom + // 8: displayMapID, 9: defaultDungeonFloor, 10: parentWorldMapID + + for (uint32_t i = 0; i < wmaDbc->getRecordCount(); i++) { + uint32_t recMapID = wmaDbc->getUInt32(i, 1); + if (static_cast(recMapID) != mapID) continue; + + WorldMapZone zone; + zone.wmaID = wmaDbc->getUInt32(i, 0); + zone.areaID = wmaDbc->getUInt32(i, 2); + zone.areaName = wmaDbc->getString(i, 3); + zone.locLeft = wmaDbc->getFloat(i, 4); + zone.locRight = wmaDbc->getFloat(i, 5); + zone.locTop = wmaDbc->getFloat(i, 6); + zone.locBottom = wmaDbc->getFloat(i, 7); + zone.displayMapID = wmaDbc->getUInt32(i, 8); + zone.parentWorldMapID = wmaDbc->getUInt32(i, 10); + + int idx = static_cast(zones.size()); + + // Debug: also log raw uint32 values for bounds fields + uint32_t raw4 = wmaDbc->getUInt32(i, 4); + uint32_t raw5 = wmaDbc->getUInt32(i, 5); + uint32_t raw6 = wmaDbc->getUInt32(i, 6); + uint32_t raw7 = wmaDbc->getUInt32(i, 7); + + LOG_INFO("WorldMap: zone[", idx, "] areaID=", zone.areaID, + " '", zone.areaName, "' L=", zone.locLeft, + " R=", zone.locRight, " T=", zone.locTop, + " B=", zone.locBottom, + " (raw4=", raw4, " raw5=", raw5, + " raw6=", raw6, " raw7=", raw7, ")"); + + if (zone.areaID == 0 && continentIdx < 0) { + continentIdx = idx; + } + + zones.push_back(std::move(zone)); + } + + // For each continent entry with missing bounds, derive bounds from its child zones only. + 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; + } + + // Prefer explicit parent linkage when deriving continent extents. + 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::max(cont.locLeft, z.locLeft); + cont.locRight = std::min(cont.locRight, z.locRight); + cont.locTop = std::max(cont.locTop, z.locTop); + cont.locBottom = std::min(cont.locBottom, z.locBottom); + } + } + + if (!first) { + LOG_INFO("WorldMap: computed bounds for continent '", cont.areaName, "': L=", cont.locLeft, + " R=", cont.locRight, " T=", cont.locTop, " B=", cont.locBottom); + } + } + + LOG_INFO("WorldMap: loaded ", zones.size(), " zones for mapID=", mapID, + ", continentIdx=", continentIdx); +} + +int WorldMap::findBestContinentForPlayer(const glm::vec3& playerRenderPos) const { + float wowX = playerRenderPos.y; // north/south + float wowY = playerRenderPos.x; // west/east + + 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) { + // Fallback if player isn't inside any continent bounds: nearest center. + float cx = (minX + maxX) * 0.5f; + float cy = (minY + maxY) * 0.5f; + float dx = wowX - cx; + float dy = wowY - cy; + float dist2 = dx * dx + dy * dy; + if (dist2 < bestCenterDist2) { + bestCenterDist2 = dist2; + 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; + + // Prefer explicit parent linkage from WorldMapArea.dbc. + 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; + + // Fallback: assign zone to the continent with highest overlap area. + 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; + } + + // Last resort: center-point containment. + float centerX = (z.locLeft + z.locRight) * 0.5f; + float centerY = (z.locTop + z.locBottom) * 0.5f; + float cMinX = rectMinX(cont), cMaxX = rectMaxX(cont); + float cMinY = rectMinY(cont), cMaxY = rectMaxY(cont); + return centerX >= cMinX && centerX <= cMaxX && + centerY >= cMinY && centerY <= cMaxY; +} + +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; + + // Prefer authored continent bounds from DBC when available. + 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; + std::vector southEdges; + std::vector westEdges; + std::vector 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; + } + + // Fallback: derive full extents from child zones. + 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 +// -------------------------------------------------------- + +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"); + } + + 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] = 0; + continue; + } + + GLuint tex; + glGenTextures(1, &tex); + glBindTexture(GL_TEXTURE_2D, tex); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, blpImage.width, blpImage.height, 0, + GL_RGBA, GL_UNSIGNED_BYTE, blpImage.data.data()); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + + zone.tileTextures[i] = tex; + loaded++; + } + + LOG_INFO("WorldMap: loaded ", loaded, "/12 tiles for '", folder, "'"); +} + +// -------------------------------------------------------- +// Composite a zone's tiles into the FBO +// -------------------------------------------------------- + +void WorldMap::compositeZone(int zoneIdx) { + if (zoneIdx < 0 || zoneIdx >= static_cast(zones.size())) return; + if (compositedIdx == zoneIdx) return; + + const auto& zone = zones[zoneIdx]; + + // Save GL state + GLint prevFBO = 0; + glGetIntegerv(GL_FRAMEBUFFER_BINDING, &prevFBO); + GLint prevViewport[4]; + glGetIntegerv(GL_VIEWPORT, prevViewport); + GLboolean prevBlend = glIsEnabled(GL_BLEND); + GLboolean prevDepthTest = glIsEnabled(GL_DEPTH_TEST); + + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glViewport(0, 0, FBO_W, FBO_H); + glClearColor(0.05f, 0.08f, 0.12f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + + glDisable(GL_DEPTH_TEST); + glDisable(GL_CULL_FACE); + glDisable(GL_BLEND); + glPolygonMode(GL_FRONT_AND_BACK, GL_FILL); + + tileShader->use(); + tileShader->setUniform("uTileTexture", 0); + tileShader->setUniform("uGridCols", static_cast(GRID_COLS)); + tileShader->setUniform("uGridRows", static_cast(GRID_ROWS)); + + glBindVertexArray(tileQuadVAO); + + // Tiles 1-12 in a 4x3 grid: tile N at col=(N-1)%4, row=(N-1)/4 + // Row 0 (tiles 1-4) = top of image (north) → placed at FBO bottom (GL y=0) + // ImGui::Image maps GL (0,0) → widget top-left → north at top ✓ + for (int i = 0; i < 12; i++) { + if (zone.tileTextures[i] == 0) continue; + + int col = i % GRID_COLS; + int row = i / GRID_COLS; + + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, zone.tileTextures[i]); + + tileShader->setUniform("uGridOffset", glm::vec2( + static_cast(col), static_cast(row))); + glDrawArrays(GL_TRIANGLES, 0, 6); + } + + glBindVertexArray(0); + + // Restore GL state + glBindFramebuffer(GL_FRAMEBUFFER, prevFBO); + glViewport(prevViewport[0], prevViewport[1], prevViewport[2], prevViewport[3]); + if (prevBlend) glEnable(GL_BLEND); + if (prevDepthTest) glEnable(GL_DEPTH_TEST); + + 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 (GLuint tex : zones[rootIdx].tileTextures) { + if (tex != 0) { hasAnyTile = true; break; } + } + if (hasAnyTile) { + compositeZone(rootIdx); + currentIdx = rootIdx; + return; + } + } + + // Fallback: use first leaf continent as world-view backdrop. + 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); + compositeZone(fallbackContinent); + currentIdx = fallbackContinent; + return; + } + + // No root world texture available: clear to neutral background. + currentIdx = -1; + compositedIdx = -1; + GLint prevFBO = 0; + glGetIntegerv(GL_FRAMEBUFFER_BINDING, &prevFBO); + GLint prevViewport[4]; + glGetIntegerv(GL_VIEWPORT, prevViewport); + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glViewport(0, 0, FBO_W, FBO_H); + glClearColor(0.05f, 0.08f, 0.12f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + glBindFramebuffer(GL_FRAMEBUFFER, prevFBO); + glViewport(prevViewport[0], prevViewport[1], prevViewport[2], prevViewport[3]); +} + +// -------------------------------------------------------- +// 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]; + + // renderPos: x = wowY (west axis), y = wowX (north axis) + float wowX = renderPos.y; // north + float wowY = renderPos.x; // west + + float left = zone.locLeft; + float right = zone.locRight; + float top = zone.locTop; + float 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; + } + } + + // WorldMapArea.dbc axis mapping: + // locLeft/locRight contain wowX values (N/S), locLeft=north > locRight=south + // locTop/locBottom contain wowY values (W/E), locTop=west > locBottom=east + // World map textures are laid out with axes transposed, so horizontal uses wowX. + float denom_h = left - right; // wowX span (N-S) → horizontal + float denom_v = top - bottom; // wowY span (W-E) → vertical + + 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; + + // Continent overlay calibration: shift overlays/player marker upward. + if (zone.areaID == 0) { + constexpr float kVScale = 1.0f; + constexpr float kVOffset = -0.15f; // ~15% upward total + v = (v - 0.5f) * kVScale + 0.5f + kVOffset; + } + return glm::vec2(u, v); +} + +// -------------------------------------------------------- +// Main render +// -------------------------------------------------------- + +void WorldMap::render(const glm::vec3& playerRenderPos, int screenWidth, int screenHeight) { + if (!initialized || !assetManager) return; + + auto& input = core::Input::getInstance(); + + // When map is open, always allow M/Escape to close (bypass ImGui keyboard capture) + if (open) { + if (input.isKeyJustPressed(SDL_SCANCODE_M) || + input.isKeyJustPressed(SDL_SCANCODE_ESCAPE)) { + open = false; + return; + } + } else { + auto& io = ImGui::GetIO(); + if (!io.WantCaptureKeyboard && input.isKeyJustPressed(SDL_SCANCODE_M)) { + open = true; + + // Lazy-load zone data on first open + if (zones.empty()) loadZonesFromDBC(); + + int bestContinent = findBestContinentForPlayer(playerRenderPos); + if (bestContinent >= 0 && bestContinent != continentIdx) { + continentIdx = bestContinent; + compositedIdx = -1; + } + + // Ensure continent textures are loaded and composited + if (continentIdx >= 0) { + loadZoneTextures(continentIdx); + compositeZone(continentIdx); + currentIdx = continentIdx; + viewLevel = ViewLevel::CONTINENT; + } + } + } + + if (!open) return; + + renderImGuiOverlay(playerRenderPos, screenWidth, screenHeight); +} + +// -------------------------------------------------------- +// ImGui overlay +// -------------------------------------------------------- + +void WorldMap::renderImGuiOverlay(const glm::vec3& playerRenderPos, int screenWidth, int screenHeight) { + float sw = static_cast(screenWidth); + float sh = static_cast(screenHeight); + + // Full-screen dark background + 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)) { + // Map display area: maintain 4:3 aspect ratio, fit within ~85% of screen + float mapAspect = static_cast(FBO_W) / static_cast(FBO_H); // 1.333 + 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)); + ImGui::Image(static_cast(static_cast(fboTexture)), + 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 we have multiple continent choices, hide the root/world alias entry + // (commonly "Azeroth") so picker only shows real continents. + 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); + compositeZone(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); + compositeZone(continentIdx); + currentIdx = continentIdx; + } + if (selected) ImGui::PopStyleColor(); + } + } + + // Player marker on current view + 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; + + drawList->AddCircleFilled(ImVec2(px, py), 6.0f, + IM_COL32(255, 40, 40, 255)); + drawList->AddCircle(ImVec2(px, py), 6.0f, + IM_COL32(0, 0, 0, 200), 0, 2.0f); + } + } + + // --- Continent view: show clickable zone overlays --- + if (viewLevel == ViewLevel::CONTINENT && continentIdx >= 0) { + const auto& cont = zones[continentIdx]; + // World map textures are transposed; match the same axis mapping as player UV. + float cLeft = cont.locLeft; + float cRight = cont.locRight; + float cTop = cont.locTop; + float cBottom = cont.locBottom; + getContinentProjectionBounds(continentIdx, cLeft, cRight, cTop, cBottom); + float cDenomU = cLeft - cRight; // wowX span (N-S) + float cDenomV = cTop - cBottom; // wowY span (W-E) + + 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]; + + // Skip zones with zero-size bounds + if (std::abs(z.locLeft - z.locRight) < 0.001f || + std::abs(z.locTop - z.locBottom) < 0.001f) continue; + + // Project zone bounds to continent UV + // u axis (left->right): north->south + float zuMin = (cLeft - z.locLeft) / cDenomU; // zone north edge + float zuMax = (cLeft - z.locRight) / cDenomU; // zone south edge + // v axis (top->bottom): west->east + float zvMin = (cTop - z.locTop) / cDenomV; // zone west edge + float zvMax = (cTop - z.locBottom) / cDenomV; // zone east edge + + // Slightly shrink DBC AABB overlays to reduce heavy overlap. + constexpr float kOverlayShrink = 0.92f; + float cu = (zuMin + zuMax) * 0.5f; + float 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; + + // Continent overlay calibration (matches player marker calibration). + constexpr float kVScale = 1.0f; + constexpr float kVOffset = -0.15f; + zvMin = (zvMin - 0.5f) * kVScale + 0.5f + kVOffset; + zvMax = (zvMax - 0.5f) * kVScale + 0.5f + kVOffset; + + // Clamp to [0,1] + 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); + + // Skip tiny or degenerate zones + if (zuMax - zuMin < 0.001f || zvMax - zvMin < 0.001f) continue; + + // Convert to screen coordinates + float sx0 = imgMin.x + zuMin * displayW; + float sy0 = imgMin.y + zvMin * displayH; + float sx1 = imgMin.x + zuMax * displayW; + float sy1 = imgMin.y + zvMax * displayH; + + // Check hover + bool hovered = (mousePos.x >= sx0 && mousePos.x <= sx1 && + mousePos.y >= sy0 && mousePos.y <= sy1); + + 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 { + drawList->AddRect(ImVec2(sx0, sy0), ImVec2(sx1, sy1), + IM_COL32(255, 255, 255, 30), 0.0f, 0, 1.0f); + } + } + } + + // Zone name tooltip + if (hoveredZone >= 0) { + ImGui::SetTooltip("%s", zones[hoveredZone].areaName.c_str()); + + // Click to zoom into zone + if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { + loadZoneTextures(hoveredZone); + compositeZone(hoveredZone); + currentIdx = hoveredZone; + viewLevel = ViewLevel::ZONE; + } + } + } + + // --- Zone view: back to continent --- + if (viewLevel == ViewLevel::ZONE && continentIdx >= 0) { + // Right-click or Back button + auto& io = ImGui::GetIO(); + bool goBack = io.MouseClicked[1]; // right-click (direct IO check) + + // "< Back" button in top-left of map area + 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, ImVec4(1.0f, 0.85f, 0.0f, 1.0f)); + if (ImGui::Button("< Back")) goBack = true; + ImGui::PopStyleColor(3); + + if (goBack) { + compositeZone(continentIdx); + currentIdx = continentIdx; + viewLevel = ViewLevel::CONTINENT; + } + + // Zone name header + 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, ImVec4(1.0f, 0.85f, 0.0f, 1.0f)); + if (ImGui::Button("< World")) goWorld = true; + ImGui::PopStyleColor(3); + + if (goWorld) enterWorldView(); + } + + // Help text + const char* helpText; + if (viewLevel == ViewLevel::ZONE) { + helpText = "Right-click to zoom out | M or Escape to close"; + } else if (viewLevel == ViewLevel::WORLD) { + helpText = "Select a continent | M or Escape to close"; + } else { + helpText = "Click a zone to zoom in | 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/ui/game_screen.cpp b/src/ui/game_screen.cpp index b7a833df..4aff77b4 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -4,6 +4,7 @@ #include "core/spawn_presets.hpp" #include "core/input.hpp" #include "rendering/renderer.hpp" +#include "rendering/minimap.hpp" #include "rendering/character_renderer.hpp" #include "rendering/camera.hpp" #include "pipeline/asset_manager.hpp" @@ -80,6 +81,9 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderGossipWindow(gameHandler); renderVendorWindow(gameHandler); + // World map (M key toggle handled inside) + renderWorldMap(gameHandler); + // Teleporter panel (T key toggle handled in Application event loop) renderTeleporterPanel(); @@ -964,6 +968,31 @@ void GameScreen::updateCharacterTextures(game::Inventory& inventory) { } } +// ============================================================ +// World Map +// ============================================================ + +void GameScreen::renderWorldMap(game::GameHandler& /* gameHandler */) { + auto& app = core::Application::getInstance(); + auto* renderer = app.getRenderer(); + auto* assetMgr = app.getAssetManager(); + if (!renderer || !assetMgr) return; + + worldMap.initialize(assetMgr); + + // Keep map name in sync with minimap's map name + auto* minimap = renderer->getMinimap(); + if (minimap) { + worldMap.setMapName(minimap->getMapName()); + } + + glm::vec3 playerPos = renderer->getCharacterPosition(); + auto* window = app.getWindow(); + int screenW = window ? window->getWidth() : 1280; + int screenH = window ? window->getHeight() : 720; + worldMap.render(playerPos, screenW, screenH); +} + // ============================================================ // Action Bar (Phase 3) // ============================================================