How can I set up an auto-aim feature?

I’ve realized that I have a notable problem with my game. The player is controlled in a 2D field, and as such only fires projectiles directly forward. But I’ve been planning on some (early and weak) enemies that very short, and that would mean the player may fire projectiles over their heads without hitting them.
I can extend the collision height of the enemies or the projectiles, but this just looks weird when the projectiles effectively collide into invisible space above the enemies.

So what I need to do is develop an auto-aim system so that the player’s projectiles will aim down (or up) at targets that are higher and lower that the player.

I’m looking for advice on how to implement this. I can easily change the aim of a projectile, but how can I efficiently determine if I should be changing the aim?

I thought at first that I could do a simple raycast to look for enemies, but this wouldn’t work. If the player is facing a slightly different angle the raycast would miss hitting an enemy. I could try a larger capsule cast, but this would be more taxing on the system, and if I make it wide enough to hit slightly-missed distant targets then it could miss seeing targets literally in front of the player (since it ignores things that collide with the initial position of the capsule.)
I thought about affixing a collision hull to the player, which I could shape into a conical shape, but then I would need to track every critter that touches the hull and then try to sort which critter is closest to determine what angle I should be firing at, and that would quickly become taxing when I’m in a rapidfire mode.

And if you create some kind of Transform TargetPoint field in your enemies. Then in your player a Transform currentTarget to know who you need to aim and a box collider in the direction that you’re looking to cache enemies in OnEnter and remove them in OnExit.

I don’t know when your change your target, if your aim automatically at near target create a small box and calculate often. If you chance target when user press some button you can have a bix box and calculate only when necessary

Edit: With a custom layer to indicate enemies head/target you don’t need to fill the monster target field and everything that enter in you “vision” will be a target. Only make sure to configure interaction between layers in your physics and I think this can work

My player won’t be changing targets per se; the player input will be setting the angle that the player pawn is facing. So if a player is turning rapidly then that collision field would travel through a lot of different enemies each frame.
I’m planning on having large swarms of enemies, so I’d like a system that doesn’t get more taxing with more enemies.

Last night I just had an idea; I can attach an object to my short enemies that just has a large invisible collider above them, set to a new layer that won’t collide with anything. My player will use a raycast directly forward that will only detect these colliders and standard enemy collision. If it hits something, and that something is one of these special colliders, it will run a script to get that enemies height and then adjust the firing angle. I could also run a script on these invisible colliders that could check their distance to the player and make the collider larger or smaller depending on that distance.

This sounds like it would at least be functional, although it wouldn’t also enable me to adjust heights for enemies above/below the player. (Oh well, guess I won’t have slopes.)
But it still seems inefficient, and if I have a large swarm of enemies then I’ll have a lot of extra scripts checking distance to the player and resizing their colliders.

I’d still like to find a better way.

In my opinion, you are being kind of contradictory here

The only way to this be true is if you do not have any logic in your “chose target behavior”, something like getting the first monsters that your system detect, and if this is the case, you can simply do a collider cast to the point that you’re looking and get the first detected monster, then targeting to right point/head. This will be one collider cast, one GetHeaderPosition() call, and one LookToThisPoint() call, independently of your swarm size.

If you aren’t going to let the player change target, you don’t need to exhaustively get enemies, do it only in the necessary frame.

Maybe I’m just not understanding something here but I don’t get why you would recalculate enemy collider size to be able to be recognized by a raycast instead of just using some bigger detector, this sounds like extra work, and probably will give you some hard time debugging. But again, I’m probably misunderstanding something here, anyway let’s see other’s approaches, good luck.

N

I’m no expert at coding but logically, cant you assign a variable on enemy ‘AimPoint’, missile can reference enemy aim’point and seek?

Don’t worry about the performance impact. It won’t affect you. And if it does, use the profiler to find out what is going wrong so that you can fix it. Speaking from experience on this exact problem I can say that you’d need tens of thousands of enemies literally stacked right on top of each other before it would even show up in the profiler.

Yes, exactly. This is why I’ve been saying I don’t want to check a list of every enemy; I’m looking for a simpler method. I’ve been leaning more towards raycasts and the like to find targets.

Perhaps a better way to look at it is this: When the player fires an attack, I need to see if there is an enemy the attack might hit, or rather, if there is an enemy that I might hit if I aim up or down.
I’m not trying to have a “true” auto-aim that will directly target an enemy, but just one that auto-aims up and down. The player is still going to control where the face for left-and-right, and this system just needs to adjust aiming up and down.

For some examples, think of classic Doom, where you don’t aim up or down. But that game mostly uses instant hitscans, whereas mine would need to be a little more generous since I have visible projectiles. Another example game is Gauntlet Dark Legacy; in fact that’s much closer to what I’m doing since it’s a top-down view. If an enemy is higher or lower than the player, it automatically fires up or down, since the player can’t aim that way.

Because raycasts (and capsule casts and box casts and etc) cannot detect any colliders that overlap with the initial point where the cast starts. So if the aim-collider was too big and the enemy too close, that starting point could be inside the aim-collider, and it wouldn’t be detected.
But on the other hand, if that aim-collider was too small and the enemy was far enough away, the raycast would miss it if the player wasn’t facing exactly the right angle. Also it would be impossible for the player to lead their shots.
So I’d want those aim-colliders to be bigger when further away, basically to compensate for the fact that I can’t make some kind of cone-shape when ray casting.

Hmm, make the projectile aim itself and seek after targets? I hadn’t actually thought to do that. It certaintly could work for ones that are further away, but would have to turn stupidly sharp to hit an ankle-biter right in front of the player.

I appreciate this advice, I really do.
But now I’m curious, what kind of method were you using before you needed tens of thousands of enemies before you had an impact?

I was using a spherecastboxcast. But before that I was actually using an invisible mesh collider that was shaped like a tall and narrow wedge. Similar I think to what you had considered. I ended up switching to a spherecastboxcast because it was easier to configure and was one less thing taking up space in the editor screen. it was also simpler to code. But if you really want a steep but narrow detection field it’s probably better to go with the mesh.

I don’t have a ton of time to explain so I’ll just give you a basic idea and post the code (including all of the stuff that will have zero relevance to any project but my own lol). Also, bare with me. Been about three years since I did any of this.

So each entity, player and NPCs alike, consists of a master gameobject and several child objects. One such object represents the weapon they are holding. Normally it faces straight forward relative to the parent. As the player moves or aims a weapon the parent object is rotated accordingly and the weapon fires in that direction. But when the aim assist feature detects a lock it begins to drift the weapon in a direction that differs from the parent. Every NPC in the game has a rather large hitbox that this spherecastboxcast checks against to see where to aim. It decides the final aim vector as a combination of the weapon position, the detected hitbox position, and then a couple configurable offsets that are stored on the aim-assist component itself (though if you want to get better results it would be best to store the target offsets on the enemy and somehow gather that data during the detection phase). There is also a sprite that represents the targeting reticule that smoothly moves to the target detected and then snaps back to a fixed position relative to it’s parent weapon. Probably not useful to you but I figured I’d mention it so you know what that’s all about. In my case, the aim-assist is off by default and only activates when the player is actually firing the weapon so as soon as they stop shooting, I reset everything, the weapon snaps back to the forward direction relative to it’s parent, and the target reticule is returned to it’s default position. You can see an example of it here:

And here’s the relevant (sorta) code:

using Toolbox;
using Toolbox.Behaviours;
using Toolbox.Graphics;
using Toolbox.Math;
using UnityEngine;

namespace WoP
{
    /// <summary>
    /// Attach this to anything that has a component that implements
    /// IWeapon and it will gain an aim assist ability.
    ///
    /// NOTE: Don't forget to place this is at an earlier execution time than all other movement for your entity.
    /// </summary>
    [DisallowMultipleComponent]
    public class WeaponAimAssist : LocalListenerMonoBehaviour
    {
        [Tooltip("The transform that will act as the default 'compass' when aim assist is not applied. It cannot be the same transform that we are changing.")]
        public Transform AimTrans;
        [Header("Aim Assist Options")]
        public bool AimAssist = true;
        public bool RequireLos = true;
        public float AimSpeed = 1000;
        [Tooltip("The max range at which aim-assist will target.")]
        public float Range = 10;
        [Tooltip("The half-width and half-height of the raycast to perform.")]
        public Vector2 HalfSize;
        [Tooltip("Offset applied to the weapon in the LoS check for aim assist.")]
        public Vector3 AssistWeaponOffset = Vector3.up;
        [Tooltip("Offset applied to the target in the LoS check for aim assist.")]
        public Vector3 AssistTargetOffset = Vector3.up;
        [Tooltip("The maximum angle of LoS allowed to consider aim-assist valid.")]
        public float AssistAngle = 66f;
        public LayerMask AssistLayers;
        public LayerMask LosBlockers;
        public SpriteRenderer ReticuleSprite;
        public float RotateSpeed = 100;
        public float BounceSpeed = 1;
        public float BounceScale = 0;

        InterpolateChild Inter;
        BillboardAdvanced RetFreeze;
        Transform MyTrans;
        Transform ReticuleTrans;
        Vector3 OriginalRetOffset;

        Vector3 DefaultAngleOffset;
        Bounds LastTargetBounds;
        public float ResetSpeedX = 50, ResetSpeedY = 50, ResetSpeedZ = 50;
        public float AimSpeedX = 25, AimSpeedY = 10, AimSpeedZ = 10;
        Vector3 Last;

        /// <summary>
        /// Returns true if the aim-assist is currently adjusting the agent's aim.
        /// </summary>
        public bool IsAiming { get; private set; }

        Transform _LastTarget;
        float LastTargetTime;
        /// <summary>
        ///
        /// </summary>
        public Transform LastTarget
        {
            get
            {
                if (Time.time - LastTargetTime > 0.5f || _LastTarget == null || !_LastTarget.gameObject.activeInHierarchy)
                    _LastTarget = null;
                return _LastTarget;
            }
            set
            {
                _LastTarget = value;
                LastTargetTime = Time.time;
            }
        }

        void Awake()
        {
            MyTrans = transform;
            ReticuleSprite.gameObject.SetActive(false);
            ReticuleTrans = ReticuleSprite.transform;
            OriginalRetOffset = ReticuleTrans.localPosition;
            RetFreeze = ReticuleTrans.GetComponent<BillboardAdvanced>();
            Inter = ReticuleTrans.GetComponent<InterpolateChild>();
        }

        protected override void OnDestroy()
        {
            base.OnDestroy();
        }


        /// <summary>
        ///
        /// </summary>
        /// <param name="agentForward"></param>
        /// <param name="agentPos"></param>
        /// <returns></returns>
        public Vector3 ForwardToLastTarget(Vector3 agentForward, Vector3 agentPos)
        {
            if (LastTarget == null) return agentForward;
            else
            {
                var targetCenter = LastTargetBounds.center + AssistTargetOffset;
                return targetCenter - (agentPos + AssistWeaponOffset);
            }
        }

        /// <summary>
        /// Helper for getting the final rotation that must be applied to the agent in order to perform the aim-assist.
        /// </summary>
        /// <param name="agentOriginalForward"></param>
        /// <param name="agentPos"></param>
        /// <param name="targetPos"></param>
        /// <returns></returns>
        public Quaternion RotateToLastTarget(Vector3 agentOriginalForward, Vector3 agentPos)
        {
            //Vector3 forwardToTarget = targetPos - agentPos;
            Vector3 forwardToTarget = ForwardToLastTarget(agentOriginalForward, agentPos);
            var assistV = Vector3.RotateTowards(agentOriginalForward, forwardToTarget.normalized, float.MaxValue, float.MaxValue);
#if UNITY_EDITOR
            Debug.DrawRay(agentPos + AssistWeaponOffset, assistV * 10, new Color(1f, 0.5f, 0.75f, 1f), 2);
#endif
            return Quaternion.LookRotation(assistV);
        }

        /// <summary>
        ///
        /// </summary>
        void Update()
        {
            ApplyAssist();
        }
     
        public void ApplyAssist()
        {
            //always be sure to reset orientation before adjusting
            MyTrans.rotation = Quaternion.LookRotation(AimTrans.forward, Vector3.up);

            var hit = Toolbox.SharedArrayFactory.Hit1;
            var forward = MyTrans.forward;
            var myPos = MyTrans.position + AssistWeaponOffset;

            if(!IsAiming)
                DefaultAngleOffset = RetFreeze.AngleOffset;

            Transform CurrentTarget = null;
            LastTargetBounds = new Bounds();
            if(Physics.BoxCastNonAlloc(myPos, new Vector3(HalfSize.x, HalfSize.y, .1f), forward, hit, MyTrans.rotation, Range, AssistLayers, QueryTriggerInteraction.Collide) > 0)
            {
                CurrentTarget = hit[0].transform;
                LastTarget = CurrentTarget;
                LastTargetBounds = hit[0].collider.bounds;
            }
         
            if (CurrentTarget != null)
            {
                if (!CurrentTarget.gameObject.activeInHierarchy)
                    CurrentTarget = null;
            }

         

            if (CurrentTarget != null)
            {
                LockToTarget(LastTargetBounds, myPos, forward);
            }
            else ResetAim();
        }
     
        /// <summary>
        ///
        /// </summary>
        /// <param name="targetBounds"></param>
        /// <param name="agentPos"></param>
        /// <param name="agentForward"></param>
        void LockToTarget(Bounds targetBounds, Vector3 agentPos, Vector3 agentForward)
        {
            var targetCenter = targetBounds.center + AssistTargetOffset;
            var forwardToTarget = (targetCenter - agentPos);
            var distToEnemy = forwardToTarget.magnitude;
            float distToWall = float.MaxValue;
            forwardToTarget.Normalize();

#if UNITY_EDITOR
            Debug.DrawLine(agentPos, targetCenter, Color.green);
#endif
            float viewAngle = Toolbox.Math.MathUtils.AngleOnY(agentPos, agentForward, targetCenter);

            //perform a quick raycast to ensure we have los before making final adjustment
            var hit = Toolbox.SharedArrayFactory.Hit1;
            int wallCount = Physics.RaycastNonAlloc(agentPos, forwardToTarget, hit, Range, LosBlockers, QueryTriggerInteraction.Collide);
            if (wallCount > 0)
                distToWall = (hit[0].point - agentPos).magnitude;
            if (viewAngle <= AssistAngle && (!RequireLos || distToEnemy < distToWall))
            {
                IsAiming = true;
                var assistV = Vector3.RotateTowards(agentForward, forwardToTarget, float.MaxValue, float.MaxValue);
                MyTrans.rotation = Quaternion.LookRotation(assistV);
                ReticuleSprite.gameObject.SetActive(true);
                if (Inter != null) Inter.enabled = false;
                ReticuleTrans.position = SetRetPos(targetCenter, AimTrans.forward, Vector3.zero, AimSpeedX, AimSpeedY, AimSpeedZ);
                RetFreeze.AngleOffset = new Vector3(RetFreeze.AngleOffset.x, RetFreeze.AngleOffset.y, RetFreeze.AngleOffset.z + (RotateSpeed * Time.deltaTime));
            }
            else
            {
#if UNITY_EDITOR
                Debug.DrawLine(agentPos, hit[0].point, Color.red);
#endif
                ResetAim();
            }
        }

        /// <summary>
        /// Resets the orignal aim of the agent from before the aim-assist adjustment.
        /// </summary>
        public void ResetAim()
        {
            IsAiming = false;
            MyTrans.forward = AimTrans.forward;
            ReticuleTrans.position = SetRetPos(AimTrans.position, MyTrans.forward, OriginalRetOffset, ResetSpeedX, ResetSpeedY, ResetSpeedZ);

            RetFreeze.AngleOffset = DefaultAngleOffset;
            ReticuleSprite.gameObject.SetActive(false);
        }

        public Vector3 SetRetPos(Vector3 parentPos, Vector3 parentForward, Vector3 offset, float xSpeed, float ySpeed, float zSpeed)
        {
            var pos = MathUtils.ForwardSpaceOffset(parentPos, parentForward, offset);
            float x = MathUtils.SmoothApproach(Last.x, pos.x, pos.x, xSpeed);
            float y = MathUtils.SmoothApproach(Last.y, pos.y, pos.y, ySpeed);
            float z = MathUtils.SmoothApproach(Last.z, pos.z, pos.z, zSpeed);
            Last = new Vector3(x, y, z);
            return Last;
        }

        public Vector3 SetRetPos(Vector3 target, float xSpeed, float ySpeed, float zSpeed)
        {
            var pos = target;
            float x = MathUtils.SmoothApproach(Last.x, pos.x, pos.x, xSpeed);
            float y = MathUtils.SmoothApproach(Last.y, pos.y, pos.y, ySpeed);
            float z = MathUtils.SmoothApproach(Last.z, pos.z, pos.z, zSpeed);
            Last = new Vector3(x, y, z);
            return Last;
        }

    }
}

Hopefully that all helps a bit. Good luck!

That does help, thank you.

Also last night I had another idea of how I could do this, and I might as well share it for anyone reading this thread looking for ideas.
I’m thinking of using boxcast, and casting a shape directly in front of the player that’s tall and skinny. This would find any enemies that are directly in front of the player. And then if nothing is found, perform another box cast with a much wider range (and also deeper so it effectively starts farther ahead) that will look for enemies a little off to the side that the player might be trying to lead their shots toward.

That would be one to two casts per shot, and it would prioritize enemies the player is directly aiming at over ones that it might be missing. Plus, it would also work with all enemies, not just short ones, so I could look into things like flying enemies and unlevel areas.

That could work if you have specific ranges where you want discrete differences in how the detection occurs. In the example I posted you’ll see that I actually do a simple angle check. Basically, I made the boxcast bigger than needed and then used an angle check to ensure that the detected collision was within an acceptable angle relative to the true facing direction of the parent (i.e., the actual aiming angle the player is inputting before the aim-assist is applied to the weapon).

This angle check also helps when targets are very close to the source of the boxcast (the weapon in this case) and are just dominating the auto-aim system even though the player is really trying to aim for something much further away that is more in direct LoS of where they are pointing their weapon.

1 Like

We need to know more about your projectiles.

If you’re using a hit-scan weapon then raycasting against a tall proxy collider and then using that to look up an offset transform is basically ideal. If the player is going to miss then you don’t need to fix up an inaccurate collision… because they’ve missed, anyway.

If you’re using a weapon where the projectile moves over time then things are different, because the player may be leading a moving enemy. The same principle can work in simple cases: just move the proxy collider based on both the projectile and enemy speed. But now you’re guessing at player intent in a scenario with quite a few edge cases, eg: what if there are valid trajectories for multiple targets of different heights?

You can make something that’s O(log n), but it’s still technically getting more taxing with more enemies. Even raycast performance still depends on the number of colliders in the scene, though that should be O(log n) already.

I recently implemented an auto-aim and am doing it with a list of potential targets. I calculate the angle between the aim direction and nearby valid targets and then aim at the one with the lowest value. No raycasts, just position lookups and math. It’s O(n), but I’m happy with that because in my case there aren’t many targets.

You could optimise the lookups with a spatial data structure, but maintaining that data structure has a cost. If you’re writing a physics engine then it’s worth it. Just for an auto-aim it could easily cost more than it saves. So if I didn’t already have a list I’d probably do something like the box-cast-plus-angle-checks as Sluggy suggested. (Make sure you use a non-allocating cast call.)

But before you get to solving it, seriously consider whether or not it really needs solving. The first time I wrote my own “physics system” was for a swarm shooter type game. I made everything a sphere, and moved on with the decision to come back and add other collider types when they were needed. They were never needed because players never noticed, and few of our enemies were even remotely spherical in shape.

You’re seen behind the curtain and know that the collisions aren’t accurate at the moment. If you add some nice particle work then will your players really be able to notice when everything is going on? Or can you make the short enemies not quite so short? Or tweak the camera angle and collider size so that it looks close enough anyway?