코딩/GoLang

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

드리프트 2021. 8. 3. 18:41
728x170

 

 

안녕하세요?

 

이번에는 테트리스 게임을 통해 GoLang(고랭) 강좌를 진행해 보겠습니다.

 

이번에 만들 테트리스 게임은 그래픽 환경이 아닌 콘솔 터미널 창에서 작동하는 TUI 프로그램입니다.

 

먼저, 고랭에서 콘솔 UI를 만들 수 있는 패키지로 유명한 게 termbox-go입니다.

 

https://github.com/nsf/termbox-go

 

GitHub - nsf/termbox-go: Pure Go termbox implementation

Pure Go termbox implementation. Contribute to nsf/termbox-go development by creating an account on GitHub.

github.com

 

다른 프로젝트에 사용해 본 결과 사용하기 쉽고, 또 프로젝트를 쉽게 확장하기도 쉬워서 이번에 만들 테트리스 게임은 termbox-go를 이용해 보도록 하겠습니다.

 

 

프로젝트 셋업

 

먼저, 프로젝트 명은 gotetris라고 합시다.

mkdir gotetris
cd gotetris
go mod init gotetris
go get -u github.com/nsf/termbox-go

위 코드는 gotetris 폴더를 만들고 고랭 모듈 초기화를 진행하며, 마지막으로 termbox-go 패키지를 다운로드합니다.

 

이제 컴파일을 쉽게 하기 위해 Makefile을 만들어 보겠습니다.

 

다음 Makefile은 제가 인터넷에서 구한 가장 좋은 GoLang Makefile이라서 항상 이것만 사용합니다.

 

BINARY_NAME=gotetris
GOFILES=$(wildcard *.go)

## help: show help
help: Makefile
	@echo
	@echo " Choose a command run in "$(PROJECTNAME)":"
	@echo
	@sed -n 's/^##//p' $< | column -t -s ':' |  sed -e 's/^/ /'
	@echo

## run: run this program
run:
	go run ${GOFILES}

## build: build this program
build:
	go build -o ${BINARY_NAME} -ldflags "-s -w" ${GOFILES}

## fmt: format go files
fmt:
	go fmt ./...

## clean: clean
clean:
	go clean
	rm -f ${BINARY_NAME}

.PHONY: help run build fmt clean

이 파일에서 우리가 고칠 곳은 BINARY_NAME만 고치면 됩니다.

 

저는 맥 OS 환경이라 BINARY_NAME을 gotetris라고 했는데 만약 Windows 환경이라면. exe 확장자를 추가하시기 바랍니다.

 

 

코드 작성

 

이제 이 강좌의 첫 번째 코드입니다.

 

gotetris.go 란 이름으로 파일을 만듭시다.

 

// gotetris.go

package main

import (
    "fmt"
    "strings"
    "time"

    "github.com/nsf/termbox-go"
)

const backgroundColor = termbox.ColorBlack
const instructionColor = termbox.ColorWhite
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 boardEndX = boardStartX + boardWidth
const boardEndY = boardStartY + boardHeight
const instructionStartX = boardEndX + defaultMarginWidth
const instructionStartY = boardStartY

const title = "Tetris Written in Go Lang"

var instructions = []string{
    "Goal: Fill in 5 lines1",
    "",
    "\u2190    Left",
    "\u2192    Right",
    "\u2191    Rotate",
    "\u2193    Drop faster",
    "s         Start",
    "p         Pause",
    "esc       Exit",
    "",
    "Level: %v",
    "Lines: %v",
}

 

일단 테트리스 게임 구조를 세팅하는 변수를 전역 변수로 설정하는 코드입니다.

 

boardWidth와 boardHeight가 각각 10과 16입니다.

 

이 변수는 테트리트 게임창이 가로로 10칸, 세로로 16칸이란 뜻입니다.

 

그리고 backgroundColor와 instructionColor를 각각 termbox 모듈에 미리 지정되어 있는 ColorBlack과 ColorWhite로 지정했습니다.

 

또한 defaultMarginWidth와 defaultMarginHeight를 각각 2와 1로 설정했는데, 그다음 boardStartX 변수와 boardStartY 변수에 적용되었습니다.

 

또한 게임 타이틀인 title 스트링을 위해 titleStartX와 titleStartY 변수도 만들었습니다.

 

titleEndY는 당연히 titleStartY와 titleHeight 만큼 더하면 됩니다.

 

즉, 테트리스 게임이 실행될 보드판의 시작을 화면 맨 위쪽에서 각각 2와 1만큼의 마진을 두고 떨어트리기 위함입니다.

 

boardStartX가 있으면 자동적으로 boardEndX가 있겠죠. 시작점인 boardStartX에서 boardWidth 만큼 더하면 그게 보드의 끝이 됩니다.

 

boardStartY와 boardEndY에도 같은 방식으로 적용됩니다.

 

instructionStartX와 instructionStartY는 게임 화면 옆에 간단하게 도움말 같은 보조화면을 출력할 자리입니다.

 

당연히 X축으로는 boardEndX에서 디폴트 마진(defaultMarginWidth)만큼 떨어져 지정했습니다.

 

instructionStartY은 Y 축이기 때문에 그냥 boardStartY 축으로 맞췄습니다.

 

그다음 instructions라는 변수는 스트링 배열인데, 간단한 도움말 및 키 설명서입니다.

 

왼쪽키, 오른쪽 키, 로테이트 키, 드랍 패스터 키는 각각 유니코드로 표현했습니다.

 

그리고 Level과 Lines를 표시할 수 있게 추가했습니다.

 

처음 보는 전역 변수가 많지만 게임코드를 이해하다보면 쉽게 이해 할 수 있으니 일단은 그냥 지나가도 괜찮을 겁니다.

 

그럼 다음 코드로 넘어가 보겠습니다.

 

 

 

termbox-go 패키지의 출력 루틴 이해하기

 

우리가 콘솔 화면 즉, 터미널 화면에서 글자를 쓰기 위해서는 fmt.Println 함수나 기타 콘솔 출력 함수를 써야 하는데 좀 더 멋지게 쓸 수 있는 패키지가 termbox-go 패키지입니다.

 

이 패키지의 가장 기본인 화면에 출력하는 코드를 살펴봅시다.

 

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

tbprint란 함수를 만들었습니다.

 

이 함수는 기본적으로 termbox의 SetCell이란 함수를 이용하고 있습니다.

 

그럼 termbox.SetCell 함수를 살펴볼까요?

 

패키지 설명서에는 인터널 백 버퍼의 특정 위치에서 셀의 특성을 바꾼다는 함수입니다.

 

termbox.SetCell 함수가 인수로 받는 x, y rune, fg, bg가 있습니다.

 

그래서 우리가 만든 tbprint함수도 (x, y int, fg, bg termbox.Attribute, msg string)처럼 기본적인 인수를 받습니다.

 

x, y는 화면의 좌표입니다.

 

그리고, fg, bg는 각각 글자색과 배경색입니다. 타입은 termbox.Attribute이고요.

 

그리고 msg라는 스트링을 인수로 받는데 우리의 목적은 바로 이 msg라는 스트링을 x,y 좌표에 fg, bg라는 컬러로 출력하는 게 목적입니다.

 

그게 바로 tbprint함수의 목적이며, 최종적으로는 termbox-go를 이용해서 문자열을 화면에 출력하는 함수를 만들었습니다.

 

 

 

게임 화면 그리기

 

이제 본격적으로 게임 화면을 그려보겠습니다.

 

다음 코드를 먼저 보겠습니다.

 

func draw() {
    termbox.Clear(backgroundColor, backgroundColor)
    tbprint(titleStartX, titleStartY, instructionColor, backgroundColor, title)
    for y := boardStartY; y < boardEndY; y++ {
        for x := boardStartX; x < boardEndX; x++ {
            termbox.SetCell(x, y, ' ', termbox.ColorGreen, termbox.ColorGreen)
        }
    }
    for i, instruction := range instructions {
        if strings.HasPrefix(instruction, "Level:") {
            instruction = fmt.Sprintf(instruction, 0)
        } else if strings.HasPrefix(instruction, "Lines:") {
            instruction = fmt.Sprintf(instruction, 0)
        }
        tbprint(instructionStartX, instructionStartY+i, instructionColor, backgroundColor, instruction)
    }
    termbox.Flush()
}

 

위 코드의 draw 함수는 게임상의 무한 루프 속에서 화면을 계속 그리는 함수입니다.

 

위 코드를 보시면 일단 termbox.Clear란 함수로 화면을 지워주고 그 다음 타이틀 문자열을 그려줍니다.

 

우리의 게임 타이틀은 바로 "Tetris Written in Go Lang"입니다.

 

그다음 boardStartY와 boardStartX 변수를 이용해 board를 화면에 그려줍니다.

 

이때 위에서 설명했던 termbox.SetCell 함수를 사용합니다.

 

그리고 instructions 스트링 배열을 tbprint함수를 이용해서 문자열 출력하고

 

마지막으로 termbox.Flush()로 버퍼에서 화면으로 보내면 됩니다.

 

instruction 스트링을 출력할 때 Level과 Lines를 위한 특별한 if 문이 있는데요.

 

여기서 strings.HasPrefix 함수를 이용해서 문자열안에 "Level:"문자열이 있으면

 

해당 문자열은 fmt.Sprintf 함수를 이용해 재정의 하고 있습니다. 즉, 게임을 진행하다 보면 Level이 올라갈텐데

 

그 Level 변수를 문자열에 적용하는 방식입니다.

 

그리고 우리가 테트리스 게임을 하면서 몇개의 라인을 지웠는지를 나타내는 Lines 변수를 출력하는 fmt.Sprintf 문구도 있습니다.

 

 

 

메인 함수

 

이제 main 함수입니다.

 

func main() {
    err := termbox.Init()
    if err != nil {
        panic(err)
    }
    defer termbox.Close()

    eventQueue := make(chan termbox.Event)
    go func() {
        for {
            eventQueue <- termbox.PollEvent()
        }
    }()

    draw()

loop:
    for {
        select {
        case ev := <-eventQueue:
            if ev.Type == termbox.EventKey && ev.Key == termbox.KeyEsc {
                break loop
            }
        default:
            draw()
            time.Sleep(10 * time.Millisecond)
        }
    }
}

 

main 함수의 첫 번째 코드는 바로 termbox 패키지의 초기화입니다. termbox.Init() 함수를 이용하면 됩니다.

 

그리고 defer를 이용해 termbox.Close() 함수를 미리 설정해 놓습니다.

 

그다음으로는 약간 어려울 수 있는 이벤트 큐를 다루는 코드입니다.

 

게임은 하나의 무한루프 속에서 사용자의 키보드 반응이나 마우스 반응에 각각 대응하여 게임이 진행되는 방식입니다.

 

그래서 우리는 무한루프속에서 키보드 인풋을 캐취 할 수 있는 코드를 만들어야 하는데,

 

고랭(GoLang)은 비동기식 프로그램을 가장 잘 지원해 주고 있어 쉽게 구현할 수 있습니다.

 

그것은 바로 Go 루틴입니다.

 

Go 루틴은 main 함수가 실행되는 시스템 프로세서 상이 아닌 다른 프로세서 상에서 실행되는 비동기식 함수 집합입니다.

 

그러면 Go 루틴과 main 함수 간의 의사소통은 어떻게 할까요?

 

참고로, ElectronJS에서는 ipcMain과 ipcRenderer 함수를 이용해 의사소통을 진행합니다.

 

GoLang에서는 이때를 위해 만든 채널(channel)이라는 방식을 통해 의사소통을 진행합니다.

 

그래서 위 코드를 보면 eventQueue라는 채널을 하나 만들었습니다.

 

chan이라는 키워드로 채널을 만들 수 있고, 그 타입은 termbox.Event라는 형식입니다.

 

그래서 eventQueue라는 채널을 통해 termbox.Event를 이용할 수 있는 겁니다.

 

그럼, Go 루틴을 만들어야겠죠.

 

Go 루틴은 다음과 같은 형식으로 만듭니다. 익명 함수라고도 합니다.

go func() {

....

}()

마지막에 ()를 붙인 이유는 바로 실행하라는 뜻입니다.

 

우리는 테트리스 게임을 위해 무한 루프가 필요합니다.

 

그래서 위의 Go 루틴에는 for 키워드를 이용해 termbox가 Event를 수신할 때마다 즉, PollEvent() 함수를 통해 우리가 만든 채널 변수인 eventQueue에 전달하게 만들면 됩니다. 다음 코드와 같이 말입니다.

for {
    eventQueue <- termbox.PollEvent()
}

 

이제 Go 루틴도 만들었겠다. main 함수의 중간 부분인 draw() 함수를 실행하면 됩니다.

 

즉, 무한루프 들어가기 전에 첫 번째로 화면에 그리라는 얘기죠.

 

그다음은 이제 우리의 키보드 인풋을 체크해서 실행하는 코드입니다.

 

loop:이라는 label을 이용해서 무한 루프에서 빠져나올 수 있게 했는데요.

 

일단 for라는 무한 루프 속에 select 키워드로 각각의 case 별 대응 코드를 만들었습니다.

 

먼저, 우리가 Go루틴에서 쓸 채널 eventQueue가 수신될 때 즉, termbox에서 이벤트가 발생됐을 때, 즉, 다시 말하면 키보드 이벤트가 발생했을 때, 또는,  사용자가 뭔가를 했을 때, 각각의 case 문에서 이에 대응하는 코드를 만들면 됩니다.

 

그래서 case ev := <-eventQueue라는 코드에서 볼 수 있듯이 eventQueue 채널에서 수신해서 ev 변수에 저장합니다.

 

당연히 eventQueue는 타입이 termbox.Event입니다.

 

그래서 그다음 코드를 보시면 ev.Type == termbox.EventKey && ev.Key == termbox.KeyEsc 방식으로 if 문이 설정되어 있습니다.

 

즉, ev 변수가 EventKey이고 그 키가 KeyEsc 즉, ESC 버튼일 경우 그 아래 코드가 실행된다는 겁니다.

 

그 아래 코드는 바로 break loop이라는 무한 루프를 뚫는 코드입니다. 그래서 우리의 프로그램은 종료하게 되는 것이죠.

 

그리고 default로는 당연히 아무 이벤트가 없기 때문에 그냥 draw() 함수를 호출해서 화면에 그려주게 됩니다.

 

그다음 코드 time.Sleep()를 통해 10 밀리 초마다 쉬게 만들어 특정 FPS를 달성할 수 있고 CPU 과부하도 막을 수 있습니다.

 

 

코드 실행

 

이제 코드를 실행해 볼까요?

 

우리가 만든 Makefile을 이용해서 쉽게 Build 해 봅시다.

make build

컴파일이 아주 잘되었습니다.

 

그럼 컴파일한 실행파일을 실행해 보겠습니다.

 

 

자! 우리의 첫 번째 콘솔 TUI 프로그램이 실행되었습니다

 

위 사진을 보시면 가로 10칸, 세로 16칸입니다. 가로 칸이 좀 더 작아 보이네요.

 

ESC 키를 누르면 프로그램이 종료됩니다.

 

이제까지 GoLang 강좌 테트리스 게임편 첫 번째인 기초 세팅에 대해 알아보았습니다.

 

다음 편에서는 본격적인 테트리스 게임 제작에 들어가 보도록 하겠습니다.

 

그리드형