diff --git a/CMakeLists.txt b/CMakeLists.txt index 19051b55..1b61100d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -195,6 +195,7 @@ set(WOWEE_SOURCES src/rendering/world_map.cpp src/rendering/swim_effects.cpp src/rendering/mount_dust.cpp + src/rendering/levelup_effect.cpp src/rendering/loading_screen.cpp src/rendering/video_player.cpp diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index e60dd066..2458039a 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -749,6 +749,10 @@ public: using LevelUpCallback = std::function; void setLevelUpCallback(LevelUpCallback cb) { levelUpCallback_ = std::move(cb); } + // Other player level-up callback — fires when another player gains a level + using OtherPlayerLevelUpCallback = std::function; + void setOtherPlayerLevelUpCallback(OtherPlayerLevelUpCallback cb) { otherPlayerLevelUpCallback_ = std::move(cb); } + // Mount state using MountCallback = std::function; // 0 = dismount void setMountCallback(MountCallback cb) { mountCallback_ = std::move(cb); } @@ -1650,6 +1654,7 @@ private: NpcFarewellCallback npcFarewellCallback_; NpcVendorCallback npcVendorCallback_; LevelUpCallback levelUpCallback_; + OtherPlayerLevelUpCallback otherPlayerLevelUpCallback_; MountCallback mountCallback_; TaxiPrecacheCallback taxiPrecacheCallback_; TaxiOrientationCallback taxiOrientationCallback_; diff --git a/include/rendering/levelup_effect.hpp b/include/rendering/levelup_effect.hpp new file mode 100644 index 00000000..f52ba4d3 --- /dev/null +++ b/include/rendering/levelup_effect.hpp @@ -0,0 +1,51 @@ +#pragma once + +#include +#include +#include + +namespace wowee { +namespace rendering { + +class M2Renderer; + +/// Manages spawning the real LevelUp.m2 spell effect at world positions. +/// The M2 model contains particle emitters that produce the golden pillar/ring effect. +class LevelUpEffect { +public: + LevelUpEffect(); + ~LevelUpEffect(); + + /// Load the LevelUp.m2 model (call once after M2Renderer is ready) + /// @param m2Renderer The M2 renderer to register the model with + /// @param m2FileData Raw bytes of Spell/LevelUp/LevelUp.m2 + /// @param skinFileData Raw bytes of Spell/LevelUp/LevelUp00.skin + /// @return true if model loaded successfully + bool loadModel(M2Renderer* m2Renderer, + const std::vector& m2FileData, + const std::vector& skinFileData); + + /// Trigger the level-up effect at a world position (render coords) + void trigger(const glm::vec3& position); + + /// Remove expired effect instances + void update(float deltaTime); + + bool isModelLoaded() const { return modelLoaded_; } + +private: + static constexpr float EFFECT_DURATION = 3.5f; + static constexpr uint32_t MODEL_ID = 999900; // Unique model ID for level-up effect + + struct ActiveEffect { + uint32_t instanceId; + float elapsed; + }; + + M2Renderer* m2Renderer_ = nullptr; + bool modelLoaded_ = false; + std::vector activeEffects_; +}; + +} // namespace rendering +} // namespace wowee diff --git a/include/rendering/m2_renderer.hpp b/include/rendering/m2_renderer.hpp index db8d2512..097bfb7a 100644 --- a/include/rendering/m2_renderer.hpp +++ b/include/rendering/m2_renderer.hpp @@ -98,6 +98,7 @@ struct M2ModelGPU { std::vector globalSequenceDurations; // Loop durations for global sequence tracks bool hasAnimation = false; // True if any bone has keyframes bool isSmoke = false; // True for smoke models (UV scroll animation) + bool isSpellEffect = false; // True for spell effect models (skip particle dampeners) bool disableAnimation = false; // Keep foliage/tree doodads visually stable bool hasTextureAnimation = false; // True if any batch has UV animation diff --git a/include/rendering/renderer.hpp b/include/rendering/renderer.hpp index d2146fc7..4667cd2f 100644 --- a/include/rendering/renderer.hpp +++ b/include/rendering/renderer.hpp @@ -31,6 +31,7 @@ class LightingManager; class SkySystem; class SwimEffects; class MountDust; +class LevelUpEffect; class CharacterRenderer; class WMORenderer; class M2Renderer; @@ -122,6 +123,7 @@ public: // Emote support void playEmote(const std::string& emoteName); + void triggerLevelUpEffect(const glm::vec3& position); void cancelEmote(); bool isEmoteActive() const { return emoteActive; } static std::string getEmoteText(const std::string& emoteName, const std::string* targetName = nullptr); @@ -186,6 +188,7 @@ private: std::unique_ptr skySystem; // Coordinator for sky rendering std::unique_ptr swimEffects; std::unique_ptr mountDust; + std::unique_ptr levelUpEffect; std::unique_ptr characterRenderer; std::unique_ptr wmoRenderer; std::unique_ptr m2Renderer; diff --git a/src/core/application.cpp b/src/core/application.cpp index f806c313..eaa529ab 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -300,6 +300,16 @@ void Application::run() { LOG_INFO("Shadows: ", enabled ? "ON" : "OFF"); } } + // F7: Test level-up effect (ignore key repeat) + else if (event.key.keysym.scancode == SDL_SCANCODE_F7 && event.key.repeat == 0) { + if (renderer) { + renderer->triggerLevelUpEffect(renderer->getCharacterPosition()); + LOG_INFO("Triggered test level-up effect"); + } + if (uiManager) { + uiManager->getGameScreen().triggerDing(99); + } + } } } @@ -1329,11 +1339,38 @@ void Application::setupUICallbacks() { despawnOnlineGameObject(guid); }); - // Level-up callback — play sound, cheer emote, and trigger UI ding overlay + // Level-up callback — play sound, cheer emote, and trigger UI ding overlay + 3D effect gameHandler->setLevelUpCallback([this](uint32_t newLevel) { if (uiManager) { uiManager->getGameScreen().triggerDing(newLevel); } + if (renderer) { + renderer->triggerLevelUpEffect(renderer->getCharacterPosition()); + } + }); + + // Other player level-up callback — trigger 3D effect + chat notification + gameHandler->setOtherPlayerLevelUpCallback([this](uint64_t guid, uint32_t newLevel) { + if (!gameHandler || !renderer) return; + + // Trigger 3D effect at the other player's position + auto entity = gameHandler->getEntityManager().getEntity(guid); + if (entity) { + glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ()); + glm::vec3 renderPos = core::coords::canonicalToRender(canonical); + renderer->triggerLevelUpEffect(renderPos); + } + + // Show chat message if in group + if (gameHandler->isInGroup()) { + std::string name = gameHandler->getCachedPlayerName(guid); + if (name.empty()) name = "A party member"; + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = name + " has reached level " + std::to_string(newLevel) + "!"; + gameHandler->addLocalChatMessage(msg); + } }); // Mount callback (online mode) - defer heavy model load to next frame diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index ef137ef1..5789854d 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4594,7 +4594,16 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { } } } - } else if (key == ufLevel) { unit->setLevel(val); } + } else if (key == ufLevel) { + uint32_t oldLvl = unit->getLevel(); + unit->setLevel(val); + if (block.guid != playerGuid && + entity->getType() == ObjectType::PLAYER && + val > oldLvl && oldLvl > 0 && + otherPlayerLevelUpCallback_) { + otherPlayerLevelUpCallback_(block.guid, val); + } + } else if (key == ufFaction) { unit->setFactionTemplate(val); unit->setHostile(isHostileFaction(val)); diff --git a/src/pipeline/m2_loader.cpp b/src/pipeline/m2_loader.cpp index 1cb3e081..16e890a7 100644 --- a/src/pipeline/m2_loader.cpp +++ b/src/pipeline/m2_loader.cpp @@ -595,14 +595,13 @@ void parseFBlock(const std::vector& data, uint32_t offset, uint32_t ofsKeys = disk.ofsKeys; if (valueType == 0) { - // Color: CImVector (4 bytes RGBA) per key. We extract RGB, ignore A. - if (ofsKeys + nKeys * 4 > data.size()) return; + // Color: C3Vector (3 × float per key, values in 0-255 range) + if (ofsKeys + nKeys * 12 > data.size()) return; fb.vec3Values.reserve(nKeys); for (uint32_t i = 0; i < nKeys; i++) { - uint8_t r = data[ofsKeys + i * 4 + 0]; - uint8_t g = data[ofsKeys + i * 4 + 1]; - uint8_t b = data[ofsKeys + i * 4 + 2]; - // byte 3 is alpha, handled separately by the alpha FBlock + float r = readValue(data, ofsKeys + i * 12 + 0); + float g = readValue(data, ofsKeys + i * 12 + 4); + float b = readValue(data, ofsKeys + i * 12 + 8); fb.vec3Values.emplace_back(r / 255.0f, g / 255.0f, b / 255.0f); } } else if (valueType == 1) { diff --git a/src/rendering/levelup_effect.cpp b/src/rendering/levelup_effect.cpp new file mode 100644 index 00000000..f5dd52da --- /dev/null +++ b/src/rendering/levelup_effect.cpp @@ -0,0 +1,71 @@ +#include "rendering/levelup_effect.hpp" +#include "rendering/m2_renderer.hpp" +#include "pipeline/m2_loader.hpp" +#include "core/logger.hpp" + +namespace wowee { +namespace rendering { + +LevelUpEffect::LevelUpEffect() = default; +LevelUpEffect::~LevelUpEffect() = default; + +bool LevelUpEffect::loadModel(M2Renderer* m2Renderer, + const std::vector& m2FileData, + const std::vector& skinFileData) { + if (!m2Renderer || m2FileData.empty()) return false; + + m2Renderer_ = m2Renderer; + + pipeline::M2Model model = pipeline::M2Loader::load(m2FileData); + // Spell effect M2s may have no geometry (particle-only), so don't require isValid() + if (model.vertices.empty() && model.particleEmitters.empty()) { + LOG_WARNING("LevelUpEffect: M2 has no vertices and no particle emitters"); + return false; + } + + if (!skinFileData.empty() && model.version >= 264) { + pipeline::M2Loader::loadSkin(skinFileData, model); + } + + if (!m2Renderer_->loadModel(model, MODEL_ID)) { + LOG_WARNING("LevelUpEffect: failed to load model to GPU"); + return false; + } + + modelLoaded_ = true; + LOG_INFO("LevelUpEffect: loaded LevelUp.m2 — vertices=", model.vertices.size(), + " indices=", model.indices.size(), + " emitters=", model.particleEmitters.size(), + " batches=", model.batches.size()); + return true; +} + +void LevelUpEffect::trigger(const glm::vec3& position) { + if (!modelLoaded_ || !m2Renderer_) return; + + uint32_t instanceId = m2Renderer_->createInstance(MODEL_ID, position, + glm::vec3(0.0f), 1.0f); + if (instanceId == 0) { + LOG_WARNING("LevelUpEffect: failed to create instance"); + return; + } + + activeEffects_.push_back({instanceId, 0.0f}); +} + +void LevelUpEffect::update(float deltaTime) { + if (activeEffects_.empty() || !m2Renderer_) return; + + for (auto it = activeEffects_.begin(); it != activeEffects_.end(); ) { + it->elapsed += deltaTime; + if (it->elapsed >= EFFECT_DURATION) { + m2Renderer_->removeInstance(it->instanceId); + it = activeEffects_.erase(it); + } else { + ++it; + } + } +} + +} // namespace rendering +} // namespace wowee diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index 38bbb59b..04e19ce2 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -830,8 +830,10 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { return true; } - if (model.vertices.empty() || model.indices.empty()) { - LOG_WARNING("M2 model has no geometry: ", model.name); + bool hasGeometry = !model.vertices.empty() && !model.indices.empty(); + bool hasParticles = !model.particleEmitters.empty(); + if (!hasGeometry && !hasParticles) { + LOG_WARNING("M2 model has no geometry and no particles: ", model.name); return false; } @@ -849,11 +851,15 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { } // Use tight bounds from actual vertices for collision/camera occlusion. // Header bounds in some M2s are overly conservative. - glm::vec3 tightMin( std::numeric_limits::max()); - glm::vec3 tightMax(-std::numeric_limits::max()); - for (const auto& v : model.vertices) { - tightMin = glm::min(tightMin, v.position); - tightMax = glm::max(tightMax, v.position); + glm::vec3 tightMin(0.0f); + glm::vec3 tightMax(0.0f); + if (hasGeometry) { + tightMin = glm::vec3(std::numeric_limits::max()); + tightMax = glm::vec3(-std::numeric_limits::max()); + for (const auto& v : model.vertices) { + tightMin = glm::min(tightMin, v.position); + tightMax = glm::max(tightMax, v.position); + } } bool foliageOrTreeLike = false; bool chestName = false; @@ -1021,6 +1027,9 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { } } gpuModel.disableAnimation = foliageOrTreeLike || chestName; + // Spell effect models: particle-dominated with minimal geometry (e.g. LevelUp.m2) + gpuModel.isSpellEffect = hasParticles && model.vertices.size() <= 200 && + model.particleEmitters.size() >= 3; // Build collision mesh + spatial grid from M2 bounding geometry gpuModel.collision.vertices = model.collisionVertices; @@ -1046,77 +1055,63 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { } } - // Create VBO with interleaved vertex data - // Format: position (3), normal (3), texcoord0 (2), texcoord1 (2), boneWeights (4), boneIndices (4 as float) - const size_t floatsPerVertex = 18; - std::vector vertexData; - vertexData.reserve(model.vertices.size() * floatsPerVertex); + if (hasGeometry) { + // Create VBO with interleaved vertex data + // Format: position (3), normal (3), texcoord0 (2), texcoord1 (2), boneWeights (4), boneIndices (4 as float) + const size_t floatsPerVertex = 18; + std::vector vertexData; + vertexData.reserve(model.vertices.size() * floatsPerVertex); - for (const auto& v : model.vertices) { - vertexData.push_back(v.position.x); - vertexData.push_back(v.position.y); - vertexData.push_back(v.position.z); - vertexData.push_back(v.normal.x); - vertexData.push_back(v.normal.y); - vertexData.push_back(v.normal.z); - vertexData.push_back(v.texCoords[0].x); - vertexData.push_back(v.texCoords[0].y); - vertexData.push_back(v.texCoords[1].x); - vertexData.push_back(v.texCoords[1].y); - // Bone weights (normalized 0-1) - float w0 = v.boneWeights[0] / 255.0f; - float w1 = v.boneWeights[1] / 255.0f; - float w2 = v.boneWeights[2] / 255.0f; - float w3 = v.boneWeights[3] / 255.0f; - vertexData.push_back(w0); - vertexData.push_back(w1); - vertexData.push_back(w2); - vertexData.push_back(w3); - // Bone indices (clamped to max 127 for uniform array) - vertexData.push_back(static_cast(std::min(v.boneIndices[0], uint8_t(127)))); - vertexData.push_back(static_cast(std::min(v.boneIndices[1], uint8_t(127)))); - vertexData.push_back(static_cast(std::min(v.boneIndices[2], uint8_t(127)))); - vertexData.push_back(static_cast(std::min(v.boneIndices[3], uint8_t(127)))); + for (const auto& v : model.vertices) { + vertexData.push_back(v.position.x); + vertexData.push_back(v.position.y); + vertexData.push_back(v.position.z); + vertexData.push_back(v.normal.x); + vertexData.push_back(v.normal.y); + vertexData.push_back(v.normal.z); + vertexData.push_back(v.texCoords[0].x); + vertexData.push_back(v.texCoords[0].y); + vertexData.push_back(v.texCoords[1].x); + vertexData.push_back(v.texCoords[1].y); + float w0 = v.boneWeights[0] / 255.0f; + float w1 = v.boneWeights[1] / 255.0f; + float w2 = v.boneWeights[2] / 255.0f; + float w3 = v.boneWeights[3] / 255.0f; + vertexData.push_back(w0); + vertexData.push_back(w1); + vertexData.push_back(w2); + vertexData.push_back(w3); + vertexData.push_back(static_cast(std::min(v.boneIndices[0], uint8_t(127)))); + vertexData.push_back(static_cast(std::min(v.boneIndices[1], uint8_t(127)))); + vertexData.push_back(static_cast(std::min(v.boneIndices[2], uint8_t(127)))); + vertexData.push_back(static_cast(std::min(v.boneIndices[3], uint8_t(127)))); + } + + glGenBuffers(1, &gpuModel.vbo); + glBindBuffer(GL_ARRAY_BUFFER, gpuModel.vbo); + glBufferData(GL_ARRAY_BUFFER, vertexData.size() * sizeof(float), + vertexData.data(), GL_STATIC_DRAW); + + glGenBuffers(1, &gpuModel.ebo); + glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, gpuModel.ebo); + glBufferData(GL_ELEMENT_ARRAY_BUFFER, model.indices.size() * sizeof(uint16_t), + model.indices.data(), GL_STATIC_DRAW); + + const size_t stride = floatsPerVertex * sizeof(float); + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, stride, (void*)0); + glEnableVertexAttribArray(1); + glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, stride, (void*)(3 * sizeof(float))); + glEnableVertexAttribArray(2); + glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, stride, (void*)(6 * sizeof(float))); + glEnableVertexAttribArray(5); + glVertexAttribPointer(5, 2, GL_FLOAT, GL_FALSE, stride, (void*)(8 * sizeof(float))); + glEnableVertexAttribArray(3); + glVertexAttribPointer(3, 4, GL_FLOAT, GL_FALSE, stride, (void*)(10 * sizeof(float))); + glEnableVertexAttribArray(4); + glVertexAttribPointer(4, 4, GL_FLOAT, GL_FALSE, stride, (void*)(14 * sizeof(float))); } - glGenBuffers(1, &gpuModel.vbo); - glBindBuffer(GL_ARRAY_BUFFER, gpuModel.vbo); - glBufferData(GL_ARRAY_BUFFER, vertexData.size() * sizeof(float), - vertexData.data(), GL_STATIC_DRAW); - - // Create EBO - glGenBuffers(1, &gpuModel.ebo); - glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, gpuModel.ebo); - glBufferData(GL_ELEMENT_ARRAY_BUFFER, model.indices.size() * sizeof(uint16_t), - model.indices.data(), GL_STATIC_DRAW); - - // Set up vertex attributes - const size_t stride = floatsPerVertex * sizeof(float); - - // Position - glEnableVertexAttribArray(0); - glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, stride, (void*)0); - - // Normal - glEnableVertexAttribArray(1); - glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, stride, (void*)(3 * sizeof(float))); - - // TexCoord0 - glEnableVertexAttribArray(2); - glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, stride, (void*)(6 * sizeof(float))); - - // TexCoord1 - glEnableVertexAttribArray(5); - glVertexAttribPointer(5, 2, GL_FLOAT, GL_FALSE, stride, (void*)(8 * sizeof(float))); - - // Bone Weights - glEnableVertexAttribArray(3); - glVertexAttribPointer(3, 4, GL_FLOAT, GL_FALSE, stride, (void*)(10 * sizeof(float))); - - // Bone Indices (as integer attribute) - glEnableVertexAttribArray(4); - glVertexAttribPointer(4, 4, GL_FLOAT, GL_FALSE, stride, (void*)(14 * sizeof(float))); - glBindVertexArray(0); // Load ALL textures from the model into a local vector. @@ -2042,6 +2037,7 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm:: const bool batchUnlit = (batch.materialFlags & 0x01) != 0; const bool shouldUseGlowSprite = !koboldFlameCard && + !model.isSpellEffect && smallCardLikeBatch && ((batch.blendMode >= 3) || (batch.colorKeyBlack && flameLikeModel && batchUnlit && batch.blendMode >= 1)); @@ -2081,8 +2077,13 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm:: // Apply per-batch blend mode from M2 material (only if changed) // 0=Opaque, 1=AlphaKey, 2=Alpha, 3=Add, 4=Mod, 5=Mod2x, 6=BlendAdd, 7=Screen bool batchTransparent = false; - if (batch.blendMode != lastBlendMode) { - switch (batch.blendMode) { + // Spell effects: override Mod/Mod2x to Additive for bright glow rendering + uint8_t effectiveBlendMode = batch.blendMode; + if (model.isSpellEffect && (effectiveBlendMode == 4 || effectiveBlendMode == 5)) { + effectiveBlendMode = 3; // Additive + } + if (effectiveBlendMode != lastBlendMode) { + switch (effectiveBlendMode) { case 0: // Opaque glBlendFunc(GL_ONE, GL_ZERO); break; @@ -2113,11 +2114,11 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm:: glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); break; } - lastBlendMode = batch.blendMode; - shader->setUniform("uBlendMode", static_cast(batch.blendMode)); + lastBlendMode = effectiveBlendMode; + shader->setUniform("uBlendMode", static_cast(effectiveBlendMode)); } else { // Still need to know if batch is transparent for depth mask logic - batchTransparent = (batch.blendMode >= 2); + batchTransparent = (effectiveBlendMode >= 2); } // Disable depth writes for transparent/additive batches @@ -2142,8 +2143,8 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm:: lastHasTexture = hasTexture; } - bool alphaTest = (batch.blendMode == 1) || - (batch.blendMode >= 2 && !batch.hasAlpha); + bool alphaTest = (effectiveBlendMode == 1) || + (effectiveBlendMode >= 2 && !batch.hasAlpha); if (alphaTest != lastAlphaTest) { shader->setUniform("uAlphaTest", alphaTest); lastAlphaTest = alphaTest; @@ -2158,7 +2159,7 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm:: // the scene, so use a high threshold to remove the dark rectangle. if (colorKeyBlack) { float thresh = 0.08f; - if (batch.blendMode == 4 || batch.blendMode == 5) { + if (effectiveBlendMode == 4 || effectiveBlendMode == 5) { thresh = 0.7f; // Mod/Mod2x: only keep near-white pixels } shader->setUniform("uColorKeyThreshold", thresh); @@ -2572,20 +2573,22 @@ void M2Renderer::renderM2Particles(const glm::mat4& view, const glm::mat4& proj) float alpha = std::min(interpFBlockFloat(em.particleAlpha, lifeRatio), 1.0f); float rawScale = interpFBlockFloat(em.particleScale, lifeRatio); - // FBlock colors are tint values meant to multiply a bright texture. - // Desaturate toward white so particles look like water spray, not neon. - color = glm::mix(color, glm::vec3(1.0f), 0.7f); + if (!gpu.isSpellEffect) { + // FBlock colors are tint values meant to multiply a bright texture. + // Desaturate toward white so particles look like water spray, not neon. + color = glm::mix(color, glm::vec3(1.0f), 0.7f); - // Large-scale particles (>2.0) are volume/backdrop effects meant to be - // nearly invisible mist. Fade them heavily since we render as point sprites. - if (rawScale > 2.0f) { - alpha *= 0.02f; + // Large-scale particles (>2.0) are volume/backdrop effects meant to be + // nearly invisible mist. Fade them heavily since we render as point sprites. + if (rawScale > 2.0f) { + alpha *= 0.02f; + } + // Reduce additive particle intensity to prevent blinding overlap + if (em.blendingType == 3 || em.blendingType == 4) { + alpha *= 0.05f; + } } - // Reduce additive particle intensity to prevent blinding overlap - if (em.blendingType == 3 || em.blendingType == 4) { - alpha *= 0.05f; - } - float scale = std::min(rawScale, 1.5f); + float scale = gpu.isSpellEffect ? rawScale : std::min(rawScale, 1.5f); GLuint tex = whiteTexture; if (p.emitterIndex < static_cast(gpu.particleTextures.size())) { @@ -3039,7 +3042,7 @@ std::optional M2Renderer::getFloorHeight(float glX, float glY, float glZ, if (instance.scale <= 0.001f) continue; const M2ModelGPU& model = it->second; - if (model.collisionNoBlock || model.isInvisibleTrap) continue; + if (model.collisionNoBlock || model.isInvisibleTrap || model.isSpellEffect) continue; // --- Mesh-based floor: vertical ray vs collision triangles --- // Does NOT skip the AABB path — both contribute and highest wins. @@ -3193,7 +3196,7 @@ bool M2Renderer::checkCollision(const glm::vec3& from, const glm::vec3& to, if (it == models.end()) continue; const M2ModelGPU& model = it->second; - if (model.collisionNoBlock || model.isInvisibleTrap) continue; + if (model.collisionNoBlock || model.isInvisibleTrap || model.isSpellEffect) continue; if (instance.scale <= 0.001f) continue; // --- Mesh-based wall collision: closest-point push --- @@ -3433,7 +3436,7 @@ float M2Renderer::raycastBoundingBoxes(const glm::vec3& origin, const glm::vec3& if (it == models.end()) continue; const M2ModelGPU& model = it->second; - if (model.collisionNoBlock || model.isInvisibleTrap) continue; + if (model.collisionNoBlock || model.isInvisibleTrap || model.isSpellEffect) continue; glm::vec3 localMin, localMax; getTightCollisionBounds(model, localMin, localMax); // Skip tiny doodads for camera occlusion; they cause jitter and false hits. diff --git a/src/rendering/performance_hud.cpp b/src/rendering/performance_hud.cpp index e79ed9f2..f801fab0 100644 --- a/src/rendering/performance_hud.cpp +++ b/src/rendering/performance_hud.cpp @@ -418,7 +418,7 @@ void PerformanceHUD::render(const Renderer* renderer, const Camera* camera) { ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "F4: Culling"); ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "F5: Stats"); ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "F6: Multi-tile"); - ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "F7: Streaming"); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "F7: Level-up FX"); ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "F8: Water"); ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "F9: Time"); ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "F10: Sun/Moon"); diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index 9e2c6dcc..6499abc2 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -16,6 +16,7 @@ #include "rendering/sky_system.hpp" #include "rendering/swim_effects.hpp" #include "rendering/mount_dust.hpp" +#include "rendering/levelup_effect.hpp" #include "rendering/character_renderer.hpp" #include "rendering/wmo_renderer.hpp" #include "rendering/m2_renderer.hpp" @@ -355,6 +356,9 @@ bool Renderer::initialize(core::Window* win) { mountDust.reset(); } + // Create level-up effect (model loaded later via loadLevelUpEffect) + levelUpEffect = std::make_unique(); + // Create character renderer characterRenderer = std::make_unique(); if (!characterRenderer->initialize()) { @@ -1603,6 +1607,31 @@ void Renderer::cancelEmote() { emoteLoop = false; } +void Renderer::triggerLevelUpEffect(const glm::vec3& position) { + if (!levelUpEffect) return; + + // Lazy-load the M2 model on first trigger + if (!levelUpEffect->isModelLoaded() && m2Renderer) { + if (!cachedAssetManager) { + cachedAssetManager = core::Application::getInstance().getAssetManager(); + } + if (!cachedAssetManager) { + LOG_WARNING("LevelUpEffect: no asset manager available"); + } else { + auto m2Data = cachedAssetManager->readFile("Spells\\LevelUp\\LevelUp.m2"); + auto skinData = cachedAssetManager->readFile("Spells\\LevelUp\\LevelUp00.skin"); + LOG_INFO("LevelUpEffect: m2Data=", m2Data.size(), " skinData=", skinData.size()); + if (!m2Data.empty()) { + levelUpEffect->loadModel(m2Renderer.get(), m2Data, skinData); + } else { + LOG_WARNING("LevelUpEffect: failed to read Spell\\LevelUp\\LevelUp.m2"); + } + } + } + + levelUpEffect->trigger(position); +} + void Renderer::triggerMeleeSwing() { if (!characterRenderer || characterInstanceId == 0) return; if (meleeSwingCooldown > 0.0f) return; @@ -1975,6 +2004,11 @@ void Renderer::update(float deltaTime) { } } } + // Update level-up effect + if (levelUpEffect) { + levelUpEffect->update(deltaTime); + } + auto sky2 = std::chrono::high_resolution_clock::now(); skyTime += std::chrono::duration(sky2 - sky1).count(); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index e4b7c140..397dacb3 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -7493,35 +7493,14 @@ void GameScreen::renderDingEffect() { dingTimer_ -= dt; if (dingTimer_ < 0.0f) dingTimer_ = 0.0f; - float progress = 1.0f - (dingTimer_ / DING_DURATION); // 0→1 over duration float alpha = dingTimer_ < 0.8f ? (dingTimer_ / 0.8f) : 1.0f; // fade out last 0.8s ImGuiIO& io = ImGui::GetIO(); float cx = io.DisplaySize.x * 0.5f; float cy = io.DisplaySize.y * 0.5f; - float maxR = std::min(cx, cy) * 1.1f; ImDrawList* draw = ImGui::GetForegroundDrawList(); - // 3 expanding golden rings staggered by 0.12s of phase - for (int r = 0; r < 3; r++) { - float phase = progress - r * 0.12f; - if (phase <= 0.0f || phase >= 1.0f) continue; - float ringAlpha = (1.0f - phase) * alpha * 0.9f; - float radius = phase * maxR; - float thickness = 10.0f * (1.0f - phase) + 2.0f; - draw->AddCircle(ImVec2(cx, cy), radius, - IM_COL32(255, 215, 0, (int)(ringAlpha * 255)), - 96, thickness); - } - - // Inner golden glow disk (very transparent) - if (progress < 0.5f) { - float glowAlpha = (1.0f - progress * 2.0f) * alpha * 0.15f; - draw->AddCircleFilled(ImVec2(cx, cy), progress * maxR * 0.6f, - IM_COL32(255, 215, 0, (int)(glowAlpha * 255))); - } - // "LEVEL X!" text — visible for first 2.2s if (dingTimer_ > 0.8f) { ImFont* font = ImGui::GetFont();