Gamesmithing: Stationary Children of Rotating Parents

Josh_3_larger.png

This gamesmithing article discusses my solution to a recent programming issue. I wasn't able to find any solution with a couple minutes of cursory googling, so I math'ed it out myself and I'm posting the solution here for posterity's sake. I'm using Unity, but the general math can apply to other systems.

rockhead_faded_drop.gif

I recently added a new enemy into Caravana. This enemy rolls around on the ground and charges at you when you get too close. It uses Unity's built-in physics engine to rotate the sprite around as the little dude is coming at you. However, I ran across a small issue while coding this. All of my enemies have child objects, like a health bar, to help the player know how close each enemy is to death. As the parent object rotates, the child objects follow along with the rotation. Inconvenient!

sprite and healthbar bad rotate.gif

Let's start from the beginning. I create a 2D sprite in an empty scene, and attach a quick script giving the guy some rotation.

public class Rotating : MonoBehaviour
{
    private float _timer;
    public float RotationSpeed = 180;

    public void Update()
    {
        _timer += Time.deltaTime;
        transform.rotation = Quaternion.AngleAxis(RotationSpeed * _timer, Vector3.forward);
    }
}

I attach this script to the sprite, and viola! He moves!

just sprite rotation.gif

Next, I add a 2D sprite as a child object to the parent. I replace the default null sprite with a healthbar icon, and reposition the healthbar 1 unit above the parent sprite. If you press "Play" here, you'll see the same behaviour as in the first gif: the healthbar rotates around the sprite, staying in the same local rotation orientation as the sprite itself. I say “local” meaning “in reference to its parent”. The healthbar stays right by the top of the enemy’s head, even as that head rotates around.

We can solve this problem with the magic of trigonometry. (High school Josh! If you are somehow reading this, pay more attention in math class! Also, the Cavs win the NBA championship in 2016, bet the farm on that one.) First, a little trigonometry review. The functions sine and cosine can be used to measure the y and the x positions (respectively) on a unit circle (or circle of radius 1) at a given angle. This is important, because rotations are (ya guessed it) circular. Quick review:

basic trig.png

There's two main issues to fix to get the healthbar to stay stationary. First, we have to stop the parent's new rotation from being applied to the child object. Second, we have to keep the same relative position to the parent even as the parent rotates. I say relative because we'll really be orbiting around the parent at the opposite speed that the parent is rotating. This will keep it in the same global position.

I add another couple fields to the Rotating script. One is a GameObject “Healthbar” to keep a reference to the health bar. The next is a float“HealthbarOffsetY” that tells us how high above the parent object to keep the health bar. Then, at the end of Update(), we add three lines.

Healthbar.transform.rotation = Quaternion.AngleAxis(-1 * transform.rotation.z, Vector3.forward);
var zRot = transform.rotation.eulerAngles.z * Mathf.Deg2Rad;
Healthbar.transform.localPosition = new Vector3(Mathf.Sin(zRot) * HealthbarOffsetY, Mathf.Cos(zRot) * HealthbarOffsetY, 0);

The first line takes our parent object's rotation and applies the opposite to the child object. The next line calculates our Z rotation (and converts that to radians), and the third keeps the local position orbiting around the parent object in the opposite direction of the parent's rotation. Why is the x value sine and the y value cosine? Take a look at this next picture:

trig w parent.png

If you look at the local coordinates of the healthbar, you'll see they lineup with the sine and cosine functions exactly. And so it goes here.

This works well! Let's try to add a dust cloud to the side of our rolling dude. It'll really give him that sense of speed. I add another child sprite to the parent and position it so it looks right. (x,y : 0.46, -0.19). I apply the same steps in the rotation script. What happens?

DustCloud.transform.rotation = Quaternion.AngleAxis(-1 * transform.rotation.z, Vector3.forward);
DustCloud.transform.localPosition = new Vector3(Mathf.Sin(zRot) * DustCloudOffsetX, Mathf.Cos(zRot) * DustCloudOffsetY, 0);
dust probs.gif

Well, that's no good. What's going on here is that we're only partially accounting for the rotation. Let's take a closer look at those offsets.

offsets 1.png

We're using -B as our y offset and A as our x offset. Now, what happens when we rotate the dust cloud by 90 degrees? Prepare to have your mind blown.

offsets 2.png

Whooooaaa, dude. It looks like the A and B have swapped out for our x and y offset. Doing this again in 90 degree increments shows us the following:

trig w parent 2.png

An important thing to note is that the sine and cosine functions alternate between being -1 or 1 and 0 at each of the 90 degrees of theta. Also, the X and Y offsets each alternate between being A and B at different steps. When you combine these two pieces of information, you realize that you have a handy scheme to get the local position coordinates during rotation. We'll replace the last line of code with the following:

DustCloud.transform.localPosition = new Vector3(Mathf.Cos(zRot) * DustCloudOffsetX + Mathf.Sin(zRot) * DustCloudOffsetY, Mathf.Cos(zRot) * DustCloudOffsetY - Mathf.Sin(zRot) * DustCloudOffsetX, 0);

Or, more tersely:

        x = cos(t) * A + sin(t) * B
        y = cos(t) * B - sin(t) * A        

Here's the finished product:

finished gif.gif

Hurray! Note that for the healthbar, the X offset (A) was always 0, so the second formula reduces to what I used in the first place.

There is an alternative (and much less math-heavy) way to accomplish the same thing here. You can create an empty parent object to hold both the enemy dude and the healthbar and the dust cloud. That way, a rotation of the enemy won't cause an automatic rotation of the other objects. A script could be added to the other objects that updates their positions to that of the enemy. Usually pretty easy to do, but sometimes you want to preserve that parent-child relationship. In my case, I would've had to break my prefabs in the game, and doing the math seemed like the easier way out.

Here's the code. As usual, I'm releasing it under an MIT license.

public class Rotating : MonoBehaviour
{
    private float _timer;
    public float RotationSpeed = 180;
    public GameObject Healthbar;
    public GameObject DustCloud;


    public float HealthbarOffsetY = 1;
    public float DustCloudOffsetY = -0.19f;
    public float DustCloudOffsetX = 0.46f;

    public void Update()
    {
        _timer += Time.deltaTime;
        transform.rotation = Quaternion.AngleAxis(RotationSpeed * _timer, Vector3.forward);


        Healthbar.transform.rotation = Quaternion.AngleAxis(-1 * transform.rotation.z, Vector3.forward);
        var zRot = transform.rotation.eulerAngles.z * Mathf.Deg2Rad;
        Healthbar.transform.localPosition = new Vector3(Mathf.Sin(zRot) * HealthbarOffsetY, Mathf.Cos(zRot) * HealthbarOffsetY, 0);

        DustCloud.transform.rotation = Quaternion.AngleAxis(-1 * transform.rotation.z, Vector3.forward);
        //DustCloud.transform.localPosition = new Vector3(Mathf.Sin(zRot) * DustCloudOffsetX, Mathf.Cos(zRot) * DustCloudOffsetY, 0);
        DustCloud.transform.localPosition = new Vector3(Mathf.Cos(zRot) * DustCloudOffsetX + Mathf.Sin(zRot) * DustCloudOffsetY,
            Mathf.Cos(zRot) * DustCloudOffsetY - Mathf.Sin(zRot) * DustCloudOffsetX, 0);
    }
}