본문 바로가기

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

09. [스칼라 테트리스] 05. 테트로미노를 쌓는 게임보드

지금까지 블록, 테트로미노 클래스를 개발하고 테스트했다.

블록은 하나의 정사각형, 테트로미노는 4개의 블록으로 이루어진 테트리스의 기본 블록이다.


이제 테트로미노 블록이 움직이고 쌓이는 보드 클래스와 보드의 게임 동작을 제어하는 보드 컨트롤러를 구현하고

보드 컨트롤러와 스윙을 이용한 패널, 프레임을 개발하면 싱글 테트리스는 완성된다.

이번에는 테트로미노를 쌓는 게임보드 클래스를 구현해보자.


게임보드


스칼라 테트리스의 기본 실행화면은 위의 사진과 같다.

메인프레임 내의 패널, 점수판, 메뉴바가 존재한다.

게임보드 클래스는 왼쪽 검정색 패널에서 테트로미노 블록을 이동시키고, 쌓기 위한 공간을 의미한다.


앞에서 (x, y) 튜플의 2차원 배열로 블록의 포지션을 정의하고,

테트로미노 블록의 회전값과 위치를 정의한 이유가 모두 보드 클래스에서 테트로미노 블록을 그리기 위함이다.


짝 객체

자바는 클래스(static) 메소드인스턴스(non-static) 메소드를 하나의 클래스 안에 만들 수 있다.

스칼라는 이런 방법을 이용하지 않는다.

인스턴스 메소드는 class 정의 내부에서 선언한다.

그리고 그것이 하나의 객체 인스턴스만 갖는다면 class 키워드 대신 object 키워드를 사용한다.


1
2
3
object Board { ... // static }
 
class Board { ... // non-static }
cs

위 코드는 싱글톤 객체를 생성한다.

클래스 메소드는 싱글톤 객체 선언 내에서, 인스턴스 메소드는 클래스 선언 내에서 정의한다.

class와 object를 같이 사용하는 방법을 짝 객체(Companion Object)라고 부른다.

스칼라에서 object 키워드를 이용하여 만든 객체는 보통 함수, 팩토리에 사용된다.


게임보드 구현 - 짝 객체

1
2
3
4
5
6
7
8
9
object Board {
  val Width = 10
  val Height = 24
  val EmptyBoardRow = Array.fill[Block.Value](Width)(Block.EMPTY)
  
  def emptyBoard: Array[Array[Block.Value]] = {
    Array.fill[Array[Block.Value]](Height)(EmptyBoardRow)
  }
}
cs

게임보드는 위와 같은 짝 객체를 갖는다.

보드는 기본적으로 넓이와 높이를 가진다.

또한, 편의를 위해 Empty 블록으로 셀이 가득 채워져있는 row와 그 row로 열이 가득 채워진 board를 가지는데,

이는 나중에 가득 채워진 열을 지우거나, 보드를 새로 생성할 때 사용한다.


게임보드 구현 - 게임보드 클래스

1
2
3
4
5
6
class Board(var board: Array[Array[Block.Value]]) {
  
  def this() = this(Board.emptyBoard)
 
  override def clone: Board = new Board(board.map(_.clone))
}
cs

게임보드는 Block의 2차원 배열을 가진다.

테트로미노의 블록이 쌓이는 곳이므로 당연하다.


게임보드의 가장 기본적인 기능은 2차원 배열에 블록을 저장하는 것이다.

이를 위해서는 기본적으로 저장하려는 블록의 위치값에 다른 블록의 존재여부를 검사해야한다.

다른 블록이 존재하지 않을 경우, 해당 블록의 위치값이 보드 게임보드 짝 객체의 넓이와 높이 범위에 적합한지 검사해야한다.

이러한 두 과정을 거치면 해당 테트로미노 블록은 보드에 저장할 수 있게 된다.


2차원 배열에 블록을 저장하면서 블록을 쌓아가다보면 Block.Empty가 아닌 블록으로 셀이 가득 채워진 열이 생기게 된다.

게임보드는 이러한 꽉 채워진 열들을 지울 수 있는 기능또한 지원해야한다.


테트로미노 블록 쌓기

1
2
3
4
5
6
7
8
9
10
11
12
def withTetromino(tetromino: Tetromino): Board = {
  val boardCopy = clone
  val positions = tetromino.getBlockPositions
  
  positions.foreach {
    position =>  {
      boardCopy.board(position._2)(position._1= tetromino.block
    }
  }
  
  boardCopy
}
cs

테트로미노 블록의 위치값(Tuple) 배열을 얻어온 뒤, 루프를 돌면서 게임보드(y)(x)에 블록을 저장한다.

게임보드에 블록을 저장하면 게임보드의 상태가 변하는 것이므로, 새로운 게임보드를 반환해야한다.

때문에 호출한 board에 블록을 저장하는 것이 아닌, clone을 이용하여 복사된 boardCopy에 블록을 저장한다.


다른 블록의 존재여부를 검사

1
2
3
4
5
6
def overlap(tetromino: Tetromino): Boolean = {
  val positions = tetromino.getBlockPositions
  !positions.forall {
    position => board(position._2)(position._1).equals(Block.EMPTY)
  }
}
cs

마찬가지로 테트로미노 블록의 위치값(Tuple) 배열을 얻어온다.

그 후에 루프를 돌면서 게임보드(y)(x)에 빈 블록이 아닌 다른 블록이 있는지 검사하여

다른 블록이 있을 경우 true를 반환한다. 없을 경우에는 오버랩이 아니므로 false를 반환한다.


범위에 적합한지 검사

1
2
3
4
5
6
7
8
9
private def legalX: Range = (0 until Board.Width)
private def legalY: Range = (0 until Board.Height)
 
def isLegal(tetromino: Tetromino): Boolean = {
  val positions = tetromino.getBlockPositions
  positions.forall {
    position => legalX.contains(position._1&& legalY.contains(position._2)
  } && !overlap(tetromino)
}
cs

이번에도 위치값 배열을 얻어온다.

그전에 미리 x와 y의 범위를 정해놓고, 루프내에서 범위에 포함되는지 검사한다.

각 루프에서 x와 y가 올바른 범위에 해당할 경우, 다른 블록이 존재하지 않는지 또한 검사한다.


* legalX와 legalY가 짝 객체에 정의되어야 하는건 아닌지 고민해봐야겠다.


꽉 채워진 열들을 지울 수 있는 기능

1
2
3
4
5
6
def clearFullRows: Int = {
  val clearedBoard = board.filter(_.contains(Block.EMPTY))
  val clearedRows = Board.Height - clearedBoard.size
  board = Array.fill[Array[Block.Value]](clearedRows)(Board.EmptyBoardRow) ++ clearedBoard
  clearedRows
}

cs

이번에는 조금 구현이 복잡하다.

우선 2차언 배열인 board에서 빈 블록이 포함된 rows만 필터링한다.


7

 Empty

 Empty

 T

 Empty

 Empty

 Empty

 Empty

6

 Empty

 T

 T

 T

 Empty

 Empty

 Empty

5

 Empty

 Empty

 Empty

 Empty

 Empty

 Empty

 Empty

4

 Empty

 Empty

 Empty

 Empty

 Empty

 Empty

 Empty

3

 Empty

 Empty

 Empty

 Empty

 Empty

 Empty

 Empty

2

 T 

 T 

 T 

 T 

 T 

 T 

 Empty 

1

 T

 T

 T

 T

 T

 T

 T

0

 T

 T

 T

 T

 T

 T

 T

보드내의 블록이 위와 같이 저장되어 있을 경우 2~7만 필터링 된다.

보드의 높이가 8, 빈 블록이 포함된 rows의 수는 5이다.

여기서 8-5를 하면 꽉 채워진 열들 (0, 1, 2)가 몇 개 인지 알 수 있다.

그러므로, 보드의 높이에서 빈 블록이 포함된 rows의 수를 빼서 꽉 채워진 열들의 수를 구한다.


마지막으로 게임보드의 board 배열을 새로 채우면된다.

Array.fill[Array[Block.Value]](clearedRows)(Board.EmptyBoardRow)에서 꽉 채워진 열의 개수만큼 빈 블록으로 채운다.

꽉 채워진 열의 개수만큼 빈 블록으로 채운 배열에 이전에 저장해둔 빈 블록이 포함된 rows의 배열인 clearedBoard을 더한다.

반환값이 Int 객체로, 지워진 열의 개수를 반환하는 이유는 점수를 갱신하는데 사용하기 위해서다.


* 이 부분에서 board의 복사본을 생성하지 않고 배열을 변경하는데, 메소드를 두개로 분리해야할 지 고민해봐야겠다.


게임보드 테스트

1
2
3
4
def fixture =
  new {
    val board = new Board
  }
cs

테스트용 게임보드


1
2
3
it should "start with an empty board" in {
  fixture.board.board.flatten.forall(_.equals(Block.EMPTY)) should be (true)
}
cs

게임보드는 빈 상태로 시작한다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
it should "place a block" in {
    val t = new Tetromino(Block.T)
    val l = new Tetromino(Block.L)
    
    fixture.board.overlap(t) should be (false)
    fixture.board.overlap(l) should be (false)
    fixture.board.isLegal(t) should be (true)
    fixture.board.isLegal(l) should be (true)
    
    fixture.board.withTetromino(t).board should be (
        Array(
              Array(Block.EMPTY, Block.EMPTY, Block.EMPTY, Block.EMPTY, Block.EMPTY, Block.T, Block.EMPTY, Block.EMPTY, Block.EMPTY, Block.EMPTY),
              Array(Block.EMPTY, Block.EMPTY, Block.EMPTY, Block.EMPTY, Block.T, Block.T, Block.T, Block.EMPTY, Block.EMPTY, Block.EMPTY)
        )++ Array.fill[Array[Block.Value]](Board.Height-2)(Board.EmptyBoardRow)
    )
    
    
    fixture.board.withTetromino(l).board should be (
        Array(
              Array(Block.EMPTY, Block.EMPTY, Block.EMPTY, Block.EMPTY, Block.L, Block.EMPTY, Block.EMPTY, Block.EMPTY, Block.EMPTY, Block.EMPTY),
              Array(Block.EMPTY, Block.EMPTY, Block.EMPTY, Block.EMPTY, Block.L, Block.L, Block.L, Block.EMPTY, Block.EMPTY, Block.EMPTY)
        )++ Array.fill[Array[Block.Value]](Board.Height-2)(Board.EmptyBoardRow)
    )
}
cs

게임보드에 블록을 저장하기 위한 단위 테스트


1
2
3
4
5
6
it should "notice overlaps" in {
  val l = new Tetromino(Block.L)
  
  fixture.board.overlap(l) should be (false)
  fixture.board.withTetromino(l).overlap(l) should be (true)
}
cs

게임보드에 블록이 오버랩되는지 검사하기 위한 단위 테스트


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
it should "notice illegal tetrominos" in {
  val l = new Tetromino(Block.L)
  
  fixture.board.isLegal(l) should be (true)
  fixture.board.withTetromino(l).isLegal(l) should be (false)
  
  fixture.board.isLegal(l.withMoveLeft.withMoveLeft.withMoveLeft.withMoveLeft.withMoveLeft) should be (false)
  fixture.board.isLegal(l.withMoveRight.withMoveRight.withMoveRight.withMoveRight.withMoveRight) should be (false)
  fixture.board.isLegal(
    l.withMoveDown.withMoveDown.withMoveDown.withMoveDown.withMoveDown
      .withMoveDown.withMoveDown.withMoveDown.withMoveDown.withMoveDown
      .withMoveDown.withMoveDown.withMoveDown.withMoveDown.withMoveDown
      .withMoveDown.withMoveDown.withMoveDown.withMoveDown.withMoveDown
      .withMoveDown.withMoveDown.withMoveDown.withMoveDown.withMoveDown
  ) should be (false)
}
cs

게임보드에 블록을 저장하기에 적합한지 검사하기 위한 단위 테스트


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
it should "clear full rows with empty in board" in {
  val board = new Board(
    Array(
      Array(Block.EMPTY, Block.EMPTY, Block.EMPTY, Block.EMPTY, Block.EMPTY, Block.T, Block.EMPTY, Block.EMPTY, Block.EMPTY, Block.EMPTY),
      Array(Block.EMPTY, Block.EMPTY, Block.EMPTY, Block.EMPTY, Block.T, Block.T, Block.T, Block.EMPTY, Block.EMPTY, Block.EMPTY),
      Board.EmptyBoardRow,
      Array.fill[Block.Value](Board.Width)(Block.L),
      Array.fill[Block.Value](Board.Width)(Block.O)) ++
      Array.fill[Array[Block.Value]](Board.Height-5)(Board.EmptyBoardRow))
 
  board.clearFullRows should be (2)
 
  board.board should be (
    Array.fill[Array[Block.Value]](2)(Board.EmptyBoardRow) ++
    Array(
       Array(Block.EMPTY, Block.EMPTY, Block.EMPTY, Block.EMPTY, Block.EMPTY, Block.T, Block.EMPTY, Block.EMPTY, Block.EMPTY, Block.EMPTY),
       Array(Block.EMPTY, Block.EMPTY, Block.EMPTY, Block.EMPTY, Block.T, Block.T, Block.T, Block.EMPTY, Block.EMPTY, Block.EMPTY)
    ) ++ Array.fill[Array[Block.Value]](Board.Height-4)(Board.EmptyBoardRow))
}
cs

게임보드에서 꽉 찬 열들을 지우는 기능의 동작을 검사하는 단위 테스트