티스토리 뷰


 

위 영화는 애플티비 플러스에서 볼 수 있다.

본래 SF로 제작 된다는 얘기가 있었지만,

결국 실존 인물들의 이야기를 각색한 영화가 되었다.

개인의 자유와 개성, 가치가 무시 받는 공산주의 나라에서

전 세계를 매료시킨 역사상 가장 전설적인 게임이 탄생했다.

그 이름하여 '테트리스'.

TIME지 선정 50대 인기 게임 1위에 빛나는 이 게임에 대한 이야기는

게임 개발자에게 큰 감명을 주기에 충분하다고 느꼈다.

 

 

5개의 사각형으로 조합된 도형을 사용하는

전통 퍼즐게임 펜토미노(Pentomino)를 

4개의 사각형으로 조합된 도형(Tetromino)을 사용하도록 바꾸고

4개를 뜻하는 Tetro~에

개발자 '알렉세이 레오니도비치 파지노프'가 좋아하던 테니스를 어미에 붙인 이름이다.

 

첫 과제로 이 테트리스를 만들어 보기로 했다.


 

 

 

1. 어떻게 만들지?

구현 방법에 대해 고심해보았다.

 

 

"내려오는 블록은 Rigid Body와 Gravity를 사용하면 되나?"
"꽉 찬 줄은 어떻게 검출하지? Ray를 사용하나? Collider의 Trigger를 사용하나?"

 

 

아마 이 두 고민이 게임의 핵심이기에 제일 중요한 과제라 생각했다.

처음 유니티를 독학하던 때,

골머리를 앓으며 내 방식대로 만들다

엉망진창인 코드를 짰던 일을 반성하고

이번엔 많은 블로그를 참고하기로 했다.

 

그 중에 아래 주소의 글이 큰 도움이 되었다.

https://blog.naver.com/fightingdog/221630005805

 

테트리스 #1 - 유니티로 테트리스 만들기

가장 유명한 퍼즐게임이죠! https://youtu.be/hGQsHsxvOwQ 이번 강좌는 약간 중급 코스로 진행할 예정...

blog.naver.com

 

 

4부작으로 된 튜토리얼이라 금방 만들 수 있었다.

 


2. 핵심 코드

Matrix라는 이름은 어디서 들어는 봤는데

막상 써보려니 복잡해서 포기했던 일이 있었다.

이번에 테트리스를 만들면서

각 블록들을 행과 열로 정리하여

2차원 배열로 다루게 되었다.

 

하지만 array[ ][ ] 형식을 쓰진 않는다.

Hierarchy 를 사용해 유사한 구조를 만들었다.

 

아래 코드로 세로열을 먼저 만든다.

Transform boardNode; //생성한 블록들을 관리할 부모객체
int height; //블록을 쌓을 세로열 높이 (웬만해선 테트리스 게임들이 세로 20칸을 고수)

//반복문을 사용해서 같은 객체를 여러개 만든다.
for (int i = 0; i < height; ++i)
{
	//new GameObject()를 할당하는 것으로 씬에 Empty 오브젝트를 만든다.
    //생성한 객체 이름을 세로열 순서로 정한다. 
    var col = new GameObject(i.ToString());
    
    //생성한 객체를 블록을 세로열 높이로 바꾼다. 
    col.transform.position = new Vector3(0, i, 0);
    
    //부모 객체를 최종 관리 객체로 설정
    col.transform.parent = boardNode;
}

 

 

이 다음, 게임 중에 블록이 자리에 위치하면 해당하는 세로열의 자식 객체로 설정한다.

Transform root; //블록의 이동을 관리할 빈 객체.

//조작을 마친 블록을 게임 보드에 추가하여 고정
void AddToBoard(Transform root)
{
	//이동을 관리할 객체의 자식 객체가 하나도 없을 때까지
	while (root.childCount > 0)
    {
    	//그 자식 객체들의 부모객체를 각 위치의 세로열 객체로 바꾼다.
		var node = root.GetChild(0);
		node.transform.SetParent(null);
		
        //이때 각 블록 위치의 값은 float 값이기 때문에 int 값으로 바꿔야 혹시나의 오류가 없다.
		int x = Mathf.RoundToInt(node.transform.position.x);
	    int y = Mathf.RoundToInt(node.transform.position.y);
		
        //확실하게 정수로 바꾼 위치 값을 블록의 이름과 위치값으로 재설정한다.
        node.SetParent(boardNode.Find(y.ToString()));
        node.name = x.ToString();
	    node.transform.position  = new Vector3(x, y, 0);    
    }
}

 

 

 

 

그러면 씬의 하이어라키 구조가 위 사진과 같이 만들어진다.

이제 각 블록에 접근하는 방법은

"Board 객체 하위의 1번 세로열 객체의 자식 객체인 4번 객체" 순이다.

 

이것을 n번째 자식 객체를 반환하는 GetChild( ) 함수와

해당이름을 가진 자식 객체를 반환하는 Find( ) 함수를 사용하면 아래의 코드와 같다.

boardNode.GetChild(세로 y번째 줄).Find(가로 x칸의 블록)이다.

 

코딩을 하는 내내 자주 접한 함수이다.

 


3. 세부 구현

테트리스는 7개의 도형을 사용하는 게임이다.

무작위로 7개의 도형을 생성하기 위해선

범위 내 랜덤한 수를 반환하는 Random.range(x,y) 내장 함수를 사용한다.

짚고 넘어가야 할 점은,

매개 변수값으로 Int형과 Float형을 사용할 때 차이가 있다는 것이다.

Int형 x, y의 경우엔 x이상 y미만의 수 중에 하나를 반환한다는 것이고,

Float형 x, y의 경우에는 x이상 y이하의 수 하나를 반환한다는 것이다.

 

 

테트리스의 블록 모양과 색은 공식으로 규정되어 있다.

각 모양과 색을 각 배열로 만들고,

무작위로 얻은 수에 대응하는 블록을 생성하도록 만들었다.

    Color[] colors = new Color[]{
            new Color32(115, 251, 253, 255),
            new Color32(0, 33, 245, 255),
            new Color32(243, 168, 59, 255),
            new Color32(255, 253, 84, 255),
            new Color32(117, 250, 76, 255),
            new Color32(155, 47, 246, 255),
            new Color32(235, 51, 35, 255)
        };

	//이차원 배열을 사용하여 n번 도형의 각 블록 위치를 준비하였다.
    Vector2[,] tetrominos = new Vector2[,] {
            {new Vector2(-2f, 0.0f),new Vector2(-1f, 0.0f),new Vector2(0f, 0.0f), new Vector2(1f, 0.0f)},
            {new Vector2(-1f, 0.0f),new Vector2(0f, 0.0f),new Vector2(1f, 0.0f),new Vector2(-1f, 1.0f)},
            {new Vector2(-1f, 0.0f),new Vector2(0f, 0.0f),new Vector2(1f, 0.0f), new Vector2(1f, 1.0f)},
            {new Vector2(0f, 0f), new Vector2(1f, 0f), new Vector2(0f, 1f), new Vector2(1f, 1f) },
            {new Vector2(-1f, -1f),new Vector2(0f, -1f),new Vector2(0f, 0f),new Vector2(1f, 0f)},
            {new Vector2(-1f, 0f),new Vector2(0f, 0f),new Vector2(1f, 0f),new Vector2(0f, 1f)},
            {new Vector2(-1f, 1f),new Vector2(0f, 1f),new Vector2(0f, 0f),new Vector2(1f, 0f)}
        };

	Transform tetrominoNode; //생성할 블록의 부모객체로 하여 블록 대신 이 객체를 대신 이동시킨다.
	int nextTetrominoNum; //다음에 생성할 블록 번호

	Transform tilePrefab; //프리팹화 시켜둔 하나의 블록 오브젝트

    void CreateTetromino()
    {
        int index = nextTetrominoNum; //지금 생성하는데 쓸 번호를 index 변수에 대입
        nextTetrominoNum = Random.Range(0, 7); //다음에 생성할 블록 번호를 미리 대입해둠
		
        //이동을 마친 블록 부모객체의 위치와 회전 값을 초기화한다.
        tetrominoNode.rotation = Quaternion.identity;
        tetrominoNode.position = new Vector2(5, height + 4);

		//테트리스의 도형은 전부 4개의 블록을 사용했다.
        //따라서 블록을 따로따로 생성하는 이 방식에선 반복문을 4번만 반복하면 된다.
        for (int i = 0; i < 4; i++)
        {
        	// 블록 객체를 생성하고
        	var go = Instantiate(tilePrefab);
            
            // Color 배열에서 생성하기로한 블록과 같은 번호(index)의 색상 값을 사용
            go.GetComponent<SpriteRenderer>().Color = colors[index];
            
            // tetrominos 배열에서 생성하기로한 블록 번호와 같은 행으로부터
            // i번째 위치 값을 가져온 후, 생성한 블록의 위치를 정한다.
            go.transform.localPosition = tetrominos[index][i];
            
            //다음 차례의 도형은 전부 미리 씬에 준비하고
            //비활성과 활성화를 번갈아가며 사용한다.
            nextTetrominoNode.GetChild(index).gameObject.SetActive(false);
            nextTetrominoNode.GetChild(nextTetrominoNum).gameObject.SetActive(true);
        }

    }

 

비록 실제 블록을 관리하는데 사용한 건 Hierarchy 구조의 응용이었지만,

도형을 생성할 때 블록의 위치를 정하는 부분에 이차원 배열을 사용할 수 있었다.

헷깔리면 안되는 부분은 Array[세로열][가로행] 순으로 표기한다는 것이다.

 

 

이 외에도 간략히 설명하자면,

const int width = 10; // 일반적인 테트리스는 가로 10칸을 기본으로 한다.
public Transform boardNode; // 모든 블록을 하위에 두어 관리하는 최상위 객체
public ParticleSystem clearLine // 블록이 사라질 때 사용할 파티클 객체이다.

foreach (Transform column in boardNode) // foreach문을 사용하여 모든 줄을 반복하여 검사
{
    if (column.childCount == width) // 세로열의 자식객체가 10개라면 그 줄은 꽉찬 것이다.
    {
        foreach (Transform tile in column) //반복문을 사용하여 세로열 자식 객체를 하나씩 제거
        {
        	//블록 제거와 동시에 블록과 같은 색의 파티클을 생성한다.
            ParticleSystem ptcl = Instantiate(clearLine, tile.position,Quaternion.identity);
            Color c = tile.GetComponent<SpriteRenderer>().color;
            
            //파티클 시스템에 접근하여 색을 바꾸기 위해선 MainModule에 한 번 더 접근해야 했다.
            //ParticleSystem.startColor처럼 바로 색을 바꾸려고 하면 경고 log가 출력됐다.
            ParticleSystem.MainModule main = ptcl.main;
            main.startColor = colors[t];
            
            Destroy(tile.gameObject); //전부 삭제
        }
        
        //보통 객체를 삭제하면 종속관계도 사라지지만,
        //참고한 블로그 글에서는 혹시 모를 오류를 방지하고자
        //모든 자식 객체를 해제하는 코드를 추가하였다.
        column.DetachChildren();
    }
}

 

한 줄을 빈틈없이 채운 경우는

세로열의 자식 객체 수를 확인하는 방식으로 구현했다.

if (column.childCount == width)

 

파티클 시스템의 값을 바꾸기 위해서 메인모듈에 한 번 더 접근해야하는 점도 인상깊었다.

 


4. 메모

예전의 나는 간단한 게임을 만들 때,

처음부터 끝까지 통채로 코루틴 하나에 집어 넣어 만들곤 했다.

yield return new WaitForSeconds(Time.deltaTime);

 

While문과 위 코드를 이용해서 자유자재로 게임 진행의 기점과 연출 타이밍을 잡을 수 있어서였다.

그땐 뭣도 모르고 코루틴이 무조건 좋은 줄만 알고 썼던 것 뿐이었는데...

 

하지만 참고한 글에서는 전부 Update문에서 처리하도록 짜여있었다!

 void Update()
 {
 	//ESC를 누르면 게임 종료
     if (Input.GetKeyDown(KeyCode.Escape))
         Application.Quit();
         
	//엔터를 누르면 게임을 일시정지, 해제 할 수 있다. isPause는 Bool형 변수
     if (!gameoverPanel.activeSelf&&Input.GetKeyDown(KeyCode.Return))
     {
         if (isPause)
         {
             isPause = !isPause;
             PauseUI.SetActive(false);
         }
         else {
             isPause = !isPause;
             PauseUI.SetActive(true);
         }
     }
     
     // 일시정지를 하면 isPause 값이 true가 되고 이 구문에서 Update가 끝나서
     // 아래의 게임 운용 구문이 작동하지 못해 게임이 정지된다.
     if (isPause) return;

	//gameoverPanel은 UI 객체이다.
    //이 객체는 게임오버 조건으로 활성화 되게 된다.
         if (gameoverPanel.activeSelf)
     {
         if (Input.GetKeyDown(KeyCode.Return))
             UnityEngine.SceneManagement.SceneManager.LoadScene(0);
     }
     
     //gameoverPanel 객체가 활성화 된 것으로 아래의 구문이 역시 무시된다.
     else
     {
     
     	//여기서부터는 실제 게임을 운용하는데 쓰이는 구문들이다.
     
     	//매 프레임마다 블록의 움직임을 초기화하고
         Vector3 moveDir = Vector3.zero;
         bool isRotate = false;

         if (Input.GetKey(KeyCode.LeftArrow)) //왼쪽 방향키를 누르면 왼쪽으로 1칸
         {
             if (Time.time > nextinputtime )
             {
                 nextinputtime = Time.time + inputCycle;
                 moveDir.x = -1;
             }
         }
         else if (Input.GetKey(KeyCode.RightArrow)) //오른쪽 방향키를 누르면 오른쪽으로 1칸
         {
             if (Time.time > nextinputtime)
             {
                 nextinputtime = Time.time + inputCycle;
                 moveDir.x = 1;
             }
         }

         if (Input.GetKeyDown(KeyCode.UpArrow)) //위쪽 방향키를 누르면 회전
         {
             if (tetrominoNode.GetChild(0).GetComponent<Tile>().color != colors[3])
                 isRotate = true;
         }
         else if (Input.GetKey(KeyCode.DownArrow)) //아래쪽 방향키를 누르면 내려가는 속도 +1(아래로 한칸)
         {
             if (Time.time > nextinputtime)
             {
                 nextinputtime = Time.time + inputCycle;
                 moveDir.y = -1;
             }
         }
		
         if (Input.GetKeyDown(KeyCode.Space)) // 아래로 빠르게 내리기
         {
             while (MoveTetromino(Vector3.down, false))
             {
             }
             StartCoroutine(Camera.main.GetComponent<CamCtrl>().CamShake()); // 카메라 쉐이크
         }
		
        // 특정 시간마다 블록은 아래로 내려간다.
        // 이 if문 다음 if문의 MoveTetromino() 함수가 실질적으로 블록을 이동시키는 코드이다.
        // 하지만 이 부분이 먼저 실행된다는 것은,
        // 특정 시간마다 블록이 이동한다는 규칙은
        // 플레이어의 조작을 덮어 씌운다...!
         if (Time.time > nextFalltime)
         {
             nextFalltime = Time.time + fallCycle;
             moveDir = Vector3.down;
             isRotate = false;
         }
         
		//moveDir != Vector3.zero 이라면 추가 조작이 있거나 블록이 아래로 이동할 시간이라는 뜻
       	//isRotate == true 도 마찬가지.
         if (moveDir != Vector3.zero || isRotate)
         { 
             MoveTetromino(moveDir, isRotate); //이 함수를 통해 블록을 이동
         }
         
         //블록이 착지할 위치를 표시하는 보조기능을 추가해보았다.
         //게임오버되지 않은 동안에만 기능하도록 만든 부분이다.
		 //특별히 블록의 이동을 마친 후 동작하여 후보정하도록 한다.
         if (!gameoverPanel.activeSelf)
             GuidePos();
     }
 }

 

참고한 글에선 일시정지 기능은 없었지만 따로 구현해보았다.

이 부분이 내가 간과하고 있던 부분이었는데,

return을 사용하면 그 부분에서 함수가 종료된다는 것이었다.

 

if (isPause) return;

 

이것으로 Update 구문을

원하는 부분에서 끊고 계속할 수 있다는 것을 깨달았다...

정말 제대로 배우지 않고 멋대로 독학을 했더니

이런 기본적인 구현 방식을 모르고 있어서 너무 손해 본 기분이다...

창피할 따름이다...ㅜ

 

 

 

 

 


 

완성한 프로젝트의 빌드 파일은 아래의 주소로 다운 받을 수 있다.

https://drive.google.com/file/d/1t5FOGoG1VyIfyVahS-72cpJsiSGPw8uY/view?usp=drive_link

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/11   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
글 보관함