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