From efdbaee1b0c0e5fce54fdb81d917b070d045c0ba Mon Sep 17 00:00:00 2001 From: NaySurGithub <96527285+NaySurGithub@users.noreply.github.com> Date: Fri, 6 Nov 2026 22:11:01 +0100 Subject: [PATCH] fix(entity): interpolate actors over ticks like the game's lerpTo Remote actors jumped straight to every move_entity / move_entity_delta, and the attack ray and click were tested against that newest position. The game moves them toward it over 3 ticks (or the packet's ticks), so its hit position sits between the positions the server has seen acknowledged; precise hitbox checks flagged ours by 0.3 to 0.5 block on moving targets. The renderer now draws between the last two ticks instead of its own ~100 ms sample buffer, so what is drawn is what the hit is tested against. --- CMakeLists.txt | 4 + src/client/PerlinFrame.cpp | 11 +- src/client/PerlinFrame.hpp | 2 + src/client/PerlinWorldRender.cpp | 3 + src/client/entity/EntityRenderer.cpp | 70 ++--------- src/client/entity/EntityRenderer.hpp | 2 + src/client/entity/EntityTracker.cpp | 140 ++++++++++++++++++--- src/client/entity/EntityTracker.hpp | 28 ++++- src/client/multiplayer/BedrockHandlers.cpp | 11 +- src/client/player/LocalPlayer.cpp | 1 + tests/actor_lerp_selftest.cpp | 107 ++++++++++++++++ 11 files changed, 295 insertions(+), 84 deletions(-) create mode 100644 tests/actor_lerp_selftest.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 06d960f8..5b9d7d9f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -887,6 +887,10 @@ if(PERLIN_BUILD_TESTS AND NOT EMSCRIPTEN AND NOT ANDROID AND NOT PERLIN_IOS) target_include_directories(skin_lifetime_selftest PRIVATE src "${glm_SOURCE_DIR}") target_compile_features(skin_lifetime_selftest PRIVATE cxx_std_23) add_test(NAME skin_lifetime_selftest COMMAND skin_lifetime_selftest) + add_executable(actor_lerp_selftest tests/actor_lerp_selftest.cpp src/client/entity/EntityTracker.cpp) + target_include_directories(actor_lerp_selftest PRIVATE src "${glm_SOURCE_DIR}") + target_compile_features(actor_lerp_selftest PRIVATE cxx_std_23) + add_test(NAME actor_lerp_selftest COMMAND actor_lerp_selftest) add_executable(block_palette_selftest tests/block_palette_selftest.cpp src/world/level/block/BlockPalette.cpp src/util/Json.cpp) target_include_directories(block_palette_selftest PRIVATE src) diff --git a/src/client/PerlinFrame.cpp b/src/client/PerlinFrame.cpp index d7abba43..2d897b9d 100644 --- a/src/client/PerlinFrame.cpp +++ b/src/client/PerlinFrame.cpp @@ -400,8 +400,17 @@ namespace perlin { player.SetToggleModes(options.sprintMode == "toggle", options.sneakMode == "toggle"); hudState.moveStatus.clear(); UpdateEatingAndDrinking(f, *live); - if (!replayCtl.Playing()) + if (!replayCtl.Playing()) { player.Update(*live, inputManager, camera, levelRenderer->Blocks(), dt); + } else { + double& clock = loop->replayActorClock; + clock += std::min(static_cast(dt), 0.1) * std::max(0.0, replayCtl.Speed()); + for (int i = 0; clock >= physics::kTickSeconds && i < 10; ++i) { + clock -= physics::kTickSeconds; + live->Entities().TickInterpolation(); + } + clock = std::min(clock, static_cast(physics::kTickSeconds)); + } } void Perlin::UpdateRiding(BedrockSession& live, const std::vector& frameEntities) { diff --git a/src/client/PerlinFrame.hpp b/src/client/PerlinFrame.hpp index b732bdc5..fa1d5cd3 100644 --- a/src/client/PerlinFrame.hpp +++ b/src/client/PerlinFrame.hpp @@ -153,6 +153,8 @@ namespace perlin { Zoom zoom; float scopeFov = 1.0f; + // replays have no local player tick: actors interpolate on their own 20 Hz clock + double replayActorClock = 0.0; TimePoint previous = Clock::now(); const TimePoint startedAt = previous; uint64_t frameCounter = 0; diff --git a/src/client/PerlinWorldRender.cpp b/src/client/PerlinWorldRender.cpp index ed9a95f5..8effdb5a 100644 --- a/src/client/PerlinWorldRender.cpp +++ b/src/client/PerlinWorldRender.cpp @@ -1106,6 +1106,9 @@ namespace perlin { entity::EntityRenderer::FrameIn ein; ein.time = std::chrono::duration(frameStart - startedAt).count(); ein.dt = dt; + ein.partialTick = replayCtl.Playing() + ? static_cast(std::clamp(loop->replayActorClock / physics::kTickSeconds, 0.0, 1.0)) + : player.PartialTick(); { const glm::vec3 eyePos = camera.EyePosition(); ein.camera = {eyePos.x, eyePos.y, eyePos.z}; diff --git a/src/client/entity/EntityRenderer.cpp b/src/client/entity/EntityRenderer.cpp index 5ddd9767..fda117b8 100644 --- a/src/client/entity/EntityRenderer.cpp +++ b/src/client/entity/EntityRenderer.cpp @@ -155,14 +155,6 @@ namespace perlin::entity { struct EntityRenderer::View { bool init = false; glm::dvec3 pos{0.0}, lastPos{0.0}; - struct Sample { - double t = 0; - glm::dvec3 pos{0.0}; - float yaw = 0, headYaw = 0, pitch = 0; - }; - std::deque samples; - double interval = 0.05, jitter = 0.0, delay = 0.1; - double lastArrival = 0; float yaw = 0, headYaw = 0, pitch = 0; uint32_t moveSerial = 0, hurtSerial = 0, swingSerial = 0; float limbSwing = 0, limbAmount = 0; @@ -471,57 +463,17 @@ namespace perlin::entity { const float prevYaw = v.yaw; glm::dvec3 d = e.pos - v.pos; if (buffered) { - if (v.samples.empty() || v.samples.back().pos != e.pos || v.samples.back().yaw != e.yaw || - v.samples.back().headYaw != e.headYaw || v.samples.back().pitch != e.pitch) { - if (!v.samples.empty() && glm::length(e.pos - v.samples.back().pos) > 12.0) { - v.samples.clear(); - v.pos = e.pos; - v.lastPos = e.pos; - v.yaw = e.yaw; - v.headYaw = e.headYaw; - v.pitch = e.pitch; - v.lastArrival = 0; - } - if (v.lastArrival > 0) { - const double gap = std::clamp(in.time - v.lastArrival, 0.005, 0.5); - const double dev = std::fabs(gap - v.interval); - v.interval += (gap - v.interval) * 0.1; - v.jitter += (dev - v.jitter) * 0.1; - } - v.lastArrival = in.time; - v.samples.push_back({in.time, e.pos, e.yaw, e.headYaw, e.pitch}); - while (v.samples.size() > 64) - v.samples.pop_front(); - } - const double wantDelay = std::clamp(v.interval * 1.25 + v.jitter * 2.0, 0.06, 0.25); - v.delay += (wantDelay - v.delay) * std::min(1.0, static_cast(dt) * 2.0); - const double renderT = in.time - v.delay; - while (v.samples.size() > 2 && v.samples[1].t <= renderT) - v.samples.pop_front(); - if (v.samples.size() >= 2 && renderT >= v.samples[0].t) { - const auto& a = v.samples[0]; - const auto& b = v.samples[1]; - const double span = b.t - a.t; - const double f = span > 1e-6 ? std::clamp((renderT - a.t) / span, 0.0, 1.0) : 1.0; - v.pos = a.pos + (b.pos - a.pos) * f; - const auto mix = [&](float x, float y) { - return x + WrapDegrees(y - x) * static_cast(f); - }; - v.yaw = mix(a.yaw, b.yaw); - v.headYaw = mix(a.headYaw, b.headYaw); - v.pitch = a.pitch + WrapDegrees(b.pitch - a.pitch) * static_cast(f); - } else if (v.samples.size() >= 2) { - v.pos = v.samples[0].pos; - } else if (!v.samples.empty()) { - const double catchUp = 1.0 - std::exp(-static_cast(dt) / 0.06); - v.pos += (v.samples.back().pos - v.pos) * catchUp; - const auto turnTo = [&](float cur, float target) { - return cur + WrapDegrees(target - cur) * static_cast(catchUp); - }; - v.yaw = turnTo(v.yaw, v.samples.back().yaw); - v.headYaw = turnTo(v.headYaw, v.samples.back().headYaw); - v.pitch += WrapDegrees(v.samples.back().pitch - v.pitch) * static_cast(catchUp); - } + // the tracker moves actors once per tick like MovementInterpolatorSystem::tickClient; draw between the + // last two ticks so the drawn body is the one our hits are tested against + const double a = std::clamp(static_cast(in.partialTick), 0.0, 1.0); + const glm::dvec3 step = e.pos - e.prevPos; + v.pos = glm::dot(step, step) > 144.0 ? e.pos : e.prevPos + step * a; + const auto mix = [&](float from, float to) { + return from + WrapDegrees(to - from) * static_cast(a); + }; + v.yaw = mix(e.prevYaw, e.yaw); + v.headYaw = mix(e.prevHeadYaw, e.headYaw); + v.pitch = mix(e.prevPitch, e.pitch); v.yawSpeed = WrapDegrees(v.yaw - prevYaw) / dt; } else { v.pos = glm::length(d) > 24.0 ? e.pos : v.pos + d * follow; diff --git a/src/client/entity/EntityRenderer.hpp b/src/client/entity/EntityRenderer.hpp index 690398a5..cf48ff0d 100644 --- a/src/client/entity/EntityRenderer.hpp +++ b/src/client/entity/EntityRenderer.hpp @@ -51,6 +51,8 @@ namespace perlin::entity { struct FrameIn { double time = 0; float dt = 0.016f; + // client tick progress (0..1) since the last actor interpolation step + float partialTick = 1.0f; glm::dvec3 camera{0.0}; std::function light; std::function lightLevels; // block, sky (0-15) diff --git a/src/client/entity/EntityTracker.cpp b/src/client/entity/EntityTracker.cpp index 88b4ed8e..0b657e43 100644 --- a/src/client/entity/EntityTracker.cpp +++ b/src/client/entity/EntityTracker.cpp @@ -45,6 +45,13 @@ namespace perlin::entity { e.pitch = SignedPitch(pitch); e.yaw = yaw; e.headYaw = headYaw; + e.prevPos = e.pos; + e.prevPitch = e.pitch; + e.prevYaw = e.yaw; + e.prevHeadYaw = e.headYaw; + e.lerp.steps = 0; + e.hasQueued = false; + e.haveNet = false; e.flags = flags; e.mainHand = mainHand; e.isSelf = rid == selfRuntime && rid != 0; @@ -67,6 +74,13 @@ namespace perlin::entity { e.pitch = SignedPitch(pitch); e.yaw = yaw; e.headYaw = headYaw; + e.prevPos = e.pos; + e.prevPitch = e.pitch; + e.prevYaw = e.yaw; + e.prevHeadYaw = e.headYaw; + e.lerp.steps = 0; + e.hasQueued = false; + e.haveNet = false; e.flags = flags; e.scale = scale; e.variant = variant; @@ -131,8 +145,60 @@ namespace perlin::entity { } } - void EntityTracker::Move(uint64_t rid, glm::dvec3 pos, float pitch, float yaw, float headYaw, - bool onGround) { + namespace { + float Wrap(float degrees) { + degrees = std::fmod(degrees, 360.0f); + if (degrees >= 180.0f) + degrees -= 360.0f; + if (degrees < -180.0f) + degrees += 360.0f; + return degrees; + } + + void PlaceNow(Entity& e, const Entity::Lerp& to) { + e.pos = e.prevPos = to.pos; + e.pitch = e.prevPitch = to.pitch; + e.yaw = e.prevYaw = to.yaw; + e.headYaw = e.prevHeadYaw = to.headYaw; + e.lerp.steps = 0; + e.hasQueued = false; + } + + // Actor::lerpTo 0x142C74350: a running interpolation started with forceCompletion keeps going and the new + // target waits in QueuedMovementInterpolationComponent; anything else replaces the target + void LerpTo(Entity& e, const Entity::Lerp& to) { + if (!to.forceCompletion && e.lerp.forceCompletion && e.lerp.steps != 0) { + e.queued = to; + e.hasQueued = true; + return; + } + e.lerp = to; + } + + // move_entity / move_entity_delta handler 0x1422C88B0 -> 0x1422C8D20: steps = ticks when above 3, else 3; + // a teleport of 0.5 block or more is placed at once + void Receive(Entity& e, const glm::dvec3& next, float pitch, float yaw, float headYaw, + EntityTracker::MoveKind kind, uint64_t ticks, bool forceCompletion) { + e.haveNet = true; + e.netPos = next; + e.netPitch = pitch; + e.netYaw = yaw; + e.netHeadYaw = headYaw; + const Entity::Lerp to{next, pitch, yaw, headYaw, ticks >= 4 ? static_cast(std::min(ticks, 1000)) : 3, + forceCompletion}; + const glm::dvec3 jump = next - e.pos; + const bool farTeleport = + kind == EntityTracker::MoveKind::Teleport && glm::dot(jump, jump) >= 0.25; + // TODO: the game also places at once when the destination block is not loaded + if (kind == EntityTracker::MoveKind::Place || farTeleport || !e.ticked) + PlaceNow(e, to); + else + LerpTo(e, to); + } + } + + void EntityTracker::Move(uint64_t rid, glm::dvec3 pos, float pitch, float yaw, float headYaw, bool onGround, + MoveKind kind, uint64_t ticks, bool forceCompletion) { std::lock_guard lock(mutex); const auto it = entities.find(rid); if (it == entities.end()) @@ -145,16 +211,14 @@ namespace perlin::entity { const glm::dvec3 next = e.isPlayer ? glm::dvec3(pos.x, pos.y - kPlayerEye, pos.z) : pos; if (e.dying && e.isPlayer && glm::dot(next - e.pos, next - e.pos) > 2.25) e.dying = false; - e.pos = next; - e.pitch = SignedPitch(pitch); - e.yaw = yaw; - e.headYaw = headYaw; + Receive(e, next, SignedPitch(pitch), yaw, headYaw, kind, ticks, forceCompletion); e.onGround = onGround; e.moveSerial++; } void EntityTracker::MoveDelta(uint64_t rid, const double* x, const double* y, const double* z, - const float* pitch, const float* yaw, const float* headYaw, bool onGround) { + const float* pitch, const float* yaw, const float* headYaw, bool onGround, + bool teleport, uint64_t ticks, bool forceCompletion) { std::lock_guard lock(mutex); const auto it = entities.find(rid); if (it == entities.end()) @@ -164,22 +228,60 @@ namespace perlin::entity { ItemServerUpdate(e, glm::dvec3(x ? *x : e.pos.x, y ? *y : e.pos.y, z ? *z : e.pos.z), onGround); return; } - if (x) - e.pos.x = *x; - if (y) - e.pos.y = e.isPlayer ? *y - kPlayerEye : *y; - if (z) - e.pos.z = *z; - if (pitch) - e.pitch = SignedPitch(*pitch); - if (yaw) - e.yaw = *yaw; - if (headYaw) - e.headYaw = *headYaw; + if (!e.haveNet) { + e.netPos = e.pos; + e.netPitch = e.pitch; + e.netYaw = e.yaw; + e.netHeadYaw = e.headYaw; + } + const glm::dvec3 next(x ? *x : e.netPos.x, y ? (e.isPlayer ? *y - kPlayerEye : *y) : e.netPos.y, + z ? *z : e.netPos.z); + Receive(e, next, pitch ? SignedPitch(*pitch) : e.netPitch, yaw ? *yaw : e.netYaw, + headYaw ? *headYaw : e.netHeadYaw, teleport ? MoveKind::Teleport : MoveKind::Lerp, ticks, + forceCompletion); e.onGround = onGround; e.moveSerial++; } + void EntityTracker::TickInterpolation() { + std::lock_guard lock(mutex); + for (auto& [rid, e] : entities) { + e.prevPos = e.pos; + e.prevPitch = e.pitch; + e.prevYaw = e.yaw; + e.prevHeadYaw = e.headYaw; + e.ticked = true; + if (e.isSelf || (e.isItem && e.simulated) || pickups.contains(rid)) + continue; + // MovementInterpolatorSystem::handleQueuedMovementInterpolation 0x143634AE0 + if (e.hasQueued) { + if (e.lerp.forceCompletion && e.lerp.steps != 0) { + e.queued.steps = std::max(e.queued.steps, 2) - 1; + } else { + e.lerp = e.queued; + e.lerp.forceCompletion = false; + e.hasQueued = false; + } + } + // MovementInterpolatorSystem::tickClient 0x143630D70: 1/steps of the way, the last step lands on it + if (e.lerp.steps > 0) { + if (e.lerp.steps == 1) { + e.pos = e.lerp.pos; + e.pitch = e.lerp.pitch; + e.yaw = e.lerp.yaw; + e.headYaw = e.lerp.headYaw; + } else { + const double f = 1.0 / e.lerp.steps; + e.pos += (e.lerp.pos - e.pos) * f; + e.pitch += Wrap(e.lerp.pitch - e.pitch) * static_cast(f); + e.yaw += Wrap(e.lerp.yaw - e.yaw) * static_cast(f); + e.headYaw += Wrap(e.lerp.headYaw - e.headYaw) * static_cast(f); + } + e.lerp.steps--; + } + } + } + void EntityTracker::SetVehicle(int64_t riderUid, int64_t vehicleUid) { std::lock_guard lock(mutex); const auto u = byUnique.find(riderUid); diff --git a/src/client/entity/EntityTracker.hpp b/src/client/entity/EntityTracker.hpp index 15f80c83..e51fc607 100644 --- a/src/client/entity/EntityTracker.hpp +++ b/src/client/entity/EntityTracker.hpp @@ -115,6 +115,23 @@ namespace perlin::entity { glm::dvec3 vel{0.0}; bool sleeping = false, sleepExt = false, sleepPlayer = false; bool simulated = false; + // MovementInterpolatorComponent: pos above is the actor's tick position, moved toward the last received + // one over a few ticks; prevPos/prev* are the previous tick for render interpolation + struct Lerp { + glm::dvec3 pos{0.0}; + float pitch = 0, yaw = 0, headYaw = 0; + int steps = 0; + bool forceCompletion = false; + }; + Lerp lerp, queued; + bool hasQueued = false; + glm::dvec3 prevPos{0.0}; + float prevYaw = 0, prevHeadYaw = 0, prevPitch = 0; + bool ticked = false; + // last absolute pose received: move_entity_delta only sends the changed lanes + glm::dvec3 netPos{0.0}; + float netPitch = 0, netYaw = 0, netHeadYaw = 0; + bool haveNet = false; bool haveServerPos = false; glm::dvec3 lastServerPos{0.0}; std::chrono::steady_clock::time_point lastServerAt; @@ -160,9 +177,16 @@ namespace perlin::entity { void SetMotion(uint64_t rid, glm::dvec3 velocity); void SetSleeping(uint64_t rid, bool sleeping, bool playerFlag = false); void StepItems(const std::function& solid); - void Move(uint64_t rid, glm::dvec3 pos, float pitch, float yaw, float headYaw, bool onGround); + // how a server position reaches the actor: Place sets it now (our own predicted vehicles), Lerp and + // Teleport go through Actor::lerpTo like the game's move_entity / move_entity_delta handler + enum class MoveKind { Place, Lerp, Teleport }; + void Move(uint64_t rid, glm::dvec3 pos, float pitch, float yaw, float headYaw, bool onGround, + MoveKind kind = MoveKind::Place, uint64_t ticks = 0, bool forceCompletion = false); void MoveDelta(uint64_t rid, const double* x, const double* y, const double* z, const float* pitch, - const float* yaw, const float* headYaw, bool onGround); + const float* yaw, const float* headYaw, bool onGround, bool teleport = false, + uint64_t ticks = 0, bool forceCompletion = false); + // one client tick of MovementInterpolatorSystem::tickClient for every actor + void TickInterpolation(); void SetFlags(uint64_t rid, uint64_t flags); void SetFlagsExtended(uint64_t rid, uint64_t flags); void SetCrawling(uint64_t rid, bool crawling); diff --git a/src/client/multiplayer/BedrockHandlers.cpp b/src/client/multiplayer/BedrockHandlers.cpp index 7f826ed0..e8c6d99e 100644 --- a/src/client/multiplayer/BedrockHandlers.cpp +++ b/src/client/multiplayer/BedrockHandlers.cpp @@ -1337,7 +1337,9 @@ namespace perlin { constexpr float kByte = 360.0f / 256.0f; entities.Move(p.runtimeEntityId, {p.position.x, p.position.y, p.position.z}, p.rotation.pitch * kByte, p.rotation.yaw * kByte, p.rotation.headYaw * kByte, - (p.flags & 1) != 0); + (p.flags & 1) != 0, + (p.flags & 2) != 0 ? entity::EntityTracker::MoveKind::Teleport + : entity::EntityTracker::MoveKind::Lerp); }); On([this](const MoveEntityDeltaPacket& p) { @@ -1362,14 +1364,17 @@ namespace perlin { head = *p.rotZ * kByte; entities.MoveDelta(p.runtimeEntityId, p.x ? &x : nullptr, p.y ? &y : nullptr, p.z ? &z : nullptr, p.rotX ? &pitch : nullptr, p.rotY ? &yaw : nullptr, p.rotZ ? &head : nullptr, - p.onGround); + p.onGround, p.forceMove, p.ticks, p.forceCompletion); }); On([this](const MovePlayerPacket& p) { if (runtimeId != 0 && p.runtimeId == runtimeId) return; entities.Move(p.runtimeId, {p.position.x, p.position.y, p.position.z}, p.pitch, p.yaw, p.headYaw, - p.onGround); + p.onGround, + p.mode == MovePlayerPacketMode::Normal || p.mode == MovePlayerPacketMode::Rotation + ? entity::EntityTracker::MoveKind::Lerp + : entity::EntityTracker::MoveKind::Teleport); }); On([this](const SetEntityDataPacket& p) { diff --git a/src/client/player/LocalPlayer.cpp b/src/client/player/LocalPlayer.cpp index 6c53afe0..b35ed912 100644 --- a/src/client/player/LocalPlayer.cpp +++ b/src/client/player/LocalPlayer.cpp @@ -1369,6 +1369,7 @@ namespace perlin { break; } input.SetControlTick(tickCounter); + session.Entities().TickInterpolation(); Tick(session, input, camera, world); // VanillaOffsetSystem::ClientSide 0x142CED480: the drop below eye level moves halfway each tick const float standEye = static_cast(physics::kStandEye); diff --git a/tests/actor_lerp_selftest.cpp b/tests/actor_lerp_selftest.cpp new file mode 100644 index 00000000..17210821 --- /dev/null +++ b/tests/actor_lerp_selftest.cpp @@ -0,0 +1,107 @@ +#include "client/entity/EntityTracker.hpp" + +#include +#include + +using namespace perlin::entity; + +static int checks = 0, failures = 0; +#define CHECK(condition) \ + do { \ + ++checks; \ + if (!(condition)) { \ + std::fprintf(stderr, "FAIL line %d: %s\n", __LINE__, #condition); \ + ++failures; \ + } \ + } while (0) + +static bool Near(double a, double b) { return std::fabs(a - b) < 1e-9; } + +static Entity Get(const EntityTracker& t, uint64_t rid) { + Entity e; + t.Find(rid, e); + return e; +} + +int main() { + using Kind = EntityTracker::MoveKind; + { + EntityTracker t; + t.AddEntity(1, 1, "minecraft:zombie", {0, 0, 0}, 0, 0, 0, 0, 1, 0, 0, 0); + t.TickInterpolation(); + // 3 steps: 1/3, then 1/2 of the rest, then the target + t.Move(1, {3, 0, 0}, 0, 90, 90, true, Kind::Lerp); + CHECK(Near(Get(t, 1).pos.x, 0)); + t.TickInterpolation(); + CHECK(Near(Get(t, 1).pos.x, 1)); + CHECK(Near(Get(t, 1).prevPos.x, 0)); + t.TickInterpolation(); + CHECK(Near(Get(t, 1).pos.x, 2)); + t.TickInterpolation(); + CHECK(Near(Get(t, 1).pos.x, 3)); + CHECK(std::fabs(Get(t, 1).yaw - 90.0f) < 1e-4f); + t.TickInterpolation(); + CHECK(Near(Get(t, 1).pos.x, 3)); + CHECK(Near(Get(t, 1).prevPos.x, 3)); + } + { + // a new target replaces an ordinary interpolation + EntityTracker t; + t.AddEntity(1, 1, "minecraft:zombie", {0, 0, 0}, 0, 0, 0, 0, 1, 0, 0, 0); + t.TickInterpolation(); + t.Move(1, {3, 0, 0}, 0, 0, 0, true, Kind::Lerp); + t.TickInterpolation(); + t.Move(1, {7, 0, 0}, 0, 0, 0, true, Kind::Lerp); + t.TickInterpolation(); + CHECK(Near(Get(t, 1).pos.x, 3)); + } + { + // ticks above 3 set the step count; delta fills missing lanes from the last received pose + EntityTracker t; + t.AddEntity(1, 1, "minecraft:zombie", {0, 5, 0}, 0, 0, 0, 0, 1, 0, 0, 0); + t.TickInterpolation(); + const double x = 4; + t.MoveDelta(1, &x, nullptr, nullptr, nullptr, nullptr, nullptr, true, false, 4); + t.TickInterpolation(); + CHECK(Near(Get(t, 1).pos.x, 1)); + CHECK(Near(Get(t, 1).pos.y, 5)); + } + { + // forceCompletion: the next target waits, shrinking by a step each tick, then plays out + EntityTracker t; + t.AddEntity(1, 1, "minecraft:zombie", {0, 0, 0}, 0, 0, 0, 0, 1, 0, 0, 0); + t.TickInterpolation(); + const double a = 3, b = 9; + t.MoveDelta(1, &a, nullptr, nullptr, nullptr, nullptr, nullptr, true, false, 0, true); + t.MoveDelta(1, &b, nullptr, nullptr, nullptr, nullptr, nullptr, true, false, 0, false); + t.TickInterpolation(); + CHECK(Near(Get(t, 1).pos.x, 1)); + t.TickInterpolation(); + t.TickInterpolation(); + CHECK(Near(Get(t, 1).pos.x, 3)); + // queued 3 -> 2 -> 1 while the forced one ran, then becomes active with 1 step left + t.TickInterpolation(); + CHECK(Near(Get(t, 1).pos.x, 9)); + } + { + // a teleport of 0.5 block or more lands at once, a shorter one still interpolates + EntityTracker t; + t.AddEntity(1, 1, "minecraft:zombie", {0, 0, 0}, 0, 0, 0, 0, 1, 0, 0, 0); + t.TickInterpolation(); + t.Move(1, {10, 0, 0}, 0, 0, 0, true, Kind::Teleport); + CHECK(Near(Get(t, 1).pos.x, 10)); + t.Move(1, {10.3, 0, 0}, 0, 0, 0, true, Kind::Teleport); + CHECK(Near(Get(t, 1).pos.x, 10)); + t.TickInterpolation(); + CHECK(Near(Get(t, 1).pos.x, 10.1)); + } + { + // before the first tick there is nothing to interpolate from + EntityTracker t; + t.AddEntity(1, 1, "minecraft:zombie", {0, 0, 0}, 0, 0, 0, 0, 1, 0, 0, 0); + t.Move(1, {2, 0, 0}, 0, 0, 0, true, Kind::Lerp); + CHECK(Near(Get(t, 1).pos.x, 2)); + } + std::printf("ActorLerp: %d checks, %d failures\n", checks, failures); + return failures ? 1 : 0; +}