v0.0.1-beta.1
뱀 게임 만드는 법
시작하는 프로그래머를 위한 인터랙티브 튜토리얼. JavaScript를 배웠고 React의 setState도 알지만 프로그램을 짜는 법을 배우진 못했다고 느끼나요? 고전 게임을 만들면서 프로그램을 조감하는 기분을 만끽해보세요.
JavaScript를 익혔고, React 튜토리얼도 한번 따라해보았나요? 아마 지금쯤 지루한 Click Counter, FizzBuzz, 혹은 To-do List 말고 조금 더 흥미로운 다음 단계의 프로그램을 만들어보고 싶다고 생각하고 있을지도 모르겠군요. 비동기나 상태 관리 같은 중요한 키워드를 학습할 수 있고, 다른 무엇보다도 만드는 과정이 나에게 깨달음을 줄 수 있는 그런 프로그램 말입니다. 뱀 게임 만드는 법 How to Make Snake Game은 그런 생각을 하고 있지만, 어디서부터 시작해서 어떻게 프로그램을 만들어나가야 하는지 감을 잡지 못한 사람을 위한 튜토리얼입니다.
이 튜토리얼은 Web 기술을 다루고 있고 특히 화면을 그리기 위해서 React에 의존하긴 하지만 특정 기술이나 라이브러리의 사용법을 알리려는 의도로 만들어진 것은 아닙니다. 세세한 부분에 너무 연연하기보단 작은 게임이나마 프로그램의 시작부터 완성까지, 어떤 사고와 과정을 거쳐 만들어지는지 알아가길 바랍니다.
시작하기에 앞서 미리 귀뜀하자면, 우리는 아래의 질문들을 던지면서 진행해나가게 됩니다.
이 튜토리얼은 복잡한 설정을 피하기 위해 create-react-app을 사용합니다. 새로 생성한 프로젝트의 src/App.js
파일의 내용을 지운 후, 아래와 같은 상태에서 시작하도록 하겠습니다.
import React from 'react'
class Game extends React.Component {
render () {
return null
}
}
export default Game
우리가 만드려는 뱀 게임은 아주 유명한 고전입니다. 화면에 뱀과 먹이가 동시에 표시되고, 뱀은 일정 시간 간격으로 한 칸씩 전진합니다. 플레이어는 방향키로 뱀의 이동방향을 변경할 수 있습니다. 뱀이 먹이를 먹을 때마다 뱀의 길이가 늘어나고, 새로운 먹이가 나타납니다. 뱀이 벽에 부딪치거나 자기 몸통을 물면 게임은 종료됩니다.
만드려는 프로그램을 충분히 이해하는 것은 말할 필요도 없이 매우 중요한 일입니다. 우리가 가장 먼저 할 일은 연필로 종이에 그림을 그려보면서 실행 중인 게임이 어떤 모습일지 상상해보는 것입니다. 각기 다른 상태에서 실행 중인 프로그램을 시각화해보면 다음과 같습니다.
첫번째 그림은 게임이 시작하는 순간입니다. 처음에 뱀은 머리만 있는 상태로 녹색 원 하나로 표현되었고, 붉은 원으로 표현된 먹이가 있습니다. 두번째 그림은 몸통이 길어진 뱀이 먹이를 먹기 직전의 상태입니다. 마지막 그림은 뱀이 벽에 부딪혀 게임이 종료되는 상태입니다.
자, 이제 어떤 게임인지 알았으니 프로그램을 본격적으로 구현해 볼 차례입니다. 우리는 게임의 일부분만, 그마저도 단순화된 버전으로 먼저 구현한 뒤 프로그램에 살을 붙이면서 수정해나갈 겁니다. 게임의 모든 요소를 한 번에 구현하려고 하면 프로그램의 구석구석이 공사 중인 상태가 되어버리겠죠. 우리는 단순한 버전부터 시작해서 프로그램이 실행되는 상태를 최대한 유지하면서 차츰차츰 완성도를 높여나갈 겁니다.
우선 머리만 하나 있는 뱀을 만들어서 움직여보도록 할까요?
프로그램을 만들려면 프로그램에 존재하는 요소들의 데이터를 코드로 표현해야 합니다. 그러기 위해서 게임 상에서 변하지 않는 정보Constants와 변하는 정보State를 찾아낸 후, 각각을 코드로 옮기도록 하겠습니다. 위에서 그렸던 그림을 살펴보면 우리가 코드로 표현해야 할 것들이 크게 세 가지가 있습니다.
나란히 놓인 그림 세 장에서 알 수 있듯이, 게임이 진행되는 동안 무대는 항상 그대로이고, 먹이의 색과 크기, 뱀을 구성하는 원들의 색과 크기도 변하지 않습니다. 반면 뱀의 길이와 위치, 진행 방향은 동일하지 않고, 먹이의 위치도 제각각입니다.
(0, 0) Snake
Food (19, 19)
w = h = 500px
우선 변하지 않는 것들의 값을 상수Constants로 정의합니다. 이 상수들을 정의하기 위해 다음과 같은 무대의 성질을 임의로 정합니다.
// ===========================
// Constants
// ===========================
// string
// 무대의 배경색
const SCENE_COLOR = '#f1f3f5'
// string
// 뱀의 색
const SNAKE_COLOR = '#5c940d'
// string
// 먹이의 색
const FOOD_COLOR = '#ff8787'
// number
// 무대의 가로/세로 길이 (단위: px)
// * SCENE_SIZE = SCENE_COUNT x CELL_SIZE
// * 500 = 20 * 25
const SCENE_SIZE = 500
// number
// 무대의 한 열에 들어가는 칸 수
// * 20 = 500 / 25
const SCENE_COUNT = 20
// number
// 먹이와 뱀 몸통 한 칸의 가로/세로 길이 (단위: px)
// * 25 = 500 / 20
const CELL_SIZE = 25
// number
// 뱀 이동 시간 간격 (단위: ms)
const INTERVAL = 500
길이와 관련된 값들은 게임 무대의 가로와 세로가 각각 스무 칸인 정사각형이어야 한다는 임의의 결정을 전제로 정의되었습니다. 그래서 무대의 가로 길이와 세로 길이가 하나의 동일한 값으로 표현되었고, CELL_SIZE
는 SCENE_SIZE
500을 20으로 나눈 값이 되었습니다.
World⇐Snake
Snake⇐Position⨉Direction
Position⇐number⨉number
Direction⇐↑|→|↓|←
World
Snake
Position
Direction
변화하는 데이터들은 게임의 상태State가 됩니다. 앞서 그림을 보면서 찾아낸 상태 데이터는 총 네 개가 있었습니다. 하지만 우리는 지금 머리 하나만 움직여볼 것이기 때문에 뱀의 길이와 먹이의 위치는 제외하고, 뱀의 위치와 뱀의 진행 방향 데이터만 생각하겠습니다.
한 칸짜리 뱀만 존재하는 이 게임 세계의 상태를 아래처럼 World라는 형식의 객체로 표현할 수 있습니다.
// ===========================
// Data Definitions
// ===========================
// A World is an object:
// {
// snake: Snake,
// }
// * snake: 뱀의 상태 (위치, 방향)
// A Snake is an object:
// {
// position: Position,
// direction: Direction,
// }
// * position: 뱀의 위치
// * direction: 뱀의 진행 방향
// A Position is an object:
// {
// x: number,
// y: number,
// }
// * x: 가로 좌표. 왼쪽 끝에서 0으로 시작해서 오른쪽으로 갈수록 커진다.
// * y: 세로 좌표. 위쪽 끝에서 0으로 시작해서 아래쪽으로 갈수록 커진다.
// A Direction is one of:
// - 'up'
// - 'down'
// - 'left'
// - 'right'
// Snake -> World
// Given a snake, produces a world.
const world = snake => ({ snake })
// Position * Direction -> Snake
// Given a position and a direction, produces a snake.
const snake = (position, direction) => ({ position, direction })
// number * number -> Position
// Given x and y coordinates, produces a position.
const position = (x, y) => ({ x, y })
위의 코드에서는 주석을 통해 게임 상태를 표현하는 데이터 형식들을 정의하고 있습니다. (이 튜토리얼에서는 주석도 프로그램의 일부로 보아주기 바랍니다.)
먼저 게임의 전체 상태를 표현하는 World는 한 개의 필드를 가진 객체로 정의합니다. 이 데이터 형식 안에 게임에 필요한 모든 상태 정보가 들어가야 합니다.
snake
: 뱀의 상태 (머리의 위치, 방향)다음으로 World를 구성하는 데에 필요한 나머지 데이터 형식들을 정의합니다.
뱀의 상태를 표현하는 Snake는 각각 위치와 방향 정보를 나타내는 두 개의 필드를 가진 객체로 정의합니다. 이 두 개의 정보가 있으면 우리는 무대에 뱀을 어떻게 배치할지 판단할 수 있습니다.
position
: 뱀 머리의 위치direction
: 뱀의 진행 방향위치를 표현하는 Position은 각각 해당 좌표를 나타내는 x
, y
필드를 가진 객체입니다. 우리는 게임 무대에 놓일 뱀의 몸통을 단순한 좌표값으로 보고 있습니다.
x
: x 좌표y
: y 좌표방향을 표현하는 Direction은 'up'
, 'down'
, 'left'
, 'right'
네 개 중 하나의 값을 갖습니다.
데이터 정의와 설명이 복잡하게 보였다면 코드로 World의 예시값을 한 번 만들어볼까요?
// Snake
const exampleSnake = snake(position(5, 7), 'right')
// World
const exampleWorld = world(exampleSnake)
// ==> {
// snake: {
// position: {
// x: 5,
// y: 7,
// },
// direction: 'right',
// },
// }
우리가 시도 중인 초기 버전 게임에서, 어느 한 시점의 상태는 두 줄만으로 간단하게 표현이 됩니다. 뱀은 왼쪽으로부터 여섯 번째, 위쪽으로부터 여덟 번째 칸에 위치해있고, 진행 방향은 오른쪽입니다. 이 상태값으로 판단해보면 뱀의 다음 위치는 (6, 7)
이 되겠죠?
지금까지 상태 데이터들의 형식을 정의했습니다. 이제 렌더링, 즉 표현을 고민해 볼 단계입니다. 우리는 이미 게임에 필요한 모든 데이터를 정의해놓았기 때문에 상태에서 표현으로 넘어가는 과정을 최대한 단순하게 볼 필요가 있습니다. 짧게 요약하자면, 여기서 해야 할 일은 게임의 상태 정보인 World를 표현을 담당하는 React 컴포넌트에게 전달해주는 것입니다.
빈 무대
아직 렌더링 관련 코드는 하나도 쓰지 않았지만, 조금 앞서나가서 World 데이터를 넘겨주면 화면에 게임 무대를 표시하는 Scene
컴포넌트가 이미 만들어져 있다고 한 번 생각해볼까요? 만약 그렇다면 그 컴포넌트에 변화하는 상태 데이터를 계속해서 넘겨주기만 하면 데이터를 반영하도록 화면이 갱신될 겁니다. 희망사항을 반영해서 코드를 써 보면 다음과 같습니다.
// Snake
// 뱀의 최초 상태
const initialSnake = snake(position(10, 10)), 'right')
// World
// 게임 세계의 최초 상태
const initialWorld = world(initialSnake)
class Game extends React.Component {
// World
state = initialWorld
// -> ReactElement
render () {
return (
<Scene world={this.state} />
)
}
}
// World -> ReactElement
const Scene = world => (
<div>
</div>
)
우선 뱀의 위치는 화면 중앙이고 진행 방향은 오른쪽인 initialWorld
를 만든 다음, Game
컴포넌트의 state로 배정했습니다. 이 state가 바뀔 때마다 아래 쪽 render
함수 안의 Scene
컴포넌트가 상태값을 반영해 화면을 갱신하게 됩니다. 아직 Scene
컴포넌트는 작성하지 않았기 때문에 오류가 나지 않도록 허수아비만 세워놓은 상태지만 입력받은 world
안의 데이터를 화면에 표시하는 역할을 하게 될 겁니다. 이제 무대의 배경부터 표시해 볼까요?
// World -> ReactElement
const Scene = world => (
<div
style={{
position: 'relative',
width: SCENE_SIZE,
height: SCENE_SIZE,
backgroundColor: SCENE_COLOR,
}}
>
</div>
)
화면에 출력된 네모난 회색 박스가 게임의 무대입니다. 이제 무대를 채워볼 시간입니다.
멈춰있는 뱀
뱀을 표시하는 것도 방금 무대를 표시했던 것과 크게 다르지 않습니다. 상태값에 영향을 받지 않는 무대는 상수만으로 그릴 수 있었던 반면, 뱀은 상태값이 필요하다는 차이점이 있긴 하지만요. 이번에도 무대를 그릴 때 했던 것처럼, 있었으면 하는 Snake
컴포넌트를 먼저 한 번 상상해보겠습니다.
// World -> ReactElement
const Scene = world => (
<div
style={{
position: 'relative',
width: SCENE_SIZE,
height: SCENE_SIZE,
backgroundColor: SCENE_COLOR,
}}
>
<Snake snake={world.snake} />
</div>
)
// Snake -> ReactElement
const Snake = snake => <div></div>
희망사항을 코드로 구현해봅시다. Snake
컴포넌트는 입력 받은 snake
의 position
값을 이용해 무대 위의 해당 위치에 뱀을 표시하는 역할을 합니다. position.x
값으로 무대 왼쪽으로부터의 거리, position.y
값으로 무대 위쪽으로부터의 거리를 계산할 수 있습니다.
// Snake -> ReactElement
const Snake = snake => (
<div
style={{
position: 'absolute',
left: snake.position.x * CELL_SIZE,
top: snake.position.y * CELL_SIZE,
width: CELL_SIZE,
height: CELL_SIZE,
borderRadius: CELL_SIZE / 2,
backgroundColor: SNAKE_COLOR,
}}
>
</div>
)
움직이는 뱀
이제 우리는 뱀을 움직여보려고 합니다. 뱀이 지금까지 가만히 멈춰만 있었던 이유는 World에 아무런 변동이 없었기 때문입니다. 이 게임에서 뱀은 일정 시간 간격으로 한 칸씩 전진해야 하므로, 계속해서 상태를 바꿔 줄 필요가 있습니다. 아이디어는 간단합니다. 일정 시간 간격으로 뱀의 위치가 업데이트된 새로운 World를 만들어, 그걸로 기존 World를 교체하는 것입니다. 좀 전에 우리가 만든 Scene
컴포넌트에게 새 World를 넘겨주면 화면을 갱신하는 일은 알아서 해 줄 겁니다. 마치 스톱 모션 애니메이션처럼 말이죠.
일정 시간 간격이 지나 뱀이 앞으로 한 칸 움직여야 할 때, 기존 World 데이터를 바탕으로 뱀의 위치가 업데이트된 새로운 World를 만들 수 있습니다. 이 아이디어를 nextWorld
라는 함수로 표현해볼까요? 일단 새 World를 만드는 로직은 비워두고 언제나 기존 World와 똑같은 복사본을 만들어 반환하도록 합니다.
// An Action is one of:
// - 'tick'
// World * Action -> World
const nextWorld = (oldWorld, action) => {
if (action === 'tick') {
// World
const newWorld = world(oldWorld.snake)
return newWorld
}
return oldWorld
}
우리는 게임의 상태인 World를 어떤 식으로든 변경하려고 할 때마다 매번 변경사항이 적용된 새로운 World를 만들 것입니다. 이렇게 만들어진 새로운 World를 기존의 World와 바꿔치기하는 것으로 상태 변경의 한 사이클이 끝납니다. 위의 nextWorld
는 상태 변경이 필요한 매 순간마다 새 상태를 만들어내기 위해 호출될 함수입니다. 말 그대로 모든 상태 변경은 이 nextWorld
를 거쳐갑니다.
하지만 기존 World의 정보만으로는 새로운 World를 만들어내기에 충분하지 않습니다. 변경의 의도를 알아야 World의 어떤 부분을 어떻게 바꿀 수 있을지 결정할 수 있겠죠? 예를 들면 일정 시간 간격이 지났으니 뱀이 한 칸 앞으로 움직여야 한다던가 하는 식으로요.
위의 nextWorld
함수 정의를 보면 기존 World 외에 Action이라는 것을 같이 받도록 되어 있습니다. Action 안에는 상태 변경에 대한 의도가 담겨 있어, 이를 통해 기존의 상태에 어떤 변경사항을 적용해서 새 상태를 만들어내야 할지를 알 수 있습니다. 뱀 게임 세계에는 Action을 유발하는, 즉 상태를 업데이트시키는 요인이 크게 두 가지가 있습니다.
위의 코드는 아직 다른 Action은 무시하고 tick
에 대해서만 새로운 상태를 만들 준비를 하고 있습니다. 이 tick
은 뱀이 전진해야 할 시간을 알리는 것이므로 우리는 뱀이 한 칸 전진한 새로운 World를 만들어내야 합니다.
이제 움직이는 뱀이 코 앞에 있습니다! nextWorld
함수 안에 뱀 위치가 업데이트된 World를 만드는 로직을 작성하고, 일정한 시간 간격으로 tick
액션을 발생시키는 타이머를 설치하기만 하면 됩니다.
/// World * Action -> World
const nextWorld = (oldWorld, action) => {
if (action === 'tick') {
// Snake
const oldSnake = oldWorld.snake
// Position
const newPosition = nextHead(oldSnake.position, oldSnake.direction)
// World
const newWorld = world(newSnake)
return newWorld
}
return oldWorld
}
// Position * Direction -> Position
// Given a snake's head and a direction, produces a next head.
const nextHead = (posn, dir) => {
// number
let x = posn.x
// number
let y = posn.y
if (dir === 'up') {
y = y - 1
} else if (dir === 'down') {
y = y + 1
} else if (dir === 'left') {
x = x - 1
} else if (dir === 'right') {
x = x + 1
}
return position(x, y)
}
위 코드에서 뱀의 새로운 위치를 구하기 위해 nextHead
라는 보조 함수를 사용하고 있습니다. nextHead
는 현재 진행 방향에 따라 x
혹은 y
좌표값 중 하나의 값을 증가 혹은 감소시켜 새로운 Position을 만들고 있습니다. 이걸로 nextWorld
는 tick
이 발생할 때마다 새로운World를 만들어낼 준비가 끝났습니다. 이제 일정 시간 간격으로 tick
을 반복 발생시킬 타이머만 있으면 움직이는 뱀을 볼 수 있습니다.
class Game extends React.Component {
// World
state = initialWorld
// -> void
componentDidMount () {
setInterval(this.handleTick, INTERVAL)
}
// -> void
handleTick = () => {
this.setState(oldWorld => nextWorld(oldWorld, 'tick'))
}
// ...
}
INTERVAL
밀리초 간격으로 handleTick
메서드를 실행하는 타이머가 설치되었습니다. handleTick
메서드는 타이머에 의해 반복적으로 실행될 때마다 매번 nextWorld
를 이용해서 새 World를 만든 다음, 그것을 기존의 World와 교체해주고 있습니다.
뱀의 위치가 한 칸 옮겨진 이 새로운 World가 <Scene />
컴포넌트에 전달되고, 요주의 데이터인 Snake가 다시 <Snake />
컴포넌트에 전달되면서 화면에 표시되는 뱀의 위치가 갱신되고 있습니다. 아직 방향을 바꿀 수 없어서 금방 벽을 뚫고 사라져 버리긴 하지만 이제 뱀이 움직이고 있습니다!
방향키로 조작할 수 있는 뱀
위에서 nextHead
함수를 통해 뱀의 현재 진행 방향에 따라 다음 위치가 결정되도록 했었죠. 이제 진행 방향을 바꾸는 Action들을 처리해서 플레이어가 뱀을 마음대로 조작할 수 있도록 할 차례입니다. 현재 정의되어 있는 tick
외에 상/하/좌/우 네 방향에 대해 모두 별도로, 총 네 개의 Action을 추가합니다.
// An Action is one of:
// - 'tick'
// - 'up'
// - 'down'
// - 'left'
// - 'right'
상/하/좌/우를 나타내는 이 네 개의 값들은 문자 그대로 각각 해당 방향으로의 방향 전환을 의미합니다. 이 추가된 Action들 역시 nextWorld
함수를 거쳐 뱀의 진행 방향이 업데이트된 새로운 World를 만들어내어야 합니다.
// World * Action -> World
const nextWorld = (oldWorld, action) => {
if (action === 'tick') {
// Snake
const oldSnake = oldWorld.snake
// Position
const newPosition = nextHead(oldSnake.position, oldSnake.direction)
// Snake
const newSnake = snake(newPosition, oldSnake.direction)
return world(newSnake)
}
if (action === 'up') {
// Snake
const newSnake = snake(oldSnake.position, 'up')
return world(newSnake)
}
if (action === 'down') {
// Snake
const newSnake = snake(oldSnake.position, 'down')
return world(newSnake)
}
if (action === 'left') {
// Snake
const newSnake = snake(oldSnake.position, 'left')
return world(newSnake)
}
if (action === 'right') {
// Snake
const newSnake = snake(oldSnake.position, 'right')
return world(newSnake)
}
return oldWorld
}
방향 전환을 처리하는 위의 코드에 대해서는 별달리 설명할 부분이 없습니다. 단순히 인자 action
에 따라 방향이 변경된 Snake를 만들고, 그걸로 다시 World를 만들어 반환하고 있습니다. 지금은 이 정도로 해 놓고 나중에 후진 금지 같은 제약을 넣어보는 것도 괜찮겠죠?
자, 이제 사용자의 방향키 입력만 처리해주면 뱀을 조작할 있습니다. 위에서 타이머가 tick
을 일정 주기로 발생시켰던 것과 아이디어는 별반 다르지 않습니다. 그 때는 일정 주기로 tick
을 nextWorld
함수에 인자로 넘겨 새 World를 만들었었죠. 이번에는 사용자가 방향키를 누를 때마다 해당 Action을 nextWorld
함수에 인자로 넘겨 새 World를 만들 겁니다. 그런 다음 React의 setState
를 이용해 기존의 World를 대체해주는 걸로 방향 전환의 사이클이 끝이 납니다.
class Game extends React.Component {
// World
state = initialWorld
// -> void
componentDidMount () {
document.addEventListener('keydown', this.handleKey)
setInterval(this.handleTick, INTERVAL)
}
// KeyboardEvent -> void
handleKey = e => {
const { key } = e
let action = null
if (key === 'ArrowUp') {
action = 'up'
} else if (key === 'ArrowDown') {
action = 'down'
} else if (key === 'ArrowLeft') {
action = 'left'
} else if (key === 'ArrowRight') {
action = 'right'
}
if (action) {
this.setState(oldWorld => nextWorld(oldWorld, action))
}
}
// ...
}
Coming soon.