본문 바로가기

OOP, FP/스칼라로 만드는 테트리스

10. [스칼라 테트리스] 06. 게임을 진행하는 보드 컨트롤러

보드 컨트롤러


보드 컨트롤러는 게임 진행을 담당하는 클래스. 테트리스는 현재 블록, 현재 블록의 고스트블록과 다음에 생성될 블록 세 개의 테트로미노 블록으로 게임을 진행된다. 이러한 블록들의 움직임과 처리를 담당하는 클래스가 보드 컨트롤러이다.


키보드 이벤트를 추가하기 위해 Reactor 트레잇의 reactions 속성을 이용한다. 자세한 설명은 분석을 참조하자.


구현


보드 컨트롤러는 앞서 말한 세 개의 테트로미노 블록이 필요하다. 또한 패널을 다시 그리기 위해서 패널의 인스턴스를 가져야 하며, 공간을 의미하는 보드의 인스턴스까지 필요하다.


1
2
3
4
5
6
class BoardController(val parent: TetrisPanel) extends Reactor {
 
  var board = new Board
  var currentTetromino = new Tetromino
  var nextTetromino = new Tetromino
}
cs


테트리스에는 점수가 존재하며 해당 점수에 따라 게임 속도가 증가한다. 게임을 시작하고 중지할 수 있으며 게임이 시작되고 점수를 갱신하다가 자칫 블록이 가득차면 게임이 종료되고 만다. 이러한 종료를 위한 애니메이션 또한 구현할 예정이다.


필요한 속성들을 모두 정의하면 다음과 같다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class BoardController(val parent: TetrisPanel) extends Reactor {
 
  var board = new Board
  var currentTetromino = new Tetromino
  var nextTetromino = new Tetromino
 
  val StartTickInterval = 600
 
  var score = 0
 
  private val gameOverAnimation = new GameOverAnimation(this)
 
  var gameRunning = true
  var gameOver = false
}
cs

 

StartTrickInterval는 속도, score는 점수, gameOverAnimation은 게임종료 애니메이션, gameRunning은 게임 진행 중 여부, gameOver는 게임종료 여부를 위한 변수이다.

 

게임 진행 속도

가장 먼저 타이머와 이를 이용한 게임 진행 속도에 관련된 기능부터 구현해보자.  우리는 현재 score가 높아질수록 진행속도를 빠르게 해야한다. StartTickInterval과 score를 이용하여 해당 기능을 구현해보자.


1
 def getTickInterval(score: Int): Int = StartTickInterval / (Math.sqrt(score/5).toInt + 1)
cs


StartTickInterval의 초기값인 600으로 시작해서 점수가 올라갈 때마다 특정 값으로 나눈다. 이제 이 메소드를 이용한 타이머를 구현해보자.


1
2
3
4
5
6
val tetrisTick: Timer = new Timer(getTickInterval(score), gameLoop)
 
private def setScore(newScore: Int): Unit = {
  score = newScore
  tetrisTick.setDelay(getTickInterval(score))
}
cs


게임보드에 타이머의 인스턴스를 생성하는데, 이 타이머는 getTickInterval 메소드의 반환값을 주기로 gameLoop 라는 액션리스너의 특정 메소드를 수행한다. 다음으로 setScore 메소드는 말 그대로 점수를 갱신하는 메소드이다. 컨트롤러의 점수를 인자값으로 받은 점수로 갱신한다. 점수가 갱신되었다는 의미는 게임 속도 또한 변경된다는 의미이므로 미리 생성한 타이머의 주기 또한 변경한다.


게임 진행 속도와 관련된 기능의 구현이 완료되었다. 이제 본격적으로 게임 진행과 관련된 기능을 구현해보자.


상하좌우 움직임

1
2
3
4
5
6
7
def tryMove(tetromino: Tetromino): Unit = {
  if (!gameRunning) return
  if (board.isLegal(tetromino)) {
    currentTetromino = tetromino
  }
  parent.repaint
}
cs


tryMove 메소드는현재 움직이고 있는 테트로미노 블록을 변경하는 역할을 한다. 게임이 진행 중일 때, 인자로 받은 테트로미노 블록의 적합성을 검사하고 현재 블록을 변경한다. 이 메소드가 호출되었다는 것은 화면이 변경되었다는 의미이므로 마지막에 패널을 다시 그려준다. 이러한 tryMove 메소드는 상하좌우 움직임에 사용한다. 즉 키보드 방향키를 눌렀을 때 호출한다. 키보드 방향키 이벤트를 등록해보자.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
reactions += {
  case KeyPressed(_, Key.Down, __=> tryMove(currentTetromino.withMoveDown)
  
  case KeyPressed(_, Key.Left, __=> tryMove(currentTetromino.withMoveLeft)
  
  case KeyPressed(_, Key.Right, __=> tryMove(currentTetromino.withMoveRight)
  
  case KeyPressed(_, Key.Up, __=> tryMove(currentTetromino.withRotation)
  
  case KeyPressed(_, Key.Space, __=> dropTetromino
 
  case KeyPressed(_, Key.P, __=> togglePause
 
  case KeyPressed(_, Key.N, __=> newGame
}
cs


2, 4, 6, 8번 라인에 주목하자. reactions에 상하좌우 방향키를 눌렀을 때 수행할 이벤트를 추가한다. 각각 테트로미노 블록의 방향을 지정하여 tryMove 메소드에 전달해서 현재 블록을 이동시킨다. 스페이스키는 블록을 쌓고, P키는 게임을 일시정지 혹은 재진행시키고, N키는 새로운 게임을 시작하는데 추후에 구현할 것이다.


* reactions

reactions는 scala.swing.Reactor 트레잇의 속성으로 Reactions.Impl 클래스의 인스턴스이다. Reactions.Impl 클래스는 4가지 API를 제공하는데, event.Event를 추가하고 삭제하는 것에 관련된 기능을 제공한다. 우리는 Reactions.Impl 클래스의 += 메소드를 이용해서 이벤트를 추가할 수 있다. 키보드를 누르는 이벤트는 scala.swing.event.KeyPressed 케이스 클래스를 이용해서 등록한다.


* 케이스 클래스

스칼라는 케이스 클래스 개념을 지원한다. 케이스 클래스는 아래와 같은 특징을 가지는 일반 클래스이다.

- 기본적으로 불변

- 패턴 매칭을 통해 분해가능

- 래퍼런스가 아닌 구조적인 동등성으로 비교됨

- 초기화와 운영이 간결함

- 예시

1
2
3
4
5
6
7
8
9
10
11
12
sealed abstract class KeyEvent extends InputEvent {
  def peer: java.awt.event.KeyEvent
}
 
case class KeyTyped(val source: Component, char: Char, val modifiers: Key.Modifiers,
                    location: Key.Location.Value)
                   (val peer: java.awt.event.KeyEvent) extends KeyEvent {
  def this(e: java.awt.event.KeyEvent) =
    this(UIElement.cachedWrapper[Component](e.getSource.asInstanceOf[JComponent]),
        e.getKeyChar, e.getModifiersEx,
        Key.Location(e.getKeyLocation))(e)
}
cs


지금까지 게임 진행속도와 상하좌우 움직임을 구현했다. 게임 진행을 구현하기에 앞서 이번에는 나머지 키입력 이벤트인 블록 쌓기, 게임 상태 토글, 게임 시작을 구현해보자.


블록 쌓기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def droppedTetromino: Tetromino = {
  var tetromino = currentTetromino
  while (board.isLegal(tetromino.withMoveDown)) {
    tetromino = tetromino.withMoveDown
  }
  tetromino
}
 
def placeTetromino: Unit = {
  board = board.withTetromino(currentTetromino)
  setScore(score + board.clearFullRows)
  currentTetromino = nextTetromino
  nextTetromino = new Tetromino
}
 
def dropTetromino: Unit = {
  if (!gameRunning) return
  currentTetromino = droppedTetromino
  placeTetromino
}
cs


droppedTetromino 메소드는 현재 블록을 제일 아래 좌표를 갖게 한 뒤 반환한다. 보드의 적합성을 위반하지 않을 때까지 현재 테트로미노를 아래로 이동시키면된다.


placeTetromino 메소드는 현재 블록을 보드에 그린다. 우선 보드에 현재 테트로미노 블록을 위치시키고 점수를 갱신한다. 다음으로 새로운 블록을 생성해야 하므로 현재 블록에는 다음 블록을 넣고, 다음에 생성될 블록을 랜덤으로 생성한다.


dropTetromino 메소드는 위의 두 메소드를 이용하여 블록을 바닥에 위치시키고 화면을 갱신한다.

먼저 droppedTetromino 메소드를 호출하여 바닥에 위치시킨 블록을 가져와 현재 블록으로 지정한다. 결국 현재 블록은 바닥에 위치하게 된다. 이후 placeTetromino 메소드를 호출하여 그 블록을 보드에 위치시키고 화면을 갱신한다.

 

게임 상태 토글

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def pauseGame: Unit = {
  gameRunning = false
  tetrisTick.stop
}
 
def resumeGame: Unit = {
  if (!gameOver) {
    gameRunning = true
    tetrisTick.start
  }
}
 
def togglePause = {
  if (gameRunning) {
    pauseGame
  } else {
    resumeGame
  }
}
cs

 

pauseGame 메소드는 게임을 잠시 멈추기 위하여 gameRunning을 false로 지정한 뒤 타이머를 중지한다.

 

resumeGame 메소드는 게임을 다시 진행하기 위하여 gameRunning을 true로 지정한 뒤 타이머를 시작한다.

 

togglePause 메소드는 위의 두 메소드를 이용하여 게임이 진행 중일 경우 게임을 중지, 게임이 진행 중이지 않을 경우 게임을 진행상태로 변경한다.

 

게임 시작

1
2
3
4
5
6
7
def newGame: Unit = {
  placeTetromino
  board = new Board
  setScore(0)
  gameOver = false
  resumeGame
}
cs


newGame 메소드는 게임을 새로 시작하기 위한 메소드이다.

가장 먼저 placeTetromino 메소드를 호출하여 현재 블록을 보드에 그리고 화면을 갱신한다.

다음으로 보드의 인스턴스를 새로 생성한 뒤, 점수를 0으로 설정한다.

마지막으로 gameOver를 false로 지정 한 뒤, resumeGame 메소드를 호출하여 게임을 진행상태로 변경한다.

 

* 왜 맨 처음에 placeTetromino를 호출할까?

우선 newGame의 구현을 아래와 같이 바꿔도 같은 동작을 한다.

여기서 placeTetromino를 호출하는 이유는 단지 블록을 변경하기 위함인듯 하다.

 

게임 진행

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
def repaint = parent.repaint
 
def getTickInterval(score: Int): Int = StartTickInterval / (Math.sqrt(score/5).toInt + 1)
 
val gameLoop = new ActionListener {
  override def actionPerformed(e: ActionEvent) {
    val newTetromino = currentTetromino.withMoveDown
    if (board.isLegal(newTetromino)) {
      currentTetromino = newTetromino
 
    } else {
      placeTetromino
      if (!board.isLegal(currentTetromino)) {
        gameOver = true
        gameOverAnimation.restart
        pauseGame
      }
    }
    parent.repaint
  }
}
 
val tetrisTick: Timer = new Timer(getTickInterval(score), gameLoop)
 
tetrisTick.start
cs

 

getTrickInterval 메소드와 tetrisTick 변수는 앞에서 등장했지만, 게임 진행과 직접적인 관련이 있기 때문에 다시 등장했다.

repaint 메소드는 보드 컨트롤러 생성자의 인자로 받은 패널의 repaint 메소드를 호출하여 패널을 다시 그리기 위한 메소드이다.

 

보드 컨트롤러가 생성되면 보드, 현재 블록, 다음 블록 생성 등 초기화 작업이 진행된다.

초기화 작업을 모두 완료하고 타이머를 생성한 뒤, 마지막으로 타이머를 시작한다.

이로써 일정 주기마다 gameLoop라는 액션 리스너의 메소드가 호출되면서 게임 진행을 제어하게 된다.

결국, 보드 컨트롤러의 핵심은 gameLoop 액션 리스너와 타이머라고 할 수 있다.

 

Timer 클래스

Timer(int delay, ActionListener listener)
Creates a Timer and initializes both the initial delay and between-event delay to delay milliseconds.

 

위처럼 타이머 클래스의 생성자는 두 개의 인자를 받는다.

delay: 초기 지연 후, listener를 해당 ms마다 호출한다.

listener: 호출될 액션 리스너의 인스턴스. 액션 리스너는 버튼클릭, 등등의 이벤트가 발생했을 때 수행되는 이벤트이다.

 

gameLoop 리스너

설정된 주기마다 실행되는 이벤트이다.

테트리스는 주기마다 현재 블록이 한 칸 아래로 움직인다.

한 칸 아래로 움직이면서 블록을 바닥에 위치시키거나, 게임이 종료되었음을 판단한다.

 

1
val newTetromino = currentTetromino.withMoveDown
cs

우선 현재 블록이 아래로 움직인 좌표를 갖는 새 블록을 생성한다.

 

1
2
3
if (board.isLegal(newTetromino)) {
  currentTetromino = newTetromino
}
cs

새 블록이 적법한 좌표를 갖는다면 현재 블록에 대입한다.

 

1
2
3
4
5
6
7
8
else {
  placeTetromino
  if (!board.isLegal(currentTetromino)) {
    gameOver = true
    gameOverAnimation.restart
    pauseGame
  }
}
cs

새 블록이 적법한 좌표를 갖지 않는다면 현재 블록은 아래로 한 칸 이동할 수 없는 상태이다.

때문에 우선 placeTetromino를 호출하여 현재 블록을 바닥에 위치시키고 화면과 블록을 갱신한다.

이제 현재 블록은 다음 블록으로 교체되었다. 여기서 교체된 블록이 적법한 좌표를 갖지 않는 경우는 어떤 경우일까?

 

바로 위와 같은 경우일 것이다. 현재 블록인 J를 바닥에 위치시키고 다음 블록과 교체해야 한다.

그러나 화면에 블록이 꽉 차서 다음 블록이 overlap되어 적법하지 않은 좌표를 갖게 된다.

이런 상황이 테트리스 게임오버가되는 상황이다.

 

1
2
3
4
5
if (!board.isLegal(currentTetromino)) {
    gameOver = true
    gameOverAnimation.restart
    pauseGame
}
cs

 

게임오버가 되는 경우, gameOver를 true로 지정하고 미리 생성해둔 게임오버 애니메이션을 시작한다.

마지막으로 pauseGame을 호출하여 게임을 중지상태로 변경하고 타이머를 중지시키면 게임은 더 이상 진행되지 않는다.


정리


블록, 테트로미노, 게임보드에 이어 게임보드 컨트롤러를 구현했다.

게임보드 컨트롤러는 게임보드에서 블록의 움직임과 게임 진행을 제어하는 컨트롤러이다.