4. 블록 생성 및 제거

2021. 6. 22. 09:14[Unity] Game Programing/유니티를 활용한 마인크래프트 청크 시스템 구현하기

이 글을 읽이에 앞서 이전글에 대해서 이해를 하지 못하셨다면 이전 글을 읽는 것을 추천 드립니다.


* 이동 스크립트 만들기

블럭을 생성/삭제 하는 기능을 만들기에 앞서 불편하지 않게 먼저 플레이어 이동 스크립트를 간단하게 만들어 주세요.

 

- Player 스크립트 -

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

public class Player : MonoBehaviour
{
    public float speed = 3;

    void Start()
    {
        // 마우스 위치를 고정
        Cursor.lockState = CursorLockMode.Locked;
    }

    void Update()
    {
        PlayerMovement();
        MouseMovement();
    }

    // 이동과 관련된 함수
    void PlayerMovement()
    {
        Vector3 lr = gameObject.transform.right * Input.GetAxis("Horizontal");
        Vector3 fb = gameObject.transform.forward * Input.GetAxis("Vertical");
        Vector3 ud = Vector3.up * (Input.GetAxis("Jump") - Input.GetAxis("Fire3"));
        gameObject.transform.position += (lr + fb + ud) * speed * Time.deltaTime;
    }

    // 마우스 움직임과 관련된 함수
    void MouseMovement()
    {
        gameObject.transform.Rotate(0, Input.GetAxis("Mouse X"), 0);
        Camera.main.transform.Rotate(-Input.GetAxis("Mouse Y"), 0, 0);
    }
}

 

1. GetBlockInfo

블럭을 생성/삭제 하는 기능을 만들기 위해서는 우리가 어떤 블럭을 선택했는지 알아야 합니다.

 

그러기 위해서 1차적으로 Ray쏴야 하고, 2차적으로는 Ray의 충돌정보를 이용해야 합니다.

하지만, 우리는 Ray의 충돌정보를 단순히 이용할 수 없습니다. 왜 그럴까요?

 

바로, Chunk라는 단위로 묶인 Block들이 하나의 오브젝트로 표현이 되어있기 때문입니다. 그렇기 때문에 hit.transform 같은 형식으로는 블럭들의 위치 정보를 가져올 수 없습니다.

 

이 문제를 해결하기 위해서는 hit.point를 이용하여 충돌이 일어난 위치정보를 기반으로 블럭의 정보를 새로 계산해야 합니다.

 

hit.point로 얻은 정보는 world좌표로 표현 됩니다. 우선 이 좌표를 충돌한 Chunk의 local좌표에 맞게끔 변경해 줘야 합니다.

 

간단하게 생각한다면 ( hit.point - hit.transform.position ) 으로 local좌표를 구할 수 있겠네요.

 

하지만, 저는 단순하게 Vector3의 값 1개만을 넘겨 받아서 블럭의 정보를 구하는 함수를 만들고 싶습니다.

 

음... 그렇다면 Chunk와 Block의 규칙을 잘 활용해야 합니다.

 

1. Chunk간의 거리는 chunkSize만큼 떨어져 있다.

2. Block의 위치는 부모오브젝트(Chunk)가 기준인 local좌표 이다.

 

이 2개를 잘 이용한다면, Vector3의 x,y,z의 값을 chunkSize로 나눈 후, 정수 부분만 chunkSize를 곱하면 Chunk의 위치를 구할 수 있습니다.  ( (Mathf.RoundToInt(Vector3.x) / chunkSize) * chunkSize )

 

하지만, Vector3의 x,y,z중에 음수(-)가 있다면 단순히 chunkSize로 나눠서 해결할 수 없습니다.

 

그 이유는 바로 Chunk의 Pivot이 Vector3(-1,-1,-1)에 있기 때문입니다. ( Chunk의 정 가운데 위치가 Vector3(0,0,0)이라는 가정하에 )

Chunk의 Pivot위치

이 문제를 해결 하기 위해서는 음수에 맞는 계산방식으로 구해야 합니다.

 

저같은 경우는 ( ((Vector3.x - chunkSize + 1) / chunkSize) * chunkSize ) 이렇게 구현 했습니다.

 

음... 해당 위치의 Chunk가 어떤건지 찾았습니다.

 

이제 각 좌표별로 계산한 값을 Vector3 형태로 만들어서 BuildChunkName() 함수를 이용하여 string형으로 변환후 Dictionary인 chunks에 접근을 해서 실제 Chunk를 구하면 됩니다.

 

string Cn = BuildChunkName(new Vector3(Cx, Cy, Cz));

Chunk c;

chunks.TryGetValue(Cn, out c)

 

그렇다면 Block은 어떻게 찾아야 할까요?

 

Block은 의외로 간단합니다! Vector3는 float이 3개로 이루어 졌다고 할 수 있습니다.

 

하지만, Block의 위치를 찾으려면 Chunk의 chunkDate의 배열을 통해서 찾아야 합니다.

 

이 chunkDate의 배열의 위치가 Block의 위치와 일맥상통 한다고 할 수 있는데, 그러려면 우선 float의 값을 int형으로 변환 시켜서 배열에 접근해야 합니다.

 

그렇지만, 단순히 int형으로 형변환을 시키면 내가 원하고자 하는 블럭의 정확한 값을 얻을 수 없을 것입니다.

 

그 이유는 블럭의 정 가운대를 기준으로 각 좌표당 (-0.5 ~ 0.5)로 표현되기 때문입니다.

 

그렇기 때문에 Mathf.Round() 함수를 사용하여 float의 값을 반올림을 해야합니다.

 

이제 최종적으로 반올림한 값에 Chunk의 위치값을 빼서 World 좌표에서 Local 좌표로 변경후 절대값을 이용하여 음수가 없도로 만들어야 합니다.

 

그렇다면 이제 이 방식을 이용해서 Chunk를 찾는 함수를 만들어 보겠습니다.

 

- World 스크립트 안에 있는 GetBlockInfo() 함수 -

    // 위치정보를 기반으로 정확한 블럭 찾기
    public static Block GetBlockInfo(Vector3 pos)
    {
        int Cx, Cy, Cz;

        #region X에 관한것

        // x좌표가 0보다 작을 경우
        if (pos.x < 0)
        {
            Cx = ((Mathf.RoundToInt(pos.x - chunkSize) + 1) / chunkSize) * chunkSize;
        }
        else
        {
            Cx = (Mathf.RoundToInt(pos.x) / chunkSize) * chunkSize;
        }

        #endregion

        #region Y에 관한것

        // y좌표가 0보다 작을 경우
        if (pos.y < 0)
        {
            Cy = ((Mathf.RoundToInt(pos.y - chunkSize) + 1) / chunkSize) * chunkSize;
        }
        else
        {
            Cy = (Mathf.RoundToInt(pos.y) / chunkSize) * chunkSize;
        }

        #endregion

        #region Z에 관한것

        // z좌표가 0보다 작을 경우
        if (pos.z < 0)
        {
            Cz = ((Mathf.RoundToInt(pos.z - chunkSize) + 1) / chunkSize) * chunkSize;
        }
        else
        {
            Cz = (Mathf.RoundToInt(pos.z) / chunkSize) * chunkSize;
        }

        #endregion

        // 해당 좌표를 반올림 하여 정확한 블럭의 좌표를 구함
        int Block_x = Mathf.Abs(Mathf.RoundToInt(pos.x) - Cx);
        int Block_y = Mathf.Abs(Mathf.RoundToInt(pos.y) - Cy);
        int Block_z = Mathf.Abs(Mathf.RoundToInt(pos.z) - Cz);

        string Cn = BuildChunkName(new Vector3(Cx, Cy, Cz));

        Chunk c;

        // 해당 청크가 존재 한다면
        if (chunks.TryGetValue(Cn, out c))
        {
            // 해당 청크에 존재하는 로컬 좌표의 블럭정보를 반환
            return c.chunkData[Block_x, Block_y, Block_z];
        }
        else
        {
            // 새로운 청크를 만들고 해당 좌표의 블럭정보를 반환
            c = new Chunk(new Vector3(Cx, Cy, Cz));
            c.chunk.transform.parent = ChunksObject.transform;
            chunks.Add(c.chunk.name, c);
            return c.chunkData[Block_x, Block_y, Block_z];
        }
    }

 

2. BlockCreate 스크립트 만들기

이제 본격적으로 Block을 생성/삭제 하는 스크립트를 만들어 봅시다.

 

플레이어가 어떤 블럭을 선택했는지 알기 위해 Ray쏴서 확인 해야 합니다.

 

그 후에 미리 만들어둔 World.GetBlockInfo() 함수를 사용하면 선택한 블럭의 정보를 구할 수 있습니다만...

 

여기에서 문제가 발생합니다. GetBlockInfo() 함수는 각 좌표를 반올림을 하기 때문에, 만약 블럭의 위치가 Vector3(1, 1, 1)일때 해당 블럭의 Front에 해당하는 면에 충돌이 일어났다면 z좌표가 1.5 로 고정이 됩니다.

 

이렇게 되었을 경우 반올림이 되어서 Vector3(1, 1, 2) 에 해당하는 블럭의 정보를 가져오게 됩니다.

내가 원하는 블럭의 정보는 Vector3(1, 1, 1) 인데도 말이죠...

 

그렇기 때문에 GetBlockInfo() 함수를 사용하기 전에 충돌이 일어난 면의 법선벡터(Normal)를 참조하여 해당 좌표의 값을 미리 할당해야 합니다. ( Vector3(0.xxx, 0.xxx, 1) 이런 식으로 만들어야 합니다. )

 

이 Normal 값을 이용하여 충돌한 면이 어느쪽인지 쉽게 알 수 있습니다.

 

지금까지 잘 따라 오셨다면, 여기서 한가지 의문이 듭니다.

 

hit.normal과 mesh.normals의 차이점은 무엇일까요?

 

hit.normal은 말 그대로 충돌한 위치에 해당하는 면의 수직인 법선벡터를 의미하는 것이기 때문에 Collider의 모양에 따라 값이 달라집니다.

 

mesh.normals의 normal은 라이트 벡터와의 각도에 따라서 빛의 밝기를 정하게 하는 역할을 하고, 노멀 벡터는 버텍스에 있다는 차이점이 있습니다.

 

이제 마지막으로 블럭의 정보를 수정하고, 다시 그리는(ReDraw) 함수를 만들어야 합니다.

 

우선 Chunk 스크립트에 ReDraw() 함수를 만들어 줍니다.

 

- Chunk 스크립트 안에 있는 ReDraw() 함수 -

    // 새로고침 하는 함수
    public void ReDraw()
    {
        DrawChunk();
    }

그리고 나서 Block 스크립트에서 BuildBlock() 함수를 만들어 주기만 하면 됩니다.

 

- Block 스크립트 안에 있는 BuildBlock() 함수 -

    // 블럭을 변경하는 함수
    public void BuildBlock(BlockType b)
    {
        bType = b;
        if (b != BlockType.AIR) isSolid = true;
        else isSolid = false;
        owner.ReDraw();
    }

 

이렇게 필요한 모든 준비가 다 끝났으니 BlockCreate 스크립트를 만들어 봅시다.

 

- BlockCreate 스크립트 -

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

public class BlockCreate : MonoBehaviour
{

    void Update()
    {
        Build();
    }
    
    void Build()
    {
        // 카메라의 정 중앙을 RayPoint로 설정
        Ray ray = Camera.main.ScreenPointToRay(new Vector2(Screen.width / 2, Screen.height / 2));

        RaycastHit hit;

        // Ray 충돌이 일어났을 경우
        if (Physics.Raycast(ray, out hit, 5))
        {
            Chunk c;

            Block b;

            // 충돌한 오브젝트가 Dictionary에 없을 경우
            if (!World.chunks.TryGetValue(hit.collider.name, out c)) return;

            Vector3 point;

            // 좌클릭을 했을때의 블럭 위치
            point = hit.point - (hit.normal * 0.5f);

            // 우클릭을 했을때의 블럭 위치
            if (Input.GetMouseButtonDown(1)) point = hit.point + (hit.normal * 0.5f);

            // 블럭 정보 가져오기
            b = World.GetBlockInfo(point);

            c = b.owner;
            
            // 좌클릭을 했을 경우
            if (Input.GetMouseButtonDown(0))
            {
                // 해당 블럭의 종류를 변경
                b.BuildBlock(Block.BlockType.AIR);
            } // 우클릭을 했을 경우
            else if (Input.GetMouseButtonDown(1))
            {
                // 해당 블럭의 종류를 병경
                b.BuildBlock(Block.BlockType.DIRT);
            }
        }
    }
}

 

 

 

3. ReDraw가 필요한 이웃 청크 찾기

우리는 간단하게 블럭을 생성/삭제 하는 기능을 만들었습니다.

하지만, 아직 다음과 같은 작은 문제들이 남아 있습니다.

이과 같은 문제의 공통점은 다른 청크와 인접해 있는 블럭에만 일어난다는 것입니다.

 

좀더 정리해 보자면, 해당 청크에(Chunk1) 블럭이 설치는 되었지만 그 블럭과 맞닿아 있는 다른 청크(Chunk2)는 새로고침이 안되어 있다는 것과 같습니다.

 

이 문제를 해결하려면, 각 좌표의 끝에 해당하는 위치에 블럭이 설치 되었을 경우, 인접한 Chunk를 찾고 해당하는 모든 Chunk를 새로 고침 해야 합니다.

 

- BlockCreate 스크립트 안에 있는 BlockUpdate() 함수 -

// 새로고침이 필요한 다른 청크가 있는지 확인
    void BlockUpdata(Chunk Hit_c, Block b)
    {
        
        List<string> Updates = new List<string>();
        float ThisChunk_x = Hit_c.chunk.transform.position.x;
        float ThisChunk_y = Hit_c.chunk.transform.position.y;
        float ThisChunk_z = Hit_c.chunk.transform.position.z;

        #region X에 관한것

        // 청크 안에서 X좌표의 0의 블럭을 클릭 했을때.
        if (b.position.x == 0)
            Updates.Add(World.BuildChunkName(new Vector3(ThisChunk_x - World.chunkSize, ThisChunk_y, ThisChunk_z)));

        // 청크 안에서 X좌표의 마지막 인덱스 블럭을 클릭 했을때.
        if (b.position.x == World.chunkSize - 1)
            Updates.Add(World.BuildChunkName(new Vector3(ThisChunk_x + World.chunkSize, ThisChunk_y, ThisChunk_z)));

        #endregion

        #region Y에 관한것

        // 청크 안에서 Y좌표의 0의 블럭을 클릭 했을때.
        if (b.position.y == 0)
            Updates.Add(World.BuildChunkName(new Vector3(ThisChunk_x, ThisChunk_y - World.chunkSize, ThisChunk_z)));

        // 청크 안에서 Y좌표의 마지막 인덱스 블럭을 클릭 했을때.
        if (b.position.y == World.chunkSize - 1)
            Updates.Add(World.BuildChunkName(new Vector3(ThisChunk_x, ThisChunk_y + World.chunkSize, ThisChunk_z)));

        #endregion

        #region Z에 관한것

        // 청크 안에서 Z좌표의 0의 블럭을 클릭 했을때.
        if (b.position.z == 0)
            Updates.Add(World.BuildChunkName(new Vector3(ThisChunk_x, ThisChunk_y, ThisChunk_z - World.chunkSize)));

        // 청크 안에서 Z좌표의 마지막 인덱스 블럭을 클릭 했을때.
        if (b.position.z == World.chunkSize - 1)
            Updates.Add(World.BuildChunkName(new Vector3(ThisChunk_x, ThisChunk_y, ThisChunk_z + World.chunkSize)));

        #endregion


        // 수정해야 되는 블럭 갯수 만큼 반복함.
        for (int i = 0; i < Updates.Count; i++)
        {
            // 수정해야 되는 이웃 청크를 수록.
            string Chunk_n = Updates[i];

            Chunk ck;

            // 수정 해야 되는 청크에 대한것.
            if (World.chunks.TryGetValue(Chunk_n, out ck))
            {
                ck.ReDraw();
            }
        }
        
    }

 

이렇게 새로 고침이 필요한 Chunk가 있는지 확인하는 함수를 만들었습니다.

 

이제 마지막으로 청크는 존재를 하지만, 그 Chunk 안에 블럭이 하나도 없다면 해당 청크를 삭제하는 함수를 만들어 줘야 합니다.

 

만드는 방법은 간단합니다.

 

3중 for문으로 각 좌표를 표현해 주고, 해당 Chunk의 모든 블럭의 종류를 확인하여 AIR이외의 블럭이 있는지 확인만 하면 됩니다.

 

- BlockCreate 스크립트 안에 있는 nullBlockCheck() 함수 -

    // 청크에 블럭이 없는지 확인
    bool nullBlockCheck(Chunk ck)
    {
        for (int x = 0; x < World.chunkSize; x++)
        {
            for (int y = 0; y < World.chunkSize; y++)
            {
                for (int z = 0; z < World.chunkSize; z++)
                {
                    if (ck.chunkData[x, y, z].bType != Block.BlockType.AIR) return false;
                }
            }
        }

        return true;
    }

 

이제 이 모든 함수들을 알맞게 사용할 수 있게 기존의 Build() 함수를 수정해 주겠습니다.

 

- BlockCreate 스크립트 안에 있는 Build() 함수 -

    void Build()
    {
        // 카메라의 정 중앙을 RayPoint로 설정
        Ray ray = Camera.main.ScreenPointToRay(new Vector2(Screen.width / 2, Screen.height / 2));

        RaycastHit hit;

        // Ray 충돌이 일어났을 경우
        if (Physics.Raycast(ray, out hit, 5))
        {
            Chunk c;

            Block b;

            // 충돌한 오브젝트가 Dictionary에 없을 경우
            if (!World.chunks.TryGetValue(hit.collider.name, out c)) return;

            Vector3 point;

            // 좌클릭을 했을때의 블럭 위치
            point = hit.point - (hit.normal * 0.5f);

            // 우클릭을 했을때의 블럭 위치
            if (Input.GetMouseButtonDown(1)) point = hit.point + (hit.normal * 0.5f);

            // 블럭 정보 가져오기
            b = World.GetBlockInfo(point);

            c = b.owner;

            bool update = false;

            // 좌클릭을 했을 경우
            if (Input.GetMouseButtonDown(0))
            {
                update = true;
                // 해당 블럭의 종류를 변경
                b.BuildBlock(Block.BlockType.AIR);

                // 해당 청크에 블럭이 하나도 없을경우
                if(nullBlockCheck(c))
                {
                    // 청크 삭제
                    World.chunks.Remove(c.chunk.name);
                    Destroy(c.chunk);
                }
            } // 우클릭을 했을 경우
            else if (Input.GetMouseButtonDown(1))
            {
                update = true;
                // 해당 블럭의 종류를 병경
                b.BuildBlock(Block.BlockType.DIRT);
            }

            // 청크 새로고침
            if(update)
            {
                BlockUpdata(c, b);
            }
        }
    }

이렇게 코드를 작성 하셨다면 모든 과정이 다 끝났습니다.

 

여기 까지 잘 따라 오셨다면, 지금까지의 코드를 잘 이해했다고 생각합니다.

이제는 직접 원하는 기능을 추가해 나가시면 됩니다!

 

수고하셨습니다!


Block.cs
0.01MB
BlockCreate.cs
0.00MB
Chunk.cs
0.00MB
Player.cs
0.00MB
World.cs
0.00MB