diff --git a/CMakeLists.txt b/CMakeLists.txt index 9ac168aa..9eaa8148 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -547,6 +547,8 @@ set(WOWEE_SOURCES src/game/warden_memory.cpp src/game/transport_manager.cpp src/game/transport_path_repository.cpp + src/game/transport_clock_sync.cpp + src/game/transport_animator.cpp src/game/world.cpp src/game/player.cpp src/game/entity.cpp diff --git a/include/game/entity.hpp b/include/game/entity.hpp index 2166bfa8..981aeb82 100644 --- a/include/game/entity.hpp +++ b/include/game/entity.hpp @@ -8,6 +8,8 @@ #include #include #include +#include +#include "math/spline.hpp" namespace wowee { namespace game { @@ -80,31 +82,41 @@ public: orientation = o; isMoving_ = false; // Instant position set cancels interpolation usePathMode_ = false; + activeSpline_.reset(); } - // Multi-segment path movement + // Multi-segment path movement (Catmull-Rom spline interpolation) void startMoveAlongPath(const std::vector>& path, float destO, float totalDuration) { if (path.empty()) return; if (path.size() == 1 || totalDuration <= 0.0f) { startMoveTo(path.back()[0], path.back()[1], path.back()[2], destO, totalDuration); return; } - // Compute cumulative distances for proportional segment timing - pathPoints_ = path; - pathSegDists_.resize(path.size()); - pathSegDists_[0] = 0.0f; + // Build cumulative distances for proportional time assignment + std::vector cumDist(path.size(), 0.0f); float totalDist = 0.0f; for (size_t i = 1; i < path.size(); i++) { float dx = path[i][0] - path[i - 1][0]; float dy = path[i][1] - path[i - 1][1]; float dz = path[i][2] - path[i - 1][2]; totalDist += std::sqrt(dx * dx + dy * dy + dz * dz); - pathSegDists_[i] = totalDist; + cumDist[i] = totalDist; } if (totalDist < 0.001f) { startMoveTo(path.back()[0], path.back()[1], path.back()[2], destO, totalDuration); return; } + // Build SplineKeys with distance-proportional time + uint32_t durationMs = static_cast(totalDuration * 1000.0f); + std::vector keys(path.size()); + for (size_t i = 0; i < path.size(); i++) { + float fraction = cumDist[i] / totalDist; + keys[i].timeMs = static_cast(fraction * durationMs); + keys[i].position = {path[i][0], path[i][1], path[i][2]}; + } + activeSpline_.emplace(std::move(keys), /*timeClosed=*/false); + splineDurationMs_ = durationMs; + // Snap position if in overrun phase if (isMoving_ && moveElapsed_ >= moveDuration_) { x = moveEndX_; y = moveEndY_; z = moveEndZ_; @@ -130,6 +142,7 @@ public: // Movement interpolation (syncs entity position with renderer during movement) void startMoveTo(float destX, float destY, float destZ, float destO, float durationSec) { usePathMode_ = false; + activeSpline_.reset(); if (durationSec <= 0.0f) { setPosition(destX, destY, destZ, destO); return; @@ -172,24 +185,14 @@ public: if (!isMoving_) return; moveElapsed_ += deltaTime; if (moveElapsed_ < moveDuration_) { - if (usePathMode_ && pathPoints_.size() > 1) { - // Multi-segment path interpolation - float totalDist = pathSegDists_.back(); - float t = moveElapsed_ / moveDuration_; - float targetDist = t * totalDist; - // Find the segment containing targetDist - size_t seg = 1; - while (seg < pathSegDists_.size() - 1 && pathSegDists_[seg] < targetDist) - seg++; - float segStart = pathSegDists_[seg - 1]; - float segEnd = pathSegDists_[seg]; - float segLen = segEnd - segStart; - float segT = (segLen > 0.001f) ? (targetDist - segStart) / segLen : 0.0f; - const auto& p0 = pathPoints_[seg - 1]; - const auto& p1 = pathPoints_[seg]; - x = p0[0] + (p1[0] - p0[0]) * segT; - y = p0[1] + (p1[1] - p0[1]) * segT; - z = p0[2] + (p1[2] - p0[2]) * segT; + if (usePathMode_ && activeSpline_) { + // Catmull-Rom spline interpolation + uint32_t pathTimeMs = static_cast(moveElapsed_ * 1000.0f); + if (pathTimeMs >= splineDurationMs_) pathTimeMs = splineDurationMs_ - 1; + glm::vec3 pos = activeSpline_->evaluatePosition(pathTimeMs); + x = pos.x; + y = pos.y; + z = pos.z; } else { // Single-segment linear interpolation float t = moveElapsed_ / moveDuration_; @@ -277,9 +280,9 @@ protected: float moveDuration_ = 0; float moveElapsed_ = 0; float velX_ = 0, velY_ = 0, velZ_ = 0; // Smoothed velocity for dead reckoning - // Multi-segment path data - std::vector> pathPoints_; - std::vector pathSegDists_; // Cumulative distances for each waypoint + // CatmullRom spline for multi-segment path movement (replaces linear pathPoints_/pathSegDists_) + std::optional activeSpline_; + uint32_t splineDurationMs_ = 0; }; /** diff --git a/include/game/transport_animator.hpp b/include/game/transport_animator.hpp new file mode 100644 index 00000000..1102cd89 --- /dev/null +++ b/include/game/transport_animator.hpp @@ -0,0 +1,33 @@ +// include/game/transport_animator.hpp +// Path evaluation, Z clamping, and orientation for transports. +// Extracted from TransportManager (Phase 3b of spline refactoring). +#pragma once + +#include +#include +#include + +namespace wowee::math { class CatmullRomSpline; } + +namespace wowee::game { + +struct ActiveTransport; +struct PathEntry; + +/// Evaluates a transport's position and orientation from a spline path and path time. +class TransportAnimator { +public: + /// Evaluate the spline at pathTimeMs, apply Z clamping, and update + /// transport.position and transport.rotation in-place. + void evaluateAndApply( + ActiveTransport& transport, + const PathEntry& pathEntry, + uint32_t pathTimeMs) const; + +private: + /// Guard against bad fallback Z offsets on non-world-coordinate paths. + static float clampZOffset(float z, bool worldCoords, bool clientAnim, + int serverUpdateCount, bool hasServerClock); +}; + +} // namespace wowee::game diff --git a/include/game/transport_clock_sync.hpp b/include/game/transport_clock_sync.hpp new file mode 100644 index 00000000..fe4286c2 --- /dev/null +++ b/include/game/transport_clock_sync.hpp @@ -0,0 +1,49 @@ +// include/game/transport_clock_sync.hpp +// Clock synchronization and yaw correction for transports. +// Extracted from TransportManager (Phase 3a of spline refactoring). +#pragma once + +#include +#include + +namespace wowee::math { class CatmullRomSpline; } + +namespace wowee::game { + +struct ActiveTransport; +struct PathEntry; + +/// Manages clock sync, server-yaw correction, and velocity bootstrap for a single transport. +class TransportClockSync { +public: + /// Compute pathTimeMs for a transport given current elapsed time and deltaTime. + /// Returns false if the transport should use raw server position (no interpolation). + [[nodiscard]] bool computePathTime( + ActiveTransport& transport, + const math::CatmullRomSpline& spline, + double elapsedTime, + float deltaTime, + uint32_t& outPathTimeMs) const; + + /// Process a server position update: update clock offset, detect yaw flips, + /// bootstrap velocity, and switch between client/server driven modes. + void processServerUpdate( + ActiveTransport& transport, + const PathEntry* pathEntry, + const glm::vec3& position, + float orientation, + double elapsedTime); + +private: + /// Detect and apply 180-degree yaw correction based on movement vs heading alignment. + void updateYawAlignment( + ActiveTransport& transport, + const glm::vec3& velocity) const; + + /// Bootstrap velocity from nearest DBC path segment on first authoritative sample. + void bootstrapVelocityFromPath( + ActiveTransport& transport, + const PathEntry& pathEntry) const; +}; + +} // namespace wowee::game diff --git a/include/game/transport_manager.hpp b/include/game/transport_manager.hpp index 4c374341..94904c17 100644 --- a/include/game/transport_manager.hpp +++ b/include/game/transport_manager.hpp @@ -1,6 +1,8 @@ #pragma once #include "game/transport_path_repository.hpp" +#include "game/transport_clock_sync.hpp" +#include "game/transport_animator.hpp" #include #include #include @@ -124,10 +126,11 @@ public: private: void updateTransportMovement(ActiveTransport& transport, float deltaTime); void updateTransformMatrices(ActiveTransport& transport); - /// Legacy transport orientation from tangent (preserves original cross-product order). - static glm::quat orientationFromSplineTangent(const glm::vec3& tangent); + void pushTransform(ActiveTransport& transport); TransportPathRepository pathRepo_; + TransportClockSync clockSync_; + TransportAnimator animator_; std::unordered_map transports_; rendering::WMORenderer* wmoRenderer_ = nullptr; rendering::M2Renderer* m2Renderer_ = nullptr; diff --git a/src/game/transport_animator.cpp b/src/game/transport_animator.cpp new file mode 100644 index 00000000..ba0344c0 --- /dev/null +++ b/src/game/transport_animator.cpp @@ -0,0 +1,64 @@ +// src/game/transport_animator.cpp +// Path evaluation, Z clamping, and orientation for transports. +// Extracted from TransportManager::updateTransportMovement (Phase 3b of spline refactoring). +#include "game/transport_animator.hpp" +#include "game/transport_manager.hpp" +#include "game/transport_path_repository.hpp" +#include "math/spline.hpp" +#include +#include + +namespace wowee::game { + +void TransportAnimator::evaluateAndApply( + ActiveTransport& transport, + const PathEntry& pathEntry, + uint32_t pathTimeMs) const +{ + const auto& spline = pathEntry.spline; + + // Evaluate position from time via CatmullRomSpline (path is local offsets, add base position) + glm::vec3 pathOffset = spline.evaluatePosition(pathTimeMs); + + pathOffset.z = clampZOffset( + pathOffset.z, + pathEntry.worldCoords, + transport.useClientAnimation, + transport.serverUpdateCount, + transport.hasServerClock); + + transport.position = transport.basePosition + pathOffset; + + // Use server yaw if available (authoritative), otherwise compute from spline tangent + if (transport.hasServerYaw) { + float effectiveYaw = transport.serverYaw + + (transport.serverYawFlipped180 ? glm::pi() : 0.0f); + transport.rotation = glm::angleAxis(effectiveYaw, glm::vec3(0.0f, 0.0f, 1.0f)); + } else { + auto result = spline.evaluate(pathTimeMs); + transport.rotation = math::CatmullRomSpline::orientationFromTangent(result.tangent); + } +} + +float TransportAnimator::clampZOffset(float z, bool worldCoords, bool clientAnim, + int serverUpdateCount, bool hasServerClock) +{ + // Skip Z clamping for world-coordinate paths (TaxiPathNode) where values are absolute positions. + if (worldCoords) return z; + + constexpr float kMinFallbackZOffset = -2.0f; + constexpr float kMaxFallbackZOffset = 8.0f; + + // Clamp fallback Z offsets for non-world-coordinate paths to prevent transport + // models from sinking below sea level on paths derived only from spawn-time data + // (notably icebreaker routes where the DBC path has steep vertical curves). + if (clientAnim && serverUpdateCount <= 1) { + z = std::max(z, kMinFallbackZOffset); + } + if (!clientAnim && !hasServerClock) { + z = std::clamp(z, kMinFallbackZOffset, kMaxFallbackZOffset); + } + return z; +} + +} // namespace wowee::game diff --git a/src/game/transport_clock_sync.cpp b/src/game/transport_clock_sync.cpp new file mode 100644 index 00000000..5db82f2e --- /dev/null +++ b/src/game/transport_clock_sync.cpp @@ -0,0 +1,272 @@ +// src/game/transport_clock_sync.cpp +// Clock synchronization and yaw correction for transports. +// Extracted from TransportManager (Phase 3a of spline refactoring). +#include "game/transport_clock_sync.hpp" +#include "game/transport_manager.hpp" +#include "game/transport_path_repository.hpp" +#include "math/spline.hpp" +#include "core/logger.hpp" +#include +#include + +namespace wowee::game { + +bool TransportClockSync::computePathTime( + ActiveTransport& transport, + const math::CatmullRomSpline& spline, + double elapsedTime, + float deltaTime, + uint32_t& outPathTimeMs) const +{ + uint32_t nowMs = static_cast(elapsedTime * 1000.0); + uint32_t durationMs = spline.durationMs(); + + if (transport.hasServerClock) { + // Predict server time using clock offset (works for both client and server-driven modes) + int64_t serverTimeMs = static_cast(nowMs) + transport.serverClockOffsetMs; + int64_t mod = static_cast(durationMs); + int64_t wrapped = serverTimeMs % mod; + if (wrapped < 0) wrapped += mod; + outPathTimeMs = static_cast(wrapped); + return true; + } + + if (transport.useClientAnimation) { + // Pure local clock (no server sync yet, client-driven) + uint32_t dtMs = static_cast(deltaTime * 1000.0f); + if (!transport.clientAnimationReverse) { + transport.localClockMs += dtMs; + } else { + if (dtMs > durationMs) { + dtMs %= durationMs; + } + if (transport.localClockMs >= dtMs) { + transport.localClockMs -= dtMs; + } else { + transport.localClockMs = durationMs - (dtMs - transport.localClockMs); + } + } + outPathTimeMs = transport.localClockMs % durationMs; + return true; + } + + // Strict server-authoritative mode: do not guess movement between server snapshots. + return false; +} + +void TransportClockSync::processServerUpdate( + ActiveTransport& transport, + const PathEntry* pathEntry, + const glm::vec3& position, + float orientation, + double elapsedTime) +{ + const bool hadPrevUpdate = (transport.serverUpdateCount > 0); + const double prevUpdateTime = transport.lastServerUpdate; + const glm::vec3 prevPos = transport.position; + + const bool hasPath = (pathEntry != nullptr); + const bool isZOnlyPath = (hasPath && pathEntry->fromDBC && pathEntry->zOnly && pathEntry->spline.durationMs() > 0); + const bool isWorldCoordPath = (hasPath && pathEntry->worldCoords && pathEntry->spline.durationMs() > 0); + + // Don't let (0,0,0) server updates override a TaxiPathNode world-coordinate path + if (isWorldCoordPath && glm::dot(position, position) < 1.0f) { + transport.serverUpdateCount++; + transport.lastServerUpdate = elapsedTime; + transport.serverYaw = orientation; + transport.hasServerYaw = true; + return; + } + + // Track server updates + transport.serverUpdateCount++; + transport.lastServerUpdate = elapsedTime; + // Z-only elevators and world-coordinate paths (TaxiPathNode) always stay client-driven. + // For other DBC paths (trams, ships): only switch to server-driven mode when the server + // sends a position that actually differs from the current position, indicating it's + // actively streaming movement data (not just echoing the spawn position). + if (isZOnlyPath || isWorldCoordPath) { + transport.useClientAnimation = true; + } else if (transport.useClientAnimation && hasPath && pathEntry->fromDBC) { + glm::vec3 pd = position - transport.position; + float posDeltaSq = glm::dot(pd, pd); + if (posDeltaSq > 1.0f) { + // Server sent a meaningfully different position — it's actively driving this transport + transport.useClientAnimation = false; + LOG_INFO("Transport 0x", std::hex, transport.guid, std::dec, + " switching to server-driven (posDeltaSq=", posDeltaSq, ")"); + } + // Otherwise keep client animation (server just echoed spawn pos or sent small jitter) + } else if (!hasPath || !pathEntry->fromDBC) { + // No DBC path — purely server-driven + transport.useClientAnimation = false; + } + transport.clientAnimationReverse = false; + + // Server-authoritative transport mode: + // Trust explicit server world position/orientation directly for all moving transports. + transport.hasServerClock = false; + if (transport.serverUpdateCount == 1) { + // Seed once from first authoritative update; keep stable base for fallback phase estimation. + // For z-only elevator paths, keep the spawn-derived basePosition (the DBC path is local offsets). + if (!isZOnlyPath) { + transport.basePosition = position; + } + } + transport.position = position; + transport.serverYaw = orientation; + transport.hasServerYaw = true; + float effectiveYaw = transport.serverYaw + (transport.serverYawFlipped180 ? glm::pi() : 0.0f); + transport.rotation = glm::angleAxis(effectiveYaw, glm::vec3(0.0f, 0.0f, 1.0f)); + + if (hadPrevUpdate) { + const double dt = elapsedTime - prevUpdateTime; + if (dt > 0.001) { + glm::vec3 v = (position - prevPos) / static_cast(dt); + float speedSq = glm::dot(v, v); + constexpr float kMinAuthoritativeSpeed = 0.15f; + constexpr float kMaxSpeed = 60.0f; + if (speedSq >= kMinAuthoritativeSpeed * kMinAuthoritativeSpeed) { + updateYawAlignment(transport, v); + + if (speedSq > kMaxSpeed * kMaxSpeed) { + v *= (kMaxSpeed * glm::inversesqrt(speedSq)); + } + + transport.serverLinearVelocity = v; + transport.serverAngularVelocity = 0.0f; + transport.hasServerVelocity = true; + + // Re-apply potentially corrected yaw this frame after alignment check. + effectiveYaw = transport.serverYaw + (transport.serverYawFlipped180 ? glm::pi() : 0.0f); + transport.rotation = glm::angleAxis(effectiveYaw, glm::vec3(0.0f, 0.0f, 1.0f)); + } + } + } else { + // Seed fallback path phase from nearest waypoint to the first authoritative sample. + if (pathEntry && pathEntry->spline.keyCount() > 0 && pathEntry->spline.durationMs() > 0) { + glm::vec3 local = position - transport.basePosition; + size_t bestIdx = pathEntry->spline.findNearestKey(local); + transport.localClockMs = pathEntry->spline.keys()[bestIdx].timeMs % pathEntry->spline.durationMs(); + } + + // Bootstrap velocity from mapped DBC path on first authoritative sample. + if (transport.allowBootstrapVelocity && pathEntry && pathEntry->spline.keyCount() >= 2 && pathEntry->spline.durationMs() > 0) { + bootstrapVelocityFromPath(transport, *pathEntry); + } else if (!transport.allowBootstrapVelocity) { + LOG_INFO("Transport 0x", std::hex, transport.guid, std::dec, + " DBC bootstrap velocity disabled for this transport"); + } + } +} + +void TransportClockSync::updateYawAlignment( + ActiveTransport& transport, + const glm::vec3& velocity) const +{ + // Auto-detect 180-degree yaw mismatch by comparing heading to movement direction. + // Some transports appear to report yaw opposite their actual travel direction. + glm::vec2 horizontalV(velocity.x, velocity.y); + float hLenSq = glm::dot(horizontalV, horizontalV); + if (hLenSq > 0.04f) { + horizontalV *= glm::inversesqrt(hLenSq); + glm::vec2 heading(std::cos(transport.serverYaw), std::sin(transport.serverYaw)); + float alignDot = glm::dot(heading, horizontalV); + + if (alignDot < -0.35f) { + transport.serverYawAlignmentScore = std::max(transport.serverYawAlignmentScore - 1, -12); + } else if (alignDot > 0.35f) { + transport.serverYawAlignmentScore = std::min(transport.serverYawAlignmentScore + 1, 12); + } + + if (!transport.serverYawFlipped180 && transport.serverYawAlignmentScore <= -4) { + transport.serverYawFlipped180 = true; + LOG_INFO("Transport 0x", std::hex, transport.guid, std::dec, + " enabled 180-degree yaw correction (alignScore=", + transport.serverYawAlignmentScore, ")"); + } else if (transport.serverYawFlipped180 && + transport.serverYawAlignmentScore >= 4) { + transport.serverYawFlipped180 = false; + LOG_INFO("Transport 0x", std::hex, transport.guid, std::dec, + " disabled 180-degree yaw correction (alignScore=", + transport.serverYawAlignmentScore, ")"); + } + } +} + +void TransportClockSync::bootstrapVelocityFromPath( + ActiveTransport& transport, + const PathEntry& pathEntry) const +{ + // Bootstrap velocity from nearest DBC path segment on first authoritative sample. + // This avoids "stalled at dock" when server sends sparse transport snapshots. + const auto& keys = pathEntry.spline.keys(); + glm::vec3 local = transport.position - transport.basePosition; + size_t bestIdx = pathEntry.spline.findNearestKey(local); + + float bestDistSq = 0.0f; + { + glm::vec3 d = keys[bestIdx].position - local; + bestDistSq = glm::dot(d, d); + } + + constexpr float kMaxBootstrapNearestDist = 80.0f; + if (bestDistSq > (kMaxBootstrapNearestDist * kMaxBootstrapNearestDist)) { + LOG_WARNING("Transport 0x", std::hex, transport.guid, std::dec, + " skipping DBC bootstrap velocity: nearest path point too far (dist=", + std::sqrt(bestDistSq), ", path=", transport.pathId, ")"); + return; + } + + size_t n = keys.size(); + uint32_t durMs = pathEntry.spline.durationMs(); + constexpr float kMinBootstrapSpeed = 0.25f; + constexpr float kMaxSpeed = 60.0f; + + auto tryApplySegment = [&](size_t a, size_t b) { + uint32_t t0 = keys[a].timeMs; + uint32_t t1 = keys[b].timeMs; + if (b == 0 && t1 <= t0 && durMs > 0) { + t1 = durMs; + } + if (t1 <= t0) return; + glm::vec3 seg = keys[b].position - keys[a].position; + float dtSeg = static_cast(t1 - t0) / 1000.0f; + if (dtSeg <= 0.001f) return; + glm::vec3 v = seg / dtSeg; + float speedSq = glm::dot(v, v); + if (speedSq < kMinBootstrapSpeed * kMinBootstrapSpeed) return; + if (speedSq > kMaxSpeed * kMaxSpeed) { + v *= (kMaxSpeed * glm::inversesqrt(speedSq)); + } + transport.serverLinearVelocity = v; + transport.serverAngularVelocity = 0.0f; + transport.hasServerVelocity = true; + }; + + // Prefer nearest forward meaningful segment from bestIdx. + for (size_t step = 1; step < n && !transport.hasServerVelocity; ++step) { + size_t a = (bestIdx + step - 1) % n; + size_t b = (bestIdx + step) % n; + tryApplySegment(a, b); + } + // Fallback: nearest backward meaningful segment. + for (size_t step = 1; step < n && !transport.hasServerVelocity; ++step) { + size_t b = (bestIdx + n - step + 1) % n; + size_t a = (bestIdx + n - step) % n; + tryApplySegment(a, b); + } + + if (transport.hasServerVelocity) { + LOG_INFO("Transport 0x", std::hex, transport.guid, std::dec, + " bootstrapped velocity from DBC path ", transport.pathId, + " v=(", transport.serverLinearVelocity.x, ", ", + transport.serverLinearVelocity.y, ", ", + transport.serverLinearVelocity.z, ")"); + } else { + LOG_INFO("Transport 0x", std::hex, transport.guid, std::dec, + " skipped DBC bootstrap velocity (segment too short/static)"); + } +} + +} // namespace wowee::game diff --git a/src/game/transport_manager.cpp b/src/game/transport_manager.cpp index 9507a22e..65be6b45 100644 --- a/src/game/transport_manager.cpp +++ b/src/game/transport_manager.cpp @@ -1,4 +1,6 @@ #include "game/transport_manager.hpp" +#include "game/transport_clock_sync.hpp" +#include "game/transport_animator.hpp" #include "rendering/wmo_renderer.hpp" #include "rendering/m2_renderer.hpp" #include "core/coordinates.hpp" @@ -105,11 +107,7 @@ void TransportManager::registerTransport(uint64_t guid, uint32_t wmoInstanceId, updateTransformMatrices(transport); // CRITICAL: Update WMO renderer with initial transform - if (transport.isM2) { - if (m2Renderer_) m2Renderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform); - } else { - if (wmoRenderer_) wmoRenderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform); - } + pushTransform(transport); transports_[guid] = transport; @@ -195,129 +193,44 @@ void TransportManager::updateTransportMovement(ActiveTransport& transport, float if (spline.durationMs() == 0) { // Just update transform (position already set) updateTransformMatrices(transport); - if (transport.isM2) { - if (m2Renderer_) m2Renderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform); - } else { - if (wmoRenderer_) wmoRenderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform); - } + pushTransform(transport); return; } - // Evaluate path time - uint32_t nowMs = static_cast(elapsedTime_ * 1000.0); + // Compute path time via ClockSync uint32_t pathTimeMs = 0; - uint32_t durationMs = spline.durationMs(); - - if (transport.hasServerClock) { - // Predict server time using clock offset (works for both client and server-driven modes) - int64_t serverTimeMs = static_cast(nowMs) + transport.serverClockOffsetMs; - int64_t mod = static_cast(durationMs); - int64_t wrapped = serverTimeMs % mod; - if (wrapped < 0) wrapped += mod; - pathTimeMs = static_cast(wrapped); - } else if (transport.useClientAnimation) { - // Pure local clock (no server sync yet, client-driven) - uint32_t dtMs = static_cast(deltaTime * 1000.0f); - if (!transport.clientAnimationReverse) { - transport.localClockMs += dtMs; - } else { - if (dtMs > durationMs) { - dtMs %= durationMs; - } - if (transport.localClockMs >= dtMs) { - transport.localClockMs -= dtMs; - } else { - transport.localClockMs = durationMs - (dtMs - transport.localClockMs); - } - } - pathTimeMs = transport.localClockMs % durationMs; - } else { + if (!clockSync_.computePathTime(transport, spline, elapsedTime_, deltaTime, pathTimeMs)) { // Strict server-authoritative mode: do not guess movement between server snapshots. updateTransformMatrices(transport); - if (transport.isM2) { - if (m2Renderer_) m2Renderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform); - } else { - if (wmoRenderer_) wmoRenderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform); - } + pushTransform(transport); return; } - // Evaluate position from time via CatmullRomSpline (path is local offsets, add base position) - glm::vec3 pathOffset = spline.evaluatePosition(pathTimeMs); - // Guard against bad fallback Z curves on some remapped transport paths (notably icebreakers), - // where path offsets can sink far below sea level when we only have spawn-time data. - // Skip Z clamping for world-coordinate paths (TaxiPathNode) where values are absolute positions. - // Clamp fallback Z offsets for non-world-coordinate paths to prevent transport - // models from sinking below sea level on paths derived only from spawn-time data - // (notably icebreaker routes where the DBC path has steep vertical curves). - constexpr float kMinFallbackZOffset = -2.0f; - constexpr float kMaxFallbackZOffset = 8.0f; - if (!pathEntry->worldCoords) { - if (transport.useClientAnimation && transport.serverUpdateCount <= 1) { - pathOffset.z = glm::max(pathOffset.z, kMinFallbackZOffset); - } - if (!transport.useClientAnimation && !transport.hasServerClock) { - pathOffset.z = glm::clamp(pathOffset.z, kMinFallbackZOffset, kMaxFallbackZOffset); - } - } - transport.position = transport.basePosition + pathOffset; - - // Use server yaw if available (authoritative), otherwise compute from spline tangent - if (transport.hasServerYaw) { - float effectiveYaw = transport.serverYaw + (transport.serverYawFlipped180 ? glm::pi() : 0.0f); - transport.rotation = glm::angleAxis(effectiveYaw, glm::vec3(0.0f, 0.0f, 1.0f)); - } else { - auto result = spline.evaluate(pathTimeMs); - transport.rotation = orientationFromSplineTangent(result.tangent); - } + // Evaluate position + rotation via Animator + animator_.evaluateAndApply(transport, *pathEntry, pathTimeMs); // Update transform matrices updateTransformMatrices(transport); - - // Update WMO instance position - if (transport.isM2) { - if (m2Renderer_) m2Renderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform); - } else { - if (wmoRenderer_) wmoRenderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform); - } + pushTransform(transport); // Debug logging every 600 frames (~10 seconds at 60fps) static int debugFrameCount = 0; if (debugFrameCount++ % 600 == 0) { LOG_DEBUG("Transport 0x", std::hex, transport.guid, std::dec, - " pathTime=", pathTimeMs, "ms / ", durationMs, "ms", + " pathTime=", pathTimeMs, "ms / ", spline.durationMs(), "ms", " pos=(", transport.position.x, ", ", transport.position.y, ", ", transport.position.z, ")", " mode=", (transport.useClientAnimation ? "client" : "server"), " isM2=", transport.isM2); } } -// Legacy transport orientation from spline tangent. -// Preserves the original TransportManager cross-product order for visual consistency. -glm::quat TransportManager::orientationFromSplineTangent(const glm::vec3& tangent) { - float tangentLenSq = glm::dot(tangent, tangent); - if (tangentLenSq < 1e-6f) { - return glm::quat(1.0f, 0.0f, 0.0f, 0.0f); // Identity +// Push transform to the appropriate renderer (WMO or M2). +void TransportManager::pushTransform(ActiveTransport& transport) { + if (transport.isM2) { + if (m2Renderer_) m2Renderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform); + } else { + if (wmoRenderer_) wmoRenderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform); } - - glm::vec3 forward = tangent * glm::inversesqrt(tangentLenSq); - glm::vec3 up(0.0f, 0.0f, 1.0f); // WoW Z is up - - // If forward is nearly vertical, use different up vector - if (std::abs(forward.z) > 0.99f) { - up = glm::vec3(0.0f, 1.0f, 0.0f); - } - - glm::vec3 right = glm::normalize(glm::cross(up, forward)); - up = glm::cross(forward, right); - - // Build rotation matrix and convert to quaternion - glm::mat3 rotMat; - rotMat[0] = right; - rotMat[1] = forward; - rotMat[2] = up; - - return glm::quat_cast(rotMat); } void TransportManager::updateTransformMatrices(ActiveTransport& transport) { @@ -349,220 +262,26 @@ void TransportManager::updateServerTransport(uint64_t guid, const glm::vec3& pos return; } - const bool hadPrevUpdate = (transport->serverUpdateCount > 0); - const float prevUpdateTime = transport->lastServerUpdate; - const glm::vec3 prevPos = transport->position; - auto* pathEntry = pathRepo_.findPath(transport->pathId); - const bool hasPath = (pathEntry != nullptr); - const bool isZOnlyPath = (hasPath && pathEntry->fromDBC && pathEntry->zOnly && pathEntry->spline.durationMs() > 0); - const bool isWorldCoordPath = (hasPath && pathEntry->worldCoords && pathEntry->spline.durationMs() > 0); - // Don't let (0,0,0) server updates override a TaxiPathNode world-coordinate path - if (isWorldCoordPath && glm::dot(position, position) < 1.0f) { + if (!pathEntry || pathEntry->spline.durationMs() == 0) { + // No path or stationary — handle directly before delegating to ClockSync. + // Still track update count so future path assignments work. transport->serverUpdateCount++; transport->lastServerUpdate = elapsedTime_; - transport->serverYaw = orientation; - transport->hasServerYaw = true; - return; - } - - // Track server updates - transport->serverUpdateCount++; - transport->lastServerUpdate = elapsedTime_; - // Z-only elevators and world-coordinate paths (TaxiPathNode) always stay client-driven. - // For other DBC paths (trams, ships): only switch to server-driven mode when the server - // sends a position that actually differs from the current position, indicating it's - // actively streaming movement data (not just echoing the spawn position). - if (isZOnlyPath || isWorldCoordPath) { - transport->useClientAnimation = true; - } else if (transport->useClientAnimation && hasPath && pathEntry->fromDBC) { - glm::vec3 pd = position - transport->position; - float posDeltaSq = glm::dot(pd, pd); - if (posDeltaSq > 1.0f) { - // Server sent a meaningfully different position — it's actively driving this transport - transport->useClientAnimation = false; - LOG_INFO("Transport 0x", std::hex, guid, std::dec, - " switching to server-driven (posDeltaSq=", posDeltaSq, ")"); - } - // Otherwise keep client animation (server just echoed spawn pos or sent small jitter) - } else if (!hasPath || !pathEntry->fromDBC) { - // No DBC path — purely server-driven - transport->useClientAnimation = false; - } - transport->clientAnimationReverse = false; - - if (!hasPath || pathEntry->spline.durationMs() == 0) { - // No path or stationary - just set position directly transport->basePosition = position; transport->position = position; transport->rotation = glm::angleAxis(orientation, glm::vec3(0.0f, 0.0f, 1.0f)); updateTransformMatrices(*transport); - if (transport->isM2) { - if (m2Renderer_) m2Renderer_->setInstanceTransform(transport->wmoInstanceId, transport->transform); - } else { - if (wmoRenderer_) wmoRenderer_->setInstanceTransform(transport->wmoInstanceId, transport->transform); - } + pushTransform(*transport); return; } - // Server-authoritative transport mode: - // Trust explicit server world position/orientation directly for all moving transports. - // This avoids wrong-route and direction errors when local DBC path mapping differs from server route IDs. - transport->hasServerClock = false; - if (transport->serverUpdateCount == 1) { - // Seed once from first authoritative update; keep stable base for fallback phase estimation. - // For z-only elevator paths, keep the spawn-derived basePosition (the DBC path is local offsets). - if (!isZOnlyPath) { - transport->basePosition = position; - } - } - transport->position = position; - transport->serverYaw = orientation; - transport->hasServerYaw = true; - float effectiveYaw = transport->serverYaw + (transport->serverYawFlipped180 ? glm::pi() : 0.0f); - transport->rotation = glm::angleAxis(effectiveYaw, glm::vec3(0.0f, 0.0f, 1.0f)); - - if (hadPrevUpdate) { - const float dt = elapsedTime_ - prevUpdateTime; - if (dt > 0.001f) { - glm::vec3 v = (position - prevPos) / dt; - float speedSq = glm::dot(v, v); - constexpr float kMinAuthoritativeSpeed = 0.15f; - constexpr float kMaxSpeed = 60.0f; - if (speedSq >= kMinAuthoritativeSpeed * kMinAuthoritativeSpeed) { - // Auto-detect 180-degree yaw mismatch by comparing heading to movement direction. - // Some transports appear to report yaw opposite their actual travel direction. - glm::vec2 horizontalV(v.x, v.y); - float hLenSq = glm::dot(horizontalV, horizontalV); - if (hLenSq > 0.04f) { - horizontalV *= glm::inversesqrt(hLenSq); - glm::vec2 heading(std::cos(transport->serverYaw), std::sin(transport->serverYaw)); - float alignDot = glm::dot(heading, horizontalV); - - if (alignDot < -0.35f) { - transport->serverYawAlignmentScore = std::max(transport->serverYawAlignmentScore - 1, -12); - } else if (alignDot > 0.35f) { - transport->serverYawAlignmentScore = std::min(transport->serverYawAlignmentScore + 1, 12); - } - - if (!transport->serverYawFlipped180 && transport->serverYawAlignmentScore <= -4) { - transport->serverYawFlipped180 = true; - LOG_INFO("Transport 0x", std::hex, guid, std::dec, - " enabled 180-degree yaw correction (alignScore=", - transport->serverYawAlignmentScore, ")"); - } else if (transport->serverYawFlipped180 && - transport->serverYawAlignmentScore >= 4) { - transport->serverYawFlipped180 = false; - LOG_INFO("Transport 0x", std::hex, guid, std::dec, - " disabled 180-degree yaw correction (alignScore=", - transport->serverYawAlignmentScore, ")"); - } - } - - if (speedSq > kMaxSpeed * kMaxSpeed) { - v *= (kMaxSpeed * glm::inversesqrt(speedSq)); - } - - transport->serverLinearVelocity = v; - transport->serverAngularVelocity = 0.0f; - transport->hasServerVelocity = true; - - // Re-apply potentially corrected yaw this frame after alignment check. - effectiveYaw = transport->serverYaw + (transport->serverYawFlipped180 ? glm::pi() : 0.0f); - transport->rotation = glm::angleAxis(effectiveYaw, glm::vec3(0.0f, 0.0f, 1.0f)); - } - } - } else { - // Seed fallback path phase from nearest waypoint to the first authoritative sample. - if (pathEntry && pathEntry->spline.keyCount() > 0 && pathEntry->spline.durationMs() > 0) { - glm::vec3 local = position - transport->basePosition; - size_t bestIdx = pathEntry->spline.findNearestKey(local); - transport->localClockMs = pathEntry->spline.keys()[bestIdx].timeMs % pathEntry->spline.durationMs(); - } - - // Bootstrap velocity from mapped DBC path on first authoritative sample. - // This avoids "stalled at dock" when server sends sparse transport snapshots. - if (transport->allowBootstrapVelocity && pathEntry && pathEntry->spline.keyCount() >= 2 && pathEntry->spline.durationMs() > 0) { - const auto& keys = pathEntry->spline.keys(); - glm::vec3 local = position - transport->basePosition; - size_t bestIdx = pathEntry->spline.findNearestKey(local); - - float bestDistSq = 0.0f; - { - glm::vec3 d = keys[bestIdx].position - local; - bestDistSq = glm::dot(d, d); - } - - constexpr float kMaxBootstrapNearestDist = 80.0f; - if (bestDistSq > (kMaxBootstrapNearestDist * kMaxBootstrapNearestDist)) { - LOG_WARNING("Transport 0x", std::hex, guid, std::dec, - " skipping DBC bootstrap velocity: nearest path point too far (dist=", - std::sqrt(bestDistSq), ", path=", transport->pathId, ")"); - } else { - size_t n = keys.size(); - uint32_t durMs = pathEntry->spline.durationMs(); - constexpr float kMinBootstrapSpeed = 0.25f; - constexpr float kMaxSpeed = 60.0f; - - auto tryApplySegment = [&](size_t a, size_t b) { - uint32_t t0 = keys[a].timeMs; - uint32_t t1 = keys[b].timeMs; - if (b == 0 && t1 <= t0 && durMs > 0) { - t1 = durMs; - } - if (t1 <= t0) return; - glm::vec3 seg = keys[b].position - keys[a].position; - float dtSeg = static_cast(t1 - t0) / 1000.0f; - if (dtSeg <= 0.001f) return; - glm::vec3 v = seg / dtSeg; - float speedSq = glm::dot(v, v); - if (speedSq < kMinBootstrapSpeed * kMinBootstrapSpeed) return; - if (speedSq > kMaxSpeed * kMaxSpeed) { - v *= (kMaxSpeed * glm::inversesqrt(speedSq)); - } - transport->serverLinearVelocity = v; - transport->serverAngularVelocity = 0.0f; - transport->hasServerVelocity = true; - }; - - // Prefer nearest forward meaningful segment from bestIdx. - for (size_t step = 1; step < n && !transport->hasServerVelocity; ++step) { - size_t a = (bestIdx + step - 1) % n; - size_t b = (bestIdx + step) % n; - tryApplySegment(a, b); - } - // Fallback: nearest backward meaningful segment. - for (size_t step = 1; step < n && !transport->hasServerVelocity; ++step) { - size_t b = (bestIdx + n - step + 1) % n; - size_t a = (bestIdx + n - step) % n; - tryApplySegment(a, b); - } - - if (transport->hasServerVelocity) { - LOG_INFO("Transport 0x", std::hex, guid, std::dec, - " bootstrapped velocity from DBC path ", transport->pathId, - " v=(", transport->serverLinearVelocity.x, ", ", - transport->serverLinearVelocity.y, ", ", - transport->serverLinearVelocity.z, ")"); - } else { - LOG_INFO("Transport 0x", std::hex, guid, std::dec, - " skipped DBC bootstrap velocity (segment too short/static)"); - } - } - } else if (!transport->allowBootstrapVelocity) { - LOG_INFO("Transport 0x", std::hex, guid, std::dec, - " DBC bootstrap velocity disabled for this transport"); - } - } + // Delegate clock sync, yaw correction, and velocity bootstrap to ClockSync. + clockSync_.processServerUpdate(*transport, pathEntry, position, orientation, elapsedTime_); updateTransformMatrices(*transport); - if (transport->isM2) { - if (m2Renderer_) m2Renderer_->setInstanceTransform(transport->wmoInstanceId, transport->transform); - } else { - if (wmoRenderer_) wmoRenderer_->setInstanceTransform(transport->wmoInstanceId, transport->transform); - } - return; + pushTransform(*transport); } bool TransportManager::loadTransportAnimationDBC(pipeline::AssetManager* assetMgr) { @@ -609,9 +328,7 @@ bool TransportManager::assignTaxiPathToTransport(uint32_t entry, uint32_t taxiPa } updateTransformMatrices(transport); - if (wmoRenderer_) { - wmoRenderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform); - } + pushTransform(transport); LOG_INFO("Assigned TaxiPathNode path to transport 0x", std::hex, guid, std::dec, " entry=", entry, " taxiPathId=", taxiPathId, diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 667475f9..40bfc558 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -102,6 +102,7 @@ add_executable(test_entity test_entity.cpp ${TEST_COMMON_SOURCES} ${CMAKE_SOURCE_DIR}/src/game/entity.cpp + ${CMAKE_SOURCE_DIR}/src/math/spline.cpp ) target_include_directories(test_entity PRIVATE ${TEST_INCLUDE_DIRS}) target_include_directories(test_entity SYSTEM PRIVATE ${TEST_SYSTEM_INCLUDE_DIRS}) @@ -236,6 +237,20 @@ endif() add_test(NAME indoor_shadows COMMAND test_indoor_shadows) register_test_target(test_indoor_shadows) +# ── test_transport_components ──────────────────────────────── +add_executable(test_transport_components + test_transport_components.cpp + ${TEST_COMMON_SOURCES} + ${CMAKE_SOURCE_DIR}/src/game/transport_clock_sync.cpp + ${CMAKE_SOURCE_DIR}/src/game/transport_animator.cpp + ${CMAKE_SOURCE_DIR}/src/math/spline.cpp +) +target_include_directories(test_transport_components PRIVATE ${TEST_INCLUDE_DIRS}) +target_include_directories(test_transport_components SYSTEM PRIVATE ${TEST_SYSTEM_INCLUDE_DIRS}) +target_link_libraries(test_transport_components PRIVATE catch2_main) +add_test(NAME transport_components COMMAND test_transport_components) +register_test_target(test_transport_components) + # ── ASAN / UBSan for test targets ──────────────────────────── if(WOWEE_ENABLE_ASAN AND NOT MSVC) foreach(_t IN LISTS ALL_TEST_TARGETS) diff --git a/tests/test_transport_components.cpp b/tests/test_transport_components.cpp new file mode 100644 index 00000000..629e13aa --- /dev/null +++ b/tests/test_transport_components.cpp @@ -0,0 +1,209 @@ +// tests/test_transport_components.cpp +// Unit tests for TransportClockSync and TransportAnimator (Phase 3 extractions). +#include +#include "game/transport_clock_sync.hpp" +#include "game/transport_animator.hpp" +#include "game/transport_manager.hpp" +#include "game/transport_path_repository.hpp" +#include "math/spline.hpp" +#include +#include + +using namespace wowee::game; +using namespace wowee::math; + +// ── Helper: build a simple circular path ────────────────────────── +static PathEntry makeCirclePath() { + // Circle-ish path with 4 points, 4000ms duration + std::vector keys = { + {0, glm::vec3(0.0f, 0.0f, 0.0f)}, + {1000, glm::vec3(10.0f, 0.0f, 0.0f)}, + {2000, glm::vec3(10.0f, 10.0f, 0.0f)}, + {3000, glm::vec3(0.0f, 10.0f, 0.0f)}, + {4000, glm::vec3(0.0f, 0.0f, 0.0f)}, + }; + CatmullRomSpline spline(std::move(keys), /*timeClosed=*/true); + return PathEntry(std::move(spline), /*pathId=*/100, /*zOnly=*/false, /*fromDBC=*/true, /*worldCoords=*/false); +} + +// ── Helper: create a fresh ActiveTransport ──────────────────────── +static ActiveTransport makeTransport(uint64_t guid = 1, uint32_t pathId = 100) { + ActiveTransport t{}; + t.guid = guid; + t.pathId = pathId; + t.basePosition = glm::vec3(100.0f, 200.0f, 0.0f); + t.position = glm::vec3(100.0f, 200.0f, 0.0f); + t.rotation = glm::quat(1.0f, 0.0f, 0.0f, 0.0f); + t.playerOnBoard = false; + t.playerLocalOffset = glm::vec3(0); + t.hasDeckBounds = false; + t.localClockMs = 0; + t.hasServerClock = false; + t.serverClockOffsetMs = 0; + t.useClientAnimation = true; + t.clientAnimationReverse = false; + t.serverYaw = 0.0f; + t.hasServerYaw = false; + t.serverYawFlipped180 = false; + t.serverYawAlignmentScore = 0; + t.lastServerUpdate = 0.0; + t.serverUpdateCount = 0; + t.serverLinearVelocity = glm::vec3(0); + t.serverAngularVelocity = 0.0f; + t.hasServerVelocity = false; + t.allowBootstrapVelocity = true; + t.isM2 = false; + return t; +} + +// ══════════════════════════════════════════════════════════════════ +// TransportClockSync tests +// ══════════════════════════════════════════════════════════════════ + +TEST_CASE("ClockSync: client animation advances localClockMs", "[transport_clock_sync]") { + TransportClockSync sync; + auto path = makeCirclePath(); + auto t = makeTransport(); + t.useClientAnimation = true; + t.hasServerClock = false; + + uint32_t pathTimeMs = 0; + bool result = sync.computePathTime(t, path.spline, 1.0, 0.016f, pathTimeMs); + REQUIRE(result); + REQUIRE(t.localClockMs > 0); // Should have advanced + REQUIRE(pathTimeMs == t.localClockMs % path.spline.durationMs()); +} + +TEST_CASE("ClockSync: server clock mode wraps correctly", "[transport_clock_sync]") { + TransportClockSync sync; + auto path = makeCirclePath(); + auto t = makeTransport(); + t.hasServerClock = true; + t.serverClockOffsetMs = 500; // Server is 500ms ahead + + uint32_t pathTimeMs = 0; + double elapsedTime = 3.7; // 3700ms local → 4200ms server → 200ms wrapped (dur=4000) + bool result = sync.computePathTime(t, path.spline, elapsedTime, 0.016f, pathTimeMs); + REQUIRE(result); + REQUIRE(pathTimeMs == 200); +} + +TEST_CASE("ClockSync: strict server mode returns false", "[transport_clock_sync]") { + TransportClockSync sync; + auto path = makeCirclePath(); + auto t = makeTransport(); + t.useClientAnimation = false; + t.hasServerClock = false; + + uint32_t pathTimeMs = 0; + bool result = sync.computePathTime(t, path.spline, 1.0, 0.016f, pathTimeMs); + REQUIRE_FALSE(result); +} + +TEST_CASE("ClockSync: reverse client animation decrements", "[transport_clock_sync]") { + TransportClockSync sync; + auto path = makeCirclePath(); + auto t = makeTransport(); + t.useClientAnimation = true; + t.clientAnimationReverse = true; + t.localClockMs = 2000; + + uint32_t pathTimeMs = 0; + bool result = sync.computePathTime(t, path.spline, 1.0, 0.5f, pathTimeMs); + REQUIRE(result); + // localClockMs should have decreased by ~500ms + REQUIRE(t.localClockMs < 2000); +} + +TEST_CASE("ClockSync: processServerUpdate sets yaw and rotation", "[transport_clock_sync]") { + TransportClockSync sync; + auto path = makeCirclePath(); + auto t = makeTransport(); + + glm::vec3 pos(105.0f, 205.0f, 1.0f); + float yaw = 1.5f; + sync.processServerUpdate(t, &path, pos, yaw, 10.0); + + REQUIRE(t.serverUpdateCount == 1); + REQUIRE(t.hasServerYaw); + REQUIRE(t.serverYaw == Catch::Approx(1.5f)); + REQUIRE(t.position == pos); +} + +TEST_CASE("ClockSync: yaw flip detection after repeated misaligned updates", "[transport_clock_sync]") { + TransportClockSync sync; + auto path = makeCirclePath(); + auto t = makeTransport(); + t.useClientAnimation = false; + + // Simulate transport moving east (+X) but reporting yaw pointing west (pi) + float westYaw = glm::pi(); + glm::vec3 pos(100.0f, 200.0f, 0.0f); + sync.processServerUpdate(t, &path, pos, westYaw, 1.0); + + // Send several updates moving east with west-facing yaw + for (int i = 1; i <= 8; i++) { + pos.x += 5.0f; + sync.processServerUpdate(t, &path, pos, westYaw, 1.0 + i * 0.5); + } + + // After enough misaligned updates, should have flipped + REQUIRE(t.serverYawFlipped180); +} + +// ══════════════════════════════════════════════════════════════════ +// TransportAnimator tests +// ══════════════════════════════════════════════════════════════════ + +TEST_CASE("Animator: evaluateAndApply updates position from spline", "[transport_animator]") { + TransportAnimator animator; + auto path = makeCirclePath(); + auto t = makeTransport(); + t.hasServerYaw = false; + + animator.evaluateAndApply(t, path, 0); + // At t=0, path offset is (0,0,0), so pos = base + (0,0,0) = (100,200,0) + REQUIRE(t.position.x == Catch::Approx(100.0f)); + REQUIRE(t.position.y == Catch::Approx(200.0f)); + + animator.evaluateAndApply(t, path, 1000); + // At t=1000, path offset is (10,0,0), so pos = base + (10,0,0) = (110,200,0) + REQUIRE(t.position.x == Catch::Approx(110.0f)); +} + +TEST_CASE("Animator: uses server yaw when available", "[transport_animator]") { + TransportAnimator animator; + auto path = makeCirclePath(); + auto t = makeTransport(); + t.hasServerYaw = true; + t.serverYaw = 1.0f; + t.serverYawFlipped180 = false; + + animator.evaluateAndApply(t, path, 500); + // Rotation should be based on serverYaw=1.0, not spline tangent + float expectedYaw = 1.0f; + glm::quat expected = glm::angleAxis(expectedYaw, glm::vec3(0.0f, 0.0f, 1.0f)); + REQUIRE(t.rotation.w == Catch::Approx(expected.w).margin(0.01f)); + REQUIRE(t.rotation.z == Catch::Approx(expected.z).margin(0.01f)); +} + +TEST_CASE("Animator: Z clamping on non-world-coord client anim", "[transport_animator]") { + TransportAnimator animator; + + // Build a path with a deep negative Z offset + std::vector keys = { + {0, glm::vec3(0.0f, 0.0f, 0.0f)}, + {1000, glm::vec3(5.0f, 0.0f, -50.0f)}, // Deep negative Z + {2000, glm::vec3(10.0f, 0.0f, 0.0f)}, + }; + CatmullRomSpline spline(std::move(keys), false); + PathEntry path(std::move(spline), 200, false, true, false); + + auto t = makeTransport(); + t.useClientAnimation = true; + t.serverUpdateCount = 0; // <= 1, so Z clamping applies + + animator.evaluateAndApply(t, path, 1000); + // Z should be clamped to >= -2.0 (kMinFallbackZOffset) + REQUIRE(t.position.z >= (t.basePosition.z - 2.0f)); +}