프로젝트 아이디어를 고민하다가 우연히 테트리스 게임을 접하게 되었는데 기능은 간단하지만 챌린징 해 보여서 '한 번 만들어 볼까?' 싶었다.
테트리스 게임의 알고리즘도 구현해야 하고, 화면에 그릴 때는 canvas
를 사용해 보면 좋을 것 같았다.
3/22 ~ 4/6 (2주간 진행. 약 8일 소요)
src/
├── pages: 게임 화면을 구성하는 페이지
│ ├── main
│ ├── game
├── components: page에서 사용하는 공통 컴포넌트 (atomic design system 적용)
├── core: 게임의 코어 로직
├── view: 캔버스에 그릴 View 요소 정의
├── context: 전역 상태관리 (Context API)
├── hooks: 커스텀 훅
├── types: 공통 type
├── utils: 유틸리티 기능
└── App.tsx
간단하게 프로젝트 주요 구성 요소를 도식화 해보면 다음과 같다.
아래 도식에서 화살표(→)는 사용한다
, 알고있다
의 의미이다.
테트로미노는 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();
}
}
테트로미노는 자기 자신의 matrix
와 position
을 가지고 있고, 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는 정착한 테트로미노만 저장하는 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();
}
}
미리 떨어지는 위치를 보여주는 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을 바닥에 닿을 때까지 내리면 된다.
리액트 컴포넌트에서는 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]);
캔버스에서는 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 컴포넌트가 점점 비대해지고 여러 Dialog
가 추가되면서 이를 분리하기 위해 Presenter-Container 패턴을 적용했다.
GameContainer.tsx
파일은 화면에 보여줄 데이터와 이벤트를 처리하고, Game.tsx
는 그 데이터를 받아 화면에 그리는 역할을 한다.
Dialog를 구현하면서 여러 유형의 Dialog를 블럭처럼 조립해서 구현하면 좋겠다는 생각이 들어 Styled Component를 활용한 Atomic Design Pattern을 적용 해 보았다.
기본적으로 앱 전반적인 테마에 맞게 Title
, Button
과 같은 atomic한 컴포넌트를 만든다.
Dialog
컴포넌트는 다음과 같이 atomic한 Title
과 Button
을 다이얼로그에 맞게 수정 해서 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>
);
};
캔버스의 크기를 변경하려고 하는 경우 주의해야 한다.
style로 크기를 변경하는 것과 canvas.width
, canvas.height
로 크기를 변경하는 것의 동작이 다르기 때문이다.
실제 캔버스의 크기를 변경하고 싶다면 canvas
의 width
, height
값을 설정 해야 하고, style로 크기를 설정하는 경우는 캔버스 뷰를 설정한 크기로 늘리는 것이다.
그래서 실제 크기가 300x150인 캔버스의 style을 width:150px, height:150px
으로 바꾸면 아래와 같이 찌그러진 모양이 된다.
캔버스의 실제 크기를 화면에 보이는 크기에 맞게 설정하고 싶다면 아래처럼 설정 해 줘야 한다. (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와 관련 없는 대상을 다룰 때 발생하는 문제이다.
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]);
디자인
피그마를 이용해 대략적인 디자인 만든 후 진행 (피그마 파일)
관심사 분리
2.1. core와 view 분리
core는 view를 전혀 모르는 상태로 게임 로직을 구현하려고 했다.
그렇게 하면 웹 페이지가 아닌 다른 환경(ex. 콘솔, 모바일 등)에서도 테트리스를 쉽게 구현할 수 있어 확장성 있는 애플리케이션을 만들 수 있다.
실제로 개발 중에 콘솔로 테스트 후 손쉽게 Canvas에 적용 시킬 수 있었다.
2.2. atomic design system 적용
앱의 테마에 맞는 atom 컴포넌트를 만들어두고, 상황에 따라 atom 컴포넌트를 조금씩 수정해 사용하면 재사용성이 높은 컴포넌트 구조를 만들 수 있도록 했다.
MVP 위주의 점진적 개발 및 지속적인 배포
테트리스 기획은 많은 기능이 있지만 이 중에 가장 중요한 프로덕트를 만들어 배포하고 조금씩 살을 붙여나가면서 지속적으로 개선해 나가는 방식으로 진행하고자 했다.
실제로 첫 배포는 게임만 플레이 할 수 있는 수준이었다. (점수 X, 다음 테트로미노 미리보기 X)
흥미 위주의 프로젝트인 만큼 재미있고 편하게 만들긴 했지만 전략적으로 진행하지 못한 부분이 아쉽다.
구현해야 하는 목록만 리스트업 해 두고 편하게 개발해 나갔는데, 그래서인지 시간을 비효율적으로 소비한 부분이 있었다.
다음부터는 프로젝트의 목표를 명확히 하고 주기적으로 점검하는 시간을 가지는 게 좋을 것 같다.
다음 프로젝트에는 목표를 좀 더 높게 잡고 효율적으로 많은 것을 배울 수 있도록 노력 해야겠다. 개발하면서 겪은 트러블슈팅도 꼼꼼하게 기록하자.
이후에 여유가 된다면 아래의 기능도 추가하고 싶다.
테트리스가 단순히 블럭 이동시키며 내려놓는 게임이라고 생각했는데 테트리스 위키도 있을 정도로 게임 가이드라인이 꽤나 견고했다.
점수, 레벨, 속도, 다음 블럭 나오는 규칙 등을 새롭게 알게 되었고, 해당 규칙들을 최대한 따르면서 중요하지 않은 부분은 재량껏 만들었다.
테트리스 게임은 사이드 프로젝트를 고민할 때마다 맴돌던 주제였는데 드디어 만들어봤다! 게임을 만들면서 테스트 하다보니 의도치 않게 게임 실력도 오른 것 같다ㅋㅋ