[Vanilla JS] 테트리스 만들기

Kyle·2020년 12월 4일
4

project

목록 보기
1/13

코드스쿼드 코코아 과정을 하면서 Final Proeject로 자유주제 개인 프로젝트를 하게 되었다. 주제를 고민하던 중 테트리스 게임을 구현하기로 다짐하고 4일동안 진행했다~! 예상과 달리 진행하면서 하나씩 생기는 문제들을 해결하면서 몰랐던 것들도 많이 알게되고 너무 재밌는 시간이었다.
깃허브링크에 테트리스 완성본이 있으니 자세한 코드는 링크를 타고 보시면 좋을 것 같아요~!

실행화면

tetris
tetris-end

  • 테트리스는 html의 canvas를 이용해서 블록들과 테트리스 격자판을 그렸습니다. canvas를 지우고 다시 그리는 방식으로 애니메이션을 구현했다.

테트리스 구상해보기

크게 4개의 class로 나눈 뒤 진행했다.

  1. TetrisModel
    tetrisModel은 테트리스 게임판의 데이터(배열), 점수, 레벨(난이도) 같은 데이터를 총 관리 하는 class
  2. TetrisShapeModel
    테트리스에 사용되는 모양들의 데이터. 7가지의 모양들을 각각 회전할 때 시작점 좌표를 배열에 입력했습니다.
  3. ScoreLevelView
    점수와 레벨을 rendering 해주는 class로, tetrisModel과 통신?하면서 화면에 렌더링해줍니다.
  4. TetrisView
    테트리스 게임의 나머지를 다 보여주는 class입니다.
    앞으로의 포스트는 이 TetrisView class의 내용들을 위주로 진행할 것 입니다.

필요기능 생각해보자

  • 격자판 그리기
  • 블록 그리기
  • 블록 움직이기
  • 블록 회전하기
  • 블록 빠르게 떨어뜨리기
  • 한줄이 채워지면 없애기
  • 점수 올리기
  • 레벨 조절하기

크게 이정도의 8가지의 기능을 구현을 목표로 구현했습니다.


이제 TetrisView 코드만 보면서 흐름을 설명할겠습니다. 다른 3개의 클래스와 통신을 하는데 이름을 명시적으로 작성해 이 클래스만 봐도 어떻게 돌아가는지 이해가 되지 않을까 하는 생각?에 주요 동작 과정만 설명하겠다. 궁금하신분은 위의 깃허브에 들어가셔서 보세용~!

class TetrisView 전체 코드

export class TetrisView {
  constructor({
    KEY,
    selector,
    START_POINT,
    tetrisModel,
    shapeModel,
    scoreLevelView,
  }) {
    this.key = KEY;
    this.canvas = selector.canvas;
    this.context = this.canvas.getContext("2d");
    this.nextCanvas = selector.nextCanvas;
    this.nextContext = this.nextCanvas.getContext("2d");
    this.playBtn = selector.playBtn;
    this.resetBtn = selector.resetBtn;
    this.gameover = selector.gameover;
    this.tetrisModel = tetrisModel;
    this.model = tetrisModel.getModel();
    //메소드에서 class 생성 때 받은 파라미터를 직접 못쓰기 때문에 따로 저장
    this.scoreLevelView = scoreLevelView;
    this.shapeModel = shapeModel;
    this.START_POINT = START_POINT;
    this.startLeft = START_POINT.LEFT;
    this.startTop = START_POINT.TOP;
    this.shape;
    this.nextShape;
    this.changeCnt = 0;
    this.cellSize = 30;
    this.timer = null;
    this.requestID = null;
    //removeEvent때 콜백함수가 다르면 안되기 때문에 따로 저장
    this.handleKeydown = this.handleKeydown.bind(this);
  }

  init() {
    this.clear();
    this.setNextShape();
    this.playBtn.addEventListener("click", this.handleClick.bind(this));
    this.resetBtn.addEventListener("click", this.handleClick.bind(this));
  }
  //random으로 next 블록 설정
  setNextShape() {
    const shapeList = this.shapeModel.getShapeList();
    const random = Math.floor(Math.random() * 7);
    this.nextShape = shapeList[random];
  }

  //model, score, view reset
  handleClick({ target }) {
    this.gameover.classList.add("hidden");
    this.model = this.tetrisModel.resetModel();
    this.tetrisModel.resetScore();
    this.scoreLevelView.updateScore();
    if (target.innerHTML === "RESET") {
      this.resetBlock();
      this.playBtn.disabled = false;
      document.removeEventListener("keydown", this.handleKeydown);
    } else {
      this.playBtn.disabled = true;
      document.addEventListener("keydown", this.handleKeydown);
      this.play();
    }
  }
  //새로운 블럭을 위해 초기화
  resetBlock() {
    cancelAnimationFrame(this.requestID);
    clearTimeout(this.timer);
    this.clear();
    this.clearNextShape();
    this.changeCnt = 0;
    this.startLeft = this.START_POINT.LEFT;
    this.startTop = this.START_POINT.TOP;
  }
  //새로운 블럭을 play
  play() {
    this.resetBlock();
    this.shape = this.nextShape;
    this.setNextShape();
    this.renderNextBlock(this.nextShape.color);
    this.renderBlock(this.shape.color);
    if (this.checkGameOver()) {
      return this.finishPlay();
    }
    this.autoMove();
  }
  //check 게임 오버
  //1번 index가 맨 윗줄이기 때문에 [1]
  checkGameOver() {
    for (let x of this.model[1]) {
      if (x !== 0) return true;
    }
    return false;
  }
  //게임 끝내기
  finishPlay() {
    document.removeEventListener("keydown", this.handleKeydown);
    this.gameover.classList.remove("hidden");
  }

  // 자동으로 내려가기
  autoMove() {
    const level = this.scoreLevelView.getLevel();
    const moveFast = 1000 - (level - 1) * 100;
    if (this.checkBlock(this.startLeft, this.startTop + this.cellSize)) {
      this.clear();
      this.startTop += this.cellSize;
      this.renderBlock(this.shape.color);
      this.timer = setTimeout(this.autoMove.bind(this), moveFast);
    } else {
      this.fixBlock();
      this.deleteLine();
      this.play();
    }
  }

  //keyboard 이벤트
  handleKeydown({ keyCode }) {
    if (
      keyCode === this.key.LEFT ||
      keyCode === this.key.RIGHT ||
      keyCode === this.key.DOWN
    ) {
      this.move(keyCode);
    } else if (keyCode === this.key.UP) {
      this.change();
    } else if (keyCode === this.key.SPACE) {
      event.preventDefault();
      this.drop();
    }
  }

  //움직이기
  move(keyCode) {
    if (keyCode === this.key.DOWN) {
      this.moveDown();
    } else if (keyCode === this.key.LEFT) {
      this.moveLeft();
    } else if (keyCode === this.key.RIGHT) {
      this.moveRight();
    }
  }
  moveDown() {
    if (this.checkBlock(this.startLeft, this.startTop + this.cellSize)) {
      this.clear();
      this.startTop += this.cellSize;
      this.renderBlock(this.shape.color);
    } else {
      this.fixBlock();
      this.deleteLine();
      this.play();
    }
  }
  moveLeft() {
    if (this.checkBlock(this.startLeft - this.cellSize, this.startTop)) {
      this.clear();
      this.startLeft -= this.cellSize;
      this.renderBlock(this.shape.color);
    }
  }
  moveRight() {
    if (this.checkBlock(this.startLeft + this.cellSize, this.startTop)) {
      this.clear();
      this.startLeft += this.cellSize;
      this.renderBlock(this.shape.color);
    }
  }

  //space바 누를 시 drop
  drop() {
    if (this.checkBlock(this.startLeft, this.startTop + this.cellSize)) {
      this.clear();
      this.startTop += this.cellSize;
      this.renderBlock(this.shape.color);
      this.requestID = requestAnimationFrame(this.drop.bind(this));
    } else {
      this.fixBlock();
      this.deleteLine();
      this.play();
    }
  }

  //모양 변경
  change() {
    this.clear();
    this.changeCnt++;
    if (!this.checkBlock(this.startLeft, this.startTop)) {
      if (this.checkBlock(this.startLeft - this.cellSize, this.startTop)) {
        this.startLeft -= this.cellSize;
      } else if (
        this.checkBlock(this.startLeft + this.cellSize, this.startTop)
      ) {
        this.startLeft += this.cellSize;
      } else if (this.shape.name === "I") {
        this.startLeft -= this.cellSize * (this.shape.width - 1);
      } else {
        this.changeCnt--;
      }
    }
    this.renderBlock(this.shape.color);
  }

  //충돌 check하기
  checkBlock(startLeft, startTop) {
    const nowIdx = this.changeCnt % this.shape.location.length;
    for (let size of this.shape.location[nowIdx]) {
      const left = startLeft + this.cellSize * size[0];
      const top = startTop + this.cellSize * size[1];
      if (top >= this.canvas.height) {
        return false;
      }
      if (this.model[top / this.cellSize + 1][left / this.cellSize] !== 0) {
        return false;
      }
    }
    return true;
  }

  //Render now shape - 게임 진행 화면
  renderBlock(color) {
    const nowIdx = this.changeCnt % this.shape.location.length;
    this.shape.location[nowIdx].forEach((size) => {
      const left = this.startLeft + this.cellSize * size[0];
      const top = this.startTop + this.cellSize * size[1];
      this.renderBox(this.context, left, top, color, this.cellSize);
    });
  }

  //Render next shape
  //nextShape는 항상 default block이 나오게 했다.
  renderNextBlock(color) {
    const cellSize = this.nextCanvas.width / 5;
    const startLeft =
      (this.nextCanvas.width - cellSize * this.nextShape.width) / 2;
    const startTop =
      (this.nextCanvas.height - cellSize * this.nextShape.height) / 2;
    this.nextShape.location[0].forEach((size) => {
      const left = startLeft + cellSize * size[0];
      const top = startTop + cellSize * size[1];
      this.renderBox(this.nextContext, left, top, color, cellSize);
    });
  }

  //tetris 격자판
  renderGrid() {
    const canvasWidth = this.canvas.width;
    const canvasHeight = this.canvas.height;
    this.context.lineWidth = 0.3;
    for (let i = 1; i < canvasWidth / this.cellSize; i++) {
      this.drawLine(this.cellSize * i, 0, this.cellSize * i, canvasHeight);
    }
    for (let i = 1; i < canvasHeight / this.cellSize; i++) {
      this.drawLine(0, this.cellSize * i, canvasWidth, this.cellSize * i);
    }
  }
  // 격자판 선 긋는 메소드
  drawLine(startLeft, startTop, endLeft, endTop) {
    this.context.beginPath();
    this.context.moveTo(startLeft, startTop);
    this.context.lineTo(endLeft, endTop);
    this.context.stroke();
    this.context.closePath();
  }
  //한줄 지우기
  deleteLine() {
    this.model.forEach((line, idx) => {
      if (!line.includes(0)) {
        this.model.splice(idx, 1);
        const newArray = new Array(10).fill(0);
        this.model.unshift(newArray);
        this.tetrisModel.addScore();
        this.scoreLevelView.updateScore();
      }
    });
  }
  //화면 재구성
  clear() {
    this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
    this.renderGrid();
    this.reScreen();
  }
  clearNextShape() {
    this.nextContext.clearRect(
      0,
      0,
      this.nextCanvas.width,
      this.nextCanvas.height
    );
  }

  //모델에 block저장
  fixBlock() {
    const nowIdx = this.changeCnt % this.shape.location.length;
    this.shape.location[nowIdx].forEach((v) => {
      const left = this.startLeft + this.cellSize * v[0];
      const top = this.startTop + this.cellSize * v[1];
      this.model[top / this.cellSize + 1][left / this.cellSize] = this.shape.id;
    });
  }

  //model에 따라서 쌓인 블럭들 그리기
  reScreen() {
    const colorList = this.shapeModel.getColor();
    for (let top = 1; top < this.model.length; top++) {
      this.model[top].forEach((cell, left) => {
        if (cell !== 0) {
          const color = colorList[cell];
          this.renderBox(
            this.context,
            left * this.cellSize,
            (top - 1) * this.cellSize,
            color,
            this.cellSize
          );
        }
      });
    }
  }
  // context에 size크기의 정사각형 그리기
  renderBox(context, left, top, color, size) {
    context.beginPath();
    context.fillStyle = color;
    context.lineWidth = 1.5;
    context.rect(left, top, size, size);
    context.fill();
    context.stroke();
    context.closePath();
  }
}

클래스 생성 때 받는 arguments

  • KEY : 화살표와 스페이스바의 keycode.
  • selector : DOM에서 선택한 element.
  • START_POINT : 블럭이 위에서 생성될 때 생성되는 시작 위치
  • tetrisModel : class TetrisModel
  • shapeModel : class TetrisShapeModel
  • scoreLevelView : class ScoreLevelView

실행 과정

main.js에서 init()호출로 게임을 시작한다.

init() {
    this.clear();
    this.setNextShape();
    this.playBtn.addEventListener("click", this.handleClick.bind(this));
    this.resetBtn.addEventListener("click", this.handleClick.bind(this));
  }

this.clear()
1. 캔버스의 모든것을 지우고
2. 격자판 그리기
3. 테트리스 판 데이터를 갖고 다시 그리기

init 하게 되면 화면을 재구성하고, nextshape를 미리 설정해 놓습니다. 또한 play와 reset버튼에 event를 걸어 둡니다.

이제 play와 reset버튼을 누르면

  handleClick({ target }) {
    this.gameover.classList.add("hidden");
    this.model = this.tetrisModel.resetModel();
    this.tetrisModel.resetScore();
    this.scoreLevelView.updateScore();
    if (target.innerHTML === "RESET") {
      this.resetBlock();
      this.playBtn.disabled = false;
      document.removeEventListener("keydown", this.handleKeydown);
    } else {
      this.playBtn.disabled = true;
      document.addEventListener("keydown", this.handleKeydown);
      this.play();
    }
  }

게임오버가 화면을 다시 숨기고 모든 데이터를 초기화 시켜줍니다.
reset버튼이 눌렸을 때는 새로운 block이 생기기위한 준비를 하기위해 resetBlock() 함수가 실행되고 play버튼을 누르면 play합니다.

constructor을 자세히 보셨던 분이라면 의문점이 들겁니다.
왜? this.handleKeydown = this.handleKeydown.bind(this); 메소드를 따로 프로퍼티로 선언해서 사용하지?
-> 그 답변은 addEventListener 와 removeEventListener 를 사용할 때 this는 trigger의 element로 바인딩 됩니다. 이를 피하기위해 bind()를 사용하지만 이러면 새로운 함수가 생성되며 똑같이 생겼지만 다른 함수를 참조하게됩니다. 그렇기 때문에 removeEventListener가 제대로 동작이 되지 않아 따로 프로퍼티에 설정해놓고 사용해야 제대로 이벤트가 지워집니다.

1. play()호출되면 게임이 실행 됩니다.

  play() {
    this.resetBlock();
    this.shape = this.nextShape;
    this.setNextShape();
    this.renderNextBlock(this.nextShape.color);
    this.renderBlock(this.shape.color);
    if (this.checkGameOver()) {
      return this.finishPlay();
    }
    this.autoMove();
  }
  1. play는 바닥에 블럭이 고정되면 다시 play가 호출되기 때문에
    새로운 블럭을 위해 resetBlock()을 실행해 준다.
  2. 지금 떨어지는 this.shape를 nextShape를 가져와서 저장하고, 새로운 nextShape를 만든다.
  3. renderNextBlock,renderBlock에서 위의 데이터를 가지고 render해준다.
    • 여기서 블럭의 시작점은 left : 90, top: -30입니다. 왜 top이 -30일 까요? 이유는 play()호출되면 블럭이 render되면서 autoMove()에서 아래로 한칸 떨어 뜨립니다. 그렇기 때문에 맨 윗줄부터 시작하게 보이기 위해서 top을 -30으로 선언했다.
      (START_POINT에 전역 변수로 저장해서 파라미터로 받아서 사용한다.)
    • 이와 같은 이유로 테트리스 모델의 인덱스는 [0~20][0~9]입니다. canvas의 맨윗줄은 1번 인덱스이다.
  4. 게임이 끝났는지 체크한다.
    • checkGameOver 는 테트리스판 모델에서 1번 줄이 0이 아닌것이 있으면 어떤 블럭이 맨위에 쌓였다는 뜻이기 때문에 종료된다.
  5. render된 블록이 autoMove() 됩니다. setTimeout함수로 일정 시간마다 아래로 떨어지게 만들었습니다. level에 따라 setTimeout의 delay시간이 달라진다.

Keyboard Event

  • 충돌 체크

changeCnt를 이용해 현재 바뀐 블럭을 체크할 수 있다.

4개의 셀들을 돌면서 확인한다.

  • 블럭 한칸의 시작점 >= 캔버스의 높이 -> false
  • 블럭 한칸의 model 좌표가 0이 아닐 때 -> false
  checkBlock(startLeft, startTop) {
    const nowIdx = this.changeCnt % this.shape.location.length;
    for (let size of this.shape.location[nowIdx]) {
      const left = startLeft + this.cellSize * size[0];
      const top = startTop + this.cellSize * size[1];
      if (top >= this.canvas.height) {
        return false;
      }
      if (this.model[top / this.cellSize + 1][left / this.cellSize] !== 0) {
        return false;
      }
    }
    return true;
  }

1. 좌, 우 이동

moveLeft() {
    if (this.checkBlock(this.startLeft - this.cellSize, this.startTop)) {
      this.clear();
      this.startLeft -= this.cellSize;
      this.renderBlock(this.shape.color);
    }
  }

좌, 우 같은 논리로 된다. checkBlock에 이동할 좌표를 입력해 체크해보고 true일 경우에만 지웠다가 한칸 옆에 다시 render해준다.

2. 아래 화살표 - 아래로 한칸 이동, 스페이스바-drop, autoMove

3개 모두 같은 형태이지만 조건 하나씩만 다르다.

  moveDown() {
    if (this.checkBlock(this.startLeft, this.startTop + this.cellSize)) {
      this.clear();
      this.startTop += this.cellSize;
      this.renderBlock(this.shape.color);
    } else {
      this.fixBlock();
      this.deleteLine();
      this.play();
    }
  }

아래로 움직였을 때 checkBlock 한뒤 이동시킨다.
하지만 아래로 이동할 때 checkBlock 이 false 라는 것은 고정 되야 된다는 뜻이기 때문에 고정시킨다.

1. `fixBlock()` 떨어진 좌표값으로 그 block의 id를 테트리스 모델에 입력한다.
2. `deleteLine()` 한 줄이 채워졌을 때 그 줄을 모델에서 splice로 지워주고 한줄 추가
3. `play()` 새로운 블럭 play.

moveDown(), drop(), autoMove() 모두 if문 안에 한칸 씩 만다르다.

moveDown() : 한칸만 내려가는 것이기 때문에 위의 코드 그대로이다.
drop(): canvas의 이벤트인 requestAnimationFrame(this.drop.bind(this))를 사용해 빠르게 내려고헤 했다.
autoMove() : setTimeout을 이용해 재귀로 다시 호출해 주었다. level에 따라 속도를 조절하게 설정했다.

3. 모양 바꾸기

화살표 위키를 누르면 change()를 호출한다.

 change() {
    this.clear();
    this.changeCnt++;
    if (!this.checkBlock(this.startLeft, this.startTop)) {
      if (this.checkBlock(this.startLeft - this.cellSize, this.startTop)) {
        this.startLeft -= this.cellSize;
      } else if (
        this.checkBlock(this.startLeft + this.cellSize, this.startTop)
      ) {
        this.startLeft += this.cellSize;
      } else if (this.shape.name === "I") {
        this.startLeft -= this.cellSize * (this.shape.width - 1);
      } else {
        this.changeCnt--;
      }
    }
    this.renderBlock(this.shape.color);
  }

모양을 바꿀 때도 충돌방지를 해주어야 한다.
벽에서 모양을 바꾼다면 벽 너머로 넘어갈 수 있기 때문이다.
하지만 벽에서 또 모양이 바뀌면 안되기 때문에 위와 같이 조건을 설정해 주었다.

이 부분이 아직 미완성인 부분이기도하다.. 나중에 꼭 고칠 것이다. 하드코딩으로 이루어져있다. 나중에는 각모양의 width를 구해서 그것만큼 벽에서 떨어뜨려주게 만들어서 구상 할 것이다.

일단 위처럼 하면 하드코딩이긴 하지만 동작은 된다..
결론은! change할 때도 충돌을 체크해야한다.


크게 이정도로 테트리스의 큼지막한 기능들은 설명 됐다고 생각된다.
하나의 글에서 테트리스를 모든 과정을 자세하게 설명하기에는 많이 힘든 것 같다. 간단한 흐름정도와 주요 로직들을 파악한 뒤에는 스스로 구현해보는 것을 추천드린다.

리팩토리 전 코드를 코드펜에도 작성해 봤다. 테트리스-Kyle

깃허브에는 모듈로 파일을 나눠놨기 때문에 보기 클래스끼리 메소드를 이용하는걸 확인하기 귀찮을 수 있다. 그렇다면 저 코드펜의 코드를 보고 간단한 흐름을 익히는 것도 좋은 방법이라고 생각된다.


프로젝트 때 힘들었던? 에러

addEventListener , removeEventListener

addEventListenerremoveEventListener를 사용할 때 똑같은 이벤트에 똑같은 콜백함수를 적어주어야 그 이벤트가 정확하게 사라지게 된다.

하지만 class에서 addEventListener를 사용하게 되면 this가 event의 트리거가된 element에 바인딩 되기 때문에 콜백함수를 bind함수로 바인딩해 작성했었다.

이 과정에서 callbackFn.bind(this)를 하게 된다면, bind함수가 this로 바인딩한 새로운 함수를 만들어 내는 것이기 때문에 아래와 같이 작성해주어도 callback함수는 서로 다른 callback함수를 가르키게 된다.

element.addEventListener("click", callbackFn.bind(this));
element.removeEventListener("click", callbackFn.bind(this));

해결방법

나는 constructor의 property에 this.callback = this.callbackFn.bind(this)
이런식으로 바인딩해 this.callback 이 자체를 콜백함수에 넣어주었다.

class의 argument와 메소드에서의 활용

class에서 argument로 START_POINT를 받아와서 사용했다. 하지만 메소드 안에서 START_POINT를 바로 활용하려 했는데 활용이 되지 않았다.

해결방법

this.START_POINT = START_POINT 로 constructor의 프로퍼티로 작성해서 활용했다.

간단하게 해결됐던 문제였지만, 인자를 메소드에서 직접적으로 사용못한다는 점을 배웠다.

import, export

아직까지 이것이 해결된 정확한 이유는 알지 못한다.

일단, import,export를 사용했다. html의 script에서 type='module' 속성을 주게 되면 사용할 수 있었다.

나는 프로젝트를 로컬 환경에서 실행하고 확인하였는데 import, export를 사용하고 로컬 환경에서 실행하니 CORS policy error 가 발생하였다. 간단하게 프로토콜, 호스트, 포트가 다르면 안되는 브라우저 보안 방침이다. local에서 실행했기 때문에 당연히 null이므로 에러가 발생하는 것이었다.

// 다음에 CORS policy 와 SOP 에 대해서 자세히 알아 볼 것이다.

아무튼! 구글에서 간단한 해결법을 찾았다. http-server을 npm에서 install해 사용하면 된다는 것이었는데... 또 다른 에러가 계속 발생해 해결하지 못했다.

그래서 코드스쿼드 동료분들에게 도움을 요청했는데 다들 너무 잘 도와주셔서 vsc의 extension중 live server을 이용해서 실행해보니 한번에 해결됐다!

해결원리는 아직 잘모른다... 이 부분에 대해서는 앞으로도 자주 마주칠 문제일 것 같으니 자세하게 공부해 보는 것이 좋을 것 같다!


후기

혼자서의 힘으로만 진행하고 내가 선택한 주제로 진행한 첫 프로젝트이다. 캔버스를 처음 사용해보면서 너무 재밌기도 했고 기능을 하나씩 구현해 가면서 많은 것을 배울 수 있는 기회였던 것 같다.

코드스쿼드 코코아 과정을 진행하면서 정말 많은 것을 배울 수 있었고 동료분들의 많은 도움으로 과정을 잘 즐겁게 마무리 할 수 있어서 행복한 시간이었던 것 같다~~!

profile
Kyle 발전기

0개의 댓글