tags
2020
스터디
자기개발
개인프로젝트
author
description
처음으로 자바스크립트 캔버스로 테트리스 개발하지만 대부분은 알고리즘이었다!
created_at
Feb 11, 2022 07:47 AM
priority
published
published
published_at
Oct 12, 2020
updated_at
Mar 7, 2022 01:18 AM
교육의 마지막 과제로 테트리스를 자바스크립트 캔버스로 제작하는 과제를 받아서 제작을 진행하였습니다.
테트리스는 오래된 게임이며 많은 개발 언어와 툴로 많은 게임 개발자를 희망하는 분들의 초기 프로젝트로 많이 하는 편인데, 저는 게임에 관하여 자세히 모르다 보니 많은 점에 대해서 미숙하고 고려하지 못한 점이 많았습니다.
게임을 즐기고 좋아하는 사람이라 게임의 충돌검사나 버그에 대하여 걱정과 가능성에 대하여 많이 고려하였지만, 게임을 개발하는 도중에 고려하지 못한 경우의 수가 많았습니다
고려하지 않은 경우의 수 때문에 개발 일정이 여유로웠지만, 기간에 맞추어 진행하지 못하였고, 개발을 하여도 버그 때문에 진행하지 못한 사항이 많아 개발 일정에 맞추어 개발하지 못하였습니다.
이번 프로젝트를 진행하면서 버그, 경우의 수, 개발 일정 데드라인, 개발 설계시 고려해야 할 점들 외에도 많은 배움을 가졌던 과제였습니다.

1. 개발 설계


이번 개발에서는 많은 테스트 케이스가 필요했습니다. 기본적으로 블록이 7개라 하나의 검사에도 7가지와 회전의 경우의 수가 필요하기 때문에 4배가 필요했습니다.

기본적인 블록과 캔버스 크기

  1. block size: 30 x 30으로 설정하였습니다.
  1. canvas: 게임을 진행할 수 있는 영역의 크기입니다. 각각 12 x 20으로 362 x 602사이즈로 설정하였습니다.
  1. 블록의 형태 : 테트리스의 블록 형태는 총 7가지가 존재합니다. [O,J,L,Z,S,I,T]
  1. 블록 확인 및 블록의 이동주기 : 처음에 100(0.1s)으로 주었지만 이후 블록 이동의 끊김과 이동 속도를 조절하기 버거움에 있어 10(0.01s)으로 변경하였습니다.
  1. 블록 이동: 0.05

게임 보드 생성

게임 보드는 12 X 20이므로 이중 배열을 이용하였습니다. 그러므로 12x20 크기의 0인 값을 가진 배열을 생성하여야 했습니다. 일단 12개의 배열에 20개의 인덱스를 가진 이중 배열을 만들기 위해 12개의 빈 배열을 만든 후 하나의 배열에 합쳐주어 게임 보드를 생성하였습니다.

게임 보드와 그리기

테트리스는 가운데서 그려져 블록이 내려와야 하므로 11의 중앙인 5에서 x축을 시작점을 잡았습니다. 그리고, y축은 10(0.01s)마다 변화가 있기 때문에 이동 값을 0.05로 주었습니다.

라인 검사

저는 라인검사, 삭제,이동을 각각 함수로 나누었습니다. 게임 보드의 라인이 모두 채워졌는지 확인, 모두 채워진 값을 받아 삭제, 삭제 이후 사라진 만큼 위에 존재하는 블록을 아래로 이동하게 했습니다. 또한 블록 삭제에 따른 점수계산과 블록 이동속도에 관하여 설정하였습니다.

아래의 블록과 충돌

캔버스 아래로 내려가지 못하게 y축의 좌표에 따라 멈추는 점을 설정해주었습니다. 이와 함께 아래의 블록이 존재하는지를 확인 후 충돌을 검사하였습니다.

좌우 이동.

좌,우이동에서 벽을 고려하여 x 좌표를 이동시켜 주었습니다.

바로 이동

블록이 이동할 수 있는 가장 낮은 위치로 이동시켜주었습니다.
 

2. 내 뜻대로 되는 것은 없다.


테트리스 블록설계 (시작은 왼쪽에서 도착은 오른쪽에서) 다음 블록 생성 (T블록 다음은 T블록 또 T블록...) 게임 보드 생성 블록 충돌검사 (블록이 쌓인다!) 라인 검사 (블록 대이주) 좌우 이동 (x만 변경하면 될 줄 알았습니다. 현실을 마주하기 전엔 말이죠) 바로 이동(바로 멘탈 파괴)
 

테트리스 블록설계 (시작은 왼쪽에서 도착은 오른쪽에서)

notion image

초기 블록 설계

초기에는 블록을 설계할 때 L의 경우 [[1],[1],[1,1]]로 설계하였습니다.
이 방식에서 회전의 경우에서 회전 축을 고려하지 않아 벽끝에서 회전시 맞은편 벽으로 이동을 하여 이에 해결 방식에 대하여 생각해보았습니다.
  • 블록 회전시 x좌표를 수정
  • 블록 회전시 중심축을 기준으로 회전

블록, 회전

저는 x좌표 이동을 하면 나중에 이동, 바로 이동 기능 추가 시 문제점이 생길거 같아서 블록 중심 축을 잡아주었습니다.
O 블록의 경우는 회전이 존재하지 않아 고려하지 않았으며, J,L,Z,S,T 블록 는 3x3로 (1,1)을 중심으로, I블록 는 4x4로 (1,1), (1,2), (2,1), (2,2)로 잡았습니다.
해당 회전은 각각 중심축을 기준으로 회전을 하였습니다.
L블록의 예로 [[0,1,0],[0,1,0],[0,1,1]]로 변경하여 회전 시 [[0,0,0],[1,1,1],[0,1,0]]으로 변경하게 바꾸었습니다.
// 블록 회전 const rotateBlock = (block) => { const blockLength = block.length; let setBlockLength = block.length; const returnBlock = []; while (setBlockLength--) { returnBlock.push(new Array(blockLength).fill(0)); } for (let i = blockLength - 1; i >= 0; i--) { for (let j = blockLength - 1; j >= 0; j--) { returnBlock[i][j] = block[blockLength - j - 1][i]; } } return returnBlock; };
 
각 블록 사이즈 별로 로 matrix를 생성 후 0으로 값을 채운 배열을 생성하여 해당 블록에 회전한 위치값을 넣어주었습니다. 이때 바꾼 I가 모든 소스 증가의 원인이 되었습니다.

다음 블록 생성 (T블록 다음은 T블록 또 T블록... ㅌㅌㅌㅌ)

다음 블록생성

게임을 개발할때 의외로 자주 문제가 생기는 부분입니다.
특히 게임을 할때 유저들이 가장 자주 말하는  과 관련되어 있기 때문입니다.
초기에는 블록생성시 해당 블록색을 저장한 상수를 생성하여 해당 상수에 index값을 난수로 생성하여 블록타입과 색상을 반환하였습니다.
const nextBlock = () => { const randomBlockType = ['I', 'O', 'Z', 'S', 'J', 'L', 'T']; const randomBlockColor = [ '#FF0000', '#0000A6', '#05FF00', '#FFF000', '#FF9B00', '#0000FF', '#CC00FF', ]; const randomNumber = parseInt(Math.random() * 7); const returnBlock = { blockType: randomBlockType[randomNumber], blockType: randomBlockColor[randomNumber], }; return returnBlock; };
하지만 블록색상을 반환하고 해당 블록색상으로 그려주기 때문에 쌓인 블록의 색이 동일하게 그려주다보니 쌓인 블록과 생성된 블록의 색이 동일하고 모든 라인의 색이 동일하기 때문에 구별하기 힘들어져 color가 저장된 값을 외부로 빼고, randomBlockColor의 index값으로 이용하기 위해 블록을 1로 통일 하였던 값을 1,2,3,4,5,6,7로 나누어서 사용했습니다.
const randomBlockColor = [ '#FF0000', '#0000A6', '#05FF00', '#FFF000', '#FF9B00', '#0000FF', '#CC00FF', ]; const nextBlock = () => { const randomBlockType = ['I', 'O', 'Z', 'S', 'J', 'L', 'T']; const randomNumber = parseInt(Math.random() * 7); const returnBlock = { blockType: randomBlockType[randomNumber], blockType: randomBlockColor[randomNumber], }; return returnBlock; };
색상은 해결 하였지만, 테스트 도중에 T자 블록이 연속으로 5번이 나왔습니다.
난수 생성이긴 하지만 프로그램에서는 앞에 생성 되었는지에 대한 고려없이 그냥 지정한 영역 이내에서만 난수를 생성합니다.
그래서 6이 앞에 생성 되어도 고려하지 않고 6,6,6,6,6가 생성됩니다.
let randomBlockType = ['I', 'O', 'Z', 'S', 'J', 'L', 'T']; function nextBlock() { if (randomBlockType.length < 1) { randomBlockType = ['I', 'O', 'Z', 'S', 'J', 'L', 'T']; } const randomNumber = parseInt(Math.random() * randomBlockType.length - 1); const returnBlock = { blockType: randomBlockType[randomNumber], }; randomBlockType.splice(randomNumber, 1); return returnBlock; }
블록 7개를 하나의 생성 기본값으로 생각하여 7개가 모두 사용 되면 새로 블록을 생성하는 배열을 생성해주었습니다. 또한 남은 블록의 길이 중에서 난수를 생성하게 변경하였습니다.

게임 보드 생성

초기 게임

보드초기에는 board를 계속 생성,삭제하면서 블록의 이동을 주었습니다. 하지만 게임보드가 생성,삭제되다보니 블록이 축적이 되지 않았습니다.
default 게임보드 생성
그리는 board와, 그려진 블록을 저장하는 defaultboard를 나누었습니다. 그리는 board는 계속 블록을 그리는 board의 역할을 진행하며, defaultboard 는 충돌 값이 감지되면 저장 되어진 값을 그려주는 총 2번을 그려주었습니다.
default, board의 따로 사용하니 발생하는 문제점
따로따로 그려주니 사소한 문제가 생기더라도 따로따로 그려주어 각각 고려해야하였습니다. 그래서 그려주기 이전에 defaultboard 를 board 통합하여 board에서 모든 검사하기로 생각하여 진행하였습니다.

블록 충돌검사(블록이 쌓인다!)

블록생성과 보드 생성이 완료 되어서 이제 블록이 하단에 쌓이는 것을 계산했습니다. 충돌시 새로운 블록과, y축 좌표가 0으로 이동하는 계산을 하였습니다.
블록이 하단에 닿았을 때 테트리스를 하다보면 블록이 바닥에 닿으면 더 이동하지 않고 쌓입니다.
이를 구현하기 위해서 y축 최하단이 19로 잡아서 블록이 최하단에 닿는것을 고려하였습니다.
하지만, 블록은 아래서 부터가 아니라 위에서 부터 즉 (0,0)좌표부터 판단하여 그리기 때문에 블록이 바닥을 뚫고 내려갔습니다.
y가 19에서 블록이 존재하는 길이만큼 빼서 해당 길이의 바닥이 아닌 상단을 기준으로 만들었습니다.
const above = (board, blockType, x, y) => { // 블록이 멈출지 확인 const blockLength = blockType.length - 1; if (y + blockLength >= 19) { defaultBoard = board; pos = 0; move = 0; blockList = nextBlock(); nowBlock = block({ type: blockList.blockType }); } };
하지만 저는 회전만 생각하고 2x2, 3x3, 4x4로 제작하였기 Eo문에 하단의 [0,0,0,]이 존재하는 함수가 존재하였습니다.
Z = [ [1, 1, 0], [0, 1, 1], [0, 0, 0], ];
Z블록을 기준으로 하면 길이만큼 하면 한칸 위에 떠버리게 됩니다.
그래서 blockLength를 구하는 변수에서 하단에 0이 존재하는가에 따라서 길이 값을 나누었습니다.
const blockLength = blockType[blockType.length - 1].indexOf(0) !== 0 ? blockType.length - 1 : blockType.length - 2;
즉, 하단에 0이 존재하면 하나더 내려가는 길이를 계산하게 됩니다.
[0,0,0]을 해결 하였으니, 바닥 충돌은 완료하였습니다.
하지만 블록끼리 계산하지 않습니다. 바로 값을 덮어 씌우기 떄문에 해당 블록이 멈추지 않기 때문입니다 그래서 저는 하단 블록 충돌을 따로 계산하였습니다.
블록끼리 만났을때. 먼저 블록기준으로 아래에 위치하는 값을 계산하였습니다.
예를 들어 Z블록 일 경우 [[0,0],[1,1],[1,2]]으로 0을 제외한 값중 가장 아래에 위치하는 값을 뽑기 위해서입니다. 왜냐하면 그 위치에서 아래의 값을 계산을 해주기 위해서 입니다.
const existBlock = (block) => { const blockCheckIndex = []; let blockLength = block.length === 4 ? block.length - 2 : block.length - 1; if (block[blockLength].every((value) => value === 0)) { blockLength -= 1; } for (let i = block.length - 1; i >= 0; i--) { for (let j = blockLength; j >= 0; j--) { if (block[j][i] === 0) { continue; } else if (block.length === 4) { blockCheckIndex.push([j + 1, i]); break; } else { blockCheckIndex.push([j, i]); break; } } } return blockCheckIndex.reverse(); };
블록 길이를 검사 한 후 해당 블록 길이만큼
감소했던 값들은 검사하여 최종값을 반환했습니다.
const crash = (board, block, x, y) => { const blockIndex = existBlock(block); if (blockIndex.length === 1 && block[0].indexOf(1) !== -1) { // I if (board[x + blockIndex[0][1] - 1][y + blockIndex[0][0] + 1] !== 0) { return true; } } else if (blockIndex.length === 4) { // ㅡ for (let i = 0; i < block.length; i++) { if (board[x + i - 1][y + blockIndex[i][0]] !== 0) { return true; } return false; } } else { // 나머지 for (let i = 0; i < blockIndex.length; i++) { if (x + blockIndex[i][1] > 11) break; if (x + blockIndex[i][1] < 0) continue; if (board[x + blockIndex[i][1]][y + blockIndex[i][0] + 1] !== 0) { return true; } } } };
I와, I회전시, 나머지로 각각 분리하여 계산하였습니다.
I블록은 한개이기 때문에 하나만 검사해주면 되고, 회전시 I블록은 1을 빼주어야 하기 때문입니다.
게임 보드의 하단 부분에 블럭 존재를 검사하여 아래에 블록이 존재하면 true값을 반환합니다.
다음이 최종적인 아래에 블록이 존재하는지 충돌검사값입니다.
const above = (board, blockType, x, y) => { // 블록이 멈출지 확인 const blockLength = blockType.length - 1; if (y + blockLength >= 19 && crash(board, blockType, x, y)) { defaultBoard = board; pos = 0; move = 0; blockList = nextBlock(); nowBlock = block({ type: blockList.blockType }); } };

라인 검사(블록 대이주)

라인 파괴 경우 굉장히 문제가 많았습니다. 알고리즘 자체는 간단하지만, 생각보다 경우의 수가 많았기 때문에 제가 고려한 테스트 케이스는 모두 통과하였지만 이상의 버그가 가장 많았던 부분이었습니다.
 
const destoryLine = (board) => { const lineStatus = lineCheck(board); const score = document.querySelector('.score'); const scoreBoard = [100, 200, 350, 500]; if (lineStatus[0] !== 0) { lineStatus[1].forEach((value) => { for (let h = 0; h < board.length; h++) { board[h][value] = 0; } }); board = moveLine( board, lineStatus[0], lineStatus[1][lineStatus[1].length - 1], ); score.innerHTML = scoreBoard[lineStatus[0] - 1] + parseInt(score.innerHTML); moveSpeed += parseInt(score.innerHTML) / 1000000; } return board; };
가장 먼저 라인 파괴 부분입니다. 라인파괴에서 총 2가지 함수를 더 이용하며, 모든 라인이 찼는지, 라인 이동시 이동에 관한 함수, 이외에 점수에 관한여 여기에서 계산합니다. I블럭이 4칸이므로 최대 삭제 블럭은 4개입니다. 그래서 가능한 점수의 수는 4가지로 각줄별로 점수를 더 주었습니다.
const lineCheck = (board) => { const stack = [0, []]; for (let w = 0; w < board[0].length; w++) { if (board[1][w] === 0) continue; for (let h = 0; h < board.length; h++) { if (board[h][w] === 0) break; else if (h === board.length - 1) { stack[0]++; stack[1].push(w); } } } return stack; };
라인 검사입니다. 블럭순대로 라인 검사 후 stack이라는 상수에 값을 담습니다. 1줄 삭제시 stack[0]은 증가하고, 나중에 삭제한 길이의 위치를 stack[1]에 담습니다.
const moveLine = (board, nextLine, checkLine) => { for (let w = 0; w < board.length; w++) { for (let h = checkLine; h >= 0; h--) { if (h - nextLine === 0) break; board[w][h] = board[w][h - nextLine]; board[w][h - nextLine] = 0; } } return board; };
이후 라인 이동을 해당 길이만큼 이동하며, 아래에 블럭이 존재하면 이동하지 않습니다.

좌우이동(x만 변경하면 될 줄 알았습니다. 현실을 마주하기 전엔 말이죠)

초창기에는 move값을 주어서 x값만 이동하였습니다.
하지만 벽충돌을 마주했습니다. 여기서도 가장 큰 문제는 I블럭 이었습니다.
I블럭의 특성상 4x4에 위치를 다른블록에 비하여 크게 이동하기때문에 많은 문제가 있었습니다.
const sideBlock = () => { const side = [1, 1]; if (nowBlock.length === 4 && nowBlock[0].indexOf(1) !== -1) { side[0] = 0; return side; } if (nowBlock.length === 4) { side[0] = 0; side[1] = 3; return side; } nowBlock.forEach((value) => { if (value[0] !== 0) { side[0] = 0; } if (value[value.length - 1] !== 0) { side[1] = value.length - 1; } }); return side; };
sideBlock 는 앞의 existBlock 과 다릅니다. existBlock은 밑의 블럭 모두를 검사합지만. sideBlock은 아래의 양쪽끝을 검사합니다.
const xMoveLeft = () => { const sideCheck = sideBlock(); let leftMove = true; for (let i = sideCheck[0] + 5 + move; i < sideCheck[1] + 5 + move; i++) { const crashCheck = crash(defaultBoard, nowBlock, i, parseInt(pos)); if ( nowBlock.length === 4 && nowBlock[0].indexOf(1) === -1 && (i === 1 || crashCheck) ) { leftMove = false; break; } else if ( nowBlock.length === 4 && nowBlock[0].indexOf(1) >= 0 && (i === -1 || crashCheck) ) { leftMove = false; break; } else if (nowBlock.length <= 3 && (crashCheck || i === 0)) { leftMove = false; break; } } if (leftMove) { move -= 1; } };
const xMoveRight = () => { let rightMove = true; const sideCheck = sideBlock(); for (let i = sideCheck[0] + 5 + move; i < sideCheck[1] + 5 + move; i++) { const crashCheck = crash(defaultBoard, nowBlock, i, parseInt(pos)); if ( nowBlock.length === 4 && nowBlock[0].indexOf(1) === -1 && (i === 11 || crashCheck) ) { rightMove = false; break; } if (i === 10 || crashCheck) { rightMove = false; if (nowBlock.length === 4 && nowBlock[0].indexOf(1) === -1) { rightMove = true; } } } if (rightMove) { move += 1; } };
길어보이지만, 사실상 I블럭입니다. 아래의 조건문만 다른 블럭을 체크하는 값입니다.
간단하게 블럭의 양 사이드만큼 검사하고, y위치 아래에 블럭이 존재하거나, 벽밖으로 나가면 이동을 못하게 막아둡니다.
하지만, 회전시 해당 블럭이 나가버리거나, 오류가 발생하는데 이때 막기위하여 회전시 양쪽 벽 검사도 진행하였습니다.

바로 이동(바로 멘탈 파괴)

이번에도 sideBlock를 이용하여. 아래부터 블럭이 존재하는 위치까지 반환 한 후 블럭의 높이만큼 위에서 그려주는 간단한 코드입니다.
const directMove = () => { const sideCheck = sideBlock(); let nowBlockLength = nowBlock.length; let temp = 20; if (nowBlockLength === 4 && nowBlock[0].indexOf(1) !== 0) { for (let j = 0; j < defaultBoard[0].length; j++) { const i = sideCheck[1] + 5 + move; if (i > 11) break; if (defaultBoard[i][j] === 0) continue; if (temp > j) { temp = j; break; } else { temp = j - 1; break; } } } else { for (let i = 5 + move + sideCheck[0]; i < sideCheck[1] + 6 + move; i++) { if (i > 11 || i < 0) break; const found = defaultBoard[i].findIndex((element) => element !== 0); if (found !== -1) { if (temp > found) { temp = found; } } } } if (nowBlock[nowBlockLength - 1].every((value) => value === 0)) { nowBlockLength -= 1; } pos = temp - nowBlockLength; };
하지만, 초창기에는 아래에 위치한 블럭의 위치를 정상적으로 계산을 하고도 다른 블럭끼리 충돌하여도 블럭을 뚫고 아래로 내려갔고 심지어 게임보드를 뚫고 내려가기도 하였습니다.
일단 temp는 y위치의 임시값입니다.
temp를 보드길이만큼 내려가고, 아래로 내려간 블럭은 그 위치에 그려지죠, temp를 19값을 주고 수식을 하였더니 19번째 위치에 존재하는 블럭을 확인하여도 temp가 19이니 아래에 위치한 블럭을 뚫고 내려갔습니다.
그래서 초기값을 20으로 하여 아래에 19번째 위치한 블럭의 위치도 수식하도록 변경해주었습니다.

3. 무수한 fetch의 요청


초창기에는 랭킹등록시 간단한게 생각했습니다.
그래서 제약조건 없이 이름,점수를 전송하면 랭킹에 등록되게 하였습니다.
하지만, 개발자 도구와 fetch를 이용하여 관짝소년단 이미지로 스크립트 테러를 당한 후 이름이나 점수에 관하여 제약조건을 추가시켜 주었습니다.
만약 undefined, "", null 일 경우 무명으로 강제변환, script 이용 시 특수문자로 강제변환 등을 이용 하였고, 점수의 상한선을 두어 해당 점수이상 넣지 못하도록 랭킹등록을 수정하였습니다.
이번 개발은 사실상 Test case와 알고리즘이 중요한 과제였습니다.
블럭이 7개다 보니 생성될 수 있는 상황이 매우 많았고, 설계를 조금더 침착하고 자세히 하였으면 보다 완벽한 코드가 될 수 있을거 같았는데. 아직도 버그가 많이 존재한 상태로 마무리가 되었습니다