From 3112379387d7f9e19464fedce4a867b4822a9d29 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 10 Nov 2026 13:32:00 -0600 Subject: [PATCH 01/14] fix(moving platform): a mover's states as the client and live: 12 stopped, 9 and 25 waiting, 28 stopped at an end The state bits were named the wrong way round: 1 is waiting and 4 is stopped (LWOPlatform::StartTravelling 0x0058b170, StopTravelling 0x0058b1d0), and the client only stops on 4 (LWOPlatform::Deserialize 0x00c870e0), so a mover stopped on its way kept moving on the client. The mover now runs as LWOPlatform::RunPlatform 0x00c86e10, ArrivedAtWaypoint 0x00c86c30, HandleWaypointArrived 0x00c17bc0 and ResumeTravelling 0x00c86b90, which gives the states of the live captures: 12 until started, 9 waiting at a waypoint (25 at an end), 2 on its way, 28 stopped at an end of a path run once. A GoToWaypoint at a waypoint waits there first with the desired waypoint, as the NJ falling tiles did, and a mover that stops is not serialized for it. A quick build's mover does not run until it is built. Co-Authored-By: Claude Opus 5.5 --- dGame/dComponents/MovingPlatformComponent.cpp | 224 ++++++++++++------ dGame/dComponents/MovingPlatformComponent.h | 53 +++-- .../MovingPlatformComponentTests.cpp | 79 +++++- 3 files changed, 260 insertions(+), 96 deletions(-) diff --git a/dGame/dComponents/MovingPlatformComponent.cpp b/dGame/dComponents/MovingPlatformComponent.cpp index f4b7e36c3..047a40802 100644 --- a/dGame/dComponents/MovingPlatformComponent.cpp +++ b/dGame/dComponents/MovingPlatformComponent.cpp @@ -20,6 +20,8 @@ #include "Entity.h" #include "ObjectLoadState.h" #include "MovementAIComponent.h" +#include "QuickBuildComponent.h" +#include "eQuickBuildState.h" void PlatformSubComponent::Serialize(RakNet::BitStream& outBitStream, bool bIsInitialUpdate) { outBitStream.Write(true); @@ -163,8 +165,8 @@ void MovingPlatformComponent::LoadConfigData() { LOG("Path not found: %s", pathName.c_str()); } - // A mover starts at its attached_path_start waypoint, waiting there, and leaves after the waypoint's wait unless - // startPathingOnLoad is false (live: 25 with the time waited in the constructions) + // LWOPlatform::SetupPath: a mover is at its attached_path_start waypoint, stopped (12, as live constructed movers + // nothing had started). Unless startPathingOnLoad is false it then waits the waypoint's wait and leaves (live: 9). if (IsMover()) { auto* const mover = GetMoverSubComponent(); mover->mTimeBased = m_Path->movingPlatform.timeBasedMovement != 0; @@ -172,12 +174,12 @@ void MovingPlatformComponent::LoadConfigData() { m_StartWaypoint = start >= 0 && static_cast(start) < m_Path->pathWaypoints.size() ? start : 0; MoverProcessStateChange(m_StartWaypoint); const bool startOnLoad = !m_Parent->HasVar(u"startPathingOnLoad") || m_Parent->GetVar(u"startPathingOnLoad"); - MoverStopAtWaypoint(startOnLoad); + if (startOnLoad) MoverResumeTravelling(); } 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 const bool startOnLoad = !m_Parent->HasVar(u"startPathingOnLoad") || m_Parent->GetVar(u"startPathingOnLoad"); - GetMoverSubComponent()->mState = startOnLoad ? ePlatformStateFlag::Travelling : ePlatformStateFlag::Stopped; + if (startOnLoad) GetMoverSubComponent()->mState = ePlatformStateFlag::Travelling; } // Live constructed every moving platform with simple physics as keyframed @@ -269,7 +271,9 @@ void MovingPlatformComponent::SetMovementState(eMovementPlatformState value) { Game::entityManager->SerializeEntity(m_Parent); } -// Sends the mover to a waypoint, stopping there when stopAtWaypoint: it turns round if the waypoint is behind it +// Sends the mover to a waypoint, stopping there when stopAtWaypoint: it turns round if the waypoint is behind it. Live +// (the NJ falling tiles): a mover at a waypoint first waits there (9, or 25 at an end, with the desired waypoint), then +// leaves (2). void MovingPlatformComponent::GotoWaypoint(uint32_t index, bool stopAtWaypoint) { if (m_MoverSubComponentType == eMoverSubComponentType::simpleMover) { SimpleMoverGotoWaypoint(static_cast(index)); @@ -282,7 +286,9 @@ void MovingPlatformComponent::GotoWaypoint(uint32_t index, bool stopAtWaypoint) // Already there mover->mDesiredWaypointIndex = -1; mover->mShouldStopAtDesiredWaypoint = false; - if (stopAtWaypoint) MoverStopAtWaypoint(false); + if (stopAtWaypoint) { + mover->mState = (mover->mState & ~(ePlatformStateFlag::Waiting | ePlatformStateFlag::Travelling)) | ePlatformStateFlag::Stopped; + } FireArrivalHooks(index, true, index == m_Path->pathWaypoints.size() - 1); return; } @@ -307,11 +313,14 @@ void MovingPlatformComponent::GotoWaypoint(uint32_t index, bool stopAtWaypoint) mover->mNextWaypointIndex = mover->mCurrentWaypointIndex; mover->mCurrentWaypointIndex = heading; mover->mPercentBetweenPoints = 1.0f - std::min(percent, 1.0f); + mover->mSegmentLength = Vector3::Distance(m_Path->pathWaypoints[mover->mCurrentWaypointIndex].position, m_Path->pathWaypoints[mover->mNextWaypointIndex].position); + mover->mWaitTime = m_Path->pathWaypoints[mover->mCurrentWaypointIndex].movingPlatform.wait; if (mover->mTimeBased) mover->mMoveTimeElapsed = mover->mCurrentSpeed * mover->mPercentBetweenPoints; else std::swap(mover->mCurrentSpeed, mover->mNextSpeed); } } - MoverDepart(); + MoverResumeTravelling(); + Game::entityManager->SerializeEntity(m_Parent); } void MovingPlatformComponent::StartPathing() { @@ -332,36 +341,33 @@ void MovingPlatformComponent::StartPathing() { if ((mover->mState & ePlatformStateFlag::Travelling) != 0) return; // LWOPlatform::ResumeTravelling: stopped between two waypoints it carries on; at a waypoint it first waits there // for the waypoint's wait (issue 964: the AG monument elevator waited about 2 s before rising) - if ((mover->mState & ePlatformStateFlag::ReachedDesiredWaypoint) != 0) { - MoverStopAtWaypoint(true); - Game::entityManager->SerializeEntity(m_Parent); - return; - } - MoverDepart(); + MoverResumeTravelling(); + Game::entityManager->SerializeEntity(m_Parent); } void MovingPlatformComponent::StopPathing() { if (m_MoverSubComponentType == eMoverSubComponentType::simpleMover) { - // LWOPlatformSimpleMover::StopPlatform: it stops where it is, no longer travelling + // LWOPlatformSimpleMover::StopPlatform 0x00cce1e0: at an end it is stopped; on its way it goes on to the end it + // was heading for, where its path (run once) stops it anyway auto* const simpleMover = GetSimpleMoverSubComponent(); - m_MoveGeneration++; - simpleMover->mState = ePlatformStateFlag::Stopped; + if ((simpleMover->mState & ePlatformStateFlag::ReachedDesiredWaypoint) == 0) return; + simpleMover->mState = (simpleMover->mState & ~(ePlatformStateFlag::Waiting | ePlatformStateFlag::Travelling)) | ePlatformStateFlag::Stopped; simpleMover->mDirtyState = true; Game::entityManager->SerializeEntity(m_Parent); return; } if (m_MoverSubComponentType == eMoverSubComponentType::rotater) { // LWOPlatformRotator::StopPlatform: no longer spinning - GetMoverSubComponent()->mState = ePlatformStateFlag::Stopped; + auto* const rotator = GetMoverSubComponent(); + rotator->mState = (rotator->mState & ~(ePlatformStateFlag::Waiting | ePlatformStateFlag::Travelling)) | ePlatformStateFlag::Stopped; Game::entityManager->SerializeEntity(m_Parent); return; } if (!IsMover()) return; m_PathingStopped = true; auto* const mover = GetMoverSubComponent(); - // LWOPlatform::StopPlatform: stopped where it is, not leaving after a wait - mover->mState = (mover->mState & ~(ePlatformStateFlag::Travelling | ePlatformStateFlag::Waiting)) | ePlatformStateFlag::Stopped; - mover->mDepartAfterWait = false; + // LWOPlatform::StopPlatform 0x00c86b70: stopped where it is (the client stops on the stopped bit, 4) + mover->mState = (mover->mState & ~(ePlatformStateFlag::Waiting | ePlatformStateFlag::Travelling)) | ePlatformStateFlag::Stopped; mover->mDesiredWaypointIndex = -1; mover->mShouldStopAtDesiredWaypoint = false; Game::entityManager->SerializeEntity(m_Parent); @@ -404,6 +410,7 @@ void MovingPlatformComponent::MoverProcessStateChange(const uint32_t index) { mover->mCurrentWaypointIndex = current; mover->mNextWaypointIndex = next; + mover->mWaitTime = waypoints[current].movingPlatform.wait; mover->mPercentBetweenPoints = 0.0f; mover->mMoveTimeElapsed = 0.0f; mover->mPosition = waypoints[current].position; @@ -419,74 +426,155 @@ void MovingPlatformComponent::MoverProcessStateChange(const uint32_t index) { } } -void MovingPlatformComponent::MoverStopAtWaypoint(const bool depart) { +void MovingPlatformComponent::MoverSetWaitingTime(const float waitTime, const float idleTimeElapsed) { auto* const mover = GetMoverSubComponent(); - const auto last = static_cast(m_Path->pathWaypoints.size() - 1); - const bool atEnd = mover->mCurrentWaypointIndex == 0 || mover->mCurrentWaypointIndex == last; - mover->mState = ePlatformStateFlag::Stopped | ePlatformStateFlag::ReachedDesiredWaypoint | (atEnd ? ePlatformStateFlag::ReachedFinalDestination : 0); - mover->mIdleTimeElapsed = 0.0f; - // LWOPlatform::SetWaitingTime: at least 0.1 s - mover->mTimeToWait = std::max(0.1f, m_Path->pathWaypoints[mover->mCurrentWaypointIndex].movingPlatform.wait); - mover->mDepartAfterWait = depart; + mover->mTimeToWait = std::max(0.1f, waitTime); + mover->mIdleTimeElapsed = idleTimeElapsed; + mover->mWaitSet = true; } -void MovingPlatformComponent::MoverDepart() { +void MovingPlatformComponent::MoverResumeTravelling() { auto* const mover = GetMoverSubComponent(); - mover->mState = ePlatformStateFlag::Travelling; - mover->mIdleTimeElapsed = 0.0f; - mover->mDepartAfterWait = false; - Game::entityManager->SerializeEntity(m_Parent); + if ((mover->mState & ePlatformStateFlag::Stopped) != 0 && (mover->mState & ePlatformStateFlag::ReachedDesiredWaypoint) == 0) { + // LWOPlatform::StartTravelling 0x0058b170 + mover->mState = (mover->mState & ~(ePlatformStateFlag::Waiting | ePlatformStateFlag::Stopped)) | ePlatformStateFlag::Travelling; + } + if ((mover->mState & ePlatformStateFlag::Travelling) != 0) { + // LWOPlatform::UpdateVelocities + mover->mState &= ePlatformStateFlag::MovementMask; + } else { + mover->mState = (mover->mState & ~(ePlatformStateFlag::Travelling | ePlatformStateFlag::Stopped)) | ePlatformStateFlag::Waiting; + MoverSetWaitingTime(mover->mWaitTime, 0.0f); + } } -// LWOPlatform::ArrivedAtWaypoint and HandleWaypointArrived: at the next waypoint, it stops there if it was sent there -// to stop, or at an end of a path run once (which turns it round); otherwise it waits there and goes on -void MovingPlatformComponent::MoverArrive() { +void MovingPlatformComponent::MoverArrivedAtWaypoint() { auto* const mover = GetMoverSubComponent(); + // A platform with nowhere to go (one waypoint, or at the end of a path run once) is not arriving + if (mover->mCurrentWaypointIndex == mover->mNextWaypointIndex) return; + + const auto& next = m_Path->pathWaypoints[mover->mNextWaypointIndex].position; + bool closeEnough{}; + if (mover->mTimeBased) { + // LWOPlatform::CheckEnoughTimeElapsedAtWaypoint 0x00bdc720 + closeEnough = std::abs(mover->mCurrentSpeed - mover->mMoveTimeElapsed) < 0.02f; + } else { + // LWOPlatform::CloseToNextWaypoint 0x00bd4140: within 0.01 of it, or past it + const auto offset = next - mover->mPosition; + const auto direction = next - m_Path->pathWaypoints[mover->mCurrentWaypointIndex].position; + closeEnough = offset.SquaredLength() <= 0.0001f || direction.DotProduct(offset) <= 0.0f; + } + if (!closeEnough) return; + const auto arrived = mover->mNextWaypointIndex; - const auto last = static_cast(m_Path->pathWaypoints.size() - 1); - const bool atEnd = arrived == last; - const bool atDesired = mover->mDesiredWaypointIndex == static_cast(arrived); - bool stop = false; - if (atDesired) { - stop = mover->mShouldStopAtDesiredWaypoint; + const bool atLastWaypoint = arrived == static_cast(m_Path->pathWaypoints.size() - 1); + bool atDesiredWaypoint = false; + bool stopAtDesiredWaypoint = false; + bool turnedAtEnd = false; + + mover->mState = ePlatformStateFlag::ReachedDesiredWaypoint; + if (mover->mDesiredWaypointIndex == static_cast(arrived)) { + atDesiredWaypoint = true; + stopAtDesiredWaypoint = mover->mShouldStopAtDesiredWaypoint; mover->mShouldStopAtDesiredWaypoint = false; mover->mDesiredWaypointIndex = -1; } - if ((arrived == 0 || atEnd) && static_cast(m_Path->pathBehavior) == PathBehavior::Once) { - stop = true; - mover->mInReverse = !mover->mInReverse; + if (arrived == 0 || atLastWaypoint) { + mover->mState |= ePlatformStateFlag::ReachedFinalDestination; + if (static_cast(m_Path->pathBehavior) == PathBehavior::Once) { + turnedAtEnd = true; + mover->mInReverse = !mover->mInReverse; + } + } + if (stopAtDesiredWaypoint || turnedAtEnd) { + mover->mState = (mover->mState & ~(ePlatformStateFlag::Waiting | ePlatformStateFlag::Travelling)) | ePlatformStateFlag::Stopped; + } else { + mover->mState = (mover->mState & ~(ePlatformStateFlag::Waiting | ePlatformStateFlag::Stopped)) | ePlatformStateFlag::Travelling; } MoverProcessStateChange(arrived); - MoverStopAtWaypoint(!stop); - Game::entityManager->SerializeEntity(m_Parent); - FireArrivalHooks(arrived, atDesired, atEnd); + MoverHandleWaypointArrived(arrived, atDesiredWaypoint, atLastWaypoint); } +void MovingPlatformComponent::MoverHandleWaypointArrived(const uint32_t index, const bool atDesiredWaypoint, const bool atLastWaypoint) { + FireArrivalHooks(index, atDesiredWaypoint, atLastWaypoint); + + // The scripts may have taken the platform or its object away + auto* const mover = GetMoverSubComponent(); + if (!mover || !IsMover()) return; + if ((mover->mState & ePlatformStateFlag::Travelling) != 0) { + mover->mState = (mover->mState & ~(ePlatformStateFlag::Travelling | ePlatformStateFlag::Stopped)) | ePlatformStateFlag::Waiting; + MoverSetWaitingTime(mover->mWaitTime, 0.0f); + } + // Live serialized a platform that waits at the waypoint it arrived at (it leaves again), not one that stopped there + if ((mover->mState & ePlatformStateFlag::Stopped) == 0) Game::entityManager->SerializeEntity(m_Parent); +} + +// 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* const mover = GetMoverSubComponent(); - if ((mover->mState & ePlatformStateFlag::Travelling) != 0) { - // LWOPlatform::Run / RunPlatform: time based paths count the time travelled; others move at the speed between - // the two waypoints' (CalculateSpeed), the client by its physics - if (mover->mTimeBased) { - mover->mMoveTimeElapsed += deltaTime; - mover->mPercentBetweenPoints = mover->mCurrentSpeed > 0.0f ? std::min(mover->mMoveTimeElapsed, mover->mCurrentSpeed) / mover->mCurrentSpeed : 1.0f; - } else if (mover->mSegmentLength > 0.0f) { + 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 + if (!m_CheckedQuickBuild) { + m_CheckedQuickBuild = true; + m_QuickBuild = m_Parent->GetComponent(); + if (m_QuickBuild) mover->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 (!mover->mActive || mover->mState == 0) return; + + const bool travelling = (mover->mState & ePlatformStateFlag::Travelling) != 0; + const auto& from = m_Path->pathWaypoints[mover->mCurrentWaypointIndex].position; + const auto& to = m_Path->pathWaypoints[mover->mNextWaypointIndex].position; + if (mover->mTimeBased) { + // LWOPlatform::TimeBasedCalculatePercentToNextWaypoint 0x00bd4090: mCurrentSpeed is the seconds to the next waypoint + if (travelling) mover->mMoveTimeElapsed += deltaTime; + mover->mMoveTimeElapsed = std::min(mover->mMoveTimeElapsed, mover->mCurrentSpeed); + mover->mPercentBetweenPoints = mover->mCurrentSpeed > 0.0f ? mover->mMoveTimeElapsed / mover->mCurrentSpeed : 1.0f; + if (travelling) mover->mPosition = from + (to - from) * mover->mPercentBetweenPoints; + } else if (travelling) { + // LWOPlatform::GetSpeed 0x00bd40e0: from the waypoint's speed to the next one's along the way + if (mover->mSegmentLength > 0.0f) { const float speed = mover->mCurrentSpeed + (mover->mNextSpeed - mover->mCurrentSpeed) * mover->mPercentBetweenPoints; - mover->mPercentBetweenPoints += std::max(speed, 0.0f) * deltaTime / mover->mSegmentLength; + const float travelled = mover->mPercentBetweenPoints * mover->mSegmentLength + std::max(speed, 0.0f) * deltaTime; + mover->mPercentBetweenPoints = std::min(travelled / mover->mSegmentLength, 1.0f); } else { mover->mPercentBetweenPoints = 1.0f; } - const auto& from = m_Path->pathWaypoints[mover->mCurrentWaypointIndex].position; - const auto& to = m_Path->pathWaypoints[mover->mNextWaypointIndex].position; - const float percent = std::min(mover->mPercentBetweenPoints, 1.0f); - mover->mPosition = from + (to - from) * percent; - if (mover->mPercentBetweenPoints >= 1.0f) MoverArrive(); - } else if (mover->mDepartAfterWait) { - mover->mIdleTimeElapsed += deltaTime; - if (mover->mIdleTimeElapsed >= mover->mTimeToWait) MoverDepart(); - } else if ((mover->mState & ePlatformStateFlag::ReachedDesiredWaypoint) != 0) { + mover->mPosition = from + (to - from) * mover->mPercentBetweenPoints; + } + + const bool stopped = (mover->mState & ePlatformStateFlag::Stopped) != 0; + // LWOPlatform::IncrementWaitingTime 0x00bd4370 + bool doneWaiting = false; + if ((mover->mState & ePlatformStateFlag::Waiting) != 0 && mover->mWaitSet) { mover->mIdleTimeElapsed += deltaTime; + if (mover->mIdleTimeElapsed >= mover->mTimeToWait) { + mover->mIdleTimeElapsed = 0.0f; + doneWaiting = true; + } + } + if (doneWaiting) { + mover->mState = (mover->mState & ~(ePlatformStateFlag::Waiting | ePlatformStateFlag::Stopped)) | ePlatformStateFlag::Travelling; + } else if (!stopped && travelling) { + // LWOPlatform::HandleStoppedLogic 0x00bd16a0. The client also checks while it waits, which only matters where + // two waypoints are in one place: there it would arrive every frame. + MoverArrivedAtWaypoint(); + // A script told of the arrival may have put the platform on another path + mover = GetMoverSubComponent(); + if (!IsMover()) return; + } + + if ((mover->mState & ePlatformStateFlag::Travelling) != 0 && (doneWaiting || mover->mCurrentSpeed != mover->mNextSpeed)) { + mover->mState &= ePlatformStateFlag::MovementMask; + // Live serialized a platform leaving its waypoint + if (doneWaiting) Game::entityManager->SerializeEntity(m_Parent); } } @@ -535,7 +623,9 @@ void MovingPlatformComponent::WarpToWaypoint(size_t index) { if (!IsMover() || index >= m_Path->pathWaypoints.size()) return; const auto& waypoint = m_Path->pathWaypoints[index]; MoverProcessStateChange(static_cast(index)); - MoverStopAtWaypoint(false); + auto* const mover = GetMoverSubComponent(); + const bool atEnd = index == 0 || index == m_Path->pathWaypoints.size() - 1; + mover->mState = ePlatformStateFlag::Stopped | ePlatformStateFlag::ReachedDesiredWaypoint | (atEnd ? ePlatformStateFlag::ReachedFinalDestination : 0); m_Parent->SetPosition(waypoint.position); m_Parent->SetRotation(waypoint.rotation); diff --git a/dGame/dComponents/MovingPlatformComponent.h b/dGame/dComponents/MovingPlatformComponent.h index f732b9bea..fd030d82d 100644 --- a/dGame/dComponents/MovingPlatformComponent.h +++ b/dGame/dComponents/MovingPlatformComponent.h @@ -48,14 +48,18 @@ enum class eMoverSubComponentType : uint32_t { }; /** - * The client's platform state flags (LWOPlatform) + * The client's platform state flags (LWOPlatform), as live sent them: a platform that was never started is stopped at + * its waypoint (12), one waiting out a waypoint's wait is 9 (25 at either end of its path), one on its way is 2 and + * one that stopped at an end of a path run once is 28. */ namespace ePlatformStateFlag { - constexpr uint32_t Stopped = 1; + constexpr uint32_t Waiting = 1; constexpr uint32_t Travelling = 2; - constexpr uint32_t Waiting = 4; + constexpr uint32_t Stopped = 4; constexpr uint32_t ReachedDesiredWaypoint = 8; constexpr uint32_t ReachedFinalDestination = 16; + // What LWOPlatform::UpdateVelocities 0x00bd4330 keeps when the platform sets off (it drops the two "reached" bits) + constexpr uint32_t MovementMask = Waiting | Travelling | Stopped; }; /** @@ -94,11 +98,16 @@ public: NiPoint3 mPosition{}; uint32_t mCurrentWaypointIndex = 0; uint32_t mNextWaypointIndex = 0; - // Time spent at the waypoint, and how long it waits there (the waypoint's wait, at least 0.1 s) + // Time spent at the waypoint, and how long it waits there (LWOPlatform::SetWaitingTime 0x00bdc860: the waypoint's + // wait, at least 0.1 s); the wait only counts once it was set float mIdleTimeElapsed = 0; float mTimeToWait = 0; - // Whether it leaves when the wait is over (false: stopped until told otherwise) - bool mDepartAfterWait = false; + bool mWaitSet = false; + // The current waypoint's wait + float mWaitTime = 0; + // Whether it runs: a quick build's platform does not until it is built, nor while the quick build is open again + // (LWOPlatform::SendMessage 0x00bdc930, RebuildNotifyState) + bool mActive = true; // Time travelled since the current waypoint (time based paths only) float mMoveTimeElapsed = 0; // The current and next waypoints' speeds; time based paths: the seconds from one to the next in mCurrentSpeed @@ -111,11 +120,10 @@ public: /** * A mover along its path (4), as the client's LWOPlatformMover, run by the server as live ran it: the client's - * LWOPlatform logic (ArrivedAtWaypoint 0x00c86c30, ProcessStateChange 0x00cce380, GetNextWaypoint 0x00bd4490 / - * GetNextReversedWaypoint 0x00bd4420, the speeds of GetWaypointSpeed 0x00be90d0), with live's states: it travels (2), - * and at a waypoint it is stopped (1) with ReachedDesiredWaypoint (8), and ReachedFinalDestination (16) at either end, - * while it waits the waypoint's wait time; each arrival and departure is serialized (NT movers in the live captures: - * 25 on arrival, 2 on departure). + * LWOPlatform logic (RunPlatform 0x00c86e10, ArrivedAtWaypoint 0x00c86c30, HandleWaypointArrived 0x00c17bc0, + * ResumeTravelling 0x00c86b90, ProcessStateChange 0x00cce380, GetNextWaypoint 0x00bd4490 / GetNextReversedWaypoint + * 0x00bd4420, the speeds of GetWaypointSpeed 0x00be90d0). Live serialized it when it began waiting at a waypoint and + * when it left one, not when it stopped (the client stops by itself). */ class MoverSubComponent final : public PlatformSubComponent { public: @@ -136,10 +144,12 @@ public: /** * A simple mover (5), as the client's LWOPlatformSimpleMover: between a start point and that point moved by * platformMove (in the start rotation), taking platformMoveTime seconds, as GenerateSimpleMoverPath builds its two - * waypoint path. Live constructed simple movers stopped at their desired waypoint. + * waypoint path. At an end it has stopped there (28), as any platform on a path run once. */ class SimpleMoverSubComponent final : public PlatformSubComponent { public: + // Live constructed the NJ counterweights waiting at their waypoint (9) + SimpleMoverSubComponent() { mState = ePlatformStateFlag::Waiting | ePlatformStateFlag::ReachedDesiredWaypoint; } eMoverSubComponentType GetType() const override { return eMoverSubComponentType::simpleMover; } // LWOPlatformSimpleMover::Deserialize: a dirty bit, then whether there is a starting point with its position and @@ -295,6 +305,10 @@ private: */ std::vector> m_SubComponents; + // The object's quick build, looked up when the platform first runs + class QuickBuildComponent* m_QuickBuild = nullptr; + bool m_CheckedQuickBuild = false; + /** * Counts sends so an arrival timer from an older send does nothing */ @@ -304,13 +318,16 @@ private: void FireArrivalHooks(uint32_t index, bool atDesiredWaypoint, bool atLastWaypoint); - // The mover: index becomes the current waypoint (the next from the path's behavior and direction), at it + // LWOPlatform::ProcessStateChange: index becomes the current waypoint (the next from the path's behavior and direction) void MoverProcessStateChange(uint32_t index); - // At its current waypoint, stopped; it leaves after the waypoint's wait when depart - void MoverStopAtWaypoint(bool depart); - // Leaves (or carries on) towards the next waypoint - void MoverDepart(); - void MoverArrive(); + // 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 + void MoverResumeTravelling(); + // LWOPlatform::ArrivedAtWaypoint: once it is at its next waypoint + void MoverArrivedAtWaypoint(); + // LWOPlatform::HandleWaypointArrived: tells the scripts, then a platform that travels on waits at the waypoint + void MoverHandleWaypointArrived(uint32_t index, bool atDesiredWaypoint, bool atLastWaypoint); public: void Run(float deltaTime) override; diff --git a/tests/dGameTests/dComponentsTests/MovingPlatformComponentTests.cpp b/tests/dGameTests/dComponentsTests/MovingPlatformComponentTests.cpp index 04b8fcd89..883b5f29d 100644 --- a/tests/dGameTests/dComponentsTests/MovingPlatformComponentTests.cpp +++ b/tests/dGameTests/dComponentsTests/MovingPlatformComponentTests.cpp @@ -89,7 +89,7 @@ TEST_F(MovingPlatformComponentTest, OneSubcomponentPerSetting) { } // Live: 1 (subcomponents), 1 (dirty), 0 (no path), 1, type 5, the starting point (the object's position and -// rotation, w first), state 9 (stopped at its desired waypoint), waypoint 0, not reversing, then the ending 0 bit +// rotation, w first), state 9 (waiting at its desired waypoint), waypoint 0, not reversing, then the ending 0 bit TEST_F(MovingPlatformComponentTest, SimpleMoverConstructionMatchesLive) { info.lot = 16141; info.pos = NiPoint3(Float(0xc2e02255), Float(0x436d1992), Float(0xc32347e8)); @@ -167,9 +167,9 @@ TEST_F(MovingPlatformComponentTest, RotaterIsWrittenAsTypeSix) { EXPECT_EQ(bits.back(), '0'); } -// A mover runs on the server as live ran it (the NT movers in the live captures): stopped at its first waypoint with -// the time waited (25), travelling after the waypoint's wait (2), arriving at the other end stopped there (25, turned -// round on a bounce path), and a GoToWaypoint that stops keeps it there +// A mover runs on the server as live ran it: it waits at its first waypoint (9: waiting at a waypoint, as the GF +// shooting gallery targets were constructed), travels after the waypoint's wait (2), waits at the other end (25, turned +// round on a bounce path); sent to a waypoint to stop it waits with the desired waypoint, leaves and stops there (28) TEST_F(MovingPlatformComponentTest, MoverRunsLikeLive) { Path path; path.pathType = PathType::MovingPlatform; @@ -189,12 +189,12 @@ TEST_F(MovingPlatformComponentTest, MoverRunsLikeLive) { auto* const platform = entity.AddComponent(-1, "TestMoverPath"); ASSERT_TRUE(platform->IsMover()); auto* const mover = platform->GetMoverSubComponent(); - EXPECT_EQ(mover->mState, 25u); + EXPECT_EQ(mover->mState, 9u); EXPECT_EQ(mover->mCurrentWaypointIndex, 0u); EXPECT_EQ(mover->mNextWaypointIndex, 1u); platform->Run(1.9f); - EXPECT_EQ(mover->mState, 25u); + EXPECT_EQ(mover->mState, 9u); EXPECT_FLOAT_EQ(mover->mIdleTimeElapsed, 1.9f); platform->Run(0.2f); EXPECT_EQ(mover->mState, 2u); @@ -217,28 +217,85 @@ TEST_F(MovingPlatformComponentTest, MoverRunsLikeLive) { EXPECT_TRUE(mover->mInReverse); EXPECT_EQ(mover->mPercentBetweenPoints, 0.0f); - // Sent back to the start to stop there: it leaves at once and stays + // Sent back to the start to stop there: it waits out the waypoint's wait, leaves and stays platform->GotoWaypoint(0, true); + EXPECT_EQ(mover->mState, 25u); + EXPECT_EQ(mover->mDesiredWaypointIndex, 0); + EXPECT_TRUE(mover->mShouldStopAtDesiredWaypoint); + platform->Run(1.1f); EXPECT_EQ(mover->mState, 2u); EXPECT_EQ(mover->mDesiredWaypointIndex, 0); platform->Run(2.5f); - EXPECT_EQ(mover->mState, 25u); + EXPECT_EQ(mover->mState, 28u); EXPECT_EQ(mover->mCurrentWaypointIndex, 0u); EXPECT_EQ(mover->mDesiredWaypointIndex, -1); platform->Run(10.0f); - EXPECT_EQ(mover->mState, 25u); + EXPECT_EQ(mover->mState, 28u); - // Started at a waypoint it first waits there (issue 964), then stopped mid way and started again + // Started at a waypoint it first waits there (issue 964), then stopped mid way (4, the bit the client stops on) and + // started again platform->StartPathing(); EXPECT_EQ(mover->mState, 25u); platform->Run(2.1f); EXPECT_EQ(mover->mState, 2u); platform->Run(1.0f); platform->StopPathing(); - EXPECT_EQ(mover->mState & 1u, 1u); + EXPECT_EQ(mover->mState, 4u); const auto percent = mover->mPercentBetweenPoints; platform->Run(1.0f); EXPECT_EQ(mover->mPercentBetweenPoints, percent); platform->StartPathing(); EXPECT_EQ(mover->mState, 2u); } + +// The NJ falling tiles in the live captures (LOT 14335, a two waypoint path run once, not started on load): 12 until +// stepped on, sent to waypoint 1 it waits (9) and leaves (2); there it has stopped and turned round (28), and sent back +// it waits (25, reversed, towards 0) and leaves (2), to stop at the start as live constructed used tiles (28, forwards) +TEST_F(MovingPlatformComponentTest, MoverOnceLikeLiveFallingTile) { + Path path; + path.pathType = PathType::MovingPlatform; + path.pathName = "TestDropPath"; + path.pathBehavior = PathBehavior::Once; + PathWaypoint first; + first.position = NiPoint3(0.0f, 10.0f, 0.0f); + first.speed = 5.0f; + PathWaypoint second = first; + second.position = NiPoint3(0.0f, 0.0f, 0.0f); + path.pathWaypoints = { first, second }; + const_cast(Game::zoneManager->GetZone())->AddPath(path); + + info.settings.Insert(u"startPathingOnLoad", false); + Entity entity(288300744895900109, info); + info.settings.values.clear(); + auto* const platform = entity.AddComponent(-1, "TestDropPath"); + auto* const mover = platform->GetMoverSubComponent(); + EXPECT_EQ(mover->mState, 12u); + platform->Run(5.0f); + EXPECT_EQ(mover->mState, 12u); + + platform->GotoWaypoint(1); + EXPECT_EQ(mover->mState, 9u); + EXPECT_EQ(mover->mDesiredWaypointIndex, 1); + EXPECT_TRUE(mover->mShouldStopAtDesiredWaypoint); + platform->Run(0.11f); + EXPECT_EQ(mover->mState, 2u); + platform->Run(1.0f); + EXPECT_EQ(mover->mState, 2u); + platform->Run(1.1f); + EXPECT_EQ(mover->mState, 28u); + EXPECT_EQ(mover->mCurrentWaypointIndex, 1u); + EXPECT_EQ(mover->mNextWaypointIndex, 0u); + EXPECT_TRUE(mover->mInReverse); + EXPECT_EQ(mover->mDesiredWaypointIndex, -1); + + platform->GotoWaypoint(0); + EXPECT_EQ(mover->mState, 25u); + EXPECT_EQ(mover->mDesiredWaypointIndex, 0); + platform->Run(0.11f); + EXPECT_EQ(mover->mState, 2u); + platform->Run(2.1f); + EXPECT_EQ(mover->mState, 28u); + EXPECT_EQ(mover->mCurrentWaypointIndex, 0u); + EXPECT_EQ(mover->mNextWaypointIndex, 1u); + EXPECT_FALSE(mover->mInReverse); +} From c826db94f57c610520567981511332d73cf51239 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 10 Nov 2026 13:34:49 -0600 Subject: [PATCH 02/14] feat(moving platform): the object goes with its mover on the server LWOPlatform moves its object with SetPosition and SetRotation ignoring the dirty flags (ProcessStateChange 0x00cce380), turned between the two waypoints' rotations, and live serialized a moving platform's mover data without its physics. The server's object stayed where it spawned, so anything that asks where a platform is (the lightning orbs' knockback of issue 1352, phantoms riding a platform) had the start of its path. Co-Authored-By: Claude Opus 5.5 --- dGame/dComponents/MovingPlatformComponent.cpp | 20 ++++++++++ dGame/dComponents/MovingPlatformComponent.h | 2 + dGame/dComponents/Physics/PhysicsComponent.h | 8 ++++ .../MovingPlatformComponentTests.cpp | 38 +++++++++++++++++++ 4 files changed, 68 insertions(+) diff --git a/dGame/dComponents/MovingPlatformComponent.cpp b/dGame/dComponents/MovingPlatformComponent.cpp index 047a40802..0f2bac7b5 100644 --- a/dGame/dComponents/MovingPlatformComponent.cpp +++ b/dGame/dComponents/MovingPlatformComponent.cpp @@ -21,6 +21,7 @@ #include "ObjectLoadState.h" #include "MovementAIComponent.h" #include "QuickBuildComponent.h" +#include "PhantomPhysicsComponent.h" #include "eQuickBuildState.h" void PlatformSubComponent::Serialize(RakNet::BitStream& outBitStream, bool bIsInitialUpdate) { @@ -509,6 +510,22 @@ void MovingPlatformComponent::MoverHandleWaypointArrived(const uint32_t index, c if ((mover->mState & ePlatformStateFlag::Stopped) == 0) Game::entityManager->SerializeEntity(m_Parent); } +// LWOPlatform moves its object with SetPosition and SetRotation ignoring the dirty flags (ProcessStateChange 0x00cce380, +// Resync 0x00be9310), and its physics carry it between waypoints: live did not serialize a mover's physics for it, each +// client moves its own. The server's object goes with the mover, turned between the two waypoints' rotations. +void MovingPlatformComponent::MoveObject() { + const auto* const mover = GetMoverSubComponent(); + const auto& from = m_Path->pathWaypoints[mover->mCurrentWaypointIndex].rotation; + const auto& to = m_Path->pathWaypoints[mover->mNextWaypointIndex].rotation; + const auto rotation = from == to ? from : glm::slerp(from, to, std::min(mover->mPercentBetweenPoints, 1.0f)); + if (auto* const simplePhysics = m_Parent->GetComponent()) { + simplePhysics->SetPositionAndRotationIgnoringDirtyFlags(mover->mPosition, rotation); + } + if (auto* const phantomPhysics = m_Parent->GetComponent()) { + phantomPhysics->SetPositionAndRotationIgnoringDirtyFlags(mover->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) { @@ -550,6 +567,8 @@ void MovingPlatformComponent::Run(const float deltaTime) { mover->mPosition = from + (to - from) * mover->mPercentBetweenPoints; } + MoveObject(); + const bool stopped = (mover->mState & ePlatformStateFlag::Stopped) != 0; // LWOPlatform::IncrementWaitingTime 0x00bd4370 bool doneWaiting = false; @@ -576,6 +595,7 @@ void MovingPlatformComponent::Run(const float deltaTime) { // Live serialized a platform leaving its waypoint if (doneWaiting) Game::entityManager->SerializeEntity(m_Parent); } + if (IsMover()) MoveObject(); } void MovingPlatformComponent::FillResync(GameMessages::PlatformResync& resync) const { diff --git a/dGame/dComponents/MovingPlatformComponent.h b/dGame/dComponents/MovingPlatformComponent.h index fd030d82d..aae1bca82 100644 --- a/dGame/dComponents/MovingPlatformComponent.h +++ b/dGame/dComponents/MovingPlatformComponent.h @@ -320,6 +320,8 @@ private: // LWOPlatform::ProcessStateChange: index becomes the current waypoint (the next from the path's behavior and direction) void MoverProcessStateChange(uint32_t index); + // Puts the object where the mover is, without a serialization of its physics + void MoveObject(); // 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/dGame/dComponents/Physics/PhysicsComponent.h b/dGame/dComponents/Physics/PhysicsComponent.h index da10e3146..df7a5ceeb 100644 --- a/dGame/dComponents/Physics/PhysicsComponent.h +++ b/dGame/dComponents/Physics/PhysicsComponent.h @@ -37,6 +37,14 @@ public: const NiQuaternion& GetRotation() const { return m_Rotation; } virtual void SetRotation(const NiQuaternion& rot) { if (m_Rotation == rot) return; m_Rotation = rot; m_DirtyPosition = true; } + // As the client's SetPosition and SetRotation with bIgnoreDirtyFlags: moved without being serialized for it + void SetPositionAndRotationIgnoringDirtyFlags(const NiPoint3& pos, const NiQuaternion& rot) { + const bool dirty = m_DirtyPosition; + SetPosition(pos); + SetRotation(rot); + m_DirtyPosition = dirty; + } + int32_t GetCollisionGroup() const noexcept { return m_CollisionGroup; } void SetCollisionGroup(int32_t group) noexcept { m_CollisionGroup = group; } diff --git a/tests/dGameTests/dComponentsTests/MovingPlatformComponentTests.cpp b/tests/dGameTests/dComponentsTests/MovingPlatformComponentTests.cpp index 883b5f29d..db210ca28 100644 --- a/tests/dGameTests/dComponentsTests/MovingPlatformComponentTests.cpp +++ b/tests/dGameTests/dComponentsTests/MovingPlatformComponentTests.cpp @@ -299,3 +299,41 @@ TEST_F(MovingPlatformComponentTest, MoverOnceLikeLiveFallingTile) { EXPECT_EQ(mover->mNextWaypointIndex, 1u); EXPECT_FALSE(mover->mInReverse); } + +// The object goes with its mover on the server (LWOPlatform moves it ignoring the dirty flags), turned between the +// waypoints' rotations, and its physics are not serialized for it: live sent a moving target's mover data only +TEST_F(MovingPlatformComponentTest, MoverCarriesItsObject) { + Path path; + path.pathType = PathType::MovingPlatform; + path.pathName = "TestCarryPath"; + path.pathBehavior = PathBehavior::Once; + PathWaypoint first; + first.position = NiPoint3(0.0f, 0.0f, 0.0f); + first.speed = 5.0f; + PathWaypoint second = first; + second.position = NiPoint3(10.0f, 0.0f, 0.0f); + second.rotation = NiQuaternion(0.0f, 0.0f, 1.0f, 0.0f); + path.pathWaypoints = { first, second }; + const_cast(Game::zoneManager->GetZone())->AddPath(path); + + info.pos = NiPoint3(0.0f, 0.0f, 0.0f); + Entity entity(288300744895900110, info); + auto* const simplePhysics = entity.AddComponent(-1); + auto* const platform = entity.AddComponent(-1, "TestCarryPath"); + + RakNet::BitStream before; + simplePhysics->Serialize(before, false); + platform->Run(0.11f); + platform->Run(1.0f); + EXPECT_NEAR(entity.GetPosition().x, 5.0f, 0.01f); + EXPECT_NEAR(std::abs(entity.GetRotation().y), 0.7071f, 0.001f); + + RakNet::BitStream moved; + simplePhysics->Serialize(moved, false); + // No position and rotation (seven floats) in it + EXPECT_LT(moved.GetNumberOfBitsUsed(), 7u * 32u); + + platform->Run(1.1f); + EXPECT_EQ(entity.GetPosition(), NiPoint3(10.0f, 0.0f, 0.0f)); + EXPECT_NEAR(std::abs(entity.GetRotation().y), 1.0f, 0.001f); +} From 275243cf8676aac14cfcd5e499840d41763d636a Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 10 Nov 2026 13:36:25 -0600 Subject: [PATCH 03/14] feat(moving platform): SetCurrentPath puts a mover on a path LWOMovingPlatformComponent::SetCurrentPath (0x00d0f570, the SetCurrentPath game message of 0x00d0f5d0): the path, its starting waypoint and direction, then the subcomponents are made anew (SetupPlatform 0x00ccea50) and the mover takes the path at that waypoint. The path is written to the clients once more, whose component sets it the same way (Deserialize 0x00c88000), and the reverse flag of the path is no longer always written false. Co-Authored-By: Claude Opus 5.5 --- dGame/dComponents/MovingPlatformComponent.cpp | 58 +++++++++++++++---- dGame/dComponents/MovingPlatformComponent.h | 12 ++++ .../MovingPlatformComponentTests.cpp | 53 +++++++++++++++++ 3 files changed, 112 insertions(+), 11 deletions(-) diff --git a/dGame/dComponents/MovingPlatformComponent.cpp b/dGame/dComponents/MovingPlatformComponent.cpp index 0f2bac7b5..11c9cb8b7 100644 --- a/dGame/dComponents/MovingPlatformComponent.cpp +++ b/dGame/dComponents/MovingPlatformComponent.cpp @@ -166,16 +166,10 @@ void MovingPlatformComponent::LoadConfigData() { LOG("Path not found: %s", pathName.c_str()); } - // LWOPlatform::SetupPath: a mover is at its attached_path_start waypoint, stopped (12, as live constructed movers - // nothing had started). Unless startPathingOnLoad is false it then waits the waypoint's wait and leaves (live: 9). if (IsMover()) { - auto* const mover = GetMoverSubComponent(); - mover->mTimeBased = m_Path->movingPlatform.timeBasedMovement != 0; const auto start = m_Parent->HasVar(u"attached_path_start") ? m_Parent->GetVar(u"attached_path_start") : 0; m_StartWaypoint = start >= 0 && static_cast(start) < m_Path->pathWaypoints.size() ? start : 0; - MoverProcessStateChange(m_StartWaypoint); - const bool startOnLoad = !m_Parent->HasVar(u"startPathingOnLoad") || m_Parent->GetVar(u"startPathingOnLoad"); - if (startOnLoad) MoverResumeTravelling(); + 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 @@ -188,6 +182,46 @@ void MovingPlatformComponent::LoadConfigData() { if (simplePhysics && !m_Parent->HasVar(u"motionType")) simplePhysics->SetPhysicsMotionState(SimplePhysicsComponent::MOTION_TYPE_KEYFRAMED); } +// LWOPlatform::SetupPath 0x00c17d10: a mover is at its starting waypoint, stopped (12, as live constructed movers nothing +// had started). Unless startPathingOnLoad is false it then waits the waypoint's wait and leaves (live: 9). +void MovingPlatformComponent::SetupMoverPath() { + auto* const mover = GetMoverSubComponent(); + mover->mTimeBased = m_Path->movingPlatform.timeBasedMovement != 0; + mover->mInReverse = m_PathReverse; + MoverProcessStateChange(m_StartWaypoint); + const bool startOnLoad = !m_Parent->HasVar(u"startPathingOnLoad") || m_Parent->GetVar(u"startPathingOnLoad"); + if (startOnLoad) MoverResumeTravelling(); +} + +// 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 +// LWOMovingPlatformComponent::Deserialize 0x00c88000 does the same there. +bool MovingPlatformComponent::SetCurrentPath(const std::string& pathName, const uint32_t startPoint, const bool reverse) { + m_PathName = GeneralUtils::ASCIIToUTF16(pathName); + m_Path = Game::zoneManager->GetZone()->GetPath(pathName); + m_PathReverse = reverse; + m_DirtyPath = true; + m_PathingStopped = false; + + for (auto& subComponent : m_SubComponents) { + if (subComponent->GetType() == eMoverSubComponentType::mover) subComponent = std::make_unique(m_Parent->GetPosition()); + else if (subComponent->GetType() == eMoverSubComponentType::rotater) subComponent = std::make_unique(m_Parent->GetPosition()); + } + + const bool mover = IsMover(); + if (mover) { + m_StartWaypoint = static_cast(startPoint) < m_Path->pathWaypoints.size() ? startPoint : 0; + SetupMoverPath(); + MoveObject(); + } else { + m_StartWaypoint = startPoint; + if (m_Path == nullptr) LOG("Cannot setup platform on path %s", pathName.c_str()); + } + Game::entityManager->SerializeEntity(m_Parent); + return mover; +} + MovingPlatformComponent::~MovingPlatformComponent() = default; void MovingPlatformComponent::Serialize(RakNet::BitStream& outBitStream, bool bIsInitialUpdate) { @@ -210,11 +244,13 @@ void MovingPlatformComponent::Serialize(RakNet::BitStream& outBitStream, bool bI // LWOMovingPlatformComponent::Deserialize: whether subcomponents follow, whether the path changed (then whether // there is one: its name, starting waypoint and reverse), then the subcomponents. Live wrote the path with the - // construction and the mover's state with every serialization. + // construction and the mover's state with every serialization; a path set later (SetCurrentPath) is written once. outBitStream.Write(true); - outBitStream.Write(bIsInitialUpdate); - if (bIsInitialUpdate) { + const bool writePath = bIsInitialUpdate || m_DirtyPath; + outBitStream.Write(writePath); + if (writePath) { + if (!bIsInitialUpdate) m_DirtyPath = false; const auto hasPath = !m_PathName.empty() && m_Path != nullptr; outBitStream.Write(hasPath); if (hasPath) { @@ -223,7 +259,7 @@ void MovingPlatformComponent::Serialize(RakNet::BitStream& outBitStream, bool bI outBitStream.Write(c); } outBitStream.Write(m_StartWaypoint); - outBitStream.Write(false); + outBitStream.Write(m_PathReverse); } } diff --git a/dGame/dComponents/MovingPlatformComponent.h b/dGame/dComponents/MovingPlatformComponent.h index aae1bca82..25536a2a1 100644 --- a/dGame/dComponents/MovingPlatformComponent.h +++ b/dGame/dComponents/MovingPlatformComponent.h @@ -217,6 +217,12 @@ public: */ void GotoWaypoint(uint32_t index, bool stopAtWaypoint = true); + /** + * Puts the platform on a path of the zone, at one of its waypoints (the SetCurrentPath game message): a mover + * starts over on it. False when the platform is not a mover or the zone has no such path. + */ + bool SetCurrentPath(const std::string& pathName, uint32_t startPoint = 0, bool reverse = false); + /** * Starts the pathing of this platform, setting appropriate waypoints and speeds */ @@ -294,6 +300,10 @@ private: // The waypoint the path starts at (attached_path_start), written with the path uint32_t m_StartWaypoint = 0; + // Whether it runs the path backwards from there (SetCurrentPath), written with the path + bool m_PathReverse = false; + // The path changed since it was last written + bool m_DirtyPath = false; /** * The type of the first subcomponent, the one the server runs (none without one) @@ -320,6 +330,8 @@ private: // LWOPlatform::ProcessStateChange: index becomes the current waypoint (the next from the path's behavior and direction) void MoverProcessStateChange(uint32_t index); + // The mover at its starting waypoint on the path, started unless startPathingOnLoad is false + void SetupMoverPath(); // Puts the object where the mover is, without a serialization of its physics void MoveObject(); // LWOPlatform::SetWaitingTime: starts a wait of at least 0.1 s diff --git a/tests/dGameTests/dComponentsTests/MovingPlatformComponentTests.cpp b/tests/dGameTests/dComponentsTests/MovingPlatformComponentTests.cpp index db210ca28..d231f02b6 100644 --- a/tests/dGameTests/dComponentsTests/MovingPlatformComponentTests.cpp +++ b/tests/dGameTests/dComponentsTests/MovingPlatformComponentTests.cpp @@ -337,3 +337,56 @@ TEST_F(MovingPlatformComponentTest, MoverCarriesItsObject) { EXPECT_EQ(entity.GetPosition(), NiPoint3(10.0f, 0.0f, 0.0f)); EXPECT_NEAR(std::abs(entity.GetRotation().y), 1.0f, 0.001f); } + +// SetCurrentPath (live's shooting gallery script puts its moving platform targets on their paths with it): the mover +// starts over at the path's waypoint, waiting there as live constructed the targets (9), and the path is written once +TEST_F(MovingPlatformComponentTest, SetCurrentPathPutsTheMoverOnThePath) { + Path path; + path.pathType = PathType::MovingPlatform; + path.pathName = "TestTargetPath"; + path.pathBehavior = PathBehavior::Once; + PathWaypoint first; + first.position = NiPoint3(1.0f, 2.0f, 3.0f); + first.speed = 10.0f; + PathWaypoint second = first; + second.position = NiPoint3(1.0f, 2.0f, 23.0f); + PathWaypoint third = first; + third.position = NiPoint3(1.0f, 2.0f, 43.0f); + path.pathWaypoints = { first, second, third }; + const_cast(Game::zoneManager->GetZone())->AddPath(path); + + Entity entity(288300744895900111, info); + entity.AddComponent(-1); + auto* const platform = entity.AddComponent(0, ""); + ASSERT_EQ(platform->GetMoverSubComponentType(), eMoverSubComponentType::mover); + EXPECT_FALSE(platform->IsMover()); + EXPECT_FALSE(platform->SetCurrentPath("NoSuchPath")); + + ASSERT_TRUE(platform->SetCurrentPath("TestTargetPath", 0)); + ASSERT_TRUE(platform->IsMover()); + auto* const mover = platform->GetMoverSubComponent(); + EXPECT_EQ(mover->mState, 9u); + EXPECT_EQ(mover->mCurrentWaypointIndex, 0u); + EXPECT_EQ(mover->mNextWaypointIndex, 1u); + EXPECT_EQ(entity.GetPosition(), NiPoint3(1.0f, 2.0f, 3.0f)); + + // 1 (subcomponents), 1 (the path changed), 1 (there is one), its name's length + RakNet::BitStream withPath; + platform->Serialize(withPath, false); + EXPECT_EQ(Bits(withPath).substr(0, 3 + 16), "111" "0000111000000000"); + RakNet::BitStream withoutPath; + platform->Serialize(withoutPath, false); + EXPECT_EQ(Bits(withoutPath).substr(0, 2), "10"); + + // It leaves after the shortest wait and passes the middle waypoint waiting there (9), as the live targets did + platform->Run(0.11f); + EXPECT_EQ(mover->mState, 2u); + platform->Run(2.1f); + EXPECT_EQ(mover->mState, 9u); + EXPECT_EQ(mover->mCurrentWaypointIndex, 1u); + platform->Run(0.11f); + EXPECT_EQ(mover->mState, 2u); + platform->Run(2.1f); + EXPECT_EQ(mover->mState, 28u); + EXPECT_EQ(mover->mCurrentWaypointIndex, 2u); +} From 11d7455cad316340c71f0ae060e4e1104a0cdea1 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 10 Nov 2026 13:36:25 -0600 Subject: [PATCH 04/14] fix(shooting gallery): moving platform targets run on their mover, as live sg_cannon.lua puts a moving platform target (the subs and the kraken) on its path with SetCurrentPath{ pathName, startPoint = 0 }; its SetPathingSpeed is read by nothing on a moving platform, so these run their paths at the waypoints' speeds (10, 15 and 35 in the zone), as the client's mover does. Live constructed them with the path and the mover waiting at waypoint 0 (9) and serialized the mover at each waypoint (9, then 2). They had a movement AI and a made up PlatformResync instead, and the kraken a speed chosen to look right, which goes back to the script's. PlatformResync's constructor that overrode its arguments for a few LOTs (the FV tree platform 9483 among them) has no user left and is gone; a resync request is answered by movers only. Co-Authored-By: Claude Opus 5.5 --- dGame/dGameMessages/MovementMessages.cpp | 41 ++++--------------- dGame/dGameMessages/MovementMessages.h | 5 --- .../ai/MINIGAME/SG_GF/SERVER/SGCannon.cpp | 40 ++++++++++-------- .../RemainingMessagesTests.cpp | 11 ++++- 4 files changed, 40 insertions(+), 57 deletions(-) diff --git a/dGame/dGameMessages/MovementMessages.cpp b/dGame/dGameMessages/MovementMessages.cpp index cede9c002..2056430fd 100644 --- a/dGame/dGameMessages/MovementMessages.cpp +++ b/dGame/dGameMessages/MovementMessages.cpp @@ -50,26 +50,6 @@ namespace GameMessages { return true; } - PlatformResync::PlatformResync(const Entity& entity, bool bStopAtDesiredWaypoint, int32_t iIndex, int32_t iDesiredWaypointIndex, - int32_t nextIndex, eMovementPlatformState movementState, bool special) : PlatformResync() { - const auto lot = entity.GetLOT(); - - if (lot == 12341 || lot == 5027 || lot == 5028 || lot == 14335 || lot == 14447 || lot == 14449 || lot == 11306 || lot == 11308 || lot == 9483) { - iDesiredWaypointIndex = (lot == 11306 || lot == 11308) ? 1 : 0; - iIndex = lot == 9483 ? 1 : 0; - nextIndex = lot == 9483 && !special ? 1 : 0; - bStopAtDesiredWaypoint = true; - movementState = lot == 9483 && !special ? eMovementPlatformState::Stopped : eMovementPlatformState::Stationary; - } - - target = entity.GetObjectID(); - this->bStopAtDesiredWaypoint = bStopAtDesiredWaypoint; - this->iIndex = iIndex; - this->iDesiredWaypointIndex = iDesiredWaypointIndex; - this->iNextIndex = nextIndex; - this->eState = movementState; - } - void PlatformResync::Serialize(RakNet::BitStream& bitStream) const { bitStream.Write(bReverse); bitStream.Write(bStopAtDesiredWaypoint); @@ -121,21 +101,18 @@ namespace GameMessages { } void RequestPlatformResync::Handle(Entity& entity, const SystemAddress& sysAddr) { - // A mover answers with where the server has it (live: travelling 2 with its percent and position, or 25 at a - // waypoint with the time waited) + // A mover answers with where the server has it (live: travelling 2 with its percent and position, or waiting at + // a waypoint with the time waited) auto* const platform = entity.GetComponent(); - if (platform && platform->IsMover()) { - PlatformResync resync; - resync.target = entity.GetObjectID(); - platform->FillResync(resync); - resync.Send(sysAddr); + if (!platform || !platform->IsMover()) { + // Nothing else has waypoints to resync to: the client's simple mover reads them from its own two point path + // (LWOPlatformSimpleMover::Resync 0x00bf9ec0, a crash without one) and would set off on a travelling state return; } - // A platform without a path has no waypoints to resync to; the client's simple mover reads them anyway and - // crashes (LWOPlatformSimpleMover::Resync 0x00bf9ec0 -> Path::GetWaypointAtIndex on a null path) - if (platform && !platform->HasPath()) return; - if (entity.GetLOT() == 6267 || entity.GetLOT() == 16141) return; - PlatformResync(entity).Send(sysAddr); + PlatformResync resync; + resync.target = entity.GetObjectID(); + platform->FillResync(resync); + resync.Send(sysAddr); } void OrientToAngle::Serialize(RakNet::BitStream& bitStream) const { diff --git a/dGame/dGameMessages/MovementMessages.h b/dGame/dGameMessages/MovementMessages.h index a1a8524e4..11ad03097 100644 --- a/dGame/dGameMessages/MovementMessages.h +++ b/dGame/dGameMessages/MovementMessages.h @@ -49,11 +49,6 @@ namespace GameMessages { // Server -> client, to one client (UNASSIGNED broadcasts). struct PlatformResync : public NetGameMsg { PlatformResync() : NetGameMsg(MessageType::Game::PLATFORM_RESYNC) {} - // What DLU sends for a platform entity: the arguments, overridden for a few LOTs whose paths only have a - // start and an end (special is for the FV tree platform). - PlatformResync(const Entity& entity, bool bStopAtDesiredWaypoint = false, - int32_t iIndex = 0, int32_t iDesiredWaypointIndex = 1, int32_t nextIndex = 1, - eMovementPlatformState movementState = eMovementPlatformState::Moving, bool special = false); void Serialize(RakNet::BitStream& bitStream) const override; bool Deserialize(RakNet::BitStream& bitStream) override; diff --git a/dScripts/ai/MINIGAME/SG_GF/SERVER/SGCannon.cpp b/dScripts/ai/MINIGAME/SG_GF/SERVER/SGCannon.cpp index 2a889eb86..b7172380d 100644 --- a/dScripts/ai/MINIGAME/SG_GF/SERVER/SGCannon.cpp +++ b/dScripts/ai/MINIGAME/SG_GF/SERVER/SGCannon.cpp @@ -11,6 +11,7 @@ #include "CharacterComponent.h" #include "SimplePhysicsComponent.h" #include "MovementAIComponent.h" +#include "MovingPlatformComponent.h" #include "MissionComponent.h" #include "Loot.h" #include "InventoryComponent.h" @@ -295,7 +296,17 @@ void SGCannon::DoSpawnTimerFunc(Entity* self, const std::string& name) { auto* enemy = Game::entityManager->CreateEntity(info, nullptr, self); - auto* movementAI = enemy->AddComponent(-1, MovementAIInfo{}); + // sg_cannon.lua (onChildLoaded): a moving platform target gets SetPathingSpeed, which nothing on a moving platform + // reads, and SetCurrentPath{ pathName, startPoint = 0 }: its mover runs the path at the waypoints' speeds, as the + // client's does. Live constructed these targets with the path and the mover waiting at waypoint 0 (9), and + // serialized the mover at each waypoint. The other targets follow their waypoints. + auto* const movingPlatform = toSpawn.isMovingPlatform ? enemy->GetComponent() : nullptr; + MovementAIComponent* movementAI = nullptr; + if (movingPlatform) { + movingPlatform->SetCurrentPath(path->pathName, 0); + } else { + movementAI = enemy->AddComponent(-1, MovementAIInfo{}); + } auto* simplePhysicsComponent = enemy->GetComponent(); if (simplePhysicsComponent) { simplePhysicsComponent->SetPhysicsMotionState(4); @@ -303,20 +314,21 @@ void SGCannon::DoSpawnTimerFunc(Entity* self, const std::string& name) { Game::entityManager->ConstructEntity(enemy); - movementAI->SetMaxSpeed(toSpawn.initialSpeed); - movementAI->SetCurrentSpeed(toSpawn.initialSpeed); - movementAI->SetHaltDistance(0.0f); + if (movementAI) { + movementAI->SetMaxSpeed(toSpawn.initialSpeed); + movementAI->SetCurrentSpeed(toSpawn.initialSpeed); + movementAI->SetHaltDistance(0.0f); - movementAI->SetPath(path->pathWaypoints); + movementAI->SetPath(path->pathWaypoints); + } enemy->AddDieCallback([this, self, enemy, name]() { RegisterHit(self, enemy, name); }); - // Save the enemy and tell it to start pathing + // Save the enemy if (enemy != nullptr) { const_cast&>(self->GetVar>(SpawnedObjects)).push_back(enemy->GetObjectID()); - GameMessages::PlatformResync(*enemy).Send(UNASSIGNED_SYSTEM_ADDRESS); } } } @@ -1104,22 +1116,14 @@ std::vector> SGCannon::GetWaves() { 1.0, false, true }, - // Ness temp fix for dlu where speeds are set to 7 to match a speed closer to live while we work on movingplatform components. + // Ness { std::vector { "Wave_1_Ness_1", "Wave_1_Ness_2", "Wave_2_Ness_1" }, 2565, 10.0, 15.0, true, 10.0, 15.0, - 7.0, 10000, false, 0.0, 7.0, - 7.0, true, true + 2.0, 10000, false, 0.0, 1.0, + 1.0, true, true }, - // // Ness - // { - // std::vector { "Wave_1_Ness_1", "Wave_1_Ness_2", "Wave_2_Ness_1" }, - // 2565, 10.0, 15.0, true, 10.0, 15.0, - // 2.0, 10000, false, 0.0, 1.0, - // 1.0, true, true - // }, - // Friendly 1 { std::vector { "Wave_3_FShip_1", "Wave_3_FShip_2" }, diff --git a/tests/dGameTests/dGameMessagesTests/RemainingMessagesTests.cpp b/tests/dGameTests/dGameMessagesTests/RemainingMessagesTests.cpp index accbdf551..307adb6c9 100644 --- a/tests/dGameTests/dGameMessagesTests/RemainingMessagesTests.cpp +++ b/tests/dGameTests/dGameMessagesTests/RemainingMessagesTests.cpp @@ -89,14 +89,21 @@ TEST_F(RemainingMessagesTests, TeleportMatchesLegacy) { } TEST_F(RemainingMessagesTests, StartPathingAndPlatformResyncMatchLegacy) { - for (const LOT lot : { 999, 12341, 9483, 11306, 6267 }) { + // The legacy sender overrode its arguments for a few LOTs; the message has no such cases, so only others compare + for (const LOT lot : { 999, 6267 }) { info.lot = lot; Entity entity(0x1000000000000001LL, info); ExpectSameAsLegacyBroadcast([&] { LegacyGameMessages::SendStartPathing(&entity); }, [&] { GameMessages::StartPathing msg; msg.target = entity.GetObjectID(); return msg; }()); for (uint32_t mask = 0; mask < 4; mask++) { for (const auto state : { eMovementPlatformState::Moving, eMovementPlatformState::Stationary, eMovementPlatformState::Stopped }) { for (const int32_t index : { 0, 1, 5 }) { - GameMessages::PlatformResync msg(entity, Bit(mask, 0), index, index + 1, index + 2, state, Bit(mask, 1)); + GameMessages::PlatformResync msg; + msg.target = entity.GetObjectID(); + msg.bStopAtDesiredWaypoint = Bit(mask, 0); + msg.iIndex = index; + msg.iDesiredWaypointIndex = index + 1; + msg.iNextIndex = index + 2; + msg.eState = state; ExpectSameAsLegacy([&](const SystemAddress& address) { LegacyGameMessages::SendPlatformResync(&entity, address, Bit(mask, 0), index, index + 1, index + 2, state, Bit(mask, 1)); }, msg); From 7f6242545684bc59b7b99bce208bed7fbd6e9f3a Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 10 Nov 2026 13:39:48 -0600 Subject: [PATCH 05/14] fix(njhub): a built counterweight goes down by itself, and the scripts hear it arrive Live (the Frakjaw battle captures, LOT 16141): when its quick build completes the simple mover is serialized waiting at waypoint 0 (9) and about 0.1 s later travelling (2); it stops at its end without a serialization. That is the client's LWOPlatform, active once the quick build is complete (SendMessage 0x00bdc930), waiting the shortest wait and setting off (RunPlatform 0x00c86e10). L_CAVE_PRISON_CAGE and L_MONASTERY_BOSS_INSTANCE_SERVER never send it: they ask to be told of its PlatformAtLastWaypoint, and act then. So the simple mover sets off when it is built, the cave prison's button goes down when the counterweight arrives instead of when it is built, and Frakjaw is hit when it arrives (0.1 s and the row's 1 s after the build) instead of on a 0.5 s timer with a counterweight that never moved. Co-Authored-By: Claude Opus 5.5 --- dGame/dComponents/MovingPlatformComponent.cpp | 41 +++++++++-- dGame/dComponents/MovingPlatformComponent.h | 9 +++ .../02_server/Map/njhub/CavePrisonCage.cpp | 17 ++--- .../boss_instance/NjMonastryBossInstance.cpp | 72 ++++++++++--------- .../MovingPlatformComponentTests.cpp | 40 +++++++++++ 5 files changed, 130 insertions(+), 49 deletions(-) diff --git a/dGame/dComponents/MovingPlatformComponent.cpp b/dGame/dComponents/MovingPlatformComponent.cpp index 11c9cb8b7..e1639db37 100644 --- a/dGame/dComponents/MovingPlatformComponent.cpp +++ b/dGame/dComponents/MovingPlatformComponent.cpp @@ -285,7 +285,21 @@ void MovingPlatformComponent::OnQuickBuildInitilized() { void MovingPlatformComponent::OnCompleteQuickBuild() { if (m_MoverSubComponentType == eMoverSubComponentType::simpleMover) { - GetSimpleMoverSubComponent()->mDirtyState = true; + // Live (the NJ counterweights, LOT 16141): the built simple mover is serialized waiting at its waypoint (9) and + // about 0.1 s later travelling (2) to its other end, where it stops without a serialization and + // PlatformAtLastWaypoint tells the scripts. It is the client's LWOPlatform at work: active once its quick build + // is complete (SendMessage 0x00bdc930), it waits the shortest wait (SetWaitingTime 0x00bdc860) and sets off + // (RunPlatform 0x00c86e10). + auto* const simpleMover = GetSimpleMoverSubComponent(); + simpleMover->mDirtyState = true; + const bool startOnLoad = !m_Parent->HasVar(u"startPathingOnLoad") || m_Parent->GetVar(u"startPathingOnLoad"); + if (!startOnLoad || (simpleMover->mState & ePlatformStateFlag::Travelling) != 0) return; + const auto generation = ++m_MoveGeneration; + m_Parent->AddCallbackTimer(0.1f, [this, generation] { + if (generation != m_MoveGeneration) return; + auto* const simpleMover = GetSimpleMoverSubComponent(); + if (simpleMover) SimpleMoverGotoWaypoint(simpleMover->mCurrentWaypointIndex == 0 ? 1 : 0); + }); return; } @@ -728,13 +742,19 @@ void MovingPlatformComponent::OnSimpleMoverArrived(const int32_t index) { auto* const simpleMover = GetSimpleMoverSubComponent(); if (!simpleMover) return; - // LWOPlatform::ArrivedAtWaypoint: both ends are the path's ends, and a path run once stops there + // LWOPlatform::ArrivedAtWaypoint: both ends are the path's ends, and a path run once stops there (28) and turns + // round. Live did not serialize it (each client's simple mover arrives by itself), and the object is moved ignoring + // the dirty flags (ProcessStateChange 0x00cce380). simpleMover->mCurrentWaypointIndex = index; simpleMover->mState = ePlatformStateFlag::Stopped | ePlatformStateFlag::ReachedDesiredWaypoint | ePlatformStateFlag::ReachedFinalDestination; simpleMover->mInReverse = index == 1; - simpleMover->mDirtyState = true; - m_Parent->SetPosition(simpleMover->GetWaypointPosition(index)); - Game::entityManager->SerializeEntity(m_Parent); + const auto position = simpleMover->GetWaypointPosition(index); + if (auto* const simplePhysics = m_Parent->GetComponent()) { + simplePhysics->SetPositionAndRotationIgnoringDirtyFlags(position, simpleMover->mStartRotation); + } + if (auto* const phantomPhysics = m_Parent->GetComponent()) { + phantomPhysics->SetPositionAndRotationIgnoringDirtyFlags(position, simpleMover->mStartRotation); + } FireArrivalHooks(index, true, index == 1); } @@ -745,7 +765,16 @@ void MovingPlatformComponent::FireArrivalHooks(const uint32_t index, const bool auto* const script = m_Parent->GetScript(); script->OnWaypointReached(m_Parent, index); if (atDesiredWaypoint) script->OnArrivedAtDesiredWaypoint(m_Parent, index); - if (atLastWaypoint) script->OnPlatformAtLastWaypoint(m_Parent); + if (atLastWaypoint) { + script->OnPlatformAtLastWaypoint(m_Parent); + // A callback may add another + const auto callbacks = m_AtLastWaypointCallbacks; + for (const auto& callback : callbacks) callback(); + } +} + +void MovingPlatformComponent::AddPlatformAtLastWaypointCallback(const std::function& callback) { + m_AtLastWaypointCallbacks.push_back(callback); } void MovingPlatformComponent::Allocator(Entity& entity, ObjectLoadState& state) { diff --git a/dGame/dComponents/MovingPlatformComponent.h b/dGame/dComponents/MovingPlatformComponent.h index 25536a2a1..59d5b9b5c 100644 --- a/dGame/dComponents/MovingPlatformComponent.h +++ b/dGame/dComponents/MovingPlatformComponent.h @@ -6,6 +6,7 @@ #ifndef MOVINGPLATFORMCOMPONENT_H #define MOVINGPLATFORMCOMPONENT_H +#include #include #include @@ -275,6 +276,12 @@ public: eMoverSubComponentType GetMoverSubComponentType() const { return m_MoverSubComponentType; } + /** + * Calls back whenever the platform is at the last waypoint of its path (the PlatformAtLastWaypoint game message, + * which live's scripts ask to be notified of on the NJ counterweights) + */ + void AddPlatformAtLastWaypointCallback(const std::function& callback); + /** * Called when a simple mover gets to the waypoint it was sent to: the client's LWOPlatform::ArrivedAtWaypoint * and HandleWaypointArrived (Arrived, then ArrivedAtDesiredWaypoint and PlatformAtLastWaypoint) @@ -315,6 +322,8 @@ private: */ std::vector> m_SubComponents; + std::vector> m_AtLastWaypointCallbacks; + // The object's quick build, looked up when the platform first runs class QuickBuildComponent* m_QuickBuild = nullptr; bool m_CheckedQuickBuild = false; diff --git a/dScripts/02_server/Map/njhub/CavePrisonCage.cpp b/dScripts/02_server/Map/njhub/CavePrisonCage.cpp index 6ce4e0868..04b4d6690 100644 --- a/dScripts/02_server/Map/njhub/CavePrisonCage.cpp +++ b/dScripts/02_server/Map/njhub/CavePrisonCage.cpp @@ -84,19 +84,16 @@ void CavePrisonCage::SpawnCounterweight(Entity* self, Spawner* spawner) { }); quickBuildComponent->AddQuickBuildCompleteCallback([this, self](Entity* user) { - auto* counterweight = Game::entityManager->GetEntity(self->GetVar(u"Counterweight")); - - if (counterweight == nullptr) { - return; - } - - // The counterweight is a simple mover (MovingPlatforms row 41): send it down to its end - auto* const platform = counterweight->GetComponent(); - if (platform) platform->GotoWaypoint(1); - // Save the userID as Builder self->SetVar(u"Builder", user->GetObjectID()); + }); + } + // L_CAVE_PRISON_CAGE (notifyPlatformAtLastWaypoint): the built counterweight, a simple mover (MovingPlatforms row + // 41) that sets off by itself when its quick build completes, has gone down to the end of its path + auto* const platform = counterweight->GetComponent(); + if (platform) { + platform->AddPlatformAtLastWaypointCallback([this, self]() { // Get the button and make sure it still exists auto* button = Game::entityManager->GetEntity(self->GetVar(u"Button")); diff --git a/dScripts/02_server/Map/njhub/boss_instance/NjMonastryBossInstance.cpp b/dScripts/02_server/Map/njhub/boss_instance/NjMonastryBossInstance.cpp index 965662b75..2b2b01253 100644 --- a/dScripts/02_server/Map/njhub/boss_instance/NjMonastryBossInstance.cpp +++ b/dScripts/02_server/Map/njhub/boss_instance/NjMonastryBossInstance.cpp @@ -1,5 +1,6 @@ #include "NjMonastryBossInstance.h" #include "QuickBuildComponent.h" +#include "MovingPlatformComponent.h" #include "DestroyableComponent.h" #include "EntityManager.h" #include "dZoneManager.h" @@ -237,44 +238,49 @@ void NjMonastryBossInstance::HandleCounterWeightSpawned(Entity* self, Entity* co case eQuickBuildState::RESETTING: ActivityTimerStart(self, SpawnCounterWeightTimer, 0.0f, 0.0f); return; - case eQuickBuildState::COMPLETED: { - // TODO: Move the platform? - - // The counterweight is actually a moving platform and we should listen to the last waypoint event here - // 0.5f is a rough estimate of that path, though, and results in less needed logic - self->AddCallbackTimer(0.5f, [this, self, counterWeight]() { - if (counterWeight != nullptr) { - counterWeight->Kill(); - } - - auto* frakjaw = Game::entityManager->GetEntity(self->GetVar(LedgeFrakjawVariable)); - if (frakjaw == nullptr) { - GameMessages::NotifyClientObject(self->GetObjectID(), u"LedgeFrakjawDead", 0, 0, LWOOBJID_EMPTY, "").Send(UNASSIGNED_SYSTEM_ADDRESS); - return; - } - - auto* skillComponent = frakjaw->GetComponent(); - if (skillComponent != nullptr) { - skillComponent->CalculateBehavior(1635, 39097, frakjaw->GetObjectID(), true, false); - } - - RenderComponent::PlayAnimation(frakjaw, StunnedAnimation); - GameMessages::PlayNDAudioEmitter(frakjaw->GetObjectID(), CounterSmashAudio).Send(UNASSIGNED_SYSTEM_ADDRESS); - - // Before wave 4 we should lower frakjaw from the ledge - if (self->GetVar(WaveNumberVariable) == 3) { - LowerFrakjaw(self, frakjaw); - return; - } - - ActivityTimerStart(self, SpawnNextWaveTimer, 2.0f, 2.0f); - }); - } default: return; } }); } + + // L_MONASTERY_BOSS_INSTANCE_SERVER (notifyPlatformAtLastWaypoint): the built counterweight has gone down to the end + // of its path. It is a simple mover that sets off by itself when its quick build completes: live sent it + // travelling about 0.1 s after the build, and its trip takes the second of its MovingPlatforms row. + auto* const platform = counterWeight->GetComponent(); + if (platform == nullptr) return; + const auto counterWeightID = counterWeight->GetObjectID(); + platform->AddPlatformAtLastWaypointCallback([this, self, counterWeightID]() { + // Not from inside the counterweight's own arrival + self->AddCallbackTimer(0.0f, [this, self, counterWeightID]() { + auto* const counterWeight = Game::entityManager->GetEntity(counterWeightID); + if (counterWeight != nullptr) { + counterWeight->Kill(); + } + + auto* frakjaw = Game::entityManager->GetEntity(self->GetVar(LedgeFrakjawVariable)); + if (frakjaw == nullptr) { + GameMessages::NotifyClientObject(self->GetObjectID(), u"LedgeFrakjawDead", 0, 0, LWOOBJID_EMPTY, "").Send(UNASSIGNED_SYSTEM_ADDRESS); + return; + } + + auto* skillComponent = frakjaw->GetComponent(); + if (skillComponent != nullptr) { + skillComponent->CalculateBehavior(1635, 39097, frakjaw->GetObjectID(), true, false); + } + + RenderComponent::PlayAnimation(frakjaw, StunnedAnimation); + GameMessages::PlayNDAudioEmitter(frakjaw->GetObjectID(), CounterSmashAudio).Send(UNASSIGNED_SYSTEM_ADDRESS); + + // Before wave 4 we should lower frakjaw from the ledge + if (self->GetVar(WaveNumberVariable) == 3) { + LowerFrakjaw(self, frakjaw); + return; + } + + ActivityTimerStart(self, SpawnNextWaveTimer, 2.0f, 2.0f); + }); + }); } void NjMonastryBossInstance::HandleLowerFrakjawSpawned(Entity* self, Entity* lowerFrakjaw) { diff --git a/tests/dGameTests/dComponentsTests/MovingPlatformComponentTests.cpp b/tests/dGameTests/dComponentsTests/MovingPlatformComponentTests.cpp index d231f02b6..dbfc654fb 100644 --- a/tests/dGameTests/dComponentsTests/MovingPlatformComponentTests.cpp +++ b/tests/dGameTests/dComponentsTests/MovingPlatformComponentTests.cpp @@ -390,3 +390,43 @@ TEST_F(MovingPlatformComponentTest, SetCurrentPathPutsTheMoverOnThePath) { EXPECT_EQ(mover->mState, 28u); EXPECT_EQ(mover->mCurrentWaypointIndex, 2u); } + +// The NJ counterweights in the live captures (LOT 16141, the Frakjaw battle): when the quick build completes the simple +// mover is serialized waiting at waypoint 0 (9), then travelling from it (2); it stops at its end without a +// serialization, where PlatformAtLastWaypoint tells the scripts +TEST_F(MovingPlatformComponentTest, BuiltSimpleMoverSetsOffLikeLive) { + info.lot = 16141; + info.pos = NiPoint3(10.0f, 20.0f, 30.0f); + info.rot = QuatUtils::IDENTITY; + Entity counterweight(288300744895900112, info); + counterweight.AddComponent(-1); + auto* const platform = counterweight.AddComponent(41, ""); + auto* const mover = platform->GetSimpleMoverSubComponent(); + uint32_t atLastWaypoint = 0; + platform->AddPlatformAtLastWaypointCallback([&atLastWaypoint] { atLastWaypoint++; }); + + platform->OnCompleteQuickBuild(); + RakNet::BitStream built; + platform->Serialize(built, false); + EXPECT_EQ(Bits(built), "101" "00000101000000000000000000000000" "0" "1" "00001001000000000000000000000000" "00000000000000000000000000000000" "0" "0"); + + counterweight.Update(0.0f); // timers added now start on the next update + counterweight.Update(0.11f); + EXPECT_EQ(mover->mState, 2u); + RakNet::BitStream travelling; + platform->Serialize(travelling, false); + EXPECT_EQ(Bits(travelling), "101" "00000101000000000000000000000000" "0" "1" "00000010000000000000000000000000" "00000000000000000000000000000000" "0" "0"); + + counterweight.Update(0.0f); + counterweight.Update(0.5f); + EXPECT_EQ(mover->mState, 2u); + EXPECT_EQ(atLastWaypoint, 0u); + counterweight.Update(0.6f); + EXPECT_EQ(mover->mState, 28u); + EXPECT_EQ(mover->mCurrentWaypointIndex, 1u); + EXPECT_EQ(atLastWaypoint, 1u); + EXPECT_EQ(counterweight.GetPosition(), NiPoint3(10.0f, 17.5f, 30.0f)); + RakNet::BitStream arrived; + platform->Serialize(arrived, false); + EXPECT_EQ(Bits(arrived), "00"); +} From f6d0a48172958ee8fa08eeeed711e925f9784867 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 10 Nov 2026 13:40:30 -0600 Subject: [PATCH 06/14] fix(moving platform): a rotater without a path stays stopped at its waypoint Only a rotater on a path runs (LWOMovingPlatformComponent::SetupPlatform 0x00ccea50), and the client reads a platform that is not at a waypoint from its path's waypoints (LWOPlatform::Deserialize 0x00c870e0), which a rotater without a path does not have. It was sent travelling (2) whenever startPathingOnLoad was not false. No object in the client's maps is a rotater. Co-Authored-By: Claude Opus 5.5 --- dGame/dComponents/MovingPlatformComponent.cpp | 9 ++++++--- .../dComponentsTests/MovingPlatformComponentTests.cpp | 4 ++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/dGame/dComponents/MovingPlatformComponent.cpp b/dGame/dComponents/MovingPlatformComponent.cpp index e1639db37..01ab84284 100644 --- a/dGame/dComponents/MovingPlatformComponent.cpp +++ b/dGame/dComponents/MovingPlatformComponent.cpp @@ -172,9 +172,11 @@ void MovingPlatformComponent::LoadConfigData() { 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 + // 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) GetMoverSubComponent()->mState = ePlatformStateFlag::Travelling; + if (startOnLoad && m_Path != nullptr) GetMoverSubComponent()->mState = ePlatformStateFlag::Travelling; } // Live constructed every moving platform with simple physics as keyframed @@ -381,7 +383,8 @@ void MovingPlatformComponent::StartPathing() { return; } if (m_MoverSubComponentType == eMoverSubComponentType::rotater) { - // LWOPlatformRotator::StartLinearMovement: spinning again + // LWOPlatformRotator::StartLinearMovement: spinning again (only on a path, as above) + if (m_Path == nullptr) return; GetMoverSubComponent()->mState = ePlatformStateFlag::Travelling; Game::entityManager->SerializeEntity(m_Parent); return; diff --git a/tests/dGameTests/dComponentsTests/MovingPlatformComponentTests.cpp b/tests/dGameTests/dComponentsTests/MovingPlatformComponentTests.cpp index dbfc654fb..ec0938172 100644 --- a/tests/dGameTests/dComponentsTests/MovingPlatformComponentTests.cpp +++ b/tests/dGameTests/dComponentsTests/MovingPlatformComponentTests.cpp @@ -158,6 +158,10 @@ TEST_F(MovingPlatformComponentTest, RotaterIsWrittenAsTypeSix) { ASSERT_EQ(platform->GetMoverSubComponentType(), eMoverSubComponentType::rotater); ASSERT_NE(platform->GetMoverSubComponent(), nullptr); platform->SetSerialized(true); + // Without a path it does not run: it stays stopped at its waypoint, also when started + EXPECT_EQ(platform->GetMoverSubComponent()->mState, 12u); + platform->StartPathing(); + EXPECT_EQ(platform->GetMoverSubComponent()->mState, 12u); RakNet::BitStream stream; platform->Serialize(stream, true); From 2ed70710cf9d2ecafe27b657f6936aa27876c7a0 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 10 Nov 2026 16:45:01 -0600 Subject: [PATCH 07/14] 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)); +} From 4e8e0e64efc3d87912465ac94f013192298e4f89 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 10 Nov 2026 16:46:31 -0600 Subject: [PATCH 08/14] fix(moving platform): a mover keeps its velocity through a wait between the ends, as live Live serialized a mover leaving a waypoint not where the waypoint is but past it: the GF shooting gallery subs 2 units on (speed 15), the FV dragons 5.33 (speed 40), each along the stretch they came by, and a dragon met 2.2 s into its 5 s wait was 88 units past its waypoint (1.219 of the next stretch). At either end of a path they left from the waypoint itself. It is the client's LWOPlatform at work, which the server ran too: - SetWaitingTime (0x00bdc860) takes the object's velocities away only when the platform has stopped or is at an end of its path, so through a wait in between the object goes on as it came; - the percent is how far the object is from the waypoint (CalculatePercentTravelledToWaypoint 0x00be9050); - UpdateVelocities (0x00bd4330) on leaving turns it the next stretch's way from where it is, and it arrives when it passes the next waypoint (CloseToNextWaypoint 0x00bd4140), where ProcessStateChange (0x00cce380) puts it at the waypoint; - a platform that has not stopped looks whether it arrived while it waits too (HandleStoppedLogic 0x00bd16a0): the dragons were at waypoint 5 without having left 4. The server's mover now moves by its velocity in the same way, so the state it sends on leaving carries the place and percent live sent. Co-Authored-By: Claude Fable 5.2 --- dGame/dComponents/MovingPlatformComponent.cpp | 67 +++++++++++++------ dGame/dComponents/MovingPlatformComponent.h | 8 ++- .../MovingPlatformComponentTests.cpp | 58 ++++++++++++++++ 3 files changed, 110 insertions(+), 23 deletions(-) diff --git a/dGame/dComponents/MovingPlatformComponent.cpp b/dGame/dComponents/MovingPlatformComponent.cpp index 65de8dbe7..9688d5e63 100644 --- a/dGame/dComponents/MovingPlatformComponent.cpp +++ b/dGame/dComponents/MovingPlatformComponent.cpp @@ -363,6 +363,7 @@ void MovingPlatformComponent::GotoWaypoint(uint32_t index, bool stopAtWaypoint) mover->mShouldStopAtDesiredWaypoint = false; if (stopAtWaypoint) { mover->mState = (mover->mState & ~(ePlatformStateFlag::Waiting | ePlatformStateFlag::Travelling)) | ePlatformStateFlag::Stopped; + mover->mVelocity = NiPoint3Constant::ZERO; } FireArrivalHooks(index, true, index == m_Path->pathWaypoints.size() - 1); return; @@ -444,6 +445,7 @@ void MovingPlatformComponent::StopPathing() { auto* const mover = GetMoverSubComponent(); // LWOPlatform::StopPlatform 0x00c86b70: stopped where it is (the client stops on the stopped bit, 4) mover->mState = (mover->mState & ~(ePlatformStateFlag::Waiting | ePlatformStateFlag::Travelling)) | ePlatformStateFlag::Stopped; + mover->mVelocity = NiPoint3Constant::ZERO; mover->mDesiredWaypointIndex = -1; mover->mShouldStopAtDesiredWaypoint = false; Game::entityManager->SerializeEntity(m_Parent); @@ -507,6 +509,24 @@ void MovingPlatformComponent::MoverSetWaitingTime(const float waitTime, const fl mover->mTimeToWait = std::max(0.1f, waitTime); mover->mIdleTimeElapsed = idleTimeElapsed; mover->mWaitSet = true; + // Only at an end of the path, or stopped, does the object lose its velocities here. Waiting at a waypoint in between + // it keeps them: live's platforms went on as they came for the wait (the GF shooting gallery subs 2 units past each + // waypoint, the FV dragons 5.33; a dragon met 2.2 s into a 5 s wait was 88 past its waypoint). + if ((mover->mState & (ePlatformStateFlag::ReachedFinalDestination | ePlatformStateFlag::Stopped)) != 0) mover->mVelocity = NiPoint3Constant::ZERO; +} + +void MovingPlatformComponent::MoverUpdateVelocities() { + auto* const mover = GetMoverSubComponent(); + auto direction = m_Path->pathWaypoints[mover->mNextWaypointIndex].position - m_Path->pathWaypoints[mover->mCurrentWaypointIndex].position; + const float distance = direction.Length(); + if (distance > 0.0f) direction = direction / distance; + // LWOPlatform::GetSpeed 0x00bd40e0: a time based path's mCurrentSpeed is the seconds to the next waypoint; else from + // the waypoint's speed to the next one's along the way + float speed{}; + if (mover->mTimeBased) speed = mover->mCurrentSpeed > 0.0f ? distance / mover->mCurrentSpeed : 0.0f; + else speed = mover->mCurrentSpeed + (mover->mNextSpeed - mover->mCurrentSpeed) * mover->mPercentBetweenPoints; + mover->mVelocity = direction * speed; + mover->mState &= ePlatformStateFlag::MovementMask; } void MovingPlatformComponent::MoverResumeTravelling() { @@ -516,8 +536,7 @@ void MovingPlatformComponent::MoverResumeTravelling() { mover->mState = (mover->mState & ~(ePlatformStateFlag::Waiting | ePlatformStateFlag::Stopped)) | ePlatformStateFlag::Travelling; } if ((mover->mState & ePlatformStateFlag::Travelling) != 0) { - // LWOPlatform::UpdateVelocities - mover->mState &= ePlatformStateFlag::MovementMask; + MoverUpdateVelocities(); } else { mover->mState = (mover->mState & ~(ePlatformStateFlag::Travelling | ePlatformStateFlag::Stopped)) | ePlatformStateFlag::Waiting; MoverSetWaitingTime(mover->mWaitTime, 0.0f); @@ -564,6 +583,7 @@ void MovingPlatformComponent::MoverArrivedAtWaypoint() { } if (stopAtDesiredWaypoint || turnedAtEnd) { mover->mState = (mover->mState & ~(ePlatformStateFlag::Waiting | ePlatformStateFlag::Travelling)) | ePlatformStateFlag::Stopped; + mover->mVelocity = NiPoint3Constant::ZERO; } else { mover->mState = (mover->mState & ~(ePlatformStateFlag::Waiting | ePlatformStateFlag::Stopped)) | ePlatformStateFlag::Travelling; } @@ -718,27 +738,27 @@ void MovingPlatformComponent::Run(const float deltaTime) { if (!IsMover()) return; auto* mover = GetMoverSubComponent(); UpdateActive(*mover); - if (!mover->mActive || mover->mState == 0) return; + if (!mover->mActive) { + // RebuildNotifyState: an open quick build's platform loses its velocities + mover->mVelocity = NiPoint3Constant::ZERO; + return; + } + if (mover->mState == 0) return; const bool travelling = (mover->mState & ePlatformStateFlag::Travelling) != 0; - const auto& from = m_Path->pathWaypoints[mover->mCurrentWaypointIndex].position; - const auto& to = m_Path->pathWaypoints[mover->mNextWaypointIndex].position; + if (mover->mTimeBased && travelling) mover->mMoveTimeElapsed += deltaTime; + // The object's physics: it goes at its velocity, travelling or not + mover->mPosition = mover->mPosition + mover->mVelocity * deltaTime; + if (mover->mTimeBased) { // LWOPlatform::TimeBasedCalculatePercentToNextWaypoint 0x00bd4090: mCurrentSpeed is the seconds to the next waypoint - if (travelling) mover->mMoveTimeElapsed += deltaTime; mover->mMoveTimeElapsed = std::min(mover->mMoveTimeElapsed, mover->mCurrentSpeed); mover->mPercentBetweenPoints = mover->mCurrentSpeed > 0.0f ? mover->mMoveTimeElapsed / mover->mCurrentSpeed : 1.0f; - if (travelling) mover->mPosition = from + (to - from) * mover->mPercentBetweenPoints; - } else if (travelling) { - // LWOPlatform::GetSpeed 0x00bd40e0: from the waypoint's speed to the next one's along the way - if (mover->mSegmentLength > 0.0f) { - const float speed = mover->mCurrentSpeed + (mover->mNextSpeed - mover->mCurrentSpeed) * mover->mPercentBetweenPoints; - const float travelled = mover->mPercentBetweenPoints * mover->mSegmentLength + std::max(speed, 0.0f) * deltaTime; - mover->mPercentBetweenPoints = std::min(travelled / mover->mSegmentLength, 1.0f); - } else { - mover->mPercentBetweenPoints = 1.0f; - } - mover->mPosition = from + (to - from) * mover->mPercentBetweenPoints; + } else { + // LWOPlatform::CalculatePercentTravelledToWaypoint 0x00be9050: how far the object is from the current waypoint, + // over the way to the next (live sent 1.219 for a dragon that far past its waypoint) + const auto& from = m_Path->pathWaypoints[mover->mCurrentWaypointIndex].position; + mover->mPercentBetweenPoints = mover->mSegmentLength > 0.0f ? Vector3::Distance(from, mover->mPosition) / mover->mSegmentLength : 0.0f; } MoveObject(); @@ -755,9 +775,11 @@ void MovingPlatformComponent::Run(const float deltaTime) { } if (doneWaiting) { mover->mState = (mover->mState & ~(ePlatformStateFlag::Waiting | ePlatformStateFlag::Stopped)) | ePlatformStateFlag::Travelling; - } else if (!stopped && travelling) { - // LWOPlatform::HandleStoppedLogic 0x00bd16a0. The client also checks while it waits, which only matters where - // two waypoints are in one place: there it would arrive every frame. + } else if (!stopped && (travelling || mover->mSegmentLength > 0.0f)) { + // LWOPlatform::HandleStoppedLogic 0x00bd16a0: a platform that has not stopped looks whether it arrived, also while + // it waits. Live's FV dragons, going on as they came through the 5 s wait at their waypoint 4, were at waypoint 5 + // (waiting there, then going on in the direction they came to 4 in) without ever having left 4. Where two + // waypoints are in one place the client arrives every frame of the wait; here only a travelling one arrives there. MoverArrivedAtWaypoint(); // A script told of the arrival may have put the platform on another path mover = GetMoverSubComponent(); @@ -765,8 +787,8 @@ void MovingPlatformComponent::Run(const float deltaTime) { } if ((mover->mState & ePlatformStateFlag::Travelling) != 0 && (doneWaiting || mover->mCurrentSpeed != mover->mNextSpeed)) { - mover->mState &= ePlatformStateFlag::MovementMask; - // Live serialized a platform leaving its waypoint + MoverUpdateVelocities(); + // Live serialized a platform leaving its waypoint, where it then was: as far past the waypoint as the wait took it if (doneWaiting) Game::entityManager->SerializeEntity(m_Parent); } if (IsMover()) MoveObject(); @@ -820,6 +842,7 @@ void MovingPlatformComponent::WarpToWaypoint(size_t index) { auto* const mover = GetMoverSubComponent(); const bool atEnd = index == 0 || index == m_Path->pathWaypoints.size() - 1; mover->mState = ePlatformStateFlag::Stopped | ePlatformStateFlag::ReachedDesiredWaypoint | (atEnd ? ePlatformStateFlag::ReachedFinalDestination : 0); + mover->mVelocity = NiPoint3Constant::ZERO; m_Parent->SetPosition(waypoint.position); m_Parent->SetRotation(waypoint.rotation); diff --git a/dGame/dComponents/MovingPlatformComponent.h b/dGame/dComponents/MovingPlatformComponent.h index ed18d446f..1dfa72fae 100644 --- a/dGame/dComponents/MovingPlatformComponent.h +++ b/dGame/dComponents/MovingPlatformComponent.h @@ -95,8 +95,12 @@ public: bool mShouldStopAtDesiredWaypoint = false; // From the current waypoint (0) to the next (1) float mPercentBetweenPoints = 0; - // Where it is now (current waypoint + percent towards the next) + // Where it is now: its object's position, which its linear velocity moves NiPoint3 mPosition{}; + // The linear velocity LWOPlatform::SetLinearVelocity 0x00be9200 gave its object when it last set off (or last looked, + // between waypoints of different speeds). It is only taken away where the platform stops and at either end of the + // path (SetWaitingTime 0x00bdc860), so through a wait at a waypoint in between the object goes on as it came. + NiPoint3 mVelocity{}; uint32_t mCurrentWaypointIndex = 0; uint32_t mNextWaypointIndex = 0; // Time spent at the waypoint, and how long it waits there (LWOPlatform::SetWaitingTime 0x00bdc860: the waypoint's @@ -373,6 +377,8 @@ private: bool UpdateActive(PlatformSubComponent& platform); // LWOPlatform::SetWaitingTime: starts a wait of at least 0.1 s void MoverSetWaitingTime(float waitTime, float idleTimeElapsed); + // LWOPlatform::UpdateVelocities 0x00bd4330: the velocity towards the next waypoint at the speed for where it is + void MoverUpdateVelocities(); // LWOPlatform::ResumeTravelling: stopped between two waypoints it travels on; at a waypoint it first waits there void MoverResumeTravelling(); // LWOPlatform::ArrivedAtWaypoint: once it is at its next waypoint diff --git a/tests/dGameTests/dComponentsTests/MovingPlatformComponentTests.cpp b/tests/dGameTests/dComponentsTests/MovingPlatformComponentTests.cpp index 8bdb504c7..93d49997e 100644 --- a/tests/dGameTests/dComponentsTests/MovingPlatformComponentTests.cpp +++ b/tests/dGameTests/dComponentsTests/MovingPlatformComponentTests.cpp @@ -517,3 +517,61 @@ TEST_F(MovingPlatformComponentTest, RotatorTurnsItsObject) { EXPECT_NEAR(std::abs(entity.GetRotation().y), 1.0f, 0.001f); EXPECT_EQ(entity.GetPosition(), NiPoint3(1.0f, 2.0f, 3.0f)); } + +// Live's movers at a waypoint between the ends of their path: the object is put at the waypoint and serialized waiting +// there (9), keeps the velocity it came with for the wait (LWOPlatform::SetWaitingTime 0x00bdc860 only takes it away at +// an end or when stopped), and is serialized leaving (2) from where that took it, with the part of the next stretch it +// is from the waypoint: the GF shooting gallery subs (speed 15, no wait, so the shortest, 0.1 s: two of live's frames) +// left 2 units past each waypoint. From there it goes the next stretch's direction and arrives as it passes the +// waypoint. At the end of the path it stops where the waypoint is. +TEST_F(MovingPlatformComponentTest, MoverKeepsItsVelocityThroughAWaitLikeLive) { + Path path; + path.pathType = PathType::MovingPlatform; + path.pathName = "TestCornerPath"; + path.pathBehavior = PathBehavior::Once; + PathWaypoint first; + first.position = NiPoint3(0.0f, 0.0f, 0.0f); + first.speed = 15.0f; + PathWaypoint second = first; + second.position = NiPoint3(30.0f, 0.0f, 0.0f); + PathWaypoint third = first; + third.position = NiPoint3(30.0f, 0.0f, 30.0f); + path.pathWaypoints = { first, second, third }; + const_cast(Game::zoneManager->GetZone())->AddPath(path); + + info.pos = NiPoint3(0.0f, 0.0f, 0.0f); + Entity entity(288300744895900114, info); + entity.AddComponent(-1); + auto* const platform = entity.AddComponent(-1, "TestCornerPath"); + auto* const mover = platform->GetMoverSubComponent(); + platform->Run(0.11f); + EXPECT_EQ(mover->mState, 2u); + EXPECT_EQ(mover->mPercentBetweenPoints, 0.0f); + + platform->Run(2.0f); + EXPECT_EQ(mover->mState, 9u); + EXPECT_EQ(mover->mCurrentWaypointIndex, 1u); + EXPECT_EQ(mover->mPosition, NiPoint3(30.0f, 0.0f, 0.0f)); + EXPECT_EQ(mover->mPercentBetweenPoints, 0.0f); + + platform->Run(1.0f / 15.0f); + EXPECT_EQ(mover->mState, 9u); + EXPECT_NEAR(mover->mPosition.x, 31.0f, 0.001f); + platform->Run(1.0f / 15.0f); + EXPECT_EQ(mover->mState, 2u); + EXPECT_NEAR(mover->mPosition.x, 32.0f, 0.001f); + EXPECT_NEAR(mover->mPosition.z, 0.0f, 0.001f); + EXPECT_NEAR(mover->mPercentBetweenPoints, 2.0f / 30.0f, 0.001f); + EXPECT_NEAR(entity.GetPosition().x, 32.0f, 0.001f); + + platform->Run(1.0f); + EXPECT_NEAR(mover->mPosition.x, 32.0f, 0.001f); + EXPECT_NEAR(mover->mPosition.z, 15.0f, 0.001f); + platform->Run(1.0f); + EXPECT_EQ(mover->mState, 28u); + EXPECT_EQ(mover->mCurrentWaypointIndex, 2u); + EXPECT_EQ(mover->mPosition, NiPoint3(30.0f, 0.0f, 30.0f)); + platform->Run(1.0f); + EXPECT_EQ(mover->mPosition, NiPoint3(30.0f, 0.0f, 30.0f)); + EXPECT_EQ(entity.GetPosition(), NiPoint3(30.0f, 0.0f, 30.0f)); +} From 22c31685875385c45009d67f87b99d597bed461a Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 10 Nov 2026 16:49:24 -0600 Subject: [PATCH 09/14] fix(moving platform): a simple mover without a quick build sets off when it is loaded A simple mover is a LWOPlatform like a mover: made stopped at its waypoint (12) and, unless startPathingOnLoad is false, waiting there (9) for the shortest wait before it leaves for its other end (ResumeTravelling 0x00c86b90, SetWaitingTime 0x00bdc860, RunPlatform 0x00c86e10). Live showed it on the NJ counterweights, which wait for their quick build first; one without a quick build was left waiting for a script here. The client only reads startPathingOnLoad (LWOMovingPlatformComponent:: LoadConfigData 0x00d0f690) and never starts a platform by itself, so the rule is the server's, taken from what live did with movers (12 with the setting false, else 9 and off) and from the scripts: L_NJ_SPINNER_SERVER stops its platform when it starts up, simple movers included, and the simple movers that wait for an event without that script all have startPathingOnLoad 0 in the maps. The wait now runs in the component for both, in place of the timer a built counterweight got, and a simple mover with startPathingOnLoad false is constructed stopped (12). Co-Authored-By: Claude Fable 5.2 --- dGame/dComponents/MovingPlatformComponent.cpp | 43 ++++++++++++----- dGame/dComponents/MovingPlatformComponent.h | 8 ++-- .../MovingPlatformComponentTests.cpp | 47 +++++++++++++++++++ 3 files changed, 84 insertions(+), 14 deletions(-) diff --git a/dGame/dComponents/MovingPlatformComponent.cpp b/dGame/dComponents/MovingPlatformComponent.cpp index 9688d5e63..82d1b52a5 100644 --- a/dGame/dComponents/MovingPlatformComponent.cpp +++ b/dGame/dComponents/MovingPlatformComponent.cpp @@ -157,6 +157,18 @@ void MovingPlatformComponent::LoadConfigData() { if (m_Parent->HasVar(u"platformMoveTime")) simpleMover->mMoveTime = m_Parent->GetVar(u"platformMoveTime"); } simpleMover->mCurrentWaypointIndex = simpleMover->mStartAtEnd ? 1 : 0; + + // As a mover on its path (LWOPlatform::ResumeTravelling 0x00c86b90 and SetWaitingTime 0x00bdc860): unless + // startPathingOnLoad is false it waits at its waypoint (9), for the shortest wait as its waypoints have none. + // Live's scripts for the NJ spinners, which are simple movers that only move when used, stop them when they + // start up (L_NJ_SPINNER_SERVER: StopPathing), and the simple movers that wait for an event without such a script + // have startPathingOnLoad 0. + const bool startOnLoad = !m_Parent->HasVar(u"startPathingOnLoad") || m_Parent->GetVar(u"startPathingOnLoad"); + if (startOnLoad) { + simpleMover->mState = ePlatformStateFlag::Waiting | ePlatformStateFlag::ReachedDesiredWaypoint; + simpleMover->mTimeToWait = 0.1f; + simpleMover->mWaitSet = true; + } } const auto pathName = GeneralUtils::UTF16ToWTF8(m_PathName); @@ -313,17 +325,8 @@ void MovingPlatformComponent::OnCompleteQuickBuild() { // about 0.1 s later travelling (2) to its other end, where it stops without a serialization and // PlatformAtLastWaypoint tells the scripts. It is the client's LWOPlatform at work: active once its quick build // is complete (SendMessage 0x00bdc930), it waits the shortest wait (SetWaitingTime 0x00bdc860) and sets off - // (RunPlatform 0x00c86e10). - auto* const simpleMover = GetSimpleMoverSubComponent(); - simpleMover->mDirtyState = true; - const bool startOnLoad = !m_Parent->HasVar(u"startPathingOnLoad") || m_Parent->GetVar(u"startPathingOnLoad"); - if (!startOnLoad || (simpleMover->mState & ePlatformStateFlag::Travelling) != 0) return; - const auto generation = ++m_MoveGeneration; - m_Parent->AddCallbackTimer(0.1f, [this, generation] { - if (generation != m_MoveGeneration) return; - auto* const simpleMover = GetSimpleMoverSubComponent(); - if (simpleMover) SimpleMoverGotoWaypoint(simpleMover->mCurrentWaypointIndex == 0 ? 1 : 0); - }); + // (RunPlatform 0x00c86e10): RunSimpleMover. + GetSimpleMoverSubComponent()->mDirtyState = true; return; } @@ -694,6 +697,20 @@ NiPoint3 CalcRotatorAngularVelocity(const float rotX, const float rotY, const fl return NiPoint3(q[0] * scale, q[1] * scale, q[2] * scale); } +// LWOPlatform::RunPlatform 0x00c86e10 for a simple mover waiting at its waypoint: once the wait is over it sets off for +// its other end (LWOPlatform::IncrementWaitingTime 0x00bd4370, StartTravelling 0x0058b170). A quick build's does not +// run until it is built (LWOPlatform::SendMessage 0x00bdc930). Live: a built counterweight was serialized waiting (9) +// and about 0.1 s later travelling (2). +void MovingPlatformComponent::RunSimpleMover(const float deltaTime) { + auto* const simpleMover = GetSimpleMoverSubComponent(); + if (!UpdateActive(*simpleMover)) return; + if ((simpleMover->mState & ePlatformStateFlag::Waiting) == 0 || !simpleMover->mWaitSet) return; + simpleMover->mIdleTimeElapsed += deltaTime; + if (simpleMover->mIdleTimeElapsed < simpleMover->mTimeToWait) return; + simpleMover->mIdleTimeElapsed = 0.0f; + SimpleMoverGotoWaypoint(simpleMover->mCurrentWaypointIndex == 0 ? 1 : 0); +} + // 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 @@ -735,6 +752,10 @@ void MovingPlatformComponent::Run(const float deltaTime) { RunRotator(deltaTime); return; } + if (m_MoverSubComponentType == eMoverSubComponentType::simpleMover) { + RunSimpleMover(deltaTime); + return; + } if (!IsMover()) return; auto* mover = GetMoverSubComponent(); UpdateActive(*mover); diff --git a/dGame/dComponents/MovingPlatformComponent.h b/dGame/dComponents/MovingPlatformComponent.h index 1dfa72fae..6eb4e364e 100644 --- a/dGame/dComponents/MovingPlatformComponent.h +++ b/dGame/dComponents/MovingPlatformComponent.h @@ -171,12 +171,12 @@ public: /** * A simple mover (5), as the client's LWOPlatformSimpleMover: between a start point and that point moved by * platformMove (in the start rotation), taking platformMoveTime seconds, as GenerateSimpleMoverPath builds its two - * waypoint path. At an end it has stopped there (28), as any platform on a path run once. + * waypoint path. At an end it has stopped there (28), as any platform on a path run once. Like a mover it is made + * stopped at its waypoint (12) and, unless startPathingOnLoad is false, waits there (9, as live constructed the NJ + * counterweights) the shortest wait before it sets off for its other end, a quick build's once it is built. */ class SimpleMoverSubComponent final : public PlatformSubComponent { public: - // Live constructed the NJ counterweights waiting at their waypoint (9) - SimpleMoverSubComponent() { mState = ePlatformStateFlag::Waiting | ePlatformStateFlag::ReachedDesiredWaypoint; } eMoverSubComponentType GetType() const override { return eMoverSubComponentType::simpleMover; } // LWOPlatformSimpleMover::Deserialize: a dirty bit, then whether there is a starting point with its position and @@ -369,6 +369,8 @@ private: void SetupMoverPath(); // Puts the object where the mover is, without a serialization of its physics void MoveObject(); + // A simple mover's wait at its waypoint before it sets off by itself + void RunSimpleMover(float deltaTime); // 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 diff --git a/tests/dGameTests/dComponentsTests/MovingPlatformComponentTests.cpp b/tests/dGameTests/dComponentsTests/MovingPlatformComponentTests.cpp index 93d49997e..908255243 100644 --- a/tests/dGameTests/dComponentsTests/MovingPlatformComponentTests.cpp +++ b/tests/dGameTests/dComponentsTests/MovingPlatformComponentTests.cpp @@ -575,3 +575,50 @@ TEST_F(MovingPlatformComponentTest, MoverKeepsItsVelocityThroughAWaitLikeLive) { EXPECT_EQ(mover->mPosition, NiPoint3(30.0f, 0.0f, 30.0f)); EXPECT_EQ(entity.GetPosition(), NiPoint3(30.0f, 0.0f, 30.0f)); } + +// A simple mover without a quick build runs from when it is loaded, as a mover does: waiting at its waypoint (9) for the +// shortest wait, then off to its other end, where it has stopped (28). With startPathingOnLoad false it stays as it was +// made (12), and so does one its script stops when it starts up (live's NJ spinner script, L_NJ_SPINNER_SERVER). +TEST_F(MovingPlatformComponentTest, SimpleMoverSetsOffOnLoadUnlessToldNotTo) { + info.lot = 16141; + info.pos = NiPoint3(10.0f, 20.0f, 30.0f); + info.rot = QuatUtils::IDENTITY; + { + Entity entity(288300744895900115, info); + entity.AddComponent(-1); + auto* const platform = entity.AddComponent(41, ""); + auto* const mover = platform->GetSimpleMoverSubComponent(); + EXPECT_EQ(mover->mState, 9u); + entity.Update(0.05f); + EXPECT_EQ(mover->mState, 9u); + entity.Update(0.06f); + EXPECT_EQ(mover->mState, 2u); + entity.Update(0.0f); + entity.Update(1.1f); + EXPECT_EQ(mover->mState, 28u); + EXPECT_EQ(mover->mCurrentWaypointIndex, 1u); + // Stopped at its end it does not set off again by itself + entity.Update(1.0f); + EXPECT_EQ(mover->mState, 28u); + } + { + Entity entity(288300744895900116, info); + auto* const platform = entity.AddComponent(41, ""); + auto* const mover = platform->GetSimpleMoverSubComponent(); + platform->StopPathing(); + EXPECT_EQ(mover->mState, 12u); + entity.Update(1.0f); + EXPECT_EQ(mover->mState, 12u); + EXPECT_EQ(mover->mCurrentWaypointIndex, 0u); + } + { + info.settings.Insert(u"startPathingOnLoad", false); + Entity entity(288300744895900117, info); + info.settings.values.clear(); + auto* const platform = entity.AddComponent(41, ""); + auto* const mover = platform->GetSimpleMoverSubComponent(); + EXPECT_EQ(mover->mState, 12u); + entity.Update(1.0f); + EXPECT_EQ(mover->mState, 12u); + } +} From 8a238ab1d03ba9d19018110ee240c4d31788daf0 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 10 Nov 2026 16:50:32 -0600 Subject: [PATCH 10/14] fix(shooting gallery): a target at the end of its path respawns and despawns, without a score SG_TARGET.lua, the targets' server script: at the last waypoint of its path (onArrived with isLastPoint, which the client's PushToLua 0x00b1a910 sets for a moving platform's Arrived as for a waypoint follower's) a target notifies the cannon, FinishedPath, and sg_cannon.lua spawns the next one as after a hit (checkSpawn). A target set to despawn there then dies silently after 1 s, a moving platform after 2 s. Here ships and subs sat at the end of their path until the game ended, and nothing came after them. The script cannot load (the targets have a script row of their own), so the cannon does it: - the respawn is split out of RegisterHit (CheckSpawn), and the die callback only registers a hit when the death is not the silent one; - a moving platform target tells through PlatformAtLastWaypoint, a waypoint follower through a new MovementAIComponent waypoint callback. Co-Authored-By: Claude Fable 5.2 --- dGame/dComponents/MovementAIComponent.cpp | 3 + dGame/dComponents/MovementAIComponent.h | 9 +++ .../ai/MINIGAME/SG_GF/SERVER/SGCannon.cpp | 60 ++++++++++++++++--- dScripts/ai/MINIGAME/SG_GF/SERVER/SGCannon.h | 5 ++ 4 files changed, 69 insertions(+), 8 deletions(-) diff --git a/dGame/dComponents/MovementAIComponent.cpp b/dGame/dComponents/MovementAIComponent.cpp index 792139eec..44f904396 100644 --- a/dGame/dComponents/MovementAIComponent.cpp +++ b/dGame/dComponents/MovementAIComponent.cpp @@ -541,6 +541,9 @@ void MovementAIComponent::SetMaxSpeed(const float value) { void MovementAIComponent::RunWaypointCommands(uint32_t waypointNum) { m_Parent->GetScript()->OnWaypointReached(m_Parent, waypointNum); + // A callback may add another + const auto callbacks = m_WaypointReachedCallbacks; + for (const auto& callback : callbacks) callback(waypointNum); if (!m_Path || waypointNum >= m_Path->pathWaypoints.size()) return; const auto& commands = m_Path->pathWaypoints[waypointNum].commands; diff --git a/dGame/dComponents/MovementAIComponent.h b/dGame/dComponents/MovementAIComponent.h index 91fce3184..d4a508a70 100644 --- a/dGame/dComponents/MovementAIComponent.h +++ b/dGame/dComponents/MovementAIComponent.h @@ -16,6 +16,7 @@ #include "eReplicaComponentType.h" #include "Zone.h" #include "dpKnockback.h" +#include #include #include @@ -188,6 +189,12 @@ public: */ bool AtFinalWaypoint() const { return m_AtFinalWaypoint; } + /** + * Calls back at each waypoint of the path the entity follows, with the waypoint's number (what the Arrived game + * message tells an object's script) + */ + void AddWaypointReachedCallback(const std::function& callback) { m_WaypointReachedCallbacks.push_back(callback); } + /** * Renders the entity stationary */ @@ -380,6 +387,8 @@ private: // The number of waypoints that were on the path in the call to SetPath uint32_t m_CurrentPathWaypointCount{ 0 }; + std::vector> m_WaypointReachedCallbacks; + LWOOBJID m_FollowedTarget{ LWOOBJID_EMPTY }; /** diff --git a/dScripts/ai/MINIGAME/SG_GF/SERVER/SGCannon.cpp b/dScripts/ai/MINIGAME/SG_GF/SERVER/SGCannon.cpp index b7172380d..4b6262b08 100644 --- a/dScripts/ai/MINIGAME/SG_GF/SERVER/SGCannon.cpp +++ b/dScripts/ai/MINIGAME/SG_GF/SERVER/SGCannon.cpp @@ -287,7 +287,7 @@ void SGCannon::DoSpawnTimerFunc(Entity* self, const std::string& name) { info.pos = path->pathWaypoints[0].position; info.settings.Insert(u"SpawnData", toSpawn); - info.settings.Insert(u"custom_script_server", "scripts/ai/ACT/SG_TARGET.lua"); // this script is never loaded; + info.settings.Insert(u"custom_script_server", "scripts/ai/ACT/SG_TARGET.lua"); // never loaded (the targets have a script row): TargetFinishedPath does its part info.settings.Insert(u"custom_script_client", "scripts/client/ai/SG_TARGET_CLIENT.lua"); info.settings.Insert(u"attached_path", path->pathName); info.settings.Insert(u"attached_path_start", 0); @@ -322,10 +322,27 @@ void SGCannon::DoSpawnTimerFunc(Entity* self, const std::string& name) { movementAI->SetPath(path->pathWaypoints); } - enemy->AddDieCallback([this, self, enemy, name]() { - RegisterHit(self, enemy, name); + // A hit target scores (live's cannon ball tells the cannon of its kills). One that got to the end of its path + // tells the cannon and dies silently a moment later (SG_TARGET.lua), which is no hit. + const auto silentDeath = std::make_shared(false); + enemy->AddDieCallback([this, self, enemy, name, silentDeath]() { + if (!*silentDeath) RegisterHit(self, enemy, name); }); + // SG_TARGET.lua (onArrived): the Arrived message says whether the waypoint is the path's last one, for a moving + // platform as for an object following waypoints (the script reads isLastPoint, which the client's PushToLua + // 0x00b1a910 sets from the path and the waypoint) + const auto lastWaypoint = static_cast(path->pathWaypoints.size() - 1); + if (movingPlatform) { + movingPlatform->AddPlatformAtLastWaypointCallback([this, self, enemy, name, silentDeath]() { + TargetFinishedPath(self, enemy, name, silentDeath); + }); + } else if (movementAI) { + movementAI->AddWaypointReachedCallback([this, self, enemy, name, silentDeath, lastWaypoint](const uint32_t waypoint) { + if (waypoint == lastWaypoint) TargetFinishedPath(self, enemy, name, silentDeath); + }); + } + // Save the enemy if (enemy != nullptr) { const_cast&>(self->GetVar>(SpawnedObjects)).push_back(enemy->GetObjectID()); @@ -684,6 +701,37 @@ void SGCannon::OnActivityNotify(Entity* self, GameMessages::ActivityNotify& noti self->SetVar>(u"CannonBallKills", {}); } +void SGCannon::CheckSpawn(Entity* self, Entity* target, const std::string& timerName) { + if (!self->GetVar(GameStartedVariable)) return; + const auto& spawnInfo = target->GetVar(u"SpawnData"); + + if (spawnInfo.respawns && target->GetVar(u"wave") == self->GetVar(ThisWaveVariable)) { + const auto respawnTime = GeneralUtils::GenerateRandomNumber(spawnInfo.minRespawnTime, spawnInfo.maxRespawnTime); + + ActivityTimerStart(self, timerName, respawnTime, respawnTime); + } +} + +// SG_TARGET.lua: at the last waypoint of its path a target notifies the cannon (FinishedPath: sg_cannon.lua spawns the +// next one as after a hit, without a score) and, when it is to despawn there, dies silently 1 s later, a moving +// platform after 2 s (so the clients' platforms get to the end of the path first). +void SGCannon::TargetFinishedPath(Entity* self, Entity* target, const std::string& timerName, const std::shared_ptr& silentDeath) { + // Once: a platform sent on would tell again + if (target->GetVar(u"FinishedPath")) return; + target->SetVar(u"FinishedPath", true); + + CheckSpawn(self, target, timerName); + + const auto& spawnInfo = target->GetVar(u"SpawnData"); + if (!spawnInfo.despawnOnLastWaypoint) return; + target->AddCallbackTimer(spawnInfo.isMovingPlatform ? 2.0f : 1.0f, [target, silentDeath]() { + // Hit in the meantime + if (target->GetIsDead()) return; + *silentDeath = true; + target->Smash(target->GetObjectID(), eKillType::SILENT); + }); +} + void SGCannon::RegisterHit(Entity* self, Entity* target, const std::string& timerName) { if (!self->GetVar(GameStartedVariable)) return; @@ -692,11 +740,7 @@ void SGCannon::RegisterHit(Entity* self, Entity* target, const std::string& time self->SetVar>(u"CannonBallKills", cannonBallKills); const auto& spawnInfo = target->GetVar(u"SpawnData"); - if (spawnInfo.respawns && target->GetVar(u"wave") == self->GetVar(ThisWaveVariable)) { - const auto respawnTime = GeneralUtils::GenerateRandomNumber(spawnInfo.minRespawnTime, spawnInfo.maxRespawnTime); - - ActivityTimerStart(self, timerName, respawnTime, respawnTime); - } + CheckSpawn(self, target, timerName); int32_t score = spawnInfo.score; diff --git a/dScripts/ai/MINIGAME/SG_GF/SERVER/SGCannon.h b/dScripts/ai/MINIGAME/SG_GF/SERVER/SGCannon.h index 3c9ce83bc..1d810a562 100644 --- a/dScripts/ai/MINIGAME/SG_GF/SERVER/SGCannon.h +++ b/dScripts/ai/MINIGAME/SG_GF/SERVER/SGCannon.h @@ -1,4 +1,5 @@ #pragma once +#include #include #include "ActivityManager.h" @@ -89,6 +90,10 @@ private: void PauseChargeCannon(Entity* self); void StopGame(Entity* self, bool cancel = false); void RegisterHit(Entity* self, Entity* target, const std::string& timerName); + // sg_cannon.lua (checkSpawn): a target that respawns gets its next spawn, while its wave runs + void CheckSpawn(Entity* self, Entity* target, const std::string& timerName); + // SG_TARGET.lua (onArrived at the last waypoint): the cannon spawns the next one, and the target is gone a moment later + void TargetFinishedPath(Entity* self, Entity* target, const std::string& timerName, const std::shared_ptr& silentDeath); void UpdateStreak(Entity* self); float_t GetCurrentBonus(Entity* self); From 42d02fc6199ca1faa5284c29a14c13d8abb5e218 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 10 Nov 2026 16:57:15 -0600 Subject: [PATCH 11/14] fix(moving platform): a rotator reads its angles and rate whatever type the level gave them LWOPlatformRotator::LoadConfigData (0x00c33e10) reads rotX, rotY, rotZ and rate as floats from the config; a value the level stored as another number type read as 0 here. Co-Authored-By: Claude Fable 5.2 --- dGame/dComponents/MovingPlatformComponent.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dGame/dComponents/MovingPlatformComponent.cpp b/dGame/dComponents/MovingPlatformComponent.cpp index 82d1b52a5..74f836288 100644 --- a/dGame/dComponents/MovingPlatformComponent.cpp +++ b/dGame/dComponents/MovingPlatformComponent.cpp @@ -213,10 +213,10 @@ 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_Parent->HasVar(u"rotX")) rotator->mRotationX = m_Parent->GetVarAs(u"rotX") * pi; + if (m_Parent->HasVar(u"rotY")) rotator->mRotationY = m_Parent->GetVarAs(u"rotY") * pi; + if (m_Parent->HasVar(u"rotZ")) rotator->mRotationZ = m_Parent->GetVarAs(u"rotZ") * pi; + rotator->mRate = m_Parent->GetVarAs(u"rate"); if (m_Path == nullptr || m_Path->pathWaypoints.empty()) return; // LWOPlatform::SetupPath 0x00c17d10 From 423b55e0b83a1735e22028f5cebf3e305056a6ae Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 10 Nov 2026 16:57:15 -0600 Subject: [PATCH 12/14] feat(scripts): L_SIMPLE_ONCE_ONLY_MOVER_SPINNER_EVENT The script of the platforms a spinner moves once: a simple mover (or a mover on a two point path) in the spinner's group_deactivated_event group goes to its waypoint 1 when the spinner fires the event named in the platform's name_deactivated_event. The NJ spinner script already fires those events at its groups. Co-Authored-By: Claude Fable 5.2 --- dScripts/02_server/Map/General/CMakeLists.txt | 1 + .../SimpleOnceOnlyMoverSpinnerEvent.cpp | 10 +++++++++ .../General/SimpleOnceOnlyMoverSpinnerEvent.h | 10 +++++++++ dScripts/CppScripts.cpp | 2 ++ .../MovingPlatformComponentTests.cpp | 21 +++++++++++++++++++ 5 files changed, 44 insertions(+) create mode 100644 dScripts/02_server/Map/General/SimpleOnceOnlyMoverSpinnerEvent.cpp create mode 100644 dScripts/02_server/Map/General/SimpleOnceOnlyMoverSpinnerEvent.h diff --git a/dScripts/02_server/Map/General/CMakeLists.txt b/dScripts/02_server/Map/General/CMakeLists.txt index dc32d53c5..221faebea 100644 --- a/dScripts/02_server/Map/General/CMakeLists.txt +++ b/dScripts/02_server/Map/General/CMakeLists.txt @@ -15,6 +15,7 @@ set(DSCRIPTS_SOURCES_02_SERVER_MAP_GENERAL "PropertyPlatform.cpp" "QbEnemyStunner.cpp" "QbSpawner.cpp" + "SimpleOnceOnlyMoverSpinnerEvent.cpp" "StoryBoxInteractServer.cpp" "TokenConsoleServer.cpp" "TouchMissionUpdateServer.cpp" diff --git a/dScripts/02_server/Map/General/SimpleOnceOnlyMoverSpinnerEvent.cpp b/dScripts/02_server/Map/General/SimpleOnceOnlyMoverSpinnerEvent.cpp new file mode 100644 index 000000000..81e0a232d --- /dev/null +++ b/dScripts/02_server/Map/General/SimpleOnceOnlyMoverSpinnerEvent.cpp @@ -0,0 +1,10 @@ +#include "SimpleOnceOnlyMoverSpinnerEvent.h" + +#include "Entity.h" +#include "MovingPlatformComponent.h" + +void SimpleOnceOnlyMoverSpinnerEvent::OnFireEventServerSide(Entity* self, Entity* sender, std::string args, int32_t param1, int32_t param2, int32_t param3) { + const auto event = self->GetVarAsString(u"name_deactivated_event"); + if (event.empty() || args != event) return; + if (auto* const platform = self->GetComponent()) platform->GotoWaypoint(1); +} diff --git a/dScripts/02_server/Map/General/SimpleOnceOnlyMoverSpinnerEvent.h b/dScripts/02_server/Map/General/SimpleOnceOnlyMoverSpinnerEvent.h new file mode 100644 index 000000000..dfd39aa5f --- /dev/null +++ b/dScripts/02_server/Map/General/SimpleOnceOnlyMoverSpinnerEvent.h @@ -0,0 +1,10 @@ +#pragma once +#include "CppScripts.h" + +// L_SIMPLE_ONCE_ONLY_MOVER_SPINNER_EVENT.lua (02_server/Map/firetemple): a simple mover (or a mover on a two point +// path) that a spinner sends to its other end. It is put in the spinner's group_deactivated_event group with the +// spinner's name_deactivated_event, and goes to waypoint 1 when the spinner fires that event. +class SimpleOnceOnlyMoverSpinnerEvent : public CppScripts::Script { +public: + void OnFireEventServerSide(Entity* self, Entity* sender, std::string args, int32_t param1, int32_t param2, int32_t param3) override; +}; diff --git a/dScripts/CppScripts.cpp b/dScripts/CppScripts.cpp index c84075099..645792ad5 100644 --- a/dScripts/CppScripts.cpp +++ b/dScripts/CppScripts.cpp @@ -272,6 +272,7 @@ #include "NjNPCMissionSpinjitzuServer.h" #include "NjJayMissionItems.h" #include "FallingTile.h" +#include "SimpleOnceOnlyMoverSpinnerEvent.h" #include "ImaginationShrineServer.h" #include "Lieutenant.h" #include "RainOfArrows.h" @@ -679,6 +680,7 @@ namespace { {"scripts\\02_server\\Map\\General\\Ninjago\\L_RAIL_POST_SERVER.lua", []() {return new NjRailPostServer();}}, {"scripts\\02_server\\Map\\General\\Ninjago\\L_ICE_RAIL_ACTIVATOR_SERVER.lua", []() {return new NjIceRailActivator();}}, {"scripts\\02_server\\Map\\njhub\\L_FALLING_TILE.lua", []() {return new FallingTile();}}, + {"scripts\\02_server\\Map\\firetemple\\L_SIMPLE_ONCE_ONLY_MOVER_SPINNER_EVENT.lua", []() {return new SimpleOnceOnlyMoverSpinnerEvent();}}, {"scripts\\02_server\\Enemy\\General\\L_ENEMY_NJ_BUFF_STUN_IMMUNITY.lua", []() {return new EnemyNjBuff();}}, {"scripts\\02_server\\Map\\njhub\\L_IMAGINATION_SHRINE_SERVER.lua", []() {return new ImaginationShrineServer();}}, {"scripts\\02_server\\Map\\njhub\\L_LIEUTENANT.lua", []() {return new Lieutenant();}}, diff --git a/tests/dGameTests/dComponentsTests/MovingPlatformComponentTests.cpp b/tests/dGameTests/dComponentsTests/MovingPlatformComponentTests.cpp index 908255243..61d37ae36 100644 --- a/tests/dGameTests/dComponentsTests/MovingPlatformComponentTests.cpp +++ b/tests/dGameTests/dComponentsTests/MovingPlatformComponentTests.cpp @@ -8,9 +8,11 @@ #include "BitStream.h" #include "CDClientDatabase.h" #include "Entity.h" +#include "GameMessages.h" #include "LDFFormat.h" #include "MovementMessages.h" #include "MovingPlatformComponent.h" +#include "02_server/Map/General/SimpleOnceOnlyMoverSpinnerEvent.h" #include "SimplePhysicsComponent.h" #include "Zone.h" #include "dZoneManager.h" @@ -622,3 +624,22 @@ TEST_F(MovingPlatformComponentTest, SimpleMoverSetsOffOnLoadUnlessToldNotTo) { EXPECT_EQ(mover->mState, 12u); } } + +// L_SIMPLE_ONCE_ONLY_MOVER_SPINNER_EVENT: the platform goes to its waypoint 1 when the event named in its +// name_deactivated_event is fired at it, and at no other +TEST_F(MovingPlatformComponentTest, OnceOnlyMoverGoesOnItsSpinnersEvent) { + info.lot = 16141; + info.settings.Insert(u"startPathingOnLoad", false); + info.settings.Insert(u"name_deactivated_event", u"blades_off"); + Entity entity(288300744895900118, info); + info.settings.values.clear(); + auto* const platform = entity.AddComponent(41, ""); + auto* const mover = platform->GetSimpleMoverSubComponent(); + SimpleOnceOnlyMoverSpinnerEvent script; + + script.OnFireEventServerSide(&entity, &entity, "blades_on", 0, 0, 0); + EXPECT_EQ(mover->mState, 12u); + script.OnFireEventServerSide(&entity, &entity, "blades_off", 0, 0, 0); + EXPECT_EQ(mover->mState, 2u); + EXPECT_EQ(mover->mCurrentWaypointIndex, 0u); +} From beed2651e59ed4fedc0a9d4be4d62e10069655d0 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 10 Nov 2026 16:57:15 -0600 Subject: [PATCH 13/14] feat(scripts): L_SIMPLE_MOVER_SWITCH, the platforms of the NJ attic switches The script (with L_BASE_SWITCH_ACTIVATED_SERVER) asks the switches in the platform's switchGroup to tell it when they are activated and deactivated, and sends the platform to waypoint 1 on the one and back to waypoint 0 on the other. The NJ monastery's ice and lightning attic platforms (LOTs 14539 and 14376) run it. A switch can now be asked for that (SwitchComponent:: AddActivationCallback). Where live's switch tells its group that it has loaded, the platform here looks for its switches again each second until it has found one. The script was on the list of scripts left out, with a note that its platform does not exist; it is taken off the list. Co-Authored-By: Claude Fable 5.2 --- dGame/dComponents/SwitchComponent.cpp | 2 + dGame/dComponents/SwitchComponent.h | 9 +++++ dScripts/02_server/Map/General/CMakeLists.txt | 1 + .../Map/General/SimpleMoverSwitch.cpp | 37 +++++++++++++++++++ .../02_server/Map/General/SimpleMoverSwitch.h | 13 +++++++ dScripts/CppScripts.cpp | 3 +- 6 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 dScripts/02_server/Map/General/SimpleMoverSwitch.cpp create mode 100644 dScripts/02_server/Map/General/SimpleMoverSwitch.h diff --git a/dGame/dComponents/SwitchComponent.cpp b/dGame/dComponents/SwitchComponent.cpp index 7c38fba11..205491cfc 100644 --- a/dGame/dComponents/SwitchComponent.cpp +++ b/dGame/dComponents/SwitchComponent.cpp @@ -77,6 +77,7 @@ void SwitchComponent::EntityEnter(Entity* entity) { m_Active = true; if (!m_Parent) return; m_Parent->TriggerEvent(eTriggerEventType::ACTIVATED, entity); + for (const auto& callback : std::vector(m_ActivationCallbacks)) callback(true); const auto grpName = m_Parent->GetVarAsString(u"grp_name"); @@ -114,6 +115,7 @@ void SwitchComponent::Run(float deltaTime) { m_Active = false; if (!m_Parent) return; m_Parent->TriggerEvent(eTriggerEventType::DEACTIVATED, m_Parent); + for (const auto& callback : std::vector(m_ActivationCallbacks)) callback(false); const auto grpName = m_Parent->GetVarAsString(u"grp_name"); diff --git a/dGame/dComponents/SwitchComponent.h b/dGame/dComponents/SwitchComponent.h index 88763647e..b7d9aa0cb 100644 --- a/dGame/dComponents/SwitchComponent.h +++ b/dGame/dComponents/SwitchComponent.h @@ -8,6 +8,7 @@ #include "QuickBuildComponent.h" #include "BouncerComponent.h" #include +#include #include "Component.h" #include "eReplicaComponentType.h" @@ -72,6 +73,12 @@ public: */ static SwitchComponent* GetClosestSwitch(NiPoint3 position); + /** + * Calls back when the switch is activated (true) and when it is deactivated (false): what an object's script asks + * a switch for with a notification request for ObjectActivated and ObjectDeactivated + */ + void AddActivationCallback(const std::function& callback) { m_ActivationCallbacks.push_back(callback); } + const std::vector& GetFactionsToRespondTo() const { return m_FactionsToRespondTo; } @@ -113,6 +120,8 @@ private: BouncerComponent* m_PetBouncer = nullptr; std::vector m_FactionsToRespondTo{}; + + std::vector> m_ActivationCallbacks; }; #endif // SWITCHCOMPONENT_H diff --git a/dScripts/02_server/Map/General/CMakeLists.txt b/dScripts/02_server/Map/General/CMakeLists.txt index 221faebea..cb07588fe 100644 --- a/dScripts/02_server/Map/General/CMakeLists.txt +++ b/dScripts/02_server/Map/General/CMakeLists.txt @@ -15,6 +15,7 @@ set(DSCRIPTS_SOURCES_02_SERVER_MAP_GENERAL "PropertyPlatform.cpp" "QbEnemyStunner.cpp" "QbSpawner.cpp" + "SimpleMoverSwitch.cpp" "SimpleOnceOnlyMoverSpinnerEvent.cpp" "StoryBoxInteractServer.cpp" "TokenConsoleServer.cpp" diff --git a/dScripts/02_server/Map/General/SimpleMoverSwitch.cpp b/dScripts/02_server/Map/General/SimpleMoverSwitch.cpp new file mode 100644 index 000000000..868993735 --- /dev/null +++ b/dScripts/02_server/Map/General/SimpleMoverSwitch.cpp @@ -0,0 +1,37 @@ +#include "SimpleMoverSwitch.h" + +#include "Entity.h" +#include "EntityManager.h" +#include "MovingPlatformComponent.h" +#include "SwitchComponent.h" + +void SimpleMoverSwitch::OnStartup(Entity* self) { + RequestSwitches(self); +} + +// switchStartup: every switch in the group is asked for its ObjectActivated (and from then on, in turn, its +// ObjectDeactivated and ObjectActivated). A switch loaded after the platform tells its group so in live +// (objectTypeLoaded) and is asked then; here the platform looks again until it has found one. +void SimpleMoverSwitch::RequestSwitches(Entity* self) { + const auto group = self->GetVarAsString(u"switchGroup"); + if (group.empty()) return; + + bool found = false; + for (auto* const object : Game::entityManager->GetEntitiesInGroup(group)) { + if (object == self) continue; + auto* const switchComponent = object->GetComponent(); + if (!switchComponent) continue; + found = true; + switchComponent->AddActivationCallback([platformID = self->GetObjectID()](const bool active) { + auto* const platformObject = Game::entityManager->GetEntity(platformID); + if (!platformObject) return; + // notifyObjectActivated: GoToWaypoint{ iPathIndex = 1 }; notifyObjectDeactivated: GoToWaypoint{ iPathIndex = 0 } + if (auto* const platform = platformObject->GetComponent()) platform->GotoWaypoint(active ? 1 : 0); + }); + } + if (!found) self->AddTimer("FindSwitch", 1.0f); +} + +void SimpleMoverSwitch::OnTimerDone(Entity* self, std::string timerName) { + if (timerName == "FindSwitch") RequestSwitches(self); +} diff --git a/dScripts/02_server/Map/General/SimpleMoverSwitch.h b/dScripts/02_server/Map/General/SimpleMoverSwitch.h new file mode 100644 index 000000000..7ac01bf44 --- /dev/null +++ b/dScripts/02_server/Map/General/SimpleMoverSwitch.h @@ -0,0 +1,13 @@ +#pragma once +#include "CppScripts.h" + +// L_SIMPLE_MOVER_SWITCH.lua with L_BASE_SWITCH_ACTIVATED_SERVER.lua: a moving platform that a switch sends to and fro +// (the NJ monastery's ice and lightning attic puzzles). It asks the switches in its switchGroup to tell it when they +// are activated and deactivated, and goes to waypoint 1 on the one and back to waypoint 0 on the other. +class SimpleMoverSwitch : public CppScripts::Script { +public: + void OnStartup(Entity* self) override; + void OnTimerDone(Entity* self, std::string timerName) override; +private: + void RequestSwitches(Entity* self); +}; diff --git a/dScripts/CppScripts.cpp b/dScripts/CppScripts.cpp index 645792ad5..b927ab6df 100644 --- a/dScripts/CppScripts.cpp +++ b/dScripts/CppScripts.cpp @@ -272,6 +272,7 @@ #include "NjNPCMissionSpinjitzuServer.h" #include "NjJayMissionItems.h" #include "FallingTile.h" +#include "SimpleMoverSwitch.h" #include "SimpleOnceOnlyMoverSpinnerEvent.h" #include "ImaginationShrineServer.h" #include "Lieutenant.h" @@ -680,6 +681,7 @@ namespace { {"scripts\\02_server\\Map\\General\\Ninjago\\L_RAIL_POST_SERVER.lua", []() {return new NjRailPostServer();}}, {"scripts\\02_server\\Map\\General\\Ninjago\\L_ICE_RAIL_ACTIVATOR_SERVER.lua", []() {return new NjIceRailActivator();}}, {"scripts\\02_server\\Map\\njhub\\L_FALLING_TILE.lua", []() {return new FallingTile();}}, + {"scripts\\02_server\\Map\\General\\L_SIMPLE_MOVER_SWITCH.lua", []() {return new SimpleMoverSwitch();}}, {"scripts\\02_server\\Map\\firetemple\\L_SIMPLE_ONCE_ONLY_MOVER_SPINNER_EVENT.lua", []() {return new SimpleOnceOnlyMoverSpinnerEvent();}}, {"scripts\\02_server\\Enemy\\General\\L_ENEMY_NJ_BUFF_STUN_IMMUNITY.lua", []() {return new EnemyNjBuff();}}, {"scripts\\02_server\\Map\\njhub\\L_IMAGINATION_SHRINE_SERVER.lua", []() {return new ImaginationShrineServer();}}, @@ -814,7 +816,6 @@ namespace { "scripts\\ai\\GENERAL\\L_NPC_GENERIC_WANDER_SMALL.lua", // Really old alpha script "scripts\\zone\\LUPs\\Moonbase Intro\\WBL_Enemy_Grabbler.lua", // Really old alpha script, only sets wander variables "scripts\\ai\\NP\\L_NPC_NP_OLD_MAN_SHERLAND.lua", // This NPC doesn't even exist in modern crux, the only place this is used... - "scripts\\02_server\\Map\\General\\L_SIMPLE_MOVER_SWITCH.lua", // This platform does not exist even when moved manually on a client }; }; From 4e5fb276d639c0624761f74166b98a39827fe0b9 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 10 Nov 2026 16:57:15 -0600 Subject: [PATCH 14/14] feat(scripts): L_FIRE_TRANS_ROCKS and its manager, the rocks of the NJ fire transition L_FIRE_TRANS_ROCKS_MANAGER waits until the rocks of its RockGroup are all there (NumberOfRocks, 3), sends them to waypoint 1 five seconds later, and each time all of them have told it they are back down sends them up again after a second. L_FIRE_TRANS_ROCKS holds a rock that arrived at waypoint 1 for 3.5 s and sends it back to waypoint 0, where it tells the manager (PlatformDown). Both act on ArrivedAtDesiredWaypoint and GoToWaypoint, which the mover has. Co-Authored-By: Claude Fable 5.2 --- dScripts/02_server/Map/njhub/CMakeLists.txt | 2 + .../02_server/Map/njhub/FireTransRocks.cpp | 24 +++++++++ dScripts/02_server/Map/njhub/FireTransRocks.h | 11 ++++ .../Map/njhub/FireTransRocksManager.cpp | 53 +++++++++++++++++++ .../Map/njhub/FireTransRocksManager.h | 15 ++++++ dScripts/CppScripts.cpp | 4 ++ .../MovingPlatformComponentTests.cpp | 39 ++++++++++++++ 7 files changed, 148 insertions(+) create mode 100644 dScripts/02_server/Map/njhub/FireTransRocks.cpp create mode 100644 dScripts/02_server/Map/njhub/FireTransRocks.h create mode 100644 dScripts/02_server/Map/njhub/FireTransRocksManager.cpp create mode 100644 dScripts/02_server/Map/njhub/FireTransRocksManager.h diff --git a/dScripts/02_server/Map/njhub/CMakeLists.txt b/dScripts/02_server/Map/njhub/CMakeLists.txt index b2d0ba7f8..4c13fcb82 100644 --- a/dScripts/02_server/Map/njhub/CMakeLists.txt +++ b/dScripts/02_server/Map/njhub/CMakeLists.txt @@ -5,6 +5,8 @@ set(DSCRIPTS_SOURCES_02_SERVER_MAP_NJHUB "CavePrisonCage.cpp" "EnemySkeletonSpawner.cpp" "FallingTile.cpp" + "FireTransRocks.cpp" + "FireTransRocksManager.cpp" "FlameJetServer.cpp" "LightningOrbServer.cpp" "ImaginationShrineServer.cpp" diff --git a/dScripts/02_server/Map/njhub/FireTransRocks.cpp b/dScripts/02_server/Map/njhub/FireTransRocks.cpp new file mode 100644 index 000000000..ad9147e52 --- /dev/null +++ b/dScripts/02_server/Map/njhub/FireTransRocks.cpp @@ -0,0 +1,24 @@ +#include "FireTransRocks.h" + +#include "Entity.h" +#include "EntityManager.h" +#include "MovingPlatformComponent.h" + +void FireTransRocks::OnStartup(Entity* self) { + if (!self->HasVar(u"RockManagerGroup")) self->SetVar(u"RockManagerGroup", u"FireTransRocksManager"); +} + +void FireTransRocks::OnArrivedAtDesiredWaypoint(Entity* self, const uint32_t waypointIndex) { + if (waypointIndex == 1) { + self->AddTimer("PlatHold", 3.5f); + return; + } + // The first manager there is hears that the rock is down + const auto managers = Game::entityManager->GetEntitiesInGroup(self->GetVarAsString(u"RockManagerGroup")); + if (!managers.empty()) managers.front()->GetScript()->OnNotifyObject(managers.front(), self, "PlatformDown"); +} + +void FireTransRocks::OnTimerDone(Entity* self, std::string timerName) { + if (timerName != "PlatHold") return; + if (auto* const platform = self->GetComponent()) platform->GotoWaypoint(0); +} diff --git a/dScripts/02_server/Map/njhub/FireTransRocks.h b/dScripts/02_server/Map/njhub/FireTransRocks.h new file mode 100644 index 000000000..ffcc0107d --- /dev/null +++ b/dScripts/02_server/Map/njhub/FireTransRocks.h @@ -0,0 +1,11 @@ +#pragma once +#include "CppScripts.h" + +// L_FIRE_TRANS_ROCKS.lua: a rock in the NJ monastery's fire transition, a mover between its bottom (waypoint 0) and its +// top (waypoint 1). At the top it holds 3.5 s and goes back down; at the bottom it tells the rocks' manager. +class FireTransRocks : public CppScripts::Script { +public: + void OnStartup(Entity* self) override; + void OnArrivedAtDesiredWaypoint(Entity* self, uint32_t waypointIndex) override; + void OnTimerDone(Entity* self, std::string timerName) override; +}; diff --git a/dScripts/02_server/Map/njhub/FireTransRocksManager.cpp b/dScripts/02_server/Map/njhub/FireTransRocksManager.cpp new file mode 100644 index 000000000..97407e9ff --- /dev/null +++ b/dScripts/02_server/Map/njhub/FireTransRocksManager.cpp @@ -0,0 +1,53 @@ +#include "FireTransRocksManager.h" + +#include + +#include "Entity.h" +#include "EntityManager.h" +#include "MovingPlatformComponent.h" + +void FireTransRocksManager::OnStartup(Entity* self) { + self->AddTimer("check", 5.0f); + if (!self->HasVar(u"RockGroup")) self->SetVar(u"RockGroup", u"FireTransRocks"); + if (!self->HasVar(u"NumberOfRocks")) self->SetVar(u"NumberOfRocks", 3); +} + +void FireTransRocksManager::CheckForRocks(Entity* self) { + const auto rocks = Game::entityManager->GetEntitiesInGroup(self->GetVarAsString(u"RockGroup")); + // Not all there yet: look again in a second + if (static_cast(rocks.size()) < self->GetVarAs(u"NumberOfRocks")) { + self->AddTimer("check", 1.0f); + return; + } + self->AddTimer("StartPlatforms", 5.0f); +} + +void FireTransRocksManager::OnNotifyObject(Entity* self, Entity* sender, const std::string& name, int32_t param1, int32_t param2) { + if (name != "PlatformDown" || !sender) return; + auto down = self->GetVar>(u"PlatTable"); + if (std::ranges::find(down, sender->GetObjectID()) != down.end()) return; + down.push_back(sender->GetObjectID()); + if (static_cast(down.size()) < self->GetVarAs(u"NumberOfRocks")) { + self->SetVar>(u"PlatTable", down); + return; + } + // All of them are down + self->AddTimer("restartPlatforms", 1.0f); +} + +void FireTransRocksManager::SendRocksUp(Entity* self) { + for (auto* const rock : Game::entityManager->GetEntitiesInGroup(self->GetVarAsString(u"RockGroup"))) { + if (auto* const platform = rock->GetComponent()) platform->GotoWaypoint(1); + } +} + +void FireTransRocksManager::OnTimerDone(Entity* self, std::string timerName) { + if (timerName == "check") { + CheckForRocks(self); + } else if (timerName == "StartPlatforms") { + SendRocksUp(self); + } else if (timerName == "restartPlatforms") { + self->SetVar>(u"PlatTable", {}); + SendRocksUp(self); + } +} diff --git a/dScripts/02_server/Map/njhub/FireTransRocksManager.h b/dScripts/02_server/Map/njhub/FireTransRocksManager.h new file mode 100644 index 000000000..4d942f551 --- /dev/null +++ b/dScripts/02_server/Map/njhub/FireTransRocksManager.h @@ -0,0 +1,15 @@ +#pragma once +#include "CppScripts.h" + +// L_FIRE_TRANS_ROCKS_MANAGER.lua: the manager of the rocks in the NJ monastery's fire transition. Once all the rocks of +// its RockGroup are there (NumberOfRocks, 3) it sends them up 5 s later, and each time all of them are back down it +// sends them up again after 1 s. +class FireTransRocksManager : public CppScripts::Script { +public: + void OnStartup(Entity* self) override; + void OnNotifyObject(Entity* self, Entity* sender, const std::string& name, int32_t param1 = 0, int32_t param2 = 0) override; + void OnTimerDone(Entity* self, std::string timerName) override; +private: + void CheckForRocks(Entity* self); + void SendRocksUp(Entity* self); +}; diff --git a/dScripts/CppScripts.cpp b/dScripts/CppScripts.cpp index b927ab6df..0c2a30b4e 100644 --- a/dScripts/CppScripts.cpp +++ b/dScripts/CppScripts.cpp @@ -272,6 +272,8 @@ #include "NjNPCMissionSpinjitzuServer.h" #include "NjJayMissionItems.h" #include "FallingTile.h" +#include "FireTransRocks.h" +#include "FireTransRocksManager.h" #include "SimpleMoverSwitch.h" #include "SimpleOnceOnlyMoverSpinnerEvent.h" #include "ImaginationShrineServer.h" @@ -681,6 +683,8 @@ namespace { {"scripts\\02_server\\Map\\General\\Ninjago\\L_RAIL_POST_SERVER.lua", []() {return new NjRailPostServer();}}, {"scripts\\02_server\\Map\\General\\Ninjago\\L_ICE_RAIL_ACTIVATOR_SERVER.lua", []() {return new NjIceRailActivator();}}, {"scripts\\02_server\\Map\\njhub\\L_FALLING_TILE.lua", []() {return new FallingTile();}}, + {"scripts\\02_server\\Map\\njhub\\L_FIRE_TRANS_ROCKS.lua", []() {return new FireTransRocks();}}, + {"scripts\\02_server\\Map\\njhub\\L_FIRE_TRANS_ROCKS_MANAGER.lua", []() {return new FireTransRocksManager();}}, {"scripts\\02_server\\Map\\General\\L_SIMPLE_MOVER_SWITCH.lua", []() {return new SimpleMoverSwitch();}}, {"scripts\\02_server\\Map\\firetemple\\L_SIMPLE_ONCE_ONLY_MOVER_SPINNER_EVENT.lua", []() {return new SimpleOnceOnlyMoverSpinnerEvent();}}, {"scripts\\02_server\\Enemy\\General\\L_ENEMY_NJ_BUFF_STUN_IMMUNITY.lua", []() {return new EnemyNjBuff();}}, diff --git a/tests/dGameTests/dComponentsTests/MovingPlatformComponentTests.cpp b/tests/dGameTests/dComponentsTests/MovingPlatformComponentTests.cpp index 61d37ae36..00b92846d 100644 --- a/tests/dGameTests/dComponentsTests/MovingPlatformComponentTests.cpp +++ b/tests/dGameTests/dComponentsTests/MovingPlatformComponentTests.cpp @@ -9,6 +9,7 @@ #include "CDClientDatabase.h" #include "Entity.h" #include "GameMessages.h" +#include "02_server/Map/njhub/FireTransRocks.h" #include "LDFFormat.h" #include "MovementMessages.h" #include "MovingPlatformComponent.h" @@ -643,3 +644,41 @@ TEST_F(MovingPlatformComponentTest, OnceOnlyMoverGoesOnItsSpinnersEvent) { EXPECT_EQ(mover->mState, 2u); EXPECT_EQ(mover->mCurrentWaypointIndex, 0u); } + +// L_FIRE_TRANS_ROCKS: a rock that arrived at its top (waypoint 1) holds there (the PlatHold timer) and is then sent +// back down to waypoint 0 +TEST_F(MovingPlatformComponentTest, FireTransRockGoesBackDownAfterItsHold) { + Path path; + path.pathType = PathType::MovingPlatform; + path.pathName = "TestRockPath"; + path.pathBehavior = PathBehavior::Once; + PathWaypoint first; + first.position = NiPoint3(0.0f, 0.0f, 0.0f); + first.speed = 5.0f; + PathWaypoint second = first; + second.position = NiPoint3(0.0f, 10.0f, 0.0f); + path.pathWaypoints = { first, second }; + const_cast(Game::zoneManager->GetZone())->AddPath(path); + + info.settings.Insert(u"startPathingOnLoad", false); + Entity entity(288300744895900119, info); + info.settings.values.clear(); + auto* const platform = entity.AddComponent(-1, "TestRockPath"); + auto* const mover = platform->GetMoverSubComponent(); + FireTransRocks script; + script.OnStartup(&entity); + EXPECT_EQ(entity.GetVarAsString(u"RockManagerGroup"), "FireTransRocksManager"); + + platform->GotoWaypoint(1); + platform->Run(0.11f); + platform->Run(2.1f); + EXPECT_EQ(mover->mState, 28u); + EXPECT_EQ(mover->mCurrentWaypointIndex, 1u); + + script.OnTimerDone(&entity, "PlatHold"); + EXPECT_EQ(mover->mDesiredWaypointIndex, 0); + platform->Run(0.11f); + platform->Run(2.1f); + EXPECT_EQ(mover->mState, 28u); + EXPECT_EQ(mover->mCurrentWaypointIndex, 0u); +}