From 2ed70710cf9d2ecafe27b657f6936aa27876c7a0 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 10 Nov 2026 16:45:01 -0600 Subject: [PATCH] feat(moving platform): a rotator turns its object as the client's does LWOPlatformRotator on a path does not move: while it travels it turns at the angular velocity SetAngularVelocity (0x00bfa380) works out, never waits (IncrementWaitingTime 0x004a0390) and never arrives (HandleStoppedLogic 0x00bdc660). The velocity is the one that turns from no rotation to NiMatrix3::FromEulerAnglesXYZ (0x0116c3a0) of rotX, rotY and rotZ (half turns in the level config, LoadConfigData 0x00c33e10) in half the rate, as hkpKeyFrameUtility's setUsingPositionOrientationPair (0x012a4050) gives it: the shortest way, the axis times the angle over the time, none for whole turns. In reverse it is the other way round. The server's object now turns with it, without a serialization of its physics (each client turns its own once the rotator is sent travelling), and is put at its waypoint as SetupPath does. A quick build's rotator waits for the build like a mover. Co-Authored-By: Claude Fable 5.2 --- dGame/dComponents/MovingPlatformComponent.cpp | 155 ++++++++++++++++-- dGame/dComponents/MovingPlatformComponent.h | 32 +++- .../MovingPlatformComponentTests.cpp | 83 ++++++++++ 3 files changed, 251 insertions(+), 19 deletions(-) diff --git a/dGame/dComponents/MovingPlatformComponent.cpp b/dGame/dComponents/MovingPlatformComponent.cpp index 01ab84284..65de8dbe7 100644 --- a/dGame/dComponents/MovingPlatformComponent.cpp +++ b/dGame/dComponents/MovingPlatformComponent.cpp @@ -171,12 +171,9 @@ void MovingPlatformComponent::LoadConfigData() { m_StartWaypoint = start >= 0 && static_cast(start) < m_Path->pathWaypoints.size() ? start : 0; SetupMoverPath(); } else if (m_MoverSubComponentType == eMoverSubComponentType::rotater) { - // LWOPlatformRotator: it only spins (rotX/rotY/rotZ at rate, its own path never arrives anywhere: its - // HandleStoppedLogic 0x00bdc660 only keeps the angular velocity), while travelling. Only one on a path runs - // (LWOMovingPlatformComponent::SetupPlatform 0x00ccea50), and the client reads a platform that is not at a - // waypoint from its path (LWOPlatform::Deserialize 0x00c870e0), so one without a path stays as it was made (12). - const bool startOnLoad = !m_Parent->HasVar(u"startPathingOnLoad") || m_Parent->GetVar(u"startPathingOnLoad"); - if (startOnLoad && m_Path != nullptr) GetMoverSubComponent()->mState = ePlatformStateFlag::Travelling; + const auto start = m_Parent->HasVar(u"attached_path_start") ? m_Parent->GetVar(u"attached_path_start") : 0; + m_StartWaypoint = start >= 0 ? start : 0; + SetupRotator(); } // Live constructed every moving platform with simple physics as keyframed @@ -195,6 +192,30 @@ void MovingPlatformComponent::SetupMoverPath() { if (startOnLoad) MoverResumeTravelling(); } +// LWOPlatformRotator: on its path it only turns (rotX/rotY/rotZ at rate; its HandleStoppedLogic 0x00bdc660 only keeps +// the angular velocity, so it never arrives anywhere), while travelling. Only one on a path runs +// (LWOMovingPlatformComponent::SetupPlatform 0x00ccea50), and the client reads a platform that is not at a waypoint +// from its path (LWOPlatform::Deserialize 0x00c870e0), so one without a path stays as it was made (12). The client's +// rotator starts turning when it is sent travelling (Deserialize: StartTravelling, ResumeTravelling, UpdateVelocities). +void MovingPlatformComponent::SetupRotator() { + auto* const rotator = static_cast(GetMoverSubComponent()); + // LWOPlatformRotator::LoadConfigData 0x00c33e10 + constexpr float pi = 3.14159265358979323846f; + if (m_Parent->HasVar(u"rotX")) rotator->mRotationX = m_Parent->GetVar(u"rotX") * pi; + if (m_Parent->HasVar(u"rotY")) rotator->mRotationY = m_Parent->GetVar(u"rotY") * pi; + if (m_Parent->HasVar(u"rotZ")) rotator->mRotationZ = m_Parent->GetVar(u"rotZ") * pi; + rotator->mRate = m_Parent->GetVar(u"rate"); + + if (m_Path == nullptr || m_Path->pathWaypoints.empty()) return; + // LWOPlatform::SetupPath 0x00c17d10 + rotator->mTimeBased = m_Path->movingPlatform.timeBasedMovement != 0; + rotator->mInReverse = m_PathReverse; + if (static_cast(m_StartWaypoint) >= m_Path->pathWaypoints.size()) m_StartWaypoint = 0; + MoverProcessStateChange(m_StartWaypoint); + const bool startOnLoad = !m_Parent->HasVar(u"startPathingOnLoad") || m_Parent->GetVar(u"startPathingOnLoad"); + if (startOnLoad) rotator->mState = ePlatformStateFlag::Travelling; +} + // LWOMovingPlatformComponent::SetCurrentPath 0x00d0f570 (the SetCurrentPath game message, 0x00d0f5d0): the path, the // waypoint it starts at and its direction, then SetupPlatform 0x00ccea50 makes the subcomponents anew and a mover takes // the path. Live wrote the path to the clients with the mover (the GF shooting gallery's targets), whose @@ -219,6 +240,7 @@ bool MovingPlatformComponent::SetCurrentPath(const std::string& pathName, const } else { m_StartWaypoint = startPoint; if (m_Path == nullptr) LOG("Cannot setup platform on path %s", pathName.c_str()); + else if (m_MoverSubComponentType == eMoverSubComponentType::rotater) SetupRotator(); } Game::entityManager->SerializeEntity(m_Parent); return mover; @@ -579,24 +601,123 @@ void MovingPlatformComponent::MoveObject() { } } -// LWOPlatform::Run 0x00c87080 and RunPlatform 0x00c86e10. The client's platform is moved by its physics, at the -// velocity SetLinearVelocity 0x00be9200 gives it; the server moves it here by the same speed. -void MovingPlatformComponent::Run(const float deltaTime) { - if (!IsMover()) return; - auto* mover = GetMoverSubComponent(); - - // LWOPlatform::SetupPath 0x00c17d10 and SendMessage 0x00bdc930 (RebuildNotifyState): a quick build's platform does not - // run until it is built, nor once the quick build is open again +// LWOPlatform::SetupPath 0x00c17d10 and SendMessage 0x00bdc930 (RebuildNotifyState): a quick build's platform does not +// run until it is built, nor once the quick build is open again +bool MovingPlatformComponent::UpdateActive(PlatformSubComponent& platform) { if (!m_CheckedQuickBuild) { m_CheckedQuickBuild = true; m_QuickBuild = m_Parent->GetComponent(); - if (m_QuickBuild) mover->mActive = false; + if (m_QuickBuild) platform.mActive = false; } if (m_QuickBuild) { const auto quickBuildState = m_QuickBuild->GetState(); - if (quickBuildState == eQuickBuildState::OPEN) mover->mActive = false; - else if (quickBuildState == eQuickBuildState::COMPLETED) mover->mActive = true; + if (quickBuildState == eQuickBuildState::OPEN) platform.mActive = false; + else if (quickBuildState == eQuickBuildState::COMPLETED) platform.mActive = true; } + return platform.mActive; +} + +NiPoint3 CalcRotatorAngularVelocity(const float rotX, const float rotY, const float rotZ, const float rate, const bool reverse) { + // NiMatrix3::MakeXRotation 0x0116c070, MakeYRotation 0x0116c020 and MakeZRotation 0x0116bfd0 + const float sx = static_cast(std::sin(static_cast(rotX))), cx = static_cast(std::cos(static_cast(rotX))); + const float sy = static_cast(std::sin(static_cast(rotY))), cy = static_cast(std::cos(static_cast(rotY))); + const float sz = static_cast(std::sin(static_cast(rotZ))), cz = static_cast(std::cos(static_cast(rotZ))); + const float x[3][3] = { { 1.0f, 0.0f, 0.0f }, { 0.0f, cx, sx }, { 0.0f, -sx, cx } }; + const float y[3][3] = { { cy, 0.0f, -sy }, { 0.0f, 1.0f, 0.0f }, { sy, 0.0f, cy } }; + const float z[3][3] = { { cz, sz, 0.0f }, { -sz, cz, 0.0f }, { 0.0f, 0.0f, 1.0f } }; + // NiMatrix3::FromEulerAnglesXYZ 0x0116c3a0: x * (y * z) + float yz[3][3]{}; + float m[3][3]{}; + for (int32_t i = 0; i < 3; i++) for (int32_t j = 0; j < 3; j++) for (int32_t k = 0; k < 3; k++) yz[i][j] += y[i][k] * z[k][j]; + for (int32_t i = 0; i < 3; i++) for (int32_t j = 0; j < 3; j++) for (int32_t k = 0; k < 3; k++) m[i][j] += x[i][k] * yz[k][j]; + + // NiQuaternion::FromRotation + float q[4]{}; // x, y, z, w + const float trace = m[0][0] + m[1][1] + m[2][2]; + if (trace > 0.0f) { + float root = std::sqrt(trace + 1.0f); + q[3] = 0.5f * root; + root = 0.5f / root; + q[0] = (m[2][1] - m[1][2]) * root; + q[1] = (m[0][2] - m[2][0]) * root; + q[2] = (m[1][0] - m[0][1]) * root; + } else { + constexpr int32_t next[3] = { 1, 2, 0 }; + int32_t i = 0; + if (m[1][1] > m[0][0]) i = 1; + if (m[2][2] > m[i][i]) i = 2; + const int32_t j = next[i]; + const int32_t k = next[j]; + float root = std::sqrt(m[i][i] - m[j][j] - m[k][k] + 1.0f); + q[i] = 0.5f * root; + root = 0.5f / root; + q[3] = (m[k][j] - m[j][k]) * root; + q[j] = (m[j][i] + m[i][j]) * root; + q[k] = (m[k][i] + m[i][k]) * root; + } + + // hkpKeyFrameUtility::KeyFrameInfo::setUsingPositionOrientationPair 0x012a4050: the rotation from the first to the + // second (the rotation itself, or its inverse in reverse), normalized; without an axis there is no velocity; else + // the axis (the other way for a negative w) times twice the arc cosine of |w| over the time + if (reverse) { + q[0] = -q[0]; + q[1] = -q[1]; + q[2] = -q[2]; + } + const float length = std::sqrt(q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3]); + if (length <= 0.0f || rate == 0.0f) return NiPoint3Constant::ZERO; + for (auto& part : q) part /= length; + const float axisLength = std::sqrt(q[0] * q[0] + q[1] * q[1] + q[2] * q[2]); + if (!(axisLength > std::numeric_limits::epsilon())) return NiPoint3Constant::ZERO; + const float angle = 2.0f * std::acos(std::min(std::abs(q[3]), 1.0f)); + const float scale = (q[3] < 0.0f ? -1.0f : 1.0f) / axisLength * angle * (1.0f / (rate * 0.5f)); + return NiPoint3(q[0] * scale, q[1] * scale, q[2] * scale); +} + +// LWOPlatform::RunPlatform 0x00c86e10 for a rotator: its wait never ends (IncrementWaitingTime 0x004a0390) and it never +// arrives (HandleStoppedLogic 0x00bdc660 keeps the object's angular velocity at the rotator's); it has no linear +// velocity (SetLinearVelocity 0x00bd1660). The client's physics turn the object at that velocity; the server turns its +// object here, without a serialization of its physics, as a mover's. +void MovingPlatformComponent::RunRotator(const float deltaTime) { + auto* const rotator = static_cast(GetMoverSubComponent()); + if (!rotator || m_Path == nullptr || m_Path->pathWaypoints.empty()) return; + + auto rotation = m_Parent->GetRotation(); + bool moved = false; + if (!rotator->mPlaced) { + // LWOPlatform::ProcessStateChange 0x00cce380 from SetupPath: the object is put at its waypoint + rotator->mPlaced = true; + rotation = m_Path->pathWaypoints[rotator->mCurrentWaypointIndex].rotation; + moved = true; + } + if (UpdateActive(*rotator) && (rotator->mState & ePlatformStateFlag::Travelling) != 0) { + const auto velocity = rotator->GetAngularVelocity(); + const float speed = velocity.Length(); + if (speed > 0.0f && std::isfinite(speed)) { + const glm::vec3 axis(velocity.x / speed, velocity.y / speed, velocity.z / speed); + rotation = glm::normalize(glm::angleAxis(speed * deltaTime, axis) * rotation); + moved = true; + } + } + if (!moved) return; + if (auto* const simplePhysics = m_Parent->GetComponent()) { + simplePhysics->SetPositionAndRotationIgnoringDirtyFlags(rotator->mPosition, rotation); + } + if (auto* const phantomPhysics = m_Parent->GetComponent()) { + phantomPhysics->SetPositionAndRotationIgnoringDirtyFlags(rotator->mPosition, rotation); + } +} + +// LWOPlatform::Run 0x00c87080 and RunPlatform 0x00c86e10. The client's platform is moved by its physics, at the +// velocity SetLinearVelocity 0x00be9200 gives it; the server moves it here by the same speed. +void MovingPlatformComponent::Run(const float deltaTime) { + if (m_MoverSubComponentType == eMoverSubComponentType::rotater) { + RunRotator(deltaTime); + return; + } + if (!IsMover()) return; + auto* mover = GetMoverSubComponent(); + UpdateActive(*mover); if (!mover->mActive || mover->mState == 0) return; const bool travelling = (mover->mState & ePlatformStateFlag::Travelling) != 0; diff --git a/dGame/dComponents/MovingPlatformComponent.h b/dGame/dComponents/MovingPlatformComponent.h index 59d5b9b5c..ed18d446f 100644 --- a/dGame/dComponents/MovingPlatformComponent.h +++ b/dGame/dComponents/MovingPlatformComponent.h @@ -133,13 +133,35 @@ public: }; /** - * A rotator (6), as the client's LWOPlatformRotator: it only spins (rotX/rotY/rotZ at rate) while travelling; it sends - * the LWOPlatform data a mover sends. + * The angular velocity of a rotator, as the client's LWOPlatformRotator::SetAngularVelocity 0x00bfa380 works it out: + * the rotation NiMatrix3::FromEulerAnglesXYZ 0x0116c3a0 makes of the three angles (radians; the matrix about x times + * the one about y times the one about z, each turning the other way round than the usual right-handed one), as a + * quaternion (NiQuaternion::FromRotation), and the velocity that turns from no rotation to it (from it to none in + * reverse) in half the rate, by the shortest way (hkpKeyFrameUtility::KeyFrameInfo::setUsingPositionOrientationPair + * 0x012a4050: the axis times the angle, at most half a turn, over the time). Angles that add up to whole turns give + * no velocity. The client divides by a rate of 0; here that gives none. + */ +NiPoint3 CalcRotatorAngularVelocity(float rotX, float rotY, float rotZ, float rate, bool reverse); + +/** + * A rotator (6), as the client's LWOPlatformRotator: on its path it does not move, it turns at its angular velocity + * while travelling (rotX/rotY/rotZ in half turns, at rate), and never waits or arrives (IncrementWaitingTime + * 0x004a0390, HandleStoppedLogic 0x00bdc660); it sends the LWOPlatform data a mover sends. */ class RotatorSubComponent final : public PlatformSubComponent { public: RotatorSubComponent(const NiPoint3& startPos) { mPosition = startPos; } eMoverSubComponentType GetType() const override { return eMoverSubComponentType::rotater; } + + // LWOPlatformRotator::LoadConfigData 0x00c33e10: rotX, rotY and rotZ times pi, and rate (seconds) + float mRotationX = 0.0f; + float mRotationY = 0.0f; + float mRotationZ = 0.0f; + float mRate = 0.0f; + // Whether its object was put at its waypoint (LWOPlatform::ProcessStateChange 0x00cce380, from SetupPath) + bool mPlaced = false; + + NiPoint3 GetAngularVelocity() const { return CalcRotatorAngularVelocity(mRotationX, mRotationY, mRotationZ, mRate, mInReverse); } }; /** @@ -343,6 +365,12 @@ private: void SetupMoverPath(); // Puts the object where the mover is, without a serialization of its physics void MoveObject(); + // A rotator's settings from the level config, and where it is on its path + void SetupRotator(); + // Turns a travelling rotator's object by its angular velocity + void RunRotator(float deltaTime); + // LWOPlatform::SetupPath and SendMessage (RebuildNotifyState): whether the platform of a quick build runs yet + bool UpdateActive(PlatformSubComponent& platform); // LWOPlatform::SetWaitingTime: starts a wait of at least 0.1 s void MoverSetWaitingTime(float waitTime, float idleTimeElapsed); // LWOPlatform::ResumeTravelling: stopped between two waypoints it travels on; at a waypoint it first waits there diff --git a/tests/dGameTests/dComponentsTests/MovingPlatformComponentTests.cpp b/tests/dGameTests/dComponentsTests/MovingPlatformComponentTests.cpp index ec0938172..8bdb504c7 100644 --- a/tests/dGameTests/dComponentsTests/MovingPlatformComponentTests.cpp +++ b/tests/dGameTests/dComponentsTests/MovingPlatformComponentTests.cpp @@ -434,3 +434,86 @@ TEST_F(MovingPlatformComponentTest, BuiltSimpleMoverSetsOffLikeLive) { platform->Serialize(arrived, false); EXPECT_EQ(Bits(arrived), "00"); } + +// LWOPlatformRotator::SetAngularVelocity 0x00bfa380: the velocity that turns from no rotation to the configured one in +// half the rate. The client's matrices about an axis turn the other way round than the usual ones, so a quarter turn +// about x in 1 s is -pi/2 about x; in reverse it is the other way. Whole turns have no axis and give no velocity. Half +// a turn can go either way round: the float pi is a little over pi, so the shortest way is the usual direction. +TEST_F(MovingPlatformComponentTest, RotatorAngularVelocityAsTheClient) { + constexpr float pi = 3.14159265358979323846f; + const auto quarterX = CalcRotatorAngularVelocity(0.5f * pi, 0.0f, 0.0f, 2.0f, false); + EXPECT_NEAR(quarterX.x, -pi / 2.0f, 0.0001f); + EXPECT_NEAR(quarterX.y, 0.0f, 0.0001f); + EXPECT_NEAR(quarterX.z, 0.0f, 0.0001f); + const auto quarterXReverse = CalcRotatorAngularVelocity(0.5f * pi, 0.0f, 0.0f, 2.0f, true); + EXPECT_NEAR(quarterXReverse.x, pi / 2.0f, 0.0001f); + + const auto eighthY = CalcRotatorAngularVelocity(0.0f, 0.25f * pi, 0.0f, 4.0f, false); + EXPECT_NEAR(eighthY.y, -pi / 8.0f, 0.0001f); + const auto quarterZ = CalcRotatorAngularVelocity(0.0f, 0.0f, 0.5f * pi, 1.0f, false); + EXPECT_NEAR(quarterZ.z, -pi, 0.0001f); + + // x * (y * z): a quarter turn about x and one about y is a third of a turn about (-1, -1, 1) + const auto both = CalcRotatorAngularVelocity(0.5f * pi, 0.5f * pi, 0.0f, 2.0f, false); + const float third = 2.0f * pi / 3.0f / std::sqrt(3.0f); + EXPECT_NEAR(both.x, -third, 0.0001f); + EXPECT_NEAR(both.y, -third, 0.0001f); + EXPECT_NEAR(both.z, third, 0.0001f); + + EXPECT_EQ(CalcRotatorAngularVelocity(2.0f * pi, 0.0f, 0.0f, 2.0f, false), NiPoint3Constant::ZERO); + EXPECT_EQ(CalcRotatorAngularVelocity(0.0f, 0.0f, 0.0f, 2.0f, false), NiPoint3Constant::ZERO); + EXPECT_EQ(CalcRotatorAngularVelocity(0.5f * pi, 0.0f, 0.0f, 0.0f, false), NiPoint3Constant::ZERO); + + const auto halfX = CalcRotatorAngularVelocity(pi, 0.0f, 0.0f, 2.0f, false); + EXPECT_NEAR(halfX.x, pi, 0.0001f); +} + +// A rotator on a path stays at its waypoint and turns its object at its angular velocity while it travels (rotY half a +// half turn at rate 2: a quarter turn the client's way about y each second), without a serialization of its physics; +// stopped it turns no more, started again it does +TEST_F(MovingPlatformComponentTest, RotatorTurnsItsObject) { + Path path; + path.pathType = PathType::MovingPlatform; + path.pathName = "TestRotatorPath"; + path.pathBehavior = PathBehavior::Loop; + PathWaypoint first; + first.position = NiPoint3(1.0f, 2.0f, 3.0f); + PathWaypoint second = first; + second.position = NiPoint3(10.0f, 2.0f, 3.0f); + path.pathWaypoints = { first, second }; + const_cast(Game::zoneManager->GetZone())->AddPath(path); + + info.pos = NiPoint3(1.0f, 2.0f, 3.0f); + info.rot = QuatUtils::IDENTITY; + info.settings.Insert(u"platformIsRotater", true); + info.settings.Insert(u"rotY", 0.5f); + info.settings.Insert(u"rate", 2.0f); + Entity entity(288300744895900113, info); + info.settings.values.clear(); + auto* const simplePhysics = entity.AddComponent(-1); + auto* const platform = entity.AddComponent(-1, "TestRotatorPath"); + ASSERT_EQ(platform->GetMoverSubComponentType(), eMoverSubComponentType::rotater); + auto* const rotator = platform->GetMoverSubComponent(); + EXPECT_EQ(rotator->mState, 2u); + + RakNet::BitStream before; + simplePhysics->Serialize(before, false); + platform->Run(1.0f); + EXPECT_EQ(entity.GetPosition(), NiPoint3(1.0f, 2.0f, 3.0f)); + EXPECT_NEAR(entity.GetRotation().w, 0.7071f, 0.001f); + EXPECT_NEAR(entity.GetRotation().y, -0.7071f, 0.001f); + RakNet::BitStream moved; + simplePhysics->Serialize(moved, false); + EXPECT_LT(moved.GetNumberOfBitsUsed(), 7u * 32u); + + platform->StopPathing(); + EXPECT_EQ(rotator->mState & 7u, 4u); + platform->Run(1.0f); + EXPECT_NEAR(entity.GetRotation().y, -0.7071f, 0.001f); + + platform->StartPathing(); + EXPECT_EQ(rotator->mState, 2u); + platform->Run(1.0f); + EXPECT_NEAR(std::abs(entity.GetRotation().y), 1.0f, 0.001f); + EXPECT_EQ(entity.GetPosition(), NiPoint3(1.0f, 2.0f, 3.0f)); +}