Best Practice for projectile prefabs

Hi guys!
I’m still new to Unity and trying to use the XR Toolkit in combination with bow and arrow gameplay.
I have created an Arrow prefab that consists of a derived XRGrabable script that handles stuff like collision detection, its flight and physics, etc. It also contains variables like speed or damage.
The rest of the prefab contains meshes for the arrow itself, the tip, a collider for picking it up and a transform to attach it to sockets like the hand or bow.

Now I want to have an easy way to introduce new arrows to the game, that also have some effects like fire and particle effects and/or different meshes.

My first idea was to introduce a scriptable object that contains the values, meshes and effects. Then I would create different arrow-scripts derived from the scriptable object and assign the parameters during the instantiation of a new arrow from the quiver. To save me some headaches, I would like to hear how you guys would implement different arrow / projectile types with different behaviors and meshes, are there best practices to do it?

public class Arrow : XRGrabInteractable
{

    public float speed = 2000.0f;
    public float damage = 20.0f;
    public Transform tip = null;    // For Linecast
    public BoxCollider boxCollider = null;

    private bool inAir = false;
    private Vector3 lastPosition = Vector3.zero;

    private Rigidbody rb = null;
    protected override void Awake()
    {
        base.Awake();
        rb = GetComponent<Rigidbody>();
        movementType = MovementType.VelocityTracking;
    }

    private void FixedUpdate()
    {
        if (inAir)
        {
            CheckForCollision();
            lastPosition = tip.transform.position;
        }
    }

    private void CheckForCollision()
    {
        RaycastHit hitInfo;
        if (Physics.Linecast(lastPosition, tip.position, out hitInfo))
        {
            Debug.Log("Arrow hit:" + hitInfo.collider.gameObject.name);
            // TODO: Hit of enemies and objects, deal damage and effects
            if (hitInfo.transform.TryGetComponent(out Rigidbody body))
            {
                rb.interpolation = RigidbodyInterpolation.None;
                transform.parent = hitInfo.transform;
                body.AddForce(rb.velocity, ForceMode.Impulse);
            }
            Stop();
        }
    }

    private void Stop()
    {
        inAir = false;
        SetPhysics(false);
    }

    public void Release(float pullValue)
    {
        inAir = true;
        SetPhysics(true);

        MaskAndFire(pullValue);
        StartCoroutine(RotateWithVelocity());

        lastPosition = tip.position;
    }

    private void SetPhysics(bool usePhysics)
    {
        rb.isKinematic = !usePhysics;
        rb.useGravity = usePhysics;
    }

    private void MaskAndFire(float power)
    {
        colliders[0].enabled = false;
        interactionLayerMask = 1 << LayerMask.NameToLayer("Ignore");

        Vector3 force = transform.forward * (power * speed);
        rb.AddForce(force);
    }

    private IEnumerator RotateWithVelocity()
    {

        yield return new WaitForFixedUpdate();
        // Rotate tip towards moving direction
        while (inAir)
        {
            Quaternion newRotation = Quaternion.LookRotation(rb.velocity, transform.up);
            transform.rotation = newRotation;
            yield return null;
        }
    }

    private void SetColliders(bool status)
    {
        foreach (var c in gameObject.GetComponentsInChildren<Collider>())
        {
            Debug.Log("Collider status:" + status.ToString());
            if (!c.isTrigger) c.enabled = status;
        }
    }

    public new void OnSelectEntering(XRBaseInteractor interactor)
    {
        base.OnSelectEntering(interactor);
    }
    public new void OnSelectEntered(XRBaseInteractor interactor)
    {
        base.OnSelectEntered(interactor);
    }

    public new void OnSelectExited(XRBaseInteractor interactor)
    {
        base.OnSelectExited(interactor);
    }

    public new void OnSelectExiting(XRBaseInteractor interactor)
    {
        base.OnSelectExiting(interactor);
    }
}

Make the projectiles self-contained (i.e. after they are instanced they control themselves), and define an Interface for them for when they are fired (for initialization) and when they hit something (for passing damage). Once instanced, the projectile’s Update() handles the rest, including hit detection.

That way all your projectiles are generic prefabs, can be very diverse, and your code doesn’t have to bother with many different cases.

2 Likes

So if I understand you correctly, I should define several prefabs with their own attributes (damage, speed, idle/hit animations, meshes) and declare interfaces for firing (release-method) and call a doDamage()-interface on all objects that should respond to my arrows with physics/damage reactions?

The arrows are pretty self-contained at the moment, as soon as the Release-Method is called, they’re on their own anyway. Only thing that can call it is the bow anyway, so maybe I don’t even need an interface for that?

I want to prevent having the same code twice or more, so my biggest concern is how do I structure different arrow types.
Via polymorphism of my standard arrow, so every prefab gets its own script?
Just one arrow prefab with one script that gets all of its values/effects/meshes from scriptable objects during init and skips steps in the script if it’s not set (like no spawning of particle effects if there isn’t one defined)?

The interface idea is good for different hit-cases though, will try to implement it to my objects in the scene.

1 Like

Polimorphism/Inheritance will get you a very long way, but may not cover some hybrid cases. So you could, for example, create a ‘projectile’ class and subclass that for multiple different types and/or damage types (e.g guided / unguided missiles, aoe / direct damage). As I recommended above, make sure to implement the actual ‘outside’ interface with the world as Interface.

Here’s why: you may come across a type of projectile that wasn’t originally designed as such, or a projectile used as non-projectile during an attack - for example a thrown knife or arrow a spear used in melee.

Using Interfaces allows you to separate the class tree (inherent structure that allows you to code similar items only once and groups them by logic) from the delivery that can pair wildly disparate items by similar functions.

2 Likes