diff --git a/CMakeLists.txt b/CMakeLists.txt index 6f3320e3..af6dad5a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -96,6 +96,7 @@ set(WOWEE_SOURCES # Audio src/audio/music_manager.cpp + src/audio/footstep_manager.cpp # Pipeline (asset loaders) src/pipeline/mpq_manager.cpp @@ -174,6 +175,7 @@ set(WOWEE_HEADERS include/game/inventory.hpp include/audio/music_manager.hpp + include/audio/footstep_manager.hpp include/pipeline/mpq_manager.hpp include/pipeline/blp_loader.hpp diff --git a/include/audio/footstep_manager.hpp b/include/audio/footstep_manager.hpp new file mode 100644 index 00000000..0c4e7321 --- /dev/null +++ b/include/audio/footstep_manager.hpp @@ -0,0 +1,65 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace wowee { +namespace pipeline { class AssetManager; } + +namespace audio { + +enum class FootstepSurface : uint8_t { + STONE = 0, + DIRT, + GRASS, + WOOD, + METAL, + WATER, + SNOW +}; + +class FootstepManager { +public: + FootstepManager(); + ~FootstepManager(); + + bool initialize(pipeline::AssetManager* assets); + void shutdown(); + + void update(float deltaTime); + void playFootstep(FootstepSurface surface, bool sprinting); + + bool isInitialized() const { return assetManager != nullptr; } + bool hasAnySamples() const { return sampleCount > 0; } + +private: + struct Sample { + std::string path; + std::vector data; + }; + + struct SurfaceSamples { + std::vector clips; + }; + + void preloadSurface(FootstepSurface surface, const std::vector& candidates); + void stopCurrentProcess(); + void reapFinishedProcess(); + bool playRandomStep(FootstepSurface surface, bool sprinting); + static const char* surfaceName(FootstepSurface surface); + + pipeline::AssetManager* assetManager = nullptr; + SurfaceSamples surfaces[7]; + size_t sampleCount = 0; + + std::string tempFilePath = "/tmp/wowee_footstep.wav"; + pid_t playerPid = -1; + + std::mt19937 rng; +}; + +} // namespace audio +} // namespace wowee diff --git a/include/rendering/camera_controller.hpp b/include/rendering/camera_controller.hpp index 1226ba05..749718a1 100644 --- a/include/rendering/camera_controller.hpp +++ b/include/rendering/camera_controller.hpp @@ -122,12 +122,16 @@ private: bool enabled = true; bool sitting = false; bool xKeyWasDown = false; + bool rKeyWasDown = false; + bool runPace = false; // Movement state tracking (for sending opcodes on state change) bool wasMovingForward = false; bool wasMovingBackward = false; bool wasStrafingLeft = false; bool wasStrafingRight = false; + bool wasTurningLeft = false; + bool wasTurningRight = false; bool wasJumping = false; bool wasFalling = false; @@ -136,10 +140,11 @@ private: // Movement speeds bool useWoWSpeed = false; - static constexpr float WOW_RUN_SPEED = 14.0f; // Normal run (WASD) - static constexpr float WOW_SPRINT_SPEED = 28.0f; // Sprint (hold Shift) - static constexpr float WOW_WALK_SPEED = 5.0f; // Walk (hold Ctrl) - static constexpr float WOW_BACK_SPEED = 9.0f; // Backpedal + static constexpr float WOW_RUN_SPEED = 7.0f; // Normal run (WotLK) + static constexpr float WOW_SPRINT_SPEED = 10.5f; // Optional fast mode (not default WoW behavior) + static constexpr float WOW_WALK_SPEED = 2.5f; // Walk + static constexpr float WOW_BACK_SPEED = 4.5f; // Backpedal + static constexpr float WOW_TURN_SPEED = 180.0f; // Keyboard turn deg/sec static constexpr float WOW_GRAVITY = -19.29f; static constexpr float WOW_JUMP_VELOCITY = 7.96f; diff --git a/include/rendering/character_renderer.hpp b/include/rendering/character_renderer.hpp index 72e91937..3457282f 100644 --- a/include/rendering/character_renderer.hpp +++ b/include/rendering/character_renderer.hpp @@ -63,6 +63,7 @@ public: void setActiveGeosets(uint32_t instanceId, const std::unordered_set& geosets); void setInstanceVisible(uint32_t instanceId, bool visible); void removeInstance(uint32_t instanceId); + bool getAnimationState(uint32_t instanceId, uint32_t& animationId, float& animationTimeMs, float& animationDurationMs) 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 25a9971c..04d957cf 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; } +namespace audio { class MusicManager; class FootstepManager; enum class FootstepSurface : uint8_t; } namespace pipeline { class AssetManager; } namespace rendering { @@ -142,6 +142,7 @@ private: std::unique_ptr m2Renderer; std::unique_ptr minimap; std::unique_ptr musicManager; + std::unique_ptr footstepManager; std::unique_ptr zoneManager; pipeline::AssetManager* cachedAssetManager = nullptr; @@ -157,6 +158,9 @@ private: enum class CharAnimState { IDLE, WALK, RUN, JUMP_START, JUMP_MID, JUMP_END, SIT_DOWN, SITTING, EMOTE, SWIM_IDLE, SWIM }; CharAnimState charAnimState = CharAnimState::IDLE; void updateCharacterAnimation(); + bool isFootstepAnimationState() const; + bool shouldTriggerFootstepEvent(uint32_t animationId, float animationTimeMs, float animationDurationMs); + audio::FootstepSurface resolveFootstepSurface() const; // Emote state bool emoteActive = false; @@ -166,6 +170,11 @@ private: // Target facing const glm::vec3* targetPosition = nullptr; + // Footstep event tracking (animation-driven) + uint32_t footstepLastAnimationId = 0; + float footstepLastNormTime = 0.0f; + bool footstepNormInitialized = false; + bool terrainEnabled = true; bool terrainLoaded = false; }; diff --git a/include/rendering/terrain_manager.hpp b/include/rendering/terrain_manager.hpp index 9254ba77..34777538 100644 --- a/include/rendering/terrain_manager.hpp +++ b/include/rendering/terrain_manager.hpp @@ -172,6 +172,12 @@ public: */ std::optional getHeightAt(float glX, float glY) const; + /** + * Get dominant terrain texture name at a GL position. + * Returns empty if terrain is not loaded at that position. + */ + std::optional getDominantTextureAt(float glX, float glY) const; + /** * Get statistics */ diff --git a/src/audio/footstep_manager.cpp b/src/audio/footstep_manager.cpp new file mode 100644 index 00000000..12d490aa --- /dev/null +++ b/src/audio/footstep_manager.cpp @@ -0,0 +1,204 @@ +#include "audio/footstep_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 buildClassicFootstepSet(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; +} + +std::vector buildAltFootstepSet(const std::string& folder, const std::string& stem) { + std::vector out; + for (int i = 1; i <= 8; ++i) { + char index[4]; + std::snprintf(index, sizeof(index), "%02d", i); + out.push_back("Sound\\Character\\Footsteps\\" + folder + "\\" + stem + "_" + index + ".wav"); + } + return out; +} + +} // namespace + +FootstepManager::FootstepManager() : rng(std::random_device{}()) {} + +FootstepManager::~FootstepManager() { + shutdown(); +} + +bool FootstepManager::initialize(pipeline::AssetManager* assets) { + assetManager = assets; + sampleCount = 0; + for (auto& surface : surfaces) { + surface.clips.clear(); + } + + if (!assetManager) { + return false; + } + + preloadSurface(FootstepSurface::STONE, buildClassicFootstepSet("Stone")); + preloadSurface(FootstepSurface::DIRT, buildClassicFootstepSet("Dirt")); + preloadSurface(FootstepSurface::GRASS, buildClassicFootstepSet("Grass")); + preloadSurface(FootstepSurface::WOOD, buildClassicFootstepSet("Wood")); + preloadSurface(FootstepSurface::SNOW, buildClassicFootstepSet("Snow")); + preloadSurface(FootstepSurface::WATER, buildClassicFootstepSet("Water")); + + // Alternate naming seen in some builds (especially metals). + preloadSurface(FootstepSurface::METAL, buildAltFootstepSet("MediumLargeMetalFootsteps", "MediumLargeFootstepMetal")); + if (surfaces[static_cast(FootstepSurface::METAL)].clips.empty()) { + preloadSurface(FootstepSurface::METAL, buildClassicFootstepSet("Metal")); + } + + LOG_INFO("Footstep manager initialized (", sampleCount, " clips)"); + return sampleCount > 0; +} + +void FootstepManager::shutdown() { + stopCurrentProcess(); + std::remove(tempFilePath.c_str()); + for (auto& surface : surfaces) { + surface.clips.clear(); + } + sampleCount = 0; + assetManager = nullptr; +} + +void FootstepManager::update(float) { + reapFinishedProcess(); +} + +void FootstepManager::playFootstep(FootstepSurface surface, bool sprinting) { + if (!assetManager || sampleCount == 0) { + return; + } + reapFinishedProcess(); + playRandomStep(surface, sprinting); +} + +void FootstepManager::preloadSurface(FootstepSurface surface, const std::vector& candidates) { + if (!assetManager) { + return; + } + + auto& list = surfaces[static_cast(surface)].clips; + for (const std::string& path : candidates) { + if (!assetManager->fileExists(path)) { + continue; + } + auto data = assetManager->readFile(path); + if (data.empty()) { + continue; + } + list.push_back({path, std::move(data)}); + sampleCount++; + } + + if (!list.empty()) { + LOG_INFO("Footsteps ", surfaceName(surface), ": loaded ", list.size(), " clips"); + } +} + +void FootstepManager::stopCurrentProcess() { + if (playerPid > 0) { + kill(-playerPid, SIGTERM); + kill(playerPid, SIGTERM); + int status = 0; + waitpid(playerPid, &status, 0); + playerPid = -1; + } +} + +void FootstepManager::reapFinishedProcess() { + if (playerPid <= 0) { + return; + } + int status = 0; + pid_t result = waitpid(playerPid, &status, WNOHANG); + if (result == playerPid) { + playerPid = -1; + } +} + +bool FootstepManager::playRandomStep(FootstepSurface surface, bool sprinting) { + auto& list = surfaces[static_cast(surface)].clips; + if (list.empty()) { + list = surfaces[static_cast(FootstepSurface::STONE)].clips; + if (list.empty()) { + return false; + } + } + + // Keep one active step at a time to avoid ffplay process buildup. + if (playerPid > 0) { + return false; + } + + std::uniform_int_distribution clipDist(0, list.size() - 1); + const Sample& sample = list[clipDist(rng)]; + + std::ofstream out(tempFilePath, std::ios::binary); + if (!out) { + return false; + } + out.write(reinterpret_cast(sample.data.data()), static_cast(sample.data.size())); + out.close(); + + // Subtle variation for less repetitive cadence. + std::uniform_real_distribution pitchDist(0.97f, 1.05f); + std::uniform_real_distribution volumeDist(0.92f, 1.00f); + float pitch = pitchDist(rng); + float volume = volumeDist(rng) * (sprinting ? 1.0f : 0.88f); + if (volume > 1.0f) volume = 1.0f; + if (volume < 0.1f) volume = 0.1f; + + 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(), tempFilePath.c_str(), nullptr); + _exit(1); + } else if (pid > 0) { + playerPid = pid; + return true; + } + + return false; +} + +const char* FootstepManager::surfaceName(FootstepSurface surface) { + switch (surface) { + case FootstepSurface::STONE: return "stone"; + case FootstepSurface::DIRT: return "dirt"; + case FootstepSurface::GRASS: return "grass"; + case FootstepSurface::WOOD: return "wood"; + case FootstepSurface::METAL: return "metal"; + case FootstepSurface::WATER: return "water"; + case FootstepSurface::SNOW: return "snow"; + default: return "unknown"; + } +} + +} // namespace audio +} // namespace wowee diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index ab6ceda0..48f5f9b0 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -12,6 +12,27 @@ namespace wowee { namespace rendering { +namespace { + +std::optional selectReachableFloor(const std::optional& terrainH, + const std::optional& wmoH, + float refZ, + float maxStepUp) { + std::optional best; + auto consider = [&](const std::optional& h) { + if (!h) return; + if (*h > refZ + maxStepUp) return; // Ignore roofs/floors too far above us. + if (!best || *h > *best) { + best = *h; // Choose highest reachable floor. + } + }; + consider(terrainH); + consider(wmoH); + return best; +} + +} // namespace + CameraController::CameraController(Camera* cam) : camera(cam) { yaw = defaultYaw; pitch = defaultPitch; @@ -29,12 +50,47 @@ void CameraController::update(float deltaTime) { bool uiWantsKeyboard = ImGui::GetIO().WantCaptureKeyboard; // Determine current key states - bool nowForward = !uiWantsKeyboard && !sitting && input.isKeyPressed(SDL_SCANCODE_W); - bool nowBackward = !uiWantsKeyboard && !sitting && input.isKeyPressed(SDL_SCANCODE_S); - bool nowStrafeLeft = !uiWantsKeyboard && !sitting && input.isKeyPressed(SDL_SCANCODE_A); - bool nowStrafeRight = !uiWantsKeyboard && !sitting && input.isKeyPressed(SDL_SCANCODE_D); + bool keyW = !uiWantsKeyboard && !sitting && input.isKeyPressed(SDL_SCANCODE_W); + bool keyS = !uiWantsKeyboard && !sitting && input.isKeyPressed(SDL_SCANCODE_S); + bool keyA = !uiWantsKeyboard && !sitting && input.isKeyPressed(SDL_SCANCODE_A); + bool keyD = !uiWantsKeyboard && !sitting && input.isKeyPressed(SDL_SCANCODE_D); + bool keyQ = !uiWantsKeyboard && !sitting && input.isKeyPressed(SDL_SCANCODE_Q); + bool keyE = !uiWantsKeyboard && !sitting && input.isKeyPressed(SDL_SCANCODE_E); + bool shiftDown = !uiWantsKeyboard && (input.isKeyPressed(SDL_SCANCODE_LSHIFT) || input.isKeyPressed(SDL_SCANCODE_RSHIFT)); + bool ctrlDown = !uiWantsKeyboard && (input.isKeyPressed(SDL_SCANCODE_LCTRL) || input.isKeyPressed(SDL_SCANCODE_RCTRL)); bool nowJump = !uiWantsKeyboard && !sitting && input.isKeyPressed(SDL_SCANCODE_SPACE); + bool mouseAutorun = !uiWantsKeyboard && !sitting && leftMouseDown && rightMouseDown; + bool nowForward = keyW || mouseAutorun; + bool nowBackward = keyS; + bool nowStrafeLeft = false; + bool nowStrafeRight = false; + bool nowTurnLeft = false; + bool nowTurnRight = false; + + // WoW-like third-person keyboard behavior: + // - RMB held: A/D strafe + // - RMB released: A/D turn character+camera, Q/E strafe + if (thirdPerson && !rightMouseDown) { + nowTurnLeft = keyA; + nowTurnRight = keyD; + nowStrafeLeft = keyQ; + nowStrafeRight = keyE; + } else { + nowStrafeLeft = keyA || keyQ; + nowStrafeRight = keyD || keyE; + } + + // Keyboard turning updates camera yaw (character follows yaw in renderer) + if (nowTurnLeft && !nowTurnRight) { + yaw += WOW_TURN_SPEED * deltaTime; + } else if (nowTurnRight && !nowTurnLeft) { + yaw -= WOW_TURN_SPEED * deltaTime; + } + if (nowTurnLeft || nowTurnRight) { + camera->setRotation(yaw, pitch); + } + // Select physics constants based on mode float gravity = useWoWSpeed ? WOW_GRAVITY : GRAVITY; float jumpVel = useWoWSpeed ? WOW_JUMP_VELOCITY : JUMP_VELOCITY; @@ -42,27 +98,34 @@ void CameraController::update(float deltaTime) { // Calculate movement speed based on direction and modifiers float speed; if (useWoWSpeed) { - // Movement speeds (Shift = sprint, Ctrl = walk) + // Movement speeds (WoW-like: Ctrl walk, default run, backpedal slower) if (nowBackward && !nowForward) { speed = WOW_BACK_SPEED; - } else if (!uiWantsKeyboard && (input.isKeyPressed(SDL_SCANCODE_LSHIFT) || input.isKeyPressed(SDL_SCANCODE_RSHIFT))) { - speed = WOW_SPRINT_SPEED; // Shift = sprint (faster) - } else if (!uiWantsKeyboard && (input.isKeyPressed(SDL_SCANCODE_LCTRL) || input.isKeyPressed(SDL_SCANCODE_RCTRL))) { - speed = WOW_WALK_SPEED; // Ctrl = walk (slower) + } else if (ctrlDown) { + speed = WOW_WALK_SPEED; } else { - speed = WOW_RUN_SPEED; // Normal run + speed = WOW_RUN_SPEED; } } else { // Exploration mode (original behavior) speed = movementSpeed; - if (!uiWantsKeyboard && (input.isKeyPressed(SDL_SCANCODE_LSHIFT) || input.isKeyPressed(SDL_SCANCODE_RSHIFT))) { + if (shiftDown) { speed *= sprintMultiplier; } - if (!uiWantsKeyboard && (input.isKeyPressed(SDL_SCANCODE_LCTRL) || input.isKeyPressed(SDL_SCANCODE_RCTRL))) { + if (ctrlDown) { speed *= slowMultiplier; } } + bool hasMoveInput = nowForward || nowBackward || nowStrafeLeft || nowStrafeRight; + if (useWoWSpeed) { + // "Sprinting" flag drives run animation/stronger footstep set. + // In WoW mode this means running pace (not walk/backpedal), not Shift. + runPace = hasMoveInput && !ctrlDown && !nowBackward; + } else { + runPace = hasMoveInput && shiftDown; + } + // 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)); @@ -189,17 +252,15 @@ void CameraController::update(float deltaTime) { // Helper to get ground height at a position auto getGroundAt = [&](float x, float y) -> std::optional { - std::optional h; + std::optional terrainH; + std::optional wmoH; if (terrainManager) { - h = terrainManager->getHeightAt(x, y); + terrainH = terrainManager->getHeightAt(x, y); } if (wmoRenderer) { - auto wh = wmoRenderer->getFloorHeight(x, y, targetPos.z + 5.0f); - if (wh && (!h || *wh > *h)) { - h = wh; - } + wmoH = wmoRenderer->getFloorHeight(x, y, targetPos.z + 5.0f); } - return h; + return selectReachableFloor(terrainH, wmoH, targetPos.z, 1.0f); }; // Get ground height at target position @@ -274,35 +335,17 @@ void CameraController::update(float deltaTime) { wmoH = wmoRenderer->getFloorHeight(targetPos.x, targetPos.y, targetPos.z + eyeHeight); } - std::optional groundH; - if (terrainH && wmoH) { - groundH = std::max(*terrainH, *wmoH); - } else if (terrainH) { - groundH = terrainH; - } else if (wmoH) { - groundH = wmoH; - } + std::optional groundH = selectReachableFloor(terrainH, wmoH, targetPos.z, 1.0f); if (groundH) { float groundDiff = *groundH - lastGroundZ; - float currentFeetZ = targetPos.z; - - // Only consider floors that are: - // 1. Below us (we can fall onto them) - // 2. Slightly above us (we can step up onto them, max 1 unit) - // Don't teleport to roofs/floors that are way above us - bool floorIsReachable = (*groundH <= currentFeetZ + 1.0f); - - if (floorIsReachable) { - if (std::abs(groundDiff) < 2.0f) { - // Small height difference - smooth it - lastGroundZ += groundDiff * std::min(1.0f, deltaTime * 15.0f); - } else { - // Large height difference - snap (for falling onto ledges) - lastGroundZ = *groundH; - } + if (std::abs(groundDiff) < 2.0f) { + // Small height difference - smooth it + lastGroundZ += groundDiff * std::min(1.0f, deltaTime * 15.0f); + } else { + // Large height difference - snap (for falling onto ledges) + lastGroundZ = *groundH; } - // If floor is way above us (roof), ignore it and keep lastGroundZ if (targetPos.z <= lastGroundZ + 0.1f) { targetPos.z = lastGroundZ; @@ -334,26 +377,21 @@ void CameraController::update(float deltaTime) { float zoomLerp = 1.0f - std::exp(-ZOOM_SMOOTH_SPEED * deltaTime); currentDistance += (userTargetDistance - currentDistance) * zoomLerp; - // Desired camera position (before collision) - glm::vec3 desiredCam = pivot + camDir * currentDistance; - // ===== Camera collision (sphere sweep approximation) ===== // Find max safe distance using raycast + sphere radius collisionDistance = currentDistance; // Helper to get floor height auto getFloorAt = [&](float x, float y, float z) -> std::optional { - std::optional h; + std::optional terrainH; + std::optional wmoH; if (terrainManager) { - h = terrainManager->getHeightAt(x, y); + terrainH = terrainManager->getHeightAt(x, y); } if (wmoRenderer) { - auto wh = wmoRenderer->getFloorHeight(x, y, z + 5.0f); - if (wh && (!h || *wh > *h)) { - h = wh; - } + wmoH = wmoRenderer->getFloorHeight(x, y, z + 5.0f); } - return h; + return selectReachableFloor(terrainH, wmoH, z, 0.5f); }; // Raycast against WMO bounding boxes @@ -409,7 +447,7 @@ void CameraController::update(float deltaTime) { // After smoothing, ensure camera is above the floor at its final position // This prevents camera clipping through ground in Stormwind and similar areas constexpr float MIN_FLOOR_CLEARANCE = 0.20f; // Keep camera at least 20cm above floor - auto finalFloorH = getFloorAt(smoothedCamPos.x, smoothedCamPos.y, smoothedCamPos.z + 5.0f); + auto finalFloorH = getFloorAt(smoothedCamPos.x, smoothedCamPos.y, smoothedCamPos.z); if (finalFloorH && smoothedCamPos.z < *finalFloorH + MIN_FLOOR_CLEARANCE) { smoothedCamPos.z = *finalFloorH + MIN_FLOOR_CLEARANCE; } @@ -506,14 +544,7 @@ void CameraController::update(float deltaTime) { wmoH = wmoRenderer->getFloorHeight(newPos.x, newPos.y, newPos.z); } - std::optional groundH; - if (terrainH && wmoH) { - groundH = std::max(*terrainH, *wmoH); - } else if (terrainH) { - groundH = terrainH; - } else if (wmoH) { - groundH = wmoH; - } + std::optional groundH = selectReachableFloor(terrainH, wmoH, newPos.z - eyeHeight, 1.0f); if (groundH) { lastGroundZ = *groundH; @@ -565,6 +596,19 @@ void CameraController::update(float deltaTime) { } } + // Turning + if (nowTurnLeft && !wasTurningLeft) { + movementCallback(static_cast(game::Opcode::CMSG_MOVE_START_TURN_LEFT)); + } + if (nowTurnRight && !wasTurningRight) { + movementCallback(static_cast(game::Opcode::CMSG_MOVE_START_TURN_RIGHT)); + } + if ((!nowTurnLeft && wasTurningLeft) || (!nowTurnRight && wasTurningRight)) { + if (!nowTurnLeft && !nowTurnRight) { + movementCallback(static_cast(game::Opcode::CMSG_MOVE_STOP_TURN)); + } + } + // Jump if (nowJump && !wasJumping && grounded) { movementCallback(static_cast(game::Opcode::CMSG_MOVE_JUMP)); @@ -591,13 +635,17 @@ void CameraController::update(float deltaTime) { wasMovingBackward = nowBackward; wasStrafingLeft = nowStrafeLeft; wasStrafingRight = nowStrafeRight; + wasTurningLeft = nowTurnLeft; + wasTurningRight = nowTurnRight; wasJumping = nowJump; wasFalling = !grounded && verticalVelocity <= 0.0f; - // Reset camera (R key) - if (!uiWantsKeyboard && input.isKeyPressed(SDL_SCANCODE_R)) { + // Reset camera/character (R key, edge-triggered) + bool rDown = !uiWantsKeyboard && input.isKeyPressed(SDL_SCANCODE_R); + if (rDown && !rKeyWasDown) { reset(); } + rKeyWasDown = rDown; } void CameraController::processMouseMotion(const SDL_MouseMotionEvent& event) { @@ -648,7 +696,20 @@ void CameraController::reset() { yaw = defaultYaw; pitch = defaultPitch; verticalVelocity = 0.0f; - grounded = false; + grounded = true; + swimming = false; + sitting = false; + + // Clear edge-state so movement packets can re-start cleanly after respawn. + wasMovingForward = false; + wasMovingBackward = false; + wasStrafingLeft = false; + wasStrafingRight = false; + wasTurningLeft = false; + wasTurningRight = false; + wasJumping = false; + wasFalling = false; + wasSwimming = false; glm::vec3 spawnPos = defaultPosition; @@ -665,11 +726,32 @@ void CameraController::reset() { } if (h) { lastGroundZ = *h; - spawnPos.z = *h + eyeHeight; + spawnPos.z = *h; } - camera->setPosition(spawnPos); camera->setRotation(yaw, pitch); + glm::vec3 forward3D = camera->getForward(); + + if (thirdPerson && followTarget) { + // In follow mode, respawn the character (feet position), then place camera behind it. + *followTarget = spawnPos; + + currentDistance = userTargetDistance; + collisionDistance = currentDistance; + + glm::vec3 pivot = spawnPos + glm::vec3(0.0f, 0.0f, PIVOT_HEIGHT); + glm::vec3 camDir = -forward3D; + glm::vec3 camPos = pivot + camDir * currentDistance; + smoothedCamPos = camPos; + camera->setPosition(camPos); + } else { + // Free-fly mode keeps camera eye-height above ground. + if (h) { + spawnPos.z += eyeHeight; + } + smoothedCamPos = spawnPos; + camera->setPosition(spawnPos); + } LOG_INFO("Camera reset to default position"); } @@ -701,22 +783,24 @@ bool CameraController::isMoving() const { } auto& input = core::Input::getInstance(); + bool keyW = input.isKeyPressed(SDL_SCANCODE_W); + bool keyS = input.isKeyPressed(SDL_SCANCODE_S); + bool keyA = input.isKeyPressed(SDL_SCANCODE_A); + bool keyD = input.isKeyPressed(SDL_SCANCODE_D); + bool keyQ = input.isKeyPressed(SDL_SCANCODE_Q); + bool keyE = input.isKeyPressed(SDL_SCANCODE_E); - return input.isKeyPressed(SDL_SCANCODE_W) || - input.isKeyPressed(SDL_SCANCODE_S) || - input.isKeyPressed(SDL_SCANCODE_A) || - input.isKeyPressed(SDL_SCANCODE_D); + // In third-person without RMB, A/D are turn keys (not movement). + if (thirdPerson && !rightMouseDown) { + return keyW || keyS || keyQ || keyE; + } + + bool mouseAutorun = leftMouseDown && rightMouseDown; + return keyW || keyS || keyA || keyD || keyQ || keyE || mouseAutorun; } bool CameraController::isSprinting() const { - if (!enabled || !camera) { - return false; - } - if (ImGui::GetIO().WantCaptureKeyboard) { - return false; - } - auto& input = core::Input::getInstance(); - return isMoving() && (input.isKeyPressed(SDL_SCANCODE_LSHIFT) || input.isKeyPressed(SDL_SCANCODE_RSHIFT)); + return enabled && camera && runPace; } } // namespace rendering diff --git a/src/rendering/character_renderer.cpp b/src/rendering/character_renderer.cpp index 94a14239..1f7c1988 100644 --- a/src/rendering/character_renderer.cpp +++ b/src/rendering/character_renderer.cpp @@ -1141,6 +1141,30 @@ void CharacterRenderer::removeInstance(uint32_t instanceId) { instances.erase(instanceId); } +bool CharacterRenderer::getAnimationState(uint32_t instanceId, uint32_t& animationId, + float& animationTimeMs, float& animationDurationMs) const { + auto it = instances.find(instanceId); + if (it == instances.end()) { + return false; + } + + const CharacterInstance& instance = it->second; + auto modelIt = models.find(instance.modelId); + if (modelIt == models.end()) { + return false; + } + + const auto& sequences = modelIt->second.data.sequences; + if (instance.currentSequenceIndex < 0 || instance.currentSequenceIndex >= static_cast(sequences.size())) { + return false; + } + + animationId = instance.currentAnimationId; + animationTimeMs = instance.animationTime; + animationDurationMs = static_cast(sequences[instance.currentSequenceIndex].duration); + return true; +} + 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 ac03ef30..31561105 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -27,11 +27,14 @@ #include "game/world.hpp" #include "game/zone_manager.hpp" #include "audio/music_manager.hpp" +#include "audio/footstep_manager.hpp" #include #include #include #include #include +#include +#include #include #include @@ -185,6 +188,7 @@ bool Renderer::initialize(core::Window* win) { // Create music manager (initialized later with asset manager) musicManager = std::make_unique(); + footstepManager = std::make_unique(); LOG_INFO("Renderer initialized"); return true; @@ -257,6 +261,10 @@ void Renderer::shutdown() { musicManager->shutdown(); musicManager.reset(); } + if (footstepManager) { + footstepManager->shutdown(); + footstepManager.reset(); + } zoneManager.reset(); @@ -492,6 +500,87 @@ bool Renderer::isMoving() const { return cameraController && cameraController->isMoving(); } +bool Renderer::isFootstepAnimationState() const { + return charAnimState == CharAnimState::WALK || charAnimState == CharAnimState::RUN; +} + +bool Renderer::shouldTriggerFootstepEvent(uint32_t animationId, float animationTimeMs, float animationDurationMs) { + if (animationDurationMs <= 1.0f) { + footstepNormInitialized = false; + return false; + } + + float norm = std::fmod(animationTimeMs, animationDurationMs) / animationDurationMs; + if (norm < 0.0f) norm += 1.0f; + + if (animationId != footstepLastAnimationId) { + footstepLastAnimationId = animationId; + footstepLastNormTime = norm; + footstepNormInitialized = true; + return false; + } + + if (!footstepNormInitialized) { + footstepNormInitialized = true; + footstepLastNormTime = norm; + return false; + } + + auto crossed = [&](float eventNorm) { + if (footstepLastNormTime <= norm) { + return footstepLastNormTime < eventNorm && eventNorm <= norm; + } + return footstepLastNormTime < eventNorm || eventNorm <= norm; + }; + + bool trigger = crossed(0.22f) || crossed(0.72f); + footstepLastNormTime = norm; + return trigger; +} + +audio::FootstepSurface Renderer::resolveFootstepSurface() const { + if (!cameraController || !cameraController->isThirdPerson()) { + return audio::FootstepSurface::STONE; + } + + const glm::vec3& p = characterPosition; + + if (cameraController->isSwimming()) { + return audio::FootstepSurface::WATER; + } + + if (waterRenderer) { + auto waterH = waterRenderer->getWaterHeightAt(p.x, p.y); + if (waterH && p.z < (*waterH + 0.25f)) { + return audio::FootstepSurface::WATER; + } + } + + if (wmoRenderer) { + auto wmoFloor = wmoRenderer->getFloorHeight(p.x, p.y, p.z + 1.5f); + auto terrainFloor = terrainManager ? terrainManager->getHeightAt(p.x, p.y) : std::nullopt; + if (wmoFloor && (!terrainFloor || *wmoFloor >= *terrainFloor - 0.1f)) { + return audio::FootstepSurface::STONE; + } + } + + if (terrainManager) { + auto texture = terrainManager->getDominantTextureAt(p.x, p.y); + if (texture) { + std::string t = *texture; + for (char& c : t) c = static_cast(std::tolower(static_cast(c))); + if (t.find("snow") != std::string::npos || t.find("ice") != std::string::npos) return audio::FootstepSurface::SNOW; + if (t.find("grass") != std::string::npos || t.find("moss") != std::string::npos || t.find("leaf") != std::string::npos) return audio::FootstepSurface::GRASS; + if (t.find("sand") != std::string::npos || t.find("dirt") != std::string::npos || t.find("mud") != std::string::npos) return audio::FootstepSurface::DIRT; + if (t.find("wood") != std::string::npos || t.find("timber") != std::string::npos) return audio::FootstepSurface::WOOD; + if (t.find("metal") != std::string::npos || t.find("iron") != std::string::npos) return audio::FootstepSurface::METAL; + if (t.find("stone") != std::string::npos || t.find("rock") != std::string::npos || t.find("cobble") != std::string::npos || t.find("brick") != std::string::npos) return audio::FootstepSurface::STONE; + } + } + + return audio::FootstepSurface::STONE; +} + void Renderer::update(float deltaTime) { if (cameraController) { cameraController->update(deltaTime); @@ -569,6 +658,25 @@ void Renderer::update(float deltaTime) { characterRenderer->update(deltaTime); } + // Footsteps: animation-event driven + surface query at event time. + if (footstepManager) { + footstepManager->update(deltaTime); + if (characterRenderer && characterInstanceId > 0 && + cameraController && cameraController->isThirdPerson() && + isFootstepAnimationState() && cameraController->isGrounded() && + !cameraController->isSwimming()) { + uint32_t animId = 0; + float animTimeMs = 0.0f; + float animDurationMs = 0.0f; + if (characterRenderer->getAnimationState(characterInstanceId, animId, animTimeMs, animDurationMs) && + shouldTriggerFootstepEvent(animId, animTimeMs, animDurationMs)) { + footstepManager->playFootstep(resolveFootstepSurface(), cameraController->isSprinting()); + } + } else { + footstepNormInitialized = false; + } + } + // Update M2 doodad animations if (m2Renderer) { m2Renderer->update(deltaTime); @@ -820,6 +928,9 @@ bool Renderer::loadTestTerrain(pipeline::AssetManager* assetManager, const std:: // Initialize music manager with asset manager if (musicManager && assetManager && !cachedAssetManager) { musicManager->initialize(assetManager); + if (footstepManager) { + footstepManager->initialize(assetManager); + } cachedAssetManager = assetManager; } @@ -883,6 +994,11 @@ bool Renderer::loadTerrainArea(const std::string& mapName, int centerX, int cent musicManager->initialize(cachedAssetManager); } } + if (footstepManager && cachedAssetManager) { + if (!footstepManager->isInitialized()) { + footstepManager->initialize(cachedAssetManager); + } + } // Wire WMO, M2, and water renderer to camera controller if (cameraController && wmoRenderer) { diff --git a/src/rendering/terrain_manager.cpp b/src/rendering/terrain_manager.cpp index aba9430e..dc44f3ec 100644 --- a/src/rendering/terrain_manager.cpp +++ b/src/rendering/terrain_manager.cpp @@ -21,6 +21,66 @@ namespace wowee { namespace rendering { +namespace { + +bool decodeLayerAlpha(const pipeline::MapChunk& chunk, size_t layerIdx, std::vector& outAlpha) { + if (layerIdx >= chunk.layers.size()) return false; + const auto& layer = chunk.layers[layerIdx]; + if (!layer.useAlpha() || layer.offsetMCAL >= chunk.alphaMap.size()) return false; + + size_t offset = layer.offsetMCAL; + size_t layerSize = chunk.alphaMap.size() - offset; + for (size_t j = layerIdx + 1; j < chunk.layers.size(); j++) { + if (chunk.layers[j].useAlpha()) { + layerSize = chunk.layers[j].offsetMCAL - offset; + break; + } + } + + outAlpha.assign(4096, 255); + + if (layer.compressedAlpha()) { + size_t readPos = offset; + size_t writePos = 0; + while (writePos < 4096 && readPos < chunk.alphaMap.size()) { + uint8_t cmd = chunk.alphaMap[readPos++]; + bool fill = (cmd & 0x80) != 0; + int count = (cmd & 0x7F) + 1; + + if (fill) { + if (readPos >= chunk.alphaMap.size()) break; + uint8_t val = chunk.alphaMap[readPos++]; + for (int i = 0; i < count && writePos < 4096; i++) { + outAlpha[writePos++] = val; + } + } else { + for (int i = 0; i < count && writePos < 4096 && readPos < chunk.alphaMap.size(); i++) { + outAlpha[writePos++] = chunk.alphaMap[readPos++]; + } + } + } + return true; + } + + if (layerSize >= 4096) { + std::copy(chunk.alphaMap.begin() + offset, chunk.alphaMap.begin() + offset + 4096, outAlpha.begin()); + return true; + } + + if (layerSize >= 2048) { + for (size_t i = 0; i < 2048; i++) { + uint8_t v = chunk.alphaMap[offset + i]; + outAlpha[i * 2] = (v & 0x0F) * 17; + outAlpha[i * 2 + 1] = (v >> 4) * 17; + } + return true; + } + + return false; +} + +} // namespace + TerrainManager::TerrainManager() { } @@ -807,6 +867,69 @@ std::optional TerrainManager::getHeightAt(float glX, float glY) const { return std::nullopt; } +std::optional TerrainManager::getDominantTextureAt(float glX, float glY) const { + const float unitSize = CHUNK_SIZE / 8.0f; + std::vector alphaScratch; + + for (const auto& [coord, tile] : loadedTiles) { + (void)coord; + if (!tile || !tile->loaded) continue; + + for (int cy = 0; cy < 16; cy++) { + for (int cx = 0; cx < 16; cx++) { + const auto& chunk = tile->terrain.getChunk(cx, cy); + if (!chunk.hasHeightMap() || chunk.layers.empty()) continue; + + float chunkMaxX = chunk.position[0]; + float chunkMinX = chunk.position[0] - 8.0f * unitSize; + float chunkMaxY = chunk.position[1]; + float chunkMinY = chunk.position[1] - 8.0f * unitSize; + if (glX < chunkMinX || glX > chunkMaxX || glY < chunkMinY || glY > chunkMaxY) { + continue; + } + + float fracY = (chunk.position[0] - glX) / unitSize; + float fracX = (chunk.position[1] - glY) / unitSize; + fracX = glm::clamp(fracX, 0.0f, 8.0f); + fracY = glm::clamp(fracY, 0.0f, 8.0f); + + int alphaX = glm::clamp(static_cast((fracX / 8.0f) * 63.0f), 0, 63); + int alphaY = glm::clamp(static_cast((fracY / 8.0f) * 63.0f), 0, 63); + int alphaIndex = alphaY * 64 + alphaX; + + std::vector weights(chunk.layers.size(), 0); + int accum = 0; + for (size_t layerIdx = 1; layerIdx < chunk.layers.size(); layerIdx++) { + int alpha = 0; + if (decodeLayerAlpha(chunk, layerIdx, alphaScratch) && alphaIndex < static_cast(alphaScratch.size())) { + alpha = alphaScratch[alphaIndex]; + } + weights[layerIdx] = alpha; + accum += alpha; + } + weights[0] = glm::clamp(255 - accum, 0, 255); + + size_t bestLayer = 0; + int bestWeight = weights[0]; + for (size_t i = 1; i < weights.size(); i++) { + if (weights[i] > bestWeight) { + bestWeight = weights[i]; + bestLayer = i; + } + } + + uint32_t texId = chunk.layers[bestLayer].textureId; + if (texId < tile->terrain.textures.size()) { + return tile->terrain.textures[texId]; + } + return std::nullopt; + } + } + } + + return std::nullopt; +} + void TerrainManager::streamTiles() { // Enqueue tiles in radius around current tile for async loading {