mirror of
https://github.com/Kelsidavis/WoWee.git
synced 2026-03-22 23:30:14 +00:00
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:
parent
da49593268
commit
e163813dee
9 changed files with 761 additions and 2 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -745,6 +745,11 @@ public:
|
|||
}
|
||||
const std::unordered_map<uint64_t, QuestGiverStatus>& 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(uint64_t targetGuid, float x, float y, float z)>;
|
||||
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(uint32_t newLevel)>;
|
||||
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_;
|
||||
|
|
|
|||
107
include/rendering/charge_effect.hpp
Normal file
107
include/rendering/charge_effect.hpp
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
#pragma once
|
||||
|
||||
#include <GL/glew.h>
|
||||
#include <glm/glm.hpp>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
#include <deque>
|
||||
#include <cstdint>
|
||||
|
||||
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<TrailPoint> trail_;
|
||||
|
||||
GLuint ribbonVao_ = 0;
|
||||
GLuint ribbonVbo_ = 0;
|
||||
std::unique_ptr<Shader> ribbonShader_;
|
||||
std::vector<float> 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<DustPuff> dustPuffs_;
|
||||
|
||||
GLuint dustVao_ = 0;
|
||||
GLuint dustVbo_ = 0;
|
||||
std::unique_ptr<Shader> dustShader_;
|
||||
std::vector<float> 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<ActiveM2> activeImpacts_;
|
||||
};
|
||||
|
||||
} // namespace rendering
|
||||
} // namespace wowee
|
||||
|
|
@ -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> swimEffects;
|
||||
std::unique_ptr<MountDust> mountDust;
|
||||
std::unique_ptr<LevelUpEffect> levelUpEffect;
|
||||
std::unique_ptr<ChargeEffect> chargeEffect;
|
||||
std::unique_ptr<CharacterRenderer> characterRenderer;
|
||||
std::unique_ptr<WMORenderer> wmoRenderer;
|
||||
std::unique_ptr<M2Renderer> 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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
442
src/rendering/charge_effect.cpp
Normal file
442
src/rendering/charge_effect.cpp
Normal file
|
|
@ -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 <glm/gtc/matrix_transform.hpp>
|
||||
#include <random>
|
||||
#include <cmath>
|
||||
|
||||
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<float> dist(lo, hi);
|
||||
return dist(rng());
|
||||
}
|
||||
|
||||
ChargeEffect::ChargeEffect() = default;
|
||||
ChargeEffect::~ChargeEffect() { shutdown(); }
|
||||
|
||||
bool ChargeEffect::initialize() {
|
||||
// ---- Ribbon trail shader ----
|
||||
ribbonShader_ = std::make_unique<Shader>();
|
||||
|
||||
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<Shader>();
|
||||
|
||||
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<int>(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<float>(i) / static_cast<float>(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<GLsizei>(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<GLsizei>(dustPuffs_.size()));
|
||||
glBindVertexArray(0);
|
||||
|
||||
glDepthMask(GL_TRUE);
|
||||
glDisable(GL_PROGRAM_POINT_SIZE);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace rendering
|
||||
} // namespace wowee
|
||||
|
|
@ -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<LevelUpEffect>();
|
||||
|
||||
// Create charge effect (point-sprite particles + optional M2 models)
|
||||
chargeEffect = std::make_unique<ChargeEffect>();
|
||||
if (!chargeEffect->initialize()) {
|
||||
LOG_WARNING("Failed to initialize charge effect");
|
||||
chargeEffect.reset();
|
||||
}
|
||||
|
||||
// Create character renderer
|
||||
characterRenderer = std::make_unique<CharacterRenderer>();
|
||||
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<float, std::milli>(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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue