using System;
using System.Numerics;
namespace AcDream.Core.Physics.Motion;
///
/// R5 — verbatim port of retail's StickyManager (acclient.h:31518,
/// struct #3466; decomp block 0x00555400-0x00555866,
/// r5-positionmanager-sticky-decomp.md). Makes the owning object
/// FOLLOW a target object at a bounded gap: each tick
/// steers the mover horizontally toward the
/// target's live (or last-known) position and turns it to face the target,
/// speed- and turn-rate-limited. A 1-second watchdog ()
/// drops the stick if no target-position update arrives.
///
/// Owned by (lazily created on first
/// ). Establishes its target-tracking
/// subscription through the owning 's
/// set_target (→ ); receives the target's
/// live position back through , fanned out from
/// CPhysicsObj::HandleUpdateTarget → .
///
/// The dense x87 back half of retail's adjust_offset is decoded
/// against ACE's StickyManager.cs (the two constants
/// =0.3 and =1.0 are ACE's,
/// verified against the retail mush structure — see the port-plan §2a).
///
public sealed class StickyManager
{
/// Retail StickyRadius const (ACE: 0.3f) — the desired
/// follow gap subtracted from the cylinder distance.
public const float StickyRadius = 0.3f;
/// Retail StickyTime const (ACE: 1.0f) — the one-shot grace
/// window: if no target update refreshes the stick within this many
/// seconds of , drops it.
public const float StickyTime = 1.0f;
/// Retail get_max_speed multiplier for the follow speed
/// (ACE: ×5 — the follower catches up faster than a normal walk/run).
private const float FollowSpeedFactor = 5.0f;
/// Retail fallback follow speed when no motion interpreter exists
/// (ACE: 15.0f, i.e. the MAX_VELOCITY constant the mush loads).
private const float FallbackFollowSpeed = 15.0f;
private readonly IPhysicsObjHost _host;
/// +0x00 retail target_id — the object we are stuck to
/// (0 = not stuck).
public uint TargetId { get; private set; }
/// +0x04 retail target_radius — the target's cylinder
/// radius (from CPartArray::GetRadius of the stuck-to object).
public float TargetRadius { get; private set; }
/// +0x08 retail target_position — last-known target
/// position (from ), used when the live
/// GetObjectA resolve fails.
public Position TargetPosition { get; private set; }
/// +0x54 retail initialized — false until the first
/// Ok target update arrives (gates and
/// the timeout).
public bool Initialized { get; private set; }
/// +0x58 retail sticky_timeout_time — the wall-clock
/// deadline set once at time (now + 1 s).
public double StickyTimeoutTime { get; private set; }
public StickyManager(IPhysicsObjHost host)
=> _host = host ?? throw new ArgumentNullException(nameof(host));
///
/// Retail StickyManager::UnStick (0x00555400). No-op if not stuck;
/// otherwise the standard 4-step teardown: clear +
/// , tell the host to clear_target (drop
/// the voyeur subscription), then interrupt_current_movement.
///
public void UnStick()
{
if (TargetId == 0)
return;
TargetId = 0;
Initialized = false;
_host.ClearTarget();
_host.InterruptCurrentMovement();
}
///
/// Retail StickyManager::StickTo (0x00555710). Begin following
/// . If already stuck, tears the old stick down
/// first (same 4-step sequence as ). Sets the 1 s
/// timeout deadline and registers as a voyeur of the target via the host's
/// set_target(context=0, objectId, radius=0.5, quantum=0.5) — the
/// live target position then arrives asynchronously through
/// .
///
/// Retail arg2 — target object id.
/// Retail arg3 — the target's cylinder
/// radius (feeds 's distance math).
/// Retail arg4 — accepted for call-shape
/// parity but UNUSED in the body (matches retail + ACE; the height feeds
/// the caller-side geometry only).
public void StickTo(uint objectId, float targetRadius, float targetHeight)
{
_ = targetHeight; // retail/ACE: arg4 is read nowhere in this body.
if (TargetId != 0)
{
// Inlined 4-step teardown of the previous stick (retail 0x00555716).
TargetId = 0;
Initialized = false;
_host.ClearTarget();
_host.InterruptCurrentMovement();
}
TargetRadius = targetRadius;
TargetId = objectId;
Initialized = false;
StickyTimeoutTime = _host.CurTime + StickyTime;
// set_target(context_id=0, objectId, radius=0.5, quantum=0.5).
_host.SetTarget(0, objectId, 0.5f, 0.5);
}
///
/// Retail StickyManager::UseTime (0x00555610). The 1 s watchdog: if
/// Timer::cur_time >= sticky_timeout_time, force-unstick (same
/// 4-step teardown). The deadline is set once in and
/// NOT refreshed by (retail + ACE) — a
/// stick survives at most 1 s of wall-clock unless re-issued.
///
public void UseTime()
{
if (TargetId == 0)
return;
// Strictly AFTER the deadline (retail 0x00555626 `test ah,0x41` —
// C0|C3 clear = cur_time > timeout; ACE `>` too), not >=.
if (_host.CurTime > StickyTimeoutTime)
{
TargetId = 0;
Initialized = false;
_host.ClearTarget();
_host.InterruptCurrentMovement();
}
}
///
/// Retail StickyManager::HandleUpdateTarget (0x00555780). The
/// target-position callback (fanned out from
/// CPhysicsObj::HandleUpdateTarget → ).
/// Ignores updates whose doesn't match
/// our . On : cache the
/// target position and mark . On any other status
/// (lost/exit/teleport): tear the stick down (4-step).
///
public void HandleUpdateTarget(TargetInfo info)
{
if (info.ObjectId != TargetId)
return;
if (info.Status == TargetStatus.Ok)
{
Initialized = true;
TargetPosition = info.TargetPosition;
return;
}
if (TargetId != 0)
{
TargetId = 0;
Initialized = false;
_host.ClearTarget();
_host.InterruptCurrentMovement();
}
}
///
/// Retail StickyManager::adjust_offset (0x00555430). Writes this
/// tick's follow steering into the shared
/// accumulator: a speed-clamped horizontal position delta toward the
/// target plus a bounded turn to face it. No-op unless stuck AND
/// initialized. See port-plan §2a for the x87-mush decode.
///
/// The per-tick delta frame
/// ('s shared accumulator).
/// Elapsed time this tick, seconds.
public void AdjustOffset(MotionDeltaFrame offset, double quantum)
{
if (TargetId == 0 || !Initialized)
return;
var self = _host.Position;
var target = _host.GetObjectA(TargetId);
var targetPos = target != null ? target.Position : TargetPosition;
// offset = local-frame, Z-flattened vector from self to target.
Vector3 worldOffset = targetPos.Frame.Origin - self.Frame.Origin; // Position::get_offset
Vector3 local = MoveToMath.GlobalToLocalVec(self.Frame.Orientation, worldOffset);
local.Z = 0f;
offset.Origin = local;
// Signed horizontal cylinder distance past the 0.3 m stick gap.
float dist = MoveToMath.CylinderDistanceNoZ(
_host.Radius, self.Frame.Origin, TargetRadius, targetPos.Frame.Origin) - StickyRadius;
if (MoveToMath.NormalizeCheckSmall(ref offset.Origin))
offset.Origin = Vector3.Zero;
// Follow speed = 5× own max locomotion speed (catch up), fallback 15.
float speed = 0f;
float? maxSpeed = _host.MinterpMaxSpeed;
if (maxSpeed.HasValue)
speed = maxSpeed.Value * FollowSpeedFactor;
if (speed < MoveToMath.Epsilon)
speed = FallbackFollowSpeed;
// Don't overshoot: clamp the per-tick step to the remaining (signed)
// distance — a negative dist inverts the direction (back off).
float delta = speed * (float)quantum;
if (delta >= MathF.Abs(dist))
delta = dist;
offset.Origin *= delta;
// Bounded turn to face the target (relative heading this tick).
float curHeading = MoveToMath.GetHeading(self.Frame.Orientation);
float targetHeading = MoveToMath.PositionHeading(self.Frame.Origin, targetPos.Frame.Origin);
float heading = targetHeading - curHeading;
if (MathF.Abs(heading) < MoveToMath.Epsilon)
heading = 0f;
if (heading < -MoveToMath.Epsilon)
heading += 360f;
offset.SetHeading(heading);
}
}