Summa sidvisningar

fredag 11 februari 2011

Ett steg på vägen

Nu har jag uppnått ett första delmål nämligen att få programmet att spela Tetris och uppföra sig på samma sätt som C++ versionen, dock än så länge utan grafiskt gränssnitt. När jag testar köra C++ versionen på min nya bärbara dator med Intel i7-processor lägger den 23.000 bitar per sekund medan Scala-versionen endast gör 2.000 bitar per sekund, alltså mindre än 10% av C++ versionens hastighet. Den ursprungliga Java-versionen (som skrevs före C++ versionen, alltså ej den halvfärdiga Java-version som finns med i detta projekt) kom upp till 20% av C++ versionen. Scala-versionen är dessutom endast hälften så snabb som Java-versionen trots att den kör på en tio år nyare 64-bitars JVM! Detta är lite av en besvikelse, men jag utgår ifrån att det finns utrymme för optimering.

Under Pieces/sec ser man att C++ versionen kör med en hastighet av 23.803 bitar per sekund (varierar lite under körning). Jag beklagar att jag inte kan släppa en körbar version då den innehåller en grafisk komponent som inte får distribueras fritt:


För att hantera att datorn kan spela själv har jag skapat klassen Game. Då jag ville säkerställa att Scala-versionen uppför sig exakt som C++ versionen skrev jag ett test som verifierar detta, GameTest:

package net.sf.tetrisai.game

import org.junit.Test
import net.sf.tetrisai.settings.DefaultGameSettings
import net.sf.tetrisai.board.Board
import net.sf.tetrisai.boardevaluator.JoakimTengstrandBoardEvaluator
import net.sf.tetrisai.BaseTest
import net.sf.tetrisai.piece._
import collection.mutable.Buffer
import net.sf.tetrisai.piecegenerator.PredictablePieceGenerator

class GameTest extends BaseTest {

  @Test def playFivePieces() {
    val board = Board(10,15)
    val boardEvaluator = new JoakimTengstrandBoardEvaluator(board.width, board.height)
    val pieceGenerator = new PredictablePieceGenerator(List(new PieceO, new PieceL, new PieceI, new PieceZ, new PieceT))
    val settings = new DefaultGameSettings
    val game = new Game(board, boardEvaluator, pieceGenerator, settings)
    game.play(5)

    game.linesCleared should be (1)

    board should be (Board(Buffer(
      "#----------#",
      "#----------#",
      "#----------#",
      "#----------#",
      "#----------#",
      "#----------#",
      "#----------#",
      "#----------#",
      "#----------#",
      "#----------#",
      "#----------#",
      "#----------#",
      "#---------Z#",
      "#--------ZZ#",
      "#OO---TTTZL#",
      "############")))
  }
}


Den spelar fem bitar, för varje bit använder den sig av samma algoritm som i C++ för att hitta bästa alternativ att placera biten (jag kommer gå igenom algoritmen i detalj vid nästa blogginlägg). Därefter testas att korrekt antal rader rensats och att slutpositionen är den förväntade. Implementation av Game:
package net.sf.tetrisai.game

import net.sf.tetrisai.settings.GameSettings
import net.sf.tetrisai.board.Board
import net.sf.tetrisai.boardevaluator.BoardEvaluator
import net.sf.tetrisai.piecegenerator.PieceGenerator
import net.sf.tetrisai.move.EvaluatedMoves

class Game(board: Board, boardEvaluator: BoardEvaluator, pieceGenerator: PieceGenerator, settings: GameSettings) {
  var position = new Position(board, pieceGenerator.nextPiece, settings)
  var linesCleared = 0

  def play(maxMoves: Long) {
    var moves: Long = 0
    var evaluatedMoves = new EvaluatedMoves(position, board, boardEvaluator)

    while (moves < maxMoves && !evaluatedMoves.isEmpty) {
      val bestMove = evaluatedMoves reduceLeft { _ min _ }
      position.movePiece(bestMove)
      linesCleared += position.setPiece().linesCleared
      moves += 1
      position = new Position(board, pieceGenerator.nextPiece, settings)
      evaluatedMoves = new EvaluatedMoves(position, board, boardEvaluator)
    }
  }
}
Det jag tror och hoppas är att jag hittat en tydlig uppdelning av ansvar för de klasser som är inblandade i att spela Tetris. Här följer en beskrivning av de klasser som används av Game, med respektive test angivet först:

För att hitta rätt värderingar av de olika alternativen att lägga en bit, behöver jag jämföra med C++ versionen:


EvaluatedMovesTest:
package net.sf.tetrisai.move

import org.junit.Test
import net.sf.tetrisai.BaseTest
import net.sf.tetrisai.boardevaluator.JoakimTengstrandBoardEvaluator
import net.sf.tetrisai.settings.DefaultGameSettings
import net.sf.tetrisai.game.Position
import net.sf.tetrisai.board.Board
import net.sf.tetrisai.piece.{PieceS}

class EvaluatedMovesTest extends BaseTest {

  @Test def evaluatedMoves {
    val board = Board()
    val position = new Position(board, new PieceS, new DefaultGameSettings)
    val boardEvaluator = new JoakimTengstrandBoardEvaluator(board.width, board.height)
    val evaluatedMoves = new EvaluatedMoves(position, board, boardEvaluator)

    val moves = for {
      move <- evaluatedMoves.moves.sortBy{m => (m.equity)}
    } yield roundThreeDecimals(move)

    moves should be (List(
      new MoveEquity(0,7, 18, 0.000),
      new MoveEquity(1,0, 17, 0.660),
      new MoveEquity(0,0, 18, 2.291),
      new MoveEquity(0,5, 18, 3.040),
      new MoveEquity(1,8, 17, 3.437),
      new MoveEquity(0,2, 18, 3.468),
      new MoveEquity(0,3, 18, 3.546),
      new MoveEquity(0,1, 18, 3.854),
      new MoveEquity(0,4, 18, 3.955),
      new MoveEquity(0,6, 18, 4.727),
      new MoveEquity(1,2, 17, 6.931),
      new MoveEquity(1,6, 17, 7.017),
      new MoveEquity(1,4, 17, 7.144),
      new MoveEquity(1,5, 17, 7.902),
      new MoveEquity(1,7, 17, 8.127),
      new MoveEquity(1,3, 17, 8.925),
      new MoveEquity(1,1, 17, 10.717)))
  }

  private def roundThreeDecimals(move: MoveEquity) = {
    val equity = scala.math.round((move.equity - 12.43) * 1000) / 1000.0
    new MoveEquity(move.rotation, move.x, move.y, equity)
  }
}

EvaluatedMoves:
package net.sf.tetrisai.move

import net.sf.tetrisai.boardevaluator.BoardEvaluator
import net.sf.tetrisai.board.Board
import net.sf.tetrisai.game.Position

class EvaluatedMoves(position: Position, board: Board, boardEvaluator: BoardEvaluator) extends Traversable[MoveEquity] {
  val moves: List[MoveEquity] = evaluateValidMoves

  def foreach[U](f: MoveEquity => U) = {
    val iterator = moves.iterator
    while (iterator.hasNext) f(iterator.next)
  }

  /**
   * Evaluates the equity of all valid moves for a given position.
   */
  private def evaluateValidMoves: List[MoveEquity] = {
    for {
      move <- position.validMoves
    } yield new MoveEquity(move.rotation,move.x, move.y, evaluate(move))
  }

  private def evaluate(move: Move) = {
    position.movePiece(move)
    val clearedLines = position.setPiece
    val equity = boardEvaluator.evaluate(board)
    position.undoSetPiece(clearedLines)

    equity
  }
}

En bra egenskap med denna klass är att den helt döljer den interna listan moves och i stället erbjuder användaren av klassen möjlighet att stega elementen i listan genom att mixa in traitet Traversable och implementera metoden foreach. Klassen Game får då bl.a automatiskt tillgång till metoderna isEmpty (rad 17) och reduceLeft (rad 18) i Game.

Metoderna setPiece och undoSetPiece (tidigare clearPiece) har fått hantering av vilka rader som tagits bort. Detta för att kunna återställa positionen när man går igenom alla möjliga alternativ att lägga en bit, PositionTest:
...
  @Test def setPieceAndClearLine {
    val board = Board(Buffer(
      "#-----#",
      "#-----#",
      "#----x#",
      "#x-xxx#",
      "#######"))
    val piece: Piece = new PieceT
    val position = new Position(board, piece, settings)
    position.movePiece(Move(0,0,2))
    val clearedLines = position.setPiece()

    clearedLines.linesCleared should be (1)

    board should be (Board(Buffer(
      "#-----#",
      "#-----#",
      "#-----#",
      "#TTT-x#",
      "#######")))
  }

  @Test def undoSetPieceRestoreClearedLine {
    val board = Board(Buffer(
      "#-----#",
      "#-----#",
      "#-----#",
      "#TTT-O#",
      "#######"))
    val piece: Piece = new PieceT
    val position = new Position(board, piece, settings)
    position.movePiece(Move(0,0,2))
    val clearedLines = new ClearedLines(List(new ClearedLine(3, BoardLine("#LTSSZ#"))))
    position.undoSetPiece(clearedLines)

    board should be (Board(Buffer(
      "#-----#",
      "#-----#",
      "#----O#",
      "#L-SSZ#",
      "#######")))
  }

Position:
package net.sf.tetrisai.game

import net.sf.tetrisai.board.Board
import net.sf.tetrisai.settings.GameSettings
import net.sf.tetrisai.piece.Piece
import net.sf.tetrisai.move.{ValidMoves, Move}

/**
 * Responsible for moving and rotating current piece plus
 * setting and clearing that piece on the board.
 */
class Position(board: Board, val piece: Piece, settings: GameSettings) {
  val boardWidth = board.width
  val boardHeight = board.height
  var pieceMove = settings.pieceStartMove(board.width, piece)
  private val rotationDirection = settings.rotationDirection

  def setPiece(): ClearedLines = {
    piece.shape(pieceMove.rotation).dots.foreach(dot => board.set(dot + pieceMove, piece.number))
    board.clearLines(pieceMove.y, piece.height(pieceMove.rotation))
  }
  def undoSetPiece(clearedLines: ClearedLines) = {
    board.undoClearedLines(clearedLines)
    piece.shape(pieceMove.rotation).dots.foreach(dot => board.clear(dot + pieceMove))
  }
  def rotatePiece() = pieceMove = pieceMove.rotate(rotationDirection, piece.rotationModulus)
  def movePiece(move: Move) = pieceMove = move
  def validMoves = new ValidMoves(this).moves
  def isValidMove: Boolean = isValidMove(pieceMove)
  def isValidMove(move: Move): Boolean = {
    try {
      (piece.shape(move.rotation).dots.foldLeft(0) { (sum,dot) => sum + board.get(dot + move) }) == 0
    } catch {
      case e: IndexOutOfBoundsException => false
    }
  }
}

Metoden clearLines i Board har jag skrivit om tidigare och kommer därför bara att nämna en detalj kring hanteringen att lägga till rader till ClearedLines, Board:

package net.sf.tetrisai.board

import net.sf.tetrisai.Point
import scala.collection.mutable.Buffer
import net.sf.tetrisai.game.{ClearedLine, ClearedLines}

object Board {
  def apply(width: Int = 10, height: Int = 20) = {
    val boardLines = Buffer.fill(height) { BoardLine.emptyLine(width) }
    new Board(width, height, boardLines)
  }

  def apply(lines: Buffer[String]) = {
    val width = lines(0).length - 2
    val height = lines.length - 1

    require(lines(height) == bottomTextLine(width))

    val boardLines = Buffer.tabulate(height) (
      ((y) => (BoardLine(lines(y))))
    )
    new Board(width, height, boardLines)
  }

  def bottomTextLine(width: Int) = "#" * (width + 2)
}

/**
 * Represents the game board.
 * Standard size is 10x20 (width x height).
 */
class Board(
    val width: Int,
    val height: Int,
    private val lines: Buffer[BoardLine]) {
  require(height >= 4)

  def get(dot: Point) = lines(dot.y).get(dot.x)
  def set(dot: Point, number: Byte) = lines(dot.y).set(dot.x, number)
  def clear(dot: Point) = lines(dot.y).clear(dot.x)
  def emptyLine = BoardLine.emptyLine(width)
  def completedLine = BoardLine.completedLine(width)
  def bottomTextLine = Board.bottomTextLine(width)
  def countDots = lines.foldLeft(0) { (sum,line) => sum + line.countDots }

  def isFree(x: Int, y: Int) = {
    try {
      lines(y).get(x) == 0
    } catch {
      case e: IndexOutOfBoundsException => false
    }
  }

  def undoClearedLines(clearedLines: ClearedLines): Board = {
    clearedLines.lines.reverse.foreach(insertLine(_))
    this
  }

  private def insertLine(clearedLine: ClearedLine) {
    lines.remove(0)
    lines.insert(clearedLine.y, clearedLine.boardLine)
  }

  /**
   * Clears completed lines and returns which lines that was cleared.
   * This method should be called after a piece has been dropped on the board.
   *   pieceY: the y position of the piece.
   *   pieceHeight: height of the piece.
   */
  def clearLines(pieceY: Int, pieceHeight: Int): ClearedLines = {
    var clearedLines = ClearedLines()
    var y1 = pieceY + pieceHeight;

    // Find first line to clear
    do {
      y1 -= 1
      if (lines(y1).isComplete)
        clearedLines += (y1, lines(y1).copy)
    } while (clearedLines.linesCleared == 0 && y1 > pieceY)

    // Clear lines
    if (clearedLines.linesCleared > 0) {
      var y2 = y1;

      while (y1 >= 0) {
        y2 -= 1
        while (y2 >= pieceY && lines(y2).isComplete) {
          clearedLines += (y2, lines(y2).copy)
          y2 -= 1
        }
        if (y2 >= 0)
          lines(y1) = lines(y2)
        else
          lines(y1) = emptyLine

        y1 -= 1;
      }
    }
    clearedLines;
  }

  override def equals(that: Any) = that match {
    case other: Board => lines.toList == other.lines.toList
    case _ => false
  }

  override def toString = lines.map { _.toString }.mkString("\n") + "\n" + bottomTextLine
}

Rad 78 och 88 lägger till en rad till listan clearedLines. Det finns två fiffiga saker med hur detta är gjort. Det första är att jag använt mig av något som heter Tuples. Det är en konstruktion i Scala där man kan paketera ihop två eller fler värden genom att omringa dem med en parantes. Det andra är att jag implementerat operatorn + (plus) i ClearedLines genom att just ta en Tupel som inargument:
package net.sf.tetrisai.game

import net.sf.tetrisai.board.BoardLine

object ClearedLines {
  def apply() = new ClearedLines(List[ClearedLine]())
}

case class ClearedLine(val y: Int, val boardLine: BoardLine)

class ClearedLines(val lines: List[ClearedLine]) {
  def +(y: Int, boardLine: BoardLine) = new ClearedLines(new ClearedLine(y, boardLine) :: lines)

  val linesCleared = lines.size

  override def equals(that: Any) = that match {
    case other: ClearedLines => lines == other.lines
    case _ => false
  }

  override def toString = lines.toString
}

Klassen innehåller en inner case klass ClearedLine. Att jag valt att göra den som en case klass beror på att jag vill dra nytta av att case klasser automatiskt genererar bl.a metoden equals som jag behöver då jag jämför instanser av ClearedLine med varandra.

Det finns en sak till som kan vara värt att nämna. Scala (och Java) har ingen hantering av unsignade heltal. Slumptalsgeneratorn i C++ versionen använder sig just av detta, Random.h:
#ifndef __random__
#define __random__

class Random
{
 private:
  unsigned int seed;

 public:
  Random();
  Random(unsigned int seed);
  ~Random();
  void setSeed(int seed) { this->seed = seed; }
  unsigned int getRandom();
  int getRandomP();
};

#endif

Random.cpp:
#include "Random.h"

Random::Random()
{
 this->seed = 0;
}

Random::Random(unsigned int seed)
{
 this->seed = seed;
}

Random::~Random()
{
}

unsigned int Random::getRandom()
{
 seed *= (unsigned int)(1664525);
 seed += (unsigned int)(1013904223);

 return seed;
}

int Random::getRandomP()
{
 unsigned int seed = getRandom();

 return seed % 7 + 1;
}

För att emulera detta beteende har jag i Scala behövt utföra operationerna med datatypen Long och sedan maska av de 32 minst signifikanta bitarna:
package net.sf.tetrisai.piecegenerator

object DefaultPieceGenerator {
  val BitMask = 0x00000000FFFFFFFFL
}

class DefaultPieceGenerator(var seed: Long = 0) extends PieceGenerator {

  def nextPieceNumber = {
    seed = (seed * 1664525) & DefaultPieceGenerator.BitMask
    seed = (seed + 1013904223) & DefaultPieceGenerator.BitMask

    modulus7 + 1
  }

  private def modulus7 = {
    val div: Long = seed / 7;
    (seed - div * 7).toInt
  }
}

Scala (och Java) kan inte utföra modulus (%) operatorn på datatypen Long, utan vill ha en Integer. Jag skulle ha velat skriva (seed.toInt % 7) + 1 på rad 13 men detta ger inte samma resultat som metoden modulus7.

Det var allt denna gång!

Inga kommentarer:

Skicka en kommentar