Add WoW-style footsteps and improve third-person movement/collision

This commit is contained in:
Kelsi 2026-02-03 14:55:32 -08:00
parent 54dc27c2ec
commit dfb1f3cfdc
11 changed files with 724 additions and 85 deletions

View file

@ -96,6 +96,7 @@ set(WOWEE_SOURCES
# Audio # Audio
src/audio/music_manager.cpp src/audio/music_manager.cpp
src/audio/footstep_manager.cpp
# Pipeline (asset loaders) # Pipeline (asset loaders)
src/pipeline/mpq_manager.cpp src/pipeline/mpq_manager.cpp
@ -174,6 +175,7 @@ set(WOWEE_HEADERS
include/game/inventory.hpp include/game/inventory.hpp
include/audio/music_manager.hpp include/audio/music_manager.hpp
include/audio/footstep_manager.hpp
include/pipeline/mpq_manager.hpp include/pipeline/mpq_manager.hpp
include/pipeline/blp_loader.hpp include/pipeline/blp_loader.hpp

View file

@ -0,0 +1,65 @@
#pragma once
#include <cstdint>
#include <random>
#include <string>
#include <vector>
#include <sys/types.h>
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<uint8_t> data;
};
struct SurfaceSamples {
std::vector<Sample> clips;
};
void preloadSurface(FootstepSurface surface, const std::vector<std::string>& 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

View file

@ -122,12 +122,16 @@ private:
bool enabled = true; bool enabled = true;
bool sitting = false; bool sitting = false;
bool xKeyWasDown = false; bool xKeyWasDown = false;
bool rKeyWasDown = false;
bool runPace = false;
// Movement state tracking (for sending opcodes on state change) // Movement state tracking (for sending opcodes on state change)
bool wasMovingForward = false; bool wasMovingForward = false;
bool wasMovingBackward = false; bool wasMovingBackward = false;
bool wasStrafingLeft = false; bool wasStrafingLeft = false;
bool wasStrafingRight = false; bool wasStrafingRight = false;
bool wasTurningLeft = false;
bool wasTurningRight = false;
bool wasJumping = false; bool wasJumping = false;
bool wasFalling = false; bool wasFalling = false;
@ -136,10 +140,11 @@ private:
// Movement speeds // Movement speeds
bool useWoWSpeed = false; bool useWoWSpeed = false;
static constexpr float WOW_RUN_SPEED = 14.0f; // Normal run (WASD) static constexpr float WOW_RUN_SPEED = 7.0f; // Normal run (WotLK)
static constexpr float WOW_SPRINT_SPEED = 28.0f; // Sprint (hold Shift) static constexpr float WOW_SPRINT_SPEED = 10.5f; // Optional fast mode (not default WoW behavior)
static constexpr float WOW_WALK_SPEED = 5.0f; // Walk (hold Ctrl) static constexpr float WOW_WALK_SPEED = 2.5f; // Walk
static constexpr float WOW_BACK_SPEED = 9.0f; // Backpedal 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_GRAVITY = -19.29f;
static constexpr float WOW_JUMP_VELOCITY = 7.96f; static constexpr float WOW_JUMP_VELOCITY = 7.96f;

View file

@ -63,6 +63,7 @@ public:
void setActiveGeosets(uint32_t instanceId, const std::unordered_set<uint16_t>& geosets); void setActiveGeosets(uint32_t instanceId, const std::unordered_set<uint16_t>& geosets);
void setInstanceVisible(uint32_t instanceId, bool visible); void setInstanceVisible(uint32_t instanceId, bool visible);
void removeInstance(uint32_t instanceId); 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. */ /** Attach a weapon model to a character instance at the given attachment point. */
bool attachWeapon(uint32_t charInstanceId, uint32_t attachmentId, bool attachWeapon(uint32_t charInstanceId, uint32_t attachmentId,

View file

@ -8,7 +8,7 @@
namespace wowee { namespace wowee {
namespace core { class Window; } namespace core { class Window; }
namespace game { class World; class ZoneManager; } 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 pipeline { class AssetManager; }
namespace rendering { namespace rendering {
@ -142,6 +142,7 @@ private:
std::unique_ptr<M2Renderer> m2Renderer; std::unique_ptr<M2Renderer> m2Renderer;
std::unique_ptr<Minimap> minimap; std::unique_ptr<Minimap> minimap;
std::unique_ptr<audio::MusicManager> musicManager; std::unique_ptr<audio::MusicManager> musicManager;
std::unique_ptr<audio::FootstepManager> footstepManager;
std::unique_ptr<game::ZoneManager> zoneManager; std::unique_ptr<game::ZoneManager> zoneManager;
pipeline::AssetManager* cachedAssetManager = nullptr; 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 }; enum class CharAnimState { IDLE, WALK, RUN, JUMP_START, JUMP_MID, JUMP_END, SIT_DOWN, SITTING, EMOTE, SWIM_IDLE, SWIM };
CharAnimState charAnimState = CharAnimState::IDLE; CharAnimState charAnimState = CharAnimState::IDLE;
void updateCharacterAnimation(); void updateCharacterAnimation();
bool isFootstepAnimationState() const;
bool shouldTriggerFootstepEvent(uint32_t animationId, float animationTimeMs, float animationDurationMs);
audio::FootstepSurface resolveFootstepSurface() const;
// Emote state // Emote state
bool emoteActive = false; bool emoteActive = false;
@ -166,6 +170,11 @@ private:
// Target facing // Target facing
const glm::vec3* targetPosition = nullptr; 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 terrainEnabled = true;
bool terrainLoaded = false; bool terrainLoaded = false;
}; };

View file

@ -172,6 +172,12 @@ public:
*/ */
std::optional<float> getHeightAt(float glX, float glY) const; std::optional<float> 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<std::string> getDominantTextureAt(float glX, float glY) const;
/** /**
* Get statistics * Get statistics
*/ */

View file

@ -0,0 +1,204 @@
#include "audio/footstep_manager.hpp"
#include "pipeline/asset_manager.hpp"
#include "core/logger.hpp"
#include <algorithm>
#include <csignal>
#include <cstdio>
#include <fstream>
#include <string>
#include <sys/wait.h>
#include <unistd.h>
namespace wowee {
namespace audio {
namespace {
std::vector<std::string> buildClassicFootstepSet(const std::string& material) {
std::vector<std::string> 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<std::string> buildAltFootstepSet(const std::string& folder, const std::string& stem) {
std::vector<std::string> 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<size_t>(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<std::string>& candidates) {
if (!assetManager) {
return;
}
auto& list = surfaces[static_cast<size_t>(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<size_t>(surface)].clips;
if (list.empty()) {
list = surfaces[static_cast<size_t>(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<size_t> 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<const char*>(sample.data.data()), static_cast<std::streamsize>(sample.data.size()));
out.close();
// Subtle variation for less repetitive cadence.
std::uniform_real_distribution<float> pitchDist(0.97f, 1.05f);
std::uniform_real_distribution<float> 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

View file

@ -12,6 +12,27 @@
namespace wowee { namespace wowee {
namespace rendering { namespace rendering {
namespace {
std::optional<float> selectReachableFloor(const std::optional<float>& terrainH,
const std::optional<float>& wmoH,
float refZ,
float maxStepUp) {
std::optional<float> best;
auto consider = [&](const std::optional<float>& 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) { CameraController::CameraController(Camera* cam) : camera(cam) {
yaw = defaultYaw; yaw = defaultYaw;
pitch = defaultPitch; pitch = defaultPitch;
@ -29,12 +50,47 @@ void CameraController::update(float deltaTime) {
bool uiWantsKeyboard = ImGui::GetIO().WantCaptureKeyboard; bool uiWantsKeyboard = ImGui::GetIO().WantCaptureKeyboard;
// Determine current key states // Determine current key states
bool nowForward = !uiWantsKeyboard && !sitting && input.isKeyPressed(SDL_SCANCODE_W); bool keyW = !uiWantsKeyboard && !sitting && input.isKeyPressed(SDL_SCANCODE_W);
bool nowBackward = !uiWantsKeyboard && !sitting && input.isKeyPressed(SDL_SCANCODE_S); bool keyS = !uiWantsKeyboard && !sitting && input.isKeyPressed(SDL_SCANCODE_S);
bool nowStrafeLeft = !uiWantsKeyboard && !sitting && input.isKeyPressed(SDL_SCANCODE_A); bool keyA = !uiWantsKeyboard && !sitting && input.isKeyPressed(SDL_SCANCODE_A);
bool nowStrafeRight = !uiWantsKeyboard && !sitting && input.isKeyPressed(SDL_SCANCODE_D); 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 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 // Select physics constants based on mode
float gravity = useWoWSpeed ? WOW_GRAVITY : GRAVITY; float gravity = useWoWSpeed ? WOW_GRAVITY : GRAVITY;
float jumpVel = useWoWSpeed ? WOW_JUMP_VELOCITY : JUMP_VELOCITY; 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 // Calculate movement speed based on direction and modifiers
float speed; float speed;
if (useWoWSpeed) { if (useWoWSpeed) {
// Movement speeds (Shift = sprint, Ctrl = walk) // Movement speeds (WoW-like: Ctrl walk, default run, backpedal slower)
if (nowBackward && !nowForward) { if (nowBackward && !nowForward) {
speed = WOW_BACK_SPEED; speed = WOW_BACK_SPEED;
} else if (!uiWantsKeyboard && (input.isKeyPressed(SDL_SCANCODE_LSHIFT) || input.isKeyPressed(SDL_SCANCODE_RSHIFT))) { } else if (ctrlDown) {
speed = WOW_SPRINT_SPEED; // Shift = sprint (faster) speed = WOW_WALK_SPEED;
} else if (!uiWantsKeyboard && (input.isKeyPressed(SDL_SCANCODE_LCTRL) || input.isKeyPressed(SDL_SCANCODE_RCTRL))) {
speed = WOW_WALK_SPEED; // Ctrl = walk (slower)
} else { } else {
speed = WOW_RUN_SPEED; // Normal run speed = WOW_RUN_SPEED;
} }
} else { } else {
// Exploration mode (original behavior) // Exploration mode (original behavior)
speed = movementSpeed; speed = movementSpeed;
if (!uiWantsKeyboard && (input.isKeyPressed(SDL_SCANCODE_LSHIFT) || input.isKeyPressed(SDL_SCANCODE_RSHIFT))) { if (shiftDown) {
speed *= sprintMultiplier; speed *= sprintMultiplier;
} }
if (!uiWantsKeyboard && (input.isKeyPressed(SDL_SCANCODE_LCTRL) || input.isKeyPressed(SDL_SCANCODE_RCTRL))) { if (ctrlDown) {
speed *= slowMultiplier; 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 // Get camera axes — project forward onto XY plane for walking
glm::vec3 forward3D = camera->getForward(); glm::vec3 forward3D = camera->getForward();
glm::vec3 forward = glm::normalize(glm::vec3(forward3D.x, forward3D.y, 0.0f)); 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 // Helper to get ground height at a position
auto getGroundAt = [&](float x, float y) -> std::optional<float> { auto getGroundAt = [&](float x, float y) -> std::optional<float> {
std::optional<float> h; std::optional<float> terrainH;
std::optional<float> wmoH;
if (terrainManager) { if (terrainManager) {
h = terrainManager->getHeightAt(x, y); terrainH = terrainManager->getHeightAt(x, y);
} }
if (wmoRenderer) { if (wmoRenderer) {
auto wh = wmoRenderer->getFloorHeight(x, y, targetPos.z + 5.0f); wmoH = wmoRenderer->getFloorHeight(x, y, targetPos.z + 5.0f);
if (wh && (!h || *wh > *h)) {
h = wh;
}
} }
return h; return selectReachableFloor(terrainH, wmoH, targetPos.z, 1.0f);
}; };
// Get ground height at target position // 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); wmoH = wmoRenderer->getFloorHeight(targetPos.x, targetPos.y, targetPos.z + eyeHeight);
} }
std::optional<float> groundH; std::optional<float> groundH = selectReachableFloor(terrainH, wmoH, targetPos.z, 1.0f);
if (terrainH && wmoH) {
groundH = std::max(*terrainH, *wmoH);
} else if (terrainH) {
groundH = terrainH;
} else if (wmoH) {
groundH = wmoH;
}
if (groundH) { if (groundH) {
float groundDiff = *groundH - lastGroundZ; float groundDiff = *groundH - lastGroundZ;
float currentFeetZ = targetPos.z; if (std::abs(groundDiff) < 2.0f) {
// Small height difference - smooth it
// Only consider floors that are: lastGroundZ += groundDiff * std::min(1.0f, deltaTime * 15.0f);
// 1. Below us (we can fall onto them) } else {
// 2. Slightly above us (we can step up onto them, max 1 unit) // Large height difference - snap (for falling onto ledges)
// Don't teleport to roofs/floors that are way above us lastGroundZ = *groundH;
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 floor is way above us (roof), ignore it and keep lastGroundZ
if (targetPos.z <= lastGroundZ + 0.1f) { if (targetPos.z <= lastGroundZ + 0.1f) {
targetPos.z = lastGroundZ; targetPos.z = lastGroundZ;
@ -334,26 +377,21 @@ void CameraController::update(float deltaTime) {
float zoomLerp = 1.0f - std::exp(-ZOOM_SMOOTH_SPEED * deltaTime); float zoomLerp = 1.0f - std::exp(-ZOOM_SMOOTH_SPEED * deltaTime);
currentDistance += (userTargetDistance - currentDistance) * zoomLerp; currentDistance += (userTargetDistance - currentDistance) * zoomLerp;
// Desired camera position (before collision)
glm::vec3 desiredCam = pivot + camDir * currentDistance;
// ===== Camera collision (sphere sweep approximation) ===== // ===== Camera collision (sphere sweep approximation) =====
// Find max safe distance using raycast + sphere radius // Find max safe distance using raycast + sphere radius
collisionDistance = currentDistance; collisionDistance = currentDistance;
// Helper to get floor height // Helper to get floor height
auto getFloorAt = [&](float x, float y, float z) -> std::optional<float> { auto getFloorAt = [&](float x, float y, float z) -> std::optional<float> {
std::optional<float> h; std::optional<float> terrainH;
std::optional<float> wmoH;
if (terrainManager) { if (terrainManager) {
h = terrainManager->getHeightAt(x, y); terrainH = terrainManager->getHeightAt(x, y);
} }
if (wmoRenderer) { if (wmoRenderer) {
auto wh = wmoRenderer->getFloorHeight(x, y, z + 5.0f); wmoH = wmoRenderer->getFloorHeight(x, y, z + 5.0f);
if (wh && (!h || *wh > *h)) {
h = wh;
}
} }
return h; return selectReachableFloor(terrainH, wmoH, z, 0.5f);
}; };
// Raycast against WMO bounding boxes // 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 // After smoothing, ensure camera is above the floor at its final position
// This prevents camera clipping through ground in Stormwind and similar areas // 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 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) { if (finalFloorH && smoothedCamPos.z < *finalFloorH + MIN_FLOOR_CLEARANCE) {
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); wmoH = wmoRenderer->getFloorHeight(newPos.x, newPos.y, newPos.z);
} }
std::optional<float> groundH; std::optional<float> groundH = selectReachableFloor(terrainH, wmoH, newPos.z - eyeHeight, 1.0f);
if (terrainH && wmoH) {
groundH = std::max(*terrainH, *wmoH);
} else if (terrainH) {
groundH = terrainH;
} else if (wmoH) {
groundH = wmoH;
}
if (groundH) { if (groundH) {
lastGroundZ = *groundH; lastGroundZ = *groundH;
@ -565,6 +596,19 @@ void CameraController::update(float deltaTime) {
} }
} }
// Turning
if (nowTurnLeft && !wasTurningLeft) {
movementCallback(static_cast<uint32_t>(game::Opcode::CMSG_MOVE_START_TURN_LEFT));
}
if (nowTurnRight && !wasTurningRight) {
movementCallback(static_cast<uint32_t>(game::Opcode::CMSG_MOVE_START_TURN_RIGHT));
}
if ((!nowTurnLeft && wasTurningLeft) || (!nowTurnRight && wasTurningRight)) {
if (!nowTurnLeft && !nowTurnRight) {
movementCallback(static_cast<uint32_t>(game::Opcode::CMSG_MOVE_STOP_TURN));
}
}
// Jump // Jump
if (nowJump && !wasJumping && grounded) { if (nowJump && !wasJumping && grounded) {
movementCallback(static_cast<uint32_t>(game::Opcode::CMSG_MOVE_JUMP)); movementCallback(static_cast<uint32_t>(game::Opcode::CMSG_MOVE_JUMP));
@ -591,13 +635,17 @@ void CameraController::update(float deltaTime) {
wasMovingBackward = nowBackward; wasMovingBackward = nowBackward;
wasStrafingLeft = nowStrafeLeft; wasStrafingLeft = nowStrafeLeft;
wasStrafingRight = nowStrafeRight; wasStrafingRight = nowStrafeRight;
wasTurningLeft = nowTurnLeft;
wasTurningRight = nowTurnRight;
wasJumping = nowJump; wasJumping = nowJump;
wasFalling = !grounded && verticalVelocity <= 0.0f; wasFalling = !grounded && verticalVelocity <= 0.0f;
// Reset camera (R key) // Reset camera/character (R key, edge-triggered)
if (!uiWantsKeyboard && input.isKeyPressed(SDL_SCANCODE_R)) { bool rDown = !uiWantsKeyboard && input.isKeyPressed(SDL_SCANCODE_R);
if (rDown && !rKeyWasDown) {
reset(); reset();
} }
rKeyWasDown = rDown;
} }
void CameraController::processMouseMotion(const SDL_MouseMotionEvent& event) { void CameraController::processMouseMotion(const SDL_MouseMotionEvent& event) {
@ -648,7 +696,20 @@ void CameraController::reset() {
yaw = defaultYaw; yaw = defaultYaw;
pitch = defaultPitch; pitch = defaultPitch;
verticalVelocity = 0.0f; 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; glm::vec3 spawnPos = defaultPosition;
@ -665,11 +726,32 @@ void CameraController::reset() {
} }
if (h) { if (h) {
lastGroundZ = *h; lastGroundZ = *h;
spawnPos.z = *h + eyeHeight; spawnPos.z = *h;
} }
camera->setPosition(spawnPos);
camera->setRotation(yaw, pitch); 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"); LOG_INFO("Camera reset to default position");
} }
@ -701,22 +783,24 @@ bool CameraController::isMoving() const {
} }
auto& input = core::Input::getInstance(); 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) || // In third-person without RMB, A/D are turn keys (not movement).
input.isKeyPressed(SDL_SCANCODE_S) || if (thirdPerson && !rightMouseDown) {
input.isKeyPressed(SDL_SCANCODE_A) || return keyW || keyS || keyQ || keyE;
input.isKeyPressed(SDL_SCANCODE_D); }
bool mouseAutorun = leftMouseDown && rightMouseDown;
return keyW || keyS || keyA || keyD || keyQ || keyE || mouseAutorun;
} }
bool CameraController::isSprinting() const { bool CameraController::isSprinting() const {
if (!enabled || !camera) { return enabled && camera && runPace;
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));
} }
} // namespace rendering } // namespace rendering

View file

@ -1141,6 +1141,30 @@ void CharacterRenderer::removeInstance(uint32_t instanceId) {
instances.erase(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<int>(sequences.size())) {
return false;
}
animationId = instance.currentAnimationId;
animationTimeMs = instance.animationTime;
animationDurationMs = static_cast<float>(sequences[instance.currentSequenceIndex].duration);
return true;
}
bool CharacterRenderer::attachWeapon(uint32_t charInstanceId, uint32_t attachmentId, bool CharacterRenderer::attachWeapon(uint32_t charInstanceId, uint32_t attachmentId,
const pipeline::M2Model& weaponModel, uint32_t weaponModelId, const pipeline::M2Model& weaponModel, uint32_t weaponModelId,
const std::string& texturePath) { const std::string& texturePath) {

View file

@ -27,11 +27,14 @@
#include "game/world.hpp" #include "game/world.hpp"
#include "game/zone_manager.hpp" #include "game/zone_manager.hpp"
#include "audio/music_manager.hpp" #include "audio/music_manager.hpp"
#include "audio/footstep_manager.hpp"
#include <GL/glew.h> #include <GL/glew.h>
#include <glm/gtc/matrix_transform.hpp> #include <glm/gtc/matrix_transform.hpp>
#include <glm/gtx/euler_angles.hpp> #include <glm/gtx/euler_angles.hpp>
#include <glm/gtc/quaternion.hpp> #include <glm/gtc/quaternion.hpp>
#include <cctype> #include <cctype>
#include <cmath>
#include <optional>
#include <unordered_map> #include <unordered_map>
#include <unordered_set> #include <unordered_set>
@ -185,6 +188,7 @@ bool Renderer::initialize(core::Window* win) {
// Create music manager (initialized later with asset manager) // Create music manager (initialized later with asset manager)
musicManager = std::make_unique<audio::MusicManager>(); musicManager = std::make_unique<audio::MusicManager>();
footstepManager = std::make_unique<audio::FootstepManager>();
LOG_INFO("Renderer initialized"); LOG_INFO("Renderer initialized");
return true; return true;
@ -257,6 +261,10 @@ void Renderer::shutdown() {
musicManager->shutdown(); musicManager->shutdown();
musicManager.reset(); musicManager.reset();
} }
if (footstepManager) {
footstepManager->shutdown();
footstepManager.reset();
}
zoneManager.reset(); zoneManager.reset();
@ -492,6 +500,87 @@ bool Renderer::isMoving() const {
return cameraController && cameraController->isMoving(); 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<char>(std::tolower(static_cast<unsigned char>(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) { void Renderer::update(float deltaTime) {
if (cameraController) { if (cameraController) {
cameraController->update(deltaTime); cameraController->update(deltaTime);
@ -569,6 +658,25 @@ void Renderer::update(float deltaTime) {
characterRenderer->update(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 // Update M2 doodad animations
if (m2Renderer) { if (m2Renderer) {
m2Renderer->update(deltaTime); m2Renderer->update(deltaTime);
@ -820,6 +928,9 @@ bool Renderer::loadTestTerrain(pipeline::AssetManager* assetManager, const std::
// Initialize music manager with asset manager // Initialize music manager with asset manager
if (musicManager && assetManager && !cachedAssetManager) { if (musicManager && assetManager && !cachedAssetManager) {
musicManager->initialize(assetManager); musicManager->initialize(assetManager);
if (footstepManager) {
footstepManager->initialize(assetManager);
}
cachedAssetManager = assetManager; cachedAssetManager = assetManager;
} }
@ -883,6 +994,11 @@ bool Renderer::loadTerrainArea(const std::string& mapName, int centerX, int cent
musicManager->initialize(cachedAssetManager); musicManager->initialize(cachedAssetManager);
} }
} }
if (footstepManager && cachedAssetManager) {
if (!footstepManager->isInitialized()) {
footstepManager->initialize(cachedAssetManager);
}
}
// Wire WMO, M2, and water renderer to camera controller // Wire WMO, M2, and water renderer to camera controller
if (cameraController && wmoRenderer) { if (cameraController && wmoRenderer) {

View file

@ -21,6 +21,66 @@
namespace wowee { namespace wowee {
namespace rendering { namespace rendering {
namespace {
bool decodeLayerAlpha(const pipeline::MapChunk& chunk, size_t layerIdx, std::vector<uint8_t>& 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() { TerrainManager::TerrainManager() {
} }
@ -807,6 +867,69 @@ std::optional<float> TerrainManager::getHeightAt(float glX, float glY) const {
return std::nullopt; return std::nullopt;
} }
std::optional<std::string> TerrainManager::getDominantTextureAt(float glX, float glY) const {
const float unitSize = CHUNK_SIZE / 8.0f;
std::vector<uint8_t> 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<int>((fracX / 8.0f) * 63.0f), 0, 63);
int alphaY = glm::clamp(static_cast<int>((fracY / 8.0f) * 63.0f), 0, 63);
int alphaIndex = alphaY * 64 + alphaX;
std::vector<int> 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<int>(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() { void TerrainManager::streamTiles() {
// Enqueue tiles in radius around current tile for async loading // Enqueue tiles in radius around current tile for async loading
{ {