Quantcast
Channel: Envato Tuts+ Game Development
Viewing all articles
Browse latest Browse all 728

Create an Asteroids-Like Screen Wrapping Effect With Unity

$
0
0

Take a look at the demo below and let's get started!

Click the demo to give it focus, then use the arrow keys to move the ship.

As you can see, there are two ways to do this. The first one is easier to wrap your head around. The second solution is not much more complicated, but requires some thinking out-of-the-box. We're going to cover them both.

Setting Up the Scene

Let's set up the scene first. Fire up Unity, start a new project, and set the camera position to x = 0, y = 0 (z can be whatever you like). You'll probably want the camera to be in orthographic mode; our screen wrapping will work in perspective mode, but it might not look the way you want it to. Feel free to experiment.

Add an object that's going to wrap around and make it move. You can use the ShipMovementBehaviour script from the demo source.

Unity Asteroids Wrapping Setup

In my case, I have a simple space ship (a cone) with Asteroids-like movement. As you can see in the picture, I prefer to have the mesh parented to the main object. Whether you do it like that or not, whether you have one or multiple meshes, it doesn't matter; we're going to make a nice screen wrapping script that works in any case.

Simple Wrapping

The basic idea behind screen wrapping is this:

  1. Check whether the object went off-screen.
  2. Find out where it went off-screen. Did it go over the left edge or right? Top or bottom?
  3. Teleport the object right behind the opposite edge of the screen. For example, if it goes over the left edge, we teleport it behind the right edge. We teleport the object behind the opposite edge, so that it actually looks like its wrapping around instead of being teleported.

Doing This in Unity

So, the first thing we want to do is to check if the object went completely off-screen. One simple way to do this in Unity is by checking whether the object's renderers are visible. If they aren't, it means that the object is completely off-camera and thus off-screen.

Let's fetch the renderers on Start() and make a utility function to check them:

Renderer[] renderers;

void Start()
{
    renderers = GetComponentsInChildren();
}

bool CheckRenderers()
{
    foreach(var renderer in renderers)
    {
        // If at least one render is visible, return true
        if(renderer.isVisible)
        {
            return true;
        }
    }

    // Otherwise, the object is invisible
    return false;
}

We can now tell whether our object went off screen, but we still need to find out where it went off, and then teleport it to the opposite side. To do this, we can look at the axes separately. For example, if our ship's x-position is out of the screen bounds, it means that it went off either to the left or to the right.

The easiest way to check that is to first convert the ship's world position to viewport position, and then do the check. This way, it will work whether you use an orthographic or a perspective camera.

var cam = Camera.main;

var viewportPosition = cam.WorldToViewportPoint(transform.position);

To make things clearer, let me explain viewport coordinates. Viewport space is relative to the camera. The coordinates range from 0 to 1 for everything that is on screen, meaning:

  • x = 0 is the coordinate of the left edge of the screen.
  • x = 1 is the coordinate of the right edge of the screen.

Similarly,

  • y = 0 is the bottom screen edge coordinate.
  • y = 1 is the top screen edge coordinate.

This means that, if an object is off-screen, it is going to have either a negative coordinate (less than 0), or a coordinate greater than 1.

Viewport coordinates

Since our cam's position is at x = 0, y = 0, the scene is laid out like a mirror. Everything to the right has positive x-coordinates; everything to the left, negative. Everything in the top half has positive y coordinates; everything in the bottom half, negative. So, in order to position our object at the opposite side of the screen, we just invert its position along the appropriate axis. For example:

  • If our ship moves to the right and its position is (20, 0), it becomes (-20, 0).
  • If our ship moves over the bottom edge and its position is (0, -15), it becomes (0, 15).

Note that we are transforming the ship's transform position, not its viewport position.

Wrap illustration

In code, that looks like this:

var newPosition = transform.position;

if (viewportPosition.x > 1 || viewportPosition.x < 0)
{
    newPosition.y = -newPosition.y;
}

if (viewportPosition.y > 1 || viewportPosition.y < 0)
{
   newPosition.y = -newPosition.y;
}

transform.position = newPosition;

If you run the project now, it'll work fine most of the time. But sometimes, the object might not wrap around. This happens because our object is constantly swapping positions while off screen, instead of just once. We can prevent this by adding a couple of control variables:

bool isWrappingX = false;
bool isWrappingY = false;

Everything should work perfectly now, and the final screen wrapping code should look like this:

void ScreenWrap()
{
    var isVisible = CheckRenderers();

    if(isVisible)
    {
        isWrappingX = false;
        isWrappingY = false;
        return;
    }

    if(isWrappingX && isWrappingY) {
        return;
    }

    var cam = Camera.main;
    var viewportPosition = cam.WorldToViewportPoint(transform.position);
    var newPosition = transform.position;

    if (!isWrappingX && (viewportPosition.x > 1 || viewportPosition.x < 0))
    {
        newPosition.x = -newPosition.x;

        isWrappingX = true;
    }

    if (!isWrappingY && (viewportPosition.y > 1 || viewportPosition.y < 0))
    {
        newPosition.y = -newPosition.y;

        isWrappingY = true;
    }

    transform.position = newPosition;
}

Advanced Wrapping

The simple wrapping works well, but it could look better. Instead of the object going off-screen before wrapping around, you could have perfect wrapping, like in the picture below:

Perfect Wrapping

The easiest way to do this is to cheat a little and have multiple ships on scene. This way, we will create the illusion of a single ship wrapping around. We're going to need eight additional ships (I'm going to call them ghosts): one for each edge and one for each corner of the screen.

We want these ghost ships to be visible only when the player comes to an edge. To do that, we need to position them at certain distances from the main ship:

  • Two ships positioned a screen width away to the left and to the right, respectively.
  • Two ships positioned a screen height away above and below, respectively.
  • Four corner ships positioned a screen width away horizontally and a screen height away vertically.
Wrapping Ghosts

Doing This in Unity

We need to fetch the screen size first, so that we can position our ghost ships. The thing is, we need the screen size in world coordinates relative to the player ship. That doesn't really matter if we use an orthographic camera, but with perspective view it is very important for the ghost ships to be on the same z-coordinate as the main ship. 

So, to do this in a catch-all way, we are going to transform the viewport coordinates of the top-right and bottom-left screen corners to the world coordinates that lie on on the same z-axis as the main ship. We then use these coordinates to calculate the screen width and height in world units relative to our ship's position.

Declare screenWidth and screenHeight as class variables and add this to Start():

var cam = Camera.main;

var screenBottomLeft = cam.ViewportToWorldPoint(new Vector3(0, 0, transform.position.z));
var screenTopRight = cam.ViewportToWorldPoint(new Vector3(1, 1, transform.position.z));

screenWidth = screenTopRight.x - screenBottomLeft.x;
screenHeight = screenTopRight.y - screenBottomLeft.y;

Now that we can position them correctly, let's spawn the ghost ships. We'll use an array to store them:

Transform[] ghosts = new Transform[8];

And let's create a function that will do the spawning. I am going to clone the main ship to create the ghosts, and then I'll remove the ScreenWrapBehaviour from them. The main ship is the only one which should have ScreenWrapBehaviour, because it can have full control over the ghosts and we don't want the ghosts to spawn their own ghosts. You could also have a separate prefab for the ghost ships and instantiate that; this is the way to go if you want the ghosts to have some special behaviour.

void CreateGhostShips()
{
    for(int i = 0; i < 8; i++)
    {
        ghosts[i] = Instantiate(transform, Vector3.zero, Quaternion.identity) as Transform;

        DestroyImmediate(ghosts[i].GetComponent());
    }
}

We then position the ghosts as in the image above:

void PositionGhostShips()
{
	// All ghost positions will be relative to the ships (this) transform,
	// so let's star with that.
	var ghostPosition = transform.position;

	// We're positioning the ghosts clockwise behind the edges of the screen.
	// Let's start with the far right.
	ghostPosition.x = transform.position.x + screenWidth;
	ghostPosition.y = transform.position.y;
	ghosts[0].position = ghostPosition;

	// Bottom-right
	ghostPosition.x = transform.position.x + screenWidth;
	ghostPosition.y = transform.position.y - screenHeight;
	ghosts[1].position = ghostPosition;

	// Bottom
	ghostPosition.x = transform.position.x;
	ghostPosition.y = transform.position.y - screenHeight;
	ghosts[2].position = ghostPosition;

	// Bottom-left
	ghostPosition.x = transform.position.x - screenWidth;
	ghostPosition.y = transform.position.y - screenHeight;
	ghosts[3].position = ghostPosition;

	// Left
	ghostPosition.x = transform.position.x - screenWidth;
	ghostPosition.y = transform.position.y;
	ghosts[4].position = ghostPosition;

	// Top-left
	ghostPosition.x = transform.position.x - screenWidth;
	ghostPosition.y = transform.position.y + screenHeight;
	ghosts[5].position = ghostPosition;

	// Top
	ghostPosition.x = transform.position.x;
	ghostPosition.y = transform.position.y + screenHeight;
	ghosts[6].position = ghostPosition;

	// Top-right
	ghostPosition.x = transform.position.x + screenWidth;
	ghostPosition.y = transform.position.y + screenHeight;
	ghosts[7].position = ghostPosition;

	// All ghost ships should have the same rotation as the main ship
	for(int i = 0; i < 8; i++)
	{
		ghosts[i].rotation = transform.rotation;
	}
}

Run your project and try it out. If you check the scene view, you will see that all the ghost ships move with the main ship and turn when it turns. We didn't explicitly code this, but it still works. Do you have an idea why?

Ghost ships are clones of the main ship without the ScreenWrappingBehaviour. They should still have the separate movement behaviour and since they all receive the same input, they all move the same. If you want to spawn the ghosts from a prefab, don't forget to include a movement component, or some other script that will synchronize their movement with the main ship's.

Everything seems to work fine now, right? Well, almost. If you keep going in one direction, the first time it wraps it'll work fine, but once you reach the edge again, there won't be a ship on the other side. Makes sense, since we aren't doing any teleporting this time. Let's fix it.

Once the main ship goes off the edge, a ghost ship will be on screen. We need to swap their positions and then reposition the ghost ships around the main ship. We already keep an array of ghost, we just need to determine which one of them is on screen. Then we do the swapping and repositioning. In code:

void SwapShips()
{
    foreach(var ghost in ghosts)
    {
        if (ghost.position.x < screenWidth && ghost.position.x > -screenWidth &&
            ghost.position.y < screenHeight && ghost.position.y > -screenHeight)
        {
            transform.position = ghost.position;

            break;
        }
    }

    PositionGhostShips();
}

Try it now, and everything should work perfectly.

Final Thoughts

You now have a working screen wrapping component. Whether this is enough for you depends on the game you're making and what you're trying to achieve.

Simple wrapping is pretty straightforward to use: just attach it to an object and you don't have to worry about its behaviours. On the other hand, you have to be a bit careful if you use advanced wrapping. Imagine a situation where a bullet, or an asteroid hits a ghost ship: you are going to have to propagate collision events to the main ship, or an external controller object.

You might also want your game objects to wrap along only one axis. We're already doing separate checks for each axis, so it's just a matter of adding a couple of Booleans to the code.

One more interesting thing to consider. What if you wanted the camera to move a bit instead of being fixed in space? Perhaps you want to have an arena that's larger than the screen. In that case, you could still use the same wrapping script. You'd only need a separate behaviour that controls bounds the camera's movement. Since our code is based on viewport position, the camera's in-game position doesn't really matter.

You probably have some ideas of your own by now. So go ahead, try them out and make some games!

References


Viewing all articles
Browse latest Browse all 728

Trending Articles