Thursday 10 December 2015

Writing a world generator - Spawning system tutorial for Unity

1 - Intro:



In this tutorial, I hope to show you how to write a script that will run when the game starts and go over your game world, spawning objects randomly to represent terrain and items/world objects.

It should, when finished, look like this:







This tutorial is primarily a C#, Unity scripting tutorial aimed at beginners. 


Pay attention to anything written like this: Grey Background White Text;
This means I want you to add this text to the script. Unless I state otherwise (e.g. 'Put this inside of this loop'), assume this goes after whatever you wrote previously.


This script uses some very common Unity and C# functions and code. I'll do my best to explain what I'm doing but I can't stress enough that if you really take the time to understand how the individual functions work, it'll be a lot more helpful in the long run than simply copy pasting in the end result and trying to work it out from there.

I won't really cover creating the objects you need or how to use the interface, if you're not familiar with these things, I'd recommend doing some basics tutorials first (the Unity website itself being a great place for this - http://unity3d.com/learn)

As you'll also hopefully see, this script is very flexible and can be expanded to suit a variety of uses (some of which I hope to cover later), by the end of this tutorial, you'll ideally be able to build, run and completely modify the way this script works, creating layered spawning to create as robust and complicated a world layout as you want.

Keeping in mind this is designed to be randomised, you will have control over what spawns and roughly where and you can limit it to only spawn on (or not on) certain areas but ultimately if you're just after a flexible world building solution but still want the hand-crafted look, then this likely isn't the tutorial you're looking for.




 2 - How the script works:


Your game world is going to be used like a flat grid, using variables we set we're going to go through that grid from left to right, bottom to top, spawning terrain first, then other world objects at randomised points on the grid. How many points we break the grid into, how randomised the locations are, etc, will all be defined and easily modifiable from our variables, which we'll be able to change from within the editor.

Rather than code in the size of the game world, or the start and end points of the grid manually, we're going to use two 'marker' objects to represent the very bottom left and very top right of the game world. This means that you can scale your world to be as big as you like, while keep the spawning area isolated to only between these two objects.


3 - Before we Begin:


There's a few things we need before we can write the script.

- You need an object for your ground. You can use a plane but I'd recommend using a scaled up cube with a small Y scale to represent it instead, as planes have a habit of letting objects fall through them. Ensure that this object has a collider and is assigned to a layer called 'Ground'. It helps to position this at 0, 0, 0.

- You should already have some prefabs to represent some terrain (e.g. hills, mountains) and some 'world objects' (e.g. trees, stones, etc). These prefabs can be any size or shape but need to have colliders on them and be assigned to layer called 'Terrain' (if they're terrain) or 'WorldObject'.


- Have two empty game objects in your scene called 'BLMarker' and 'TRMarker'. These are the marker objects I mentioned earlier, position BLMarker at the very bottom left corner of your game world and TRMarker at the top right. Try to keep the Y position on 0, tags and layers don't matter for these.

- Have a third empty game object to run the world generation script.


- Create a new C# script called 'WorldGen', attach this to the world generation object.

Open the WorldGen script and we can begin.


4 - The script - Variables:


Note - For all the variables, when using [SerializeField] we're doing it to expose them to the editor. This means we need to remember to set them on the object in the editor itself before the script can run correctly.

Create your variables under:

using UnityEngine;
using System.Collections;

public class WorldGen : MonoBehaviour
{

First, create three GameObject arrays to hold our spawn objects. We'll create one to hold tress, one to hold stones and one for all the terrain.


[SerializeField]
private GameObject[] trees;
[SerializeField]
private GameObject[] stones;

[SerializeField]
private GameObject[] terrainObjects;

Note - These variables are declared as arrays rather than single values by adding the [] (square brackets) onto the end of the type (GameObject).

Now that we have these, we need variables to hold our two marker objects. These are only singular game objects - not arrays - and will be dragged in later in the editor.

[SerializeField]

private GameObject blMarker;
[SerializeField]
private GameObject trMarker;

And also an integer that we'll use to determine how often stone will spawn vs. trees (you'd want less stone than trees, else it looks a little odd).

[SerializeField]
private int stoneChanceAmt;

We now need some variables to hold the world size and values to calculate the grid positions. 

We'll do this by creating a starting point on the grid for each type of object we spawn (so, 1 for the terrain and 1 for the world objects, we split this up so you can have them spawn at different amounts if needed) as well as a variable to keep track of the current position on the grid as it works its way through.

These will be Vector3's and for these - and the rest of the grid calculation variables - we're setting them only within the script, so they don't require the [SerializeField].


private Vector3 currentPos;
private Vector3 worldObjectStartPos;

private Vector3 terrainStartPos;

A float to store the width of the world, which we'll need to calculate spawning positions.

private float groundWidth;

And 2 floats to store the 'Inc' (or increment) amounts. These values are used to determine how far apart each spawn point is from the others. Once again, we have a different float for each object type (Terrain or World Object).

private float worldObjectIncAmt;
private float terrainObjectIncAmt;

Similar to above, we're also going to set a float value to help randomise the spawn position.

private float worldObjectRandAmt;
private float terrainRandAmt;

And that's it for the grid! If it doesn't make sense yet, don't worry, just know that these
 variables should all come together to ultimately help decide how well or sparsely populated your world is with objects.

Now, we move onto some variables to control the spawning loop. When I said above that all these variables will be set in the script, these next values are responsible for kicking that all off.


Firstly, 2 integers to control how large the grid is.

These won't make much sense yet but I'll try and explain. This value says how many times the grid will be divided up. As an example, if you have a ground width of 100 and you set the RowsAndCols to 4, you're saying 'Split the world up into 4x4, creating a grid of 25x25 squares', the points between the squares are the spawn points that we later randomise.


[SerializeField]
private int worldObjectRowsAndCols;
[SerializeField]
private int terrainRowsAndCols;

After this, a value to monitor which 'pass' of the world we're on (a 'pass', in this case, being one full spawn loop of the world, from bottom left to top right).

private int currentPass;

And an integer to say how many passes after the initial pass we should make.

Note - This script will always perform at least one pass over the world, this value controls how many passes after that first one should be made.


[SerializeField]
private int repeatPasses;

Using currentPass and repeatPasses, we'll be able to 'layer' the spawning, so that you can control which objects spawn, when and what they spawn on top of.

To check what we're spawning on, we'll be using Raycasting and OverlapSphere. To control these, we need a few variables.

The first are 2 floats to control the size of an OverlapSphere, 1 for Terrain, 1 for WorldObjects.

[SerializeField]
private float worldObjectSphereRad;
[SerializeField]
private float terrainSphereRad;

The second are some LayerMasks, which we use to limit the collisions included with the Raycast and OverlapSphere. We need 3, 1 for ground, 1 for World Objects, 1 for Terrain.

[SerializeField]
private LayerMask groundLayer;
[SerializeField]
private LayerMask worldObjectLayer;
[SerializeField]
private LayerMask terrainLayer;

And that's the variables all done. Now onto starting the script.

5 - The script - SpawnWorld():


If you're starting with a brand new C# script, you should still have the Update() function in there. Get rid of it, we only need this script to run once so we won't be using it.

Create a Co-Routine where Update() was, under the Start() function, called SpawnWorld.

IEnumerator SpawnWorld()
{

}

If the syntax gets error checked and highlighted, it's because the IEnumerator wants a yield statement. Within SpawnWorld(), add in a WaitForSeconds(), with a value of 0.01f.

IEnumerator SpawnWorld()
{
    yield return new WaitForSeconds(0.01f);
}

There's not a lot happening in there yet, this will just stop the editor from complaining and allow the script to compile. The reason we're running this in a co-routine is so that the process can be spaced out. If you have larger world sizes and try and do all the spawning in one go in a single frame, you're gunna have a bad time. A co-routine allows us to control this and can later be used to your advantage (e.g. writing a loading screen (a tutorial for later, perhaps?)).

On another note. Now would be a good time to save the script and apply it to your world generator object, if you haven't already.

With the script applied, we should also set a few of those variables in the editor. This is how the example I'm using is currently setup:




I have the spawn objects set in the arrays and I've dragged and dropped in the BL and TR Marker objects from the scene. Rows and Cols are set to easy values to make life easier. The sphere rads aren't too big.


Only by playing with all these values will you find the correct amounts for your particular world and objects.

Importantly, the LayerMasks have had layers assigned within them.



The ground layer is used by the Raycast and it needs to contain any layer that you want objects to be able to spawn on top of (in this case, the ground and the terrain objects). It's like a master layer, ensure that anything that needs to have objects spawned on top of it is included in this layer.



The terrain layer and world object layer are for the OverlapSphere, these tell the spawner if you're about to spawn inside of another object and let you decide what can and can't be spawned near (and as a result of the way they work, how closely objects can spawn from each other).

Back on the script itself, the first main calculation we need to do is work out how wide our world is. This script assumes your game world is square (it doesn't matter if it's not, if there's no ground, objects won't spawn) and calculates things accordingly (once again, remember the bottom left and top right corners are where we get our start and end points).

So, to find the width of the game world, we need to use the X position values of the marker objects and subtract the lowest from the highest to give us the amount between them, this is our groundWidth. Add this within the SpawnWorld() co-routine.

groundWidth = trMarker.transform.position.x - blMarker.transform.position.x;

From this value, we can work out the Inc amounts using the value set for RowsAndCols. As explained before, we want to divide the width by the amount of RowsAndCols that have been defined.

worldObjectIncAmt = groundWidth / worldObjectRowsAndCols;
terrainIncAmt = groundWidth / terrainRowsAndCols;

Then the random Inc amt. This value will be half of the primary Inc amt, the reason being that this will create a 'zone' around the spawn point that the randomised location will be in, this zone, being half the distance between the new and old spawn points, will completely cover all areas of the map, ensuring that a randomised spawn location could be anywhere on the ground. 

worldObjectRandAmt = worldObjectIncAmt / 2f;
terrainRandAmt = terrainIncAmt / 2f;

Note - If you were to make this value come out smaller (so, divide by 4 instead of 2), you'd see the objects spawning in 'clumps' or tight groups as the zone would end up smaller as a result.

Now that we have this, we can set the start positions by assigning the worldObjectStartPos and terrainStartPos to new Vector3's.

This bit is hard to visualise but you need to be aware that when setting up the start position, you'd need to adjust them slightly.

Because of the way the loop we're going to write works, if we were to simply start at the very bottom left corner and then make our first step to the right (X+1), we'd be 1 step too far in on X and our Z would still be perfectly on the bottom edge of the world.

We don't want this, we want to start 1 half step (or Inc) back on the X axis and 1 half step in on the Z axis (so, 'X - Inc /2' and 'Z + Inc /2').

worldObjectStartPos = new Vector3 (blMarker.transform.position - (worldObjectIncAmt / 2f), blMarker.transform.position.y, blMarker.transform.position.z + (worldObjectIncAmt / 2f));


terrainStartPos = new Vector3 (blMarker.transform.position - (terrainIncAmt / 2f), blMarker.transform.position.y, blMarker.transform.position.z + (terrainIncAmt / 2f));

Note - If you're still a bit confused about this bit, adjust this value once the script is working, remove the -IncAmt and +IncAmt bits and see what result you get, it should be fairly obvious from this how this works.

And that's all the values we need for now. It's time to start writing the actual spawn loop. If you're familiar with FOR loops, this should be fairly easy for you to understand. If not, try to stay with me.

What we're going to do is create three main loops, 1 for the main pass (so, as mentioned before, the whole spawn loop, bottom to top), 1 for the columns and 1 the rows.

Start a FOR loop for the passes, we use repeatPasses as the control value for this.
for (int rp = 0; rp <= repeatPasses; rp++)
{

}


Note - We're saying, create a new integer called 'rp' (means 'Repeat Passes') and set it to 0, each time the loop runs, increase rp by 1 until rp is greater than the repeatPasses variable.

Within this loop, set currentPass to the rp variable defined in the loop parameters.

currentPass = rp;

Now we break the loop up. We want to run 1 of 2 different loops, depending on the pass number. The first loop (so, pass # 0) is for spawning terrain.

To do this, use an IF statement.

if (currentPass == 0)
{

}

So if this is true, we know we're on the initial pass and should be spawning terrain. Inside the IF statement, make the current position variable reflect the terrain start position we set earlier.

currentPos = terrainStartPos;

Now, still inside the IF statement, start a new loop to start incrementing the rows. This time, we use the terrain RowsAndCols variables to control it.

for (int rows = 1; rows <= terrainRowsAndCols; rows++)
{

}

And directly within this, do the same for columns.

for (int cols = 1; cols <= terrainRowsAndCols; cols++)
{

}

Right, that's the terrain loops started. It's a bit confusing but the idea is that each time the passes loop runs, the cols loop will increment  moving the position along X for as many times as defined in RowsAndCols, then the rows loop will increment, move the positions Z up and repeat until rows is also greater than RowsAndCols. This is the 'left to right, bottom to top' behaviour.

Inside the cols loop, each time it runs (so, each new spawn position) we want it to increase the currentPos.x, set a terrain object to spawn from the array, spawn it, wait for a moment and then finish.

//Move the current position.
currentPos = new Vector3 (currentPos.x + terrainIncAmt, currentPos.y, currentPos.z);

//Define a temporary GameObject and set it to a random object from the array.
GameObject newSpawn = terrainObjects[Random.Range(0, terrainObjects.Length)];

//Run the SpawnHere() method, giving it parameters for the location, object, sphere radius and a bool for if the object is terrain or not (useful later).
SpawnHere(currentPos, newSpawn, terrainSphereRad, true);

Under this, use the same WaitForSeconds statement as before to finish that particular loop. Remove the one you wrote earlier.

Note - The WaitForSeconds statements are what spaces the script out. Moving them around in the loops can cause very different results. If you place it in the rows loop rather than cols, for example, it'll only pause once every time it increments Z, which means more objects will spawn at once but you could start noticing the delay more. Experiment and see what works for you.

yield return new WaitForSeconds(0.01f);

Now that the cols loop is done, underneath it, increment the Z position and set the current X position to be back at the start.

currentPos = new Vector3(terrainStartPos.x, currentPos.y, currentPos.z + terrainIncAmt);

That's the terrain loop done. Now onto the World Objects.

It's pretty much the same, after the closing brackets for the IF (current pass == 0) statement, write an ELSE IF to check if the value is greater than 0.

else if (currentPass > 0)
{

}

We're using ELSE IF so it can be added onto later easily. If you want to define more passes with different layers, you'd simply keep adding onto this IF statement.

Inside the ELSE IF statement, repeat the loops, this time setting currentPos to worldObjectStartPos and using worldObjectRowsAndCols instead.


currentPos = worldObjectStartPos;

for (int rows = 1; rows <= worldObjectRowsAndCols; rows++)
{
    for (int cols = 1; cols <= worldObjectRowsAndCols; cols++)
    {
     
    }
}

Inside the cols loop, it's much the same as the terrain loops.

We move the current pos one step.

currentPos = new Vector3 (currentPos.x + worldObjectIncAmt, currentPos.y, currentPos.x);

This time, before we run the spawn method, we generate a temporary random int value, using the stoneChanceAmt variable to decide the maximum random value.

int spawnChance = Random.Range(1, stoneChanceAmt + 1);

Note - We add 1 to the chance amount because when using an int the method doesn't include the final value, e.g. if stoneChanceAmt is 5 and I don't use + 1, the result will only ever end up being 4 at most.

Using spawnChance, we do a check to see if we get a 1 or greater (the idea being, you get a 1 out of chanceAmt - e.g. 1 out of 5).

if (spawnChance == 1)
{

}
else if (spawnChance > 1)
{

}

Inside these statements, set the object from the array (same as the terrain) run the spawn method and wait for 0.01f seconds.

//For if (chance == 1):
GameObject newSpawn = stones[Random.Range(0, stones.Length)];
SpawnHere(currentPos, newSpawn, worldObjectSphereRad, false);
yield return new WaitForSeconds(0.01f);

//For if (chance > 1):
GameObject newSpawn = trees[Random.Range(0, trees.Length)];
SpawnHere(currentPos, newSpawn, worldObjectSphereRad, false);
yield return new WaitForSeconds(0.01f);

Like before, after the cols loop, make sure you increment the Z position and reset X to the start pos.

currentPos = new Vector3 (worldObjectStartPos.x, currentPos.y, currentPos.z + worldObjectIncAmt);

And finally, before the last set of brackets, at the end of all the loops but still within the co-routine, we'll call a method that we'll create in a moment.

WorldGenDone();

Nice! That's the co-routine done. If all has been done correctly, it should look a bit like this:

IEnumerator SpawnWorld()
{
    //Set Ground width from marker objects.
    groundWidth = trMarker.transform.position.x - blMarker.transform.position.x;

    //Set worldObject amounts.
    worldObjectIncAmt = groundWidth / rowsAndCols;
    worldObjectRandAmt = worldObjectIncAmt / 2f;

    //Set terrain amounts.
    terrainIncAmt = groundWidth / terrainRowsAndCols;
    terrainRandAmt = terrainIncAmt / 2f;

    //Set starting positions for WO's and Terrain.
    worldObjectStartPos = new Vector3(blMarker.transform.position.x - (worldObjectIncAmt / 2f), blMarker.transform.position.y, blMarker.transform.position.z + (worldObjectIncAmt / 2f));
    terrainStartPos = new Vector3(blMarker.transform.position.x - (terrainIncAmt / 2f), blMarker.transform.position.y, blMarker.transform.position.z + (terrainIncAmt / 2f));

    //Start generation loop.
    for (int rp = 0; rp <= repeatPasses; rp++)
    {
        currentPass = rp;

        //If this is the first pass, we spawn terrain.
        if (currentPass == 0)
        {
            //Set the current position to the terrain start position.
            currentPos = terrainStartPos;

            //Start terrain generation loop, first loop counts the rows (vertical increments).
            for (int rows = 1; rows <= terrainRowsAndCols; rows++)
            {
                //Second loop counts the columns (horizontal increments).
                for (int cols = 1; cols <= terrainRowsAndCols; cols++)
                {
                    //Move position + terrain inc amt.
                    currentPos = new Vector3(currentPos.x + terrainIncAmt, currentPos.y, currentPos.z);

                    //Set the object to spawn.
                    GameObject newSpawn = terrainObjects[Random.Range(0, terrainObjects.Length)];

                    //Spawn method, parameters are the Vector3 position to spawn at, the GameObject to spawn, 
                    //the float for sphere size to use (terrain is bigger) and a bool for if the object is terrain or a world object.
                    SpawnHere(currentPos, newSpawn, terrainSphereRad, true);
                    yield return new WaitForSeconds(0.01f);
                }

                //Second loop complete, increment the current pos' Z position before the next 'row'.
                currentPos = new Vector3(terrainStartPos.x, currentPos.y, currentPos.z + terrainIncAmt);
            }

        }
        //If not the first pass, spawn world objects instead.
        else if (currentPass > 0)
        {
            //Set the current position to the WO start position.
            currentPos = worldObjectStartPos;

            //Start WO generation loop, same as above.
            for (int rows = 1; rows <= worldObjectRowsAndCols; rows++)
            {
                //Second loop, as above.
                for (int cols = 1; cols <= worldObjectrowsAndCols; cols++)
                {
                    //Move position + WO inc amt.
                    currentPos = new Vector3(currentPos.x + worldObjectIncAmt, currentPos.y, currentPos.z);

                    //Differs from above, this time we perform a random check 
                    //to see if a tree or stone is spawned.
                    int spawnChance = Random.Range(1, stoneChanceAmt + 1);

                    //If the spawnChance is 1 (out of stoneChanceAmt)
                    if (spawnChance == 1)
                    {
                        //Spawn a random stone from the array.
                        GameObject newSpawn = stones[Random.Range(0, stones.Length)];
                        SpawnHere(currentPos, newSpawn, sphereRad, false);
                        yield return new WaitForSeconds(0.01f);
                    }
                    else if (spawnChance > 1)
                    {
                        //Spawn a random tree from the array.
                        GameObject newSpawn = trees[Random.Range(0, trees.Length)];
                        SpawnHere(currentPos, newSpawn, sphereRad, false);
                        yield return new WaitForSeconds(0.01f);
                    }
                }

                //Second loop complete, increment the current pos' Z position before the next 'row'.
                currentPos = new Vector3(worldObjectStartPos.x, currentPos.y, currentPos.z + worldObjectIncAmt);
            }
        }
    }

    //Method to call when world generation is done.
    WorldGenDone();

}

6 - The Script - SpawnHere():

Cool. So now we have our co-routine that starts as soon as the script is run. At this point, it shouldn't compile, as we haven't added the two SpawnHere() and WorldGenDone() methods.

We'll add in the SpawnHere() method. This is going to be where all our checks happen to see if we're about to spawn on top of something and where we randomise the spawn point.

Lets begin by, under the SpawnWorld co-routine, adding the method.

This time, we're adding in a few parameters. You remember when we called the method before we had to pass it all the additional arguments, this is were we're telling the method what arguments it's accepting. In this, once again, we've giving it the spawn position as a Vector3, a game object that we're going to instantiate, a float for the OverlapSphere and a bool to say if the object is terrain or not.

void SpawnHere(Vector3 newSpawnPos, GameObject objToSpawn, float radiusOfSphere, bool isTerrainObject)
{

}

Inside this method, we want a check to see if the object is terrain or not (as terrain will be spawned a little differently). So have an IF statement using the isTerrainObject bool.

if (isTerrainObject == true)
{

}

Inside this, we create two new temporary Vector3's, one for the random position (based off the newSpawnPosition Vector) and a position to cast a Raycast from.

First, the initial randomisation of the position, we're randomising the X and Z values by the RandIncAmt we set earlier.

Vector3 randPos = new Vector3(newSpawnPos.x + Random.Range(-terrainRandAmt, terrainRandAmt + 1), 0, newSpawnPos.z + Random.Range(-terrainRandAmt, terrainRandAmt + 1));

And now the raycast position. This is now based off the random position. A Y value of 10 is used here to ensure it starts well above the world, ensure this is a value higher than the tallest objects or it might not work correctly.

Vector3 rayPos = new Vector3(randPos.x, 10, randPos.z);

Now we cast the raycast. We do it as an IF condition to say 'do something only IF this raycast I'm firing hits something', then because it's using the ground layer, the theory is that if it doesn't hit anything nothing should spawn (i.e. there's no ground to spawn on). The ray starts at rayPos and fires downwards (-Vector3.up).

if (Physics.Raycast(rayPos, -Vector3.up, Mathf.Infinity, groundLayer))
{

}

Inside this statement, we perform an OverlapSphere to see if the object is going to spawn in the same location as another object. To do this, we create a new temporary array of Colliders, which we fill with the results of the OverlapSphere. We cast the sphere using the random position and the radiusOfSphere variable. Because this current part of the script is spawning terrain, the OverlapSphere will only hit the terrain layer (to prevent terrain spawning too far inside other terrain).

Collider[] objectsHit = Physics.OverlapSphere(randPos, radiusOfSphere, terrainLayer);

Then we say 'continue IF the list of objects hit is 0'. We do this easily by simply checking to see if the list of objects hit is at 0, if it's not then obviously we hit something and shouldn't spawn.

if (objectsHit.Length == 0)
{

}

Inside this, we instantiate the object and store a reference to it. The instantiate function will be given the object we're spawning (objToSpawn), the random position (randPos) and told to spawn the object using it's own rotation (Quaternion.identity).

GameObject terrainObject = (GameObject) Instantiate(objToSpawn, randPos, Quaternion.identity);

Using the reference, we now randomise the Y rotation via EulerAngles, to make it look a little more natural.

}terrainObject.transform.eulerAnges = new Vector3(transform.eulerAngles.x, Random.Range(0, 360), transform.eulerAngles.z);

And that's the terrain section of the method done. Now we move onto the world objects. Put this as an ELSE statement to the 'IF (isTerrainObject == true) check.

It's going to be virtually the same as the terrain section but with a few differences. Create the rand and ray pos, like before but using the worldObjectRandAmt variables instead.

else
{
    Vector3 randPos = new Vector3(newSpawnPos.x + Random.Range(-worldObjectRandAmt, worldObjectRandAmt + 1), newSpawnPos.y, newSpawnPos.z + Random.Range(-worldObjectRandAmt, worldObjectRandAmt + 1));
    Vector3 rayPos = new Vector3(randPos.x, 10, randPos.z);
}

This time around though, we're going to want to store the point where the raycast hits the world, so we can better control the height our objects are spawning at. Underneath the Vector3s we just created but inside the ELSE statement, create a temporary RaycastHit variable called 'hit'.

RaycastHit hit;

Then we do the same raycast check as before but this time we store the hit info via OUT into the 'hit' variable.

if (Physics.Raycast(rayPos, -Vector3.up, out hit, Mathf.Infinity, groundLayer))
{

}

Inside this IF statement, we modify randPos again and adjust the position's Y value, setting it to be where the raycast hit. Adjusting for the raycast will ensure that objects don't spawn half inside the terrain below them. We leave the X and Z values as they are.

randPos = new Vector3(randPos.x, hit.point.y, randPos.z);

We perform the OverlapSphere, same as before but using a different layer.

Collider[] objectsHit = Physics.OverlapSphere(randPos, radiusOfSphere, worldObjectsLayer);

Perform the check again to see if any objects were hit.

if (objectsHit.Length == 0)
{

}

And then inside this, do the spawning, once again storing a reference to the spawned object.

GameObject worldObject = (GameObject) Instantiate(objToSpawn, randPos, Quaternion.identity);

This time, we're going to move the object after we spawn it. When the object spawns, it will be half in the ground, we bring it up by adjusting the Y value based off the object's height.

To do this, we're going to use the objects 'Renderer' component. This contains a function to get the bounds of y, meaning we can get a value to represent the object's height in the world. We add this value, multiplied by (in this case) 0.75f (to get 75%) and then add that to Y when we move the object, to ensure it's spawning at least partially above the ground.

Note - You'll likely want to change the float, depending on the objects you're using. It simply means how much of the object will be under the ground after it moves (multiplying by a lower value means the object will be lower).

worldObject.transform.position = new Vector3(worldObject.transform.position.x, worldObject.transform.position.y + (worldObject.GetComponent<Renderer>().bounds.extents.y * 0.75f), worldObject.transform.position.z);

After the position, we alter the rotation like before.

worldObject.transform.eulerAngles = new Vector3(transform.eulerAngels.x, Random.Range(0, 360), transform.eulerAngles.z);

Ensure all the brackets are closed off and that should be it. That's the spawn method done!

Assuming everything is correct, it should look like this:

//Spawn method, takes 4 parameters, as detailled above.
void SpawnHere(Vector3 newSpawnPos, GameObject objToSpawn, float radiusOfSphere, bool isTerrainObject)
{
    //Is the object terrain or world?
    if (isTerrainObject)
    {
        //Set random position based off currentPos/newSpawnPos.
        Vector3 randPos = new Vector3(newSpawnPos.x + Random.Range(-terrainRandAmt, terrainRandAmt + 1), 0, newSpawnPos.z + Random.Range(-terrainRandAmt, terrainRandAmt + 1));
        Vector3 rayPos = new Vector3(randPos.x, 10, randPos.z);

        //Perform a raycast to ensure pos is over ground.
        if (Physics.Raycast(rayPos, -Vector3.up, Mathf.Infinity, groundLayer))
        {
            //Perform a check to see if there's any terrain objects in the road.
            Collider[] objectsHit = Physics.OverlapSphere(randPos, radiusOfSphere, terrainLayer);

            //If no objects have been hit.
            if (objectsHit.Length == 0)
            {
                GameObject terrainObject = (GameObject)Instantiate(objToSpawn, randPos, Quaternion.identity);

                terrainObject.transform.eulerAngles = new Vector3(transform.eulerAngles.x, Random.Range(0, 360), transform.eulerAngles.z);
            }
        }
    }
    else
    {
        Vector3 randPos = new Vector3(newSpawnPos.x + Random.Range(-worldObjectRandAmt, worldObjectRandAmt + 1), newSpawnPos.y, newSpawnPos.z + Random.Range(-worldObjectRandAmt, worldObjectRandAmt + 1));
        Vector3 rayPos = new Vector3(randPos.x, 10, randPos.z);

        RaycastHit hit;

        if (Physics.Raycast(rayPos, -Vector3.up, out hit, Mathf.Infinity, groundLayer))
        {
            randPos = new Vector3(randPos.x, hit.point.y, randPos.z);

            Collider[] objectsHit = Physics.OverlapSphere(randPos, radiusOfSphere, worldObjectsLayer);

            if (objectsHit.Length == 0)
            {
                GameObject worldObject = (GameObject)Instantiate(objToSpawn, randPos, Quaternion.identity);
                worldObject.transform.position = new Vector3(worldObject.transform.position.x, worldObject.transform.position.y + (worldObject.GetComponent<Renderer>().bounds.extents.y * 0.75f), worldObject.transform.position.z);

                worldObject.transform.eulerAngles = new Vector3(transform.eulerAngles.x, Random.Range(0, 360), transform.eulerAngles.z);
            }
        }
    }

}

And finally, underneath SpawnHere(), create another method for WorldGenDone(). Put a simple print function in it.

This function will run once the world has generated, you can use this to determine when a good time to spawn the player might be for example. Or use it to tell when a loading screen should close. 


In this case though, it's only going to run and say that it's done.

void WorldGenDone()
{
    print ("World has been generated! Hooray!");
}

And that's it! You're all done.




I hope this helps demonstrate a lot of these functions clearly and is useful to anyone working on anything like this. I'd value any feedback and I'm happy to hear any questions.


Thanks for reading!

No comments:

Post a Comment