I went ahead and added block breaking and block placing to make our chunks interactive.
This tutorial is about editing the terrain similar to part 4 but in 3d. First of all we're going to change the scripts we've already written a little bit. If I had been smarter I would have written them this way in the first place but I didn't think ahead.
In Chunk.cs the change is simple, make the GenerateMesh function public so we can call it whenever we make changes to a mesh.
public void GenerateMesh(){ //Made this public
In World.cs our changes are bigger, we're changing the chunks array from a GameObject array to a Chunk array because we don't actually need access to the gameobjects we need access to the chunk scripts. And we're offsetting the positions the chunks are created at because if you remember in the 2d prototype whenever we checked the position of a block we had to offset the point of collision before we could round to get the block position. This time we're doing it right and just making sure the center of every block is its x,y,z position.
We'll start with the position change, in the instatiate line for the chunks we'll add offsets to each axis:
chunks[x,y,z]=Instantiate(chunk,
new Vector3(x*chunkSize-0.5f,y*chunkSize+0.5f,z*chunkSize-0.5f),
new Quaternion(0,0,0,0)) as GameObject;
See how after x*chunkSize I have -0.5f? That's the offset, -0.5 to x, +0.5 to y and -0.5 to z.
Now we'll change that chunks array, change it in the variable definition first:
public Chunk[,,] chunks; //Changed from public GameObject[,,] chunks;
Next change the line where we define the size of the array:
chunks=new Chunk[Mathf.FloorToInt(worldX/chunkSize),
Mathf.FloorToInt(worldY/chunkSize),Mathf.FloorToInt(worldZ/chunkSize)];
Just change the chunks= new GameObject... to chunks=new Chunk...
Now all the stuff in those for loops below is wrong so we need to switch some stuff around:
//Create a temporary Gameobject for the new chunk instead of using chunks[x,y,z]
GameObject newChunk= Instantiate(chunk,new Vector3(x*chunkSize-0.5f,
y*chunkSize+0.5f,z*chunkSize-0.5f),new Quaternion(0,0,0,0)) as GameObject;
//Now instead of using a temporary variable for the script assign it
//to chunks[x,y,z] and use it instead of the old \"newChunkScript\"
chunks[x,y,z]= newChunk.GetComponent(\"Chunk\") as Chunk;
chunks[x,y,z].worldGO=gameObject;
chunks[x,y,z].chunkSize=chunkSize;
chunks[x,y,z].chunkX=x*chunkSize;
chunks[x,y,z].chunkY=y*chunkSize;
chunks[x,y,z].chunkZ=z*chunkSize;
Ok great! Sorry about that but now it's done. Now we can get started, create a new script called "ModifyTerrain.cs" and open it. This script is going to have a collection of functions for adding and removing blocks. We'll set it up by creating all the functions first and then writing what they do after.
public void ReplaceBlockCenter(float range, byte block){
//Replaces the block directly in front of the player
}
public void AddBlockCenter(float range, byte block){
//Adds the block specified directly in front of the player
}
public void ReplaceBlockCursor(byte block){
//Replaces the block specified where the mouse cursor is pointing
}
public void AddBlockCursor( byte block){
//Adds the block specified where the mouse cursor is pointing
}
public void ReplaceBlockAt(RaycastHit hit, byte block) {
//removes a block at these impact coordinates, you can raycast against the terrain and call this with the hit.point
}
public void AddBlockAt(RaycastHit hit, byte block) {
//adds the specified block at these impact coordinates, you can raycast against the terrain and call this with the hit.point
}
public void SetBlockAt(Vector3 position, byte block) {
//sets the specified block at these coordinates
}
public void SetBlockAt(int x, int y, int z, byte block) {
//adds the specified block at these coordinates
}
public void UpdateChunkAt(int x, int y, int z){
//Updates the chunk containing this block
}
That's a lot of functions but the way this is going to work is that if the player calls one of the top functions like ReplaceBlockCursor(block) it runs and then calls ReplaceBlockAt(RaycastHit, block) that runs and calls SetBlockAt (vector3, block) which calls SetBlockAt(int,int,int, block) which sets the block and calls UpdateChunkAt. So if you have a reason to you can set a block by its coordinates or you can just send collision data to the script or you can just call a function to remove the block in front of the player.
Now we also need access to some other things so add the following variables:
World world;
GameObject cameraGO;
And we'll assign those in the start function. We can get the world script with gameObject.getComponent because we'll place both scripts on the same game object and we'll get the camera by its tag.
void Start () {
world=gameObject.GetComponent("World") as World;
cameraGO=GameObject.FindGameObjectWithTag("MainCamera");
}
Now we can start with the functions, let's start with SetBlockAt(int,int,int,block), here we just change the value in the data array in World.cs and run UpdateChunk:
public void SetBlockAt(int x, int y, int z, byte block) {
//adds the specified block at these coordinates
print("Adding: " + x + ", " + y + ", " + z);
world.data[x,y,z]=block;
UpdateChunkAt(x,y,z);
}
Let's get UpdateChunkAt while we're at it, we need to derive which chunk the block is in from its coordinates and run an update on that block. For now we'll just update the block immediately to get it working but this is an inefficient method because often the player will be editing multiple blocks in the same chunk in a single frame and this way we generate the mesh again for each change. Later we'll switch to setting a flag in the chunk and then updating the chunk if the flag is set at the end of the frame.
//To do: add a way to just flag the chunk for update then it update it in lateupdate
public void UpdateChunkAt(int x, int y, int z){
//Updates the chunk containing this block
int updateX= Mathf.FloorToInt( x/world.chunkSize);
int updateY= Mathf.FloorToInt( y/world.chunkSize);
int updateZ= Mathf.FloorToInt( z/world.chunkSize);
print(\"Updating: \" + updateX + \", \" + updateY + \", \" + updateZ);
world.chunks[updateX,updateY, updateZ].GenerateMesh();
}
So what we do is take each axis, divide the value by the chunk size and this gives us a value that used to be between 0 and (world width) as a value between 0 and (number of chunks on this axis) so when we round to the nearest whole number we round to the closed chunk index.
Continuing to work our way up move to SetBlockAt(Vector3, block) This takes a vector3 and finds the nearest block by rounding the float components of the vector3 to ints:
public void SetBlockAt(Vector3 position, byte block) {
//sets the specified block at these coordinates
int x= Mathf.RoundToInt( position.x );
int y= Mathf.RoundToInt( position.y );
int z= Mathf.RoundToInt( position.z );
SetBlockAt(x,y,z,block);
}
Now the RaycastHit functions, these will take a collision and find either the block collided with or the one next to the one collided with for placing blocks where you're looking. Lets start first with ReplaceBlockAt(RaycastHit, block):
public void ReplaceBlockAt(RaycastHit hit, byte block) {
//removes a block at these impact coordinates, you can raycast against the terrain and call this with the hit.point
Vector3 position = hit.point;
position+=(hit.normal*-0.5f);
SetBlockAt(position, block);
}
This takes the impact coordinates of a raycast and finds the block it hit by moving the point inwards into the block so that when we round it it's within the cube and is rounded to its coordinates. hit.normal is the outwards direction of the surface it hit so the reverse of that in the direction into the cube. Therefore we take the hit position and add to it the half the reverse normal (Half so that it doesn't come out the other end of the block). This places the point within the bounds of the cube so we send it off to SetBlockAt(Vector3, block).
The other RaycastHit function, AddBlockAt(RaycastHit,block) is very similar only that it doesn't invert the normal because it places a block at the block next to the block hit so we move the impact position outwards from the surface hit and run SetBlockAt(Vector3, block):
public void AddBlockAt(RaycastHit hit, byte block) {
//adds the specified block at these impact coordinates, you can raycast against the terrain and call this with the hit.point
Vector3 position = hit.point;
position+=(hit.normal*0.5f);
SetBlockAt(position,block);
}
Now that we have ways to handle raycast information we should carry out some raycasts, start with the cursor functions. These are only different in what function they call after they're done and they're pretty standard raycast from mouse position functions:
public void ReplaceBlockCursor(byte block){
//Replaces the block specified where the mouse cursor is pointing
Ray ray = Camera.main.ScreenPointToRay (Input.mousePosition);
RaycastHit hit;
if (Physics.Raycast (ray, out hit)) {
ReplaceBlockAt(hit, block);
Debug.DrawLine(ray.origin,ray.origin+( ray.direction*hit.distance),
Color.green,2);
}
}
public void AddBlockCursor( byte block){
//Adds the block specified where the mouse cursor is pointing
Ray ray = Camera.main.ScreenPointToRay (Input.mousePosition);
RaycastHit hit;
if (Physics.Raycast (ray, out hit)) {
AddBlockAt(hit, block);
Debug.DrawLine(ray.origin,ray.origin+( ray.direction*hit.distance),
Color.green,2);
}
}
We define a ray using the mouse position, then use that to raycast and send the information with the RaycastHit functions.
The last two functions, the center functions are very similar to the last two just that the ray is derived from the camera position and rotation not taking into account the position of the mouse and these functions take a range parameter which stops the function if the terrain is beyond range:
public void ReplaceBlockCenter(float range, byte block){
//Replaces the block directly in front of the player
Ray ray = new Ray(cameraGO.transform.position, cameraGO.transform.forward);
RaycastHit hit;
if (Physics.Raycast (ray, out hit)) {
if(hit.distance<range){
ReplaceBlockAt(hit, block);
}
}
}
public void AddBlockCenter(float range, byte block){
//Adds the block specified directly in front of the player
Ray ray = new Ray(cameraGO.transform.position, cameraGO.transform.forward);
RaycastHit hit;
if (Physics.Raycast (ray, out hit)) {
if(hit.distance<range){
AddBlockAt(hit,block);
}
Debug.DrawLine(ray.origin,ray.origin+( ray.direction*hit.distance),Color.green,2);
}
}
You can test all this by adding this to the update loop:
if(Input.GetMouseButtonDown(0)){
ReplaceBlockCursor(0);
}
if(Input.GetMouseButtonDown(1)){
AddBlockCursor(1);
}
You should be able to place and remove blocks but you might notice that sometimes it seems to glitch and you can see through the terrain after a block is removed. What's happening here is that after you remove a block and update the chunk it updates fine but if the block is on the border with another chunk then that chunk still won't update meaning the side of the now exposed block in the neighbor chunk won't get drawn.
To fix this we need to get back to the chunk update script which we were going to change a bit anyway to make more efficient. Lets start with the update method, we'll make some changes in Chunk.cs, first add a bool and call it update. Then create a new function called LateUpdate, this is a unity function called after all the other update functions, here we'll update the chunk if update is true:
public bool update;
void LateUpdate () {
if(update){
GenerateMesh();
update=false;
}
}
Now instead of calling the GenerateTerrain function in UpdateChunkAt in the ModifyTerrain.cs script just set update to true:
world.chunks[updateX,updateY, updateZ].update=true;
Now on to making neighbor blocks update when necessary, this is only needed when the block changed is on the edge of its chunk so only if x, y or z is 0 or 15 relative to its chunk. Based on the coordinates of the block we can find if its on the edge and also which edge like this:
if(x-(world.chunkSize*updateX)==0 && updateX!=0){
world.chunks[updateX-1,updateY, updateZ].update=true;
}
if(x-(world.chunkSize*updateX)==15 && updateX!=world.chunks.GetLength(0)-1){
world.chunks[updateX+1,updateY, updateZ].update=true;
}
if(y-(world.chunkSize*updateY)==0 && updateY!=0){
world.chunks[updateX,updateY-1, updateZ].update=true;
}
if(y-(world.chunkSize*updateY)==15 && updateY!=world.chunks.GetLength(1)-1){
world.chunks[updateX,updateY+1, updateZ].update=true;
}
if(z-(world.chunkSize*updateZ)==0 && updateZ!=0){
world.chunks[updateX,updateY, updateZ-1].update=true;
}
if(z-(world.chunkSize*updateZ)==15 && updateZ!=world.chunks.GetLength(2)-1){
world.chunks[updateX,updateY, updateZ+1].update=true;
}
This should keep all the neighbors updated if they need to be, it finds the x, y or z of the block relative to the chunk by subtracting the chunk's coordinates (world.chunkSize*updateX where updateX is how many chunks along on the x axis this chunk is) and then if the relative coordinate is 0 it updates the block further down on that axis, if it's 15 it updates the one further up. It also checks to make sure that there is a chunk in that direction in case it's the edge of the level.
It should now work as intended to left click and right click to remove and place blocks. Also at this point it's probably a good idea to add a directional light to the scene. I hope you guys come up with some cool uses for this!
|
Student Game Dev. Unfortunately I am art impaired. |
Edit:
|
With fog enabled in render settings and shadows enabled on your directional lights it can look pretty cool. |
Feel free to follow me on
twitter or
g+ and as always of you have a problem please let me know and I'll do my best to fix it.
Part 8: Loading Chunks