mirror of
https://github.com/Kelsidavis/WoWee.git
synced 2026-03-22 23:30:14 +00:00
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:
parent
1fb1daea7f
commit
da49593268
13 changed files with 321 additions and 128 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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_;
|
||||
|
|
|
|||
51
include/rendering/levelup_effect.hpp
Normal file
51
include/rendering/levelup_effect.hpp
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
71
src/rendering/levelup_effect.cpp
Normal file
71
src/rendering/levelup_effect.cpp
Normal 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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue