1984년 소련의 프로그래머 알렉세이 파지노프(Alexey Leonidovich Pajitnov)가 만들었고 21년 6월 6일에 37주년을 맞았다. 전통 퍼즐 게임인 '펜토미노(Pentomino)'를 개량하여 만든것. 하지만 5개의 정사각형으로 조합된 도형들을 이용하던 '펜토미노'는 1984년 당시의 기술로는 게임화하기에 다소 복잡해서 4개의 사각형을 조합한 '테트로미노(Tetromino)'를 사용, 개량하면서 이름도 그리스어 접두사 'Tetra~(4개의)'와 개발자가 좋아하던 스포츠인 '테니스(Tennis)'의 끝자리를 따와서 붙였다.
퍼즐게임의 대표주자이자, 세계에서 8번째로 많이 팔린 게임 시리즈. 배우긴 쉽지만 마스터하는건 어려운 게임. 저작권을 관리하는 테트리스 컴퍼니가 2003년 3월 한국에 상륙하여 테트리스의 저작권을 주장하여 국내 테트리스 게임이 대거 서비스가 중단되는 테트리스 대란이 일어났다. 대부분의 회사들은 소송을 감수하고 서비스를 계속할 실익이 없다고 판단하고 서비스를 중단하거나, 돈을 주고 서비스를 계속하는 길을 선택하였다. 아직 한국에는 테트리스 게임을 따와서 배포할때 저작권을 침해하는 지에 대한 판례는 없다. 다만 한국에서도 테트리스 컴퍼니 측이 '테트리스' 명칭에 대한 상표권을 취득했기 때문에 테트리스 컴퍼니가 아닌 곳에서 '테트리스'라는 명칭을 사용해 상업적인 게임을 출시할 수는 없다고 한다. ㅠㅠ
계약을 하더라도 테트리스 컴퍼니의 동의 없이는 게임 룰을 변경할 수 없게 하고 있다. 한게임에서 계약한 서비스를 했었지만 2013년 이용자 수 부족으로 서비스 종료. 모바일 버전은 컴투스가 꾸준히 내다가 라리선스가 EA로 넘어갔으나, 2020년 4월에 서비스가 종료. 현재는 중국 산하 N3TWORK에서 모바일 라이선스를 취득해 스마트폰용 테트리스가 출시되었으며, Primetime(프라임타임)이라는 기간을 정해 테트리스 대호를 개최하고 있는데, 상금까지 걸려있으므로 자신이 고수라고 생각한다면 참가해 볼 만 하다.
테트로미노(Tetromino), 폴리오미노(Polyomino)에 대해
테트로미노(Tetromino)
또는 4-오미노(4-omino)는 4개의 정사각형으로 이루어진 폴리오미노이다. 5가지의 자유테트로미노, 7가지의 단면 테트로미노, 19가지의 고정 테트로미노가 있다. 단면 테트로미노 7가지는 테트리스에서 이용된다.
폴리오미노(Polyomino)
하버드 대학교의 솔로몬 골롬(Solomon W. Golomb) 박사가 수학 강의 중에서 처음 사용한, n개의 정사각형들이 서로 최소한 1개의 변을 공유하여 만들어지는 다각형들을 총칭하며 위 표의 형태를 말한다. n이 몇개인가에 따라 부르는 이름이 달라지는데 두개일 경우 우리가 많이 먹는 도미노피자 이름이 있는걸 알수 있다.
테트로미노 Prefab 제작
2DSprite 1x1사이즈를 만들고 이를 기본으로 4개의 블록을 가지는 7가지 테트로미노 프리팹을 제작한다.
회전을 고려 하여 축을 설정 한다.
그리드 설정
좌측 최하단 을 0,0으로 10x20의 그리드를 생성한다. 포토샾으로 미리 제작해서 가져와도 되지만 여기서는 유니티에서 직접 2DSprite를 사용해 레이아웃을 그렸다.
테트리스 컨트롤 정의 및 구현
기본적으로 각 테트로미노프리팹에 TetrisBlock.cs 스크립이 붙게 되고 아래 설명되는 코드들은 모두 이 스크립트에 들어간다.
1. 움직임 정의
왼쪽, 오른쪽, 아래, 한번에 내리기, 회전을 할 수 있는 기능을 넣을 예정이고 아래 키에 맵핑한다.
2. 유동범위 체크
블록이 움직일 수 있는 유동 가능한 번위를 체크한다.
유동범위체크 스크립트
public static int Height = 20;
public static int Width = 10;
// 블록들을 저장하고 체크하기 위한 10x20 그리드 데이터
private static Transform[,] grid = new Transform[Width, Height];
bool ValidMove()
{
foreach (Transform children in transform) // 4씩 존재함.
{
int roundedX = Mathf.RoundToInt(children.transform.position.x);
int roundedY = Mathf.RoundToInt(children.transform.position.y);
if (roundedX < 0 || roundedX >= Width || roundedY < 0 || roundedY >= Height)
return false; // 그리드 좌표를 벗어나면 false
if (grid[roundedX, roundedY] != null) // 그리드에 블록이 있으면 false
return false;
}
return true;
}
3. 아래로 움직이기
키가 눌리지 않아도 자동으로 밑으로 움직여야 하고 키가 길게 눌리면 빠르게 이동된다.
public float FallTime = 0.8f;
if (Time.time - previousTime > (Input.GetKey(KeyCode.DownArrow) ? FallTime / 14 : FallTime))
{
moveDown();
}
void moveDown()
{
// Down
transform.position += new Vector3(0, -1, 0);
if (!ValidMove())
{
transform.position -= new Vector3(0, -1, 0);
AddToGrid();
checkForLines();
this.enabled = false;
if (!isGameOver)
spawTetromino.NewTetromino();
}
previousTime = Time.time;
}
4. 왼쪽, 오른쪽 이동
if ((Input.GetKey(KeyCode.LeftArrow) && Time.time - previousTimeLeft > (Input.GetKey(KeyCode.LeftArrow) ? FallTime / 8 : FallTime)))
moveLeft();
else if ((Input.GetKey(KeyCode.RightArrow) && Time.time - previousTimeRight > (Input.GetKey(KeyCode.RightArrow) ? FallTime / 8 : FallTime)))
moveRight();
void moveLeft()
{
// Left
transform.position += new Vector3(-1, 0, 0);
if (!ValidMove())
transform.position -= new Vector3(-1, 0, 0);
previousTimeLeft = Time.time;
}
void moveRight()
{
// Right
transform.position += new Vector3(1, 0, 0);
if (!ValidMove())
transform.position -= new Vector3(1, 0, 0);
previousTimeRight = Time.time;
}
5. 회전
if (Input.GetKeyDown(KeyCode.UpArrow))
rotateBlock();
void rotateBlock()
{
transform.RotateAround(transform.TransformPoint(RotationPoint), new Vector3(0, 0, 1), 90);
if (!ValidMove())
transform.RotateAround(transform.TransformPoint(RotationPoint), new Vector3(0, 0, 1), -90);
// 터치커맨드 리셋 연타방지용
touchButtons.MyCommand = Command.None;
}
바닥에 닿을 때 처리 할 것들
기본적으로 10x20의 그리드의 정보를 처리 할 수 있는 Array[,]를 만들어 두고 데이터를 관리한다. 해당 그리드에 블록이 있는지 없는지 체크하고 삭제하거나 입력한다.
1. 그리드에 해당 블록 추가
블록이 내려와 바닥에 닿거나 블록이 있어서 더이상 내려갈 수 없을 때 해당 그리드에 블록을 추가한다.
private static Transform[,] grid = new Transform[Width, Height]; // 블록들을 저장하고 체크하기 위한 10x20 그리드 데이터
void AddToGrid()
{
foreach (Transform children in transform)
{
int roundedX = Mathf.RoundToInt(children.transform.position.x);
int roundedY = Mathf.RoundToInt(children.transform.position.y);
if (roundedY < 20)
grid[roundedX, roundedY] = children; // 해당 좌표를 그리드에 입력한다.
else
gameOver();
}
}
2. 가로라인 블록 체크
그리드의 행을 기준으로 블록이 가득 차있는지 체크한다.
void checkForLines()
{
int cnt = 0;
for (int i = Height - 1; i >= 0; i--)
{
if (hasLine(i)) // 채워진 라인이 있는지 확인하고 있다면 삭제한 후 아래로 내려준다.
{
DeleteLine(i);
RowDown(i);
cnt++;
}
}
}
bool hasLine(int i) // 해당 라인이 모두 채워졌는지 확인하여 true, false 반환
{
for (int j = 0; j < Width; j++)
if (grid[j, i] == null)
return false;
return true;
}
3. 라인 삭제 및 블록 아래로 이동
해당 라인에 블록이 모두 채워졌을 때 삭제하고 해당 라인 위쪽 블록들을 아래로 내려준다.
void DeleteLine(int i)
{
for (int j = 0; j < Width; j++)
{
Instantiate(sparkPref, grid[j, i].gameObject.transform.position, Quaternion.identity); // 이펙트효과 추가
Destroy(grid[j, i].gameObject);
grid[j, i] = null;
}
}
void RowDown(int i)
{
for (int y = i; y < Height; y++)
for (int j = 0; j < Width; j++)
if (grid[j, y] != null)
{
grid[j, y - 1] = grid[j, y];
grid[j, y] = null;
grid[j, y - 1].transform.position -= new Vector3(0, 1, 0);
}
}
Spawn 생성과 관리
SpawnTetromino.cs파일을 생성하고 작성 후 같은 이름으로 하이어라키에 위치시킨다.
테트로미노 안쪽 4개의 블록 오브젝트가 모두 파괴되면 나머지 오브젝트도 체크해서 삭제 처리한다.
public List<GameObject> ListTetrominoes;
void destoroyCheck()
{
if (ListTetrominoes.Count > 0)
{
for (int i = 0; i < ListTetrominoes.Count; i++)
{
if (ListTetrominoes[i].transform.childCount == 0)
{
Destroy(ListTetrominoes[i]);
ListTetrominoes.RemoveAt(i);
}
}
}
}
전체스크립트 및 하이어라키 구조
GameData.cs
게임데이터를 관리하기 위한 스크립트이다. 하이어라키에 해당이름으로 빈게임오브젝트를 만들고 해당 스크립트를 얹어준다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class GameData : MonoBehaviour
{
public int Score;
public int PlayTime;
public int PlayLevel;
public int PlayCombo;
}
GameOver.cs
하이어라키에 게임오버 화면을 미리 만들고 해당 스크립트를 얹어준다.
using UnityEngine.SceneManagement;
using UnityEngine;
using UnityEngine.UI;
public class GameOver : MonoBehaviour
{
public Button Btn_Restart;
void Start()
{
Btn_Restart.onClick.AddListener(Reset);
}
void Reset()
{
SceneManager.LoadScene("Main");
}
}
SelfDestroy.cs
블록이 사라질 때 파티클 오브젝트를 사라지게 하기 위한 스크립트이다. 파티클 프리팹에 해당 스크립트를 붙여주면 해당 시간이 지난 후 알아서 자체적으로 디스트로이 된다.
using UnityEngine;
public class SelfDestroy : MonoBehaviour
{
public float LifeTime = 3;
void Start()
{
Invoke("destroyMe", LifeTime);
}
void destroyMe()
{
Destroy(this.gameObject);
}
}
SoundController.cs
효과음을 관리하는 파일이다. 마찬가지로 하이어라키에 위치시켜준다. 각 효과음을 미리 준비하여 mp3파일 형태로 만들어두고 인스펙터에 해당 효과음을 넣어준다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public enum SoundClip
{
Drop,
Explo,
Explo4,
GameStart,
GameOver
}
public class SoundController : MonoBehaviour
{
public AudioClip A_Drop, A_Explo, A_Explo4lines, A_GameStart, A_GameOver;
public AudioSource myAudio;
public void PlaySound(SoundClip audioType)
{
switch (audioType)
{
case SoundClip.Drop:
myAudio.clip = A_Drop;
break;
case SoundClip.Explo:
myAudio.clip = A_Explo;
break;
case SoundClip.Explo4:
myAudio.clip = A_Explo4lines;
break;
case SoundClip.GameStart:
myAudio.clip = A_GameStart;
break;
case SoundClip.GameOver:
myAudio.clip = A_GameOver;
break;
}
myAudio.Play();
}
}
SpawnTetromino.cs
위에 설명한 생성관리를 해주는 스크립트이다. 마찬가지로 하이어라키에 아래와 같이 위치시켜주고 7개의 만들어둔 테트로미노 프리팹을 넣어준다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
public class SpawnTetromino : MonoBehaviour
{
public GameObject[] Tetrominoes;
public List<GameObject> ListTetrominoes;
GameObject nextSpawn;
GameObject targetSpawn;
bool isFirst = true;
//-------------
SoundController soundController;
void Start()
{
if (soundController == null)
soundController = FindObjectOfType<SoundController>();
Application.targetFrameRate = 60;
ListTetrominoes = new List<GameObject>();
soundController.PlaySound(SoundClip.GameStart);
NewTetromino();
}
public void NewTetromino()
{
Invoke("createTetromino", 0.5f);
}
void createTetromino()
{
if (isFirst)
{
isFirst = false;
targetSpawn = Instantiate(Tetrominoes[Random.Range(0, Tetrominoes.Length)], transform.position, Quaternion.identity);
ListTetrominoes.Add(targetSpawn);
nextSpawn = Instantiate(Tetrominoes[Random.Range(0, Tetrominoes.Length)], transform.position + new Vector3(4, 1f, 0), Quaternion.identity);
nextSpawn.GetComponent<TetrisBlock>().enabled = false;
nextSpawn.transform.localScale = new Vector3(0.5f, 0.5f, 0.5f);
}
else
{
targetSpawn = nextSpawn;
targetSpawn.transform.position = transform.position;
targetSpawn.transform.localScale = Vector3.one;
targetSpawn.GetComponent<TetrisBlock>().enabled = true;
ListTetrominoes.Add(targetSpawn);
nextSpawn = null;
nextSpawn = Instantiate(Tetrominoes[Random.Range(0, Tetrominoes.Length)], transform.position + new Vector3(4, 1f, 0), Quaternion.identity);
nextSpawn.GetComponent<TetrisBlock>().enabled = false;
nextSpawn.transform.localScale = new Vector3(0.5f, 0.5f, 0.5f);
destoroyCheck();
}
}
void destoroyCheck()
{
if (ListTetrominoes.Count > 0)
{
for (int i = 0; i < ListTetrominoes.Count; i++)
{
if (ListTetrominoes[i].transform.childCount == 0)
{
Destroy(ListTetrominoes[i]);
ListTetrominoes.RemoveAt(i);
}
}
}
}
}
TetrisBlock.cs
가장 핵심이 되는 코드 이다. 위에 설명한 부분들이 모두 들어가 있으며 7개의 테트로미노 프리팹에 각각 붙여준다.
using UnityEngine;
public class TetrisBlock : MonoBehaviour
{
public Vector3 RotationPoint;
public float FallTime = 0.8f;
public static int Height = 20;
public static int Width = 10;
private float previousTime, previousTimeLeft, previousTimeRight;
private static Transform[,] grid = new Transform[Width, Height];
bool isGameOver;
// other commponent ----------
TouchButtons touchButtons;
GameData gameData;
SpawnTetromino spawTetromino;
SoundController soundController;
// vfx ----------
[SerializeField] GameObject sparkPref;
void Update()
{
if (touchButtons == null)
touchButtons = FindObjectOfType<TouchButtons>();
if (gameData == null)
gameData = FindObjectOfType<GameData>();
if (spawTetromino == null)
spawTetromino = FindObjectOfType<SpawnTetromino>();
if (soundController == null)
soundController = FindObjectOfType<SoundController>();
//=================== 키보드일 경우
if ((Input.GetKey(KeyCode.LeftArrow) && Time.time - previousTimeLeft > (Input.GetKey(KeyCode.LeftArrow) ? FallTime / 8 : FallTime)))
moveLeft();
else if ((Input.GetKey(KeyCode.RightArrow) && Time.time - previousTimeRight > (Input.GetKey(KeyCode.RightArrow) ? FallTime / 8 : FallTime)))
moveRight();
else if (Input.GetKeyDown(KeyCode.UpArrow))
rotateBlock();
if (Time.time - previousTime > (Input.GetKey(KeyCode.DownArrow) ? FallTime / 14 : FallTime))
moveDown();
if (Input.GetKeyDown(KeyCode.Space)) // 터치
dropBlock();
//================== 터치일 경우
if (touchButtons)
{
if (touchButtons.MyCommand == Command.Left && Time.time - previousTimeLeft > (touchButtons.MyCommand == Command.Left ? FallTime / 8 : FallTime))
moveLeft();
else if (touchButtons.MyCommand == Command.Right && Time.time - previousTimeRight > (touchButtons.MyCommand == Command.Right ? FallTime / 8 : FallTime))
moveRight();
else if (touchButtons.MyCommand == Command.Rotate)
rotateBlock();
if (touchButtons.MyCommand == Command.MoveDown)
moveDown();
if (touchButtons.MyCommand == Command.Down)
dropBlock();
}
}
void moveLeft()
{
// Left
transform.position += new Vector3(-1, 0, 0);
if (!ValidMove())
transform.position -= new Vector3(-1, 0, 0);
previousTimeLeft = Time.time;
}
void moveRight()
{
// Right
transform.position += new Vector3(1, 0, 0);
if (!ValidMove())
transform.position -= new Vector3(1, 0, 0);
previousTimeRight = Time.time;
}
void rotateBlock()
{
// Rotate!
transform.RotateAround(transform.TransformPoint(RotationPoint), new Vector3(0, 0, 1), 90);
if (this.tag == "Mino_I" || this.tag == "Mino_S" || this.tag == "Mino_Z") // 도는 범위가 넓어서 다시 잡아줌. 90도로 돌아갔다가 다시 원래 위치로
{
if (this.transform.localEulerAngles.z < 0 || this.transform.localEulerAngles.z > 90)
this.transform.localEulerAngles = Vector3.zero;
}
else if (this.tag == "Mino_O") // 돌릴필요 없음
this.transform.localEulerAngles = Vector3.zero;
if (!ValidMove())
transform.RotateAround(transform.TransformPoint(RotationPoint), new Vector3(0, 0, 1), -90);
// 4개의 블록의 부모가 돌아가니 그림자가 돌아가는데 해당 블록들은 항상 그대로 있게 만들어준다.
for (int i = 0; i < transform.childCount; i++)
transform.GetChild(i).transform.rotation = Quaternion.identity;
// 터치커맨드 리셋 연타방지용
touchButtons.MyCommand = Command.None;
}
void moveDown()
{
// Down
transform.position += new Vector3(0, -1, 0);
if (!ValidMove())
{
transform.position -= new Vector3(0, -1, 0);
AddToGrid();
checkForLines();
this.enabled = false;
if (!isGameOver)
spawTetromino.NewTetromino();
}
previousTime = Time.time;
}
void dropBlock()
{
touchButtons.MyCommand = Command.None;
FallTime = 0;
}
/// <sammary>
/// 10x20 그리드 전체 라인을 체크해서 가로라인이 만들어 질 경우 해당 라인을 삭제하고 아래로 내려준다. 그리고 몇줄의 라인이 만들어 졌는지 체크해서 점수를 준다.
/// </sammary>
void checkForLines()
{
int cnt = 0;
for (int i = Height - 1; i >= 0; i--)
{
if (hasLine(i)) // 채워진 라인이 있는지 확인하고 있다면 삭제한 후 아래로 내려준다.
{
DeleteLine(i);
RowDown(i);
cnt++;
}
}
if (cnt == 0)
{
soundController.PlaySound(SoundClip.Drop);
gameData.Score += 10;
}
else
{
Debug.Log("deleted lines : " + cnt);
// Score
gameData.Score += cnt * 100;
if (cnt == 4)
soundController.PlaySound(SoundClip.Explo4);
else
soundController.PlaySound(SoundClip.Explo);
}
}
/// <sammary>
/// 해당 가로라인에 블록이 모두 있는지 없는지를 체크한다.
/// </sammary>
bool hasLine(int i)
{
for (int j = 0; j < Width; j++)
if (grid[j, i] == null)
return false;
return true;
}
/// <sammary>
/// 해당 라인을 삭제한다.
/// </sammary>
void DeleteLine(int i)
{
for (int j = 0; j < Width; j++)
{
Instantiate(sparkPref, grid[j, i].gameObject.transform.position, Quaternion.identity);
Destroy(grid[j, i].gameObject);
grid[j, i] = null;
}
// soundController.PlaySound(SoundClip.Explo);
}
/// <sammary>
/// 해당 가로 라인이 비어있을 경우 아래로 내려준다
/// </sammary>
void RowDown(int i)
{
for (int y = i; y < Height; y++)
for (int j = 0; j < Width; j++)
if (grid[j, y] != null)
{
grid[j, y - 1] = grid[j, y];
grid[j, y] = null;
grid[j, y - 1].transform.position -= new Vector3(0, 1, 0);
}
}
/// <sammary>
/// 10x20 그리드에 해당 블록을 채워 넣어줌 채울 수 없을 경우 게임 오버
/// <sammary>
void AddToGrid()
{
foreach (Transform children in transform)
{
int roundedX = Mathf.RoundToInt(children.transform.position.x);
int roundedY = Mathf.RoundToInt(children.transform.position.y);
if (roundedY < 20)
grid[roundedX, roundedY] = children;
else
gameOver();
}
}
/// <summary>
/// 게임오버가 됐을 경우 띄우는 화면
/// </summary>
void gameOver()
{
isGameOver = true;
Debug.Log("Game Over@@");
GameObject.FindWithTag("GameOver").gameObject.transform.GetChild(0).gameObject.SetActive(true);
soundController.PlaySound(SoundClip.GameOver);
}
/// <summary>
/// 10x20 그리드 안쪽에서 움직이고 있는지 아닌지를 판단
/// </summary>
bool ValidMove()
{
foreach (Transform children in transform)
{
int roundedX = Mathf.RoundToInt(children.transform.position.x);
int roundedY = Mathf.RoundToInt(children.transform.position.y);
if (roundedX < 0 || roundedX >= Width || roundedY < 0 || roundedY >= Height)
return false;
if (grid[roundedX, roundedY] != null)
return false;
}
return true;
}
}