[프로젝트] 테트리스 회고

엘리(Ellie)·2023년 4월 11일
0
post-thumbnail

전체 코드는 Github에서 보실 수 있습니다.
여기에서 게임을 직접 플레이 하실 수 있습니다.

프로젝트 설명

주제 선정

프로젝트 아이디어를 고민하다가 우연히 테트리스 게임을 접하게 되었는데 기능은 간단하지만 챌린징 해 보여서 '한 번 만들어 볼까?' 싶었다.
테트리스 게임의 알고리즘도 구현해야 하고, 화면에 그릴 때는 canvas를 사용해 보면 좋을 것 같았다.

프로젝트 수행 기간

3/22 ~ 4/6 (2주간 진행. 약 8일 소요)

목표

  • 테트리스 알고리즘 구현
  • html5 canvas 사용 해 보기

사용 기술

  • React + Vite
    : 이전 프로젝트에서는 주로 CRA를 사용했는데 Vite가 빌드 속도가 빠르고 가볍다고 해서 한 번 사용해 보았다.
  • TypeScript
  • HTML5 Canvas
  • Styled Component
    : 빠르게 스타일링할 수 있는 TailwindCSS와 고민하다가 스타일링을 좀 더 구조적으로 할 수 있을 것 같아서 Styled Component를 선택했다.
  • Context API
    : React로 관리해야 하는 전역 상태가 많지 않았기 때문에 간단하게 사용할 수 있는 Context API를 선택했다.

프로젝트 구조

src/
├── pages: 게임 화면을 구성하는 페이지
│   ├── main
│   ├── game
├── components: page에서 사용하는 공통 컴포넌트 (atomic design system 적용)
├── core: 게임의 코어 로직
├── view: 캔버스에 그릴 View 요소 정의
├── context: 전역 상태관리 (Context API)
├── hooks: 커스텀 훅
├── types: 공통 type
├── utils: 유틸리티 기능
└── App.tsx

주요 구성요소

간단하게 프로젝트 주요 구성 요소를 도식화 해보면 다음과 같다.
아래 도식에서 화살표(→)는 사용한다, 알고있다의 의미이다.

screen-shot

구현 방법

Core

다음 테트로미노 생성

테트로미노는 7가지 종류의 블럭이 있는데, 한 번의 루프 안에서 모든 블럭이 랜덤한 순서로 나와야 한다.
이 규칙을 만족하는 TetrominoGenerator를 다음과 같이 만들었다.

class TetrominoGenerator {
  private bag: Set<Type> = new Set(ALL_TYPES);

  next(): TetrominoBase {
    if (this.bag.size === 0) this.bag = new Set(ALL_TYPES);

    const nextType = randomItem([...this.bag]);
    this.bag.delete(nextType);

    return genTetromino(nextType);
  }
}

// Tetromino 블럭의 종류
type Type = 'Z' | 'L' | 'O' | 'S' | 'I' | 'J' | 'T';
const ALL_TYPES: readonly Type[] = ['Z', 'L', 'O', 'S', 'I', 'J', 'T'];

// Type에 따라 Tetromino 데이터 인스턴스 생성
function genTetromino(type: Type): TetrominoBase {
  switch (type) {
    case 'Z': return new TetrominoZ();
    case 'L': return new TetrominoL();
    case 'O': return new TetrominoO();
    case 'S': return new TetrominoS();
    case 'I': return new TetrominoI();
    case 'J': return new TetrominoJ();
    case 'T': return new TetrominoT();
  }
}

테트로미노 Transform (이동/회전)

테트로미노는 자기 자신의 matrixposition을 가지고 있고, transform을 적용 할 수 있다.

class TetrominoBase {
  matrix: Matrix = new Matrix([]);
  // tetromino의 좌상단 좌표
  position: Pos = { x: 0, y: 0 };

  transform({ dx, dy, rotR, rotL }: Transform) {
    if (dx) this.position.x += dx;
    if (dy) this.position.y += dy;
    if (rotR) this.rotateRight();
    if (rotL) this.rotateLeft();
  }

  rotateRight(times: number = 1) {
    this.matrix.rotateRight(times);
  }

  rotateLeft(times: number = 1) {
    this.matrix.rotateLeft(times);
  }
  
  ...
}

Tetris에서는 넘어오는 액션에 맞게 해당 테트로미노의 transform()을 호출한다.

테트로미노 board에 내려놓기

board는 정착한 테트로미노만 저장하는 Matrix이다.
board를 검사하고 테트로미노를 내려놓는(Drop) 함수는 다음과 같다.

  checkAndDrop() {
    // 바닥에 닿았는지 확인
    if (!isBottomAttached(this.board, this.tetromino)) return;

    // 1. board에 적용
    let pos = this.tetromino.position;
    this.tetromino.matrix.forEach((x, y, val) => {
      if (val > 0) this.board.set(pos.x + x, pos.y + y, val);
    });

    // 2. 완성 된 라인 지우기
    const lines = sweepLines(this.board);
    if (lines > 0) {
	  // 라인이 있으면 점수 & 스피드 반영
    }

    // 3. 다음 tetromino 가져오기
    this.tetromino = this.pickNextTetromino();

	// 4. 새로운 tetromino가 기존 board와 충돌한 경우 게임 오버
    if (isCollided(this.board, this.tetromino)) {
      this.gameOver();
    }
  }

Hard drop

미리 떨어지는 위치를 보여주는 previewTetromino가 있다면 target(현재 테트로미노)을 해당 위치로 이동 시킨다.

그렇다면 previewTetromino는 어떻게 구할까?
단순히 테트로미노를 바닥에 닿을 때까지 직접 내려보면 된다.

  createPreviewTetromino(): TetrominoBase {
    const target = this.tetromino.duplicate();

    while (!isBottomAttached(this.board, target)) {
      target.transform({ dy: 1 });
    }

    return target;
  }

target의 형태가 바뀔 때마다 previewTetromino를 새로 만들어 업데이트 해 준다.

previewTetromino가 없다면 더 간단하다. hardDrop 액션이 왔을 때 target을 바닥에 닿을 때까지 내리면 된다.

View

React Component와 Tetris 연동

리액트 컴포넌트에서는 KeyEventListener를 등록해서 키보드 키와 Tetris의 액션을 연동한다.
Tetris의 상태가 바뀌었을 때 그에 대응하는 화면이 업데이트 되도록 하기 위해 onChange 핸들러와 React State를 적절히 사용했다.

const [tetris, setTetris] = useState<Tetris>(new Tetris());
const [scoreState, setScoreState, bestScore] = useScoreboard(tetris.scoreBoard.state);

useEffect(() => {
  // 테트리스 초기화
  tetris.scoreBoard.onStateChanged = setScoreState;
}, [tetris]);

Canvas와 Tetris 연동

캔버스에서는 Tetris의 데이터를 이용해 그리고자 하는 View를 만들어서 화면에 그린다.

ex) GameCanvas(게임 화면을 그리는 캔버스)의 주요 코드

class GameCanvas {
  // ...
  draw() {
    const boardView = new BoardView(this.tetris.board);
    boardView.draw();

    const tetrominoPreview = new TetrominoPreview(this.tetris.previewTetromino);
    tetrominoPreview.draw();

    const tetrominoView = new TetrominoView(this.tetris.tetromino);
    tetrominoView.draw();
  }
}

Game 컴포넌트 Presenter-Container 패턴 적용

게임의 주요 로직을 처리하는 Game 컴포넌트가 점점 비대해지고 여러 Dialog가 추가되면서 이를 분리하기 위해 Presenter-Container 패턴을 적용했다.
GameContainer.tsx 파일은 화면에 보여줄 데이터와 이벤트를 처리하고, Game.tsx는 그 데이터를 받아 화면에 그리는 역할을 한다.

Atomic Design Pattern 적용

Dialog를 구현하면서 여러 유형의 Dialog를 블럭처럼 조립해서 구현하면 좋겠다는 생각이 들어 Styled Component를 활용한 Atomic Design Pattern을 적용 해 보았다.

기본적으로 앱 전반적인 테마에 맞게 Title, Button과 같은 atomic한 컴포넌트를 만든다.

Dialog 컴포넌트는 다음과 같이 atomic한 TitleButton을 다이얼로그에 맞게 수정 해서 Dialog.Title, Dialog.Button을 만들었다.

const Dialog = ({ style, children }: Props) => {
  return (
    <SC.Container>
      <SC.Content style={style}>{children}</SC.Content>
    </SC.Container>
  );
};

Dialog.Title = styled(Title)`
  margin-bottom: 0.4em;
`;

Dialog.Button = styled(Button)`
  width: 250px;
  margin: 0.3em 0em;
`;

대표적인 다이얼로그인 PausedDialog는 아래 처럼 만들 수 있었다.
컴포넌트 자체가 어떤 모습인지 어떤 역할을 하는지 스스로 설명하는 것 같아 마음에 들었다.

const PausedDialog = ({ onResume, onRestart, onHelp, onQuit }: Props) => {
  return (
    <Dialog>
      <Dialog.Title>PAUSED</Dialog.Title>
      <Dialog.Button onClick={onResume}>RESUME</Dialog.Button>
      <Dialog.Button onClick={onRestart}>RESTART</Dialog.Button>
      <Dialog.Button onClick={onHelp}>HELP</Dialog.Button>
      <Dialog.Button onClick={onQuit}>QUIT</Dialog.Button>
    </Dialog>
  );
};

트러블 슈팅

canvas width, height 설정 문제

캔버스의 크기를 변경하려고 하는 경우 주의해야 한다.
style로 크기를 변경하는 것과 canvas.width, canvas.height로 크기를 변경하는 것의 동작이 다르기 때문이다.

실제 캔버스의 크기를 변경하고 싶다면 canvaswidth, height 값을 설정 해야 하고, style로 크기를 설정하는 경우는 캔버스 뷰를 설정한 크기로 늘리는 것이다.
그래서 실제 크기가 300x150인 캔버스의 style을 width:150px, height:150px으로 바꾸면 아래와 같이 찌그러진 모양이 된다.

canvas-image

캔버스의 실제 크기를 화면에 보이는 크기에 맞게 설정하고 싶다면 아래처럼 설정 해 줘야 한다. (offsetWidth, offsetHeight는 캔버스가 화면에 차지한 크기를 나타낸다.)

canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;

이차원 배열 선언

이건 정말 단순한 함정(?)인데 항상 주의하지 않은 순간에 마주쳐서 당황스럽게 만든다...

아래와 같이 2차원 배열을 선언하면 모든 row가 같은 객체를 가리키게 되면서 arr[1][1] = 1이라고 하면 모든 row의 2번째 값이 1로 바뀐다.

new Array(row).fill(new Array(col).fill(0));

아래와 같이 만들어야 한다.

Array.from(Array(row), () => Array(col).fill(0))

React.StrictMode에서 캔버스 두 번씩 그려지는 문제

이건 캔버스도 그렇고 이벤트도 그렇고 React와 관련 없는 대상을 다룰 때 발생하는 문제이다.
useEffect 안에서 캔버스나 이벤트를 초기화 했다면 컴포넌트가 unmount 되는 시점에 초기화 해 준 대상들을 함께 없애줘야 한다.
그렇지 않으면 React.StrictMode에서 두 번씩 호출되는 문제를 겪게 된다.

  useEffect(() => {
    // 캔버스 생성
    gameCanvas = new GameCanvas(tetris);
    previewCanvas = new PreviewCanvas(tetris);

	// ...
	const keyEventListener = ...;
    addEventListener('keydown', keyEventListener);

    return () => {
      // 키보드 이벤트 제거
      removeEventListener('keydown', keyEventListener);

      // 캔버스 지우기
      gameCanvas?.stopAnimation();
      gameCanvas = undefined;
      previewCanvas?.stopAnimation();
      previewCanvas = undefined;
    };
  }, [tetris]);

되돌아보기

프로젝트에서 중요하게 생각했던 부분?

  1. 디자인
    피그마를 이용해 대략적인 디자인 만든 후 진행 (피그마 파일)

  2. 관심사 분리

    2.1. core와 view 분리
    core는 view를 전혀 모르는 상태로 게임 로직을 구현하려고 했다.
    그렇게 하면 웹 페이지가 아닌 다른 환경(ex. 콘솔, 모바일 등)에서도 테트리스를 쉽게 구현할 수 있어 확장성 있는 애플리케이션을 만들 수 있다.
    실제로 개발 중에 콘솔로 테스트 후 손쉽게 Canvas에 적용 시킬 수 있었다.

    2.2. atomic design system 적용
    앱의 테마에 맞는 atom 컴포넌트를 만들어두고, 상황에 따라 atom 컴포넌트를 조금씩 수정해 사용하면 재사용성이 높은 컴포넌트 구조를 만들 수 있도록 했다.

  3. MVP 위주의 점진적 개발 및 지속적인 배포
    테트리스 기획은 많은 기능이 있지만 이 중에 가장 중요한 프로덕트를 만들어 배포하고 조금씩 살을 붙여나가면서 지속적으로 개선해 나가는 방식으로 진행하고자 했다.
    실제로 첫 배포는 게임만 플레이 할 수 있는 수준이었다. (점수 X, 다음 테트로미노 미리보기 X)

배운 점

  • Html5 canvas를 공부해서 적용 해 보았고, React와는 어떻게 함께 사용할 수 있는지 알게 되었다.
  • Styled component를 활용해 atomic 컴포넌트를 만들어 사용하는 곳에서 스타일을 확장해서 사용하는 방법을 경험했다.

아쉬운 점

흥미 위주의 프로젝트인 만큼 재미있고 편하게 만들긴 했지만 전략적으로 진행하지 못한 부분이 아쉽다.
구현해야 하는 목록만 리스트업 해 두고 편하게 개발해 나갔는데, 그래서인지 시간을 비효율적으로 소비한 부분이 있었다.
다음부터는 프로젝트의 목표를 명확히 하고 주기적으로 점검하는 시간을 가지는 게 좋을 것 같다.
다음 프로젝트에는 목표를 좀 더 높게 잡고 효율적으로 많은 것을 배울 수 있도록 노력 해야겠다. 개발하면서 겪은 트러블슈팅도 꼼꼼하게 기록하자.

이후에 여유가 된다면 아래의 기능도 추가하고 싶다.

  • 효과음
  • 테스트 코드
  • 모바일 환경에서 플레이

맺으며

테트리스가 단순히 블럭 이동시키며 내려놓는 게임이라고 생각했는데 테트리스 위키도 있을 정도로 게임 가이드라인이 꽤나 견고했다.
점수, 레벨, 속도, 다음 블럭 나오는 규칙 등을 새롭게 알게 되었고, 해당 규칙들을 최대한 따르면서 중요하지 않은 부분은 재량껏 만들었다.
테트리스 게임은 사이드 프로젝트를 고민할 때마다 맴돌던 주제였는데 드디어 만들어봤다! 게임을 만들면서 테스트 하다보니 의도치 않게 게임 실력도 오른 것 같다ㅋㅋ

profile
신기하고 재미있는 것 만들기를 좋아합니다 :)

0개의 댓글