From 8bc50818a9297714b8d3e19e96422a76da41c015 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 3 Feb 2026 19:49:56 -0800 Subject: [PATCH] Implement activity SFX and decouple camera orbit from movement facing --- CMakeLists.txt | 2 + include/audio/activity_sound_manager.hpp | 78 +++++ include/rendering/camera_controller.hpp | 2 + include/rendering/character_renderer.hpp | 1 + include/rendering/renderer.hpp | 8 +- src/audio/activity_sound_manager.cpp | 390 +++++++++++++++++++++++ src/rendering/camera_controller.cpp | 36 ++- src/rendering/character_renderer.cpp | 13 + src/rendering/renderer.cpp | 74 ++++- 9 files changed, 592 insertions(+), 12 deletions(-) create mode 100644 include/audio/activity_sound_manager.hpp create mode 100644 src/audio/activity_sound_manager.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index af6dad5a..6ea8df1c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -97,6 +97,7 @@ set(WOWEE_SOURCES # Audio src/audio/music_manager.cpp src/audio/footstep_manager.cpp + src/audio/activity_sound_manager.cpp # Pipeline (asset loaders) src/pipeline/mpq_manager.cpp @@ -176,6 +177,7 @@ set(WOWEE_HEADERS include/audio/music_manager.hpp include/audio/footstep_manager.hpp + include/audio/activity_sound_manager.hpp include/pipeline/mpq_manager.hpp include/pipeline/blp_loader.hpp diff --git a/include/audio/activity_sound_manager.hpp b/include/audio/activity_sound_manager.hpp new file mode 100644 index 00000000..1a4b253c --- /dev/null +++ b/include/audio/activity_sound_manager.hpp @@ -0,0 +1,78 @@ +#pragma once + +#include "audio/footstep_manager.hpp" +#include +#include +#include +#include +#include +#include + +namespace wowee { +namespace pipeline { class AssetManager; } +namespace audio { + +class ActivitySoundManager { +public: + ActivitySoundManager(); + ~ActivitySoundManager(); + + bool initialize(pipeline::AssetManager* assets); + void shutdown(); + void update(float deltaTime); + bool isInitialized() const { return initialized; } + + void playJump(); + void playLanding(FootstepSurface surface, bool hardLanding); + void setSwimmingState(bool swimming, bool moving); + void setCharacterVoiceProfile(const std::string& modelName); + void playWaterEnter(); + void playWaterExit(); + +private: + struct Sample { + std::string path; + std::vector data; + }; + + struct SurfaceLandingSet { + std::vector clips; + }; + + bool initialized = false; + pipeline::AssetManager* assetManager = nullptr; + + std::vector jumpClips; + std::vector splashEnterClips; + std::vector splashExitClips; + std::vector swimLoopClips; + std::vector hardLandClips; + std::array landingSets; + + bool swimmingActive = false; + bool swimMoving = false; + pid_t swimLoopPid = -1; + pid_t oneShotPid = -1; + std::string loopTempPath = "/tmp/wowee_swim_loop.wav"; + std::string oneShotTempPath = "/tmp/wowee_activity.wav"; + std::mt19937 rng; + + std::chrono::steady_clock::time_point lastJumpAt{}; + std::chrono::steady_clock::time_point lastLandAt{}; + std::chrono::steady_clock::time_point lastSplashAt{}; + std::string voiceProfileKey; + + void preloadCandidates(std::vector& out, const std::vector& candidates); + void preloadLandingSet(FootstepSurface surface, const std::string& material); + 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); + bool playOneShot(const std::vector& clips, float volume, float pitchLo, float pitchHi); + void startSwimLoop(); + void stopSwimLoop(); + void stopOneShot(); + void reapProcesses(); +}; + +} // namespace audio +} // namespace wowee diff --git a/include/rendering/camera_controller.hpp b/include/rendering/camera_controller.hpp index ffe5c631..5ed0b325 100644 --- a/include/rendering/camera_controller.hpp +++ b/include/rendering/camera_controller.hpp @@ -37,6 +37,7 @@ public: float getMovementSpeed() const { return movementSpeed; } bool isMoving() const; float getYaw() const { return yaw; } + float getFacingYaw() const { return facingYaw; } bool isThirdPerson() const { return thirdPerson; } bool isGrounded() const { return grounded; } bool isJumping() const { return !grounded && verticalVelocity > 0.0f; } @@ -74,6 +75,7 @@ private: // Stored rotation (avoids lossy forward-vector round-trip) float yaw = 180.0f; float pitch = -30.0f; + float facingYaw = 180.0f; // Character-facing yaw (can differ from camera yaw) // Movement settings float movementSpeed = 50.0f; diff --git a/include/rendering/character_renderer.hpp b/include/rendering/character_renderer.hpp index c5dc7964..e56a6a2c 100644 --- a/include/rendering/character_renderer.hpp +++ b/include/rendering/character_renderer.hpp @@ -65,6 +65,7 @@ public: void removeInstance(uint32_t instanceId); bool getAnimationState(uint32_t instanceId, uint32_t& animationId, float& animationTimeMs, float& animationDurationMs) const; bool hasAnimation(uint32_t instanceId, uint32_t animationId) const; + bool getInstanceModelName(uint32_t instanceId, std::string& modelName) const; /** Attach a weapon model to a character instance at the given attachment point. */ bool attachWeapon(uint32_t charInstanceId, uint32_t attachmentId, diff --git a/include/rendering/renderer.hpp b/include/rendering/renderer.hpp index 67c3bdb8..3963c31c 100644 --- a/include/rendering/renderer.hpp +++ b/include/rendering/renderer.hpp @@ -8,7 +8,7 @@ namespace wowee { namespace core { class Window; } namespace game { class World; class ZoneManager; } -namespace audio { class MusicManager; class FootstepManager; enum class FootstepSurface : uint8_t; } +namespace audio { class MusicManager; class FootstepManager; class ActivitySoundManager; enum class FootstepSurface : uint8_t; } namespace pipeline { class AssetManager; } namespace rendering { @@ -151,6 +151,7 @@ private: std::unique_ptr minimap; std::unique_ptr musicManager; std::unique_ptr footstepManager; + std::unique_ptr activitySoundManager; std::unique_ptr zoneManager; pipeline::AssetManager* cachedAssetManager = nullptr; @@ -182,6 +183,11 @@ private: uint32_t footstepLastAnimationId = 0; float footstepLastNormTime = 0.0f; bool footstepNormInitialized = false; + bool sfxStateInitialized = false; + bool sfxPrevGrounded = true; + bool sfxPrevJumping = false; + bool sfxPrevFalling = false; + bool sfxPrevSwimming = false; bool terrainEnabled = true; bool terrainLoaded = false; diff --git a/src/audio/activity_sound_manager.cpp b/src/audio/activity_sound_manager.cpp new file mode 100644 index 00000000..120ea5b7 --- /dev/null +++ b/src/audio/activity_sound_manager.cpp @@ -0,0 +1,390 @@ +#include "audio/activity_sound_manager.hpp" +#include "pipeline/asset_manager.hpp" +#include "core/logger.hpp" +#include +#include +#include +#include +#include +#include +#include + +namespace wowee { +namespace audio { + +namespace { + +std::vector buildClassicSet(const std::string& material) { + std::vector out; + for (char c = 'A'; c <= 'L'; ++c) { + out.push_back("Sound\\Character\\Footsteps\\mFootMediumLarge" + material + std::string(1, c) + ".wav"); + } + return out; +} + +} // namespace + +ActivitySoundManager::ActivitySoundManager() : rng(std::random_device{}()) {} +ActivitySoundManager::~ActivitySoundManager() { shutdown(); } + +bool ActivitySoundManager::initialize(pipeline::AssetManager* assets) { + shutdown(); + assetManager = assets; + if (!assetManager) return false; + + rebuildJumpClipsForProfile("Human", "Human", true); + rebuildSwimLoopClipsForProfile("Human", "Human", true); + rebuildHardLandClipsForProfile("Human", "Human", true); + + preloadCandidates(splashEnterClips, { + "Sound\\Character\\General\\Water\\WaterSplashSmall.wav", + "Sound\\Character\\General\\Water\\WaterSplashMedium.wav", + "Sound\\Character\\General\\Water\\WaterSplashLarge.wav", + "Sound\\Character\\Footsteps\\mFootMediumLargeWaterA.wav", + "Sound\\Character\\Footsteps\\mFootMediumLargeWaterB.wav", + "Sound\\Character\\Footsteps\\mFootMediumLargeWaterC.wav", + "Sound\\Character\\Footsteps\\mFootMediumLargeWaterD.wav" + }); + splashExitClips = splashEnterClips; + + preloadLandingSet(FootstepSurface::STONE, "Stone"); + preloadLandingSet(FootstepSurface::DIRT, "Dirt"); + preloadLandingSet(FootstepSurface::GRASS, "Grass"); + preloadLandingSet(FootstepSurface::WOOD, "Wood"); + preloadLandingSet(FootstepSurface::METAL, "Metal"); + preloadLandingSet(FootstepSurface::WATER, "Water"); + preloadLandingSet(FootstepSurface::SNOW, "Snow"); + + initialized = true; + core::Logger::getInstance().info("Activity SFX loaded: jump=", jumpClips.size(), + " splash=", splashEnterClips.size(), + " swimLoop=", swimLoopClips.size()); + return true; +} + +void ActivitySoundManager::shutdown() { + stopSwimLoop(); + stopOneShot(); + std::remove(loopTempPath.c_str()); + std::remove(oneShotTempPath.c_str()); + for (auto& set : landingSets) set.clips.clear(); + jumpClips.clear(); + splashEnterClips.clear(); + splashExitClips.clear(); + swimLoopClips.clear(); + hardLandClips.clear(); + swimmingActive = false; + swimMoving = false; + initialized = false; + assetManager = nullptr; +} + +void ActivitySoundManager::update(float) { + reapProcesses(); +} + +void ActivitySoundManager::preloadCandidates(std::vector& out, const std::vector& candidates) { + if (!assetManager) return; + for (const auto& path : candidates) { + if (!assetManager->fileExists(path)) continue; + auto data = assetManager->readFile(path); + if (data.empty()) continue; + out.push_back({path, std::move(data)}); + } +} + +void ActivitySoundManager::preloadLandingSet(FootstepSurface surface, const std::string& material) { + auto& clips = landingSets[static_cast(surface)].clips; + preloadCandidates(clips, buildClassicSet(material)); +} + +void ActivitySoundManager::rebuildJumpClipsForProfile(const std::string& raceFolder, const std::string& raceBase, bool male) { + jumpClips.clear(); + const std::string gender = male ? "Male" : "Female"; + const std::string prefix = "Sound\\Character\\" + raceFolder + "\\"; + const std::string stem = raceBase + gender; + const std::string genderDir = male ? "Male" : "Female"; + preloadCandidates(jumpClips, { + // Common WotLK-style variants. + prefix + stem + "\\" + stem + "Jump01.wav", + prefix + stem + "\\" + stem + "Jump02.wav", + prefix + stem + "\\" + stem + "Jump03.wav", + prefix + stem + "\\" + stem + "Exertion01.wav", + prefix + stem + "\\" + stem + "Exertion02.wav", + prefix + stem + "JumpA.wav", + prefix + stem + "JumpB.wav", + prefix + stem + "JumpC.wav", + prefix + stem + "Jump.wav", + prefix + stem + "JumpStart.wav", + prefix + stem + "Land.wav", + prefix + genderDir + "\\" + stem + "JumpA.wav", + prefix + genderDir + "\\" + stem + "JumpB.wav", + prefix + genderDir + "\\" + stem + "JumpC.wav", + prefix + genderDir + "\\" + stem + "Jump.wav", + prefix + genderDir + "\\" + stem + "JumpStart.wav", + prefix + raceBase + "JumpA.wav", + prefix + raceBase + "JumpB.wav", + prefix + raceBase + "JumpC.wav", + prefix + raceBase + "Jump.wav", + prefix + raceBase + "\\" + stem + "JumpA.wav", + prefix + raceBase + "\\" + stem + "JumpB.wav", + prefix + raceBase + "\\" + stem + "JumpC.wav", + // Alternate folder naming in some packs. + "Sound\\Character\\" + stem + "\\" + stem + "JumpA.wav", + "Sound\\Character\\" + stem + "\\" + stem + "JumpB.wav", + "Sound\\Character\\" + stem + "\\" + stem + "Jump.wav", + // Fallback safety + "Sound\\Character\\Human\\HumanMaleJumpA.wav", + "Sound\\Character\\Human\\HumanMaleJumpB.wav", + "Sound\\Character\\Human\\HumanFemaleJumpA.wav", + "Sound\\Character\\Human\\HumanFemaleJumpB.wav", + "Sound\\Character\\Human\\Male\\HumanMaleJumpA.wav", + "Sound\\Character\\Human\\Male\\HumanMaleJumpB.wav", + "Sound\\Character\\Human\\Female\\HumanFemaleJumpA.wav", + "Sound\\Character\\Human\\Female\\HumanFemaleJumpB.wav", + "Sound\\Character\\Human\\HumanMale\\HumanMaleJump01.wav", + "Sound\\Character\\Human\\HumanMale\\HumanMaleJump02.wav", + "Sound\\Character\\Human\\HumanMale\\HumanMaleJump03.wav", + "Sound\\Character\\Human\\HumanFemale\\HumanFemaleJump01.wav", + "Sound\\Character\\Human\\HumanFemale\\HumanFemaleJump02.wav", + "Sound\\Character\\Human\\HumanFemale\\HumanFemaleJump03.wav", + "Sound\\Character\\HumanMale\\HumanMaleJumpA.wav", + "Sound\\Character\\HumanMale\\HumanMaleJumpB.wav", + "Sound\\Character\\HumanFemale\\HumanFemaleJumpA.wav", + "Sound\\Character\\HumanFemale\\HumanFemaleJumpB.wav" + }); +} + +void ActivitySoundManager::rebuildSwimLoopClipsForProfile(const std::string& raceFolder, const std::string& raceBase, bool male) { + swimLoopClips.clear(); + const std::string gender = male ? "Male" : "Female"; + const std::string prefix = "Sound\\Character\\" + raceFolder + "\\"; + const std::string stem = raceBase + gender; + preloadCandidates(swimLoopClips, { + prefix + stem + "\\" + stem + "SwimLoop.wav", + prefix + stem + "\\" + stem + "Swim01.wav", + prefix + stem + "\\" + stem + "Swim02.wav", + prefix + stem + "SwimLoop.wav", + prefix + stem + "Swim01.wav", + prefix + stem + "Swim02.wav", + prefix + (male ? "Male" : "Female") + "\\" + stem + "SwimLoop.wav", + "Sound\\Character\\Swim\\SwimMoveLoop.wav", + "Sound\\Character\\Swim\\SwimLoop.wav", + "Sound\\Character\\Swim\\SwimSlowLoop.wav" + }); + if (swimLoopClips.empty()) { + preloadCandidates(swimLoopClips, buildClassicSet("Water")); + } +} + +void ActivitySoundManager::rebuildHardLandClipsForProfile(const std::string& raceFolder, const std::string& raceBase, bool male) { + hardLandClips.clear(); + const std::string gender = male ? "Male" : "Female"; + const std::string prefix = "Sound\\Character\\" + raceFolder + "\\"; + const std::string stem = raceBase + gender; + preloadCandidates(hardLandClips, { + prefix + stem + "\\" + stem + "LandHard01.wav", + prefix + stem + "\\" + stem + "LandHard02.wav", + prefix + stem + "LandHard01.wav", + prefix + stem + "LandHard02.wav" + }); +} + +bool ActivitySoundManager::playOneShot(const std::vector& clips, float volume, float pitchLo, float pitchHi) { + if (clips.empty()) return false; + reapProcesses(); + if (oneShotPid > 0) return false; + + std::uniform_int_distribution clipDist(0, clips.size() - 1); + const Sample& sample = clips[clipDist(rng)]; + std::ofstream out(oneShotTempPath, std::ios::binary); + if (!out) return false; + out.write(reinterpret_cast(sample.data.data()), static_cast(sample.data.size())); + out.close(); + + std::uniform_real_distribution pitchDist(pitchLo, pitchHi); + float pitch = pitchDist(rng); + if (volume < 0.1f) volume = 0.1f; + if (volume > 1.2f) volume = 1.2f; + std::string filter = "asetrate=44100*" + std::to_string(pitch) + + ",aresample=44100,volume=" + std::to_string(volume); + + pid_t pid = fork(); + if (pid == 0) { + setpgid(0, 0); + FILE* outFile = freopen("/dev/null", "w", stdout); + FILE* errFile = freopen("/dev/null", "w", stderr); + (void)outFile; (void)errFile; + execlp("ffplay", "ffplay", "-nodisp", "-autoexit", "-loglevel", "quiet", + "-af", filter.c_str(), oneShotTempPath.c_str(), nullptr); + _exit(1); + } else if (pid > 0) { + oneShotPid = pid; + return true; + } + return false; +} + +void ActivitySoundManager::startSwimLoop() { + if (swimLoopPid > 0 || swimLoopClips.empty()) return; + std::uniform_int_distribution clipDist(0, swimLoopClips.size() - 1); + const Sample& sample = swimLoopClips[clipDist(rng)]; + + std::ofstream out(loopTempPath, std::ios::binary); + if (!out) return; + out.write(reinterpret_cast(sample.data.data()), static_cast(sample.data.size())); + out.close(); + + float volume = swimMoving ? 0.85f : 0.65f; + std::string filter = "volume=" + std::to_string(volume); + + pid_t pid = fork(); + if (pid == 0) { + setpgid(0, 0); + FILE* outFile = freopen("/dev/null", "w", stdout); + FILE* errFile = freopen("/dev/null", "w", stderr); + (void)outFile; (void)errFile; + execlp("ffplay", "ffplay", "-nodisp", "-autoexit", "-loop", "0", "-loglevel", "quiet", + "-af", filter.c_str(), loopTempPath.c_str(), nullptr); + _exit(1); + } else if (pid > 0) { + swimLoopPid = pid; + } +} + +void ActivitySoundManager::stopSwimLoop() { + if (swimLoopPid > 0) { + kill(-swimLoopPid, SIGTERM); + kill(swimLoopPid, SIGTERM); + int status = 0; + waitpid(swimLoopPid, &status, 0); + swimLoopPid = -1; + } +} + +void ActivitySoundManager::stopOneShot() { + if (oneShotPid > 0) { + kill(-oneShotPid, SIGTERM); + kill(oneShotPid, SIGTERM); + int status = 0; + waitpid(oneShotPid, &status, 0); + oneShotPid = -1; + } +} + +void ActivitySoundManager::reapProcesses() { + if (oneShotPid > 0) { + int status = 0; + pid_t result = waitpid(oneShotPid, &status, WNOHANG); + if (result == oneShotPid) oneShotPid = -1; + } + if (swimLoopPid > 0) { + int status = 0; + pid_t result = waitpid(swimLoopPid, &status, WNOHANG); + if (result == swimLoopPid) swimLoopPid = -1; + } +} + +void ActivitySoundManager::playJump() { + auto now = std::chrono::steady_clock::now(); + if (lastJumpAt.time_since_epoch().count() != 0) { + if (std::chrono::duration(now - lastJumpAt).count() < 0.35f) return; + } + if (playOneShot(jumpClips, 0.72f, 0.98f, 1.04f)) { + lastJumpAt = now; + } +} + +void ActivitySoundManager::playLanding(FootstepSurface surface, bool hardLanding) { + auto now = std::chrono::steady_clock::now(); + if (lastLandAt.time_since_epoch().count() != 0) { + if (std::chrono::duration(now - lastLandAt).count() < 0.10f) return; + } + const auto& clips = landingSets[static_cast(surface)].clips; + if (playOneShot(clips, hardLanding ? 1.00f : 0.82f, 0.95f, 1.03f)) { + lastLandAt = now; + } + if (hardLanding) { + playOneShot(hardLandClips, 0.84f, 0.97f, 1.03f); + } +} + +void ActivitySoundManager::setSwimmingState(bool swimming, bool moving) { + swimMoving = moving; + if (swimming == swimmingActive) return; + swimmingActive = swimming; + if (swimmingActive) { + startSwimLoop(); + } else { + stopSwimLoop(); + } +} + +void ActivitySoundManager::setCharacterVoiceProfile(const std::string& modelName) { + if (!assetManager || modelName.empty()) return; + + std::string lower = modelName; + for (char& c : lower) c = static_cast(std::tolower(static_cast(c))); + + bool male = (lower.find("female") == std::string::npos); + std::string folder = "Human"; + std::string base = "Human"; + + struct RaceMap { const char* token; const char* folder; const char* base; }; + static const RaceMap races[] = { + {"human", "Human", "Human"}, + {"orc", "Orc", "Orc"}, + {"dwarf", "Dwarf", "Dwarf"}, + {"nightelf", "NightElf", "NightElf"}, + {"scourge", "Scourge", "Scourge"}, + {"undead", "Scourge", "Scourge"}, + {"tauren", "Tauren", "Tauren"}, + {"gnome", "Gnome", "Gnome"}, + {"troll", "Troll", "Troll"}, + {"bloodelf", "BloodElf", "BloodElf"}, + {"draenei", "Draenei", "Draenei"}, + {"goblin", "Goblin", "Goblin"}, + {"worgen", "Worgen", "Worgen"}, + }; + for (const auto& r : races) { + if (lower.find(r.token) != std::string::npos) { + folder = r.folder; + base = r.base; + break; + } + } + + std::string key = folder + "|" + base + "|" + (male ? "M" : "F"); + if (key == voiceProfileKey) return; + voiceProfileKey = key; + rebuildJumpClipsForProfile(folder, base, male); + rebuildSwimLoopClipsForProfile(folder, base, male); + rebuildHardLandClipsForProfile(folder, base, male); + core::Logger::getInstance().info("Activity SFX voice profile: ", voiceProfileKey, + " jump clips=", jumpClips.size(), + " swim clips=", swimLoopClips.size(), + " hardLand clips=", hardLandClips.size()); +} + +void ActivitySoundManager::playWaterEnter() { + auto now = std::chrono::steady_clock::now(); + if (lastSplashAt.time_since_epoch().count() != 0) { + if (std::chrono::duration(now - lastSplashAt).count() < 0.20f) return; + } + if (playOneShot(splashEnterClips, 0.95f, 0.95f, 1.05f)) { + lastSplashAt = now; + } +} + +void ActivitySoundManager::playWaterExit() { + auto now = std::chrono::steady_clock::now(); + if (lastSplashAt.time_since_epoch().count() != 0) { + if (std::chrono::duration(now - lastSplashAt).count() < 0.20f) return; + } + if (playOneShot(splashExitClips, 0.95f, 0.95f, 1.05f)) { + lastSplashAt = now; + } +} + +} // namespace audio +} // namespace wowee diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index 621499b0..4df74d2c 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -36,6 +36,7 @@ std::optional selectReachableFloor(const std::optional& terrainH, CameraController::CameraController(Camera* cam) : camera(cam) { yaw = defaultYaw; + facingYaw = defaultYaw; pitch = defaultPitch; reset(); } @@ -90,6 +91,7 @@ void CameraController::update(float deltaTime) { } if (nowTurnLeft || nowTurnRight) { camera->setRotation(yaw, pitch); + facingYaw = yaw; } // Select physics constants based on mode @@ -129,12 +131,14 @@ void CameraController::update(float deltaTime) { // Get camera axes — project forward onto XY plane for walking glm::vec3 forward3D = camera->getForward(); - glm::vec3 forward = glm::normalize(glm::vec3(forward3D.x, forward3D.y, 0.0f)); - glm::vec3 right = camera->getRight(); - right.z = 0.0f; - if (glm::length(right) > 0.001f) { - right = glm::normalize(right); + bool cameraDrivesFacing = rightMouseDown || mouseAutorun; + if (cameraDrivesFacing) { + facingYaw = yaw; } + float moveYaw = cameraDrivesFacing ? yaw : facingYaw; + float moveYawRad = glm::radians(moveYaw); + glm::vec3 forward(std::cos(moveYawRad), std::sin(moveYawRad), 0.0f); + glm::vec3 right(-std::sin(moveYawRad), std::cos(moveYawRad), 0.0f); // Toggle sit/crouch with X or C key (edge-triggered) — only when UI doesn't want keyboard bool xDown = !uiWantsKeyboard && (input.isKeyPressed(SDL_SCANCODE_X) || input.isKeyPressed(SDL_SCANCODE_C)); @@ -209,6 +213,27 @@ void CameraController::update(float deltaTime) { if (verticalVelocity > 0.0f) verticalVelocity = 0.0f; } + // Prevent sinking/clipping through world floor while swimming. + std::optional floorH; + if (terrainManager) { + floorH = terrainManager->getHeightAt(targetPos.x, targetPos.y); + } + if (wmoRenderer) { + auto wh = wmoRenderer->getFloorHeight(targetPos.x, targetPos.y, targetPos.z + 5.0f); + if (wh && (!floorH || *wh > *floorH)) floorH = wh; + } + if (m2Renderer) { + auto mh = m2Renderer->getFloorHeight(targetPos.x, targetPos.y, targetPos.z); + if (mh && (!floorH || *mh > *floorH)) floorH = mh; + } + if (floorH) { + float swimFloor = *floorH + 0.30f; + if (targetPos.z < swimFloor) { + targetPos.z = swimFloor; + if (verticalVelocity < 0.0f) verticalVelocity = 0.0f; + } + } + grounded = false; } else { swimming = false; @@ -815,6 +840,7 @@ void CameraController::reset() { } yaw = defaultYaw; + facingYaw = defaultYaw; pitch = defaultPitch; verticalVelocity = 0.0f; grounded = true; diff --git a/src/rendering/character_renderer.cpp b/src/rendering/character_renderer.cpp index 56853abe..d95df084 100644 --- a/src/rendering/character_renderer.cpp +++ b/src/rendering/character_renderer.cpp @@ -1185,6 +1185,19 @@ bool CharacterRenderer::hasAnimation(uint32_t instanceId, uint32_t animationId) return false; } +bool CharacterRenderer::getInstanceModelName(uint32_t instanceId, std::string& modelName) const { + auto it = instances.find(instanceId); + if (it == instances.end()) { + return false; + } + auto modelIt = models.find(it->second.modelId); + if (modelIt == models.end()) { + return false; + } + modelName = modelIt->second.data.name; + return !modelName.empty(); +} + bool CharacterRenderer::attachWeapon(uint32_t charInstanceId, uint32_t attachmentId, const pipeline::M2Model& weaponModel, uint32_t weaponModelId, const std::string& texturePath) { diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index 58643ee7..c1beb872 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -28,6 +28,7 @@ #include "game/zone_manager.hpp" #include "audio/music_manager.hpp" #include "audio/footstep_manager.hpp" +#include "audio/activity_sound_manager.hpp" #include #include #include @@ -190,6 +191,7 @@ bool Renderer::initialize(core::Window* win) { // Create music manager (initialized later with asset manager) musicManager = std::make_unique(); footstepManager = std::make_unique(); + activitySoundManager = std::make_unique(); LOG_INFO("Renderer initialized"); return true; @@ -266,6 +268,10 @@ void Renderer::shutdown() { footstepManager->shutdown(); footstepManager.reset(); } + if (activitySoundManager) { + activitySoundManager->shutdown(); + activitySoundManager.reset(); + } zoneManager.reset(); @@ -647,13 +653,16 @@ void Renderer::update(float deltaTime) { // Sync character model position/rotation and animation with follow target if (characterInstanceId > 0 && characterRenderer && cameraController && cameraController->isThirdPerson()) { characterRenderer->setInstancePosition(characterInstanceId, characterPosition); + if (activitySoundManager) { + std::string modelName; + if (characterRenderer->getInstanceModelName(characterInstanceId, modelName)) { + activitySoundManager->setCharacterVoiceProfile(modelName); + } + } - // Keep facing decoupled from lateral movement: - // face camera when RMB is held, or with forward/back intent. - if (cameraController->isRightMouseHeld() || - cameraController->isMovingForward() || - cameraController->isMovingBackward()) { - characterYaw = cameraController->getYaw(); + // Movement-facing comes from camera controller and is decoupled from LMB orbit. + if (cameraController->isMoving() || cameraController->isRightMouseHeld()) { + characterYaw = cameraController->getFacingYaw(); } else if (targetPosition && !emoteActive && !cameraController->isMoving()) { // Face target when idle glm::vec3 toTarget = *targetPosition - characterPosition; @@ -737,6 +746,51 @@ void Renderer::update(float deltaTime) { } } + // Activity SFX: animation/state-driven jump, landing, and swim loops/splashes. + if (activitySoundManager) { + activitySoundManager->update(deltaTime); + if (cameraController && cameraController->isThirdPerson()) { + bool grounded = cameraController->isGrounded(); + bool jumping = cameraController->isJumping(); + bool falling = cameraController->isFalling(); + bool swimming = cameraController->isSwimming(); + bool moving = cameraController->isMoving(); + + if (!sfxStateInitialized) { + sfxPrevGrounded = grounded; + sfxPrevJumping = jumping; + sfxPrevFalling = falling; + sfxPrevSwimming = swimming; + sfxStateInitialized = true; + } + + if (jumping && !sfxPrevJumping && !swimming) { + activitySoundManager->playJump(); + } + + if (grounded && !sfxPrevGrounded) { + bool hardLanding = sfxPrevFalling; + activitySoundManager->playLanding(resolveFootstepSurface(), hardLanding); + } + + if (swimming && !sfxPrevSwimming) { + activitySoundManager->playWaterEnter(); + } else if (!swimming && sfxPrevSwimming) { + activitySoundManager->playWaterExit(); + } + + activitySoundManager->setSwimmingState(swimming, moving); + + sfxPrevGrounded = grounded; + sfxPrevJumping = jumping; + sfxPrevFalling = falling; + sfxPrevSwimming = swimming; + } else { + activitySoundManager->setSwimmingState(false, false); + sfxStateInitialized = false; + } + } + // Update M2 doodad animations if (m2Renderer) { m2Renderer->update(deltaTime); @@ -1011,6 +1065,9 @@ bool Renderer::loadTestTerrain(pipeline::AssetManager* assetManager, const std:: if (footstepManager) { footstepManager->initialize(assetManager); } + if (activitySoundManager) { + activitySoundManager->initialize(assetManager); + } cachedAssetManager = assetManager; } @@ -1079,6 +1136,11 @@ bool Renderer::loadTerrainArea(const std::string& mapName, int centerX, int cent footstepManager->initialize(cachedAssetManager); } } + if (activitySoundManager && cachedAssetManager) { + if (!activitySoundManager->isInitialized()) { + activitySoundManager->initialize(cachedAssetManager); + } + } // Wire WMO, M2, and water renderer to camera controller if (cameraController && wmoRenderer) {