Search code examples
c#unity-game-enginecoroutinelerp

How to spawn evenly spaced game objects and move them in a circular path?


I don't know why my code doesn't work as intended!

when I put speed = 1, it works fine. But if I increase the speed, it doesn't work.

I tried to use FixedUpdate too on the circle class, but it didn't fix the issue.

I don't know what else I have to do.

Actual Behavior: Link for actual behavior

Expected Behavior: Link for expected behavior

Orbit Class:

 using System.Collections;
 using System.Collections.Generic;
 using UnityEngine;

 public class Orbit : MonoBehaviour
 {
     public Circle circlePrefab;
     public Vector2 centerPoint = Vector2.zero;
     [Range(3, 360)] public int segments = 5;
     public float xRadius = 2f;
     public float yRadius = 2f;
     public int numberOfCircles = 0;
     public float speed = 0f;

     private Vector2 initPosition = new Vector2(0, 2f);
     private Vector2[] points;
     private List<float> distances = new List<float>();
     private float totalDistance = 0;

     // Start is called before the first frame update
     void Start()
     {
         points = new Vector2[segments];

         for (var i = 0; i < segments; i++)
         {
             Vector2 point = GetPathPoint(i / (float) segments);

             if (i > 0)
                 totalDistance += AddSegment(points[i - 1], point);

             points[i] = point;
         }

         totalDistance += AddSegment(points[segments - 1], points[0]);

         StartCoroutine(InitCircles());
     }

     private Vector2 GetPathPoint(float t)
     {
         var angle = t * 360f * Mathf.Deg2Rad;
         var x = Mathf.Sin(angle) * xRadius;
         var y = Mathf.Cos(angle) * yRadius;
         return new Vector2(centerPoint.x + x, centerPoint.y + y);
     }

     private float AddSegment(Vector2 from, Vector2 to)
     {
         float distance = (from - to).sqrMagnitude;
         distances.Add(distance);
         return distance;
     }

     private IEnumerator InitCircles()
     {
         yield return new WaitForSeconds(1);

         var time = new WaitForSeconds(totalDistance / speed / numberOfCircles);

         for (var i = 0; i < numberOfCircles; i++)
         {
             Circle circle = Instantiate(circlePrefab, initPosition, transform.rotation);
             circle.transform.parent = transform;
             circle.name = "circle " + i;
             circle.points = points;
             circle.distances = distances;
             circle.speed = speed;

             yield return time;
         }
     }
 }

Circle class:

 using System.Collections;
 using System.Collections.Generic;
 using UnityEngine;

 public class Circle : MonoBehaviour
 {
     public Vector2[] points;
     public float speed = 1f;
     public List<float> distances = new List<float>();

     private float distance = 0;
     private int currentIndex = 0;
     private float time = 0;
     private Vector2 currentPoint;
     private Vector2 nextPoint;

     // Start is called before the first frame update
     void Start()
     {
         currentPoint = points[currentIndex];
         nextPoint = points[currentIndex + 1];
     }

     // Update is called once per frame
     void Update()
     {
         if (transform.position == (Vector3) nextPoint)
         {
             currentIndex++;
             currentIndex %= distances.Count;

             time = 0;
             currentPoint = points[currentIndex];
             nextPoint = points[(currentIndex + 1) % points.Length];
         }

         time += Time.deltaTime;
         distance = time * speed / distances[currentIndex];
         transform.position = Vector2.Lerp(currentPoint, nextPoint, distance);
     }
 }

Solution

  • One problem is that your circles may overshoot your destination because you are using Vector2.Lerp. Instead, consider finding the minimum between distance you need to travel and the distance left to the next point, and/or using Vector2.MoveTowards so that you are guaranteed to never overshoot your destination.

    You also need to keep track of how far you need to travel and loop until all of the distance you need to travel this frame is covered:

     void Update()
     {
         float distanceToTravel = speed * Time.deltaTime;
    
         while (distanceToTravel > 0) 
         {
             if (transform.position == (Vector3) nextPoint)
             {
                 currentIndex = (currentIndex + 1) % distances.Count;
    
                 currentPoint = points[currentIndex];
                 nextPoint = points[(currentIndex + 1) % points.Length];
             }
    
             float distanceThisIteration = Mathf.Min(distanceToTravel,
                     Vector2.Distance(transform.position, nextPoint));
    
             transform.position = 
                     Vector2.MoveTowards(transform.position, nextPoint, distanceThisIteration);
    
             distanceToTravel -= distanceThisIteration;
         }
     }
    

    In the code in the question, when/if you do overshoot your destination with Lerp, then you will enter a condition transform.position == (Vector3) nextPoint will forever resolve to false. Using MoveTowards instead guarantees that transform.position == (Vector3) nextPoint will eventually resolve to true (as long as speed is nonzero!).

    Also, Vector2.sqrMagnitude is not an acceptable way to calculate distance! Use Vector2.magnitude or Vector2.Distance(v1,v2) instead:

     private float AddSegment(Vector2 from, Vector2 to)
     {
         float distance = Vector2.Distance(from, to);
         distances.Add(distance);
         return distance;
     }
    

    The last problem is that there is rounding type of error that occurs when using WaitForSeconds. From the documentation:

    There are some factors which can mean the actual amount of time waited does not precisely match the amount of time specified:

    1. Start waiting at the end of the current frame. If you start WaitForSeconds with duration 't' in a long frame (for example, one which has a long operation which blocks the main thread such as some synchronous loading), the coroutine will return 't' seconds after the end of the frame, not 't' seconds after it was called.

    2. Allow the coroutine to resume on the first frame after 't' seconds has passed, not exactly after 't' seconds has passed.

    So since Unity will often resume the coroutine the first frame after t seconds have passed, it's actually adding on a fraction of a fraction of a second of an error. So, each time you yield WaitForSeconds in a row, you are adding up that error which leads to the first and the last Circle being very close to each other.

    To fix this, you can create a coroutine that will create each sphere that begins a WaitForSeconds all starting from the same frame:

    private IEnumerator InitCircles()
    {
        yield return new WaitForSeconds(1);
    
    
        for (var i = 0; i < numberOfCircles; i++)
        {
            StartCoroutine(WaitCreateCircle(i));
        }
    }
    
    private IEnumerator WaitCreateCircle(int index)
    {
        var time = index * totalDistance / speed / numberOfCircles;
    
        yield return new WaitForSeconds(time);
        Circle circle = Instantiate(circlePrefab, initPosition, transform.rotation);
        circle.transform.parent = transform;
        circle.name = "circle " + index;
        circle.points = points;
        circle.distances = distances;
        circle.speed = speed;
    
    }