Summa sidvisningar

tisdag 22 februari 2011

Algoritmen

Detta inlägg kommer i huvudsak ta upp två saker. Först tänkte jag börja med att skryta om att jag skrivit världens bästa Tetris-algoritm! Därefter kommer en genomgång av hur algoritmen fungerar.

Världens bästa Tetris AI
Colin Fahey är vad jag vet den ende som systematiskt samlat algoritmer som spelar Tetris och genom att ha standardiserade regler också kunnat jämföra algoritmerna sinsemellan. Under sektion 10. Best one-piece Tetris-playing algorithm in the world beskrivs en algoritm som är skriven av fransmannen Pierre Dellacherie och som officiellt är den bästa kända altoritmen. Den gör ca 675.000 bitar i snitt per spel i en hastighet av 160 bitar per sekund på en 800 Mhz dator. Algoritmen som detta projekt (TetrisAi) använder sig av är något vassare och gör 1.800.000 bitar i snitt per spel i en hastighet av 15.000 bitar per sekund (C++ versionen) på en 2.8 Ghz Pentium. Den gör alltså nästan tre gånger så många bitar per spel och är ca 25 gånger så snabb om man tar hänsyn till skillnad i klockfrekvens. Har under de senaste tio åren inte hittat någon algoritm som gör fler bitar per spel (d.v.s spelar bättre) men dock en som är snabbare och det är HP's Tetris som enligt hans egna uppgifter gör 24.000 bitar per sekund på en 2.2 Ghz PC.

Hur algoritmen fungerar
När programmet TetrisAi ska hitta det bästa sättet att placera aktuell bit, går den igenom alla giltiga sätt att placera biten. För varje giltigt drag/placering rensas eventuellt kompletta/fulla rader varefter positionen är redo att värderas av algoritmen. Om utöver aktuell bit även nästa bit är känd och/eller sökdjupet (level) är större än antalet kända bitar, är algoritmen för vilka positioner som ska värderas något mer komplicerad vilket jag kommer beskriva då Scala-versionen får stöd för detta. Den position som har den lägsta värderingen ses av algoritmen som det bästa draget för aktuell bit. Antag att vi har följande position:


Siffrorna i vänsterkant visar y-värdet och siffrorna i underkant visar x-värdet. Som algoritmen ser ut nu, och som den sett ut sedan C++ versionen skrevs någon gång mellan 2001-2002, är att den värderar positionen utifrån tre egenskaper som den adderar ihop för att få fram den slutliga värderingen. Värdet på positionen kallas i algoritmen för equity och är en benämning tagen från Backgammon där den används som beteckning på värdet av en position. Det finns för övrigt ganska många likheter mellan Backgammon och Tetris t ex att man inte vet vilka bitar i Tetris eller tärningsslag i Backgammon som ska komma. Följande tre egenskaper mäts:

Konturens höjd
Denna egenskap mäter hur högt bygget är eller rättare sagt hur hög konturen är för respektive kolumn (x-värde):

 
Ju högre konturen är, desto större värde adderas till equity. Värdet 2,5 adderas för kolumnerna 1, 5 och 6, värdet 2,2 för kolumnerna 2, 4 och 7, värdet 1,8 för kolumnerna 0 och 3 och slutligen värdet 1,3 för kolumnerna 8 och 9. Totalt adderas 2,5*3 + 2,2*3 + 1,8*2 + 1,3*2 = 20.3 till equity. Detta exempel har en väldigt låg höjd med en storlek på 10x5 mot normalt 10x20. Hade spelplanen varit normalstor hade värdet blivit 0.1*10 = 1 i stället för 20.3, och då varit den egenskap (av tre) som påverkat värderingen av positionen minst.

Konturens struktur
Nästa egenskap som mäts och adderas till equity är avgränsade områden i konturen. Ett sådant område är avskilt med väggar på dess båda sidor. När spelet börjar är detta avgränsade område 10x20. Det minsta möjliga området är 1x1 likt C i bilden nedan. Ju högre området är desto större värde adderas till equity. Ju bredare desto lägre värde adderas till equity med undantag för bredden 3 som har en större konstant än bredden 2 och anses alltså vara sämre.


För att beräkna värdet av ett område multipliceras en konstant som varierar med bredden med en annan konstant som varierar med höjden. Område A har bredden 1 vilket ger konstanten 4,25 och höjden två som ger konstanten 1,19 och produkten 5,0575. Område B har bredden 3 som ger konstanten 3.1 och höjden 1 som ger konstanten 0,42 och produkten 1,302.

Det finns ytterligare en parameter att ta hänsyn till i denna beräkning och det är huruvida väggarna på respektive sida om området har samma höjd. Område B har samma höjd (y=2) på de väggar som avgränsar området (kolumnerna 1 och 5) till skillnad från område D där höjden på väggarna skiljer sig (kolumn 6 har y=2 och kolumn 10 har y=0). Område B anses vara något gynsammare än D:
  • A: 1x2 = 4,25*1,19 = 5.0575
  • B: 3x1 = 3,1*0,42 = 1,302
  • C: 1x1 = 4,25*0,42 = 1,785
  • D: 3x1 = 3,1*0,5 = 1,55
  • E: 2x2 = 2,39*1,19 = 2,8441
Lägger man ihop dessa fem värden får man 12,5386 som adderas till equity.

Det finns en felaktighet i hur områden som angränsar mot den högra väggen (kolumn 10) beräknas i C++ versionen (version 1.0). Där sätts y-värdet för kolumn 10 till 2 (lägsta y-värde på konturen) varpå område D uppfattas som ett område vars omslutande väggar har samma höjd och får därför samma värdering som område B. Detta är åtgärdat i aktuell version av algoritmen: TengstrandBoardEvaluator1 (version 1.1) och verifierat med testet TengstrandBoardEvaluator1Test.

Håligheter
Denna egenskap mäter håligheter i positionen. Till håligheter räknas rader där det finns hål som täcks av någon ovanliggande rad. I bilden nedan är rad 3 täckt av rad 2 i kolumn 6.

Konstanterna i högerkant varierar beroende på antalet fyllda rutor på raden. Tre fyllda rutor (rad 2) ger konstanten 0,1, fem fyllda rutor (rad 3) ger konstanten 0,3 och sju fyllda rutor (rad 4) ger konstanten 0,5.


För varje rad med början på den högsta konturen (y=2) t.o.m sista raden (y=4) görs en sak till förutom att beräkna nyss nämnda konstant. För varje kolumn i raden görs följande: i fallet att en ruta är tom kontrolleras det om rutan är täckt genom att se i fall konturen för kolumnen är mindre än y-värdet för raden vilket i så fall betyder att vi hittat ett hål. Därefter beräknas värdet som ska adderas till equity genom att multiplicera alla konstanter från och med raden som motsvarar konturen för den kolumn där hålet hittats till och med aktuell rad. Värdet 10 i beräkningen är positionens bredd:
  • Rad 3: (1 - 0.1*0.3)*10 = 9,7
  • Rad 4: (1 - 0,3*0,5)*10 = 8,5
Rad 3 multiplicerar med konstanterna för rad 2 t.o.m 3 (2 då kolumn 6 är täckt av rad 2).
Rad 4 multipliceras med konstanterna för rad 3 t.o.m 4 (3 då kolumn 4 är täckt av rad 3).

Totalt motsvarar egenskapen håligheter värdet 9,7+8,5 = 18,2 som equity räknas upp med.

Equity för respektive egenskap:
  • Konturens höjd: 20,3
  • Konturens struktur: 12,5386
  • Håligheter: 18,2
Vilket ger en total equity för positionen på: 51,0386

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!

torsdag 20 januari 2011

Är det fult så är det fel!

Vill bara börja med att redogöra lite för min viktigaste och trognaste designprincip som jag lutat mig mot ett antal år. Den lyder: Är det fult så är det fel! Den är mycket bra att använda sig av när man funderar över huruvida man behöver skriva om sin (eller annans) kod eller inte. Om svaret på frågan är det fult är ja mår åtminstone delar av koden bra av att skrivas om.

Ett designbeslut kan kännas okej när man gör det, men hundra rader kod senare upptäcker man att det plötsligt ser fult ut. I mitt förra gjorde jag ett designbeslut att låta klassen Position hantera placering av Piece men inte hantera rotationsläget av den samma (det var gjort så i Java-versionen). När jag lade till hantering av den nya klassen Move till Position blev problemen tydliga. Move hanterar både x,y och rotation och det finns stöd för att sätta en Move direkt i Position via metoden movePiece(move: Move). Att det samtidigt gick att rotera en Piece via en instans utanför Positions kontroll ledde till extrakod som dessutom inte var helt uppenbar varför den behövdes när man tittade på den.

Det blev helt enkelt fult och är det fult så är det fel. Jag hade i och för sig haft en känsla hela tiden att det kanske inte var så bra att låta Piece hantera sin egen rotation, men valde ändå att testa detta då det verkade bli en enklare hantering (vilket det också var till en början). Fick det även påpekat av en kollega som ställde sig undrande till min design! Problemet löste sig genom att låta Position ha hand om både bitens placering och rotationsläge. Ett annat resultat av manövern blev att koden från PieceType fick flytta in till Piece och att PieceType kunde tas bort. Position ser nu ut så här:
package net.sf.tetrisai

import move.Move
import piece.Piece
import settings.GameSettings

class Position(board: Board, val piece: Piece, settings: GameSettings) {
  val boardWidth = board.width
  val boardHeight = board.height
  var pieceMove = settings.pieceStartMove(board.width, piece)
  val rotationDirection = settings.rotationDirection

  def setPiece() = piece.shape(pieceMove.rotation).dots.foreach(dot => board.set(dot + pieceMove, piece.number))
  def clearPiece() = 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 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
    }
  }
}

I metoden isValidMove på rad 18 låter jag språket självt hålla reda på om biten hamnat utanför brädets kanter genom att låta den fånga IndexOutOfBoundsException. Detta är både en presntandaoptimering och tar samtidigt bort en hel massa boundary-checks som jag annars skulle behövt lägga till.

Klassen Move ser ut så här:
package net.sf.tetrisai.move

import net.sf.tetrisai.piece.Point

object Move {
  def apply(y: Int, x: Int, rotation: Int) = new Move(y, x, rotation)
}

class Move(y: Int, x: Int, val rotation: Int) extends Point(x,y) with Ordered[Move] {
  def down = new Move(y + 1, x, rotation)
  def left = new Move(y, x - 1, rotation)
  def right = new Move(y, x + 1, rotation)
  def rotate(rotationType: RotationDirection, rotationModulus: Int) = new Move(y, x, (rotation + rotationType.direction) & rotationModulus)

  def compare(that: Move): Int = {
    if (y != that.y)
      y compare that.y
    else
      if (x != that.x)
        x compare that.x
      else
        rotation compare that.rotation
  }

  override def equals(that: Any) = that match {
    case other: Move => y == other.y && x == other.x && rotation == other.rotation
    case _ => false
  }

  override def toString = "Move(" + y + "," + x + "," + rotation + ")"
}

Det som kan vara värt att nämna här är att jag ärver från klassen Point och att jag gjort klassen sorterbar (mitt test vill ha en förutsägbar ordning på listor av Move). Det är en väldigt elegant syntax när man ska delegera vidare konstruktorns argument till den ärvda klassen (rad 9): extends Point(x,y). En ny instansvariabel position (rad 9) har också tillkommit vilket markeras med nyckelordet val (immutable value).

En annan detalj som kan se konstigt ut för en Javaprogrammerare är syntaxen på rad 17 y compare that.y. Detta tolkas av Scala som y.compare(that.y) och det är en smaksak vilket man väljer!

Vän av ordning undrar nu säkert varför Move ärver från Point. Anledningen är att Move ibland ska hanteras som en Point vilket görs av metoderna setPiece, clearPiece och isValidMove i klassen Position där vi adderar en Point med en Move (rad 13, 14 och 20).

För att stödja olika rotationsriktningar (med- och moturs) i metoden rotate (rad 13 i klassen Move) har jag lagt till den abstrakta klassen RotationDirection:
package net.sf.tetrisai.move

abstract class RotationDirection {
  val direction: Int
}

Själva klassen är markerad som abstrakt och attributet direction är här inte satt vilket implicit gör den abstrakt. Mina två implementationer av denna klass måste därför implementera detta värde. ClockwiseRotation:
package net.sf.tetrisai.move

class ClockwiseRotation extends RotationDirection {
  val direction = 1
}
AnticlockwiseRotation:
package net.sf.tetrisai.move

class AnticlockwiseRotation extends RotationDirection {
  val direction = -1
}

Målet med detta projekt är att datorn själv ska kunna spela Tetris. Ett steg på vägen är att leta fram giltiga flytt för en viss position. Klassen ValidMoveCalculator hanterar detta, tillhörande test ser ut så här:
package net.sf.tetrisai.move

import org.junit.Test
import net.sf.tetrisai.settings.DefaultGameSettings
import net.sf.tetrisai.{Position, Board, BaseTest}
import net.sf.tetrisai.piece.PieceS

class ValidMoveCalculatorTest extends BaseTest {

  @Test def validMoves() {
    val board = Board(Array(
      "#-----#",
      "#-----#",
      "#--X--#",
      "#--X--#",
      "#######"))
    val position = new Position(board, new PieceS, new DefaultGameSettings)
    val validMoveCalculator = new ValidMoveCalculator(position)
    val moves = validMoveCalculator.validMoves.sorted[Move]

    moves should be (List(Move(0,1,0), Move(0,2,0), Move(0,2,1), Move(1,0,0), Move(1,0,1), Move(1,3,1)))
  }
}

Implementationen av ValidMoveCalculator:
package net.sf.tetrisai.move

import net.sf.tetrisai.Position

class ValidMoveCalculator(val position: Position) {
  var moves: List[Move] = Nil
  val visitedMoves = Array.fill(position.boardHeight, position.boardWidth) { 0 }

  private def markAsVisited(move: Move) { visitedMoves(move.y)(move.x) |= 1 << move.rotation }
  private def isUnvisitedMove(move: Move): Boolean = {
    try {
      (visitedMoves(move.y)(move.x) & (1 << move.rotation)) == 0
    } catch {
      case e: IndexOutOfBoundsException => false
    }
  }

  def validMoves = {
    moves = List.empty[Move]
    calculateValidMoves
    moves
  }

  private def calculateValidMoves {
    while (isUnvisitedMove(position.pieceMove) && position.isValidMove) {
      val move = position.pieceMove
      markAsVisited(move)
      if (position.isValidMove(move down)) {
        position.movePiece(move down)
        calculateValidMoves
      } else {
        moves = move :: moves
      }
      position.movePiece(move left)
      calculateValidMoves
      position.movePiece(move right)
      calculateValidMoves

      position.movePiece(move)
      position.rotatePiece
    }
  }
}

Hanteringen att ta fram giltiga flytt var i C++ versionen rätt hårt optimerad och
svårbegriplig. Här har jag i stället valt att göra den rekursiv. Då vi med flaggor i visitedMoves markerar vilka sökvägar som redan behandlats bör vi även får bra prestanda. Anledninge till att jag tjatar om bra prestanda är att finjusteringen av algoritmerna som väljer bästa plats för en bit kräver bra prestanda (återstår att implementera). Den låter datorn spela x antal miljoner bitar per parametersättning. Sedan görs en statistiks jämförelse mellan de olika parametersättningarna för att hitta bästa värde för respektive parameter.

fredag 7 januari 2011

Skapa Piece och Position

Jag var inte helt nöjd med hur klasserna såg ut i varken C++ eller Java-versionen. Nu har jag chansen att förbättra detta. Dags sätta igång och skriva koden för att sätta en bit på spelplanen. För att klara det behövs ett antal nya klasser och min tanke är att ha en klass Position som ansvarar för att sätta ut en Piece på våran Board. Den håller en referens till Piece med dess position och en Board som hanterar brädet och en GameSettings som hanterar olika sättningar, t ex bitens startposition.

Jag har brutit ut testkod till klassen BaseTest som nu alla test använder sig av:
package net.sf.tetrisai

import org.scalatest.junit.{JUnitSuite, ShouldMatchersForJUnit}

abstract class BaseTest extends JUnitSuite with ShouldMatchersForJUnit

Testen för Position, PositionTest, ser ut enligt följande:
package net.sf.tetrisai

import piece.{PieceT, Piece, PieceS}
import org.junit.{Before, Test}
import settings.{GameSettings, DefaultGameSettings}

class PositionTest extends BaseTest {
  var settings: GameSettings = null
  var board: Board = null
  var piece: Piece = null

  @Before def setUp() {
    settings = new DefaultGameSettings
  }

  def emptyPosition = {
    board = Board(5,4)
    piece = new Piece(new PieceS)
    new Position(board, piece, settings)
  }

  @Test def setPiece() {
    val position = emptyPosition
    position.setPiece()

    board should be (Board(Array(
      "#--xx-#",
      "#-xx--#",
      "#-----#",
      "#-----#",
      "#######")))
  }

  @Test def clearPiece() {
    val board = Board(Array(
      "#-xxx-#",
      "#--x--#",
      "#-----#",
      "#-----#",
      "#######"))
    val piece: Piece = new Piece(new PieceT)
    val position = new Position(board, piece, settings)
    position.clearPiece()

    board should be (Board(5,4))
  }

  @Test def rotatePieceOnce() {
    val position = emptyPosition
    piece.rotate()
    position.setPiece

    board should be (Board(Array(
      "#-x---#",
      "#-xx--#",
      "#--x--#",
      "#-----#",
      "#######")))
  }

  @Test def rotatePieceTwice() {
    val position = emptyPosition
    piece.rotate()
    piece.rotate()
    position.setPiece

    board should be (Board(Array(
      "#--xx-#",
      "#-xx--#",
      "#-----#",
      "#-----#",
      "#######")))
  }
}

Det man kan notera här är att jag bryter mot inkapsling av klassen Position då jag utanför klassen kan ändra i instanser av Piece och Board. För att hindra detta hade jag behövt göra Piece och Position immutable och t ex lägga till metoden rotatePiece till klassen Position. Detta är i nuläget ett val jag gjort och vi får se om det löper väl ut.

För att kunna jämföra mina instanser av Board i testet har jag implementerat metoden equals i både BoardLine (har tagit bort oväsentlig kod)...
class BoardLine(val line: Array[Byte]) {
  ...

  override def equals(that: Any) = that match {
    case other: BoardLine => line.toList == other.line.toList
    case _ => false
  }
}
...och Board:
class Board(
    val width: Int,
    height: Int,
    private val lines: Array[BoardLine]) {
  ...
 
  override def equals(that: Any) = that match {
    case other: Board => lines.toList == other.lines.toList
    case _ => false
  }

Här använder jag mig av Scalas mönstermatchning. Det är ett ganska smidigt sätt att slippa använda metoderna isInstanceOf och asInstanceOf. En annan sak att notera är att Scala fungerar på samma sätt som Java när man ska jämföra arrayer, t ex kommer man få false som svar om man jämför två olika instanser av en array trots att de har samma innehåll. Jag har valt att lagra mina data i Arrayer för att de är något effektivare än Listor. Då det endast är testen som behöver jämföra instanser gör det ingenting att vi här tvingas konvertera dessa till listor via metoden toList.

Vår nya klass Piece ser ut så här:
package net.sf.tetrisai.piece

object Piece {
  def apply(pieceType: PieceType) = new Piece(pieceType)

  def apply(index: Int) = {
    val pieceType = PieceType(index)
    new Piece(pieceType)
  }
}

class Piece(
     private val pieceType: PieceType,
     private var rotation: Int = 0,
     private val rotationDirection: Int = 1) {
  def height = pieceType.height(rotation)
  def dots = pieceType.shape(rotation).dots
  def rotate() = rotation = (rotation + rotationDirection) & pieceType.rotationModulus
}

Rad 6 ser till att man kan skapa en bit genom att skriva t ex Piece(1). Rad 4 stödjer att man instansierar en bit med syntaxen, t ex Piece(new PieceT). Rad 13-15 definierar både hur konstruktorn ser ut och vilka medlemsvariabler som lagras av klassen. Rad 14-15 använder sig av default-värden som har det goda med siga att man reducerar antalet konstruktorer som vi t ex i Java hade behövt skapa.

Piece lutar sig mycket mot PieceType:
package net.sf.tetrisai.piece

object PieceType {
  private val rotationModulus: Array[Int] = Array(0, 0, 1, 0, 3)
  private val pieces: Array[PieceType] = Array(
    new PieceI, new PieceZ, new PieceS, new PieceJ, new PieceL, new PieceT, new PieceO
  )

  def apply(index: Int): PieceType = pieces(index)
}

abstract class PieceType {
  def index: Int
  def character: String
  def maxRotations = heights.length
  def rotationModulus = PieceType.rotationModulus(maxRotations)
  def height(rotation: Int) = heights(rotation)
  def shape(rotation: Int) = shapes(rotation)
  protected def heights: Array[Int]
  protected def shapes: Array[PieceShape]
}

Klassen PieceType är en abstrakt klass som kan definierar en viss typ av bit. Varje typ av bit finns sedan i sin egen konkreta klass t ex PieceS...
package net.sf.tetrisai.piece

class PieceS extends PieceType {
  val index = 2
  val character = "S"
  protected val heights = Array(2, 3)
  protected val shapes = Array(
    new PieceShape(Array(Point(1,0), Point(2,0), Point(0,1), Point(1,1))),
    new PieceShape(Array(Point(0,0), Point(0,1), Point(1,1), Point(1,2)))
  )
}

...eller PieceJ:
package net.sf.tetrisai.piece

class PieceJ extends PieceType {
  val index = 3
  val character = "J"
  protected val heights = Array(2, 3, 2, 3)
  protected val shapes = Array(
    new PieceShape(Array(Point(0,0), Point(1,0), Point(2,0), Point(2,1))),
    new PieceShape(Array(Point(0,0), Point(1,1), Point(0,1), Point(0,2))),
    new PieceShape(Array(Point(0,0), Point(0,1), Point(1,1), Point(2,1))),
    new PieceShape(Array(Point(1,0), Point(1,1), Point(0,2), Point(1,2)))
  )
}

PieceShape kapslar in de fyra "dot":s som en bit i ett visst rotationsläge består av:
package net.sf.tetrisai.piece

class PieceShape(val dots: Array[Point]) {
}

Övriga bitar och klasser relaterade till Piece finns i paketet piece.

Klassen Position ser ut så här:
package net.sf.tetrisai

import piece.{Point, Piece}
import settings.GameSettings

class Position(board: Board, piece: Piece, settings: GameSettings) {
  var piecePosition: Point = settings.pieceStartPosition(board.width)

  def setPiece() = piece.dots.foreach(dot => board.set(dot + piecePosition))
  def clearPiece() = piece.dots.foreach(dot => board.clear(dot + piecePosition))
}

En rolig detalj här är att jag lagt till operatorn plus (+) till klassen Point för att kunna placera biten i metoderna setPiece() och clearPiece():
package net.sf.tetrisai.piece

object Point {
  def apply(x: Int, y: Int) = new Point(x, y)
}

class Point(val x: Int, val y: Int) {
  def +(that: Point): Point = new Point(x + that.x, y + that.y)

  override def equals(that: Any) = that match {
    case other: Point => x == other.x && y == other.y
    case _ => false
  }
}

Nu går det att flytta, rotera och ta bort en bit från en Board, en bra början!

söndag 2 januari 2011

Implementera clearLines()

C++ versionen TetrisAnalyzer var ganska hårt prestandaoptimerad. Då jag är mån om att även Scala-versionen ska ha bra prestanda kommer jag att behålla en del av dessa prestandaoptimeringar. Vissa av optimeringarna kommer jag att avvakta med och införa vid ett senare tillfälle. Metoden clearLines i klassen Board såg i C++ versionen ut så här:
int Board::clearLines(int ymin, int pieceHeight)
{
 int y1;
 int y2;
 int clearedLines = 0;

 y1 = ymin + pieceHeight;

 while (--y1 >= ymin)
 {
  if (countLine(y1) == width)
  {
   clearedLines++;
   break;
  }
 }
   
 if (clearedLines == 0)
  return 0;   // No rows to clear

 y2 = y1;

 while (y1 >= 0)
 {
  while (--y2 >= ymin)
   if (countLine(y2) == width)
    clearedLines++;
   else
    break;
    
  if (y2 >= 0)
   copyLine(y2, y1);
  else
   clearLine(y1);

  y1--;
 }
 
 return clearedLines;
}

void Board::copyLine(int from_y, int to_y)
{
 char *board1 = board + from_y * width;
 char *board2 = board + to_y * width;

 for (int x=0; x<width; x++)
  board2[x] = board1[x];
}

void Board::clearLine(int y)
{
 char *line = board + y*width;
 
 for (int x=0; x<width; x++)
  line[x] = 0;
}

int Board::countLine(int y)
{
 int cnt = 0;
 char *line = board + y*width;
 
 for (int x=0; x<width; x++)
  if (line[x] != 0)
   cnt++;
   
 return cnt;
}

En sak man kan notera är att jag för tio år sedan valde att deklarera variabler i början av metoder (rad 3-5) och inte vid dess första användning! En annan detalj är att då vi infört klassen BoardLine slipper vi hantering av enskilda rader i klassen Board (metoderna copyLine, clearLine och countLine).

Metoden clearLines är något lång och lätt kryptisk men har fördelen att den har bra prestanda. Väljer därför kopiera den i stort sett rakt av, så här ser nu våran Board ut:
package net.sf.tetrisai

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

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

    val boardLines: Array[BoardLine] = Array.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(width: Int, height: Int, lines: Array[BoardLine]) {
  require(height >= 4)

  def emptyLine() = BoardLine.emptyLine(width)
  def bottomTextLine() = Board.bottomTextLine(width)
  def asText() = lines.map { _.asText }.mkString("\n") + "\n" + bottomTextLine

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

    do {
      y1 -= 1
      if (lines(y1).isComplete)
        clearedLines += 1
    } while (clearedLines == 0 && y1 > minY)

    if (clearedLines > 0) {
      var y2 = y1;

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

        y1 -= 1;
      }
    }
    clearedLines;
  }
}

Här har metoderna emptyLine och clearLines tillkommit. I C++ versionen används break på rad 14 och 29. Scala har inte stöd för break och jag blev då tvungen att skriva om koden lite som på köpet blev något mer läsbar.

I objektet BoardLine har metoden emptyLine tillkommit som använder sig av metoden fill som fyller en array med i detta fallet värdet 0:
def emptyLine(width: Int) = new BoardLine(Array.fill(width){ 0 })

I klassen BoardLine har metoden isComplete tillkommit:

def isComplete() = line.sum == line.length

Testet ser ut så här:
@Test def clearLines() {
    val board = Board(Array(
      "#----------#",
      "#----x-----#",
      "#xxxxxxxxxx#",
      "#xxxxxxxxxx#",
      "#-x--x----x#",
      "#xxxxxxxxxx#",
      "############"))

    board.clearLines(2, 4) should be (3)

    board.asText should be (Array(
      "#----------#",
      "#----------#",
      "#----------#",
      "#----------#",
      "#----x-----#",
      "#-x--x----x#",
      "############").mkString("\n"))
  }

Jag ville egentligen implementera metoden equals på klassen Board för att slippa köra asText och mkString men hade problem att komma åt attributet lines på inkommande argument i equals och väljer därför denna lösning tills vidare.

fredag 31 december 2010

Dela upp klassen Board

Vill börja med en liten rättning från föregående inlägg. Där skrev jag att this metoder i sin första sats måste anropa huvudkonstruktorn, det får den göra, men den kan lika gärna anropa en annan this medod!

När jag tittade på resultatet av klassen Board inser jag att den har väldigt mycket hantering av enskilda rader i det normalt 10x20 stora griden. Hanteringen kommer att utökas ytterligare när vi inför metoden clearLines. Inser att det skulle vara mycket bättre att skapa en egen klass av varje rad för att kapsla in den hanteringen och samtidigt få klassen Board mer läsbar.

Gör samtidigt en annan förändring då jag byter ut metoden print mot asText. Metoden print har sidoeffekter vilket ibland kan vara nödvändigt men i det här fallet är det bättre att returnera en sträng. Det har också den fördelen att vi kan testa metoden! Vårat test av klassen BoardLine ser ut enligt följande:

package net.sf.tetrisai

import org.junit.Test
import org.scalatest.junit.{JUnitSuite, ShouldMatchersForJUnit, AssertionsForJUnit}

class BoardLineTest extends JUnitSuite with ShouldMatchersForJUnit {

  @Test def asText() {
    val boardLine = BoardLine("#---x-x-x--#")

    boardLine.asText should be ("#---x-x-x--#")
  }
}

Syntaxen should be är i mitt tycke ett bra exempel på hur man kan skriva läsbar kod i Scala. Dock är det mycket magi när man kommer som ny Javautvecklare. Bra inlägg här som beskriver magin bakom denna syntax.

Vår nya klass BoardLine blev så här:
package net.sf.tetrisai

object BoardLine {
  val Empty: Byte = 0
  val Occupied: Byte = 1
  val Characters = Array("-", "x")

  def apply(textLine: String) = {
    val width = textLine.length - 2

    val line: Array[Byte] = Array.tabulate(width) (
        ((x) => (if (textLine(x+1) == 'x') Occupied else Empty))
    )
    new BoardLine(line)
  }
}

/**
 * Represents a row in the game board.
 */
class BoardLine(line: Array[Byte]) {
  require(line.length >= 4)

  def asText() = "#" + (line.foldLeft("") { (text, n) => text + BoardLine.Characters(n) }) + "#"
}

Rad 24 kräver en förklaring. Det denna kodrad gör är att loopa arrayen line som består av Bytes (som troligen kompileras till vanliga 8-bitarstal av Scala-kompilatorn) och gör om värdena till "-" eller"x" plus lägger till "#" i början och slutet. Här har jag hittat den fiffiga metoden foldLeft där man ger ett startvärde som första argument ("" i vårat fall) följt av "(text, n)" där text representerar resultatet (med "" som ingångsvärde) och n representerar aktuellt värde i arrayen för index 0 till och med 9 vilket är värdena line(0), line(1)...line(9). Från arrayen [0,0,0,1,0,1,0,1,0,0] beräknas resultatet "#---x-x-x--#".

Dags att skriva testet för klassen Board där vi dels testar att brädet inte är mindre än 4x4 och dels testar metoden asText:
package net.sf.tetrisai

import org.junit.Test
import org.scalatest.junit.{JUnitSuite, ShouldMatchersForJUnit, AssertionsForJUnit}

class BoardTest extends JUnitSuite with ShouldMatchersForJUnit {

  @Test def tooLow() {
    evaluating {
      Board(Array(
        "#----------#",
        "#----x-----#",
        "#-x--x----x#",
        "############"))
    } should produce[IllegalArgumentException]
  }

  @Test def tooNarrow() {
    evaluating {
      Board(Array(
        "#---#",
        "#---#",
        "#---#",
        "#-x-#",
        "#####"))
    } should produce[IllegalArgumentException]
  }

  @Test def asText() {
    val boardArray = Array(
      "#----------#",
      "#----------#",
      "#----x-----#",
      "#-x--x----x#",
      "############")

    val board = Board(boardArray)

    board.asText should be (boardArray.mkString("\n"))
  }
}

De första två testerna tooLow och tooNarrow använder sig av konstruktionen evaluating { ... } should produce[Exception] som förklaras här. Konstruktionen är rätt självförklarande men går i kort ut på att vi kan specificera vilken exception som vi förväntar oss i det omslutande kodblocket.

För att kunna använda formatet shuld be i testet asText måste vi ärva från ShouldMatchersForJUnit. Metoden mkString på rad 39 fungerar så att den skapar en ny sträng genom att lägga in radmatning ("\n") mellan alla element i boardArray.

Efter att ha refaktorerat ut kod till BoardLine ser våran Board ut så här:
package net.sf.tetrisai

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

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

    val boardLines: Array[BoardLine] = Array.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(width: Int, height: Int, lines: Array[BoardLine]) {
  require(height >= 4)

  def bottomTextLine = Board.bottomTextLine(width)
  def asText() = lines.map { _.asText }.mkString("\n") + "\n" + bottomTextLine
}

Det stora som skett här är att klassen nu lagrar en array med BoardLine i stället för array med array av Byte. Metoden asText på rad 27 använder sig av metoden map. Den fungerar i vårat fall så att den skapar en ny array utifrån arrayen lines där den aplicerar metoden asText på varje element.

söndag 26 december 2010

En första version av klassen Board

Mina förberedelser innan jag satte igång att koda Scala var att jag införskaffade boken Programming in Scala som nu även finns i en 2nd edition (som PDF, boken kommer i början av 2011) plus att jag läst lite bloggar då och då i ett par månaders tid. Jag hann läsa de första två hundra sidorna innan jag insåg att det var dags att praktisera. Bra ändå att skaffa sig en första överblick av språket. Scala är onekligen ett kraftfullt språk och detta är bara början på en spännande resa. Steget från Java till Scala känns ungefär lika stort som att gå från C till Java!

Till att börja med behövde jag en utvecklingsmiljö och då föll valet på IntelliJ IDEA som nu kommit i version 10 och som enligt rykte ska vara den bästa IDE:n för Scala för tillfället. Efter att ha installerat verktyget och pluginen för Scala var det dags att börja skriva min första klass. Som den testdrivne utvecklare jag försöker vara tänker jag använda mig av ScalaTest som jag hört ska vara bra. Programmet som ska "konverteras" hette i C++ versionen TetrisAnalyzer som skrevs mellan åren 2001 och 2002 och dels en halvfärdig omskrivning till Java som jag lagt på SourceForge med namnet TetrisAi där numera även Scala-version ligger. Då Java-versionen använde sig av Maven väljer jag här att fortsätta med det och hoppas att det är så man gör i Scala-värden!

Bestämmer mig för att börja med att skriva testet för klassen Board. Då jag redan har en halvfärdig version i Java kan jag utgå från den koden som ser ut enligt följande:

package net.sf.tetrisai;

import static org.junit.Assert.assertEquals;

import org.junit.Test;

public class BoardTest {

    @Test
    public void clearLines() {
        Board board = new Board(
            "#----------#",
            "#----x-----#",
            "#xxxxxxxxxx#",
            "#-x--x----x#",
            "#xxxxxxxxxx#",
            "############");

        board.clearLines(1, 4);
  
        Board expectedBoard = new Board(
            "#----------#",
            "#----------#",
            "#----------#",
            "#----x-----#",
            "#-x--x----x#",
            "############");

        assertEquals(expectedBoard, board);
    }
}

Innan jag börjar med att implementera metoden clearLines vill jag få ordning på den interna lagringen av min Board. Tänker därför tills vidare köra metoden print från mitt test för att se att den skriver ut samma possition som jag skickar in. Testet av metoden clearLines fortsätter jag med vid nästa blogginlägg!

Så här ser det halvfärdiga testet ut som just nu saknar en assert:

package net.sf.tetrisai

import org.scalatest.junit.AssertionsForJUnit
import org.junit.Test

class BoardTest extends AssertionsForJUnit {

  @Test def clearLines() {
    val board = Board(Array(
      "#----------#",
      "#----x-----#",
      "#xxxxxxxxxx#",
      "#-x--x----x#",
      "#xxxxxxxxxx#",
      "############")
    )

    board.print()
  }
}

I Java-koden kunde man skicka in en array direkt i konstruktorn, men vad jag har sett hittils måste man i Scala ange att det är en array (rad 9). Om någon läsare av denna blogg har en bättre lösning vill jag gärna att ni lägger in kommentarer!

Våran Java-version av klassen Board har två konstruktorer (se listning nedan), en som används av "den vanlig koden" (rad 10) och en som används av testerna (rad 20), fullständiga koden hittar du här:

...

public class Board {
    private int width;
    private int height;
    private int[][] grid;

    private static int DEFAULT_DOT = 1;

    public Board(int width, int height) {
        if (width < 4) {
            throw new IllegalStateException("Illegal size of board, minimum with is 4.");
        }

        this.width = width;
        this.height = height;
        grid = new int[height][width];
    }

    public Board(String... boardRows) {
        this(boardRows[0].length() - 2, boardRows.length - 1);

        for (int y = 0; y < height; y++) {
            String row = boardRows[y];

            for (int x = 1; x <= width; x++) {
                if (!row.substring(x, x + 1).equals("-")) {
                    grid[y][x - 1] = DEFAULT_DOT;
                }
            }
        }
    }

   ...
}
Vad jag märkte när jag skulle skriva om detta till Skala var att Scala hanterar konstruktorer annorlunda än t ex Java. Scala har en huvudkonstruktor. Om man vill ha fler konstruktorer får man lägga till this metoder som i sin första sats måste anropa huvudkonstruktorn, se Multiple Constructors. Jag testade göra på detta vis först men stötte då på problemet med att jag ville göra saker med de inkomna argumenten (en array med String i vårat fall) innan huvudkonstruktorn anropades. Lösningen är att utnyttja att varje klass i Scala även får ha en kompis (object) med samma namn som klassen och som hanterar all statisk information. Detta object lägger man i samma fil som den vanliga klassen och genom att definiera apply metoder (en i vårat fall) kan man köra dessa genom att bara ange klassens namn utan att använda sig av new syntaxen. Hade vi haft en this metod i klassen Board kunde vi ha skrivit (strippad version)
val board = new Board(Array("#----------#",...))
men nu kan vi skriva (strippad version):
val board = Board(Array("#----------#",...))
När jag implementerar Scala-version av Board blir resultatet detta, notera att sådant som i Java skulle ha varit static här ligger i våran object, raderna 3-26 och själva klassen definieras från och med rad 31:
package net.sf.tetrisai

object Board {
  val Empty: Byte = 0
  val Occupied: Byte = 1
  val Characters = Array("-", "x")

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

    require(lines(height) == ("#" * (width + 2)))

    val board: Array[Array[Byte]] = Array.tabulate(height, width) (
        ((y, x) => (getBoardValue(x, y, width, height, lines)))
    )
    new Board(width, height, board)
  }

  def getBoardValue(x: Int, y: Int, width: Int, height: Int, lines: Array[String]):Byte = {
    require(lines(y).length - 2 == width)

    if (lines(y)(x+1) == 'x') Occupied
    else Empty
  }
}

/**
 * Represents the game board, standard size is 10x20 (width x height).
 */
class Board(width: Int, height: Int, board: Array[Array[Byte]]) {
  require(width >= 4 && height >= 4)

  def print() {
    for (y <- 0 to height-1) {
      Predef.print("#")
      for (x <- 0 to width-1)
        Predef.print(Board.Characters(board(y)(x)))
      Predef.println("#")
    }
    Predef.println("#" * (width + 2))
  }
}
Värt att notera:
  • Konstanter placeras i objektet. Namngivningsstandarden är att en konstant ska börja med stor bokstav och vara i CamelCase (rad 4-6)
  • ScalaTest använder sig av nyckelordet require i stället för som i Java assert (rad 12)
  • Operatorn == motsvaras av metoden equals i Java, vill man jämföra objektreferenser får man använda operatorn === (rad 12)
  • Det går att repetera en sträng x antal gånger genom att t ex skriva "#" * x (rad 12) snyggt!
  • På rad 14 använder jag mig av en mycket vacker konstruktion som finns i Scala nämligen möjligheten att skicka funktioner som argument. Här anropar jag metoden tabulate med storleken på arrayen följt av variablerna y, x följt av värdet för varje kombination av y, x vilket i detta fallet är resultatet av metoden getBoardValue. På detta sätt har vi här trollat bort en nästad loop som vi annars hade behövt göra och på köpet fått koden mer läsbar!
  • I Scala kan man utelämna nyckelordet return om värdet man vill returnera är det sista som görs i metoden (rad 17)
  • På rad 34 har jag definierat metoden print. Då jag har definierat en lokal version av print utan parametrar, måste jag hårt ange på rad 36, 38, 39 och 41 att det är systemmetoden print som jag vill köra och den ligger i objektet Predef som alltid finns importerad i Scala och gör att man normalt kan skriva print i stället för som här Predef.print
  • Jag har kämpat med att försöka skriva den nästade loopen på rad 35-40 på ett funktionellt sätt, men gav upp. Grejen här var att jag ville göra något en gång i början och slutet av varje rad (rad 33 och 40) och det hade jag problem med att få till. Kan någon se en bättre lösning så kom med förslag! Denna version är ändå i mitt tycke ganska läsbar.
  • En tumregel är att man ska deklarera "magic values" som konstanter vilket jag gjort på rad 4-5. Dock har jag (till vidare) valt att inte definiera strängen "#" som en konstant, detta för att höja läsbarheten av koden vilket jag anser väger över i detta fallet!
Våra två klasser Board och BoardTest ser för närvarande ut så här i vårat repository.

Det var allt för denna gång!