Sonntag, 4. November 2012

Game of Life

Ich habe vor ca. 8 Monaten meine Liebe zu Clojure gefunden. Clojure hat seinen Ursprung ja auf der JVM, aber es gibt auch eine Portierung auf der CLR. Da ich nun mal ein .NET Entwickler bin, liegt mir diese Implementierung nun mal mehr am Herzen.

Ich habe also mehrere Bücher zu Clojure bestellt und durchgearbeitet. Während dieser Experimente habe ich die REPL natürlich gequält und einiges am Code eingegeben. Nun war es aber mal an der Zeit ein richtiges Programm zu schreiben. Es ist zwar halt nur ein kleines, aber an diesem Sample können einige Wirkmechanismen und Ideen von Clojure gezeigt werden.

Für mein erstes Clojure Programm habe ich mir eine einfache Implementierung von "Conways Game of Life" ausgesucht. Für die Spielregeln bitte nach googeln. Das Programm ist einfach gestrickt und hat keine Extras. Diese Oberfläche wurde mit Windows Forms realisiert. In nachfolgendem Bild ist das laufende Programm zu sehen.


Mit Start Game kann das Spiel gestartet/gestoppt werden. In der Textbox wird die laufende Iteration angezeigt. Mit dem New Game Button wird ein neues Spiel gestartet. Per Zufall wird das Board dann gefüllt.

Ich habe das Programm in  Visual Studio mit Hilfe des Clojure Plugins (vsClojure) realisiert. (Kann über den Extension Manager von Visual Studio installiert werden)

In Visual Studio ergibt sich dann folgendes Bild.

 
Das Programm besteht aus den Dateien Board.clj. In Board.clj ist die Spiellogik enthalten. GameOfLife.clj enthält die Windows Forms UI. BoardTest.clj enthält die Unit Test für das Board.
 
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
(ns Board)

;; board handling
;; independent of ui
(defn- make-row 
  "Makes a sequence of length which is randomly filled with :s or :u"
  [length]
  (for [x (range length)]
    (if (< (rand 10) 5)
      :s
      :u)))

(defn make-board 
  "Makes a board as square of length with :s :u randomly set"
  [length]
  (for [x (range length)]
    (make-row length)))

(defn- neighbour-count 
  "Calculates the count of neighbours with :s of x/y in board"
  [board x y]
  (let  [length (count board)
        r1 (nth board (- y 1) (repeat length :u))
        r2 (nth board y)
        r3  (nth board (+ y 1) (repeat length :u))
        r11 [(nth r1 (- x 1) :u) (nth r1 x) (nth r1 (+ x 1) :u)]
        r21 [(nth r2 (- x 1) :u) :u         (nth r2 (+ x 1) :u)]
        r31 [(nth r3 (- x 1) :u) (nth r3 x) (nth r3 (+ x 1) :u)]
        ]
    (count (filter #(= % :s)(concat r11 r21 r31)))))

(defn next-board 
  "Calculates the next board regarding conway law's"
  [board]
  (let [length (count board)]
    (for [y (range length)]
      (let [row (nth board y)]
        (for [x (range length)]
          (let [count (neighbour-count board x y)]
            (if (= (nth row x) :s)
              (if (or (= count 2) (= count 3))
                :s
                :u)
              (if (= count 3)
                :s
                :u))))))))

Also fangen wir von oben an.

make-row erstellt eine Sequence mit der Länge length  die zufallsmäßig mit :s :u gefüllt ist. :s steht hierbei für eine gesetzte Zelle :u für eine ungesetzte. Die Funktion ist private definiert, da sie nur hier gebraucht wird.

make-board ruft length mal make-row auf. Wir haben dann eine Sequence der Länge length, deren Elemente jeweils eine Sequence der Länge length ist. Die innere Sequence ist mit :s oder :u zufallsmäßig besetzt. Wir haben also ein "quadratisches", Zufall besetztes Board mit den wir beginnen können.

neighbour-count berechnet die Anzahl der gesetzten Nachbarzellen an der Koordinate x/y innerhalb des Boards. Die meiste Logik befindet sich innerhalb der let Anweisung. Als erstes bestimmen wir alle benötigten Zeilen des Boards. r2 ist an einfachsten. Wir holen uns mit der nth Funktion einfach die y-te Zeile. r1 kann genauso berechnet werden, aber leider haben wir für y = 0 einen Sonderfall, da wir keine Zeile mit den Index -1 haben. Unser Spielboard ist halt begrenzt. Die nth Funktion hat dafür aber eine passende Überladung. Der 4-te Parameter ist in diesen Fall ein Default Wert, der dann zurückgeliefert wird wenn der Index von nth sozusagen ins Leere greift. Derselbe Fall ist bei r3, dort haben wir den Fall, das y + 1 nicht vorhanden sein kann. Auch dort geben wir für diesen Sonderfall eine Zeile die nur mit :u gesetzt wird zurück.  Nach der Separtion der Zeilen müssen wir nun die Spalten ermitteln. Hier bedienen wir uns derselben Fähigkeiten von nth. Wir holen uns aus der Zeile die Spalten (x -1), x und  (x+1) heraus. Wenn der obere oder untere Rand des Index erreicht ist, dann liefern wir als Default :u zurück. Ein weiterer Sonderfall ist das Element x/y selber. Dies ist ja der Punkt selber. Damit beim späteren Zusammenrechnen kein Fehler auftaucht, wird dieser Punkt konstant auf :u gesetzt. (Zu sehen in der mittleren Zeile von r21). In den Body der let Anweisung selber concatenieren wir die drei kleinen, jeweils aus drei Elementen bestehenden Einzelsequencen zusammen und filtern das Ergebnis nach Elementen mit :s. Das Ergebnis wird dann nur noch zusammen gezählt (count) und als Rückgabe von neighbour-count zurückgeliefert. Die Funktion selber ist eine Hilfsfunktion deren Scope privat ist.

next-board berechnet aus den übergebenen Start Board die nächste Iteration. In der ersten let Anweisung ermitteln wir die Länge des Boards. (Bitte dran denken, das Board ist quadratisch, so dass wir die Länge für die x und auch y Richtung benutzen können). Darauf folgt das erste for das y von 0 bis length - 1 durchiteriert. Die nächste let Anweisung bindet in row die benutzte Zeile. Die nächste for Anweisung lässt x von 0 bis lenght - 1 laufen. Die letzte let Anweisung bindet an count die Anzahl der gesetzten Nachbar Zellen bezüglich der x/y Koordinate. Die nachfolgende if Anweisung bestimmt, ob die aktuelle Zelle gesetzt ist. Wenn ja und die Anzahl der gesetzten Nachbarn ist 2 oder 3, dann bleibt diese Zelle weiterhin gesetzt. Wenn nicht, dann haucht Sie leider Ihr Leben aus und bekommt den Wert :u. War die Zelle an x/y nicht am Leben ist (sprich mit :u belegt) und die Anzahl der lebenden Nachbar ist genau 3, dann entsteht mit :s ein neues Leben. Wenn nicht bleibt es bei :u.

Das ganze kann dann schon mal an der REPLausprobiert werden.



Als nächstes werfen wir einem Blick auf die UI die mit Windows Forms implementiert ist. Die UI befindet sich in der Datei GameOfLife.clj.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
(ns GameOfLife
  (:gen-class))

(use '[Board :only (make-board next-board)])

;; UI Handling for game of life
;; Windows forms technologie
(System.Reflection.Assembly/LoadWithPartialName "System.Windows.Forms")

(import (System.Drawing 
          Size Point Color SolidBrush))
(import (System.Windows.Forms
          Form TableLayoutPanel Label Button TextBox Panel Timer PaintEventHandler PaintEventArgs))

(defn- paint 
  "Paints the board to the panel"
  [pnl board]
  (let [length (count board)
        black (SolidBrush. Color/Black)
        white (SolidBrush. Color/White)
        w (int (/ (.get_Width pnl) length))
        h (int (/ (.get_Height pnl) length))
        g (.CreateGraphics pnl) ]
    (doseq [y (range length)
            x (range length)]
      (let [row (nth board y)
            cell (nth row x)]
      (if (= cell :s)
        (.FillRectangle g black (* x w) (* y h) w h)
        (.FillRectangle g white (* x w) (* y h) w h)
        ))
    )
    (.Dispose black)
    (.Dispose white)
    (.Dispose g)
    ))

(defn game-of-life 
  "Main program for starting the game with a square of length"
  [length]
   (let [form (Form.)
         pnl-paint (Panel.)
         btnNewGame (Button.)
         btnStartStop (Button.)
         txtCount (TextBox.)
         gameTimer (Timer.)
         gameCount (atom 1)
         board (atom (make-board length))]

     (doto btnNewGame
       (.set_Text "New Game")
       (.set_Size (Size. 100 25))
       (.set_Location (Point. 1 10)))

     (doto btnStartStop
       (.set_Tag "IsStoped")
       (.set_Text "Start Game")
       (.set_Size (Size. 100 25))
       (.set_Location (Point. 120 10)))

     (doto txtCount
       (.set_Size (Size. 100 25))
       (.set_Location (Point. 240 10)))

     (doto gameTimer
       (.set_Interval 500))

     (doto pnl-paint
       (.set_Size (Size. 287 219))
       (.set_Location (Point. 1 42))
       (.set_Anchor 15)
       (.set_BackColor Color/White))
         
     (.. form Controls (Add btnNewGame))
     (.. form Controls (Add btnStartStop))
     (.. form Controls (Add txtCount))
     (.. form Controls (Add pnl-paint))

     (.add_Tick gameTimer
       (gen-delegate EventHandler [sender args]
         (do
           (.set_Text txtCount (str @gameCount))
           (paint pnl-paint @board)
           (swap! gameCount inc)
           (reset! board (next-board @board))
           )))
       
    (.add_Click btnNewGame
      (gen-delegate EventHandler [sender args]
          (do
            (reset! board (make-board length))
            (reset! gameCount 1))))

     (.add_Click btnStartStop
       (gen-delegate EventHandler [sender args]
         (if (= (.get_Tag btnStartStop) "IsStoped")
           (do
             (.set_Tag btnStartStop "IsStarted")
             (.set_Text btnStartStop "Stop Game")
             (.Start gameTimer))
           (do
             (.set_Tag btnStartStop "IsStoped")
             (.set_Text btnStartStop "Start Game")
             (.Stop gameTimer)))))
                                   
      (doto form             
        (.set_Text "Game of Life")
        (.set_Size (Size. 300 300))
        .ShowDialog)))

(defn -main 
  "Main Entry Point, starts the game with a board lengh of 50"
  [& args]
  (game-of-life 50))

Die Datei fängt mit einer use Anweisung an. Hier werden die beiden Funktionen make-board und next-board zur Verwendung bekannt gegeben.
Mit der LoadWithPartialName Funktion wird das System.Windows.Forms Assembly geladen. Die nachfolgenden Import Anweisungen geben die Klassen bekannt, die innerhalb dieser Datei verwendet werden. Z.B Size, Point, Color und SolidBrush aus den System.Drawing Namespace.

paint ist eine Hilfsfunktion mit lokalen Scope, die ein board auf dem übergebenen Panel zeichnet. In der let Anweisung werden schwarze und weiße Pinsel erzeugt, Breiten und Höhen berechnet, sowie das Graphics Objekt erzeugt. In den Body des let werden alle y und x Kombinationen bearbeitet. Ist die Zelle an der Position x/y gesetzt, dann wird schwarz gezeichnet, ansonsten weiß. Nach der Schleife sind wir noch so nett, und geben die unmanged Ressourcen schon mal freiwillig zurück.

Die game-of-life funktion ist das Hauptprogramm selber. Der übergebene Parameter length bestimmt die Maße des Spiels. In der startenden let Anweisung werden die Windows Forms Elemente erzeugt. Des Weiteren werden 2 Atome angelegt die den State des Spiels beinhalten. gameCount ist ein Integer mit der Anzahl der Iterationen. Der Startwert hier ist 1. board ist das Spiel selber. Initial wird ein neues board mit make-board erzeugt. In den nachfolgen doto Anweisungen werden die UI Elemente zusammen gebaut. Besonders erwähnenswert hier ist vielleicht das Timer Intervall von 500 ( 1/2 Sekunde) des gameTimer. (Achtung: Clojure arbeitet mit den nativen Funktionsnamen. Die Property Size, die in Visual Studio Projekten (C#/VB) auch so benutzt werden kann, wird unter der Haube mit get_Size und set_Size übersetzt.) .
Nachfolgend werden die Event Handler implementiert. add_Tick des game timers ist der Erste. Der Handler wird aller 500 Millisekunden durchlaufen. Innerhalb des Handlers wird der gameCount in die Textbox geschrieben. Die paint Funktion zeichnet dann das Board neu. Der gameCount wird erhöht und die nächste Version des Board wird berechnet und auf das Atom board zurückgeschrieben. Der Spielkreislauf ist damit komplett.
Der Click Eventhandler des btnNewGame Button erzeugt ein neues Board und resetet das atom board. Der gameCount wird wieder auf 1 gesetzt.
Der Click EventHandler des btnStartStop Buttons startet/stoppt den gameTimer. Der Button Text wird entsprechend geändert. Die Steuerung des Buttons wird über die Tag Property realisiert.
In letzten Teil wird die Form mit Parameter versehen und mit Show Dialog angezeigt.

Die main Funktion startet das das game-of-life mit der Board length 50.

Die dritte Clojure Datei ist BoardTest.clj. Die Datei enthält einige Unit Test die das Board entsprechend ab testen. Exemplarisch ein Test der den "Blinker" ab testet. Der Blinker ist ein Stab aus drei nebeneinander gesetzten Zellen. Entsprechend der Game of Life Regeln wird der Blinker in der nächsten Iteration hoch stehend aus drei übereinander gesetzten Zellen bestehen. Mit der nächsten Iteration wiederholt sich das Ganze.


1
2
3
4
5
6
7
8
9
(deftest blinker-test
  (let [b [[:u :u :u] 
           [:s :s :s]
           [:u :u :u]]]
    (is (= [[:u :s :u] 
            [:u :s :u]
            [:u :s :u]] (next-board b)))
    (is (= b (next-board (next-board b))))  
    ))

Der erste Test stellt sicher, dass der Blinker gedreht ist. Der zweite Test (2 Aufrufe von next-board) muss identisch mit der Ausgangssituation sein.

Nachtrag: Es gibt an diesen Programm sicherlich noch jede Menge Verbesserungs Möglichkeiten. Grade in der Funktion neighbour-count ist jede Menge Redundanz drin. Ich vermute mal die Funktion next-board kann auch kompackter formuliert werden. Bei der UI fehlt die Behandlung des Paint Events. Nach einen Resize der UI ist das panel leer. Da das Board aber alle 1/2 Sekunde neu gezeichnet wird, habe ich darauf verzeichtet.

Die verwendete Clojure Version ist 1.3.0. Der Quellcode kann von Github runtergeladen werden.
http://github.com/thomasschulte/clojureclr-gol-wf