코딩/GoLang

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

드리프트 2021. 8. 18. 17:15
728x170

 

 

안녕하세요?

 

오늘은 GoLang 강좌로 지난 시간부터 시작한 테트리스 게임 만들기 3편입니다.

 

1편에서는 GoLang에서 터미널 UI를 담당하는 termbox-go에 대한 기본적인 사항에 대해 알아보았습니다.

 

1편 바로가기

 

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

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

cpro95.tistory.com

 

2편에서는 MVC 패턴에 있어 View와 Controller에 대해 알아보았습니다.

 

2편 바로가기

 

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

안녕하세요? 지난 시간부터 시작한 GoLang 강좌인 테트리스 게임 만들기 2편을 1편에 이어 마저 이어가도록 하겠습니다. 1편에서는 터미널 UI 패키지인 termbox-go에 대해 가볍게 살펴보았는데요. 1편

cpro95.tistory.com

 

3편에서는 이제 마지막 단계인 Model 부분에 대해 상세히 알아보겠습니다.

 

 

Model.go

 

일단 지난 시간까지 작성한 MVC 패턴의 View와 Controller 부분은 컴파일이 되지 않았습니다.

 

왜냐하면 Model 부분이 없기 때문이죠.

 

그래서 일단 Model부분을 자세히 들어가기 전에 컴파일이 가능한 수준의 Model을 먼저 알아보겠습니다.

 

package main

import (
  "time"
)

// Speeds
const slowestSpeed = 700 * time.Millisecond
const fastestSpeed = 60 * time.Millisecond

// 게임 운영에 필요한 기초 변수(상수)
const numSquares = 4  // 한개의 블록은 총 4개의 스퀘어로 구성됨
const numTypes = 7    // 블록의 종류는 7가지
const defaultLevel = 1
const maxLevel = 10
const rowsPerLevel = 5

// Pieces
var dxBank = [][]int{
  {},
  {0, 1, -1, 0},
  {0, 1, -1, -1},
  {0, 1, -1, 1},
  {0, -1, 1, 0},
  {0, 1, -1, 0},
  {0, 1, -1, -2},
  {0, 1, 1, 0},
}

var dyBank = [][]int{
  {},
  {0, 0, 0, 1},
  {0, 0, 0, 1},
  {0, 0, 0, 1},
  {0, 0, 1, 1},
  {0, 0, 1, 1},
  {0, 0, 0, 0},
  {0, 0, 1, 1},
}

type gameState int

const (
  gameIntro gameState = iota
  gameStarted
  gamePaused
  gameOver
)

// Game 구조체를 만들어 모든 게임관련 변수를 저장
type Game struct {
  board        [][]int // [y][x]
  state        gameState
  level        int
  numLines     int
  piece        int
  x            int
  y            int
  dx           []int
  dy           []int
  dxPrime      []int
  dyPrime      []int
  skyline      int
  fallingTimer *time.Timer
}

// NewGame 함수는 g.resetGame()으로 초기화한 상태의 Game 구조체를 리턴
func NewGame() *Game {
  g := new(Game)
  g.resetGame()
  return g
}

// Game 구조체를 새로 리셋(초기화)
func (g *Game) resetGame() {
  g.board = make([][]int, boardHeight)
  for y := 0; y < boardHeight; y++ {
    g.board[y] = make([]int, boardWidth)
    for x := 0; x < boardWidth; x++ {
      g.board[y][x] = 0
    }
  }

  g.state = gameIntro
  g.level = 1
  g.numLines = 0
  g.x = 1
  g.y = 1
  g.dx = []int{0, 0, 0, 0}
  g.dy = []int{0, 0, 0, 0}
  g.dxPrime = []int{0, 0, 0, 0}
  g.dyPrime = []int{0, 0, 0, 0}
  g.skyline = boardHeight - 1

  g.fallingTimer = time.NewTimer(time.Duration(1000000 * time.Second))
  g.fallingTimer.Stop()
}

func (g *Game) play() {

}

func (g *Game) pause() {

}

func (g *Game) start() {

}

func (g *Game) fall() {

}

func (g *Game) moveDown() {

}

func (g *Game) moveRight() {

}

func (g *Game) moveLeft() {

}

func (g *Game) rotate() {

}

 

코드를 자세히 살펴보겠습니다.

 

먼저 slowestSpeed와 fastestSpeed 부분입니다. 각각 700밀리 초와 60밀리 초로 설정되어 게임 진행 중에 레벨이 올라갈수록 이 범위 내에서 속도를 조절하게 됩니다.

 

그리고 게임 플레이 관련 상수입니다.

 

numSquares=4는 테트리스 블록 각각 4개의 스퀘어로 구성된다는 뜻입니다.

 

그리고 numTypes=7 은 테트리스 블록 종류가 7가지라는 뜻입니다.

 

그다음 상수는 이름 그대로 이해하면 되기 때문에 지나가도록 하겠습니다.

 

그리고 가장 중요한 dxBank와 dyBank 2차원 배열에 대해 알아보겠습니다.

 

// Pieces
var dxBank = [][]int{
  {},
  {0, 1, -1, 0},
  {0, 1, -1, -1},
  {0, 1, -1, 1},
  {0, -1, 1, 0},
  {0, 1, -1, 0},
  {0, 1, -1, -2},
  {0, 1, 1, 0},
}

먼저 dxBank입니다.

 

4개의 구성원 가지는 자식 배열이 총 8개가 있습니다.

 

그중에서 첫 번째 배열은 빈 배열인데 배열 인덱스 0 은 쓰지 않을 생각으로 빈 배열로 지정했습니다.

 

그다음 dyBank도 같은 방식입니다.

 

그러면 이 dxBank와 dyBank가 무엇을 뜻하는지 자세히 알아보겠습니다.

 

지난 시간에 우리는 테트리스 보드판이 커다란 2차원 배열이라는 것을 배웠습니다.

 

위 그림과 같이 X축과 Y축으로 구성된 커다란 배열인 거죠.

 

그러면 dxBank와 dyBank에 있는 값을 위 테트리스 보드판에 그려볼까요?

 

위 그림은 그리다 보니까 rotate 동작에 대한 로직도 같이 그렸는데 먼저 각 블록의 첫 번째를 보시면 됩니다.

 

dxBank 변수의 첫 번째 값인 {0, 1, -1, 0} 과 dyBank 변수의 첫번째 값인 {0, 0, 0, 1}이 한 쌍을 이뤄 테트리스 블록을 구성합니다.

 

위 그림의 첫 번째 블록을 보시면 dxBank 변수의 첫번째 값인 {0, 1, -1, 0} 에서 첫번째 값인 0과 dyBank 의 첫번째 값인 0이 한쌍을 이뤄 테트리스 보드판에서 보면 (0,0) 좌표를 구성하게 됩니다.

 

그림을 자세히 보시면 이해할 수 있을 겁니다.

 

제가 아까 rotate 동작에 대해서 잠깐 언급했는데 테트리스에서 회전 버튼을 누르면 블록이 회전하는 로직입니다.

 

간단히 현상태의 {x, y} 값에다 -1을 곱하면 됩니다. 즉, 0은 0으로 1은 -1로, -1은 1로 바꾸면 그 블록이 회전한다는 뜻입니다.

 

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

 

그럼 그다음으로 gameState에 대해 알아보겠습니다.

 

gameState는 int 값으로 type 새로 생성했는데 gameIntro와 gameStarted, gamePaused, gameOver를 각각 iota 함수에 의해 0, 1, 2, 3의 값을 가지게 됩니다.

 

이제 중요한 Game 구조체에 대해 알아보겠습니다.

 

type Game struct 구조체로 정의된 우리의 Game 구조체에는 게임에 필요한 모든 요소가 들어가 있습니다.

 

board 변수와 state 변수, level 변수, numLines 변수, piece변수, x, y, 좌표, dx, dy 배열, dxPrime, dyPrime 배열, 그리고 skyline 변수, fallingTimer 변수까지 정말 다양한 변수로 이루어져 있습니다.

 

그리고 그다음 코드는 NewGame으로 Game 구조체를 만들어서 리턴해 주고

 

resetGame 메써드로 Game 구조체에 대해 초기화해주는 루틴이 있습니다.

 

그리고 그다음에 빈칸으로 있는 각각의 메써드는 이제 차례차례 구현해 나갈 예정입니다.

 

일단 여기까지 작성하고 컴파일 및 실행해 보겠습니다.

 

실행 화면은 아래와 같습니다.

 

Model, View, Controller 패턴에 의해 구현된 테트리스 게임입니다.

 

이제 본격적으로 Model 부분을 마저 이어나가도록 해보겠습니다.

 

먼저 start 메써드입니다.

 

//  s 키를 누르면 게임이 시작됩니다.
func (g *Game) start() {
	switch g.state {
	case gameStarted:
		return
	case gamePaused:
		g.resume()
		return
	case gameOver:
		g.resetGame()
		fallthrough
	default:
		g.state = gameStarted
		g.getPiece()
		g.placePiece()
		g.resetFallingTimer()
	}
}

Controller 부분에서 s 키를 누르면 호출되는 start 메써드입니다.

 

이 메써드는 로직이 간단합니다. gameState 인 g.state에 따라서 해당되는 루틴을 실행합니다.

 

 default 부분에 있는 코드를 유심히 살펴보겠습니다.

 

기본적으로 s 키를 누르면 g.state를 gameStarted 상태로 설정하고

 

그다음 g.getPiece() 함수로 게임상에서 떨어질 테트리스 블록을 구하는 함수입니다.

 

그리고 g.placePiece() 함수로 g.getPiece()로 설정된 테트리스 블록을 보드상에 넣어주는 함수이고

 

마지막으로 g.resetFallingTimer() 함수로 시간을 재설정하는 겁니다.

 

테트리스 게임은 일정 시간 동안 느리게 움직이면서 게임이 진행되는데 이 역할을 하는 게 g.resetFallingTimer() 함수입니다.

 

그럼 g.getPiece() 함수에 대해 알아보겠습니다.

 

// 테트리스 블록을 랜덤으로 설정
func (g *Game) getPiece() bool {
	g.piece = 1 + rand.Int()%numTypes
	g.x = boardWidth / 2
	g.y = 0
	for k := 0; k < numSquares; k++ {
		g.dx[k] = dxBank[g.piece][k]
		g.dy[k] = dyBank[g.piece][k]
	}
	for k := 0; k < numSquares; k++ {
		g.dxPrime[k] = g.dx[k]
		g.dyPrime[k] = g.dy[k]
	}
	if !g.pieceFits(g.x, g.y) {
		return false
	}
	g.placePiece()
	return true
}

게임 속에서 떨어질 테트리스 블록은 랜덤 하게 매번 바뀝니다.

 

즉 첫 번째 코드에 의해 1부터 7까지의 숫자 중 하나가 산정되는 거죠. 이 숫자는 g.piece에 저장됩니다.

 

1부터 7까지의 숫자는 각각의 테트리스 블록에 해당됩니다.

 

그리고 처음 테트리스 블록이 나오면 그 위치는 보드 상단 가운데로 지정됩니다.

 

g.y = 0 코드와 g.x = boardWidth / 2 코드에 의해서죠.

 

그러면 이제 현재 움직이는 블록의 상태에 대해 dx와 dy에 그 값을 저장하게 됩니다.

 

이때 우리는 dxBank와 dyBank 변수에서 그 값을 가져오게 되는 거죠.

 

dxBank [g.piece][k] 부분에서 보면 k는 for 루프를 4번 하는 변수이고 g.piece는 위에서 랜덤 하게 나온 1부터 7까지의 숫자입니다.

 

즉, 블록의 구성요소를 g.dx와 g.dy에 저장한다는 뜻입니다.

 

그리고 g.dxPrime과 g.dyPrime에도 같은 값으로 저장합니다.

 

일단은 dxPrime과 dyPrime은 블록 이동, 회전 시 잠깐 이용하는 임시 변수로 이해하시면 됩니다.

 

그리고 g.pieceFits(g.x , g.y) 함수에 의해 새로 랜덤 하게 생긴 테트리스 블록이 현재 상태에서 정상인지 체크하게 됩니다.

 

이 코드를 보겠습니다.

 

// Return whether or not a piece fits.
func (g *Game) pieceFits(x, y int) bool {
  for k := 0; k < numSquares; k++ {
    theX := x + g.dxPrime[k]
    theY := y + g.dyPrime[k]
    if theX < 0 || theX >= boardWidth || theY >= boardHeight {
      return false
      }
        if theY > -1 && g.board[theY][theX] > 0 {
          return false
        }
    }
  return true
}

현재 블록의 위치가 보드 폭(boardWidth)과 보드 높이(boardHeight)를 벗어나는지,

 

아니면 블록이 Y축과 X축을 넘어서는지 체크하게 됩니다.

 

그다음으로 g.placePiece() 함수에 대해 알아보겠습니다.

 

// 테트리스 블록을 보드에 놓는 함수
func (g *Game) placePiece() {
  for k := 0; k < numSquares; k++ {
    x := g.x + g.dx[k]
    y := g.y + g.dy[k]
    if 0 <= y && y < boardHeight && 0 <= x && x < boardWidth && g.board[y][x] != -g.piece {
      g.board[y][x] = -g.piece
    }
  }
}

위 코드를 보시면 g.board [y][x]에 -g.piece 값을 넣었습니다.

 

즉, 현재 블록은 -값으로 구분하여 저장하는 거죠.


쉽게 풀어보면 먼저 g.board [0][8] 은 초기값이 0입니다.

g.piece = 1에서 7까지 숫자

최종적으로 placePiece는 g.board [][]에 음수 값이 들어감.

g.board [y][x]
   x0 x1 x2 x3 x4 x5 x6 x7 x8 x9 x10
y0                      -3 -3 -3
y1                         -3
y2
y3
y4
y5
y6
y7
y8
..
..
..
..

위 그림과 같이 X축, Y축을 이루면서 해당 블록이 음수 값으로 g.board에 저장되는 거죠.

 

이제 g.play() 함수와 관련 함수에 대해 알아보겠습니다.

 

// Set the timer to make the pieces fall again.
func (g *Game) resetFallingTimer() {
	g.fallingTimer.Reset(g.speed())
}

// Function speed calculates the speed based on the level.
func (g *Game) speed() time.Duration {
	return slowestSpeed - fastestSpeed*time.Duration(g.level)
}

// This gets called everytime g.fallingTimer goes off.
func (g *Game) play() {
	if g.moveDown() {
		g.resetFallingTimer()
	} else {
		g.lockPiece()
	}
}

// Pressing spacebar should immediately lock the piece at the bottom.
func (g *Game) lockPiece() {
	g.fillMatrix()
	g.removeLines()
	if g.skyline > 0 && g.getPiece() {
		g.resetFallingTimer()
	} else {
		g.state = gameOver
	}
}

// This gets called as part of the piece falling.
func (g *Game) fillMatrix() {
	for k := 0; k < numSquares; k++ {
		x := g.x + g.dx[k]
		y := g.y + g.dy[k]
		if 0 <= y && y < boardHeight && 0 <= x && x < boardWidth {
			g.board[y][x] = g.piece
			if y < g.skyline {
				g.skyline = y
			}
		}
	}
}

// Look for completed lines and remove them.
func (g *Game) removeLines() {
	for y := 0; y < boardHeight; y++ {
		gapFound := false
		for x := 0; x < boardWidth; x++ {
			if g.board[y][x] == 0 {
				gapFound = true
				break
			}
		}
		if !gapFound {
			for k := y; k >= g.skyline; k-- {
				for x := 0; x < boardWidth; x++ {
					g.board[k][x] = g.board[k-1][x]
				}
			}
			for x := 0; x < boardWidth; x++ {
				g.board[0][x] = 0
			}
			g.numLines++
			g.skyline++
			if g.numLines%rowsPerLevel == 0 && g.level < maxLevel {
				g.level++
			}
		}
	}
}

g.play() 함수는 게임이 진행되는 상태의 함수인데요.

 

게임 진행은 블록이 아래로 떨어져야 하는가와 그렇지 않은가로 구분합니다.

 

그래서 g.moveDown() 함수를 호출해서 정상일 때는 g.resetFallingTimer()를 이용해서 타이머를 재조정하고,

 

g.moveDown() 함수에서 뭔가 이상한 일이 발생하면 g.lockPiece() 함수로 일단 락을 겁니다.

 

그럼 g.lockPiece() 함수를 좀 더 살펴보겠습니다.

 

일단 g.fillMatrix() 함수로 board 2차원 배열에 해당 블록을 지정하고

 

그리고 g.removeLines() 함수로 블록을 지워야 할 게 있으면 지우는 함수를 실행합니다.

 

그리고 g.skyline변수를 이용해 보드 상단을 벗어 낫는지 체크하고 게임오버 상태로 만드는 거죠.

 

이제 기타 함수에 대해 알아보겠습니다.

 

// The user pressed the 'p' key to pause the game.
func (g *Game) pause() {
	switch g.state {
	case gameStarted:
		g.state = gamePaused
		g.fallingTimer.Stop()
	case gamePaused:
		g.resume()
	}
}

// The user pressed the left arrow.
func (g *Game) moveLeft() {
	if g.state != gameStarted {
		return
	}
	for k := 0; k < numSquares; k++ {
		g.dxPrime[k] = g.dx[k]
		g.dyPrime[k] = g.dy[k]
	}
	if g.pieceFits(g.x-1, g.y) {
		g.erasePiece()
		g.x--
		g.placePiece()
	}
}

// The user pressed the right arrow.
func (g *Game) moveRight() {
	if g.state != gameStarted {
		return
	}
	for k := 0; k < numSquares; k++ {
		g.dxPrime[k] = g.dx[k]
		g.dyPrime[k] = g.dy[k]
	}
	if g.pieceFits(g.x+1, g.y) {
		g.erasePiece()
		g.x++
		g.placePiece()
	}
}

// The user pressed the up arrow in order to rotate the piece.
func (g *Game) rotate() {
	if g.state != gameStarted {
		return
	}
	for k := 0; k < numSquares; k++ {
		g.dxPrime[k] = g.dy[k]
		g.dyPrime[k] = -g.dx[k]
	}
	if g.pieceFits(g.x, g.y) {
		g.erasePiece()
		for k := 0; k < numSquares; k++ {
			g.dx[k] = g.dxPrime[k]
			g.dy[k] = g.dyPrime[k]
		}
		g.placePiece()
	}
}

// Move the piece downward if possible.
func (g *Game) moveDown() bool {
	if g.state != gameStarted {
		return false
	}
	for k := 0; k < numSquares; k++ {
		g.dxPrime[k] = g.dx[k]
		g.dyPrime[k] = g.dy[k]
	}
	if !g.pieceFits(g.x, g.y+1) {
		return false
	}
	g.erasePiece()
	g.y++
	g.placePiece()
	return true
}

// The user pressed the space bar to make the piece fall.
func (g *Game) fall() {
	if g.state != gameStarted {
		return
	}
	for k := 0; k < numSquares; k++ {
		g.dxPrime[k] = g.dx[k]
		g.dyPrime[k] = g.dy[k]
	}
	if !g.pieceFits(g.x, g.y+1) {
		return
	}
	g.fallingTimer.Stop()
	g.erasePiece()
	for g.pieceFits(g.x, g.y+1) {
		g.y++
	}
	g.placePiece()
	g.resetFallingTimer()
	g.lockPiece()
}

// Resume after pausing.
func (g *Game) resume() {
	g.state = gameStarted
	g.play()
}

g.pause() 함수는 게임을 일시 정지하는 함수이고요

 

g.moveLeft() g.moveRight(), g.moveDown() 함수는 각각 블록을 왼쪽, 오른쪽, 아래로 이동하는 함수힙니다.

 

g.x 값을 조정하는 거는 g.moveLeft()와 g.moveRight() 함수이고,

 

g.y 값을 조정하는거는 g.moveDown() 함수입니다.

 

위 함수는 각각 g.pieceFits() 함수에 의해 바운드 체크를 하게 됩니다.

 

그리고 g.rotate() 함수인데 앞부분에서 살펴본 거처럼 각 블록의 dx, dy값에 -값을 곱해서 블록을 회전시키는 함수입니다.

 

마지막으로 g.resume() 함수는 게임 일시정지를 푸는 함수입니다.

 

이상으로 GoLang으로 테트리스 게임을 만들어 보았는데요.

 

실행화면을 한번 보실까요?

 

잘 실행되네요.

 

이상으로 Go언어 게임 강좌를 마치도록 하겠습니다.

그리드형