Add 3D level-up effect using LevelUp.m2 spell model

Replace 2D screen-space ding rings with real WoW LevelUp.m2 particle/geometry
effect. Fix FBlock particle color parsing (C3Vector floats, not CImVector bytes)
which was producing blue/red instead of golden yellow. Spell effect models bypass
particle dampeners, glow sprite conversion, Mod→Additive blend override, and all
collision (floor/wall/camera) to prevent camera zoom-in. Other players' level-ups
trigger the 3D effect at their position with group chat notification. F7 hotkey
for testing.
This commit is contained in:
Kelsi 2026-02-19 20:36:25 -08:00
parent 1fb1daea7f
commit da49593268
13 changed files with 321 additions and 128 deletions

View file

@ -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

View file

@ -749,6 +749,10 @@ public:
using LevelUpCallback = std::function<void(uint32_t newLevel)>;
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(uint64_t guid, uint32_t newLevel)>;
void setOtherPlayerLevelUpCallback(OtherPlayerLevelUpCallback cb) { otherPlayerLevelUpCallback_ = std::move(cb); }
// Mount state
using MountCallback = std::function<void(uint32_t mountDisplayId)>; // 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_;

View file

@ -0,0 +1,51 @@
#pragma once
#include <glm/glm.hpp>
#include <cstdint>
#include <vector>
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<uint8_t>& m2FileData,
const std::vector<uint8_t>& 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<ActiveEffect> activeEffects_;
};
} // namespace rendering
} // namespace wowee

View file

@ -98,6 +98,7 @@ struct M2ModelGPU {
std::vector<uint32_t> 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

View file

@ -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> skySystem; // Coordinator for sky rendering
std::unique_ptr<SwimEffects> swimEffects;
std::unique_ptr<MountDust> mountDust;
std::unique_ptr<LevelUpEffect> levelUpEffect;
std::unique_ptr<CharacterRenderer> characterRenderer;
std::unique_ptr<WMORenderer> wmoRenderer;
std::unique_ptr<M2Renderer> m2Renderer;

View file

@ -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

View file

@ -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));

View file

@ -595,14 +595,13 @@ void parseFBlock(const std::vector<uint8_t>& 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<float>(data, ofsKeys + i * 12 + 0);
float g = readValue<float>(data, ofsKeys + i * 12 + 4);
float b = readValue<float>(data, ofsKeys + i * 12 + 8);
fb.vec3Values.emplace_back(r / 255.0f, g / 255.0f, b / 255.0f);
}
} else if (valueType == 1) {

View file

@ -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<uint8_t>& m2FileData,
const std::vector<uint8_t>& 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

View file

@ -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<float>::max());
glm::vec3 tightMax(-std::numeric_limits<float>::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<float>::max());
tightMax = glm::vec3(-std::numeric_limits<float>::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<float> 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<float> 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<float>(std::min(v.boneIndices[0], uint8_t(127))));
vertexData.push_back(static_cast<float>(std::min(v.boneIndices[1], uint8_t(127))));
vertexData.push_back(static_cast<float>(std::min(v.boneIndices[2], uint8_t(127))));
vertexData.push_back(static_cast<float>(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<float>(std::min(v.boneIndices[0], uint8_t(127))));
vertexData.push_back(static_cast<float>(std::min(v.boneIndices[1], uint8_t(127))));
vertexData.push_back(static_cast<float>(std::min(v.boneIndices[2], uint8_t(127))));
vertexData.push_back(static_cast<float>(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<int>(batch.blendMode));
lastBlendMode = effectiveBlendMode;
shader->setUniform("uBlendMode", static_cast<int>(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<int>(gpu.particleTextures.size())) {
@ -3039,7 +3042,7 @@ std::optional<float> 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.

View file

@ -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");

View file

@ -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<LevelUpEffect>();
// Create character renderer
characterRenderer = std::make_unique<CharacterRenderer>();
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<float, std::milli>(sky2 - sky1).count();

View file

@ -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();