#include "rendering/camera_controller.hpp" #include "rendering/terrain_manager.hpp" #include "rendering/wmo_renderer.hpp" #include "rendering/m2_renderer.hpp" #include "rendering/water_renderer.hpp" #include "rendering/character_renderer.hpp" #include "game/opcodes.hpp" #include "core/logger.hpp" #include #include #include #include namespace wowee { namespace rendering { namespace { std::optional selectReachableFloor(const std::optional& terrainH, const std::optional& wmoH, float refZ, float maxStepUp) { // Filter to reachable floors (not too far above) std::optional reachTerrain; std::optional reachWmo; if (terrainH && *terrainH <= refZ + maxStepUp) reachTerrain = terrainH; if (wmoH && *wmoH <= refZ + maxStepUp) reachWmo = wmoH; if (reachTerrain && reachWmo) { // Prefer the highest surface — prevents clipping through // WMO floors that sit above terrain. return (*reachWmo >= *reachTerrain) ? reachWmo : reachTerrain; } if (reachWmo) return reachWmo; if (reachTerrain) return reachTerrain; return std::nullopt; } std::optional selectHighestFloor(const std::optional& a, const std::optional& b, const std::optional& c) { std::optional best; auto consider = [&](const std::optional& h) { if (!h) return; if (!best || *h > *best) best = *h; }; consider(a); consider(b); consider(c); return best; } } // namespace CameraController::CameraController(Camera* cam) : camera(cam) { yaw = defaultYaw; facingYaw = defaultYaw; pitch = defaultPitch; reset(); } void CameraController::startIntroPan(float durationSec, float orbitDegrees) { if (!camera) return; introActive = true; introTimer = 0.0f; idleTimer_ = 0.0f; introDuration = std::max(0.5f, durationSec); introStartYaw = yaw; introEndYaw = yaw - orbitDegrees; introOrbitDegrees = orbitDegrees; introStartPitch = pitch; introEndPitch = pitch; introStartDistance = currentDistance; introEndDistance = currentDistance; thirdPerson = true; } void CameraController::update(float deltaTime) { if (!enabled || !camera) { return; } auto& input = core::Input::getInstance(); // Don't process keyboard input when UI text input (e.g. chat box) has focus bool uiWantsKeyboard = ImGui::GetIO().WantTextInput; // Determine current key states 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.isKeyJustPressed(SDL_SCANCODE_SPACE); // Idle camera: any input resets the timer; timeout triggers a slow orbit pan bool anyInput = leftMouseDown || rightMouseDown || keyW || keyS || keyA || keyD || keyQ || keyE || nowJump; if (anyInput) { idleTimer_ = 0.0f; } else if (!introActive) { idleTimer_ += deltaTime; if (idleTimer_ >= IDLE_TIMEOUT) { idleTimer_ = 0.0f; startIntroPan(30.0f, 360.0f); // Slow casual orbit over 30 seconds idleOrbit_ = true; } } if (introActive) { if (anyInput) { introActive = false; idleOrbit_ = false; idleTimer_ = 0.0f; } else { introTimer += deltaTime; if (idleOrbit_) { // Continuous smooth rotation — no lerp endpoint, just constant angular velocity float degreesPerSec = introOrbitDegrees / introDuration; yaw -= degreesPerSec * deltaTime; camera->setRotation(yaw, pitch); facingYaw = yaw; } else { float t = (introDuration > 0.0f) ? std::min(introTimer / introDuration, 1.0f) : 1.0f; yaw = introStartYaw + (introEndYaw - introStartYaw) * t; pitch = introStartPitch + (introEndPitch - introStartPitch) * t; currentDistance = introStartDistance + (introEndDistance - introStartDistance) * t; userTargetDistance = introEndDistance; camera->setRotation(yaw, pitch); facingYaw = yaw; if (t >= 1.0f) { introActive = false; } } } // Suppress player movement/input during intro. keyW = keyS = keyA = keyD = keyQ = keyE = nowJump = false; } // Tilde toggles auto-run; any forward/backward key cancels it bool tildeDown = !uiWantsKeyboard && input.isKeyPressed(SDL_SCANCODE_GRAVE); if (tildeDown && !tildeWasDown) { autoRunning = !autoRunning; } tildeWasDown = tildeDown; if (keyW || keyS) { autoRunning = false; } bool mouseAutorun = !uiWantsKeyboard && !sitting && leftMouseDown && rightMouseDown; if (mouseAutorun) { autoRunning = false; } bool nowForward = keyW || mouseAutorun || autoRunning; 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); facingYaw = yaw; } // Select physics constants based on mode float gravity = useWoWSpeed ? WOW_GRAVITY : GRAVITY; float jumpVel = useWoWSpeed ? WOW_JUMP_VELOCITY : JUMP_VELOCITY; // Calculate movement speed based on direction and modifiers float speed; if (useWoWSpeed) { // Movement speeds (WoW-like: Ctrl walk, default run, backpedal slower) if (nowBackward && !nowForward) { speed = WOW_BACK_SPEED; } else if (ctrlDown) { speed = WOW_WALK_SPEED; } else if (runSpeedOverride_ > 0.0f && runSpeedOverride_ < 100.0f && !std::isnan(runSpeedOverride_)) { speed = runSpeedOverride_; } else { speed = WOW_RUN_SPEED; } } else { // Exploration mode (original behavior) speed = movementSpeed; if (shiftDown) { speed *= sprintMultiplier; } 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(); 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 key (edge-triggered) — only when UI doesn't want keyboard // Blocked while mounted bool xDown = !uiWantsKeyboard && input.isKeyPressed(SDL_SCANCODE_X); if (xDown && !xKeyWasDown && !mounted_) { sitting = !sitting; } if (mounted_) sitting = false; xKeyWasDown = xDown; // Update eye height based on crouch state (smooth transition) float targetEyeHeight = sitting ? CROUCH_EYE_HEIGHT : STAND_EYE_HEIGHT; float heightLerpSpeed = 10.0f * deltaTime; eyeHeight = eyeHeight + (targetEyeHeight - eyeHeight) * std::min(1.0f, heightLerpSpeed); // Calculate horizontal movement vector glm::vec3 movement(0.0f); if (nowForward) movement += forward; if (nowBackward) movement -= forward; if (nowStrafeLeft) movement += right; if (nowStrafeRight) movement -= right; // Stand up if jumping while crouched if (!uiWantsKeyboard && sitting && input.isKeyPressed(SDL_SCANCODE_SPACE)) { sitting = false; } // Third-person orbit camera mode if (thirdPerson && followTarget) { // Move the follow target (character position) instead of the camera glm::vec3 targetPos = *followTarget; if (!externalFollow_) { if (wmoRenderer) { wmoRenderer->setCollisionFocus(targetPos, COLLISION_FOCUS_RADIUS_THIRD_PERSON); } if (m2Renderer) { m2Renderer->setCollisionFocus(targetPos, COLLISION_FOCUS_RADIUS_THIRD_PERSON); } } if (!externalFollow_) { // Check for water at current position — simple submersion test. // If the player's feet are meaningfully below the water surface, swim. std::optional waterH; if (waterRenderer) { waterH = waterRenderer->getWaterHeightAt(targetPos.x, targetPos.y); } bool inWater = waterH && (targetPos.z < (*waterH - 0.3f)); // Keep swimming through water-data gaps (chunk boundaries). if (!inWater && swimming && !waterH) { inWater = true; } if (inWater) { swimming = true; // Swim movement follows look pitch (forward/back), while strafe stays // lateral for stable control. float swimSpeed = speed * SWIM_SPEED_FACTOR; float waterSurfaceZ = waterH ? (*waterH - WATER_SURFACE_OFFSET) : targetPos.z; glm::vec3 swimForward = glm::normalize(forward3D); if (glm::length(swimForward) < 1e-4f) { swimForward = forward; } glm::vec3 swimRight = camera->getRight(); swimRight.z = 0.0f; if (glm::length(swimRight) > 1e-4f) { swimRight = glm::normalize(swimRight); } else { swimRight = right; } glm::vec3 swimMove(0.0f); if (nowForward) swimMove += swimForward; if (nowBackward) swimMove -= swimForward; if (nowStrafeLeft) swimMove -= swimRight; if (nowStrafeRight) swimMove += swimRight; if (glm::length(swimMove) > 0.001f) { swimMove = glm::normalize(swimMove); targetPos += swimMove * swimSpeed * deltaTime; } // Spacebar = swim up (continuous, not a jump) bool diveIntent = nowForward && (forward3D.z < -0.28f); if (nowJump) { verticalVelocity = SWIM_BUOYANCY; } else { // Gentle sink when not pressing space verticalVelocity += SWIM_GRAVITY * deltaTime; if (verticalVelocity < SWIM_SINK_SPEED) { verticalVelocity = SWIM_SINK_SPEED; } // Strong surface lock while idle/normal swim so buoyancy keeps // you afloat unless you're intentionally diving. if (!diveIntent) { float surfaceErr = (waterSurfaceZ - targetPos.z); verticalVelocity += surfaceErr * 7.0f * deltaTime; verticalVelocity *= std::max(0.0f, 1.0f - 3.2f * deltaTime); if (std::abs(surfaceErr) < 0.06f && std::abs(verticalVelocity) < 0.35f) { verticalVelocity = 0.0f; } } } targetPos.z += verticalVelocity * deltaTime; // Don't rise above water surface if (waterH && targetPos.z > *waterH - WATER_SURFACE_OFFSET) { targetPos.z = *waterH - WATER_SURFACE_OFFSET; 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 + 2.0f); if (wh && (!floorH || *wh > *floorH)) floorH = wh; } if (m2Renderer && !externalFollow_) { auto mh = m2Renderer->getFloorHeight(targetPos.x, targetPos.y, targetPos.z); if (mh && (!floorH || *mh > *floorH)) floorH = mh; } if (floorH) { float swimFloor = *floorH + 0.5f; if (targetPos.z < swimFloor) { targetPos.z = swimFloor; if (verticalVelocity < 0.0f) verticalVelocity = 0.0f; } } // Enforce collision while swimming too (horizontal only), skip when stationary. { glm::vec3 swimFrom = *followTarget; glm::vec3 swimTo = targetPos; float swimMoveDist = glm::length(swimTo - swimFrom); glm::vec3 stepPos = swimFrom; if (swimMoveDist > 0.01f) { int swimSteps = std::max(1, std::min(3, static_cast(std::ceil(swimMoveDist / 0.65f)))); glm::vec3 stepDelta = (swimTo - swimFrom) / static_cast(swimSteps); for (int i = 0; i < swimSteps; i++) { glm::vec3 candidate = stepPos + stepDelta; if (wmoRenderer) { glm::vec3 adjusted; if (wmoRenderer->checkWallCollision(stepPos, candidate, adjusted)) { candidate.x = adjusted.x; candidate.y = adjusted.y; candidate.z = std::max(candidate.z, adjusted.z); } } if (m2Renderer && !externalFollow_) { glm::vec3 adjusted; if (m2Renderer->checkCollision(stepPos, candidate, adjusted)) { candidate.x = adjusted.x; candidate.y = adjusted.y; } } stepPos = candidate; } } targetPos.x = stepPos.x; targetPos.y = stepPos.y; } grounded = false; } else { // Exiting water — give a small upward boost to help climb onto shore. swimming = false; if (glm::length(movement) > 0.001f) { movement = glm::normalize(movement); targetPos += movement * speed * deltaTime; } // Jump with input buffering and coyote time if (nowJump) jumpBufferTimer = JUMP_BUFFER_TIME; if (grounded) coyoteTimer = COYOTE_TIME; bool canJump = (coyoteTimer > 0.0f) && (jumpBufferTimer > 0.0f); if (canJump) { verticalVelocity = jumpVel; grounded = false; jumpBufferTimer = 0.0f; coyoteTimer = 0.0f; } jumpBufferTimer -= deltaTime; coyoteTimer -= deltaTime; // Apply gravity verticalVelocity += gravity * deltaTime; targetPos.z += verticalVelocity * deltaTime; } } else { // External follow (e.g., taxi): trust server position without grounding. swimming = false; grounded = true; verticalVelocity = 0.0f; } // Sweep collisions in small steps to reduce tunneling through thin walls/floors. // Skip entirely when stationary to avoid wasting collision calls. // Use tighter steps when inside WMO for more precise collision. { glm::vec3 startPos = *followTarget; glm::vec3 desiredPos = targetPos; float moveDist = glm::length(desiredPos - startPos); if (moveDist > 0.01f) { // Smaller step size when inside buildings for tighter collision float stepSize = cachedInsideWMO ? 0.20f : 0.35f; int sweepSteps = std::max(1, std::min(8, static_cast(std::ceil(moveDist / stepSize)))); glm::vec3 stepPos = startPos; glm::vec3 stepDelta = (desiredPos - startPos) / static_cast(sweepSteps); for (int i = 0; i < sweepSteps; i++) { glm::vec3 candidate = stepPos + stepDelta; if (wmoRenderer) { glm::vec3 adjusted; if (wmoRenderer->checkWallCollision(stepPos, candidate, adjusted, cachedInsideWMO)) { candidate.x = adjusted.x; candidate.y = adjusted.y; // Accept upward Z correction (ramps), reject downward candidate.z = std::max(candidate.z, adjusted.z); } } if (m2Renderer && !externalFollow_) { glm::vec3 adjusted; if (m2Renderer->checkCollision(stepPos, candidate, adjusted)) { candidate.x = adjusted.x; candidate.y = adjusted.y; } } stepPos = candidate; } targetPos = stepPos; } } // Ground the character to terrain or WMO floor // Skip entirely while swimming — the swim floor clamp handles vertical bounds. if (!swimming) { float stepUpBudget = grounded ? 1.6f : 1.2f; // 1. Center-only sample for terrain/WMO floor selection. // Using only the center prevents tunnel entrances from snapping // to terrain when offset samples miss the WMO floor geometry. std::optional groundH; { std::optional terrainH; std::optional wmoH; if (terrainManager) { terrainH = terrainManager->getHeightAt(targetPos.x, targetPos.y); } float wmoProbeZ = std::max(targetPos.z, lastGroundZ) + stepUpBudget + 0.5f; if (wmoRenderer) { wmoH = wmoRenderer->getFloorHeight(targetPos.x, targetPos.y, wmoProbeZ); } groundH = selectReachableFloor(terrainH, wmoH, targetPos.z, stepUpBudget); } // 2. Multi-sample for M2 objects (rugs, planks, bridges, ships) — // these are narrow and need offset probes to detect reliably. if (m2Renderer && !externalFollow_) { constexpr float FOOTPRINT = 0.4f; const glm::vec2 offsets[] = { {0.0f, 0.0f}, {FOOTPRINT, 0.0f}, {-FOOTPRINT, 0.0f}, {0.0f, FOOTPRINT}, {0.0f, -FOOTPRINT} }; float m2ProbeZ = std::max(targetPos.z, lastGroundZ) + 6.0f; for (const auto& o : offsets) { auto m2H = m2Renderer->getFloorHeight( targetPos.x + o.x, targetPos.y + o.y, m2ProbeZ); // Prefer M2 floors (ships, platforms) even if slightly lower than terrain // to prevent falling through ship decks to water below if (m2H && *m2H <= targetPos.z + stepUpBudget) { if (!groundH || *m2H > *groundH || (*m2H >= targetPos.z - 0.5f && *groundH < targetPos.z - 1.0f)) { groundH = m2H; } } } } if (groundH) { hasRealGround_ = true; noGroundTimer_ = 0.0f; float feetZ = targetPos.z; float stepUp = stepUpBudget; stepUp += 0.05f; float fallCatch = 3.0f; float dz = *groundH - feetZ; // WoW-style: snap to floor if within step-up or fall-catch range, // but only when not moving upward (jumping) if (dz <= stepUp && dz >= -fallCatch && verticalVelocity <= 0.0f) { targetPos.z = *groundH; verticalVelocity = 0.0f; grounded = true; lastGroundZ = *groundH; } else { grounded = false; lastGroundZ = *groundH; } } else { hasRealGround_ = false; noGroundTimer_ += deltaTime; if (noGroundTimer_ < NO_GROUND_GRACE) { // Grace should prevent instant falling at seams, // but should NEVER pull you down or cancel a jump. targetPos.z = std::max(targetPos.z, lastGroundZ); // Only zero velocity if we're not moving upward. if (verticalVelocity <= 0.0f) { verticalVelocity = 0.0f; grounded = true; } else { grounded = false; // jumping upward: don't "stick" to ghost ground } } else { grounded = false; } } } // Update follow target position *followTarget = targetPos; // --- Safe position caching + void fall detection --- if (grounded && hasRealGround_ && !swimming && verticalVelocity >= 0.0f) { // Player is safely on real geometry — save periodically continuousFallTime_ = 0.0f; autoUnstuckFired_ = false; safePosSaveTimer_ += deltaTime; if (safePosSaveTimer_ >= SAFE_POS_SAVE_INTERVAL) { safePosSaveTimer_ = 0.0f; lastSafePos_ = targetPos; hasLastSafe_ = true; } } else if (!grounded && !swimming && !externalFollow_) { // Falling (or standing on nothing past grace period) — accumulate fall time continuousFallTime_ += deltaTime; if (continuousFallTime_ >= AUTO_UNSTUCK_FALL_TIME && !autoUnstuckFired_) { autoUnstuckFired_ = true; if (autoUnstuckCallback_) { autoUnstuckCallback_(); } } } // ===== WoW-style orbit camera ===== // Pivot point at upper chest/neck float mountedOffset = mounted_ ? mountHeightOffset_ : 0.0f; glm::vec3 pivot = targetPos + glm::vec3(0.0f, 0.0f, PIVOT_HEIGHT + mountedOffset); // Camera direction from yaw/pitch (already computed as forward3D) glm::vec3 camDir = -forward3D; // Camera looks at pivot, so it's behind // Smooth zoom toward user target float zoomLerp = 1.0f - std::exp(-ZOOM_SMOOTH_SPEED * deltaTime); currentDistance += (userTargetDistance - currentDistance) * zoomLerp; // Limit max zoom when inside a WMO with a ceiling (building interior) // Throttle: only recheck every 10 frames or when position changes >2 units. if (wmoRenderer) { float distFromLastCheck = glm::length(targetPos - lastInsideWMOCheckPos); if (++insideWMOCheckCounter >= 10 || distFromLastCheck > 2.0f) { cachedInsideWMO = wmoRenderer->isInsideWMO(targetPos.x, targetPos.y, targetPos.z + 1.0f, nullptr); wmoRenderer->updateActiveGroup(targetPos.x, targetPos.y, targetPos.z + 1.0f); insideWMOCheckCounter = 0; lastInsideWMOCheckPos = targetPos; } // Constrain zoom if there's a ceiling/upper floor above player // Raycast upward from player to find ceiling, limit camera distance glm::vec3 upRayOrigin = targetPos; glm::vec3 upRayDir(0.0f, 0.0f, 1.0f); float ceilingDist = wmoRenderer->raycastBoundingBoxes(upRayOrigin, upRayDir, 20.0f); if (ceilingDist < 20.0f) { // Found ceiling above — limit zoom to prevent camera from going through it // Camera is behind player by currentDistance, at an angle // Approximate: if ceiling is N units above, limit zoom to ~N units float maxZoomForCeiling = std::max(1.5f, ceilingDist * 0.7f); if (currentDistance > maxZoomForCeiling) { currentDistance = maxZoomForCeiling; } } } // ===== Camera collision (sphere sweep approximation) ===== // Find max safe distance using raycast + sphere radius collisionDistance = currentDistance; // WMO raycast collision: zoom in when camera would clip through walls if (wmoRenderer && cachedInsideWMO && currentDistance > MIN_DISTANCE) { glm::vec3 camRayOrigin = pivot; glm::vec3 camRayDir = camDir; float wmoHitDist = wmoRenderer->raycastBoundingBoxes(camRayOrigin, camRayDir, currentDistance); if (wmoHitDist < currentDistance) { // Hit WMO geometry — pull camera in to avoid clipping constexpr float CAM_RADIUS = 0.3f; collisionDistance = std::max(MIN_DISTANCE, wmoHitDist - CAM_RADIUS); } } // M2 raycast collision: zoom in when camera would clip through doodads if (m2Renderer && !externalFollow_ && currentDistance > MIN_DISTANCE) { glm::vec3 camRayOrigin = pivot; glm::vec3 camRayDir = camDir; float m2HitDist = m2Renderer->raycastBoundingBoxes(camRayOrigin, camRayDir, currentDistance); if (m2HitDist < collisionDistance) { constexpr float CAM_RADIUS = 0.3f; collisionDistance = std::max(MIN_DISTANCE, m2HitDist - CAM_RADIUS); } } // Camera collision: terrain-only floor clamping auto getTerrainFloorAt = [&](float x, float y) -> std::optional { if (terrainManager) { return terrainManager->getHeightAt(x, y); } return std::nullopt; }; // Use collision distance (don't exceed user target) float actualDist = std::min(currentDistance, collisionDistance); // Compute actual camera position glm::vec3 actualCam; if (actualDist < MIN_DISTANCE + 0.1f) { // First-person: position camera at pivot (player's eyes) actualCam = pivot + forward3D * 0.1f; // Slightly forward to not clip head } else { actualCam = pivot + camDir * actualDist; } // Smooth camera position to avoid jitter if (glm::length(smoothedCamPos) < 0.01f) { smoothedCamPos = actualCam; // Initialize } float camLerp = 1.0f - std::exp(-CAM_SMOOTH_SPEED * deltaTime); smoothedCamPos += (actualCam - smoothedCamPos) * camLerp; // ===== Final floor clearance check ===== // Use WMO-aware floor so the camera doesn't pop above tunnels/caves. constexpr float MIN_FLOOR_CLEARANCE = 0.35f; { auto camTerrainH = getTerrainFloorAt(smoothedCamPos.x, smoothedCamPos.y); std::optional camWmoH; if (wmoRenderer) { // Skip expensive WMO floor query if camera barely moved float camDelta = glm::length(glm::vec2(smoothedCamPos.x - lastCamFloorQueryPos.x, smoothedCamPos.y - lastCamFloorQueryPos.y)); if (camDelta < 0.3f && hasCachedCamFloor) { camWmoH = cachedCamWmoFloor; } else { camWmoH = wmoRenderer->getFloorHeight( smoothedCamPos.x, smoothedCamPos.y, smoothedCamPos.z); cachedCamWmoFloor = camWmoH; hasCachedCamFloor = true; lastCamFloorQueryPos = smoothedCamPos; } } auto camFloorH = selectReachableFloor( camTerrainH, camWmoH, smoothedCamPos.z, 0.5f); if (camFloorH && smoothedCamPos.z < *camFloorH + MIN_FLOOR_CLEARANCE) { smoothedCamPos.z = *camFloorH + MIN_FLOOR_CLEARANCE; } } // Never let camera sink below the character's feet plane. smoothedCamPos.z = std::max(smoothedCamPos.z, targetPos.z + 0.15f); camera->setPosition(smoothedCamPos); // Hide player model when in first-person (camera too close) // WoW fades between ~1.0m and ~0.5m, hides fully below 0.5m // For now, just hide below first-person threshold if (characterRenderer && playerInstanceId > 0) { bool shouldHidePlayer = (actualDist < MIN_DISTANCE + 0.1f); // Hide in first-person characterRenderer->setInstanceVisible(playerInstanceId, !shouldHidePlayer); } } else { // Free-fly camera mode (original behavior) glm::vec3 newPos = camera->getPosition(); if (wmoRenderer) { wmoRenderer->setCollisionFocus(newPos, COLLISION_FOCUS_RADIUS_FREE_FLY); } if (m2Renderer) { m2Renderer->setCollisionFocus(newPos, COLLISION_FOCUS_RADIUS_FREE_FLY); } float feetZ = newPos.z - eyeHeight; // Check for water at feet position std::optional waterH; if (waterRenderer) { waterH = waterRenderer->getWaterHeightAt(newPos.x, newPos.y); } constexpr float MAX_SWIM_DEPTH_FROM_SURFACE = 12.0f; bool inWater = false; if (waterH && feetZ < *waterH) { std::optional waterType; if (waterRenderer) { waterType = waterRenderer->getWaterTypeAt(newPos.x, newPos.y); } bool isOcean = false; if (waterType && *waterType != 0) { isOcean = (((*waterType - 1) % 4) == 1); } bool depthAllowed = isOcean || ((*waterH - feetZ) <= MAX_SWIM_DEPTH_FROM_SURFACE); if (!depthAllowed) { inWater = false; } else { std::optional terrainH; std::optional wmoH; std::optional m2H; if (terrainManager) terrainH = terrainManager->getHeightAt(newPos.x, newPos.y); if (wmoRenderer) wmoH = wmoRenderer->getFloorHeight(newPos.x, newPos.y, feetZ + 2.0f); if (m2Renderer && !externalFollow_) m2H = m2Renderer->getFloorHeight(newPos.x, newPos.y, feetZ + 1.0f); auto floorH = selectHighestFloor(terrainH, wmoH, m2H); constexpr float MIN_SWIM_WATER_DEPTH = 1.8f; inWater = (floorH && ((*waterH - *floorH) >= MIN_SWIM_WATER_DEPTH)) || (isOcean && !floorH); } } if (inWater) { swimming = true; float swimSpeed = speed * SWIM_SPEED_FACTOR; float waterSurfaceCamZ = waterH ? (*waterH - WATER_SURFACE_OFFSET + eyeHeight) : newPos.z; bool diveIntent = nowForward && (forward3D.z < -0.28f); if (glm::length(movement) > 0.001f) { movement = glm::normalize(movement); newPos += movement * swimSpeed * deltaTime; } if (nowJump) { verticalVelocity = SWIM_BUOYANCY; } else { verticalVelocity += SWIM_GRAVITY * deltaTime; if (verticalVelocity < SWIM_SINK_SPEED) { verticalVelocity = SWIM_SINK_SPEED; } if (!diveIntent) { float surfaceErr = (waterSurfaceCamZ - newPos.z); verticalVelocity += surfaceErr * 7.0f * deltaTime; verticalVelocity *= std::max(0.0f, 1.0f - 3.2f * deltaTime); if (std::abs(surfaceErr) < 0.06f && std::abs(verticalVelocity) < 0.35f) { verticalVelocity = 0.0f; } } } newPos.z += verticalVelocity * deltaTime; // Don't rise above water surface (feet at water level) if (waterH && (newPos.z - eyeHeight) > *waterH - WATER_SURFACE_OFFSET) { newPos.z = *waterH - WATER_SURFACE_OFFSET + eyeHeight; if (verticalVelocity > 0.0f) verticalVelocity = 0.0f; } grounded = false; } else { swimming = false; if (glm::length(movement) > 0.001f) { movement = glm::normalize(movement); newPos += movement * speed * deltaTime; } // Jump with input buffering and coyote time if (nowJump) jumpBufferTimer = JUMP_BUFFER_TIME; if (grounded) coyoteTimer = COYOTE_TIME; if (coyoteTimer > 0.0f && jumpBufferTimer > 0.0f) { verticalVelocity = jumpVel; grounded = false; jumpBufferTimer = 0.0f; coyoteTimer = 0.0f; } jumpBufferTimer -= deltaTime; coyoteTimer -= deltaTime; // Apply gravity verticalVelocity += gravity * deltaTime; newPos.z += verticalVelocity * deltaTime; } // Wall sweep collision before grounding (skip when stationary). if (wmoRenderer) { glm::vec3 startFeet = camera->getPosition() - glm::vec3(0, 0, eyeHeight); glm::vec3 desiredFeet = newPos - glm::vec3(0, 0, eyeHeight); float moveDist = glm::length(desiredFeet - startFeet); if (moveDist > 0.01f) { int sweepSteps = std::max(1, std::min(3, static_cast(std::ceil(moveDist / 0.65f)))); glm::vec3 stepPos = startFeet; glm::vec3 stepDelta = (desiredFeet - startFeet) / static_cast(sweepSteps); for (int i = 0; i < sweepSteps; i++) { glm::vec3 candidate = stepPos + stepDelta; glm::vec3 adjusted; if (wmoRenderer->checkWallCollision(stepPos, candidate, adjusted)) { candidate.x = adjusted.x; candidate.y = adjusted.y; candidate.z = std::max(candidate.z, adjusted.z); } stepPos = candidate; } newPos = stepPos + glm::vec3(0, 0, eyeHeight); } } // Ground to terrain or WMO floor { auto sampleGround = [&](float x, float y) -> std::optional { std::optional terrainH; std::optional wmoH; std::optional m2H; if (terrainManager) { terrainH = terrainManager->getHeightAt(x, y); } float feetZ = newPos.z - eyeHeight; float wmoProbeZ = std::max(feetZ, lastGroundZ) + 1.5f; float m2ProbeZ = std::max(feetZ, lastGroundZ) + 6.0f; if (wmoRenderer) { wmoH = wmoRenderer->getFloorHeight(x, y, wmoProbeZ); } if (m2Renderer && !externalFollow_) { m2H = m2Renderer->getFloorHeight(x, y, m2ProbeZ); } auto base = selectReachableFloor(terrainH, wmoH, feetZ, 1.0f); if (m2H && *m2H <= feetZ + 1.0f && (!base || *m2H > *base)) { base = m2H; } return base; }; // Single center probe. std::optional groundH = sampleGround(newPos.x, newPos.y); if (groundH) { float feetZ = newPos.z - eyeHeight; float stepUp = 1.0f; float fallCatch = 3.0f; float dz = *groundH - feetZ; if (dz <= stepUp && dz >= -fallCatch && (verticalVelocity <= 0.0f || *groundH > feetZ)) { newPos.z = *groundH + eyeHeight; verticalVelocity = 0.0f; grounded = true; lastGroundZ = *groundH; swimming = false; } else if (!swimming) { grounded = false; lastGroundZ = *groundH; } } else if (!swimming) { newPos.z = lastGroundZ + eyeHeight; verticalVelocity = 0.0f; grounded = true; } } camera->setPosition(newPos); } // --- Edge-detection: send movement opcodes on state transitions --- if (movementCallback) { // Forward/backward if (nowForward && !wasMovingForward) { movementCallback(static_cast(game::Opcode::CMSG_MOVE_START_FORWARD)); } if (nowBackward && !wasMovingBackward) { movementCallback(static_cast(game::Opcode::CMSG_MOVE_START_BACKWARD)); } if ((!nowForward && wasMovingForward) || (!nowBackward && wasMovingBackward)) { if (!nowForward && !nowBackward) { movementCallback(static_cast(game::Opcode::CMSG_MOVE_STOP)); } } // Strafing if (nowStrafeLeft && !wasStrafingLeft) { movementCallback(static_cast(game::Opcode::CMSG_MOVE_START_STRAFE_LEFT)); } if (nowStrafeRight && !wasStrafingRight) { movementCallback(static_cast(game::Opcode::CMSG_MOVE_START_STRAFE_RIGHT)); } if ((!nowStrafeLeft && wasStrafingLeft) || (!nowStrafeRight && wasStrafingRight)) { if (!nowStrafeLeft && !nowStrafeRight) { movementCallback(static_cast(game::Opcode::CMSG_MOVE_STOP_STRAFE)); } } // 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)); } // Fall landing if (wasFalling && grounded) { movementCallback(static_cast(game::Opcode::CMSG_MOVE_FALL_LAND)); } } // Swimming state transitions if (movementCallback) { if (swimming && !wasSwimming) { movementCallback(static_cast(game::Opcode::CMSG_MOVE_START_SWIM)); } else if (!swimming && wasSwimming) { movementCallback(static_cast(game::Opcode::CMSG_MOVE_STOP_SWIM)); } } // Update previous-frame state wasSwimming = swimming; wasMovingForward = nowForward; wasMovingBackward = nowBackward; wasStrafingLeft = nowStrafeLeft; wasStrafingRight = nowStrafeRight; moveForwardActive = nowForward; moveBackwardActive = nowBackward; strafeLeftActive = nowStrafeLeft; strafeRightActive = nowStrafeRight; wasTurningLeft = nowTurnLeft; wasTurningRight = nowTurnRight; wasJumping = nowJump; wasFalling = !grounded && verticalVelocity <= 0.0f; // R key disabled — was camera reset, conflicts with chat reply rKeyWasDown = false; } void CameraController::processMouseMotion(const SDL_MouseMotionEvent& event) { if (!enabled || !camera) { return; } if (introActive) { return; } if (!mouseButtonDown) { return; } // Directly update stored yaw/pitch (no lossy forward-vector derivation) yaw -= event.xrel * mouseSensitivity; float invert = invertMouse ? -1.0f : 1.0f; pitch += event.yrel * mouseSensitivity * invert; // WoW-style pitch limits: can look almost straight down, limited upward pitch = glm::clamp(pitch, MIN_PITCH, MAX_PITCH); camera->setRotation(yaw, pitch); } void CameraController::processMouseButton(const SDL_MouseButtonEvent& event) { if (!enabled) { return; } if (event.button == SDL_BUTTON_LEFT) { leftMouseDown = (event.state == SDL_PRESSED); if (event.state == SDL_PRESSED && event.clicks >= 2) { autoRunning = false; } } if (event.button == SDL_BUTTON_RIGHT) { rightMouseDown = (event.state == SDL_PRESSED); } bool anyDown = leftMouseDown || rightMouseDown; if (anyDown && !mouseButtonDown) { SDL_SetRelativeMouseMode(SDL_TRUE); } else if (!anyDown && mouseButtonDown) { SDL_SetRelativeMouseMode(SDL_FALSE); } mouseButtonDown = anyDown; } void CameraController::reset() { if (!camera) { return; } yaw = defaultYaw; facingYaw = defaultYaw; pitch = defaultPitch; verticalVelocity = 0.0f; grounded = true; swimming = false; sitting = false; autoRunning = false; noGroundTimer_ = 0.0f; autoUnstuckFired_ = 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; moveForwardActive = false; moveBackwardActive = false; strafeLeftActive = false; strafeRightActive = false; glm::vec3 spawnPos = defaultPosition; auto evalFloorAt = [&](float x, float y, float refZ) -> std::optional { std::optional terrainH; std::optional wmoH; std::optional m2H; if (terrainManager) { terrainH = terrainManager->getHeightAt(x, y); } // Probe from the highest of terrain, refZ (server position), and defaultPosition.z // so we don't miss WMO floors above terrain (e.g. Stormwind city surface). float floorProbeZ = std::max(terrainH.value_or(refZ), refZ); if (wmoRenderer) { wmoH = wmoRenderer->getFloorHeight(x, y, floorProbeZ + 4.0f); } if (m2Renderer && !externalFollow_) { m2H = m2Renderer->getFloorHeight(x, y, floorProbeZ + 4.0f); } auto h = selectReachableFloor(terrainH, wmoH, refZ, 16.0f); if (!h) { h = selectHighestFloor(terrainH, wmoH, m2H); } return h; }; // Search nearby for a stable, non-steep spawn floor to avoid waterfall/ledge spawns. // In online mode, use a tight search radius since the server dictates position. float bestScore = std::numeric_limits::max(); glm::vec3 bestPos = spawnPos; bool foundBest = false; constexpr float radiiOffline[] = {0.0f, 6.0f, 12.0f, 18.0f, 24.0f, 32.0f}; constexpr float radiiOnline[] = {0.0f, 2.0f}; const float* radii = onlineMode ? radiiOnline : radiiOffline; const int radiiCount = onlineMode ? 2 : 6; constexpr int ANGLES = 16; constexpr float PI = 3.14159265f; for (int ri = 0; ri < radiiCount; ri++) { float r = radii[ri]; int steps = (r <= 0.01f) ? 1 : ANGLES; for (int i = 0; i < steps; i++) { float a = (2.0f * PI * static_cast(i)) / static_cast(steps); float x = defaultPosition.x + r * std::cos(a); float y = defaultPosition.y + r * std::sin(a); auto h = evalFloorAt(x, y, defaultPosition.z); if (!h) continue; // Allow large downward snaps, but avoid snapping onto high roofs/odd geometry. constexpr float MAX_SPAWN_SNAP_UP = 16.0f; if (*h > defaultPosition.z + MAX_SPAWN_SNAP_UP) continue; float score = r * 0.02f; if (terrainManager) { // Penalize steep/unstable spots. int slopeSamples = 0; float slopeAccum = 0.0f; constexpr float off = 2.5f; const float dx[4] = {off, -off, 0.0f, 0.0f}; const float dy[4] = {0.0f, 0.0f, off, -off}; for (int s = 0; s < 4; s++) { auto hn = terrainManager->getHeightAt(x + dx[s], y + dy[s]); if (!hn) continue; slopeAccum += std::abs(*hn - *h); slopeSamples++; } if (slopeSamples > 0) { score += (slopeAccum / static_cast(slopeSamples)) * 2.0f; } } if (waterRenderer) { auto wh = waterRenderer->getWaterHeightAt(x, y); if (wh && *h < *wh - 0.2f) { score += 8.0f; } } if (wmoRenderer) { const glm::vec3 from(x, y, *h + 0.20f); const bool insideWMO = wmoRenderer->isInsideWMO(x, y, *h + 1.5f, nullptr); // Prefer outdoors for default hearth-like spawn points (offline only). // In online mode, trust the server position even if inside a WMO. if (insideWMO && !onlineMode) { score += 120.0f; } // Reject points embedded in nearby walls by probing tiny cardinal moves. int wallHits = 0; constexpr float probeStep = 0.85f; const glm::vec3 probes[4] = { glm::vec3(x + probeStep, y, *h + 0.20f), glm::vec3(x - probeStep, y, *h + 0.20f), glm::vec3(x, y + probeStep, *h + 0.20f), glm::vec3(x, y - probeStep, *h + 0.20f), }; for (const auto& to : probes) { glm::vec3 adjusted; if (wmoRenderer->checkWallCollision(from, to, adjusted)) { wallHits++; } } if (wallHits >= 2) { continue; // Likely wedged in geometry. } if (wallHits == 1) { score += 30.0f; } // If the point is inside a WMO, ensure there is an easy escape path. // If almost all directions are blocked, treat it as invalid spawn. if (insideWMO) { int blocked = 0; constexpr int radialChecks = 12; constexpr float radialDist = 2.2f; for (int ri = 0; ri < radialChecks; ri++) { float ang = (2.0f * PI * static_cast(ri)) / static_cast(radialChecks); glm::vec3 to( x + std::cos(ang) * radialDist, y + std::sin(ang) * radialDist, *h + 0.20f ); glm::vec3 adjusted; if (wmoRenderer->checkWallCollision(from, to, adjusted)) { blocked++; } } if (blocked >= 9) { continue; // Enclosed by interior/wall geometry. } score += static_cast(blocked) * 3.0f; } } if (score < bestScore) { bestScore = score; bestPos = glm::vec3(x, y, *h + 0.05f); foundBest = true; } } } if (foundBest) { spawnPos = bestPos; lastGroundZ = spawnPos.z - 0.05f; } 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; float mountedOffset = mounted_ ? mountHeightOffset_ : 0.0f; glm::vec3 pivot = spawnPos + glm::vec3(0.0f, 0.0f, PIVOT_HEIGHT + mountedOffset); 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 (foundBest) { spawnPos.z += eyeHeight; } smoothedCamPos = spawnPos; camera->setPosition(spawnPos); } LOG_INFO("Camera reset to default position"); } void CameraController::teleportTo(const glm::vec3& pos) { if (!camera) return; verticalVelocity = 0.0f; grounded = true; swimming = false; sitting = false; lastGroundZ = pos.z; noGroundTimer_ = 0.0f; // Reset grace period so terrain has time to stream autoUnstuckFired_ = false; continuousFallTime_ = 0.0f; // Invalidate active WMO group so it's re-detected at new position if (wmoRenderer) { wmoRenderer->updateActiveGroup(pos.x, pos.y, pos.z + 1.0f); } if (thirdPerson && followTarget) { *followTarget = pos; camera->setRotation(yaw, pitch); glm::vec3 forward3D = camera->getForward(); float mountedOffset = mounted_ ? mountHeightOffset_ : 0.0f; glm::vec3 pivot = pos + glm::vec3(0.0f, 0.0f, PIVOT_HEIGHT + mountedOffset); glm::vec3 camDir = -forward3D; glm::vec3 camPos = pivot + camDir * currentDistance; smoothedCamPos = camPos; camera->setPosition(camPos); } else { glm::vec3 camPos = pos + glm::vec3(0.0f, 0.0f, eyeHeight); smoothedCamPos = camPos; camera->setPosition(camPos); } LOG_INFO("Teleported to (", pos.x, ", ", pos.y, ", ", pos.z, ")"); } void CameraController::processMouseWheel(float delta) { // Adjust user's target distance (collision may limit actual distance) userTargetDistance -= delta * 2.0f; // 2.0 units per scroll notch userTargetDistance = glm::clamp(userTargetDistance, MIN_DISTANCE, MAX_DISTANCE); } void CameraController::setFollowTarget(glm::vec3* target) { followTarget = target; if (target) { thirdPerson = true; LOG_INFO("Third-person camera enabled"); } else { thirdPerson = false; LOG_INFO("Free-fly camera enabled"); } } bool CameraController::isMoving() const { if (!enabled || !camera) { return false; } if (externalMoving_) return true; return moveForwardActive || moveBackwardActive || strafeLeftActive || strafeRightActive || autoRunning; } void CameraController::clearMovementInputs() { moveForwardActive = false; moveBackwardActive = false; strafeLeftActive = false; strafeRightActive = false; autoRunning = false; } bool CameraController::isSprinting() const { return enabled && camera && runPace; } } // namespace rendering } // namespace wowee