Völlig entkoppelt – das Modell


Ich hatte mir schon länger vorgenommen, endlich wieder etwas über Scala zu schreiben. In dieser kleinen Serie möchte ich anhand eines kleinen Beispiels zeigen, wie weit man Entkopplung in Scala nur mit „Bordmitteln“ treiben kann, und welche Techniken dabei helfen. Als Beispiel habe ich mir das altbekannte Spielchen „Sokoban“ herausgepickt, und mich bei der Umsetzung grob an diesem Ruby Quiz orientiert, wo es auch eine Datei mit Leveln gibt.

Und schon stellt sich die erste Frage, nämlich womit man anfangen sollte. Ich denke, man sollte mit dem Modell beginnen, schon deshalb, weil dieses keine Abhängigkeiten zu den anderen Komponenten haben sollte. Fangen wir also damit an!

Zuerst einmal benötigen wir Bewegungsrichtungen und Positionen:

sealed abstract class Move(val dx: Int, val dy: Int)
case object North extends Move(0, -1)
case object South extends Move(0, 1)
case object West extends Move(-1, 0)
case object East extends Move(1, 0)

case class Pos(x: Int, y: Int) {
  def goto(m: Move) = Pos(x + m.dx, y + m.dy)
  def max(that:Pos) = Pos(math.max(this.x, that.x), math.max(this.y,that.y))
}

Sehr schön: Unsere Klassen sind nicht völlig dumm, sie können schon ein wenig rechnen. Die Positionsklasse kann eine Richtung dazuaddieren, und auch das Maximum bezüglich einer weiteren Position bestimmen (was praktisch ist, um die Größe des Levels sozusagen „ganz nebenbei“ zu ermitteln).

Nun zum eigentlichen Level. Auch hier bietet sich eine Fall-Klasse an, allerdings tue ich etwas ungewöhnliches: Ich mache alle Argumente private. Der Grund dafür ist, dass die gewählten Argumenttypen wirklich nur ein Implementationsdetail sind. Ich hätte z.B. statt der Sets auch Listen nehmen können, oder das ganze Spielfeld als Array darstellen. Code von außen sollte daran nicht herumwursteln. Die Level-Klasse weiß am besten, wie sie Inkonsistenzen vermeidet oder wann der Zähler für die Züge hochgesetzt werden muss. Damit man trotzdem problemlos ein „leeres“ Level erstellen kann, habe ich allen Argumenten Default-Werte mitgegeben.

case class Level(private val walls: Set[Pos] = Set.empty,
                 private val storages: Set[Pos] = Set.empty,
                 private val crates: Set[Pos] = Set.empty,
                 private val worker: Pos = Pos(-1, -1),
                 private val size: Pos = Pos(-1, -1),
                 private val moves: Int = 0) {

  def addWall(pos:Pos) = copy(walls = walls + pos, size = size max pos)
  def addStorage(pos:Pos) = copy(storages = storages + pos, size = size max pos)
  def addCrate(pos:Pos) = copy(crates = crates + pos, size = size max pos)
  def setWorker(pos:Pos) = copy(worker = pos)
  private def removeCrate(pos:Pos) = copy(crates = crates - pos)
  private def increaseMoves = copy(moves = moves + 1)

  private def isWall(pos: Pos) = walls.contains(pos)
  private def isStorage(pos: Pos) = storages.contains(pos)
  private def isCrate(pos: Pos) = crates.contains(pos)
  private def isWorker(pos: Pos) = pos == worker
  private def isFree(pos: Pos) = ! (isCrate(pos) || isWall(pos))

  def isSolved = storages == crates
  def getMoves = moves

  def move(m: Move) = {
    val newWorkerPos = worker.goto(m)
    val newCratePos = newWorkerPos.goto(m)
    if (isFree(newWorkerPos))
      Some(setWorker(newWorkerPos).increaseMoves)
    else if (isCrate(newWorkerPos) && isFree(newCratePos))
      Some(setWorker(newWorkerPos).removeCrate(newWorkerPos).addCrate(newCratePos).increaseMoves)
    else None
  }

  override def toString = {
    def toChar(pos: Pos) = (isStorage(pos), isCrate(pos), isWorker(pos), isWall(pos)) match {
      case (true,  true,  false, false) => '*' //crate on a storage position
      case (true,  false, true,  false) => '+' //worker on a storage position
      case (true,  false, false, false) => '.' //storage position
      case (false, true,  false, false) => 'o' //crate
      case (false, false, true,  false) => '@' //worker
      case (false, false, false, true)  => '#' //wall
      case (false, false, false, false) => ' ' //empty
      case _ => sys.error("Illegal level state for " + pos)
    }

    (for (y <- 0 to size.y) yield
      (for (x <- 0 to size.x; ch = toChar(Pos(x, y))) yield ch)
        .mkString + "\n")
      .mkString
  }
}

Wie für Fall-Klassen üblich ist Level unveränderlich, und um neue Versionen zu erstellen wird ausgiebig die copy-Methode benutzt. Ansonsten gibt es viele kleine Helfermethoden, die sowohl das Einlesen eines Levels unterstützen wie auch die move- und toChar-Methode vereinfachen. Leider ist die List-Comprehension am Ende von toString nicht sehr elegant, weil ich keinen cleveren Weg gefunden habe, die notwendingen Zeilenumbrüche einzuschmuggeln. Vielleicht hat ja jemand eine Idee dazu?

Die bei toChar verwendete Pattern-Matching-Technik kann helfen, längere if-else-Kaskaden zu vermeiden. Als Anfänger beschränkt man sich meist darauf, auf die Methoden-Argumente selbst zu matchen, aber in vielen Fällen kommt man durch Umstellungen, Kombinationen oder Vorberechnungen zu besseren Lösungen.

Die Kapselung der Level-Klasse ist nicht perfekt. Würde man z.B. das Companion-Objekt zum Einlesen verwenden, könnte man die ganzen add-Methoden „privatisieren“. Aber ich wollte auch hier flexibel bleiben: Das Model sollte sich möglichst nicht ändern, wenn man z.B. ein anderes Dateiformat verwendet. Hier der Lade-Mechanismus:

import io.Source

trait Loader {
    def getLevels(name: String) : List[Level]
}

trait TextFileLoader extends Loader {

  override def getLevels(name: String) = Source.fromFile("./" + name).
    getLines().foldRight(List(List[String]()))((e: String, s: List[List[String]]) =>
      if (e.trim.isEmpty) List[String]() :: s else (e :: s.head) :: s.tail).
    map(makeLevel(_))

  private def makeLevel(list: List[String]) = list.zipWithIndex.foldLeft(Level()) {
    (level, yPair) => yPair._1.zipWithIndex.foldLeft(level) {
        (level, xPair) =>
          val pos = Pos(xPair._2, yPair._2)
          xPair._1 match {
            case '.' => level.addStorage(pos)
            case '#' => level.addWall(pos)
            case 'o' => level.addCrate(pos)
            case '@' => level.setWorker(pos)
            case '+' => level.setWorker(pos).addStorage(pos)
            case '*' => level.addCrate(pos).addStorage(pos)
            case ' ' => level
            case ch => sys.error("Unknown character " + ch)
          }
      }
  }
}

Die getLevel-Methode sieht gefährlicher aus, als sie ist: Da die Levels nur durch Leerzeilen getrennt in der Datei gespeichert sind, muss man etwas Aufwand treiben, um sie auseinanderzudividieren. Die makeLevel-Methode ist auch nicht besonders schön. Das Problem dabei ist, dass wir nicht nur die Zeichen brauchen, sondern auch ihre Position. Vielleicht gibt es hier bessere Techniken als das doppelte zipWithIndex, und mit dem Falten habe ich es wahrscheinlich auch ein wenig übertrieben. Allerdings ist das Hinzufügen neuer Elemente zum Level recht transparent – besser jedenfalls, als wenn wir hier selbst mit den Sets herumhantieren würden.

So weit haben wir also ein Modell, das absolut nichts vom Rest der Welt weiß, und einen austauschbaren Lade-Mechanismus.

Nächstes Mal bringen wir das Spiel zum Laufen, allerdings erst einmal ganz bescheiden auf der Konsole.

Advertisements

Ein Gedanke zu “Völlig entkoppelt – das Modell

  1. Sieht sehr interessant aus, hat aber noch ein paar Schönheitsfehler wie ich finde. Level.move 2. if-Abfrage wäre mir zu ineffizient. Das würde ich in eine Methode auslagern und alle Operationen mit einem copy auf einmal erledigen. Level.toString wäre mir zu hässlich mit dem ganzen pattern matching – schon daran gedacht einen Zellen-Typen einzuführen, der mit der Position verknüpft werden kann (z.B in einer Map[Pos, Cell])? Mutable state schadet nicht wenn er ausreichend gekapselt ist – ich würde die geschachtelte for-comprehension in Level.toString auf ein 2D-Array operieren lassen und dieses dann als arr.map(_.mkString).mkString(„\n“) zu einem String umformen. Eine Map[Pos, Cell] als Spielfeld würde schreiben und lesen ebenfalls vereinfachen, da diese dann nur noch Zeichenweise gefüllt werden muss.

    Ich habe mal Othello in Scala implementiert, das viele Ähnlichkeiten hat. Vielleicht magst du es dir mal anschauen: https://gist.github.com/2472666

    Zuletzt: Ein abschließendes gist (oder Vergleichbares) mit komplettem Code-snippet wäre gut, dann muss man nicht alles einzeln kopieren wenn man den Code testen möchte

Kommentar verfassen

Trage deine Daten unten ein oder klicke ein Icon um dich einzuloggen:

WordPress.com-Logo

Du kommentierst mit Deinem WordPress.com-Konto. Abmelden / Ändern )

Twitter-Bild

Du kommentierst mit Deinem Twitter-Konto. Abmelden / Ändern )

Facebook-Foto

Du kommentierst mit Deinem Facebook-Konto. Abmelden / Ändern )

Google+ Foto

Du kommentierst mit Deinem Google+-Konto. Abmelden / Ändern )

Verbinde mit %s