코딩/GoLang

GoLang 강좌, 테트리스 게임 만들기 2편

드리프트 2021. 8. 5. 19:11
728x170



안녕하세요?

지난 시간부터 시작한 GoLang 강좌인 테트리스 게임 만들기 2편을 1편에 이어 마저 이어가도록 하겠습니다.

1편에서는 터미널 UI 패키지인 termbox-go에 대해 가볍게 살펴보았는데요.

1편 바로가기

 

GoLang 강좌, 테트리스 게임 만들기

안녕하세요? 이번에는 테트리스 게임을 통해 GoLang(고랭) 강좌를 진행해 보겠습니다. 이번에 만들 테트리스 게임은 그래픽 환경이 아닌 콘솔 터미널 창에서 작동하는 TUI 프로그램입니다. 먼저,

cpro95.tistory.com


2편부터는 본격적인 게임 설계에 들어가 보겠습니다.

컴퓨터 게임 제작에 있어 가장 쉬운 패턴은 아마 MVC 패턴일 겁니다.

제가 MVC 패턴을 처음 접한 거는 QT로 GUI 프로그래밍하면서부터 일 겁니다.

처음에는 이해하기 되게 어려웠는데 한번 이해하니까 참 쉽더라고요.

MVC는 Model, View, Controller의 약자로

Model에서 전체적인 구조를 만들고,

Controller에서 Model을 조정하면,

조정할 때마다의 각각의 상태를 View(화면)에 뿌려주는 구조입니다.

어찌 보면 GUI 프로그래밍에 최적화되어있다고 볼 있습니다.

자바스크립트 세계에서 유명한 React도 사실 MVC의 View에 해당하는 프레임웍입니다.

거기에 useState Hook(훅)으로 Model에 해당하는 state를 관리하면 React가 화면을 갱신하는 구조인 거죠.

그럼, 우리의 목표인 테트리스 게임에 있어 전체적인 파일 구조를 MVC 패턴에 맞춰서 다시 짜 보도록 하겠습니다.

일단 Controller 부분은 바로 main 함수가 들어가 있는 부분으로 실행 및 키보드 조정에 따른 각종 제어를 담당하는 부분입니다.

그럼 우리가 만들려고 하는 테트리스 게임의 Controller 부분을 다시 짜 보도록 하겠습니다.



controller.go

package main

import (
  "math/rand"
  "time"
  "github.com/nsf/termbox-go"
) 

// 애니메이션스피드는 10 미릴세컨드로 설정 
const animationSpeed = 10 * time.Millisecond

func main() {

  // random 숫자를 뽑아내기 위해 Seed를 초기화하는 루틴입니다. 
  rand.Seed(time.Now().UnixNano())

  err := termbox.Init()
  if err != nil {
    panic(err)
  }
  
  defer termbox.Close()
  
  eventQueue := make(chan termbox.Event)
  go func() {
    for {
      eventQueue <- termbox.PollEvent()
    }
  }()
  
}

일단 지난 편에서 설명한 main 함수와 비슷합니다.

추가된 거는 아래 코드처럼 전체적인 화면 속도를 10밀리 초로 설정하라는 변수 설정입니다.

const animationSpeed = 10 * time.Millisecond


그리고 main 함수로 들어가 보면

rand.Seed 함수를 볼 수 있는데 이 함수는 컴퓨터 난수를 설정하는 겁니다.

rand.Seed 함수에 큰 숫자를 넣으면 난수가 좀 더 난수 같아지기 때문에 time.Now().UnixNano()을 넣었습니다.


그다음은 지난 편에서도 봤듯이 eventQueue를 채널로 만들어 termbox의 키보드 이벤트를 캡처하는 고 루틴입니다.

여기까지는 지난 편과 같습니다.

그럼 본격적으로 이번 편에서 알아보게 될 controller 부분을 작성해 보도록 하겠습니다.

 g := NewGame()
 render(g)


g := NewGame()

이 문장은 NewGame() 함수로 Game 객체를 생성해서 g에 할당하는 코드입니다.

우리가 다음 시간에 알아볼 Game 구조체가 바로 MVC 패턴에서 Model에 해당됩니다.

Model 인 Game 구조체 구현은 나중에 진행할 예정이니까 지금은 그냥 넘어가도록 하겠습니다.

그리고 그다음 명령어

render(g)는 화면에 g를 그리라는 뜻입니다. 즉, Game 객체를 그리라는 뜻이죠.


render 함수는 MVC 패턴의 View에 해당됩니다.

이제 다음 코드로 넘어가 보겠습니다.

for {
 select {
   case ev := <-eventQueue:
     if ev.Type == termbox.EventKey {
       switch {
         case ev.Key == termbox.KeyArrowLeft:
           g.moveLeft()
         case ev.Key == termbox.KeyArrowRight:
           g.moveRight()
         case ev.Key == termbox.KeyArrowUp:
           g.rotate()
         case ev.Key == termbox.KeyArrowDown:
           g.moveDown()
         case ev.Key == termbox.KeySpace:
           g.fall()
         case ev.Ch == 's':
           g.start()
         case ev.Ch == 'p':
           g.pause()
         case ev.Ch == 'q' || ev.Key == termbox.KeyEsc || ev.Key == termbox.KeyCtrlC || ev.Key == termbox.KeyCtrlD:
           return
       }
   }
   case <-g.fallingTimer.C:
     g.play()
   default:
     render(g)
     time.Sleep(animationSpeed)
  }
}

뭔가 코드가 어려워 보이는데요. 전혀 그렇지 않습니다.

일단은 for 문으로 무한 루프를 돌리고 있고 select 문으로 해당 case별로 명령어를 적어 놓은 구조입니다.

select 문에는 세 가지의 case가 있는데

첫 번째는 eventQueue에 의한 키보드 이벤트에 대응하는 거고,

두 번째 case는 g.fallingTimer.C라는 다소 어려운 코드를 적어놨습니다.

세 번째 case는 default로 첫 번째 case와 두 번째 case가 아닌 다른 경우 일 때 그냥 render(g) 명령어로 게임 상태를 View 루틴인 render 함수로 그리라는 명령어입니다.

그리고 time.Sleep 명령어로 animationSpeed 상수만큼 Sleep 시키는 코드입니다.

그럼 다소 어려운 코드인 <-g.fallingTimer.C라는 case 문을 볼까요?

g는 Game 구조체의 객체이고 fallingTimer는 Game 구조체 멤버입니다.

다음 시간에 Game 구조체 설명할 때 자세히 설명할 예정입니다.

그래서 여기서는 간단하게 설명해 보겠습니다

고랭의 Timer 함수는 자바스크립의 setTimeout과 같은 효과를 볼 수 함수입니다.

자바스크립트의 setTimeout() 함수는 일정 시간이 지나면 실행시키는 로직입니다.

우리도 그 부분이 필요한데요. 일정 시간이 지나면 게임을 진행시키라는 의미입니다.

왜냐하면 그 일정 시간이 블록이 떨어지는 시간인 거죠.

즉, 테트리스 게임에서 블록이 떨어지는 시간을 지연하기 위한 로직이라고 생각하시면 편합니다.

Timer는 일정 시간이 지나면 C라는 Time 객체를 C라는 채널로 반환합니다.

type Timer struct {
  C <-chan Time
  // ...
}

그래서 case <-g.fallingTimer.C 코드는 다음과 같이 이해하면 됩니다.


Timer가 만료됐을 때 C가 Time을 반환받으면 g.play()라는 함수를 실행하라는 문구입니다.

즉, 일정 시간이 지나서 C라는 Time을 채널로 받으면 g.play()를 실행시켜서 게임을 마저 진행하라는 뜻이죠.

Timer pkg 설명



그다음으로 볼 case는 eventQueue로 지난 시간에도 봐왔던 termbox-go의 이벤트입니다.

해당 termbox-go 이벤트에 따라서 프로그램을 진행하라는 로직입니다.

일단 쉽게 이해할 수 있는 거는 왼쪽, 오른쪽, 위쪽, 아래쪽 키에 따라서 g.moveLeft(), g.moveRight(), g.rotate(), g.moveDown() 함수를 실행하는 겁니다.

해당 기능은 함수 이름에 맞게 쉽게 유추할 수 있을 겁니다.

그리고 그다음 키인 스페이스 키는 테트리스 블록을 맨 아래에 떨어트리는 겁니다. 해당되는 코드는 g.fall()입니다.

그리고 키 s, p, q에 따라 각자 게임 시작, 게임 일시정지, 게임 끝내기 코드를 실행하는 겁니다.

뭔가 어려워 보였는데 하나하나 따지고 보니까 그리 어렵지 않은 거 같네요.

다음으로는 MVC 패턴의 View에 해당하는 View 로직에 대해 알아보겠습니다.



view.go

package main

import (
  "fmt"
  "math"
  "strings"
  
  "github.com/nsf/termbox-go"
)

// Colors
const backgroundColor = termbox.ColorBlue
const boardColor = termbox.ColorBlack
const instructionsColor = termbox.ColorYellow

var pieceColors = []termbox.Attribute{
  termbox.ColorBlack,
  termbox.ColorRed,
  termbox.ColorGreen,
  termbox.ColorYellow,
  termbox.ColorBlue,
  termbox.ColorMagenta,
  termbox.ColorCyan,
  termbox.ColorWhite,
} 

// Layout
const defaultMarginWidth = 2
const defaultMarginHeight = 1
const titleStartX = defaultMarginWidth
const titleStartY = defaultMarginHeight
const titleHeight = 1
const titleEndY = titleStartY + titleHeight
const boardStartX = defaultMarginWidth
const boardStartY = titleEndY + defaultMarginHeight
const boardWidth = 10
const boardHeight = 16
const cellWidth = 2
const boardEndX = boardStartX + boardWidth*cellWidth
const boardEndY = boardStartY + boardHeight
const instructionsStartX = boardEndX + defaultMarginWidth
const instructionsStartY = boardStartY

// Text in the UI
const title = "TETRIS WRITTEN IN GO"
var instructions = []string{
  "Goal: Fill in 5 lines!",
  "",
  "left Left",
  "right Right",
  "up Rotate",
  "down Down",
  "space Fall",
  "s Start",
  "p Pause",
  "esc,q Exit",
  "",
  "Level: %v",
  "Lines: %v",
  "",
  "GAME OVER!",
} 

// Function tbprint draws a string. 
func tbprint(x, y int, fg, bg termbox.Attribute, msg string) {
  for _, c := range msg {
    termbox.SetCell(x, y, c, fg, bg)
    x++
  }
}

일단 view.go 파일의 일부인인데요.

지난 시간에 다 배운 코드입니다.

몇 가지가 추가되었느데요. 추가된 것이 무엇인지는 쉽게 유추할 수 있을 겁니다.

추가된 것 중에 cellWidth 가 눈에 띄는데요.

지난 시간에 완성한 코드를 실행했을 때 가로폭이 좁아 보인다고 했었죠?

그래서 cellWidth를 2로 지정해서 boardWidth에 곱했습니다.

그러면 boardEndX 가 2배로 커지게 됩니다.

그리고, 그다음은 가장 중요한 render() 함수입니다.

// rendering everything.
func render(g *Game) {
  termbox.Clear(backgroundColor, backgroundColor) 
  tbprint(titleStartX, titleStartY, instructionsColor, backgroundColor, title)
  
  for y := 0; y < boardHeight; y++ {
    for x := 0; x < boardWidth; x++ {
      cellValue := g.board[y][x]
      absCellValue := int(math.Abs(float64(cellValue)))
      cellColor := pieceColors[absCellValue]
      for i := 0; i < cellWidth; i++ {
        termbox.SetCell(boardStartX+cellWidth*x+i, boardStartY+y, ' ', cellColor, cellColor)
      }
    }
  } 
  
  for y, instruction := range instructions {
    if strings.HasPrefix(instruction, "Level:") {
      instruction = fmt.Sprintf(instruction, g.level)
    } else if strings.HasPrefix(instruction, "Lines:") {
      instruction = fmt.Sprintf(instruction, g.numLines)
    } else if strings.HasPrefix(instruction, "GAME OVER") && g.state != gameOver {
      instruction = ""
    } tbprint(instructionsStartX, instructionsStartY+y, instructionsColor, backgroundColor, instruction)
  }

  termbox.Flush()
}


먼저, 아래쪽 코드인 instructions을 출력하는 코드를 보겠습니다.

이 코드는 지난 시간에 설명했듯이 게임 내 도움말이 되는 텍스트를 출력하는 루틴입니다.

"GAME OVER" 부분에서는 g.state != gameOver라는 로직이 있는데 다음 시간에 설명할 Game 구조체에는 state라는 int 변수가 있는데 이 state 변수는 현재 게임의 상태를 나타냅니다.

현재 게임 상태가 일시 정지인지, 게임 오버인지, 게임 중인지를 나타내는 변수입니다.

tbprint() 함수는 지난 시간에 배웠기 때문에 지나가도록 하겠습니다.

그럼 render 함수에서 가장 중요한 로직인 for 루프 문에 대해 알아보겠습니다.

for y :=0; y < boardHeight; y++ {
  for x :=0; x < boardWidth; x++ {
    ...
    ...
    ...
  }
}


두 개의 for 루프를 돌리는데 그 경계는 바로 board 전체 크기입니다.

board는 가로가 boardWidth = 10칸이고 세로가 boardHeight = 16칸인 2차원 배열로 생각하시면 이해가 쉬울 겁니다.

아래 그림을 보시면 쉽게 이해할 수 있을 겁니다.

board의 시작은 왼쪽 상단 (0,0)부터 시작합니다. 오른쪽으로 가면서 X축이 증가합니다.

(1,0) (2,0) (3,0)처럼 증가하는 거죠.

아래로 갈수록 Y축이 증가합니다. (0,1) (0,2) (0,3) (0,4)처럼 말이죠.

그래서 for 문을 y와 x로 이중으로 쓴 거는 board가 2차원 배열이기 때문입니다.

for 루프 문 안을 좀 더 살펴보겠습니다.

cellValue := g.board[y][x]

g라는 Game 객체에는 board 변수가 있는데 이 변수는 2차원 배열입니다.

이 2차원 배열의 y, x 좌표에 있는 값을 cellValue에 저장하라는 뜻입니다.


우리가 만들 게임은 테트리스 블록을 board라는 2차원 좌표에 저장할 예정이며, 그 좌표에는 음수를 넣을 예정입니다.

즉, 블록 넘버의 네거티브 숫자를 넣은 예정입니다.

블록 넘버는 1번부터 7번까지 있으니까 -1부터 -7까지의 숫자가 들어가는 꼴이죠.

그 값을 cellValue에 저장하라는 코드입니다.

그다음 라인을 볼까요?

absCellValue := int(math.Abs(float64(cellValue)))

아까 저장한 cellValue는 음수이기 때문에 math.Abs 함수를 이용해서 절대값을 구하는 로직입니다.

절대값이면 항상 양수가 나오겠죠.

그 양수 값은 바로 테트리스 블록 넘버입니다. 1번부터 7번까지요.

그럼 우리가 왜 블록 넘버를 알아야 할까요?

그것은 바로 블록 넘버에 따라 색깔을 달리해서 화면에 그리기 위해서입니다.

그게 더 보기 좋으니까요.

그래서 그다음 코드를 보시면

cellColor := pieceColors[absCellValue]

absCellValue의 값을 이용해 pieceColors 배열에서 해당 블록의 컬러를 구하는 겁니다.

pieceColors 배열은 0부터 시작하는데 0은 테트리스 블록에 해당 안되니까 termbox.ColorBlack로 지정되어 있습니다.

즉, 빈칸으로 그린다는 얘기죠, 그리고 1부터 7까지는 빨간색, 녹색 등으로 지정되어 있습니다.

이제 다음 코드로 넘어가 볼까요?

for i := 0; i < cellWidth; i++ {
  termbox.SetCell(boardStartX+cellWidth*x+i, boardStartY+y, ' ', cellColor, cellColor)
}

for 루프 문이 나왔는데 이거는 cellWidth까지 루프를 돌리라는 얘기로 cellWidth가 2이니까 2번 돌리라는 얘기네요.

cellWidth가 2라는 뜻은 가로폭을 보기 좋게 2배로 늘리기 위해서입니다.

중요한 거는 termbox.SetCell 함수입니다.


위 그림에서 볼 수 있듯이 x, y좌표에 fg, bg 컬러로 rune 글자를 출력하는 함수입니다.

그래서 우리는 boardStartX+cellWidth*x+i 를 X 좌표로,


boardStartY+y를 Y좌표로 해서 지정된 컬러인 cellColor으로 빈칸(' ')을 출력하는 거죠.

종합해 보면 board[y][x] 2차원 배열 전체를 루프로 돌려서 특정 board 좌표에 블록이 있으면 그 좌표에 해당 블록 색상으로 빈칸(' ')을 그리는 로직입니다.

너무 단순하지 않은가요?

맞습니다. 우리의 View 모델인 render 함수는 그냥 board [y][x]를 화면에 알맞게 뿌리는 겁니다.



지금까지 테트리스 게임의 MVC 패턴 중 View와 Controller 부분에 대해 알아보았습니다.

다음 편에서는 가장 중요한 Model 부분인 Game 구조체에 대해 알아보겠습니다.

그리드형