diff --git a/include/audio/activity_sound_manager.hpp b/include/audio/activity_sound_manager.hpp index 6e49156c..471f785c 100644 --- a/include/audio/activity_sound_manager.hpp +++ b/include/audio/activity_sound_manager.hpp @@ -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 swimLoopClips; std::vector hardLandClips; std::vector meleeSwingClips; + std::vector attackGruntClips; + std::vector woundClips; + std::vector woundCritClips; + std::vector deathClips; std::array 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& clips, float volume, float pitchLo, float pitchHi); void startSwimLoop(); void stopSwimLoop(); diff --git a/src/audio/activity_sound_manager.cpp b/src/audio/activity_sound_manager.cpp index e9d0a2d7..52dec880 100644 --- a/src/audio/activity_sound_manager.cpp +++ b/src/audio/activity_sound_manager.cpp @@ -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 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 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 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(now - lastAttackGruntAt).count() < 1.5f) return; + } + // ~30% chance per swing to grunt (not every hit) + std::uniform_int_distribution chance(0, 9); + if (chance(rng) > 2) return; + + std::uniform_int_distribution dist(0, attackGruntClips.size() - 1); + const Sample& sample = attackGruntClips[dist(rng)]; + std::uniform_real_distribution volDist(0.55f, 0.70f); + std::uniform_real_distribution 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(now - lastWoundAt).count() < 0.8f) return; + } + std::uniform_int_distribution dist(0, clips.size() - 1); + const Sample& sample = clips[dist(rng)]; + float vol = isCrit ? 0.80f : 0.65f; + std::uniform_real_distribution 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 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 diff --git a/src/core/application.cpp b/src/core/application.cpp index fe5005cb..ec5d9c61 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -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(); } diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index abec1c22..241bec39 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -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;