Add combat sounds, melee ability animations, and player vocalizations

Wire CombatSoundManager into SMSG_ATTACKERSTATEUPDATE for weapon swing,
impact, and miss sounds. Add attack grunt and wound vocalizations to
ActivitySoundManager using correct WoW MPQ PC-suffix paths. Trigger
attack animation on SMSG_SPELL_GO for warrior melee abilities. Add
client-side melee range and facing checks to prevent server rejections.
Snap charge arrival to target's current position for reliable melee range.
This commit is contained in:
Kelsi 2026-02-19 21:31:37 -08:00
parent e163813dee
commit 8a9d9f47db
4 changed files with 250 additions and 18 deletions

View file

@ -30,6 +30,9 @@ public:
void playWaterEnter();
void playWaterExit();
void playMeleeSwing();
void playAttackGrunt();
void playWound(bool isCrit = false);
void playDeath();
void setVolumeScale(float scale) { volumeScale = scale; }
float getVolumeScale() const { return volumeScale; }
@ -52,6 +55,10 @@ private:
std::vector<Sample> swimLoopClips;
std::vector<Sample> hardLandClips;
std::vector<Sample> meleeSwingClips;
std::vector<Sample> attackGruntClips;
std::vector<Sample> woundClips;
std::vector<Sample> woundCritClips;
std::vector<Sample> deathClips;
std::array<SurfaceLandingSet, 7> landingSets;
bool swimmingActive = false;
@ -66,6 +73,8 @@ private:
std::chrono::steady_clock::time_point lastLandAt{};
std::chrono::steady_clock::time_point lastSplashAt{};
std::chrono::steady_clock::time_point lastMeleeSwingAt{};
std::chrono::steady_clock::time_point lastAttackGruntAt{};
std::chrono::steady_clock::time_point lastWoundAt{};
std::chrono::steady_clock::time_point lastSwimStrokeAt{};
bool meleeSwingWarned = false;
std::string voiceProfileKey;
@ -76,6 +85,7 @@ private:
void rebuildJumpClipsForProfile(const std::string& raceFolder, const std::string& raceBase, bool male);
void rebuildSwimLoopClipsForProfile(const std::string& raceFolder, const std::string& raceBase, bool male);
void rebuildHardLandClipsForProfile(const std::string& raceFolder, const std::string& raceBase, bool male);
void rebuildCombatVocalClipsForProfile(const std::string& raceFolder, const std::string& raceBase, bool male);
bool playOneShot(const std::vector<Sample>& clips, float volume, float pitchLo, float pitchHi);
void startSwimLoop();
void stopSwimLoop();

View file

@ -408,10 +408,14 @@ void ActivitySoundManager::setCharacterVoiceProfile(const std::string& modelName
rebuildJumpClipsForProfile(folder, base, male);
rebuildSwimLoopClipsForProfile(folder, base, male);
rebuildHardLandClipsForProfile(folder, base, male);
rebuildCombatVocalClipsForProfile(folder, base, male);
core::Logger::getInstance().info("Activity SFX voice profile: ", voiceProfileKey,
" jump clips=", jumpClips.size(),
" swim clips=", swimLoopClips.size(),
" hardLand clips=", hardLandClips.size());
" hardLand clips=", hardLandClips.size(),
" attackGrunt clips=", attackGruntClips.size(),
" wound clips=", woundClips.size(),
" death clips=", deathClips.size());
}
void ActivitySoundManager::playWaterEnter() {
@ -448,5 +452,114 @@ void ActivitySoundManager::playWaterExit() {
}
}
void ActivitySoundManager::rebuildCombatVocalClipsForProfile(const std::string& raceFolder, const std::string& raceBase, bool male) {
attackGruntClips.clear();
woundClips.clear();
woundCritClips.clear();
deathClips.clear();
const std::string gender = male ? "Male" : "Female";
// WoW MPQ convention: Sound\Character\{Race}{Gender}PC\{Race}{Gender}PC{Type}{Letter}.wav
const std::string pcStem = raceBase + gender + "PC";
const std::string pcPrefix = "Sound\\Character\\" + pcStem + "\\";
// Fallback: Sound\Character\{Race}\{Race}{Gender}{Type}{Letter}.wav
const std::string plainPrefix = "Sound\\Character\\" + raceFolder + "\\";
const std::string plainStem = raceBase + gender;
// Attack grunts (A-I covers all races)
std::vector<std::string> attackPaths;
for (char c = 'A'; c <= 'I'; ++c) {
std::string s(1, c);
attackPaths.push_back(pcPrefix + pcStem + "Attack" + s + ".wav");
}
for (char c = 'A'; c <= 'I'; ++c) {
std::string s(1, c);
attackPaths.push_back(plainPrefix + plainStem + "Attack" + s + ".wav");
}
// Also try exertion sounds as attack grunts
for (char c = 'A'; c <= 'F'; ++c) {
std::string s(1, c);
attackPaths.push_back(pcPrefix + pcStem + "Exertion" + s + ".wav");
attackPaths.push_back(plainPrefix + plainStem + "Exertion" + s + ".wav");
}
preloadCandidates(attackGruntClips, attackPaths);
// Wound sounds (A-H covers all races)
std::vector<std::string> woundPaths;
for (char c = 'A'; c <= 'H'; ++c) {
std::string s(1, c);
woundPaths.push_back(pcPrefix + pcStem + "Wound" + s + ".wav");
}
for (char c = 'A'; c <= 'H'; ++c) {
std::string s(1, c);
woundPaths.push_back(plainPrefix + plainStem + "Wound" + s + ".wav");
}
preloadCandidates(woundClips, woundPaths);
// Wound crit sounds (A-C)
std::vector<std::string> woundCritPaths;
for (char c = 'A'; c <= 'C'; ++c) {
std::string s(1, c);
woundCritPaths.push_back(pcPrefix + pcStem + "WoundCrit" + s + ".wav");
woundCritPaths.push_back(plainPrefix + plainStem + "WoundCrit" + s + ".wav");
}
preloadCandidates(woundCritClips, woundCritPaths);
// Death sounds
preloadCandidates(deathClips, {
pcPrefix + pcStem + "Death.wav",
pcPrefix + pcStem + "Death2.wav",
pcPrefix + pcStem + "DeathA.wav",
pcPrefix + pcStem + "DeathB.wav",
plainPrefix + plainStem + "Death.wav",
plainPrefix + plainStem + "Death2.wav",
plainPrefix + plainStem + "DeathA.wav",
plainPrefix + plainStem + "DeathB.wav",
});
}
void ActivitySoundManager::playAttackGrunt() {
if (!AudioEngine::instance().isInitialized() || attackGruntClips.empty()) return;
auto now = std::chrono::steady_clock::now();
if (lastAttackGruntAt.time_since_epoch().count() != 0) {
if (std::chrono::duration<float>(now - lastAttackGruntAt).count() < 1.5f) return;
}
// ~30% chance per swing to grunt (not every hit)
std::uniform_int_distribution<int> chance(0, 9);
if (chance(rng) > 2) return;
std::uniform_int_distribution<size_t> dist(0, attackGruntClips.size() - 1);
const Sample& sample = attackGruntClips[dist(rng)];
std::uniform_real_distribution<float> volDist(0.55f, 0.70f);
std::uniform_real_distribution<float> pitchDist(0.96f, 1.04f);
if (AudioEngine::instance().playSound2D(sample.data, volDist(rng) * volumeScale, pitchDist(rng))) {
lastAttackGruntAt = now;
}
}
void ActivitySoundManager::playWound(bool isCrit) {
if (!AudioEngine::instance().isInitialized()) return;
auto& clips = (isCrit && !woundCritClips.empty()) ? woundCritClips : woundClips;
if (clips.empty()) return;
auto now = std::chrono::steady_clock::now();
if (lastWoundAt.time_since_epoch().count() != 0) {
if (std::chrono::duration<float>(now - lastWoundAt).count() < 0.8f) return;
}
std::uniform_int_distribution<size_t> dist(0, clips.size() - 1);
const Sample& sample = clips[dist(rng)];
float vol = isCrit ? 0.80f : 0.65f;
std::uniform_real_distribution<float> pitchDist(0.96f, 1.04f);
if (AudioEngine::instance().playSound2D(sample.data, vol * volumeScale, pitchDist(rng))) {
lastWoundAt = now;
}
}
void ActivitySoundManager::playDeath() {
if (!AudioEngine::instance().isInitialized() || deathClips.empty()) return;
std::uniform_int_distribution<size_t> dist(0, deathClips.size() - 1);
const Sample& sample = deathClips[dist(rng)];
AudioEngine::instance().playSound2D(sample.data, 0.85f * volumeScale, 1.0f);
}
} // namespace audio
} // namespace wowee

View file

@ -856,8 +856,26 @@ void Application::update(float deltaTime) {
renderer->getCameraController()->setExternalFollow(false);
renderer->getCameraController()->setExternalMoving(false);
// Start auto-attack on arrival
// Snap to melee range of target's CURRENT position (it may have moved)
if (chargeTargetGuid_ != 0) {
auto targetEntity = gameHandler->getEntityManager().getEntity(chargeTargetGuid_);
if (targetEntity) {
glm::vec3 targetCanonical(targetEntity->getX(), targetEntity->getY(), targetEntity->getZ());
glm::vec3 targetRender = core::coords::canonicalToRender(targetCanonical);
glm::vec3 toTarget = targetRender - renderPos;
float d = glm::length(toTarget);
if (d > 1.5f) {
// Place us 1.5 units from target (well within 8-unit melee range)
glm::vec3 snapPos = targetRender - glm::normalize(toTarget) * 1.5f;
renderer->getCharacterPosition() = snapPos;
glm::vec3 snapCanonical = core::coords::renderToCanonical(snapPos);
gameHandler->setPosition(snapCanonical.x, snapCanonical.y, snapCanonical.z);
if (renderer->getCameraController()) {
glm::vec3* ft = renderer->getCameraController()->getFollowTargetMutable();
if (ft) *ft = snapPos;
}
}
}
gameHandler->startAutoAttack(chargeTargetGuid_);
renderer->triggerMeleeSwing();
}

View file

@ -8,6 +8,8 @@
#include "game/update_field_table.hpp"
#include "game/expansion_profile.hpp"
#include "rendering/renderer.hpp"
#include "audio/activity_sound_manager.hpp"
#include "audio/combat_sound_manager.hpp"
#include "audio/spell_sound_manager.hpp"
#include "audio/ui_sound_manager.hpp"
#include "pipeline/dbc_layout.hpp"
@ -7969,6 +7971,32 @@ void GameHandler::handleAttackerStateUpdate(network::Packet& packet) {
autoTargetAttacker(data.attackerGuid);
}
// Play combat sounds via CombatSoundManager + character vocalizations
if (auto* renderer = core::Application::getInstance().getRenderer()) {
if (auto* csm = renderer->getCombatSoundManager()) {
auto weaponSize = audio::CombatSoundManager::WeaponSize::MEDIUM;
if (data.isMiss()) {
csm->playWeaponMiss(false);
} else if (data.victimState == 1 || data.victimState == 2) {
// Dodge/parry — swing whoosh but no impact
csm->playWeaponSwing(weaponSize, false);
} else {
// Hit — swing + flesh impact
csm->playWeaponSwing(weaponSize, data.isCrit());
csm->playImpact(weaponSize, audio::CombatSoundManager::ImpactType::FLESH, data.isCrit());
}
}
// Character vocalizations
if (auto* asm_ = renderer->getActivitySoundManager()) {
if (isPlayerAttacker && !data.isMiss() && data.victimState != 1 && data.victimState != 2) {
asm_->playAttackGrunt();
}
if (isPlayerTarget && !data.isMiss() && data.victimState != 1 && data.victimState != 2) {
asm_->playWound(data.isCrit());
}
}
}
if (data.isMiss()) {
addCombatText(CombatTextEntry::MISS, 0, 0, isPlayerAttacker);
} else if (data.victimState == 1) {
@ -8048,28 +8076,71 @@ void GameHandler::castSpell(uint32_t spellId, uint64_t targetGuid) {
if (spellId == 8690) target = 0;
// Warrior Charge (ranks 1-3): client-side range check + charge callback
// Must face target and validate range BEFORE sending packet to server
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);
if (!entity) {
addSystemChatMessage("You have no target.");
return;
}
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;
}
// Face the target before sending the cast packet to avoid "not in front" rejection
float yaw = std::atan2(dy, dx);
movementInfo.orientation = yaw;
sendMovement(Opcode::CMSG_MOVE_SET_FACING);
if (chargeCallback_) {
chargeCallback_(target, tx, ty, tz);
}
}
// Instant melee abilities: client-side range + facing check to avoid server "not in front" errors
{
uint32_t sid = spellId;
bool isMeleeAbility =
sid == 78 || sid == 284 || sid == 285 || sid == 1608 || // Heroic Strike
sid == 11564 || sid == 11565 || sid == 11566 || sid == 11567 ||
sid == 25286 || sid == 29707 || sid == 30324 ||
sid == 772 || sid == 6546 || sid == 6547 || sid == 6548 || // Rend
sid == 11572 || sid == 11573 || sid == 11574 || sid == 25208 ||
sid == 6572 || sid == 6574 || sid == 7379 || sid == 11600 || // Revenge
sid == 11601 || sid == 25288 || sid == 25269 || sid == 30357 ||
sid == 845 || sid == 7369 || sid == 11608 || sid == 11609 || // Cleave
sid == 20569 || sid == 25231 || sid == 47519 || sid == 47520 ||
sid == 12294 || sid == 21551 || sid == 21552 || sid == 21553 || // Mortal Strike
sid == 25248 || sid == 30330 || sid == 47485 || sid == 47486 ||
sid == 23922 || sid == 23923 || sid == 23924 || sid == 23925 || // Shield Slam
sid == 25258 || sid == 30356 || sid == 47487 || sid == 47488;
if (isMeleeAbility && target != 0) {
auto entity = entityManager.getEntity(target);
if (entity) {
float dx = entity->getX() - movementInfo.x;
float dy = entity->getY() - movementInfo.y;
float dz = entity->getZ() - movementInfo.z;
float dist = std::sqrt(dx * dx + dy * dy + dz * dz);
if (dist > 8.0f) {
addSystemChatMessage("Out of range.");
return;
}
// Face the target to prevent "not in front" rejection
float yaw = std::atan2(dy, dx);
movementInfo.orientation = yaw;
sendMovement(Opcode::CMSG_MOVE_SET_FACING);
}
}
}
@ -8208,6 +8279,26 @@ void GameHandler::handleSpellGo(network::Packet& packet) {
}
}
// Instant melee abilities → trigger attack animation
uint32_t sid = data.spellId;
bool isMeleeAbility =
sid == 78 || sid == 284 || sid == 285 || sid == 1608 || // Heroic Strike ranks
sid == 11564 || sid == 11565 || sid == 11566 || sid == 11567 ||
sid == 25286 || sid == 29707 || sid == 30324 ||
sid == 772 || sid == 6546 || sid == 6547 || sid == 6548 || // Rend ranks
sid == 11572 || sid == 11573 || sid == 11574 || sid == 25208 ||
sid == 6572 || sid == 6574 || sid == 7379 || sid == 11600 || // Revenge ranks
sid == 11601 || sid == 25288 || sid == 25269 || sid == 30357 ||
sid == 845 || sid == 7369 || sid == 11608 || sid == 11609 || // Cleave ranks
sid == 20569 || sid == 25231 || sid == 47519 || sid == 47520 ||
sid == 12294 || sid == 21551 || sid == 21552 || sid == 21553 || // Mortal Strike ranks
sid == 25248 || sid == 30330 || sid == 47485 || sid == 47486 ||
sid == 23922 || sid == 23923 || sid == 23924 || sid == 23925 || // Shield Slam ranks
sid == 25258 || sid == 30356 || sid == 47487 || sid == 47488;
if (isMeleeAbility && meleeSwingCallback_) {
meleeSwingCallback_();
}
casting = false;
currentCastSpellId = 0;
castTimeRemaining = 0.0f;