본문 바로가기

Ecmascript/Canvas

[테트리스 만들기] 레이아웃부터 모션까지 제작

자바스크립트로 만든 테트리스

사용하는 자바스크립트 ES6 문법

  • Classes, Arrow functions, Spread operator, Let and const, Default parameters, Array.from(), Proxy
  • 각 파트에서 해당 문법을 사용하면 제목옆에 표기 해놓았으니 참고

파일 구조

  • board.js: 보드 로직
  • constants.js: 게임 설정과 규칙을 정의
  • index.html: 기본 html
  • main.js: 게임 초기화와 종료 로직
  • piece.js: 테트리스 조각 로직
  • styles.css: 모든 스타일링

레이아웃 만들기

  • html, css는 걍 복붙할 것(index.html, styles.css)
  • 상수를 통해 보드 행렬, 블록 사이즈 정의
  • canvas로 그래픽 표현
//*** constants.js
'use strict';

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

//*** main.js
const canvas = document.getElementById('board');
const ctx = canvas.getContext('2d');

// 캔버스 크기 계산
ctx.canvas.width = COLS * BLOCK_SIZE;
ctx.canvas.height = ROWS * BLOCK_SIZE;

// 블록 크기 변경: 매번 BLOCK_SIZE로 계산할 필요가 없이 블록의 크기를 1로 취급
ctx.scale(BLOCK_SIZE, BLOCK_SIZE);

테트리스 보드: Classes, Array.from()

  • 테트리스 보드는 셀들로 구성되어 있고, 각 셀은 채워져 있거나 그렇지 않을 수 있다.
  • 비어있는 셀은 0으로 표시하고 색상은 1-7을 사용해 표시하며 초기 보드의 모든 셀은 0 이다.
  • 게임 보드는 행렬로 이뤄져있고 행을 나타내기 위해 숫자형의 배열을 사용한다.
//*** board.js
class Board {
  grid;
  // 보드 초기화
  reset() {
    this.grid = this.getEmptyBoard();
  }
  // 행(20)만큼 length를 가진 객체를 배열로 만들껀데 열(10)을 0으로 배열로 만들어라
  getEmptyBoard() {
    return Array.from(
      {length: ROWS}, () => Array(COLS).fill(0)
    );
  }
}

//*** main.js
let board = new Board();

function play() {
  board.reset(); // 보드판 초기화
  console.table(board.grid);
}

테트로미노

  • 테트리스 한조각은 4개 블록으로 구성되어 있다.
  • 하나의 테트로미노는 I, J, L, O, S, T, Z 모양을 띄고 있다.
  • J, L, T는 평평한 쪽을 먼저 수평으로 생성한다.
  • 가령 J 모양은 아래의 행렬구조를 띄고 있다.
let j = [
  [2, 0, 0],
  [2, 2, 2],
  [0, 0, 0];
];
  • 보드에 각 테트로미노를 그릴 수 있도록 캔버스 컨텍스트를 참조하는 Piece 클래스를 생성 한다.
//*** piece.js
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]
    ];
    // 시작 위치
    this.x = 3;
    this.y = 0;
  }

  draw() {
    this.ctx.fillStyle=this.color;
    // shape의 셀을 순회하면서 0보다 크면 색을 칠한다.
    this.shape.forEach((row, y) => {
      row.forEach((value, x) => {
        if(value > 0) this.ctx.fillRect(this.x + x, this.y + y, 1, 1);
      });
    });
  }

}
  • 버튼을 클릭하면 테트로미노 그리는 기능 추가한다.
//*** main.js
let board = new Board();

function play() {
  board.reset(); // 보드판 초기화
  console.table(board.grid);

  let piece = new Piece(ctx);
  piece.draw(); // 테트로미노 그리기

  board.piece=piece;
}

키보드 입력 : Spread operator, Let and const

  • 보드 위에서 위치를 변경하기 위해 현재 조각의 x 또는 y 속성값을 변경하는 메서드 추가
//*** piece.js

  // 키보드로 움직이기
  move(p) {
    this.x = p.x;
    this.y = p.y;
  }
  • 키들을 키 코드 값으로 매핑한다. 열거형(enum)처럼 사용하기 위해 객체를 프리징한다.
//*** constants.js
// 키코드로 키매핑
const KEY = {
  LEFT: 37,
  RIGHT: 39,
  DOWN: 40
}
// 불변으로 만드는 값은 1레벨에서만 동작한다 -> 객체 안에 하위의 객체는 불변하게 만들 수 없다.
Object.freeze(KEY);
  • 키보드 이벤트를 감지해서, 왼쪽, 오른쪽, 아래 방향키를 누르면 조각이 움직이게 만든다.
//*** main.js

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

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();
    }
  }
});

충돌 감지

  • 먼저 아래와 같은 충돌을 확인 후 안전한 경우에만 테트로미노를 움직인다.

    • 바닥에 닿는다.
    • 왼쪽 또는 오른쪽 벽으로 이동한다.
    • 보드 안에 다른 블록과 부딪친다.
    • 회전하는 중에 벽 또는 다른 블록과 부딪친다.
  • 테트로미노를 움직이기 전에 이동한 위치가 유효한지 확인하는 로직을 추가한다. 충돌을 감지하기 위해, 테트로미노가 새롭게 차지할 그리드의 모든 공간을 순회한다.

//*** board.js

  insideWalls(x) {
    return x >= 0 && x < COLS;
  }

  aboveFloor(y) {
    return y <= ROWS;
  }

  notOccupied(x, y) {
    return this.grid[y] && this.grid[y][x] === 0;
  }

  valid(p) {
    // 조각의 모든 블록 좌표를 계산하고 유효한 위치인지 확인한다
    return p.shape.every((row, dy) => {
      return row.every((value, dx) => {
        let x = p.x + dx;
        let y = p.y + dy;
        return (
          value === 0 ||
          (this.insideWalls(x) && this.aboveFloor(y) && this.notOccupied(x, y))
        );
      });
    });
  }

하드 드롭(hard drop)을 추가

  • 스페이스 바를 누르면 테트로미노가 무언가와 충돌할 때까지 떨어진다.
//*** constants.js
// 키코드로 키매핑
const KEY = {
  // ... 기존 코드
  SPACE: 32,
}

//*** main.js
moves = {
  // ... 기존 코드
  [KEY.SPACE]: p => ({ ...p, y: p.y + 1 }),
}
document.addEventListener('keydown', event => {
  if(moves[event.keyCode]) {
    event.preventDefault();

    // 조각의 새 상태를 얻음
    let p = moves[event.keyCode](board.piece);

    // 스페이스 누를 경우 하드 드롭
    if (event.keyCode === KEY.SPACE) {
      while (board.valid(p)) {
        board.piece.move(p);   
        p = moves[KEY.DOWN](board.piece);
      }
    }

    if(board.valid(p)) {
      // 기존 코드
    }
  }
});

회전

  • 시계 방향으로 회전시키는 방법
    • 두 개의 반사 행렬은 45도에서 90도로 회전을 가능하게 하므로 행렬을 변환할 수 있다.
    • 그런 다음 열의 순서를 바꾸는 치환 행렬을 곱한다.
    • 한마디로 \ 을 기준으로 서로 바꿔주고 reverse하면 90도 회전

시계방향 회전 수식

//*** board.js
  rotate(p){
    // 불변성을 위해 JSON으로 복사
    let clone = JSON.parse(JSON.stringify(p));

    // 행과 열을 서로 바꾸는 반사행렬 처리
    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());

    return clone;
  }
  • 회전하는 키코드 추가하고 회전메서드 연결
//*** constants.js
// 한번씩 등록하기 귀찮아서 미리 키코드 다 만듬
const KEY = {
  ESC: 27,
  SPACE: 32,
  LEFT: 37,
  UP: 38,
  RIGHT: 39,
  DOWN: 40,
  P: 80,
  Q: 81
}

//*** main.js
moves = {
  //
  [KEY.UP]: p => board.rotate(p, ROTATION.RIGHT),
}

테트로미노 랜덤화

//*** constants.js
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]],
  [[0, 0, 3], // 0,0 -> 2,0 ; 0,1 -> 1,0 ; 0,2 -> 0,0
   [3, 3, 3], // 1,0 -> 2,1 ; 1,1 -> 1,1 ; 1,2 -> 0,1 
   [0, 0, 0]],// 2,0 -> 2,2 ; 2,1 -> 1,2 ; 2,2 -> 0,2
  [[4, 4], [4, 4]],
  [[0, 5, 5], [5, 5, 0], [0, 0, 0]],
  [[0, 6, 0], [6, 6, 6], [0, 0, 0]],
  [[7, 7, 0], [0, 7, 7], [0, 0, 0]]
];
Object.freeze(SHAPES);

//*** piece.js
class Piece {

  // 한 조각을 선택하기 위해 조각들의 인덱스를 랜덤화
  randomizeTetrominoType(noOfTypes) {
    return Math.floor(Math.random() * noOfTypes + 1);
  }

  spawn() {
    // this.color = 'blue';
    // this.shape = [
    //   [2, 0, 0],
    //   [2, 2, 2],
    //   [0, 0, 0]
    // ];

    // Play 버튼을 누를 때마다 다른 모양과 색상의 조각들 생성
    const typeId = this.randomizeTetrominoType(COLORS.length);
    this.shape = SHAPES[typeId];
    this.color = COLORS[typeId];

    // 시작 위치
    this.x = 0;
    this.y = 0;
  }
}

게임 루프 : Default parameters

  • 같은 코어 함수를 실행하고 또 실행하는 사이클을 일컬어 게임 루프(game loop)라고 부른다.
  • 테트로미노가 1초마다 스크린 아래로 움직이는 게임 루프를 만들어야 한다.
//*** board.js

  drop() {
    let p = moves[KEY.DOWN](this.piece);
    if (this.valid(p)) {
      this.piece.move(p);
    }
    return true;
  }
  • 1초마다 아래로 한칸씩 움직이는 drop()메서드를 반복해서 호출한다.
//*** main.js

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

function animate(now=0){
  time.elapsed = now - time.start;

  // 1초마다 아래로 한칸씩 움직이는 drop()메서드를 호출
  if(time.elapsed > time.level) {
    time.start = now;
    board.drop();
  }  

  // 새로운 상태로 그리기 전에 보드를 지운다.
  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); 

  board.piece.draw();  

  // drop()메서드를 호출을 애니메이션으로 반복 처리
  requestId = requestAnimationFrame(animate);
}