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;
}
}