using System; using System.Numerics; namespace AcDream.Core.Physics; /// /// Per-tick steering for server-controlled remote creatures while a /// MoveToObject (movementType 6) or MoveToPosition (movementType 7) packet /// is the active locomotion source. /// /// /// Replaces the 882a07c-era "hold body Velocity at zero during MoveTo" /// stabilizer. With the full MoveTo path payload now captured on /// , /// the body solver has the destination + heading + thresholds it needs to /// run the retail per-tick loop instead of waiting for sparse /// UpdatePosition snap corrections. /// /// /// /// Retail references: /// /// /// MoveToManager::HandleMoveToPosition (0x00529d80) — the /// per-tick driver. Computes heading-to-target, fires an aux /// TurnLeft/TurnRight command when |delta| > 20°, snaps /// orientation when within tolerance, and tests arrival via /// dist <= min_distance (chase) or /// dist >= distance_to_object (flee). /// /// /// MoveToManager::_DoMotion / _StopMotion route turn /// commands through CMotionInterp::DoInterpretedMotion — i.e. /// MoveToManager itself does NOT touch the body. The body's actual /// velocity comes from CMotionInterp::apply_current_movement /// reading InterpretedState.ForwardCommand = RunForward and /// emitting velocity.Y = RunAnimSpeed × speedMod, transformed by /// the body's orientation. /// /// /// /// /// /// Acdream port scope: minimum viable subset. We skip target re-tracking /// (server re-emits MoveTo every ~1 s with refreshed Origin), sticky/ /// StickTo, fail-distance progress detector, and the sphere-cylinder /// distance variant — all server-side concerns the local body doesn't need /// to model. We DO port heading-to-target, the ±20° aux-turn tolerance /// (with ACE's set_heading(true) snap-on-aligned fudge), and /// arrival detection via min_distance. /// /// /// /// ACE divergence: ACE swaps the chase/flee arrival predicates /// (dist <= DistanceToObject vs retail's dist <= MinDistance). /// We follow retail. /// /// public static class RemoteMoveToDriver { /// /// Heading tolerance below which we snap orientation directly to the /// target heading (ACE's set_heading(target, true) /// server-tic-rate fudge). Above tolerance we rotate at /// . Retail value (line 307251 of /// acclient_2013_pseudo_c.txt) is 20°. /// public const float HeadingSnapToleranceRad = 20.0f * MathF.PI / 180.0f; /// /// Default angular rate for in-motion heading correction when delta /// exceeds . Picked to match /// ACE's TurnSpeed default of π/2 rad/s for monsters; /// when the per-creature value differs, the future port can wire it /// in via the TurnSpeed field on InterpretedMotionState. /// public const float TurnRateRadPerSec = MathF.PI / 2.0f; /// /// Float-comparison slack for the arrival predicate. With /// min_distance == 0 in a chase packet, exact equality is /// unreachable due to integration wobble; this epsilon prevents the /// driver from over-shooting by a sub-meter and snap-flipping back. /// public const float ArrivalEpsilon = 0.05f; /// /// Maximum staleness (seconds) of the most recent MoveTo packet /// before the driver gives up steering. ACE re-emits MoveTo at ~1 Hz /// during active chase; if no fresh packet arrives for this long, /// the entity has likely either left our streaming view, switched /// to a non-MoveTo motion the server's broadcast didn't reach us /// for, or had its move cancelled server-side without our seeing /// the cancel UM. In any of those cases, continuing to drive the /// body toward a stale destination produces the "monster runs in /// place after popping back into view" symptom (2026-04-28). /// 1.5 s gives us comfortable margin over the ~1 s emit cadence /// while still failing fast on real loss-of-state. /// public const double StaleDestinationSeconds = 1.5; public enum DriveResult { /// Within arrival window — caller should zero velocity. Arrived, /// Steering active — caller should let /// apply_current_movement set body velocity from the cycle. Steering, } /// /// Steer body orientation toward /// and report whether the body has arrived or should keep running. /// Pure function — emits the updated orientation via /// (the input is not mutated; the /// caller assigns the new value back to its body). /// /// /// min_distance from the wire's MovementParameters block — /// retail's HandleMoveToPosition chase-arrival threshold. /// /// /// distance_to_object from the wire — ACE's chase-arrival /// threshold (default 0.6 m, the melee range). The actual arrival /// gate is max(minDistance, distanceToObject): retail-faithful /// when retail sends min_distance > 0, ACE-compatible when /// ACE puts the value in distance_to_object with /// min_distance == 0. Without this, ACE's min_distance==0 /// chase packets never arrive — the body keeps re-targeting around /// the player at melee range and visibly oscillates between facings, /// which is the user-reported "monster keeps running in different /// directions when it should be attacking" symptom (2026-04-28). /// public static DriveResult Drive( Vector3 bodyPosition, Quaternion bodyOrientation, Vector3 destinationWorld, float minDistance, float distanceToObject, float dt, bool moveTowards, out Quaternion newOrientation) { // Horizontal distance only — server owns Z, our body Z is // hard-snapped to the latest UpdatePosition. float dx = destinationWorld.X - bodyPosition.X; float dy = destinationWorld.Y - bodyPosition.Y; float dist = MathF.Sqrt(dx * dx + dy * dy); // Arrival predicate per retail MoveToManager::HandleMoveToPosition // (acclient_2013_pseudo_c.txt:307289-307320) and ACE // MoveToManager.cs:476: // // chase (MoveTowards): dist <= distance_to_object // flee (MoveAway): dist >= min_distance // // (My earlier max(MinDistance, DistanceToObject) was a // defensive guess; cross-checked with two independent research // agents against the named retail decomp + ACE port + holtburger, // the chase threshold is unambiguously DistanceToObject — // MinDistance is the FLEE arrival threshold. ACE's wire defaults // give MinDistance=0, DistanceToObject=0.6 — the body should stop // at melee range, not run to zero.) float arrivalThreshold = moveTowards ? distanceToObject : minDistance; if (moveTowards && dist <= arrivalThreshold + ArrivalEpsilon) { newOrientation = bodyOrientation; return DriveResult.Arrived; } if (!moveTowards && dist >= arrivalThreshold - ArrivalEpsilon) { newOrientation = bodyOrientation; return DriveResult.Arrived; } // Degenerate — already on target horizontally; preserve heading. if (dist < 1e-4f) { newOrientation = bodyOrientation; return DriveResult.Steering; } // Body's local-forward is +Y (see MotionInterpreter.get_state_velocity // at line 605-616: velocity.Y = (Walk/Run)AnimSpeed × ForwardSpeed). // World forward = Transform((0,1,0), orientation). Yaw extracted // via atan2(-worldFwd.X, worldFwd.Y) so yaw = 0 ↔ orientation = Identity. var localForward = new Vector3(0f, 1f, 0f); var worldForward = Vector3.Transform(localForward, bodyOrientation); float currentYaw = MathF.Atan2(-worldForward.X, worldForward.Y); // Desired heading: face the target. (dx, dy) is the world-space // offset to the target. With local-forward=+Y we want yaw such // that Transform((0,1,0), R_Z(yaw)) = (dx, dy)/dist; that solves // to yaw = atan2(-dx, dy). float desiredYaw = MathF.Atan2(-dx, dy); float delta = WrapPi(desiredYaw - currentYaw); if (MathF.Abs(delta) <= HeadingSnapToleranceRad) { // ACE's set_heading(target, true) — sync to server-tic-rate. // We have the same sparse-UP problem ACE does, so the same // fudge applies. newOrientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, desiredYaw); } else { // Retail BeginTurnToHeading / HandleMoveToPosition aux turn: // rotate at TurnRate clamped to dt, in the shorter direction. float maxStep = TurnRateRadPerSec * dt; float step = MathF.Sign(delta) * MathF.Min(MathF.Abs(delta), maxStep); // Apply incremental yaw around world +Z (preserving any // server-supplied pitch/roll from the latest UpdatePosition). var deltaQuat = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, step); newOrientation = Quaternion.Normalize(deltaQuat * bodyOrientation); } return DriveResult.Steering; } /// /// Convert a landblock-local Origin from a MoveTo packet /// () /// into acdream's render world space using the same arithmetic as /// OnLivePositionUpdated: shift by the landblock-grid offset /// from the live-mode center. /// public static Vector3 OriginToWorld( uint originCellId, float originX, float originY, float originZ, int liveCenterLandblockX, int liveCenterLandblockY) { int lbX = (int)((originCellId >> 24) & 0xFFu); int lbY = (int)((originCellId >> 16) & 0xFFu); return new Vector3( originX + (lbX - liveCenterLandblockX) * 192f, originY + (lbY - liveCenterLandblockY) * 192f, originZ); } /// /// Cap horizontal velocity so the body lands exactly at /// rather than overshooting past /// it during the final tick of approach. Without this clamp, a body /// running at RunAnimSpeed × speedMod ≈ 4 m/s can overshoot /// the 0.6 m arrival window by up to one tick's advance (~6 cm at /// 60 fps) — visible as the creature "running slightly through" the /// player it's about to attack (user-reported 2026-04-28). /// /// /// The clamp is a strict scale-down of the horizontal component /// (X/Y); the vertical component (Z) is left to gravity / terrain /// handling. false (flee branch) is a /// no-op since fleeing has no overshoot risk — the body wants to /// move AWAY from the destination. /// /// public static Vector3 ClampApproachVelocity( Vector3 bodyPosition, Vector3 currentVelocity, Vector3 destinationWorld, float arrivalThreshold, float dt, bool moveTowards) { if (!moveTowards || dt <= 0f) return currentVelocity; float dx = destinationWorld.X - bodyPosition.X; float dy = destinationWorld.Y - bodyPosition.Y; float dist = MathF.Sqrt(dx * dx + dy * dy); float remaining = MathF.Max(0f, dist - arrivalThreshold); float vxy = MathF.Sqrt(currentVelocity.X * currentVelocity.X + currentVelocity.Y * currentVelocity.Y); if (vxy < 1e-3f) return currentVelocity; float advance = vxy * dt; if (advance <= remaining) return currentVelocity; // Already inside or right at the threshold: zero horizontal // velocity, keep Z. (The arrival predicate in Drive() should // have fired this tick, but this is the belt-and-braces guard.) if (remaining < 1e-3f) return new Vector3(0f, 0f, currentVelocity.Z); float scale = remaining / advance; return new Vector3( currentVelocity.X * scale, currentVelocity.Y * scale, currentVelocity.Z); } /// Wrap an angle in radians to [-π, π]. private static float WrapPi(float r) { const float TwoPi = MathF.PI * 2f; r %= TwoPi; if (r > MathF.PI) r -= TwoPi; if (r < -MathF.PI) r += TwoPi; return r; } }