모던 자바스크립트로 테트리스 만들기


원문 : https://medium.com/@michael.karen/learning-modern-javascript-with-tetris-92d532bcd057

오늘은 고전 게임인 테트리스로 게임 개발 여행을 해보려고 한다. 그래픽, 게임 루프 및 충돌 감지 등을 다루고, 마지막에는 점수와 난이도가 완벽하게 작동하는 게임을 갖게 될 것이다. 게임 개발을 하며 다음과 같이 ECMAScript 2015(ES6)에 도입된 최신 기능을 사용할 것이다.

이 글을 통해서 당신의 무기가 될 만한 새로운 자바스크립트 트릭을 얻기를 바란다.

프로젝트를 생성하고 코드 스니펫에서 에러가 발생했다면, 깃헙 리포지터리의 코드를 확인해보라. 뭔가 동작하지 않는다면 메시지를 남겨달라. 완성된 게임은 다음과 같다.

테트리스

테트리스는 1984년 Alexey Pajitnov가 만들었다. 이 게임에서는 플레이어가 떨어지는 테트리스 조각을 회전하고 움직여야 한다. 플레이어는 빈 셀 없이 블록 가로 행을 완성하여 한 줄을 지운다. 만약 조각들이 상단 꼭대기에 도달하면 게임은 끝난다!

테트리스는 게임 개발 여행을 시작하기 아주 좋은 예제이다. 기초적인 게임 요소를 담고 있고 프로그래밍하기에도 상대적으로 쉽다. 테트로미노스(tetrominos)는 4개의 블록만으로 구성되기 때문에, 대부분의 게임보다 그래픽을 구현하기가 더 쉽다.

프로젝트 구조

코드 베이스가 크지 않더라도 프로젝트에서 코드를 분할하는 것이 좋다. 자바스크립트 파일은 4개다.

  • constants.js는 게임 설정과 규칙을 정의한다.
  • board.js는 보드 로직 파일이다.
  • piece.js는 테트리스 조각 로직 파일이다.
  • main.js은 게임 초기화와 종료 로직 코드를 가진다.
  • index.html에서 하단에 추가하는 스크립트의 순서는 중요하다.
  • styles.css에 모든 스타일이 있다.
  • README.md는 마크다운 파일로, 리포지터리의 첫 번째 페이지다.

크기와 스타일

플레잉 보드는 10개의 열과 20개의 행으로 구성되어 있다. 이 값들은 보드를 순환하는데 자주 재사용하므로 블록 크기와 함께 constants.js 파일에 상수 값으로 추가한다.

const COLS = 10;
const ROWS = 20;
const BLOCK_SIZE = 30;

constants.js

필자는 그래픽을 표현하는데 canvas 엘리먼트 사용을 선호한다.

<div class="grid">
  <canvas id="board" class="game-board"></canvas>
  <div class="right-column">
    <div>
      <h1>TETRIS</h1>
      <p>Score: <span id="score">0</span></p>
      <p>Lines: <span id="lines">0</span></p>
      <p>Level: <span id="level">0</span></p>
      <canvas id="next" class="next"></canvas>
    </div>
    <button onclick="play()" class="play-button">Play</button>
  </div>
</div>

index.html

main.js에서 캔버스 엘리먼트 및 캔버스 엘리먼트의 2d 컨텍스트를 얻고 위에서 정의한 상수로 크기를 설정할 수 있다.

const canvas = document.getElementById('board');
const ctx = canvas.getContext('2d');

// 상수를 사용해 캔버스의 크기를 계산한다.
ctx.canvas.width = COLS * BLOCK_SIZE;
ctx.canvas.height = ROWS * BLOCK_SIZE;

// 블록의 크기를 변경한다.
ctx.scale(BLOCK_SIZE, BLOCK_SIZE);

main.js

scale 함수를 사용하면 매번 BLOCK_SIZE로 계산할 필요가 없이 블록의 크기를 1로 취급할 수 있어 코드를 단순화할 수 있다.

스타일링

우리가 만드는 게임에 80년대 느낌이 나면 좋겠다. Press Start 2P는 1980년대의 남코(Namco) 아케이드 게임에서 디자인한 폰트를 기반으로 한 비트맵 폰트이다. <head>에 이 폰트를 링크를 걸고 스타일에도 추가하자.

<link 
  href="https://fonts.googleapis.com/css?family=Press+Start+2P" 
  rel="stylesheet"
/>

index.html

styles.css에서 첫 번째 스타일에 아케이드 스타일 폰트가 적용되어 있다. 레이아웃을 위해 CSS Grid와 Flexbox를 사용하였음을 참고하라.

* {
  font-family: 'Press Start 2P', cursive;
}

.grid {
  display: grid;
  grid-template-columns: 320px 200px;
}

.right-column {
  display: flex;
  flex-direction: column;
  justify-content: space-between;
}

.game-board {
  border: solid 2px;
}

.play-button {
  background-color: #4caf50;
  font-size: 16px;
  padding: 15px 30px;
  cursor: pointer;
}

styles.css

이것으로 게임 컨테이너의 스타일이 준비되었고, 코드 입력만 남았다.

보드

테트리스 보드는 셀들로 구성되어 있고, 각 셀은 채워져 있거나 그렇지 않을 수 있다. 가장 단순한 방법은 불리언 값으로 셀을 나타내는 것이다. 그러나 블록의 효과적인 표현을 위해 숫자를 사용하는 것이 더 나을 것이다. 비어있는 셀은 0으로 표시하고 색상은 1-7을 사용해 표시한다.

다음 개념은 게임 보드의 행과 열을 나타내는 것이다. 행을 나타내기 위해 숫자형의 배열을 사용할 수 있다. 그리고 보드는 행들의 배열이다. 다른 말로, 2차원 배열 또는 행렬이라고 부른다.

Board 클래스에 모든 셀이 0으로 초기화된 비어있는 보드를 반환하는 함수를 생성해보자. 다음과 같이 fill() 메서드를 사용해 편하게 처리할 수 있다.

class Board {
  grid;
  
  // 새 게임이 시작되면 보드를 초기화한다.
  reset() {
    this.grid = this.getEmptyBoard();
  }
  
  // 0으로 채워진 행렬을 얻는다.
  getEmptyBoard() {
    return Array.from(
      {length: ROWS}, () => Array(COLS).fill(0)
    );
  }
}

board.js

Play 버튼을 누를 때 main.js 파일에서 이 함수를 호출한다.

let board = new Board();

function play() {
  board.reset();
  console.table(board.grid);
}

main.js

console.table을 사용하면 숫자 값으로 보드를 확인할 수 있다.

X 및 Y축 좌표는 보드의 셀들로 보인다. 보드를 만들었으니 다음으로 넘어가보자.

테트로미노

테트리스의 한 조각은 조합된 4개의 블록으로 구성되어 있다. 이 조각을 테트로미노(tetromino)라고 부르며, 7가지 패턴과 색상이 있다. I, J, L, O, S, T, Z로 불리는 조각들의 모습은 다음과 같다.

J 테트로미노를 숫자 2를 사용해 행렬로 다음과 같이 만들었다. 중심을 회전시키기 위해 0으로 나머지 행을 채운다.

[2, 0, 0],
[2, 2, 2],
[0, 0, 0];

테트로미노는 J, L, T가 평평한 쪽을 먼저 수평으로 생성한다.

우리는 보드에서 테트로미노의 위치, 색상모양을 알기 위해서 Piece 클래스가 필요하다. 보드에 각 테트로미노를 그릴 수 있도록 캔버스 컨텍스트를 참조해야 한다.

먼저, 조각의 값을 하드 코딩해서 시작해보자.

class Piece {
  x;
  y;
  color;
  shape;
  ctx;
  
  constructor(ctx) {
    this.ctx = ctx;
    this.spawn();
  }
  
  spawn() {
    this.color = 'blue';
    this.shape = [
      [2, 0, 0], 
      [2, 2, 2], 
      [0, 0, 0]
    ];
    
    // Starting position.
    this.x = 3;
    this.y = 0;
  }
}

piece.js

보드에 테트로미노를 그리기 위해, shape의 모든 셀을 순회한다. 셀 값이 0보다 크다면, 블록을 칠한다.

draw() {
  this.ctx.fillStyle = this.color;
  this.shape.forEach((row, y) => {
    row.forEach((value, x) => {
      // this.x, this.y는 shape의 상단 왼쪽 좌표이다
      // shape 안에 있는 블록 좌표에 x, y를 더한다.
      // 보드에서 블록의 좌표는 this.x + x가 된다.
      if (value > 0) {
        this.ctx.fillRect(this.x + x, this.y + y, 1, 1);
      }
    });
  });
}

piece.js

보드는 테트로미노의 이동 경로를 추적하므로, 게임을 시작하면 테트로미노를 생성하고 그릴 수 있다.

function play() {
  board = getEmptyBoard();
  let piece = new Piece(ctx);
  piece.draw();
  
  board.piece = piece;
}

main.js

파란색 J 테트로미노가 나타났다!

다음은 키보드를 통해서 마법을 부려보겠다.

키보드 입력

보드에서 조각을 움직이기 위해서는 키보드 이벤트를 연결해야 한다. move 함수는 보드 위에서 위치를 변경하기 위해 현재 조각의 x 또는 y 속성값을 변경한다.

move(p) {
  this.x = p.x;
  this.y = p.y;
}

열거형

다음은 constants.js 파일에 키들을 키 코드 값으로 매핑한다. 열거형(enum)을 사용하는 것이 좋다.

열거형(enumeration)은 상수의 집합을 정의하기 위해 사용되는 특별한 타입이다.

자바스크립트에는 내장된 열거형이 없으므로 값을 가진 객체를 만들어서 사용한다.

const KEY = {
  LEFT: 37,
  RIGHT: 39,
  DOWN: 40
}
Object.freeze(KEY);

constants.js

const 키워드는 객체 및 배열을 정의할 때 약간 오해하기 쉬운데, 실제로 객체나 배열을 불변하게 만들어주지 않는다. Object.freeze()를 사용하면 불변하게 사용할 수 있다. 이 함수를 사용할 때 두 가지를 고려해야 한다.

  • 잘 동작하게 하려면, 엄격 모드(strict mode)를 사용해야 한다.
  • 불변으로 만드는 값은 1레벨에서만 동작한다. 다시 말하면, 객체 안에 또 다른 객체가 있으면 하위의 객체는 불변하게 만들 수 없다.

객체 리터럴

이벤트를 동작과 일치시키기 위해서 객체 리터럴을 사용할 수 있다.

ES6는 표현식을 사용하기 위해 객체 리터럴의 속성 키를 허용하고, 계산된 속성 키로 사용한다.

상수를 사용할 수 있도록 계산된 속성 이름들을 얻으려면 대괄호를 사용한다. 다음은 어떻게 동작하는지 보여주는 간단한 예제이다.

const X = 'x';
const a = { [X]: 5 };
console.log(a.x); // 5

현재 생성된 테트로미노를 제거하고 좌표 변경과 함께 복사된 테트로미노를 반환하려고 한다. 이를 처리하기 위해서 펼침 연산자(spread operator)를 사용해 얕은 복사를 하고 요구되는 위치로 좌표를 변경한다.

자바스크립트에서, 얕은 복사(shallow copying) 는 숫자 및 문자열과 같은 원시 타입 값을 복사할 때 사용한다. 우리 예제에서 좌표는 숫자형이다. ES6는 Object.assign펼침 연산자를 사용한 2가지 얕은 복사 매커니즘을 제공한다.

이 코드 스니펫에서 위에서 말한 것들을 처리하고 있다.

moves = {
  [KEY.LEFT]:  p => ({ ...p, x: p.x - 1 }),
  [KEY.RIGHT]: p => ({ ...p, x: p.x + 1 }),
  [KEY.UP]:    p => ({ ...p, y: p.y + 1 })
};

main.js

원본 조각을 변화시키지 않고 새로운 상태를 얻기 위해서 아래 코드와 함께 사용할 수 있다. 이 코드가 중요한 이유는 항상 새로운 위치로 조각을 움직이고 싶지 않기 때문이다.

const p = this.moves[event.key](this.piece);

다음에는 keydown 이벤트를 감지하는 이벤트 리스너를 추가한다.

document.addEventListener('keydown', event => {
  if (moves[event.keyCode]) {  
    // 이벤트 버블링을 막는다.
    event.preventDefault();
    
    // 조각의 새 상태를 얻는다.
    let p = moves[event.keyCode](board.piece);
    
    if (board.valid(p)) {    
      // 이동이 가능한 상태라면 조각을 이동한다.
      board.piece.move(p);
      
      // 그리기 전에 이전 좌표를 지운다.
      ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); 
      
      board.piece.draw();
    }
  }
});

main.js

이제 키보드 이벤트를 감지하고 있으므로, 왼쪽, 오른쪽 위, 아래 방향키를 누르면 조각이 움직이는 것을 볼 수 있다.

조각이 움직인다! 그러나 조각이 보드 밖으로 벗어나는 동작은 우리가 원하는 것이 아니다.

충돌 감지

만약 테트리스 블록들이 서로 교차하여 지나가거나 보드 밖을 빠져나간다면 게임이 재미없을 것이다. 테트로미노를 그냥 움직이기보다 먼저 잠재적인 충돌을 확인한 다음 안전한 경우에만 테트로미노를 움직인다. 고려해야 할 몇 가지 충돌 상황이 있다.

테트로미노가

  • 바닥에 닿는다.
  • 왼쪽 또는 오른쪽 벽으로 이동한다.
  • 보드 안에 다른 블록과 부딪친다.
  • 회전하는 중에 벽 또는 다른 블록과 부딪친다.

이러한 경우에 충돌이 발생한다.

이미 변경된 모양을 위해서 새로운 위치를 정의했다. 테트로미노를 움직이기 전에 이동한 위치가 유효한지 확인하는 로직을 추가한다. 충돌을 감지하기 위해, 테트로미노가 새롭게 차지할 그리드의 모든 공간을 순회한다.

이 로직을 처리할 때 배열의 메서드 중 every()를 사용하는 것이 적절하다. 이 메서드를 사용하면 배열의 모든 요소가 충돌 조건을 통과했는지 여부를 확인할 수 있다. 조각의 모든 블록 좌표를 계산하고 유효한 위치인지 확인한다.

valid(p) {
  return p.shape.every((row, dy) => {
    return row.every((value, dx) => {
      let x = p.x + dx;
      let y = p.y + dy;
      return (
        this.isEmpty(value) ||
       (this.insideWalls(x) &&
        this.aboveFloor(y)
      );
    });
  });
}

board.js

테트로미노를 움직이기 전에 이 메서드를 사용하면, 유효하지 않은 장소로 이동하는 것을 막을 수 있다.

if (this.valid(p)) {
  this.piece.move(p);
}

다시 보드 밖으로 테트로미노를 움직여 보자.

테트로미노가 더 이상 사라지지 않는다!

이제 테트로미노가 바닥에서 멈추기 때문에, 또 다른 움직임인 하드 드롭(hard drop)을 추가할 수 있다. 스페이스 바를 누르면 테트로미노가 무언가와 충돌할 때까지 떨어진다. 이것을 하드 드롭이라고 한다. 우리는 새로운 키 맵을 추가하여 이동시킬 수 있다.

const KEY = {  
  SPACE: 32,
  // ...
}

moves = {  
  [KEY.SPACE]: p => ({ ...p, y: p.y + 1 })
  // ...
};

// 이벤트 리스너 안에서
if (event.keyCode === KEY.SPACE) {
  // 하드 드롭한다.
  while (board.valid(p)) {
    board.piece.move(p);   
    p = moves[KEY.DOWN](board.piece);
  }
}

main.js

다음에는 무엇을 할까?

회전

테트로미노를 이동 시켜보았지만 회전을 할 수 없어서 재미가 없다. 중심을 기준으로 테트로미노를 회전시킬 수 있어야 한다.

학교에서 선형 대수(linear algebra)를 공부한 지 꽤 되었지만, 다음을 따르면 시계 방향으로 회전시키는 방법을 쉽게 처리할 수 있다.

두 개의 반사 행렬은 45도에서 90도로 회전을 가능하게 하므로 행렬을 변환할 수 있다. 그런 다음 열의 순서를 바꾸는 치환 행렬을 곱한다.

자바스크립트 코드로 작성하면 다음과 같다.

// 행렬을 변환한다. p는 Piece의 인스턴스이다.
for (let y = 0; y < p.shape.length; ++y) {
  for (let x = 0; x < y; ++x) {
    [p.shape[x][y], p.shape[y][x]] = 
    [p.shape[y][x], p.shape[x][y]];
  }
}

// 열 순서대로 뒤집는다.
p.shape.forEach(row => row.reverse());

board.js

조각을 회전시키는 함수를 추가할 수 있다. 이전에 펼침 연산자를 사용해 좌표를 복사했었다. 2차원 배열을 사용하는 경우에는 펼침 연산자가 1레벨의 값만 복사한다. 나머지는 참조를 복사한다.

그래서 이 방법 대신에 JSON.parseJSON.stringify를 사용했다. stringify() 메서드는 행렬을 JSON 문자열로 변환한다. parse() 메서드는 JSON 문자열을 파싱하고, 복사한 다음 다시 행렬로 만든다.

rotate(p){
  // 불변성을 위해 JSON으로 복사
  let clone: IPiece = JSON.parse(JSON.stringify(p));
  
  // 알고리즘 처리
  
  return clone;
}

board.js

그런 다음 board.js 파일에 위로 이동 방향키를 위한 새로운 상태를 추가한다.

[KEY.UP]: (p) => this.rotate(p)

이제 회전한다!

테트로미노 랜덤화

다양한 조각들을 얻어오기 위해서, 랜덤화 코드를 추가해야 한다.

Super Rotation System에 따르면, 조각의 초기 위치를 지정하고 색상과 함께 상수에 추가할 수 있다.

const COLORS = [  
  'cyan',
  'blue',
  'orange',
  'yellow',
  'green',
  'purple',
  'red'
];

const SHAPES = [  
  [
    [0, 0, 0, 0], 
    [1, 1, 1, 1],
    [0, 0, 0, 0], 
    [0, 0, 0, 0]
  ], 
  [
    [2, 0, 0],
    [2, 2, 2],
    [0, 0, 0]
  ],
  // 등등
];

constants.js

한 조각을 선택하기 위해 조각들의 인덱스를 랜덤화해야 한다. 랜덤 숫자를 얻기 위해 배열의 길이를 사용한 함수를 생성한다.

randomizeTetrominoType(noOfTypes) {
  return Math.floor(Math.random() * noOfTypes);
}

이 메서드로 랜덤한 테트로미노 조각을 얻을 수 있고, 가져온 조각에 색상과 모양을 적용할 수 있다.

const typeId = this.randomizeTetrominoType(COLORS.length);
this.shape = SHAPES[typeId];
this.color = COLORS[typeId];

Play 버튼을 누를 때마다 다른 모양과 색상의 조각들이 보인다.

게임 루프

대부분의 게임은 플레이어들이 아무것도 하지 않을 때 게임의 실행을 유지하는 1개의 main 함수를 가진다. 같은 코어 함수를 실행하고 또 실행하는 사이클을 일컬어 게임 루프(game loop)라고 부른다. 우리가 만드는 게임에서는 테트로미노가 스크린 아래로 움직이는 게임 루프가 필요하다.

RequestAnimationFrame

이 게임 루프를 만들기 위해 requestAnimationFrame을 사용할 수 있다. 브라우저에서 애니메이션이 필요할 때 호출하며, 다음 리페인트 전에 애니메이션을 업데이트하기 위해서 호출한다. 다시 말하면, 브라우저에게 이렇게 말하는 것이다. "네가 다음에 나올 화면을 그릴 때, 나도 무언가를 그리고 싶기 때문에 이 함수도 실행시켜줘."

"애니메이션은 움직이는 그림의 예술이 아니라 그려진 움직임의 예술이다." - Norman McLaren

window.requestAnimationFrame()으로 애니메이션을 처리하는 방법은 프레임을 그리는 함수를 생성한 다음 함수를 반복 호출하는 것이다. 클래스(우리의 경우는 그렇지 않다) 안에서 이 함수를 호출한다면, bind 함수를 이용해 this의 컨텍스트를 고정시켜야 한다. 그렇지 않으면 함수 컨텍스트로써 window 객체를 가진다. animate 함수가 포함되어 있지 않으므로 오류가 발생한다.

animate() {
  this.piece.draw();
  requestAnimationFrame(this.animate.bind(this));
}

draw() 함수에 대한 이전 호출을 모두 제거하고 대신에 애니메이션을 시작하도록 play() 함수에서 animate()를 호출한다. 게임을 시도해도 여전히 이전과 같이 실행된다.

타이머

다음은 타이머가 필요하다. 매 프레임마다 테트로미노를 떨어뜨린다. 여기에서 우리의 요구 사항에 맞게 수정할 수 있는 예제를 확인할 수 있다.

필요한 정보로 객체를 생성하는 것으로 시작한다.

time = { start: 0, elapsed: 0, level: 1000 };

게임 루프에서, 인터벌 시간에 기초하여 게임 상태를 업데이트한 다음 결과를 그린다.

function animate(now = 0) {
  // 지난 시간을 업데이트한다.
  time.elapsed = now - time.start;
  
  // 지난 시간이 현재 레벨의 시간을 초과했는지 확인한다.
  if (time.elapsed > time.level) {
  
    // 현재 시간을 다시 측정한다.
    time.start = now;   
    
    this.drop();  
  }
  
  // 새로운 상태로 그리기 전에 보드를 지운다.
  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); 
  
  board.draw();  
  requestId = requestAnimationFrame(animate);
}

main.js

애니메이션이 적용되었다!

다음에는 테트로미노가 보드 바닥에 도착했을 때 어떤 일이 일어나는지 살펴보자.

고정하기

테트리미노를 더 이상 아래로 움직일 수 없을 때, 조각을 고정하고 새로운 조각을 떨어뜨린다. freeze() 함수를 선언해보자. 이 함수는 보드에 테트로미노 블록을 병합시킨다.

freeze() {
  this.piece.shape.forEach((row, y) => {
    row.forEach((value, x) => {
      if (value > 0) {
        this.grid[y + this.piece.y][x + this.piece.x] = value;
      }
    });
  });
}

board.js

게임 화면에서는 아무것도 보이지 않지만, 콘솔 로그 화면에서는 보드의 모양을 확인할 수 있다.

보드를 그리는 함수를 추가해보자.

drawBoard() {
  this.grid.forEach((row, y) => {
    row.forEach((value, x) => {
      if (value > 0) {
        this.ctx.fillStyle = COLORS[value];
        this.ctx.fillRect(x, y, 1, 1);
      }
    });
  });
}

board.js

draw 함수는 다음과 같다.

draw() {
  this.piece.draw();
  this.drawBoard();
}

게임을 실행하면 조각들이 보인다.

이제 조각들을 고정하고 있으므로 새로운 충돌 감지를 추가해야 한다. 이번에는 보드에 고정된 테트로미노와 충돌하지 않게 만들어야 한다. 셀 값이 0인지 확인하는 것으로 이것을 할 수 있다. valid 메서드에 이 로직을 추가하고 인자로 보드에 전달한다.

board[p.y + y][p.x + x] === 0;

이제 보드에 조각들이 쌓이기 때문에 보드가 금방 가득 차게 될 것이다. 뭔가 조치를 해야 한다.

줄 지우기

게임을 오래 하기 위해서는, 블록이 한 줄을 가득 채우도록 테트로미노를 조립해서 그 줄을 제거해야 한다. 그렇게 하면 줄이 사라지고, 그 위에 나머지 조각들이 남는다.

줄이 만들어졌는지 감지하는 방법은 아주 쉽다. 0 값이 하나라도 있는지를 확인하면 된다.

this.grid.forEach((row, y) => {

  // 모든 값이 0보다 큰지 비교한다.
  if (row.every(value => value > 0)) {
  
    // 행을 삭제한다.
    this.grid.splice(y, 1);
    
    // 맨 위에 0으로 채워진 행을 추가한다.
    this.grid.unshift(Array(COLS).fill(0));
  } 
});

board.js

freeze() 함수를 호출한 후에 이 clearLines() 함수 호출을 추가할 수 있다. 게임을 하면 원하던 대로 블록 한 줄이 사라지는 것을 볼 수 있다.

점수

게임을 좀 더 재미있게 만들기 위해 점수를 추가해보자. 테트리스 가이드라인에서 다음과 같은 값으로 점수를 얻는다.

const POINTS = {
  SINGLE: 100,
  DOUBLE: 300,
  TRIPLE: 500,
  TETRIS: 800,
  SOFT_DROP: 1,
  HARD_DROP: 2
}
Object.freeze(POINTS);

constants.js

게임 진행 상황을 추적하려면, 점수와 블록 줄이 있는 accountValues 객체를 추가한다. 이 값들이 변경될 때 화면도 변경되길 원한다. HTML에서 엘리먼트를 얻고 이 엘리먼트의 textContext를 제공되는 값으로 변경하는 제네릭 함수를 추가한다.

account 객체의 변경 사항을 처리하려면, Proxy 객체를 생성하고 set 메서드로 화면을 업데이트하는 코드를 실행할 수 있다. accountValues 객체는 커스텀 동작을 가질 수 있기 때문에 이 객체를 프록시에 전달한다.

let accountValues = {
  score: 0,
  lines: 0
}

function updateAccount(key, value) {
  let element = document.getElementById(key);
  if (element) {
    element.textContent = value;
  }
}

let account = new Proxy(accountValues, {
  set: (target, key, value) => {
    target[key] = value;
    updateAccount(key, value);
    return true;
  }
}

main.js

이제 프록시인 account 변수에서 속성들을 호출할 때마다 updateAccount() 함수를 호출하고 DOM을 업데이트한다. 이벤트 핸들러에서 소프트 드롭과 하드 드롭을 위한 점수를 추가해보자.

if (event.keyCode === KEY.SPACE) {
  while (board.valid(p)) {
    account.score += POINTS.HARD_DROP;
    board.piece.move(p);
    p = moves[KEY.DOWN](board.piece);
  }
} else if (board.valid(p)) {
  board.piece.move(p);
  if (event.keyCode === KEY.DOWN) {
    account.score += POINTS.SOFT_DROP;
  }
}

main.js

블록 줄을 지웠을 때 점수이다. 줄 수에 따라 점수를 정의한다.

getLineClearPoints(lines) {  
  return lines === 1 ? Points.SINGLE :
         lines === 2 ? Points.DOUBLE :  
         lines === 3 ? Points.TRIPLE :     
         lines === 4 ? Points.TETRIS : 
         0;
}

board.js

이 점수를 동작하게 하려면, 줄이 얼마나 지워졌는지 계산하는 로직을 추가해야 한다.

clearLines() {
  let lines = 0;
  this.board.forEach((row, y) => {    
    if (row.every(value => value !== 0)) {      
      lines++; // 지워진 줄 수를 증가시킨다.
      this.board.splice(y, 1); 
      this.board.unshift(Array(COLS).fill(0));
    }  
  });  
  if (lines > 0) {    
    // 지워진 줄이 있다면 점수를 더한다.
    account.score += this.getLineClearPoints(lines);  
  }
}

board.js

이제 게임을 하면 점수가 올라가는 것을 볼 수 있다. 여기서 주의해야 할 점은 화면에 무언가가 나타나기를 원할 때마다 프록시를 통해서 account 객체에 접근해야 한다는 것이다.

난이도

테트리스를 잘하게 되면, 시작할 때의 속도는 너무 쉽게 느껴진다. 굉장히 쉽다는 것은 지루함을 의미한다. 따라서 난이도를 높여야 한다. 게임 루프에서 인터벌 속도를 줄이면 된다.

const LINES_PER_LEVEL = 10;

const LEVEL = {
  0: 800,
  1: 720,
  2: 630,
  3: 550,
  // ...
}

Object.freeze(LEVEL);

constants.js

또한 게임 플레이어의 현재 난이도를 보여줄 수 있다. 트랙을 유지하고 난이도 및 남아있는 줄을 보여주는 로직은 포인트를 처리하는 것과 같다. 스코어와 줄에 대한 값을 초기화하고 새로운 게임을 시작할 때 이 값들을 재설정해야 한다.

account 객체에 난이도에 대한 속성을 추가한다.

let accountValues = {
  score: 0,
  lines: 0,
  level: 0
}

게임 초기화는 play()에서 호출할 수 있다.

function resetGame() {
  account.score = 0;
  account.lines = 0;
  account.level = 0;
  board = this.getEmptyBoard();
}

난이도가 증가하면 더 높은 줄 제거 점수를 얻게 된다. 난이도 0에서 시작한 이후에 현재 난이도와 점수를 곱하고 1을 더한다.

(account.level + 1) * lineClearPoints;

설정한 대로 줄이 삭제되면 다음 난이도에 도달한다. 난이도의 속도를 높이는 것도 필요하다.

if (lines > 0) {
  // 지워진 줄과 레벨로 점수를 계산한다.
  
  account.score += this.getLinesClearedPoints(lines, this.level);
  account.lines += lines;
  
  // 다음 레벨에 도달할 수 있는 줄 수가 되었다면
  if (account.lines >= LINES_PER_LEVEL) {
    
    // 레벨 값을 증가시킨다.
    account.level++;
    
    // 다음 레벨을 시작하기 위해 줄을 지운다.
    account.lines -= LINES_PER_LEVEL;
    
    // 게임 속도를 올린다.
    time.level = Level[account.level];
  }
}

board.js

이제 게임을 하고 10줄을 제거하면 난이도 증가와 2배로 점수가 올라가는 것을 볼 수 있다. 그리고 게임도 조금 더 빨리 움직이기 시작한다.

게임 종료

게임을 하는 동안 테트로미노들이 멈추지 않고 내려올 것이다. 우리는 게임이 언제 끝날지 알아야 한다.

이 경우에 남아있는 행이 0인지 확인한 후, 게임 루프 함수를 종료하여 게임을 멈춘다.

if (this.piece.y === 0) {
  this.gameOver();
  return;
}

게임을 종료하기 전에, cancelAnimationFrame을 호출하여 이전에 실행되고 있던 애니메이션 프레임을 취소한다. 그리고 플레이어에게 메시지를 보여준다.

function gameOver() {
  cancelAnimationFrame(requestId);
  this.ctx.fillStyle = 'black';
  this.ctx.fillRect(1, 3, 8, 1.2);
  this.ctx.font = '1px Arial';
  this.ctx.fillStyle = 'red';
  this.ctx.fillText('GAME OVER', 1.8, 4);
}

main.js

다음에 나올 테트로미노

마지막으로 다음에 나올 테트로미노를 추가해보자. 이를 위해 또 다른 캔버스를 추가한다.

<canvas id="next" class=”next”></canvas>

그런 다음 첫 번째 캔버스를 설정할 때와 유사한 작업을 한다.

const canvasNext = document.getElementById('next');
const ctxNext = canvasNext.getContext('2d');
// 4개 블록을 위한 캔버스 사이즈를 설정한다.
ctxNext.canvas.width = 4 * BLOCK_SIZE;
ctxNext.canvas.height = 4 * BLOCK_SIZE;
ctxNext.scale(BLOCK_SIZE, BLOCK_SIZE);

drop 함수에서 로직을 조금 수정해야 한다. Piece 클래스로 생성한 인스턴스를 this.next에 설정할 새로운 조각으로 설정한다.

this.piece = this.next;
this.next = new Piece(this.ctx);
this.next.drawNext(this.ctxNext);

이제 다음에 나올 테트로미노 조각을 볼 수 있기 때문에 좀 더 전략적으로 플레이할 수 있다.

결론

오늘 우리는 게임 개발의 기초와 캔버스를 사용해 그래픽을 표현하는 방법을 배웠다. 이 글을 읽으며, 모던 자바스크립트와 자바스크립트의 새로운 활용 방법에 대해 배우는 즐거운 시간이었길 바란다.

이제 게임 개발의 첫 번째 스텝을 밟았으니, 다음에는 어떤 게임을 만들어볼까?

이 테트리스 여행에 많은 조언을 해 준 Tim Deschryver에게 감사하다.

참조


류선임, FE Development Lab2019.12.16Back to list