Vier gewinnt – zum Zweiten


Ich habe mein Vier-Gewinnt-Progrämmchen um eine grafische Oberfläche erweitert (wobei die Text-Variante immer noch spielbar ist). Auch wenn es etwas viel ist, möchte ich den Code hier posten, denn es gibt nicht so viele Scala-Swing-Beispiele im Internet, und außerdem komme ich ja vielleicht auch noch dazu, einen Computergegner zu implementieren, und da bräuchten wir das hier sowieso.

Am Spielfeld hat sich nicht viel verändert:

sealed abstract class Coin(override val toString: String)
case object Naught extends Coin("O")
case object Cross extends Coin("X")

class Board private(data:Map[Int, List[Coin]]) {

  def move(x:Int, coin:Coin) = {
    require(data(x).size < 6, "column " + x + " is full.")
    new Board(data + (x -> (coin :: data(x))))
  }

  def apply(x: Int, y: Int):Option[Coin] = data.get(x) match {
    case Some(list) if 6 - list.size <= y =>  Some(list(y - 6 + list.size))
    case _ => None
  }

  override def toString = {
    val sb = new StringBuilder
    for(y <- 0 to 5; x <- 0 to 6)
      sb.append(apply(x, y).getOrElse(".")).append(if (x == 6) "\n" else "|")
    sb.append("0 1 2 3 4 5 6\n").toString
  }

  lazy val isFull = (0 to 6).forall(data(_).size == 6)

  lazy val winner: Option[Coin] = winner(Cross).orElse(winner(Naught))

  private def winner(c:Coin):Option[Coin] = {
    val rows = for(y <- 0 to 5) yield for(x <- 0 to 6) yield apply(x, y)
    val cols = for(x <- 0 to 6) yield for(y <- 0 to 5) yield apply(x, y)
    val dia1 = for(x <- -2 to 3) yield for(y <- 0 to 5) yield apply(x+y, y)
    val dia2 = for(x <- 3 to 8 ) yield for(y <- 0 to 5) yield apply(x-y, y)

    val slice = List.fill(4)(Some(c))
    if((rows ++ cols ++ dia1 ++ dia2).exists(_.containsSlice(slice))) Some(c)
    else None
  }
}

object Board { def apply() = new Board(Map((0 to 6).map(_ -> Nil):_*)) }

Die Player-Klassen sind zusammengeschrumpft, denn die ganze Kommunikation mit der Außenwelt ist ausgelagert worden:

trait Player {
  def move(board: Board, coin: Coin, io: IO): Option[Board]
}

case class Human(override val toString: String) extends Player {
  private val scanner = new java.util.Scanner(System.in)

  def move(board: Board, coin: Coin, io: IO): Option[Board] = {
    io.askForHumanMove(board).map(board.move(_, coin))
  }
}

Die eigentliche Spielklasse hat sich auch verändert, denn jetzt wird nach Spielernamen gefragt, und man hat die Möglichkeit, ein neues Spiel zu beginnen. Wie schon bei Player ist schon wieder die gesamte Kommunikation in das geheimnisvolle IO ausgelagert worden:

object ConnectFour {

  def apply(io:IO) {
    def loop(players: Option[(Player, Player)]) {
      val (player1, player2) = players.getOrElse(io.askForPlayers)
      round(player1, player2, io)
      if(io.askKeepPlaying) {
        loop(if (io.askKeepPlayers) Some(player2, player1) else None)
      } else {
        io.exit()
      }
    }
    loop(None)
  }

  def round(player1: Player, player2: Player, io:IO) {

    def play(board: Board, player: Player) {
      val coin = if (player == player1) Cross else Naught
      io.showBoard(board)
      if(board.winner != None) io.announceWinner(
        if (board.winner.get == Cross) player1 else player2, board.winner.get)
      else if (board.isFull) io.announceDraw()
      else {
        io.announceMove(player, coin)
        player.move(board, coin, io) match {
          case None => io.announceResign(player, coin)
          case Some(b) => play(b, if(player == player1) player2 else player1)
        }
      }
    }

    play(Board(), player1)
  }

  def main(args: Array[String]): Unit = {
    if (args.size == 1 && args(0) == "text") ConnectFour(ConsoleIO)
    else ConnectFour(SwingIO)
  }
}

Dabei ist IO einfach nur ein Trait, das alle notwendingen Aktionen als abstrakte Methoden enthält. Der Name IO steht natürlich für „Input/Output“ (und erinnert augenzwinkernd an Haskells IO-Monade):

trait IO {
  def showBoard(board: Board): Unit
  def announceWinner(player: Player, coin: Coin): Unit
  def announceDraw(): Unit
  def announceResign(player: Player, coin: Coin): Unit
  def announceMove(player: Player, coin: Coin): Unit
  def askForHumanMove(board: Board): Option[Int]
  def askForPlayers: (Player, Player)
  def askKeepPlaying: Boolean
  def askKeepPlayers: Boolean
  def exit(): Unit
}

Die Aufrufe der alten Textvariante habe ich einfach aus ConnectFour und Player „herausoperiert“ und in ConsoleIO gesteckt:

case object ConsoleIO extends IO {

  def showBoard(board: Board) {
    println(board)
    println
  }
  def announceWinner(player: Player, coin: Coin) {
    println("The winner is " + player + " (" + coin + ")")
  }
  def announceDraw() { 
    println("Board is full, draw.")
  }
  def announceResign(player: Player, coin: Coin) {
    println("Player " + player + " (" + coin + ") gives up.")
  }
  def announceMove(player: Player, coin: Coin) {
    println(player + "'s move (" + coin + ")")
  }

  private val scanner = new java.util.Scanner(System.in)

  def askForHumanMove(board: Board): Option[Int] = {
    var m = -1
    var giveup = false
    do {
      println("Your move ('q' for resign)?")
      try {
        val s = scanner.next
        if(s == "q") giveup = true else m = Integer.parseInt(s)
      } catch {
        case _ =>
      }
    } while(!giveup && (m < 0 || m >= 7 || board(m, 0) != None))
    if(giveup) None else Some(m)
  }

  def askKeepPlaying = {
    var s = ""
    do {
      println("Play again (y/n)?")
      s = scanner.next.toLowerCase
    } while(s != "y" && s != "n")
    s == "y"
  }

  def askKeepPlayers = {
    var s = ""
    do {
      println("Keep players (y/n)?")
      s = scanner.next.toLowerCase
    } while(s != "y" && s != "n")
    s == "y"
  }

  def askForPlayers: (Player, Player) = {
    def ask(number: String): Player = {
      var s = ""
      do {
        println("Is the " + number + " player human or computer (h/c)?")
        s = scanner.next.toLowerCase
      } while(s != "h" && s != "c")
      if(s == "c") {
        println("AI istn't supported yet, you must play HAL yourself.")
        Human("HAL")
      } else {
        println("Please enter the name of the " + number + " player:")
        Human(scanner.next)
      }
    }
    return (ask("first"), ask("second"))
  }

  def exit() { /* do nothing */ }
}

Damit war das Spiel während der Entwicklung der Swing-Version immer spielbar, was durchaus hilfreich war. Durch die Einführung von IO bin ich gar nicht erst in Versuchung gekommen, mein Fenster „schlau“ zu machen, sondern die Spiel-Logik wird sauber getrennt in ConnectFour und den Playern ausgeführt:

case object SwingIO extends IO {

  import scala.swing._
  import scala.swing.event._
  import scala.swing.Swing._

  val frame = new MainFrame {
    title = "Connect Four"
    size = new java.awt.Dimension(500, 400)
    location = new java.awt.Point(50, 50)
    visible = true

    val messageLabel = new Label("Welcome to Connect Four!")
    val connectFourPanel =   new GridPanel(6,7) {
      var board = Board()
      for(y <- 0 to 5; x <- 0 to 6) contents += new CoinComp(x, y)

      class CoinComp(x: Int, y: Int) extends Component {
        preferredSize = new Dimension(50, 50)

        import java.awt.Color._
        border = LineBorder(BLACK)
        override def paintComponent(g: Graphics2D) {
          g.setRenderingHint(java.awt.RenderingHints.KEY_ANTIALIASING,
                             java.awt.RenderingHints.VALUE_ANTIALIAS_ON)
          g.setColor(GRAY)
          g.fillRect(0, 0, size.width - 1, size.height - 1)
          g.setColor{board(x, y) match {
              case None => WHITE.darker
              case Some(Naught) => ORANGE
              case Some(Cross) => RED.darker
            }}
          val delta = size.width / 7
          g.fillOval(delta, delta, size.width - 2*delta, size.height - 2*delta)
          g.setColor(BLACK)
          g.drawOval(delta, delta, size.width - 2*delta, size.height - 2*delta)
        }
      }
    }
    val moveButtons = (1 to 7).map(n => new Button{ text = n.toString }).toList
    moveButtons.foreach(listenTo(_))
    val resignButton = new Button("Resign")
    listenTo(resignButton)

    val mainPanel = new BorderPanel {
      import BorderPanel.Position._
      layout += new BoxPanel(scala.swing.Orientation.Horizontal){
        contents += HStrut(5)
        contents += messageLabel
        contents += HGlue
        contents += resignButton
      } -> North
      layout += connectFourPanel -> Center
      layout += new GridPanel(1,7) { moveButtons.foreach(contents += _) } ->  South
    }
    contents = mainPanel

    val clickedButton = new scala.concurrent.SyncVar[AbstractButton]

    reactions += {
      case ButtonClicked(b) => clickedButton.set(b)
    }

    def updateBoard(board :Board) {
      connectFourPanel.board = board
      connectFourPanel.repaint
    }
  }

  private def coinToColor(coin: Coin) = if(coin == Cross) "red" else "yellow"

  def showBoard(board: Board) { frame.updateBoard(board) }

  def announceWinner(player: Player, coin: Coin) {
    frame.messageLabel.text = "The winner is " + player
    Dialog.showMessage(frame.mainPanel , "The winner is " + player + " (" + 
                       coinToColor(coin) + ")!", "Congratulations", Dialog.Message.Info)
  }
  def announceDraw() {
    frame.messageLabel.text = "Board is full, draw."
  }
  def announceResign(player: Player, coin: Coin) {
    frame.messageLabel.text = "Player " + player + " gives up."
    Dialog.showMessage(frame.mainPanel , "Player " + player + " (" + 
                       coinToColor(coin) + ") gives up.", "Resign", Dialog.Message.Info)
  }
  def announceMove(player: Player, coin: Coin) {
    frame.messageLabel.text = player + "'s move (" + coinToColor(coin) + ")"
  }

  def askForHumanMove(board: Board): Option[Int] = {
    var r = Int.MinValue
    do {
      val button = frame.clickedButton.take
      if(button == frame.resignButton) r = Int.MaxValue else {
        val i = frame.moveButtons.indexOf(button)
        if (board(i, 0) == None) r = i
      }
    } while (r == Int.MinValue) 
    if(r == Int.MaxValue) None else Some(r)
  }

  def askKeepPlaying = Dialog.Result.Yes == Dialog.showConfirmation(
    frame.mainPanel, "Play again?", "Question", Dialog.Options.YesNo, Dialog.Message.Question)

  def askKeepPlayers = Dialog.Result.Yes == Dialog.showConfirmation(
    frame.mainPanel, "Keep players?", "Question", Dialog.Options.YesNo, Dialog.Message.Question)

  def askForPlayers: (Player, Player) = {
    val panel = new BoxPanel(Orientation.Vertical) {
      val name1 = new TextField("Adam")
      val human1 = new CheckBox("human")
      human1.selected = true
      val name2 = new TextField("Eve")
      val human2 = new CheckBox("human")
      human2.selected = true
      contents += new Label("Player 1:")
      contents += name1
      contents += human1
      contents += VStrut(10)
      contents += new Label("Player 2:")
      contents += name2
      contents += human2
    }
    Dialog.showMessage(frame.mainPanel , panel.peer, "Select Players", Dialog.Message.Plain)
    val player1 = if(panel.human1.selected) Human(if(panel.name1.text == "") "Adam" else panel.name1.text)
    else Human((if(panel.name1.text == "") "HAL" else panel.name1.text) + " (AI not implemented)")
    val player2 = if(panel.human2.selected) Human(if(panel.name2.text == "") "Eve" else panel.name2.text)
    else Human((if(panel.name2.text == "") "R2D2" else panel.name2.text) + " (AI not implemented)")
    return (player1, player2)
  }

  def exit() {
     frame.close()
     System.exit(0)
  }
}

Ein interessantes Detail ist die Verwendung von scala.concurrent.SyncVar[T]. Wenn ein Spieler am Zug ist (und das ist faktisch immer, denn sonst ist irgendein modaler Dialog offen), muss meine askForHumanMove-Methode auf einen Knopfdruck warten. SyncVar.take blockiert die Methode solange, bis irgend jemand (in unserem Fall der Listener für die ganzen Knöpfe) etwas in die Variable hineintut – sozusagen eine BlockingQueue der Länge eins.

Das Ganze sieht dann so aus:
Screenshot "Vier Gewinnt"

Na dann viel Spaß beim Spielen und Experimentieren!

Advertisements

2 Gedanken zu “Vier gewinnt – zum Zweiten

  1. Interessanter Artikel, jedoch habe ich mal eine Frage. Wie kann ich diesen Beitrag zu meinem RSS Reader hinzufügen? Ich finde das Icon nicht. Danke

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