2026-02-02 12:24:50 -08:00
|
|
|
#include "rendering/camera_controller.hpp"
|
2026-02-18 18:45:08 -08:00
|
|
|
#include <algorithm>
|
2026-02-19 22:34:22 -08:00
|
|
|
#include <imgui.h>
|
2026-02-02 12:24:50 -08:00
|
|
|
#include "rendering/terrain_manager.hpp"
|
|
|
|
|
#include "rendering/wmo_renderer.hpp"
|
2026-02-02 23:03:45 -08:00
|
|
|
#include "rendering/m2_renderer.hpp"
|
2026-02-02 12:24:50 -08:00
|
|
|
#include "rendering/water_renderer.hpp"
|
2026-02-03 14:26:08 -08:00
|
|
|
#include "rendering/character_renderer.hpp"
|
2026-02-02 12:24:50 -08:00
|
|
|
#include "game/opcodes.hpp"
|
|
|
|
|
#include "core/logger.hpp"
|
|
|
|
|
#include <glm/glm.hpp>
|
|
|
|
|
#include <imgui.h>
|
2026-02-03 14:57:06 -08:00
|
|
|
#include <cmath>
|
2026-02-04 17:37:28 -08:00
|
|
|
#include <limits>
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
|
|
|
namespace wowee {
|
|
|
|
|
namespace rendering {
|
|
|
|
|
|
2026-02-03 14:55:32 -08:00
|
|
|
namespace {
|
|
|
|
|
|
|
|
|
|
std::optional<float> selectReachableFloor(const std::optional<float>& terrainH,
|
|
|
|
|
const std::optional<float>& wmoH,
|
|
|
|
|
float refZ,
|
|
|
|
|
float maxStepUp) {
|
2026-02-07 23:50:44 -08:00
|
|
|
// Filter to reachable floors (not too far above)
|
|
|
|
|
std::optional<float> reachTerrain;
|
|
|
|
|
std::optional<float> reachWmo;
|
|
|
|
|
if (terrainH && *terrainH <= refZ + maxStepUp) reachTerrain = terrainH;
|
|
|
|
|
if (wmoH && *wmoH <= refZ + maxStepUp) reachWmo = wmoH;
|
|
|
|
|
|
|
|
|
|
if (reachTerrain && reachWmo) {
|
2026-02-08 17:38:30 -08:00
|
|
|
// Prefer the highest surface — prevents clipping through
|
|
|
|
|
// WMO floors that sit above terrain.
|
|
|
|
|
return (*reachWmo >= *reachTerrain) ? reachWmo : reachTerrain;
|
2026-02-07 23:50:44 -08:00
|
|
|
}
|
|
|
|
|
if (reachWmo) return reachWmo;
|
|
|
|
|
if (reachTerrain) return reachTerrain;
|
|
|
|
|
return std::nullopt;
|
2026-02-03 14:55:32 -08:00
|
|
|
}
|
|
|
|
|
|
2026-02-03 21:11:10 -08:00
|
|
|
std::optional<float> selectHighestFloor(const std::optional<float>& a,
|
|
|
|
|
const std::optional<float>& b,
|
|
|
|
|
const std::optional<float>& c) {
|
|
|
|
|
std::optional<float> best;
|
|
|
|
|
auto consider = [&](const std::optional<float>& h) {
|
|
|
|
|
if (!h) return;
|
|
|
|
|
if (!best || *h > *best) best = *h;
|
|
|
|
|
};
|
|
|
|
|
consider(a);
|
|
|
|
|
consider(b);
|
|
|
|
|
consider(c);
|
|
|
|
|
return best;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 00:04:53 -08:00
|
|
|
std::optional<float> selectClosestFloor(const std::optional<float>& a,
|
|
|
|
|
const std::optional<float>& b,
|
|
|
|
|
float refZ) {
|
|
|
|
|
if (a && b) {
|
|
|
|
|
float da = std::abs(*a - refZ);
|
|
|
|
|
float db = std::abs(*b - refZ);
|
|
|
|
|
return (da <= db) ? a : b;
|
|
|
|
|
}
|
|
|
|
|
if (a) return a;
|
|
|
|
|
if (b) return b;
|
|
|
|
|
return std::nullopt;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-03 14:55:32 -08:00
|
|
|
} // namespace
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
CameraController::CameraController(Camera* cam) : camera(cam) {
|
|
|
|
|
yaw = defaultYaw;
|
2026-02-03 19:49:56 -08:00
|
|
|
facingYaw = defaultYaw;
|
2026-02-02 12:24:50 -08:00
|
|
|
pitch = defaultPitch;
|
|
|
|
|
reset();
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-05 18:06:52 -08:00
|
|
|
void CameraController::startIntroPan(float durationSec, float orbitDegrees) {
|
|
|
|
|
if (!camera) return;
|
|
|
|
|
introActive = true;
|
|
|
|
|
introTimer = 0.0f;
|
2026-02-06 13:47:03 -08:00
|
|
|
idleTimer_ = 0.0f;
|
2026-02-05 18:06:52 -08:00
|
|
|
introDuration = std::max(0.5f, durationSec);
|
Fix camera orbit, deselect, chat formatting, loot/vendor bugs, critter hostility, and character screen
Smooth idle camera orbit without jump at loop boundary, click empty space to
deselect target, auto-target when attacked, fix critter hostility so neutral
factions aren't flagged red, add armor/stats to item templates, fix loot
iterator invalidation, show item template names as fallback, position drop
confirmation at cursor, remove [SYSTEM] chat prefix, show NPC names in monster
say/yell, and prevent auto-login on character select screen.
2026-02-06 16:40:44 -08:00
|
|
|
introStartYaw = yaw;
|
|
|
|
|
introEndYaw = yaw - orbitDegrees;
|
2026-02-05 18:06:52 -08:00
|
|
|
introOrbitDegrees = orbitDegrees;
|
Fix camera orbit, deselect, chat formatting, loot/vendor bugs, critter hostility, and character screen
Smooth idle camera orbit without jump at loop boundary, click empty space to
deselect target, auto-target when attacked, fix critter hostility so neutral
factions aren't flagged red, add armor/stats to item templates, fix loot
iterator invalidation, show item template names as fallback, position drop
confirmation at cursor, remove [SYSTEM] chat prefix, show NPC names in monster
say/yell, and prevent auto-login on character select screen.
2026-02-06 16:40:44 -08:00
|
|
|
introStartPitch = pitch;
|
|
|
|
|
introEndPitch = pitch;
|
|
|
|
|
introStartDistance = currentDistance;
|
|
|
|
|
introEndDistance = currentDistance;
|
2026-02-05 18:06:52 -08:00
|
|
|
thirdPerson = true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-10 19:30:45 -08:00
|
|
|
std::optional<float> CameraController::getCachedFloorHeight(float x, float y, float z) {
|
|
|
|
|
// Check cache validity (position within threshold and frame count)
|
|
|
|
|
glm::vec2 queryPos(x, y);
|
|
|
|
|
glm::vec2 cachedPos(lastFloorQueryPos.x, lastFloorQueryPos.y);
|
|
|
|
|
float dist = glm::length(queryPos - cachedPos);
|
|
|
|
|
|
|
|
|
|
if (dist < FLOOR_QUERY_DISTANCE_THRESHOLD && floorQueryFrameCounter < FLOOR_QUERY_FRAME_INTERVAL) {
|
|
|
|
|
floorQueryFrameCounter++;
|
|
|
|
|
return cachedFloorHeight;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Cache miss - query and update
|
|
|
|
|
floorQueryFrameCounter = 0;
|
|
|
|
|
lastFloorQueryPos = glm::vec3(x, y, z);
|
|
|
|
|
|
|
|
|
|
std::optional<float> result;
|
|
|
|
|
if (terrainManager) {
|
|
|
|
|
result = terrainManager->getHeightAt(x, y);
|
|
|
|
|
}
|
|
|
|
|
if (wmoRenderer) {
|
|
|
|
|
auto wh = wmoRenderer->getFloorHeight(x, y, z + 2.0f);
|
|
|
|
|
if (wh && (!result || *wh > *result)) result = wh;
|
|
|
|
|
}
|
|
|
|
|
if (m2Renderer && !externalFollow_) {
|
|
|
|
|
auto mh = m2Renderer->getFloorHeight(x, y, z);
|
|
|
|
|
if (mh && (!result || *mh > *result)) result = mh;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cachedFloorHeight = result;
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
void CameraController::update(float deltaTime) {
|
|
|
|
|
if (!enabled || !camera) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-11 19:28:15 -08:00
|
|
|
// During taxi flights, skip movement logic but keep camera orbit/zoom controls.
|
2026-02-08 22:33:45 -08:00
|
|
|
if (externalFollow_) {
|
2026-02-17 02:23:41 -08:00
|
|
|
// Cancel any active intro/idle orbit so mouse panning works during taxi.
|
|
|
|
|
// The intro handling code (below) is unreachable during externalFollow_.
|
|
|
|
|
introActive = false;
|
|
|
|
|
idleOrbit_ = false;
|
|
|
|
|
idleTimer_ = 0.0f;
|
|
|
|
|
|
2026-02-11 19:28:15 -08:00
|
|
|
camera->setRotation(yaw, pitch);
|
|
|
|
|
float zoomLerp = 1.0f - std::exp(-ZOOM_SMOOTH_SPEED * deltaTime);
|
|
|
|
|
currentDistance += (userTargetDistance - currentDistance) * zoomLerp;
|
|
|
|
|
collisionDistance = currentDistance;
|
2026-02-08 23:15:26 -08:00
|
|
|
|
|
|
|
|
// Position camera behind character during taxi
|
|
|
|
|
if (thirdPerson && followTarget) {
|
|
|
|
|
glm::vec3 targetPos = *followTarget;
|
|
|
|
|
glm::vec3 forward3D = camera->getForward();
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
glm::vec3 camDir = -forward3D;
|
|
|
|
|
|
|
|
|
|
// Use current distance
|
|
|
|
|
float actualDist = std::min(currentDistance, collisionDistance);
|
|
|
|
|
|
|
|
|
|
// Compute camera position
|
|
|
|
|
glm::vec3 actualCam;
|
|
|
|
|
if (actualDist < MIN_DISTANCE + 0.1f) {
|
|
|
|
|
actualCam = pivot + forward3D * 0.1f;
|
|
|
|
|
} else {
|
|
|
|
|
actualCam = pivot + camDir * actualDist;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Smooth camera position
|
|
|
|
|
if (glm::length(smoothedCamPos) < 0.01f) {
|
|
|
|
|
smoothedCamPos = actualCam;
|
|
|
|
|
}
|
|
|
|
|
float camLerp = 1.0f - std::exp(-CAM_SMOOTH_SPEED * deltaTime);
|
|
|
|
|
smoothedCamPos += (actualCam - smoothedCamPos) * camLerp;
|
|
|
|
|
|
|
|
|
|
camera->setPosition(smoothedCamPos);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 22:33:45 -08:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
auto& input = core::Input::getInstance();
|
|
|
|
|
|
2026-02-06 18:34:45 -08:00
|
|
|
// Don't process keyboard input when UI text input (e.g. chat box) has focus
|
|
|
|
|
bool uiWantsKeyboard = ImGui::GetIO().WantTextInput;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
|
|
|
// Determine current key states
|
2026-02-03 14:55:32 -08:00
|
|
|
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));
|
2026-02-07 18:57:27 -08:00
|
|
|
bool nowJump = !uiWantsKeyboard && !sitting && input.isKeyJustPressed(SDL_SCANCODE_SPACE);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
2026-02-06 13:47:03 -08:00
|
|
|
// 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;
|
Fix camera orbit, deselect, chat formatting, loot/vendor bugs, critter hostility, and character screen
Smooth idle camera orbit without jump at loop boundary, click empty space to
deselect target, auto-target when attacked, fix critter hostility so neutral
factions aren't flagged red, add armor/stats to item templates, fix loot
iterator invalidation, show item template names as fallback, position drop
confirmation at cursor, remove [SYSTEM] chat prefix, show NPC names in monster
say/yell, and prevent auto-login on character select screen.
2026-02-06 16:40:44 -08:00
|
|
|
startIntroPan(30.0f, 360.0f); // Slow casual orbit over 30 seconds
|
2026-02-06 13:47:03 -08:00
|
|
|
idleOrbit_ = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-05 18:06:52 -08:00
|
|
|
if (introActive) {
|
2026-02-06 13:47:03 -08:00
|
|
|
if (anyInput) {
|
2026-02-05 18:06:52 -08:00
|
|
|
introActive = false;
|
2026-02-06 13:47:03 -08:00
|
|
|
idleOrbit_ = false;
|
|
|
|
|
idleTimer_ = 0.0f;
|
2026-02-05 18:06:52 -08:00
|
|
|
} else {
|
|
|
|
|
introTimer += deltaTime;
|
Fix camera orbit, deselect, chat formatting, loot/vendor bugs, critter hostility, and character screen
Smooth idle camera orbit without jump at loop boundary, click empty space to
deselect target, auto-target when attacked, fix critter hostility so neutral
factions aren't flagged red, add armor/stats to item templates, fix loot
iterator invalidation, show item template names as fallback, position drop
confirmation at cursor, remove [SYSTEM] chat prefix, show NPC names in monster
say/yell, and prevent auto-login on character select screen.
2026-02-06 16:40:44 -08:00
|
|
|
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) {
|
2026-02-06 13:47:03 -08:00
|
|
|
introActive = false;
|
|
|
|
|
}
|
2026-02-05 18:06:52 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Suppress player movement/input during intro.
|
|
|
|
|
keyW = keyS = keyA = keyD = keyQ = keyE = nowJump = false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-07 16:05:13 -08:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-03 14:55:32 -08:00
|
|
|
bool mouseAutorun = !uiWantsKeyboard && !sitting && leftMouseDown && rightMouseDown;
|
2026-02-07 23:47:43 -08:00
|
|
|
if (mouseAutorun) {
|
|
|
|
|
autoRunning = false;
|
|
|
|
|
}
|
2026-02-07 16:05:13 -08:00
|
|
|
bool nowForward = keyW || mouseAutorun || autoRunning;
|
2026-02-03 14:55:32 -08:00
|
|
|
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);
|
2026-02-03 19:49:56 -08:00
|
|
|
facingYaw = yaw;
|
2026-02-03 14:55:32 -08:00
|
|
|
}
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
// 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) {
|
2026-02-03 14:55:32 -08:00
|
|
|
// Movement speeds (WoW-like: Ctrl walk, default run, backpedal slower)
|
2026-02-02 12:24:50 -08:00
|
|
|
if (nowBackward && !nowForward) {
|
|
|
|
|
speed = WOW_BACK_SPEED;
|
2026-02-03 14:55:32 -08:00
|
|
|
} else if (ctrlDown) {
|
|
|
|
|
speed = WOW_WALK_SPEED;
|
2026-02-07 18:33:14 -08:00
|
|
|
} else if (runSpeedOverride_ > 0.0f && runSpeedOverride_ < 100.0f && !std::isnan(runSpeedOverride_)) {
|
|
|
|
|
speed = runSpeedOverride_;
|
2026-02-02 12:24:50 -08:00
|
|
|
} else {
|
2026-02-07 18:33:14 -08:00
|
|
|
speed = WOW_RUN_SPEED;
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// Exploration mode (original behavior)
|
|
|
|
|
speed = movementSpeed;
|
2026-02-03 14:55:32 -08:00
|
|
|
if (shiftDown) {
|
2026-02-02 12:24:50 -08:00
|
|
|
speed *= sprintMultiplier;
|
|
|
|
|
}
|
2026-02-03 14:55:32 -08:00
|
|
|
if (ctrlDown) {
|
2026-02-02 12:24:50 -08:00
|
|
|
speed *= slowMultiplier;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-03 14:55:32 -08:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
// Get camera axes — project forward onto XY plane for walking
|
|
|
|
|
glm::vec3 forward3D = camera->getForward();
|
2026-02-03 19:49:56 -08:00
|
|
|
bool cameraDrivesFacing = rightMouseDown || mouseAutorun;
|
2026-02-08 22:00:33 -08:00
|
|
|
// During taxi flights, orientation is controlled by the flight path, not player input
|
|
|
|
|
if (cameraDrivesFacing && !externalFollow_) {
|
2026-02-03 19:49:56 -08:00
|
|
|
facingYaw = yaw;
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
2026-02-03 19:49:56 -08:00
|
|
|
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);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
2026-02-04 13:29:27 -08:00
|
|
|
// Toggle sit/crouch with X key (edge-triggered) — only when UI doesn't want keyboard
|
2026-02-07 17:59:40 -08:00
|
|
|
// Blocked while mounted
|
2026-02-04 13:29:27 -08:00
|
|
|
bool xDown = !uiWantsKeyboard && input.isKeyPressed(SDL_SCANCODE_X);
|
2026-02-07 17:59:40 -08:00
|
|
|
if (xDown && !xKeyWasDown && !mounted_) {
|
2026-02-02 12:24:50 -08:00
|
|
|
sitting = !sitting;
|
|
|
|
|
}
|
2026-02-07 17:59:40 -08:00
|
|
|
if (mounted_) sitting = false;
|
2026-02-02 12:24:50 -08:00
|
|
|
xKeyWasDown = xDown;
|
|
|
|
|
|
2026-02-02 23:10:19 -08:00
|
|
|
// 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);
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
// Calculate horizontal movement vector
|
|
|
|
|
glm::vec3 movement(0.0f);
|
|
|
|
|
|
|
|
|
|
if (nowForward) movement += forward;
|
|
|
|
|
if (nowBackward) movement -= forward;
|
2026-02-03 19:56:25 -08:00
|
|
|
if (nowStrafeLeft) movement += right;
|
|
|
|
|
if (nowStrafeRight) movement -= right;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
2026-02-02 23:10:19 -08:00
|
|
|
// Stand up if jumping while crouched
|
|
|
|
|
if (!uiWantsKeyboard && sitting && input.isKeyPressed(SDL_SCANCODE_SPACE)) {
|
2026-02-02 12:24:50 -08:00
|
|
|
sitting = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Third-person orbit camera mode
|
|
|
|
|
if (thirdPerson && followTarget) {
|
|
|
|
|
// Move the follow target (character position) instead of the camera
|
|
|
|
|
glm::vec3 targetPos = *followTarget;
|
2026-02-08 03:05:38 -08:00
|
|
|
if (!externalFollow_) {
|
|
|
|
|
if (wmoRenderer) {
|
|
|
|
|
wmoRenderer->setCollisionFocus(targetPos, COLLISION_FOCUS_RADIUS_THIRD_PERSON);
|
|
|
|
|
}
|
|
|
|
|
if (m2Renderer) {
|
|
|
|
|
m2Renderer->setCollisionFocus(targetPos, COLLISION_FOCUS_RADIUS_THIRD_PERSON);
|
|
|
|
|
}
|
2026-02-03 16:21:48 -08:00
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
2026-02-08 03:05:38 -08:00
|
|
|
if (!externalFollow_) {
|
2026-02-19 02:46:52 -08:00
|
|
|
// Enter swim only when water is deep enough (waist-deep+),
|
|
|
|
|
// not for shallow wading.
|
2026-02-08 03:05:38 -08:00
|
|
|
std::optional<float> waterH;
|
|
|
|
|
if (waterRenderer) {
|
|
|
|
|
waterH = waterRenderer->getWaterHeightAt(targetPos.x, targetPos.y);
|
|
|
|
|
}
|
2026-02-19 02:46:52 -08:00
|
|
|
constexpr float MAX_SWIM_DEPTH_FROM_SURFACE = 12.0f;
|
|
|
|
|
constexpr float MIN_SWIM_WATER_DEPTH = 1.0f;
|
|
|
|
|
bool inWater = false;
|
|
|
|
|
if (waterH && targetPos.z < *waterH) {
|
|
|
|
|
std::optional<uint16_t> waterType;
|
|
|
|
|
if (waterRenderer) {
|
|
|
|
|
waterType = waterRenderer->getWaterTypeAt(targetPos.x, targetPos.y);
|
|
|
|
|
}
|
|
|
|
|
bool isOcean = false;
|
|
|
|
|
if (waterType && *waterType != 0) {
|
|
|
|
|
isOcean = (((*waterType - 1) % 4) == 1);
|
|
|
|
|
}
|
|
|
|
|
bool depthAllowed = isOcean || ((*waterH - targetPos.z) <= MAX_SWIM_DEPTH_FROM_SURFACE);
|
|
|
|
|
if (depthAllowed) {
|
|
|
|
|
std::optional<float> terrainH;
|
|
|
|
|
std::optional<float> wmoH;
|
|
|
|
|
std::optional<float> m2H;
|
|
|
|
|
if (terrainManager) terrainH = terrainManager->getHeightAt(targetPos.x, targetPos.y);
|
|
|
|
|
if (wmoRenderer) wmoH = wmoRenderer->getFloorHeight(targetPos.x, targetPos.y, targetPos.z + 2.0f);
|
|
|
|
|
if (m2Renderer) m2H = m2Renderer->getFloorHeight(targetPos.x, targetPos.y, targetPos.z + 1.0f);
|
|
|
|
|
auto floorH = selectHighestFloor(terrainH, wmoH, m2H);
|
|
|
|
|
|
|
|
|
|
// Prefer measured depth from floor; if floor sample is missing,
|
|
|
|
|
// fall back to feet-to-surface depth.
|
|
|
|
|
float depthFromFeet = (*waterH - targetPos.z);
|
|
|
|
|
inWater = (floorH && ((*waterH - *floorH) >= MIN_SWIM_WATER_DEPTH)) ||
|
|
|
|
|
(!floorH && (depthFromFeet >= MIN_SWIM_WATER_DEPTH));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Keep swimming through water-data gaps at chunk boundaries.
|
2026-02-08 03:05:38 -08:00
|
|
|
if (!inWater && swimming && !waterH) {
|
|
|
|
|
inWater = true;
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
|
|
|
|
2026-02-08 03:05:38 -08:00
|
|
|
if (inWater) {
|
2026-02-02 12:24:50 -08:00
|
|
|
swimming = true;
|
2026-02-03 19:56:25 -08:00
|
|
|
// Swim movement follows look pitch (forward/back), while strafe stays
|
|
|
|
|
// lateral for stable control.
|
2026-02-02 12:24:50 -08:00
|
|
|
float swimSpeed = speed * SWIM_SPEED_FACTOR;
|
2026-02-03 20:40:59 -08:00
|
|
|
float waterSurfaceZ = waterH ? (*waterH - WATER_SURFACE_OFFSET) : targetPos.z;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
2026-02-10 19:30:45 -08:00
|
|
|
// For auto-run/auto-swim: use character facing (immune to camera pan)
|
|
|
|
|
// For manual W key: use camera direction (swim where you look)
|
|
|
|
|
glm::vec3 swimForward;
|
|
|
|
|
if (autoRunning || (leftMouseDown && rightMouseDown)) {
|
|
|
|
|
// Auto-running: use character's horizontal facing direction
|
2026-02-03 19:56:25 -08:00
|
|
|
swimForward = forward;
|
|
|
|
|
} else {
|
2026-02-10 19:30:45 -08:00
|
|
|
// Manual control: use camera's 3D direction (swim where you look)
|
|
|
|
|
swimForward = glm::normalize(forward3D);
|
|
|
|
|
if (glm::length(swimForward) < 1e-4f) {
|
|
|
|
|
swimForward = forward;
|
|
|
|
|
}
|
2026-02-03 19:56:25 -08:00
|
|
|
}
|
2026-02-10 19:30:45 -08:00
|
|
|
// Use character's facing direction for strafe, not camera's right vector
|
|
|
|
|
glm::vec3 swimRight = right; // Character's right (horizontal facing), not camera's
|
2026-02-03 19:56:25 -08:00
|
|
|
|
|
|
|
|
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;
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Spacebar = swim up (continuous, not a jump)
|
2026-02-03 20:40:59 -08:00
|
|
|
bool diveIntent = nowForward && (forward3D.z < -0.28f);
|
2026-02-02 12:24:50 -08:00
|
|
|
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;
|
|
|
|
|
}
|
2026-02-03 20:40:59 -08:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-03 19:49:56 -08:00
|
|
|
// Prevent sinking/clipping through world floor while swimming.
|
2026-02-10 19:30:45 -08:00
|
|
|
// Cache floor queries (update every 3 frames or 1 unit movement)
|
2026-02-03 19:49:56 -08:00
|
|
|
std::optional<float> floorH;
|
2026-02-10 19:30:45 -08:00
|
|
|
float dist2D = glm::length(glm::vec2(targetPos.x - lastFloorQueryPos.x,
|
|
|
|
|
targetPos.y - lastFloorQueryPos.y));
|
|
|
|
|
bool updateFloorCache = (floorQueryFrameCounter++ >= FLOOR_QUERY_FRAME_INTERVAL) ||
|
|
|
|
|
(dist2D > FLOOR_QUERY_DISTANCE_THRESHOLD);
|
|
|
|
|
|
|
|
|
|
if (updateFloorCache) {
|
|
|
|
|
floorQueryFrameCounter = 0;
|
|
|
|
|
lastFloorQueryPos = targetPos;
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cachedFloorHeight = floorH;
|
|
|
|
|
} else {
|
|
|
|
|
floorH = cachedFloorHeight;
|
2026-02-03 19:49:56 -08:00
|
|
|
}
|
|
|
|
|
if (floorH) {
|
2026-02-04 13:29:27 -08:00
|
|
|
float swimFloor = *floorH + 0.5f;
|
2026-02-03 19:49:56 -08:00
|
|
|
if (targetPos.z < swimFloor) {
|
|
|
|
|
targetPos.z = swimFloor;
|
|
|
|
|
if (verticalVelocity < 0.0f) verticalVelocity = 0.0f;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-07 15:54:33 -08:00
|
|
|
// Enforce collision while swimming too (horizontal only), skip when stationary.
|
2026-02-03 19:56:25 -08:00
|
|
|
{
|
|
|
|
|
glm::vec3 swimFrom = *followTarget;
|
|
|
|
|
glm::vec3 swimTo = targetPos;
|
|
|
|
|
float swimMoveDist = glm::length(swimTo - swimFrom);
|
|
|
|
|
glm::vec3 stepPos = swimFrom;
|
|
|
|
|
|
2026-02-07 15:54:33 -08:00
|
|
|
if (swimMoveDist > 0.01f) {
|
2026-02-07 16:00:57 -08:00
|
|
|
int swimSteps = std::max(1, std::min(3, static_cast<int>(std::ceil(swimMoveDist / 0.65f))));
|
2026-02-07 15:54:33 -08:00
|
|
|
glm::vec3 stepDelta = (swimTo - swimFrom) / static_cast<float>(swimSteps);
|
2026-02-03 19:56:25 -08:00
|
|
|
|
2026-02-07 15:54:33 -08:00
|
|
|
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;
|
2026-02-08 17:38:30 -08:00
|
|
|
candidate.z = std::max(candidate.z, adjusted.z);
|
2026-02-07 15:54:33 -08:00
|
|
|
}
|
2026-02-03 19:56:25 -08:00
|
|
|
}
|
|
|
|
|
|
2026-02-08 20:45:59 -08:00
|
|
|
if (m2Renderer && !externalFollow_) {
|
2026-02-07 15:54:33 -08:00
|
|
|
glm::vec3 adjusted;
|
|
|
|
|
if (m2Renderer->checkCollision(stepPos, candidate, adjusted)) {
|
|
|
|
|
candidate.x = adjusted.x;
|
|
|
|
|
candidate.y = adjusted.y;
|
|
|
|
|
}
|
2026-02-03 19:56:25 -08:00
|
|
|
}
|
|
|
|
|
|
2026-02-07 15:54:33 -08:00
|
|
|
stepPos = candidate;
|
|
|
|
|
}
|
2026-02-03 19:56:25 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
targetPos.x = stepPos.x;
|
|
|
|
|
targetPos.y = stepPos.y;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
grounded = false;
|
2026-02-08 03:05:38 -08:00
|
|
|
} else {
|
2026-02-04 13:29:27 -08:00
|
|
|
// Exiting water — give a small upward boost to help climb onto shore.
|
2026-02-02 12:24:50 -08:00
|
|
|
swimming = false;
|
|
|
|
|
|
|
|
|
|
if (glm::length(movement) > 0.001f) {
|
|
|
|
|
movement = glm::normalize(movement);
|
|
|
|
|
targetPos += movement * speed * deltaTime;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-04 13:29:27 -08:00
|
|
|
// Jump with input buffering and coyote time
|
|
|
|
|
if (nowJump) jumpBufferTimer = JUMP_BUFFER_TIME;
|
|
|
|
|
if (grounded) coyoteTimer = COYOTE_TIME;
|
|
|
|
|
|
2026-02-10 19:30:45 -08:00
|
|
|
bool canJump = (coyoteTimer > 0.0f) && (jumpBufferTimer > 0.0f) && !mounted_;
|
2026-02-04 13:29:27 -08:00
|
|
|
if (canJump) {
|
2026-02-02 12:24:50 -08:00
|
|
|
verticalVelocity = jumpVel;
|
|
|
|
|
grounded = false;
|
2026-02-04 13:29:27 -08:00
|
|
|
jumpBufferTimer = 0.0f;
|
|
|
|
|
coyoteTimer = 0.0f;
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
|
2026-02-04 13:29:27 -08:00
|
|
|
jumpBufferTimer -= deltaTime;
|
|
|
|
|
coyoteTimer -= deltaTime;
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
// Apply gravity
|
|
|
|
|
verticalVelocity += gravity * deltaTime;
|
|
|
|
|
targetPos.z += verticalVelocity * deltaTime;
|
2026-02-08 03:05:38 -08:00
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// External follow (e.g., taxi): trust server position without grounding.
|
|
|
|
|
swimming = false;
|
|
|
|
|
grounded = true;
|
|
|
|
|
verticalVelocity = 0.0f;
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
|
2026-02-12 00:04:53 -08:00
|
|
|
// Refresh inside-WMO state before collision/grounding so we don't use stale
|
|
|
|
|
// terrain-first caches while entering enclosed tunnel/building spaces.
|
|
|
|
|
if (wmoRenderer && !externalFollow_) {
|
|
|
|
|
bool prevInside = cachedInsideWMO;
|
|
|
|
|
bool prevInsideInterior = cachedInsideInteriorWMO;
|
|
|
|
|
cachedInsideWMO = wmoRenderer->isInsideWMO(targetPos.x, targetPos.y, targetPos.z + 1.0f, nullptr);
|
|
|
|
|
cachedInsideInteriorWMO = wmoRenderer->isInsideInteriorWMO(targetPos.x, targetPos.y, targetPos.z + 1.0f);
|
|
|
|
|
if (cachedInsideWMO != prevInside || cachedInsideInteriorWMO != prevInsideInterior) {
|
|
|
|
|
hasCachedFloor_ = false;
|
|
|
|
|
hasCachedCamFloor = false;
|
|
|
|
|
cachedPivotLift_ = 0.0f;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-03 14:57:06 -08:00
|
|
|
// Sweep collisions in small steps to reduce tunneling through thin walls/floors.
|
2026-02-07 15:54:33 -08:00
|
|
|
// Skip entirely when stationary to avoid wasting collision calls.
|
2026-02-08 20:20:37 -08:00
|
|
|
// Use tighter steps when inside WMO for more precise collision.
|
2026-02-03 14:57:06 -08:00
|
|
|
{
|
|
|
|
|
glm::vec3 startPos = *followTarget;
|
|
|
|
|
glm::vec3 desiredPos = targetPos;
|
2026-02-03 16:21:48 -08:00
|
|
|
float moveDist = glm::length(desiredPos - startPos);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
2026-02-07 15:54:33 -08:00
|
|
|
if (moveDist > 0.01f) {
|
2026-02-08 20:20:37 -08:00
|
|
|
// 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<int>(std::ceil(moveDist / stepSize))));
|
2026-02-07 15:54:33 -08:00
|
|
|
glm::vec3 stepPos = startPos;
|
|
|
|
|
glm::vec3 stepDelta = (desiredPos - startPos) / static_cast<float>(sweepSteps);
|
2026-02-03 14:57:06 -08:00
|
|
|
|
2026-02-07 15:54:33 -08:00
|
|
|
for (int i = 0; i < sweepSteps; i++) {
|
|
|
|
|
glm::vec3 candidate = stepPos + stepDelta;
|
|
|
|
|
|
|
|
|
|
if (wmoRenderer) {
|
|
|
|
|
glm::vec3 adjusted;
|
2026-02-08 20:20:37 -08:00
|
|
|
if (wmoRenderer->checkWallCollision(stepPos, candidate, adjusted, cachedInsideWMO)) {
|
2026-02-08 17:38:30 -08:00
|
|
|
candidate.x = adjusted.x;
|
|
|
|
|
candidate.y = adjusted.y;
|
|
|
|
|
// Accept upward Z correction (ramps), reject downward
|
|
|
|
|
candidate.z = std::max(candidate.z, adjusted.z);
|
2026-02-07 15:54:33 -08:00
|
|
|
}
|
2026-02-03 14:57:06 -08:00
|
|
|
}
|
|
|
|
|
|
2026-02-08 20:45:59 -08:00
|
|
|
if (m2Renderer && !externalFollow_) {
|
2026-02-07 15:54:33 -08:00
|
|
|
glm::vec3 adjusted;
|
|
|
|
|
if (m2Renderer->checkCollision(stepPos, candidate, adjusted)) {
|
|
|
|
|
candidate.x = adjusted.x;
|
|
|
|
|
candidate.y = adjusted.y;
|
|
|
|
|
}
|
2026-02-03 14:57:06 -08:00
|
|
|
}
|
2026-02-07 15:54:33 -08:00
|
|
|
|
|
|
|
|
stepPos = candidate;
|
2026-02-03 14:57:06 -08:00
|
|
|
}
|
|
|
|
|
|
2026-02-07 15:54:33 -08:00
|
|
|
targetPos = stepPos;
|
2026-02-02 23:03:45 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
// Ground the character to terrain or WMO floor
|
2026-02-04 13:29:27 -08:00
|
|
|
// Skip entirely while swimming — the swim floor clamp handles vertical bounds.
|
|
|
|
|
if (!swimming) {
|
2026-02-08 00:18:47 -08:00
|
|
|
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.
|
2026-02-12 00:04:53 -08:00
|
|
|
// Slope limit: reject surfaces too steep to walk (prevent clipping).
|
|
|
|
|
// WMO tunnel/bridge ramps are often steeper than outdoor terrain ramps.
|
|
|
|
|
constexpr float MIN_WALKABLE_NORMAL_TERRAIN = 0.7f; // ~45°
|
|
|
|
|
constexpr float MIN_WALKABLE_NORMAL_WMO = 0.45f; // allow tunnel ramps
|
2026-02-10 20:45:25 -08:00
|
|
|
|
2026-02-08 00:18:47 -08:00
|
|
|
std::optional<float> groundH;
|
2026-02-12 00:04:53 -08:00
|
|
|
std::optional<float> centerTerrainH;
|
|
|
|
|
std::optional<float> centerWmoH;
|
2026-02-08 00:18:47 -08:00
|
|
|
{
|
2026-02-08 22:30:37 -08:00
|
|
|
// Collision cache: skip expensive checks if barely moved (15cm threshold)
|
|
|
|
|
float distMoved = glm::length(glm::vec2(targetPos.x, targetPos.y) -
|
|
|
|
|
glm::vec2(lastCollisionCheckPos_.x, lastCollisionCheckPos_.y));
|
|
|
|
|
bool useCached = hasCachedFloor_ && distMoved < COLLISION_CACHE_DISTANCE;
|
2026-02-12 00:04:53 -08:00
|
|
|
if (useCached) {
|
|
|
|
|
// Never trust cached ground while actively descending or when
|
|
|
|
|
// vertical drift from cached floor is meaningful.
|
|
|
|
|
float dzCached = std::abs(targetPos.z - cachedFloorHeight_);
|
|
|
|
|
if (verticalVelocity < -0.4f || dzCached > 0.35f) {
|
|
|
|
|
useCached = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-08 22:30:37 -08:00
|
|
|
|
|
|
|
|
if (useCached) {
|
|
|
|
|
groundH = cachedFloorHeight_;
|
|
|
|
|
} else {
|
|
|
|
|
// Full collision check
|
|
|
|
|
std::optional<float> terrainH;
|
|
|
|
|
std::optional<float> wmoH;
|
|
|
|
|
if (terrainManager) {
|
|
|
|
|
terrainH = terrainManager->getHeightAt(targetPos.x, targetPos.y);
|
|
|
|
|
}
|
|
|
|
|
float wmoProbeZ = std::max(targetPos.z, lastGroundZ) + stepUpBudget + 0.5f;
|
2026-02-10 20:45:25 -08:00
|
|
|
float wmoNormalZ = 1.0f;
|
2026-02-08 22:30:37 -08:00
|
|
|
if (wmoRenderer) {
|
2026-02-10 20:45:25 -08:00
|
|
|
wmoH = wmoRenderer->getFloorHeight(targetPos.x, targetPos.y, wmoProbeZ, &wmoNormalZ);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Reject steep WMO slopes
|
2026-02-12 00:04:53 -08:00
|
|
|
float minWalkableWmo = cachedInsideWMO ? MIN_WALKABLE_NORMAL_WMO : MIN_WALKABLE_NORMAL_TERRAIN;
|
|
|
|
|
if (wmoH && wmoNormalZ < minWalkableWmo) {
|
2026-02-10 20:45:25 -08:00
|
|
|
wmoH = std::nullopt; // Treat as unwalkable
|
2026-02-08 22:30:37 -08:00
|
|
|
}
|
2026-02-12 00:04:53 -08:00
|
|
|
centerTerrainH = terrainH;
|
|
|
|
|
centerWmoH = wmoH;
|
|
|
|
|
|
|
|
|
|
// Guard against extremely bad WMO void ramps, but keep normal tunnel
|
|
|
|
|
// transitions valid. Only reject when the WMO sample is implausibly far
|
|
|
|
|
// below terrain and player is not already descending.
|
|
|
|
|
if (terrainH && wmoH) {
|
|
|
|
|
float terrainMinusWmo = *terrainH - *wmoH;
|
|
|
|
|
if (terrainMinusWmo > 12.0f && verticalVelocity > -8.0f) {
|
|
|
|
|
wmoH = std::nullopt;
|
|
|
|
|
centerWmoH = std::nullopt;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-10 20:45:25 -08:00
|
|
|
|
2026-02-12 00:04:53 -08:00
|
|
|
if (cachedInsideWMO && wmoH) {
|
|
|
|
|
// Transition seam (e.g. tunnel mouths): if terrain is much higher than
|
|
|
|
|
// nearby WMO walkable floor, prefer the WMO floor so we can enter.
|
|
|
|
|
bool preferWmoAtSeam = false;
|
|
|
|
|
if (terrainH) {
|
|
|
|
|
float terrainAboveWmo = *terrainH - *wmoH;
|
|
|
|
|
float wmoDropFromPlayer = targetPos.z - *wmoH;
|
|
|
|
|
float playerVsTerrain = targetPos.z - *terrainH;
|
|
|
|
|
bool descendingIntoTunnel = (verticalVelocity < -1.0f) || (playerVsTerrain < -0.35f);
|
|
|
|
|
if (terrainAboveWmo > 1.2f && terrainAboveWmo < 8.0f &&
|
|
|
|
|
wmoDropFromPlayer >= -0.4f && wmoDropFromPlayer < 1.8f &&
|
|
|
|
|
*wmoH <= targetPos.z + stepUpBudget &&
|
|
|
|
|
descendingIntoTunnel) {
|
|
|
|
|
preferWmoAtSeam = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (preferWmoAtSeam) {
|
|
|
|
|
groundH = wmoH;
|
|
|
|
|
} else if (terrainH) {
|
|
|
|
|
// At tunnel seams where both exist, pick the one closest to current feet Z
|
|
|
|
|
// to avoid oscillating between top terrain and deep WMO floors.
|
|
|
|
|
groundH = selectClosestFloor(terrainH, wmoH, targetPos.z);
|
|
|
|
|
} else {
|
|
|
|
|
groundH = selectReachableFloor(terrainH, wmoH, targetPos.z, stepUpBudget);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
groundH = selectReachableFloor(terrainH, wmoH, targetPos.z, stepUpBudget);
|
|
|
|
|
}
|
2026-02-08 22:30:37 -08:00
|
|
|
|
|
|
|
|
// Update cache
|
|
|
|
|
lastCollisionCheckPos_ = targetPos;
|
|
|
|
|
if (groundH) {
|
|
|
|
|
cachedFloorHeight_ = *groundH;
|
|
|
|
|
hasCachedFloor_ = true;
|
|
|
|
|
} else {
|
|
|
|
|
hasCachedFloor_ = false;
|
|
|
|
|
}
|
2026-02-03 15:17:54 -08:00
|
|
|
}
|
2026-02-08 00:18:47 -08:00
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
2026-02-12 00:04:53 -08:00
|
|
|
// Transition safety: if no reachable floor was selected, choose the higher
|
|
|
|
|
// of terrain/WMO center surfaces when it is still near the player.
|
|
|
|
|
// This avoids dropping into void gaps at terrain<->WMO seams.
|
|
|
|
|
if (!groundH) {
|
|
|
|
|
auto highestCenter = selectHighestFloor(centerTerrainH, centerWmoH, std::nullopt);
|
|
|
|
|
if (highestCenter) {
|
|
|
|
|
float dz = targetPos.z - *highestCenter;
|
|
|
|
|
// Keep this fallback narrow: only for WMO seam cases, or very short
|
|
|
|
|
// transient misses while still almost touching the last floor.
|
|
|
|
|
bool allowFallback = cachedInsideWMO || (noGroundTimer_ < 0.10f && dz < 0.6f);
|
|
|
|
|
if (allowFallback && dz >= -0.5f && dz < 2.0f) {
|
|
|
|
|
groundH = highestCenter;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Continuity guard only for WMO seam overlap: avoid instantly switching to a
|
|
|
|
|
// much lower floor sample at tunnel mouths (bad WMO ramp chains into void).
|
|
|
|
|
if (groundH && hasRealGround_ && cachedInsideWMO && !cachedInsideInteriorWMO) {
|
|
|
|
|
float dropFromLast = lastGroundZ - *groundH;
|
|
|
|
|
if (dropFromLast > 1.5f) {
|
|
|
|
|
if (centerTerrainH && *centerTerrainH > *groundH + 1.5f) {
|
|
|
|
|
groundH = centerTerrainH;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Seam stability: while overlapping WMO shells, cap how fast floor height can
|
|
|
|
|
// step downward in a single frame to avoid following bad ramp samples into void.
|
|
|
|
|
if (groundH && cachedInsideWMO && !cachedInsideInteriorWMO && lastGroundZ > 1.0f) {
|
|
|
|
|
float maxDropPerFrame = (verticalVelocity < -8.0f) ? 2.0f : 0.60f;
|
|
|
|
|
float minAllowed = lastGroundZ - maxDropPerFrame;
|
|
|
|
|
// Extra seam guard: outside interior groups, avoid accepting floors that
|
|
|
|
|
// are far below nearby terrain. Keeps shark-mouth transitions from
|
|
|
|
|
// following erroneous WMO ramps into void.
|
|
|
|
|
if (centerTerrainH) {
|
|
|
|
|
// Never let terrain-based seam guard push floor above current feet;
|
|
|
|
|
// it should only prevent excessive downward drops.
|
|
|
|
|
float terrainGuard = std::min(*centerTerrainH - 1.0f, targetPos.z - 0.15f);
|
|
|
|
|
minAllowed = std::max(minAllowed, terrainGuard);
|
|
|
|
|
}
|
|
|
|
|
if (*groundH < minAllowed) {
|
|
|
|
|
*groundH = minAllowed;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 1b. Multi-sample WMO floors when in/near WMO space to avoid
|
|
|
|
|
// falling through narrow board/plank gaps where center ray misses.
|
|
|
|
|
if (wmoRenderer && cachedInsideWMO) {
|
|
|
|
|
constexpr float WMO_FOOTPRINT = 0.35f;
|
|
|
|
|
const glm::vec2 wmoOffsets[] = {
|
|
|
|
|
{0.0f, 0.0f},
|
|
|
|
|
{ WMO_FOOTPRINT, 0.0f}, {-WMO_FOOTPRINT, 0.0f},
|
|
|
|
|
{0.0f, WMO_FOOTPRINT}, {0.0f, -WMO_FOOTPRINT}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
float wmoProbeZ = std::max(targetPos.z, lastGroundZ) + stepUpBudget + 0.6f;
|
|
|
|
|
float minWalkableWmo = cachedInsideWMO ? MIN_WALKABLE_NORMAL_WMO : MIN_WALKABLE_NORMAL_TERRAIN;
|
|
|
|
|
|
|
|
|
|
for (const auto& o : wmoOffsets) {
|
|
|
|
|
float nz = 1.0f;
|
|
|
|
|
auto wh = wmoRenderer->getFloorHeight(targetPos.x + o.x, targetPos.y + o.y, wmoProbeZ, &nz);
|
|
|
|
|
if (!wh) continue;
|
|
|
|
|
if (nz < minWalkableWmo) continue;
|
|
|
|
|
|
|
|
|
|
// Keep to nearby, walkable steps only.
|
|
|
|
|
if (*wh > targetPos.z + stepUpBudget) continue;
|
|
|
|
|
if (*wh < targetPos.z - 2.5f) continue;
|
|
|
|
|
|
|
|
|
|
if (!groundH || *wh > *groundH) {
|
|
|
|
|
groundH = wh;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 20:31:00 -08:00
|
|
|
// 2. Multi-sample for M2 objects (rugs, planks, bridges, ships) —
|
2026-02-08 00:18:47 -08:00
|
|
|
// these are narrow and need offset probes to detect reliably.
|
2026-02-08 20:45:59 -08:00
|
|
|
if (m2Renderer && !externalFollow_) {
|
2026-02-07 16:59:20 -08:00
|
|
|
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}
|
|
|
|
|
};
|
2026-02-08 00:18:47 -08:00
|
|
|
float m2ProbeZ = std::max(targetPos.z, lastGroundZ) + 6.0f;
|
2026-02-07 16:59:20 -08:00
|
|
|
for (const auto& o : offsets) {
|
2026-02-10 20:45:25 -08:00
|
|
|
float m2NormalZ = 1.0f;
|
2026-02-08 00:18:47 -08:00
|
|
|
auto m2H = m2Renderer->getFloorHeight(
|
2026-02-10 20:45:25 -08:00
|
|
|
targetPos.x + o.x, targetPos.y + o.y, m2ProbeZ, &m2NormalZ);
|
|
|
|
|
|
|
|
|
|
// Reject steep M2 slopes
|
2026-02-12 00:04:53 -08:00
|
|
|
if (m2H && m2NormalZ < MIN_WALKABLE_NORMAL_TERRAIN) {
|
2026-02-10 20:45:25 -08:00
|
|
|
continue; // Skip unwalkable M2 surface
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 20:31:00 -08:00
|
|
|
// 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;
|
|
|
|
|
}
|
2026-02-07 16:59:20 -08:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-07 15:54:33 -08:00
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
|
|
|
if (groundH) {
|
2026-02-08 15:32:04 -08:00
|
|
|
hasRealGround_ = true;
|
|
|
|
|
noGroundTimer_ = 0.0f;
|
2026-02-08 17:38:30 -08:00
|
|
|
float feetZ = targetPos.z;
|
2026-02-08 17:51:35 -08:00
|
|
|
float stepUp = stepUpBudget;
|
|
|
|
|
stepUp += 0.05f;
|
2026-02-08 17:38:30 -08:00
|
|
|
float fallCatch = 3.0f;
|
|
|
|
|
float dz = *groundH - feetZ;
|
|
|
|
|
|
2026-02-09 01:01:25 -08:00
|
|
|
// Only snap when:
|
|
|
|
|
// 1. Near ground (within step-up range above) - handles walking
|
|
|
|
|
// 2. Actually falling from height (was airborne + falling fast)
|
|
|
|
|
// 3. Was grounded + ground is close (grace for slopes)
|
|
|
|
|
bool nearGround = (dz >= 0.0f && dz <= stepUp);
|
|
|
|
|
bool airFalling = (!grounded && verticalVelocity < -5.0f);
|
2026-02-12 00:04:53 -08:00
|
|
|
bool slopeGrace = (grounded && verticalVelocity > -1.0f &&
|
|
|
|
|
dz >= -0.25f && dz <= stepUp * 1.5f);
|
2026-02-09 01:01:25 -08:00
|
|
|
|
|
|
|
|
if (dz >= -fallCatch && (nearGround || airFalling || slopeGrace)) {
|
2026-02-08 17:38:30 -08:00
|
|
|
targetPos.z = *groundH;
|
2026-02-02 12:24:50 -08:00
|
|
|
verticalVelocity = 0.0f;
|
|
|
|
|
grounded = true;
|
2026-02-08 17:38:30 -08:00
|
|
|
lastGroundZ = *groundH;
|
2026-02-04 13:29:27 -08:00
|
|
|
} else {
|
2026-02-02 12:24:50 -08:00
|
|
|
grounded = false;
|
2026-02-08 17:38:30 -08:00
|
|
|
lastGroundZ = *groundH;
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
2026-02-08 15:32:04 -08:00
|
|
|
} else {
|
2026-02-08 18:39:45 -08:00
|
|
|
hasRealGround_ = false;
|
|
|
|
|
noGroundTimer_ += deltaTime;
|
|
|
|
|
|
2026-02-12 00:04:53 -08:00
|
|
|
float dropFromLastGround = lastGroundZ - targetPos.z;
|
|
|
|
|
bool seamSizedGap = dropFromLastGround <= 0.35f;
|
|
|
|
|
if (noGroundTimer_ < NO_GROUND_GRACE && seamSizedGap) {
|
|
|
|
|
// Micro-gap grace only: keep continuity for tiny seam misses,
|
|
|
|
|
// but never convert air into persistent ground.
|
|
|
|
|
targetPos.z = std::max(targetPos.z, lastGroundZ - 0.10f);
|
|
|
|
|
grounded = false;
|
2026-02-08 18:39:45 -08:00
|
|
|
} else {
|
|
|
|
|
grounded = false;
|
|
|
|
|
}
|
2026-02-08 15:32:04 -08:00
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update follow target position
|
|
|
|
|
*followTarget = targetPos;
|
|
|
|
|
|
2026-02-08 15:32:04 -08:00
|
|
|
// --- 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_();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-03 14:26:08 -08:00
|
|
|
// ===== WoW-style orbit camera =====
|
2026-02-11 21:14:35 -08:00
|
|
|
// Pivot point at upper chest/neck.
|
2026-02-07 20:05:07 -08:00
|
|
|
float mountedOffset = mounted_ ? mountHeightOffset_ : 0.0f;
|
2026-02-11 21:14:35 -08:00
|
|
|
float pivotLift = 0.0f;
|
2026-02-12 00:04:53 -08:00
|
|
|
if (terrainManager && !externalFollow_ && !cachedInsideInteriorWMO) {
|
2026-02-11 21:14:35 -08:00
|
|
|
float moved = glm::length(glm::vec2(targetPos.x - lastPivotLiftQueryPos_.x,
|
|
|
|
|
targetPos.y - lastPivotLiftQueryPos_.y));
|
|
|
|
|
float distDelta = std::abs(currentDistance - lastPivotLiftDistance_);
|
|
|
|
|
bool queryLift = (++pivotLiftQueryCounter_ >= PIVOT_LIFT_QUERY_INTERVAL) ||
|
|
|
|
|
(moved >= PIVOT_LIFT_POS_THRESHOLD) ||
|
|
|
|
|
(distDelta >= PIVOT_LIFT_DIST_THRESHOLD);
|
|
|
|
|
if (queryLift) {
|
|
|
|
|
pivotLiftQueryCounter_ = 0;
|
|
|
|
|
lastPivotLiftQueryPos_ = targetPos;
|
|
|
|
|
lastPivotLiftDistance_ = currentDistance;
|
|
|
|
|
|
|
|
|
|
// Estimate where camera sits horizontally and ensure enough terrain clearance.
|
|
|
|
|
glm::vec3 probeCam = targetPos + (-forward3D) * currentDistance;
|
|
|
|
|
auto terrainAtCam = terrainManager->getHeightAt(probeCam.x, probeCam.y);
|
|
|
|
|
auto terrainAtPivot = terrainManager->getHeightAt(targetPos.x, targetPos.y);
|
|
|
|
|
|
|
|
|
|
float desiredLift = 0.0f;
|
|
|
|
|
if (terrainAtCam) {
|
|
|
|
|
// Keep pivot high enough so near-hill camera rays don't cut through terrain.
|
|
|
|
|
constexpr float kMinRayClearance = 2.0f;
|
|
|
|
|
float basePivotZ = targetPos.z + PIVOT_HEIGHT + mountedOffset;
|
|
|
|
|
float rayClearance = basePivotZ - *terrainAtCam;
|
|
|
|
|
if (rayClearance < kMinRayClearance) {
|
|
|
|
|
desiredLift = std::clamp(kMinRayClearance - rayClearance, 0.0f, 1.4f);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// If character is already below local terrain sample, avoid lifting aggressively.
|
|
|
|
|
if (terrainAtPivot && targetPos.z < *terrainAtPivot - 0.2f) {
|
|
|
|
|
desiredLift = 0.0f;
|
|
|
|
|
}
|
|
|
|
|
cachedPivotLift_ = desiredLift;
|
|
|
|
|
}
|
|
|
|
|
pivotLift = cachedPivotLift_;
|
2026-02-12 00:04:53 -08:00
|
|
|
} else if (cachedInsideInteriorWMO) {
|
|
|
|
|
// Inside WMO volumes (including tunnel/cave shells): terrain-above samples
|
|
|
|
|
// are not relevant for camera pivoting.
|
|
|
|
|
cachedPivotLift_ = 0.0f;
|
2026-02-11 21:14:35 -08:00
|
|
|
}
|
|
|
|
|
glm::vec3 pivot = targetPos + glm::vec3(0.0f, 0.0f, PIVOT_HEIGHT + mountedOffset + pivotLift);
|
2026-02-02 23:18:34 -08:00
|
|
|
|
2026-02-03 14:26:08 -08:00
|
|
|
// Camera direction from yaw/pitch (already computed as forward3D)
|
|
|
|
|
glm::vec3 camDir = -forward3D; // Camera looks at pivot, so it's behind
|
2026-02-02 23:18:34 -08:00
|
|
|
|
2026-02-03 14:26:08 -08:00
|
|
|
// Smooth zoom toward user target
|
|
|
|
|
float zoomLerp = 1.0f - std::exp(-ZOOM_SMOOTH_SPEED * deltaTime);
|
|
|
|
|
currentDistance += (userTargetDistance - currentDistance) * zoomLerp;
|
2026-02-02 23:18:34 -08:00
|
|
|
|
2026-02-08 20:21:49 -08:00
|
|
|
// Limit max zoom when inside a WMO with a ceiling (building interior)
|
2026-02-07 15:47:43 -08:00
|
|
|
// 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) {
|
2026-02-08 17:38:30 -08:00
|
|
|
wmoRenderer->updateActiveGroup(targetPos.x, targetPos.y, targetPos.z + 1.0f);
|
2026-02-07 15:47:43 -08:00
|
|
|
insideWMOCheckCounter = 0;
|
|
|
|
|
lastInsideWMOCheckPos = targetPos;
|
|
|
|
|
}
|
2026-02-08 20:15:34 -08:00
|
|
|
|
2026-02-12 00:04:53 -08:00
|
|
|
// Do not clamp zoom target by ceiling checks. First-person should always
|
|
|
|
|
// be reachable; occlusion handling below will resolve camera placement safely.
|
2026-02-05 18:19:09 -08:00
|
|
|
}
|
|
|
|
|
|
2026-02-03 14:26:08 -08:00
|
|
|
// ===== Camera collision (sphere sweep approximation) =====
|
|
|
|
|
// Find max safe distance using raycast + sphere radius
|
|
|
|
|
collisionDistance = currentDistance;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
2026-02-16 20:16:14 -08:00
|
|
|
// WMO raycast collision: zoom in when camera would clip through walls/floors
|
|
|
|
|
if (wmoRenderer && currentDistance > MIN_DISTANCE) {
|
2026-02-08 20:33:40 -08:00
|
|
|
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
|
2026-02-08 20:45:59 -08:00
|
|
|
if (m2Renderer && !externalFollow_ && currentDistance > MIN_DISTANCE) {
|
2026-02-08 20:33:40 -08:00
|
|
|
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
|
2026-02-07 15:54:33 -08:00
|
|
|
auto getTerrainFloorAt = [&](float x, float y) -> std::optional<float> {
|
2026-02-02 12:24:50 -08:00
|
|
|
if (terrainManager) {
|
2026-02-07 15:54:33 -08:00
|
|
|
return terrainManager->getHeightAt(x, y);
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
2026-02-07 15:54:33 -08:00
|
|
|
return std::nullopt;
|
2026-02-03 14:26:08 -08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
2026-02-08 00:18:47 -08:00
|
|
|
// ===== Final floor clearance check =====
|
|
|
|
|
// Use WMO-aware floor so the camera doesn't pop above tunnels/caves.
|
2026-02-03 16:04:21 -08:00
|
|
|
constexpr float MIN_FLOOR_CLEARANCE = 0.35f;
|
2026-02-12 00:04:53 -08:00
|
|
|
if (!cachedInsideWMO) {
|
|
|
|
|
std::optional<float> camTerrainH;
|
|
|
|
|
if (!cachedInsideInteriorWMO) {
|
|
|
|
|
camTerrainH = getTerrainFloorAt(smoothedCamPos.x, smoothedCamPos.y);
|
|
|
|
|
}
|
2026-02-08 00:18:47 -08:00
|
|
|
std::optional<float> camWmoH;
|
|
|
|
|
if (wmoRenderer) {
|
2026-02-08 17:38:30 -08:00
|
|
|
// 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 {
|
2026-02-12 00:04:53 -08:00
|
|
|
float camFloorProbeZ = smoothedCamPos.z;
|
|
|
|
|
if (cachedInsideInteriorWMO) {
|
|
|
|
|
// Inside tunnels/buildings, probe near player height so roof
|
|
|
|
|
// triangles above the camera don't get treated as floor.
|
|
|
|
|
camFloorProbeZ = std::min(smoothedCamPos.z, targetPos.z + 1.0f);
|
|
|
|
|
}
|
2026-02-08 17:38:30 -08:00
|
|
|
camWmoH = wmoRenderer->getFloorHeight(
|
2026-02-12 00:04:53 -08:00
|
|
|
smoothedCamPos.x, smoothedCamPos.y, camFloorProbeZ);
|
|
|
|
|
|
|
|
|
|
if (cachedInsideInteriorWMO && camWmoH) {
|
|
|
|
|
// Never let camera floor clamp latch to tunnel ceilings / upper decks.
|
|
|
|
|
float maxValidIndoorFloor = targetPos.z + 0.9f;
|
|
|
|
|
if (*camWmoH > maxValidIndoorFloor) {
|
|
|
|
|
camWmoH = std::nullopt;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-08 17:38:30 -08:00
|
|
|
cachedCamWmoFloor = camWmoH;
|
|
|
|
|
hasCachedCamFloor = true;
|
|
|
|
|
lastCamFloorQueryPos = smoothedCamPos;
|
|
|
|
|
}
|
2026-02-08 00:18:47 -08:00
|
|
|
}
|
2026-02-12 00:04:53 -08:00
|
|
|
// When camera/character are inside a WMO, force WMO floor usage for camera
|
|
|
|
|
// clearance to avoid snapping toward terrain above enclosed tunnels/caves.
|
|
|
|
|
std::optional<float> camFloorH;
|
|
|
|
|
if (cachedInsideWMO && camWmoH && camTerrainH) {
|
|
|
|
|
// Transition seam: avoid terrain-above clamp near tunnel entrances.
|
|
|
|
|
float camDropFromPlayer = targetPos.z - *camWmoH;
|
|
|
|
|
if ((*camTerrainH - *camWmoH) > 1.2f &&
|
|
|
|
|
(*camTerrainH - *camWmoH) < 8.0f &&
|
|
|
|
|
camDropFromPlayer >= -0.4f &&
|
|
|
|
|
camDropFromPlayer < 1.8f) {
|
|
|
|
|
camFloorH = camWmoH;
|
|
|
|
|
} else {
|
|
|
|
|
camFloorH = selectClosestFloor(camTerrainH, camWmoH, smoothedCamPos.z);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
camFloorH = selectReachableFloor(
|
|
|
|
|
camTerrainH, camWmoH, smoothedCamPos.z, 0.5f);
|
|
|
|
|
}
|
2026-02-08 00:18:47 -08:00
|
|
|
if (camFloorH && smoothedCamPos.z < *camFloorH + MIN_FLOOR_CLEARANCE) {
|
|
|
|
|
smoothedCamPos.z = *camFloorH + MIN_FLOOR_CLEARANCE;
|
|
|
|
|
}
|
2026-02-03 14:26:08 -08:00
|
|
|
}
|
2026-02-03 16:04:21 -08:00
|
|
|
// Never let camera sink below the character's feet plane.
|
|
|
|
|
smoothedCamPos.z = std::max(smoothedCamPos.z, targetPos.z + 0.15f);
|
2026-02-03 14:26:08 -08:00
|
|
|
|
|
|
|
|
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) {
|
2026-02-11 22:27:02 -08:00
|
|
|
// Honor first-person intent even if anti-clipping pushes camera back slightly.
|
|
|
|
|
bool shouldHidePlayer = isFirstPersonView() || (actualDist < MIN_DISTANCE + 0.1f);
|
2026-02-03 14:26:08 -08:00
|
|
|
characterRenderer->setInstanceVisible(playerInstanceId, !shouldHidePlayer);
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
} else {
|
|
|
|
|
// Free-fly camera mode (original behavior)
|
|
|
|
|
glm::vec3 newPos = camera->getPosition();
|
2026-02-03 16:21:48 -08:00
|
|
|
if (wmoRenderer) {
|
|
|
|
|
wmoRenderer->setCollisionFocus(newPos, COLLISION_FOCUS_RADIUS_FREE_FLY);
|
|
|
|
|
}
|
|
|
|
|
if (m2Renderer) {
|
|
|
|
|
m2Renderer->setCollisionFocus(newPos, COLLISION_FOCUS_RADIUS_FREE_FLY);
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
float feetZ = newPos.z - eyeHeight;
|
|
|
|
|
|
|
|
|
|
// Check for water at feet position
|
|
|
|
|
std::optional<float> waterH;
|
|
|
|
|
if (waterRenderer) {
|
|
|
|
|
waterH = waterRenderer->getWaterHeightAt(newPos.x, newPos.y);
|
|
|
|
|
}
|
2026-02-03 20:40:59 -08:00
|
|
|
constexpr float MAX_SWIM_DEPTH_FROM_SURFACE = 12.0f;
|
2026-02-03 21:11:10 -08:00
|
|
|
bool inWater = false;
|
2026-02-03 21:32:42 -08:00
|
|
|
if (waterH && feetZ < *waterH) {
|
|
|
|
|
std::optional<uint16_t> 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 {
|
2026-02-03 21:11:10 -08:00
|
|
|
std::optional<float> terrainH;
|
|
|
|
|
std::optional<float> wmoH;
|
|
|
|
|
std::optional<float> m2H;
|
|
|
|
|
if (terrainManager) terrainH = terrainManager->getHeightAt(newPos.x, newPos.y);
|
2026-02-04 14:06:59 -08:00
|
|
|
if (wmoRenderer) wmoH = wmoRenderer->getFloorHeight(newPos.x, newPos.y, feetZ + 2.0f);
|
2026-02-08 20:45:59 -08:00
|
|
|
if (m2Renderer && !externalFollow_) m2H = m2Renderer->getFloorHeight(newPos.x, newPos.y, feetZ + 1.0f);
|
2026-02-03 21:11:10 -08:00
|
|
|
auto floorH = selectHighestFloor(terrainH, wmoH, m2H);
|
2026-02-19 02:46:52 -08:00
|
|
|
constexpr float MIN_SWIM_WATER_DEPTH = 1.0f;
|
2026-02-03 21:32:42 -08:00
|
|
|
inWater = (floorH && ((*waterH - *floorH) >= MIN_SWIM_WATER_DEPTH)) || (isOcean && !floorH);
|
|
|
|
|
}
|
2026-02-03 21:11:10 -08:00
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
if (inWater) {
|
|
|
|
|
swimming = true;
|
|
|
|
|
float swimSpeed = speed * SWIM_SPEED_FACTOR;
|
2026-02-03 20:40:59 -08:00
|
|
|
float waterSurfaceCamZ = waterH ? (*waterH - WATER_SURFACE_OFFSET + eyeHeight) : newPos.z;
|
|
|
|
|
bool diveIntent = nowForward && (forward3D.z < -0.28f);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
2026-02-03 20:40:59 -08:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-04 13:29:27 -08:00
|
|
|
// Jump with input buffering and coyote time
|
|
|
|
|
if (nowJump) jumpBufferTimer = JUMP_BUFFER_TIME;
|
|
|
|
|
if (grounded) coyoteTimer = COYOTE_TIME;
|
|
|
|
|
|
2026-02-10 19:30:45 -08:00
|
|
|
if (coyoteTimer > 0.0f && jumpBufferTimer > 0.0f && !mounted_) {
|
2026-02-02 12:24:50 -08:00
|
|
|
verticalVelocity = jumpVel;
|
|
|
|
|
grounded = false;
|
2026-02-04 13:29:27 -08:00
|
|
|
jumpBufferTimer = 0.0f;
|
|
|
|
|
coyoteTimer = 0.0f;
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
|
2026-02-04 13:29:27 -08:00
|
|
|
jumpBufferTimer -= deltaTime;
|
|
|
|
|
coyoteTimer -= deltaTime;
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
// Apply gravity
|
|
|
|
|
verticalVelocity += gravity * deltaTime;
|
|
|
|
|
newPos.z += verticalVelocity * deltaTime;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-07 15:54:33 -08:00
|
|
|
// Wall sweep collision before grounding (skip when stationary).
|
2026-02-02 12:24:50 -08:00
|
|
|
if (wmoRenderer) {
|
2026-02-03 14:57:06 -08:00
|
|
|
glm::vec3 startFeet = camera->getPosition() - glm::vec3(0, 0, eyeHeight);
|
|
|
|
|
glm::vec3 desiredFeet = newPos - glm::vec3(0, 0, eyeHeight);
|
2026-02-03 16:21:48 -08:00
|
|
|
float moveDist = glm::length(desiredFeet - startFeet);
|
2026-02-07 15:54:33 -08:00
|
|
|
|
|
|
|
|
if (moveDist > 0.01f) {
|
2026-02-07 16:00:57 -08:00
|
|
|
int sweepSteps = std::max(1, std::min(3, static_cast<int>(std::ceil(moveDist / 0.65f))));
|
2026-02-07 15:54:33 -08:00
|
|
|
glm::vec3 stepPos = startFeet;
|
|
|
|
|
glm::vec3 stepDelta = (desiredFeet - startFeet) / static_cast<float>(sweepSteps);
|
|
|
|
|
|
|
|
|
|
for (int i = 0; i < sweepSteps; i++) {
|
|
|
|
|
glm::vec3 candidate = stepPos + stepDelta;
|
|
|
|
|
glm::vec3 adjusted;
|
|
|
|
|
if (wmoRenderer->checkWallCollision(stepPos, candidate, adjusted)) {
|
2026-02-08 17:38:30 -08:00
|
|
|
candidate.x = adjusted.x;
|
|
|
|
|
candidate.y = adjusted.y;
|
|
|
|
|
candidate.z = std::max(candidate.z, adjusted.z);
|
2026-02-07 15:54:33 -08:00
|
|
|
}
|
|
|
|
|
stepPos = candidate;
|
2026-02-03 14:57:06 -08:00
|
|
|
}
|
|
|
|
|
|
2026-02-07 15:54:33 -08:00
|
|
|
newPos = stepPos + glm::vec3(0, 0, eyeHeight);
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Ground to terrain or WMO floor
|
|
|
|
|
{
|
2026-02-03 15:17:54 -08:00
|
|
|
auto sampleGround = [&](float x, float y) -> std::optional<float> {
|
|
|
|
|
std::optional<float> terrainH;
|
|
|
|
|
std::optional<float> wmoH;
|
|
|
|
|
std::optional<float> m2H;
|
|
|
|
|
if (terrainManager) {
|
|
|
|
|
terrainH = terrainManager->getHeightAt(x, y);
|
|
|
|
|
}
|
2026-02-03 21:30:59 -08:00
|
|
|
float feetZ = newPos.z - eyeHeight;
|
2026-02-04 14:06:59 -08:00
|
|
|
float wmoProbeZ = std::max(feetZ, lastGroundZ) + 1.5f;
|
|
|
|
|
float m2ProbeZ = std::max(feetZ, lastGroundZ) + 6.0f;
|
2026-02-03 15:17:54 -08:00
|
|
|
if (wmoRenderer) {
|
2026-02-04 14:06:59 -08:00
|
|
|
wmoH = wmoRenderer->getFloorHeight(x, y, wmoProbeZ);
|
2026-02-03 15:17:54 -08:00
|
|
|
}
|
2026-02-08 20:45:59 -08:00
|
|
|
if (m2Renderer && !externalFollow_) {
|
2026-02-04 14:06:59 -08:00
|
|
|
m2H = m2Renderer->getFloorHeight(x, y, m2ProbeZ);
|
2026-02-03 15:17:54 -08:00
|
|
|
}
|
2026-02-03 21:30:59 -08:00
|
|
|
auto base = selectReachableFloor(terrainH, wmoH, feetZ, 1.0f);
|
|
|
|
|
if (m2H && *m2H <= feetZ + 1.0f && (!base || *m2H > *base)) {
|
2026-02-03 15:17:54 -08:00
|
|
|
base = m2H;
|
|
|
|
|
}
|
|
|
|
|
return base;
|
|
|
|
|
};
|
2026-02-02 12:24:50 -08:00
|
|
|
|
2026-02-07 15:54:33 -08:00
|
|
|
// Single center probe.
|
|
|
|
|
std::optional<float> groundH = sampleGround(newPos.x, newPos.y);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
|
|
|
if (groundH) {
|
2026-02-08 17:38:30 -08:00
|
|
|
float feetZ = newPos.z - eyeHeight;
|
|
|
|
|
float stepUp = 1.0f;
|
|
|
|
|
float fallCatch = 3.0f;
|
|
|
|
|
float dz = *groundH - feetZ;
|
2026-02-05 17:48:58 -08:00
|
|
|
|
2026-02-09 01:01:25 -08:00
|
|
|
// Only snap when:
|
|
|
|
|
// 1. Near ground (within step-up range above) - handles walking
|
|
|
|
|
// 2. Actually falling from height (was airborne + falling fast)
|
|
|
|
|
// 3. Was grounded + ground is close (grace for slopes)
|
|
|
|
|
bool nearGround = (dz >= 0.0f && dz <= stepUp);
|
|
|
|
|
bool airFalling = (!grounded && verticalVelocity < -5.0f);
|
|
|
|
|
bool slopeGrace = (grounded && dz >= -1.0f && dz <= stepUp * 2.0f);
|
|
|
|
|
|
|
|
|
|
if (dz >= -fallCatch && (nearGround || airFalling || slopeGrace)) {
|
2026-02-08 17:38:30 -08:00
|
|
|
newPos.z = *groundH + eyeHeight;
|
2026-02-02 12:24:50 -08:00
|
|
|
verticalVelocity = 0.0f;
|
|
|
|
|
grounded = true;
|
2026-02-08 17:38:30 -08:00
|
|
|
lastGroundZ = *groundH;
|
|
|
|
|
swimming = false;
|
2026-02-02 12:24:50 -08:00
|
|
|
} else if (!swimming) {
|
|
|
|
|
grounded = false;
|
2026-02-08 17:38:30 -08:00
|
|
|
lastGroundZ = *groundH;
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
} else if (!swimming) {
|
2026-02-08 17:38:30 -08:00
|
|
|
newPos.z = lastGroundZ + eyeHeight;
|
2026-02-02 12:24:50 -08:00
|
|
|
verticalVelocity = 0.0f;
|
|
|
|
|
grounded = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
camera->setPosition(newPos);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Edge-detection: send movement opcodes on state transitions ---
|
|
|
|
|
if (movementCallback) {
|
|
|
|
|
// Forward/backward
|
|
|
|
|
if (nowForward && !wasMovingForward) {
|
2026-02-20 02:50:59 -08:00
|
|
|
movementCallback(static_cast<uint32_t>(game::Opcode::MSG_MOVE_START_FORWARD));
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
if (nowBackward && !wasMovingBackward) {
|
2026-02-20 02:50:59 -08:00
|
|
|
movementCallback(static_cast<uint32_t>(game::Opcode::MSG_MOVE_START_BACKWARD));
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
if ((!nowForward && wasMovingForward) || (!nowBackward && wasMovingBackward)) {
|
|
|
|
|
if (!nowForward && !nowBackward) {
|
2026-02-20 02:50:59 -08:00
|
|
|
movementCallback(static_cast<uint32_t>(game::Opcode::MSG_MOVE_STOP));
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Strafing
|
|
|
|
|
if (nowStrafeLeft && !wasStrafingLeft) {
|
2026-02-20 02:50:59 -08:00
|
|
|
movementCallback(static_cast<uint32_t>(game::Opcode::MSG_MOVE_START_STRAFE_LEFT));
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
if (nowStrafeRight && !wasStrafingRight) {
|
2026-02-20 02:50:59 -08:00
|
|
|
movementCallback(static_cast<uint32_t>(game::Opcode::MSG_MOVE_START_STRAFE_RIGHT));
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
if ((!nowStrafeLeft && wasStrafingLeft) || (!nowStrafeRight && wasStrafingRight)) {
|
|
|
|
|
if (!nowStrafeLeft && !nowStrafeRight) {
|
2026-02-20 02:50:59 -08:00
|
|
|
movementCallback(static_cast<uint32_t>(game::Opcode::MSG_MOVE_STOP_STRAFE));
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-03 14:55:32 -08:00
|
|
|
// Turning
|
|
|
|
|
if (nowTurnLeft && !wasTurningLeft) {
|
2026-02-20 02:50:59 -08:00
|
|
|
movementCallback(static_cast<uint32_t>(game::Opcode::MSG_MOVE_START_TURN_LEFT));
|
2026-02-03 14:55:32 -08:00
|
|
|
}
|
|
|
|
|
if (nowTurnRight && !wasTurningRight) {
|
2026-02-20 02:50:59 -08:00
|
|
|
movementCallback(static_cast<uint32_t>(game::Opcode::MSG_MOVE_START_TURN_RIGHT));
|
2026-02-03 14:55:32 -08:00
|
|
|
}
|
|
|
|
|
if ((!nowTurnLeft && wasTurningLeft) || (!nowTurnRight && wasTurningRight)) {
|
|
|
|
|
if (!nowTurnLeft && !nowTurnRight) {
|
2026-02-20 02:50:59 -08:00
|
|
|
movementCallback(static_cast<uint32_t>(game::Opcode::MSG_MOVE_STOP_TURN));
|
2026-02-03 14:55:32 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
// Jump
|
|
|
|
|
if (nowJump && !wasJumping && grounded) {
|
2026-02-20 02:50:59 -08:00
|
|
|
movementCallback(static_cast<uint32_t>(game::Opcode::MSG_MOVE_JUMP));
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fall landing
|
|
|
|
|
if (wasFalling && grounded) {
|
2026-02-20 02:50:59 -08:00
|
|
|
movementCallback(static_cast<uint32_t>(game::Opcode::MSG_MOVE_FALL_LAND));
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Swimming state transitions
|
|
|
|
|
if (movementCallback) {
|
|
|
|
|
if (swimming && !wasSwimming) {
|
2026-02-20 02:50:59 -08:00
|
|
|
movementCallback(static_cast<uint32_t>(game::Opcode::MSG_MOVE_START_SWIM));
|
2026-02-02 12:24:50 -08:00
|
|
|
} else if (!swimming && wasSwimming) {
|
2026-02-20 02:50:59 -08:00
|
|
|
movementCallback(static_cast<uint32_t>(game::Opcode::MSG_MOVE_STOP_SWIM));
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update previous-frame state
|
|
|
|
|
wasSwimming = swimming;
|
|
|
|
|
wasMovingForward = nowForward;
|
|
|
|
|
wasMovingBackward = nowBackward;
|
|
|
|
|
wasStrafingLeft = nowStrafeLeft;
|
|
|
|
|
wasStrafingRight = nowStrafeRight;
|
2026-02-03 19:29:11 -08:00
|
|
|
moveForwardActive = nowForward;
|
|
|
|
|
moveBackwardActive = nowBackward;
|
|
|
|
|
strafeLeftActive = nowStrafeLeft;
|
|
|
|
|
strafeRightActive = nowStrafeRight;
|
2026-02-03 14:55:32 -08:00
|
|
|
wasTurningLeft = nowTurnLeft;
|
|
|
|
|
wasTurningRight = nowTurnRight;
|
2026-02-02 12:24:50 -08:00
|
|
|
wasJumping = nowJump;
|
|
|
|
|
wasFalling = !grounded && verticalVelocity <= 0.0f;
|
|
|
|
|
|
2026-02-08 00:12:15 -08:00
|
|
|
// R key disabled — was camera reset, conflicts with chat reply
|
|
|
|
|
rKeyWasDown = false;
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void CameraController::processMouseMotion(const SDL_MouseMotionEvent& event) {
|
|
|
|
|
if (!enabled || !camera) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-05 18:06:52 -08:00
|
|
|
if (introActive) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
|
|
|
if (!mouseButtonDown) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Directly update stored yaw/pitch (no lossy forward-vector derivation)
|
|
|
|
|
yaw -= event.xrel * mouseSensitivity;
|
2026-02-05 17:40:15 -08:00
|
|
|
float invert = invertMouse ? -1.0f : 1.0f;
|
|
|
|
|
pitch += event.yrel * mouseSensitivity * invert;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
2026-02-03 14:26:08 -08:00
|
|
|
// WoW-style pitch limits: can look almost straight down, limited upward
|
|
|
|
|
pitch = glm::clamp(pitch, MIN_PITCH, MAX_PITCH);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
|
|
|
camera->setRotation(yaw, pitch);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void CameraController::processMouseButton(const SDL_MouseButtonEvent& event) {
|
|
|
|
|
if (!enabled) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 22:34:22 -08:00
|
|
|
// Don't capture mouse when ImGui wants it (hovering UI windows)
|
|
|
|
|
bool uiWantsMouse = ImGui::GetIO().WantCaptureMouse;
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
if (event.button == SDL_BUTTON_LEFT) {
|
2026-02-19 22:34:22 -08:00
|
|
|
leftMouseDown = (event.state == SDL_PRESSED) && !uiWantsMouse;
|
2026-02-07 16:08:06 -08:00
|
|
|
if (event.state == SDL_PRESSED && event.clicks >= 2) {
|
|
|
|
|
autoRunning = false;
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
if (event.button == SDL_BUTTON_RIGHT) {
|
2026-02-19 22:34:22 -08:00
|
|
|
rightMouseDown = (event.state == SDL_PRESSED) && !uiWantsMouse;
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
2026-02-03 19:49:56 -08:00
|
|
|
facingYaw = defaultYaw;
|
2026-02-02 12:24:50 -08:00
|
|
|
pitch = defaultPitch;
|
|
|
|
|
verticalVelocity = 0.0f;
|
2026-02-03 14:55:32 -08:00
|
|
|
grounded = true;
|
|
|
|
|
swimming = false;
|
|
|
|
|
sitting = false;
|
2026-02-07 16:05:13 -08:00
|
|
|
autoRunning = false;
|
2026-02-08 15:32:04 -08:00
|
|
|
noGroundTimer_ = 0.0f;
|
|
|
|
|
autoUnstuckFired_ = false;
|
2026-02-03 14:55:32 -08:00
|
|
|
|
|
|
|
|
// 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;
|
2026-02-03 19:29:11 -08:00
|
|
|
moveForwardActive = false;
|
|
|
|
|
moveBackwardActive = false;
|
|
|
|
|
strafeLeftActive = false;
|
|
|
|
|
strafeRightActive = false;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
|
|
|
glm::vec3 spawnPos = defaultPosition;
|
|
|
|
|
|
2026-02-04 17:37:28 -08:00
|
|
|
auto evalFloorAt = [&](float x, float y, float refZ) -> std::optional<float> {
|
|
|
|
|
std::optional<float> terrainH;
|
|
|
|
|
std::optional<float> wmoH;
|
|
|
|
|
std::optional<float> m2H;
|
|
|
|
|
if (terrainManager) {
|
|
|
|
|
terrainH = terrainManager->getHeightAt(x, y);
|
|
|
|
|
}
|
2026-02-07 15:58:18 -08:00
|
|
|
// 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);
|
2026-02-04 17:37:28 -08:00
|
|
|
if (wmoRenderer) {
|
2026-02-07 15:58:18 -08:00
|
|
|
wmoH = wmoRenderer->getFloorHeight(x, y, floorProbeZ + 4.0f);
|
2026-02-04 17:37:28 -08:00
|
|
|
}
|
2026-02-08 20:45:59 -08:00
|
|
|
if (m2Renderer && !externalFollow_) {
|
2026-02-07 15:58:18 -08:00
|
|
|
m2H = m2Renderer->getFloorHeight(x, y, floorProbeZ + 4.0f);
|
2026-02-04 17:37:28 -08:00
|
|
|
}
|
|
|
|
|
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.
|
2026-02-07 16:59:20 -08:00
|
|
|
// In online mode, use a tight search radius since the server dictates position.
|
2026-02-04 17:37:28 -08:00
|
|
|
float bestScore = std::numeric_limits<float>::max();
|
|
|
|
|
glm::vec3 bestPos = spawnPos;
|
|
|
|
|
bool foundBest = false;
|
2026-02-07 16:59:20 -08:00
|
|
|
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;
|
2026-02-04 17:37:28 -08:00
|
|
|
constexpr int ANGLES = 16;
|
|
|
|
|
constexpr float PI = 3.14159265f;
|
2026-02-07 16:59:20 -08:00
|
|
|
for (int ri = 0; ri < radiiCount; ri++) {
|
|
|
|
|
float r = radii[ri];
|
2026-02-04 17:37:28 -08:00
|
|
|
int steps = (r <= 0.01f) ? 1 : ANGLES;
|
|
|
|
|
for (int i = 0; i < steps; i++) {
|
|
|
|
|
float a = (2.0f * PI * static_cast<float>(i)) / static_cast<float>(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<float>(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);
|
2026-02-03 21:30:59 -08:00
|
|
|
|
2026-02-07 16:59:20 -08:00
|
|
|
// 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) {
|
2026-02-04 17:37:28 -08:00
|
|
|
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<float>(ri)) / static_cast<float>(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<float>(blocked) * 3.0f;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (score < bestScore) {
|
|
|
|
|
bestScore = score;
|
|
|
|
|
bestPos = glm::vec3(x, y, *h + 0.05f);
|
|
|
|
|
foundBest = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
2026-02-04 17:37:28 -08:00
|
|
|
if (foundBest) {
|
|
|
|
|
spawnPos = bestPos;
|
|
|
|
|
lastGroundZ = spawnPos.z - 0.05f;
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
camera->setRotation(yaw, pitch);
|
2026-02-03 14:55:32 -08:00
|
|
|
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;
|
|
|
|
|
|
2026-02-07 20:05:07 -08:00
|
|
|
float mountedOffset = mounted_ ? mountHeightOffset_ : 0.0f;
|
|
|
|
|
glm::vec3 pivot = spawnPos + glm::vec3(0.0f, 0.0f, PIVOT_HEIGHT + mountedOffset);
|
2026-02-03 14:55:32 -08:00
|
|
|
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.
|
2026-02-04 17:37:28 -08:00
|
|
|
if (foundBest) {
|
2026-02-03 14:55:32 -08:00
|
|
|
spawnPos.z += eyeHeight;
|
|
|
|
|
}
|
|
|
|
|
smoothedCamPos = spawnPos;
|
|
|
|
|
camera->setPosition(spawnPos);
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
|
|
|
LOG_INFO("Camera reset to default position");
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 15:13:55 -08:00
|
|
|
void CameraController::teleportTo(const glm::vec3& pos) {
|
|
|
|
|
if (!camera) return;
|
|
|
|
|
|
|
|
|
|
verticalVelocity = 0.0f;
|
|
|
|
|
grounded = true;
|
|
|
|
|
swimming = false;
|
2026-02-08 15:32:04 -08:00
|
|
|
sitting = false;
|
2026-02-08 15:13:55 -08:00
|
|
|
lastGroundZ = pos.z;
|
2026-02-08 15:32:04 -08:00
|
|
|
noGroundTimer_ = 0.0f; // Reset grace period so terrain has time to stream
|
|
|
|
|
autoUnstuckFired_ = false;
|
|
|
|
|
continuousFallTime_ = 0.0f;
|
2026-02-08 15:13:55 -08:00
|
|
|
|
2026-02-08 17:38:30 -08:00
|
|
|
// Invalidate active WMO group so it's re-detected at new position
|
|
|
|
|
if (wmoRenderer) {
|
|
|
|
|
wmoRenderer->updateActiveGroup(pos.x, pos.y, pos.z + 1.0f);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 15:13:55 -08:00
|
|
|
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, ")");
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
void CameraController::processMouseWheel(float delta) {
|
2026-02-03 14:26:08 -08:00
|
|
|
// 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);
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
2026-02-08 03:05:38 -08:00
|
|
|
if (externalMoving_) return true;
|
2026-02-07 20:24:25 -08:00
|
|
|
return moveForwardActive || moveBackwardActive || strafeLeftActive || strafeRightActive || autoRunning;
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
|
2026-02-08 03:05:38 -08:00
|
|
|
void CameraController::clearMovementInputs() {
|
|
|
|
|
moveForwardActive = false;
|
|
|
|
|
moveBackwardActive = false;
|
|
|
|
|
strafeLeftActive = false;
|
|
|
|
|
strafeRightActive = false;
|
|
|
|
|
autoRunning = false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
bool CameraController::isSprinting() const {
|
2026-02-03 14:55:32 -08:00
|
|
|
return enabled && camera && runPace;
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
|
2026-02-10 19:30:45 -08:00
|
|
|
void CameraController::triggerMountJump() {
|
|
|
|
|
// Apply physics-driven mount jump: vz = sqrt(2 * g * h)
|
|
|
|
|
// Desired height and gravity are configurable constants
|
|
|
|
|
if (grounded || coyoteTimer > 0.0f) {
|
|
|
|
|
verticalVelocity = getMountJumpVelocity();
|
|
|
|
|
grounded = false;
|
|
|
|
|
coyoteTimer = 0.0f;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
} // namespace rendering
|
|
|
|
|
} // namespace wowee
|