본문 바로가기

Ecmascript/Canvas

[테트리스 만들기] 테트리스 기능 구현(다음 블록 생성, 한줄 채우면 사라지고 점수 증가)

고정하기

  • 테트리미노 화면 바닥에 닿으면 조각을 고정하고 새로운 조각을 생성해야 한다.
  • 먼저 조각을 고정하는 freeze() 함수 생성해서 테스트
//*** board.js
class Board {
  grid;    // 보드 그리드
  piece;   // 현재 조각 객체

  // ... 기존 코드

  drop() {
    let p = moves[KEY.DOWN](this.piece);
    if (this.valid(p)) {
      this.piece.move(p);
    } else {
      // 유효한 공간이 아니면 못움직이게 고정
      this.freeze();
      if (this.piece.y === 0) {
        // Game over
        return false;
      }
    }

    return true;
  }

  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.grid를 콘솔 찍어보면 해당 그리드에 블록이 차지

추가 블록(테트리미노) 생성하기

  • 블록이 바닥에 닿으면 추가로 블록을 생성
  • 블록을 추가된다는건 각 조각의 인스턴스마다 속성값을 가지고 있어야 된다는 것
    • piece의 typeID의 인스턴스화 this.typeId으로 접근하게 변경
  • 각 조각 시작위치를 구하는 부분을 추가
//*** piece.js
class Piece {
  x;
  y;
  color;
  shape;
  ctx;
  typeId; // 조각타입도 인스턴스마다 할당(this.typeId으로 접근)

  constructor(ctx) {
    this.ctx = ctx;
    this.spawn();
  }

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

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

  // ... 기존 코드

  // 키보드로 움직이기
  move(p) {
    this.x = p.x;
    this.y = p.y;
    this.shape = p.shape;
  }

  // ... 기존 코드

  // 시작 위치
  setStartingPosition() {
    this.x = this.typeId === 4 ? 4 : 3;
  }
}
  • 블록을 추가하기 위해 다음 조각의 객체와 캔버스 객체를 추가한다
  • main.js에 있던 캔버스와 블록 크기 관련 로직을 가져와서 board의 init() 메서드에서 처리
  • 다음 조각 생성하는 getNewPiece() 메서드 생성
  • 끝까지 내려오면(drop) getNewPiece() 호출해서 새로운 조각 생성
  • 각 인스턴스가 회전할수 있게 boardd의 rotate() 메서드를 수정 필요
    • 로직이 변경되는건 아니고 원본 블로거가 반사행렬할때 언급안하고 변수명들을 바꿔놨다다. (clone -> p, p -> piece)
//*** board.js
class Board {
  grid;    // 보드 그리드
  piece;   // 현재 조각 객체
  next;    // 다음 조각 객체
  ctx;     // 현재 조각의 캔버스
  ctxNext; // 다음 조각의 캔버스

  constructor(ctx, ctxNext) {
    this.ctx = ctx;
    this.ctxNext = ctxNext;
    this.init();
  }

  init() {
    // 캔버스 크기 계산
    this.ctx.canvas.width = COLS * BLOCK_SIZE;
    this.ctx.canvas.height = ROWS * BLOCK_SIZE;

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

    // 다음 조각 생성
    this.getNewPiece();
  }

  // 게임 초기화
  reset() {
    // 보드 초기화
    this.grid = this.getEmptyBoard();
    // 새로 조각 생성
    this.piece = new Piece(this.ctx);
    this.piece.setStartingPosition();
    this.getNewPiece();
  }

  // ... 기존 코드

  rotate(piece){
    // 불변성을 위해 JSON으로 복사
    let p = JSON.parse(JSON.stringify(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());

    return p;
  }

  drop() {
    let p = moves[KEY.DOWN](this.piece);
    if (this.valid(p)) {
      this.piece.move(p);
    } else {
      // 유효한 공간이 아니면 못움직이게 고정
      this.freeze();
      if (this.piece.y === 0) {
        // Game over
        return false;
      }
      // 끝까지 내려오면 새로운 조각 생성
      this.piece = this.next;
      this.piece.ctx = this.ctx;
      this.piece.setStartingPosition();
      this.getNewPiece();
    }
    return true;
  }

  // ... 기존 코드

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

  draw() {
    this.piece.draw(); // 조각을 칠해서 테트로미노로 만듬
    this.drawBoard();  // 테트로미노 색으로 칠해서 화면을 그림
  }

  // 다음 조각 생성
  getNewPiece() {
    this.next = new Piece(this.ctxNext);
    this.ctxNext.clearRect(
      0,
      0, 
      this.ctxNext.canvas.width, 
      this.ctxNext.canvas.height
    );
    this.next.draw();
  }
  • 블록을 추가할수 있게 메인에서 다음 블록의 캔버스 객체를 추가
  • board의 init() 메서드로 옮긴 캔버스와 블록 크기 관련 로직 삭제
  • 각 인스턴스마다 애니메이션을 반복하게 requestId 변수 추가해서 play() 함수의 로직을 변경한다.
  • 다음 블록 크기를 만드는 initNext() 함수 선언 및 호출
//*** main.js
// 현재 블록
const canvas = document.getElementById('board');
const ctx = canvas.getContext('2d');
// 다음 블록
const canvasNext = document.getElementById('next');
const ctxNext = canvasNext.getContext('2d');

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

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

let requestId;

let board = new Board(ctx, ctxNext);

initNext();

function initNext() {
  // Calculate size of canvas from constants.
  ctxNext.canvas.width = 4 * BLOCK_SIZE;
  ctxNext.canvas.height = 4 * BLOCK_SIZE;
  ctxNext.scale(BLOCK_SIZE, BLOCK_SIZE);
}

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

  // let piece = new Piece(ctx);
  // board.piece=piece;

  if (requestId) {
    cancelAnimationFrame(requestId);
  }

  // piece.draw(); // 테트로미노 그리기
  animate();
}

// ... 기존 코드

function animate(now=0){
  // ... 기존 코드

  // board에 piece 추가했으므로 piece 삭제
  board.draw();  

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

// ... 기존 코드

꽉찬 라인 지우기

  • 꽉찬 라인은 사라지고, 그 위에 나머지 조각들이 남아야 함
  • 라인은 지우는 clearLines() 메서드 생성
  • 한 라인의 모든 값이 0보다 크면 행을 삭제하고 맨 위에 0으로 채워진 행을 추가하는 원리
//*** board.js
// ... 기존 코드

  drop() {
    let p = moves[KEY.DOWN](this.piece);
    if (this.valid(p)) {
      this.piece.move(p);
    } else {
      // 유효한 공간이 아니면 못움직이게 고정
      this.freeze();
      // 한 라인이 꽉차면 블록 삭제
      this.clearLines();
      if (this.piece.y === 0) {
        // Game over
        return false;
      }
      // 끝까지 내려오면 새로운 조각 생성
      this.piece = this.next;
      this.piece.ctx = this.ctx;
      this.piece.setStartingPosition();
      this.getNewPiece();
    }
    return true;
  }

// ... 기존 코드

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

점수 기능 : Proxy

  • 아래처럼 점수를 나타내는 코드를 추가
//*** constants.js

// 이벤트에 따른 점수 표시
const POINTS = {
  SINGLE: 100,
  DOUBLE: 300,
  TRIPLE: 500,
  TETRIS: 800,
  SOFT_DROP: 1,
  HARD_DROP: 2
}
Object.freeze(POINTS);
  • board에 지운 라인수 체크하는 로직 추가하고 라인수당 점수를 반환하는 getLineClearPoints() 메서드 추가
//*** board.js

// ... 기존 코드

  clearLines() {
    let lines = 0; // 지운 라인 수

    this.grid.forEach((row, y) => {
      // 모든 값이 0보다 큰지 비교한다.
      if (row.every(value => value > 0)) {
        lines++;

        // 행을 삭제한다.
        this.grid.splice(y, 1);

        // 맨 위에 0으로 채워진 행을 추가한다.
        this.grid.unshift(Array(COLS).fill(0));
      } 
    });

    if (lines > 0) {    
      // 지워진 라인들이 있다면 점수를 추가
      account.score += this.getLineClearPoints(lines);  
    }
  }

  // 지운 라인 갯수당 점수 제공
  getLineClearPoints(lines) {  
    let score = 
      lines === 1 ? POINTS.SINGLE :
      lines === 2 ? POINTS.DOUBLE :  
      lines === 3 ? POINTS.TRIPLE :     
      lines === 4 ? POINTS.TETRIS : 
      0;
    return score;
  }
}
  • 점수와 블록 라인을 계산하기 위한 accountValues 객체를 추가
  • accountValues 객체는 이벤트에 따라 다르게 값이 할당되는 객체이며 이 객체를 Proxy 객체에 전달
  • Proxy 객체를 생성하고 set 메서드에서 화면의 점수 텍스트를 변경하는 updateAccount() 메서드 실행
//*** main.js

// ... 기존 코드

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)) {
        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; // 아래 방향키 눌러서 빨리 내리면 점수 증가
      }
    }

    if(board.valid(p)) {
      // 이동 가능한 조각을 이동
      board.piece.move(p);
      // 그리기 전에 이전 좌표를 삭제
      ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
      // 테트로미노 그림
      board.piece.draw();
    }
  }
});

// 점수 계산
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;
  }
});