mirror of
https://github.com/Kelsidavis/WoWee.git
synced 2026-04-17 17:43:52 +00:00
Rewrite minimap to use pre-baked BLP tile textures from MPQ archives
Replace the 3D top-down rendered minimap with WoW's native pre-rendered BLP tile textures loaded via md5translate.trs. Tiles are composited into a 3x3 grid FBO with proper axis transposition matching the ADT terrain convention. The screen shader provides camera-rotation, circular mask, directional player arrow, and 20% transparency. Minimap is now on by default with the N toggle key removed.
This commit is contained in:
parent
d8e2becbaa
commit
47945451be
4 changed files with 435 additions and 129 deletions
|
|
@ -4,13 +4,15 @@
|
||||||
#include <glm/glm.hpp>
|
#include <glm/glm.hpp>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
#include <unordered_map>
|
||||||
|
|
||||||
namespace wowee {
|
namespace wowee {
|
||||||
|
namespace pipeline { class AssetManager; }
|
||||||
namespace rendering {
|
namespace rendering {
|
||||||
|
|
||||||
class Shader;
|
class Shader;
|
||||||
class Camera;
|
class Camera;
|
||||||
class TerrainRenderer;
|
|
||||||
|
|
||||||
class Minimap {
|
class Minimap {
|
||||||
public:
|
public:
|
||||||
|
|
@ -20,7 +22,8 @@ public:
|
||||||
bool initialize(int size = 200);
|
bool initialize(int size = 200);
|
||||||
void shutdown();
|
void shutdown();
|
||||||
|
|
||||||
void setTerrainRenderer(TerrainRenderer* tr) { terrainRenderer = tr; }
|
void setAssetManager(pipeline::AssetManager* am) { assetManager = am; }
|
||||||
|
void setMapName(const std::string& name);
|
||||||
|
|
||||||
void render(const Camera& playerCamera, const glm::vec3& centerWorldPos,
|
void render(const Camera& playerCamera, const glm::vec3& centerWorldPos,
|
||||||
int screenWidth, int screenHeight);
|
int screenWidth, int screenHeight);
|
||||||
|
|
@ -32,15 +35,33 @@ public:
|
||||||
void setViewRadius(float radius) { viewRadius = radius; }
|
void setViewRadius(float radius) { viewRadius = radius; }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void renderTerrainToFBO(const Camera& playerCamera, const glm::vec3& centerWorldPos);
|
void parseTRS();
|
||||||
void renderQuad(int screenWidth, int screenHeight);
|
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);
|
||||||
|
|
||||||
TerrainRenderer* terrainRenderer = nullptr;
|
pipeline::AssetManager* assetManager = nullptr;
|
||||||
|
std::string mapName = "Azeroth";
|
||||||
|
|
||||||
// FBO for offscreen rendering
|
// TRS lookup: "Azeroth\map32_49" → "e7f0dea73ee6baca78231aaf4b7e772a"
|
||||||
GLuint fbo = 0;
|
std::unordered_map<std::string, std::string> trsLookup;
|
||||||
GLuint fboTexture = 0;
|
bool trsParsed = false;
|
||||||
GLuint fboDepth = 0;
|
|
||||||
|
// Tile texture cache: hash → GL texture ID
|
||||||
|
std::unordered_map<std::string, GLuint> tileTextureCache;
|
||||||
|
GLuint noDataTexture = 0; // dark fallback for missing tiles
|
||||||
|
|
||||||
|
// Composite FBO (3x3 tiles = 768x768)
|
||||||
|
GLuint compositeFBO = 0;
|
||||||
|
GLuint compositeTexture = 0;
|
||||||
|
static constexpr int TILE_PX = 256;
|
||||||
|
static constexpr int COMPOSITE_PX = TILE_PX * 3; // 768
|
||||||
|
|
||||||
|
// Tile compositing quad
|
||||||
|
GLuint tileQuadVAO = 0;
|
||||||
|
GLuint tileQuadVBO = 0;
|
||||||
|
std::unique_ptr<Shader> tileShader;
|
||||||
|
|
||||||
// Screen quad
|
// Screen quad
|
||||||
GLuint quadVAO = 0;
|
GLuint quadVAO = 0;
|
||||||
|
|
@ -48,13 +69,19 @@ private:
|
||||||
std::unique_ptr<Shader> quadShader;
|
std::unique_ptr<Shader> quadShader;
|
||||||
|
|
||||||
int mapSize = 200;
|
int mapSize = 200;
|
||||||
float viewRadius = 500.0f;
|
float viewRadius = 400.0f; // world units visible in minimap radius
|
||||||
bool enabled = false;
|
bool enabled = true;
|
||||||
|
|
||||||
|
// Throttling
|
||||||
float updateIntervalSec = 0.25f;
|
float updateIntervalSec = 0.25f;
|
||||||
float updateDistance = 6.0f;
|
float updateDistance = 6.0f;
|
||||||
std::chrono::steady_clock::time_point lastUpdateTime = std::chrono::steady_clock::time_point{};
|
std::chrono::steady_clock::time_point lastUpdateTime{};
|
||||||
glm::vec3 lastUpdatePos = glm::vec3(0.0f);
|
glm::vec3 lastUpdatePos{0.0f};
|
||||||
bool hasCachedFrame = false;
|
bool hasCachedFrame = false;
|
||||||
|
|
||||||
|
// Tile tracking
|
||||||
|
int lastCenterTileX = -1;
|
||||||
|
int lastCenterTileY = -1;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace rendering
|
} // namespace rendering
|
||||||
|
|
|
||||||
|
|
@ -236,12 +236,6 @@ void Application::run() {
|
||||||
LOG_INFO("Performance HUD: ", enabled ? "ON" : "OFF");
|
LOG_INFO("Performance HUD: ", enabled ? "ON" : "OFF");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// N: Toggle minimap
|
|
||||||
else if (event.key.keysym.scancode == SDL_SCANCODE_N) {
|
|
||||||
if (renderer && renderer->getMinimap()) {
|
|
||||||
renderer->getMinimap()->toggle();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// T: Toggle teleporter panel
|
// T: Toggle teleporter panel
|
||||||
else if (event.key.keysym.scancode == SDL_SCANCODE_T) {
|
else if (event.key.keysym.scancode == SDL_SCANCODE_T) {
|
||||||
if (state == AppState::IN_GAME && uiManager) {
|
if (state == AppState::IN_GAME && uiManager) {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
#include "rendering/minimap.hpp"
|
#include "rendering/minimap.hpp"
|
||||||
#include "rendering/shader.hpp"
|
#include "rendering/shader.hpp"
|
||||||
#include "rendering/camera.hpp"
|
#include "rendering/camera.hpp"
|
||||||
#include "rendering/terrain_renderer.hpp"
|
#include "pipeline/asset_manager.hpp"
|
||||||
|
#include "pipeline/blp_loader.hpp"
|
||||||
|
#include "core/coordinates.hpp"
|
||||||
#include "core/logger.hpp"
|
#include "core/logger.hpp"
|
||||||
#include <GL/glew.h>
|
#include <GL/glew.h>
|
||||||
#include <glm/gtc/matrix_transform.hpp>
|
#include <glm/gtc/matrix_transform.hpp>
|
||||||
|
#include <sstream>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
namespace wowee {
|
namespace wowee {
|
||||||
namespace rendering {
|
namespace rendering {
|
||||||
|
|
@ -18,44 +22,90 @@ Minimap::~Minimap() {
|
||||||
bool Minimap::initialize(int size) {
|
bool Minimap::initialize(int size) {
|
||||||
mapSize = size;
|
mapSize = size;
|
||||||
|
|
||||||
// Create FBO
|
// --- Composite FBO (3x3 tiles = 768x768) ---
|
||||||
glGenFramebuffers(1, &fbo);
|
glGenFramebuffers(1, &compositeFBO);
|
||||||
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
|
glBindFramebuffer(GL_FRAMEBUFFER, compositeFBO);
|
||||||
|
|
||||||
// Color texture
|
glGenTextures(1, &compositeTexture);
|
||||||
glGenTextures(1, &fboTexture);
|
glBindTexture(GL_TEXTURE_2D, compositeTexture);
|
||||||
glBindTexture(GL_TEXTURE_2D, fboTexture);
|
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, COMPOSITE_PX, COMPOSITE_PX, 0,
|
||||||
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, mapSize, mapSize, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
|
GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
|
||||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
|
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_MAG_FILTER, GL_LINEAR);
|
||||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
|
||||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, 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);
|
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, compositeTexture, 0);
|
||||||
|
|
||||||
// Depth renderbuffer
|
|
||||||
glGenRenderbuffers(1, &fboDepth);
|
|
||||||
glBindRenderbuffer(GL_RENDERBUFFER, fboDepth);
|
|
||||||
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, mapSize, mapSize);
|
|
||||||
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, fboDepth);
|
|
||||||
|
|
||||||
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
|
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
|
||||||
LOG_ERROR("Minimap FBO incomplete");
|
LOG_ERROR("Minimap composite FBO incomplete");
|
||||||
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
||||||
|
|
||||||
// Screen quad (NDC fullscreen, we'll position via uniforms)
|
// --- Unit quad for tile compositing ---
|
||||||
float quadVerts[] = {
|
float quadVerts[] = {
|
||||||
// pos (x,y), uv (u,v)
|
// pos (x,y), uv (u,v)
|
||||||
-1.0f, -1.0f, 0.0f, 0.0f,
|
0.0f, 0.0f, 0.0f, 0.0f,
|
||||||
1.0f, -1.0f, 1.0f, 0.0f,
|
1.0f, 0.0f, 1.0f, 0.0f,
|
||||||
1.0f, 1.0f, 1.0f, 1.0f,
|
1.0f, 1.0f, 1.0f, 1.0f,
|
||||||
-1.0f, -1.0f, 0.0f, 0.0f,
|
0.0f, 0.0f, 0.0f, 0.0f,
|
||||||
1.0f, 1.0f, 1.0f, 1.0f,
|
1.0f, 1.0f, 1.0f, 1.0f,
|
||||||
-1.0f, 1.0f, 0.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);
|
||||||
|
|
||||||
|
// --- Tile compositing shader ---
|
||||||
|
const char* tileVertSrc = R"(
|
||||||
|
#version 330 core
|
||||||
|
layout (location = 0) in vec2 aPos;
|
||||||
|
layout (location = 1) in vec2 aUV;
|
||||||
|
|
||||||
|
uniform vec2 uGridOffset; // (col, row) in 0-2
|
||||||
|
|
||||||
|
out vec2 TexCoord;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec2 gridPos = (uGridOffset + aPos) / 3.0;
|
||||||
|
gl_Position = vec4(gridPos * 2.0 - 1.0, 0.0, 1.0);
|
||||||
|
TexCoord = aUV;
|
||||||
|
}
|
||||||
|
)";
|
||||||
|
|
||||||
|
const char* tileFragSrc = R"(
|
||||||
|
#version 330 core
|
||||||
|
in vec2 TexCoord;
|
||||||
|
|
||||||
|
uniform sampler2D uTileTexture;
|
||||||
|
|
||||||
|
out vec4 FragColor;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
// BLP minimap tiles have same axis transposition as ADT terrain:
|
||||||
|
// tile U (cols) = north-south, tile V (rows) = west-east
|
||||||
|
// Composite grid: TexCoord.x = west-east, TexCoord.y = north-south
|
||||||
|
// So swap to match
|
||||||
|
FragColor = texture(uTileTexture, vec2(TexCoord.y, TexCoord.x));
|
||||||
|
}
|
||||||
|
)";
|
||||||
|
|
||||||
|
tileShader = std::make_unique<Shader>();
|
||||||
|
if (!tileShader->loadFromSource(tileVertSrc, tileFragSrc)) {
|
||||||
|
LOG_ERROR("Failed to create minimap tile compositing shader");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Screen quad ---
|
||||||
glGenVertexArrays(1, &quadVAO);
|
glGenVertexArrays(1, &quadVAO);
|
||||||
glGenBuffers(1, &quadVBO);
|
glGenBuffers(1, &quadVBO);
|
||||||
glBindVertexArray(quadVAO);
|
glBindVertexArray(quadVAO);
|
||||||
|
|
@ -67,13 +117,13 @@ bool Minimap::initialize(int size) {
|
||||||
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), (void*)(2 * sizeof(float)));
|
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), (void*)(2 * sizeof(float)));
|
||||||
glBindVertexArray(0);
|
glBindVertexArray(0);
|
||||||
|
|
||||||
// Quad shader with circular mask and border
|
// --- Screen quad shader with rotation + circular mask ---
|
||||||
const char* vertSrc = R"(
|
const char* quadVertSrc = R"(
|
||||||
#version 330 core
|
#version 330 core
|
||||||
layout (location = 0) in vec2 aPos;
|
layout (location = 0) in vec2 aPos;
|
||||||
layout (location = 1) in vec2 aUV;
|
layout (location = 1) in vec2 aUV;
|
||||||
|
|
||||||
uniform vec4 uRect; // x, y, w, h in NDC
|
uniform vec4 uRect; // x, y, w, h in 0..1 screen space
|
||||||
|
|
||||||
out vec2 TexCoord;
|
out vec2 TexCoord;
|
||||||
|
|
||||||
|
|
@ -84,143 +134,375 @@ bool Minimap::initialize(int size) {
|
||||||
}
|
}
|
||||||
)";
|
)";
|
||||||
|
|
||||||
const char* fragSrc = R"(
|
const char* quadFragSrc = R"(
|
||||||
#version 330 core
|
#version 330 core
|
||||||
in vec2 TexCoord;
|
in vec2 TexCoord;
|
||||||
|
|
||||||
uniform sampler2D uMapTexture;
|
uniform sampler2D uComposite;
|
||||||
|
uniform vec2 uPlayerUV;
|
||||||
|
uniform float uRotation;
|
||||||
|
uniform float uZoomRadius;
|
||||||
|
|
||||||
out vec4 FragColor;
|
out vec4 FragColor;
|
||||||
|
|
||||||
void main() {
|
bool pointInTriangle(vec2 p, vec2 a, vec2 b, vec2 c) {
|
||||||
vec2 center = TexCoord - vec2(0.5);
|
vec2 v0 = c - a, v1 = b - a, v2 = p - a;
|
||||||
float dist = length(center);
|
float d00 = dot(v0, v0);
|
||||||
|
float d01 = dot(v0, v1);
|
||||||
|
float d02 = dot(v0, v2);
|
||||||
|
float d11 = dot(v1, v1);
|
||||||
|
float d12 = dot(v1, v2);
|
||||||
|
float inv = 1.0 / (d00 * d11 - d01 * d01);
|
||||||
|
float u = (d11 * d02 - d01 * d12) * inv;
|
||||||
|
float v = (d00 * d12 - d01 * d02) * inv;
|
||||||
|
return (u >= 0.0) && (v >= 0.0) && (u + v <= 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
// Circular mask
|
void main() {
|
||||||
|
vec2 centered = TexCoord - 0.5;
|
||||||
|
float dist = length(centered);
|
||||||
if (dist > 0.5) discard;
|
if (dist > 0.5) discard;
|
||||||
|
|
||||||
// Gold border ring
|
// Rotate screen coords → composite UV offset
|
||||||
float borderWidth = 0.02;
|
// Composite: U increases east, V increases south
|
||||||
if (dist > 0.5 - borderWidth) {
|
// Screen: +X=right, +Y=up
|
||||||
FragColor = vec4(0.8, 0.65, 0.2, 1.0);
|
// The -cos(a) term in dV inherently flips V (screen up → composite north)
|
||||||
return;
|
float c = cos(uRotation);
|
||||||
|
float s = sin(uRotation);
|
||||||
|
float scale = uZoomRadius * 2.0;
|
||||||
|
|
||||||
|
vec2 offset = vec2(
|
||||||
|
centered.x * c + centered.y * s,
|
||||||
|
centered.x * s - centered.y * c
|
||||||
|
) * scale;
|
||||||
|
|
||||||
|
vec2 uv = uPlayerUV + offset;
|
||||||
|
vec3 color = texture(uComposite, uv).rgb;
|
||||||
|
|
||||||
|
// Thin dark border at circle edge
|
||||||
|
if (dist > 0.49) {
|
||||||
|
color = mix(color, vec3(0.08), smoothstep(0.49, 0.5, dist));
|
||||||
}
|
}
|
||||||
|
|
||||||
vec4 texColor = texture(uMapTexture, TexCoord);
|
// Player arrow at center (always points up = forward)
|
||||||
|
vec2 ap = centered;
|
||||||
|
vec2 tip = vec2(0.0, 0.035);
|
||||||
|
vec2 lt = vec2(-0.018, -0.016);
|
||||||
|
vec2 rt = vec2(0.018, -0.016);
|
||||||
|
vec2 nL = vec2(-0.006, -0.006);
|
||||||
|
vec2 nR = vec2(0.006, -0.006);
|
||||||
|
vec2 nB = vec2(0.0, 0.006);
|
||||||
|
|
||||||
// Player dot at center
|
bool inArrow = pointInTriangle(ap, tip, lt, rt)
|
||||||
if (dist < 0.02) {
|
&& !pointInTriangle(ap, nL, nR, nB);
|
||||||
FragColor = vec4(1.0, 0.3, 0.3, 1.0);
|
|
||||||
return;
|
if (inArrow) {
|
||||||
|
color = vec3(0.0, 0.0, 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
FragColor = texColor;
|
FragColor = vec4(color, 0.8);
|
||||||
}
|
}
|
||||||
)";
|
)";
|
||||||
|
|
||||||
quadShader = std::make_unique<Shader>();
|
quadShader = std::make_unique<Shader>();
|
||||||
if (!quadShader->loadFromSource(vertSrc, fragSrc)) {
|
if (!quadShader->loadFromSource(quadVertSrc, quadFragSrc)) {
|
||||||
LOG_ERROR("Failed to create minimap shader");
|
LOG_ERROR("Failed to create minimap screen quad shader");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
LOG_INFO("Minimap initialized (", mapSize, "x", mapSize, ")");
|
// --- No-data fallback texture (dark blue-gray) ---
|
||||||
|
glGenTextures(1, &noDataTexture);
|
||||||
|
glBindTexture(GL_TEXTURE_2D, noDataTexture);
|
||||||
|
uint8_t darkPixel[4] = { 12, 20, 30, 255 };
|
||||||
|
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 1, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, darkPixel);
|
||||||
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
|
||||||
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
|
||||||
|
|
||||||
|
LOG_INFO("Minimap initialized (", mapSize, "x", mapSize, " screen, ",
|
||||||
|
COMPOSITE_PX, "x", COMPOSITE_PX, " composite)");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void Minimap::shutdown() {
|
void Minimap::shutdown() {
|
||||||
if (fbo) { glDeleteFramebuffers(1, &fbo); fbo = 0; }
|
if (compositeFBO) { glDeleteFramebuffers(1, &compositeFBO); compositeFBO = 0; }
|
||||||
if (fboTexture) { glDeleteTextures(1, &fboTexture); fboTexture = 0; }
|
if (compositeTexture) { glDeleteTextures(1, &compositeTexture); compositeTexture = 0; }
|
||||||
if (fboDepth) { glDeleteRenderbuffers(1, &fboDepth); fboDepth = 0; }
|
if (tileQuadVAO) { glDeleteVertexArrays(1, &tileQuadVAO); tileQuadVAO = 0; }
|
||||||
|
if (tileQuadVBO) { glDeleteBuffers(1, &tileQuadVBO); tileQuadVBO = 0; }
|
||||||
if (quadVAO) { glDeleteVertexArrays(1, &quadVAO); quadVAO = 0; }
|
if (quadVAO) { glDeleteVertexArrays(1, &quadVAO); quadVAO = 0; }
|
||||||
if (quadVBO) { glDeleteBuffers(1, &quadVBO); quadVBO = 0; }
|
if (quadVBO) { glDeleteBuffers(1, &quadVBO); quadVBO = 0; }
|
||||||
|
if (noDataTexture) { glDeleteTextures(1, &noDataTexture); noDataTexture = 0; }
|
||||||
|
|
||||||
|
// Delete cached tile textures
|
||||||
|
for (auto& [hash, tex] : tileTextureCache) {
|
||||||
|
if (tex) glDeleteTextures(1, &tex);
|
||||||
|
}
|
||||||
|
tileTextureCache.clear();
|
||||||
|
|
||||||
|
tileShader.reset();
|
||||||
quadShader.reset();
|
quadShader.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Minimap::setMapName(const std::string& name) {
|
||||||
|
if (mapName != name) {
|
||||||
|
mapName = name;
|
||||||
|
hasCachedFrame = false;
|
||||||
|
lastCenterTileX = -1;
|
||||||
|
lastCenterTileY = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// TRS parsing
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
void Minimap::parseTRS() {
|
||||||
|
if (trsParsed || !assetManager) return;
|
||||||
|
trsParsed = true;
|
||||||
|
|
||||||
|
auto data = assetManager->getMPQManager().readFile("Textures\\Minimap\\md5translate.trs");
|
||||||
|
if (data.empty()) {
|
||||||
|
LOG_WARNING("Failed to load md5translate.trs");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string content(reinterpret_cast<const char*>(data.data()), data.size());
|
||||||
|
std::istringstream stream(content);
|
||||||
|
std::string line;
|
||||||
|
int count = 0;
|
||||||
|
|
||||||
|
while (std::getline(stream, line)) {
|
||||||
|
// Remove \r
|
||||||
|
if (!line.empty() && line.back() == '\r') line.pop_back();
|
||||||
|
|
||||||
|
// Skip "dir:" lines and empty lines
|
||||||
|
if (line.empty() || line.substr(0, 4) == "dir:") continue;
|
||||||
|
|
||||||
|
// Format: "Azeroth\map32_49.blp\t<hash>.blp"
|
||||||
|
auto tabPos = line.find('\t');
|
||||||
|
if (tabPos == std::string::npos) continue;
|
||||||
|
|
||||||
|
std::string key = line.substr(0, tabPos);
|
||||||
|
std::string hashFile = line.substr(tabPos + 1);
|
||||||
|
|
||||||
|
// Strip .blp from key: "Azeroth\map32_49"
|
||||||
|
if (key.size() > 4 && key.substr(key.size() - 4) == ".blp") {
|
||||||
|
key = key.substr(0, key.size() - 4);
|
||||||
|
}
|
||||||
|
// Strip .blp from hash to get just the md5: "e7f0dea73ee6baca78231aaf4b7e772a"
|
||||||
|
if (hashFile.size() > 4 && hashFile.substr(hashFile.size() - 4) == ".blp") {
|
||||||
|
hashFile = hashFile.substr(0, hashFile.size() - 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
trsLookup[key] = hashFile;
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_INFO("Parsed md5translate.trs: ", count, " entries");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Tile texture loading
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
GLuint Minimap::getOrLoadTileTexture(int tileX, int tileY) {
|
||||||
|
// Build TRS key: "Azeroth\map32_49"
|
||||||
|
std::string key = mapName + "\\map" + std::to_string(tileX) + "_" + std::to_string(tileY);
|
||||||
|
|
||||||
|
auto trsIt = trsLookup.find(key);
|
||||||
|
if (trsIt == trsLookup.end()) {
|
||||||
|
return noDataTexture;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string& hash = trsIt->second;
|
||||||
|
|
||||||
|
// Check texture cache
|
||||||
|
auto cacheIt = tileTextureCache.find(hash);
|
||||||
|
if (cacheIt != tileTextureCache.end()) {
|
||||||
|
return cacheIt->second;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load from MPQ
|
||||||
|
std::string blpPath = "Textures\\Minimap\\" + hash + ".blp";
|
||||||
|
auto blpImage = assetManager->loadTexture(blpPath);
|
||||||
|
if (!blpImage.isValid()) {
|
||||||
|
tileTextureCache[hash] = noDataTexture;
|
||||||
|
return noDataTexture;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create GL texture
|
||||||
|
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);
|
||||||
|
|
||||||
|
tileTextureCache[hash] = tex;
|
||||||
|
return tex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Composite 3x3 tiles into FBO
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
void Minimap::compositeTilesToFBO(const glm::vec3& centerWorldPos) {
|
||||||
|
// centerWorldPos is in render coords (renderX=wowY, renderY=wowX)
|
||||||
|
auto [tileX, tileY] = core::coords::worldToTile(centerWorldPos.x, centerWorldPos.y);
|
||||||
|
|
||||||
|
// Save GL state
|
||||||
|
GLint prevFBO = 0;
|
||||||
|
glGetIntegerv(GL_FRAMEBUFFER_BINDING, &prevFBO);
|
||||||
|
GLint prevViewport[4];
|
||||||
|
glGetIntegerv(GL_VIEWPORT, prevViewport);
|
||||||
|
|
||||||
|
glBindFramebuffer(GL_FRAMEBUFFER, compositeFBO);
|
||||||
|
glViewport(0, 0, COMPOSITE_PX, COMPOSITE_PX);
|
||||||
|
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);
|
||||||
|
|
||||||
|
glBindVertexArray(tileQuadVAO);
|
||||||
|
|
||||||
|
// Draw 3x3 tile grid into composite FBO.
|
||||||
|
// BLP first row → GL V=0 (bottom) = north edge of tile.
|
||||||
|
// So north tile (dr=-1) goes to row 0 (bottom), south (dr=+1) to row 2 (top).
|
||||||
|
// West tile (dc=-1) goes to col 0 (left), east (dc=+1) to col 2 (right).
|
||||||
|
// Result: composite U=0→west, U=1→east, V=0→north, V=1→south.
|
||||||
|
for (int dr = -1; dr <= 1; dr++) {
|
||||||
|
for (int dc = -1; dc <= 1; dc++) {
|
||||||
|
int tx = tileX + dr;
|
||||||
|
int ty = tileY + dc;
|
||||||
|
|
||||||
|
GLuint tileTex = getOrLoadTileTexture(tx, ty);
|
||||||
|
|
||||||
|
glActiveTexture(GL_TEXTURE0);
|
||||||
|
glBindTexture(GL_TEXTURE_2D, tileTex);
|
||||||
|
|
||||||
|
// Grid position: dr=-1 (north) → row 0, dr=0 → row 1, dr=+1 (south) → row 2
|
||||||
|
float col = static_cast<float>(dc + 1); // 0, 1, 2
|
||||||
|
float row = static_cast<float>(dr + 1); // 0, 1, 2
|
||||||
|
|
||||||
|
tileShader->setUniform("uGridOffset", glm::vec2(col, row));
|
||||||
|
glDrawArrays(GL_TRIANGLES, 0, 6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
glBindVertexArray(0);
|
||||||
|
|
||||||
|
// Restore GL state
|
||||||
|
glBindFramebuffer(GL_FRAMEBUFFER, prevFBO);
|
||||||
|
glViewport(prevViewport[0], prevViewport[1], prevViewport[2], prevViewport[3]);
|
||||||
|
|
||||||
|
lastCenterTileX = tileX;
|
||||||
|
lastCenterTileY = tileY;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Main render
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
void Minimap::render(const Camera& playerCamera, const glm::vec3& centerWorldPos,
|
void Minimap::render(const Camera& playerCamera, const glm::vec3& centerWorldPos,
|
||||||
int screenWidth, int screenHeight) {
|
int screenWidth, int screenHeight) {
|
||||||
if (!enabled || !terrainRenderer || !fbo) return;
|
if (!enabled || !assetManager || !compositeFBO) return;
|
||||||
|
|
||||||
|
// Lazy-parse TRS on first use
|
||||||
|
if (!trsParsed) parseTRS();
|
||||||
|
|
||||||
|
// Check if composite needs refresh
|
||||||
const auto now = std::chrono::steady_clock::now();
|
const auto now = std::chrono::steady_clock::now();
|
||||||
glm::vec3 playerPos = centerWorldPos;
|
|
||||||
bool needsRefresh = !hasCachedFrame;
|
bool needsRefresh = !hasCachedFrame;
|
||||||
if (!needsRefresh) {
|
if (!needsRefresh) {
|
||||||
float moved = glm::length(glm::vec2(playerPos.x - lastUpdatePos.x, playerPos.y - lastUpdatePos.y));
|
float moved = glm::length(glm::vec2(centerWorldPos.x - lastUpdatePos.x,
|
||||||
|
centerWorldPos.y - lastUpdatePos.y));
|
||||||
float elapsed = std::chrono::duration<float>(now - lastUpdateTime).count();
|
float elapsed = std::chrono::duration<float>(now - lastUpdateTime).count();
|
||||||
needsRefresh = (moved >= updateDistance) || (elapsed >= updateIntervalSec);
|
needsRefresh = (moved >= updateDistance) || (elapsed >= updateIntervalSec);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Render terrain from top-down into FBO (throttled)
|
// Also refresh if player crossed a tile boundary
|
||||||
|
auto [curTileX, curTileY] = core::coords::worldToTile(centerWorldPos.x, centerWorldPos.y);
|
||||||
|
if (curTileX != lastCenterTileX || curTileY != lastCenterTileY) {
|
||||||
|
needsRefresh = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (needsRefresh) {
|
if (needsRefresh) {
|
||||||
renderTerrainToFBO(playerCamera, centerWorldPos);
|
compositeTilesToFBO(centerWorldPos);
|
||||||
lastUpdateTime = now;
|
lastUpdateTime = now;
|
||||||
lastUpdatePos = playerPos;
|
lastUpdatePos = centerWorldPos;
|
||||||
hasCachedFrame = true;
|
hasCachedFrame = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Draw the minimap quad on screen
|
// Draw screen quad
|
||||||
renderQuad(screenWidth, screenHeight);
|
renderQuad(playerCamera, centerWorldPos, screenWidth, screenHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
void Minimap::renderTerrainToFBO(const Camera& /*playerCamera*/, const glm::vec3& centerWorldPos) {
|
void Minimap::renderQuad(const Camera& playerCamera, const glm::vec3& centerWorldPos,
|
||||||
// Save current viewport
|
int screenWidth, int screenHeight) {
|
||||||
GLint prevViewport[4];
|
|
||||||
glGetIntegerv(GL_VIEWPORT, prevViewport);
|
|
||||||
|
|
||||||
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
|
|
||||||
glViewport(0, 0, mapSize, mapSize);
|
|
||||||
glClearColor(0.05f, 0.1f, 0.15f, 1.0f);
|
|
||||||
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
|
|
||||||
|
|
||||||
// Create a top-down camera at the player's XY position
|
|
||||||
Camera topDownCamera;
|
|
||||||
glm::vec3 playerPos = centerWorldPos;
|
|
||||||
topDownCamera.setPosition(glm::vec3(playerPos.x, playerPos.y, playerPos.z + 5000.0f));
|
|
||||||
topDownCamera.setRotation(0.0f, -89.9f); // Look straight down
|
|
||||||
topDownCamera.setAspectRatio(1.0f);
|
|
||||||
topDownCamera.setFov(1.0f); // Will be overridden by ortho below
|
|
||||||
|
|
||||||
// We need orthographic projection, but Camera only supports perspective.
|
|
||||||
// Use the terrain renderer's render with a custom view/projection.
|
|
||||||
// For now, render with the top-down camera (perspective, narrow FOV approximates ortho)
|
|
||||||
// The narrow FOV + high altitude gives a near-orthographic result.
|
|
||||||
|
|
||||||
// Calculate FOV that covers viewRadius at the altitude
|
|
||||||
float altitude = 5000.0f;
|
|
||||||
float fovDeg = glm::degrees(2.0f * std::atan(viewRadius / altitude));
|
|
||||||
topDownCamera.setFov(fovDeg);
|
|
||||||
|
|
||||||
terrainRenderer->render(topDownCamera);
|
|
||||||
|
|
||||||
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
|
||||||
|
|
||||||
// Restore viewport
|
|
||||||
glViewport(prevViewport[0], prevViewport[1], prevViewport[2], prevViewport[3]);
|
|
||||||
}
|
|
||||||
|
|
||||||
void Minimap::renderQuad(int screenWidth, int screenHeight) {
|
|
||||||
glDisable(GL_DEPTH_TEST);
|
glDisable(GL_DEPTH_TEST);
|
||||||
|
glDisable(GL_CULL_FACE);
|
||||||
|
glEnable(GL_BLEND);
|
||||||
|
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
|
||||||
|
glPolygonMode(GL_FRONT_AND_BACK, GL_FILL);
|
||||||
|
|
||||||
quadShader->use();
|
quadShader->use();
|
||||||
|
|
||||||
// Position minimap in top-right corner with margin
|
// Position minimap in top-right corner
|
||||||
float margin = 10.0f;
|
float margin = 10.0f;
|
||||||
float pixelW = static_cast<float>(mapSize) / screenWidth;
|
float pixelW = static_cast<float>(mapSize) / screenWidth;
|
||||||
float pixelH = static_cast<float>(mapSize) / screenHeight;
|
float pixelH = static_cast<float>(mapSize) / screenHeight;
|
||||||
float x = 1.0f - pixelW - margin / screenWidth;
|
float x = 1.0f - pixelW - margin / screenWidth;
|
||||||
float y = 1.0f - pixelH - margin / screenHeight;
|
float y = 1.0f - pixelH - margin / screenHeight;
|
||||||
|
|
||||||
// uRect: x, y, w, h in 0..1 screen space
|
|
||||||
quadShader->setUniform("uRect", glm::vec4(x, y, pixelW, pixelH));
|
quadShader->setUniform("uRect", glm::vec4(x, y, pixelW, pixelH));
|
||||||
quadShader->setUniform("uMapTexture", 0);
|
|
||||||
|
|
||||||
|
// Compute player's UV in the composite texture
|
||||||
|
// Render coords: renderX = wowY (west axis), renderY = wowX (north axis)
|
||||||
|
constexpr float TILE_SIZE = core::coords::TILE_SIZE;
|
||||||
|
auto [tileX, tileY] = core::coords::worldToTile(centerWorldPos.x, centerWorldPos.y);
|
||||||
|
|
||||||
|
// Fractional position within center tile
|
||||||
|
// tileX = floor(32 - wowX/TILE_SIZE), wowX = renderY
|
||||||
|
// fracNS: 0 = north edge of tile, 1 = south edge
|
||||||
|
float fracNS = 32.0f - static_cast<float>(tileX) - centerWorldPos.y / TILE_SIZE;
|
||||||
|
// fracEW: 0 = west edge of tile, 1 = east edge
|
||||||
|
float fracEW = 32.0f - static_cast<float>(tileY) - centerWorldPos.x / TILE_SIZE;
|
||||||
|
|
||||||
|
// Composite UV: center tile is grid slot (1,1) → UV range [1/3, 2/3]
|
||||||
|
// Composite orientation: U=0→west, U=1→east, V=0→north, V=1→south
|
||||||
|
float playerU = (1.0f + fracEW) / 3.0f;
|
||||||
|
float playerV = (1.0f + fracNS) / 3.0f;
|
||||||
|
|
||||||
|
quadShader->setUniform("uPlayerUV", glm::vec2(playerU, playerV));
|
||||||
|
|
||||||
|
// Zoom: convert view radius from world units to composite UV fraction
|
||||||
|
float zoomRadius = viewRadius / (TILE_SIZE * 3.0f);
|
||||||
|
quadShader->setUniform("uZoomRadius", zoomRadius);
|
||||||
|
|
||||||
|
// Rotation: compass bearing from north, clockwise
|
||||||
|
// renderX = wowY (west), renderY = wowX (north)
|
||||||
|
// Facing north: fwd=(0,1,0) → bearing=0
|
||||||
|
// Facing east: fwd=(-1,0,0) → bearing=π/2
|
||||||
|
glm::vec3 fwd = playerCamera.getForward();
|
||||||
|
float rotation = std::atan2(-fwd.x, fwd.y);
|
||||||
|
quadShader->setUniform("uRotation", rotation);
|
||||||
|
|
||||||
|
quadShader->setUniform("uComposite", 0);
|
||||||
glActiveTexture(GL_TEXTURE0);
|
glActiveTexture(GL_TEXTURE0);
|
||||||
glBindTexture(GL_TEXTURE_2D, fboTexture);
|
glBindTexture(GL_TEXTURE_2D, compositeTexture);
|
||||||
|
|
||||||
glBindVertexArray(quadVAO);
|
glBindVertexArray(quadVAO);
|
||||||
glDrawArrays(GL_TRIANGLES, 0, 6);
|
glDrawArrays(GL_TRIANGLES, 0, 6);
|
||||||
glBindVertexArray(0);
|
glBindVertexArray(0);
|
||||||
|
|
||||||
|
glDisable(GL_BLEND);
|
||||||
glEnable(GL_DEPTH_TEST);
|
glEnable(GL_DEPTH_TEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1064,15 +1064,6 @@ void Renderer::renderWorld(game::World* world) {
|
||||||
glEnable(GL_DEPTH_TEST);
|
glEnable(GL_DEPTH_TEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render minimap overlay
|
|
||||||
if (minimap && camera && window) {
|
|
||||||
glm::vec3 minimapCenter = camera->getPosition();
|
|
||||||
if (cameraController && cameraController->isThirdPerson()) {
|
|
||||||
minimapCenter = characterPosition;
|
|
||||||
}
|
|
||||||
minimap->render(*camera, minimapCenter, window->getWidth(), window->getHeight());
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Resolve MSAA → non-MSAA texture ---
|
// --- Resolve MSAA → non-MSAA texture ---
|
||||||
glBindFramebuffer(GL_READ_FRAMEBUFFER, sceneFBO);
|
glBindFramebuffer(GL_READ_FRAMEBUFFER, sceneFBO);
|
||||||
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, resolveFBO);
|
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, resolveFBO);
|
||||||
|
|
@ -1096,6 +1087,15 @@ void Renderer::renderWorld(game::World* world) {
|
||||||
postProcessShader->unuse();
|
postProcessShader->unuse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render minimap overlay (after post-process so it's not overwritten)
|
||||||
|
if (minimap && camera && window) {
|
||||||
|
glm::vec3 minimapCenter = camera->getPosition();
|
||||||
|
if (cameraController && cameraController->isThirdPerson()) {
|
||||||
|
minimapCenter = characterPosition;
|
||||||
|
}
|
||||||
|
minimap->render(*camera, minimapCenter, window->getWidth(), window->getHeight());
|
||||||
|
}
|
||||||
|
|
||||||
glEnable(GL_DEPTH_TEST);
|
glEnable(GL_DEPTH_TEST);
|
||||||
|
|
||||||
auto renderEnd = std::chrono::steady_clock::now();
|
auto renderEnd = std::chrono::steady_clock::now();
|
||||||
|
|
@ -1307,9 +1307,9 @@ bool Renderer::loadTestTerrain(pipeline::AssetManager* assetManager, const std::
|
||||||
if (characterRenderer) {
|
if (characterRenderer) {
|
||||||
characterRenderer->setAssetManager(assetManager);
|
characterRenderer->setAssetManager(assetManager);
|
||||||
}
|
}
|
||||||
// Wire terrain renderer to minimap
|
// Wire asset manager to minimap for tile texture loading
|
||||||
if (minimap) {
|
if (minimap) {
|
||||||
minimap->setTerrainRenderer(terrainRenderer.get());
|
minimap->setAssetManager(assetManager);
|
||||||
}
|
}
|
||||||
// Wire terrain manager, WMO renderer, and water renderer to camera controller
|
// Wire terrain manager, WMO renderer, and water renderer to camera controller
|
||||||
if (cameraController) {
|
if (cameraController) {
|
||||||
|
|
@ -1349,6 +1349,9 @@ bool Renderer::loadTestTerrain(pipeline::AssetManager* assetManager, const std::
|
||||||
// Extract map name
|
// Extract map name
|
||||||
std::string mapName = filename.substr(0, firstUnderscore != std::string::npos ? firstUnderscore : filename.size());
|
std::string mapName = filename.substr(0, firstUnderscore != std::string::npos ? firstUnderscore : filename.size());
|
||||||
terrainManager->setMapName(mapName);
|
terrainManager->setMapName(mapName);
|
||||||
|
if (minimap) {
|
||||||
|
minimap->setMapName(mapName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue