Add Warrior Charge ability with ribbon trail visual effect

Implements charge rush-to-target for spell IDs 100/6178/11578 with
smoothstep lerp movement, vertical red-orange ribbon trail, dust puffs,
client-side range validation, and sound fallback chain.
This commit is contained in:
Kelsi 2026-02-19 21:13:13 -08:00
parent da49593268
commit e163813dee
9 changed files with 761 additions and 2 deletions

View file

@ -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 <imgui.h>
#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) {