refactor: decompose TransportManager and upgrade Entity to CatmullRom splines

TransportManager decomposition:
- Extract TransportClockSync: server clock offset, yaw flip detection,
  velocity bootstrap, client/server mode switching
- Extract TransportAnimator: spline evaluation, Z clamping, orientation
  from server yaw or spline tangent
- Slim TransportManager to thin orchestrator delegating to ClockSync and
  Animator; add pushTransform() helper to deduplicate WMO/M2 renderer calls
- Remove legacy orientationFromSplineTangent (now uses
  CatmullRomSpline::orientationFromTangent)

Entity path following upgrade:
- Replace pathPoints_/pathSegDists_ linear lerp with
  std::optional<CatmullRomSpline> activeSpline_
- startMoveAlongPath builds SplineKeys with distance-proportional timing
- updateMovement evaluates CatmullRomSpline for smooth Catmull-Rom
  interpolation matching server-side creature movement
- Reset activeSpline_ on setPosition/startMoveTo to prevent stale state

Tests:
- Add test_transport_components (9 cases): ClockSync client/server/reverse
  modes, yaw flip detection, Animator position eval, server yaw, Z clamping
- Link spline.cpp into test_entity for CatmullRomSpline dependency

Signed-off-by: Pavel Okhlopkov <pavel.okhlopkov@flant.com>
This commit is contained in:
Pavel Okhlopkov 2026-04-11 09:50:38 +03:00
parent de0383aa6b
commit 39719cac82
10 changed files with 704 additions and 337 deletions

View file

@ -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

View file

@ -8,6 +8,8 @@
#include <map>
#include <unordered_map>
#include <memory>
#include <optional>
#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<std::array<float, 3>>& 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<float> 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<uint32_t>(totalDuration * 1000.0f);
std::vector<math::SplineKey> keys(path.size());
for (size_t i = 0; i < path.size(); i++) {
float fraction = cumDist[i] / totalDist;
keys[i].timeMs = static_cast<uint32_t>(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<uint32_t>(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<std::array<float, 3>> pathPoints_;
std::vector<float> pathSegDists_; // Cumulative distances for each waypoint
// CatmullRom spline for multi-segment path movement (replaces linear pathPoints_/pathSegDists_)
std::optional<math::CatmullRomSpline> activeSpline_;
uint32_t splineDurationMs_ = 0;
};
/**

View file

@ -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 <glm/glm.hpp>
#include <glm/gtc/quaternion.hpp>
#include <cstdint>
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

View file

@ -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 <glm/glm.hpp>
#include <cstdint>
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

View file

@ -1,6 +1,8 @@
#pragma once
#include "game/transport_path_repository.hpp"
#include "game/transport_clock_sync.hpp"
#include "game/transport_animator.hpp"
#include <cstdint>
#include <vector>
#include <unordered_map>
@ -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<uint64_t, ActiveTransport> transports_;
rendering::WMORenderer* wmoRenderer_ = nullptr;
rendering::M2Renderer* m2Renderer_ = nullptr;

View file

@ -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 <glm/gtc/constants.hpp>
#include <algorithm>
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<float>() : 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

View file

@ -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 <glm/gtc/constants.hpp>
#include <cmath>
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<uint32_t>(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<int64_t>(nowMs) + transport.serverClockOffsetMs;
int64_t mod = static_cast<int64_t>(durationMs);
int64_t wrapped = serverTimeMs % mod;
if (wrapped < 0) wrapped += mod;
outPathTimeMs = static_cast<uint32_t>(wrapped);
return true;
}
if (transport.useClientAnimation) {
// Pure local clock (no server sync yet, client-driven)
uint32_t dtMs = static_cast<uint32_t>(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<float>() : 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<float>(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<float>() : 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<float>(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

View file

@ -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<uint32_t>(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<int64_t>(nowMs) + transport.serverClockOffsetMs;
int64_t mod = static_cast<int64_t>(durationMs);
int64_t wrapped = serverTimeMs % mod;
if (wrapped < 0) wrapped += mod;
pathTimeMs = static_cast<uint32_t>(wrapped);
} else if (transport.useClientAnimation) {
// Pure local clock (no server sync yet, client-driven)
uint32_t dtMs = static_cast<uint32_t>(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<float>() : 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<float>() : 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<float>() : 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<float>(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,

View file

@ -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)

View file

@ -0,0 +1,209 @@
// tests/test_transport_components.cpp
// Unit tests for TransportClockSync and TransportAnimator (Phase 3 extractions).
#include <catch_amalgamated.hpp>
#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 <glm/gtc/constants.hpp>
#include <cmath>
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<SplineKey> 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<float>();
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<SplineKey> 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));
}