Summa sidvisningar

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.