diff --git a/CMakeLists.txt b/CMakeLists.txt index 1b61100d..d2328cf0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -196,6 +196,7 @@ set(WOWEE_SOURCES src/rendering/swim_effects.cpp src/rendering/mount_dust.cpp src/rendering/levelup_effect.cpp + src/rendering/charge_effect.cpp src/rendering/loading_screen.cpp src/rendering/video_player.cpp diff --git a/include/core/application.hpp b/include/core/application.hpp index 9f3939ba..1eddca3e 100644 --- a/include/core/application.hpp +++ b/include/core/application.hpp @@ -189,6 +189,14 @@ private: float taxiStreamCooldown_ = 0.0f; bool idleYawned_ = false; + // Charge rush state + bool chargeActive_ = false; + float chargeTimer_ = 0.0f; + float chargeDuration_ = 0.0f; + glm::vec3 chargeStartPos_{0.0f}; // Render coordinates + glm::vec3 chargeEndPos_{0.0f}; // Render coordinates + uint64_t chargeTargetGuid_ = 0; + // Online gameobject model spawning struct GameObjectInstanceInfo { uint32_t modelId = 0; diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 2458039a..bb9d5e8d 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -745,6 +745,11 @@ public: } const std::unordered_map& getNpcQuestStatuses() const { return npcQuestStatus_; } + // Charge callback — fires when player casts a charge spell toward target + // Parameters: targetGuid, targetX, targetY, targetZ (canonical WoW coordinates) + using ChargeCallback = std::function; + void setChargeCallback(ChargeCallback cb) { chargeCallback_ = std::move(cb); } + // Level-up callback — fires when the player gains a level (newLevel > 1) using LevelUpCallback = std::function; void setLevelUpCallback(LevelUpCallback cb) { levelUpCallback_ = std::move(cb); } @@ -1653,6 +1658,7 @@ private: NpcGreetingCallback npcGreetingCallback_; NpcFarewellCallback npcFarewellCallback_; NpcVendorCallback npcVendorCallback_; + ChargeCallback chargeCallback_; LevelUpCallback levelUpCallback_; OtherPlayerLevelUpCallback otherPlayerLevelUpCallback_; MountCallback mountCallback_; diff --git a/include/rendering/charge_effect.hpp b/include/rendering/charge_effect.hpp new file mode 100644 index 00000000..9319a601 --- /dev/null +++ b/include/rendering/charge_effect.hpp @@ -0,0 +1,107 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace wowee { +namespace pipeline { class AssetManager; } +namespace rendering { + +class Camera; +class Shader; +class M2Renderer; + +/// Renders a red-orange ribbon streak trailing behind the warrior during Charge, +/// plus small dust puffs at ground level. +class ChargeEffect { +public: + ChargeEffect(); + ~ChargeEffect(); + + bool initialize(); + void shutdown(); + + /// Try to load M2 spell models (Charge_Caster.m2, etc.) + void tryLoadM2Models(M2Renderer* m2Renderer, pipeline::AssetManager* assets); + + /// Start the trail (call once when charge begins) + void start(const glm::vec3& position, const glm::vec3& direction); + + /// Feed current position each frame while charging + void emit(const glm::vec3& position, const glm::vec3& direction); + + /// Stop adding trail points (existing ribbon fades out) + void stop(); + + /// Spawn M2 impact burst at target position + void triggerImpact(const glm::vec3& position); + + void update(float deltaTime); + void render(const Camera& camera); + + bool isActive() const { return emitting_ || !trail_.empty() || !dustPuffs_.empty(); } + +private: + // --- Ribbon trail --- + struct TrailPoint { + glm::vec3 center; // World position of trail spine + glm::vec3 side; // Perpendicular direction (for ribbon width) + float age; // Seconds since spawned + }; + + static constexpr int MAX_TRAIL_POINTS = 64; + static constexpr float TRAIL_LIFETIME = 0.5f; // Seconds before trail point fades + static constexpr float TRAIL_HALF_WIDTH = 0.8f; // Half-width of ribbon + static constexpr float TRAIL_SPAWN_DIST = 0.4f; // Min distance between trail points + std::deque trail_; + + GLuint ribbonVao_ = 0; + GLuint ribbonVbo_ = 0; + std::unique_ptr ribbonShader_; + std::vector ribbonVerts_; // pos(3) + alpha(1) + heat(1) = 5 floats per vert + + // --- Dust puffs (small point sprites at feet) --- + struct DustPuff { + glm::vec3 position; + glm::vec3 velocity; + float lifetime; + float maxLifetime; + float size; + float alpha; + }; + + static constexpr int MAX_DUST = 80; + std::vector dustPuffs_; + + GLuint dustVao_ = 0; + GLuint dustVbo_ = 0; + std::unique_ptr dustShader_; + std::vector dustVerts_; + + bool emitting_ = false; + glm::vec3 lastEmitPos_{0.0f}; + float dustAccum_ = 0.0f; + + // --- M2 spell effect models (optional) --- + static constexpr uint32_t CASTER_MODEL_ID = 999800; + static constexpr uint32_t IMPACT_MODEL_ID = 999801; + static constexpr float M2_EFFECT_DURATION = 2.0f; + + M2Renderer* m2Renderer_ = nullptr; + bool casterModelLoaded_ = false; + bool impactModelLoaded_ = false; + + uint32_t activeCasterInstanceId_ = 0; + struct ActiveM2 { + uint32_t instanceId; + float elapsed; + }; + std::vector activeImpacts_; +}; + +} // namespace rendering +} // namespace wowee diff --git a/include/rendering/renderer.hpp b/include/rendering/renderer.hpp index 4667cd2f..e1bdc8e9 100644 --- a/include/rendering/renderer.hpp +++ b/include/rendering/renderer.hpp @@ -32,6 +32,7 @@ class SkySystem; class SwimEffects; class MountDust; class LevelUpEffect; +class ChargeEffect; class CharacterRenderer; class WMORenderer; class M2Renderer; @@ -137,6 +138,11 @@ public: bool isMoving() const; void triggerMeleeSwing(); void setEquippedWeaponType(uint32_t inventoryType) { equippedWeaponInvType_ = inventoryType; meleeAnimId = 0; } + void setCharging(bool charging) { charging_ = charging; } + bool isCharging() const { return charging_; } + void startChargeEffect(const glm::vec3& position, const glm::vec3& direction); + void emitChargeEffect(const glm::vec3& position, const glm::vec3& direction); + void stopChargeEffect(); // Mount rendering void setMounted(uint32_t mountInstId, uint32_t mountDisplayId, float heightOffset, const std::string& modelPath = ""); @@ -189,6 +195,7 @@ private: std::unique_ptr swimEffects; std::unique_ptr mountDust; std::unique_ptr levelUpEffect; + std::unique_ptr chargeEffect; std::unique_ptr characterRenderer; std::unique_ptr wmoRenderer; std::unique_ptr m2Renderer; @@ -259,7 +266,7 @@ private: float characterYaw = 0.0f; // Character animation state - enum class CharAnimState { IDLE, WALK, RUN, JUMP_START, JUMP_MID, JUMP_END, SIT_DOWN, SITTING, EMOTE, SWIM_IDLE, SWIM, MELEE_SWING, MOUNT }; + enum class CharAnimState { IDLE, WALK, RUN, JUMP_START, JUMP_MID, JUMP_END, SIT_DOWN, SITTING, EMOTE, SWIM_IDLE, SWIM, MELEE_SWING, MOUNT, CHARGE }; CharAnimState charAnimState = CharAnimState::IDLE; void updateCharacterAnimation(); bool isFootstepAnimationState() const; @@ -308,6 +315,7 @@ private: bool sfxPrevFalling = false; bool sfxPrevSwimming = false; + bool charging_ = false; float meleeSwingTimer = 0.0f; float meleeSwingCooldown = 0.0f; float meleeAnimDurationMs = 0.0f; diff --git a/src/core/application.cpp b/src/core/application.cpp index eaa529ab..fe5005cb 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -28,6 +28,7 @@ #include "audio/music_manager.hpp" #include "audio/footstep_manager.hpp" #include "audio/activity_sound_manager.hpp" +#include "audio/audio_engine.hpp" #include #include "pipeline/m2_loader.hpp" #include "pipeline/wmo_loader.hpp" @@ -689,7 +690,7 @@ void Application::update(float deltaTime) { worldEntryMovementGraceTimer_ -= deltaTime; } if (renderer && renderer->getCameraController()) { - const bool externallyDrivenMotion = onTaxi || onTransportNow; + const bool externallyDrivenMotion = onTaxi || onTransportNow || chargeActive_; // Keep physics frozen (externalFollow) during landing clamp when terrain // hasn't loaded yet — prevents gravity from pulling player through void. bool landingClampActive = !onTaxi && taxiLandingClampTimer_ > 0.0f && @@ -817,6 +818,53 @@ void Application::update(float deltaTime) { *followTarget = renderPos; } } + } else if (chargeActive_) { + // Warrior Charge: lerp position from start to end using smoothstep + chargeTimer_ += deltaTime; + float t = std::min(chargeTimer_ / chargeDuration_, 1.0f); + // smoothstep for natural acceleration/deceleration + float s = t * t * (3.0f - 2.0f * t); + glm::vec3 renderPos = chargeStartPos_ + (chargeEndPos_ - chargeStartPos_) * s; + renderer->getCharacterPosition() = renderPos; + + // Keep facing toward target and emit charge effect + glm::vec3 dir = chargeEndPos_ - chargeStartPos_; + if (glm::length(dir) > 0.01f) { + dir = glm::normalize(dir); + float yawDeg = glm::degrees(std::atan2(dir.x, dir.y)); + renderer->setCharacterYaw(yawDeg); + renderer->emitChargeEffect(renderPos, dir); + } + + // Sync to game handler + glm::vec3 canonical = core::coords::renderToCanonical(renderPos); + gameHandler->setPosition(canonical.x, canonical.y, canonical.z); + + // Update camera follow target + if (renderer->getCameraController()) { + glm::vec3* followTarget = renderer->getCameraController()->getFollowTargetMutable(); + if (followTarget) { + *followTarget = renderPos; + } + } + + // Charge complete + if (t >= 1.0f) { + chargeActive_ = false; + renderer->setCharging(false); + renderer->stopChargeEffect(); + renderer->getCameraController()->setExternalFollow(false); + renderer->getCameraController()->setExternalMoving(false); + + // Start auto-attack on arrival + if (chargeTargetGuid_ != 0) { + gameHandler->startAutoAttack(chargeTargetGuid_); + renderer->triggerMeleeSwing(); + } + + // Send movement heartbeat so server knows our new position + gameHandler->sendMovement(game::Opcode::CMSG_MOVE_HEARTBEAT); + } } else { glm::vec3 renderPos = renderer->getCharacterPosition(); glm::vec3 canonical = core::coords::renderToCanonical(renderPos); @@ -1339,6 +1387,60 @@ void Application::setupUICallbacks() { despawnOnlineGameObject(guid); }); + // Charge callback — warrior rushes toward target + gameHandler->setChargeCallback([this](uint64_t targetGuid, float tx, float ty, float tz) { + if (!renderer || !renderer->getCameraController() || !gameHandler) return; + + // Get current player position in render coords + glm::vec3 startRender = renderer->getCharacterPosition(); + // Convert target from canonical to render + glm::vec3 targetRender = core::coords::canonicalToRender(glm::vec3(tx, ty, tz)); + + // Compute direction and stop 2.0 units short (melee reach) + glm::vec3 dir = targetRender - startRender; + float dist = glm::length(dir); + if (dist < 3.0f) return; // Too close, nothing to do + glm::vec3 dirNorm = dir / dist; + glm::vec3 endRender = targetRender - dirNorm * 2.0f; + + // Face toward target BEFORE starting charge + float yawRad = std::atan2(dirNorm.x, dirNorm.y); + float yawDeg = glm::degrees(yawRad); + renderer->setCharacterYaw(yawDeg); + // Sync canonical orientation to server so it knows we turned + float canonicalYaw = core::coords::normalizeAngleRad(glm::radians(180.0f - yawDeg)); + gameHandler->setOrientation(canonicalYaw); + gameHandler->sendMovement(game::Opcode::CMSG_MOVE_SET_FACING); + + // Set charge state + chargeActive_ = true; + chargeTimer_ = 0.0f; + chargeDuration_ = std::max(dist / 25.0f, 0.3f); // ~25 units/sec + chargeStartPos_ = startRender; + chargeEndPos_ = endRender; + chargeTargetGuid_ = targetGuid; + + // Disable player input, play charge animation + renderer->getCameraController()->setExternalFollow(true); + renderer->getCameraController()->clearMovementInputs(); + renderer->setCharging(true); + + // Start charge visual effect (red haze + dust) + glm::vec3 chargeDir = glm::normalize(endRender - startRender); + renderer->startChargeEffect(startRender, chargeDir); + + // Play charge whoosh sound (try multiple paths) + auto& audio = audio::AudioEngine::instance(); + if (!audio.playSound2D("Sound\\Spells\\Charge.wav", 0.8f)) { + if (!audio.playSound2D("Sound\\Spells\\charge.wav", 0.8f)) { + if (!audio.playSound2D("Sound\\Spells\\SpellCharge.wav", 0.8f)) { + // Fallback: weapon whoosh + audio.playSound2D("Sound\\Item\\Weapons\\WeaponSwings\\mWooshLarge1.wav", 0.9f); + } + } + } + }); + // Level-up callback — play sound, cheer emote, and trigger UI ding overlay + 3D effect gameHandler->setLevelUpCallback([this](uint32_t newLevel) { if (uiManager) { diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 5789854d..abec1c22 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -8046,6 +8046,34 @@ void GameHandler::castSpell(uint32_t spellId, uint64_t targetGuid) { uint64_t target = targetGuid != 0 ? targetGuid : this->targetGuid; // Self-targeted spells like hearthstone should not send a target if (spellId == 8690) target = 0; + + // Warrior Charge (ranks 1-3): client-side range check + charge callback + if (spellId == 100 || spellId == 6178 || spellId == 11578) { + if (target == 0) { + addSystemChatMessage("You have no target."); + return; + } + auto entity = entityManager.getEntity(target); + if (entity) { + float tx = entity->getX(), ty = entity->getY(), tz = entity->getZ(); + float dx = tx - movementInfo.x; + float dy = ty - movementInfo.y; + float dz = tz - movementInfo.z; + float dist = std::sqrt(dx * dx + dy * dy + dz * dz); + if (dist < 8.0f) { + addSystemChatMessage("Target is too close."); + return; + } + if (dist > 25.0f) { + addSystemChatMessage("Out of range."); + return; + } + if (chargeCallback_) { + chargeCallback_(target, tx, ty, tz); + } + } + } + auto packet = packetParsers_ ? packetParsers_->buildCastSpell(spellId, target, ++castCount) : CastSpellPacket::build(spellId, target, ++castCount); diff --git a/src/rendering/charge_effect.cpp b/src/rendering/charge_effect.cpp new file mode 100644 index 00000000..b02443ae --- /dev/null +++ b/src/rendering/charge_effect.cpp @@ -0,0 +1,442 @@ +#include "rendering/charge_effect.hpp" +#include "rendering/camera.hpp" +#include "rendering/shader.hpp" +#include "rendering/m2_renderer.hpp" +#include "pipeline/m2_loader.hpp" +#include "pipeline/asset_manager.hpp" +#include "core/logger.hpp" +#include +#include +#include + +namespace wowee { +namespace rendering { + +static std::mt19937& rng() { + static std::random_device rd; + static std::mt19937 gen(rd()); + return gen; +} + +static float randFloat(float lo, float hi) { + std::uniform_real_distribution dist(lo, hi); + return dist(rng()); +} + +ChargeEffect::ChargeEffect() = default; +ChargeEffect::~ChargeEffect() { shutdown(); } + +bool ChargeEffect::initialize() { + // ---- Ribbon trail shader ---- + ribbonShader_ = std::make_unique(); + + const char* ribbonVS = R"( + #version 330 core + layout (location = 0) in vec3 aPos; + layout (location = 1) in float aAlpha; + layout (location = 2) in float aHeat; + layout (location = 3) in float aHeight; + + uniform mat4 uView; + uniform mat4 uProjection; + + out float vAlpha; + out float vHeat; + out float vHeight; + + void main() { + gl_Position = uProjection * uView * vec4(aPos, 1.0); + vAlpha = aAlpha; + vHeat = aHeat; + vHeight = aHeight; + } + )"; + + const char* ribbonFS = R"( + #version 330 core + in float vAlpha; + in float vHeat; + in float vHeight; + out vec4 FragColor; + + void main() { + // Vertical gradient: top is red/opaque, bottom is transparent + vec3 topColor = vec3(0.9, 0.15, 0.05); // Deep red at top + vec3 midColor = vec3(1.0, 0.5, 0.1); // Orange in middle + vec3 color = mix(midColor, topColor, vHeight); + // Mix with heat (head vs tail along length) + vec3 hotColor = vec3(1.0, 0.6, 0.15); + color = mix(color, hotColor, vHeat * 0.4); + + // Bottom fades to transparent, top is opaque + float vertAlpha = smoothstep(0.0, 0.4, vHeight); + FragColor = vec4(color, vAlpha * vertAlpha * 0.7); + } + )"; + + if (!ribbonShader_->loadFromSource(ribbonVS, ribbonFS)) { + LOG_ERROR("Failed to create charge ribbon shader"); + return false; + } + + glGenVertexArrays(1, &ribbonVao_); + glGenBuffers(1, &ribbonVbo_); + glBindVertexArray(ribbonVao_); + glBindBuffer(GL_ARRAY_BUFFER, ribbonVbo_); + // pos(3) + alpha(1) + heat(1) + height(1) = 6 floats + glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)0); + glEnableVertexAttribArray(0); + glVertexAttribPointer(1, 1, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(3 * sizeof(float))); + glEnableVertexAttribArray(1); + glVertexAttribPointer(2, 1, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(4 * sizeof(float))); + glEnableVertexAttribArray(2); + glVertexAttribPointer(3, 1, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(5 * sizeof(float))); + glEnableVertexAttribArray(3); + glBindVertexArray(0); + + ribbonVerts_.reserve(MAX_TRAIL_POINTS * 2 * 6); + + // ---- Dust puff shader (small point sprites) ---- + dustShader_ = std::make_unique(); + + const char* dustVS = R"( + #version 330 core + layout (location = 0) in vec3 aPos; + layout (location = 1) in float aSize; + layout (location = 2) in float aAlpha; + + uniform mat4 uView; + uniform mat4 uProjection; + + out float vAlpha; + + void main() { + gl_Position = uProjection * uView * vec4(aPos, 1.0); + gl_PointSize = aSize; + vAlpha = aAlpha; + } + )"; + + const char* dustFS = R"( + #version 330 core + in float vAlpha; + out vec4 FragColor; + + void main() { + vec2 coord = gl_PointCoord - vec2(0.5); + float dist = length(coord); + if (dist > 0.5) discard; + float alpha = smoothstep(0.5, 0.0, dist) * vAlpha; + vec3 dustColor = vec3(0.65, 0.55, 0.40); + FragColor = vec4(dustColor, alpha * 0.45); + } + )"; + + if (!dustShader_->loadFromSource(dustVS, dustFS)) { + LOG_ERROR("Failed to create charge dust shader"); + return false; + } + + glGenVertexArrays(1, &dustVao_); + glGenBuffers(1, &dustVbo_); + glBindVertexArray(dustVao_); + glBindBuffer(GL_ARRAY_BUFFER, dustVbo_); + // pos(3) + size(1) + alpha(1) = 5 floats + glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)0); + glEnableVertexAttribArray(0); + glVertexAttribPointer(1, 1, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)(3 * sizeof(float))); + glEnableVertexAttribArray(1); + glVertexAttribPointer(2, 1, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)(4 * sizeof(float))); + glEnableVertexAttribArray(2); + glBindVertexArray(0); + + dustVerts_.reserve(MAX_DUST * 5); + dustPuffs_.reserve(MAX_DUST); + + return true; +} + +void ChargeEffect::shutdown() { + if (ribbonVao_) glDeleteVertexArrays(1, &ribbonVao_); + if (ribbonVbo_) glDeleteBuffers(1, &ribbonVbo_); + ribbonVao_ = 0; ribbonVbo_ = 0; + if (dustVao_) glDeleteVertexArrays(1, &dustVao_); + if (dustVbo_) glDeleteBuffers(1, &dustVbo_); + dustVao_ = 0; dustVbo_ = 0; + trail_.clear(); + dustPuffs_.clear(); + ribbonShader_.reset(); + dustShader_.reset(); +} + +void ChargeEffect::tryLoadM2Models(M2Renderer* m2Renderer, pipeline::AssetManager* assets) { + if (!m2Renderer || !assets) return; + m2Renderer_ = m2Renderer; + + const char* casterPaths[] = { + "Spells\\Charge_Caster.m2", + "Spells\\WarriorCharge.m2", + "Spells\\Charge\\Charge_Caster.m2", + "Spells\\Dust_Medium.m2", + }; + for (const char* path : casterPaths) { + auto m2Data = assets->readFile(path); + if (m2Data.empty()) continue; + pipeline::M2Model model = pipeline::M2Loader::load(m2Data); + if (model.vertices.empty() && model.particleEmitters.empty()) continue; + std::string skinPath = std::string(path); + auto dotPos = skinPath.rfind('.'); + if (dotPos != std::string::npos) { + std::string skinFile = skinPath.substr(0, dotPos) + "00.skin"; + auto skinData = assets->readFile(skinFile); + if (!skinData.empty() && model.version >= 264) + pipeline::M2Loader::loadSkin(skinData, model); + } + if (m2Renderer_->loadModel(model, CASTER_MODEL_ID)) { + casterModelLoaded_ = true; + LOG_INFO("ChargeEffect: loaded caster model from ", path); + break; + } + } + + const char* impactPaths[] = { + "Spells\\Charge_Impact.m2", + "Spells\\Charge\\Charge_Impact.m2", + "Spells\\ImpactDust.m2", + }; + for (const char* path : impactPaths) { + auto m2Data = assets->readFile(path); + if (m2Data.empty()) continue; + pipeline::M2Model model = pipeline::M2Loader::load(m2Data); + if (model.vertices.empty() && model.particleEmitters.empty()) continue; + std::string skinPath = std::string(path); + auto dotPos = skinPath.rfind('.'); + if (dotPos != std::string::npos) { + std::string skinFile = skinPath.substr(0, dotPos) + "00.skin"; + auto skinData = assets->readFile(skinFile); + if (!skinData.empty() && model.version >= 264) + pipeline::M2Loader::loadSkin(skinData, model); + } + if (m2Renderer_->loadModel(model, IMPACT_MODEL_ID)) { + impactModelLoaded_ = true; + LOG_INFO("ChargeEffect: loaded impact model from ", path); + break; + } + } +} + +void ChargeEffect::start(const glm::vec3& position, const glm::vec3& direction) { + emitting_ = true; + dustAccum_ = 0.0f; + trail_.clear(); + dustPuffs_.clear(); + lastEmitPos_ = position; + + // Spawn M2 caster effect + if (casterModelLoaded_ && m2Renderer_) { + activeCasterInstanceId_ = m2Renderer_->createInstance( + CASTER_MODEL_ID, position, glm::vec3(0.0f), 1.0f); + } + + // Seed the first trail point + emit(position, direction); +} + +void ChargeEffect::emit(const glm::vec3& position, const glm::vec3& direction) { + if (!emitting_) return; + + // Move M2 caster with player + if (activeCasterInstanceId_ != 0 && m2Renderer_) { + m2Renderer_->setInstancePosition(activeCasterInstanceId_, position); + } + + // Only add a new trail point if we've moved enough + float dist = glm::length(position - lastEmitPos_); + if (dist >= TRAIL_SPAWN_DIST || trail_.empty()) { + // Ribbon is vertical: side vector points straight up + glm::vec3 side = glm::vec3(0.0f, 0.0f, 1.0f); + + // Trail spawns at character's mid-height (ribbon extends above and below) + glm::vec3 trailCenter = position + glm::vec3(0.0f, 0.0f, 1.0f); + + trail_.push_back({trailCenter, side, 0.0f}); + if (trail_.size() > MAX_TRAIL_POINTS) { + trail_.pop_front(); + } + lastEmitPos_ = position; + } + + // Spawn dust puffs at feet + glm::vec3 horizDir = glm::vec3(direction.x, direction.y, 0.0f); + float horizLen = glm::length(horizDir); + if (horizLen < 0.001f) return; + glm::vec3 backDir = -horizDir / horizLen; + glm::vec3 sideDir = glm::vec3(-backDir.y, backDir.x, 0.0f); + + dustAccum_ += 30.0f * 0.016f; + while (dustAccum_ >= 1.0f && dustPuffs_.size() < MAX_DUST) { + dustAccum_ -= 1.0f; + DustPuff d; + d.position = position + backDir * randFloat(0.0f, 0.6f) + + sideDir * randFloat(-0.4f, 0.4f) + + glm::vec3(0.0f, 0.0f, 0.1f); + d.velocity = backDir * randFloat(0.5f, 2.0f) + + sideDir * randFloat(-0.3f, 0.3f) + + glm::vec3(0.0f, 0.0f, randFloat(0.8f, 2.0f)); + d.lifetime = 0.0f; + d.maxLifetime = randFloat(0.3f, 0.5f); + d.size = randFloat(5.0f, 10.0f); + d.alpha = 1.0f; + dustPuffs_.push_back(d); + } +} + +void ChargeEffect::stop() { + emitting_ = false; + + if (activeCasterInstanceId_ != 0 && m2Renderer_) { + m2Renderer_->removeInstance(activeCasterInstanceId_); + activeCasterInstanceId_ = 0; + } +} + +void ChargeEffect::triggerImpact(const glm::vec3& position) { + if (!impactModelLoaded_ || !m2Renderer_) return; + uint32_t instanceId = m2Renderer_->createInstance( + IMPACT_MODEL_ID, position, glm::vec3(0.0f), 1.0f); + if (instanceId != 0) { + activeImpacts_.push_back({instanceId, 0.0f}); + } +} + +void ChargeEffect::update(float deltaTime) { + // Age trail points and remove expired ones + for (auto& tp : trail_) { + tp.age += deltaTime; + } + while (!trail_.empty() && trail_.front().age >= TRAIL_LIFETIME) { + trail_.pop_front(); + } + + // Update dust puffs + for (auto it = dustPuffs_.begin(); it != dustPuffs_.end(); ) { + it->lifetime += deltaTime; + if (it->lifetime >= it->maxLifetime) { + it = dustPuffs_.erase(it); + continue; + } + it->position += it->velocity * deltaTime; + it->velocity *= 0.93f; + float t = it->lifetime / it->maxLifetime; + it->alpha = 1.0f - t * t; + it->size += deltaTime * 8.0f; + ++it; + } + + // Clean up expired M2 impacts + for (auto it = activeImpacts_.begin(); it != activeImpacts_.end(); ) { + it->elapsed += deltaTime; + if (it->elapsed >= M2_EFFECT_DURATION) { + if (m2Renderer_) m2Renderer_->removeInstance(it->instanceId); + it = activeImpacts_.erase(it); + } else { + ++it; + } + } +} + +void ChargeEffect::render(const Camera& camera) { + // ---- Render ribbon trail as triangle strip ---- + if (trail_.size() >= 2 && ribbonShader_) { + ribbonVerts_.clear(); + + int n = static_cast(trail_.size()); + for (int i = 0; i < n; i++) { + const auto& tp = trail_[i]; + float ageFrac = tp.age / TRAIL_LIFETIME; // 0 = fresh, 1 = about to expire + float positionFrac = static_cast(i) / static_cast(n - 1); // 0 = tail, 1 = head + + // Alpha: fade out by age and also taper toward the tail end + float alpha = (1.0f - ageFrac) * std::min(positionFrac * 3.0f, 1.0f); + // Heat: hotter near the head (character), cooler at the tail + float heat = positionFrac; + + // Width tapers: thin at tail, full at head + float width = TRAIL_HALF_WIDTH * std::min(positionFrac * 2.0f, 1.0f); + + // Two vertices: bottom (center - up*width) and top (center + up*width) + glm::vec3 bottom = tp.center - tp.side * width; + glm::vec3 top = tp.center + tp.side * width; + + // Bottom vertex (height=0, more transparent) + ribbonVerts_.push_back(bottom.x); + ribbonVerts_.push_back(bottom.y); + ribbonVerts_.push_back(bottom.z); + ribbonVerts_.push_back(alpha); + ribbonVerts_.push_back(heat); + ribbonVerts_.push_back(0.0f); // height = bottom + + // Top vertex (height=1, redder and more opaque) + ribbonVerts_.push_back(top.x); + ribbonVerts_.push_back(top.y); + ribbonVerts_.push_back(top.z); + ribbonVerts_.push_back(alpha); + ribbonVerts_.push_back(heat); + ribbonVerts_.push_back(1.0f); // height = top + } + + glBindBuffer(GL_ARRAY_BUFFER, ribbonVbo_); + glBufferData(GL_ARRAY_BUFFER, ribbonVerts_.size() * sizeof(float), + ribbonVerts_.data(), GL_DYNAMIC_DRAW); + + ribbonShader_->use(); + ribbonShader_->setUniform("uView", camera.getViewMatrix()); + ribbonShader_->setUniform("uProjection", camera.getProjectionMatrix()); + + glEnable(GL_BLEND); + glBlendFunc(GL_SRC_ALPHA, GL_ONE); // Additive blend for fiery glow + glDepthMask(GL_FALSE); + + glBindVertexArray(ribbonVao_); + glDrawArrays(GL_TRIANGLE_STRIP, 0, static_cast(n * 2)); + glBindVertexArray(0); + + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + glDepthMask(GL_TRUE); + } + + // ---- Render dust puffs ---- + if (!dustPuffs_.empty() && dustShader_) { + dustVerts_.clear(); + for (const auto& d : dustPuffs_) { + dustVerts_.push_back(d.position.x); + dustVerts_.push_back(d.position.y); + dustVerts_.push_back(d.position.z); + dustVerts_.push_back(d.size); + dustVerts_.push_back(d.alpha); + } + + glBindBuffer(GL_ARRAY_BUFFER, dustVbo_); + glBufferData(GL_ARRAY_BUFFER, dustVerts_.size() * sizeof(float), + dustVerts_.data(), GL_DYNAMIC_DRAW); + + dustShader_->use(); + dustShader_->setUniform("uView", camera.getViewMatrix()); + dustShader_->setUniform("uProjection", camera.getProjectionMatrix()); + + glEnable(GL_BLEND); + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + glDepthMask(GL_FALSE); + glEnable(GL_PROGRAM_POINT_SIZE); + + glBindVertexArray(dustVao_); + glDrawArrays(GL_POINTS, 0, static_cast(dustPuffs_.size())); + glBindVertexArray(0); + + glDepthMask(GL_TRUE); + glDisable(GL_PROGRAM_POINT_SIZE); + } +} + +} // namespace rendering +} // namespace wowee diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index 6499abc2..bd6db5d7 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/charge_effect.hpp" #include "rendering/levelup_effect.hpp" #include "rendering/character_renderer.hpp" #include "rendering/wmo_renderer.hpp" @@ -359,6 +360,13 @@ bool Renderer::initialize(core::Window* win) { // Create level-up effect (model loaded later via loadLevelUpEffect) levelUpEffect = std::make_unique(); + // Create charge effect (point-sprite particles + optional M2 models) + chargeEffect = std::make_unique(); + if (!chargeEffect->initialize()) { + LOG_WARNING("Failed to initialize charge effect"); + chargeEffect.reset(); + } + // Create character renderer characterRenderer = std::make_unique(); if (!characterRenderer->initialize()) { @@ -1509,12 +1517,20 @@ void Renderer::updateCharacterAnimation() { newState = CharAnimState::IDLE; } break; + + case CharAnimState::CHARGE: + // Stay in CHARGE until charging_ is cleared + break; } if (forceMelee) { newState = CharAnimState::MELEE_SWING; } + if (charging_) { + newState = CharAnimState::CHARGE; + } + if (newState != charAnimState) { charAnimState = newState; } @@ -1573,6 +1589,10 @@ void Renderer::updateCharacterAnimation() { loop = false; break; case CharAnimState::MOUNT: animId = ANIM_MOUNT; loop = true; break; + case CharAnimState::CHARGE: + animId = ANIM_RUN; + loop = true; + break; } uint32_t currentAnimId = 0; @@ -1632,6 +1652,34 @@ void Renderer::triggerLevelUpEffect(const glm::vec3& position) { levelUpEffect->trigger(position); } +void Renderer::startChargeEffect(const glm::vec3& position, const glm::vec3& direction) { + if (!chargeEffect) return; + + // Lazy-load M2 models on first use + if (!chargeEffect->isActive() && m2Renderer) { + if (!cachedAssetManager) { + cachedAssetManager = core::Application::getInstance().getAssetManager(); + } + if (cachedAssetManager) { + chargeEffect->tryLoadM2Models(m2Renderer.get(), cachedAssetManager); + } + } + + chargeEffect->start(position, direction); +} + +void Renderer::emitChargeEffect(const glm::vec3& position, const glm::vec3& direction) { + if (chargeEffect) { + chargeEffect->emit(position, direction); + } +} + +void Renderer::stopChargeEffect() { + if (chargeEffect) { + chargeEffect->stop(); + } +} + void Renderer::triggerMeleeSwing() { if (!characterRenderer || characterInstanceId == 0) return; if (meleeSwingCooldown > 0.0f) return; @@ -2008,6 +2056,10 @@ void Renderer::update(float deltaTime) { if (levelUpEffect) { levelUpEffect->update(deltaTime); } + // Update charge effect + if (chargeEffect) { + chargeEffect->update(deltaTime); + } auto sky2 = std::chrono::high_resolution_clock::now(); skyTime += std::chrono::duration(sky2 - sky1).count(); @@ -2685,6 +2737,11 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { mountDust->render(*camera); } + // Render charge effect (red haze + dust) + if (chargeEffect && camera) { + chargeEffect->render(*camera); + } + // Compute view/projection once for all sub-renderers const glm::mat4& view = camera ? camera->getViewMatrix() : glm::mat4(1.0f); const glm::mat4& projection = camera ? camera->getProjectionMatrix() : glm::mat4(1.0f);