using System.Collections.Generic; using UnityEngine; /// /// Owns all leader-following concerns: collision passthrough near allies, companion-list /// management, leader-cache expiry, and teleport-to-leader decisions. /// /// Key improvements over EntityAliveSDX: /// - Proximity check is throttled (ProximityCheckInterval) instead of running every frame. /// - Entity query uses a pre-allocated buffer list — no per-tick heap allocation. /// - Entity query filters to EntityPlayer at the World layer, skipping the manual cast loop. /// - Companion IndexOf is called once; Count-1 is used after Add instead of a second IndexOf. /// - Leader-cache expiry countdown lives here instead of as a raw int on the entity. /// - LargeEntityBlocker transform is found once and cached. /// public class NPCLeaderComponent : INPCComponent { // ----------------------------------------------------------------------- // Configuration // ----------------------------------------------------------------------- /// Seconds between collision/proximity recalculations. private const float ProximityCheckInterval = 0.5f; /// Ticks between forced leader-cache invalidations. private const int LeaderCacheExpiry = 30; /// Distance (units) at which a following NPC is teleported to the leader. private const float TeleportThreshold = 60f; private const float TeleportThresholdSq = TeleportThreshold * TeleportThreshold; /// Distance (units) at which a vehicle-attached NPC is snapped to the leader. private const float VehicleSnapThreshold = 10f; private const float VehicleSnapThresholdSq = VehicleSnapThreshold * VehicleSnapThreshold; // ----------------------------------------------------------------------- // State // ----------------------------------------------------------------------- private EntityAliveSDXV4 _entity; private int _leaderCacheCountdown = LeaderCacheExpiry; private float _lastProximityCheckTime = -999f; private Transform _largeEntityBlocker; /// Pre-allocated list reused on every proximity query — never re-allocated. private readonly List _proximityBuffer = new List(); // ----------------------------------------------------------------------- // INPCComponent // ----------------------------------------------------------------------- public void Initialize(EntityAliveSDXV4 entity) => _entity = entity; public void OnUpdateLive(ref NPCFrameCache cache) { if (_entity.IsDead()) return; if (_entity.Buffs.HasBuff("buffOrderDismiss")) return; if (!cache.HasLeader) { _entity.Owner = null; _entity.bWillRespawn = false; return; } // Throttled: only re-evaluate ally collision every ProximityCheckInterval seconds. if (Time.time - _lastProximityCheckTime >= ProximityCheckInterval) { _lastProximityCheckTime = Time.time; CheckLeaderProximity(); } // Set Owner reference and nav object on first contact. if (_entity.Owner == null) { _entity.Owner = cache.Leader; if (cache.IsLeaderLocalPlayer) _entity.HandleNavObject(); } // Periodically force-clear the leader cache to pick up ownership changes. if (--_leaderCacheCountdown < 0) { _leaderCacheCountdown = LeaderCacheExpiry; SphereCache.LeaderCache.Remove(_entity.entityId); } // Keep the "hired_" cvar on the leader so the party system tracks us. if (cache.LeaderAsPlayer != null) cache.Leader.Buffs.SetCustomVar($"hired_{_entity.entityId}", (float)_entity.entityId); switch (EntityUtilities.GetCurrentOrder(_entity.entityId)) { case EntityUtilities.Orders.Loot: case EntityUtilities.Orders.Follow: HandleFollowOrder(ref cache); break; default: _entity.bWillRespawn = false; cache.LeaderAsPlayer?.Companions.Remove(_entity); break; } } public void OnDead() { } public void OnUnload() { } // ----------------------------------------------------------------------- // Proximity / collision // ----------------------------------------------------------------------- /// /// Disables collision with the NPC when an allied player is within 2 blocks. /// Uses a pre-allocated buffer and a typed world query to avoid allocations. /// private void CheckLeaderProximity() { _proximityBuffer.Clear(); GameManager.Instance.World.GetEntitiesInBounds( typeof(EntityPlayer), new Bounds(_entity.position, Vector3.one * 2f), _proximityBuffer); foreach (var e in _proximityBuffer) { if (e is EntityPlayer player && EntityUtilities.IsAnAlly(_entity.entityId, player.entityId)) { SetCollisions(false); return; } } SetCollisions(true); } private void SetCollisions(bool enabled) { // Cache the blocker transform on first use. if (_largeEntityBlocker == null) _largeEntityBlocker = GameUtils.FindTagInChilds(_entity.RootTransform, "LargeEntityBlocker"); if (_largeEntityBlocker != null) _largeEntityBlocker.gameObject.SetActive(enabled); _entity.PhysicsTransform.gameObject.SetActive(enabled); _entity.IsNoCollisionMode.Value = !enabled; } // ----------------------------------------------------------------------- // Follow / teleport // ----------------------------------------------------------------------- private void HandleFollowOrder(ref NPCFrameCache cache) { if (cache.Leader.AttachedToEntity != null) { // Leader is in a vehicle — hide the NPC and keep it near the vehicle. _entity.SendOnMission(true); if (cache.DistanceSqToLeader > VehicleSnapThresholdSq) { var snapPos = cache.Leader.GetPosition(); snapPos.y += 2f; _entity.SetPosition(snapPos); } } else { if (_entity.Buffs.HasCustomVar("onMission")) _entity.SendOnMission(false); } _entity.bWillRespawn = true; if (cache.DistanceSqToLeader > TeleportThresholdSq) _entity.TeleportToPlayer(cache.Leader); // Companion-list management. Single IndexOf call; uses Count-1 after Add. if (cache.LeaderAsPlayer != null && _entity.AddNPCToCompanion && _entity.IsAlive()) { var companions = cache.LeaderAsPlayer.Companions; var idx = companions.IndexOf(_entity); if (idx < 0) { companions.Add(_entity); idx = companions.Count - 1; var color = Constants.TrackedFriendColors[idx % Constants.TrackedFriendColors.Length]; if (_entity.NavObject != null) { _entity.NavObject.UseOverrideColor = true; _entity.NavObject.OverrideColor = color; } } } } }